feat: deliver AI drama production demo
This commit is contained in:
14
.env.example
Normal file
14
.env.example
Normal file
@@ -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=
|
||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
.env.local
|
||||
.DS_Store
|
||||
node_modules/
|
||||
logs/
|
||||
|
||||
# 仅用于本地设计回归,不属于客户部署包
|
||||
design-qa.md
|
||||
design-qa*.png
|
||||
qa/
|
||||
qa-*.jpg
|
||||
91
README.md
Normal file
91
README.md
Normal file
@@ -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
|
||||
```
|
||||
36
lib/normalize.mjs
Normal file
36
lib/normalize.mjs
Normal file
@@ -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";
|
||||
}
|
||||
14
package.json
Normal file
14
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
1712
public/app.js
Normal file
1712
public/app.js
Normal file
File diff suppressed because it is too large
Load Diff
268
public/index.html
Normal file
268
public/index.html
Normal file
@@ -0,0 +1,268 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>营响力|AI 短剧数字人 Demo</title>
|
||||
<link rel="stylesheet" href="./styles.css?v=21" />
|
||||
</head>
|
||||
<body data-active-module="library">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<span class="eyebrow">营响力 · 真实客户验证</span>
|
||||
<h1>AI 短剧数字人生产</h1>
|
||||
</div>
|
||||
<div class="connection" id="connectionState">正在检查接口配置…</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<nav class="module-tabs" aria-label="功能模块">
|
||||
<button class="module-tab active" type="button" data-module="library">
|
||||
<b>人物素材库</b><span>上传、审核、查看历史素材并组合人物</span>
|
||||
</button>
|
||||
<button class="module-tab" type="button" data-module="production">
|
||||
<b>短剧生产</b><span>选择本场角色,输入图片和视频后生成</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<section class="shot-overview production-only" aria-label="片段进度与切换">
|
||||
<div class="shot-overview-head">
|
||||
<div>
|
||||
<strong>片段进度</strong>
|
||||
<span id="timelineSummary">0/1 已选定 · 预计 5 秒</span>
|
||||
</div>
|
||||
<button class="link-button" type="button" id="addShotButton">+ 新增片段</button>
|
||||
</div>
|
||||
<div class="shot-progress" aria-hidden="true"><i id="shotProgressBar"></i></div>
|
||||
<div class="shot-list shot-overview-list" id="shotList"></div>
|
||||
</section>
|
||||
|
||||
<div class="workspace">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-title production-only">
|
||||
<span>本场角色</span>
|
||||
<button class="link-button" id="addRoleButton">+ 添加角色</button>
|
||||
</div>
|
||||
<div class="scene-roles production-only" id="sceneRoles"></div>
|
||||
<div class="sidebar-title asset-library-title">
|
||||
<span>人物/形象素材库</span>
|
||||
<button class="link-button" id="refreshAssets">刷新素材库</button>
|
||||
</div>
|
||||
<div class="character-list" id="characterList">
|
||||
<div class="empty-small">正在整理历史人物素材…</div>
|
||||
</div>
|
||||
<button class="secondary full-button library-only" type="button" id="createCharacterButton">+ 将历史素材组合成人物</button>
|
||||
<div class="character-editor hidden library-only" id="characterEditor">
|
||||
<label class="field"><span>人物名称</span><input id="characterName" placeholder="例如:女主角 / 宋度宗" /></label>
|
||||
<p>选择 1–9 张属于同一人物/形象的历史素材</p>
|
||||
<div class="asset-pick-list" id="assetPickList"></div>
|
||||
<div class="editor-actions">
|
||||
<button class="secondary" type="button" id="cancelCharacterButton">取消</button>
|
||||
<button class="primary compact-button" type="button" id="saveCharacterButton">保存人物</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="review-tracker production-only" id="reviewTracker">
|
||||
<div class="tracker-head">
|
||||
<strong id="activeRoleLabel">角色 A · 三视图</strong>
|
||||
<span id="reviewElapsed">等待提交</span>
|
||||
</div>
|
||||
<div class="tracker-row" id="reviewFront"><span>正面</span><b>未提交</b><small></small></div>
|
||||
<div class="tracker-row" id="reviewSide"><span>侧面</span><b>未提交</b><small></small></div>
|
||||
<div class="tracker-row" id="reviewBack"><span>背面</span><b>未提交</b><small></small></div>
|
||||
</div>
|
||||
<details class="history-assets library-only" open>
|
||||
<summary>全部历史素材 <span id="assetHistoryCount">0</span></summary>
|
||||
<p class="selection-help">点击单张素材可增减当前场景角色;也可在上方将多张素材长期组合成人物。</p>
|
||||
<div class="asset-list" id="assetList">
|
||||
<div class="empty-small">审核通过的历史素材会出现在这里</div>
|
||||
</div>
|
||||
</details>
|
||||
<div class="rule-box library-only">
|
||||
<strong>入库前检查</strong>
|
||||
<ul>
|
||||
<li>正面、侧面、背面分别提供公网 URL</li>
|
||||
<li>格式:JPG / PNG / WebP 等</li>
|
||||
<li>宽或高 300–6000 px,单张小于 30MB</li>
|
||||
<li>三张全部通过后组成一个角色素材组</li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header library-only">
|
||||
<div>
|
||||
<span class="section-kicker">STEP 1—2</span>
|
||||
<h2>人物素材入库与审核</h2>
|
||||
</div>
|
||||
<span class="status-badge neutral" id="assetStatus">未提交</span>
|
||||
</div>
|
||||
|
||||
<form id="assetForm" class="form-grid library-only">
|
||||
<div class="view-grid wide">
|
||||
<label class="view-field">
|
||||
<span><b>01</b> 正面图</span>
|
||||
<input id="assetFrontUrl" type="url" required placeholder="https://example.com/front.png" />
|
||||
<small>五官与服装正面细节</small>
|
||||
</label>
|
||||
<label class="view-field">
|
||||
<span><b>02</b> 侧面图</span>
|
||||
<input id="assetSideUrl" type="url" required placeholder="https://example.com/side.png" />
|
||||
<small>轮廓、发型和体态</small>
|
||||
</label>
|
||||
<label class="view-field">
|
||||
<span><b>03</b> 背面图</span>
|
||||
<input id="assetBackUrl" type="url" required placeholder="https://example.com/back.png" />
|
||||
<small>背部服装与发型细节</small>
|
||||
</label>
|
||||
</div>
|
||||
<p class="mock-hint wide" id="mockHint">Mock 测试:任一图片 URL 中加入 <code>reject</code>,可验证单张未通过与原因展示。</p>
|
||||
<label class="field">
|
||||
<span>角色名称</span>
|
||||
<input id="assetName" value="客户数字人·女主角" />
|
||||
</label>
|
||||
<div class="field action-field">
|
||||
<span> </span>
|
||||
<button class="primary" type="submit" id="reviewButton">提交三视图审核</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="result-card hidden library-only" id="assetResult">
|
||||
<div class="result-icon">✓</div>
|
||||
<div>
|
||||
<strong>人物素材已入库并完成组合</strong>
|
||||
<p>审核通过的图片会绑定为一个可复用人物,也可在人物库中重新组合。</p>
|
||||
<div class="reference-list" id="assetReferences"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="storyboard production-only">
|
||||
<label class="field continuity-choice">
|
||||
<span>与上一片段的衔接</span>
|
||||
<select id="continuityMode">
|
||||
<option value="previous_video">引用上一段</option>
|
||||
<option value="none">不引用上一段</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="panel-header production-only production-panel-header">
|
||||
<div>
|
||||
<span class="section-kicker">当前片段</span>
|
||||
<h2>片段内容与生成参数</h2>
|
||||
</div>
|
||||
<span class="status-badge neutral" id="videoStatus">等待角色</span>
|
||||
</div>
|
||||
|
||||
<form id="videoForm" class="form-grid disabled-section production-only">
|
||||
<label class="field wide">
|
||||
<span>本片段分镜提示词</span>
|
||||
<textarea id="prompt" rows="6">竖屏双人对话短剧镜头:角色 A 站在办公楼电梯口,先低头查看手机消息;角色 B 从走廊走近并停在她身侧。角色 B 说:“你真的决定了吗?”角色 A 抬头回答:“这一次,我自己做决定。”保持两名角色外观与各自参考图一致,避免交换五官、服装或身份。镜头从双人中景缓慢推进至角色 A 近景,口型与中文台词同步,环境声自然。</textarea>
|
||||
<small id="referenceMap">系统会根据本场角色自动生成图号映射。</small>
|
||||
</label>
|
||||
|
||||
<div class="multimodal-grid wide">
|
||||
<div class="field media-field">
|
||||
<div class="media-field-head">
|
||||
<span>其他参考图片</span>
|
||||
<button class="media-add-button" type="button" id="addExtraImageButton">+ 添加图片</button>
|
||||
</div>
|
||||
<div class="media-url-list" id="extraImageInputs"></div>
|
||||
<small>不进入人物库,但与人物图片共同占用本次最多 9 张图片额度。</small>
|
||||
</div>
|
||||
<div class="field media-field">
|
||||
<div class="media-field-head">
|
||||
<span>参考视频</span>
|
||||
<button class="media-add-button" type="button" id="addReferenceVideoButton">+ 添加视频</button>
|
||||
</div>
|
||||
<div class="media-url-list" id="referenceVideoInputs"></div>
|
||||
<small>手动补充 reference_video;选择“引用上一片段”后系统会自动加入,无需重复填写。</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-quota wide" id="inputQuota">人物图片 0 张 · 其他图片 0 张 · 图片合计 0/9 · 视频 0 条</div>
|
||||
|
||||
<label class="field">
|
||||
<span>模型</span>
|
||||
<select id="model">
|
||||
<option value="doubao-seedance-2-0-260128">Seedance 2.0 · 标准</option>
|
||||
<option value="doubao-seedance-2-0-fast-260128">Seedance 2.0 · 快速</option>
|
||||
<option value="doubao-seedance-2-0-mini-260615">Seedance 2.0 · Mini</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>画面比例</span>
|
||||
<select id="ratio">
|
||||
<option value="9:16">9:16 · 竖屏短剧</option>
|
||||
<option value="16:9">16:9 · 横屏</option>
|
||||
<option value="1:1">1:1 · 方形</option>
|
||||
<option value="adaptive">跟随参考图</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>时长</span>
|
||||
<select id="duration">
|
||||
<option value="5">5 秒</option>
|
||||
<option value="8">8 秒</option>
|
||||
<option value="10">10 秒</option>
|
||||
<option value="15">15 秒</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>分辨率</span>
|
||||
<select id="resolution">
|
||||
<option value="720p">720p</option>
|
||||
<option value="480p">480p</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="toggle-row"><input id="generateAudio" type="checkbox" checked /><span>生成同步音频</span></label>
|
||||
<label class="toggle-row"><input id="watermark" type="checkbox" /><span>保留平台水印</span></label>
|
||||
<div class="field action-field wide">
|
||||
<button class="primary video-button" type="submit" id="generateButton">生成当前片段候选</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="generation hidden production-only" id="generationResult">
|
||||
<div class="video-stage" id="videoStage">
|
||||
<div class="mock-frame">
|
||||
<div class="mock-person"></div>
|
||||
<span>Seedance 2 正在生成镜头…</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-meta">
|
||||
<div>
|
||||
<strong id="generationTitle">任务已提交</strong>
|
||||
<span id="generationTaskId"></span>
|
||||
<span class="generation-detail" id="generationElapsed"></span>
|
||||
<span class="generation-detail" id="generationLastChecked"></span>
|
||||
</div>
|
||||
<span id="generationProgress">0%</span>
|
||||
</div>
|
||||
<div class="progress-track"><i id="progressBar"></i></div>
|
||||
</div>
|
||||
|
||||
<section class="version-section production-only">
|
||||
<div class="version-head">
|
||||
<div><span class="section-kicker">候选版本</span><h3>选择一个版本用于下一片段衔接</h3></div>
|
||||
<span id="versionCount">暂无候选</span>
|
||||
</div>
|
||||
<div class="version-list" id="versionList">
|
||||
<div class="empty-small">生成当前片段后,候选视频会出现在这里</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="assembly-panel hidden production-only" id="assemblyPanel">
|
||||
<div class="version-head">
|
||||
<div><span class="section-kicker">拼接清单</span><h3>按片段顺序汇总选定版本</h3></div>
|
||||
<button class="link-button" type="button" id="closeAssemblyButton">收起</button>
|
||||
</div>
|
||||
<div class="assembly-list" id="assemblyList"></div>
|
||||
<p id="assemblyHint">所有片段选定版本后,再交给拼接服务合成完整短剧。</p>
|
||||
</section>
|
||||
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div class="toast hidden" id="toast"></div>
|
||||
<script src="./app.js?v=25"></script>
|
||||
</body>
|
||||
</html>
|
||||
270
public/styles.css
Normal file
270
public/styles.css
Normal file
@@ -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; }
|
||||
}
|
||||
740
server.mjs
Normal file
740
server.mjs
Normal file
@@ -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 };
|
||||
26
tests/normalize.test.mjs
Normal file
26
tests/normalize.test.mjs
Normal file
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user