From 1ec2933e70afc10f7d2fa27c32c08da66a0ac0cd Mon Sep 17 00:00:00 2001
From: xuejianwu <374601383@qq.com>
Date: Thu, 30 Jul 2026 08:58:38 +0800
Subject: [PATCH] feat: deliver AI drama production demo
---
.env.example | 14 +
.gitignore | 10 +
README.md | 91 ++
lib/normalize.mjs | 36 +
package.json | 14 +
public/app.js | 1712 ++++++++++++++++++++++++++++++++++++++
public/index.html | 268 ++++++
public/styles.css | 270 ++++++
server.mjs | 740 ++++++++++++++++
tests/normalize.test.mjs | 26 +
10 files changed, 3181 insertions(+)
create mode 100644 .env.example
create mode 100644 .gitignore
create mode 100644 README.md
create mode 100644 lib/normalize.mjs
create mode 100644 package.json
create mode 100644 public/app.js
create mode 100644 public/index.html
create mode 100644 public/styles.css
create mode 100644 server.mjs
create mode 100644 tests/normalize.test.mjs
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..5779eb5
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,14 @@
+# 快快 AI Hub 统一 Key:素材库与 Seedance 2.0 均使用 Bearer 鉴权
+KK_API_KEY=
+
+# 1 = 使用 Mock 数据评审流程;0 = 调用真实接口
+DEMO_MOCK=1
+
+PORT=4173
+HOST=0.0.0.0
+
+# 审计日志目录;线上请指向持久化磁盘或挂载卷
+AUDIT_LOG_DIR=./logs
+
+# 用于对 IP 等识别信息做不可逆哈希,线上请换成随机字符串
+AUDIT_HASH_SALT=
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..bdc80db
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+.env.local
+.DS_Store
+node_modules/
+logs/
+
+# 仅用于本地设计回归,不属于客户部署包
+design-qa.md
+design-qa*.png
+qa/
+qa-*.jpg
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..1808fec
--- /dev/null
+++ b/README.md
@@ -0,0 +1,91 @@
+# 营响力 AI 短剧数字人 Demo
+
+用于验证“数字人形象先进入快快网络素材库审核,再通过特别版 Seedance 2 生成短剧视频”的完整链路。
+
+页面拆分为两个一级模块:
+
+- **人物素材库**:负责素材入库、审核、历史素材查看与人物组合,是长期资产管理区。
+- **短剧生产**:以一个短剧项目管理多个片段;每段独立配置出场人物、提示词、图片、视频、连续性和生成参数。
+
+在人物素材库点击“用于短剧生产”,会把该人物绑定到当前场景角色并自动进入生产模块。
+
+短剧项目支持新增、复制、删除和调整片段顺序。每个片段可以生成多个候选版本,选定的正式版本会作为后续片段的连续性来源,并最终进入拼接清单。连续性模式包括:独立生成、引用上一片段选定视频、引用上一片段尾帧和仅使用手动参考视频。当前接口已支持上一片段视频作为 `reference_video` 自动传入;尾帧抽取和最终 MP4 拼接属于下一步服务端能力,页面会明确显示是否已具备前置条件。
+
+## 已串联的流程
+
+1. 输入数字人正面、侧面、背面三张公网图片 URL。
+2. 逐张调用 `POST /v1/assets/upload`,以统一 API Key 提交三张素材审核。
+3. 对仍在审核的 `mat_*` 素材调用 `GET /v1/assets/{asset_id}`,直至三张全部 `ready` 或出现 `failed`。
+4. 从全部历史素材中选择多张图片,绑定成可复用的人物/形象;三视图只是常见组合,不再是固定的数据结构。
+5. 为本场角色选择人物素材,并补充场景、道具等非人物图片及参考视频,组织成官方 `content` 数组后调用 `POST /sd/api/v3/contents/generations/tasks`。
+6. 用返回的 `id` 调用 `GET /sd/api/v3/contents/generations/tasks/{task_id}`,直至 `succeeded` 或 `failed`。
+
+视频提交后页面每 2.5 秒自动查询一次,最长持续 30 分钟。只有上游明确返回 `failed` 才显示生成失败;30 分钟仍为 `running` 时会保留“仍在生成”状态,不把等待超时误判为失败。任务 ID 会保存在浏览器本地,刷新或重新打开页面后会自动恢复查询。
+
+已完成的候选视频支持“下载视频”和“复制视频链接”。下载直接使用服务商返回的临时签名地址,不占用本 Demo 服务器的视频下载带宽;如果服务商没有返回附件响应头,浏览器可能先打开播放页,用户仍可保存。复制的是本 Demo 域名下以 `.mp4` 结尾的代理链接,可直接作为后续片段的公网参考视频;服务端会根据任务 ID 重新获取服务商的有效地址。
+
+短剧、片段、候选版本、任务 ID 和视频地址保存在当前域名的浏览器 `localStorage` 中。普通刷新不会丢失;清理网站数据、使用无痕模式、更换浏览器/设备/域名会丢失本地项目记录。在没有数据库和对象存储的当前版本中,仍建议用户及时下载成品。
+
+## 不可见审计日志
+
+客户页面不展示请求诊断。页面点击、参数修改、表单提交、视频播放/下载/复制链接、模块与片段切换、前端异常,以及服务端 HTTP 请求、上游调用、任务状态、耗时和错误都会写入 JSONL 日志。
+
+- 默认目录:`./logs/audit-YYYY-MM-DD-PID.jsonl`
+- 生产环境:用 `AUDIT_LOG_DIR` 指向持久化磁盘,并由技术配置日志保留/清理周期
+- 脱敏:不记录 API Key、Authorization、Cookie 和签名查询参数;提示词仅记录长度和哈希;URL 仅记录域名、路径与查询字段名
+- 关联排查:日志保留 `requestId`、浏览器 `clientId/sessionId`、任务 ID、服务商 `trackId`、状态码和耗时
+
+## 10–20 人试用的部署边界
+
+单实例 Node 服务可用于 10–20 人低频试用,但上线必须配置 HTTPS、访问鉴权/限流、进程守护和持久日志目录。所有用户共享同一个服务商 Key,需要另行确认上游 QPS、同时生成任务数和额度。`assetReviewTasks` 当前仍在单进程内存中,因此暂时应保持单实例部署;如果要多实例扩容,需先将任务状态迁入 Redis/数据库。
+
+页面会分别展示正面、侧面、背面的审核状态与失败原因。异步审核任务 ID 会保存在浏览器本地,刷新或重新打开页面后会继续查询;本场角色尚未选齐人物素材时不会解锁视频生成。
+
+人物库会读取全部历史素材,并把名称形如“人物名 · 正面/侧面/背面”的素材自动组合成人物;也可以手动选择 1–9 张历史素材创建人物组合。场景支持 1–3 个角色,每个角色可绑定一组人物素材,并在刷新后恢复本场角色。
+
+生产页支持文本、图片和视频三类输入。人物图片与场景、道具等其他参考图片共同占用单次最多 9 张图片额度;参考视频单独计数,当前 Demo 最多 3 条。最终请求按“文本 → 人物图片 → 其他参考图片 → 参考视频”的顺序写入 v3 `content` 数组,页面会同步生成图号和角色映射。
+
+所有第三方 token 都只放在本地服务端环境变量中,不进入浏览器,也不会写入 Git。
+
+## 本地运行
+
+```bash
+cp .env.example .env.local
+npm start
+```
+
+打开 `http://localhost:4173`。
+
+请优先通过这个地址访问,不要把 `public/index.html` 当成普通文档直接打开。页面现已兼容直接打开时的样式加载,但接口交互仍依赖本地服务。
+
+默认 `.env.example` 使用 Mock 模式,可以先完整评审交互。连接真实接口时设置:
+
+```bash
+KK_API_KEY=sk-统一接口Key
+DEMO_MOCK=0
+```
+
+Mock 模式下可将任一素材 URL 写成包含 `reject` 的地址,验证单张驳回、失败原因和视频生成锁定状态。
+
+## 当前还缺的真实联调材料
+
+- **快快 AI Hub 统一 Key**:形如 `sk-...`,同时用于新版素材库与 Seedance 2.0。
+- **数字人三视图的公网 URL**:正面、侧面、背面各一张;接口不支持直接上传本地文件或 Base64,URL 需带 `.png`、`.jpg` 等扩展名。
+- **客户希望验证的第一段分镜**:人物动作、台词、场景、时长、横竖屏及是否需要同步声音。Demo 已提供一段可替换示例。
+- **视频任务成功响应**:新版接口在 `content.video_url` 返回临时签名视频地址。
+
+## 接口边界
+
+- 素材审核与视频生成统一使用 `https://ai-api.kkidc.com` 和同一个 Bearer Key。
+- 新版素材库只在列表中返回 `ready` 素材;`pending` / `failed` 通过单素材接口查询。
+- 视频生成应长期保存 `asset://mat_*`,不要依赖临时签名视频地址。
+- 参考图/参考视频/首帧/首尾帧属于不同互斥调用方式;本 Demo 采用“多模态参考图”方式。
+- 默认视频 720p,时长 4–15 秒;真人/数字人必须使用审核通过的 `mat_*` 素材 ID。
+
+接口依据:[快快 AI Hub 最新文档](https://ai.kkidc.com/api-docs/8238027m0)。
+
+## 验证
+
+```bash
+npm test
+```
diff --git a/lib/normalize.mjs b/lib/normalize.mjs
new file mode 100644
index 0000000..be877de
--- /dev/null
+++ b/lib/normalize.mjs
@@ -0,0 +1,36 @@
+export 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}` : "";
+}
+
+export function extractVideoTaskId(payload = {}) {
+ return payload.task_id || payload.id || payload.data?.task_id || payload.data?.id || "";
+}
+
+export function extractVideoUrl(payload = {}) {
+ const candidates = [
+ payload.video_url,
+ payload.url,
+ payload.output?.video_url,
+ payload.output?.url,
+ payload.data?.video_url,
+ payload.data?.url,
+ payload.result?.video_url,
+ payload.result?.url,
+ payload.content?.video_url,
+ payload.content?.url,
+ ];
+ return candidates.find((value) => typeof value === "string" && /^https?:\/\//.test(value)) || "";
+}
+
+export function normalizeVideoStatus(payload = {}) {
+ const value = String(payload.status || payload.data?.status || "").toLowerCase();
+ if (["succeeded", "success", "completed", "done"].includes(value)) return "completed";
+ if (["failed", "error", "cancelled", "canceled"].includes(value)) return "failed";
+ return value || "processing";
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..df8be9c
--- /dev/null
+++ b/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "yingxiangli-ai-drama-avatar-demo",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "engines": {
+ "node": ">=20"
+ },
+ "scripts": {
+ "dev": "node --watch server.mjs",
+ "start": "node server.mjs",
+ "test": "node --test tests/*.test.mjs"
+ }
+}
diff --git a/public/app.js b/public/app.js
new file mode 100644
index 0000000..73f87d7
--- /dev/null
+++ b/public/app.js
@@ -0,0 +1,1712 @@
+window.__demoRuntimeErrors = [];
+document.documentElement.dataset.runtimeErrors = "0";
+function recordRuntimeError(message) {
+ window.__demoRuntimeErrors.push(message);
+ document.documentElement.dataset.runtimeErrors = String(window.__demoRuntimeErrors.length);
+ if (window.__auditReady) logClientOperation("frontend.runtime_error", { message: String(message || "").slice(0, 500) });
+}
+window.addEventListener("error", (event) => recordRuntimeError(event.message || "Unknown runtime error"));
+window.addEventListener("unhandledrejection", (event) => recordRuntimeError(String(event.reason || "Unhandled rejection")));
+
+const PENDING_REVIEW_KEY = "yingxiangli.pendingAssetReview";
+const PENDING_VIDEO_KEY = "yingxiangli.pendingVideoTask.v1";
+const LEGACY_GROUP_KEY = "yingxiangli.selectedAssetGroup";
+const SCENE_ROLES_KEY = "yingxiangli.sceneRoles.v1";
+const CHARACTER_LIBRARY_KEY = "yingxiangli.characterLibrary.v1";
+const DRAMA_PROJECT_KEY = "yingxiangli.dramaProject.v1";
+const AUDIT_CLIENT_KEY = "yingxiangli.auditClientId.v1";
+const AUDIT_SESSION_KEY = "yingxiangli.auditSessionId.v1";
+const VIEW_LABELS = ["正面", "侧面", "背面"];
+const ROLE_LETTERS = ["A", "B", "C"];
+const API_BASE = location.protocol === "file:" ? "http://localhost:4173" : "";
+const VIDEO_POLL_INTERVAL_MS = 2500;
+const VIDEO_POLL_MAX_MS = 30 * 60 * 1000;
+const DEFAULT_SHOT_PROMPT = "竖屏双人对话短剧镜头:角色 A 站在办公楼电梯口,先低头查看手机消息;角色 B 从走廊走近并停在她身侧。角色 B 说:“你真的决定了吗?”角色 A 抬头回答:“这一次,我自己做决定。”保持两名角色外观与各自参考图一致,避免交换五官、服装或身份。镜头从双人中景缓慢推进至角色 A 近景,口型与中文台词同步,环境声自然。";
+
+const state = {
+ config: null,
+ roles: [],
+ activeRoleIndex: 0,
+ pollTimer: null,
+ reviewStartedAt: 0,
+ assets: [],
+ characters: [],
+ characterDraftReferences: new Set(),
+ activeModule: document.body.dataset.activeModule || "library",
+ projectName: "未命名短剧",
+ shots: [],
+ activeShotId: null,
+ activeGenerationShotId: null,
+ activeGenerationVersionId: null,
+};
+
+const el = Object.fromEntries([
+ "connectionState", "assetForm", "assetFrontUrl", "assetSideUrl", "assetBackUrl", "assetName",
+ "assetStatus", "assetResult", "assetReferences", "assetList", "refreshAssets", "videoForm",
+ "videoStatus", "prompt", "referenceMap", "model", "ratio", "duration", "resolution",
+ "generateAudio", "watermark", "generationResult", "generationTitle", "generationTaskId",
+ "generationProgress", "progressBar", "generationElapsed", "generationLastChecked", "videoStage", "toast", "reviewButton",
+ "generateButton", "reviewElapsed", "reviewFront", "reviewSide", "reviewBack", "mockHint",
+ "sceneRoles", "addRoleButton", "activeRoleLabel",
+ "characterList", "createCharacterButton", "characterEditor", "characterName", "assetPickList",
+ "cancelCharacterButton", "saveCharacterButton", "assetHistoryCount", "extraImageInputs",
+ "referenceVideoInputs", "addExtraImageButton", "addReferenceVideoButton", "inputQuota",
+ "addShotButton", "shotList", "timelineSummary", "shotProgressBar", "continuityMode", "versionCount",
+ "versionList", "assemblyPanel", "assemblyList", "assemblyHint", "closeAssemblyButton",
+].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();
+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();
+ await loadAssets();
+ loadActiveShotIntoForm();
+ renderScene();
+ renderStoryboard();
+ resumePendingReview();
+ resumePendingVideo();
+ } catch (error) {
+ showToast(error.message, true);
+ el.connectionState.textContent = "本地服务连接失败";
+ el.connectionState.className = "connection blocked";
+ }
+}
+
+el.addRoleButton.addEventListener("click", () => {
+ if (state.roles.length >= 3) return showToast("当前 Demo 最多支持三个场景角色,人物图片总数不超过 9 张", true);
+ const role = createRole(`角色 ${ROLE_LETTERS[state.roles.length]}`);
+ state.roles.push(role);
+ if (activeShot()) activeShot().roleIds.push(role.id);
+ state.activeRoleIndex = state.roles.length - 1;
+ renderScene();
+ renderStoryboard();
+ saveDramaProject();
+});
+
+el.assetName.addEventListener("input", () => {
+ activeRole().name = el.assetName.value.trim() || `角色 ${ROLE_LETTERS[state.activeRoleIndex]}`;
+ renderRoleCards();
+ renderSceneSummary();
+ saveSceneRoles();
+});
+
+el.assetForm.addEventListener("submit", async (event) => {
+ event.preventDefault();
+ const role = activeRole();
+ role.name = el.assetName.value.trim() || `角色 ${ROLE_LETTERS[state.activeRoleIndex]}`;
+ setAssetStatus("processing", "审核中");
+ state.reviewStartedAt = Date.now();
+ setAllReviewRows("processing", "审核中", "正在提交");
+ updateReviewElapsed();
+ el.reviewButton.disabled = true;
+
+ try {
+ let result = await api("/api/assets/review", {
+ method: "POST",
+ body: JSON.stringify({
+ name: role.name,
+ images: [
+ { view: "front", url: el.assetFrontUrl.value.trim() },
+ { view: "side", url: el.assetSideUrl.value.trim() },
+ { view: "back", url: el.assetBackUrl.value.trim() },
+ ],
+ }),
+ });
+ if (Number(result.code) === 202 || normalizeAssetTaskStatus(result) === "processing") {
+ const taskId = result.task_id || result.data?.task_id || result.id;
+ if (!taskId) throw new Error("审核接口已受理,但没有返回任务 ID");
+ savePendingReview(taskId, role.id);
+ result = await pollAssetTask(taskId);
+ }
+ await finishAssetReview(result, role.id, role.name);
+ } catch (error) {
+ if (error.pending) {
+ setAssetStatus("processing", "仍在审核");
+ el.reviewElapsed.textContent = "仍在审核,可稍后回来查看";
+ } else {
+ setAssetStatus("error", "审核失败");
+ markUnresolvedRowsFailed(error.message);
+ clearPendingReview();
+ }
+ showToast(error.message, true);
+ } finally {
+ el.reviewButton.disabled = false;
+ }
+});
+
+el.videoForm.addEventListener("submit", async (event) => {
+ event.preventDefault();
+ updateActiveShotFromForm();
+ const quota = mediaQuota();
+ if (!activeShotRoles().every((role) => role.references.length > 0)) return showToast("请先为本片段每个出场角色选择人物素材", true);
+ if (!quota.valid) return showToast(quota.message, true);
+ if (activeShot().continuityMode === "previous_video" && previousShot() && !continuityReference()) {
+ return showToast("上一片段还没有可用的选定版本,请先完成上一段或调整衔接方式", true);
+ }
+
+ clearTimeout(state.pollTimer);
+ el.generateButton.disabled = true;
+ setVideoStatus("processing", "生成中");
+ setStep(4);
+ el.generationResult.classList.remove("hidden");
+ el.generationTaskId.textContent = "正在获取任务 ID…";
+ el.generationElapsed.textContent = "已等待 0 秒";
+ el.generationLastChecked.textContent = "尚未收到首次查询结果";
+ updateProgress(5, "任务正在提交");
+ resetVideoStage();
+ const shot = activeShot();
+ const version = {
+ id: `version-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
+ status: "running",
+ taskId: "",
+ videoUrl: "",
+ lastFrameUrl: "",
+ createdAt: Date.now(),
+ lastCheckedAt: null,
+ progress: 5,
+ };
+ shot.versions.push(version);
+ state.activeGenerationShotId = shot.id;
+ state.activeGenerationVersionId = version.id;
+ saveDramaProject();
+ renderStoryboard();
+
+ try {
+ const result = await api("/api/videos", {
+ method: "POST",
+ body: JSON.stringify(buildVideoClientBody()),
+ });
+ const taskId = result.task_id || result.id || result.data?.task_id || result.data?.id;
+ if (!taskId) throw new Error("视频接口未返回任务 ID");
+ el.generationTaskId.textContent = taskId;
+ version.taskId = taskId;
+ saveDramaProject();
+ renderVersionList();
+ savePendingVideo(taskId, shot.id, version.id);
+ await pollVideoTask(taskId, shot.id, version.id);
+ } catch (error) {
+ const message = contextualizeGenerationError(error.message);
+ if (error.pending) {
+ if (activeShot()?.id === shot.id) {
+ setVideoStatus("processing", "仍在生成");
+ updateProgress(version.progress || 20, "仍在生成,正在自动查询结果");
+ }
+ showToast(message);
+ } else {
+ clearPendingVideo();
+ failShotVersion(shot.id, version.id, message);
+ if (activeShot()?.id === shot.id) {
+ setVideoStatus("error", "生成失败");
+ renderGenerationError(message);
+ }
+ showToast(message, true);
+ }
+ } finally {
+ finishGenerationUi(shot, version);
+ }
+});
+
+el.refreshAssets.addEventListener("click", loadAssets);
+el.createCharacterButton.addEventListener("click", openCharacterEditor);
+el.cancelCharacterButton.addEventListener("click", closeCharacterEditor);
+el.saveCharacterButton.addEventListener("click", saveCharacterFromDraft);
+[el.prompt, el.extraImageInputs, el.referenceVideoInputs].forEach((input) => input.addEventListener("input", () => {
+ updateActiveShotFromForm();
+ updateVideoAvailability();
+}));
+el.addExtraImageButton.addEventListener("click", () => addMediaInput("image"));
+el.addReferenceVideoButton.addEventListener("click", () => addMediaInput("video"));
+[el.extraImageInputs, el.referenceVideoInputs].forEach((list) => list.addEventListener("click", (event) => {
+ const removeButton = event.target.closest("[data-remove-media]");
+ if (!removeButton) return;
+ removeMediaInput(removeButton);
+}));
+el.continuityMode.addEventListener("change", () => {
+ updateActiveShotFromForm();
+ updateVideoAvailability();
+});
+el.addShotButton.addEventListener("click", addShot);
+el.closeAssemblyButton.addEventListener("click", () => el.assemblyPanel.classList.add("hidden"));
+el.model.addEventListener("change", () => {
+ if (el.model.value.includes("4k")) el.resolution.value = "4k";
+ else if (el.model.value.includes("1080")) el.resolution.value = "1080p";
+ else el.resolution.value = "720p";
+ updateActiveShotFromForm();
+});
+[el.ratio, el.duration, el.resolution, el.generateAudio, el.watermark].forEach((input) => {
+ input.addEventListener("change", updateActiveShotFromForm);
+});
+
+async function loadAssets() {
+ try {
+ const items = [];
+ const pageSize = 100;
+ for (let page = 1; page <= 50; page += 1) {
+ const result = await api(`/api/assets?page=${page}&page_size=${pageSize}&sort_order=Desc`);
+ const pageItems = result.data?.items || result.data?.Items || [];
+ items.push(...pageItems);
+ const total = Number(result.data?.total ?? result.data?.TotalCount ?? items.length);
+ if (!pageItems.length || items.length >= total || pageItems.length < pageSize) break;
+ }
+ state.assets = items;
+ ingestHistoricalCharacters(items);
+ renderCharacterLibrary();
+ renderAssetPicker();
+ el.assetHistoryCount.textContent = String(items.length);
+ el.assetList.innerHTML = "";
+ if (!items.length) {
+ el.assetList.innerHTML = '
新版素材库暂无历史素材
';
+ return;
+ }
+ for (const item of items) {
+ const reference = normalizeAssetReference(item);
+ const position = referencePosition(reference);
+ const button = document.createElement("button");
+ button.className = `asset-item asset-card${position ? " selected" : ""}`;
+ button.type = "button";
+ if (position) button.dataset.order = `${ROLE_LETTERS[position.roleIndex]}${position.viewIndex + 1}`;
+ const imageUrl = item.original_url || item.URL || "";
+ if (imageUrl) {
+ const image = document.createElement("img");
+ image.src = imageUrl;
+ image.alt = item.purpose || item.Name || "素材预览";
+ image.loading = "lazy";
+ image.addEventListener("error", () => image.classList.add("image-error"));
+ button.appendChild(image);
+ }
+ const name = item.purpose || item.Name || fileNameFromUrl(imageUrl) || "未命名素材";
+ const info = document.createElement("div");
+ info.className = "asset-info";
+ info.innerHTML = `${escapeHtml(name)}${escapeHtml(reference)}`;
+ button.appendChild(info);
+ button.addEventListener("click", () => toggleHistoricalAsset(reference));
+ el.assetList.appendChild(button);
+ }
+ } catch (error) {
+ el.assetList.innerHTML = `${escapeHtml(error.message)}
`;
+ }
+}
+
+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];
+ if (activeShot() && !activeShot().roleIds.includes(role.id)) activeShot().roleIds.push(role.id);
+ renderScene();
+ if (state.activeModule === "library") switchModule("production");
+ showToast(`已将「${character.name}」用于角色 ${ROLE_LETTERS[state.activeRoleIndex]}`);
+}
+
+function switchModule(module) {
+ if (!['library', 'production'].includes(module)) return;
+ state.activeModule = module;
+ document.body.dataset.activeModule = module;
+ document.querySelectorAll('.module-tab').forEach((button) => {
+ button.classList.toggle('active', button.dataset.module === module);
+ });
+ renderCharacterLibrary();
+}
+
+function createShot(seed = {}, index = state.shots.length) {
+ return {
+ id: seed.id || `shot-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
+ title: seed.title || (index === 0 ? "电梯口对话" : `片段 ${String(index + 1).padStart(2, "0")}`),
+ prompt: seed.prompt || DEFAULT_SHOT_PROMPT,
+ extraImageUrls: seed.extraImageUrls || "",
+ referenceVideoUrls: seed.referenceVideoUrls || "",
+ continuityMode: index === 0 ? "none" : (seed.continuityMode === "none" ? "none" : "previous_video"),
+ roleIds: Array.isArray(seed.roleIds) ? [...seed.roleIds] : state.roles.map((role) => role.id),
+ model: seed.model || "doubao-seedance-2-0-260128",
+ ratio: seed.ratio || "9:16",
+ duration: Number(seed.duration) || 5,
+ resolution: seed.resolution || "720p",
+ generateAudio: seed.generateAudio !== false,
+ watermark: Boolean(seed.watermark),
+ versions: Array.isArray(seed.versions) ? seed.versions : [],
+ selectedVersionId: seed.selectedVersionId || null,
+ };
+}
+
+function restoreDramaProject() {
+ let saved;
+ try { saved = JSON.parse(localStorage.getItem(DRAMA_PROJECT_KEY) || "null"); } catch { saved = null; }
+ state.projectName = String(saved?.name || "未命名短剧");
+ state.shots = Array.isArray(saved?.shots) && saved.shots.length
+ ? saved.shots.slice(0, 30).map((shot, index) => createShot(shot, index))
+ : [createShot()];
+ const roleIds = new Set(state.roles.map((role) => role.id));
+ state.shots.forEach((shot) => {
+ shot.roleIds = shot.roleIds.filter((id) => roleIds.has(id));
+ if (!shot.roleIds.length) shot.roleIds = state.roles.map((role) => role.id);
+ });
+ state.activeShotId = state.shots.some((shot) => shot.id === saved?.activeShotId) ? saved.activeShotId : state.shots[0].id;
+}
+
+function saveDramaProject() {
+ localStorage.setItem(DRAMA_PROJECT_KEY, JSON.stringify({
+ name: state.projectName,
+ activeShotId: state.activeShotId,
+ shots: state.shots,
+ }));
+}
+
+function activeShot() { return state.shots.find((shot) => shot.id === state.activeShotId) || state.shots[0]; }
+function shotIndex(shot = activeShot()) { return Math.max(0, state.shots.findIndex((item) => item.id === shot?.id)); }
+function previousShot(shot = activeShot()) { const index = shotIndex(shot); return index > 0 ? state.shots[index - 1] : null; }
+function selectedShotVersion(shot) { return shot?.versions?.find((version) => version.id === shot.selectedVersionId) || null; }
+
+function updateActiveShotFromForm() {
+ const shot = activeShot();
+ if (!shot) return;
+ shot.prompt = el.prompt.value;
+ shot.extraImageUrls = collectMediaInputUrls("image").join("\n");
+ shot.referenceVideoUrls = collectMediaInputUrls("video").join("\n");
+ shot.continuityMode = el.continuityMode.value;
+ shot.model = el.model.value;
+ shot.ratio = el.ratio.value;
+ shot.duration = Number(el.duration.value);
+ shot.resolution = el.resolution.value;
+ shot.generateAudio = el.generateAudio.checked;
+ shot.watermark = el.watermark.checked;
+ saveDramaProject();
+ renderShotList();
+ renderProjectSummary();
+}
+
+function loadActiveShotIntoForm() {
+ const shot = activeShot();
+ if (!shot) return;
+ el.prompt.value = shot.prompt;
+ renderMediaInputList("image", shot.extraImageUrls);
+ renderMediaInputList("video", shot.referenceVideoUrls);
+ el.continuityMode.value = shotIndex(shot) === 0 ? "none" : shot.continuityMode;
+ el.continuityMode.disabled = shotIndex(shot) === 0;
+ el.model.value = shot.model;
+ el.ratio.value = shot.ratio;
+ el.duration.value = String(shot.duration);
+ el.resolution.value = shot.resolution;
+ el.generateAudio.checked = shot.generateAudio;
+ el.watermark.checked = shot.watermark;
+ renderActiveShotGenerationState();
+}
+
+function renderStoryboard() {
+ renderProjectSummary();
+ renderShotList();
+ const shot = activeShot();
+ if (!shot) return;
+ el.continuityMode.value = shotIndex(shot) === 0 ? "none" : shot.continuityMode;
+ el.continuityMode.disabled = shotIndex(shot) === 0;
+ renderVersionList();
+ if (!el.assemblyPanel.classList.contains("hidden")) renderAssembly();
+}
+
+function renderProjectSummary() {
+ const selected = state.shots.filter((shot) => selectedShotVersion(shot)).length;
+ const seconds = state.shots.reduce((sum, shot) => sum + Number(shot.duration || 0), 0);
+ el.timelineSummary.textContent = `${selected}/${state.shots.length} 已选定 · 预计 ${seconds} 秒`;
+ el.shotProgressBar.style.width = `${state.shots.length ? (selected / state.shots.length) * 100 : 0}%`;
+}
+
+function renderShotList() {
+ el.shotList.innerHTML = "";
+ state.shots.forEach((shot, index) => {
+ const selected = selectedShotVersion(shot);
+ const running = shot.versions.some((version) => version.status === "running");
+ const failed = shot.versions.length > 0 && shot.versions.every((version) => version.status === "failed");
+ const card = document.createElement("div");
+ card.className = `shot-card${shot.id === state.activeShotId ? " active" : ""}`;
+ const stateClass = selected ? "ready" : running ? "running" : failed ? "failed" : "";
+ card.innerHTML = `${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";
+ if (version.videoUrl) {
+ const video = document.createElement("video");
+ video.src = version.videoUrl;
+ video.controls = true;
+ preview.appendChild(video);
+ } else preview.textContent = version.status === "running" ? "生成中" : version.status === "failed" ? "生成失败" : "暂无预览";
+ const details = document.createElement("div");
+ details.className = "version-copy";
+ details.innerHTML = `候选 ${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 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 reference = role.references[index];
+ setReviewRow(index, reference ? "success" : "neutral", reference ? "已选择" : "待选择", reference || `下一张请选择${VIEW_LABELS[index]}图`);
+ });
+ el.reviewElapsed.textContent = `角色 ${letter} · 已选 ${role.references.length} 张`;
+}
+
+function renderSceneSummary() {
+ const completeRoles = state.roles.filter((role) => role.references.length > 0);
+ el.assetReferences.innerHTML = "";
+ for (const role of completeRoles) {
+ const index = state.roles.indexOf(role);
+ const title = document.createElement("div");
+ title.className = "role-reference-title";
+ title.textContent = `角色 ${ROLE_LETTERS[index]} · ${role.name}`;
+ el.assetReferences.appendChild(title);
+ role.references.forEach((reference, viewIndex) => {
+ const row = document.createElement("div");
+ row.className = "reference-row";
+ row.innerHTML = `${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 ? "缺少视频 Key" : !sceneReady() ? "等待角色" : quota.message;
+ setVideoStatus(!state.config.videoReady || !quota.valid ? "error" : "neutral", label);
+ setStep(sceneReady() ? 2 : 1);
+ }
+}
+
+function runningShotVersion(shot) {
+ return shot?.versions?.slice().reverse().find((version) => version.status === "running") || null;
+}
+
+function formatElapsed(startedAt) {
+ const seconds = Math.max(0, Math.floor((Date.now() - Number(startedAt || Date.now())) / 1000));
+ const minutes = Math.floor(seconds / 60);
+ const remainder = seconds % 60;
+ return minutes ? `${minutes} 分 ${remainder} 秒` : `${remainder} 秒`;
+}
+
+function formatCheckedAt(value) {
+ if (!value) return "尚未收到首次查询结果";
+ return `最近查询 ${new Date(value).toLocaleTimeString("zh-CN", { hour12: false })}`;
+}
+
+function renderGenerationMeta(version) {
+ el.generationElapsed.textContent = `已等待 ${formatElapsed(version.createdAt)}`;
+ el.generationLastChecked.textContent = formatCheckedAt(version.lastCheckedAt);
+}
+
+function renderActiveShotGenerationState() {
+ clearInterval(state.pollTimer);
+ state.pollTimer = null;
+ const version = runningShotVersion(activeShot());
+ if (!version) {
+ el.generationResult.classList.add("hidden");
+ el.generationElapsed.textContent = "";
+ el.generationLastChecked.textContent = "";
+ return;
+ }
+ el.generationResult.classList.remove("hidden");
+ el.generationTaskId.textContent = version.taskId || "正在获取任务 ID…";
+ resetVideoStage();
+ updateProgress(version.progress || 20, "正在自动查询生成结果");
+ renderGenerationMeta(version);
+ state.pollTimer = setInterval(() => {
+ if (activeShot()?.id !== state.activeGenerationShotId && version.id !== runningShotVersion(activeShot())?.id) return;
+ renderGenerationMeta(version);
+ }, 1000);
+}
+
+function finishGenerationUi(shot, version) {
+ renderStoryboard();
+ if (activeShot()?.id !== shot?.id) {
+ updateVideoAvailability();
+ return;
+ }
+ if (version?.status === "running") {
+ updateVideoAvailability();
+ return;
+ }
+ el.generateButton.disabled = false;
+ setVideoStatus(version?.status === "completed" ? "success" : "error", version?.status === "completed" ? "生成完成" : "生成失败");
+}
+
+function refreshAssetSelectionStyles() {
+ for (const button of el.assetList.querySelectorAll(".asset-card")) {
+ const position = referencePosition(button.textContent);
+ button.classList.toggle("selected", Boolean(position));
+ if (position) button.dataset.order = `${ROLE_LETTERS[position.roleIndex]}${position.viewIndex + 1}`;
+ else delete button.dataset.order;
+ }
+}
+
+function referencePosition(value) {
+ const text = String(value || "").toLowerCase();
+ for (let roleIndex = 0; roleIndex < state.roles.length; roleIndex += 1) {
+ const viewIndex = state.roles[roleIndex].references.findIndex((reference) => text.includes(String(reference).toLowerCase()));
+ if (viewIndex >= 0) return { roleIndex, viewIndex };
+ }
+ return null;
+}
+
+function buildReferenceMap() {
+ let start = 1;
+ const parts = [];
+ for (const role of activeShotRoles()) {
+ const index = state.roles.indexOf(role);
+ if (!role.references.length) continue;
+ const end = start + role.references.length - 1;
+ const range = start === end ? `图${start}` : `图${start}–${end}`;
+ const detail = role.references.length === 3 ? "正面、侧面、背面" : `${role.references.length} 张人物参考图`;
+ parts.push(`${range}:角色 ${ROLE_LETTERS[index]}「${role.name}」的${detail}`);
+ start = end + 1;
+ }
+ const extraImages = effectiveExtraImageReferences();
+ if (extraImages.length) {
+ const end = start + extraImages.length - 1;
+ parts.push(`${start === end ? `图${start}` : `图${start}–${end}`}:场景、道具或其他非人物参考图`);
+ }
+ return parts.length ? parts.join(";") : "系统会根据本场角色自动生成图号映射。";
+}
+
+function buildMappedPrompt() {
+ const videoReferences = effectiveVideoReferences();
+ const videoMap = videoReferences.length ? `\n参考视频映射:视频1–${videoReferences.length}为运镜、动作或节奏参考。` : "";
+ const continuity = continuityReference();
+ const continuityHint = continuity ? `\n连续性要求:本片段需承接上一片段「${continuity.previous.title}」的${continuity.type === "video" ? "动作、构图与状态" : "尾帧画面状态"}。` : "";
+ return `参考图角色映射:${buildReferenceMap()}。\n人物一致性要求:每个角色只参考自己的人物素材组,禁止交换五官、发型、服装、体型或身份。${videoMap}${continuityHint}\n${el.prompt.value.trim()}`;
+}
+
+function buildVideoClientBody() {
+ return {
+ assetReferences: sceneReferences(),
+ extraImageReferences: effectiveExtraImageReferences(),
+ videoReferences: effectiveVideoReferences(),
+ prompt: buildMappedPrompt(),
+ model: el.model.value,
+ ratio: el.ratio.value,
+ duration: Number(el.duration.value),
+ resolution: el.resolution.value,
+ generateAudio: el.generateAudio.checked,
+ watermark: el.watermark.checked,
+ };
+}
+
+function sceneReferences() {
+ return activeShotRoles().flatMap((role) => role.references);
+}
+
+function sceneReady() {
+ const roles = activeShotRoles();
+ return roles.length > 0 && roles.every((role) => role.references.length > 0) && mediaQuota().valid;
+}
+
+function activeRole() {
+ return state.roles[state.activeRoleIndex];
+}
+
+function createRole(name) {
+ return { id: `role-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, name, references: [] };
+}
+
+function saveSceneRoles() {
+ localStorage.setItem(SCENE_ROLES_KEY, JSON.stringify({ roles: state.roles, activeRoleIndex: state.activeRoleIndex }));
+}
+
+function saveCharacterLibrary() {
+ localStorage.setItem(CHARACTER_LIBRARY_KEY, JSON.stringify(state.characters));
+}
+
+function restoreCharacterLibrary() {
+ let saved;
+ try { saved = JSON.parse(localStorage.getItem(CHARACTER_LIBRARY_KEY) || "[]"); } catch { saved = []; }
+ state.characters = Array.isArray(saved) ? saved.map((character) => ({
+ id: character.id || `character-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
+ name: String(character.name || "未命名人物"),
+ references: Array.isArray(character.references) ? character.references.map(String).filter((value) => state.config.mockMode || /^asset:\/\/mat_/i.test(value)).slice(0, 9) : [],
+ source: character.source || "manual",
+ })).filter((character) => character.references.length) : [];
+}
+
+function parseMediaLines(value) {
+ return [...new Set(String(value || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean))];
+}
+
+function mediaInputConfig(type) {
+ return type === "video"
+ ? { list: el.referenceVideoInputs, button: el.addReferenceVideoButton, limit: 3, placeholder: "https://example.com/reference.mp4" }
+ : { list: el.extraImageInputs, button: el.addExtraImageButton, limit: 9, placeholder: "https://example.com/reference.jpg" };
+}
+
+function createMediaInputRow(type, value = "") {
+ const { placeholder } = mediaInputConfig(type);
+ const row = document.createElement("div");
+ row.className = "media-url-row";
+
+ const input = document.createElement("input");
+ input.type = "url";
+ input.value = value;
+ input.placeholder = placeholder;
+ input.setAttribute("aria-label", type === "video" ? "参考视频链接" : "其他参考图片链接");
+
+ const remove = document.createElement("button");
+ remove.type = "button";
+ remove.className = "media-remove-button";
+ remove.dataset.removeMedia = type;
+ remove.setAttribute("aria-label", "删除该链接");
+ remove.textContent = "×";
+
+ row.append(input, remove);
+ return row;
+}
+
+function renderMediaInputList(type, value) {
+ const { list } = mediaInputConfig(type);
+ const values = parseMediaLines(value);
+ list.replaceChildren(...(values.length ? values : [""]).map((item) => createMediaInputRow(type, item)));
+ refreshMediaAddButton(type);
+}
+
+function collectMediaInputUrls(type) {
+ const { list } = mediaInputConfig(type);
+ return [...new Set([...list.querySelectorAll("input")].map((input) => input.value.trim()).filter(Boolean))];
+}
+
+function addMediaInput(type) {
+ const { list, limit } = mediaInputConfig(type);
+ if (list.children.length >= limit) return;
+ const row = createMediaInputRow(type);
+ list.append(row);
+ row.querySelector("input").focus();
+ refreshMediaAddButton(type);
+}
+
+function removeMediaInput(button) {
+ const type = button.dataset.removeMedia;
+ const { list } = mediaInputConfig(type);
+ if (list.children.length === 1) {
+ list.querySelector("input").value = "";
+ } else {
+ button.closest(".media-url-row").remove();
+ }
+ refreshMediaAddButton(type);
+ updateActiveShotFromForm();
+ updateVideoAvailability();
+}
+
+function refreshMediaAddButton(type) {
+ const { list, button, limit } = mediaInputConfig(type);
+ button.disabled = list.children.length >= limit;
+}
+
+function mediaQuota() {
+ const characterImages = sceneReferences().length;
+ const extraImages = effectiveExtraImageReferences().length;
+ const videos = effectiveVideoReferences().length;
+ const imageTotal = characterImages + extraImages;
+ if (imageTotal > 9) return { valid: false, characterImages, extraImages, videos, imageTotal, message: `图片 ${imageTotal}/9,已超出上限` };
+ if (videos > 3) return { valid: false, characterImages, extraImages, videos, imageTotal, message: `参考视频 ${videos}/3,已超出 Demo 上限` };
+ return { valid: true, characterImages, extraImages, videos, imageTotal, message: "可提交" };
+}
+
+function renderInputQuota() {
+ if (!el.inputQuota) return;
+ const quota = mediaQuota();
+ el.inputQuota.textContent = `人物图片 ${quota.characterImages} 张 · 其他图片 ${quota.extraImages} 张 · 图片合计 ${quota.imageTotal}/9 · 视频 ${quota.videos}/3`;
+ el.inputQuota.className = `input-quota wide ${quota.valid ? "valid" : "invalid"}`;
+}
+
+function restoreSceneRoles() {
+ let saved;
+ try { saved = JSON.parse(localStorage.getItem(SCENE_ROLES_KEY) || "null"); } catch { saved = null; }
+ if (Array.isArray(saved?.roles) && saved.roles.length) {
+ state.roles = saved.roles.slice(0, 3).map((role, index) => ({
+ id: role.id || createRole("").id,
+ name: role.name || `角色 ${ROLE_LETTERS[index]}`,
+ references: Array.isArray(role.references)
+ ? role.references.slice(0, 3).map(String).filter((value) => state.config.mockMode || /^asset:\/\/mat_/i.test(value))
+ : [],
+ }));
+ state.activeRoleIndex = Math.min(Number(saved.activeRoleIndex) || 0, state.roles.length - 1);
+ return;
+ }
+ let legacy;
+ try { legacy = JSON.parse(localStorage.getItem(LEGACY_GROUP_KEY) || "null"); } catch { legacy = null; }
+ if (Array.isArray(legacy?.references) && legacy.references.length === 3) {
+ state.roles = [{ ...createRole(legacy.name || "角色 A"), references: state.config.mockMode ? legacy.references.map(String) : [] }];
+ localStorage.removeItem(LEGACY_GROUP_KEY);
+ } else {
+ state.roles = [createRole("角色 A")];
+ }
+}
+
+async function finishAssetReview(result, roleId, name) {
+ const items = getReviewItems(result);
+ renderReviewItems(items);
+ if (items.length !== 3) throw new Error(result.message || `预期返回 3 张素材,实际返回 ${items.length} 张`);
+ const failedItem = items.find((item) => Number(item.submit_review_status) !== 1);
+ if (failedItem) throw new Error(failedItem.error_message || "三视图中有素材未通过审核");
+ const references = items.map(normalizeAssetReference);
+ if (references.some((reference) => !reference)) throw new Error("审核通过,但有素材未返回可用的 asset:// ID");
+ clearPendingReview();
+ const roleIndex = Math.max(0, state.roles.findIndex((role) => role.id === roleId));
+ state.activeRoleIndex = roleIndex;
+ state.roles[roleIndex].name = name;
+ state.roles[roleIndex].references = references;
+ upsertCharacter(name, references, "history", true);
+ el.reviewElapsed.textContent = "审核完成";
+ await loadAssets();
+ renderScene();
+ showToast(`角色 ${ROLE_LETTERS[roleIndex]} 的三视图已全部通过审核`);
+}
+
+async function pollAssetTask(taskId) {
+ for (let attempt = 0; attempt < 100; attempt += 1) {
+ updateReviewElapsed();
+ await delay(3000);
+ const result = await api(`/api/assets/tasks/${encodeURIComponent(taskId)}`);
+ const items = getReviewItems(result);
+ if (items.length) renderReviewItems(items);
+ const status = normalizeAssetTaskStatus(result);
+ if (status === "completed" || status === "failed") return result;
+ }
+ const error = new Error("素材仍在审核,任务已保存;稍后重新打开页面会继续查询");
+ error.pending = true;
+ throw error;
+}
+
+async function resumePendingReview() {
+ let pending;
+ try { pending = JSON.parse(localStorage.getItem(PENDING_REVIEW_KEY) || "null"); } catch { pending = null; }
+ if (!pending?.taskId) return;
+ const roleIndex = state.roles.findIndex((role) => role.id === pending.roleId);
+ if (roleIndex >= 0) state.activeRoleIndex = roleIndex;
+ state.reviewStartedAt = Number(pending.startedAt) || Date.now();
+ setAssetStatus("processing", "继续查询");
+ setAllReviewRows("processing", "审核中", "正在恢复上次任务");
+ try {
+ const result = await pollAssetTask(pending.taskId);
+ await finishAssetReview(result, pending.roleId || activeRole().id, pending.name || activeRole().name);
+ } catch (error) {
+ if (error.pending) {
+ setAssetStatus("processing", "仍在审核");
+ el.reviewElapsed.textContent = "仍在审核,可稍后回来查看";
+ } else {
+ setAssetStatus("error", "审核失败");
+ markUnresolvedRowsFailed(error.message);
+ clearPendingReview();
+ showToast(error.message, true);
+ }
+ }
+}
+
+function savePendingReview(taskId, roleId) {
+ localStorage.setItem(PENDING_REVIEW_KEY, JSON.stringify({
+ taskId, roleId, name: activeRole().name, startedAt: state.reviewStartedAt || Date.now(),
+ }));
+}
+
+function clearPendingReview() { localStorage.removeItem(PENDING_REVIEW_KEY); }
+
+function getReviewItems(result) { return result.result?.items || result.data?.items || result.items || []; }
+
+function normalizeAssetTaskStatus(result) {
+ const value = String(result.status || result.data?.status || result.result?.status || "").toLowerCase();
+ if (["completed", "complete", "succeeded", "success", "done"].includes(value)) return "completed";
+ if (["failed", "error", "cancelled", "canceled"].includes(value)) return "failed";
+ const items = getReviewItems(result);
+ if (items.length && items.every((item) => [0, 1].includes(Number(item.submit_review_status)))) return "completed";
+ return "processing";
+}
+
+function renderReviewItems(items) {
+ items.slice(0, 3).forEach((item, index) => {
+ const passed = Number(item.submit_review_status) === 1;
+ const failed = Number(item.submit_review_status) === 0 || item.error_code || item.error_message;
+ if (passed) setReviewRow(index, "success", "已通过", normalizeAssetReference(item));
+ else if (failed) setReviewRow(index, "error", "未通过", item.error_message || item.error_code || "审核未通过");
+ else setReviewRow(index, "processing", "审核中", "等待审核结果");
+ });
+}
+
+function setReviewRow(index, kind, status, detail = "") {
+ const row = reviewRows[index];
+ if (!row) return;
+ row.className = `tracker-row ${kind}`;
+ row.querySelector("b").textContent = status;
+ row.querySelector("small").textContent = detail;
+ row.querySelector("small").title = detail;
+}
+
+function setAllReviewRows(kind, status, detail = "") { reviewRows.forEach((_, index) => setReviewRow(index, kind, status, detail)); }
+
+function markUnresolvedRowsFailed(message) {
+ reviewRows.forEach((row, index) => {
+ if (!row.classList.contains("success") && !row.classList.contains("error")) setReviewRow(index, "error", "查询失败", message);
+ });
+ el.reviewElapsed.textContent = "已返回结果";
+}
+
+function updateReviewElapsed() {
+ const seconds = Math.max(0, Math.floor((Date.now() - state.reviewStartedAt) / 1000));
+ el.reviewElapsed.textContent = `审核中 · ${seconds} 秒`;
+}
+
+async function pollVideoTask(taskId, shotId = state.activeGenerationShotId || activeShot()?.id, versionId = state.activeGenerationVersionId) {
+ const startedAt = Date.now();
+ while (Date.now() - startedAt < VIDEO_POLL_MAX_MS) {
+ await delay(VIDEO_POLL_INTERVAL_MS);
+ if (!state.shots.some((item) => item.id === shotId)) {
+ logClientOperation("video.poll_stopped", { taskId, reason: "shot_deleted", deletedShotId: shotId });
+ return;
+ }
+ const result = await api(`/api/videos/${encodeURIComponent(taskId)}`);
+ const status = result._normalized?.status || normalizeVideoStatus(result.status || result.data?.status);
+ const progress = Number(result.progress ?? result.data?.progress ?? (status === "completed" ? 100 : 20));
+ const shot = state.shots.find((item) => item.id === shotId);
+ const version = shot?.versions.find((item) => item.id === versionId);
+ if (version) {
+ version.taskId = taskId;
+ version.progress = Math.max(10, progress);
+ version.lastCheckedAt = Date.now();
+ saveDramaProject();
+ }
+ if (activeShot()?.id === shotId) {
+ updateProgress(Math.max(10, progress), status === "completed" ? "视频生成完成" : "正在自动查询生成结果");
+ if (version) renderGenerationMeta(version);
+ }
+ if (status === "completed") {
+ const videoUrl = result._normalized?.videoUrl || result.video_url || result.output?.video_url || result.data?.video_url;
+ completeShotVersion(shotId, versionId, videoUrl, result.mock);
+ if (activeShot()?.id === shotId) {
+ renderCompletedVideo(videoUrl, result.mock);
+ setVideoStatus("success", "生成完成");
+ }
+ clearPendingVideo();
+ state.activeGenerationShotId = null;
+ state.activeGenerationVersionId = null;
+ showToast("当前片段候选已生成");
+ return;
+ }
+ if (status === "failed") throw new Error(result.error?.message || result.message || "视频生成失败");
+ }
+ const error = new Error("视频仍在服务商侧生成,页面已等待 30 分钟;这不是生成失败,可凭任务 ID 继续查询");
+ error.pending = true;
+ throw error;
+}
+
+function savePendingVideo(taskId, shotId, versionId) {
+ localStorage.setItem(PENDING_VIDEO_KEY, JSON.stringify({ taskId, shotId, versionId, startedAt: Date.now() }));
+}
+
+function clearPendingVideo() { localStorage.removeItem(PENDING_VIDEO_KEY); }
+
+async function resumePendingVideo() {
+ let pending;
+ try { pending = JSON.parse(localStorage.getItem(PENDING_VIDEO_KEY) || "null"); } catch { pending = null; }
+ if (!pending?.taskId) return;
+ switchModule("production");
+ let shot = state.shots.find((item) => item.id === pending.shotId);
+ if (!shot) shot = activeShot();
+ state.activeShotId = shot.id;
+ let version = shot.versions.find((item) => item.id === pending.versionId);
+ if (!version) {
+ version = { id: pending.versionId || `version-${Date.now()}`, status: "running", taskId: pending.taskId, videoUrl: "", lastFrameUrl: "", createdAt: pending.startedAt || Date.now(), lastCheckedAt: null, progress: 20 };
+ shot.versions.push(version);
+ }
+ state.activeGenerationShotId = shot.id;
+ state.activeGenerationVersionId = version.id;
+ loadActiveShotIntoForm();
+ renderStoryboard();
+ clearTimeout(state.pollTimer);
+ el.generateButton.disabled = true;
+ el.generationResult.classList.remove("hidden");
+ el.generationTaskId.textContent = pending.taskId;
+ setVideoStatus("processing", "继续查询");
+ setStep(4);
+ resetVideoStage();
+ updateProgress(20, "正在恢复上次视频任务");
+ try {
+ await pollVideoTask(pending.taskId, shot.id, version.id);
+ } catch (error) {
+ const message = contextualizeGenerationError(error.message);
+ if (error.pending) {
+ setVideoStatus("processing", "仍在生成");
+ updateProgress(20, "仍在生成,可稍后继续查询");
+ showToast(message);
+ } else {
+ clearPendingVideo();
+ setVideoStatus("error", "生成失败");
+ failShotVersion(shot.id, version.id, message);
+ renderGenerationError(message);
+ showToast(message, true);
+ }
+ } finally {
+ finishGenerationUi(shot, version);
+ }
+}
+
+function renderConnection() {
+ 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 = "素材审核 Live · 视频 Key 待配置";
+ el.connectionState.className = "connection live";
+ el.reviewButton.textContent = "提交真实素材审核";
+ el.mockHint.classList.add("hidden");
+ } else {
+ el.connectionState.textContent = `缺少配置:${state.config.missing.join("、")}`;
+ el.connectionState.className = "connection blocked";
+ }
+}
+
+function completeShotVersion(shotId, versionId, videoUrl, mock) {
+ const shot = state.shots.find((item) => item.id === shotId);
+ const version = shot?.versions.find((item) => item.id === versionId);
+ if (!shot || !version) return;
+ version.status = "completed";
+ version.videoUrl = videoUrl || "";
+ version.mock = Boolean(mock);
+ if (!shot.selectedVersionId) shot.selectedVersionId = version.id;
+ saveDramaProject();
+ renderStoryboard();
+}
+
+function failShotVersion(shotId, versionId, message) {
+ const shot = state.shots.find((item) => item.id === shotId);
+ const version = shot?.versions.find((item) => item.id === versionId);
+ if (!version) return;
+ version.status = "failed";
+ version.error = message;
+ saveDramaProject();
+ renderStoryboard();
+}
+
+function renderCompletedVideo(videoUrl, mock) {
+ if (videoUrl) {
+ const video = document.createElement("video");
+ video.controls = true;
+ video.src = videoUrl;
+ el.videoStage.replaceChildren(video);
+ } else {
+ el.videoStage.innerHTML = `${mock ? "Mock 流程已完成" : "任务已完成"}${mock ? "接入视频 Key 后此处展示真实视频" : "接口响应中未识别到视频 URL,请查看原始返回"} `;
+ }
+ updateProgress(100, "视频生成完成");
+}
+
+function renderGenerationError(message) {
+ const wrapper = document.createElement("div");
+ wrapper.className = "generation-error";
+ const title = document.createElement("strong");
+ title.textContent = "视频生成失败";
+ const detail = document.createElement("p");
+ detail.textContent = message;
+ wrapper.append(title, detail);
+ el.videoStage.replaceChildren(wrapper);
+ updateProgress(0, "任务已失败");
+}
+
+function contextualizeGenerationError(message) {
+ const text = String(message || "视频生成失败");
+ const assetMatch = text.match(/(?:asset-[a-z0-9-]+|mat_[a-z0-9]+)/i);
+ if (!assetMatch || !/not found|不存在/i.test(text)) return text;
+ const position = referencePosition(assetMatch[0]);
+ const label = position ? `角色 ${ROLE_LETTERS[position.roleIndex]} · ${VIEW_LABELS[position.viewIndex]}` : "某张参考图";
+ return `${label}在视频接口中不存在。通常表示素材库 Token 与视频 Key 不属于同一资源空间,或素材尚未同步到视频侧。素材 ID:${assetMatch[0]}`;
+}
+
+function resetVideoStage() { el.videoStage.innerHTML = ''; }
+function updateProgress(value, title) {
+ const safe = Math.max(0, Math.min(100, Number(value) || 0));
+ el.generationTitle.textContent = title;
+ el.generationProgress.textContent = `${safe}%`;
+ el.progressBar.style.width = `${safe}%`;
+}
+function setAssetStatus(kind, text) { el.assetStatus.className = `status-badge ${kind}`; el.assetStatus.textContent = text; }
+function setVideoStatus(kind, text) { el.videoStatus.className = `status-badge ${kind}`; el.videoStatus.textContent = text; }
+function setStep(active) {
+ document.querySelectorAll(".step").forEach((step) => {
+ const number = Number(step.dataset.step);
+ step.classList.toggle("done", number < active);
+ step.classList.toggle("active", number === active);
+ });
+}
+function fallbackCopy(text) {
+ const area = document.createElement("textarea");
+ area.value = text;
+ area.style.position = "fixed";
+ area.style.opacity = "0";
+ document.body.appendChild(area);
+ area.select();
+ document.execCommand("copy");
+ area.remove();
+}
+function normalizeAssetReference(item) {
+ const direct = item.asset_url || item.AssetURL || item.assetUrl;
+ if (typeof direct === "string" && /^asset:\/\//i.test(direct)) return `asset://${direct.slice(direct.indexOf("//") + 2)}`;
+ const id = item.downstream_asset_id || item.asset_id || item.Id || item.id;
+ return id ? `asset://${id}` : "";
+}
+function normalizeVideoStatus(value) {
+ const status = String(value || "").toLowerCase();
+ if (["succeeded", "success", "completed", "done"].includes(status)) return "completed";
+ if (["failed", "error", "cancelled", "canceled"].includes(status)) return "failed";
+ return status || "processing";
+}
+
+function getOrCreateAuditId(storage, key, prefix) {
+ try {
+ let value = storage.getItem(key);
+ if (!value) {
+ value = `${prefix}-${crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`}`;
+ storage.setItem(key, value);
+ }
+ return value;
+ } catch {
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
+ }
+}
+
+function setupClientAudit() {
+ window.__auditReady = true;
+ logClientOperation("page.open", {
+ path: location.pathname,
+ viewport: `${window.innerWidth}x${window.innerHeight}`,
+ referrerOrigin: browserUrlSummary(document.referrer)?.origin || "",
+ });
+
+ document.addEventListener("click", (event) => {
+ const target = event.target instanceof Element ? event.target.closest("button, a, [role='button']") : null;
+ if (!target) return;
+ logClientOperation("ui.click", describeAuditControl(target));
+ }, true);
+
+ document.addEventListener("change", (event) => {
+ const target = event.target;
+ if (!(target instanceof HTMLInputElement || target instanceof HTMLSelectElement || target instanceof HTMLTextAreaElement)) return;
+ logClientOperation("ui.change", describeAuditControl(target));
+ }, true);
+
+ document.addEventListener("input", (event) => {
+ const target = event.target;
+ if (!(target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement)) return;
+ const key = target.id || target.name || target.getAttribute("aria-label") || "anonymous-input";
+ clearTimeout(auditInputTimers.get(key));
+ auditInputTimers.set(key, setTimeout(() => {
+ logClientOperation("ui.input", describeAuditControl(target));
+ auditInputTimers.delete(key);
+ }, 900));
+ }, true);
+
+ document.addEventListener("submit", (event) => {
+ logClientOperation("ui.submit", { formId: event.target?.id || "", fieldCount: event.target?.elements?.length || 0 });
+ }, true);
+
+ document.addEventListener("play", (event) => {
+ if (event.target instanceof HTMLVideoElement) logClientOperation("video.play", { source: browserUrlSummary(event.target.currentSrc) });
+ }, true);
+ document.addEventListener("pause", (event) => {
+ if (event.target instanceof HTMLVideoElement) logClientOperation("video.pause", { currentTime: Math.round(event.target.currentTime * 10) / 10 });
+ }, true);
+
+ document.addEventListener("visibilitychange", () => {
+ logClientOperation("page.visibility", { state: document.visibilityState });
+ if (document.visibilityState === "hidden") flushAuditQueue(true);
+ });
+ window.addEventListener("pagehide", () => {
+ logClientOperation("page.close", { persisted: false });
+ flushAuditQueue(true);
+ });
+}
+
+function describeAuditControl(target) {
+ const value = auditControlValue(target);
+ return {
+ elementId: target.id || "",
+ tag: target.tagName?.toLowerCase() || "",
+ className: String(target.className || "").slice(0, 180),
+ type: target.type || "",
+ ariaLabel: String(target.getAttribute?.("aria-label") || "").slice(0, 120),
+ title: String(target.getAttribute?.("title") || "").slice(0, 120),
+ moduleTarget: target.dataset?.module || "",
+ step: target.dataset?.step || "",
+ removeMedia: target.dataset?.removeMedia || "",
+ textLength: String(target.textContent || "").trim().length,
+ href: target instanceof HTMLAnchorElement ? browserUrlSummary(target.href) : null,
+ value,
+ };
+}
+
+function auditControlValue(target) {
+ if (target instanceof HTMLInputElement && ["checkbox", "radio"].includes(target.type)) return { checked: target.checked };
+ if (target instanceof HTMLSelectElement) return { selected: String(target.value).slice(0, 120) };
+ if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
+ const value = String(target.value || "");
+ if (target.type === "url") return browserUrlSummary(value);
+ return { length: value.length, lines: value ? value.split(/\r?\n/).length : 0 };
+ }
+ return null;
+}
+
+function browserUrlSummary(value) {
+ if (!value) return null;
+ try {
+ const url = new URL(value, location.href);
+ return { origin: url.origin, pathname: url.pathname.slice(0, 300), hasQuery: Boolean(url.search) };
+ } catch {
+ return { valid: false, length: String(value).length };
+ }
+}
+
+function logClientOperation(operation, details = {}) {
+ auditQueue.push({
+ operation,
+ timestamp: new Date().toISOString(),
+ module: state.activeModule,
+ shotId: state.activeShotId || "",
+ details,
+ });
+ if (auditQueue.length >= 20) flushAuditQueue();
+ else if (!auditFlushTimer) auditFlushTimer = setTimeout(() => flushAuditQueue(), 1800);
+}
+
+function flushAuditQueue(useBeacon = false) {
+ clearTimeout(auditFlushTimer);
+ auditFlushTimer = null;
+ if (!auditQueue.length) return;
+ const events = auditQueue.splice(0, 50);
+ const payload = JSON.stringify({ clientId: auditClientId, sessionId: auditSessionId, events });
+ const endpoint = `${API_BASE}/api/events`;
+ if (useBeacon && navigator.sendBeacon) {
+ navigator.sendBeacon(endpoint, new Blob([payload], { type: "application/json" }));
+ return;
+ }
+ fetch(endpoint, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", "X-Session-Id": auditSessionId },
+ body: payload,
+ keepalive: true,
+ }).catch(() => {
+ auditQueue.unshift(...events.slice(-50));
+ });
+}
+
+function shouldLogClientApi(path) {
+ return !/^\/api\/(?:videos|assets\/tasks)\/[^/]+$/.test(path);
+}
+
+async function api(path, options = {}) {
+ const startedAt = Date.now();
+ const method = options.method || "GET";
+ const shouldLog = shouldLogClientApi(path);
+ 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, ...(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;
+ 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 ""; }
+}
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..1db6cf8
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,268 @@
+
+
+
+
+
+ 营响力|AI 短剧数字人 Demo
+
+
+
+
+
+
+
+
+
+
+
+ 片段进度
+ 0/1 已选定 · 预计 5 秒
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
✓
+
+
人物素材已入库并完成组合
+
审核通过的图片会绑定为一个可复用人物,也可在人物库中重新组合。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
候选版本
选择一个版本用于下一片段衔接
+
暂无候选
+
+
+
+
+
+
+
+ 所有片段选定版本后,再交给拼接服务合成完整短剧。
+
+
+
+
+
+
+
+
+
+
diff --git a/public/styles.css b/public/styles.css
new file mode 100644
index 0000000..240f6eb
--- /dev/null
+++ b/public/styles.css
@@ -0,0 +1,270 @@
+:root {
+ --ink: #17233c;
+ --muted: #667085;
+ --line: #e4e8f0;
+ --blue: #2957e8;
+ --blue-soft: #eef3ff;
+ --green: #0f9f6e;
+ --green-soft: #e9f8f2;
+ --amber: #b76e00;
+ --amber-soft: #fff5df;
+ --surface: #ffffff;
+ --canvas: #f5f7fb;
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
+ color: var(--ink);
+ background: var(--canvas);
+}
+
+* { box-sizing: border-box; }
+body { margin: 0; min-width: 320px; background: var(--canvas); }
+button, input, select, textarea { font: inherit; }
+button { cursor: pointer; }
+
+.topbar {
+ min-height: 84px;
+ padding: 18px max(24px, calc((100vw - 1480px) / 2));
+ background: #101a31;
+ color: #fff;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 20px;
+}
+.topbar h1 { margin: 2px 0 0; font-size: 22px; }
+.eyebrow, .section-kicker { font-size: 12px; letter-spacing: .08em; font-weight: 700; text-transform: uppercase; }
+.eyebrow { color: #92a9e8; }
+.section-kicker { color: var(--blue); }
+.connection { font-size: 13px; padding: 8px 12px; border-radius: 999px; background: rgba(255,255,255,.1); color: #dce5ff; }
+.connection.live { background: rgba(15,159,110,.2); color: #91ebca; }
+.connection.mock { background: rgba(255,183,77,.15); color: #ffd28a; }
+.connection.blocked { background: rgba(239,68,68,.16); color: #ffb1b1; }
+
+main { max-width: 1480px; margin: 0 auto; padding: 24px; }
+.panel, .sidebar { background: var(--surface); border: 1px solid var(--line); border-radius: 16px; }
+
+.module-tabs { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin: 18px 0; }
+.module-tab { min-height: 72px; border: 1px solid var(--line); border-radius: 14px; background: white; padding: 14px 18px; display: grid; gap: 4px; text-align: left; color: var(--muted); }
+.module-tab b { color: var(--ink); font-size: 16px; }
+.module-tab span { font-size: 12px; }
+.module-tab.active { border-color: var(--blue); background: var(--blue-soft); box-shadow: 0 0 0 2px rgba(41,87,232,.06); }
+.module-tab.active b { color: var(--blue); }
+body[data-active-module="library"] .production-only,
+body[data-active-module="production"] .library-only { display: none !important; }
+body[data-active-module="library"] .asset-library-title { margin-top: 0; }
+
+.shot-overview { margin: -2px 0 18px; padding: 14px 16px 12px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); }
+.shot-overview-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
+.shot-overview-head > div { display: flex; align-items: baseline; gap: 10px; min-width: 0; }
+.shot-overview-head strong { font-size: 13px; }
+.shot-overview-head span { color: var(--muted); font-size: 11px; }
+.shot-progress { height: 3px; margin-top: 10px; overflow: hidden; border-radius: 99px; background: #e9edf5; }
+.shot-progress i { display: block; width: 0; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--blue), #6c86f4); transition: width .25s ease; }
+.shot-list.shot-overview-list { display: flex; gap: 9px; margin-top: 10px; padding: 1px 1px 4px; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: thin; }
+.shot-overview-list .shot-card { flex: 0 0 215px; }
+
+.workspace { display: grid; grid-template-columns: 300px minmax(0, 1fr); gap: 18px; align-items: start; }
+.sidebar { padding: 18px; position: sticky; top: 18px; }
+.sidebar-title { display: flex; align-items: center; justify-content: space-between; font-weight: 750; margin-bottom: 14px; }
+.asset-library-title { margin-top: 18px; margin-bottom: 10px; }
+.scene-roles { display: grid; gap: 8px; }
+.role-card { width: 100%; display: grid; grid-template-columns: 30px minmax(0, 1fr) auto; gap: 9px; align-items: center; padding: 10px; border: 1px solid var(--line); border-radius: 11px; background: white; text-align: left; }
+.role-card.active { border-color: var(--blue); background: var(--blue-soft); }
+.role-select { grid-column: 1 / 3; display: grid; grid-template-columns: 30px minmax(0, 1fr); gap: 9px; align-items: center; min-width: 0; padding: 0; border: 0; background: transparent; text-align: left; }
+.role-letter { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 8px; background: #eef1f6; color: var(--muted); font-size: 12px; font-weight: 800; }
+.role-card.active .role-letter { background: var(--blue); color: white; }
+.role-copy { min-width: 0; display: grid; gap: 3px; }
+.role-copy strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
+.role-copy small { color: var(--muted); font-size: 10px; }
+.role-remove { border: 0; background: transparent; color: #98a1b2; padding: 4px; font-size: 15px; }
+.link-button { border: 0; padding: 0; background: transparent; color: var(--blue); font-size: 13px; }
+.secondary { border: 1px solid #cfd6e3; border-radius: 9px; background: white; color: var(--ink); padding: 9px 12px; font-weight: 700; }
+.full-button { width: 100%; margin: 10px 0 14px; color: var(--blue); border-style: dashed; }
+.compact-button { min-height: 36px; padding: 8px 12px; }
+.character-list { display: grid; gap: 9px; }
+.character-card { border: 1px solid var(--line); border-radius: 12px; background: #fafbfe; padding: 10px; }
+.character-card.selected { border-color: var(--blue); background: var(--blue-soft); }
+.character-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
+.character-head strong { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }
+.character-head span { color: var(--muted); font-size: 10px; white-space: nowrap; }
+.character-thumbs { display: flex; gap: 5px; margin: 9px 0; overflow: hidden; }
+.character-thumbs img, .character-thumb-placeholder { width: 42px; height: 42px; flex: 0 0 42px; border-radius: 7px; background: #e9edf5; object-fit: cover; }
+.character-actions { display: flex; gap: 7px; }
+.character-actions button { flex: 1; border: 1px solid #ccd5e6; border-radius: 7px; background: white; padding: 6px; color: var(--blue); font-size: 11px; }
+.character-actions .danger-link { flex: 0 0 auto; color: #b43a48; }
+.character-editor { margin: 0 0 14px; padding: 12px; border: 1px solid var(--blue); border-radius: 12px; background: var(--blue-soft); }
+.character-editor p { margin: 9px 0; color: var(--muted); font-size: 11px; }
+.asset-pick-list { display: grid; gap: 6px; max-height: 260px; overflow: auto; }
+.asset-pick { display: grid; grid-template-columns: 18px 38px minmax(0, 1fr); gap: 7px; align-items: center; padding: 6px; border-radius: 8px; background: white; font-size: 11px; }
+.asset-pick input { width: auto; }
+.asset-pick img { width: 38px; height: 38px; border-radius: 6px; object-fit: cover; background: #eef1f6; }
+.asset-pick span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.editor-actions { display: flex; justify-content: flex-end; gap: 7px; margin-top: 10px; }
+.history-assets { margin-top: 4px; }
+.history-assets summary { cursor: pointer; color: var(--ink); font-size: 12px; font-weight: 800; }
+.history-assets summary span { color: var(--muted); font-weight: 500; }
+.asset-list { display: grid; gap: 9px; }
+.selection-help { margin: 0 0 10px; color: var(--muted); font-size: 11px; line-height: 1.55; }
+.review-tracker { margin-bottom: 14px; padding: 12px; border: 1px solid var(--line); border-radius: 12px; background: #fafbfe; }
+.tracker-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 9px; }
+.tracker-head strong { font-size: 12px; }
+.tracker-head span { color: var(--muted); font-size: 11px; }
+.tracker-row { display: grid; grid-template-columns: 38px 58px minmax(0, 1fr); gap: 7px; align-items: center; min-height: 30px; border-top: 1px solid #edf0f5; font-size: 11px; }
+.tracker-row > span { color: var(--muted); }
+.tracker-row b { font-weight: 750; color: #7b8497; }
+.tracker-row small { overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; }
+.tracker-row.processing b { color: var(--amber); }
+.tracker-row.success b { color: var(--green); }
+.tracker-row.error b, .tracker-row.error small { color: #c03945; }
+.asset-item { width: 100%; border: 1px solid var(--line); background: #fff; border-radius: 11px; padding: 12px; text-align: left; display: grid; gap: 4px; }
+.asset-card { grid-template-columns: 56px minmax(0, 1fr); gap: 10px; align-items: center; padding: 8px; }
+.asset-card { position: relative; }
+.asset-card.selected::after { content: attr(data-order); position: absolute; top: 6px; right: 6px; min-width: 20px; height: 20px; padding: 0 4px; border-radius: 10px; display: grid; place-items: center; background: var(--blue); color: white; font-size: 10px; font-weight: 800; }
+.role-reference-title { margin-top: 5px; color: #286a58; font-size: 12px; font-weight: 800; }
+.asset-card img { width: 56px; height: 56px; border-radius: 8px; background: #eef1f6; object-fit: cover; }
+.asset-card img.image-error { opacity: .22; }
+.asset-info { min-width: 0; display: grid; gap: 5px; }
+.asset-item:hover, .asset-item.selected { border-color: var(--blue); background: var(--blue-soft); }
+.asset-item strong { font-size: 13px; }
+.asset-item span { color: var(--muted); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.empty-small { color: var(--muted); font-size: 13px; padding: 18px 10px; text-align: center; border: 1px dashed #ccd3df; border-radius: 10px; }
+.rule-box { margin-top: 18px; padding: 14px; border-radius: 12px; background: #f7f9fd; font-size: 12px; }
+.rule-box ul { padding-left: 18px; margin: 8px 0 0; color: var(--muted); line-height: 1.7; }
+
+.panel { padding: 24px; }
+.storyboard { margin: 0 0 20px; padding: 14px; border: 1px solid var(--line); border-radius: 14px; background: #f7f9fd; }
+.version-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
+.shot-list { display: grid; gap: 8px; }
+.shot-card { width: 100%; padding: 4px; border: 1px solid var(--line); border-radius: 10px; background: white; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px; align-items: center; }
+.shot-card.active { border-color: var(--blue); background: var(--blue-soft); }
+.shot-card-select { min-width: 0; padding: 6px; border: 0; background: transparent; display: grid; grid-template-columns: 30px minmax(0, 1fr) auto; gap: 9px; align-items: center; text-align: left; }
+.shot-number { width: 30px; height: 30px; border-radius: 8px; display: grid; place-items: center; background: #eef1f6; color: var(--muted); font-size: 11px; font-weight: 800; }
+.shot-card.active .shot-number { background: var(--blue); color: white; }
+.shot-card-copy { min-width: 0; display: grid; gap: 3px; }
+.shot-card-copy strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
+.shot-card-copy small { color: var(--muted); font-size: 10px; }
+.shot-state { width: 8px; height: 8px; border-radius: 50%; background: #c5ccda; }
+.shot-state.ready { background: var(--green); }
+.shot-state.running { background: var(--amber); }
+.shot-state.failed { background: #c03945; }
+.shot-delete-button { width: 26px; height: 26px; border: 0; border-radius: 7px; background: transparent; color: #9aa3b2; font-size: 18px; line-height: 1; }
+.shot-delete-button:hover, .shot-delete-button:focus-visible { background: #fff0f1; color: #c03945; }
+.continuity-choice { max-width: 420px; }
+.production-panel-header { margin-top: 22px; }
+.panel-header { display: flex; justify-content: space-between; gap: 20px; align-items: flex-start; margin-bottom: 20px; }
+.panel-header.compact { margin-top: 24px; }
+.panel-header h2 { font-size: 20px; margin: 6px 0 0; }
+.status-badge { font-size: 12px; padding: 6px 10px; border-radius: 999px; font-weight: 700; }
+.status-badge.neutral { color: #667085; background: #f0f2f5; }
+.status-badge.processing { color: var(--amber); background: var(--amber-soft); }
+.status-badge.success { color: var(--green); background: var(--green-soft); }
+.status-badge.error { color: #c03945; background: #fff0f1; }
+
+.form-grid { display: grid; grid-template-columns: repeat(4, minmax(130px, 1fr)); gap: 15px; }
+.form-grid > .wide { grid-column: 1 / -1; }
+.view-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
+.view-grid.wide { grid-column: 1 / -1; }
+.view-field { display: flex; flex-direction: column; gap: 7px; padding: 14px; border: 1px solid var(--line); border-radius: 12px; background: #fafbfe; }
+.view-field > span { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 750; }
+.view-field > span b { color: var(--blue); font-size: 11px; letter-spacing: .08em; }
+.view-field small { color: var(--muted); font-size: 12px; }
+.mock-hint { grid-column: 1 / -1; margin: -5px 0 0; color: var(--muted); font-size: 11px; }
+.mock-hint code { padding: 2px 5px; border-radius: 4px; background: #f0f2f5; color: var(--ink); }
+.field { display: flex; flex-direction: column; gap: 7px; }
+.field.wide { grid-column: 1 / -1; }
+.field > span { font-size: 13px; font-weight: 700; }
+.field small { color: var(--muted); font-size: 12px; }
+input, select, textarea { width: 100%; border: 1px solid #cfd6e3; border-radius: 9px; padding: 10px 12px; color: var(--ink); background: white; outline: none; }
+input:focus, select:focus, textarea:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(41,87,232,.1); }
+textarea { resize: vertical; min-height: 132px; line-height: 1.65; }
+.multimodal-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
+.media-field { padding: 14px; border: 1px solid var(--line); border-radius: 12px; background: #fafbfe; }
+.media-field-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
+.media-field-head > span { font-weight: 700; }
+.media-add-button { border: 0; background: transparent; color: var(--blue); padding: 2px 0; font-size: 12px; font-weight: 700; cursor: pointer; }
+.media-add-button:disabled { color: var(--muted); cursor: not-allowed; }
+.media-url-list { display: grid; gap: 8px; }
+.media-url-row { display: grid; grid-template-columns: minmax(0, 1fr) 30px; gap: 8px; align-items: center; }
+.media-url-row input { min-width: 0; background: white; font-size: 12px; }
+.media-remove-button { width: 30px; height: 30px; border: 1px solid var(--line); border-radius: 8px; background: white; color: var(--muted); cursor: pointer; line-height: 1; }
+.media-remove-button:hover { border-color: #f3b6b6; color: var(--danger); background: #fff6f6; }
+.input-quota { padding: 10px 12px; border-radius: 9px; background: #f0f3f8; color: var(--muted); font-size: 12px; }
+.input-quota.valid { color: var(--green); background: var(--green-soft); }
+.input-quota.invalid { color: #c03945; background: #fff0f1; }
+.action-field { justify-content: end; }
+.primary { border: 0; border-radius: 9px; background: var(--blue); color: white; font-weight: 750; padding: 11px 18px; min-height: 42px; }
+.primary:hover { background: #2048c7; }
+.primary:disabled { background: #aab4cc; cursor: not-allowed; }
+.video-button { min-width: 210px; }
+.toggle-row { display: flex; gap: 9px; align-items: center; font-size: 13px; padding-top: 25px; }
+.toggle-row input { width: auto; }
+.disabled-section { opacity: .48; pointer-events: none; }
+
+.result-card { margin-top: 18px; padding: 15px; border-radius: 12px; background: var(--green-soft); display: flex; gap: 13px; align-items: flex-start; }
+.result-icon { width: 32px; height: 32px; border-radius: 50%; background: var(--green); color: white; display: grid; place-items: center; font-weight: 900; }
+.result-card p { margin: 5px 0 8px; color: #41645a; font-size: 13px; }
+.result-card code { display: inline-block; padding: 5px 8px; border-radius: 6px; background: white; color: var(--green); font-size: 12px; }
+.reference-list { display: grid; gap: 6px; margin-top: 8px; }
+.reference-row { display: flex; align-items: center; gap: 8px; }
+.reference-row span { min-width: 34px; color: #41645a; font-size: 12px; font-weight: 700; }
+hr { border: 0; border-top: 1px solid var(--line); margin: 26px 0 0; }
+
+.generation { margin-top: 22px; padding: 16px; border-radius: 14px; border: 1px solid var(--line); background: #fafbfe; }
+.video-stage { aspect-ratio: 16 / 8; max-height: 430px; background: #111827; border-radius: 11px; overflow: hidden; display: grid; place-items: center; color: white; }
+.video-stage video { width: 100%; height: 100%; object-fit: contain; }
+.generation-error { max-width: 620px; padding: 24px; text-align: center; }
+.generation-error strong { display: block; color: #ffb8bd; font-size: 18px; }
+.generation-error p { margin: 10px 0 0; color: #d7deed; font-size: 13px; line-height: 1.65; }
+.mock-frame { text-align: center; color: #d6dceb; }
+.mock-person { width: 88px; height: 120px; margin: 0 auto 15px; border-radius: 42px 42px 20px 20px; background: linear-gradient(145deg, #7e9cff, #d8a7e5); box-shadow: 0 0 50px rgba(108,132,255,.4); animation: pulse 1.6s infinite ease-in-out; }
+@keyframes pulse { 50% { transform: scale(1.04); filter: brightness(1.16); } }
+.progress-meta { margin-top: 13px; display: flex; justify-content: space-between; gap: 16px; }
+.progress-meta strong, .progress-meta span { display: block; }
+.progress-meta div span { color: var(--muted); font-size: 12px; margin-top: 3px; }
+.progress-meta .generation-detail { display: inline-block; margin-right: 12px; }
+.progress-track { height: 7px; margin-top: 10px; background: #e3e7ef; border-radius: 99px; overflow: hidden; }
+.progress-track i { display: block; height: 100%; width: 0; background: linear-gradient(90deg, var(--blue), #7f5ce8); transition: width .4s; }
+.version-section, .assembly-panel { margin-top: 20px; padding-top: 18px; border-top: 1px solid var(--line); }
+.version-head h3 { margin: 5px 0 0; font-size: 16px; }
+.version-head > span { color: var(--muted); font-size: 11px; }
+.version-list, .assembly-list { display: grid; gap: 10px; margin-top: 12px; }
+.version-card { display: grid; grid-template-columns: 116px minmax(0, 1fr) auto; gap: 12px; align-items: center; padding: 10px; border: 1px solid var(--line); border-radius: 12px; background: #fafbfe; }
+.version-card.selected { border-color: var(--green); background: var(--green-soft); }
+.version-preview { width: 116px; aspect-ratio: 16 / 9; border-radius: 8px; overflow: hidden; display: grid; place-items: center; background: #111827; color: #d6dceb; font-size: 10px; }
+.version-preview video { width: 100%; height: 100%; object-fit: cover; }
+.version-copy { min-width: 0; display: grid; gap: 4px; }
+.version-copy strong { font-size: 13px; }
+.version-copy span { overflow: hidden; color: var(--muted); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
+.version-actions { display: grid; grid-template-columns: repeat(2, auto); gap: 6px; justify-content: end; }
+.version-card button, .version-card a { border: 1px solid #cbd4e5; border-radius: 8px; background: white; padding: 7px 10px; color: var(--blue); font-size: 11px; text-align: center; text-decoration: none; cursor: pointer; }
+.version-card button:disabled { color: var(--muted); cursor: not-allowed; }
+.version-actions .select-version-button { grid-column: 1 / -1; }
+.version-card.selected .select-version-button { color: var(--green); }
+.assembly-row { display: grid; grid-template-columns: 50px minmax(0, 1fr) auto; gap: 12px; align-items: center; padding: 11px 12px; border: 1px solid var(--line); border-radius: 10px; background: #fafbfe; }
+.assembly-row b { color: var(--blue); font-size: 11px; }
+.assembly-row strong { font-size: 12px; }
+.assembly-row span { color: var(--muted); font-size: 11px; }
+.assembly-panel > p { margin: 12px 0 0; color: var(--muted); font-size: 12px; }
+.toast { position: fixed; right: 22px; bottom: 22px; max-width: 420px; background: #16213b; color: white; padding: 12px 15px; border-radius: 10px; box-shadow: 0 12px 34px rgba(15,23,42,.22); font-size: 13px; z-index: 30; }
+.toast.error { background: #a52f3a; }
+.hidden { display: none !important; }
+
+@media (max-width: 980px) {
+ .workspace { grid-template-columns: 1fr; }
+ .sidebar { position: static; }
+}
+
+@media (max-width: 680px) {
+ .topbar { align-items: flex-start; padding: 16px; }
+ main { padding: 14px; }
+ .panel { padding: 18px; }
+ .module-tabs { grid-template-columns: 1fr; }
+ .shot-overview-head { align-items: flex-start; }
+ .shot-overview-head > div { align-items: flex-start; flex-direction: column; gap: 3px; }
+ .shot-overview-list .shot-card { flex-basis: 190px; }
+ .version-card { grid-template-columns: 90px minmax(0, 1fr); }
+ .version-actions { grid-column: 1 / -1; width: 100%; grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .form-grid { grid-template-columns: 1fr; }
+ .view-grid { grid-template-columns: 1fr; }
+ .multimodal-grid { grid-template-columns: 1fr; }
+ .field.wide { grid-column: auto; }
+}
diff --git a/server.mjs b/server.mjs
new file mode 100644
index 0000000..2c2b610
--- /dev/null
+++ b/server.mjs
@@ -0,0 +1,740 @@
+import { createServer } from "node:http";
+import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
+import { extname, join, normalize } from "node:path";
+import { Readable } from "node:stream";
+import { pipeline } from "node:stream/promises";
+import { fileURLToPath } from "node:url";
+import { AsyncLocalStorage } from "node:async_hooks";
+import { createHash, randomUUID } from "node:crypto";
+import {
+ extractVideoTaskId,
+ extractVideoUrl,
+ normalizeAssetReference,
+ normalizeVideoStatus,
+} from "./lib/normalize.mjs";
+
+const root = fileURLToPath(new URL(".", import.meta.url));
+const publicDir = join(root, "public");
+const requestContext = new AsyncLocalStorage();
+
+loadLocalEnv();
+
+const port = Number(process.env.PORT || 4173);
+const host = process.env.HOST || "127.0.0.1";
+const apiToken = process.env.KK_API_KEY || process.env.KK_VIDEO_TOKEN || "";
+const mockMode = process.env.DEMO_MOCK === "1" || !apiToken;
+const auditLogDir = process.env.AUDIT_LOG_DIR || join(root, "logs");
+const auditHashSalt = process.env.AUDIT_HASH_SALT || "yingxiangli-demo-audit";
+
+const mockAssets = [];
+const mockVideos = new Map();
+const assetReviewTasks = new Map();
+
+const server = createServer((request, response) => {
+ const startedAt = Date.now();
+ const requestId = randomUUID();
+ const sessionId = safeIdentifier(request.headers["x-session-id"]);
+ const requestPath = safeRequestPath(request.url);
+ const context = { requestId, sessionId };
+ response.setHeader("X-Request-Id", requestId);
+
+ response.once("finish", () => {
+ writeAuditLog({
+ level: response.statusCode >= 500 ? "error" : response.statusCode >= 400 ? "warn" : "info",
+ event: "http.request",
+ requestId,
+ sessionId,
+ method: request.method,
+ path: requestPath,
+ status: response.statusCode,
+ durationMs: Date.now() - startedAt,
+ ipHash: hashAuditValue(clientIp(request)),
+ userAgent: String(request.headers["user-agent"] || "").slice(0, 300),
+ referer: sanitizeUrlForLog(request.headers.referer || ""),
+ });
+ });
+
+ requestContext.run(context, async () => {
+ try {
+ const url = new URL(request.url, `http://${request.headers.host || "localhost"}`);
+
+ if (request.method === "OPTIONS") {
+ response.writeHead(204, corsHeaders());
+ response.end();
+ return;
+ }
+
+ if (url.pathname.startsWith("/api/")) {
+ await handleApi(request, response, url);
+ return;
+ }
+
+ serveStatic(response, url.pathname);
+ } catch (error) {
+ writeAuditLog({
+ level: "error",
+ event: "http.error",
+ requestId,
+ sessionId,
+ method: request.method,
+ path: requestPath,
+ error: sanitizeLogData({ name: error.name, message: error.message, status: error.status }),
+ });
+ if (response.headersSent) response.destroy(error);
+ else sendJson(response, error.status || 500, { error: error.message || "服务异常" });
+ }
+ });
+});
+
+server.listen(port, host, () => {
+ console.log(`AI短剧数字人 Demo: http://${host === "0.0.0.0" ? "localhost" : host}:${port}`);
+ console.log(`运行模式: ${mockMode ? "Mock" : "Live"}`);
+});
+
+async function handleApi(request, response, url) {
+ if (request.method === "POST" && url.pathname === "/api/events") {
+ const body = await readJson(request);
+ const events = (Array.isArray(body.events) ? body.events : [body]).slice(0, 50);
+ const context = requestContext.getStore() || {};
+ for (const item of events) {
+ writeAuditLog({
+ level: "info",
+ event: "client.operation",
+ requestId: context.requestId,
+ sessionId: safeIdentifier(context.sessionId || body.sessionId || item?.sessionId),
+ clientId: safeIdentifier(body.clientId || item?.clientId),
+ operation: String(item?.operation || item?.event || "unknown").slice(0, 120),
+ module: String(item?.module || "").slice(0, 50),
+ shotId: safeIdentifier(item?.shotId),
+ details: sanitizeLogData(item?.details || {}),
+ clientTimestamp: String(item?.timestamp || "").slice(0, 40),
+ });
+ }
+ sendJson(response, 202, { ok: true, accepted: events.length });
+ return;
+ }
+
+ if (request.method === "GET" && url.pathname === "/api/config") {
+ sendJson(response, 200, {
+ mockMode,
+ assetReady: mockMode || Boolean(apiToken),
+ videoReady: mockMode || Boolean(apiToken),
+ apiVersion: "v3",
+ missing: !mockMode && !apiToken ? ["KK_API_KEY"] : [],
+ });
+ return;
+ }
+
+ if (request.method === "POST" && url.pathname === "/api/assets/review") {
+ const body = await readJson(request);
+ const imageInputs = Array.isArray(body.images)
+ ? body.images.map((item) => typeof item === "string" ? { url: item } : item)
+ : [{ url: body.url }];
+ if (imageInputs.length < 1 || imageInputs.length > 9) {
+ throw new ClientError("素材图片数量必须为 1–9 张");
+ }
+ for (const item of imageInputs) validatePublicUrl(item.url);
+
+ if (mockMode) {
+ const now = new Date().toISOString();
+ const labels = { front: "正面", side: "侧面", back: "背面" };
+ const items = imageInputs.map((input, index) => {
+ const id = `mat_demo_${Date.now()}_${index + 1}`;
+ const viewLabel = labels[input.view] || `参考图${index + 1}`;
+ const rejected = /reject|fail/i.test(input.url);
+ if (!rejected) {
+ mockAssets.unshift({
+ asset_id: id,
+ purpose: `${body.name || "数字人形象"} · ${viewLabel}`,
+ type: "image",
+ status: "ready",
+ original_url: input.url,
+ View: input.view || "reference",
+ CreateTime: now,
+ UpdateTime: now,
+ });
+ }
+ return {
+ source_url: input.url,
+ view: input.view || "reference",
+ asset_type: "Image",
+ submit_review_status: rejected ? 0 : 1,
+ downstream_asset_id: rejected ? "" : id,
+ asset_url: rejected ? "" : `asset://${id}`,
+ error_code: rejected ? "MOCK_REVIEW_REJECTED" : "",
+ error_message: rejected ? `${viewLabel}图未通过素材审核(Mock 驳回)` : "",
+ };
+ });
+ sendJson(response, 200, {
+ code: 200,
+ status: "completed",
+ result: { items },
+ mock: true,
+ });
+ return;
+ }
+
+ requireToken(apiToken, "KK_API_KEY");
+ const labels = { front: "正面", side: "侧面", back: "背面" };
+ const uploaded = await Promise.all(imageInputs.map(async (input, index) => {
+ const viewLabel = labels[input.view] || `参考图${index + 1}`;
+ const upstream = await callJson("https://ai-api.kkidc.com/v1/assets/upload", {
+ method: "POST",
+ headers: bearerHeaders(),
+ body: JSON.stringify({
+ url: input.url,
+ type: "image",
+ purpose: `${body.name || "数字人形象"} · ${viewLabel}`,
+ }),
+ });
+ if (upstream.status >= 400 || upstream.body?.success === false || !upstream.body?.data?.asset_id) {
+ throw new ClientError(extractUpstreamMessage(upstream.body) || `${viewLabel}图提交失败`, upstream.status || 400);
+ }
+ return {
+ sourceUrl: input.url,
+ view: input.view || "reference",
+ assetId: upstream.body.data.asset_id,
+ groupId: upstream.body.data.group_id || "",
+ status: String(upstream.body.data.status || "pending").toLowerCase(),
+ errorMessage: "",
+ };
+ }));
+ const taskId = `asset-review-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+ const task = { id: taskId, items: uploaded, createdAt: Date.now() };
+ assetReviewTasks.set(taskId, task);
+ const result = formatAssetReviewTask(task);
+ sendJson(response, result.status === "completed" ? 200 : 202, result);
+ return;
+ }
+
+ if (request.method === "GET" && url.pathname.startsWith("/api/assets/tasks/")) {
+ requireToken(apiToken, "KK_API_KEY");
+ const taskId = decodeURIComponent(url.pathname.split("/").pop());
+ const task = assetReviewTasks.get(taskId);
+ if (!task) throw new ClientError("素材审核任务不存在或本地服务已重启,请在素材库刷新状态", 404);
+ await refreshAssetReviewTask(task);
+ sendJson(response, 200, formatAssetReviewTask(task));
+ return;
+ }
+
+ if (request.method === "GET" && url.pathname === "/api/assets") {
+ if (mockMode) {
+ sendJson(response, 200, {
+ code: 0,
+ message: "ok",
+ data: { items: mockAssets, total: mockAssets.length, page: 1, page_size: mockAssets.length },
+ mock: true,
+ });
+ return;
+ }
+ requireToken(apiToken, "KK_API_KEY");
+ const upstreamUrl = new URL("https://ai-api.kkidc.com/v1/assets");
+ for (const key of ["page", "page_size", "purpose", "sort_order", "group_id"]) {
+ for (const value of url.searchParams.getAll(key)) upstreamUrl.searchParams.append(key, value);
+ }
+ const upstream = await callJson(upstreamUrl, { headers: bearerHeaders(false) });
+ sendJson(response, upstream.status, upstream.body, upstream.trackId);
+ return;
+ }
+
+ if (request.method === "POST" && url.pathname === "/api/videos") {
+ const body = await readJson(request);
+ const payload = buildVideoPayload(body);
+
+ if (mockMode) {
+ const taskId = `cgt-demo-${Date.now()}`;
+ mockVideos.set(taskId, { createdAt: Date.now(), request: body });
+ const mockResponse = {
+ id: taskId,
+ task_id: taskId,
+ object: "video",
+ model: body.model,
+ status: "processing",
+ progress: 0,
+ mock: true,
+ };
+ sendJson(response, 200, mockResponse);
+ return;
+ }
+
+ requireToken(apiToken, "KK_API_KEY");
+ const upstream = await callJson("https://ai-api.kkidc.com/sd/api/v3/contents/generations/tasks", {
+ method: "POST",
+ headers: bearerHeaders(),
+ body: JSON.stringify(payload),
+ });
+ sendJson(response, upstream.status, upstream.body, upstream.trackId);
+ return;
+ }
+
+ const videoFileMatch = request.method === "GET" && url.pathname.match(/^\/api\/videos\/([^/]+)\/video\.mp4$/);
+ if (videoFileMatch) {
+ await streamVideoTask(response, decodeURIComponent(videoFileMatch[1]));
+ return;
+ }
+
+ if (request.method === "GET" && url.pathname.startsWith("/api/videos/")) {
+ const taskId = url.pathname.split("/").pop();
+ if (mockMode) {
+ const task = mockVideos.get(taskId);
+ if (!task) throw new ClientError("Mock 任务不存在", 404);
+ const elapsed = Date.now() - task.createdAt;
+ const completed = elapsed > 4500;
+ sendJson(response, 200, {
+ id: taskId,
+ task_id: taskId,
+ object: "video",
+ model: task.request.model,
+ status: completed ? "completed" : "processing",
+ progress: completed ? 100 : Math.min(90, Math.round(elapsed / 50)),
+ mock: true,
+ });
+ return;
+ }
+
+ requireToken(apiToken, "KK_API_KEY");
+ const upstream = await callJson(`https://ai-api.kkidc.com/sd/api/v3/contents/generations/tasks/${encodeURIComponent(taskId)}`, {
+ headers: bearerHeaders(false),
+ });
+ const normalized = {
+ ...upstream.body,
+ _normalized: {
+ taskId: extractVideoTaskId(upstream.body),
+ status: normalizeVideoStatus(upstream.body),
+ videoUrl: extractVideoUrl(upstream.body),
+ },
+ };
+ sendJson(response, upstream.status, normalized, upstream.trackId);
+ return;
+ }
+
+ sendJson(response, 404, { error: "接口不存在" });
+}
+
+async function streamVideoTask(response, taskId) {
+ if (mockMode) throw new ClientError("Mock 视频没有可下载的真实文件", 404);
+ requireToken(apiToken, "KK_API_KEY");
+ const startedAt = Date.now();
+ const context = requestContext.getStore() || {};
+ writeAuditLog({ level: "info", event: "video.proxy.start", requestId: context.requestId, sessionId: context.sessionId, taskId });
+
+ const task = await callJson(`https://ai-api.kkidc.com/sd/api/v3/contents/generations/tasks/${encodeURIComponent(taskId)}`, {
+ headers: bearerHeaders(false),
+ });
+ if (task.status < 200 || task.status >= 300) {
+ throw new ClientError(task.body?.message || "无法读取视频任务", task.status);
+ }
+
+ const videoUrl = extractVideoUrl(task.body);
+ if (!videoUrl || !/^https?:\/\//i.test(videoUrl)) throw new ClientError("视频任务尚无可下载的结果", 409);
+
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 2 * 60 * 1000);
+ try {
+ const upstream = await fetch(videoUrl, { signal: controller.signal });
+ if (!upstream.ok || !upstream.body) throw new ClientError("服务商视频链接已失效,请重新查询任务", upstream.status || 502);
+ const contentType = upstream.headers.get("content-type") || "video/mp4";
+ const extension = /quicktime|mov/i.test(contentType) ? "mov" : "mp4";
+ const safeTaskId = taskId.replace(/[^a-z0-9_-]/gi, "").slice(-48) || "video";
+ response.writeHead(200, {
+ "Content-Type": contentType,
+ "Content-Disposition": `inline; filename="yingxiangli-${safeTaskId}.${extension}"`,
+ "Cache-Control": "private, no-store",
+ });
+ await pipeline(Readable.fromWeb(upstream.body), response);
+ writeAuditLog({
+ level: "info",
+ event: "video.proxy.finish",
+ requestId: context.requestId,
+ sessionId: context.sessionId,
+ taskId,
+ durationMs: Date.now() - startedAt,
+ contentType,
+ contentLength: Number(upstream.headers.get("content-length") || 0) || null,
+ });
+ } catch (error) {
+ writeAuditLog({
+ level: "error",
+ event: "video.proxy.error",
+ requestId: context.requestId,
+ sessionId: context.sessionId,
+ taskId,
+ durationMs: Date.now() - startedAt,
+ error: sanitizeLogData({ name: error.name, message: error.message }),
+ });
+ throw error;
+ } finally {
+ clearTimeout(timeout);
+ }
+}
+
+async function callJson(url, options = {}) {
+ const startedAt = Date.now();
+ const context = requestContext.getStore() || {};
+ const method = options.method || "GET";
+ const target = sanitizeUrlForLog(String(url));
+ let requestBody = null;
+ if (typeof options.body === "string") {
+ try { requestBody = JSON.parse(options.body); }
+ catch { requestBody = { bodyLength: options.body.length }; }
+ }
+ writeAuditLog({
+ level: "info",
+ event: "upstream.start",
+ requestId: context.requestId,
+ sessionId: context.sessionId,
+ method,
+ target,
+ request: sanitizeLogData(requestBody),
+ });
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 90000);
+ try {
+ const response = await fetch(url, { ...options, signal: controller.signal });
+ const text = await response.text();
+ let body;
+ try {
+ body = text ? JSON.parse(text) : {};
+ } catch {
+ body = { error: "上游返回了非 JSON 内容", detail: text.slice(0, 500) };
+ }
+ const result = {
+ status: response.status,
+ body,
+ trackId: response.headers.get("x-track-id") || "",
+ };
+ writeAuditLog({
+ level: response.ok ? "info" : "warn",
+ event: "upstream.finish",
+ requestId: context.requestId,
+ sessionId: context.sessionId,
+ method,
+ target,
+ status: response.status,
+ durationMs: Date.now() - startedAt,
+ trackId: result.trackId,
+ response: sanitizeLogData(body),
+ });
+ return result;
+ } catch (error) {
+ writeAuditLog({
+ level: "error",
+ event: "upstream.error",
+ requestId: context.requestId,
+ sessionId: context.sessionId,
+ method,
+ target,
+ durationMs: Date.now() - startedAt,
+ error: sanitizeLogData({ name: error.name, message: error.message }),
+ });
+ throw error;
+ } finally {
+ clearTimeout(timeout);
+ }
+}
+
+function serveStatic(response, pathname) {
+ const requested = pathname === "/" ? "index.html" : pathname.replace(/^\//, "");
+ const safePath = normalize(requested).replace(/^(\.\.(\/|\\|$))+/, "");
+ const filePath = join(publicDir, safePath);
+ if (!filePath.startsWith(publicDir) || !existsSync(filePath)) {
+ sendJson(response, 404, { error: "页面不存在" });
+ return;
+ }
+
+ const mime = {
+ ".html": "text/html; charset=utf-8",
+ ".css": "text/css; charset=utf-8",
+ ".js": "text/javascript; charset=utf-8",
+ ".svg": "image/svg+xml",
+ }[extname(filePath)] || "application/octet-stream";
+ response.writeHead(200, { "Content-Type": mime, "Cache-Control": "no-store" });
+ response.end(readFileSync(filePath));
+}
+
+function loadLocalEnv() {
+ const envPath = join(root, ".env.local");
+ if (!existsSync(envPath)) return;
+ const lines = readFileSync(envPath, "utf8").split(/\r?\n/);
+ for (const line of lines) {
+ const match = line.match(/^([A-Z0-9_]+)=(.*)$/);
+ if (!match || process.env[match[1]]) continue;
+ process.env[match[1]] = match[2].replace(/^['"]|['"]$/g, "");
+ }
+}
+
+async function readJson(request) {
+ const chunks = [];
+ let size = 0;
+ for await (const chunk of request) {
+ size += chunk.length;
+ if (size > 1_000_000) throw new ClientError("请求体过大", 413);
+ chunks.push(chunk);
+ }
+ try {
+ return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
+ } catch {
+ throw new ClientError("请求体不是有效 JSON");
+ }
+}
+
+function validatePublicUrl(value) {
+ let url;
+ try {
+ url = new URL(value);
+ } catch {
+ throw new ClientError("请输入公网可访问的图片 URL");
+ }
+ if (!["http:", "https:"].includes(url.protocol)) throw new ClientError("素材仅支持 http/https URL");
+ if (!/\.(jpe?g|png|webp|bmp|tiff?|gif|heic|heif)(\?.*)?$/i.test(url.href)) {
+ throw new ClientError("URL 必须带受支持的图片扩展名,例如 .png 或 .jpg");
+ }
+}
+
+function requireToken(value, name) {
+ if (!value) throw new ClientError(`服务端缺少 ${name} 配置`, 503);
+}
+
+function clamp(value, min, max) {
+ return Math.min(max, Math.max(min, value));
+}
+
+function resolveResolution(model = "", requested = "720p") {
+ return requested === "480p" ? "480p" : "720p";
+}
+
+function buildVideoPayload(body) {
+ const assetReferences = Array.isArray(body.assetReferences)
+ ? body.assetReferences
+ : [body.assetReference].filter(Boolean);
+ const extraImageReferences = Array.isArray(body.extraImageReferences) ? body.extraImageReferences : [];
+ const videoReferences = Array.isArray(body.videoReferences) ? body.videoReferences : [];
+ const imageReferences = [...assetReferences, ...extraImageReferences];
+ if (assetReferences.length < 1 || assetReferences.some((item) => !/^asset:\/\/(?:mat_|asset-)/i.test(item))) {
+ throw new ClientError("请先选择审核通过的人物素材(asset://mat_...)");
+ }
+ if (imageReferences.length > 9) {
+ throw new ClientError(`人物图片与其他参考图片合计最多 9 张,当前为 ${imageReferences.length} 张`);
+ }
+ if (extraImageReferences.some((item) => !isPublicMediaUrl(item, "image"))) {
+ throw new ClientError("其他参考图片必须是公网可访问的 http/https 图片 URL");
+ }
+ if (videoReferences.length > 3 || videoReferences.some((item) => !isPublicMediaUrl(item, "video"))) {
+ throw new ClientError("参考视频最多 3 条,且必须是公网可访问的 MP4/MOV URL");
+ }
+ if (!String(body.prompt || "").trim()) throw new ClientError("请填写分镜提示词");
+ return {
+ model: resolveVideoModel(body.model),
+ content: [
+ { type: "text", text: body.prompt },
+ ...imageReferences.map((reference) => ({
+ type: "image_url",
+ image_url: { url: reference },
+ role: "reference_image",
+ })),
+ ...videoReferences.map((reference) => ({
+ type: "video_url",
+ video_url: { url: reference },
+ role: "reference_video",
+ })),
+ ],
+ duration: clamp(Number(body.duration || 5), 4, 15),
+ resolution: resolveResolution(body.model, body.resolution),
+ ratio: body.ratio || "9:16",
+ generate_audio: body.generateAudio !== false,
+ watermark: Boolean(body.watermark),
+ };
+}
+
+function isPublicMediaUrl(value, kind) {
+ try {
+ const url = new URL(value);
+ if (!["http:", "https:"].includes(url.protocol)) return false;
+ const pattern = kind === "video" ? /\.(mp4|mov)(\?.*)?$/i : /\.(jpe?g|png|webp|bmp|tiff?|gif|heic|heif)(\?.*)?$/i;
+ return pattern.test(url.href);
+ } catch {
+ return false;
+ }
+}
+
+function resolveVideoModel(value = "") {
+ const aliases = {
+ "seed-2": "doubao-seedance-2-0-260128",
+ "seed-2-fast": "doubao-seedance-2-0-fast-260128",
+ "seed-2-mini": "doubao-seedance-2-0-mini-260615",
+ };
+ if (aliases[value]) return aliases[value];
+ if (/^doubao-seedance-2-0-(?:fast-260128|mini-260615|260128)$/.test(value)) return value;
+ return "doubao-seedance-2-0-260128";
+}
+
+function bearerHeaders(includeContentType = true) {
+ return {
+ Authorization: `Bearer ${apiToken}`,
+ ...(includeContentType ? { "Content-Type": "application/json" } : {}),
+ };
+}
+
+function extractUpstreamMessage(body) {
+ if (!body) return "";
+ if (typeof body === "string") return body;
+ return body.error?.message || body.error || body.message || body.detail || "";
+}
+
+async function refreshAssetReviewTask(task) {
+ await Promise.all(task.items.map(async (item) => {
+ if (["ready", "failed"].includes(item.status)) return;
+ const upstreamUrl = new URL(`https://ai-api.kkidc.com/v1/assets/${encodeURIComponent(item.assetId)}`);
+ if (item.groupId) upstreamUrl.searchParams.set("group_id", item.groupId);
+ const upstream = await callJson(upstreamUrl, { headers: bearerHeaders(false) });
+ if (upstream.status >= 400 || upstream.body?.success === false) {
+ item.status = "failed";
+ item.errorMessage = extractUpstreamMessage(upstream.body) || "素材状态查询失败";
+ return;
+ }
+ item.status = String(upstream.body?.data?.status || item.status || "pending").toLowerCase();
+ item.errorMessage = upstream.body?.data?.error_message || upstream.body?.message || "";
+ }));
+}
+
+function formatAssetReviewTask(task) {
+ const allFinal = task.items.every((item) => ["ready", "failed"].includes(item.status));
+ return {
+ code: allFinal ? 200 : 202,
+ status: allFinal ? "completed" : "processing",
+ task_id: task.id,
+ result: {
+ items: task.items.map((item) => ({
+ source_url: item.sourceUrl,
+ view: item.view,
+ asset_type: "Image",
+ submit_review_status: item.status === "ready" ? 1 : item.status === "failed" ? 0 : null,
+ downstream_asset_id: item.assetId,
+ asset_url: item.status === "ready" ? `asset://${item.assetId}` : "",
+ group_id: item.groupId,
+ status: item.status,
+ error_code: item.status === "failed" ? "ASSET_REVIEW_FAILED" : "",
+ error_message: item.errorMessage,
+ })),
+ },
+ };
+}
+
+function writeAuditLog(entry) {
+ try {
+ mkdirSync(auditLogDir, { recursive: true, mode: 0o700 });
+ const day = new Date().toISOString().slice(0, 10);
+ const filePath = join(auditLogDir, `audit-${day}-${process.pid}.jsonl`);
+ const record = sanitizeLogData({
+ timestamp: new Date().toISOString(),
+ pid: process.pid,
+ ...entry,
+ });
+ appendFileSync(filePath, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 });
+ } catch (error) {
+ console.error("审计日志写入失败", error.message);
+ }
+}
+
+function sanitizeLogData(value, key = "", depth = 0) {
+ if (value == null || typeof value === "number" || typeof value === "boolean") return value;
+ if (depth > 7) return "[MAX_DEPTH]";
+ if (/authorization|api.?key|token|secret|password|cookie/i.test(key)) return "[REDACTED]";
+ if (typeof value === "string") {
+ if (/^bearer\s+/i.test(value)) return "[REDACTED]";
+ if (/prompt|(^|_)text$/i.test(key)) return summarizeText(value);
+ if (/url|referer/i.test(key) || /^https?:\/\//i.test(value)) return sanitizeUrlForLog(value);
+ return value.length > 2000 ? `${value.slice(0, 2000)}…[TRUNCATED:${value.length}]` : value;
+ }
+ if (Array.isArray(value)) return value.slice(0, 50).map((item) => sanitizeLogData(item, key, depth + 1));
+ if (typeof value === "object") {
+ const result = {};
+ for (const [childKey, childValue] of Object.entries(value).slice(0, 100)) {
+ result[childKey] = sanitizeLogData(childValue, childKey, depth + 1);
+ }
+ return result;
+ }
+ return String(value);
+}
+
+function summarizeText(value) {
+ const text = String(value || "");
+ return { length: text.length, sha256: hashAuditValue(text).slice(0, 16) };
+}
+
+function sanitizeUrlForLog(value) {
+ if (!value) return "";
+ try {
+ const url = new URL(String(value));
+ return {
+ protocol: url.protocol,
+ host: url.host,
+ pathname: url.pathname.slice(0, 500),
+ queryKeys: [...new Set([...url.searchParams.keys()])].slice(0, 30),
+ hasQuery: Boolean(url.search),
+ };
+ } catch {
+ return String(value).slice(0, 500);
+ }
+}
+
+function safeRequestPath(value) {
+ try {
+ const url = new URL(value, "http://localhost");
+ const keys = [...new Set([...url.searchParams.keys()])].slice(0, 30);
+ return `${url.pathname}${keys.length ? `?${keys.join("&")}` : ""}`;
+ } catch {
+ return String(value || "").slice(0, 500);
+ }
+}
+
+function safeIdentifier(value) {
+ return String(value || "").replace(/[^a-z0-9_.:-]/gi, "").slice(0, 180);
+}
+
+function clientIp(request) {
+ const forwarded = String(request.headers["x-forwarded-for"] || "").split(",")[0].trim();
+ return forwarded || request.socket.remoteAddress || "unknown";
+}
+
+function hashAuditValue(value) {
+ return createHash("sha256").update(`${auditHashSalt}:${String(value || "")}`).digest("hex");
+}
+
+function sendJson(response, status, body, trackId = "") {
+ response.writeHead(status, {
+ "Content-Type": "application/json; charset=utf-8",
+ "Cache-Control": "no-store",
+ ...corsHeaders(),
+ ...(trackId ? { "X-Track-Id": trackId } : {}),
+ });
+ response.end(JSON.stringify(body));
+}
+
+function corsHeaders() {
+ return {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Headers": "Content-Type, X-Session-Id",
+ "Access-Control-Expose-Headers": "X-Request-Id",
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
+ "Access-Control-Allow-Private-Network": "true",
+ };
+}
+
+class ClientError extends Error {
+ constructor(message, status = 400) {
+ super(message);
+ this.status = status;
+ }
+}
+
+process.on("uncaughtException", (error) => {
+ writeAuditLog({ level: "fatal", event: "process.uncaught_exception", error: sanitizeLogData({ name: error.name, message: error.message, stack: error.stack }) });
+ console.error(error);
+});
+process.on("unhandledRejection", (error) => {
+ writeAuditLog({ level: "error", event: "process.unhandled_rejection", error: sanitizeLogData({ message: String(error), stack: error?.stack }) });
+ console.error(error);
+});
+
+export { normalizeAssetReference };
diff --git a/tests/normalize.test.mjs b/tests/normalize.test.mjs
new file mode 100644
index 0000000..7077ca9
--- /dev/null
+++ b/tests/normalize.test.mjs
@@ -0,0 +1,26 @@
+import test from "node:test";
+import assert from "node:assert/strict";
+import {
+ extractVideoTaskId,
+ extractVideoUrl,
+ normalizeAssetReference,
+ normalizeVideoStatus,
+} from "../lib/normalize.mjs";
+
+test("素材审核返回值统一为 asset 协议并保留大小写", () => {
+ assert.equal(normalizeAssetReference({ asset_url: "Asset://asset-1" }), "asset://asset-1");
+ assert.equal(normalizeAssetReference({ downstream_asset_id: "asset-2" }), "asset://asset-2");
+ assert.equal(normalizeAssetReference({ Id: "asset-3" }), "asset://asset-3");
+ assert.equal(normalizeAssetReference({ asset_id: "mat_ADyx4iQv" }), "asset://mat_ADyx4iQv");
+});
+
+test("兼容视频任务 id 和状态字段", () => {
+ assert.equal(extractVideoTaskId({ task_id: "task-1" }), "task-1");
+ assert.equal(extractVideoTaskId({ data: { id: "task-2" } }), "task-2");
+ assert.equal(normalizeVideoStatus({ status: "succeeded" }), "completed");
+ assert.equal(normalizeVideoStatus({ data: { status: "failed" } }), "failed");
+});
+
+test("从常见响应位置提取视频地址", () => {
+ assert.equal(extractVideoUrl({ output: { video_url: "https://example.com/demo.mp4" } }), "https://example.com/demo.mp4");
+});