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_GENERIC_ASSET_KEY = "yingxiangli.pendingGenericAssetReview.v1";
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: [],
genericAssets: [],
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",
"genericAssetForm", "genericAssetType", "genericAssetUrl", "genericAssetPurpose", "genericAssetSubmit", "genericAssetStatus",
"genericAssetHint", "genericAssetResult", "genericAssetResultText", "genericAssetList", "genericAssetHistoryCount", "refreshGenericAssets",
].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();
updateGenericAssetForm();
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();
resumePendingGenericAsset();
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 images = [
{ view: "front", url: el.assetFrontUrl.value.trim() },
{ view: "side", url: el.assetSideUrl.value.trim() },
{ view: "back", url: el.assetBackUrl.value.trim() },
].filter((item) => item.url);
if (!images.length) return showToast("正面、侧面、背面请至少填写一张", true);
const role = activeRole();
role.name = el.assetName.value.trim() || `角色 ${ROLE_LETTERS[state.activeRoleIndex]}`;
setAssetStatus("processing", "审核中");
state.reviewStartedAt = Date.now();
preparePersonReviewRows(images);
updateReviewElapsed();
el.reviewButton.disabled = true;
try {
let result = await api("/api/assets/review", {
method: "POST",
body: JSON.stringify({
name: role.name,
images,
}),
});
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.genericAssetForm.addEventListener("submit", submitGenericAsset);
el.genericAssetType.addEventListener("change", updateGenericAssetForm);
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.refreshGenericAssets.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.filter(isPersonImageAsset);
state.genericAssets = items.filter(isGenericAsset);
ingestHistoricalCharacters(state.assets);
renderCharacterLibrary();
renderAssetPicker();
renderGenericAssetLibrary();
el.assetHistoryCount.textContent = String(state.assets.length);
el.assetList.innerHTML = "";
if (!state.assets.length) {
el.assetList.innerHTML = '
素材库暂无人物图片素材
';
return;
}
for (const item of state.assets) {
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 = `${escapeHtml(name)}${escapeHtml(reference)}`;
button.appendChild(info);
button.addEventListener("click", () => toggleHistoricalAsset(reference));
el.assetList.appendChild(button);
}
} catch (error) {
const message = /unauthorized|invalid token/i.test(String(error.message || ""))
? "客户令牌无效或已过期,请重新输入"
: error.message;
el.assetList.innerHTML = `${escapeHtml(message)}
`;
el.genericAssetList.innerHTML = `${escapeHtml(message)}
`;
if (!state.config?.mockMode) {
el.connectionState.textContent = "素材库连接失败";
el.connectionState.className = "connection blocked";
setAssetStatus("error", "素材库不可用");
}
}
}
function assetItemType(item) {
return String(item?.type || item?.asset_type || item?.AssetType || "image").toLowerCase();
}
function isPersonImageAsset(item) {
if (assetItemType(item) !== "image") return false;
const purpose = String(item?.purpose || item?.Name || "").trim();
return /\s*[·・]\s*(正面|侧面|背面)$/.test(purpose);
}
function isGenericAsset(item) {
if (assetItemType(item) === "video") return true;
return assetItemType(item) === "image" && !isPersonImageAsset(item);
}
function genericAssetName(item, sourceUrl) {
const purpose = String(item.purpose || item.Name || "");
if (/^数字人形象\s*[·・]\s*参考图\d+$/.test(purpose)) {
return fileNameFromUrl(sourceUrl) || "未命名图片素材";
}
return purpose.replace(/^通用图片\s*[·・]\s*/, "") || fileNameFromUrl(sourceUrl) || "未命名通用素材";
}
function renderGenericAssetLibrary() {
el.genericAssetHistoryCount.textContent = String(state.genericAssets.length);
el.genericAssetList.innerHTML = "";
if (!state.genericAssets.length) {
el.genericAssetList.innerHTML = '暂无已就绪通用素材
';
return;
}
for (const item of state.genericAssets) {
const type = assetItemType(item);
const reference = normalizeAssetReference(item);
const sourceUrl = item.original_url || item.URL || "";
const name = genericAssetName(item, sourceUrl);
const card = document.createElement("article");
card.className = "video-asset-card";
const preview = document.createElement("div");
preview.className = "video-asset-preview";
if (sourceUrl && type === "video") {
const video = document.createElement("video");
video.src = sourceUrl;
video.controls = true;
video.playsInline = true;
video.preload = "metadata";
preview.appendChild(video);
} else if (sourceUrl) {
const image = document.createElement("img");
image.src = sourceUrl;
image.alt = name;
image.loading = "lazy";
preview.appendChild(image);
} else preview.textContent = "暂无素材预览";
const copy = document.createElement("div");
copy.className = "video-asset-copy";
copy.innerHTML = `${escapeHtml(name)}${type === "video" ? "视频" : "图片"} · ${escapeHtml(reference)}`;
const actions = document.createElement("div");
actions.className = "video-asset-actions";
const useButton = document.createElement("button");
useButton.type = "button";
useButton.textContent = "用于当前片段";
useButton.addEventListener("click", () => useGenericAssetInActiveShot(reference, name, type));
const copyButton = document.createElement("button");
copyButton.type = "button";
copyButton.textContent = "复制素材引用";
copyButton.addEventListener("click", async () => {
try { await navigator.clipboard.writeText(reference); } catch { fallbackCopy(reference); }
showToast("素材引用已复制");
});
actions.append(useButton, copyButton);
card.append(preview, copy, actions);
el.genericAssetList.appendChild(card);
}
}
function useGenericAssetInActiveShot(reference, name, type) {
const shot = activeShot();
if (type === "video") {
const current = parseMediaLines(shot.referenceVideoUrls);
if (!current.includes(reference) && current.length >= 3) return showToast("当前片段最多使用 3 条参考视频", true);
shot.referenceVideoUrls = [...new Set([...current, reference])].join("\n");
} else {
const current = parseMediaLines(shot.extraImageUrls);
const occupied = sceneReferences().length + current.length;
if (!current.includes(reference) && occupied >= 9) return showToast("人物图片和其他参考图片合计最多 9 张", true);
shot.extraImageUrls = [...new Set([...current, reference])].join("\n");
}
saveDramaProject();
switchModule("production");
loadActiveShotIntoForm();
renderStoryboard();
showToast(`已将「${name}」用于当前片段`);
}
function updateGenericAssetForm() {
const isVideo = el.genericAssetType.value === "video";
el.genericAssetUrl.placeholder = isVideo ? "https://example.com/reference.mp4" : "https://example.com/reference.jpg";
el.genericAssetHint.textContent = isVideo
? "支持 MP4 / MOV,必须可通过公网直接访问。"
: "支持 JPG / PNG / WebP 等图片格式,必须可通过公网直接访问。";
}
async function submitGenericAsset(event) {
event.preventDefault();
const type = el.genericAssetType.value;
const url = el.genericAssetUrl.value.trim();
const purpose = el.genericAssetPurpose.value.trim();
setGenericAssetStatus("processing", "审核中");
el.genericAssetResult.classList.add("hidden");
el.genericAssetSubmit.disabled = true;
try {
let result = await api("/api/assets/upload", {
method: "POST",
body: JSON.stringify({ type, category: "generic", url, purpose }),
});
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");
savePendingGenericAsset(taskId, purpose, type);
result = await pollAssetTask(taskId, false);
}
await finishGenericAssetUpload(result);
} catch (error) {
setGenericAssetStatus(error.pending ? "processing" : "error", error.pending ? "仍在审核" : "审核失败");
showToast(error.message, !error.pending);
} finally {
el.genericAssetSubmit.disabled = false;
}
}
async function finishGenericAssetUpload(result) {
const items = getReviewItems(result);
if (items.length !== 1) throw new Error(result.message || "素材接口未返回有效结果");
const item = items[0];
if (Number(item.submit_review_status) !== 1) throw new Error(item.error_message || "素材未通过审核");
const reference = normalizeAssetReference(item);
if (!reference) throw new Error("审核通过,但未返回可用的 asset:// ID");
clearPendingGenericAsset();
setGenericAssetStatus("success", "已入库");
el.genericAssetResultText.textContent = `素材引用:${reference}`;
el.genericAssetResult.classList.remove("hidden");
el.genericAssetForm.reset();
updateGenericAssetForm();
await loadAssets();
showToast("通用素材已通过审核并入库");
}
function savePendingGenericAsset(taskId, purpose, type) {
localStorage.setItem(PENDING_GENERIC_ASSET_KEY, JSON.stringify({ taskId, purpose, type, startedAt: Date.now() }));
}
function clearPendingGenericAsset() { localStorage.removeItem(PENDING_GENERIC_ASSET_KEY); }
async function resumePendingGenericAsset() {
let pending;
try { pending = JSON.parse(localStorage.getItem(PENDING_GENERIC_ASSET_KEY) || "null"); } catch { pending = null; }
if (!pending?.taskId) return;
setGenericAssetStatus("processing", "继续查询");
try {
const result = await pollAssetTask(pending.taskId, false);
await finishGenericAssetUpload(result);
} catch (error) {
setGenericAssetStatus(error.pending ? "processing" : "error", error.pending ? "仍在审核" : "审核失败");
if (!error.pending) clearPendingGenericAsset();
}
}
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 = '暂无已组合人物,可从历史素材新建
';
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 = `${escapeHtml(character.name)}${character.references.length} 张人物素材`;
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];
role.referenceViews = [];
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", "generic-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) {
const seedDuration = Number(seed.duration);
const duration = Number.isFinite(seedDuration) ? Math.min(14, Math.max(5, Math.round(seedDuration))) : 5;
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,
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 = `${state.shots.length > 1 ? `` : ""}`;
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 = '生成当前片段后,候选视频会出现在这里
';
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";
setVersionPreviewRatio(preview, version.ratio || shot.ratio);
if (version.videoUrl) {
const video = document.createElement("video");
video.src = version.videoUrl;
video.controls = true;
video.playsInline = true;
video.preload = "metadata";
video.addEventListener("loadedmetadata", () => {
if (video.videoWidth && video.videoHeight) setVersionPreviewRatio(preview, `${video.videoWidth}:${video.videoHeight}`);
}, { once: true });
preview.appendChild(video);
} else preview.textContent = version.status === "running" ? "生成中" : version.status === "failed" ? "生成失败" : "暂无预览";
const details = document.createElement("div");
details.className = "version-copy";
details.innerHTML = `候选 ${number} · ${version.status === "completed" ? "已完成" : version.status === "running" ? "生成中" : "失败"}${escapeHtml(version.taskId || version.error || "等待任务 ID")}`;
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 setVersionPreviewRatio(preview, ratio) {
const [width, height] = String(ratio || "16:9").split(":").map(Number);
const safeWidth = width > 0 ? width : 16;
const safeHeight = height > 0 ? height : 9;
preview.style.setProperty("--preview-ratio", `${safeWidth} / ${safeHeight}`);
preview.classList.toggle("portrait", safeHeight > safeWidth);
preview.classList.toggle("square", safeHeight === safeWidth);
}
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 = `${String(index + 1).padStart(2, "0")}${escapeHtml(shot.title)}${version ? `${shot.duration} 秒 · 已选版本` : "待选版本"}`;
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 = '暂无历史素材
';
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 = `${ROLE_LETTERS[index]}${escapeHtml(role.name)}${role.references.length} 张人物素材`;
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 view = ["front", "side", "back"][index];
const hasViewMap = Array.isArray(role.referenceViews) && role.referenceViews.length > 0;
const mappedIndex = hasViewMap ? role.referenceViews.indexOf(view) : index;
const reference = mappedIndex >= 0 ? role.references[mappedIndex] : "";
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 = `${VIEW_LABELS[viewIndex] || `素材${viewIndex + 1}`}${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: [], referenceViews: [] };
}
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: "公网 MP4/MOV 或 asset://mat..." }
: { 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 = "text";
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))
: [],
referenceViews: Array.isArray(role.referenceViews) ? role.referenceViews.slice(0, 3).map(String) : [],
}));
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 < 1 || items.length > 3) throw new Error(result.message || `预期返回 1~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;
state.roles[roleIndex].referenceViews = items.map((item) => String(item.view || "reference").toLowerCase());
upsertCharacter(name, references, "history", true);
el.reviewElapsed.textContent = "审核完成";
await loadAssets();
renderScene();
showToast(`角色 ${ROLE_LETTERS[roleIndex]} 的 ${references.length} 张人物素材已全部通过审核`);
}
async function pollAssetTask(taskId, renderItems = true) {
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 (renderItems && 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) => {
const reviewStatus = item.submit_review_status;
return reviewStatus !== null && reviewStatus !== undefined && reviewStatus !== "" && [0, 1].includes(Number(reviewStatus));
})) return "completed";
return "processing";
}
function renderReviewItems(items) {
items.slice(0, 3).forEach((item, fallbackIndex) => {
const index = { front: 0, side: 1, back: 2 }[String(item.view || "").toLowerCase()] ?? fallbackIndex;
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 preparePersonReviewRows(images) {
reviewRows.forEach((_, index) => setReviewRow(index, "neutral", "未上传", ""));
images.forEach((item, fallbackIndex) => {
const index = { front: 0, side: 1, back: 2 }[item.view] ?? fallbackIndex;
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 = `${mock ? "Mock 流程已完成" : "任务已完成"}${mock ? "输入客户令牌后此处展示真实视频" : "接口响应中未识别到视频 URL,请稍后重试"} `;
}
updateProgress(100, "视频生成完成");
}
function renderGenerationError(message) {
el.videoStage.classList.remove("has-video");
const wrapper = document.createElement("div");
wrapper.className = "generation-error";
const title = document.createElement("strong");
title.textContent = "视频生成失败";
const detail = document.createElement("p");
detail.textContent = message;
wrapper.append(title, detail);
el.videoStage.replaceChildren(wrapper);
updateProgress(0, "任务已失败");
}
function contextualizeGenerationError(message) {
const text = String(message || "视频生成失败");
const assetMatch = text.match(/(?:asset-[a-z0-9-]+|mat_[a-z0-9]+)/i);
if (!assetMatch || !/not found|不存在/i.test(text)) return text;
const position = referencePosition(assetMatch[0]);
const label = position ? `角色 ${ROLE_LETTERS[position.roleIndex]} · ${VIEW_LABELS[position.viewIndex]}` : "某张参考图";
return `${label}在视频接口中不存在。通常表示素材已失效、尚未同步,或不属于当前客户令牌。素材 ID:${assetMatch[0]}`;
}
function resetVideoStage() {
el.videoStage.classList.remove("has-video");
el.videoStage.innerHTML = '';
}
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 setGenericAssetStatus(kind, text) { el.genericAssetStatus.className = `status-badge ${kind}`; el.genericAssetStatus.textContent = text; }
function setVideoStatus(kind, text) { el.videoStatus.className = `status-badge ${kind}`; el.videoStatus.textContent = text; }
function setStep(active) {
document.querySelectorAll(".step").forEach((step) => {
const number = Number(step.dataset.step);
step.classList.toggle("done", number < active);
step.classList.toggle("active", number === active);
});
}
function fallbackCopy(text) {
const area = document.createElement("textarea");
area.value = text;
area.style.position = "fixed";
area.style.opacity = "0";
document.body.appendChild(area);
area.select();
document.execCommand("copy");
area.remove();
}
function normalizeAssetReference(item) {
const direct = item.asset_url || item.AssetURL || item.assetUrl;
if (typeof direct === "string" && /^asset:\/\//i.test(direct)) return `asset://${direct.slice(direct.indexOf("//") + 2)}`;
const id = item.downstream_asset_id || item.asset_id || item.Id || item.id;
return id ? `asset://${id}` : "";
}
function normalizeVideoStatus(value) {
const status = String(value || "").toLowerCase();
if (["succeeded", "success", "completed", "done"].includes(status)) return "completed";
if (["failed", "error", "cancelled", "canceled"].includes(status)) return "failed";
return status || "processing";
}
function getOrCreateAuditId(storage, key, prefix) {
try {
let value = storage.getItem(key);
if (!value) {
value = `${prefix}-${crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`}`;
storage.setItem(key, value);
}
return value;
} catch {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
}
function setupClientAudit() {
window.__auditReady = true;
logClientOperation("page.open", {
path: location.pathname,
viewport: `${window.innerWidth}x${window.innerHeight}`,
referrerOrigin: browserUrlSummary(document.referrer)?.origin || "",
});
document.addEventListener("click", (event) => {
const target = event.target instanceof Element ? event.target.closest("button, a, [role='button']") : null;
if (!target) return;
logClientOperation("ui.click", describeAuditControl(target));
}, true);
document.addEventListener("change", (event) => {
const target = event.target;
if (!(target instanceof HTMLInputElement || target instanceof HTMLSelectElement || target instanceof HTMLTextAreaElement)) return;
logClientOperation("ui.change", describeAuditControl(target));
}, true);
document.addEventListener("input", (event) => {
const target = event.target;
if (!(target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement)) return;
const key = target.id || target.name || target.getAttribute("aria-label") || "anonymous-input";
clearTimeout(auditInputTimers.get(key));
auditInputTimers.set(key, setTimeout(() => {
logClientOperation("ui.input", describeAuditControl(target));
auditInputTimers.delete(key);
}, 900));
}, true);
document.addEventListener("submit", (event) => {
logClientOperation("ui.submit", { formId: event.target?.id || "", fieldCount: event.target?.elements?.length || 0 });
}, true);
document.addEventListener("play", (event) => {
if (event.target instanceof HTMLVideoElement) logClientOperation("video.play", { source: browserUrlSummary(event.target.currentSrc) });
}, true);
document.addEventListener("pause", (event) => {
if (event.target instanceof HTMLVideoElement) logClientOperation("video.pause", { currentTime: Math.round(event.target.currentTime * 10) / 10 });
}, true);
document.addEventListener("visibilitychange", () => {
logClientOperation("page.visibility", { state: document.visibilityState });
if (document.visibilityState === "hidden") flushAuditQueue(true);
});
window.addEventListener("pagehide", () => {
logClientOperation("page.close", { persisted: false });
flushAuditQueue(true);
});
}
function describeAuditControl(target) {
const value = auditControlValue(target);
return {
elementId: target.id || "",
tag: target.tagName?.toLowerCase() || "",
className: String(target.className || "").slice(0, 180),
type: target.type || "",
ariaLabel: String(target.getAttribute?.("aria-label") || "").slice(0, 120),
title: String(target.getAttribute?.("title") || "").slice(0, 120),
moduleTarget: target.dataset?.module || "",
step: target.dataset?.step || "",
removeMedia: target.dataset?.removeMedia || "",
textLength: String(target.textContent || "").trim().length,
href: target instanceof HTMLAnchorElement ? browserUrlSummary(target.href) : null,
value,
};
}
function auditControlValue(target) {
if (target instanceof HTMLInputElement && ["checkbox", "radio"].includes(target.type)) return { checked: target.checked };
if (target instanceof HTMLSelectElement) return { selected: String(target.value).slice(0, 120) };
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
const value = String(target.value || "");
if (target.type === "url") return browserUrlSummary(value);
return { length: value.length, lines: value ? value.split(/\r?\n/).length : 0 };
}
return null;
}
function browserUrlSummary(value) {
if (!value) return null;
try {
const url = new URL(value, location.href);
return { origin: url.origin, pathname: url.pathname.slice(0, 300), hasQuery: Boolean(url.search) };
} catch {
return { valid: false, length: String(value).length };
}
}
function logClientOperation(operation, details = {}) {
auditQueue.push({
operation,
timestamp: new Date().toISOString(),
module: state.activeModule,
shotId: state.activeShotId || "",
details,
});
if (auditQueue.length >= 20) flushAuditQueue();
else if (!auditFlushTimer) auditFlushTimer = setTimeout(() => flushAuditQueue(), 1800);
}
function flushAuditQueue(useBeacon = false) {
clearTimeout(auditFlushTimer);
auditFlushTimer = null;
if (!auditQueue.length) return;
const events = auditQueue.splice(0, 50);
const payload = JSON.stringify({ clientId: auditClientId, sessionId: auditSessionId, events });
const endpoint = `${API_BASE}/api/events`;
if (useBeacon && navigator.sendBeacon) {
navigator.sendBeacon(endpoint, new Blob([payload], { type: "application/json" }));
return;
}
fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Session-Id": auditSessionId },
body: payload,
keepalive: true,
}).catch(() => {
auditQueue.unshift(...events.slice(-50));
});
}
function shouldLogClientApi(path) {
return !/^\/api\/(?:videos|assets\/tasks)\/[^/]+$/.test(path);
}
async function api(path, options = {}) {
const startedAt = Date.now();
const method = options.method || "GET";
const shouldLog = shouldLogClientApi(path);
const videoKey = currentVideoKey();
const videoHeaders = videoKey && (/^\/api\/(?:videos|assets)(?:[/?]|$)/.test(path) || path === "/api/config")
? { "X-Video-Key": videoKey }
: {};
if (shouldLog) logClientOperation("api.request", { method, path });
try {
const response = await fetch(`${API_BASE}${path}`, {
...options,
headers: { "Content-Type": "application/json", "X-Session-Id": auditSessionId, ...videoHeaders, ...(options.headers || {}) },
});
const result = await response.json().catch(() => ({ error: "服务返回了无法解析的内容" }));
if (shouldLog || !response.ok) {
logClientOperation("api.response", {
method,
path,
status: response.status,
durationMs: Date.now() - startedAt,
requestId: response.headers.get("x-request-id") || "",
});
}
if (!response.ok) {
const error = new Error(extractApiError(result) || `请求失败(${response.status})`);
error.payload = result;
error.status = response.status;
if (response.status === 401 && /^\/api\/(?:videos|assets)(?:[/?]|$)/.test(path)) {
localStorage.removeItem(VIDEO_KEY_STORAGE);
showVideoKeyGate(false);
}
throw error;
}
return result;
} catch (error) {
if (shouldLog) logClientOperation("api.network_error", { method, path, durationMs: Date.now() - startedAt, message: String(error.message || error).slice(0, 500) });
throw error;
}
}
function extractApiError(value) {
if (!value) return "";
if (typeof value === "string") {
try { return extractApiError(JSON.parse(value)) || value; } catch { return value; }
}
if (typeof value !== "object") return String(value);
return extractApiError(value.error?.message) || extractApiError(value.error) || extractApiError(value.message) || extractApiError(value.detail);
}
function showToast(message, error = false) {
el.toast.textContent = message;
el.toast.className = `toast${error ? " error" : ""}`;
clearTimeout(showToast.timer);
showToast.timer = setTimeout(() => el.toast.classList.add("hidden"), 4200);
}
function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
function escapeHtml(value) { return String(value).replace(/[&<>'"]/g, (char) => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ }[char])); }
function fileNameFromUrl(value) {
try { return decodeURIComponent(new URL(value).pathname.split("/").filter(Boolean).pop() || ""); } catch { return ""; }
}