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_ASSET_KEY = "yingxiangli.pendingVideoAssetReview.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: [], videoAssets: [], 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", "videoAssetForm", "videoAssetUrl", "videoAssetPurpose", "videoAssetSubmit", "videoAssetStatus", "videoAssetResult", "videoAssetResultText", "videoAssetList", "videoAssetHistoryCount", "refreshVideoAssets", ].map((id) => [id, document.getElementById(id)])); const reviewRows = [el.reviewFront, el.reviewSide, el.reviewBack]; const auditClientId = getOrCreateAuditId(localStorage, AUDIT_CLIENT_KEY, "client"); const auditSessionId = getOrCreateAuditId(sessionStorage, AUDIT_SESSION_KEY, "session"); const auditQueue = []; const auditInputTimers = new Map(); let auditFlushTimer = null; setupClientAudit(); setupVideoKeyGate(); init(); document.querySelectorAll(".module-tab").forEach((button) => { button.addEventListener("click", () => switchModule(button.dataset.module)); }); async function init() { try { state.config = await api("/api/config"); restoreSceneRoles(); restoreCharacterLibrary(); restoreDramaProject(); renderConnection(); if (!state.config.mockMode && !currentVideoKey()) { showVideoKeyGate(false); return; } await loadAssets(); loadActiveShotIntoForm(); renderScene(); renderStoryboard(); resumePendingReview(); resumePendingVideoAsset(); 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.videoAssetForm.addEventListener("submit", submitVideoAsset); 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.refreshVideoAssets.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((item) => assetItemType(item) !== "video"); state.videoAssets = items.filter((item) => assetItemType(item) === "video"); ingestHistoricalCharacters(state.assets); renderCharacterLibrary(); renderAssetPicker(); renderVideoAssetLibrary(); 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.videoAssetList.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 renderVideoAssetLibrary() { el.videoAssetHistoryCount.textContent = String(state.videoAssets.length); el.videoAssetList.innerHTML = ""; if (!state.videoAssets.length) { el.videoAssetList.innerHTML = '
暂无已就绪视频素材
'; return; } for (const item of state.videoAssets) { const reference = normalizeAssetReference(item); const videoUrl = item.original_url || item.URL || ""; const name = item.purpose || item.Name || fileNameFromUrl(videoUrl) || "未命名视频素材"; const card = document.createElement("article"); card.className = "video-asset-card"; const preview = document.createElement("div"); preview.className = "video-asset-preview"; if (videoUrl) { const video = document.createElement("video"); video.src = videoUrl; video.controls = true; video.playsInline = true; video.preload = "metadata"; preview.appendChild(video); } else preview.textContent = "暂无视频预览"; const copy = document.createElement("div"); copy.className = "video-asset-copy"; copy.innerHTML = `${escapeHtml(name)}${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", () => useVideoAssetInActiveShot(reference, name)); 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.videoAssetList.appendChild(card); } } function useVideoAssetInActiveShot(reference, name) { const shot = activeShot(); const current = parseMediaLines(shot.referenceVideoUrls); if (!current.includes(reference) && current.length >= 3) return showToast("当前片段最多使用 3 条参考视频", true); shot.referenceVideoUrls = [...new Set([...current, reference])].join("\n"); saveDramaProject(); switchModule("production"); loadActiveShotIntoForm(); renderStoryboard(); showToast(`已将「${name}」用于当前片段`); } async function submitVideoAsset(event) { event.preventDefault(); const url = el.videoAssetUrl.value.trim(); const purpose = el.videoAssetPurpose.value.trim(); setVideoAssetStatus("processing", "审核中"); el.videoAssetResult.classList.add("hidden"); el.videoAssetSubmit.disabled = true; try { let result = await api("/api/assets/upload", { method: "POST", body: JSON.stringify({ type: "video", 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"); savePendingVideoAsset(taskId, purpose); result = await pollAssetTask(taskId, false); } await finishVideoAssetUpload(result); } catch (error) { setVideoAssetStatus(error.pending ? "processing" : "error", error.pending ? "仍在审核" : "审核失败"); showToast(error.message, !error.pending); } finally { el.videoAssetSubmit.disabled = false; } } async function finishVideoAssetUpload(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"); clearPendingVideoAsset(); setVideoAssetStatus("success", "已入库"); el.videoAssetResultText.textContent = `素材引用:${reference}`; el.videoAssetResult.classList.remove("hidden"); el.videoAssetForm.reset(); await loadAssets(); showToast("视频素材已通过审核并入库"); } function savePendingVideoAsset(taskId, purpose) { localStorage.setItem(PENDING_VIDEO_ASSET_KEY, JSON.stringify({ taskId, purpose, startedAt: Date.now() })); } function clearPendingVideoAsset() { localStorage.removeItem(PENDING_VIDEO_ASSET_KEY); } async function resumePendingVideoAsset() { let pending; try { pending = JSON.parse(localStorage.getItem(PENDING_VIDEO_ASSET_KEY) || "null"); } catch { pending = null; } if (!pending?.taskId) return; setVideoAssetStatus("processing", "继续查询"); try { const result = await pollAssetTask(pending.taskId, false); await finishVideoAssetUpload(result); } catch (error) { setVideoAssetStatus(error.pending ? "processing" : "error", error.pending ? "仍在审核" : "审核失败"); if (!error.pending) clearPendingVideoAsset(); } } 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", "video-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 = type === "video" ? "text" : "url"; input.value = value; input.placeholder = placeholder; input.setAttribute("aria-label", type === "video" ? "参考视频链接" : "其他参考图片链接"); const remove = document.createElement("button"); remove.type = "button"; remove.className = "media-remove-button"; remove.dataset.removeMedia = type; remove.setAttribute("aria-label", "删除该链接"); remove.textContent = "×"; row.append(input, remove); return row; } function renderMediaInputList(type, value) { const { list } = mediaInputConfig(type); const values = parseMediaLines(value); list.replaceChildren(...(values.length ? values : [""]).map((item) => createMediaInputRow(type, item))); refreshMediaAddButton(type); } function collectMediaInputUrls(type) { const { list } = mediaInputConfig(type); return [...new Set([...list.querySelectorAll("input")].map((input) => input.value.trim()).filter(Boolean))]; } function addMediaInput(type) { const { list, limit } = mediaInputConfig(type); if (list.children.length >= limit) return; const row = createMediaInputRow(type); list.append(row); row.querySelector("input").focus(); refreshMediaAddButton(type); } function removeMediaInput(button) { const type = button.dataset.removeMedia; const { list } = mediaInputConfig(type); if (list.children.length === 1) { list.querySelector("input").value = ""; } else { button.closest(".media-url-row").remove(); } refreshMediaAddButton(type); updateActiveShotFromForm(); updateVideoAvailability(); } function refreshMediaAddButton(type) { const { list, button, limit } = mediaInputConfig(type); button.disabled = list.children.length >= limit; } function mediaQuota() { const characterImages = sceneReferences().length; const extraImages = effectiveExtraImageReferences().length; const videos = effectiveVideoReferences().length; const imageTotal = characterImages + extraImages; if (imageTotal > 9) return { valid: false, characterImages, extraImages, videos, imageTotal, message: `图片 ${imageTotal}/9,已超出上限` }; if (videos > 3) return { valid: false, characterImages, extraImages, videos, imageTotal, message: `参考视频 ${videos}/3,已超出 Demo 上限` }; return { valid: true, characterImages, extraImages, videos, imageTotal, message: "可提交" }; } function renderInputQuota() { if (!el.inputQuota) return; const quota = mediaQuota(); el.inputQuota.textContent = `人物图片 ${quota.characterImages} 张 · 其他图片 ${quota.extraImages} 张 · 图片合计 ${quota.imageTotal}/9 · 视频 ${quota.videos}/3`; el.inputQuota.className = `input-quota wide ${quota.valid ? "valid" : "invalid"}`; } function restoreSceneRoles() { let saved; try { saved = JSON.parse(localStorage.getItem(SCENE_ROLES_KEY) || "null"); } catch { saved = null; } if (Array.isArray(saved?.roles) && saved.roles.length) { state.roles = saved.roles.slice(0, 3).map((role, index) => ({ id: role.id || createRole("").id, name: role.name || `角色 ${ROLE_LETTERS[index]}`, references: Array.isArray(role.references) ? role.references.slice(0, 3).map(String).filter((value) => state.config.mockMode || /^asset:\/\/(?:mat_|asset-)/i.test(value)) : [], 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 = '
Seedance 2 正在生成镜头…
'; } 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 setVideoAssetStatus(kind, text) { el.videoAssetStatus.className = `status-badge ${kind}`; el.videoAssetStatus.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 ""; } }