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, ratio: shot.ratio, }; 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 = '
${escapeHtml(reference)}`;
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 = `