Files
yingxiangli-duanju/public/app.js
2026-07-30 09:12:42 +08:00

1764 lines
73 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
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 = "请输入正确格式的生视频 Keysk-...";
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) {
el.assetList.innerHTML = `<div class="empty-small">${escapeHtml(error.message)}</div>`;
}
}
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 ? "缺少视频 Key" : !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_/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_/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 = `更换视频 Key · ${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 = "请输入视频 Key";
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.src = videoUrl;
el.videoStage.replaceChildren(video);
} else {
el.videoStage.innerHTML = `<div class="mock-frame"><div class="mock-person"></div><strong>${mock ? "Mock 流程已完成" : "任务已完成"}</strong><span>${mock ? "接入视频 Key 后此处展示真实视频" : "接口响应中未识别到视频 URL请查看原始返回"}</span></div>`;
}
updateProgress(100, "视频生成完成");
}
function renderGenerationError(message) {
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}在视频接口中不存在。通常表示素材库 Token 与视频 Key 不属于同一资源空间,或素材尚未同步到视频侧。素材 ID${assetMatch[0]}`;
}
function resetVideoStage() { 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(?:\/|$)/.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(?:\/|$)/.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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "'": "&#39;", '"': "&quot;" }[char])); }
function fileNameFromUrl(value) {
try { return decodeURIComponent(new URL(value).pathname.split("/").filter(Boolean).pop() || ""); } catch { return ""; }
}