1783 lines
74 KiB
JavaScript
1783 lines
74 KiB
JavaScript
window.__demoRuntimeErrors = [];
|
||
document.documentElement.dataset.runtimeErrors = "0";
|
||
function recordRuntimeError(message) {
|
||
window.__demoRuntimeErrors.push(message);
|
||
document.documentElement.dataset.runtimeErrors = String(window.__demoRuntimeErrors.length);
|
||
if (window.__auditReady) logClientOperation("frontend.runtime_error", { message: String(message || "").slice(0, 500) });
|
||
}
|
||
window.addEventListener("error", (event) => recordRuntimeError(event.message || "Unknown runtime error"));
|
||
window.addEventListener("unhandledrejection", (event) => recordRuntimeError(String(event.reason || "Unhandled rejection")));
|
||
|
||
const PENDING_REVIEW_KEY = "yingxiangli.pendingAssetReview";
|
||
const PENDING_VIDEO_KEY = "yingxiangli.pendingVideoTask.v1";
|
||
const LEGACY_GROUP_KEY = "yingxiangli.selectedAssetGroup";
|
||
const SCENE_ROLES_KEY = "yingxiangli.sceneRoles.v1";
|
||
const CHARACTER_LIBRARY_KEY = "yingxiangli.characterLibrary.v1";
|
||
const DRAMA_PROJECT_KEY = "yingxiangli.dramaProject.v1";
|
||
const VIDEO_KEY_STORAGE = "yingxiangli.videoKey.v1";
|
||
const AUDIT_CLIENT_KEY = "yingxiangli.auditClientId.v1";
|
||
const AUDIT_SESSION_KEY = "yingxiangli.auditSessionId.v1";
|
||
const VIEW_LABELS = ["正面", "侧面", "背面"];
|
||
const ROLE_LETTERS = ["A", "B", "C"];
|
||
const API_BASE = location.protocol === "file:" ? "http://localhost:4173" : "";
|
||
const VIDEO_POLL_INTERVAL_MS = 2500;
|
||
const VIDEO_POLL_MAX_MS = 30 * 60 * 1000;
|
||
const DEFAULT_SHOT_PROMPT = "竖屏双人对话短剧镜头:角色 A 站在办公楼电梯口,先低头查看手机消息;角色 B 从走廊走近并停在她身侧。角色 B 说:“你真的决定了吗?”角色 A 抬头回答:“这一次,我自己做决定。”保持两名角色外观与各自参考图一致,避免交换五官、服装或身份。镜头从双人中景缓慢推进至角色 A 近景,口型与中文台词同步,环境声自然。";
|
||
|
||
const state = {
|
||
config: null,
|
||
roles: [],
|
||
activeRoleIndex: 0,
|
||
pollTimer: null,
|
||
reviewStartedAt: 0,
|
||
assets: [],
|
||
characters: [],
|
||
characterDraftReferences: new Set(),
|
||
activeModule: document.body.dataset.activeModule || "library",
|
||
projectName: "未命名短剧",
|
||
shots: [],
|
||
activeShotId: null,
|
||
activeGenerationShotId: null,
|
||
activeGenerationVersionId: null,
|
||
};
|
||
|
||
const el = Object.fromEntries([
|
||
"connectionState", "assetForm", "assetFrontUrl", "assetSideUrl", "assetBackUrl", "assetName",
|
||
"assetStatus", "assetResult", "assetReferences", "assetList", "refreshAssets", "videoForm",
|
||
"videoStatus", "prompt", "referenceMap", "model", "ratio", "duration", "resolution",
|
||
"generateAudio", "watermark", "generationResult", "generationTitle", "generationTaskId",
|
||
"generationProgress", "progressBar", "generationElapsed", "generationLastChecked", "videoStage", "toast", "reviewButton",
|
||
"generateButton", "reviewElapsed", "reviewFront", "reviewSide", "reviewBack", "mockHint",
|
||
"sceneRoles", "addRoleButton", "activeRoleLabel",
|
||
"characterList", "createCharacterButton", "characterEditor", "characterName", "assetPickList",
|
||
"cancelCharacterButton", "saveCharacterButton", "assetHistoryCount", "extraImageInputs",
|
||
"referenceVideoInputs", "addExtraImageButton", "addReferenceVideoButton", "inputQuota",
|
||
"addShotButton", "shotList", "timelineSummary", "shotProgressBar", "continuityMode", "versionCount",
|
||
"versionList", "assemblyPanel", "assemblyList", "assemblyHint", "closeAssemblyButton",
|
||
"videoKeyGate", "videoKeyForm", "videoKeyInput", "videoKeyError", "changeVideoKeyButton", "cancelVideoKeyButton",
|
||
].map((id) => [id, document.getElementById(id)]));
|
||
|
||
const reviewRows = [el.reviewFront, el.reviewSide, el.reviewBack];
|
||
const auditClientId = getOrCreateAuditId(localStorage, AUDIT_CLIENT_KEY, "client");
|
||
const auditSessionId = getOrCreateAuditId(sessionStorage, AUDIT_SESSION_KEY, "session");
|
||
const auditQueue = [];
|
||
const auditInputTimers = new Map();
|
||
let auditFlushTimer = null;
|
||
|
||
setupClientAudit();
|
||
setupVideoKeyGate();
|
||
init();
|
||
|
||
document.querySelectorAll(".module-tab").forEach((button) => {
|
||
button.addEventListener("click", () => switchModule(button.dataset.module));
|
||
});
|
||
|
||
async function init() {
|
||
try {
|
||
state.config = await api("/api/config");
|
||
restoreSceneRoles();
|
||
restoreCharacterLibrary();
|
||
restoreDramaProject();
|
||
renderConnection();
|
||
if (!state.config.mockMode && !currentVideoKey()) {
|
||
showVideoKeyGate(false);
|
||
return;
|
||
}
|
||
await loadAssets();
|
||
loadActiveShotIntoForm();
|
||
renderScene();
|
||
renderStoryboard();
|
||
resumePendingReview();
|
||
if (state.config.mockMode || currentVideoKey()) resumePendingVideo();
|
||
} catch (error) {
|
||
showToast(error.message, true);
|
||
el.connectionState.textContent = "本地服务连接失败";
|
||
el.connectionState.className = "connection blocked";
|
||
}
|
||
}
|
||
|
||
function setupVideoKeyGate() {
|
||
el.videoKeyForm.addEventListener("submit", (event) => {
|
||
event.preventDefault();
|
||
const key = el.videoKeyInput.value.trim();
|
||
if (!/^sk-[A-Za-z0-9_-]{8,}$/.test(key)) {
|
||
el.videoKeyError.textContent = "请输入正确格式的客户令牌(sk-...)";
|
||
el.videoKeyError.classList.remove("hidden");
|
||
return;
|
||
}
|
||
localStorage.setItem(VIDEO_KEY_STORAGE, key);
|
||
logClientOperation("video_key.saved", { keySuffix: key.slice(-4), keyLength: key.length });
|
||
location.reload();
|
||
});
|
||
el.changeVideoKeyButton.addEventListener("click", () => showVideoKeyGate(true));
|
||
el.cancelVideoKeyButton.addEventListener("click", hideVideoKeyGate);
|
||
}
|
||
|
||
function currentVideoKey() {
|
||
try { return localStorage.getItem(VIDEO_KEY_STORAGE)?.trim() || ""; }
|
||
catch { return ""; }
|
||
}
|
||
|
||
function showVideoKeyGate(canCancel = Boolean(currentVideoKey())) {
|
||
el.videoKeyInput.value = "";
|
||
el.videoKeyError.textContent = "";
|
||
el.videoKeyError.classList.add("hidden");
|
||
el.cancelVideoKeyButton.classList.toggle("hidden", !canCancel);
|
||
el.videoKeyGate.classList.remove("hidden");
|
||
requestAnimationFrame(() => el.videoKeyInput.focus());
|
||
}
|
||
|
||
function hideVideoKeyGate() {
|
||
if (!currentVideoKey() && !state.config?.mockMode) return;
|
||
el.videoKeyGate.classList.add("hidden");
|
||
}
|
||
|
||
el.addRoleButton.addEventListener("click", () => {
|
||
if (state.roles.length >= 3) return showToast("当前 Demo 最多支持三个场景角色,人物图片总数不超过 9 张", true);
|
||
const role = createRole(`角色 ${ROLE_LETTERS[state.roles.length]}`);
|
||
state.roles.push(role);
|
||
if (activeShot()) activeShot().roleIds.push(role.id);
|
||
state.activeRoleIndex = state.roles.length - 1;
|
||
renderScene();
|
||
renderStoryboard();
|
||
saveDramaProject();
|
||
});
|
||
|
||
el.assetName.addEventListener("input", () => {
|
||
activeRole().name = el.assetName.value.trim() || `角色 ${ROLE_LETTERS[state.activeRoleIndex]}`;
|
||
renderRoleCards();
|
||
renderSceneSummary();
|
||
saveSceneRoles();
|
||
});
|
||
|
||
el.assetForm.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
const role = activeRole();
|
||
role.name = el.assetName.value.trim() || `角色 ${ROLE_LETTERS[state.activeRoleIndex]}`;
|
||
setAssetStatus("processing", "审核中");
|
||
state.reviewStartedAt = Date.now();
|
||
setAllReviewRows("processing", "审核中", "正在提交");
|
||
updateReviewElapsed();
|
||
el.reviewButton.disabled = true;
|
||
|
||
try {
|
||
let result = await api("/api/assets/review", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
name: role.name,
|
||
images: [
|
||
{ view: "front", url: el.assetFrontUrl.value.trim() },
|
||
{ view: "side", url: el.assetSideUrl.value.trim() },
|
||
{ view: "back", url: el.assetBackUrl.value.trim() },
|
||
],
|
||
}),
|
||
});
|
||
if (Number(result.code) === 202 || normalizeAssetTaskStatus(result) === "processing") {
|
||
const taskId = result.task_id || result.data?.task_id || result.id;
|
||
if (!taskId) throw new Error("审核接口已受理,但没有返回任务 ID");
|
||
savePendingReview(taskId, role.id);
|
||
result = await pollAssetTask(taskId);
|
||
}
|
||
await finishAssetReview(result, role.id, role.name);
|
||
} catch (error) {
|
||
if (error.pending) {
|
||
setAssetStatus("processing", "仍在审核");
|
||
el.reviewElapsed.textContent = "仍在审核,可稍后回来查看";
|
||
} else {
|
||
setAssetStatus("error", "审核失败");
|
||
markUnresolvedRowsFailed(error.message);
|
||
clearPendingReview();
|
||
}
|
||
showToast(error.message, true);
|
||
} finally {
|
||
el.reviewButton.disabled = false;
|
||
}
|
||
});
|
||
|
||
el.videoForm.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
updateActiveShotFromForm();
|
||
const quota = mediaQuota();
|
||
if (!activeShotRoles().every((role) => role.references.length > 0)) return showToast("请先为本片段每个出场角色选择人物素材", true);
|
||
if (!quota.valid) return showToast(quota.message, true);
|
||
if (activeShot().continuityMode === "previous_video" && previousShot() && !continuityReference()) {
|
||
return showToast("上一片段还没有可用的选定版本,请先完成上一段或调整衔接方式", true);
|
||
}
|
||
|
||
clearTimeout(state.pollTimer);
|
||
el.generateButton.disabled = true;
|
||
setVideoStatus("processing", "生成中");
|
||
setStep(4);
|
||
el.generationResult.classList.remove("hidden");
|
||
el.generationTaskId.textContent = "正在获取任务 ID…";
|
||
el.generationElapsed.textContent = "已等待 0 秒";
|
||
el.generationLastChecked.textContent = "尚未收到首次查询结果";
|
||
updateProgress(5, "任务正在提交");
|
||
resetVideoStage();
|
||
const shot = activeShot();
|
||
const version = {
|
||
id: `version-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||
status: "running",
|
||
taskId: "",
|
||
videoUrl: "",
|
||
lastFrameUrl: "",
|
||
createdAt: Date.now(),
|
||
lastCheckedAt: null,
|
||
progress: 5,
|
||
};
|
||
shot.versions.push(version);
|
||
state.activeGenerationShotId = shot.id;
|
||
state.activeGenerationVersionId = version.id;
|
||
saveDramaProject();
|
||
renderStoryboard();
|
||
|
||
try {
|
||
const result = await api("/api/videos", {
|
||
method: "POST",
|
||
body: JSON.stringify(buildVideoClientBody()),
|
||
});
|
||
const taskId = result.task_id || result.id || result.data?.task_id || result.data?.id;
|
||
if (!taskId) throw new Error("视频接口未返回任务 ID");
|
||
el.generationTaskId.textContent = taskId;
|
||
version.taskId = taskId;
|
||
saveDramaProject();
|
||
renderVersionList();
|
||
savePendingVideo(taskId, shot.id, version.id);
|
||
await pollVideoTask(taskId, shot.id, version.id);
|
||
} catch (error) {
|
||
const message = contextualizeGenerationError(error.message);
|
||
if (error.pending) {
|
||
if (activeShot()?.id === shot.id) {
|
||
setVideoStatus("processing", "仍在生成");
|
||
updateProgress(version.progress || 20, "仍在生成,正在自动查询结果");
|
||
}
|
||
showToast(message);
|
||
} else {
|
||
clearPendingVideo();
|
||
failShotVersion(shot.id, version.id, message);
|
||
if (activeShot()?.id === shot.id) {
|
||
setVideoStatus("error", "生成失败");
|
||
renderGenerationError(message);
|
||
}
|
||
showToast(message, true);
|
||
}
|
||
} finally {
|
||
finishGenerationUi(shot, version);
|
||
}
|
||
});
|
||
|
||
el.refreshAssets.addEventListener("click", loadAssets);
|
||
el.createCharacterButton.addEventListener("click", openCharacterEditor);
|
||
el.cancelCharacterButton.addEventListener("click", closeCharacterEditor);
|
||
el.saveCharacterButton.addEventListener("click", saveCharacterFromDraft);
|
||
[el.prompt, el.extraImageInputs, el.referenceVideoInputs].forEach((input) => input.addEventListener("input", () => {
|
||
updateActiveShotFromForm();
|
||
updateVideoAvailability();
|
||
}));
|
||
el.addExtraImageButton.addEventListener("click", () => addMediaInput("image"));
|
||
el.addReferenceVideoButton.addEventListener("click", () => addMediaInput("video"));
|
||
[el.extraImageInputs, el.referenceVideoInputs].forEach((list) => list.addEventListener("click", (event) => {
|
||
const removeButton = event.target.closest("[data-remove-media]");
|
||
if (!removeButton) return;
|
||
removeMediaInput(removeButton);
|
||
}));
|
||
el.continuityMode.addEventListener("change", () => {
|
||
updateActiveShotFromForm();
|
||
updateVideoAvailability();
|
||
});
|
||
el.addShotButton.addEventListener("click", addShot);
|
||
el.closeAssemblyButton.addEventListener("click", () => el.assemblyPanel.classList.add("hidden"));
|
||
el.model.addEventListener("change", () => {
|
||
if (el.model.value.includes("4k")) el.resolution.value = "4k";
|
||
else if (el.model.value.includes("1080")) el.resolution.value = "1080p";
|
||
else el.resolution.value = "720p";
|
||
updateActiveShotFromForm();
|
||
});
|
||
[el.ratio, el.duration, el.resolution, el.generateAudio, el.watermark].forEach((input) => {
|
||
input.addEventListener("change", updateActiveShotFromForm);
|
||
});
|
||
|
||
async function loadAssets() {
|
||
try {
|
||
const items = [];
|
||
const pageSize = 100;
|
||
for (let page = 1; page <= 50; page += 1) {
|
||
const result = await api(`/api/assets?page=${page}&page_size=${pageSize}&sort_order=Desc`);
|
||
const pageItems = result.data?.items || result.data?.Items || [];
|
||
items.push(...pageItems);
|
||
const total = Number(result.data?.total ?? result.data?.TotalCount ?? items.length);
|
||
if (!pageItems.length || items.length >= total || pageItems.length < pageSize) break;
|
||
}
|
||
state.assets = items;
|
||
ingestHistoricalCharacters(items);
|
||
renderCharacterLibrary();
|
||
renderAssetPicker();
|
||
el.assetHistoryCount.textContent = String(items.length);
|
||
el.assetList.innerHTML = "";
|
||
if (!items.length) {
|
||
el.assetList.innerHTML = '<div class="empty-small">素材库暂无历史素材</div>';
|
||
return;
|
||
}
|
||
for (const item of items) {
|
||
const reference = normalizeAssetReference(item);
|
||
const position = referencePosition(reference);
|
||
const button = document.createElement("button");
|
||
button.className = `asset-item asset-card${position ? " selected" : ""}`;
|
||
button.type = "button";
|
||
if (position) button.dataset.order = `${ROLE_LETTERS[position.roleIndex]}${position.viewIndex + 1}`;
|
||
const imageUrl = item.original_url || item.URL || "";
|
||
if (imageUrl) {
|
||
const image = document.createElement("img");
|
||
image.src = imageUrl;
|
||
image.alt = item.purpose || item.Name || "素材预览";
|
||
image.loading = "lazy";
|
||
image.addEventListener("error", () => image.classList.add("image-error"));
|
||
button.appendChild(image);
|
||
}
|
||
const name = item.purpose || item.Name || fileNameFromUrl(imageUrl) || "未命名素材";
|
||
const info = document.createElement("div");
|
||
info.className = "asset-info";
|
||
info.innerHTML = `<strong>${escapeHtml(name)}</strong><span>${escapeHtml(reference)}</span>`;
|
||
button.appendChild(info);
|
||
button.addEventListener("click", () => toggleHistoricalAsset(reference));
|
||
el.assetList.appendChild(button);
|
||
}
|
||
} catch (error) {
|
||
const message = /unauthorized|invalid token/i.test(String(error.message || ""))
|
||
? "客户令牌无效或已过期,请重新输入"
|
||
: error.message;
|
||
el.assetList.innerHTML = `<div class="empty-small">${escapeHtml(message)}</div>`;
|
||
if (!state.config?.mockMode) {
|
||
el.connectionState.textContent = "素材库连接失败";
|
||
el.connectionState.className = "connection blocked";
|
||
setAssetStatus("error", "素材库不可用");
|
||
}
|
||
}
|
||
}
|
||
|
||
function ingestHistoricalCharacters(items) {
|
||
const groups = new Map();
|
||
for (const item of items) {
|
||
const purpose = String(item.purpose || item.Name || "").trim();
|
||
const match = purpose.match(/^(.+?)\s*[·・]\s*(正面|侧面|背面)$/);
|
||
if (!match) continue;
|
||
const reference = normalizeAssetReference(item);
|
||
if (!reference) continue;
|
||
if (!groups.has(match[1])) groups.set(match[1], []);
|
||
groups.get(match[1]).push({ reference, view: match[2] });
|
||
}
|
||
for (const [name, entries] of groups) {
|
||
const order = { 正面: 0, 侧面: 1, 背面: 2 };
|
||
const references = entries.sort((a, b) => order[a.view] - order[b.view]).map((entry) => entry.reference);
|
||
upsertCharacter(name, references, "history", false);
|
||
}
|
||
saveCharacterLibrary();
|
||
}
|
||
|
||
function renderCharacterLibrary() {
|
||
el.characterList.innerHTML = "";
|
||
if (!state.characters.length) {
|
||
el.characterList.innerHTML = '<div class="empty-small">暂无已组合人物,可从历史素材新建</div>';
|
||
return;
|
||
}
|
||
for (const character of state.characters) {
|
||
const card = document.createElement("div");
|
||
const used = state.roles.some((role) => sameReferences(role.references, character.references));
|
||
card.className = `character-card${used ? " selected" : ""}`;
|
||
const head = document.createElement("div");
|
||
head.className = "character-head";
|
||
head.innerHTML = `<strong>${escapeHtml(character.name)}</strong><span>${character.references.length} 张人物素材</span>`;
|
||
const thumbs = document.createElement("div");
|
||
thumbs.className = "character-thumbs";
|
||
for (const reference of character.references.slice(0, 5)) {
|
||
const asset = state.assets.find((item) => normalizeAssetReference(item) === reference);
|
||
const imageUrl = asset?.original_url || asset?.URL || "";
|
||
if (imageUrl) {
|
||
const image = document.createElement("img");
|
||
image.src = imageUrl;
|
||
image.alt = character.name;
|
||
thumbs.appendChild(image);
|
||
} else {
|
||
const placeholder = document.createElement("span");
|
||
placeholder.className = "character-thumb-placeholder";
|
||
thumbs.appendChild(placeholder);
|
||
}
|
||
}
|
||
const actions = document.createElement("div");
|
||
actions.className = "character-actions";
|
||
const useButton = document.createElement("button");
|
||
useButton.type = "button";
|
||
useButton.textContent = state.activeModule === "library" ? "用于短剧生产" : `用于角色 ${ROLE_LETTERS[state.activeRoleIndex]}`;
|
||
useButton.addEventListener("click", () => bindCharacterToActiveRole(character));
|
||
actions.appendChild(useButton);
|
||
if (character.source === "manual" && state.activeModule === "library") {
|
||
const removeButton = document.createElement("button");
|
||
removeButton.type = "button";
|
||
removeButton.className = "danger-link";
|
||
removeButton.textContent = "删除组合";
|
||
removeButton.addEventListener("click", () => {
|
||
state.characters = state.characters.filter((item) => item.id !== character.id);
|
||
saveCharacterLibrary();
|
||
renderCharacterLibrary();
|
||
});
|
||
actions.appendChild(removeButton);
|
||
}
|
||
card.append(head, thumbs, actions);
|
||
el.characterList.appendChild(card);
|
||
}
|
||
}
|
||
|
||
function bindCharacterToActiveRole(character) {
|
||
const otherRoleImages = state.roles.reduce((sum, role, index) => sum + (index === state.activeRoleIndex ? 0 : role.references.length), 0);
|
||
const extraImages = collectMediaInputUrls("image").length;
|
||
if (otherRoleImages + character.references.length + extraImages > 9) {
|
||
showToast(`绑定后图片总数将超过 9 张;当前其他角色与场景图已占 ${otherRoleImages + extraImages} 张`, true);
|
||
return;
|
||
}
|
||
const role = activeRole();
|
||
role.name = character.name;
|
||
role.characterId = character.id;
|
||
role.references = [...character.references];
|
||
if (activeShot() && !activeShot().roleIds.includes(role.id)) activeShot().roleIds.push(role.id);
|
||
renderScene();
|
||
if (state.activeModule === "library") switchModule("production");
|
||
showToast(`已将「${character.name}」用于角色 ${ROLE_LETTERS[state.activeRoleIndex]}`);
|
||
}
|
||
|
||
function switchModule(module) {
|
||
if (!['library', 'production'].includes(module)) return;
|
||
state.activeModule = module;
|
||
document.body.dataset.activeModule = module;
|
||
document.querySelectorAll('.module-tab').forEach((button) => {
|
||
button.classList.toggle('active', button.dataset.module === module);
|
||
});
|
||
renderCharacterLibrary();
|
||
}
|
||
|
||
function createShot(seed = {}, index = state.shots.length) {
|
||
return {
|
||
id: seed.id || `shot-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||
title: seed.title || (index === 0 ? "电梯口对话" : `片段 ${String(index + 1).padStart(2, "0")}`),
|
||
prompt: seed.prompt || DEFAULT_SHOT_PROMPT,
|
||
extraImageUrls: seed.extraImageUrls || "",
|
||
referenceVideoUrls: seed.referenceVideoUrls || "",
|
||
continuityMode: index === 0 ? "none" : (seed.continuityMode === "none" ? "none" : "previous_video"),
|
||
roleIds: Array.isArray(seed.roleIds) ? [...seed.roleIds] : state.roles.map((role) => role.id),
|
||
model: seed.model || "doubao-seedance-2-0-260128",
|
||
ratio: seed.ratio || "9:16",
|
||
duration: Number(seed.duration) || 5,
|
||
resolution: seed.resolution || "720p",
|
||
generateAudio: seed.generateAudio !== false,
|
||
watermark: Boolean(seed.watermark),
|
||
versions: Array.isArray(seed.versions) ? seed.versions : [],
|
||
selectedVersionId: seed.selectedVersionId || null,
|
||
};
|
||
}
|
||
|
||
function restoreDramaProject() {
|
||
let saved;
|
||
try { saved = JSON.parse(localStorage.getItem(DRAMA_PROJECT_KEY) || "null"); } catch { saved = null; }
|
||
state.projectName = String(saved?.name || "未命名短剧");
|
||
state.shots = Array.isArray(saved?.shots) && saved.shots.length
|
||
? saved.shots.slice(0, 30).map((shot, index) => createShot(shot, index))
|
||
: [createShot()];
|
||
const roleIds = new Set(state.roles.map((role) => role.id));
|
||
state.shots.forEach((shot) => {
|
||
shot.roleIds = shot.roleIds.filter((id) => roleIds.has(id));
|
||
if (!shot.roleIds.length) shot.roleIds = state.roles.map((role) => role.id);
|
||
});
|
||
state.activeShotId = state.shots.some((shot) => shot.id === saved?.activeShotId) ? saved.activeShotId : state.shots[0].id;
|
||
}
|
||
|
||
function saveDramaProject() {
|
||
localStorage.setItem(DRAMA_PROJECT_KEY, JSON.stringify({
|
||
name: state.projectName,
|
||
activeShotId: state.activeShotId,
|
||
shots: state.shots,
|
||
}));
|
||
}
|
||
|
||
function activeShot() { return state.shots.find((shot) => shot.id === state.activeShotId) || state.shots[0]; }
|
||
function shotIndex(shot = activeShot()) { return Math.max(0, state.shots.findIndex((item) => item.id === shot?.id)); }
|
||
function previousShot(shot = activeShot()) { const index = shotIndex(shot); return index > 0 ? state.shots[index - 1] : null; }
|
||
function selectedShotVersion(shot) { return shot?.versions?.find((version) => version.id === shot.selectedVersionId) || null; }
|
||
|
||
function updateActiveShotFromForm() {
|
||
const shot = activeShot();
|
||
if (!shot) return;
|
||
shot.prompt = el.prompt.value;
|
||
shot.extraImageUrls = collectMediaInputUrls("image").join("\n");
|
||
shot.referenceVideoUrls = collectMediaInputUrls("video").join("\n");
|
||
shot.continuityMode = el.continuityMode.value;
|
||
shot.model = el.model.value;
|
||
shot.ratio = el.ratio.value;
|
||
shot.duration = Number(el.duration.value);
|
||
shot.resolution = el.resolution.value;
|
||
shot.generateAudio = el.generateAudio.checked;
|
||
shot.watermark = el.watermark.checked;
|
||
saveDramaProject();
|
||
renderShotList();
|
||
renderProjectSummary();
|
||
}
|
||
|
||
function loadActiveShotIntoForm() {
|
||
const shot = activeShot();
|
||
if (!shot) return;
|
||
el.prompt.value = shot.prompt;
|
||
renderMediaInputList("image", shot.extraImageUrls);
|
||
renderMediaInputList("video", shot.referenceVideoUrls);
|
||
el.continuityMode.value = shotIndex(shot) === 0 ? "none" : shot.continuityMode;
|
||
el.continuityMode.disabled = shotIndex(shot) === 0;
|
||
el.model.value = shot.model;
|
||
el.ratio.value = shot.ratio;
|
||
el.duration.value = String(shot.duration);
|
||
el.resolution.value = shot.resolution;
|
||
el.generateAudio.checked = shot.generateAudio;
|
||
el.watermark.checked = shot.watermark;
|
||
renderActiveShotGenerationState();
|
||
}
|
||
|
||
function renderStoryboard() {
|
||
renderProjectSummary();
|
||
renderShotList();
|
||
const shot = activeShot();
|
||
if (!shot) return;
|
||
el.continuityMode.value = shotIndex(shot) === 0 ? "none" : shot.continuityMode;
|
||
el.continuityMode.disabled = shotIndex(shot) === 0;
|
||
renderVersionList();
|
||
if (!el.assemblyPanel.classList.contains("hidden")) renderAssembly();
|
||
}
|
||
|
||
function renderProjectSummary() {
|
||
const selected = state.shots.filter((shot) => selectedShotVersion(shot)).length;
|
||
const seconds = state.shots.reduce((sum, shot) => sum + Number(shot.duration || 0), 0);
|
||
el.timelineSummary.textContent = `${selected}/${state.shots.length} 已选定 · 预计 ${seconds} 秒`;
|
||
el.shotProgressBar.style.width = `${state.shots.length ? (selected / state.shots.length) * 100 : 0}%`;
|
||
}
|
||
|
||
function renderShotList() {
|
||
el.shotList.innerHTML = "";
|
||
state.shots.forEach((shot, index) => {
|
||
const selected = selectedShotVersion(shot);
|
||
const running = shot.versions.some((version) => version.status === "running");
|
||
const failed = shot.versions.length > 0 && shot.versions.every((version) => version.status === "failed");
|
||
const card = document.createElement("div");
|
||
card.className = `shot-card${shot.id === state.activeShotId ? " active" : ""}`;
|
||
const stateClass = selected ? "ready" : running ? "running" : failed ? "failed" : "";
|
||
card.innerHTML = `<button type="button" class="shot-card-select" aria-label="打开片段 ${index + 1}"><span class="shot-number">${String(index + 1).padStart(2, "0")}</span><span class="shot-card-copy"><strong>${escapeHtml(shot.title)}</strong><small>${shot.duration} 秒 · ${shot.versions.length} 个候选</small></span><i class="shot-state ${stateClass}"></i></button>${state.shots.length > 1 ? `<button type="button" class="shot-delete-button" aria-label="删除片段 ${index + 1}" title="删除片段">×</button>` : ""}`;
|
||
card.querySelector(".shot-card-select").addEventListener("click", () => selectShot(shot.id));
|
||
card.querySelector(".shot-delete-button")?.addEventListener("click", () => deleteShotById(shot.id));
|
||
el.shotList.appendChild(card);
|
||
});
|
||
}
|
||
|
||
function selectShot(id) {
|
||
updateActiveShotFromForm();
|
||
state.activeShotId = id;
|
||
loadActiveShotIntoForm();
|
||
renderStoryboard();
|
||
renderSceneSummary();
|
||
updateVideoAvailability();
|
||
saveDramaProject();
|
||
}
|
||
|
||
function addShot() {
|
||
updateActiveShotFromForm();
|
||
const shot = createShot({ prompt: "请描述本片段的场景、出场人物、动作、台词、镜头与声音。" });
|
||
state.shots.push(shot);
|
||
state.activeShotId = shot.id;
|
||
loadActiveShotIntoForm();
|
||
renderStoryboard();
|
||
renderSceneSummary();
|
||
updateVideoAvailability();
|
||
saveDramaProject();
|
||
}
|
||
|
||
function duplicateActiveShot() {
|
||
updateActiveShotFromForm();
|
||
const source = activeShot();
|
||
const index = shotIndex(source);
|
||
const copy = createShot({ ...source, id: null, title: `${source.title} · 副本`, versions: [], selectedVersionId: null });
|
||
state.shots.splice(index + 1, 0, copy);
|
||
state.activeShotId = copy.id;
|
||
loadActiveShotIntoForm();
|
||
renderStoryboard();
|
||
updateVideoAvailability();
|
||
saveDramaProject();
|
||
}
|
||
|
||
function deleteShotById(id) {
|
||
if (state.shots.length <= 1) return showToast("至少保留一个片段", true);
|
||
const index = state.shots.findIndex((shot) => shot.id === id);
|
||
if (index < 0) return;
|
||
const shot = state.shots[index];
|
||
if (!window.confirm(`确定删除片段 ${String(index + 1).padStart(2, "0")}「${shot.title}」吗?\n该片段的候选视频记录也会一起删除。`)) return;
|
||
|
||
updateActiveShotFromForm();
|
||
let pending = null;
|
||
try { pending = JSON.parse(localStorage.getItem(PENDING_VIDEO_KEY) || "null"); } catch { pending = null; }
|
||
if (pending?.shotId === id) clearPendingVideo();
|
||
if (state.activeGenerationShotId === id) {
|
||
clearInterval(state.pollTimer);
|
||
state.pollTimer = null;
|
||
state.activeGenerationShotId = null;
|
||
state.activeGenerationVersionId = null;
|
||
}
|
||
|
||
state.shots.splice(index, 1);
|
||
if (state.activeShotId === id) state.activeShotId = state.shots[Math.min(index, state.shots.length - 1)].id;
|
||
state.shots[0].continuityMode = "none";
|
||
loadActiveShotIntoForm();
|
||
renderStoryboard();
|
||
renderSceneSummary();
|
||
updateVideoAvailability();
|
||
saveDramaProject();
|
||
logClientOperation("shot.delete", {
|
||
deletedShotId: id,
|
||
deletedIndex: index + 1,
|
||
hadRunningVersion: shot.versions.some((version) => version.status === "running"),
|
||
versionCount: shot.versions.length,
|
||
remainingShotCount: state.shots.length,
|
||
});
|
||
showToast("片段已删除");
|
||
}
|
||
|
||
function moveActiveShot(direction) {
|
||
const index = shotIndex();
|
||
const target = index + direction;
|
||
if (target < 0 || target >= state.shots.length) return;
|
||
const [shot] = state.shots.splice(index, 1);
|
||
state.shots.splice(target, 0, shot);
|
||
if (target === 0) shot.continuityMode = "none";
|
||
loadActiveShotIntoForm();
|
||
renderStoryboard();
|
||
saveDramaProject();
|
||
}
|
||
|
||
function activeShotRoles() {
|
||
return state.roles;
|
||
}
|
||
|
||
function continuityReference(shot = activeShot()) {
|
||
const previous = previousShot(shot);
|
||
const version = selectedShotVersion(previous);
|
||
if (!previous || shot.continuityMode === "none") return null;
|
||
if (shot.continuityMode === "previous_video") return version?.videoUrl ? { type: "video", url: version.videoUrl, version, previous } : null;
|
||
return null;
|
||
}
|
||
|
||
function effectiveVideoReferences() {
|
||
const manual = collectMediaInputUrls("video");
|
||
const continuity = continuityReference();
|
||
return [...new Set([...(continuity?.type === "video" ? [continuity.url] : []), ...manual])];
|
||
}
|
||
|
||
function effectiveExtraImageReferences() {
|
||
const manual = collectMediaInputUrls("image");
|
||
return [...new Set(manual)];
|
||
}
|
||
|
||
function renderVersionList() {
|
||
const shot = activeShot();
|
||
const versions = shot?.versions || [];
|
||
el.versionCount.textContent = versions.length ? `${versions.length} 个候选` : "暂无候选";
|
||
el.versionList.innerHTML = "";
|
||
if (!versions.length) {
|
||
el.versionList.innerHTML = '<div class="empty-small">生成当前片段后,候选视频会出现在这里</div>';
|
||
return;
|
||
}
|
||
versions.slice().reverse().forEach((version, reverseIndex) => {
|
||
const number = versions.length - reverseIndex;
|
||
const card = document.createElement("div");
|
||
card.className = `version-card${shot.selectedVersionId === version.id ? " selected" : ""}`;
|
||
const preview = document.createElement("div");
|
||
preview.className = "version-preview";
|
||
if (version.videoUrl) {
|
||
const video = document.createElement("video");
|
||
video.src = version.videoUrl;
|
||
video.controls = true;
|
||
preview.appendChild(video);
|
||
} else preview.textContent = version.status === "running" ? "生成中" : version.status === "failed" ? "生成失败" : "暂无预览";
|
||
const details = document.createElement("div");
|
||
details.className = "version-copy";
|
||
details.innerHTML = `<strong>候选 ${number} · ${version.status === "completed" ? "已完成" : version.status === "running" ? "生成中" : "失败"}</strong><span>${escapeHtml(version.taskId || version.error || "等待任务 ID")}</span>`;
|
||
|
||
const actions = document.createElement("div");
|
||
actions.className = "version-actions";
|
||
const selectButton = document.createElement("button");
|
||
selectButton.type = "button";
|
||
selectButton.className = "select-version-button";
|
||
selectButton.disabled = version.status !== "completed";
|
||
selectButton.textContent = shot.selectedVersionId === version.id ? "已选定" : "选为正式版本";
|
||
selectButton.addEventListener("click", () => selectShotVersion(shot.id, version.id));
|
||
actions.appendChild(selectButton);
|
||
|
||
if (version.status === "completed" && version.taskId && !version.mock) {
|
||
const download = document.createElement("a");
|
||
download.className = "video-action-link";
|
||
download.href = version.videoUrl;
|
||
download.textContent = "下载视频";
|
||
download.setAttribute("download", "");
|
||
download.target = "_blank";
|
||
download.rel = "noopener";
|
||
|
||
const copyLink = document.createElement("button");
|
||
copyLink.type = "button";
|
||
copyLink.className = "video-action-link";
|
||
copyLink.textContent = "复制视频链接";
|
||
copyLink.addEventListener("click", () => copyGeneratedVideoLink(version.taskId));
|
||
actions.append(download, copyLink);
|
||
}
|
||
|
||
card.append(preview, details, actions);
|
||
el.versionList.appendChild(card);
|
||
});
|
||
}
|
||
|
||
function videoProxyUrl(taskId) {
|
||
const base = API_BASE || location.origin;
|
||
return `${base}/api/videos/${encodeURIComponent(taskId)}/video.mp4`;
|
||
}
|
||
|
||
async function copyGeneratedVideoLink(taskId) {
|
||
const url = videoProxyUrl(taskId);
|
||
try {
|
||
if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(url);
|
||
else fallbackCopy(url);
|
||
showToast("视频链接已复制,可直接用作参考视频");
|
||
} catch {
|
||
fallbackCopy(url);
|
||
showToast("视频链接已复制,可直接用作参考视频");
|
||
}
|
||
}
|
||
|
||
function selectShotVersion(shotId, versionId) {
|
||
const shot = state.shots.find((item) => item.id === shotId);
|
||
if (!shot) return;
|
||
shot.selectedVersionId = versionId;
|
||
saveDramaProject();
|
||
renderStoryboard();
|
||
showToast(`已选定「${shot.title}」的正式版本`);
|
||
}
|
||
|
||
function renderAssembly() {
|
||
el.assemblyList.innerHTML = "";
|
||
let ready = 0;
|
||
state.shots.forEach((shot, index) => {
|
||
const version = selectedShotVersion(shot);
|
||
if (version) ready += 1;
|
||
const row = document.createElement("div");
|
||
row.className = "assembly-row";
|
||
row.innerHTML = `<b>${String(index + 1).padStart(2, "0")}</b><strong>${escapeHtml(shot.title)}</strong><span>${version ? `${shot.duration} 秒 · 已选版本` : "待选版本"}</span>`;
|
||
el.assemblyList.appendChild(row);
|
||
});
|
||
el.assemblyHint.textContent = ready === state.shots.length
|
||
? `全部 ${ready} 个片段已选定,可进入服务端拼接与导出。`
|
||
: `还差 ${state.shots.length - ready} 个片段选定正式版本。`;
|
||
}
|
||
|
||
function openCharacterEditor() {
|
||
state.characterDraftReferences = new Set();
|
||
el.characterName.value = "";
|
||
el.characterEditor.classList.remove("hidden");
|
||
renderAssetPicker();
|
||
}
|
||
|
||
function closeCharacterEditor() {
|
||
el.characterEditor.classList.add("hidden");
|
||
state.characterDraftReferences.clear();
|
||
}
|
||
|
||
function renderAssetPicker() {
|
||
if (!el.assetPickList) return;
|
||
el.assetPickList.innerHTML = "";
|
||
if (!state.assets.length) {
|
||
el.assetPickList.innerHTML = '<div class="empty-small">暂无历史素材</div>';
|
||
return;
|
||
}
|
||
for (const item of state.assets) {
|
||
const reference = normalizeAssetReference(item);
|
||
const imageUrl = item.original_url || item.URL || "";
|
||
const label = item.purpose || item.Name || reference;
|
||
const row = document.createElement("label");
|
||
row.className = "asset-pick";
|
||
const checkbox = document.createElement("input");
|
||
checkbox.type = "checkbox";
|
||
checkbox.checked = state.characterDraftReferences.has(reference);
|
||
checkbox.addEventListener("change", () => {
|
||
if (checkbox.checked && state.characterDraftReferences.size >= 9) {
|
||
checkbox.checked = false;
|
||
return showToast("一个人物素材组最多选择 9 张图片", true);
|
||
}
|
||
if (checkbox.checked) state.characterDraftReferences.add(reference);
|
||
else state.characterDraftReferences.delete(reference);
|
||
});
|
||
const image = document.createElement("img");
|
||
image.src = imageUrl;
|
||
image.alt = label;
|
||
const text = document.createElement("span");
|
||
text.textContent = label;
|
||
row.append(checkbox, image, text);
|
||
el.assetPickList.appendChild(row);
|
||
}
|
||
}
|
||
|
||
function saveCharacterFromDraft() {
|
||
const name = el.characterName.value.trim();
|
||
const references = [...state.characterDraftReferences];
|
||
if (!name) return showToast("请填写人物名称", true);
|
||
if (!references.length) return showToast("请至少选择一张人物素材", true);
|
||
upsertCharacter(name, references, "manual", true);
|
||
closeCharacterEditor();
|
||
renderCharacterLibrary();
|
||
showToast(`人物「${name}」已保存,共 ${references.length} 张素材`);
|
||
}
|
||
|
||
function upsertCharacter(name, references, source = "manual", persist = true) {
|
||
const existing = state.characters.find((item) => item.name === name);
|
||
if (existing) {
|
||
existing.references = [...new Set([...existing.references, ...references])].slice(0, 9);
|
||
if (source === "manual") existing.source = "manual";
|
||
} else {
|
||
state.characters.push({
|
||
id: `character-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||
name,
|
||
references: [...new Set(references)].slice(0, 9),
|
||
source,
|
||
});
|
||
}
|
||
if (persist) saveCharacterLibrary();
|
||
}
|
||
|
||
function sameReferences(left = [], right = []) {
|
||
return left.length === right.length && left.every((reference, index) => reference === right[index]);
|
||
}
|
||
|
||
function toggleHistoricalAsset(reference) {
|
||
const existing = referencePosition(reference);
|
||
if (existing) {
|
||
state.roles[existing.roleIndex].references.splice(existing.viewIndex, 1);
|
||
state.activeRoleIndex = existing.roleIndex;
|
||
} else {
|
||
const role = activeRole();
|
||
if (sceneReferences().length >= 9) {
|
||
showToast("本次人物图片已达到 9 张上限;请先取消其他图片", true);
|
||
return;
|
||
}
|
||
role.references.push(reference);
|
||
}
|
||
renderScene();
|
||
}
|
||
|
||
function renderScene() {
|
||
if (!state.roles.length) state.roles.push(createRole("角色 A"));
|
||
state.activeRoleIndex = Math.min(state.activeRoleIndex, state.roles.length - 1);
|
||
renderRoleCards();
|
||
renderActiveRole();
|
||
renderSceneSummary();
|
||
renderCharacterLibrary();
|
||
refreshAssetSelectionStyles();
|
||
updateVideoAvailability();
|
||
saveSceneRoles();
|
||
if (state.shots.length) renderStoryboard();
|
||
}
|
||
|
||
function renderRoleCards() {
|
||
el.sceneRoles.innerHTML = "";
|
||
state.roles.forEach((role, index) => {
|
||
const card = document.createElement("div");
|
||
card.className = `role-card${index === state.activeRoleIndex ? " active" : ""}`;
|
||
const select = document.createElement("button");
|
||
select.type = "button";
|
||
select.className = "role-select";
|
||
select.innerHTML = `<span class="role-letter">${ROLE_LETTERS[index]}</span><span class="role-copy"><strong>${escapeHtml(role.name)}</strong><small>${role.references.length} 张人物素材</small></span>`;
|
||
select.addEventListener("click", () => {
|
||
state.activeRoleIndex = index;
|
||
renderScene();
|
||
});
|
||
card.appendChild(select);
|
||
if (state.roles.length > 1) {
|
||
const remove = document.createElement("button");
|
||
remove.type = "button";
|
||
remove.className = "role-remove";
|
||
remove.textContent = "×";
|
||
remove.title = `删除角色 ${ROLE_LETTERS[index]}`;
|
||
remove.addEventListener("click", (event) => {
|
||
event.stopPropagation();
|
||
const removedId = state.roles[index].id;
|
||
state.roles.splice(index, 1);
|
||
state.shots.forEach((shot) => {
|
||
shot.roleIds = shot.roleIds.filter((id) => id !== removedId);
|
||
if (!shot.roleIds.length && state.roles[0]) shot.roleIds = [state.roles[0].id];
|
||
});
|
||
state.activeRoleIndex = Math.max(0, Math.min(state.activeRoleIndex, state.roles.length - 1));
|
||
renderScene();
|
||
renderStoryboard();
|
||
saveDramaProject();
|
||
});
|
||
card.appendChild(remove);
|
||
}
|
||
el.sceneRoles.appendChild(card);
|
||
});
|
||
el.addRoleButton.disabled = state.roles.length >= 3;
|
||
}
|
||
|
||
function renderActiveRole() {
|
||
const role = activeRole();
|
||
const letter = ROLE_LETTERS[state.activeRoleIndex];
|
||
el.activeRoleLabel.textContent = `角色 ${letter} · 人物素材`;
|
||
el.assetName.value = role.name;
|
||
reviewRows.forEach((_, index) => {
|
||
const reference = role.references[index];
|
||
setReviewRow(index, reference ? "success" : "neutral", reference ? "已选择" : "待选择", reference || `下一张请选择${VIEW_LABELS[index]}图`);
|
||
});
|
||
el.reviewElapsed.textContent = `角色 ${letter} · 已选 ${role.references.length} 张`;
|
||
}
|
||
|
||
function renderSceneSummary() {
|
||
const completeRoles = state.roles.filter((role) => role.references.length > 0);
|
||
el.assetReferences.innerHTML = "";
|
||
for (const role of completeRoles) {
|
||
const index = state.roles.indexOf(role);
|
||
const title = document.createElement("div");
|
||
title.className = "role-reference-title";
|
||
title.textContent = `角色 ${ROLE_LETTERS[index]} · ${role.name}`;
|
||
el.assetReferences.appendChild(title);
|
||
role.references.forEach((reference, viewIndex) => {
|
||
const row = document.createElement("div");
|
||
row.className = "reference-row";
|
||
row.innerHTML = `<span>${VIEW_LABELS[viewIndex] || `素材${viewIndex + 1}`}</span><code>${escapeHtml(reference)}</code>`;
|
||
el.assetReferences.appendChild(row);
|
||
});
|
||
}
|
||
el.assetResult.classList.toggle("hidden", completeRoles.length === 0);
|
||
const selected = state.roles.reduce((sum, role) => sum + role.references.length, 0);
|
||
setAssetStatus(sceneReady() ? "success" : "processing", sceneReady() ? `${state.roles.length} 个角色 · ${selected} 张人物图` : "请为每个角色选择人物素材");
|
||
el.referenceMap.textContent = buildReferenceMap();
|
||
renderInputQuota();
|
||
}
|
||
|
||
function updateVideoAvailability() {
|
||
const quota = mediaQuota();
|
||
renderInputQuota();
|
||
const running = runningShotVersion(activeShot());
|
||
if (running) {
|
||
el.videoForm.classList.remove("disabled-section");
|
||
el.generateButton.disabled = true;
|
||
setVideoStatus("processing", "生成中");
|
||
setStep(4);
|
||
renderActiveShotGenerationState();
|
||
return;
|
||
}
|
||
el.generateButton.disabled = false;
|
||
if (sceneReady() && quota.valid && state.config.videoReady) {
|
||
el.videoForm.classList.remove("disabled-section");
|
||
setVideoStatus("neutral", "可提交");
|
||
setStep(3);
|
||
} else {
|
||
el.videoForm.classList.add("disabled-section");
|
||
const label = !state.config.videoReady ? "请输入客户令牌" : !sceneReady() ? "等待角色" : quota.message;
|
||
setVideoStatus(!state.config.videoReady || !quota.valid ? "error" : "neutral", label);
|
||
setStep(sceneReady() ? 2 : 1);
|
||
}
|
||
}
|
||
|
||
function runningShotVersion(shot) {
|
||
return shot?.versions?.slice().reverse().find((version) => version.status === "running") || null;
|
||
}
|
||
|
||
function formatElapsed(startedAt) {
|
||
const seconds = Math.max(0, Math.floor((Date.now() - Number(startedAt || Date.now())) / 1000));
|
||
const minutes = Math.floor(seconds / 60);
|
||
const remainder = seconds % 60;
|
||
return minutes ? `${minutes} 分 ${remainder} 秒` : `${remainder} 秒`;
|
||
}
|
||
|
||
function formatCheckedAt(value) {
|
||
if (!value) return "尚未收到首次查询结果";
|
||
return `最近查询 ${new Date(value).toLocaleTimeString("zh-CN", { hour12: false })}`;
|
||
}
|
||
|
||
function renderGenerationMeta(version) {
|
||
el.generationElapsed.textContent = `已等待 ${formatElapsed(version.createdAt)}`;
|
||
el.generationLastChecked.textContent = formatCheckedAt(version.lastCheckedAt);
|
||
}
|
||
|
||
function renderActiveShotGenerationState() {
|
||
clearInterval(state.pollTimer);
|
||
state.pollTimer = null;
|
||
const version = runningShotVersion(activeShot());
|
||
if (!version) {
|
||
el.generationResult.classList.add("hidden");
|
||
el.generationElapsed.textContent = "";
|
||
el.generationLastChecked.textContent = "";
|
||
return;
|
||
}
|
||
el.generationResult.classList.remove("hidden");
|
||
el.generationTaskId.textContent = version.taskId || "正在获取任务 ID…";
|
||
resetVideoStage();
|
||
updateProgress(version.progress || 20, "正在自动查询生成结果");
|
||
renderGenerationMeta(version);
|
||
state.pollTimer = setInterval(() => {
|
||
if (activeShot()?.id !== state.activeGenerationShotId && version.id !== runningShotVersion(activeShot())?.id) return;
|
||
renderGenerationMeta(version);
|
||
}, 1000);
|
||
}
|
||
|
||
function finishGenerationUi(shot, version) {
|
||
renderStoryboard();
|
||
if (activeShot()?.id !== shot?.id) {
|
||
updateVideoAvailability();
|
||
return;
|
||
}
|
||
if (version?.status === "running") {
|
||
updateVideoAvailability();
|
||
return;
|
||
}
|
||
el.generateButton.disabled = false;
|
||
setVideoStatus(version?.status === "completed" ? "success" : "error", version?.status === "completed" ? "生成完成" : "生成失败");
|
||
}
|
||
|
||
function refreshAssetSelectionStyles() {
|
||
for (const button of el.assetList.querySelectorAll(".asset-card")) {
|
||
const position = referencePosition(button.textContent);
|
||
button.classList.toggle("selected", Boolean(position));
|
||
if (position) button.dataset.order = `${ROLE_LETTERS[position.roleIndex]}${position.viewIndex + 1}`;
|
||
else delete button.dataset.order;
|
||
}
|
||
}
|
||
|
||
function referencePosition(value) {
|
||
const text = String(value || "").toLowerCase();
|
||
for (let roleIndex = 0; roleIndex < state.roles.length; roleIndex += 1) {
|
||
const viewIndex = state.roles[roleIndex].references.findIndex((reference) => text.includes(String(reference).toLowerCase()));
|
||
if (viewIndex >= 0) return { roleIndex, viewIndex };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function buildReferenceMap() {
|
||
let start = 1;
|
||
const parts = [];
|
||
for (const role of activeShotRoles()) {
|
||
const index = state.roles.indexOf(role);
|
||
if (!role.references.length) continue;
|
||
const end = start + role.references.length - 1;
|
||
const range = start === end ? `图${start}` : `图${start}–${end}`;
|
||
const detail = role.references.length === 3 ? "正面、侧面、背面" : `${role.references.length} 张人物参考图`;
|
||
parts.push(`${range}:角色 ${ROLE_LETTERS[index]}「${role.name}」的${detail}`);
|
||
start = end + 1;
|
||
}
|
||
const extraImages = effectiveExtraImageReferences();
|
||
if (extraImages.length) {
|
||
const end = start + extraImages.length - 1;
|
||
parts.push(`${start === end ? `图${start}` : `图${start}–${end}`}:场景、道具或其他非人物参考图`);
|
||
}
|
||
return parts.length ? parts.join(";") : "系统会根据本场角色自动生成图号映射。";
|
||
}
|
||
|
||
function buildMappedPrompt() {
|
||
const videoReferences = effectiveVideoReferences();
|
||
const videoMap = videoReferences.length ? `\n参考视频映射:视频1–${videoReferences.length}为运镜、动作或节奏参考。` : "";
|
||
const continuity = continuityReference();
|
||
const continuityHint = continuity ? `\n连续性要求:本片段需承接上一片段「${continuity.previous.title}」的${continuity.type === "video" ? "动作、构图与状态" : "尾帧画面状态"}。` : "";
|
||
return `参考图角色映射:${buildReferenceMap()}。\n人物一致性要求:每个角色只参考自己的人物素材组,禁止交换五官、发型、服装、体型或身份。${videoMap}${continuityHint}\n${el.prompt.value.trim()}`;
|
||
}
|
||
|
||
function buildVideoClientBody() {
|
||
return {
|
||
assetReferences: sceneReferences(),
|
||
extraImageReferences: effectiveExtraImageReferences(),
|
||
videoReferences: effectiveVideoReferences(),
|
||
prompt: buildMappedPrompt(),
|
||
model: el.model.value,
|
||
ratio: el.ratio.value,
|
||
duration: Number(el.duration.value),
|
||
resolution: el.resolution.value,
|
||
generateAudio: el.generateAudio.checked,
|
||
watermark: el.watermark.checked,
|
||
};
|
||
}
|
||
|
||
function sceneReferences() {
|
||
return activeShotRoles().flatMap((role) => role.references);
|
||
}
|
||
|
||
function sceneReady() {
|
||
const roles = activeShotRoles();
|
||
return roles.length > 0 && roles.every((role) => role.references.length > 0) && mediaQuota().valid;
|
||
}
|
||
|
||
function activeRole() {
|
||
return state.roles[state.activeRoleIndex];
|
||
}
|
||
|
||
function createRole(name) {
|
||
return { id: `role-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, name, references: [] };
|
||
}
|
||
|
||
function saveSceneRoles() {
|
||
localStorage.setItem(SCENE_ROLES_KEY, JSON.stringify({ roles: state.roles, activeRoleIndex: state.activeRoleIndex }));
|
||
}
|
||
|
||
function saveCharacterLibrary() {
|
||
localStorage.setItem(CHARACTER_LIBRARY_KEY, JSON.stringify(state.characters));
|
||
}
|
||
|
||
function restoreCharacterLibrary() {
|
||
let saved;
|
||
try { saved = JSON.parse(localStorage.getItem(CHARACTER_LIBRARY_KEY) || "[]"); } catch { saved = []; }
|
||
state.characters = Array.isArray(saved) ? saved.map((character) => ({
|
||
id: character.id || `character-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||
name: String(character.name || "未命名人物"),
|
||
references: Array.isArray(character.references) ? character.references.map(String).filter((value) => state.config.mockMode || /^asset:\/\/(?:mat_|asset-)/i.test(value)).slice(0, 9) : [],
|
||
source: character.source || "manual",
|
||
})).filter((character) => character.references.length) : [];
|
||
}
|
||
|
||
function parseMediaLines(value) {
|
||
return [...new Set(String(value || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean))];
|
||
}
|
||
|
||
function mediaInputConfig(type) {
|
||
return type === "video"
|
||
? { list: el.referenceVideoInputs, button: el.addReferenceVideoButton, limit: 3, placeholder: "https://example.com/reference.mp4" }
|
||
: { list: el.extraImageInputs, button: el.addExtraImageButton, limit: 9, placeholder: "https://example.com/reference.jpg" };
|
||
}
|
||
|
||
function createMediaInputRow(type, value = "") {
|
||
const { placeholder } = mediaInputConfig(type);
|
||
const row = document.createElement("div");
|
||
row.className = "media-url-row";
|
||
|
||
const input = document.createElement("input");
|
||
input.type = "url";
|
||
input.value = value;
|
||
input.placeholder = placeholder;
|
||
input.setAttribute("aria-label", type === "video" ? "参考视频链接" : "其他参考图片链接");
|
||
|
||
const remove = document.createElement("button");
|
||
remove.type = "button";
|
||
remove.className = "media-remove-button";
|
||
remove.dataset.removeMedia = type;
|
||
remove.setAttribute("aria-label", "删除该链接");
|
||
remove.textContent = "×";
|
||
|
||
row.append(input, remove);
|
||
return row;
|
||
}
|
||
|
||
function renderMediaInputList(type, value) {
|
||
const { list } = mediaInputConfig(type);
|
||
const values = parseMediaLines(value);
|
||
list.replaceChildren(...(values.length ? values : [""]).map((item) => createMediaInputRow(type, item)));
|
||
refreshMediaAddButton(type);
|
||
}
|
||
|
||
function collectMediaInputUrls(type) {
|
||
const { list } = mediaInputConfig(type);
|
||
return [...new Set([...list.querySelectorAll("input")].map((input) => input.value.trim()).filter(Boolean))];
|
||
}
|
||
|
||
function addMediaInput(type) {
|
||
const { list, limit } = mediaInputConfig(type);
|
||
if (list.children.length >= limit) return;
|
||
const row = createMediaInputRow(type);
|
||
list.append(row);
|
||
row.querySelector("input").focus();
|
||
refreshMediaAddButton(type);
|
||
}
|
||
|
||
function removeMediaInput(button) {
|
||
const type = button.dataset.removeMedia;
|
||
const { list } = mediaInputConfig(type);
|
||
if (list.children.length === 1) {
|
||
list.querySelector("input").value = "";
|
||
} else {
|
||
button.closest(".media-url-row").remove();
|
||
}
|
||
refreshMediaAddButton(type);
|
||
updateActiveShotFromForm();
|
||
updateVideoAvailability();
|
||
}
|
||
|
||
function refreshMediaAddButton(type) {
|
||
const { list, button, limit } = mediaInputConfig(type);
|
||
button.disabled = list.children.length >= limit;
|
||
}
|
||
|
||
function mediaQuota() {
|
||
const characterImages = sceneReferences().length;
|
||
const extraImages = effectiveExtraImageReferences().length;
|
||
const videos = effectiveVideoReferences().length;
|
||
const imageTotal = characterImages + extraImages;
|
||
if (imageTotal > 9) return { valid: false, characterImages, extraImages, videos, imageTotal, message: `图片 ${imageTotal}/9,已超出上限` };
|
||
if (videos > 3) return { valid: false, characterImages, extraImages, videos, imageTotal, message: `参考视频 ${videos}/3,已超出 Demo 上限` };
|
||
return { valid: true, characterImages, extraImages, videos, imageTotal, message: "可提交" };
|
||
}
|
||
|
||
function renderInputQuota() {
|
||
if (!el.inputQuota) return;
|
||
const quota = mediaQuota();
|
||
el.inputQuota.textContent = `人物图片 ${quota.characterImages} 张 · 其他图片 ${quota.extraImages} 张 · 图片合计 ${quota.imageTotal}/9 · 视频 ${quota.videos}/3`;
|
||
el.inputQuota.className = `input-quota wide ${quota.valid ? "valid" : "invalid"}`;
|
||
}
|
||
|
||
function restoreSceneRoles() {
|
||
let saved;
|
||
try { saved = JSON.parse(localStorage.getItem(SCENE_ROLES_KEY) || "null"); } catch { saved = null; }
|
||
if (Array.isArray(saved?.roles) && saved.roles.length) {
|
||
state.roles = saved.roles.slice(0, 3).map((role, index) => ({
|
||
id: role.id || createRole("").id,
|
||
name: role.name || `角色 ${ROLE_LETTERS[index]}`,
|
||
references: Array.isArray(role.references)
|
||
? role.references.slice(0, 3).map(String).filter((value) => state.config.mockMode || /^asset:\/\/(?:mat_|asset-)/i.test(value))
|
||
: [],
|
||
}));
|
||
state.activeRoleIndex = Math.min(Number(saved.activeRoleIndex) || 0, state.roles.length - 1);
|
||
return;
|
||
}
|
||
let legacy;
|
||
try { legacy = JSON.parse(localStorage.getItem(LEGACY_GROUP_KEY) || "null"); } catch { legacy = null; }
|
||
if (Array.isArray(legacy?.references) && legacy.references.length === 3) {
|
||
state.roles = [{ ...createRole(legacy.name || "角色 A"), references: state.config.mockMode ? legacy.references.map(String) : [] }];
|
||
localStorage.removeItem(LEGACY_GROUP_KEY);
|
||
} else {
|
||
state.roles = [createRole("角色 A")];
|
||
}
|
||
}
|
||
|
||
async function finishAssetReview(result, roleId, name) {
|
||
const items = getReviewItems(result);
|
||
renderReviewItems(items);
|
||
if (items.length !== 3) throw new Error(result.message || `预期返回 3 张素材,实际返回 ${items.length} 张`);
|
||
const failedItem = items.find((item) => Number(item.submit_review_status) !== 1);
|
||
if (failedItem) throw new Error(failedItem.error_message || "三视图中有素材未通过审核");
|
||
const references = items.map(normalizeAssetReference);
|
||
if (references.some((reference) => !reference)) throw new Error("审核通过,但有素材未返回可用的 asset:// ID");
|
||
clearPendingReview();
|
||
const roleIndex = Math.max(0, state.roles.findIndex((role) => role.id === roleId));
|
||
state.activeRoleIndex = roleIndex;
|
||
state.roles[roleIndex].name = name;
|
||
state.roles[roleIndex].references = references;
|
||
upsertCharacter(name, references, "history", true);
|
||
el.reviewElapsed.textContent = "审核完成";
|
||
await loadAssets();
|
||
renderScene();
|
||
showToast(`角色 ${ROLE_LETTERS[roleIndex]} 的三视图已全部通过审核`);
|
||
}
|
||
|
||
async function pollAssetTask(taskId) {
|
||
for (let attempt = 0; attempt < 100; attempt += 1) {
|
||
updateReviewElapsed();
|
||
await delay(3000);
|
||
const result = await api(`/api/assets/tasks/${encodeURIComponent(taskId)}`);
|
||
const items = getReviewItems(result);
|
||
if (items.length) renderReviewItems(items);
|
||
const status = normalizeAssetTaskStatus(result);
|
||
if (status === "completed" || status === "failed") return result;
|
||
}
|
||
const error = new Error("素材仍在审核,任务已保存;稍后重新打开页面会继续查询");
|
||
error.pending = true;
|
||
throw error;
|
||
}
|
||
|
||
async function resumePendingReview() {
|
||
let pending;
|
||
try { pending = JSON.parse(localStorage.getItem(PENDING_REVIEW_KEY) || "null"); } catch { pending = null; }
|
||
if (!pending?.taskId) return;
|
||
const roleIndex = state.roles.findIndex((role) => role.id === pending.roleId);
|
||
if (roleIndex >= 0) state.activeRoleIndex = roleIndex;
|
||
state.reviewStartedAt = Number(pending.startedAt) || Date.now();
|
||
setAssetStatus("processing", "继续查询");
|
||
setAllReviewRows("processing", "审核中", "正在恢复上次任务");
|
||
try {
|
||
const result = await pollAssetTask(pending.taskId);
|
||
await finishAssetReview(result, pending.roleId || activeRole().id, pending.name || activeRole().name);
|
||
} catch (error) {
|
||
if (error.pending) {
|
||
setAssetStatus("processing", "仍在审核");
|
||
el.reviewElapsed.textContent = "仍在审核,可稍后回来查看";
|
||
} else {
|
||
setAssetStatus("error", "审核失败");
|
||
markUnresolvedRowsFailed(error.message);
|
||
clearPendingReview();
|
||
showToast(error.message, true);
|
||
}
|
||
}
|
||
}
|
||
|
||
function savePendingReview(taskId, roleId) {
|
||
localStorage.setItem(PENDING_REVIEW_KEY, JSON.stringify({
|
||
taskId, roleId, name: activeRole().name, startedAt: state.reviewStartedAt || Date.now(),
|
||
}));
|
||
}
|
||
|
||
function clearPendingReview() { localStorage.removeItem(PENDING_REVIEW_KEY); }
|
||
|
||
function getReviewItems(result) { return result.result?.items || result.data?.items || result.items || []; }
|
||
|
||
function normalizeAssetTaskStatus(result) {
|
||
const value = String(result.status || result.data?.status || result.result?.status || "").toLowerCase();
|
||
if (["completed", "complete", "succeeded", "success", "done"].includes(value)) return "completed";
|
||
if (["failed", "error", "cancelled", "canceled"].includes(value)) return "failed";
|
||
const items = getReviewItems(result);
|
||
if (items.length && items.every((item) => [0, 1].includes(Number(item.submit_review_status)))) return "completed";
|
||
return "processing";
|
||
}
|
||
|
||
function renderReviewItems(items) {
|
||
items.slice(0, 3).forEach((item, index) => {
|
||
const passed = Number(item.submit_review_status) === 1;
|
||
const failed = Number(item.submit_review_status) === 0 || item.error_code || item.error_message;
|
||
if (passed) setReviewRow(index, "success", "已通过", normalizeAssetReference(item));
|
||
else if (failed) setReviewRow(index, "error", "未通过", item.error_message || item.error_code || "审核未通过");
|
||
else setReviewRow(index, "processing", "审核中", "等待审核结果");
|
||
});
|
||
}
|
||
|
||
function setReviewRow(index, kind, status, detail = "") {
|
||
const row = reviewRows[index];
|
||
if (!row) return;
|
||
row.className = `tracker-row ${kind}`;
|
||
row.querySelector("b").textContent = status;
|
||
row.querySelector("small").textContent = detail;
|
||
row.querySelector("small").title = detail;
|
||
}
|
||
|
||
function setAllReviewRows(kind, status, detail = "") { reviewRows.forEach((_, index) => setReviewRow(index, kind, status, detail)); }
|
||
|
||
function markUnresolvedRowsFailed(message) {
|
||
reviewRows.forEach((row, index) => {
|
||
if (!row.classList.contains("success") && !row.classList.contains("error")) setReviewRow(index, "error", "查询失败", message);
|
||
});
|
||
el.reviewElapsed.textContent = "已返回结果";
|
||
}
|
||
|
||
function updateReviewElapsed() {
|
||
const seconds = Math.max(0, Math.floor((Date.now() - state.reviewStartedAt) / 1000));
|
||
el.reviewElapsed.textContent = `审核中 · ${seconds} 秒`;
|
||
}
|
||
|
||
async function pollVideoTask(taskId, shotId = state.activeGenerationShotId || activeShot()?.id, versionId = state.activeGenerationVersionId) {
|
||
const startedAt = Date.now();
|
||
while (Date.now() - startedAt < VIDEO_POLL_MAX_MS) {
|
||
await delay(VIDEO_POLL_INTERVAL_MS);
|
||
if (!state.shots.some((item) => item.id === shotId)) {
|
||
logClientOperation("video.poll_stopped", { taskId, reason: "shot_deleted", deletedShotId: shotId });
|
||
return;
|
||
}
|
||
const result = await api(`/api/videos/${encodeURIComponent(taskId)}`);
|
||
const status = result._normalized?.status || normalizeVideoStatus(result.status || result.data?.status);
|
||
const progress = Number(result.progress ?? result.data?.progress ?? (status === "completed" ? 100 : 20));
|
||
const shot = state.shots.find((item) => item.id === shotId);
|
||
const version = shot?.versions.find((item) => item.id === versionId);
|
||
if (version) {
|
||
version.taskId = taskId;
|
||
version.progress = Math.max(10, progress);
|
||
version.lastCheckedAt = Date.now();
|
||
saveDramaProject();
|
||
}
|
||
if (activeShot()?.id === shotId) {
|
||
updateProgress(Math.max(10, progress), status === "completed" ? "视频生成完成" : "正在自动查询生成结果");
|
||
if (version) renderGenerationMeta(version);
|
||
}
|
||
if (status === "completed") {
|
||
const videoUrl = result._normalized?.videoUrl || result.video_url || result.output?.video_url || result.data?.video_url;
|
||
completeShotVersion(shotId, versionId, videoUrl, result.mock);
|
||
if (activeShot()?.id === shotId) {
|
||
renderCompletedVideo(videoUrl, result.mock);
|
||
setVideoStatus("success", "生成完成");
|
||
}
|
||
clearPendingVideo();
|
||
state.activeGenerationShotId = null;
|
||
state.activeGenerationVersionId = null;
|
||
showToast("当前片段候选已生成");
|
||
return;
|
||
}
|
||
if (status === "failed") throw new Error(result.error?.message || result.message || "视频生成失败");
|
||
}
|
||
const error = new Error("视频仍在服务商侧生成,页面已等待 30 分钟;这不是生成失败,可凭任务 ID 继续查询");
|
||
error.pending = true;
|
||
throw error;
|
||
}
|
||
|
||
function savePendingVideo(taskId, shotId, versionId) {
|
||
localStorage.setItem(PENDING_VIDEO_KEY, JSON.stringify({ taskId, shotId, versionId, startedAt: Date.now() }));
|
||
}
|
||
|
||
function clearPendingVideo() { localStorage.removeItem(PENDING_VIDEO_KEY); }
|
||
|
||
async function resumePendingVideo() {
|
||
let pending;
|
||
try { pending = JSON.parse(localStorage.getItem(PENDING_VIDEO_KEY) || "null"); } catch { pending = null; }
|
||
if (!pending?.taskId) return;
|
||
switchModule("production");
|
||
let shot = state.shots.find((item) => item.id === pending.shotId);
|
||
if (!shot) shot = activeShot();
|
||
state.activeShotId = shot.id;
|
||
let version = shot.versions.find((item) => item.id === pending.versionId);
|
||
if (!version) {
|
||
version = { id: pending.versionId || `version-${Date.now()}`, status: "running", taskId: pending.taskId, videoUrl: "", lastFrameUrl: "", createdAt: pending.startedAt || Date.now(), lastCheckedAt: null, progress: 20 };
|
||
shot.versions.push(version);
|
||
}
|
||
state.activeGenerationShotId = shot.id;
|
||
state.activeGenerationVersionId = version.id;
|
||
loadActiveShotIntoForm();
|
||
renderStoryboard();
|
||
clearTimeout(state.pollTimer);
|
||
el.generateButton.disabled = true;
|
||
el.generationResult.classList.remove("hidden");
|
||
el.generationTaskId.textContent = pending.taskId;
|
||
setVideoStatus("processing", "继续查询");
|
||
setStep(4);
|
||
resetVideoStage();
|
||
updateProgress(20, "正在恢复上次视频任务");
|
||
try {
|
||
await pollVideoTask(pending.taskId, shot.id, version.id);
|
||
} catch (error) {
|
||
const message = contextualizeGenerationError(error.message);
|
||
if (error.pending) {
|
||
setVideoStatus("processing", "仍在生成");
|
||
updateProgress(20, "仍在生成,可稍后继续查询");
|
||
showToast(message);
|
||
} else {
|
||
clearPendingVideo();
|
||
setVideoStatus("error", "生成失败");
|
||
failShotVersion(shot.id, version.id, message);
|
||
renderGenerationError(message);
|
||
showToast(message, true);
|
||
}
|
||
} finally {
|
||
finishGenerationUi(shot, version);
|
||
}
|
||
}
|
||
|
||
function renderConnection() {
|
||
const videoKey = currentVideoKey();
|
||
el.changeVideoKeyButton.classList.toggle("hidden", state.config.mockMode || !videoKey);
|
||
if (videoKey) el.changeVideoKeyButton.textContent = `更换客户令牌 · ${videoKey.slice(-4)}`;
|
||
if (state.config.mockMode) {
|
||
el.connectionState.textContent = "Mock 模式 · 可评审完整流程";
|
||
el.connectionState.className = "connection mock";
|
||
el.reviewButton.textContent = "模拟提交三视图";
|
||
el.mockHint.classList.remove("hidden");
|
||
} else if (state.config.assetReady && state.config.videoReady) {
|
||
el.connectionState.textContent = "真实接口已连接";
|
||
el.connectionState.className = "connection live";
|
||
el.reviewButton.textContent = "提交真实素材审核";
|
||
el.mockHint.classList.add("hidden");
|
||
} else if (state.config.assetReady) {
|
||
el.connectionState.textContent = "请输入客户令牌";
|
||
el.connectionState.className = "connection live";
|
||
el.reviewButton.textContent = "提交真实素材审核";
|
||
el.mockHint.classList.add("hidden");
|
||
} else {
|
||
el.connectionState.textContent = `缺少配置:${state.config.missing.join("、")}`;
|
||
el.connectionState.className = "connection blocked";
|
||
}
|
||
}
|
||
|
||
function completeShotVersion(shotId, versionId, videoUrl, mock) {
|
||
const shot = state.shots.find((item) => item.id === shotId);
|
||
const version = shot?.versions.find((item) => item.id === versionId);
|
||
if (!shot || !version) return;
|
||
version.status = "completed";
|
||
version.videoUrl = videoUrl || "";
|
||
version.mock = Boolean(mock);
|
||
if (!shot.selectedVersionId) shot.selectedVersionId = version.id;
|
||
saveDramaProject();
|
||
renderStoryboard();
|
||
}
|
||
|
||
function failShotVersion(shotId, versionId, message) {
|
||
const shot = state.shots.find((item) => item.id === shotId);
|
||
const version = shot?.versions.find((item) => item.id === versionId);
|
||
if (!version) return;
|
||
version.status = "failed";
|
||
version.error = message;
|
||
saveDramaProject();
|
||
renderStoryboard();
|
||
}
|
||
|
||
function renderCompletedVideo(videoUrl, mock) {
|
||
if (videoUrl) {
|
||
const video = document.createElement("video");
|
||
video.controls = true;
|
||
video.playsInline = true;
|
||
video.preload = "metadata";
|
||
video.src = videoUrl;
|
||
el.videoStage.classList.add("has-video");
|
||
el.videoStage.replaceChildren(video);
|
||
} else {
|
||
el.videoStage.classList.remove("has-video");
|
||
el.videoStage.innerHTML = `<div class="mock-frame"><div class="mock-person"></div><strong>${mock ? "Mock 流程已完成" : "任务已完成"}</strong><span>${mock ? "输入客户令牌后此处展示真实视频" : "接口响应中未识别到视频 URL,请稍后重试"}</span></div>`;
|
||
}
|
||
updateProgress(100, "视频生成完成");
|
||
}
|
||
|
||
function renderGenerationError(message) {
|
||
el.videoStage.classList.remove("has-video");
|
||
const wrapper = document.createElement("div");
|
||
wrapper.className = "generation-error";
|
||
const title = document.createElement("strong");
|
||
title.textContent = "视频生成失败";
|
||
const detail = document.createElement("p");
|
||
detail.textContent = message;
|
||
wrapper.append(title, detail);
|
||
el.videoStage.replaceChildren(wrapper);
|
||
updateProgress(0, "任务已失败");
|
||
}
|
||
|
||
function contextualizeGenerationError(message) {
|
||
const text = String(message || "视频生成失败");
|
||
const assetMatch = text.match(/(?:asset-[a-z0-9-]+|mat_[a-z0-9]+)/i);
|
||
if (!assetMatch || !/not found|不存在/i.test(text)) return text;
|
||
const position = referencePosition(assetMatch[0]);
|
||
const label = position ? `角色 ${ROLE_LETTERS[position.roleIndex]} · ${VIEW_LABELS[position.viewIndex]}` : "某张参考图";
|
||
return `${label}在视频接口中不存在。通常表示素材已失效、尚未同步,或不属于当前客户令牌。素材 ID:${assetMatch[0]}`;
|
||
}
|
||
|
||
function resetVideoStage() {
|
||
el.videoStage.classList.remove("has-video");
|
||
el.videoStage.innerHTML = '<div class="mock-frame"><div class="mock-person"></div><span>Seedance 2 正在生成镜头…</span></div>';
|
||
}
|
||
function updateProgress(value, title) {
|
||
const safe = Math.max(0, Math.min(100, Number(value) || 0));
|
||
el.generationTitle.textContent = title;
|
||
el.generationProgress.textContent = `${safe}%`;
|
||
el.progressBar.style.width = `${safe}%`;
|
||
}
|
||
function setAssetStatus(kind, text) { el.assetStatus.className = `status-badge ${kind}`; el.assetStatus.textContent = text; }
|
||
function setVideoStatus(kind, text) { el.videoStatus.className = `status-badge ${kind}`; el.videoStatus.textContent = text; }
|
||
function setStep(active) {
|
||
document.querySelectorAll(".step").forEach((step) => {
|
||
const number = Number(step.dataset.step);
|
||
step.classList.toggle("done", number < active);
|
||
step.classList.toggle("active", number === active);
|
||
});
|
||
}
|
||
function fallbackCopy(text) {
|
||
const area = document.createElement("textarea");
|
||
area.value = text;
|
||
area.style.position = "fixed";
|
||
area.style.opacity = "0";
|
||
document.body.appendChild(area);
|
||
area.select();
|
||
document.execCommand("copy");
|
||
area.remove();
|
||
}
|
||
function normalizeAssetReference(item) {
|
||
const direct = item.asset_url || item.AssetURL || item.assetUrl;
|
||
if (typeof direct === "string" && /^asset:\/\//i.test(direct)) return `asset://${direct.slice(direct.indexOf("//") + 2)}`;
|
||
const id = item.downstream_asset_id || item.asset_id || item.Id || item.id;
|
||
return id ? `asset://${id}` : "";
|
||
}
|
||
function normalizeVideoStatus(value) {
|
||
const status = String(value || "").toLowerCase();
|
||
if (["succeeded", "success", "completed", "done"].includes(status)) return "completed";
|
||
if (["failed", "error", "cancelled", "canceled"].includes(status)) return "failed";
|
||
return status || "processing";
|
||
}
|
||
|
||
function getOrCreateAuditId(storage, key, prefix) {
|
||
try {
|
||
let value = storage.getItem(key);
|
||
if (!value) {
|
||
value = `${prefix}-${crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`}`;
|
||
storage.setItem(key, value);
|
||
}
|
||
return value;
|
||
} catch {
|
||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||
}
|
||
}
|
||
|
||
function setupClientAudit() {
|
||
window.__auditReady = true;
|
||
logClientOperation("page.open", {
|
||
path: location.pathname,
|
||
viewport: `${window.innerWidth}x${window.innerHeight}`,
|
||
referrerOrigin: browserUrlSummary(document.referrer)?.origin || "",
|
||
});
|
||
|
||
document.addEventListener("click", (event) => {
|
||
const target = event.target instanceof Element ? event.target.closest("button, a, [role='button']") : null;
|
||
if (!target) return;
|
||
logClientOperation("ui.click", describeAuditControl(target));
|
||
}, true);
|
||
|
||
document.addEventListener("change", (event) => {
|
||
const target = event.target;
|
||
if (!(target instanceof HTMLInputElement || target instanceof HTMLSelectElement || target instanceof HTMLTextAreaElement)) return;
|
||
logClientOperation("ui.change", describeAuditControl(target));
|
||
}, true);
|
||
|
||
document.addEventListener("input", (event) => {
|
||
const target = event.target;
|
||
if (!(target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement)) return;
|
||
const key = target.id || target.name || target.getAttribute("aria-label") || "anonymous-input";
|
||
clearTimeout(auditInputTimers.get(key));
|
||
auditInputTimers.set(key, setTimeout(() => {
|
||
logClientOperation("ui.input", describeAuditControl(target));
|
||
auditInputTimers.delete(key);
|
||
}, 900));
|
||
}, true);
|
||
|
||
document.addEventListener("submit", (event) => {
|
||
logClientOperation("ui.submit", { formId: event.target?.id || "", fieldCount: event.target?.elements?.length || 0 });
|
||
}, true);
|
||
|
||
document.addEventListener("play", (event) => {
|
||
if (event.target instanceof HTMLVideoElement) logClientOperation("video.play", { source: browserUrlSummary(event.target.currentSrc) });
|
||
}, true);
|
||
document.addEventListener("pause", (event) => {
|
||
if (event.target instanceof HTMLVideoElement) logClientOperation("video.pause", { currentTime: Math.round(event.target.currentTime * 10) / 10 });
|
||
}, true);
|
||
|
||
document.addEventListener("visibilitychange", () => {
|
||
logClientOperation("page.visibility", { state: document.visibilityState });
|
||
if (document.visibilityState === "hidden") flushAuditQueue(true);
|
||
});
|
||
window.addEventListener("pagehide", () => {
|
||
logClientOperation("page.close", { persisted: false });
|
||
flushAuditQueue(true);
|
||
});
|
||
}
|
||
|
||
function describeAuditControl(target) {
|
||
const value = auditControlValue(target);
|
||
return {
|
||
elementId: target.id || "",
|
||
tag: target.tagName?.toLowerCase() || "",
|
||
className: String(target.className || "").slice(0, 180),
|
||
type: target.type || "",
|
||
ariaLabel: String(target.getAttribute?.("aria-label") || "").slice(0, 120),
|
||
title: String(target.getAttribute?.("title") || "").slice(0, 120),
|
||
moduleTarget: target.dataset?.module || "",
|
||
step: target.dataset?.step || "",
|
||
removeMedia: target.dataset?.removeMedia || "",
|
||
textLength: String(target.textContent || "").trim().length,
|
||
href: target instanceof HTMLAnchorElement ? browserUrlSummary(target.href) : null,
|
||
value,
|
||
};
|
||
}
|
||
|
||
function auditControlValue(target) {
|
||
if (target instanceof HTMLInputElement && ["checkbox", "radio"].includes(target.type)) return { checked: target.checked };
|
||
if (target instanceof HTMLSelectElement) return { selected: String(target.value).slice(0, 120) };
|
||
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
|
||
const value = String(target.value || "");
|
||
if (target.type === "url") return browserUrlSummary(value);
|
||
return { length: value.length, lines: value ? value.split(/\r?\n/).length : 0 };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function browserUrlSummary(value) {
|
||
if (!value) return null;
|
||
try {
|
||
const url = new URL(value, location.href);
|
||
return { origin: url.origin, pathname: url.pathname.slice(0, 300), hasQuery: Boolean(url.search) };
|
||
} catch {
|
||
return { valid: false, length: String(value).length };
|
||
}
|
||
}
|
||
|
||
function logClientOperation(operation, details = {}) {
|
||
auditQueue.push({
|
||
operation,
|
||
timestamp: new Date().toISOString(),
|
||
module: state.activeModule,
|
||
shotId: state.activeShotId || "",
|
||
details,
|
||
});
|
||
if (auditQueue.length >= 20) flushAuditQueue();
|
||
else if (!auditFlushTimer) auditFlushTimer = setTimeout(() => flushAuditQueue(), 1800);
|
||
}
|
||
|
||
function flushAuditQueue(useBeacon = false) {
|
||
clearTimeout(auditFlushTimer);
|
||
auditFlushTimer = null;
|
||
if (!auditQueue.length) return;
|
||
const events = auditQueue.splice(0, 50);
|
||
const payload = JSON.stringify({ clientId: auditClientId, sessionId: auditSessionId, events });
|
||
const endpoint = `${API_BASE}/api/events`;
|
||
if (useBeacon && navigator.sendBeacon) {
|
||
navigator.sendBeacon(endpoint, new Blob([payload], { type: "application/json" }));
|
||
return;
|
||
}
|
||
fetch(endpoint, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", "X-Session-Id": auditSessionId },
|
||
body: payload,
|
||
keepalive: true,
|
||
}).catch(() => {
|
||
auditQueue.unshift(...events.slice(-50));
|
||
});
|
||
}
|
||
|
||
function shouldLogClientApi(path) {
|
||
return !/^\/api\/(?:videos|assets\/tasks)\/[^/]+$/.test(path);
|
||
}
|
||
|
||
async function api(path, options = {}) {
|
||
const startedAt = Date.now();
|
||
const method = options.method || "GET";
|
||
const shouldLog = shouldLogClientApi(path);
|
||
const videoKey = currentVideoKey();
|
||
const videoHeaders = videoKey && (/^\/api\/(?:videos|assets)(?:[/?]|$)/.test(path) || path === "/api/config")
|
||
? { "X-Video-Key": videoKey }
|
||
: {};
|
||
if (shouldLog) logClientOperation("api.request", { method, path });
|
||
try {
|
||
const response = await fetch(`${API_BASE}${path}`, {
|
||
...options,
|
||
headers: { "Content-Type": "application/json", "X-Session-Id": auditSessionId, ...videoHeaders, ...(options.headers || {}) },
|
||
});
|
||
const result = await response.json().catch(() => ({ error: "服务返回了无法解析的内容" }));
|
||
if (shouldLog || !response.ok) {
|
||
logClientOperation("api.response", {
|
||
method,
|
||
path,
|
||
status: response.status,
|
||
durationMs: Date.now() - startedAt,
|
||
requestId: response.headers.get("x-request-id") || "",
|
||
});
|
||
}
|
||
if (!response.ok) {
|
||
const error = new Error(extractApiError(result) || `请求失败(${response.status})`);
|
||
error.payload = result;
|
||
error.status = response.status;
|
||
if (response.status === 401 && /^\/api\/(?:videos|assets)(?:[/?]|$)/.test(path)) {
|
||
localStorage.removeItem(VIDEO_KEY_STORAGE);
|
||
showVideoKeyGate(false);
|
||
}
|
||
throw error;
|
||
}
|
||
return result;
|
||
} catch (error) {
|
||
if (shouldLog) logClientOperation("api.network_error", { method, path, durationMs: Date.now() - startedAt, message: String(error.message || error).slice(0, 500) });
|
||
throw error;
|
||
}
|
||
}
|
||
function extractApiError(value) {
|
||
if (!value) return "";
|
||
if (typeof value === "string") {
|
||
try { return extractApiError(JSON.parse(value)) || value; } catch { return value; }
|
||
}
|
||
if (typeof value !== "object") return String(value);
|
||
return extractApiError(value.error?.message) || extractApiError(value.error) || extractApiError(value.message) || extractApiError(value.detail);
|
||
}
|
||
function showToast(message, error = false) {
|
||
el.toast.textContent = message;
|
||
el.toast.className = `toast${error ? " error" : ""}`;
|
||
clearTimeout(showToast.timer);
|
||
showToast.timer = setTimeout(() => el.toast.classList.add("hidden"), 4200);
|
||
}
|
||
function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
|
||
function escapeHtml(value) { return String(value).replace(/[&<>'"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ }[char])); }
|
||
function fileNameFromUrl(value) {
|
||
try { return decodeURIComponent(new URL(value).pathname.split("/").filter(Boolean).pop() || ""); } catch { return ""; }
|
||
}
|