commit 1ec2933e70afc10f7d2fa27c32c08da66a0ac0cd Author: xuejianwu <374601383@qq.com> Date: Thu Jul 30 08:58:38 2026 +0800 feat: deliver AI drama production demo 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 = '
Seedance 2 正在生成镜头…
'; } +function updateProgress(value, title) { + const safe = Math.max(0, Math.min(100, Number(value) || 0)); + el.generationTitle.textContent = title; + el.generationProgress.textContent = `${safe}%`; + el.progressBar.style.width = `${safe}%`; +} +function setAssetStatus(kind, text) { el.assetStatus.className = `status-badge ${kind}`; el.assetStatus.textContent = text; } +function 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 + + + +
+
+ 营响力 · 真实客户验证 +

AI 短剧数字人生产

+
+
正在检查接口配置…
+
+ +
+ + +
+
+
+ 片段进度 + 0/1 已选定 · 预计 5 秒 +
+ +
+ +
+
+ +
+ + +
+
+
+ STEP 1—2 +

人物素材入库与审核

+
+ 未提交 +
+ +
+
+ + + +
+

Mock 测试:任一图片 URL 中加入 reject,可验证单张未通过与原因展示。

+ +
+   + +
+
+ + + +
+ +
+ +
+
+ 当前片段 +

片段内容与生成参数

+
+ 等待角色 +
+ +
+ + +
+
+
+ 其他参考图片 + +
+
+ 不进入人物库,但与人物图片共同占用本次最多 9 张图片额度。 +
+
+
+ 参考视频 + +
+
+ 手动补充 reference_video;选择“引用上一片段”后系统会自动加入,无需重复填写。 +
+
+
人物图片 0 张 · 其他图片 0 张 · 图片合计 0/9 · 视频 0 条
+ + + + + + + +
+ +
+
+ + + +
+
+
候选版本

选择一个版本用于下一片段衔接

+ 暂无候选 +
+
+
生成当前片段后,候选视频会出现在这里
+
+
+ + + +
+
+
+ + + + + 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"); +});