commit 7a8191650f6474885ccdec87ab128283fed740e6 Author: www Date: Mon Jun 15 17:45:28 2026 +0800 Initial AI manga platform diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..72171c1 --- /dev/null +++ b/.env.example @@ -0,0 +1,56 @@ +# Runtime +NODE_ENV=development +PORT=3000 +ADMIN_PORT=5173 +USER_APP_PORT=5174 + +# Transport security +# Production should terminate TLS at Nginx/Caddy/Load Balancer and forward X-Forwarded-Proto=https. +HTTPS_REQUIRED=false +HTTPS_ALLOW_LOCAL_HTTP=true +TRUST_PROXY=false +CORS_ORIGINS=http://127.0.0.1:5173,http://127.0.0.1:5174 +API_CRYPTO_ENABLED=auto +API_CRYPTO_SESSION_TTL_SECONDS=900 +VITE_API_CRYPTO_ENABLED=auto + +# Database +DATABASE_URL=mysql://ai_manga:ai_manga_password@127.0.0.1:3306/ai_manga + +# Queue +REDIS_URL=redis://127.0.0.1:6379 + +# Object storage +MINIO_ENDPOINT=127.0.0.1 +MINIO_PORT=9000 +MINIO_USE_SSL=false +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin +MINIO_BUCKET_PRIVATE=ai-manga-private +MINIO_BUCKET_PUBLIC=ai-manga-public + +# Auth +JWT_SECRET=change_me_in_local_env +JWT_EXPIRES_IN=7d + +# Media +FFMPEG_PATH=ffmpeg +LOCAL_STORAGE_ROOT=../storage +STORAGE_DRIVER=local + +# AI providers are mocked until the MVP flow is complete. +AI_PROVIDER_MODE=mock +OPENAI_API_KEY= +OPENAI_BASE_URL= +OPENAI_TEXT_MODEL= +OPENAI_NOVEL_MODEL= +OPENAI_IMAGE_MODEL= +OPENAI_VIDEO_MODEL= +OPENAI_TTS_MODEL= +OPENAI_MODERATION_MODEL= +OPENAI_EMBEDDING_MODEL= +# Must stay stable after saving Provider API keys in the admin console. +PROVIDER_SECRET_KEY= +PROVIDER_MAX_COST_PER_CALL= +PROVIDER_DAILY_COST_LIMIT= +WORKER_SECRET= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a633a13 --- /dev/null +++ b/.gitignore @@ -0,0 +1,53 @@ +# Dependencies +node_modules/ +**/node_modules/ + +# Build outputs +dist/ +**/dist/ +.output/ +**/.output/ +.vite/ +**/.vite/ +coverage/ +**/coverage/ + +# Runtime logs / temp files +*.log +*.pid +*.tmp +*.temp +*.swp +.DS_Store +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Local environment and secrets +.env +.env.* +!.env.example + +# Runtime storage: uploads, generated media, private assets, provider outputs +storage/* +!storage/.gitkeep +backend/storage/ +admin/storage/ +user-app/storage/ +workers/storage/ + +# Generated archives / local exports +*.zip +*.tar +*.tar.gz +*.tgz +*.bak +*.dump +*.sql +!backend/prisma/migrations/**/*.sql + +# Local database files +*.sqlite +*.sqlite3 +*.db diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..cad52c2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,100 @@ +# AGENTS.md + +## 项目说明 + +本项目是 AI 漫剧 / AI 写真视频生成平台,包含两套业务系统: + +1. 系统 A:原创小说 / 上传小说 -> 韩漫 / 漫剧生成系统 +2. 系统 B:真人照片 -> 多人生主题写真 / 婚礼 / 纪念视频生成系统 + +当前优先开发系统 A。 + +## 当前开发基线 + +系统 A 开发基线: + +- docs/system_a/00_上下文难点清单_已接入设计.md +- docs/system_a/01_系统A总需求文档_v2_生产级基线.md +- docs/system_a/02_业务流程_状态流转_权限设计.md +- docs/system_a/03_功能清单_页面清单.md +- docs/system_a/04_技术架构设计_模块拆分.md +- docs/system_a/05_数据库表结构设计.md +- docs/system_a/06_API接口设计文档.md +- docs/system_a/09_AI生成流水线_Provider抽象设计.md +- docs/system_a/11_角色一致性专项设计.md +- docs/system_a/12_长篇连载记忆_剧情一致性专项设计.md +- docs/system_a/14_分镜镜头_视频合成_特效设计.md +- docs/system_a/15_任务队列_错误重试_稳定性设计.md +- docs/system_a/20_测试用例_验收标准.md +- docs/system_a/21_Codex开发任务拆解文档.md + +系统 B 暂时只做架构预留,不先开发完整业务。 + +## 技术栈约定 + +- 后端:Node.js + NestJS +- 数据库:MySQL 8 +- 队列:Redis + BullMQ +- 对象存储:MinIO +- 用户端:uni-app +- 后台端:Geeker-Admin +- 视频合成:FFmpeg +- AI Provider:必须抽象,先 mock,后续再接真实模型 + +## 强制开发规则 + +1. 不允许一次性开发完整系统。 +2. 每次只完成一个明确阶段。 +3. 每个阶段完成后必须等待人工审核。 +4. 不允许擅自修改 docs 目录。 +5. 不允许把真实 API Key 写进代码。 +6. 所有密钥必须放在 .env。 +7. 所有 AI Provider 必须先实现 mock。 +8. 不允许把 OpenAI 或任何模型写死在业务逻辑里。 +9. 图片、视频、TTS、文本、审核都必须走 Provider 抽象层。 +10. 所有生成任务必须进入任务队列。 +11. 所有任务必须有状态、重试次数、错误信息、成本记录。 +12. 所有用户上传小说和素材默认私有。 +13. 上传小说必须有版权确认记录。 +14. 公开案例必须有单独授权记录。 +15. 角色一致性、剧情记忆、分镜确认、任务恢复是系统 A 的核心,不允许省略。 +16. 每个阶段必须更新 CODEX_PROGRESS.md。 +17. 每个阶段必须说明:完成了什么、改了哪些文件、如何测试、下一步是什么。 +18. 如果发现文档冲突,先停下来汇报,不要擅自决定。 +19. 开发前先查看 Git 状态。 +20. 开发后运行 lint/typecheck/test,失败必须说明原因。 + +## MVP 完成标准 + +系统 A MVP 必须实现: + +1. 用户注册登录。 +2. 创建小说漫剧项目。 +3. AI 原创小说 mock 流程。 +4. 上传小说解析流程。 +5. 版权确认流程。 +6. 故事圣经。 +7. 角色圣经。 +8. 角色锚点图 mock。 +9. 分集计划。 +10. 单集脚本。 +11. 分镜脚本。 +12. AI Provider mock。 +13. BullMQ 任务队列。 +14. 图片生成 mock。 +15. TTS / 字幕 mock。 +16. FFmpeg 视频合成。 +17. 后台项目管理。 +18. 后台任务管理。 +19. 用户端查看进度和下载成品。 +20. 任务失败可重试。 + +## 最终目标 + +完整落地目标是: + +AI 原创小说 -> 生成 3 集基础漫剧 MP4 +上传小说 -> 生成 1-3 集基础漫剧 MP4 +后台可查看项目、角色、分镜、任务、成本、审核 +任务失败可恢复 +系统后续可扩展到 20-100 集长篇连载 diff --git a/AI_VIDEO_TEST_LESSONS.md b/AI_VIDEO_TEST_LESSONS.md new file mode 100644 index 0000000..0dcba21 --- /dev/null +++ b/AI_VIDEO_TEST_LESSONS.md @@ -0,0 +1,323 @@ +# AI 真人/漫剧视频流水线测试踩坑记录 V1 + +更新时间:2026-06-12 + +用途: + +- 记录测试阶段已经遇到的问题、原因判断、系统优化点。 +- 后续接入 Kling、Seedance、Wan、Vidu、Runway、Veo 等平台时,按同一套准入标准测试,避免重复烧钱。 +- 本文件是运营/开发测试手册,不替代 `docs/` 里的系统需求文档。 + +## 当前阶段定位 + +当前仍是测试磨合阶段,不追求一次性自动发布。 + +目标顺序: + +1. 跑通自动化流水线。 +2. 找到各 Provider 的能力边界。 +3. 把失败原因转成系统规则。 +4. 形成可恢复、可路由、可统计成本的生产流程。 +5. 最后再逐步提高自动化比例。 + +## 已验证结论 + +### Hailuo 适合做什么 + +Hailuo / MiniMax Hailuo 2.3 Fast 当前更适合: + +- 都市真人短剧普通镜头。 +- 中景、远景、走路、转身、递物、沉默、反应镜头。 +- 咖啡厅、街道、办公室、豪车、酒店、家庭等现实场景。 +- 旁白 + 字幕 + BGM 推动剧情的短剧。 +- 成本敏感的批量生产。 + +当前实测成本: + +- `minimax_hailuo_23_fast`:约 `$0.317 / 10秒` +- `minimax_hailuo_23`:约 `$0.467 / 10秒` + +### Hailuo 不适合硬扛什么 + +Hailuo 对以下镜头可控性不足: + +- 凌空翻滚、复杂武打、连续打斗。 +- 精准手诀、结印、舞蹈式手部动作。 +- 法相天地、千臂法身、巨型神像复杂动作。 +- 一镜中塞太多高复杂动作。 +- 正脸强台词口型同步。 + +处理原则: + +- 不要在同一失败动作上无脑重跑 Hailuo。 +- 如果同类复杂动作 1-2 次失败,优先切换 Provider 或补动作参考/姿势关键帧。 +- 修仙、法相、打斗、次元壁穿越等镜头默认标记为高价值/高复杂度,不按普通都市镜头处理。 + +## 分镜时长经验 + +### Hailuo 时长设计规则 + +Hailuo 当前按真实可用规格优先设计: + +- 常规可用镜头时长:6 秒 / 10 秒。 +- 真人短剧主流程优先按 10 秒长镜头设计。 +- 只有非常简单的过渡镜头、环境镜头、无台词动作镜头,才考虑 6 秒。 +- 不再设计 7 秒、8 秒这种无法直接匹配 Hailuo 输出规格的镜头。 + +系统规则: + +- 导演分镜上限必须允许 10 秒。 +- 如果原分镜明确是 10 秒,prepare 阶段不得自动压缩成 5 秒或 8 秒。 +- 负面约束里的“避免嘴部特写”不能导致镜头被误判成 insert 特写。 +- “手里的道具”不能被误判成“手部特写”,只有手部/手指/手掌/手腕/手势等明确关键词才算 insert。 + +### 3 秒碎片问题 + +法相天地三段式测试暴露问题: + +- 镜头切太碎,像图片拼接。 +- Hailuo 实际输出常按 6 秒返回,系统再裁成 3/3/4 秒时,动作容易被截断。 +- 复杂动作被拆太短,会失去电影感和动作连贯性。 + +结论: + +- 普通反应镜头:3-5 秒可以。 +- 都市剧情镜头:5-6 秒更稳。 +- 复杂动作/爆点镜头:优先 8-10 秒一镜到底。 +- 不能把 6 秒 Provider 输出硬裁成 3 秒作为长期策略。 + +### 10 秒一镜到底结论 + +法相天地 10 秒 Hailuo 测试已验证: + +- Hailuo API 可以返回 10 秒。 +- 10 秒一镜到底比 3 段裁切更连贯。 +- 人物、服装、场景稳定性明显提升。 +- 但复杂动作本身仍受 Provider 能力限制。 + +系统规则: + +- `action_score >= 8` 且 `importance >= 8` 的镜头,优先生成 8-10 秒单镜头。 +- 如果 Provider 不支持目标时长,再由系统拆分,不要随意裁切关键动作。 + +## Prompt Engine 经验 + +直接把剧情发给视频模型,质量不稳定。 + +必须通过 Prompt Engine 组装: + +- 角色。 +- 场景。 +- 主动作。 +- 镜头大小。 +- 运镜。 +- 动作时间轴。 +- 情绪表演。 +- 灯光。 +- 特效时机。 +- 声音卡点。 +- 负面约束。 + +已落地: + +- Motion Director Prompt。 +- 10 秒仙侠一镜到底时间轴。 +- Hailuo 长镜头 prompt 上限提升。 + +仍需注意: + +- Prompt 只能提高概率,不能保证复杂动作精确执行。 +- 复杂动作需要动作参考图、姿势关键帧或更强 Provider。 +- 如果同一个 Prompt 多次失败,继续加形容词意义不大。 + +## 角色一致性经验 + +当前测试发现: + +- 没有固定角色定妆图/锚点图时,人物容易漂移。 +- 修仙/奇幻镜头更容易因为特效导致人物脸和服装变化。 +- 都市短剧也需要固定林凡、陈雪、王伯这类角色锚点。 + +系统规则: + +- 真人短剧正式验收前必须先做角色锚点图。 +- 新剧首轮至少固定主角、女主、关键配角。 +- Provider 小样要使用同一角色锚点和同一关键帧,避免测试结果不可比。 + +## 声音与字幕经验 + +没有声音、没有字幕、没有 BGM 的视频判定为失败。 + +已经遇到的问题: + +- 屏幕女孩测试:无声音、无字幕,发布感不足。 +- 口型镜头:语音先到,嘴型后动。 +- 部分合成音量偏低,视觉还可以但情绪不够。 + +系统规则: + +- 每条可发布视频必须包含字幕。 +- 每条可发布视频必须包含 BGM 或环境底噪。 +- 情绪镜头必须有 SFX:雨声、脚步、门声、心跳、低频冲击、sting 等。 +- 合成后必须跑音量检测。 +- 音量偏低时重新后期合成,不一定重跑视频。 + +## 台词与 lip-sync 经验 + +没有稳定 lip-sync Provider 前,不要把关键台词做成正脸大嘴型特写。 + +当前策略: + +- `lip_sync_required=true` 但没有真实 lip-sync Provider 时: + - 改成旁白。 + - 改成字幕。 + - 改成中景/侧脸/轻口型。 + - 避免正脸嘴部特写。 + +未来接入 lip-sync Provider 后: + +- 只给关键正脸台词使用。 +- 不全片 lip-sync,避免成本翻倍。 +- lip-sync 放在视频片段生成后、FFmpeg 合成前。 + +## 成本控制经验 + +默认策略: + +- 每个镜头默认只生成 1 条。 +- 不默认生成 2-3 条候选。 +- 只有封面级、爆点、最后反转、人工验收阶段才允许候选 2 条。 +- 任何候选数增加都必须进入成本预估和审计日志。 + +当前后台已支持: + +- AI 接入列表展示每 10 秒成本。 +- Provider `cost_summary`。 +- 单次成本上限。 +- 当日成本上限。 +- `price_per_second=0` 不当成免费,而是提示需账单回填。 + +## Provider 路由经验 + +推荐基础路由: + +```text +普通都市镜头 +=> Hailuo + +都市高价值镜头 +=> Hailuo 优先,必要时 Kling / Vidu / Wan 对比 + +修仙 / 法相 / 打斗 / 次元壁 / 复杂动作 +=> Premium Provider 候选 + +正脸台词 +=> 有 lip-sync Provider 才允许正脸特写,否则中景/旁白/字幕 + +成本超预算 +=> Premium -> Hailuo -> Seedance/Mock +``` + +不要把“平台选择”写死在业务代码里,必须走 Router。 + +## Provider 准入测试标准 + +每接入一个新视频 Provider,都按固定小样测试: + +1. 同一项目。 +2. 同一角色锚点图。 +3. 同一关键帧。 +4. 同一镜头文本。 +5. 同一目标时长。 +6. 同一分辨率/比例。 +7. 记录真实成本。 +8. 记录失败原因。 +9. 记录生成耗时。 +10. 记录人工观感评分。 + +必须记录: + +- `provider_code` +- `model_name` +- `clip_id` +- `asset_id` +- `duration` +- `cost_actual` +- `quality_score` +- `human_acceptance` +- `failure_reason` +- `prompt_version` +- `keyframe_asset_id` + +## 人工验收标准 + +当前 mock-qc 分数只能说明流程没坏,不能代表可发布。 + +真人视频上线前必须人工看: + +- 人物是否稳定。 +- 动作是否连贯。 +- 镜头是否像真实拍摄。 +- 字幕是否准确。 +- 声音是否完整。 +- BGM/SFX 是否有情绪。 +- 口型是否明显穿帮。 +- 是否有明显 AI 手、脸、身体变形。 +- 成本是否在预算内。 + +## 不要重复踩的坑 + +- 不要把 mock-qc 通过当成真实质量通过。 +- 不要无音频/无字幕就判断视频流程合格。 +- 不要用 3 秒碎切测试复杂动作。 +- 不要在 Hailuo 上反复烧复杂修仙动作。 +- 不要把 `price_per_second=0` 当成免费。 +- 不要默认生成 2-3 条候选。 +- 不要让正脸台词在没有 lip-sync 时直出。 +- 不要只看最终成片,要保留脚本、分镜、prompt、关键帧、音频、视频片段、合成记录。 +- 不要默认认为 `mock-video` 就是全链路零成本;如果 TTS Provider 已启用真实平台,Mock 视频合成仍可能调用真实 TTS。零成本压测要显式指定 `mock-voice` 或关闭音频。 +- 真实 Hailuo / Kling / Wan 等图生视频前必须先跑 preflight,确认关键帧是 PNG/JPG/WebP;Mock SVG 只能测流程,不能直接发给真实视频 Provider。 +- Hailuo Fast 跑都市真人 10 秒镜头可用,5 条 10 秒顺序生成约 9 分钟,总成本记录 `$1.585`;后续应进入队列并在后台显示耗时。 +- 只用单张临时关键帧会改善画面质感,但不能彻底解决同脸一致性;要上架必须补“角色锚点图 / 定妆图 / 同脸参考图”流程。 +- 都市短剧 Hailuo 长镜头比 3-6 秒碎切更像真人短剧,但部分镜头仍会像关键帧慢推;Prompt 里要继续强化明确动作、人物走位、视线、反应和镜头结束动作。 +- 角色锚点图 V1 对关键帧生成有效:同一角色的脸、服装、气质会比临时起图更稳;但 Hailuo Fast 当前按单首帧图生视频跑,不能直接吃多角色参考图,所以运动中仍可能轻微改脸。 +- 锚点重跑应优先挑第 1 镜、第 3 镜、第 5 镜这类“角色出场 / 冲突 / 反转”镜头;默认候选仍为 1 条,避免成本翻倍。 +- 真人短剧对白不能一镜一段 TTS 直接读完;只要 `dialogue_text` 里出现 `角色名:台词`,必须拆成多段 TTS,每段记录 `speaker_name` / `character_id` / `voice_id` / `voice_style`。 +- 角色声线是发布级基础配置:顾辰、林雨薇、周浩、管家这类核心角色必须先在 Character 表绑定声音;否则即使画面像真人,声音也会变成解说感。 +- 主持人、旁白也要当作独立声线处理;如果没有角色记录,至少要走固定 narration / host voice,不能复用男主或女主声音。 +- 多声线 TTS 只解决“谁在说话”的问题,不解决“嘴型同步”;正脸台词仍要继续走 lip-sync 或中景轻口型策略。 +- TTS 声音 ID 不能只看名字想当然:`Chinese (Mandarin)_Gentle_Senior` 在老管家场景听感偏女,不适合作为中老年男管家默认声线;当前老管家默认改为 `Chinese (Mandarin)_Gentleman`,但仍需人工听感确认。 +- 核心角色上线前要做“角色声线小样验收”:每个角色先生成 1-2 句固定台词试听,确认性别、年龄、气质,再进入整集成片,避免整集重合成。 +- Hailuo 2.3 标准版 1080P 比 Fast 768P 清晰度和雨夜质感略好,但第 5 镜 A/B 证明它不能单独解决“像图片动”的问题;高价值反转镜头更需要三关键帧/动作参考/更强 Provider,而不是只把 Fast 换成标准版。 +- Provider A/B 测试必须保护当前成片:生成候选后要恢复 `storyboard_shots.video_clip_asset_id`,避免实验 clip 自动替换正式整集。 +- 动作节拍链式生成 V1 对高价值反转镜头有效:第 5 镜用 2 段 Fast 生成,第二段用第一段结尾帧作为首帧,比单首帧慢推更能呈现“递卡 -> 看卡震惊”的动作链。 +- action beat 不能默认全片开启:成本、耗时都会增加,适合反转/封面/爆点/高价值动作镜头;普通对话镜头仍用单条生成。 +- preflight 必须按 action beat 的真实分段估算成本,否则后台会低估费用;第 5 镜 action beat 从单条 `$0.317` 变为 2 段 `$0.3804`。 +- action beat 只解决“动作链更连续”,不解决“同一角色像同一个演员”;整集样片必须单独检查人物脸、年龄感、发型、服装是否跨镜稳定。 +- 真实视频生成不能只传角色名字;必须把本镜出现角色的 ActorProfile 注入 provider prompt,并在 task input 记录 `actor_lock`,否则后台无法审计“这条视频到底有没有走锁脸策略”。 +- prepare 阶段不要把全项目角色描述混进单镜 prompt;单镜只允许注入本镜出现的人物,否则多人项目会增加串脸概率。 +- Hailuo 当前配置不是强角色参考模型,`supports_character_reference=false`;它可以做低成本图生视频,但发布级真人短剧要靠“角色锚点图生成首帧 + 强一致性 prompt + 人工抽检”,必要时横测支持角色参考的 Provider。 +- 如果整集都换脸,不要继续盲目重跑全片;应先重跑第 1 / 3 / 5 镜这种出场、冲突、反转镜头,对比抽帧确认角色锁定是否有效,再决定是否整集重跑。 +- 多角色音频不能把跨平台 voice_id 混用:`coral` 属于 OpenAI TTS,不能直接发给 MiniMax;Provider 层必须做 voice_id 适配或降级到当前 Provider 默认声线。 +- MiniMax TTS 返回 JSON 时必须先检查 `base_resp.status_code`;否则账号权限、voice_id、group_id 等真实错误会被误报成 `EMPTY_AUDIO`。 +- 小说上传走 encrypted JSON/base64 时,实际 HTTP body 会比原文件大约 33%;后端 body limit 必须高于文件限制,否则会先被 `request entity too large` 拦截。 + +## 下一步建议 + +短期优先: + +1. 继续围绕都市真人短剧做 Hailuo 量产质量优化。 +2. 做角色锚点图/定妆图 V1。 +3. 做动作参考/姿势关键帧 V1。 +4. 用同一固定镜头横测 Kling / Seedance / Wan / Vidu。 +5. 建 Provider 准入表:质量、成本、失败率、耗时。 +6. 做 BGM/SFX 发布级音量标准。 +7. lip-sync 只做关键台词小样,不先全片接入。 + +长期原则: + +- Hailuo 做低成本量产。 +- 高价值镜头交给高质 Provider。 +- Router 负责自动选模型。 +- QA + 人工抽检负责质量闭环。 +- 成本、失败、重试、降级全部进入审计。 diff --git a/CODEX_PROGRESS.md b/CODEX_PROGRESS.md new file mode 100644 index 0000000..7fd08c6 --- /dev/null +++ b/CODEX_PROGRESS.md @@ -0,0 +1,12031 @@ +# CODEX_PROGRESS.md + +## 当前项目阶段 + +当前阶段:生产化补齐进行中(真实图片/TTS/视频资产落库、国内/海外可替换真实视频 Provider 驱动、后台用户高危操作、成本阈值、队列 worker 消费、细粒度 RBAC、审计导出、单句 TTS 连续重试片段元数据保留、本地存储路径稳定化、成本优化策略硬落地、AI Router V1、Router 队列化 V1、爆款诊断 V1、Prompt Library / 题材套路库前台化 V1、模式库运营闭环 V1、真人视频小样预检 / 验收闭环 V1、真人小样测试台 V1、真人视频真实 Provider 小样验收脚本 V1、真人视频 Prompt Engine V1、MiniMax LipSyncProvider 占位接入、首条真人短剧一集压测用例整理、压测项目导入/Prompt准备和 Mock 全链路成片验收已完成) + +## 已完成阶段 + +- 阶段 00:读取 docs 并输出开发计划 +- 阶段 01:初始化项目骨架 +- 阶段 02:数据库 schema +- 阶段 03:用户认证 +- 阶段 04:文件上传和 MinIO +- 阶段 05:项目创建流程 +- 阶段 06:上传小说解析 +- 阶段 07:AI 原创小说 mock +- 阶段 08:故事圣经 +- 阶段 09:角色圣经 +- 阶段 10:长篇记忆 +- 阶段 11:分集计划 +- 阶段 12:脚本和分镜 +- 阶段 13:BullMQ 队列 +- 阶段 14:AI Provider 抽象 +- 阶段 15:图片生成 mock +- 阶段 16:TTS / 字幕 / FFmpeg +- 阶段 17:后台管理 +- 阶段 18:uni-app 用户端 +- 阶段 19:订单额度 +- 阶段 20:内容审核 +- 阶段 21:真实 AI Provider 接入 +- 阶段 22:MVP 验收 +- 阶段 23:API 加密传输 +- 生产化优化:单句 TTS 连续重试片段元数据保留 +- 生产化修复:后台视频预览本地存储路径稳定化 +- 生产化优化:成本优化策略硬落地 +- 生产化优化:AI Router V1 / 镜头评分 / 自动选模型 +- 生产化优化:质检任务化 / Router 队列化 V1 +- 生产化优化:爆款诊断 / 拉片分析 V1 +- 生产化优化:Prompt Library / 题材套路库 / IP 设定宇宙前台化 V1 +- 生产化优化:模式库运营闭环 V1 +- 生产化优化:真人视频小样预检 / 验收闭环 V1 +- 生产化优化:真人小样测试台 V1 +- 生产化优化:真人视频真实 Provider 小样验收脚本 V1 +- 生产化优化:真人视频 Prompt Engine V1 +- 生产化优化:MiniMax LipSyncProvider 占位接入 +- 生产化优化:首条真人短剧一集压测用例整理 +- 生产化优化:首条真人短剧压测项目导入 / Prompt 准备 +- 生产化优化:首条真人短剧 Mock 全链路成片验收 + +## 正在进行 + +- 生产化上线补齐与验收 + +## 待开发阶段 + +- 生产环境真实 Key / 真实 Sora 付费调用 E2E 验收 +- 更细的权限表 UI、权限配置页面和多租户数据范围策略 + +## 阶段记录 + +### 阶段 00:读取 docs 并输出开发计划 + +完成时间:2026-05-31 17:00:00 CST + +完成内容: + +- 读取并梳理 docs/system_a 核心文档。 +- 明确系统 A MVP 为 AI 原创小说 3 集 MP4 与上传小说 1 集 MP4 两条闭环。 +- 标记阶段编号、数据库补充字段、队列清单、上传格式、支付额度前置等待确认点。 + +修改文件: + +- 无 + +新增文件: + +- 无 + +运行命令: + +- find docs/system_a -maxdepth 1 -type f +- wc -l docs/system_a/*.md +- rg 文档标题 +- sed 阅读核心文档 + +测试结果: + +- 只读阶段,无代码测试。 + +遗留问题: + +- 当前目录不是 Git 仓库,无法执行 git status。 + +下一步建议: + +- 进入阶段 01:初始化项目骨架。 + +### 阶段 01:初始化项目骨架 + +完成时间:2026-05-31 17:03:26 CST + +完成内容: + +- 创建 npm workspaces monorepo。 +- 初始化 backend 为最小 NestJS API 服务。 +- 初始化 admin 为 Geeker-Admin 可接入的 Vue/Vite 后台骨架。 +- 初始化 user-app 为带 manifest/pages 配置的用户端 H5 骨架,保留后续 uni-app 接入位置。 +- 初始化 workers 作为后续 BullMQ / FFmpeg worker 入口。 +- 初始化 deploy 与本地 MySQL、Redis、MinIO docker-compose.dev.yml。 +- 创建 .env.example、README.md、storage 占位目录。 + +修改文件: + +- CODEX_PROGRESS.md + +新增文件: + +- package.json +- package-lock.json +- tsconfig.base.json +- .gitignore +- .env.example +- README.md +- backend/ +- admin/ +- user-app/ +- workers/ +- deploy/ +- storage/ + +运行命令: + +- node -v +- npm -v +- npm install +- npm run lint +- npm run typecheck +- npm test +- npm run build +- npm run dev:backend +- npm run dev:admin +- npm run dev:user +- curl http://127.0.0.1:3000/api/health +- curl -I http://127.0.0.1:5175 +- curl -I http://127.0.0.1:5174 + +测试结果: + +- npm run lint:通过 +- npm run typecheck:通过 +- npm test:通过,backend 1 个测试通过,workers 1 个测试通过,admin/user-app 暂无测试文件并以 passWithNoTests 通过 +- npm run build:通过 +- 后端健康检查返回 code=0,status=ok +- 后台骨架 HTTP 200 +- 用户端 H5 骨架 HTTP 200 + +遗留问题: + +- 当前目录不是 Git 仓库,按用户要求暂不提交 Git。 +- npm install 后 audit 提示 13 个依赖风险,暂未执行 npm audit fix --force,避免阶段 01 被破坏性升级影响。 +- user-app 当前是可运行 H5 骨架,并保留 manifest/pages 配置;完整 uni-app 插件接入留到用户端阶段。 +- 5173 端口已有其他 Node 进程占用,本阶段后台改用 5175。 + +下一步建议: + +- 人工审核阶段 01。 +- 审核通过后进入阶段 02:数据库 schema。 + +### 阶段 02:数据库 schema + +完成时间:2026-05-31 17:17:57 CST + +完成内容: + +- 选择 Prisma 6.19.3 作为 MySQL 8 ORM。 +- 根据 docs/system_a/05 及相关订单、Provider、审核、日志文档创建 30 张核心表模型。 +- 覆盖 users、projects、novel_sources、novel_chapters、copyright_records、story_bibles、world_bibles、characters、character_images、character_memories、episodes、episode_scripts、storyboard_shots、shot_images、plot_memories、plot_threads、continuity_checks、assets、render_tasks、provider_configs、provider_logs、orders、quota_accounts、quota_logs、revision_requests、content_reviews、case_showcases、analytics_events、system_configs、operation_logs。 +- 添加核心唯一约束和索引:用户邮箱/手机号、项目状态、章节顺序、角色类型、分集编号、分镜顺序、任务幂等 key、Provider 配置、订单号、额度账户等。 +- 生成初始 migration.sql。 +- 创建 seed.ts,包含管理员示例用户、mock TextProvider、系统阶段配置、示例额度账户。 +- 在 README.md 补充数据库命令和 ORM 选择说明。 + +修改文件: + +- package.json +- package-lock.json +- backend/package.json +- README.md +- CODEX_PROGRESS.md + +新增文件: + +- backend/prisma/schema.prisma +- backend/prisma/migrations/20260531093000_init_system_a/migration.sql +- backend/prisma/seed.ts + +运行命令: + +- npm view prisma@6 version --json +- npm install -w backend @prisma/client@6.19.3 +- npm install -D -w backend prisma@6.19.3 +- DATABASE_URL=... npm run db:validate +- DATABASE_URL=... npm run prisma:generate -w backend +- npx prisma migrate diff --from-empty --to-schema-datamodel prisma/schema.prisma --script --output prisma/migrations/20260531093000_init_system_a/migration.sql +- DATABASE_URL=... npm run prisma:migrate -w backend -- --name init_system_a --create-only +- npx tsc --noEmit --target ES2022 --module CommonJS --moduleResolution Node --esModuleInterop --skipLibCheck --strict backend/prisma/seed.ts +- npm run lint +- npm run typecheck +- npm test +- npm run build + +测试结果: + +- Prisma schema validate:通过 +- Prisma Client generate:通过 +- seed.ts TypeScript 编译检查:通过 +- migration.sql 离线生成:通过,包含 30 张表 +- npm run lint:通过 +- npm run typecheck:通过 +- npm test:通过,backend 1 个测试通过,workers 1 个测试通过,admin/user-app 暂无测试文件并以 passWithNoTests 通过 +- npm run build:通过 + +遗留问题: + +- 当前目录不是 Git 仓库,按用户要求暂不提交 Git。 +- npm audit 当前仍提示依赖风险,未执行强制修复,避免破坏阶段成果。 + +补充记录:2026-05-31 17:37:37 CST + +- 已确认问题原因:`.env.example` 中的 `ai_manga` 是占位账号,之前尚未在 MySQL 中创建;Linux root 权限不等于 MySQL root 账号权限。 +- 已从本机宝塔/aaPanel 配置读取 MySQL root 管理凭据,没有打印密码。 +- 已创建 `ai_manga` 数据库和 `ai_manga` 本地用户。 +- 已使用 `prisma migrate deploy` 应用现有 migration。 +- 已执行 `npm run db:seed`。 +- 已验证库中存在 31 张表,其中 30 张业务表加 1 张 Prisma 迁移表。 + +下一步建议: + +- 人工审核阶段 02。 +- 提供可用 MySQL DATABASE_URL 后执行迁移和 seed。 +- 审核通过后进入阶段 03:用户认证。 + +### 阶段 03:用户认证 + +完成时间:2026-05-31 17:28:53 CST + +完成内容: + +- 实现 PrismaService,供后续业务模块统一访问数据库。 +- 实现 UsersModule 和 UsersService。 +- 实现 AuthModule、AuthController、AuthService、JwtAuthGuard。 +- 支持 POST /api/auth/register 用户注册。 +- 支持 POST /api/auth/login 用户登录。 +- 支持 POST /api/auth/logout 占位退出。 +- 支持 GET /api/auth/profile 获取当前用户信息。 +- 额外支持 GET /api/profile,兼容阶段指令中的 /profile 验收口径。 +- 使用 bcryptjs 对密码哈希。 +- 使用 JWT Bearer Token 鉴权。 +- 用户表 role 字段预留 user/operator/admin 等后台权限。 +- 增加 RequestIdMiddleware、ApiResponseInterceptor、AllExceptionsFilter。 +- 全局成功响应格式为 `{ code, message, data, request_id }`。 +- 全局异常响应格式为 `{ code, message, data: null, request_id }`。 +- README.md 补充认证接口说明。 + +修改文件: + +- backend/package.json +- backend/src/app.module.ts +- backend/src/app.controller.ts +- backend/src/app.controller.spec.ts +- README.md +- package-lock.json +- CODEX_PROGRESS.md + +新增文件: + +- backend/src/prisma/prisma.module.ts +- backend/src/prisma/prisma.service.ts +- backend/src/common/request-with-id.ts +- backend/src/common/request-id.middleware.ts +- backend/src/common/api-response.interceptor.ts +- backend/src/common/all-exceptions.filter.ts +- backend/src/users/user.types.ts +- backend/src/users/users.module.ts +- backend/src/users/users.service.ts +- backend/src/auth/auth.dto.ts +- backend/src/auth/auth.types.ts +- backend/src/auth/current-user.decorator.ts +- backend/src/auth/jwt-auth.guard.ts +- backend/src/auth/auth.service.ts +- backend/src/auth/auth.controller.ts +- backend/src/auth/auth.module.ts +- backend/src/auth/auth.service.spec.ts +- backend/src/auth/jwt-auth.guard.spec.ts + +运行命令: + +- npm install -w backend @nestjs/jwt bcryptjs +- npm install -D -w backend @types/express +- npm run typecheck -w backend +- npm test -w backend +- npm run lint +- npm run typecheck +- npm test +- npm run build +- curl http://127.0.0.1:3000/api/health +- curl -i http://127.0.0.1:3000/api/auth/profile +- curl -i http://127.0.0.1:3000/api/profile + +测试结果: + +- backend typecheck:通过 +- backend test:通过,3 个测试文件,7 个测试通过 +- npm run lint:通过 +- npm run typecheck:通过 +- npm test:通过,backend 7 个测试通过,workers 1 个测试通过,admin/user-app 暂无测试文件并以 passWithNoTests 通过 +- npm run build:通过 +- GET /api/health:返回统一成功响应 +- GET /api/auth/profile 未带 token:返回 401 Missing bearer token +- GET /api/profile 未带 token:返回 401 Missing bearer token + +遗留问题: + +- 当前目录不是 Git 仓库,按用户要求暂不提交 Git。 +- npm audit 仍提示依赖风险,未执行强制修复,避免破坏阶段成果。 + +补充记录:2026-05-31 17:37:37 CST + +- 已重启后端并注入 DATABASE_URL。 +- 已完成真实接口联调:POST /api/auth/register 成功,POST /api/auth/login 成功,GET /api/auth/profile 带 token 成功。 +- 修复运行态 NestJS 依赖注入问题:为 AuthController、ProfileController、AuthService、JwtAuthGuard、UsersService 增加显式 `@Inject(...)`。 +- 新增 `db:deploy` / `prisma:deploy` 脚本,服务器已有 migration 时优先用 deploy,避免 `migrate dev` 需要 shadow database 权限。 +- 重新运行 npm run lint、npm run typecheck、npm test、npm run build,全部通过。 + +下一步建议: + +- 人工审核阶段 03。 +- 提供可用 MySQL DATABASE_URL 后先执行 `npm run db:migrate` 与 `npm run db:seed`,再做真实注册登录接口联调。 +- 审核通过后进入阶段 04:文件上传和 MinIO。 + +### 阶段 04:文件上传和 MinIO + +完成时间:2026-05-31 17:44:38 CST + +完成内容: + +- 实现 AssetsModule、AssetsController、AssetsService、StorageService。 +- 支持 POST /api/assets/upload 通用私有资产上传。 +- 支持 POST /api/projects/:projectId/novel/upload 小说文件上传。 +- 支持 GET /api/assets/:assetId 查询当前用户自己的私有资产。 +- 上传接口全部受 JWT Bearer Token 保护。 +- 小说上传当前支持 txt 和 md,pdf/docx 保留到后续解析阶段。 +- 文件默认写入 `assets` 表,`visibility=private`。 +- 接口不返回原始文件公网 URL,`file_url` 保持 null。 +- 本机未启动 MinIO 时默认使用本地 mock 私有存储:`storage/private/...`。 +- StorageService 已预留 MinIO 客户端;设置 `STORAGE_DRIVER=minio` 并配置 `MINIO_*` 后可切换。 +- `.env.example` 增加 `STORAGE_DRIVER=local`,并调整本地存储路径到仓库根 storage。 +- README.md 补充上传接口说明。 +- 增加资产上传单元测试。 + +修改文件: + +- .env.example +- .gitignore +- README.md +- backend/package.json +- backend/src/app.module.ts +- backend/src/auth/auth.module.ts +- package-lock.json +- CODEX_PROGRESS.md + +新增文件: + +- backend/src/assets/asset.types.ts +- backend/src/assets/upload.dto.ts +- backend/src/assets/storage.service.ts +- backend/src/assets/assets.service.ts +- backend/src/assets/assets.controller.ts +- backend/src/assets/assets.module.ts +- backend/src/assets/assets.service.spec.ts + +运行命令: + +- npm install -w backend minio multer +- npm install -D -w backend @types/multer +- npm run typecheck -w backend +- npm test -w backend +- npm run lint +- npm run typecheck +- npm test +- npm run build +- curl POST /api/auth/register +- MySQL 插入阶段 04 测试项目 +- curl POST /api/projects/:projectId/novel/upload -F file=@stage04-novel.txt +- MySQL 查询 assets 表 +- curl GET /api/assets/:assetId + +测试结果: + +- backend typecheck:通过 +- backend test:通过,4 个测试文件,10 个测试通过 +- npm run lint:通过 +- npm run typecheck:通过 +- npm test:通过,backend 10 个测试通过,workers 1 个测试通过,admin/user-app 暂无测试文件并以 passWithNoTests 通过 +- npm run build:通过 +- 真实注册测试用户成功 +- 真实创建测试项目成功 +- 真实上传 txt 小说成功,返回 asset_type=novel_text,visibility=private,storage_backend=local,next_step=copyright_confirm +- assets 表记录 file_url 为 NULL,file_path 为 local:// 前缀 +- 本地文件写入 storage/private/novels/YYYY-MM-DD +- 未登录访问 GET /api/assets/:assetId 返回 401 +- 带 token 查询自己的 asset 成功 + +遗留问题: + +- 当前目录不是 Git 仓库,按用户要求暂不提交 Git。 +- 本机没有 MinIO 9000/9001 服务监听,因此本阶段用本地 mock private 存储完成联调。 +- docx/pdf 文件格式尚未开放,留到上传小说解析阶段处理。 +- npm audit 仍提示依赖风险,未执行强制修复,避免破坏阶段成果。 + +下一步建议: + +- 人工审核阶段 04。 +- 如要真实 MinIO 联调,先启动 MinIO 并设置 `STORAGE_DRIVER=minio`。 +- 审核通过后进入阶段 05:项目创建流程。 + +### 阶段 05:项目创建流程 + +完成时间:2026-05-31 17:49:49 CST + +完成内容: + +- 实现 ProjectsModule、ProjectsController、ProjectsService。 +- 支持 POST /api/projects 创建登录用户项目。 +- 支持 GET /api/projects 查询当前用户项目列表。 +- 支持 GET /api/projects/:id 查询项目详情。 +- 支持 PATCH /api/projects/:id 更新项目基础配置。 +- 支持 POST /api/projects/:id/cancel 取消项目。 +- 支持 DELETE /api/projects/:id 软删除项目,当前实现为归档到 `archived`。 +- 新项目默认状态为 `source_selecting`。 +- `input_mode` 当前开放 `ai_original` 和 `upload`,`admin_import` 预留后台导入。 +- 根据创建模式填充默认风格、输出类型、质量档位、目标集数和版权状态。 +- 增加项目所有权校验,用户只能访问自己的项目,管理员预留跨项目能力。 +- 文件上传模块改为调用 ProjectsService 校验项目归属,避免给非项目所有者上传小说。 +- README.md 补充项目接口说明。 +- 增加项目服务单元测试。 + +修改文件: + +- README.md +- backend/src/app.module.ts +- backend/src/assets/assets.module.ts +- backend/src/assets/assets.service.ts +- backend/src/assets/assets.service.spec.ts +- CODEX_PROGRESS.md + +新增文件: + +- backend/src/projects/project.types.ts +- backend/src/projects/project.dto.ts +- backend/src/projects/projects.service.ts +- backend/src/projects/projects.controller.ts +- backend/src/projects/projects.module.ts +- backend/src/projects/projects.service.spec.ts + +运行命令: + +- npm run typecheck -w backend +- npm test -w backend +- npm run lint +- npm run typecheck +- npm test +- npm run build +- curl POST /api/auth/register +- curl POST /api/projects +- curl GET /api/projects +- curl GET /api/projects/:id +- curl PATCH /api/projects/:id +- curl GET /api/projects/:id 使用另一个用户 token +- curl POST /api/projects/:id/cancel +- curl DELETE /api/projects/:id + +测试结果: + +- backend typecheck:通过 +- backend test:通过,5 个测试文件,15 个测试通过 +- npm run lint:通过 +- npm run typecheck:通过 +- npm test:通过,backend 15 个测试通过,workers 1 个测试通过,admin/user-app 暂无测试文件并以 passWithNoTests 通过 +- npm run build:通过 +- 真实注册测试用户成功 +- 真实创建 upload 项目成功,返回 status=source_selecting +- 真实查询项目列表成功,包含刚创建项目 +- 真实查询项目详情成功 +- 真实更新项目标题和目标集数成功 +- 使用另一个用户 token 查询该项目返回 403 Project is private +- 真实取消项目成功,状态变为 cancelled +- 真实软删除项目成功,状态变为 archived + +遗留问题: + +- 当前目录不是 Git 仓库,按用户要求暂不提交 Git。 +- npm audit 仍提示依赖风险,未执行强制修复,避免破坏阶段成果。 +- 项目删除当前为软删除归档,不物理删除数据库记录和已上传文件。 +- 本阶段只实现项目创建和基础流转,不做上传小说解析、AI 原创、队列或真实 Provider。 + +下一步建议: + +- 人工审核阶段 05。 +- 审核通过后进入阶段 06:上传小说解析。 + +### 阶段 06:上传小说解析 + +完成时间:2026-05-31 18:09:02 CST + +完成内容: + +- 安装 `mammoth` 和 `pdf-parse`,用于 docx 与文本型 pdf 抽取。 +- StorageService 增加私有对象读取能力,支持读取 `local://` 和 `minio://` 路径。 +- 小说上传白名单扩展为 txt、md、docx、文本型 pdf。 +- 新增 NovelsModule、NovelsController、NovelsService、NovelParserService。 +- 支持 POST /api/projects/:projectId/copyright/confirm 确认上传小说版权。 +- 支持 GET /api/projects/:projectId/copyright 查询版权确认记录。 +- 支持 POST /api/projects/:projectId/novel/paste 保存粘贴文本来源。 +- 支持 POST /api/projects/:projectId/novel/parse 解析上传 asset 或粘贴 source。 +- 支持 GET /api/projects/:projectId/novel/parse-result 查询解析结果。 +- 支持 PATCH /api/novel-chapters/:chapterId 手动编辑章节。 +- 解析前强制校验版权确认,未确认时返回 400。 +- 解析流程写入 `novel_sources` 和 `novel_chapters`。 +- 文本清洗会处理 BOM、空行、部分广告/水印/链接噪声。 +- 章节识别支持 `第1章`、`第一章`、`Chapter 1`、`001 标题`、`序章`、`楔子`、`番外` 等格式。 +- 章节识别失败时按字数切分,并在 parse_report 中记录 warning。 +- 解析成功后项目状态进入 `novel_uploaded`;解析失败时进入 `text_parse_failed`。 +- 手动编辑章节后状态标记为 `edited`。 +- README.md 补充小说解析与版权接口说明。 +- 增加小说解析和小说服务单元测试。 + +修改文件: + +- package.json +- package-lock.json +- backend/package.json +- README.md +- backend/src/app.module.ts +- backend/src/assets/assets.service.ts +- backend/src/assets/assets.service.spec.ts +- backend/src/assets/storage.service.ts +- backend/src/projects/project.types.ts +- backend/src/projects/projects.service.ts +- CODEX_PROGRESS.md + +新增文件: + +- backend/src/novels/novel.dto.ts +- backend/src/novels/novel.types.ts +- backend/src/novels/novel-parser.service.ts +- backend/src/novels/novel-parser.service.spec.ts +- backend/src/novels/novels.controller.ts +- backend/src/novels/novels.module.ts +- backend/src/novels/novels.service.ts +- backend/src/novels/novels.service.spec.ts + +运行命令: + +- git status --short +- npm view mammoth version +- npm view pdf-parse version +- npm install -w backend mammoth pdf-parse +- npm run typecheck -w backend +- npm test -w backend +- npm run lint +- npm run typecheck +- npm test +- npm run build +- curl http://127.0.0.1:3000/api/health +- Node fetch 真实接口联调:注册、创建项目、上传 txt、未确认版权解析、确认版权、解析、查结果、编辑章节、粘贴文本 + +测试结果: + +- backend typecheck:通过 +- backend test:通过,7 个测试文件,24 个测试通过 +- npm run lint:通过 +- npm run typecheck:通过 +- npm test:通过,backend 24 个测试通过,workers 1 个测试通过,admin/user-app 暂无测试文件并以 passWithNoTests 通过 +- npm run build:通过 +- 后端健康检查返回 code=0,status=ok +- 真实注册测试用户成功 +- 真实创建 upload 项目成功 +- 真实上传 txt 小说成功 +- 未确认版权调用 POST /api/projects/:projectId/novel/parse 返回 400 +- 确认版权成功,返回 next_step=novel_parse +- 真实解析上传 txt 成功,识别 2 个章节,parse_report.strategy=heading +- 清洗测试链接噪声成功,removed_line_count=1 +- GET /api/projects/:projectId/novel/parse-result 返回 2 个章节 +- PATCH /api/novel-chapters/:chapterId 成功,章节状态变为 edited +- POST /api/projects/:projectId/novel/paste 成功创建粘贴文本 source + +遗留问题: + +- 当前目录不是 Git 仓库,按用户要求暂不提交 Git。 +- npm audit 当前提示 8 个依赖风险,未执行强制修复,避免破坏阶段成果。 +- 本机没有 MinIO 9000/9001 服务监听,因此 MinIO 读取路径只做代码预留,本阶段真实联调用本地 private 存储完成。 +- pdf 当前仅支持文本型 PDF;扫描件 OCR 不在本阶段实现。 +- 本阶段只做 deterministic 文本解析,不做 AI 改编、故事分析、任务队列或真实 Provider。 +- 真实联调产生了测试用户、项目、资产、小说来源和章节数据,未清理。 + +下一步建议: + +- 人工审核阶段 06。 +- 审核通过后进入阶段 07:AI 原创小说 mock。 + +### 阶段 07:AI 原创小说 mock + +完成时间:2026-05-31 18:16:31 CST + +完成内容: + +- 新增 OriginalNovelsController 和 OriginalNovelMockService。 +- 支持 POST /api/projects/:projectId/original/idea 生成 mock 故事创意。 +- 支持 POST /api/projects/:projectId/original/outline 生成 mock 故事大纲和分章大纲。 +- 支持 POST /api/projects/:projectId/original/chapters 生成 mock 章节正文。 +- 支持 POST /api/projects/:projectId/original/self-check 执行 mock 自检。 +- 支持 GET /api/projects/:projectId/original/result 查询原创小说 mock 结果。 +- 原创接口强制要求项目 `input_mode=ai_original`,上传小说项目调用会返回 400。 +- mock 结果写入 `novel_sources`,`source_type=ai_original`。 +- mock 章节写入 `novel_chapters`,状态为 `generated`。 +- mock 生成过程记录到 `parse_report`,`provider=mock_novel_provider`。 +- 生成 idea 后项目状态进入 `novel_generating`。 +- 生成章节后项目状态进入 `novel_uploaded`。 +- 自检结果写入 `parse_report.self_check`,通过后 source 状态为 `checked`。 +- 自检覆盖主角一致性、主线明确、冲突强度、可视化摘要、短视频钩子。 +- README.md 补充 AI 原创小说 mock 接口说明。 +- 增加原创小说 mock 单元测试。 + +修改文件: + +- README.md +- backend/src/novels/novels.module.ts +- CODEX_PROGRESS.md + +新增文件: + +- backend/src/novels/original-novel.dto.ts +- backend/src/novels/original-novel.types.ts +- backend/src/novels/original-novel-mock.service.ts +- backend/src/novels/original-novel-mock.service.spec.ts +- backend/src/novels/original-novels.controller.ts + +运行命令: + +- git status --short +- npm run typecheck -w backend +- npm test -w backend +- npm run lint +- npm run typecheck +- npm test +- npm run build +- curl http://127.0.0.1:3000/api/health +- tail -n 80 /tmp/ai-manga-backend.log +- Node fetch 真实接口联调:注册、创建 upload 项目、验证 upload 项目调用原创接口被拒绝、创建 ai_original 项目、生成 idea、生成 outline、生成 chapters、自检、查询 result + +测试结果: + +- backend typecheck:通过 +- backend test:通过,8 个测试文件,29 个测试通过 +- npm run lint:通过 +- npm run typecheck:通过 +- npm test:通过,backend 29 个测试通过,workers 1 个测试通过,admin/user-app 暂无测试文件并以 passWithNoTests 通过 +- npm run build:通过 +- 后端健康检查返回 code=0,status=ok +- 后端日志确认 original 路由全部映射成功 +- 真实注册测试用户成功 +- 真实创建 upload 项目成功,调用 original/idea 返回 400 +- 真实创建 ai_original 项目成功 +- POST /original/idea 成功,返回 source_id 和 mock idea +- POST /original/outline 成功,生成 3 个分章大纲 +- POST /original/chapters 成功,生成 3 个章节并写入 `novel_chapters` +- POST /original/self-check 成功,self_check.passed=true,score=100 +- GET /original/result 成功,返回 source、idea、outline、self_check 和 3 个章节 + +遗留问题: + +- 当前目录不是 Git 仓库,按用户要求暂不提交 Git。 +- npm audit 当前提示 8 个依赖风险,未执行强制修复,避免破坏阶段成果。 +- 本阶段只做 deterministic mock,不接真实 AI Provider,也不实现 Provider 抽象、队列或成本记录。 +- 生成内容是固定模板拼装,质量只用于 MVP 流程联调,不代表最终小说质量。 +- 真实联调产生了测试用户、项目、小说来源和章节数据,未清理。 + +下一步建议: + +- 人工审核阶段 07。 +- 审核通过后进入阶段 08:故事圣经。 + +### 阶段 08:故事圣经 + +完成时间:2026-05-31 18:25:56 CST + +完成内容: + +- 新增 StoryBiblesModule、StoryBiblesController、StoryBiblesService。 +- 支持 POST /api/projects/:projectId/story-bible/generate 生成故事圣经。 +- 支持 GET /api/projects/:projectId/story-bible 查询最新故事圣经和版本列表。 +- 支持 GET /api/projects/:projectId/story-bible?version=N 查询指定版本。 +- 支持 PATCH /api/projects/:projectId/story-bible 编辑故事圣经并创建新版本。 +- 支持 POST /api/projects/:projectId/story-bible/confirm 确认故事圣经。 +- 生成前要求项目已有小说来源和章节,可接上传解析结果或 AI 原创 mock 结果。 +- 生成时从 `novel_sources.parse_report`、`novel_chapters.summary`、`novel_chapters.visual_summary` 提取故事要素。 +- 故事圣经覆盖一句话简介、主线目标、核心冲突、核心卖点、风格基调、世界规则、时间线、伏笔、禁用设定和结局方向。 +- 故事圣经写入 `story_bibles`,初始状态为 `waiting_confirm`。 +- 编辑不会覆盖旧记录,而是创建 version+1 的新版本。 +- 确认时把当前版本状态改为 `confirmed`,并把同项目旧 confirmed 版本置为 `superseded`。 +- 生成时项目状态流转到 `story_bible_generating`,生成完成到 `waiting_story_confirm`。 +- 确认后项目状态流转到 `story_confirmed`。 +- README.md 补充故事圣经接口说明。 +- 增加故事圣经服务单元测试。 + +修改文件: + +- README.md +- backend/src/app.module.ts +- CODEX_PROGRESS.md + +新增文件: + +- backend/src/story-bibles/story-bible.dto.ts +- backend/src/story-bibles/story-bible.types.ts +- backend/src/story-bibles/story-bibles.controller.ts +- backend/src/story-bibles/story-bibles.module.ts +- backend/src/story-bibles/story-bibles.service.ts +- backend/src/story-bibles/story-bibles.service.spec.ts + +运行命令: + +- git status --short +- rg 故事圣经 / story-bible 相关文档和代码 +- npm run typecheck -w backend +- npm test -w backend +- npm run lint +- npm run typecheck +- npm test +- npm run build +- curl http://127.0.0.1:3000/api/health +- tail -n 90 /tmp/ai-manga-backend.log +- Node fetch 真实接口联调:注册、创建无章节项目并验证生成被拒绝、创建 ai_original 项目、生成原创 idea/outline/chapters、生成故事圣经、查询、编辑 v2、确认、查询项目状态 + +测试结果: + +- backend typecheck:通过 +- backend test:通过,9 个测试文件,34 个测试通过 +- npm run lint:通过 +- npm run typecheck:通过 +- npm test:通过,backend 34 个测试通过,workers 1 个测试通过,admin/user-app 暂无测试文件并以 passWithNoTests 通过 +- npm run build:通过 +- 后端健康检查返回 code=0,status=ok +- 后端日志确认 story-bible 路由全部映射成功 +- 无小说来源/章节时调用 story-bible/generate 被拒绝 +- 真实 AI 原创 mock 生成章节成功 +- 真实生成故事圣经成功,version=1,status=waiting_confirm +- GET /story-bible 返回当前版本和版本列表 +- PATCH /story-bible 成功创建 version=2 +- POST /story-bible/confirm 成功,故事圣经状态变为 confirmed +- GET /projects/:id 确认项目状态为 story_confirmed + +遗留问题: + +- 当前目录不是 Git 仓库,按用户要求暂不提交 Git。 +- npm audit 当前提示 8 个依赖风险,未执行强制修复,避免破坏阶段成果。 +- 本阶段故事圣经为 deterministic 提取/拼装,不调用真实 AI Provider。 +- `world_bibles` 表暂未单独写入;世界规则先落在 `story_bibles.world_summary`,后续如拆 WorldBibleModule 再迁移。 +- 真实联调产生了测试用户、项目、小说来源、章节和故事圣经数据,未清理。 + +下一步建议: + +- 人工审核阶段 08。 +- 审核通过后进入阶段 09:角色圣经。 + +### 阶段 09:角色圣经 + +完成时间:2026-05-31 18:34:22 CST + +完成内容: + +- 新增 CharactersModule、CharactersController、CharactersService。 +- 支持 POST /api/projects/:projectId/characters/extract 从已确认故事圣经和小说章节抽取角色草稿。 +- 支持 GET /api/projects/:projectId/characters 查询角色列表,默认不返回 deleted,可通过 include_deleted=true 包含软删除角色。 +- 支持 POST /api/projects/:projectId/characters 手动新增角色。 +- 支持 PATCH /api/characters/:characterId 编辑角色。 +- 支持 DELETE /api/characters/:characterId 软删除未锁定角色。 +- 支持 POST /api/projects/:projectId/characters/confirm 确认角色库并锁定角色。 +- 抽取前要求项目已有 confirmed 故事圣经;未确认时返回 400。 +- 角色抽取当前为 deterministic mock,默认生成主角、反派、配角 3 类角色。 +- 抽取结果写入 `characters` 表,状态为 `generated`。 +- 手动新增角色状态为 `edited`,编辑未锁定角色后状态标记为 `edited`。 +- 确认角色库会把 `draft`、`generated`、`edited` 状态角色锁定为 `locked`。 +- 锁定后禁止修改姓名、角色类型、性别、年龄、身份和核心外观字段;允许继续补充服装规则、表情风格等非核心描述。 +- 项目状态流转覆盖 `character_extracting`、`waiting_character_confirm`、`character_confirmed`。 +- README.md 补充角色圣经接口说明。 +- 增加角色圣经服务单元测试。 + +修改文件: + +- README.md +- CODEX_PROGRESS.md +- backend/src/app.module.ts +- backend/src/projects/project.types.ts + +新增文件: + +- backend/src/characters/character.dto.ts +- backend/src/characters/character.types.ts +- backend/src/characters/characters.service.ts +- backend/src/characters/characters.controller.ts +- backend/src/characters/characters.module.ts +- backend/src/characters/characters.service.spec.ts + +运行命令: + +- git status --short +- rg 角色圣经 / characters 相关文档和代码 +- npm run typecheck -w backend +- npm test -w backend +- npm run lint +- npm run typecheck +- npm test +- npm run build +- curl http://127.0.0.1:3000/api/health +- tail -n 100 /tmp/ai-manga-backend.log +- Node fetch 真实接口联调:注册、创建未确认故事圣经项目并验证抽取被拒绝、创建 ai_original 项目、生成原创章节、生成并确认故事圣经、抽取角色、列表、编辑、手动新增、删除、确认锁定、验证 locked 限制、查询项目状态 + +测试结果: + +- backend typecheck:通过 +- backend test:通过,10 个测试文件,41 个测试通过 +- npm run lint:通过 +- npm run typecheck:通过 +- npm test:通过,backend 41 个测试通过,workers 1 个测试通过,admin/user-app 暂无测试文件并以 passWithNoTests 通过 +- npm run build:通过 +- 后端健康检查返回 code=0,status=ok +- 后端日志确认角色圣经路由全部映射成功 +- 未确认故事圣经时调用 POST /characters/extract 返回 400 +- 真实 AI 原创 mock 生成章节成功 +- 真实生成并确认故事圣经成功,story_bible_id=3 +- 真实抽取角色成功,生成 3 个角色,首个角色为林晚 +- GET /projects/:projectId/characters 返回 3 个未删除角色 +- PATCH 角色服装规则成功 +- POST 手动新增配角顾南成功 +- DELETE 手动角色成功,状态变为 deleted +- POST /characters/confirm 成功,抽取角色全部变为 locked +- locked 角色修改核心姓名字段返回 400 +- locked 角色修改非核心服装规则成功,状态保持 locked +- GET /projects/:id 确认项目状态为 character_confirmed + +遗留问题: + +- 当前目录不是 Git 仓库,按用户要求暂不提交 Git。 +- npm audit 当前提示 8 个依赖风险,未执行强制修复,避免破坏阶段成果。 +- 本阶段角色抽取为 deterministic mock / 模板生成,不调用真实 AI Provider。 +- 本阶段不生成角色图片、不生成 anchor 图,也不接入 ImageProvider 或队列。 +- `anchor_asset_id` 字段保留,但本阶段不写入。 +- 真实联调产生了测试用户、项目、小说来源、章节、故事圣经和角色数据,未清理。 + +下一步建议: + +- 人工审核阶段 09。 +- 审核通过后进入阶段 10:长篇记忆。 + +### 阶段 10:长篇记忆 + +完成时间:2026-05-31 18:51:19 CST + +完成内容: + +- 新增 MemoriesModule、MemoriesController、MemoriesService。 +- 支持 GET /api/projects/:projectId/plot-memories 查询剧情记忆,可按 memory_type、status、episode_id 过滤。 +- 支持 POST /api/projects/:projectId/plot-memories/generate 从 confirmed 故事圣经、locked 角色库和小说章节/分集摘要生成长篇记忆。 +- 支持 POST /api/projects/:projectId/plot-memories 手动新增剧情记忆。 +- 支持 PATCH /api/plot-memories/:memoryId 更新剧情记忆,覆盖标记 resolved/archived 等人工维护场景。 +- 支持 GET /api/projects/:projectId/memory-context?episode_no=N 获取分集生成前上下文。 +- 支持 GET /api/characters/:characterId/memories 查询角色记忆。 +- 支持 GET /api/projects/:projectId/plot-threads 查询剧情线。 +- 支持 POST /api/projects/:projectId/plot-threads 手动新增剧情线。 +- 支持 PATCH /api/plot-threads/:threadId 更新剧情线状态、预计/实际解决集数等。 +- 支持 POST /api/episodes/:episodeId/continuity-check 执行规则版连续性检查。 +- 记忆生成前要求项目已有 confirmed 故事圣经和 locked 角色库;否则返回 400。 +- 生成结果写入 `plot_memories`、`plot_threads` 和 `character_memories`。 +- 默认剧情记忆覆盖章节事件、未解决冲突、伏笔、世界规则、人物关系变化、重要道具状态和下一集钩子。 +- 默认剧情线覆盖主线目标、反派计划和角色成长线。 +- `memory-context` 会聚合故事圣经、锁定角色、活跃剧情记忆、开放剧情线、前 3 集摘要和上一集结尾钩子,供下一阶段分集计划使用。 +- locked 角色非核心资料补充时,会自动写入 `character_memories.profile_adjustment`。 +- 连续性检查可发现角色未承接、伏笔未推进、上一集钩子未承接、缺少结尾钩子、开放剧情线未推进和明显破坏世界观的内容。 +- README.md 补充长篇记忆接口说明。 +- 增加长篇记忆服务单元测试。 + +修改文件: + +- README.md +- CODEX_PROGRESS.md +- backend/src/app.module.ts +- backend/src/characters/characters.service.ts +- backend/src/characters/characters.service.spec.ts + +新增文件: + +- backend/src/memories/memory.dto.ts +- backend/src/memories/memory.types.ts +- backend/src/memories/memories.service.ts +- backend/src/memories/memories.controller.ts +- backend/src/memories/memories.module.ts +- backend/src/memories/memories.service.spec.ts + +运行命令: + +- git status --short +- rg 长篇记忆 / memory / plot_memories / character_memories / continuity 相关文档和代码 +- npm run typecheck -w backend +- npm test -w backend +- npm run lint +- npm run typecheck +- npm test +- npm run build +- curl http://127.0.0.1:3000/api/health +- tail -n 140 /tmp/ai-manga-backend.log +- Node fetch 真实接口联调:注册、创建未满足条件项目并验证记忆生成被拒绝、创建 ai_original 项目、生成原创章节、生成并确认故事圣经、验证未锁角色时记忆生成被拒绝、抽取并确认角色、生成剧情记忆、列表、手动新增/标记剧情记忆、剧情线新增/更新、locked 角色补充并记录角色记忆、直接插入测试分集、获取第 5 集记忆上下文、执行连续性检查、查询项目状态 + +测试结果: + +- backend typecheck:通过 +- backend test:通过,11 个测试文件,48 个测试通过 +- npm run lint:通过 +- npm run typecheck:通过 +- npm test:通过,backend 48 个测试通过,workers 1 个测试通过,admin/user-app 暂无测试文件并以 passWithNoTests 通过 +- npm run build:通过 +- 后端健康检查返回 code=0,status=ok +- 后端日志确认长篇记忆路由全部映射成功 +- 未确认故事圣经时调用 POST /plot-memories/generate 返回 400 +- 已确认故事圣经但未锁定角色时调用 POST /plot-memories/generate 返回 400 +- 真实生成长篇记忆成功,生成 plot_memories=11、character_memories=12、plot_threads=3 +- GET /projects/:projectId/plot-memories 成功返回剧情记忆列表 +- 手动新增剧情记忆成功,PATCH 标记 resolved 成功 +- GET /projects/:projectId/plot-threads 成功返回剧情线列表 +- 手动新增剧情线成功,PATCH 更新为 progressing 成功 +- locked 角色补充服装规则成功,并在 GET /characters/:characterId/memories 中看到 profile_adjustment +- GET /projects/:projectId/memory-context?episode_no=5 成功返回前 3 集摘要和上一集结尾钩子 +- POST /episodes/:episodeId/continuity-check 成功发现“突然觉醒超能力”世界观冲突,result_status=fail +- GET /projects/:id 确认项目状态保持 character_confirmed,下一阶段可进入分集计划 + +遗留问题: + +- 当前目录不是 Git 仓库,按用户要求暂不提交 Git。 +- npm audit 当前提示 8 个依赖风险,未执行强制修复,避免破坏阶段成果。 +- 本阶段长篇记忆为 deterministic mock / 规则生成,不调用真实 AI Provider。 +- 本阶段不接入 EmbeddingProvider、不做向量检索、不进 BullMQ 队列,也不记录 Provider 成本。 +- 分集接口尚未实现;真实联调中的第 2-5 集测试数据通过 Prisma 直接插入,用于验证 memory-context 和 continuity-check。 +- 真实联调产生了测试用户、项目、小说来源、章节、故事圣经、角色、剧情记忆、剧情线、角色记忆、分集和连续性检查数据,未清理。 + +下一步建议: + +- 人工审核阶段 10。 +- 审核通过后进入阶段 11:分集计划。 + +### 阶段 11:分集计划 + +完成时间:2026-05-31 18:58:23 CST + +完成内容: + +- 新增 EpisodesModule、EpisodesController、EpisodesService。 +- 支持 POST /api/projects/:projectId/episodes/generate-plan 生成分集计划。 +- 支持 GET /api/projects/:projectId/episodes 查询项目分集列表。 +- 支持 PATCH /api/episodes/:episodeId 编辑确认前分集。 +- 支持 POST /api/projects/:projectId/episodes/confirm 确认分集计划。 +- 分集生成前要求项目已有 confirmed 故事圣经、locked 角色库、小说章节和 active 长篇记忆;缺失时返回 400。 +- 分集生成当前为 deterministic mock,从故事圣经、角色圣经、长篇记忆、剧情线和小说章节生成分集。 +- 每集写入 `episodes` 表,包含标题、剧情摘要、开头钩子、中段冲突、结尾悬念、关联章节和预计时长。 +- 生成分集时项目状态先进入 `episode_planning`,生成完成后进入 `waiting_episode_confirm`。 +- 编辑分集会把分集状态标记为 `edited`,并让项目保持 `waiting_episode_confirm`。 +- 确认分集前校验集数连续、每集具备标题/摘要/钩子/冲突/预计时长。 +- 确认分集会把 `draft`、`generated`、`edited` 状态分集更新为 `confirmed`,项目状态变为 `episode_confirmed`。 +- 已 confirmed 分集不可继续编辑,后续返工留给修改申请/返工流程。 +- README.md 补充分集计划接口说明。 +- 增加分集计划服务单元测试。 + +修改文件: + +- README.md +- CODEX_PROGRESS.md +- backend/src/app.module.ts +- backend/src/projects/project.types.ts + +新增文件: + +- backend/src/episodes/episode.dto.ts +- backend/src/episodes/episode.types.ts +- backend/src/episodes/episodes.service.ts +- backend/src/episodes/episodes.controller.ts +- backend/src/episodes/episodes.module.ts +- backend/src/episodes/episodes.service.spec.ts + +运行命令: + +- git status --short +- rg 分集计划 / episodes / episode_planning 相关文档和代码 +- npm run typecheck -w backend +- npm test -w backend +- npm run lint +- npm run typecheck +- npm test +- npm run build +- curl http://127.0.0.1:3000/api/health +- tail -n 180 /tmp/ai-manga-backend.log +- Node fetch 真实接口联调:注册、创建 ai_original 项目、验证缺少上下文时分集生成被拒绝、生成原创章节、生成并确认故事圣经、抽取并确认角色、验证缺少长篇记忆时分集生成被拒绝、生成长篇记忆、生成 3 集分集计划、查询分集、编辑第 1 集、确认分集、验证 confirmed 分集禁止编辑、查询项目状态 + +测试结果: + +- backend typecheck:通过 +- backend test:通过,12 个测试文件,55 个测试通过 +- npm run lint:通过 +- npm run typecheck:通过 +- npm test:通过,backend 55 个测试通过,workers 1 个测试通过,admin/user-app 暂无测试文件并以 passWithNoTests 通过 +- npm run build:通过 +- 后端健康检查返回 code=0,status=ok +- 后端日志确认分集计划路由全部映射成功 +- 缺少故事圣经/角色/记忆上下文时调用 POST /episodes/generate-plan 返回 400 +- 已确认故事圣经和角色但缺少 active 长篇记忆时调用 POST /episodes/generate-plan 返回 400 +- 真实生成 3 集分集计划成功,每集都有 opening_hook、middle_conflict 和 ending_hook +- GET /projects/:projectId/episodes 成功返回 3 集 +- PATCH /episodes/:episodeId 成功编辑第 1 集,状态变为 edited +- POST /projects/:projectId/episodes/confirm 成功,3 集全部变为 confirmed +- confirmed 分集继续 PATCH 返回 400 +- GET /projects/:id 确认项目状态为 episode_confirmed + +遗留问题: + +- 当前目录不是 Git 仓库,按用户要求暂不提交 Git。 +- npm audit 当前提示 8 个依赖风险,未执行强制修复,避免破坏阶段成果。 +- 本阶段分集计划为 deterministic mock / 规则生成,不调用真实 AI Provider。 +- 本阶段不生成单集脚本、不生成分镜、不进 BullMQ 队列,也不记录 Provider 成本。 +- 分集确认后暂不支持重排/拆分/合并/返工;后续通过修改申请或返工流程补充。 +- 真实联调产生了测试用户、项目、小说来源、章节、故事圣经、角色、长篇记忆、剧情线和分集数据,未清理。 + +下一步建议: + +- 人工审核阶段 11。 +- 审核通过后进入阶段 12:脚本和分镜。 + +### 阶段 12:脚本和分镜 + +完成时间:2026-05-31 19:07:37 CST + +完成内容: + +- 新增 ScriptsModule、ScriptsController、ScriptsService。 +- 支持 POST /api/episodes/:episodeId/script/generate 生成单集脚本。 +- 支持 GET /api/episodes/:episodeId/script 查询最新脚本和版本列表。 +- 支持 PATCH /api/episodes/:episodeId/script 编辑未确认脚本。 +- 支持 POST /api/episodes/:episodeId/script/confirm 确认单集脚本。 +- 支持 POST /api/episodes/:episodeId/storyboard/generate 生成分镜。 +- 支持 GET /api/episodes/:episodeId/storyboard 查询分镜镜头列表。 +- 支持 PATCH /api/storyboard-shots/:shotId 编辑未确认镜头。 +- 支持 DELETE /api/storyboard-shots/:shotId 删除未确认镜头。 +- 支持 POST /api/episodes/:episodeId/storyboard/confirm 确认分镜。 +- 支持 POST /api/storyboard-shots/:shotId/regenerate-prompt 重生未确认镜头 Prompt。 +- 脚本生成前要求分集已 confirmed,且项目已有 confirmed 故事圣经和 locked 角色库;缺失时返回 400。 +- 脚本写入 `episode_scripts`,包含 `script_text`、`narration_text`、`dialogue_json`、version 和 status。 +- 脚本生成时项目状态进入 `script_generating`,生成完成后进入 `waiting_script_confirm`。 +- 脚本确认后状态为 `confirmed`,项目状态变为 `script_confirmed`,旧 confirmed 版本会标记为 `superseded`。 +- 分镜生成前要求已有 confirmed 单集脚本;未确认脚本时返回 400。 +- 分镜写入 `storyboard_shots`,默认每集生成 10 个镜头。 +- 每个镜头包含场景名、地点、角色 JSON、画面描述、动作描述、台词/旁白、镜头运动、特效、2-5 秒时长、Prompt 和负面 Prompt。 +- Prompt 会带入角色固定年龄段、脸型、发型、服装范围,并加入防混脸、年龄/发色漂移、复杂多人镜头等负面约束。 +- 分镜生成时项目状态进入 `storyboard_generating`,生成完成后进入 `waiting_storyboard_confirm`。 +- 分镜确认前校验每个镜头必须有画面、动作、时长、Prompt 和负面 Prompt。 +- 分镜确认后镜头状态变为 `confirmed`,项目状态变为 `storyboard_confirmed`。 +- 已 confirmed 脚本和分镜不可继续编辑/删除/重生 Prompt。 +- README.md 补充脚本和分镜接口说明。 +- 增加脚本和分镜服务单元测试。 + +修改文件: + +- README.md +- CODEX_PROGRESS.md +- backend/src/app.module.ts +- backend/src/projects/project.types.ts + +新增文件: + +- backend/src/scripts/script.dto.ts +- backend/src/scripts/script.types.ts +- backend/src/scripts/scripts.service.ts +- backend/src/scripts/scripts.controller.ts +- backend/src/scripts/scripts.module.ts +- backend/src/scripts/scripts.service.spec.ts + +运行命令: + +- git status --short +- rg 脚本 / 分镜 / storyboard / episode_scripts / storyboard_shots / Prompt 相关文档和代码 +- npm run typecheck -w backend +- npm test -w backend +- npm run lint +- npm run typecheck +- npm test +- npm run build +- curl http://127.0.0.1:3000/api/health +- tail -n 200 /tmp/ai-manga-backend.log +- Node fetch 真实接口联调:注册、创建 ai_original 项目、生成原创章节、生成并确认故事圣经、抽取并确认角色、生成长篇记忆、生成并确认分集计划、验证未确认脚本时分镜生成被拒绝、生成脚本、查询脚本、编辑脚本、确认脚本、验证 confirmed 脚本禁止编辑、生成 10 个分镜镜头、查询分镜、编辑镜头、重生 Prompt、确认分镜、验证 confirmed 镜头禁止编辑、查询项目状态 + +测试结果: + +- backend typecheck:通过 +- backend test:通过,13 个测试文件,63 个测试通过 +- npm run lint:通过 +- npm run typecheck:通过 +- npm test:通过,backend 63 个测试通过,workers 1 个测试通过,admin/user-app 暂无测试文件并以 passWithNoTests 通过 +- npm run build:通过 +- 后端健康检查返回 code=0,status=ok +- 后端日志确认脚本和分镜路由全部映射成功 +- 未确认脚本时调用 POST /storyboard/generate 返回 400 +- 真实生成单集脚本成功,脚本文本包含结构化脚本段落 +- GET /episodes/:episodeId/script 成功返回最新脚本和版本列表 +- PATCH /episodes/:episodeId/script 成功编辑旁白,状态变为 edited +- POST /episodes/:episodeId/script/confirm 成功,脚本状态变为 confirmed +- confirmed 脚本继续 PATCH 返回 400 +- POST /episodes/:episodeId/storyboard/generate 成功生成 10 个镜头 +- 每个镜头都有 visual_desc、duration 和 prompt_text +- GET /episodes/:episodeId/storyboard 成功返回 10 个镜头 +- PATCH /storyboard-shots/:shotId 成功编辑镜头,状态变为 edited +- POST /storyboard-shots/:shotId/regenerate-prompt 成功,Prompt 包含“高质量韩漫风” +- POST /episodes/:episodeId/storyboard/confirm 成功,10 个镜头全部 confirmed +- confirmed 镜头继续 PATCH 返回 400 +- GET /projects/:id 确认项目状态为 storyboard_confirmed + +遗留问题: + +- 当前目录不是 Git 仓库,按用户要求暂不提交 Git。 +- npm audit 当前提示 8 个依赖风险,未执行强制修复,避免破坏阶段成果。 +- 本阶段脚本和分镜为 deterministic mock / 规则生成,不调用真实 AI Provider。 +- 本阶段不生成分镜图片、不接入 ImageProvider、不进 BullMQ 队列,也不记录 Provider 成本。 +- 分镜确认后暂不支持返工;后续通过修改申请或返工流程补充。 +- 真实联调产生了测试用户、项目、小说来源、章节、故事圣经、角色、长篇记忆、剧情线、分集、脚本和分镜数据,未清理。 + +下一步建议: + +- 人工审核阶段 12。 +- 审核通过后进入阶段 13:BullMQ 队列。 + +### 阶段 13:BullMQ 队列 + +完成时间:2026-05-31 19:39:54 CST + +完成内容: + +- 安装 backend / workers 的 `bullmq` 和 `ioredis` 依赖。 +- 新增 QueuesModule、QueuesController、QueuesService。 +- 新增任务类型、任务状态、队列名、任务类型到队列映射、默认重试次数配置。 +- 支持 POST /api/projects/:projectId/tasks 创建项目任务。 +- 支持 GET /api/projects/:projectId/tasks 查询项目任务。 +- 支持 GET /api/tasks/:taskId 查询单个任务及队列归属。 +- 支持 GET /api/admin/tasks 管理员查询任务。 +- 支持 POST /api/admin/tasks/:taskId/retry 管理员重试 failed / manual_required 任务。 +- 支持 POST /api/admin/tasks/:taskId/cancel 管理员取消任务,并尽量移除该任务所有 attempt job。 +- 支持 POST /api/admin/tasks/:taskId/manual-required 管理员标记任务进入人工介入状态。 +- 支持 POST /api/admin/tasks/recover-stale 恢复过久未完成的 running / retrying 任务。 +- 支持 GET /api/admin/queues 查询 BullMQ 各队列 waiting、active、delayed、failed、completed、paused 计数。 +- 创建任务先落 `render_tasks`,再入 BullMQ;入队失败时不丢 DB 任务,返回 `queue_backend=bullmq_unavailable`。 +- 默认幂等 key 使用 `project_id + episode_id + shot_id + task_type + input_hash`。 +- `input_hash` 基于稳定 JSON 序列化后 SHA-256 生成,字段顺序不同但内容相同会命中同一幂等任务。 +- 校验项目 owner/admin 权限,校验 episode_id / shot_id 必须属于项目。 +- 管理员接口要求 JWT 中 role 为 `admin`。 +- worker 入口状态输出已包含 BullMQ 后端、Redis URL 脱敏展示和完整队列清单。 +- 发现并修复 BullMQ v5 自定义 jobId 不允许冒号的问题,改为 `task--attempt-` 格式。 +- README.md 补充 BullMQ 队列接口说明。 +- 增加队列服务单元测试。 + +修改文件: + +- package-lock.json +- backend/package.json +- workers/package.json +- backend/src/app.module.ts +- workers/src/main.ts +- workers/src/main.spec.ts +- README.md +- CODEX_PROGRESS.md + +新增文件: + +- backend/src/queues/task.types.ts +- backend/src/queues/task.dto.ts +- backend/src/queues/queues.service.ts +- backend/src/queues/queues.controller.ts +- backend/src/queues/queues.module.ts +- backend/src/queues/queues.service.spec.ts + +运行命令: + +- redis-cli ping +- npm install -w backend bullmq ioredis +- npm install -w workers bullmq ioredis +- npm run typecheck -w backend +- npm test -w backend -- queues.service.spec.ts +- npm test -w workers +- npm run lint +- npm run typecheck +- npm test +- npm run build +- curl http://127.0.0.1:3000/api/health +- Node BullMQ debug 入队验证 +- Node fetch 真实接口联调:注册普通用户、创建项目、创建任务、重复幂等创建、查询任务、注册并提升 admin、管理员重试、取消、人工介入、管理员任务列表、队列统计 + +测试结果: + +- redis-cli ping:PONG +- backend typecheck:通过 +- backend 队列服务单测:通过,6 个测试通过 +- workers test:通过,1 个测试通过 +- npm run lint:通过 +- npm run typecheck:通过 +- npm test:通过,backend 14 个测试文件,69 个测试通过;workers 1 个测试通过;admin/user-app 暂无测试文件并以 passWithNoTests 通过 +- npm run build:通过 +- 后端健康检查返回 code=0,status=ok +- 后端日志确认队列路由全部映射成功 +- 真实联调创建任务成功写入 `render_tasks`,入队 `story_queue` 成功,job_id 为 `task-3-attempt-0` +- 相同 input_json 字段顺序不同的重复创建命中同一任务,返回 `idempotent=true` +- 管理员重试 failed 任务成功,状态变为 `retrying`,retry_count 变为 1,job_id 为 `task-3-attempt-1` +- 管理员取消任务成功,状态变为 `cancelled`,当前 job 移除成功 +- 管理员标记人工介入成功,状态变为 `manual_required`,error_code 为 `NEEDS_OPERATOR` +- GET /api/admin/queues 成功返回队列统计,novel_queue、parse_queue、story_queue 状态均为 ok + +遗留问题: + +- 当前目录不是 Git 仓库,按用户要求暂不提交 Git。 +- npm audit 当前提示 8 个依赖风险,未执行强制修复,避免破坏阶段成果。 +- worker 当前只声明队列清单和状态输出,还未启动真实消费者处理图片、音频、字幕、视频或 QC 任务。 +- 本阶段不接入真实 AI Provider,不记录真实 Provider 成本,不扣减额度。 +- 真实联调产生了测试用户、项目和 render_tasks 数据,未清理。 +- 修复 BullMQ jobId 格式前曾产生一次 `bullmq_unavailable` 测试任务记录,保留为联调痕迹。 + +下一步建议: + +- 跳过人工审核后进入阶段 14:AI Provider 抽象。 + +### 阶段 14:AI Provider 抽象 + +完成时间:2026-05-31 19:56:25 CST + +完成内容: + +- 新增 ProvidersModule、ProvidersController、ProvidersService。 +- 新增 Provider 类型、模式、日志状态、安全输出类型和默认 mock provider 配置。 +- 支持 TextProvider、NovelProvider、ImageProvider、VideoProvider、VoiceProvider、ModerationProvider、QualityCheckProvider、FileParseProvider、EmbeddingProvider。 +- 支持 GET /api/admin/providers 查询 Provider 配置。 +- 支持 POST /api/admin/providers/bootstrap-mocks 写入或更新 9 个默认 mock Provider。 +- 支持 POST /api/admin/providers/execute 执行指定类型 Provider。 +- 支持 PATCH /api/admin/providers/:providerId 更新 Provider 配置。 +- 支持 POST /api/admin/providers/:providerId/test 测试指定 Provider。 +- 支持 GET /api/admin/provider-logs 查询 Provider 请求/响应/失败日志。 +- 支持 GET /api/admin/costs 聚合 Provider 成本。 +- Provider 执行会从 `provider_configs` 选择启用配置,按 priority 排序,primary 失败后支持 fallback。 +- Provider 执行会写入 `provider_logs`,包含 provider、task、project、request、response、input_size、output_size、cost、status、错误信息和时间。 +- 传入 `task_id` 时会回写 `render_tasks`:执行前 running,成功后 success,并记录 provider_id、provider_request_id、cost_estimate、cost_actual。 +- 当前 real mode 不会调用外部模型,会返回 `REAL_PROVIDER_NOT_CONFIGURED` 并触发 fallback。 +- mock driver 支持文本、小说、图片占位、视频占位、TTS 占位、内容审核、质量检查、文件解析和 embedding 向量占位。 +- Provider 输入和配置更新会拒绝 `api_key`、`secret`、`token`、`password`、`credential` 等疑似密钥字段。 +- Provider 配置和日志输出会对疑似密钥字段脱敏。 +- `backend/prisma/seed.ts` 更新为写入 9 个默认 mock Provider。 +- README.md 补充 AI Provider 抽象接口说明。 +- 增加 Provider 服务单元测试。 + +修改文件: + +- backend/prisma/seed.ts +- backend/src/app.module.ts +- README.md +- CODEX_PROGRESS.md + +新增文件: + +- backend/src/providers/provider.types.ts +- backend/src/providers/provider.dto.ts +- backend/src/providers/providers.service.ts +- backend/src/providers/providers.controller.ts +- backend/src/providers/providers.module.ts +- backend/src/providers/providers.service.spec.ts + +运行命令: + +- rg Provider / provider / AI Provider 相关文档和代码 +- npm run typecheck -w backend +- npx tsc --noEmit --target ES2022 --module CommonJS --moduleResolution Node --esModuleInterop --skipLibCheck --strict backend/prisma/seed.ts +- npm test -w backend -- providers.service.spec.ts +- DATABASE_URL=... npm run db:seed +- npm run lint +- npm run typecheck +- npm test +- npm run build +- curl http://127.0.0.1:3000/api/health +- Node fetch 真实接口联调:注册并提升 admin、bootstrap mock providers、查询 providers、执行 TextProvider、创建 render_task 后执行 ImageProvider、指定 provider test、验证疑似密钥字段拒绝、查询 provider logs、查询 costs + +测试结果: + +- backend typecheck:通过 +- seed.ts TypeScript 编译检查:通过 +- backend Provider 服务单测:通过,6 个测试通过 +- db:seed:通过,已写入或更新默认 mock Provider 配置 +- npm run lint:通过 +- npm run typecheck:通过 +- npm test:通过,backend 15 个测试文件,75 个测试通过;workers 1 个测试通过;admin/user-app 暂无测试文件并以 passWithNoTests 通过 +- npm run build:通过 +- 后端健康检查返回 code=0,status=ok +- 后端日志确认 Provider 路由全部映射成功 +- 真实联调 POST /api/admin/providers/bootstrap-mocks 成功,返回 9 个 mock Provider +- GET /api/admin/providers 成功,确认 TextProvider 和 ImageProvider 均为 mock +- POST /api/admin/providers/execute 执行 TextProvider 成功,写入 success provider_log +- POST /api/admin/providers/execute 执行 ImageProvider 成功,返回 `mock://image/...png` +- ImageProvider 执行传入 task_id 后,对应 render_task 状态变为 success,并写入 provider_id 和 provider_request_id +- POST /api/admin/providers/:providerId/test 成功执行指定 Provider +- 传入 `input_json.api_key` 被拒绝,返回 400 +- GET /api/admin/provider-logs 成功返回日志 +- GET /api/admin/costs 成功返回 mock 成本聚合,总成本为 0 + +遗留问题: + +- 当前目录不是 Git 仓库,按用户要求暂不提交 Git。 +- npm audit 当前提示 8 个依赖风险,未执行强制修复,避免破坏阶段成果。 +- 当前 Provider 全部为 deterministic mock,不调用真实 AI Provider。 +- 真实 Provider、真实限流、真实计费、额度扣减和外部错误码映射留到后续阶段。 +- 前面已实现的原创小说、故事圣经、角色、记忆、分集、脚本和分镜仍是各自服务内的 deterministic mock,尚未逐步迁移为统一 Provider 调用。 +- 真实联调产生了测试 admin、项目、render_task 和 provider_logs 数据,未清理。 + +下一步建议: + +- 跳过人工审核后进入阶段 15:图片生成 mock。 + +### 阶段 15:图片生成 mock + +完成时间:2026-05-31 20:15:52 CST + +完成内容: + +- 新增 ImagesModule、ImagesController、ImagesService。 +- 新增图片 DTO、安全输出类型、角色图类型和分镜图类型。 +- 支持 POST /api/characters/:characterId/generate-images 生成角色候选图、锚点图和表情图。 +- 支持 GET /api/characters/:characterId/images 查询角色图片。 +- 支持 POST /api/characters/:characterId/set-anchor 设置角色锚点图。 +- 支持 POST /api/storyboard-shots/:shotId/images/generate 生成单个分镜 preview / final 图片。 +- 支持 GET /api/storyboard-shots/:shotId/images 查询单个分镜图片。 +- 支持 POST /api/episodes/:episodeId/shot-images/generate 批量生成某集 confirmed 分镜图片。 +- 角色图片生成要求角色状态为 locked。 +- 分镜图片生成要求 storyboard_shots.status=confirmed。 +- 角色图 Prompt 组合角色姓名、角色类型、性别、年龄、身份、外貌、脸型、发型、眼睛、体型、服装和道具规则。 +- 分镜图 Prompt 组合项目、镜头、场景、地点、画面、动作、台词、旁白、运镜、特效和锁定角色描述。 +- 分镜图 Prompt 会引用角色 `anchor_asset_id`,用于后续真实 ImageProvider 做角色一致性约束。 +- 每次图片生成都会创建 `render_tasks`,调用 `ImageProvider` mock,写入 `provider_logs`。 +- mock 图片会保存为本地私有 SVG 文件,写入 `assets`,asset_type=image,visibility=private。 +- 角色图片写入 `character_images`,分镜图片写入 `shot_images`。 +- 设置锚点图会更新 `character_images.is_anchor` 和 `characters.anchor_asset_id`。 +- 项目状态会随图片阶段更新为 `character_image_generated`、`preview_images_generated` 或 `final_images_generated`。 +- README.md 补充图片生成 mock 接口说明。 +- 增加图片服务单元测试。 + +修改文件: + +- backend/src/app.module.ts +- README.md +- CODEX_PROGRESS.md + +新增文件: + +- backend/src/images/image.dto.ts +- backend/src/images/image.types.ts +- backend/src/images/images.service.ts +- backend/src/images/images.controller.ts +- backend/src/images/images.module.ts +- backend/src/images/images.service.spec.ts + +运行命令: + +- rg 图片 / ImageProvider / 角色图 / 锚点 / shot_images / character_images 相关文档和代码 +- npm run typecheck -w backend +- npm test -w backend -- images.service.spec.ts +- npm run lint +- npm test +- npm run build +- Node fetch 真实接口联调:注册用户、创建项目、准备 locked 角色、准备 confirmed 分集和分镜、生成角色图、设置锚点图、查询角色图、生成分镜 preview 图、查询分镜图、批量生成 episode final 图、验证 assets / render_tasks / provider_logs + +测试结果: + +- backend typecheck:通过 +- backend 图片服务单测:通过,5 个测试通过 +- npm run lint:通过 +- npm test:通过,backend 16 个测试文件,80 个测试通过;workers 1 个测试通过;admin/user-app 暂无测试文件并以 passWithNoTests 通过 +- npm run build:通过 +- 真实联调生成角色图片 2 张,自动设置 anchor_asset_id +- GET /characters/:id/images 返回 2 张角色图,图片 asset_path 为 local private SVG +- POST /storyboard-shots/:id/images/generate 成功生成 preview 图 +- GET /storyboard-shots/:id/images 返回分镜图 +- POST /episodes/:id/shot-images/generate 成功批量生成 final 图 +- 真实联调项目写入 4 个 image assets、4 个 image render_tasks、4 条 ImageProvider provider_logs + +遗留问题: + +- 当前目录不是 Git 仓库,按用户要求暂不提交 Git。 +- npm audit 当前提示 8 个依赖风险,未执行强制修复,避免破坏阶段成果。 +- 当前图片是 SVG mock 占位图,不是真实模型生成图片。 +- 图片质检仅预留字段 `quality_score=92`,尚未接入 QualityCheckProvider。 +- 图片生成目前同步执行 Provider mock,尚未由 worker 消费 image_queue。 +- 真实联调产生了测试用户、项目、角色、分集、分镜、assets、render_tasks、provider_logs 数据,未清理。 + +下一步建议: + +- 跳过人工审核后进入阶段 16:TTS / 字幕 / FFmpeg。 + +### 阶段 16:TTS / 字幕 / FFmpeg + +完成时间:2026-05-31 20:30:10 CST + +完成内容: + +- 新增 MediaModule、MediaController、MediaService。 +- 新增音频生成、字幕生成和视频渲染 DTO 与安全输出类型。 +- 支持 POST /api/episodes/:episodeId/audio/generate 生成单集旁白音频。 +- 支持 POST /api/episodes/:episodeId/subtitle/generate 生成单集 SRT 字幕。 +- 支持 POST /api/episodes/:episodeId/video/render 渲染单集视频。 +- 支持 GET /api/episodes/:episodeId/media-assets 查询单集音频、字幕和视频资产。 +- 音频生成要求已有 confirmed 单集脚本,会组合脚本旁白、分镜旁白和台词作为 TTS 输入。 +- 音频生成通过 `VoiceProvider` mock 执行,写入 `render_tasks`、`provider_logs` 和本地私有 WAV 资产。 +- 字幕生成要求已有 confirmed 分镜,会按镜头时长生成 SRT cues,并写入本地私有 `.srt` 资产。 +- 视频渲染要求已有 confirmed 分镜和 generated 分镜图,缺少分镜图时返回 400。 +- 视频渲染默认复用最新音频和字幕;不存在时会自动生成。 +- 视频渲染通过 `VideoProvider` mock 记录执行日志,默认使用 FFmpeg 读取私有分镜图、音频和字幕并写入本地私有 MP4 资产。 +- `prefer_ffmpeg=false` 或本机缺少 FFmpeg 时保留 mock fallback。 +- 项目状态随媒体阶段更新为 `audio_generated`、`subtitle_generated`、`video_rendered`。 +- README.md 补充 TTS / 字幕 / FFmpeg 接口说明。 +- 增加媒体服务单元测试。 + +修改文件: + +- backend/src/app.module.ts +- backend/src/projects/project.types.ts +- README.md +- CODEX_PROGRESS.md + +新增文件: + +- backend/src/media/media.dto.ts +- backend/src/media/media.types.ts +- backend/src/media/media.service.ts +- backend/src/media/media.controller.ts +- backend/src/media/media.module.ts +- backend/src/media/media.service.spec.ts + +运行命令: + +- command -v ffmpeg +- npm run typecheck -w backend +- npm test -w backend -- media.service.spec.ts +- npm run lint +- npm run typecheck +- npm test +- npm run build +- curl http://127.0.0.1:3000/api/health +- Node fetch 真实接口联调:注册用户、创建项目、准备 confirmed 单集脚本、confirmed 分镜、generated 分镜图,生成 audio、subtitle、video,并验证 assets / render_tasks / provider_logs + +测试结果: + +- 阶段完成时本机 `ffmpeg` 未安装,因此阶段 16 当次视频联调使用 mock fallback。 +- backend typecheck:通过 +- backend 媒体服务单测:通过,5 个测试通过 +- npm run lint:通过 +- npm run typecheck:通过 +- npm test:通过,backend 17 个测试文件,85 个测试通过;workers 1 个测试通过;admin/user-app 暂无测试文件并以 passWithNoTests 通过 +- npm run build:通过 +- 后端健康检查返回 code=0,status=ok +- 真实联调 POST /episodes/:episodeId/audio/generate 成功生成 audio asset,mime_type 为 audio/wav,task_status 为 success +- 真实联调 POST /episodes/:episodeId/subtitle/generate 成功生成 subtitle asset,mime_type 为 application/x-subrip,生成 1 条 SRT cue +- 真实联调 POST /episodes/:episodeId/video/render 成功生成 video asset,mime_type 为 video/mp4,status 为 mock,`ffmpeg_used=false` +- 真实联调 GET /episodes/:episodeId/media-assets 返回 3 个媒体资产 +- 真实联调写入 1 个 audio asset、1 个 subtitle asset、1 个 video asset +- 真实联调写入 `audio_generate`、`subtitle_generate`、`video_render` 各 1 个 success render_task +- 真实联调写入 VoiceProvider 和 VideoProvider success provider_log 各 1 条 + +遗留问题: + +- 当前目录不是 Git 仓库,按用户要求暂不提交 Git。 +- npm audit 当前提示 8 个依赖风险,未执行强制修复,避免破坏阶段成果。 +- 当前 TTS 为静音 WAV mock,不调用真实 TTS Provider。 +- 当前 TTS 为静音 WAV mock,但视频已切换为真实 FFmpeg 合成。 +- 音频、字幕和视频目前同步执行 Provider mock,尚未由 worker 消费 audio/subtitle/video 队列。 +- 未接入 BGM、音效、字幕样式、封面图和真实视频编码参数。 +- 真实联调产生了测试用户、项目、分集、分镜、assets、render_tasks、provider_logs 数据,未清理。 + +下一步建议: + +- 跳过人工审核后进入阶段 17:后台管理。 + +补充记录:2026-05-31 20:40:04 CST + +- 已在 AlmaLinux 9.7 上安装 RPM Fusion free 仓库和 FFmpeg。 +- 已安装 `ffmpeg-5.1.9-2.el9.x86_64`、`ffmpeg-libs-5.1.9-2.el9.x86_64` 及相关音视频依赖。 +- 已验证 `ffmpeg -version` 可用,`libx264` 和 `aac` 编码器可用。 +- 已用 FFmpeg 生成 1 秒 smoke test MP4,并用 `ffprobe` 验证输出文件时长和大小。 +- 注意:此时阶段 16 业务代码仍生成 MP4 placeholder;后续如需真实合成,需要改造 `MediaService.createVideoBuffer` 使用 FFmpeg 拼接分镜图、音频和字幕。 + +补充记录:2026-05-31 20:46:04 CST + +- 已将 `MediaService.createVideoBuffer` 从 FFmpeg mock 改为真实 FFmpeg 合成。 +- 默认视频渲染会读取私有分镜图资产、WAV 音频资产和 SRT 字幕资产,写入临时目录后用 FFmpeg 生成 1080x1920 MP4。 +- 每个分镜图会按 `storyboard_shots.duration` 生成视频片段,再通过 concat demuxer 合并。 +- 字幕通过 FFmpeg `subtitles` filter 烧录进画面,音频转码为 AAC。 +- `prefer_ffmpeg=false` 仍保留 mock fallback;FFmpeg 不存在时仍返回 `mock_ffmpeg_unavailable`。 +- FFmpeg 合成失败或输入资产无法读取时,会把 `video_render` 任务标记为 `failed`,并写入 `VIDEO_RENDER_FAILED`。 +- 真实联调成功生成 active video asset,返回 `ffmpeg_used=true`、`render_backend=ffmpeg`。 +- `ffprobe` 验证输出包含 1080x1920 H.264 视频流和 AAC 音频流,时长 4 秒。 +- 真实联调写入 1 个 image asset、1 个 audio asset、1 个 subtitle asset、1 个 active video asset。 +- 真实联调写入 `shot_image_generate`、`audio_generate`、`subtitle_generate`、`video_render` 各 1 个 success render_task。 +- 真实联调写入 ImageProvider、VoiceProvider、VideoProvider success provider_log 各 1 条。 +- 补充验证:`npm run lint`、`npm run typecheck`、`npm test`、`npm run build` 均通过;backend 17 个测试文件,85 个测试通过。 + +### 阶段 17:后台管理 + +完成时间:2026-05-31 21:04:26 CST + +完成内容: + +- 新增 AdminModule、AdminController、AdminService。 +- 新增后台 DTO 和安全输出辅助。 +- 支持 GET /api/admin/dashboard 查询仪表盘指标。 +- 支持 GET /api/admin/projects 查询项目列表,包含 owner、episode/asset/task 计数和最近任务。 +- 支持 GET /api/admin/projects/:projectId 查询项目详情,包含小说源、章节、故事圣经摘要、角色、分集、素材、任务、Provider 日志、版权记录和成本。 +- 支持 PATCH /api/admin/projects/:projectId/status 调整项目状态,并写入 operation_logs。 +- 支持 GET /api/admin/users 查询用户列表和项目/素材计数。 +- 支持 GET /api/admin/assets 查询素材列表。 +- 支持 GET /api/admin/novel-sources 查询小说源列表,包含项目和章节数量。 +- 支持 GET /api/admin/novel-chapters 查询章节列表,支持项目、小说源和状态筛选。 +- 支持 GET /api/admin/characters 查询角色资源列表,包含项目、图片数量和长篇记忆数量。 +- 支持 GET /api/admin/storyboard-shots 查询分镜资源列表,包含项目、分集、图片数量和最新分镜图 asset。 +- 支持 GET /api/admin/works 查询成品漫剧列表,按 video asset 汇总项目、用户、分集和渲染任务。 +- 支持 GET /api/admin/copyright-records 查询版权确认记录。 +- 后台接口统一要求 admin 角色。 +- admin 前端从静态骨架升级为可登录、可请求真实 API 的 Vue/Vite 控制台。 +- 前端支持仪表盘、项目管理、小说管理、角色资源、分镜资源、成品漫剧、任务管理、AI Provider、成本日志、用户管理、素材管理和版权记录视图。 +- 前端接入已有任务接口,支持失败任务重试、取消、转人工。 +- 前端接入已有 Provider 接口,支持初始化 mock providers、查看 Provider 配置和 Provider 日志。 +- `backend/prisma/seed.ts` 改为生成真实 bcrypt 管理员密码,默认本地账号 `admin@example.com` / `Admin123!`,支持 `SEED_ADMIN_PASSWORD` 覆盖。 +- README.md 补充后台管理接口、资源管理入口、管理端地址和本地管理员账号说明。 +- 增加后台服务单元测试。 + +修改文件: + +- backend/prisma/seed.ts +- backend/src/app.module.ts +- backend/src/projects/project.types.ts +- admin/src/App.vue +- admin/src/styles.css +- .env.example +- README.md +- CODEX_PROGRESS.md + +新增文件: + +- backend/src/admin/admin.dto.ts +- backend/src/admin/admin.types.ts +- backend/src/admin/admin.service.ts +- backend/src/admin/admin.controller.ts +- backend/src/admin/admin.module.ts +- backend/src/admin/admin.service.spec.ts +- admin/src/api/client.ts + +运行命令: + +- rg / sed 阅读后台管理设计文档、验收文档、现有 admin/API 代码 +- npm run typecheck -w backend +- npm test -w backend -- admin.service.spec.ts +- npm run typecheck -w admin +- npm run build -w admin +- DATABASE_URL=... npm run db:seed +- curl http://127.0.0.1:3000/api/health +- Node fetch 真实接口联调:admin 登录、仪表盘、项目列表、项目详情、任务列表、队列统计、Provider 列表、成本、用户列表、素材列表、版权记录 +- Node fetch 真实资源联调:小说源列表、章节列表、角色资源、分镜资源、成品漫剧 +- Node fetch 真实任务操作联调:创建 failed render_task,调用 retry、manual-required、cancel +- npx tsc --noEmit ... backend/prisma/seed.ts +- npm run lint +- npm run typecheck +- npm test +- npm run build +- curl http://127.0.0.1:5175 + +测试结果: + +- backend typecheck:通过 +- backend Admin 服务单测:通过,4 个测试通过 +- admin typecheck:通过 +- admin build:通过 +- db:seed:通过,已更新本地 admin@example.com 为可登录 admin 用户 +- 后端健康检查返回 code=0,status=ok +- 后台真实联调 admin 登录成功,role=admin +- GET /api/admin/dashboard 成功返回 total_users、total_projects、failed_tasks、queue_backlog、ai_cost_actual 等指标 +- GET /api/admin/projects 成功返回项目列表 +- GET /api/admin/projects/:projectId 成功返回项目详情和关联计数 +- GET /api/admin/tasks、/admin/queues、/admin/providers、/admin/costs、/admin/users、/admin/assets、/admin/copyright-records 均通过真实联调 +- GET /api/admin/novel-sources、/admin/novel-chapters、/admin/characters、/admin/storyboard-shots、/admin/works 均通过真实联调 +- 成品漫剧联调成功返回阶段 16 生成的 video asset,并关联项目、分集和 render_task +- 任务操作真实联调成功:failed 任务 retry 后进入 retrying,随后可转 manual_required,再取消为 cancelled +- npm run lint:通过 +- npm run typecheck:通过 +- npm test:通过,backend 18 个测试文件,89 个测试通过;workers 1 个测试通过;admin/user-app 暂无测试文件并以 passWithNoTests 通过 +- npm run build:通过 +- admin dev server 已在 `http://127.0.0.1:5175` 返回 200,并热更新到新后台页面 + +遗留问题: + +- 当前目录不是 Git 仓库,按用户要求暂不提交 Git。 +- npm audit 当前提示 8 个依赖风险,未执行强制修复,避免破坏阶段成果。 +- 后台当前是轻量 Vue/Vite 控制台,还不是完整 Geeker-Admin 二开工程。 +- 内容审核、订单额度、模板管理仅保留入口方向,具体业务留到后续阶段。 +- 用户管理当前只读,未做禁用用户、改角色、重置密码等高危操作。 +- 项目状态调整已写 operation_logs,但完整操作日志查询页尚未实现。 +- 真实联调产生了一个 stage17 测试 render_task 及操作痕迹,未清理。 + +下一步建议: + +- 跳过人工审核后进入阶段 18:uni-app 用户端。 + +### 阶段 18:uni-app 用户端 + +完成时间:2026-05-31 21:36:00 CST + +完成内容: + +- 将 user-app 从静态 H5 骨架升级为可连接真实 API 的用户端制作台。 +- 用户端支持登录、注册、退出和本地 token 恢复。 +- 支持新建 AI 原创 / 上传小说改编项目,并可查看和切换我的项目。 +- 支持 AI 原创小说一键生成:idea、outline、chapters、self-check。 +- 支持上传小说入口:粘贴文本、H5 文件选择、版权确认和解析。 +- 支持故事圣经生成和确认。 +- 支持角色抽取、角色锚点图生成入口和角色库确认。 +- 支持长篇记忆生成,补齐分集计划前置依赖。 +- 支持分集计划生成、分集选择和分集确认。 +- 支持单集脚本生成/确认、分镜生成/确认、分镜图生成、音频字幕生成和 FFmpeg 视频合成。 +- 支持项目任务进度、失败任务数量和任务错误信息查看。 +- 支持成品视频列表、私有视频预览和私有 MP4 下载。 +- 用户端样式按 H5 优先设计,移动端为底部导航和单列流程,PC 宽屏为左侧导航和两栏制作台。 +- 保留 uni-app `pages.json`、`manifest.json` 和页面路由文件,后续微信小程序/App 可继续迁移。 +- 后端新增 `GET /api/assets/:assetId/download` 私有下载接口,校验 asset 归属后返回文件流。 +- 全局 API 响应拦截器支持跳过 `StreamableFile`,避免下载流被 JSON envelope 包裹。 +- README.md 补充用户端 H5、私有下载接口和阶段状态说明。 + +修改文件: + +- backend/src/assets/assets.controller.ts +- backend/src/assets/assets.service.ts +- backend/src/assets/assets.service.spec.ts +- backend/src/common/api-response.interceptor.ts +- user-app/manifest.json +- user-app/pages.json +- user-app/src/pages/index/index.vue +- user-app/src/styles.css +- README.md +- CODEX_PROGRESS.md + +新增文件: + +- user-app/src/api/client.ts +- user-app/src/workflow.ts +- user-app/src/pages/auth/login.vue +- user-app/src/pages/projects/create.vue +- user-app/src/pages/projects/source-select.vue +- user-app/src/pages/projects/original-setting.vue +- user-app/src/pages/projects/upload-novel.vue +- user-app/src/pages/projects/copyright.vue +- user-app/src/pages/projects/story-bible.vue +- user-app/src/pages/projects/characters.vue +- user-app/src/pages/projects/episodes.vue +- user-app/src/pages/projects/storyboard.vue +- user-app/src/pages/projects/progress.vue +- user-app/src/pages/projects/result.vue +- user-app/src/pages/user/projects.vue +- user-app/src/pages/user/profile.vue + +运行命令: + +- git status --short +- rg / sed 阅读用户端阶段文档、现有 user-app、后端 API controller / dto / service +- npm view @dcloudio/uni-app version +- npm run typecheck -w backend +- npm run typecheck -w user-app +- npm test -w backend -- assets.service.spec.ts +- npm run build -w user-app +- Node fetch 真实用户端流程联调:注册用户、创建原创项目、原创小说、故事圣经、角色、长篇记忆、分集、脚本、分镜、分镜图、音频、字幕、视频合成、私有下载 +- curl http://127.0.0.1:5174 +- npm run lint +- npm run typecheck +- npm test +- npm run build + +测试结果: + +- backend typecheck:通过 +- user-app typecheck:通过 +- backend Assets 服务单测:通过,4 个测试通过 +- user-app build:通过 +- 用户端 dev server 已在 `http://127.0.0.1:5174` 返回 200,并热更新到新页面 +- 真实用户端流程联调成功:临时用户创建 1 集原创项目,生成 video asset `29` +- 真实联调 FFmpeg 返回 `ffmpeg_used=true`、`render_backend=ffmpeg` +- 私有下载接口返回 `content-type=video/mp4`、`content-length=222085`,MP4 头部探测为 `ftypisom` +- npm run lint:通过 +- npm run typecheck:通过 +- npm test:通过,backend 18 个测试文件,90 个测试通过;workers 1 个测试通过;admin/user-app 暂无测试文件并以 passWithNoTests 通过 +- npm run build:通过 + +遗留问题: + +- 当前目录不是 Git 仓库,按用户要求暂不提交 Git。 +- npm audit 当前提示 8 个依赖风险,未执行强制修复,避免破坏阶段成果。 +- 当前用户端仍以 Vue/Vite H5 可运行版本为主,没有正式切换到 `@dcloudio/vite-plugin-uni` 构建链。 +- 微信小程序/App 的文件选择、下载保存、分享、支付、登录授权等平台能力尚未适配。 +- 用户端当前不提供深度编辑页;故事圣经、角色、分集、脚本和分镜的编辑能力仍主要在 API / 后台侧。 +- 订单额度、支付冻结和正式生成前扣费尚未接入。 +- 真实联调产生了一个 stage18 测试用户、项目、任务、素材和 MP4 资产,未清理。 + +补充记录:2026-05-31 21:46:00 CST + +- 用户反馈用户端访问异常。 +- 已确认用户端 dev server 正常监听 `0.0.0.0:5174`,后端正常监听 `0.0.0.0:3000`。 +- Vite 当前外网访问地址为 `http://152.53.37.118:5174/`。 +- 修复用户端默认 API 地址:外网 IP/域名访问时自动请求同主机 `:3000/api`,避免浏览器把 `127.0.0.1:3000` 当作用户本机。 +- README.md 已同步说明动态 API 默认行为。 + +补充记录:2026-05-31 21:52:00 CST + +- 用户再次反馈无法访问前端页面。 +- 已定位本机 firewalld 未放行 TCP `5174` 和 `3000`,公网访问会被防火墙挡住。 +- 已执行 `firewall-cmd --add-port=5174/tcp --add-port=3000/tcp` 和 permanent 持久化后 reload。 +- 已验证 firewalld 查询 `5174/tcp`、`3000/tcp` 均为 yes。 +- 已验证 `http://152.53.37.118:5174/` 返回 HTTP 200,`http://152.53.37.118:3000/api/health` 返回后端健康检查成功。 +- README.md 已补充公网调试访问需放行 TCP `5174` 和 `3000`。 + +下一步建议: + +- 跳过人工审核后进入阶段 19:订单额度。 + +### 阶段 19:订单额度 + +完成时间:2026-05-31 22:08:00 CST + +完成内容: + +- 新增 BillingModule、BillingController、BillingService。 +- 基于现有 `orders`、`quota_accounts`、`quota_logs` 表实现套餐、订单、额度账户和额度流水。 +- 支持 GET /api/billing/packages 公开查看 4 个套餐:试用版、标准短剧版、连载测试版、高端定制版。 +- 支持 GET /api/billing/quota 查询当前用户额度账户,不存在时自动创建 0 额度账户。 +- 支持 GET /api/billing/quota/logs 查询当前用户额度流水。 +- 支持 GET /api/billing/orders 查询当前用户订单。 +- 支持 POST /api/billing/orders 创建 pending 订单。 +- 支持 POST /api/billing/orders/:orderId/mock-pay 模拟支付,订单标记 paid,并写入 recharge 额度流水。 +- 支持 GET /api/projects/:projectId/quota/estimate 估算项目生成额度。 +- 支持 POST /api/projects/:projectId/quota/freeze 冻结项目额度,项目 `payment_status` 变为 `quota_frozen`。 +- 支持 POST /api/projects/:projectId/quota/release 释放项目冻结额度。 +- 视频合成前校验项目必须已冻结额度或已支付。 +- 视频合成成功后自动扣减冻结额度,写入 deduct 额度流水,项目 `payment_status` 变为 `paid`。 +- 支持 GET /api/admin/orders 管理员查看订单。 +- 支持 GET /api/admin/quota-accounts 管理员查看额度账户。 +- 支持 POST /api/admin/users/:userId/quota/grant 管理员手动赠送额度。 +- 用户端新增“额度”导航和额度中心,支持套餐、模拟支付、订单、额度账户和项目预估。 +- 用户端制作台新增支付/额度卡片,视频合成前会自动尝试冻结额度。 +- 管理端新增“订单额度”页面,展示订单和额度账户。 +- README.md 补充订单额度接口、视频合成额度约束和阶段状态。 + +修改文件: + +- backend/src/app.module.ts +- backend/src/media/media.module.ts +- backend/src/media/media.service.ts +- backend/src/media/media.service.spec.ts +- admin/src/App.vue +- user-app/src/api/client.ts +- user-app/src/pages/index/index.vue +- user-app/src/styles.css +- README.md +- CODEX_PROGRESS.md + +新增文件: + +- backend/src/billing/billing.dto.ts +- backend/src/billing/billing.types.ts +- backend/src/billing/billing.service.ts +- backend/src/billing/billing.controller.ts +- backend/src/billing/billing.module.ts +- backend/src/billing/billing.service.spec.ts + +运行命令: + +- git status --short +- rg / sed 阅读订单额度设计文档、现有 Prisma 表、媒体合成服务和用户端页面 +- npm run typecheck -w backend +- npm test -w backend -- billing.service.spec.ts media.service.spec.ts +- npm run typecheck -w user-app +- npm run build -w user-app +- npm run typecheck -w admin +- Node fetch 真实接口联调:套餐、注册用户、创建项目、创建订单、模拟支付、额度冻结、额度流水 +- Node fetch 真实生成联调:未冻结时视频合成拒绝,模拟支付和冻结后视频合成成功并扣减额度 +- Node fetch 管理员接口联调:admin/orders、admin/quota-accounts + +测试结果: + +- backend typecheck:通过 +- billing + media 单测:通过,2 个测试文件,11 个测试通过 +- user-app typecheck:通过 +- user-app build:通过 +- admin typecheck:通过 +- 真实订单额度联调成功:标准短剧版 mock 支付后可用额度 120,1 集项目预估 69,冻结后可用额度 51、项目状态 `quota_frozen` +- 真实生成联调成功:未冻结时 `/video/render` 返回 `Project quota must be frozen before formal video render` +- 冻结后视频合成成功生成 video asset `42`,项目 `payment_status=paid` +- 扣减后额度账户:available=51、frozen=0、used=69 +- 额度流水顺序包含 deduct、freeze、recharge +- 管理员订单和额度账户接口均通过真实联调 +- npm run lint:通过 +- npm run typecheck:通过 +- npm test:通过,backend 19 个测试文件,96 个测试通过;workers 1 个测试通过;admin/user-app 暂无测试文件并以 passWithNoTests 通过 +- npm run build:通过 +- 后端、用户端、管理端本地 HTTP 均返回 200 + +遗留问题: + +- 当前目录不是 Git 仓库,按用户要求暂不提交 Git。 +- npm audit 当前提示 8 个依赖风险,未执行强制修复,避免破坏阶段成果。 +- 当前支付为 mock,不接入微信支付、支付宝、Stripe 或真实回调验签。 +- 套餐暂为代码常量,未做后台可配置套餐表。 +- 额度预估按默认每集 6 个镜头估算,后续可结合实际分镜数量动态重算。 +- 系统失败后的冻结额度自动释放/重试占用策略尚未细化,目前成功扣减、手动 release 可释放。 +- 真实联调产生了 stage19 测试用户、订单、额度流水、项目、任务和素材资产,未清理。 + +下一步建议: + +- 跳过人工审核后进入阶段 20:内容审核。 + +### 阶段 20:内容审核 + +完成时间:2026-05-31 22:32:40 CST + +完成内容: + +- 新增 ReviewsModule、ReviewsController、ReviewsService。 +- 基于现有 `content_reviews` 表实现项目文本审核、素材审核、用户审核记录列表和管理员审核处理。 +- 基于现有 `case_showcases` 表实现用户公开案例授权、用户案例列表、管理员案例列表和发布/驳回处理。 +- 支持 POST /api/projects/:projectId/reviews/text,对项目文本或请求体 `content` 执行内容审核。 +- 支持 GET /api/projects/:projectId/reviews 查询当前项目审核记录。 +- 支持 POST /api/assets/:assetId/review,对 image/video/audio/subtitle/document 等素材执行审核。 +- 支持 POST /api/projects/:projectId/showcase/authorize 提交公开案例授权。 +- 支持 GET /api/projects/:projectId/showcase 查看项目公开案例授权记录。 +- 支持 GET /api/admin/content-reviews 管理员查询审核队列。 +- 支持 PATCH /api/admin/content-reviews/:reviewId 管理员通过、修改、驳回、屏蔽或转人工。 +- 支持 GET /api/admin/case-showcases 管理员查看公开案例授权。 +- 支持 PATCH /api/admin/case-showcases/:showcaseId 管理员授权、发布或驳回公开案例。 +- 内容审核复用阶段 14 的 `ModerationProvider` mock,命中敏感关键词时写入 `manual_required`,否则写入 `passed`。 +- 需要人工处理的审核会把项目状态标记为 `manual_required`。 +- 后台管理新增“内容审核”页,支持筛选审核状态、处理审核项、发布/驳回公开案例。 +- 用户端新增“审核”导航和制作台审核卡片,支持文本审核、成品视频审核、审核状态查看和公开案例授权。 +- README.md 补充内容审核接口、后台能力、用户端接入和当前阶段状态。 + +修改文件: + +- backend/src/app.module.ts +- backend/src/admin/admin.service.ts +- admin/src/App.vue +- user-app/src/api/client.ts +- user-app/src/pages/index/index.vue +- user-app/src/styles.css +- README.md +- CODEX_PROGRESS.md + +新增文件: + +- backend/src/reviews/review.dto.ts +- backend/src/reviews/review.types.ts +- backend/src/reviews/reviews.controller.ts +- backend/src/reviews/reviews.service.ts +- backend/src/reviews/reviews.module.ts +- backend/src/reviews/reviews.service.spec.ts + +运行命令: + +- git status --short +- rg / sed 阅读内容审核、后台管理、Codex 阶段文档、现有 Prisma schema、Provider mock、后台和用户端页面 +- npm run typecheck -w backend +- npm test -w backend -- reviews.service.spec.ts +- npm run typecheck -w admin +- npm run typecheck -w user-app +- npm run lint +- npm run typecheck +- npm test +- npm run build +- Node fetch 真实内容审核联调:注册用户、创建项目、文本审核、敏感文本触发人工、上传素材、素材审核、公开案例授权、管理员审核通过、管理员发布案例 +- curl http://127.0.0.1:3000/api/health +- curl -I http://127.0.0.1:5174 +- curl -I http://127.0.0.1:5175 + +测试结果: + +- backend typecheck:通过 +- reviews 单测:通过,1 个测试文件,8 个测试通过 +- admin typecheck:通过 +- user-app typecheck:通过 +- npm run lint:通过 +- npm run typecheck:通过 +- npm test:通过,backend 20 个测试文件,104 个测试通过;workers 1 个测试通过;admin/user-app 暂无测试文件并以 passWithNoTests 通过 +- npm run build:通过 +- 真实内容审核联调成功:clean 文本 `passed`,含“违规”的文本 `manual_required`,管理员更新后 `passed` +- 真实资产审核联调成功:上传 document asset `43` 后审核 `passed` +- 真实公开案例联调成功:用户授权后后台发布为 `published/public` +- 真实联调项目 `26` 共写入 4 条审核记录和 1 条公开案例记录 +- 后端健康检查返回 code=0,用户端 H5 和管理端 HTTP 均返回 200 + +遗留问题: + +- 当前目录不是 Git 仓库,按用户要求暂不提交 Git。 +- npm audit 当前提示 8 个依赖风险,未执行强制修复,避免破坏阶段成果。 +- 当前内容审核为 mock moderation,不接入真实内容安全平台、版权库、OCR/ASR/视频抽帧审核或人工工单系统。 +- 管理员通过审核不会自动恢复此前因风险被标记为 `manual_required` 的项目状态,后续可结合完整人工审核工作流细化状态回滚。 +- 公开案例授权和发布当前只记录授权状态与可见性,未做真实前台案例广场。 +- 真实联调产生了 stage20 测试用户、项目、素材、审核记录和公开案例记录,未清理。 + +下一步建议: + +- 跳过人工审核后进入阶段 21:真实 AI Provider 接入。 + +### 阶段 21:真实 AI Provider 接入 + +完成时间:2026-05-31 22:58:00 CST + +完成内容: + +- 使用 OpenAI 官方文档确认 Responses API、Image API、Moderation、Embeddings 和 Text to Speech 的当前接入形态。 +- 新增 `DEFAULT_OPENAI_PROVIDER_CONFIGS`,支持一键初始化 OpenAI real provider 配置。 +- 新增 `POST /api/admin/providers/bootstrap-openai`。 +- `ProvidersService` 支持 `real` 模式,按 `config_json.driver` 调用: + - `openai_responses` -> `/v1/responses` + - `openai_moderation` -> `/v1/moderations` + - `openai_embeddings` -> `/v1/embeddings` + - `openai_image_generation` -> `/v1/images/generations` + - `openai_audio_speech` -> `/v1/audio/speech` +- Provider 配置只保存 `api_key_env` 这类环境变量引用,不保存真实密钥;原始 `api_key`、`token`、`secret` 等字段仍会被拒绝或脱敏。 +- 真实图片和 TTS 调用日志只保存 URL/大小/hash 等元数据,不把 base64 图片或音频字节写入 `provider_logs`。 +- OpenAI 图片和 TTS real provider 默认优先级低于 mock,避免现有 mock 图片/本地音频生产链路在未接真实资产落库前误消耗真实模型。 +- 后台 AI Provider 页面支持初始化 OpenAI Provider、查看 driver、指定 provider 测试并展示测试结果。 +- README 补充真实 Provider 环境变量、接口和当前边界说明。 + +修改文件: + +- backend/src/providers/provider.types.ts +- backend/src/providers/providers.service.ts +- backend/src/providers/providers.controller.ts +- backend/src/providers/providers.service.spec.ts +- admin/src/App.vue +- admin/src/styles.css +- README.md +- CODEX_PROGRESS.md + +新增文件: + +- 无 + +运行命令: + +- npm run typecheck -w backend +- npm run typecheck -w admin +- npm test -w backend -- providers.service.spec.ts +- npm run lint +- npm run typecheck +- npm test +- npm run build +- curl http://127.0.0.1:3000/api/health +- Node fetch 真实接口冒烟:admin 登录、bootstrap OpenAI Provider、查询 Provider、指定 OpenAI TextProvider 执行并在无 `OPENAI_API_KEY` 时 fallback 到 mock + +测试结果: + +- backend typecheck:通过 +- admin typecheck:通过 +- backend Provider 单测:通过,10 个测试通过 +- npm run lint:通过 +- npm run typecheck:通过 +- npm test:通过,backend 20 个测试文件 108 个测试通过,workers 1 个测试通过,admin/user-app 暂无测试文件并以 passWithNoTests 通过 +- npm run build:通过 +- 后端健康检查:通过 +- 真实接口冒烟:`bootstrap-openai` 返回 6 个 OpenAI Provider;指定 `openai-responses-text` 时因未配置 `OPENAI_API_KEY` 记录 failed attempt,并成功 fallback 到 `mock-text` + +遗留问题: + +- 当前目录不是 Git 仓库,按用户要求暂不提交 Git。 +- 本机未配置真实 `OPENAI_API_KEY`,因此本阶段只做了 mock fetch 单测和配置/路由验证;未向 OpenAI 发起真实付费调用。 +- 原创小说、故事圣经、角色、记忆、分集、脚本和分镜仍是各自服务内 deterministic 生成逻辑,后续可逐步迁移到统一 Provider。 +- 图片/TTS real provider 已能测试调用,但现有图片、音频、视频生产链路仍默认 mock/本地合成,真实图片和音频资产落库需要后续阶段接入。 +- VideoProvider 暂无 OpenAI 视频生成真实驱动,仍保持 mock。 + +下一步建议: + +- 进入 MVP 验收,按原创小说 3 集 MP4 与上传小说 1 集 MP4 两条链路做端到端检查。 + +### 阶段 22:MVP 验收 + +完成时间:2026-06-01 00:15:00 CST + +完成内容: + +- 使用本机 API 跑通系统 A 两条 MVP 闭环。 +- AI 原创小说 3 集链路:注册验收用户、mock 支付充值、创建原创项目、生成原创构思/大纲/章节、自检、故事圣经、角色、角色锚点图、长篇记忆、3 集分集计划、3 集脚本、3 集分镜、30 张正式分镜图、3 集音频、3 集字幕、3 个 FFmpeg MP4、私有下载校验、成品视频审核和公开案例授权。 +- 上传小说 1 集链路:TXT 文件上传、版权确认、小说解析、故事圣经、角色、角色锚点图、长篇记忆、1 集分集计划、脚本、分镜、10 张正式分镜图、音频、字幕、FFmpeg MP4、私有下载校验、成品视频审核和公开案例授权。 +- 后台详情校验项目、素材和任务数量。 +- 验收发现并修复 mock 文本审核误伤安全规则提示的问题:`不得生成违法、低俗、仇恨、侵权...` 这类合规约束不再被 `违法` 关键词误判;真实风险词仍会进入 `manual_required`。 +- 更新 README 当前阶段、版权授权枚举、MVP 验收结果和 mock moderation 说明。 + +验收数据: + +- 验收用户:`mvp-1780243409631@example.com`,用户 ID `30` +- AI 原创项目:项目 ID `28`,3 集,状态 `video_rendered`,支付状态 `paid` +- 上传小说项目:项目 ID `29`,1 集,状态 `video_rendered`,支付状态 `paid` +- 原创 MP4 asset:`112`、`115`、`118`,私有下载均为 `video/mp4`,大小分别约 367 KB、386 KB、375 KB +- 上传 MP4 asset:`135`,私有下载为 `video/mp4`,大小约 399 KB +- 额度账户:`total_quota=1200`,`used_quota=196`,`available_quota=1004`,`frozen_quota=0` +- 原创文本复审:review `13`,`passed` +- 上传文本复审:review `14`,`passed` +- 视频审核:原创 3 条和上传 1 条均 `passed` + +修改文件: + +- backend/src/providers/providers.service.ts +- backend/src/providers/providers.service.spec.ts +- README.md +- CODEX_PROGRESS.md + +新增文件: + +- 无 + +运行命令: + +- curl http://127.0.0.1:3000/api/health +- Node fetch MVP 验收脚本:AI 原创 3 集、上传小说 1 集、私有 MP4 下载、审核、额度和后台详情校验 +- npm test -w backend -- providers.service.spec.ts +- npm run typecheck -w backend +- npm run lint +- npm run typecheck +- npm test +- npm run build + +测试结果: + +- AI 原创 3 集 MP4:通过 +- 上传小说 1 集 MP4:通过 +- 私有下载校验:4 个视频均返回 `video/mp4`,大小均大于 300 KB +- 视频审核:通过 +- 文本复审:通过 +- backend Provider 单测:通过,11 个测试通过 +- backend typecheck:通过 +- npm run lint:通过 +- npm run typecheck:通过 +- npm test:通过,backend 20 个测试文件 109 个测试通过,workers 1 个测试通过,admin/user-app 暂无测试文件并以 passWithNoTests 通过 +- npm run build:通过 + +遗留问题: + +- 当前目录不是 Git 仓库,按用户要求暂不提交 Git。 +- MVP 验收使用 mock 支付、mock 图片、mock TTS 和本地 FFmpeg 合成;真实图片/TTS 资产落库、真实支付、真实内容安全平台仍需生产化阶段接入。 +- 因本机未配置 `OPENAI_API_KEY`,文本/视频审核的 OpenAI moderation real provider 会先记录 `OPENAI_API_KEY_NOT_CONFIGURED` 失败 attempt,再 fallback 到 mock moderation。 +- 第一轮验收产生了项目 `27` 的半成品数据,第二轮完整验收项目为 `28` 和 `29`,未清理历史测试数据。 +- 公开案例授权当前为用户提交 `pending/authorized` 流程,未做真实前台案例广场。 + +下一步建议: + +- 规划生产化阶段:真实图片/TTS 资产落库、OpenAI key 配置策略、真实支付、worker 异步消费、审核工单和微信小程序/App 适配。 + +### 阶段 23:API 加密传输 + +完成时间:2026-06-01 00:32:22 CST + +完成内容: + +- 保留并完善 HTTPS 强制策略:生产默认要求 HTTPS,支持反向代理 `X-Forwarded-Proto=https`,生产 CORS 改为显式白名单。 +- 新增 `GET /api/crypto/handshake`,使用短期内存会话完成 ECDH P-256 握手。 +- 前后端使用 `ECDH P-256 + HKDF-SHA256` 派生 AES-256-GCM 会话密钥。 +- 后端新增加密请求中间件:识别加密信封,解密 JSON 请求体后再进入原有 Controller/Service。 +- 后端响应包装和异常过滤器支持加密返回:JSON 成功响应、业务异常响应都会在加密请求上下文中返回 AES-GCM 密文。 +- 后台管理和用户端 API Client 支持按配置启用加密信封:开启后请求前加密业务 payload,收到响应后解密再渲染。 +- 用户端小说文件上传改为先转 base64 文件 payload,再作为加密 JSON 请求发送。 +- 私有素材下载在加密请求下返回加密 JSON 文件 payload,前端解密后生成 Blob,避免成品 MP4 以明文业务响应返回。 +- 前端生产环境默认同源 `/api`,显式 `VITE_API_BASE_URL` 禁止使用 `http://`。 +- API 加密新增后台开关 `security.api_crypto_enabled`,测试默认关闭,上线后可在后台“配置管理”手动开启。 +- 新增 `GET /api/client-config`,前端启动请求前读取加密开关;`API_CRYPTO_ENABLED=true/false` 和 `VITE_API_CRYPTO_ENABLED=true/false` 可强制覆盖。 +- 补充 `.env.example`、README 和 Nginx HTTPS 部署示例。 + +修改文件: + +- .env.example +- README.md +- CODEX_PROGRESS.md +- backend/src/app.module.ts +- backend/src/main.ts +- backend/src/assets/assets.controller.ts +- backend/src/common/all-exceptions.filter.ts +- backend/src/common/api-response.interceptor.ts +- backend/src/common/api-crypto.controller.ts +- backend/src/common/api-crypto.service.ts +- backend/src/common/encrypted-request.middleware.ts +- backend/src/common/secure-transport.middleware.ts +- backend/src/admin/admin.controller.ts +- backend/src/admin/admin.dto.ts +- backend/src/admin/admin.service.ts +- backend/src/admin/admin.types.ts +- backend/prisma/seed.ts +- admin/src/App.vue +- admin/src/api/crypto.ts +- admin/src/api/client.ts +- user-app/src/api/crypto.ts +- user-app/src/api/client.ts +- deploy/README.md + +新增文件: + +- backend/src/common/api-crypto.controller.ts +- backend/src/common/api-crypto.service.ts +- backend/src/common/api-crypto.service.spec.ts +- backend/src/common/encrypted-request.middleware.ts +- backend/src/common/secure-transport.middleware.spec.ts +- admin/src/api/crypto.ts +- user-app/src/api/crypto.ts +- deploy/nginx.https.example.conf + +运行命令: + +- npm run typecheck -w backend +- npm run typecheck -w admin +- npm run typecheck -w user-app +- npm run lint +- npm run typecheck +- npm test +- npm run build +- PORT=3010 HTTPS_REQUIRED=false npm run start -w backend +- Node fetch 加密握手 + 加密 GET /api/health 冒烟 +- PORT=3011 API_CRYPTO_ENABLED=auto HTTPS_REQUIRED=false npm run start -w backend +- curl http://127.0.0.1:3011/api/client-config +- curl http://127.0.0.1:3011/api/health +- PORT=3012 API_CRYPTO_ENABLED=true HTTPS_REQUIRED=false npm run start -w backend +- curl http://127.0.0.1:3012/api/client-config +- curl -i http://127.0.0.1:3012/api/health +- Node fetch 强制开启下的加密 GET /api/health 冒烟 + +测试结果: + +- backend typecheck:通过 +- admin typecheck:通过 +- user-app typecheck:通过 +- npm run lint:通过 +- npm run typecheck:通过 +- npm test:通过,backend 22 个测试文件 113 个测试通过,workers 1 个测试通过,admin/user-app 暂无测试文件并以 passWithNoTests 通过 +- npm run build:通过 +- 加密 API 冒烟:`GET /api/crypto/handshake` 成功,带 `x-api-encrypted: v1` 的 `GET /api/health` 返回加密信封,Node 客户端解密后得到 `code=0`、`status=ok` +- 默认关闭冒烟:`API_CRYPTO_ENABLED=auto` 且数据库配置不可用/未开启时,`GET /api/client-config` 返回 `api_crypto_enabled=false`,普通 `GET /api/health` 明文 JSON 正常返回。 +- 强制开启冒烟:`API_CRYPTO_ENABLED=true` 时,普通 `GET /api/health` 返回 400;带加密 headers 的 `GET /api/health` 返回加密信封并可解密为 `status=ok`。 + +遗留问题: + +- 当前目录不是 Git 仓库,按用户要求暂不提交 Git。 +- 应用层加密保护请求体和响应体;HTTP 方法、路径、域名和 query string 仍属于传输元数据,生产必须继续使用 HTTPS,且不要把敏感内容放进 query。 +- 加密会话当前保存在单进程内存中;多实例部署需要粘性会话,或把 session 私钥/盐迁移到 Redis 等共享存储。 +- 当前前端加密实现面向 H5 浏览器 WebCrypto;微信小程序/App 需要后续补平台 crypto adapter。 +- 后台开关只对 `API_CRYPTO_ENABLED=auto` 生效;如果环境变量显式设置为 `true` 或 `false`,会覆盖数据库配置。 + +下一步建议: + +- 进入生产化安全补强:敏感 query 改 POST body、加密会话 Redis 化、CSP/XSS 防护、真实证书部署、微信小程序/App 加密适配。 + +### MVP 易用性修复:后台中文说明 / 额度页排版 / AI Provider 配置入口 + +完成时间:2026-06-01 12:34 CST + +完成内容: + +- 后台仪表盘、项目、小说、角色、分镜、成品、订单额度、审核、任务、Provider、成本、用户、素材、配置、版权等页面的 status/type/key 展示改为 `英文码(中文说明)` 或对应中文说明。 +- 后台 AI Provider 页面新增“AI 接入配置”说明区,明确真实 OpenAI/兼容 Provider 的密钥填写在后端环境变量,不在后台保存明文。 +- Provider 表格补充模型环境变量、密钥环境变量和接口地址列,方便运营和部署人员定位配置项。 +- 用户端额度/支付区域改为更稳定的自适应网格,套餐卡、额度数字、订单行在 H5/PC 窄宽度下不再互相挤压。 +- 用户端额度、订单、审核、素材等常见状态展示改为中文短标签,减少 raw code 撑破布局。 + +修改文件: + +- admin/src/App.vue +- admin/src/styles.css +- user-app/src/pages/index/index.vue +- user-app/src/styles.css +- CODEX_PROGRESS.md + +运行命令: + +- npm run typecheck -w admin +- npm run typecheck -w user-app +- npm run build -w admin +- npm run build -w user-app + +测试结果: + +- admin typecheck:通过 +- user-app typecheck:通过 +- admin build:通过 +- user-app build:通过 + +遗留问题: + +- 当前目录不是 Git 仓库,按用户要求暂不提交 Git。 +- 后台 Provider 页现在显示应填的环境变量名;真实密钥仍需要在服务器后端运行环境或 backend/.env 中填写,并重启后端。 + +下一步建议: + +- 填入 OPENAI_API_KEY 后,在后台 AI Provider 页面点击“初始化 OpenAI Provider”和“测试”,验证真实模型链路。 + +### 后台运营体验优化:资源预览 / 中文展示 / AI 接入后台配置 + +完成时间:2026-06-01 13:52 CST + +完成内容: + +- 后台状态、类型、风险、授权、任务等字段对运营显示中文,不再默认展示英文枚举码。 +- 小说源、章节、角色、分镜、素材、成品漫剧增加预览入口;图片、视频、音频、文本类资源可在后台抽屉内预览或下载。 +- 后端 admin 资源接口补充小说文本预览、章节正文预览、角色设定详情、分镜提示词/动作/旁白等预览字段。 +- AI Provider 增加后台运行配置接口 `PATCH /api/admin/providers/:providerId/runtime-config`,支持后台配置 API Key、Base URL、模型、超时、启停和优先级。 +- API Key 不再要求运营修改服务器环境变量;后台输入后,后端用 AES-256-GCM 加密保存到 Provider 配置中,列表只显示已配置/未配置,不回显明文。 +- README 和 `.env.example` 补充 `PROVIDER_SECRET_KEY` 说明。 + +修改文件: + +- backend/src/admin/admin.service.ts +- backend/src/admin/admin.types.ts +- backend/src/providers/provider.dto.ts +- backend/src/providers/providers.controller.ts +- backend/src/providers/providers.service.ts +- backend/src/providers/providers.service.spec.ts +- admin/src/api/client.ts +- admin/src/api/crypto.ts +- admin/src/App.vue +- admin/src/styles.css +- README.md +- .env.example +- CODEX_PROGRESS.md + +运行命令: + +- npm run typecheck -w backend +- npm run typecheck -w admin +- npm test -w backend -- providers.service.spec.ts +- npm run build -w backend +- npm run build -w admin +- npm test -w backend +- 重启 3000 后端 dist 进程 +- curl http://127.0.0.1:3000/api/health +- 登录 admin 后 GET /api/admin/providers + +测试结果: + +- backend typecheck:通过 +- admin typecheck:通过 +- providers.service.spec.ts:通过,12 个测试通过 +- backend build:通过 +- admin build:通过 +- backend 全量测试:通过,22 个测试文件 114 个测试通过 +- 后端 3000 已重启到新构建,健康检查返回 `status=ok` +- 后台页面 5175 返回 200,`GET /api/admin/providers` 返回 15 条 Provider 配置 + +遗留问题: + +- 当前目录不是 Git 仓库,按用户要求暂不提交 Git。 +- 生产环境建议显式设置 `PROVIDER_SECRET_KEY`;否则 Provider 密钥加密会回退使用 `JWT_SECRET`。 +- 后台预览依赖已有私有素材下载接口;如果素材实体缺失或文件在本地/MinIO 不存在,预览会提示下载失败。 + +下一步建议: + +- 在后台 AI 接入页初始化 OpenAI 接入,选择目标 Provider 点“配置”,填入 API Key 后测试真实模型链路。 + +### 后台用户管理:人工加余额 + +完成时间:2026-06-01 14:10 CST + +完成内容: + +- 用户管理页新增“人工加余额”操作区,运营可选择用户、填写增加额度和备注后提交。 +- 用户列表新增总额度、可用额度和快捷“加余额”操作,直接复用当前表单额度与备注。 +- 后台页面调用现有 `POST /api/admin/users/:userId/quota/grant` 接口,额度变更会进入后端额度账户和额度流水。 +- 切换到用户管理页时同步刷新用户列表与额度账户,避免运营看到旧余额。 +- README 补充后台用户人工加余额入口说明。 + +修改文件: + +- admin/src/App.vue +- admin/src/styles.css +- README.md +- CODEX_PROGRESS.md + +运行命令: + +- npm run typecheck -w admin +- npm run build -w admin + +测试结果: + +- admin typecheck:通过 +- admin build:通过 + +遗留问题: + +- 当前只实现“增加额度/余额”,未做扣减、冻结调整、禁用额度账户等高风险操作。 +- 当前目录不是 Git 仓库,按用户要求暂不提交 Git。 + +下一步建议: + +- 后台用户管理可继续补“额度流水明细/最近订单/最近项目”抽屉,方便运营核对加余额原因。 + +### 后台运营闭环:用户详情抽屉 / 内部额度模式 / 上线验收 + +完成时间:2026-06-01 22:36 CST + +完成内容: + +- 修复后台用户管理页“人工加余额”区域在中等宽度下重叠的问题,改为稳定的多列栅格和移动端单列布局。 +- 新增 `GET /api/admin/users/:userId/detail` 后台接口,返回用户基础信息、额度账户、额度流水、订单记录、最近项目、最近素材和最近操作。 +- 后台用户管理列表新增“详情”按钮,打开用户详情抽屉;抽屉内可核对额度流水、项目、订单、素材和操作记录,素材可继续走预览入口。 +- 用户端 H5 隐藏套餐、模拟支付和订单展示;当前内部测试模式只展示额度账户、项目预估和冻结额度,余额由后台人工增加。 +- README 更新为“内部测试额度模式”,补充用户详情接口和后台运营说明。 +- 后端 dist 服务已重启到新构建,当前监听 `0.0.0.0:3000`。 + +修改文件: + +- backend/src/admin/admin.controller.ts +- backend/src/admin/admin.service.ts +- backend/src/admin/admin.service.spec.ts +- admin/src/App.vue +- admin/src/styles.css +- user-app/src/pages/index/index.vue +- README.md +- CODEX_PROGRESS.md + +运行命令: + +- npm run typecheck -w backend +- npm run typecheck -w admin +- npm run typecheck -w user-app +- npm test -w backend -- admin.service.spec.ts +- npm run typecheck +- npm test +- npm run lint +- DATABASE_URL=mysql://ai_manga:ai_manga_password@127.0.0.1:3306/ai_manga npm run prisma:validate -w backend +- npm run build +- curl http://127.0.0.1:3000/api/health +- curl -I http://127.0.0.1:5175 +- curl -I http://127.0.0.1:5174 +- Node fetch 验收:管理员登录、用户列表、用户详情、注册测试用户、创建项目、额度预估、后台人工加额度、用户侧查余额 + +测试结果: + +- backend/admin/user-app/workers 全量 typecheck:通过 +- backend/admin/user-app/workers 全量 lint:通过 +- backend 全量测试:通过,22 个测试文件 115 个测试通过 +- admin 测试:无测试文件,按 `--passWithNoTests` 通过 +- user-app 测试:无测试文件,按 `--passWithNoTests` 通过 +- workers 测试:通过,1 个测试通过 +- Prisma schema validate:通过(需要带 `DATABASE_URL`) +- 全 workspace build:通过 +- 后端健康检查:通过,`status=ok` +- 后台 5175:返回 200 +- 用户端 5174:返回 200 +- 后台用户详情真实接口:通过,测试用户详情返回 `project_count=2`、`asset_count=59`、`quota_log_count=5` +- 内部额度真实接口:通过,新注册测试用户 `launch-check-1780324339354@example.com`,项目 `31`,后台加 10 额度后用户侧可用额度为 `10` + +上线验收结论: + +- 内部测试 / 自己人试用:可以继续使用。当前链路支持后台加额度、用户端按额度生成、后台查看用户详情和资源预览。 +- 正式公网商业上线:暂不能宣布已达标。真实图片/TTS/视频 Provider 的生产调用、资产落库、失败重试、成本控制、内容安全平台、支付/开票或彻底移除支付域模型、用户禁用/改角色/重置密码/额度冲正等后台高风险操作还需要按生产标准补齐。 + +遗留问题: + +- 当前用户端不展示支付入口;历史 mock 支付接口保留用于回归测试,后续如果对外收费,需要重新按真实支付网关设计。 +- 图片/TTS/视频链路已有抽象和本地合成,但生产环境仍需切换真实 Provider 调用、成本记录、失败重试和资产一致性验收。 +- 后台用户管理已具备详情和加余额,但扣减/冲正、禁用用户、改角色、重置密码、操作二次确认与审计策略尚未实现。 +- API 加密开关仍按测试默认关闭;正式环境需要 HTTPS、`PROVIDER_SECRET_KEY`、`JWT_SECRET`、`API_CRYPTO_ENABLED` 和后台配置同步完成。 +- 当前目录不是 Git 仓库,按用户要求暂不提交 Git。 + +下一步建议: + +- 进入正式上线补齐阶段:先做真实图片/TTS/视频 Provider 生产链路和资产落库验收,再补后台用户高危操作与额度冲正审计。 + +### 生产化补齐:真实图片 / TTS / 视频二进制资产落库 + +完成时间:2026-06-02 00:26 CST + +完成内容: + +- Provider 执行结果新增内部 `return_binary` 开关:业务生成链路可拿真实二进制,后台 Provider 测试默认不返回大体积 base64。 +- OpenAI 图片 Provider 返回的 `b64_json` 会作为短暂 `content_base64` 交给图片生成服务;`provider_logs` 只记录 URL、字节数、hash、prompt 等摘要,不写入 base64。 +- OpenAI TTS Provider 返回的音频 buffer 会作为短暂 `content_base64` 交给音频生成服务;`provider_logs` 只记录音频字节数、hash、mime、voice 等摘要。 +- 图片生成链路改为优先保存 Provider 返回的真实图片字节,或下载 HTTP(S) `asset_url`;拿不到真实素材时才回退 SVG mock 占位图。 +- 图片生成成功后回填 `render_tasks.output_asset_id`,后台可从任务追到真实图片资产。 +- TTS 链路改为优先保存真实音频字节或下载音频 URL;拿不到真实素材时才回退静音 WAV。 +- 视频渲染链路支持 Provider 返回 `content_base64` 时直接保存 Provider MP4;否则继续用 FFmpeg 读取分镜图、音频和字幕合成本地 MP4。 +- README 更新图片、TTS、视频生产链路说明。 + +修改文件: + +- backend/src/providers/provider.dto.ts +- backend/src/providers/providers.service.ts +- backend/src/providers/providers.service.spec.ts +- backend/src/images/images.service.ts +- backend/src/images/images.service.spec.ts +- backend/src/media/media.service.ts +- backend/src/media/media.service.spec.ts +- README.md +- CODEX_PROGRESS.md + +运行命令: + +- npm run typecheck -w backend +- npm test -w backend -- images.service.spec.ts media.service.spec.ts providers.service.spec.ts +- npm test -w backend +- npm run build -w backend +- 重启 3000 后端 dist 进程 +- curl http://127.0.0.1:3000/api/health + +测试结果: + +- backend typecheck:通过 +- 定向测试:通过,3 个测试文件 27 个测试通过 +- backend 全量测试:通过,22 个测试文件 120 个测试通过 +- backend build:通过 +- 后端 3000 已重启到新构建,健康检查返回 `status=ok` + +上线验收结论: + +- 图片/TTS 资产落库链路已具备真实 Provider 生产能力:后台配置真实 Provider 并调高优先级后,业务生成会保存真实图片/音频私有资产。 +- 视频链路已支持 Provider 二进制 MP4 落库;当前默认仍可使用 FFmpeg 本地合成,VideoProvider 真实驱动仍需按所选视频模型另接。 + +遗留问题: + +- 本机未配置真实 OpenAI Key,本轮未发起真实付费调用;已通过 mock fetch 单测验证 OpenAI 图片/TTS 二进制进入业务链路。 +- 图片质量检查、失败自动重试、队列 worker 异步消费和真实视频 Provider 驱动仍需继续生产化。 +- 当前目录不是 Git 仓库,按用户要求暂不提交 Git。 + +下一步建议: + +- 继续补后台用户高危操作与额度冲正审计,或先接入指定真实视频 Provider 驱动并做一次真实付费 E2E 验收。 + +### 生产化补齐:后台用户高危操作与额度冲正审计 + +完成时间:2026-06-02 00:45 CST + +完成内容: + +- 新增后台用户状态管理接口:`PATCH /api/admin/users/:userId/status`,支持启用/停用用户,禁止管理员停用自己。 +- 新增后台用户角色管理接口:`PATCH /api/admin/users/:userId/role`,支持普通用户 / 管理员角色切换,禁止管理员移除自己的 admin 角色。 +- 新增后台重置密码接口:`POST /api/admin/users/:userId/reset-password`,可输入新密码或自动生成临时密码;操作日志不保存明文密码。 +- 新增后台额度冲正接口:`POST /api/admin/users/:userId/quota/adjust`,支持正向补额度和反向扣减可用额度,扣减时校验可用余额。 +- 后台人工加余额和额度冲正都会写入额度流水,并额外写入 `operation_logs` 审计记录。 +- 用户详情抽屉新增“运营操作”区,运营可在同一处执行状态、角色、密码、额度冲正操作并查看最新流水和最近操作。 +- 修复后台用户管理“人工加余额”区域在中等宽度下的重叠风险,改成 `auto-fit` 自适应栅格。 +- README 更新后台用户运营能力和内部额度模式说明。 + +修改文件: + +- backend/src/admin/admin.controller.ts +- backend/src/admin/admin.dto.ts +- backend/src/admin/admin.service.ts +- backend/src/admin/admin.service.spec.ts +- backend/src/billing/billing.controller.ts +- backend/src/billing/billing.dto.ts +- backend/src/billing/billing.service.ts +- backend/src/billing/billing.service.spec.ts +- admin/src/App.vue +- admin/src/styles.css +- README.md +- CODEX_PROGRESS.md + +运行命令: + +- npm run typecheck -w backend +- npm run build -w admin +- npm test -w backend -- admin.service.spec.ts billing.service.spec.ts +- npm run lint +- npm test +- DATABASE_URL=mysql://ai_manga:ai_manga_password@127.0.0.1:3306/ai_manga npm run prisma:validate -w backend +- npm run build +- 重启 3000 后端 dist 进程 +- curl http://127.0.0.1:3000/api/health +- curl -I http://127.0.0.1:5175 +- curl -I http://127.0.0.1:5174 +- Node fetch 烟测:管理员登录、新注册测试用户、人工加额度、额度冲正、用户禁用/恢复、角色变更/恢复、重置密码后登录、用户详情审计查询 + +测试结果: + +- backend typecheck:通过 +- admin build:通过 +- 定向测试:通过,2 个测试文件 19 个测试通过 +- 全 workspace lint/typecheck:通过 +- backend 全量测试:通过,22 个测试文件 128 个测试通过 +- admin 测试:无测试文件,按 `--passWithNoTests` 通过 +- user-app 测试:无测试文件,按 `--passWithNoTests` 通过 +- workers 测试:通过,1 个测试通过 +- Prisma schema validate:通过 +- 全 workspace build:通过 +- 后端 3000 已重启到 PID `3698670`,健康检查返回 `status=ok` +- 后台 5175:返回 200 +- 用户端 5174:返回 200 +- 真实接口烟测通过:测试用户 `ops-smoke-1780331993114@example.com`,最终 `quota_available=15`、`status=active`、`role=user`,重置密码后登录成功;用户详情返回 `admin_correction_deduct` / `admin_grant` 额度流水和状态、角色、密码、冲正操作日志。 + +遗留问题: + +- 高危操作暂未加二次确认弹窗和细粒度 RBAC;目前统一由 admin 角色执行并记录审计日志。 +- 当前目录不是 Git 仓库,按用户要求暂不提交 Git。 + +下一步建议: + +- 跑全量验收并重启服务;随后继续补生产级失败重试、成本阈值、队列 worker 消费和真实视频 Provider 驱动。 + +### 生产化补齐:失败重试 / 成本阈值 / 队列 worker 消费 + +完成时间:2026-06-02 01:00 CST + +完成内容: + +- Provider 运行配置新增成本保护字段:`max_cost_per_call` 和 `daily_cost_limit`,后台 AI 接入页可直接填写单次成本上限和当日成本上限。 +- Provider 执行前会按 `cost_rule_json` 和环境变量 `PROVIDER_MAX_COST_PER_CALL` / `PROVIDER_DAILY_COST_LIMIT` 做成本预检,超过阈值会拦截调用并写入 failed provider log。 +- Provider 执行后会再次检查实际估算成本和输出大小,防止输出超出 Provider 成本规则。 +- 新增 worker 内部接口 `POST /api/internal/worker/tasks/:taskId/execute`,使用 `WORKER_SECRET` 鉴权。 +- QueuesService 新增 `executeQueuedTask`,按任务类型映射到 Text/Novel/Image/Voice/Video/Moderation/QC/FileParse Provider。 +- worker 包从占位状态升级为 BullMQ 消费器:订阅全部队列,收到 job 后调用后端内部执行接口。 +- worker 执行失败时,后端会按任务 `max_retry` 自动重入队;达到上限后转为 `manual_required`,后台可继续人工介入。 +- 后台用户详情中的状态、角色、重置密码、额度冲正操作增加二次确认弹窗。 +- README 更新 worker、成本阈值和后台能力说明。 + +修改文件: + +- backend/src/providers/provider.dto.ts +- backend/src/providers/providers.service.ts +- backend/src/providers/providers.service.spec.ts +- backend/src/queues/queues.module.ts +- backend/src/queues/queues.service.ts +- backend/src/queues/queues.service.spec.ts +- backend/src/queues/worker-tasks.controller.ts +- workers/src/main.ts +- workers/src/main.spec.ts +- admin/src/App.vue +- README.md +- CODEX_PROGRESS.md + +运行命令: + +- npm run typecheck -w backend +- npm run typecheck -w workers +- npm test -w backend -- providers.service.spec.ts queues.service.spec.ts +- npm test -w workers +- npm run build -w admin +- npm run lint +- npm test +- DATABASE_URL=mysql://ai_manga:ai_manga_password@127.0.0.1:3306/ai_manga npm run prisma:validate -w backend +- npm run build +- 重启 3000 后端 dist 进程 +- curl http://127.0.0.1:3000/api/health +- curl -I http://127.0.0.1:5175 +- curl -I http://127.0.0.1:5174 +- Node fetch 烟测:新建项目任务、调用内部 worker 执行接口、Provider 成本阈值拦截与恢复 + +测试结果: + +- backend typecheck:通过 +- workers typecheck:通过 +- Provider / Queue 定向测试:通过,2 个测试文件 25 个测试通过 +- workers 测试:通过,1 个测试通过 +- admin build:通过 +- 全 workspace lint/typecheck:通过 +- backend 全量测试:通过,22 个测试文件 133 个测试通过 +- admin 测试:无测试文件,按 `--passWithNoTests` 通过 +- user-app 测试:无测试文件,按 `--passWithNoTests` 通过 +- workers 测试:通过,1 个测试通过 +- Prisma schema validate:通过 +- 全 workspace build:通过 +- 后端 3000 已重启到 PID `3741838`,健康检查返回 `status=ok` +- 后台 5175:返回 200 +- 用户端 5174:返回 200 +- 真实内部 worker 烟测通过:测试用户 `worker-smoke-1780332971727@example.com`,项目 `32`,任务 `135` 通过 `/api/internal/worker/tasks/135/execute` 执行后状态为 `success`,provider_log 为 `success`。 +- 真实成本阈值烟测通过:临时把 `mock-text` 设置为 `flat_cost=2`、`max_cost_per_call=1`,执行被 503 拦截,错误为 `PROVIDER_COST_LIMIT_EXCEEDED`;随后已恢复 `mock-text` 成本规则为 `{ flat_cost: 0, unit: 'mock' }`。 + +遗留问题: + +- worker 当前是通用 Provider 任务消费,图片/音频/视频资产生成业务接口仍保留同步链路;后续可把具体业务生成步骤逐步改成完全异步编排。 +- 真实视频 Provider 驱动仍需按选定视频模型单独接入。 +- 细粒度 RBAC 仍未做权限表和角色矩阵,目前高危后台接口仍统一要求 admin。 + +下一步建议: + +- 跑全量验收并重启服务;随后接真实视频 Provider 驱动或补 RBAC/审计导出。 + +### 生产化补齐:真实视频 Provider 驱动 / 细粒度 RBAC / 审计导出 + +完成时间:2026-06-02 01:18 CST + +完成内容: + +- 新增 OpenAI Sora 视频 Provider 默认配置 `openai-video`,`/api/admin/providers/bootstrap-openai` 会写入 `VideoProvider` real provider。 +- 重复初始化 OpenAI Provider 时会保留已加密保存的 API Key、Base URL、超时和成本阈值,避免误清空线上配置。 +- Provider 执行层新增 `openai_video_generation` 驱动:按 OpenAI Videos API 异步流程创建视频任务、轮询状态,业务链路需要二进制时下载 MP4。 +- `openai_video_generation` 的 `provider_logs` 只记录视频 ID、状态、字节数、hash、mime 等摘要,不把 `content_base64` 写入日志。 +- 视频生产链路继续复用阶段 16 能力:Provider 返回 MP4 二进制时直接落私有视频资产,否则回退 FFmpeg 本地合成。 +- 新增 RBAC helper,后台按 `admin/operator/finance/auditor` 和 `admin:read`、`users:write`、`billing:write`、`providers:write`、`audit:export` 等权限做后端强校验。 +- 后台新增 `/api/admin/rbac/me`,前端按权限显示菜单和高危按钮;非 admin 角色不再只能靠页面隐藏。 +- 新增审计日志列表和导出接口 `/api/admin/operation-logs`、`/api/admin/operation-logs/export`,导出操作本身也写入 `operation_logs`。 +- 后台新增“审计日志”页面,支持按动作、对象类型、操作角色和时间筛选,并可导出 CSV。 +- 后台用户角色可调整为 `user/admin/operator/finance/auditor`。 +- README 和 `.env.example` 补充 `OPENAI_VIDEO_MODEL`、RBAC、Sora Video Provider 和审计导出说明。 + +修改文件: + +- .env.example +- README.md +- CODEX_PROGRESS.md +- backend/src/auth/rbac.ts +- backend/src/admin/admin.controller.ts +- backend/src/admin/admin.dto.ts +- backend/src/admin/admin.service.ts +- backend/src/admin/admin.service.spec.ts +- backend/src/billing/billing.service.ts +- backend/src/providers/provider.types.ts +- backend/src/providers/providers.service.ts +- backend/src/providers/providers.service.spec.ts +- backend/src/queues/queues.service.ts +- backend/src/reviews/reviews.service.ts +- admin/src/App.vue +- admin/src/styles.css + +运行命令: + +- npm run typecheck -w backend +- npm run typecheck -w admin +- npm run typecheck -w workers +- npm test -w backend +- npm run lint +- npm test +- DATABASE_URL=mysql://ai_manga:ai_manga_password@127.0.0.1:3306/ai_manga npm run prisma:validate -w backend +- npm run build +- npm test -w backend -- providers.service.spec.ts +- npm test -w backend -- admin.service.spec.ts +- npm run build -w backend +- 重启 3000 后端 dist 进程 +- curl http://127.0.0.1:3000/api/health +- curl -I http://127.0.0.1:5175 +- curl -I http://127.0.0.1:5174 +- Node fetch 烟测:admin 登录、RBAC 权限查询、OpenAI Video Provider 存在性、审计导出、普通用户访问后台 RBAC 被拒绝 + +测试结果: + +- backend typecheck:通过 +- admin typecheck:通过 +- workers typecheck:通过 +- backend 全量测试:通过,22 个测试文件 136 个测试通过 +- 全 workspace lint/typecheck:通过 +- 全 workspace 测试:通过,backend 136 个测试、workers 1 个测试、admin/user-app 无测试文件按 `--passWithNoTests` 通过 +- Prisma schema validate:通过 +- 全 workspace build:通过 +- 后端 3000 已重启到 PID `3816243`,健康检查返回 `status=ok` +- 后台 5175:返回 200 +- 用户端 5174:返回 200 +- 真实接口烟测通过:OpenAI Provider 列表包含 `openai-video`,驱动为 `openai_video_generation`;审计导出文件名为 `operation-logs-2026-06-02.csv`;普通用户访问 `/api/admin/rbac/me` 返回 403。 + +遗留问题: + +- 本机未配置真实 `OPENAI_API_KEY`,本轮未发起真实 Sora 付费调用;已用 mock fetch 单测验证创建、轮询、下载 MP4 和日志脱敏。 +- 当前 RBAC 为代码内角色矩阵,尚未做可配置权限表、权限配置 UI 和数据范围隔离。 + +下一步建议: + +- 跑全 workspace 验收、重启后端和前端服务;随后在生产 Key 配好后做一次真实 Sora 视频小样 E2E 验收。 + +### 后台体验优化:OpenAI 统一接入 + +完成时间:2026-06-02 01:39 CST + +完成内容: + +- 后台 AI 接入默认改为“OpenAI 统一接入”,运营只需要填写一个 OpenAI API Key。 +- 新增 `PATCH /api/admin/providers/openai/runtime-config`,批量把同一个 Key、Base URL、超时、成本阈值应用到全部 OpenAI Provider。 +- 高级 Provider 配置不删除,默认折叠,只给技术人员单独调整模型、优先级、mock/real 切换和兼容服务。 +- 统一配置支持“生产任务优先使用 OpenAI”,勾选后批量把 OpenAI Provider 优先级调到 220。 +- 增加测试覆盖,确认统一 Key 批量保存时不会把明文写入配置。 + +修改文件: + +- backend/src/providers/provider.dto.ts +- backend/src/providers/providers.controller.ts +- backend/src/providers/providers.service.ts +- backend/src/providers/providers.service.spec.ts +- admin/src/App.vue +- admin/src/styles.css +- README.md +- CODEX_PROGRESS.md + +运行命令: + +- npm run typecheck -w backend +- npm run typecheck -w admin +- npm test -w backend -- providers.service.spec.ts +- npm run build -w admin +- npm run build -w backend +- 重启 3000 后端 dist 进程 +- curl http://127.0.0.1:3000/api/health +- Node fetch 烟测:admin 登录后调用 `/api/admin/providers/openai/runtime-config` + +测试结果: + +- backend typecheck:通过 +- admin typecheck:通过 +- Provider 定向测试:通过,18 个测试通过 +- admin build:通过 +- backend build:通过 +- 后端 3000 已重启到 PID `3856864`,健康检查返回 `status=ok` +- 统一 OpenAI 配置烟测通过:批量更新 7 个 OpenAI Provider,Provider 列表仍包含 `openai-video`。 + +遗留问题: + +- 本机仍未配置真实 OpenAI Key,未做真实付费调用。 + +下一步建议: + +- 运营在后台“AI 接入”页只填统一 OpenAI Key;确认要真实生成时再勾选“生产任务优先使用 OpenAI”并做一次小样验收。 + +### OpenAI Key 防误耗额度保护 + +完成时间:2026-06-02 02:04 CST + +完成内容: + +- 后台“OpenAI 统一接入”的测试按钮改为“检查连接(不生成内容)”,只调用 `/api/admin/providers/openai/connection-check`。 +- 新增 OpenAI 连接检查接口:仅请求 OpenAI `/models` 检查 Key/网络,不生成文本、图片、语音或视频,不写 `provider_logs`。 +- 保存 OpenAI 统一配置时,未勾选“生产任务优先使用 OpenAI”会把全部 OpenAI Provider 优先级保持为 50,低于 mock,避免保存 Key 后自动切到真实模型。 +- 高级 Provider 的真实测试增加前端二次确认;真实视频 Provider 测试按钮禁用。 +- 后端 `/api/admin/providers/:providerId/test` 增加硬保护:真实 Provider 必须带 `confirm_paid_test=true`,真实视频 Provider 测试直接拒绝。 +- 连接检查遇到 `PROVIDER_SECRET_DECRYPT_FAILED` 时返回中文提示,说明需要保持 `PROVIDER_SECRET_KEY/JWT_SECRET` 稳定或重新保存 Key,且不触发生成。 + +修改文件: + +- backend/src/providers/provider.dto.ts +- backend/src/providers/providers.controller.ts +- backend/src/providers/providers.service.ts +- backend/src/providers/providers.service.spec.ts +- admin/src/App.vue +- admin/src/styles.css +- README.md +- CODEX_PROGRESS.md + +运行命令: + +- npm run typecheck -w backend +- npm run typecheck -w admin +- npm test -w backend -- providers.service.spec.ts +- npm run build -w admin +- npm run build -w backend +- 重启 3000 后端 dist 进程 +- Node fetch 安全烟测:admin 登录、OpenAI Provider 优先级检查、真实测试接口拦截、连接检查、provider_logs 总数对比 + +测试结果: + +- backend typecheck:通过 +- admin typecheck:通过 +- Provider 定向测试:通过,22 个测试通过 +- admin build:通过 +- backend build:通过 +- 后端 3000 已重启到 PID `3921216`,健康检查返回 `status=ok` +- 安全烟测通过:OpenAI Provider 共 7 个,优先级均为 50;已配置 Key 的 Provider 为 7 个;真实文本测试未带确认返回 `REAL_PROVIDER_TEST_CONFIRMATION_REQUIRED`;真实视频测试返回 `REAL_VIDEO_PROVIDER_TEST_DISABLED`;连接检查 `billed=false` 且 `provider_logs` 总数保持 147 不变。 + +遗留问题: + +- 当前已保存的后台 Key 在本次重启环境下返回 `PROVIDER_SECRET_DECRYPT_FAILED`,说明保存 Key 时使用的 `PROVIDER_SECRET_KEY/JWT_SECRET` 与当前启动环境不一致;需要用同一服务密钥启动后端,或在当前稳定服务密钥下重新保存 OpenAI API Key。 +- 本轮未调用任何文本/图片/TTS/视频生成接口,未做真实付费生成验收。 + +下一步建议: + +- 固定生产 `PROVIDER_SECRET_KEY` 后重新保存一次 OpenAI API Key,再只点“检查连接(不生成内容)”确认 Key 可用;确认成本策略后再手动勾选“生产任务优先使用 OpenAI”做受控小样。 + +### 后端环境变量稳定加载 + +完成时间:2026-06-02 14:09 CST + +完成内容: + +- 新增后端 `.env` 加载器,后端入口会在加载 `AppModule` 前读取根目录 `.env` 和 `backend/.env`。 +- 加载优先级为:系统环境变量优先,其次 `.env` 文件;避免重启后 `DATABASE_URL`、`JWT_SECRET`、`PROVIDER_SECRET_KEY` 丢失。 +- 创建本机 `.env`,写入本地数据库地址、端口、CORS、JWT 密钥和 `PROVIDER_SECRET_KEY`;未写入 OpenAI API Key。 +- 用裸 `node backend/dist/main.js` 重启后端,验证不再需要手动在启动命令注入 `DATABASE_URL`。 +- README 补充 `.env` 自动加载和 `PROVIDER_SECRET_KEY` 必须长期稳定的说明。 + +修改文件: + +- .env +- backend/src/config/load-env.ts +- backend/src/main.ts +- README.md +- CODEX_PROGRESS.md + +运行命令: + +- git status --short(当前目录不是 Git 仓库) +- npm run typecheck -w backend +- npm run build -w backend +- npm test -w backend -- providers.service.spec.ts +- 重启 3000 后端 dist 进程:`setsid -f node backend/dist/main.js ...` +- Node fetch 烟测:admin 登录、profile、Provider 列表、OpenAI 连接检查、provider_logs 总数对比 + +测试结果: + +- backend typecheck:通过 +- backend build:通过 +- Provider 定向测试:通过,22 个测试通过 +- 后端 3000 已重启到 PID `1309062`,健康检查返回 `status=ok` +- 裸启动烟测通过:`admin@example.com` 登录成功,`/api/auth/profile` 返回 admin,Provider 列表可读,OpenAI Provider 仍为 7 个且优先级均为 50。 +- 连接检查未触发生成:返回 `billed=false`,`provider_logs` 总数保持 147 不变。 + +遗留问题: + +- 旧的后台 OpenAI Key 仍返回 `PROVIDER_SECRET_DECRYPT_FAILED`,因为无法知道保存当时使用的服务加密密钥;现在已固定新的 `PROVIDER_SECRET_KEY`,需要在后台重新保存一次 OpenAI Key。 + +下一步建议: + +- 在后台 AI 接入页重新保存 OpenAI API Key,然后只点击“检查连接(不生成内容)”;通过后再决定是否勾选“生产任务优先使用 OpenAI”。 + +### OpenAI Key 重新保存与连接确认 + +完成时间:2026-06-02 14:11 CST + +完成内容: + +- 用户已在后台重新保存 OpenAI API Key。 +- 重新执行后台 OpenAI 连接检查,只调用 `/api/admin/providers/openai/connection-check`。 +- 连接检查返回 `ok=true`,OpenAI `/models` 可访问,Key 能在当前稳定 `PROVIDER_SECRET_KEY` 下解密。 +- OpenAI Provider 仍保持优先级 50,默认低于 mock,不会自动切到真实生成。 + +运行命令: + +- Node fetch 安全烟测:admin 登录、Provider 列表、OpenAI 连接检查、provider_logs 总数对比 + +测试结果: + +- OpenAI Provider:7 个 +- 已配置 Key:7 个 +- OpenAI Provider 优先级:50 +- 连接检查:`ok=true`,`billed=false`,`endpoint=/models`,`model_count=118` +- `provider_logs` 总数前后保持 147 不变,确认未触发文本/图片/TTS/视频生成。 + +遗留问题: + +- 当前仍未做真实生成小样;这是有意保留,避免未确认成本策略前消耗额度。 + +下一步建议: + +- 保持当前状态继续用 mock 做业务验收;如果要做真实 AI 小样,先设置单次/当日成本上限,再手动勾选“生产任务优先使用 OpenAI”,只跑一个受控小样。 + +### 小白使用手册补齐 + +完成时间:2026-06-02 14:24 CST + +完成内容: + +- 新增根目录 `OPERATION_GUIDE.md`,不修改 `docs/` 需求文档目录。 +- 手册按小白视角解释项目、故事圣经、角色圣经、锚点图、长篇记忆、分集计划、单集脚本、分镜、分镜图、TTS、字幕和视频。 +- 补充用户端从注册/登录、新建项目、AI 原创/上传小说、版权确认、故事圣经、角色、分集、脚本、分镜、图片、音频、字幕、视频、下载的一整套操作流程。 +- 补充后台运营流程:仪表盘、项目、小说、角色资源、分镜资源、成品漫剧、用户管理、额度、审核、任务、AI 接入、成本日志、系统配置和审计日志。 +- 明确说明“场景”当前不是独立场景库,而是 AI 在分集/脚本/分镜中生成的场景名、地点、画面和动作字段,确认前可编辑。 +- 增加“哪些内容 AI 生成,哪些需要人确认”的表格,以及常见问题排查表。 +- README 增加 `OPERATION_GUIDE.md` 入口。 + +修改文件: + +- OPERATION_GUIDE.md +- README.md +- CODEX_PROGRESS.md + +运行命令: + +- ls -la +- rg 阅读当前 README、进度记录、用户端和后台页面入口 + +测试结果: + +- 本阶段为纯文档补齐,未调用 OpenAI、未运行真实生成、未消耗额度。 +- 未运行代码测试;本次未修改业务代码。 + +遗留问题: + +- 用户端页面本身仍缺少内嵌引导文案和步骤提示;当前先以独立手册形式补齐。 +- 后续可把手册内容拆成后台“帮助/操作说明”页面和用户端流程提示。 + +下一步建议: + +- 先按 `OPERATION_GUIDE.md` 用 mock 流程完整走一遍,熟悉每个确认点;确认操作理解后,再决定是否做真实 AI 小样。 + +### 产品内教程页补齐 + +完成时间:2026-06-02 14:42 CST + +完成内容: + +- 后台新增左侧“使用教程”页面,展示后台使用流程、故事圣经/角色圣经/场景/额度等概念说明、从小说到成品的操作顺序、后台常见排查和人工确认点。 +- 用户端新增“教程”导航页,登录后可直接查看新手概念、一集从头到尾的步骤、角色/场景/额度等常见问题和新手建议。 +- 用户端登录页增加“先看教程”折叠入口,未开始建项目前也能先理解基础流程。 +- 用户端新增并注册 `src/pages/help/tutorial` 独立教程页面,给后续 uni-app 小程序/App 路由迁移预留。 +- README 更新产品内教程入口说明。 + +修改文件: + +- admin/src/App.vue +- admin/src/styles.css +- user-app/src/pages/index/index.vue +- user-app/src/pages/help/tutorial.vue +- user-app/src/styles.css +- user-app/pages.json +- README.md +- CODEX_PROGRESS.md + +运行命令: + +- git status --short +- npm run typecheck -w admin +- npm run typecheck -w user-app +- npm run build -w admin +- npm run build -w user-app + +测试结果: + +- 当前目录不是 git 仓库,`git status --short` 返回 `fatal: not a git repository`。 +- 后台类型检查通过。 +- 用户端类型检查通过。 +- 后台生产构建通过。 +- 用户端生产构建通过。 +- 本阶段未调用 OpenAI、未运行真实生成、未消耗额度。 + +遗留问题: + +- 当前教程为产品内摘要版,详细长文仍保留在根目录 `OPERATION_GUIDE.md`。 +- 当前 Vite H5 入口仍以首页内导航为主;`pages.json` 的独立教程页为后续 uni-app 多端路由预留。 + +下一步建议: + +- 让运营和测试先按用户端“教程”页走一遍 mock 流程;如果仍有不懂的字段,再把对应字段旁边补成就地提示。 + +### 上传小说解析 400 排查与前端保护 + +完成时间:2026-06-02 14:51 CST + +问题现象: + +- 用户端请求 `POST /api/projects/33/novel/parse` 返回 `400 Bad Request`。 + +排查结论: + +- 项目 `33` 是上传小说项目,当前 `copyright_status=pending`,项目状态 `source_selecting`。 +- 项目 `33` 已有粘贴小说源 `source_id=16`,但 `copyright_records` 数量为 `0`。 +- 后端解析接口要求上传小说必须先完成版权确认,因此返回 `Copyright must be confirmed before parsing novel`。 + +完成内容: + +- 用户端上传小说区新增解析前置判断:未完成版权确认时禁用“解析小说”按钮。 +- 用户端上传小说区新增提示文案:先保存/上传小说,再在下方完成版权确认,最后解析小说。 +- 用户端版权确认按钮在已确认后显示“已确认”并禁用,避免重复点击。 +- 用户端 API Client 增加常见英文错误的中文映射,后端返回英文 BadRequest 时前端显示中文可理解提示。 + +修改文件: + +- user-app/src/api/client.ts +- user-app/src/pages/index/index.vue +- user-app/src/styles.css +- CODEX_PROGRESS.md + +运行命令: + +- rg 定位 `novel/parse` 前后端调用链 +- mysql 查询项目 `33`、小说源、素材、版权记录和章节状态 +- npm run typecheck -w user-app +- npm run build -w user-app +- curl -I http://127.0.0.1:5174 +- curl http://127.0.0.1:3000/api/health + +测试结果: + +- 用户端类型检查通过。 +- 用户端生产构建通过。 +- 用户端 H5 返回 `200 OK`。 +- 后端 health 返回 `status=ok`。 +- 本阶段未调用 OpenAI、未运行真实生成、未消耗额度。 + +下一步建议: + +- 对项目 `33`:先在用户端点击“版权确认”的“确认”,再点击“解析小说”。 + +### 上传小说解析结果就地反馈 + +完成时间:2026-06-02 14:59 CST + +问题现象: + +- 用户端点击“解析小说”后按钮只闪一下,页面附近没有明确显示是否成功,用户不知道下一步做什么。 + +排查结论: + +- 项目 `33` 实际已解析成功,数据库中最新小说源 `parse_status=parsed`,章节已生成。 +- 用户端只有顶部全局 `解析小说完成` 提示,上传小说卡片内没有解析结果、章节数、字数和下一步提示。 + +完成内容: + +- 上传小说卡片内新增解析状态行:小说源 ID、解析状态、章节数、字数。 +- 解析按钮新增动态文案:`解析中`、`解析小说`、`重新解析`。 +- 解析成功后在上传小说卡片内固定显示:已拆出章节数、字数,以及下一步“故事圣经 -> 生成”。 +- 点击解析后立即接收接口返回的 `source` 和 `chapters` 写入页面状态,再刷新工作台,避免用户只看到按钮闪烁。 +- 粘贴小说且版权已确认时,自动解析返回结果也会同步到页面状态。 + +修改文件: + +- user-app/src/pages/index/index.vue +- CODEX_PROGRESS.md + +运行命令: + +- mysql 查询项目 `33`、小说源、章节状态 +- npm run typecheck -w user-app +- npm run build -w user-app +- curl -I http://127.0.0.1:5174 +- curl http://127.0.0.1:3000/api/health + +测试结果: + +- 用户端类型检查通过。 +- 用户端生产构建通过。 +- 用户端 H5 返回 `200 OK`。 +- 后端 health 返回 `status=ok`。 +- 本阶段未调用 OpenAI、未运行真实生成、未消耗额度。 + +下一步建议: + +- 用户解析成功后,直接看上传小说卡片里的绿色提示;下一步点击“故事圣经”的“生成”。 + +### 用户端全流程下一步指引 + +完成时间:2026-06-02 15:04 CST + +问题现象: + +- 用户端点击“故事圣经 -> 生成”后虽然已生成,但页面没有明确告诉用户下一步应该“检查并确认故事圣经”,后续流程容易迷路。 + +完成内容: + +- 用户端制作页新增顶部“当前下一步”提示卡,会根据项目当前数据自动显示下一步动作。 +- 故事圣经卡片新增就地指引:未生成、生成中、待确认、已确认时分别提示下一步。 +- 角色库卡片新增就地指引:提示抽取角色、生成锚点图、确认角色库和进入长篇记忆。 +- 长篇记忆卡片新增就地指引:提示角色确认后生成记忆,完成后进入分集计划。 +- 分集计划卡片新增就地指引:提示生成、检查摘要/钩子、确认分集,以及下一步生成脚本。 +- 脚本和分镜卡片新增就地指引:提示生成脚本、确认脚本、生成分镜、确认分镜,以及下一步生成素材。 +- 图片/音频/视频卡片新增就地指引:提示分镜图、音频字幕、合成视频的顺序。 +- 内容审核卡片新增就地指引:提示合成视频后做文本/视频审核,审核通过后去成品页。 + +修改文件: + +- user-app/src/pages/index/index.vue +- user-app/src/styles.css +- CODEX_PROGRESS.md + +运行命令: + +- npm run typecheck -w user-app +- npm run build -w user-app +- curl -I http://127.0.0.1:5174 +- curl http://127.0.0.1:3000/api/health + +测试结果: + +- 用户端类型检查通过。 +- 用户端生产构建通过。 +- 用户端 H5 返回 `200 OK`。 +- 后端 health 返回 `status=ok`。 +- 本阶段未调用 OpenAI、未运行真实生成、未消耗额度。 + +下一步建议: + +- 继续按用户端顶部“当前下一步”和各卡片绿色/灰色提示操作;如果某个字段仍看不懂,再补字段级说明。 + +### 用户端结果阅览与媒体任务反馈优化 + +完成时间:2026-06-02 15:19 CST + +问题现象: + +- 上传小说解析后刷新页面,输入框为空,用户感觉上传内容“全清空”,只有提示,看不到已保存/已解析的小说内容。 +- “图片、音频和视频”区域点击分镜图、音频字幕、合成后,缺少成功/失败/进行中状态和错误提示。 +- 媒体生成后没有明显的结果预览入口,用户不知道每一步到底生成了什么。 +- 用户确认刚才生成链路是否使用了真实 OpenAI。 + +排查结论: + +- 项目 `33` 已成功生成 10 张分镜图、1 个音频、1 个字幕和 1 个 MP4。 +- Provider 日志显示项目 `33` 使用的是 `mock-image`、`mock-voice`、`mock-video`,成本均为 `0.0000`,没有真实 OpenAI 调用。 + +完成内容: + +- 上传小说区刷新后会展示已保存小说信息:小说源、标题、作者、来源、解析状态和保存时间。 +- 上传小说区新增章节预览列表,展示章节号、标题、字数、状态和正文片段。 +- 上传小说文本框增加占位提示:已保存内容在下方预览,如需替换可重新粘贴。 +- “图片、音频和视频”区域新增分步骤任务状态卡:分镜图、音频、字幕、视频。 +- 每个媒体步骤展示成功/失败/进行中/未开始、成功数量、失败错误原因和下一步动作。 +- 媒体素材列表新增预览/下载按钮。 +- 新增通用素材预览区,支持图片、音频、视频内嵌预览;其他文件提示下载查看。 +- 媒体流程下一步判断改为结合任务状态和素材结果,避免已生成后仍提示“下一步点分镜图”。 + +修改文件: + +- user-app/src/pages/index/index.vue +- user-app/src/styles.css +- CODEX_PROGRESS.md + +运行命令: + +- mysql 查询项目 `33` 的项目状态、小说源、章节、任务、Provider 日志和素材。 +- npm run typecheck -w user-app +- npm run build -w user-app +- curl -I http://127.0.0.1:5174 +- curl http://127.0.0.1:3000/api/health + +测试结果: + +- 用户端类型检查通过。 +- 用户端生产构建通过。 +- 用户端 H5 返回 `200 OK`。 +- 后端 health 返回 `status=ok`。 +- 项目 `33` Provider 汇总:`mock-image` 10 次、`mock-voice` 1 次、`mock-video` 1 次,成本均为 `0.0000`。 +- 本阶段未调用 OpenAI、未运行真实生成、未消耗额度。 + +下一步建议: + +- 用户端继续补字段级“这是什么”说明,尤其是故事圣经、角色字段、分镜字段和媒体任务字段。 + +### 下一步提示高亮与 mock 上线策略确认 + +完成时间:2026-06-02 15:27 CST + +完成内容: + +- 用户端“当前下一步”提示卡改成红色边框和红色标题,提升用户注意力。 +- 核查当前 Provider 配置:mock Provider 9 个启用,优先级 100;real OpenAI Provider 7 个启用,优先级 50。 +- 确认当前业务链路默认仍走 mock,不会因为已配置 OpenAI Key 就自动消耗真实额度。 +- 确认后台已经有“生产任务优先使用 OpenAI”开关;勾选后会把 OpenAI Provider 优先级提高到 220。 + +上线策略建议: + +- 不建议删除 mock Provider。mock 是内部测试、演示、回归测试、故障降级和成本保护的兜底能力。 +- 不建议把 mock/real 勾选放到普通用户前端。普通用户看到这个会困惑,也可能误选真实生成导致成本不可控。 +- 建议上线时由后台“AI 接入”统一控制:测试默认 mock;准备真实生产时由管理员在后台勾选“生产任务优先使用 OpenAI”,并设置单次/当日成本阈值。 +- 对外用户端只展示业务流程和结果,不展示 Provider 模式。 + +修改文件: + +- user-app/src/styles.css +- CODEX_PROGRESS.md + +运行命令: + +- rg 查询用户端样式、后台 OpenAI 统一接入、Provider 逻辑。 +- mysql 查询 Provider 配置模式、启用状态和优先级。 +- npm run typecheck -w user-app +- npm run build -w user-app +- curl -I http://127.0.0.1:5174 +- curl http://127.0.0.1:3000/api/health + +测试结果: + +- 用户端类型检查通过。 +- 用户端生产构建通过。 +- 用户端 H5 返回 `200 OK`。 +- 后端 health 返回 `status=ok`。 +- 本阶段未调用 OpenAI、未运行真实生成、未消耗额度。 + +下一步建议: + +- 真正上线前不要删除 mock;先在后台设置 OpenAI 成本阈值,再勾选“生产任务优先使用 OpenAI”,用 1 个测试项目跑小样验收。 + +### 角色锚点图 400 排查与流程顺序修正 + +完成时间:2026-06-02 15:34 CST + +问题现象: + +- 用户端请求 `POST /api/characters/36/generate-images` 返回 `400 Bad Request`。 +- 用户以为真实 OpenAI 图片生成失败。 + +排查结论: + +- 角色 `36` 当前状态是 `generated`,还不是 `locked`。 +- 项目 `35` 当前状态是 `waiting_character_confirm`,也就是角色库还未确认。 +- 后端 `generateCharacterImages` 明确要求 `character.status === locked`,否则返回 `Locked character is required before image generation`。 +- 这次 400 发生在调用 ImageProvider 之前,项目 `35` 没有新增 Provider 日志,因此没有触发 OpenAI,也没有产生图片成本。 +- 当前 ImageProvider 配置为 `openai-image` real 优先级 `220`、`mock-image` 优先级 `100`;角色确认后再点锚点图会走真实 OpenAI 图片生成。 + +完成内容: + +- 用户端 API Client 增加错误中文映射:`Locked character is required before image generation` -> `请先确认角色库,再生成角色锚点图。` +- 用户端角色流程提示修正为:先抽取角色 -> 检查角色 -> 确认角色库 -> 生成锚点图。 +- 用户端“锚点图”按钮改为只有角色库确认后才可点击。 +- 用户端点击锚点图时增加前置保护,未确认角色库会直接提示中文错误。 +- 用户端角色状态增加 `locked` 中文展示为“已锁定”。 +- 用户端下一步提示增加“真实 OpenAI 模式下锚点图会产生图片生成成本”的提醒。 + +修改文件: + +- user-app/src/api/client.ts +- user-app/src/pages/index/index.vue +- CODEX_PROGRESS.md + +运行命令: + +- rg 定位 `generate-images` 前后端调用链。 +- mysql 查询角色 `36`、项目 `35`、ImageProvider 优先级和项目 Provider 日志。 +- npm run typecheck -w user-app +- npm run build -w user-app +- curl -I http://127.0.0.1:5174 +- curl http://127.0.0.1:3000/api/health + +测试结果: + +- 用户端类型检查通过。 +- 用户端生产构建通过。 +- 用户端 H5 返回 `200 OK`。 +- 后端 health 返回 `status=ok`。 +- 本阶段未调用 OpenAI、未运行真实生成、未消耗额度。 + +下一步建议: + +- 对项目 `35`:先点“确认角色库”,确认角色被锁定后,再点“锚点图”。因为当前 ImageProvider 已经是 OpenAI 优先,锚点图会走真实图片生成并产生成本。 + +### 角色锚点图耗时说明与悬浮下一步提示 + +完成时间:2026-06-02 15:42 CST + +完成内容: + +- 用户端角色锚点图生成中提示补充预计耗时:真实 OpenAI 通常每个角色约 20-90 秒;如果超时,当前后端约 60 秒后会回退 mock。 +- 用户端角色锚点图生成成功后显示明确成功文案:`角色锚点图生成完成:已处理 N 个角色。` +- 用户端锚点图按钮会在未确认角色库、无待生成角色时给出中文提示,避免用户误点。 +- 用户端“下一步”红色提示从页面内卡片改为固定悬浮提示,位于底部导航上方,可点击关闭;当下一步内容变化时会重新出现。 + +修改文件: + +- user-app/src/pages/index/index.vue +- user-app/src/styles.css +- CODEX_PROGRESS.md + +运行命令: + +- npm run typecheck -w user-app +- npm run build -w user-app +- curl -I http://127.0.0.1:5174 +- curl http://127.0.0.1:3000/api/health + +测试结果: + +- 用户端类型检查通过。 +- 用户端生产构建通过。 +- 用户端 H5 返回 `200 OK`。 +- 后端 health 返回 `status=ok`。 +- 本阶段只改前端提示和文档,未调用 OpenAI、未运行真实生成、未消耗额度。 + +下一步建议: + +- 如果正式生产不允许 mock fallback,需要再把图片 Provider 策略改成“真实 Provider 失败即失败并提示”,而不是自动回退 mock。 + +### 生产链路禁用 mock 自动回退 + +完成时间:2026-06-02 15:52 CST + +问题结论: + +- 正式上线不能出现“OpenAI 超时失败,但系统自动回退 mock 并把任务标成成功”的行为。 +- mock 只应保留给内部测试、演示和熟悉流程;生产优先 OpenAI 时,真实 Provider 失败必须让任务失败并提示原因。 + +完成内容: + +- 图片生成调用 ImageProvider 时显式设置 `allow_fallback: false`,真实 OpenAI 超时不会再自动回退 `mock-image`。 +- TTS、视频、内容审核和队列 worker 的 Provider 调用也统一改为 `allow_fallback: false`。 +- 后台单个 Provider 测试接口改为只测试选中的 Provider,不允许真实 Provider 测试失败后 fallback mock。 +- 图片 Provider 成功但没有返回 `content_base64` 或可下载图片 URL 时,不再生成 mock SVG,占位资产不会落库。 +- TTS Provider 成功但没有返回音频内容时,不再生成静音 mock 音频。 +- 视频 Provider 没有返回真实视频内容且 FFmpeg 不可用时,不再生成 mock MP4。 +- 用户端新增中文错误提示:OpenAI 超时、图片/TTS/视频 Provider 没返回真实内容时会明确说明“真实素材未生成”。 +- 已重新构建并重启后端,当前后端进程为 `1508518`,`/api/health` 正常。 + +修改文件: + +- backend/src/images/images.service.ts +- backend/src/media/media.service.ts +- backend/src/providers/providers.service.ts +- backend/src/queues/queues.service.ts +- backend/src/reviews/reviews.service.ts +- backend/src/images/images.service.spec.ts +- backend/src/media/media.service.spec.ts +- backend/src/queues/queues.service.spec.ts +- user-app/src/api/client.ts +- CODEX_PROGRESS.md + +运行命令: + +- npm run typecheck -w backend +- npm test -w backend +- npm run build -w backend +- npm run build -w user-app +- curl http://127.0.0.1:3000/api/health +- curl -I http://127.0.0.1:5174 + +测试结果: + +- 后端类型检查通过。 +- 后端全量单测通过:22 个测试文件、144 个测试通过。 +- 后端构建通过。 +- 用户端构建通过。 +- 后端 health 返回 `status=ok`。 +- 用户端 H5 返回 `200 OK`。 +- 本阶段没有调用真实 OpenAI,没有消耗额度。 + +下一步建议: + +- 重新点一次角色锚点图时,如果 OpenAI 仍超时,前端会显示失败;需要从后台调高 OpenAI 图片 Provider 超时时间,或检查服务器到 OpenAI 的网络连通性。 + +### 角色锚点图预览与重生成流程 + +完成时间:2026-06-02 16:03 CST + +问题结论: + +- 角色锚点图生成后不能只显示任务成功,必须能预览当前锚点图、查看候选图、不满意时重生成或切换锚点。 +- 后端已有角色图片列表、设为锚点和强制重生成能力,主要缺少用户端/后台运营入口。 + +完成内容: + +- 用户端新增角色图片列表拉取:刷新项目时自动加载每个角色的锚点图和候选图。 +- 用户端角色卡片展示当前锚点状态、候选图数量、候选图列表。 +- 用户端支持点击“预览锚点”“预览候选图”“下载候选图”。 +- 用户端支持从候选图中点击“设为锚点”。 +- 用户端支持对单个角色点击“重生成”,会强制生成新锚点图并设为当前锚点。 +- 用户端素材预览面板从媒体区内联面板改成全局浮层,角色区、媒体区、成品区预览都能立即弹出。 +- 后台“角色资源”列表操作栏新增“锚点图”按钮,运营可直接预览角色锚点素材。 + +修改文件: + +- user-app/src/api/client.ts +- user-app/src/pages/index/index.vue +- user-app/src/styles.css +- admin/src/App.vue +- CODEX_PROGRESS.md + +运行命令: + +- npm run typecheck -w user-app +- npm run build -w user-app +- npm run typecheck -w admin +- npm run build -w admin +- curl -I http://127.0.0.1:5174 +- curl -I http://127.0.0.1:5175 +- curl http://127.0.0.1:3000/api/health + +测试结果: + +- 用户端类型检查通过。 +- 用户端构建通过。 +- 后台类型检查通过。 +- 后台构建通过。 +- 用户端 H5 返回 `200 OK`。 +- 后台返回 `200 OK`。 +- 后端 health 返回 `status=ok`。 +- 本阶段没有调用真实 OpenAI,没有消耗额度。 + +下一步建议: + +- 如果要允许修改锁定角色的核心外貌字段,需要新增“角色回退编辑/重新确认”流程;当前后端只允许锁定后补充服装、道具、禁用规则等非核心字段,避免破坏后续角色一致性。 + +### OpenAI 图片超时配置调整 + +完成时间:2026-06-02 16:10 CST + +问题现象: + +- 角色锚点图真实 OpenAI 请求返回 `503 OPENAI_REQUEST_TIMEOUT`。 +- 最新 ImageProvider 日志显示 `openai-image` 从 `2026-06-02 08:05:08.979` 等到 `08:06:08.981`,约 60 秒后超时。 + +排查结论: + +- 当前数据库里所有 OpenAI Provider 的 `timeout_ms` 都被后台统一配置保存成了 `60000`。 +- 生产链路已经禁用 mock fallback,所以真实 OpenAI 超时后会正确失败,不再生成 mock 占位图。 + +完成内容: + +- 将当前数据库所有 `openai-*` Provider 的 `timeout_ms` 从 `60000` 更新为 `180000`。 +- 后台“OpenAI 统一接入”默认超时时间改为 `180000`。 +- 后台单个 Provider 高级配置的超时占位改为 `180000`。 +- 后端 `openai-image` 默认初始化配置改为 `180000`。 +- 重新构建并重启后端,当前后端进程为 `1538959`。 + +修改文件: + +- backend/src/providers/provider.types.ts +- admin/src/App.vue +- CODEX_PROGRESS.md + +运行命令: + +- mysql 查询/更新 provider_configs +- npm run typecheck -w backend +- npm run build -w backend +- npm run typecheck -w admin +- npm run build -w admin +- curl http://127.0.0.1:3000/api/health +- curl -I http://127.0.0.1:5175 + +测试结果: + +- 数据库确认所有 `openai-*` Provider 超时为 `180000`。 +- 后端类型检查通过。 +- 后端构建通过。 +- 后台类型检查通过。 +- 后台构建通过。 +- 后端 health 返回 `status=ok`。 +- 后台返回 `200 OK`。 +- 本阶段没有调用真实 OpenAI,没有消耗额度。 + +下一步建议: + +- 重新生成角色锚点图时会最多等待 180 秒;如果仍然超时,需要检查服务器到 OpenAI 的网络延迟/代理,或降低图片质量、尺寸后重试。 + +### 长任务可见进度提示 + +完成时间:2026-06-02 16:16 CST + +问题现象: + +- 用户点击单个角色“重生成”后,只有按钮变灰,没有明显 loading、耗时和下一步反馈;非技术用户会误以为页面卡住。 + +完成内容: + +- 用户端所有 `runAction` 动作新增计时器,展示已等待时间。 +- 用户端顶部新增当前动作提示条,例如“正在生成角色锚点图 / 已等待 N 秒”。 +- 用户端新增全局悬浮进度提示,展示当前动作、已等待时长和长任务说明。 +- 角色卡片中新增单角色重生成提示,明确显示“正在重生成某角色的锚点图”。 +- 角色锚点生成文案更新为:真实 OpenAI 通常 1-3 分钟,180 秒超时直接失败,不回退 mock。 + +修改文件: + +- user-app/src/pages/index/index.vue +- user-app/src/styles.css +- CODEX_PROGRESS.md + +运行命令: + +- npm run typecheck -w user-app +- npm run build -w user-app +- curl -I http://127.0.0.1:5174 +- mysql 查询 ImageProvider 最近日志 + +测试结果: + +- 用户端类型检查通过。 +- 用户端构建通过。 +- 用户端 H5 返回 `200 OK`。 +- 最近 ImageProvider 日志仍为旧 60 秒超时记录,180 秒配置后暂无新的图片生成日志。 +- 本阶段没有调用真实 OpenAI,没有消耗额度。 + +下一步建议: + +- 更生产化的做法是把图片生成彻底改为后台队列任务:前端提交任务后轮询任务状态,用户可离开页面,完成后站内提示和自动刷新候选图。 + +### 视频合成 503 修复:默认走 FFmpeg 成片合成 + +完成时间:2026-06-02 16:46 CST + +问题现象: + +- 用户端调用 `POST /api/episodes/25/video/render` 返回 `503 Service Unavailable`。 +- 数据库任务和 Provider 日志显示 `VideoProvider openai-video` 失败,错误为 `Invalid value: '40'. Supported values are: '4', '8', '12', '16', and '20'.` + +根因: + +- `/video/render` 是“把分镜图、音频、字幕合成为最终 MP4”的接口,但当前真实 OpenAI 优先后误先调用了 `openai-video`。 +- OpenAI Sora 视频生成的 `seconds` 不是任意成片时长,40 秒被 Provider 拒绝。 +- 当前项目的分镜图和 TTS 真实生成日志是成功的,失败点只在最终视频合成误走视频生成 Provider。 + +完成内容: + +- `MediaService.renderEpisodeVideo` 改为:默认 `prefer_ffmpeg !== false` 时直接走本地 FFmpeg 合成,不调用 `VideoProvider`。 +- 只有显式传 `prefer_ffmpeg:false` 时才调用 `VideoProvider`,用于未来单独的 AI 视频生成/小样流程。 +- 默认 FFmpeg 合成仍使用已有分镜图、音频和字幕,生产模式下 FFmpeg 不可用会失败,不生成 mock 成片。 +- 新增单测锁定默认行为:本地 FFmpeg 合成不会调用 `VideoProvider`。 + +修改文件: + +- backend/src/media/media.service.ts +- backend/src/media/media.service.spec.ts +- CODEX_PROGRESS.md + +运行命令: + +- npm run typecheck -w backend +- npm test -w backend -- media.service.spec.ts +- npm test -w backend +- npm run build -w backend +- ffmpeg -version +- curl http://127.0.0.1:3000/api/health + +测试结果: + +- 后端类型检查通过。 +- `media.service` 单测通过:10 tests passed。 +- 后端完整测试通过:22 files / 145 tests passed。 +- 后端构建通过。 +- 本机 FFmpeg 可用:5.1.9。 +- 后端已重启,新进程 PID `1605147`,health 返回 `status=ok`。 +- 本阶段没有调用真实 OpenAI,没有消耗额度。 + +下一步建议: + +- 用户端重新点击“合成”后应走 FFmpeg 成片合成;如果素材齐全,应生成 MP4。 +- 真正的 OpenAI/Sora 视频生成应单独做“AI 视频小样”入口,并限制 `seconds` 为 Provider 支持值,不再复用最终成片合成接口。 + +### 视频体验核查:声音 / 中文字幕 / Sora 成本说明 + +完成时间:2026-06-02 17:03 CST + +问题现象: + +- 用户合成后发现最终成片仍是图片加解说,不是人物真实动态视频。 +- 用户反馈字幕没有正常显示,画面上出现两排小方框。 +- 用户反馈播放时没有声音,并观察到 OpenAI 消费约 0.8 美金。 + +核查结论: + +- 第 25 集最新视频任务 `184` 为 FFmpeg 本地合成成功,输出视频资产 `180`,没有调用 Sora。 +- 当前产物确实是“分镜图 + TTS + 字幕”的剪辑成片,不是 Sora 这类 AI 动态视频。 +- 视频文件内存在 AAC 音轨,时长 40 秒;`ffmpeg volumedetect` 检测到正常音量,文件层面不是无音轨/静音。 +- 字幕小方框根因是服务器缺少中文字体,且代码强制 `FontName=Arial`,Arial 不覆盖中文。 +- 第 25 集本地 provider_logs 里项目成功日志包含 13 次 `openai-image` 和 1 次 `openai-tts`,系统内部成本字段仍为 0;用户在 OpenAI Dashboard 看到的约 0.8 美金应来自真实图片生成和 TTS,而不是 FFmpeg 合成或 Sora。 + +完成内容: + +- 服务器安装 `google-noto-sans-cjk-ttc-fonts`,并刷新字体缓存。 +- FFmpeg 字幕样式改为 `Noto Sans CJK SC`,字号从 24 提升到 52,底部边距和描边同步优化。 +- FFmpeg 音频合成增加 `loudnorm` 标准响度处理,并把 AAC 码率提升到 128k。 +- 用户端点击“合成”改为传 `force:true`,便于重新生成已存在的 FFmpeg 成片;该动作复用已存在图片/音频/字幕,不额外调用 OpenAI。 +- 用现有 SRT 做 1 秒 FFmpeg dry-run,确认新字体字幕过滤器可运行。 + +修改文件: + +- backend/src/media/media.service.ts +- user-app/src/api/client.ts +- CODEX_PROGRESS.md + +运行命令: + +- dnf install -y google-noto-sans-cjk-ttc-fonts +- fc-cache -fv +- fc-list :lang=zh +- ffprobe 检查第 25 集视频和音频文件 +- ffmpeg volumedetect 检测最终 MP4 音轨 +- npm run typecheck -w backend +- npm run build -w backend +- npm run typecheck -w user-app +- npm run build -w user-app +- curl http://127.0.0.1:3000/api/health +- curl -I http://127.0.0.1:5174 + +测试结果: + +- 后端类型检查通过。 +- 后端构建通过。 +- 用户端类型检查通过。 +- 用户端构建通过。 +- 后端已重启,新进程 PID `1643780`,health 返回 `status=ok`。 +- 用户端 H5 返回 `200 OK`。 +- 本阶段未调用任何真实 OpenAI 生成接口,没有新增 AI 消耗。 + +下一步建议: + +- 让用户重新点击“合成”,拿到新字体字幕版本。 +- 若要人物真实运动,需要新增“AI 动态视频 / Sora 分镜视频”独立流程:按 4-20 秒短镜头生成动态视频,再做最终剪辑合成。 +- 上线前需要在后台清楚区分“低成本剪辑成片”和“高成本动态视频”,并在每一步显示预计费用。 + +### 视频字幕字号回调 + +完成时间:2026-06-02 17:11 CST + +问题现象: + +- 用户反馈新字体字幕过大,已经超出视频画面。 + +完成内容: + +- FFmpeg 字幕 `FontSize` 从 52 回调到 36。 +- 字幕描边从 3 回调到 2。 +- 字幕增加 `MarginL=90`、`MarginR=90`、`MarginV=130`,减少横向溢出和底部贴边。 +- 重新执行 FFmpeg 字幕 dry-run,确认过滤器可正常运行。 + +修改文件: + +- backend/src/media/media.service.ts +- CODEX_PROGRESS.md + +运行命令: + +- npm run typecheck -w backend +- npm run build -w backend +- ffmpeg 字幕 dry-run +- curl http://127.0.0.1:3000/api/health + +测试结果: + +- 后端类型检查通过。 +- 后端构建通过。 +- FFmpeg 字幕 dry-run 通过。 +- 后端已重启,新进程 PID `1654039`,health 返回 `status=ok`。 +- 本阶段未调用任何真实 OpenAI 生成接口,没有新增 AI 消耗。 + +### 视频字幕手机屏幕尺寸二次回调 + +完成时间:2026-06-02 17:17 CST + +问题现象: + +- 用户反馈字幕仍然偏大,需要按手机竖屏观看尺寸处理。 + +完成内容: + +- FFmpeg 字幕 `FontSize` 从 36 继续下调到 24。 +- 去掉字幕阴影,保留 2px 描边,减少大字压迫感。 +- 字幕边距调整为 `MarginL=80`、`MarginR=80`、`MarginV=120`,适配 1080x1920 手机竖屏底部字幕。 + +修改文件: + +- backend/src/media/media.service.ts +- CODEX_PROGRESS.md + +运行命令: + +- npm run typecheck -w backend +- npm run build -w backend +- ffmpeg 字幕 dry-run +- curl http://127.0.0.1:3000/api/health + +测试结果: + +- 后端类型检查通过。 +- 后端构建通过。 +- FFmpeg 字幕 dry-run 通过。 +- 后端已重启,新进程 PID `1666467`,health 返回 `status=ok`。 +- 本阶段未调用任何真实 OpenAI 生成接口,没有新增 AI 消耗。 + +### 视频字幕手机屏幕尺寸三次回调 + +完成时间:2026-06-02 17:20 CST + +问题现象: + +- 用户反馈字幕仍需再小一点,并希望左右两边留出更多空隙。 + +完成内容: + +- FFmpeg 字幕 `FontSize` 从 24 继续下调到 20。 +- 字幕左右边距从 80 提升到 150,增加手机竖屏两侧留白。 +- 保留中文字体 `Noto Sans CJK SC`、2px 描边和底部 `MarginV=120`。 + +修改文件: + +- backend/src/media/media.service.ts +- CODEX_PROGRESS.md + +运行命令: + +- npm run typecheck -w backend +- npm run build -w backend +- ffmpeg 字幕 dry-run +- curl http://127.0.0.1:3000/api/health + +测试结果: + +- 后端类型检查通过。 +- 后端构建通过。 +- FFmpeg 字幕 dry-run 通过。 +- 后端已重启,新进程 PID `1672854`,health 返回 `status=ok`。 +- 本阶段未调用任何真实 OpenAI 生成接口,没有新增 AI 消耗。 + +### 视频字幕字号 12 与分镜文字伪影修复 + +完成时间:2026-06-02 17:27 CST + +问题现象: + +- 用户反馈字幕仍很大并超出边框,希望字号改成 12。 +- 本地预览发现画面中的大方框并非 FFmpeg 字幕,而是原始分镜图本身生成了漫画气泡/乱码文字。 + +完成内容: + +- 视频合成时不再直接把 SRT 交给 FFmpeg `subtitles` 样式缩放。 +- 新增 SRT -> ASS 转换,写入 `PlayResX=1080`、`PlayResY=1920`,保证字幕字号按手机竖屏固定生效。 +- ASS 字幕样式固定为 `Noto Sans CJK SC`、`Fontsize=12`、左右边距 180、底部边距 120。 +- 未来角色图和分镜图 prompt 增加强约束:禁止画面内可见文字、字幕、漫画气泡、对话框、乱码方块;台词只通过表情和动作表达。 +- 明确:旧分镜图中的方框已经在图片像素里,重新合成不能移除;需要重新生成分镜图才会消失。 + +修改文件: + +- backend/src/media/media.service.ts +- backend/src/images/images.service.ts +- CODEX_PROGRESS.md + +运行命令: + +- npm run typecheck -w backend +- npm run build -w backend +- npm test -w backend -- media.service.spec.ts images.service.spec.ts +- ffmpeg 生成 ASS 字幕预览帧 +- curl http://127.0.0.1:3000/api/health + +测试结果: + +- 后端类型检查通过。 +- 后端构建通过。 +- 图片/媒体相关单测通过:2 files / 17 tests passed。 +- ASS 字幕字号 12 预览帧生成成功。 +- 后端已重启,新进程 PID `1687323`,health 返回 `status=ok`。 +- 本阶段未调用任何真实 OpenAI 生成接口,没有新增 AI 消耗。 + +下一步建议: + +- 重新点“合成”可获得真正字号 12 的底部字幕。 +- 若要去掉画面里的大方框,需要在新 prompt 规则生效后重新生成分镜图;这会产生新的图片生成成本,应由用户确认后再执行。 + +### 视频无声兼容性修复 + +完成时间:2026-06-02 17:34 CST + +问题现象: + +- 用户反馈最新成片前端预览没有声音。 +- 检测旧成片资产 `185`:文件内存在 AAC 音轨,平均音量正常,但音频参数为 `96000 Hz / mono`,存在 H5/手机播放器兼容风险。 + +完成内容: + +- FFmpeg 成片合成音频输出固定为更通用的 `AAC 48kHz stereo`。 +- 保留 `loudnorm` 音量标准化,避免 TTS 音量过低。 +- 已用现有图片、音频、字幕重新合成第 25 集成片,未调用 OpenAI。 + +修改文件: + +- backend/src/media/media.service.ts +- CODEX_PROGRESS.md + +运行命令: + +- npm run typecheck -w backend +- npm run build -w backend +- POST /api/episodes/25/video/render +- ffprobe 音视频流检测 +- ffmpeg volumedetect 音量检测 +- curl http://127.0.0.1:3000/api/health + +测试结果: + +- 后端类型检查通过。 +- 后端构建通过。 +- 后端已重启,新进程 PID `1699032`,health 返回 `status=ok`。 +- 新成片资产 `186` 生成成功,`ffmpeg_used=true`。 +- 新成片音轨检测通过:`aac`、`48000 Hz`、`stereo`、时长 40 秒。 +- 新成片音量检测通过:平均音量约 `-20.1 dB`,不是静音。 +- 本阶段未调用任何真实 OpenAI 生成接口,没有新增 AI 消耗。 + +下一步建议: + +- 前端点“同步/预览”刷新到最新成片资产 `186` 后再试听。 +- 若旧预览 Blob 仍停留在浏览器缓存里,关闭预览后重新点最新 MP4 的“预览”。 + +### 视频无声二次兼容与自动预览 + +完成时间:2026-06-02 17:41 CST + +问题现象: + +- 用户反馈资产 `186` 在电脑播放仍然没有声音。 +- 复核 `186` 的磁盘文件和接口下载文件:均存在 AAC 音轨,音量非静音,但用户实际播放仍无声。 + +完成内容: + +- FFmpeg 成片音频进一步改为更保守的 `AAC 44.1kHz stereo`。 +- 音轨显式标记为默认音轨,并写入中文语言标记 `chi`。 +- 音频响度从 `I=-16` 调整为 `I=-15` 并叠加 `volume=3dB`,提升电脑播放可感知音量。 +- 用户端“合成视频”完成后会清空旧预览,并自动预览本次接口返回的新视频资产,避免继续播放旧 Blob。 +- 用现有素材重新合成第 25 集,生成新成片资产 `187`,未调用 OpenAI。 + +修改文件: + +- backend/src/media/media.service.ts +- user-app/src/api/client.ts +- user-app/src/pages/index/index.vue +- CODEX_PROGRESS.md + +运行命令: + +- npm run typecheck -w backend +- npm run build -w backend +- npm run typecheck -w user-app +- npm run build -w user-app +- POST /api/episodes/25/video/render +- GET /api/assets/187/download +- ffprobe 音视频流检测 +- ffmpeg volumedetect 音量检测 +- curl http://127.0.0.1:3000/api/health +- curl -I http://127.0.0.1:5174 + +测试结果: + +- 后端类型检查通过。 +- 后端构建通过。 +- 用户端类型检查通过。 +- 用户端构建通过。 +- 后端已重启,新进程 PID `1714706`,health 返回 `status=ok`。 +- 用户端 H5 返回 `200 OK`。 +- 新成片资产 `187` 生成成功,`ffmpeg_used=true`。 +- `187` 接口下载文件检测通过:`aac`、`44100 Hz`、`stereo`、默认音轨、时长 40 秒。 +- `187` 音量检测通过:平均音量约 `-16.2 dB`,最大音量约 `-0.8 dB`,不是静音。 +- 本阶段未调用任何真实 OpenAI 生成接口,没有新增 AI 消耗。 + +下一步建议: + +- 前端重新点最新成片 `187` 的“预览”或“下载”试听;合成按钮后续会自动打开新成片。 +- 如果电脑本地播放器仍无声,请优先用浏览器或 VLC 打开 `video-187.mp4` 交叉验证,因为文件层面已确认音轨存在且可解码。 + +### AI 真人短剧 mock 模式 + +完成时间:2026-06-02 18:26 CST + +背景: + +- 用户明确当前图片漫剧链路不是抖音真人短剧效果,需要新增 `live_action_ai` 输出模式。 +- 本阶段只实现 mock 数据流,不接真实 Runway/Kling/Sora/OpenAI 视频模型,不产生真实 AI 成本。 + +完成内容: + +- `projects` 新增 `output_mode`、`visual_mode`、`video_generation_level`。 +- `storyboard_shots` 新增真人短剧字段:`live_action_desc`、`actor_action`、`camera_instruction`、`performance_instruction`、`video_prompt`、`keyframe_asset_id`、`video_clip_asset_id`、`video_status`。 +- 新增 `actor_profiles` 表,保存真人演员定妆设定。 +- 新增 `video_clips` 表,记录每个分镜的视频片段、Provider、输入关键帧、输出素材、状态和成本。 +- 后端新增 LiveAction 模块: + - `GET/POST /projects/:projectId/live-action/actor-profiles` + - `GET/POST /episodes/:episodeId/live-action/shots` + - `POST /episodes/:episodeId/live-action/keyframes/generate` + - `GET/POST /episodes/:episodeId/live-action/video-clips` + - `POST /episodes/:episodeId/live-action/render` +- 关键帧生成显式使用 `mock-image` Provider。 +- 视频片段生成显式使用 `mock-video` Provider,并生成可预览的 1080x1920 H.264 MP4 mock 片段。 +- 真人短剧合成使用 FFmpeg concat,把视频片段合成为最终 mock MP4。 +- 用户端新建项目增加生成类型选择:图片漫剧版、动态漫画版、AI 真人短剧版。 +- 用户端真人短剧项目新增“AI 真人短剧”面板:演员定妆、真人分镜、关键帧、视频片段、合成。 +- 后台项目详情展示生成类型、演员定妆数量、视频片段数量,并列出 actor profiles / video clips。 +- 操作文档新增“生成类型要先选清楚”和真人短剧 mock 流程说明。 + +新增文件: + +- backend/prisma/migrations/20260602095000_live_action_ai_mode/migration.sql +- backend/src/live-action/live-action.controller.ts +- backend/src/live-action/live-action.dto.ts +- backend/src/live-action/live-action.module.ts +- backend/src/live-action/live-action.service.ts +- backend/src/live-action/live-action.types.ts + +修改文件: + +- backend/prisma/schema.prisma +- backend/src/app.module.ts +- backend/src/admin/admin.service.ts +- backend/src/projects/project.dto.ts +- backend/src/projects/project.types.ts +- backend/src/projects/projects.service.ts +- backend/src/*/*.spec.ts 相关 Project/StoryboardShot 测试工厂 +- user-app/src/api/client.ts +- user-app/src/pages/index/index.vue +- user-app/src/workflow.ts +- admin/src/App.vue +- OPERATION_GUIDE.md +- CODEX_PROGRESS.md + +运行命令: + +- DATABASE_URL=... npm run db:validate +- npm run db:generate +- DATABASE_URL=... npm run db:deploy +- npm run typecheck -w backend +- npm run typecheck -w user-app +- npm run typecheck -w admin +- npm run build -w backend +- npm run build -w user-app +- npm run build -w admin +- curl http://127.0.0.1:3000/api/health +- live_action_ai 最小项目 API 烟测 +- ffprobe mock video clip / final live-action mock MP4 + +测试结果: + +- Prisma schema validate 通过。 +- Prisma migration deploy 通过,已应用 `20260602095000_live_action_ai_mode`。 +- 后端类型检查通过。 +- 用户端类型检查通过。 +- 后台类型检查通过。 +- 后端构建通过。 +- 用户端构建通过。 +- 后台构建通过。 +- 后端已重启,新进程 PID `1797918`,health 返回 `status=ok`。 +- API 烟测创建 `live_action_ai` 项目 `36`,插入最小角色/分集/分镜数据后跑通: + - actor profile 1 条 + - live action shot 1 条 + - keyframe asset `188` + - video clip asset `189` + - final mock video asset `190` +- `ffprobe` 确认 `189` / `190` 均为 1080x1920、4 秒 H.264 MP4。 +- Provider 日志确认仅调用 `ImageProvider/mock-image` 与 `VideoProvider/mock-video`,`cost_actual=0`。 +- 本阶段未调用任何真实 OpenAI/视频生成接口,没有新增 AI 消耗。 + +遗留问题: + +- `live_action_ai` 当前仍是 mock 视频片段,不是真人会动的真实 AI 视频。 +- 动态漫画版 `motion_comic` 仅预留入口,尚未实现局部动效链路。 +- 真人短剧最终音频/口型/BGM/字幕还未并入 live-action render,本阶段只拼接视频片段。 +- 真实视频 Provider 已完成可替换驱动和受控入口;真实生成仍需配置并显式确认后才会调用。 + +下一步建议: + +- 先用用户端新建 `AI 真人短剧版` 项目熟悉 mock 流程。 +- mock 流程确认后,可在后台启用 Runway/Kling 等真实视频 Provider 做受控小样。 + +### 真实可替换 VideoProvider / 成本预估 / 重试 / 片段质检 + +完成时间:2026-06-02 + +完成内容: + +- 新增可替换 `VideoProvider` 预设:`runway-image-to-video`、`kling-image-to-video`。 +- 两个真实视频 Provider 默认 `is_enabled=false`,不会因为配置 Key 或初始化而自动扣费。 +- 后台新增“初始化视频接入”按钮,可写入 Runway/Kling 配置;真实视频 Provider 后台测试仍禁用。 +- Provider 服务新增 `runway_image_to_video` / `kling_image_to_video` 驱动: + - 创建 image-to-video 任务。 + - 轮询任务状态。 + - 提取视频下载 URL。 + - 下载 MP4/WebM/MOV 二进制并交给业务层落库。 + - Provider 日志会打码 data URI/base64 大字段,避免图片原文写入日志。 +- `VideoProvider` 成本规则支持 `unit=video_seconds`,可按 `price_per_second`、`price_per_clip` 做估算和成本阈值拦截。 +- 真人短剧用户端新增: + - 视频 Provider 选择。 + - 成本估算。 + - 单片段成本上限。 + - 真实视频生成确认勾选。 + - 强制重生成。 + - 片段重试。 + - 片段质检。 +- 真人短剧后端新增接口: + - `GET /api/live-action/video-providers` + - `GET /api/episodes/:episodeId/live-action/video-clips/cost-estimate` + - `POST /api/live-action/video-clips/:clipId/retry` + - `POST /api/live-action/video-clips/:clipId/quality-check` +- `video_clips` 新增质检字段:`quality_status`、`quality_score`、`quality_issues`。 +- 真实视频 Provider 未带 `confirm_real_video=true` 时直接返回 `REAL_VIDEO_GENERATION_CONFIRMATION_REQUIRED`,不会发起外部 API 调用。 +- 后台项目详情展示视频片段成本和质检结果。 + +修改文件: + +- backend/prisma/schema.prisma +- backend/src/providers/provider.types.ts +- backend/src/providers/providers.service.ts +- backend/src/providers/providers.controller.ts +- backend/src/live-action/live-action.dto.ts +- backend/src/live-action/live-action.controller.ts +- backend/src/live-action/live-action.service.ts +- backend/src/live-action/live-action.types.ts +- backend/src/admin/admin.service.ts +- admin/src/App.vue +- user-app/src/api/client.ts +- user-app/src/pages/index/index.vue +- user-app/src/styles.css +- CODEX_PROGRESS.md + +新增文件: + +- backend/prisma/migrations/20260602112000_real_video_provider_qc/migration.sql + +运行命令: + +- `set -a; source .env; set +a; npm run db:validate` +- `set -a; source .env; set +a; npm run db:deploy` +- `set -a; source .env; set +a; npm run db:generate` +- `npm run typecheck -w backend` +- `npm run typecheck -w user-app` +- `npm run typecheck -w admin` +- `npm run build -w backend` +- `npm run build -w user-app` +- `npm run build -w admin` +- 后端重启:`setsid node /www/wwwroot/ai/backend/dist/main.js ...` +- `curl -sS --max-time 5 http://127.0.0.1:3000/api/health` +- 后端重启:`setsid node /www/wwwroot/ai/backend/dist/main.js ...` +- `curl -sS --max-time 5 http://127.0.0.1:3000/api/health` +- `curl http://127.0.0.1:3000/api/health` +- live-action mock-only API 烟测 +- `ffprobe` 检查 mock MP4 + +测试结果: + +- Prisma schema validate 通过。 +- Prisma migration deploy 通过,已应用 `20260602112000_real_video_provider_qc`。 +- 后端、用户端、后台 typecheck 全部通过。 +- 后端、用户端、后台 build 全部通过。 +- 后端已重启,新进程 PID `1949213`,health 返回 `status=ok`。 +- 安全烟测结果: + - `GET /live-action/video-providers` 返回启用的 `mock-video`。 + - `GET /episodes/28/live-action/video-clips/cost-estimate?provider_code=mock-video` 返回 `estimated_cost=0`。 + - 未确认真实 Runway 生成返回 `400 REAL_VIDEO_GENERATION_CONFIRMATION_REQUIRED`。 + - mock 片段生成成功,新增 video_clip `2`、asset `191`,`cost_actual=0`。 + - mock 质检成功,`quality_status=passed`,`quality_score=94`。 + - Provider 日志仅新增 `VideoProvider/mock-video` 与 `QualityCheckProvider/mock-qc`,未调用 Runway/Kling/OpenAI 真实视频接口。 + - `ffprobe` 确认 asset `191` 为 H.264、1080x1920、4 秒 MP4。 + +遗留问题: + +- Runway/Kling 真实付费小样尚未开启验证;需要你确认 Provider、填写 Key、设置价格和成本阈值后再跑。 +- 真实视频要求关键帧为 PNG/JPG/WebP;当前真人关键帧 mock 是 SVG,真实视频小样前需要用真实图片 Provider 产出栅格关键帧或提供外部参考图。 +- 真人短剧最终合成仍是拼接视频片段,音频、口型、字幕、BGM 和音效还未并入 live-action 成片。 + +下一步建议: + +- 后台 AI 接入页初始化视频接入后,只启用一个视频 Provider,先填 `price_per_second` 和每日成本上限。 +- 用 1 个镜头做真实 image-to-video 小样;确认质量、耗时和账单后再扩大到整集。 + +### 国内可替换 VideoProvider:Hailuo / Wan / Vidu / Seedance + +完成时间:2026-06-02 + +完成内容: + +- 新增通用 `configurable_image_to_video` 驱动,支持可配置异步图生视频流程: + - 创建视频任务。 + - 轮询任务状态。 + - 从任务结果提取视频 URL。 + - 供应商只返回 `file_id` 时,可通过 `output_url_endpoint_template` 再取下载链接。 + - 下载真实视频二进制后交给业务层落私有资产。 +- 新增国内/短剧向视频 Provider 预设,全部默认禁用: + - `minimax_hailuo_23_fast` + - `minimax_hailuo_23` + - `alibaba_wan26_i2v_flash` + - `alibaba_wan26_i2v` + - `vidu_q3_turbo_reference` + - `vidu_q3_pro` + - `jimeng_seedance` +- `bootstrap-video` 现在会写入 9 个视频 Provider:上述 7 个国内/短剧向 Provider,加 Runway、Kling。 +- 重复初始化视频 Provider 时会继续保留已配置的 API Key、Base URL、超时、endpoint、body_style、headers、成本阈值等运行字段,避免覆盖运营配置。 +- 后台 AI 接入页补充“可配置图生视频”中文驱动名和 Hailuo/Wan/Vidu/Seedance 说明。 +- 用户端真人短剧生成保护增强: + - 指定真实 Provider 但 Provider 未启用时返回 `LIVE_ACTION_VIDEO_PROVIDER_DISABLED`。 + - 指定真实 Provider 且未确认时返回 `REAL_VIDEO_GENERATION_CONFIRMATION_REQUIRED`。 + - 不再因为已有旧 mock 片段就绕过真实 Provider 守卫。 +- 小白手册补充真实视频小样建议:先测 MiniMax Hailuo 2.3 Fast,再对比 Wan、Vidu、Seedance、Kling/Runway。 + +修改文件: + +- backend/src/providers/provider.types.ts +- backend/src/providers/providers.service.ts +- backend/src/live-action/live-action.service.ts +- admin/src/App.vue +- README.md +- OPERATION_GUIDE.md +- CODEX_PROGRESS.md + +新增文件: + +- backend/prisma/migrations/20260602130000_domestic_video_providers/migration.sql + +运行命令: + +- `set -a; source .env; set +a; npm run db:validate` +- `set -a; source .env; set +a; npm run db:deploy` +- `set -a; source .env; set +a; npm run db:generate` +- `npm run typecheck` +- `npm test` +- `npm run build` +- `curl http://127.0.0.1:3000/api/health` +- admin 登录后接口烟测:`bootstrap-video`、Provider 列表、用户端 Provider 列表、成本估算、禁用真实 Provider 保护、Provider 日志查询 + +测试结果: + +- Prisma schema validate 通过。 +- Prisma migration deploy 通过,已应用 `20260602130000_domestic_video_providers`。 +- Prisma Client generate 通过。 +- 全量 typecheck 通过:backend、admin、user-app、workers。 +- 全量 test 通过:backend 22 个测试文件 145 个测试通过;workers 1 个测试通过;admin/user-app 暂无测试文件并以 passWithNoTests 通过。 +- 全量 build 通过:backend、admin、user-app、workers。 +- 后端已重启,新进程 PID `1989678`,health 返回 `status=ok`。 +- `bootstrap-video` 返回 9 个视频 Provider。 +- 后台 VideoProvider 总数为 11,国内 7 个 Provider 均存在,`is_enabled=false`,driver 均为 `configurable_image_to_video`。 +- 用户端 `/live-action/video-providers` 仍只返回启用的 `mock-video`,不会把未启用真实 Provider 暴露给用户。 +- MiniMax Hailuo 2.3 Fast 成本估算接口可返回预估:第 28 集当前 1 个片段、4 秒、估算 0.1268 USD;Provider 仍为 disabled。 +- 使用禁用的 `minimax_hailuo_23_fast` 强制生成会返回 `400 LIVE_ACTION_VIDEO_PROVIDER_DISABLED`,不会回落 mock,也不会调用外部接口。 +- Provider 日志确认本次无新增真实国内视频调用;最近视频日志只有 mock 和旧的 OpenAI 视频失败记录,成本为 0。 + +遗留问题: + +- 国内 Provider 的真实付费小样尚未执行;需要你确认使用哪家、填 Key、启用 Provider、设置价格和成本阈值后,先跑 1 个镜头。 +- 阿里 Wan、Seedance/即梦不同开通渠道和网关字段差异较大,当前作为可配置模板;正式启用前必须用 1 个镜头核对请求字段、返回 URL、耗时和账单。 +- 真实视频仍要求关键帧为 PNG/JPG/WebP 或外部可访问 URL;mock SVG 关键帧不能直接作为真实图生视频输入。 + +下一步建议: + +- 后台只启用 `minimax_hailuo_23_fast`,填 Key、Base URL、单次/每日成本上限和真实单价,用 1 个镜头做真实小样。 +- 小样验收维度:人物一致性、面部表情、动作自然度、镜头语言、中文短剧感、生成耗时、失败率、实际扣费。 + +### 系统 B 文档升级:V3 真人动态视频版 + +完成时间:2026-06-02 + +完成内容: + +- 系统 B 文档从 V2「真人照片 -> 写真图集 / 图片纪念视频」升级为 V3「真人动态视频版」。 +- 新增 V3 升级说明,明确系统 B 不推翻旧设计,而是在原照片、主题、世界、场景、视频合成、隐私授权基础上新增真人动态视频链路。 +- 新增 V3 主需求文档,明确 4 档输出模式: + - 高清写真图集。 + - 图片纪念视频。 + - 动态写真视频。 + - AI 真人动态视频。 + - 高端真人纪念片作为人工报价和多轮精修套餐。 +- 明确系统 B 和系统 A 的核心差异: + - 系统 A 是虚构小说角色转真人短剧。 + - 系统 B 是真实用户照片转真人动态纪念视频,更强调本人相似度、肖像权、隐私、未成年人和公开授权。 +- 主需求、功能清单、技术架构、数据库、API、用户端、后台、AI 流水线、Prompt、成本、队列、合规、测试和 Codex 拆解均补充 V3 增量。 +- 新增或强化设计对象: + - `IdentityAnchor` + - `MotionTemplate` + - `VideoClip` + - `LipSyncTask` + - `FaceIdentityProvider` + - `FaceConsistencyProvider` + - `MotionPortraitProvider` + - `LipSyncProvider` +- 明确国内短剧向 VideoProvider 策略:MiniMax Hailuo、阿里 Wan、Vidu、Seedance/即梦、Kling、Runway、MockVideoProvider。 +- 明确真实视频 Provider 默认禁用,必须成本预估、用户确认、后台启用、阈值保护后才能调用;失败不能回落 mock 假成功。 +- README 和 manifest 已更新为 V3 文档包入口。 + +修改文件: + +- docs/system_b/02_需求文档修改v2.md +- docs/system_b/03_功能清单_页面清单_状态流转设计.md +- docs/system_b/04_技术架构设计_模块拆分.md +- docs/system_b/05_数据库表结构设计.md +- docs/system_b/06_API接口设计文档.md +- docs/system_b/07_uniapp用户端页面交互文档.md +- docs/system_b/08_GeekerAdmin后台管理设计.md +- docs/system_b/09_AI生成流水线_Provider抽象设计.md +- docs/system_b/10_Prompt模板_世界观模板规范.md +- docs/system_b/11_订单支付_额度_成本控制设计.md +- docs/system_b/12_任务队列_错误重试_稳定性设计.md +- docs/system_b/13_隐私授权_内容审核_合规设计.md +- docs/system_b/15_测试用例_验收标准.md +- docs/system_b/16_Codex开发任务拆解文档.md +- docs/system_b/README.md +- docs/system_b/manifest.json +- CODEX_PROGRESS.md + +新增文件: + +- docs/system_b/00_系统B升级说明_真人动态视频.md +- docs/system_b/01_系统B总需求文档_v3_真人动态视频版.md + +运行命令: + +- 读取用户粘贴的系统 B 升级建议。 +- `find docs -maxdepth 3 -type f` +- `rg` 检索系统 B、VideoProvider、真人动态视频相关文档。 +- `sed` 检查系统 B README、主需求、AI 流水线、数据库、合规、测试、拆解等文档。 + +测试结果: + +- 文档更新完成,无代码改动。 +- 未运行构建和单测。 + +下一步建议: + +- 按 V3 文档先做系统 B 独立开发计划,优先落地:输出模式、照片上传要求、身份锚点、本人相似度质检、动态写真 mock、真实视频单镜头小样。 + +### 后台新增 AI 平台入口页 + +完成时间:2026-06-02 + +完成内容: + +- 后台新增“AI 平台入口”菜单页,方便运营集中查看和开户注册各大 AI 平台。 +- 页面整理了已接入或预留接入的平台: + - OpenAI + - MiniMax / Hailuo + - 阿里云百炼 / DashScope + - Vidu + - 火山方舟 / Seedance + - Runway + - Kling + - 内部 Mock / 本地 FFmpeg +- 每个平台展示: + - 官网。 + - 控制台/注册入口。 + - 文档入口。 + - 对应 Provider 编码。 + - 后台应填写的 Key 名称。 + - 接入状态。 + - 费用提醒。 + - 运营备注。 +- 页面提示运营顺序:先注册/主体认证/充值,再回“AI 接入”页保存 Key;真实视频先做单镜头小样,避免误扣费。 +- Kling 标注为“渠道待核”,避免运营误以为接口已完全确认。 + +修改文件: + +- admin/src/App.vue +- admin/src/styles.css +- CODEX_PROGRESS.md + +运行命令: + +- 官方入口检索:OpenAI、MiniMax、阿里百炼/DashScope、Vidu、Runway、火山方舟/Seedance、Kling。 +- `npm run typecheck -w admin` +- `npm run build -w admin` +- `curl -I http://127.0.0.1:5175` + +测试结果: + +- 后台 typecheck 通过。 +- 后台 build 通过。 +- 后台 dev 服务仍在 5175 运行,HTTP 200。 +- 本次未调用任何真实 AI Provider,没有产生 AI 成本。 + +### 后台 AI 平台入口接入配置跳转 + +完成时间:2026-06-02 + +完成内容: + +- “AI 平台入口”页新增 Hailuo 快速配置卡片,直接说明 MiniMax / Hailuo 的注册、Key 获取、后台配置路径和推荐默认值。 +- 平台入口清单新增“后台配置”列,展示每个平台对应 Provider 的配置状态: + - 未初始化。 + - 待配置 Key,未启用。 + - 已保存 Key,未启用。 + - 已配置并启用。 +- 点击“去配置”会自动进入“AI 接入”页并展开高级配置。 +- 如果视频 Provider 还没初始化,点击“去配置”会先初始化视频 Provider,再打开对应配置表单。 +- Hailuo 默认打开 `minimax_hailuo_23_fast`,便于运营先做低成本单镜头小样。 +- 初始化视频 Provider 的提示文案改为“视频 Provider 已初始化,真实接入默认未启用”,避免误解只支持 Runway/Kling。 +- 本次只做配置入口和后台 UI 优化,不调用真实 AI,不产生 AI 成本。 + +修改文件: + +- admin/src/App.vue +- admin/src/styles.css +- CODEX_PROGRESS.md + +运行命令: + +- `npm run typecheck -w admin` +- `npm run build -w admin` +- `curl -I --max-time 5 http://127.0.0.1:5175` +- `git status --short` + +测试结果: + +- 后台 typecheck 通过。 +- 后台 build 通过。 +- 后台 dev 服务仍在 5175 运行,HTTP 200。 +- 当前目录不是 git 仓库,`git status --short` 返回 `fatal: not a git repository`。 + +### 全局角色资产库 / 跨项目角色复用 + +完成时间:2026-06-03 + +完成内容: + +- 新增全局角色资产库,用于沉淀可跨项目复用的主角、配角、反派和声音/服装配置。 +- 新增数据库表: + - `global_characters` + - `global_character_assets` +- `characters` 表新增复用字段: + - `global_character_id` + - `wardrobe_variant` + - `voice_provider_code` + - `voice_model` + - `voice_id` + - `voice_style` + - `performance_style` +- 后端新增后台接口: + - `GET /api/admin/global-characters` + - `POST /api/admin/global-characters` + - `PATCH /api/admin/global-characters/:globalCharacterId` + - `POST /api/admin/characters/:characterId/bind-global` +- 后台新增“角色资产库”菜单页: + - 可创建/编辑全局角色。 + - 可配置锚点素材 ID、固定外观、默认服装、声音 Provider、Voice ID、声音风格、表演风格和授权范围。 + - 可查看全局角色被多少项目角色绑定。 +- 后台“角色资源”页新增: + - 全局角色绑定显示。 + - 下拉选择全局角色并绑定/解绑。 + - 角色详情预览展示全局角色、声音、服装变体和表演风格。 +- 项目角色创建/更新支持绑定 `global_character_id`。 +- 绑定全局角色时,如果项目角色缺少锚点、声音或默认服装,会自动带入全局角色资产;项目角色已有差异化设置不强行覆盖。 +- 图片 Prompt、分镜 Prompt、真人演员定妆 Prompt 增加全局角色 ID、服装变体、角色声音/表演风格提示,给后续真实图片/视频 Provider 做一致性约束。 +- 用户端 `SafeCharacter` 类型同步新增全局角色、服装和声音字段。 +- `OPERATION_GUIDE.md` 新增“全局角色资产库”小白说明。 +- 已创建一个内部测试全局角色资产:`内部测试女主模板`,用于后台页面联调,不调用 AI、不产生 AI 成本。 + +修改文件: + +- backend/prisma/schema.prisma +- backend/prisma/migrations/20260603093000_global_character_library/migration.sql +- backend/src/admin/admin.controller.ts +- backend/src/admin/admin.dto.ts +- backend/src/admin/admin.service.ts +- backend/src/characters/character.dto.ts +- backend/src/characters/character.types.ts +- backend/src/characters/characters.service.ts +- backend/src/images/images.service.ts +- backend/src/scripts/scripts.service.ts +- backend/src/live-action/live-action.service.ts +- backend/src/*/*.spec.ts 相关测试夹具 +- admin/src/App.vue +- admin/src/styles.css +- user-app/src/api/client.ts +- OPERATION_GUIDE.md +- CODEX_PROGRESS.md + +运行命令: + +- `set -a; . ./.env; set +a; npm run prisma:validate -w backend` +- `set -a; . ./.env; set +a; npm run prisma:generate -w backend` +- `npm run typecheck -w backend` +- `npm run typecheck -w admin` +- `npm run typecheck -w user-app` +- `set -a; . ./.env; set +a; npm run prisma:deploy -w backend` +- `npm test -w backend` +- `npm run build -w admin` +- `npm run build -w backend` +- `npm run build -w user-app` +- 后端重启:`setsid node /www/wwwroot/ai/backend/dist/main.js ...` +- `curl -sS --max-time 5 http://127.0.0.1:3000/api/health` +- Node 脚本真实联调后台登录、创建/更新/读取全局角色资产。 + +测试结果: + +- Prisma schema validate 通过。 +- Prisma Client generate 通过。 +- 数据库迁移已成功应用。 +- 后端 typecheck 通过。 +- 后台 typecheck 通过。 +- 用户端 typecheck 通过。 +- 后端测试 22 个测试文件、145 个测试全部通过。 +- 后台 build 通过。 +- 后端 build 通过。 +- 用户端 build 通过。 +- 后端已重启,新 PID:4072726。 +- 后端健康接口正常。 +- 后台 dev 服务仍在 5175 运行,HTTP 200。 +- 本次未调用任何真实 AI Provider,没有产生 AI 成本。 + +遗留问题: + +- TTS 当前仍是单集音频生成接口为主,已经有角色级声音数据基础;下一步需要把脚本对白拆成按角色声线合成,再混音成最终音轨。 +- 全局角色资产的图片/声音上传和预览目前依赖素材 ID;后续应加“从素材库选择/上传”的弹窗。 +- 全局角色版本管理、角色授权到期提醒、批量换装/换声线还未做。 + +下一步建议: + +- 进入“角色级 TTS / 多角色对白混音”阶段:按角色 voice_id 生成对白,旁白独立声线,最后混音并与字幕时间轴对齐。 + +### 角色级 TTS / 多角色对白混音 + +完成时间:2026-06-03 + +完成内容: + +- `POST /api/episodes/:episodeId/audio/generate` 默认升级为 `dialogue_mode: mixed` 多角色音频模式。 +- 音频生成会按已确认分镜顺序拆分: + - 分镜旁白片段。 + - 分镜对白片段。 + - 支持识别 `角色名:台词` 格式。 + - 无角色名前缀时,从分镜 `characters_json` 推断角色;再兜底到主角/领衔角色。 +- 角色对白优先使用项目角色/全局角色里的声音字段: + - `voice_provider_code` + - `voice_model` + - `voice_id` + - `voice_style` +- 旁白使用 `narration_voice` 或默认 `coral`。 +- 每个片段独立调用 `VoiceProvider`,真实 TTS 会产生多次调用成本;任务 input_json 会记录片段摘要、角色、voice 和 speaker。 +- 多个 TTS 片段会用 FFmpeg 转码并 concat 成整集 `dialogue-mix.wav`。 +- FFmpeg 不可用且全是 mock 片段时,会兜底生成静音 mock wav;真实片段混音必须有 FFmpeg。 +- 保留旧单段旁白模式:请求体传 `{ "dialogue_mode": "narration" }`。 +- 用户端“音频字幕”按钮改为“多角色音频”,并显式传 `dialogue_mode: mixed`。 +- `OPERATION_GUIDE.md` 增加多角色音频说明和真实 TTS 成本提醒。 + +修改文件: + +- backend/src/media/media.dto.ts +- backend/src/media/media.service.ts +- backend/src/media/media.service.spec.ts +- user-app/src/api/client.ts +- user-app/src/pages/index/index.vue +- OPERATION_GUIDE.md +- CODEX_PROGRESS.md + +运行命令: + +- `npm run typecheck -w backend` +- `npm run typecheck -w user-app` +- `npm run typecheck -w admin` +- `npm test -w backend -- src/media/media.service.spec.ts` +- `npm test -w backend` +- `npm run build -w backend` +- `npm run build -w user-app` +- `npm run build -w admin` +- 后端重启:`setsid node /www/wwwroot/ai/backend/dist/main.js ...` +- `curl -sS --max-time 5 http://127.0.0.1:3000/api/health` + +测试结果: + +- 媒体服务单测 10 个全部通过,覆盖默认多角色混音路径。 +- 后端测试 22 个测试文件、145 个测试全部通过。 +- 后端 typecheck 通过。 +- 用户端 typecheck 通过。 +- 后台 typecheck 通过。 +- 后端 build 通过。 +- 用户端 build 通过。 +- 后台 build 通过。 +- 后端已重启,新 PID:4090999。 +- 3000、5174、5175 端口均在监听,健康接口正常。 +- 本次未调用任何真实 AI Provider,没有产生 AI 成本。 + +遗留问题: + +- 当前混音是按片段顺序 concat,尚未按镜头时间轴精确对齐到每个分镜起止时间。 +- 字幕仍按镜头生成,还未拆到每句对白级时间码。 +- 后台还需要增加角色声音试听、voice_id 校验、真实 TTS 成本预估和片段级失败重试 UI。 + +下一步建议: + +- 继续做“对白级字幕 / 音频时间轴对齐”:生成每句台词的 SRT cue,并让音频片段按分镜时间轴铺到对应位置。 + +### 对白级字幕 / 音频时间轴对齐 + +完成时间:2026-06-03 + +完成内容: + +- `POST /api/episodes/:episodeId/audio/generate` 的多角色音频片段新增时间轴字段: + - `start_seconds` + - `end_seconds` + - `target_duration` +- 多角色 TTS 不再只按顺序 concat;现在会用 FFmpeg 按每段 `start_seconds` 延迟铺轨,输出一条与分镜时间轴对齐的整集 WAV。 +- 音频接口返回 `timeline_warnings`,真实 TTS 超出分配时长时会标出超长片段,方便运营缩短台词、加长镜头或调语速。 +- `POST /api/episodes/:episodeId/subtitle/generate` 默认升级为 `subtitle_mode: dialogue`。 +- 对白级字幕会把每句旁白/台词生成独立 SRT cue,并与多角色音频共用同一套分镜时间轴。 +- 保留旧版分镜级字幕:请求体传 `{ "subtitle_mode": "shot" }`。 +- 用户端“图片、音频和视频”区域文案更新为“多角色音频 / 对白级字幕”。 +- `OPERATION_GUIDE.md` 更新小白说明:解释多角色音频、对白级字幕、时间轴告警和旧版字幕参数。 + +修改文件: + +- backend/src/media/media.dto.ts +- backend/src/media/media.types.ts +- backend/src/media/media.service.ts +- backend/src/media/media.service.spec.ts +- user-app/src/api/client.ts +- user-app/src/pages/index/index.vue +- OPERATION_GUIDE.md +- CODEX_PROGRESS.md + +运行命令: + +- `npm run typecheck -w backend` +- `npm run typecheck -w user-app` +- `npm run typecheck -w admin` +- `npm test -w backend -- src/media/media.service.spec.ts` +- `npm test -w backend` +- `npm run build -w backend` +- `npm run build -w user-app` +- `npm run build -w admin` +- 后端重启:`setsid node /www/wwwroot/ai/backend/dist/main.js ...` +- `curl -sS --max-time 5 http://127.0.0.1:3000/api/health` + +测试结果: + +- 后端 typecheck 通过。 +- 用户端 typecheck 通过。 +- 后台 typecheck 通过。 +- 媒体服务单测 11 个全部通过,覆盖默认多角色音频时间轴和默认对白级字幕。 +- 后端测试 22 个测试文件、146 个测试全部通过。 +- 后端 build 通过。 +- 用户端 build 通过。 +- 后台 build 通过。 +- 后端已重启,新 PID:4109378。 +- 3000、5174、5175 端口均在监听,健康接口正常。 +- 本阶段未调用真实 AI Provider,没有产生 AI 成本。 + +遗留问题: + +- 尚未做前端逐句字幕/音频片段预览和单句重试 UI。 +- 真实 TTS 语速控制仍依赖 Provider 能力;后续需要把“超时长台词”在前端明显提示出来。 +- 视频最终仍是分镜图 + 音频 + 字幕的 FFmpeg 合成,不是人物真实动态视频。 + +下一步建议: + +- 继续补片段级运营闭环:逐句音频试听、逐句字幕预览、单句重试、超时长台词红色提示,以及真实 TTS 成本预估。 + +### 片段级音频字幕预览 / 超时提示 / TTS 成本提示 + +完成时间:2026-06-03 + +完成内容: + +- `GET /api/episodes/:episodeId/media-assets` 保留旧字段: + - `task_type` + - `task_id` + - `asset` +- 同时新增任务详情和媒体摘要: + - `task` + - `timeline` + - `stats` +- 音频资产行会返回: + - 多角色音频 `segments` + - `timeline_warnings` + - 片段数、声音数、总字符数、总时长、告警数量 + - TTS 成本提示:按字符/Provider usage 估算,最终以 Provider 日志和平台账单为准 +- 字幕资产行会读取私有 SRT 文件并返回 `cues`,用于前端展开预览。 +- 多角色音频任务成功后会把实际片段时长、mock 标记、超时告警写回 `render_tasks.input_json`,刷新页面后仍可查看。 +- 用户端“图片、音频和视频”新增“音频字幕时间轴”面板: + - 展示每句旁白/对白。 + - 展示镜头号、说话人、起止秒、目标时长、实际 TTS 时长、voice。 + - 超时句子红色边框提示。 + - 支持试听整集音频、下载字幕。 + - 支持展开查看前 20 条字幕 cue。 +- 工作流任务中文标签从“旁白音频”改为“多角色音频”。 +- `OPERATION_GUIDE.md` 补充时间轴面板、超时处理和成本提示说明。 + +修改文件: + +- backend/src/media/media.service.ts +- backend/src/media/media.service.spec.ts +- user-app/src/api/client.ts +- user-app/src/pages/index/index.vue +- user-app/src/styles.css +- user-app/src/workflow.ts +- OPERATION_GUIDE.md +- CODEX_PROGRESS.md + +运行命令: + +- `npm run typecheck -w backend` +- `npm run typecheck -w user-app` +- `npm run typecheck -w admin` +- `npm test -w backend -- src/media/media.service.spec.ts` +- `npm test -w backend` +- `npm run build -w backend` +- `npm run build -w user-app` +- `npm run build -w admin` + +测试结果: + +- 后端 typecheck 通过。 +- 用户端 typecheck 通过。 +- 后台 typecheck 通过。 +- 媒体服务单测 12 个全部通过,覆盖 media-assets 时间轴返回。 +- 后端测试 22 个测试文件、147 个测试全部通过。 +- 后端 build 通过。 +- 用户端 build 通过。 +- 后台 build 通过。 +- 后端已重启,新 PID:4144718。 +- 3000、5174、5175 端口均在监听,健康接口正常。 +- 本阶段未调用真实 AI Provider,没有产生 AI 成本。 + +遗留问题: + +- 逐句“只重试这一句 TTS”还未做,当前是先展示和定位问题。 +- 逐句音频裁切试听还未做,当前试听的是整集音频。 +- 成本提示是字符级/Provider usage 提醒,还不是根据具体 Provider 价格换算成美元。 + +下一步建议: + +- 继续做单句 TTS 重试:选择某句、指定 voice/语速、重新合成该句并重新铺轨;同时把真实 Provider 价格规则接入前端美元预估。 + +### 单句 TTS 重试 / 片段文件保存 + +完成时间:2026-06-03 + +完成内容: + +- 新增接口 `POST /api/episodes/:episodeId/audio/segments/:segmentIndex/retry`。 +- 单句重试支持参数: + - `voice` + - `voice_style` + - `speech_speed`,范围 0.6 到 1.4 +- 多角色音频生成时会把每句 TTS 的原始音频片段单独保存到私有存储 `generated-audio-segments`。 +- `render_tasks.input_json.segment_results` 会记录每句片段的: + - 实际时长 + - mock 标记 + - MIME + - 私有片段路径 + - hash/size +- 单句重试时,后端只调用一次 `VoiceProvider` 生成目标句;其它句从私有片段文件读取,然后重新按时间轴混成整集音频。 +- 单句重试会创建新的整集音频资产,旧音频资产保留;后续合成视频会使用最新音频。 +- 老版本音频缺少片段文件时,不会偷偷整集重跑;接口会提示先重生成整集多角色音频一次。 +- 用户端时间轴每句新增“重试此句”按钮。 +- 点开后可填写声音 ID、语速、语气说明。 +- 如果当前音频不支持单句重试,用户端会显示“重生成音频”按钮和费用提醒。 +- 错误文案补充中文提示,避免运营看到英文异常。 +- `OPERATION_GUIDE.md` 补充单句重试说明。 + +修改文件: + +- backend/src/media/media.dto.ts +- backend/src/media/media.controller.ts +- backend/src/media/media.service.ts +- backend/src/media/media.service.spec.ts +- user-app/src/api/client.ts +- user-app/src/pages/index/index.vue +- user-app/src/styles.css +- OPERATION_GUIDE.md +- CODEX_PROGRESS.md + +运行命令: + +- `npm run typecheck -w backend` +- `npm run typecheck -w user-app` +- `npm run typecheck -w admin` +- `npm test -w backend -- src/media/media.service.spec.ts` +- `npm test -w backend` +- `npm run build -w backend` +- `npm run build -w user-app` +- `npm run build -w admin` + +测试结果: + +- 后端 typecheck 通过。 +- 用户端 typecheck 通过。 +- 后台 typecheck 通过。 +- 媒体服务单测 13 个全部通过,覆盖单句 TTS 重试只调用一次 VoiceProvider。 +- 后端测试 22 个测试文件、148 个测试全部通过。 +- 后端 build 通过。 +- 用户端 build 通过。 +- 后台 build 通过。 +- 后端已重启,新 PID:4174180。 +- 3000、5174、5175 端口均在监听,健康接口正常。 +- 本阶段未调用真实 AI Provider,没有产生 AI 成本。 + +遗留问题: + +- 单句重试后字幕文本未改动;如果要改台词内容,需要后续做“改句文本 + 重写 SRT cue”。 +- 逐句音频裁切试听还未做,当前仍试听整集音频。 +- 真实 Provider 价格规则还未换算成前端美元预估。 + +下一步建议: + +- 继续做“单句文本编辑 + 字幕 cue 更新 + 单句音频试听裁切”,让运营能在一个抽屉里完成台词、字幕、声音的一句级修正。 + +### 单句 TTS 连续重试片段元数据保留 + +完成时间:2026-06-03 16:40:34 CST + +完成内容: + +- 修复深度验收发现的问题:单句 TTS 重试后,新音频任务只保留被重试片段的 `segment_file_path`,其它复用片段的文件路径丢失,导致无法稳定继续做第二次单句重试。 +- `loadStoredAudioSegmentFiles` 读取旧片段时,现在会带回原始 `filePath`、`size` 和 `hash`。 +- `readTaskAudioSegmentResults` 新增读取 `segment_size` 和 `segment_hash`。 +- 单句重试后写回新的 `render_tasks.input_json.segment_results` 时,所有片段都会保留私有片段文件路径。 +- 媒体服务单测补充断言:重试后的 `segment_results` 每个片段都必须有 `segment_file_path`,并保留旧片段 size/hash。 + +修改文件: + +- backend/src/media/media.service.ts +- backend/src/media/media.service.spec.ts +- CODEX_PROGRESS.md + +新增文件: + +- 无 + +运行命令: + +- `git status --short` +- `npm test -w backend -- src/media/media.service.spec.ts` +- `npm run lint` +- `npm run typecheck` +- `npm test` +- `npm run build` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- 媒体服务单测 13 个全部通过。 +- `npm run lint` 通过。 +- `npm run typecheck` 通过。 +- `npm test` 通过:后端 22 个测试文件、148 个测试通过;workers 1 个测试通过;admin/user-app 当前无测试文件并以 `passWithNoTests` 通过。 +- `npm run build` 通过:backend、admin、user-app、workers 均构建成功。 +- 后端已重启,新 PID:47233。 +- `GET /api/health` 健康检查通过。 +- 本阶段未调用真实 AI Provider,没有产生 AI 成本。 + +遗留问题: + +- 还未重新跑一次完整端到端连续两次单句重试实机验收;当前已有单测覆盖元数据保留。 +- 单句重试后字幕文本未改动;如果要改台词内容,需要后续做“改句文本 + 重写 SRT cue”。 +- 逐句音频裁切试听还未做,当前仍试听整集音频。 + +下一步建议: + +- 补一次真实 API E2E:生成多角色音频后连续重试两次不同句子,确认 `media-assets` 始终返回 `segment_retry_ready=true`。 +- 继续做“单句文本编辑 + 字幕 cue 更新 + 单句音频试听裁切”。 + +### 后台视频预览本地存储路径稳定化 + +完成时间:2026-06-03 16:52:23 CST + +完成内容: + +- 修复后台预览视频时报错:`ENOENT: no such file or directory, open '../storage/private/rendered-videos/2026-06-03/e86eb917-111d-4969-b505-585319fd5e23.mp4'`。 +- 根因:`.env` 中 `LOCAL_STORAGE_ROOT=../storage` 是相对路径,后端从 `/www/wwwroot/ai` 启动时会写入 `/www/wwwroot/storage`,从 `/www/wwwroot/ai/backend` 启动时会读取 `/www/wwwroot/ai/storage`,导致同一条 `local://...` 资产路径在不同启动目录下指向不同磁盘位置。 +- `StorageService` 现在会把相对 `LOCAL_STORAGE_ROOT` 固定按 backend 包目录解析,避免启动目录不同导致文件分裂。 +- 已将历史目录 `/www/wwwroot/storage/private` 下的本地私有文件按不覆盖方式复制到规范目录 `/www/wwwroot/ai/storage/private`。 +- 已确认报错资产 `asset_id=221` 对应文件存在于规范目录,并可通过 `GET /api/assets/221/download` 正常返回。 + +修改文件: + +- backend/src/assets/storage.service.ts +- CODEX_PROGRESS.md + +新增文件: + +- 无 + +运行命令: + +- `git status --short` +- `find /www/wwwroot -path '*e86eb917-111d-4969-b505-585319fd5e23.mp4'` +- `cp -an /www/wwwroot/storage/private/. /www/wwwroot/ai/storage/private/` +- `npm run typecheck -w backend` +- `npm test -w backend` +- `npm run build -w backend` +- `npm run lint` +- `npm run typecheck` +- `npm test` +- `npm run build` +- `curl http://127.0.0.1:3000/api/health` +- 管理员登录后请求 `GET /api/assets/221/download` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- 后端 typecheck 通过。 +- 后端测试 22 个测试文件、148 个测试通过。 +- 后端 build 通过。 +- 全项目 `npm run lint` 通过。 +- 全项目 `npm run typecheck` 通过。 +- 全项目 `npm test` 通过:后端 148 个测试通过;workers 1 个测试通过;admin/user-app 当前无测试文件并以 `passWithNoTests` 通过。 +- 全项目 `npm run build` 通过。 +- 后端已重启,新 PID:65044。 +- `GET /api/health` 健康检查通过。 +- `GET /api/assets/221/download` 返回 HTTP 200,`content-type=video/mp4`,大小 `210697` 字节,MP4 header 为 `ftyp`。 +- 本阶段未调用真实 AI Provider,没有产生 AI 成本。 + +遗留问题: + +- 服务器上仍保留旧目录 `/www/wwwroot/storage/private` 作为历史文件来源,暂未删除,避免误删仍被其它进程引用的文件。 +- 当前修复覆盖 local 存储;MinIO 模式不受此相对路径问题影响。 + +下一步建议: + +- 后续部署脚本中建议显式配置绝对路径 `LOCAL_STORAGE_ROOT=/www/wwwroot/ai/storage`,进一步减少运维误启动风险。 + +### 扩展 AI 平台接入骨架 + +完成时间:2026-06-09 17:22:49 CST + +完成内容: + +- 新增“扩展 AI Provider”默认预设,后续开通账号后可在后台填写 Key、Base URL、模型名、成本阈值并启用。 +- 扩展预设全部默认 `is_enabled=false`,不会自动调用真实外部接口,不会自动扣费。 +- 已预置 Google Gemini/Imagen/Veo、Anthropic Claude、DeepSeek、Qwen、Kimi、智谱 GLM、百度千帆、腾讯混元、讯飞星火、豆包/火山方舟文本、MiniMax 文本/TTS、Baichuan、StepFun、SenseNova、360、Mistral、Cohere、xAI、OpenRouter、Together、Fireworks、Perplexity、Azure OpenAI、AWS Bedrock 兼容网关、Stability、Replicate、fal.ai、Ideogram、Leonardo、ElevenLabs、Luma、Pika 等 Provider 配置位。 +- 新增真实 driver: + - `openai_compatible_chat` + - `anthropic_messages` + - `google_gemini_generate_content` + - `cohere_chat` + - `configurable_image_generation` + - `configurable_text_to_speech` + - `configurable_async_asset_generation` + - `google_veo_video_generation` +- 后台新增 `POST /api/admin/providers/bootstrap-extended-ai`,用于初始化扩展 AI 接入。 +- 后台“AI 平台入口”补充扩展平台开户注册/控制台/文档/Key 名称/运营备注。 +- 后台“AI 接入”高级工具栏新增“初始化扩展 AI 接入”按钮。 +- 已将当前数据库写入 45 个扩展 Provider 预设,全部保持 disabled。 + +修改文件: + +- backend/src/providers/provider.types.ts +- backend/src/providers/providers.service.ts +- backend/src/providers/providers.controller.ts +- backend/src/providers/providers.service.spec.ts +- admin/src/App.vue +- CODEX_PROGRESS.md + +新增文件: + +- 无 + +运行命令: + +- `git status --short` +- `npm test -w backend -- providers.service.spec.ts` +- `npm run typecheck -w backend` +- `npm run typecheck -w admin` +- `npm run lint` +- `npm run test` +- `npm run build` +- 使用 Prisma Client 初始化当前数据库扩展 Provider 预设 +- 使用 Prisma Client 抽样核验扩展 Provider 列表 + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- Provider 单测通过:`27` 个测试通过。 +- 后端 typecheck 通过。 +- 后台 typecheck 通过。 +- 全项目 `npm run lint` 通过。 +- 全项目 `npm run test` 通过:后端 `22` 个测试文件、`153` 个测试通过;workers `1` 个测试通过;admin/user-app 当前无测试文件并以 `passWithNoTests` 通过。 +- 全项目 `npm run build` 通过。 +- 当前数据库扩展 Provider 初始化结果:`extended_ai_providers_upserted=45`。 +- 抽样核验 Google、Claude、DeepSeek、Qwen、Kimi、GLM、ElevenLabs、Stability、Replicate、Luma、Pika 等 Provider 均存在且为 `disabled`。 +- 后端已重启到新版本,新 PID:`3474791`;`GET /api/health` 健康检查通过。 +- 本阶段未配置真实 API Key,未调用真实 AI Provider,没有产生 AI 成本。 + +遗留问题: + +- 部分平台接口、模型名和鉴权方式会随账号渠道变化,当前按可配置模板或 OpenAI-compatible 通道预置;正式启用前仍必须用 1 次小样验证 endpoint、返回字段、耗时和实际账单。 +- AWS Bedrock 原生 SigV4、部分国内平台 HMAC 签名通道未在本阶段实现,当前预设优先服务兼容网关/可配置接入。 +- 视频类扩展 Provider 成本较高,仍应保持默认禁用并设置单次/每日成本阈值。 + +下一步建议: + +- 先挑 3 条低风险链路做真实 Key 联调:DeepSeek/Qwen 文本、ElevenLabs TTS、Google Gemini 文本。 +- 视频真实小样仍建议从 Hailuo Fast 或 Seedance 单镜头开始,不要一次性启用多个真实视频 Provider。 + +### 后台生产驾驶舱 UI 兼容融合 + +完成时间:2026-06-09 18:28:16 CST + +完成内容: + +- 已查看参考图 `/www/wwwroot/dc16513b-fdaf-4721-8091-7fde3a523492.png`,确认可与当前后台兼容融合。 +- 后台“仪表盘”改为深色 AI 生产驾驶舱风格,融合参考图中的 KPI 顶栏、系统模块、漫剧生成工作流、AI 平台选择策略、平台对比和成本分析。 +- 新仪表盘继续使用现有 `/admin/dashboard`、`/admin/queues`、`/admin/providers` 数据,不新增后端接口,不改变 Provider、任务队列、成本控制和权限逻辑。 +- 首页加载时同步读取 Provider 列表,用于展示平台预置/启用/Key 配置状态。 +- 保留任务状态、项目状态、队列状态等原始运营信息,并增加移动端单列兼容布局。 + +修改文件: + +- admin/src/App.vue +- admin/src/styles.css +- CODEX_PROGRESS.md + +新增文件: + +- 无 + +运行命令: + +- `git status --short` +- `npm run typecheck -w admin` +- `npm run build -w admin` +- `npm run lint` +- `npm run test` +- `npm run build` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- 后台 typecheck 通过。 +- 后台单独 build 通过。 +- 全项目 `npm run lint` 通过。 +- 全项目 `npm run test` 通过:后端 `22` 个测试文件、`153` 个测试通过;workers `1` 个测试通过;admin/user-app 当前无测试文件并以 `passWithNoTests` 通过。 +- 全项目 `npm run build` 通过。 +- Vite 输出 CJS Node API deprecation 提醒,不影响构建产物。 +- 后台预览服务已确认可访问:`http://127.0.0.1:5175/`。 +- 后端已用 `setsid node backend/dist/main.js` 守护式启动,PID:`3603810`;`GET /api/health` 健康检查通过。 + +遗留问题: + +- 本阶段是后台仪表盘 UI 融合,没有做真实浏览器截图验收;如需像素级贴近参考图,可继续启动后台预览并按实际窗口微调间距、字号和色彩。 +- 平台质量评分仍按真实配置状态展示,没有引入虚假的模型评分。 + +下一步建议: + +- 后台启动预览后人工看一眼仪表盘首屏,确认是否继续把同一视觉风格扩展到“AI 平台入口”和“AI 接入”两个页面。 + +### AI 平台入口 / AI 接入深色运营台风格接入 + +完成时间:2026-06-09 20:28:58 CST + +完成内容: + +- 已将后台深色生产驾驶舱风格继续扩展到“AI 平台入口”和“AI 接入”两个页面。 +- `dashboard / aiPlatforms / providers` 三个 section 共享 cockpit 深色外壳、侧边栏和工作区视觉。 +- “AI 平台入口”保留开户注册入口、Hailuo 快速配置、平台清单、开户注册顺序,同时统一为深色面板、深色表格、亮色链接按钮和状态 Badge。 +- “AI 接入”保留 OpenAI 统一配置、高级 Provider 配置、初始化按钮、Provider 列表、测试结果,同时统一为深色表单、深色输入框、深色工具栏和 Provider 配置面板。 +- 本阶段只改后台展示层,不新增接口,不修改真实 Provider 调用逻辑,不写入 API Key。 + +修改文件: + +- admin/src/App.vue +- admin/src/styles.css +- CODEX_PROGRESS.md + +新增文件: + +- 无 + +运行命令: + +- `git status --short` +- `npm run typecheck -w admin` +- `npm run build -w admin` +- `npm run lint` +- `npm run test` +- `npm run build` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- 后台 typecheck 通过。 +- 后台单独 build 通过。 +- 全项目 `npm run lint` 通过。 +- 全项目 `npm run test` 通过:后端 `22` 个测试文件、`153` 个测试通过;workers `1` 个测试通过;admin/user-app 当前无测试文件并以 `passWithNoTests` 通过。 +- 全项目 `npm run build` 通过。 +- Vite 输出 CJS Node API deprecation 提醒,不影响构建产物。 + +遗留问题: + +- 本阶段未做浏览器截图级微调;实际观感还需人工打开后台确认列表密度、表格宽度、色彩对比是否满意。 + +下一步建议: + +- 打开后台 `http://127.0.0.1:5175/`,依次查看“仪表盘 / AI 平台入口 / AI 接入”三页,确认是否继续把“成本日志 / 任务管理”也统一到这套运营台风格。 + +### 任务管理 / 成本日志深色运营台风格接入 + +完成时间:2026-06-09 20:36:49 CST + +完成内容: + +- 已将后台“任务管理”和“成本日志”继续接入深色运营台风格。 +- 任务管理页新增任务运行概览卡片,展示当前列表任务数、执行中、失败、人工处理和累计重试信息。 +- 任务管理页筛选栏、任务表格、刷新任务按钮统一为深色运营台视觉。 +- 成本日志页新增 Provider 成本概览卡片,展示总成本、日志数、成功调用、异常调用。 +- 成本日志页 Provider 日志表格、刷新成本按钮统一为深色运营台视觉。 +- 本阶段只改后台展示层,不修改任务队列、成本统计、Provider 调用和后端接口。 + +修改文件: + +- admin/src/App.vue +- admin/src/styles.css +- CODEX_PROGRESS.md + +新增文件: + +- 无 + +运行命令: + +- `git status --short` +- `npm run typecheck -w admin` +- `npm run build -w admin` +- `npm run lint` +- `npm run test` +- `npm run build` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- 后台 typecheck 通过。 +- 后台单独 build 通过。 +- 全项目 `npm run lint` 通过。 +- 全项目 `npm run test` 通过:后端 `22` 个测试文件、`153` 个测试通过;workers `1` 个测试通过;admin/user-app 当前无测试文件并以 `passWithNoTests` 通过。 +- 全项目 `npm run build` 通过。 +- Vite 输出 CJS Node API deprecation 提醒,不影响构建产物。 + +遗留问题: + +- 未做浏览器截图级细调,任务/成本页表格列宽和首屏信息密度仍建议人工预览确认。 + +下一步建议: + +- 人工打开后台依次查看“任务管理 / 成本日志”两页;若整体满意,可继续把“内容审核 / 项目管理 / 成品漫剧”统一成同一套后台风格。 + +### 全项目 UI 深色运营台风格统一 + +完成时间:2026-06-09 20:41:36 CST + +完成内容: + +- 已将后台端所有 section 扩展为统一深色运营台外壳,不再只限制在仪表盘、AI 接入、任务和成本页面。 +- 后台补充全局深色覆盖:面板、表格、表单、按钮、状态 Badge、额度卡片、操作区、预览抽屉、JSON 预览、提示消息等统一风格。 +- 用户端 `user-app` 增加全局深色生产主题:登录页、底部/侧边导航、项目创建、项目列表、工作台、进度、结果、额度、审核、教程、个人中心、素材预览弹窗等常用组件统一为深色运营台风格。 +- 本阶段只改 UI 展示层,不修改后端接口、任务队列、Provider、成本统计、登录鉴权和业务流程。 + +修改文件: + +- admin/src/App.vue +- admin/src/styles.css +- user-app/src/styles.css +- CODEX_PROGRESS.md + +新增文件: + +- 无 + +运行命令: + +- `git status --short` +- `npm run typecheck -w admin` +- `npm run typecheck -w user-app` +- `npm run build -w admin` +- `npm run build -w user-app` +- `npm run lint` +- `npm run test` +- `npm run build` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- 后台 typecheck 通过。 +- 用户端 typecheck 通过。 +- 后台单独 build 通过。 +- 用户端单独 build 通过。 +- 全项目 `npm run lint` 通过。 +- 全项目 `npm run test` 通过:后端 `22` 个测试文件、`153` 个测试通过;workers `1` 个测试通过;admin/user-app 当前无测试文件并以 `passWithNoTests` 通过。 +- 全项目 `npm run build` 通过。 +- Vite 输出 CJS Node API deprecation 提醒,不影响构建产物。 + +遗留问题: + +- 本阶段为全局风格统一,未做逐页浏览器截图级微调;个别长表格、移动端密集表单和预览弹窗仍建议人工打开确认视觉密度。 + +下一步建议: + +- 人工打开后台和用户端各跑一遍主流程,重点看移动端按钮换行、表格横向滚动、弹窗预览和长文本卡片是否需要精修。 + +### 成本优化策略硬落地 + +完成时间:2026-06-09 21:51:16 CST + +完成内容: + +- 真人动态视频镜头生成增加 Provider 子片段限制:真实/Provider 单次镜头按最多 10 秒拆分,超长镜头自动拆成多个 10 秒以内子片段后用 FFmpeg 拼接回一个镜头资产。 +- 真人动态视频成本预估改为按 Provider 子片段计算,任务输入记录 `provider_clip_count` 和 `provider_clip_durations`,镜头任务完成后记录预估成本和实际成本。 +- TTS 增加同用户私有缓存复用:同文本、同 voice、同 Provider code、同模型、同声音风格、同语速、同片段类型命中时,直接读取历史 `generated-audio-segments` 私有文件,不再调用 TTS Provider。 +- 多角色/分段 TTS 已接入缓存与批量适配入口;普通旁白单段 TTS 也已接入同一套缓存元数据。 +- ProviderService 新增 `executeProviderBatch` 批量适配层,当前支持统一批量入口和不支持原生批量时的逐条 fallback。 +- 音频任务元数据新增 `audio_cache_key`、`cache_hit`、`cached_segments`、`generated_segments`,方便后台后续展示缓存命中和成本节省。 +- 本阶段未调用任何真实 AI Provider,没有产生真实 AI 成本;未修改 `docs/` 目录。 + +修改文件: + +- backend/src/live-action/live-action.service.ts +- backend/src/media/media.service.ts +- backend/src/media/media.service.spec.ts +- backend/src/providers/providers.service.ts +- backend/src/providers/providers.service.spec.ts +- CODEX_PROGRESS.md + +新增文件: + +- 无 + +运行命令: + +- `git status --short` +- `npm run typecheck -w backend` +- `npm test -w backend -- media.service.spec.ts providers.service.spec.ts` +- `npm run typecheck` +- `npm run lint` +- `npm run test` +- `npm run build` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- 后端单独 typecheck 通过。 +- 目标单测通过:`media.service.spec.ts` 和 `providers.service.spec.ts` 共 `42` 个测试通过。 +- 全项目 `npm run typecheck` 通过。 +- 全项目 `npm run lint` 通过。 +- 全项目 `npm run test` 通过:后端 `22` 个测试文件、`155` 个测试通过;workers `1` 个测试通过;admin/user-app 当前无测试文件并以 `passWithNoTests` 通过。 +- 全项目 `npm run build` 通过。 +- Vite 输出 CJS Node API deprecation 提醒,不影响测试或构建产物。 + +遗留问题: + +- `executeProviderBatch` 当前已实现批量入口和顺序 fallback;具体真实 Provider 的原生合并请求能力还需要按平台 API 单独接驱动,不能默认把所有模型强行合并。 +- TTS 缓存按同用户项目范围复用,暂不做全局跨用户缓存,避免私有内容和声音素材串用。 +- 真人视频镜头拆分依赖 FFmpeg 拼接;生产机需要保证 FFmpeg 可用。 + +下一步建议: + +- 把后台任务/成本页面补一个“缓存命中 / 拆分片段 / 预估节省”展示,让运营能直观看到哪些镜头或 TTS 片段省了钱。 +- 选择一个真实 TTS Provider 和一个真实视频 Provider 做小样,把平台原生批量/异步批量能力接到 `executeProviderBatch` 的 native 分支。 + +### AI Router V1 / 镜头评分 / 自动选模型 + +完成时间:2026-06-09 22:21:32 CST + +完成内容: + +- `storyboard_shots` 新增镜头路由字段:`scene_type`、`importance_score`、`emotion_score`、`action_score`、`route_tier`。 +- 新增 `AiRouterModule` / `AiRouterService`,支持按语言、任务类型、镜头评分、Provider 可用状态、预算和降级链自动选择视频 Provider。 +- 新增默认 `ai.router.v1` 系统配置:中文普通真人视频镜头优先 `minimax_hailuo_23_fast`,高价值镜头优先 `kling-image-to-video`,降级链为 `kling -> hailuo -> jimeng -> mock`。 +- Router 会自动给镜头打标签和评分:普通对话、情绪戏、动作戏、远景转场等会得到不同 `scene_type` 与分数。 +- 真人短剧分镜准备阶段会写入镜头评分和 `route_tier`;历史镜头缺字段时也会在准备或生成时补齐。 +- 真人视频片段生成在未传 `provider_code` 时自动走 Router,不再默认固定 mock;人工 `provider_code` override 仅允许 admin 角色用于测试。 +- 视频片段任务 `input_json` 记录完整 `router_decision`,包含候选 Provider、降级原因、评分、route tier、预估成本和最终 provider。 +- 用户端真人视频 Provider 默认改为“自动路由”,生成/估算时不再强制传 `mock-video`;仍保留下拉供后续管理员/调试场景使用。 +- 本阶段未调用任何真实 AI Provider,没有产生真实 AI 成本;未修改 `docs/` 目录。 + +修改文件: + +- backend/prisma/schema.prisma +- backend/prisma/migrations/20260609220500_ai_router_v1_shot_scores/migration.sql +- backend/prisma/seed.ts +- backend/src/ai-router/ai-router.module.ts +- backend/src/ai-router/ai-router.service.ts +- backend/src/ai-router/ai-router.service.spec.ts +- backend/src/ai-router/ai-router.types.ts +- backend/src/admin/admin.service.ts +- backend/src/live-action/live-action.module.ts +- backend/src/live-action/live-action.service.ts +- backend/src/live-action/live-action.service.spec.ts +- backend/src/live-action/live-action.types.ts +- backend/src/images/images.service.spec.ts +- backend/src/media/media.service.spec.ts +- backend/src/scripts/scripts.service.spec.ts +- user-app/src/api/client.ts +- user-app/src/pages/index/index.vue +- CODEX_PROGRESS.md + +新增文件: + +- backend/prisma/migrations/20260609220500_ai_router_v1_shot_scores/migration.sql +- backend/src/ai-router/ai-router.module.ts +- backend/src/ai-router/ai-router.service.ts +- backend/src/ai-router/ai-router.service.spec.ts +- backend/src/ai-router/ai-router.types.ts +- backend/src/live-action/live-action.service.spec.ts + +运行命令: + +- `git status --short` +- `npm run prisma:generate -w backend` +- `npm run typecheck -w backend` +- `npm test -w backend -- ai-router.service.spec.ts media.service.spec.ts images.service.spec.ts scripts.service.spec.ts` +- `npm test -w backend -- ai-router.service.spec.ts live-action.service.spec.ts` +- `npm run prisma:deploy -w backend` +- `set -a; . ./.env; set +a; npm run prisma:deploy -w backend` +- `npm run typecheck` +- `npm run lint` +- `npm run test` +- `npm run build` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- Prisma Client 生成通过。 +- 首次 `npm run prisma:deploy -w backend` 失败:Prisma CLI 未读取到 `DATABASE_URL`。 +- 加载根目录 `.env` 后 `prisma migrate deploy` 成功,已应用 `20260609220500_ai_router_v1_shot_scores`。 +- 后端单独 typecheck 通过。 +- Router / media / images / scripts 目标单测通过:`33` 个测试通过。 +- Router / live-action 集成目标单测通过:`5` 个测试通过。 +- 全项目 `npm run typecheck` 通过。 +- 全项目 `npm run lint` 通过。 +- 全项目 `npm run test` 通过:后端 `24` 个测试文件、`160` 个测试通过;workers `1` 个测试通过;admin/user-app 当前无测试文件并以 `passWithNoTests` 通过。 +- 全项目 `npm run build` 通过。 +- Vite 输出 CJS Node API deprecation 提醒,不影响测试或构建产物。 + +遗留问题: + +- Router V1 当前只接入真人视频片段 Provider 自动选择;文本、图片、TTS 的自动路由还未统一接入。 +- Router 规则目前用 `SystemConfig` JSON 管理,后台还没有专门的可视化规则编辑器。 +- 质量评分低于阈值后的“自动重试一次、再切换 Provider、再进人工”闭环还未做。 +- 多语言仅预留 `language` 输入,尚未建立 story/episode/shot translation 表和多语言生产线。 + +下一步建议: + +- 做“Router 质检闭环 V1”:`score < 80` 自动原 Provider 重试一次,再按 fallback 切换 Provider,第三次失败进入人工处理。 +- 后台任务/成本页展示 `router_decision`:镜头评分、route tier、候选 Provider、降级原因和预估节省。 + +### Router 质检闭环 V1 + +完成时间:2026-06-09 23:15 CST + +完成内容: + +- 真人视频片段质检入口支持 `auto_repair`、`min_quality_score`、`confirm_real_video`、`max_cost_per_clip`。 +- 默认质检阈值为 `80` 分;低于阈值时进入自动修复闭环。 +- 首次低分:自动沿用当前 Provider 重新生成一次。 +- 已重试仍低分:按 Router `fallback_chain` 切换下一个可用 Provider。 +- 自动修复超过上限、无可用 fallback、或真实/非 mock Provider 未确认费用时,自动标记为 `manual_required`。 +- 关闭自动修复时,只标记 `needs_retry`,不误进人工处理。 +- 自动修复生成任务会把 `repair_context` 写入 `render_tasks.input_json`,包含源片段、修复动作、Provider、fallback chain、阈值和上一轮质检分数。 +- 用户端点击“质检”默认开启自动修复,并根据返回结果提示“通过 / 同 Provider 重试 / 切换 Provider / 人工处理”。 +- 单测覆盖: + - `score < 80` 后同 Provider 自动重试并通过。 + - 已重试片段再次低分后切换到 fallback Provider 并通过。 + +修改文件: + +- backend/src/live-action/live-action.dto.ts +- backend/src/live-action/live-action.controller.ts +- backend/src/live-action/live-action.service.ts +- backend/src/live-action/live-action.service.spec.ts +- user-app/src/api/client.ts +- user-app/src/pages/index/index.vue +- CODEX_PROGRESS.md + +新增文件: + +- 无 + +运行命令: + +- `git status --short` +- `npm run typecheck -w backend` +- `npm test -w backend -- live-action.service.spec.ts ai-router.service.spec.ts` +- `npm run typecheck` +- `npm run lint` +- `npm run test` +- `npm run build` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- 后端单独 typecheck 通过。 +- Router / live-action 目标单测通过:`7` 个测试通过。 +- 全项目 `npm run typecheck` 通过。 +- 全项目 `npm run lint` 通过。 +- 全项目 `npm run test` 通过:后端 `24` 个测试文件、`162` 个测试通过;workers `1` 个测试通过;admin/user-app 当前无测试文件并以 `passWithNoTests` 通过。 +- 全项目 `npm run build` 通过。 +- Vite 输出 CJS Node API deprecation 提醒,不影响测试或构建产物。 + +遗留问题: + +- Router 质检闭环 V1 只覆盖真人视频片段;图片、TTS、字幕、整集成片质检还未纳入同一闭环。 +- 人工处理队列目前通过 `quality_status=manual_required` 和 `video_status=quality_manual_required` 标记,后台尚未做专门的人工处理工作台。 +- `repair_context` 已记录在任务输入里,但后台还未可视化展示每次修复链路和节省/新增成本。 + +下一步建议: + +- 做“后台 Router/质检审计视图”:展示镜头评分、Provider 决策、fallback 链、修复次数、质检分数、人工处理原因和成本变化。 + +### 后台 Router / 质检审计视图 + +完成时间:2026-06-09 23:35 CST + +完成内容: + +- 新增后台审计 API:`GET /api/admin/router-audits`。 +- 审计 API 汇总真人视频片段、分镜评分、Provider 配置、生成任务 `router_decision`、`repair_context` 和质检结果。 +- 支持筛选: + - `project_id` + - `episode_id` + - `provider_code` + - `quality_status` + - `route_tier` + - `limit` +- API 返回统计摘要: + - 当前片段数 + - 质检通过数 + - 建议重试数 + - 人工处理数 + - 未质检数 + - 低分数 + - 自动修复数 + - 切换 Provider 数 + - 预估成本、实际成本、修复新增成本 + - 平均质检分 +- API 返回明细: + - 项目 / 分集 / 镜头信息 + - `scene_type`、`importance_score`、`emotion_score`、`action_score`、`route_tier` + - 选中 Provider、Provider 模式、模型名 + - Router 决策原因、候选 Provider 数、fallback chain、是否人工 override + - 质检状态、分数、人工处理原因 + - 自动修复动作、来源片段、上一轮质检状态和分数 + - 预估成本、实际成本、成本差额、修复新增成本 + - 对应生成任务状态 +- 后台新增导航页:“Router 审计”。 +- 后台审计页新增统计卡、筛选工具条和明细表格。 +- 后台审计页沿用深色运营台风格,并对大表格做横向滚动。 +- 单测覆盖 Router 审计 API 能正确解析路由、fallback、修复动作、人工原因和成本差额。 + +修改文件: + +- backend/src/admin/admin.dto.ts +- backend/src/admin/admin.controller.ts +- backend/src/admin/admin.service.ts +- backend/src/admin/admin.service.spec.ts +- admin/src/App.vue +- admin/src/styles.css +- CODEX_PROGRESS.md + +新增文件: + +- 无 + +运行命令: + +- `git status --short` +- `npm run typecheck -w backend` +- `npm test -w backend -- admin.service.spec.ts live-action.service.spec.ts ai-router.service.spec.ts` +- `npm run typecheck -w admin` +- `npm run typecheck` +- `npm run lint` +- `npm run test` +- `npm run build` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- 后端单独 typecheck 通过。 +- 后台单独 typecheck 通过。 +- Admin / live-action / router 目标单测通过:`20` 个测试通过。 +- 全项目 `npm run typecheck` 通过。 +- 全项目 `npm run lint` 通过。 +- 全项目 `npm run test` 通过:后端 `24` 个测试文件、`163` 个测试通过;workers `1` 个测试通过;admin/user-app 当前无测试文件并以 `passWithNoTests` 通过。 +- 全项目 `npm run build` 通过。 +- Vite 输出 CJS Node API deprecation 提醒,不影响测试或构建产物。 + +遗留问题: + +- Router 审计当前是只读视图,还没有从审计页直接触发“重新质检 / 进入人工处理 / 指定 Provider 重试”等操作。 +- 审计汇总基于当前查询列表,不是全库长期统计报表;后续如果要做日报/周报,需要单独做聚合表或分析接口。 +- 当前只覆盖真人视频片段,图片、TTS、整集视频的 Router/质检审计还未纳入。 + +下一步建议: + +- 做“Router 审计操作闭环”:在审计页对 `manual_required`、`needs_retry`、低分片段提供重新质检、指定 Provider 重试、确认人工通过/驳回等后台操作。 + +### Router 审计操作闭环 + +完成时间:2026-06-09 23:51 CST + +完成内容: + +- 后台 Router 审计页新增“审计操作参数”面板: + - 指定 Provider Code + - 单片段成本上限 + - 是否允许真实/非 Mock Provider 付费修复或重试 + - 人工原因 + - 人工分数 +- 审计明细每行新增操作: + - 重新质检 + - 指定 Provider 重试 + - 人工通过 + - 人工驳回 +- “重新质检”复用现有 `POST /api/live-action/video-clips/:clipId/quality-check`: + - 默认 `auto_repair=true` + - 默认阈值 `80` + - 只有勾选确认后才允许真实/非 Mock Provider 自动修复。 +- “指定 Provider 重试”复用现有 `POST /api/live-action/video-clips/:clipId/retry`: + - 使用后台填写的 Provider Code。 + - 支持单片段成本上限。 + - 真实/非 Mock Provider 会二次确认,并仍受后端确认和成本阈值保护。 +- 新增后台人工质检接口:`PATCH /api/admin/router-audits/video-clips/:clipId/quality`。 +- 人工质检接口支持: + - `passed` + - `rejected` + - `manual_required` + - `needs_retry` +- 人工通过会把低分片段提升到至少 `80` 分,避免仍被统计为低分。 +- 人工通过/驳回会同步更新 `storyboard_shots.video_status`: + - `quality_passed` + - `quality_rejected` + - `quality_needs_retry` + - `quality_manual_required` +- 人工通过/驳回会写入 `operation_logs`: + - 操作人 + - 原质检状态 + - 新质检状态 + - 原分数 + - 新分数 + - 项目、分集、镜头、片段 ID + - 人工原因 +- 权限策略: + - 重新质检 / 指定重试:`tasks:write` + - 人工通过 / 人工驳回:`reviews:write` + - 审计员和财务角色保持只读。 +- 单测覆盖人工通过写入 video_clip、storyboardShot 和 operation_log。 + +修改文件: + +- backend/src/admin/admin.dto.ts +- backend/src/admin/admin.controller.ts +- backend/src/admin/admin.service.ts +- backend/src/admin/admin.service.spec.ts +- admin/src/App.vue +- admin/src/styles.css +- CODEX_PROGRESS.md + +新增文件: + +- 无 + +运行命令: + +- `git status --short` +- `npm run typecheck -w backend` +- `npm run typecheck -w admin` +- `npm test -w backend -- admin.service.spec.ts live-action.service.spec.ts ai-router.service.spec.ts` +- `npm run typecheck` +- `npm run lint` +- `npm run test` +- `npm run build` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- 后端单独 typecheck 通过。 +- 后台单独 typecheck 通过。 +- Admin / live-action / router 目标单测通过:`21` 个测试通过。 +- 全项目 `npm run typecheck` 通过。 +- 全项目 `npm run lint` 通过。 +- 全项目 `npm run test` 通过:后端 `24` 个测试文件、`164` 个测试通过;workers `1` 个测试通过;admin/user-app 当前无测试文件并以 `passWithNoTests` 通过。 +- 全项目 `npm run build` 通过。 +- Vite 输出 CJS Node API deprecation 提醒,不影响测试或构建产物。 + +遗留问题: + +- 重新质检和指定重试目前是同步接口;如果未来真实视频生成耗时很长,需要把这两个动作改成后台队列任务。 +- 人工通过/驳回只覆盖真人视频片段;图片、TTS、整集视频还没有统一人工质量操作入口。 +- Router 审计页还没有展示操作日志时间线;目前操作日志已写入,可在审计日志页查。 + +下一步建议: + +- 做“Router 审计时间线 / 操作历史”:在审计页展开单个片段,显示每次质检、重试、Provider 切换、人工处理、成本变化的完整时间线。 + +### Router 审计时间线 / 操作历史 + +完成时间:2026-06-10 00:07 CST + +完成内容: + +- 新增后台 Router 片段时间线接口:`GET /api/admin/router-audits/video-clips/:clipId/timeline`。 +- 时间线接口聚合现有数据,不新增表、不改 Prisma schema: + - 当前片段审计行 + - 同镜头相关 `video_clips` + - 相关 `render_tasks` + - 相关 `provider_logs` + - 相关 `operation_logs` +- 时间线事件覆盖: + - Router 自动选模型 + - 视频生成任务创建 / 完成 + - Provider 生成调用 + - 质检 Provider 调用 + - 自动修复策略 + - 片段生成记录 + - 片段质检状态 + - 后台人工质检处理 +- 时间线摘要展示: + - 事件数量 + - 相关片段数量 + - 任务数量 + - Provider 调用数量 + - 人工操作数量 + - Provider 成本 + - 最新质检状态和分数 +- 后台 Router 审计表每行新增“时间线”按钮。 +- 新增右侧 Router 时间线抽屉: + - 顶部摘要卡片 + - 事件链路 + - 关联任务 + - 人工操作 +- 时间线节点显示: + - 事件类型 + - 状态 + - 时间 + - Provider Code + - 任务 ID + - 片段 ID + - 成本 + - 结构化详情 JSON +- 权限策略:沿用 Router 审计只读权限,仍要求 `admin:read`。 +- 单测覆盖 Router 时间线聚合,验证路由、修复、Provider、质检、人工操作事件进入同一时间线。 + +修改文件: + +- backend/src/admin/admin.controller.ts +- backend/src/admin/admin.service.ts +- backend/src/admin/admin.service.spec.ts +- admin/src/App.vue +- admin/src/styles.css +- CODEX_PROGRESS.md + +新增文件: + +- 无 + +运行命令: + +- `git status --short` +- `npm run typecheck -w backend` +- `npm test -w backend -- admin.service.spec.ts` +- `npm run typecheck -w admin` +- `npm run typecheck` +- `npm run lint` +- `npm run test` +- `npm run build` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- 后端单独 typecheck 通过。 +- 后台单独 typecheck 通过。 +- `admin.service.spec.ts` 通过:`15` 个测试通过。 +- 全项目 `npm run typecheck` 通过。 +- 全项目 `npm run lint` 通过。 +- 全项目 `npm run test` 通过: + - 后端 `24` 个测试文件、`165` 个测试通过。 + - workers `1` 个测试通过。 + - admin/user-app 当前无测试文件并以 `passWithNoTests` 通过。 +- 全项目 `npm run build` 通过。 +- Vite 仍输出 CJS Node API deprecation 提醒,不影响测试或构建产物。 + +遗留问题: + +- 时间线目前按已有数据库记录聚合;重新质检 / 指定重试这类 live-action 操作本身还没有单独写 `operation_logs`,所以时间线主要通过任务、Provider 日志和片段状态体现。 +- 质检 Provider 调用没有 `task_id`,当前通过 `purpose` / `clip_id` 文本匹配归属片段;后续可把质检也任务化,让链路更精确。 +- 时间线目前是只读复盘;操作仍在表格行内完成。 + +下一步建议: + +- 做“Router 操作日志增强”:给重新质检、指定 Provider 重试、自动修复触发点补充 `operation_logs`,让时间线能完整区分是谁、什么时候、为什么触发了每一次动作。 + +### Router 操作日志增强 + +完成时间:2026-06-10 00:40 CST + +完成内容: + +- 给后台重新质检入口补充 `operation_logs`: + - action:`router_audit_quality_recheck` + - target:`video_clip` + - metadata 记录项目、分集、镜头、片段、是否自动修复、最低质检分、成本上限、是否确认真实 Provider、原质检状态、原质检分。 +- 给指定 Provider 重试入口补充 `operation_logs`: + - action:`router_audit_manual_provider_retry` + - target:`video_clip` + - metadata 记录 Provider Code、成本上限、是否确认真实 Provider、原质检状态、原质检分、重试次数。 +- 给低分后的自动修复触发点补充 `operation_logs`: + - action:`router_audit_auto_repair_triggered` + - target:源 `video_clip` + - metadata 记录修复动作、目标 Provider、触发原因、fallback 链、上一轮质检状态/分数、最低质检分、成本上限。 +- 自动修复动作支持进入时间线: + - `retry_same_provider` + - `switch_provider` +- Admin 时间线标题增强: + - `router_audit_quality_recheck` 显示为“后台重新质检” + - `router_audit_manual_provider_retry` 显示为“后台指定 Provider 重试” + - `router_audit_auto_repair_triggered` 显示为“Router 自动修复触发” +- 后台中文标签同步补充上述三个 action。 +- 现在 Router 时间线能完整看到: + - 谁触发了重新质检 + - 谁指定 Provider 重试 + - 系统为什么自动重试或切 Provider + - 每次动作对应的片段、Provider、成本阈值和质检阈值 +- 单测补充: + - 指定 Provider 重试会写操作日志。 + - 重新质检会写操作日志。 + - 自动同平台重试会写操作日志。 + - 自动切 Provider 会写操作日志。 + +修改文件: + +- backend/src/live-action/live-action.service.ts +- backend/src/live-action/live-action.service.spec.ts +- backend/src/admin/admin.service.ts +- admin/src/App.vue +- CODEX_PROGRESS.md + +新增文件: + +- 无 + +运行命令: + +- `git status --short` +- `npm run typecheck -w backend` +- `npm test -w backend -- live-action.service.spec.ts admin.service.spec.ts` +- `npm run typecheck` +- `npm run lint` +- `npm run test` +- `npm run build` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- 后端单独 typecheck 通过。 +- 目标单测通过:`live-action.service.spec.ts` + `admin.service.spec.ts` 共 `19` 个测试通过。 +- 全项目 `npm run typecheck` 通过。 +- 全项目 `npm run lint` 通过。 +- 全项目 `npm run test` 通过: + - 后端 `24` 个测试文件、`166` 个测试通过。 + - workers `1` 个测试通过。 + - admin/user-app 当前无测试文件并以 `passWithNoTests` 通过。 +- 全项目 `npm run build` 通过。 +- Vite 仍输出 CJS Node API deprecation 提醒,不影响测试或构建产物。 + +遗留问题: + +- 质检 Provider 调用仍然没有独立 `task_id`,时间线目前通过 `purpose` / `clip_id` 文本匹配归属片段。 +- 重新质检、指定重试目前仍是同步接口;真实视频 Provider 耗时较长时,后续应改成队列任务。 +- 自动修复日志记录的是触发点和原因;如果需要“修复完成/失败”的独立人工可读节点,后续可以在生成完成后再写一条结果日志。 + +下一步建议: + +- 做“质检任务化 / Router 队列化”:把重新质检、自动修复、指定 Provider 重试改成后台任务,给每个质检和修复动作分配 `task_id`,让时间线从“可复盘”升级成“可恢复、可重跑、可追踪队列状态”。 + +### 质检任务化 / Router 队列化 V1 + +完成时间:2026-06-10 01:08 CST + +完成内容: + +- 新增真人 Router 队列任务类型: + - `live_action_keyframe_generate` + - `live_action_video_clip_generate` + - `live_action_video_clip_retry` + - `live_action_video_clip_quality_check` + - `live_action_video_render` +- 队列映射补齐: + - 关键帧进入 `image_queue` + - 视频片段生成、指定重试、成片合成进入 `video_queue` + - 片段质检进入 `qc_queue` +- `QueuesService` 新增内部任务创建入口,支持业务服务创建 `render_tasks` 后直接进入队列。 +- worker 执行链路支持分发真人 Router 业务任务: + - `live_action_video_clip_retry` + - `live_action_video_clip_quality_check` +- 后台“重新质检”改为创建质检队列任务,返回 `task_id` 与队列信息。 +- 后台“指定 Provider 重试”改为创建视频队列任务,返回 `task_id` 与队列信息。 +- 保留无队列注入时的同步 fallback,便于单测和极端场景兜底。 +- 质检 Provider 调用补充 `task_id`,时间线可以更准确串联到对应质检任务。 +- 自动修复触发日志补充父级质检 `task_id`,修复来源可以追溯。 +- Router 审计 / 时间线任务查询补充新的真人队列任务类型。 +- 后台提示文案更新为“任务已创建”,显示任务 ID 和队列名。 + +修改文件: + +- backend/src/queues/task.types.ts +- backend/src/queues/queues.module.ts +- backend/src/queues/queues.service.ts +- backend/src/queues/queues.service.spec.ts +- backend/src/live-action/live-action.module.ts +- backend/src/live-action/live-action.service.ts +- backend/src/live-action/live-action.service.spec.ts +- backend/src/admin/admin.service.ts +- admin/src/App.vue +- CODEX_PROGRESS.md + +新增文件: + +- 无 + +运行命令: + +- `git status --short` +- `npm run typecheck -w backend` +- `npm test -w backend -- live-action.service.spec.ts queues.service.spec.ts admin.service.spec.ts` +- `npm run typecheck` +- `npm run lint` +- `npm run test` +- `npm run build` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- 后端单独 typecheck 通过。 +- 目标单测通过:`live-action.service.spec.ts`、`queues.service.spec.ts`、`admin.service.spec.ts` 共 `29` 个测试通过。 +- 全项目 `npm run typecheck` 通过。 +- 全项目 `npm run lint` 通过。 +- 全项目 `npm run test` 通过: + - 后端 `24` 个测试文件、`167` 个测试通过。 + - workers `1` 个测试通过。 + - admin/user-app 当前无测试文件并以 `passWithNoTests` 通过。 +- 全项目 `npm run build` 通过。 +- Vite 仍输出 CJS Node API deprecation 提醒,不影响测试或构建产物。 + +遗留问题: + +- 自动修复目前仍在质检任务内部闭环执行;子级修复生成已记录 `repair_context` 和父级质检任务,但还没有拆成独立子任务树。 +- `live_action_keyframe_generate`、`live_action_video_clip_generate`、`live_action_video_render` 已预置任务类型和队列映射,但本阶段只先把重新质检和指定 Provider 重试任务化。 +- Redis 不可用时队列适配器会返回 `enqueued=false`,任务仍会落库;后续可在任务详情页提供重新入队操作。 + +下一步建议: + +- 做“爆款诊断 / 拉片分析 V1”:把创作型产品里有价值的拆片、套路、角色关系、反转节奏沉淀成可复用数据,用来反哺 Story Bible、Prompt Library 和后续自动生成质量。 + +### 爆款诊断 / 拉片分析 V1 + +完成时间:2026-06-10 13:29 CST + +完成内容: + +- 新增爆款拉片数据模型: + - `hit_analysis_cases`:爆款样本、来源平台、题材、指标、拉片文本、诊断结果。 + - `hit_analysis_segments`:分段拆解,记录钩子、冲突、情绪、反转、视觉策略、Prompt 种子和镜头评分。 + - `creative_patterns`:可复用题材套路 / 角色套路 / 视觉 Prompt / 集节奏模式库。 +- 新增后台 API: + - `GET /api/admin/hit-analyses` + - `POST /api/admin/hit-analyses` + - `POST /api/admin/hit-analyses/:caseId/analyze` + - `POST /api/admin/hit-analyses/:caseId/patterns` + - `GET /api/admin/creative-patterns` +- 新增规则化诊断 V1: + - 按拉片文本自动切分 5-10 秒生产友好的段落。 + - 基于本地关键词规则给出钩子、冲突、反转、情绪、视觉、生产复用评分。 + - 输出 `key_takeaways`、`story_bible_seeds`、`character_archetypes`、`prompt_keywords`、`route_hints`。 + - 不接真实 AI,不消耗 Provider 成本,后续可替换为 TextProvider/Router 驱动分析。 +- 新增“沉淀模式”能力: + - 从已诊断样本生成 `opening_hook`、`reversal_loop`、`character_archetype`、`visual_prompt`、`episode_rhythm` 五类模式。 + - 模式记录结构 JSON、Prompt 模板、负面 Prompt、标签和有效性评分。 +- 后台新增“爆款诊断”菜单页: + - 录入拉片样本。 + - 查看诊断分、核心维度分、拉片结论、分段拆解。 + - 对样本重新诊断。 + - 一键沉淀模式库。 + - 查看可复用模式库。 +- 操作日志补充: + - `admin_create_hit_analysis_case` + - `admin_analyze_hit_case` + - `admin_promote_hit_analysis_patterns` +- 已应用本地数据库 migration:`20260610011500_hit_analysis_v1`。 + +修改文件: + +- backend/prisma/schema.prisma +- backend/src/admin/admin.controller.ts +- backend/src/admin/admin.dto.ts +- backend/src/admin/admin.service.ts +- backend/src/admin/admin.service.spec.ts +- backend/src/admin/admin.types.ts +- admin/src/App.vue +- admin/src/styles.css +- CODEX_PROGRESS.md + +新增文件: + +- backend/prisma/migrations/20260610011500_hit_analysis_v1/migration.sql + +运行命令: + +- `git status --short` +- `npm run prisma:generate -w backend` +- `npm run typecheck -w backend` +- `npm test -w backend -- admin.service.spec.ts` +- `npm run typecheck -w admin` +- `set -a; . ./.env; set +a; npm run prisma:deploy -w backend` +- `npm run typecheck` +- `npm run lint` +- `npm run test` +- `npm run build` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- Prisma Client generate:通过。 +- 本地数据库 migration deploy:通过,已应用 `20260610011500_hit_analysis_v1`。 +- 后端单独 typecheck 通过。 +- 后台单独 typecheck 通过。 +- 目标单测通过:`admin.service.spec.ts` 共 `17` 个测试通过。 +- 全项目 `npm run typecheck` 通过。 +- 全项目 `npm run lint` 通过。 +- 全项目 `npm run test` 通过: + - 后端 `24` 个测试文件、`169` 个测试通过。 + - workers `1` 个测试通过。 + - admin/user-app 当前无测试文件并以 `passWithNoTests` 通过。 +- 全项目 `npm run build` 通过。 +- Vite 仍输出 CJS Node API deprecation 提醒,不影响测试或构建产物。 + +遗留问题: + +- 诊断 V1 是本地规则化分析,不调用真实 TextProvider;优点是稳定、零成本,缺点是语义理解不如真实模型。 +- 模式库目前已入库并后台可见,但还没有自动接入 Story Bible / 分镜生成 / Prompt Library 的生成上下文。 +- 目前不做竞品视频下载或自动转写,拉片文本需要人工粘贴;后续可接素材上传、ASR 或外部数据导入。 + +下一步建议: + +- 做“Prompt Library / 题材套路库 / IP 设定宇宙前台化 V1”:把 `creative_patterns`、Story Bible、角色资产库串起来,让新项目创建和脚本/分镜生成能直接选择并复用这些生产资产。 + +### Prompt Library / 题材套路库 / IP 设定宇宙前台化 V1 + +完成时间:2026-06-10 13:45:05 CST + +完成内容: + +- 新增项目与题材套路绑定表 `project_creative_patterns`,支持一个项目绑定多个 `creative_patterns`,并保留创建时的套路快照。 +- 新增用户端 API: + - `GET /api/projects/creative-patterns/library` + - `GET /api/projects/:id/creative-patterns` + - `PATCH /api/projects/:id/creative-patterns` +- 新项目创建支持传入 `creative_pattern_ids`,创建后自动绑定已启用的模式库条目,并增加对应 `usage_count`。 +- Story Bible 生成已接入项目绑定的题材套路: + - `selling_points` 增加题材套路库摘要。 + - `tone` 增加已选套路风格。 + - `world_summary` 增加套路 / Prompt 规则。 + - `taboo_rules` 增加模式库负向禁区。 +- 单集脚本生成已接入题材套路,脚本文本新增 `【题材套路库】` 区块,旁白也会吸收套路描述。 +- 分镜生成与单镜头 Prompt 重生成已接入题材套路: + - 正向 Prompt 增加 `题材套路/视觉Prompt参考`。 + - 负向 Prompt 增加 `题材套路禁区`。 + - 开场镜头吸收 `opening_hook`,结尾镜头吸收 `episode_rhythm`。 +- 用户端新建项目页新增“题材套路 / IP 设定宇宙”选择器,可从爆款诊断沉淀的模式库选择生产资产。 +- 用户端项目工作台新增已绑定套路摘要,方便人工确认当前项目使用了哪些生产模式。 +- 已应用本地数据库 migration:`20260610133500_project_creative_patterns_v1`。 + +修改文件: + +- backend/prisma/schema.prisma +- backend/src/projects/project.dto.ts +- backend/src/projects/project.types.ts +- backend/src/projects/projects.controller.ts +- backend/src/projects/projects.service.ts +- backend/src/projects/projects.service.spec.ts +- backend/src/story-bibles/story-bibles.service.ts +- backend/src/story-bibles/story-bibles.service.spec.ts +- backend/src/scripts/scripts.service.ts +- backend/src/scripts/scripts.service.spec.ts +- user-app/src/api/client.ts +- user-app/src/pages/index/index.vue +- user-app/src/styles.css +- CODEX_PROGRESS.md + +新增文件: + +- backend/prisma/migrations/20260610133500_project_creative_patterns_v1/migration.sql + +运行命令: + +- `git status --short` +- `npm run prisma:generate -w backend` +- `npm run typecheck -w backend` +- `npm run typecheck -w user-app` +- `npm test -w backend -- projects.service.spec.ts story-bibles.service.spec.ts scripts.service.spec.ts` +- `bash -lc 'set -a; source .env; set +a; npm run prisma:deploy -w backend'` +- `npm run typecheck` +- `npm run lint` +- `npm run test` +- `npm run build` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- Prisma Client generate:通过。 +- 本地数据库 migration deploy:通过,已应用 `20260610133500_project_creative_patterns_v1`。 +- 后端单独 typecheck 通过。 +- 用户端单独 typecheck 通过。 +- 目标单测通过:`projects.service.spec.ts`、`story-bibles.service.spec.ts`、`scripts.service.spec.ts` 共 `22` 个测试通过。 +- 全项目 `npm run typecheck` 通过。 +- 全项目 `npm run lint` 通过。 +- 全项目 `npm run test` 通过: + - 后端 `24` 个测试文件、`173` 个测试通过。 + - workers `1` 个测试通过。 + - admin/user-app 当前无测试文件并以 `passWithNoTests` 通过。 +- 全项目 `npm run build` 通过。 +- Vite 仍输出 CJS Node API deprecation 提醒,不影响测试或构建产物。 + +遗留问题: + +- 现有项目可以通过 API 更新绑定,但用户端当前只在“新建项目”阶段提供选择器;已有项目的可视化编辑入口建议下一阶段补。 +- 模式库来源仍依赖后台“爆款诊断 / 沉淀模式”,用户端暂不创建新模式,避免普通用户污染生产资产库。 +- 当前是本地规则化生成链路吸收题材套路;后续接真实 TextProvider 时,需要把这些模式作为 Provider Prompt 上下文继续传递。 + +下一步建议: + +- 做“模式库运营闭环 V1”:后台支持模式编辑、上架/停用、项目效果回流、使用次数和 ROI 统计;用户端补已有项目的模式调整入口,但要限制普通用户只能选择已审核上架的模式。 + +### 模式库运营闭环 V1 + +完成时间:2026-06-10 14:05:33 CST + +完成内容: + +- 后台模式库从“只读列表”升级为“可运营资产”: + - 支持编辑模式类型、标题、题材、语言、描述、Prompt 模板、负向 Prompt、标签、结构 JSON、效果分和状态。 + - 支持模式上架、停用、归档。 + - 支持从项目效果数据回流并重算 `effectiveness_score`。 +- 新增后台 API: + - `PATCH /api/admin/creative-patterns/:patternId` + - `PATCH /api/admin/creative-patterns/:patternId/status` + - `POST /api/admin/creative-patterns/:patternId/refresh-metrics` +- `GET /api/admin/creative-patterns` 现在返回每条模式的运营指标: + - 绑定项目数、完成项目数、活跃项目数。 + - 视频产物数、Provider 日志数、任务数、analytics 事件数。 + - 成本、收入估算、ROI 估算。 + - 平均质检分、平均完播率、播放数、点赞数。 +- ROI / 效果回流 V1 统计口径: + - 成本优先取成功 `provider_logs.cost_actual`,没有 Provider 成本时用 `render_tasks` / `video_clips` 兜底。 + - 收入估算取项目已支付订单金额,加上 `analytics_events.metric_json` 里的 `revenue` / `income` / `amount` / `gmv`。 + - 播放、点赞、完播率从 `analytics_events.metric_json` 读取。 + - 质量分从 `video_clips.quality_score` 读取。 +- 效果分回流会按使用项目数、完成项目数、质量分、完播率、ROI、播放和点赞综合折算,不再只靠人工主观分。 +- 新增操作日志: + - `admin_update_creative_pattern` + - `admin_update_creative_pattern_status` + - `admin_refresh_creative_pattern_metrics` +- 后台“爆款诊断 / 拉片分析”页的模式库区域新增: + - 模式库汇总指标卡。 + - 模式编辑表单。 + - 选中模式指标侧栏。 + - 表格 ROI / 成本 / 播放 / 完播展示。 + - 每行“编辑”“回流”操作。 + +修改文件: + +- backend/src/admin/admin.controller.ts +- backend/src/admin/admin.dto.ts +- backend/src/admin/admin.service.ts +- backend/src/admin/admin.service.spec.ts +- admin/src/App.vue +- admin/src/styles.css +- CODEX_PROGRESS.md + +新增文件: + +- 无 + +运行命令: + +- `git status --short` +- `npm run typecheck -w backend` +- `npm test -w backend -- admin.service.spec.ts` +- `npm run typecheck -w admin` +- `npm run typecheck` +- `npm run lint` +- `npm run test` +- `npm run build` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- 后端单独 typecheck 通过。 +- 后台单独 typecheck 通过。 +- 目标单测通过:`admin.service.spec.ts` 共 `20` 个测试通过。 +- 全项目 `npm run typecheck` 通过。 +- 全项目 `npm run lint` 通过。 +- 全项目 `npm run test` 通过: + - 后端 `24` 个测试文件、`176` 个测试通过。 + - workers `1` 个测试通过。 + - admin/user-app 当前无测试文件并以 `passWithNoTests` 通过。 +- 全项目 `npm run build` 通过。 +- Vite 仍输出 CJS Node API deprecation 提醒,不影响测试或构建产物。 + +遗留问题: + +- 本阶段没有新增长期效果快照表,模式 ROI 是按当前项目绑定和现有日志动态计算;后续如果 analytics 事件量很大,应单独做日级汇总表。 +- 收入估算依赖订单和 `analytics_events.metric_json`,真实发布平台数据未接入前,ROI 仍是内部估算口径。 +- 用户端已有项目的模式可视化调整入口仍未做,本阶段优先补后台运营闭环。 + +下一步建议: + +- 做“已有项目模式调整 / 生成上下文重跑 V1”:用户端和后台都能给已有项目调整模式绑定,并选择是否重新生成 Story Bible、脚本或分镜,使模式库运营结果真正反哺存量项目。 + +### 真人视频小样预检 / 验收闭环 V1 + +完成时间:2026-06-10 17:06:26 CST + +完成内容: + +- 开发重心切回仿真人视频测试验收,模式库后续再验证。 +- 新增真人视频生成前预检接口: + - `GET /api/episodes/:episodeId/live-action/video-clips/preflight` +- 预检报告不会生成视频、不消耗 Provider 成本,只读取项目、分镜、关键帧、Provider、Router 和成本配置。 +- 预检报告输出: + - `ready` + - `next_step` + - `blockers` + - `warnings` + - `summary` + - `breakdown` +- 预检可提前发现: + - 未确认分镜。 + - 未执行真人分镜改写。 + - 缺关键帧。 + - 真实视频 Provider 需要 PNG/JPG/WebP 关键帧,但当前仍是 mock SVG。 + - 真实视频生成未勾选确认。 + - Provider 不存在或未启用。 + - 预估费用超过单片段上限。 + - 超过 10 秒镜头会自动拆分成多个 5-10 秒子片段。 +- 用户端 AI 真人短剧区域接入预检: + - 工作台刷新时自动加载预检报告。 + - 原“估算”按钮升级为“预检”,同时刷新成本和预检。 + - 生成视频片段前强制再跑一次预检,不通过则阻断生成并显示第一条原因。 + - 页面展示预检状态、下一步、阻断/警告、Router 决策 Provider、拆片数量和预估成本。 +- 预检保持和实际生成一致的判断口径: + - 非 `mock-video` Provider 或 real mode Provider 需要真实视频确认。 + - 真实视频 Provider 必须使用 raster 关键帧。 + - 普通用户选择 Provider 不会强制 override,预检会提示最终仍走 Router;管理员保留 override 用于测试。 + +修改文件: + +- backend/src/live-action/live-action.controller.ts +- backend/src/live-action/live-action.dto.ts +- backend/src/live-action/live-action.service.ts +- backend/src/live-action/live-action.service.spec.ts +- user-app/src/api/client.ts +- user-app/src/pages/index/index.vue +- user-app/src/styles.css +- CODEX_PROGRESS.md + +新增文件: + +- 无 + +运行命令: + +- `git status --short` +- `npm test -w backend -- live-action.service.spec.ts` +- `npm run typecheck -w backend` +- `npm run typecheck -w user-app` +- `npm run typecheck` +- `npm run lint` +- `npm run test` +- `npm run build` +- `npm run typecheck` +- `npm run lint` +- `npm run test` +- `npm run build` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- 后端单独 typecheck 通过。 +- 用户端单独 typecheck 通过。 +- 目标单测通过:`live-action.service.spec.ts` 共 `8` 个测试通过。 +- 全项目 `npm run typecheck` 通过。 +- 全项目 `npm run lint` 通过。 +- 全项目 `npm run test` 通过: + - 后端 `24` 个测试文件、`179` 个测试通过。 + - workers `1` 个测试通过。 + - admin/user-app 当前无测试文件并以 `passWithNoTests` 通过。 +- 全项目 `npm run build` 通过。 +- Vite 仍输出 CJS Node API deprecation 提醒,不影响测试或构建产物。 + +遗留问题: + +- 当前预检已经覆盖真实视频开跑前的关键安全阀,但还没有做完整“一键跑 1 个镜头小样”的独立测试台。 +- 用户端真实关键帧目前仍主要来自 mock SVG;要测试真实 Hailuo/Kling 等图生视频,需要先接入真实图片关键帧或上传 raster 关键帧。 +- 后台 Router 审计已有质检/重试操作,但还没有把“预检报告”并入后台审计时间线。 + +下一步建议: + +- 继续围绕仿真人视频做“小样测试台 V1”:后台或用户端选择 1 个镜头,执行预检 -> 生成关键帧/上传关键帧 -> 生成单片段 -> 质检 -> 预览 -> 人工通过/驳回,形成上线前真实 Provider 验收标准流程。 + +### 真人小样测试台 V1 + +完成时间:2026-06-10 17:26:53 CST + +完成内容: + +- 继续聚焦仿真人视频测试优化验收,暂不推进模式库后续验证。 +- 后端新增按单个 shot 执行小样验收的能力: + - 预检接口支持 `shot_id`,可只返回当前测试镜头的 Router 决策、阻断原因、关键帧状态和成本。 + - 新增绑定上传 raster 关键帧接口:`POST /api/episodes/:episodeId/live-action/shots/:shotId/keyframe`。 + - 新增单镜头视频片段生成接口:`POST /api/episodes/:episodeId/live-action/shots/:shotId/video-clip/generate`。 + - 新增人工验收接口:`POST /api/live-action/video-clips/:clipId/manual-review`。 +- 单镜头生成复用现有 `generateSingleVideoClip`,不会产生一套和批量生成不同的逻辑。 +- 单镜头生成前强制执行该 shot 的预检,不通过则按阻断码直接拒绝。 +- 上传关键帧复用现有加密资产上传,再绑定到 shot: + - 只接受当前用户/管理员可访问资产。 + - 只接受当前项目资产。 + - 只接受 `image/png`、`image/jpeg`、`image/webp`。 + - 绑定后清空该 shot 旧的 `video_clip_asset_id`,避免旧片段被误认为当前关键帧产物。 +- 人工验收会写回: + - `video_clips.quality_status` + - `video_clips.quality_score` + - `video_clips.quality_issues` + - `storyboard_shots.video_status` + - `operation_logs` +- 用户端 AI 真人短剧区域新增“真人小样测试台”: + - 可选择一个真人镜头。 + - 可单镜头预检。 + - 可上传 PNG/JPG/WebP 关键帧并绑定到该镜头。 + - 可只生成该镜头的小样视频。 + - 可预览关键帧和小样视频。 + - 可对当前小样执行质检。 + - 可人工通过或驳回当前小样。 + - 显示当前 shot 的重要度、情绪、动作评分、route tier、Provider、拆片数量和预计成本。 +- 分集切换时同步刷新真人资源,避免小样测试台仍显示上一集的 shot/clip。 +- 补充小样测试台样式,适配现有深色运营台风格和移动端单列布局。 + +修改文件: + +- backend/src/live-action/live-action.controller.ts +- backend/src/live-action/live-action.dto.ts +- backend/src/live-action/live-action.service.ts +- backend/src/live-action/live-action.service.spec.ts +- user-app/src/api/client.ts +- user-app/src/pages/index/index.vue +- user-app/src/styles.css +- CODEX_PROGRESS.md + +新增文件: + +- 无 + +运行命令: + +- `git status --short` +- `npm test -w backend -- live-action.service.spec.ts` +- `npm run typecheck -w backend` +- `npm run typecheck -w user-app` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- 目标单测通过:`live-action.service.spec.ts` 共 `12` 个测试通过。 +- 后端单独 typecheck 通过。 +- 用户端单独 typecheck 通过。 +- 全项目 `npm run typecheck` 通过。 +- 全项目 `npm run lint` 通过。 +- 全项目 `npm run test` 通过: + - 后端 `24` 个测试文件、`183` 个测试通过。 + - workers `1` 个测试通过。 + - admin/user-app 当前无测试文件并以 `passWithNoTests` 通过。 +- 全项目 `npm run build` 通过。 +- Vite 仍输出 CJS Node API deprecation 提醒,不影响测试或构建产物。 + +遗留问题: + +- 真实 Hailuo/Kling 等 Provider 的账号、Key 和真实返回体还未接入,本阶段仍保持 mock-first 和可替换 Provider 架构。 +- 上传关键帧目前通过用户端通用资产上传完成,后台端暂未新增独立小样测试页。 +- 人工验收备注为当前小样级备注,后续可扩展为结构化问题标签:脸漂、手崩、口型、动作跳切、画质、违规等。 + +下一步建议: + +- 继续做“真人视频真实 Provider 小样验收脚本 V1”:用 1 个固定项目、1 个固定镜头、1 张真实 PNG/JPG 关键帧,按 Provider 分别跑 Hailuo/Kling/mock,记录首帧、片段、质检分、成本和失败原因,形成上线前 Provider 准入标准。 + +### 真人视频真实 Provider 小样验收脚本 V1 + +完成时间:2026-06-10 17:52:03 CST + +完成内容: + +- 新增可重复执行的真人视频 Provider 小样验收脚本: + - `npm run live-action:acceptance -w backend` + - 根目录别名:`npm run live-action:acceptance` +- 脚本通过 Nest application context 调用现有服务,不绕过业务链路: + - `AssetsService` + - `LiveActionService` + - `ProvidersService` + - `PrismaService` +- 脚本输入固定项目、固定分集、固定镜头和真实 PNG/JPG/WebP 关键帧。 +- 脚本会按 Provider 矩阵逐个执行: + - 单镜头预检。 + - 可选上传并绑定 raster 关键帧。 + - 单镜头视频片段生成。 + - 即时质检。 + - 成本、质量分、失败原因、输出资产记录。 +- 默认 Provider 矩阵: + - `hailuo` -> `minimax_hailuo_23_fast` + - `kling` -> `kling-image-to-video` + - `mock` -> `mock-video` +- 脚本默认安全: + - 没有 `LIVE_ACTION_ACCEPTANCE_CONFIRM_REAL_VIDEO=true` 时,真实 Provider 只做预检并跳过生成。 + - 真实 Provider 默认不自动启用,除非显式设置 `LIVE_ACTION_ACCEPTANCE_FORCE_ENABLE_PROVIDERS=true`。 + - 真实生成仍会走现有 `confirm_real_video`、Provider enabled、raster keyframe、成本上限等保护。 +- 关键帧上传细节: + - 上传使用项目 owner 身份,避免管理员上传后用户端无法预览私有素材。 + - Provider override、验收和日志仍使用管理员身份。 +- 脚本输出验收报告: + - JSON 报告。 + - Markdown 报告。 + - 默认路径:`storage/private/live-action-acceptance/YYYY-MM-DD/` +- 报告字段包含: + - Provider code / label。 + - preflight ready / next step。 + - blockers / warnings。 + - clip id。 + - output asset id。 + - `/api/assets/:assetId/download` 预览下载路径。 + - actual cost。 + - quality status / score。 + - repair action。 + - error message。 + - passed / failed / skipped 汇总。 +- 支持失败门禁: + - `LIVE_ACTION_ACCEPTANCE_FAIL_ON_REJECT=true` 时,只要有 Provider 未通过,脚本退出码为 1,后续可接 CI 或上线前检查。 + +示例命令: + +```bash +LIVE_ACTION_ACCEPTANCE_PROJECT_ID=123 \ +LIVE_ACTION_ACCEPTANCE_EPISODE_ID=456 \ +LIVE_ACTION_ACCEPTANCE_SHOT_ID=789 \ +LIVE_ACTION_ACCEPTANCE_KEYFRAME_PATH=/www/wwwroot/ai/storage/test-keyframe.png \ +LIVE_ACTION_ACCEPTANCE_PROVIDERS=hailuo,kling,mock \ +LIVE_ACTION_ACCEPTANCE_CONFIRM_REAL_VIDEO=true \ +LIVE_ACTION_ACCEPTANCE_MAX_COST_PER_CLIP=1 \ +npm run live-action:acceptance -w backend +``` + +修改文件: + +- package.json +- backend/package.json +- backend/src/live-action/live-action-provider-acceptance.ts +- CODEX_PROGRESS.md + +新增文件: + +- backend/src/live-action/live-action-provider-acceptance.ts + +运行命令: + +- `git status --short` +- `npm run typecheck -w backend` +- `npm run live-action:acceptance -w backend` +- `npm run typecheck` +- `npm run lint` +- `npm run test` +- `npm run build` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- 后端单独 typecheck 通过。 +- `npm run live-action:acceptance -w backend` 可正常拉起脚本,并在未传必填环境变量时安全失败:`LIVE_ACTION_ACCEPTANCE_PROJECT_ID is required`。 +- 全项目 `npm run typecheck` 通过。 +- 全项目 `npm run lint` 通过。 +- 全项目 `npm run test` 通过: + - 后端 `24` 个测试文件、`183` 个测试通过。 + - workers `1` 个测试通过。 + - admin/user-app 当前无测试文件并以 `passWithNoTests` 通过。 +- 全项目 `npm run build` 通过。 +- Vite 仍输出 CJS Node API deprecation 提醒,不影响测试或构建产物。 + +遗留问题: + +- 本阶段只实现验收脚本,不实际调用 Hailuo/Kling 付费接口;真实调用需要先在后台配置真实 Key、启用 Provider,并显式设置确认环境变量。 +- 报告为文件型报告,后台端暂未做 Provider 准入报告列表页。 +- 当前质量检查仍走 mock-qc;后续可接真实视觉 QA Provider,对脸部漂移、手部异常、动作跳切、口型等做更细评分。 + +下一步建议: + +- 做“真人 Provider 准入报告后台化 V1”:后台读取 `storage/private/live-action-acceptance` 报告,按 Provider 展示质量分、成本、失败原因、输出视频预览,并标记“准入/禁用/待复测”。 + +### 前端 H5/PC 流程视觉验收与重叠修复 V1 + +完成时间:2026-06-10 18:34:37 CST + +完成内容: + +- 新增 Playwright 视觉巡检脚本,覆盖用户端 PC/H5 与后台 PC/H5 的关键登录态页面。 +- 用户端覆盖:新建、项目、制作、额度、审核、进度、成品、教程、我的。 +- 后台覆盖:仪表盘、项目管理、任务管理、Router 审计、爆款诊断、AI 平台入口、AI 接入、成本日志、审计日志。 +- 修复用户端 H5 NEXT 浮动提示遮挡/重叠问题: + - H5 端不再显示浮动 NEXT 提示,避免压住输入框、按钮和底部导航。 + - PC 端保留浮动提示,并限制高度、字号和长文本省略。 + - H5 顶部长项目标题字号下调,降低长标题换行造成的视觉压迫。 +- 修复后台 AI 平台入口 PC 表格右侧状态列被裁切问题: + - 平台入口清单增加专用表格类。 + - 宽屏下使用固定表格布局和列宽,内容允许换行。 +- 巡检脚本支持识别合法的 line-clamp/ellipsis 省略,避免把正常省略号误报为 UI 溢出。 +- 最终视觉报告生成在 `storage/private/frontend-visual-audit/report.json`,截图生成在 `storage/private/frontend-visual-audit/*.png`。 + +修改文件: + +- user-app/src/styles.css +- admin/src/App.vue +- admin/src/styles.css +- CODEX_PROGRESS.md + +新增文件: + +- tools/frontend-visual-audit.mjs + +运行命令: + +- `git status --short` +- `npx -y playwright@1.49.1 --version` +- `npx -y playwright@1.49.1 install chromium` +- `NODE_PATH=/root/.npm/_npx/f0a362733743bae2/node_modules node tools/frontend-visual-audit.mjs` +- `npm run lint` +- `npm run typecheck` +- `npm run test` +- `npm run build` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- Playwright Chromium 安装成功,视觉巡检脚本可执行。 +- 最终视觉巡检共覆盖 `36` 个页面步骤,汇总结果: + - 横向页面溢出:`0` + - 文本异常溢出:`0` + - 交互元素重叠:`0` + - console/page error:`0` +- 全项目 `npm run lint` 通过。 +- 全项目 `npm run typecheck` 通过。 +- 全项目 `npm run test` 通过: + - backend `24` 个测试文件、`183` 个测试通过。 + - workers `1` 个测试通过。 + - admin/user-app 当前无测试文件并以 `passWithNoTests` 通过。 +- 全项目 `npm run build` 通过。 +- Vite 仍输出 CJS Node API deprecation 提醒,不影响测试或构建产物。 + +遗留问题: + +- 本次是自动化视觉巡检 + 关键截图人工抽查,不等同于全部业务动作真实提交验收;真实流程仍建议按“创建项目 -> 生成 -> 任务队列 -> 预览/下载”逐条人工点一遍。 +- Playwright 目前通过 `npx` 缓存路径配合 `NODE_PATH` 运行,尚未写入项目依赖或 npm script。 +- H5 端关闭了浮动 NEXT 提示,后续如果要恢复,需要改成非固定布局或页面内提示,不能再用固定浮层压操作区。 + +下一步建议: + +- 做“前端真实业务动作 E2E V1”:用 Playwright 不只截图,而是实际走创建项目、版权确认、生成 mock、查看任务、预览成品、后台审计的完整点击链路,并把失败点输出成报告。 + +### 前端真实业务动作 E2E V1 + +完成时间:2026-06-10 18:48:50 CST + +完成内容: + +- 新增真实业务动作 E2E 脚本,覆盖“上传小说改编 -> mock 生成 -> 成品预览 -> 后台核查”的完整主链路。 +- E2E 使用 Playwright 打开真实用户端和后台端页面,关键业务动作通过 UI 点击执行,API 仅用于登录、额度准备和结果断言。 +- 用户端真实点击链路: + - 打开制作台。 + - 新建“上传小说改编 / 图片漫剧版”项目。 + - 准备 mock 支付额度。 + - 粘贴小说正文。 + - 版权确认。 + - 解析小说。 + - 生成并确认故事圣经。 + - 抽取并确认角色库。 + - 生成长篇记忆。 + - 生成并确认分集计划。 + - 生成并确认脚本。 + - 生成并确认分镜。 + - 生成分镜图。 + - 生成多角色音频和字幕。 + - 合成 MP4。 + - 自动预览合成结果。 + - 文本审核、视频审核。 + - 成品页再次预览。 +- 后台真实点击链路: + - 打开任务管理。 + - 打开内容审核。 + - 打开审计日志。 +- E2E 产出 JSON 与 Markdown 报告,并保存关键截图。 +- 最终通过样本: + - project_id:`48` + - episode_id:`34` + - video_asset_id:`272` + - 任务数:`13` + - 后台内容审核记录:`2` + - 失败任务:`0` + +修改文件: + +- CODEX_PROGRESS.md + +新增文件: + +- tools/frontend-business-e2e.mjs + +运行命令: + +- `git status --short` +- `NODE_PATH=/root/.npm/_npx/f0a362733743bae2/node_modules node tools/frontend-business-e2e.mjs` +- `npm run lint` +- `npm run typecheck` +- `npm run test` +- `npm run build` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- 最终 E2E 报告: + - `storage/private/frontend-business-e2e/report-20260610104646.json` + - `storage/private/frontend-business-e2e/report-20260610104646.md` + - `storage/private/frontend-business-e2e/latest-report.json` + - `storage/private/frontend-business-e2e/latest-report.md` +- 最终 E2E 汇总: + - 总步骤:`9` + - 通过步骤:`9` + - 失败步骤:`0` + - 失败数:`0` + - 截图数:`11` +- 全项目 `npm run lint` 通过。 +- 全项目 `npm run typecheck` 通过。 +- 全项目 `npm run test` 通过: + - backend `24` 个测试文件、`183` 个测试通过。 + - workers `1` 个测试通过。 + - admin/user-app 当前无测试文件并以 `passWithNoTests` 通过。 +- 全项目 `npm run build` 通过。 +- Vite 仍输出 CJS Node API deprecation 提醒,不影响测试或构建产物。 + +遗留问题: + +- E2E 记录了一个非阻断 UI warning:`有 8 句 TTS 超出分配时长`。音频、字幕、视频仍生成成功,但说明当前 mock 剧本台词时长与镜头时长存在节奏不匹配,后续应优化脚本/分镜时长分配或 TTS 语速策略。 +- 后台 `operation_logs` 对普通用户生成动作没有 project 维度日志,E2E 标记为 warning;当前后台仍能通过任务管理和内容审核查到本项目生成与审核结果。 +- Playwright 仍通过 `npx` 缓存路径配合 `NODE_PATH` 运行,尚未写入项目依赖或 npm script。 + +下一步建议: + +- 做“E2E 问题闭环 V1”:针对 TTS 超时 warning,优化脚本分镜生成约束,让每句台词预估时长不超过镜头时长;同时评估是否需要为普通用户关键生成动作补充 operation_logs 或生成流水线审计日志。 + +### PC 制作页额度余额遮挡修复 V1 + +完成时间:2026-06-10 18:53:40 CST + +完成内容: + +- 修复用户端 PC 制作页额度余额卡片在双列布局下宽度不足导致的标题、额度信息和按钮挤压遮挡问题。 +- 将 `.quota-inline` 在桌面制作页中设置为横跨整行,让“额度余额 / 可用 / 冻结 / 预估 / 状态 / 查看额度 / 冻结额度”有稳定展示空间。 +- 为额度卡片单独补充标题换行、按钮对齐、720px 以上三列布局、1080px 以上桌面整行布局,保留 H5 纵向堆叠。 +- 重新运行前端视觉巡检,PC/H5 用户端和后台端共 36 个页面状态均无页面溢出、文本溢出、交互元素重叠、控制台错误。 + +修改文件: + +- user-app/src/styles.css +- CODEX_PROGRESS.md + +新增文件: + +- 无 + +运行命令: + +- `git status --short` +- `NODE_PATH=/root/.npm/_npx/f0a362733743bae2/node_modules node tools/frontend-visual-audit.mjs` +- `npm run lint` +- `npm run typecheck` +- `npm run test` +- `npm run build` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- 前端视觉巡检通过: + - 报告:`storage/private/frontend-visual-audit/report.json` + - PC 制作页截图:`storage/private/frontend-visual-audit/user-pc-studio.png` + - 36 个页面状态全部 `pageOverflow=0`、`textOverflow=0`、`overlaps=0`、`consoleErrors=0` +- 全项目 `npm run lint` 通过。 +- 全项目 `npm run typecheck` 通过。 +- 全项目 `npm run test` 通过: + - backend `24` 个测试文件、`183` 个测试通过。 + - workers `1` 个测试通过。 + - admin/user-app 当前无测试文件并以 `passWithNoTests` 通过。 +- 全项目 `npm run build` 通过。 +- Vite 仍输出 CJS Node API deprecation 提醒,不影响测试或构建产物。 + +遗留问题: + +- 本次只修复 PC 制作页额度卡片遮挡;此前 E2E 记录的 TTS 超时 warning 和普通用户生成动作缺少 project 维度 operation_logs 仍待后续阶段处理。 + +下一步建议: + +- 继续围绕“仿真人视频完美落地”,优先处理真实业务 E2E 报告中的 TTS 节奏分配和生成流水线日志可追踪性。 + +### E2E 非阻断问题闭环 V1 + +完成时间:2026-06-10 20:27:28 CST + +完成内容: + +- 修复真实业务 E2E 中的两个非阻断 warning: + - `8` 句 TTS 超出分配时长。 + - 普通用户生成动作缺少 project 维度 `operation_logs`。 +- TTS 节奏优化: + - mock VoiceProvider 生成的单句音频按当前片段 `target_duration` 参与时间轴,避免 mock Provider 粗略时长导致假阳性超时 warning。 + - 真实 VoiceProvider 请求增加 `target_duration` 输入。 + - 台词预估时长超过镜头分配时,自动写入建议 `speech_speed`,后续真实 Provider 可按语速约束生成。 + - 保留原有 TTS 缓存策略:同文本 + 同音色 + 同 voice 配置复用;mock 缓存命中时按当前片段目标时长参与验收。 +- 生成流水线日志补强: + - `audio_generate`、`subtitle_generate`、`video_render` 创建任务时写入 `target_type=project` 的 `operation_logs`。 + - 日志 metadata 记录 `task_id`、`episode_id`、`shot_id`、`task_type`、`input_hash`,后台审计页可按项目查到普通用户生成动作。 +- 新增/更新 MediaService 单元测试,覆盖 mock TTS 不再产生时间轴 warning、媒体任务创建写 project 维度操作日志。 +- 重新 build 后端并重启 `127.0.0.1:3000` 后台服务,确保 E2E 跑到最新代码。 + +修改文件: + +- backend/src/media/media.service.ts +- backend/src/media/media.service.spec.ts +- CODEX_PROGRESS.md + +新增文件: + +- 无 + +运行命令: + +- `git status --short` +- `npm --workspace backend run test -- src/media/media.service.spec.ts` +- `npm --workspace backend run build` +- `NODE_PATH=/root/.npm/_npx/f0a362733743bae2/node_modules node tools/frontend-business-e2e.mjs` +- `npm run lint` +- `npm run typecheck` +- `npm run test` +- `npm run build` +- `curl -fsS http://127.0.0.1:3000/api/client-config` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- `backend/src/media/media.service.spec.ts` 单测通过:`14` 个测试通过。 +- 真实业务动作 E2E 通过: + - 报告:`storage/private/frontend-business-e2e/report-20260610122518.json` + - Markdown:`storage/private/frontend-business-e2e/report-20260610122518.md` + - project_id:`50` + - episode_id:`36` + - video_asset_id:`298` + - 总步骤:`9` + - 通过步骤:`9` + - 失败步骤:`0` + - warning:`0` + - user_task_count:`13` + - admin_task_count:`13` + - admin_review_count:`2` + - operation_log_count:`3` + - failed_tasks:`0` +- 全项目 `npm run lint` 通过。 +- 全项目 `npm run typecheck` 通过。 +- 全项目 `npm run test` 通过: + - backend `24` 个测试文件、`183` 个测试通过。 + - workers `1` 个测试通过。 + - admin/user-app 当前无测试文件并以 `passWithNoTests` 通过。 +- 全项目 `npm run build` 通过。 +- 后端服务已重启,`/api/client-config` 健康检查通过。 +- Vite 仍输出 CJS Node API deprecation 提醒,不影响测试或构建产物。 + +遗留问题: + +- 当前闭环验证仍基于 mock Provider 和 FFmpeg 合成,真实 Hailuo / Kling / MiniMax 等 Provider 还需要小样准入验收。 +- E2E 只验证主链路,不代表所有异常分支、任务恢复分支、真实 Provider 失败回退分支已经完全覆盖。 + +下一步建议: + +- 回到“仿真人视频完美落地”主线,做真实 Provider 小样准入验收:固定项目、固定镜头、真实 PNG/JPG 关键帧,分别跑 Mock / Hailuo / Kling,记录画面、成本、质检分和失败原因,形成 Provider 准入标准。 + +### 真实 Provider 小样准入验收 V1 + +完成时间:2026-06-10 20:36:42 CST + +完成内容: + +- 进入“固定项目 / 固定镜头 / 真实 raster 关键帧 / Provider 矩阵”验收阶段。 +- 选定固定样本: + - project_id:`36` + - episode_id:`28` + - shot_id:`134` + - 镜头:`雨夜病房惊醒` + - 时长:`4` 秒 +- 原镜头关键帧是 mock SVG,不满足真实 Provider PNG/JPG/WebP 要求;本阶段上传并绑定真实 PNG 关键帧: + - keyframe_asset_id:`301` + - mime_type:`image/png` + - 来源文件:`storage/private/generated-images/2026-06-02/557d05bf-2b86-48d0-a427-f0171d7038ce.png` +- 执行 Provider 准入矩阵: + - `mock-video` + - `minimax_hailuo_23_fast` + - `kling-image-to-video` +- Mock Provider 实际生成单镜头小样: + - clip_id:`4` + - output_asset_id:`302` + - 视频:H.264 / 1080x1920 / 4 秒 + - cost_actual:`0` + - quality_status:`passed` + - quality_score:`94` +- Hailuo / Kling 未调用外部接口,未产生真实成本;准入报告标记为 `skipped`: + - Hailuo:Provider 未启用、未显式确认真实视频费用、`MINIMAX_API_KEY` 未配置。 + - Kling:Provider 未启用、未显式确认真实视频费用、`KLING_API_KEY` 未配置。 +- 增强 `live-action-provider-acceptance` 验收脚本: + - 增加 Provider 准入预检字段:enabled、mode、api_key_env、api_key_configured。 + - 未启用 / 缺 Key / 未确认真实费用时标记 `skipped`,避免和真实生成失败混淆。 + - Markdown 表格增加 Enabled / Key 列。 + - 默认报告目录改为项目根目录 `storage/private/live-action-acceptance`,从 backend workspace 执行时不再落到 `backend/storage`。 + - 控制台摘要增加 enabled/key 状态。 + +修改文件: + +- backend/src/live-action/live-action-provider-acceptance.ts +- CODEX_PROGRESS.md + +新增文件: + +- 无 + +运行命令: + +- `git status --short` +- `npm run live-action:acceptance` +- `npm run typecheck -w backend` +- `npm run lint` +- `npm run typecheck` +- `npm run test` +- `npm run build` +- `ffprobe -v error -show_entries stream=codec_type,codec_name,width,height,duration -show_entries format=duration,size -of json /www/wwwroot/ai/storage/private/live-action-video-clips/2026-06-10/33fcb7ed-75ac-4306-9735-89ca58d62b44.mp4` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- Provider 准入报告: + - JSON:`storage/private/live-action-acceptance/2026-06-10/live-action-acceptance-project-36-episode-28-shot-134-20260610123444.json` + - Markdown:`storage/private/live-action-acceptance/2026-06-10/live-action-acceptance-project-36-episode-28-shot-134-20260610123444.md` +- 报告汇总: + - passed:`1` + - failed:`0` + - skipped:`2` +- Provider 结果: + - `mock-video`:passed,clip_id=`4`,output_asset_id=`302`,quality_score=`94`,cost_actual=`0`。 + - `minimax_hailuo_23_fast`:skipped,未启用,`MINIMAX_API_KEY` 未配置,未确认真实费用。 + - `kling-image-to-video`:skipped,未启用,`KLING_API_KEY` 未配置,未确认真实费用。 +- 视频资产验证: + - asset_id:`302` + - mime_type:`video/mp4` + - codec:`h264` + - 分辨率:`1080x1920` + - duration:`4.000000` + - size:`11704` +- 提取首帧用于画面记录: + - `storage/private/live-action-acceptance/2026-06-10/frames/mock-clip-4-first-frame.png` + - 该首帧为 mock 占位画面,只验证流水线,不代表真实 Provider 画质。 +- 后端单独 typecheck 通过。 +- 全项目 `npm run lint` 通过。 +- 全项目 `npm run typecheck` 通过。 +- 全项目 `npm run test` 通过: + - backend `24` 个测试文件、`183` 个测试通过。 + - workers `1` 个测试通过。 + - admin/user-app 当前无测试文件并以 `passWithNoTests` 通过。 +- 全项目 `npm run build` 通过。 +- Vite 仍输出 CJS Node API deprecation 提醒,不影响测试或构建产物。 + +遗留问题: + +- Hailuo / Kling 尚未真实生成,因为后台 Provider 仍是 disabled,且 `.env` 未配置 `MINIMAX_API_KEY` / `KLING_API_KEY`。 +- 本轮 Mock 小样首帧是占位色块,只能证明“任务、关键帧、片段、质检、成本、报告”链路打通,不能作为真实画质判断。 +- 当前质量检查仍是 mock-qc;真实 Provider 准入后,还需要引入真实视觉 QA 或人工小样评分标准。 + +下一步建议: + +- 开通并配置 MiniMax Hailuo 账号后,只启用 `minimax_hailuo_23_fast`,设置 `MINIMAX_API_KEY`、单次成本上限和每日成本上限,再用同一个 `project_id=36 / episode_id=28 / shot_id=134 / keyframe_asset_id=301` 跑一次真实 Hailuo 小样;Kling 放在 Hailuo 通过后再对比。 + +### Live Action 白底风格修复 V1 + +完成时间:2026-06-10 20:50:15 CST + +完成内容: + +- 修复用户端制作页 `Live Action / AI 真人短剧` 区块内部白底不匹配问题。 +- 给真人短剧区域增加 `live-action-panel` 专属 class,避免依赖不存在的 `.app-dark` 选择器。 +- 为真人视频预检框、小样测试台、小样卡片、预检 breakdown 增加深色运营台风格兜底。 +- 修复 `强制重生成` checkbox 原生白色方块问题,统一成深色小控件,并覆盖通用 input padding 导致的尺寸撑大。 + +修改文件: + +- `user-app/src/pages/index/index.vue` +- `user-app/src/styles.css` +- `CODEX_PROGRESS.md` + +新增文件: + +- 无 + +运行命令: + +- `git status --short` +- `NODE_PATH=/root/.npm/_npx/f0a362733743bae2/node_modules node tools/frontend-visual-audit.mjs` +- 定点 Playwright 截图检查 Live Action 区块 PC/H5 背景色与 checkbox 尺寸 +- `npm run lint` +- `npm run typecheck` +- `npm run test` +- `npm run build` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- 完整前端视觉巡检通过: + - 报告:`storage/private/frontend-visual-audit/report.json` + - 用户端 PC/H5 与后台 PC/H5 共 `36` 个页面状态全部为 `pageOverflow=0 / textOverflow=0 / overlaps=0 / consoleErrors=0`。 +- Live Action 定点截图: + - PC:`storage/private/frontend-visual-audit/live-action-panel-pc.png` + - H5:`storage/private/frontend-visual-audit/live-action-panel-h5.png` +- 定点样式读取结果: + - `.live-action-panel .sample-panel` 背景为 `rgba(10, 19, 33, 0.78)`。 + - `.live-action-panel input[type="checkbox"]` 背景为 `rgb(7, 20, 38)`,尺寸为 `18x18`,`padding=0px`。 +- 全项目 `npm run lint` 通过。 +- 全项目 `npm run typecheck` 通过。 +- 全项目 `npm run test` 通过: + - backend `24` 个测试文件、`183` 个测试通过。 + - workers `1` 个测试通过。 + - admin/user-app 当前无测试文件并以 `passWithNoTests` 通过。 +- 全项目 `npm run build` 通过。 +- Vite 仍输出 CJS Node API deprecation 提醒,不影响测试或构建产物。 + +遗留问题: + +- 本轮只修复 Live Action 制作区白底/checkbox 风格问题,没有改动真人视频生成业务逻辑。 +- Hailuo / Kling 真实 Provider 仍未启用,真实画质验收待 API Key 和费用确认后继续。 + +下一步建议: + +- 继续围绕真人视频真实 Provider 小样验收,先启用 Hailuo 单 Provider 跑固定镜头,再和 Mock/Kling 做成本、画质、失败原因对比。 + +### MiniMax Hailuo 超时保存修复 V1 + +完成时间:2026-06-10 22:02:39 CST + +完成内容: + +- 修复后台保存 MiniMax/Hailuo Provider 配置时报 `timeout_ms must be an integer between 1000 and 180000` 的问题。 +- 原因是 Hailuo/Wan/Vidu/Seedance 等异步视频 Provider 默认需要 `300000ms` 级别长轮询超时,但通用 Provider 保存接口只允许到 `180000ms`。 +- 将通用 Provider 运行配置保存上限放宽到 `600000ms`,OpenAI 统一配置的独立上限暂不改变。 +- 新增单元测试覆盖 `minimax_hailuo_23_fast` 保存 `timeout_ms=300000` 的场景。 +- 重新构建并用 systemd 临时服务 `ai-backend.service` 启动后端,使修复立即生效。 + +修改文件: + +- `backend/src/providers/providers.service.ts` +- `backend/src/providers/providers.service.spec.ts` +- `CODEX_PROGRESS.md` + +新增文件: + +- 无 + +运行命令: + +- `git status --short` +- `npm run test -w backend -- src/providers/providers.service.spec.ts` +- `npm run typecheck -w backend` +- `npm run lint` +- `npm run typecheck` +- `npm run test` +- `npm run build` +- `systemd-run --unit=ai-backend ... node dist/main.js` +- `curl http://127.0.0.1:3000/api/health` +- 后台 API 验证 `minimax_hailuo_23_fast` 保存 `timeout_ms=300000` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- Provider 单测通过:`src/providers/providers.service.spec.ts`,`29` 个测试通过。 +- 后端 `npm run typecheck -w backend` 通过。 +- 全项目 `npm run lint` 通过。 +- 全项目 `npm run typecheck` 通过。 +- 全项目 `npm run test` 通过: + - backend `24` 个测试文件、`184` 个测试通过。 + - workers `1` 个测试通过。 + - admin/user-app 当前无测试文件并以 `passWithNoTests` 通过。 +- 全项目 `npm run build` 通过。 +- 后端 `ai-backend.service` 状态为 `active`,`/api/health` 返回 `status=ok`。 +- 实际接口验证通过: + - Provider:`minimax_hailuo_23_fast` + - 保存后 `timeout_ms=300000` + - `enabled=false` + - `key_status=none` + +遗留问题: + +- 本轮没有替用户保存 MiniMax API Key,也没有启用真实 Hailuo,避免误触发真实扣费。 +- 用户需要重新在后台保存 MiniMax Key;保存成功后再手动启用 `minimax_hailuo_23_fast`。 + +下一步建议: + +- 重新保存 MiniMax API Key,超时填 `300000`;第一轮只启用 `minimax_hailuo_23_fast`,单次成本上限 `1 USD`,当日上限 `10 USD`,先跑 1 个固定镜头小样。 + +### AI Provider 同公司 Key 同步与列表筛选 V1 + +完成时间:2026-06-10 22:20:56 CST + +完成内容: + +- 修复 AI 接入体验问题:同一家公司同一个 API Key 不再需要在 Text / Novel / Image / Video / TTS 中反复保存。 +- 后端 `updateProviderRuntimeConfig` 在保存新 `api_key` 时,默认按相同 `config_json.api_key_env` 同步密钥到其它真实 Provider。 +- 同步只写入密钥,不自动启用其它 Provider,不修改优先级和成本阈值,避免误触发真实扣费。 +- 后台单 Provider 配置表单新增“保存新 Key 时同步同公司接入”开关,并显示当前 Key 分组。 +- 后台 AI 接入列表新增关键词、类型、密钥状态、启用状态筛选,增加 Key 分组列,减少长列表翻找成本。 +- 新增单元测试覆盖保存 `deepseek-text` 时同步到 `deepseek-novel`,但不影响其它公司 Provider 的场景。 +- 对当前数据库做了一次安全回填:复用已加密保存的 `minimax_hailuo_23_fast` Key,同步到 `minimax_hailuo_23`、`minimax-text`、`minimax-tts`;未启用这些 Provider。 + +修改文件: + +- `backend/src/providers/provider.dto.ts` +- `backend/src/providers/providers.service.ts` +- `backend/src/providers/providers.service.spec.ts` +- `admin/src/App.vue` +- `admin/src/styles.css` +- `CODEX_PROGRESS.md` + +新增文件: + +- 无 + +运行命令: + +- `git status --short` +- `npm run test -w backend -- src/providers/providers.service.spec.ts` +- `npm run typecheck -w backend` +- `npm run lint` +- `npm run typecheck` +- `npm run test` +- `npm run build` +- `systemctl restart ai-backend.service` +- `curl http://127.0.0.1:3000/api/health` +- DeepSeek `/models` 轻量连接检查 +- 当前 MiniMax 同分组加密 Key 回填检查 + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- Provider 单测通过:`src/providers/providers.service.spec.ts`,`30` 个测试通过。 +- 后端 `npm run typecheck -w backend` 通过。 +- 全项目 `npm run lint` 通过。 +- 全项目 `npm run typecheck` 通过。 +- 全项目 `npm run test` 通过: + - backend `24` 个测试文件、`185` 个测试通过。 + - workers `1` 个测试通过。 + - admin/user-app 当前无测试文件并以 `passWithNoTests` 通过。 +- 全项目 `npm run build` 通过。 +- 后端 `ai-backend.service` 重启成功,`/api/health` 返回 `status=ok`。 +- DeepSeek 轻量连接检查通过: + - `deepseek-text`:Key 已保存,已启用,`/models=200`。 + - `deepseek-novel`:Key 已保存,已启用,`/models=200`。 +- MiniMax 当前 Key 状态: + - `minimax_hailuo_23_fast`:Key 已保存,已启用。 + - `minimax_hailuo_23`:Key 已保存,未启用。 + - `minimax-text`:Key 已保存,未启用。 + - `minimax-tts`:Key 已保存,未启用。 + +遗留问题: + +- MiniMax TTS/Text 和标准 Hailuo 只是补齐 Key,仍需按真实测试计划单独启用和限制成本阈值。 +- 本轮没有触发任何真实生成任务,没有进行 Hailuo 视频扣费测试。 + +下一步建议: + +- 后台人工刷新 AI 接入页,确认筛选、Key 分组和同步开关显示正常;然后进入真人视频固定镜头小样,只启用 `minimax_hailuo_23_fast` 先跑 1 条。 + +### AI 接入后受控验收测试 V1 + +完成时间:2026-06-10 23:06:00 CST + +完成内容: + +- 执行 DeepSeek 真实文本/小说小样测试,未触发真实视频生成。 +- 执行一轮用户端 Mock 漫剧生产 API E2E: + - 注册测试用户。 + - 创建 AI 原创项目。 + - mock 支付标准包并冻结额度。 + - 生成原创创意、大纲、章节、自检。 + - 生成并确认故事圣经。 + - 抽取并确认角色。 + - 生成剧情记忆。 + - 生成并确认分集计划。 + - 生成并确认单集脚本。 + - 生成并确认分镜。 + - 生成角色锚点图与 10 张分镜图。 + - 生成混合 TTS、字幕。 + - 使用 FFmpeg 合成 1080x1920 MP4。 + - 检查后台任务、Provider 日志、成本日志、项目详情、磁盘文件和 `ffprobe` 元数据。 +- 本轮没有触发 Hailuo / Kling / Sora 等真实视频扣费。 + +测试对象: + +- 测试用户:`codex-e2e-20260610150239@example.com` +- 项目 ID:`51` +- Episode ID:`37` +- Audio Asset ID:`314` +- Subtitle Asset ID:`315` +- Video Asset ID:`316` +- 视频文件:`local://rendered-videos/2026-06-10/c04807a9-99eb-4a9e-a847-e8f7ba3a6ad0.mp4` + +修改文件: + +- `CODEX_PROGRESS.md` + +新增文件: + +- 本轮生成业务测试资产和私有存储文件,无新增代码文件。 + +运行命令: + +- `git status --short` +- `curl http://127.0.0.1:3000/api/health` +- 后台 Provider 状态检查脚本。 +- DeepSeek Provider 小样测试脚本。 +- 用户端 Mock 漫剧生产 E2E 脚本。 +- 续跑音频/字幕/FFmpeg 视频合成脚本。 +- Prisma 数据核验脚本。 +- `ffprobe` 检查音频和视频文件。 + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- `/api/health` 正常。 +- DeepSeek Provider 小样通过: + - `deepseek-text` 真实调用成功。 + - `deepseek-novel` 真实调用成功。 +- Mock 生产流程最终通过: + - 项目状态:`video_rendered` + - 支付状态:`paid` + - 小说章节:`3` + - 角色:`3` + - 分镜:`10` + - 项目资产:`14` + - RenderTask:`14`,成功 `14`,失败 `0` + - ProviderLog:`23` + - Provider 成本:mock 图像和 mock 语音为 `0` +- FFmpeg 合成通过: + - 视频编码:`h264` + - 音频编码:`aac` + - 分辨率:`1080x1920` + - 时长:`40s` + - 文件大小:`239746 bytes` +- 文件存在性检查通过: + - 音频 WAV 存在。 + - 字幕 SRT 存在。 + - MP4 成片存在且可被 `ffprobe` 读取。 +- Operation Logs: + - 已记录 `user_audio_generate` + - 已记录 `user_subtitle_generate` + - 已记录 `user_video_render` + +发现问题: + +- 第一次音频生成测试脚本使用 `max_segments=8` 时失败:实际混合 TTS 段数为 `12`,错误为 `audio segment count 12 exceeds max_segments 8`。 +- 续跑时改为 `max_segments=20` 后音频生成通过,且 timeline warnings 为 `0`。 +- 用户端当前没有传 `max_segments`,后端默认上限是 `80`,所以这不是现有用户端默认阻断问题;但测试台/高级参数不要再默认填 `8`。 +- 普通用户早期流程如项目创建、故事圣经、角色、分镜生成的 operation_logs 还不是全量覆盖;当前关键生成动作已有日志,但审计完整性仍可增强。 + +遗留问题: + +- 真实 Hailuo 小样尚未触发,仍需用户明确确认后单独跑 1 条固定镜头。 +- 前端人工点击路径尚未在浏览器里逐屏复测;本轮是 API E2E 和文件级验收。 +- TTS 测试脚本/高级配置中的 `max_segments=8` 对 10 镜头短剧偏低,后续测试建议不传或设置 `20` 以上。 + +下一步建议: + +- 先把真实 Provider 小样脚本里的 TTS 参数规范化:混合配音不要写死 `max_segments=8`。 +- 再做前端 PC/H5 人工点击复测,确认制作页、任务页、预览页展示不重叠。 +- 最后进入真实 Hailuo 固定镜头小样准入测试。 + +## 每阶段记录模板 + +### 公版经典小说仿真人视频完整流程验收 V1 + +完成时间:2026-06-10 23:43 CST + +完成内容: + +- 选择公版经典《聊斋志异·画皮》作为测试题材,避开现代版权作品和受保护改编版本。 +- 使用 imagegen 生成 photorealistic 竖屏关键帧,内容为雨夜旧宅、王生持灯、神秘女子立于门内。 +- 通过系统正式服务跑通一条完整链路: + - 创建真人短剧项目 + - 版权确认:`public_domain` + - 粘贴并解析公版测试片段 + - 创建 Story Bible + - 创建角色:王生、神秘女子 + - 创建 Actor Profile + - 创建分集、脚本、分镜 + - 上传 photorealistic PNG 关键帧 + - 绑定关键帧到分镜 + - Hailuo Fast 真实图生视频 + - 质检任务化 + - 真人短剧最终合成 +- 生成结果: + - project:`52` + - novel_source:`33` + - story_bible:`25` + - episode:`38` + - shot:`225` + - keyframe_asset:`318` + - video_clip:`8` + - Hailuo clip asset:`319` + - final rendered asset:`320` + - Hailuo provider log:`374` + - video task:`323` + - quality task:`324` + - render task:`325` +- 成本: + - Hailuo Fast 真实视频成本:`0.1902 USD` + - 质检 mock 成本:`0` +- 质量结果: + - clip `8`:`generated` + - quality_status:`passed` + - quality_score:`94` +- 文件级验收: + - 片段资产 `319`:`local://live-action-video-clips/2026-06-10/282b13ae-a03a-4063-ab13-da0b256417fc.mp4` + - 最终成片 `320`:`local://rendered-videos/2026-06-10/99244ee5-fc01-424e-850c-9620ef222302.mp4` + - 编码:`h264` + - 分辨率:`768x1364` + - 帧率:`24fps` + - 时长:`5.875s` + - 片段大小:`920552 bytes` + - 成片大小:`920592 bytes` +- 抽帧预览: + - `/www/wwwroot/ai/storage/private/live-action-acceptance/2026-06-10/classic-liaozhai-final-midframe.jpg` + - 画面可见真人古风雨夜场景,无黑屏、无水印、无明显跑题。 + +修改文件: + +- `CODEX_PROGRESS.md` + +新增测试文件: + +- `/www/wwwroot/ai/storage/private/live-action-acceptance/2026-06-10/classic-liaozhai-keyframe.png` +- `/www/wwwroot/ai/storage/private/live-action-acceptance/2026-06-10/classic-liaozhai-final-midframe.jpg` + +运行命令: + +- `git status --short` +- `curl http://127.0.0.1:3000/api/health` +- imagegen 生成关键帧 +- Nest 服务脚本执行经典小说真人短剧流程 +- `ffprobe` 检查 clip asset `319` +- `ffprobe` 检查 final asset `320` +- Prisma 数据核验脚本 +- `ffmpeg` 抽取最终成片中帧 + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- 后端健康检查正常。 +- 公版版权记录创建成功。 +- 小说粘贴和解析成功,章节数 `1`。 +- Hailuo Fast 真实视频生成成功。 +- 质检任务化路径成功。 +- 最终合成成功。 +- 后台可通过素材 ID 观看: + - 关键帧:`318` + - Hailuo 片段:`319` + - 最终成片:`320` + +遗留问题: + +- 最终成片 asset `320` 的 `status` 当前为 `mock`,原因是 `renderLiveActionEpisode` 旧逻辑统一把合成资产写成 `mock`;实际源片段 `319` 是 Hailuo 真实输出。后续应根据源 clip 是否真实 Provider 输出,把最终资产状态写为 `active` 或增加 `source_mode` 字段。 +- 最终成片没有音轨;本次测试重点是仿真人画面生成链路,不包含 TTS/对白/配乐合成。 +- Hailuo 输出分辨率是 `768x1364`,不是系统元数据里常用的 `1080x1920`;后续正式合成阶段需要统一转码/补边/缩放到目标竖屏规格。 +- 质检仍使用 `mock-qc`,真实画面质量最终仍需要视觉质检 Provider 或人工准入标准。 + +下一步建议: + +- 修复真人合成资产状态:真实 Provider 片段合成出的最终 asset 不应标记为 `mock`。 +- 增加真人成片转码规格化:统一输出 `1080x1920`、H.264、可选 AAC 音轨。 +- 增加真人视频小样测试台:直接展示 keyframe、clip、final、Provider、真实成本、质检分和预览入口。 + +### Hailuo Fast 真人视频真实小样验收 V1 + +完成时间:2026-06-10 23:25 CST + +完成内容: + +- 按固定样本 `project_id=36 / episode_id=28 / shot_id=134 / keyframe_asset_id=301` 运行 MiniMax Hailuo 2.3 Fast 单镜头真实小样。 +- 第一次真实调用失败原因已定位并可审计:MiniMax 返回 `base_resp_status_code=2013`,原因是 `MiniMax-Hailuo-2.3-Fast` 不支持 `4s`,只支持 `6s / 10s`。 +- Provider 执行层新增外部 Provider 返回摘要:失败日志现在记录 `base_resp_status_code`、`base_resp_status_msg`、`task_id`、`file_id`、顶层字段列表等,不再只有泛化的 `TASK_ID_MISSING`。 +- Hailuo Fast/标准 Provider 新增 `allowed_durations` 配置;通用图生视频驱动会把业务镜头时长自动归一到 Provider 支持档位。当前 4 秒镜头会按 Hailuo Fast 6 秒请求生成,后续合成阶段再裁切/对齐。 +- 真人 Provider 验收脚本修复:现在能识别后台保存的加密 API Key,不再只检查 `.env` 环境变量。 +- 真实 Hailuo Fast 第二次复测通过: + - clip:`7` + - output_asset:`317` + - 文件:`local://live-action-video-clips/2026-06-10/ddb89e25-4b51-4450-975f-9b5cf1ae2221.mp4` + - 真实成本记录:`0.1902 USD` + - 验收报告:`storage/private/live-action-acceptance/2026-06-10/live-action-acceptance-project-36-episode-28-shot-134-20260610152252.md` +- MP4 文件级检查通过: + - 编码:`h264` + - 分辨率:`768x1152` + - 帧率:`24fps` + - 时长:`5.875s` + - 文件大小:`1363391 bytes` +- 质检任务化路径跑通: + - quality task:`322` + - QualityCheckProvider:`mock-qc` + - clip `7` 质检结果:`passed` + - 质检分:`94` + +修改文件: + +- `backend/src/providers/providers.service.ts` +- `backend/src/providers/provider.types.ts` +- `backend/src/providers/providers.service.spec.ts` +- `backend/src/live-action/live-action-provider-acceptance.ts` +- `CODEX_PROGRESS.md` + +运行命令: + +- `git status --short` +- `npm test -- providers.service.spec.ts` +- `npm run typecheck` +- `npm test` +- `npm run lint` +- `npm run build` +- `systemctl restart ai-backend.service` +- `curl http://127.0.0.1:3000/api/health` +- `npm run live-action:acceptance`,仅跑 `minimax_hailuo_23_fast` +- `ffprobe` 检查 Hailuo 输出 MP4 +- `ffmpeg` 抽取中帧预览图 +- 质检队列任务脚本执行 clip `7` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- 后端单测:`187 passed` +- 后端 lint/typecheck:通过。 +- 后端 build:通过。 +- 后端服务重启后 `/api/health` 正常。 +- MiniMax Hailuo Fast 真实单镜头:通过,生成真实 MP4 并落库。 +- 成本阈值生效:单片段成本 `0.1902 USD`,低于 `0.5 USD` 上限。 +- Provider 失败审计增强有效:真实失败原因可以在 provider log 看到 `base_resp_status_msg`。 + +遗留问题: + +- 当前关键帧 `301` 视觉上偏韩漫/插画,不是真人照片质感;本轮证明真实 Hailuo Provider 接入、任务、成本、落库、预览文件和质检路径跑通,但不能代表最终“仿真人照片级”画质验收。 +- 业务镜头时长仍是 `4s`,Hailuo 实际输出 `~6s`;后续合成真人短剧时需要在 FFmpeg 拼接阶段裁切到业务时长,或在分镜层把真实视频镜头统一约束到 `6/10s` 档位。 +- 质检仍使用 `mock-qc`,真实画面质量判断还需要后续接入视觉质检 Provider 或人工验收标准。 + +下一步建议: + +- 上传或生成真正 photorealistic PNG/JPG 关键帧,再用同一套 Hailuo Fast 小样验收一次,重点看真人质感、脸部一致性、手部和动作。 +- 在真人视频合成阶段补“Provider 输出时长 > 业务镜头时长时自动裁切”的 FFmpeg 规则。 +- 后台小样测试台展示 Provider 支持时长档位、实际请求时长、业务裁切时长和真实成本,避免运营误以为 4 秒直接送给 Hailuo。 + +### 阶段名称 + +真人最终成片资产状态修复 V1 + +完成时间: + +- 2026-06-10 23:48:34 CST + +完成内容: + +- 修复真人短剧最终合成 MP4 资产状态写死为 `mock` 的问题。 +- 新规则:合成前读取所有源片段资产;只要任一源片段资产为 `active`,最终成片资产状态就标记为 `active`,文件名使用 `live-action-real.mp4`;全部源片段都是 `mock` 时才保留 `mock`。 +- `renderTask.input_json` 新增 `rendered_asset_status` 和 `rendered_asset_mode`,方便后续后台审计。 +- 已将本次公版经典小样的最终成片 `asset 320` 从 `mock` 校正为 `active`;源片段 `asset 319` 保持 `active`。 + +修改文件: + +- `backend/src/live-action/live-action.service.ts` +- `backend/src/live-action/live-action.service.spec.ts` +- `CODEX_PROGRESS.md` + +运行命令: + +- `git status --short` +- `npm test -- live-action.service.spec.ts` +- `npm run typecheck` +- `npm test` +- `npm run lint` +- `npm run build` +- Prisma 脚本检查并更新 `asset 320` +- `systemctl restart ai-backend.service` +- `curl http://127.0.0.1:3000/api/health` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- 真人模块单测:`14 passed` +- 后端全量测试:`189 passed` +- 后端 lint/typecheck:通过。 +- 后端 build:通过。 +- 后端健康检查:通过,`/api/health` 返回 `status: ok`。 +- 数据校正后:`asset 319` 为 `active`,`asset 320` 为 `active`。 + +遗留问题: + +- 旧数据里如果还有其它“真实源片段合成但最终资产误标 mock”的成片,需要按同样规则批量审计;本轮只修正了已确认的 `asset 320`。 + +下一步建议: + +- 继续真人视频验收时,重点补 FFmpeg 裁切规则:真实 Provider 输出时长大于业务镜头时长时,合成前自动裁切到分镜目标时长。 + +### 阶段名称 + +真人视频片段时长标准化 / FFmpeg 自动裁切 V1 + +完成时间: + +- 2026-06-11 00:02:14 CST + +完成内容: + +- 真人短剧最终合成前新增片段标准化流程,不再直接把 Provider 原始 MP4 丢进 concat。 +- 每个源片段会先通过 `ffprobe` 读取真实时长,再按分镜 `shot.duration` 判断是否需要裁切。 +- 裁切规则: + - `source_duration > target_duration + 0.3s` 时自动裁切。 + - 普通对话镜头默认居中裁切。 + - 动作类镜头优先从头部保留,避免切掉关键动作起始。 +- 标准化输出使用临时文件,不修改原始 Hailuo / Kling / Mock 资产。 +- 标准化片段统一转为竖屏 `1080x1920`、`24fps`、H.264、`yuv420p`,降低不同 Provider 输出参数导致的拼接风险。 +- `renderTask.input_json` 新增 `clip_normalization`,记录每个镜头的: + - `target_duration` + - `source_duration` + - `final_duration` + - `trimmed` + - `trim_strategy` + - `trim_start` + - `trim_tolerance` + +修改文件: + +- `backend/src/live-action/live-action.service.ts` +- `backend/src/live-action/live-action.service.spec.ts` +- `CODEX_PROGRESS.md` + +运行命令: + +- `git status --short` +- `npm test -- live-action.service.spec.ts` +- `npm run typecheck` +- `npm run lint` +- `npm run build` +- `npm test` +- 临时脚本调用真实 Hailuo 源片段 `asset 319` 做 4 秒标准化测试 +- `systemctl restart ai-backend.service` +- `curl http://127.0.0.1:3000/api/health` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- 真人模块单测:`14 passed` +- 后端全量测试:`189 passed` +- 后端 typecheck/lint:通过。 +- 后端 build:通过。 +- 后端健康检查:通过,`/api/health` 返回 `status: ok`。 +- 临时真实文件验证: + - 源片段:`asset 319` + - 源时长:`5.875s` + - 模拟业务目标时长:`4s` + - 输出时长:`4.000s` + - 输出规格:`1080x1920`、`24fps` + - 裁切策略:`center` + - 裁切起点:`0.938s` + +遗留问题: + +- 当前 V1 只处理“源片段过长自动裁切”;如果源片段短于分镜目标时长,暂不做冻结帧/慢放/补帧延长。 +- 真实小样 `shot 225` 目前分镜时长为 `6s`,Hailuo 输出 `5.875s`,不会触发裁切;本轮用同一个真实文件模拟了 `4s` 目标时长来验证裁切路径。 + +下一步建议: + +- 后台 Router/任务审计页展示 `clip_normalization`,让运营能看到每个镜头是否被裁切、裁切前后时长和裁切策略。 +- 后续如果要更细,可以增加“AI 最佳裁切点”或“动作峰值裁切”,但现在 V1 规则已经足够支撑生产验收。 + +### 阶段名称 + +后台 Router 审计展示合成裁切 V1 + +完成时间: + +- 2026-06-11 00:10:28 CST + +完成内容: + +- 后台 Router 审计列表新增“合成裁切”展示列。 +- 后端 `listRouterAudits` 新增关联最终合成任务 `live_action_video_render`,从 `input_json.clip_normalization` 中提取当前镜头的标准化/裁切记录。 +- 每条审计行新增 `render_normalization`,包含: + - `task_id` + - `output_asset_id` + - `shot_id` + - `target_duration` + - `source_duration` + - `final_duration` + - `trimmed` + - `trim_strategy` + - `trim_start` + - `trim_tolerance` +- Router 审计汇总新增: + - `normalized_clip_count` + - `trimmed_clip_count` + - `trimmed_seconds_total` +- Router 时间线新增 `clip_normalization` 事件,打开片段时间线可以看到“合成片段标准化 / 合成片段自动裁切”的详细 JSON。 +- 前端 Router 审计页新增中文映射:`clip_normalization`、`trimmed`、`normalized`、`center`、`head`、`none`。 +- 对经典小样第 38 集执行了一次只走 FFmpeg 的强制重新合成,不重新调用 Hailuo: + - 新最终成片 asset:`321` + - 新合成任务 task:`326` + - 标准化记录:源片段 `5.875s`,目标 `6s`,最终 `5.875s`,`trimmed=false` + +修改文件: + +- `backend/src/admin/admin.service.ts` +- `backend/src/admin/admin.service.spec.ts` +- `admin/src/App.vue` +- `admin/src/styles.css` +- `CODEX_PROGRESS.md` + +运行命令: + +- `git status --short` +- `npm test -- admin.service.spec.ts` +- `npm run typecheck` +- `npm test` +- `npm run lint` +- `npm run build` +- `cd admin && npm run build` +- 服务层强制重新合成 episode `38` +- 后台服务层查询项目 `52` Router 审计 +- `systemctl restart ai-backend.service` +- `curl http://127.0.0.1:3000/api/health` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- 后端 Admin 单测:`20 passed` +- 后端全量测试:`189 passed` +- 后端 typecheck/lint/build:通过。 +- 后台前端 build:通过。 +- 后端健康检查:通过,`/api/health` 返回 `status: ok`。 +- 真实数据验证: + - 项目 `52` Router 审计 summary 返回 `normalized_clip_count=1`、`trimmed_clip_count=0` + - 片段 `8` 返回 `render_normalization.task_id=326`、`output_asset_id=321` + +遗留问题: + +- 目前只有新合成任务会有 `clip_normalization`;旧合成任务不会回填。需要展示旧数据时,需要重新合成或做一次历史任务回填。 +- 当前真实小样目标时长为 `6s`,源片段 `5.875s`,所以展示为“未裁切”。真正的“已裁切”展示已通过后端单测和临时真实文件测试覆盖。 + +下一步建议: + +- 后台可以继续补一个“只重新合成/刷新裁切审计”的按钮,方便不重新生成 Provider 片段的情况下刷新最终成片与裁切记录。 + +### 阶段名称 + +全海螺 30 秒仿真人数字人跨屏样片验收 V1 + +完成时间: + +- 2026-06-11 00:44:23 CST + +完成内容: + +- 按“高价值镜头 / 宣传级样片 / Router Premium 验收样片”思路,完成一条 30 秒全 Hailuo 真人视频小样。 +- 题材:深夜程序员桌面,仿真人 AI 数字女性从笔记本屏幕进入现实世界,并说“你终于找到我了”。 +- 使用 imagegen 生成 5 张真实 PNG 关键帧,并复制到项目私有目录: + - `storage/private/live-action-acceptance/2026-06-11/dimensional-hailuo-keyframes/shot-01-screen-appear.png` + - `storage/private/live-action-acceptance/2026-06-11/dimensional-hailuo-keyframes/shot-02-touch-screen.png` + - `storage/private/live-action-acceptance/2026-06-11/dimensional-hailuo-keyframes/shot-03-hand-through.png` + - `storage/private/live-action-acceptance/2026-06-11/dimensional-hailuo-keyframes/shot-04-step-out.png` + - `storage/private/live-action-acceptance/2026-06-11/dimensional-hailuo-keyframes/shot-05-real-world.png` +- 新建测试项目: + - project:`53` + - episode:`39` + - keyframe assets:`322-326` + - storyboard shots:`226-230` +- 全部 5 个镜头均使用 `minimax_hailuo_23_fast` 真实 Provider 生成: + - shot 1:clip `9`,asset `327`,成本 `0.1902 USD` + - shot 2:clip `10`,asset `328`,成本 `0.1902 USD` + - shot 3:clip `11`,asset `329`,成本 `0.1902 USD` + - shot 4:clip `12`,asset `330`,成本 `0.1902 USD` + - shot 5:clip `13`,asset `331`,成本 `0.1902 USD` +- 合成最终成片: + - final asset:`332` + - render task:`332` + - 文件:`local://rendered-videos/2026-06-10/4ddc1e08-d336-4fe3-93e9-7301079f99e5.mp4` +- 跑通 Router 审计: + - `total_estimated_cost=0.9510` + - `total_actual_cost=0.9510` + - `normalized_clip_count=5` + - `trimmed_clip_count=0` + - `avg_quality_score=94` +- 跑通质检记录: + - clips `9-13` 均为 `passed` + - 质检分均为 `94` + - 当前质检 Provider 为 `mock-qc` +- 抽帧验收文件: + - `storage/private/live-action-acceptance/2026-06-11/dimensional-hailuo-frames/final-contact-sheet.jpg` + - `storage/private/live-action-acceptance/2026-06-11/dimensional-hailuo-frames/final-midframe.jpg` + +修改文件: + +- `CODEX_PROGRESS.md` + +生成/新增数据: + +- 数据库项目、故事圣经、分集、5 个镜头、5 个关键帧资产、5 个 Hailuo 视频片段、1 个最终成片资产。 +- 私有关键帧 PNG 与抽帧 JPG。 + +运行命令: + +- `git status --short` +- Provider 配置检查脚本 +- imagegen 生成关键帧 +- Prisma 脚本创建项目/分镜/关键帧资产 +- 服务层逐镜头调用 `generateShotVideoClip` +- 服务层调用 `renderLiveActionEpisode` +- `ffprobe` 检查最终 MP4 +- `ffmpeg` 抽帧和生成 contact sheet +- 服务层调用 `checkVideoClipQuality` +- 后台服务层查询 Router 审计 +- `npm test -- live-action.service.spec.ts` +- `curl http://127.0.0.1:3000/api/health` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- Hailuo 真实生成:5/5 成功。 +- Hailuo 真实成本:`0.9510 USD`。 +- 最终 MP4 文件级检查: + - 编码:`h264` + - 分辨率:`1080x1920` + - 帧率:`24fps` + - 实际时长:`29.375s` + - 文件大小:`10241260 bytes` +- 5 个片段标准化记录正常:源时长均 `5.875s`,目标均 `6s`,未触发裁切。 +- 后端真人模块单测:`14 passed` +- 后端健康检查:通过,`/api/health` 返回 `status: ok`。 + +人工视觉观察: + +- 第 1-4 镜整体效果成立:屏幕中出现、触屏、手穿屏、从笔记本中出来的视觉逻辑清楚。 +- 第 3/4 镜“跨屏”动作可读性不错,符合全海螺先做准入测试的目标。 +- 第 5 镜出现轻微服装一致性变化,从高领科技服偏成白色连衣裙;这是全海螺复杂连续镜头的可见瑕疵,后续如果做宣传片级别,建议只重跑第 5 镜或等 Kling 开通后重跑第 3-5 镜。 + +遗留问题: + +- 当前质检仍是 `mock-qc`,分数只能证明流程闭环,不等同于真实视觉质检。 +- 第 5 镜服装一致性需要人工复核,必要时单独重跑。 +- 最终成片实际时长 `29.375s`,业务记录为 `30s`,这是 Hailuo 6 秒档实际输出约 `5.875s` 导致,属于可接受范围;如要求严格 30 秒,后续可做尾帧补齐或轻微延长。 + +下一步建议: + +- 人工打开后台预览 `asset 332`,重点看第 3-5 镜人物一致性和跨屏动作。 +- 如果要做更接近宣传片的一版,保留第 1-4 镜,优先重跑第 5 镜;Kling 开通后再重跑第 3/4 镜做对照。 + +### 阶段名称 + +真人视频后期音频层修复 / 30 秒样片重新验收 V1 + +完成时间: + +- 2026-06-11 01:10:27 CST + +完成内容: + +- 将上一版 `asset 332` 按发布标准判定为失败:只有视频流,没有音频流、字幕和 BGM;第 5 镜嘴型也不能证明中文台词同步。 +- 真人 `renderLiveActionEpisode` 增加后期层: + - 默认准备对白音频、字幕和 BGM。 + - 新增 `live_action_audio_generate`、`live_action_subtitle_generate`、`live_action_bgm_generate` 三类任务。 + - 从 `storyboard_shots.dialogue_text/narration_text` 直接生成真人小样对白段,不再强依赖已确认 `episode_script`。 + - 最终 `live_action_video_render` 的 `input_json.post_production` 记录 audio/subtitle/bgm asset、task、Provider、warning。 +- 真人 FFmpeg 合成升级: + - 先拼接 Hailuo 视频片段。 + - 再混入 TTS 人声和 BGM。 + - 再烧录 ASS 字幕。 + - 输出 AAC 立体声音轨。 +- BGM V1: + - 支持指定 `bgm_asset_id`。 + - 未指定时生成版权安全的低音量氛围底音 `system_ambient_bed_v1`,用于测试和保底,不作为最终商业音乐库。 +- Provider 修复: + - 修复 MiniMax TTS 返回 `data.audio` 十六进制音频串被误当 base64 解码的问题。 + - `decodeProviderAudioPayload` 自动识别 hex/base64。 +- 开启已有 Key 的 `minimax-tts` Provider: + - `provider_code=minimax-tts` + - `is_enabled=true` + - `priority=180` +- 重新合成 30 秒有声样片: + - audio asset:`333` + - subtitle asset:`334` + - bgm asset:`335` + - final video asset:`336` + - final render task:`337` + - 文件:`local://rendered-videos/2026-06-10/5b300243-c635-42d3-9d13-c0f73f449b87.mp4` +- 抽帧验收: + - `storage/private/live-action-acceptance/2026-06-11/dimensional-hailuo-frames/final-asset-336-subtitle-frame-25s.jpg` + - 25 秒帧已确认字幕“你终于找到我了。”烧录成功。 + +修改文件: + +- `backend/src/live-action/live-action.dto.ts` +- `backend/src/live-action/live-action.service.ts` +- `backend/src/live-action/live-action.service.spec.ts` +- `backend/src/providers/providers.service.ts` +- `CODEX_PROGRESS.md` + +新增文件: + +- 无源码新增文件。 + +生成/新增数据: + +- `render_tasks` + - `334`:`live_action_audio_generate` 成功,真实 `minimax-tts` + - `335`:`live_action_subtitle_generate` 成功 + - `336`:`live_action_bgm_generate` 成功 + - `337`:`live_action_video_render` 成功 + - `333`:修复前失败的 TTS 任务,保留为问题追踪记录 +- `assets` + - `333`:真人对白混音 WAV + - `334`:SRT 字幕 + - `335`:BGM WAV + - `336`:最终有声 MP4 +- `provider_logs` + - `389`:`minimax-tts` 成功,`audio_available=true`,`audio_bytes=23674` + +运行命令: + +- `git status --short` +- `npm test -- live-action.service.spec.ts` +- `npm test -- providers.service.spec.ts` +- `npm run typecheck --workspace backend` +- `npm run build --workspace backend` +- `npm run lint --workspace backend` +- MiniMax TTS debug 脚本 +- `renderLiveActionEpisode` 重合成 episode `39` +- `ffprobe` 检查 `asset 336` +- `ffmpeg volumedetect` 检查音量 +- `ffmpeg` 抽字幕帧 +- Prisma 查询任务、素材、Provider 日志 +- 重启后端 `node dist/main.js` +- `curl http://127.0.0.1:3000/api/health` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- 后端真人单测:`14 passed` +- Provider 单测:`32 passed` +- 后端 typecheck:通过 +- 后端 build:通过 +- 后端 lint:通过 +- 后端已重启加载新 `dist`,健康检查通过 +- `asset 336` 文件级验收: + - 容器:MP4 + - 视频:H.264,`1080x1920`,`24fps` + - 音频:AAC LC,`44100 Hz`,stereo,`159 kb/s` + - format duration:`30.000s` + - audio duration:`30.000s` + - video stream duration:`29.375s` + - 文件大小:`11093467 bytes` +- 音量验收: + - `mean_volume=-16.0 dB` + - `max_volume=-1.0 dB` + - 不是静音文件。 +- 字幕验收: + - 25 秒抽帧可见“你终于找到我了。” +- TTS 验收: + - `minimax-tts` 真实成功。 + - 修复后音频字节头为合法 MP3 `ID3`。 + +分项验收结论: + +- 真实 Hailuo 视频片段:通过,可跑通但第 5 镜服装一致性仍有瑕疵。 +- 视频合成:通过,最终 MP4 可生成。 +- 对白音轨:通过,已接真实 MiniMax TTS 并混入最终 MP4。 +- BGM:流程通过,但当前是系统氛围底音 V1;商业发布前建议接入可运营的 BGM 素材库/上传授权库。 +- 字幕:通过,已烧录进画面。 +- 音量:通过,文件层面非静音,音量在可播放范围。 +- 嘴型同步:不通过。当前第 5 镜只是 Hailuo prompt 生成的说话表情,不是由真实中文音频驱动的 lip-sync。 +- 视觉质检:不通过生产级。当前 `mock-qc=94` 只能证明流程,不代表真实画面质检。 +- 人工审核/发布:未验收。本阶段只到成片文件和后台任务链路。 + +遗留问题: + +- 第 5 镜嘴型仍不是生产级中文口型。适合中景/轻微开口,不适合正脸近景强台词。 +- BGM 还不是正式音乐库,只是版权安全氛围底音保底。 +- 真实视觉 QA 仍未接入,`mock-qc` 不能作为发布依据。 +- `provider_logs.cost_actual` 对 MiniMax TTS 仍为 `0`,因为当前按 `provider_usage_metadata` 记录,后续要按字符/供应商账单补精确成本。 +- 修复前失败的 `live_action_audio_generate` task `333` 保留在任务表中,后台需要能清楚显示失败原因和后续成功任务。 + +下一步建议: + +- 做“发布验收清单 V1”:后台成片页明确显示视频流、音频流、字幕、BGM、真实 TTS Provider、mock/real QA、lip-sync 风险。 +- 做“台词镜头策略 V1”:正脸近景台词默认标记 `lip_sync_required`,没有 lip-sync Provider 时自动改成旁白/字幕/轻微开口中景,避免上线露馅。 +- 做 BGM 素材库/授权库 V1:不要长期依赖系统氛围底音。 + +### 阶段名称 + +台词镜头策略 V1 / Lip-Sync 风险自动降级 + +完成时间: + +- 2026-06-11 09:30:12 CST + +完成内容: + +- 针对真人视频发布级风险补了台词镜头策略:正脸、近景、带台词、说话/开口类镜头会自动判定为 `lip_sync_required`。 +- 不新增数据库字段,先用 Provider 配置和任务 `input_json` 落地策略,避免当前阶段频繁迁移核心表。 +- 新增 lip-sync Provider 可用性判断:检测已启用的视频 Provider 配置中是否声明 `config_json.supports_lipsync=true`。 +- 没有 lip-sync Provider 时,系统保留对白给后期 TTS 和字幕,但会自动把视频生成提示词降级为: + - 中景或三分之二侧脸; + - 轻微开口/自然表情; + - 避免正脸嘴部特写; + - 禁止生成清晰中文口型; + - 通过旁白、字幕、画面反应承接台词。 +- `prepareLiveActionShots` 会在生成分镜 prompt 时写入 lip-sync 风险策略。 +- `generateSingleVideoClip` 会对已有旧 prompt 动态补策略,避免旧项目重跑时漏掉降级规则。 +- 真人视频片段生成任务、后期合成任务都会记录 `lip_sync_policy`,方便后续后台审计和发布验收。 + +修改文件: + +- `backend/src/live-action/live-action.service.ts` +- `backend/src/live-action/live-action.service.spec.ts` +- `CODEX_PROGRESS.md` + +新增文件: + +- 无。 + +运行命令: + +- `git status --short` +- `npm test -- live-action.service.spec.ts` +- `npm run typecheck --workspace backend` +- `npm run build --workspace backend` +- `npm run lint --workspace backend` +- 重启后端 `node dist/main.js` +- `curl http://127.0.0.1:3000/api/health` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- 后端真人单测:`15 passed` +- 后端 typecheck:通过 +- 后端 build:通过 +- 后端 lint:通过 +- 后端已重启加载新 `dist`,健康检查通过 +- 本阶段未额外重跑 Hailuo 真实视频,未新增真实视频费用。 + +分项验收结论: + +- 台词镜头风险识别:通过。单测已覆盖近景台词镜头自动判定高风险。 +- 无 lip-sync Provider 降级:通过。单测已确认 prompt 会写入 `post_tts_subtitle_light_mouth`、中景、侧脸、避免清晰口型等约束。 +- 旧 prompt 动态补策略:通过。片段生成时会再次计算并注入策略。 +- 任务审计记录:通过。render/post-production 任务 `input_json` 会记录 `lip_sync_policy`。 +- 真实画面效果:未验收。本阶段只做策略和链路,不烧真实 Provider 额度。 + +遗留问题: + +- 目前还没有真实 lip-sync Provider Adapter,系统只能自动降级风险镜头,不能做到真实中文口型驱动。 +- 旧成片 `asset 336` 的画面不会自动改变,嘴型问题仍然存在;后续重新生成相关镜头才会套用新策略。 +- 后台 Router/发布验收页还没有展示 `lip_sync_policy`,运营人员暂时需要查任务 JSON。 + +下一步建议: + +- 重跑第 5 镜台词镜头,验证 Hailuo 在“中景轻口型 + TTS/字幕后期”策略下是否明显更稳。 +- 做“发布验收清单 V1”:后台成片页展示音频、字幕、BGM、真实/Mock QA、lip-sync 风险和是否已降级。 +- 后续接入真实 lip-sync Provider 后,把高风险正脸台词镜头路由到 lip-sync Provider。 + +### 阶段名称 + +真人第 5 镜台词镜头重跑验收 / Hailuo + 轻口型降级策略 + +完成时间: + +- 2026-06-11 12:04:06 CST + +完成内容: + +- 按台词镜头策略 V1,重跑项目 `53`、episode `39`、第 5 镜 `shot 230`。 +- 本次只重跑 1 条 6 秒 Hailuo 真实视频,前 4 镜不重跑。 +- Hailuo Provider 配置确认: + - `minimax_hailuo_23_fast` 已启用; + - `mode=real`; + - `supports_lipsync=false`; + - 已配置 `MINIMAX_API_KEY`; + - 单条 6 秒预估/记录成本 `0.1902 USD`。 +- 新片段生成成功: + - `video_clip.id=14` + - `asset.id=337` + - 文件:`local://live-action-video-clips/2026-06-11/9f435b34-34a4-4f56-a0e6-ef8932ecffa9.mp4` + - 质量状态:`passed` + - mock 质检分:`94` +- 新片段任务 `338` 已记录 lip-sync 策略: + - `lip_sync_required=true` + - `high_risk_dialogue=true` + - `provider_available=false` + - `strategy=post_tts_subtitle_light_mouth` + - `visual_fallback=true` +- 新片段 prompt 已注入风险规避指令: + - 后期 TTS + 字幕承接对白; + - 不生成清晰中文口型; + - 嘴部保持闭合或轻微移动; + - 避免正脸嘴部特写; + - 优先中景、三分之二侧脸或反应镜头。 +- 重新合成 30 秒成片,使用新第 5 镜片段: + - `asset.id=341` + - 文件:`local://rendered-videos/2026-06-11/74a7acdd-dbdc-4774-9cd0-a59985eec25c.mp4` + - 片段列表:`327, 328, 329, 330, 337` + - TTS asset:`338` + - 字幕 asset:`339` + - BGM asset:`340` + - render task:`342` +- 抽帧验收: + - 第 5 镜联系表:`storage/private/live-action-acceptance/2026-06-11/shot-230-rerun-frames/contact-sheet.jpg` + - 成片 25 秒字幕帧:`storage/private/live-action-acceptance/2026-06-11/asset-341-frames/subtitle-frame-25s.jpg` + - 成片 27 秒字幕帧:`storage/private/live-action-acceptance/2026-06-11/asset-341-frames/subtitle-frame-27s.jpg` + +修改文件: + +- `CODEX_PROGRESS.md` + +新增文件: + +- 无源码新增文件。 + +生成/新增数据: + +- `video_clips` + - `14`:第 5 镜 Hailuo 真实重跑片段 +- `assets` + - `337`:第 5 镜新 Hailuo 片段 + - `338`:真人对白混音 WAV + - `339`:SRT 字幕 + - `340`:BGM WAV + - `341`:重新合成后的 30 秒有声 MP4 +- `render_tasks` + - `338`:`live_action_video_clip_generate` 成功 + - `339`:`live_action_audio_generate` 成功 + - `340`:`live_action_subtitle_generate` 成功 + - `341`:`live_action_bgm_generate` 成功 + - `342`:`live_action_video_render` 成功 +- `provider_logs` + - `390`:`minimax_hailuo_23_fast` 成功,真实视频可用,成本 `0.1902` + +运行命令: + +- `git status --short` +- Prisma 查询 episode/shot/provider/clip/task +- `npm run live-action:acceptance` +- `ffprobe` 检查第 5 镜新片段 +- `ffmpeg` 抽第 5 镜联系表 +- Nest application context 调用 `renderLiveActionEpisode` +- `ffprobe` 检查最终成片 `asset 341` +- `ffmpeg volumedetect` 检查最终成片音量 +- `ffmpeg` 抽 25 秒、27 秒字幕帧 +- `curl http://127.0.0.1:3000/api/health` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- Hailuo 第 5 镜真实重跑:通过。 +- Provider acceptance 报告: + - `/www/wwwroot/ai/storage/private/live-action-acceptance/2026-06-11/live-action-acceptance-project-53-episode-39-shot-230-20260611040048.json` + - `/www/wwwroot/ai/storage/private/live-action-acceptance/2026-06-11/live-action-acceptance-project-53-episode-39-shot-230-20260611040048.md` +- 新片段 `asset 337`: + - 视频:H.264 + - 分辨率:`768x1364` + - 帧率:`24fps` + - 时长:`5.875s` + - 文件大小:`545522 bytes` + - 无音轨,符合单镜头视频片段设计。 +- 新成片 `asset 341`: + - 容器:MP4 + - 视频:H.264,`1080x1920`,`24fps` + - 视频流时长:`29.375s` + - 音频:AAC LC,`44100 Hz`,stereo,`159 kb/s` + - 音频流时长:`30.000s` + - format duration:`30.000s` + - 文件大小:`10854047 bytes` +- 音量验收: + - `mean_volume=-15.8 dB` + - `max_volume=-1.0 dB` + - 非静音,音量在可播放范围。 +- 字幕验收: + - 25 秒、27 秒抽帧均可见“你终于找到我了。” +- 后端健康检查:通过。 + +分项验收结论: + +- 台词镜头降级策略:通过。任务 JSON 和 Provider prompt 均记录并执行了 `post_tts_subtitle_light_mouth`。 +- 嘴型风险规避:阶段性通过。抽帧显示人物为中景/轻口型/表情反应,未出现明显正脸大幅口型对不上。 +- TTS:通过。最终成片有真实 `minimax-tts` 音轨。 +- 字幕:通过。字幕已烧录入最终成片。 +- BGM:流程通过。仍是系统氛围底音 V1,不是正式音乐素材库。 +- 合成:通过。最终 MP4 30 秒、有音轨、有字幕、有 BGM。 +- 发布级结论:比上一版明显更接近可发布,但仍需人工完整播放审核;本阶段无法替代真实 lip-sync 供应商,也没有真实视觉 QA。 + +遗留问题: + +- 第 5 镜并不是真正 lip-sync,只是通过中景、轻口型、字幕和 TTS 规避风险。 +- Hailuo 返回片段为 `5.875s`,目标为 `6s`,在 `0.3s` 容差内没有裁切,最终 format 仍为 30 秒。 +- BGM 仍需后续接入素材库/授权库,才能进入正式商用发布标准。 +- `live_action_audio_generate.cost_actual` 仍为 `0`,MiniMax TTS 真实成本需要后续按字符或账单回填。 +- mock 质检分 `94` 不能代表真实视觉 QA,仍需补真实画面审核能力。 + +下一步建议: + +- 做“发布验收清单 V1”,把音轨、字幕、BGM、lip-sync 降级、mock/real QA、人工审核状态集中显示。 +- 做“真人成片人工验收台 V1”,让后台能直接预览 `asset 341` 并人工通过/驳回。 +- 接入正式 BGM 素材库/授权库,替代系统氛围底音。 + +### 阶段名称 + +真人成片音频噪声 / 台词时间轴修复 V1 + +完成时间: + +- 2026-06-11 12:14:22 CST + +问题反馈: + +- 用户验收 `asset 341` 后反馈: + - 成片里全是“呼呼”的噪音; + - 语音先到,说完后画面嘴型/表情才开始动。 + +原因分析: + +- “呼呼声”不是 MiniMax TTS 的问题,也不是 Hailuo 视频的问题,而是系统兜底 BGM 问题: + - 旧 `system_ambient_bed_v1` 用 `anoisesrc=color=pink` 生成粉噪声氛围底音; + - 最终混音后又做整条 loudnorm,把背景噪声进一步抬高; + - 结果听感像风噪/底噪,不适合发布。 +- “语音先到”不是单纯 AI 平台质量问题,而是当前流水线时间轴问题叠加无 lip-sync Provider: + - Hailuo 不是音频驱动 lip-sync; + - 旧策略把第 5 镜对白放在镜头开头 `24.55s`; + - 画面里的轻口型/表情动作出现在镜头中后段,导致听感错位。 + +完成内容: + +- 系统兜底 BGM 从粉噪声改为静音保底: + - `bgm_source` 从 `system_ambient_bed_v1` 调整为 `system_silent_bed_v1`; + - 不再用粉噪声伪装 BGM; + - 正式发布用 BGM 后续必须接授权素材库/上传素材库。 +- 混音策略修复: + - 默认 `LIVE_ACTION_DEFAULT_BGM_VOLUME` 从 `0.16` 降到 `0.08`; + - `bgm_volume` 参数现在真正参与混音; + - voice+BGM 混合后不再对整条音轨做 loudnorm,避免把背景噪声拉响; + - 混合后改用 `alimiter` 做安全限幅。 +- 台词时间轴修复: + - `visual_fallback=true` 且是 dialogue 的镜头,TTS/字幕不再默认 `0.55s` 入声; + - 6 秒台词镜头会延后到约 `2.04s` 入声; + - 本次第 5 镜全片时间从旧 `24.55s` 延后到 `26.04s`。 +- 新增单测: + - 验证无 lip-sync 的台词镜头会延后进声; + - 验证混音滤镜不会再把 BGM 通过整体 loudnorm 拉响。 +- 重新合成新版 30 秒成片: + - `asset.id=345` + - 文件:`local://rendered-videos/2026-06-11/38a399bd-4550-4684-9855-0ae02e90e59c.mp4` + - 使用已有 Hailuo 视频片段,不再重跑 Hailuo。 + +修改文件: + +- `backend/src/live-action/live-action.service.ts` +- `backend/src/live-action/live-action.service.spec.ts` +- `CODEX_PROGRESS.md` + +新增文件: + +- 无源码新增文件。 + +生成/新增数据: + +- `assets` + - `342`:新版真人对白混音 WAV + - `343`:新版 SRT 字幕 + - `344`:新版静音 BGM 保底 WAV + - `345`:新版最终成片 MP4 +- `render_tasks` + - `343`:`live_action_audio_generate` 成功,台词 `start_seconds=26.04` + - `344`:`live_action_subtitle_generate` 成功,字幕 `start_seconds=26.04` + - `345`:`live_action_bgm_generate` 成功,`bgm_source=system_silent_bed_v1` + - `346`:`live_action_video_render` 成功,`bgm_volume=0` + +运行命令: + +- `git status --short` +- `npm test -- live-action.service.spec.ts` +- `npm run typecheck --workspace backend` +- `npm run build --workspace backend` +- `npm run lint --workspace backend` +- Nest application context 调用 `renderLiveActionEpisode` +- `ffprobe` 检查新版成片 +- `ffmpeg volumedetect` 检查新版音频 +- `ffmpeg` 抽 25 秒、27 秒画面帧 +- 重启后端 `node dist/main.js` +- `curl http://127.0.0.1:3000/api/health` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- 后端真人单测:`17 passed` +- 后端 typecheck:通过 +- 后端 build:通过 +- 后端 lint:通过 +- 后端已重启,PID `4060680`,健康检查通过。 +- 新版成片 `asset 345`: + - 容器:MP4 + - 视频:H.264,`1080x1920`,`24fps` + - format duration:`30.000s` + - audio duration:`30.000s` + - 音频:AAC LC,`44100 Hz`,stereo + - 文件大小:`10291399 bytes` +- 新版音量: + - `mean_volume=-37.0 dB` + - `max_volume=-7.8 dB` + - 相比旧版 `mean_volume=-15.8 dB`,背景底噪已明显压下。 +- 新版字幕/台词时间: + - 第 5 镜台词和字幕从 `26.04s` 开始; + - 旧版是 `24.55s`。 +- 抽帧: + - `storage/private/live-action-acceptance/2026-06-11/asset-345-frames/frame-25s.jpg` + - `storage/private/live-action-acceptance/2026-06-11/asset-345-frames/frame-27s.jpg` + +分项验收结论: + +- 呼呼噪声:代码层面已修复。新版使用静音保底 BGM,且混音不再拉响背景。 +- 语音抢跑:代码层面已修复。visual fallback 台词镜头会延后到镜头中后段。 +- 真 lip-sync:仍未实现。当前方案是“规避嘴型风险”,不是音频驱动口型。 +- BGM 发布标准:仍未完成。正式发布需要授权 BGM 素材库,不能依赖系统兜底音。 + +遗留问题: + +- `asset 345` 是无正式 BGM 版本,只解决噪声和台词时间轴,不代表最终配乐发布标准。 +- Hailuo 仍不是 lip-sync Provider;正脸强台词仍要接入真实 lip-sync 或改变镜头设计。 +- 用户需要人工完整播放 `asset 345`,确认实际听感是否过关。 + +下一步建议: + +- 后台增加“发布验收清单 V1”:显示是否有正式 BGM、是否静音保底、TTS 起止时间、lip-sync 策略、人工通过/驳回。 +- 做 BGM 素材库/授权库 V1,支持上传可商用 BGM 并控制音量。 +- 对正脸强台词镜头继续优先使用旁白/字幕/背影/反应镜头,直到接入真实 lip-sync Provider。 + +### 阶段名称 + +LipSyncProvider 接入 V1 / 真人后期口型同步 Provider 抽象 + +完成时间: + +- 2026-06-11 13:44:28 CST + +完成内容: + +- 新增独立 Provider 类型:`LipSyncProvider`。 +- 新增 mock Provider: + - `mock-lipsync` + - 默认禁用; + - 只用于测试链路; + - 明确标记 `mock_passthrough`,不伪装成真实口型同步能力。 +- 新增真实通用 Provider 预设: + - `generic-lipsync` + - 默认禁用; + - driver:`configurable_lip_sync` + - 默认 env:`LIPSYNC_API_KEY` + - 默认请求字段:`video`、`audio`、`text` + - 支持 Provider 返回 `video_url` 或 `content_base64` 后落盘为私有视频片段。 +- 真人视频 DTO 增加: + - `include_lip_sync` + - `lip_sync_provider_code` +- 真人后期链路新增 lip-sync 阶段: + - 位置:TTS 生成后、最终 FFmpeg 合成前; + - 输入:原视频片段 + 对应对白音频片段 + 台词文本 + 时间信息; + - 输出:新的 lip-sync 视频片段; + - 合成时优先使用 lip-sync 后的新片段。 +- Provider 选择策略: + - 默认只自动使用已启用的真实 `LipSyncProvider`; + - mock 不会被当成生产可用能力; + - 后台/测试可显式指定 `lip_sync_provider_code=mock-lipsync` 验证链路。 +- 审计记录: + - 新增任务类型 `live_action_lip_sync_generate`; + - `live_action_video_render.input_json.post_production` 记录 `lip_sync_clip_count` 和 `lip_sync_clips`; + - 每个 lip-sync clip 记录 source asset、output asset、task、provider、成本和策略。 +- 安全处理: + - 视频/音频 data URI 只进入 Provider 调用; + - render task 不保存大 base64; + - provider logs 已走媒体字段脱敏。 +- Provider 配置已落库: + - `71 LipSyncProvider:mock-lipsync enabled=false mode=mock` + - `72 LipSyncProvider:generic-lipsync enabled=false mode=real` + +修改文件: + +- `backend/src/live-action/live-action.dto.ts` +- `backend/src/live-action/live-action.service.ts` +- `backend/src/live-action/live-action.service.spec.ts` +- `backend/src/providers/provider.types.ts` +- `backend/src/providers/providers.service.ts` +- `backend/prisma/seed.ts` +- `CODEX_PROGRESS.md` + +新增文件: + +- 无。 + +运行命令: + +- `git status --short` +- `npm test -- live-action.service.spec.ts` +- `npm test -- providers.service.spec.ts` +- `npm run typecheck --workspace backend` +- `npm run build --workspace backend` +- `npm run lint --workspace backend` +- Prisma upsert `LipSyncProvider` 配置 +- 重启后端 `node dist/main.js` +- `curl http://127.0.0.1:3000/api/health` +- Prisma 查询 `LipSyncProvider` 配置 + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- Provider 单测:`32 passed` +- 真人服务单测:`18 passed` +- 后端 typecheck:通过 +- 后端 build:通过 +- 后端 lint:通过 +- 后端已重启,PID `31253`,健康检查通过。 +- 数据库确认: + - `mock-lipsync` 已存在,默认禁用; + - `generic-lipsync` 已存在,默认禁用; + - 未写入任何真实 API Key。 + +分项验收结论: + +- Provider 抽象:通过。`LipSyncProvider` 已成为独立能力类型。 +- mock 链路:通过。单测已验证指定 `mock-lipsync` 时,会创建 `live_action_lip_sync_generate` 任务、调用 `LipSyncProvider`、保存新片段。 +- 真实 Provider 预留:通过。`configurable_lip_sync` 支持通用 JSON 请求和 URL/base64 视频输出。 +- 生产默认策略:通过。没有真实启用的 LipSyncProvider 时,不会误把 mock 当成可发布口型能力。 +- 成本/审计:通过。任务和 Provider log 已能记录 lip-sync 调用。 + +遗留问题: + +- 目前还没有开通真实 lip-sync 平台账号,`generic-lipsync` 只是通用适配器配置。 +- 不同厂商可能要求 multipart/form-data、文件先上传或异步任务轮询;当前 V1 优先支持 JSON data URI + video_url/base64 输出。 +- 后台 UI 还没有把 `LipSyncProvider` 单独分组展示,也没有发布验收页展示 `lip_sync_clips`。 +- 尚未用真实 lip-sync Provider 重跑第 5 镜,所以真实口型效果还未验收。 + +下一步建议: + +- 选定真实 lip-sync 平台后,按其 API 调整 `generic-lipsync` 的 `base_url/create_endpoint/video_field/audio_field/text_field`,填写 Key 后启用。 +- 做后台“LipSyncProvider 配置/测试”入口,避免和普通 VideoProvider 混在一起。 +- 用第 5 镜真实跑一次 lip-sync 小样,比较 `asset 345` 和 lip-sync 后版本的口型效果。 + +### 阶段名称 + +LipSync 成本闸门 V1 / 镜头级按需口型同步 + +完成时间: + +2026-06-11 14:14 Asia/Shanghai + +完成内容: + +- 明确落地“单镜头级 lip-sync,不做整片默认 lip-sync”的生产策略。 +- `LiveActionGenerateDto` 新增 `lip_sync_max_seconds`,用于限制单集最多进入真实 lip-sync Provider 的秒数。 +- 真人后期合成前新增 lip-sync 预算计划: + - 默认每集最多 `18s`; + - 上限硬限制 `120s`; + - 按 `route_tier`、`importance_score`、`action_score`、`emotion_score` 优先保留高价值镜头; + - 超出预算的高风险台词镜头自动降级为 `post_tts_subtitle_light_mouth`,不调用 lip-sync Provider。 +- 实际 Provider 调用条件改为只处理 `lip_sync_strategy=provider_lipsync`,避免仅“需要口型同步但被预算跳过”的镜头误触发二次视频处理。 +- 后期任务 `input_json.post_production` 增加: + - `lip_sync_budget` + - `lip_sync_policy.segments[].skip_reason` + - `lip_sync_skip_reason` +- 单测新增“两个高风险台词镜头只同步高价值镜头,普通镜头预算跳过并降级”的覆盖。 +- 单测新增“显式关闭 lip-sync 时也走轻口型/字幕安全降级”的覆盖,避免关闭后仍按 `provider_lipsync` 排时序。 + +修改文件: + +- `backend/src/live-action/live-action.dto.ts` +- `backend/src/live-action/live-action.service.ts` +- `backend/src/live-action/live-action.service.spec.ts` +- `CODEX_PROGRESS.md` + +新增文件: + +- 无。 + +运行命令: + +- `git status --short` +- `npm test --workspace backend -- live-action.service.spec.ts` +- `npm run typecheck --workspace backend` +- `npm run lint --workspace backend` +- `npm run build --workspace backend` +- `npm test --workspace backend` +- 重启后端 `setsid -f node dist/main.js` +- `curl http://127.0.0.1:3000/api/health` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- 真人服务单测:`20 passed` +- 后端 typecheck:通过 +- 后端 lint:通过 +- 后端 build:通过 +- 后端全量测试:`24 passed / 195 passed` +- 后端已重启,PID `94928`,健康检查通过。 + +遗留问题: + +- 真实 lip-sync Provider 还没有开通并验收,当前只是把成本闸门和调用策略先做生产级保护。 +- `lip_sync_max_seconds` 目前通过接口参数控制,后续后台可以做成项目级/单集级配置项。 +- 后台审计页还未展示 `lip_sync_budget.skipped`,运营人员暂时需要查看任务 JSON。 + +下一步建议: + +- 选一个真实 lip-sync 平台做小样准入,优先阿里 VideoRetalk。 +- 后台增加 lip-sync 预算档位配置与审计展示:已同步秒数、跳过镜头、跳过原因、节省成本。 +- 继续用第 5 镜或 30s 打破次元壁样片做真实 Provider 对比验收。 + +### 阶段名称 + +LipSync 多平台 Adapter V1 / 阿里 VideoRetalk 可执行接入 + +完成时间: + +2026-06-11 14:35 Asia/Shanghai + +完成内容: + +- 新增 `configurable_async_lip_sync` Provider 驱动,用于“提交任务 -> task_id 轮询 -> 下载结果视频”的 lip-sync 平台。 +- 阿里云百炼 VideoRetalk 接入为可执行 Adapter: + - provider_code:`alibaba-videoretalk-lipsync` + - model:`videoretalk` + - base_url:`https://dashscope.aliyuncs.com` + - create_endpoint:`/api/v1/services/aigc/video-generation/video-retalk` + - task_endpoint_template:`/api/v1/tasks/{task_id}` + - 请求体:`model + input.video_url/audio_url/text` + - 请求头:`X-DashScope-Async: enable` + - 默认禁用。 +- 新增默认禁用 lip-sync Provider 配置: + - `alibaba-videoretalk-lipsync` + - `heygen-lipsync` + - `sync-labs-lipsync` + - `fal-veed-lipsync` + - `volcengine-doubao-lipsync` + - `generic-lipsync` +- 豆包/火山 lip-sync 先接后台占位,默认禁用;当前未确认稳定“已有视频+音频口型替换”公开 API,不硬写不确定 endpoint。 +- live-action 调用 lip-sync Provider 时,除了 data URI,也会传入已有公网 `video_url/audio_url`;阿里这类要求公网 URL 的平台在没有公网 URL 时会明确报错。 +- Provider bootstrap 保留 `video_field`、`text_field`、`requires_public_urls`、`parameters_json` 等厂商字段,避免后台改完后被初始化覆盖。 +- 数据库已 upsert 当前 `LipSyncProvider` 列表,全部保持 `is_enabled=false`,未写入任何真实 API Key。 + +修改文件: + +- `backend/src/providers/provider.types.ts` +- `backend/src/providers/providers.service.ts` +- `backend/src/providers/providers.service.spec.ts` +- `backend/src/live-action/live-action.service.ts` +- `backend/src/live-action/live-action.service.spec.ts` +- `CODEX_PROGRESS.md` + +新增文件: + +- 无。 + +运行命令: + +- `git status --short` +- `npm test --workspace backend -- providers.service.spec.ts` +- `npm test --workspace backend -- live-action.service.spec.ts` +- `npm run typecheck --workspace backend` +- `npm run lint --workspace backend` +- `npm run build --workspace backend` +- `npm test --workspace backend` +- Prisma upsert `LipSyncProvider` 配置 +- Prisma 查询 `LipSyncProvider` 配置 +- 重启后端 `setsid -f node dist/main.js` +- `curl http://127.0.0.1:3000/api/health` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- Provider 单测:`33 passed` +- 真人服务单测:`20 passed` +- 后端 typecheck:通过 +- 后端 lint:通过 +- 后端 build:通过 +- 后端全量测试:`24 passed / 196 passed` +- 数据库确认: + - `mock-lipsync`:禁用 + - `generic-lipsync`:禁用 + - `alibaba-videoretalk-lipsync`:禁用,`0.08 CNY/s` + - `heygen-lipsync`:禁用 + - `sync-labs-lipsync`:禁用 + - `fal-veed-lipsync`:禁用 + - `volcengine-doubao-lipsync`:禁用占位 +- 后端已重启,PID `130104`,健康检查通过。 + +遗留问题: + +- 阿里 VideoRetalk 官方要求公网可访问的 `video_url/audio_url`;当前项目存储层仍以私有本地/MinIO 为主,还需要补“临时公开 URL / 预签名 URL / OSS 中转”才能真实跑阿里。 +- HeyGen、Sync Labs、fal/VEED 已有默认禁用配置,但具体账号版本、endpoint 和响应字段需要开通后用小样校准。 +- 豆包/火山 lip-sync 只做默认禁用占位,等控制台确认正式 API 后再补准确 endpoint。 + +下一步建议: + +- 先补“lip-sync 素材临时公网 URL”能力,优先 MinIO presigned URL 或 OSS 中转。 +- 阿里百炼开通后填 `ALIBABA_DASHSCOPE_API_KEY`,启用 `alibaba-videoretalk-lipsync`,用第 5 镜跑 6 秒真实小样。 +- 后台 Provider 列表增加 LipSyncProvider 分组和“需要公网素材 URL”提示,避免运营误启用。 + +### 阶段名称 + +ProviderAssetBridge V1 / LipSync 临时公网素材 URL + +完成时间: + +2026-06-11 14:58 Asia/Shanghai + +完成内容: + +- 新增后端签名临时素材 URL 能力,兼容本地私有存储和 MinIO 私有存储: + - `StorageService.createTemporaryPublicUrl` + - `StorageService.readTemporaryPublicFile` + - 默认有效期 `3600s` + - 最短 `60s`,最长 `24h` + - 需要配置 `PUBLIC_ASSET_BASE_URL` + - 签名密钥优先读取 `PUBLIC_ASSET_SIGNING_SECRET`,可回退 `JWT_SECRET` +- 新增无登录公开临时下载入口: + - `GET /api/public-temp-assets/:token` + - 只读下载,过期失效 + - `Cache-Control: no-store` +- 真人 lip-sync 调用新增素材桥接: + - Provider 配置 `requires_public_urls=true` 时,自动把私有视频片段生成临时 `video_url`; + - 单句 TTS 音频如果没有公网 URL,先写入私有临时对象,再生成临时 `audio_url`; + - 传给 Provider 的 input 同时保留 data URI,兼容 fal/通用 Provider; + - 任务 JSON 记录 `asset_bridge` 审计摘要,不记录真实 URL token。 +- Provider 日志脱敏: + - `video_url/audio_url` 如果包含 `/public-temp-assets/`,日志中写为 `[REDACTED_TEMP_PUBLIC_ASSET_URL]`。 +- Provider 默认配置新增并保留: + - `public_url_expires_seconds` + - `asset_url_expires_seconds` +- 数据库已同步 LipSyncProvider 配置,阿里/HeyGen/Sync/fal/火山占位继续保持默认禁用。 + +修改文件: + +- `backend/src/assets/storage.service.ts` +- `backend/src/assets/storage.service.spec.ts` +- `backend/src/assets/public-temp-assets.controller.ts` +- `backend/src/assets/assets.module.ts` +- `backend/src/live-action/live-action.service.ts` +- `backend/src/live-action/live-action.service.spec.ts` +- `backend/src/providers/provider.types.ts` +- `backend/src/providers/providers.service.ts` +- `CODEX_PROGRESS.md` + +新增文件: + +- `backend/src/assets/storage.service.spec.ts` +- `backend/src/assets/public-temp-assets.controller.ts` + +运行命令: + +- `git status --short` +- `npm test --workspace backend -- storage.service.spec.ts` +- `npm test --workspace backend -- live-action.service.spec.ts` +- `npm test --workspace backend -- providers.service.spec.ts` +- `npm test --workspace backend -- live-action.service.spec.ts storage.service.spec.ts providers.service.spec.ts` +- `npm run typecheck --workspace backend` +- `npm run lint --workspace backend` +- `npm run build --workspace backend` +- `npm test --workspace backend` +- Prisma upsert `LipSyncProvider` 配置 +- 重启后端 `setsid -f node dist/main.js` +- `curl http://127.0.0.1:3000/api/health` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- Storage 单测:`2 passed` +- Provider 单测:`33 passed` +- 真人服务单测:`21 passed` +- 相关单测合计:`3 passed / 56 passed` +- 后端 typecheck:通过 +- 后端 lint:通过 +- 后端 build:通过 +- 后端全量测试:`25 passed / 199 passed` +- 数据库同步: + - `alibaba-videoretalk-lipsync`:禁用,`public_url_expires_seconds=3600` + - `heygen-lipsync`:禁用,`public_url_expires_seconds=3600` + - `sync-labs-lipsync`:禁用,`public_url_expires_seconds=3600` + - `fal-veed-lipsync`:禁用,`public_url_expires_seconds=3600` + - `volcengine-doubao-lipsync`:禁用,`public_url_expires_seconds=3600` +- 后端已重启,PID `171746`,健康检查通过。 + +遗留问题: + +- 真实阿里 VideoRetalk 运行前需要配置公网可访问的 `PUBLIC_ASSET_BASE_URL`,这个地址必须能从阿里云侧访问到本服务器。 +- 生产环境建议单独设置高强度 `PUBLIC_ASSET_SIGNING_SECRET`,不要长期依赖 `JWT_SECRET` 回退。 +- MinIO 直签 / OSS 中转还没做;当前 V1 使用后端签名下载入口,足够先跑小样。 + +下一步建议: + +- 配置 `PUBLIC_ASSET_BASE_URL=https://你的域名` 和 `PUBLIC_ASSET_SIGNING_SECRET`。 +- 开通阿里百炼后填 `ALIBABA_DASHSCOPE_API_KEY`,启用 `alibaba-videoretalk-lipsync`,用第 5 镜跑真实口型小样。 +- 后台 Provider 页增加“需要公网素材 URL / 临时 URL 有效期 / 当前是否配置 PUBLIC_ASSET_BASE_URL”的提示。 + +### 阶段名称 + +真人视频 Prompt Engine V1 / Provider Profile / 镜头模板库 + +完成时间: + +2026-06-11 17:52 Asia/Shanghai + +完成内容: + +- 新增真人视频 Prompt Engine V1: + - 支持 `generic`、`hailuo`、`kling`、`mock` 四种 Provider Profile。 + - 支持按 `scene_type` 选择镜头模板:`dialog`、`conflict`、`reveal`、`dimensional_break`、`xianxia_transformation`、`action`。 + - 输出结构化 `prompt_components`,包含角色、场景、主动作、运镜、灯光、特效、后期音效提示、口型策略、负面提示词。 + - Hailuo Profile 增加方括号运镜指令,例如 `[推进]`、`[拉远]`、`[环绕]`、`[跟拍]`、`[固定]`。 + - Hailuo Profile prompt 控制在 `1800` 字符以内,预留给平台上限和后续追加字段。 +- 真人分镜准备阶段改为用 Prompt Engine 生成通用版 `video_prompt`。 +- 真人视频真实生成阶段改为根据 Router 选出的 `provider_code` 重新生成 Provider 专属 prompt。 +- `live_action_video_clip_generate` 任务输入新增审计字段: + - `prompt_version` + - `prompt_profile` + - `prompt_components` + - `negative_prompt` +- 保留原有 lip-sync 降级策略文案,避免没有 lip-sync Provider 时出现正脸口型翻车。 + +修改文件: + +- `backend/src/live-action/live-action.service.ts` +- `backend/src/live-action/live-action.service.spec.ts` +- `backend/src/live-action/live-action.module.ts` +- `CODEX_PROGRESS.md` + +新增文件: + +- `backend/src/live-action/prompt-builder.service.ts` +- `backend/src/live-action/prompt-builder.service.spec.ts` + +运行命令: + +- `git status --short` +- `npm test --workspace backend -- prompt-builder.service.spec.ts live-action.service.spec.ts` +- `npm run typecheck --workspace backend` +- `npm run lint --workspace backend` +- `npm test --workspace backend` +- `npm run build --workspace backend` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- Prompt Engine + 真人服务目标测试:`2 passed / 23 passed` +- 后端 typecheck:通过 +- 后端 lint:通过 +- 后端 build:通过 +- 后端全量测试:`26 passed / 201 passed` + +遗留问题: + +- Prompt Engine V1 先以内置模板落地,暂未做后台可编辑 Prompt 模板库。 +- 音效/BGM 目前只作为 `sound_cue` 写入 prompt 组件和审计;真实混音仍走后期音频/BGM 流程,不依赖视频 Provider 直接出声。 +- Kling/Veo/Sora 等 Provider 专属模板后续需要真实小样回测再细化。 + +下一步建议: + +- 用现有 Hailuo Key 重跑“打破次元壁 30 秒小样”,比较 Prompt Engine V1 前后的画面稳定性、动作清晰度和失败原因。 +- 后台 Router 审计页展示 `prompt_profile`、`prompt_version`、`prompt_components`。 +- 第二阶段再把 Prompt Engine 模板前台化,做可编辑的 Prompt Library / 运镜库 / 特效库。 + +### 阶段名称 + +MiniMax LipSyncProvider 占位接入 + +完成时间: + +2026-06-11 18:15 Asia/Shanghai + +完成内容: + +- 确认 MiniMax/Hailuo 体系已经在项目中区分为: + - `VideoProvider`:Hailuo 图生视频/文生视频方向。 + - `VoiceProvider`:MiniMax TTS。 + - `LipSyncProvider`:已有视频 + 音频口型替换方向。 +- 新增 `minimax-lipsync` 默认禁用 Provider 占位: + - `provider_type=LipSyncProvider` + - `provider_code=minimax-lipsync` + - `api_key_env=MINIMAX_API_KEY` + - `driver=configurable_async_lip_sync` + - `requires_public_urls=true` + - `public_url_expires_seconds=3600` + - `create_endpoint/task_endpoint_template` 暂留空,等待 MiniMax 控制台或官方文档确认。 +- 数据库已同步该 Provider: + - `id=78` + - `is_enabled=false` + - 未写入真实 API Key。 + +修改文件: + +- `backend/src/providers/provider.types.ts` +- `backend/src/providers/providers.service.spec.ts` +- `CODEX_PROGRESS.md` + +新增文件: + +- 无 + +运行命令: + +- `git status --short` +- `npm test --workspace backend -- providers.service.spec.ts` +- `npm run typecheck --workspace backend` +- `npm run lint --workspace backend` +- `npm test --workspace backend` +- `npm run build --workspace backend` +- Prisma upsert `minimax-lipsync` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- Provider 单测:`34 passed` +- 后端 typecheck:通过 +- 后端 lint:通过 +- 后端 build:通过 +- 后端全量测试:`26 passed / 202 passed` +- 数据库同步:`minimax-lipsync` 已创建,默认禁用。 + +遗留问题: + +- MiniMax 开放平台当前未确认稳定“已有视频+音频口型替换”的公开 API endpoint;该 Provider 不能直接启用。 +- 若 MiniMax 控制台开通后提供 endpoint,需要补齐: + - `create_endpoint` + - `task_endpoint_template` + - 请求体字段映射 + - 输出视频字段 + - 真实计费规则 + +下一步建议: + +- 继续以 DeepSeek + Hailuo + MiniMax TTS + FFmpeg 跑可发布样片。 +- lip-sync 仍按策略只给高风险正脸台词镜头使用,不做全片 lip-sync。 +- 若要优先真实测试 lip-sync,当前更稳的是阿里 VideoRetalk;MiniMax 等官方 endpoint 确认后再启用。 + +### 阶段名称 + +MiniMax Lip Sync 公开 API 验证 + +完成时间: + +2026-06-11 18:23 Asia/Shanghai + +完成内容: + +- 读取 MiniMax 官方 `llms.txt` 文档索引和 OpenAPI 规格。 +- 官方 API 索引当前只确认: + - Text / Responses + - TTS / 异步 TTS + - Voice Clone / Voice Design + - Image Generation + - Video Generation + - Video Agent / Template Generation + - File Management +- 官方 OpenAPI 当前只检索到以下视频/音频相关路径: + - `/v1/t2a_async_v2` + - `/v1/query/t2a_async_query_v2` + - `/v1/video_generation` + - `/v1/video_template_generation` + - `/v1/query/video_template_generation` +- 对常见 MiniMax lip-sync 路径做无密钥存在性探测,全部返回 `404 page not found`: + - `/v1/lip_sync` + - `/v1/lipsync` + - `/v1/video/lip_sync` + - `/v1/video/lipsync` + - `/v1/video_generation/lipsync` + - `/v1/video/lip-sync` +- 检查本机环境变量: + - `MINIMAX_API_KEY`:未配置 + - `MINIMAX_GROUP_ID`:未配置 + - `MINIMAX_BASE_URL`:未配置 + +修改文件: + +- `CODEX_PROGRESS.md` + +新增文件: + +- 无 + +运行命令: + +- `git status --short` +- `curl https://platform.minimax.io/docs/llms.txt` +- `curl https://platform.minimax.io/docs/api-reference/openapi.json` +- 多个 MiniMax lip-sync 猜测 endpoint 的 `POST {}` 404 探测 +- `.env` MiniMax 相关变量存在性检查 + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- 官方文档 / OpenAPI:未发现稳定公开“已有视频+音频口型替换” Lip Sync API。 +- 猜测 endpoint 探测:全部 404。 +- 本机未配置 MiniMax Key,无法做鉴权后的真实调用。 + +结论: + +- `minimax-lipsync` 保持默认禁用占位是正确的。 +- 当前不能把 MiniMax 当作已可用稳定 LipSyncProvider。 +- MiniMax 仍可继续用于 Hailuo 视频生成和 MiniMax TTS;lip-sync 优先测试阿里 VideoRetalk / HeyGen / Sync Labs / fal VEED 等已经有明确 lip-sync API 形态的平台。 + +下一步建议: + +- 若 MiniMax 控制台或商务支持提供正式 lip-sync endpoint,再补齐 `minimax-lipsync` 的 endpoint、请求体和输出字段。 +- 当前真人短剧流程继续采用“非正脸台词 + TTS + 字幕 + 少量高风险镜头 lip-sync”的低成本策略。 + +### 阶段名称 + +首条真人短剧一集压测用例整理 + +完成时间: + +2026-06-11 18:32 Asia/Shanghai + +完成内容: + +- 将用户提供的《我送外卖时,继承了百亿集团》整理为系统可用的一集真人短剧测试用例。 +- 输出机器可读 JSON: + - 项目配置 + - 故事圣经 + - 3 个角色 + - 2 个场景 + - 第 1 集剧情 + - 10 个分镜 + - Router 预期 + - 口型策略 + - 输出要求 + - 验收标准 +- 输出人工审核 Markdown,方便先看剧情、镜头和验收点。 +- 当前只整理用例,不触发 DeepSeek / Hailuo / FFmpeg 真实生成。 + +修改文件: + +- `CODEX_PROGRESS.md` + +新增文件: + +- `storage/private/live-action-testcases/takeaway-heir-episode-001.json` +- `storage/private/live-action-testcases/takeaway-heir-episode-001.md` + +运行命令: + +- `git status --short` +- `node -e` JSON 解析与时长/镜头数/角色数/场景数校验 + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- JSON 校验通过: + - 镜头:10 个 + - 总时长:53 秒 + - 角色:3 个 + - 场景:2 个 + - normal 镜头:5 个 + - premium 镜头:5 个 + +遗留问题: + +- 还未导入数据库。 +- 还未跑 DeepSeek 剧本生成、Prompt Builder、Hailuo 视频、TTS、字幕、BGM、FFmpeg 合成。 +- 该用例目前作为“第一集压测 fixture”,不是最终成片。 + +下一步建议: + +- 人工先确认剧情和 10 个镜头是否满意。 +- 确认后新增导入脚本,把该 JSON 导入为真实项目/角色/分集/分镜。 +- 再按低风险顺序执行:先 Mock 全链路,再 Hailuo 单镜小样,再 Hailuo 全 10 镜。 + +### 阶段名称 + +首条真人短剧压测项目导入 / Prompt 准备 + +完成时间: + +2026-06-11 20:18 Asia/Shanghai + +完成内容: + +- 新增真人短剧测试用例导入脚本: + - 支持默认读取 `storage/private/live-action-testcases/takeaway-heir-episode-001.json` + - 支持 `--replace=true` 清理同一 `testcase_id` 的旧导入项目 + - 创建 Project / CopyrightRecord / StoryBible / WorldBible / Character / ActorProfile / Episode / EpisodeScript / StoryboardShot / OperationLog + - 不触发真实 AI Provider,不生成视频,不产生外部成本 +- 已执行一次导入: + - `project_id=54` + - `episode_id=40` + - `owner_user_id=1` + - `shot_id=231-240` + - 总时长 `53s` +- 已执行 `prepareLiveActionShots`: + - 10 个镜头全部生成 `video_prompt` + - 项目状态更新为 `live_action_shots_prepared` + - 下一步为 `live_action_keyframes_generate` + +修改文件: + +- `backend/package.json` +- `CODEX_PROGRESS.md` + +新增文件: + +- `backend/src/live-action/import-live-action-testcase.ts` + +运行命令: + +- `git status --short` +- `npm run typecheck --workspace backend` +- `npm run lint --workspace backend` +- `npm run build --workspace backend` +- `npm test --workspace backend` +- `npm run live-action:testcase:import --workspace backend -- --replace=true` +- Prisma 查询校验导入结果 +- Nest ApplicationContext 调用 `LiveActionService.prepareLiveActionShots` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- 后端 typecheck:通过 +- 后端 lint:通过 +- 后端 build:通过 +- 后端全量测试:`26 passed / 202 passed` +- 数据库导入校验: + - 角色:3 + - ActorProfile:3 + - 分镜:10 + - 总时长:53 秒 + - Premium 镜头:5 + - `video_prompt`:10/10 已生成 + +遗留问题: + +- 还未生成关键帧。 +- 还未跑 Mock 视频全链路。 +- 还未调用 Hailuo 真实视频。 +- 还未生成 TTS / 字幕 / BGM / 最终 `episode_001.mp4`。 + +下一步建议: + +- 先跑 Mock 关键帧和 Mock 视频全链路,确认 10 镜合成、字幕、BGM、审计视图都能走通。 +- 再挑 1 个 premium 镜头,用真实 PNG/JPG 关键帧跑 Hailuo 单镜小样。 +- 单镜合格后再跑全 10 镜真实 Hailuo,严格限制成本上限。 + +### 阶段名称 + +首条真人短剧 Mock 全链路成片验收 + +完成时间: + +2026-06-11 20:24 Asia/Shanghai + +完成内容: + +- 对项目 `54` / 第 `40` 集执行 Mock 全链路: + - Mock 关键帧生成 + - Mock 视频片段生成 + - Mock TTS 音频 + - 字幕生成 + - BGM 生成 + - FFmpeg 最终合成 +- 生成最终成片资产: + - `asset_id=373` + - `file_path=local://rendered-videos/2026-06-11/e83438a3-ef14-4ef5-89f4-2a8511bcfe19.mp4` + - 本地路径:`storage/private/rendered-videos/2026-06-11/e83438a3-ef14-4ef5-89f4-2a8511bcfe19.mp4` +- 发现并修复验收差异: + - 测试用例要求 `30FPS` + - 旧 live-action 渲染常量为 `24FPS` + - 已将 `LIVE_ACTION_RENDER_FPS` 从 `24` 改为 `30` + - 重新渲染后 ffprobe 确认输出为 `30/1` + +修改文件: + +- `backend/src/live-action/live-action.service.ts` +- `CODEX_PROGRESS.md` + +新增文件: + +- 无 + +运行命令: + +- `git status --short` +- Nest ApplicationContext 调用: + - `generateKeyframes` + - `generateVideoClips` + - `renderLiveActionEpisode` +- `ffprobe` 检查最终 MP4 +- `npm run typecheck --workspace backend` +- `npm test --workspace backend -- live-action.service.spec.ts` +- `npm run lint --workspace backend` +- `npm test --workspace backend` +- `npm run build --workspace backend` +- 重启后端 `node dist/main.js` +- `curl http://127.0.0.1:3000/api/health` + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- Mock 关键帧:`10/10` +- Mock 视频片段:`10/10` +- 最终成片:成功 +- ffprobe: + - 视频编码:`h264` + - 分辨率:`1080x1920` + - 帧率:`30/1` + - 视频时长:`53.000000s` + - 音频编码:`aac` + - 音频时长:`53.000000s` +- 后端 typecheck:通过 +- 真人服务单测:`21 passed` +- 后端 lint:通过 +- 后端 build:通过 +- 后端全量测试:`26 passed / 202 passed` +- 后端已重启,PID `784440`,健康检查通过。 + +遗留问题: + +- 当前成片是 Mock 视频,不能评价真实画面质量。 +- 当前口型策略为 TTS + 字幕 + 轻口型规避,未启用真实 lip-sync。 +- 真实 Hailuo 单镜还未跑。 + +下一步建议: + +- 在后台打开资产 `373` 或项目 `54` 的结果页,人工先看 Mock 流程预览和字幕/BGM节奏。 +- 选择一个 premium 镜头做真实 Hailuo 单镜小样,建议优先镜头 `235`(劳斯莱斯停靠 + 王伯鞠躬)或镜头 `240`(车门关闭反击)。 +- 单镜通过后再跑全 10 镜真实 Hailuo。 + +## 2026-06-11 真实 Hailuo 单镜小样验收 + +完成时间: + +2026-06-11 20:36 Asia/Shanghai + +完成内容: + +- 对项目 `54` / 第 `40` 集 / 镜头 `235` 跑真实 Hailuo 单镜小样。 +- 先将镜头 `235` 的 mock SVG 关键帧替换为可供真实视频接口使用的 PNG 关键帧: + - `keyframe_asset_id=374` + - `file_path=local://image/2026-06-11/84761003-b42a-43b5-8e46-b3ba8a1f5f62.png` +- 真实视频 Provider 预检通过: + - Provider:`minimax_hailuo_23_fast` + - 模式:`real` + - 预计成本:`0.1902 USD` + - 关键帧:`image/png` + - 阻断项:无 +- 发起真实 Hailuo 图生视频调用并成功回收视频: + - `video_clip_id=25` + - `output_asset_id=375` + - `provider_id=19` + - `provider_request_id=408077018386698` + - 实际成本:`0.1902 USD` + - 文件:`storage/private/live-action-video-clips/2026-06-11/cb0fd951-657a-4d07-b3c7-661a1d8cfa93.mp4` +- 执行队列化质检任务: + - `task_id=376` + - Provider:`mock-qc` + - 结果:`passed` + - 分数:`94` + +修改文件: + +- `CODEX_PROGRESS.md` + +新增文件: + +- 无 + +运行命令: + +- `git status --short` +- Nest ApplicationContext 调用: + - `preflightVideoClips` + - `generateShotVideoClip` + - `checkVideoClipQuality` + - `executeQueuedRouterTask` +- `ffprobe` 检查真实 Hailuo MP4 +- `ffmpeg` 抽帧检查中间画面 + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- Provider 密钥状态: + - `.env` 未设置 `MINIMAX_API_KEY` + - 后台 Provider 已保存加密托管密钥,且已同步到 Hailuo / MiniMax TTS / MiniMax Text +- Hailuo 单镜生成:成功 +- 质检闭环:成功 +- ffprobe 原始 Provider 输出: + - 视频编码:`h264` + - 分辨率:`768x1364` + - 帧率:`24/1` + - 时长:`5.875000s` + - 音频:无 +- 人工抽帧观感: + - 雨夜、豪车、管家鞠躬、男主站位均符合镜头意图。 + - 该片段是无声单镜,需要进入 TTS / 字幕 / BGM / FFmpeg 合成后才可作为完整成片验收。 + +遗留问题: + +- Hailuo Fast 原始输出不是 `1080x1920 / 30FPS / 6s`,最终成片必须依赖 FFmpeg 归一化。 +- 资产表当前记录的是目标尺寸 `1080x1920 / 6s`,但真实原始文件为 `768x1364 / 5.875s`;后续后台审计页应同时展示 Provider 原始媒体参数和归一化参数。 +- 当前只验证了单镜真实视频,没有验证 10 镜真实 Hailuo 全集生成和最终音画合成。 + +下一步建议: + +- 进入 10 镜真实 Hailuo 全集小样,但在全量扣费前先批量准备 PNG/JPG 关键帧。 +- 真实全集跑完后,执行 TTS / 字幕 / BGM / FFmpeg 合成,重点验收音画节奏、字幕、BGM、镜头时长归一化。 +- 后台 Router 审计页补充 Provider 原始输出参数展示,避免 `768x1364/24fps` 和最终 `1080x1920/30fps` 混淆。 + +## 2026-06-11 真实 Hailuo 全 10 镜小样验收 + +完成时间: + +2026-06-11 20:59 Asia/Shanghai + +完成内容: + +- 对项目 `54` / 第 `40` 集执行真实 Hailuo 全 10 镜小样。 +- 因当前没有启用真实 ImageProvider,先为除第 5 镜外的 9 个镜头生成基础构图 PNG 关键帧,用于真实视频链路压测: + - 镜头 `231` -> `keyframe_asset_id=376` + - 镜头 `232` -> `keyframe_asset_id=377` + - 镜头 `233` -> `keyframe_asset_id=378` + - 镜头 `234` -> `keyframe_asset_id=379` + - 镜头 `236` -> `keyframe_asset_id=380` + - 镜头 `237` -> `keyframe_asset_id=381` + - 镜头 `238` -> `keyframe_asset_id=382` + - 镜头 `239` -> `keyframe_asset_id=383` + - 镜头 `240` -> `keyframe_asset_id=384` +- 整集真实 Hailuo 预检通过: + - 镜头数:`10` + - 关键帧:`10/10` 均为 PNG + - Provider:`minimax_hailuo_23_fast` + - 预估成本:`1.6801 USD` + - 阻断项:无 +- 补跑剩余 9 条真实 Hailuo 视频片段: + - 镜头 `231` -> `video_clip_id=26` / `asset_id=385` + - 镜头 `232` -> `video_clip_id=27` / `asset_id=386` + - 镜头 `233` -> `video_clip_id=28` / `asset_id=387` + - 镜头 `234` -> `video_clip_id=29` / `asset_id=388` + - 镜头 `236` -> `video_clip_id=30` / `asset_id=389` + - 镜头 `237` -> `video_clip_id=31` / `asset_id=390` + - 镜头 `238` -> `video_clip_id=32` / `asset_id=391` + - 镜头 `239` -> `video_clip_id=33` / `asset_id=392` + - 镜头 `240` -> `video_clip_id=34` / `asset_id=393` +- 结合已完成的镜头 `235`: + - 10 条真实 Hailuo 视频片段全部成功。 + - 10 条片段质检全部通过,分数均为 `94`。 +- 强制重新合成整集: + - `render_task_id=398` + - `output_asset_id=397` + - 文件:`storage/private/rendered-videos/2026-06-11/69f4335a-cbe5-4a80-99ff-dbbcb7f32741.mp4` + - 状态:`active` +- 后期资产: + - TTS:`audio_task_id=395` / `audio_asset_id=394` + - VoiceProvider:`minimax-tts` + - `audio_is_mock=false` + - 字幕:`subtitle_task_id=396` / `subtitle_asset_id=395` + - BGM:`bgm_task_id=397` / `bgm_asset_id=396` + +修改文件: + +- `CODEX_PROGRESS.md` + +新增文件: + +- 无 + +运行命令: + +- `git status --short` +- Nest ApplicationContext / dist 脚本调用: + - PNG 关键帧上传并回写镜头 + - `preflightVideoClips` + - `generateShotVideoClip` + - `checkVideoClipQuality` + - `executeQueuedRouterTask` + - `renderLiveActionEpisode` +- `ffprobe` 检查最终 MP4 +- `ffmpeg` 抽帧与接触图检查 + +测试结果: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- Hailuo 视频生成: + - 成功:`10/10` + - 失败:`0` + - 单条耗时约 `64s-88s` + - 每条实际成本:`0.1902 USD` + - 总视频成本:`1.902 USD` +- Hailuo 成本估算差异: + - 系统预估 `5s` 镜头为 `0.1585 USD` + - 实际 Hailuo Fast 仍按 `6s` 档返回/扣费,实际为 `0.1902 USD` + - 后续成本模型应按 Provider 档位估算,而不是简单按秒线性估算。 +- 最终成片 ffprobe: + - 视频编码:`h264` + - 分辨率:`1080x1920` + - 帧率:`30/1` + - 视频流时长:`52.600000s` + - 音频编码:`aac` + - 音频流时长:`53.000000s` + - 容器时长:`53.000000s` +- clip_normalization: + - 5 秒业务镜头均从 Hailuo 原始 `5.875s` 裁切到 `5s` + - 6 秒业务镜头保留约 `5.867s` + - 合成后总时长对齐到 `53s` +- TTS: + - 已走真实 `minimax-tts` + - 发现第 1 个音频片段超时: + - 目标:`2.85s` + - 实际:`4s` + - 超出:`1.15s` +- 人工抽帧 / 接触图观感: + - 画面已经是真人短剧风格,字幕烧录位置基本正常。 + - 第 4 镜黑卡、雨夜车、管家、人物近景等主要叙事元素可识别。 + - 由于 9 个关键帧是临时构图图,不是真实定妆图,角色一致性和服装一致性仍不足。 + - 当前 BGM 是 `system_silent_bed_v1`,只能算底噪/铺底,不是正式都市逆袭音乐。 + +遗留问题: + +- 这版证明真实 Hailuo + MiniMax TTS + 字幕 + FFmpeg 合成链路跑通,但不能直接判定为发布级成片。 +- 发布级还缺真实角色定妆图 / 角色锚点图,否则人物脸和服装会漂。 +- 成本估算需要按 Hailuo `6s/10s` 档位修正。 +- 第 1 镜台词过长,TTS 超出分配时长,需要脚本压缩或镜头延长。 +- BGM 还不是正式音乐,需要接音乐素材库或 BGM Provider。 +- 质检仍是 `mock-qc`,还没有真实视觉质检模型。 + +下一步建议: + +- 优先做“真人定妆图 / 角色锚点图小样 V1”:先固定林凡、陈雪、王伯三个人的真实头像和服装,再重跑 2-3 个关键镜头验证角色一致性。 +- 修复 Hailuo 成本估算:按 Provider 配置的 duration 档位向上取整到 `6s/10s`。 +- 优化 TTS 节奏:台词超时自动压缩、分拆或延长镜头。 +- 给 BGM 增加正式素材或 Provider,替换 `system_silent_bed_v1`。 + +## 2026-06-11 公版热门故事《画皮》30 秒质量定位 + +完成时间: + +2026-06-11 21:39 Asia/Shanghai + +完成内容: + +- 停止继续使用《我送外卖时,继承了百亿集团》做画质判断,改用公版热门故事《聊斋志异·画皮》做 30 秒质量定位。 +- 新增内部测试用例: + - `storage/private/live-action-testcases/painted-skin-episode-001.json` + - 6 镜头,每镜 `5s` + - 总时长 `30s` + - 风格:古风悬疑真人短剧 +- 导入新测试项目: + - `project_id=55` + - `episode_id=41` + - `shot_id=241-246` +- 运行现有 Prompt Builder 准备真人分镜提示词: + - 6/6 镜头已进入 `prepared` +- 使用 Codex 内置图片生成能力生成三张真人定妆图,并上传为项目私有资产: + - 王生:`character_id=79` / `anchor_asset_id=398` + - 画皮女子:`character_id=80` / `anchor_asset_id=399` + - 老道士:`character_id=81` / `anchor_asset_id=400` +- 已写入: + - `characters.anchor_asset_id` + - `actor_profiles.anchor_asset_id` + - `actor_profiles.reference_asset_ids` + - `character_images` +- 只跑 3 个关键镜头真实 Hailuo 小样,未跑整集: + - 镜头 `242` 女子求助:`video_clip_id=35` / `asset_id=401` + - 镜头 `244` 道士警告:`video_clip_id=36` / `asset_id=402` + - 镜头 `245` 窗外窥视:`video_clip_id=37` / `asset_id=403` +- 新增真实视频成本: + - `0.1902 USD * 3 = 0.5706 USD` + +修改文件: + +- `CODEX_PROGRESS.md` + +新增文件: + +- `storage/private/live-action-testcases/painted-skin-episode-001.json` + +运行命令: + +- `npm run live-action:testcase:import --workspace backend -- --file=/www/wwwroot/ai/storage/private/live-action-testcases/painted-skin-episode-001.json --replace=true` +- Nest ApplicationContext / dist 脚本调用: + - `prepareLiveActionShots` + - 上传角色锚点图到资产库 + - 回写角色 / ActorProfile / CharacterImage + - `preflightVideoClips` + - `generateShotVideoClip` +- `ffmpeg` 抽帧生成三镜头接触图: + - `/tmp/painted-skin-sample/contact.jpg` + +测试结果: + +- 30 秒故事结构明显比上一版更清楚: + - 夜巷初遇 + - 女子求助 + - 书斋收留 + - 道士警告 + - 窗外窥视 + - 画皮真相一闪 +- 三张角色定妆图质量可用: + - 服装、年龄、气质、古风身份都基本符合设定。 +- 三条 Hailuo 关键镜头生成均成功。 +- 人工抽帧结论: + - 角色脸和服装比临时构图图稳定得多。 + - 但直接把“人物肖像图”作为 Hailuo 首帧,会导致视频更像人物肖像动图,而不是完整场景动作。 + - Hailuo 对首帧构图继承很强,首帧不是场景图,后续很难自然生成复杂动作和双人互动。 + +关键结论: + +- 不应该继续用“纯角色头像”直接跑整集 Hailuo。 +- 正确流程应升级为: + - 角色锚点图 + - 生成每个镜头的场景关键帧 + - 场景关键帧中已经包含角色、服装、场景、动作起手式 + - 再送 Hailuo 图生视频 +- 也就是说,角色锚点图是必要条件,但不是视频首帧本身。 + +下一步建议: + +- 做“场景关键帧 V1”:用角色锚点图约束人物,再为镜头 `242 / 244 / 245` 各生成一张真正的场景关键帧。 +- 只重跑这 3 个镜头,不跑整集,确认动作和场景是否改善。 +- 如果 3 个镜头可用,再补全 30 秒整集;如果仍不行,先改 Prompt Builder 和关键帧生成策略,不再继续烧视频额度。 + +## 2026-06-11 《画皮》场景关键帧 V1 验证 + +完成时间: + +2026-06-11 22:04 Asia/Shanghai + +完成内容: + +- 基于项目 `55` / 第 `41` 集,继续验证“角色锚点图 -> 场景关键帧 -> Hailuo 图生视频”的正确链路。 +- 使用 Codex 内置图片生成能力生成 3 张场景关键帧: + - 镜头 `242` 女子求助:`asset_id=404` + - 镜头 `244` 道士警告:`asset_id=405` + - 镜头 `245` 窗外窥视:`asset_id=406` +- 将三张场景关键帧替换为对应镜头的 `keyframe_asset_id`。 +- 重跑 3 条真实 Hailuo 视频: + - 镜头 `242` -> `video_clip_id=38` / `asset_id=407` + - 镜头 `244` -> `video_clip_id=39` / `asset_id=408` + - 镜头 `245` -> `video_clip_id=40` / `asset_id=409` +- 3 条新片段全部通过 mock-qc: + - `clip_id=38` -> `94` + - `clip_id=39` -> `94` + - `clip_id=40` -> `94` +- 将这 3 条片段合成 15 秒小样: + - `render_asset_id=413` + - 文件:`storage/private/rendered-videos/2026-06-11/3c388019-7ac4-4699-ba52-f9d4f0260eb1.mp4` + - 包含真实 MiniMax TTS、字幕、BGM 铺底。 + +修改文件: + +- `CODEX_PROGRESS.md` + +新增文件: + +- 无 + +运行命令: + +- Codex 内置图片生成:3 张场景关键帧 +- Nest ApplicationContext / dist 脚本调用: + - 上传场景关键帧 + - 回写 `StoryboardShot.keyframe_asset_id` + - `preflightVideoClips` + - `generateShotVideoClip` + - `checkVideoClipQuality` + - `executeQueuedRouterTask` + - `renderLiveActionEpisode` +- `ffprobe` 检查 15 秒小样 +- `ffmpeg` 抽帧和生成对比图 + +测试结果: + +- 三条 Hailuo 生成全部成功: + - 成本:`0.1902 USD * 3 = 0.5706 USD` + - 单条耗时约 `76s-97s` +- 15 秒小样 ffprobe: + - 视频编码:`h264` + - 分辨率:`1080x1920` + - 帧率:`30/1` + - 视频时长:`15.000000s` + - 音频编码:`aac` + - 音频时长:`15.000000s` +- TTS: + - Provider:`minimax-tts` + - `audio_is_mock=false` + - 无 TTS 超时警告 +- clip_normalization: + - Hailuo 原始 `5.875s` + - 每条裁切到目标 `5s` + +人工观感结论: + +- 场景关键帧版明显优于头像首帧版。 +- 镜头 `242` 能看到女子、王生、伞、夜巷、红灯笼,求助关系成立。 +- 镜头 `244` 能看到老道士、王生、竹杖拦路、街市口,警告关系成立。 +- 镜头 `245` 能看到王生窗外窥视、屋内烛光和模糊白衣女子,悬疑关系成立。 +- 这说明“先生成场景首帧,再送 Hailuo”是正确方向。 + +遗留问题: + +- 当前场景关键帧由 Codex 图片生成能力手动生成,还未进入后台自动化流程。 +- 角色锚点图没有被真实 ImageProvider 以多图参考方式自动消费,仍是人工 prompt 对齐。 +- Hailuo 图生视频仍无原生音频,音频必须靠后期 TTS/字幕/BGM。 +- BGM 仍是系统铺底,不是正式古风悬疑音乐。 +- 成本估算仍需按 Hailuo `6s/10s` 档位修正。 + +下一步建议: + +- 把“场景关键帧生成”做成后台正式流程: + - 输入:镜头分镜 + 角色锚点图 + 场景设定 + - 输出:`ShotImage(image_type=scene_keyframe)` + `StoryboardShot.keyframe_asset_id` + - 然后再进入 Hailuo 视频生成 +- 继续补齐《画皮》剩余 `241 / 243 / 246` 三个镜头的场景关键帧,再跑完整 30 秒。 +- 之后再走前端 E2E,因为现在核心质量方向已经明确。 + +## 2026-06-11 《画皮》30 秒完整真人小样验收 + +完成时间: + +2026-06-11 22:31 Asia/Shanghai + +完成内容: + +- 继续基于项目 `55` / 第 `41` 集,补齐剩余三个镜头的场景关键帧: + - 镜头 `241` 夜巷初遇:`keyframe_asset_id=414` + - 镜头 `243` 书斋收留:`keyframe_asset_id=416` + - 镜头 `246` 画皮真相一闪:`keyframe_asset_id=418` +- 分别使用真实 Hailuo 生成三条新片段: + - 镜头 `241` -> `video_clip_id=41` / `asset_id=415` + - 镜头 `243` -> `video_clip_id=42` / `asset_id=417` + - 镜头 `246` -> `video_clip_id=43` / `asset_id=419` +- 合并前 3 条已验证片段: + - 镜头 `242` -> `video_clip_id=38` / `asset_id=407` + - 镜头 `244` -> `video_clip_id=39` / `asset_id=408` + - 镜头 `245` -> `video_clip_id=40` / `asset_id=409` +- 合成完整 30 秒成片: + - `render_asset_id=423` + - 文件:`storage/private/rendered-videos/2026-06-11/1511d944-273b-493f-a860-dae29249a0c5.mp4` + - 包含真实 MiniMax TTS、字幕、静音 BGM 占位。 + +修改文件: + +- `CODEX_PROGRESS.md` + +新增文件: + +- `storage/private/live-action-testcases/painted-skin-episode-001.json` + +运行命令: + +- Codex 内置图片生成:补齐 3 张场景关键帧。 +- `npm run live-action:acceptance --workspace backend`: + - 逐条跑 `241 / 243 / 246` + - Provider:`minimax_hailuo_23_fast` + - `confirm_real_video=true` +- Nest `dist` ApplicationContext: + - `renderLiveActionEpisode` +- `ffprobe`: + - 验证最终成片视频/音频流 +- `ffmpeg`: + - 抽帧检查 6 镜头时间线 + - `volumedetect` 检查最终音频电平 +- `curl http://127.0.0.1:3000/api/health` + +测试结果: + +- Hailuo 本轮新增 3 条均成功: + - `clip_id=41` / `42` / `43` + - 成本:`0.1902 USD * 3 = 0.5706 USD` + - mock 质检:均 `94` +- 《画皮》6 条 Hailuo 片段合计: + - 成本:`0.1902 USD * 6 = 1.1412 USD` + - 约合人民币按汇率浮动约 `8 元` 左右。 +- 30 秒成片规格: + - 视频:`h264` + - 分辨率:`1080x1920` + - 帧率:`30fps` + - 视频时长:`30.000000s` + - 音频:`aac` + - 音频时长:`30.000000s` + - 文件大小:约 `14.8MB` +- 后期: + - TTS Provider:`minimax-tts` + - `audio_is_mock=false` + - `audio_warnings=[]` + - 字幕 cue:`6` + - BGM:`system_silent_bed_v1`,当前只是静音占位,不是正式音乐。 +- clip_normalization: + - 6 条 Hailuo 原始视频均约 `5.875s` + - 合成前全部自动裁切到目标 `5s` + - 最终总时长准确为 `30s` +- 后端健康检查: + - `/api/health` 返回 `status=ok` + +人工观感结论: + +- 这版明显优于之前“外卖继承百亿”和“头像首帧直接跑 Hailuo”的结果。 +- 6 个镜头都与剧情相关,没有出现蜡烛图、无关画面或明显断片。 +- 叙事链路基本成立: + - 夜巷初遇 + - 女子求助 + - 书斋收留 + - 道士警告 + - 窗外窥视 + - 画皮揭露 +- 字幕位置没有挡脸,画面和字幕节奏基本可读。 +- 当前还不能判定为“可直接上架发布”,但已经证明质量问题的主因不是 Hailuo 单点,而是前置关键帧和 Prompt Builder。 + +遗留问题: + +- 角色一致性仍未达到生产级: + - 王生服装在蓝袍、灰袍之间漂移。 + - 人脸一致性比纯文字 prompt 好,但还不够稳定。 +- 场景关键帧目前仍是人工生成,尚未自动接入后台 ImageProvider。 +- 当前 BGM 是静音占位,不能满足正式发布的音乐需求。 +- 旁白/对白已经存在,但未做真实 lip-sync,正脸对白仍应继续走“中景轻口型 + 字幕 + 旁白”策略,或后续接 lip-sync Provider。 +- mock 质检只验证流程,不能替代人工画面审片。 + +关键结论: + +- 后续真人短剧流水线必须改为: + - 故事/分镜 + - 角色锚点图 + - 场景关键帧生成 + - Hailuo 图生视频 + - TTS/字幕/BGM + - clip_normalization + - 人工审片 +- 不建议再用“纯文字 prompt -> Hailuo”或“纯人物头像 -> Hailuo”跑整集。 + +下一步建议: + +- 优先做“场景关键帧自动化 V1”: + - 把 `scene_keyframe` 作为正式中间资产。 + - 后台能看到每个镜头的关键帧、视频片段、最终成片对比。 + - 角色锚点图作为关键帧生成输入,而不是直接作为视频首帧。 +- 同时补“正式 BGM 资产/模板 V1”: + - 都市逆袭、古风悬疑、情感虐恋、修仙爆点各一套。 + - 允许后台上传或选择 BGM。 +- 质量通过后再走前端 E2E,避免继续在低质量画面上验 UI 流程。 + +## 2026-06-11 仿真人发布级质感 V1 + +完成时间: + +2026-06-11 23:03 Asia/Shanghai + +完成内容: + +- 围绕“像抖音真人拍摄短剧”的差距,先补合成层的发布包装能力,不继续盲目烧 Hailuo 额度。 +- 后端真人视频最终合成新增轻量影视包装: + - 轻对比 + - 轻降饱和 + - 轻暗角 + - 细颗粒 +- 后端默认 BGM 从静音占位升级为系统低频悬疑氛围底: + - `system_silent_bed_v1` -> `system_cinematic_bed_v1` + - 使用 FFmpeg 合成低频 pad,不再是完全静音轨。 +- `live_action_video_render.input_json` 新增 `video_polish` 审计字段: + - `version=live-action-video-polish-v1` + - 记录使用的后期包装项。 +- 基于已有《画皮》6 条 Hailuo 片段重做导演剪辑版: + - 不重新调用 Hailuo + - 不增加视频生成成本 + - 将平均 `5s * 6 = 30s` 改为更短更快的 `18.9s` + - 缩短旁白/对白,避免正脸长对白。 + +修改文件: + +- `backend/src/live-action/live-action.service.ts` +- `backend/dist/**` +- `CODEX_PROGRESS.md` + +数据调整: + +- 项目 `55` / 第 `41` 集《画皮》测试集: + - 镜头 `241`:`3s` + - 镜头 `242`:`3.8s` + - 镜头 `243`:`2.7s` + - 镜头 `244`:`3.2s` + - 镜头 `245`:`2.8s` + - 镜头 `246`:`3.4s` +- 台词压缩: + - `女子:公子,救我。` + - `道士:她不是人。` + - `王生:不可能。` + +新增成片: + +- 导演剪辑版 asset: + - `render_asset_id=431` + - 文件:`storage/private/rendered-videos/2026-06-11/6e44c8a9-6d22-438c-ba36-73da0ac230e0.mp4` + - 时长:`18.9s` + +测试结果: + +- `npm run typecheck --workspace backend`:通过。 +- `npm run build --workspace backend`:通过。 +- `npm run test --workspace backend -- live-action.service.spec.ts`: + - `21 passed` +- FFmpeg BGM 表达式验证:通过。 +- 成片 ffprobe: + - 视频:`1080x1920` + - 帧率:`30fps` + - 视频时长:`18.900000s` + - 音频:`aac` + - 音频时长:`18.900000s` +- 音频电平: + - `mean_volume=-18.6 dB` + - `max_volume=-2.4 dB` +- render task 审计: + - `audio_warnings=[]` + - `video_polish.version=live-action-video-polish-v1` + - `bgm_volume=0.18` +- 后端重启并验证: + - 新进程:`node dist/main.js` + - 父进程:`1` + - `/api/health` 返回 `status=ok` + +人工观感结论: + +- 导演剪辑版比 30 秒平均镜头版节奏更接近短视频。 +- 字幕更短,读起来更干净。 +- 合成层统一调色、颗粒、暗角后,画面少了一点“AI直出平铺感”。 +- 但它仍不是最终发布级,原因不是合成层能完全解决的: + - 角色一致性仍会漂。 + - 真人表演感仍不足。 + - 镜头内动作还不够像真实演员自然运动。 + - 真实环境声、脚步声、雨声、衣料声、转场音效还没系统化。 + - 未接真实 lip-sync,正脸对白仍不能大量使用。 + +关键结论: + +- “发布级仿真人”不是单纯 Hailuo 重跑,而是需要进入导演工艺层: + - 短镜头节奏 + - 更短台词 + - 避免正脸长对白 + - 真实声音设计 + - 人工挑片 + - 角色一致性约束 + - 关键镜头多候选择优 + +下一步建议: + +- 做“候选片段择优 V1”: + - 每个关键镜头允许生成 2-3 个候选。 + - 后台人工选择最像真人的一条作为 `video_clip_asset_id`。 + - 不通过的候选保留成本和失败原因。 +- 做“音效轨 V1”: + - 雨声、脚步、门响、心跳、低频转场、惊悚 sting。 + - 按 `scene_type/effect_type` 自动铺音效。 +- 做“角色一致性强化 V1”: + - 场景关键帧自动生成时必须消费角色锚点图。 + - 后台显示锚点图、场景关键帧、视频中帧三栏对比。 + +## 2026-06-12 候选片段择优 V1 + 音效轨 V1 + +本阶段目标: + +- 让关键镜头可以一次生成 `2-3` 条候选,后台人工选择最像真人的一条。 +- 给真人短剧后期增加独立 SFX 音效轨,先解决“画面有了但不像真实拍摄环境”的声音缺口。 + +完成内容: + +- `LiveActionGenerateDto` 新增: + - `candidate_count` + - `include_sfx` + - `sfx_volume` +- 单镜头真人视频生成支持候选片段: + - `candidate_count` 限制为 `1-3`。 + - 多候选生成时,默认第 1 条自动绑定到 `StoryboardShot.video_clip_asset_id`。 + - 第 2/3 条只作为候选保留在 `video_clips`,不会覆盖当前 active clip。 + - render task 的 `input_json` 记录: + - `candidate_index` + - `candidate_count` + - `auto_select_clip` +- 新增候选选择接口: + - `POST /api/live-action/video-clips/:clipId/select-candidate` + - 选择后更新分镜: + - `video_clip_asset_id = clip.output_asset_id` + - `video_status = video_clip_candidate_selected` + - 写入 `operation_logs`: + - `live_action_video_clip_candidate_selected` + - 记录旧 asset、新 asset、质量分、成本、选择原因。 +- 真人后期新增 SFX 音效轨: + - 新 task 类型:`live_action_sfx_generate` + - 新系统音效源标识:`system_scene_sfx_v1` + - 自动按镜头文本/分数生成 cue: + - `rain` + - `footstep` + - `door` + - `heartbeat` + - `sting` + - SFX 独立生成 audio asset,最终和 BGM / TTS 分轨混音。 + - `post_production` 审计新增: + - `include_sfx` + - `sfx_asset_id` + - `sfx_task_id` + - `sfx_volume` + - `sfx_cue_count` + - `sfx_cues` +- SFX 音量策略: + - 默认 `sfx_volume=0.45` + - SFX 总线增加总增益和限幅,避免“有音效但听不见”。 + +改动文件: + +- `backend/src/live-action/live-action.dto.ts` +- `backend/src/live-action/live-action.controller.ts` +- `backend/src/live-action/live-action.service.ts` +- `backend/src/live-action/live-action.service.spec.ts` +- `CODEX_PROGRESS.md` + +测试结果: + +- `npm run typecheck --workspace backend`:通过。 +- `npm run lint --workspace backend`:通过。 +- `npm run test --workspace backend -- live-action.service.spec.ts`: + - `24 passed` +- `npm run build --workspace backend`:通过。 +- 后端重启并验证: + - `/api/health` 返回 `status=ok` + +实测验收: + +- 复用项目 `55` / 第 `41` 集《画皮:书生夜遇美人》。 +- 未重新触发 Hailuo 视频生成。 +- 使用参数: + - `force=true` + - `include_audio=false` + - `include_subtitle=false` + - `include_bgm=true` + - `include_sfx=true` + - `bgm_volume=0.055` +- 生成最终验证版: + - `render_asset_id=440` + - 文件:`storage/private/rendered-videos/2026-06-12/78e1b854-c177-41e9-9622-b05146933e69.mp4` + - 时长:`18.9s` + - 分辨率:`1080x1920` + - 帧率:`30fps` + - 音频:`aac / 44100Hz / stereo` +- SFX 审计: + - `render_task_id=435` + - `sfx_task_id=434` + - `sfx_asset_id=439` + - `sfx_volume=0.45` + - `sfx_cue_count=14` + - SFX 文件:`storage/private/generated-audio/2026-06-12/f5ea97b2-29d5-420d-892d-d0baeeab0b24.wav` +- 音量检测: + - 最终成片:`mean_volume=-37.7 dB`,`max_volume=-24.0 dB` + - SFX 源轨:`mean_volume=-35.5 dB`,`max_volume=-20.1 dB` + +当前结论: + +- 候选片段择优的后端闭环已经具备,下一步需要后台 UI 把同一 shot 的候选并排预览出来。 +- SFX 音效轨已可自动生成、落库、审计、混音;对惊悚、雨夜、反转类镜头会明显提升“像真实后期”的感觉。 +- 这一步仍不解决角色漂移和真人表演不自然,后续还要继续做: + - 后台候选片段并排挑选 UI。 + - 角色锚点图参与关键帧/视频 prompt。 + - 关键镜头候选的人工评分和失败原因回流。 + - 台词镜头仅给必要镜头接 lip-sync,其他继续走旁白/字幕/中景策略。 + +## 2026-06-12 导演分镜 V1 / 连续剪辑优化 + +问题复盘: + +- 《画皮:书生夜遇美人》目标时长是 `30s`,但当前实际分镜总时长只有 `18.9s`。 +- 当前 6 个镜头分别只有 `2.7-3.8s`,节奏更像“AI 图片快切预告”,不是“真人导演拍摄的一场戏”。 +- 主要问题不是单纯 6 秒不够,而是: + - 场景跳转太大。 + - 每个镜头都像独立生成。 + - 缺少建立镜头、动作衔接、反应镜头、插入镜头的剪辑组合。 + - 缺少 eyeline / match-on-action / sound bridge 一类连续剪辑信息。 + +完成内容: + +- 新增导演分镜计划: + - `LIVE_ACTION_DIRECTOR_PLAN_VERSION=live-action-director-plan-v1` + - 每个镜头自动标记: + - `establishing` + - `movement` + - `dialogue` + - `reaction` + - `insert` + - `reveal` +- 真人分镜准备阶段新增目标时长分配: + - 按 `episode.target_duration` 分配镜头时长。 + - 单镜头限制在 `2.5-8s`。 + - 建立镜头、对白镜头、揭示镜头权重更高。 + - 插入镜头、反应镜头相对更短。 +- 对 episode 41 的 6 镜头测试数据,导演分镜计划会把总时长分配到 `30s`,不是旧版 `18.9s`。 +- 新增连续剪辑字段进入生成提示词: + - `scene_group_id` + - `shot_role` + - `shot_size` + - `blocking` + - `continuity_in` + - `continuity_out` + - `edit_intent` + - `sound_bridge` +- Prompt Builder 升级: + - Hailuo 中文 prompt 中加入: + - `导演分镜` + - `剪辑目的` + - `连续性` + - `声音桥` + - `场景组` + - 英文/generic prompt 中加入: + - director beat + - editing intent + - continuity in/out + - sound bridge + - negative prompt 增加: + - `montage slideshow look` + - `unmotivated time jump` + - `new location jump cut` +- `prepareLiveActionShots(force=true)` 会写入更强的: + - `duration` + - `actor_action` + - `camera_instruction` + - `performance_instruction` + - `live_action_desc` + - `video_prompt` +- 导演分镜重写时会清空旧的 `video_clip_asset_id`,避免新分镜继续误用旧短片。 +- 真人视频生成阶段新增 active clip 复用保护: + - 只复用当前 shot 绑定的 `video_clip_asset_id`。 + - 只复用时长仍匹配当前分镜目标时长的片段。 + - 如果旧片段只有约 `3s`、新导演分镜要求 `5-8s`,会强制重新生成,不再混入旧素材。 + +改动文件: + +- `backend/src/live-action/live-action.service.ts` +- `backend/src/live-action/prompt-builder.service.ts` +- `backend/src/live-action/live-action.service.spec.ts` +- `backend/src/live-action/prompt-builder.service.spec.ts` +- `CODEX_PROGRESS.md` + +测试结果: + +- `npm run typecheck --workspace backend`:通过。 +- `npm run lint --workspace backend`:通过。 +- `npm run test --workspace backend -- live-action.service.spec.ts prompt-builder.service.spec.ts`: + - `28 passed` +- `npm run build --workspace backend`:通过。 +- 后端重启并验证: + - `/api/health` 返回 `status=ok` + +注意事项: + +- 没有直接把现有《画皮》旧素材重合成为 `30s`。 +- 原因: + - 旧 Hailuo 视频片段本身只有约 `3s` 一条。 + - 只改分镜目标时长、不重新生成视频,会造成素材时长和目标时长不一致。 + - 正确验证方式是:下一条真实小样先重新执行 `prepareLiveActionShots(force=true)`,再按新时长重新生成视频片段。 + +下一步建议: + +- 用《画皮》或新的 30 秒测试集重新跑一版真实 Provider: + - 2 个场景以内。 + - 6-8 个镜头。 + - 总时长严格接近 `30s`。 + - 每个镜头都带导演分镜计划。 +- 后台增加导演分镜审计视图: + - 显示 shot_role、duration、continuity_in/out、edit_intent、sound_bridge。 +- 后续继续做: + - 角色锚点图参与关键帧。 + - 候选片段并排挑选 UI。 + - 真实 SFX 素材库替换当前系统合成音效。 + +## 2026-06-12 《画皮》导演分镜版 30 秒真实 Hailuo 小样 + +完成时间: + +2026-06-12 20:05 Asia/Shanghai + +目标: + +- 继续使用公版《聊斋志异·画皮》,但不沿用旧版“夜巷 -> 街市 -> 书斋”跨场景快切结构。 +- 新版只做一场戏:王生深夜隔窗窥见画皮真相。 +- 验证: + - 导演分镜 V1 + - Hailuo 图生视频 + - FFmpeg 自动裁切 + - MiniMax TTS + - 字幕 + - BGM + - SFX 音效轨 + - 质检任务化闭环 + +完成内容: + +- 新增测试用例: + - `storage/private/live-action-testcases/painted-skin-director-episode-001.json` + - 5 个镜头,总目标时长 `30s` + - 结构: + - 建立镜头:雨夜书斋窗外 + - 窥视镜头:王生从窗缝看向屋内 + - 插入镜头:铜镜、画笔、人皮轮廓暗示 + - 反应镜头:王生屏息后退 + - 揭示镜头:铜镜中女子发现窗外有人 +- 导入新测试项目: + - `project_id=57` + - `episode_id=43` + - `shot_id=248-252` +- 执行导演分镜准备: + - 镜头时长重新分配为: + - `7.66s` + - `4.08s` + - `7.09s` + - `4.08s` + - `7.09s` + - 合计约 `30s` +- 为新项目复制并绑定旧《画皮》场景 PNG 关键帧: + - `441-445` + - 仅用于低成本验证导演分镜和后期链路,未重新生成全新导演级首帧。 +- 使用真实 Hailuo 生成 5 个视频片段: + - `clip_id=44` / `asset_id=446` / `0.317 USD` + - `clip_id=45` / `asset_id=447` / `0.1902 USD` + - `clip_id=46` / `asset_id=448` / `0.317 USD` + - `clip_id=47` / `asset_id=449` / `0.1902 USD` + - `clip_id=48` / `asset_id=450` / `0.317 USD` +- 合成最终成片: + - `render_asset_id=455` + - 文件:`storage/private/rendered-videos/2026-06-12/e2cdb72c-d6d0-45e8-b3c5-ca8de2cd488d.mp4` + - `render_task_id=450` + +验收结果: + +- Hailuo 生成: + - 成功:`5/5` + - 失败:`0/5` + - 真实视频成本合计:`1.3314 USD` +- 最终成片规格: + - 视频:H.264 + - 分辨率:`1080x1920` + - 帧率:`30fps` + - 视频时长:`30.000000s` + - 音频:AAC stereo + - 音频时长:`30.000000s` + - 文件大小:约 `11.8MB` +- 音频检测: + - `mean_volume=-19.3 dB` + - `max_volume=-2.9 dB` + - 不再是静音成片。 +- 后期层: + - `audio_is_mock=false` + - `audio_provider_codes=["minimax-tts"]` + - `subtitle_cue_count=5` + - `sfx_cue_count=13` + - `include_bgm=true` + - `include_sfx=true` + - `audio_warnings=[]` +- FFmpeg 裁切: + - 5 个 Hailuo 原始片段全部按分镜目标时长裁切。 + - `10.125s` 原始片段裁为约 `7.1-7.67s`。 + - `5.875s` 原始片段裁为约 `4.07s`。 +- 质检任务补跑: + - `clip_id=44`:`passed / 94` + - `clip_id=45`:`passed / 94` + - `clip_id=46`:`passed / 94` + - `clip_id=47`:`passed / 94` + - `clip_id=48`:`needs_retry / 72` + - 第 5 镜被 mock-qc 的恐怖/画皮关键词打低分,原因包含 `mock_quality_keyword` 和 `AUTO_REPAIR_DISABLED`。 + +人工抽帧观感: + +- 对比旧版 `18.9s` 快切,节奏明显更接近“一场戏”: + - 建立镜头 -> 窥视 -> 插入 -> 反应 -> 揭示 + - 字幕、音轨、雨声、心跳和 sting 已经形成完整后期层。 +- 仍未达到发布级: + - 第 2/4 镜复用了同一张窗外关键帧,画面重复感明显。 + - 第 3/5 镜复用了同一张揭示关键帧,结尾缺少真正“镜中抬眼”的新动作起点。 + - 关键帧不是为新版 5 镜专门生成,所以视频仍像“旧素材重新导演剪辑”,不是完整专业拍摄。 + - 第 5 镜需要人工看画面决定是否接受,当前系统质检状态是 `needs_retry`。 + +修改文件: + +- `storage/private/live-action-testcases/painted-skin-director-episode-001.json` +- `CODEX_PROGRESS.md` + +运行 / 验证: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- `jq empty storage/private/live-action-testcases/painted-skin-director-episode-001.json`:通过。 +- `npm run live-action:testcase:import --workspace backend -- --file=/www/wwwroot/ai/storage/private/live-action-testcases/painted-skin-director-episode-001.json --replace=true`:通过。 +- Nest ApplicationContext 调用: + - `prepareLiveActionShots(force=true)` + - 复制并绑定关键帧资产 + - `preflightVideoClips` + - `generateShotVideoClip` + - `renderLiveActionEpisode` + - `executeQueuedRouterTask` 补跑质检任务 +- `ffprobe`:通过,最终视频/音频均为 `30s`。 +- `ffmpeg volumedetect`:通过,非静音。 +- `npm run typecheck --workspace backend`:通过。 +- `npm run test --workspace backend -- live-action.service.spec.ts prompt-builder.service.spec.ts`: + - `28 passed` +- `npm run lint --workspace backend`:通过。 +- `npm run build --workspace backend`:通过。 +- `/api/health`:返回 `status=ok`。 + +当前结论: + +- “继续用画皮”是合适的,因为它能稳定暴露古风真人短剧的关键问题:角色、场景、镜头连续性、惊悚后期和揭示镜头质量。 +- 导演分镜 V1 有效,30 秒成片节奏比旧快切版更顺。 +- 但这版不能算最终 PASS,原因不是 Hailuo 接入失败,而是关键帧不够精细: + - 必须为每个镜头单独生成“导演级场景首帧”。 + - 不能复用同一张关键帧承担不同剪辑功能。 + +下一步建议: + +- 继续围绕《画皮》做“导演级关键帧 V1”: + - 为 `248-252` 每个镜头重新生成专属首帧。 + - 第 5 镜单独生成“铜镜中女子抬眼”的首帧。 + - 再只重跑第 2/4/5 镜,先不全片重跑。 +- 后台增加关键帧验收:每个镜头生成视频前先人工确认首帧是否符合导演分镜。 + +## 2026-06-12 《画皮》导演级关键帧 V1 / 局部重跑 + +完成时间: + +2026-06-12 20:25 Asia/Shanghai + +目标: + +- 继续优化 `project_id=57` / `episode_id=43` 的《画皮:书斋窥真》导演分镜版。 +- 不全片重跑,只针对上一版问题最明显的镜头重新生成导演级首帧并重跑: + - 第 2 镜:窗缝窥看 + - 第 4 镜:王生反应 + - 第 5 镜:铜镜回望反转 +- 验证是否能降低“复用旧关键帧导致的重复镜头感”。 + +完成内容: + +- 使用 `imagegen` 生成 3 张新 PNG 首帧: + - 第 2 镜:over-the-shoulder 窗缝窥看,明确王生视线和屋内女子空间关系。 + - 第 4 镜:王生中近景反应,独立于第 2 镜,不再复用同构窗外画面。 + - 第 5 镜:铜镜中女子抬眼回望,作为真正的结尾反转首帧。 +- 上传并绑定新关键帧: + - `shot_id=249` -> `asset_id=456` + - `shot_id=251` -> `asset_id=457` + - `shot_id=252` -> `asset_id=458` +- 只重跑 3 条 Hailuo 真实片段: + - `shot_id=249` -> `clip_id=49` / `asset_id=459` / `0.1902 USD` + - `shot_id=251` -> `clip_id=50` / `asset_id=460` / `0.1902 USD` + - `shot_id=252` -> `clip_id=51` / `asset_id=461` / `0.317 USD` +- 新增真实视频成本: + - `0.6974 USD` +- 重新合成最终成片: + - `render_asset_id=466` + - 文件:`storage/private/rendered-videos/2026-06-12/5785a058-7ebf-423f-8822-80c3ce4bdcdc.mp4` + - `render_task_id=461` + +验收结果: + +- 最终成片规格: + - 视频:H.264 + - 分辨率:`1080x1920` + - 帧率:`30fps` + - 视频时长:`30.000000s` + - 音频:AAC stereo + - 音频时长:`30.000000s` + - 文件大小:约 `12MB` +- 音频检测: + - `mean_volume=-19.5 dB` + - `max_volume=-1.4 dB` + - 音轨正常,非静音。 +- 质检结果: + - `clip_id=49`:`passed / 94` + - `clip_id=50`:`passed / 94` + - `clip_id=51`:`needs_retry / 72` + - 第 5 镜仍被 mock-qc 按“画皮/揭示”关键词打低分,当前不自动重跑,等待人工看画面决定。 +- FFmpeg 裁切: + - 新片段均按业务分镜时长裁切: + - 第 2 镜:`5.875s -> 4.067s` + - 第 4 镜:`5.875s -> 4.067s` + - 第 5 镜:`10.125s -> 7.1s` + +人工抽帧观感: + +- 明显改善: + - 第 2 镜和第 4 镜不再是同一张窗外窥视图的重复。 + - 第 2 镜承担“视线关系 / 王生看见屋内”的功能。 + - 第 4 镜承担“王生反应 / 控制恐惧”的功能。 + - 第 5 镜变成真正的“镜中凝视 / 被发现”反转镜头。 +- 仍未达到最终发布级: + - 第 3 镜仍沿用旧“贴脸画皮”图,和新版第 5 镜空间衔接略硬。 + - 第 3 镜应该改成真正的插入特写:画笔、铜镜边缘、苍白纸/面皮轮廓、女子手部,而不是人物正面恐怖动作。 + - 第 5 镜质检状态仍是 `needs_retry`,需要人工观看实际视频后决定是否接受或再生成 1 条候选。 + +新增 / 变更资产: + +- 新关键帧: + - `456`:`local://image/2026-06-12/1c4de6c5-a187-43e0-baa2-8d0f52d799eb.png` + - `457`:`local://image/2026-06-12/de392947-fc73-4ad4-b8cc-0f96cdfe8548.png` + - `458`:`local://image/2026-06-12/acd2eea1-094a-4ab4-a5a7-2dda68e7fe16.png` +- 新视频片段: + - `459` + - `460` + - `461` +- 新最终成片: + - `466` + +修改文件: + +- `CODEX_PROGRESS.md` + +运行 / 验证: + +- `ffprobe`:通过,最终视频/音频均为 `30s`。 +- `ffmpeg volumedetect`:通过,非静音。 +- 抽帧接触图: + - `/tmp/painted-skin-director/final-asset-466/contact.jpg` + - `/tmp/painted-skin-director/final-asset-466/frame-27s.jpg` + +当前结论: + +- “导演级首帧”方向是有效的。 +- 后续系统不应该只写 prompt 后直接生成视频,而应该多一步: + - 分镜角色:establishing / POV / insert / reaction / reveal + - 每种角色生成对应首帧 + - 首帧人工确认 + - 再进入 Hailuo / Kling 视频生成 +- 下一步最适合补第 3 镜“插入特写首帧”,只重跑第 3 镜和最终合成,不全片重跑。 + +## 2026-06-12 法相天地 10 秒爆点样片 / Hailuo 真实生成 + +完成时间: + +2026-06-12 20:55 Asia/Shanghai + +背景: + +- 用户暂停《画皮》优化,要求按提供的“法相天地三段分镜提示词”做一条 10 秒样片,对比抖音强视觉爆点效果。 +- 本轮不做剧情完整性,只验证: + - 强动作 + - 强运镜 + - 仙侠 VFX + - 音效冲击 + - Hailuo 对高价值爆点镜头的表现 + +完成内容: + +- 新增法相天地测试用例: + - `storage/private/live-action-testcases/faxiang-tiandi-episode-001.json` + - 项目名:`法相天地:千臂法身` + - `project_id=58` + - `episode_id=44` + - 镜头: + - `shot_id=253`:浴血惊鸿,`3s` + - `shot_id=254`:繁花结印,`3s` + - `shot_id=255`:法身降临,`4s` +- 生成 3 张专属关键帧: + - 浴血落地 + - 手部结印 + 紫色光球 + - 低机位千臂法身 +- 上传并绑定关键帧: + - `asset_id=467` + - `asset_id=468` + - `asset_id=469` +- 扩展真人后期 SFX 规则: + - 新增 `wind` + - 新增 `debris` + - 新增 `electric` + - 新增 `impact` + - 用于法相、灵力、电流、碎石、轰鸣、低频冲击类镜头。 +- 使用真实 Hailuo 生成 3 条视频片段: + - `clip_id=52` / `asset_id=470` / `0.1902 USD` + - `clip_id=53` / `asset_id=471` / `0.1902 USD` + - `clip_id=54` / `asset_id=472` / `0.1902 USD` +- 第一次合成: + - `asset_id=475` + - 文件:`storage/private/rendered-videos/2026-06-12/36971308-23f7-4bc8-8db5-dfb3a8be8484.mp4` +- 因音效电平偏弱,未重跑 Hailuo,只重新合成更大声 SFX/BGM 版: + - `asset_id=478` + - 文件:`storage/private/rendered-videos/2026-06-12/84977037-fe9b-4211-8e7e-c92a63de2d9b.mp4` + +验收结果: + +- Hailuo 生成: + - 成功:`3/3` + - 失败:`0` + - 真实视频成本:`0.5706 USD` +- 质检: + - `clip_id=52`:`passed / 94` + - `clip_id=53`:`passed / 94` + - `clip_id=54`:`passed / 94` +- 最终成片规格: + - 分辨率:`1080x1920` + - 帧率:`30fps` + - 时长:`10.000000s` + - 视频编码:H.264 + - 音频编码:AAC + - 字幕:无 + - TTS:无 + - BGM:有 + - SFX:有 +- FFmpeg 裁切: + - 3 条 Hailuo 原始片段均约 `5.875s` + - 分别裁切到 `3s / 3s / 4s` +- SFX: + - 生成 `15` 个音效点。 + - 包含: + - `wind` + - `debris` + - `electric` + - `impact` + - `heartbeat` + - `sting` +- 音量检测: + - 第一版 `asset_id=475`: + - `mean_volume=-28.8 dB` + - `max_volume=-16.9 dB` + - 增强版 `asset_id=478`: + - `mean_volume=-25.2 dB` + - `max_volume=-12.6 dB` + +人工抽帧观感: + +- 整体比《画皮》更接近抖音爆点视频。 +- 第 3 镜“法身降临”效果最好: + - 低机位仰拍成立。 + - 千臂法身规模感明显。 + - 白裙女仙和巨大法身的比例关系有压迫感。 +- 第 2 镜“繁花结印”稳定: + - 手部和紫色光球清晰。 + - 适合做中段能量聚集。 +- 第 1 镜“浴血惊鸿”首帧强,但后续仍需人工看完整视频判断动作幅度: + - 如果动作偏小,下一步只重跑第 1 镜,生成 2 条候选,选落地/推眼动作更强的一条。 +- 当前仍未达到商业发布级的原因: + - 系统合成 SFX 不如真实商业音效库炸裂。 + - 三镜之间仍偏“高级概念镜头拼接”,还不是完整武指/剪辑师调过的动作连续段。 + - Hailuo 对复杂千臂法身能保住大画面,但细看手臂细节可能仍有 AI 感。 + +修改文件: + +- `backend/src/live-action/live-action.service.ts` +- `backend/src/live-action/live-action.service.spec.ts` +- `storage/private/live-action-testcases/faxiang-tiandi-episode-001.json` +- `CODEX_PROGRESS.md` + +运行 / 验证: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- `jq empty storage/private/live-action-testcases/faxiang-tiandi-episode-001.json`:通过。 +- `npm run test --workspace backend -- live-action.service.spec.ts`: + - `27 passed` +- `npm run typecheck --workspace backend`:通过。 +- `npm run lint --workspace backend`:通过。 +- `npm run build --workspace backend`:通过。 +- `ffprobe`:通过,最终视频/音频均为 `10s`。 +- `ffmpeg volumedetect`:通过,非静音。 +- 抽帧接触图: + - `/tmp/faxiang-tiandi/final-asset-478/contact.jpg` + - `/tmp/faxiang-tiandi/final-asset-478/frame-8_5s.jpg` + +当前结论: + +- 对比《画皮》,法相天地这种强 VFX 题材更容易获得“第一眼爆点”。 +- 现有流水线已经能生成 10 秒高冲击仙侠样片,但要接近抖音成熟爆款,还需要: + - 关键镜头候选片段择优。 + - 商业音效库 / 音乐素材库。 + - Motion Director Prompt V1,把每镜内部 0-1s / 1-2s / 2-3s 动作节奏写进 prompt。 + +## 2026-06-12 法相天地 Motion Director Prompt V1 / 动作导演层 + +完成时间: + +2026-06-12 21:08 Asia/Shanghai + +背景: + +- 用户反馈当前法相天地样片质量与抖音热门“法相天地”差距很大: + - 热门动作没有。 + - 结印手法没有。 + - 镜头仍偏静态概念图运动,缺少武指 / 动作导演 / 剪辑节奏。 +- 公开搜索 `sholi888` / 抖音法相天地相关结果时,抖音详情页无法稳定直接观看,但能确认该类爆点视频的核心不是单纯“法相出现”,而是: + - 明确手部结印动作。 + - 睁眼 / 爆光 / 抬头 / 展臂等强动作卡点。 + - 低机位、快速推进、拉远显规模。 + - 音效与动作同步卡点。 +- 本轮不继续消耗真实 Hailuo 额度,先修系统 Prompt Engine。 + +完成内容: + +- 在真人视频 Prompt Engine 中新增 `motion_director` 结构化组件: + - `motion_version` + - `beat_style` + - `action_technique` + - `time_beats` + - `camera_rhythm` + - `vfx_timing` + - `sound_hits` + - `negative_motion` +- 为 `xianxia_transformation` 增加 3 类动作导演模板: + - 浴血落地 / 抬头爆眼: + - 凌空翻身落地。 + - 手掌和膝盖触地。 + - 碎石震开。 + - 镜头极速推进到眼部。 + - 繁花结印 / 紫色光球: + - 手部特写。 + - 食指中指并拢交错。 + - 手腕翻转。 + - 拇指扣成莲花印。 + - 紫色光球随第二次手印出现,最后双掌震出爆亮。 + - 千臂法身 / 法相降临: + - 女仙双臂像凤凰展翅一样展开。 + - 透明法身从地面升起。 + - 千只巨手一层层展开并结印。 + - 地裂、碎石上浮、粉化按时间递进。 +- Hailuo / Kling / Generic / Mock prompt 均会输出动作导演字段。 +- `negative_prompt` 自动加入动作禁忌,例如: + - `random hand waving` + - `blurred fingers` + - `static magical orb only` + - `tiny dharma body` + - `static statue behind actor` +- 修正识别优先级: + - 出现 `法相 / 法身 / 千臂 / 巨手` 时优先走“法身降临”模板。 + - 避免因为“千只巨手结印”被误判为普通手部结印镜头。 + +修改文件: + +- `backend/src/live-action/prompt-builder.service.ts` +- `backend/src/live-action/prompt-builder.service.spec.ts` +- `CODEX_PROGRESS.md` + +运行 / 验证: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- `npm run test --workspace backend -- prompt-builder.service.spec.ts`: + - `4 passed` +- `npm run test --workspace backend -- live-action.service.spec.ts`: + - `27 passed` +- `npm run typecheck --workspace backend`:通过。 +- `npm run lint --workspace backend`:通过。 +- `npm run build --workspace backend`:通过。 + +当前结论: + +- 这轮没有重跑真实视频,没有新增 Hailuo 成本。 +- 之前样片质量差的根因已定位为: + - 不是单纯 Provider 不行。 + - 也不是分镜秒数问题本身。 + - 而是 Prompt Engine 缺少“动作导演层”,没有把 3-4 秒内的热门动作拆成模型可执行的时间节奏。 +- 下一步建议只重跑第 2 镜“繁花结印”和第 3 镜“法身降临”: + - 使用新版 Motion Director Prompt。 + - 默认每镜只生成 1 条,控制成本。 + - 如果仍达不到标准,再考虑关键镜头候选片段择优。 + +## 2026-06-12 法相天地 BGM Director V1 / 剧情驱动配乐 + +完成时间: + +2026-06-12 21:24 Asia/Shanghai + +背景: + +- 用户指出当前法相天地样片缺少 BGM 音乐带来的情绪推进: + - 没有音乐,爆点效果少一半。 + - BGM 需要根据剧情和镜头插入,而不是简单铺一条环境底音。 +- 本轮不重跑 Hailuo 视频片段,先升级后期合成系统,并用已有真实 Hailuo 片段重新合成验证。 + +完成内容: + +- 新增 BGM Director V1: + - `LiveActionBgmCue` + - `LiveActionBgmCueType` + - `buildLiveActionBgmCues` + - `createLiveActionCueBgmTrack` +- 系统会根据分镜自动生成 BGM cue: + - `urban_drama`:普通都市剧情底乐。 + - `suspense_tension`:悬疑 / 惊悚 / 高情绪张力。 + - `xianxia_tension`:仙侠开场压迫、废墟、狂风、浴血。 + - `xianxia_build_up`:结印、聚能、光球、电流。 + - `xianxia_epic`:法相 / 法身 / 千臂 / 威压 / 史诗爆发。 +- 法相天地 3 镜自动识别为: + - `0-3s`:`xianxia_tension` + - `3-6s`:`xianxia_build_up` + - `6-10s`:`xianxia_epic` +- `live_action_bgm_generate` 任务会记录: + - `cue_count` + - `cues` + - `bgm_source` + - `duration_seconds` +- `live_action_video_render` 的 `post_production` 审计新增: + - `bgm_cue_count` + - `bgm_cues` +- BGM / SFX 默认音量改为自动策略: + - 有对白:BGM 自动压低,避免盖住人声。 + - 无对白仙侠爆点:BGM / SFX 自动提高,适合测试强视觉爆点。 + - 仍保留 `bgm_volume` / `sfx_volume` 人工 override。 + +生成验证: + +- 未重跑 Hailuo,未新增真实视频 Provider 成本。 +- 用已有真实片段重新合成两版: + - `asset_id=481` + - 文件:`storage/private/rendered-videos/2026-06-12/67809a8c-3bac-44ea-9c63-d5134f9b680b.mp4` + - `bgm_volume=0.32` + - `sfx_volume=0.85` + - `mean_volume=-25.7 dB` + - `max_volume=-11.3 dB` + - `asset_id=484` + - 文件:`storage/private/rendered-videos/2026-06-12/f372bfb3-fdb4-4fee-a212-353f73d3bbf0.mp4` + - `bgm_volume=0.55` + - `sfx_volume=0.95` + - `mean_volume=-22.1 dB` + - `max_volume=-7.7 dB` +- 当前建议优先查看 `asset_id=484`,这是无对白爆点测试更接近短视频观感的一版。 + +修改文件: + +- `backend/src/live-action/live-action.service.ts` +- `backend/src/live-action/live-action.service.spec.ts` +- `CODEX_PROGRESS.md` + +运行 / 验证: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- `npm run test --workspace backend -- live-action.service.spec.ts prompt-builder.service.spec.ts`: + - `32 passed` +- `npm run typecheck --workspace backend`:通过。 +- `npm run lint --workspace backend`:通过。 +- `npm run build --workspace backend`:通过。 +- `ffprobe asset_id=484`: + - `10.000000s` + - `1080x1920` + - `30fps` + - `H.264 + AAC` +- `ffmpeg volumedetect asset_id=484`: + - `mean_volume=-22.1 dB` + - `max_volume=-7.7 dB` + +当前结论: + +- 系统后期从“有无 BGM”升级为“按分镜剧情自动配乐”。 +- 对法相天地这类测试片,后续不需要前端手动传音量也能获得更强的默认音乐冲击。 +- 下一步可以进入低成本视频质量复测: + - 用新版 Motion Director Prompt 重跑第 1 镜 / 第 2 镜 / 第 3 镜。 + - 每镜默认 1 条,继续控制成本。 + - 对比 `asset_id=484` 的后期版本,看问题是否主要剩在画面动作本身。 + +## 2026-06-12 法相天地 Motion Director 真实重跑 / Hailuo 三镜复测 + +完成时间: + +2026-06-12 21:36 Asia/Shanghai + +背景: + +- 用户确认测试优化阶段可以继续重跑,只要系统质量有进步。 +- 本轮目标: + - 用新版 Motion Director Prompt 重跑第 1 / 2 / 3 镜。 + - 每镜只生成 1 条,控制成本。 + - 用 BGM Director V1 自动配乐重新合成。 + - 检查动作导演提示词是否真实进入 Hailuo 请求。 + +执行内容: + +- 固定真实 Provider: + - `minimax_hailuo_23_fast` + - `confirm_real_video=true` + - `max_cost_per_clip=0.3` + - `candidate_count=1` +- 重跑镜头: + - 第 1 镜 `shot_id=253`:浴血惊鸿 + - 第 2 镜 `shot_id=254`:繁花结印 + - 第 3 镜 `shot_id=255`:法身降临 +- 新生成片段: + - `clip_id=55` / `asset_id=485` / `0.1902 USD` + - `clip_id=56` / `asset_id=486` / `0.1902 USD` + - `clip_id=57` / `asset_id=487` / `0.1902 USD` +- 本轮新增真实视频成本: + - `0.5706 USD` +- 队列质检任务: + - `task_id=481` + - `task_id=483` + - `task_id=485` +- 由于 worker 未实时消费,手动通过正式 `executeQueuedRouterTask` 执行 3 个 pending 质检任务。 +- 质检结果: + - `clip_id=55`:`passed / 94` + - `clip_id=56`:`passed / 94` + - `clip_id=57`:`passed / 94` + +关键 Prompt 验收: + +- 第 1 镜真实请求已包含: + - `凌空翻身` + - `手掌和膝盖触地滑停` + - `碎石被冲击震开` + - `镜头极速推进到眼部特写` + - `瞳孔金光爆亮` +- 第 2 镜真实请求已包含: + - `食指中指并拢交错` + - `手腕快速翻转` + - `拇指扣成莲花印` + - `双掌向外一震` + - `紫色光球在第二次手印后出现` +- 第 3 镜真实请求已包含: + - `女仙双臂像凤凰展翅一样猛然后扫` + - `身后千臂法身随动作拔地而起` + - `每一层巨手依次结出不同仙印` + - `地裂 / 碎石失重上浮 / 粉化三层递进` + +最终成片: + +- `asset_id=490` +- 文件: + - `storage/private/rendered-videos/2026-06-12/24e4df88-0041-4547-bdfd-30249093c08e.mp4` +- 规格: + - `10.000000s` + - `1080x1920` + - `30fps` + - `H.264 + AAC` +- 音量检测: + - `mean_volume=-22.1 dB` + - `max_volume=-7.7 dB` +- 后期: + - `BGM cue_count=3` + - `SFX cue_count=15` + - `bgm_volume=0.55` + - `sfx_volume=0.95` +- 裁切: + - Hailuo 原片均约 `5.875s` + - 合成时裁到 `3s / 3s / 4s` + - `trim_strategy=head` +- 抽帧接触图: + - `/tmp/faxiang-tiandi/final-asset-490/contact.jpg` + +人工抽帧观感: + +- 对比上一版,进步明显: + - 第 1 镜已经能看到落地、撑地、尘土和身体动作。 + - 第 2 镜有清晰手部近景和结印动作构图。 + - 第 3 镜法身规模、爆光和压迫感增强。 +- 仍未达到顶级抖音爆款的原因: + - 手指结印虽然有构图,但是否精确到“漂亮手法”还要看完整视频动态。 + - Hailuo 对复杂手部动作和千臂细节仍可能有 AI 变形风险。 + - 目前还缺商业级真实音乐库 / 打击音效库,系统生成音频已经能铺情绪,但不是最终商用音效品质。 + +运行 / 验证: + +- `git status --short`:失败,当前目录不是 Git 仓库。 +- `ffprobe asset_id=490`:通过。 +- `ffmpeg volumedetect asset_id=490`:通过。 +- 抽帧接触图人工检查:通过。 +- 目标测试在上一阶段已通过: + - `npm run test --workspace backend -- live-action.service.spec.ts prompt-builder.service.spec.ts` + - `32 passed` + - `typecheck / lint / build` 均通过。 + +当前结论: + +- 本轮验证 Motion Director Prompt 已真实进入 Hailuo 请求。 +- 法相天地样片从“静态概念图拼接”提升到“有动作层级的爆点片段”。 +- 现在最值得人工打开 `asset_id=490` 看完整动态: + - 如果第 2 镜手势动态仍不够漂亮,下一步不要继续调普通 prompt,而应做“手部结印关键帧 / 手势参考图 V1”。 + - 如果第 3 镜法身动作仍不够炸,下一步考虑只给第 3 镜开候选 2 条,或等 Kling / Vidu / Wan 真实账号接入后做横向 Provider 对比。 + +## 2026-06-12 法相天地 10 秒一镜到底 / Hailuo 真实生成 + +完成时间: + +2026-06-12 21:56 Asia/Shanghai + +背景: + +- 用户指出 3 段式法相天地仍然割裂: + - 切镜太多,像图片拼接。 + - 翻滚落地、结印手法、法身爆发不够像抖音热门作品。 + - Hailuo 支持 10s,不应继续把 6s 片段裁成 3s/3s/4s。 +- 本轮目标: + - 把“浴血落地 -> 抬头觉醒 -> 双手结印 -> 紫色光球 -> 展臂 -> 千臂法身”压成 10 秒一镜到底。 + - 验证真实 Hailuo API 是否按 10s 返回。 + - 检查 Prompt Engine 是否把完整动作节奏写进请求。 + +代码改动: + +- `backend/src/live-action/prompt-builder.service.ts` + - Hailuo 9 秒以上镜头 prompt 上限从 `1800` 提升到 `2400`。 + - 新增 10 秒一镜到底仙侠爆点 Motion Director: + - `0.0-2.0s` 受伤落地 / 撑地 / 碎石冲击 + - `2.0-3.0s` 抬头 / 眼部金光 / 快速推进 + - `3.0-5.0s` 双手胸前结印 / 莲花印动作 + - `5.0-6.5s` 紫色光球聚能 + - `6.5-8.0s` 凤凰展臂 / 千臂法身拔地 + - `8.0-10.0s` 法身完全展开 / 爆光定格 +- `backend/src/live-action/prompt-builder.service.spec.ts` + - 新增 Hailuo 10 秒一镜到底 prompt 测试。 + +测试数据: + +- 项目: + - `project_id=58` +- 新建测试集: + - `episode_id=45` + - `episode_no=2` + - 标题:`法相天地 10秒一镜到底测试` +- 新建测试镜头: + - `shot_id=256` + - `duration=10` + - `scene_type=xianxia_transformation` + - `route_tier=premium` + - `importance/emotion/action = 10/10/10` + - 关键帧:`asset_id=467` + +真实 Provider 执行: + +- Provider: + - `minimax_hailuo_23_fast` + - `confirm_real_video=true` + - `candidate_count=1` + - `max_cost_per_clip=0.5` +- 真实片段: + - `clip_id=58` + - `output_asset_id=491` + - `provider_log.id=535` + - `request_input.duration=10` + - `response.duration=10` + - `cost_actual=0.317 USD` +- 结论: + - Hailuo 本次确实返回 10 秒视频。 + - 不再发生 6 秒片段被裁成 3 秒/4 秒的问题。 + +质检与合成: + +- 队列质检任务: + - `task_id=490` + - `live_action_video_clip_quality_check` +- 手动执行队列质检: + - `status=success` + - `quality_status=passed` + - `quality_score=94` + - `issues=[]` +- 最终成片: + - `asset_id=494` + - 文件: + - `storage/private/rendered-videos/2026-06-12/ca7b56a7-1995-4197-9dc5-ca4e45dad14a.mp4` + - 规格: + - `10.000000s` + - `1080x1920` + - `30fps` + - `H.264 + AAC` + - 裁切: + - `trimmed=false` + - `source_duration=10.125` + - `final_duration=10.134` + - `target_duration=10` + - 后期: + - `BGM cue_count=1` + - `SFX cue_count=6` + - `bgm_volume=0.55` + - `sfx_volume=0.95` + - 音量检测: + - `mean_volume=-27.2 dB` + - `max_volume=-13.9 dB` + - 抽帧接触图: + - `/tmp/faxiang-tiandi/final-asset-494/contact.jpg` + +人工抽帧观感: + +- 明显改善: + - 人物、服装、废墟空间在 10 秒内基本连续。 + - 镜头从受伤跪地、眼部发光、手部聚能、法身爆发有完整递进。 + - 千臂法身最后 2 秒的画面冲击比三段式更完整。 +- 仍未达到抖音头部爆款: + - “凌空翻滚落地”被 Hailuo 弱化成跪地/滑落/撑地,缺少真正空中翻身动作。 + - 结印有近景和手部动作,但还不像专业武术/舞蹈手诀。 + - BGM/SFX 已进入合成,但本轮音量偏低,发布级还需要更强音乐和打击音量。 + - 这说明单纯调文字 prompt 有上限;下一步若继续冲击热门质感,应加“动作参考 / 姿势关键帧 / Provider 横向对比”。 + +运行 / 验证: + +- `git status --short` + - 失败:当前目录不是 Git 仓库。 +- `npm run test --workspace backend -- live-action.service.spec.ts prompt-builder.service.spec.ts` + - 通过:`33 passed` +- `npm run typecheck --workspace backend` + - 通过 +- `npm run lint --workspace backend` + - 通过 +- `npm run build --workspace backend` + - 通过 + +当前结论: + +- 系统层面: + - 10 秒单镜头 Hailuo 流程已跑通。 + - Prompt Engine 可以按 10 秒动作节奏发送真实请求。 + - 真实 Provider、质检任务、FFmpeg 合成、音效轨、审计记录均可闭环。 +- 质量层面: + - 10 秒一镜到底方向比 3 段裁切更适合法相天地这类爆点镜头。 + - 但 Hailuo 对“凌空翻滚 + 精准结印 + 巨型法身复杂动作”的可控性仍不足。 + - 后续不建议继续无脑烧 Hailuo 多次重跑;更优先做动作/姿势参考输入和 Kling/Vidu/Wan 横向 Provider 小样。 + +## 2026-06-12 后台 AI 平台 10 秒成本展示 + +完成时间: + +2026-06-12 22:15 Asia/Shanghai + +背景: + +- 用户希望后台 AI 平台接入列表能直接展示各平台成本,按视频生产常用口径换算为“每 10 秒多少钱”。 +- 当前系统已有 `cost_rule_json`,但运营需要看 JSON 或只看到单次/当日阈值,不方便比较 Hailuo / Seedance / Kling / Wan / Vidu。 + +完成内容: + +- 后端 Provider 安全返回结构新增 `cost_summary`: + - `estimated_cost_10s_label` + - `estimated_cost_10s / min / max` + - `currency` + - `price_per_second` + - `max_cost_per_call` + - `daily_cost_limit` + - `pricing_basis` + - `needs_manual_pricing` + - `note` +- `price_per_second=0` 不再被当作免费: + - 视为未回填正式账单价。 + - 可使用展示估算价,但会标记 `needs_manual_pricing=true`。 +- 后台页面: + - “AI 平台入口”费用提醒列新增主成本展示。 + - “AI 接入列表”新增“10秒成本”列。 + - 成本单元格使用换行样式,避免 PC/H5 表格中文字和按钮挤压重叠。 + +当前抽样成本展示: + +- `minimax_hailuo_23_fast` + - `10秒约 $0.3170` +- `minimax_hailuo_23` + - `10秒约 $0.4670` +- `alibaba_wan26_i2v_flash` + - `10秒约 $0.2150` +- `vidu_q3_turbo_reference` + - `10秒约 $0.5000` +- `kling-image-to-video` + - `10秒约 $0.7500` + - 显示估算,需开通后用实际账单回填。 +- `jimeng_seedance` + - `10秒约 ¥1.72-¥3.46` + - 显示估算,需开通后用实际账单回填。 + +改动文件: + +- `backend/src/providers/provider.types.ts` +- `backend/src/providers/providers.service.spec.ts` +- `admin/src/App.vue` +- `admin/src/styles.css` +- `CODEX_PROGRESS.md` + +运行 / 验证: + +- `git status --short` + - 失败:当前目录不是 Git 仓库。 +- `npm run test --workspace backend -- providers.service.spec.ts` + - 通过:`35 passed` +- `npm run typecheck --workspace backend` + - 通过 +- `npm run lint --workspace backend` + - 通过 +- `npm run build --workspace backend` + - 通过 +- `npm run build --workspace admin` + - 通过 +- 数据库抽样脚本确认 Provider 成本摘要可正常生成。 + +当前结论: + +- 后台已经可以按“每 10 秒多少钱”查看视频 Provider 成本。 +- 后续开通 Kling / Seedance / Runway 等账号后,只需要把 `cost_rule_json.price_per_second` 或 10 秒估算字段回填,后台会自动更新展示。 +- Router 后续也可以直接复用 `cost_summary` 做预算路由、降级和成本审计展示。 + +## 2026-06-12 AI 视频测试踩坑记录 V1 + +完成时间: + +2026-06-12 22:25 Asia/Shanghai + +背景: + +- 用户确认当前仍是测试阶段,核心不是马上上线,而是把真人/漫剧视频流水线磨合好。 +- 前面已遇到多类问题: + - Hailuo 对复杂修仙动作、凌空翻滚、精准结印控制不足。 + - 3 秒碎切导致镜头像图片拼接,缺少电影感。 + - 无声音、无字幕、无 BGM 的样片不可发布。 + - 正脸台词无 lip-sync 时容易嘴型错位。 + - mock-qc 分数不能代表真实人工观感。 + - 成本必须按 10 秒、Provider、候选数进行前置估算。 +- 需要形成长期记录,后续接入 Kling / Seedance / Wan / Vidu / Runway / Veo 时避免重复踩坑。 + +完成内容: + +- 新增根目录测试手册: + - `AI_VIDEO_TEST_LESSONS.md` +- 内容覆盖: + - 当前阶段定位。 + - Hailuo 已验证适用场景。 + - Hailuo 不适合硬扛的镜头。 + - 分镜时长经验。 + - 10 秒一镜到底结论。 + - Prompt Engine 经验。 + - 角色锚点/定妆图经验。 + - BGM/SFX/字幕经验。 + - lip-sync 策略。 + - 成本控制策略。 + - Provider Router 经验。 + - 新 Provider 准入测试标准。 + - 人工验收标准。 + - 不要重复踩的坑。 + - 下一步建议。 + +关键沉淀: + +- Hailuo 做低成本都市量产,不再默认硬扛法相/打斗/复杂手诀。 +- 复杂动作失败 1-2 次后,优先切 Provider 或补动作参考/姿势关键帧,不继续无脑烧钱。 +- 复杂动作/爆点镜头优先 8-10 秒一镜到底,普通都市镜头可 5-6 秒。 +- 没有声音、字幕、BGM 的视频判定为失败。 +- 无 lip-sync Provider 时,正脸台词自动改中景/旁白/字幕/轻口型。 +- 候选片段默认 1 条,只有封面级/爆点/人工验收才允许 2 条。 +- 新 Provider 必须用同一项目、同一角色锚点、同一关键帧、同一镜头横向测试。 + +改动文件: + +- `AI_VIDEO_TEST_LESSONS.md` +- `CODEX_PROGRESS.md` + +运行 / 验证: + +- `git status --short` + - 失败:当前目录不是 Git 仓库。 +- 本次仅新增/更新 Markdown 测试记录,没有改动业务代码,未重新运行 lint/typecheck/test。 + +当前结论: + +- 测试阶段经验已形成可复用记录。 +- 后续每接一个新 AI 视频平台,都应该按 `AI_VIDEO_TEST_LESSONS.md` 的 Provider 准入测试标准记录质量、成本、失败率和人工观感。 + +## 2026-06-13 都市退婚神豪短剧 5x10 秒导演版测试用例 + +完成时间: + +2026-06-13 14:35 Asia/Shanghai + +背景: + +- 用户提供《被未婚妻退婚后,我成了首富》第一季和第 1 集设定。 +- 用户明确要求: + - 要连贯性,不要一个个突破拼接。 + - 看起来像真人拍摄。 + - 有专业运镜、专业剪辑、专业配音、高潮 BGM。 + - Hailuo 只有 6s 或 10s,因此按 10s 每镜设计。 + +完成内容: + +- 新增测试用例: + - `storage/private/live-action-testcases/urban-heir-engagement-director-episode-001.json` +- 结构: + - 5 个导演长镜头。 + - 每镜固定 `10s`。 + - 总时长 `50s`。 + - 场景只保留两个场景组: + - 酒店订婚宴会厅。 + - 酒店外雨夜街道。 +- 5 个镜头: + - 镜头 1:订婚宴开场到林雨薇挽周浩入场。 + - 镜头 2:林雨薇当众退婚。 + - 镜头 3:周浩羞辱与顾辰沉默。 + - 镜头 4:顾辰雨夜离场。 + - 镜头 5:劳斯莱斯与顾氏继承权反转。 + +系统优化: + +- 后端真人导演计划上限从 `8s` 调整为 `10s`: + - `LIVE_ACTION_DIRECTOR_MAX_SHOT_SECONDS = 10` +- 导演时长分配规则支持 5 个 10 秒镜头完整保留。 +- 新增都市高潮 BGM 类型: + - `urban_climax` + - 用于退婚、羞辱、打脸、首富、继承权、黑金卡、劳斯莱斯等短剧高潮段落。 +- 修复镜头类型误判: + - 原规则中只要出现“手”就会误判为 `insert`,导致“手里的戒指盒”被压成 5 秒。 + - 已改为只有“手部/手指/手掌/手腕/手势”等明确手部特写词才算 `insert`。 + - 台词镜头优先判定为 `dialogue`,避免“避免嘴部特写”里的“特写”把镜头误判为 `insert`。 + +导入结果: + +- 已导入后台项目: + - `project_id=61` + - `episode_id=48` +- 分镜: + - `shot_id=262` / 镜头 1 / `10s` + - `shot_id=263` / 镜头 2 / `10s` + - `shot_id=264` / 镜头 3 / `10s` + - `shot_id=265` / 镜头 4 / `10s` + - `shot_id=266` / 镜头 5 / `10s` +- 已执行 `prepareLiveActionShots`: + - 5 个镜头均为 `video_status=prepared` + - 5 个镜头均保留 `duration=10` + - 5 个镜头 Prompt 均包含 `10秒` + +Hailuo Fast 成本预估: + +- Provider: + - `minimax_hailuo_23_fast` +- 总镜头数: + - `5` +- 总时长: + - `50s` +- 每镜: + - `10s` +- 每镜预估: + - `$0.317` +- 总预估: + - `$1.585` + +踩坑记录同步: + +- 已更新 `AI_VIDEO_TEST_LESSONS.md` + - 新增 Hailuo 时长设计规则。 + - 明确真人短剧主流程优先按 10 秒长镜头设计。 + - 明确 prepare 阶段不得把 10 秒镜头压缩成 5 秒或 8 秒。 + - 明确负面约束和“手里的道具”不能误触 insert 特写。 + +改动文件: + +- `backend/src/live-action/live-action.service.ts` +- `backend/src/live-action/live-action.service.spec.ts` +- `storage/private/live-action-testcases/urban-heir-engagement-director-episode-001.json` +- `AI_VIDEO_TEST_LESSONS.md` +- `CODEX_PROGRESS.md` + +运行 / 验证: + +- `git status --short` + - 失败:当前目录不是 Git 仓库。 +- JSON 校验: + - 通过,5 镜共 `50s`。 +- `npm run test --workspace backend -- live-action.service.spec.ts` + - 通过:`29 passed` +- `npm run typecheck --workspace backend` + - 通过 +- `npm run lint --workspace backend` + - 通过 +- `npm run build --workspace backend` + - 通过 +- 导入测试用例: + - 通过 +- prepare 真人镜头: + - 通过,5 个镜头全部保持 10 秒。 + +当前结论: + +- 这集已经按 Hailuo 真实 10 秒规格重排成导演连续版。 +- 当前尚未调用真实 Hailuo 生成视频,避免直接扣费。 +- 下一步如果用户确认,可以先跑 Mock 全流程,再选择是否用 Hailuo Fast 跑 5 条真实 10 秒小样。 + +## 2026-06-13 都市退婚神豪短剧 Mock 全链路验收 + +完成时间: + +2026-06-13 14:40 Asia/Shanghai + +背景: + +- 用户要求继续推进上一步 5x10 秒导演版测试。 +- 本轮目标不是评价真实 Hailuo 画面质量,而是验证: + - 5 个 10 秒长镜头是否能完整进入生成链路。 + - 关键帧、Mock 视频片段、TTS、字幕、BGM、SFX、最终合成是否完整落库。 + - 后台预览资产是否存在,避免再次出现文件不存在类问题。 + +执行对象: + +- `project_id=61` +- `episode_id=48` +- `shot_id=262-266` + +执行流程: + +1. `preflightVideoClips` + - 初始结果:未就绪。 + - 阻断原因:5 个镜头均缺关键帧。 + - 统计: + - `shot_count=5` + - `prepared_shot_count=5` + - `keyframe_count=0` + - `provider_clip_count=5` + - `total_seconds=50` +2. `generateKeyframes` + - 生成关键帧资产:5 个。 +3. 再次 `preflightVideoClips` + - 结果:就绪。 + - 阻断项:0。 + - 统计: + - `keyframe_count=5` + - `provider_clip_count=5` + - `total_seconds=50` +4. `generateVideoClips` + - Provider:`mock-video` + - 生成片段: + - clip `59` / shot `262` / asset `500` / `10s` + - clip `60` / shot `263` / asset `501` / `10s` + - clip `61` / shot `264` / asset `502` / `10s` + - clip `62` / shot `265` / asset `503` / `10s` + - clip `63` / shot `266` / asset `504` / `10s` + - 真实视频费用:`0` +5. `renderLiveActionEpisode` + - `include_audio=true` + - `include_subtitle=true` + - `include_bgm=true` + - `include_sfx=true` + - `include_lip_sync=false` + +输出资产: + +- 对白/TTS 混音: + - asset `505` + - `local://generated-audio/2026-06-13/d6b4f222-a5f5-4619-9851-762c2fe03f47.wav` + - `duration=49.55s` + - `status=active` +- 字幕: + - asset `506` + - `local://generated-subtitles/2026-06-13/7acf1a1b-81bc-4daf-bd8d-2dbc31484843.srt` + - `duration=50s` + - `status=active` +- BGM: + - asset `507` + - `local://generated-audio/2026-06-13/82bc90e6-0959-4ec2-8c0b-bed3bfd46a68.wav` + - `duration=50s` + - `status=active` +- SFX: + - asset `508` + - `local://generated-audio/2026-06-13/37b8e5d6-9729-4cc3-8b1c-b1b3b6f22e2c.wav` + - `duration=50s` + - `status=active` +- 最终成片: + - asset `509` + - `local://rendered-videos/2026-06-13/c7163cfe-0be3-4014-baad-1fe81eacb586.mp4` + - `duration=50s` + - `size=2130857` + - `status=mock` + +后期策略记录: + +- BGM cue: + - 5 个镜头均生成 BGM cue。 + - 镜头 1、2、3、5 使用 `urban_climax`。 + - 镜头 4 使用 `suspense_tension`。 + - `bgm_volume=0.14` +- SFX cue: + - 共 `22` 个。 + - 包含 `rain`、`footstep`、`door`、`heartbeat`、`sting`、`impact`。 + - `sfx_volume=0.45` +- 字幕 cue: + - 共 `5` 个。 +- lip-sync: + - 本轮关闭真实 lip-sync。 + - 策略记录中 `required_count=2`,但 `lip_sync_clip_count=0`。 + - 代表系统能识别关键台词镜头,但当前按“中景轻口型 + TTS + 字幕”降级。 + +FFprobe 验证: + +- 文件: + - `/www/wwwroot/ai/storage/private/rendered-videos/2026-06-13/c7163cfe-0be3-4014-baad-1fe81eacb586.mp4` +- 视频流: + - `h264` + - `1080x1920` + - `50.000000s` +- 音频流: + - `aac` + - `50.000000s` +- 音量: + - `mean_volume=-18.8 dB` + - `max_volume=-1.2 dB` +- 抽帧联系表: + - `/www/wwwroot/ai/storage/private/live-action-acceptance/2026-06-13/urban-heir-mock-asset-509-contact.jpg` + +重要发现: + +- 本轮没有调用真实 Hailuo,真实视频费用为 `0`。 +- 但音频链路使用了已配置并启用的 `minimax-tts`,因此严格意义上不是“全零成本 Mock”。 +- 这对发布级验收是好事,因为可以顺便验证真实 TTS、字幕、BGM、SFX 混音。 +- 如果后续只想做零成本流程测试,应显式指定 `voice_provider_code=mock-voice` 或关闭 `include_audio`。 + +当前结论: + +- 5x10 秒导演版 Mock 全链路已跑通。 +- 关键帧、视频片段、音频、字幕、BGM、SFX、最终成片均已落库。 +- 成片存在可预览文件,不存在 `ENOENT` 类文件缺失问题。 +- Mock 画面是占位色块,只能证明流程,不能代表真实画面质量。 +- 下一步适合用这 5 个镜头跑真实 Hailuo Fast,验收真人画面连贯性、角色一致性和真实镜头观感。 + +## 2026-06-13 都市退婚神豪短剧 Hailuo 真实 5x10 秒小样 + +完成时间: + +2026-06-13 17:30 Asia/Shanghai + +背景: + +- 用户确认下一步跑真实 Hailuo 5 条 10 秒镜头。 +- 目标: + - 验证真实 Hailuo Fast 是否能按 10 秒镜头跑通。 + - 验证真实片段能否进入后期合成。 + - 初步判断真人画面、镜头连贯、角色一致性。 + +真实 Provider 前置检查: + +- Provider: + - `minimax_hailuo_23_fast` + - 模式:`real` + - 状态:启用 +- 初次 preflight 结果: + - 未通过。 + - 原因:5 个关键帧均为 Mock SVG。 + - 阻断码:`LIVE_ACTION_KEYFRAME_RASTER_REQUIRED` + - 结论:真实视频 Provider 必须使用 PNG/JPG/WebP,不能把 Mock SVG 发给 Hailuo。 + +临时真人关键帧处理: + +- 使用 `imagegen` 生成 5 张真人摄影风关键帧。 +- 统一转为 `1080x1920 PNG`。 +- 本地目录: + - `storage/private/live-action-acceptance/2026-06-13/urban-heir-keyframes-normalized/` +- 联系表: + - `storage/private/live-action-acceptance/2026-06-13/urban-heir-keyframes/contact.jpg` +- 绑定资产: + - shot `262` -> keyframe asset `510` + - shot `263` -> keyframe asset `511` + - shot `264` -> keyframe asset `512` + - shot `265` -> keyframe asset `513` + - shot `266` -> keyframe asset `514` +- 再次 preflight: + - 通过。 + - `raster_keyframe_count=5` + - `provider_clip_count=5` + - `total_seconds=50` + - `estimated_cost=$1.585` + +Hailuo 真实生成结果: + +- 开始: + - `2026-06-13T09:18:15.268Z` +- 完成: + - `2026-06-13T09:27:18.180Z` +- 总耗时: + - 约 9 分钟 +- 生成方式: + - 5 条顺序生成。 + - 每条 `10s`。 + - `candidate_count=1`,未生成候选,避免成本翻倍。 +- 片段: + - clip `64` / shot `262` / asset `515` / cost `$0.317` + - clip `65` / shot `263` / asset `516` / cost `$0.317` + - clip `66` / shot `264` / asset `517` / cost `$0.317` + - clip `67` / shot `265` / asset `518` / cost `$0.317` + - clip `68` / shot `266` / asset `519` / cost `$0.317` +- 总成本记录: + - `$1.585` + +真实成片合成结果: + +- 成片 asset: + - `524` +- 文件: + - `local://rendered-videos/2026-06-13/d5a3f566-48fe-4f67-a5d7-4e6929e9eba5.mp4` + - `/www/wwwroot/ai/storage/private/rendered-videos/2026-06-13/d5a3f566-48fe-4f67-a5d7-4e6929e9eba5.mp4` +- 状态: + - `active` +- 时长: + - `50s` +- 大小: + - `26412374` +- 后期资产: + - TTS/audio asset `520` + - subtitle asset `521` + - BGM asset `522` + - SFX asset `523` +- render task: + - `518` + - status `success` + +FFprobe 验证: + +- 视频: + - `h264` + - `1080x1920` + - `50.000000s` +- 音频: + - `aac` + - `50.000000s` +- 音量: + - `mean_volume=-18.9 dB` + - `max_volume=-2.3 dB` + +抽帧验收文件: + +- 5 秒间隔联系表: + - `/www/wwwroot/ai/storage/private/live-action-acceptance/2026-06-13/urban-heir-hailuo-asset-524/contact.jpg` +- 2 秒间隔联系表: + - `/www/wwwroot/ai/storage/private/live-action-acceptance/2026-06-13/urban-heir-hailuo-asset-524/contact-2s.jpg` + +初步人工观感: + +- 明显优于 Mock 和之前碎切测试。 +- Hailuo 在都市酒店、雨夜、车灯、人物中景上表现可用。 +- 5x10 秒长镜头比 3-6 秒碎切更像短剧段落。 +- 字幕、TTS、BGM、SFX 均进入成片。 +- 仍存在生产级问题: + - 男主在室内和雨夜之间脸有漂移。 + - 部分镜头更像关键帧慢推,表演调度还不够“真实拍摄”。 + - 台词镜头仍依赖 TTS + 字幕,口型不做强同步。 + - 第 4/5 镜头画面质感较好,但角色锚点还不够稳。 + +当前结论: + +- Hailuo Fast 真实 5x10 秒都市短剧链路已跑通。 +- 技术链路 PASS: + - raster keyframe -> Hailuo -> 视频片段落库 -> FFmpeg 合成 -> 字幕/TTS/BGM/SFX -> active 成片。 +- 质量链路进入下一轮: + - 需要“角色锚点图 / 同脸参考 / 定妆图 V1”。 + - 需要把关键帧生成也纳入系统 Provider,而不是临时手动生成。 + - 需要后台把真实 Provider 耗时、成本、关键帧类型、人工观感评分记录到审计页。 + +## 2026-06-13 角色锚点图 / 定妆图 V1 与 1/3/5 镜重跑 + +完成时间: + +2026-06-13 18:50 Asia/Shanghai + +背景: + +- 用户确认下一步做“角色锚点图 / 定妆图 V1”。 +- 目标: + - 固定顾辰、林雨薇、周浩、老管家四个角色的脸、服装、年龄气质。 + - 写入系统 `actor_profiles`。 + - 用锚点图约束重做第 1、3、5 镜关键帧。 + - 只重跑第 1、3、5 镜 Hailuo,避免全片重复烧成本。 + +锚点图生成: + +- 使用 `imagegen` 生成 4 张角色半身定妆图。 +- 角色: + - 顾辰 + - 林雨薇 + - 周浩 + - 老管家 +- 本地目录: + - `storage/private/live-action-acceptance/2026-06-13/urban-heir-actor-anchors/` +- 联系表: + - `storage/private/live-action-acceptance/2026-06-13/urban-heir-actor-anchors/contact.jpg` + +锚点资产落库: + +- 顾辰: + - actor_profile `27` + - character `99` + - anchor asset `525` +- 林雨薇: + - actor_profile `28` + - character `100` + - anchor asset `526` +- 周浩: + - actor_profile `29` + - character `101` + - anchor asset `527` +- 老管家: + - actor_profile `31` + - character `103` + - anchor asset `528` +- 已写入: + - `actor_profiles.anchor_asset_id` + - `actor_profiles.reference_asset_ids` + - `characters.anchor_asset_id` +- 锚点角色状态: + - `status=locked` + +锚点关键帧重做: + +- 重做镜头: + - shot `262` / 镜头 1 + - shot `264` / 镜头 3 + - shot `266` / 镜头 5 +- 本地目录: + - `storage/private/live-action-acceptance/2026-06-13/urban-heir-anchor-keyframes/` +- 归一化目录: + - `storage/private/live-action-acceptance/2026-06-13/urban-heir-anchor-keyframes-normalized/` +- 联系表: + - `storage/private/live-action-acceptance/2026-06-13/urban-heir-anchor-keyframes/contact.jpg` +- 绑定资产: + - shot `262` -> keyframe asset `529` + - shot `264` -> keyframe asset `530` + - shot `266` -> keyframe asset `531` +- preflight: + - 3 个镜头均通过。 + - 每镜 `10s` + - 每镜预估 `$0.317` + - 本轮预计 `$0.951` + +Hailuo 真实重跑: + +- 重跑镜头: + - 第 1、3、5 镜。 +- Provider: + - `minimax_hailuo_23_fast` +- 生成结果: + - clip `69` / shot `262` / input asset `529` / output asset `532` / cost `$0.317` + - clip `70` / shot `264` / input asset `530` / output asset `533` / cost `$0.317` + - clip `71` / shot `266` / input asset `531` / output asset `534` / cost `$0.317` +- 本轮真实视频成本: + - `$0.951` +- 生成耗时: + - `2026-06-13T10:42:14.256Z` 到 `2026-06-13T10:47:30.345Z` + - 约 5 分 16 秒 + +锚点增强版成片: + +- 使用: + - 新第 1 镜 asset `532` + - 旧第 2 镜 asset `516` + - 新第 3 镜 asset `533` + - 旧第 4 镜 asset `518` + - 新第 5 镜 asset `534` +- 成片 asset: + - `539` +- 文件: + - `local://rendered-videos/2026-06-13/b3e3faa4-f320-42b6-9e08-f4c80935d427.mp4` + - `/www/wwwroot/ai/storage/private/rendered-videos/2026-06-13/b3e3faa4-f320-42b6-9e08-f4c80935d427.mp4` +- 状态: + - `active` +- 时长: + - `50s` +- render task: + - `526` + - status `success` + +FFprobe 验证: + +- 视频: + - `h264` + - `1080x1920` + - `50.000000s` +- 音频: + - `aac` + - `50.000000s` +- 音量: + - `mean_volume=-19.0 dB` + - `max_volume=-0.5 dB` + +抽帧验收: + +- 5 秒间隔: + - `/www/wwwroot/ai/storage/private/live-action-acceptance/2026-06-13/urban-heir-anchor-rerun-asset-539/contact.jpg` +- 2 秒间隔: + - `/www/wwwroot/ai/storage/private/live-action-acceptance/2026-06-13/urban-heir-anchor-rerun-asset-539/contact-2s.jpg` + +人工观感: + +- 有改善: + - 第 1 镜空间关系更稳定,顾辰和入场二人组关系更明确。 + - 第 3 镜羞辱动作更清楚,周浩拍肩、顾辰低头拿戒指盒更像剧情动作。 + - 第 5 镜老管家、豪车、递文件动作更明确,反转感比上一版强。 +- 仍有问题: + - 第 2、4 镜没有重跑,因此全片同脸一致性还没有完全统一。 + - Hailuo 运动过程中仍会轻微改脸,尤其侧脸和低头动作。 + - 目前只把锚点图用于关键帧生成,没有真正把多角色参考图直接传给 Hailuo,因为当前 Hailuo Fast 配置是单首帧图生视频。 + +当前结论: + +- 角色锚点图 / 定妆图 V1 方向有效。 +- 比临时关键帧版更适合进入生产流程。 +- 但要达到可上架,还需要: + - 关键帧生成必须系统化,而不是临时 `imagegen` 手工生成。 + - 需要把第 2、4 镜也用锚点重做一次,得到全片统一版本。 + - 后续 Provider 若支持 character reference / 多参考图,应优先用于高价值镜头。 + +## 2026-06-13 真人短剧多角色对白 / 多声线 TTS V1 + +目标: + +- 修复真人短剧成片里“所有人像同一个解说在读”的问题。 +- 同一镜头内多个人说话时,按 `角色名:台词` 自动拆成多个 TTS 段。 +- 每个角色优先使用 Character 表里的 `voice_provider_code` / `voice_id` / `voice_style`。 + +本次代码改动: + +- `backend/src/live-action/live-action.service.ts` + - `prepareLiveActionPostProductionAssets` 增加项目角色加载。 + - `buildLiveActionAudioSegments` 支持一镜多说话人拆分。 + - 新增角色声音映射: + - `buildLiveActionCharacterVoiceMap` + - `resolveLiveActionSegmentVoice` + - `defaultLiveActionVoiceId` + - TTS Provider 输入增加: + - `voice_id` + - `speaker` + - `instructions` + - 音频任务记录增加: + - `voice_id` + - `voice_style` + - `character_id` +- `backend/src/live-action/live-action.service.spec.ts` + - 新增单测:同一镜头中 `林雨薇 / 顾辰 / 周浩` 三人对白拆成 3 个独立 TTS 段。 + +当前项目 61 声线配置: + +- 顾辰 / character `99` + - Provider:`minimax-tts` + - voice_id:`Chinese (Mandarin)_Sincere_Adult` +- 林雨薇 / character `100` + - Provider:`minimax-tts` + - voice_id:`Arrogant_Miss` +- 周浩 / character `101` + - Provider:`minimax-tts` + - voice_id:`Chinese (Mandarin)_Reliable_Executive` +- 老管家 / character `103` + - Provider:`minimax-tts` + - voice_id:`Chinese (Mandarin)_Gentle_Senior` + +重合成结果: + +- 新成片 asset: + - `544` +- 文件: + - `local://rendered-videos/2026-06-13/4a42aeea-5e5e-489b-a724-c098f06a2042.mp4` + - `/www/wwwroot/ai/storage/private/rendered-videos/2026-06-13/4a42aeea-5e5e-489b-a724-c098f06a2042.mp4` +- 状态: + - `active` +- 时长: + - `50s` +- render task: + - `531` +- audio task: + - `527` + +音频分段对比: + +- 旧版 audio task `522` + - `segment_count=5` + - 每个镜头一段,多个角色被合成同一个 TTS 文本。 +- 新版 audio task `527` + - `segment_count=10` + - 每个人独立发声: + - 主持人:`Chinese (Mandarin)_News_Anchor` + - 顾辰:`Chinese (Mandarin)_Sincere_Adult` + - 林雨薇:`Arrogant_Miss` + - 周浩:`Chinese (Mandarin)_Reliable_Executive` + - 旁白:`Chinese (Mandarin)_News_Anchor` + - 老管家:`Chinese (Mandarin)_Gentle_Senior` + +FFprobe 验证: + +- 视频: + - `h264` + - `1080x1920` + - `30fps` + - `50.000000s` +- 音频: + - `aac` + - `stereo` + - `50.000000s` +- 音量: + - `mean_volume=-19.3 dB` + - `max_volume=-0.3 dB` + +验证命令: + +- `npm run test --workspace backend -- live-action.service.spec.ts` + - 30 passed +- `npm run typecheck --workspace backend` + - passed +- `npm run lint --workspace backend` + - passed +- `npm run build --workspace backend` + - passed + +当前结论: + +- “多人对白被一个声音读完”的问题已修复。 +- 后台/API 已重启到新版代码。 +- 这一步只解决多角色声音,不等于解决严格口型同步。 +- 如果要发布级正脸对白,仍需要后续接 lip-sync Provider 或继续采用中景轻口型策略。 + +## 2026-06-13 老管家声线纠偏 + +问题: + +- 多角色对白版 asset `544` 比上一版明显改善。 +- 但第 5 镜老管家听感偏女声。 +- 原因: + - 老管家绑定了 `Chinese (Mandarin)_Gentle_Senior`。 + - 实际听感不适合“中老年男性管家”。 + +修复: + +- `backend/src/live-action/live-action.service.ts` + - 老管家 / 王伯 / elder 类默认 MiniMax 声线从: + - `Chinese (Mandarin)_Gentle_Senior` + - 改为: + - `Chinese (Mandarin)_Gentleman` +- `backend/src/live-action/live-action.service.spec.ts` + - 新增单测: + - 管家对白默认使用 `Chinese (Mandarin)_Gentleman`。 +- 数据库: + - project `61` + - character `103` + - 老管家 voice_id 已改为 `Chinese (Mandarin)_Gentleman` + - voice_style 已改为: + - `中老年男性,沉稳、正式、低沉,像忠诚管家汇报重要消息。` + +重合成结果: + +- 新成片 asset: + - `549` +- 文件: + - `local://rendered-videos/2026-06-13/70c30ded-2a9e-4b19-9ed0-5873dc2c8feb.mp4` + - `/www/wwwroot/ai/storage/private/rendered-videos/2026-06-13/70c30ded-2a9e-4b19-9ed0-5873dc2c8feb.mp4` +- 音频 task: + - `532` +- 音频 asset: + - `545` +- segment_count: + - `10` +- 老管家分段: + - segment `9` + - voice_id:`Chinese (Mandarin)_Gentleman` + - segment `10` + - voice_id:`Chinese (Mandarin)_Gentleman` + +验证: + +- `npm run test --workspace backend -- live-action.service.spec.ts` + - 31 passed +- `npm run typecheck --workspace backend` + - passed +- `npm run lint --workspace backend` + - passed +- `npm run build --workspace backend` + - passed +- 后端 API 已重启,`/api/health` 正常。 + +当前结论: + +- 管家女声问题已修复为男声候选。 +- 是否最终采用 `Gentleman` 还要人工听感确认。 +- 如果仍觉得太年轻或不够稳,下一轮可横测: + - `Chinese (Mandarin)_Male_Announcer` + - `Chinese (Mandarin)_Reliable_Executive` + - `Chinese (Mandarin)_Humorous_Elder` + +## 2026-06-13 Hailuo 2.3 Fast vs 标准版第 5 镜 A/B 小样 + +目标: + +- 验证当前海螺质量问题是否主要来自 Fast 模型档位。 +- 只测试第 5 镜“劳斯莱斯与继承权反转”,避免整集烧钱。 + +A 版本: + +- clip: + - `71` +- asset: + - `534` +- Provider: + - `minimax_hailuo_23_fast` +- 模型: + - `MiniMax-Hailuo-2.3-Fast` +- 分辨率: + - `768P` +- 目标时长: + - `10s` +- 实际时长: + - `10.125s` +- 成本: + - `$0.317` +- 文件: + - `/www/wwwroot/ai/storage/private/live-action-video-clips/2026-06-13/52538a5b-5cb0-4c26-a42d-003cb7662739.mp4` + +B 版本: + +- clip: + - `72` +- asset: + - `550` +- Provider: + - `minimax_hailuo_23` +- 模型: + - `MiniMax-Hailuo-2.3` +- 分辨率: + - `1080P` +- 目标时长: + - `6s` +- 实际时长: + - `5.875s` +- 成本: + - `$0.2802` +- 生成耗时: + - 约 4 分 20 秒 +- 文件: + - `/www/wwwroot/ai/storage/private/live-action-video-clips/2026-06-13/a2b99754-4447-46a4-8c74-e150ef54b31b.mp4` + +测试保护: + +- 临时启用 `minimax_hailuo_23`。 +- 生成完成后已恢复为关闭状态,避免后台误点烧更高成本。 +- 第 5 镜 `video_clip_asset_id` 已还原为原 Fast asset `534`。 +- 原整集成片不受本次 A/B 影响。 + +对比产物: + +- 抽帧图: + - `/www/wwwroot/ai/storage/private/live-action-acceptance/2026-06-13/hailuo-standard-ab-shot5/fast-vs-standard-contact.jpg` +- 并排视频: + - `/www/wwwroot/ai/storage/private/live-action-acceptance/2026-06-13/hailuo-standard-ab-shot5/fast-vs-standard-side-by-side.mp4` + +初步观感: + +- 标准版 1080P 的清晰度、车灯、雨夜质感略优于 Fast。 +- 但两个版本动作结构仍接近: + - 基于同一首帧缓慢推进。 + - 老管家递卡和顾辰反应仍不够像真实剧组调度。 + - 没有产生明显“电影拍摄感”的质变。 +- 结论: + - Fast 不是唯一问题。 + - 更大的瓶颈是: + - 单首帧图生视频。 + - 缺少中间/结束关键帧。 + - 缺少可控动作参考。 + - 高价值反转镜头需要更强 Provider 或多关键帧流程。 + +下一步建议: + +- 不建议直接把整集切到 Hailuo 标准版。 +- 建议继续做: + - 第 5 镜三关键帧流程: + - 起始:车灯照亮顾辰和管家。 + - 中段:管家递黑金卡。 + - 结束:顾辰震惊看卡。 + - 横测 Kling / Seedance / Wan / Vidu。 + - 同一镜头只比较一个变量,建立 Provider 准入表。 + +## 2026-06-13 动作节拍链式生成 V1 + +目标: + +- 解决高价值镜头“单首帧图生视频像图片慢慢动”的问题。 +- 不默认增加全片成本,只在显式开启 `action_beat_mode=true` 时启用。 +- 用上一段视频的结尾帧作为下一段首帧,降低动作断裂和模型自由发挥。 + +本次代码改动: + +- `backend/src/live-action/live-action.dto.ts` + - `LiveActionGenerateDto` 新增: + - `action_beat_mode` + - `action_beat_count` + - `LiveActionPreflightQueryDto` 新增同名字段。 +- `backend/src/live-action/live-action.service.ts` + - 新增动作节拍子片段规划: + - `buildLiveActionProviderClipSegments` + - `liveActionActionBeatPrompts` + - `resolveLiveActionActionBeatProviderDuration` + - 生成流程支持: + - 第 1 段使用原始 `keyframe_asset_id`。 + - 第 2 段开始使用上一段视频结尾帧作为新首帧。 + - 新增尾帧抽取入库: + - `storeLiveActionSegmentEndFrameAsset` + - 同步写入 `shot_images.image_type=action_beat_end_N` + - 多段拼接后会裁回目标镜头时长: + - `trimLiveActionGeneratedClipBuffer` + - preflight 已同步显示 action beat 后的真实: + - `provider_clip_count` + - `provider_clip_durations` + - `estimated_cost` +- `backend/src/live-action/live-action.service.spec.ts` + - 新增单测: + - 开启 `action_beat_mode` 后,第 5 镜拆为 2 段。 + - 第 2 段使用 `previous_segment_end_frame`。 + +第 5 镜测试: + +- 镜头: + - shot `266` + - `劳斯莱斯与继承权反转` +- Provider: + - `minimax_hailuo_23_fast` +- 模式: + - `action_beat_mode=true` + - `action_beat_count=2` +- 分段: + - beat 1: + - 原始关键帧 asset `531` + - 6s + - 管家撑伞走近、递黑金卡和文件袋。 + - beat 2: + - 使用 beat 1 结尾帧作为首帧。 + - 6s + - 顾辰看卡、抬眼震惊。 +- 生成结果: + - clip `73` + - asset `552` + - task `538` + - 成本 `$0.3804` + - 生成耗时约 2 分 47 秒 +- 文件: + - `/www/wwwroot/ai/storage/private/live-action-video-clips/2026-06-13/4843e97d-52ba-47ea-ba4d-6cccf32eedbc.mp4` + +对比产物: + +- 三版本抽帧: + - `/www/wwwroot/ai/storage/private/live-action-acceptance/2026-06-13/action-beat-shot5/fast-standard-actionbeat-contact.jpg` +- 三版本并排视频: + - `/www/wwwroot/ai/storage/private/live-action-acceptance/2026-06-13/action-beat-shot5/fast-standard-actionbeat-side-by-side.mp4` +- action beat 后半段抽帧: + - `/www/wwwroot/ai/storage/private/live-action-acceptance/2026-06-13/action-beat-shot5/actionbeat-second-half-contact.jpg` + +系统保护: + +- 本次测试没有替换正式成片镜头。 +- shot `266` 当前 `video_clip_asset_id` 已还原为原 Fast asset `534`。 +- action beat 生成的 asset `552` 作为候选样片保留。 + +预检验证: + +- action beat preflight: + - `provider_clip_count=2` + - `provider_clip_durations=[6,6]` + - `estimated_cost=$0.3804` + - warning: + - `动作节拍模式会拆成 2 个连续子片段,并使用上一段尾帧承接下一段。` + +人工观感: + +- 有明显进步: + - 后半段能看到顾辰低头看卡、再抬眼震惊。 + - 黑金卡和手套道具更清楚。 + - 比单首帧慢推更像一个连续动作链。 +- 仍有问题: + - 第 1 段到第 2 段仍可能有轻微跳切。 + - 这是“链式尾帧”方案,不等于真正的三关键帧/首尾帧 Provider。 + - 成本从单条 Fast `$0.317` 增加到 `$0.3804`。 + +当前结论: + +- action beat 链式生成 V1 方向有效,值得保留到系统。 +- 默认不能全量开启,应只用于: + - 反转镜头 + - 封面级镜头 + - 爆点镜头 + - 高价值动作镜头 +- 下一步应该在后台给高价值镜头加开关: + - 普通模式:单条生成。 + - 动作节拍模式:2 段链式生成。 + - 真正多关键帧模式:待接支持 start/end/reference 的 Provider 后启用。 + +## 2026-06-13 第一集 action beat 版重合成 + +目标: + +- 使用第 5 镜 action beat 候选 asset `552` 重合成完整第一集。 +- 验证动作节拍链式生成放进整集后的观感。 +- 不重新烧 1-4 镜真实视频。 + +合成策略: + +- 第 1 镜: + - asset `532` +- 第 2 镜: + - asset `516` +- 第 3 镜: + - asset `533` +- 第 4 镜: + - asset `518` +- 第 5 镜: + - 临时替换为 action beat asset `552` +- 合成完成后: + - shot `266` 当前指针已恢复为原 asset `534` + - 本次新成片 asset `557` 保留用于预览对比 + +生成结果: + +- 新成片 asset: + - `557` +- 文件: + - `local://rendered-videos/2026-06-13/6d4980e3-f4f7-49f7-a4b1-986319d3e059.mp4` + - `/www/wwwroot/ai/storage/private/rendered-videos/2026-06-13/6d4980e3-f4f7-49f7-a4b1-986319d3e059.mp4` +- render task: + - `543` +- 使用 clip_asset_ids: + - `[532,516,533,518,552]` +- post production: + - `segment_count=10` +- 状态: + - `active` +- 时长: + - `50s` + +验证: + +- FFprobe: + - video:`h264` + - size:`1080x1920` + - fps:`30` + - duration:`50.000000s` + - audio:`aac stereo` +- 音量: + - `mean_volume=-20.0 dB` + - `max_volume=-0.2 dB` +- 抽帧: + - `/www/wwwroot/ai/storage/private/live-action-acceptance/2026-06-13/episode48-actionbeat-render-557/contact-5s.jpg` + - `/www/wwwroot/ai/storage/private/live-action-acceptance/2026-06-13/episode48-actionbeat-render-557/contact-2s.jpg` + +人工观感: + +- 第 5 镜整体比旧版更像动作链: + - 管家递卡更明确。 + - 顾辰低头看卡、再抬眼震惊更清楚。 + - 黑金卡道具更突出。 +- 整集仍有短板: + - 第 2、4 镜仍是旧单首帧素材。 + - 前半段表演和镜头调度仍偏 AI 生成感。 + - 角色一致性和真实拍摄感还没有达到可发布标准。 + +当前结论: + +- action beat 放进整集后有效,值得用于反转/爆点镜头。 +- 下一步如果继续优化第一集,应优先重做: + - 第 2 镜退婚对峙 + - 第 4 镜雨夜离场 +- 这两镜若也走“更短动作节拍 + 明确反应 + 更少动作目标”,整集观感会明显提升。 + +## 2026-06-13 第一集全镜头 action beat 版生成 + +目标: + +- 5 个镜头全部用 `action_beat_mode=true` 重新生成。 +- 验证动作节拍链式生成放到整集后,是否能改善“单首帧慢推 / 图片动”的整体观感。 + +预检: + +- 镜头数: + - `5` +- provider: + - `minimax_hailuo_23_fast` +- provider 子片段数: + - `10` +- 每镜: + - `2` 段 + - 每段 `6s` + - 最终裁回每镜 `10s` +- 预计视频成本: + - `$1.902` +- 阻断: + - 无 + +生成结果: + +| 镜头 | clip | asset | 成本 | 用时 | +| --- | --- | --- | --- | --- | +| 第 1 镜 订婚宴开场到破局 | `74` | `559` | `$0.3804` | `148s` | +| 第 2 镜 当众退婚 | `75` | `561` | `$0.3804` | `155s` | +| 第 3 镜 富二代羞辱与顾辰沉默 | `76` | `563` | `$0.3804` | `148s` | +| 第 4 镜 雨夜离场 | `77` | `565` | `$0.3804` | `156s` | +| 第 5 镜 劳斯莱斯与继承权反转 | `78` | `567` | `$0.3804` | `146s` | + +视频生成成本: + +- 合计: + - `$1.902` + +新整集成片: + +- asset: + - `572` +- 文件: + - `local://rendered-videos/2026-06-13/01824074-610e-4b34-8742-3948ebf38c9d.mp4` + - `/www/wwwroot/ai/storage/private/rendered-videos/2026-06-13/01824074-610e-4b34-8742-3948ebf38c9d.mp4` +- render task: + - `553` +- 使用 clip_asset_ids: + - `[559,561,563,565,567]` +- post production: + - `segment_count=10` +- 渲染耗时: + - `55s` + +当前镜头指针: + +- 第 1 镜: + - asset `559` +- 第 2 镜: + - asset `561` +- 第 3 镜: + - asset `563` +- 第 4 镜: + - asset `565` +- 第 5 镜: + - asset `567` + +文件验证: + +- FFprobe: + - `h264` + - `1080x1920` + - `30fps` + - `50.000000s` + - audio `aac stereo` +- 音量: + - `mean_volume=-20.1 dB` + - `max_volume=-0.3 dB` + +抽帧: + +- `/www/wwwroot/ai/storage/private/live-action-acceptance/2026-06-13/episode48-full-actionbeat-render-572/contact-5s.jpg` +- `/www/wwwroot/ai/storage/private/live-action-acceptance/2026-06-13/episode48-full-actionbeat-render-572/contact-2s.jpg` + +人工观感: + +- 有改善: + - 第 1 镜人物推进和男主正脸更明显。 + - 第 2 镜退婚对峙比旧版更有连续表演感。 + - 第 3 镜羞辱和男主反应更连贯。 + - 第 4 镜雨夜情绪转场更完整。 + - 第 5 镜递卡、顾辰看卡反应比单首帧版更明确。 +- 新问题: + - 第 5 镜中段管家道具出现比例异常,像大文件夹/牌匾,黑金卡不够真实。 + - action beat 全量开启后,每镜有潜在轻微跳切风险。 + - 角色脸部仍会有轻微漂移,尤其中近景和侧脸转换时。 + - 仍没有真正 lip-sync,正脸对白仍只能靠中景轻口型规避。 + +当前结论: + +- 全镜头 action beat 版比旧版更能看出完整效果。 +- 方向有效,但不能盲目全片默认开启。 +- 更合理的生产策略: + - 普通对话: + - 单条或轻量 action beat。 + - 情绪反应 / 转折: + - 2 段 action beat。 + - 反转道具 / 爆点: + - 必须加“道具关键帧 / 结束关键帧”,否则容易道具变形。 + - 正脸对白: + - 仍需 lip-sync 或继续规避口型。 + +下一步建议: + +- 优化第 5 镜道具策略: + - 黑金卡单独生成清晰道具参考图。 + - prompt 禁止文件夹/大牌匾。 + - 第 5 镜只保留黑金卡,不同时递文件袋。 +- 后台应增加镜头级开关: + - `单条生成` + - `动作节拍` + - `道具锁定` + - `角色锁定` + - `需要 lip-sync` + +## 2026-06-14 Live-action Character Lock V1 + +触发原因: + +- 第一集 action beat 整集样片虽然动作连续性更好,但用户复看后确认不是单点问题: + - 整集人物都有换脸感。 + - 同名角色跨镜头像不同演员。 + - 这会直接破坏真人短剧发布观感。 + +问题定位: + +- 数据库已经有: + - `characters.anchor_asset_id` + - `character_images` + - `actor_profiles.anchor_asset_id` + - `actor_profiles.reference_asset_ids` +- 但真实视频生成阶段重新 build provider prompt 时,没有按本镜头角色重新加载 actor profile。 +- prepare 阶段的 `actorHints` 也存在把全项目角色混入单镜 prompt 的风险。 +- 结果: + - 第 1 镜可能带入非本镜角色描述。 + - 第 5 镜也可能缺少顾辰/管家明确同脸锁定。 + - Hailuo 只能吃首帧图生视频时,缺少稳定的角色文字锁定,模型更容易自由换脸。 + +本阶段完成: + +- 新增 `LiveActionActorLockContext`: + - 解析本镜头 `characters_json`。 + - 只加载本镜出现角色的 `ActorProfile`。 + - 组装 `actor_hints`: + - 角色描述。 + - 外貌锁定。 + - 服装锁定。 + - 表演方式。 + - 同名角色禁止换脸/换年龄/换演员。 + - 收集: + - `anchor_asset_ids` + - `reference_asset_ids` + - `missing_actor_profile_character_ids` + - `missing_anchor_character_ids` +- 真实视频生成任务 `input_json` 记录: + - `actor_lock` + - `character_reference_asset_ids` +- Provider prompt 现在会注入本镜头角色一致性规则。 +- 如果未来 Provider 支持多角色参考图: + - 自动把角色参考图转成 `reference_images` 传入。 + - 当前 Hailuo 配置仍显示 `supports_character_reference=false`,所以主要靠首帧 + prompt 锁定。 +- 修复 prepare 阶段: + - 不再把全项目 actor profiles 混进单镜 prompt。 + - 只按本镜出现角色注入演员提示。 +- mock prompt 也显示 `演员一致性 / actor_consistency`,方便本地验收和后台审计。 + +修改文件: + +- `backend/src/live-action/live-action.service.ts` +- `backend/src/live-action/prompt-builder.service.ts` +- `backend/src/live-action/live-action.service.spec.ts` +- `CODEX_PROGRESS.md` + +验证: + +- `npm run test --workspace backend -- live-action.service.spec.ts` + - 33 tests passed +- `npm run typecheck --workspace backend` + - passed +- `npm run lint --workspace backend` + - passed +- `npm run build --workspace backend` + - passed +- 后端已重启: + - `ai-backend.service` + - PID `4003094` + - `http://127.0.0.1:3000/api/health` 返回 `ok` + +当前结论: + +- 整集换脸问题已在流程层补上第一道防线。 +- 但这不是最终锁脸方案: + - Hailuo 当前不是强角色参考模型。 + - 仍需要先做角色定妆图/锚点图,并用锚点图生成每个镜头首帧。 + - 发布级样片建议重跑第 1、3、5 镜验证,再决定是否整集重跑。 + +下一步建议: + +- 先不要再全量重跑整集。 +- 先做: + - 顾辰 / 林雨薇 / 周浩 / 管家 4 个角色锚点图检查。 + - 用 Character Lock V1 重跑第 1、3、5 镜。 + - 对比旧版抽帧,看是否仍明显换脸。 + - 如果仍严重换脸,再接 Kling / Vidu / Seedance 这类更适合角色参考的 Provider 做横测。 + +## 2026-06-15 Git 提交前数据/代码整理 + +触发问题: + +- 准备提交 Git 前,需要区分: + - 哪些是代码和默认初始化数据,应该提交。 + - 哪些是服务器测试/生产运行数据,不应该提交。 + - AI 平台配置是否能通过初始化生成。 + +检查结果: + +- 当前 `/www/wwwroot/ai` 还不是 Git 仓库: + - `git status` 返回 `fatal: not a git repository` +- 当前目录总大小约: + - `2.3G` +- 主要运行时数据: + - `storage` 约 `1.8G` + - 根 `node_modules` 约 `264M` + - `backend/node_modules` 约 `235M` + - `backend/dist`、`admin/dist`、`user-app/dist`、`workers/dist` 为构建产物 +- 当前数据库存在大量测试/运行数据: + - 用户 `44` + - 项目 `59` + - 小说源 `36` + - 章节 `75` + - 故事圣经 `33` + - 角色 `96` + - 分镜 `280` + - 任务 `594` + - Provider 调用日志 `676` + - 视频片段 `78` + - 素材 `604` +- 当前启用 Provider: + - mock 系列默认 Provider + - `deepseek-text` + - `deepseek-novel` + - `minimax-tts` + - `minimax_hailuo_23_fast` + +判断: + +- 数据库里的项目、任务、素材、调用日志、用户、额度、真实 Provider 启用状态和 Key 配置都属于当前服务器运行数据,不提交 Git。 +- `storage/private/**` 下的图片、音频、视频、小说上传、Hailuo 测试片段都是运行时私有素材,不提交 Git。 +- `.env` 包含数据库、JWT、Provider Key 等本地/生产配置,不提交 Git。 +- AI Provider 默认模板已经在代码中: + - `backend/src/providers/provider.types.ts` + - `backend/prisma/migrations/20260602130000_domestic_video_providers/migration.sql` + - `backend/prisma/seed.ts` +- 后台保存后的真实 Key 和启用状态应保留在数据库或 `.env`,不进入 Git。 + +本阶段完成: + +- 加固 `.gitignore`,防止误提交: + - `node_modules` + - `dist` + - `.vite` + - `.output` + - `coverage` + - `.env` + - 日志文件 + - runtime storage + - zip/tar/sql/dump/bak + - sqlite/db 本地数据库 + +修改文件: + +- `.gitignore` +- `CODEX_PROGRESS.md` + +提交建议: + +- 应提交: + - 源码:`backend/src`、`admin/src`、`user-app/src`、`workers/src` + - Prisma:`backend/prisma/schema.prisma`、`backend/prisma/migrations`、`backend/prisma/seed.ts` + - 配置模板:`.env.example` + - 包管理:`package.json`、`package-lock.json`、各 workspace `package.json` + - 运维模板:`deploy` + - 根说明文档和进度文档 +- 不提交: + - `.env` + - `storage/private/**` + - `backend/storage/**` + - `*.log` + - `dist` + - `node_modules` + - 服务器数据库导出、zip 包、运行备份 + +验证: + +- 本阶段只调整 Git 忽略规则和进度记录,没有修改业务代码。 +- 未运行 lint/typecheck/test。 + +## 2026-06-14 用户端制作页单列流程 / 下一步自动滚动 + +触发问题: + +- 用户反馈前端制作页面 PC 端是双列,流程不好看。 +- 希望制作页改成一列纵向流程,并且每次完成当前动作后自动滚动到下一步对应位置。 + +本阶段完成: + +- 用户端制作页外层增加 `studio-flow`,仅影响“制作”页。 +- PC 端制作页不再使用双列卡片流,强制改为一列往下跑: + - 进度 + - 额度 + - 来源 + - 版权 + - 故事圣经 + - 角色库 + - 长篇记忆 + - 分集计划 + - 脚本和分镜 + - AI 真人短剧 + - 图片/音频/视频 + - 内容审核 +- 制作页内部表单在 `studio-flow` 下也改为单列,减少 PC/H5 字段和按钮挤压。 +- 给制作页关键步骤增加锚点: + - `source` + - `copyright` + - `story` + - `characters` + - `memory` + - `episodes` + - `script` + - `live-action` + - `render` + - `review` +- 新增下一步定位逻辑: + - 动作成功后根据当前项目状态自动判断下一步。 + - 自动滚动到对应步骤卡片。 + - AI 原创项目未生成正文时,优先回到“来源”。 + - 真人短剧项目在最终成片前,优先回到“AI 真人短剧”面板。 +- 排除不应该滚动的局部操作: + - 恢复会话 + - 视频预检刷新 + - 小样预检刷新 + - 单张锚点重生成 + - 单张锚点切换 + - 素材预览 + - 成品下载 + +修改文件: + +- `user-app/src/pages/index/index.vue` +- `user-app/src/styles.css` +- `CODEX_PROGRESS.md` + +验证: + +- `npm run typecheck --workspace user-app` + - passed +- `npm run lint --workspace user-app` + - passed +- `npm run test --workspace user-app` + - passed, no test files +- `curl -I http://152.53.37.118:5174/` + - `200 OK` + +下一步建议: + +- 人工打开 PC/H5 制作页,点一遍“版权确认 -> 解析小说 -> 故事圣经 -> 角色 -> 分集 -> 脚本/分镜 -> 真人视频/合成”,确认自动滚动位置是否符合操作习惯。 +- 如果仍觉得步骤太长,可以再加一个右侧/顶部“当前流程目录”,点击可跳到任一步。 + +## 2026-06-14 前端真人视频流程同步 / Action Beat 控制 + +触发问题: + +- 用户询问 `http://152.53.37.118:5174/` 前端按流程生成视频时,到底是 mock 还是同步了最近真实 Hailuo 测试步骤。 + +问题定位: + +- 前端普通主流程的“生成视频/合成”仍是系统 A 常规 FFmpeg 合成链路,不等于真人 Hailuo 视频片段生成链路。 +- 前端已有“AI 真人短剧”面板,能执行演员定妆、真人分镜、关键帧、视频片段、合成。 +- 后端已支持 `action_beat_mode/action_beat_count`,但用户端没有暴露开关,导致无法在前端明确控制“默认 1 条”还是“复杂动作拆成 2-3 个子片段”。 + +本阶段完成: + +- 用户端真人视频表单新增: + - `动作节拍` + - `节拍段数` +- 默认关闭动作节拍,保持单镜头默认 1 条,避免默认成本翻倍。 +- 开启动作节拍后,前端预检、小样生成、整集片段生成、片段重试都会带上: + - `action_beat_mode` + - `action_beat_count` +- 统一真人视频前端参数构造,减少小样和整集口径不一致。 +- `UserApiClient` 的真人视频 preflight/generate/retry 类型和 query/body 增加 action beat 参数。 + +修改文件: + +- `user-app/src/pages/index/index.vue` +- `user-app/src/api/client.ts` +- `CODEX_PROGRESS.md` + +验证: + +- `npm run typecheck --workspace user-app` + - passed +- `npm run lint --workspace user-app` + - passed +- `npm run test --workspace user-app` + - passed, no test files +- `curl -I http://152.53.37.118:5174/` + - `200 OK` + +当前判断: + +- 前端普通流程的“多角色音频”会按当前 Provider 配置走真实 MiniMax 或 mock。 +- 前端普通流程的“生成视频/合成”是 FFmpeg 合成,不会直接调用 Hailuo 生成真人视频。 +- 前端“AI 真人短剧”面板里的“视频片段/小样视频片段”才是 Hailuo/Kling/Jimeng/Mock 真实视频 Provider 链路。 +- 选择真实 Hailuo Provider 并勾选真实费用确认后,才会调用真实 Hailuo;不选 Provider 时走 Router,Router 可能因为配置/可用性落到 Mock。 +- 后端 Character Lock V1 已自动生效,但前端目前只使用它,不单独展示 actor_lock 审计明细。 + +下一步建议: + +- 把前端“普通生成视频”和“真人视频片段生成”视觉上再拆清楚,避免误点普通合成却以为在跑 Hailuo。 +- 后台/前端补 actor_lock、关键帧来源、clip_normalization 的可视化,让每个片段是否锁脸、是否裁切、是否动作节拍拆段一眼可见。 + +## 2026-06-14 TTS / Upload Blocking Fix + +触发问题: + +- 用户端点击“多角色音频”报: + - `MINIMAX_TTS_EMPTY_AUDIO` +- 上传小说时报: + - `request entity too large` + +问题定位: + +- 最近失败的 `minimax-tts` ProviderLog 显示: + - 多角色音频把 `voice=coral` 发给了 MiniMax。 + - `coral` 是 OpenAI TTS 声音名,不是 MiniMax voice_id。 +- `runConfigurableSpeechProvider` 解析 JSON 响应时,没有先检查 MiniMax `base_resp.status_code`,会把业务拒绝误报成 `MINIMAX_TTS_EMPTY_AUDIO`。 +- 上传小说的后端文件限制为 20MB。 +- 加密上传会把文件转成 base64 放入 JSON,Nest 默认 body parser 限制会先拦截,导致 `request entity too large`。 + +本阶段完成: + +- MiniMax TTS 兼容: + - `body_style=minimax_tts` 时,如果输入是 OpenAI voice 名(如 `coral`),自动降级为 MiniMax 配置默认 `voice_id`。 + - MiniMax JSON 响应先检查 `base_resp.status_code`,真实业务错误会显示 `MINIMAX_TTS_PROVIDER_REJECTED`,不再误报空音频。 + - JSON 无音频时带上安全摘要,方便后台排查。 + - Media 多角色音频 provider input 增加 `voice_id` 字段,方便 Provider 适配。 +- 上传限制: + - 小说/素材上传默认上限从 20MB 提高到 100MB。 + - 新增 `MAX_UPLOAD_BYTES` 环境变量支持。 + - Nest body parser 改为显式配置: + - 默认 `REQUEST_BODY_LIMIT=160mb` + - 支持 `MAX_REQUEST_BODY_SIZE` + - 兼容 encrypted JSON/base64 上传。 +- 用户端错误文案: + - 增加 `MINIMAX_TTS_EMPTY_AUDIO` + - 增加 `MINIMAX_TTS_PROVIDER_REJECTED` + - 增加 `request entity too large` + +修改文件: + +- `backend/src/providers/providers.service.ts` +- `backend/src/providers/providers.service.spec.ts` +- `backend/src/media/media.service.ts` +- `backend/src/assets/assets.controller.ts` +- `backend/src/main.ts` +- `user-app/src/api/client.ts` +- `CODEX_PROGRESS.md` + +验证: + +- `npm run test --workspace backend -- providers.service.spec.ts media.service.spec.ts assets.service.spec.ts` + - 55 tests passed +- `npm run typecheck --workspace backend` + - passed +- `npm run lint --workspace backend` + - passed +- `npm run build --workspace backend` + - passed +- `npm run typecheck --workspace user-app` + - passed +- `npm run test --workspace user-app` + - passed, no test files +- MiniMax TTS smoke: + - `text=测试。` + - `voice=coral` + - provider `minimax-tts` + - status `success` + - returned `audio/mpeg` + - audio bytes `18485` +- 大 body 上传 smoke: + - 1MB base64 JSON 上传不再返回 413 + - 未登录场景返回正常 `401 Missing bearer token` +- 后端已重启: + - `ai-backend.service` + - PID `4097583` + - `/api/health` 返回 `ok` + +注意: + +- 如果上传通过公网 Nginx/宝塔代理仍报 413,还需要同步调高 Nginx `client_max_body_size`。 +- 当前应用层已经放开到默认 100MB 文件 / 160MB JSON body。 diff --git a/OPERATION_GUIDE.md b/OPERATION_GUIDE.md new file mode 100644 index 0000000..7f1f0f0 --- /dev/null +++ b/OPERATION_GUIDE.md @@ -0,0 +1,826 @@ +# AI 漫剧平台小白使用手册 + +本文给第一次使用系统的人看,目标是讲清楚两件事: + +- 用户端怎么从一个想法或一段小说,生成一集漫剧成品。 +- 后台运营怎么给用户加额度、看资源、查问题、管 AI 接入。 + +当前建议先用 mock 或低成本配置熟悉流程。不要在不理解成本和任务状态时直接打开“生产任务优先使用 OpenAI”。 + +## 1. 先理解几个核心概念 + +### 项目 + +项目是一部漫剧的制作单元。一个项目可以是: + +- AI 原创小说:你给题材、人设、卖点,AI 先写小说内容,再改成漫剧。 +- 上传小说:你粘贴或上传已有小说,系统解析章节,再改成漫剧。 + +项目里会逐步产生:小说源、故事圣经、角色、分集、脚本、分镜、图片、音频、字幕、视频。 + +### 故事圣经 + +故事圣经不是最终小说,也不是一集脚本。它是整部作品的“规则说明书”。 + +它主要回答: + +- 这个故事是什么类型,例如都市逆袭、甜宠、悬疑。 +- 故事的核心冲突是什么。 +- 主角要达成什么目标。 +- 世界观和风格是什么。 +- 什么内容不能乱写,例如不能突然科幻、不能低俗、不能侵权。 +- 结尾方向、爽点、反转和长线伏笔是什么。 + +为什么需要它:后面的角色、分集、脚本、分镜都会参考故事圣经。没有故事圣经,AI 容易前后风格不一致。 + +故事圣经一般由 AI 先生成,人再检查。确认后,后续流程才会继续。 + +### 角色圣经 + +角色圣经是角色库,也就是每个重要人物的设定卡。 + +它会记录: + +- 姓名 +- 角色类型:主角、反派、配角、次要角色 +- 性格 +- 背景 +- 目标和动机 +- 与其他角色的关系 +- 外貌特征 +- 服装规则 +- 说话风格 +- 锚点图 + +角色怎么定: + +- AI 原创项目会结合你填写的主角设定、题材、卖点和故事圣经自动抽取。 +- 上传小说项目会结合小说章节和故事圣经自动抽取。 +- 运营或用户可以在确认前改名、改关系、补外貌、删除不需要的角色,也可以手动新增角色。 + +确认角色后,角色会被锁定。后续分镜和图片生成会尽量遵守这些角色设定。 + +### 全局角色资产库 + +全局角色资产库是后台给运营用的“演员库”。 + +它和项目里的角色不一样: + +- 全局角色:可跨项目复用的固定演员,例如“都市冷感女主模板”“霸总男主模板”“绿茶反派模板”。 +- 项目角色:某个项目里的具体人物,例如这一部剧里叫“林晚”、身份是设计师、剧情目标是夺回项目。 + +正式运营时,建议先沉淀一批全局角色: + +- 固定脸:锚点图、脸型、发型、身形。 +- 服装规则:日常装、职场装、礼服、古装、校服等变体。 +- 声音:每个角色自己的 voice id、模型、声线、语速和情绪风格。 +- 表演风格:眼神、表情、动作习惯。 +- 授权范围:内部测试、公司自有、授权、限制使用。 + +这样不同项目可以复用同一批主角、配角、反派,只在项目里改名字、身份、关系和服装变体,不需要每次重新设计。 + +后台路径: + +```text +后台 -> 角色资产库 +``` + +项目角色绑定路径: + +```text +后台 -> 角色资源 -> 全局角色 -> 选择角色资产 -> 绑定 +``` + +绑定后,如果项目角色还没有锚点图、声音或默认服装,系统会自动带入全局角色资产里的默认配置。项目角色已有的差异化设置不会被强行覆盖。 + +### 锚点图 + +锚点图是角色的一张参考图,用来稳定角色外观。 + +简单理解:以后生成这个角色的分镜图时,系统会尽量让人物长得像这张图。 + +内部测试时可以先用 mock 图熟悉流程;真实上线时再接真实图片 Provider。 + +### 长篇记忆 + +长篇记忆用于保证多集连续性。 + +它会记录: + +- 已发生的重要事件 +- 角色关系变化 +- 未回收的伏笔 +- 不能打破的设定 +- 前几集结尾留下的悬念 + +如果做 1 集短测试,长篇记忆看起来存在感不强;如果做 20 集、100 集,它非常关键。 + +### 分集计划 + +分集计划就是把故事拆成第 1 集、第 2 集、第 3 集。 + +每集会有: + +- 标题 +- 剧情摘要 +- 开头钩子 +- 中段冲突 +- 结尾悬念 +- 关联章节 +- 预计时长 + +你说“一章一个集数”,在系统里可以理解为:一个章节内容对应一个 episode,也就是一集。 + +### 单集脚本 + +单集脚本是一集的文字剧本。 + +它主要包含: + +- 旁白 +- 关键对白 +- 剧情推进 +- 情绪节奏 + +脚本确认后,才会进入分镜。 + +### 分镜 + +分镜是把一集拆成一个个镜头。 + +当前默认每集约 10 个镜头。每个镜头会有: + +- 镜头序号 +- 场景名,例如“开局压迫”“主角反击”“结尾钩子” +- 地点描述,例如“会议室中心”“走廊阴影” +- 出场角色 +- 画面描述 +- 动作描述 +- 对白或旁白 +- 镜头运动 +- 特效类型 +- 时长 +- 图片生成 Prompt +- 负面 Prompt + +场景是不是 AI 生成:是。当前场景不是单独的“场景库”,而是在分集、脚本、分镜里由 AI 自动生成场景名、地点、画面和动作。确认前可以编辑分镜字段,也可以重新生成 Prompt。 + +### 分镜图 + +分镜图是每个镜头对应的一张画面。 + +系统会根据分镜 Prompt、角色设定和风格生成图片。当前一集默认约 10 张正式分镜图。 + +### 音频、字幕和视频 + +音频是 TTS 声音,字幕是 SRT 字幕,视频是最终 MP4。 + +当前推荐上线链路是: + +分镜图 + TTS 音频 + SRT 字幕 + FFmpeg 本地合成 MP4 + +这样成本更可控。Sora 视频可以作为高级能力,不建议默认打开。 + +现在音频默认走“多角色音频”: + +- 旁白使用旁白声线。 +- 角色对白优先使用角色资产库里的 `voice_id`。 +- 如果项目角色绑定了全局角色,会继承全局角色的声音配置。 +- 如果角色没有单独声音,才使用默认 TTS 声音。 +- 系统会按分镜顺序把旁白和对白拆成多个片段,逐段合成。 +- 每个片段会带上起始秒数、结束秒数和目标时长,再按分镜时间轴铺成一条整集音轨。 +- 如果某句真实 TTS 比分配时间更长,接口会返回时间轴告警,运营需要缩短台词、增加镜头时长或调整语速。 + +现在字幕默认走“对白级字幕”: + +- 一句旁白或一句角色台词对应一条 SRT 字幕。 +- 字幕时间和多角色音频使用同一套分镜时间轴。 +- 如果需要旧版“每个分镜一条字幕”,技术接口可以传 `subtitle_mode: "shot"`。 + +生成完成后,在用户端“图片、音频和视频”区域可以看“音频字幕时间轴”: + +- 每一行是一句旁白或一句角色对白。 +- 可以看到镜头号、说话人、起止秒、目标时长、实际 TTS 时长和声音 ID。 +- 如果某句 TTS 超出分配时长,会出现红色提示。 +- 这时优先处理台词:缩短句子、拆成两个镜头、增加镜头时长,或换更快的声线。 +- 面板里的“试听音频”是整集音频试听;“下载字幕”可以下载 SRT 文件。 +- 每句后面可以点“重试此句”,只重合成这一句 TTS,再重新铺成整集音频。 +- 单句重试可以换声音 ID、语速和语气说明。 +- 老版本生成的音频如果缺少逐句片段文件,页面会提示先“重生成音频”;真实 TTS 模式下这一步会重新产生费用。 + +成本预估说明: + +- 页面会显示本集大约多少句、多少个声音、多少 TTS 字符。 +- 这不是最终账单金额。 +- 真实金额以后台 Provider 日志和 AI 平台账单为准。 + +旧的一条旁白音频模式仍保留,技术接口可以传: + +```json +{ + "dialogue_mode": "narration" +} +``` + +正式测试真实 TTS 前要注意:多角色音频会按片段多次调用 TTS,成本高于一条旁白音频。第一次建议只测 1 集、少量分镜。 + +## 2. 用户端从 0 到 1 操作流程 + +用户端地址: + +```text +http://152.53.37.118:5174 +``` + +本机地址: + +```text +http://127.0.0.1:5174 +``` + +### 2.1 登录或注册 + +打开用户端后,先登录或注册。 + +如果是内部测试用户,需要后台先给这个用户加额度。当前用户端不展示真实支付入口,额度由后台人工增加。 + +### 2.2 新建项目 + +进入“新建”页面。 + +主要字段: + +- 标题:项目名,方便后台和自己识别。 +- 输入方式:AI 原创小说或上传小说。 +- 类型:例如都市逆袭、甜宠、悬疑。 +- 风格:例如韩漫风。 +- 目标集数:想生成几集。 +- 单集时长:一集预计多长。 + +新手建议: + +- 第一次测试选 1 集。 +- 单集时长填 40-60 秒。 +- 风格先保持默认。 + +### 2.3 AI 原创小说流程 + +如果输入方式选择“AI 原创小说”,继续填写原创信息。 + +常见字段: + +- 目标受众:给谁看。 +- 主角姓名:可以填,也可以留空让 AI 起名。 +- 主角设定:身份、性格、困境。 +- 故事氛围:高能反击、甜虐、悬疑等。 +- 卖点:强钩子、快节奏、反转爽点。 +- 禁忌规则:不希望出现的内容。 + +建议写法: + +```text +主角设定:女主是被豪门退婚的设计师,表面温和,实际非常冷静,有隐藏实力。 +卖点:退婚现场反击、身份反转、前任后悔、女主独立成长。 +禁忌规则:不要血腥,不要低俗,不要真实品牌。 +``` + +然后按页面顺序生成: + +1. 创意或小说内容 +2. 故事圣经 +3. 角色 +4. 长篇记忆 +5. 分集计划 +6. 单集脚本 +7. 分镜 +8. 分镜图 +9. 音频 +10. 字幕 +11. 视频 + +每一步生成后,都要先看结果是否合理,再确认进入下一步。 + +### 2.4 上传小说流程 + +如果输入方式选择“上传小说”,流程是: + +1. 新建项目时选择上传小说。 +2. 粘贴小说文本,或后续用文件上传入口。 +3. 填标题、作者名。 +4. 做版权确认。 +5. 解析小说章节。 +6. 生成故事圣经。 +7. 抽取角色。 +8. 后续与 AI 原创流程相同。 + +版权确认是什么意思:确认你有权使用这段文本做 AI 改编。没有确认版权,系统不应该继续改编。 + +### 2.5 故事圣经怎么检查 + +看到故事圣经后,重点检查: + +- 类型对不对。 +- 主角目标是否清楚。 +- 核心冲突是否够强。 +- 风格是否符合你要的漫剧方向。 +- 有没有明显跑题。 +- 禁忌规则有没有写进去。 + +可以接受时再确认。 + +如果不满意: + +- 用户端当前以生成和确认为主,细改能力相对轻。 +- 后台或 API 可做更细的编辑。 +- 简单测试可以重新建项目或回到前一步调整输入。 + +### 2.6 角色怎么检查 + +角色生成后,重点看: + +- 主角是否正确。 +- 反派是否明确。 +- 配角是否过多。 +- 人物关系是否合理。 +- 外貌和服装是否适合后续图片生成。 +- 是否有不需要的人物。 + +确认角色前,尽量把主角、反派、关键配角定清楚。 + +角色锚点图建议: + +- 主角必须有锚点图。 +- 反派和重要配角最好也有。 +- 次要角色可以后面再补。 + +### 2.7 分集计划怎么检查 + +每集都要看: + +- 开头钩子是否吸引人。 +- 中段冲突是否推进。 +- 结尾悬念是否让人想看下一集。 +- 每集信息量是否太少或太多。 +- 是否符合“一章一个集数”的目标。 + +确认后再生成脚本。 + +### 2.8 单集脚本怎么检查 + +看三个点: + +- 旁白是否顺。 +- 对白是否符合角色。 +- 剧情是否能在目标时长内讲清楚。 + +如果一集 40-60 秒,脚本不要太长。旁白越长,TTS 音频越长,视频时长也会变长。 + +### 2.9 分镜怎么检查 + +分镜是最容易影响成片质量的地方。 + +重点看: + +- 是否有 8-12 个镜头。 +- 每个镜头是否有清楚画面。 +- 出场角色是否合理。 +- 画面描述是否适合生成图片。 +- 镜头顺序是否能讲清故事。 +- 结尾镜头是否有悬念。 + +场景字段由 AI 生成,包括场景名、地点、画面、动作、镜头运动。确认前可以修改,确认后再生成分镜图。 + +### 2.10 生成分镜图 + +分镜确认后,生成分镜图。 + +如果是真实图片 Provider: + +- 每张图都会产生费用。 +- 当前一集约 10 张分镜图。 +- 建议第一次真实测试只开一集,并设置成本上限。 + +如果是 mock: + +- 不消耗真实 AI 额度。 +- 适合熟悉流程和验收页面。 + +### 2.11 生成音频、字幕和视频 + +顺序建议: + +1. 点击“多角色音频”,生成角色音频和对白级字幕。 +2. 检查音频、字幕素材是否都已出现。 +3. 渲染视频。 +4. 去成品页预览或下载。 + +视频渲染前需要额度可用。当前内部测试由后台人工加余额。 + +### 2.12 成品下载和审核 + +视频生成后,到“成品”页面看预览和下载。 + +如果要做公开案例,需要提交公开案例授权。后台审核通过后才可以公开展示。 + +## 3. 后台运营操作流程 + +后台地址: + +```text +http://152.53.37.118:5175 +``` + +本机地址: + +```text +http://127.0.0.1:5175 +``` + +本地 seed 默认管理员: + +```text +邮箱:admin@example.com +密码:Admin123! +``` + +生产环境必须改密码。 + +### 3.1 仪表盘 + +仪表盘用于看整体情况: + +- 今日用户 +- 今日项目 +- 今日生成集数 +- 任务状态 +- 项目状态 +- 队列状态 + +运营每天先看这里,确认有没有大量失败任务或异常队列。 + +### 3.2 项目管理 + +项目管理用于查看用户项目。 + +你可以看到: + +- 项目标题 +- 用户 +- 当前状态 +- 分集数量 +- 角色数量 +- 分镜数量 +- 最近任务 +- 成本 + +常用操作: + +- 转人工:项目卡住或内容有风险。 +- 取消项目:内部测试废弃项目。 +- 查看项目详情:排查用户说“生成不了”的问题。 + +### 3.3 小说管理 + +小说管理包含小说源和章节。 + +小说源是用户上传或 AI 原创产生的原始文本。章节是解析后的结构化内容。 + +看这里可以判断: + +- 上传文本有没有解析成功。 +- 章节数量是否正常。 +- 字数是否异常。 +- 内容预览是否乱码。 + +### 3.4 角色资源 + +角色资源页用于看所有项目里的角色。 + +运营重点看: + +- 主角是否生成。 +- 角色状态是否已确认。 +- 是否有锚点图。 +- 图片数量是否正常。 +- 长篇记忆数量是否正常。 + +用户反馈“人物长得不一致”,先看这里有没有锚点图。 + +### 3.5 分镜资源 + +分镜资源页用于看镜头。 + +运营重点看: + +- 一集是否有足够分镜。 +- 每个镜头的场景名是否合理。 +- 画面描述是否清楚。 +- 是否已经生成图片。 +- 最新图片能否预览。 + +用户反馈“画面不对”,先看分镜字段,再看分镜图。 + +### 3.6 成品漫剧 + +成品漫剧页看最终视频。 + +你可以看: + +- 视频资产 ID +- 所属项目 +- 所属分集 +- 文件路径 +- 时长 +- 渲染任务 +- 预览 + +用户反馈“视频下载不了”或“成品不对”,从这里开始查。 + +### 3.7 用户管理 + +用户管理是当前内部测试最常用的后台页面。 + +常用操作: + +- 人工加余额 +- 查看用户详情 +- 看用户余额流水 +- 看用户订单 +- 看用户项目 +- 看用户素材 +- 看最近操作 +- 禁用或启用用户 +- 修改角色 +- 重置密码 +- 额度冲正 + +当前用户端不展示真实支付入口,所以测试用户要先在后台加余额。 + +建议: + +- 内部测试用户先加少量额度。 +- 真实 AI 测试前再加额外额度。 +- 额度冲正要写清楚原因。 + +### 3.8 订单额度 + +订单额度页用于看额度账户和历史订单。 + +当前内部测试模式下: + +- 用户端不展示套餐和支付。 +- 后台人工加余额是主流程。 +- 历史 mock 支付接口还在,但不作为正式入口。 + +### 3.9 内容审核 + +内容审核页用于处理文本、素材、成品视频、公开案例。 + +常见状态: + +- 通过 +- 需修改 +- 驳回 +- 屏蔽 +- 转人工 + +用户反馈“项目被卡住”,要看这里有没有人工审核项。 + +### 3.10 任务管理 + +任务管理用于处理生成失败。 + +常见操作: + +- 重试 +- 取消 +- 转人工 + +任务失败时先看: + +- 任务类型 +- 错误信息 +- 重试次数 +- 是否达到上限 +- 项目是否缺前置资源 + +例如视频失败,可能是分镜图不足、音频未生成、字幕缺失或 FFmpeg 异常。 + +### 3.11 AI 接入 + +AI 接入用于配置 OpenAI 或 mock Provider。 + +顶部“OpenAI 统一接入”是运营日常使用区域。 + +“初始化视频接入”会写入真实视频 Provider 预设,包含 MiniMax Hailuo、阿里 Wan、Vidu、Seedance、Runway 和 Kling。它们默认都是关闭状态,只是把配置位放进后台,不会自动调用、不自动扣费。 + +重要规则: + +- 保存 Key 不等于开始花钱。 +- 只有勾选“生产任务优先使用 OpenAI”,业务链路才会优先使用真实 OpenAI。 +- “检查连接(不生成内容)”只查 Key 和网络,不生成内容。 +- 真实 Provider 的付费测试需要二次确认。 +- 真实视频测试默认禁用,避免误触发高成本视频任务。 +- 真实视频只能在用户端真人短剧流程里显式勾选确认后调用。 + +建议第一次真实测试配置: + +- 单次成本上限:3 +- 当日成本上限:10 +- 先不要打开真实视频 Provider。 +- 先用真实文本和图片小样确认链路。 + +### 3.12 成本日志 + +成本页用于看 Provider 调用日志和估算成本。 + +如果发现费用异常: + +- 先看哪个 Provider 调用多。 +- 再看是文本、图片、TTS 还是视频。 +- 视频按秒计费,最容易变贵。 +- 图片按张数累积。 + +### 3.13 系统配置 + +系统配置当前包含 API 加密开关。 + +测试默认不开启。上线后可以手动开启。 + +注意:应用层加密不能替代 HTTPS。正式上线必须使用 HTTPS。 + +### 3.14 审计日志 + +审计日志记录后台高危操作。 + +例如: + +- 给用户加余额 +- 扣减或冲正额度 +- 禁用用户 +- 改角色 +- 重置密码 +- 修改系统配置 +- 导出日志 + +出现运营争议或客服问题时,先查审计日志。 + +## 4. 一集从头到尾的小白流程 + +这是最推荐的新手演练流程,先用 mock 熟悉,不产生真实 AI 生成费用。 + +1. 后台给测试用户加余额。 +2. 用户端登录测试用户。 +3. 新建项目,选择 AI 原创小说。 +4. 目标集数填 1。 +5. 填主角设定和卖点。 +6. 生成原创内容。 +7. 生成故事圣经。 +8. 检查故事圣经,确认。 +9. 抽取角色。 +10. 检查主角、反派、配角,生成锚点图,确认角色。 +11. 生成长篇记忆。 +12. 生成分集计划,确认。 +13. 选择第 1 集,生成脚本,确认。 +14. 生成分镜,检查 10 个镜头,确认。 +15. 生成分镜图。 +16. 生成音频。 +17. 生成字幕。 +18. 渲染视频。 +19. 到成品页预览和下载。 +20. 后台看项目、分镜资源、成品漫剧、任务和成本日志。 + +## 5. 哪些内容是 AI 生成,哪些需要人确认 + +| 阶段 | AI 会生成什么 | 人要看什么 | +|---|---|---| +| 原创小说 | 创意、章节、大纲、自检 | 是否符合题材和禁忌 | +| 故事圣经 | 世界观、冲突、风格、结尾方向 | 是否跑题,规则是否清楚 | +| 角色圣经 | 角色列表、人设、关系、外貌 | 主角/反派是否正确,关系是否合理 | +| 角色图 | 候选图、锚点图、表情图 | 外观是否稳定,锚点是否合适 | +| 长篇记忆 | 事件、伏笔、角色记忆 | 是否和前文冲突 | +| 分集计划 | 每集标题、摘要、钩子、悬念 | 节奏是否适合短剧 | +| 单集脚本 | 旁白、对白、剧情推进 | 语言是否顺,角色是否不崩 | +| 分镜 | 场景、地点、画面、动作、镜头、Prompt | 画面是否能生成,镜头是否连贯 | +| 分镜图 | 每个镜头的图片 | 人物一致性、画面质量 | +| TTS | 多角色音频 | 声音、语速、时长、角色是否匹配 | +| 字幕 | 对白级 SRT 字幕 | 每句台词时间轴和文字 | +| 视频 | MP4 成品 | 是否可播放、画音字幕是否对齐 | + +## 6. 新手最容易误解的地方 + +### 故事圣经不是让用户看的简介 + +它更像制作规则。用户未必需要理解每个字段,但运营要知道它会影响后面所有生成。 + +### 角色不是只靠一张图定 + +角色由文字设定和锚点图共同决定。文字设定决定“是谁”,锚点图决定“长什么样”。 + +### 场景目前不是独立资产库 + +当前场景主要在分镜里体现。AI 会生成场景名、地点、画面描述。后续如果要做场景库,可以把常用地点、背景图、风格规则单独管理。 + +### 确认不是摆设 + +确认的意思是:这一步结果可以作为后续生成依据。 + +如果故事圣经错了就确认,后面角色、分集、脚本都会跟着错。 + +### mock 不等于没用 + +mock 用来验证流程、页面、状态、素材落库、任务重试和下载。真实 AI 只是在 Provider 层替换生成能力。 + +### Sora 视频不要默认打开 + +Sora 按秒计费。当前漫剧落地更适合先用分镜图 + TTS + FFmpeg 合成,成本更可控。 + +### 生成类型要先选清楚 + +新建项目现在有三种生成类型: + +| 生成类型 | 当前作用 | 适合场景 | +|---|---|---| +| 图片漫剧版 | 分镜图 + 配音字幕 + FFmpeg 合成 | 低成本批量测试 | +| 动态漫画版 | 先预留入口 | 后续做局部动效 | +| AI 真人短剧版 | 演员定妆、真人分镜、关键帧、视频片段、片段质检、合成 | 先验证真人短剧数据流,再做真实视频小样 | + +AI 真人短剧版默认使用 mock video clip 跑通流程。后台已经支持 MiniMax Hailuo、阿里 Wan、Vidu、Seedance、Runway、Kling 等 image-to-video Provider,但真实视频默认禁用,必须运营启用 Provider、填写成本阈值,并且用户端勾选“确认使用真实视频生成并承担费用”后才会调用。 + +真人短剧的顺序是: + +1. 先完成故事、角色、分集、脚本、分镜。 +2. 点“演员定妆”,把角色圣经转成真人演员设定。 +3. 点“真人分镜”,把漫画分镜改写成真人短剧镜头。 +4. 点“关键帧”,生成每个镜头的真人短剧关键帧 mock 图。 +5. 在“视频 Provider”里保持 mock,先点“估算”,确认成本为 0。 +6. 点“视频片段”,生成每个镜头的 mock MP4 片段。 +7. 预览片段,点“质检”;不满意可以点“重试”。 +8. 点“合成”,把视频片段拼成真人短剧 mock 成片。 + +真实 Hailuo/Wan/Vidu/Seedance/Runway/Kling/Sora 等视频模型建议只做 1 个镜头小样。真实小样前要注意: + +- 后台 AI 接入页先点“初始化视频接入”。 +- 只启用一个视频 Provider。第一轮建议优先试 `minimax_hailuo_23_fast`,速度和成本更适合小样验证。 +- 填 API Key、Base URL、模型、单次成本上限、当日成本上限。 +- 填 `price_per_second`,否则页面只能显示 0 美元估算。 +- 真实视频需要 PNG/JPG/WebP 关键帧;mock SVG 关键帧不能直接用于真实视频 Provider。 +- 真实视频测试不要在后台 Provider 测试里做,后台仍禁用真实视频测试,避免误扣费。 +- 阿里 Wan、Seedance 等不同渠道接口字段差异较大;后台预设是可配置模板,正式上线前必须用 1 个镜头核对请求字段、返回视频 URL、耗时和实际账单。 + +推荐真实视频小样顺序: + +1. MiniMax Hailuo 2.3 Fast:先看“人物会动”和中文短剧感是否接近目标。 +2. 阿里 Wan2.6 I2V Flash:对比速度、稳定性和成本。 +3. Vidu Q3 Turbo Reference:重点看角色一致性、表情和音画能力。 +4. Seedance/即梦:重点看真人感、镜头语言和中文短剧风格。 +5. Kling/Runway:作为备用对比。 + +每次只测同一个项目、同一个角色、同一个镜头,这样才看得出哪个 Provider 更适合。 + +## 7. 建议的真实 AI 验收顺序 + +等你熟悉流程后,再做真实 AI 验收。 + +建议顺序: + +1. 后台 AI 接入页设置单次成本上限和当日成本上限。 +2. 点“检查连接(不生成内容)”。 +3. 只打开真实文本或真实图片中的一个能力。 +4. 跑 1 个项目、1 集、10 个分镜。 +5. 看 Provider 日志和成本。 +6. 确认结果可控后,再打开 TTS。 +7. 视频继续先用 FFmpeg。 +8. 最后再考虑 Sora 小样。 + +建议成本阈值: + +- 单次成本上限:3 美元 +- 当日成本上限:10 美元 +- 第一轮不打开 Sora + +## 8. 运营排查问题速查 + +| 用户反馈 | 先看哪里 | +|---|---| +| 登录不了 | 后台用户管理、用户状态 | +| 没额度 | 后台用户管理、额度账户、额度流水 | +| 项目卡住 | 后台项目详情、任务管理 | +| 小说解析失败 | 小说管理、小说源预览、章节数量 | +| 角色不对 | 角色资源、故事圣经 | +| 人物长得不一致 | 角色锚点图、分镜图 | +| 场景乱 | 分镜资源里的场景名、地点、画面描述 | +| 视频生成失败 | 成品漫剧、任务管理、分镜图、音频、字幕 | +| 审核不过 | 内容审核页 | +| 费用异常 | 成本日志、AI 接入优先级、成本阈值 | + +## 9. 当前系统状态提醒 + +- 用户端是 H5 优先,PC 自适应;微信小程序/App 后续再适配。 +- 当前内部测试不展示真实支付入口。 +- 额度由后台人工增加。 +- OpenAI Key 已支持后台保存并加密。 +- 保存 Key 不会自动消耗额度。 +- OpenAI Provider 默认优先级低于 mock。 +- 真实视频测试已禁用,避免误触发高成本任务。 +- 当前适合先做 mock 全流程熟悉,再做真实 AI 小样。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..866f29d --- /dev/null +++ b/README.md @@ -0,0 +1,622 @@ +# AI Manga Platform + +AI 漫剧 / AI 写真视频生成平台。当前优先开发系统 A:原创小说 / 上传小说 -> 韩漫 / 漫剧生成系统。 + +## 当前阶段 + +阶段 23:API 加密传输已完成,当前进入生产化补齐。AI 原创小说 3 集 MP4 与上传小说 1 集 MP4 两条闭环已跑通,并在 HTTPS 基础上叠加前后端应用层加密信封;真实图片/TTS/视频资产落库、OpenAI Sora 视频 Provider 驱动、后台用户高危操作审计、细粒度 RBAC 和审计导出已补齐。 + +当前已完成后端基础骨架、数据库 schema、JWT 认证、私有文件上传、项目创建流程、上传小说解析、AI 原创小说 mock、故事圣经、角色圣经、长篇记忆、分集计划、单集脚本和分镜、BullMQ 队列与任务状态流转、AI Provider 抽象层、真实 AI Provider 接入、真实图片/TTS/视频资产落库、Provider 成本阈值、worker 队列消费、TTS 音频、SRT 字幕、FFmpeg 视频合成、后台管理、用户端 H5 制作台、订单额度、内容审核、MVP 验收和 API 加密传输。后台管理已覆盖项目、任务、小说源、章节、角色资源、分镜资源、成品漫剧、订单额度、内容审核、Provider、成本、用户、素材、版权记录和审计日志,并补充资源详情/媒体预览、用户人工加余额、额度冲正、用户禁用/启用、改角色、重置密码和用户详情抽屉,高危用户操作带二次确认,后台接口按 admin/operator/finance/auditor 做细粒度权限校验。用户端已支持登录注册、项目创建、原创/上传入口、版权确认、故事/角色/记忆/分集/脚本/分镜/媒体生成、额度查看/冻结、内容审核、公开案例授权、进度查看和私有成品下载;当前内部测试不展示支付/套餐入口,额度由后台人工增加。AI Provider 已支持 OpenAI 真实调用,后台可配置 API Key、Base URL、模型、优先级、启停状态、单次成本上限和当日成本上限;API Key 使用服务端密钥加密落库,页面不回显明文。图片、TTS 和 OpenAI Sora 视频生产链路已能保存真实 Provider 返回的二进制素材,默认优先级仍可保持 mock,避免未确认成本前误消耗真实模型。 + +新手操作请先看 [OPERATION_GUIDE.md](./OPERATION_GUIDE.md),里面按用户端和后台分别说明从项目创建到成品视频、额度、审核、AI 接入和常见问题排查。产品内也已补充教程入口:后台左侧“使用教程”,用户端导航“教程”,并预留 `src/pages/help/tutorial` 独立页面。 + +## 目录结构 + +```text +backend/ NestJS API 服务 +admin/ Geeker-Admin 可接入的后台前端骨架 +user-app/ uni-app 路由预留、H5 优先的用户端制作台 +workers/ BullMQ / FFmpeg worker 入口 +deploy/ 本地依赖、Docker、Nginx、部署脚本预留 +docs/ 系统 A / B 需求和工程文档 +storage/ 本地 mock 存储目录 +``` + +## 本地启动 + +安装依赖: + +```bash +npm install +``` + +后端启动会自动加载根目录 `.env` 和 `backend/.env`;系统环境变量优先级最高。`PROVIDER_SECRET_KEY` 必须长期保持稳定,用于解密后台保存的 AI Provider Key。 + +启动后端空服务: + +```bash +npm run dev:backend +``` + +后端默认监听: + +```text +http://127.0.0.1:3000/api/health +``` + +启动后台骨架: + +```bash +npm run dev:admin +``` + +启动用户端 H5: + +```bash +npm run dev:user +``` + +## 验证命令 + +```bash +npm run lint +npm run typecheck +npm test +npm run build +``` + +## API 加密传输 + +阶段 23 已实现前后端应用层加密信封,并保留 HTTPS 作为生产必需底座。阶段 23 补充了后台配置开关:测试默认关闭,上线后可在后台“配置管理”手动开启。 + +实现范围: + +- 前端先通过 `GET /api/client-config` 读取 `api_crypto_enabled`,默认不开启加密。 +- 开关开启后,前端通过 `GET /api/crypto/handshake` 获取短期 ECDH 会话参数。 +- 前端和后端使用 `ECDH P-256 + HKDF-SHA256` 派生会话 AES key。 +- JSON API 请求体使用 `AES-256-GCM` 加密后发送。 +- JSON API 响应体、异常响应体使用 `AES-256-GCM` 加密后返回。 +- 用户端小说上传改为加密 JSON 文件 payload,不再裸传 multipart 正文。 +- 私有素材下载在加密请求下返回加密 JSON 文件 payload,前端解密后再生成 Blob。 +- 后台和用户端生产构建默认使用同源 `/api`;显式配置 `VITE_API_BASE_URL` 时生产环境禁止 `http://`。 +- 后台管理新增“配置管理”,可维护 `security.api_crypto_enabled`。 +- `API_CRYPTO_ENABLED=true/false` 可强制覆盖数据库配置;默认 `auto` 表示读取后台配置。 + +生产建议: + +```bash +NODE_ENV=production +HTTPS_REQUIRED=true +HTTPS_ALLOW_LOCAL_HTTP=false +TRUST_PROXY=true +CORS_ORIGINS=https://manga.example.com,https://admin.manga.example.com +API_CRYPTO_ENABLED=auto +API_CRYPTO_SESSION_TTL_SECONDS=900 +VITE_API_CRYPTO_ENABLED=auto +VITE_API_BASE_URL=/api +``` + +开启方式: + +1. 保持 `API_CRYPTO_ENABLED=auto` 和 `VITE_API_CRYPTO_ENABLED=auto`。 +2. 用管理员账号进入后台“配置管理”。 +3. 点击“开启 API 加密”,之后普通 API 会强制要求加密信封。 + +注意:应用层加密保护的是请求体和响应体内容。HTTP 方法、路径、域名和查询字符串不能被前端 JS 在应用层隐藏,所以生产环境仍必须使用 HTTPS,并应避免把敏感业务内容放进 query string。多实例部署时,当前内存态加密会话需要粘性会话或迁移到 Redis。 + +## 数据库 + +阶段 02 选择 Prisma 作为 MySQL 8 ORM。原因是当前系统 A 表数量多、JSON 字段多、后续迁移频繁,Prisma 的 schema、migration 和类型生成更适合分阶段落地。 + +核心文件: + +```text +backend/prisma/schema.prisma +backend/prisma/migrations/20260531093000_init_system_a/migration.sql +backend/prisma/seed.ts +``` + +常用命令: + +```bash +cp .env.example .env +DATABASE_URL="mysql://ai_manga:ai_manga_password@127.0.0.1:3306/ai_manga" npm run db:validate +DATABASE_URL="mysql://ai_manga:ai_manga_password@127.0.0.1:3306/ai_manga" npm run db:generate +DATABASE_URL="mysql://ai_manga:ai_manga_password@127.0.0.1:3306/ai_manga" npm run db:migrate +DATABASE_URL="mysql://ai_manga:ai_manga_password@127.0.0.1:3306/ai_manga" npm run db:deploy +DATABASE_URL="mysql://ai_manga:ai_manga_password@127.0.0.1:3306/ai_manga" npm run db:seed +``` + +`db:migrate` 用于开发环境生成/演进迁移,可能需要创建 shadow database 的权限。已有 migration 文件时,服务器环境优先使用 `db:deploy`。 + +## 认证接口 + +阶段 03 已实现基础 JWT 认证: + +```text +POST /api/auth/register +POST /api/auth/login +POST /api/auth/logout +GET /api/auth/profile +GET /api/profile +``` + +`/api/auth/profile` 和 `/api/profile` 都需要 `Authorization: Bearer `。 + +## 项目接口 + +阶段 05 已实现登录用户的项目创建、列表、详情、更新、取消和软删除流程: + +```text +POST /api/projects +GET /api/projects +GET /api/projects/:id +PATCH /api/projects/:id +POST /api/projects/:id/cancel +DELETE /api/projects/:id +``` + +要求: + +- 请求必须带 `Authorization: Bearer `。 +- `input_mode` 当前支持 `ai_original` 和 `upload`,`admin_import` 预留给后台导入。 +- 新项目默认进入 `source_selecting` 状态。 +- `DELETE /api/projects/:id` 当前为软删除,会把可删除项目归档为 `archived`。 + +## 文件上传 + +阶段 04 已实现私有资产上传。当前本机未启动 MinIO 时,默认使用本地 mock 私有存储;后续将 `STORAGE_DRIVER=minio` 并配置 `MINIO_*` 即可切换。 + +```text +POST /api/assets/upload +POST /api/projects/:projectId/novel/upload +GET /api/assets/:assetId +GET /api/assets/:assetId/download +``` + +要求: + +- 请求必须带 `Authorization: Bearer `。 +- 上传字段名为 `file`。 +- 小说上传当前支持 `txt`、`md`、`docx` 和文本型 `pdf`。 +- 上传资产默认 `visibility=private`。 +- 普通 asset 查询只返回内部 asset 信息,不返回原始文件公网地址。 +- `/api/assets/:assetId/download` 需要 Bearer Token,会校验资产归属后返回私有文件流,用户端成品下载和视频预览使用该接口。 + +## 小说解析与版权 + +阶段 06 已实现上传小说解析链路: + +```text +POST /api/projects/:projectId/copyright/confirm +GET /api/projects/:projectId/copyright +POST /api/projects/:projectId/novel/paste +POST /api/projects/:projectId/novel/parse +GET /api/projects/:projectId/novel/parse-result +PATCH /api/novel-chapters/:chapterId +``` + +要求: + +- 请求必须带 `Authorization: Bearer `。 +- 上传小说解析前必须先确认版权,否则 `/novel/parse` 返回 400。 +- `authorization_type` 支持 `author_self`、`licensed`、`public_domain`、`internal_test`。 +- `/novel/parse` 支持从已上传 asset 解析,也支持从粘贴文本 source 解析。 +- 解析会清洗常见广告/水印行,识别章节标题,失败时按字数切分。 +- 解析结果写入 `novel_sources` 和 `novel_chapters`。 +- 章节可手动编辑,编辑后状态标记为 `edited`。 + +## AI 原创小说 Mock + +阶段 07 已实现 AI 原创小说 mock 链路: + +```text +POST /api/projects/:projectId/original/idea +POST /api/projects/:projectId/original/outline +POST /api/projects/:projectId/original/chapters +POST /api/projects/:projectId/original/self-check +GET /api/projects/:projectId/original/result +``` + +要求: + +- 请求必须带 `Authorization: Bearer `。 +- 项目 `input_mode` 必须为 `ai_original`,上传小说项目调用原创接口会返回 400。 +- 当前实现为 deterministic mock,`parse_report.provider=mock_novel_provider`,不调用真实 AI Provider。 +- 生成内容写入 `novel_sources` 和 `novel_chapters`。 +- 默认按项目 `target_episode_count` 生成章节,MVP 场景通常为 3 章。 +- 自检覆盖主角一致性、主线、冲突、可视化摘要和短视频钩子。 + +## 故事圣经 + +阶段 08 已实现故事圣经链路: + +```text +POST /api/projects/:projectId/story-bible/generate +GET /api/projects/:projectId/story-bible +PATCH /api/projects/:projectId/story-bible +POST /api/projects/:projectId/story-bible/confirm +``` + +要求: + +- 请求必须带 `Authorization: Bearer `。 +- 生成前必须已有小说来源和章节,支持上传解析链路和 AI 原创 mock 链路。 +- 生成结果写入 `story_bibles`,状态为 `waiting_confirm`。 +- 编辑故事圣经会创建新版本,不覆盖旧版本。 +- 确认后故事圣经状态变为 `confirmed`,项目状态变为 `story_confirmed`。 +- `GET /story-bible?version=2` 可查询指定版本;不传 version 返回最新版本和版本列表。 + +## 角色圣经 + +阶段 09 已实现角色圣经链路: + +```text +POST /api/projects/:projectId/characters/extract +GET /api/projects/:projectId/characters +POST /api/projects/:projectId/characters +POST /api/projects/:projectId/characters/confirm +PATCH /api/characters/:characterId +DELETE /api/characters/:characterId +``` + +要求: + +- 请求必须带 `Authorization: Bearer `。 +- 抽取角色前必须已有已确认故事圣经,否则 `/characters/extract` 返回 400。 +- 角色抽取当前为 deterministic mock,从已确认故事圣经和小说章节生成主角、反派、配角等角色草稿。 +- 角色数据写入 `characters` 表,支持列表、手动新增、编辑和软删除。 +- 确认角色库会把 `draft`、`generated`、`edited` 状态角色锁定为 `locked`,项目状态变为 `character_confirmed`。 +- `locked` 角色不可修改姓名、角色类型、性别、年龄、身份和核心外观字段;仍可补充服装规则、表情风格等非核心描述。 +- 本阶段不生成角色图片、不生成 anchor 图,也不接入 ImageProvider 或队列。 + +## 长篇记忆 + +阶段 10 已实现长篇记忆链路: + +```text +GET /api/projects/:projectId/plot-memories +POST /api/projects/:projectId/plot-memories/generate +POST /api/projects/:projectId/plot-memories +PATCH /api/plot-memories/:memoryId +GET /api/projects/:projectId/memory-context?episode_no=5 +GET /api/characters/:characterId/memories +GET /api/projects/:projectId/plot-threads +POST /api/projects/:projectId/plot-threads +PATCH /api/plot-threads/:threadId +POST /api/episodes/:episodeId/continuity-check +``` + +要求: + +- 请求必须带 `Authorization: Bearer `。 +- 生成长篇记忆前必须已有 confirmed 故事圣经和 locked 角色库。 +- 记忆生成当前为 deterministic mock,会写入 `plot_memories`、`plot_threads` 和 `character_memories`。 +- 剧情记忆支持手动新增、标记 resolved/archived、按类型/状态/分集筛选。 +- 剧情线支持新增、查询、更新状态,类型覆盖主线、反派计划、悬疑线、角色成长线等。 +- `GET /memory-context?episode_no=N` 会返回故事圣经、锁定角色、活跃剧情记忆、开放剧情线、前 3 集摘要和上一集结尾钩子,供后续分集/脚本生成使用。 +- locked 角色的非核心资料补充会自动记录 `character_memories.profile_adjustment`。 +- 连续性检查当前为规则版 mock,可发现角色未承接、伏笔未推进、上一集钩子未承接、缺少结尾钩子、开放剧情线未推进和明显破坏世界观的内容。 +- 本阶段不接入 EmbeddingProvider、不做向量检索、不进队列,也不调用真实 AI Provider。 + +## 分集计划 + +阶段 11 已实现分集计划链路: + +```text +POST /api/projects/:projectId/episodes/generate-plan +GET /api/projects/:projectId/episodes +PATCH /api/episodes/:episodeId +POST /api/projects/:projectId/episodes/confirm +``` + +要求: + +- 请求必须带 `Authorization: Bearer `。 +- 生成分集前必须已有 confirmed 故事圣经、locked 角色库、小说章节和 active 长篇记忆。 +- 分集生成当前为 deterministic mock,会写入 `episodes` 表。 +- 每集包含标题、剧情摘要、开头钩子、中段冲突、结尾悬念、关联章节和预计时长。 +- 生成时项目状态流转为 `episode_planning`,完成后为 `waiting_episode_confirm`。 +- 分集可在确认前编辑,编辑后状态为 `edited`。 +- 确认分集会把 `draft`、`generated`、`edited` 状态分集锁定为 `confirmed`,项目状态变为 `episode_confirmed`。 +- 已确认分集不可继续编辑;如需重做,后续会通过返工/修改申请流程处理。 +- 本阶段不生成单集脚本、不生成分镜、不进队列,也不调用真实 AI Provider。 + +## 脚本和分镜 + +阶段 12 已实现单集脚本和分镜链路: + +```text +POST /api/episodes/:episodeId/script/generate +GET /api/episodes/:episodeId/script +PATCH /api/episodes/:episodeId/script +POST /api/episodes/:episodeId/script/confirm +POST /api/episodes/:episodeId/storyboard/generate +GET /api/episodes/:episodeId/storyboard +PATCH /api/storyboard-shots/:shotId +DELETE /api/storyboard-shots/:shotId +POST /api/episodes/:episodeId/storyboard/confirm +POST /api/storyboard-shots/:shotId/regenerate-prompt +``` + +要求: + +- 请求必须带 `Authorization: Bearer `。 +- 生成单集脚本前必须已有 confirmed 分集、confirmed 故事圣经和 locked 角色库。 +- 单集脚本当前为 deterministic mock,会写入 `episode_scripts`,包含脚本文本、旁白和结构化台词。 +- 脚本生成后项目状态进入 `waiting_script_confirm`;确认后脚本状态为 `confirmed`,项目状态为 `script_confirmed`。 +- 生成分镜前必须已有 confirmed 单集脚本。 +- 分镜生成当前为 deterministic mock,会写入 `storyboard_shots`,默认每集 10 个镜头。 +- 每个镜头包含画面描述、人物、场景、动作、台词/旁白、镜头运动、特效、2-5 秒时长、Prompt 和负面 Prompt。 +- Prompt 会带入角色固定描述,并包含防混脸、年龄/发色漂移、复杂多人镜头等负面约束。 +- 分镜可在确认前编辑、删除和重生 Prompt;确认后镜头状态为 `confirmed`,项目状态为 `storyboard_confirmed`。 +- 本阶段不生成分镜图片、不接入 ImageProvider、不进队列,也不调用真实 AI Provider。 + +## BullMQ 队列 + +阶段 13 已实现任务记录、幂等入队、管理员重试/取消/人工介入和队列监控: + +```text +POST /api/projects/:projectId/tasks +GET /api/projects/:projectId/tasks +GET /api/tasks/:taskId +GET /api/admin/tasks +POST /api/admin/tasks/recover-stale +POST /api/admin/tasks/:taskId/retry +POST /api/admin/tasks/:taskId/cancel +POST /api/admin/tasks/:taskId/manual-required +GET /api/admin/queues +``` + +要求: + +- 请求必须带 `Authorization: Bearer `。 +- 普通用户只能操作自己的项目任务,admin 可查看和管理全部任务。 +- 创建任务会先写入 `render_tasks`,再尝试入 BullMQ;Redis 或 BullMQ 不可用时,任务仍保留为 `pending` / `retrying`,接口返回 `queue_backend=bullmq_unavailable`。 +- 幂等 key 默认由 `project_id + episode_id + shot_id + task_type + input_hash` 组成,`input_hash` 基于稳定 JSON 序列化生成。 +- 已支持 novel、parse、story、character、episode、script、storyboard、image、audio、subtitle、video、qc、review、analytics 等队列映射。 +- 默认重试次数按任务类型区分:文本类 2 次、图片类 3 次、视频 2 次、TTS/字幕 2 次、QC 1 次、人工审核 0 次。 +- 管理员可把失败任务重试为 `retrying`,可取消任务为 `cancelled`,可标记为 `manual_required`,可恢复过久未完成的 `running` / `retrying` 任务。 +- `/api/admin/queues` 会返回 BullMQ 每个队列的 waiting、active、delayed、failed、completed、paused 计数。 +- worker 包已接入 BullMQ 消费器,会订阅全部队列并调用后端内部接口 `POST /api/internal/worker/tasks/:taskId/execute`,通过 `WORKER_SECRET` 鉴权。 +- worker 执行失败时后端会按 `max_retry` 自动重入队;达到上限后把任务转为 `manual_required`,后台任务页可继续人工处理。 +- worker 当前执行的是通用 Provider 任务委托:按任务类型映射到 Text/Novel/Image/Voice/Video/Moderation/QC/FileParse Provider;图片、音频、视频资产生成的业务接口仍保留同步链路。 + +## AI Provider 抽象 + +阶段 14 已实现 mock-first Provider 抽象、管理员配置入口、执行日志和成本聚合;阶段 21 已补充 OpenAI / OpenAI 兼容真实驱动: + +```text +GET /api/admin/providers +POST /api/admin/providers/bootstrap-mocks +POST /api/admin/providers/bootstrap-openai +POST /api/admin/providers/bootstrap-video +POST /api/admin/providers/execute +PATCH /api/admin/providers/:providerId +PATCH /api/admin/providers/openai/runtime-config +PATCH /api/admin/providers/:providerId/runtime-config +POST /api/admin/providers/:providerId/test +GET /api/admin/provider-logs +GET /api/admin/costs +``` + +要求: + +- 请求必须带 `Authorization: Bearer `;后台接口按 RBAC 权限校验,admin 拥有全部权限,operator/finance/auditor 只开放对应读写范围。 +- 已声明 `TextProvider`、`NovelProvider`、`ImageProvider`、`VideoProvider`、`VoiceProvider`、`ModerationProvider`、`QualityCheckProvider`、`FileParseProvider`、`EmbeddingProvider`。 +- `/bootstrap-mocks` 会写入或更新 9 个默认 mock provider 配置。 +- `/bootstrap-openai` 会写入或更新 OpenAI real provider 配置,包含 Responses、Moderation、Embeddings、Image Generation、Sora Video 和 Text to Speech 驱动。 +- 后台默认显示“OpenAI 统一接入”:运营只需填写一个 OpenAI API Key,`PATCH /admin/providers/openai/runtime-config` 会批量应用到全部 OpenAI 能力;未勾选“生产任务优先使用 OpenAI”时会把 OpenAI 优先级保持为 50,低于 mock,避免保存 Key 后立刻消耗真实额度。 +- `GET /admin/providers/openai/connection-check` 只请求 OpenAI `/models` 检查 Key/网络,不生成文本、图片、语音或视频,不写 `provider_logs`。 +- `/execute` 会从 `provider_configs` 选择启用 provider,执行 mock 或 real driver,写入 `provider_logs`。 +- 如果传入 `task_id`,执行时会把对应 `render_tasks` 标记为 `running`,成功后回写 `success`、`provider_id`、`provider_request_id`、`cost_estimate` 和 `cost_actual`。 +- primary provider 失败时会按 fallback provider 或同类型优先级继续尝试,并记录失败日志。 +- provider 输入、配置输出和日志输出会拒绝/脱敏 `api_key`、`secret`、`token`、`password`、`credential` 等疑似密钥字段;后台运行配置接口会把 API Key 加密为 `api_key_secure` 后保存,列表只显示已配置状态。 +- 真实 Provider 优先读取后台加密保存的 API Key;未配置时仍可回退读取 `OPENAI_API_KEY`。后台可配置 `base_url` 接入 OpenAI 兼容服务,也可修改模型名、优先级和启停状态。`PROVIDER_SECRET_KEY` 用于加密后台保存的 Provider 密钥,未设置时回退 `JWT_SECRET`;生产环境必须保持该值稳定,否则已保存 Key 无法解密,需要重新保存。 +- 重复点击“初始化 OpenAI 接入”会刷新默认 Provider 定义,但会保留后台已加密保存的 API Key、Base URL、超时和成本阈值,避免误清空线上配置。 +- 后台真实 Provider 的“付费测试”必须二次确认,后端 `/admin/providers/:providerId/test` 也要求 `confirm_paid_test=true`;真实视频 Provider 测试接口默认禁用,避免误触发高成本视频任务。 +- Provider 可配置 `max_cost_per_call` 和 `daily_cost_limit` 成本阈值,也可通过 `PROVIDER_MAX_COST_PER_CALL`、`PROVIDER_DAILY_COST_LIMIT` 设置全局保护;超过阈值会在调用前拦截并写失败日志。 +- `openai_image_generation`、`openai_video_generation` 和 `openai_audio_speech` 的日志只保存 URL/大小/hash 等元数据,不把 base64 图片、视频或音频字节写入 `provider_logs`;真实二进制只在业务生成链路中短暂返回,用于立即写入私有素材。 +- OpenAI 图片、Sora 视频和 TTS real provider 默认优先级低于 mock;后台可提高优先级或禁用 mock 后让业务生产链路保存真实图片、真实视频或 TTS 音频资产。 +- `openai_video_generation` 按 OpenAI Videos API 的异步流程执行:创建视频任务、轮询完成状态,业务链路需要二进制时再下载 MP4 并落库。可用 `OPENAI_VIDEO_MODEL` 覆盖默认 `sora-2`。 +- 真人短剧链路已新增可替换 image-to-video Provider:`minimax_hailuo_23_fast`、`minimax_hailuo_23`、`alibaba_wan26_i2v_flash`、`alibaba_wan26_i2v`、`vidu_q3_turbo_reference`、`vidu_q3_pro`、`jimeng_seedance`、`runway-image-to-video`、`kling-image-to-video`。真实视频 Provider 默认禁用,用户端真实视频生成必须传 `confirm_real_video=true`,并受单片段成本上限、Provider 单次/每日成本阈值保护。 +- `configurable_image_to_video` 驱动支持通用异步图生视频流程:创建任务、轮询任务、提取视频 URL;如果供应商只返回 `file_id`,可通过 `output_url_endpoint_template` 再取下载链接。MiniMax Hailuo、阿里 Wan、Vidu 和 Seedance 预设都走这类可配置模板,正式启用前应先用 1 个镜头小样核对字段、速度、质量和账单。 +- `VideoProvider` 成本规则支持 `unit=video_seconds`、`price_per_second`、`price_per_clip`。后台配置真实视频价格后,用户端真人短剧面板会显示片段级成本预估。 + +## 图片生成 Mock + +阶段 15 已实现角色图、锚点图、分镜预览图和正式图的 mock 生成: + +```text +POST /api/characters/:characterId/generate-images +GET /api/characters/:characterId/images +POST /api/characters/:characterId/set-anchor +POST /api/storyboard-shots/:shotId/images/generate +GET /api/storyboard-shots/:shotId/images +POST /api/episodes/:episodeId/shot-images/generate +``` + +要求: + +- 请求必须带 `Authorization: Bearer `。 +- 角色图生成要求角色已 `locked`。 +- 默认生成 `front_reference`、`anchor`、`expression_pack`,也可指定 `image_types`。 +- 角色锚点会写入 `character_images.is_anchor=true`,并回写 `characters.anchor_asset_id`。 +- 分镜图生成要求 `storyboard_shots.status=confirmed`。 +- 分镜图 Prompt 会引用角色固定描述和 `anchor_asset_id`,降低混脸和漂移风险。 +- 图片生成会创建 `render_tasks`,通过 `ImageProvider` 执行,写入 `provider_logs`,并保存本地/MinIO 私有图片资产。 +- 生成结果写入 `character_images` 或 `shot_images`,图片资产写入 `assets`,`visibility=private`。 +- 当 Provider 返回 `content_base64` 或可下载的 HTTP(S) `asset_url` 时,会保存真实位图;否则回退生成 SVG mock 占位图。图片任务会回填 `render_tasks.output_asset_id`,方便后台追踪产物。 + +## TTS / 字幕 / FFmpeg + +阶段 16 已实现单集音频、字幕和 FFmpeg 视频合成链路: + +```text +POST /api/episodes/:episodeId/audio/generate +POST /api/episodes/:episodeId/subtitle/generate +POST /api/episodes/:episodeId/video/render +GET /api/episodes/:episodeId/media-assets +``` + +要求: + +- 请求必须带 `Authorization: Bearer `。 +- 音频生成要求已有 `confirmed` 单集脚本,会调用 `VoiceProvider`,写入 `render_tasks`、`provider_logs` 和私有音频资产;真实 TTS 返回 `content_base64` 或可下载 URL 时保存真实音频,否则回退静音 WAV。 +- 字幕生成要求已有 `confirmed` 分镜,会按镜头时长生成 SRT cues,写入本地私有 `.srt` 资产。 +- 视频渲染要求已有 `confirmed` 分镜和已生成的分镜图;默认会复用或自动生成最新音频和字幕。 +- 视频渲染要求项目已支付或冻结额度,否则会返回错误;阶段 19 用户端会在合成前调用额度冻结。 +- 视频渲染会调用 `VideoProvider` 记录 provider 执行日志;若启用 `openai-video`,后端会创建 Sora 视频任务、轮询完成并下载 MP4,随后直接保存 Provider 渲染出的 MP4;若 Provider 未返回二进制,则读取私有分镜图、音频和 SRT 字幕,使用 FFmpeg 生成本地私有 MP4 资产。 +- 默认优先使用 FFmpeg,成功时返回 `ffmpeg_used=true` 和 `render_backend=ffmpeg`;传入 `prefer_ffmpeg=false` 或本机缺少 FFmpeg 时会走 mock fallback。 +- 生成结果写入 `assets`,asset_type 分别为 `audio`、`subtitle`、`video`,`visibility=private`。 +- 当前不生成真实 BGM;图片、TTS 和 Sora 视频可切换真实 Provider 落库,视频也可保留 FFmpeg 本地合成,音频、字幕和视频目前仍为同步接口执行,worker 消费器后续接入。 + +## 后台管理 + +阶段 17 已实现后台管理 API 和可用的 Vue/Vite 管理端: + +```text +GET /api/admin/dashboard +GET /api/admin/rbac/me +GET /api/admin/projects +GET /api/admin/projects/:projectId +PATCH /api/admin/projects/:projectId/status +GET /api/admin/users +GET /api/admin/assets +GET /api/admin/novel-sources +GET /api/admin/novel-chapters +GET /api/admin/characters +GET /api/admin/storyboard-shots +GET /api/admin/works +GET /api/admin/copyright-records +GET /api/admin/operation-logs +GET /api/admin/operation-logs/export +``` + +已接入已有管理接口: + +```text +GET /api/admin/tasks +POST /api/admin/tasks/:taskId/retry +POST /api/admin/tasks/:taskId/cancel +POST /api/admin/tasks/:taskId/manual-required +GET /api/admin/queues +GET /api/admin/providers +POST /api/admin/providers/bootstrap-mocks +GET /api/admin/provider-logs +GET /api/admin/costs +GET /api/admin/orders +GET /api/admin/quota-accounts +GET /api/admin/users/:userId/detail +POST /api/admin/users/:userId/quota/grant +GET /api/admin/content-reviews +PATCH /api/admin/content-reviews/:reviewId +GET /api/admin/case-showcases +PATCH /api/admin/case-showcases/:showcaseId +GET /api/admin/system-configs +PATCH /api/admin/system-configs/:configKey +``` + +要求: + +- 请求必须带 `Authorization: Bearer `;后台支持 `admin`、`operator`、`finance`、`auditor` 角色,接口按 `projects/users/billing/reviews/tasks/providers/costs/settings/audit` 等权限校验。 +- `npm run db:seed` 会创建或更新本地 `admin@example.com`,默认开发密码为 `Admin123!`,可用 `SEED_ADMIN_PASSWORD` 覆盖。 +- 管理端默认运行在 `http://127.0.0.1:5175`,API 默认指向 `http://127.0.0.1:3000/api`,可用 `VITE_API_BASE_URL` 覆盖。 +- 当前后台已支持登录、仪表盘、项目管理、项目详情、项目状态调整、小说源/章节管理、角色资源、分镜资源、成品漫剧、订单额度、内容审核、公开案例授权、任务重试/取消/转人工、队列统计、Provider 配置、Provider 成本阈值、成本日志、用户列表、用户详情抽屉、用户人工加余额、额度冲正、禁用/启用用户、改角色、重置密码、素材列表、版权记录、系统配置管理和审计日志导出;小说、章节、角色、分镜、素材和成品资源支持详情/预览,高危用户操作在页面上会弹出二次确认。 +- 模板管理深水区留到后续对应阶段继续扩展。 + +## 订单额度 + +阶段 19 已实现订单、额度账户和项目正式生成前冻结/扣减: + +```text +GET /api/billing/packages +GET /api/billing/quota +GET /api/billing/quota/logs +GET /api/billing/orders +POST /api/billing/orders +POST /api/billing/orders/:orderId/mock-pay +GET /api/projects/:projectId/quota/estimate +POST /api/projects/:projectId/quota/freeze +POST /api/projects/:projectId/quota/release +``` + +要求: + +- `/api/billing/packages` 可公开查看,其他用户额度接口需要 Bearer Token。 +- 当前为内部测试额度模式:用户端不展示支付/套餐入口,运营在后台用户管理中人工增加额度。历史 mock 支付接口仍保留用于接口回归,不作为当前用户端入口。 +- 项目额度预估按输入模式、目标集数和默认每集 6 个镜头估算。 +- `quota/freeze` 会扣减可用额度、增加冻结额度,并把项目 `payment_status` 标记为 `quota_frozen`。 +- 视频合成成功后会把冻结额度扣为已用额度,并把项目 `payment_status` 标记为 `paid`。 +- 管理端新增“订单额度”页,可查看订单和额度账户;用户管理页支持管理员手动给用户增加余额/额度和执行额度冲正,并写入额度流水和操作日志,用户详情抽屉可查看额度流水、订单、项目、素材和最近操作。 + +## 内容审核 + +阶段 20 已实现文本、素材、成品视频和公开案例授权的审核闭环: + +```text +POST /api/projects/:projectId/reviews/text +GET /api/projects/:projectId/reviews +POST /api/assets/:assetId/review +POST /api/projects/:projectId/showcase/authorize +GET /api/projects/:projectId/showcase +GET /api/admin/content-reviews +PATCH /api/admin/content-reviews/:reviewId +GET /api/admin/case-showcases +PATCH /api/admin/case-showcases/:showcaseId +``` + +要求: + +- 用户接口必须带 Bearer Token,并校验项目或素材归属。 +- 文本审核会聚合项目中的小说、故事圣经、角色、分集、脚本和分镜文本,也支持请求体直接传入 `content`。 +- 素材审核覆盖 image、video、audio、subtitle 和 document/text 类资产;用户端当前接入文本审核和成品视频审核按钮。 +- 审核调用现有 `ModerationProvider`;未初始化真实 Provider 时使用 mock,初始化 OpenAI 接入并在后台保存 API Key 后会优先调用 `openai-moderation`,失败时回退 mock。 +- 需要人工处理的审核会把项目状态标记为 `manual_required`,后台可执行通过、修改、驳回、屏蔽和转人工。 +- 用户端可提交公开案例授权,后台可将案例授权、发布为 public 或驳回。 +- 当前不接入真实内容安全平台,不做真实版权库比对;商业发布前仍需人工确认版权授权、平台规则和内容合规。 +- mock moderation 会识别“不得/禁止/避免违法内容”这类安全规则提示,不再把合规约束本身误判为风险文本。 + +## MVP 验收 + +阶段 22 已完成系统 A MVP 端到端验收: + +- AI 原创小说链路:原创构思、章节、故事圣经、角色、角色锚点、长篇记忆、3 集分集、脚本、分镜、正式分镜图、音频、字幕、FFmpeg MP4、私有下载、视频审核和案例授权。 +- 上传小说链路:TXT 上传、版权确认、小说解析、故事圣经、角色、角色锚点、长篇记忆、1 集分集、脚本、分镜、正式分镜图、音频、字幕、FFmpeg MP4、私有下载、视频审核和案例授权。 +- 验收用户 `mvp-1780243409631@example.com`,原创项目 ID `28`,上传项目 ID `29`。 +- 原创 3 个 MP4 与上传 1 个 MP4 均通过私有下载校验,返回 `video/mp4`,文件大小均大于 300 KB。 +- 额度流程通过 mock 支付、项目冻结、视频成功后扣减;验收后账户 `used_quota=196`、`available_quota=1004`。 +- 验收时发现并修复 mock 文本审核误伤安全规则提示的问题;复审记录 `13`、`14` 均为 `passed`。 + +## 用户端 H5 + +阶段 18 已实现 H5 优先、PC 自适应的用户端制作台,并保留 uni-app `pages.json`、`manifest.json` 和页面路由文件,后续微信小程序/App 可继续迁移: + +```text +登录 / 注册 +新建项目 +我的项目 +制作台 +生成进度 +成品漫剧 +用户中心 +``` + +要求: + +- 用户端 API 可用 `VITE_API_BASE_URL` 覆盖;未配置时,本机访问会请求 `http://127.0.0.1:3000/api`,外网 IP/域名访问会自动请求同主机的 `:3000/api`。 +- 用户端默认运行在 `http://127.0.0.1:5174`。 +- 公网调试访问需要服务器防火墙/安全组放行 TCP `5174` 和 `3000`;当前本机 firewalld 已放行这两个端口。 +- H5 为主布局,移动端底部导航和单列长表单优先;PC 宽屏自动切换为左侧导航和两栏制作台。 +- 当前用户端已接入真实 API:认证、项目列表/创建、AI 原创小说、上传小说粘贴/文件入口、版权确认、故事圣经、角色库、长篇记忆、分集计划、单集脚本、分镜、分镜图、音频、字幕、FFmpeg 视频合成、任务进度、私有视频预览和下载。 +- 阶段 19 已接入额度中心;当前用户端隐藏套餐、模拟支付和订单入口,仅展示额度账户、项目额度预估和合成前冻结额度。 +- 阶段 20 已接入内容审核中心:项目文本审核、成品视频审核、审核状态列表和公开案例授权。 +- 微信小程序/App 的文件选择、下载保存、支付能力、分享能力留到后续平台适配阶段;当前内部测试不开放用户端支付入口,图片/TTS 生产链路可切换真实 Provider,视频默认仍可用 FFmpeg 本地合成。 + +## 开发约束 + +- 按 `AGENTS.md` 和 `docs/system_a/21_Codex开发任务拆解文档.md` 分阶段开发。 +- 真实 AI Provider 已接入执行层;原创小说、故事圣经、角色、记忆、分集、脚本、分镜等 deterministic 生成服务后续可逐步迁移到统一 Provider 调用。 +- 密钥只放 `.env`,不要提交真实密钥。 +- 用户上传小说和素材默认私有。 +- 每个阶段完成后更新 `CODEX_PROGRESS.md`。 diff --git a/admin/index.html b/admin/index.html new file mode 100644 index 0000000..ef424f0 --- /dev/null +++ b/admin/index.html @@ -0,0 +1,12 @@ + + + + + + AI Manga Admin + + +
+ + + diff --git a/admin/package.json b/admin/package.json new file mode 100644 index 0000000..5959348 --- /dev/null +++ b/admin/package.json @@ -0,0 +1,15 @@ +{ + "name": "admin", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "vite --host 0.0.0.0 --port ${ADMIN_PORT:-5173}", + "build": "vue-tsc --noEmit -p tsconfig.json && vite build", + "lint": "vue-tsc --noEmit -p tsconfig.json", + "typecheck": "vue-tsc --noEmit -p tsconfig.json", + "test": "vitest run --passWithNoTests" + }, + "dependencies": { + "vue": "^3.5.16" + } +} diff --git a/admin/src/App.vue b/admin/src/App.vue new file mode 100644 index 0000000..3d71380 --- /dev/null +++ b/admin/src/App.vue @@ -0,0 +1,6264 @@ + + + diff --git a/admin/src/api/.gitkeep b/admin/src/api/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/admin/src/api/.gitkeep @@ -0,0 +1 @@ + diff --git a/admin/src/api/client.ts b/admin/src/api/client.ts new file mode 100644 index 0000000..149787b --- /dev/null +++ b/admin/src/api/client.ts @@ -0,0 +1,175 @@ +import { ApiCryptoClient, base64ToBytes } from './crypto'; + +export interface ApiEnvelope { + code: number; + message: string; + data: T; + request_id: string; +} + +export interface AuthResult { + access_token: string; + token_type: 'Bearer'; + expires_in: string; + user: AdminUser; +} + +export interface AdminUser { + id: string; + email: string | null; + nickname: string | null; + role: string; + status: string; + created_at: string; +} + +function trimTrailingSlash(value: string) { + return value.replace(/\/+$/, ''); +} + +function assertSecureProductionApiUrl(value: string) { + if (import.meta.env.PROD && /^http:\/\//i.test(value)) { + throw new Error('生产环境 VITE_API_BASE_URL 必须使用 HTTPS,或使用同源 /api。'); + } + + return value; +} + +function resolveApiBaseUrl() { + const configured = (import.meta.env.VITE_API_BASE_URL as string | undefined)?.trim(); + + if (configured) { + return assertSecureProductionApiUrl(trimTrailingSlash(configured)); + } + + if (import.meta.env.PROD) { + return '/api'; + } + + if (typeof window !== 'undefined') { + const { protocol, hostname, origin } = window.location; + + if (protocol === 'https:') { + return `${origin}/api`; + } + + if (hostname && hostname !== 'localhost' && hostname !== '127.0.0.1') { + return `${protocol}//${hostname}:3000/api`; + } + } + + return 'http://127.0.0.1:3000/api'; +} + +const API_BASE_URL = resolveApiBaseUrl(); +const apiCrypto = new ApiCryptoClient(API_BASE_URL); + +export class ApiClient { + constructor(private token: string | null) {} + + setToken(token: string | null) { + this.token = token; + } + + async get(path: string) { + return this.request(path); + } + + async post(path: string, body?: unknown) { + return this.request(path, { + method: 'POST', + body: body === undefined ? undefined : JSON.stringify(body) + }); + } + + async patch(path: string, body?: unknown) { + return this.request(path, { + method: 'PATCH', + body: body === undefined ? undefined : JSON.stringify(body) + }); + } + + async downloadAssetBlob(assetId: string) { + const response = await fetch(`${API_BASE_URL}/assets/${assetId}/download`, { + headers: { + ...(await apiCrypto.encryptionHeaders()), + ...(this.token ? { authorization: `Bearer ${this.token}` } : {}) + } + }); + const contentType = response.headers.get('content-type') ?? ''; + + if (contentType.includes('application/json')) { + const text = await response.text(); + const rawPayload = text ? (JSON.parse(text) as unknown) : null; + const payload = rawPayload + ? await apiCrypto.decryptResponse< + ApiEnvelope<{ + filename: string; + mime_type: string; + size: number; + content_base64: string; + }> + >(rawPayload) + : null; + + if (!response.ok || !payload || payload.code !== 0) { + throw new Error(payload?.message || `下载失败:HTTP ${response.status}`); + } + + const bytes = base64ToBytes(payload.data.content_base64); + const blobPart = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + + return { + blob: new Blob([blobPart], { type: payload.data.mime_type || 'application/octet-stream' }), + filename: payload.data.filename || `asset-${assetId}` + }; + } + + if (!response.ok) { + throw new Error(`下载失败:HTTP ${response.status}`); + } + + const blob = await response.blob(); + const disposition = response.headers.get('content-disposition') ?? ''; + const filename = /filename="([^"]+)"/.exec(disposition)?.[1] ?? `asset-${assetId}`; + + return { blob, filename }; + } + + private async request(path: string, init: RequestInit = {}) { + const method = (init.method ?? 'GET').toUpperCase(); + const shouldSendEmptyJsonBody = + init.body === undefined && method !== 'GET' && method !== 'HEAD'; + const bodyPayload = + typeof init.body === 'string' + ? (JSON.parse(init.body) as unknown) + : shouldSendEmptyJsonBody + ? {} + : undefined; + const encryptedBody = + bodyPayload !== undefined ? await apiCrypto.encryptBody(bodyPayload) : null; + const encryptedHeaders = encryptedBody?.headers ?? (await apiCrypto.encryptionHeaders()); + const response = await fetch(`${API_BASE_URL}${path}`, { + ...init, + body: encryptedBody ? JSON.stringify(encryptedBody.body) : init.body, + headers: { + ...encryptedHeaders, + 'content-type': 'application/json', + ...(this.token ? { authorization: `Bearer ${this.token}` } : {}), + ...(init.headers ?? {}) + } + }); + const rawPayload = (await response.json().catch(() => null)) as unknown; + const payload = rawPayload + ? await apiCrypto.decryptResponse>(rawPayload) + : null; + + if (!response.ok || !payload || payload.code !== 0) { + throw new Error(payload?.message || `Request failed: ${response.status}`); + } + + return payload.data; + } +} + +export const apiBaseUrl = API_BASE_URL; diff --git a/admin/src/api/crypto.ts b/admin/src/api/crypto.ts new file mode 100644 index 0000000..91b57e6 --- /dev/null +++ b/admin/src/api/crypto.ts @@ -0,0 +1,294 @@ +interface ApiEnvelope { + code: number; + message: string; + data: T; + request_id: string; +} + +interface ApiCryptoHandshake { + version: number; + algorithm: string; + session_id: string; + server_public_key: JsonWebKey; + salt: string; + expires_at: string; +} + +interface ClientConfig { + api_crypto_enabled: boolean; + api_crypto_mode: string; + api_crypto_session_ttl_seconds: number; +} + +interface ApiCryptoEnvelope { + encrypted?: boolean; + version: number; + session_id: string; + client_public_key?: JsonWebKey; + iv: string; + ciphertext: string; +} + +interface ApiCryptoSession { + sessionId: string; + clientPublicKey: JsonWebKey; + aesKey: CryptoKey; + expiresAt: number; +} + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +function getWebCrypto() { + if (!globalThis.crypto?.subtle) { + throw new Error('当前浏览器不支持 API 加密所需的 WebCrypto。'); + } + + return globalThis.crypto; +} + +function bytesToBinary(bytes: Uint8Array) { + let binary = ''; + const chunkSize = 0x8000; + + for (let index = 0; index < bytes.length; index += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize)); + } + + return binary; +} + +function binaryToBytes(value: string) { + const bytes = new Uint8Array(value.length); + + for (let index = 0; index < value.length; index += 1) { + bytes[index] = value.charCodeAt(index); + } + + return bytes; +} + +function bytesToBase64Url(bytes: Uint8Array) { + return btoa(bytesToBinary(bytes)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); +} + +function base64UrlToBytes(value: string) { + const normalized = value.replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '='); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + + return bytes; +} + +export function base64ToBytes(value: string) { + return binaryToBytes(atob(value)); +} + +function jsonToBase64Url(value: unknown) { + return bytesToBase64Url(encoder.encode(JSON.stringify(value))); +} + +function isEncryptedEnvelope(value: unknown): value is ApiCryptoEnvelope { + if (typeof value !== 'object' || value === null) return false; + const record = value as Record; + + return ( + record.version === 1 && + typeof record.session_id === 'string' && + typeof record.iv === 'string' && + typeof record.ciphertext === 'string' + ); +} + +export class ApiCryptoClient { + private session: ApiCryptoSession | null = null; + private pendingSession: Promise | null = null; + private enabledCache: { value: boolean; expiresAt: number } | null = null; + + constructor(private readonly baseUrl: string) {} + + async encryptionHeaders() { + if (!(await this.isEnabled())) { + return {}; + } + + const session = await this.getSession(); + return this.buildHeaders(session); + } + + async encryptBody(body: unknown) { + if (!(await this.isEnabled())) { + return null; + } + + const session = await this.getSession(); + const crypto = getWebCrypto(); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + session.aesKey, + encoder.encode(JSON.stringify(body ?? null)) + ); + + return { + body: { + version: 1, + session_id: session.sessionId, + client_public_key: session.clientPublicKey, + iv: bytesToBase64Url(iv), + ciphertext: bytesToBase64Url(new Uint8Array(ciphertext)) + }, + headers: this.buildHeaders(session) + }; + } + + async isEnabled() { + const mode = this.configuredMode(); + + if (['1', 'true', 'yes', 'on'].includes(mode)) return true; + if (['0', 'false', 'no', 'off'].includes(mode)) return false; + + const now = Date.now(); + + if (this.enabledCache && this.enabledCache.expiresAt > now) { + return this.enabledCache.value; + } + + try { + const response = await fetch(`${this.baseUrl}/client-config`, { + headers: { accept: 'application/json' } + }); + const envelope = (await response.json()) as ApiEnvelope; + const value = Boolean(response.ok && envelope.code === 0 && envelope.data.api_crypto_enabled); + + this.enabledCache = { + value, + expiresAt: now + 3000 + }; + + return value; + } catch { + this.enabledCache = { + value: false, + expiresAt: now + 3000 + }; + + return false; + } + } + + async decryptResponse(payload: unknown) { + if (!isEncryptedEnvelope(payload)) { + return payload as T; + } + + const session = await this.getSession(payload.session_id); + const plaintext = await getWebCrypto().subtle.decrypt( + { name: 'AES-GCM', iv: base64UrlToBytes(payload.iv) }, + session.aesKey, + base64UrlToBytes(payload.ciphertext) + ); + + return JSON.parse(decoder.decode(plaintext)) as T; + } + + private async getSession(expectedSessionId?: string) { + const now = Date.now(); + + if ( + this.session && + this.session.expiresAt > now && + (!expectedSessionId || this.session.sessionId === expectedSessionId) + ) { + return this.session; + } + + if (expectedSessionId) { + throw new Error('API 加密会话已失效,请刷新页面后重试。'); + } + + if (!this.pendingSession) { + this.pendingSession = this.createSession().finally(() => { + this.pendingSession = null; + }); + } + + this.session = await this.pendingSession; + return this.session; + } + + private async createSession() { + const response = await fetch(`${this.baseUrl}/crypto/handshake`, { + headers: { accept: 'application/json' } + }); + const envelope = (await response.json()) as ApiEnvelope; + + if (!response.ok || envelope.code !== 0) { + throw new Error(envelope.message || `API 加密握手失败:HTTP ${response.status}`); + } + + const crypto = getWebCrypto(); + const keyPair = await crypto.subtle.generateKey( + { name: 'ECDH', namedCurve: 'P-256' }, + true, + ['deriveBits'] + ); + const serverPublicKey = await crypto.subtle.importKey( + 'jwk', + envelope.data.server_public_key, + { name: 'ECDH', namedCurve: 'P-256' }, + false, + [] + ); + const sharedBits = await crypto.subtle.deriveBits( + { name: 'ECDH', public: serverPublicKey }, + keyPair.privateKey, + 256 + ); + const hkdfKey = await crypto.subtle.importKey('raw', sharedBits, 'HKDF', false, [ + 'deriveKey' + ]); + const aesKey = await crypto.subtle.deriveKey( + { + name: 'HKDF', + hash: 'SHA-256', + salt: base64UrlToBytes(envelope.data.salt), + info: encoder.encode(`ai-manga-api-v1:${envelope.data.session_id}`) + }, + hkdfKey, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ); + const clientPublicKey = await crypto.subtle.exportKey('jwk', keyPair.publicKey); + + return { + sessionId: envelope.data.session_id, + clientPublicKey, + aesKey, + expiresAt: Date.parse(envelope.data.expires_at) - 30_000 + }; + } + + private buildHeaders(session: ApiCryptoSession) { + return { + 'x-api-encrypted': 'v1', + 'x-api-session-id': session.sessionId, + 'x-api-client-public-key': jsonToBase64Url(session.clientPublicKey) + }; + } + + private configuredMode() { + return ((import.meta.env.VITE_API_CRYPTO_ENABLED as string | undefined) || 'auto') + .trim() + .toLowerCase(); + } +} diff --git a/admin/src/env.d.ts b/admin/src/env.d.ts new file mode 100644 index 0000000..b82632e --- /dev/null +++ b/admin/src/env.d.ts @@ -0,0 +1,6 @@ +declare module '*.vue' { + import type { DefineComponent } from 'vue'; + + const component: DefineComponent, Record, unknown>; + export default component; +} diff --git a/admin/src/main.ts b/admin/src/main.ts new file mode 100644 index 0000000..27a79bf --- /dev/null +++ b/admin/src/main.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue'; +import App from './App.vue'; +import './styles.css'; + +createApp(App).mount('#app'); diff --git a/admin/src/router/.gitkeep b/admin/src/router/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/admin/src/router/.gitkeep @@ -0,0 +1 @@ + diff --git a/admin/src/stores/.gitkeep b/admin/src/stores/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/admin/src/stores/.gitkeep @@ -0,0 +1 @@ + diff --git a/admin/src/styles.css b/admin/src/styles.css new file mode 100644 index 0000000..b61dd21 --- /dev/null +++ b/admin/src/styles.css @@ -0,0 +1,2054 @@ +:root { + color: #18202c; + background: #eef2f6; + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; + line-height: 1.45; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button, +select, +input, +textarea { + border: 1px solid #c9d3df; + border-radius: 6px; +} + +button { + background: #ffffff; + color: #263242; + cursor: pointer; + min-height: 34px; + padding: 7px 11px; +} + +button:hover:not(:disabled) { + border-color: #6d7f94; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +input, +select, +textarea { + background: #ffffff; + color: #18202c; + min-height: 34px; + padding: 7px 10px; +} + +textarea { + resize: vertical; +} + +table { + border-collapse: collapse; + width: 100%; +} + +th, +td { + border-bottom: 1px solid #e1e7ef; + font-size: 13px; + padding: 10px 8px; + text-align: left; + vertical-align: top; +} + +th { + color: #607086; + font-size: 12px; + font-weight: 700; +} + +h1, +h2, +h3, +p { + margin: 0; +} + +.login-screen { + align-items: center; + display: grid; + min-height: 100vh; + padding: 24px; +} + +.login-panel { + background: #ffffff; + border: 1px solid #d9e1ea; + border-radius: 8px; + box-shadow: 0 18px 40px rgba(48, 65, 86, 0.12); + display: grid; + gap: 16px; + margin: 0 auto; + max-width: 380px; + padding: 26px; + width: 100%; +} + +.login-panel h1 { + font-size: 26px; +} + +.login-panel label { + display: grid; + gap: 7px; +} + +.login-panel span { + color: #617188; + font-size: 13px; +} + +.primary { + background: #2454a6; + border-color: #2454a6; + color: #ffffff; +} + +.endpoint, +.eyebrow { + color: #708197; + font-size: 12px; +} + +.admin-shell { + display: grid; + grid-template-columns: 230px minmax(0, 1fr); + min-height: 100vh; +} + +.sidebar { + background: #17202d; + color: #f4f7fb; + display: flex; + flex-direction: column; + gap: 22px; + padding: 22px 14px; +} + +.brand { + display: grid; + gap: 2px; + padding: 0 8px; +} + +.brand strong { + font-size: 18px; +} + +.brand span { + color: #9db0c8; + font-size: 12px; +} + +nav { + display: grid; + gap: 5px; +} + +nav button { + background: transparent; + border-color: transparent; + color: #c7d2df; + text-align: left; + width: 100%; +} + +nav button.active, +nav button:hover { + background: #243246; + border-color: #31445f; + color: #ffffff; +} + +.workspace { + display: grid; + gap: 16px; + min-width: 0; + padding: 22px; +} + +.topbar { + align-items: center; + display: flex; + justify-content: space-between; + gap: 16px; +} + +.topbar p { + color: #68788e; + font-size: 13px; + margin-bottom: 2px; +} + +.topbar h1 { + font-size: 24px; +} + +.topbar-actions, +.toolbar, +.actions-cell { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.audit-toolbar input, +.audit-toolbar select { + min-width: 180px; +} + +.message { + border-radius: 6px; + font-size: 13px; + padding: 10px 12px; +} + +.message.error { + background: #fff1f1; + border: 1px solid #ffc9c9; + color: #a33a3a; +} + +.message.ok { + background: #edf9f0; + border: 1px solid #bfe6c8; + color: #2d6a3b; +} + +.view-stack { + display: grid; + gap: 16px; +} + +.metric-grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); +} + +.metric-grid.compact { + grid-template-columns: repeat(auto-fit, minmax(180px, 240px)); +} + +.metric-card { + background: #ffffff; + border: 1px solid #dbe3ed; + border-radius: 8px; + display: grid; + gap: 6px; + min-height: 86px; + padding: 15px; +} + +.metric-card span { + color: #65758c; + font-size: 13px; +} + +.metric-card strong { + font-size: 26px; + line-height: 1.1; +} + +.panel, +.metric-card { + box-shadow: 0 1px 2px rgba(36, 52, 71, 0.05); +} + +.panel { + background: #ffffff; + border: 1px solid #dbe3ed; + border-radius: 8px; + min-width: 0; + overflow: auto; + padding: 16px; +} + +.panel h2 { + font-size: 16px; + margin-bottom: 12px; +} + +.help-text { + color: #526379; + font-size: 13px; + margin-bottom: 12px; + max-width: 860px; +} + +.note-grid, +.provider-form, +.quota-grant-form, +.preview-fields, +.preview-blocks { + display: grid; + gap: 10px; +} + +.note-grid { + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + margin-bottom: 14px; +} + +.note-card, +.preview-fields article { + background: #f7f9fc; + border: 1px solid #dbe3ed; + border-radius: 8px; + display: grid; + gap: 5px; + padding: 11px; +} + +.note-card span, +.preview-fields span { + color: #64748b; + font-size: 12px; +} + +.guide-hero { + display: grid; + gap: 14px; +} + +.guide-hero h2 { + font-size: 22px; +} + +.section-heading { + align-items: flex-start; + display: flex; + gap: 12px; + justify-content: space-between; +} + +.guide-card { + align-content: start; + min-height: 112px; +} + +.guide-card strong { + color: #18202c; +} + +.guide-card span { + line-height: 1.6; +} + +.guide-steps { + display: grid; + gap: 10px; +} + +.guide-step { + background: #f7f9fc; + border: 1px solid #dbe3ed; + border-radius: 8px; + display: grid; + gap: 12px; + grid-template-columns: 48px minmax(0, 1fr); + padding: 12px; +} + +.guide-step > span { + align-items: center; + background: #2454a6; + border-radius: 6px; + color: #ffffff; + display: inline-flex; + font-weight: 800; + height: 36px; + justify-content: center; + width: 36px; +} + +.guide-step strong { + display: block; + margin-bottom: 4px; +} + +.guide-step p { + color: #526379; + font-size: 13px; + line-height: 1.6; +} + +.provider-form { + grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); +} + +.provider-form input, +.provider-form select, +.provider-form textarea { + min-width: 0; + width: 100%; +} + +.global-character-form { + grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); +} + +.quota-grant-form { + align-items: end; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); +} + +.provider-form h3, +.provider-form .toolbar { + grid-column: 1 / -1; +} + +.provider-form .wide-field { + grid-column: span 2; +} + +.provider-form label, +.quota-grant-form label { + display: grid; + gap: 6px; + min-width: 0; +} + +.quota-grant-form input, +.quota-grant-form select { + min-width: 0; + width: 100%; +} + +.quota-grant-form .toolbar { + justify-content: flex-start; + min-width: 0; +} + +.quota-grant-form .wide-field { + grid-column: span 2; +} + +.provider-status-grid { + margin-top: 14px; +} + +.json-inline { + display: grid; + gap: 8px; + margin-top: 14px; +} + +.json-inline strong { + color: #213047; + font-size: 13px; +} + +.provider-form label span, +.quota-grant-form label span, +.quota-summary span { + color: #526379; + font-size: 12px; + font-weight: 700; +} + +.quota-summary { + background: #f7f9fc; + border: 1px solid #dbe3ed; + border-radius: 8px; + display: grid; + gap: 4px; + min-height: 64px; + min-width: 0; + padding: 10px 12px; +} + +.quota-summary strong { + color: #0f172a; + font-size: 18px; + overflow-wrap: anywhere; +} + +.danger-action { + background: #fff7ed; + border-color: #f4b26a; + color: #8a3f0a; +} + +.check-line { + align-items: center; + display: flex !important; + gap: 8px !important; +} + +.check-line input { + width: auto; +} + +.empty-state { + align-items: center; + background: #f7f9fc; + border: 1px dashed #c9d3df; + border-radius: 8px; + color: #64748b; + display: flex; + min-height: 54px; + padding: 12px; +} + +.panel-grid { + display: grid; + gap: 16px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.panel.wide { + grid-column: 1 / -1; +} + +.split-view { + display: grid; + gap: 16px; + grid-template-columns: minmax(360px, 1.1fr) minmax(360px, 0.9fr); +} + +.selected { + background: #f1f6ff; +} + +.badge { + background: #edf2f8; + border: 1px solid #d4deeb; + border-radius: 999px; + color: #44566f; + display: inline-block; + font-size: 12px; + line-height: 1; + padding: 5px 8px; +} + +.muted-text { + color: #66758a; + font-size: 12px; + margin-top: 4px; +} + +.platform-purpose { + min-width: 180px; +} + +.platform-config-guide { + border-color: #f0b4a8; +} + +.quick-guide-list { + display: grid; + gap: 10px; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.quick-guide-list article { + background: #fff8f6; + border: 1px solid #f2c8bf; + border-radius: 8px; + display: grid; + gap: 6px; + padding: 12px; +} + +.quick-guide-list span { + color: #63504b; + font-size: 12px; + line-height: 1.6; +} + +.provider-code-cell { + color: #2b3d56; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; + min-width: 170px; +} + +.platform-config-box { + display: grid; + gap: 7px; + min-width: 160px; +} + +.platform-config-box small { + color: #66758a; + line-height: 1.45; +} + +.platform-config-box button { + justify-self: start; +} + +.cost-stack { + display: grid; + gap: 4px; + line-height: 1.35; + min-width: 118px; +} + +.cost-stack strong { + color: #203149; + font-size: 13px; + overflow-wrap: anywhere; +} + +.cost-stack small { + color: #66758a; + font-size: 11px; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.cost-stack.compact { + min-width: 104px; +} + +.platform-links { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-width: 160px; +} + +.platform-links a { + background: #f1f6ff; + border: 1px solid #cddbf2; + border-radius: 6px; + color: #2454a6; + font-size: 12px; + padding: 5px 8px; + text-decoration: none; +} + +.platform-links a:hover { + border-color: #2454a6; +} + +.numeric { + text-align: right; +} + +.truncate { + max-width: 280px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.json-preview { + background: #f7f9fc; + border: 1px solid #dbe3ed; + border-radius: 6px; + color: #213047; + font-size: 12px; + line-height: 1.55; + margin: 0; + max-height: 360px; + overflow: auto; + padding: 12px; + white-space: pre-wrap; + word-break: break-word; +} + +.detail-list { + display: grid; + gap: 8px 14px; + grid-template-columns: 80px 1fr; + margin-bottom: 16px; +} + +.detail-list dt { + color: #66758a; +} + +.detail-list dd { + margin: 0; +} + +.preview-drawer { + background: #ffffff; + border: 1px solid #c9d3df; + border-radius: 8px; + box-shadow: 0 18px 40px rgba(23, 32, 45, 0.16); + bottom: 18px; + display: grid; + gap: 14px; + max-height: calc(100vh - 36px); + overflow: auto; + padding: 16px; + position: fixed; + right: 18px; + width: min(560px, calc(100vw - 36px)); + z-index: 30; +} + +.user-detail-drawer { + width: min(820px, calc(100vw - 36px)); +} + +.preview-header { + align-items: start; + display: flex; + gap: 12px; + justify-content: space-between; +} + +.preview-header h2 { + font-size: 18px; + margin: 2px 0; +} + +.preview-header p { + color: #64748b; + font-size: 12px; +} + +.preview-fields { + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); +} + +.preview-fields strong { + font-size: 13px; + overflow-wrap: anywhere; +} + +.media-preview { + display: grid; + gap: 10px; +} + +.media-preview img, +.media-preview video { + background: #111827; + border-radius: 8px; + max-height: 56vh; + object-fit: contain; + width: 100%; +} + +.media-preview audio { + width: 100%; +} + +.preview-blocks article { + display: grid; + gap: 8px; +} + +.drawer-section { + display: grid; + gap: 8px; +} + +.drawer-section h3 { + font-size: 14px; +} + +.compact-table th, +.compact-table td { + font-size: 12px; + padding: 8px 6px; +} + +.ops-panel { + background: #fbfcfe; + border: 1px solid #dbe3ed; + border-radius: 8px; + padding: 12px; +} + +.ops-grid { + align-items: end; + display: grid; + gap: 10px; + grid-template-columns: minmax(140px, 0.8fr) minmax(190px, 1.2fr) minmax(104px, auto); +} + +.ops-grid label { + display: grid; + gap: 6px; + min-width: 0; +} + +.ops-grid label span { + color: #526379; + font-size: 12px; + font-weight: 700; +} + +.ops-grid input, +.ops-grid select, +.ops-grid button { + min-width: 0; + width: 100%; +} + +.temp-password { + overflow-wrap: anywhere; +} + +.preview-blocks h3 { + font-size: 14px; +} + +.preview-blocks pre { + background: #f7f9fc; + border: 1px solid #dbe3ed; + border-radius: 8px; + font-family: inherit; + font-size: 13px; + line-height: 1.6; + margin: 0; + max-height: 320px; + overflow: auto; + padding: 12px; + white-space: pre-wrap; + word-break: break-word; +} + +.cockpit-shell { + background: #07101a; +} + +.cockpit-shell .sidebar { + background: #050b13; + border-right: 1px solid #1e3552; +} + +.cockpit-shell .workspace { + background: + linear-gradient(180deg, #08111d 0%, #050914 100%); + color: #eef6ff; +} + +.cockpit-shell .topbar p { + color: #8fa7c2; +} + +.cockpit-shell .topbar h1 { + color: #f6fbff; +} + +.cockpit-dashboard { + display: grid; + gap: 16px; +} + +.cockpit-hero, +.cockpit-panel, +.cockpit-main-panel { + background: rgba(10, 19, 33, 0.94); + border: 1px solid #244163; + border-radius: 8px; + box-shadow: 0 18px 38px rgba(0, 0, 0, 0.22); +} + +.cockpit-hero { + align-items: center; + display: grid; + gap: 18px; + grid-template-columns: minmax(260px, 1fr) minmax(420px, 0.95fr); + min-height: 118px; + padding: 20px; +} + +.cockpit-title { + display: grid; + gap: 6px; +} + +.cockpit-title h2 { + color: #f7fbff; + font-size: 30px; + line-height: 1.15; +} + +.cockpit-title span { + color: #aabbd0; + font-size: 15px; +} + +.cockpit-kpis { + display: grid; + gap: 12px; + grid-template-columns: repeat(4, minmax(120px, 1fr)); +} + +.cockpit-kpi { + background: #0d1a2c; + border: 1px solid #294766; + border-radius: 8px; + display: grid; + gap: 5px; + min-height: 86px; + padding: 12px; +} + +.cockpit-kpi span, +.cockpit-kpi small { + color: #9fb2c8; + font-size: 12px; +} + +.cockpit-kpi strong { + color: #f5faff; + font-size: 24px; + line-height: 1.12; + overflow-wrap: anywhere; +} + +.cockpit-kpi.tone-green strong { + color: #68df86; +} + +.cockpit-kpi.tone-blue strong { + color: #4ea1ff; +} + +.cockpit-kpi.tone-purple strong { + color: #bb8cff; +} + +.cockpit-layout { + align-items: start; + display: grid; + gap: 16px; + grid-template-columns: minmax(190px, 0.72fr) minmax(460px, 2fr) minmax(280px, 1fr); +} + +.cockpit-column, +.cockpit-main-panel, +.cockpit-bottom-grid { + min-width: 0; +} + +.cockpit-column { + display: grid; + gap: 16px; +} + +.cockpit-panel, +.cockpit-main-panel { + overflow: auto; + padding: 16px; +} + +.cockpit-panel h2, +.cockpit-main-panel h2 { + color: #f4f8ff; + font-size: 16px; + margin-bottom: 12px; +} + +.cockpit-main-panel .section-heading { + margin-bottom: 14px; +} + +.cockpit-main-panel .eyebrow, +.cockpit-title .eyebrow { + color: #78a9ff; +} + +.cockpit-module-list, +.cockpit-status-list, +.strategy-list, +.cost-bars { + display: grid; + gap: 9px; +} + +.cockpit-module, +.cockpit-status-list article, +.strategy-list article, +.cost-bars article { + border-bottom: 1px solid rgba(101, 135, 176, 0.22); + display: grid; + gap: 4px; + padding: 0 0 10px; +} + +.cockpit-module { + align-items: center; + grid-template-columns: minmax(0, 1fr) auto; +} + +.cockpit-module strong, +.strategy-list strong, +.cockpit-table strong { + color: #f8fbff; +} + +.cockpit-module span, +.strategy-list span, +.cockpit-table span { + color: #96a9bd; + display: block; + font-size: 12px; + line-height: 1.45; + margin-top: 2px; +} + +.cockpit-module b, +.cockpit-status-list strong { + color: #7ee58e; + font-size: 18px; +} + +.cockpit-status-list article { + align-items: center; + grid-template-columns: minmax(0, 1fr) auto; +} + +.cockpit-status-list span { + color: #c5d4e6; + font-size: 13px; +} + +.cockpit-flow { + display: grid; + gap: 12px; +} + +.cockpit-flow-step { + align-items: center; + background: rgba(14, 27, 46, 0.84); + border: 1px solid #284664; + border-left-width: 3px; + border-radius: 8px; + display: grid; + gap: 14px; + grid-template-columns: 38px minmax(130px, 0.9fr) minmax(210px, 1.15fr) minmax(150px, 0.85fr); + min-height: 106px; + padding: 12px; + position: relative; +} + +.cockpit-flow-step + .cockpit-flow-step::before { + background: #4ddc9d; + content: ""; + height: 12px; + left: 31px; + position: absolute; + top: -13px; + width: 2px; +} + +.flow-index { + align-items: center; + background: #2f83ee; + border-radius: 999px; + color: #ffffff; + display: inline-flex; + font-weight: 800; + height: 30px; + justify-content: center; + width: 30px; +} + +.flow-copy h3 { + color: #f9fcff; + font-size: 17px; + margin: 0 0 6px; +} + +.flow-copy ul { + color: #b6c6d9; + display: grid; + font-size: 12px; + gap: 2px; + margin: 0; + padding-left: 16px; +} + +.flow-provider, +.flow-output { + background: rgba(8, 17, 31, 0.72); + border: 1px solid #24476a; + border-radius: 8px; + display: grid; + gap: 5px; + min-height: 78px; + padding: 10px 12px; +} + +.flow-provider span, +.flow-output span { + color: #91a8bf; + font-size: 12px; +} + +.flow-provider strong, +.flow-output strong { + color: #f6fbff; + overflow-wrap: anywhere; +} + +.flow-provider small, +.flow-output small { + color: #aebed0; + font-size: 12px; + line-height: 1.35; +} + +.flow-provider em { + color: #69df8f; + font-size: 12px; + font-style: normal; + overflow-wrap: anywhere; +} + +.cockpit-flow-step.accent-cyan { + border-left-color: #38d6d0; +} + +.cockpit-flow-step.accent-green { + border-left-color: #62db75; +} + +.cockpit-flow-step.accent-yellow { + border-left-color: #dbc75d; +} + +.cockpit-flow-step.accent-orange { + border-left-color: #f59d3d; +} + +.cockpit-flow-step.accent-pink { + border-left-color: #f25c92; +} + +.cockpit-publish-panel { + background: rgba(9, 23, 42, 0.78); + border: 1px solid #24517f; + border-radius: 8px; + display: grid; + gap: 12px; + margin-top: 14px; + padding: 14px; +} + +.cockpit-publish-panel h3 { + color: #f7fbff; + font-size: 15px; + margin: 0; +} + +.cockpit-publish-panel div { + display: grid; + gap: 10px; + grid-template-columns: repeat(5, minmax(86px, 1fr)); +} + +.cockpit-publish-panel span { + background: #0d1a2d; + border: 1px solid #243f61; + border-radius: 8px; + color: #d9e6f5; + font-size: 13px; + min-height: 44px; + padding: 12px 8px; + text-align: center; +} + +.cockpit-table-wrap { + max-height: 310px; + overflow: auto; +} + +.cockpit-table th, +.cockpit-table td { + border-bottom-color: rgba(105, 139, 180, 0.22); + color: #d8e4f2; +} + +.cockpit-table th { + color: #8fa6bf; +} + +.cockpit-badge { + background: #10243b; + border: 1px solid #315a83; + border-radius: 999px; + color: #b9d7ff; + display: inline-block; + font-size: 12px; + line-height: 1; + padding: 5px 8px; +} + +.cost-bars article > div:first-child { + align-items: center; + display: flex; + gap: 8px; + justify-content: space-between; +} + +.cost-bars span { + color: #c9d8e8; + font-size: 12px; +} + +.cost-bars strong { + color: #9de582; + font-size: 12px; +} + +.cost-bar { + background: #14243a; + border-radius: 999px; + height: 7px; + overflow: hidden; +} + +.cost-bar span { + background: #2f8cff; + border-radius: inherit; + display: block; + height: 100%; + min-width: 4px; +} + +.cockpit-total-cost { + align-items: center; + background: #0f1f32; + border: 1px solid #274766; + border-radius: 8px; + display: flex; + justify-content: space-between; + margin-top: 12px; + padding: 12px; +} + +.cockpit-total-cost span { + color: #b8c9dc; +} + +.cockpit-total-cost strong { + color: #78df82; + font-size: 18px; + overflow-wrap: anywhere; +} + +.cockpit-bottom-grid { + display: grid; + gap: 16px; + grid-template-columns: minmax(220px, 0.75fr) minmax(360px, 1.25fr); +} + +.cockpit-bottom-grid .wide { + min-width: 0; +} + +.cockpit-page { + color: #edf6ff; +} + +.cockpit-shell .panel { + background: rgba(10, 19, 33, 0.94); + border-color: #244163; + box-shadow: 0 18px 38px rgba(0, 0, 0, 0.2); + color: #e8f1fb; +} + +.cockpit-shell .panel h2, +.cockpit-shell .panel h3 { + color: #f6fbff; +} + +.cockpit-shell .help-text, +.cockpit-shell .muted-text, +.cockpit-shell .endpoint { + color: #95a9bf; +} + +.cockpit-shell .guide-hero { + align-items: center; + border-color: #2b5886; + display: grid; + gap: 18px; + grid-template-columns: minmax(280px, 0.85fr) minmax(420px, 1.15fr); + min-height: 178px; + padding: 20px; +} + +.cockpit-shell .guide-hero h2 { + font-size: 26px; + line-height: 1.18; +} + +.cockpit-shell .note-grid { + margin-bottom: 0; +} + +.cockpit-shell .note-card, +.cockpit-shell .quick-guide-list article { + background: rgba(12, 27, 47, 0.82); + border-color: #294c72; + color: #eef6ff; +} + +.cockpit-shell .note-card strong, +.cockpit-shell .quick-guide-list strong, +.cockpit-shell .guide-step strong { + color: #fbfdff; +} + +.cockpit-shell .note-card span, +.cockpit-shell .quick-guide-list span, +.cockpit-shell .guide-step p, +.cockpit-shell .guide-step span { + color: #9fb3c9; +} + +.cockpit-shell .platform-config-guide { + border-color: #27825e; +} + +.cockpit-shell .platform-config-guide .section-heading { + align-items: center; +} + +.cockpit-shell .guide-steps { + grid-template-columns: repeat(4, minmax(150px, 1fr)); +} + +.cockpit-shell .guide-step { + background: rgba(11, 25, 43, 0.82); + border-color: #294c72; + grid-template-columns: 1fr; + min-height: 132px; +} + +.cockpit-shell .guide-step > span { + background: #2f83ee; + border-radius: 999px; + height: 32px; + width: 32px; +} + +.ai-platform-page .guide-step > span { + background: transparent; + border-radius: 0; + color: #9fb3c9; + display: block; + font-weight: 400; + height: auto; + justify-content: flex-start; + line-height: 1.6; + width: auto; +} + +.cockpit-shell table { + color: #d8e4f2; +} + +.cockpit-shell th, +.cockpit-shell td { + border-bottom-color: rgba(105, 139, 180, 0.22); +} + +.cockpit-shell th { + color: #8fa6bf; +} + +.cockpit-shell td { + color: #dbe8f7; +} + +.cockpit-shell td strong { + color: #f6fbff; +} + +.cockpit-shell code, +.cockpit-shell .provider-code-cell { + color: #8bd7ff; +} + +.cockpit-shell .platform-purpose { + color: #c7d7e9; + line-height: 1.55; +} + +.cockpit-shell .platform-links a { + background: #0f243b; + border-color: #315a83; + color: #b9d7ff; +} + +.cockpit-shell .platform-links a:hover { + border-color: #4ea1ff; + color: #ffffff; +} + +.cockpit-shell .badge { + background: #10243b; + border-color: #315a83; + color: #b9d7ff; +} + +.cockpit-shell .platform-config-box small { + color: #94aac1; +} + +.cockpit-shell .cost-stack small { + color: #94aac1; +} + +.cockpit-shell button { + background: #0d1c31; + border-color: #2d5278; + color: #dcecff; +} + +.cockpit-shell button:hover:not(:disabled) { + border-color: #58a6ff; + color: #ffffff; +} + +.cockpit-shell button.primary, +.cockpit-shell .primary { + background: #2673d9; + border-color: #3287f4; + color: #ffffff; +} + +.cockpit-shell input, +.cockpit-shell select, +.cockpit-shell textarea { + background: #081527; + border-color: #2d5278; + color: #f4f9ff; +} + +.cockpit-shell input::placeholder, +.cockpit-shell textarea::placeholder { + color: #697f98; +} + +.cockpit-shell .provider-form label span, +.cockpit-shell .quota-grant-form label span, +.cockpit-shell .quota-summary span { + color: #a9bdd3; +} + +.cockpit-shell .check-line { + background: rgba(13, 28, 49, 0.8); + border: 1px solid #294c72; + border-radius: 8px; + min-height: 42px; + padding: 8px 10px; +} + +.cockpit-shell .empty-state { + background: rgba(10, 22, 38, 0.82); + border-color: #315679; + color: #a9bdd3; +} + +.cockpit-shell .json-preview { + background: #071426; + border-color: #294c72; + color: #dcecff; +} + +.ai-platform-page > .panel, +.providers-page > .panel { + overflow: auto; +} + +.ai-platform-page .platform-config-guide { + background: + linear-gradient(135deg, rgba(12, 35, 48, 0.96), rgba(10, 22, 38, 0.96)); +} + +.ai-platform-page .platform-config-guide h2 { + margin-bottom: 6px; +} + +.providers-page { + grid-template-columns: minmax(0, 1fr); +} + +.provider-hero-panel { + border-color: #2b5886; +} + +.provider-hero-panel .provider-form { + margin-top: 14px; +} + +.provider-status-grid { + grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); +} + +.provider-action-bar { + background: rgba(10, 19, 33, 0.94); + border: 1px solid #244163; + border-radius: 8px; + box-shadow: 0 18px 38px rgba(0, 0, 0, 0.18); + padding: 12px; +} + +.provider-runtime-panel { + border-color: #27825e; +} + +.provider-list-panel { + border-color: #5b4f91; +} + +.provider-list-controls { + align-items: center; + display: grid; + gap: 10px; + grid-template-columns: minmax(220px, 1.5fr) repeat(3, minmax(120px, 0.7fr)) auto; + margin: 12px 0; +} + +.provider-list-controls input, +.provider-list-controls select { + min-width: 0; + width: 100%; +} + +.provider-list-controls .muted-text { + justify-self: end; + white-space: nowrap; +} + +.compact-help { + margin: -2px 0 8px; +} + +.provider-list-panel table, +.ai-platform-page table, +.task-list-panel table, +.router-audit-table-panel table, +.cost-log-panel table { + min-width: 1180px; +} + +.platform-registry-table th, +.platform-registry-table td { + overflow-wrap: anywhere; + vertical-align: top; +} + +.platform-registry-table .badge { + line-height: 1.3; + white-space: normal; +} + +.platform-registry-table .platform-links, +.platform-registry-table .platform-config-box { + min-width: 0; +} + +@media (min-width: 1120px) { + .ai-platform-page .platform-registry-table { + min-width: 0; + table-layout: fixed; + width: 100%; + } + + .platform-registry-table th:nth-child(1) { + width: 11%; + } + + .platform-registry-table th:nth-child(2) { + width: 16%; + } + + .platform-registry-table th:nth-child(3) { + width: 12%; + } + + .platform-registry-table th:nth-child(4) { + width: 10%; + } + + .platform-registry-table th:nth-child(5) { + width: 11%; + } + + .platform-registry-table th:nth-child(6) { + width: 15%; + } + + .platform-registry-table th:nth-child(7) { + width: 8%; + } + + .platform-registry-table th:nth-child(8) { + width: 9%; + } + + .platform-registry-table th:nth-child(9) { + width: 8%; + } +} + +@media (max-width: 960px) { + .provider-list-controls { + grid-template-columns: 1fr 1fr; + } + + .provider-list-controls .muted-text { + grid-column: 1 / -1; + justify-self: start; + white-space: normal; + } +} + +.cockpit-toolbar { + background: rgba(10, 19, 33, 0.94); + border: 1px solid #244163; + border-radius: 8px; + box-shadow: 0 18px 38px rgba(0, 0, 0, 0.18); + padding: 12px; +} + +.task-page .note-card strong, +.router-audit-page .note-card strong, +.cost-log-page .note-card strong { + color: #7ee58e; + font-size: 24px; + line-height: 1.1; + overflow-wrap: anywhere; +} + +.task-list-panel { + border-color: #3a5d8e; +} + +.router-audit-table-panel { + border-color: #4665a8; + overflow-x: auto; +} + +.router-audit-action-panel { + border-color: #375b7f; +} + +.router-action-grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.router-action-grid label { + display: grid; + gap: 6px; +} + +.router-action-grid .wide-field { + grid-column: span 2; +} + +.router-action-grid .check-line { + align-items: center; + display: flex; + gap: 8px; +} + +.cost-log-panel { + border-color: #27825e; +} + +.task-list-panel .section-heading, +.router-audit-table-panel .section-heading, +.cost-log-panel .section-heading { + align-items: center; + margin-bottom: 12px; +} + +.task-page input, +.task-page select, +.router-audit-page input, +.router-audit-page select { + min-width: min(220px, 100%); +} + +.router-audit-page td { + vertical-align: top; +} + +.router-audit-page small { + color: #9fb3c9; + display: block; + margin-top: 4px; +} + +.router-audit-page td:nth-child(8) strong { + color: #9de582; +} + +.router-timeline-drawer { + max-width: min(860px, calc(100vw - 32px)); +} + +.router-timeline-summary { + margin: 12px 0 18px; +} + +.router-timeline-list { + display: grid; + gap: 12px; +} + +.router-timeline-item { + display: grid; + gap: 10px; + grid-template-columns: 112px minmax(0, 1fr); +} + +.timeline-marker { + align-items: start; + display: flex; + justify-content: flex-end; + padding-top: 8px; +} + +.timeline-marker span { + background: rgba(29, 54, 86, 0.86); + border: 1px solid #345f91; + border-radius: 999px; + color: #bcd1ea; + font-size: 12px; + padding: 4px 8px; + white-space: nowrap; +} + +.timeline-card { + background: rgba(10, 19, 33, 0.72); + border: 1px solid #244163; + border-radius: 8px; + padding: 12px; +} + +.timeline-card-head { + align-items: flex-start; + display: flex; + gap: 12px; + justify-content: space-between; +} + +.timeline-card-head strong { + color: #edf6ff; + display: block; + font-size: 14px; + line-height: 1.35; +} + +.timeline-card-head p { + color: #9fb3c9; + font-size: 12px; + margin: 4px 0 0; +} + +.timeline-card small { + color: #7f94ad; + display: block; + margin: 8px 0; +} + +.timeline-card pre { + background: rgba(4, 9, 16, 0.72); + border: 1px solid #1d3656; + border-radius: 8px; + color: #cfe4ff; + font-size: 12px; + line-height: 1.5; + margin: 0; + max-height: 220px; + overflow: auto; + padding: 10px; + white-space: pre-wrap; + word-break: break-word; +} + +.cost-log-page td:nth-child(4) { + color: #9de582; + font-weight: 700; +} + +.tag-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; +} + +.compact-list { + display: grid; + gap: 4px; + margin: 0; + padding-left: 16px; +} + +.compact-list li { + color: #b7c8dc; + font-size: 12px; + line-height: 1.45; +} + +.hit-segment-list { + display: grid; + gap: 8px; + min-width: 220px; +} + +.mini-card { + background: rgba(10, 19, 33, 0.72); + border: 1px solid #244163; + border-radius: 8px; + display: grid; + gap: 4px; + padding: 8px; +} + +.mini-card strong { + color: #edf6ff; + font-size: 12px; +} + +.mini-card span, +.mini-card small { + color: #9fb3c9; + font-size: 12px; + line-height: 1.35; +} + +.pattern-metrics-grid { + margin-bottom: 14px; +} + +.pattern-ops-grid { + display: grid; + gap: 14px; + grid-template-columns: minmax(0, 2fr) minmax(260px, 0.8fr); + margin-bottom: 16px; +} + +.pattern-editor, +.pattern-selected-metrics { + background: rgba(10, 19, 33, 0.78); + border: 1px solid #244163; + border-radius: 8px; + padding: 14px; +} + +.compact-heading { + margin-bottom: 12px; +} + +.dense-form { + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); +} + +.pattern-selected-metrics { + align-content: start; + display: grid; + gap: 12px; +} + +.pattern-selected-metrics h3 { + color: #edf6ff; + font-size: 16px; +} + +.pattern-selected-metrics dl { + display: grid; + gap: 8px; + margin: 0; +} + +.pattern-selected-metrics dl div { + align-items: center; + border-bottom: 1px solid rgba(78, 113, 153, 0.35); + display: flex; + justify-content: space-between; + padding-bottom: 7px; +} + +.pattern-selected-metrics dt { + color: #93a8bf; + font-size: 12px; +} + +.pattern-selected-metrics dd { + color: #7ee58e; + font-weight: 700; + margin: 0; +} + +.selected-row { + background: rgba(56, 116, 180, 0.18); +} + +.cockpit-shell .metric-card, +.cockpit-shell .quota-summary, +.cockpit-shell .preview-fields article, +.cockpit-shell .ops-panel, +.cockpit-shell .preview-drawer, +.cockpit-shell .media-preview img, +.cockpit-shell .media-preview video, +.cockpit-shell .preview-blocks pre { + background: rgba(10, 19, 33, 0.96); + border-color: #244163; + color: #e8f1fb; +} + +.cockpit-shell .metric-card span, +.cockpit-shell .quota-summary span, +.cockpit-shell .preview-fields span, +.cockpit-shell .preview-header p, +.cockpit-shell .drawer-section h3, +.cockpit-shell .ops-grid label span { + color: #9fb3c9; +} + +.cockpit-shell .metric-card strong, +.cockpit-shell .quota-summary strong, +.cockpit-shell .preview-fields strong, +.cockpit-shell .preview-header h2, +.cockpit-shell .preview-blocks h3, +.cockpit-shell .ops-panel strong { + color: #f6fbff; +} + +.cockpit-shell .selected { + background: rgba(47, 131, 238, 0.18); +} + +.cockpit-shell .danger-action { + background: rgba(127, 29, 29, 0.28); + border-color: #b45309; + color: #fecaca; +} + +.cockpit-shell .message.ok { + background: rgba(20, 83, 45, 0.42); + border-color: #22c55e; + color: #d1fae5; +} + +.cockpit-shell .message.error { + background: rgba(127, 29, 29, 0.42); + border-color: #ef4444; + color: #fee2e2; +} + +.cockpit-shell .empty-state, +.cockpit-shell .file-line { + background: rgba(10, 22, 38, 0.82); + border-color: #315679; + color: #a9bdd3; +} + +.cockpit-shell a { + color: #8bd7ff; +} + +@media (max-width: 980px) { + .admin-shell, + .split-view, + .panel-grid, + .cockpit-layout, + .cockpit-hero, + .cockpit-bottom-grid { + grid-template-columns: 1fr; + } + + .cockpit-kpis, + .cockpit-publish-panel div { + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + } + + .cockpit-shell .guide-hero, + .cockpit-shell .guide-steps { + grid-template-columns: 1fr; + } + + .cockpit-flow-step { + grid-template-columns: 34px minmax(0, 1fr); + } + + .flow-provider, + .flow-output { + grid-column: 1 / -1; + } + + .quota-grant-form { + grid-template-columns: 1fr; + } + + .quota-grant-form .wide-field { + grid-column: auto; + } + + .provider-form .wide-field { + grid-column: auto; + } + + .router-action-grid .wide-field { + grid-column: auto; + } + + .router-timeline-item { + grid-template-columns: 1fr; + } + + .pattern-ops-grid { + grid-template-columns: 1fr; + } + + .timeline-marker { + justify-content: flex-start; + padding-top: 0; + } + + .section-heading { + display: grid; + } + + .ops-grid { + grid-template-columns: 1fr; + } + + .sidebar { + position: static; + } + + nav { + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + } +} diff --git a/admin/src/views/.gitkeep b/admin/src/views/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/admin/src/views/.gitkeep @@ -0,0 +1 @@ + diff --git a/admin/tsconfig.json b/admin/tsconfig.json new file mode 100644 index 0000000..3b77dea --- /dev/null +++ b/admin/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "module": "ESNext", + "jsx": "preserve", + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "types": [ + "vite/client", + "vitest" + ], + "allowImportingTsExtensions": true, + "noEmit": true + }, + "include": [ + "src/**/*.ts", + "src/**/*.vue", + "vite.config.ts" + ] +} diff --git a/admin/vite.config.ts b/admin/vite.config.ts new file mode 100644 index 0000000..53119dc --- /dev/null +++ b/admin/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; + +export default defineConfig({ + plugins: [vue()], + server: { + host: '0.0.0.0', + port: Number(process.env.ADMIN_PORT ?? 5173) + } +}); diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..c68a2c8 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,41 @@ +{ + "name": "backend", + "version": "0.1.0", + "private": true, + "scripts": { + "start": "node dist/main.js", + "start:dev": "tsx watch src/main.ts", + "build": "tsc -p tsconfig.build.json", + "lint": "tsc --noEmit -p tsconfig.json", + "typecheck": "tsc --noEmit -p tsconfig.json", + "test": "vitest run", + "live-action:acceptance": "tsx src/live-action/live-action-provider-acceptance.ts", + "live-action:testcase:import": "tsx src/live-action/import-live-action-testcase.ts", + "prisma:generate": "prisma generate", + "prisma:validate": "prisma validate", + "prisma:migrate": "prisma migrate dev", + "prisma:deploy": "prisma migrate deploy", + "prisma:seed": "tsx prisma/seed.ts" + }, + "dependencies": { + "@nestjs/common": "^10.4.20", + "@nestjs/core": "^10.4.20", + "@nestjs/jwt": "^11.0.2", + "@nestjs/platform-express": "^10.4.20", + "@prisma/client": "^6.19.3", + "bcryptjs": "^3.0.3", + "bullmq": "^5.77.6", + "ioredis": "^5.11.0", + "mammoth": "^1.12.0", + "minio": "^8.0.7", + "multer": "^2.1.1", + "pdf-parse": "^2.4.5", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2" + }, + "devDependencies": { + "@types/express": "^5.0.6", + "@types/multer": "^2.1.0", + "prisma": "^6.19.3" + } +} diff --git a/backend/prisma/migrations/20260531093000_init_system_a/migration.sql b/backend/prisma/migrations/20260531093000_init_system_a/migration.sql new file mode 100644 index 0000000..12df6f2 --- /dev/null +++ b/backend/prisma/migrations/20260531093000_init_system_a/migration.sql @@ -0,0 +1,629 @@ +-- CreateTable +CREATE TABLE `users` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `email` VARCHAR(191) NULL, + `phone` VARCHAR(50) NULL, + `password_hash` VARCHAR(255) NOT NULL, + `nickname` VARCHAR(100) NULL, + `avatar_url` VARCHAR(500) NULL, + `role` VARCHAR(50) NOT NULL DEFAULT 'user', + `status` VARCHAR(50) NOT NULL DEFAULT 'active', + `wechat_openid` VARCHAR(191) NULL, + `last_login_at` DATETIME(3) NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + UNIQUE INDEX `users_email_key`(`email`), + UNIQUE INDEX `users_phone_key`(`phone`), + UNIQUE INDEX `users_wechat_openid_key`(`wechat_openid`), + INDEX `users_role_status_idx`(`role`, `status`), + INDEX `users_created_at_idx`(`created_at`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `projects` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `user_id` BIGINT NOT NULL, + `title` VARCHAR(255) NULL, + `input_mode` VARCHAR(50) NOT NULL, + `genre` VARCHAR(100) NULL, + `style_code` VARCHAR(100) NULL, + `output_type` VARCHAR(50) NULL, + `target_episode_count` INTEGER NULL, + `episode_duration` INTEGER NULL, + `status` VARCHAR(80) NOT NULL DEFAULT 'draft', + `copyright_status` VARCHAR(80) NOT NULL DEFAULT 'pending', + `payment_status` VARCHAR(80) NOT NULL DEFAULT 'unpaid', + `quality_level` VARCHAR(50) NULL, + `is_long_series` BOOLEAN NOT NULL DEFAULT false, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + `completed_at` DATETIME(3) NULL, + + INDEX `projects_user_id_status_idx`(`user_id`, `status`), + INDEX `projects_genre_status_idx`(`genre`, `status`), + INDEX `projects_created_at_idx`(`created_at`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `novel_sources` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `project_id` BIGINT NOT NULL, + `source_type` VARCHAR(50) NOT NULL, + `title` VARCHAR(255) NULL, + `author_name` VARCHAR(100) NULL, + `raw_asset_id` BIGINT NULL, + `raw_text` LONGTEXT NULL, + `clean_text` LONGTEXT NULL, + `word_count` INTEGER NULL, + `chapter_count` INTEGER NULL, + `parse_status` VARCHAR(50) NOT NULL DEFAULT 'pending', + `parse_report` JSON NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `novel_sources_project_id_idx`(`project_id`), + INDEX `novel_sources_parse_status_idx`(`parse_status`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `novel_chapters` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `project_id` BIGINT NOT NULL, + `novel_source_id` BIGINT NULL, + `chapter_no` INTEGER NOT NULL, + `title` VARCHAR(255) NULL, + `content` LONGTEXT NOT NULL, + `summary` TEXT NULL, + `visual_summary` TEXT NULL, + `word_count` INTEGER NULL, + `status` VARCHAR(50) NOT NULL DEFAULT 'draft', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `novel_chapters_project_id_chapter_no_idx`(`project_id`, `chapter_no`), + INDEX `novel_chapters_project_id_status_idx`(`project_id`, `status`), + UNIQUE INDEX `novel_chapters_novel_source_id_chapter_no_key`(`novel_source_id`, `chapter_no`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `copyright_records` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `project_id` BIGINT NOT NULL, + `user_id` BIGINT NOT NULL, + `authorization_type` VARCHAR(50) NOT NULL, + `statement_text` TEXT NOT NULL, + `ip` VARCHAR(80) NULL, + `user_agent` TEXT NULL, + `confirmed_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `copyright_records_project_id_idx`(`project_id`), + INDEX `copyright_records_user_id_idx`(`user_id`), + INDEX `copyright_records_authorization_type_idx`(`authorization_type`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `story_bibles` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `project_id` BIGINT NOT NULL, + `title` VARCHAR(255) NULL, + `logline` TEXT NULL, + `main_plot` TEXT NULL, + `core_conflict` TEXT NULL, + `selling_points` TEXT NULL, + `tone` VARCHAR(100) NULL, + `world_summary` TEXT NULL, + `ending_direction` TEXT NULL, + `taboo_rules` TEXT NULL, + `version` INTEGER NOT NULL DEFAULT 1, + `status` VARCHAR(50) NOT NULL DEFAULT 'draft', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + INDEX `story_bibles_project_id_status_idx`(`project_id`, `status`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `world_bibles` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `project_id` BIGINT NOT NULL, + `world_type` VARCHAR(100) NULL, + `setting_text` TEXT NULL, + `rules_text` TEXT NULL, + `power_system` TEXT NULL, + `social_structure` TEXT NULL, + `time_period` TEXT NULL, + `visual_rules` TEXT NULL, + `forbidden_rules` TEXT NULL, + `status` VARCHAR(50) NOT NULL DEFAULT 'draft', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `world_bibles_project_id_status_idx`(`project_id`, `status`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `characters` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `project_id` BIGINT NOT NULL, + `name` VARCHAR(100) NOT NULL, + `alias_names` JSON NULL, + `role_type` VARCHAR(50) NOT NULL, + `gender_label` VARCHAR(50) NULL, + `age_group` VARCHAR(50) NULL, + `identity_desc` TEXT NULL, + `appearance_desc` TEXT NULL, + `face_desc` TEXT NULL, + `hair_desc` TEXT NULL, + `eye_desc` TEXT NULL, + `body_desc` TEXT NULL, + `costume_rules` TEXT NULL, + `special_props` TEXT NULL, + `personality_desc` TEXT NULL, + `speech_style` TEXT NULL, + `relationship_desc` TEXT NULL, + `character_arc` TEXT NULL, + `negative_rules` TEXT NULL, + `anchor_asset_id` BIGINT NULL, + `importance_level` INTEGER NOT NULL DEFAULT 0, + `status` VARCHAR(50) NOT NULL DEFAULT 'draft', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + INDEX `characters_project_id_role_type_idx`(`project_id`, `role_type`), + INDEX `characters_project_id_status_idx`(`project_id`, `status`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `character_images` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `project_id` BIGINT NOT NULL, + `character_id` BIGINT NOT NULL, + `asset_id` BIGINT NULL, + `image_type` VARCHAR(50) NOT NULL, + `prompt_text` TEXT NULL, + `negative_prompt` TEXT NULL, + `is_anchor` BOOLEAN NOT NULL DEFAULT false, + `quality_score` DECIMAL(5, 2) NULL, + `status` VARCHAR(50) NOT NULL DEFAULT 'pending', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `character_images_project_id_idx`(`project_id`), + INDEX `character_images_character_id_image_type_idx`(`character_id`, `image_type`), + INDEX `character_images_character_id_is_anchor_idx`(`character_id`, `is_anchor`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `character_memories` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `project_id` BIGINT NOT NULL, + `character_id` BIGINT NOT NULL, + `episode_id` BIGINT NULL, + `memory_type` VARCHAR(50) NOT NULL, + `content` TEXT NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `character_memories_project_id_character_id_idx`(`project_id`, `character_id`), + INDEX `character_memories_project_id_episode_id_idx`(`project_id`, `episode_id`), + INDEX `character_memories_memory_type_idx`(`memory_type`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `episodes` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `project_id` BIGINT NOT NULL, + `episode_no` INTEGER NOT NULL, + `source_chapter_ids` JSON NULL, + `title` VARCHAR(255) NULL, + `summary` TEXT NULL, + `opening_hook` TEXT NULL, + `middle_conflict` TEXT NULL, + `ending_hook` TEXT NULL, + `target_duration` INTEGER NULL, + `status` VARCHAR(50) NOT NULL DEFAULT 'draft', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + INDEX `episodes_project_id_status_idx`(`project_id`, `status`), + UNIQUE INDEX `episodes_project_id_episode_no_key`(`project_id`, `episode_no`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `episode_scripts` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `project_id` BIGINT NOT NULL, + `episode_id` BIGINT NOT NULL, + `script_text` LONGTEXT NULL, + `narration_text` LONGTEXT NULL, + `dialogue_json` JSON NULL, + `version` INTEGER NOT NULL DEFAULT 1, + `status` VARCHAR(50) NOT NULL DEFAULT 'draft', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + INDEX `episode_scripts_project_id_idx`(`project_id`), + INDEX `episode_scripts_episode_id_version_idx`(`episode_id`, `version`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `storyboard_shots` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `project_id` BIGINT NOT NULL, + `episode_id` BIGINT NOT NULL, + `shot_no` INTEGER NOT NULL, + `scene_name` VARCHAR(255) NULL, + `location_desc` TEXT NULL, + `characters_json` JSON NULL, + `visual_desc` TEXT NULL, + `action_desc` TEXT NULL, + `dialogue_text` TEXT NULL, + `narration_text` TEXT NULL, + `camera_motion` VARCHAR(100) NULL, + `effect_type` VARCHAR(100) NULL, + `duration` DECIMAL(6, 2) NULL, + `prompt_text` TEXT NULL, + `negative_prompt` TEXT NULL, + `status` VARCHAR(50) NOT NULL DEFAULT 'draft', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + INDEX `storyboard_shots_project_id_episode_id_shot_no_idx`(`project_id`, `episode_id`, `shot_no`), + INDEX `storyboard_shots_project_id_status_idx`(`project_id`, `status`), + UNIQUE INDEX `storyboard_shots_episode_id_shot_no_key`(`episode_id`, `shot_no`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `shot_images` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `project_id` BIGINT NOT NULL, + `episode_id` BIGINT NULL, + `shot_id` BIGINT NOT NULL, + `asset_id` BIGINT NULL, + `image_type` VARCHAR(50) NOT NULL DEFAULT 'preview', + `prompt_text` TEXT NULL, + `negative_prompt` TEXT NULL, + `quality_score` DECIMAL(5, 2) NULL, + `status` VARCHAR(50) NOT NULL DEFAULT 'pending', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `shot_images_project_id_episode_id_idx`(`project_id`, `episode_id`), + INDEX `shot_images_shot_id_image_type_idx`(`shot_id`, `image_type`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `plot_memories` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `project_id` BIGINT NOT NULL, + `episode_id` BIGINT NULL, + `chapter_id` BIGINT NULL, + `memory_type` VARCHAR(50) NOT NULL, + `content` TEXT NOT NULL, + `importance_level` INTEGER NOT NULL DEFAULT 0, + `status` VARCHAR(50) NOT NULL DEFAULT 'active', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `plot_memories_project_id_episode_id_idx`(`project_id`, `episode_id`), + INDEX `plot_memories_project_id_memory_type_idx`(`project_id`, `memory_type`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `plot_threads` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `project_id` BIGINT NOT NULL, + `thread_name` VARCHAR(255) NOT NULL, + `thread_type` VARCHAR(80) NOT NULL, + `description` TEXT NULL, + `start_episode_no` INTEGER NULL, + `expected_resolve_episode_no` INTEGER NULL, + `resolved_episode_no` INTEGER NULL, + `status` VARCHAR(50) NOT NULL DEFAULT 'open', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + INDEX `plot_threads_project_id_status_idx`(`project_id`, `status`), + INDEX `plot_threads_project_id_thread_type_idx`(`project_id`, `thread_type`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `continuity_checks` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `project_id` BIGINT NOT NULL, + `episode_id` BIGINT NULL, + `check_type` VARCHAR(80) NOT NULL, + `result_status` VARCHAR(50) NOT NULL, + `issue_text` TEXT NULL, + `suggestion_text` TEXT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `continuity_checks_project_id_episode_id_idx`(`project_id`, `episode_id`), + INDEX `continuity_checks_result_status_idx`(`result_status`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `assets` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `user_id` BIGINT NULL, + `project_id` BIGINT NULL, + `asset_type` VARCHAR(50) NOT NULL, + `file_path` VARCHAR(500) NOT NULL, + `file_url` VARCHAR(500) NULL, + `mime_type` VARCHAR(100) NULL, + `width` INTEGER NULL, + `height` INTEGER NULL, + `duration` DECIMAL(10, 2) NULL, + `size` BIGINT NULL, + `hash` VARCHAR(128) NULL, + `visibility` VARCHAR(30) NOT NULL DEFAULT 'private', + `status` VARCHAR(50) NOT NULL DEFAULT 'active', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `assets_project_id_asset_type_idx`(`project_id`, `asset_type`), + INDEX `assets_user_id_asset_type_idx`(`user_id`, `asset_type`), + INDEX `assets_hash_idx`(`hash`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `render_tasks` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `project_id` BIGINT NOT NULL, + `episode_id` BIGINT NULL, + `shot_id` BIGINT NULL, + `task_type` VARCHAR(80) NOT NULL, + `provider_id` BIGINT NULL, + `status` VARCHAR(50) NOT NULL DEFAULT 'pending', + `input_json` JSON NULL, + `input_hash` VARCHAR(128) NULL, + `idempotency_key` VARCHAR(191) NULL, + `output_asset_id` BIGINT NULL, + `provider_request_id` VARCHAR(255) NULL, + `retry_count` INTEGER NOT NULL DEFAULT 0, + `max_retry` INTEGER NOT NULL DEFAULT 0, + `cost_estimate` DECIMAL(12, 4) NULL, + `cost_actual` DECIMAL(12, 4) NULL, + `error_code` VARCHAR(100) NULL, + `error_message` TEXT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `started_at` DATETIME(3) NULL, + `finished_at` DATETIME(3) NULL, + + UNIQUE INDEX `render_tasks_idempotency_key_key`(`idempotency_key`), + INDEX `render_tasks_project_id_status_idx`(`project_id`, `status`), + INDEX `render_tasks_task_type_status_idx`(`task_type`, `status`), + INDEX `render_tasks_project_id_episode_id_idx`(`project_id`, `episode_id`), + INDEX `render_tasks_input_hash_idx`(`input_hash`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `provider_configs` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `provider_type` VARCHAR(80) NOT NULL, + `provider_code` VARCHAR(100) NOT NULL, + `display_name` VARCHAR(100) NULL, + `mode` VARCHAR(50) NOT NULL DEFAULT 'mock', + `model_name` VARCHAR(100) NULL, + `config_json` JSON NULL, + `fallback_provider_id` BIGINT NULL, + `is_enabled` BOOLEAN NOT NULL DEFAULT true, + `priority` INTEGER NOT NULL DEFAULT 0, + `rate_limit_json` JSON NULL, + `cost_rule_json` JSON NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + INDEX `provider_configs_provider_type_is_enabled_idx`(`provider_type`, `is_enabled`), + UNIQUE INDEX `provider_configs_provider_type_provider_code_key`(`provider_type`, `provider_code`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `provider_logs` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `provider_id` BIGINT NULL, + `task_id` BIGINT NULL, + `project_id` BIGINT NULL, + `provider_type` VARCHAR(80) NOT NULL, + `provider_code` VARCHAR(100) NULL, + `model_name` VARCHAR(100) NULL, + `request_json` JSON NULL, + `response_json` JSON NULL, + `input_size` INTEGER NULL, + `output_size` INTEGER NULL, + `cost_estimate` DECIMAL(12, 4) NULL, + `cost_actual` DECIMAL(12, 4) NULL, + `status` VARCHAR(50) NOT NULL DEFAULT 'success', + `error_code` VARCHAR(100) NULL, + `error_message` TEXT NULL, + `started_at` DATETIME(3) NULL, + `finished_at` DATETIME(3) NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `provider_logs_provider_type_status_idx`(`provider_type`, `status`), + INDEX `provider_logs_project_id_idx`(`project_id`), + INDEX `provider_logs_task_id_idx`(`task_id`), + INDEX `provider_logs_created_at_idx`(`created_at`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `orders` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `user_id` BIGINT NOT NULL, + `project_id` BIGINT NULL, + `order_no` VARCHAR(100) NOT NULL, + `package_code` VARCHAR(100) NULL, + `amount` DECIMAL(12, 2) NOT NULL DEFAULT 0, + `currency` VARCHAR(20) NOT NULL DEFAULT 'CNY', + `payment_method` VARCHAR(50) NULL, + `payment_status` VARCHAR(50) NOT NULL DEFAULT 'pending', + `paid_at` DATETIME(3) NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + UNIQUE INDEX `orders_order_no_key`(`order_no`), + INDEX `orders_user_id_payment_status_idx`(`user_id`, `payment_status`), + INDEX `orders_project_id_idx`(`project_id`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `quota_accounts` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `user_id` BIGINT NOT NULL, + `total_quota` DECIMAL(12, 2) NOT NULL DEFAULT 0, + `available_quota` DECIMAL(12, 2) NOT NULL DEFAULT 0, + `frozen_quota` DECIMAL(12, 2) NOT NULL DEFAULT 0, + `used_quota` DECIMAL(12, 2) NOT NULL DEFAULT 0, + `status` VARCHAR(50) NOT NULL DEFAULT 'active', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + UNIQUE INDEX `quota_accounts_user_id_key`(`user_id`), + INDEX `quota_accounts_status_idx`(`status`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `quota_logs` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `user_id` BIGINT NOT NULL, + `project_id` BIGINT NULL, + `task_id` BIGINT NULL, + `change_type` VARCHAR(50) NOT NULL, + `amount` DECIMAL(12, 2) NOT NULL, + `balance_after` DECIMAL(12, 2) NULL, + `reason` VARCHAR(255) NULL, + `metadata_json` JSON NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `quota_logs_user_id_created_at_idx`(`user_id`, `created_at`), + INDEX `quota_logs_project_id_idx`(`project_id`), + INDEX `quota_logs_task_id_idx`(`task_id`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `revision_requests` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `project_id` BIGINT NOT NULL, + `user_id` BIGINT NOT NULL, + `revision_type` VARCHAR(50) NOT NULL, + `target_type` VARCHAR(80) NULL, + `target_id` BIGINT NULL, + `description` TEXT NOT NULL, + `status` VARCHAR(50) NOT NULL DEFAULT 'pending', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + INDEX `revision_requests_project_id_status_idx`(`project_id`, `status`), + INDEX `revision_requests_user_id_status_idx`(`user_id`, `status`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `content_reviews` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `project_id` BIGINT NULL, + `user_id` BIGINT NULL, + `target_type` VARCHAR(80) NOT NULL, + `target_id` BIGINT NULL, + `review_type` VARCHAR(80) NOT NULL, + `result_status` VARCHAR(50) NOT NULL DEFAULT 'pending', + `risk_level` VARCHAR(50) NULL, + `issue_text` TEXT NULL, + `suggestion_text` TEXT NULL, + `reviewer_id` BIGINT NULL, + `reviewed_at` DATETIME(3) NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + INDEX `content_reviews_project_id_result_status_idx`(`project_id`, `result_status`), + INDEX `content_reviews_target_type_target_id_idx`(`target_type`, `target_id`), + INDEX `content_reviews_review_type_result_status_idx`(`review_type`, `result_status`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `case_showcases` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `project_id` BIGINT NOT NULL, + `user_id` BIGINT NULL, + `title` VARCHAR(255) NOT NULL, + `cover_asset_id` BIGINT NULL, + `video_asset_id` BIGINT NULL, + `authorization_status` VARCHAR(50) NOT NULL DEFAULT 'pending', + `visibility` VARCHAR(30) NOT NULL DEFAULT 'private', + `sort_order` INTEGER NOT NULL DEFAULT 0, + `published_at` DATETIME(3) NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + INDEX `case_showcases_visibility_sort_order_idx`(`visibility`, `sort_order`), + INDEX `case_showcases_project_id_idx`(`project_id`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `analytics_events` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `project_id` BIGINT NOT NULL, + `episode_id` BIGINT NULL, + `event_type` VARCHAR(80) NOT NULL, + `platform` VARCHAR(80) NULL, + `metric_json` JSON NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `analytics_events_project_id_idx`(`project_id`), + INDEX `analytics_events_episode_id_idx`(`episode_id`), + INDEX `analytics_events_event_type_created_at_idx`(`event_type`, `created_at`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `system_configs` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `config_key` VARCHAR(191) NOT NULL, + `config_value` JSON NULL, + `description` TEXT NULL, + `is_public` BOOLEAN NOT NULL DEFAULT false, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + + UNIQUE INDEX `system_configs_config_key_key`(`config_key`), + INDEX `system_configs_is_public_idx`(`is_public`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `operation_logs` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `user_id` BIGINT NULL, + `operator_role` VARCHAR(50) NULL, + `action` VARCHAR(100) NOT NULL, + `target_type` VARCHAR(80) NULL, + `target_id` BIGINT NULL, + `ip` VARCHAR(80) NULL, + `user_agent` TEXT NULL, + `metadata_json` JSON NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `operation_logs_user_id_created_at_idx`(`user_id`, `created_at`), + INDEX `operation_logs_target_type_target_id_idx`(`target_type`, `target_id`), + INDEX `operation_logs_action_created_at_idx`(`action`, `created_at`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/backend/prisma/migrations/20260602095000_live_action_ai_mode/migration.sql b/backend/prisma/migrations/20260602095000_live_action_ai_mode/migration.sql new file mode 100644 index 0000000..9bab9ee --- /dev/null +++ b/backend/prisma/migrations/20260602095000_live_action_ai_mode/migration.sql @@ -0,0 +1,55 @@ +ALTER TABLE `projects` + ADD COLUMN `output_mode` VARCHAR(50) NOT NULL DEFAULT 'image_manga' AFTER `output_type`, + ADD COLUMN `visual_mode` VARCHAR(100) NULL AFTER `output_mode`, + ADD COLUMN `video_generation_level` VARCHAR(50) NULL AFTER `visual_mode`; + +ALTER TABLE `storyboard_shots` + ADD COLUMN `live_action_desc` TEXT NULL AFTER `negative_prompt`, + ADD COLUMN `actor_action` TEXT NULL AFTER `live_action_desc`, + ADD COLUMN `camera_instruction` TEXT NULL AFTER `actor_action`, + ADD COLUMN `performance_instruction` TEXT NULL AFTER `camera_instruction`, + ADD COLUMN `video_prompt` TEXT NULL AFTER `performance_instruction`, + ADD COLUMN `keyframe_asset_id` BIGINT NULL AFTER `video_prompt`, + ADD COLUMN `video_clip_asset_id` BIGINT NULL AFTER `keyframe_asset_id`, + ADD COLUMN `video_status` VARCHAR(50) NULL AFTER `video_clip_asset_id`; + +CREATE TABLE `actor_profiles` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `project_id` BIGINT NOT NULL, + `character_id` BIGINT NOT NULL, + `actor_desc` TEXT NULL, + `appearance_rules` TEXT NULL, + `wardrobe_rules` TEXT NULL, + `performance_style` TEXT NULL, + `voice_style` TEXT NULL, + `reference_asset_ids` JSON NULL, + `anchor_asset_id` BIGINT NULL, + `status` VARCHAR(50) NOT NULL DEFAULT 'draft', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + UNIQUE INDEX `actor_profiles_project_id_character_id_key`(`project_id`, `character_id`), + INDEX `actor_profiles_project_id_status_idx`(`project_id`, `status`), + INDEX `actor_profiles_character_id_idx`(`character_id`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `video_clips` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `project_id` BIGINT NOT NULL, + `episode_id` BIGINT NOT NULL, + `shot_id` BIGINT NOT NULL, + `provider_id` BIGINT NULL, + `input_asset_id` BIGINT NULL, + `output_asset_id` BIGINT NULL, + `duration` DECIMAL(6, 2) NULL, + `prompt_text` TEXT NULL, + `status` VARCHAR(50) NOT NULL DEFAULT 'pending', + `cost_actual` DECIMAL(12, 4) NULL, + `retry_count` INTEGER NOT NULL DEFAULT 0, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + INDEX `video_clips_project_id_episode_id_idx`(`project_id`, `episode_id`), + INDEX `video_clips_shot_id_status_idx`(`shot_id`, `status`), + INDEX `video_clips_provider_id_idx`(`provider_id`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/backend/prisma/migrations/20260602112000_real_video_provider_qc/migration.sql b/backend/prisma/migrations/20260602112000_real_video_provider_qc/migration.sql new file mode 100644 index 0000000..8e44b4b --- /dev/null +++ b/backend/prisma/migrations/20260602112000_real_video_provider_qc/migration.sql @@ -0,0 +1,88 @@ +ALTER TABLE `video_clips` + ADD COLUMN `quality_status` VARCHAR(50) NULL AFTER `retry_count`, + ADD COLUMN `quality_score` DECIMAL(5, 2) NULL AFTER `quality_status`, + ADD COLUMN `quality_issues` JSON NULL AFTER `quality_score`; + +INSERT INTO `provider_configs` + (`provider_type`, `provider_code`, `display_name`, `mode`, `model_name`, `config_json`, `is_enabled`, `priority`, `rate_limit_json`, `cost_rule_json`, `created_at`, `updated_at`) +VALUES + ( + 'VideoProvider', + 'runway-image-to-video', + 'Runway Image-to-Video', + 'real', + 'gen4.5', + JSON_OBJECT( + 'driver', 'runway_image_to_video', + 'api_key_env', 'RUNWAYML_API_SECRET', + 'base_url', 'https://api.dev.runwayml.com', + 'api_version', '2024-11-06', + 'timeout_ms', 180000, + 'create_endpoint', '/v1/image_to_video', + 'task_endpoint_template', '/v1/tasks/{task_id}', + 'poll_interval_ms', 10000, + 'max_poll_attempts', 90, + 'ratio', '720:1280', + 'duration', 5, + 'note', '默认禁用。启用后必须在业务侧显式确认真实视频生成,避免误扣费。' + ), + false, + 40, + JSON_OBJECT('rpm', 5, 'concurrency', 1), + JSON_OBJECT( + 'flat_cost', 0, + 'unit', 'video_seconds', + 'price_per_second', 0, + 'currency', 'USD', + 'max_cost_per_call', 0, + 'daily_cost_limit', 0, + 'note', '请按 Runway 实际账单填写 price_per_second 或单次/每日成本上限。' + ), + NOW(3), + NOW(3) + ), + ( + 'VideoProvider', + 'kling-image-to-video', + 'Kling Image-to-Video', + 'real', + 'kling-v3', + JSON_OBJECT( + 'driver', 'kling_image_to_video', + 'api_key_env', 'KLING_API_KEY', + 'base_url', 'https://api-singapore.klingai.com', + 'timeout_ms', 180000, + 'create_endpoint', '/v1/videos/image2video', + 'task_endpoint_template', '/v1/videos/image2video/{task_id}', + 'poll_interval_ms', 10000, + 'max_poll_attempts', 90, + 'image_field', 'image', + 'prompt_field', 'prompt', + 'duration_field', 'duration', + 'aspect_ratio_field', 'aspect_ratio', + 'aspect_ratio', '9:16', + 'duration', 5, + 'note', '默认禁用。不同 Kling 官方/网关接口字段可能不同,可在高级配置里调整字段名和 Base URL。' + ), + false, + 40, + JSON_OBJECT('rpm', 5, 'concurrency', 1), + JSON_OBJECT( + 'flat_cost', 0, + 'unit', 'video_seconds', + 'price_per_second', 0, + 'currency', 'USD', + 'max_cost_per_call', 0, + 'daily_cost_limit', 0, + 'note', '请按 Kling 实际账单填写 price_per_second 或单次/每日成本上限。' + ), + NOW(3), + NOW(3) + ) +ON DUPLICATE KEY UPDATE + `display_name` = VALUES(`display_name`), + `mode` = VALUES(`mode`), + `model_name` = VALUES(`model_name`), + `priority` = VALUES(`priority`), + `rate_limit_json` = VALUES(`rate_limit_json`), + `updated_at` = NOW(3); diff --git a/backend/prisma/migrations/20260602130000_domestic_video_providers/migration.sql b/backend/prisma/migrations/20260602130000_domestic_video_providers/migration.sql new file mode 100644 index 0000000..784fea4 --- /dev/null +++ b/backend/prisma/migrations/20260602130000_domestic_video_providers/migration.sql @@ -0,0 +1,108 @@ +INSERT INTO `provider_configs` + (`provider_type`, `provider_code`, `display_name`, `mode`, `model_name`, `config_json`, `is_enabled`, `priority`, `rate_limit_json`, `cost_rule_json`, `created_at`, `updated_at`) +VALUES + ( + 'VideoProvider', + 'minimax_hailuo_23_fast', + 'MiniMax Hailuo 2.3 Fast 图生视频', + 'real', + 'MiniMax-Hailuo-2.3-Fast', + '{"driver":"configurable_image_to_video","error_prefix":"MINIMAX_VIDEO","api_key_env":"MINIMAX_API_KEY","base_url":"https://api.minimax.io","timeout_ms":300000,"create_endpoint":"/v1/video_generation","task_endpoint_template":"/v1/query/video_generation?task_id={task_id}","output_url_endpoint_template":"/v1/files/retrieve?file_id={file_id}","poll_interval_ms":10000,"max_poll_attempts":120,"image_field":"first_frame_image","prompt_field":"prompt","model_field":"model","duration_field":"duration","resolution_field":"resolution","duration":6,"resolution":"768P","extra_body_json":{"prompt_optimizer":true},"supports_reference_image":true,"supports_start_end_frame":false,"supports_audio":false,"supports_lipsync":false,"supports_character_reference":false,"note":"默认禁用。优先用于低成本快速验证真人短剧动效;成功后 MiniMax 返回 file_id,系统会再取 download_url 落库。"}', + false, + 80, + '{"rpm":5,"concurrency":1,"retry_limit":2}', + '{"flat_cost":0,"unit":"video_seconds","price_per_second":0.0317,"currency":"USD","max_cost_per_call":1,"daily_cost_limit":10,"estimated_seconds":6,"note":"预估价仅用于后台试算,请按 MiniMax 控制台实时价格和账单调整。"}', + NOW(3), + NOW(3) + ), + ( + 'VideoProvider', + 'minimax_hailuo_23', + 'MiniMax Hailuo 2.3 图生视频', + 'real', + 'MiniMax-Hailuo-2.3', + '{"driver":"configurable_image_to_video","error_prefix":"MINIMAX_VIDEO","api_key_env":"MINIMAX_API_KEY","base_url":"https://api.minimax.io","timeout_ms":300000,"create_endpoint":"/v1/video_generation","task_endpoint_template":"/v1/query/video_generation?task_id={task_id}","output_url_endpoint_template":"/v1/files/retrieve?file_id={file_id}","poll_interval_ms":10000,"max_poll_attempts":120,"image_field":"first_frame_image","prompt_field":"prompt","model_field":"model","duration_field":"duration","resolution_field":"resolution","duration":6,"resolution":"1080P","extra_body_json":{"prompt_optimizer":true},"supports_reference_image":true,"supports_start_end_frame":false,"supports_audio":false,"supports_lipsync":false,"supports_character_reference":false,"note":"默认禁用。质量优先于 Fast,适合正式样片对比;真实调用前必须在业务侧确认成本。"}', + false, + 78, + '{"rpm":5,"concurrency":1,"retry_limit":2}', + '{"flat_cost":0,"unit":"video_seconds","price_per_second":0.0467,"currency":"USD","max_cost_per_call":1.5,"daily_cost_limit":15,"estimated_seconds":6,"note":"预估价仅用于后台试算,请按 MiniMax 控制台实时价格和账单调整。"}', + NOW(3), + NOW(3) + ), + ( + 'VideoProvider', + 'alibaba_wan26_i2v_flash', + '阿里 Wan2.6 I2V Flash', + 'real', + 'wan2.6-i2v-flash', + '{"driver":"configurable_image_to_video","error_prefix":"DASHSCOPE_VIDEO","api_key_env":"ALIBABA_DASHSCOPE_API_KEY","base_url":"https://dashscope.aliyuncs.com","timeout_ms":300000,"create_endpoint":"/api/v1/services/aigc/video-generation/video-synthesis","task_endpoint_template":"/api/v1/tasks/{task_id}","poll_interval_ms":10000,"max_poll_attempts":120,"body_style":"dashscope_legacy_i2v","headers":{"X-DashScope-Async":"enable"},"duration":5,"resolution":"720P","prompt_extend":true,"watermark":false,"supports_reference_image":true,"supports_start_end_frame":false,"supports_audio":true,"supports_lipsync":false,"supports_character_reference":false,"note":"默认禁用。阿里/百炼不同模型版本字段可能变化,必要时在高级配置中调整 endpoint、body_style 或 extra_body_json。"}', + false, + 74, + '{"rpm":5,"concurrency":1,"retry_limit":2}', + '{"flat_cost":0,"unit":"video_seconds","price_per_second":0.0215,"currency":"USD","max_cost_per_call":1,"daily_cost_limit":10,"estimated_seconds":5,"note":"Flash 预估价仅用于试算,请按阿里云/百炼实际账单调整。"}', + NOW(3), + NOW(3) + ), + ( + 'VideoProvider', + 'alibaba_wan26_i2v', + '阿里 Wan2.6 I2V 标准', + 'real', + 'wan2.6-i2v', + '{"driver":"configurable_image_to_video","error_prefix":"DASHSCOPE_VIDEO","api_key_env":"ALIBABA_DASHSCOPE_API_KEY","base_url":"https://dashscope.aliyuncs.com","timeout_ms":300000,"create_endpoint":"/api/v1/services/aigc/video-generation/video-synthesis","task_endpoint_template":"/api/v1/tasks/{task_id}","poll_interval_ms":10000,"max_poll_attempts":120,"body_style":"dashscope_legacy_i2v","headers":{"X-DashScope-Async":"enable"},"duration":5,"resolution":"1080P","prompt_extend":true,"watermark":false,"supports_reference_image":true,"supports_start_end_frame":false,"supports_audio":true,"supports_lipsync":false,"supports_character_reference":false,"note":"默认禁用。标准模式适合正式出片对比;真实启用前请先小样本验证字段、速度和账单。"}', + false, + 72, + '{"rpm":5,"concurrency":1,"retry_limit":2}', + '{"flat_cost":0,"unit":"video_seconds","price_per_second":0.086,"currency":"USD","max_cost_per_call":2,"daily_cost_limit":20,"estimated_seconds":5,"note":"标准模式预估价仅用于试算,请按阿里云/百炼实际账单调整。"}', + NOW(3), + NOW(3) + ), + ( + 'VideoProvider', + 'vidu_q3_turbo_reference', + 'Vidu Q3 Turbo 参考图生视频', + 'real', + 'viduq3-turbo', + '{"driver":"configurable_image_to_video","error_prefix":"VIDU_VIDEO","api_key_env":"VIDU_API_KEY","base_url":"https://api.vidu.com","auth_scheme":"Token","timeout_ms":300000,"create_endpoint":"/ent/v2/reference2video","task_endpoint_template":"/ent/v2/tasks/{task_id}/creations","poll_interval_ms":10000,"max_poll_attempts":120,"body_style":"vidu_reference","duration":5,"resolution":"720p","aspect_ratio":"9:16","extra_body_json":{"audio":true,"movement_amplitude":"auto"},"supports_reference_image":true,"supports_start_end_frame":true,"supports_audio":true,"supports_lipsync":true,"supports_character_reference":true,"note":"默认禁用。适合做人物一致性和中文短剧感对比,参考图需要外部可访问 URL。"}', + false, + 70, + '{"rpm":5,"concurrency":1,"retry_limit":2}', + '{"flat_cost":0,"unit":"video_seconds","price_per_second":0.05,"currency":"USD","max_cost_per_call":1.5,"daily_cost_limit":15,"estimated_seconds":5,"note":"Vidu Q3 Turbo 预估价仅用于试算,请按 Vidu 控制台实时价格和 credits 消耗调整。"}', + NOW(3), + NOW(3) + ), + ( + 'VideoProvider', + 'vidu_q3_pro', + 'Vidu Q3 Pro 参考图生视频', + 'real', + 'viduq3', + '{"driver":"configurable_image_to_video","error_prefix":"VIDU_VIDEO","api_key_env":"VIDU_API_KEY","base_url":"https://api.vidu.com","auth_scheme":"Token","timeout_ms":300000,"create_endpoint":"/ent/v2/reference2video","task_endpoint_template":"/ent/v2/tasks/{task_id}/creations","poll_interval_ms":10000,"max_poll_attempts":120,"body_style":"vidu_reference","duration":5,"resolution":"1080p","aspect_ratio":"9:16","extra_body_json":{"audio":true,"movement_amplitude":"auto"},"supports_reference_image":true,"supports_start_end_frame":true,"supports_audio":true,"supports_lipsync":true,"supports_character_reference":true,"note":"默认禁用。质量优先,适合正式样片;真实启用前请先限制单次成本。"}', + false, + 68, + '{"rpm":5,"concurrency":1,"retry_limit":2}', + '{"flat_cost":0,"unit":"video_seconds","price_per_second":0.12,"currency":"USD","max_cost_per_call":3,"daily_cost_limit":30,"estimated_seconds":5,"note":"Vidu Q3 Pro 预估价仅用于试算,请按 Vidu 控制台实时价格和 credits 消耗调整。"}', + NOW(3), + NOW(3) + ), + ( + 'VideoProvider', + 'jimeng_seedance', + '即梦/Seedance 图生视频', + 'real', + 'doubao-seedance-1-5-pro-251215', + '{"driver":"configurable_image_to_video","error_prefix":"SEEDANCE_VIDEO","api_key_env":"VOLCENGINE_API_KEY","base_url":"https://ark.cn-beijing.volces.com/api/v3","timeout_ms":300000,"create_endpoint":"/videos/generations","task_endpoint_template":"/videos/generations/{task_id}","poll_interval_ms":10000,"max_poll_attempts":120,"image_field":"image_url","prompt_field":"prompt","model_field":"model","duration_field":"duration","resolution_field":"resolution","aspect_ratio_field":"ratio","duration":5,"resolution":"720p","aspect_ratio":"9:16","extra_body_json":{"fps":24,"watermark":false,"camerafixed":false},"supports_reference_image":true,"supports_start_end_frame":true,"supports_audio":true,"supports_lipsync":true,"supports_character_reference":true,"note":"默认禁用。不同火山/即梦/网关 API 字段差异较大,此配置作为可改模板,正式接入前必须用小样本验证。"}', + false, + 66, + '{"rpm":5,"concurrency":1,"retry_limit":2}', + '{"flat_cost":0,"unit":"video_seconds","price_per_second":0,"currency":"USD","max_cost_per_call":0,"daily_cost_limit":0,"estimated_seconds":5,"note":"Seedance/即梦价格按具体开通渠道差异较大,启用前请手动填写 price_per_second 和成本上限。"}', + NOW(3), + NOW(3) + ) +ON DUPLICATE KEY UPDATE + `display_name` = VALUES(`display_name`), + `mode` = VALUES(`mode`), + `model_name` = VALUES(`model_name`), + `priority` = VALUES(`priority`), + `rate_limit_json` = VALUES(`rate_limit_json`), + `updated_at` = NOW(3); diff --git a/backend/prisma/migrations/20260603093000_global_character_library/migration.sql b/backend/prisma/migrations/20260603093000_global_character_library/migration.sql new file mode 100644 index 0000000..6a2270b --- /dev/null +++ b/backend/prisma/migrations/20260603093000_global_character_library/migration.sql @@ -0,0 +1,62 @@ +CREATE TABLE `global_characters` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `name` VARCHAR(100) NOT NULL, + `display_name` VARCHAR(100) NULL, + `role_archetype` VARCHAR(50) NOT NULL DEFAULT 'lead', + `gender_label` VARCHAR(50) NULL, + `age_group` VARCHAR(50) NULL, + `identity_desc` TEXT NULL, + `appearance_desc` TEXT NULL, + `face_desc` TEXT NULL, + `hair_desc` TEXT NULL, + `eye_desc` TEXT NULL, + `body_desc` TEXT NULL, + `default_costume_rules` TEXT NULL, + `wardrobe_json` JSON NULL, + `special_props` TEXT NULL, + `personality_desc` TEXT NULL, + `speech_style` TEXT NULL, + `voice_provider_code` VARCHAR(100) NULL, + `voice_model` VARCHAR(100) NULL, + `voice_id` VARCHAR(100) NULL, + `voice_style` TEXT NULL, + `performance_style` TEXT NULL, + `negative_rules` TEXT NULL, + `anchor_asset_id` BIGINT NULL, + `voice_sample_asset_id` BIGINT NULL, + `commercial_status` VARCHAR(50) NOT NULL DEFAULT 'internal_test', + `usage_scope` VARCHAR(50) NOT NULL DEFAULT 'internal', + `status` VARCHAR(50) NOT NULL DEFAULT 'active', + `created_by_user_id` BIGINT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + INDEX `global_characters_status_role_archetype_idx`(`status`, `role_archetype`), + INDEX `global_characters_commercial_status_idx`(`commercial_status`), + INDEX `global_characters_created_by_user_id_idx`(`created_by_user_id`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `global_character_assets` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `global_character_id` BIGINT NOT NULL, + `asset_id` BIGINT NULL, + `asset_type` VARCHAR(50) NOT NULL, + `label` VARCHAR(100) NULL, + `prompt_text` TEXT NULL, + `is_primary` BOOLEAN NOT NULL DEFAULT false, + `status` VARCHAR(50) NOT NULL DEFAULT 'active', + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + INDEX `global_character_assets_global_character_id_asset_type_idx`(`global_character_id`, `asset_type`), + INDEX `global_character_assets_asset_id_idx`(`asset_id`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +ALTER TABLE `characters` + ADD COLUMN `global_character_id` BIGINT NULL, + ADD COLUMN `wardrobe_variant` VARCHAR(100) NULL, + ADD COLUMN `voice_provider_code` VARCHAR(100) NULL, + ADD COLUMN `voice_model` VARCHAR(100) NULL, + ADD COLUMN `voice_id` VARCHAR(100) NULL, + ADD COLUMN `voice_style` TEXT NULL, + ADD COLUMN `performance_style` TEXT NULL, + ADD INDEX `characters_global_character_id_idx`(`global_character_id`); diff --git a/backend/prisma/migrations/20260609220500_ai_router_v1_shot_scores/migration.sql b/backend/prisma/migrations/20260609220500_ai_router_v1_shot_scores/migration.sql new file mode 100644 index 0000000..7f41eb4 --- /dev/null +++ b/backend/prisma/migrations/20260609220500_ai_router_v1_shot_scores/migration.sql @@ -0,0 +1,8 @@ +ALTER TABLE `storyboard_shots` + ADD COLUMN `scene_type` VARCHAR(50) NULL AFTER `duration`, + ADD COLUMN `importance_score` INT NULL AFTER `scene_type`, + ADD COLUMN `emotion_score` INT NULL AFTER `importance_score`, + ADD COLUMN `action_score` INT NULL AFTER `emotion_score`, + ADD COLUMN `route_tier` VARCHAR(50) NULL AFTER `action_score`, + ADD INDEX `storyboard_shots_scene_type_idx`(`scene_type`), + ADD INDEX `storyboard_shots_route_tier_idx`(`route_tier`); diff --git a/backend/prisma/migrations/20260610011500_hit_analysis_v1/migration.sql b/backend/prisma/migrations/20260610011500_hit_analysis_v1/migration.sql new file mode 100644 index 0000000..5a36063 --- /dev/null +++ b/backend/prisma/migrations/20260610011500_hit_analysis_v1/migration.sql @@ -0,0 +1,79 @@ +CREATE TABLE `hit_analysis_cases` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `title` VARCHAR(255) NOT NULL, + `source_platform` VARCHAR(100) NULL, + `source_url` VARCHAR(500) NULL, + `content_type` VARCHAR(80) NOT NULL DEFAULT 'short_drama', + `genre` VARCHAR(100) NULL, + `language` VARCHAR(30) NOT NULL DEFAULT 'zh-CN', + `target_audience` VARCHAR(255) NULL, + `duration_seconds` INT NULL, + `episode_count` INT NULL, + `tags_json` JSON NULL, + `metrics_json` JSON NULL, + `transcript_text` LONGTEXT NULL, + `summary_text` TEXT NULL, + `analysis_json` JSON NULL, + `diagnosis_score` DECIMAL(5, 2) NULL, + `status` VARCHAR(50) NOT NULL DEFAULT 'draft', + `created_by_user_id` BIGINT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + PRIMARY KEY (`id`), + INDEX `hit_analysis_cases_source_platform_idx`(`source_platform`), + INDEX `hit_analysis_cases_genre_status_idx`(`genre`, `status`), + INDEX `hit_analysis_cases_status_diagnosis_score_idx`(`status`, `diagnosis_score`), + INDEX `hit_analysis_cases_created_at_idx`(`created_at`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `hit_analysis_segments` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `case_id` BIGINT NOT NULL, + `segment_no` INT NOT NULL, + `start_second` INT NULL, + `end_second` INT NULL, + `scene_type` VARCHAR(80) NULL, + `hook_type` VARCHAR(100) NULL, + `emotion` VARCHAR(80) NULL, + `conflict_type` VARCHAR(100) NULL, + `plot_function` VARCHAR(120) NULL, + `visual_strategy` VARCHAR(120) NULL, + `dialogue_pattern` VARCHAR(120) NULL, + `camera_notes` TEXT NULL, + `importance_score` INT NULL, + `emotion_score` INT NULL, + `action_score` INT NULL, + `tags_json` JSON NULL, + `summary_text` TEXT NULL, + `prompt_seed` TEXT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE INDEX `hit_analysis_segments_case_id_segment_no_key`(`case_id`, `segment_no`), + INDEX `hit_analysis_segments_case_id_idx`(`case_id`), + INDEX `hit_analysis_segments_scene_type_idx`(`scene_type`), + INDEX `hit_analysis_segments_hook_type_idx`(`hook_type`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +CREATE TABLE `creative_patterns` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `source_case_id` BIGINT NULL, + `pattern_type` VARCHAR(80) NOT NULL, + `title` VARCHAR(255) NOT NULL, + `genre` VARCHAR(100) NULL, + `language` VARCHAR(30) NOT NULL DEFAULT 'zh-CN', + `description` TEXT NULL, + `structure_json` JSON NULL, + `prompt_template` TEXT NULL, + `negative_prompt` TEXT NULL, + `tags_json` JSON NULL, + `usage_count` INT NOT NULL DEFAULT 0, + `effectiveness_score` DECIMAL(5, 2) NULL, + `status` VARCHAR(50) NOT NULL DEFAULT 'active', + `created_by_user_id` BIGINT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NOT NULL, + PRIMARY KEY (`id`), + INDEX `creative_patterns_pattern_type_status_idx`(`pattern_type`, `status`), + INDEX `creative_patterns_genre_status_idx`(`genre`, `status`), + INDEX `creative_patterns_source_case_id_idx`(`source_case_id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/backend/prisma/migrations/20260610133500_project_creative_patterns_v1/migration.sql b/backend/prisma/migrations/20260610133500_project_creative_patterns_v1/migration.sql new file mode 100644 index 0000000..e93dd55 --- /dev/null +++ b/backend/prisma/migrations/20260610133500_project_creative_patterns_v1/migration.sql @@ -0,0 +1,13 @@ +CREATE TABLE `project_creative_patterns` ( + `id` BIGINT NOT NULL AUTO_INCREMENT, + `project_id` BIGINT NOT NULL, + `creative_pattern_id` BIGINT NOT NULL, + `source` VARCHAR(50) NOT NULL DEFAULT 'user_selected', + `snapshot_json` JSON NULL, + `sort_order` INT NOT NULL DEFAULT 0, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE INDEX `project_creative_patterns_project_id_creative_pattern_id_key`(`project_id`, `creative_pattern_id`), + INDEX `project_creative_patterns_project_id_sort_order_idx`(`project_id`, `sort_order`), + INDEX `project_creative_patterns_creative_pattern_id_idx`(`creative_pattern_id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 0000000..3b67c15 --- /dev/null +++ b/backend/prisma/schema.prisma @@ -0,0 +1,826 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "mysql" + url = env("DATABASE_URL") +} + +model User { + id BigInt @id @default(autoincrement()) + email String? @unique @db.VarChar(191) + phone String? @unique @db.VarChar(50) + password_hash String @db.VarChar(255) + nickname String? @db.VarChar(100) + avatar_url String? @db.VarChar(500) + role String @default("user") @db.VarChar(50) + status String @default("active") @db.VarChar(50) + wechat_openid String? @unique @db.VarChar(191) + last_login_at DateTime? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([role, status]) + @@index([created_at]) + @@map("users") +} + +model Project { + id BigInt @id @default(autoincrement()) + user_id BigInt + title String? @db.VarChar(255) + input_mode String @db.VarChar(50) + genre String? @db.VarChar(100) + style_code String? @db.VarChar(100) + output_type String? @db.VarChar(50) + output_mode String @default("image_manga") @db.VarChar(50) + visual_mode String? @db.VarChar(100) + video_generation_level String? @db.VarChar(50) + target_episode_count Int? + episode_duration Int? + status String @default("draft") @db.VarChar(80) + copyright_status String @default("pending") @db.VarChar(80) + payment_status String @default("unpaid") @db.VarChar(80) + quality_level String? @db.VarChar(50) + is_long_series Boolean @default(false) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + completed_at DateTime? + + @@index([user_id, status]) + @@index([genre, status]) + @@index([created_at]) + @@map("projects") +} + +model NovelSource { + id BigInt @id @default(autoincrement()) + project_id BigInt + source_type String @db.VarChar(50) + title String? @db.VarChar(255) + author_name String? @db.VarChar(100) + raw_asset_id BigInt? + raw_text String? @db.LongText + clean_text String? @db.LongText + word_count Int? + chapter_count Int? + parse_status String @default("pending") @db.VarChar(50) + parse_report Json? + created_at DateTime @default(now()) + + @@index([project_id]) + @@index([parse_status]) + @@map("novel_sources") +} + +model NovelChapter { + id BigInt @id @default(autoincrement()) + project_id BigInt + novel_source_id BigInt? + chapter_no Int + title String? @db.VarChar(255) + content String @db.LongText + summary String? @db.Text + visual_summary String? @db.Text + word_count Int? + status String @default("draft") @db.VarChar(50) + created_at DateTime @default(now()) + + @@unique([novel_source_id, chapter_no]) + @@index([project_id, chapter_no]) + @@index([project_id, status]) + @@map("novel_chapters") +} + +model CopyrightRecord { + id BigInt @id @default(autoincrement()) + project_id BigInt + user_id BigInt + authorization_type String @db.VarChar(50) + statement_text String @db.Text + ip String? @db.VarChar(80) + user_agent String? @db.Text + confirmed_at DateTime @default(now()) + + @@index([project_id]) + @@index([user_id]) + @@index([authorization_type]) + @@map("copyright_records") +} + +model StoryBible { + id BigInt @id @default(autoincrement()) + project_id BigInt + title String? @db.VarChar(255) + logline String? @db.Text + main_plot String? @db.Text + core_conflict String? @db.Text + selling_points String? @db.Text + tone String? @db.VarChar(100) + world_summary String? @db.Text + ending_direction String? @db.Text + taboo_rules String? @db.Text + version Int @default(1) + status String @default("draft") @db.VarChar(50) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([project_id, status]) + @@map("story_bibles") +} + +model WorldBible { + id BigInt @id @default(autoincrement()) + project_id BigInt + world_type String? @db.VarChar(100) + setting_text String? @db.Text + rules_text String? @db.Text + power_system String? @db.Text + social_structure String? @db.Text + time_period String? @db.Text + visual_rules String? @db.Text + forbidden_rules String? @db.Text + status String @default("draft") @db.VarChar(50) + created_at DateTime @default(now()) + + @@index([project_id, status]) + @@map("world_bibles") +} + +model Character { + id BigInt @id @default(autoincrement()) + project_id BigInt + global_character_id BigInt? + name String @db.VarChar(100) + alias_names Json? + role_type String @db.VarChar(50) + gender_label String? @db.VarChar(50) + age_group String? @db.VarChar(50) + identity_desc String? @db.Text + appearance_desc String? @db.Text + face_desc String? @db.Text + hair_desc String? @db.Text + eye_desc String? @db.Text + body_desc String? @db.Text + costume_rules String? @db.Text + special_props String? @db.Text + personality_desc String? @db.Text + speech_style String? @db.Text + relationship_desc String? @db.Text + character_arc String? @db.Text + negative_rules String? @db.Text + anchor_asset_id BigInt? + wardrobe_variant String? @db.VarChar(100) + voice_provider_code String? @db.VarChar(100) + voice_model String? @db.VarChar(100) + voice_id String? @db.VarChar(100) + voice_style String? @db.Text + performance_style String? @db.Text + importance_level Int @default(0) + status String @default("draft") @db.VarChar(50) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([project_id, role_type]) + @@index([project_id, status]) + @@index([global_character_id]) + @@map("characters") +} + +model GlobalCharacter { + id BigInt @id @default(autoincrement()) + name String @db.VarChar(100) + display_name String? @db.VarChar(100) + role_archetype String @default("lead") @db.VarChar(50) + gender_label String? @db.VarChar(50) + age_group String? @db.VarChar(50) + identity_desc String? @db.Text + appearance_desc String? @db.Text + face_desc String? @db.Text + hair_desc String? @db.Text + eye_desc String? @db.Text + body_desc String? @db.Text + default_costume_rules String? @db.Text + wardrobe_json Json? + special_props String? @db.Text + personality_desc String? @db.Text + speech_style String? @db.Text + voice_provider_code String? @db.VarChar(100) + voice_model String? @db.VarChar(100) + voice_id String? @db.VarChar(100) + voice_style String? @db.Text + performance_style String? @db.Text + negative_rules String? @db.Text + anchor_asset_id BigInt? + voice_sample_asset_id BigInt? + commercial_status String @default("internal_test") @db.VarChar(50) + usage_scope String @default("internal") @db.VarChar(50) + status String @default("active") @db.VarChar(50) + created_by_user_id BigInt? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([status, role_archetype]) + @@index([commercial_status]) + @@index([created_by_user_id]) + @@map("global_characters") +} + +model GlobalCharacterAsset { + id BigInt @id @default(autoincrement()) + global_character_id BigInt + asset_id BigInt? + asset_type String @db.VarChar(50) + label String? @db.VarChar(100) + prompt_text String? @db.Text + is_primary Boolean @default(false) + status String @default("active") @db.VarChar(50) + created_at DateTime @default(now()) + + @@index([global_character_id, asset_type]) + @@index([asset_id]) + @@map("global_character_assets") +} + +model CharacterImage { + id BigInt @id @default(autoincrement()) + project_id BigInt + character_id BigInt + asset_id BigInt? + image_type String @db.VarChar(50) + prompt_text String? @db.Text + negative_prompt String? @db.Text + is_anchor Boolean @default(false) + quality_score Decimal? @db.Decimal(5, 2) + status String @default("pending") @db.VarChar(50) + created_at DateTime @default(now()) + + @@index([project_id]) + @@index([character_id, image_type]) + @@index([character_id, is_anchor]) + @@map("character_images") +} + +model CharacterMemory { + id BigInt @id @default(autoincrement()) + project_id BigInt + character_id BigInt + episode_id BigInt? + memory_type String @db.VarChar(50) + content String @db.Text + created_at DateTime @default(now()) + + @@index([project_id, character_id]) + @@index([project_id, episode_id]) + @@index([memory_type]) + @@map("character_memories") +} + +model Episode { + id BigInt @id @default(autoincrement()) + project_id BigInt + episode_no Int + source_chapter_ids Json? + title String? @db.VarChar(255) + summary String? @db.Text + opening_hook String? @db.Text + middle_conflict String? @db.Text + ending_hook String? @db.Text + target_duration Int? + status String @default("draft") @db.VarChar(50) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@unique([project_id, episode_no]) + @@index([project_id, status]) + @@map("episodes") +} + +model EpisodeScript { + id BigInt @id @default(autoincrement()) + project_id BigInt + episode_id BigInt + script_text String? @db.LongText + narration_text String? @db.LongText + dialogue_json Json? + version Int @default(1) + status String @default("draft") @db.VarChar(50) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([project_id]) + @@index([episode_id, version]) + @@map("episode_scripts") +} + +model StoryboardShot { + id BigInt @id @default(autoincrement()) + project_id BigInt + episode_id BigInt + shot_no Int + scene_name String? @db.VarChar(255) + location_desc String? @db.Text + characters_json Json? + visual_desc String? @db.Text + action_desc String? @db.Text + dialogue_text String? @db.Text + narration_text String? @db.Text + camera_motion String? @db.VarChar(100) + effect_type String? @db.VarChar(100) + duration Decimal? @db.Decimal(6, 2) + scene_type String? @db.VarChar(50) + importance_score Int? + emotion_score Int? + action_score Int? + route_tier String? @db.VarChar(50) + prompt_text String? @db.Text + negative_prompt String? @db.Text + live_action_desc String? @db.Text + actor_action String? @db.Text + camera_instruction String? @db.Text + performance_instruction String? @db.Text + video_prompt String? @db.Text + keyframe_asset_id BigInt? + video_clip_asset_id BigInt? + video_status String? @db.VarChar(50) + status String @default("draft") @db.VarChar(50) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@unique([episode_id, shot_no]) + @@index([project_id, episode_id, shot_no]) + @@index([project_id, status]) + @@index([scene_type]) + @@index([route_tier]) + @@map("storyboard_shots") +} + +model ShotImage { + id BigInt @id @default(autoincrement()) + project_id BigInt + episode_id BigInt? + shot_id BigInt + asset_id BigInt? + image_type String @default("preview") @db.VarChar(50) + prompt_text String? @db.Text + negative_prompt String? @db.Text + quality_score Decimal? @db.Decimal(5, 2) + status String @default("pending") @db.VarChar(50) + created_at DateTime @default(now()) + + @@index([project_id, episode_id]) + @@index([shot_id, image_type]) + @@map("shot_images") +} + +model ActorProfile { + id BigInt @id @default(autoincrement()) + project_id BigInt + character_id BigInt + actor_desc String? @db.Text + appearance_rules String? @db.Text + wardrobe_rules String? @db.Text + performance_style String? @db.Text + voice_style String? @db.Text + reference_asset_ids Json? + anchor_asset_id BigInt? + status String @default("draft") @db.VarChar(50) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@unique([project_id, character_id]) + @@index([project_id, status]) + @@index([character_id]) + @@map("actor_profiles") +} + +model VideoClip { + id BigInt @id @default(autoincrement()) + project_id BigInt + episode_id BigInt + shot_id BigInt + provider_id BigInt? + input_asset_id BigInt? + output_asset_id BigInt? + duration Decimal? @db.Decimal(6, 2) + prompt_text String? @db.Text + status String @default("pending") @db.VarChar(50) + cost_actual Decimal? @db.Decimal(12, 4) + retry_count Int @default(0) + quality_status String? @db.VarChar(50) + quality_score Decimal? @db.Decimal(5, 2) + quality_issues Json? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([project_id, episode_id]) + @@index([shot_id, status]) + @@index([provider_id]) + @@map("video_clips") +} + +model PlotMemory { + id BigInt @id @default(autoincrement()) + project_id BigInt + episode_id BigInt? + chapter_id BigInt? + memory_type String @db.VarChar(50) + content String @db.Text + importance_level Int @default(0) + status String @default("active") @db.VarChar(50) + created_at DateTime @default(now()) + + @@index([project_id, episode_id]) + @@index([project_id, memory_type]) + @@map("plot_memories") +} + +model PlotThread { + id BigInt @id @default(autoincrement()) + project_id BigInt + thread_name String @db.VarChar(255) + thread_type String @db.VarChar(80) + description String? @db.Text + start_episode_no Int? + expected_resolve_episode_no Int? + resolved_episode_no Int? + status String @default("open") @db.VarChar(50) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([project_id, status]) + @@index([project_id, thread_type]) + @@map("plot_threads") +} + +model ContinuityCheck { + id BigInt @id @default(autoincrement()) + project_id BigInt + episode_id BigInt? + check_type String @db.VarChar(80) + result_status String @db.VarChar(50) + issue_text String? @db.Text + suggestion_text String? @db.Text + created_at DateTime @default(now()) + + @@index([project_id, episode_id]) + @@index([result_status]) + @@map("continuity_checks") +} + +model Asset { + id BigInt @id @default(autoincrement()) + user_id BigInt? + project_id BigInt? + asset_type String @db.VarChar(50) + file_path String @db.VarChar(500) + file_url String? @db.VarChar(500) + mime_type String? @db.VarChar(100) + width Int? + height Int? + duration Decimal? @db.Decimal(10, 2) + size BigInt? + hash String? @db.VarChar(128) + visibility String @default("private") @db.VarChar(30) + status String @default("active") @db.VarChar(50) + created_at DateTime @default(now()) + + @@index([project_id, asset_type]) + @@index([user_id, asset_type]) + @@index([hash]) + @@map("assets") +} + +model RenderTask { + id BigInt @id @default(autoincrement()) + project_id BigInt + episode_id BigInt? + shot_id BigInt? + task_type String @db.VarChar(80) + provider_id BigInt? + status String @default("pending") @db.VarChar(50) + input_json Json? + input_hash String? @db.VarChar(128) + idempotency_key String? @unique @db.VarChar(191) + output_asset_id BigInt? + provider_request_id String? @db.VarChar(255) + retry_count Int @default(0) + max_retry Int @default(0) + cost_estimate Decimal? @db.Decimal(12, 4) + cost_actual Decimal? @db.Decimal(12, 4) + error_code String? @db.VarChar(100) + error_message String? @db.Text + created_at DateTime @default(now()) + started_at DateTime? + finished_at DateTime? + + @@index([project_id, status]) + @@index([task_type, status]) + @@index([project_id, episode_id]) + @@index([input_hash]) + @@map("render_tasks") +} + +model ProviderConfig { + id BigInt @id @default(autoincrement()) + provider_type String @db.VarChar(80) + provider_code String @db.VarChar(100) + display_name String? @db.VarChar(100) + mode String @default("mock") @db.VarChar(50) + model_name String? @db.VarChar(100) + config_json Json? + fallback_provider_id BigInt? + is_enabled Boolean @default(true) + priority Int @default(0) + rate_limit_json Json? + cost_rule_json Json? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@unique([provider_type, provider_code]) + @@index([provider_type, is_enabled]) + @@map("provider_configs") +} + +model ProviderLog { + id BigInt @id @default(autoincrement()) + provider_id BigInt? + task_id BigInt? + project_id BigInt? + provider_type String @db.VarChar(80) + provider_code String? @db.VarChar(100) + model_name String? @db.VarChar(100) + request_json Json? + response_json Json? + input_size Int? + output_size Int? + cost_estimate Decimal? @db.Decimal(12, 4) + cost_actual Decimal? @db.Decimal(12, 4) + status String @default("success") @db.VarChar(50) + error_code String? @db.VarChar(100) + error_message String? @db.Text + started_at DateTime? + finished_at DateTime? + created_at DateTime @default(now()) + + @@index([provider_type, status]) + @@index([project_id]) + @@index([task_id]) + @@index([created_at]) + @@map("provider_logs") +} + +model Order { + id BigInt @id @default(autoincrement()) + user_id BigInt + project_id BigInt? + order_no String @unique @db.VarChar(100) + package_code String? @db.VarChar(100) + amount Decimal @default(0) @db.Decimal(12, 2) + currency String @default("CNY") @db.VarChar(20) + payment_method String? @db.VarChar(50) + payment_status String @default("pending") @db.VarChar(50) + paid_at DateTime? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([user_id, payment_status]) + @@index([project_id]) + @@map("orders") +} + +model QuotaAccount { + id BigInt @id @default(autoincrement()) + user_id BigInt @unique + total_quota Decimal @default(0) @db.Decimal(12, 2) + available_quota Decimal @default(0) @db.Decimal(12, 2) + frozen_quota Decimal @default(0) @db.Decimal(12, 2) + used_quota Decimal @default(0) @db.Decimal(12, 2) + status String @default("active") @db.VarChar(50) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([status]) + @@map("quota_accounts") +} + +model QuotaLog { + id BigInt @id @default(autoincrement()) + user_id BigInt + project_id BigInt? + task_id BigInt? + change_type String @db.VarChar(50) + amount Decimal @db.Decimal(12, 2) + balance_after Decimal? @db.Decimal(12, 2) + reason String? @db.VarChar(255) + metadata_json Json? + created_at DateTime @default(now()) + + @@index([user_id, created_at]) + @@index([project_id]) + @@index([task_id]) + @@map("quota_logs") +} + +model RevisionRequest { + id BigInt @id @default(autoincrement()) + project_id BigInt + user_id BigInt + revision_type String @db.VarChar(50) + target_type String? @db.VarChar(80) + target_id BigInt? + description String @db.Text + status String @default("pending") @db.VarChar(50) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([project_id, status]) + @@index([user_id, status]) + @@map("revision_requests") +} + +model ContentReview { + id BigInt @id @default(autoincrement()) + project_id BigInt? + user_id BigInt? + target_type String @db.VarChar(80) + target_id BigInt? + review_type String @db.VarChar(80) + result_status String @default("pending") @db.VarChar(50) + risk_level String? @db.VarChar(50) + issue_text String? @db.Text + suggestion_text String? @db.Text + reviewer_id BigInt? + reviewed_at DateTime? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([project_id, result_status]) + @@index([target_type, target_id]) + @@index([review_type, result_status]) + @@map("content_reviews") +} + +model CaseShowcase { + id BigInt @id @default(autoincrement()) + project_id BigInt + user_id BigInt? + title String @db.VarChar(255) + cover_asset_id BigInt? + video_asset_id BigInt? + authorization_status String @default("pending") @db.VarChar(50) + visibility String @default("private") @db.VarChar(30) + sort_order Int @default(0) + published_at DateTime? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([visibility, sort_order]) + @@index([project_id]) + @@map("case_showcases") +} + +model HitAnalysisCase { + id BigInt @id @default(autoincrement()) + title String @db.VarChar(255) + source_platform String? @db.VarChar(100) + source_url String? @db.VarChar(500) + content_type String @default("short_drama") @db.VarChar(80) + genre String? @db.VarChar(100) + language String @default("zh-CN") @db.VarChar(30) + target_audience String? @db.VarChar(255) + duration_seconds Int? + episode_count Int? + tags_json Json? + metrics_json Json? + transcript_text String? @db.LongText + summary_text String? @db.Text + analysis_json Json? + diagnosis_score Decimal? @db.Decimal(5, 2) + status String @default("draft") @db.VarChar(50) + created_by_user_id BigInt? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([source_platform]) + @@index([genre, status]) + @@index([status, diagnosis_score]) + @@index([created_at]) + @@map("hit_analysis_cases") +} + +model HitAnalysisSegment { + id BigInt @id @default(autoincrement()) + case_id BigInt + segment_no Int + start_second Int? + end_second Int? + scene_type String? @db.VarChar(80) + hook_type String? @db.VarChar(100) + emotion String? @db.VarChar(80) + conflict_type String? @db.VarChar(100) + plot_function String? @db.VarChar(120) + visual_strategy String? @db.VarChar(120) + dialogue_pattern String? @db.VarChar(120) + camera_notes String? @db.Text + importance_score Int? + emotion_score Int? + action_score Int? + tags_json Json? + summary_text String? @db.Text + prompt_seed String? @db.Text + created_at DateTime @default(now()) + + @@unique([case_id, segment_no]) + @@index([case_id]) + @@index([scene_type]) + @@index([hook_type]) + @@map("hit_analysis_segments") +} + +model CreativePattern { + id BigInt @id @default(autoincrement()) + source_case_id BigInt? + pattern_type String @db.VarChar(80) + title String @db.VarChar(255) + genre String? @db.VarChar(100) + language String @default("zh-CN") @db.VarChar(30) + description String? @db.Text + structure_json Json? + prompt_template String? @db.Text + negative_prompt String? @db.Text + tags_json Json? + usage_count Int @default(0) + effectiveness_score Decimal? @db.Decimal(5, 2) + status String @default("active") @db.VarChar(50) + created_by_user_id BigInt? + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([pattern_type, status]) + @@index([genre, status]) + @@index([source_case_id]) + @@map("creative_patterns") +} + +model ProjectCreativePattern { + id BigInt @id @default(autoincrement()) + project_id BigInt + creative_pattern_id BigInt + source String @default("user_selected") @db.VarChar(50) + snapshot_json Json? + sort_order Int @default(0) + created_at DateTime @default(now()) + + @@unique([project_id, creative_pattern_id]) + @@index([project_id, sort_order]) + @@index([creative_pattern_id]) + @@map("project_creative_patterns") +} + +model AnalyticsEvent { + id BigInt @id @default(autoincrement()) + project_id BigInt + episode_id BigInt? + event_type String @db.VarChar(80) + platform String? @db.VarChar(80) + metric_json Json? + created_at DateTime @default(now()) + + @@index([project_id]) + @@index([episode_id]) + @@index([event_type, created_at]) + @@map("analytics_events") +} + +model SystemConfig { + id BigInt @id @default(autoincrement()) + config_key String @unique @db.VarChar(191) + config_value Json? + description String? @db.Text + is_public Boolean @default(false) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@index([is_public]) + @@map("system_configs") +} + +model OperationLog { + id BigInt @id @default(autoincrement()) + user_id BigInt? + operator_role String? @db.VarChar(50) + action String @db.VarChar(100) + target_type String? @db.VarChar(80) + target_id BigInt? + ip String? @db.VarChar(80) + user_agent String? @db.Text + metadata_json Json? + created_at DateTime @default(now()) + + @@index([user_id, created_at]) + @@index([target_type, target_id]) + @@index([action, created_at]) + @@map("operation_logs") +} diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts new file mode 100644 index 0000000..abdcb59 --- /dev/null +++ b/backend/prisma/seed.ts @@ -0,0 +1,147 @@ +import { PrismaClient } from '@prisma/client'; +import { hash } from 'bcryptjs'; +import { DEFAULT_AI_ROUTER_CONFIG } from '../src/ai-router/ai-router.types'; + +const prisma = new PrismaClient(); + +const mockProviders = [ + ['TextProvider', 'mock-text', 'Mock Text Provider', 'mock-text-v1'], + ['NovelProvider', 'mock-novel', 'Mock Novel Provider', 'mock-novel-v1'], + ['ImageProvider', 'mock-image', 'Mock Image Provider', 'mock-image-v1'], + ['VideoProvider', 'mock-video', 'Mock Video Provider', 'mock-video-v1'], + ['VoiceProvider', 'mock-voice', 'Mock Voice Provider', 'mock-voice-v1'], + ['LipSyncProvider', 'mock-lipsync', 'Mock Lip Sync Provider', 'mock-lipsync-v1'], + ['ModerationProvider', 'mock-moderation', 'Mock Moderation Provider', 'mock-moderation-v1'], + ['QualityCheckProvider', 'mock-qc', 'Mock Quality Check Provider', 'mock-qc-v1'], + ['FileParseProvider', 'mock-file-parse', 'Mock File Parse Provider', 'mock-file-parse-v1'], + ['EmbeddingProvider', 'mock-embedding', 'Mock Embedding Provider', 'mock-embedding-v1'] +] as const; + +async function main() { + const adminPassword = process.env.SEED_ADMIN_PASSWORD || 'Admin123!'; + const adminPasswordHash = await hash(adminPassword, 12); + const admin = await prisma.user.upsert({ + where: { email: 'admin@example.com' }, + update: { + password_hash: adminPasswordHash, + role: 'admin', + status: 'active' + }, + create: { + email: 'admin@example.com', + password_hash: adminPasswordHash, + nickname: 'System Admin', + role: 'admin', + status: 'active' + } + }); + + for (const [providerType, providerCode, displayName, modelName] of mockProviders) { + const isEnabled = providerCode !== 'mock-lipsync'; + + await prisma.providerConfig.upsert({ + where: { + provider_type_provider_code: { + provider_type: providerType, + provider_code: providerCode + } + }, + update: { + display_name: displayName, + mode: 'mock', + model_name: modelName, + is_enabled: isEnabled, + priority: 100, + rate_limit_json: { + rpm: 120, + concurrency: 8 + }, + cost_rule_json: { + flat_cost: 0, + unit: 'mock' + } + }, + create: { + provider_type: providerType, + provider_code: providerCode, + display_name: displayName, + mode: 'mock', + model_name: modelName, + is_enabled: isEnabled, + priority: 100, + config_json: { + note: 'Used until the MVP flow is complete.' + }, + rate_limit_json: { + rpm: 120, + concurrency: 8 + }, + cost_rule_json: { + flat_cost: 0, + unit: 'mock' + } + } + }); + } + + await prisma.systemConfig.upsert({ + where: { config_key: 'system_a.current_stage' }, + update: { + config_value: { + stage: 'stage-02-database-schema' + } + }, + create: { + config_key: 'system_a.current_stage', + config_value: { + stage: 'stage-02-database-schema' + }, + description: 'Tracks current System A development stage.' + } + }); + + await prisma.systemConfig.upsert({ + where: { config_key: 'security.api_crypto_enabled' }, + update: {}, + create: { + config_key: 'security.api_crypto_enabled', + config_value: { + enabled: false + }, + description: 'Controls frontend/backend API payload encryption. Default off for testing; enable manually in production.', + is_public: true + } + }); + + await prisma.systemConfig.upsert({ + where: { config_key: 'ai.router.v1' }, + update: {}, + create: { + config_key: 'ai.router.v1', + config_value: DEFAULT_AI_ROUTER_CONFIG, + description: 'AI Router V1 route config for automatic provider selection by language, shot score and budget.', + is_public: false + } + }); + + await prisma.quotaAccount.upsert({ + where: { user_id: admin.id }, + update: {}, + create: { + user_id: admin.id, + total_quota: 100, + available_quota: 100, + status: 'active' + } + }); +} + +main() + .then(async () => { + await prisma.$disconnect(); + }) + .catch(async (error) => { + console.error(error); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/backend/src/admin/admin.controller.ts b/backend/src/admin/admin.controller.ts new file mode 100644 index 0000000..778e0dc --- /dev/null +++ b/backend/src/admin/admin.controller.ts @@ -0,0 +1,320 @@ +import { + Body, + Controller, + Get, + Inject, + Param, + Patch, + Post, + Query, + UseGuards +} from '@nestjs/common'; +import type { AuthRequestUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { + AdminListAssetsQueryDto, + AdminListCharactersQueryDto, + AdminListCopyrightRecordsQueryDto, + AdminListGlobalCharactersQueryDto, + AdminListHitAnalysesQueryDto, + AdminListNovelChaptersQueryDto, + AdminListNovelSourcesQueryDto, + AdminListOperationLogsQueryDto, + AdminBindCharacterGlobalDto, + AdminAnalyzeHitCaseDto, + AdminCreateHitAnalysisCaseDto, + AdminListProjectsQueryDto, + AdminListRouterAuditsQueryDto, + AdminListStoryboardShotsQueryDto, + AdminListUsersQueryDto, + AdminListWorksQueryDto, + AdminListCreativePatternsQueryDto, + AdminPromoteHitCasePatternsDto, + AdminResetUserPasswordDto, + AdminSaveGlobalCharacterDto, + AdminUpdateRouterAuditQualityDto, + AdminUpdateCreativePatternDto, + AdminUpdateCreativePatternStatusDto, + AdminUpdateProjectStatusDto, + AdminUpdateUserRoleDto, + AdminUpdateUserStatusDto, + AdminUpdateSystemConfigDto +} from './admin.dto'; +import { AdminService } from './admin.service'; + +@Controller('admin') +@UseGuards(JwtAuthGuard) +export class AdminController { + constructor(@Inject(AdminService) private readonly adminService: AdminService) {} + + @Get('dashboard') + getDashboard(@CurrentUser() user: AuthRequestUser) { + return this.adminService.getDashboard(user); + } + + @Get('rbac/me') + getRbacProfile(@CurrentUser() user: AuthRequestUser) { + return this.adminService.getRbacProfile(user); + } + + @Get('projects') + listProjects(@CurrentUser() user: AuthRequestUser, @Query() query: AdminListProjectsQueryDto) { + return this.adminService.listProjects(user, query); + } + + @Get('projects/:projectId') + getProjectDetail(@CurrentUser() user: AuthRequestUser, @Param('projectId') projectId: string) { + return this.adminService.getProjectDetail(user, projectId); + } + + @Patch('projects/:projectId/status') + updateProjectStatus( + @CurrentUser() user: AuthRequestUser, + @Param('projectId') projectId: string, + @Body() dto: AdminUpdateProjectStatusDto + ) { + return this.adminService.updateProjectStatus(user, projectId, dto); + } + + @Get('users') + listUsers(@CurrentUser() user: AuthRequestUser, @Query() query: AdminListUsersQueryDto) { + return this.adminService.listUsers(user, query); + } + + @Get('users/:userId/detail') + getUserDetail(@CurrentUser() user: AuthRequestUser, @Param('userId') userId: string) { + return this.adminService.getUserDetail(user, userId); + } + + @Patch('users/:userId/status') + updateUserStatus( + @CurrentUser() user: AuthRequestUser, + @Param('userId') userId: string, + @Body() dto: AdminUpdateUserStatusDto + ) { + return this.adminService.updateUserStatus(user, userId, dto); + } + + @Patch('users/:userId/role') + updateUserRole( + @CurrentUser() user: AuthRequestUser, + @Param('userId') userId: string, + @Body() dto: AdminUpdateUserRoleDto + ) { + return this.adminService.updateUserRole(user, userId, dto); + } + + @Post('users/:userId/reset-password') + resetUserPassword( + @CurrentUser() user: AuthRequestUser, + @Param('userId') userId: string, + @Body() dto: AdminResetUserPasswordDto + ) { + return this.adminService.resetUserPassword(user, userId, dto); + } + + @Get('assets') + listAssets(@CurrentUser() user: AuthRequestUser, @Query() query: AdminListAssetsQueryDto) { + return this.adminService.listAssets(user, query); + } + + @Get('novel-sources') + listNovelSources( + @CurrentUser() user: AuthRequestUser, + @Query() query: AdminListNovelSourcesQueryDto + ) { + return this.adminService.listNovelSources(user, query); + } + + @Get('novel-chapters') + listNovelChapters( + @CurrentUser() user: AuthRequestUser, + @Query() query: AdminListNovelChaptersQueryDto + ) { + return this.adminService.listNovelChapters(user, query); + } + + @Get('characters') + listCharacters(@CurrentUser() user: AuthRequestUser, @Query() query: AdminListCharactersQueryDto) { + return this.adminService.listCharacters(user, query); + } + + @Get('global-characters') + listGlobalCharacters( + @CurrentUser() user: AuthRequestUser, + @Query() query: AdminListGlobalCharactersQueryDto + ) { + return this.adminService.listGlobalCharacters(user, query); + } + + @Post('global-characters') + createGlobalCharacter( + @CurrentUser() user: AuthRequestUser, + @Body() dto: AdminSaveGlobalCharacterDto + ) { + return this.adminService.createGlobalCharacter(user, dto); + } + + @Patch('global-characters/:globalCharacterId') + updateGlobalCharacter( + @CurrentUser() user: AuthRequestUser, + @Param('globalCharacterId') globalCharacterId: string, + @Body() dto: AdminSaveGlobalCharacterDto + ) { + return this.adminService.updateGlobalCharacter(user, globalCharacterId, dto); + } + + @Post('characters/:characterId/bind-global') + bindCharacterGlobal( + @CurrentUser() user: AuthRequestUser, + @Param('characterId') characterId: string, + @Body() dto: AdminBindCharacterGlobalDto + ) { + return this.adminService.bindCharacterGlobal(user, characterId, dto); + } + + @Get('storyboard-shots') + listStoryboardShots( + @CurrentUser() user: AuthRequestUser, + @Query() query: AdminListStoryboardShotsQueryDto + ) { + return this.adminService.listStoryboardShots(user, query); + } + + @Get('router-audits') + listRouterAudits( + @CurrentUser() user: AuthRequestUser, + @Query() query: AdminListRouterAuditsQueryDto + ) { + return this.adminService.listRouterAudits(user, query); + } + + @Get('hit-analyses') + listHitAnalyses( + @CurrentUser() user: AuthRequestUser, + @Query() query: AdminListHitAnalysesQueryDto + ) { + return this.adminService.listHitAnalyses(user, query); + } + + @Post('hit-analyses') + createHitAnalysisCase( + @CurrentUser() user: AuthRequestUser, + @Body() dto: AdminCreateHitAnalysisCaseDto + ) { + return this.adminService.createHitAnalysisCase(user, dto); + } + + @Post('hit-analyses/:caseId/analyze') + analyzeHitCase( + @CurrentUser() user: AuthRequestUser, + @Param('caseId') caseId: string, + @Body() dto: AdminAnalyzeHitCaseDto + ) { + return this.adminService.analyzeHitCase(user, caseId, dto); + } + + @Post('hit-analyses/:caseId/patterns') + promoteHitCasePatterns( + @CurrentUser() user: AuthRequestUser, + @Param('caseId') caseId: string, + @Body() dto: AdminPromoteHitCasePatternsDto + ) { + return this.adminService.promoteHitCasePatterns(user, caseId, dto); + } + + @Get('creative-patterns') + listCreativePatterns( + @CurrentUser() user: AuthRequestUser, + @Query() query: AdminListCreativePatternsQueryDto + ) { + return this.adminService.listCreativePatterns(user, query); + } + + @Patch('creative-patterns/:patternId') + updateCreativePattern( + @CurrentUser() user: AuthRequestUser, + @Param('patternId') patternId: string, + @Body() dto: AdminUpdateCreativePatternDto + ) { + return this.adminService.updateCreativePattern(user, patternId, dto); + } + + @Patch('creative-patterns/:patternId/status') + updateCreativePatternStatus( + @CurrentUser() user: AuthRequestUser, + @Param('patternId') patternId: string, + @Body() dto: AdminUpdateCreativePatternStatusDto + ) { + return this.adminService.updateCreativePatternStatus(user, patternId, dto); + } + + @Post('creative-patterns/:patternId/refresh-metrics') + refreshCreativePatternMetrics( + @CurrentUser() user: AuthRequestUser, + @Param('patternId') patternId: string + ) { + return this.adminService.refreshCreativePatternMetrics(user, patternId); + } + + @Get('router-audits/video-clips/:clipId/timeline') + getRouterAuditClipTimeline( + @CurrentUser() user: AuthRequestUser, + @Param('clipId') clipId: string + ) { + return this.adminService.getRouterAuditClipTimeline(user, clipId); + } + + @Patch('router-audits/video-clips/:clipId/quality') + updateRouterAuditClipQuality( + @CurrentUser() user: AuthRequestUser, + @Param('clipId') clipId: string, + @Body() dto: AdminUpdateRouterAuditQualityDto + ) { + return this.adminService.updateRouterAuditClipQuality(user, clipId, dto); + } + + @Get('works') + listWorks(@CurrentUser() user: AuthRequestUser, @Query() query: AdminListWorksQueryDto) { + return this.adminService.listWorks(user, query); + } + + @Get('copyright-records') + listCopyrightRecords( + @CurrentUser() user: AuthRequestUser, + @Query() query: AdminListCopyrightRecordsQueryDto + ) { + return this.adminService.listCopyrightRecords(user, query); + } + + @Get('operation-logs') + listOperationLogs( + @CurrentUser() user: AuthRequestUser, + @Query() query: AdminListOperationLogsQueryDto + ) { + return this.adminService.listOperationLogs(user, query); + } + + @Get('operation-logs/export') + exportOperationLogs( + @CurrentUser() user: AuthRequestUser, + @Query() query: AdminListOperationLogsQueryDto + ) { + return this.adminService.exportOperationLogs(user, query); + } + + @Get('system-configs') + listSystemConfigs(@CurrentUser() user: AuthRequestUser) { + return this.adminService.listSystemConfigs(user); + } + + @Patch('system-configs/:configKey') + updateSystemConfig( + @CurrentUser() user: AuthRequestUser, + @Param('configKey') configKey: string, + @Body() dto: AdminUpdateSystemConfigDto + ) { + return this.adminService.updateSystemConfig(user, configKey, dto); + } +} diff --git a/backend/src/admin/admin.dto.ts b/backend/src/admin/admin.dto.ts new file mode 100644 index 0000000..8f1ac1d --- /dev/null +++ b/backend/src/admin/admin.dto.ts @@ -0,0 +1,218 @@ +export class AdminListProjectsQueryDto { + status?: string; + input_mode?: string; + user_id?: string; + limit?: string; +} + +export class AdminListUsersQueryDto { + role?: string; + status?: string; + limit?: string; +} + +export class AdminListAssetsQueryDto { + asset_type?: string; + status?: string; + project_id?: string; + user_id?: string; + limit?: string; +} + +export class AdminListNovelSourcesQueryDto { + project_id?: string; + user_id?: string; + source_type?: string; + parse_status?: string; + limit?: string; +} + +export class AdminListNovelChaptersQueryDto { + project_id?: string; + novel_source_id?: string; + status?: string; + limit?: string; +} + +export class AdminListCharactersQueryDto { + project_id?: string; + global_character_id?: string; + status?: string; + role_type?: string; + limit?: string; +} + +export class AdminListGlobalCharactersQueryDto { + status?: string; + role_archetype?: string; + commercial_status?: string; + limit?: string; +} + +export class AdminSaveGlobalCharacterDto { + name?: string; + display_name?: string; + role_archetype?: string; + gender_label?: string; + age_group?: string; + identity_desc?: string; + appearance_desc?: string; + face_desc?: string; + hair_desc?: string; + eye_desc?: string; + body_desc?: string; + default_costume_rules?: string; + wardrobe_json?: unknown; + special_props?: string; + personality_desc?: string; + speech_style?: string; + voice_provider_code?: string; + voice_model?: string; + voice_id?: string; + voice_style?: string; + performance_style?: string; + negative_rules?: string; + anchor_asset_id?: string; + voice_sample_asset_id?: string; + commercial_status?: string; + usage_scope?: string; + status?: string; +} + +export class AdminBindCharacterGlobalDto { + global_character_id?: string; + reason?: string; +} + +export class AdminListStoryboardShotsQueryDto { + project_id?: string; + episode_id?: string; + status?: string; + limit?: string; +} + +export class AdminListRouterAuditsQueryDto { + project_id?: string; + episode_id?: string; + provider_code?: string; + quality_status?: string; + route_tier?: string; + limit?: string; +} + +export class AdminListHitAnalysesQueryDto { + source_platform?: string; + genre?: string; + status?: string; + limit?: string; +} + +export class AdminCreateHitAnalysisCaseDto { + title?: string; + source_platform?: string; + source_url?: string; + content_type?: string; + genre?: string; + language?: string; + target_audience?: string; + duration_seconds?: number | string | null; + episode_count?: number | string | null; + tags?: string[] | string; + metrics_json?: unknown; + transcript_text?: string; + summary_text?: string; + auto_analyze?: boolean; +} + +export class AdminAnalyzeHitCaseDto { + min_segment_seconds?: number | string | null; + segment_count?: number | string | null; +} + +export class AdminPromoteHitCasePatternsDto { + pattern_types?: string[] | string; +} + +export class AdminListCreativePatternsQueryDto { + pattern_type?: string; + genre?: string; + status?: string; + limit?: string; +} + +export class AdminUpdateCreativePatternDto { + pattern_type?: string; + title?: string; + genre?: string | null; + language?: string; + description?: string | null; + structure_json?: unknown; + prompt_template?: string | null; + negative_prompt?: string | null; + tags?: string[] | string | null; + effectiveness_score?: number | string | null; + status?: string; + reason?: string; +} + +export class AdminUpdateCreativePatternStatusDto { + status?: string; + reason?: string; +} + +export class AdminUpdateRouterAuditQualityDto { + result_status?: string; + reason?: string; + quality_score?: number | string | null; +} + +export class AdminListWorksQueryDto { + project_id?: string; + user_id?: string; + status?: string; + limit?: string; +} + +export class AdminListCopyrightRecordsQueryDto { + project_id?: string; + user_id?: string; + authorization_type?: string; + limit?: string; +} + +export class AdminListOperationLogsQueryDto { + user_id?: string; + operator_role?: string; + action?: string; + target_type?: string; + target_id?: string; + date_from?: string; + date_to?: string; + limit?: string; +} + +export class AdminUpdateProjectStatusDto { + status?: string; + reason?: string; +} + +export class AdminUpdateUserStatusDto { + status?: string; + reason?: string; +} + +export class AdminUpdateUserRoleDto { + role?: string; + reason?: string; +} + +export class AdminResetUserPasswordDto { + new_password?: string; + reason?: string; +} + +export class AdminUpdateSystemConfigDto { + config_value?: unknown; + description?: string; + is_public?: boolean; +} diff --git a/backend/src/admin/admin.module.ts b/backend/src/admin/admin.module.ts new file mode 100644 index 0000000..c875f34 --- /dev/null +++ b/backend/src/admin/admin.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { PrismaModule } from '../prisma/prisma.module'; +import { AdminController } from './admin.controller'; +import { AdminService } from './admin.service'; + +@Module({ + imports: [AuthModule, PrismaModule], + controllers: [AdminController], + providers: [AdminService], + exports: [AdminService] +}) +export class AdminModule {} diff --git a/backend/src/admin/admin.service.spec.ts b/backend/src/admin/admin.service.spec.ts new file mode 100644 index 0000000..9defa39 --- /dev/null +++ b/backend/src/admin/admin.service.spec.ts @@ -0,0 +1,1165 @@ +import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AuthRequestUser } from '../auth/auth.types'; +import type { PrismaService } from '../prisma/prisma.service'; +import { AdminService } from './admin.service'; + +const admin: AuthRequestUser = { + id: '1', + email: 'admin@example.com', + role: 'admin' +}; + +const user: AuthRequestUser = { + id: '2', + email: 'user@example.com', + role: 'user' +}; + +const auditor: AuthRequestUser = { + id: '3', + email: 'auditor@example.com', + role: 'auditor' +}; + +const now = new Date('2026-05-31T00:00:00.000Z'); + +function createUser(overrides: Record = {}) { + return { + id: 1n, + email: 'admin@example.com', + phone: null, + password_hash: 'hash', + nickname: 'Admin', + avatar_url: null, + role: 'admin', + status: 'active', + wechat_openid: null, + last_login_at: null, + created_at: now, + updated_at: now, + ...overrides + }; +} + +function createProject(overrides: Record = {}) { + return { + id: 10n, + user_id: 1n, + title: '后台测试项目', + input_mode: 'ai_original', + genre: 'urban_rebirth', + style_code: 'korean_comic', + output_type: 'short_video', + output_mode: 'image_manga', + visual_mode: 'korean_manga', + video_generation_level: 'standard', + target_episode_count: 3, + episode_duration: 60, + status: 'video_rendered', + copyright_status: 'ai_original', + payment_status: 'unpaid', + quality_level: 'mvp', + is_long_series: false, + created_at: now, + updated_at: now, + completed_at: null, + ...overrides + }; +} + +function createEpisode(overrides: Record = {}) { + return { + id: 11n, + project_id: 10n, + episode_no: 1, + source_chapter_ids: ['1'], + title: '第1集', + summary: '女主反击。', + opening_hook: '会议室录音曝光。', + middle_conflict: '男主施压。', + ending_hook: '幕后车辆出现。', + target_duration: 60, + status: 'confirmed', + created_at: now, + updated_at: now, + ...overrides + }; +} + +function createStoryboardShot(overrides: Record = {}) { + return { + id: 12n, + project_id: 10n, + episode_id: 11n, + shot_no: 3, + scene_name: '会议室反击', + location_desc: '高层会议室', + characters_json: [], + visual_desc: '女主播放录音。', + action_desc: '女主反击。', + dialogue_text: '这一回,我不会再退。', + narration_text: null, + camera_motion: 'zoom_in', + effect_type: null, + duration: new Prisma.Decimal(4), + scene_type: 'dialog', + importance_score: 8, + emotion_score: 7, + action_score: 3, + route_tier: 'premium', + prompt_text: '真人短剧会议室反击', + negative_prompt: null, + live_action_desc: '真人短剧风格会议室反击。', + actor_action: '播放录音证据。', + camera_instruction: 'medium close-up', + performance_instruction: '冷静克制', + video_prompt: 'photorealistic Chinese vertical short drama', + keyframe_asset_id: 30n, + video_clip_asset_id: 31n, + video_status: 'video_clip_generated', + status: 'confirmed', + created_at: now, + updated_at: now, + ...overrides + }; +} + +function createProviderConfig(overrides: Record = {}) { + return { + id: 13n, + provider_type: 'VideoProvider', + provider_code: 'kling-image-to-video', + display_name: 'Kling', + mode: 'mock', + model_name: 'kling-v1', + config_json: {}, + fallback_provider_id: null, + is_enabled: true, + priority: 10, + rate_limit_json: {}, + cost_rule_json: {}, + created_at: now, + updated_at: now, + ...overrides + }; +} + +function createProviderLog(overrides: Record = {}) { + return { + id: 60n, + provider_id: 13n, + task_id: 15n, + project_id: 10n, + provider_type: 'VideoProvider', + provider_code: 'kling-image-to-video', + model_name: 'kling-v1', + request_json: { + purpose: 'live-action-video-clip-12-1', + input_json: { + prompt: 'photorealistic Chinese vertical short drama' + } + }, + response_json: { + provider_request_id: 'mock-request' + }, + input_size: 100, + output_size: 200, + cost_estimate: new Prisma.Decimal(0.3), + cost_actual: new Prisma.Decimal(0.35), + status: 'success', + error_code: null, + error_message: null, + started_at: now, + finished_at: now, + created_at: now, + ...overrides + }; +} + +function createVideoClip(overrides: Record = {}) { + return { + id: 14n, + project_id: 10n, + episode_id: 11n, + shot_id: 12n, + provider_id: 13n, + input_asset_id: 30n, + output_asset_id: 31n, + duration: new Prisma.Decimal(4), + prompt_text: 'photorealistic Chinese vertical short drama', + status: 'generated', + cost_actual: new Prisma.Decimal(0.35), + retry_count: 2, + quality_status: 'manual_required', + quality_score: new Prisma.Decimal(72), + quality_issues: ['face drift', 'AUTO_REPAIR_LIMIT_REACHED'], + created_at: now, + updated_at: now, + ...overrides + }; +} + +function createTask(overrides: Record = {}) { + return { + id: 20n, + project_id: 10n, + episode_id: null, + shot_id: null, + task_type: 'video_render', + provider_id: null, + status: 'success', + input_json: {}, + input_hash: 'hash', + idempotency_key: 'idem', + output_asset_id: null, + provider_request_id: null, + retry_count: 0, + max_retry: 2, + cost_estimate: null, + cost_actual: null, + error_code: null, + error_message: null, + created_at: now, + started_at: null, + finished_at: now, + ...overrides + }; +} + +function createAsset(overrides: Record = {}) { + return { + id: 30n, + user_id: 1n, + project_id: 10n, + asset_type: 'video', + file_path: 'local://rendered-videos/test.mp4', + file_url: null, + mime_type: 'video/mp4', + width: 1080, + height: 1920, + duration: new Prisma.Decimal(4), + size: 1024n, + hash: 'hash', + visibility: 'private', + status: 'active', + created_at: now, + ...overrides + }; +} + +function createQuotaAccount(overrides: Record = {}) { + return { + id: 40n, + user_id: 2n, + total_quota: new Prisma.Decimal(300), + available_quota: new Prisma.Decimal(220), + frozen_quota: new Prisma.Decimal(20), + used_quota: new Prisma.Decimal(60), + status: 'active', + created_at: now, + updated_at: now, + ...overrides + }; +} + +function createQuotaLog(overrides: Record = {}) { + return { + id: 41n, + user_id: 2n, + project_id: 10n, + task_id: null, + change_type: 'admin_grant', + amount: new Prisma.Decimal(100), + balance_after: new Prisma.Decimal(220), + reason: '后台人工加余额', + metadata_json: {}, + created_at: now, + ...overrides + }; +} + +function createOrder(overrides: Record = {}) { + return { + id: 42n, + user_id: 2n, + project_id: 10n, + order_no: 'ORDER-TEST', + package_code: 'standard_3ep', + amount: new Prisma.Decimal(199), + currency: 'CNY', + payment_method: 'mock_pay', + payment_status: 'paid', + paid_at: now, + created_at: now, + updated_at: now, + ...overrides + }; +} + +function createAnalyticsEvent(overrides: Record = {}) { + return { + id: 43n, + project_id: 10n, + episode_id: null, + event_type: 'publish_metrics', + platform: 'douyin', + metric_json: { + play_count: 25000, + like_count: 1200, + completion_rate: 0.72, + revenue: 20 + }, + created_at: now, + ...overrides + }; +} + +function createOperationLog(overrides: Record = {}) { + return { + id: 90n, + user_id: 1n, + operator_role: 'admin', + action: 'admin_update_project_status', + target_type: 'project', + target_id: 10n, + ip: null, + user_agent: null, + metadata_json: {}, + created_at: now, + ...overrides + }; +} + +function createHitAnalysisCase(overrides: Record = {}) { + return { + id: 100n, + title: '退婚现场女主反击', + source_platform: 'hongguo', + source_url: 'https://example.com/hit-case', + content_type: 'short_drama', + genre: 'urban_revenge', + language: 'zh-CN', + target_audience: '女性 25-40', + duration_seconds: 60, + episode_count: 1, + tags_json: ['退婚', '打脸'], + metrics_json: { likes: 120000, completion_rate: 0.72 }, + transcript_text: '退婚现场,男主逼女主签字。女主冷笑拿出录音证据。众人没想到她才是真正的继承人。结尾豪车停在门口。', + summary_text: '退婚现场女主用证据反击,身份反转。', + analysis_json: null, + diagnosis_score: null, + status: 'draft', + created_by_user_id: 1n, + created_at: now, + updated_at: now, + ...overrides + }; +} + +function createHitAnalysisSegment(overrides: Record = {}) { + return { + id: 101n, + case_id: 100n, + segment_no: 1, + start_second: 0, + end_second: 8, + scene_type: 'dialog', + hook_type: 'relationship_break', + emotion: 'anger', + conflict_type: 'romance_conflict', + plot_function: 'opening_hook', + visual_strategy: 'ceremony_high_contrast', + dialogue_pattern: 'direct_confrontation', + camera_notes: '双人对峙构图,关键台词前推镜。', + importance_score: 9, + emotion_score: 7, + action_score: 2, + tags_json: ['dialog', 'relationship_break'], + summary_text: '退婚现场,男主逼女主签字。', + prompt_seed: '竖版短剧镜头,退婚现场', + created_at: now, + ...overrides + }; +} + +function createCreativePattern(overrides: Record = {}) { + return { + id: 102n, + source_case_id: 100n, + pattern_type: 'opening_hook', + title: '都市复仇开场钩子:退婚', + genre: 'urban_revenge', + language: 'zh-CN', + description: '前8秒建立退婚压迫和证据反击。', + structure_json: { required_elements: ['身份信息差'] }, + prompt_template: '写一个退婚现场开场钩子。', + negative_prompt: '拖慢铺垫', + tags_json: ['opening', '退婚'], + usage_count: 0, + effectiveness_score: new Prisma.Decimal(82), + status: 'active', + created_by_user_id: 1n, + created_at: now, + updated_at: now, + ...overrides + }; +} + +function createProjectCreativePattern(overrides: Record = {}) { + return { + id: 103n, + project_id: 10n, + creative_pattern_id: 102n, + source: 'user_selected', + snapshot_json: {}, + sort_order: 1, + created_at: now, + ...overrides + }; +} + +describe('AdminService', () => { + let prisma: any; + let service: AdminService; + + beforeEach(() => { + prisma = { + user: { + count: vi.fn().mockResolvedValue(2), + findUnique: vi.fn().mockResolvedValue(createUser()), + findMany: vi.fn().mockResolvedValue([createUser()]), + update: vi.fn(async ({ where, data }: { where: { id: bigint }; data: Record }) => + createUser({ id: where.id, ...data }) + ) + }, + project: { + count: vi.fn().mockResolvedValue(3), + groupBy: vi.fn().mockResolvedValue([{ status: 'video_rendered', _count: { _all: 1 } }]), + findMany: vi.fn().mockResolvedValue([createProject()]), + findUnique: vi.fn().mockResolvedValue(createProject()), + update: vi.fn().mockResolvedValue(createProject({ status: 'cancelled' })) + }, + episode: { + count: vi.fn().mockResolvedValue(1), + findUnique: vi.fn().mockResolvedValue(createEpisode()), + findMany: vi.fn().mockResolvedValue([]) + }, + asset: { + count: vi.fn().mockResolvedValue(1), + findMany: vi.fn().mockResolvedValue([createAsset()]) + }, + renderTask: { + count: vi.fn().mockResolvedValue(1), + groupBy: vi.fn().mockResolvedValue([{ status: 'success', _count: { _all: 1 } }]), + findFirst: vi.fn().mockResolvedValue(createTask()), + findMany: vi.fn().mockResolvedValue([createTask()]) + }, + providerConfig: { + findMany: vi.fn().mockResolvedValue([]) + }, + videoClip: { + findUnique: vi.fn().mockResolvedValue(createVideoClip()), + update: vi.fn(async ({ data }: { data: Record }) => + createVideoClip(data) + ), + findMany: vi.fn().mockResolvedValue([]), + count: vi.fn().mockResolvedValue(0) + }, + contentReview: { + count: vi.fn().mockResolvedValue(0) + }, + providerLog: { + groupBy: vi.fn().mockResolvedValue([ + { + provider_type: 'VideoProvider', + status: 'success', + _count: { _all: 1 }, + _sum: { cost_actual: new Prisma.Decimal(0) } + } + ]), + aggregate: vi.fn().mockResolvedValue({ + _sum: { cost_actual: new Prisma.Decimal(0) }, + _count: { _all: 1 } + }), + findMany: vi.fn().mockResolvedValue([]) + }, + novelSource: { + findMany: vi.fn().mockResolvedValue([]) + }, + novelChapter: { + findMany: vi.fn().mockResolvedValue([]), + count: vi.fn().mockResolvedValue(0) + }, + storyBible: { + findFirst: vi.fn().mockResolvedValue(null) + }, + character: { + findMany: vi.fn().mockResolvedValue([]) + }, + storyboardShot: { + count: vi.fn().mockResolvedValue(0), + findUnique: vi.fn().mockResolvedValue(createStoryboardShot()), + findMany: vi.fn().mockResolvedValue([]), + update: vi.fn().mockResolvedValue(createStoryboardShot()) + }, + copyrightRecord: { + findMany: vi.fn().mockResolvedValue([]), + count: vi.fn().mockResolvedValue(0) + }, + operationLog: { + create: vi.fn().mockResolvedValue(createOperationLog()), + findMany: vi.fn().mockResolvedValue([createOperationLog()]), + count: vi.fn().mockResolvedValue(1) + }, + hitAnalysisCase: { + create: vi.fn(async ({ data }: { data: Record }) => + createHitAnalysisCase({ ...data }) + ), + findUnique: vi.fn().mockResolvedValue(createHitAnalysisCase()), + findMany: vi.fn().mockResolvedValue([createHitAnalysisCase()]), + count: vi.fn().mockResolvedValue(1), + update: vi.fn(async ({ data }: { data: Record }) => + createHitAnalysisCase({ ...data, status: data.status ?? 'analyzed' }) + ) + }, + hitAnalysisSegment: { + findMany: vi.fn().mockResolvedValue([createHitAnalysisSegment()]), + deleteMany: vi.fn().mockResolvedValue({ count: 1 }), + create: vi.fn(async ({ data }: { data: Record }) => + createHitAnalysisSegment({ ...data }) + ) + }, + creativePattern: { + findUnique: vi.fn().mockResolvedValue(createCreativePattern()), + findMany: vi.fn().mockResolvedValue([createCreativePattern()]), + count: vi.fn().mockResolvedValue(1), + update: vi.fn(async ({ data }: { data: Record }) => + createCreativePattern({ ...data }) + ), + create: vi.fn(async ({ data }: { data: Record }) => + createCreativePattern({ ...data }) + ) + }, + projectCreativePattern: { + findMany: vi.fn().mockResolvedValue([]) + }, + analyticsEvent: { + findMany: vi.fn().mockResolvedValue([]) + }, + quotaAccount: { + findUnique: vi.fn().mockResolvedValue(createQuotaAccount()) + }, + quotaLog: { + findMany: vi.fn().mockResolvedValue([createQuotaLog()]), + count: vi.fn().mockResolvedValue(1) + }, + order: { + findMany: vi.fn().mockResolvedValue([createOrder()]), + count: vi.fn().mockResolvedValue(1) + } + }; + service = new AdminService(prisma as PrismaService); + }); + + it('rejects non-admin users', async () => { + await expect(service.getDashboard(user)).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('returns dashboard metrics for admins', async () => { + const result = await service.getDashboard(admin); + + expect(result.metrics.total_users).toBe(2); + expect(result.metrics.total_projects).toBe(3); + expect(result.project_status_counts[0]).toEqual({ key: 'video_rendered', count: 1 }); + }); + + it('returns RBAC permissions for audit roles', async () => { + const result = await service.getRbacProfile(auditor); + + expect(result.permissions).toContain('audit:export'); + await expect(service.updateUserStatus(auditor, '2', { status: 'disabled' })).rejects.toBeInstanceOf( + ForbiddenException + ); + }); + + it('lists projects with owner and counters', async () => { + const result = await service.listProjects(admin, { limit: '10' }); + + expect(result.projects[0].project.id).toBe('10'); + expect(result.projects[0].owner?.email).toBe('admin@example.com'); + expect(result.projects[0].latest_task?.task_type).toBe('video_render'); + }); + + it('lists router quality audit rows with routing, repair and cost details', async () => { + prisma.project.findMany.mockResolvedValue([createProject({ output_mode: 'live_action_ai' })]); + prisma.episode.findMany.mockResolvedValue([createEpisode()]); + prisma.storyboardShot.findMany.mockResolvedValue([createStoryboardShot()]); + prisma.providerConfig.findMany.mockResolvedValue([createProviderConfig()]); + prisma.videoClip.findMany + .mockResolvedValueOnce([createVideoClip()]) + .mockResolvedValueOnce([createVideoClip({ id: 9n, cost_actual: new Prisma.Decimal(0.2) })]); + prisma.videoClip.count.mockResolvedValue(1); + prisma.renderTask.findMany + .mockResolvedValueOnce([ + createTask({ + id: 15n, + episode_id: 11n, + shot_id: 12n, + task_type: 'live_action_video_clip_generate', + output_asset_id: 31n, + cost_estimate: new Prisma.Decimal(0.3), + cost_actual: new Prisma.Decimal(0.35), + input_json: { + provider: 'kling-image-to-video', + router_decision: { + config_key: 'ai.router.v1', + provider_code: 'kling-image-to-video', + provider_mode: 'mock', + route_tier: 'premium', + decision_reason: 'auto_premium_route', + estimated_cost: 0.3, + fallback_chain: ['hailuo', 'kling-image-to-video', 'mock-video'], + candidates: [{ provider_code: 'kling-image-to-video', status: 'selected' }] + }, + repair_context: { + source_clip_id: '9', + action: 'switch_provider', + provider_code: 'kling-image-to-video', + previous_quality_status: 'passed', + previous_quality_score: 68, + min_quality_score: 80, + fallback_chain: ['hailuo', 'kling-image-to-video', 'mock-video'] + } + } + }) + ]) + .mockResolvedValueOnce([ + createTask({ + id: 16n, + episode_id: 11n, + shot_id: null, + task_type: 'live_action_video_render', + output_asset_id: 32n, + input_json: { + clip_normalization: [ + { + shot_id: '12', + shot_no: 3, + target_duration: 4, + source_duration: 5.875, + final_duration: 4, + trimmed: true, + trim_strategy: 'center', + trim_start: 0.938, + trim_tolerance: 0.3 + } + ] + } + }) + ]); + + const result = await service.listRouterAudits(admin, { limit: '10' }); + const row = result.audits[0]; + + expect(result.summary.clip_count).toBe(1); + expect(result.summary.manual_required_count).toBe(1); + expect(result.summary.switched_provider_count).toBe(1); + expect(row.provider.provider_code).toBe('kling-image-to-video'); + expect(row.router.fallback_chain).toEqual(['hailuo', 'kling-image-to-video', 'mock-video']); + expect(row.repair.action).toBe('switch_provider'); + expect(row.quality.manual_reason).toBe('AUTO_REPAIR_LIMIT_REACHED'); + expect(row.cost.cost_delta).toBe(0.05); + expect(row.cost.repair_added_cost).toBe(0.35); + expect(result.summary.normalized_clip_count).toBe(1); + expect(result.summary.trimmed_clip_count).toBe(1); + expect(result.summary.trimmed_seconds_total).toBe(1.875); + expect(row.render_normalization).toEqual( + expect.objectContaining({ + task_id: '16', + output_asset_id: '32', + shot_id: '12', + target_duration: 4, + source_duration: 5.875, + final_duration: 4, + trimmed: true, + trim_strategy: 'center', + trim_start: 0.938 + }) + ); + }); + + it('creates a hit analysis case and auto-generates diagnosis segments', async () => { + prisma.hitAnalysisSegment.create + .mockResolvedValueOnce(createHitAnalysisSegment({ segment_no: 1, hook_type: 'relationship_break' })) + .mockResolvedValueOnce(createHitAnalysisSegment({ id: 103n, segment_no: 2, hook_type: 'evidence_reveal' })) + .mockResolvedValueOnce(createHitAnalysisSegment({ id: 104n, segment_no: 3, hook_type: 'identity_gap' })); + + const result = await service.createHitAnalysisCase(admin, { + title: '退婚现场女主反击', + source_platform: 'hongguo', + genre: 'urban_revenge', + transcript_text: '退婚现场,男主逼女主签字。女主冷笑拿出录音证据。众人没想到她才是真正的继承人。', + auto_analyze: true + }); + + expect(prisma.hitAnalysisCase.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + title: '退婚现场女主反击', + source_platform: 'hongguo', + genre: 'urban_revenge' + }) + }); + expect(prisma.hitAnalysisSegment.deleteMany).toHaveBeenCalledWith({ where: { case_id: 100n } }); + expect(prisma.hitAnalysisCase.update).toHaveBeenCalledWith({ + where: { id: 100n }, + data: expect.objectContaining({ + status: 'analyzed', + diagnosis_score: expect.any(Number), + analysis_json: expect.objectContaining({ + version: 'hit_analysis_v1_rule_mock', + scores: expect.objectContaining({ + total: expect.any(Number) + }) + }) + }) + }); + expect(result.case.status).toBe('analyzed'); + expect(result.segments.length).toBeGreaterThanOrEqual(3); + expect(result.analysis?.key_takeaways.length).toBeGreaterThan(0); + }); + + it('promotes analyzed hit case into reusable creative patterns', async () => { + prisma.hitAnalysisCase.findUnique.mockResolvedValue(createHitAnalysisCase({ + status: 'analyzed', + diagnosis_score: new Prisma.Decimal(84), + analysis_json: { + scores: { total: 84 }, + key_takeaways: ['开场强钩子'] + } + })); + prisma.hitAnalysisSegment.findMany.mockResolvedValue([ + createHitAnalysisSegment({ segment_no: 1, hook_type: 'relationship_break' }), + createHitAnalysisSegment({ id: 103n, segment_no: 2, plot_function: 'reversal', hook_type: 'identity_gap' }) + ]); + + const result = await service.promoteHitCasePatterns(admin, '100', { + pattern_types: ['opening_hook', 'visual_prompt'] + }); + + expect(prisma.creativePattern.create).toHaveBeenCalledTimes(2); + expect(prisma.creativePattern.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + source_case_id: 100n, + pattern_type: 'opening_hook', + status: 'active', + prompt_template: expect.stringContaining('短剧开场') + }) + }); + expect(result.patterns.map((pattern) => pattern.pattern_type)).toEqual(['opening_hook', 'visual_prompt']); + expect(prisma.operationLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + action: 'admin_promote_hit_analysis_patterns', + target_type: 'hit_analysis_case', + target_id: 100n + }) + }); + }); + + it('lists creative patterns with usage and ROI metrics', async () => { + prisma.projectCreativePattern.findMany.mockResolvedValue([createProjectCreativePattern()]); + prisma.project.findMany.mockResolvedValue([createProject({ status: 'completed' })]); + prisma.providerLog.findMany.mockResolvedValue([createProviderLog({ cost_actual: new Prisma.Decimal(0.35) })]); + prisma.renderTask.findMany.mockResolvedValue([createTask({ cost_actual: new Prisma.Decimal(0.5) })]); + prisma.videoClip.findMany.mockResolvedValue([createVideoClip({ quality_score: new Prisma.Decimal(88) })]); + prisma.asset.findMany.mockResolvedValue([createAsset()]); + prisma.analyticsEvent.findMany.mockResolvedValue([createAnalyticsEvent()]); + prisma.order.findMany.mockResolvedValue([createOrder({ amount: new Prisma.Decimal(199) })]); + + const result = await service.listCreativePatterns(admin, { limit: '10' }); + + expect(result.patterns[0].metrics.bound_project_count).toBe(1); + expect(result.patterns[0].metrics.completed_project_count).toBe(1); + expect(result.patterns[0].metrics.total_cost_actual).toBe(0.35); + expect(result.patterns[0].metrics.total_revenue_estimate).toBe(219); + expect(result.summary.bound_project_count).toBe(1); + expect(result.summary.roi_estimate).toBe(218.65); + }); + + it('updates creative pattern content and status with operation logs', async () => { + const updated = await service.updateCreativePattern(admin, '102', { + title: '升级后的开场钩子', + pattern_type: 'opening_hook', + genre: 'urban_revenge', + language: 'zh-CN', + prompt_template: '前 8 秒退婚,随后证据反杀。', + negative_prompt: '拖慢铺垫', + tags: '退婚, 证据, 打脸', + effectiveness_score: '88', + reason: '人工复盘后优化' + }); + + expect(prisma.creativePattern.update).toHaveBeenCalledWith({ + where: { id: 102n }, + data: expect.objectContaining({ + title: '升级后的开场钩子', + pattern_type: 'opening_hook', + tags_json: ['退婚', '证据', '打脸'], + effectiveness_score: 88 + }) + }); + expect(updated.pattern.title).toBe('升级后的开场钩子'); + expect(prisma.operationLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + action: 'admin_update_creative_pattern', + target_type: 'creative_pattern', + target_id: 102n + }) + }); + + await service.updateCreativePatternStatus(admin, '102', { + status: 'disabled', + reason: 'ROI 偏低,先停用' + }); + + expect(prisma.creativePattern.update).toHaveBeenLastCalledWith({ + where: { id: 102n }, + data: { status: 'disabled' } + }); + expect(prisma.operationLog.create).toHaveBeenLastCalledWith({ + data: expect.objectContaining({ + action: 'admin_update_creative_pattern_status', + metadata_json: expect.objectContaining({ + from_status: 'active', + to_status: 'disabled' + }) + }) + }); + }); + + it('refreshes creative pattern metrics back into effectiveness score', async () => { + prisma.projectCreativePattern.findMany.mockResolvedValue([createProjectCreativePattern()]); + prisma.project.findMany.mockResolvedValue([createProject({ status: 'completed' })]); + prisma.providerLog.findMany.mockResolvedValue([createProviderLog({ cost_actual: new Prisma.Decimal(0.35) })]); + prisma.renderTask.findMany.mockResolvedValue([createTask({ cost_actual: new Prisma.Decimal(0.5) })]); + prisma.videoClip.findMany.mockResolvedValue([createVideoClip({ quality_score: new Prisma.Decimal(92) })]); + prisma.asset.findMany.mockResolvedValue([createAsset()]); + prisma.analyticsEvent.findMany.mockResolvedValue([createAnalyticsEvent()]); + prisma.order.findMany.mockResolvedValue([createOrder({ amount: new Prisma.Decimal(199) })]); + + const result = await service.refreshCreativePatternMetrics(admin, '102'); + + expect(result.metrics.bound_project_count).toBe(1); + expect(result.metrics.roi_estimate).toBe(218.65); + expect(result.pattern.effectiveness_score).toBeGreaterThan(70); + expect(result.project_metrics[0].project.id).toBe('10'); + expect(prisma.creativePattern.update).toHaveBeenCalledWith({ + where: { id: 102n }, + data: { effectiveness_score: expect.any(Number) } + }); + expect(prisma.operationLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + action: 'admin_refresh_creative_pattern_metrics', + target_type: 'creative_pattern', + target_id: 102n + }) + }); + }); + + it('returns router audit timeline with route, repair, provider and manual operation events', async () => { + prisma.project.findUnique.mockResolvedValue(createProject({ output_mode: 'live_action_ai' })); + prisma.episode.findUnique.mockResolvedValue(createEpisode()); + prisma.storyboardShot.findUnique.mockResolvedValue(createStoryboardShot()); + prisma.videoClip.findUnique.mockResolvedValue(createVideoClip()); + prisma.videoClip.findMany.mockResolvedValue([ + createVideoClip({ + id: 9n, + retry_count: 0, + quality_status: 'needs_retry', + quality_score: new Prisma.Decimal(68), + cost_actual: new Prisma.Decimal(0.2) + }), + createVideoClip() + ]); + prisma.renderTask.findMany.mockResolvedValue([ + createTask({ + id: 15n, + episode_id: 11n, + shot_id: 12n, + task_type: 'live_action_video_clip_generate', + output_asset_id: 31n, + cost_estimate: new Prisma.Decimal(0.3), + cost_actual: new Prisma.Decimal(0.35), + input_json: { + provider: 'kling-image-to-video', + router_decision: { + provider_code: 'kling-image-to-video', + route_tier: 'premium', + decision_reason: 'auto_premium_route', + estimated_cost: 0.3, + fallback_chain: ['hailuo', 'kling-image-to-video', 'mock-video'] + }, + repair_context: { + source_clip_id: '9', + action: 'switch_provider', + provider_code: 'kling-image-to-video', + previous_quality_status: 'needs_retry', + previous_quality_score: 68, + min_quality_score: 80, + fallback_chain: ['hailuo', 'kling-image-to-video', 'mock-video'] + } + } + }), + createTask({ + id: 16n, + episode_id: 11n, + shot_id: null, + task_type: 'live_action_video_render', + output_asset_id: 32n, + input_json: { + clip_normalization: [ + { + shot_id: '12', + shot_no: 3, + target_duration: 4, + source_duration: 5.875, + final_duration: 4, + trimmed: true, + trim_strategy: 'center', + trim_start: 0.938, + trim_tolerance: 0.3 + } + ] + } + }) + ]); + prisma.providerConfig.findMany.mockResolvedValue([createProviderConfig()]); + prisma.providerLog.findMany.mockResolvedValue([ + createProviderLog(), + createProviderLog({ + id: 61n, + task_id: null, + provider_type: 'QualityCheckProvider', + provider_code: 'mock-qc', + model_name: 'mock-qc', + request_json: { + purpose: 'live-action-video-clip-qc-14', + input_json: { prompt: 'clip_id=14\nstatus=mock' } + }, + response_json: { + result_status: 'manual_required', + quality_score: 72 + }, + cost_actual: new Prisma.Decimal(0) + }) + ]); + prisma.operationLog.findMany.mockResolvedValue([ + createOperationLog({ + action: 'admin_update_router_audit_quality', + target_type: 'video_clip', + target_id: 14n, + metadata_json: { + from_quality_status: 'manual_required', + to_quality_status: 'passed', + reason: '人工确认可用' + } + }) + ]); + + const result = await service.getRouterAuditClipTimeline(admin, '14'); + const kinds = result.timeline.map((item) => item.kind); + + expect(result.audit.clip.id).toBe('14'); + expect(result.summary.related_clip_count).toBe(2); + expect(result.summary.provider_call_count).toBe(2); + expect(kinds).toContain('route_decision'); + expect(kinds).toContain('repair'); + expect(kinds).toContain('provider_call'); + expect(kinds).toContain('quality_check'); + expect(kinds).toContain('manual_operation'); + expect(kinds).toContain('clip_normalization'); + expect(result.audit.render_normalization).toEqual( + expect.objectContaining({ + task_id: '16', + trimmed: true, + trim_strategy: 'center' + }) + ); + expect(result.timeline.some((item) => item.title === '合成片段自动裁切')).toBe(true); + expect(result.timeline.some((item) => item.title === '后台人工质检处理')).toBe(true); + }); + + it('manually updates router audit quality and records an operation log', async () => { + prisma.videoClip.findUnique.mockResolvedValue(createVideoClip({ + quality_status: 'manual_required', + quality_score: new Prisma.Decimal(72) + })); + + const result = await service.updateRouterAuditClipQuality(admin, '14', { + result_status: 'passed', + reason: '画面可接受,人工通过' + }); + + expect(result.video_clip.quality_status).toBe('passed'); + expect(result.video_clip.quality_score).toBe(80); + expect(prisma.videoClip.update).toHaveBeenCalledWith({ + where: { id: 14n }, + data: expect.objectContaining({ + quality_status: 'passed', + quality_score: 80, + quality_issues: expect.arrayContaining(['MANUAL_PASS: 画面可接受,人工通过']) + }) + }); + expect(prisma.storyboardShot.update).toHaveBeenCalledWith({ + where: { id: 12n }, + data: { video_status: 'quality_passed' } + }); + expect(prisma.operationLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + action: 'admin_update_router_audit_quality', + target_type: 'video_clip', + target_id: 14n, + metadata_json: expect.objectContaining({ + from_quality_status: 'manual_required', + to_quality_status: 'passed', + reason: '画面可接受,人工通过' + }) + }) + }); + }); + + it('updates project status and writes operation log', async () => { + const result = await service.updateProjectStatus(admin, '10', { + status: 'cancelled', + reason: 'operator stop' + }); + + expect(prisma.project.update).toHaveBeenCalledWith({ + where: { id: 10n }, + data: { status: 'cancelled' } + }); + expect(prisma.operationLog.create).toHaveBeenCalled(); + expect(result.project.status).toBe('cancelled'); + }); + + it('returns user detail with quota, orders, projects, assets and operations', async () => { + prisma.user.findUnique.mockResolvedValue(createUser({ id: 2n, email: 'user@example.com', role: 'user' })); + prisma.project.findMany.mockResolvedValue([createProject({ user_id: 2n })]); + prisma.project.count.mockResolvedValue(1); + prisma.asset.findMany.mockResolvedValue([createAsset({ user_id: 2n })]); + prisma.asset.count.mockResolvedValue(1); + + const result = await service.getUserDetail(admin, '2'); + + expect(result.user.email).toBe('user@example.com'); + expect(result.quota_account?.available_quota).toBe(220); + expect(result.quota_logs[0].change_type).toBe('admin_grant'); + expect(result.orders[0].order_no).toBe('ORDER-TEST'); + expect(result.projects[0].id).toBe('10'); + expect(result.assets[0].asset.id).toBe('30'); + expect(result.operation_logs[0].action).toBe('admin_update_project_status'); + }); + + it('updates user status and writes operation log', async () => { + prisma.user.findUnique.mockResolvedValue(createUser({ id: 2n, email: 'user@example.com', role: 'user' })); + + const result = await service.updateUserStatus(admin, '2', { + status: 'disabled', + reason: 'risk control' + }); + + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: 2n }, + data: { status: 'disabled' } + }); + expect(prisma.operationLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + action: 'admin_update_user_status', + target_type: 'user', + target_id: 2n, + metadata_json: expect.objectContaining({ + from_status: 'active', + to_status: 'disabled', + reason: 'risk control' + }) + }) + }); + expect(result.user.status).toBe('disabled'); + }); + + it('prevents current admin from disabling self', async () => { + prisma.user.findUnique.mockResolvedValue(createUser({ id: 1n, role: 'admin' })); + + await expect( + service.updateUserStatus(admin, '1', { status: 'disabled' }) + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('updates user role but prevents self demotion', async () => { + prisma.user.findUnique.mockResolvedValueOnce(createUser({ id: 2n, role: 'user' })); + + const result = await service.updateUserRole(admin, '2', { + role: 'admin', + reason: 'operator promotion' + }); + + expect(result.user.role).toBe('admin'); + expect(prisma.operationLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + action: 'admin_update_user_role', + metadata_json: expect.objectContaining({ + from_role: 'user', + to_role: 'admin' + }) + }) + }); + + prisma.user.findUnique.mockResolvedValueOnce(createUser({ id: 1n, role: 'admin' })); + await expect(service.updateUserRole(admin, '1', { role: 'user' })).rejects.toBeInstanceOf( + BadRequestException + ); + }); + + it('resets user password without storing raw password in operation metadata', async () => { + prisma.user.findUnique.mockResolvedValue(createUser({ id: 2n, email: 'user@example.com', role: 'user' })); + + const result = await service.resetUserPassword(admin, '2', { + new_password: 'Reset123456!', + reason: 'support reset' + }); + const updateCall = prisma.user.update.mock.calls[0][0]; + const operationCall = prisma.operationLog.create.mock.calls.at(-1)?.[0]; + + expect(updateCall.data.password_hash).not.toBe('Reset123456!'); + expect(result.temporary_password).toBeNull(); + expect(operationCall.data.action).toBe('admin_reset_user_password'); + expect(JSON.stringify(operationCall.data.metadata_json)).not.toContain('Reset123456!'); + }); + + it('generates a temporary password when reset password body is empty', async () => { + prisma.user.findUnique.mockResolvedValue(createUser({ id: 2n, email: 'user@example.com', role: 'user' })); + + const result = await service.resetUserPassword(admin, '2', { + reason: 'support reset' + }); + + expect(result.temporary_password).toMatch(/^Tmp-/); + expect(result.temporary_password?.length).toBeGreaterThanOrEqual(12); + }); + + it('lists and exports operation logs for auditors', async () => { + const list = await service.listOperationLogs(auditor, { action: 'admin_update_project_status' }); + const exported = await service.exportOperationLogs(auditor, { limit: '100' }); + + expect(list.logs[0].action).toBe('admin_update_project_status'); + expect(exported.filename).toContain('operation-logs-'); + expect(exported.content).toContain('admin_update_project_status'); + expect(prisma.operationLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + action: 'admin_export_operation_logs', + operator_role: 'auditor' + }) + }); + }); +}); diff --git a/backend/src/admin/admin.service.ts b/backend/src/admin/admin.service.ts new file mode 100644 index 0000000..7694471 --- /dev/null +++ b/backend/src/admin/admin.service.ts @@ -0,0 +1,4254 @@ +import { + BadRequestException, + Inject, + Injectable, + NotFoundException, + Optional +} from '@nestjs/common'; +import { Prisma, type CreativePattern, type Episode, type HitAnalysisCase, type HitAnalysisSegment, type OperationLog, type Project, type ProviderConfig, type ProviderLog, type RenderTask, type StoryboardShot, type VideoClip } from '@prisma/client'; +import { randomBytes } from 'node:crypto'; +import { hash } from 'bcryptjs'; +import type { AuthRequestUser } from '../auth/auth.types'; +import { assertPermission, permissionsForRole } from '../auth/rbac'; +import { DEFAULT_AI_ROUTER_CONFIG } from '../ai-router/ai-router.types'; +import { toSafeAsset } from '../assets/asset.types'; +import { toSafeOrder, toSafeQuotaAccount, toSafeQuotaLog } from '../billing/billing.types'; +import { toSafeCharacter, toSafeGlobalCharacter } from '../characters/character.types'; +import { ApiCryptoService } from '../common/api-crypto.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { PROJECT_STATUSES, toSafeProject } from '../projects/project.types'; +import { toSafeActorProfile, toSafeVideoClip } from '../live-action/live-action.types'; +import { toSafeProviderLog } from '../providers/provider.types'; +import { toSafeRenderTask } from '../queues/task.types'; +import { toSafeUser } from '../users/user.types'; +import { + AdminListAssetsQueryDto, + AdminListCharactersQueryDto, + AdminListCreativePatternsQueryDto, + AdminListCopyrightRecordsQueryDto, + AdminListGlobalCharactersQueryDto, + AdminListHitAnalysesQueryDto, + AdminListNovelChaptersQueryDto, + AdminListNovelSourcesQueryDto, + AdminListOperationLogsQueryDto, + AdminAnalyzeHitCaseDto, + AdminBindCharacterGlobalDto, + AdminCreateHitAnalysisCaseDto, + AdminListProjectsQueryDto, + AdminListRouterAuditsQueryDto, + AdminListStoryboardShotsQueryDto, + AdminListUsersQueryDto, + AdminListWorksQueryDto, + AdminPromoteHitCasePatternsDto, + AdminResetUserPasswordDto, + AdminSaveGlobalCharacterDto, + AdminUpdateCreativePatternDto, + AdminUpdateCreativePatternStatusDto, + AdminUpdateRouterAuditQualityDto, + AdminUpdateProjectStatusDto, + AdminUpdateUserRoleDto, + AdminUpdateUserStatusDto, + AdminUpdateSystemConfigDto +} from './admin.dto'; +import { + toSafeCopyrightRecord, + toSafeCreativePattern, + toSafeHitAnalysisCase, + toSafeHitAnalysisSegment, + toSafeNovelChapter, + toSafeNovelSource, + toSafeOperationLog, + toSafeSystemConfig +} from './admin.types'; + +const DEFAULT_SYSTEM_CONFIGS = [ + { + config_key: 'security.api_crypto_enabled', + config_value: { enabled: false }, + description: 'API 请求体和响应体应用层加密开关。测试默认关闭,上线后可手动开启。', + is_public: true + }, + { + config_key: 'ai.router.v1', + config_value: DEFAULT_AI_ROUTER_CONFIG, + description: 'AI Router V1 路由配置。用于按语言、任务、镜头评分、预算和 Provider 可用性自动选择模型。', + is_public: false + } +] as const; +const ADMIN_USER_STATUSES = ['active', 'disabled'] as const; +const ADMIN_USER_ROLES = ['user', 'admin', 'operator', 'finance', 'auditor'] as const; +const GLOBAL_CHARACTER_STATUSES = ['active', 'disabled', 'archived'] as const; +const GLOBAL_CHARACTER_COMMERCIAL_STATUSES = [ + 'internal_test', + 'company_owned', + 'licensed', + 'restricted' +] as const; +const GLOBAL_CHARACTER_USAGE_SCOPES = ['internal', 'commercial', 'single_project', 'restricted'] as const; +const ROUTER_AUDIT_QUALITY_STATUSES = ['passed', 'rejected', 'manual_required', 'needs_retry'] as const; +const HIT_ANALYSIS_STATUSES = ['draft', 'analyzed', 'archived'] as const; +const CREATIVE_PATTERN_STATUSES = ['active', 'disabled', 'archived'] as const; + +type RouterAuditRenderNormalization = { + task_id: string; + output_asset_id: string | null; + shot_id: string; + shot_no: number | null; + target_duration: number | null; + source_duration: number | null; + final_duration: number | null; + trimmed: boolean; + trim_strategy: string | null; + trim_start: number | null; + trim_tolerance: number | null; +}; +const CREATIVE_PATTERN_TYPES = [ + 'opening_hook', + 'reversal_loop', + 'character_archetype', + 'visual_prompt', + 'episode_rhythm' +] as const; +const PASSWORD_MIN_LENGTH = 8; +const PASSWORD_MAX_LENGTH = 72; + +const HIT_KEYWORDS = { + hook: ['重生', '退婚', '离婚', '背叛', '秘密', '怀孕', '替身', '直播', '曝光', '十年后', '第一集', '开局'], + conflict: ['打脸', '复仇', '争吵', '威胁', '陷害', '夺权', '抢婚', '背叛', '逼迫', '证据', '误会', '反击'], + reversal: ['没想到', '原来', '其实', '真相', '身份曝光', '反转', '另有隐情', '突然', '揭穿'], + emotion: ['崩溃', '哭', '愤怒', '心碎', '表白', '后悔', '绝望', '冷笑', '隐忍', '释然'], + visual: ['雨夜', '婚礼', '医院', '会议室', '豪宅', '车祸', '电梯', '天台', '镜头', '特写', '转身', '门口'], + action: ['追车', '奔跑', '摔门', '推开', '打斗', '爆炸', '抢夺', '跪下', '拥抱', '逃跑'] +} as const; + +type RouterAuditTimelineKind = + | 'route_decision' + | 'render_task' + | 'provider_call' + | 'quality_check' + | 'repair' + | 'clip_normalization' + | 'video_clip' + | 'manual_operation'; + +interface RouterAuditTimelineItem { + id: string; + kind: RouterAuditTimelineKind; + title: string; + status: string | null; + at: string; + actor: string | null; + provider_code: string | null; + task_id: string | null; + clip_id: string | null; + cost_actual: number | null; + details: Prisma.InputJsonValue; +} + +interface HitSegmentDraft { + segment_no: number; + start_second: number; + end_second: number; + scene_type: string; + hook_type: string; + emotion: string; + conflict_type: string; + plot_function: string; + visual_strategy: string; + dialogue_pattern: string; + camera_notes: string; + importance_score: number; + emotion_score: number; + action_score: number; + tags: string[]; + summary_text: string; + prompt_seed: string; +} + +interface HitPatternDraft { + pattern_type: (typeof CREATIVE_PATTERN_TYPES)[number]; + title: string; + description: string; + structure_json: Prisma.InputJsonValue; + prompt_template: string; + negative_prompt: string; + tags: string[]; + effectiveness_score: number; +} + +interface CreativePatternMetrics { + bound_project_count: number; + completed_project_count: number; + active_project_count: number; + video_asset_count: number; + provider_log_count: number; + task_count: number; + analytics_event_count: number; + total_cost_actual: number; + total_revenue_estimate: number; + roi_estimate: number; + avg_quality_score: number | null; + avg_completion_rate: number | null; + total_play_count: number; + total_like_count: number; + project_ids: string[]; +} + +interface CreativePatternProjectMetricRow { + project: ReturnType; + cost_actual: number; + revenue_estimate: number; + roi_estimate: number; + video_asset_count: number; + analytics_event_count: number; + avg_quality_score: number | null; + avg_completion_rate: number | null; + play_count: number; + like_count: number; +} + +interface ProjectMetricSnapshot { + project_id: string; + project: Project | null; + cost_actual: number; + revenue_estimate: number; + roi_estimate: number; + video_asset_count: number; + provider_log_count: number; + task_count: number; + analytics_event_count: number; + avg_quality_score: number | null; + avg_completion_rate: number | null; + play_count: number; + like_count: number; +} + +@Injectable() +export class AdminService { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Optional() @Inject(ApiCryptoService) private readonly apiCrypto?: ApiCryptoService + ) {} + + async getDashboard(user: AuthRequestUser) { + this.assertAdmin(user); + const today = this.startOfToday(); + const [ + totalUsers, + totalProjects, + todayProjects, + todayEpisodes, + todayVideos, + failedTasks, + manualTasks, + pendingReviews, + queueBacklog, + projectStatusCounts, + taskStatusCounts, + providerStatusCounts, + topGenres, + topStyles, + costAggregate + ] = await Promise.all([ + this.prisma.user.count(), + this.prisma.project.count(), + this.prisma.project.count({ where: { created_at: { gte: today } } }), + this.prisma.episode.count({ where: { created_at: { gte: today } } }), + this.prisma.asset.count({ + where: { + asset_type: 'video', + status: 'active', + created_at: { gte: today } + } + }), + this.prisma.renderTask.count({ where: { status: 'failed' } }), + this.prisma.renderTask.count({ where: { status: 'manual_required' } }), + this.prisma.contentReview.count({ + where: { result_status: { in: ['pending', 'manual_required'] } } + }), + this.prisma.renderTask.count({ where: { status: { in: ['pending', 'running', 'retrying'] } } }), + this.prisma.project.groupBy({ + by: ['status'], + _count: { _all: true }, + orderBy: { _count: { status: 'desc' } } + }), + this.prisma.renderTask.groupBy({ + by: ['status'], + _count: { _all: true }, + orderBy: { _count: { status: 'desc' } } + }), + this.prisma.providerLog.groupBy({ + by: ['provider_type', 'status'], + _count: { _all: true }, + _sum: { cost_actual: true }, + orderBy: { _count: { provider_type: 'desc' } } + }), + this.prisma.project.groupBy({ + by: ['genre'], + where: { genre: { not: null } }, + _count: { _all: true }, + orderBy: { _count: { genre: 'desc' } }, + take: 5 + }), + this.prisma.project.groupBy({ + by: ['style_code'], + where: { style_code: { not: null } }, + _count: { _all: true }, + orderBy: { _count: { style_code: 'desc' } }, + take: 5 + }), + this.prisma.providerLog.aggregate({ + where: { status: 'success' }, + _sum: { cost_actual: true }, + _count: { _all: true } + }) + ]); + const totalCost = this.decimalToNumber(costAggregate._sum.cost_actual); + + return { + metrics: { + total_users: totalUsers, + total_projects: totalProjects, + today_projects: todayProjects, + today_episodes: todayEpisodes, + today_completed_videos: todayVideos, + failed_tasks: failedTasks, + manual_required_tasks: manualTasks, + pending_reviews: pendingReviews, + queue_backlog: queueBacklog, + ai_cost_actual: totalCost, + average_provider_cost: + costAggregate._count._all > 0 ? Number((totalCost / costAggregate._count._all).toFixed(4)) : 0 + }, + project_status_counts: this.toCountRows(projectStatusCounts, 'status'), + task_status_counts: this.toCountRows(taskStatusCounts, 'status'), + provider_status_counts: providerStatusCounts.map((row) => ({ + provider_type: row.provider_type, + status: row.status, + count: row._count._all, + cost_actual: this.decimalToNumber(row._sum.cost_actual) + })), + top_genres: this.toCountRows(topGenres, 'genre'), + top_styles: this.toCountRows(topStyles, 'style_code') + }; + } + + async getRbacProfile(user: AuthRequestUser) { + this.assertAdmin(user); + + return { + role: user.role, + permissions: permissionsForRole(user.role) + }; + } + + async listProjects(user: AuthRequestUser, query: AdminListProjectsQueryDto) { + this.assertAdmin(user); + const where: Prisma.ProjectWhereInput = {}; + const limit = this.normalizePositiveInt(query.limit, 'limit', 1, 100, 50); + + if (query.status) { + where.status = this.normalizeOptionalText(query.status, 80); + } + if (query.input_mode) { + where.input_mode = this.normalizeOptionalText(query.input_mode, 50); + } + if (query.user_id) { + where.user_id = this.parseId(query.user_id, 'Invalid user_id'); + } + + const [projects, total] = await Promise.all([ + this.prisma.project.findMany({ + where, + orderBy: { created_at: 'desc' }, + take: limit + }), + this.prisma.project.count({ where }) + ]); + + const rows = await Promise.all( + projects.map(async (project) => { + const [owner, episodeCount, assetCount, taskCount, latestTask] = await Promise.all([ + this.prisma.user.findUnique({ where: { id: project.user_id } }), + this.prisma.episode.count({ where: { project_id: project.id } }), + this.prisma.asset.count({ where: { project_id: project.id } }), + this.prisma.renderTask.count({ where: { project_id: project.id } }), + this.prisma.renderTask.findFirst({ + where: { project_id: project.id }, + orderBy: { created_at: 'desc' } + }) + ]); + + return { + project: toSafeProject(project), + owner: owner ? toSafeUser(owner) : null, + counts: { + episodes: episodeCount, + assets: assetCount, + tasks: taskCount + }, + latest_task: latestTask ? toSafeRenderTask(latestTask) : null + }; + }) + ); + + return { projects: rows, total, limit }; + } + + async getProjectDetail(user: AuthRequestUser, projectId: string) { + this.assertAdmin(user); + const project = await this.prisma.project.findUnique({ + where: { id: this.parseId(projectId, 'Invalid project id') } + }); + + if (!project) { + throw new NotFoundException('Project not found'); + } + + const [ + owner, + novelSources, + chapters, + latestStoryBible, + characters, + episodes, + shotCount, + assets, + tasks, + providerLogs, + copyrightRecords, + actorProfiles, + videoClips, + costAggregate + ] = await Promise.all([ + this.prisma.user.findUnique({ where: { id: project.user_id } }), + this.prisma.novelSource.findMany({ + where: { project_id: project.id }, + orderBy: { created_at: 'desc' }, + take: 10 + }), + this.prisma.novelChapter.findMany({ + where: { project_id: project.id }, + orderBy: [{ chapter_no: 'asc' }], + take: 20 + }), + this.prisma.storyBible.findFirst({ + where: { project_id: project.id }, + orderBy: { version: 'desc' } + }), + this.prisma.character.findMany({ + where: { project_id: project.id, status: { not: 'deleted' } }, + orderBy: [{ importance_level: 'desc' }, { id: 'asc' }], + take: 30 + }), + this.prisma.episode.findMany({ + where: { project_id: project.id }, + orderBy: { episode_no: 'asc' }, + take: 50 + }), + this.prisma.storyboardShot.count({ where: { project_id: project.id } }), + this.prisma.asset.findMany({ + where: { project_id: project.id }, + orderBy: { created_at: 'desc' }, + take: 30 + }), + this.prisma.renderTask.findMany({ + where: { project_id: project.id }, + orderBy: { created_at: 'desc' }, + take: 30 + }), + this.prisma.providerLog.findMany({ + where: { project_id: project.id }, + orderBy: { created_at: 'desc' }, + take: 30 + }), + this.prisma.copyrightRecord.findMany({ + where: { project_id: project.id }, + orderBy: { confirmed_at: 'desc' }, + take: 10 + }), + this.prisma.actorProfile.findMany({ + where: { project_id: project.id }, + orderBy: { created_at: 'asc' }, + take: 50 + }), + this.prisma.videoClip.findMany({ + where: { project_id: project.id }, + orderBy: { created_at: 'desc' }, + take: 50 + }), + this.prisma.providerLog.aggregate({ + where: { project_id: project.id, status: 'success' }, + _sum: { cost_actual: true }, + _count: { _all: true } + }) + ]); + + return { + project: toSafeProject(project), + owner: owner ? toSafeUser(owner) : null, + counts: { + novel_sources: novelSources.length, + chapters: await this.prisma.novelChapter.count({ where: { project_id: project.id } }), + characters: characters.length, + episodes: episodes.length, + storyboard_shots: shotCount, + actor_profiles: actorProfiles.length, + video_clips: videoClips.length, + assets: await this.prisma.asset.count({ where: { project_id: project.id } }), + tasks: await this.prisma.renderTask.count({ where: { project_id: project.id } }) + }, + cost: { + provider_log_count: costAggregate._count._all, + cost_actual: this.decimalToNumber(costAggregate._sum.cost_actual) + }, + novel_sources: novelSources.map(toSafeNovelSource), + chapters: chapters.map(toSafeNovelChapter), + story_bible: latestStoryBible + ? { + id: latestStoryBible.id.toString(), + version: latestStoryBible.version, + status: latestStoryBible.status, + title: latestStoryBible.title, + logline: latestStoryBible.logline, + tone: latestStoryBible.tone, + updated_at: latestStoryBible.updated_at.toISOString() + } + : null, + characters: characters.map(toSafeCharacter), + episodes: episodes.map((episode) => ({ + id: episode.id.toString(), + episode_no: episode.episode_no, + title: episode.title, + status: episode.status, + target_duration: episode.target_duration, + updated_at: episode.updated_at.toISOString() + })), + assets: assets.map(toSafeAsset), + actor_profiles: actorProfiles.map(toSafeActorProfile), + video_clips: videoClips.map(toSafeVideoClip), + tasks: tasks.map(toSafeRenderTask), + provider_logs: providerLogs.map(toSafeProviderLog), + copyright_records: copyrightRecords.map(toSafeCopyrightRecord) + }; + } + + async updateProjectStatus( + user: AuthRequestUser, + projectId: string, + dto: AdminUpdateProjectStatusDto + ) { + assertPermission(user, 'projects:write'); + const status = this.validateProjectStatus(dto.status); + const project = await this.prisma.project.findUnique({ + where: { id: this.parseId(projectId, 'Invalid project id') } + }); + + if (!project) { + throw new NotFoundException('Project not found'); + } + + const updated = await this.prisma.project.update({ + where: { id: project.id }, + data: { status } + }); + const operationLog = await this.prisma.operationLog.create({ + data: { + user_id: BigInt(user.id), + operator_role: user.role, + action: 'admin_update_project_status', + target_type: 'project', + target_id: project.id, + metadata_json: { + from_status: project.status, + to_status: status, + reason: this.normalizeOptionalText(dto.reason, 255) ?? null + } + } + }); + + return { + project: toSafeProject(updated), + operation_log: toSafeOperationLog(operationLog) + }; + } + + async listUsers(user: AuthRequestUser, query: AdminListUsersQueryDto) { + assertPermission(user, 'users:read'); + const where: Prisma.UserWhereInput = {}; + const limit = this.normalizePositiveInt(query.limit, 'limit', 1, 100, 50); + + if (query.role) { + where.role = this.normalizeOptionalText(query.role, 50); + } + if (query.status) { + where.status = this.normalizeOptionalText(query.status, 50); + } + + const [users, total] = await Promise.all([ + this.prisma.user.findMany({ + where, + orderBy: { created_at: 'desc' }, + take: limit + }), + this.prisma.user.count({ where }) + ]); + const rows = await Promise.all( + users.map(async (item) => ({ + user: toSafeUser(item), + project_count: await this.prisma.project.count({ where: { user_id: item.id } }), + asset_count: await this.prisma.asset.count({ where: { user_id: item.id } }) + })) + ); + + return { users: rows, total, limit }; + } + + async getUserDetail(user: AuthRequestUser, userId: string) { + assertPermission(user, 'users:read'); + const id = this.parseId(userId, 'Invalid user id'); + const target = await this.prisma.user.findUnique({ where: { id } }); + + if (!target) { + throw new NotFoundException('User not found'); + } + + const projects = await this.prisma.project.findMany({ + where: { user_id: id }, + orderBy: { created_at: 'desc' }, + take: 20 + }); + const projectIds = projects.map((project) => project.id); + const operationWhere: Prisma.OperationLogWhereInput = { + OR: [ + { user_id: id }, + { target_type: 'user', target_id: id }, + ...(projectIds.length ? [{ target_type: 'project', target_id: { in: projectIds } }] : []) + ] + }; + + const [ + quotaAccount, + quotaLogs, + orders, + assets, + operationLogs, + projectCount, + assetCount, + orderCount, + quotaLogCount + ] = await Promise.all([ + this.prisma.quotaAccount.findUnique({ where: { user_id: id } }), + this.prisma.quotaLog.findMany({ + where: { user_id: id }, + orderBy: { created_at: 'desc' }, + take: 30 + }), + this.prisma.order.findMany({ + where: { user_id: id }, + orderBy: { created_at: 'desc' }, + take: 20 + }), + this.prisma.asset.findMany({ + where: { user_id: id }, + orderBy: { created_at: 'desc' }, + take: 30 + }), + this.prisma.operationLog.findMany({ + where: operationWhere, + orderBy: { created_at: 'desc' }, + take: 30 + }), + this.prisma.project.count({ where: { user_id: id } }), + this.prisma.asset.count({ where: { user_id: id } }), + this.prisma.order.count({ where: { user_id: id } }), + this.prisma.quotaLog.count({ where: { user_id: id } }) + ]); + + const projectTitleById = new Map(projects.map((project) => [project.id.toString(), project.title])); + + return { + user: toSafeUser(target), + quota_account: quotaAccount ? toSafeQuotaAccount(quotaAccount) : null, + quota_logs: quotaLogs.map(toSafeQuotaLog), + orders: orders.map(toSafeOrder), + projects: projects.map(toSafeProject), + assets: assets.map((asset) => ({ + asset: toSafeAsset(asset), + project_title: asset.project_id ? projectTitleById.get(asset.project_id.toString()) ?? null : null + })), + operation_logs: operationLogs.map(toSafeOperationLog), + counts: { + project_count: projectCount, + asset_count: assetCount, + order_count: orderCount, + quota_log_count: quotaLogCount + } + }; + } + + async updateUserStatus( + user: AuthRequestUser, + userId: string, + dto: AdminUpdateUserStatusDto + ) { + assertPermission(user, 'users:write'); + const target = await this.findUserOrThrow(userId); + const status = this.validateUserStatus(dto.status); + + if (target.id.toString() === user.id && status !== 'active') { + throw new BadRequestException('Current admin user cannot be disabled'); + } + + const updated = await this.prisma.user.update({ + where: { id: target.id }, + data: { status } + }); + const operationLog = await this.writeUserOperationLog(user, target.id, 'admin_update_user_status', { + from_status: target.status, + to_status: status, + reason: this.normalizeOptionalText(dto.reason, 255) ?? null + }); + + return { + user: toSafeUser(updated), + operation_log: toSafeOperationLog(operationLog) + }; + } + + async updateUserRole(user: AuthRequestUser, userId: string, dto: AdminUpdateUserRoleDto) { + assertPermission(user, 'users:write'); + const target = await this.findUserOrThrow(userId); + const role = this.validateUserRole(dto.role); + + if (target.id.toString() === user.id && role !== 'admin') { + throw new BadRequestException('Current admin user cannot remove own admin role'); + } + + const updated = await this.prisma.user.update({ + where: { id: target.id }, + data: { role } + }); + const operationLog = await this.writeUserOperationLog(user, target.id, 'admin_update_user_role', { + from_role: target.role, + to_role: role, + reason: this.normalizeOptionalText(dto.reason, 255) ?? null + }); + + return { + user: toSafeUser(updated), + operation_log: toSafeOperationLog(operationLog) + }; + } + + async resetUserPassword( + user: AuthRequestUser, + userId: string, + dto: AdminResetUserPasswordDto + ) { + assertPermission(user, 'users:write'); + const target = await this.findUserOrThrow(userId); + const generated = !this.normalizeOptionalText(dto.new_password, PASSWORD_MAX_LENGTH); + const nextPassword = generated + ? this.createTemporaryPassword() + : this.validatePassword(dto.new_password); + const passwordHash = await hash(nextPassword, 12); + const updated = await this.prisma.user.update({ + where: { id: target.id }, + data: { password_hash: passwordHash } + }); + const operationLog = await this.writeUserOperationLog(user, target.id, 'admin_reset_user_password', { + generated, + reason: this.normalizeOptionalText(dto.reason, 255) ?? null + }); + + return { + user: toSafeUser(updated), + temporary_password: generated ? nextPassword : null, + operation_log: toSafeOperationLog(operationLog) + }; + } + + async listAssets(user: AuthRequestUser, query: AdminListAssetsQueryDto) { + this.assertAdmin(user); + const where: Prisma.AssetWhereInput = {}; + const limit = this.normalizePositiveInt(query.limit, 'limit', 1, 100, 50); + + if (query.asset_type) { + where.asset_type = this.normalizeOptionalText(query.asset_type, 50); + } + if (query.status) { + where.status = this.normalizeOptionalText(query.status, 50); + } + if (query.project_id) { + where.project_id = this.parseId(query.project_id, 'Invalid project_id'); + } + if (query.user_id) { + where.user_id = this.parseId(query.user_id, 'Invalid user_id'); + } + + const [assets, total] = await Promise.all([ + this.prisma.asset.findMany({ + where, + orderBy: { created_at: 'desc' }, + take: limit + }), + this.prisma.asset.count({ where }) + ]); + const rows = await Promise.all( + assets.map(async (asset) => { + const [project, owner] = await Promise.all([ + asset.project_id ? this.prisma.project.findUnique({ where: { id: asset.project_id } }) : null, + asset.user_id ? this.prisma.user.findUnique({ where: { id: asset.user_id } }) : null + ]); + + return { + asset: toSafeAsset(asset), + project_title: project?.title ?? null, + user_email: owner?.email ?? null + }; + }) + ); + + return { assets: rows, total, limit }; + } + + async listNovelSources(user: AuthRequestUser, query: AdminListNovelSourcesQueryDto) { + this.assertAdmin(user); + const where: Prisma.NovelSourceWhereInput = {}; + const limit = this.normalizePositiveInt(query.limit, 'limit', 1, 100, 50); + + if (query.project_id) { + where.project_id = this.parseId(query.project_id, 'Invalid project_id'); + } + if (query.source_type) { + where.source_type = this.normalizeOptionalText(query.source_type, 50); + } + if (query.parse_status) { + where.parse_status = this.normalizeOptionalText(query.parse_status, 50); + } + if (query.user_id) { + const projectIds = await this.projectIdsForUser(query.user_id); + where.project_id = { in: projectIds }; + } + + const [sources, total] = await Promise.all([ + this.prisma.novelSource.findMany({ + where, + orderBy: { created_at: 'desc' }, + take: limit + }), + this.prisma.novelSource.count({ where }) + ]); + const rows = await Promise.all( + sources.map(async (source) => { + const [project, chapterCount] = await Promise.all([ + this.prisma.project.findUnique({ where: { id: source.project_id } }), + this.prisma.novelChapter.count({ where: { novel_source_id: source.id } }) + ]); + + return { + source: toSafeNovelSource(source), + project: project ? toSafeProject(project) : null, + chapter_count: chapterCount + }; + }) + ); + + return { sources: rows, total, limit }; + } + + async listNovelChapters(user: AuthRequestUser, query: AdminListNovelChaptersQueryDto) { + this.assertAdmin(user); + const where: Prisma.NovelChapterWhereInput = {}; + const limit = this.normalizePositiveInt(query.limit, 'limit', 1, 200, 80); + + if (query.project_id) { + where.project_id = this.parseId(query.project_id, 'Invalid project_id'); + } + if (query.novel_source_id) { + where.novel_source_id = this.parseId(query.novel_source_id, 'Invalid novel_source_id'); + } + if (query.status) { + where.status = this.normalizeOptionalText(query.status, 50); + } + + const [chapters, total] = await Promise.all([ + this.prisma.novelChapter.findMany({ + where, + orderBy: [{ project_id: 'desc' }, { chapter_no: 'asc' }], + take: limit + }), + this.prisma.novelChapter.count({ where }) + ]); + const rows = await Promise.all( + chapters.map(async (chapter) => { + const project = await this.prisma.project.findUnique({ where: { id: chapter.project_id } }); + + return { + chapter: toSafeNovelChapter(chapter), + project_title: project?.title ?? null + }; + }) + ); + + return { chapters: rows, total, limit }; + } + + async listCharacters(user: AuthRequestUser, query: AdminListCharactersQueryDto) { + this.assertAdmin(user); + const where: Prisma.CharacterWhereInput = {}; + const limit = this.normalizePositiveInt(query.limit, 'limit', 1, 100, 50); + + if (query.project_id) { + where.project_id = this.parseId(query.project_id, 'Invalid project_id'); + } + if (query.global_character_id) { + where.global_character_id = this.parseId(query.global_character_id, 'Invalid global_character_id'); + } + if (query.status) { + where.status = this.normalizeOptionalText(query.status, 50); + } else { + where.status = { not: 'deleted' }; + } + if (query.role_type) { + where.role_type = this.normalizeOptionalText(query.role_type, 50); + } + + const [characters, total] = await Promise.all([ + this.prisma.character.findMany({ + where, + orderBy: [{ project_id: 'desc' }, { importance_level: 'desc' }, { id: 'asc' }], + take: limit + }), + this.prisma.character.count({ where }) + ]); + const rows = await Promise.all( + characters.map(async (character) => { + const [project, globalCharacter, imageCount, memoryCount] = await Promise.all([ + this.prisma.project.findUnique({ where: { id: character.project_id } }), + character.global_character_id + ? this.prisma.globalCharacter.findUnique({ where: { id: character.global_character_id } }) + : Promise.resolve(null), + this.prisma.characterImage.count({ where: { character_id: character.id } }), + this.prisma.characterMemory.count({ where: { character_id: character.id } }) + ]); + + return { + character: toSafeCharacter(character), + global_character: globalCharacter ? toSafeGlobalCharacter(globalCharacter) : null, + project_title: project?.title ?? null, + image_count: imageCount, + memory_count: memoryCount + }; + }) + ); + + return { characters: rows, total, limit }; + } + + async listGlobalCharacters(user: AuthRequestUser, query: AdminListGlobalCharactersQueryDto) { + this.assertAdmin(user); + const where: Prisma.GlobalCharacterWhereInput = {}; + const limit = this.normalizePositiveInt(query.limit, 'limit', 1, 100, 50); + + if (query.status) { + where.status = this.normalizeOptionalText(query.status, 50); + } else { + where.status = { not: 'archived' }; + } + if (query.role_archetype) { + where.role_archetype = this.normalizeOptionalText(query.role_archetype, 50); + } + if (query.commercial_status) { + where.commercial_status = this.normalizeOptionalText(query.commercial_status, 50); + } + + const [globalCharacters, total] = await Promise.all([ + this.prisma.globalCharacter.findMany({ + where, + orderBy: [{ status: 'asc' }, { updated_at: 'desc' }], + take: limit + }), + this.prisma.globalCharacter.count({ where }) + ]); + + const rows = await Promise.all( + globalCharacters.map(async (character) => { + const [assetCount, boundProjectCharacterCount] = await Promise.all([ + this.prisma.globalCharacterAsset.count({ where: { global_character_id: character.id, status: 'active' } }), + this.prisma.character.count({ + where: { global_character_id: character.id, status: { not: 'deleted' } } + }) + ]); + + return { + character: toSafeGlobalCharacter(character), + asset_count: assetCount, + bound_project_character_count: boundProjectCharacterCount + }; + }) + ); + + return { characters: rows, total, limit }; + } + + async createGlobalCharacter(user: AuthRequestUser, dto: AdminSaveGlobalCharacterDto) { + assertPermission(user, 'projects:write'); + const data = this.createGlobalCharacterCreateData(user, dto); + const character = await this.prisma.globalCharacter.create({ data }); + const operationLog = await this.prisma.operationLog.create({ + data: { + user_id: this.parseId(user.id, 'Invalid user id'), + operator_role: user.role, + action: 'admin_create_global_character', + target_type: 'global_character', + target_id: character.id, + metadata_json: { + name: character.name, + role_archetype: character.role_archetype, + commercial_status: character.commercial_status + } + } + }); + + return { + character: toSafeGlobalCharacter(character), + operation_log: toSafeOperationLog(operationLog) + }; + } + + async updateGlobalCharacter( + user: AuthRequestUser, + globalCharacterId: string, + dto: AdminSaveGlobalCharacterDto + ) { + assertPermission(user, 'projects:write'); + const id = this.parseId(globalCharacterId, 'Invalid global character id'); + const existing = await this.prisma.globalCharacter.findUnique({ where: { id } }); + + if (!existing) { + throw new NotFoundException('Global character not found'); + } + + const data = this.createGlobalCharacterUpdateData(dto); + if (Object.keys(data).length === 0) { + throw new BadRequestException('No global character fields to update'); + } + + const character = await this.prisma.globalCharacter.update({ + where: { id }, + data + }); + const operationLog = await this.prisma.operationLog.create({ + data: { + user_id: this.parseId(user.id, 'Invalid user id'), + operator_role: user.role, + action: 'admin_update_global_character', + target_type: 'global_character', + target_id: id, + metadata_json: { + name: character.name, + from_status: existing.status, + to_status: character.status + } + } + }); + + return { + character: toSafeGlobalCharacter(character), + operation_log: toSafeOperationLog(operationLog) + }; + } + + async bindCharacterGlobal( + user: AuthRequestUser, + characterId: string, + dto: AdminBindCharacterGlobalDto + ) { + assertPermission(user, 'projects:write'); + const id = this.parseId(characterId, 'Invalid character id'); + const character = await this.prisma.character.findUnique({ where: { id } }); + + if (!character || character.status === 'deleted') { + throw new NotFoundException('Character not found'); + } + + const globalCharacter = dto.global_character_id + ? await this.prisma.globalCharacter.findUnique({ + where: { id: this.parseId(dto.global_character_id, 'Invalid global_character_id') } + }) + : null; + + if (dto.global_character_id && (!globalCharacter || globalCharacter.status !== 'active')) { + throw new NotFoundException('Active global character not found'); + } + + const data = this.createCharacterBindData(character, globalCharacter); + const updated = await this.prisma.character.update({ + where: { id }, + data + }); + const operationLog = await this.prisma.operationLog.create({ + data: { + user_id: this.parseId(user.id, 'Invalid user id'), + operator_role: user.role, + action: 'admin_bind_global_character', + target_type: 'character', + target_id: id, + metadata_json: { + project_id: character.project_id.toString(), + from_global_character_id: character.global_character_id?.toString() ?? null, + to_global_character_id: globalCharacter?.id.toString() ?? null, + reason: this.normalizeOptionalText(dto.reason, 255) ?? null + } + } + }); + + return { + character: toSafeCharacter(updated), + global_character: globalCharacter ? toSafeGlobalCharacter(globalCharacter) : null, + operation_log: toSafeOperationLog(operationLog) + }; + } + + async listStoryboardShots(user: AuthRequestUser, query: AdminListStoryboardShotsQueryDto) { + this.assertAdmin(user); + const where: Prisma.StoryboardShotWhereInput = {}; + const limit = this.normalizePositiveInt(query.limit, 'limit', 1, 200, 80); + + if (query.project_id) { + where.project_id = this.parseId(query.project_id, 'Invalid project_id'); + } + if (query.episode_id) { + where.episode_id = this.parseId(query.episode_id, 'Invalid episode_id'); + } + if (query.status) { + where.status = this.normalizeOptionalText(query.status, 50); + } + + const [shots, total] = await Promise.all([ + this.prisma.storyboardShot.findMany({ + where, + orderBy: [{ project_id: 'desc' }, { episode_id: 'asc' }, { shot_no: 'asc' }], + take: limit + }), + this.prisma.storyboardShot.count({ where }) + ]); + const rows = await Promise.all( + shots.map(async (shot) => { + const [project, episode, imageCount, latestImage] = await Promise.all([ + this.prisma.project.findUnique({ where: { id: shot.project_id } }), + this.prisma.episode.findUnique({ where: { id: shot.episode_id } }), + this.prisma.shotImage.count({ where: { shot_id: shot.id } }), + this.prisma.shotImage.findFirst({ + where: { shot_id: shot.id, asset_id: { not: null } }, + orderBy: [{ image_type: 'desc' }, { created_at: 'desc' }] + }) + ]); + + return { + shot: { + id: shot.id.toString(), + project_id: shot.project_id.toString(), + episode_id: shot.episode_id.toString(), + shot_no: shot.shot_no, + scene_name: shot.scene_name, + visual_desc: shot.visual_desc, + action_desc: shot.action_desc, + dialogue_text: shot.dialogue_text, + narration_text: shot.narration_text, + camera_motion: shot.camera_motion, + prompt_text: shot.prompt_text, + negative_prompt: shot.negative_prompt, + duration: shot.duration?.toString() ?? null, + status: shot.status, + updated_at: shot.updated_at.toISOString() + }, + project_title: project?.title ?? null, + episode_title: episode?.title ?? null, + image_count: imageCount, + latest_image_asset_id: latestImage?.asset_id?.toString() ?? null + }; + }) + ); + + return { shots: rows, total, limit }; + } + + async listHitAnalyses(user: AuthRequestUser, query: AdminListHitAnalysesQueryDto) { + this.assertAdmin(user); + const where: Prisma.HitAnalysisCaseWhereInput = {}; + const limit = this.normalizePositiveInt(query.limit, 'limit', 1, 100, 50); + + if (query.source_platform) { + where.source_platform = this.normalizeOptionalText(query.source_platform, 100); + } + if (query.genre) { + where.genre = this.normalizeOptionalText(query.genre, 100); + } + if (query.status) { + where.status = this.validateChoice(query.status, HIT_ANALYSIS_STATUSES, 'status'); + } + + const [cases, total] = await Promise.all([ + this.prisma.hitAnalysisCase.findMany({ + where, + orderBy: [{ diagnosis_score: 'desc' }, { updated_at: 'desc' }], + take: limit + }), + this.prisma.hitAnalysisCase.count({ where }) + ]); + const caseIds = cases.map((item) => item.id); + const [segments, patterns] = await Promise.all([ + caseIds.length > 0 + ? this.prisma.hitAnalysisSegment.findMany({ + where: { case_id: { in: caseIds } }, + orderBy: [{ case_id: 'desc' }, { segment_no: 'asc' }], + take: 1000 + }) + : Promise.resolve([]), + caseIds.length > 0 + ? this.prisma.creativePattern.findMany({ + where: { source_case_id: { in: caseIds } }, + orderBy: { created_at: 'desc' }, + take: 500 + }) + : Promise.resolve([]) + ]); + const segmentsByCase = this.groupByBigint(segments, 'case_id'); + const patternCountByCase = this.countByBigint(patterns, 'source_case_id'); + const rows = cases.map((item) => { + const caseSegments = segmentsByCase.get(item.id.toString()) ?? []; + + return { + case: toSafeHitAnalysisCase(item), + segment_count: caseSegments.length, + pattern_count: patternCountByCase.get(item.id.toString()) ?? 0, + segments: caseSegments.map(toSafeHitAnalysisSegment) + }; + }); + + return { + summary: this.createHitAnalysisSummary(cases, segments, patterns), + cases: rows, + total, + limit + }; + } + + async createHitAnalysisCase(user: AuthRequestUser, dto: AdminCreateHitAnalysisCaseDto) { + assertPermission(user, 'projects:write'); + const created = await this.prisma.hitAnalysisCase.create({ + data: this.createHitAnalysisCaseData(user, dto) + }); + const operationLog = await this.prisma.operationLog.create({ + data: { + user_id: this.parseId(user.id, 'Invalid user id'), + operator_role: user.role, + action: 'admin_create_hit_analysis_case', + target_type: 'hit_analysis_case', + target_id: created.id, + metadata_json: { + title: created.title, + source_platform: created.source_platform, + genre: created.genre, + auto_analyze: Boolean(dto.auto_analyze) + } + } + }); + + if (dto.auto_analyze) { + const analyzed = await this.analyzeHitCaseRecord(user, created, {}); + + return { + ...analyzed, + create_operation_log: toSafeOperationLog(operationLog) + }; + } + + return { + case: toSafeHitAnalysisCase(created), + segments: [], + analysis: null, + operation_log: toSafeOperationLog(operationLog) + }; + } + + async analyzeHitCase( + user: AuthRequestUser, + caseId: string, + dto: AdminAnalyzeHitCaseDto + ) { + assertPermission(user, 'projects:write'); + const hitCase = await this.findHitAnalysisCaseOrThrow(caseId); + + return this.analyzeHitCaseRecord(user, hitCase, dto); + } + + async promoteHitCasePatterns( + user: AuthRequestUser, + caseId: string, + dto: AdminPromoteHitCasePatternsDto + ) { + assertPermission(user, 'projects:write'); + const hitCase = await this.findHitAnalysisCaseOrThrow(caseId); + const segments = await this.prisma.hitAnalysisSegment.findMany({ + where: { case_id: hitCase.id }, + orderBy: { segment_no: 'asc' } + }); + + if (segments.length === 0 || !hitCase.analysis_json) { + throw new BadRequestException('Hit case must be analyzed before promoting patterns'); + } + + const requestedTypes = this.normalizePatternTypes(dto.pattern_types); + const drafts = this.createHitPatternDrafts(hitCase, segments, this.jsonObject(hitCase.analysis_json)) + .filter((draft) => requestedTypes.length === 0 || requestedTypes.includes(draft.pattern_type)); + const patterns = await Promise.all( + drafts.map((draft) => + this.prisma.creativePattern.create({ + data: this.createCreativePatternData(user, hitCase, draft) + }) + ) + ); + const operationLog = await this.prisma.operationLog.create({ + data: { + user_id: this.parseId(user.id, 'Invalid user id'), + operator_role: user.role, + action: 'admin_promote_hit_analysis_patterns', + target_type: 'hit_analysis_case', + target_id: hitCase.id, + metadata_json: { + title: hitCase.title, + pattern_count: patterns.length, + pattern_types: patterns.map((pattern) => pattern.pattern_type) + } + } + }); + + return { + patterns: patterns.map(toSafeCreativePattern), + operation_log: toSafeOperationLog(operationLog) + }; + } + + async listCreativePatterns(user: AuthRequestUser, query: AdminListCreativePatternsQueryDto) { + this.assertAdmin(user); + const where: Prisma.CreativePatternWhereInput = {}; + const limit = this.normalizePositiveInt(query.limit, 'limit', 1, 200, 80); + + if (query.pattern_type) { + where.pattern_type = this.validateChoice(query.pattern_type, CREATIVE_PATTERN_TYPES, 'pattern_type'); + } + if (query.genre) { + where.genre = this.normalizeOptionalText(query.genre, 100); + } + if (query.status) { + where.status = this.validateChoice(query.status, CREATIVE_PATTERN_STATUSES, 'status'); + } else { + where.status = { not: 'archived' }; + } + + const [patterns, total] = await Promise.all([ + this.prisma.creativePattern.findMany({ + where, + orderBy: [{ effectiveness_score: 'desc' }, { updated_at: 'desc' }], + take: limit + }), + this.prisma.creativePattern.count({ where }) + ]); + const metricsMap = await this.createCreativePatternMetricsMap(patterns); + + return { + summary: this.createCreativePatternSummary(patterns, metricsMap), + patterns: patterns.map((pattern) => ({ + ...toSafeCreativePattern(pattern), + metrics: metricsMap.get(pattern.id.toString()) ?? this.emptyCreativePatternMetrics() + })), + total, + limit + }; + } + + async updateCreativePattern( + user: AuthRequestUser, + patternId: string, + dto: AdminUpdateCreativePatternDto + ) { + assertPermission(user, 'projects:write'); + const pattern = await this.findCreativePatternOrThrow(patternId); + const data = this.createCreativePatternUpdateData(dto); + + if (Object.keys(data).length === 0) { + throw new BadRequestException('No creative pattern fields to update'); + } + + const updated = await this.prisma.creativePattern.update({ + where: { id: pattern.id }, + data + }); + const metricsMap = await this.createCreativePatternMetricsMap([updated]); + const operationLog = await this.prisma.operationLog.create({ + data: { + user_id: this.parseId(user.id, 'Invalid user id'), + operator_role: user.role, + action: 'admin_update_creative_pattern', + target_type: 'creative_pattern', + target_id: pattern.id, + metadata_json: { + title: updated.title, + pattern_type: updated.pattern_type, + reason: this.normalizeOptionalText(dto.reason, 500) ?? null, + changed_fields: Object.keys(data) + } + } + }); + + return { + pattern: { + ...toSafeCreativePattern(updated), + metrics: metricsMap.get(updated.id.toString()) ?? this.emptyCreativePatternMetrics() + }, + operation_log: toSafeOperationLog(operationLog) + }; + } + + async updateCreativePatternStatus( + user: AuthRequestUser, + patternId: string, + dto: AdminUpdateCreativePatternStatusDto + ) { + assertPermission(user, 'projects:write'); + const pattern = await this.findCreativePatternOrThrow(patternId); + const status = this.validateChoice(dto.status, CREATIVE_PATTERN_STATUSES, 'status'); + const updated = await this.prisma.creativePattern.update({ + where: { id: pattern.id }, + data: { status } + }); + const metricsMap = await this.createCreativePatternMetricsMap([updated]); + const operationLog = await this.prisma.operationLog.create({ + data: { + user_id: this.parseId(user.id, 'Invalid user id'), + operator_role: user.role, + action: 'admin_update_creative_pattern_status', + target_type: 'creative_pattern', + target_id: pattern.id, + metadata_json: { + title: pattern.title, + from_status: pattern.status, + to_status: status, + reason: this.normalizeOptionalText(dto.reason, 500) ?? null + } + } + }); + + return { + pattern: { + ...toSafeCreativePattern(updated), + metrics: metricsMap.get(updated.id.toString()) ?? this.emptyCreativePatternMetrics() + }, + operation_log: toSafeOperationLog(operationLog) + }; + } + + async refreshCreativePatternMetrics(user: AuthRequestUser, patternId: string) { + assertPermission(user, 'projects:write'); + const pattern = await this.findCreativePatternOrThrow(patternId); + const metricsMap = await this.createCreativePatternMetricsMap([pattern]); + const metrics = metricsMap.get(pattern.id.toString()) ?? this.emptyCreativePatternMetrics(); + const projectMetrics = await this.createCreativePatternProjectMetricRows(metrics.project_ids); + const effectivenessScore = this.calculateCreativePatternEffectiveness(metrics); + const updated = await this.prisma.creativePattern.update({ + where: { id: pattern.id }, + data: { effectiveness_score: effectivenessScore } + }); + const operationLog = await this.prisma.operationLog.create({ + data: { + user_id: this.parseId(user.id, 'Invalid user id'), + operator_role: user.role, + action: 'admin_refresh_creative_pattern_metrics', + target_type: 'creative_pattern', + target_id: pattern.id, + metadata_json: { + title: pattern.title, + previous_effectiveness_score: this.decimalToOptionalNumber(pattern.effectiveness_score), + effectiveness_score: effectivenessScore, + metrics: metrics as unknown as Prisma.InputJsonValue + } + } + }); + + return { + pattern: { + ...toSafeCreativePattern(updated), + metrics + }, + metrics, + project_metrics: projectMetrics, + operation_log: toSafeOperationLog(operationLog) + }; + } + + async listRouterAudits(user: AuthRequestUser, query: AdminListRouterAuditsQueryDto) { + this.assertAdmin(user); + const where: Prisma.VideoClipWhereInput = {}; + const limit = this.normalizePositiveInt(query.limit, 'limit', 1, 200, 80); + + if (query.project_id) { + where.project_id = this.parseId(query.project_id, 'Invalid project_id'); + } + if (query.episode_id) { + where.episode_id = this.parseId(query.episode_id, 'Invalid episode_id'); + } + if (query.quality_status) { + where.quality_status = this.normalizeOptionalText(query.quality_status, 50); + } + if (query.provider_code) { + const providerCode = this.normalizeOptionalText(query.provider_code, 100); + const providers = await this.prisma.providerConfig.findMany({ + where: { + provider_type: 'VideoProvider', + provider_code: providerCode + }, + select: { id: true } + }); + const providerIds = providers.map((provider) => provider.id); + + where.provider_id = providerIds.length > 0 ? { in: providerIds } : -1n; + } + if (query.route_tier) { + const routeTier = this.normalizeOptionalText(query.route_tier, 50); + const shotWhere: Prisma.StoryboardShotWhereInput = { route_tier: routeTier }; + + if (where.project_id && typeof where.project_id === 'bigint') { + shotWhere.project_id = where.project_id; + } + if (where.episode_id && typeof where.episode_id === 'bigint') { + shotWhere.episode_id = where.episode_id; + } + + const shots = await this.prisma.storyboardShot.findMany({ + where: shotWhere, + select: { id: true }, + take: 2000 + }); + const shotIds = shots.map((shot) => shot.id); + + where.shot_id = shotIds.length > 0 ? { in: shotIds } : -1n; + } + + const [clips, total] = await Promise.all([ + this.prisma.videoClip.findMany({ + where, + orderBy: { created_at: 'desc' }, + take: limit + }), + this.prisma.videoClip.count({ where }) + ]); + + if (clips.length === 0) { + return { + summary: this.createRouterAuditSummary([]), + audits: [], + total, + limit + }; + } + + const projectIds = this.uniqueBigints(clips.map((clip) => clip.project_id)); + const episodeIds = this.uniqueBigints(clips.map((clip) => clip.episode_id)); + const shotIds = this.uniqueBigints(clips.map((clip) => clip.shot_id)); + const providerIds = this.uniqueBigints(clips.map((clip) => clip.provider_id).filter((id): id is bigint => Boolean(id))); + const outputAssetIds = this.uniqueBigints(clips.map((clip) => clip.output_asset_id).filter((id): id is bigint => Boolean(id))); + const [projects, episodes, shots, providers, tasks, renderTasks] = await Promise.all([ + this.prisma.project.findMany({ where: { id: { in: projectIds } } }), + this.prisma.episode.findMany({ where: { id: { in: episodeIds } } }), + this.prisma.storyboardShot.findMany({ where: { id: { in: shotIds } } }), + providerIds.length > 0 + ? this.prisma.providerConfig.findMany({ where: { id: { in: providerIds } } }) + : Promise.resolve([]), + this.prisma.renderTask.findMany({ + where: { + task_type: { + in: [ + 'live_action_video_clip_generate', + 'live_action_video_clip_retry', + 'live_action_video_clip_quality_check' + ] + }, + shot_id: { in: shotIds }, + ...(outputAssetIds.length > 0 ? { output_asset_id: { in: outputAssetIds } } : {}) + }, + orderBy: { created_at: 'desc' }, + take: Math.max(limit * 3, 200) + }), + this.prisma.renderTask.findMany({ + where: { + task_type: 'live_action_video_render', + episode_id: { in: episodeIds }, + status: 'success' + }, + orderBy: { created_at: 'desc' }, + take: Math.max(episodeIds.length * 3, 100) + }) + ]); + const projectMap = new Map(projects.map((project) => [project.id.toString(), project])); + const episodeMap = new Map(episodes.map((episode) => [episode.id.toString(), episode])); + const shotMap = new Map(shots.map((shot) => [shot.id.toString(), shot])); + const providerMap = new Map(providers.map((provider) => [provider.id.toString(), provider])); + const taskMap = this.createRouterAuditTaskMap(tasks); + const renderNormalizationMap = this.createRouterAuditRenderNormalizationMap(renderTasks); + const sourceClipIds = this.uniqueBigints( + clips + .map((clip) => this.routerAuditTaskForClip(clip, taskMap)) + .map((task) => this.jsonObject(task?.input_json ?? null)) + .map((input) => this.jsonObject(input.repair_context ?? null)) + .map((repairContext) => this.toBigIntOrNull(this.stringifyJsonText(repairContext.source_clip_id))) + .filter((id): id is bigint => Boolean(id)) + ); + const sourceClips = sourceClipIds.length > 0 + ? await this.prisma.videoClip.findMany({ where: { id: { in: sourceClipIds } } }) + : []; + const sourceClipMap = new Map(sourceClips.map((clip) => [clip.id.toString(), clip])); + const rows = clips.map((clip) => { + const task = this.routerAuditTaskForClip(clip, taskMap); + const project = projectMap.get(clip.project_id.toString()) ?? null; + const episode = episodeMap.get(clip.episode_id.toString()) ?? null; + const shot = shotMap.get(clip.shot_id.toString()) ?? null; + const provider = clip.provider_id ? providerMap.get(clip.provider_id.toString()) ?? null : null; + const input = this.jsonObject(task?.input_json ?? null); + const routerDecision = this.jsonObject(input.router_decision ?? null); + const repairContext = this.jsonObject(input.repair_context ?? null); + const sourceClipId = this.stringifyJsonText(repairContext.source_clip_id); + const sourceClip = sourceClipId ? sourceClipMap.get(sourceClipId) ?? null : null; + const renderNormalization = renderNormalizationMap.get( + this.routerAuditRenderNormalizationKey(clip.episode_id, clip.shot_id) + ) ?? null; + + return this.createRouterAuditRow({ + clip, + project, + episode, + shot, + provider, + task, + routerDecision, + repairContext, + sourceClip, + renderNormalization + }); + }); + + return { + summary: this.createRouterAuditSummary(rows), + audits: rows, + total, + limit + }; + } + + async getRouterAuditClipTimeline(user: AuthRequestUser, clipId: string) { + this.assertAdmin(user); + const clip = await this.prisma.videoClip.findUnique({ + where: { id: this.parseId(clipId, 'Invalid video clip id') } + }); + + if (!clip) { + throw new NotFoundException('Video clip not found'); + } + + const [project, episode, shot, relatedClips, tasks] = await Promise.all([ + this.prisma.project.findUnique({ where: { id: clip.project_id } }), + this.prisma.episode.findUnique({ where: { id: clip.episode_id } }), + this.prisma.storyboardShot.findUnique({ where: { id: clip.shot_id } }), + this.prisma.videoClip.findMany({ + where: { shot_id: clip.shot_id }, + orderBy: { created_at: 'asc' }, + take: 80 + }), + this.prisma.renderTask.findMany({ + where: { + project_id: clip.project_id, + episode_id: clip.episode_id, + OR: [ + { + shot_id: clip.shot_id, + task_type: { + in: [ + 'live_action_video_clip_generate', + 'live_action_video_clip_retry', + 'live_action_video_clip_quality_check' + ] + } + }, + { + task_type: 'live_action_video_render' + } + ] + }, + orderBy: { created_at: 'asc' }, + take: 80 + }) + ]); + const clipIds = this.uniqueBigints([clip.id, ...relatedClips.map((item) => item.id)]); + const taskIds = this.uniqueBigints(tasks.map((task) => task.id)); + const providerIds = this.uniqueBigints( + [ + ...relatedClips.map((item) => item.provider_id).filter((id): id is bigint => Boolean(id)), + ...tasks.map((task) => task.provider_id).filter((id): id is bigint => Boolean(id)) + ] + ); + const [providers, rawProviderLogs, operationLogs] = await Promise.all([ + providerIds.length > 0 + ? this.prisma.providerConfig.findMany({ where: { id: { in: providerIds } } }) + : Promise.resolve([]), + this.prisma.providerLog.findMany({ + where: this.createRouterAuditTimelineProviderLogWhere(clip, taskIds), + orderBy: { created_at: 'asc' }, + take: 200 + }), + this.prisma.operationLog.findMany({ + where: this.createRouterAuditTimelineOperationWhere(clip, clipIds, taskIds), + orderBy: { created_at: 'asc' }, + take: 200 + }) + ]); + const providerMap = new Map(providers.map((provider) => [provider.id.toString(), provider])); + const providerLogs = rawProviderLogs.filter((log) => + this.providerLogMatchesRouterAuditTimeline(log, clipIds, taskIds) + ); + const currentTaskMap = this.createRouterAuditTaskMap( + [...tasks].sort((left, right) => right.created_at.getTime() - left.created_at.getTime()) + ); + const currentTask = this.routerAuditTaskForClip(clip, currentTaskMap); + const input = this.jsonObject(currentTask?.input_json ?? null); + const repairContext = this.jsonObject(input.repair_context ?? null); + const renderNormalizationMap = this.createRouterAuditRenderNormalizationMap( + tasks.filter((task) => task.task_type === 'live_action_video_render') + ); + const renderNormalization = renderNormalizationMap.get( + this.routerAuditRenderNormalizationKey(clip.episode_id, clip.shot_id) + ) ?? null; + const sourceClipId = this.toBigIntOrNull(this.stringifyJsonText(repairContext.source_clip_id)); + const sourceClip = sourceClipId + ? relatedClips.find((item) => item.id === sourceClipId) ?? null + : null; + const audit = this.createRouterAuditRow({ + clip, + project, + episode, + shot, + provider: clip.provider_id ? providerMap.get(clip.provider_id.toString()) ?? null : null, + task: currentTask, + routerDecision: this.jsonObject(input.router_decision ?? null), + repairContext, + sourceClip, + renderNormalization + }); + const timeline = this.createRouterAuditTimeline({ + relatedClips, + tasks, + providerLogs, + operationLogs, + providerMap + }); + + return { + audit, + summary: this.createRouterAuditTimelineSummary(clip, timeline, relatedClips, tasks, providerLogs, operationLogs), + timeline, + related_clips: relatedClips.map(toSafeVideoClip), + tasks: tasks.map(toSafeRenderTask), + provider_logs: providerLogs.map(toSafeProviderLog), + operation_logs: operationLogs.map(toSafeOperationLog) + }; + } + + async updateRouterAuditClipQuality( + user: AuthRequestUser, + clipId: string, + dto: AdminUpdateRouterAuditQualityDto + ) { + assertPermission(user, 'reviews:write'); + const status = this.validateChoice( + dto.result_status, + ROUTER_AUDIT_QUALITY_STATUSES, + 'result_status' + ); + const clip = await this.prisma.videoClip.findUnique({ + where: { id: this.parseId(clipId, 'Invalid video clip id') } + }); + + if (!clip) { + throw new NotFoundException('Video clip not found'); + } + + const reason = this.normalizeOptionalText(dto.reason, 500) ?? this.defaultRouterAuditManualReason(status); + const requestedScore = this.optionalNumberFromJson(dto.quality_score); + const previousScore = this.decimalToOptionalNumber(clip.quality_score); + const qualityScore = this.manualQualityScore(status, requestedScore, previousScore); + const existingIssues = Array.isArray(clip.quality_issues) ? clip.quality_issues : []; + const issueTag = status === 'passed' ? 'MANUAL_PASS' : status === 'rejected' ? 'MANUAL_REJECT' : 'MANUAL_UPDATE'; + const updatedIssues = [ + ...existingIssues.map((issue) => this.toJsonSafeValue(issue)), + `${issueTag}: ${reason}` + ]; + const updated = await this.prisma.videoClip.update({ + where: { id: clip.id }, + data: { + quality_status: status, + quality_score: qualityScore, + quality_issues: updatedIssues as Prisma.InputJsonArray + } + }); + + await this.prisma.storyboardShot.update({ + where: { id: clip.shot_id }, + data: { video_status: this.videoStatusForManualQuality(status) } + }).catch(() => undefined); + + const operationLog = await this.prisma.operationLog.create({ + data: { + user_id: this.parseId(user.id, 'Invalid user id'), + operator_role: user.role, + action: 'admin_update_router_audit_quality', + target_type: 'video_clip', + target_id: clip.id, + metadata_json: { + project_id: clip.project_id.toString(), + episode_id: clip.episode_id.toString(), + shot_id: clip.shot_id.toString(), + from_quality_status: clip.quality_status, + to_quality_status: status, + from_quality_score: previousScore, + to_quality_score: qualityScore, + reason + } + } + }); + + return { + video_clip: toSafeVideoClip(updated), + operation_log: toSafeOperationLog(operationLog), + next_step: status === 'passed' ? 'render_episode' : 'operator_review' + }; + } + + async listWorks(user: AuthRequestUser, query: AdminListWorksQueryDto) { + this.assertAdmin(user); + const where: Prisma.AssetWhereInput = { + asset_type: 'video' + }; + const limit = this.normalizePositiveInt(query.limit, 'limit', 1, 100, 50); + + if (query.project_id) { + where.project_id = this.parseId(query.project_id, 'Invalid project_id'); + } + if (query.user_id) { + where.user_id = this.parseId(query.user_id, 'Invalid user_id'); + } + if (query.status) { + where.status = this.normalizeOptionalText(query.status, 50); + } + + const [videos, total] = await Promise.all([ + this.prisma.asset.findMany({ + where, + orderBy: { created_at: 'desc' }, + take: limit + }), + this.prisma.asset.count({ where }) + ]); + const rows = await Promise.all( + videos.map(async (asset) => { + const [project, owner, task] = await Promise.all([ + asset.project_id ? this.prisma.project.findUnique({ where: { id: asset.project_id } }) : null, + asset.user_id ? this.prisma.user.findUnique({ where: { id: asset.user_id } }) : null, + this.prisma.renderTask.findFirst({ + where: { output_asset_id: asset.id }, + orderBy: { created_at: 'desc' } + }) + ]); + const episode = task?.episode_id + ? await this.prisma.episode.findUnique({ where: { id: task.episode_id } }) + : null; + + return { + asset: toSafeAsset(asset), + project: project ? toSafeProject(project) : null, + owner: owner ? toSafeUser(owner) : null, + episode: episode + ? { + id: episode.id.toString(), + episode_no: episode.episode_no, + title: episode.title, + status: episode.status + } + : null, + render_task: task ? toSafeRenderTask(task) : null + }; + }) + ); + + return { works: rows, total, limit }; + } + + async listCopyrightRecords( + user: AuthRequestUser, + query: AdminListCopyrightRecordsQueryDto + ) { + this.assertAdmin(user); + const where: Prisma.CopyrightRecordWhereInput = {}; + const limit = this.normalizePositiveInt(query.limit, 'limit', 1, 100, 50); + + if (query.project_id) { + where.project_id = this.parseId(query.project_id, 'Invalid project_id'); + } + if (query.user_id) { + where.user_id = this.parseId(query.user_id, 'Invalid user_id'); + } + if (query.authorization_type) { + where.authorization_type = this.normalizeOptionalText(query.authorization_type, 50); + } + + const [records, total] = await Promise.all([ + this.prisma.copyrightRecord.findMany({ + where, + orderBy: { confirmed_at: 'desc' }, + take: limit + }), + this.prisma.copyrightRecord.count({ where }) + ]); + + return { + records: records.map(toSafeCopyrightRecord), + total, + limit + }; + } + + async listOperationLogs(user: AuthRequestUser, query: AdminListOperationLogsQueryDto) { + assertPermission(user, 'audit:read'); + const where = this.createOperationLogWhere(query); + const limit = this.normalizePositiveInt(query.limit, 'limit', 1, 500, 100); + const [logs, total] = await Promise.all([ + this.prisma.operationLog.findMany({ + where, + orderBy: { created_at: 'desc' }, + take: limit + }), + this.prisma.operationLog.count({ where }) + ]); + + return { + logs: logs.map(toSafeOperationLog), + total, + limit + }; + } + + async exportOperationLogs(user: AuthRequestUser, query: AdminListOperationLogsQueryDto) { + assertPermission(user, 'audit:export'); + const where = this.createOperationLogWhere(query); + const logs = await this.prisma.operationLog.findMany({ + where, + orderBy: { created_at: 'desc' }, + take: this.normalizePositiveInt(query.limit, 'limit', 1, 5000, 1000) + }); + const safeLogs = logs.map(toSafeOperationLog); + const content = this.createOperationLogCsv(safeLogs); + const operationLog = await this.prisma.operationLog.create({ + data: { + user_id: this.parseId(user.id, 'Invalid user id'), + operator_role: user.role, + action: 'admin_export_operation_logs', + target_type: 'operation_log', + target_id: null, + metadata_json: { + count: safeLogs.length, + filters: this.toJsonSafeObject(query) + } + } + }); + + return { + filename: `operation-logs-${this.localDateStamp(new Date())}.csv`, + content_type: 'text/csv; charset=utf-8', + content, + operation_log: toSafeOperationLog(operationLog) + }; + } + + async listSystemConfigs(user: AuthRequestUser) { + assertPermission(user, 'settings:read'); + await this.ensureDefaultSystemConfigs(); + const configs = await this.prisma.systemConfig.findMany({ + orderBy: [{ config_key: 'asc' }] + }); + + return { + configs: configs.map(toSafeSystemConfig) + }; + } + + async updateSystemConfig( + user: AuthRequestUser, + configKey: string, + dto: AdminUpdateSystemConfigDto + ) { + assertPermission(user, 'settings:write'); + const definition = DEFAULT_SYSTEM_CONFIGS.find((item) => item.config_key === configKey); + + if (!definition) { + throw new BadRequestException('System config key is not editable'); + } + + const configValue = this.normalizeSystemConfigValue(configKey, dto.config_value); + const config = await this.prisma.systemConfig.upsert({ + where: { config_key: configKey }, + update: { + config_value: configValue as Prisma.InputJsonValue, + description: this.normalizeOptionalText(dto.description, 500) ?? definition.description, + is_public: dto.is_public ?? definition.is_public + }, + create: { + config_key: configKey, + config_value: configValue as Prisma.InputJsonValue, + description: this.normalizeOptionalText(dto.description, 500) ?? definition.description, + is_public: dto.is_public ?? definition.is_public + } + }); + + await this.prisma.operationLog.create({ + data: { + user_id: this.parseId(user.id, 'Invalid user id'), + operator_role: user.role, + action: 'admin_update_system_config', + target_type: 'system_config', + target_id: config.id, + metadata_json: { + config_key: configKey, + config_value: configValue + } + } + }); + this.apiCrypto?.clearConfigCache(); + + return { + config: toSafeSystemConfig(config) + }; + } + + private createHitAnalysisCaseData( + user: AuthRequestUser, + dto: AdminCreateHitAnalysisCaseDto + ): Prisma.HitAnalysisCaseUncheckedCreateInput { + return { + title: this.requiredText(dto.title, 255, 'title is required'), + source_platform: this.normalizeNullableText(dto.source_platform, 100), + source_url: this.normalizeNullableText(dto.source_url, 500), + content_type: this.normalizeOptionalText(dto.content_type, 80) ?? 'short_drama', + genre: this.normalizeNullableText(dto.genre, 100), + language: this.normalizeOptionalText(dto.language, 30) ?? 'zh-CN', + target_audience: this.normalizeNullableText(dto.target_audience, 255), + duration_seconds: this.normalizeOptionalInt(dto.duration_seconds, 'duration_seconds', 1, 86400), + episode_count: this.normalizeOptionalInt(dto.episode_count, 'episode_count', 1, 10000), + tags_json: this.normalizeStringList(dto.tags) as Prisma.InputJsonValue, + metrics_json: this.normalizeJsonValue(dto.metrics_json), + transcript_text: this.normalizeNullableText(dto.transcript_text, 200000), + summary_text: this.normalizeNullableText(dto.summary_text, 10000), + status: 'draft', + created_by_user_id: this.parseId(user.id, 'Invalid user id') + }; + } + + private async findHitAnalysisCaseOrThrow(caseId: string) { + const hitCase = await this.prisma.hitAnalysisCase.findUnique({ + where: { id: this.parseId(caseId, 'Invalid hit analysis case id') } + }); + + if (!hitCase) { + throw new NotFoundException('Hit analysis case not found'); + } + + return hitCase; + } + + private async analyzeHitCaseRecord( + user: AuthRequestUser, + hitCase: HitAnalysisCase, + dto: AdminAnalyzeHitCaseDto + ) { + const segmentCount = this.normalizeOptionalInt(dto.segment_count, 'segment_count', 3, 24) ?? 8; + const minSegmentSeconds = this.normalizeOptionalInt(dto.min_segment_seconds, 'min_segment_seconds', 3, 30) ?? 6; + const drafts = this.createHitSegmentDrafts(hitCase, segmentCount, minSegmentSeconds); + const analysis = this.createHitCaseAnalysis(hitCase, drafts); + + await this.prisma.hitAnalysisSegment.deleteMany({ where: { case_id: hitCase.id } }); + const segments = await Promise.all( + drafts.map((draft) => + this.prisma.hitAnalysisSegment.create({ + data: this.createHitSegmentData(hitCase.id, draft) + }) + ) + ); + const updated = await this.prisma.hitAnalysisCase.update({ + where: { id: hitCase.id }, + data: { + analysis_json: analysis as Prisma.InputJsonValue, + diagnosis_score: analysis.scores.total, + status: 'analyzed' + } + }); + const operationLog = await this.prisma.operationLog.create({ + data: { + user_id: this.parseId(user.id, 'Invalid user id'), + operator_role: user.role, + action: 'admin_analyze_hit_case', + target_type: 'hit_analysis_case', + target_id: hitCase.id, + metadata_json: { + title: hitCase.title, + segment_count: segments.length, + diagnosis_score: analysis.scores.total, + top_takeaways: analysis.key_takeaways.slice(0, 3) + } + } + }); + + return { + case: toSafeHitAnalysisCase(updated), + segments: segments.map(toSafeHitAnalysisSegment), + analysis, + operation_log: toSafeOperationLog(operationLog) + }; + } + + private createHitSegmentData( + caseId: bigint, + draft: HitSegmentDraft + ): Prisma.HitAnalysisSegmentUncheckedCreateInput { + return { + case_id: caseId, + segment_no: draft.segment_no, + start_second: draft.start_second, + end_second: draft.end_second, + scene_type: draft.scene_type, + hook_type: draft.hook_type, + emotion: draft.emotion, + conflict_type: draft.conflict_type, + plot_function: draft.plot_function, + visual_strategy: draft.visual_strategy, + dialogue_pattern: draft.dialogue_pattern, + camera_notes: draft.camera_notes, + importance_score: draft.importance_score, + emotion_score: draft.emotion_score, + action_score: draft.action_score, + tags_json: draft.tags as Prisma.InputJsonValue, + summary_text: draft.summary_text, + prompt_seed: draft.prompt_seed + }; + } + + private createCreativePatternData( + user: AuthRequestUser, + hitCase: HitAnalysisCase, + draft: HitPatternDraft + ): Prisma.CreativePatternUncheckedCreateInput { + return { + source_case_id: hitCase.id, + pattern_type: draft.pattern_type, + title: draft.title, + genre: hitCase.genre, + language: hitCase.language, + description: draft.description, + structure_json: draft.structure_json, + prompt_template: draft.prompt_template, + negative_prompt: draft.negative_prompt, + tags_json: draft.tags as Prisma.InputJsonValue, + effectiveness_score: draft.effectiveness_score, + status: 'active', + created_by_user_id: this.parseId(user.id, 'Invalid user id') + }; + } + + private createHitSegmentDrafts( + hitCase: HitAnalysisCase, + requestedCount: number, + minSegmentSeconds: number + ): HitSegmentDraft[] { + const sourceText = this.hitCaseSourceText(hitCase); + const rawChunks = this.splitHitText(sourceText, requestedCount); + const duration = hitCase.duration_seconds ?? Math.max(rawChunks.length * minSegmentSeconds, minSegmentSeconds); + const segmentSeconds = Math.max(minSegmentSeconds, Math.ceil(duration / Math.max(rawChunks.length, 1))); + + return rawChunks.map((chunk, index) => { + const segmentNo = index + 1; + const start = index * segmentSeconds; + const end = Math.min(duration, start + segmentSeconds); + const sceneType = this.inferHitSceneType(chunk); + const hookType = this.inferHitHookType(chunk, segmentNo); + const emotion = this.inferHitEmotion(chunk); + const conflictType = this.inferHitConflictType(chunk); + const visualStrategy = this.inferHitVisualStrategy(chunk, sceneType); + const plotFunction = this.inferHitPlotFunction(segmentNo, rawChunks.length, chunk); + const tags = this.uniqueStrings([ + sceneType, + hookType, + emotion, + conflictType, + visualStrategy, + ...this.keywordTags(chunk) + ]); + + return { + segment_no: segmentNo, + start_second: start, + end_second: Math.max(end, start + minSegmentSeconds), + scene_type: sceneType, + hook_type: hookType, + emotion, + conflict_type: conflictType, + plot_function: plotFunction, + visual_strategy: visualStrategy, + dialogue_pattern: this.inferHitDialoguePattern(chunk), + camera_notes: this.inferHitCameraNotes(chunk, sceneType), + importance_score: this.clampScore(4 + this.countKeywordHits(chunk, HIT_KEYWORDS.hook) + this.countKeywordHits(chunk, HIT_KEYWORDS.conflict), 1, 10), + emotion_score: this.clampScore(3 + this.countKeywordHits(chunk, HIT_KEYWORDS.emotion) * 2, 1, 10), + action_score: this.clampScore(2 + this.countKeywordHits(chunk, HIT_KEYWORDS.action) * 2, 1, 10), + tags, + summary_text: this.compactText(chunk, 500) ?? '', + prompt_seed: this.createHitPromptSeed(chunk, visualStrategy, sceneType) + }; + }); + } + + private createHitCaseAnalysis(hitCase: HitAnalysisCase, segments: HitSegmentDraft[]) { + const text = this.hitCaseSourceText(hitCase); + const hookHits = this.countKeywordHits(text, HIT_KEYWORDS.hook); + const conflictHits = this.countKeywordHits(text, HIT_KEYWORDS.conflict); + const reversalHits = this.countKeywordHits(text, HIT_KEYWORDS.reversal); + const emotionHits = this.countKeywordHits(text, HIT_KEYWORDS.emotion); + const visualHits = this.countKeywordHits(text, HIT_KEYWORDS.visual); + const opening = segments[0]; + const ending = segments.at(-1); + const hookScore = this.clampScore(42 + hookHits * 9 + (opening?.hook_type !== 'setup' ? 12 : 0), 0, 100); + const conflictScore = this.clampScore(38 + conflictHits * 8 + segments.filter((segment) => segment.conflict_type !== 'soft_conflict').length * 3, 0, 100); + const reversalScore = this.clampScore(35 + reversalHits * 11 + segments.filter((segment) => segment.plot_function === 'reversal').length * 8, 0, 100); + const emotionScore = this.clampScore(36 + emotionHits * 9 + Math.max(...segments.map((segment) => segment.emotion_score), 1) * 3, 0, 100); + const visualScore = this.clampScore(34 + visualHits * 8 + segments.filter((segment) => segment.visual_strategy !== 'close_up_dialogue').length * 4, 0, 100); + const productionScore = this.clampScore( + 48 + + (hitCase.duration_seconds && hitCase.duration_seconds <= 180 ? 12 : 0) + + (segments.length >= 5 && segments.length <= 12 ? 14 : 0) + + segments.filter((segment) => segment.scene_type !== 'action').length * 2, + 0, + 100 + ); + const total = Number( + ( + hookScore * 0.2 + + conflictScore * 0.22 + + reversalScore * 0.18 + + emotionScore * 0.16 + + visualScore * 0.12 + + productionScore * 0.12 + ).toFixed(2) + ); + const reusableTags = this.uniqueStrings([ + ...(Array.isArray(hitCase.tags_json) ? hitCase.tags_json.map((tag) => this.stringifyJsonText(tag)) : []), + ...segments.flatMap((segment) => segment.tags) + ]).slice(0, 18); + + return { + version: 'hit_analysis_v1_rule_mock', + generated_by: 'local_rule_analyzer', + summary: hitCase.summary_text ?? this.compactText(text, 260) ?? hitCase.title, + scores: { + hook: hookScore, + conflict: conflictScore, + reversal: reversalScore, + emotion: emotionScore, + visual: visualScore, + production_reuse: productionScore, + total + }, + key_takeaways: [ + `开场钩子:${opening?.hook_type ?? 'setup'},建议前 ${opening?.end_second ?? 8} 秒内完成信息差。`, + `冲突密度:${conflictScore} 分,核心冲突为 ${this.mostCommon(segments.map((segment) => segment.conflict_type))}。`, + `反转能力:${reversalScore} 分,适合拆成 ${Math.max(3, Math.min(segments.length, 12))} 个 5-10 秒镜头。`, + `生产复用:${productionScore} 分,可沉淀为 Story Bible、角色关系和镜头 Prompt 模板。` + ], + reusable_assets: { + story_bible_seeds: [ + hitCase.title, + `题材:${hitCase.genre ?? '短剧'} / 语言:${hitCase.language}`, + `核心卖点:${this.mostCommon(segments.map((segment) => segment.hook_type))} + ${this.mostCommon(segments.map((segment) => segment.conflict_type))}` + ], + character_archetypes: this.inferCharacterArchetypes(text), + prompt_keywords: reusableTags, + route_hints: { + premium_scene_types: this.uniqueStrings( + segments + .filter((segment) => segment.importance_score >= 8 || segment.action_score >= 7) + .map((segment) => segment.scene_type) + ), + cheap_reusable_shots: segments + .filter((segment) => segment.importance_score <= 6 && segment.action_score <= 4) + .map((segment) => segment.segment_no) + } + }, + pattern_candidates: this.createHitPatternDrafts(hitCase, segments as unknown as HitAnalysisSegment[], {}).map((draft) => ({ + pattern_type: draft.pattern_type, + title: draft.title, + effectiveness_score: draft.effectiveness_score + })), + rhythm: { + opening_segment: opening?.summary_text ?? '', + cliffhanger_segment: ending?.summary_text ?? '', + segment_count: segments.length, + recommended_clip_seconds: '5-10' + }, + risk_notes: this.createHitRiskNotes(text) + }; + } + + private createHitPatternDrafts( + hitCase: HitAnalysisCase, + segments: Array, + analysis: Record + ): HitPatternDraft[] { + const first = segments[0]; + const last = segments.at(-1); + const scores = this.jsonObject(analysis.scores ?? null); + const totalScore = this.optionalNumberFromJson(scores.total) ?? this.decimalToOptionalNumber(hitCase.diagnosis_score) ?? 70; + const score = this.clampScore(totalScore, 1, 100); + const genre = hitCase.genre ?? '短剧'; + const hookTitle = this.segmentText(first, 'hook_type') || '强钩子'; + const conflictTitle = this.segmentText(first, 'conflict_type') || '身份冲突'; + const beatSequence = segments.map((segment) => ({ + segment_no: this.valueFromSegment(segment, 'segment_no'), + plot_function: this.valueFromSegment(segment, 'plot_function'), + hook_type: this.valueFromSegment(segment, 'hook_type'), + summary: this.valueFromSegment(segment, 'summary_text') + })); + + return [ + { + pattern_type: 'opening_hook', + title: `${genre}开场钩子:${hookTitle}`, + description: `用“${hookTitle}”在前 5-10 秒建立信息差,并立刻抛出 ${conflictTitle}。`, + structure_json: { + beat_sequence: beatSequence.slice(0, 3), + recommended_duration_seconds: 8, + required_elements: ['身份信息差', '明确威胁', '一句可传播台词'] + }, + prompt_template: `写一个${genre}短剧开场:前8秒出现${hookTitle},主角遭遇${conflictTitle},结尾留一个反转钩子。`, + negative_prompt: '拖慢铺垫、无冲突闲聊、过多背景解释', + tags: this.uniqueStrings([genre, hookTitle, conflictTitle, 'opening']), + effectiveness_score: score + }, + { + pattern_type: 'reversal_loop', + title: `${genre}反转循环:证据递进`, + description: '每 2-3 个镜头给一个小证据,先让观众站队,再用身份或证据反转制造完播动力。', + structure_json: { + beat_sequence: beatSequence, + loop: ['压迫', '证据', '误判', '反击', '更大悬念'] + }, + prompt_template: `把剧情拆成5个短镜头:压迫、证据、误判、反击、更大悬念。每个镜头5-10秒,适合AI视频生成。`, + negative_prompt: '一次性解释全部真相、连续同场景站桩对白', + tags: this.uniqueStrings([genre, 'reversal', 'evidence', conflictTitle]), + effectiveness_score: Math.max(60, score - 3) + }, + { + pattern_type: 'character_archetype', + title: `${genre}角色关系:隐忍主角 vs 压迫者`, + description: '角色关系以压迫和反击为核心,主角先被低估,随后通过证据、身份或资源完成反杀。', + structure_json: { + archetypes: this.inferCharacterArchetypes(this.hitCaseSourceText(hitCase)), + relationship_conflict: conflictTitle + }, + prompt_template: `创建一组${genre}短剧角色:隐忍但有底牌的主角、施压的反派、摇摆的旁观者,并写清外貌、口头禅和关系矛盾。`, + negative_prompt: '角色动机模糊、关系网太散、人物年龄身份前后矛盾', + tags: this.uniqueStrings([genre, 'character', conflictTitle]), + effectiveness_score: Math.max(58, score - 5) + }, + { + pattern_type: 'visual_prompt', + title: `${genre}镜头视觉:${this.segmentText(first, 'visual_strategy') || '强特写'}`, + description: '把爆点镜头转换成可复用视觉 Prompt,优先服务真人短剧和漫剧关键帧。', + structure_json: { + prompt_seeds: segments.slice(0, 8).map((segment) => this.valueFromSegment(segment, 'prompt_seed')), + premium_segments: segments + .filter((segment) => Number(this.valueFromSegment(segment, 'importance_score') ?? 0) >= 8) + .map((segment) => this.valueFromSegment(segment, 'segment_no')) + }, + prompt_template: `竖版短剧镜头,${this.segmentText(first, 'visual_strategy') || '电影感近景'},人物情绪强,画面服务${hookTitle},保留下一镜头悬念。`, + negative_prompt: '低清晰度、表情僵硬、人物不一致、无明确镜头主体', + tags: this.uniqueStrings([genre, 'visual', this.segmentText(first, 'scene_type')]), + effectiveness_score: Math.max(55, score - 6) + }, + { + pattern_type: 'episode_rhythm', + title: `${genre}集节奏:${segments.length}段式`, + description: `按 ${segments.length} 个段落组织单集,每段可拆 1-2 个 5-10 秒镜头,末尾用“${this.valueFromSegment(last, 'plot_function') || 'cliffhanger'}”承接下一集。`, + structure_json: { + segment_count: segments.length, + recommended_clip_seconds: '5-10', + final_beat: this.valueFromSegment(last, 'summary_text') + }, + prompt_template: `把这一集改成${segments.length}段式短剧节奏,每段输出scene_type、importance、emotion、action和视频prompt。`, + negative_prompt: '单镜头过长、高潮提前耗尽、结尾没有下一集动机', + tags: this.uniqueStrings([genre, 'rhythm', 'shot_router']), + effectiveness_score: Math.max(62, score - 2) + } + ]; + } + + private createHitAnalysisSummary( + cases: HitAnalysisCase[], + segments: HitAnalysisSegment[], + patterns: CreativePattern[] + ) { + const scores = cases + .map((item) => this.decimalToOptionalNumber(item.diagnosis_score)) + .filter((score): score is number => typeof score === 'number'); + + return { + case_count: cases.length, + analyzed_count: cases.filter((item) => item.status === 'analyzed').length, + segment_count: segments.length, + pattern_count: patterns.length, + avg_score: scores.length ? Number((scores.reduce((sum, score) => sum + score, 0) / scores.length).toFixed(2)) : null, + high_score_count: scores.filter((score) => score >= 80).length, + top_genres: this.topValues(cases.map((item) => item.genre ?? 'unknown'), 5), + top_pattern_types: this.topValues(patterns.map((item) => item.pattern_type), 5) + }; + } + + private createCreativePatternSummary( + patterns: CreativePattern[], + metricsMap: Map = new Map() + ) { + const scores = patterns + .map((item) => this.decimalToOptionalNumber(item.effectiveness_score)) + .filter((score): score is number => typeof score === 'number'); + const metrics = patterns.map((pattern) => metricsMap.get(pattern.id.toString()) ?? this.emptyCreativePatternMetrics()); + const totalCost = metrics.reduce((sum, item) => sum + item.total_cost_actual, 0); + const totalRevenue = metrics.reduce((sum, item) => sum + item.total_revenue_estimate, 0); + + return { + pattern_count: patterns.length, + active_count: patterns.filter((item) => item.status === 'active').length, + avg_effectiveness_score: scores.length + ? Number((scores.reduce((sum, score) => sum + score, 0) / scores.length).toFixed(2)) + : null, + bound_project_count: metrics.reduce((sum, item) => sum + item.bound_project_count, 0), + total_cost_actual: this.roundMoney(totalCost), + total_revenue_estimate: this.roundMoney(totalRevenue), + roi_estimate: this.roundMoney(totalRevenue - totalCost), + by_type: this.topValues(patterns.map((item) => item.pattern_type), 8), + by_genre: this.topValues(patterns.map((item) => item.genre ?? 'unknown'), 8) + }; + } + + private async findCreativePatternOrThrow(patternId: string) { + const pattern = await this.prisma.creativePattern.findUnique({ + where: { id: this.parseId(patternId, 'Invalid creative pattern id') } + }); + + if (!pattern) { + throw new NotFoundException('Creative pattern not found'); + } + + return pattern; + } + + private createCreativePatternUpdateData(dto: AdminUpdateCreativePatternDto) { + const data: Prisma.CreativePatternUncheckedUpdateInput = {}; + + if ('pattern_type' in dto) { + data.pattern_type = this.validateChoice(dto.pattern_type, CREATIVE_PATTERN_TYPES, 'pattern_type'); + } + if ('title' in dto) { + data.title = this.requiredText(dto.title, 255, 'title is required'); + } + if ('genre' in dto) data.genre = this.normalizeNullableText(dto.genre ?? undefined, 100); + if ('language' in dto) data.language = this.normalizeOptionalText(dto.language, 30) ?? 'zh-CN'; + if ('description' in dto) data.description = this.normalizeNullableText(dto.description ?? undefined, 5000); + if ('structure_json' in dto) data.structure_json = this.normalizeJsonValue(dto.structure_json); + if ('prompt_template' in dto) data.prompt_template = this.normalizeNullableText(dto.prompt_template ?? undefined, 10000); + if ('negative_prompt' in dto) data.negative_prompt = this.normalizeNullableText(dto.negative_prompt ?? undefined, 5000); + if ('tags' in dto) data.tags_json = this.normalizeCreativePatternTags(dto.tags); + if ('effectiveness_score' in dto) data.effectiveness_score = this.normalizeOptionalScore(dto.effectiveness_score, 'effectiveness_score'); + if ('status' in dto) data.status = this.validateChoice(dto.status, CREATIVE_PATTERN_STATUSES, 'status'); + + return data; + } + + private normalizeCreativePatternTags(value: string[] | string | null | undefined) { + if (value === null) return Prisma.JsonNull; + if (value === undefined) return Prisma.JsonNull; + const tags = Array.isArray(value) + ? value.map((item) => this.normalizeOptionalText(item, 80)).filter((item): item is string => Boolean(item)) + : value + .split(/[,\n,]/) + .map((item) => this.normalizeOptionalText(item, 80)) + .filter((item): item is string => Boolean(item)); + + return this.uniqueStrings(tags).slice(0, 20) as Prisma.InputJsonValue; + } + + private normalizeOptionalScore(value: number | string | null | undefined, field: string) { + if (value === null || value === undefined || value === '') { + return null; + } + + const numberValue = Number(value); + + if (!Number.isFinite(numberValue) || numberValue < 0 || numberValue > 100) { + throw new BadRequestException(`${field} must be a number between 0 and 100`); + } + + return Number(numberValue.toFixed(2)); + } + + private async createCreativePatternMetricsMap(patterns: CreativePattern[]) { + const map = new Map(); + + for (const pattern of patterns) { + map.set(pattern.id.toString(), this.emptyCreativePatternMetrics()); + } + + if (patterns.length === 0) { + return map; + } + + const patternIds = patterns.map((pattern) => pattern.id); + const bindings = await this.prisma.projectCreativePattern.findMany({ + where: { creative_pattern_id: { in: patternIds } } + }); + const projectIds = this.uniqueBigints(bindings.map((binding) => binding.project_id)); + const projectMetrics = await this.createProjectMetricSnapshotMap(projectIds); + + for (const pattern of patterns) { + const projectIdStrings = this.uniqueStrings( + bindings + .filter((binding) => binding.creative_pattern_id === pattern.id) + .map((binding) => binding.project_id.toString()) + ); + const snapshots = projectIdStrings + .map((projectId) => projectMetrics.get(projectId) ?? null) + .filter((item): item is ProjectMetricSnapshot => Boolean(item)); + map.set(pattern.id.toString(), this.aggregateCreativePatternMetrics(snapshots)); + } + + return map; + } + + private async createCreativePatternProjectMetricRows(projectIds: string[]) { + const ids = this.uniqueStrings(projectIds) + .map((projectId) => this.toBigIntOrNull(projectId)) + .filter((projectId): projectId is bigint => Boolean(projectId)); + const snapshots = await this.createProjectMetricSnapshotMap(ids); + + return [...snapshots.values()] + .filter((snapshot) => snapshot.project) + .sort((left, right) => right.roi_estimate - left.roi_estimate) + .map((snapshot): CreativePatternProjectMetricRow => ({ + project: toSafeProject(snapshot.project as Project), + cost_actual: snapshot.cost_actual, + revenue_estimate: snapshot.revenue_estimate, + roi_estimate: snapshot.roi_estimate, + video_asset_count: snapshot.video_asset_count, + analytics_event_count: snapshot.analytics_event_count, + avg_quality_score: snapshot.avg_quality_score, + avg_completion_rate: snapshot.avg_completion_rate, + play_count: snapshot.play_count, + like_count: snapshot.like_count + })); + } + + private async createProjectMetricSnapshotMap(projectIds: bigint[]) { + const map = new Map(); + + if (projectIds.length === 0) { + return map; + } + + const ids = this.uniqueBigints(projectIds); + const [ + projects, + providerLogs, + renderTasks, + videoClips, + videoAssets, + analyticsEvents, + paidOrders + ] = await Promise.all([ + this.prisma.project.findMany({ where: { id: { in: ids } } }), + this.prisma.providerLog.findMany({ where: { project_id: { in: ids }, status: 'success' } }), + this.prisma.renderTask.findMany({ where: { project_id: { in: ids } } }), + this.prisma.videoClip.findMany({ where: { project_id: { in: ids } } }), + this.prisma.asset.findMany({ where: { project_id: { in: ids }, asset_type: 'video' } }), + this.prisma.analyticsEvent.findMany({ where: { project_id: { in: ids } } }), + this.prisma.order.findMany({ where: { project_id: { in: ids }, payment_status: 'paid' } }) + ]); + const projectMap = new Map(projects.map((project) => [project.id.toString(), project])); + + for (const id of ids) { + const key = id.toString(); + const projectProviderLogs = providerLogs.filter((item) => item.project_id?.toString() === key); + const projectTasks = renderTasks.filter((item) => item.project_id.toString() === key); + const projectClips = videoClips.filter((item) => item.project_id.toString() === key); + const projectAssets = videoAssets.filter((item) => item.project_id?.toString() === key && item.status !== 'deleted'); + const projectEvents = analyticsEvents.filter((item) => item.project_id.toString() === key); + const projectOrders = paidOrders.filter((item) => item.project_id?.toString() === key); + const providerCost = projectProviderLogs.reduce((sum, item) => sum + this.decimalToNumber(item.cost_actual), 0); + const taskCost = projectTasks.reduce((sum, item) => sum + this.decimalToNumber(item.cost_actual), 0); + const clipCost = projectClips.reduce((sum, item) => sum + this.decimalToNumber(item.cost_actual), 0); + const cost = providerCost > 0 ? providerCost : taskCost > 0 ? taskCost : clipCost; + const analyticsRevenue = projectEvents.reduce( + (sum, item) => sum + this.metricNumber(item.metric_json, ['revenue', 'income', 'amount', 'gmv']), + 0 + ); + const orderRevenue = projectOrders.reduce((sum, item) => sum + this.decimalToNumber(item.amount), 0); + const completionRates = projectEvents + .map((item) => this.metricRate(item.metric_json, ['completion_rate', 'complete_rate', 'finish_rate'])) + .filter((item): item is number => item !== null); + const qualityScores = projectClips + .map((item) => this.decimalToOptionalNumber(item.quality_score)) + .filter((item): item is number => item !== null); + const revenue = analyticsRevenue + orderRevenue; + + map.set(key, { + project_id: key, + project: projectMap.get(key) ?? null, + cost_actual: this.roundMoney(cost), + revenue_estimate: this.roundMoney(revenue), + roi_estimate: this.roundMoney(revenue - cost), + video_asset_count: projectAssets.length, + provider_log_count: projectProviderLogs.length, + task_count: projectTasks.length, + analytics_event_count: projectEvents.length, + avg_quality_score: this.averageOrNull(qualityScores), + avg_completion_rate: this.averageOrNull(completionRates), + play_count: projectEvents.reduce( + (sum, item) => sum + this.metricNumber(item.metric_json, ['play_count', 'plays', 'views', 'view_count']), + 0 + ), + like_count: projectEvents.reduce( + (sum, item) => sum + this.metricNumber(item.metric_json, ['like_count', 'likes']), + 0 + ) + }); + } + + return map; + } + + private aggregateCreativePatternMetrics(snapshots: ProjectMetricSnapshot[]) { + const totalCost = snapshots.reduce((sum, item) => sum + item.cost_actual, 0); + const totalRevenue = snapshots.reduce((sum, item) => sum + item.revenue_estimate, 0); + const qualityScores = snapshots + .map((item) => item.avg_quality_score) + .filter((item): item is number => item !== null); + const completionRates = snapshots + .map((item) => item.avg_completion_rate) + .filter((item): item is number => item !== null); + + return { + bound_project_count: snapshots.length, + completed_project_count: snapshots.filter((item) => + ['completed', 'video_rendered', 'live_action_video_rendered'].includes(item.project?.status ?? '') + ).length, + active_project_count: snapshots.filter((item) => + item.project ? !['cancelled', 'failed', 'archived'].includes(item.project.status) : false + ).length, + video_asset_count: snapshots.reduce((sum, item) => sum + item.video_asset_count, 0), + provider_log_count: snapshots.reduce((sum, item) => sum + item.provider_log_count, 0), + task_count: snapshots.reduce((sum, item) => sum + item.task_count, 0), + analytics_event_count: snapshots.reduce((sum, item) => sum + item.analytics_event_count, 0), + total_cost_actual: this.roundMoney(totalCost), + total_revenue_estimate: this.roundMoney(totalRevenue), + roi_estimate: this.roundMoney(totalRevenue - totalCost), + avg_quality_score: this.averageOrNull(qualityScores), + avg_completion_rate: this.averageOrNull(completionRates), + total_play_count: snapshots.reduce((sum, item) => sum + item.play_count, 0), + total_like_count: snapshots.reduce((sum, item) => sum + item.like_count, 0), + project_ids: snapshots.map((item) => item.project_id) + }; + } + + private emptyCreativePatternMetrics(): CreativePatternMetrics { + return { + bound_project_count: 0, + completed_project_count: 0, + active_project_count: 0, + video_asset_count: 0, + provider_log_count: 0, + task_count: 0, + analytics_event_count: 0, + total_cost_actual: 0, + total_revenue_estimate: 0, + roi_estimate: 0, + avg_quality_score: null, + avg_completion_rate: null, + total_play_count: 0, + total_like_count: 0, + project_ids: [] + }; + } + + private calculateCreativePatternEffectiveness(metrics: CreativePatternMetrics) { + const qualitySignal = metrics.avg_quality_score === null ? 0 : (metrics.avg_quality_score - 80) * 0.35; + const completionSignal = metrics.avg_completion_rate === null ? 0 : (metrics.avg_completion_rate - 0.5) * 30; + const roiSignal = metrics.total_cost_actual <= 0 + ? 0 + : this.clampScore((metrics.roi_estimate / Math.max(metrics.total_cost_actual, 1)) * 12, -12, 12); + const usageSignal = Math.min(metrics.bound_project_count * 2, 12) + Math.min(metrics.completed_project_count * 4, 16); + const engagementSignal = Math.min(metrics.total_play_count / 10000, 8) + Math.min(metrics.total_like_count / 1000, 6); + + return this.clampScore( + Number((55 + usageSignal + qualitySignal + completionSignal + roiSignal + engagementSignal).toFixed(2)), + 0, + 100 + ); + } + + private metricNumber(value: unknown, keys: string[]) { + const object = this.jsonObject(value as Prisma.InputJsonValue | Prisma.JsonValue | null); + + for (const key of keys) { + const raw = object[key]; + const numberValue = Number(raw); + + if (Number.isFinite(numberValue)) { + return numberValue; + } + } + + return 0; + } + + private metricRate(value: unknown, keys: string[]) { + const numberValue = this.metricNumber(value, keys); + + if (!numberValue) { + return null; + } + + if (numberValue > 1 && numberValue <= 100) { + return Number((numberValue / 100).toFixed(4)); + } + + return Number(Math.min(numberValue, 1).toFixed(4)); + } + + private averageOrNull(values: number[]) { + if (values.length === 0) { + return null; + } + + return Number((values.reduce((sum, value) => sum + value, 0) / values.length).toFixed(2)); + } + + private hitCaseSourceText(hitCase: HitAnalysisCase) { + return [hitCase.transcript_text, hitCase.summary_text, hitCase.title] + .map((value) => value?.trim()) + .filter((value): value is string => Boolean(value)) + .join('\n'); + } + + private splitHitText(text: string, requestedCount: number) { + const lineChunks = text + .split(/\r?\n+/) + .map((item) => item.replace(/^\s*[\d::.\-\[\]]+\s*/, '').trim()) + .filter(Boolean); + const chunks = lineChunks.length >= 3 + ? lineChunks + : text + .split(/(?<=[。!?!?;;])/) + .map((item) => item.trim()) + .filter(Boolean); + const source = chunks.length ? chunks : [text]; + + if (source.length <= requestedCount) { + return source.map((item) => this.compactText(item, 700) ?? item); + } + + const bucketSize = Math.ceil(source.length / requestedCount); + const output: string[] = []; + + for (let index = 0; index < source.length; index += bucketSize) { + output.push(source.slice(index, index + bucketSize).join(' ')); + } + + return output.slice(0, requestedCount).map((item) => this.compactText(item, 700) ?? item); + } + + private inferHitSceneType(text: string) { + if (this.hasKeyword(text, HIT_KEYWORDS.action)) return 'action'; + if (this.hasKeyword(text, HIT_KEYWORDS.emotion)) return 'emotion'; + if (this.hasKeyword(text, ['门口', '转身', '离开', '车上', '走廊'])) return 'transition'; + + return 'dialog'; + } + + private inferHitHookType(text: string, segmentNo: number) { + if (this.hasKeyword(text, ['重生', '十年后'])) return 'rebirth'; + if (this.hasKeyword(text, ['退婚', '离婚', '抢婚'])) return 'relationship_break'; + if (this.hasKeyword(text, ['身份曝光', '真相', '秘密'])) return 'identity_gap'; + if (this.hasKeyword(text, ['证据', '录音', '直播', '曝光'])) return 'evidence_reveal'; + + return segmentNo === 1 ? 'setup' : 'escalation'; + } + + private inferHitEmotion(text: string) { + if (this.hasKeyword(text, ['哭', '心碎', '绝望'])) return 'grief'; + if (this.hasKeyword(text, ['愤怒', '冷笑', '反击'])) return 'anger'; + if (this.hasKeyword(text, ['表白', '拥抱', '后悔'])) return 'love_regret'; + if (this.hasKeyword(text, ['隐忍', '克制'])) return 'restraint'; + + return 'tension'; + } + + private inferHitConflictType(text: string) { + if (this.hasKeyword(text, ['退婚', '离婚', '抢婚', '表白'])) return 'romance_conflict'; + if (this.hasKeyword(text, ['夺权', '会议室', '股权', '公司'])) return 'power_conflict'; + if (this.hasKeyword(text, ['证据', '录音', '直播', '曝光'])) return 'evidence_conflict'; + if (this.hasKeyword(text, ['陷害', '背叛', '误会'])) return 'betrayal_conflict'; + + return 'soft_conflict'; + } + + private inferHitVisualStrategy(text: string, sceneType: string) { + if (this.hasKeyword(text, ['雨夜', '天台'])) return 'rain_night_cinematic'; + if (this.hasKeyword(text, ['婚礼', '退婚', '抢婚'])) return 'ceremony_high_contrast'; + if (this.hasKeyword(text, ['医院', '病房'])) return 'hospital_cold_light'; + if (this.hasKeyword(text, ['会议室', '股权', '公司'])) return 'boardroom_power_frame'; + if (sceneType === 'emotion') return 'face_close_up'; + if (sceneType === 'action') return 'handheld_action'; + + return 'close_up_dialogue'; + } + + private inferHitPlotFunction(segmentNo: number, totalSegments: number, text: string) { + if (segmentNo === 1) return 'opening_hook'; + if (segmentNo === totalSegments) return 'cliffhanger'; + if (this.hasKeyword(text, HIT_KEYWORDS.reversal)) return 'reversal'; + if (this.hasKeyword(text, HIT_KEYWORDS.conflict)) return 'conflict_escalation'; + + return 'setup_payoff'; + } + + private inferHitDialoguePattern(text: string) { + if (/[“”"']/.test(text)) return 'quoted_dialogue'; + if (this.hasKeyword(text, ['你', '我', '他', '她'])) return 'direct_confrontation'; + if (this.hasKeyword(text, ['旁白', '心想', '终于'])) return 'narration_inner_voice'; + + return 'short_line'; + } + + private inferHitCameraNotes(text: string, sceneType: string) { + if (sceneType === 'emotion') return '中近景切表情特写,保留眼神和停顿。'; + if (sceneType === 'action') return '竖版跟拍,动作清楚,避免大场面复杂调度。'; + if (this.hasKeyword(text, ['证据', '录音', '手机'])) return '道具特写后切人物反应。'; + + return '双人对峙构图,关键台词前推镜。'; + } + + private createHitPromptSeed(text: string, visualStrategy: string, sceneType: string) { + return `竖版短剧镜头,${visualStrategy},${sceneType},${this.compactText(text, 180) ?? text}`; + } + + private inferCharacterArchetypes(text: string) { + const archetypes = ['隐忍主角', '施压反派']; + + if (this.hasKeyword(text, ['豪门', '总裁', '股权', '公司'])) archetypes.push('资源型上位者'); + if (this.hasKeyword(text, ['闺蜜', '妹妹', '替身'])) archetypes.push('亲密背叛者'); + if (this.hasKeyword(text, ['医生', '律师', '助理', '司机'])) archetypes.push('功能型证人'); + + return this.uniqueStrings(archetypes); + } + + private createHitRiskNotes(text: string) { + const notes: string[] = []; + + if (this.hasKeyword(text, ['未成年', '校园霸凌'])) notes.push('涉及未成年或校园情节时,需要额外内容审核。'); + if (this.hasKeyword(text, ['车祸', '自杀', '暴力'])) notes.push('高风险情节应弱化血腥和可模仿动作。'); + if (this.hasKeyword(text, ['真实明星', '品牌名'])) notes.push('疑似真实人物或品牌时,需要版权/肖像权复核。'); + + return notes.length ? notes : ['V1 未发现明显高风险关键词,仍需上线前人工抽检。']; + } + + private keywordTags(text: string) { + return Object.entries(HIT_KEYWORDS) + .filter(([, keywords]) => this.hasKeyword(text, keywords)) + .map(([tag]) => tag); + } + + private countKeywordHits(text: string, keywords: readonly string[]) { + return keywords.reduce((sum, keyword) => { + const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const matches = text.match(new RegExp(escaped, 'g')); + + return sum + (matches?.length ?? 0); + }, 0); + } + + private hasKeyword(text: string, keywords: readonly string[]) { + return keywords.some((keyword) => text.includes(keyword)); + } + + private clampScore(value: number, min: number, max: number) { + return Math.max(min, Math.min(max, Math.round(value))); + } + + private valueFromSegment(segment: HitAnalysisSegment | HitSegmentDraft | undefined, key: string) { + if (!segment) return null; + + return (segment as unknown as Record)[key] ?? null; + } + + private segmentText(segment: HitAnalysisSegment | HitSegmentDraft | undefined, key: string) { + const value = this.valueFromSegment(segment, key); + + return typeof value === 'string' ? value : ''; + } + + private normalizePatternTypes(value: string[] | string | undefined) { + return this.normalizeStringList(value) + .filter((item) => (CREATIVE_PATTERN_TYPES as readonly string[]).includes(item)) as Array< + (typeof CREATIVE_PATTERN_TYPES)[number] + >; + } + + private normalizeStringList(value: unknown) { + if (Array.isArray(value)) { + return this.uniqueStrings(value.map((item) => String(item))); + } + if (typeof value === 'string') { + return this.uniqueStrings(value.split(/[,\n,]/).map((item) => item.trim())); + } + + return []; + } + + private normalizeOptionalInt( + value: unknown, + field: string, + min: number, + max: number + ) { + if (value === undefined || value === null || value === '') { + return null; + } + + const numberValue = Number(value); + + if (!Number.isInteger(numberValue) || numberValue < min || numberValue > max) { + throw new BadRequestException(`${field} must be an integer between ${min} and ${max}`); + } + + return numberValue; + } + + private groupByBigint>(items: T[], key: keyof T) { + const map = new Map(); + + for (const item of items) { + const value = item[key]; + const id = typeof value === 'bigint' ? value.toString() : ''; + + if (!id) continue; + map.set(id, [...(map.get(id) ?? []), item]); + } + + return map; + } + + private countByBigint>(items: T[], key: keyof T) { + const map = new Map(); + + for (const item of items) { + const value = item[key]; + const id = typeof value === 'bigint' ? value.toString() : ''; + + if (!id) continue; + map.set(id, (map.get(id) ?? 0) + 1); + } + + return map; + } + + private topValues(values: string[], limit: number) { + const counts = new Map(); + + for (const value of values.filter(Boolean)) { + counts.set(value, (counts.get(value) ?? 0) + 1); + } + + return [...counts.entries()] + .sort((left, right) => right[1] - left[1]) + .slice(0, limit) + .map(([key, count]) => ({ key, count })); + } + + private mostCommon(values: string[]) { + return this.topValues(values.filter(Boolean), 1)[0]?.key ?? '未识别'; + } + + private createGlobalCharacterCreateData( + user: AuthRequestUser, + dto: AdminSaveGlobalCharacterDto + ): Prisma.GlobalCharacterUncheckedCreateInput { + return { + name: this.requiredText(dto.name, 100, 'name is required'), + display_name: this.normalizeNullableText(dto.display_name, 100), + role_archetype: this.normalizeOptionalText(dto.role_archetype, 50) ?? 'lead', + gender_label: this.normalizeNullableText(dto.gender_label, 50), + age_group: this.normalizeNullableText(dto.age_group, 50), + identity_desc: this.normalizeNullableText(dto.identity_desc, 5000), + appearance_desc: this.normalizeNullableText(dto.appearance_desc, 5000), + face_desc: this.normalizeNullableText(dto.face_desc, 5000), + hair_desc: this.normalizeNullableText(dto.hair_desc, 5000), + eye_desc: this.normalizeNullableText(dto.eye_desc, 5000), + body_desc: this.normalizeNullableText(dto.body_desc, 5000), + default_costume_rules: this.normalizeNullableText(dto.default_costume_rules, 5000), + wardrobe_json: this.normalizeJsonValue(dto.wardrobe_json), + special_props: this.normalizeNullableText(dto.special_props, 5000), + personality_desc: this.normalizeNullableText(dto.personality_desc, 5000), + speech_style: this.normalizeNullableText(dto.speech_style, 5000), + voice_provider_code: this.normalizeNullableText(dto.voice_provider_code, 100), + voice_model: this.normalizeNullableText(dto.voice_model, 100), + voice_id: this.normalizeNullableText(dto.voice_id, 100), + voice_style: this.normalizeNullableText(dto.voice_style, 5000), + performance_style: this.normalizeNullableText(dto.performance_style, 5000), + negative_rules: this.normalizeNullableText(dto.negative_rules, 5000), + anchor_asset_id: this.parseOptionalId(dto.anchor_asset_id, 'Invalid anchor_asset_id'), + voice_sample_asset_id: this.parseOptionalId(dto.voice_sample_asset_id, 'Invalid voice_sample_asset_id'), + commercial_status: this.validateChoice( + dto.commercial_status, + GLOBAL_CHARACTER_COMMERCIAL_STATUSES, + 'commercial_status', + 'internal_test' + ), + usage_scope: this.validateChoice(dto.usage_scope, GLOBAL_CHARACTER_USAGE_SCOPES, 'usage_scope', 'internal'), + status: this.validateChoice(dto.status, GLOBAL_CHARACTER_STATUSES, 'status', 'active'), + created_by_user_id: this.parseId(user.id, 'Invalid user id') + }; + } + + private createGlobalCharacterUpdateData( + dto: AdminSaveGlobalCharacterDto + ): Prisma.GlobalCharacterUncheckedUpdateInput { + const data: Prisma.GlobalCharacterUncheckedUpdateInput = {}; + + if ('name' in dto) data.name = this.requiredText(dto.name, 100, 'name is required'); + if ('display_name' in dto) data.display_name = this.normalizeNullableText(dto.display_name, 100); + if ('role_archetype' in dto) data.role_archetype = this.normalizeOptionalText(dto.role_archetype, 50) ?? 'lead'; + if ('gender_label' in dto) data.gender_label = this.normalizeNullableText(dto.gender_label, 50); + if ('age_group' in dto) data.age_group = this.normalizeNullableText(dto.age_group, 50); + if ('identity_desc' in dto) data.identity_desc = this.normalizeNullableText(dto.identity_desc, 5000); + if ('appearance_desc' in dto) data.appearance_desc = this.normalizeNullableText(dto.appearance_desc, 5000); + if ('face_desc' in dto) data.face_desc = this.normalizeNullableText(dto.face_desc, 5000); + if ('hair_desc' in dto) data.hair_desc = this.normalizeNullableText(dto.hair_desc, 5000); + if ('eye_desc' in dto) data.eye_desc = this.normalizeNullableText(dto.eye_desc, 5000); + if ('body_desc' in dto) data.body_desc = this.normalizeNullableText(dto.body_desc, 5000); + if ('default_costume_rules' in dto) data.default_costume_rules = this.normalizeNullableText(dto.default_costume_rules, 5000); + if ('wardrobe_json' in dto) data.wardrobe_json = this.normalizeJsonValue(dto.wardrobe_json); + if ('special_props' in dto) data.special_props = this.normalizeNullableText(dto.special_props, 5000); + if ('personality_desc' in dto) data.personality_desc = this.normalizeNullableText(dto.personality_desc, 5000); + if ('speech_style' in dto) data.speech_style = this.normalizeNullableText(dto.speech_style, 5000); + if ('voice_provider_code' in dto) data.voice_provider_code = this.normalizeNullableText(dto.voice_provider_code, 100); + if ('voice_model' in dto) data.voice_model = this.normalizeNullableText(dto.voice_model, 100); + if ('voice_id' in dto) data.voice_id = this.normalizeNullableText(dto.voice_id, 100); + if ('voice_style' in dto) data.voice_style = this.normalizeNullableText(dto.voice_style, 5000); + if ('performance_style' in dto) data.performance_style = this.normalizeNullableText(dto.performance_style, 5000); + if ('negative_rules' in dto) data.negative_rules = this.normalizeNullableText(dto.negative_rules, 5000); + if ('anchor_asset_id' in dto) data.anchor_asset_id = this.parseOptionalId(dto.anchor_asset_id, 'Invalid anchor_asset_id'); + if ('voice_sample_asset_id' in dto) data.voice_sample_asset_id = this.parseOptionalId(dto.voice_sample_asset_id, 'Invalid voice_sample_asset_id'); + if ('commercial_status' in dto) { + data.commercial_status = this.validateChoice( + dto.commercial_status, + GLOBAL_CHARACTER_COMMERCIAL_STATUSES, + 'commercial_status' + ); + } + if ('usage_scope' in dto) { + data.usage_scope = this.validateChoice(dto.usage_scope, GLOBAL_CHARACTER_USAGE_SCOPES, 'usage_scope'); + } + if ('status' in dto) { + data.status = this.validateChoice(dto.status, GLOBAL_CHARACTER_STATUSES, 'status'); + } + + return data; + } + + private createCharacterBindData( + character: { + anchor_asset_id: bigint | null; + costume_rules: string | null; + voice_provider_code: string | null; + voice_model: string | null; + voice_id: string | null; + voice_style: string | null; + performance_style: string | null; + }, + globalCharacter: { + id: bigint; + anchor_asset_id: bigint | null; + default_costume_rules: string | null; + voice_provider_code: string | null; + voice_model: string | null; + voice_id: string | null; + voice_style: string | null; + performance_style: string | null; + } | null + ): Prisma.CharacterUncheckedUpdateInput { + const data: Prisma.CharacterUncheckedUpdateInput = { + global_character_id: globalCharacter?.id ?? null + }; + + if (!globalCharacter) { + return data; + } + if (!character.anchor_asset_id && globalCharacter.anchor_asset_id) { + data.anchor_asset_id = globalCharacter.anchor_asset_id; + } + if (!character.costume_rules && globalCharacter.default_costume_rules) { + data.costume_rules = globalCharacter.default_costume_rules; + } + if (!character.voice_provider_code && globalCharacter.voice_provider_code) { + data.voice_provider_code = globalCharacter.voice_provider_code; + } + if (!character.voice_model && globalCharacter.voice_model) { + data.voice_model = globalCharacter.voice_model; + } + if (!character.voice_id && globalCharacter.voice_id) { + data.voice_id = globalCharacter.voice_id; + } + if (!character.voice_style && globalCharacter.voice_style) { + data.voice_style = globalCharacter.voice_style; + } + if (!character.performance_style && globalCharacter.performance_style) { + data.performance_style = globalCharacter.performance_style; + } + + return data; + } + + private createRouterAuditTimelineProviderLogWhere( + clip: VideoClip, + taskIds: bigint[] + ): Prisma.ProviderLogWhereInput { + const or: Prisma.ProviderLogWhereInput[] = [ + { + project_id: clip.project_id, + provider_type: 'QualityCheckProvider' + } + ]; + + if (taskIds.length > 0) { + or.unshift({ task_id: { in: taskIds } }); + } + + return { OR: or }; + } + + private createRouterAuditTimelineOperationWhere( + clip: VideoClip, + clipIds: bigint[], + taskIds: bigint[] + ): Prisma.OperationLogWhereInput { + const or: Prisma.OperationLogWhereInput[] = [ + { target_type: 'video_clip', target_id: { in: clipIds } }, + { target_type: 'storyboard_shot', target_id: clip.shot_id } + ]; + + if (taskIds.length > 0) { + or.push({ target_type: 'render_task', target_id: { in: taskIds } }); + } + + return { OR: or }; + } + + private providerLogMatchesRouterAuditTimeline( + log: ProviderLog, + clipIds: bigint[], + taskIds: bigint[] + ) { + if (log.task_id && taskIds.some((taskId) => taskId === log.task_id)) { + return true; + } + + if (log.provider_type !== 'QualityCheckProvider') { + return false; + } + + return Boolean(this.routerAuditTimelineClipIdForProviderLog(log, clipIds)); + } + + private createRouterAuditTimeline(input: { + relatedClips: VideoClip[]; + tasks: RenderTask[]; + providerLogs: ProviderLog[]; + operationLogs: OperationLog[]; + providerMap: Map; + }) { + const items: RouterAuditTimelineItem[] = []; + const relatedShotIds = new Set(input.relatedClips.map((clip) => clip.shot_id.toString())); + + for (const task of input.tasks) { + const inputJson = this.jsonObject(task.input_json ?? null); + const routerDecision = this.jsonObject(inputJson.router_decision ?? null); + const repairContext = this.jsonObject(inputJson.repair_context ?? null); + const providerCode = this.providerCodeFromRouterTask(task, input.providerMap); + const normalizations = task.task_type === 'live_action_video_render' + ? this.routerAuditRenderNormalizationsForTask(task) + .filter((normalization) => relatedShotIds.has(normalization.shot_id)) + : []; + + if (Object.keys(routerDecision).length > 0) { + items.push(this.createRouterAuditTimelineItem({ + id: `task-${task.id.toString()}-route`, + kind: 'route_decision', + title: 'Router 自动选模型', + status: this.stringifyJsonText(routerDecision.route_tier) || null, + at: task.created_at, + provider_code: providerCode, + task_id: task.id.toString(), + clip_id: null, + cost_actual: this.decimalToOptionalNumber(task.cost_actual), + details: { + provider_code: this.stringifyJsonText(routerDecision.provider_code) || providerCode || '', + decision_reason: this.stringifyJsonText(routerDecision.decision_reason), + fallback_chain: this.stringArray(routerDecision.fallback_chain), + estimated_cost: this.optionalNumberFromJson(routerDecision.estimated_cost), + manual_override: Boolean(routerDecision.manual_override) + } + })); + } + + if (Object.keys(repairContext).length > 0) { + items.push(this.createRouterAuditTimelineItem({ + id: `task-${task.id.toString()}-repair`, + kind: 'repair', + title: '质检修复策略', + status: this.stringifyJsonText(repairContext.action) || null, + at: task.created_at, + provider_code: providerCode, + task_id: task.id.toString(), + clip_id: this.stringifyJsonText(repairContext.source_clip_id) || null, + cost_actual: this.decimalToOptionalNumber(task.cost_actual), + details: { + action: this.stringifyJsonText(repairContext.action), + source_clip_id: this.stringifyJsonText(repairContext.source_clip_id), + provider_code: this.stringifyJsonText(repairContext.provider_code), + previous_quality_status: this.stringifyJsonText(repairContext.previous_quality_status), + previous_quality_score: this.optionalNumberFromJson(repairContext.previous_quality_score), + min_quality_score: this.optionalNumberFromJson(repairContext.min_quality_score), + fallback_chain: this.stringArray(repairContext.fallback_chain) + } + })); + } + + for (const normalization of normalizations) { + items.push(this.createRouterAuditTimelineItem({ + id: `task-${task.id.toString()}-normalization-${normalization.shot_id}`, + kind: 'clip_normalization', + title: normalization.trimmed ? '合成片段自动裁切' : '合成片段标准化', + status: normalization.trimmed ? 'trimmed' : 'normalized', + at: task.finished_at ?? task.created_at, + provider_code: providerCode, + task_id: task.id.toString(), + clip_id: null, + cost_actual: this.decimalToOptionalNumber(task.cost_actual), + details: { + output_asset_id: normalization.output_asset_id ?? '', + shot_id: normalization.shot_id, + shot_no: normalization.shot_no, + target_duration: normalization.target_duration, + source_duration: normalization.source_duration, + final_duration: normalization.final_duration, + trimmed: normalization.trimmed, + trim_strategy: normalization.trim_strategy, + trim_start: normalization.trim_start, + trim_tolerance: normalization.trim_tolerance + } + })); + } + + items.push(this.createRouterAuditTimelineItem({ + id: `task-${task.id.toString()}-created`, + kind: 'render_task', + title: '创建视频生成任务', + status: task.status, + at: task.created_at, + provider_code: providerCode, + task_id: task.id.toString(), + clip_id: null, + cost_actual: this.decimalToOptionalNumber(task.cost_actual), + details: { + task_type: task.task_type, + output_asset_id: task.output_asset_id?.toString() ?? '', + retry_count: task.retry_count, + max_retry: task.max_retry, + estimated_cost: this.decimalToOptionalNumber(task.cost_estimate), + error_code: task.error_code ?? '', + error_message: task.error_message ?? '' + } + })); + + if (task.finished_at || task.status !== 'pending') { + items.push(this.createRouterAuditTimelineItem({ + id: `task-${task.id.toString()}-finished`, + kind: 'render_task', + title: task.status === 'success' ? '视频生成任务完成' : '视频生成任务结束', + status: task.status, + at: task.finished_at ?? task.created_at, + provider_code: providerCode, + task_id: task.id.toString(), + clip_id: null, + cost_actual: this.decimalToOptionalNumber(task.cost_actual), + details: { + output_asset_id: task.output_asset_id?.toString() ?? '', + provider_request_id: task.provider_request_id ?? '', + cost_estimate: this.decimalToOptionalNumber(task.cost_estimate), + cost_actual: this.decimalToOptionalNumber(task.cost_actual), + error_code: task.error_code ?? '', + error_message: task.error_message ?? '' + } + })); + } + } + + for (const clip of input.relatedClips) { + const provider = clip.provider_id ? input.providerMap.get(clip.provider_id.toString()) ?? null : null; + const providerCode = provider?.provider_code ?? null; + + items.push(this.createRouterAuditTimelineItem({ + id: `clip-${clip.id.toString()}-created`, + kind: 'video_clip', + title: clip.status === 'failed' ? '片段生成失败记录' : '片段生成记录', + status: clip.status, + at: clip.created_at, + provider_code: providerCode, + task_id: null, + clip_id: clip.id.toString(), + cost_actual: this.decimalToOptionalNumber(clip.cost_actual), + details: { + output_asset_id: clip.output_asset_id?.toString() ?? '', + retry_count: clip.retry_count, + duration: clip.duration?.toString() ?? '', + quality_status: clip.quality_status ?? 'not_checked', + quality_score: this.decimalToOptionalNumber(clip.quality_score) + } + })); + + if (clip.quality_status) { + items.push(this.createRouterAuditTimelineItem({ + id: `clip-${clip.id.toString()}-quality`, + kind: 'quality_check', + title: '片段质检状态', + status: clip.quality_status, + at: clip.updated_at, + provider_code: providerCode, + task_id: null, + clip_id: clip.id.toString(), + cost_actual: this.decimalToOptionalNumber(clip.cost_actual), + details: { + quality_score: this.decimalToOptionalNumber(clip.quality_score), + quality_issues: this.toJsonSafeValue(clip.quality_issues), + manual_reason: this.latestQualityIssue(clip.quality_issues) ?? '' + } + })); + } + } + + for (const log of input.providerLogs) { + const request = this.jsonObject(log.request_json ?? null); + const response = this.jsonObject(log.response_json ?? null); + const purpose = this.stringifyJsonText(request.purpose); + + items.push(this.createRouterAuditTimelineItem({ + id: `provider-log-${log.id.toString()}`, + kind: log.provider_type === 'QualityCheckProvider' ? 'quality_check' : 'provider_call', + title: log.provider_type === 'QualityCheckProvider' ? '质检 Provider 调用' : 'Provider 生成调用', + status: log.status, + at: log.finished_at ?? log.created_at, + provider_code: log.provider_code, + task_id: log.task_id?.toString() ?? null, + clip_id: this.routerAuditTimelineClipIdForProviderLog(log, input.relatedClips.map((clip) => clip.id)), + cost_actual: this.decimalToOptionalNumber(log.cost_actual), + details: { + provider_type: log.provider_type, + model_name: log.model_name ?? '', + purpose, + input_size: log.input_size ?? 0, + output_size: log.output_size ?? 0, + cost_estimate: this.decimalToOptionalNumber(log.cost_estimate), + cost_actual: this.decimalToOptionalNumber(log.cost_actual), + error_code: log.error_code ?? '', + error_message: log.error_message ?? '', + result_status: this.stringifyJsonText(response.result_status), + quality_score: this.optionalNumberFromJson(response.quality_score) + } + })); + } + + for (const log of input.operationLogs) { + items.push(this.createRouterAuditTimelineItem({ + id: `operation-log-${log.id.toString()}`, + kind: 'manual_operation', + title: this.routerAuditTimelineOperationTitle(log.action), + status: log.action, + at: log.created_at, + actor: log.operator_role ?? null, + provider_code: null, + task_id: log.target_type === 'render_task' ? log.target_id?.toString() ?? null : null, + clip_id: log.target_type === 'video_clip' ? log.target_id?.toString() ?? null : null, + cost_actual: null, + details: { + action: log.action, + target_type: log.target_type ?? '', + target_id: log.target_id?.toString() ?? '', + metadata_json: this.toJsonSafeValue(log.metadata_json) + } + })); + } + + return items.sort((left, right) => { + const timeDelta = new Date(left.at).getTime() - new Date(right.at).getTime(); + + if (timeDelta !== 0) return timeDelta; + + return left.id.localeCompare(right.id); + }); + } + + private createRouterAuditTimelineItem(input: Omit & { + at: Date; + actor?: string | null; + details: Record; + }): RouterAuditTimelineItem { + return { + ...input, + at: input.at.toISOString(), + actor: input.actor ?? null, + details: this.toJsonSafeValue(input.details) + }; + } + + private createRouterAuditTimelineSummary( + clip: VideoClip, + timeline: RouterAuditTimelineItem[], + relatedClips: VideoClip[], + tasks: RenderTask[], + providerLogs: ProviderLog[], + operationLogs: OperationLog[] + ) { + const totalProviderCost = providerLogs.reduce( + (sum, log) => sum + (this.decimalToOptionalNumber(log.cost_actual) ?? 0), + 0 + ); + + return { + event_count: timeline.length, + related_clip_count: relatedClips.length, + task_count: tasks.length, + provider_call_count: providerLogs.length, + manual_operation_count: operationLogs.length, + total_provider_cost: this.roundMoney(totalProviderCost), + latest_clip_id: clip.id.toString(), + latest_clip_status: clip.status, + latest_quality_status: clip.quality_status ?? 'not_checked', + latest_quality_score: this.decimalToOptionalNumber(clip.quality_score) + }; + } + + private providerCodeFromRouterTask(task: RenderTask, providerMap: Map) { + const inputJson = this.jsonObject(task.input_json ?? null); + const routerDecision = this.jsonObject(inputJson.router_decision ?? null); + const repairContext = this.jsonObject(inputJson.repair_context ?? null); + + return ( + this.stringifyJsonText(repairContext.provider_code) || + this.stringifyJsonText(routerDecision.provider_code) || + this.stringifyJsonText(inputJson.provider) || + (task.provider_id ? providerMap.get(task.provider_id.toString())?.provider_code ?? null : null) + ); + } + + private routerAuditTimelineClipIdForProviderLog(log: ProviderLog, clipIds: bigint[]) { + const request = this.jsonObject(log.request_json ?? null); + const purpose = this.stringifyJsonText(request.purpose); + const requestInput = this.jsonObject(request.input_json ?? null); + const prompt = this.stringifyJsonText(requestInput.prompt); + + for (const clipId of clipIds) { + const value = clipId.toString(); + + if (purpose.endsWith(`-${value}`) || purpose.includes(`clip-qc-${value}`)) { + return value; + } + if (prompt.includes(`clip_id=${value}`)) { + return value; + } + } + + return null; + } + + private routerAuditTimelineOperationTitle(action: string) { + if (action === 'admin_update_router_audit_quality') return '后台人工质检处理'; + if (action === 'router_audit_quality_recheck') return '后台重新质检'; + if (action === 'router_audit_manual_provider_retry') return '后台指定 Provider 重试'; + if (action === 'router_audit_auto_repair_triggered') return 'Router 自动修复触发'; + if (action.includes('retry')) return '后台重试操作'; + if (action.includes('quality')) return '质量处理操作'; + + return '后台审计操作'; + } + + private createRouterAuditRenderNormalizationMap(renderTasks: RenderTask[]) { + const map = new Map(); + + for (const task of renderTasks) { + if (!task.episode_id) continue; + + for (const normalization of this.routerAuditRenderNormalizationsForTask(task)) { + const shotId = this.toBigIntOrNull(normalization.shot_id); + + if (!shotId) continue; + + const key = this.routerAuditRenderNormalizationKey(task.episode_id, shotId); + + if (!map.has(key)) { + map.set(key, normalization); + } + } + } + + return map; + } + + private routerAuditRenderNormalizationsForTask(task: RenderTask) { + const inputJson = this.jsonObject(task.input_json ?? null); + const rawItems = Array.isArray(inputJson.clip_normalization) + ? inputJson.clip_normalization + : []; + + return rawItems + .map((item) => this.routerAuditRenderNormalizationFromJson(task, item)) + .filter((item): item is RouterAuditRenderNormalization => Boolean(item)); + } + + private routerAuditRenderNormalizationFromJson(task: RenderTask, value: unknown): RouterAuditRenderNormalization | null { + const item = this.jsonObject(value as Prisma.InputJsonValue | Prisma.JsonValue | null); + const shotId = this.stringifyJsonText(item.shot_id); + + if (!shotId) { + return null; + } + + return { + task_id: task.id.toString(), + output_asset_id: task.output_asset_id?.toString() ?? null, + shot_id: shotId, + shot_no: this.optionalNumberFromJson(item.shot_no), + target_duration: this.optionalNumberFromJson(item.target_duration), + source_duration: this.optionalNumberFromJson(item.source_duration), + final_duration: this.optionalNumberFromJson(item.final_duration), + trimmed: item.trimmed === true, + trim_strategy: this.stringifyJsonText(item.trim_strategy) || null, + trim_start: this.optionalNumberFromJson(item.trim_start), + trim_tolerance: this.optionalNumberFromJson(item.trim_tolerance) + }; + } + + private routerAuditRenderNormalizationKey(episodeId: bigint, shotId: bigint) { + return `episode:${episodeId.toString()}:shot:${shotId.toString()}`; + } + + private createRouterAuditTaskMap(tasks: RenderTask[]) { + const map = new Map(); + + for (const task of tasks) { + if (!task.shot_id) continue; + + const shotKey = `shot:${task.shot_id.toString()}`; + if (!map.has(shotKey)) { + map.set(shotKey, task); + } + if (task.output_asset_id) { + const exactKey = `exact:${task.shot_id.toString()}:${task.output_asset_id.toString()}`; + + if (!map.has(exactKey)) { + map.set(exactKey, task); + } + } + } + + return map; + } + + private routerAuditTaskForClip(clip: VideoClip, taskMap: Map) { + if (clip.output_asset_id) { + const exact = taskMap.get(`exact:${clip.shot_id.toString()}:${clip.output_asset_id.toString()}`); + + if (exact) return exact; + } + + return taskMap.get(`shot:${clip.shot_id.toString()}`) ?? null; + } + + private createRouterAuditRow(input: { + clip: VideoClip; + project: Project | null; + episode: Episode | null; + shot: StoryboardShot | null; + provider: ProviderConfig | null; + task: RenderTask | null; + routerDecision: Record; + repairContext: Record; + sourceClip: VideoClip | null; + renderNormalization: RouterAuditRenderNormalization | null; + }) { + const { + clip, + project, + episode, + shot, + provider, + task, + routerDecision, + repairContext, + sourceClip, + renderNormalization + } = input; + const routerScores = this.jsonObject(routerDecision.scores ?? null); + const fallbackChain = this.uniqueStrings([ + ...this.stringArray(routerDecision.fallback_chain), + ...this.stringArray(repairContext.fallback_chain) + ]); + const candidates = Array.isArray(routerDecision.candidates) + ? routerDecision.candidates.map((candidate) => this.toJsonSafeValue(candidate)) + : []; + const estimatedCost = + this.optionalNumberFromJson(routerDecision.estimated_cost) ?? + this.decimalToOptionalNumber(task?.cost_estimate); + const actualCost = + this.decimalToOptionalNumber(clip.cost_actual) ?? + this.decimalToOptionalNumber(task?.cost_actual) ?? + 0; + const previousCost = this.decimalToOptionalNumber(sourceClip?.cost_actual); + const repairAction = this.stringifyJsonText(repairContext.action) || null; + const sourceClipId = this.stringifyJsonText(repairContext.source_clip_id) || null; + const providerCode = + this.stringifyJsonText(repairContext.provider_code) || + this.stringifyJsonText(routerDecision.provider_code) || + provider?.provider_code || + null; + const score = this.decimalToOptionalNumber(clip.quality_score); + + return { + clip: { + id: clip.id.toString(), + status: clip.status, + retry_count: clip.retry_count, + output_asset_id: clip.output_asset_id?.toString() ?? null, + duration: clip.duration?.toString() ?? null, + created_at: clip.created_at.toISOString(), + updated_at: clip.updated_at.toISOString() + }, + project: project + ? { + id: project.id.toString(), + title: project.title, + status: project.status, + output_mode: project.output_mode + } + : { + id: clip.project_id.toString(), + title: null, + status: null, + output_mode: null + }, + episode: episode + ? { + id: episode.id.toString(), + episode_no: episode.episode_no, + title: episode.title, + status: episode.status + } + : { + id: clip.episode_id.toString(), + episode_no: null, + title: null, + status: null + }, + shot: { + id: clip.shot_id.toString(), + shot_no: shot?.shot_no ?? null, + scene_name: shot?.scene_name ?? null, + scene_type: shot?.scene_type ?? (this.stringifyJsonText(routerScores.scene_type) || null), + importance_score: shot?.importance_score ?? this.optionalNumberFromJson(routerScores.importance_score), + emotion_score: shot?.emotion_score ?? this.optionalNumberFromJson(routerScores.emotion_score), + action_score: shot?.action_score ?? this.optionalNumberFromJson(routerScores.action_score), + route_tier: shot?.route_tier ?? (this.stringifyJsonText(routerDecision.route_tier) || null), + video_status: shot?.video_status ?? null, + prompt_preview: this.compactText(shot?.video_prompt ?? shot?.prompt_text ?? clip.prompt_text, 120) + }, + provider: { + id: clip.provider_id?.toString() ?? null, + provider_code: providerCode, + display_name: provider?.display_name ?? null, + mode: provider?.mode ?? (this.stringifyJsonText(routerDecision.provider_mode) || null), + model_name: provider?.model_name ?? null + }, + router: { + config_key: this.stringifyJsonText(routerDecision.config_key) || null, + decision_reason: this.stringifyJsonText(routerDecision.decision_reason) || null, + manual_override: Boolean(routerDecision.manual_override), + fallback_chain: fallbackChain, + candidates, + candidate_count: candidates.length, + estimated_cost: estimatedCost + }, + quality: { + status: clip.quality_status ?? 'not_checked', + score, + issues: clip.quality_issues ?? null, + manual_reason: this.latestQualityIssue(clip.quality_issues) + }, + repair: { + action: repairAction, + source_clip_id: sourceClipId, + previous_quality_status: this.stringifyJsonText(repairContext.previous_quality_status) || null, + previous_quality_score: this.optionalNumberFromJson(repairContext.previous_quality_score), + min_quality_score: this.optionalNumberFromJson(repairContext.min_quality_score), + switched_provider: repairAction === 'switch_provider', + auto_repaired: Boolean(repairAction || clip.retry_count > 0) + }, + cost: { + estimated_cost: estimatedCost, + actual_cost: actualCost, + previous_clip_cost: previousCost, + cost_delta: estimatedCost === null ? null : this.roundMoney(actualCost - estimatedCost), + repair_added_cost: repairAction || clip.retry_count > 0 ? actualCost : 0 + }, + render_normalization: renderNormalization, + task: task + ? { + id: task.id.toString(), + status: task.status, + retry_count: task.retry_count, + cost_estimate: this.decimalToOptionalNumber(task.cost_estimate), + cost_actual: this.decimalToOptionalNumber(task.cost_actual), + finished_at: task.finished_at?.toISOString() ?? null + } + : null + }; + } + + private createRouterAuditSummary(rows: ReturnType[]) { + const qualityScores = rows + .map((row) => row.quality.score) + .filter((score): score is number => typeof score === 'number' && Number.isFinite(score)); + const totalEstimatedCost = rows.reduce((sum, row) => sum + (row.cost.estimated_cost ?? 0), 0); + const totalActualCost = rows.reduce((sum, row) => sum + row.cost.actual_cost, 0); + const repairAddedCost = rows.reduce((sum, row) => sum + row.cost.repair_added_cost, 0); + const renderNormalizations = rows + .map((row) => row.render_normalization) + .filter((item): item is RouterAuditRenderNormalization => Boolean(item)); + const trimmedSeconds = renderNormalizations.reduce((sum, item) => { + if (!item.trimmed || typeof item.source_duration !== 'number' || typeof item.final_duration !== 'number') { + return sum; + } + + return sum + Math.max(0, item.source_duration - item.final_duration); + }, 0); + + return { + clip_count: rows.length, + passed_count: rows.filter((row) => row.quality.status === 'passed').length, + needs_retry_count: rows.filter((row) => row.quality.status === 'needs_retry').length, + manual_required_count: rows.filter((row) => row.quality.status === 'manual_required').length, + not_checked_count: rows.filter((row) => row.quality.status === 'not_checked').length, + low_score_count: rows.filter((row) => typeof row.quality.score === 'number' && row.quality.score < 80).length, + auto_repaired_count: rows.filter((row) => row.repair.auto_repaired).length, + switched_provider_count: rows.filter((row) => row.repair.switched_provider).length, + normalized_clip_count: renderNormalizations.length, + trimmed_clip_count: renderNormalizations.filter((item) => item.trimmed).length, + trimmed_seconds_total: Number(trimmedSeconds.toFixed(3)), + total_estimated_cost: this.roundMoney(totalEstimatedCost), + total_actual_cost: this.roundMoney(totalActualCost), + repair_added_cost: this.roundMoney(repairAddedCost), + avg_quality_score: qualityScores.length > 0 + ? Number((qualityScores.reduce((sum, score) => sum + score, 0) / qualityScores.length).toFixed(2)) + : null + }; + } + + private jsonObject(value: Prisma.InputJsonValue | Prisma.JsonValue | null | undefined) { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + + return {}; + } + + private stringifyJsonText(value: unknown) { + return typeof value === 'string' ? value.trim() : ''; + } + + private optionalNumberFromJson(value: unknown) { + if (value === undefined || value === null || value === '') { + return null; + } + + const numberValue = Number(value); + + return Number.isFinite(numberValue) ? numberValue : null; + } + + private stringArray(value: unknown) { + if (!Array.isArray(value)) { + return []; + } + + return value + .map((item) => this.stringifyJsonText(item)) + .filter((item): item is string => Boolean(item)); + } + + private uniqueStrings(values: Array) { + return [...new Set(values.map((value) => value?.trim()).filter((value): value is string => Boolean(value)))]; + } + + private uniqueBigints(values: bigint[]) { + return [...new Set(values.map((value) => value.toString()))].map((value) => BigInt(value)); + } + + private toBigIntOrNull(value: unknown) { + try { + if (value === null || value === undefined || value === '') return null; + return BigInt(String(value)); + } catch { + return null; + } + } + + private decimalToOptionalNumber(value: Prisma.Decimal | null | undefined) { + return value ? Number(value.toString()) : null; + } + + private roundMoney(value: number) { + return Number(value.toFixed(4)); + } + + private compactText(value: string | null | undefined, maxLength: number) { + if (!value) return null; + if (value.length <= maxLength) return value; + + return `${value.slice(0, maxLength)}...`; + } + + private latestQualityIssue(value: unknown) { + if (!Array.isArray(value) || value.length === 0) { + return null; + } + + const latest = value.at(-1); + + if (typeof latest === 'string') { + return latest; + } + + return JSON.stringify(latest); + } + + private defaultRouterAuditManualReason(status: (typeof ROUTER_AUDIT_QUALITY_STATUSES)[number]) { + if (status === 'passed') return 'Manual quality pass from router audit page'; + if (status === 'rejected') return 'Manual quality reject from router audit page'; + if (status === 'needs_retry') return 'Manual quality update requires retry'; + + return 'Manual quality review required'; + } + + private manualQualityScore( + status: (typeof ROUTER_AUDIT_QUALITY_STATUSES)[number], + requestedScore: number | null, + previousScore: number | null + ) { + if (status === 'passed') { + return Math.max(requestedScore ?? previousScore ?? 100, 80); + } + if (status === 'rejected') { + return requestedScore ?? previousScore ?? 0; + } + + return requestedScore ?? previousScore; + } + + private videoStatusForManualQuality(status: (typeof ROUTER_AUDIT_QUALITY_STATUSES)[number]) { + if (status === 'passed') return 'quality_passed'; + if (status === 'rejected') return 'quality_rejected'; + if (status === 'needs_retry') return 'quality_needs_retry'; + + return 'quality_manual_required'; + } + + private toJsonSafeValue(value: unknown): Prisma.InputJsonValue { + if (value === null) return ''; + if (typeof value === 'string' || typeof value === 'boolean') return value; + if (typeof value === 'number') return Number.isFinite(value) ? value : 0; + if (Array.isArray(value)) return value.map((item) => this.toJsonSafeValue(item)); + if (typeof value === 'object') { + const output: Record = {}; + + for (const [key, child] of Object.entries(value as Record)) { + if (child !== undefined) { + output[key] = this.toJsonSafeValue(child); + } + } + + return output as Prisma.InputJsonObject; + } + + return String(value); + } + + private createOperationLogWhere(query: AdminListOperationLogsQueryDto) { + const where: Prisma.OperationLogWhereInput = {}; + + if (query.user_id) { + where.user_id = this.parseId(query.user_id, 'Invalid user_id'); + } + if (query.operator_role) { + where.operator_role = this.normalizeOptionalText(query.operator_role, 50); + } + if (query.action) { + where.action = this.normalizeOptionalText(query.action, 100); + } + if (query.target_type) { + where.target_type = this.normalizeOptionalText(query.target_type, 80); + } + if (query.target_id) { + where.target_id = this.parseId(query.target_id, 'Invalid target_id'); + } + if (query.date_from || query.date_to) { + where.created_at = { + ...(query.date_from ? { gte: this.parseDate(query.date_from, 'Invalid date_from') } : {}), + ...(query.date_to ? { lte: this.parseDate(query.date_to, 'Invalid date_to') } : {}) + }; + } + + return where; + } + + private createOperationLogCsv(logs: ReturnType[]) { + const header = [ + 'ID', + '时间', + '操作人ID', + '操作人角色', + '动作', + '对象类型', + '对象ID', + '元数据' + ]; + const rows = logs.map((log) => [ + log.id, + log.created_at, + log.user_id ?? '', + log.operator_role ?? '', + log.action, + log.target_type ?? '', + log.target_id ?? '', + JSON.stringify(log.metadata_json ?? {}) + ]); + + return [header, ...rows].map((row) => row.map((cell) => this.csvEscape(cell)).join(',')).join('\n'); + } + + private csvEscape(value: unknown) { + const text = String(value ?? ''); + + if (/[",\n\r]/.test(text)) { + return `"${text.replace(/"/g, '""')}"`; + } + + return text; + } + + private parseDate(value: string, message: string) { + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + throw new BadRequestException(message); + } + + return date; + } + + private localDateStamp(date: Date) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + + return `${year}-${month}-${day}`; + } + + private toJsonSafeObject(value: object) { + return Object.fromEntries( + Object.entries(value as Record).filter(([, child]) => child !== undefined && child !== '') + ) as Prisma.InputJsonObject; + } + + private async findUserOrThrow(userId: string) { + const id = this.parseId(userId, 'Invalid user id'); + const user = await this.prisma.user.findUnique({ where: { id } }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + return user; + } + + private validateUserStatus(value: unknown) { + if (typeof value !== 'string' || !(ADMIN_USER_STATUSES as readonly string[]).includes(value)) { + throw new BadRequestException('status is not supported'); + } + + return value; + } + + private validateUserRole(value: unknown) { + if (typeof value !== 'string' || !(ADMIN_USER_ROLES as readonly string[]).includes(value)) { + throw new BadRequestException('role is not supported'); + } + + return value; + } + + private validatePassword(password: string | undefined) { + const normalized = password?.trim(); + + if (!normalized || normalized.length < PASSWORD_MIN_LENGTH) { + throw new BadRequestException(`Password must be at least ${PASSWORD_MIN_LENGTH} characters`); + } + if (normalized.length > PASSWORD_MAX_LENGTH) { + throw new BadRequestException(`Password must be at most ${PASSWORD_MAX_LENGTH} characters`); + } + + return normalized; + } + + private createTemporaryPassword() { + return `Tmp-${randomBytes(9).toString('base64url')}9!`; + } + + private async writeUserOperationLog( + operator: AuthRequestUser, + targetUserId: bigint, + action: string, + metadata: Record + ) { + return this.prisma.operationLog.create({ + data: { + user_id: this.parseId(operator.id, 'Invalid user id'), + operator_role: operator.role, + action, + target_type: 'user', + target_id: targetUserId, + metadata_json: metadata as Prisma.InputJsonValue + } + }); + } + + private assertAdmin(user: AuthRequestUser) { + assertPermission(user, 'admin:read'); + } + + private validateProjectStatus(value: unknown) { + if (typeof value !== 'string' || !(PROJECT_STATUSES as readonly string[]).includes(value)) { + throw new BadRequestException('status is not supported'); + } + + return value; + } + + private normalizePositiveInt( + value: unknown, + field: string, + min: number, + max: number, + fallback: number + ) { + if (value === undefined || value === null || value === '') { + return fallback; + } + + const numberValue = Number(value); + + if (!Number.isInteger(numberValue) || numberValue < min || numberValue > max) { + throw new BadRequestException(`${field} must be an integer between ${min} and ${max}`); + } + + return numberValue; + } + + private parseId(value: string | bigint, message: string) { + try { + const id = BigInt(value); + + if (id <= 0n) { + throw new Error('ID must be positive'); + } + + return id; + } catch { + throw new BadRequestException(message); + } + } + + private parseOptionalId(value: string | undefined, message: string) { + const normalized = value?.trim(); + + if (!normalized) { + return null; + } + + return this.parseId(normalized, message); + } + + private requiredText(value: string | undefined, maxLength: number, message: string) { + const normalized = this.normalizeOptionalText(value, maxLength); + + if (!normalized) { + throw new BadRequestException(message); + } + + return normalized; + } + + private normalizeOptionalText(value: string | undefined, maxLength: number) { + const normalized = value?.trim(); + + if (!normalized) { + return undefined; + } + if (normalized.length > maxLength) { + throw new BadRequestException(`Text must be at most ${maxLength} characters`); + } + + return normalized; + } + + private normalizeNullableText(value: string | undefined, maxLength: number) { + return this.normalizeOptionalText(value, maxLength) ?? null; + } + + private validateChoice( + value: string | undefined, + choices: T, + field: string, + fallback?: T[number] + ): T[number] { + const normalized = this.normalizeOptionalText(value, 80); + + if (!normalized) { + if (fallback !== undefined) return fallback; + throw new BadRequestException(`${field} is required`); + } + if (!choices.includes(normalized)) { + throw new BadRequestException(`${field} is not supported`); + } + + return normalized as T[number]; + } + + private normalizeJsonValue(value: unknown) { + if (value === undefined || value === null || value === '') { + return Prisma.JsonNull; + } + + return value as Prisma.InputJsonValue; + } + + private startOfToday() { + const date = new Date(); + date.setHours(0, 0, 0, 0); + return date; + } + + private decimalToNumber(value: Prisma.Decimal | null | undefined) { + return value ? Number(value.toString()) : 0; + } + + private async ensureDefaultSystemConfigs() { + await Promise.all( + DEFAULT_SYSTEM_CONFIGS.map((config) => + this.prisma.systemConfig.upsert({ + where: { config_key: config.config_key }, + update: {}, + create: { + config_key: config.config_key, + config_value: config.config_value as Prisma.InputJsonValue, + description: config.description, + is_public: config.is_public + } + }) + ) + ); + } + + private normalizeSystemConfigValue(configKey: string, value: unknown) { + if (configKey === 'security.api_crypto_enabled') { + if (typeof value === 'boolean') { + return { enabled: value }; + } + + if (typeof value === 'object' && value !== null && 'enabled' in value) { + return { enabled: Boolean((value as { enabled?: unknown }).enabled) }; + } + + throw new BadRequestException('security.api_crypto_enabled requires a boolean enabled value'); + } + + if (configKey === 'ai.router.v1') { + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + return value; + } + + throw new BadRequestException('ai.router.v1 requires a JSON object value'); + } + + throw new BadRequestException('System config key is not supported'); + } + + private async projectIdsForUser(userId: string) { + const projects = await this.prisma.project.findMany({ + where: { user_id: this.parseId(userId, 'Invalid user_id') }, + select: { id: true } + }); + + return projects.map((project) => project.id); + } + + private toCountRows( + rows: Array & { _count: { _all: number } }>, + key: T + ) { + return rows.map((row) => ({ + key: row[key] ?? 'unknown', + count: row._count._all + })); + } +} diff --git a/backend/src/admin/admin.types.ts b/backend/src/admin/admin.types.ts new file mode 100644 index 0000000..917780f --- /dev/null +++ b/backend/src/admin/admin.types.ts @@ -0,0 +1,160 @@ +import type { + CopyrightRecord, + CreativePattern, + HitAnalysisCase, + HitAnalysisSegment, + NovelChapter, + NovelSource, + OperationLog, + SystemConfig +} from '@prisma/client'; + +export function toSafeNovelSource(source: NovelSource) { + return { + id: source.id.toString(), + project_id: source.project_id.toString(), + source_type: source.source_type, + title: source.title, + author_name: source.author_name, + raw_asset_id: source.raw_asset_id?.toString() ?? null, + word_count: source.word_count, + chapter_count: source.chapter_count, + parse_status: source.parse_status, + parse_report: source.parse_report, + text_preview: createTextPreview(source.clean_text || source.raw_text), + created_at: source.created_at.toISOString() + }; +} + +export function toSafeNovelChapter(chapter: NovelChapter) { + return { + id: chapter.id.toString(), + project_id: chapter.project_id.toString(), + novel_source_id: chapter.novel_source_id?.toString() ?? null, + chapter_no: chapter.chapter_no, + title: chapter.title, + summary: chapter.summary, + visual_summary: chapter.visual_summary, + content_preview: createTextPreview(chapter.content, 3000), + word_count: chapter.word_count, + status: chapter.status, + created_at: chapter.created_at.toISOString() + }; +} + +function createTextPreview(value: string | null, maxLength = 2000) { + if (!value) { + return null; + } + + return value.length > maxLength ? `${value.slice(0, maxLength)}...` : value; +} + +export function toSafeCopyrightRecord(record: CopyrightRecord) { + return { + id: record.id.toString(), + project_id: record.project_id.toString(), + user_id: record.user_id.toString(), + authorization_type: record.authorization_type, + statement_text: record.statement_text, + ip: record.ip, + confirmed_at: record.confirmed_at.toISOString() + }; +} + +export function toSafeOperationLog(log: OperationLog) { + return { + id: log.id.toString(), + user_id: log.user_id?.toString() ?? null, + operator_role: log.operator_role, + action: log.action, + target_type: log.target_type, + target_id: log.target_id?.toString() ?? null, + metadata_json: log.metadata_json, + created_at: log.created_at.toISOString() + }; +} + +export function toSafeSystemConfig(config: SystemConfig) { + return { + id: config.id.toString(), + config_key: config.config_key, + config_value: config.config_value, + description: config.description, + is_public: config.is_public, + created_at: config.created_at.toISOString(), + updated_at: config.updated_at.toISOString() + }; +} + +export function toSafeHitAnalysisCase(item: HitAnalysisCase) { + return { + id: item.id.toString(), + title: item.title, + source_platform: item.source_platform, + source_url: item.source_url, + content_type: item.content_type, + genre: item.genre, + language: item.language, + target_audience: item.target_audience, + duration_seconds: item.duration_seconds, + episode_count: item.episode_count, + tags_json: item.tags_json, + metrics_json: item.metrics_json, + summary_text: item.summary_text, + transcript_preview: createTextPreview(item.transcript_text, 3000), + analysis_json: item.analysis_json, + diagnosis_score: item.diagnosis_score ? Number(item.diagnosis_score.toString()) : null, + status: item.status, + created_by_user_id: item.created_by_user_id?.toString() ?? null, + created_at: item.created_at.toISOString(), + updated_at: item.updated_at.toISOString() + }; +} + +export function toSafeHitAnalysisSegment(item: HitAnalysisSegment) { + return { + id: item.id.toString(), + case_id: item.case_id.toString(), + segment_no: item.segment_no, + start_second: item.start_second, + end_second: item.end_second, + scene_type: item.scene_type, + hook_type: item.hook_type, + emotion: item.emotion, + conflict_type: item.conflict_type, + plot_function: item.plot_function, + visual_strategy: item.visual_strategy, + dialogue_pattern: item.dialogue_pattern, + camera_notes: item.camera_notes, + importance_score: item.importance_score, + emotion_score: item.emotion_score, + action_score: item.action_score, + tags_json: item.tags_json, + summary_text: item.summary_text, + prompt_seed: item.prompt_seed, + created_at: item.created_at.toISOString() + }; +} + +export function toSafeCreativePattern(item: CreativePattern) { + return { + id: item.id.toString(), + source_case_id: item.source_case_id?.toString() ?? null, + pattern_type: item.pattern_type, + title: item.title, + genre: item.genre, + language: item.language, + description: item.description, + structure_json: item.structure_json, + prompt_template: item.prompt_template, + negative_prompt: item.negative_prompt, + tags_json: item.tags_json, + usage_count: item.usage_count, + effectiveness_score: item.effectiveness_score ? Number(item.effectiveness_score.toString()) : null, + status: item.status, + created_by_user_id: item.created_by_user_id?.toString() ?? null, + created_at: item.created_at.toISOString(), + updated_at: item.updated_at.toISOString() + }; +} diff --git a/backend/src/ai-router/ai-router.module.ts b/backend/src/ai-router/ai-router.module.ts new file mode 100644 index 0000000..a35bb27 --- /dev/null +++ b/backend/src/ai-router/ai-router.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../prisma/prisma.module'; +import { AiRouterService } from './ai-router.service'; + +@Module({ + imports: [PrismaModule], + providers: [AiRouterService], + exports: [AiRouterService] +}) +export class AiRouterModule {} diff --git a/backend/src/ai-router/ai-router.service.spec.ts b/backend/src/ai-router/ai-router.service.spec.ts new file mode 100644 index 0000000..d7789b2 --- /dev/null +++ b/backend/src/ai-router/ai-router.service.spec.ts @@ -0,0 +1,226 @@ +import { Prisma, type Project, type ProviderConfig, type StoryboardShot } from '@prisma/client'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { PrismaService } from '../prisma/prisma.service'; +import { DEFAULT_AI_ROUTER_CONFIG } from './ai-router.types'; +import { AiRouterService } from './ai-router.service'; + +const now = new Date('2026-06-09T00:00:00.000Z'); + +function createProject(overrides: Partial = {}): Project { + return { + id: 10n, + user_id: 1n, + title: 'AI Router 测试项目', + input_mode: 'ai_original', + genre: 'urban_rebirth', + style_code: 'live_action', + output_type: 'short_video', + output_mode: 'live_action_ai', + visual_mode: 'live_action', + video_generation_level: 'standard', + target_episode_count: 3, + episode_duration: 60, + status: 'live_action_shots_prepared', + copyright_status: 'ai_original', + payment_status: 'paid', + quality_level: 'mvp', + is_long_series: false, + created_at: now, + updated_at: now, + completed_at: null, + ...overrides + }; +} + +function createShot(overrides: Partial = {}): StoryboardShot { + return { + id: 20n, + project_id: 10n, + episode_id: 30n, + shot_no: 1, + scene_name: '会议室反击', + location_desc: '高层会议室', + characters_json: [{ id: '1', name: '林晚' }], + visual_desc: '林晚站在会议桌前。', + action_desc: '林晚播放录音证据。', + dialogue_text: '这一回,我不会再退。', + narration_text: '局势开始反转。', + camera_motion: 'zoom_in', + effect_type: 'flash', + duration: new Prisma.Decimal(4), + scene_type: null, + importance_score: null, + emotion_score: null, + action_score: null, + route_tier: null, + prompt_text: '真人短剧会议室反击', + negative_prompt: '低清晰度', + live_action_desc: null, + actor_action: null, + camera_instruction: null, + performance_instruction: null, + video_prompt: null, + keyframe_asset_id: 40n, + video_clip_asset_id: null, + video_status: 'keyframe_generated', + status: 'confirmed', + created_at: now, + updated_at: now, + ...overrides + }; +} + +function createProvider(overrides: Partial = {}): ProviderConfig { + return { + id: 100n, + provider_type: 'VideoProvider', + provider_code: 'mock-video', + display_name: 'Mock Video', + mode: 'mock', + model_name: 'mock-video-v1', + config_json: {}, + fallback_provider_id: null, + is_enabled: true, + priority: 100, + rate_limit_json: {}, + cost_rule_json: { flat_cost: 0, unit: 'mock' }, + created_at: now, + updated_at: now, + ...overrides + }; +} + +describe('AiRouterService', () => { + let prisma: any; + let service: AiRouterService; + + beforeEach(() => { + prisma = { + systemConfig: { + upsert: vi.fn().mockResolvedValue({ + config_key: 'ai.router.v1', + config_value: DEFAULT_AI_ROUTER_CONFIG + }) + }, + providerConfig: { + findMany: vi.fn() + }, + providerLog: { + aggregate: vi.fn().mockResolvedValue({ + _sum: { cost_actual: new Prisma.Decimal(0) } + }) + } + }; + service = new AiRouterService(prisma as PrismaService); + }); + + it('routes normal Chinese live-action shots to Hailuo when enabled', async () => { + prisma.providerConfig.findMany.mockResolvedValue([ + createProvider({ + id: 101n, + provider_code: 'minimax_hailuo_23_fast', + display_name: 'Hailuo Fast', + mode: 'real', + is_enabled: true, + cost_rule_json: { unit: 'video_seconds', price_per_second: 0.03, currency: 'USD' } + }), + createProvider() + ]); + + const decision = await service.resolveLiveActionVideoRoute({ + project: createProject(), + shot: createShot({ importance_score: 3, action_score: 1, route_tier: 'normal' }), + duration: 5 + }); + + expect(decision.provider_code).toBe('minimax_hailuo_23_fast'); + expect(decision.route_tier).toBe('normal'); + expect(decision.estimated_cost).toBe(0.15); + expect(decision.decision_reason).toBe('auto_normal_route'); + }); + + it('routes high-value or complex shots to Kling when enabled', async () => { + prisma.providerConfig.findMany.mockResolvedValue([ + createProvider({ + id: 102n, + provider_code: 'kling-image-to-video', + display_name: 'Kling', + mode: 'real', + is_enabled: true, + cost_rule_json: { unit: 'video_seconds', price_per_second: 0.12, currency: 'USD' } + }), + createProvider({ + id: 101n, + provider_code: 'minimax_hailuo_23_fast', + mode: 'real', + is_enabled: true + }), + createProvider() + ]); + + const decision = await service.resolveLiveActionVideoRoute({ + project: createProject(), + shot: createShot({ + action_desc: '女主在雨夜追车,真相曝光,高潮打脸。', + importance_score: 9, + action_score: 7 + }), + duration: 5 + }); + + expect(decision.provider_code).toBe('kling-image-to-video'); + expect(decision.route_tier).toBe('premium'); + expect(decision.fallback_chain).toContain('minimax_hailuo_23_fast'); + }); + + it('falls back through disabled providers to mock video', async () => { + prisma.providerConfig.findMany.mockResolvedValue([ + createProvider({ + provider_code: 'minimax_hailuo_23_fast', + mode: 'real', + is_enabled: false + }), + createProvider({ + provider_code: 'jimeng_seedance', + mode: 'real', + is_enabled: false + }), + createProvider() + ]); + + const decision = await service.resolveLiveActionVideoRoute({ + project: createProject(), + shot: createShot({ importance_score: 3, action_score: 1, route_tier: 'normal' }), + duration: 5 + }); + + expect(decision.provider_code).toBe('mock-video'); + expect(decision.candidates.map((candidate) => candidate.reason)).toEqual([ + 'provider_disabled', + 'provider_disabled', + 'auto_normal_route' + ]); + }); + + it('keeps admin manual override as an explicit router decision', async () => { + prisma.providerConfig.findMany.mockResolvedValue([ + createProvider({ + provider_code: 'jimeng_seedance', + mode: 'real', + is_enabled: true + }) + ]); + + const decision = await service.resolveLiveActionVideoRoute({ + project: createProject(), + shot: createShot(), + duration: 5, + manual_provider_code: 'jimeng_seedance', + allow_manual_override: true + }); + + expect(decision.provider_code).toBe('jimeng_seedance'); + expect(decision.manual_override).toBe(true); + expect(decision.decision_reason).toBe('manual_provider_override'); + }); +}); diff --git a/backend/src/ai-router/ai-router.service.ts b/backend/src/ai-router/ai-router.service.ts new file mode 100644 index 0000000..57a5993 --- /dev/null +++ b/backend/src/ai-router/ai-router.service.ts @@ -0,0 +1,374 @@ +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import type { Prisma, Project, ProviderConfig, StoryboardShot } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { + AI_ROUTER_CONFIG_KEY, + AI_ROUTER_DEFAULT_LANGUAGE, + DEFAULT_AI_ROUTER_CONFIG, + type AiRouteDecision, + type AiRouteTier, + type AiRouterShotScores +} from './ai-router.types'; + +const ROUTER_MAX_PROVIDER_CLIP_SECONDS = 10; + +@Injectable() +export class AiRouterService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + scoreLiveActionShot(shot: StoryboardShot): AiRouterShotScores { + const sceneType = this.normalizeSceneType(shot.scene_type) ?? this.inferSceneType(shot); + const importanceScore = this.clampScore(shot.importance_score ?? this.inferImportanceScore(shot, sceneType)); + const emotionScore = this.clampScore(shot.emotion_score ?? this.inferEmotionScore(shot)); + const actionScore = this.clampScore(shot.action_score ?? this.inferActionScore(shot)); + const routeTier = this.normalizeRouteTier(shot.route_tier) ?? this.routeTierForScores(importanceScore, actionScore); + + return { + scene_type: sceneType, + importance_score: importanceScore, + emotion_score: emotionScore, + action_score: actionScore, + route_tier: routeTier + }; + } + + async resolveLiveActionVideoRoute(input: { + project: Project; + shot: StoryboardShot; + duration: number; + language?: string | null; + manual_provider_code?: string | null; + allow_manual_override?: boolean; + max_cost_per_clip?: number | null; + }): Promise { + const scores = this.scoreLiveActionShot(input.shot); + const language = this.normalizeText(input.language) ?? (await this.resolveDefaultLanguage()); + const manualProviderCode = this.normalizeText(input.manual_provider_code); + + if (manualProviderCode && input.allow_manual_override) { + return this.resolveManualVideoProvider(manualProviderCode, language, input.duration, scores); + } + if (manualProviderCode && !input.allow_manual_override) { + throw new BadRequestException('AI_ROUTER_MANUAL_OVERRIDE_FORBIDDEN'); + } + + const config = await this.loadRouterConfig(); + const languageConfig = this.resolveLiveActionLanguageConfig(config, language); + const tierConfig = this.jsonObject(languageConfig[scores.route_tier]); + const primaryProviderCode = + this.normalizeText(tierConfig.provider_code) ?? + (scores.route_tier === 'premium' ? 'kling-image-to-video' : 'minimax_hailuo_23_fast'); + const fallbackChain = this.uniqueStrings([ + primaryProviderCode, + ...this.stringArray(tierConfig.fallback_chain), + 'mock-video' + ]); + + return this.selectVideoProviderFromCandidates({ + language, + duration: input.duration, + scores, + fallbackChain, + maxCostPerClip: input.max_cost_per_clip ?? null, + dailyBudget: this.numberFromJson(this.jsonObject(config).daily_budget), + manualOverride: false, + defaultReason: `auto_${scores.route_tier}_route` + }); + } + + private async resolveManualVideoProvider( + providerCode: string, + language: string, + duration: number, + scores: AiRouterShotScores + ): Promise { + return this.selectVideoProviderFromCandidates({ + language, + duration, + scores, + fallbackChain: [providerCode], + maxCostPerClip: null, + dailyBudget: 0, + manualOverride: true, + defaultReason: 'manual_provider_override' + }); + } + + private async selectVideoProviderFromCandidates(input: { + language: string; + duration: number; + scores: AiRouterShotScores; + fallbackChain: string[]; + maxCostPerClip: number | null; + dailyBudget: number; + manualOverride: boolean; + defaultReason: string; + }): Promise { + const providers = await this.prisma.providerConfig.findMany({ + where: { + provider_type: 'VideoProvider', + provider_code: { in: input.fallbackChain } + } + }); + const providerByCode = new Map(providers.map((provider) => [provider.provider_code, provider])); + const usedToday = input.dailyBudget > 0 ? await this.getTodayProviderCost() : 0; + const candidates: AiRouteDecision['candidates'] = []; + + for (const providerCode of input.fallbackChain) { + const provider = providerByCode.get(providerCode); + const estimatedCost = provider + ? this.estimateVideoCost(provider.cost_rule_json, input.duration) + : 0; + + if (!provider) { + candidates.push({ + provider_code: providerCode, + status: 'skipped', + reason: 'provider_not_found', + estimated_cost: estimatedCost + }); + continue; + } + + if (!provider.is_enabled) { + candidates.push({ + provider_code: providerCode, + status: 'skipped', + reason: 'provider_disabled', + estimated_cost: estimatedCost + }); + continue; + } + + if (input.maxCostPerClip && input.maxCostPerClip > 0 && estimatedCost > input.maxCostPerClip) { + candidates.push({ + provider_code: providerCode, + status: 'skipped', + reason: 'max_cost_per_clip_exceeded', + estimated_cost: estimatedCost + }); + continue; + } + + if (input.dailyBudget > 0 && usedToday + estimatedCost > input.dailyBudget) { + candidates.push({ + provider_code: providerCode, + status: 'skipped', + reason: 'router_daily_budget_exceeded', + estimated_cost: estimatedCost + }); + continue; + } + + candidates.push({ + provider_code: providerCode, + status: 'selected', + reason: input.defaultReason, + estimated_cost: estimatedCost + }); + + return { + config_key: AI_ROUTER_CONFIG_KEY, + task_type: 'live_action_video_clip_generate', + language: input.language, + provider_code: provider.provider_code, + provider_id: provider.id.toString(), + provider_mode: provider.mode, + route_tier: input.scores.route_tier, + fallback_chain: input.fallbackChain, + candidates, + decision_reason: input.defaultReason, + estimated_cost: estimatedCost, + manual_override: input.manualOverride, + scores: input.scores + }; + } + + throw new BadRequestException({ + message: 'AI_ROUTER_NO_VIDEO_PROVIDER_AVAILABLE', + candidates + }); + } + + private async loadRouterConfig() { + const config = await this.prisma.systemConfig.upsert({ + where: { config_key: AI_ROUTER_CONFIG_KEY }, + update: {}, + create: { + config_key: AI_ROUTER_CONFIG_KEY, + config_value: DEFAULT_AI_ROUTER_CONFIG, + description: 'AI Router V1 route config for automatic provider selection by language, shot score and budget.', + is_public: false + } + }); + + return this.jsonObject(config.config_value ?? DEFAULT_AI_ROUTER_CONFIG); + } + + private async resolveDefaultLanguage() { + const config = await this.loadRouterConfig(); + return this.normalizeText(config.default_language) ?? AI_ROUTER_DEFAULT_LANGUAGE; + } + + private resolveLiveActionLanguageConfig(config: Record, language: string) { + const liveAction = this.jsonObject(config.live_action_video); + const current = this.jsonObject(liveAction[language]); + + if (Object.keys(current).length > 0) return current; + + return this.jsonObject(liveAction[AI_ROUTER_DEFAULT_LANGUAGE]); + } + + private estimateVideoCost(rule: Prisma.JsonValue | null, duration: number) { + const costRule = this.jsonObject(rule); + const flatCost = this.numberFromJson(costRule.flat_cost); + const pricePerSecond = this.numberFromJson(costRule.price_per_second); + const pricePerClip = this.numberFromJson(costRule.price_per_clip); + const durations = this.splitProviderClipDurations(duration); + const cost = durations.reduce( + (sum, clipDuration) => sum + flatCost + pricePerClip + clipDuration * pricePerSecond, + 0 + ); + + return Number(cost.toFixed(4)); + } + + private splitProviderClipDurations(duration: number) { + const normalized = Number(Math.max(1, duration).toFixed(2)); + + if (normalized <= ROUTER_MAX_PROVIDER_CLIP_SECONDS) { + return [normalized]; + } + + const count = Math.ceil(normalized / ROUTER_MAX_PROVIDER_CLIP_SECONDS); + const base = Number((normalized / count).toFixed(2)); + const durations = Array.from({ length: count }, () => base); + const total = Number(durations.reduce((sum, item) => sum + item, 0).toFixed(2)); + const diff = Number((normalized - total).toFixed(2)); + + durations[durations.length - 1] = Number((durations[durations.length - 1] + diff).toFixed(2)); + return durations; + } + + private async getTodayProviderCost() { + const today = new Date(); + + today.setHours(0, 0, 0, 0); + + const result = await this.prisma.providerLog.aggregate({ + where: { + status: 'success', + created_at: { gte: today } + }, + _sum: { cost_actual: true } + }); + + return result._sum.cost_actual ? Number(result._sum.cost_actual.toString()) : 0; + } + + private inferSceneType(shot: StoryboardShot) { + const text = this.shotText(shot); + + if (/(打|追|跑|撞|爆|战|枪|刀|车祸|逃|搏斗|扇|摔)/.test(text)) return 'action'; + if (/(哭|崩溃|表白|分手|争吵|怒|吻|求婚|告白)/.test(text)) return 'emotion'; + if (shot.dialogue_text && shot.dialogue_text.length >= (shot.narration_text?.length ?? 0)) return 'dialog'; + if (/(远景|空镜|转场|环境|街道|夜景|大楼)/.test(text)) return 'establishing'; + + return 'dialog'; + } + + private inferImportanceScore(shot: StoryboardShot, sceneType: string) { + const text = this.shotText(shot); + let score = sceneType === 'establishing' ? 2 : 3; + + if (shot.shot_no === 1) score += 1; + if (/(主角|男主|女主|第一次|登场|相遇|重逢)/.test(text)) score += 2; + if (/(打脸|反转|真相|高潮|大结局|求婚|婚礼|分手|车祸|死亡|曝光|证据)/.test(text)) score += 3; + if (/(吻|接吻|表白|崩溃|哭|下跪|复仇|救人)/.test(text)) score += 2; + if (shot.effect_type && shot.effect_type !== 'none') score += 1; + + return score; + } + + private inferEmotionScore(shot: StoryboardShot) { + const text = this.shotText(shot); + let score = 2; + + if (/(争吵|愤怒|怒|质问|冷笑|羞辱)/.test(text)) score += 3; + if (/(哭|崩溃|绝望|心碎|分手)/.test(text)) score += 5; + if (/(表白|告白|求婚|拥抱|吻|接吻)/.test(text)) score += 5; + if (/[!!]{1,}/.test(text)) score += 1; + + return score; + } + + private inferActionScore(shot: StoryboardShot) { + const text = this.shotText(shot); + let score = 1; + + if (/(走|转身|推门|靠近)/.test(text)) score += 1; + if (/(跑|追|开车|车|摔|扇|打|抢|逃)/.test(text)) score += 4; + if (/(打架|搏斗|爆炸|枪|刀|车祸|坠落|火灾)/.test(text)) score += 6; + if (/(多人|群像|人群)/.test(text)) score += 2; + + return score; + } + + private shotText(shot: StoryboardShot) { + return [ + shot.scene_name, + shot.location_desc, + shot.visual_desc, + shot.action_desc, + shot.dialogue_text, + shot.narration_text, + shot.actor_action, + shot.performance_instruction + ] + .filter(Boolean) + .join(' '); + } + + private routeTierForScores(importanceScore: number, actionScore: number): AiRouteTier { + return importanceScore > 7 || actionScore > 5 ? 'premium' : 'normal'; + } + + private normalizeRouteTier(value: string | null): AiRouteTier | null { + return value === 'premium' || value === 'normal' ? value : null; + } + + private normalizeSceneType(value: string | null) { + const normalized = this.normalizeText(value); + return normalized ? normalized.slice(0, 50) : null; + } + + private clampScore(value: number) { + if (!Number.isFinite(value)) return 1; + return Math.max(1, Math.min(10, Math.round(value))); + } + + private uniqueStrings(values: string[]) { + return [...new Set(values.map((value) => value.trim()).filter(Boolean))]; + } + + private stringArray(value: unknown) { + return Array.isArray(value) + ? value.map((item) => this.normalizeText(item)).filter((item): item is string => Boolean(item)) + : []; + } + + private normalizeText(value: unknown) { + return typeof value === 'string' && value.trim() ? value.trim() : null; + } + + private numberFromJson(value: unknown) { + const numberValue = Number(value ?? 0); + return Number.isFinite(numberValue) ? numberValue : 0; + } + + private jsonObject(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {}; + } +} diff --git a/backend/src/ai-router/ai-router.types.ts b/backend/src/ai-router/ai-router.types.ts new file mode 100644 index 0000000..f7c2418 --- /dev/null +++ b/backend/src/ai-router/ai-router.types.ts @@ -0,0 +1,58 @@ +import type { Prisma } from '@prisma/client'; + +export const AI_ROUTER_CONFIG_KEY = 'ai.router.v1'; +export const AI_ROUTER_DEFAULT_LANGUAGE = 'zh-CN'; + +export const DEFAULT_AI_ROUTER_CONFIG = { + version: 1, + enabled: true, + default_language: AI_ROUTER_DEFAULT_LANGUAGE, + daily_budget: 500, + live_action_video: { + 'zh-CN': { + thresholds: { + premium_importance_gt: 7, + premium_action_gt: 5 + }, + normal: { + provider_code: 'minimax_hailuo_23_fast', + fallback_chain: ['minimax_hailuo_23_fast', 'jimeng_seedance', 'mock-video'] + }, + premium: { + provider_code: 'kling-image-to-video', + fallback_chain: ['kling-image-to-video', 'minimax_hailuo_23_fast', 'jimeng_seedance', 'mock-video'] + } + } + } +} satisfies Prisma.InputJsonObject; + +export type AiRouteTier = 'normal' | 'premium'; + +export interface AiRouterShotScores { + scene_type: string; + importance_score: number; + emotion_score: number; + action_score: number; + route_tier: AiRouteTier; +} + +export interface AiRouteDecision { + config_key: string; + task_type: 'live_action_video_clip_generate'; + language: string; + provider_code: string; + provider_id: string | null; + provider_mode: string | null; + route_tier: AiRouteTier; + fallback_chain: string[]; + candidates: Array<{ + provider_code: string; + status: 'selected' | 'skipped'; + reason: string; + estimated_cost: number; + }>; + decision_reason: string; + estimated_cost: number; + manual_override: boolean; + scores: AiRouterShotScores; +} diff --git a/backend/src/app.controller.spec.ts b/backend/src/app.controller.spec.ts new file mode 100644 index 0000000..cf8723a --- /dev/null +++ b/backend/src/app.controller.spec.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import { AppController } from './app.controller'; + +describe('AppController', () => { + it('returns health status', () => { + const controller = new AppController(); + + expect(controller.getHealth()).toEqual({ + status: 'ok', + service: 'backend-api' + }); + }); +}); diff --git a/backend/src/app.controller.ts b/backend/src/app.controller.ts new file mode 100644 index 0000000..462e62f --- /dev/null +++ b/backend/src/app.controller.ts @@ -0,0 +1,20 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller() +export class AppController { + @Get() + getRoot() { + return { + service: 'ai-manga-backend', + stage: 'stage-03-auth' + }; + } + + @Get('health') + getHealth() { + return { + status: 'ok', + service: 'backend-api' + }; + } +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts new file mode 100644 index 0000000..3b5cdc1 --- /dev/null +++ b/backend/src/app.module.ts @@ -0,0 +1,72 @@ +import { Module } from '@nestjs/common'; +import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core'; +import { AdminModule } from './admin/admin.module'; +import { AppController } from './app.controller'; +import { AssetsModule } from './assets/assets.module'; +import { AuthModule } from './auth/auth.module'; +import { BillingModule } from './billing/billing.module'; +import { CharactersModule } from './characters/characters.module'; +import { ApiCryptoController, ClientConfigController } from './common/api-crypto.controller'; +import { ApiCryptoService } from './common/api-crypto.service'; +import { AllExceptionsFilter } from './common/all-exceptions.filter'; +import { ApiResponseInterceptor } from './common/api-response.interceptor'; +import { EncryptedRequestMiddleware } from './common/encrypted-request.middleware'; +import { RequestIdMiddleware } from './common/request-id.middleware'; +import { SecureTransportMiddleware } from './common/secure-transport.middleware'; +import { EpisodesModule } from './episodes/episodes.module'; +import { ImagesModule } from './images/images.module'; +import { LiveActionModule } from './live-action/live-action.module'; +import { MediaModule } from './media/media.module'; +import { MemoriesModule } from './memories/memories.module'; +import { NovelsModule } from './novels/novels.module'; +import { ProjectsModule } from './projects/projects.module'; +import { ProvidersModule } from './providers/providers.module'; +import { PrismaModule } from './prisma/prisma.module'; +import { QueuesModule } from './queues/queues.module'; +import { ReviewsModule } from './reviews/reviews.module'; +import { ScriptsModule } from './scripts/scripts.module'; +import { StoryBiblesModule } from './story-bibles/story-bibles.module'; +import { UsersModule } from './users/users.module'; + +@Module({ + imports: [ + PrismaModule, + UsersModule, + AuthModule, + BillingModule, + ProjectsModule, + AssetsModule, + NovelsModule, + StoryBiblesModule, + CharactersModule, + MemoriesModule, + EpisodesModule, + ScriptsModule, + QueuesModule, + ProvidersModule, + ReviewsModule, + ImagesModule, + LiveActionModule, + MediaModule, + AdminModule + ], + controllers: [AppController, ApiCryptoController, ClientConfigController], + providers: [ + ApiCryptoService, + { + provide: APP_INTERCEPTOR, + useClass: ApiResponseInterceptor + }, + { + provide: APP_FILTER, + useClass: AllExceptionsFilter + } + ] +}) +export class AppModule { + configure(consumer: import('@nestjs/common').MiddlewareConsumer) { + consumer + .apply(RequestIdMiddleware, SecureTransportMiddleware, EncryptedRequestMiddleware) + .forRoutes('*'); + } +} diff --git a/backend/src/assets/asset.types.ts b/backend/src/assets/asset.types.ts new file mode 100644 index 0000000..09d05c3 --- /dev/null +++ b/backend/src/assets/asset.types.ts @@ -0,0 +1,46 @@ +import type { Asset } from '@prisma/client'; + +export type AssetType = 'novel_text' | 'image' | 'audio' | 'video' | 'document'; + +export interface StoredObject { + file_path: string; + size: bigint; + hash: string; + backend: 'local' | 'minio'; +} + +export interface SafeAsset { + id: string; + user_id: string | null; + project_id: string | null; + asset_type: string; + file_path: string; + mime_type: string | null; + width: number | null; + height: number | null; + duration: string | null; + size: string | null; + hash: string | null; + visibility: string; + status: string; + created_at: string; +} + +export function toSafeAsset(asset: Asset): SafeAsset { + return { + id: asset.id.toString(), + user_id: asset.user_id?.toString() ?? null, + project_id: asset.project_id?.toString() ?? null, + asset_type: asset.asset_type, + file_path: asset.file_path, + mime_type: asset.mime_type, + width: asset.width, + height: asset.height, + duration: asset.duration?.toString() ?? null, + size: asset.size?.toString() ?? null, + hash: asset.hash, + visibility: asset.visibility, + status: asset.status, + created_at: asset.created_at.toISOString() + }; +} diff --git a/backend/src/assets/assets.controller.ts b/backend/src/assets/assets.controller.ts new file mode 100644 index 0000000..688d25b --- /dev/null +++ b/backend/src/assets/assets.controller.ts @@ -0,0 +1,163 @@ +import { + BadRequestException, + Body, + Controller, + Get, + Inject, + Param, + Post, + Req, + Res, + StreamableFile, + UploadedFile, + UseGuards, + UseInterceptors +} from '@nestjs/common'; +import type { Response } from 'express'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { memoryStorage } from 'multer'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import type { AuthRequestUser } from '../auth/auth.types'; +import type { RequestWithApiCrypto } from '../common/api-crypto.service'; +import { AssetsService } from './assets.service'; +import { UploadAssetDto } from './upload.dto'; + +const DEFAULT_MAX_UPLOAD_BYTES = 100 * 1024 * 1024; +const MAX_UPLOAD_BYTES = parseUploadLimitBytes(process.env.MAX_UPLOAD_BYTES, DEFAULT_MAX_UPLOAD_BYTES); + +function parseUploadLimitBytes(value: string | undefined, fallback: number) { + if (!value) return fallback; + + const normalized = value.trim().toLowerCase(); + const match = /^(\d+(?:\.\d+)?)(b|kb|mb|gb)?$/.exec(normalized); + + if (!match) return fallback; + + const numberValue = Number(match[1]); + const unit = match[2] || 'b'; + const multiplier = + unit === 'gb' ? 1024 * 1024 * 1024 : + unit === 'mb' ? 1024 * 1024 : + unit === 'kb' ? 1024 : + 1; + + return Number.isFinite(numberValue) && numberValue > 0 + ? Math.floor(numberValue * multiplier) + : fallback; +} + +@Controller() +@UseGuards(JwtAuthGuard) +export class AssetsController { + constructor(@Inject(AssetsService) private readonly assetsService: AssetsService) {} + + @Post('assets/upload') + @UseInterceptors( + FileInterceptor('file', { + storage: memoryStorage(), + limits: { + fileSize: MAX_UPLOAD_BYTES + } + }) + ) + uploadAsset( + @CurrentUser() user: AuthRequestUser, + @UploadedFile() file: Express.Multer.File, + @Body() dto: UploadAssetDto & Record + ) { + const uploadFile = file ?? this.fileFromEncryptedBody(dto); + + return this.assetsService.uploadAsset( + user, + uploadFile, + dto.asset_type || 'document', + dto.project_id + ); + } + + @Post('projects/:projectId/novel/upload') + @UseInterceptors( + FileInterceptor('file', { + storage: memoryStorage(), + limits: { + fileSize: MAX_UPLOAD_BYTES + } + }) + ) + uploadNovel( + @CurrentUser() user: AuthRequestUser, + @Param('projectId') projectId: string, + @UploadedFile() file: Express.Multer.File, + @Body() body: Record + ) { + return this.assetsService.uploadNovelFile(user, projectId, file ?? this.fileFromEncryptedBody(body)); + } + + @Get('assets/:assetId') + getAsset(@CurrentUser() user: AuthRequestUser, @Param('assetId') assetId: string) { + return this.assetsService.getAssetForUser(user, assetId); + } + + @Get('assets/:assetId/download') + async downloadAsset( + @CurrentUser() user: AuthRequestUser, + @Param('assetId') assetId: string, + @Req() request: RequestWithApiCrypto, + @Res({ passthrough: true }) response: Response + ) { + const result = await this.assetsService.downloadAssetForUser(user, assetId); + + if (request.apiCrypto) { + response.setHeader('Cache-Control', 'private, max-age=0, must-revalidate'); + return { + filename: result.filename, + mime_type: result.asset.mime_type || 'application/octet-stream', + size: result.buffer.length, + content_base64: result.buffer.toString('base64') + }; + } + + response.setHeader('Content-Type', result.asset.mime_type || 'application/octet-stream'); + response.setHeader('Content-Length', result.buffer.length.toString()); + response.setHeader( + 'Content-Disposition', + `attachment; filename="${result.filename.replace(/"/g, '')}"` + ); + response.setHeader('Cache-Control', 'private, max-age=0, must-revalidate'); + + return new StreamableFile(result.buffer); + } + + private fileFromEncryptedBody(body: Record | undefined) { + const filePayload = body?.file; + + if (typeof filePayload !== 'object' || filePayload === null) { + throw new BadRequestException('Uploaded file is required'); + } + + const fileRecord = filePayload as Record; + const originalName = String(fileRecord.original_name || fileRecord.name || 'upload.bin'); + const mimeType = String(fileRecord.mime_type || 'application/octet-stream'); + const contentBase64 = fileRecord.content_base64; + + if (typeof contentBase64 !== 'string') { + throw new BadRequestException('Encrypted uploaded file content is required'); + } + + const buffer = Buffer.from(contentBase64, 'base64'); + + if (buffer.length > MAX_UPLOAD_BYTES) { + throw new BadRequestException('Uploaded file is too large'); + } + + return { + fieldname: 'file', + originalname: originalName, + encoding: '7bit', + mimetype: mimeType, + size: buffer.length, + buffer + } as Express.Multer.File; + } +} diff --git a/backend/src/assets/assets.module.ts b/backend/src/assets/assets.module.ts new file mode 100644 index 0000000..dc71d59 --- /dev/null +++ b/backend/src/assets/assets.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { ProjectsModule } from '../projects/projects.module'; +import { AssetsController } from './assets.controller'; +import { AssetsService } from './assets.service'; +import { PublicTempAssetsController } from './public-temp-assets.controller'; +import { StorageService } from './storage.service'; + +@Module({ + imports: [AuthModule, ProjectsModule], + controllers: [AssetsController, PublicTempAssetsController], + providers: [AssetsService, StorageService], + exports: [AssetsService, StorageService] +}) +export class AssetsModule {} diff --git a/backend/src/assets/assets.service.spec.ts b/backend/src/assets/assets.service.spec.ts new file mode 100644 index 0000000..006db00 --- /dev/null +++ b/backend/src/assets/assets.service.spec.ts @@ -0,0 +1,151 @@ +import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { AssetsService } from './assets.service'; +import type { PrismaService } from '../prisma/prisma.service'; +import type { ProjectsService } from '../projects/projects.service'; +import type { StorageService } from './storage.service'; +import type { AuthRequestUser } from '../auth/auth.types'; + +const user: AuthRequestUser = { + id: '1', + email: 'user@example.com', + role: 'user' +}; + +function createFile(overrides: Partial = {}): Express.Multer.File { + return { + fieldname: 'file', + originalname: 'novel.txt', + encoding: '7bit', + mimetype: 'text/plain', + size: 12, + buffer: Buffer.from('hello novel'), + destination: '', + filename: '', + path: '', + stream: undefined as never, + ...overrides + }; +} + +describe('AssetsService', () => { + let prisma: { + project: { findUnique: ReturnType }; + asset: { create: ReturnType; findUnique: ReturnType }; + }; + let storage: Pick; + let projectsService: Pick; + let service: AssetsService; + + beforeEach(() => { + prisma = { + project: { + findUnique: vi.fn() + }, + asset: { + create: vi.fn(), + findUnique: vi.fn() + } + }; + storage = { + storePrivateFile: vi.fn().mockResolvedValue({ + file_path: 'local://novels/test.txt', + size: 12n, + hash: 'hash', + backend: 'local' + }), + readPrivateFile: vi.fn().mockResolvedValue(Buffer.from('video bytes')) + }; + projectsService = { + assertProjectOwner: vi.fn().mockResolvedValue(100n) + }; + service = new AssetsService( + prisma as unknown as PrismaService, + storage as StorageService, + projectsService as ProjectsService + ); + }); + + it('stores novel uploads as private assets', async () => { + prisma.asset.create.mockResolvedValue({ + id: 200n, + user_id: 1n, + project_id: 100n, + asset_type: 'novel_text', + file_path: 'local://novels/test.txt', + file_url: null, + mime_type: 'text/plain', + width: null, + height: null, + duration: null, + size: 12n, + hash: 'hash', + visibility: 'private', + status: 'active', + created_at: new Date('2026-05-31T00:00:00.000Z') + }); + + const result = await service.uploadNovelFile(user, '100', createFile()); + + expect(storage.storePrivateFile).toHaveBeenCalledWith( + expect.objectContaining({ originalname: 'novel.txt' }), + 'novels' + ); + expect(prisma.asset.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + asset_type: 'novel_text', + visibility: 'private', + file_url: null + }) + }); + expect(result.asset.visibility).toBe('private'); + expect(result.next_step).toBe('copyright_confirm'); + }); + + it('rejects unsupported novel file types', async () => { + await expect( + service.uploadNovelFile( + user, + '100', + createFile({ originalname: 'novel.exe', mimetype: 'application/octet-stream' }) + ) + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('rejects uploads to projects owned by others', async () => { + vi.mocked(projectsService.assertProjectOwner).mockRejectedValue( + new ForbiddenException('Project is private') + ); + + await expect(service.uploadNovelFile(user, '100', createFile())).rejects.toBeInstanceOf( + ForbiddenException + ); + }); + + it('returns a private file buffer for owned assets', async () => { + prisma.asset.findUnique.mockResolvedValue({ + id: 300n, + user_id: 1n, + project_id: 100n, + asset_type: 'video', + file_path: 'local://videos/final.mp4', + file_url: null, + mime_type: 'video/mp4', + width: 1080, + height: 1920, + duration: 4, + size: 11n, + hash: 'video-hash', + visibility: 'private', + status: 'active', + created_at: new Date('2026-05-31T00:00:00.000Z') + }); + + const result = await service.downloadAssetForUser(user, '300'); + + expect(storage.readPrivateFile).toHaveBeenCalledWith('local://videos/final.mp4'); + expect(result.asset.id).toBe('300'); + expect(result.filename).toBe('video-300.mp4'); + expect(result.buffer.toString()).toBe('video bytes'); + }); +}); diff --git a/backend/src/assets/assets.service.ts b/backend/src/assets/assets.service.ts new file mode 100644 index 0000000..8c8486c --- /dev/null +++ b/backend/src/assets/assets.service.ts @@ -0,0 +1,192 @@ +import { + BadRequestException, + Inject, + Injectable, + NotFoundException +} from '@nestjs/common'; +import type { Asset } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import type { AuthRequestUser } from '../auth/auth.types'; +import { ProjectsService } from '../projects/projects.service'; +import { toSafeAsset, type AssetType } from './asset.types'; +import { StorageService } from './storage.service'; + +const ALLOWED_NOVEL_MIME_TYPES = new Set([ + 'text/plain', + 'text/markdown', + 'application/pdf', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/octet-stream' +]); + +@Injectable() +export class AssetsService { + constructor( + @Inject(PrismaService) + private readonly prisma: PrismaService, + @Inject(StorageService) + private readonly storage: StorageService, + @Inject(ProjectsService) + private readonly projectsService: ProjectsService + ) {} + + async uploadAsset( + user: AuthRequestUser, + file: Express.Multer.File, + assetType: AssetType = 'document', + projectId?: string + ) { + const projectBigInt = projectId + ? await this.projectsService.assertProjectOwner(projectId, user) + : null; + const stored = await this.storage.storePrivateFile(file, assetType); + const asset = await this.prisma.asset.create({ + data: { + user_id: BigInt(user.id), + project_id: projectBigInt, + asset_type: assetType, + file_path: stored.file_path, + file_url: null, + mime_type: file.mimetype || null, + size: stored.size, + hash: stored.hash, + visibility: 'private', + status: 'active' + } + }); + + return { + asset: toSafeAsset(asset), + storage_backend: stored.backend + }; + } + + async uploadNovelFile( + user: AuthRequestUser, + projectId: string, + file: Express.Multer.File + ) { + this.validateNovelFile(file); + const projectBigInt = await this.projectsService.assertProjectOwner(projectId, user); + const stored = await this.storage.storePrivateFile(file, 'novels'); + const asset = await this.prisma.asset.create({ + data: { + user_id: BigInt(user.id), + project_id: projectBigInt, + asset_type: 'novel_text', + file_path: stored.file_path, + file_url: null, + mime_type: file.mimetype || 'text/plain', + size: stored.size, + hash: stored.hash, + visibility: 'private', + status: 'active' + } + }); + + return { + asset: toSafeAsset(asset), + storage_backend: stored.backend, + next_step: 'copyright_confirm' + }; + } + + async getAssetForUser(user: AuthRequestUser, assetId: string) { + const asset = await this.findAssetForUser(user, assetId); + return toSafeAsset(asset); + } + + async downloadAssetForUser(user: AuthRequestUser, assetId: string) { + const asset = await this.findAssetForUser(user, assetId); + const buffer = await this.storage.readPrivateFile(asset.file_path); + + return { + asset: toSafeAsset(asset), + buffer, + filename: this.buildDownloadFilename(asset) + }; + } + + private async findAssetForUser(user: AuthRequestUser, assetId: string) { + const asset = await this.prisma.asset.findUnique({ + where: { id: this.parseId(assetId) } + }); + + if (!asset) { + throw new NotFoundException('Asset not found'); + } + + if (asset.user_id?.toString() !== user.id && user.role !== 'admin') { + throw new NotFoundException('Asset not found'); + } + + return asset; + } + + private buildDownloadFilename(asset: Asset) { + const extension = this.extensionFromMime(asset.mime_type) || this.extensionFromPath(asset.file_path); + const safeType = asset.asset_type.replace(/[^a-z0-9_-]/gi, '_') || 'asset'; + + return `${safeType}-${asset.id.toString()}${extension}`; + } + + private extensionFromPath(filePath: string) { + const match = /\.([a-z0-9]+)$/i.exec(filePath); + return match ? `.${match[1].toLowerCase()}` : ''; + } + + private validateNovelFile(file: Express.Multer.File) { + if (!file) { + throw new BadRequestException('Novel file is required'); + } + + const lowerName = file.originalname.toLowerCase(); + const hasAllowedExtension = + lowerName.endsWith('.txt') || + lowerName.endsWith('.md') || + lowerName.endsWith('.docx') || + lowerName.endsWith('.pdf'); + + if (!hasAllowedExtension || !ALLOWED_NOVEL_MIME_TYPES.has(file.mimetype)) { + throw new BadRequestException('Only txt, md, docx, and text pdf novel files are supported now'); + } + } + + private parseId(id: string) { + try { + return BigInt(id); + } catch { + throw new BadRequestException('Invalid id'); + } + } + + private extensionFromMime(mimeType: string | null | undefined) { + switch (mimeType) { + case 'video/mp4': + return '.mp4'; + case 'audio/wav': + case 'audio/x-wav': + return '.wav'; + case 'audio/mpeg': + return '.mp3'; + case 'application/x-subrip': + return '.srt'; + case 'image/svg+xml': + return '.svg'; + case 'image/png': + return '.png'; + case 'image/jpeg': + return '.jpg'; + case 'text/plain': + return '.txt'; + case 'text/markdown': + return '.md'; + case 'application/pdf': + return '.pdf'; + case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': + return '.docx'; + default: + return ''; + } + } +} diff --git a/backend/src/assets/public-temp-assets.controller.ts b/backend/src/assets/public-temp-assets.controller.ts new file mode 100644 index 0000000..4c51bd9 --- /dev/null +++ b/backend/src/assets/public-temp-assets.controller.ts @@ -0,0 +1,24 @@ +import { Controller, Get, Inject, Param, Res, StreamableFile } from '@nestjs/common'; +import type { Response } from 'express'; +import { StorageService } from './storage.service'; + +@Controller('public-temp-assets') +export class PublicTempAssetsController { + constructor(@Inject(StorageService) private readonly storage: StorageService) {} + + @Get(':token') + async downloadTemporaryAsset( + @Param('token') token: string, + @Res({ passthrough: true }) response: Response + ) { + const result = await this.storage.readTemporaryPublicFile(token); + + response.setHeader('Content-Type', result.mimeType); + response.setHeader('Content-Length', result.buffer.length.toString()); + response.setHeader('Content-Disposition', 'inline'); + response.setHeader('Cache-Control', 'private, max-age=0, no-store'); + response.setHeader('X-Content-Type-Options', 'nosniff'); + + return new StreamableFile(result.buffer); + } +} diff --git a/backend/src/assets/storage.service.spec.ts b/backend/src/assets/storage.service.spec.ts new file mode 100644 index 0000000..2b9c904 --- /dev/null +++ b/backend/src/assets/storage.service.spec.ts @@ -0,0 +1,43 @@ +import { BadRequestException } from '@nestjs/common'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { StorageService } from './storage.service'; + +describe('StorageService temporary public URLs', () => { + afterEach(() => { + vi.restoreAllMocks(); + delete process.env.PUBLIC_ASSET_BASE_URL; + delete process.env.PUBLIC_ASSET_SIGNING_SECRET; + }); + + it('creates a signed temporary URL and reads the private object through the token', async () => { + process.env.PUBLIC_ASSET_BASE_URL = 'https://api.example.com'; + process.env.PUBLIC_ASSET_SIGNING_SECRET = 'test-public-asset-secret'; + const service = new StorageService(); + const readSpy = vi.spyOn(service, 'readPrivateFile').mockResolvedValue(Buffer.from('video-bytes')); + + const url = service.createTemporaryPublicUrl({ + filePath: 'local://live-action-video-clips/source.mp4', + mimeType: 'video/mp4', + expiresInSeconds: 600 + }); + const token = new URL(url).pathname.split('/').pop() || ''; + const result = await service.readTemporaryPublicFile(decodeURIComponent(token)); + + expect(url).toMatch(/^https:\/\/api\.example\.com\/api\/public-temp-assets\//); + expect(readSpy).toHaveBeenCalledWith('local://live-action-video-clips/source.mp4'); + expect(result.mimeType).toBe('video/mp4'); + expect(result.buffer.toString()).toBe('video-bytes'); + }); + + it('requires a public base URL before minting temporary links', () => { + process.env.PUBLIC_ASSET_SIGNING_SECRET = 'test-public-asset-secret'; + const service = new StorageService(); + + expect(() => + service.createTemporaryPublicUrl({ + filePath: 'local://live-action-video-clips/source.mp4', + mimeType: 'video/mp4' + }) + ).toThrow(BadRequestException); + }); +}); diff --git a/backend/src/assets/storage.service.ts b/backend/src/assets/storage.service.ts new file mode 100644 index 0000000..fe02322 --- /dev/null +++ b/backend/src/assets/storage.service.ts @@ -0,0 +1,284 @@ +import { createHash, createHmac, randomUUID, timingSafeEqual } from 'node:crypto'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, extname, isAbsolute, join, resolve } from 'node:path'; +import type { Readable } from 'node:stream'; +import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common'; +import { Client } from 'minio'; +import type { StoredObject } from './asset.types'; + +type TemporaryPublicFilePayload = { + file_path: string; + mime_type: string; + expires_at: number; + nonce: string; +}; + +@Injectable() +export class StorageService { + private readonly root = this.resolveLocalRoot(process.env.LOCAL_STORAGE_ROOT || '../storage'); + private readonly privateBucket = + process.env.MINIO_BUCKET_PRIVATE || 'ai-manga-private'; + + async storePrivateFile(file: Express.Multer.File, prefix: string): Promise { + if (!file?.buffer?.length) { + throw new BadRequestException('Uploaded file is empty'); + } + + if (this.shouldUseMinio()) { + return this.storeWithMinio(file, prefix); + } + + return this.storeLocally(file, prefix); + } + + async readPrivateFile(filePath: string): Promise { + if (filePath.startsWith('local://')) { + return this.readLocalObject(filePath); + } + + if (filePath.startsWith('minio://')) { + return this.readMinioObject(filePath); + } + + throw new BadRequestException('Unsupported storage path'); + } + + createTemporaryPublicUrl(input: { + filePath: string; + mimeType?: string | null; + expiresInSeconds?: number | null; + }) { + const baseUrl = this.resolvePublicAssetBaseUrl(); + const expiresInSeconds = this.normalizeTemporaryUrlExpires(input.expiresInSeconds); + const payload: TemporaryPublicFilePayload = { + file_path: input.filePath, + mime_type: input.mimeType || 'application/octet-stream', + expires_at: Math.floor(Date.now() / 1000) + expiresInSeconds, + nonce: randomUUID() + }; + const payloadPart = this.base64UrlEncode(Buffer.from(JSON.stringify(payload), 'utf8')); + const signature = this.signTemporaryPublicPayload(payloadPart); + const token = `${payloadPart}.${signature}`; + + return `${baseUrl}/public-temp-assets/${encodeURIComponent(token)}`; + } + + async readTemporaryPublicFile(token: string) { + const payload = this.verifyTemporaryPublicToken(token); + + return { + buffer: await this.readPrivateFile(payload.file_path), + mimeType: payload.mime_type || 'application/octet-stream', + filePath: payload.file_path, + expiresAt: payload.expires_at + }; + } + + private async storeLocally( + file: Express.Multer.File, + prefix: string + ): Promise { + const safePrefix = prefix.replace(/[^a-z0-9/_-]/gi, '_'); + const extension = extname(file.originalname || '') || this.extensionFromMime(file.mimetype); + const hash = createHash('sha256').update(file.buffer).digest('hex'); + const objectName = `${safePrefix}/${new Date().toISOString().slice(0, 10)}/${randomUUID()}${extension}`; + const fullPath = join(this.root, 'private', objectName); + + await mkdir(join(this.root, 'private', safePrefix), { recursive: true }); + await mkdir(dirname(fullPath), { recursive: true }); + await writeFile(fullPath, file.buffer); + + return { + file_path: `local://${objectName}`, + size: BigInt(file.size), + hash, + backend: 'local' + }; + } + + private async storeWithMinio( + file: Express.Multer.File, + prefix: string + ): Promise { + const client = new Client({ + endPoint: process.env.MINIO_ENDPOINT || '127.0.0.1', + port: Number(process.env.MINIO_PORT || 9000), + useSSL: process.env.MINIO_USE_SSL === 'true', + accessKey: process.env.MINIO_ACCESS_KEY || '', + secretKey: process.env.MINIO_SECRET_KEY || '' + }); + const exists = await client.bucketExists(this.privateBucket).catch(() => false); + + if (!exists) { + await client.makeBucket(this.privateBucket); + } + + const extension = extname(file.originalname || '') || this.extensionFromMime(file.mimetype); + const hash = createHash('sha256').update(file.buffer).digest('hex'); + const objectName = `${prefix}/${new Date().toISOString().slice(0, 10)}/${randomUUID()}${extension}`; + + await client.putObject(this.privateBucket, objectName, file.buffer, file.size, { + 'Content-Type': file.mimetype + }); + + return { + file_path: `minio://${this.privateBucket}/${objectName}`, + size: BigInt(file.size), + hash, + backend: 'minio' + }; + } + + private async readLocalObject(filePath: string) { + const objectName = filePath.replace(/^local:\/\//, ''); + if (!objectName || objectName.includes('..')) { + throw new BadRequestException('Invalid local storage path'); + } + + return readFile(join(this.root, 'private', objectName)); + } + + private async readMinioObject(filePath: string) { + const match = /^minio:\/\/([^/]+)\/(.+)$/.exec(filePath); + if (!match) { + throw new BadRequestException('Invalid MinIO storage path'); + } + + const [, bucket, objectName] = match; + const client = new Client({ + endPoint: process.env.MINIO_ENDPOINT || '127.0.0.1', + port: Number(process.env.MINIO_PORT || 9000), + useSSL: process.env.MINIO_USE_SSL === 'true', + accessKey: process.env.MINIO_ACCESS_KEY || '', + secretKey: process.env.MINIO_SECRET_KEY || '' + }); + const stream = await client.getObject(bucket, objectName); + return this.streamToBuffer(stream); + } + + private async streamToBuffer(stream: Readable) { + const chunks: Buffer[] = []; + + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + + return Buffer.concat(chunks); + } + + private shouldUseMinio() { + return process.env.STORAGE_DRIVER === 'minio'; + } + + private resolvePublicAssetBaseUrl() { + const configured = + process.env.PUBLIC_ASSET_BASE_URL || + process.env.PUBLIC_API_BASE_URL || + process.env.API_PUBLIC_BASE_URL || + process.env.APP_PUBLIC_URL || + process.env.PUBLIC_BASE_URL || + ''; + const normalized = configured.trim().replace(/\/+$/, ''); + + if (!/^https?:\/\//i.test(normalized)) { + throw new BadRequestException('PUBLIC_ASSET_BASE_URL_REQUIRED'); + } + + return normalized.endsWith('/api') ? normalized : `${normalized}/api`; + } + + private normalizeTemporaryUrlExpires(value: number | null | undefined) { + const numeric = Number(value ?? process.env.PUBLIC_ASSET_URL_EXPIRES_SECONDS ?? 3600); + + if (!Number.isFinite(numeric)) return 3600; + + return Math.min(Math.max(Math.round(numeric), 60), 24 * 60 * 60); + } + + private verifyTemporaryPublicToken(token: string): TemporaryPublicFilePayload { + const [payloadPart, signature] = String(token || '').split('.'); + + if (!payloadPart || !signature) { + throw new BadRequestException('INVALID_TEMP_PUBLIC_ASSET_TOKEN'); + } + + const expected = this.signTemporaryPublicPayload(payloadPart); + + if (!this.safeEqualBase64Url(signature, expected)) { + throw new ForbiddenException('INVALID_TEMP_PUBLIC_ASSET_SIGNATURE'); + } + + let payload: TemporaryPublicFilePayload; + + try { + payload = JSON.parse(Buffer.from(payloadPart, 'base64url').toString('utf8')) as TemporaryPublicFilePayload; + } catch { + throw new BadRequestException('INVALID_TEMP_PUBLIC_ASSET_PAYLOAD'); + } + + if (!payload.file_path || typeof payload.file_path !== 'string') { + throw new BadRequestException('INVALID_TEMP_PUBLIC_ASSET_PATH'); + } + if (!Number.isFinite(payload.expires_at) || payload.expires_at < Math.floor(Date.now() / 1000)) { + throw new ForbiddenException('TEMP_PUBLIC_ASSET_EXPIRED'); + } + + return payload; + } + + private signTemporaryPublicPayload(payloadPart: string) { + return this.base64UrlEncode( + createHmac('sha256', this.resolveTemporaryPublicAssetSecret()) + .update(payloadPart) + .digest() + ); + } + + private resolveTemporaryPublicAssetSecret() { + const secret = + process.env.PUBLIC_ASSET_SIGNING_SECRET || + process.env.TEMP_PUBLIC_ASSET_SECRET || + process.env.JWT_SECRET || + ''; + + if (!secret || secret.length < 16) { + throw new BadRequestException('PUBLIC_ASSET_SIGNING_SECRET_REQUIRED'); + } + + return secret; + } + + private safeEqualBase64Url(left: string, right: string) { + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + + return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer); + } + + private base64UrlEncode(buffer: Buffer) { + return buffer.toString('base64url'); + } + + private resolveLocalRoot(root: string) { + if (isAbsolute(root)) return root; + + // Resolve relative storage roots from the backend package directory so + // starting the server from repo root or backend/ cannot split local files. + return resolve(__dirname, '../..', root); + } + + private extensionFromMime(mimeType: string | undefined) { + switch (mimeType) { + case 'text/plain': + return '.txt'; + case 'text/markdown': + return '.md'; + case 'application/pdf': + return '.pdf'; + case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': + return '.docx'; + default: + return ''; + } + } +} diff --git a/backend/src/assets/upload.dto.ts b/backend/src/assets/upload.dto.ts new file mode 100644 index 0000000..c0e95b8 --- /dev/null +++ b/backend/src/assets/upload.dto.ts @@ -0,0 +1,6 @@ +import type { AssetType } from './asset.types'; + +export class UploadAssetDto { + asset_type?: AssetType; + project_id?: string; +} diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts new file mode 100644 index 0000000..4988e3f --- /dev/null +++ b/backend/src/auth/auth.controller.ts @@ -0,0 +1,44 @@ +import { Body, Controller, Get, Inject, Post, UseGuards } from '@nestjs/common'; +import { AuthService } from './auth.service'; +import { CurrentUser } from './current-user.decorator'; +import { LoginDto, RegisterDto } from './auth.dto'; +import { JwtAuthGuard } from './jwt-auth.guard'; +import type { AuthRequestUser } from './auth.types'; + +@Controller('auth') +export class AuthController { + constructor(@Inject(AuthService) private readonly authService: AuthService) {} + + @Post('register') + register(@Body() dto: RegisterDto) { + return this.authService.register(dto); + } + + @Post('login') + login(@Body() dto: LoginDto) { + return this.authService.login(dto); + } + + @Post('logout') + @UseGuards(JwtAuthGuard) + logout() { + return { logged_out: true }; + } + + @Get('profile') + @UseGuards(JwtAuthGuard) + profile(@CurrentUser() user: AuthRequestUser) { + return this.authService.getProfile(user); + } +} + +@Controller() +export class ProfileController { + constructor(@Inject(AuthService) private readonly authService: AuthService) {} + + @Get('profile') + @UseGuards(JwtAuthGuard) + profile(@CurrentUser() user: AuthRequestUser) { + return this.authService.getProfile(user); + } +} diff --git a/backend/src/auth/auth.dto.ts b/backend/src/auth/auth.dto.ts new file mode 100644 index 0000000..1f68964 --- /dev/null +++ b/backend/src/auth/auth.dto.ts @@ -0,0 +1,10 @@ +export class RegisterDto { + email?: string; + password?: string; + nickname?: string; +} + +export class LoginDto { + email?: string; + password?: string; +} diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts new file mode 100644 index 0000000..39559a3 --- /dev/null +++ b/backend/src/auth/auth.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; +import { JwtModule, type JwtSignOptions } from '@nestjs/jwt'; +import { UsersModule } from '../users/users.module'; +import { AuthController, ProfileController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { JwtAuthGuard } from './jwt-auth.guard'; + +const jwtExpiresIn = (process.env.JWT_EXPIRES_IN ?? '7d') as JwtSignOptions['expiresIn']; + +@Module({ + imports: [ + UsersModule, + JwtModule.register({ + secret: process.env.JWT_SECRET ?? 'dev_only_change_me', + signOptions: { + expiresIn: jwtExpiresIn + } + }) + ], + controllers: [AuthController, ProfileController], + providers: [AuthService, JwtAuthGuard], + exports: [AuthService, JwtAuthGuard, JwtModule] +}) +export class AuthModule {} diff --git a/backend/src/auth/auth.service.spec.ts b/backend/src/auth/auth.service.spec.ts new file mode 100644 index 0000000..3bd4325 --- /dev/null +++ b/backend/src/auth/auth.service.spec.ts @@ -0,0 +1,129 @@ +import { ConflictException, UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { AuthService } from './auth.service'; +import type { SafeUser } from '../users/user.types'; +import type { UsersService } from '../users/users.service'; + +const safeUser: SafeUser = { + id: '1', + email: 'user@example.com', + phone: null, + nickname: 'User', + avatar_url: null, + role: 'user', + status: 'active', + wechat_openid: null, + created_at: '2026-05-31T00:00:00.000Z' +}; + +function createPrismaUser(passwordHash: string) { + return { + id: 1n, + email: 'user@example.com', + phone: null, + password_hash: passwordHash, + nickname: 'User', + avatar_url: null, + role: 'user', + status: 'active', + wechat_openid: null, + last_login_at: null, + created_at: new Date('2026-05-31T00:00:00.000Z'), + updated_at: new Date('2026-05-31T00:00:00.000Z') + }; +} + +describe('AuthService', () => { + let usersService: Pick< + UsersService, + 'findByEmail' | 'findById' | 'createUser' | 'toSafeUser' + >; + let jwtService: Pick; + let service: AuthService; + + beforeEach(() => { + usersService = { + findByEmail: vi.fn(), + findById: vi.fn(), + createUser: vi.fn(), + toSafeUser: vi.fn() + }; + jwtService = { + sign: vi.fn(() => 'signed.jwt.token') + }; + service = new AuthService(usersService as UsersService, jwtService as JwtService); + }); + + it('registers an active user and returns a token', async () => { + vi.mocked(usersService.findByEmail).mockResolvedValue(null); + vi.mocked(usersService.createUser).mockResolvedValue(safeUser); + + const result = await service.register({ + email: ' USER@example.com ', + password: 'password123', + nickname: 'User' + }); + + expect(usersService.findByEmail).toHaveBeenCalledWith('user@example.com'); + expect(usersService.createUser).toHaveBeenCalledWith( + expect.objectContaining({ + email: 'user@example.com', + nickname: 'User' + }) + ); + expect(result).toMatchObject({ + access_token: 'signed.jwt.token', + token_type: 'Bearer', + user: safeUser + }); + }); + + it('rejects duplicate email registration', async () => { + vi.mocked(usersService.findByEmail).mockResolvedValue(createPrismaUser('hash')); + + await expect( + service.register({ + email: 'user@example.com', + password: 'password123' + }) + ).rejects.toBeInstanceOf(ConflictException); + }); + + it('logs in with a valid password', async () => { + vi.mocked(usersService.findByEmail).mockResolvedValue(null); + vi.mocked(usersService.createUser).mockResolvedValue(safeUser); + + const registered = await service.register({ + email: 'user@example.com', + password: 'password123' + }); + const passwordHash = vi.mocked(usersService.createUser).mock.calls[0]?.[0] + .password_hash; + + expect(registered.access_token).toBe('signed.jwt.token'); + + vi.mocked(usersService.findByEmail).mockResolvedValue( + createPrismaUser(passwordHash) + ); + vi.mocked(usersService.toSafeUser).mockReturnValue(safeUser); + + const result = await service.login({ + email: 'user@example.com', + password: 'password123' + }); + + expect(result.user).toEqual(safeUser); + }); + + it('rejects invalid login credentials', async () => { + vi.mocked(usersService.findByEmail).mockResolvedValue(null); + + await expect( + service.login({ + email: 'user@example.com', + password: 'password123' + }) + ).rejects.toBeInstanceOf(UnauthorizedException); + }); +}); diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts new file mode 100644 index 0000000..2425046 --- /dev/null +++ b/backend/src/auth/auth.service.ts @@ -0,0 +1,110 @@ +import { + BadRequestException, + ConflictException, + Inject, + Injectable, + UnauthorizedException +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { compare, hash } from 'bcryptjs'; +import { UsersService } from '../users/users.service'; +import type { LoginDto, RegisterDto } from './auth.dto'; +import type { AuthResult, AuthRequestUser, JwtPayload } from './auth.types'; + +const PASSWORD_MIN_LENGTH = 8; + +@Injectable() +export class AuthService { + constructor( + @Inject(UsersService) + private readonly usersService: UsersService, + @Inject(JwtService) + private readonly jwtService: JwtService + ) {} + + async register(dto: RegisterDto): Promise { + const email = this.normalizeEmail(dto.email); + const password = this.validatePassword(dto.password); + const existing = await this.usersService.findByEmail(email); + + if (existing) { + throw new ConflictException('Email already registered'); + } + + const passwordHash = await hash(password, 12); + const user = await this.usersService.createUser({ + email, + password_hash: passwordHash, + nickname: this.normalizeOptionalText(dto.nickname) + }); + + return this.createAuthResult(user); + } + + async login(dto: LoginDto): Promise { + const email = this.normalizeEmail(dto.email); + const password = this.validatePassword(dto.password); + const user = await this.usersService.findByEmail(email); + + if (!user || user.status !== 'active') { + throw new UnauthorizedException('Invalid email or password'); + } + + const passwordMatches = await compare(password, user.password_hash); + if (!passwordMatches) { + throw new UnauthorizedException('Invalid email or password'); + } + + return this.createAuthResult(this.usersService.toSafeUser(user)); + } + + async getProfile(currentUser: AuthRequestUser) { + const user = await this.usersService.findById(currentUser.id); + + if (!user || user.status !== 'active') { + throw new UnauthorizedException('User is unavailable'); + } + + return this.usersService.toSafeUser(user); + } + + private createAuthResult(user: AuthResult['user']): AuthResult { + const payload: JwtPayload = { + sub: user.id, + email: user.email, + role: user.role + }; + + return { + access_token: this.jwtService.sign(payload), + token_type: 'Bearer', + expires_in: process.env.JWT_EXPIRES_IN ?? '7d', + user + }; + } + + private normalizeEmail(email: string | undefined) { + const value = email?.trim().toLowerCase(); + + if (!value || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { + throw new BadRequestException('Valid email is required'); + } + + return value; + } + + private validatePassword(password: string | undefined) { + if (!password || password.length < PASSWORD_MIN_LENGTH) { + throw new BadRequestException( + `Password must be at least ${PASSWORD_MIN_LENGTH} characters` + ); + } + + return password; + } + + private normalizeOptionalText(value: string | undefined) { + const normalized = value?.trim(); + return normalized || undefined; + } +} diff --git a/backend/src/auth/auth.types.ts b/backend/src/auth/auth.types.ts new file mode 100644 index 0000000..5e9ead7 --- /dev/null +++ b/backend/src/auth/auth.types.ts @@ -0,0 +1,20 @@ +import type { SafeUser } from '../users/user.types'; + +export interface JwtPayload { + sub: string; + email: string | null; + role: string; +} + +export interface AuthRequestUser { + id: string; + email: string | null; + role: string; +} + +export interface AuthResult { + access_token: string; + token_type: 'Bearer'; + expires_in: string; + user: SafeUser; +} diff --git a/backend/src/auth/current-user.decorator.ts b/backend/src/auth/current-user.decorator.ts new file mode 100644 index 0000000..163060b --- /dev/null +++ b/backend/src/auth/current-user.decorator.ts @@ -0,0 +1,13 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import type { AuthRequestUser } from './auth.types'; + +interface RequestWithUser { + user?: AuthRequestUser; +} + +export const CurrentUser = createParamDecorator( + (_data: unknown, context: ExecutionContext) => { + const request = context.switchToHttp().getRequest(); + return request.user; + } +); diff --git a/backend/src/auth/jwt-auth.guard.spec.ts b/backend/src/auth/jwt-auth.guard.spec.ts new file mode 100644 index 0000000..26938ce --- /dev/null +++ b/backend/src/auth/jwt-auth.guard.spec.ts @@ -0,0 +1,51 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { describe, expect, it, vi } from 'vitest'; +import { JwtAuthGuard } from './jwt-auth.guard'; + +function createContext(headers: Record) { + const request = { headers }; + + return { + request, + context: { + switchToHttp: () => ({ + getRequest: () => request + }) + } + }; +} + +describe('JwtAuthGuard', () => { + it('attaches user payload for valid bearer tokens', async () => { + const jwtService = { + verifyAsync: vi.fn().mockResolvedValue({ + sub: '1', + email: 'user@example.com', + role: 'user' + }) + }; + const guard = new JwtAuthGuard(jwtService as unknown as JwtService); + const { context, request } = createContext({ + authorization: 'Bearer valid-token' + }); + + await expect(guard.canActivate(context as never)).resolves.toBe(true); + expect(request).toMatchObject({ + user: { + id: '1', + email: 'user@example.com', + role: 'user' + } + }); + }); + + it('rejects missing bearer tokens', async () => { + const guard = new JwtAuthGuard({ verifyAsync: vi.fn() } as unknown as JwtService); + const { context } = createContext({}); + + await expect(guard.canActivate(context as never)).rejects.toBeInstanceOf( + UnauthorizedException + ); + }); +}); diff --git a/backend/src/auth/jwt-auth.guard.ts b/backend/src/auth/jwt-auth.guard.ts new file mode 100644 index 0000000..3179b57 --- /dev/null +++ b/backend/src/auth/jwt-auth.guard.ts @@ -0,0 +1,51 @@ +import { + CanActivate, + ExecutionContext, + Inject, + Injectable, + UnauthorizedException +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import type { AuthRequestUser, JwtPayload } from './auth.types'; + +interface AuthenticatedRequest { + headers: Record; + user?: AuthRequestUser; +} + +@Injectable() +export class JwtAuthGuard implements CanActivate { + constructor(@Inject(JwtService) private readonly jwtService: JwtService) {} + + async canActivate(context: ExecutionContext) { + const request = context.switchToHttp().getRequest(); + const token = this.extractToken(request.headers.authorization); + + if (!token) { + throw new UnauthorizedException('Missing bearer token'); + } + + try { + const payload = await this.jwtService.verifyAsync(token); + request.user = { + id: payload.sub, + email: payload.email, + role: payload.role + }; + return true; + } catch { + throw new UnauthorizedException('Invalid or expired token'); + } + } + + private extractToken(authorization: string | string[] | undefined) { + const header = Array.isArray(authorization) ? authorization[0] : authorization; + + if (!header) { + return null; + } + + const [type, token] = header.split(' '); + return type?.toLowerCase() === 'bearer' && token ? token : null; + } +} diff --git a/backend/src/auth/rbac.ts b/backend/src/auth/rbac.ts new file mode 100644 index 0000000..9ba916f --- /dev/null +++ b/backend/src/auth/rbac.ts @@ -0,0 +1,79 @@ +import { ForbiddenException } from '@nestjs/common'; +import type { AuthRequestUser } from './auth.types'; + +export const ADMIN_PERMISSIONS = [ + 'admin:read', + 'projects:write', + 'users:read', + 'users:write', + 'billing:read', + 'billing:write', + 'reviews:read', + 'reviews:write', + 'tasks:read', + 'tasks:write', + 'providers:read', + 'providers:write', + 'costs:read', + 'settings:read', + 'settings:write', + 'audit:read', + 'audit:export' +] as const; + +export type AdminPermission = (typeof ADMIN_PERMISSIONS)[number]; + +const ROLE_PERMISSIONS: Record = { + admin: '*', + operator: [ + 'admin:read', + 'projects:write', + 'users:read', + 'billing:read', + 'reviews:read', + 'reviews:write', + 'tasks:read', + 'tasks:write', + 'providers:read', + 'costs:read', + 'audit:read' + ], + finance: [ + 'admin:read', + 'users:read', + 'billing:read', + 'billing:write', + 'tasks:read', + 'costs:read', + 'audit:read', + 'audit:export' + ], + auditor: [ + 'admin:read', + 'users:read', + 'billing:read', + 'reviews:read', + 'tasks:read', + 'providers:read', + 'costs:read', + 'settings:read', + 'audit:read', + 'audit:export' + ] +}; + +export function permissionsForRole(role: string) { + const permissions = ROLE_PERMISSIONS[role]; + + return permissions === '*' ? [...ADMIN_PERMISSIONS] : [...(permissions ?? [])]; +} + +export function hasPermission(user: AuthRequestUser, permission: AdminPermission) { + return permissionsForRole(user.role).includes(permission); +} + +export function assertPermission(user: AuthRequestUser, permission: AdminPermission) { + if (!hasPermission(user, permission)) { + throw new ForbiddenException(`Permission required: ${permission}`); + } +} diff --git a/backend/src/billing/billing.controller.ts b/backend/src/billing/billing.controller.ts new file mode 100644 index 0000000..5ccbfc8 --- /dev/null +++ b/backend/src/billing/billing.controller.ts @@ -0,0 +1,122 @@ +import { Body, Controller, Get, Inject, Param, Post, Query, UseGuards } from '@nestjs/common'; +import type { AuthRequestUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { + AdminAdjustQuotaDto, + AdminGrantQuotaDto, + AdminListQuotaAccountsQueryDto, + CreateOrderDto, + FreezeProjectQuotaDto, + ListOrdersQueryDto, + ListQuotaLogsQueryDto, + ReleaseProjectQuotaDto +} from './billing.dto'; +import { BillingService } from './billing.service'; + +@Controller() +export class BillingController { + constructor(@Inject(BillingService) private readonly billingService: BillingService) {} + + @Get('billing/packages') + listPackages() { + return this.billingService.listPackages(); + } + + @Get('billing/quota') + @UseGuards(JwtAuthGuard) + getQuota(@CurrentUser() user: AuthRequestUser) { + return this.billingService.getQuotaAccount(user); + } + + @Get('billing/quota/logs') + @UseGuards(JwtAuthGuard) + listQuotaLogs( + @CurrentUser() user: AuthRequestUser, + @Query() query: ListQuotaLogsQueryDto + ) { + return this.billingService.listQuotaLogs(user, query); + } + + @Get('billing/orders') + @UseGuards(JwtAuthGuard) + listOrders(@CurrentUser() user: AuthRequestUser, @Query() query: ListOrdersQueryDto) { + return this.billingService.listMyOrders(user, query); + } + + @Post('billing/orders') + @UseGuards(JwtAuthGuard) + createOrder(@CurrentUser() user: AuthRequestUser, @Body() dto: CreateOrderDto) { + return this.billingService.createOrder(user, dto); + } + + @Post('billing/orders/:orderId/mock-pay') + @UseGuards(JwtAuthGuard) + mockPayOrder(@CurrentUser() user: AuthRequestUser, @Param('orderId') orderId: string) { + return this.billingService.mockPayOrder(user, orderId); + } + + @Get('projects/:projectId/quota/estimate') + @UseGuards(JwtAuthGuard) + estimateProjectQuota( + @CurrentUser() user: AuthRequestUser, + @Param('projectId') projectId: string + ) { + return this.billingService.estimateProjectQuota(user, projectId); + } + + @Post('projects/:projectId/quota/freeze') + @UseGuards(JwtAuthGuard) + freezeProjectQuota( + @CurrentUser() user: AuthRequestUser, + @Param('projectId') projectId: string, + @Body() dto: FreezeProjectQuotaDto + ) { + return this.billingService.freezeProjectQuota(user, projectId, dto); + } + + @Post('projects/:projectId/quota/release') + @UseGuards(JwtAuthGuard) + releaseProjectQuota( + @CurrentUser() user: AuthRequestUser, + @Param('projectId') projectId: string, + @Body() dto: ReleaseProjectQuotaDto + ) { + return this.billingService.releaseProjectQuota(user, projectId, dto); + } + + @Get('admin/orders') + @UseGuards(JwtAuthGuard) + listAdminOrders(@CurrentUser() user: AuthRequestUser, @Query() query: ListOrdersQueryDto) { + return this.billingService.listAdminOrders(user, query); + } + + @Get('admin/quota-accounts') + @UseGuards(JwtAuthGuard) + listAdminQuotaAccounts( + @CurrentUser() user: AuthRequestUser, + @Query() query: AdminListQuotaAccountsQueryDto + ) { + return this.billingService.listAdminQuotaAccounts(user, query); + } + + @Post('admin/users/:userId/quota/grant') + @UseGuards(JwtAuthGuard) + adminGrantQuota( + @CurrentUser() user: AuthRequestUser, + @Param('userId') userId: string, + @Body() dto: AdminGrantQuotaDto + ) { + return this.billingService.adminGrantQuota(user, userId, dto); + } + + @Post('admin/users/:userId/quota/adjust') + @UseGuards(JwtAuthGuard) + adminAdjustQuota( + @CurrentUser() user: AuthRequestUser, + @Param('userId') userId: string, + @Body() dto: AdminAdjustQuotaDto + ) { + return this.billingService.adminAdjustQuota(user, userId, dto); + } +} diff --git a/backend/src/billing/billing.dto.ts b/backend/src/billing/billing.dto.ts new file mode 100644 index 0000000..a2dd5f8 --- /dev/null +++ b/backend/src/billing/billing.dto.ts @@ -0,0 +1,41 @@ +export class CreateOrderDto { + package_code?: string; + project_id?: string; + payment_method?: string; +} + +export class ListOrdersQueryDto { + payment_status?: string; + limit?: string; +} + +export class ListQuotaLogsQueryDto { + project_id?: string; + change_type?: string; + limit?: string; +} + +export class FreezeProjectQuotaDto { + amount?: number; + reason?: string; +} + +export class ReleaseProjectQuotaDto { + reason?: string; +} + +export class AdminGrantQuotaDto { + amount?: number; + reason?: string; +} + +export class AdminAdjustQuotaDto { + delta?: number; + reason?: string; +} + +export class AdminListQuotaAccountsQueryDto { + user_id?: string; + status?: string; + limit?: string; +} diff --git a/backend/src/billing/billing.module.ts b/backend/src/billing/billing.module.ts new file mode 100644 index 0000000..e039bb8 --- /dev/null +++ b/backend/src/billing/billing.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { PrismaModule } from '../prisma/prisma.module'; +import { BillingController } from './billing.controller'; +import { BillingService } from './billing.service'; + +@Module({ + imports: [AuthModule, PrismaModule], + controllers: [BillingController], + providers: [BillingService], + exports: [BillingService] +}) +export class BillingModule {} diff --git a/backend/src/billing/billing.service.spec.ts b/backend/src/billing/billing.service.spec.ts new file mode 100644 index 0000000..b5d891c --- /dev/null +++ b/backend/src/billing/billing.service.spec.ts @@ -0,0 +1,278 @@ +import { BadRequestException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AuthRequestUser } from '../auth/auth.types'; +import type { PrismaService } from '../prisma/prisma.service'; +import { BillingService } from './billing.service'; + +const now = new Date('2026-05-31T00:00:00.000Z'); +const user: AuthRequestUser = { + id: '1', + email: 'user@example.com', + role: 'user' +}; +const admin: AuthRequestUser = { + id: '9', + email: 'admin@example.com', + role: 'admin' +}; + +function createProject(overrides: Record = {}) { + return { + id: 10n, + user_id: 1n, + title: '额度项目', + input_mode: 'ai_original', + genre: 'urban', + style_code: 'korean_comic', + output_type: 'short_video', + output_mode: 'image_manga', + visual_mode: 'korean_manga', + video_generation_level: 'standard', + target_episode_count: 1, + episode_duration: 60, + status: 'storyboard_confirmed', + copyright_status: 'ai_original', + payment_status: 'unpaid', + quality_level: 'mvp', + is_long_series: false, + created_at: now, + updated_at: now, + completed_at: null, + ...overrides + }; +} + +function createAccount(overrides: Record = {}) { + return { + id: 20n, + user_id: 1n, + total_quota: new Prisma.Decimal(120), + available_quota: new Prisma.Decimal(120), + frozen_quota: new Prisma.Decimal(0), + used_quota: new Prisma.Decimal(0), + status: 'active', + created_at: now, + updated_at: now, + ...overrides + }; +} + +function createOrder(overrides: Record = {}) { + return { + id: 30n, + user_id: 1n, + project_id: null, + order_no: 'ORDTEST', + package_code: 'standard_3ep', + amount: new Prisma.Decimal(199), + currency: 'CNY', + payment_method: 'mock_pay', + payment_status: 'pending', + paid_at: null, + created_at: now, + updated_at: now, + ...overrides + }; +} + +function createLog(overrides: Record = {}) { + return { + id: 40n, + user_id: 1n, + project_id: 10n, + task_id: null, + change_type: 'freeze', + amount: new Prisma.Decimal(71), + balance_after: new Prisma.Decimal(49), + reason: 'project_generation_freeze', + metadata_json: {}, + created_at: now, + ...overrides + }; +} + +describe('BillingService', () => { + let prisma: any; + let service: BillingService; + + beforeEach(() => { + prisma = { + $transaction: vi.fn((handler) => handler(prisma)), + project: { + findUnique: vi.fn().mockResolvedValue(createProject()), + update: vi.fn(async ({ data }: { data: Record }) => + createProject(data) + ) + }, + user: { + findUnique: vi.fn().mockResolvedValue({ id: 1n }) + }, + order: { + create: vi.fn().mockResolvedValue(createOrder()), + findUnique: vi.fn().mockResolvedValue(createOrder()), + findMany: vi.fn().mockResolvedValue([createOrder()]), + update: vi.fn(async ({ data }: { data: Record }) => + createOrder(data) + ) + }, + quotaAccount: { + upsert: vi.fn().mockResolvedValue(createAccount()), + findMany: vi.fn().mockResolvedValue([createAccount()]), + update: vi.fn(async ({ data }: { data: Record }) => + createAccount(data) + ) + }, + quotaLog: { + create: vi.fn().mockResolvedValue(createLog()), + findMany: vi.fn().mockResolvedValue([createLog()]) + }, + operationLog: { + create: vi.fn().mockResolvedValue({ + id: 50n, + user_id: 9n, + operator_role: 'admin', + action: 'admin_adjust_quota', + target_type: 'user', + target_id: 1n, + ip: null, + user_agent: null, + metadata_json: {}, + created_at: now + }) + } + }; + service = new BillingService(prisma as PrismaService); + }); + + it('lists available billing packages', () => { + const result = service.listPackages(); + + expect(result.packages.some((pkg) => pkg.code === 'standard_3ep')).toBe(true); + }); + + it('creates a pending order for a package', async () => { + const result = await service.createOrder(user, { package_code: 'standard_3ep' }); + + expect(prisma.order.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + user_id: 1n, + package_code: 'standard_3ep', + payment_status: 'pending' + }) + }); + expect(result.order.payment_status).toBe('pending'); + }); + + it('mock pays an order and recharges quota', async () => { + const result = await service.mockPayOrder(user, '30'); + + expect(prisma.quotaAccount.update).toHaveBeenCalledWith({ + where: { user_id: 1n }, + data: expect.objectContaining({ + total_quota: expect.any(Prisma.Decimal), + available_quota: expect.any(Prisma.Decimal) + }) + }); + expect(prisma.quotaLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + change_type: 'recharge' + }) + }); + expect(result.order.payment_status).toBe('paid'); + }); + + it('freezes project quota and marks project payment as frozen', async () => { + const result = await service.freezeProjectQuota(user, '10'); + + expect(prisma.quotaAccount.update).toHaveBeenCalledWith({ + where: { user_id: 1n }, + data: expect.objectContaining({ + available_quota: expect.any(Prisma.Decimal), + frozen_quota: expect.any(Prisma.Decimal) + }) + }); + expect(prisma.project.update).toHaveBeenCalledWith({ + where: { id: 10n }, + data: { payment_status: 'quota_frozen' } + }); + expect(result.project_payment_status).toBe('quota_frozen'); + }); + + it('rejects freezing when available quota is insufficient', async () => { + prisma.quotaAccount.upsert.mockResolvedValue( + createAccount({ available_quota: new Prisma.Decimal(1) }) + ); + + await expect(service.freezeProjectQuota(user, '10')).rejects.toBeInstanceOf( + BadRequestException + ); + }); + + it('rejects formal render when project quota is not frozen', async () => { + await expect(service.ensureProjectQuotaReserved(createProject())).rejects.toBeInstanceOf( + BadRequestException + ); + }); + + it('admin grants quota and writes an operation log', async () => { + const result = await service.adminGrantQuota(admin, '1', { + amount: 20, + reason: 'internal test' + }); + + expect(result.account.available_quota).toBe(140); + expect(prisma.quotaLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + change_type: 'admin_grant', + amount: expect.any(Prisma.Decimal), + reason: 'internal test' + }) + }); + expect(prisma.operationLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + action: 'admin_grant_quota', + target_type: 'user', + target_id: 1n + }) + }); + }); + + it('admin deducts quota through correction and preserves audit logs', async () => { + const result = await service.adminAdjustQuota(admin, '1', { + delta: -10, + reason: 'wrong manual grant' + }); + const updateData = prisma.quotaAccount.update.mock.calls[0][0].data; + + expect(updateData.total_quota.toString()).toBe('110'); + expect(updateData.available_quota.toString()).toBe('110'); + expect(result.account.available_quota).toBe(110); + expect(prisma.quotaLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + change_type: 'admin_correction_deduct', + amount: expect.any(Prisma.Decimal), + reason: 'wrong manual grant' + }) + }); + expect(prisma.operationLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + action: 'admin_adjust_quota', + metadata_json: expect.objectContaining({ + delta: -10, + reason: 'wrong manual grant' + }) + }) + }); + }); + + it('rejects admin quota deduction when available quota is insufficient', async () => { + prisma.quotaAccount.upsert.mockResolvedValue( + createAccount({ available_quota: new Prisma.Decimal(1) }) + ); + + await expect( + service.adminAdjustQuota(admin, '1', { delta: -10, reason: 'correction' }) + ).rejects.toBeInstanceOf(BadRequestException); + }); +}); diff --git a/backend/src/billing/billing.service.ts b/backend/src/billing/billing.service.ts new file mode 100644 index 0000000..7fe5571 --- /dev/null +++ b/backend/src/billing/billing.service.ts @@ -0,0 +1,749 @@ +import { + BadRequestException, + ForbiddenException, + Inject, + Injectable, + NotFoundException +} from '@nestjs/common'; +import { Prisma, type Project } from '@prisma/client'; +import { randomUUID } from 'node:crypto'; +import type { AuthRequestUser } from '../auth/auth.types'; +import { assertPermission } from '../auth/rbac'; +import { PrismaService } from '../prisma/prisma.service'; +import { + AdminAdjustQuotaDto, + AdminGrantQuotaDto, + AdminListQuotaAccountsQueryDto, + CreateOrderDto, + FreezeProjectQuotaDto, + ListOrdersQueryDto, + ListQuotaLogsQueryDto, + ReleaseProjectQuotaDto +} from './billing.dto'; +import { + BILLING_PACKAGES, + toSafeOrder, + toSafeQuotaAccount, + toSafeQuotaLog +} from './billing.types'; + +const DEFAULT_SHOTS_PER_EPISODE = 6; +const QUOTA_COSTS = { + source: 8, + story_bible: 6, + characters: 8, + character_images: 8, + memory: 4, + episode_plan_per_episode: 3, + script_per_episode: 3, + storyboard_per_episode: 4, + shot_image: 2, + audio_per_episode: 2, + subtitle_per_episode: 1, + video_per_episode: 6 +} as const; + +@Injectable() +export class BillingService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + listPackages() { + return { packages: BILLING_PACKAGES }; + } + + async getQuotaAccount(user: AuthRequestUser) { + const account = await this.ensureQuotaAccount(BigInt(user.id)); + return toSafeQuotaAccount(account); + } + + async listMyOrders(user: AuthRequestUser, query: ListOrdersQueryDto) { + const limit = this.normalizePositiveInt(query.limit, 'limit', 1, 100, 50); + const orders = await this.prisma.order.findMany({ + where: { + user_id: BigInt(user.id), + ...(query.payment_status ? { payment_status: this.normalizeText(query.payment_status, 50) } : {}) + }, + orderBy: { created_at: 'desc' }, + take: limit + }); + + return { orders: orders.map(toSafeOrder), total: orders.length, limit }; + } + + async listQuotaLogs(user: AuthRequestUser, query: ListQuotaLogsQueryDto) { + const limit = this.normalizePositiveInt(query.limit, 'limit', 1, 100, 50); + const where: Prisma.QuotaLogWhereInput = { user_id: BigInt(user.id) }; + + if (query.project_id) { + const project = await this.findProjectForUser(query.project_id, user); + where.project_id = project.id; + } + if (query.change_type) { + where.change_type = this.normalizeText(query.change_type, 50); + } + + const logs = await this.prisma.quotaLog.findMany({ + where, + orderBy: { created_at: 'desc' }, + take: limit + }); + + return { logs: logs.map(toSafeQuotaLog), total: logs.length, limit }; + } + + async createOrder(user: AuthRequestUser, dto: CreateOrderDto) { + const pkg = this.findPackage(dto.package_code); + const projectId = dto.project_id ? (await this.findProjectForUser(dto.project_id, user)).id : null; + const order = await this.prisma.order.create({ + data: { + user_id: BigInt(user.id), + project_id: projectId, + order_no: this.createOrderNo(), + package_code: pkg.code, + amount: pkg.amount, + currency: pkg.currency, + payment_method: this.normalizeText(dto.payment_method ?? 'mock_pay', 50), + payment_status: 'pending' + } + }); + + return { + order: toSafeOrder(order), + package: pkg, + next_step: 'mock_pay' + }; + } + + async mockPayOrder(user: AuthRequestUser, orderId: string) { + const order = await this.prisma.order.findUnique({ + where: { id: this.parseId(orderId, 'Invalid order id') } + }); + + if (!order || order.user_id.toString() !== user.id) { + throw new NotFoundException('Order not found'); + } + + const pkg = this.findPackage(order.package_code ?? undefined); + + if (order.payment_status === 'paid') { + const account = await this.ensureQuotaAccount(order.user_id); + return { + order: toSafeOrder(order), + account: toSafeQuotaAccount(account), + package: pkg, + reused: true + }; + } + + if (order.payment_status !== 'pending') { + throw new BadRequestException('Only pending orders can be paid in mock mode'); + } + + const paidAt = new Date(); + const result = await this.prisma.$transaction(async (tx) => { + const account = await this.ensureQuotaAccountTx(tx, order.user_id); + const quota = new Prisma.Decimal(pkg.quota_amount); + const updatedAccount = await tx.quotaAccount.update({ + where: { user_id: order.user_id }, + data: { + total_quota: account.total_quota.plus(quota), + available_quota: account.available_quota.plus(quota) + } + }); + const updatedOrder = await tx.order.update({ + where: { id: order.id }, + data: { + payment_status: 'paid', + paid_at: paidAt + } + }); + const log = await tx.quotaLog.create({ + data: { + user_id: order.user_id, + project_id: order.project_id, + change_type: 'recharge', + amount: quota, + balance_after: updatedAccount.available_quota, + reason: `mock_pay:${pkg.code}`, + metadata_json: { + order_id: order.id.toString(), + order_no: order.order_no, + package_code: pkg.code + } + } + }); + + return { account: updatedAccount, order: updatedOrder, log }; + }); + + return { + order: toSafeOrder(result.order), + account: toSafeQuotaAccount(result.account), + log: toSafeQuotaLog(result.log), + package: pkg + }; + } + + async estimateProjectQuota(user: AuthRequestUser, projectId: string) { + const project = await this.findProjectForUser(projectId, user); + return this.createProjectEstimate(project); + } + + async freezeProjectQuota( + user: AuthRequestUser, + projectId: string, + dto: FreezeProjectQuotaDto = {} + ) { + const project = await this.findProjectForUser(projectId, user); + const estimate = this.createProjectEstimate(project); + const requested = dto.amount ? this.normalizeQuotaAmount(dto.amount, 'amount') : estimate.total_quota; + + if (project.payment_status === 'paid') { + const account = await this.ensureQuotaAccount(project.user_id); + return { + account: toSafeQuotaAccount(account), + estimate, + amount: 0, + project_payment_status: 'paid', + reused: true + }; + } + + if (project.payment_status === 'quota_frozen') { + const account = await this.ensureQuotaAccount(project.user_id); + return { + account: toSafeQuotaAccount(account), + estimate, + amount: requested, + project_payment_status: 'quota_frozen', + reused: true + }; + } + + const result = await this.prisma.$transaction(async (tx) => { + const account = await this.ensureQuotaAccountTx(tx, project.user_id); + const amount = new Prisma.Decimal(requested); + + if (account.available_quota.lessThan(amount)) { + throw new BadRequestException('Insufficient quota'); + } + + const updatedAccount = await tx.quotaAccount.update({ + where: { user_id: project.user_id }, + data: { + available_quota: account.available_quota.minus(amount), + frozen_quota: account.frozen_quota.plus(amount) + } + }); + const updatedProject = await tx.project.update({ + where: { id: project.id }, + data: { payment_status: 'quota_frozen' } + }); + const log = await tx.quotaLog.create({ + data: { + user_id: project.user_id, + project_id: project.id, + change_type: 'freeze', + amount, + balance_after: updatedAccount.available_quota, + reason: this.normalizeText(dto.reason ?? 'project_generation_freeze', 255), + metadata_json: { + project_id: project.id.toString(), + estimate + } + } + }); + + return { account: updatedAccount, project: updatedProject, log, amount }; + }); + + return { + account: toSafeQuotaAccount(result.account), + log: toSafeQuotaLog(result.log), + estimate, + amount: Number(result.amount.toString()), + project_payment_status: result.project.payment_status + }; + } + + async releaseProjectQuota( + user: AuthRequestUser, + projectId: string, + dto: ReleaseProjectQuotaDto = {} + ) { + const project = await this.findProjectForUser(projectId, user); + + if (project.payment_status !== 'quota_frozen') { + const account = await this.ensureQuotaAccount(project.user_id); + return { + account: toSafeQuotaAccount(account), + amount: 0, + project_payment_status: project.payment_status, + reused: true + }; + } + + const estimate = this.createProjectEstimate(project); + const result = await this.prisma.$transaction(async (tx) => { + const account = await this.ensureQuotaAccountTx(tx, project.user_id); + const estimateAmount = new Prisma.Decimal(estimate.total_quota); + const amount = account.frozen_quota.lessThan(estimateAmount) + ? account.frozen_quota + : estimateAmount; + const updatedAccount = await tx.quotaAccount.update({ + where: { user_id: project.user_id }, + data: { + available_quota: account.available_quota.plus(amount), + frozen_quota: account.frozen_quota.minus(amount) + } + }); + const updatedProject = await tx.project.update({ + where: { id: project.id }, + data: { payment_status: 'unpaid' } + }); + const log = await tx.quotaLog.create({ + data: { + user_id: project.user_id, + project_id: project.id, + change_type: 'release', + amount, + balance_after: updatedAccount.available_quota, + reason: this.normalizeText(dto.reason ?? 'project_generation_release', 255), + metadata_json: { + project_id: project.id.toString(), + estimate + } + } + }); + + return { account: updatedAccount, project: updatedProject, log, amount }; + }); + + return { + account: toSafeQuotaAccount(result.account), + log: toSafeQuotaLog(result.log), + amount: Number(result.amount.toString()), + project_payment_status: result.project.payment_status + }; + } + + async ensureProjectQuotaReserved(project: Project) { + if (project.payment_status === 'paid') { + return; + } + + if (project.payment_status !== 'quota_frozen') { + throw new BadRequestException('Project quota must be frozen before formal video render'); + } + + const account = await this.ensureQuotaAccount(project.user_id); + const estimate = this.createProjectEstimate(project); + + if (account.frozen_quota.lessThan(new Prisma.Decimal(estimate.total_quota))) { + throw new BadRequestException('Frozen quota is insufficient for this project'); + } + } + + async deductReservedProjectQuota(project: Project, taskId: bigint, reason = 'video_render_success') { + if (project.payment_status === 'paid') { + return null; + } + + if (project.payment_status !== 'quota_frozen') { + throw new BadRequestException('Project quota is not frozen'); + } + + const estimate = this.createProjectEstimate(project); + const result = await this.prisma.$transaction(async (tx) => { + const account = await this.ensureQuotaAccountTx(tx, project.user_id); + const amount = new Prisma.Decimal(estimate.total_quota); + + if (account.frozen_quota.lessThan(amount)) { + throw new BadRequestException('Frozen quota is insufficient for deduction'); + } + + const updatedAccount = await tx.quotaAccount.update({ + where: { user_id: project.user_id }, + data: { + frozen_quota: account.frozen_quota.minus(amount), + used_quota: account.used_quota.plus(amount) + } + }); + const log = await tx.quotaLog.create({ + data: { + user_id: project.user_id, + project_id: project.id, + task_id: taskId, + change_type: 'deduct', + amount, + balance_after: updatedAccount.available_quota, + reason, + metadata_json: { + project_id: project.id.toString(), + estimate + } + } + }); + const updatedProject = await tx.project.update({ + where: { id: project.id }, + data: { payment_status: 'paid' } + }); + + return { account: updatedAccount, log, project: updatedProject }; + }); + + return { + account: toSafeQuotaAccount(result.account), + log: toSafeQuotaLog(result.log), + project_payment_status: result.project.payment_status + }; + } + + async listAdminOrders(user: AuthRequestUser, query: ListOrdersQueryDto) { + assertPermission(user, 'billing:read'); + const limit = this.normalizePositiveInt(query.limit, 'limit', 1, 100, 50); + const orders = await this.prisma.order.findMany({ + where: query.payment_status + ? { payment_status: this.normalizeText(query.payment_status, 50) } + : {}, + orderBy: { created_at: 'desc' }, + take: limit + }); + + return { orders: orders.map(toSafeOrder), total: orders.length, limit }; + } + + async listAdminQuotaAccounts(user: AuthRequestUser, query: AdminListQuotaAccountsQueryDto) { + assertPermission(user, 'billing:read'); + const limit = this.normalizePositiveInt(query.limit, 'limit', 1, 100, 50); + const where: Prisma.QuotaAccountWhereInput = {}; + + if (query.user_id) { + where.user_id = this.parseId(query.user_id, 'Invalid user_id'); + } + if (query.status) { + where.status = this.normalizeText(query.status, 50); + } + + const accounts = await this.prisma.quotaAccount.findMany({ + where, + orderBy: { updated_at: 'desc' }, + take: limit + }); + + return { accounts: accounts.map(toSafeQuotaAccount), total: accounts.length, limit }; + } + + async adminGrantQuota(user: AuthRequestUser, targetUserId: string, dto: AdminGrantQuotaDto) { + assertPermission(user, 'billing:write'); + const userId = this.parseId(targetUserId, 'Invalid user id'); + const target = await this.prisma.user.findUnique({ where: { id: userId } }); + + if (!target) { + throw new NotFoundException('User not found'); + } + + const amount = this.normalizeQuotaAmount(dto.amount, 'amount'); + const result = await this.prisma.$transaction(async (tx) => { + const account = await this.ensureQuotaAccountTx(tx, userId); + const quota = new Prisma.Decimal(amount); + const updatedAccount = await tx.quotaAccount.update({ + where: { user_id: userId }, + data: { + total_quota: account.total_quota.plus(quota), + available_quota: account.available_quota.plus(quota) + } + }); + const log = await tx.quotaLog.create({ + data: { + user_id: userId, + change_type: 'admin_grant', + amount: quota, + balance_after: updatedAccount.available_quota, + reason: this.normalizeText(dto.reason ?? 'admin_quota_grant', 255), + metadata_json: { + operator_id: user.id + } + } + }); + await tx.operationLog.create({ + data: { + user_id: this.parseId(user.id, 'Invalid user id'), + operator_role: user.role, + action: 'admin_grant_quota', + target_type: 'user', + target_id: userId, + metadata_json: { + amount, + reason: this.normalizeText(dto.reason ?? 'admin_quota_grant', 255) + } + } + }); + + return { account: updatedAccount, log }; + }); + + return { + account: toSafeQuotaAccount(result.account), + log: toSafeQuotaLog(result.log) + }; + } + + async adminAdjustQuota(user: AuthRequestUser, targetUserId: string, dto: AdminAdjustQuotaDto) { + assertPermission(user, 'billing:write'); + const userId = this.parseId(targetUserId, 'Invalid user id'); + const target = await this.prisma.user.findUnique({ where: { id: userId } }); + + if (!target) { + throw new NotFoundException('User not found'); + } + + const delta = this.normalizeQuotaDelta(dto.delta, 'delta'); + const reason = this.normalizeText(dto.reason, 255); + const result = await this.prisma.$transaction(async (tx) => { + const account = await this.ensureQuotaAccountTx(tx, userId); + const amount = new Prisma.Decimal(Math.abs(delta)); + const isAddition = delta > 0; + + if (!isAddition && account.available_quota.lessThan(amount)) { + throw new BadRequestException('Available quota is insufficient for adjustment'); + } + + const updatedAccount = await tx.quotaAccount.update({ + where: { user_id: userId }, + data: isAddition + ? { + total_quota: account.total_quota.plus(amount), + available_quota: account.available_quota.plus(amount) + } + : { + total_quota: account.total_quota.minus(amount), + available_quota: account.available_quota.minus(amount) + } + }); + const log = await tx.quotaLog.create({ + data: { + user_id: userId, + change_type: isAddition ? 'admin_correction_add' : 'admin_correction_deduct', + amount, + balance_after: updatedAccount.available_quota, + reason, + metadata_json: { + operator_id: user.id, + delta + } + } + }); + await tx.operationLog.create({ + data: { + user_id: this.parseId(user.id, 'Invalid user id'), + operator_role: user.role, + action: 'admin_adjust_quota', + target_type: 'user', + target_id: userId, + metadata_json: { + delta, + reason + } + } + }); + + return { account: updatedAccount, log }; + }); + + return { + account: toSafeQuotaAccount(result.account), + log: toSafeQuotaLog(result.log) + }; + } + + private createProjectEstimate(project: Project) { + const episodeCount = this.normalizeEpisodeCount(project.target_episode_count ?? 1); + const shotCount = episodeCount * DEFAULT_SHOTS_PER_EPISODE; + const inputModeCost = project.input_mode === 'upload' ? QUOTA_COSTS.source : QUOTA_COSTS.source + 4; + const breakdown = [ + { key: 'source', label: project.input_mode === 'upload' ? '上传小说解析' : 'AI 原创小说', quota: inputModeCost }, + { key: 'story_bible', label: '故事圣经', quota: QUOTA_COSTS.story_bible }, + { key: 'characters', label: '角色圣经', quota: QUOTA_COSTS.characters }, + { key: 'character_images', label: '角色锚点图', quota: QUOTA_COSTS.character_images }, + { key: 'memory', label: '长篇记忆', quota: QUOTA_COSTS.memory }, + { + key: 'episodes', + label: `分集计划 ${episodeCount} 集`, + quota: episodeCount * QUOTA_COSTS.episode_plan_per_episode + }, + { + key: 'scripts', + label: `单集脚本 ${episodeCount} 集`, + quota: episodeCount * QUOTA_COSTS.script_per_episode + }, + { + key: 'storyboards', + label: `分镜 ${episodeCount} 集`, + quota: episodeCount * QUOTA_COSTS.storyboard_per_episode + }, + { + key: 'shot_images', + label: `正式分镜图约 ${shotCount} 张`, + quota: shotCount * QUOTA_COSTS.shot_image + }, + { + key: 'audio', + label: `TTS ${episodeCount} 集`, + quota: episodeCount * QUOTA_COSTS.audio_per_episode + }, + { + key: 'subtitle', + label: `字幕 ${episodeCount} 集`, + quota: episodeCount * QUOTA_COSTS.subtitle_per_episode + }, + { + key: 'video', + label: `视频合成 ${episodeCount} 集`, + quota: episodeCount * QUOTA_COSTS.video_per_episode + } + ]; + const total = breakdown.reduce((sum, item) => sum + item.quota, 0); + + return { + project_id: project.id.toString(), + input_mode: project.input_mode, + target_episode_count: episodeCount, + estimated_shot_count: shotCount, + total_quota: total, + breakdown + }; + } + + private findPackage(packageCode?: string) { + const pkg = BILLING_PACKAGES.find((item) => item.code === packageCode); + + if (!pkg) { + throw new BadRequestException('Invalid package_code'); + } + + return pkg; + } + + private async ensureQuotaAccount(userId: bigint) { + return this.prisma.quotaAccount.upsert({ + where: { user_id: userId }, + update: {}, + create: { + user_id: userId, + total_quota: 0, + available_quota: 0, + frozen_quota: 0, + used_quota: 0, + status: 'active' + } + }); + } + + private async ensureQuotaAccountTx(tx: Prisma.TransactionClient, userId: bigint) { + return tx.quotaAccount.upsert({ + where: { user_id: userId }, + update: {}, + create: { + user_id: userId, + total_quota: 0, + available_quota: 0, + frozen_quota: 0, + used_quota: 0, + status: 'active' + } + }); + } + + private async findProjectForUser(projectId: string, user: AuthRequestUser) { + const project = await this.prisma.project.findUnique({ + where: { id: this.parseId(projectId, 'Invalid project id') } + }); + + if (!project) { + throw new NotFoundException('Project not found'); + } + if (project.user_id.toString() !== user.id && user.role !== 'admin') { + throw new ForbiddenException('Project is private'); + } + + return project; + } + + private createOrderNo() { + const timestamp = new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14); + return `ORD${timestamp}${randomUUID().replace(/-/g, '').slice(0, 10).toUpperCase()}`; + } + + private normalizeEpisodeCount(value: number) { + if (!Number.isInteger(value) || value < 1) return 1; + return Math.min(value, 100); + } + + private normalizePositiveInt( + value: unknown, + field: string, + min: number, + max: number, + fallback: number + ) { + if (value === undefined || value === null || value === '') return fallback; + const numberValue = Number(value); + + if (!Number.isInteger(numberValue) || numberValue < min || numberValue > max) { + throw new BadRequestException(`${field} must be an integer between ${min} and ${max}`); + } + + return numberValue; + } + + private normalizeQuotaAmount(value: unknown, field: string) { + const numberValue = Number(value); + + if (!Number.isFinite(numberValue) || numberValue <= 0) { + throw new BadRequestException(`${field} must be a positive number`); + } + + return Number(numberValue.toFixed(2)); + } + + private normalizeQuotaDelta(value: unknown, field: string) { + const numberValue = Number(value); + + if (!Number.isFinite(numberValue) || numberValue === 0) { + throw new BadRequestException(`${field} must be a non-zero number`); + } + if (Math.abs(numberValue) > 1000000) { + throw new BadRequestException(`${field} must not exceed 1000000`); + } + + return Number(numberValue.toFixed(2)); + } + + private normalizeText(value: string | undefined, maxLength: number) { + const normalized = value?.trim(); + + if (!normalized) { + throw new BadRequestException('Text value is required'); + } + if (normalized.length > maxLength) { + throw new BadRequestException(`Text value must be at most ${maxLength} characters`); + } + + return normalized; + } + + private parseId(id: string, message: string) { + try { + return BigInt(id); + } catch { + throw new BadRequestException(message); + } + } + + private assertAdmin(user: AuthRequestUser) { + if (user.role !== 'admin') { + throw new ForbiddenException('Admin role required'); + } + } +} diff --git a/backend/src/billing/billing.types.ts b/backend/src/billing/billing.types.ts new file mode 100644 index 0000000..a953a27 --- /dev/null +++ b/backend/src/billing/billing.types.ts @@ -0,0 +1,143 @@ +import type { Order, QuotaAccount, QuotaLog } from '@prisma/client'; + +export interface BillingPackage { + code: string; + name: string; + description: string; + amount: number; + currency: 'CNY'; + quota_amount: number; + included_episodes: number; + features: string[]; + recommended?: boolean; +} + +export const BILLING_PACKAGES: BillingPackage[] = [ + { + code: 'trial_1ep', + name: '试用版', + description: '适合验证 1 集基础漫剧流程。', + amount: 0, + currency: 'CNY', + quota_amount: 35, + included_episodes: 1, + features: ['1 集', '低清预览', '内部测试授权'] + }, + { + code: 'standard_3ep', + name: '标准短剧版', + description: '适合 3 集 MVP 短剧闭环。', + amount: 199, + currency: 'CNY', + quota_amount: 120, + included_episodes: 3, + recommended: true, + features: ['3 集', '正式 MP4', '1 次小改额度'] + }, + { + code: 'serial_10ep', + name: '连载测试版', + description: '适合 10 集以内连载测试。', + amount: 599, + currency: 'CNY', + quota_amount: 420, + included_episodes: 10, + features: ['10 集', '批量生成', '人工审核入口'] + }, + { + code: 'custom_20ep', + name: '高端定制版', + description: '适合 20 集以上定制项目。', + amount: 1999, + currency: 'CNY', + quota_amount: 1200, + included_episodes: 20, + features: ['20 集以上', '角色精修', '关键镜头动态预留'] + } +]; + +export interface SafeQuotaAccount { + id: string; + user_id: string; + total_quota: number; + available_quota: number; + frozen_quota: number; + used_quota: number; + status: string; + created_at: string; + updated_at: string; +} + +export interface SafeQuotaLog { + id: string; + user_id: string; + project_id: string | null; + task_id: string | null; + change_type: string; + amount: number; + balance_after: number | null; + reason: string | null; + metadata_json: unknown; + created_at: string; +} + +export interface SafeOrder { + id: string; + user_id: string; + project_id: string | null; + order_no: string; + package_code: string | null; + amount: number; + currency: string; + payment_method: string | null; + payment_status: string; + paid_at: string | null; + created_at: string; + updated_at: string; +} + +export function toSafeQuotaAccount(account: QuotaAccount): SafeQuotaAccount { + return { + id: account.id.toString(), + user_id: account.user_id.toString(), + total_quota: Number(account.total_quota.toString()), + available_quota: Number(account.available_quota.toString()), + frozen_quota: Number(account.frozen_quota.toString()), + used_quota: Number(account.used_quota.toString()), + status: account.status, + created_at: account.created_at.toISOString(), + updated_at: account.updated_at.toISOString() + }; +} + +export function toSafeQuotaLog(log: QuotaLog): SafeQuotaLog { + return { + id: log.id.toString(), + user_id: log.user_id.toString(), + project_id: log.project_id?.toString() ?? null, + task_id: log.task_id?.toString() ?? null, + change_type: log.change_type, + amount: Number(log.amount.toString()), + balance_after: log.balance_after ? Number(log.balance_after.toString()) : null, + reason: log.reason, + metadata_json: log.metadata_json, + created_at: log.created_at.toISOString() + }; +} + +export function toSafeOrder(order: Order): SafeOrder { + return { + id: order.id.toString(), + user_id: order.user_id.toString(), + project_id: order.project_id?.toString() ?? null, + order_no: order.order_no, + package_code: order.package_code, + amount: Number(order.amount.toString()), + currency: order.currency, + payment_method: order.payment_method, + payment_status: order.payment_status, + paid_at: order.paid_at?.toISOString() ?? null, + created_at: order.created_at.toISOString(), + updated_at: order.updated_at.toISOString() + }; +} diff --git a/backend/src/characters/character.dto.ts b/backend/src/characters/character.dto.ts new file mode 100644 index 0000000..e412381 --- /dev/null +++ b/backend/src/characters/character.dto.ts @@ -0,0 +1,38 @@ +import type { CharacterRoleType, CharacterStatus } from './character.types'; + +export class ExtractCharactersDto { + story_bible_id?: string; +} + +export class CreateCharacterDto { + global_character_id?: string; + name?: string; + alias_names?: string[]; + role_type?: CharacterRoleType; + gender_label?: string; + age_group?: string; + identity_desc?: string; + appearance_desc?: string; + face_desc?: string; + hair_desc?: string; + eye_desc?: string; + body_desc?: string; + costume_rules?: string; + special_props?: string; + personality_desc?: string; + speech_style?: string; + relationship_desc?: string; + character_arc?: string; + negative_rules?: string; + wardrobe_variant?: string; + voice_provider_code?: string; + voice_model?: string; + voice_id?: string; + voice_style?: string; + performance_style?: string; + importance_level?: number; +} + +export class UpdateCharacterDto extends CreateCharacterDto { + status?: CharacterStatus; +} diff --git a/backend/src/characters/character.types.ts b/backend/src/characters/character.types.ts new file mode 100644 index 0000000..0efd84b --- /dev/null +++ b/backend/src/characters/character.types.ts @@ -0,0 +1,162 @@ +import type { Character, GlobalCharacter, Prisma } from '@prisma/client'; + +export const CHARACTER_ROLE_TYPES = [ + 'protagonist', + 'lead', + 'supporting', + 'antagonist', + 'minor' +] as const; + +export const CHARACTER_STATUSES = [ + 'draft', + 'generated', + 'edited', + 'locked', + 'deleted' +] as const; + +export type CharacterRoleType = (typeof CHARACTER_ROLE_TYPES)[number]; +export type CharacterStatus = (typeof CHARACTER_STATUSES)[number]; + +export interface SafeCharacter { + id: string; + project_id: string; + global_character_id: string | null; + name: string; + alias_names: Prisma.JsonValue | null; + role_type: string; + gender_label: string | null; + age_group: string | null; + identity_desc: string | null; + appearance_desc: string | null; + face_desc: string | null; + hair_desc: string | null; + eye_desc: string | null; + body_desc: string | null; + costume_rules: string | null; + special_props: string | null; + personality_desc: string | null; + speech_style: string | null; + relationship_desc: string | null; + character_arc: string | null; + negative_rules: string | null; + anchor_asset_id: string | null; + wardrobe_variant: string | null; + voice_provider_code: string | null; + voice_model: string | null; + voice_id: string | null; + voice_style: string | null; + performance_style: string | null; + importance_level: number; + status: string; + created_at: string; + updated_at: string; +} + +export interface SafeGlobalCharacter { + id: string; + name: string; + display_name: string | null; + role_archetype: string; + gender_label: string | null; + age_group: string | null; + identity_desc: string | null; + appearance_desc: string | null; + face_desc: string | null; + hair_desc: string | null; + eye_desc: string | null; + body_desc: string | null; + default_costume_rules: string | null; + wardrobe_json: Prisma.JsonValue | null; + special_props: string | null; + personality_desc: string | null; + speech_style: string | null; + voice_provider_code: string | null; + voice_model: string | null; + voice_id: string | null; + voice_style: string | null; + performance_style: string | null; + negative_rules: string | null; + anchor_asset_id: string | null; + voice_sample_asset_id: string | null; + commercial_status: string; + usage_scope: string; + status: string; + created_by_user_id: string | null; + created_at: string; + updated_at: string; +} + +export function toSafeCharacter(character: Character): SafeCharacter { + return { + id: character.id.toString(), + project_id: character.project_id.toString(), + global_character_id: character.global_character_id?.toString() ?? null, + name: character.name, + alias_names: character.alias_names, + role_type: character.role_type, + gender_label: character.gender_label, + age_group: character.age_group, + identity_desc: character.identity_desc, + appearance_desc: character.appearance_desc, + face_desc: character.face_desc, + hair_desc: character.hair_desc, + eye_desc: character.eye_desc, + body_desc: character.body_desc, + costume_rules: character.costume_rules, + special_props: character.special_props, + personality_desc: character.personality_desc, + speech_style: character.speech_style, + relationship_desc: character.relationship_desc, + character_arc: character.character_arc, + negative_rules: character.negative_rules, + anchor_asset_id: character.anchor_asset_id?.toString() ?? null, + wardrobe_variant: character.wardrobe_variant, + voice_provider_code: character.voice_provider_code, + voice_model: character.voice_model, + voice_id: character.voice_id, + voice_style: character.voice_style, + performance_style: character.performance_style, + importance_level: character.importance_level, + status: character.status, + created_at: character.created_at.toISOString(), + updated_at: character.updated_at.toISOString() + }; +} + +export function toSafeGlobalCharacter(character: GlobalCharacter): SafeGlobalCharacter { + return { + id: character.id.toString(), + name: character.name, + display_name: character.display_name, + role_archetype: character.role_archetype, + gender_label: character.gender_label, + age_group: character.age_group, + identity_desc: character.identity_desc, + appearance_desc: character.appearance_desc, + face_desc: character.face_desc, + hair_desc: character.hair_desc, + eye_desc: character.eye_desc, + body_desc: character.body_desc, + default_costume_rules: character.default_costume_rules, + wardrobe_json: character.wardrobe_json, + special_props: character.special_props, + personality_desc: character.personality_desc, + speech_style: character.speech_style, + voice_provider_code: character.voice_provider_code, + voice_model: character.voice_model, + voice_id: character.voice_id, + voice_style: character.voice_style, + performance_style: character.performance_style, + negative_rules: character.negative_rules, + anchor_asset_id: character.anchor_asset_id?.toString() ?? null, + voice_sample_asset_id: character.voice_sample_asset_id?.toString() ?? null, + commercial_status: character.commercial_status, + usage_scope: character.usage_scope, + status: character.status, + created_by_user_id: character.created_by_user_id?.toString() ?? null, + created_at: character.created_at.toISOString(), + updated_at: character.updated_at.toISOString() + }; +} diff --git a/backend/src/characters/characters.controller.ts b/backend/src/characters/characters.controller.ts new file mode 100644 index 0000000..f3f3219 --- /dev/null +++ b/backend/src/characters/characters.controller.ts @@ -0,0 +1,75 @@ +import { + Body, + Controller, + Delete, + Get, + Inject, + Param, + Patch, + Post, + Query, + UseGuards +} from '@nestjs/common'; +import { CurrentUser } from '../auth/current-user.decorator'; +import type { AuthRequestUser } from '../auth/auth.types'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { CreateCharacterDto, ExtractCharactersDto, UpdateCharacterDto } from './character.dto'; +import { CharactersService } from './characters.service'; + +@Controller() +@UseGuards(JwtAuthGuard) +export class CharactersController { + constructor(@Inject(CharactersService) private readonly charactersService: CharactersService) {} + + @Post('projects/:projectId/characters/extract') + extractCharacters( + @CurrentUser() user: AuthRequestUser, + @Param('projectId') projectId: string, + @Body() dto: ExtractCharactersDto + ) { + return this.charactersService.extractCharacters(user, projectId, dto); + } + + @Get('projects/:projectId/characters') + listCharacters( + @CurrentUser() user: AuthRequestUser, + @Param('projectId') projectId: string, + @Query('include_deleted') includeDeleted?: string + ) { + return this.charactersService.listCharacters(user, projectId, includeDeleted === 'true'); + } + + @Post('projects/:projectId/characters') + createCharacter( + @CurrentUser() user: AuthRequestUser, + @Param('projectId') projectId: string, + @Body() dto: CreateCharacterDto + ) { + return this.charactersService.createCharacter(user, projectId, dto); + } + + @Post('projects/:projectId/characters/confirm') + confirmCharacters( + @CurrentUser() user: AuthRequestUser, + @Param('projectId') projectId: string + ) { + return this.charactersService.confirmCharacters(user, projectId); + } + + @Patch('characters/:characterId') + updateCharacter( + @CurrentUser() user: AuthRequestUser, + @Param('characterId') characterId: string, + @Body() dto: UpdateCharacterDto + ) { + return this.charactersService.updateCharacter(user, characterId, dto); + } + + @Delete('characters/:characterId') + deleteCharacter( + @CurrentUser() user: AuthRequestUser, + @Param('characterId') characterId: string + ) { + return this.charactersService.deleteCharacter(user, characterId); + } +} diff --git a/backend/src/characters/characters.module.ts b/backend/src/characters/characters.module.ts new file mode 100644 index 0000000..1ec8480 --- /dev/null +++ b/backend/src/characters/characters.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { CharactersController } from './characters.controller'; +import { CharactersService } from './characters.service'; + +@Module({ + imports: [AuthModule], + controllers: [CharactersController], + providers: [CharactersService], + exports: [CharactersService] +}) +export class CharactersModule {} diff --git a/backend/src/characters/characters.service.spec.ts b/backend/src/characters/characters.service.spec.ts new file mode 100644 index 0000000..b2a2498 --- /dev/null +++ b/backend/src/characters/characters.service.spec.ts @@ -0,0 +1,294 @@ +import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Character, NovelChapter, Project, StoryBible } from '@prisma/client'; +import type { AuthRequestUser } from '../auth/auth.types'; +import type { PrismaService } from '../prisma/prisma.service'; +import { CharactersService } from './characters.service'; + +const user: AuthRequestUser = { + id: '1', + email: 'user@example.com', + role: 'user' +}; + +function createProject(overrides: Partial = {}): Project { + return { + id: 10n, + user_id: 1n, + title: '重生归来,我只搞事业', + input_mode: 'ai_original', + genre: 'urban_rebirth', + style_code: 'korean_comic', + output_type: 'short_video', + output_mode: 'image_manga', + visual_mode: 'korean_manga', + video_generation_level: 'standard', + target_episode_count: 3, + episode_duration: 60, + status: 'story_confirmed', + copyright_status: 'ai_original', + payment_status: 'unpaid', + quality_level: 'mvp', + is_long_series: false, + created_at: new Date('2026-05-31T00:00:00.000Z'), + updated_at: new Date('2026-05-31T00:00:00.000Z'), + completed_at: null, + ...overrides + }; +} + +function createStoryBible(overrides: Partial = {}): StoryBible { + return { + id: 40n, + project_id: 10n, + title: '重生归来,我只搞事业', + logline: '林晚重回命运转折点,用证据夺回项目。', + main_plot: '主要人物:林晚;其对手、旧友、合作者将在后续角色圣经中细化。', + core_conflict: '林晚必须在资本压力中守住原创项目。', + selling_points: '重生归来\n证据反杀', + tone: '克制、锋利、连续反转', + world_summary: '现代都市内容公司', + ending_direction: '幕后真相继续推进。', + taboo_rules: '不得改变主角姓名。', + version: 1, + status: 'confirmed', + created_at: new Date('2026-05-31T00:00:00.000Z'), + updated_at: new Date('2026-05-31T00:00:00.000Z'), + ...overrides + }; +} + +function createChapter(overrides: Partial = {}): NovelChapter { + return { + id: 30n, + project_id: 10n, + novel_source_id: 20n, + chapter_no: 1, + title: '第1章 暴雨重启', + content: '林晚站在暴雨夜里醒来,决定重新夺回项目。', + summary: '林晚确认重生并整理证据。', + visual_summary: '暴雨夜,林晚醒来,手机录音亮起。', + word_count: 22, + status: 'generated', + created_at: new Date('2026-05-31T00:00:00.000Z'), + ...overrides + }; +} + +function createCharacter(overrides: Partial = {}): Character { + return { + id: 50n, + project_id: 10n, + global_character_id: null, + name: '林晚', + alias_names: [], + role_type: 'protagonist', + gender_label: '女', + age_group: '青年', + identity_desc: '故事主角', + appearance_desc: '眼神坚定', + face_desc: '精致脸型', + hair_desc: '深色中长发', + eye_desc: '深色眼睛', + body_desc: '身形修长', + costume_rules: '现代都市通勤装', + special_props: '手机、合同', + personality_desc: '冷静克制', + speech_style: '短句明确', + relationship_desc: '与对手冲突', + character_arc: '从被动到主动', + negative_rules: '不得改名', + anchor_asset_id: null, + wardrobe_variant: null, + voice_provider_code: null, + voice_model: null, + voice_id: null, + voice_style: null, + performance_style: null, + importance_level: 100, + status: 'generated', + created_at: new Date('2026-05-31T00:00:00.000Z'), + updated_at: new Date('2026-05-31T00:00:00.000Z'), + ...overrides + }; +} + +describe('CharactersService', () => { + let prisma: { + project: { findUnique: ReturnType; update: ReturnType }; + storyBible: { + findUnique: ReturnType; + findFirst: ReturnType; + }; + novelChapter: { findMany: ReturnType }; + character: { + create: ReturnType; + findMany: ReturnType; + findUnique: ReturnType; + update: ReturnType; + updateMany: ReturnType; + createMany: ReturnType; + }; + characterMemory: { create: ReturnType }; + $transaction: ReturnType; + }; + let tx: { + project: { update: ReturnType }; + character: { + createMany: ReturnType; + findMany: ReturnType; + updateMany: ReturnType; + }; + }; + let service: CharactersService; + + beforeEach(() => { + tx = { + project: { + update: vi.fn().mockResolvedValue(createProject({ status: 'waiting_character_confirm' })) + }, + character: { + createMany: vi.fn().mockResolvedValue({ count: 3 }), + findMany: vi.fn().mockResolvedValue([ + createCharacter(), + createCharacter({ id: 51n, name: '周启', role_type: 'antagonist', importance_level: 80 }), + createCharacter({ id: 52n, name: '沈知夏', role_type: 'supporting', importance_level: 60 }) + ]), + updateMany: vi.fn().mockResolvedValue({ count: 0 }) + } + }; + prisma = { + project: { + findUnique: vi.fn().mockResolvedValue(createProject()), + update: vi.fn().mockResolvedValue(createProject({ status: 'waiting_character_confirm' })) + }, + storyBible: { + findUnique: vi.fn().mockResolvedValue(createStoryBible()), + findFirst: vi.fn().mockResolvedValue(createStoryBible()) + }, + novelChapter: { + findMany: vi.fn().mockResolvedValue([createChapter()]) + }, + character: { + create: vi.fn().mockResolvedValue(createCharacter({ status: 'edited' })), + findMany: vi.fn().mockResolvedValue([createCharacter()]), + findUnique: vi.fn().mockResolvedValue(createCharacter()), + update: vi.fn().mockResolvedValue(createCharacter({ status: 'edited' })), + updateMany: vi.fn(), + createMany: vi.fn() + }, + characterMemory: { + create: vi.fn().mockResolvedValue({}) + }, + $transaction: vi.fn(async (callback: (transaction: typeof tx) => unknown) => callback(tx)) + }; + service = new CharactersService(prisma as unknown as PrismaService); + }); + + it('extracts characters from a confirmed story bible', async () => { + const result = await service.extractCharacters(user, '10', { story_bible_id: '40' }); + + expect(prisma.project.update).toHaveBeenCalledWith({ + where: { id: 10n }, + data: { status: 'character_extracting' } + }); + expect(tx.character.createMany).toHaveBeenCalled(); + expect(tx.project.update).toHaveBeenCalledWith({ + where: { id: 10n }, + data: { status: 'waiting_character_confirm' } + }); + expect(result.characters).toHaveLength(3); + expect(result.next_step).toBe('character_confirm'); + }); + + it('requires a confirmed story bible before extraction', async () => { + prisma.storyBible.findFirst.mockResolvedValue(null); + + await expect(service.extractCharacters(user, '10', {})).rejects.toBeInstanceOf( + BadRequestException + ); + }); + + it('creates a manual character', async () => { + const result = await service.createCharacter(user, '10', { + name: '顾南', + role_type: 'supporting', + importance_level: 50 + }); + + expect(prisma.character.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + project_id: 10n, + name: '顾南', + role_type: 'supporting', + status: 'edited' + }) + }); + expect(result.status).toBe('edited'); + }); + + it('blocks core field changes after a character is locked', async () => { + prisma.character.findUnique.mockResolvedValue(createCharacter({ status: 'locked' })); + + await expect( + service.updateCharacter(user, '50', { name: '新的名字' }) + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('allows non-core patching after a character is locked', async () => { + prisma.character.findUnique.mockResolvedValue(createCharacter({ status: 'locked' })); + prisma.character.update.mockResolvedValue( + createCharacter({ status: 'locked', costume_rules: '新增雨夜外套变体。' }) + ); + + const result = await service.updateCharacter(user, '50', { + costume_rules: '新增雨夜外套变体。' + }); + + expect(prisma.character.update).toHaveBeenCalledWith({ + where: { id: 50n }, + data: expect.objectContaining({ + costume_rules: '新增雨夜外套变体。' + }) + }); + expect(prisma.character.update.mock.calls[0][0].data.status).toBeUndefined(); + expect(prisma.characterMemory.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + project_id: 10n, + character_id: 50n, + memory_type: 'profile_adjustment' + }) + }); + expect(result.costume_rules).toBe('新增雨夜外套变体。'); + }); + + it('confirms characters and locks the library', async () => { + prisma.character.findMany.mockResolvedValue([ + createCharacter(), + createCharacter({ id: 51n, name: '周启', role_type: 'antagonist' }) + ]); + + const result = await service.confirmCharacters(user, '10'); + + expect(tx.character.updateMany).toHaveBeenCalledWith({ + where: { + project_id: 10n, + status: { in: ['draft', 'generated', 'edited'] } + }, + data: { status: 'locked' } + }); + expect(tx.project.update).toHaveBeenCalledWith({ + where: { id: 10n }, + data: { status: 'character_confirmed' } + }); + expect(result.next_step).toBe('episode_plan_generate'); + }); + + it('rejects access to another user project', async () => { + prisma.project.findUnique.mockResolvedValue(createProject({ user_id: 2n })); + + await expect(service.listCharacters(user, '10')).rejects.toBeInstanceOf( + ForbiddenException + ); + }); +}); diff --git a/backend/src/characters/characters.service.ts b/backend/src/characters/characters.service.ts new file mode 100644 index 0000000..8e82494 --- /dev/null +++ b/backend/src/characters/characters.service.ts @@ -0,0 +1,708 @@ +import { + BadRequestException, + ForbiddenException, + Inject, + Injectable, + NotFoundException +} from '@nestjs/common'; +import type { Character, GlobalCharacter, NovelChapter, Prisma, Project, StoryBible } from '@prisma/client'; +import type { AuthRequestUser } from '../auth/auth.types'; +import { PrismaService } from '../prisma/prisma.service'; +import { CreateCharacterDto, ExtractCharactersDto, UpdateCharacterDto } from './character.dto'; +import { + CHARACTER_ROLE_TYPES, + CHARACTER_STATUSES, + toSafeCharacter, + type CharacterRoleType +} from './character.types'; + +interface CharacterDraft { + global_character_id: bigint | null; + name: string; + alias_names: Prisma.InputJsonValue; + role_type: CharacterRoleType; + gender_label: string | null; + age_group: string | null; + identity_desc: string | null; + appearance_desc: string | null; + face_desc: string | null; + hair_desc: string | null; + eye_desc: string | null; + body_desc: string | null; + costume_rules: string | null; + special_props: string | null; + personality_desc: string | null; + speech_style: string | null; + relationship_desc: string | null; + character_arc: string | null; + negative_rules: string | null; + anchor_asset_id: bigint | null; + importance_level: number; + wardrobe_variant: string | null; + voice_provider_code: string | null; + voice_model: string | null; + voice_id: string | null; + voice_style: string | null; + performance_style: string | null; +} + +const LOCKED_CORE_FIELDS = new Set([ + 'name', + 'role_type', + 'gender_label', + 'age_group', + 'identity_desc', + 'appearance_desc', + 'face_desc', + 'hair_desc', + 'eye_desc', + 'body_desc' +]); + +@Injectable() +export class CharactersService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async extractCharacters(user: AuthRequestUser, projectId: string, dto: ExtractCharactersDto) { + const project = await this.findProjectForUser(projectId, user); + const storyBible = dto.story_bible_id + ? await this.findStoryBibleById(project.id, dto.story_bible_id) + : await this.findConfirmedStoryBible(project.id); + + if (!storyBible) { + throw new BadRequestException('Confirmed story bible is required before character extraction'); + } + + const chapters = await this.prisma.novelChapter.findMany({ + where: { project_id: project.id }, + orderBy: { chapter_no: 'asc' } + }); + const drafts = this.buildCharacterDrafts(storyBible, chapters); + + await this.prisma.project.update({ + where: { id: project.id }, + data: { status: 'character_extracting' } + }); + + const characters = await this.prisma.$transaction(async (tx) => { + await tx.character.updateMany({ + where: { + project_id: project.id, + status: { not: 'deleted' } + }, + data: { status: 'deleted' } + }); + await tx.character.createMany({ + data: drafts.map((draft) => ({ + project_id: project.id, + ...draft, + status: 'generated' + })) + }); + const saved = await tx.character.findMany({ + where: { + project_id: project.id, + status: { not: 'deleted' } + }, + orderBy: [{ importance_level: 'desc' }, { id: 'asc' }] + }); + await tx.project.update({ + where: { id: project.id }, + data: { status: 'waiting_character_confirm' } + }); + return saved; + }); + + return { + characters: characters.map(toSafeCharacter), + story_bible: { + id: storyBible.id.toString(), + version: storyBible.version, + status: storyBible.status + }, + next_step: 'character_confirm' + }; + } + + async listCharacters(user: AuthRequestUser, projectId: string, includeDeleted = false) { + const project = await this.findProjectForUser(projectId, user); + const characters = await this.prisma.character.findMany({ + where: { + project_id: project.id, + ...(includeDeleted ? {} : { status: { not: 'deleted' } }) + }, + orderBy: [{ importance_level: 'desc' }, { id: 'asc' }] + }); + + return characters.map(toSafeCharacter); + } + + async createCharacter(user: AuthRequestUser, projectId: string, dto: CreateCharacterDto) { + const project = await this.findProjectForUser(projectId, user); + const globalCharacter = await this.findActiveGlobalCharacter(dto.global_character_id); + const draft = this.createDraftFromDto(dto, globalCharacter); + const character = await this.prisma.character.create({ + data: { + project_id: project.id, + ...draft, + status: 'edited' + } + }); + + await this.prisma.project.update({ + where: { id: project.id }, + data: { status: 'waiting_character_confirm' } + }); + + return toSafeCharacter(character); + } + + async updateCharacter(user: AuthRequestUser, characterId: string, dto: UpdateCharacterDto) { + const character = await this.findCharacterForUser(characterId, user); + this.assertLockedPatchAllowed(character, dto); + const globalCharacter = await this.findActiveGlobalCharacter(dto.global_character_id); + const data = this.createUpdateData(dto, character.status !== 'locked', globalCharacter, character); + + if (Object.keys(data).length === 0) { + throw new BadRequestException('No character fields to update'); + } + + const updated = await this.prisma.character.update({ + where: { id: character.id }, + data + }); + + if (character.status === 'locked') { + await this.prisma.characterMemory.create({ + data: { + project_id: character.project_id, + character_id: character.id, + episode_id: null, + memory_type: 'profile_adjustment', + content: this.describeLockedCharacterPatch(dto) + } + }); + } + + if (updated.status !== 'locked') { + await this.prisma.project.update({ + where: { id: updated.project_id }, + data: { status: 'waiting_character_confirm' } + }); + } + + return toSafeCharacter(updated); + } + + async deleteCharacter(user: AuthRequestUser, characterId: string) { + const character = await this.findCharacterForUser(characterId, user); + + if (character.status === 'locked') { + throw new BadRequestException('Locked characters cannot be deleted'); + } + + const deleted = await this.prisma.character.update({ + where: { id: character.id }, + data: { status: 'deleted' } + }); + + await this.prisma.project.update({ + where: { id: character.project_id }, + data: { status: 'waiting_character_confirm' } + }); + + return toSafeCharacter(deleted); + } + + async confirmCharacters(user: AuthRequestUser, projectId: string) { + const project = await this.findProjectForUser(projectId, user); + const characters = await this.prisma.character.findMany({ + where: { + project_id: project.id, + status: { not: 'deleted' } + }, + orderBy: [{ importance_level: 'desc' }, { id: 'asc' }] + }); + + if (characters.length === 0) { + throw new BadRequestException('At least one character is required before confirmation'); + } + + if (!characters.some((character) => ['protagonist', 'lead'].includes(character.role_type))) { + throw new BadRequestException('A protagonist or lead character is required before confirmation'); + } + + const locked = await this.prisma.$transaction(async (tx) => { + await tx.character.updateMany({ + where: { + project_id: project.id, + status: { in: ['draft', 'generated', 'edited'] } + }, + data: { status: 'locked' } + }); + const saved = await tx.character.findMany({ + where: { + project_id: project.id, + status: { not: 'deleted' } + }, + orderBy: [{ importance_level: 'desc' }, { id: 'asc' }] + }); + await tx.project.update({ + where: { id: project.id }, + data: { status: 'character_confirmed' } + }); + return saved; + }); + + return { + characters: locked.map(toSafeCharacter), + next_step: 'episode_plan_generate' + }; + } + + private buildCharacterDrafts(storyBible: StoryBible, chapters: NovelChapter[]): CharacterDraft[] { + const protagonist = this.guessProtagonist(storyBible, chapters); + const antagonist = this.guessAntagonist(storyBible, chapters, protagonist); + const supporter = this.guessSupporter(storyBible, chapters, protagonist, antagonist); + + return [ + this.buildProtagonist(protagonist, storyBible), + this.buildAntagonist(antagonist, storyBible), + this.buildSupporter(supporter, protagonist, storyBible) + ].filter((draft, index, list) => + list.findIndex((item) => item.name === draft.name) === index + ); + } + + private buildProtagonist(name: string, storyBible: StoryBible): CharacterDraft { + return { + global_character_id: null, + name, + alias_names: [], + role_type: 'protagonist', + gender_label: this.inferGender(name), + age_group: '青年', + identity_desc: this.extractAfter(storyBible.main_plot, '主要人物') ?? '故事主角,核心目标推动者。', + appearance_desc: `${name}五官清晰,眼神坚定,整体气质克制锋利,适合韩漫短剧主角。`, + face_desc: '精致鹅蛋脸或小方脸,轮廓干净,表情有压迫感。', + hair_desc: '深色中长发或利落短发,发型稳定,不随剧情随意改变。', + eye_desc: '深色眼睛,眼神坚定,关键反击场景有锐利高光。', + body_desc: '身形修长,站姿稳定,动作干练。', + costume_rules: '默认现代都市通勤装,深色外套、干净衬衫,重要场合可换正式套装。', + special_props: '手机、合同、录音或关键证据文件。', + personality_desc: '冷静、克制、目标感强,遇到压力先观察再反击。', + speech_style: '短句明确,不解释过多,关键台词有压迫感。', + relationship_desc: storyBible.main_plot ?? '与对手存在利益冲突,与潜在合作者存在信任考验。', + character_arc: storyBible.ending_direction ?? '从被动防守转向主动掌控局面。', + negative_rules: '不得改名,不得年龄漂移,不得突然软弱或无因放弃核心目标。', + anchor_asset_id: null, + importance_level: 100, + wardrobe_variant: null, + voice_provider_code: null, + voice_model: null, + voice_id: null, + voice_style: '冷静克制的年轻女性声线,语速中等,反击台词更有压迫感。', + performance_style: '微表情克制,关键反击时眼神压迫增强。' + }; + } + + private buildAntagonist(name: string, storyBible: StoryBible): CharacterDraft { + return { + global_character_id: null, + name, + alias_names: [], + role_type: 'antagonist', + gender_label: this.inferGender(name), + age_group: '青年到中年', + identity_desc: '与主角核心目标冲突的主要阻碍者。', + appearance_desc: `${name}外表精致但带距离感,表情常带审视或压迫。`, + face_desc: '脸部线条偏锋利,笑容克制,眼神有算计感。', + hair_desc: '发型整齐,商务感强。', + eye_desc: '眼神冷静,常避开正面情绪。', + body_desc: '姿态控制感强,动作少但压迫明显。', + costume_rules: '商务深色系,避免与主角服装完全相同。', + special_props: '平板、合同、会议资料或控制权文件。', + personality_desc: '擅长隐藏真实动机,习惯利用规则和舆论施压。', + speech_style: '语气礼貌但带威胁,常用反问和条件交换。', + relationship_desc: storyBible.core_conflict ?? '与主角围绕核心目标持续对抗。', + character_arc: '前期占据优势,中期逐步暴露破绽,后期成为主线真相入口。', + negative_rules: '不得与主角混脸,不得突然洗白,不得无因放弃利益目标。', + anchor_asset_id: null, + importance_level: 80, + wardrobe_variant: null, + voice_provider_code: null, + voice_model: null, + voice_id: null, + voice_style: '低沉或冷硬声线,语速偏慢,礼貌但带压迫。', + performance_style: '动作少但控制感强,表情审视、笑容克制。' + }; + } + + private buildSupporter(name: string, protagonist: string, storyBible: StoryBible): CharacterDraft { + return { + global_character_id: null, + name, + alias_names: [], + role_type: 'supporting', + gender_label: this.inferGender(name), + age_group: '青年', + identity_desc: '主角阶段性合作者或见证者。', + appearance_desc: `${name}亲和但有专业感,视觉上与${protagonist}形成区分。`, + face_desc: '脸部线条柔和,表情更外放。', + hair_desc: '自然深色发型,轮廓清楚。', + eye_desc: '眼神明亮,情绪反应明显。', + body_desc: '行动灵活,适合辅助调查和转场。', + costume_rules: '浅色或中性色日常装,避免抢主角视觉中心。', + special_props: '笔记本、工作证或资料袋。', + personality_desc: '敏锐、讲义气,但在压力下会犹豫。', + speech_style: '语速较快,常提醒风险,也会补充信息。', + relationship_desc: `${name}与${protagonist}存在信任考验,后续可发展为稳定协作关系。`, + character_arc: storyBible.main_plot?.slice(0, 120) ?? '从旁观者成长为主角的重要支撑。', + negative_rules: '不得替代主角决策,不得在未铺垫时掌握关键真相。', + anchor_asset_id: null, + importance_level: 60, + wardrobe_variant: null, + voice_provider_code: null, + voice_model: null, + voice_id: null, + voice_style: '亲和、反应快的年轻声线,信息补充时语速略快。', + performance_style: '情绪外放,适合惊讶、提醒和辅助调查。' + }; + } + + private createDraftFromDto(dto: CreateCharacterDto, globalCharacter: GlobalCharacter | null): CharacterDraft { + const name = this.optionalText(dto.name) ?? globalCharacter?.display_name ?? globalCharacter?.name; + if (!name) { + throw new BadRequestException('name is required'); + } + const roleType = this.validateRoleType(dto.role_type ?? globalCharacter?.role_archetype ?? 'supporting'); + + return { + global_character_id: globalCharacter?.id ?? null, + name, + alias_names: this.normalizeAliases(dto.alias_names), + role_type: roleType, + gender_label: this.optionalText(dto.gender_label) ?? globalCharacter?.gender_label ?? null, + age_group: this.optionalText(dto.age_group) ?? globalCharacter?.age_group ?? null, + identity_desc: this.optionalText(dto.identity_desc) ?? globalCharacter?.identity_desc ?? null, + appearance_desc: this.optionalText(dto.appearance_desc) ?? globalCharacter?.appearance_desc ?? null, + face_desc: this.optionalText(dto.face_desc) ?? globalCharacter?.face_desc ?? null, + hair_desc: this.optionalText(dto.hair_desc) ?? globalCharacter?.hair_desc ?? null, + eye_desc: this.optionalText(dto.eye_desc) ?? globalCharacter?.eye_desc ?? null, + body_desc: this.optionalText(dto.body_desc) ?? globalCharacter?.body_desc ?? null, + costume_rules: this.optionalText(dto.costume_rules) ?? globalCharacter?.default_costume_rules ?? null, + special_props: this.optionalText(dto.special_props) ?? globalCharacter?.special_props ?? null, + personality_desc: this.optionalText(dto.personality_desc) ?? globalCharacter?.personality_desc ?? null, + speech_style: this.optionalText(dto.speech_style) ?? globalCharacter?.speech_style ?? null, + relationship_desc: this.optionalText(dto.relationship_desc), + character_arc: this.optionalText(dto.character_arc), + negative_rules: this.optionalText(dto.negative_rules) ?? globalCharacter?.negative_rules ?? null, + anchor_asset_id: globalCharacter?.anchor_asset_id ?? null, + importance_level: this.validateImportance(dto.importance_level ?? 10), + wardrobe_variant: this.optionalText(dto.wardrobe_variant), + voice_provider_code: this.optionalText(dto.voice_provider_code) ?? globalCharacter?.voice_provider_code ?? null, + voice_model: this.optionalText(dto.voice_model) ?? globalCharacter?.voice_model ?? null, + voice_id: this.optionalText(dto.voice_id) ?? globalCharacter?.voice_id ?? null, + voice_style: this.optionalText(dto.voice_style) ?? globalCharacter?.voice_style ?? null, + performance_style: this.optionalText(dto.performance_style) ?? globalCharacter?.performance_style ?? null + }; + } + + private createUpdateData( + dto: UpdateCharacterDto, + markEdited = true, + globalCharacter: GlobalCharacter | null, + currentCharacter: Character + ): Prisma.CharacterUncheckedUpdateInput { + const data: Prisma.CharacterUncheckedUpdateInput = {}; + + if ('global_character_id' in dto) { + data.global_character_id = globalCharacter?.id ?? null; + if (globalCharacter) { + if (!currentCharacter.anchor_asset_id && globalCharacter.anchor_asset_id) { + data.anchor_asset_id = globalCharacter.anchor_asset_id; + } + if (!currentCharacter.voice_provider_code && globalCharacter.voice_provider_code) { + data.voice_provider_code = globalCharacter.voice_provider_code; + } + if (!currentCharacter.voice_model && globalCharacter.voice_model) { + data.voice_model = globalCharacter.voice_model; + } + if (!currentCharacter.voice_id && globalCharacter.voice_id) { + data.voice_id = globalCharacter.voice_id; + } + if (!currentCharacter.voice_style && globalCharacter.voice_style) { + data.voice_style = globalCharacter.voice_style; + } + if (!currentCharacter.performance_style && globalCharacter.performance_style) { + data.performance_style = globalCharacter.performance_style; + } + if (!currentCharacter.costume_rules && globalCharacter.default_costume_rules) { + data.costume_rules = globalCharacter.default_costume_rules; + } + } + } + if ('name' in dto) data.name = this.requiredText(dto.name, 'name is required'); + if ('alias_names' in dto) data.alias_names = this.normalizeAliases(dto.alias_names); + if ('role_type' in dto) data.role_type = this.validateRoleType(dto.role_type); + if ('gender_label' in dto) data.gender_label = this.optionalText(dto.gender_label); + if ('age_group' in dto) data.age_group = this.optionalText(dto.age_group); + if ('identity_desc' in dto) data.identity_desc = this.optionalText(dto.identity_desc); + if ('appearance_desc' in dto) data.appearance_desc = this.optionalText(dto.appearance_desc); + if ('face_desc' in dto) data.face_desc = this.optionalText(dto.face_desc); + if ('hair_desc' in dto) data.hair_desc = this.optionalText(dto.hair_desc); + if ('eye_desc' in dto) data.eye_desc = this.optionalText(dto.eye_desc); + if ('body_desc' in dto) data.body_desc = this.optionalText(dto.body_desc); + if ('costume_rules' in dto) data.costume_rules = this.optionalText(dto.costume_rules); + if ('special_props' in dto) data.special_props = this.optionalText(dto.special_props); + if ('personality_desc' in dto) data.personality_desc = this.optionalText(dto.personality_desc); + if ('speech_style' in dto) data.speech_style = this.optionalText(dto.speech_style); + if ('relationship_desc' in dto) data.relationship_desc = this.optionalText(dto.relationship_desc); + if ('character_arc' in dto) data.character_arc = this.optionalText(dto.character_arc); + if ('negative_rules' in dto) data.negative_rules = this.optionalText(dto.negative_rules); + if ('wardrobe_variant' in dto) data.wardrobe_variant = this.optionalText(dto.wardrobe_variant); + if ('voice_provider_code' in dto) data.voice_provider_code = this.optionalText(dto.voice_provider_code); + if ('voice_model' in dto) data.voice_model = this.optionalText(dto.voice_model); + if ('voice_id' in dto) data.voice_id = this.optionalText(dto.voice_id); + if ('voice_style' in dto) data.voice_style = this.optionalText(dto.voice_style); + if ('performance_style' in dto) data.performance_style = this.optionalText(dto.performance_style); + if ('importance_level' in dto) { + data.importance_level = this.validateImportance(dto.importance_level); + } + if ('status' in dto) data.status = this.validateStatus(dto.status); + + if (markEdited && Object.keys(data).length > 0 && data.status !== 'locked') { + data.status = data.status ?? 'edited'; + } + + return data; + } + + private assertLockedPatchAllowed(character: Character, dto: UpdateCharacterDto) { + if (character.status !== 'locked') { + return; + } + + for (const field of LOCKED_CORE_FIELDS) { + if (field in dto) { + throw new BadRequestException('Locked character core fields cannot be changed'); + } + } + + if (dto.status && dto.status !== 'locked') { + throw new BadRequestException('Locked character status cannot be changed here'); + } + } + + private describeLockedCharacterPatch(dto: UpdateCharacterDto) { + const labels: string[] = []; + + if ('global_character_id' in dto) labels.push('全局角色绑定'); + if ('alias_names' in dto) labels.push('别名'); + if ('costume_rules' in dto) labels.push('服装规则'); + if ('special_props' in dto) labels.push('特殊道具'); + if ('personality_desc' in dto) labels.push('性格补充'); + if ('speech_style' in dto) labels.push('说话方式'); + if ('wardrobe_variant' in dto) labels.push('服装变体'); + if ('voice_provider_code' in dto || 'voice_model' in dto || 'voice_id' in dto || 'voice_style' in dto) { + labels.push('角色声音'); + } + if ('performance_style' in dto) labels.push('表演风格'); + if ('relationship_desc' in dto) labels.push('人物关系'); + if ('character_arc' in dto) labels.push('成长线'); + if ('negative_rules' in dto) labels.push('禁用规则'); + if ('importance_level' in dto) labels.push('重要级别'); + + return `锁定角色资料补充:${labels.join('、') || '非核心描述'}。`; + } + + private async findActiveGlobalCharacter(globalCharacterId: string | undefined) { + const normalized = globalCharacterId?.trim(); + + if (!normalized) { + return null; + } + + const globalCharacter = await this.prisma.globalCharacter.findUnique({ + where: { id: this.parseId(normalized, 'Invalid global_character_id') } + }); + + if (!globalCharacter || globalCharacter.status !== 'active') { + throw new NotFoundException('Active global character not found'); + } + + return globalCharacter; + } + + private async findCharacterForUser(characterId: string, user: AuthRequestUser) { + const character = await this.prisma.character.findUnique({ + where: { id: this.parseId(characterId, 'Invalid character id') } + }); + + if (!character || character.status === 'deleted') { + throw new NotFoundException('Character not found'); + } + + await this.findProjectForUser(character.project_id.toString(), user); + return character; + } + + private async findProjectForUser(projectId: string, user: AuthRequestUser) { + const project = await this.prisma.project.findUnique({ + where: { id: this.parseId(projectId, 'Invalid project id') } + }); + + if (!project) { + throw new NotFoundException('Project not found'); + } + + if (project.user_id.toString() !== user.id && user.role !== 'admin') { + throw new ForbiddenException('Project is private'); + } + + return project; + } + + private async findConfirmedStoryBible(projectId: bigint) { + return this.prisma.storyBible.findFirst({ + where: { + project_id: projectId, + status: 'confirmed' + }, + orderBy: { version: 'desc' } + }); + } + + private async findStoryBibleById(projectId: bigint, storyBibleId: string) { + const storyBible = await this.prisma.storyBible.findUnique({ + where: { id: this.parseId(storyBibleId, 'Invalid story bible id') } + }); + + if (!storyBible || storyBible.project_id !== projectId || storyBible.status !== 'confirmed') { + throw new NotFoundException('Confirmed story bible not found'); + } + + return storyBible; + } + + private guessProtagonist(storyBible: StoryBible, chapters: NovelChapter[]) { + const text = [storyBible.logline, storyBible.main_plot, ...chapters.map((chapter) => chapter.content)] + .filter(Boolean) + .join('\n'); + return this.matchName(text, ['林晚', '沈知夏', '顾南', '陆沉']) ?? '林晚'; + } + + private guessAntagonist(storyBible: StoryBible, chapters: NovelChapter[], protagonist: string) { + const text = [storyBible.core_conflict, storyBible.main_plot, ...chapters.map((chapter) => chapter.content)] + .filter(Boolean) + .join('\n'); + const matched = this.matchName(text, ['旧团队', '对手', '投资人', '周启', '苏曼', '陆沉']); + + if (!matched || matched === protagonist || matched.length > 4) { + return '周启'; + } + + return matched; + } + + private guessSupporter( + storyBible: StoryBible, + chapters: NovelChapter[], + protagonist: string, + antagonist: string + ) { + const text = [storyBible.main_plot, ...chapters.map((chapter) => chapter.content)] + .filter(Boolean) + .join('\n'); + const matched = this.matchName(text, ['合作者', '旧友', '助理', '沈知夏', '顾南']); + + if (!matched || matched === protagonist || matched === antagonist || matched.length > 4) { + return '沈知夏'; + } + + return matched; + } + + private matchName(text: string, candidates: string[]) { + const known = candidates.find((name) => text.includes(name) && name.length <= 4); + + if (known) { + return known; + } + + return /[\u4e00-\u9fa5]{2,4}(?=站在|醒来|必须|决定|知道|拿出|重回)/.exec(text)?.[0]; + } + + private extractAfter(value: string | null, label: string) { + if (!value) return null; + const line = value.split('\n').find((item) => item.includes(label)); + return line?.replace(`${label}:`, '').trim() || null; + } + + private inferGender(name: string) { + if (/[晚夏曼雪月柔]/.test(name)) { + return '女'; + } + + if (/[沉南启川宇]/.test(name)) { + return '男'; + } + + return '未指定'; + } + + private validateRoleType(value: string | undefined): CharacterRoleType { + if (!value || !CHARACTER_ROLE_TYPES.includes(value as never)) { + throw new BadRequestException('role_type is invalid'); + } + + return value as CharacterRoleType; + } + + private validateStatus(value: string | undefined) { + if (!value || !CHARACTER_STATUSES.includes(value as never)) { + throw new BadRequestException('status is invalid'); + } + + return value; + } + + private validateImportance(value: unknown) { + const numberValue = Number(value); + + if (!Number.isInteger(numberValue) || numberValue < 0 || numberValue > 100) { + throw new BadRequestException('importance_level must be an integer between 0 and 100'); + } + + return numberValue; + } + + private normalizeAliases(value: string[] | undefined): Prisma.InputJsonValue { + return Array.isArray(value) + ? value.map((item) => item.trim()).filter(Boolean) + : []; + } + + private requiredText(value: string | undefined, message: string) { + const normalized = value?.trim(); + + if (!normalized) { + throw new BadRequestException(message); + } + + return normalized; + } + + private optionalText(value: string | undefined) { + const normalized = value?.trim(); + return normalized || null; + } + + private parseId(id: string, message: string) { + try { + return BigInt(id); + } catch { + throw new BadRequestException(message); + } + } +} diff --git a/backend/src/common/all-exceptions.filter.ts b/backend/src/common/all-exceptions.filter.ts new file mode 100644 index 0000000..dd05eb5 --- /dev/null +++ b/backend/src/common/all-exceptions.filter.ts @@ -0,0 +1,59 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpException, + HttpStatus +} from '@nestjs/common'; +import type { Response } from 'express'; +import { ApiCryptoService, type RequestWithApiCrypto } from './api-crypto.service'; +import type { RequestWithRequestId } from './request-id.middleware'; + +@Catch() +export class AllExceptionsFilter implements ExceptionFilter { + constructor(private readonly apiCrypto: ApiCryptoService) {} + + catch(exception: unknown, host: ArgumentsHost) { + const context = host.switchToHttp(); + const response = context.getResponse(); + const request = context.getRequest(); + const status = + exception instanceof HttpException + ? exception.getStatus() + : HttpStatus.INTERNAL_SERVER_ERROR; + + const payload = { + code: status, + message: this.getMessage(exception), + data: null, + request_id: request.requestId || 'req_unknown' + }; + + if (request.apiCrypto) { + response.setHeader('x-api-encrypted', 'v1'); + } + + response.status(status).json(this.apiCrypto.encryptForRequest(request, payload)); + } + + private getMessage(exception: unknown) { + if (exception instanceof HttpException) { + const body = exception.getResponse(); + + if (typeof body === 'string') { + return body; + } + + if (typeof body === 'object' && body !== null && 'message' in body) { + const message = body.message; + return Array.isArray(message) ? message.join('; ') : String(message); + } + } + + if (exception instanceof Error) { + return exception.message || 'Internal server error'; + } + + return 'Internal server error'; + } +} diff --git a/backend/src/common/api-crypto.controller.ts b/backend/src/common/api-crypto.controller.ts new file mode 100644 index 0000000..a4f3521 --- /dev/null +++ b/backend/src/common/api-crypto.controller.ts @@ -0,0 +1,24 @@ +import { Controller, Get, Header, Inject } from '@nestjs/common'; +import { ApiCryptoService } from './api-crypto.service'; + +@Controller('crypto') +export class ApiCryptoController { + constructor(@Inject(ApiCryptoService) private readonly apiCrypto: ApiCryptoService) {} + + @Get('handshake') + @Header('Cache-Control', 'no-store') + handshake() { + return this.apiCrypto.createHandshake(); + } +} + +@Controller('client-config') +export class ClientConfigController { + constructor(@Inject(ApiCryptoService) private readonly apiCrypto: ApiCryptoService) {} + + @Get() + @Header('Cache-Control', 'no-store') + getClientConfig() { + return this.apiCrypto.getClientConfig(); + } +} diff --git a/backend/src/common/api-crypto.service.spec.ts b/backend/src/common/api-crypto.service.spec.ts new file mode 100644 index 0000000..01a7c83 --- /dev/null +++ b/backend/src/common/api-crypto.service.spec.ts @@ -0,0 +1,123 @@ +import { + createCipheriv, + createDecipheriv, + createECDH, + hkdfSync, + randomBytes +} from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { + ApiCryptoService, + type ApiCryptoEnvelope, + type ApiCryptoPublicJwk, + type RequestWithApiCrypto +} from './api-crypto.service'; + +function base64UrlEncode(input: Buffer) { + return input + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); +} + +function base64UrlDecode(value: string) { + const normalized = value.replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '='); + return Buffer.from(padded, 'base64'); +} + +function publicKeyToJwk(publicKey: Buffer): ApiCryptoPublicJwk { + return { + kty: 'EC', + crv: 'P-256', + x: base64UrlEncode(publicKey.subarray(1, 33)), + y: base64UrlEncode(publicKey.subarray(33, 65)), + ext: true + }; +} + +function jwkToPublicKey(jwk: ApiCryptoPublicJwk) { + return Buffer.concat([ + Buffer.from([4]), + base64UrlDecode(jwk.x), + base64UrlDecode(jwk.y) + ]); +} + +function encryptPayload(payload: unknown, key: Buffer, sessionId: string, clientPublicKey: ApiCryptoPublicJwk): ApiCryptoEnvelope { + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', key, iv); + const encrypted = Buffer.concat([ + cipher.update(Buffer.from(JSON.stringify(payload), 'utf8')), + cipher.final() + ]); + + return { + version: 1, + session_id: sessionId, + client_public_key: clientPublicKey, + iv: base64UrlEncode(iv), + ciphertext: base64UrlEncode(Buffer.concat([encrypted, cipher.getAuthTag()])) + }; +} + +function decryptPayload(envelope: ApiCryptoEnvelope, key: Buffer) { + const iv = base64UrlDecode(envelope.iv); + const encryptedWithTag = base64UrlDecode(envelope.ciphertext); + const decipher = createDecipheriv('aes-256-gcm', key, iv); + decipher.setAuthTag(encryptedWithTag.subarray(-16)); + const plaintext = Buffer.concat([ + decipher.update(encryptedWithTag.subarray(0, -16)), + decipher.final() + ]); + + return JSON.parse(plaintext.toString('utf8')) as unknown; +} + +describe('ApiCryptoService', () => { + it('decrypts client envelopes and encrypts API responses with the derived session key', async () => { + const service = new ApiCryptoService({ + systemConfig: { + findUnique: async () => null + } + } as never); + const handshake = service.createHandshake(); + const client = createECDH('prime256v1'); + client.generateKeys(); + const clientPublicKey = publicKeyToJwk(client.getPublicKey()); + const sharedSecret = client.computeSecret(jwkToPublicKey(handshake.server_public_key)); + const key = Buffer.from( + hkdfSync( + 'sha256', + sharedSecret, + base64UrlDecode(handshake.salt), + Buffer.from(`ai-manga-api-v1:${handshake.session_id}`, 'utf8'), + 32 + ) + ); + const requestBody = { title: '加密测试', count: 3 }; + const envelope = encryptPayload(requestBody, key, handshake.session_id, clientPublicKey); + const req = { + headers: {}, + body: envelope + } as RequestWithApiCrypto; + + const attachedEnvelope = await service.attachRequestContext(req); + const decryptedBody = service.decryptRequestBody(attachedEnvelope!, req.apiCrypto!); + + expect(decryptedBody).toEqual(requestBody); + + const encryptedResponse = service.encryptForRequest(req, { + code: 0, + message: 'success', + data: { ok: true }, + request_id: 'req_test' + }) as ApiCryptoEnvelope; + + expect(decryptPayload(encryptedResponse, key)).toMatchObject({ + code: 0, + data: { ok: true } + }); + }); +}); diff --git a/backend/src/common/api-crypto.service.ts b/backend/src/common/api-crypto.service.ts new file mode 100644 index 0000000..5149401 --- /dev/null +++ b/backend/src/common/api-crypto.service.ts @@ -0,0 +1,353 @@ +import { + BadRequestException, + Injectable, + UnauthorizedException +} from '@nestjs/common'; +import { + createCipheriv, + createDecipheriv, + createECDH, + hkdfSync, + randomBytes, + randomUUID +} from 'node:crypto'; +import type { Request } from 'express'; +import { PrismaService } from '../prisma/prisma.service'; + +const API_CRYPTO_VERSION = 1; +const AES_KEY_BYTES = 32; +const AES_GCM_AUTH_TAG_BYTES = 16; +const AES_GCM_IV_BYTES = 12; +const DEFAULT_SESSION_TTL_SECONDS = 15 * 60; +const HKDF_INFO_PREFIX = 'ai-manga-api-v1'; +const API_CRYPTO_CONFIG_KEY = 'security.api_crypto_enabled'; +const CONFIG_CACHE_MS = 5000; + +export interface ApiCryptoPublicJwk { + kty: 'EC'; + crv: 'P-256'; + x: string; + y: string; + ext?: boolean; + key_ops?: string[]; +} + +export interface ApiCryptoEnvelope { + version: number; + session_id: string; + client_public_key?: ApiCryptoPublicJwk; + iv: string; + ciphertext: string; +} + +export interface ApiCryptoContext { + sessionId: string; + clientPublicKey: ApiCryptoPublicJwk; + key: Buffer; +} + +export interface RequestWithApiCrypto extends Request { + apiCrypto?: ApiCryptoContext; + requestId?: string; +} + +interface ApiCryptoSession { + privateKey: Buffer; + salt: Buffer; + expiresAt: number; +} + +@Injectable() +export class ApiCryptoService { + private readonly sessions = new Map(); + private cachedEnabled: { value: boolean; expiresAt: number } | null = null; + + constructor(private readonly prisma: PrismaService) {} + + async isEnabled() { + const envOverride = this.readBooleanEnv(process.env.API_CRYPTO_ENABLED); + + if (envOverride !== null) { + return envOverride; + } + + const now = Date.now(); + + if (this.cachedEnabled && this.cachedEnabled.expiresAt > now) { + return this.cachedEnabled.value; + } + + let value = false; + + try { + const config = await this.prisma.systemConfig.findUnique({ + where: { config_key: API_CRYPTO_CONFIG_KEY } + }); + value = this.readEnabledFromConfig(config?.config_value); + } catch { + value = false; + } + + this.cachedEnabled = { + value, + expiresAt: now + CONFIG_CACHE_MS + }; + + return value; + } + + clearConfigCache() { + this.cachedEnabled = null; + } + + async getClientConfig() { + return { + api_crypto_enabled: await this.isEnabled(), + api_crypto_mode: process.env.API_CRYPTO_ENABLED?.trim() || 'auto', + api_crypto_session_ttl_seconds: this.sessionTtlSeconds() + }; + } + + createHandshake() { + this.pruneExpiredSessions(); + + const ecdh = createECDH('prime256v1'); + ecdh.generateKeys(); + + const sessionId = randomUUID(); + const expiresAt = Date.now() + this.sessionTtlSeconds() * 1000; + const salt = randomBytes(16); + + this.sessions.set(sessionId, { + privateKey: ecdh.getPrivateKey(), + salt, + expiresAt + }); + + return { + version: API_CRYPTO_VERSION, + algorithm: 'ECDH-P256-HKDF-SHA256-AES-256-GCM', + session_id: sessionId, + server_public_key: this.publicKeyToJwk(ecdh.getPublicKey()), + salt: this.base64UrlEncode(salt), + expires_at: new Date(expiresAt).toISOString() + }; + } + + shouldUseEncryptedApi(req: Request) { + return this.isEncryptedHeader(req.headers['x-api-encrypted']) || this.isEnvelope(req.body); + } + + async attachRequestContext(req: RequestWithApiCrypto) { + const bodyEnvelope = this.isEnvelope(req.body) ? req.body : null; + const sessionId = this.readHeader(req.headers['x-api-session-id']) || bodyEnvelope?.session_id; + const clientPublicKey = + bodyEnvelope?.client_public_key || + this.decodePublicKeyHeader(req.headers['x-api-client-public-key']); + + if (!sessionId || !clientPublicKey) { + throw new BadRequestException('Encrypted API session headers are required'); + } + + req.apiCrypto = { + sessionId, + clientPublicKey, + key: this.deriveKey(sessionId, clientPublicKey) + }; + + return bodyEnvelope; + } + + decryptRequestBody(envelope: ApiCryptoEnvelope, context: ApiCryptoContext) { + const plaintext = this.decryptEnvelope(envelope, context); + return plaintext === null ? {} : plaintext; + } + + encryptForRequest(req: RequestWithApiCrypto, payload: unknown) { + if (!req.apiCrypto) { + return payload; + } + + return this.encryptPayload(payload, req.apiCrypto); + } + + encryptPayload(payload: unknown, context: ApiCryptoContext) { + const iv = randomBytes(AES_GCM_IV_BYTES); + const cipher = createCipheriv('aes-256-gcm', context.key, iv); + const plaintext = Buffer.from(JSON.stringify(payload ?? null), 'utf8'); + const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]); + const tag = cipher.getAuthTag(); + + return { + encrypted: true, + version: API_CRYPTO_VERSION, + session_id: context.sessionId, + iv: this.base64UrlEncode(iv), + ciphertext: this.base64UrlEncode(Buffer.concat([encrypted, tag])) + }; + } + + isEnvelope(value: unknown): value is ApiCryptoEnvelope { + if (typeof value !== 'object' || value === null) { + return false; + } + + const record = value as Record; + + return ( + record.version === API_CRYPTO_VERSION && + typeof record.session_id === 'string' && + typeof record.iv === 'string' && + typeof record.ciphertext === 'string' + ); + } + + private decryptEnvelope(envelope: ApiCryptoEnvelope, context: ApiCryptoContext) { + if (envelope.session_id !== context.sessionId) { + throw new BadRequestException('Encrypted API session mismatch'); + } + + const iv = this.base64UrlDecode(envelope.iv); + const encryptedWithTag = this.base64UrlDecode(envelope.ciphertext); + + if (iv.length !== AES_GCM_IV_BYTES || encryptedWithTag.length <= AES_GCM_AUTH_TAG_BYTES) { + throw new BadRequestException('Invalid encrypted API payload'); + } + + const ciphertext = encryptedWithTag.subarray(0, -AES_GCM_AUTH_TAG_BYTES); + const tag = encryptedWithTag.subarray(-AES_GCM_AUTH_TAG_BYTES); + const decipher = createDecipheriv('aes-256-gcm', context.key, iv); + decipher.setAuthTag(tag); + + try { + const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + return JSON.parse(plaintext.toString('utf8')) as unknown; + } catch { + throw new BadRequestException('Cannot decrypt API payload'); + } + } + + private deriveKey(sessionId: string, clientPublicKey: ApiCryptoPublicJwk) { + const session = this.sessions.get(sessionId); + + if (!session || session.expiresAt <= Date.now()) { + this.sessions.delete(sessionId); + throw new UnauthorizedException('Encrypted API session expired'); + } + + const ecdh = createECDH('prime256v1'); + ecdh.setPrivateKey(session.privateKey); + const sharedSecret = ecdh.computeSecret(this.jwkToPublicKey(clientPublicKey)); + const key = hkdfSync( + 'sha256', + sharedSecret, + session.salt, + Buffer.from(`${HKDF_INFO_PREFIX}:${sessionId}`, 'utf8'), + AES_KEY_BYTES + ); + + return Buffer.from(key); + } + + private publicKeyToJwk(publicKey: Buffer): ApiCryptoPublicJwk { + if (publicKey.length !== 65 || publicKey[0] !== 4) { + throw new Error('Invalid P-256 public key'); + } + + return { + kty: 'EC', + crv: 'P-256', + x: this.base64UrlEncode(publicKey.subarray(1, 33)), + y: this.base64UrlEncode(publicKey.subarray(33, 65)), + ext: true + }; + } + + private jwkToPublicKey(jwk: ApiCryptoPublicJwk) { + if (jwk.kty !== 'EC' || jwk.crv !== 'P-256') { + throw new BadRequestException('Invalid encrypted API public key'); + } + + const x = this.base64UrlDecode(jwk.x); + const y = this.base64UrlDecode(jwk.y); + + if (x.length !== 32 || y.length !== 32) { + throw new BadRequestException('Invalid encrypted API public key'); + } + + return Buffer.concat([Buffer.from([4]), x, y]); + } + + private decodePublicKeyHeader(value: string | string[] | undefined) { + const encoded = this.readHeader(value); + if (!encoded) return null; + + try { + return JSON.parse(this.base64UrlDecode(encoded).toString('utf8')) as ApiCryptoPublicJwk; + } catch { + throw new BadRequestException('Invalid encrypted API public key header'); + } + } + + private readHeader(value: string | string[] | undefined) { + return Array.isArray(value) ? value[0] : value; + } + + private isEncryptedHeader(value: string | string[] | undefined) { + const header = this.readHeader(value)?.trim().toLowerCase(); + return header === 'v1' || header === '1' || header === 'true'; + } + + private sessionTtlSeconds() { + const configured = Number(process.env.API_CRYPTO_SESSION_TTL_SECONDS); + return Number.isFinite(configured) && configured > 0 + ? configured + : DEFAULT_SESSION_TTL_SECONDS; + } + + private readBooleanEnv(value: string | undefined) { + const normalized = value?.trim().toLowerCase(); + + if (!normalized || normalized === 'auto') return null; + if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; + if (['0', 'false', 'no', 'off'].includes(normalized)) return false; + + return null; + } + + private readEnabledFromConfig(value: unknown) { + if (typeof value === 'boolean') { + return value; + } + + if (typeof value === 'object' && value !== null && 'enabled' in value) { + return Boolean((value as { enabled?: unknown }).enabled); + } + + return false; + } + + private pruneExpiredSessions() { + const now = Date.now(); + + for (const [sessionId, session] of this.sessions.entries()) { + if (session.expiresAt <= now) { + this.sessions.delete(sessionId); + } + } + } + + private base64UrlEncode(input: Buffer) { + return input + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); + } + + private base64UrlDecode(value: string) { + const normalized = value.replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '='); + return Buffer.from(padded, 'base64'); + } +} diff --git a/backend/src/common/api-response.interceptor.ts b/backend/src/common/api-response.interceptor.ts new file mode 100644 index 0000000..22dfa77 --- /dev/null +++ b/backend/src/common/api-response.interceptor.ts @@ -0,0 +1,70 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, + StreamableFile +} from '@nestjs/common'; +import type { Response } from 'express'; +import { Observable, from, mergeMap } from 'rxjs'; +import { ApiCryptoService, type RequestWithApiCrypto } from './api-crypto.service'; +import type { RequestWithRequestId } from './request-id.middleware'; + +interface ApiEnvelope { + code: number; + message: string; + data: unknown; + request_id: string; +} + +function isApiEnvelope(value: unknown): value is ApiEnvelope { + return ( + typeof value === 'object' && + value !== null && + 'code' in value && + 'message' in value && + 'data' in value && + 'request_id' in value + ); +} + +@Injectable() +export class ApiResponseInterceptor implements NestInterceptor { + constructor(private readonly apiCrypto: ApiCryptoService) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const http = context.switchToHttp(); + const request = http.getRequest(); + const response = http.getResponse(); + const requestId = request.requestId || 'req_unknown'; + + return next.handle().pipe( + mergeMap((data) => { + if (isApiEnvelope(data)) { + this.markEncryptedResponse(request, response); + return from(Promise.resolve(this.apiCrypto.encryptForRequest(request, data))); + } + + if (data instanceof StreamableFile) { + return from(Promise.resolve(data)); + } + + const envelope = { + code: 0, + message: 'success', + data: data ?? null, + request_id: requestId + }; + + this.markEncryptedResponse(request, response); + return from(Promise.resolve(this.apiCrypto.encryptForRequest(request, envelope))); + }) + ); + } + + private markEncryptedResponse(request: RequestWithApiCrypto, response: Response) { + if (request.apiCrypto) { + response.setHeader('x-api-encrypted', 'v1'); + } + } +} diff --git a/backend/src/common/encrypted-request.middleware.ts b/backend/src/common/encrypted-request.middleware.ts new file mode 100644 index 0000000..cc188a4 --- /dev/null +++ b/backend/src/common/encrypted-request.middleware.ts @@ -0,0 +1,83 @@ +import { HttpException, HttpStatus, Inject, Injectable, NestMiddleware } from '@nestjs/common'; +import type { NextFunction, Response } from 'express'; +import { ApiCryptoService, type RequestWithApiCrypto } from './api-crypto.service'; + +@Injectable() +export class EncryptedRequestMiddleware implements NestMiddleware { + constructor(@Inject(ApiCryptoService) private readonly apiCrypto: ApiCryptoService) {} + + use(req: RequestWithApiCrypto, res: Response, next: NextFunction) { + void this.handle(req, res, next); + } + + private async handle(req: RequestWithApiCrypto, res: Response, next: NextFunction) { + if (this.isConfigRoute(req)) { + next(); + return; + } + + const isEncryptedRequest = this.apiCrypto.shouldUseEncryptedApi(req); + const isCryptoEnabled = await this.apiCrypto.isEnabled(); + + if (!isEncryptedRequest && !isCryptoEnabled) { + next(); + return; + } + + try { + if (!isEncryptedRequest) { + throw new HttpException('Encrypted API is enabled, please encrypt this request', HttpStatus.BAD_REQUEST); + } + + const envelope = await this.apiCrypto.attachRequestContext(req); + + if (envelope) { + req.body = this.apiCrypto.decryptRequestBody(envelope, req.apiCrypto!); + } else if (this.requiresEncryptedBody(req)) { + throw new HttpException('Encrypted API request body is required', HttpStatus.BAD_REQUEST); + } + + next(); + } catch (error) { + const status = error instanceof HttpException ? error.getStatus() : HttpStatus.BAD_REQUEST; + const message = error instanceof Error ? error.message : 'Cannot decrypt API payload'; + const payload = { + code: status, + message, + data: null, + request_id: req.requestId || 'req_unknown' + }; + const body = this.apiCrypto.encryptForRequest(req, payload); + + if (req.apiCrypto) { + res.setHeader('x-api-encrypted', 'v1'); + } + + res.status(status).json(body); + } + } + + private isConfigRoute(req: RequestWithApiCrypto) { + const requestWithUrl = req as RequestWithApiCrypto & { originalUrl?: string }; + const path = requestWithUrl.originalUrl || req.path || req.url || ''; + + return ( + path === '/api/crypto/handshake' || + path === '/api/client-config' || + path === '/crypto/handshake' || + path === '/client-config' + ); + } + + private requiresEncryptedBody(req: RequestWithApiCrypto) { + const method = req.method.toUpperCase(); + const contentType = req.headers['content-type']; + const normalizedContentType = Array.isArray(contentType) ? contentType[0] : contentType; + + return ( + method !== 'GET' && + method !== 'HEAD' && + Boolean(normalizedContentType?.includes('application/json')) + ); + } +} diff --git a/backend/src/common/request-id.middleware.ts b/backend/src/common/request-id.middleware.ts new file mode 100644 index 0000000..5319f34 --- /dev/null +++ b/backend/src/common/request-id.middleware.ts @@ -0,0 +1,19 @@ +import { randomUUID } from 'node:crypto'; +import { Injectable, NestMiddleware } from '@nestjs/common'; +import type { NextFunction, Request, Response } from 'express'; + +export interface RequestWithRequestId extends Request { + requestId?: string; +} + +@Injectable() +export class RequestIdMiddleware implements NestMiddleware { + use(req: RequestWithRequestId, res: Response, next: NextFunction) { + const incoming = req.headers['x-request-id']; + const requestId = Array.isArray(incoming) ? incoming[0] : incoming; + + req.requestId = requestId || `req_${randomUUID()}`; + res.setHeader('x-request-id', req.requestId); + next(); + } +} diff --git a/backend/src/common/request-with-id.ts b/backend/src/common/request-with-id.ts new file mode 100644 index 0000000..18e2ade --- /dev/null +++ b/backend/src/common/request-with-id.ts @@ -0,0 +1,5 @@ +export interface RequestWithId { + requestId?: string; + headers?: Record; + user?: unknown; +} diff --git a/backend/src/common/secure-transport.middleware.spec.ts b/backend/src/common/secure-transport.middleware.spec.ts new file mode 100644 index 0000000..7d8924b --- /dev/null +++ b/backend/src/common/secure-transport.middleware.spec.ts @@ -0,0 +1,90 @@ +import type { NextFunction, Request, Response } from 'express'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { SecureTransportMiddleware } from './secure-transport.middleware'; + +const originalEnv = { ...process.env }; + +function createRequest(overrides: Partial = {}) { + return { + headers: {}, + hostname: 'api.example.test', + ip: '203.0.113.10', + protocol: 'http', + secure: false, + requestId: 'req_test', + ...overrides + } as Request & { requestId: string }; +} + +function createResponse() { + const headers: Record = {}; + const response = { + setHeader: vi.fn((name: string, value: string) => { + headers[name.toLowerCase()] = value; + }), + status: vi.fn().mockReturnThis(), + json: vi.fn() + }; + + return { response: response as unknown as Response, headers, raw: response }; +} + +describe('SecureTransportMiddleware', () => { + afterEach(() => { + process.env = { ...originalEnv }; + vi.restoreAllMocks(); + }); + + it('allows local HTTP when local development fallback is enabled', () => { + process.env.HTTPS_REQUIRED = 'true'; + const middleware = new SecureTransportMiddleware(); + const req = createRequest({ hostname: '127.0.0.1', ip: '127.0.0.1' }); + const { response, headers, raw } = createResponse(); + const next = vi.fn(); + + middleware.use(req, response, next as unknown as NextFunction); + + expect(next).toHaveBeenCalledOnce(); + expect(raw.status).not.toHaveBeenCalled(); + expect(headers['x-content-type-options']).toBe('nosniff'); + expect(headers['strict-transport-security']).toContain('max-age=31536000'); + }); + + it('rejects non-local HTTP requests when HTTPS is required', () => { + process.env.HTTPS_REQUIRED = 'true'; + const middleware = new SecureTransportMiddleware(); + const req = createRequest(); + const { response, raw } = createResponse(); + const next = vi.fn(); + + middleware.use(req, response, next as unknown as NextFunction); + + expect(next).not.toHaveBeenCalled(); + expect(raw.status).toHaveBeenCalledWith(426); + expect(raw.json).toHaveBeenCalledWith({ + code: 426, + message: 'HTTPS is required for API requests', + data: null, + request_id: 'req_test' + }); + }); + + it('accepts HTTPS forwarded by the reverse proxy', () => { + process.env.HTTPS_REQUIRED = 'true'; + process.env.HTTPS_ALLOW_LOCAL_HTTP = 'false'; + const middleware = new SecureTransportMiddleware(); + const req = createRequest({ + headers: { 'x-forwarded-proto': 'https' }, + hostname: 'api.example.test', + ip: '203.0.113.10' + }); + const { response, headers, raw } = createResponse(); + const next = vi.fn(); + + middleware.use(req, response, next as unknown as NextFunction); + + expect(next).toHaveBeenCalledOnce(); + expect(raw.status).not.toHaveBeenCalled(); + expect(headers['strict-transport-security']).toBe('max-age=31536000; includeSubDomains'); + }); +}); diff --git a/backend/src/common/secure-transport.middleware.ts b/backend/src/common/secure-transport.middleware.ts new file mode 100644 index 0000000..4ef6068 --- /dev/null +++ b/backend/src/common/secure-transport.middleware.ts @@ -0,0 +1,66 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import type { NextFunction, Request, Response } from 'express'; +import type { RequestWithRequestId } from './request-id.middleware'; + +const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1']); + +@Injectable() +export class SecureTransportMiddleware implements NestMiddleware { + use(req: RequestWithRequestId, res: Response, next: NextFunction) { + this.setSecurityHeaders(req, res); + + if (this.isHttpsRequired() && !this.isSecureRequest(req) && !this.isLocalRequest(req)) { + res.status(426).json({ + code: 426, + message: 'HTTPS is required for API requests', + data: null, + request_id: req.requestId || 'req_unknown' + }); + return; + } + + next(); + } + + private setSecurityHeaders(req: Request, res: Response) { + res.setHeader('x-content-type-options', 'nosniff'); + res.setHeader('x-frame-options', 'DENY'); + res.setHeader('referrer-policy', 'no-referrer'); + res.setHeader('permissions-policy', 'camera=(), microphone=(), geolocation=()'); + res.setHeader('cross-origin-resource-policy', 'same-origin'); + + if (this.isHttpsRequired() || this.isSecureRequest(req)) { + res.setHeader( + 'strict-transport-security', + 'max-age=31536000; includeSubDomains' + ); + } + } + + private isHttpsRequired() { + const configured = process.env.HTTPS_REQUIRED?.trim().toLowerCase(); + + if (configured === 'true') return true; + if (configured === 'false') return false; + + return process.env.NODE_ENV === 'production'; + } + + private isSecureRequest(req: Request) { + const forwardedProto = req.headers['x-forwarded-proto']; + const proto = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto; + const firstProto = proto?.split(',')[0]?.trim().toLowerCase(); + + return req.secure || firstProto === 'https' || req.protocol === 'https'; + } + + private isLocalRequest(req: Request) { + if (process.env.HTTPS_ALLOW_LOCAL_HTTP === 'false') { + return false; + } + + const host = req.hostname || req.ip || ''; + + return LOCAL_HOSTS.has(host) || req.ip === '::ffff:127.0.0.1'; + } +} diff --git a/backend/src/config/load-env.ts b/backend/src/config/load-env.ts new file mode 100644 index 0000000..e1eaab6 --- /dev/null +++ b/backend/src/config/load-env.ts @@ -0,0 +1,56 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +function parseEnvValue(rawValue: string) { + let value = rawValue.trim(); + + if (!value) return ''; + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + + return value + .replace(/\\n/g, '\n') + .replace(/\\r/g, '\r') + .replace(/\\t/g, '\t'); +} + +function parseEnvFile(content: string) { + const result: Record = {}; + + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + + if (!trimmed || trimmed.startsWith('#')) continue; + + const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(trimmed); + if (!match) continue; + + result[match[1]] = parseEnvValue(match[2]); + } + + return result; +} + +export function loadEnvFiles() { + const initialKeys = new Set(Object.keys(process.env)); + const backendRoot = resolve(__dirname, '..', '..'); + const repoRoot = resolve(backendRoot, '..'); + const files = [...new Set([resolve(repoRoot, '.env'), resolve(backendRoot, '.env')])]; + + for (const file of files) { + if (!existsSync(file)) continue; + + const parsed = parseEnvFile(readFileSync(file, 'utf8')); + + for (const [key, value] of Object.entries(parsed)) { + if (initialKeys.has(key)) continue; + process.env[key] = value; + } + } +} + +loadEnvFiles(); diff --git a/backend/src/episodes/episode.dto.ts b/backend/src/episodes/episode.dto.ts new file mode 100644 index 0000000..06b1e1e --- /dev/null +++ b/backend/src/episodes/episode.dto.ts @@ -0,0 +1,17 @@ +import type { EpisodeStatus } from './episode.types'; + +export class GenerateEpisodePlanDto { + target_episode_count?: number; +} + +export class UpdateEpisodeDto { + episode_no?: number; + source_chapter_ids?: string[]; + title?: string; + summary?: string; + opening_hook?: string; + middle_conflict?: string; + ending_hook?: string; + target_duration?: number; + status?: EpisodeStatus; +} diff --git a/backend/src/episodes/episode.types.ts b/backend/src/episodes/episode.types.ts new file mode 100644 index 0000000..1b97f59 --- /dev/null +++ b/backend/src/episodes/episode.types.ts @@ -0,0 +1,39 @@ +import type { Episode, Prisma } from '@prisma/client'; + +export const EPISODE_STATUSES = ['draft', 'generated', 'edited', 'confirmed'] as const; + +export type EpisodeStatus = (typeof EPISODE_STATUSES)[number]; + +export interface SafeEpisode { + id: string; + project_id: string; + episode_no: number; + source_chapter_ids: Prisma.JsonValue | null; + title: string | null; + summary: string | null; + opening_hook: string | null; + middle_conflict: string | null; + ending_hook: string | null; + target_duration: number | null; + status: string; + created_at: string; + updated_at: string; +} + +export function toSafeEpisode(episode: Episode): SafeEpisode { + return { + id: episode.id.toString(), + project_id: episode.project_id.toString(), + episode_no: episode.episode_no, + source_chapter_ids: episode.source_chapter_ids, + title: episode.title, + summary: episode.summary, + opening_hook: episode.opening_hook, + middle_conflict: episode.middle_conflict, + ending_hook: episode.ending_hook, + target_duration: episode.target_duration, + status: episode.status, + created_at: episode.created_at.toISOString(), + updated_at: episode.updated_at.toISOString() + }; +} diff --git a/backend/src/episodes/episodes.controller.ts b/backend/src/episodes/episodes.controller.ts new file mode 100644 index 0000000..c1f7dd2 --- /dev/null +++ b/backend/src/episodes/episodes.controller.ts @@ -0,0 +1,46 @@ +import { Body, Controller, Get, Inject, Param, Patch, Post, UseGuards } from '@nestjs/common'; +import { CurrentUser } from '../auth/current-user.decorator'; +import type { AuthRequestUser } from '../auth/auth.types'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { GenerateEpisodePlanDto, UpdateEpisodeDto } from './episode.dto'; +import { EpisodesService } from './episodes.service'; + +@Controller() +@UseGuards(JwtAuthGuard) +export class EpisodesController { + constructor(@Inject(EpisodesService) private readonly episodesService: EpisodesService) {} + + @Post('projects/:projectId/episodes/generate-plan') + generatePlan( + @CurrentUser() user: AuthRequestUser, + @Param('projectId') projectId: string, + @Body() dto: GenerateEpisodePlanDto + ) { + return this.episodesService.generatePlan(user, projectId, dto); + } + + @Get('projects/:projectId/episodes') + listEpisodes( + @CurrentUser() user: AuthRequestUser, + @Param('projectId') projectId: string + ) { + return this.episodesService.listEpisodes(user, projectId); + } + + @Patch('episodes/:episodeId') + updateEpisode( + @CurrentUser() user: AuthRequestUser, + @Param('episodeId') episodeId: string, + @Body() dto: UpdateEpisodeDto + ) { + return this.episodesService.updateEpisode(user, episodeId, dto); + } + + @Post('projects/:projectId/episodes/confirm') + confirmEpisodes( + @CurrentUser() user: AuthRequestUser, + @Param('projectId') projectId: string + ) { + return this.episodesService.confirmEpisodes(user, projectId); + } +} diff --git a/backend/src/episodes/episodes.module.ts b/backend/src/episodes/episodes.module.ts new file mode 100644 index 0000000..749516d --- /dev/null +++ b/backend/src/episodes/episodes.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { PrismaModule } from '../prisma/prisma.module'; +import { EpisodesController } from './episodes.controller'; +import { EpisodesService } from './episodes.service'; + +@Module({ + imports: [AuthModule, PrismaModule], + controllers: [EpisodesController], + providers: [EpisodesService], + exports: [EpisodesService] +}) +export class EpisodesModule {} diff --git a/backend/src/episodes/episodes.service.spec.ts b/backend/src/episodes/episodes.service.spec.ts new file mode 100644 index 0000000..48dcedd --- /dev/null +++ b/backend/src/episodes/episodes.service.spec.ts @@ -0,0 +1,353 @@ +import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { + Character, + Episode, + NovelChapter, + PlotMemory, + PlotThread, + Project, + StoryBible +} from '@prisma/client'; +import type { AuthRequestUser } from '../auth/auth.types'; +import type { PrismaService } from '../prisma/prisma.service'; +import { EpisodesService } from './episodes.service'; + +const user: AuthRequestUser = { + id: '1', + email: 'user@example.com', + role: 'user' +}; + +const now = new Date('2026-05-31T00:00:00.000Z'); + +function createProject(overrides: Partial = {}): Project { + return { + id: 10n, + user_id: 1n, + title: '重生归来,我只搞事业', + input_mode: 'ai_original', + genre: 'urban_rebirth', + style_code: 'korean_comic', + output_type: 'short_video', + output_mode: 'image_manga', + visual_mode: 'korean_manga', + video_generation_level: 'standard', + target_episode_count: 3, + episode_duration: 60, + status: 'character_confirmed', + copyright_status: 'ai_original', + payment_status: 'unpaid', + quality_level: 'mvp', + is_long_series: false, + created_at: now, + updated_at: now, + completed_at: null, + ...overrides + }; +} + +function createStoryBible(overrides: Partial = {}): StoryBible { + return { + id: 40n, + project_id: 10n, + title: '重生归来,我只搞事业', + logline: '林晚重回命运转折点,用证据夺回项目。', + main_plot: '林晚夺回原创项目控制权,周启持续制造阻碍。', + core_conflict: '林晚必须在资本压力中守住原创项目。', + selling_points: '重生归来\n证据反杀', + tone: '克制、锋利、连续反转', + world_summary: '现代都市内容公司', + ending_direction: '幕后真相继续推进。', + taboo_rules: '不得改变主角姓名。', + version: 1, + status: 'confirmed', + created_at: now, + updated_at: now, + ...overrides + }; +} + +function createCharacter(overrides: Partial = {}): Character { + return { + id: 50n, + project_id: 10n, + global_character_id: null, + name: '林晚', + alias_names: [], + role_type: 'protagonist', + gender_label: '女', + age_group: '青年', + identity_desc: '故事主角', + appearance_desc: '眼神坚定', + face_desc: '精致脸型', + hair_desc: '深色中长发', + eye_desc: '深色眼睛', + body_desc: '身形修长', + costume_rules: '现代都市通勤装', + special_props: '手机、录音证据', + personality_desc: '冷静克制', + speech_style: '短句明确', + relationship_desc: '与周启围绕项目控制权对抗', + character_arc: '从被动到主动', + negative_rules: '不得改名', + anchor_asset_id: null, + wardrobe_variant: null, + voice_provider_code: null, + voice_model: null, + voice_id: null, + voice_style: null, + performance_style: null, + importance_level: 100, + status: 'locked', + created_at: now, + updated_at: now, + ...overrides + }; +} + +function createChapter(overrides: Partial = {}): NovelChapter { + return { + id: 30n, + project_id: 10n, + novel_source_id: 20n, + chapter_no: 1, + title: '第1章 暴雨重启', + content: '林晚站在暴雨夜里醒来,决定重新夺回项目。', + summary: '林晚确认重生并整理证据。', + visual_summary: '暴雨夜,林晚醒来,手机录音亮起。', + word_count: 22, + status: 'generated', + created_at: now, + ...overrides + }; +} + +function createPlotMemory(overrides: Partial = {}): PlotMemory { + return { + id: 60n, + project_id: 10n, + episode_id: null, + chapter_id: 30n, + memory_type: 'foreshadowing', + content: '录音证据会在后续揭开幕后真相。', + importance_level: 90, + status: 'active', + created_at: now, + ...overrides + }; +} + +function createPlotThread(overrides: Partial = {}): PlotThread { + return { + id: 70n, + project_id: 10n, + thread_name: '主线目标', + thread_type: 'main_plot', + description: '林晚夺回原创项目控制权。', + start_episode_no: 1, + expected_resolve_episode_no: 3, + resolved_episode_no: null, + status: 'open', + created_at: now, + updated_at: now, + ...overrides + }; +} + +function createEpisode(overrides: Partial = {}): Episode { + return { + id: 80n, + project_id: 10n, + episode_no: 1, + source_chapter_ids: ['30'], + title: '第1集 暴雨重启', + summary: '林晚确认重生并整理证据。', + opening_hook: '林晚在暴雨夜发现关键转机。', + middle_conflict: '周启试图转移责任。', + ending_hook: '录音证据指向幕后真相。', + target_duration: 60, + status: 'generated', + created_at: now, + updated_at: now, + ...overrides + }; +} + +describe('EpisodesService', () => { + let prisma: any; + let tx: any; + let service: EpisodesService; + + beforeEach(() => { + tx = { + episode: { + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + createMany: vi.fn().mockResolvedValue({ count: 3 }), + findMany: vi.fn().mockResolvedValue([ + createEpisode(), + createEpisode({ id: 81n, episode_no: 2, title: '第2集 会议反击' }), + createEpisode({ id: 82n, episode_no: 3, title: '第3集 真相逼近' }) + ]), + updateMany: vi.fn().mockResolvedValue({ count: 3 }) + }, + project: { + update: vi.fn().mockResolvedValue(createProject({ status: 'waiting_episode_confirm' })) + } + }; + prisma = { + project: { + findUnique: vi.fn().mockResolvedValue(createProject()), + update: vi.fn().mockResolvedValue(createProject({ status: 'episode_planning' })) + }, + storyBible: { + findFirst: vi.fn().mockResolvedValue(createStoryBible()) + }, + character: { + findMany: vi.fn().mockResolvedValue([ + createCharacter(), + createCharacter({ + id: 51n, + name: '周启', + role_type: 'antagonist', + importance_level: 80 + }) + ]) + }, + novelChapter: { + findMany: vi.fn().mockResolvedValue([ + createChapter(), + createChapter({ + id: 31n, + chapter_no: 2, + title: '第2章 会议反击', + summary: '林晚在会议上用证据反击周启。' + }), + createChapter({ + id: 32n, + chapter_no: 3, + title: '第3章 真相逼近', + summary: '幕后投资人的名字第一次出现。' + }) + ]), + count: vi.fn().mockResolvedValue(1) + }, + plotMemory: { + findMany: vi.fn().mockResolvedValue([ + createPlotMemory(), + createPlotMemory({ + id: 61n, + memory_type: 'unresolved_conflict', + content: '林晚必须在资本压力中守住原创项目。' + }) + ]) + }, + plotThread: { + findMany: vi.fn().mockResolvedValue([createPlotThread()]) + }, + episode: { + count: vi.fn().mockResolvedValue(0), + findMany: vi.fn().mockResolvedValue([createEpisode()]), + findUnique: vi.fn().mockResolvedValue(createEpisode()), + findFirst: vi.fn().mockResolvedValue(null), + update: vi.fn().mockResolvedValue(createEpisode({ status: 'edited', title: '第1集 新标题' })) + }, + $transaction: vi.fn(async (callback: (transaction: typeof tx) => unknown) => callback(tx)) + }; + service = new EpisodesService(prisma as PrismaService); + }); + + it('generates an episode plan from story, character, and memory context', async () => { + const result = await service.generatePlan(user, '10', {}); + + expect(prisma.project.update).toHaveBeenCalledWith({ + where: { id: 10n }, + data: { status: 'episode_planning' } + }); + expect(tx.episode.deleteMany).toHaveBeenCalledWith({ where: { project_id: 10n } }); + expect(tx.episode.createMany.mock.calls[0][0].data).toHaveLength(3); + expect(tx.episode.createMany.mock.calls[0][0].data[0]).toEqual( + expect.objectContaining({ + project_id: 10n, + episode_no: 1, + status: 'generated' + }) + ); + expect(tx.project.update).toHaveBeenCalledWith({ + where: { id: 10n }, + data: { status: 'waiting_episode_confirm' } + }); + expect(result.episodes).toHaveLength(3); + expect(result.next_step).toBe('episode_confirm'); + }); + + it('requires long-form memories before planning episodes', async () => { + prisma.plotMemory.findMany.mockResolvedValue([]); + + await expect(service.generatePlan(user, '10', {})).rejects.toBeInstanceOf( + BadRequestException + ); + }); + + it('updates an editable episode and marks it edited', async () => { + const result = await service.updateEpisode(user, '80', { + title: '第1集 新标题', + opening_hook: '新开头钩子' + }); + + expect(prisma.episode.update).toHaveBeenCalledWith({ + where: { id: 80n }, + data: expect.objectContaining({ + title: '第1集 新标题', + opening_hook: '新开头钩子', + status: 'edited' + }) + }); + expect(result.status).toBe('edited'); + }); + + it('blocks editing confirmed episodes', async () => { + prisma.episode.findUnique.mockResolvedValue(createEpisode({ status: 'confirmed' })); + + await expect(service.updateEpisode(user, '80', { title: '不可编辑' })).rejects.toBeInstanceOf( + BadRequestException + ); + }); + + it('confirms a complete episode plan', async () => { + prisma.episode.findMany.mockResolvedValue([ + createEpisode(), + createEpisode({ id: 81n, episode_no: 2 }), + createEpisode({ id: 82n, episode_no: 3 }) + ]); + + const result = await service.confirmEpisodes(user, '10'); + + expect(tx.episode.updateMany).toHaveBeenCalledWith({ + where: { + project_id: 10n, + status: { in: ['draft', 'generated', 'edited'] } + }, + data: { status: 'confirmed' } + }); + expect(tx.project.update).toHaveBeenCalledWith({ + where: { id: 10n }, + data: { status: 'episode_confirmed' } + }); + expect(result.next_step).toBe('script_generate'); + }); + + it('rejects incomplete episode confirmation', async () => { + prisma.episode.findMany.mockResolvedValue([createEpisode({ ending_hook: null })]); + + await expect(service.confirmEpisodes(user, '10')).rejects.toBeInstanceOf( + BadRequestException + ); + }); + + it('rejects access to another user project', async () => { + prisma.project.findUnique.mockResolvedValue(createProject({ user_id: 2n })); + + await expect(service.listEpisodes(user, '10')).rejects.toBeInstanceOf(ForbiddenException); + }); +}); diff --git a/backend/src/episodes/episodes.service.ts b/backend/src/episodes/episodes.service.ts new file mode 100644 index 0000000..7bbfdf3 --- /dev/null +++ b/backend/src/episodes/episodes.service.ts @@ -0,0 +1,501 @@ +import { + BadRequestException, + ForbiddenException, + Inject, + Injectable, + NotFoundException +} from '@nestjs/common'; +import type { + Character, + Episode, + NovelChapter, + PlotMemory, + PlotThread, + Prisma, + Project, + StoryBible +} from '@prisma/client'; +import type { AuthRequestUser } from '../auth/auth.types'; +import { PrismaService } from '../prisma/prisma.service'; +import { GenerateEpisodePlanDto, UpdateEpisodeDto } from './episode.dto'; +import { EPISODE_STATUSES, toSafeEpisode, type EpisodeStatus } from './episode.types'; + +const MIN_EPISODES = 1; +const MAX_EPISODES = 100; +const MIN_DURATION = 15; +const MAX_DURATION = 600; + +interface EpisodeDraft { + episode_no: number; + source_chapter_ids: Prisma.InputJsonValue; + title: string; + summary: string; + opening_hook: string; + middle_conflict: string; + ending_hook: string; + target_duration: number; + status: EpisodeStatus; +} + +interface EpisodePlanContext { + storyBible: StoryBible; + characters: Character[]; + chapters: NovelChapter[]; + plotMemories: PlotMemory[]; + plotThreads: PlotThread[]; +} + +@Injectable() +export class EpisodesService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async generatePlan(user: AuthRequestUser, projectId: string, dto: GenerateEpisodePlanDto) { + const project = await this.findProjectForUser(projectId, user); + const count = this.resolveEpisodeCount(project, dto.target_episode_count); + const context = await this.loadPlanContext(project.id); + const existingConfirmed = await this.prisma.episode.count({ + where: { + project_id: project.id, + status: 'confirmed' + } + }); + + if (existingConfirmed > 0) { + throw new BadRequestException('Confirmed episodes cannot be regenerated'); + } + + const drafts = this.buildEpisodeDrafts(project, count, context); + + await this.prisma.project.update({ + where: { id: project.id }, + data: { status: 'episode_planning' } + }); + + const episodes = await this.prisma.$transaction(async (tx) => { + await tx.episode.deleteMany({ + where: { project_id: project.id } + }); + await tx.episode.createMany({ + data: drafts.map((draft) => ({ + project_id: project.id, + ...draft + })) + }); + const saved = await tx.episode.findMany({ + where: { project_id: project.id }, + orderBy: { episode_no: 'asc' } + }); + await tx.project.update({ + where: { id: project.id }, + data: { status: 'waiting_episode_confirm' } + }); + return saved; + }); + + return { + episodes: episodes.map(toSafeEpisode), + memory_context: { + story_bible_id: context.storyBible.id.toString(), + locked_character_count: context.characters.length, + active_plot_memory_count: context.plotMemories.length, + open_thread_count: context.plotThreads.length + }, + next_step: 'episode_confirm' + }; + } + + async listEpisodes(user: AuthRequestUser, projectId: string) { + const project = await this.findProjectForUser(projectId, user); + const episodes = await this.prisma.episode.findMany({ + where: { project_id: project.id }, + orderBy: { episode_no: 'asc' } + }); + + return episodes.map(toSafeEpisode); + } + + async updateEpisode(user: AuthRequestUser, episodeId: string, dto: UpdateEpisodeDto) { + const episode = await this.findEpisodeForUser(episodeId, user); + + if (episode.status === 'confirmed') { + throw new BadRequestException('Confirmed episodes cannot be edited'); + } + + const data = await this.createUpdateData(episode, dto); + + if (Object.keys(data).length === 0) { + throw new BadRequestException('No episode fields to update'); + } + + const updated = await this.prisma.episode.update({ + where: { id: episode.id }, + data + }); + + await this.prisma.project.update({ + where: { id: episode.project_id }, + data: { status: 'waiting_episode_confirm' } + }); + + return toSafeEpisode(updated); + } + + async confirmEpisodes(user: AuthRequestUser, projectId: string) { + const project = await this.findProjectForUser(projectId, user); + const episodes = await this.prisma.episode.findMany({ + where: { project_id: project.id }, + orderBy: { episode_no: 'asc' } + }); + + this.assertEpisodesReadyForConfirmation(episodes); + + const confirmed = await this.prisma.$transaction(async (tx) => { + await tx.episode.updateMany({ + where: { + project_id: project.id, + status: { in: ['draft', 'generated', 'edited'] } + }, + data: { status: 'confirmed' } + }); + const saved = await tx.episode.findMany({ + where: { project_id: project.id }, + orderBy: { episode_no: 'asc' } + }); + await tx.project.update({ + where: { id: project.id }, + data: { status: 'episode_confirmed' } + }); + return saved; + }); + + return { + episodes: confirmed.map(toSafeEpisode), + next_step: 'script_generate' + }; + } + + private async loadPlanContext(projectId: bigint): Promise { + const [storyBible, characters, chapters, plotMemories, plotThreads] = await Promise.all([ + this.prisma.storyBible.findFirst({ + where: { + project_id: projectId, + status: 'confirmed' + }, + orderBy: { version: 'desc' } + }), + this.prisma.character.findMany({ + where: { + project_id: projectId, + status: 'locked' + }, + orderBy: [{ importance_level: 'desc' }, { id: 'asc' }] + }), + this.prisma.novelChapter.findMany({ + where: { project_id: projectId }, + orderBy: { chapter_no: 'asc' } + }), + this.prisma.plotMemory.findMany({ + where: { + project_id: projectId, + status: 'active' + }, + orderBy: [{ importance_level: 'desc' }, { id: 'asc' }] + }), + this.prisma.plotThread.findMany({ + where: { + project_id: projectId, + status: { in: ['open', 'progressing', 'paused'] } + }, + orderBy: [{ status: 'asc' }, { id: 'asc' }] + }) + ]); + + if (!storyBible) { + throw new BadRequestException('Confirmed story bible is required before episode planning'); + } + if (characters.length === 0) { + throw new BadRequestException('Locked characters are required before episode planning'); + } + if (chapters.length === 0) { + throw new BadRequestException('Novel chapters are required before episode planning'); + } + if (plotMemories.length === 0) { + throw new BadRequestException('Long-form plot memories are required before episode planning'); + } + + return { + storyBible, + characters, + chapters, + plotMemories, + plotThreads + }; + } + + private buildEpisodeDrafts( + project: Project, + count: number, + context: EpisodePlanContext + ): EpisodeDraft[] { + const protagonist = + context.characters.find((character) => ['protagonist', 'lead'].includes(character.role_type)) ?? + context.characters[0]; + const antagonist = context.characters.find((character) => character.role_type === 'antagonist'); + const importantForeshadowing = context.plotMemories.find( + (memory) => memory.memory_type === 'foreshadowing' + ); + const unresolvedConflict = context.plotMemories.find( + (memory) => memory.memory_type === 'unresolved_conflict' + ); + const mainThread = + context.plotThreads.find((thread) => thread.thread_type === 'main_plot') ?? + context.plotThreads[0]; + const duration = this.validateDuration(project.episode_duration ?? 60); + + return Array.from({ length: count }, (_, index) => { + const episodeNo = index + 1; + const chapterGroup = this.pickChapterGroup(context.chapters, index, count); + const firstChapter = chapterGroup[0] ?? context.chapters[0]; + const lastChapter = chapterGroup.at(-1) ?? firstChapter; + const chapterSummary = chapterGroup + .map((chapter) => chapter.summary || this.compact(chapter.content).slice(0, 70)) + .join(';'); + const sourceChapterIds = chapterGroup.map((chapter) => chapter.id.toString()); + const threadText = mainThread?.description || context.storyBible.main_plot || '主线目标持续推进'; + + return { + episode_no: episodeNo, + source_chapter_ids: sourceChapterIds, + title: this.buildEpisodeTitle(episodeNo, firstChapter, count), + summary: [ + `${protagonist.name}围绕${this.compact(threadText).slice(0, 80)}推进第${episodeNo}集。`, + chapterSummary, + episodeNo === count + ? context.storyBible.ending_direction || '阶段性回收关键伏笔,并保留下一阶段入口。' + : '本集保留短视频节奏,结尾留下可承接悬念。' + ].filter(Boolean).join(' '), + opening_hook: + episodeNo === 1 + ? `${protagonist.name}在高压场景中发现关键转机,观众第一秒进入冲突。` + : `承接上一集悬念,${protagonist.name}立刻面对新的选择和压力。`, + middle_conflict: + unresolvedConflict?.content || + `${antagonist?.name ?? '主要对手'}围绕核心利益继续施压,${protagonist.name}必须用证据或行动反击。`, + ending_hook: this.buildEndingHook( + episodeNo, + count, + protagonist.name, + lastChapter, + importantForeshadowing, + context.storyBible + ), + target_duration: duration, + status: 'generated' + }; + }); + } + + private buildEpisodeTitle(episodeNo: number, chapter: NovelChapter, count: number) { + const cleaned = chapter.title + ?.replace(/^第?[0-9一二三四五六七八九十百千万]+[章节集回话、.\s-]*/u, '') + .trim(); + const fallback = episodeNo === count ? '真相逼近' : episodeNo === 1 ? '开局反击' : '冲突升级'; + return `第${episodeNo}集 ${cleaned || fallback}`; + } + + private buildEndingHook( + episodeNo: number, + count: number, + protagonistName: string, + chapter: NovelChapter, + foreshadowing: PlotMemory | undefined, + storyBible: StoryBible + ) { + if (episodeNo === count) { + return storyBible.ending_direction || `${protagonistName}阶段性赢下对抗,但幕后真相仍未完全揭开。`; + } + + const source = foreshadowing?.content || chapter.summary || chapter.title || '关键线索'; + return `${protagonistName}发现${this.compact(source).slice(0, 42)},下一集必须继续追查。`; + } + + private pickChapterGroup(chapters: NovelChapter[], index: number, count: number) { + const start = Math.floor((index * chapters.length) / count); + const end = Math.max(start + 1, Math.floor(((index + 1) * chapters.length) / count)); + return chapters.slice(start, Math.min(end, chapters.length)); + } + + private async createUpdateData( + episode: Episode, + dto: UpdateEpisodeDto + ): Promise { + const data: Prisma.EpisodeUncheckedUpdateInput = {}; + + if ('episode_no' in dto) { + data.episode_no = await this.validateEpisodeNoForUpdate(episode, dto.episode_no); + } + if ('source_chapter_ids' in dto) { + data.source_chapter_ids = await this.validateSourceChapterIds( + episode.project_id, + dto.source_chapter_ids + ); + } + if ('title' in dto) data.title = this.optionalText(dto.title); + if ('summary' in dto) data.summary = this.optionalText(dto.summary); + if ('opening_hook' in dto) data.opening_hook = this.optionalText(dto.opening_hook); + if ('middle_conflict' in dto) data.middle_conflict = this.optionalText(dto.middle_conflict); + if ('ending_hook' in dto) data.ending_hook = this.optionalText(dto.ending_hook); + if ('target_duration' in dto) { + data.target_duration = this.validateDuration(dto.target_duration); + } + if ('status' in dto) data.status = this.validateStatus(dto.status); + + if (Object.keys(data).length > 0 && data.status !== 'confirmed') { + data.status = data.status ?? 'edited'; + } + + return data; + } + + private async validateEpisodeNoForUpdate(episode: Episode, value: number | undefined) { + const episodeNo = this.validatePositiveInt(value, 'episode_no', MIN_EPISODES, MAX_EPISODES); + + if (episodeNo === episode.episode_no) { + return episodeNo; + } + + const existing = await this.prisma.episode.findFirst({ + where: { + project_id: episode.project_id, + episode_no: episodeNo, + id: { not: episode.id } + } + }); + + if (existing) { + throw new BadRequestException('episode_no already exists in this project'); + } + + return episodeNo; + } + + private async validateSourceChapterIds(projectId: bigint, value: string[] | undefined) { + if (!Array.isArray(value) || value.length === 0) { + throw new BadRequestException('source_chapter_ids must be a non-empty array'); + } + + const ids = value.map((item) => this.parseId(String(item), 'Invalid source chapter id')); + const count = await this.prisma.novelChapter.count({ + where: { + project_id: projectId, + id: { in: ids } + } + }); + + if (count !== ids.length) { + throw new BadRequestException('source_chapter_ids contain chapters outside this project'); + } + + return ids.map((id) => id.toString()); + } + + private assertEpisodesReadyForConfirmation(episodes: Episode[]) { + if (episodes.length === 0) { + throw new BadRequestException('Episode plan is required before confirmation'); + } + + for (const [index, episode] of episodes.entries()) { + if (episode.episode_no !== index + 1) { + throw new BadRequestException('Episode numbers must be continuous from 1'); + } + + if ( + !episode.title || + !episode.summary || + !episode.opening_hook || + !episode.middle_conflict || + !episode.ending_hook || + !episode.target_duration + ) { + throw new BadRequestException('All episodes must include title, hooks, conflict, summary, and duration'); + } + } + } + + private async findProjectForUser(projectId: string, user: AuthRequestUser) { + const project = await this.prisma.project.findUnique({ + where: { id: this.parseId(projectId, 'Invalid project id') } + }); + + if (!project) { + throw new NotFoundException('Project not found'); + } + + if (project.user_id.toString() !== user.id && user.role !== 'admin') { + throw new ForbiddenException('Project is private'); + } + + return project; + } + + private async findEpisodeForUser(episodeId: string, user: AuthRequestUser) { + const episode = await this.prisma.episode.findUnique({ + where: { id: this.parseId(episodeId, 'Invalid episode id') } + }); + + if (!episode) { + throw new NotFoundException('Episode not found'); + } + + await this.findProjectForUser(episode.project_id.toString(), user); + return episode; + } + + private resolveEpisodeCount(project: Project, value: number | undefined) { + return this.validatePositiveInt( + value ?? project.target_episode_count ?? (project.input_mode === 'ai_original' ? 3 : 1), + 'target_episode_count', + MIN_EPISODES, + MAX_EPISODES + ); + } + + private validateDuration(value: number | undefined) { + return this.validatePositiveInt(value, 'target_duration', MIN_DURATION, MAX_DURATION); + } + + private validateStatus(value: string | undefined): EpisodeStatus { + if (!value || !EPISODE_STATUSES.includes(value as never)) { + throw new BadRequestException('episode status is invalid'); + } + + return value as EpisodeStatus; + } + + private validatePositiveInt(value: unknown, field: string, min: number, max: number) { + const numberValue = Number(value); + + if (!Number.isInteger(numberValue) || numberValue < min || numberValue > max) { + throw new BadRequestException(`${field} must be an integer between ${min} and ${max}`); + } + + return numberValue; + } + + private optionalText(value: string | undefined) { + const normalized = value?.trim(); + return normalized || null; + } + + private compact(value: string) { + return value.replace(/\s+/g, ' ').trim(); + } + + private parseId(id: string, message: string) { + try { + return BigInt(id); + } catch { + throw new BadRequestException(message); + } + } +} diff --git a/backend/src/images/image.dto.ts b/backend/src/images/image.dto.ts new file mode 100644 index 0000000..cee535f --- /dev/null +++ b/backend/src/images/image.dto.ts @@ -0,0 +1,22 @@ +export class GenerateCharacterImagesDto { + image_types?: string[]; + count_per_type?: number; + force?: boolean; + set_first_as_anchor?: boolean; +} + +export class SetCharacterAnchorDto { + character_image_id?: string; + asset_id?: string; +} + +export class GenerateShotImageDto { + image_type?: string; + force?: boolean; +} + +export class GenerateEpisodeShotImagesDto { + image_type?: string; + only_missing?: boolean; + limit?: number; +} diff --git a/backend/src/images/image.types.ts b/backend/src/images/image.types.ts new file mode 100644 index 0000000..bd153e4 --- /dev/null +++ b/backend/src/images/image.types.ts @@ -0,0 +1,83 @@ +import type { Asset, CharacterImage, ShotImage } from '@prisma/client'; +import { toSafeAsset, type SafeAsset } from '../assets/asset.types'; + +export const CHARACTER_IMAGE_TYPES = [ + 'front_reference', + 'side_reference', + 'expression_pack', + 'costume_default', + 'costume_special', + 'anchor', + 'scene_variant' +] as const; + +export const SHOT_IMAGE_TYPES = ['preview', 'final'] as const; + +export type CharacterImageType = (typeof CHARACTER_IMAGE_TYPES)[number]; +export type ShotImageType = (typeof SHOT_IMAGE_TYPES)[number]; + +export interface SafeCharacterImage { + id: string; + project_id: string; + character_id: string; + asset_id: string | null; + image_type: string; + prompt_text: string | null; + negative_prompt: string | null; + is_anchor: boolean; + quality_score: number | null; + status: string; + created_at: string; + asset?: SafeAsset | null; +} + +export interface SafeShotImage { + id: string; + project_id: string; + episode_id: string | null; + shot_id: string; + asset_id: string | null; + image_type: string; + prompt_text: string | null; + negative_prompt: string | null; + quality_score: number | null; + status: string; + created_at: string; + asset?: SafeAsset | null; +} + +export function toSafeCharacterImage( + image: CharacterImage & { asset?: Asset | null } +): SafeCharacterImage { + return { + id: image.id.toString(), + project_id: image.project_id.toString(), + character_id: image.character_id.toString(), + asset_id: image.asset_id?.toString() ?? null, + image_type: image.image_type, + prompt_text: image.prompt_text, + negative_prompt: image.negative_prompt, + is_anchor: image.is_anchor, + quality_score: image.quality_score ? Number(image.quality_score.toString()) : null, + status: image.status, + created_at: image.created_at.toISOString(), + asset: image.asset ? toSafeAsset(image.asset) : undefined + }; +} + +export function toSafeShotImage(image: ShotImage & { asset?: Asset | null }): SafeShotImage { + return { + id: image.id.toString(), + project_id: image.project_id.toString(), + episode_id: image.episode_id?.toString() ?? null, + shot_id: image.shot_id.toString(), + asset_id: image.asset_id?.toString() ?? null, + image_type: image.image_type, + prompt_text: image.prompt_text, + negative_prompt: image.negative_prompt, + quality_score: image.quality_score ? Number(image.quality_score.toString()) : null, + status: image.status, + created_at: image.created_at.toISOString(), + asset: image.asset ? toSafeAsset(image.asset) : undefined + }; +} diff --git a/backend/src/images/images.controller.ts b/backend/src/images/images.controller.ts new file mode 100644 index 0000000..8939379 --- /dev/null +++ b/backend/src/images/images.controller.ts @@ -0,0 +1,74 @@ +import { + Body, + Controller, + Get, + Inject, + Param, + Post, + UseGuards +} from '@nestjs/common'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import type { AuthRequestUser } from '../auth/auth.types'; +import { + GenerateCharacterImagesDto, + GenerateEpisodeShotImagesDto, + GenerateShotImageDto, + SetCharacterAnchorDto +} from './image.dto'; +import { ImagesService } from './images.service'; + +@Controller() +@UseGuards(JwtAuthGuard) +export class ImagesController { + constructor(@Inject(ImagesService) private readonly imagesService: ImagesService) {} + + @Post('characters/:characterId/generate-images') + generateCharacterImages( + @CurrentUser() user: AuthRequestUser, + @Param('characterId') characterId: string, + @Body() dto: GenerateCharacterImagesDto + ) { + return this.imagesService.generateCharacterImages(user, characterId, dto); + } + + @Get('characters/:characterId/images') + listCharacterImages( + @CurrentUser() user: AuthRequestUser, + @Param('characterId') characterId: string + ) { + return this.imagesService.listCharacterImages(user, characterId); + } + + @Post('characters/:characterId/set-anchor') + setCharacterAnchor( + @CurrentUser() user: AuthRequestUser, + @Param('characterId') characterId: string, + @Body() dto: SetCharacterAnchorDto + ) { + return this.imagesService.setCharacterAnchor(user, characterId, dto); + } + + @Post('storyboard-shots/:shotId/images/generate') + generateShotImage( + @CurrentUser() user: AuthRequestUser, + @Param('shotId') shotId: string, + @Body() dto: GenerateShotImageDto + ) { + return this.imagesService.generateShotImage(user, shotId, dto); + } + + @Get('storyboard-shots/:shotId/images') + listShotImages(@CurrentUser() user: AuthRequestUser, @Param('shotId') shotId: string) { + return this.imagesService.listShotImages(user, shotId); + } + + @Post('episodes/:episodeId/shot-images/generate') + generateEpisodeShotImages( + @CurrentUser() user: AuthRequestUser, + @Param('episodeId') episodeId: string, + @Body() dto: GenerateEpisodeShotImagesDto + ) { + return this.imagesService.generateEpisodeShotImages(user, episodeId, dto); + } +} diff --git a/backend/src/images/images.module.ts b/backend/src/images/images.module.ts new file mode 100644 index 0000000..86e768a --- /dev/null +++ b/backend/src/images/images.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { AssetsModule } from '../assets/assets.module'; +import { PrismaModule } from '../prisma/prisma.module'; +import { ProvidersModule } from '../providers/providers.module'; +import { ImagesController } from './images.controller'; +import { ImagesService } from './images.service'; + +@Module({ + imports: [AuthModule, AssetsModule, PrismaModule, ProvidersModule], + controllers: [ImagesController], + providers: [ImagesService], + exports: [ImagesService] +}) +export class ImagesModule {} diff --git a/backend/src/images/images.service.spec.ts b/backend/src/images/images.service.spec.ts new file mode 100644 index 0000000..2e99784 --- /dev/null +++ b/backend/src/images/images.service.spec.ts @@ -0,0 +1,485 @@ +import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import type { + Asset, + Character, + CharacterImage, + Episode, + Project, + RenderTask, + ShotImage, + StoryboardShot +} from '@prisma/client'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AuthRequestUser } from '../auth/auth.types'; +import type { StorageService } from '../assets/storage.service'; +import type { PrismaService } from '../prisma/prisma.service'; +import type { ProvidersService } from '../providers/providers.service'; +import { ImagesService } from './images.service'; + +const user: AuthRequestUser = { + id: '1', + email: 'user@example.com', + role: 'user' +}; + +const now = new Date('2026-05-31T00:00:00.000Z'); + +function createProject(overrides: Partial = {}): Project { + return { + id: 10n, + user_id: 1n, + title: '阶段15 图片项目', + input_mode: 'ai_original', + genre: 'urban_rebirth', + style_code: 'korean_comic', + output_type: 'short_video', + output_mode: 'image_manga', + visual_mode: 'korean_manga', + video_generation_level: 'standard', + target_episode_count: 3, + episode_duration: 60, + status: 'storyboard_confirmed', + copyright_status: 'ai_original', + payment_status: 'unpaid', + quality_level: 'mvp', + is_long_series: false, + created_at: now, + updated_at: now, + completed_at: null, + ...overrides + }; +} + +function createCharacter(overrides: Partial = {}): Character { + return { + id: 20n, + project_id: 10n, + global_character_id: null, + name: '林晚', + alias_names: [], + role_type: 'protagonist', + gender_label: '女', + age_group: '青年', + identity_desc: '短剧主角', + appearance_desc: '眼神坚定,气质冷静', + face_desc: '精致鹅蛋脸', + hair_desc: '深色中长发', + eye_desc: '深色眼睛', + body_desc: '身形修长', + costume_rules: '现代都市通勤装', + special_props: '手机、录音证据', + personality_desc: '克制果断', + speech_style: '短句明确', + relationship_desc: '与周启对抗', + character_arc: '从被动到主动', + negative_rules: '不得改名,不得改发色', + anchor_asset_id: null, + wardrobe_variant: null, + voice_provider_code: null, + voice_model: null, + voice_id: null, + voice_style: null, + performance_style: null, + importance_level: 100, + status: 'locked', + created_at: now, + updated_at: now, + ...overrides + }; +} + +function createEpisode(overrides: Partial = {}): Episode { + return { + id: 30n, + project_id: 10n, + episode_no: 1, + source_chapter_ids: ['1'], + title: '第1集', + summary: '林晚反击。', + opening_hook: '会议室大屏播放录音。', + middle_conflict: '周启试图压制。', + ending_hook: '幕后车辆出现。', + target_duration: 60, + status: 'confirmed', + created_at: now, + updated_at: now, + ...overrides + }; +} + +function createShot(overrides: Partial = {}): StoryboardShot { + return { + id: 40n, + project_id: 10n, + episode_id: 30n, + shot_no: 1, + scene_name: '雨夜反击', + location_desc: '会议室', + characters_json: [{ id: '20', name: '林晚' }], + visual_desc: '林晚站在会议桌前,冷静抬眼。', + action_desc: '林晚播放录音证据。', + dialogue_text: '这一回,我不会再退。', + narration_text: '局势开始反转。', + camera_motion: 'zoom_in', + effect_type: 'flash', + duration: new Prisma.Decimal(4), + scene_type: null, + importance_score: null, + emotion_score: null, + action_score: null, + route_tier: null, + prompt_text: '高质量韩漫风,会议室反击。', + negative_prompt: '低清晰度,多余人物。', + live_action_desc: null, + actor_action: null, + camera_instruction: null, + performance_instruction: null, + video_prompt: null, + keyframe_asset_id: null, + video_clip_asset_id: null, + video_status: null, + status: 'confirmed', + created_at: now, + updated_at: now, + ...overrides + }; +} + +function createAsset(overrides: Partial = {}): Asset { + return { + id: 50n, + user_id: 1n, + project_id: 10n, + asset_type: 'image', + file_path: 'local://generated-images/mock.svg', + file_url: 'mock://image/mock.png', + mime_type: 'image/svg+xml', + width: 1080, + height: 1920, + duration: null, + size: 1024n, + hash: 'hash-a', + visibility: 'private', + status: 'active', + created_at: now, + ...overrides + }; +} + +function createCharacterImage(overrides: Partial = {}): CharacterImage { + return { + id: 60n, + project_id: 10n, + character_id: 20n, + asset_id: 50n, + image_type: 'front_reference', + prompt_text: 'prompt', + negative_prompt: 'negative', + is_anchor: false, + quality_score: new Prisma.Decimal(92), + status: 'generated', + created_at: now, + ...overrides + }; +} + +function createShotImage(overrides: Partial = {}): ShotImage { + return { + id: 70n, + project_id: 10n, + episode_id: 30n, + shot_id: 40n, + asset_id: 50n, + image_type: 'preview', + prompt_text: 'prompt', + negative_prompt: 'negative', + quality_score: new Prisma.Decimal(92), + status: 'generated', + created_at: now, + ...overrides + }; +} + +function createTask(overrides: Partial = {}): RenderTask { + return { + id: 80n, + project_id: 10n, + episode_id: null, + shot_id: null, + task_type: 'character_image_generate', + provider_id: null, + status: 'pending', + input_json: {}, + input_hash: 'hash-task', + idempotency_key: 'idem-task', + output_asset_id: null, + provider_request_id: null, + retry_count: 0, + max_retry: 3, + cost_estimate: null, + cost_actual: null, + error_code: null, + error_message: null, + created_at: now, + started_at: null, + finished_at: null, + ...overrides + }; +} + +describe('ImagesService', () => { + let prisma: any; + let storage: any; + let providers: any; + let tx: any; + let service: ImagesService; + + beforeEach(() => { + tx = { + characterImage: { + updateMany: vi.fn().mockResolvedValue({ count: 1 }), + update: vi.fn().mockResolvedValue(createCharacterImage({ is_anchor: true, status: 'selected' })) + }, + character: { + update: vi.fn().mockResolvedValue(createCharacter({ anchor_asset_id: 50n })) + } + }; + prisma = { + project: { + findUnique: vi.fn().mockResolvedValue(createProject()), + update: vi.fn().mockResolvedValue(createProject()) + }, + character: { + findUnique: vi.fn().mockResolvedValue(createCharacter()), + findMany: vi.fn().mockResolvedValue([createCharacter({ anchor_asset_id: 50n })]) + }, + characterImage: { + findFirst: vi.fn().mockResolvedValue(null), + findUnique: vi.fn().mockResolvedValue(createCharacterImage()), + findMany: vi.fn().mockResolvedValue([createCharacterImage()]), + create: vi.fn().mockResolvedValue(createCharacterImage()) + }, + storyboardShot: { + findUnique: vi.fn().mockResolvedValue(createShot()), + findMany: vi.fn().mockResolvedValue([createShot()]) + }, + episode: { + findUnique: vi.fn().mockResolvedValue(createEpisode()) + }, + shotImage: { + findFirst: vi.fn().mockResolvedValue(null), + findUnique: vi.fn().mockResolvedValue(createShotImage()), + findMany: vi.fn().mockResolvedValue([createShotImage()]), + create: vi.fn().mockResolvedValue(createShotImage()) + }, + renderTask: { + create: vi.fn().mockResolvedValue(createTask()), + update: vi.fn().mockResolvedValue(createTask({ status: 'success', output_asset_id: 50n })) + }, + asset: { + create: vi.fn().mockResolvedValue(createAsset()), + findUnique: vi.fn().mockResolvedValue(createAsset()) + }, + $transaction: vi.fn(async (callback: (transaction: typeof tx) => unknown) => callback(tx)) + }; + storage = { + storePrivateFile: vi.fn().mockResolvedValue({ + file_path: 'local://generated-images/mock.svg', + size: 1024n, + hash: 'hash-a', + backend: 'local' + }) + }; + providers = { + executeProvider: vi.fn().mockResolvedValue({ + provider: { + mode: 'mock' + }, + result: { + provider_request_id: 'mock-mock-image-a', + asset_url: 'mock://image/a.png' + }, + provider_log: { + cost_estimate: 0, + cost_actual: 0 + } + }) + }; + service = new ImagesService( + prisma as PrismaService, + storage as StorageService, + providers as ProvidersService + ); + }); + + it('generates locked character reference images through ImageProvider', async () => { + const result = await service.generateCharacterImages(user, '20', { + image_types: ['front_reference'], + set_first_as_anchor: false + }); + + expect(providers.executeProvider).toHaveBeenCalledWith( + expect.objectContaining({ + provider_type: 'ImageProvider', + task_id: '80', + allow_fallback: false, + input_json: expect.objectContaining({ + width: 1080, + height: 1920 + }) + }) + ); + expect(prisma.characterImage.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + project_id: 10n, + character_id: 20n, + asset_id: 50n, + image_type: 'front_reference', + status: 'generated' + }) + }); + expect(prisma.renderTask.update).toHaveBeenCalledWith({ + where: { id: 80n }, + data: expect.objectContaining({ + status: 'success', + output_asset_id: 50n + }) + }); + expect(result.images).toHaveLength(1); + expect(result.next_step).toBe('shot_image_generate'); + }); + + it('sets a character anchor image and updates the character anchor asset', async () => { + const result = await service.setCharacterAnchor(user, '20', { + character_image_id: '60' + }); + + expect(tx.characterImage.updateMany).toHaveBeenCalledWith({ + where: { + character_id: 20n, + is_anchor: true, + id: { not: 60n } + }, + data: { + is_anchor: false, + status: 'generated' + } + }); + expect(tx.character.update).toHaveBeenCalledWith({ + where: { id: 20n }, + data: { anchor_asset_id: 50n } + }); + expect(result.anchor_asset_id).toBe('50'); + }); + + it('generates a preview image for a confirmed storyboard shot', async () => { + const result = await service.generateShotImage(user, '40', { + image_type: 'preview' + }); + + expect(prisma.shotImage.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + project_id: 10n, + episode_id: 30n, + shot_id: 40n, + asset_id: 50n, + image_type: 'preview', + status: 'generated' + }) + }); + expect(providers.executeProvider.mock.calls[0][0].input_json.prompt).toContain( + 'anchor_asset_id=50' + ); + expect(providers.executeProvider.mock.calls[0][0].allow_fallback).toBe(false); + expect(result.reused).toBe(false); + expect(result.next_step).toBe('final_image_generate'); + }); + + it('stores real provider image bytes instead of the SVG fallback', async () => { + const png = Buffer.from('real-image-bytes'); + providers.executeProvider.mockResolvedValueOnce({ + provider: { + mode: 'real' + }, + result: { + provider_request_id: 'real-image-1', + asset_url: 'openai://image/real-image-1.png', + content_base64: png.toString('base64'), + mime_type: 'image/png' + }, + provider_log: { + cost_estimate: 0.02, + cost_actual: 0.02 + } + }); + storage.storePrivateFile.mockResolvedValueOnce({ + file_path: 'local://generated-images/real.png', + size: BigInt(png.length), + hash: 'real-hash', + backend: 'local' + }); + + await service.generateShotImage(user, '40', { image_type: 'preview' }); + + expect(storage.storePrivateFile).toHaveBeenCalledWith( + expect.objectContaining({ + originalname: 'shot-40-preview.png', + mimetype: 'image/png', + size: png.length, + buffer: png + }), + 'generated-images' + ); + expect(prisma.asset.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + file_path: 'local://generated-images/real.png', + file_url: 'openai://image/real-image-1.png', + mime_type: 'image/png', + status: 'active' + }) + }); + }); + + it('does not create mock images when a real provider returns no image content', async () => { + providers.executeProvider.mockResolvedValueOnce({ + provider: { + mode: 'real' + }, + result: { + provider_request_id: 'real-image-empty', + asset_url: 'openai://image/empty.png' + }, + provider_log: { + cost_estimate: 0, + cost_actual: 0 + } + }); + + await expect(service.generateShotImage(user, '40', { image_type: 'preview' })).rejects.toBeInstanceOf( + BadRequestException + ); + expect(prisma.asset.create).not.toHaveBeenCalled(); + expect(prisma.renderTask.update).toHaveBeenCalledWith({ + where: { id: 80n }, + data: expect.objectContaining({ + status: 'failed', + error_code: 'IMAGE_ASSET_STORE_FAILED' + }) + }); + }); + + it('rejects image generation for unconfirmed storyboard shots', async () => { + prisma.storyboardShot.findUnique.mockResolvedValue(createShot({ status: 'generated' })); + + await expect(service.generateShotImage(user, '40', {})).rejects.toBeInstanceOf( + BadRequestException + ); + }); + + it('rejects access to another user project', async () => { + prisma.project.findUnique.mockResolvedValue(createProject({ user_id: 2n })); + + await expect(service.listShotImages(user, '40')).rejects.toBeInstanceOf(ForbiddenException); + }); +}); diff --git a/backend/src/images/images.service.ts b/backend/src/images/images.service.ts new file mode 100644 index 0000000..1f9a7b2 --- /dev/null +++ b/backend/src/images/images.service.ts @@ -0,0 +1,985 @@ +import { + BadRequestException, + ForbiddenException, + Inject, + Injectable, + NotFoundException +} from '@nestjs/common'; +import type { Asset, Character, Prisma, Project, StoryboardShot } from '@prisma/client'; +import { Prisma as PrismaNamespace } from '@prisma/client'; +import { createHash } from 'node:crypto'; +import type { AuthRequestUser } from '../auth/auth.types'; +import { StorageService } from '../assets/storage.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { ProvidersService } from '../providers/providers.service'; +import { + GenerateCharacterImagesDto, + GenerateEpisodeShotImagesDto, + GenerateShotImageDto, + SetCharacterAnchorDto +} from './image.dto'; +import { + CHARACTER_IMAGE_TYPES, + SHOT_IMAGE_TYPES, + toSafeCharacterImage, + toSafeShotImage, + type CharacterImageType, + type ShotImageType +} from './image.types'; + +const DEFAULT_CHARACTER_IMAGE_TYPES: CharacterImageType[] = [ + 'front_reference', + 'anchor', + 'expression_pack' +]; +const DEFAULT_QUALITY_SCORE = new PrismaNamespace.Decimal(92); + +interface StoredGeneratedImage { + asset: Asset; + provider_request_id: string | null; + cost_estimate: number | null; + cost_actual: number | null; +} + +@Injectable() +export class ImagesService { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(StorageService) private readonly storage: StorageService, + @Inject(ProvidersService) private readonly providersService: ProvidersService + ) {} + + async generateCharacterImages( + user: AuthRequestUser, + characterId: string, + dto: GenerateCharacterImagesDto + ) { + const { character, project } = await this.loadCharacterForUser(characterId, user); + + if (character.status !== 'locked') { + throw new BadRequestException('Locked character is required before image generation'); + } + + const imageTypes = this.resolveCharacterImageTypes(dto.image_types); + const countPerType = this.normalizePositiveInt(dto.count_per_type, 'count_per_type', 1, 3, 1); + const images = []; + + for (const imageType of imageTypes) { + for (let index = 0; index < countPerType; index += 1) { + const existing = dto.force + ? null + : await this.prisma.characterImage.findFirst({ + where: { + character_id: character.id, + image_type: imageType, + status: { in: ['generated', 'selected'] } + }, + orderBy: { created_at: 'asc' } + }); + + if (existing) { + images.push(await this.loadCharacterImage(existing.id)); + continue; + } + + images.push(await this.generateSingleCharacterImage(project, character, imageType, index)); + } + } + + let anchor = null; + + if (dto.set_first_as_anchor !== false) { + const anchorCandidate = + images.find((image) => image.image_type === 'anchor') ?? images[0] ?? null; + + if (anchorCandidate?.id) { + anchor = await this.setCharacterAnchor(user, character.id.toString(), { + character_image_id: anchorCandidate.id + }); + } + } + + await this.prisma.project.update({ + where: { id: project.id }, + data: { status: 'character_image_generated' } + }); + + return { + images, + anchor, + next_step: 'shot_image_generate' + }; + } + + async listCharacterImages(user: AuthRequestUser, characterId: string) { + const { character } = await this.loadCharacterForUser(characterId, user); + const images = await this.prisma.characterImage.findMany({ + where: { character_id: character.id }, + orderBy: [{ is_anchor: 'desc' }, { created_at: 'asc' }] + }); + + return Promise.all(images.map((image) => this.withCharacterAsset(image))); + } + + async setCharacterAnchor( + user: AuthRequestUser, + characterId: string, + dto: SetCharacterAnchorDto + ) { + const { character } = await this.loadCharacterForUser(characterId, user); + const image = await this.resolveCharacterAnchorImage(character, dto); + + if (!image.asset_id) { + throw new BadRequestException('Character image has no asset'); + } + + const [updated] = await this.prisma.$transaction(async (tx) => { + await tx.characterImage.updateMany({ + where: { + character_id: character.id, + is_anchor: true, + id: { not: image.id } + }, + data: { + is_anchor: false, + status: 'generated' + } + }); + const selected = await tx.characterImage.update({ + where: { id: image.id }, + data: { + is_anchor: true, + status: 'selected' + } + }); + await tx.character.update({ + where: { id: character.id }, + data: { anchor_asset_id: image.asset_id } + }); + return [selected]; + }); + + return { + character_id: character.id.toString(), + anchor_asset_id: image.asset_id.toString(), + image: await this.withCharacterAsset(updated), + next_step: 'storyboard_image_generate' + }; + } + + async generateShotImage(user: AuthRequestUser, shotId: string, dto: GenerateShotImageDto) { + const { shot, project } = await this.loadShotForUser(shotId, user); + const imageType = this.validateShotImageType(dto.image_type ?? 'preview'); + + if (shot.status !== 'confirmed') { + throw new BadRequestException('Confirmed storyboard shot is required before image generation'); + } + + const existing = dto.force + ? null + : await this.prisma.shotImage.findFirst({ + where: { + shot_id: shot.id, + image_type: imageType, + status: 'generated' + }, + orderBy: { created_at: 'asc' } + }); + + if (existing) { + return { + image: await this.loadShotImage(existing.id), + reused: true + }; + } + + const image = await this.generateSingleShotImage(project, shot, imageType); + + await this.prisma.project.update({ + where: { id: project.id }, + data: { status: imageType === 'final' ? 'final_images_generated' : 'preview_images_generated' } + }); + + return { + image, + reused: false, + next_step: imageType === 'final' ? 'image_qc' : 'final_image_generate' + }; + } + + async listShotImages(user: AuthRequestUser, shotId: string) { + const { shot } = await this.loadShotForUser(shotId, user); + const images = await this.prisma.shotImage.findMany({ + where: { shot_id: shot.id }, + orderBy: [{ image_type: 'asc' }, { created_at: 'asc' }] + }); + + return Promise.all(images.map((image) => this.withShotAsset(image))); + } + + async generateEpisodeShotImages( + user: AuthRequestUser, + episodeId: string, + dto: GenerateEpisodeShotImagesDto + ) { + const { episode, project } = await this.loadEpisodeForUser(episodeId, user); + const imageType = this.validateShotImageType(dto.image_type ?? 'preview'); + const onlyMissing = dto.only_missing !== false; + const limit = this.normalizePositiveInt(dto.limit, 'limit', 1, 50, 20); + const shots = await this.prisma.storyboardShot.findMany({ + where: { + episode_id: episode.id, + status: 'confirmed' + }, + orderBy: { shot_no: 'asc' }, + take: limit + }); + + if (shots.length === 0) { + throw new BadRequestException('Confirmed storyboard shots are required before image generation'); + } + + const images = []; + + for (const shot of shots) { + const existing = onlyMissing + ? await this.prisma.shotImage.findFirst({ + where: { + shot_id: shot.id, + image_type: imageType, + status: 'generated' + }, + orderBy: { created_at: 'asc' } + }) + : null; + + if (existing) { + images.push(await this.loadShotImage(existing.id)); + } else { + images.push(await this.generateSingleShotImage(project, shot, imageType)); + } + } + + await this.prisma.project.update({ + where: { id: project.id }, + data: { status: imageType === 'final' ? 'final_images_generated' : 'preview_images_generated' } + }); + + return { + episode_id: episode.id.toString(), + image_type: imageType, + images, + generated_count: images.length, + next_step: imageType === 'final' ? 'image_qc' : 'final_image_generate' + }; + } + + private async generateSingleCharacterImage( + project: Project, + character: Character, + imageType: CharacterImageType, + index: number + ) { + const prompt = this.buildCharacterPrompt(project, character, imageType, index); + const negativePrompt = this.buildCharacterNegativePrompt(character); + const task = await this.createRenderTask(project.id, null, null, 'character_image_generate', { + target_type: 'character', + character_id: character.id.toString(), + image_type: imageType, + index, + prompt, + negative_prompt: negativePrompt, + width: 1080, + height: 1920 + }); + const stored = await this.executeAndStoreGeneratedImage({ + project, + taskId: task.id, + prompt, + negativePrompt, + width: 1080, + height: 1920, + imageKind: `character-${character.id.toString()}-${imageType}-${index}` + }); + const created = await this.prisma.characterImage.create({ + data: { + project_id: project.id, + character_id: character.id, + asset_id: stored.asset.id, + image_type: imageType, + prompt_text: prompt, + negative_prompt: negativePrompt, + is_anchor: imageType === 'anchor', + quality_score: DEFAULT_QUALITY_SCORE, + status: 'generated' + } + }); + + return this.withCharacterAsset(created); + } + + private async generateSingleShotImage( + project: Project, + shot: StoryboardShot, + imageType: ShotImageType + ) { + const characterRefs = await this.loadShotCharacterRefs(shot); + const prompt = this.buildShotPrompt(project, shot, characterRefs, imageType); + const negativePrompt = this.buildShotNegativePrompt(shot, characterRefs); + const task = await this.createRenderTask(project.id, shot.episode_id, shot.id, 'shot_image_generate', { + target_type: 'storyboard_shot', + shot_id: shot.id.toString(), + image_type: imageType, + prompt, + negative_prompt: negativePrompt, + width: 1080, + height: 1920, + anchor_asset_ids: characterRefs + .map((character) => character.anchor_asset_id?.toString()) + .filter((assetId): assetId is string => Boolean(assetId)) + }); + const stored = await this.executeAndStoreGeneratedImage({ + project, + taskId: task.id, + prompt, + negativePrompt, + width: 1080, + height: 1920, + imageKind: `shot-${shot.id.toString()}-${imageType}` + }); + const created = await this.prisma.shotImage.create({ + data: { + project_id: project.id, + episode_id: shot.episode_id, + shot_id: shot.id, + asset_id: stored.asset.id, + image_type: imageType, + prompt_text: prompt, + negative_prompt: negativePrompt, + quality_score: DEFAULT_QUALITY_SCORE, + status: 'generated' + } + }); + + return this.withShotAsset(created); + } + + private async executeAndStoreGeneratedImage(input: { + project: Project; + taskId: bigint; + prompt: string; + negativePrompt: string; + width: number; + height: number; + imageKind: string; + }): Promise { + const providerResult = await this.providersService.executeProvider({ + provider_type: 'ImageProvider', + purpose: input.imageKind, + project_id: input.project.id.toString(), + task_id: input.taskId.toString(), + allow_fallback: false, + return_binary: true, + input_json: { + prompt: input.prompt, + negative_prompt: input.negativePrompt, + width: input.width, + height: input.height + } + }); + try { + const result = this.jsonObject(providerResult.result); + const providerRequestId = this.stringifyText(result.provider_request_id); + const generatedFile = await this.createGeneratedImageFile( + result, + input, + providerResult.provider.mode === 'mock' + ); + const stored = await this.storage.storePrivateFile(generatedFile as unknown as Express.Multer.File, 'generated-images'); + const asset = await this.prisma.asset.create({ + data: { + user_id: input.project.user_id, + project_id: input.project.id, + asset_type: 'image', + file_path: stored.file_path, + file_url: this.stringifyText(result.asset_url) || null, + mime_type: generatedFile.mimetype, + width: input.width, + height: input.height, + size: stored.size, + hash: stored.hash, + visibility: 'private', + status: generatedFile.isMock ? 'mock' : 'active' + } + }); + await this.prisma.renderTask.update({ + where: { id: input.taskId }, + data: { + status: 'success', + output_asset_id: asset.id, + finished_at: new Date() + } + }); + + return { + asset, + provider_request_id: providerRequestId || null, + cost_estimate: providerResult.provider_log.cost_estimate, + cost_actual: providerResult.provider_log.cost_actual + }; + } catch (error) { + const normalized = this.toError(error); + + await this.prisma.renderTask.update({ + where: { id: input.taskId }, + data: { + status: 'failed', + error_code: 'IMAGE_ASSET_STORE_FAILED', + error_message: normalized.message, + finished_at: new Date() + } + }); + throw error; + } + } + + private async createGeneratedImageFile( + result: Record, + input: { + width: number; + height: number; + imageKind: string; + prompt: string; + }, + allowMockOutput: boolean + ) { + const contentBase64 = this.stringifyText(result.content_base64); + const mimeType = this.normalizeImageMimeType(this.stringifyText(result.mime_type)); + + if (contentBase64) { + const buffer = this.decodeBase64(contentBase64, 'ImageProvider content_base64'); + + return { + originalname: `${input.imageKind}${this.extensionFromMime(mimeType)}`, + mimetype: mimeType, + size: buffer.length, + buffer, + isMock: false + }; + } + + const assetUrl = this.stringifyText(result.asset_url); + + if (/^https?:\/\//i.test(assetUrl)) { + const downloaded = await this.downloadProviderAsset(assetUrl, 'image'); + const downloadedMime = this.normalizeImageMimeType(downloaded.mimeType); + + return { + originalname: `${input.imageKind}${this.extensionFromMime(downloadedMime)}`, + mimetype: downloadedMime, + size: downloaded.buffer.length, + buffer: downloaded.buffer, + isMock: false + }; + } + + if (!allowMockOutput) { + throw new BadRequestException('ImageProvider did not return image content or downloadable URL'); + } + + const svg = this.createMockSvg(input.width, input.height, input.imageKind, input.prompt); + const buffer = Buffer.from(svg); + return { + originalname: `${input.imageKind}.svg`, + mimetype: 'image/svg+xml', + size: buffer.length, + buffer, + isMock: true + }; + } + + private async downloadProviderAsset(url: string, expectedType: 'image') { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 60000); + + try { + const response = await fetch(url, { signal: controller.signal }); + + if (!response.ok) { + throw new BadRequestException(`Provider ${expectedType} download failed: HTTP ${response.status}`); + } + + const mimeType = response.headers.get('content-type') || ''; + const buffer = Buffer.from(await response.arrayBuffer()); + + if (!buffer.length) { + throw new BadRequestException(`Provider ${expectedType} download returned empty content`); + } + + return { buffer, mimeType }; + } catch (error) { + const normalized = this.toError(error); + + throw new BadRequestException(`Provider ${expectedType} download failed: ${normalized.message}`); + } finally { + clearTimeout(timeout); + } + } + + private decodeBase64(value: string, label: string) { + const buffer = Buffer.from(value, 'base64'); + + if (!buffer.length) { + throw new BadRequestException(`${label} is empty`); + } + + return buffer; + } + + private normalizeImageMimeType(value: string | undefined) { + const normalized = value?.split(';')[0]?.trim().toLowerCase(); + + if (normalized === 'image/jpeg' || normalized === 'image/jpg') return 'image/jpeg'; + if (normalized === 'image/webp') return 'image/webp'; + if (normalized === 'image/svg+xml') return 'image/svg+xml'; + + return 'image/png'; + } + + private extensionFromMime(mimeType: string) { + switch (mimeType) { + case 'image/jpeg': + return '.jpg'; + case 'image/webp': + return '.webp'; + case 'image/svg+xml': + return '.svg'; + case 'image/png': + default: + return '.png'; + } + } + + private async createRenderTask( + projectId: bigint, + episodeId: bigint | null, + shotId: bigint | null, + taskType: 'character_image_generate' | 'shot_image_generate', + inputJson: Prisma.InputJsonObject + ) { + const inputHash = this.hashJson(inputJson); + + return this.prisma.renderTask.create({ + data: { + project_id: projectId, + episode_id: episodeId, + shot_id: shotId, + task_type: taskType, + status: 'pending', + input_json: inputJson, + input_hash: inputHash, + idempotency_key: `${taskType}:${projectId.toString()}:${episodeId?.toString() ?? 'none'}:${shotId?.toString() ?? 'none'}:${inputHash}:${Date.now()}`, + retry_count: 0, + max_retry: 3 + } + }); + } + + private buildCharacterPrompt( + project: Project, + character: Character, + imageType: CharacterImageType, + index: number + ) { + return [ + 'high quality Korean webtoon style, vertical 9:16 character reference', + 'clean illustration only, no visible text, no labels, no speech bubbles', + `project=${project.title ?? 'untitled'}`, + `image_type=${imageType}`, + `variant=${index + 1}`, + `name=${character.name}`, + character.global_character_id ? `global_character_id=${character.global_character_id.toString()}` : null, + `role=${character.role_type}`, + `gender=${character.gender_label ?? 'unspecified'}`, + `age=${character.age_group ?? 'adult'}`, + `identity=${character.identity_desc ?? 'main cast'}`, + `appearance=${character.appearance_desc ?? ''}`, + `face=${character.face_desc ?? ''}`, + `hair=${character.hair_desc ?? ''}`, + `eyes=${character.eye_desc ?? ''}`, + `body=${character.body_desc ?? ''}`, + `costume=${character.costume_rules ?? 'clean modern outfit'}`, + character.wardrobe_variant ? `wardrobe_variant=${character.wardrobe_variant}` : null, + character.performance_style ? `performance=${character.performance_style}` : null, + `props=${character.special_props ?? 'none'}` + ].filter(Boolean).join('\n'); + } + + private buildCharacterNegativePrompt(character: Character) { + return [ + 'low quality, blurry, extra fingers, bad hands, text artifacts, watermark', + 'visible text, Chinese characters, letters, subtitles, captions, speech bubbles, dialogue balloons, text boxes, unreadable glyphs, square glyph artifacts', + 'face drift, age drift, hair color drift, duplicate person, mixed identity', + character.negative_rules + ].filter(Boolean).join(', '); + } + + private buildShotPrompt( + project: Project, + shot: StoryboardShot, + characters: Character[], + imageType: ShotImageType + ) { + const characterLines = characters.map((character) => + [ + character.name, + character.age_group, + character.face_desc, + character.hair_desc, + character.costume_rules, + character.wardrobe_variant, + character.performance_style, + character.global_character_id ? `global_character_id=${character.global_character_id.toString()}` : null, + character.anchor_asset_id ? `anchor_asset_id=${character.anchor_asset_id.toString()}` : null + ].filter(Boolean).join(' | ') + ); + + return [ + 'high quality Korean webtoon style, vertical 9:16 storyboard image', + 'clean cinematic frame only, no visible text, no captions, no speech bubbles, no dialogue balloons, no comic text boxes', + 'express dialogue through facial expression, pose, camera and lighting only', + `project=${project.title ?? 'untitled'}`, + `image_type=${imageType}`, + `shot_no=${shot.shot_no}`, + `scene=${shot.scene_name ?? ''}`, + `location=${shot.location_desc ?? ''}`, + `visual=${shot.visual_desc ?? ''}`, + `action=${shot.action_desc ?? ''}`, + `camera=${shot.camera_motion ?? 'subtle zoom'}`, + `effect=${shot.effect_type ?? 'none'}`, + `dialogue=${shot.dialogue_text ?? ''}`, + `narration=${shot.narration_text ?? ''}`, + `characters=${characterLines.join(' || ') || 'no named character'}` + ].filter(Boolean).join('\n'); + } + + private buildShotNegativePrompt(shot: StoryboardShot, characters: Character[]) { + return [ + shot.negative_prompt, + 'low quality, blurry, extra fingers, bad hands, text artifacts, watermark', + 'visible text, Chinese characters, letters, subtitles, captions, speech bubbles, dialogue balloons, comic panels with text, text boxes, unreadable glyphs, square glyph artifacts', + 'wrong face, age drift, hair color drift, extra people, missing character', + characters.length > 2 ? 'avoid crowded composition, separate character faces clearly' : null + ].filter(Boolean).join(', '); + } + + private createMockSvg(width: number, height: number, label: string, prompt: string) { + const color = `#${this.hashJson({ label }).slice(0, 6)}`; + const promptHash = this.hashJson({ prompt }).slice(0, 12); + + return [ + ``, + ``, + '', + '', + '', + `MOCK IMAGE`, + `${this.escapeXml(label.slice(0, 48))}`, + `prompt:${promptHash}`, + '' + ].join(''); + } + + private async loadShotCharacterRefs(shot: StoryboardShot) { + const ids = this.extractCharacterIds(shot.characters_json); + + if (ids.length === 0) { + return []; + } + + return this.prisma.character.findMany({ + where: { + id: { in: ids }, + project_id: shot.project_id, + status: 'locked' + }, + orderBy: [{ importance_level: 'desc' }, { id: 'asc' }] + }); + } + + private extractCharacterIds(value: Prisma.JsonValue | null) { + if (!Array.isArray(value)) { + return []; + } + + const ids = value + .map((item) => { + if (!item || typeof item !== 'object' || Array.isArray(item)) { + return null; + } + + const raw = (item as Record).id; + return typeof raw === 'string' || typeof raw === 'number' ? this.tryParseId(raw) : null; + }) + .filter((id): id is bigint => id !== null); + + return [...new Set(ids)]; + } + + private async resolveCharacterAnchorImage(character: Character, dto: SetCharacterAnchorDto) { + if (dto.character_image_id) { + const image = await this.prisma.characterImage.findUnique({ + where: { id: this.parseId(dto.character_image_id, 'Invalid character_image_id') } + }); + + if (!image || image.character_id !== character.id) { + throw new NotFoundException('Character image not found'); + } + + return image; + } + + if (dto.asset_id) { + const assetId = this.parseId(dto.asset_id, 'Invalid asset_id'); + const image = await this.prisma.characterImage.findFirst({ + where: { + character_id: character.id, + asset_id: assetId + }, + orderBy: { created_at: 'asc' } + }); + + if (!image) { + throw new NotFoundException('Character image not found for asset'); + } + + return image; + } + + const image = await this.prisma.characterImage.findFirst({ + where: { + character_id: character.id, + status: { in: ['generated', 'selected'] } + }, + orderBy: [{ is_anchor: 'desc' }, { created_at: 'asc' }] + }); + + if (!image) { + throw new NotFoundException('Character image not found'); + } + + return image; + } + + private async loadCharacterImage(id: bigint) { + const image = await this.prisma.characterImage.findUnique({ + where: { id } + }); + + if (!image) { + throw new NotFoundException('Character image not found'); + } + + return this.withCharacterAsset(image); + } + + private async loadShotImage(id: bigint) { + const image = await this.prisma.shotImage.findUnique({ + where: { id } + }); + + if (!image) { + throw new NotFoundException('Shot image not found'); + } + + return this.withShotAsset(image); + } + + private async withCharacterAsset(image: Awaited>) { + if (!image) { + throw new NotFoundException('Character image not found'); + } + + const asset = image.asset_id + ? await this.prisma.asset.findUnique({ where: { id: image.asset_id } }) + : null; + + return toSafeCharacterImage({ ...image, asset }); + } + + private async withShotAsset(image: Awaited>) { + if (!image) { + throw new NotFoundException('Shot image not found'); + } + + const asset = image.asset_id + ? await this.prisma.asset.findUnique({ where: { id: image.asset_id } }) + : null; + + return toSafeShotImage({ ...image, asset }); + } + + private async loadCharacterForUser(characterId: string, user: AuthRequestUser) { + const character = await this.prisma.character.findUnique({ + where: { id: this.parseId(characterId, 'Invalid character id') } + }); + + if (!character || character.status === 'deleted') { + throw new NotFoundException('Character not found'); + } + + const project = await this.findProjectForUser(character.project_id, user); + return { character, project }; + } + + private async loadShotForUser(shotId: string, user: AuthRequestUser) { + const shot = await this.prisma.storyboardShot.findUnique({ + where: { id: this.parseId(shotId, 'Invalid shot id') } + }); + + if (!shot) { + throw new NotFoundException('Storyboard shot not found'); + } + + const project = await this.findProjectForUser(shot.project_id, user); + return { shot, project }; + } + + private async loadEpisodeForUser(episodeId: string, user: AuthRequestUser) { + const episode = await this.prisma.episode.findUnique({ + where: { id: this.parseId(episodeId, 'Invalid episode id') } + }); + + if (!episode) { + throw new NotFoundException('Episode not found'); + } + + const project = await this.findProjectForUser(episode.project_id, user); + return { episode, project }; + } + + private async findProjectForUser(projectId: bigint, user: AuthRequestUser) { + const project = await this.prisma.project.findUnique({ + where: { id: projectId } + }); + + if (!project) { + throw new NotFoundException('Project not found'); + } + if (project.user_id.toString() !== user.id && user.role !== 'admin') { + throw new ForbiddenException('Project is private'); + } + + return project; + } + + private resolveCharacterImageTypes(value: string[] | undefined) { + if (!value?.length) { + return DEFAULT_CHARACTER_IMAGE_TYPES; + } + + return value.map((item) => this.validateCharacterImageType(item)); + } + + private validateCharacterImageType(value: unknown): CharacterImageType { + if (typeof value !== 'string' || !(CHARACTER_IMAGE_TYPES as readonly string[]).includes(value)) { + throw new BadRequestException('image_type is not supported'); + } + + return value as CharacterImageType; + } + + private validateShotImageType(value: unknown): ShotImageType { + if (typeof value !== 'string' || !(SHOT_IMAGE_TYPES as readonly string[]).includes(value)) { + throw new BadRequestException('image_type must be preview or final'); + } + + return value as ShotImageType; + } + + private normalizePositiveInt( + value: unknown, + field: string, + min: number, + max: number, + fallback: number + ) { + if (value === undefined || value === null || value === '') { + return fallback; + } + + const numberValue = Number(value); + + if (!Number.isInteger(numberValue) || numberValue < min || numberValue > max) { + throw new BadRequestException(`${field} must be an integer between ${min} and ${max}`); + } + + return numberValue; + } + + private parseId(value: string | bigint | number, message: string) { + try { + const id = BigInt(value); + + if (id <= 0n) { + throw new Error('ID must be positive'); + } + + return id; + } catch { + throw new BadRequestException(message); + } + } + + private tryParseId(value: string | number) { + try { + const id = BigInt(value); + return id > 0n ? id : null; + } catch { + return null; + } + } + + private hashJson(value: Prisma.InputJsonValue | Prisma.JsonValue | null) { + return createHash('sha256').update(this.stableStringify(value)).digest('hex'); + } + + private stableStringify(value: Prisma.InputJsonValue | Prisma.JsonValue | null): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((item) => this.stableStringify(item)).join(',')}]`; + } + + const entries = Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => `${JSON.stringify(key)}:${this.stableStringify(child)}`); + + return `{${entries.join(',')}}`; + } + + private jsonObject(value: unknown) { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + + return {}; + } + + private stringifyText(value: unknown) { + return typeof value === 'string' ? value.trim() : ''; + } + + private toError(error: unknown) { + return error instanceof Error ? error : new Error(String(error)); + } + + private escapeXml(value: string) { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } +} diff --git a/backend/src/live-action/import-live-action-testcase.ts b/backend/src/live-action/import-live-action-testcase.ts new file mode 100644 index 0000000..c08270b --- /dev/null +++ b/backend/src/live-action/import-live-action-testcase.ts @@ -0,0 +1,556 @@ +import 'reflect-metadata'; +import '../config/load-env'; +import { Prisma, PrismaClient } from '@prisma/client'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +type TestcaseCharacter = { + character_key: string; + name: string; + role_type: string; + gender_label?: string; + age_group?: string; + identity_desc?: string; + appearance_desc?: string; + face_desc?: string; + hair_desc?: string; + eye_desc?: string; + body_desc?: string; + costume_rules?: string; + special_props?: string; + personality_desc?: string; + speech_style?: string; + relationship_desc?: string; + character_arc?: string; + negative_rules?: string; + wardrobe_variant?: string; + voice_provider_code?: string; + voice_model?: string; + voice_id?: string; + voice_style?: string; + performance_style?: string; + importance_level?: number; +}; + +type TestcaseShotCharacter = { + character_key?: string; + name?: string; +}; + +type TestcaseShot = { + shot_no: number; + scene_name: string; + location_desc: string; + characters_json: TestcaseShotCharacter[]; + visual_desc: string; + action_desc: string; + dialogue_text?: string | null; + narration_text?: string | null; + camera_motion?: string | null; + effect_type?: string | null; + duration: number; + scene_type?: string | null; + importance_score?: number | null; + emotion_score?: number | null; + action_score?: number | null; + route_tier?: string | null; + status?: string; +}; + +type LiveActionTestcase = { + testcase_id: string; + project: { + title?: string; + input_mode?: string; + genre?: string; + style_code?: string; + output_type?: string; + output_mode?: string; + visual_mode?: string; + video_generation_level?: string; + target_episode_count?: number; + episode_duration?: number; + quality_level?: string; + is_long_series?: boolean; + }; + story_bible: { + premise?: string; + world_setting?: string; + tone?: string; + forbidden_setting?: string; + continuity_rules?: string[]; + }; + characters: TestcaseCharacter[]; + scenes: Array<{ + scene_key: string; + name: string; + location_desc: string; + visual_rules?: string; + }>; + episode: { + episode_no: number; + title?: string; + summary?: string; + opening_hook?: string; + middle_conflict?: string; + ending_hook?: string; + target_duration?: number; + script_text?: string; + narration_text?: string; + dialogue_json?: unknown; + }; + storyboard_shots: TestcaseShot[]; + output_requirements?: unknown; + acceptance_criteria?: unknown; +}; + +type RuntimeConfig = { + filePath: string; + ownerEmail: string | null; + replace: boolean; +}; + +const DEFAULT_TESTCASE_PATH = resolve( + __dirname, + '..', + '..', + '..', + 'storage', + 'private', + 'live-action-testcases', + 'takeaway-heir-episode-001.json' +); + +async function main() { + const config = parseArgs(process.argv.slice(2)); + const prisma = new PrismaClient(); + + try { + const testcase = await loadTestcase(config.filePath); + assertTestcase(testcase); + const owner = await resolveOwner(prisma, config.ownerEmail); + + if (config.replace) { + await deleteExistingImportedProjects(prisma, testcase); + } + + const result = await prisma.$transaction(async (tx) => { + const project = await tx.project.create({ + data: { + user_id: owner.id, + title: testcase.project.title ?? '真人短剧压测项目', + input_mode: testcase.project.input_mode ?? 'ai_original', + genre: testcase.project.genre ?? 'urban_counterattack', + style_code: testcase.project.style_code ?? 'live_action', + output_type: testcase.project.output_type ?? 'short_video', + output_mode: testcase.project.output_mode ?? 'live_action_ai', + visual_mode: testcase.project.visual_mode ?? 'live_action', + video_generation_level: testcase.project.video_generation_level ?? 'standard', + target_episode_count: testcase.project.target_episode_count ?? 1, + episode_duration: testcase.project.episode_duration ?? testcase.episode.target_duration ?? 60, + status: 'storyboard_confirmed', + copyright_status: 'confirmed', + payment_status: 'paid', + quality_level: testcase.project.quality_level ?? 'provider_acceptance', + is_long_series: testcase.project.is_long_series ?? false + } + }); + + await tx.copyrightRecord.create({ + data: { + project_id: project.id, + user_id: owner.id, + authorization_type: 'ai_original_testcase', + statement_text: `测试用例 ${testcase.testcase_id}:AI 原创短剧压测素材,仅用于内部流水线验收。` + } + }); + + await tx.storyBible.create({ + data: { + project_id: project.id, + title: testcase.project.title ?? '真人短剧压测故事圣经', + logline: testcase.story_bible.premise ?? testcase.episode.summary ?? null, + main_plot: testcase.episode.script_text ?? testcase.story_bible.premise ?? null, + core_conflict: testcase.episode.middle_conflict ?? null, + selling_points: [ + testcase.episode.opening_hook, + testcase.episode.ending_hook + ].filter(Boolean).join('\n') || null, + tone: testcase.story_bible.tone ?? '都市逆袭', + world_summary: testcase.story_bible.world_setting ?? null, + ending_direction: testcase.episode.ending_hook ?? null, + taboo_rules: [ + testcase.story_bible.forbidden_setting, + ...(testcase.story_bible.continuity_rules ?? []) + ].filter(Boolean).join('\n'), + version: 1, + status: 'confirmed' + } + }); + + await tx.worldBible.create({ + data: { + project_id: project.id, + world_type: 'modern_urban', + setting_text: testcase.story_bible.world_setting ?? null, + rules_text: testcase.story_bible.premise ?? null, + social_structure: '现代都市,林氏集团为隐秘顶级财团。', + time_period: '现代', + visual_rules: testcase.scenes.map((scene) => `${scene.name}:${scene.location_desc}${scene.visual_rules ? `;${scene.visual_rules}` : ''}`).join('\n'), + forbidden_rules: testcase.story_bible.forbidden_setting ?? null, + status: 'confirmed' + } + }); + + const characterByKey = new Map(); + + for (const character of testcase.characters) { + const saved = await tx.character.create({ + data: { + project_id: project.id, + name: character.name, + alias_names: [], + role_type: character.role_type, + gender_label: character.gender_label ?? null, + age_group: character.age_group ?? null, + identity_desc: character.identity_desc ?? null, + appearance_desc: character.appearance_desc ?? null, + face_desc: character.face_desc ?? null, + hair_desc: character.hair_desc ?? null, + eye_desc: character.eye_desc ?? null, + body_desc: character.body_desc ?? null, + costume_rules: character.costume_rules ?? null, + special_props: character.special_props ?? null, + personality_desc: character.personality_desc ?? null, + speech_style: character.speech_style ?? null, + relationship_desc: character.relationship_desc ?? null, + character_arc: character.character_arc ?? null, + negative_rules: character.negative_rules ?? null, + wardrobe_variant: character.wardrobe_variant ?? null, + voice_provider_code: character.voice_provider_code ?? null, + voice_model: character.voice_model ?? null, + voice_id: character.voice_id ?? null, + voice_style: character.voice_style ?? null, + performance_style: character.performance_style ?? null, + importance_level: character.importance_level ?? 0, + status: 'locked' + } + }); + + characterByKey.set(character.character_key, { + id: saved.id, + name: saved.name + }); + + await tx.actorProfile.create({ + data: { + project_id: project.id, + character_id: saved.id, + actor_desc: [ + character.name, + character.age_group, + character.gender_label, + character.identity_desc, + character.appearance_desc + ].filter(Boolean).join(','), + appearance_rules: [ + character.face_desc, + character.hair_desc, + character.body_desc, + character.negative_rules + ].filter(Boolean).join(';'), + wardrobe_rules: [ + character.costume_rules, + character.special_props, + character.wardrobe_variant + ].filter(Boolean).join(';'), + performance_style: character.performance_style ?? character.personality_desc ?? null, + voice_style: character.voice_style ?? character.speech_style ?? null, + reference_asset_ids: [], + status: 'generated' + } + }); + } + + const episode = await tx.episode.create({ + data: { + project_id: project.id, + episode_no: testcase.episode.episode_no, + source_chapter_ids: [], + title: testcase.episode.title ?? null, + summary: testcase.episode.summary ?? null, + opening_hook: testcase.episode.opening_hook ?? null, + middle_conflict: testcase.episode.middle_conflict ?? null, + ending_hook: testcase.episode.ending_hook ?? null, + target_duration: testcase.episode.target_duration ?? null, + status: 'confirmed' + } + }); + + await tx.episodeScript.create({ + data: { + project_id: project.id, + episode_id: episode.id, + script_text: testcase.episode.script_text ?? null, + narration_text: testcase.episode.narration_text ?? null, + dialogue_json: toPrismaJson(testcase.episode.dialogue_json ?? []), + version: 1, + status: 'confirmed' + } + }); + + const shotIds: Array<{ id: string; shot_no: number; route_tier: string | null }> = []; + + for (const shot of testcase.storyboard_shots) { + const saved = await tx.storyboardShot.create({ + data: { + project_id: project.id, + episode_id: episode.id, + shot_no: shot.shot_no, + scene_name: shot.scene_name, + location_desc: shot.location_desc, + characters_json: toPrismaJson(resolveShotCharacters(shot.characters_json, characterByKey)), + visual_desc: shot.visual_desc, + action_desc: shot.action_desc, + dialogue_text: shot.dialogue_text ?? null, + narration_text: shot.narration_text ?? null, + camera_motion: shot.camera_motion ?? null, + effect_type: shot.effect_type ?? null, + duration: new Prisma.Decimal(shot.duration), + scene_type: shot.scene_type ?? null, + importance_score: shot.importance_score ?? null, + emotion_score: shot.emotion_score ?? null, + action_score: shot.action_score ?? null, + route_tier: shot.route_tier ?? null, + prompt_text: buildShotPromptText(shot), + negative_prompt: buildShotNegativePrompt(), + live_action_desc: null, + actor_action: null, + camera_instruction: null, + performance_instruction: null, + video_prompt: null, + video_status: null, + status: 'confirmed' + } + }); + + shotIds.push({ + id: saved.id.toString(), + shot_no: saved.shot_no, + route_tier: saved.route_tier + }); + } + + await tx.operationLog.create({ + data: { + user_id: owner.id, + operator_role: owner.role, + action: 'live_action_testcase_import', + target_type: 'project', + target_id: project.id, + metadata_json: toPrismaJson({ + testcase_id: testcase.testcase_id, + file_path: config.filePath, + episode_id: episode.id.toString(), + shot_count: testcase.storyboard_shots.length, + total_duration: totalDuration(testcase), + output_requirements: testcase.output_requirements ?? {}, + acceptance_criteria: testcase.acceptance_criteria ?? {} + }) + } + }); + + return { + project_id: project.id.toString(), + episode_id: episode.id.toString(), + owner_user_id: owner.id.toString(), + shot_ids: shotIds, + total_duration: totalDuration(testcase) + }; + }); + + console.log(JSON.stringify({ + status: 'imported', + testcase_id: testcase.testcase_id, + ...result, + next_step: 'prepare_live_action_shots' + }, null, 2)); + } finally { + await prisma.$disconnect(); + } +} + +function parseArgs(args: string[]): RuntimeConfig { + const map = new Map(); + + for (const arg of args) { + if (!arg.startsWith('--')) continue; + const [key, ...rest] = arg.slice(2).split('='); + map.set(key, rest.length > 0 ? rest.join('=') : 'true'); + } + + return { + filePath: resolve(map.get('file') ?? process.env.LIVE_ACTION_TESTCASE_FILE ?? DEFAULT_TESTCASE_PATH), + ownerEmail: map.get('owner-email') ?? process.env.LIVE_ACTION_TESTCASE_OWNER_EMAIL ?? null, + replace: booleanArg(map.get('replace') ?? process.env.LIVE_ACTION_TESTCASE_REPLACE) + }; +} + +function booleanArg(value: string | undefined) { + if (!value) return false; + + return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase()); +} + +async function loadTestcase(filePath: string): Promise { + const raw = await readFile(filePath, 'utf8'); + + return JSON.parse(raw) as LiveActionTestcase; +} + +function assertTestcase(testcase: LiveActionTestcase) { + if (!testcase.testcase_id) throw new Error('testcase_id is required'); + if (!testcase.project?.title) throw new Error('project.title is required'); + if (!Array.isArray(testcase.characters) || testcase.characters.length === 0) { + throw new Error('characters are required'); + } + if (!testcase.episode?.episode_no) throw new Error('episode.episode_no is required'); + if (!Array.isArray(testcase.storyboard_shots) || testcase.storyboard_shots.length === 0) { + throw new Error('storyboard_shots are required'); + } +} + +async function resolveOwner(prisma: PrismaClient, ownerEmail: string | null) { + const explicit = ownerEmail + ? await prisma.user.findFirst({ where: { email: ownerEmail, status: 'active' } }) + : null; + const owner = explicit ?? + await prisma.user.findFirst({ where: { role: 'admin', status: 'active' }, orderBy: { id: 'asc' } }) ?? + await prisma.user.findFirst({ where: { status: 'active' }, orderBy: { id: 'asc' } }); + + if (!owner) { + throw new Error('No active user found. Seed an admin user before importing the testcase.'); + } + + return owner; +} + +async function deleteExistingImportedProjects(prisma: PrismaClient, testcase: LiveActionTestcase) { + const logs = await prisma.operationLog.findMany({ + where: { + action: 'live_action_testcase_import', + target_type: 'project', + metadata_json: { + path: '$.testcase_id', + equals: testcase.testcase_id + } + }, + select: { + target_id: true + } + }); + const ids = logs + .map((log) => log.target_id) + .filter((id): id is bigint => Boolean(id)); + + if (ids.length === 0) return; + + await prisma.$transaction(async (tx) => { + await tx.videoClip.deleteMany({ where: { project_id: { in: ids } } }); + await tx.renderTask.deleteMany({ where: { project_id: { in: ids } } }); + await tx.shotImage.deleteMany({ where: { project_id: { in: ids } } }); + await tx.storyboardShot.deleteMany({ where: { project_id: { in: ids } } }); + await tx.episodeScript.deleteMany({ where: { project_id: { in: ids } } }); + await tx.episode.deleteMany({ where: { project_id: { in: ids } } }); + await tx.actorProfile.deleteMany({ where: { project_id: { in: ids } } }); + await tx.characterMemory.deleteMany({ where: { project_id: { in: ids } } }); + await tx.characterImage.deleteMany({ where: { project_id: { in: ids } } }); + await tx.character.deleteMany({ where: { project_id: { in: ids } } }); + await tx.worldBible.deleteMany({ where: { project_id: { in: ids } } }); + await tx.storyBible.deleteMany({ where: { project_id: { in: ids } } }); + await tx.plotMemory.deleteMany({ where: { project_id: { in: ids } } }); + await tx.plotThread.deleteMany({ where: { project_id: { in: ids } } }); + await tx.continuityCheck.deleteMany({ where: { project_id: { in: ids } } }); + await tx.contentReview.deleteMany({ where: { project_id: { in: ids } } }); + await tx.copyrightRecord.deleteMany({ where: { project_id: { in: ids } } }); + await tx.operationLog.deleteMany({ + where: { + OR: [ + { target_type: 'project', target_id: { in: ids } }, + { metadata_json: { path: '$.testcase_id', equals: testcase.testcase_id } } + ] + } + }); + await tx.project.deleteMany({ where: { id: { in: ids } } }); + }); +} + +function resolveShotCharacters( + characters: TestcaseShotCharacter[], + characterByKey: Map +) { + return characters.map((character) => { + const saved = character.character_key ? characterByKey.get(character.character_key) : null; + + return { + id: saved?.id.toString() ?? null, + character_key: character.character_key ?? null, + name: saved?.name ?? character.name ?? '' + }; + }); +} + +function buildShotPromptText(shot: TestcaseShot) { + return [ + '真人短剧分镜图参考', + `场景:${shot.location_desc}`, + `人物:${shot.characters_json.map((character) => character.name).filter(Boolean).join('、') || '主要角色'}`, + `画面:${shot.visual_desc}`, + `动作:${shot.action_desc}`, + shot.camera_motion ? `镜头:${shot.camera_motion}` : null, + shot.effect_type ? `效果:${shot.effect_type}` : null, + `时长:${shot.duration}秒` + ].filter(Boolean).join('\n'); +} + +function buildShotNegativePrompt() { + return [ + '低清晰度', + '人物变脸', + '服装突变', + '多余手指', + '字幕乱码', + '水印', + '动漫风', + '夸张玄幻特效' + ].join(','); +} + +function totalDuration(testcase: LiveActionTestcase) { + return Number(testcase.storyboard_shots.reduce((sum, shot) => sum + Number(shot.duration || 0), 0).toFixed(2)); +} + +function toPrismaJson(value: unknown): Prisma.InputJsonValue { + if (value === null || value === undefined) return {}; + if (typeof value === 'string' || typeof value === 'boolean') return value; + if (typeof value === 'number') return Number.isFinite(value) ? value : 0; + if (Array.isArray(value)) return value.map((item) => toPrismaJson(item)); + if (typeof value === 'object') { + const output: Record = {}; + + for (const [key, child] of Object.entries(value as Record)) { + if (child !== undefined) { + output[key] = toPrismaJson(child); + } + } + + return output; + } + + return String(value); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +}); diff --git a/backend/src/live-action/live-action-provider-acceptance.ts b/backend/src/live-action/live-action-provider-acceptance.ts new file mode 100644 index 0000000..1776223 --- /dev/null +++ b/backend/src/live-action/live-action-provider-acceptance.ts @@ -0,0 +1,690 @@ +import 'reflect-metadata'; +import { NestFactory } from '@nestjs/core'; +import type { Prisma, ProviderConfig, VideoClip } from '@prisma/client'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { basename, extname, join, resolve } from 'node:path'; +import { AppModule } from '../app.module'; +import type { AuthRequestUser } from '../auth/auth.types'; +import { AssetsService } from '../assets/assets.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { ProvidersService } from '../providers/providers.service'; +import { LiveActionService } from './live-action.service'; + +type AcceptanceStatus = 'passed' | 'failed' | 'skipped'; + +type AcceptanceRow = { + provider_code: string; + provider_label: string; + started_at: string; + finished_at: string; + status: AcceptanceStatus; + provider_enabled: boolean | null; + provider_mode: string | null; + api_key_env: string | null; + api_key_configured: boolean | null; + preflight_ready: boolean; + preflight_next_step: string | null; + blockers: string[]; + warnings: string[]; + clip_id: string | null; + output_asset_id: string | null; + cost_actual: number | null; + quality_status: string | null; + quality_score: number | null; + repair_action: string | null; + error_message: string | null; + preview_url: string | null; +}; + +type AcceptanceReport = { + generated_at: string; + project_id: string; + episode_id: string; + shot_id: string; + keyframe_asset_id: string | null; + providers: string[]; + min_quality_score: number; + max_cost_per_clip: number | null; + confirm_real_video: boolean; + force_enable_providers: boolean; + force_regenerate: boolean; + operator_user_id: string; + rows: AcceptanceRow[]; + summary: { + passed: number; + failed: number; + skipped: number; + }; +}; + +type RuntimeConfig = { + projectId: string; + episodeId: string; + shotId: string; + providerCodes: string[]; + keyframePath: string | null; + keyframeAssetId: string | null; + confirmRealVideo: boolean; + forceEnableProviders: boolean; + forceRegenerate: boolean; + runQualityCheck: boolean; + failOnReject: boolean; + maxCostPerClip: number | null; + minQualityScore: number; + outputDir: string; + operatorUserId: string | null; +}; + +type ProviderReadiness = { + provider_enabled: boolean | null; + provider_mode: string | null; + api_key_env: string | null; + api_key_configured: boolean | null; + blockers: string[]; + warnings: string[]; +}; + +type ImmediateQualityRunner = { + executeVideoClipQualityCheckNow: ( + user: AuthRequestUser, + clipId: string, + dto?: { + auto_repair?: boolean; + min_quality_score?: number | string | null; + confirm_real_video?: boolean; + max_cost_per_clip?: number | string | null; + } + ) => Promise<{ + video_clip: { + id: string; + output_asset_id: string | null; + cost_actual: number | null; + quality_status: string | null; + quality_score: number | null; + }; + repair_action: string; + }>; +}; + +const PROVIDER_ALIASES: Record = { + hailuo: 'minimax_hailuo_23_fast', + minimax: 'minimax_hailuo_23_fast', + kling: 'kling-image-to-video', + mock: 'mock-video' +}; + +async function main() { + const config = readConfig(); + const app = await NestFactory.createApplicationContext(AppModule, { logger: ['error', 'warn'] }); + + try { + const prisma = app.get(PrismaService); + const liveAction = app.get(LiveActionService); + const assets = app.get(AssetsService); + const providers = app.get(ProvidersService); + const operator = await resolveOperator(prisma, config); + const assetOwner = await resolveProjectOwner(prisma, config); + + await providers.bootstrapVideoProviders(operator); + if (config.forceEnableProviders) { + await prisma.providerConfig.updateMany({ + where: { + provider_type: 'VideoProvider', + provider_code: { in: config.providerCodes.filter((code) => code !== 'mock-video') } + }, + data: { is_enabled: true } + }); + } + + const keyframeAssetId = await ensureAcceptanceKeyframe({ + config, + operator, + assetOwner, + assets, + liveAction + }); + const rows: AcceptanceRow[] = []; + + for (const providerCode of config.providerCodes) { + rows.push(await runProviderAcceptance({ + config, + operator, + liveAction, + prisma, + providerCode + })); + } + + const report: AcceptanceReport = { + generated_at: new Date().toISOString(), + project_id: config.projectId, + episode_id: config.episodeId, + shot_id: config.shotId, + keyframe_asset_id: keyframeAssetId, + providers: config.providerCodes, + min_quality_score: config.minQualityScore, + max_cost_per_clip: config.maxCostPerClip, + confirm_real_video: config.confirmRealVideo, + force_enable_providers: config.forceEnableProviders, + force_regenerate: config.forceRegenerate, + operator_user_id: operator.id, + rows, + summary: { + passed: rows.filter((row) => row.status === 'passed').length, + failed: rows.filter((row) => row.status === 'failed').length, + skipped: rows.filter((row) => row.status === 'skipped').length + } + }; + const output = await writeReport(config, report); + + console.log(`Live-action provider acceptance report written:`); + console.log(`- ${output.jsonPath}`); + console.log(`- ${output.markdownPath}`); + console.table(rows.map((row) => ({ + provider: row.provider_code, + status: row.status, + enabled: row.provider_enabled === null ? '-' : row.provider_enabled ? 'yes' : 'no', + key: row.api_key_env ? `${row.api_key_env}:${row.api_key_configured ? 'yes' : 'no'}` : '-', + preflight: row.preflight_ready, + clip: row.clip_id ?? '-', + cost: row.cost_actual ?? '-', + quality: row.quality_score ?? row.quality_status ?? '-', + error: row.error_message ? row.error_message.slice(0, 80) : '-' + }))); + + if (config.failOnReject && rows.some((row) => row.status !== 'passed')) { + process.exitCode = 1; + } + } finally { + await app.close(); + } +} + +async function runProviderAcceptance(input: { + config: RuntimeConfig; + operator: AuthRequestUser; + liveAction: LiveActionService; + prisma: PrismaService; + providerCode: string; +}): Promise { + const startedAt = new Date().toISOString(); + const baseRow: AcceptanceRow = { + provider_code: input.providerCode, + provider_label: providerLabel(input.providerCode), + started_at: startedAt, + finished_at: startedAt, + status: 'failed', + provider_enabled: null, + provider_mode: null, + api_key_env: null, + api_key_configured: null, + preflight_ready: false, + preflight_next_step: null, + blockers: [], + warnings: [], + clip_id: null, + output_asset_id: null, + cost_actual: null, + quality_status: null, + quality_score: null, + repair_action: null, + error_message: null, + preview_url: null + }; + + try { + const readiness = await inspectProviderReadiness( + input.prisma, + input.providerCode, + input.config.confirmRealVideo + ); + baseRow.provider_enabled = readiness.provider_enabled; + baseRow.provider_mode = readiness.provider_mode; + baseRow.api_key_env = readiness.api_key_env; + baseRow.api_key_configured = readiness.api_key_configured; + baseRow.blockers.push(...readiness.blockers); + baseRow.warnings.push(...readiness.warnings); + + if (readiness.blockers.length > 0) { + return finishRow(baseRow, 'skipped', readiness.blockers[0]); + } + + const preflight = await input.liveAction.preflightVideoClips(input.operator, input.config.episodeId, { + provider_code: input.providerCode, + confirm_real_video: input.config.confirmRealVideo, + max_cost_per_clip: input.config.maxCostPerClip, + shot_id: input.config.shotId + }); + baseRow.preflight_ready = preflight.ready; + baseRow.preflight_next_step = preflight.next_step; + baseRow.blockers.push(...preflight.blockers.map((issue) => `${issue.code}: ${issue.message}`)); + baseRow.warnings.push(...preflight.warnings.map((issue) => `${issue.code}: ${issue.message}`)); + + if (!preflight.ready) { + return finishRow(baseRow, 'skipped', baseRow.blockers[0] ?? 'Preflight did not pass'); + } + if (input.providerCode !== 'mock-video' && !input.config.confirmRealVideo) { + return finishRow( + baseRow, + 'skipped', + 'LIVE_ACTION_ACCEPTANCE_CONFIRM_REAL_VIDEO=true is required before running a real video provider' + ); + } + + const generated = await input.liveAction.generateShotVideoClip(input.operator, input.config.episodeId, input.config.shotId, { + provider_code: input.providerCode, + confirm_real_video: input.config.confirmRealVideo, + force: input.config.forceRegenerate, + max_cost_per_clip: input.config.maxCostPerClip + }); + baseRow.clip_id = generated.video_clip.id; + baseRow.output_asset_id = generated.video_clip.output_asset_id; + baseRow.cost_actual = generated.video_clip.cost_actual; + baseRow.preview_url = generated.video_clip.output_asset_id + ? `/api/assets/${generated.video_clip.output_asset_id}/download` + : null; + + if (input.config.runQualityCheck) { + const qualityRunner = input.liveAction as unknown as ImmediateQualityRunner; + const quality = await qualityRunner.executeVideoClipQualityCheckNow(input.operator, generated.video_clip.id, { + auto_repair: false, + min_quality_score: input.config.minQualityScore, + confirm_real_video: input.config.confirmRealVideo, + max_cost_per_clip: input.config.maxCostPerClip + }); + baseRow.output_asset_id = quality.video_clip.output_asset_id ?? baseRow.output_asset_id; + baseRow.cost_actual = quality.video_clip.cost_actual ?? baseRow.cost_actual; + baseRow.quality_status = quality.video_clip.quality_status; + baseRow.quality_score = quality.video_clip.quality_score; + baseRow.repair_action = quality.repair_action; + } else { + const latestClip = await findLatestClip(input.prisma, generated.video_clip.id); + baseRow.quality_status = latestClip?.quality_status ?? null; + baseRow.quality_score = latestClip?.quality_score ? Number(latestClip.quality_score.toString()) : null; + } + + const passed = + baseRow.output_asset_id !== null && + (baseRow.quality_score === null + ? baseRow.quality_status === null || baseRow.quality_status === 'passed' + : baseRow.quality_status === 'passed' && baseRow.quality_score >= input.config.minQualityScore); + + return finishRow(baseRow, passed ? 'passed' : 'failed', passed ? null : 'Quality check did not meet acceptance threshold'); + } catch (error) { + const message = toErrorMessage(error); + const status = isReadinessError(message) ? 'skipped' : 'failed'; + + return finishRow(baseRow, status, message); + } +} + +async function inspectProviderReadiness( + prisma: PrismaService, + providerCode: string, + confirmRealVideo: boolean +): Promise { + const provider = await prisma.providerConfig.findUnique({ + where: { + provider_type_provider_code: { + provider_type: 'VideoProvider', + provider_code: providerCode + } + } + }); + const config = jsonObject(provider?.config_json); + const apiKeyEnv = stringifyText(config.api_key_env); + const isRealProvider = Boolean(provider && provider.mode !== 'mock'); + const blockers: string[] = []; + const warnings: string[] = []; + const hasManagedKey = hasManagedProviderSecret(config.api_key_secure); + const apiKeyConfigured = isRealProvider + ? hasManagedKey || (apiKeyEnv ? hasConfiguredEnvValue(apiKeyEnv) : false) + : null; + + if (!provider) { + blockers.push('LIVE_ACTION_VIDEO_PROVIDER_NOT_FOUND: 视频 Provider 不存在。'); + } else if (!provider.is_enabled) { + blockers.push('LIVE_ACTION_VIDEO_PROVIDER_DISABLED: 视频 Provider 未启用。'); + } + if (provider && isRealProvider && !confirmRealVideo) { + blockers.push('REAL_VIDEO_GENERATION_CONFIRMATION_REQUIRED: 真实视频生成需要显式确认费用。'); + } + if (provider && isRealProvider && !apiKeyConfigured) { + blockers.push( + apiKeyEnv + ? `${apiKeyEnv}_MISSING: 未配置 ${apiKeyEnv},不能调用真实 Provider。` + : 'VIDEO_PROVIDER_API_KEY_NOT_CONFIGURED: 未配置后台密钥或 api_key_env,不能调用真实 Provider。' + ); + } + if (provider && isRealProvider && hasManagedKey && !apiKeyEnv) { + warnings.push('VIDEO_PROVIDER_API_KEY_ENV_NOT_SET: 已保存后台密钥,但 Provider 配置未声明 api_key_env。'); + } + + return { + provider_enabled: provider?.is_enabled ?? null, + provider_mode: provider?.mode ?? null, + api_key_env: apiKeyEnv || null, + api_key_configured: apiKeyConfigured, + blockers, + warnings + }; +} + +async function ensureAcceptanceKeyframe(input: { + config: RuntimeConfig; + operator: AuthRequestUser; + assetOwner: AuthRequestUser; + assets: AssetsService; + liveAction: LiveActionService; +}) { + if (input.config.keyframePath) { + const absolutePath = resolve(input.config.keyframePath); + const buffer = await readFile(absolutePath); + const mimeType = imageMimeFromPath(absolutePath); + + if (!mimeType) { + throw new Error('LIVE_ACTION_ACCEPTANCE_KEYFRAME_PATH must be a PNG, JPG, JPEG, or WEBP file'); + } + + const uploaded = await input.assets.uploadAsset( + input.assetOwner, + { + fieldname: 'file', + originalname: basename(absolutePath), + encoding: '7bit', + mimetype: mimeType, + size: buffer.length, + buffer + } as Express.Multer.File, + 'image', + input.config.projectId + ); + await input.liveAction.attachShotKeyframe(input.operator, input.config.episodeId, input.config.shotId, { + asset_id: uploaded.asset.id + }); + + return uploaded.asset.id; + } + if (input.config.keyframeAssetId) { + await input.liveAction.attachShotKeyframe(input.operator, input.config.episodeId, input.config.shotId, { + asset_id: input.config.keyframeAssetId + }); + + return input.config.keyframeAssetId; + } + + return null; +} + +async function resolveOperator(prisma: PrismaService, config: RuntimeConfig): Promise { + const explicit = config.operatorUserId + ? await prisma.user.findUnique({ where: { id: parseBigInt(config.operatorUserId, 'LIVE_ACTION_ACCEPTANCE_OPERATOR_USER_ID') } }) + : null; + const admin = explicit ?? await prisma.user.findFirst({ where: { role: 'admin', status: 'active' }, orderBy: { id: 'asc' } }); + + if (!admin || admin.role !== 'admin') { + throw new Error('An active admin user is required for provider acceptance because provider override is admin-only'); + } + + const project = await prisma.project.findUnique({ + where: { id: parseBigInt(config.projectId, 'LIVE_ACTION_ACCEPTANCE_PROJECT_ID') } + }); + if (!project) { + throw new Error(`Project not found: ${config.projectId}`); + } + const episode = await prisma.episode.findUnique({ + where: { id: parseBigInt(config.episodeId, 'LIVE_ACTION_ACCEPTANCE_EPISODE_ID') } + }); + if (!episode || episode.project_id !== project.id) { + throw new Error('Episode does not belong to the configured project'); + } + const shot = await prisma.storyboardShot.findUnique({ + where: { id: parseBigInt(config.shotId, 'LIVE_ACTION_ACCEPTANCE_SHOT_ID') } + }); + if (!shot || shot.episode_id !== episode.id) { + throw new Error('Shot does not belong to the configured episode'); + } + + return { + id: admin.id.toString(), + email: admin.email, + role: admin.role + }; +} + +async function resolveProjectOwner(prisma: PrismaService, config: RuntimeConfig): Promise { + const project = await prisma.project.findUnique({ + where: { id: parseBigInt(config.projectId, 'LIVE_ACTION_ACCEPTANCE_PROJECT_ID') } + }); + const owner = project + ? await prisma.user.findUnique({ where: { id: project.user_id } }) + : null; + + if (!owner) { + throw new Error(`Project owner not found: ${config.projectId}`); + } + + return { + id: owner.id.toString(), + email: owner.email, + role: owner.role + }; +} + +async function findLatestClip(prisma: PrismaService, clipId: string): Promise { + return prisma.videoClip.findUnique({ + where: { id: parseBigInt(clipId, 'clip id') } + }); +} + +async function writeReport(config: RuntimeConfig, report: AcceptanceReport) { + const day = report.generated_at.slice(0, 10); + const timestamp = report.generated_at.replace(/[^0-9]+/g, '').slice(0, 14); + const dir = join(resolve(config.outputDir), day); + const baseName = `live-action-acceptance-project-${config.projectId}-episode-${config.episodeId}-shot-${config.shotId}-${timestamp}`; + const jsonPath = join(dir, `${baseName}.json`); + const markdownPath = join(dir, `${baseName}.md`); + + await mkdir(dir, { recursive: true }); + await writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`); + await writeFile(markdownPath, markdownReport(report)); + + return { jsonPath, markdownPath }; +} + +function markdownReport(report: AcceptanceReport) { + const rows = report.rows.map((row) => [ + row.provider_code, + row.status, + row.provider_enabled === null ? '-' : row.provider_enabled ? 'yes' : 'no', + row.api_key_env ? `${row.api_key_env}:${row.api_key_configured ? 'yes' : 'no'}` : '-', + row.preflight_ready ? 'yes' : 'no', + row.clip_id ?? '-', + row.output_asset_id ?? '-', + row.cost_actual ?? '-', + row.quality_score ?? row.quality_status ?? '-', + row.error_message?.replace(/\|/g, '/') ?? '-' + ]); + + return [ + '# Live Action Provider Acceptance', + '', + `Generated at: ${report.generated_at}`, + `Project: ${report.project_id}`, + `Episode: ${report.episode_id}`, + `Shot: ${report.shot_id}`, + `Keyframe asset: ${report.keyframe_asset_id ?? '-'}`, + `Confirm real video: ${report.confirm_real_video ? 'yes' : 'no'}`, + `Min quality score: ${report.min_quality_score}`, + `Max cost per clip: ${report.max_cost_per_clip ?? '-'}`, + '', + '| Provider | Status | Enabled | Key | Preflight | Clip | Asset | Cost | Quality | Error |', + '| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |', + ...rows.map((row) => `| ${row.join(' | ')} |`), + '', + `Summary: passed=${report.summary.passed}, failed=${report.summary.failed}, skipped=${report.summary.skipped}`, + '' + ].join('\n'); +} + +function readConfig(): RuntimeConfig { + const projectId = requiredEnv('LIVE_ACTION_ACCEPTANCE_PROJECT_ID'); + const episodeId = requiredEnv('LIVE_ACTION_ACCEPTANCE_EPISODE_ID'); + const shotId = requiredEnv('LIVE_ACTION_ACCEPTANCE_SHOT_ID'); + + return { + projectId, + episodeId, + shotId, + providerCodes: uniqueStrings( + optionalEnv('LIVE_ACTION_ACCEPTANCE_PROVIDERS', 'hailuo,kling,mock') + .split(',') + .map((value) => providerAlias(value.trim())) + .filter(Boolean) + ), + keyframePath: optionalEnv('LIVE_ACTION_ACCEPTANCE_KEYFRAME_PATH', '').trim() || null, + keyframeAssetId: optionalEnv('LIVE_ACTION_ACCEPTANCE_KEYFRAME_ASSET_ID', '').trim() || null, + confirmRealVideo: envBoolean('LIVE_ACTION_ACCEPTANCE_CONFIRM_REAL_VIDEO', false), + forceEnableProviders: envBoolean('LIVE_ACTION_ACCEPTANCE_FORCE_ENABLE_PROVIDERS', false), + forceRegenerate: envBoolean('LIVE_ACTION_ACCEPTANCE_FORCE_REGENERATE', true), + runQualityCheck: envBoolean('LIVE_ACTION_ACCEPTANCE_RUN_QUALITY', true), + failOnReject: envBoolean('LIVE_ACTION_ACCEPTANCE_FAIL_ON_REJECT', false), + maxCostPerClip: optionalNumber('LIVE_ACTION_ACCEPTANCE_MAX_COST_PER_CLIP'), + minQualityScore: optionalNumber('LIVE_ACTION_ACCEPTANCE_MIN_QUALITY_SCORE') ?? 80, + outputDir: optionalEnv('LIVE_ACTION_ACCEPTANCE_OUTPUT_DIR', defaultAcceptanceOutputDir()), + operatorUserId: optionalEnv('LIVE_ACTION_ACCEPTANCE_OPERATOR_USER_ID', '').trim() || null + }; +} + +function providerAlias(value: string) { + return PROVIDER_ALIASES[value] ?? value; +} + +function providerLabel(providerCode: string) { + if (providerCode === 'minimax_hailuo_23_fast') return 'Hailuo Fast'; + if (providerCode === 'kling-image-to-video') return 'Kling'; + if (providerCode === 'mock-video') return 'Mock'; + + return providerCode; +} + +function imageMimeFromPath(filePath: string) { + const extension = extname(filePath).toLowerCase(); + + if (extension === '.png') return 'image/png'; + if (extension === '.jpg' || extension === '.jpeg') return 'image/jpeg'; + if (extension === '.webp') return 'image/webp'; + + return ''; +} + +function defaultAcceptanceOutputDir() { + const cwd = process.cwd(); + + return basename(cwd) === 'backend' + ? resolve(cwd, '..', 'storage/private/live-action-acceptance') + : resolve(cwd, 'storage/private/live-action-acceptance'); +} + +function jsonObject(value: Prisma.JsonValue | null | undefined) { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; +} + +function stringifyText(value: unknown) { + return typeof value === 'string' ? value.trim() : ''; +} + +function hasConfiguredEnvValue(name: string) { + const value = process.env[name]?.trim(); + + return Boolean(value && !/^your[_-]/i.test(value) && !/replace/i.test(value)); +} + +function hasManagedProviderSecret(value: unknown) { + const payload = jsonObject(value as Prisma.JsonValue | null | undefined); + + return payload.kind === 'provider_secret_v1' && typeof payload.value === 'string' && payload.value.length > 0; +} + +function isReadinessError(message: string) { + return [ + 'AI_ROUTER_NO_VIDEO_PROVIDER_AVAILABLE', + 'LIVE_ACTION_VIDEO_PROVIDER_DISABLED', + 'LIVE_ACTION_VIDEO_PROVIDER_NOT_FOUND', + 'REAL_VIDEO_GENERATION_CONFIRMATION_REQUIRED', + 'API key', + 'api_key' + ].some((pattern) => message.includes(pattern)); +} + +function finishRow(row: AcceptanceRow, status: AcceptanceStatus, errorMessage: string | null) { + return { + ...row, + status, + finished_at: new Date().toISOString(), + error_message: errorMessage + }; +} + +function uniqueStrings(values: string[]) { + return [...new Set(values.filter(Boolean))]; +} + +function requiredEnv(name: string) { + const value = process.env[name]?.trim(); + + if (!value) { + throw new Error(`${name} is required`); + } + + return value; +} + +function optionalEnv(name: string, fallback: string) { + return process.env[name]?.trim() || fallback; +} + +function optionalNumber(name: string) { + const value = process.env[name]?.trim(); + + if (!value) return null; + + const parsed = Number(value); + + if (!Number.isFinite(parsed)) { + throw new Error(`${name} must be a number`); + } + + return parsed; +} + +function envBoolean(name: string, fallback: boolean) { + const value = process.env[name]?.trim().toLowerCase(); + + if (!value) return fallback; + if (['1', 'true', 'yes', 'on'].includes(value)) return true; + if (['0', 'false', 'no', 'off'].includes(value)) return false; + + throw new Error(`${name} must be true or false`); +} + +function parseBigInt(value: string, label: string) { + try { + return BigInt(value); + } catch { + throw new Error(`${label} must be a valid integer id`); + } +} + +function toErrorMessage(error: unknown) { + if (error instanceof Error) return error.message; + + return String(error); +} + +main().catch((error) => { + console.error(toErrorMessage(error)); + process.exit(1); +}); diff --git a/backend/src/live-action/live-action.controller.ts b/backend/src/live-action/live-action.controller.ts new file mode 100644 index 0000000..7121add --- /dev/null +++ b/backend/src/live-action/live-action.controller.ts @@ -0,0 +1,154 @@ +import { Body, Controller, Get, Inject, Param, Post, Query, UseGuards } from '@nestjs/common'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import type { AuthRequestUser } from '../auth/auth.types'; +import { + LiveActionAttachKeyframeDto, + LiveActionCostEstimateQueryDto, + LiveActionGenerateDto, + LiveActionManualReviewDto, + LiveActionPreflightQueryDto, + LiveActionQualityCheckDto +} from './live-action.dto'; +import { LiveActionService } from './live-action.service'; + +@Controller() +@UseGuards(JwtAuthGuard) +export class LiveActionController { + constructor(@Inject(LiveActionService) private readonly liveActionService: LiveActionService) {} + + @Get('projects/:projectId/live-action/actor-profiles') + listActorProfiles(@CurrentUser() user: AuthRequestUser, @Param('projectId') projectId: string) { + return this.liveActionService.listActorProfiles(user, projectId); + } + + @Post('projects/:projectId/live-action/actor-profiles/generate') + generateActorProfiles( + @CurrentUser() user: AuthRequestUser, + @Param('projectId') projectId: string, + @Body() dto: LiveActionGenerateDto + ) { + return this.liveActionService.generateActorProfiles(user, projectId, dto); + } + + @Get('episodes/:episodeId/live-action/shots') + listLiveActionShots(@CurrentUser() user: AuthRequestUser, @Param('episodeId') episodeId: string) { + return this.liveActionService.listLiveActionShots(user, episodeId); + } + + @Post('episodes/:episodeId/live-action/shots/prepare') + prepareLiveActionShots( + @CurrentUser() user: AuthRequestUser, + @Param('episodeId') episodeId: string, + @Body() dto: LiveActionGenerateDto + ) { + return this.liveActionService.prepareLiveActionShots(user, episodeId, dto); + } + + @Post('episodes/:episodeId/live-action/keyframes/generate') + generateKeyframes( + @CurrentUser() user: AuthRequestUser, + @Param('episodeId') episodeId: string, + @Body() dto: LiveActionGenerateDto + ) { + return this.liveActionService.generateKeyframes(user, episodeId, dto); + } + + @Get('episodes/:episodeId/live-action/video-clips') + listVideoClips(@CurrentUser() user: AuthRequestUser, @Param('episodeId') episodeId: string) { + return this.liveActionService.listVideoClips(user, episodeId); + } + + @Get('live-action/video-providers') + listVideoProviders(@CurrentUser() user: AuthRequestUser) { + return this.liveActionService.listVideoProviders(user); + } + + @Get('episodes/:episodeId/live-action/video-clips/cost-estimate') + estimateVideoClipCost( + @CurrentUser() user: AuthRequestUser, + @Param('episodeId') episodeId: string, + @Query() query: LiveActionCostEstimateQueryDto + ) { + return this.liveActionService.estimateVideoClipCost(user, episodeId, query.provider_code); + } + + @Get('episodes/:episodeId/live-action/video-clips/preflight') + preflightVideoClips( + @CurrentUser() user: AuthRequestUser, + @Param('episodeId') episodeId: string, + @Query() query: LiveActionPreflightQueryDto + ) { + return this.liveActionService.preflightVideoClips(user, episodeId, query); + } + + @Post('episodes/:episodeId/live-action/shots/:shotId/keyframe') + attachShotKeyframe( + @CurrentUser() user: AuthRequestUser, + @Param('episodeId') episodeId: string, + @Param('shotId') shotId: string, + @Body() dto: LiveActionAttachKeyframeDto + ) { + return this.liveActionService.attachShotKeyframe(user, episodeId, shotId, dto); + } + + @Post('episodes/:episodeId/live-action/shots/:shotId/video-clip/generate') + generateShotVideoClip( + @CurrentUser() user: AuthRequestUser, + @Param('episodeId') episodeId: string, + @Param('shotId') shotId: string, + @Body() dto: LiveActionGenerateDto + ) { + return this.liveActionService.generateShotVideoClip(user, episodeId, shotId, dto); + } + + @Post('episodes/:episodeId/live-action/video-clips/generate') + generateVideoClips( + @CurrentUser() user: AuthRequestUser, + @Param('episodeId') episodeId: string, + @Body() dto: LiveActionGenerateDto + ) { + return this.liveActionService.generateVideoClips(user, episodeId, dto); + } + + @Post('live-action/video-clips/:clipId/retry') + retryVideoClip( + @CurrentUser() user: AuthRequestUser, + @Param('clipId') clipId: string, + @Body() dto: LiveActionGenerateDto + ) { + return this.liveActionService.retryVideoClip(user, clipId, dto); + } + + @Post('live-action/video-clips/:clipId/quality-check') + checkVideoClipQuality( + @CurrentUser() user: AuthRequestUser, + @Param('clipId') clipId: string, + @Body() dto: LiveActionQualityCheckDto + ) { + return this.liveActionService.checkVideoClipQuality(user, clipId, dto); + } + + @Post('live-action/video-clips/:clipId/manual-review') + manualReviewVideoClip( + @CurrentUser() user: AuthRequestUser, + @Param('clipId') clipId: string, + @Body() dto: LiveActionManualReviewDto + ) { + return this.liveActionService.manualReviewVideoClip(user, clipId, dto); + } + + @Post('live-action/video-clips/:clipId/select-candidate') + selectVideoClipCandidate(@CurrentUser() user: AuthRequestUser, @Param('clipId') clipId: string) { + return this.liveActionService.selectVideoClipCandidate(user, clipId); + } + + @Post('episodes/:episodeId/live-action/render') + renderLiveActionEpisode( + @CurrentUser() user: AuthRequestUser, + @Param('episodeId') episodeId: string, + @Body() dto: LiveActionGenerateDto + ) { + return this.liveActionService.renderLiveActionEpisode(user, episodeId, dto); + } +} diff --git a/backend/src/live-action/live-action.dto.ts b/backend/src/live-action/live-action.dto.ts new file mode 100644 index 0000000..809a4d0 --- /dev/null +++ b/backend/src/live-action/live-action.dto.ts @@ -0,0 +1,55 @@ +export class LiveActionGenerateDto { + force?: boolean; + only_missing?: boolean; + provider_code?: string; + confirm_real_video?: boolean; + max_cost_per_clip?: number | string | null; + candidate_count?: number | string | null; + shot_id?: string; + include_audio?: boolean; + include_subtitle?: boolean; + include_bgm?: boolean; + include_sfx?: boolean; + include_lip_sync?: boolean; + lip_sync_max_seconds?: number | string | null; + voice?: string; + voice_provider_code?: string; + lip_sync_provider_code?: string; + subtitle_mode?: 'dialogue' | 'shot'; + max_chars_per_line?: number | string | null; + bgm_asset_id?: string; + bgm_volume?: number | string | null; + sfx_volume?: number | string | null; + action_beat_mode?: boolean | string; + action_beat_count?: number | string | null; +} + +export class LiveActionQualityCheckDto { + auto_repair?: boolean; + min_quality_score?: number | string | null; + confirm_real_video?: boolean; + max_cost_per_clip?: number | string | null; +} + +export class LiveActionCostEstimateQueryDto { + provider_code?: string; +} + +export class LiveActionPreflightQueryDto { + provider_code?: string; + confirm_real_video?: boolean | string; + max_cost_per_clip?: number | string | null; + shot_id?: string; + action_beat_mode?: boolean | string; + action_beat_count?: number | string | null; +} + +export class LiveActionAttachKeyframeDto { + asset_id?: string; +} + +export class LiveActionManualReviewDto { + result_status?: string; + reason?: string; + quality_score?: number | string | null; +} diff --git a/backend/src/live-action/live-action.module.ts b/backend/src/live-action/live-action.module.ts new file mode 100644 index 0000000..7555e50 --- /dev/null +++ b/backend/src/live-action/live-action.module.ts @@ -0,0 +1,18 @@ +import { forwardRef, Module } from '@nestjs/common'; +import { AiRouterModule } from '../ai-router/ai-router.module'; +import { AuthModule } from '../auth/auth.module'; +import { AssetsModule } from '../assets/assets.module'; +import { PrismaModule } from '../prisma/prisma.module'; +import { ProvidersModule } from '../providers/providers.module'; +import { QueuesModule } from '../queues/queues.module'; +import { LiveActionController } from './live-action.controller'; +import { LiveActionService } from './live-action.service'; +import { LiveActionPromptBuilderService } from './prompt-builder.service'; + +@Module({ + imports: [AiRouterModule, AuthModule, AssetsModule, PrismaModule, ProvidersModule, forwardRef(() => QueuesModule)], + controllers: [LiveActionController], + providers: [LiveActionService, LiveActionPromptBuilderService], + exports: [LiveActionService, LiveActionPromptBuilderService] +}) +export class LiveActionModule {} diff --git a/backend/src/live-action/live-action.service.spec.ts b/backend/src/live-action/live-action.service.spec.ts new file mode 100644 index 0000000..9b21c23 --- /dev/null +++ b/backend/src/live-action/live-action.service.spec.ts @@ -0,0 +1,2009 @@ +import { Prisma, type ActorProfile, type Asset, type Character, type Episode, type Project, type ProviderConfig, type RenderTask, type StoryboardShot, type VideoClip } from '@prisma/client'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AiRouterService } from '../ai-router/ai-router.service'; +import type { StorageService } from '../assets/storage.service'; +import type { PrismaService } from '../prisma/prisma.service'; +import type { ProvidersService } from '../providers/providers.service'; +import { LiveActionService } from './live-action.service'; + +const now = new Date('2026-06-09T00:00:00.000Z'); +const user = { id: '1', email: 'user@example.com', role: 'user' }; + +function createProject(overrides: Partial = {}): Project { + return { + id: 10n, + user_id: 1n, + title: '真人短剧路由测试', + input_mode: 'ai_original', + genre: 'urban_rebirth', + style_code: 'live_action', + output_type: 'short_video', + output_mode: 'live_action_ai', + visual_mode: 'live_action', + video_generation_level: 'standard', + target_episode_count: 3, + episode_duration: 60, + status: 'live_action_keyframes_generated', + copyright_status: 'ai_original', + payment_status: 'paid', + quality_level: 'mvp', + is_long_series: false, + created_at: now, + updated_at: now, + completed_at: null, + ...overrides + }; +} + +function createEpisode(overrides: Partial = {}): Episode { + return { + id: 20n, + project_id: 10n, + episode_no: 1, + source_chapter_ids: ['1'], + title: '第1集', + summary: '女主反击。', + opening_hook: '会议室录音曝光。', + middle_conflict: '男主施压。', + ending_hook: '幕后车辆出现。', + target_duration: 60, + status: 'confirmed', + created_at: now, + updated_at: now, + ...overrides + }; +} + +function createShot(overrides: Partial = {}): StoryboardShot { + return { + id: 30n, + project_id: 10n, + episode_id: 20n, + shot_no: 1, + scene_name: '会议室反击', + location_desc: '高层会议室', + characters_json: [{ id: '1', name: '林晚' }], + visual_desc: '林晚站在会议桌前。', + action_desc: '林晚播放录音证据。', + dialogue_text: '这一回,我不会再退。', + narration_text: '局势开始反转。', + camera_motion: 'zoom_in', + effect_type: 'flash', + duration: new Prisma.Decimal(4), + scene_type: 'dialog', + importance_score: 3, + emotion_score: 2, + action_score: 1, + route_tier: 'normal', + prompt_text: '真人短剧会议室反击', + negative_prompt: '低清晰度', + live_action_desc: '真人短剧风格会议室反击。', + actor_action: '播放录音证据。', + camera_instruction: 'medium close-up', + performance_instruction: '冷静克制', + video_prompt: 'photorealistic Chinese vertical short drama', + keyframe_asset_id: 40n, + video_clip_asset_id: null, + video_status: 'keyframe_generated', + status: 'confirmed', + created_at: now, + updated_at: now, + ...overrides + }; +} + +function createProvider(overrides: Partial = {}): ProviderConfig { + return { + id: 100n, + provider_type: 'VideoProvider', + provider_code: 'mock-video', + display_name: 'Mock Video Provider', + mode: 'mock', + model_name: 'mock-video-v1', + config_json: {}, + fallback_provider_id: null, + is_enabled: true, + priority: 100, + rate_limit_json: {}, + cost_rule_json: { flat_cost: 0, unit: 'mock' }, + created_at: now, + updated_at: now, + ...overrides + }; +} + +function createTask(overrides: Partial = {}): RenderTask { + return { + id: 50n, + project_id: 10n, + episode_id: 20n, + shot_id: 30n, + task_type: 'live_action_video_clip_generate', + provider_id: null, + status: 'pending', + input_json: {}, + input_hash: 'hash-task', + idempotency_key: 'idem-task', + output_asset_id: null, + provider_request_id: null, + retry_count: 0, + max_retry: 2, + cost_estimate: null, + cost_actual: null, + error_code: null, + error_message: null, + created_at: now, + started_at: null, + finished_at: null, + ...overrides + }; +} + +function createAsset(overrides: Partial = {}): Asset { + return { + id: 60n, + user_id: 1n, + project_id: 10n, + asset_type: 'video_clip', + file_path: 'local://live-action-video-clips/mock.mp4', + file_url: null, + mime_type: 'video/mp4', + width: 1080, + height: 1920, + duration: new Prisma.Decimal(4), + size: 1024n, + hash: 'hash-video', + visibility: 'private', + status: 'mock', + created_at: now, + ...overrides + }; +} + +function createClip(overrides: Partial = {}): VideoClip { + return { + id: 70n, + project_id: 10n, + episode_id: 20n, + shot_id: 30n, + provider_id: 100n, + input_asset_id: 40n, + output_asset_id: 60n, + duration: new Prisma.Decimal(4), + prompt_text: 'photorealistic Chinese vertical short drama', + status: 'generated', + cost_actual: new Prisma.Decimal(0), + retry_count: 0, + quality_status: null, + quality_score: null, + quality_issues: null, + created_at: now, + updated_at: now, + ...overrides + }; +} + +function createCharacter(overrides: Partial = {}): Character { + return { + id: 1n, + project_id: 10n, + global_character_id: null, + name: '林晚', + alias_names: [], + role_type: 'lead', + gender_label: 'female', + age_group: 'young_adult', + identity_desc: null, + appearance_desc: null, + face_desc: null, + hair_desc: null, + eye_desc: null, + body_desc: null, + costume_rules: null, + special_props: null, + personality_desc: null, + speech_style: null, + relationship_desc: null, + character_arc: null, + negative_rules: null, + anchor_asset_id: null, + wardrobe_variant: null, + voice_provider_code: null, + voice_model: null, + voice_id: null, + voice_style: null, + performance_style: null, + importance_level: 0, + status: 'active', + created_at: now, + updated_at: now, + ...overrides + }; +} + +function createActorProfile(overrides: Partial = {}): ActorProfile { + return { + id: 80n, + project_id: 10n, + character_id: 1n, + actor_desc: '林晚由同一位年轻东亚女演员出演,气质冷静坚定。', + appearance_rules: '鹅蛋脸,黑色长发,眼神清冷,脸部轮廓稳定。', + wardrobe_rules: '白色西装外套,同一集内不换装。', + performance_style: '克制、冷静,情绪递进清楚。', + voice_style: '年轻女性,语气坚定。', + reference_asset_ids: ['89'], + anchor_asset_id: 88n, + status: 'generated', + created_at: now, + updated_at: now, + ...overrides + }; +} + +describe('LiveActionService AI Router integration', () => { + let prisma: any; + let storage: any; + let providers: any; + let aiRouter: any; + let queues: any; + let service: LiveActionService; + + beforeEach(() => { + const providerCatalog = [ + createProvider(), + createProvider({ + id: 101n, + provider_code: 'minimax_hailuo_23_fast', + display_name: 'Hailuo', + mode: 'mock' + }), + createProvider({ + id: 102n, + provider_code: 'kling_21', + display_name: 'Kling', + mode: 'mock' + }) + ]; + const findProviderByCode = (providerCode: string) => + providerCatalog.find((provider) => provider.provider_code === providerCode) ?? null; + const findProviderById = (providerId: bigint) => + providerCatalog.find((provider) => provider.id === providerId) ?? null; + + prisma = { + project: { + findUnique: vi.fn().mockResolvedValue(createProject()), + update: vi.fn().mockResolvedValue(createProject()) + }, + episode: { + findUnique: vi.fn().mockResolvedValue(createEpisode()) + }, + storyboardShot: { + findMany: vi.fn().mockResolvedValue([createShot()]), + findUnique: vi.fn().mockResolvedValue(createShot()), + update: vi.fn(async ({ data }: { data: Partial }) => + createShot(data) + ) + }, + videoClip: { + findFirst: vi.fn().mockResolvedValue(null), + findUnique: vi.fn().mockResolvedValue(createClip()), + create: vi.fn().mockResolvedValue(createClip()), + update: vi.fn(async ({ data }: { data: Partial }) => + createClip(data) + ) + }, + renderTask: { + findFirst: vi.fn().mockResolvedValue(createTask({ + status: 'success', + output_asset_id: 60n, + input_json: { + provider: 'mock-video', + router_decision: { + provider_code: 'mock-video', + fallback_chain: ['minimax_hailuo_23_fast', 'kling_21', 'mock-video'] + } + } + })), + create: vi.fn(async ({ data }: { data: Partial }) => + createTask({ + input_json: data.input_json, + input_hash: data.input_hash, + idempotency_key: data.idempotency_key + }) + ), + update: vi.fn().mockResolvedValue(createTask({ status: 'success', output_asset_id: 60n })) + }, + providerConfig: { + upsert: vi.fn().mockResolvedValue(createProvider()), + findMany: vi.fn().mockResolvedValue(providerCatalog), + findFirst: vi.fn().mockResolvedValue(createProvider()), + findUnique: vi.fn(async ({ where }: { where: any }) => { + if (where?.id) { + return findProviderById(where.id); + } + + const providerCode = where?.provider_type_provider_code?.provider_code; + + return providerCode ? findProviderByCode(providerCode) : createProvider(); + }) + }, + asset: { + create: vi.fn().mockResolvedValue(createAsset()), + findUnique: vi.fn(async ({ where }: { where: { id: bigint } }) => + where.id === 40n + ? createAsset({ + id: 40n, + asset_type: 'image', + file_path: 'local://live-action-keyframes/keyframe.png', + mime_type: 'image/png' + }) + : createAsset({ id: where.id }) + ) + }, + character: { + findMany: vi.fn().mockResolvedValue([]) + }, + actorProfile: { + findMany: vi.fn().mockResolvedValue([]) + }, + operationLog: { + create: vi.fn().mockResolvedValue({ + id: 500n, + user_id: 1n, + operator_role: 'user', + action: 'router_audit_quality_recheck', + target_type: 'video_clip', + target_id: 70n, + metadata_json: {}, + ip: null, + user_agent: null, + created_at: now + }) + } + }; + storage = { + readPrivateFile: vi.fn().mockResolvedValue(Buffer.from('mock-keyframe-png')), + storePrivateFile: vi.fn().mockResolvedValue({ + file_path: 'local://live-action-video-clips/mock.mp4', + size: 1024n, + hash: 'hash-video' + }), + createTemporaryPublicUrl: vi.fn(({ filePath }: { filePath: string }) => + `https://api.example.com/api/public-temp-assets/${Buffer.from(filePath).toString('hex')}` + ) + }; + providers = { + executeProvider: vi.fn().mockResolvedValue({ + provider: { + id: '100', + provider_code: 'mock-video', + mode: 'mock' + }, + result: { + asset_url: 'mock://video/clip.mp4', + duration: 4 + }, + provider_log: { + cost_actual: 0 + } + }), + executeProviderBatch: vi.fn(async (_baseDto: any, items: any[]) => ({ + mode: 'fallback_sequential', + count: items.length, + results: items.map((item, index) => ({ + provider: { + id: '101', + provider_code: 'mock-voice', + mode: 'mock' + }, + result: { + asset_url: `mock://audio/${index + 1}.mp3`, + duration: Number(item.input_json?.target_duration) || 4 + }, + provider_log: { + cost_actual: 0 + } + })) + })) + }; + aiRouter = { + scoreLiveActionShot: vi.fn().mockReturnValue({ + scene_type: 'dialog', + importance_score: 3, + emotion_score: 2, + action_score: 1, + route_tier: 'normal' + }), + resolveLiveActionVideoRoute: vi.fn(async (params: any) => { + const manualOverride = Boolean(params.manual_provider_code && params.allow_manual_override); + const providerCode = manualOverride ? params.manual_provider_code : 'mock-video'; + const provider = findProviderByCode(providerCode) ?? createProvider({ provider_code: providerCode }); + + return { + config_key: 'ai.router.v1', + task_type: 'live_action_video_clip_generate', + language: 'zh-CN', + provider_code: provider.provider_code, + provider_id: provider.id.toString(), + provider_mode: provider.mode, + route_tier: 'normal', + fallback_chain: ['minimax_hailuo_23_fast', 'kling_21', 'mock-video'], + candidates: [{ provider_code: provider.provider_code, status: 'selected', reason: 'auto_normal_route', estimated_cost: 0 }], + decision_reason: manualOverride ? 'manual_override' : 'auto_normal_route', + estimated_cost: 0, + manual_override: manualOverride, + scores: { + scene_type: 'dialog', + importance_score: 3, + emotion_score: 2, + action_score: 1, + route_tier: 'normal' + } + }; + }) + }; + queues = { + createInternalTask: vi.fn().mockResolvedValue({ + task: { + id: '900', + project_id: '10', + episode_id: '20', + shot_id: '30', + task_type: 'live_action_video_clip_retry', + provider_id: null, + status: 'pending', + input_json: {}, + input_hash: 'hash', + idempotency_key: 'queue-idem', + output_asset_id: null, + provider_request_id: 'job-900', + retry_count: 0, + max_retry: 1, + cost_estimate: null, + cost_actual: null, + error_code: null, + error_message: null, + created_at: now.toISOString(), + started_at: null, + finished_at: null + }, + queue: { + queue_name: 'video_queue', + queue_backend: 'bullmq', + job_id: 'job-900', + enqueued: true + }, + idempotent: false + }) + }; + service = new LiveActionService( + prisma as PrismaService, + storage as StorageService, + providers as ProvidersService, + aiRouter as AiRouterService + ); + vi.spyOn(service as any, 'createMockVideoClipBuffer').mockResolvedValue(Buffer.from('mock-video-bytes')); + vi.spyOn(service as any, 'mixLiveActionAudioSegments').mockResolvedValue({ + buffer: Buffer.from('mock-dialogue-audio'), + mimeType: 'audio/wav', + duration: 4 + }); + vi.spyOn(service as any, 'createLiveActionAmbientBgm').mockResolvedValue(Buffer.from('mock-bgm-audio')); + }); + + it('uses AI Router when provider_code is omitted and records the decision on the render task', async () => { + await service.generateVideoClips(user, '20', { force: true }); + + expect(aiRouter.resolveLiveActionVideoRoute).toHaveBeenCalledWith( + expect.objectContaining({ + language: 'zh-CN', + manual_provider_code: undefined, + allow_manual_override: false + }) + ); + expect(providers.executeProvider).toHaveBeenCalledWith( + expect.objectContaining({ + provider_type: 'VideoProvider', + preferred_provider_code: 'mock-video' + }) + ); + expect(prisma.renderTask.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + input_json: expect.objectContaining({ + router_decision: expect.objectContaining({ + config_key: 'ai.router.v1', + provider_code: 'mock-video', + decision_reason: 'auto_normal_route' + }), + prompt_version: 'live-action-prompt-engine-v1', + prompt_profile: 'mock', + prompt_components: expect.objectContaining({ + prompt_version: 'live-action-prompt-engine-v1', + provider_profile: 'mock', + scene_type: 'dialog' + }) + }) + }) + }); + }); + + it('injects scoped actor lock hints into video generation and records actor lock audit data', async () => { + prisma.actorProfile.findMany.mockResolvedValueOnce([ + createActorProfile() + ]); + + await service.generateShotVideoClip(user, '20', '30', { + force: true, + max_cost_per_clip: '1' + }); + + const providerInput = providers.executeProvider.mock.calls[0][0].input_json; + const createTaskInput = prisma.renderTask.create.mock.calls[0][0].data.input_json; + + expect(providerInput.prompt).toContain('演员一致性'); + expect(providerInput.prompt).toContain('鹅蛋脸,黑色长发'); + expect(providerInput.prompt).toContain('禁止同名角色换脸'); + expect(providerInput.actor_lock).toEqual( + expect.objectContaining({ + lock_version: 'live-action-character-lock-v1', + character_ids: ['1'], + character_names: ['林晚'], + anchor_asset_ids: ['88'], + reference_asset_ids: ['88', '89'], + provider_character_reference_enabled: false + }) + ); + expect(createTaskInput.actor_lock).toEqual(providerInput.actor_lock); + }); + + it('returns a ready preflight report for auto-routed mock video generation', async () => { + const result = await service.preflightVideoClips(user, '20', {}); + + expect(result.ready).toBe(true); + expect(result.next_step).toBe('generate_video_clips'); + expect(result.summary.shot_count).toBe(1); + expect(result.summary.provider_clip_count).toBe(1); + expect(result.breakdown[0]).toEqual( + expect.objectContaining({ + shot_id: '30', + provider_code: 'mock-video', + keyframe_ready: true, + source_image_required: false + }) + ); + }); + + it('blocks preflight when a non-mock video provider is selected without confirmation', async () => { + const result = await service.preflightVideoClips( + { ...user, role: 'admin' }, + '20', + { provider_code: 'kling_21', confirm_real_video: 'false' } + ); + + expect(result.ready).toBe(false); + expect(result.next_step).toBe('confirm_real_video'); + expect(result.manual_override_allowed).toBe(true); + expect(result.requires_real_video_confirmation).toBe(true); + expect(result.blockers.map((issue) => issue.code)).toContain('REAL_VIDEO_GENERATION_CONFIRMATION_REQUIRED'); + expect(result.breakdown[0]).toEqual( + expect.objectContaining({ + provider_code: 'kling_21', + source_image_required: true, + source_image_ready: true + }) + ); + }); + + it('blocks preflight for real video when the keyframe is still a mock SVG', async () => { + prisma.asset.findUnique.mockResolvedValueOnce(createAsset({ + id: 40n, + asset_type: 'image', + file_path: 'local://live-action-keyframes/keyframe.svg', + mime_type: 'image/svg+xml' + })); + + const result = await service.preflightVideoClips( + { ...user, role: 'admin' }, + '20', + { provider_code: 'kling_21', confirm_real_video: 'true' } + ); + + expect(result.ready).toBe(false); + expect(result.next_step).toBe('real_keyframe_required'); + expect(result.blockers.map((issue) => issue.code)).toContain('LIVE_ACTION_KEYFRAME_RASTER_REQUIRED'); + expect(result.breakdown[0]).toEqual( + expect.objectContaining({ + keyframe_mime_type: 'image/svg+xml', + source_image_required: true, + source_image_ready: false + }) + ); + }); + + it('filters preflight to a selected sample shot', async () => { + prisma.storyboardShot.findMany.mockResolvedValueOnce([ + createShot({ id: 30n, shot_no: 1 }), + createShot({ + id: 31n, + shot_no: 2, + video_prompt: null, + keyframe_asset_id: null + }) + ]); + + const result = await service.preflightVideoClips(user, '20', { shot_id: '30' }); + + expect(result.ready).toBe(true); + expect(result.shot_id).toBe('30'); + expect(result.summary.shot_count).toBe(1); + expect(result.breakdown).toHaveLength(1); + expect(result.breakdown[0].shot_id).toBe('30'); + }); + + it('attaches an uploaded raster keyframe to one sample shot', async () => { + const result = await service.attachShotKeyframe(user, '20', '30', { asset_id: '40' }); + + expect(result.next_step).toBe('sample_preflight'); + expect(result.keyframe.id).toBe('40'); + expect(prisma.storyboardShot.update).toHaveBeenCalledWith({ + where: { id: 30n }, + data: { + keyframe_asset_id: 40n, + video_clip_asset_id: null, + video_status: 'sample_keyframe_uploaded' + } + }); + }); + + it('generates only the selected sample shot video clip after preflight passes', async () => { + const result = await service.generateShotVideoClip(user, '20', '30', { + force: true, + max_cost_per_clip: '1' + }); + + expect(result.next_step).toBe('sample_quality_check'); + expect(result.reused).toBe(false); + expect(result.video_clip.shot_id).toBe('30'); + expect(prisma.videoClip.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + shot_id: 30n, + status: 'generated' + }) + }); + expect(providers.executeProvider).toHaveBeenCalledWith( + expect.objectContaining({ + provider_type: 'VideoProvider', + preferred_provider_code: 'mock-video' + }) + ); + }); + + it('generates multiple live action candidates while selecting only the first one by default', async () => { + prisma.videoClip.create + .mockResolvedValueOnce(createClip({ id: 70n, output_asset_id: 60n })) + .mockResolvedValueOnce(createClip({ id: 71n, output_asset_id: 61n })); + + const result = await service.generateShotVideoClip(user, '20', '30', { + force: true, + max_cost_per_clip: '1', + candidate_count: 2 + }); + + expect(result.next_step).toBe('sample_candidate_select_or_quality_check'); + expect(result.candidate_count).toBe(2); + expect(result.video_clips).toHaveLength(2); + expect(providers.executeProvider).toHaveBeenCalledTimes(2); + expect(prisma.renderTask.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + input_json: expect.objectContaining({ + candidate_index: 1, + candidate_count: 2, + auto_select_clip: true + }) + }) + }); + expect(prisma.renderTask.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + input_json: expect.objectContaining({ + candidate_index: 2, + candidate_count: 2, + auto_select_clip: false + }) + }) + }); + expect(prisma.storyboardShot.update).toHaveBeenCalledWith({ + where: { id: 30n }, + data: { + video_clip_asset_id: 60n, + video_status: 'video_clip_candidate_selected' + } + }); + }); + + it('selects the best candidate clip and records the manual selection', async () => { + prisma.videoClip.findUnique.mockResolvedValueOnce(createClip({ id: 71n, output_asset_id: 61n })); + prisma.storyboardShot.findUnique.mockResolvedValueOnce(createShot({ video_clip_asset_id: 60n })); + + const result = await service.selectVideoClipCandidate(user, '71'); + + expect(result.next_step).toBe('render_live_action_episode'); + expect(prisma.storyboardShot.update).toHaveBeenCalledWith({ + where: { id: 30n }, + data: { + video_clip_asset_id: 61n, + video_status: 'video_clip_candidate_selected' + } + }); + expect(prisma.operationLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + action: 'live_action_video_clip_candidate_selected', + target_type: 'video_clip', + target_id: 71n, + metadata_json: expect.objectContaining({ + selected_output_asset_id: '61', + previous_video_clip_asset_id: '60', + reason: 'manual_best_candidate_selection' + }) + }) + }); + }); + + it('reuses only the active live action clip when its duration still matches the shot', async () => { + prisma.videoClip.findFirst.mockResolvedValueOnce(createClip({ + output_asset_id: 60n, + duration: new Prisma.Decimal(4) + })); + + const reusable = await (service as any).findReusableLiveActionVideoClip( + createShot({ video_clip_asset_id: 60n, duration: new Prisma.Decimal(4.1) }) + ); + + expect(reusable?.output_asset_id).toBe(60n); + + prisma.videoClip.findFirst.mockResolvedValueOnce(createClip({ + output_asset_id: 60n, + duration: new Prisma.Decimal(4) + })); + + const stale = await (service as any).findReusableLiveActionVideoClip( + createShot({ video_clip_asset_id: 60n, duration: new Prisma.Decimal(6) }) + ); + + expect(stale).toBeNull(); + }); + + it('downgrades close-up dialogue shots when no lip-sync provider is available', async () => { + await service.generateShotVideoClip(user, '20', '30', { + force: true, + max_cost_per_clip: '1' + }); + + const providerInput = providers.executeProvider.mock.calls[0][0].input_json; + const createTaskInput = prisma.renderTask.create.mock.calls[0][0].data.input_json; + + expect(providerInput.prompt).toContain('lip_sync_strategy: post_tts_subtitle_light_mouth'); + expect(providerInput.prompt).toContain('Do not animate clear mouth articulation'); + expect(providerInput.prompt).toContain('medium shot, three-quarter profile'); + expect(createTaskInput.lip_sync_policy).toEqual( + expect.objectContaining({ + lip_sync_required: true, + provider_available: false, + strategy: 'post_tts_subtitle_light_mouth', + visual_fallback: true + }) + ); + }); + + it('delays fallback dialogue audio into the shot when lip-sync is unavailable', () => { + const segments = (service as any).buildLiveActionAudioSegments( + [ + createShot({ + duration: new Prisma.Decimal(6), + scene_type: 'dialog', + importance_score: 9, + camera_instruction: 'steady_medium_close_up', + dialogue_text: '你终于找到我了。' + }) + ], + { voice_provider_code: 'minimax-tts' }, + false + ); + + expect(segments[0]).toEqual( + expect.objectContaining({ + start_seconds: 2.04, + end_seconds: 5.55, + target_duration: 3.51, + lip_sync_required: true, + lip_sync_strategy: 'post_tts_subtitle_light_mouth', + visual_fallback: true + }) + ); + }); + + it('splits one live-action shot into per-speaker dialogue segments with character voices', () => { + const segments = (service as any).buildLiveActionAudioSegments( + [ + createShot({ + duration: new Prisma.Decimal(10), + dialogue_text: [ + '林雨薇:顾辰,今天这场订婚取消吧。', + '顾辰:为什么?', + '周浩:一个月工资五千,拿什么娶老婆?' + ].join('\n'), + narration_text: null, + characters_json: [ + { id: '100', name: '林雨薇' }, + { id: '99', name: '顾辰' }, + { id: '101', name: '周浩' } + ] + }) + ], + { voice_provider_code: 'minimax-tts' }, + false, + new Map(), + [ + createCharacter({ + id: 100n, + name: '林雨薇', + role_type: 'ex_fiancee', + gender_label: 'female', + voice_provider_code: 'minimax-tts', + voice_id: 'Arrogant_Miss', + voice_style: '年轻女性,冷淡、现实、咬字清楚。' + }), + createCharacter({ + id: 99n, + name: '顾辰', + role_type: 'male_lead', + gender_label: 'male', + voice_provider_code: 'minimax-tts', + voice_id: 'Chinese (Mandarin)_Sincere_Adult', + voice_style: '年轻男性,克制、低沉、受伤但有尊严。' + }), + createCharacter({ + id: 101n, + name: '周浩', + role_type: 'villain', + gender_label: 'male', + voice_provider_code: 'minimax-tts', + voice_id: 'Chinese (Mandarin)_Reliable_Executive', + voice_style: '年轻男性,傲慢、讥讽、语速略慢。' + }) + ] + ); + + expect(segments).toHaveLength(3); + expect(segments.map((segment: any) => segment.speaker_name)).toEqual(['林雨薇', '顾辰', '周浩']); + expect(segments.map((segment: any) => segment.voice)).toEqual([ + 'Arrogant_Miss', + 'Chinese (Mandarin)_Sincere_Adult', + 'Chinese (Mandarin)_Reliable_Executive' + ]); + expect(segments.map((segment: any) => segment.character_id)).toEqual(['100', '99', '101']); + expect(segments[1].start_seconds).toBeGreaterThan(segments[0].end_seconds); + expect(segments[2].start_seconds).toBeGreaterThan(segments[1].end_seconds); + expect(segments[2].end_seconds).toBeLessThanOrEqual(10); + + expect((service as any).liveActionAudioProviderInput(segments[0])).toEqual(expect.objectContaining({ + speaker: '林雨薇', + voice: 'Arrogant_Miss', + voice_id: 'Arrogant_Miss', + instructions: '年轻女性,冷淡、现实、咬字清楚。' + })); + }); + + it('uses a male default MiniMax voice for butler dialogue', () => { + const segments = (service as any).buildLiveActionAudioSegments( + [ + createShot({ + duration: new Prisma.Decimal(10), + dialogue_text: '老管家:少爷,我终于找到您了。', + narration_text: null, + characters_json: [{ name: '老管家' }] + }) + ], + { voice_provider_code: 'minimax-tts' }, + false + ); + + expect(segments[0]).toEqual(expect.objectContaining({ + speaker_name: '老管家', + voice_provider_code: 'minimax-tts', + voice: 'Chinese (Mandarin)_Gentleman' + })); + }); + + it('builds chained action-beat provider segments when explicitly enabled', () => { + const segments = (service as any).buildLiveActionProviderClipSegments( + createShot({ + id: 266n, + shot_no: 5, + scene_type: 'reveal', + route_tier: 'premium', + importance_score: 10, + duration: new Prisma.Decimal(10), + keyframe_asset_id: 531n, + scene_name: '劳斯莱斯与继承权反转', + action_desc: '老管家撑伞走近,递出黑金卡,顾辰震惊。' + }), + 10, + { + action_beat_mode: true, + action_beat_count: 2 + }, + createProvider({ + provider_code: 'minimax_hailuo_23_fast', + config_json: { + allowed_durations: [6, 10] + } + }) + ); + + expect(segments).toEqual([ + expect.objectContaining({ + index: 1, + duration: 6, + source_strategy: 'shot_keyframe', + source_keyframe_asset_id: '531', + beat_label: 'beat_1_approach_and_card' + }), + expect.objectContaining({ + index: 2, + duration: 6, + source_strategy: 'previous_segment_end_frame', + source_keyframe_asset_id: null, + beat_label: 'beat_2_reaction_hold' + }) + ]); + expect(segments[1].beat_prompt).toContain('承接上一段最后一帧'); + }); + + it('keeps fallback BGM low and does not re-normalize the full mixed track', () => { + const filter = (service as any).liveActionFinalAudioFilter(1, 2, null, 30, 0.04); + + expect(filter).toContain('volume=0.040'); + expect(filter).toContain('alimiter=limit=0.95'); + expect(filter).not.toContain('amix=inputs=2:normalize=0:duration=first,loudnorm'); + }); + + it('builds scene sound-effect cues and keeps them on a separate mix bus', () => { + const cues = (service as any).buildLiveActionSfxCues([ + createShot({ + location_desc: '夜巷小雨,湿漉漉的街道。', + action_desc: '王生推开门走近。', + narration_text: '下一秒,他看见了那张皮。', + importance_score: 10, + emotion_score: 9 + }) + ]); + const cueTypes = cues.map((cue: any) => cue.cue_type); + const filter = (service as any).liveActionFinalAudioFilter(1, 2, 3, 12, 0.04, 0.12); + + expect(cueTypes).toEqual(expect.arrayContaining(['rain', 'footstep', 'door', 'heartbeat', 'sting'])); + expect(filter).toContain('[a_sfx]'); + expect(filter).toContain('volume=0.120'); + expect(filter).toContain('amix=inputs=3'); + }); + + it('builds xianxia transformation sound-effect cues for high-impact fantasy shots', () => { + const cues = (service as any).buildLiveActionSfxCues([ + createShot({ + scene_name: '法身降临', + location_desc: '废墟崩塌,碎石悬浮粉化。', + action_desc: '白裙女仙结印,紫色光球高速旋转,千臂法身拔地而起,空间震荡轰鸣。', + effect_type: '法相天地,狂风,灵力电流,重低音冲击', + importance_score: 10, + emotion_score: 9, + action_score: 9 + }) + ]); + const cueTypes = cues.map((cue: any) => cue.cue_type); + + expect(cueTypes).toEqual(expect.arrayContaining(['wind', 'debris', 'electric', 'impact', 'heartbeat', 'sting'])); + }); + + it('builds director-style BGM cues from xianxia story beats', () => { + const cues = (service as any).buildLiveActionBgmCues([ + createShot({ + id: 253n, + shot_no: 1, + duration: new Prisma.Decimal(3), + scene_name: '浴血惊鸿', + visual_desc: '白裙女仙从废墟残垣凌空翻滚落地,狂风呼啸。', + action_desc: '落地后镜头极速推向双眸。', + effect_type: '狂风 碎石 浴血落地', + scene_type: 'xianxia_transformation', + importance_score: 8, + emotion_score: 8, + action_score: 8 + }), + createShot({ + id: 254n, + shot_no: 2, + duration: new Prisma.Decimal(3), + scene_name: '繁花结印', + visual_desc: '十指交错结印,紫色光球在胸前高速旋转。', + effect_type: '繁花结印 紫色光球 灵力电流', + scene_type: 'xianxia_transformation', + importance_score: 9, + emotion_score: 8, + action_score: 8 + }), + createShot({ + id: 255n, + shot_no: 3, + duration: new Prisma.Decimal(4), + scene_name: '法身降临', + visual_desc: '透明千臂法身拔地而起,碎石失重粉化。', + effect_type: '法相天地 千臂法身 重低音轰鸣', + scene_type: 'xianxia_transformation', + importance_score: 10, + emotion_score: 10, + action_score: 10 + }) + ]); + + expect(cues.map((cue: any) => cue.cue_type)).toEqual([ + 'xianxia_tension', + 'xianxia_build_up', + 'xianxia_epic' + ]); + expect(cues[0]).toEqual(expect.objectContaining({ start_seconds: 0, end_seconds: 3 })); + expect(cues[1]).toEqual(expect.objectContaining({ start_seconds: 3, end_seconds: 6 })); + expect(cues[2]).toEqual(expect.objectContaining({ start_seconds: 6, end_seconds: 10 })); + expect(cues[2].reason).toBe('xianxia_dharma_form_epic_release'); + expect((service as any).resolveLiveActionBgmVolume({}, false, cues)).toBe(0.55); + expect((service as any).resolveLiveActionSfxVolume({}, false, cues)).toBe(0.95); + expect((service as any).resolveLiveActionBgmVolume({}, true, cues)).toBe(0.12); + }); + + it('builds director continuity plans and expands short live action shots to the episode target', () => { + const shots = [ + createShot({ + id: 241n, + shot_no: 1, + scene_name: '夜巷初遇', + location_desc: '古代城镇夜晚,青石巷潮湿反光。', + narration_text: '夜巷里,王生看见一个白衣女子。', + duration: new Prisma.Decimal(3) + }), + createShot({ + id: 242n, + shot_no: 2, + scene_name: '女子求助', + location_desc: '古代城镇夜晚,青石巷潮湿反光。', + dialogue_text: '女子:公子,救我。', + duration: new Prisma.Decimal(3.8) + }), + createShot({ + id: 243n, + shot_no: 3, + scene_name: '书斋收留', + location_desc: '古代书斋,木窗半开。', + action_desc: '王生推开书斋门。', + duration: new Prisma.Decimal(2.7) + }), + createShot({ + id: 244n, + shot_no: 4, + scene_name: '道士警告', + dialogue_text: '道士:她不是人。', + emotion_score: 8, + duration: new Prisma.Decimal(3.2) + }), + createShot({ + id: 245n, + shot_no: 5, + scene_name: '窗外窥视', + action_desc: '王生屏住呼吸,慢慢靠近窗缝。', + emotion_score: 9, + duration: new Prisma.Decimal(2.8) + }), + createShot({ + id: 246n, + shot_no: 6, + scene_name: '画皮真相一闪', + visual_desc: '铜镜前摆着画笔和一张白色人皮轮廓。', + narration_text: '下一秒,他看见了那张皮。', + importance_score: 10, + emotion_score: 10, + duration: new Prisma.Decimal(3.4) + }) + ]; + + const plans = (service as any).buildLiveActionDirectorPlans(shots, createEpisode({ target_duration: 30 })); + const values = shots.map((shot) => plans.get(shot.id.toString())); + const total = values.reduce((sum, plan) => sum + plan.duration_seconds, 0); + + expect(Number(total.toFixed(1))).toBe(30); + expect(values[0]).toEqual(expect.objectContaining({ + shot_role: 'establishing', + duration_seconds: expect.any(Number) + })); + expect(values[5]).toEqual(expect.objectContaining({ + shot_role: 'reveal', + edit_intent: expect.stringContaining('hook') + })); + expect(values[1].continuity_in).toContain('same screen direction'); + expect(values[2].continuity_in).toContain('sound bridge'); + }); + + it('keeps Hailuo-friendly 10-second director beats when the episode is designed as five long shots', () => { + const shots = [ + createShot({ + id: 251n, + shot_no: 1, + scene_name: '订婚宴开场', + location_desc: '现代酒店宴会厅,舞台和入口在同一空间轴线上。', + duration: new Prisma.Decimal(10) + }), + createShot({ + id: 252n, + shot_no: 2, + scene_name: '当众退婚', + location_desc: '现代酒店宴会厅,舞台中央。', + visual_desc: '顾辰站在舞台灯光下,手里的戒指盒还没有合上。', + action_desc: '林雨薇宣布退婚,镜头保持中景过肩,避免嘴部特写。', + dialogue_text: '林雨薇:今天这场订婚取消吧。', + duration: new Prisma.Decimal(10) + }), + createShot({ + id: 253n, + shot_no: 3, + scene_name: '富二代羞辱', + location_desc: '现代酒店宴会厅,舞台中央。', + dialogue_text: '周浩:一个月工资五千,拿什么娶老婆?', + importance_score: 9, + emotion_score: 9, + duration: new Prisma.Decimal(10) + }), + createShot({ + id: 254n, + shot_no: 4, + scene_name: '雨夜离场', + location_desc: '酒店外雨夜街道,霓虹和积水反光。', + action_desc: '顾辰独自走出酒店,雨水打湿西装,低头看一眼手里的戒指盒。', + duration: new Prisma.Decimal(10) + }), + createShot({ + id: 255n, + shot_no: 5, + scene_name: '继承权反转', + location_desc: '酒店外雨夜街道,一辆劳斯莱斯停在路边。', + dialogue_text: '管家:顾氏财团继承权已经生效。', + importance_score: 10, + emotion_score: 10, + duration: new Prisma.Decimal(10) + }) + ]; + + const plans = (service as any).buildLiveActionDirectorPlans(shots, createEpisode({ target_duration: 50 })); + const values = shots.map((shot) => plans.get(shot.id.toString())); + const total = values.reduce((sum, plan) => sum + plan.duration_seconds, 0); + + expect(Number(total.toFixed(1))).toBe(50); + expect(values.map((plan) => plan.duration_seconds)).toEqual([10, 10, 10, 10, 10]); + expect(values[4]).toEqual(expect.objectContaining({ + shot_role: 'reveal', + edit_intent: expect.stringContaining('hook') + })); + }); + + it('runs a selected lip-sync provider before final render and stores the synced clip', async () => { + const shot = createShot({ video_clip_asset_id: 60n }); + const segment = { + index: 1, + shot_id: '30', + shot_no: 1, + segment_type: 'dialogue', + start_seconds: 2.04, + end_seconds: 5.55, + target_duration: 3.51, + speaker_name: '林晚', + text: '这一回,我不会再退。', + voice_provider_code: 'mock-voice', + voice: null, + voice_style: null, + lip_sync_required: true, + lip_sync_strategy: 'provider_lipsync', + visual_fallback: false, + lip_sync_skip_reason: null + }; + const audioFile = { + index: 1, + segment, + buffer: Buffer.from('mock-audio'), + mimeType: 'audio/wav', + duration: 1, + isMock: false, + provider_code: 'mock-voice', + cost_actual: 0, + asset_url: null + }; + prisma.providerConfig.upsert.mockResolvedValueOnce(createProvider({ + id: 103n, + provider_type: 'LipSyncProvider', + provider_code: 'mock-lipsync', + display_name: 'Mock Lip Sync Provider', + mode: 'mock', + model_name: 'mock-lipsync-v1' + })); + providers.executeProvider.mockResolvedValueOnce({ + provider: { + id: '103', + provider_type: 'LipSyncProvider', + provider_code: 'mock-lipsync', + mode: 'mock' + }, + result: { + asset_url: 'mock://lipsync/clip.mp4', + lip_sync_applied: true, + mock_passthrough: true + }, + provider_log: { + cost_actual: 0 + } + }); + prisma.asset.create.mockResolvedValueOnce(createAsset({ id: 90n, status: 'mock' })); + + const clips = await (service as any).generateLiveActionLipSyncAssets( + user, + createProject(), + createEpisode(), + [shot], + [segment], + [audioFile], + { + include_lip_sync: true, + lip_sync_provider_code: 'mock-lipsync' + } + ); + + expect(clips).toEqual([ + expect.objectContaining({ + shot_id: '30', + output_asset_id: '90', + provider_code: 'mock-lipsync', + provider_mode: 'mock' + }) + ]); + expect(providers.executeProvider).toHaveBeenCalledWith( + expect.objectContaining({ + provider_type: 'LipSyncProvider', + preferred_provider_code: 'mock-lipsync', + return_binary: true, + input_json: expect.objectContaining({ + video_data_uri: expect.stringMatching(/^data:video\/mp4;base64,/), + audio_data_uri: expect.stringMatching(/^data:audio\/wav;base64,/) + }) + }) + ); + expect(prisma.renderTask.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + task_type: 'live_action_lip_sync_generate', + input_json: expect.not.objectContaining({ + video_data_uri: expect.any(String), + audio_data_uri: expect.any(String) + }) + }) + }); + expect(prisma.renderTask.update).toHaveBeenCalledWith({ + where: { id: 50n }, + data: expect.objectContaining({ + status: 'success', + output_asset_id: 90n + }) + }); + }); + + it('keeps lip-sync within the episode second budget and downgrades overflow shots', () => { + const normalShot = createShot({ + id: 30n, + shot_no: 1, + duration: new Prisma.Decimal(6), + importance_score: 4, + emotion_score: 3, + action_score: 1, + camera_instruction: 'front close-up dialogue' + }); + const premiumShot = createShot({ + id: 31n, + shot_no: 2, + duration: new Prisma.Decimal(6), + importance_score: 10, + emotion_score: 8, + action_score: 3, + route_tier: 'premium', + camera_instruction: 'front close-up confession' + }); + const plan = (service as any).buildLiveActionLipSyncBudgetPlan( + [normalShot, premiumShot], + { lip_sync_max_seconds: 6 }, + true + ); + const segments = (service as any).buildLiveActionAudioSegments( + [normalShot, premiumShot], + { lip_sync_max_seconds: 6 }, + true, + plan.decisions + ); + + expect(plan).toEqual(expect.objectContaining({ + max_seconds: 6, + required_count: 2, + selected_count: 1, + selected_seconds: 6, + skipped: [ + expect.objectContaining({ + shot_id: '30', + reason: 'lip_sync_budget_exceeded' + }) + ] + })); + expect(segments).toEqual([ + expect.objectContaining({ + shot_id: '30', + lip_sync_required: true, + lip_sync_strategy: 'post_tts_subtitle_light_mouth', + visual_fallback: true, + lip_sync_skip_reason: 'lip_sync_budget_exceeded', + start_seconds: 2.04 + }), + expect.objectContaining({ + shot_id: '31', + lip_sync_required: true, + lip_sync_strategy: 'provider_lipsync', + visual_fallback: false, + lip_sync_skip_reason: null, + start_seconds: 6.55 + }) + ]); + }); + + it('creates temporary public video and audio URLs for URL-only lip-sync providers', async () => { + const shot = createShot({ video_clip_asset_id: 60n }); + const segment = { + index: 1, + shot_id: '30', + shot_no: 1, + segment_type: 'dialogue', + start_seconds: 0.55, + end_seconds: 3.55, + target_duration: 3, + speaker_name: '林晚', + text: '这一回,我不会再退。', + voice_provider_code: 'mock-voice', + voice: null, + voice_style: null, + lip_sync_required: true, + lip_sync_strategy: 'provider_lipsync', + visual_fallback: false, + lip_sync_skip_reason: null + }; + const audioFile = { + index: 1, + segment, + buffer: Buffer.from('real-audio'), + mimeType: 'audio/wav', + duration: 1, + isMock: false, + provider_code: 'mock-voice', + cost_actual: 0, + asset_url: null + }; + + prisma.providerConfig.findUnique.mockResolvedValueOnce(createProvider({ + id: 104n, + provider_type: 'LipSyncProvider', + provider_code: 'alibaba-videoretalk-lipsync', + display_name: 'Alibaba VideoRetalk Lip Sync', + mode: 'real', + model_name: 'videoretalk', + config_json: { + requires_public_urls: true, + public_url_expires_seconds: 1800 + } + })); + providers.executeProvider.mockResolvedValueOnce({ + provider: { + id: '104', + provider_type: 'LipSyncProvider', + provider_code: 'alibaba-videoretalk-lipsync', + mode: 'real' + }, + result: { + content_base64: Buffer.from('synced-video').toString('base64'), + mime_type: 'video/mp4', + asset_url: 'https://cdn.example.com/synced.mp4', + lip_sync_applied: true + }, + provider_log: { + cost_actual: 0.48 + } + }); + prisma.asset.create.mockResolvedValueOnce(createAsset({ id: 91n, status: 'active' })); + + const clips = await (service as any).generateLiveActionLipSyncAssets( + user, + createProject(), + createEpisode(), + [shot], + [segment], + [audioFile], + { + include_lip_sync: true, + lip_sync_provider_code: 'alibaba-videoretalk-lipsync', + confirm_real_video: true + } + ); + + expect(clips).toEqual([ + expect.objectContaining({ + shot_id: '30', + output_asset_id: '91', + provider_code: 'alibaba-videoretalk-lipsync', + provider_mode: 'real' + }) + ]); + expect(storage.createTemporaryPublicUrl).toHaveBeenCalledWith({ + filePath: 'local://live-action-video-clips/mock.mp4', + mimeType: 'video/mp4', + expiresInSeconds: 1800 + }); + expect(storage.createTemporaryPublicUrl).toHaveBeenCalledWith({ + filePath: 'local://live-action-video-clips/mock.mp4', + mimeType: 'audio/wav', + expiresInSeconds: 1800 + }); + expect(providers.executeProvider).toHaveBeenCalledWith( + expect.objectContaining({ + provider_type: 'LipSyncProvider', + preferred_provider_code: 'alibaba-videoretalk-lipsync', + input_json: expect.objectContaining({ + video_url: expect.stringContaining('/public-temp-assets/'), + audio_url: expect.stringContaining('/public-temp-assets/'), + video_data_uri: expect.stringMatching(/^data:video\/mp4;base64,/), + audio_data_uri: expect.stringMatching(/^data:audio\/wav;base64,/) + }) + }) + ); + expect(prisma.renderTask.update).toHaveBeenCalledWith({ + where: { id: 50n }, + data: expect.objectContaining({ + input_json: expect.objectContaining({ + asset_bridge: expect.objectContaining({ + requires_public_urls: true, + video_url_source: 'temporary', + audio_url_source: 'temporary' + }) + }) + }) + }); + }); + + it('uses visual fallback timing when lip-sync is explicitly disabled', () => { + const segments = (service as any).buildLiveActionAudioSegments( + [ + createShot({ + duration: new Prisma.Decimal(6), + camera_instruction: 'front close-up dialogue' + }) + ], + { include_lip_sync: false }, + false + ); + + expect(segments[0]).toEqual(expect.objectContaining({ + lip_sync_required: true, + lip_sync_strategy: 'post_tts_subtitle_light_mouth', + visual_fallback: true, + start_seconds: 2.04 + })); + }); + + it('records a manual sample review on the selected clip', async () => { + const result = await service.manualReviewVideoClip(user, '70', { + result_status: 'passed', + reason: '镜头稳定,人物一致' + }); + + expect(result.next_step).toBe('sample_passed_or_render_episode'); + expect(prisma.videoClip.update).toHaveBeenCalledWith({ + where: { id: 70n }, + data: expect.objectContaining({ + quality_status: 'passed', + quality_score: 100, + quality_issues: expect.anything() + }) + }); + expect(prisma.storyboardShot.update).toHaveBeenCalledWith({ + where: { id: 30n }, + data: { video_status: 'sample_passed' } + }); + expect(prisma.operationLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + action: 'live_action_sample_manual_review', + target_type: 'video_clip', + target_id: 70n, + metadata_json: expect.objectContaining({ + result_status: 'passed', + quality_score: 100, + reason: '镜头稳定,人物一致' + }) + }) + }); + }); + + it('records an operation log when a provider retry is requested manually', async () => { + const queuedService = new LiveActionService( + prisma as PrismaService, + storage as StorageService, + providers as ProvidersService, + aiRouter as AiRouterService, + queues + ); + const result = await queuedService.retryVideoClip(user, '70', { + provider_code: 'mock-video', + force: true, + max_cost_per_clip: '1' + }); + + expect(result.next_step).toBe('queued_video_clip_retry'); + expect(queues.createInternalTask).toHaveBeenCalledWith( + expect.objectContaining({ + taskType: 'live_action_video_clip_retry', + projectId: 10n, + episodeId: 20n, + shotId: 30n, + maxRetry: 1, + inputJson: expect.objectContaining({ + source_clip_id: '70', + requested_by_user_id: '1', + action: 'manual_provider_retry' + }) + }) + ); + expect(prisma.operationLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + action: 'router_audit_manual_provider_retry', + target_type: 'video_clip', + target_id: 70n, + metadata_json: expect.objectContaining({ + project_id: '10', + episode_id: '20', + shot_id: '30', + clip_id: '70', + task_id: '900', + provider_code: 'mock-video', + max_cost_per_clip: 1, + reason: 'manual_provider_retry_queued' + }) + }) + }); + }); + + it('queues a quality recheck task and records the task id in operation logs', async () => { + queues.createInternalTask.mockResolvedValueOnce({ + task: { + id: '901', + project_id: '10', + episode_id: '20', + shot_id: '30', + task_type: 'live_action_video_clip_quality_check', + provider_id: null, + status: 'pending', + input_json: {}, + input_hash: 'hash', + idempotency_key: 'queue-idem-quality', + output_asset_id: null, + provider_request_id: 'job-901', + retry_count: 0, + max_retry: 1, + cost_estimate: null, + cost_actual: null, + error_code: null, + error_message: null, + created_at: now.toISOString(), + started_at: null, + finished_at: null + }, + queue: { + queue_name: 'qc_queue', + queue_backend: 'bullmq', + job_id: 'job-901', + enqueued: true + } + }); + const queuedService = new LiveActionService( + prisma as PrismaService, + storage as StorageService, + providers as ProvidersService, + aiRouter as AiRouterService, + queues + ); + const result = await queuedService.checkVideoClipQuality(user, '70', { + auto_repair: true, + min_quality_score: 80 + }); + + expect(result.next_step).toBe('queued_quality_check'); + expect(queues.createInternalTask).toHaveBeenCalledWith( + expect.objectContaining({ + taskType: 'live_action_video_clip_quality_check', + inputJson: expect.objectContaining({ + source_clip_id: '70', + requested_by_user_id: '1', + action: 'quality_recheck' + }) + }) + ); + expect(prisma.operationLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + action: 'router_audit_quality_recheck', + target_type: 'video_clip', + target_id: 70n, + metadata_json: expect.objectContaining({ + task_id: '901', + auto_repair: true, + min_quality_score: 80, + reason: 'quality_recheck_queued' + }) + }) + }); + }); + + it('auto retries the same provider when quality score is below threshold and the retry passes', async () => { + prisma.videoClip.create.mockResolvedValueOnce(createClip({ id: 71n, output_asset_id: 61n, retry_count: 1 })); + prisma.videoClip.update + .mockResolvedValueOnce(createClip({ + quality_status: 'passed', + quality_score: new Prisma.Decimal(72), + quality_issues: ['blur'] + })) + .mockResolvedValueOnce(createClip({ + id: 71n, + output_asset_id: 61n, + retry_count: 1, + quality_status: 'passed', + quality_score: new Prisma.Decimal(92), + quality_issues: [] + })); + providers.executeProvider + .mockResolvedValueOnce({ + provider: { id: '200', mode: 'mock' }, + result: { result_status: 'passed', quality_score: 72, issues: ['blur'] }, + provider_log: { cost_actual: 0 } + }) + .mockResolvedValueOnce({ + provider: { id: '100', mode: 'mock' }, + result: { asset_url: 'mock://video/retry.mp4', duration: 4 }, + provider_log: { cost_actual: 0 } + }) + .mockResolvedValueOnce({ + provider: { id: '200', mode: 'mock' }, + result: { result_status: 'passed', quality_score: 92, issues: [] }, + provider_log: { cost_actual: 0 } + }); + + const result = (await service.checkVideoClipQuality(user, '70', { + auto_repair: true, + min_quality_score: 80 + })) as { repair_action: string; repair_history: Array<{ action: string }> }; + + expect(result.repair_action).toBe('passed'); + expect(result.repair_history.map((item) => item.action)).toEqual(['retry_same_provider', 'passed']); + expect(aiRouter.resolveLiveActionVideoRoute).toHaveBeenCalledWith( + expect.objectContaining({ + manual_provider_code: 'mock-video', + allow_manual_override: true + }) + ); + expect(providers.executeProvider).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + provider_type: 'VideoProvider', + preferred_provider_code: 'mock-video' + }) + ); + expect(prisma.renderTask.create).toHaveBeenLastCalledWith({ + data: expect.objectContaining({ + input_json: expect.objectContaining({ + repair_context: expect.objectContaining({ + source_clip_id: '70', + action: 'retry_same_provider', + provider_code: 'mock-video', + min_quality_score: 80 + }) + }) + }) + }); + expect(prisma.operationLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + action: 'router_audit_quality_recheck', + target_type: 'video_clip', + target_id: 70n, + metadata_json: expect.objectContaining({ + auto_repair: true, + min_quality_score: 80, + reason: 'quality_recheck_requested' + }) + }) + }); + expect(prisma.operationLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + action: 'router_audit_auto_repair_triggered', + target_type: 'video_clip', + target_id: 70n, + metadata_json: expect.objectContaining({ + action: 'retry_same_provider', + provider_code: 'mock-video', + reason: 'QUALITY_SCORE_BELOW_80', + previous_quality_score: 72, + min_quality_score: 80 + }) + }) + }); + }); + + it('switches to the next fallback provider after an already retried clip fails quality check', async () => { + prisma.videoClip.findUnique.mockResolvedValueOnce(createClip({ + provider_id: 101n, + retry_count: 1 + })); + prisma.renderTask.findFirst.mockResolvedValue(createTask({ + status: 'success', + output_asset_id: 60n, + input_json: { + provider: 'minimax_hailuo_23_fast', + router_decision: { + provider_code: 'minimax_hailuo_23_fast', + fallback_chain: ['minimax_hailuo_23_fast', 'kling_21', 'mock-video'] + } + } + })); + prisma.videoClip.create.mockResolvedValueOnce(createClip({ + id: 72n, + provider_id: 102n, + output_asset_id: 62n, + retry_count: 2 + })); + prisma.videoClip.update + .mockResolvedValueOnce(createClip({ + provider_id: 101n, + retry_count: 1, + quality_status: 'passed', + quality_score: new Prisma.Decimal(68), + quality_issues: ['face drift'] + })) + .mockResolvedValueOnce(createClip({ + id: 72n, + provider_id: 102n, + output_asset_id: 62n, + retry_count: 2, + quality_status: 'passed', + quality_score: new Prisma.Decimal(90), + quality_issues: [] + })); + providers.executeProvider + .mockResolvedValueOnce({ + provider: { id: '200', mode: 'mock' }, + result: { result_status: 'passed', quality_score: 68, issues: ['face drift'] }, + provider_log: { cost_actual: 0 } + }) + .mockResolvedValueOnce({ + provider: { id: '102', mode: 'mock' }, + result: { asset_url: 'mock://video/kling.mp4', duration: 4 }, + provider_log: { cost_actual: 0 } + }) + .mockResolvedValueOnce({ + provider: { id: '200', mode: 'mock' }, + result: { result_status: 'passed', quality_score: 90, issues: [] }, + provider_log: { cost_actual: 0 } + }); + + const result = (await service.checkVideoClipQuality(user, '70', { + auto_repair: true, + min_quality_score: 80, + confirm_real_video: true + })) as { repair_action: string; repair_history: Array<{ action: string }> }; + + expect(result.repair_action).toBe('passed'); + expect(result.repair_history.map((item) => item.action)).toEqual(['switch_provider', 'passed']); + expect(aiRouter.resolveLiveActionVideoRoute).toHaveBeenCalledWith( + expect.objectContaining({ + manual_provider_code: 'kling_21', + allow_manual_override: true + }) + ); + expect(providers.executeProvider).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + provider_type: 'VideoProvider', + preferred_provider_code: 'kling_21' + }) + ); + expect(prisma.operationLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + action: 'router_audit_auto_repair_triggered', + target_type: 'video_clip', + target_id: 70n, + metadata_json: expect.objectContaining({ + action: 'switch_provider', + provider_code: 'kling_21', + reason: 'QUALITY_RETRY_FAILED_SWITCH_PROVIDER', + previous_quality_score: 68, + min_quality_score: 80 + }) + }) + }); + }); + + it('marks rendered live action episode asset as mock when all source clips are mock', async () => { + prisma.storyboardShot.findMany.mockResolvedValueOnce([ + createShot({ video_clip_asset_id: 60n }) + ]); + prisma.asset.create.mockImplementationOnce(async ({ data }: { data: Partial }) => + createAsset({ id: 80n, ...data }) + ); + const concatSpy = vi.spyOn(service as any, 'concatVideoClips').mockResolvedValue({ + buffer: Buffer.from('rendered-video'), + normalization: [ + { + shot_id: '30', + shot_no: 1, + asset_id: '60', + target_duration: 4, + source_duration: 4, + final_duration: 4, + trimmed: false, + trim_strategy: 'none', + trim_start: 0, + trim_tolerance: 0.3 + } + ] + }); + + const result = await service.renderLiveActionEpisode(user, '20', { + force: true, + include_audio: false, + include_subtitle: false, + include_bgm: false + }); + + expect(result.asset.status).toBe('mock'); + expect(concatSpy).toHaveBeenCalledWith( + [expect.objectContaining({ video_clip_asset_id: 60n })], + expect.objectContaining({ + include_audio: false, + include_subtitle: false, + include_bgm: false, + audio_asset: null, + subtitle_asset: null, + bgm_asset: null + }) + ); + expect(storage.storePrivateFile).toHaveBeenCalledWith( + expect.objectContaining({ + originalname: 'episode-20-live-action-mock.mp4', + mimetype: 'video/mp4' + }), + 'rendered-videos' + ); + expect(prisma.asset.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + asset_type: 'video', + status: 'mock' + }) + }); + expect(prisma.renderTask.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + input_json: expect.objectContaining({ + rendered_asset_status: 'mock', + rendered_asset_mode: 'mock' + }) + }) + }); + expect(prisma.renderTask.update).toHaveBeenCalledWith({ + where: { id: 50n }, + data: expect.objectContaining({ + status: 'success', + input_json: expect.objectContaining({ + clip_normalization: [ + expect.objectContaining({ + shot_id: '30', + target_duration: 4, + trimmed: false, + trim_strategy: 'none' + }) + ] + }) + }) + }); + }); + + it('marks rendered live action episode asset as active when any source clip is active', async () => { + prisma.storyboardShot.findMany.mockResolvedValueOnce([ + createShot({ video_clip_asset_id: 60n }), + createShot({ id: 31n, shot_no: 2, video_clip_asset_id: 61n }) + ]); + prisma.asset.findUnique.mockImplementation(async ({ where }: { where: { id: bigint } }) => { + if (where.id === 61n) { + return createAsset({ + id: 61n, + status: 'active', + file_path: 'local://live-action-video-clips/hailuo-real.mp4' + }); + } + + return createAsset({ id: where.id }); + }); + prisma.asset.create.mockImplementationOnce(async ({ data }: { data: Partial }) => + createAsset({ id: 81n, ...data }) + ); + vi.spyOn(service as any, 'concatVideoClips').mockResolvedValue({ + buffer: Buffer.from('rendered-video'), + normalization: [ + { + shot_id: '30', + shot_no: 1, + asset_id: '60', + target_duration: 4, + source_duration: 5.875, + final_duration: 4, + trimmed: true, + trim_strategy: 'center', + trim_start: 0.938, + trim_tolerance: 0.3 + } + ] + }); + + const result = await service.renderLiveActionEpisode(user, '20', { + force: true, + include_audio: false, + include_subtitle: false, + include_bgm: false + }); + + expect(result.asset.status).toBe('active'); + expect(storage.storePrivateFile).toHaveBeenCalledWith( + expect.objectContaining({ + originalname: 'episode-20-live-action-real.mp4', + mimetype: 'video/mp4' + }), + 'rendered-videos' + ); + expect(prisma.asset.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + asset_type: 'video', + status: 'active' + }) + }); + expect(prisma.renderTask.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + input_json: expect.objectContaining({ + rendered_asset_status: 'active', + rendered_asset_mode: 'real' + }) + }) + }); + expect(prisma.renderTask.update).toHaveBeenCalledWith({ + where: { id: 50n }, + data: expect.objectContaining({ + status: 'success', + input_json: expect.objectContaining({ + clip_normalization: [ + expect.objectContaining({ + source_duration: 5.875, + final_duration: 4, + trimmed: true, + trim_strategy: 'center', + trim_start: 0.938 + }) + ] + }) + }) + }); + }); +}); diff --git a/backend/src/live-action/live-action.service.ts b/backend/src/live-action/live-action.service.ts new file mode 100644 index 0000000..ac3a646 --- /dev/null +++ b/backend/src/live-action/live-action.service.ts @@ -0,0 +1,6852 @@ +import { + BadRequestException, + ForbiddenException, + forwardRef, + Inject, + Injectable, + NotFoundException, + Optional +} from '@nestjs/common'; +import type { + ActorProfile, + Asset, + Character, + Episode, + Prisma, + ProviderConfig, + Project, + RenderTask, + StoryboardShot, + VideoClip +} from '@prisma/client'; +import { Prisma as PrismaNamespace } from '@prisma/client'; +import { createHash } from 'node:crypto'; +import { execFile } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import { AiRouterService } from '../ai-router/ai-router.service'; +import type { AiRouteDecision, AiRouterShotScores } from '../ai-router/ai-router.types'; +import type { AuthRequestUser } from '../auth/auth.types'; +import { StorageService } from '../assets/storage.service'; +import { toSafeAsset } from '../assets/asset.types'; +import { PrismaService } from '../prisma/prisma.service'; +import { ProvidersService } from '../providers/providers.service'; +import { QueuesService } from '../queues/queues.service'; +import { toSafeRenderTask } from '../queues/task.types'; +import { LiveActionPromptBuilderService } from './prompt-builder.service'; +import type { + LiveActionAttachKeyframeDto, + LiveActionGenerateDto, + LiveActionManualReviewDto, + LiveActionPreflightQueryDto, + LiveActionQualityCheckDto +} from './live-action.dto'; +import { + toSafeActorProfile, + toSafeLiveActionShot, + toSafeVideoClip +} from './live-action.types'; + +const execFileAsync = promisify(execFile); +const LIVE_ACTION_WIDTH = 1080; +const LIVE_ACTION_HEIGHT = 1920; +const LIVE_ACTION_MAX_SHOT_SECONDS = 60; +const LIVE_ACTION_MAX_PROVIDER_CLIP_SECONDS = 10; +const LIVE_ACTION_RENDER_FPS = 30; +const LIVE_ACTION_RENDER_TRIM_TOLERANCE_SECONDS = 0.3; +const LIVE_ACTION_DEFAULT_MIN_QUALITY_SCORE = 80; +const LIVE_ACTION_MAX_AUTO_REPAIR_DEPTH = 2; +const LIVE_ACTION_DEFAULT_BGM_VOLUME = 0.08; +const LIVE_ACTION_DEFAULT_SFX_VOLUME = 0.45; +const LIVE_ACTION_DEFAULT_VOICE_STYLE = '中文真人短剧对白,语速自然,情绪克制,清晰可懂。'; +const LIVE_ACTION_NARRATION_VOICE = 'Chinese (Mandarin)_News_Anchor'; +const LIVE_ACTION_DIALOGUE_GAP_SECONDS = 0.12; +const LIVE_ACTION_DEFAULT_LIP_SYNC_MAX_SECONDS = 18; +const LIVE_ACTION_HARD_LIP_SYNC_MAX_SECONDS = 120; +const LIVE_ACTION_VIDEO_POLISH_VERSION = 'live-action-video-polish-v1'; +const LIVE_ACTION_SYSTEM_BGM_SOURCE = 'system_cinematic_bed_v1'; +const LIVE_ACTION_SYSTEM_SFX_SOURCE = 'system_scene_sfx_v1'; +const LIVE_ACTION_DIRECTOR_PLAN_VERSION = 'live-action-director-plan-v1'; +const LIVE_ACTION_DIRECTOR_MIN_SHOT_SECONDS = 2.5; +const LIVE_ACTION_DIRECTOR_MAX_SHOT_SECONDS = 10; + +type LiveActionRepairAction = 'passed' | 'needs_retry' | 'retry_same_provider' | 'switch_provider' | 'manual_required'; +type LiveActionTrimStrategy = 'none' | 'center' | 'head'; +type LiveActionJsonRecord = Record; +type LiveActionClipNormalizationReport = { + shot_id: string; + shot_no: number; + asset_id: string; + target_duration: number; + source_duration: number | null; + final_duration: number | null; + trimmed: boolean; + trim_strategy: LiveActionTrimStrategy; + trim_start: number; + trim_tolerance: number; +}; +type LiveActionClipNormalizationResult = { + path: string; + report: LiveActionClipNormalizationReport; +}; +type LiveActionProviderClipSegment = { + index: number; + duration: number; + source_strategy: 'shot_keyframe' | 'previous_segment_end_frame'; + source_keyframe_asset_id: string | null; + beat_label: string | null; + beat_prompt: string | null; +}; +type LiveActionActorLockContext = { + character_ids: string[]; + character_names: string[]; + actor_hints: string | null; + anchor_asset_ids: string[]; + reference_asset_ids: string[]; + reference_image_data_uris: string[]; + provider_character_reference_enabled: boolean; + audit: { + lock_version: string; + character_count: number; + character_ids: string[]; + character_names: string[]; + anchor_asset_ids: string[]; + reference_asset_ids: string[]; + provider_character_reference_enabled: boolean; + missing_actor_profile_character_ids: string[]; + missing_anchor_character_ids: string[]; + }; +}; +type LiveActionAudioSegment = { + index: number; + shot_id: string; + shot_no: number; + segment_type: 'dialogue' | 'narration'; + start_seconds: number; + end_seconds: number; + target_duration: number; + speaker_name: string; + text: string; + voice_provider_code: string | null; + voice: string | null; + voice_style: string | null; + character_id: string | null; + lip_sync_required: boolean; + lip_sync_strategy: LiveActionLipSyncStrategy; + visual_fallback: boolean; + lip_sync_skip_reason: string | null; +}; +type LiveActionDialoguePart = { + speaker_name: string; + text: string; +}; +type LiveActionSpeakerVoice = { + character: Character | null; + voice_provider_code: string | null; + voice: string | null; + voice_style: string | null; +}; +type LiveActionAudioSegmentFile = { + index: number; + segment: LiveActionAudioSegment; + buffer: Buffer; + mimeType: string; + duration: number; + isMock: boolean; + provider_code: string | null; + cost_actual: number; + asset_url: string | null; +}; +type LiveActionLipSyncClip = { + shot_id: string; + shot_no: number; + source_asset_id: string; + output_asset_id: string; + task_id: string; + provider_code: string; + provider_mode: string; + cost_actual: number; + lip_sync_strategy: LiveActionLipSyncStrategy; +}; +type LiveActionLipSyncBudgetDecision = { + selected: boolean; + reason: string; + max_seconds: number; + estimated_seconds: number; + priority_score: number; + selected_seconds_before: number; + selected_seconds_after: number; +}; +type LiveActionLipSyncBudgetSkip = { + shot_id: string; + shot_no: number; + estimated_seconds: number; + priority_score: number; + reason: string; +}; +type LiveActionLipSyncBudgetPlan = { + max_seconds: number; + required_count: number; + selected_count: number; + required_seconds: number; + selected_seconds: number; + skipped: LiveActionLipSyncBudgetSkip[]; + decisions: Map; +}; +type LiveActionAudioWarning = { + index: number; + shot_no: number; + text_preview: string; + target_duration: number; + actual_duration: number; + over_seconds: number; +}; +type LiveActionSfxCueType = 'rain' | 'footstep' | 'door' | 'heartbeat' | 'sting' | 'wind' | 'debris' | 'electric' | 'impact'; +type LiveActionSfxCue = { + index: number; + shot_id: string; + shot_no: number; + cue_type: LiveActionSfxCueType; + start_seconds: number; + end_seconds: number; + intensity: number; + reason: string; +}; +type LiveActionBgmCueType = 'urban_drama' | 'urban_climax' | 'suspense_tension' | 'xianxia_tension' | 'xianxia_build_up' | 'xianxia_epic'; +type LiveActionBgmCue = { + index: number; + shot_id: string; + shot_no: number; + cue_type: LiveActionBgmCueType; + start_seconds: number; + end_seconds: number; + intensity: number; + reason: string; +}; +type LiveActionDirectorShotRole = 'establishing' | 'movement' | 'dialogue' | 'reaction' | 'insert' | 'reveal'; +type LiveActionDirectorPlan = { + plan_version: string; + scene_group_id: string; + scene_beat: string; + shot_role: LiveActionDirectorShotRole; + shot_size: string; + blocking: string; + continuity_in: string; + continuity_out: string; + edit_intent: string; + sound_bridge: string; + duration_seconds: number; +}; +type LiveActionPostProductionAssets = { + include_audio: boolean; + include_subtitle: boolean; + include_bgm: boolean; + include_sfx: boolean; + audio_asset: Asset | null; + subtitle_asset: Asset | null; + bgm_asset: Asset | null; + sfx_asset: Asset | null; + audio_task: RenderTask | null; + subtitle_task: RenderTask | null; + bgm_task: RenderTask | null; + sfx_task: RenderTask | null; + segments: LiveActionAudioSegment[]; + subtitle_cues: LiveActionSubtitleCue[]; + bgm_cues: LiveActionBgmCue[]; + sfx_cues: LiveActionSfxCue[]; + audio_warnings: LiveActionAudioWarning[]; + audio_is_mock: boolean; + audio_provider_codes: string[]; + bgm_volume: number; + sfx_volume: number; + lip_sync_clips: LiveActionLipSyncClip[]; + lip_sync_budget: Omit; +}; +type LiveActionLipSyncStrategy = 'not_required' | 'provider_lipsync' | 'post_tts_subtitle_light_mouth'; +type LiveActionLipSyncPolicy = { + lip_sync_required: boolean; + high_risk_dialogue: boolean; + has_dialogue: boolean; + provider_available: boolean; + strategy: LiveActionLipSyncStrategy; + reason: string; + visual_fallback: boolean; + lip_sync_skip_reason: string | null; +}; +type LiveActionSubtitleCue = { + index: number; + start_seconds: number; + end_seconds: number; + text: string; +}; +type LiveActionRepairHistoryItem = { + action: LiveActionRepairAction; + clip_id: string; + provider_code?: string | null; + reason?: string; +}; +type LiveActionRepairPlan = { + action: Extract; + provider_code: string | null; + fallback_chain: string[]; + reason: string; +}; +type LiveActionRouteContext = { + provider_code: string | null; + fallback_chain: string[]; + repair_context: LiveActionJsonRecord; +}; +type LiveActionGenerateOptions = { + allowSystemProviderOverride?: boolean; + repairContext?: LiveActionJsonRecord; + candidateIndex?: number; + candidateCount?: number; + autoSelectClip?: boolean; +}; +type LiveActionQualityContext = { + taskId?: string; +}; +type LiveActionPreflightIssue = { + code: string; + message: string; + severity: 'blocker' | 'warning'; + shot_id?: string; + shot_no?: number | null; +}; +type LiveActionPreflightShot = { + shot_id: string; + shot_no: number; + scene_name: string | null; + duration: number; + provider_clip_count: number; + provider_clip_durations: number[]; + provider_code: string; + provider_mode: string; + route_tier: string | null; + decision_reason: string; + estimated_cost: number; + keyframe_asset_id: string | null; + keyframe_mime_type: string | null; + keyframe_status: string | null; + keyframe_ready: boolean; + source_image_required: boolean; + source_image_ready: boolean; + issues: LiveActionPreflightIssue[]; +}; + +@Injectable() +export class LiveActionService { + private readonly fallbackPromptBuilder = new LiveActionPromptBuilderService(); + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(StorageService) private readonly storage: StorageService, + @Inject(ProvidersService) private readonly providersService: ProvidersService, + @Inject(AiRouterService) private readonly aiRouter: AiRouterService, + @Optional() @Inject(forwardRef(() => QueuesService)) private readonly queues?: QueuesService, + @Optional() @Inject(LiveActionPromptBuilderService) private readonly promptBuilder?: LiveActionPromptBuilderService + ) {} + + async listActorProfiles(user: AuthRequestUser, projectId: string) { + const project = await this.findProjectForUser(projectId, user); + const profiles = await this.prisma.actorProfile.findMany({ + where: { project_id: project.id }, + orderBy: { created_at: 'asc' } + }); + + return profiles.map(toSafeActorProfile); + } + + async generateActorProfiles( + user: AuthRequestUser, + projectId: string, + dto: LiveActionGenerateDto + ) { + const project = await this.findProjectForUser(projectId, user); + this.assertLiveActionProject(project); + const characters = await this.prisma.character.findMany({ + where: { project_id: project.id, status: { not: 'deleted' } }, + orderBy: [{ importance_level: 'desc' }, { id: 'asc' }] + }); + + if (characters.length === 0) { + throw new BadRequestException('Characters are required before actor profile generation'); + } + + const profiles = []; + + for (const character of characters) { + const existing = await this.prisma.actorProfile.findUnique({ + where: { + project_id_character_id: { + project_id: project.id, + character_id: character.id + } + } + }); + + if (existing && !dto.force) { + profiles.push(existing); + continue; + } + + const draft = this.buildActorProfileDraft(character); + const profile = await this.prisma.actorProfile.upsert({ + where: { + project_id_character_id: { + project_id: project.id, + character_id: character.id + } + }, + update: draft, + create: { + project_id: project.id, + character_id: character.id, + ...draft + } + }); + profiles.push(profile); + } + + await this.prisma.project.update({ + where: { id: project.id }, + data: { status: 'actor_profile_generated' } + }); + + return { + actor_profiles: profiles.map(toSafeActorProfile), + next_step: 'live_action_shots_prepare' + }; + } + + async listLiveActionShots(user: AuthRequestUser, episodeId: string) { + const { episode } = await this.findEpisodeForUser(episodeId, user); + const shots = await this.prisma.storyboardShot.findMany({ + where: { episode_id: episode.id }, + orderBy: { shot_no: 'asc' } + }); + + return shots.map(toSafeLiveActionShot); + } + + async prepareLiveActionShots( + user: AuthRequestUser, + episodeId: string, + dto: LiveActionGenerateDto + ) { + const { episode, project } = await this.findEpisodeForUser(episodeId, user); + this.assertLiveActionProject(project); + const shots = await this.loadStoryboardShots(episode.id); + + if (shots.length === 0) { + throw new BadRequestException('Confirmed storyboard shots are required before live action rewrite'); + } + + const actorProfiles = await this.prisma.actorProfile.findMany({ + where: { project_id: project.id, status: { not: 'deleted' } }, + orderBy: { created_at: 'asc' } + }); + const actorsByCharacterId = new Map( + actorProfiles.map((profile) => [profile.character_id.toString(), profile]) + ); + const updated = []; + const lipSyncProviderAvailable = await this.hasLiveActionLipSyncProvider(); + const directorPlans = this.buildLiveActionDirectorPlans(shots, episode); + + for (const shot of shots) { + const scores = this.aiRouter.scoreLiveActionShot(shot); + const lipSyncPolicy = this.resolveLiveActionLipSyncPolicy(shot, lipSyncProviderAvailable); + const directorPlan = directorPlans.get(shot.id.toString()) ?? + this.buildFallbackLiveActionDirectorPlan(shot, this.normalizeShotDuration(shot)); + + if (shot.video_prompt && !dto.force) { + updated.push(await this.ensureShotRouteScoresAndLipSyncPolicy(shot, scores, lipSyncPolicy)); + continue; + } + + const characterNames = await this.characterNamesForShot(project.id, shot); + const duration = directorPlan.duration_seconds; + const draft = this.buildLiveActionShotDraft( + project, + episode, + shot, + characterNames, + actorsByCharacterId, + duration, + scores, + lipSyncPolicy, + directorPlan + ); + const saved = await this.prisma.storyboardShot.update({ + where: { id: shot.id }, + data: draft + }); + updated.push(saved); + } + + await this.prisma.project.update({ + where: { id: project.id }, + data: { status: 'live_action_shots_prepared' } + }); + + return { + shots: updated.map(toSafeLiveActionShot), + next_step: 'live_action_keyframes_generate' + }; + } + + async generateKeyframes( + user: AuthRequestUser, + episodeId: string, + dto: LiveActionGenerateDto + ) { + const { episode, project } = await this.findEpisodeForUser(episodeId, user); + this.assertLiveActionProject(project); + const shots = await this.loadPreparedLiveActionShots(episode.id); + const results = []; + + for (const shot of shots) { + if (shot.keyframe_asset_id && !dto.force) { + results.push(await this.prisma.asset.findUnique({ where: { id: shot.keyframe_asset_id } })); + continue; + } + + const prompt = shot.video_prompt ?? this.defaultVideoPrompt(project, episode, shot); + const task = await this.createRenderTask(project.id, episode.id, shot.id, 'live_action_keyframe_generate', { + shot_id: shot.id.toString(), + prompt, + width: LIVE_ACTION_WIDTH, + height: LIVE_ACTION_HEIGHT, + provider: 'mock-image' + }); + + const providerResult = await this.providersService.executeProvider({ + provider_type: 'ImageProvider', + preferred_provider_code: 'mock-image', + purpose: `live-action-keyframe-${shot.id.toString()}`, + project_id: project.id.toString(), + task_id: task.id.toString(), + allow_fallback: false, + return_binary: true, + input_json: { + prompt, + width: LIVE_ACTION_WIDTH, + height: LIVE_ACTION_HEIGHT, + style: 'photorealistic_short_drama_keyframe' + } + }); + const asset = await this.storeMockKeyframe(project, shot, prompt, providerResult.provider.mode === 'mock'); + + await this.prisma.renderTask.update({ + where: { id: task.id }, + data: { + status: 'success', + output_asset_id: asset.id, + finished_at: new Date() + } + }); + await this.prisma.storyboardShot.update({ + where: { id: shot.id }, + data: { + keyframe_asset_id: asset.id, + video_status: 'keyframe_generated' + } + }); + results.push(asset); + } + + await this.prisma.project.update({ + where: { id: project.id }, + data: { status: 'live_action_keyframes_generated' } + }); + + return { + assets: results.filter(Boolean).map((asset) => toSafeAsset(asset as Asset)), + next_step: 'live_action_video_clips_generate' + }; + } + + async listVideoClips(user: AuthRequestUser, episodeId: string) { + const { episode } = await this.findEpisodeForUser(episodeId, user); + const clips = await this.prisma.videoClip.findMany({ + where: { episode_id: episode.id }, + orderBy: { created_at: 'asc' } + }); + + return clips.map(toSafeVideoClip); + } + + async listVideoProviders(user: AuthRequestUser) { + if (!user.id) { + throw new ForbiddenException('Login required'); + } + + await this.ensureMockVideoProvider(); + const providers = await this.prisma.providerConfig.findMany({ + where: { + provider_type: 'VideoProvider', + is_enabled: true + }, + orderBy: [ + { priority: 'desc' }, + { id: 'asc' } + ] + }); + + return providers.map((provider) => { + const costRule = this.jsonObject(provider.cost_rule_json); + + return { + provider_code: provider.provider_code, + display_name: provider.display_name, + mode: provider.mode, + model_name: provider.model_name, + is_enabled: provider.is_enabled, + currency: this.stringifyText(costRule.currency) || 'USD', + price_per_second: this.numberFromJson(costRule.price_per_second), + price_per_clip: this.numberFromJson(costRule.price_per_clip), + max_cost_per_call: this.numberFromJson(costRule.max_cost_per_call), + daily_cost_limit: this.numberFromJson(costRule.daily_cost_limit) + }; + }); + } + + async estimateVideoClipCost( + user: AuthRequestUser, + episodeId: string, + providerCode?: string + ) { + const { episode, project } = await this.findEpisodeForUser(episodeId, user); + this.assertLiveActionProject(project); + const shots = await this.loadStoryboardShots(episode.id); + await this.ensureMockVideoProvider(); + const provider = await this.findVideoProviderForEstimate(providerCode); + const costRule = this.jsonObject(provider?.cost_rule_json ?? null); + const currency = this.stringifyText(costRule.currency) || 'USD'; + const flatCost = this.numberFromJson(costRule.flat_cost); + const pricePerSecond = this.numberFromJson(costRule.price_per_second); + const pricePerClip = this.numberFromJson(costRule.price_per_clip); + + if (!this.normalizeOptionalText(providerCode, 100)) { + await this.ensureMockVideoProvider(); + const routedBreakdown = []; + + for (const shot of shots) { + const duration = this.normalizeShotDuration(shot); + const decision = await this.aiRouter.resolveLiveActionVideoRoute({ + project, + shot, + duration + }); + const providerClipDurations = this.splitProviderClipDurations(duration); + + routedBreakdown.push({ + shot_id: shot.id.toString(), + shot_no: shot.shot_no, + duration, + provider_clip_count: providerClipDurations.length, + provider_clip_durations: providerClipDurations, + provider_code: decision.provider_code, + provider_mode: decision.provider_mode, + route_tier: decision.route_tier, + decision_reason: decision.decision_reason, + fallback_chain: decision.fallback_chain, + scores: decision.scores, + estimated_cost: decision.estimated_cost + }); + } + + const routedTotalCost = routedBreakdown.reduce((sum, row) => sum + row.estimated_cost, 0); + const routedTotalSeconds = routedBreakdown.reduce((sum, row) => sum + row.duration, 0); + + return { + provider_code: 'auto-router', + provider_name: 'AI Router V1', + provider_mode: 'router', + provider_enabled: true, + currency: 'USD', + clip_count: routedBreakdown.length, + total_seconds: routedTotalSeconds, + estimated_cost: Number(routedTotalCost.toFixed(4)), + max_cost_per_call: null, + daily_cost_limit: null, + breakdown: routedBreakdown + }; + } + + const breakdown = shots.map((shot) => { + const duration = this.normalizeShotDuration(shot); + const providerClipDurations = this.splitProviderClipDurations(duration); + const estimatedCost = providerClipDurations.reduce( + (sum, clipDuration) => sum + flatCost + pricePerClip + clipDuration * pricePerSecond, + 0 + ); + + return { + shot_id: shot.id.toString(), + shot_no: shot.shot_no, + duration, + provider_clip_count: providerClipDurations.length, + provider_clip_durations: providerClipDurations, + estimated_cost: Number(estimatedCost.toFixed(4)) + }; + }); + const totalCost = breakdown.reduce((sum, row) => sum + row.estimated_cost, 0); + const totalSeconds = breakdown.reduce((sum, row) => sum + row.duration, 0); + + return { + provider_code: provider?.provider_code ?? 'mock-video', + provider_name: provider?.display_name ?? 'Mock Video Provider', + provider_mode: provider?.mode ?? 'mock', + provider_enabled: provider?.is_enabled ?? true, + currency, + clip_count: breakdown.length, + total_seconds: totalSeconds, + estimated_cost: Number(totalCost.toFixed(4)), + max_cost_per_call: this.numberFromJson(costRule.max_cost_per_call), + daily_cost_limit: this.numberFromJson(costRule.daily_cost_limit), + breakdown + }; + } + + async preflightVideoClips( + user: AuthRequestUser, + episodeId: string, + query: LiveActionPreflightQueryDto = {} + ) { + const { episode, project } = await this.findEpisodeForUser(episodeId, user); + this.assertLiveActionProject(project); + await this.ensureMockVideoProvider(); + const shotId = this.normalizeOptionalText(query.shot_id, 40); + const allShots = await this.loadStoryboardShots(episode.id); + const shots = shotId ? allShots.filter((shot) => shot.id.toString() === shotId) : allShots; + const requestedProviderCode = this.normalizeOptionalText(query.provider_code, 100); + const maxCost = this.optionalNumberFromJson(query.max_cost_per_clip); + const confirmed = this.booleanFromJson(query.confirm_real_video) === true; + const issues: LiveActionPreflightIssue[] = []; + const breakdown: LiveActionPreflightShot[] = []; + + if (shots.length === 0) { + issues.push({ + code: 'STORYBOARD_SHOTS_REQUIRED', + message: '需要先确认分镜镜头。', + severity: 'blocker' + }); + } + if (shotId && allShots.length > 0 && shots.length === 0) { + issues.push({ + code: 'LIVE_ACTION_SAMPLE_SHOT_NOT_FOUND', + message: '未找到要测试的真人镜头。', + severity: 'blocker' + }); + } + if (requestedProviderCode && user.role !== 'admin') { + issues.push({ + code: 'PROVIDER_OVERRIDE_ADMIN_ONLY', + message: '普通用户选择 Provider 仅用于预估,最终生成仍由 Router 自动决策。', + severity: 'warning' + }); + } + + for (const shot of shots) { + const duration = this.normalizeShotDuration(shot); + const shotIssues: LiveActionPreflightIssue[] = []; + const routeDecision = await this.resolveVideoRoute( + project, + shot, + duration, + { + provider_code: requestedProviderCode, + confirm_real_video: confirmed, + max_cost_per_clip: query.max_cost_per_clip + }, + user + ); + const providerConfig = await this.findVideoProviderConfig(routeDecision.provider_code); + const providerClipSegments = providerConfig + ? this.buildLiveActionProviderClipSegments( + shot, + duration, + { + provider_code: requestedProviderCode, + confirm_real_video: confirmed, + max_cost_per_clip: query.max_cost_per_clip, + action_beat_mode: query.action_beat_mode, + action_beat_count: query.action_beat_count + }, + providerConfig + ) + : this.splitProviderClipDurations(duration).map((segmentDuration, index) => ({ + index: index + 1, + duration: segmentDuration, + source_strategy: index === 0 ? 'shot_keyframe' : 'previous_segment_end_frame', + source_keyframe_asset_id: index === 0 ? shot.keyframe_asset_id?.toString() ?? null : null, + beat_label: null, + beat_prompt: null + } satisfies LiveActionProviderClipSegment)); + const providerClipDurations = providerClipSegments.map((segment) => segment.duration); + const estimatedCost = providerConfig + ? Number(providerClipSegments + .reduce((sum, segment) => sum + this.estimateSingleClipCost(providerConfig.cost_rule_json, segment.duration), 0) + .toFixed(4)) + : routeDecision.estimated_cost; + const providerMode = providerConfig?.mode ?? routeDecision.provider_mode; + const sourceImageRequired = this.videoProviderRequiresRasterSource(routeDecision.provider_code, providerMode); + const keyframeAsset = shot.keyframe_asset_id + ? await this.prisma.asset.findUnique({ where: { id: shot.keyframe_asset_id } }) + : null; + const keyframeMimeType = keyframeAsset?.mime_type ?? null; + const sourceImageReady = Boolean(keyframeAsset && this.normalizeImageMimeType(keyframeMimeType)); + + if (!shot.video_prompt) { + shotIssues.push(this.createPreflightIssue('PREPARED_LIVE_ACTION_SHOT_REQUIRED', '需要先执行真人分镜。', shot)); + } + if (!shot.keyframe_asset_id || !keyframeAsset) { + shotIssues.push(this.createPreflightIssue('LIVE_ACTION_KEYFRAME_REQUIRED', '需要先生成关键帧。', shot)); + } + if (sourceImageRequired && keyframeAsset && !sourceImageReady) { + shotIssues.push(this.createPreflightIssue('LIVE_ACTION_KEYFRAME_RASTER_REQUIRED', '真实视频 Provider 需要 PNG/JPG/WebP 关键帧。', shot)); + } + if (!providerConfig) { + shotIssues.push(this.createPreflightIssue('LIVE_ACTION_VIDEO_PROVIDER_NOT_FOUND', '视频 Provider 不存在。', shot)); + } else if (!providerConfig.is_enabled) { + shotIssues.push(this.createPreflightIssue('LIVE_ACTION_VIDEO_PROVIDER_DISABLED', '视频 Provider 未启用。', shot)); + } + if (this.videoProviderRequiresConfirmation(routeDecision.provider_code, providerMode) && !confirmed) { + shotIssues.push(this.createPreflightIssue('REAL_VIDEO_GENERATION_CONFIRMATION_REQUIRED', '真实视频生成需要先勾选费用确认。', shot)); + } + if (maxCost !== null && maxCost > 0 && estimatedCost > maxCost) { + shotIssues.push(this.createPreflightIssue('LIVE_ACTION_VIDEO_COST_LIMIT_EXCEEDED', '预估费用超过单片段上限。', shot)); + } + if (providerClipDurations.length > 1) { + shotIssues.push({ + ...this.createPreflightIssue( + 'LIVE_ACTION_SHOT_WILL_BE_SPLIT', + this.booleanFromJson(query.action_beat_mode) + ? `动作节拍模式会拆成 ${providerClipDurations.length} 个连续子片段,并使用上一段尾帧承接下一段。` + : `该镜头会拆成 ${providerClipDurations.length} 个 5-10 秒子片段。`, + shot + ), + severity: 'warning' + }); + } + + issues.push(...shotIssues); + breakdown.push({ + shot_id: shot.id.toString(), + shot_no: shot.shot_no, + scene_name: shot.scene_name, + duration, + provider_clip_count: providerClipDurations.length, + provider_clip_durations: providerClipDurations, + provider_code: routeDecision.provider_code, + provider_mode: providerMode ?? 'unknown', + route_tier: routeDecision.route_tier, + decision_reason: routeDecision.decision_reason, + estimated_cost: estimatedCost, + keyframe_asset_id: shot.keyframe_asset_id?.toString() ?? null, + keyframe_mime_type: keyframeMimeType, + keyframe_status: keyframeAsset?.status ?? null, + keyframe_ready: Boolean(shot.keyframe_asset_id && keyframeAsset), + source_image_required: sourceImageRequired, + source_image_ready: sourceImageRequired ? sourceImageReady : Boolean(keyframeAsset), + issues: shotIssues + }); + } + + const blockers = issues.filter((issue) => issue.severity === 'blocker'); + const warnings = issues.filter((issue) => issue.severity === 'warning'); + const totalSeconds = breakdown.reduce((sum, item) => sum + item.duration, 0); + const providerClipCount = breakdown.reduce((sum, item) => sum + item.provider_clip_count, 0); + const estimatedCost = breakdown.reduce((sum, item) => sum + item.estimated_cost, 0); + const requiresRealConfirmation = breakdown.some((item) => + this.videoProviderRequiresConfirmation(item.provider_code, item.provider_mode) + ); + + return { + ready: blockers.length === 0, + next_step: this.liveActionPreflightNextStep(blockers), + shot_id: shotId ?? null, + requested_provider_code: requestedProviderCode ?? null, + manual_override_allowed: Boolean(requestedProviderCode && user.role === 'admin'), + confirm_real_video: confirmed, + requires_real_video_confirmation: requiresRealConfirmation, + max_cost_per_clip: maxCost, + summary: { + shot_count: shots.length, + prepared_shot_count: shots.filter((shot) => Boolean(shot.video_prompt)).length, + keyframe_count: shots.filter((shot) => Boolean(shot.keyframe_asset_id)).length, + raster_keyframe_count: breakdown.filter((item) => item.source_image_ready).length, + provider_clip_count: providerClipCount, + total_seconds: Number(totalSeconds.toFixed(2)), + estimated_cost: Number(estimatedCost.toFixed(4)), + currency: 'USD' + }, + blockers, + warnings, + breakdown + }; + } + + async generateVideoClips( + user: AuthRequestUser, + episodeId: string, + dto: LiveActionGenerateDto + ) { + const { episode, project } = await this.findEpisodeForUser(episodeId, user); + this.assertLiveActionProject(project); + await this.assertVideoProviderCanRun(dto); + const shots = await this.loadPreparedLiveActionShots(episode.id); + const clips: VideoClip[] = []; + + for (const shot of shots) { + if (!shot.keyframe_asset_id) { + throw new BadRequestException('Live action keyframes are required before video clip generation'); + } + + const existing = await this.findReusableLiveActionVideoClip(shot); + + if (existing && !dto.force) { + clips.push(existing); + continue; + } + + clips.push(await this.generateSingleVideoClip(project, episode, shot, dto, existing, user)); + } + + await this.prisma.project.update({ + where: { id: project.id }, + data: { status: 'live_action_clips_generated' } + }); + + return { + video_clips: clips.map(toSafeVideoClip), + next_step: 'live_action_render' + }; + } + + async attachShotKeyframe( + user: AuthRequestUser, + episodeId: string, + shotId: string, + dto: LiveActionAttachKeyframeDto + ) { + const { project } = await this.findEpisodeForUser(episodeId, user); + this.assertLiveActionProject(project); + const shot = await this.findShotForEpisodeOrThrow(episodeId, shotId); + const assetId = this.normalizeOptionalText(dto.asset_id, 40); + + if (!assetId) { + throw new BadRequestException('asset_id is required'); + } + + const asset = await this.prisma.asset.findUnique({ + where: { id: this.parseId(assetId, 'Invalid asset id') } + }); + + if (!asset) { + throw new NotFoundException('Asset not found'); + } + if (asset.user_id?.toString() !== user.id && user.role !== 'admin') { + throw new NotFoundException('Asset not found'); + } + if (asset.project_id && asset.project_id !== project.id) { + throw new BadRequestException('Asset belongs to another project'); + } + if (asset.asset_type !== 'image' || !this.normalizeImageMimeType(asset.mime_type)) { + throw new BadRequestException('LIVE_ACTION_KEYFRAME_RASTER_REQUIRED'); + } + + const updated = await this.prisma.storyboardShot.update({ + where: { id: shot.id }, + data: { + keyframe_asset_id: asset.id, + video_clip_asset_id: null, + video_status: 'sample_keyframe_uploaded' + } + }); + + return { + shot: toSafeLiveActionShot(updated), + keyframe: toSafeAsset(asset), + next_step: 'sample_preflight' + }; + } + + async generateShotVideoClip( + user: AuthRequestUser, + episodeId: string, + shotId: string, + dto: LiveActionGenerateDto + ) { + const { episode, project } = await this.findEpisodeForUser(episodeId, user); + this.assertLiveActionProject(project); + await this.assertVideoProviderCanRun(dto); + const shot = await this.findShotForEpisodeOrThrow(episodeId, shotId); + + if (!shot.video_prompt) { + throw new BadRequestException('Prepared live action shots are required first'); + } + if (!shot.keyframe_asset_id) { + throw new BadRequestException('Live action keyframes are required before video clip generation'); + } + + const candidateCount = this.normalizePositiveInt(dto.candidate_count, 'candidate_count', 1, 3, 1); + const preflight = await this.preflightVideoClips(user, episodeId, { + provider_code: dto.provider_code, + confirm_real_video: dto.confirm_real_video, + max_cost_per_clip: dto.max_cost_per_clip, + shot_id: shotId, + action_beat_mode: dto.action_beat_mode, + action_beat_count: dto.action_beat_count + }); + const firstBlocker = preflight.blockers[0]; + + if (firstBlocker) { + throw new BadRequestException(firstBlocker.code); + } + + const existing = await this.findReusableLiveActionVideoClip(shot); + + if (existing && !dto.force && candidateCount === 1) { + return { + video_clip: toSafeVideoClip(existing), + selected_video_clip: toSafeVideoClip(existing), + video_clips: [toSafeVideoClip(existing)], + candidate_count: 1, + preflight, + reused: true, + next_step: 'sample_quality_check' + }; + } + + const clips: VideoClip[] = []; + + for (let index = 1; index <= candidateCount; index += 1) { + clips.push(await this.generateSingleVideoClip(project, episode, shot, { ...dto, force: true }, existing, user, { + candidateIndex: index, + candidateCount, + autoSelectClip: index === 1 + })); + } + + const selectedClip = clips[0]; + + return { + video_clip: toSafeVideoClip(selectedClip), + selected_video_clip: toSafeVideoClip(selectedClip), + video_clips: clips.map(toSafeVideoClip), + candidate_count: candidateCount, + preflight, + reused: false, + next_step: candidateCount > 1 ? 'sample_candidate_select_or_quality_check' : 'sample_quality_check' + }; + } + + async retryVideoClip(user: AuthRequestUser, clipId: string, dto: LiveActionGenerateDto) { + if (!this.queues) { + return this.executeVideoClipRetryNow(user, clipId, dto); + } + + const clip = await this.findVideoClipOrThrow(clipId); + const project = await this.findProjectForUser(clip.project_id.toString(), user); + this.assertLiveActionProject(project); + const taskInput = this.createQueuedLiveActionTaskInput(user, clip, dto, { + action: 'manual_provider_retry' + }); + const queued = await this.queues.createInternalTask({ + projectId: clip.project_id, + episodeId: clip.episode_id, + shotId: clip.shot_id, + taskType: 'live_action_video_clip_retry', + inputJson: taskInput, + idempotencyKey: this.liveActionQueueIdempotencyKey('retry', clip.id), + maxRetry: 1 + }); + + await this.writeRouterAuditOperationLog(user, 'router_audit_manual_provider_retry', clip, { + task_id: queued.task.id, + provider_code: this.normalizeOptionalText(dto.provider_code, 100) ?? '', + max_cost_per_clip: this.optionalNumberFromJson(dto.max_cost_per_clip), + confirm_real_video: dto.confirm_real_video === true, + from_quality_status: clip.quality_status ?? 'not_checked', + from_quality_score: this.optionalNumberFromJson(clip.quality_score?.toString()), + retry_count: clip.retry_count, + queue_name: queued.queue.queue_name, + queue_enqueued: queued.queue.enqueued, + reason: 'manual_provider_retry_queued' + }); + + return { + task: queued.task, + queue: queued.queue, + video_clip: toSafeVideoClip(clip), + next_step: 'queued_video_clip_retry' + }; + } + + private async executeVideoClipRetryNow( + user: AuthRequestUser, + clipId: string, + dto: LiveActionGenerateDto + ) { + const clip = await this.prisma.videoClip.findUnique({ + where: { id: this.parseId(clipId, 'Invalid video clip id') } + }); + + if (!clip) { + throw new NotFoundException('Video clip not found'); + } + + const project = await this.findProjectForUser(clip.project_id.toString(), user); + this.assertLiveActionProject(project); + const episode = await this.prisma.episode.findUnique({ where: { id: clip.episode_id } }); + const shot = await this.prisma.storyboardShot.findUnique({ where: { id: clip.shot_id } }); + + if (!episode || !shot) { + throw new NotFoundException('Video clip source shot not found'); + } + + const updated = await this.generateSingleVideoClip(project, episode, shot, { ...dto, force: true }, clip, user); + + return { + video_clip: toSafeVideoClip(updated), + next_step: 'quality_check_or_render' + }; + } + + async checkVideoClipQuality(user: AuthRequestUser, clipId: string, dto: LiveActionQualityCheckDto = {}) { + if (!this.queues) { + const clip = await this.findVideoClipOrThrow(clipId); + await this.writeRouterAuditOperationLog(user, 'router_audit_quality_recheck', clip, { + auto_repair: dto.auto_repair !== false, + min_quality_score: this.resolveMinQualityScore(dto.min_quality_score), + max_cost_per_clip: this.optionalNumberFromJson(dto.max_cost_per_clip), + confirm_real_video: dto.confirm_real_video === true, + from_quality_status: clip.quality_status ?? 'not_checked', + from_quality_score: this.optionalNumberFromJson(clip.quality_score?.toString()), + retry_count: clip.retry_count, + reason: 'quality_recheck_requested' + }); + + return this.executeVideoClipQualityCheckNow(user, clipId, dto); + } + + const clip = await this.findVideoClipOrThrow(clipId); + const project = await this.findProjectForUser(clip.project_id.toString(), user); + this.assertLiveActionProject(project); + const taskInput = this.createQueuedLiveActionTaskInput(user, clip, dto, { + action: 'quality_recheck' + }); + const queued = await this.queues.createInternalTask({ + projectId: clip.project_id, + episodeId: clip.episode_id, + shotId: clip.shot_id, + taskType: 'live_action_video_clip_quality_check', + inputJson: taskInput, + idempotencyKey: this.liveActionQueueIdempotencyKey('quality-check', clip.id), + maxRetry: 1 + }); + + await this.writeRouterAuditOperationLog(user, 'router_audit_quality_recheck', clip, { + task_id: queued.task.id, + auto_repair: dto.auto_repair !== false, + min_quality_score: this.resolveMinQualityScore(dto.min_quality_score), + max_cost_per_clip: this.optionalNumberFromJson(dto.max_cost_per_clip), + confirm_real_video: dto.confirm_real_video === true, + from_quality_status: clip.quality_status ?? 'not_checked', + from_quality_score: this.optionalNumberFromJson(clip.quality_score?.toString()), + retry_count: clip.retry_count, + queue_name: queued.queue.queue_name, + queue_enqueued: queued.queue.enqueued, + reason: 'quality_recheck_queued' + }); + + return { + task: queued.task, + queue: queued.queue, + video_clip: toSafeVideoClip(clip), + next_step: 'queued_quality_check' + }; + } + + async manualReviewVideoClip(user: AuthRequestUser, clipId: string, dto: LiveActionManualReviewDto) { + const clip = await this.findVideoClipOrThrow(clipId); + const project = await this.findProjectForUser(clip.project_id.toString(), user); + this.assertLiveActionProject(project); + const resultStatus = this.normalizeManualQualityStatus(dto.result_status); + const manualScore = this.manualQualityScore(resultStatus, dto.quality_score); + const reason = this.normalizeOptionalText(dto.reason, 500) || this.defaultManualQualityReason(resultStatus); + const existingIssues = Array.isArray(clip.quality_issues) ? clip.quality_issues : []; + const updated = await this.prisma.videoClip.update({ + where: { id: clip.id }, + data: { + quality_status: resultStatus, + quality_score: manualScore, + quality_issues: this.toJsonValue([ + ...existingIssues.map((issue) => this.toJsonValue(issue)), + { + type: 'manual_review', + status: resultStatus, + reason, + reviewed_by: user.id, + reviewed_at: new Date().toISOString() + } + ]) + } + }); + + await this.prisma.storyboardShot.update({ + where: { id: clip.shot_id }, + data: { video_status: `sample_${resultStatus}` } + }).catch(() => undefined); + await this.writeRouterAuditOperationLog(user, 'live_action_sample_manual_review', clip, { + result_status: resultStatus, + quality_score: manualScore, + reason + }); + + return { + video_clip: toSafeVideoClip(updated), + next_step: resultStatus === 'passed' ? 'sample_passed_or_render_episode' : 'sample_retry_or_replace_keyframe' + }; + } + + async selectVideoClipCandidate(user: AuthRequestUser, clipId: string) { + const clip = await this.findVideoClipOrThrow(clipId); + const project = await this.findProjectForUser(clip.project_id.toString(), user); + + this.assertLiveActionProject(project); + if (clip.status !== 'generated' || !clip.output_asset_id) { + throw new BadRequestException('LIVE_ACTION_CANDIDATE_CLIP_NOT_READY'); + } + + const shot = await this.prisma.storyboardShot.findUnique({ where: { id: clip.shot_id } }); + + if (!shot || shot.episode_id !== clip.episode_id || shot.project_id !== clip.project_id) { + throw new NotFoundException('Storyboard shot not found'); + } + + const previousAssetId = shot.video_clip_asset_id?.toString() ?? null; + const updatedShot = await this.prisma.storyboardShot.update({ + where: { id: shot.id }, + data: { + video_clip_asset_id: clip.output_asset_id, + video_status: 'video_clip_candidate_selected' + } + }); + + await this.writeRouterAuditOperationLog(user, 'live_action_video_clip_candidate_selected', clip, { + selected_output_asset_id: clip.output_asset_id.toString(), + previous_video_clip_asset_id: previousAssetId, + quality_status: clip.quality_status, + quality_score: this.optionalNumberFromJson(clip.quality_score?.toString()), + cost_actual: this.optionalNumberFromJson(clip.cost_actual?.toString()), + reason: 'manual_best_candidate_selection' + }); + + return { + shot: toSafeLiveActionShot(updatedShot), + video_clip: toSafeVideoClip(clip), + next_step: 'render_live_action_episode' + }; + } + + private async executeVideoClipQualityCheckNow( + user: AuthRequestUser, + clipId: string, + dto: LiveActionQualityCheckDto = {}, + context: LiveActionQualityContext = {} + ) { + const clip = await this.prisma.videoClip.findUnique({ + where: { id: this.parseId(clipId, 'Invalid video clip id') } + }); + + if (!clip) { + throw new NotFoundException('Video clip not found'); + } + + const project = await this.findProjectForUser(clip.project_id.toString(), user); + this.assertLiveActionProject(project); + + return this.runVideoClipQualityClosure(user, project, clip, dto, [], context); + } + + async executeQueuedRouterTask(task: RenderTask) { + const input = this.jsonObject(task.input_json ?? null); + const clipId = this.stringifyText(input.source_clip_id); + + if (!clipId) { + throw new BadRequestException('Queued live action task missing source_clip_id'); + } + + const user = this.userFromQueuedLiveActionTask(input); + const dto = this.dtoFromQueuedLiveActionTask(input); + const running = await this.prisma.renderTask.update({ + where: { id: task.id }, + data: { + status: 'running', + started_at: new Date(), + error_code: null, + error_message: null + } + }); + const context: LiveActionQualityContext = { taskId: running.id.toString() }; + const result = task.task_type === 'live_action_video_clip_retry' + ? await this.executeVideoClipRetryNow(user, clipId, dto) + : await this.executeVideoClipQualityCheckNow(user, clipId, dto, context); + const outputAssetId = this.outputAssetIdFromQueuedLiveActionResult(result); + const updated = await this.prisma.renderTask.update({ + where: { id: task.id }, + data: { + status: 'success', + output_asset_id: outputAssetId, + finished_at: new Date() + } + }); + + return { + task: toSafeRenderTask(updated), + ...result + }; + } + + private async runVideoClipQualityClosure( + user: AuthRequestUser, + project: Project, + clip: VideoClip, + dto: LiveActionQualityCheckDto, + repairHistory: LiveActionRepairHistoryItem[], + context: LiveActionQualityContext = {} + ): Promise<{ + video_clip: ReturnType; + provider_result: LiveActionJsonRecord; + repair_action: LiveActionRepairAction; + repair_history: LiveActionRepairHistoryItem[]; + repaired_clip?: ReturnType; + next_step: string; + }> { + const checked = await this.runSingleVideoClipQualityCheck(project, clip, context.taskId); + const minQualityScore = this.resolveMinQualityScore(dto.min_quality_score); + const passed = this.isVideoQualityPassed( + checked.result_status, + checked.quality_score, + minQualityScore + ); + + if (passed) { + const finalHistory = [...repairHistory, { action: 'passed' as const, clip_id: checked.clip.id.toString() }]; + + return { + video_clip: toSafeVideoClip(checked.clip), + provider_result: checked.provider_result, + repair_action: 'passed', + repair_history: finalHistory, + next_step: 'render_episode' + }; + } + + if (dto.auto_repair === false) { + const retryClip = await this.markVideoClipNeedsRetry( + checked.clip, + checked.provider_result, + 'AUTO_REPAIR_DISABLED' + ); + + return { + video_clip: toSafeVideoClip(retryClip), + provider_result: checked.provider_result, + repair_action: 'needs_retry', + repair_history: [ + ...repairHistory, + { + action: 'needs_retry', + clip_id: retryClip.id.toString(), + reason: 'AUTO_REPAIR_DISABLED' + } + ], + next_step: 'retry_video_clip' + }; + } + + if (repairHistory.length >= LIVE_ACTION_MAX_AUTO_REPAIR_DEPTH) { + const manual = await this.markVideoClipManualRequired( + checked.clip, + checked.provider_result, + 'AUTO_REPAIR_LIMIT_REACHED' + ); + + return { + video_clip: toSafeVideoClip(manual), + provider_result: checked.provider_result, + repair_action: 'manual_required', + repair_history: [ + ...repairHistory, + { + action: 'manual_required', + clip_id: manual.id.toString(), + reason: 'AUTO_REPAIR_LIMIT_REACHED' + } + ], + next_step: 'manual_required' + }; + } + + const repair = await this.planVideoClipRepair(checked.clip, checked.provider_result); + + if (!repair.provider_code) { + const manual = await this.markVideoClipManualRequired(checked.clip, checked.provider_result, repair.reason); + + return { + video_clip: toSafeVideoClip(manual), + provider_result: checked.provider_result, + repair_action: 'manual_required', + repair_history: [ + ...repairHistory, + { + action: 'manual_required', + clip_id: manual.id.toString(), + reason: repair.reason + } + ], + next_step: 'manual_required' + }; + } + + if (repair.action === 'manual_required') { + const manual = await this.markVideoClipManualRequired(checked.clip, checked.provider_result, repair.reason); + + return { + video_clip: toSafeVideoClip(manual), + provider_result: checked.provider_result, + repair_action: 'manual_required', + repair_history: [ + ...repairHistory, + { + action: 'manual_required', + clip_id: manual.id.toString(), + provider_code: repair.provider_code, + reason: repair.reason + } + ], + next_step: 'manual_required' + }; + } + + if (await this.requiresRealVideoConfirmation(repair.provider_code, dto.confirm_real_video)) { + const manual = await this.markVideoClipManualRequired( + checked.clip, + checked.provider_result, + 'REAL_VIDEO_AUTO_REPAIR_CONFIRMATION_REQUIRED' + ); + + return { + video_clip: toSafeVideoClip(manual), + provider_result: checked.provider_result, + repair_action: 'manual_required', + repair_history: [ + ...repairHistory, + { + action: 'manual_required', + clip_id: manual.id.toString(), + provider_code: repair.provider_code, + reason: 'REAL_VIDEO_AUTO_REPAIR_CONFIRMATION_REQUIRED' + } + ], + next_step: 'manual_required' + }; + } + + const regenerated = await this.regenerateClipForQualityRepair( + user, + project, + checked.clip, + repair.provider_code, + repair.action, + checked.provider_result, + dto, + context + ); + const nextHistory = [ + ...repairHistory, + { + action: repair.action, + clip_id: regenerated.id.toString(), + provider_code: repair.provider_code, + reason: repair.reason + } + ]; + const result = await this.runVideoClipQualityClosure(user, project, regenerated, dto, nextHistory, context); + + return { + ...result, + repaired_clip: toSafeVideoClip(regenerated) + }; + } + + private async runSingleVideoClipQualityCheck(project: Project, clip: VideoClip, taskId?: string) { + if (!clip.output_asset_id) { + throw new BadRequestException('Generated video asset is required before quality check'); + } + + const asset = await this.prisma.asset.findUnique({ where: { id: clip.output_asset_id } }); + + if (!asset) { + throw new NotFoundException('Video clip asset not found'); + } + + const providerResult = await this.providersService.executeProvider({ + provider_type: 'QualityCheckProvider', + preferred_provider_code: 'mock-qc', + purpose: `live-action-video-clip-qc-${clip.id.toString()}`, + project_id: project.id.toString(), + task_id: taskId, + allow_fallback: false, + return_binary: false, + input_json: { + prompt: [ + '真人短剧视频片段质量检查', + `clip_id=${clip.id.toString()}`, + `duration=${clip.duration?.toString() ?? '-'}`, + `mime_type=${asset.mime_type ?? '-'}`, + `status=${asset.status}`, + clip.prompt_text ?? '' + ].join('\n') + } + }); + const output = this.jsonObject(providerResult.result); + const resultStatus = this.stringifyText(output.result_status) || 'passed'; + const qualityScore = this.optionalNumberFromJson(output.quality_score); + const issues = Array.isArray(output.issues) ? output.issues : []; + const updated = await this.prisma.videoClip.update({ + where: { id: clip.id }, + data: { + quality_status: resultStatus, + quality_score: qualityScore, + quality_issues: this.toJsonValue(issues) + } + }); + + return { + clip: updated, + provider_result: output, + result_status: resultStatus, + quality_score: qualityScore + }; + } + + private resolveMinQualityScore(value: unknown) { + const score = this.optionalNumberFromJson(value); + + if (score === null) { + return LIVE_ACTION_DEFAULT_MIN_QUALITY_SCORE; + } + + return Math.max(0, Math.min(100, score)); + } + + private isVideoQualityPassed(resultStatus: string, qualityScore: number | null, minQualityScore: number) { + if (resultStatus === 'manual_required' || resultStatus === 'failed' || resultStatus === 'needs_retry') { + return false; + } + + if (qualityScore === null) { + return resultStatus === 'passed'; + } + + return resultStatus === 'passed' && qualityScore >= minQualityScore; + } + + private async markVideoClipManualRequired( + clip: VideoClip, + providerResult: LiveActionJsonRecord, + reason: string + ) { + const issues = Array.isArray(providerResult.issues) ? providerResult.issues : []; + const updatedIssues = [...issues.map((issue) => this.toJsonValue(issue)), reason]; + const updated = await this.prisma.videoClip.update({ + where: { id: clip.id }, + data: { + quality_status: 'manual_required', + quality_issues: this.toJsonValue(updatedIssues) + } + }); + + await this.prisma.storyboardShot.update({ + where: { id: clip.shot_id }, + data: { video_status: 'quality_manual_required' } + }).catch(() => undefined); + + return updated; + } + + private async markVideoClipNeedsRetry( + clip: VideoClip, + providerResult: LiveActionJsonRecord, + reason: string + ) { + const issues = Array.isArray(providerResult.issues) ? providerResult.issues : []; + const updatedIssues = [...issues.map((issue) => this.toJsonValue(issue)), reason]; + const updated = await this.prisma.videoClip.update({ + where: { id: clip.id }, + data: { + quality_status: 'needs_retry', + quality_issues: this.toJsonValue(updatedIssues) + } + }); + + await this.prisma.storyboardShot.update({ + where: { id: clip.shot_id }, + data: { video_status: 'quality_needs_retry' } + }).catch(() => undefined); + + return updated; + } + + private async planVideoClipRepair( + clip: VideoClip, + providerResult: LiveActionJsonRecord + ): Promise { + const routeContext = await this.findClipRouteContext(clip); + const currentProviderCode = routeContext.provider_code ?? (await this.providerCodeForClip(clip)); + const fallbackChain = this.uniqueStrings([ + ...routeContext.fallback_chain, + currentProviderCode ?? '', + 'mock-video' + ]); + const qualityScore = this.optionalNumberFromJson(providerResult.quality_score); + const reason = qualityScore === null + ? 'QUALITY_CHECK_FAILED' + : `QUALITY_SCORE_BELOW_${LIVE_ACTION_DEFAULT_MIN_QUALITY_SCORE}`; + + if (clip.retry_count < 1 && currentProviderCode) { + return { + action: 'retry_same_provider', + provider_code: currentProviderCode, + fallback_chain: fallbackChain, + reason + }; + } + + const nextProviderCode = await this.nextFallbackProviderCode(fallbackChain, currentProviderCode); + + if (nextProviderCode) { + return { + action: 'switch_provider', + provider_code: nextProviderCode, + fallback_chain: fallbackChain, + reason: 'QUALITY_RETRY_FAILED_SWITCH_PROVIDER' + }; + } + + return { + action: 'manual_required', + provider_code: null, + fallback_chain: fallbackChain, + reason: 'NO_FALLBACK_PROVIDER_AVAILABLE' + }; + } + + private async requiresRealVideoConfirmation(providerCode: string, confirmed: boolean | undefined) { + if (providerCode === 'mock-video') { + return false; + } + + const provider = await this.prisma.providerConfig.findUnique({ + where: { + provider_type_provider_code: { + provider_type: 'VideoProvider', + provider_code: providerCode + } + } + }); + + return Boolean((provider?.mode === 'real' || providerCode !== 'mock-video') && confirmed !== true); + } + + private async regenerateClipForQualityRepair( + user: AuthRequestUser, + project: Project, + clip: VideoClip, + providerCode: string, + action: Extract, + providerResult: LiveActionJsonRecord, + dto: LiveActionQualityCheckDto, + context: LiveActionQualityContext = {} + ) { + const episode = await this.prisma.episode.findUnique({ where: { id: clip.episode_id } }); + const shot = await this.prisma.storyboardShot.findUnique({ where: { id: clip.shot_id } }); + + if (!episode || !shot) { + throw new NotFoundException('Video clip source shot not found'); + } + + const repairPlan = await this.planVideoClipRepair(clip, providerResult); + const repairContext: LiveActionJsonRecord = { + source_clip_id: clip.id.toString(), + action, + provider_code: providerCode, + parent_quality_task_id: context.taskId ?? '', + previous_quality_status: this.stringifyText(providerResult.result_status), + previous_quality_score: this.optionalNumberFromJson(providerResult.quality_score) ?? '', + fallback_chain: this.toJsonValue(repairPlan.fallback_chain), + min_quality_score: this.resolveMinQualityScore(dto.min_quality_score) + }; + + await this.writeRouterAuditOperationLog(user, 'router_audit_auto_repair_triggered', clip, { + task_id: context.taskId ?? '', + action, + provider_code: providerCode, + reason: repairPlan.reason, + fallback_chain: repairPlan.fallback_chain, + previous_quality_status: this.stringifyText(providerResult.result_status), + previous_quality_score: this.optionalNumberFromJson(providerResult.quality_score), + min_quality_score: this.resolveMinQualityScore(dto.min_quality_score), + max_cost_per_clip: this.optionalNumberFromJson(dto.max_cost_per_clip), + confirm_real_video: dto.confirm_real_video === true + }); + + return this.generateSingleVideoClip( + project, + episode, + shot, + { + force: true, + provider_code: providerCode, + confirm_real_video: dto.confirm_real_video, + max_cost_per_clip: dto.max_cost_per_clip + }, + clip, + user, + { + allowSystemProviderOverride: true, + repairContext + } + ); + } + + private async findClipRouteContext(clip: VideoClip): Promise { + const task = await this.prisma.renderTask.findFirst({ + where: { + task_type: 'live_action_video_clip_generate', + shot_id: clip.shot_id, + output_asset_id: clip.output_asset_id, + status: 'success' + }, + orderBy: { created_at: 'desc' } + }); + const inputJson = this.jsonObject(task?.input_json ?? null); + const routerDecision = this.jsonObject(inputJson.router_decision ?? null); + const repairContext = this.jsonObject(inputJson.repair_context ?? null); + const providerCode = + this.stringifyText(repairContext.provider_code) || + this.stringifyText(routerDecision.provider_code) || + this.stringifyText(inputJson.provider) || + null; + const fallbackChain = this.uniqueStrings([ + ...this.stringArray(repairContext.fallback_chain), + ...this.stringArray(routerDecision.fallback_chain) + ]); + + return { + provider_code: providerCode, + fallback_chain: fallbackChain, + repair_context: repairContext + }; + } + + private async providerCodeForClip(clip: VideoClip) { + if (!clip.provider_id) { + return null; + } + + const provider = await this.prisma.providerConfig.findUnique({ + where: { id: clip.provider_id } + }); + + return provider?.provider_code ?? null; + } + + private async nextFallbackProviderCode(fallbackChain: string[], currentProviderCode: string | null) { + await this.ensureMockVideoProvider(); + + const chain = this.uniqueStrings([...fallbackChain, 'mock-video']); + const currentIndex = currentProviderCode ? chain.indexOf(currentProviderCode) : -1; + const candidates = chain.slice(currentIndex >= 0 ? currentIndex + 1 : 0); + + for (const providerCode of candidates) { + const provider = await this.prisma.providerConfig.findUnique({ + where: { + provider_type_provider_code: { + provider_type: 'VideoProvider', + provider_code: providerCode + } + } + }); + + if (provider?.is_enabled) { + return provider.provider_code; + } + } + + return null; + } + + private async generateSingleVideoClip( + project: Project, + episode: Episode, + shot: StoryboardShot, + dto: LiveActionGenerateDto, + previousClip: VideoClip | null, + user: AuthRequestUser, + options: LiveActionGenerateOptions = {} + ) { + if (!shot.keyframe_asset_id) { + throw new BadRequestException('Live action keyframes are required before video clip generation'); + } + + const duration = this.normalizeShotDuration(shot); + const routeDecision = await this.resolveVideoRoute(project, shot, duration, dto, user, options); + await this.ensureShotRouteScores(shot, routeDecision.scores); + + const providerCode = routeDecision.provider_code; + const providerConfig = + providerCode === 'mock-video' + ? await this.ensureMockVideoProvider() + : await this.prisma.providerConfig.findUnique({ + where: { + provider_type_provider_code: { + provider_type: 'VideoProvider', + provider_code: providerCode + } + } + }); + const isRealProvider = this.videoProviderRequiresConfirmation(providerCode, providerConfig?.mode); + + if (!providerConfig) { + throw new BadRequestException('LIVE_ACTION_VIDEO_PROVIDER_NOT_FOUND'); + } + if (!providerConfig.is_enabled) { + throw new BadRequestException('LIVE_ACTION_VIDEO_PROVIDER_DISABLED'); + } + if (isRealProvider && dto.confirm_real_video !== true) { + throw new BadRequestException('REAL_VIDEO_GENERATION_CONFIRMATION_REQUIRED'); + } + + const lipSyncProviderAvailable = await this.hasLiveActionLipSyncProvider(); + const lipSyncPolicy = this.resolveLiveActionLipSyncPolicy(shot, lipSyncProviderAvailable); + const actorLock = await this.buildLiveActionActorLockContext(project, shot, providerConfig); + const promptBuild = this.buildLiveActionPromptForProvider( + project, + episode, + shot, + providerCode, + routeDecision, + lipSyncPolicy, + { + characterNames: actorLock.character_names, + actorHints: actorLock.actor_hints ?? undefined + } + ); + const prompt = promptBuild.prompt; + const providerClipSegments = this.buildLiveActionProviderClipSegments(shot, duration, dto, providerConfig); + const providerClipDurations = providerClipSegments.map((segment) => segment.duration); + const estimatedCost = Number(providerClipSegments + .reduce((sum, segment) => sum + this.estimateSingleClipCost(providerConfig?.cost_rule_json ?? null, segment.duration), 0) + .toFixed(4)) || routeDecision.estimated_cost; + const maxCost = this.optionalNumberFromJson(dto.max_cost_per_clip); + + if (maxCost !== null && maxCost > 0 && estimatedCost > maxCost) { + throw new BadRequestException('LIVE_ACTION_VIDEO_COST_LIMIT_EXCEEDED'); + } + + const task = await this.createRenderTask(project.id, episode.id, shot.id, 'live_action_video_clip_generate', { + shot_id: shot.id.toString(), + keyframe_asset_id: shot.keyframe_asset_id.toString(), + duration, + candidate_index: options.candidateIndex ?? 1, + candidate_count: options.candidateCount ?? 1, + auto_select_clip: options.autoSelectClip !== false, + provider_clip_count: providerClipDurations.length, + provider_clip_durations: providerClipDurations, + action_beat_mode: this.booleanFromJson(dto.action_beat_mode), + action_beat_segments: providerClipSegments.map((segment) => this.toJsonValue(segment)), + prompt, + provider: providerCode, + estimated_cost: estimatedCost, + prompt_version: promptBuild.prompt_version, + prompt_profile: promptBuild.provider_profile, + prompt_components: this.toJsonValue(promptBuild.components), + negative_prompt: promptBuild.negative_prompt, + lip_sync_policy: this.toJsonValue(lipSyncPolicy), + actor_lock: this.toJsonValue(actorLock.audit), + router_decision: this.toJsonValue(routeDecision), + repair_context: this.toJsonValue(options.repairContext ?? {}) + }); + + try { + const generatedSegments: Array<{ buffer: Buffer; mimeType: string; assetUrl: string | null }> = []; + let providerId: bigint | null = null; + let actualCost = 0; + let sourceKeyframeAssetId = shot.keyframe_asset_id; + + for (const [index, segment] of providerClipSegments.entries()) { + const segmentDuration = segment.duration; + const segmentPrompt = this.segmentVideoPrompt(prompt, index, providerClipSegments.length, segmentDuration, segment); + const currentSourceAssetId = segment.source_strategy === 'previous_segment_end_frame' + ? sourceKeyframeAssetId + : shot.keyframe_asset_id; + const inputJson: Record = { + duration: segmentDuration, + target_shot_duration: duration, + candidate_index: options.candidateIndex ?? 1, + candidate_count: options.candidateCount ?? 1, + provider_clip_index: index + 1, + provider_clip_count: providerClipSegments.length, + source_frame_strategy: segment.source_strategy, + action_beat_label: segment.beat_label, + motion: shot.camera_instruction ?? shot.camera_motion ?? 'subtle handheld push in', + prompt: segmentPrompt, + negative_prompt: promptBuild.negative_prompt, + prompt_version: promptBuild.prompt_version, + prompt_profile: promptBuild.provider_profile, + prompt_components: promptBuild.components, + actor_lock: actorLock.audit, + character_reference_asset_ids: actorLock.reference_asset_ids, + source_image_asset_id: currentSourceAssetId.toString() + }; + + if (isRealProvider) { + Object.assign(inputJson, await this.createRealVideoSourceImageInput(currentSourceAssetId)); + } + if ( + actorLock.provider_character_reference_enabled && + actorLock.reference_image_data_uris.length > 0 + ) { + inputJson.reference_images = [ + inputJson.source_image_data_uri, + ...actorLock.reference_image_data_uris + ].filter((value): value is string => typeof value === 'string' && value.length > 0); + } + + const providerResult = await this.providersService.executeProvider({ + provider_type: 'VideoProvider', + preferred_provider_code: providerCode, + purpose: `live-action-video-clip-${shot.id.toString()}-${index + 1}`, + project_id: project.id.toString(), + task_id: task.id.toString(), + allow_fallback: false, + return_binary: true, + input_json: inputJson + }); + + providerId = providerResult.provider.id ? BigInt(providerResult.provider.id) : providerId; + actualCost += providerResult.provider_log.cost_actual ?? this.estimateSingleClipCost(providerConfig?.cost_rule_json ?? null, segmentDuration); + + if (providerResult.provider.mode === 'mock') { + generatedSegments.push({ + buffer: await this.createMockVideoClipBuffer(shot, segmentDuration, segmentPrompt), + mimeType: 'video/mp4', + assetUrl: null + }); + } else { + generatedSegments.push(await this.providerVideoBufferFromOutput(this.jsonObject(providerResult.result))); + } + + if (index < providerClipSegments.length - 1 && this.booleanFromJson(dto.action_beat_mode)) { + const latest = generatedSegments[generatedSegments.length - 1]; + sourceKeyframeAssetId = await this.storeLiveActionSegmentEndFrameAsset( + project, + episode, + shot, + latest.buffer, + index + 1 + ); + } + } + + const asset = + generatedSegments.length === 1 + ? await this.storeVideoClipAssetFromBuffer(project, shot, duration, generatedSegments[0], providerCode === 'mock-video') + : await this.storeVideoClipAssetFromBuffer( + project, + shot, + duration, + { + buffer: await this.trimLiveActionGeneratedClipBuffer( + await this.concatVideoClipBuffers( + generatedSegments.map((segment) => segment.buffer), + 'ai-live-action-shot-split-' + ), + duration + ), + mimeType: 'video/mp4', + assetUrl: null + }, + providerCode === 'mock-video' + ); + const retryCount = previousClip ? previousClip.retry_count + 1 : 0; + const clip = await this.prisma.videoClip.create({ + data: { + project_id: project.id, + episode_id: episode.id, + shot_id: shot.id, + provider_id: providerId, + input_asset_id: shot.keyframe_asset_id, + output_asset_id: asset.id, + duration, + prompt_text: prompt, + status: 'generated', + cost_actual: Number(actualCost.toFixed(4)), + retry_count: retryCount, + quality_status: null, + quality_score: null, + quality_issues: PrismaNamespace.JsonNull + } + }); + + await this.prisma.renderTask.update({ + where: { id: task.id }, + data: { + status: 'success', + output_asset_id: asset.id, + cost_estimate: estimatedCost, + cost_actual: Number(actualCost.toFixed(4)), + finished_at: new Date() + } + }); + if (options.autoSelectClip !== false) { + await this.prisma.storyboardShot.update({ + where: { id: shot.id }, + data: { + video_clip_asset_id: asset.id, + video_status: options.candidateCount && options.candidateCount > 1 + ? 'video_clip_candidate_selected' + : 'video_clip_generated' + } + }); + } + + return clip; + } catch (error) { + const normalized = this.toError(error); + + await this.prisma.renderTask.update({ + where: { id: task.id }, + data: { + status: 'failed', + error_code: normalized.message.replace(/[^a-zA-Z0-9_]+/g, '_').slice(0, 100), + error_message: normalized.message, + finished_at: new Date() + } + }).catch(() => undefined); + await this.prisma.videoClip.create({ + data: { + project_id: project.id, + episode_id: episode.id, + shot_id: shot.id, + provider_id: providerConfig?.id ?? null, + input_asset_id: shot.keyframe_asset_id, + output_asset_id: null, + duration, + prompt_text: prompt, + status: 'failed', + cost_actual: 0, + retry_count: previousClip ? previousClip.retry_count + 1 : 0, + quality_status: 'not_checked', + quality_score: null, + quality_issues: this.toJsonValue([normalized.message]) + } + }); + await this.prisma.storyboardShot.update({ + where: { id: shot.id }, + data: { video_status: 'video_clip_failed' } + }).catch(() => undefined); + + throw error; + } + } + + async renderLiveActionEpisode(user: AuthRequestUser, episodeId: string, dto: LiveActionGenerateDto) { + const { episode, project } = await this.findEpisodeForUser(episodeId, user); + this.assertLiveActionProject(project); + const existing = dto.force + ? null + : await this.prisma.renderTask.findFirst({ + where: { + episode_id: episode.id, + task_type: 'live_action_video_render', + status: 'success', + output_asset_id: { not: null } + }, + orderBy: { created_at: 'desc' } + }); + + if (existing?.output_asset_id) { + const asset = await this.prisma.asset.findUnique({ where: { id: existing.output_asset_id } }); + + if (asset) { + return { + asset: toSafeAsset(asset), + reused: true, + next_step: 'download_or_review' + }; + } + } + + const shots = await this.prisma.storyboardShot.findMany({ + where: { + episode_id: episode.id, + video_clip_asset_id: { not: null } + }, + orderBy: { shot_no: 'asc' } + }); + + if (shots.length === 0) { + throw new BadRequestException('Generated live action video clips are required before render'); + } + + const renderedAssetStatus = await this.resolveRenderedLiveActionAssetStatus(shots); + const renderedAssetMode = renderedAssetStatus === 'mock' ? 'mock' : 'real'; + const postProduction = await this.prepareLiveActionPostProductionAssets(user, project, episode, shots, dto); + const renderTaskInput: Prisma.InputJsonObject = { + episode_id: episode.id.toString(), + clip_asset_ids: shots + .map((shot) => shot.video_clip_asset_id?.toString()) + .filter((assetId): assetId is string => Boolean(assetId)), + rendered_asset_status: renderedAssetStatus, + rendered_asset_mode: renderedAssetMode, + post_production: this.liveActionPostProductionTaskInput(postProduction), + video_polish: this.liveActionVideoPolishTaskInput() + }; + const task = await this.createRenderTask(project.id, episode.id, null, 'live_action_video_render', renderTaskInput); + const output = await this.concatVideoClips(shots, postProduction); + const stored = await this.storage.storePrivateFile( + { + originalname: `episode-${episode.id.toString()}-live-action-${renderedAssetMode}.mp4`, + mimetype: 'video/mp4', + size: output.buffer.length, + buffer: output.buffer + } as Express.Multer.File, + 'rendered-videos' + ); + const asset = await this.prisma.asset.create({ + data: { + user_id: project.user_id, + project_id: project.id, + asset_type: 'video', + file_path: stored.file_path, + file_url: null, + mime_type: 'video/mp4', + width: LIVE_ACTION_WIDTH, + height: LIVE_ACTION_HEIGHT, + duration: this.totalDuration(shots), + size: stored.size, + hash: stored.hash, + visibility: 'private', + status: renderedAssetStatus + } + }); + + await this.prisma.renderTask.update({ + where: { id: task.id }, + data: { + status: 'success', + output_asset_id: asset.id, + finished_at: new Date(), + input_json: { + ...renderTaskInput, + post_production: this.liveActionPostProductionTaskInput(postProduction), + video_polish: this.liveActionVideoPolishTaskInput(), + clip_normalization: output.normalization as unknown as Prisma.InputJsonValue + } + } + }); + await this.prisma.project.update({ + where: { id: project.id }, + data: { status: 'live_action_video_rendered' } + }); + + return { + asset: toSafeAsset(asset), + reused: false, + next_step: 'download_or_review' + }; + } + + private async resolveRenderedLiveActionAssetStatus(shots: StoryboardShot[]) { + const assetIds = Array.from(new Set( + shots + .map((shot) => shot.video_clip_asset_id) + .filter((assetId): assetId is bigint => Boolean(assetId)) + )); + + if (assetIds.length === 0) { + return 'mock'; + } + + const assets = await Promise.all( + assetIds.map((assetId) => this.prisma.asset.findUnique({ where: { id: assetId } })) + ); + + return assets.some((asset) => asset?.status === 'active') ? 'active' : 'mock'; + } + + private buildActorProfileDraft(character: Character) { + const actorDesc = [ + `${character.name},${character.age_group ?? '25-35岁'},${character.gender_label ?? '东亚短剧角色'}`, + character.global_character_id ? `复用全局角色资产#${character.global_character_id.toString()}` : null, + character.identity_desc, + character.appearance_desc, + character.personality_desc + ].filter(Boolean).join(';'); + + return { + actor_desc: actorDesc, + appearance_rules: [ + character.face_desc, + character.hair_desc, + character.eye_desc, + character.body_desc, + '真人短剧质感,东亚面孔,外貌年龄稳定,不要欧美脸,不要频繁变脸' + ].filter(Boolean).join(';'), + wardrobe_rules: [ + character.costume_rules, + character.wardrobe_variant ? `本项目服装变体:${character.wardrobe_variant}` : null, + character.special_props, + '服装保持现代短剧统一造型,同一集内不要突然换装' + ].filter(Boolean).join(';'), + performance_style: [ + character.performance_style, + character.speech_style, + '表演自然,情绪清晰,适合竖屏短剧,中近景表情要有层次' + ].filter(Boolean).join(';'), + voice_style: character.voice_style ?? character.speech_style ?? '中文短剧口吻,语速自然,情绪明确', + reference_asset_ids: character.anchor_asset_id + ? ([character.anchor_asset_id.toString()] as Prisma.InputJsonArray) + : [], + anchor_asset_id: character.anchor_asset_id, + status: 'generated' + }; + } + + private async buildLiveActionActorLockContext( + project: Project, + shot: StoryboardShot, + providerConfig: ProviderConfig + ): Promise { + const shotCharacterRefs = this.liveActionShotCharacterRefs(shot); + const characterIds = this.uniqueStrings(shotCharacterRefs.map((item) => item.id)); + const shotCharacterNames = this.uniqueStrings(shotCharacterRefs.map((item) => item.name)); + const characterIdValues = this.bigintArrayFromStrings(characterIds); + const characterNamesForLookup = shotCharacterNames.filter(Boolean); + const characterWhere: Prisma.CharacterWhereInput[] = []; + + if (characterIdValues.length > 0) { + characterWhere.push({ id: { in: characterIdValues } }); + } + if (characterNamesForLookup.length > 0) { + characterWhere.push({ name: { in: characterNamesForLookup } }); + } + + const characters = characterWhere.length > 0 + ? await this.prisma.character.findMany({ + where: { + project_id: project.id, + status: { not: 'deleted' }, + OR: characterWhere + }, + orderBy: [{ importance_level: 'desc' }, { id: 'asc' }] + }) + : []; + const resolvedCharacterIds = this.uniqueStrings([ + ...characterIds, + ...characters.map((character) => character.id.toString()) + ]); + const resolvedCharacterNames = this.uniqueStrings([ + ...shotCharacterNames, + ...characters.map((character) => character.name) + ]); + const actorProfiles = resolvedCharacterIds.length > 0 + ? await this.prisma.actorProfile.findMany({ + where: { + project_id: project.id, + character_id: { in: this.bigintArrayFromStrings(resolvedCharacterIds) }, + status: { not: 'deleted' } + }, + orderBy: { created_at: 'asc' } + }) + : []; + const profileByCharacterId = new Map( + actorProfiles.map((profile) => [profile.character_id.toString(), profile]) + ); + const anchorAssetIds = this.uniqueStrings([ + ...actorProfiles.map((profile) => profile.anchor_asset_id?.toString()), + ...characters.map((character) => character.anchor_asset_id?.toString()) + ]); + const referenceAssetIds = this.uniqueStrings([ + ...anchorAssetIds, + ...actorProfiles.flatMap((profile) => this.actorProfileReferenceAssetIds(profile)) + ]); + const providerCharacterReferenceEnabled = this.providerSupportsCharacterReference(providerConfig); + const referenceImageDataUris = providerCharacterReferenceEnabled + ? await this.createCharacterReferenceImageDataUris(referenceAssetIds) + : []; + + return { + character_ids: resolvedCharacterIds, + character_names: resolvedCharacterNames.length > 0 + ? resolvedCharacterNames + : this.characterNamesFromShotPayload(shot), + actor_hints: this.buildLiveActionActorLockHints(shotCharacterRefs, actorProfiles, characters), + anchor_asset_ids: anchorAssetIds, + reference_asset_ids: referenceAssetIds, + reference_image_data_uris: referenceImageDataUris, + provider_character_reference_enabled: providerCharacterReferenceEnabled, + audit: { + lock_version: 'live-action-character-lock-v1', + character_count: resolvedCharacterNames.length || shotCharacterNames.length, + character_ids: resolvedCharacterIds, + character_names: resolvedCharacterNames.length > 0 ? resolvedCharacterNames : shotCharacterNames, + anchor_asset_ids: anchorAssetIds, + reference_asset_ids: referenceAssetIds, + provider_character_reference_enabled: providerCharacterReferenceEnabled, + missing_actor_profile_character_ids: resolvedCharacterIds.filter((id) => !profileByCharacterId.has(id)), + missing_anchor_character_ids: resolvedCharacterIds.filter((id) => { + const profile = profileByCharacterId.get(id); + const character = characters.find((item) => item.id.toString() === id); + + return !profile?.anchor_asset_id && !character?.anchor_asset_id; + }) + } + }; + } + + private actorProfilesForShot( + actorsByCharacterId: Map, + shotCharacterRefs: Array<{ id: string | null; name: string }> + ) { + const ids = this.uniqueStrings(shotCharacterRefs.map((item) => item.id)); + + if (ids.length === 0) { + return []; + } + + return ids + .map((id) => actorsByCharacterId.get(id)) + .filter((profile): profile is ActorProfile => Boolean(profile)); + } + + private buildLiveActionActorLockHints( + shotCharacterRefs: Array<{ id: string | null; name: string }>, + actorProfiles: ActorProfile[], + characters: Character[] + ) { + const nameByCharacterId = new Map(); + + for (const item of shotCharacterRefs) { + if (item.id && item.name) { + nameByCharacterId.set(item.id, item.name); + } + } + for (const character of characters) { + nameByCharacterId.set(character.id.toString(), character.name); + } + + const profileLines = actorProfiles.map((profile) => { + const name = nameByCharacterId.get(profile.character_id.toString()) || `角色#${profile.character_id.toString()}`; + + return [ + `${name}:${profile.actor_desc ?? ''}`, + profile.appearance_rules ? `外貌锁定:${profile.appearance_rules}` : null, + profile.wardrobe_rules ? `服装锁定:${profile.wardrobe_rules}` : null, + profile.performance_style ? `表演方式:${profile.performance_style}` : null + ].filter(Boolean).join(';'); + }); + const fallbackLines = characters + .filter((character) => !actorProfiles.some((profile) => profile.character_id === character.id)) + .map((character) => [ + `${character.name}:${character.age_group ?? ''} ${character.gender_label ?? ''} ${character.identity_desc ?? ''}`, + character.face_desc ? `脸部:${character.face_desc}` : null, + character.hair_desc ? `发型:${character.hair_desc}` : null, + character.costume_rules ? `服装:${character.costume_rules}` : null, + character.negative_rules ? `禁止:${character.negative_rules}` : null + ].filter(Boolean).join(';')); + const names = this.uniqueStrings([ + ...shotCharacterRefs.map((item) => item.name), + ...characters.map((character) => character.name) + ]); + const lockRules = [ + `角色锁定:${names.length > 0 ? names.join('、') : '本镜角色'}必须像同一批真人演员连续出演`, + '同一角色在本集所有镜头中必须保持同一张脸、同一年龄感、同一发型、同一基础服装和气质', + '禁止同名角色换脸、换演员、换年龄、换发型、变成陌生人', + '多人同框时保持每个人的脸部差异,不要把不同角色混成同一张脸' + ]; + + return this.limitText( + this.uniqueStrings([ + ...profileLines, + ...fallbackLines, + ...lockRules + ]).join(';'), + 1800 + ); + } + + private actorProfileReferenceAssetIds(profile: ActorProfile) { + const raw = Array.isArray(profile.reference_asset_ids) ? profile.reference_asset_ids : []; + + return this.uniqueStrings([ + profile.anchor_asset_id?.toString(), + ...raw.map((item) => typeof item === 'number' || typeof item === 'bigint' ? item.toString() : this.stringifyText(item)) + ]); + } + + private providerSupportsCharacterReference(providerConfig: ProviderConfig) { + const config = this.jsonObject(providerConfig.config_json); + const bodyStyle = this.stringifyText(config.body_style); + + return ( + this.booleanFromJson(config.supports_character_reference) === true || + Boolean(this.stringifyText(config.image_array_field)) || + bodyStyle === 'vidu_reference' || + bodyStyle === 'dashscope_media_i2v' + ); + } + + private async createCharacterReferenceImageDataUris(assetIds: string[]) { + const dataUris = []; + + for (const assetId of assetIds.slice(0, 4)) { + if (!/^\d+$/.test(assetId)) { + continue; + } + + const asset = await this.prisma.asset.findUnique({ where: { id: BigInt(assetId) } }); + const mimeType = this.normalizeImageMimeType(asset?.mime_type ?? null); + + if (!asset || !mimeType) { + continue; + } + + const buffer = await this.storage.readPrivateFile(asset.file_path); + + if (buffer.length > 10 * 1024 * 1024) { + continue; + } + + dataUris.push(this.mediaDataUri(mimeType, buffer)); + } + + return dataUris; + } + + private buildLiveActionPromptForProvider( + project: Project, + episode: Episode, + shot: StoryboardShot, + providerCode: string | null, + routeDecision: AiRouteDecision | null, + lipSyncPolicy: LiveActionLipSyncPolicy, + options: { + characterNames?: string[]; + actorHints?: string; + duration?: number; + directorPlan?: LiveActionDirectorPlan; + } = {} + ) { + const characters = options.characterNames?.length + ? options.characterNames.join('、') + : this.characterNamesFromShotPayload(shot).join('、') || '主要角色'; + const location = shot.location_desc ?? shot.scene_name ?? '现代都市短剧场景'; + const duration = options.duration ?? this.normalizeShotDuration(shot); + const directorPlan = options.directorPlan ?? this.buildFallbackLiveActionDirectorPlan(shot, duration); + const scores = routeDecision?.scores ?? { + scene_type: shot.scene_type ?? 'dialog', + importance_score: shot.importance_score ?? 3, + emotion_score: shot.emotion_score ?? 2, + action_score: shot.action_score ?? 1, + route_tier: shot.route_tier ?? 'normal' + }; + + return (this.promptBuilder ?? this.fallbackPromptBuilder).buildLiveActionVideoPrompt({ + projectTitle: project.title, + episodeNo: episode.episode_no, + episodeTitle: episode.title, + shotNo: shot.shot_no, + providerCode, + sceneType: scores.scene_type, + routeTier: scores.route_tier, + durationSeconds: duration, + characters, + actorConsistencyRules: options.actorHints, + location, + action: shot.actor_action ?? shot.action_desc ?? shot.visual_desc ?? '角色完成关键动作并推动冲突', + visualDescription: shot.visual_desc, + cameraMotion: shot.camera_motion, + cameraInstruction: shot.camera_instruction, + performanceInstruction: shot.performance_instruction, + dialogueText: shot.dialogue_text, + narrationText: shot.narration_text, + effectType: shot.effect_type, + scores, + directorPlan, + lipSyncPolicy: { + lip_sync_required: lipSyncPolicy.lip_sync_required, + strategy: lipSyncPolicy.strategy, + reason: lipSyncPolicy.reason, + visual_fallback: lipSyncPolicy.visual_fallback + } + }); + } + + private buildLiveActionShotDraft( + project: Project, + episode: Episode, + shot: StoryboardShot, + characterNames: string[], + actorsByCharacterId: Map, + duration: number, + scores: AiRouterShotScores, + lipSyncPolicy: LiveActionLipSyncPolicy, + directorPlan: LiveActionDirectorPlan + ) { + const shotCharacterRefs = this.liveActionShotCharacterRefs(shot); + const scopedProfiles = this.actorProfilesForShot(actorsByCharacterId, shotCharacterRefs); + const actorHints = this.buildLiveActionActorLockHints(shotCharacterRefs, scopedProfiles, []); + const characters = characterNames.length > 0 ? characterNames.join('、') : '主要角色'; + const location = shot.location_desc ?? shot.scene_name ?? '现代都市短剧场景'; + const action = shot.action_desc ?? shot.visual_desc ?? '角色完成关键动作并推动冲突'; + const visualAction = this.applyDirectorActionPlan( + this.applyLipSyncActionPolicy(action, lipSyncPolicy), + directorPlan + ); + const camera = this.applyDirectorCameraPlan( + this.applyLipSyncCameraPolicy(this.cameraInstruction(shot.camera_motion), lipSyncPolicy), + directorPlan + ); + const performance = this.applyDirectorPerformancePlan(this.applyLipSyncPerformancePolicy( + this.performanceInstruction(shot.dialogue_text, shot.narration_text), + lipSyncPolicy + ), directorPlan); + const promptBuild = this.buildLiveActionPromptForProvider( + project, + episode, + { + ...shot, + actor_action: visualAction, + camera_instruction: camera, + performance_instruction: performance, + scene_type: scores.scene_type, + importance_score: scores.importance_score, + emotion_score: scores.emotion_score, + action_score: scores.action_score, + route_tier: scores.route_tier + }, + 'generic', + null, + lipSyncPolicy, + { + characterNames, + actorHints, + duration, + directorPlan + } + ); + const liveActionDesc = [ + `真实短剧风格,竖屏9:16,${location}`, + `人物:${characters}`, + `动作:${visualAction}`, + `导演分镜:${directorPlan.shot_role},${directorPlan.shot_size},${directorPlan.edit_intent}`, + `衔接:${directorPlan.continuity_in};${directorPlan.continuity_out}`, + `时长:${duration}秒` + ].join('。'); + + return { + live_action_desc: liveActionDesc, + scene_type: scores.scene_type, + importance_score: scores.importance_score, + emotion_score: scores.emotion_score, + action_score: scores.action_score, + route_tier: scores.route_tier, + duration: new PrismaNamespace.Decimal(duration), + actor_action: visualAction, + camera_instruction: camera, + performance_instruction: performance, + video_prompt: promptBuild.prompt, + video_clip_asset_id: null, + video_status: 'prepared' + }; + } + + private buildLiveActionDirectorPlans(shots: StoryboardShot[], episode: Episode) { + const targetDuration = this.resolveLiveActionDirectorTargetDuration(shots, episode); + const roles = shots.map((shot, index) => this.classifyLiveActionDirectorShotRole(shot, index, shots.length)); + const durations = this.distributeLiveActionDirectorDurations(roles, targetDuration); + const plans = new Map(); + + shots.forEach((shot, index) => { + const role = roles[index]; + const previous = index > 0 ? shots[index - 1] : null; + const next = index < shots.length - 1 ? shots[index + 1] : null; + const sceneGroupId = this.liveActionSceneGroupId(shots, index); + + plans.set(shot.id.toString(), { + plan_version: LIVE_ACTION_DIRECTOR_PLAN_VERSION, + scene_group_id: sceneGroupId, + scene_beat: this.liveActionDirectorSceneBeat(shot, index, shots.length), + shot_role: role, + shot_size: this.liveActionDirectorShotSize(role, shot), + blocking: this.liveActionDirectorBlocking(role, shot), + continuity_in: this.liveActionDirectorContinuityIn(shot, previous, role), + continuity_out: this.liveActionDirectorContinuityOut(shot, next, role), + edit_intent: this.liveActionDirectorEditIntent(role, shot, index, shots.length), + sound_bridge: this.liveActionDirectorSoundBridge(shot, previous, next), + duration_seconds: durations[index] + }); + }); + + return plans; + } + + private buildFallbackLiveActionDirectorPlan(shot: StoryboardShot, duration: number): LiveActionDirectorPlan { + const role = this.classifyLiveActionDirectorShotRole(shot, Math.max(0, shot.shot_no - 1), Math.max(1, shot.shot_no)); + + return { + plan_version: LIVE_ACTION_DIRECTOR_PLAN_VERSION, + scene_group_id: `scene-${Math.max(1, shot.shot_no)}`, + scene_beat: shot.scene_name ?? shot.scene_type ?? 'live action beat', + shot_role: role, + shot_size: this.liveActionDirectorShotSize(role, shot), + blocking: this.liveActionDirectorBlocking(role, shot), + continuity_in: 'start from the previous shot emotion and keep the same spatial direction', + continuity_out: 'end with a clear action or eyeline that can cut to the next shot', + edit_intent: this.liveActionDirectorEditIntent(role, shot, 0, 1), + sound_bridge: this.liveActionDirectorSoundBridge(shot, null, null), + duration_seconds: Number(Math.max(LIVE_ACTION_DIRECTOR_MIN_SHOT_SECONDS, Math.min(LIVE_ACTION_DIRECTOR_MAX_SHOT_SECONDS, duration)).toFixed(2)) + }; + } + + private resolveLiveActionDirectorTargetDuration(shots: StoryboardShot[], episode: Episode) { + const currentTotal = this.totalDuration(shots); + const requested = Number(episode.target_duration ?? 0); + const baseTarget = Number.isFinite(requested) && requested > 0 ? requested : currentTotal; + const minTotal = shots.length * 3; + const maxTotal = shots.length * LIVE_ACTION_DIRECTOR_MAX_SHOT_SECONDS; + + return Number(Math.max(minTotal, Math.min(maxTotal, baseTarget)).toFixed(2)); + } + + private distributeLiveActionDirectorDurations(roles: LiveActionDirectorShotRole[], targetDuration: number) { + const limits = roles.map((role) => this.liveActionDirectorDurationLimits(role)); + const weights = roles.map((role) => this.liveActionDirectorDurationWeight(role)); + const weightTotal = weights.reduce((sum, value) => sum + value, 0) || 1; + const durations = weights.map((weight, index) => { + const raw = targetDuration * (weight / weightTotal); + const [min, max] = limits[index]; + + return Math.max(min, Math.min(max, raw)); + }); + + this.balanceLiveActionDirectorDurations(durations, limits, targetDuration); + return durations.map((duration) => Number(duration.toFixed(2))); + } + + private balanceLiveActionDirectorDurations( + durations: number[], + limits: Array<[number, number]>, + targetDuration: number + ) { + for (let attempt = 0; attempt < 12; attempt += 1) { + const current = durations.reduce((sum, duration) => sum + duration, 0); + const diff = targetDuration - current; + + if (Math.abs(diff) < 0.05) return; + + const direction = diff > 0 ? 1 : -1; + const adjustable = durations + .map((duration, index) => ({ + index, + room: direction > 0 ? limits[index][1] - duration : duration - limits[index][0] + })) + .filter((item) => item.room > 0.01); + const roomTotal = adjustable.reduce((sum, item) => sum + item.room, 0); + + if (roomTotal <= 0) return; + + for (const item of adjustable) { + const change = Math.min(item.room, Math.abs(diff) * (item.room / roomTotal)); + + durations[item.index] += direction * change; + } + } + } + + private liveActionDirectorDurationLimits(role: LiveActionDirectorShotRole): [number, number] { + if (role === 'establishing') return [4.8, 10]; + if (role === 'dialogue') return [4.2, 10]; + if (role === 'movement') return [4, 10]; + if (role === 'reaction') return [3, 10]; + if (role === 'insert') return [2.5, 5]; + return [4, 10]; + } + + private liveActionDirectorDurationWeight(role: LiveActionDirectorShotRole) { + if (role === 'establishing') return 1.35; + if (role === 'dialogue') return 1.18; + if (role === 'movement') return 1.1; + if (role === 'reaction') return 0.82; + if (role === 'insert') return 0.72; + return 1.25; + } + + private classifyLiveActionDirectorShotRole( + shot: StoryboardShot, + index: number, + total: number + ): LiveActionDirectorShotRole { + const text = [ + shot.scene_name, + shot.scene_type, + shot.location_desc, + shot.visual_desc, + shot.action_desc, + shot.dialogue_text, + shot.narration_text, + shot.effect_type + ].filter(Boolean).join(' ').toLowerCase(); + + if (index === 0) return 'establishing'; + if (index === total - 1 || /cliffhanger|reveal|真相|反转|那张皮|不是人|曝光|揭开|一闪/.test(text)) return 'reveal'; + if (shot.dialogue_text) return 'dialogue'; + if (/特写|铜镜|镜子|手机|文件|银行卡|黑金卡|手部|手指|手掌|手腕|手势|眼睛|眼部|道具|insert|detail|close-up/.test(text)) return 'insert'; + if (Number(shot.emotion_score ?? 0) >= 8 || /震惊|恐惧|怀疑|害怕|凝住|反应|reaction/.test(text)) return 'reaction'; + if (/走|跑|进|出|推门|开门|下车|靠近|离开|跟随|tracking|follow|movement/.test(text)) return 'movement'; + + return 'movement'; + } + + private liveActionSceneGroupId(shots: StoryboardShot[], index: number) { + let group = 1; + let previousKey = this.liveActionSceneKey(shots[0]); + + for (let i = 1; i <= index; i += 1) { + const key = this.liveActionSceneKey(shots[i]); + + if (key !== previousKey) { + group += 1; + previousKey = key; + } + } + + return `scene-${group}`; + } + + private liveActionSceneKey(shot: StoryboardShot) { + return this.normalizeDirectorSceneKey(shot.location_desc ?? shot.scene_name ?? 'scene'); + } + + private normalizeDirectorSceneKey(value: string) { + const text = value.toLowerCase(); + + if (/巷|街|雨|alley|street/.test(text)) return 'street-alley'; + if (/书斋|屋|室内|房|study|room|indoor/.test(text)) return 'interior-room'; + if (/车|门口|走廊|corridor|car/.test(text)) return 'threshold'; + + return text.replace(/\s+/g, '-').slice(0, 24) || 'scene'; + } + + private liveActionDirectorSceneBeat(shot: StoryboardShot, index: number, total: number) { + if (index === 0) return 'establish space and mood'; + if (index === total - 1) return 'deliver final reveal or hook'; + return shot.scene_name ?? shot.scene_type ?? `beat ${index + 1}`; + } + + private liveActionDirectorShotSize(role: LiveActionDirectorShotRole, shot: StoryboardShot) { + if (role === 'establishing') return 'wide-to-medium establishing shot with readable environment'; + if (role === 'dialogue') return this.isHighRiskLipSyncShot(shot) + ? 'medium over-the-shoulder or three-quarter two-shot' + : 'medium two-shot with clear eyeline'; + if (role === 'reaction') return 'controlled medium close-up reaction shot'; + if (role === 'insert') return 'insert close-up on the object, hands, mirror or key detail'; + if (role === 'reveal') return 'slow reveal close-up with subject separation'; + return 'medium tracking shot that keeps body movement readable'; + } + + private liveActionDirectorBlocking(role: LiveActionDirectorShotRole, shot: StoryboardShot) { + const action = shot.action_desc ?? shot.visual_desc ?? '角色完成当前动作'; + + if (role === 'establishing') return `let the environment breathe first, then reveal the actor through foreground depth; ${action}`; + if (role === 'dialogue') return `keep both actors in the same spatial axis, one listens while the other acts; ${action}`; + if (role === 'reaction') return `hold on the actor reaction for a clear emotional beat before cutting; ${action}`; + if (role === 'insert') return `isolate the key object or hand action as a motivated cutaway; ${action}`; + if (role === 'reveal') return `delay the reveal, let the actor or object turn into frame slowly; ${action}`; + + return `continue the previous movement direction and complete one physical action; ${action}`; + } + + private liveActionDirectorContinuityIn( + shot: StoryboardShot, + previous: StoryboardShot | null, + role: LiveActionDirectorShotRole + ) { + if (!previous) return 'start with an establishing hold before the main action'; + if (this.liveActionSceneKey(previous) !== this.liveActionSceneKey(shot)) { + return 'use a sound bridge or motivated match cut before entering the new location'; + } + if (role === 'insert') return 'cut from the previous eyeline or hand movement into this detail'; + if (role === 'reaction') return 'cut from the previous line or reveal into this facial reaction'; + + return 'continue the same screen direction, lighting and actor position from the previous shot'; + } + + private liveActionDirectorContinuityOut( + shot: StoryboardShot, + next: StoryboardShot | null, + role: LiveActionDirectorShotRole + ) { + if (!next) return 'end on a held hook frame for the viewer to process'; + if (this.liveActionSceneKey(next) !== this.liveActionSceneKey(shot)) { + return 'finish on a movement, eyeline or sound cue that motivates the scene change'; + } + if (role === 'dialogue') return 'end on the listener reaction or actor eyeline to motivate the reverse shot'; + if (role === 'insert') return 'end on the object detail long enough for the next reaction cut'; + + return 'leave a clear eyeline, hand action or body turn for the next shot to match'; + } + + private liveActionDirectorEditIntent( + role: LiveActionDirectorShotRole, + shot: StoryboardShot, + index: number, + total: number + ) { + if (role === 'establishing') return 'establish geography, mood and the first subject before the story beat'; + if (role === 'dialogue') return 'hold performance and eyeline so the line feels acted, not pasted over a still image'; + if (role === 'reaction') return 'let the audience read emotion before the next information beat'; + if (role === 'insert') return 'provide a motivated detail cutaway that hides AI motion limits'; + if (role === 'reveal') return index === total - 1 + ? 'build a final hook with a delayed reveal and a clean ending frame' + : 'reveal one important clue while preserving suspense'; + + return 'connect two story beats with continuous physical movement'; + } + + private liveActionDirectorSoundBridge( + shot: StoryboardShot, + previous: StoryboardShot | null, + next: StoryboardShot | null + ) { + const text = [ + previous?.location_desc, + shot.location_desc, + shot.action_desc, + shot.effect_type, + next?.location_desc + ].filter(Boolean).join(' '); + + if (/雨|雨夜|湿|街|巷/.test(text)) return 'carry rain ambience across the cut'; + if (/门|推开|关上|车门/.test(text)) return 'use a door or threshold sound to motivate the edit'; + if (/恐惧|震惊|真相|反转|不是人|皮/.test(text)) return 'use heartbeat and a short reveal sting under the cut'; + + return 'carry room tone or low underscore across the edit'; + } + + private applyDirectorActionPlan(action: string, directorPlan: LiveActionDirectorPlan) { + return [ + action, + `导演调度:${directorPlan.blocking}`, + `剪辑功能:${directorPlan.edit_intent}` + ].join('。'); + } + + private applyDirectorCameraPlan(camera: string, directorPlan: LiveActionDirectorPlan) { + return [ + directorPlan.shot_size, + camera, + `continuity in: ${directorPlan.continuity_in}`, + `continuity out: ${directorPlan.continuity_out}`, + 'preserve screen direction, eyeline and lighting continuity' + ].join(', '); + } + + private applyDirectorPerformancePlan(performance: string, directorPlan: LiveActionDirectorPlan) { + return [ + performance, + `director_plan_version: ${directorPlan.plan_version}`, + `shot_role: ${directorPlan.shot_role}`, + `edit_intent: ${directorPlan.edit_intent}`, + `sound_bridge: ${directorPlan.sound_bridge}` + ].join('\n'); + } + + private async ensureShotRouteScoresAndLipSyncPolicy( + shot: StoryboardShot, + scores: AiRouterShotScores, + lipSyncPolicy?: LiveActionLipSyncPolicy + ) { + const lipSyncUpdate = lipSyncPolicy + ? this.createShotLipSyncPolicyUpdate(shot, lipSyncPolicy) + : {}; + + if ( + shot.scene_type === scores.scene_type && + shot.importance_score === scores.importance_score && + shot.emotion_score === scores.emotion_score && + shot.action_score === scores.action_score && + shot.route_tier === scores.route_tier && + Object.keys(lipSyncUpdate).length === 0 + ) { + return shot; + } + + return this.prisma.storyboardShot.update({ + where: { id: shot.id }, + data: { + scene_type: scores.scene_type, + importance_score: scores.importance_score, + emotion_score: scores.emotion_score, + action_score: scores.action_score, + route_tier: scores.route_tier, + ...lipSyncUpdate + } + }); + } + + private async ensureShotRouteScores(shot: StoryboardShot, scores: AiRouterShotScores) { + return this.ensureShotRouteScoresAndLipSyncPolicy(shot, scores); + } + + private async storeMockKeyframe( + project: Project, + shot: StoryboardShot, + prompt: string, + isMock: boolean + ) { + const svg = this.createMockKeyframeSvg(shot, prompt); + const buffer = Buffer.from(svg); + const stored = await this.storage.storePrivateFile( + { + originalname: `live-action-keyframe-${shot.id.toString()}.svg`, + mimetype: 'image/svg+xml', + size: buffer.length, + buffer + } as Express.Multer.File, + 'live-action-keyframes' + ); + + return this.prisma.asset.create({ + data: { + user_id: project.user_id, + project_id: project.id, + asset_type: 'image', + file_path: stored.file_path, + file_url: null, + mime_type: 'image/svg+xml', + width: LIVE_ACTION_WIDTH, + height: LIVE_ACTION_HEIGHT, + size: stored.size, + hash: stored.hash, + visibility: 'private', + status: isMock ? 'mock' : 'active' + } + }); + } + + private async storeMockVideoClip( + project: Project, + episode: Episode, + shot: StoryboardShot, + duration: number, + prompt: string + ) { + const buffer = await this.createMockVideoClipBuffer(shot, duration, prompt); + return this.storeVideoClipAssetFromBuffer( + project, + shot, + duration, + { buffer, mimeType: 'video/mp4', assetUrl: null }, + true + ); + } + + private async storeVideoClipAssetFromBuffer( + project: Project, + shot: StoryboardShot, + duration: number, + file: { buffer: Buffer; mimeType: string; assetUrl: string | null }, + isMock: boolean + ) { + const storedMimeType = this.normalizeVideoMimeType(file.mimeType); + const stored = await this.storage.storePrivateFile( + { + originalname: `live-action-shot-${shot.id.toString()}${this.videoExtensionFromMime(storedMimeType)}`, + mimetype: storedMimeType, + size: file.buffer.length, + buffer: file.buffer + } as Express.Multer.File, + 'live-action-video-clips' + ); + + return this.prisma.asset.create({ + data: { + user_id: project.user_id, + project_id: project.id, + asset_type: 'video_clip', + file_path: stored.file_path, + file_url: file.assetUrl, + mime_type: storedMimeType, + width: LIVE_ACTION_WIDTH, + height: LIVE_ACTION_HEIGHT, + duration, + size: stored.size, + hash: stored.hash, + visibility: 'private', + status: isMock ? 'mock' : 'active' + } + }); + } + + private async storeProviderVideoClip( + project: Project, + shot: StoryboardShot, + duration: number, + providerOutput: Record + ) { + const contentBase64 = this.stringifyText(providerOutput.content_base64); + const mimeType = this.normalizeVideoMimeType(this.stringifyText(providerOutput.mime_type)); + const assetUrl = this.stringifyText(providerOutput.asset_url); + const file = contentBase64 + ? { + buffer: this.decodeBase64(contentBase64, 'VideoProvider content_base64'), + mimeType, + assetUrl: assetUrl || null + } + : await this.providerVideoBufferFromOutput(providerOutput); + + return this.storeVideoClipAssetFromBuffer(project, shot, duration, file, false); + } + + private async providerVideoBufferFromOutput(providerOutput: Record) { + const contentBase64 = this.stringifyText(providerOutput.content_base64); + const mimeType = this.normalizeVideoMimeType(this.stringifyText(providerOutput.mime_type)); + const assetUrl = this.stringifyText(providerOutput.asset_url); + + if (contentBase64) { + return { + buffer: this.decodeBase64(contentBase64, 'VideoProvider content_base64'), + mimeType, + assetUrl: assetUrl || null + }; + } + if (/^https?:\/\//i.test(assetUrl)) { + const downloaded = await this.downloadProviderAsset(assetUrl); + + return { + buffer: downloaded.buffer, + mimeType: this.normalizeVideoMimeType(downloaded.mimeType), + assetUrl + }; + } + + throw new BadRequestException('VideoProvider did not return video content or downloadable URL'); + } + + private async createRealVideoSourceImageInput(keyframeAssetId: bigint) { + const asset = await this.prisma.asset.findUnique({ where: { id: keyframeAssetId } }); + + if (!asset) { + throw new NotFoundException('Keyframe asset not found'); + } + + const mimeType = this.normalizeImageMimeType(asset.mime_type); + + if (!mimeType) { + throw new BadRequestException('LIVE_ACTION_KEYFRAME_RASTER_REQUIRED'); + } + + const buffer = await this.storage.readPrivateFile(asset.file_path); + + if (buffer.length > 14 * 1024 * 1024) { + throw new BadRequestException('LIVE_ACTION_KEYFRAME_TOO_LARGE_FOR_VIDEO_PROVIDER'); + } + + return { + source_image_data_uri: `data:${mimeType};base64,${buffer.toString('base64')}`, + source_image_mime_type: mimeType + }; + } + + private async downloadProviderAsset(url: string) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 180000); + + try { + const response = await fetch(url, { signal: controller.signal }); + + if (!response.ok) { + throw new BadRequestException(`Provider video download failed: HTTP ${response.status}`); + } + + const mimeType = response.headers.get('content-type') || ''; + const buffer = Buffer.from(await response.arrayBuffer()); + + if (!buffer.length) { + throw new BadRequestException('Provider video download returned empty content'); + } + + return { buffer, mimeType }; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new BadRequestException('Provider video download timeout'); + } + + throw new BadRequestException(`Provider video download failed: ${this.toError(error).message}`); + } finally { + clearTimeout(timeout); + } + } + + private async resolveVideoRoute( + project: Project, + shot: StoryboardShot, + duration: number, + dto: LiveActionGenerateDto, + user: AuthRequestUser, + options: LiveActionGenerateOptions = {} + ): Promise { + const manualProviderCode = this.normalizeOptionalText(dto.provider_code, 100); + + await this.ensureMockVideoProvider(); + + return this.aiRouter.resolveLiveActionVideoRoute({ + project, + shot, + duration, + language: 'zh-CN', + manual_provider_code: manualProviderCode, + allow_manual_override: Boolean(manualProviderCode && (user.role === 'admin' || options.allowSystemProviderOverride)), + max_cost_per_clip: this.optionalNumberFromJson(dto.max_cost_per_clip) + }); + } + + private decodeBase64(value: string, label: string) { + const buffer = Buffer.from(value, 'base64'); + + if (!buffer.length) { + throw new BadRequestException(`${label} is empty`); + } + + return buffer; + } + + private async assertVideoProviderCanRun(dto: LiveActionGenerateDto) { + const providerCode = this.normalizeOptionalText(dto.provider_code, 100) ?? 'mock-video'; + + if (providerCode === 'mock-video') { + await this.ensureMockVideoProvider(); + return; + } + + const provider = await this.prisma.providerConfig.findUnique({ + where: { + provider_type_provider_code: { + provider_type: 'VideoProvider', + provider_code: providerCode + } + } + }); + + if (!provider) { + throw new BadRequestException('LIVE_ACTION_VIDEO_PROVIDER_NOT_FOUND'); + } + if (!provider.is_enabled) { + throw new BadRequestException('LIVE_ACTION_VIDEO_PROVIDER_DISABLED'); + } + if (provider.mode === 'real' && dto.confirm_real_video !== true) { + throw new BadRequestException('REAL_VIDEO_GENERATION_CONFIRMATION_REQUIRED'); + } + } + + private createPreflightIssue(code: string, message: string, shot: StoryboardShot): LiveActionPreflightIssue { + return { + code, + message, + severity: 'blocker', + shot_id: shot.id.toString(), + shot_no: shot.shot_no + }; + } + + private async findVideoProviderConfig(providerCode: string) { + if (providerCode === 'mock-video') { + return this.ensureMockVideoProvider(); + } + + return this.prisma.providerConfig.findUnique({ + where: { + provider_type_provider_code: { + provider_type: 'VideoProvider', + provider_code: providerCode + } + } + }); + } + + private videoProviderRequiresConfirmation(providerCode: string, providerMode: string | null | undefined) { + return providerCode !== 'mock-video' || providerMode === 'real'; + } + + private videoProviderRequiresRasterSource(providerCode: string, providerMode: string | null | undefined) { + return this.videoProviderRequiresConfirmation(providerCode, providerMode); + } + + private liveActionPreflightNextStep(blockers: LiveActionPreflightIssue[]) { + const codes = new Set(blockers.map((issue) => issue.code)); + + if (codes.has('STORYBOARD_SHOTS_REQUIRED')) return 'storyboard_confirm'; + if (codes.has('PREPARED_LIVE_ACTION_SHOT_REQUIRED')) return 'live_action_shots_prepare'; + if (codes.has('LIVE_ACTION_KEYFRAME_REQUIRED')) return 'live_action_keyframes_generate'; + if (codes.has('LIVE_ACTION_KEYFRAME_RASTER_REQUIRED')) return 'real_keyframe_required'; + if (codes.has('REAL_VIDEO_GENERATION_CONFIRMATION_REQUIRED')) return 'confirm_real_video'; + if (codes.has('LIVE_ACTION_VIDEO_COST_LIMIT_EXCEEDED')) return 'raise_cost_limit_or_switch_provider'; + if (codes.has('LIVE_ACTION_VIDEO_PROVIDER_NOT_FOUND') || codes.has('LIVE_ACTION_VIDEO_PROVIDER_DISABLED')) return 'provider_config'; + + return 'generate_video_clips'; + } + + private async createMockVideoClipBuffer(shot: StoryboardShot, duration: number, prompt: string) { + const tempDir = await mkdtemp(join(tmpdir(), 'ai-live-action-clip-')); + const outputPath = join(tempDir, 'clip.mp4'); + const color = `0x${this.hashJson({ shot: shot.id.toString(), prompt }).slice(0, 6)}`; + + try { + await execFileAsync('ffmpeg', [ + '-y', + '-hide_banner', + '-loglevel', + 'error', + '-f', + 'lavfi', + '-i', + `color=c=${color}:s=${LIVE_ACTION_WIDTH}x${LIVE_ACTION_HEIGHT}:d=${duration}:r=30`, + '-vf', + 'format=yuv420p', + '-c:v', + 'libx264', + '-preset', + 'veryfast', + '-crf', + '24', + '-movflags', + '+faststart', + outputPath + ]); + + return readFile(outputPath); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + } + + private async concatVideoClipBuffers(buffers: Buffer[], tempPrefix: string) { + if (buffers.length === 0) { + throw new BadRequestException('No video segments can be stitched'); + } + if (buffers.length === 1) { + return buffers[0]; + } + + const tempDir = await mkdtemp(join(tmpdir(), tempPrefix)); + + try { + const segmentPaths = []; + + for (const [index, buffer] of buffers.entries()) { + const segmentPath = join(tempDir, `segment-${String(index + 1).padStart(3, '0')}.mp4`); + await writeFile(segmentPath, buffer); + segmentPaths.push(segmentPath); + } + + const concatPath = join(tempDir, 'segments.txt'); + const outputPath = join(tempDir, 'stitched.mp4'); + await writeFile( + concatPath, + `${segmentPaths.map((path) => `file '${path.replace(/'/g, "'\\''")}'`).join('\n')}\n` + ); + await execFileAsync('ffmpeg', [ + '-y', + '-hide_banner', + '-loglevel', + 'error', + '-f', + 'concat', + '-safe', + '0', + '-i', + concatPath, + '-c', + 'copy', + '-movflags', + '+faststart', + outputPath + ]); + + return readFile(outputPath); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + } + + private async concatVideoClips(shots: StoryboardShot[], postProduction?: LiveActionPostProductionAssets) { + const tempDir = await mkdtemp(join(tmpdir(), 'ai-live-action-render-')); + + try { + const clipPaths: string[] = []; + const normalization: LiveActionClipNormalizationReport[] = []; + + for (const shot of shots) { + if (!shot.video_clip_asset_id) continue; + const overrideClip = this.findPostProductionLipSyncClip(postProduction, shot); + const assetId = overrideClip ? BigInt(overrideClip.output_asset_id) : shot.video_clip_asset_id; + const asset = await this.prisma.asset.findUnique({ where: { id: assetId } }); + + if (!asset) continue; + + const buffer = await this.storage.readPrivateFile(asset.file_path); + const sourcePath = join(tempDir, `source-${String(shot.shot_no).padStart(3, '0')}.mp4`); + await writeFile(sourcePath, buffer); + + const normalized = await this.normalizeLiveActionClipForRender(sourcePath, tempDir, shot, asset); + clipPaths.push(normalized.path); + normalization.push(normalized.report); + } + + if (clipPaths.length === 0) { + throw new BadRequestException('No live action clips can be rendered'); + } + + const concatPath = join(tempDir, 'clips.txt'); + const videoTrackPath = join(tempDir, 'video-track.mp4'); + await writeFile( + concatPath, + `${clipPaths.map((path) => `file '${path.replace(/'/g, "'\\''")}'`).join('\n')}\n` + ); + await execFileAsync('ffmpeg', [ + '-y', + '-hide_banner', + '-loglevel', + 'error', + '-f', + 'concat', + '-safe', + '0', + '-i', + concatPath, + '-c', + 'copy', + '-movflags', + '+faststart', + videoTrackPath + ]); + const outputPath = await this.finalizeLiveActionRenderWithPostProduction( + tempDir, + videoTrackPath, + this.totalDuration(shots), + postProduction + ); + + return { + buffer: await readFile(outputPath), + normalization + }; + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + } + + private findPostProductionLipSyncClip( + postProduction: LiveActionPostProductionAssets | undefined, + shot: StoryboardShot + ) { + if (!postProduction?.lip_sync_clips.length) { + return null; + } + + return postProduction.lip_sync_clips.find((clip) => clip.shot_id === shot.id.toString()) ?? null; + } + + private async prepareLiveActionPostProductionAssets( + user: AuthRequestUser, + project: Project, + episode: Episode, + shots: StoryboardShot[], + dto: LiveActionGenerateDto + ): Promise { + const includeAudio = dto.include_audio !== false; + const includeSubtitle = dto.include_subtitle !== false; + const includeBgm = dto.include_bgm !== false; + const includeSfx = includeBgm && dto.include_sfx !== false; + const lipSyncProviderAvailable = await this.hasLiveActionLipSyncProvider(); + const lipSyncProviderUsable = lipSyncProviderAvailable && dto.include_lip_sync !== false; + const lipSyncBudget = this.buildLiveActionLipSyncBudgetPlan(shots, dto, lipSyncProviderUsable); + const characters = await this.prisma.character.findMany({ + where: { + project_id: project.id, + status: { not: 'deleted' } + }, + orderBy: { id: 'asc' } + }); + const segments = this.buildLiveActionAudioSegments( + shots, + dto, + lipSyncProviderUsable, + lipSyncBudget.decisions, + characters + ); + const audioResult = includeAudio && segments.length > 0 + ? await this.generateLiveActionAudioAsset(user, project, episode, segments, dto) + : null; + const lipSyncClips = await this.generateLiveActionLipSyncAssets( + user, + project, + episode, + shots, + segments, + audioResult?.files ?? [], + dto + ); + const subtitleResult = includeSubtitle && segments.length > 0 + ? await this.generateLiveActionSubtitleAsset(user, project, episode, segments, dto) + : null; + const bgmResult = includeBgm + ? await this.resolveLiveActionBgmAsset(user, project, episode, shots, dto, this.totalDuration(shots)) + : null; + const sfxResult = includeSfx + ? await this.resolveLiveActionSfxAsset(user, project, episode, shots, dto, this.totalDuration(shots)) + : null; + + return { + include_audio: includeAudio, + include_subtitle: includeSubtitle, + include_bgm: includeBgm, + include_sfx: includeSfx, + audio_asset: audioResult?.asset ?? null, + subtitle_asset: subtitleResult?.asset ?? null, + bgm_asset: bgmResult?.asset ?? null, + sfx_asset: sfxResult?.asset ?? null, + audio_task: audioResult?.task ?? null, + subtitle_task: subtitleResult?.task ?? null, + bgm_task: bgmResult?.task ?? null, + sfx_task: sfxResult?.task ?? null, + segments, + subtitle_cues: subtitleResult?.cues ?? [], + bgm_cues: bgmResult?.cues ?? [], + sfx_cues: sfxResult?.cues ?? [], + audio_warnings: audioResult?.warnings ?? [], + audio_is_mock: audioResult?.isMock ?? false, + audio_provider_codes: audioResult?.providerCodes ?? [], + bgm_volume: this.resolveLiveActionBgmVolume(dto, includeAudio && segments.length > 0, bgmResult?.cues ?? []), + sfx_volume: this.resolveLiveActionSfxVolume(dto, includeAudio && segments.length > 0, bgmResult?.cues ?? []), + lip_sync_clips: lipSyncClips, + lip_sync_budget: this.liveActionLipSyncBudgetSummary(lipSyncBudget) + }; + } + + private buildLiveActionAudioSegments( + shots: StoryboardShot[], + dto: LiveActionGenerateDto, + lipSyncProviderAvailable: boolean, + lipSyncBudgetDecisions: Map = new Map(), + characters: Character[] = [] + ) { + const segments: LiveActionAudioSegment[] = []; + let cursor = 0; + let index = 1; + const characterVoices = this.buildLiveActionCharacterVoiceMap(characters); + + for (const shot of shots) { + const duration = this.normalizeShotDuration(shot); + const shotStart = cursor; + const shotEnd = cursor + duration; + const dialogueText = this.normalizeDialogueText(shot.dialogue_text); + const narrationText = this.normalizeDialogueText(shot.narration_text); + const segmentType: LiveActionAudioSegment['segment_type'] = dialogueText ? 'dialogue' : 'narration'; + const parts = dialogueText + ? this.parseLiveActionDialogueParts(shot.dialogue_text, this.liveActionSpeakerName(shot)) + : narrationText + ? [{ speaker_name: '旁白', text: narrationText }] + : []; + const lipSyncPolicy = this.resolveLiveActionLipSyncPolicy( + shot, + lipSyncProviderAvailable, + lipSyncBudgetDecisions.get(shot.id.toString()) + ); + + if (parts.length > 0) { + const timing = this.resolveLiveActionAudioTiming(duration, lipSyncPolicy, segmentType); + const leadOut = duration >= 2.4 ? 0.45 : 0.1; + const voiceWindow = Number(Math.max(1, duration - timing.leadIn - leadOut).toFixed(2)); + const gapSeconds = segmentType === 'dialogue' ? LIVE_ACTION_DIALOGUE_GAP_SECONDS : 0; + const totalGap = Number((Math.max(0, parts.length - 1) * gapSeconds).toFixed(2)); + const allocatableDuration = Number(Math.max(0.4, voiceWindow - totalGap).toFixed(2)); + let currentStart = Number((shotStart + timing.leadIn).toFixed(2)); + let remainingDuration = allocatableDuration; + let remainingWeight = parts.reduce((sum, part) => sum + Math.max(1, part.text.length), 0); + + for (const [partIndex, part] of parts.entries()) { + if (currentStart >= shotEnd) break; + + const weight = Math.max(1, part.text.length); + const isLast = partIndex === parts.length - 1; + const rawDuration = isLast || remainingWeight <= 0 + ? remainingDuration + : (remainingDuration * weight) / remainingWeight; + const targetDuration = Number(Math.max(0.35, rawDuration).toFixed(2)); + const endSeconds = Number(Math.min(shotEnd, currentStart + targetDuration).toFixed(2)); + const actualDuration = Number(Math.max(0.1, endSeconds - currentStart).toFixed(2)); + const voice = this.resolveLiveActionSegmentVoice(part.speaker_name, segmentType, dto, characterVoices); + + segments.push({ + index, + shot_id: shot.id.toString(), + shot_no: shot.shot_no, + segment_type: segmentType, + start_seconds: currentStart, + end_seconds: endSeconds, + target_duration: actualDuration, + speaker_name: part.speaker_name, + text: part.text, + voice_provider_code: voice.voice_provider_code, + voice: voice.voice, + voice_style: voice.voice_style, + character_id: voice.character?.id.toString() ?? null, + lip_sync_required: lipSyncPolicy.lip_sync_required, + lip_sync_strategy: lipSyncPolicy.strategy, + visual_fallback: lipSyncPolicy.visual_fallback, + lip_sync_skip_reason: lipSyncPolicy.lip_sync_skip_reason + }); + index += 1; + currentStart = Number((endSeconds + gapSeconds).toFixed(2)); + remainingDuration = Number(Math.max(0, remainingDuration - actualDuration).toFixed(2)); + remainingWeight -= weight; + } + } + + cursor = shotEnd; + } + + return segments; + } + + private buildLiveActionLipSyncBudgetPlan( + shots: StoryboardShot[], + dto: LiveActionGenerateDto, + providerAvailable: boolean + ): LiveActionLipSyncBudgetPlan { + const maxSeconds = this.resolveLiveActionLipSyncMaxSeconds(dto); + const decisions = new Map(); + const candidates = providerAvailable && dto.include_lip_sync !== false + ? shots + .filter((shot) => this.resolveLiveActionLipSyncPolicy(shot, providerAvailable).lip_sync_required) + .map((shot) => ({ + shot, + estimated_seconds: this.normalizeShotDuration(shot), + priority_score: this.liveActionLipSyncPriorityScore(shot) + })) + : []; + const sorted = [...candidates].sort((left, right) => { + if (right.priority_score !== left.priority_score) return right.priority_score - left.priority_score; + + return left.shot.shot_no - right.shot.shot_no; + }); + const skipped: LiveActionLipSyncBudgetSkip[] = []; + let selectedSeconds = 0; + + for (const candidate of sorted) { + const selectedSecondsBefore = selectedSeconds; + const canSelect = candidate.estimated_seconds > 0 && selectedSeconds + candidate.estimated_seconds <= maxSeconds; + const reason = canSelect ? 'within_lip_sync_budget' : 'lip_sync_budget_exceeded'; + + if (canSelect) { + selectedSeconds = Number((selectedSeconds + candidate.estimated_seconds).toFixed(2)); + } else { + skipped.push({ + shot_id: candidate.shot.id.toString(), + shot_no: candidate.shot.shot_no, + estimated_seconds: candidate.estimated_seconds, + priority_score: candidate.priority_score, + reason + }); + } + decisions.set(candidate.shot.id.toString(), { + selected: canSelect, + reason, + max_seconds: maxSeconds, + estimated_seconds: candidate.estimated_seconds, + priority_score: candidate.priority_score, + selected_seconds_before: selectedSecondsBefore, + selected_seconds_after: selectedSeconds + }); + } + + return { + max_seconds: maxSeconds, + required_count: candidates.length, + selected_count: sorted.length - skipped.length, + required_seconds: Number(candidates.reduce((sum, candidate) => sum + candidate.estimated_seconds, 0).toFixed(2)), + selected_seconds: selectedSeconds, + skipped, + decisions + }; + } + + private liveActionLipSyncBudgetSummary(plan: LiveActionLipSyncBudgetPlan) { + return { + max_seconds: plan.max_seconds, + required_count: plan.required_count, + selected_count: plan.selected_count, + required_seconds: plan.required_seconds, + selected_seconds: plan.selected_seconds, + skipped: plan.skipped + }; + } + + private resolveLiveActionAudioTiming( + duration: number, + lipSyncPolicy: LiveActionLipSyncPolicy, + segmentType: LiveActionAudioSegment['segment_type'] + ) { + if (lipSyncPolicy.visual_fallback && segmentType === 'dialogue') { + return { + leadIn: Number(Math.min(Math.max(duration * 0.34, 1.4), Math.max(0.15, duration - 1.2), 2.2).toFixed(2)) + }; + } + + return { + leadIn: duration >= 2.4 ? 0.55 : 0.15 + }; + } + + private async generateLiveActionAudioAsset( + user: AuthRequestUser, + project: Project, + episode: Episode, + segments: LiveActionAudioSegment[], + dto: LiveActionGenerateDto + ) { + const existing = dto.force ? null : await this.findLatestLiveActionOutputAsset(episode.id, 'live_action_audio_generate'); + + if (existing) { + const input = this.jsonObject(existing.task.input_json ?? null); + + return { + asset: existing.asset, + task: existing.task, + isMock: existing.asset.status === 'mock', + warnings: this.readLiveActionAudioWarnings(input.audio_warnings), + providerCodes: this.stringArray(input.provider_codes), + files: [] + }; + } + + const task = await this.createRenderTask( + project.id, + episode.id, + null, + 'live_action_audio_generate', + { + episode_id: episode.id.toString(), + segment_count: segments.length, + segments: segments.map((segment) => this.liveActionAudioSegmentTaskInput(segment)), + requested_by_user_id: user.id + } + ); + let files: LiveActionAudioSegmentFile[]; + let mixed: { buffer: Buffer; mimeType: string; duration: number }; + let asset: Asset; + + try { + const batch = await this.providersService.executeProviderBatch( + { + provider_type: 'VoiceProvider', + purpose: `live-action-episode-${episode.id.toString()}-tts-batch`, + project_id: project.id.toString(), + task_id: task.id.toString(), + allow_fallback: true, + return_binary: true + }, + segments.map((segment) => ({ + preferred_provider_code: segment.voice_provider_code ?? undefined, + purpose: `live-action-episode-${episode.id.toString()}-tts-${segment.index}`, + input_json: this.liveActionAudioProviderInput(segment) + })) + ); + + files = await Promise.all( + batch.results.map(async (result, resultIndex) => { + const segment = segments[resultIndex]; + const output = this.jsonObject(result.result); + const providerDuration = Number(output.duration) || segment.text.length / 5; + const duration = result.provider.mode === 'mock' + ? segment.target_duration + : Number(Math.max(0.5, providerDuration).toFixed(3)); + const audioFile = await this.liveActionAudioFileFromProviderOutput( + output, + episode.id, + duration, + result.provider.mode === 'mock' + ); + + return { + index: segment.index, + segment, + buffer: audioFile.buffer, + mimeType: audioFile.mimeType, + duration, + isMock: audioFile.isMock, + provider_code: result.provider.provider_code, + cost_actual: Number(result.provider_log.cost_actual ?? 0), + asset_url: audioFile.assetUrl + }; + }) + ); + mixed = await this.mixLiveActionAudioSegments(files); + const stored = await this.storage.storePrivateFile( + { + originalname: `episode-${episode.id.toString()}-live-action-dialogue.wav`, + mimetype: mixed.mimeType, + size: mixed.buffer.length, + buffer: mixed.buffer + } as Express.Multer.File, + 'generated-audio' + ); + asset = await this.prisma.asset.create({ + data: { + user_id: project.user_id, + project_id: project.id, + asset_type: 'audio', + file_path: stored.file_path, + file_url: null, + mime_type: mixed.mimeType, + duration: mixed.duration, + size: stored.size, + hash: stored.hash, + visibility: 'private', + status: files.every((file) => file.isMock) ? 'mock' : 'active' + } + }); + + const warnings = this.buildLiveActionAudioWarnings(files); + await this.prisma.renderTask.update({ + where: { id: task.id }, + data: { + status: 'success', + output_asset_id: asset.id, + cost_actual: files.reduce((sum, file) => sum + file.cost_actual, 0), + input_json: { + episode_id: episode.id.toString(), + segment_count: segments.length, + segments: segments.map((segment) => this.liveActionAudioSegmentTaskInput(segment)), + segment_results: files.map((file) => ({ + index: file.index, + provider_code: file.provider_code, + actual_duration: file.duration, + target_duration: file.segment.target_duration, + is_mock: file.isMock, + cost_actual: file.cost_actual + })), + provider_codes: this.uniqueStrings(files.map((file) => file.provider_code)), + audio_warnings: warnings, + duration_seconds: mixed.duration, + requested_by_user_id: user.id + } as unknown as Prisma.InputJsonObject, + finished_at: new Date() + } + }); + + return { + asset, + task: { ...task, status: 'success', output_asset_id: asset.id } as RenderTask, + isMock: files.every((file) => file.isMock), + warnings, + providerCodes: this.uniqueStrings(files.map((file) => file.provider_code)), + files + }; + } catch (error) { + await this.prisma.renderTask.update({ + where: { id: task.id }, + data: { + status: 'failed', + error_code: this.toError(error).name, + error_message: this.toError(error).message, + finished_at: new Date() + } + }); + throw error; + } + } + + private async generateLiveActionLipSyncAssets( + user: AuthRequestUser, + project: Project, + episode: Episode, + shots: StoryboardShot[], + segments: LiveActionAudioSegment[], + audioFiles: LiveActionAudioSegmentFile[], + dto: LiveActionGenerateDto + ): Promise { + if (dto.include_lip_sync === false || segments.length === 0 || audioFiles.length === 0) { + return []; + } + + const provider = await this.findLiveActionLipSyncProviderConfig(dto); + + if (!provider) { + return []; + } + if (provider.mode === 'real' && dto.confirm_real_video !== true) { + throw new BadRequestException('REAL_LIP_SYNC_PROVIDER_CONFIRMATION_REQUIRED'); + } + + const clips: LiveActionLipSyncClip[] = []; + const shotsById = new Map(shots.map((shot) => [shot.id.toString(), shot])); + const audioBySegmentIndex = new Map(audioFiles.map((file) => [file.index, file])); + + for (const segment of segments) { + if (segment.lip_sync_strategy !== 'provider_lipsync') continue; + + const shot = shotsById.get(segment.shot_id); + const audioFile = audioBySegmentIndex.get(segment.index); + + if (!shot?.video_clip_asset_id || !audioFile) continue; + + const sourceAsset = await this.prisma.asset.findUnique({ where: { id: shot.video_clip_asset_id } }); + + if (!sourceAsset) continue; + + const sourceVideoBuffer = await this.storage.readPrivateFile(sourceAsset.file_path); + const duration = this.normalizeShotDuration(shot); + const assetBridge = await this.createLiveActionLipSyncAssetBridge( + provider, + sourceAsset, + audioFile + ); + const taskInput: Prisma.InputJsonObject = { + episode_id: episode.id.toString(), + shot_id: shot.id.toString(), + shot_no: shot.shot_no, + source_video_asset_id: sourceAsset.id.toString(), + audio_segment_index: segment.index, + text: segment.text, + start_seconds: segment.start_seconds, + target_duration: segment.target_duration, + provider: provider.provider_code, + provider_mode: provider.mode, + lip_sync_strategy: segment.lip_sync_strategy, + requested_by_user_id: user.id, + asset_bridge: this.toJsonValue(assetBridge.audit) + }; + const task = await this.createRenderTask(project.id, episode.id, shot.id, 'live_action_lip_sync_generate', taskInput); + + try { + const providerResult = await this.providersService.executeProvider({ + provider_type: 'LipSyncProvider', + preferred_provider_code: provider.provider_code, + purpose: `live-action-lipsync-shot-${shot.id.toString()}-${segment.index}`, + project_id: project.id.toString(), + task_id: task.id.toString(), + allow_fallback: false, + return_binary: true, + input_json: { + shot_id: shot.id.toString(), + shot_no: shot.shot_no, + duration, + target_duration: segment.target_duration, + start_seconds: segment.start_seconds, + text: segment.text, + ...assetBridge.input_json, + video_data_uri: this.mediaDataUri(this.normalizeVideoMimeType(sourceAsset.mime_type), sourceVideoBuffer), + audio_data_uri: this.mediaDataUri(this.normalizeAudioMimeType(audioFile.mimeType), audioFile.buffer) + } + }); + const output = this.jsonObject(providerResult.result); + const providerMode = providerResult.provider.mode; + const outputFile = providerMode === 'mock' + ? { + buffer: sourceVideoBuffer, + mimeType: this.normalizeVideoMimeType(sourceAsset.mime_type), + assetUrl: sourceAsset.file_url + } + : await this.providerVideoBufferFromOutput(output); + const outputAsset = await this.storeVideoClipAssetFromBuffer( + project, + shot, + duration, + outputFile, + providerMode === 'mock' + ); + const providerResultSummary = this.jsonObject(output); + + delete providerResultSummary.content_base64; + + await this.prisma.renderTask.update({ + where: { id: task.id }, + data: { + status: 'success', + output_asset_id: outputAsset.id, + cost_actual: providerResult.provider_log.cost_actual ?? 0, + input_json: { + ...taskInput, + output_asset_id: outputAsset.id.toString(), + provider_result: this.toJsonValue(providerResultSummary) + }, + finished_at: new Date() + } + }); + clips.push({ + shot_id: shot.id.toString(), + shot_no: shot.shot_no, + source_asset_id: sourceAsset.id.toString(), + output_asset_id: outputAsset.id.toString(), + task_id: task.id.toString(), + provider_code: providerResult.provider.provider_code, + provider_mode: providerMode, + cost_actual: Number(providerResult.provider_log.cost_actual ?? 0), + lip_sync_strategy: segment.lip_sync_strategy + }); + } catch (error) { + await this.prisma.renderTask.update({ + where: { id: task.id }, + data: { + status: 'failed', + error_code: this.toError(error).name, + error_message: this.toError(error).message, + finished_at: new Date() + } + }); + throw error; + } + } + + return clips; + } + + private async createLiveActionLipSyncAssetBridge( + provider: ProviderConfig, + sourceAsset: Asset, + audioFile: LiveActionAudioSegmentFile + ) { + const config = this.jsonObject(provider.config_json); + const requiresPublicUrls = this.booleanFromJson(config.requires_public_urls) === true; + const expiresInSeconds = this.resolveLiveActionProviderAssetUrlExpires(config); + let videoUrl = this.publicHttpUrl(sourceAsset.file_url); + let audioUrl = this.publicHttpUrl(audioFile.asset_url); + let videoUrlSource: 'existing' | 'temporary' | 'none' = videoUrl ? 'existing' : 'none'; + let audioUrlSource: 'existing' | 'temporary' | 'none' = audioUrl ? 'existing' : 'none'; + let temporaryAudioFilePath: string | null = null; + + if (requiresPublicUrls && !videoUrl) { + videoUrl = this.storage.createTemporaryPublicUrl({ + filePath: sourceAsset.file_path, + mimeType: this.normalizeVideoMimeType(sourceAsset.mime_type), + expiresInSeconds + }); + videoUrlSource = 'temporary'; + } + if (requiresPublicUrls && !audioUrl) { + const stored = await this.storage.storePrivateFile( + { + originalname: `lip-sync-audio-${audioFile.index}.wav`, + mimetype: this.normalizeAudioMimeType(audioFile.mimeType), + size: audioFile.buffer.length, + buffer: audioFile.buffer + } as Express.Multer.File, + 'provider-bridge-audio' + ); + + temporaryAudioFilePath = stored.file_path; + audioUrl = this.storage.createTemporaryPublicUrl({ + filePath: stored.file_path, + mimeType: this.normalizeAudioMimeType(audioFile.mimeType), + expiresInSeconds + }); + audioUrlSource = 'temporary'; + } + + const inputJson: Record = {}; + + if (videoUrl) inputJson.video_url = videoUrl; + if (audioUrl) inputJson.audio_url = audioUrl; + + return { + input_json: inputJson, + audit: { + requires_public_urls: requiresPublicUrls, + expires_in_seconds: expiresInSeconds, + video_url_source: videoUrlSource, + audio_url_source: audioUrlSource, + temporary_audio_file_path: temporaryAudioFilePath + } + }; + } + + private async generateLiveActionSubtitleAsset( + user: AuthRequestUser, + project: Project, + episode: Episode, + segments: LiveActionAudioSegment[], + dto: LiveActionGenerateDto + ) { + const existing = dto.force ? null : await this.findLatestLiveActionOutputAsset(episode.id, 'live_action_subtitle_generate'); + + if (existing) { + return { + asset: existing.asset, + task: existing.task, + cues: this.buildLiveActionSubtitleCues(segments, dto) + }; + } + + const cues = this.buildLiveActionSubtitleCues(segments, dto); + const srt = this.stringifyLiveActionSrt(cues); + const task = await this.createRenderTask( + project.id, + episode.id, + null, + 'live_action_subtitle_generate', + { + episode_id: episode.id.toString(), + cue_count: cues.length, + cues, + requested_by_user_id: user.id + } + ); + const stored = await this.storage.storePrivateFile( + { + originalname: `episode-${episode.id.toString()}-live-action.srt`, + mimetype: 'application/x-subrip', + size: Buffer.byteLength(srt), + buffer: Buffer.from(srt, 'utf8') + } as Express.Multer.File, + 'generated-subtitles' + ); + const asset = await this.prisma.asset.create({ + data: { + user_id: project.user_id, + project_id: project.id, + asset_type: 'subtitle', + file_path: stored.file_path, + file_url: null, + mime_type: 'application/x-subrip', + duration: this.totalDuration(await this.loadStoryboardShots(episode.id)), + size: stored.size, + hash: stored.hash, + visibility: 'private', + status: 'active' + } + }); + + await this.prisma.renderTask.update({ + where: { id: task.id }, + data: { + status: 'success', + output_asset_id: asset.id, + finished_at: new Date() + } + }); + + return { asset, task: { ...task, status: 'success', output_asset_id: asset.id } as RenderTask, cues }; + } + + private async resolveLiveActionBgmAsset( + user: AuthRequestUser, + project: Project, + episode: Episode, + shots: StoryboardShot[], + dto: LiveActionGenerateDto, + duration: number + ) { + const requestedAssetId = this.normalizeOptionalText(dto.bgm_asset_id, 100); + const cues = this.buildLiveActionBgmCues(shots); + + if (requestedAssetId) { + const asset = await this.prisma.asset.findUnique({ where: { id: this.parseId(requestedAssetId, 'Invalid bgm asset id') } }); + + if (!asset || asset.asset_type !== 'audio') { + throw new BadRequestException('LIVE_ACTION_BGM_AUDIO_ASSET_REQUIRED'); + } + if (asset.project_id && asset.project_id !== project.id) { + throw new ForbiddenException('BGM asset belongs to another project'); + } + + return { asset, task: null, cues }; + } + + const existing = dto.force ? null : await this.findLatestLiveActionOutputAsset(episode.id, 'live_action_bgm_generate'); + + if (existing) { + return { asset: existing.asset, task: existing.task, cues }; + } + + const taskInput: Prisma.InputJsonObject = { + episode_id: episode.id.toString(), + duration_seconds: duration, + bgm_source: LIVE_ACTION_SYSTEM_BGM_SOURCE, + cue_count: cues.length, + cues: this.toJsonValue(cues), + requested_by_user_id: user.id + }; + const task = await this.createRenderTask( + project.id, + episode.id, + null, + 'live_action_bgm_generate', + taskInput + ); + + try { + const buffer = await this.createLiveActionAmbientBgm(duration, cues); + const stored = await this.storage.storePrivateFile( + { + originalname: `episode-${episode.id.toString()}-ambient-bgm.wav`, + mimetype: 'audio/wav', + size: buffer.length, + buffer + } as Express.Multer.File, + 'generated-audio' + ); + const asset = await this.prisma.asset.create({ + data: { + user_id: project.user_id, + project_id: project.id, + asset_type: 'audio', + file_path: stored.file_path, + file_url: null, + mime_type: 'audio/wav', + duration, + size: stored.size, + hash: stored.hash, + visibility: 'private', + status: 'active' + } + }); + + await this.prisma.renderTask.update({ + where: { id: task.id }, + data: { + status: 'success', + output_asset_id: asset.id, + input_json: { + ...taskInput, + output_asset_id: asset.id.toString() + }, + finished_at: new Date() + } + }); + + return { asset, task: { ...task, status: 'success', output_asset_id: asset.id } as RenderTask, cues }; + } catch (error) { + await this.prisma.renderTask.update({ + where: { id: task.id }, + data: { + status: 'failed', + error_code: this.toError(error).name, + error_message: this.toError(error).message, + finished_at: new Date() + } + }); + throw error; + } + } + + private async resolveLiveActionSfxAsset( + user: AuthRequestUser, + project: Project, + episode: Episode, + shots: StoryboardShot[], + dto: LiveActionGenerateDto, + duration: number + ) { + const cues = this.buildLiveActionSfxCues(shots); + const existing = dto.force ? null : await this.findLatestLiveActionOutputAsset(episode.id, 'live_action_sfx_generate'); + + if (existing) { + return { + asset: existing.asset, + task: existing.task, + cues + }; + } + + const taskInput: Prisma.InputJsonObject = { + episode_id: episode.id.toString(), + duration_seconds: duration, + sfx_source: LIVE_ACTION_SYSTEM_SFX_SOURCE, + cue_count: cues.length, + cues: this.toJsonValue(cues), + requested_by_user_id: user.id + }; + const task = await this.createRenderTask(project.id, episode.id, null, 'live_action_sfx_generate', taskInput); + + try { + const buffer = await this.createLiveActionSfxTrack(duration, cues); + const stored = await this.storage.storePrivateFile( + { + originalname: `episode-${episode.id.toString()}-scene-sfx.wav`, + mimetype: 'audio/wav', + size: buffer.length, + buffer + } as Express.Multer.File, + 'generated-audio' + ); + const asset = await this.prisma.asset.create({ + data: { + user_id: project.user_id, + project_id: project.id, + asset_type: 'audio', + file_path: stored.file_path, + file_url: null, + mime_type: 'audio/wav', + duration, + size: stored.size, + hash: stored.hash, + visibility: 'private', + status: 'active' + } + }); + + await this.prisma.renderTask.update({ + where: { id: task.id }, + data: { + status: 'success', + output_asset_id: asset.id, + input_json: { + ...taskInput, + output_asset_id: asset.id.toString() + }, + finished_at: new Date() + } + }); + + return { asset, task: { ...task, status: 'success', output_asset_id: asset.id } as RenderTask, cues }; + } catch (error) { + await this.prisma.renderTask.update({ + where: { id: task.id }, + data: { + status: 'failed', + error_code: this.toError(error).name, + error_message: this.toError(error).message, + finished_at: new Date() + } + }); + throw error; + } + } + + private async finalizeLiveActionRenderWithPostProduction( + tempDir: string, + videoTrackPath: string, + duration: number, + postProduction?: LiveActionPostProductionAssets + ) { + if (!postProduction) { + return videoTrackPath; + } + + const audioPath = postProduction.audio_asset + ? await this.writeLiveActionAssetForFfmpeg(tempDir, postProduction.audio_asset, 'dialogue', 'audio') + : null; + const bgmPath = postProduction.bgm_asset + ? await this.writeLiveActionAssetForFfmpeg(tempDir, postProduction.bgm_asset, 'bgm', 'audio') + : null; + const sfxPath = postProduction.sfx_asset + ? await this.writeLiveActionAssetForFfmpeg(tempDir, postProduction.sfx_asset, 'sfx', 'audio') + : null; + const rawSubtitlePath = postProduction.subtitle_asset + ? await this.writeLiveActionAssetForFfmpeg(tempDir, postProduction.subtitle_asset, 'subtitle', 'subtitle') + : null; + const subtitlePath = rawSubtitlePath + ? await this.writeLiveActionAssSubtitleForFfmpeg(tempDir, rawSubtitlePath) + : null; + const outputPath = join(tempDir, 'episode-final.mp4'); + const args = ['-y', '-hide_banner', '-loglevel', 'error', '-i', videoTrackPath]; + let audioInputIndex: number | null = null; + let bgmInputIndex: number | null = null; + let sfxInputIndex: number | null = null; + + if (audioPath) { + audioInputIndex = args.filter((arg) => arg === '-i').length; + args.push('-i', audioPath); + } + if (bgmPath) { + bgmInputIndex = args.filter((arg) => arg === '-i').length; + args.push('-stream_loop', '-1', '-i', bgmPath); + } + if (sfxPath) { + sfxInputIndex = args.filter((arg) => arg === '-i').length; + args.push('-i', sfxPath); + } + const videoFilter = this.liveActionFinalVideoFilter(subtitlePath); + + if (videoFilter) { + args.push('-vf', videoFilter); + } + + args.push('-map', '0:v:0'); + + const audioFilter = this.liveActionFinalAudioFilter( + audioInputIndex, + bgmInputIndex, + sfxInputIndex, + duration, + postProduction.bgm_volume, + postProduction.sfx_volume + ); + + if (audioFilter) { + args.push('-filter_complex', audioFilter, '-map', '[aout]'); + } else { + args.push('-an'); + } + + args.push( + '-t', + duration.toFixed(3), + '-c:v', + 'libx264', + '-preset', + 'veryfast', + '-crf', + '20', + '-pix_fmt', + 'yuv420p' + ); + if (audioFilter) { + args.push('-c:a', 'aac', '-b:a', '160k', '-ar', '44100', '-ac', '2'); + } + args.push('-movflags', '+faststart', outputPath); + + await execFileAsync('ffmpeg', args, { timeout: 180000 }); + return outputPath; + } + + private liveActionFinalAudioFilter( + audioInputIndex: number | null, + bgmInputIndex: number | null, + sfxInputIndex: number | null, + duration: number, + bgmVolume = LIVE_ACTION_DEFAULT_BGM_VOLUME, + sfxVolume = LIVE_ACTION_DEFAULT_SFX_VOLUME + ) { + const parts: string[] = []; + const durationText = duration.toFixed(3); + const mixLabels: string[] = []; + + if (audioInputIndex !== null) { + parts.push( + `[${audioInputIndex}:a]aresample=44100,apad,atrim=0:${durationText},loudnorm=I=-15:TP=-1.0:LRA=11,volume=1.0[a_voice]` + ); + mixLabels.push('[a_voice]'); + } + if (bgmInputIndex !== null) { + parts.push( + `[${bgmInputIndex}:a]aresample=44100,apad,atrim=0:${durationText},volume=${bgmVolume.toFixed(3)},afade=t=in:st=0:d=0.8,afade=t=out:st=${Math.max(0, duration - 1).toFixed(3)}:d=1[a_bgm]` + ); + mixLabels.push('[a_bgm]'); + } + if (sfxInputIndex !== null) { + parts.push( + `[${sfxInputIndex}:a]aresample=44100,apad,atrim=0:${durationText},volume=${sfxVolume.toFixed(3)},alimiter=limit=0.90[a_sfx]` + ); + mixLabels.push('[a_sfx]'); + } + if (mixLabels.length > 1) { + parts.push(`${mixLabels.join('')}amix=inputs=${mixLabels.length}:normalize=0:duration=first,alimiter=limit=0.95[aout]`); + } else if (mixLabels.length === 1) { + parts.push(`${mixLabels[0]}anull[aout]`); + } + + return parts.length > 0 ? parts.join(';') : ''; + } + + private liveActionFinalVideoFilter(subtitlePath: string | null) { + return [ + this.liveActionVideoPolishFilter(), + subtitlePath ? this.liveActionSubtitleFilter(subtitlePath) : null + ].filter(Boolean).join(','); + } + + private liveActionVideoPolishFilter() { + return [ + 'eq=contrast=1.045:saturation=0.94:brightness=-0.018', + 'vignette=angle=PI/7', + 'noise=alls=2:allf=t+u' + ].join(','); + } + + private resolveLiveActionBgmVolume( + dto: LiveActionGenerateDto, + hasVoice = false, + cues: LiveActionBgmCue[] = [] + ) { + if (this.hasExplicitDecimal(dto.bgm_volume)) { + return this.normalizeDecimal(dto.bgm_volume, LIVE_ACTION_DEFAULT_BGM_VOLUME, 0, 1); + } + if (hasVoice) { + if (cues.some((cue) => cue.cue_type.startsWith('xianxia_'))) return 0.12; + if (cues.some((cue) => cue.cue_type === 'urban_climax')) return 0.14; + return LIVE_ACTION_DEFAULT_BGM_VOLUME; + } + if (cues.some((cue) => cue.cue_type === 'xianxia_epic')) return 0.55; + if (cues.some((cue) => cue.cue_type.startsWith('xianxia_'))) return 0.42; + if (cues.some((cue) => cue.cue_type === 'urban_climax')) return 0.38; + + return 0.28; + } + + private resolveLiveActionSfxVolume( + dto: LiveActionGenerateDto, + hasVoice = false, + cues: LiveActionBgmCue[] = [] + ) { + if (this.hasExplicitDecimal(dto.sfx_volume)) { + return this.normalizeDecimal(dto.sfx_volume, LIVE_ACTION_DEFAULT_SFX_VOLUME, 0, 1); + } + if (hasVoice) return LIVE_ACTION_DEFAULT_SFX_VOLUME; + if (cues.some((cue) => cue.cue_type.startsWith('xianxia_'))) return 0.95; + + return 0.65; + } + + private hasExplicitDecimal(value: unknown) { + return value !== undefined && value !== null && String(value).trim() !== ''; + } + + private resolveLiveActionLipSyncMaxSeconds(dto: LiveActionGenerateDto) { + return this.normalizeDecimal( + dto.lip_sync_max_seconds, + LIVE_ACTION_DEFAULT_LIP_SYNC_MAX_SECONDS, + 0, + LIVE_ACTION_HARD_LIP_SYNC_MAX_SECONDS + ); + } + + private resolveLiveActionProviderAssetUrlExpires(config: Record) { + const expires = + this.optionalNumberFromJson(config.public_url_expires_seconds) ?? + this.optionalNumberFromJson(config.asset_url_expires_seconds) ?? + 3600; + + return Math.min(Math.max(Math.round(expires), 60), 24 * 60 * 60); + } + + private async writeLiveActionAssetForFfmpeg( + tempDir: string, + asset: Asset, + name: string, + fallbackType: 'audio' | 'subtitle' + ) { + const ext = fallbackType === 'audio' + ? this.audioExtensionFromMime(asset.mime_type || '') + : '.srt'; + const path = join(tempDir, `${name}${ext}`); + const buffer = await this.storage.readPrivateFile(asset.file_path); + + await writeFile(path, buffer); + return path; + } + + private async writeLiveActionAssSubtitleForFfmpeg(tempDir: string, subtitlePath: string) { + const content = await readFile(subtitlePath, 'utf8'); + const cues = this.parseLiveActionSrtContent(content); + const assPath = join(tempDir, 'live-action-subtitles.ass'); + + await writeFile(assPath, this.stringifyLiveActionAss(cues), 'utf8'); + return assPath; + } + + private liveActionSubtitleFilter(subtitlePath: string) { + return [ + `subtitles=${this.escapeFfmpegFilterPath(subtitlePath)}`, + 'fontsdir=/usr/share/fonts/google-noto-cjk' + ].join(':'); + } + + private async mixLiveActionAudioSegments(segmentFiles: LiveActionAudioSegmentFile[]) { + const duration = this.liveActionAudioTimelineDuration(segmentFiles); + const tempDir = await mkdtemp(join(tmpdir(), 'ai-live-action-audio-')); + + try { + const normalizedPaths: string[] = []; + + for (const file of segmentFiles) { + const inputPath = join(tempDir, `input-${String(file.index).padStart(3, '0')}${this.audioExtensionFromMime(file.mimeType)}`); + const normalizedPath = join(tempDir, `normalized-${String(file.index).padStart(3, '0')}.wav`); + + await writeFile(inputPath, file.buffer); + await execFileAsync('ffmpeg', [ + '-y', + '-hide_banner', + '-loglevel', + 'error', + '-i', + inputPath, + '-ar', + '44100', + '-ac', + '2', + normalizedPath + ], { timeout: 180000 }); + normalizedPaths.push(normalizedPath); + } + + const outputPath = join(tempDir, 'live-action-dialogue-mix.wav'); + const args = ['-y', '-hide_banner', '-loglevel', 'error']; + const filters: string[] = []; + + normalizedPaths.forEach((path) => args.push('-i', path)); + segmentFiles.forEach((file, index) => { + const delayMs = Math.max(0, Math.round(file.segment.start_seconds * 1000)); + + filters.push( + `[${index}:a]adelay=${delayMs}|${delayMs},apad,atrim=0:${duration.toFixed(3)}[a${index}]` + ); + }); + if (segmentFiles.length === 1) { + filters.push( + `[a0]loudnorm=I=-16:TP=-1.5:LRA=11,atrim=0:${duration.toFixed(3)},asetpts=N/SR/TB[out]` + ); + } else { + filters.push( + `${segmentFiles.map((_, index) => `[a${index}]`).join('')}amix=inputs=${segmentFiles.length}:normalize=0:duration=longest,loudnorm=I=-16:TP=-1.5:LRA=11,atrim=0:${duration.toFixed(3)},asetpts=N/SR/TB[out]` + ); + } + + args.push('-filter_complex', filters.join(';'), '-map', '[out]', '-c:a', 'pcm_s16le', outputPath); + await execFileAsync('ffmpeg', args, { timeout: 180000 }); + + return { + buffer: await readFile(outputPath), + mimeType: 'audio/wav', + duration + }; + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + } + + private async liveActionAudioFileFromProviderOutput( + providerOutput: Record, + episodeId: bigint, + duration: number, + allowMockOutput: boolean + ) { + const contentBase64 = this.stringifyText(providerOutput.content_base64); + const mimeType = this.normalizeAudioMimeType(this.stringifyText(providerOutput.mime_type)); + const assetUrl = this.stringifyText(providerOutput.asset_url); + + if (contentBase64) { + return { + buffer: this.decodeBase64(contentBase64, 'VoiceProvider content_base64'), + mimeType, + isMock: false, + assetUrl: assetUrl || null + }; + } + if (/^https?:\/\//i.test(assetUrl)) { + const downloaded = await this.downloadProviderAsset(assetUrl); + + return { + buffer: downloaded.buffer, + mimeType: this.normalizeAudioMimeType(downloaded.mimeType), + isMock: false, + assetUrl + }; + } + if (!allowMockOutput) { + throw new BadRequestException('VoiceProvider did not return audio content or downloadable URL'); + } + + return { + buffer: this.createSilentWav(duration), + mimeType: 'audio/wav', + isMock: true, + assetUrl: null + }; + } + + private buildLiveActionBgmCues(shots: StoryboardShot[]) { + const cues: LiveActionBgmCue[] = []; + let cursor = 0; + + for (const shot of shots) { + const duration = this.normalizeShotDuration(shot); + const text = [ + shot.scene_name, + shot.location_desc, + shot.visual_desc, + shot.action_desc, + shot.dialogue_text, + shot.narration_text, + shot.effect_type, + shot.scene_type + ].filter(Boolean).join(' '); + const cueType = this.resolveLiveActionBgmCueType(shot, text); + + cues.push({ + index: cues.length + 1, + shot_id: shot.id.toString(), + shot_no: shot.shot_no, + cue_type: cueType, + start_seconds: Number(cursor.toFixed(3)), + end_seconds: Number((cursor + duration).toFixed(3)), + intensity: this.resolveLiveActionBgmIntensity(shot, cueType), + reason: this.liveActionBgmCueReason(cueType) + }); + cursor += duration; + } + + return cues; + } + + private resolveLiveActionBgmCueType(shot: StoryboardShot, text: string): LiveActionBgmCueType { + if (this.matchesLiveActionSfxCue(text, /(法相|法身|千臂|巨手|威压|降临|爆发|粉化|dharma|giant|colossal|impact)/i)) { + return 'xianxia_epic'; + } + if (this.matchesLiveActionSfxCue(text, /(结印|印法|光球|紫色|灵力|电流|聚能|seal|mudra|orb|energy)/i)) { + return 'xianxia_build_up'; + } + if (this.matchesLiveActionSfxCue(text, /(仙|废墟|残垣|崩塌|狂风|浴血|xianxia|ruins|storm|wind)/i)) { + return 'xianxia_tension'; + } + if ( + this.matchesLiveActionSfxCue(text, /(退婚|羞辱|嘲笑|打脸|首富|继承权|黑金卡|劳斯莱斯|反击|逆袭|神豪|财团|董事会|高光|高潮|slap|humiliate|counterattack|heir|billionaire|rolls-royce)/i) || + Number(shot.importance_score ?? 0) >= 9 + ) { + return 'urban_climax'; + } + if ( + this.matchesLiveActionSfxCue(text, /(恐惧|害怕|震惊|惊悚|诡异|悬疑|反转|reveal|twist|horror|suspense)/i) || + Number(shot.emotion_score ?? 0) >= 8 + ) { + return 'suspense_tension'; + } + + return 'urban_drama'; + } + + private resolveLiveActionBgmIntensity(shot: StoryboardShot, cueType: LiveActionBgmCueType) { + const score = Math.max( + Number(shot.importance_score ?? 1), + Number(shot.emotion_score ?? 1), + Number(shot.action_score ?? 1) + ); + const baseByType: Record = { + urban_drama: 0.42, + urban_climax: 0.68, + suspense_tension: 0.58, + xianxia_tension: 0.62, + xianxia_build_up: 0.74, + xianxia_epic: 0.9 + }; + + return Number(Math.max(0.3, Math.min(1, baseByType[cueType] + score * 0.025)).toFixed(2)); + } + + private liveActionBgmCueReason(cueType: LiveActionBgmCueType) { + const reasons: Record = { + urban_drama: 'default_short_drama_emotional_bed', + urban_climax: 'urban_counterattack_climax_and_face_slap', + suspense_tension: 'suspense_or_high_emotion_music_bed', + xianxia_tension: 'xianxia_opening_pressure_and_ruins', + xianxia_build_up: 'xianxia_hand_seal_energy_build_up', + xianxia_epic: 'xianxia_dharma_form_epic_release' + }; + + return reasons[cueType]; + } + + private async createLiveActionAmbientBgm(duration: number, cues: LiveActionBgmCue[] = []) { + const tempDir = await mkdtemp(join(tmpdir(), 'ai-live-action-bgm-')); + + try { + const outputPath = join(tempDir, 'ambient-bgm.wav'); + const safeDuration = Math.max(1, duration); + + if (cues.length > 0) { + return await this.createLiveActionCueBgmTrack(tempDir, outputPath, safeDuration, cues); + } + + const expression = [ + '0.18*sin(2*PI*110*t)', + '0.06*sin(2*PI*220*t)*(0.55+0.45*sin(2*PI*t/8))', + '0.035*sin(2*PI*329.63*t)*(0.50+0.50*sin(2*PI*t/11))' + ].join('+'); + const stereoExpression = `${expression}|${expression.replace(/110/g, '111').replace(/220/g, '218')}`; + + await execFileAsync('ffmpeg', [ + '-y', + '-hide_banner', + '-loglevel', + 'error', + '-f', + 'lavfi', + '-i', + `aevalsrc=${stereoExpression}:s=44100:d=${safeDuration.toFixed(3)}`, + '-af', + `highpass=f=45,lowpass=f=950,afade=t=in:st=0:d=0.8,afade=t=out:st=${Math.max(0, safeDuration - 1).toFixed(3)}:d=1,alimiter=limit=0.75`, + '-map', + '0:a', + '-c:a', + 'pcm_s16le', + outputPath + ], { timeout: 180000 }); + + return readFile(outputPath); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + } + + private async createLiveActionCueBgmTrack( + tempDir: string, + outputPath: string, + duration: number, + cues: LiveActionBgmCue[] + ) { + const cuePaths: string[] = []; + const safeDuration = Math.max(1, duration); + + for (const cue of cues) { + const cueDuration = Math.max(0.5, cue.end_seconds - cue.start_seconds); + const cuePath = join(tempDir, `bgm-cue-${String(cue.index).padStart(3, '0')}.wav`); + + await execFileAsync('ffmpeg', [ + '-y', + '-hide_banner', + '-loglevel', + 'error', + ...this.liveActionBgmLavfiArgs(cue, cueDuration), + '-ar', + '44100', + '-ac', + '2', + '-c:a', + 'pcm_s16le', + cuePath + ], { timeout: 180000 }); + cuePaths.push(cuePath); + } + + const args = ['-y', '-hide_banner', '-loglevel', 'error']; + const labels: string[] = []; + + for (const cuePath of cuePaths) { + args.push('-i', cuePath); + } + cues.forEach((cue, index) => { + const delayMs = Math.max(0, Math.round(cue.start_seconds * 1000)); + const label = `bgm${index}`; + + labels.push( + `[${index}:a]adelay=${delayMs}|${delayMs},apad,atrim=0:${safeDuration.toFixed(3)},aresample=44100,aformat=channel_layouts=stereo[${label}]` + ); + }); + const mixInputs = cues.map((_cue, index) => `[bgm${index}]`).join(''); + const filter = [ + ...labels, + `${mixInputs}amix=inputs=${cues.length}:normalize=0:duration=longest,atrim=0:${safeDuration.toFixed(3)},volume=1.15,alimiter=limit=0.82[aout]` + ].join(';'); + + args.push('-filter_complex', filter, '-map', '[aout]', '-c:a', 'pcm_s16le', outputPath); + await execFileAsync('ffmpeg', args, { timeout: 180000 }); + + return readFile(outputPath); + } + + private liveActionBgmLavfiArgs(cue: LiveActionBgmCue, duration: number) { + const durationText = duration.toFixed(3); + const expression = this.liveActionBgmExpression(cue); + const stereoExpression = `${expression}|${this.liveActionBgmStereoExpression(expression)}`; + const fadeIn = Math.min(0.55, duration * 0.22).toFixed(3); + const fadeOutStart = Math.max(0, duration - Math.min(0.65, duration * 0.22)).toFixed(3); + const filter = [ + this.liveActionBgmFilter(cue), + `volume=${(0.55 + cue.intensity * 0.38).toFixed(3)}`, + `afade=t=in:st=0:d=${fadeIn}`, + `afade=t=out:st=${fadeOutStart}:d=${Math.min(0.65, duration * 0.22).toFixed(3)}`, + 'alimiter=limit=0.82' + ].filter(Boolean).join(','); + + return [ + '-f', + 'lavfi', + '-i', + `aevalsrc=${stereoExpression}:s=44100:d=${durationText}`, + '-af', + filter + ]; + } + + private liveActionBgmExpression(cue: LiveActionBgmCue) { + if (cue.cue_type === 'xianxia_epic') { + return [ + '0.26*sin(2*PI*55*t)', + '0.12*sin(2*PI*110*t)', + '0.10*sin(2*PI*220*t)*(0.65+0.35*sin(2*PI*2*t))', + '0.07*sin(2*PI*329.63*t)*(0.50+0.50*sin(2*PI*4*t))', + '0.08*sin(2*PI*46*t)*(0.55+0.45*sin(2*PI*1.25*t))' + ].join('+'); + } + if (cue.cue_type === 'xianxia_build_up') { + return [ + '0.20*sin(2*PI*55*t)', + '0.08*sin(2*PI*146.83*t)', + '0.08*sin(2*PI*220*t)*(0.55+0.45*sin(2*PI*3.2*t))', + '0.05*sin(2*PI*440*t)*(0.50+0.50*sin(2*PI*6*t))' + ].join('+'); + } + if (cue.cue_type === 'xianxia_tension') { + return [ + '0.20*sin(2*PI*49*t)', + '0.08*sin(2*PI*98*t)*(0.55+0.45*sin(2*PI*0.7*t))', + '0.045*sin(2*PI*196*t)*(0.45+0.55*sin(2*PI*1.8*t))' + ].join('+'); + } + if (cue.cue_type === 'suspense_tension') { + return [ + '0.18*sin(2*PI*52*t)', + '0.05*sin(2*PI*185*t)', + '0.04*sin(2*PI*196.5*t)*(0.60+0.40*sin(2*PI*0.8*t))' + ].join('+'); + } + if (cue.cue_type === 'urban_climax') { + return [ + '0.22*sin(2*PI*73.42*t)', + '0.10*sin(2*PI*146.83*t)*(0.60+0.40*sin(2*PI*1.5*t))', + '0.075*sin(2*PI*220*t)*(0.55+0.45*sin(2*PI*3.0*t))', + '0.045*sin(2*PI*440*t)*(0.45+0.55*sin(2*PI*6.0*t))' + ].join('+'); + } + + return [ + '0.16*sin(2*PI*110*t)', + '0.055*sin(2*PI*220*t)*(0.55+0.45*sin(2*PI*t/8))', + '0.032*sin(2*PI*329.63*t)*(0.50+0.50*sin(2*PI*t/11))' + ].join('+'); + } + + private liveActionBgmStereoExpression(expression: string) { + return expression + .replace(/55/g, '56') + .replace(/110/g, '111') + .replace(/146\.83/g, '147.8') + .replace(/73\.42/g, '74.3') + .replace(/196\.5/g, '197.2') + .replace(/196/g, '197') + .replace(/220/g, '218') + .replace(/329\.63/g, '331') + .replace(/440/g, '436') + .replace(/46/g, '45'); + } + + private liveActionBgmFilter(cue: LiveActionBgmCue) { + if (cue.cue_type === 'xianxia_epic') { + return 'highpass=f=38,lowpass=f=2200,aecho=0.6:0.22:180:0.18'; + } + if (cue.cue_type === 'xianxia_build_up') { + return 'highpass=f=45,lowpass=f=2600,tremolo=f=5.5:d=0.22'; + } + if (cue.cue_type === 'xianxia_tension') { + return 'highpass=f=38,lowpass=f=1400,tremolo=f=1.2:d=0.18'; + } + if (cue.cue_type === 'suspense_tension') { + return 'highpass=f=42,lowpass=f=1250,aecho=0.45:0.18:260:0.12'; + } + if (cue.cue_type === 'urban_climax') { + return 'highpass=f=42,lowpass=f=2200,acompressor=threshold=-18dB:ratio=2.2:attack=16:release=180'; + } + + return 'highpass=f=45,lowpass=f=950'; + } + + private buildLiveActionSfxCues(shots: StoryboardShot[]) { + const cues: LiveActionSfxCue[] = []; + let cursor = 0; + + for (const shot of shots) { + const duration = this.normalizeShotDuration(shot); + const text = [ + shot.scene_name, + shot.location_desc, + shot.visual_desc, + shot.action_desc, + shot.dialogue_text, + shot.narration_text, + shot.effect_type, + shot.scene_type + ].filter(Boolean).join(' '); + + const pushCue = ( + cueType: LiveActionSfxCueType, + offsetStart: number, + offsetEnd: number, + intensity: number, + reason: string + ) => { + const start = Number((cursor + Math.max(0, Math.min(duration, offsetStart))).toFixed(3)); + const end = Number((cursor + Math.max(0.2, Math.min(duration, offsetEnd))).toFixed(3)); + + if (end <= start) return; + cues.push({ + index: cues.length + 1, + shot_id: shot.id.toString(), + shot_no: shot.shot_no, + cue_type: cueType, + start_seconds: start, + end_seconds: end, + intensity: Number(Math.max(0.2, Math.min(1, intensity)).toFixed(2)), + reason + }); + }; + + if (this.matchesLiveActionSfxCue(text, /(雨|雨夜|小雨|暴雨|淋湿|街道|夜巷|巷子|湿|rain|storm|wet|alley|street)/i)) { + pushCue('rain', 0, duration, 0.55, 'scene_ambient_rain_or_wet_street'); + } + if (this.matchesLiveActionSfxCue(text, /(走|跑|靠近|离开|下车|进门|出门|脚步|追|walk|run|approach|leave|footstep)/i)) { + pushCue('footstep', Math.min(0.15, duration * 0.12), Math.min(duration, 1.8), 0.42, 'character_movement_footstep'); + } + if (this.matchesLiveActionSfxCue(text, /(门|车门|推开|关上|开门|关门|door|car door)/i)) { + pushCue('door', Math.min(0.25, duration * 0.2), Math.min(duration, 1.15), 0.65, 'door_or_car_door_action'); + } + if ( + this.matchesLiveActionSfxCue(text, /(恐惧|害怕|震惊|惊悚|诡异|悬疑|心跳|颤抖|fear|shock|horror|suspense|heartbeat)/i) || + Number(shot.emotion_score ?? 0) >= 8 + ) { + pushCue('heartbeat', 0, duration, 0.42, 'high_emotion_or_suspense'); + } + if ( + this.matchesLiveActionSfxCue(text, /(反转|真相|不是人|那张皮|皮|曝光|揭开|突然|一闪|惊悚|sting|reveal|twist)/i) || + Number(shot.importance_score ?? 0) >= 9 + ) { + pushCue('sting', Math.max(0, duration - 1.1), duration, 0.75, 'reveal_or_high_importance_sting'); + } + if (this.matchesLiveActionSfxCue(text, /(狂风|风暴|呼啸|气流|衣袂|破空|storm|wind|whoosh|airflow)/i)) { + pushCue('wind', 0, duration, 0.62, 'xianxia_wind_pressure_or_whoosh'); + } + if (this.matchesLiveActionSfxCue(text, /(碎石|残垣|废墟|崩塌|塌陷|粉化|岩石|rubble|debris|collapse|stone)/i)) { + pushCue('debris', Math.max(0, duration * 0.08), Math.min(duration, duration * 0.88), 0.58, 'xianxia_rubble_debris'); + } + if (this.matchesLiveActionSfxCue(text, /(灵力|电流|紫色光球|光球|雷|闪电|能量|electric|lightning|energy|orb)/i)) { + pushCue('electric', Math.max(0, duration * 0.12), duration, 0.68, 'xianxia_energy_electric_current'); + } + if ( + this.matchesLiveActionSfxCue(text, /(法相|法身|千臂|威压|震荡|轰鸣|重低音|降临|shockwave|impact|bass|lfe|dharma)/i) || + Number(shot.action_score ?? 0) >= 8 + ) { + pushCue('impact', Math.max(0, duration - 1.45), duration, 0.9, 'xianxia_deep_impact_hit'); + } + + cursor += duration; + } + + return cues; + } + + private matchesLiveActionSfxCue(text: string, pattern: RegExp) { + return pattern.test(text || ''); + } + + private async createLiveActionSfxTrack(duration: number, cues: LiveActionSfxCue[]) { + const safeDuration = Math.max(1, duration); + + if (cues.length === 0) { + return this.createSilentWav(safeDuration); + } + + const tempDir = await mkdtemp(join(tmpdir(), 'ai-live-action-sfx-')); + + try { + const cuePaths: string[] = []; + + for (const cue of cues) { + const cueDuration = Math.max(0.2, cue.end_seconds - cue.start_seconds); + const cuePath = join(tempDir, `cue-${String(cue.index).padStart(3, '0')}.wav`); + + await execFileAsync('ffmpeg', [ + '-y', + '-hide_banner', + '-loglevel', + 'error', + ...this.liveActionSfxLavfiArgs(cue, cueDuration), + '-ar', + '44100', + '-ac', + '2', + '-c:a', + 'pcm_s16le', + cuePath + ], { timeout: 180000 }); + cuePaths.push(cuePath); + } + + const outputPath = join(tempDir, 'scene-sfx-mix.wav'); + const args = ['-y', '-hide_banner', '-loglevel', 'error']; + + for (const cuePath of cuePaths) { + args.push('-i', cuePath); + } + + const delayedLabels = cues.map((cue, index) => { + const delayMs = Math.max(0, Math.round(cue.start_seconds * 1000)); + const label = `sfx${index}`; + + return `[${index}:a]adelay=${delayMs}|${delayMs},apad,atrim=0:${safeDuration.toFixed(3)},aresample=44100,aformat=channel_layouts=stereo[${label}]`; + }); + const mixInputs = cues.map((_cue, index) => `[sfx${index}]`).join(''); + const filter = [ + ...delayedLabels, + `${mixInputs}amix=inputs=${cues.length}:normalize=0:duration=longest,atrim=0:${safeDuration.toFixed(3)},volume=1.8,alimiter=limit=0.90[aout]` + ].join(';'); + + args.push('-filter_complex', filter, '-map', '[aout]', '-c:a', 'pcm_s16le', outputPath); + await execFileAsync('ffmpeg', args, { timeout: 180000 }); + + return readFile(outputPath); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + } + + private liveActionSfxLavfiArgs(cue: LiveActionSfxCue, duration: number) { + const amplitude = Math.max(0.04, Math.min(0.28, cue.intensity * 0.2)); + const durationText = duration.toFixed(3); + const fadeOutStart = Math.max(0, duration - 0.15).toFixed(3); + + if (cue.cue_type === 'rain') { + return [ + '-f', + 'lavfi', + '-i', + `anoisesrc=color=pink:amplitude=${amplitude.toFixed(3)}:d=${durationText}:s=44100`, + '-af', + `highpass=f=700,lowpass=f=4200,afade=t=in:st=0:d=0.05,afade=t=out:st=${fadeOutStart}:d=0.15` + ]; + } + if (cue.cue_type === 'heartbeat') { + return [ + '-f', + 'lavfi', + '-i', + `sine=frequency=62:duration=${durationText}:sample_rate=44100`, + '-af', + `tremolo=f=1.6:d=0.85,lowpass=f=160,volume=${(cue.intensity * 0.42).toFixed(3)},afade=t=in:st=0:d=0.04,afade=t=out:st=${fadeOutStart}:d=0.15` + ]; + } + if (cue.cue_type === 'sting') { + return [ + '-f', + 'lavfi', + '-i', + `sine=frequency=210:duration=${durationText}:sample_rate=44100`, + '-af', + `aecho=0.6:0.35:80:0.45,lowpass=f=900,volume=${(cue.intensity * 0.58).toFixed(3)},afade=t=out:st=${fadeOutStart}:d=0.15` + ]; + } + if (cue.cue_type === 'wind') { + return [ + '-f', + 'lavfi', + '-i', + `anoisesrc=color=pink:amplitude=${amplitude.toFixed(3)}:d=${durationText}:s=44100`, + '-af', + `highpass=f=160,lowpass=f=2600,tremolo=f=4.2:d=0.65,volume=0.95,afade=t=in:st=0:d=0.05,afade=t=out:st=${fadeOutStart}:d=0.15` + ]; + } + if (cue.cue_type === 'debris') { + return [ + '-f', + 'lavfi', + '-i', + `anoisesrc=color=brown:amplitude=${amplitude.toFixed(3)}:d=${durationText}:s=44100`, + '-af', + `lowpass=f=950,highpass=f=80,tremolo=f=9:d=0.45,volume=1.25,afade=t=in:st=0:d=0.01,afade=t=out:st=${fadeOutStart}:d=0.18` + ]; + } + if (cue.cue_type === 'electric') { + return [ + '-f', + 'lavfi', + '-i', + `sine=frequency=1100:duration=${durationText}:sample_rate=44100`, + '-af', + `tremolo=f=38:d=0.85,aecho=0.45:0.32:35:0.45,highpass=f=500,lowpass=f=5200,volume=${(cue.intensity * 0.28).toFixed(3)},afade=t=in:st=0:d=0.03,afade=t=out:st=${fadeOutStart}:d=0.12` + ]; + } + if (cue.cue_type === 'impact') { + return [ + '-f', + 'lavfi', + '-i', + `sine=frequency=46:duration=${durationText}:sample_rate=44100`, + '-af', + `aecho=0.85:0.45:120:0.38,lowpass=f=150,volume=${(cue.intensity * 0.95).toFixed(3)},afade=t=in:st=0:d=0.02,afade=t=out:st=${fadeOutStart}:d=0.2` + ]; + } + if (cue.cue_type === 'door') { + return [ + '-f', + 'lavfi', + '-i', + `anoisesrc=color=white:amplitude=${amplitude.toFixed(3)}:d=${durationText}:s=44100`, + '-af', + `lowpass=f=1350,volume=1.0,afade=t=in:st=0:d=0.02,afade=t=out:st=${fadeOutStart}:d=0.12` + ]; + } + + return [ + '-f', + 'lavfi', + '-i', + `anoisesrc=color=white:amplitude=${amplitude.toFixed(3)}:d=${durationText}:s=44100`, + '-af', + `highpass=f=120,lowpass=f=1800,tremolo=f=2.4:d=0.8,volume=0.7,afade=t=in:st=0:d=0.03,afade=t=out:st=${fadeOutStart}:d=0.12` + ]; + } + + private buildLiveActionSubtitleCues(segments: LiveActionAudioSegment[], dto: LiveActionGenerateDto) { + const mode = dto.subtitle_mode ?? 'dialogue'; + const maxChars = this.normalizePositiveInt(dto.max_chars_per_line, 'max_chars_per_line', 8, 24, 18); + + return segments.map((segment, index) => ({ + index: index + 1, + start_seconds: segment.start_seconds, + end_seconds: segment.end_seconds, + text: mode === 'shot' ? this.wrapLiveActionSubtitleText(segment.text, maxChars) : this.wrapLiveActionSubtitleText(segment.text, maxChars) + })); + } + + private stringifyLiveActionSrt(cues: LiveActionSubtitleCue[]) { + return `${cues.map((cue) => [ + cue.index.toString(), + `${this.secondsToSrtTime(cue.start_seconds)} --> ${this.secondsToSrtTime(cue.end_seconds)}`, + cue.text + ].join('\n')).join('\n\n')}\n`; + } + + private parseLiveActionSrtContent(content: string) { + const blocks = content.replace(/\r/g, '').trim().split(/\n{2,}/); + + return blocks + .map((block, index) => { + const lines = block.split('\n').map((line) => line.trim()); + const timeIndex = lines.findIndex((line) => line.includes('-->')); + + if (timeIndex === -1) return null; + const [start, end] = lines[timeIndex].split(/\s+-->\s+/); + const text = lines.slice(timeIndex + 1).filter(Boolean).join('\\N'); + + return { + index: index + 1, + start_seconds: this.srtTimeToSeconds(start), + end_seconds: this.srtTimeToSeconds(end?.split(/\s+/)[0] ?? start), + text + }; + }) + .filter((cue): cue is LiveActionSubtitleCue => Boolean(cue && cue.text)); + } + + private stringifyLiveActionAss(cues: LiveActionSubtitleCue[]) { + const events = cues + .map( + (cue) => + `Dialogue: 0,${this.secondsToAssTime(cue.start_seconds)},${this.secondsToAssTime(cue.end_seconds)},Default,,0,0,0,,${this.escapeAssDialogue(cue.text)}` + ) + .join('\n'); + + return `[Script Info] +ScriptType: v4.00+ +PlayResX: ${LIVE_ACTION_WIDTH} +PlayResY: ${LIVE_ACTION_HEIGHT} +WrapStyle: 0 +ScaledBorderAndShadow: yes + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Noto Sans CJK SC,56,&H00FFFFFF,&H000000FF,&HA0000000,&H66000000,0,0,0,0,100,100,0,0,1,3,0,2,90,90,140,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +${events} +`; + } + + private liveActionPostProductionTaskInput(postProduction: LiveActionPostProductionAssets) { + return { + include_audio: postProduction.include_audio, + include_subtitle: postProduction.include_subtitle, + include_bgm: postProduction.include_bgm, + include_sfx: postProduction.include_sfx, + audio_asset_id: postProduction.audio_asset?.id.toString() ?? null, + subtitle_asset_id: postProduction.subtitle_asset?.id.toString() ?? null, + bgm_asset_id: postProduction.bgm_asset?.id.toString() ?? null, + sfx_asset_id: postProduction.sfx_asset?.id.toString() ?? null, + audio_task_id: postProduction.audio_task?.id.toString() ?? null, + subtitle_task_id: postProduction.subtitle_task?.id.toString() ?? null, + bgm_task_id: postProduction.bgm_task?.id.toString() ?? null, + sfx_task_id: postProduction.sfx_task?.id.toString() ?? null, + bgm_volume: postProduction.bgm_volume, + sfx_volume: postProduction.sfx_volume, + lip_sync_budget: postProduction.lip_sync_budget, + segment_count: postProduction.segments.length, + subtitle_cue_count: postProduction.subtitle_cues.length, + bgm_cue_count: postProduction.bgm_cues.length, + bgm_cues: postProduction.bgm_cues, + sfx_cue_count: postProduction.sfx_cues.length, + sfx_cues: postProduction.sfx_cues, + lip_sync_clip_count: postProduction.lip_sync_clips.length, + lip_sync_clips: postProduction.lip_sync_clips.map((clip) => ({ + shot_id: clip.shot_id, + shot_no: clip.shot_no, + source_asset_id: clip.source_asset_id, + output_asset_id: clip.output_asset_id, + task_id: clip.task_id, + provider_code: clip.provider_code, + provider_mode: clip.provider_mode, + cost_actual: clip.cost_actual, + strategy: clip.lip_sync_strategy + })), + lip_sync_policy: { + required_count: postProduction.segments.filter((segment) => segment.lip_sync_required).length, + fallback_count: postProduction.segments.filter((segment) => segment.visual_fallback).length, + strategies: this.uniqueStrings(postProduction.segments.map((segment) => segment.lip_sync_strategy)), + segments: postProduction.segments.map((segment) => ({ + index: segment.index, + shot_id: segment.shot_id, + shot_no: segment.shot_no, + lip_sync_required: segment.lip_sync_required, + strategy: segment.lip_sync_strategy, + visual_fallback: segment.visual_fallback, + skip_reason: segment.lip_sync_skip_reason + })) + }, + audio_is_mock: postProduction.audio_is_mock, + audio_provider_codes: postProduction.audio_provider_codes, + audio_warnings: postProduction.audio_warnings + }; + } + + private liveActionVideoPolishTaskInput() { + return { + version: LIVE_ACTION_VIDEO_POLISH_VERSION, + filters: [ + 'cinematic_contrast', + 'slight_desaturation', + 'subtle_vignette', + 'fine_grain' + ] + }; + } + + private async findLatestLiveActionOutputAsset(episodeId: bigint, taskType: string) { + const task = await this.prisma.renderTask.findFirst({ + where: { + episode_id: episodeId, + task_type: taskType, + status: 'success', + output_asset_id: { not: null } + }, + orderBy: { created_at: 'desc' } + }); + + if (!task?.output_asset_id) return null; + const asset = await this.prisma.asset.findUnique({ where: { id: task.output_asset_id } }); + + return asset ? { task, asset } : null; + } + + private liveActionAudioProviderInput(segment: LiveActionAudioSegment): Prisma.InputJsonObject { + return { + text: segment.text, + speaker: segment.speaker_name, + segment_type: segment.segment_type, + target_duration: segment.target_duration, + voice: segment.voice, + voice_id: segment.voice, + instructions: segment.voice_style, + shot_id: segment.shot_id, + shot_no: segment.shot_no + }; + } + + private liveActionAudioSegmentTaskInput(segment: LiveActionAudioSegment) { + return { + index: segment.index, + shot_id: segment.shot_id, + shot_no: segment.shot_no, + segment_type: segment.segment_type, + start_seconds: segment.start_seconds, + end_seconds: segment.end_seconds, + target_duration: segment.target_duration, + speaker_name: segment.speaker_name, + text: segment.text, + voice_provider_code: segment.voice_provider_code, + voice: segment.voice, + voice_id: segment.voice, + voice_style: segment.voice_style, + character_id: segment.character_id, + lip_sync_required: segment.lip_sync_required, + lip_sync_strategy: segment.lip_sync_strategy, + visual_fallback: segment.visual_fallback, + lip_sync_skip_reason: segment.lip_sync_skip_reason + }; + } + + private liveActionAudioTimelineDuration(segmentFiles: LiveActionAudioSegmentFile[]) { + const targetDuration = segmentFiles.reduce( + (duration, file) => Math.max(duration, file.segment.end_seconds), + 0 + ); + const actualDuration = segmentFiles.reduce( + (duration, file) => Math.max(duration, file.segment.start_seconds + file.duration), + 0 + ); + + return Number(Math.max(1, targetDuration, actualDuration).toFixed(3)); + } + + private buildLiveActionAudioWarnings(segmentFiles: LiveActionAudioSegmentFile[]) { + return segmentFiles + .map((file) => { + const overSeconds = Number((file.duration - file.segment.target_duration).toFixed(3)); + + if (overSeconds <= 0.35) return null; + + return { + index: file.index, + shot_no: file.segment.shot_no, + text_preview: file.segment.text.slice(0, 40), + target_duration: file.segment.target_duration, + actual_duration: file.duration, + over_seconds: overSeconds + }; + }) + .filter((warning): warning is LiveActionAudioWarning => Boolean(warning)); + } + + private readLiveActionAudioWarnings(value: unknown): LiveActionAudioWarning[] { + if (!Array.isArray(value)) return []; + + return value + .map((item) => this.jsonObject(item as Prisma.InputJsonValue)) + .map((item) => ({ + index: Math.max(0, Math.round(this.numberFromJson(item.index))), + shot_no: Math.max(0, Math.round(this.numberFromJson(item.shot_no))), + text_preview: this.stringifyText(item.text_preview), + target_duration: this.numberFromJson(item.target_duration), + actual_duration: this.numberFromJson(item.actual_duration), + over_seconds: this.numberFromJson(item.over_seconds) + })) + .filter((item) => item.index > 0 && item.shot_no > 0); + } + + private normalizeDialogueText(value: string | null) { + return (value || '').replace(/\s+/g, ' ').trim(); + } + + private parseLiveActionDialogueParts(value: string | null, fallbackSpeaker: string): LiveActionDialoguePart[] { + const source = (value || '').replace(/\r\n/g, '\n').trim(); + if (!source) return []; + + const speakerPattern = /([\u4e00-\u9fa5A-Za-z0-9_·]{1,16})\s*[::]/g; + const matches = [...source.matchAll(speakerPattern)]; + if (matches.length === 0) { + const text = this.normalizeDialogueText(source); + + return text ? [{ speaker_name: fallbackSpeaker || '角色', text }] : []; + } + + const parts = matches + .map((match, index) => { + const speakerName = this.liveActionSpeakerKey(match[1]) || fallbackSpeaker || '角色'; + const textStart = (match.index ?? 0) + match[0].length; + const textEnd = matches[index + 1]?.index ?? source.length; + const text = this.normalizeDialogueText(source.slice(textStart, textEnd)); + + return { speaker_name: speakerName, text }; + }) + .filter((part) => Boolean(part.text)); + + if (parts.length > 0) { + return parts; + } + + const text = this.normalizeDialogueText(source); + + return text ? [{ speaker_name: fallbackSpeaker || '角色', text }] : []; + } + + private buildLiveActionCharacterVoiceMap(characters: Character[]) { + const voices = new Map(); + + for (const character of characters) { + const names = this.uniqueStrings([ + character.name, + ...this.stringArray(character.alias_names) + ]); + + for (const name of names) { + const key = this.liveActionSpeakerKey(name); + + if (key && !voices.has(key)) { + voices.set(key, character); + } + } + } + + return voices; + } + + private resolveLiveActionSegmentVoice( + speakerName: string, + segmentType: LiveActionAudioSegment['segment_type'], + dto: LiveActionGenerateDto, + characterVoices: Map + ): LiveActionSpeakerVoice { + const character = characterVoices.get(this.liveActionSpeakerKey(speakerName)) ?? null; + const dtoProviderCode = this.normalizeOptionalText(dto.voice_provider_code, 100) ?? null; + const dtoVoice = this.normalizeOptionalText(dto.voice, 100) ?? null; + const characterProviderCode = this.normalizeOptionalText(character?.voice_provider_code, 100) ?? null; + const voiceProviderCode = characterProviderCode ?? dtoProviderCode; + const characterVoice = this.normalizeOptionalText(character?.voice_id, 100) ?? null; + const voice = characterVoice + ?? dtoVoice + ?? this.defaultLiveActionVoiceId(speakerName, segmentType, character, voiceProviderCode); + const voiceStyle = this.liveActionVoiceStyleText(character?.voice_style) + ?? this.liveActionVoiceStyleText(character?.speech_style) + ?? LIVE_ACTION_DEFAULT_VOICE_STYLE; + + return { + character, + voice_provider_code: voiceProviderCode, + voice, + voice_style: voiceStyle + }; + } + + private defaultLiveActionVoiceId( + speakerName: string, + segmentType: LiveActionAudioSegment['segment_type'], + character: Character | null, + providerCode: string | null + ) { + if (providerCode !== 'minimax-tts') return null; + if (segmentType === 'narration') return LIVE_ACTION_NARRATION_VOICE; + + const speaker = `${speakerName} ${character?.name ?? ''} ${character?.role_type ?? ''} ${character?.gender_label ?? ''} ${character?.age_group ?? ''} ${character?.identity_desc ?? ''}`; + + if (/主持|旁白|narrator|host/i.test(speaker)) return LIVE_ACTION_NARRATION_VOICE; + if (/管家|王伯|老|伯|叔|senior|elder/i.test(speaker)) return 'Chinese (Mandarin)_Gentleman'; + if (/周浩|反派|老板|富二代|villain|executive/i.test(speaker)) return 'Chinese (Mandarin)_Reliable_Executive'; + if (/女|未婚妻|林雨薇|陈雪|苏清雅|female|woman|miss/i.test(speaker)) return 'Arrogant_Miss'; + + return 'Chinese (Mandarin)_Sincere_Adult'; + } + + private liveActionSpeakerKey(value: unknown) { + return this.stringifyText(value) + .replace(/[::]/g, '') + .replace(/\s+/g, '') + .trim(); + } + + private liveActionVoiceStyleText(value: unknown) { + const text = this.stringifyText(value); + + return text ? text.slice(0, 500) : null; + } + + private liveActionSpeakerName(shot: StoryboardShot) { + const characters = Array.isArray(shot.characters_json) ? shot.characters_json : []; + const first = characters.find((item) => item && typeof item === 'object') as Record | undefined; + const name = first ? this.stringifyText(first.name) : ''; + + return name || (shot.dialogue_text ? '角色' : '旁白'); + } + + private liveActionShotCharacterRefs(shot: StoryboardShot) { + const characters = Array.isArray(shot.characters_json) ? shot.characters_json : []; + + return characters + .map((item) => { + if (typeof item === 'string') { + return { id: null, name: item.trim() }; + } + if (item && typeof item === 'object') { + const record = item as Record; + const id = this.stringifyText(record.id) || this.stringifyText(record.character_id) || null; + const name = this.stringifyText(record.name); + + return { id, name }; + } + + return { id: null, name: '' }; + }) + .filter((item) => Boolean(item.id || item.name)); + } + + private characterNamesFromShotPayload(shot: StoryboardShot) { + const characters = Array.isArray(shot.characters_json) ? shot.characters_json : []; + + return characters + .map((item) => { + if (typeof item === 'string') return item; + if (item && typeof item === 'object') { + return this.stringifyText((item as Record).name); + } + + return ''; + }) + .map((name) => name.trim()) + .filter(Boolean); + } + + private wrapLiveActionSubtitleText(text: string, maxChars: number) { + if (text.length <= maxChars) return text; + + const lines = []; + for (let index = 0; index < text.length; index += maxChars) { + lines.push(text.slice(index, index + maxChars)); + } + + return lines.slice(0, 2).join('\n'); + } + + private normalizePositiveInt( + value: unknown, + field: string, + min: number, + max: number, + fallback: number + ) { + if (value === undefined || value === null || value === '') return fallback; + const numeric = Number(value); + + if (!Number.isInteger(numeric) || numeric < min || numeric > max) { + throw new BadRequestException(`${field} must be an integer between ${min} and ${max}`); + } + + return numeric; + } + + private normalizeDecimal(value: unknown, fallback: number, min: number, max: number) { + if (value === undefined || value === null || value === '') return fallback; + const numeric = Number(value); + + if (!Number.isFinite(numeric) || numeric < min || numeric > max) { + throw new BadRequestException(`value must be a number between ${min} and ${max}`); + } + + return numeric; + } + + private normalizeAudioMimeType(value: string | null) { + const normalized = (value || '').toLowerCase(); + + if (normalized.includes('wav')) return 'audio/wav'; + if (normalized.includes('aac')) return 'audio/aac'; + if (normalized.includes('flac')) return 'audio/flac'; + if (normalized.includes('ogg') || normalized.includes('opus')) return 'audio/ogg'; + + return 'audio/mpeg'; + } + + private audioExtensionFromMime(mimeType: string) { + const normalized = mimeType.toLowerCase(); + + if (normalized.includes('wav')) return '.wav'; + if (normalized.includes('aac')) return '.aac'; + if (normalized.includes('flac')) return '.flac'; + if (normalized.includes('ogg') || normalized.includes('opus')) return '.ogg'; + + return '.mp3'; + } + + private createSilentWav(durationSeconds: number) { + const sampleRate = 8000; + const channels = 1; + const bitsPerSample = 16; + const dataSize = Math.max(1, Math.round(durationSeconds * sampleRate * channels * (bitsPerSample / 8))); + const buffer = Buffer.alloc(44 + dataSize); + + buffer.write('RIFF', 0); + buffer.writeUInt32LE(36 + dataSize, 4); + buffer.write('WAVE', 8); + buffer.write('fmt ', 12); + buffer.writeUInt32LE(16, 16); + buffer.writeUInt16LE(1, 20); + buffer.writeUInt16LE(channels, 22); + buffer.writeUInt32LE(sampleRate, 24); + buffer.writeUInt32LE(sampleRate * channels * (bitsPerSample / 8), 28); + buffer.writeUInt16LE(channels * (bitsPerSample / 8), 32); + buffer.writeUInt16LE(bitsPerSample, 34); + buffer.write('data', 36); + buffer.writeUInt32LE(dataSize, 40); + + return buffer; + } + + private secondsToSrtTime(value: number) { + const safe = Math.max(0, value); + const hours = Math.floor(safe / 3600); + const minutes = Math.floor((safe % 3600) / 60); + const seconds = Math.floor(safe % 60); + const ms = Math.round((safe - Math.floor(safe)) * 1000); + + return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')},${String(ms).padStart(3, '0')}`; + } + + private secondsToAssTime(value: number) { + const safe = Math.max(0, value); + const hours = Math.floor(safe / 3600); + const minutes = Math.floor((safe % 3600) / 60); + const seconds = Math.floor(safe % 60); + const centiseconds = Math.round((safe - Math.floor(safe)) * 100); + + return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(centiseconds).padStart(2, '0')}`; + } + + private srtTimeToSeconds(value: string) { + const match = /^(\d+):(\d{2}):(\d{2}),(\d{3})$/.exec(value.trim()); + + if (!match) return 0; + const [, hours, minutes, seconds, ms] = match; + + return Number(hours) * 3600 + Number(minutes) * 60 + Number(seconds) + Number(ms) / 1000; + } + + private escapeAssDialogue(value: string) { + return value.replace(/\r?\n/g, '\\N').replace(/[{}]/g, ''); + } + + private escapeFfmpegFilterPath(value: string) { + return `'${value.replace(/\\/g, '/').replace(/'/g, "'\\''").replace(/:/g, '\\:')}'`; + } + + private async normalizeLiveActionClipForRender( + sourcePath: string, + tempDir: string, + shot: StoryboardShot, + asset: Asset + ): Promise { + const targetDuration = this.normalizeShotDuration(shot); + const sourceDuration = await this.probeVideoDuration(sourcePath).catch(() => null); + const shouldTrim = sourceDuration !== null && sourceDuration > targetDuration + LIVE_ACTION_RENDER_TRIM_TOLERANCE_SECONDS; + let trimStrategy: LiveActionTrimStrategy = 'none'; + let trimStart = 0; + + if (shouldTrim) { + trimStrategy = this.resolveLiveActionTrimStrategy(shot); + trimStart = this.resolveLiveActionTrimStart(sourceDuration, targetDuration, trimStrategy); + } + const outputPath = join(tempDir, `clip-${String(shot.shot_no).padStart(3, '0')}.mp4`); + const args = [ + '-y', + '-hide_banner', + '-loglevel', + 'error', + '-i', + sourcePath + ]; + + if (shouldTrim && trimStart > 0) { + args.push('-ss', trimStart.toFixed(3)); + } + if (shouldTrim) { + args.push('-t', targetDuration.toFixed(3)); + } + + args.push( + '-map', + '0:v:0', + '-an', + '-vf', + this.liveActionRenderVideoFilter(), + '-c:v', + 'libx264', + '-preset', + 'veryfast', + '-crf', + '20', + '-movflags', + '+faststart', + outputPath + ); + + await execFileAsync('ffmpeg', args); + const finalDuration = await this.probeVideoDuration(outputPath).catch(() => null); + + return { + path: outputPath, + report: { + shot_id: shot.id.toString(), + shot_no: shot.shot_no, + asset_id: asset.id.toString(), + target_duration: targetDuration, + source_duration: sourceDuration, + final_duration: finalDuration, + trimmed: shouldTrim, + trim_strategy: trimStrategy, + trim_start: Number(trimStart.toFixed(3)), + trim_tolerance: LIVE_ACTION_RENDER_TRIM_TOLERANCE_SECONDS + } + }; + } + + private async probeVideoDuration(filePath: string) { + const { stdout } = await execFileAsync('ffprobe', [ + '-v', + 'error', + '-show_entries', + 'format=duration', + '-of', + 'default=noprint_wrappers=1:nokey=1', + filePath + ]); + const duration = Number(stdout.trim()); + + return Number.isFinite(duration) && duration > 0 ? Number(duration.toFixed(3)) : null; + } + + private liveActionRenderVideoFilter() { + return [ + `scale=${LIVE_ACTION_WIDTH}:${LIVE_ACTION_HEIGHT}:force_original_aspect_ratio=decrease`, + `pad=${LIVE_ACTION_WIDTH}:${LIVE_ACTION_HEIGHT}:(ow-iw)/2:(oh-ih)/2`, + 'setsar=1', + `fps=${LIVE_ACTION_RENDER_FPS}`, + 'format=yuv420p' + ].join(','); + } + + private resolveLiveActionTrimStrategy(shot: StoryboardShot): Extract { + const text = [ + shot.scene_type, + shot.action_desc, + shot.actor_action, + shot.camera_motion, + shot.effect_type + ].filter(Boolean).join(' ').toLowerCase(); + + return /打|跑|走|开门|转身|推|拉|追|逃|摔|爆炸|打脸|动作|action|fight|run|walk|turn|door/.test(text) + ? 'head' + : 'center'; + } + + private resolveLiveActionTrimStart( + sourceDuration: number, + targetDuration: number, + strategy: Extract + ) { + if (strategy === 'head') { + return 0; + } + + return Math.max(0, (sourceDuration - targetDuration) / 2); + } + + private createMockKeyframeSvg(shot: StoryboardShot, prompt: string) { + const color = `#${this.hashJson({ shot: shot.id.toString() }).slice(0, 6)}`; + const promptHash = this.hashJson({ prompt }).slice(0, 12); + + return [ + ``, + ``, + '', + '', + '', + '', + 'MOCK LIVE KEYFRAME', + `shot ${shot.shot_no} / prompt:${promptHash}`, + '' + ].join(''); + } + + private async loadStoryboardShots(episodeId: bigint) { + const confirmed = await this.prisma.storyboardShot.findMany({ + where: { episode_id: episodeId, status: 'confirmed' }, + orderBy: { shot_no: 'asc' } + }); + + return confirmed.length > 0 + ? confirmed + : this.prisma.storyboardShot.findMany({ + where: { episode_id: episodeId }, + orderBy: { shot_no: 'asc' } + }); + } + + private async loadPreparedLiveActionShots(episodeId: bigint) { + const shots = await this.loadStoryboardShots(episodeId); + + if (shots.length === 0 || shots.some((shot) => !shot.video_prompt)) { + throw new BadRequestException('Prepared live action shots are required first'); + } + + return shots; + } + + private async characterNamesForShot(projectId: bigint, shot: StoryboardShot) { + const ids = this.extractCharacterIds(shot.characters_json); + + if (ids.length === 0) { + return []; + } + + const characters = await this.prisma.character.findMany({ + where: { + project_id: projectId, + id: { in: ids } + } + }); + + return characters.map((character) => character.name); + } + + private extractCharacterIds(value: Prisma.JsonValue | null) { + if (!Array.isArray(value)) return []; + + return value + .map((item) => { + if (typeof item === 'string' || typeof item === 'number' || typeof item === 'bigint') { + return this.toBigIntOrNull(item); + } + if (item && typeof item === 'object' && 'id' in item) { + return this.toBigIntOrNull((item as { id?: unknown }).id); + } + if (item && typeof item === 'object' && 'character_id' in item) { + return this.toBigIntOrNull((item as { character_id?: unknown }).character_id); + } + + return null; + }) + .filter((id): id is bigint => Boolean(id)); + } + + private toBigIntOrNull(value: unknown) { + try { + if (value === null || value === undefined || value === '') return null; + return BigInt(String(value)); + } catch { + return null; + } + } + + private normalizeShotDuration(shot: StoryboardShot) { + const value = Number(shot.duration?.toString()) || 4; + return Number(Math.max(1, Math.min(LIVE_ACTION_MAX_SHOT_SECONDS, value)).toFixed(2)); + } + + private splitProviderClipDurations(duration: number) { + if (duration <= LIVE_ACTION_MAX_PROVIDER_CLIP_SECONDS) { + return [Number(duration.toFixed(2))]; + } + + const count = Math.ceil(duration / LIVE_ACTION_MAX_PROVIDER_CLIP_SECONDS); + const baseDuration = duration / count; + + return Array.from({ length: count }, (_, index) => { + const remaining = duration - baseDuration * index; + const value = index === count - 1 ? remaining : baseDuration; + + return Number(value.toFixed(2)); + }); + } + + private buildLiveActionProviderClipSegments( + shot: StoryboardShot, + duration: number, + dto: LiveActionGenerateDto, + providerConfig: ProviderConfig + ): LiveActionProviderClipSegment[] { + const baseDurations = this.splitProviderClipDurations(duration); + const actionBeatEnabled = this.booleanFromJson(dto.action_beat_mode) === true; + const requestedBeatCount = this.normalizePositiveInt(dto.action_beat_count, 'action_beat_count', 1, 3, 2); + + if (!actionBeatEnabled || requestedBeatCount <= 1 || duration < 6) { + return baseDurations.map((segmentDuration, index) => ({ + index: index + 1, + duration: segmentDuration, + source_strategy: index === 0 ? 'shot_keyframe' : 'previous_segment_end_frame', + source_keyframe_asset_id: index === 0 ? shot.keyframe_asset_id?.toString() ?? null : null, + beat_label: null, + beat_prompt: null + })); + } + + const beatCount = Math.min(requestedBeatCount, duration >= 9 ? 3 : 2); + const segmentDuration = this.resolveLiveActionActionBeatProviderDuration(duration, beatCount, providerConfig); + const prompts = this.liveActionActionBeatPrompts(shot, beatCount); + + return Array.from({ length: beatCount }, (_, index) => ({ + index: index + 1, + duration: segmentDuration, + source_strategy: index === 0 ? 'shot_keyframe' : 'previous_segment_end_frame', + source_keyframe_asset_id: index === 0 ? shot.keyframe_asset_id?.toString() ?? null : null, + beat_label: prompts[index]?.label ?? `beat_${index + 1}`, + beat_prompt: prompts[index]?.prompt ?? null + })); + } + + private resolveLiveActionActionBeatProviderDuration( + targetDuration: number, + beatCount: number, + providerConfig: ProviderConfig + ) { + const allowed = this.numberArrayFromUnknown(this.jsonObject(providerConfig.config_json).allowed_durations) + .filter((value) => value > 0) + .sort((left, right) => left - right); + const desired = Math.max(2, targetDuration / beatCount); + + if (allowed.length === 0) { + return Number(Math.min(LIVE_ACTION_MAX_PROVIDER_CLIP_SECONDS, desired).toFixed(2)); + } + + return allowed.find((value) => value >= desired) ?? allowed[allowed.length - 1]; + } + + private liveActionActionBeatPrompts(shot: StoryboardShot, beatCount: number) { + const text = [ + shot.scene_type, + shot.scene_name, + shot.location_desc, + shot.action_desc, + shot.actor_action, + shot.camera_instruction, + shot.effect_type + ].filter(Boolean).join(' '); + + if (/管家|黑金卡|劳斯莱斯|继承权|反转|reveal|Rolls/i.test(text)) { + const beats = [ + { + label: 'beat_1_approach_and_card', + prompt: '动作节拍1:雨夜车灯照亮顾辰,老管家撑黑伞从劳斯莱斯旁走近一步,停在顾辰面前,微微鞠躬,把黑金卡和文件袋递到画面中心;镜头只做缓慢推进,重点是递卡动作清楚、手套和卡片可读。' + }, + { + label: 'beat_2_reaction_hold', + prompt: '动作节拍2:承接上一段最后一帧,黑金卡已经递到顾辰面前;顾辰低头看卡,手微微停住,随后缓慢抬眼看向老管家,表情从茫然变成震惊;老管家保持沉稳鞠躬姿态,车灯和雨幕持续。' + }, + { + label: 'beat_3_hook_frame', + prompt: '动作节拍3:承接顾辰震惊反应,镜头靠近黑金卡和顾辰侧脸,管家低声汇报身份,顾辰抬头看向车门方向;最后停在顾辰、黑金卡、劳斯莱斯车灯同框的钩子画面。' + } + ]; + + return beats.slice(0, beatCount); + } + + const generic = [ + { + label: 'beat_1_action_setup', + prompt: '动作节拍1:只完成主要动作的起势和空间关系建立,人物位置、视线方向和道具关系必须清楚,不要切换地点。' + }, + { + label: 'beat_2_action_payoff', + prompt: '动作节拍2:承接上一段最后一帧,完成动作结果和人物反应,保持同一角色、同一服装、同一光线方向。' + }, + { + label: 'beat_3_hook_hold', + prompt: '动作节拍3:承接上一段最后一帧,保留最终情绪和钩子画面,动作收束,不新增角色和地点。' + } + ]; + + return generic.slice(0, beatCount); + } + + private segmentVideoPrompt( + prompt: string, + index: number, + total: number, + duration: number, + segment?: LiveActionProviderClipSegment + ) { + if (total <= 1) return prompt; + + return [ + prompt, + '', + segment?.beat_prompt ? `动作节拍链式生成:${segment.beat_prompt}` : `成本优化分段:这是同一镜头的第 ${index + 1}/${total} 个连续子片段。`, + `本子片段时长:${duration.toFixed(2)} 秒。`, + segment?.source_strategy === 'previous_segment_end_frame' + ? '本子片段首帧来自上一子片段结尾帧,必须承接上一帧姿态、站位、道具位置和光线方向。' + : '本子片段使用镜头原始关键帧作为首帧,建立清晰人物和道具关系。', + '保持同一角色、同一景别、同一动作连续性,避免明显跳切。' + ].join('\n'); + } + + private async storeLiveActionSegmentEndFrameAsset( + project: Project, + episode: Episode, + shot: StoryboardShot, + videoBuffer: Buffer, + segmentIndex: number + ) { + const tempDir = await mkdtemp(join(tmpdir(), 'ai-live-action-end-frame-')); + const sourcePath = join(tempDir, 'segment.mp4'); + const framePath = join(tempDir, 'end-frame.png'); + + try { + await writeFile(sourcePath, videoBuffer); + await execFileAsync('ffmpeg', [ + '-y', + '-hide_banner', + '-loglevel', + 'error', + '-sseof', + '-0.2', + '-i', + sourcePath, + '-frames:v', + '1', + framePath + ]); + const frameBuffer = await readFile(framePath); + const stored = await this.storage.storePrivateFile( + { + originalname: `episode-${episode.id.toString()}-shot-${shot.id.toString()}-beat-${segmentIndex}-end.png`, + mimetype: 'image/png', + size: frameBuffer.length, + buffer: frameBuffer + } as Express.Multer.File, + 'live-action-keyframes' + ); + const asset = await this.prisma.asset.create({ + data: { + user_id: project.user_id, + project_id: project.id, + asset_type: 'image', + file_path: stored.file_path, + file_url: null, + mime_type: 'image/png', + width: LIVE_ACTION_WIDTH, + height: LIVE_ACTION_HEIGHT, + size: stored.size, + hash: stored.hash, + visibility: 'private', + status: 'active' + } + }); + await this.prisma.shotImage.create({ + data: { + project_id: project.id, + episode_id: episode.id, + shot_id: shot.id, + asset_id: asset.id, + image_type: `action_beat_end_${segmentIndex}`, + prompt_text: 'Generated from previous provider segment end frame for action beat chaining.', + status: 'generated' + } + }).catch(() => undefined); + + return asset.id; + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + } + + private async trimLiveActionGeneratedClipBuffer(buffer: Buffer, targetDuration: number) { + const tempDir = await mkdtemp(join(tmpdir(), 'ai-live-action-trim-generated-')); + const inputPath = join(tempDir, 'input.mp4'); + const outputPath = join(tempDir, 'output.mp4'); + + try { + await writeFile(inputPath, buffer); + await execFileAsync('ffmpeg', [ + '-y', + '-hide_banner', + '-loglevel', + 'error', + '-i', + inputPath, + '-t', + targetDuration.toFixed(2), + '-vf', + this.liveActionRenderVideoFilter(), + '-an', + '-c:v', + 'libx264', + '-preset', + 'veryfast', + '-crf', + '22', + '-movflags', + '+faststart', + outputPath + ]); + + return readFile(outputPath); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + } + + private totalDuration(shots: StoryboardShot[]) { + return shots.reduce((sum, shot) => sum + this.normalizeShotDuration(shot), 0); + } + + private async findReusableLiveActionVideoClip(shot: StoryboardShot) { + if (!shot.video_clip_asset_id) { + return null; + } + + const clip = await this.prisma.videoClip.findFirst({ + where: { + shot_id: shot.id, + status: 'generated', + output_asset_id: shot.video_clip_asset_id + }, + orderBy: { created_at: 'desc' } + }); + + if (!clip?.output_asset_id) { + return null; + } + + const clipDuration = Number(clip.duration?.toString()) || 0; + const targetDuration = this.normalizeShotDuration(shot); + + return Math.abs(clipDuration - targetDuration) <= LIVE_ACTION_RENDER_TRIM_TOLERANCE_SECONDS + ? clip + : null; + } + + private async findVideoProviderForEstimate(providerCode?: string) { + const normalizedProviderCode = this.normalizeOptionalText(providerCode, 100); + + if (normalizedProviderCode) { + return this.prisma.providerConfig.findUnique({ + where: { + provider_type_provider_code: { + provider_type: 'VideoProvider', + provider_code: normalizedProviderCode + } + } + }); + } + + const provider = await this.prisma.providerConfig.findFirst({ + where: { + provider_type: 'VideoProvider', + provider_code: 'mock-video' + } + }); + + return provider; + } + + private async ensureMockVideoProvider() { + return this.prisma.providerConfig.upsert({ + where: { + provider_type_provider_code: { + provider_type: 'VideoProvider', + provider_code: 'mock-video' + } + }, + update: { + display_name: 'Mock Video Provider', + mode: 'mock', + model_name: 'mock-video-v1', + is_enabled: true, + priority: 100 + }, + create: { + provider_type: 'VideoProvider', + provider_code: 'mock-video', + display_name: 'Mock Video Provider', + mode: 'mock', + model_name: 'mock-video-v1', + is_enabled: true, + priority: 100, + config_json: { note: 'Returns mock video asset placeholders.' }, + rate_limit_json: { rpm: 60, concurrency: 4 }, + cost_rule_json: { flat_cost: 0, unit: 'mock' } + } + }); + } + + private async ensureMockLipSyncProvider() { + return this.prisma.providerConfig.upsert({ + where: { + provider_type_provider_code: { + provider_type: 'LipSyncProvider', + provider_code: 'mock-lipsync' + } + }, + update: { + display_name: 'Mock Lip Sync Provider', + mode: 'mock', + model_name: 'mock-lipsync-v1', + is_enabled: true, + priority: 100 + }, + create: { + provider_type: 'LipSyncProvider', + provider_code: 'mock-lipsync', + display_name: 'Mock Lip Sync Provider', + mode: 'mock', + model_name: 'mock-lipsync-v1', + is_enabled: true, + priority: 100, + config_json: { + note: 'Mock lip-sync provider for pipeline tests. It does not improve mouth motion.' + }, + rate_limit_json: { rpm: 60, concurrency: 2 }, + cost_rule_json: { flat_cost: 0, unit: 'mock' } + } + }); + } + + private estimateSingleClipCost(rule: Prisma.JsonValue | null, duration: number) { + const costRule = this.jsonObject(rule); + const flatCost = this.numberFromJson(costRule.flat_cost); + const pricePerSecond = this.numberFromJson(costRule.price_per_second); + const pricePerClip = this.numberFromJson(costRule.price_per_clip); + const total = this.splitProviderClipDurations(duration).reduce( + (sum, clipDuration) => sum + flatCost + pricePerClip + clipDuration * pricePerSecond, + 0 + ); + + return Number(total.toFixed(4)); + } + + private async hasLiveActionLipSyncProvider() { + const provider = await this.prisma.providerConfig.findFirst({ + where: { + provider_type: 'LipSyncProvider', + mode: 'real', + is_enabled: true + }, + orderBy: [ + { priority: 'desc' }, + { id: 'asc' } + ] + }); + + return Boolean(provider && provider.provider_type === 'LipSyncProvider' && provider.mode === 'real'); + } + + private async findLiveActionLipSyncProviderConfig(dto: LiveActionGenerateDto) { + const providerCode = this.normalizeOptionalText(dto.lip_sync_provider_code, 100); + + if (providerCode === 'mock-lipsync') { + return this.ensureMockLipSyncProvider(); + } + if (providerCode) { + const provider = await this.prisma.providerConfig.findUnique({ + where: { + provider_type_provider_code: { + provider_type: 'LipSyncProvider', + provider_code: providerCode + } + } + }); + + if (!provider || provider.provider_type !== 'LipSyncProvider' || !provider.is_enabled) { + throw new BadRequestException('LIVE_ACTION_LIP_SYNC_PROVIDER_NOT_AVAILABLE'); + } + + return provider; + } + + const provider = await this.prisma.providerConfig.findFirst({ + where: { + provider_type: 'LipSyncProvider', + mode: 'real', + is_enabled: true + }, + orderBy: [ + { priority: 'desc' }, + { id: 'asc' } + ] + }); + + return provider?.provider_type === 'LipSyncProvider' ? provider : null; + } + + private resolveLiveActionLipSyncPolicy( + shot: StoryboardShot, + providerAvailable: boolean, + budgetDecision?: LiveActionLipSyncBudgetDecision + ): LiveActionLipSyncPolicy { + const hasDialogue = Boolean(this.normalizeDialogueText(shot.dialogue_text)); + const highRiskDialogue = hasDialogue && this.isHighRiskLipSyncShot(shot); + const lipSyncRequired = highRiskDialogue; + let strategy: LiveActionLipSyncStrategy = 'not_required'; + + if (lipSyncRequired && providerAvailable && budgetDecision?.selected === false) { + strategy = 'post_tts_subtitle_light_mouth'; + } else if (lipSyncRequired && providerAvailable) { + strategy = 'provider_lipsync'; + } else if (hasDialogue && !providerAvailable) { + strategy = 'post_tts_subtitle_light_mouth'; + } + + return { + lip_sync_required: lipSyncRequired, + high_risk_dialogue: highRiskDialogue, + has_dialogue: hasDialogue, + provider_available: providerAvailable, + strategy, + reason: this.liveActionLipSyncReason(hasDialogue, highRiskDialogue, providerAvailable, strategy, budgetDecision), + visual_fallback: strategy === 'post_tts_subtitle_light_mouth', + lip_sync_skip_reason: budgetDecision?.selected === false ? budgetDecision.reason : null + }; + } + + private liveActionLipSyncPriorityScore(shot: StoryboardShot) { + const importanceScore = Number(shot.importance_score ?? 5); + const actionScore = Number(shot.action_score ?? 1); + const emotionScore = Number(shot.emotion_score ?? 1); + const routeTierBonus = shot.route_tier === 'premium' ? 30 : 0; + + return routeTierBonus + importanceScore * 10 + actionScore * 5 + emotionScore * 2; + } + + private isHighRiskLipSyncShot(shot: StoryboardShot) { + const text = [ + shot.scene_type, + shot.camera_motion, + shot.camera_instruction, + shot.visual_desc, + shot.action_desc, + shot.actor_action, + shot.performance_instruction, + shot.video_prompt + ].filter(Boolean).join(' ').toLowerCase(); + const explicitlySafe = /远景|背影|侧后方|过肩|环境|wide|long shot|back view|over.?the.?shoulder/.test(text); + + if (explicitlySafe) return false; + if (/特写|近景|正脸|面对镜头|看向镜头|开口|说|台词|close.?up|medium.?close|frontal|front.?facing|talk|speak|says?|dialog/.test(text)) { + return true; + } + + return true; + } + + private liveActionLipSyncReason( + hasDialogue: boolean, + highRiskDialogue: boolean, + providerAvailable: boolean, + strategy: LiveActionLipSyncStrategy, + budgetDecision?: LiveActionLipSyncBudgetDecision + ) { + if (!hasDialogue) return 'no_dialogue'; + if (highRiskDialogue && providerAvailable && budgetDecision?.selected === false) { + return 'high_risk_dialogue_lipsync_budget_exceeded'; + } + if (strategy === 'provider_lipsync') return 'high_risk_dialogue_with_lipsync_provider'; + if (highRiskDialogue && !providerAvailable) return 'high_risk_dialogue_without_lipsync_provider'; + if (!providerAvailable) return 'dialogue_without_lipsync_provider_soft_fallback'; + + return 'dialogue_low_lip_sync_risk'; + } + + private createShotLipSyncPolicyUpdate(shot: StoryboardShot, policy: LiveActionLipSyncPolicy) { + if (!policy.has_dialogue || policy.strategy === 'not_required') { + return {}; + } + + const nextCamera = this.applyLipSyncCameraPolicy(shot.camera_instruction ?? this.cameraInstruction(shot.camera_motion), policy); + const nextPerformance = this.applyLipSyncPerformancePolicy( + shot.performance_instruction ?? this.performanceInstruction(shot.dialogue_text, shot.narration_text), + policy + ); + const nextPrompt = this.applyLipSyncPromptPolicy(shot.video_prompt ?? '', policy); + const nextActorAction = shot.actor_action + ? this.applyLipSyncActionPolicy(shot.actor_action, policy) + : shot.actor_action; + const update: Record = {}; + + if (shot.camera_instruction !== nextCamera) update.camera_instruction = nextCamera; + if (shot.performance_instruction !== nextPerformance) update.performance_instruction = nextPerformance; + if (shot.video_prompt && shot.video_prompt !== nextPrompt) update.video_prompt = nextPrompt; + if (nextActorAction && shot.actor_action !== nextActorAction) update.actor_action = nextActorAction; + + return update; + } + + private applyLipSyncActionPolicy(action: string, policy: LiveActionLipSyncPolicy) { + if (!policy.visual_fallback) return action; + if (action.includes('台词由后期')) return action; + + return [ + action, + '台词由后期 TTS 和字幕承载,画面只做看向对方、轻微微笑或极轻微唇动,避免清晰说话口型和嘴部特写' + ].join('。'); + } + + private applyLipSyncCameraPolicy(camera: string, policy: LiveActionLipSyncPolicy) { + if (!policy.visual_fallback) return camera; + + return [ + 'medium shot, three-quarter angle or slight profile', + 'avoid direct frontal mouth close-up', + 'keep lips small in frame', + 'subtle handheld realism' + ].join(', '); + } + + private applyLipSyncPerformancePolicy(performance: string, policy: LiveActionLipSyncPolicy) { + if (policy.strategy === 'not_required') return performance; + if (performance.includes('lip_sync_strategy:')) return performance; + + if (policy.visual_fallback) { + return [ + performance, + `lip_sync_required: ${policy.lip_sync_required}`, + 'lip_sync_strategy: post_tts_subtitle_light_mouth', + 'Dialogue must be delivered by post-production TTS and burned subtitles; the actor only gives a subtle reaction, slight smile, or tiny mouth movement.' + ].join('\n'); + } + + return [ + performance, + `lip_sync_required: ${policy.lip_sync_required}`, + 'lip_sync_strategy: provider_lipsync', + 'Keep the face stable for downstream lip-sync and avoid exaggerated mouth motion.' + ].join('\n'); + } + + private applyLipSyncPromptPolicy(prompt: string, policy: LiveActionLipSyncPolicy) { + if (policy.strategy === 'not_required' || prompt.includes('lip_sync_strategy:')) { + return prompt; + } + + return [ + prompt, + ...this.liveActionLipSyncPromptLines(policy) + ].filter(Boolean).join('\n'); + } + + private liveActionLipSyncPromptLines(policy: LiveActionLipSyncPolicy) { + if (policy.strategy === 'not_required') return []; + const base = [ + `lip_sync_required: ${policy.lip_sync_required}`, + `lip_sync_strategy: ${policy.strategy}`, + `lip_sync_reason: ${policy.reason}` + ]; + + if (policy.visual_fallback) { + return [ + ...base, + 'The dialogue will be added later as TTS audio and burned subtitles.', + 'Do not animate clear mouth articulation for Chinese speech.', + 'Keep the mouth mostly closed or only slightly moving; use a soft reaction, slight smile, or listening pose.', + 'Avoid direct frontal mouth close-up; prefer medium shot, three-quarter profile, or over-the-shoulder composition.' + ]; + } + + return [ + ...base, + 'Preserve stable face identity for downstream lip-sync; avoid exaggerated mouth shapes.' + ]; + } + + private cameraInstruction(value: string | null) { + if (!value) return 'medium close-up, subtle handheld push in, realistic camera movement'; + + return `medium close-up, ${value}, subtle handheld realism`; + } + + private performanceInstruction(dialogue: string | null, narration: string | null) { + if (dialogue) return `natural short drama acting, clear emotional reaction, dialogue beat: ${dialogue}`; + if (narration) return `natural short drama acting, expressive face, narration beat: ${narration}`; + return 'natural short drama acting, clear facial expression, restrained but readable emotion'; + } + + private defaultVideoPrompt(project: Project, episode: Episode, shot: StoryboardShot) { + return [ + 'photorealistic Chinese vertical short drama, 9:16', + `project=${project.title ?? 'untitled'}`, + `episode=${episode.episode_no}`, + `shot=${shot.shot_no}`, + shot.live_action_desc ?? shot.visual_desc ?? 'realistic short drama shot', + shot.camera_instruction ?? 'medium close-up, handheld camera', + shot.performance_instruction ?? 'natural acting' + ].filter(Boolean).join('\n'); + } + + private async createRenderTask( + projectId: bigint, + episodeId: bigint | null, + shotId: bigint | null, + taskType: string, + inputJson: Prisma.InputJsonObject + ) { + const inputHash = this.hashJson(inputJson); + + return this.prisma.renderTask.create({ + data: { + project_id: projectId, + episode_id: episodeId, + shot_id: shotId, + task_type: taskType, + status: 'pending', + input_json: inputJson, + input_hash: inputHash, + idempotency_key: `${taskType}:${projectId.toString()}:${episodeId?.toString() ?? 'none'}:${shotId?.toString() ?? 'none'}:${inputHash}:${Date.now()}`, + retry_count: 0, + max_retry: 2 + } + }); + } + + private createQueuedLiveActionTaskInput( + user: AuthRequestUser, + clip: VideoClip, + dto: LiveActionGenerateDto | LiveActionQualityCheckDto, + extras: Record + ): Prisma.InputJsonObject { + return this.toJsonValue({ + source_clip_id: clip.id.toString(), + project_id: clip.project_id.toString(), + episode_id: clip.episode_id.toString(), + shot_id: clip.shot_id.toString(), + requested_by_user_id: user.id, + requested_by_email: user.email ?? '', + requested_by_role: user.role, + requested_at: new Date().toISOString(), + dto: this.toJsonValue(dto), + ...extras + }) as Prisma.InputJsonObject; + } + + private liveActionQueueIdempotencyKey(action: string, clipId: bigint) { + return `live-action:${action}:clip:${clipId.toString()}:${Date.now()}:${Math.random().toString(36).slice(2)}`; + } + + private userFromQueuedLiveActionTask(input: Record): AuthRequestUser { + const id = this.stringifyText(input.requested_by_user_id); + + if (!id) { + throw new BadRequestException('Queued live action task missing requested_by_user_id'); + } + + return { + id, + email: this.stringifyText(input.requested_by_email) || 'queued-worker@local', + role: this.stringifyText(input.requested_by_role) || 'admin' + }; + } + + private dtoFromQueuedLiveActionTask( + input: Record + ): LiveActionGenerateDto & LiveActionQualityCheckDto { + const dto = this.jsonObject(input.dto ?? null); + + return { + force: this.booleanFromJson(dto.force), + provider_code: this.stringifyText(dto.provider_code) || undefined, + confirm_real_video: this.booleanFromJson(dto.confirm_real_video), + max_cost_per_clip: this.stringifyText(dto.max_cost_per_clip) || this.optionalNumberFromJson(dto.max_cost_per_clip)?.toString(), + action_beat_mode: this.booleanFromJson(dto.action_beat_mode), + action_beat_count: this.stringifyText(dto.action_beat_count) || this.optionalNumberFromJson(dto.action_beat_count)?.toString(), + auto_repair: this.booleanFromJson(dto.auto_repair), + min_quality_score: this.stringifyText(dto.min_quality_score) || this.optionalNumberFromJson(dto.min_quality_score)?.toString() + }; + } + + private outputAssetIdFromQueuedLiveActionResult(result: unknown) { + const output = this.jsonObject(result as Prisma.InputJsonValue); + const repairedClip = this.jsonObject(output.repaired_clip ?? null); + const clip = Object.keys(repairedClip).length > 0 + ? repairedClip + : this.jsonObject(output.video_clip ?? null); + + return this.toBigIntOrNull(clip.output_asset_id); + } + + private async writeRouterAuditOperationLog( + user: AuthRequestUser, + action: string, + clip: VideoClip, + metadata: Record + ) { + await this.prisma.operationLog.create({ + data: { + user_id: this.parseId(user.id, 'Invalid user id'), + operator_role: user.role, + action, + target_type: 'video_clip', + target_id: clip.id, + metadata_json: this.toJsonValue({ + project_id: clip.project_id.toString(), + episode_id: clip.episode_id.toString(), + shot_id: clip.shot_id.toString(), + clip_id: clip.id.toString(), + ...metadata + }) + } + }); + } + + private async findVideoClipOrThrow(clipId: string) { + const clip = await this.prisma.videoClip.findUnique({ + where: { id: this.parseId(clipId, 'Invalid video clip id') } + }); + + if (!clip) { + throw new NotFoundException('Video clip not found'); + } + + return clip; + } + + private async findShotForEpisodeOrThrow(episodeId: string, shotId: string) { + const shot = await this.prisma.storyboardShot.findUnique({ + where: { id: this.parseId(shotId, 'Invalid storyboard shot id') } + }); + + if (!shot || shot.episode_id !== this.parseId(episodeId, 'Invalid episode id')) { + throw new NotFoundException('Storyboard shot not found'); + } + + return shot; + } + + private normalizeManualQualityStatus(value: unknown) { + const status = this.stringifyText(value) || 'manual_required'; + + if (['passed', 'rejected', 'manual_required', 'needs_retry'].includes(status)) { + return status; + } + + throw new BadRequestException('Invalid manual review status'); + } + + private manualQualityScore(status: string, value: unknown) { + const score = this.optionalNumberFromJson(value); + + if (score !== null) { + return Math.max(0, Math.min(100, score)); + } + if (status === 'passed') return 100; + if (status === 'rejected') return 0; + + return null; + } + + private defaultManualQualityReason(status: string) { + if (status === 'passed') return 'Manual sample pass'; + if (status === 'rejected') return 'Manual sample rejected'; + if (status === 'needs_retry') return 'Manual sample needs retry'; + + return 'Manual sample requires handling'; + } + + private async findProjectForUser(projectId: string, user: AuthRequestUser) { + const project = await this.prisma.project.findUnique({ + where: { id: this.parseId(projectId, 'Invalid project id') } + }); + + if (!project) { + throw new NotFoundException('Project not found'); + } + if (project.user_id.toString() !== user.id && user.role !== 'admin') { + throw new ForbiddenException('Project is private'); + } + + return project; + } + + private async findEpisodeForUser(episodeId: string, user: AuthRequestUser) { + const episode = await this.prisma.episode.findUnique({ + where: { id: this.parseId(episodeId, 'Invalid episode id') } + }); + + if (!episode) { + throw new NotFoundException('Episode not found'); + } + + const project = await this.findProjectForUser(episode.project_id.toString(), user); + + return { episode, project }; + } + + private assertLiveActionProject(project: Project) { + if (project.output_mode !== 'live_action_ai') { + throw new BadRequestException('Project output_mode must be live_action_ai'); + } + } + + private normalizeVideoMimeType(value: string | null) { + const normalized = (value || '').toLowerCase(); + + if (normalized.includes('quicktime')) return 'video/quicktime'; + if (normalized.includes('webm')) return 'video/webm'; + if (normalized.includes('mp4') || normalized.includes('mpeg')) return 'video/mp4'; + + return 'video/mp4'; + } + + private videoExtensionFromMime(value: string) { + if (value.includes('webm')) return '.webm'; + if (value.includes('quicktime')) return '.mov'; + return '.mp4'; + } + + private mediaDataUri(mimeType: string, buffer: Buffer) { + return `data:${mimeType};base64,${buffer.toString('base64')}`; + } + + private publicHttpUrl(value: string | null | undefined) { + const normalized = this.stringifyText(value); + + return /^https?:\/\//i.test(normalized) ? normalized : null; + } + + private normalizeImageMimeType(value: string | null) { + const normalized = (value || '').toLowerCase(); + + if (normalized.includes('png')) return 'image/png'; + if (normalized.includes('jpeg') || normalized.includes('jpg')) return 'image/jpeg'; + if (normalized.includes('webp')) return 'image/webp'; + + return ''; + } + + private jsonObject(value: Prisma.InputJsonValue | Prisma.JsonValue | null) { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + + return {}; + } + + private stringifyText(value: unknown) { + return typeof value === 'string' ? value.trim() : ''; + } + + private numberFromJson(value: unknown) { + const numberValue = Number(value ?? 0); + + return Number.isFinite(numberValue) ? numberValue : 0; + } + + private optionalNumberFromJson(value: unknown) { + if (value === undefined || value === null || value === '') { + return null; + } + + const numberValue = Number(value); + + return Number.isFinite(numberValue) ? numberValue : null; + } + + private booleanFromJson(value: unknown) { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + + if (normalized === 'true') return true; + if (normalized === 'false') return false; + } + + return undefined; + } + + private numberArrayFromUnknown(value: unknown) { + if (!Array.isArray(value)) return []; + + return value + .map((item) => Number(item)) + .filter((item) => Number.isFinite(item)); + } + + private bigintArrayFromStrings(values: string[]) { + return values + .filter((value) => /^\d+$/.test(value)) + .map((value) => BigInt(value)); + } + + private stringArray(value: unknown) { + if (!Array.isArray(value)) { + return []; + } + + return value + .map((item) => this.stringifyText(item)) + .filter((item): item is string => Boolean(item)); + } + + private uniqueStrings(values: Array) { + return [...new Set(values.map((value) => value?.trim()).filter((value): value is string => Boolean(value)))]; + } + + private normalizeOptionalText(value: unknown, maxLength: number) { + if (typeof value !== 'string') { + return undefined; + } + + const normalized = value.trim(); + + if (!normalized) { + return undefined; + } + if (normalized.length > maxLength) { + throw new BadRequestException(`Text value must be ${maxLength} characters or less`); + } + + return normalized; + } + + private limitText(value: string, maxLength: number) { + return value.length > maxLength ? value.slice(0, maxLength) : value; + } + + private toJsonValue(value: unknown): Prisma.InputJsonValue { + if (value === null) { + return ''; + } + if (typeof value === 'string' || typeof value === 'boolean') { + return value; + } + if (typeof value === 'number') { + return Number.isFinite(value) ? value : 0; + } + if (Array.isArray(value)) { + return value.map((item) => this.toJsonValue(item)); + } + if (typeof value === 'object') { + const output: Record = {}; + + for (const [key, child] of Object.entries(value as Record)) { + if (child !== undefined) { + output[key] = this.toJsonValue(child); + } + } + + return output as Prisma.InputJsonObject; + } + + return String(value); + } + + private parseId(value: string, message: string) { + try { + return BigInt(value); + } catch { + throw new BadRequestException(message); + } + } + + private hashJson(value: unknown) { + return createHash('sha256').update(JSON.stringify(value)).digest('hex'); + } + + private toError(error: unknown) { + return error instanceof Error ? error : new Error(String(error)); + } +} diff --git a/backend/src/live-action/live-action.types.ts b/backend/src/live-action/live-action.types.ts new file mode 100644 index 0000000..71e22fe --- /dev/null +++ b/backend/src/live-action/live-action.types.ts @@ -0,0 +1,127 @@ +import type { ActorProfile, Prisma, StoryboardShot, VideoClip } from '@prisma/client'; + +export interface SafeActorProfile { + id: string; + project_id: string; + character_id: string; + actor_desc: string | null; + appearance_rules: string | null; + wardrobe_rules: string | null; + performance_style: string | null; + voice_style: string | null; + reference_asset_ids: Prisma.JsonValue | null; + anchor_asset_id: string | null; + status: string; + created_at: string; + updated_at: string; +} + +export interface SafeLiveActionShot { + id: string; + project_id: string; + episode_id: string; + shot_no: number; + scene_name: string | null; + live_action_desc: string | null; + actor_action: string | null; + camera_instruction: string | null; + performance_instruction: string | null; + scene_type: string | null; + importance_score: number | null; + emotion_score: number | null; + action_score: number | null; + route_tier: string | null; + video_prompt: string | null; + keyframe_asset_id: string | null; + video_clip_asset_id: string | null; + video_status: string | null; + duration: string | null; + status: string; + updated_at: string; +} + +export interface SafeVideoClip { + id: string; + project_id: string; + episode_id: string; + shot_id: string; + provider_id: string | null; + input_asset_id: string | null; + output_asset_id: string | null; + duration: string | null; + prompt_text: string | null; + status: string; + cost_actual: number | null; + retry_count: number; + quality_status: string | null; + quality_score: number | null; + quality_issues: Prisma.JsonValue | null; + created_at: string; + updated_at: string; +} + +export function toSafeActorProfile(profile: ActorProfile): SafeActorProfile { + return { + id: profile.id.toString(), + project_id: profile.project_id.toString(), + character_id: profile.character_id.toString(), + actor_desc: profile.actor_desc, + appearance_rules: profile.appearance_rules, + wardrobe_rules: profile.wardrobe_rules, + performance_style: profile.performance_style, + voice_style: profile.voice_style, + reference_asset_ids: profile.reference_asset_ids, + anchor_asset_id: profile.anchor_asset_id?.toString() ?? null, + status: profile.status, + created_at: profile.created_at.toISOString(), + updated_at: profile.updated_at.toISOString() + }; +} + +export function toSafeLiveActionShot(shot: StoryboardShot): SafeLiveActionShot { + return { + id: shot.id.toString(), + project_id: shot.project_id.toString(), + episode_id: shot.episode_id.toString(), + shot_no: shot.shot_no, + scene_name: shot.scene_name, + live_action_desc: shot.live_action_desc, + actor_action: shot.actor_action, + camera_instruction: shot.camera_instruction, + performance_instruction: shot.performance_instruction, + scene_type: shot.scene_type, + importance_score: shot.importance_score, + emotion_score: shot.emotion_score, + action_score: shot.action_score, + route_tier: shot.route_tier, + video_prompt: shot.video_prompt, + keyframe_asset_id: shot.keyframe_asset_id?.toString() ?? null, + video_clip_asset_id: shot.video_clip_asset_id?.toString() ?? null, + video_status: shot.video_status, + duration: shot.duration?.toString() ?? null, + status: shot.status, + updated_at: shot.updated_at.toISOString() + }; +} + +export function toSafeVideoClip(clip: VideoClip): SafeVideoClip { + return { + id: clip.id.toString(), + project_id: clip.project_id.toString(), + episode_id: clip.episode_id.toString(), + shot_id: clip.shot_id.toString(), + provider_id: clip.provider_id?.toString() ?? null, + input_asset_id: clip.input_asset_id?.toString() ?? null, + output_asset_id: clip.output_asset_id?.toString() ?? null, + duration: clip.duration?.toString() ?? null, + prompt_text: clip.prompt_text, + status: clip.status, + cost_actual: clip.cost_actual ? Number(clip.cost_actual.toString()) : null, + retry_count: clip.retry_count, + quality_status: clip.quality_status, + quality_score: clip.quality_score ? Number(clip.quality_score.toString()) : null, + quality_issues: clip.quality_issues, + created_at: clip.created_at.toISOString(), + updated_at: clip.updated_at.toISOString() + }; +} diff --git a/backend/src/live-action/prompt-builder.service.spec.ts b/backend/src/live-action/prompt-builder.service.spec.ts new file mode 100644 index 0000000..458d0bd --- /dev/null +++ b/backend/src/live-action/prompt-builder.service.spec.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from 'vitest'; +import { LiveActionPromptBuilderService } from './prompt-builder.service'; + +describe('LiveActionPromptBuilderService', () => { + const builder = new LiveActionPromptBuilderService(); + + it('builds a Hailuo profile prompt with camera syntax and compact audit components', () => { + const result = builder.buildLiveActionVideoPrompt({ + projectTitle: '屏幕恋人', + episodeNo: 1, + episodeTitle: '她从屏幕里出现', + shotNo: 3, + providerCode: 'minimax_hailuo_23_fast', + sceneType: 'dimensional_break', + routeTier: 'premium', + durationSeconds: 6, + characters: '白裙少女、程序员', + actorConsistencyRules: '白裙少女保持同一张脸、白色连衣裙、黑色长发', + location: '深夜程序员桌面,笔记本电脑发出冷蓝色光', + action: '白裙少女从笔记本屏幕边缘伸出手,慢慢跨入现实房间', + cameraMotion: 'zoom_in', + performanceInstruction: '好奇、温柔,但动作克制真实', + effectType: 'digital portal', + directorPlan: { + plan_version: 'live-action-director-plan-v1', + scene_group_id: 'scene-1', + scene_beat: 'screen emergence', + shot_role: 'reveal', + shot_size: 'medium reveal shot', + blocking: 'the character slowly reaches through the laptop edge while the programmer holds still', + continuity_in: 'cut from the programmer eyeline to the laptop screen', + continuity_out: 'end with the hand crossing the frame edge', + edit_intent: 'sell the impossible action with a delayed reveal', + sound_bridge: 'digital shimmer carries across the edit' + }, + lipSyncPolicy: { + lip_sync_required: false, + strategy: 'not_required', + reason: 'no_dialogue', + visual_fallback: false + }, + scores: { + importance_score: 10, + emotion_score: 8, + action_score: 8, + route_tier: 'premium' + } + }); + + expect(result.provider_profile).toBe('hailuo'); + expect(result.prompt).toContain('[推进]'); + expect(result.prompt).toContain('导演分镜'); + expect(result.prompt).toContain('剪辑目的'); + expect(result.prompt).toContain('后期音效提示'); + expect(result.prompt.length).toBeLessThanOrEqual(1800); + expect(result.components).toEqual( + expect.objectContaining({ + prompt_version: 'live-action-prompt-engine-v1', + provider_profile: 'hailuo', + scene_type: 'dimensional_break', + route_tier: 'premium', + camera_tag: '[推进]' + }) + ); + expect(result.negative_prompt).toContain('anime style'); + }); + + it('keeps high-risk dialogue away from frontal mouth close-ups when lip-sync falls back to TTS subtitles', () => { + const result = builder.buildLiveActionVideoPrompt({ + providerCode: 'kling_21', + sceneType: 'dialog', + routeTier: 'normal', + durationSeconds: 5, + characters: '林晚', + location: '会议室', + action: '林晚抬头看向对方,准备说出关键台词', + cameraInstruction: 'front close-up dialogue', + dialogueText: '这一回,我不会再退。', + lipSyncPolicy: { + lip_sync_required: true, + strategy: 'post_tts_subtitle_light_mouth', + reason: 'high_risk_dialogue_without_lipsync_provider', + visual_fallback: true + } + }); + + expect(result.provider_profile).toBe('kling'); + expect(result.prompt).toContain('medium shot, three-quarter angle'); + expect(result.prompt).toContain('post_tts_subtitle_light_mouth'); + expect(result.negative_prompt).toContain('frontal mouth close-up'); + }); + + it('adds motion-director choreography for xianxia hand-seal power-up shots', () => { + const result = builder.buildLiveActionVideoPrompt({ + projectTitle: '法相天地', + episodeNo: 1, + episodeTitle: '千臂法身', + shotNo: 2, + providerCode: 'minimax_hailuo_23_fast', + sceneType: 'xianxia_transformation', + routeTier: 'premium', + durationSeconds: 3, + characters: '浴血白裙女仙,银质头饰,保持同一张脸', + location: '崩塌废墟,乌云压低,碎石悬浮', + action: '镜头紧贴她翻飞的皓腕,十指如古典舞般柔美却迅捷地交错结印。', + visualDescription: '胸前骤然聚起高速旋转的紫色光球,强大气流撕扯半透仙裙。', + cameraMotion: '手部特写接环绕跟拍', + effectType: '繁花结印,紫色光球,灵力电流' + }); + + expect(result.provider_profile).toBe('hailuo'); + expect(result.prompt).toContain('动作导演'); + expect(result.prompt).toContain('时间节奏'); + expect(result.prompt).toContain('结印手法必须清楚'); + expect(result.prompt).toContain('食指中指并拢'); + expect(result.prompt).toContain('lotus seal'); + expect(result.prompt).toContain('紫色光球'); + expect(result.negative_prompt).toContain('random hand waving'); + expect(result.components.motion_director).toEqual( + expect.objectContaining({ + motion_version: 'live-action-motion-director-v1', + beat_style: expect.stringContaining('hand-seal') + }) + ); + }); + + it('adds motion-director scale beats for thousand-arm dharma-form shots', () => { + const result = builder.buildLiveActionVideoPrompt({ + providerCode: 'kling_21', + sceneType: 'xianxia_transformation', + routeTier: 'premium', + durationSeconds: 4, + characters: '白裙女仙', + location: '废墟战场,地面裂开', + action: '女仙双臂猛然向后一展,身后透明千臂法身拔地而起。', + visualDescription: '千只巨手结出不同仙印,碎石失重悬浮并瞬间粉化。', + cameraMotion: '贴地极低视角仰拍', + effectType: '法相天地 千臂法身 重低音轰鸣 碎石粉化' + }); + + expect(result.prompt).toContain('Motion director'); + expect(result.prompt).toContain('千臂法身'); + expect(result.prompt).toContain('巨手依次结出不同仙印'); + expect(result.prompt).toContain('贴地低角度仰拍'); + expect(result.negative_prompt).toContain('tiny dharma body'); + expect(result.components.motion_director?.time_beats.join(' ')).toContain('千只巨手'); + }); + + it('builds a 10-second one-shot xianxia climax prompt for Hailuo', () => { + const result = builder.buildLiveActionVideoPrompt({ + projectTitle: '法相天地', + episodeNo: 1, + episodeTitle: '一镜到底', + shotNo: 1, + providerCode: 'minimax_hailuo_23_fast', + sceneType: 'xianxia_transformation', + routeTier: 'premium', + durationSeconds: 10, + characters: '白裙女仙,黑色长发,银质头饰,保持同一张脸和服装', + location: '崩塌的仙侠废墟战场,残垣断壁,碎石悬浮,狂风呼啸', + action: '白裙女仙落地撑地,抬头觉醒,双手结印,紫色光球聚能,双臂展开,千臂法身升起。', + visualDescription: '同一镜头内完成落地、结印、法相天地爆发,透明千臂法身拔地而起。', + cameraMotion: 'one-shot push in, hand close-up, low-angle upward follow', + effectType: '法相天地 千臂法身 紫色光球 结印 落地 碎石粉化' + }); + + expect(result.provider_profile).toBe('hailuo'); + expect(result.prompt).toContain('10秒'); + expect(result.prompt).toContain('一镜到底动作链'); + expect(result.prompt).toContain('0.0-2.0s'); + expect(result.prompt).toContain('3.0-5.0s'); + expect(result.prompt).toContain('8.0-10.0s'); + expect(result.prompt).toContain('双手在胸前清晰结印'); + expect(result.prompt).toContain('千臂法身完全展开'); + expect(result.prompt.length).toBeLessThanOrEqual(2400); + expect(result.negative_prompt).toContain('multi-shot montage'); + expect(result.negative_prompt).toContain('character identity drift'); + }); +}); diff --git a/backend/src/live-action/prompt-builder.service.ts b/backend/src/live-action/prompt-builder.service.ts new file mode 100644 index 0000000..efcf997 --- /dev/null +++ b/backend/src/live-action/prompt-builder.service.ts @@ -0,0 +1,711 @@ +import { Injectable } from '@nestjs/common'; + +export type LiveActionPromptProfile = 'generic' | 'hailuo' | 'kling' | 'mock'; + +export interface LiveActionPromptLipSyncPolicyInput { + lip_sync_required: boolean; + strategy: string; + reason: string; + visual_fallback: boolean; +} + +export interface LiveActionPromptScoresInput { + importance_score: number; + emotion_score: number; + action_score: number; + route_tier: string; +} + +export interface LiveActionPromptDirectorPlanInput { + plan_version: string; + scene_group_id: string; + scene_beat: string; + shot_role: string; + shot_size: string; + blocking: string; + continuity_in: string; + continuity_out: string; + edit_intent: string; + sound_bridge: string; +} + +export interface LiveActionPromptMotionDirectorInput { + motion_version: string; + beat_style: string; + action_technique: string; + time_beats: string[]; + camera_rhythm: string; + vfx_timing: string; + sound_hits: string; + negative_motion: string[]; +} + +export interface LiveActionPromptBuildInput { + projectTitle?: string | null; + episodeNo?: number | null; + episodeTitle?: string | null; + shotNo?: number | null; + providerCode?: string | null; + sceneType?: string | null; + routeTier?: string | null; + durationSeconds: number; + characters: string; + actorConsistencyRules?: string | null; + location: string; + action: string; + visualDescription?: string | null; + cameraMotion?: string | null; + cameraInstruction?: string | null; + performanceInstruction?: string | null; + dialogueText?: string | null; + narrationText?: string | null; + effectType?: string | null; + scores?: LiveActionPromptScoresInput | null; + lipSyncPolicy?: LiveActionPromptLipSyncPolicyInput | null; + directorPlan?: LiveActionPromptDirectorPlanInput | null; +} + +export interface LiveActionPromptComponents { + prompt_version: string; + provider_profile: LiveActionPromptProfile; + scene_type: string; + route_tier: string; + aspect_ratio: string; + visual_style: string; + characters: string; + actor_consistency_rules: string | null; + location: string; + main_action: string; + camera_shot: string; + camera_move: string; + camera_tag: string | null; + performance: string; + lighting: string; + vfx_cue: string | null; + sound_cue: string | null; + duration_seconds: number; + continuity_rules: string[]; + motion_director: LiveActionPromptMotionDirectorInput | null; + director_plan: LiveActionPromptDirectorPlanInput | null; + lip_sync: LiveActionPromptLipSyncPolicyInput | null; + negative_prompt: string; +} + +export interface LiveActionPromptBuildResult { + prompt: string; + negative_prompt: string; + components: LiveActionPromptComponents; + prompt_version: string; + provider_profile: LiveActionPromptProfile; +} + +type SceneTemplate = { + sceneType: string; + cameraShot: string; + cameraMove: string; + lighting: string; + performance: string; + vfxCue: string | null; + soundCue: string | null; + negative: string[]; +}; + +const PROMPT_VERSION = 'live-action-prompt-engine-v1'; +const DEFAULT_NEGATIVE = [ + 'anime style', + 'comic style', + 'cartoon face', + 'text overlays', + 'burned subtitles', + 'watermark', + 'logo', + 'distorted hands', + 'extra fingers', + 'face drift', + 'identity change', + 'overexposed skin', + 'low resolution', + 'random scene cuts' +]; + +@Injectable() +export class LiveActionPromptBuilderService { + buildLiveActionVideoPrompt(input: LiveActionPromptBuildInput): LiveActionPromptBuildResult { + const providerProfile = this.resolveProviderProfile(input.providerCode); + const sceneType = this.normalizeSceneType(input.sceneType, input); + const template = this.sceneTemplate(sceneType); + const routeTier = this.clean(input.routeTier) || input.scores?.route_tier || 'normal'; + const cameraShot = this.resolveCameraShot(input, template); + const cameraMove = this.resolveCameraMove(input, template, providerProfile); + const cameraTag = providerProfile === 'hailuo' ? this.hailuoCameraTag(cameraMove, input.cameraMotion) : null; + const lipSync = input.lipSyncPolicy ?? null; + const motionDirector = this.buildMotionDirector(input, sceneType); + const negativePrompt = this.joinUnique([ + ...DEFAULT_NEGATIVE, + ...template.negative, + ...(motionDirector?.negative_motion ?? []), + ...(input.directorPlan ? ['montage slideshow look', 'unmotivated time jump', 'new location jump cut'] : []), + ...(lipSync?.visual_fallback ? ['frontal mouth close-up', 'clear Chinese mouth articulation'] : []), + ...(providerProfile === 'hailuo' ? ['long multi-action sequence in one clip'] : []), + ...(providerProfile === 'kling' ? ['inconsistent motion physics'] : []) + ], ', '); + const components: LiveActionPromptComponents = { + prompt_version: PROMPT_VERSION, + provider_profile: providerProfile, + scene_type: sceneType, + route_tier: routeTier, + aspect_ratio: '9:16 vertical video', + visual_style: 'photorealistic Chinese live-action short drama', + characters: this.clean(input.characters) || 'main characters', + actor_consistency_rules: this.clean(input.actorConsistencyRules) || null, + location: this.clean(input.location) || 'modern Chinese short-drama location', + main_action: this.resolveMainAction(input, template), + camera_shot: cameraShot, + camera_move: cameraMove, + camera_tag: cameraTag, + performance: this.resolvePerformance(input, template), + lighting: this.resolveLighting(input, template, routeTier), + vfx_cue: this.resolveVfxCue(input.effectType, sceneType, template), + sound_cue: this.resolveSoundCue(input.effectType, sceneType, template), + duration_seconds: this.clampDuration(input.durationSeconds), + continuity_rules: this.continuityRules(providerProfile, lipSync, input.directorPlan ?? null), + motion_director: motionDirector, + director_plan: input.directorPlan ?? null, + lip_sync: lipSync, + negative_prompt: negativePrompt + }; + + return { + prompt: this.composePrompt(input, components), + negative_prompt: negativePrompt, + components, + prompt_version: PROMPT_VERSION, + provider_profile: providerProfile + }; + } + + private composePrompt(input: LiveActionPromptBuildInput, components: LiveActionPromptComponents) { + const profile = components.provider_profile; + + if (profile === 'hailuo') { + const maxPromptLength = components.duration_seconds >= 9 ? 2400 : 1800; + + return this.limitPrompt([ + '真人短剧竖屏9:16,写实电影感,适合抖音短剧。', + components.camera_tag, + `项目:${this.clean(input.projectTitle) || '真人短剧'};第${input.episodeNo ?? '-'}集:${this.clean(input.episodeTitle) || '短剧片段'};镜头${input.shotNo ?? '-'}`, + `场景:${components.location}`, + `人物:${components.characters}`, + components.actor_consistency_rules ? `演员一致性:${components.actor_consistency_rules}` : null, + `主动作:${components.main_action}`, + ...this.motionDirectorLinesZh(components), + ...this.directorPlanLinesZh(components), + `镜头:${components.camera_shot},${components.camera_move}`, + `表演:${components.performance}`, + `光线:${components.lighting}`, + components.vfx_cue ? `视觉特效:${components.vfx_cue}` : null, + components.sound_cue ? `后期音效提示:${components.sound_cue}` : null, + ...this.lipSyncLines(components), + `时长:${components.duration_seconds}秒,只完成一个主要动作,动作连续,不要突然切场景。`, + `避免:${components.negative_prompt}` + ], maxPromptLength); + } + + if (profile === 'mock') { + return [ + `prompt_version: ${components.prompt_version}`, + `provider_profile: ${profile}`, + `scene_type: ${components.scene_type}`, + `route_tier: ${components.route_tier}`, + `characters: ${components.characters}`, + components.actor_consistency_rules ? `演员一致性 / actor_consistency: ${components.actor_consistency_rules}` : null, + `scene: ${components.location}`, + `action: ${components.main_action}`, + `camera: ${components.camera_shot}; ${components.camera_move}`, + `performance: ${components.performance}`, + components.vfx_cue ? `vfx: ${components.vfx_cue}` : null, + components.sound_cue ? `sound_cue: ${components.sound_cue}` : null, + ...this.motionDirectorLinesMock(components), + ...this.directorPlanLinesMock(components), + ...this.lipSyncLines(components), + `duration: ${components.duration_seconds}s`, + `negative_prompt: ${components.negative_prompt}` + ].filter(Boolean).join('\n'); + } + + return this.limitPrompt([ + 'Photorealistic Chinese vertical short drama, 9:16 vertical video.', + `Project: ${this.clean(input.projectTitle) || 'live action short drama'}. Episode ${input.episodeNo ?? '-'}: ${this.clean(input.episodeTitle) || 'short drama episode'}. Shot ${input.shotNo ?? '-'}.`, + `Scene type: ${components.scene_type}. Route tier: ${components.route_tier}.`, + `Location: ${components.location}.`, + `Characters: ${components.characters}.`, + components.actor_consistency_rules ? `Actor consistency rules: ${components.actor_consistency_rules}.` : null, + `Main action: ${components.main_action}.`, + ...this.motionDirectorLinesEn(components), + ...this.directorPlanLinesEn(components), + `Camera shot: ${components.camera_shot}.`, + `Camera movement: ${components.camera_move}.`, + `Performance: ${components.performance}.`, + `Lighting: ${components.lighting}.`, + components.vfx_cue ? `Visual effects: ${components.vfx_cue}.` : null, + components.sound_cue ? `Sound cue for post-production: ${components.sound_cue}.` : null, + ...this.lipSyncLines(components), + `Continuity: ${components.continuity_rules.join('; ')}.`, + `Duration: ${components.duration_seconds} seconds. Complete one clear action only, with continuous motion and no random scene cuts.`, + `Avoid: ${components.negative_prompt}.` + ], profile === 'kling' ? 2200 : 2000); + } + + private sceneTemplate(sceneType: string): SceneTemplate { + const templates: Record = { + dialog: { + sceneType: 'dialog', + cameraShot: 'medium shot or over-the-shoulder composition', + cameraMove: 'slow push-in with subtle handheld realism', + lighting: 'natural indoor cinematic lighting', + performance: 'restrained short-drama acting, readable eye contact and reaction', + vfxCue: null, + soundCue: 'quiet room tone, soft dramatic underscore', + negative: ['exaggerated mouth movement', 'static CCTV angle'] + }, + conflict: { + sceneType: 'conflict', + cameraShot: 'medium close-up with reaction space', + cameraMove: 'controlled push-in, slight handheld tension', + lighting: 'high-contrast realistic short-drama lighting', + performance: 'tense eye contact, controlled anger, clear reaction beat', + vfxCue: null, + soundCue: 'low tension hit, subtle heartbeat ambience', + negative: ['comedy expression', 'random action jump'] + }, + reveal: { + sceneType: 'reveal', + cameraShot: 'close-up detail then readable character reaction', + cameraMove: 'slow dolly-in, suspenseful pause', + lighting: 'focused cinematic key light with realistic shadows', + performance: 'shock is visible but restrained, short-drama reveal beat', + vfxCue: 'brief highlight on the revealed object or face', + soundCue: 'short reveal sting, low bass swell', + negative: ['overly magical glow', 'unreadable object'] + }, + dimensional_break: { + sceneType: 'dimensional_break', + cameraShot: 'close-up on laptop screen then medium shot of the real room', + cameraMove: 'camera tracks from screen edge into the real space', + lighting: 'dark room with screen glow and realistic rim light', + performance: 'curious, controlled expression, surreal but believable', + vfxCue: 'digital shimmer at the screen boundary, soft portal glow', + soundCue: 'digital shimmer, tiny electric crackle, soft portal pulse', + negative: ['full cartoon body', 'warped laptop', 'mismatched scale'] + }, + xianxia_transformation: { + sceneType: 'xianxia_transformation', + cameraShot: 'low-angle heroic shot with large scale background', + cameraMove: 'fast push-in then upward follow movement', + lighting: 'volumetric golden light, stormy sky contrast', + performance: 'divine wrath, calm but overwhelming power', + vfxCue: 'golden runes, lightning, shockwave, huge scale', + soundCue: 'thunder roar, energy burst, deep impact hit', + negative: ['small scale', 'cheap game effect', 'chaotic camera'] + }, + action: { + sceneType: 'action', + cameraShot: 'medium wide shot keeping the full body readable', + cameraMove: 'tracking movement with stable handheld energy', + lighting: 'realistic cinematic light with clear subject separation', + performance: 'decisive movement, believable body mechanics', + vfxCue: null, + soundCue: 'movement whoosh, short impact accent', + negative: ['motion smear', 'broken limbs', 'unreadable action'] + } + }; + + return templates[sceneType] ?? templates.dialog; + } + + private resolveProviderProfile(providerCode?: string | null): LiveActionPromptProfile { + const code = (providerCode || '').toLowerCase(); + + if (!code || code.includes('mock')) return 'mock'; + if (/hailuo|minimax|video-0|seaweed/.test(code)) return 'hailuo'; + if (/kling|kuaishou|可灵/.test(code)) return 'kling'; + + return 'generic'; + } + + private normalizeSceneType(value: string | null | undefined, input: LiveActionPromptBuildInput) { + const explicit = this.clean(value); + const text = `${value || ''} ${input.effectType || ''} ${input.action || ''} ${input.visualDescription || ''}`.toLowerCase(); + + if (/dimensional|次元|屏幕|laptop|portal|screen/.test(text)) return 'dimensional_break'; + if (/法相|xianxia|仙侠|dharma|giant|rune|lightning|transform/.test(text)) return 'xianxia_transformation'; + if (explicit) return explicit; + if (/conflict|争吵|反击|打脸|confront/.test(text)) return 'conflict'; + if (/reveal|曝光|发现|证据|反转/.test(text)) return 'reveal'; + if (/run|fight|追|打|爆炸|action/.test(text)) return 'action'; + + return 'dialog'; + } + + private resolveMainAction(input: LiveActionPromptBuildInput, template: SceneTemplate) { + return this.clean(input.action) || this.clean(input.visualDescription) || template.performance; + } + + private resolveCameraShot(input: LiveActionPromptBuildInput, template: SceneTemplate) { + const raw = this.clean(input.cameraInstruction); + + if (input.lipSyncPolicy?.visual_fallback) { + return 'medium shot, three-quarter angle or slight profile, lips small in frame'; + } + if (!raw) return template.cameraShot; + if (/close.?up|特写|近景|medium close/i.test(raw)) return raw; + + return `${template.cameraShot}, ${raw}`; + } + + private resolveCameraMove(input: LiveActionPromptBuildInput, template: SceneTemplate, profile: LiveActionPromptProfile) { + const raw = this.clean(input.cameraMotion) || this.clean(input.cameraInstruction); + const text = raw.toLowerCase(); + + if (/zoom_in|push|推进|dolly.?in|靠近/.test(text)) return profile === 'hailuo' ? '镜头缓慢推进,主体逐渐变大' : 'slow cinematic push-in'; + if (/zoom_out|pull|拉远|dolly.?out|远离/.test(text)) return profile === 'hailuo' ? '镜头缓慢拉远,展示环境关系' : 'slow pull-back revealing the environment'; + if (/orbit|环绕|circle|旋转/.test(text)) return profile === 'hailuo' ? '镜头小幅环绕主体,保持人物稳定' : 'subtle orbit camera around the subject'; + if (/track|follow|跟拍/.test(text)) return profile === 'hailuo' ? '镜头平稳跟随人物动作' : 'smooth tracking shot following the action'; + if (/fixed|static|固定/.test(text)) return profile === 'hailuo' ? '固定镜头,人物表演推动情绪' : 'locked-off shot, acting carries the emotion'; + + return template.cameraMove; + } + + private hailuoCameraTag(cameraMove: string, rawMotion?: string | null) { + const text = `${cameraMove} ${rawMotion || ''}`.toLowerCase(); + + if (/pull|拉远|zoom_out|远离/.test(text)) return '[拉远]'; + if (/orbit|环绕|circle|旋转/.test(text)) return '[环绕]'; + if (/track|follow|跟拍/.test(text)) return '[跟拍]'; + if (/fixed|static|固定/.test(text)) return '[固定]'; + if (/push|推进|zoom_in|dolly.?in|靠近/.test(text)) return '[推进]'; + + return '[推进]'; + } + + private resolvePerformance(input: LiveActionPromptBuildInput, template: SceneTemplate) { + const raw = this.clean(input.performanceInstruction); + const dialogue = this.clean(input.dialogueText); + const narration = this.clean(input.narrationText); + + if (raw) return raw; + if (dialogue) return `${template.performance}; dialogue beat: ${dialogue}`; + if (narration) return `${template.performance}; narration beat: ${narration}`; + + return template.performance; + } + + private resolveLighting(input: LiveActionPromptBuildInput, template: SceneTemplate, routeTier: string) { + if (routeTier === 'premium') { + return `${template.lighting}, stronger depth of field and cinematic subject separation`; + } + + return template.lighting; + } + + private resolveVfxCue(effectType: string | null | undefined, sceneType: string, template: SceneTemplate) { + const effect = this.clean(effectType); + + if (/flash|闪|证据/.test(effect)) return 'brief realistic highlight or camera flash accent, no cartoon effect'; + if (/portal|digital|screen|次元/.test(effect) || sceneType === 'dimensional_break') return template.vfxCue; + if (/法相|lightning|rune|仙/.test(effect) || sceneType === 'xianxia_transformation') return template.vfxCue; + + return template.vfxCue; + } + + private resolveSoundCue(effectType: string | null | undefined, sceneType: string, template: SceneTemplate) { + const effect = this.clean(effectType); + + if (/flash|闪|证据/.test(effect)) return 'short camera flash tick, low reveal sting'; + if (/portal|digital|screen|次元/.test(effect) || sceneType === 'dimensional_break') return template.soundCue; + if (/法相|lightning|rune|仙/.test(effect) || sceneType === 'xianxia_transformation') return template.soundCue; + + return template.soundCue; + } + + private continuityRules( + profile: LiveActionPromptProfile, + lipSync: LiveActionPromptLipSyncPolicyInput | null, + directorPlan: LiveActionPromptDirectorPlanInput | null + ) { + return [ + 'keep the same actor face, hairstyle, costume and body scale', + 'one main action only in this short clip', + 'no random cuts, no new characters unless specified', + directorPlan ? `editorial purpose: ${directorPlan.edit_intent}` : '', + directorPlan ? `continuity in: ${directorPlan.continuity_in}` : '', + directorPlan ? `continuity out: ${directorPlan.continuity_out}` : '', + directorPlan ? 'preserve screen direction, eyeline and lighting continuity across adjacent shots' : '', + profile === 'hailuo' ? 'camera instruction should be simple and visible' : 'motion must remain physically believable', + lipSync?.visual_fallback ? 'avoid visible Chinese mouth articulation because audio/subtitles are added later' : '' + ].filter(Boolean); + } + + private buildMotionDirector(input: LiveActionPromptBuildInput, sceneType: string): LiveActionPromptMotionDirectorInput | null { + if (sceneType === 'xianxia_transformation') { + return this.buildXianxiaMotionDirector(input); + } + + return null; + } + + private buildXianxiaMotionDirector(input: LiveActionPromptBuildInput): LiveActionPromptMotionDirectorInput { + const text = `${input.action || ''} ${input.visualDescription || ''} ${input.effectType || ''} ${input.cameraMotion || ''} ${input.cameraInstruction || ''}`.toLowerCase(); + const duration = this.clampDuration(input.durationSeconds); + + if ( + duration >= 9 && + /落地|翻身|翻滚|抬头|撑地|landing|roll|kneel/i.test(text) && + /结印|印法|光球|紫色|灵力|seal|mudra|orb|energy/i.test(text) && + /法相|法身|千臂|巨手|dharma|giant|thousand/i.test(text) + ) { + return { + motion_version: 'live-action-motion-director-v1', + beat_style: '10-second one-shot Douyin xianxia climax, one continuous action chain from injury landing to dharma-form reveal', + action_technique: '一镜到底动作链:受伤落地、抬头觉醒、双手结印、紫色光球聚能、双臂展开、千臂法身升起、爆光定格;全程保持同一人物、同一废墟空间、同一镜头动机', + time_beats: [ + '0.0-2.0s:白裙女仙从废墟残垣中落地,手掌先撑地,膝盖滑过碎石,尘土和碎石被冲击震开,银饰剧烈晃动', + '2.0-3.0s:她缓慢抬头,眼神极度凌厉,瞳孔金光亮起,镜头快速推进到眼部特写', + '3.0-5.0s:镜头回到中近景,双手在胸前清晰结印,食指中指并拢交错,手腕翻转,拇指扣成莲花印', + '5.0-6.5s:紫色光球在胸前高速旋转变大,灵力电流闪烁,气流撕扯衣袂,周围碎石失重悬浮', + '6.5-8.0s:女仙双臂像凤凰展翅一样猛然后扫,胸口抬起,肩线打开,身后透明千臂法身从地面拔地而起', + '8.0-10.0s:千臂法身完全展开,巨手一层层结出不同仙印,地面塌陷,碎石粉化,金色神光爆发,低角度仰拍定格' + ], + camera_rhythm: '连续一镜到底:中景接住落地,快速推进眼部,中近景跟随双手结印,最后贴地低角度仰拍拉升到法身;不要突然切换场景', + vfx_timing: '尘土在落地时爆开,金光在抬头时出现,紫色光球在结印后出现,法身必须由双臂展开触发,最后 2 秒爆光定格', + sound_hits: 'landing rubble hit, eye flash sting, hand-seal electric rise, orb bass swell, arms-spread whoosh, final dharma LFE impact', + negative_motion: [ + 'multi-shot montage', + 'random scene cuts', + 'character identity drift', + 'standing still power pose', + 'random hand waving', + 'static dharma statue', + 'cheap game aura' + ] + }; + } + + if (/法相|法身|千臂|巨手|威压|降临|dharma|giant|colossal|thousand/.test(text)) { + return { + motion_version: 'live-action-motion-director-v1', + beat_style: 'high-value divine arrival beat, body action triggers the giant dharma form', + action_technique: '女仙双臂像凤凰展翅一样猛然后扫,胸口抬起,肩线打开;身后千臂法身随动作拔地而起,每一层巨手依次结出不同仙印', + time_beats: this.xianxiaTimeBeats(duration, [ + '0.0-0.7s:贴地极低机位,女仙双臂向后展开,裙摆被风压掀起', + '0.7-1.8s:她身后透明法身从地面升起,先出现头部和肩部轮廓', + '1.8-3.0s:千只巨手一层层展开并结印,地面裂开,碎石失重上浮', + '3.0s-end:镜头从脚下仰拍拉到法身脸部,低频冲击,碎石瞬间粉化' + ]), + camera_rhythm: '贴地低角度仰拍,跟随法身向上拉升,不要横向乱晃', + vfx_timing: '法身必须由女仙展开双臂触发,巨手展开、地裂、碎石粉化要分三层递进', + sound_hits: 'arms spread whoosh, dharma rise sub-bass, ground crack, final LFE impact', + negative_motion: ['tiny dharma body', 'static statue behind actor', 'arms not forming seals', 'cheap game aura', 'chaotic camera shake'] + }; + } + + if (/结印|印法|手|皓腕|光球|紫色|orb|seal|mudra|finger/.test(text)) { + return { + motion_version: 'live-action-motion-director-v1', + beat_style: 'Douyin xianxia power-up beat, readable hand-seal choreography before the energy burst', + action_technique: '结印手法必须清楚:食指中指并拢交错,手腕快速翻转,拇指扣成莲花印,双手向外一震;finger mudra / lotus seal, not random hand waving', + time_beats: this.xianxiaTimeBeats(duration, [ + '0.0-0.5s:手部特写,双腕从胸前交叉进入画面,银饰轻响', + '0.5-1.4s:十指连续完成三次清晰结印,食指中指并拢、交错、翻腕、扣印', + '1.4-2.2s:紫色光球在胸前从小点高速旋转变大,气流撕扯衣袂', + '2.2s-end:双掌猛然向外一震,光球爆亮,镜头小幅环绕但手势保持可读' + ]), + camera_rhythm: '手部特写先稳住 0.5 秒,再小幅环绕跟拍,最后跟着双掌震出产生短促冲击', + vfx_timing: '紫色光球必须在第二次手印后出现,在最后双掌震出时爆亮', + sound_hits: '0.5s silver ornament tick, 1.4s electric rise, final palm snap with bass hit', + negative_motion: ['random hand waving', 'blurred fingers', 'hands leaving frame', 'static magical orb only', 'no visible seal gesture'] + }; + } + + return { + motion_version: 'live-action-motion-director-v1', + beat_style: 'Douyin opening hook, injury landing then eye-power reveal', + action_technique: '白裙女仙从残垣借力翻滚落地,手掌先撑地,膝盖滑过碎石,银饰剧烈摆动;抬头一瞬双眼爆出金光', + time_beats: this.xianxiaTimeBeats(duration, [ + '0.0-0.6s:废墟崩塌中凌空翻身,身体从画面侧上方落入中景', + '0.6-1.4s:手掌和膝盖触地滑停,碎石被冲击震开,腿部血迹清楚', + '1.4-2.2s:她猛然抬头,白发和银饰被狂风甩动', + '2.2s-end:镜头极速推进到眼部特写,瞳孔金光爆亮,停住半拍' + ]), + camera_rhythm: '中景接住落地动作,然后快速推进到眼睛;只做一次强推,不要连续乱切', + vfx_timing: '落地时尘土爆开,抬头时风压增强,眼神定格时金光出现', + sound_hits: 'rubble hit on landing, silver ornaments rattle, sharp wind rise, eye flash sting', + negative_motion: ['standing still power pose', 'floating without landing impact', 'slide-show image movement', 'weak eye reveal'] + }; + } + + private xianxiaTimeBeats(duration: number, beats: string[]) { + if (duration <= 3) return beats.slice(0, 4); + if (duration <= 4) return beats; + + return [ + ...beats, + `${Math.max(0, duration - 1).toFixed(1)}s-end:保留最后一秒给爆点余震,不新增地点和新动作。` + ]; + } + + private motionDirectorLinesZh(components: LiveActionPromptComponents) { + const motion = components.motion_director; + + if (!motion) return []; + + return [ + `动作导演:${motion.beat_style};${motion.action_technique}`, + `时间节奏:${motion.time_beats.join(' / ')}`, + `运镜节奏:${motion.camera_rhythm}`, + `特效时机:${motion.vfx_timing}`, + `声音卡点:${motion.sound_hits}`, + `动作禁忌:${motion.negative_motion.join(',')}` + ]; + } + + private motionDirectorLinesEn(components: LiveActionPromptComponents) { + const motion = components.motion_director; + + if (!motion) return []; + + return [ + `Motion director: ${motion.beat_style}; ${motion.action_technique}.`, + `Time beats: ${motion.time_beats.join(' / ')}.`, + `Camera rhythm: ${motion.camera_rhythm}.`, + `VFX timing: ${motion.vfx_timing}.`, + `Sound hits: ${motion.sound_hits}.`, + `Avoid motion: ${motion.negative_motion.join(', ')}.` + ]; + } + + private motionDirectorLinesMock(components: LiveActionPromptComponents) { + const motion = components.motion_director; + + if (!motion) return []; + + return [ + `motion_director_version: ${motion.motion_version}`, + `motion_beat_style: ${motion.beat_style}`, + `motion_action_technique: ${motion.action_technique}`, + `motion_time_beats: ${motion.time_beats.join(' | ')}`, + `motion_camera_rhythm: ${motion.camera_rhythm}`, + `motion_vfx_timing: ${motion.vfx_timing}`, + `motion_sound_hits: ${motion.sound_hits}`, + `motion_negative: ${motion.negative_motion.join(', ')}` + ]; + } + + private directorPlanLinesZh(components: LiveActionPromptComponents) { + const plan = components.director_plan; + + if (!plan) return []; + + return [ + `导演分镜:${plan.shot_role},${plan.shot_size},${plan.blocking}`, + `剪辑目的:${plan.edit_intent}`, + `连续性:承接上一镜=${plan.continuity_in};出画衔接=${plan.continuity_out}`, + `声音桥:${plan.sound_bridge}`, + `场景组:${plan.scene_group_id},保持同一空间轴线和光线方向。` + ]; + } + + private directorPlanLinesEn(components: LiveActionPromptComponents) { + const plan = components.director_plan; + + if (!plan) return []; + + return [ + `Director beat: ${plan.shot_role}, ${plan.shot_size}, ${plan.blocking}.`, + `Editing intent: ${plan.edit_intent}.`, + `Continuity in: ${plan.continuity_in}. Continuity out: ${plan.continuity_out}.`, + `Sound bridge: ${plan.sound_bridge}.`, + `Scene group: ${plan.scene_group_id}; keep the same spatial axis and lighting direction.` + ]; + } + + private directorPlanLinesMock(components: LiveActionPromptComponents) { + const plan = components.director_plan; + + if (!plan) return []; + + return [ + `director_plan_version: ${plan.plan_version}`, + `scene_group_id: ${plan.scene_group_id}`, + `shot_role: ${plan.shot_role}`, + `shot_size: ${plan.shot_size}`, + `blocking: ${plan.blocking}`, + `continuity_in: ${plan.continuity_in}`, + `continuity_out: ${plan.continuity_out}`, + `edit_intent: ${plan.edit_intent}`, + `sound_bridge: ${plan.sound_bridge}` + ]; + } + + private lipSyncLines(components: LiveActionPromptComponents) { + const policy = components.lip_sync; + + if (!policy || policy.strategy === 'not_required') return []; + if (policy.visual_fallback) { + return [ + `lip_sync_required: ${policy.lip_sync_required}`, + `lip_sync_strategy: ${policy.strategy}`, + `lip_sync_reason: ${policy.reason}`, + 'Dialogue will be handled by post-production TTS and subtitles; keep the mouth mostly closed or tiny in frame.', + 'Do not animate clear mouth articulation for Chinese speech.', + 'Avoid direct frontal mouth close-up; prefer medium shot, three-quarter profile, or over-the-shoulder composition.' + ]; + } + + return [ + `lip_sync_required: ${policy.lip_sync_required}`, + `lip_sync_strategy: ${policy.strategy}`, + `lip_sync_reason: ${policy.reason}`, + 'Keep face identity stable for downstream lip-sync; avoid exaggerated mouth shapes.' + ]; + } + + private joinUnique(values: Array, separator: string) { + const seen = new Set(); + const result: string[] = []; + + for (const value of values) { + const cleaned = this.clean(value); + const key = cleaned.toLowerCase(); + + if (cleaned && !seen.has(key)) { + seen.add(key); + result.push(cleaned); + } + } + + return result.join(separator); + } + + private limitPrompt(lines: Array, maxLength: number) { + const kept = lines.filter((line): line is string => Boolean(this.clean(line))); + + while (kept.join('\n').length > maxLength && kept.length > 8) { + kept.splice(kept.length - 3, 1); + } + + const prompt = kept.join('\n'); + + return prompt.length <= maxLength ? prompt : prompt.slice(0, maxLength - 20).trimEnd(); + } + + private clampDuration(value: number) { + if (!Number.isFinite(value)) return 5; + + return Math.max(1, Math.min(60, Number(value.toFixed(2)))); + } + + private clean(value: unknown) { + return String(value ?? '').replace(/\s+/g, ' ').trim(); + } +} diff --git a/backend/src/main.ts b/backend/src/main.ts new file mode 100644 index 0000000..61605fe --- /dev/null +++ b/backend/src/main.ts @@ -0,0 +1,91 @@ +import 'reflect-metadata'; +import './config/load-env'; +import type { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface'; +import type { INestApplication } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { json, urlencoded } from 'express'; +import { AppModule } from './app.module'; + +type ExpressLikeApp = { + set?: (key: string, value: boolean | number | string) => void; +}; + +function isProduction() { + return process.env.NODE_ENV === 'production'; +} + +function parseBoolean(value: string | undefined, fallback: boolean) { + if (value === undefined) return fallback; + + const normalized = value.trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; + if (['0', 'false', 'no', 'off'].includes(normalized)) return false; + + return fallback; +} + +function parseCommaList(value: string | undefined) { + return (value ?? '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +} + +function createCorsOptions(): CorsOptions { + const allowedOrigins = parseCommaList(process.env.CORS_ORIGINS); + + return { + origin: + allowedOrigins.length > 0 + ? (origin, callback) => { + if (!origin || allowedOrigins.includes(origin)) { + callback(null, true); + return; + } + + callback(new Error('Origin is not allowed by CORS'), false); + } + : !isProduction(), + methods: ['GET', 'POST', 'PATCH', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: [ + 'authorization', + 'content-type', + 'x-request-id', + 'x-api-encrypted', + 'x-api-session-id', + 'x-api-client-public-key' + ], + exposedHeaders: ['content-disposition', 'x-request-id', 'x-api-encrypted'], + maxAge: 86400 + }; +} + +function configureTrustProxy(app: INestApplication) { + const express = app.getHttpAdapter().getInstance() as ExpressLikeApp; + const shouldTrustProxy = parseBoolean(process.env.TRUST_PROXY, isProduction()); + + express.set?.('trust proxy', shouldTrustProxy); +} + +function requestBodyLimit() { + return process.env.REQUEST_BODY_LIMIT || process.env.MAX_REQUEST_BODY_SIZE || '160mb'; +} + +async function bootstrap() { + const app = await NestFactory.create(AppModule, { bodyParser: false }); + const bodyLimit = requestBodyLimit(); + + app.use(json({ limit: bodyLimit })); + app.use(urlencoded({ limit: bodyLimit, extended: true })); + configureTrustProxy(app); + app.enableCors(createCorsOptions()); + app.setGlobalPrefix('api'); + + const port = Number(process.env.PORT ?? 3000); + await app.listen(port, '0.0.0.0'); + + // eslint-disable-next-line no-console + console.log(`Backend API listening on http://127.0.0.1:${port}/api`); +} + +void bootstrap(); diff --git a/backend/src/media/media.controller.ts b/backend/src/media/media.controller.ts new file mode 100644 index 0000000..b5bae21 --- /dev/null +++ b/backend/src/media/media.controller.ts @@ -0,0 +1,59 @@ +import { Body, Controller, Get, Inject, Param, Post, UseGuards } from '@nestjs/common'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import type { AuthRequestUser } from '../auth/auth.types'; +import { + GenerateEpisodeAudioDto, + GenerateEpisodeSubtitleDto, + RetryEpisodeAudioSegmentDto, + RenderEpisodeVideoDto +} from './media.dto'; +import { MediaService } from './media.service'; + +@Controller('episodes/:episodeId') +@UseGuards(JwtAuthGuard) +export class MediaController { + constructor(@Inject(MediaService) private readonly mediaService: MediaService) {} + + @Post('audio/generate') + generateAudio( + @CurrentUser() user: AuthRequestUser, + @Param('episodeId') episodeId: string, + @Body() dto: GenerateEpisodeAudioDto + ) { + return this.mediaService.generateEpisodeAudio(user, episodeId, dto); + } + + @Post('audio/segments/:segmentIndex/retry') + retryAudioSegment( + @CurrentUser() user: AuthRequestUser, + @Param('episodeId') episodeId: string, + @Param('segmentIndex') segmentIndex: string, + @Body() dto: RetryEpisodeAudioSegmentDto + ) { + return this.mediaService.retryEpisodeAudioSegment(user, episodeId, segmentIndex, dto); + } + + @Post('subtitle/generate') + generateSubtitle( + @CurrentUser() user: AuthRequestUser, + @Param('episodeId') episodeId: string, + @Body() dto: GenerateEpisodeSubtitleDto + ) { + return this.mediaService.generateEpisodeSubtitle(user, episodeId, dto); + } + + @Post('video/render') + renderVideo( + @CurrentUser() user: AuthRequestUser, + @Param('episodeId') episodeId: string, + @Body() dto: RenderEpisodeVideoDto + ) { + return this.mediaService.renderEpisodeVideo(user, episodeId, dto); + } + + @Get('media-assets') + listMediaAssets(@CurrentUser() user: AuthRequestUser, @Param('episodeId') episodeId: string) { + return this.mediaService.listEpisodeMediaAssets(user, episodeId); + } +} diff --git a/backend/src/media/media.dto.ts b/backend/src/media/media.dto.ts new file mode 100644 index 0000000..959568b --- /dev/null +++ b/backend/src/media/media.dto.ts @@ -0,0 +1,27 @@ +export class GenerateEpisodeAudioDto { + voice?: string; + narration_voice?: string; + dialogue_mode?: 'mixed' | 'narration'; + max_segments?: number; + force?: boolean; +} + +export class RetryEpisodeAudioSegmentDto { + voice?: string; + voice_style?: string; + speech_speed?: number | string; +} + +export class GenerateEpisodeSubtitleDto { + max_chars_per_line?: number; + subtitle_mode?: 'dialogue' | 'shot'; + include_speaker?: boolean; + force?: boolean; +} + +export class RenderEpisodeVideoDto { + force?: boolean; + include_audio?: boolean; + include_subtitle?: boolean; + prefer_ffmpeg?: boolean; +} diff --git a/backend/src/media/media.module.ts b/backend/src/media/media.module.ts new file mode 100644 index 0000000..c38c0b6 --- /dev/null +++ b/backend/src/media/media.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { AssetsModule } from '../assets/assets.module'; +import { BillingModule } from '../billing/billing.module'; +import { PrismaModule } from '../prisma/prisma.module'; +import { ProvidersModule } from '../providers/providers.module'; +import { MediaController } from './media.controller'; +import { MediaService } from './media.service'; + +@Module({ + imports: [AuthModule, AssetsModule, BillingModule, PrismaModule, ProvidersModule], + controllers: [MediaController], + providers: [MediaService], + exports: [MediaService] +}) +export class MediaModule {} diff --git a/backend/src/media/media.service.spec.ts b/backend/src/media/media.service.spec.ts new file mode 100644 index 0000000..04ed036 --- /dev/null +++ b/backend/src/media/media.service.spec.ts @@ -0,0 +1,1080 @@ +import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import type { + Asset, + Character, + Episode, + EpisodeScript, + Project, + RenderTask, + ShotImage, + StoryboardShot +} from '@prisma/client'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AuthRequestUser } from '../auth/auth.types'; +import type { StorageService } from '../assets/storage.service'; +import type { BillingService } from '../billing/billing.service'; +import type { PrismaService } from '../prisma/prisma.service'; +import type { ProvidersService } from '../providers/providers.service'; +import { MediaService } from './media.service'; + +const user: AuthRequestUser = { + id: '1', + email: 'user@example.com', + role: 'user' +}; + +const now = new Date('2026-05-31T00:00:00.000Z'); + +function createProject(overrides: Partial = {}): Project { + return { + id: 10n, + user_id: 1n, + title: '阶段16 视频项目', + input_mode: 'ai_original', + genre: 'urban_rebirth', + style_code: 'korean_comic', + output_type: 'short_video', + output_mode: 'image_manga', + visual_mode: 'korean_manga', + video_generation_level: 'standard', + target_episode_count: 3, + episode_duration: 60, + status: 'final_images_generated', + copyright_status: 'ai_original', + payment_status: 'unpaid', + quality_level: 'mvp', + is_long_series: false, + created_at: now, + updated_at: now, + completed_at: null, + ...overrides + }; +} + +function createEpisode(overrides: Partial = {}): Episode { + return { + id: 20n, + project_id: 10n, + episode_no: 1, + source_chapter_ids: ['1'], + title: '第1集', + summary: '林晚反击。', + opening_hook: '会议室大屏播放录音。', + middle_conflict: '周启压制局面。', + ending_hook: '幕后车辆出现。', + target_duration: 60, + status: 'confirmed', + created_at: now, + updated_at: now, + ...overrides + }; +} + +function createScript(overrides: Partial = {}): EpisodeScript { + return { + id: 30n, + project_id: 10n, + episode_id: 20n, + script_text: '【第1集】林晚在会议室反击。', + narration_text: '局势开始反转。', + dialogue_json: [{ speaker: '林晚', line: '这一回,我不会再退。' }], + version: 1, + status: 'confirmed', + created_at: now, + updated_at: now, + ...overrides + }; +} + +function createCharacter(overrides: Partial = {}): Character { + return { + id: 80n, + project_id: 10n, + global_character_id: 1n, + name: '林晚', + alias_names: [], + role_type: 'protagonist', + gender_label: '女', + age_group: '青年', + identity_desc: '故事主角', + appearance_desc: '眼神坚定', + face_desc: '精致脸型', + hair_desc: '深色中长发', + eye_desc: '深色眼睛', + body_desc: '身形修长', + costume_rules: '现代都市通勤装', + special_props: '手机、录音证据', + personality_desc: '冷静克制', + speech_style: '短句明确', + relationship_desc: '与周启对抗', + character_arc: '从被动到主动', + negative_rules: '不得改名', + anchor_asset_id: null, + wardrobe_variant: null, + voice_provider_code: 'mock-voice', + voice_model: 'mock-voice-v1', + voice_id: 'heroine-calm-a', + voice_style: '冷静克制的年轻女性声线。', + performance_style: '微表情克制。', + importance_level: 100, + status: 'locked', + created_at: now, + updated_at: now, + ...overrides + }; +} + +function createShot(overrides: Partial = {}): StoryboardShot { + return { + id: 40n, + project_id: 10n, + episode_id: 20n, + shot_no: 1, + scene_name: '会议室反击', + location_desc: '高层会议室', + characters_json: [{ id: '1', name: '林晚' }], + visual_desc: '林晚站在会议桌前。', + action_desc: '林晚播放录音证据。', + dialogue_text: '这一回,我不会再退。', + narration_text: '局势从这一秒开始反转。', + camera_motion: 'zoom_in', + effect_type: 'flash', + duration: new Prisma.Decimal(4), + scene_type: null, + importance_score: null, + emotion_score: null, + action_score: null, + route_tier: null, + prompt_text: '韩漫风会议室反击', + negative_prompt: '低清晰度', + live_action_desc: null, + actor_action: null, + camera_instruction: null, + performance_instruction: null, + video_prompt: null, + keyframe_asset_id: null, + video_clip_asset_id: null, + video_status: null, + status: 'confirmed', + created_at: now, + updated_at: now, + ...overrides + }; +} + +function createShotImage(overrides: Partial = {}): ShotImage { + return { + id: 50n, + project_id: 10n, + episode_id: 20n, + shot_id: 40n, + asset_id: 60n, + image_type: 'final', + prompt_text: 'prompt', + negative_prompt: 'negative', + quality_score: new Prisma.Decimal(92), + status: 'generated', + created_at: now, + ...overrides + }; +} + +function createAsset(overrides: Partial = {}): Asset { + return { + id: 60n, + user_id: 1n, + project_id: 10n, + asset_type: 'audio', + file_path: 'local://generated-audio/mock.wav', + file_url: null, + mime_type: 'audio/wav', + width: null, + height: null, + duration: new Prisma.Decimal(4), + size: 1024n, + hash: 'hash-a', + visibility: 'private', + status: 'active', + created_at: now, + ...overrides + }; +} + +function createTask(overrides: Partial = {}): RenderTask { + return { + id: 70n, + project_id: 10n, + episode_id: 20n, + shot_id: null, + task_type: 'audio_generate', + provider_id: null, + status: 'pending', + input_json: {}, + input_hash: 'hash-task', + idempotency_key: 'idem-task', + output_asset_id: null, + provider_request_id: null, + retry_count: 0, + max_retry: 2, + cost_estimate: null, + cost_actual: null, + error_code: null, + error_message: null, + created_at: now, + started_at: null, + finished_at: null, + ...overrides + }; +} + +describe('MediaService', () => { + let prisma: any; + let storage: any; + let providers: any; + let billing: any; + let service: MediaService; + + beforeEach(() => { + prisma = { + project: { + findUnique: vi.fn().mockResolvedValue(createProject()), + findMany: vi.fn().mockResolvedValue([createProject()]), + update: vi.fn().mockResolvedValue(createProject()) + }, + episode: { + findUnique: vi.fn().mockResolvedValue(createEpisode()) + }, + episodeScript: { + findFirst: vi.fn().mockResolvedValue(createScript()) + }, + storyboardShot: { + findMany: vi.fn().mockResolvedValue([createShot()]) + }, + shotImage: { + findFirst: vi.fn().mockResolvedValue(createShotImage()) + }, + character: { + findMany: vi.fn().mockResolvedValue([createCharacter()]) + }, + renderTask: { + findFirst: vi.fn().mockResolvedValue(null), + findMany: vi.fn().mockResolvedValue([ + createTask({ task_type: 'audio_generate', output_asset_id: 61n }), + createTask({ id: 71n, task_type: 'subtitle_generate', output_asset_id: 62n }) + ]), + create: vi.fn(async ({ data }: { data: Partial }) => + createTask({ + id: + data.task_type === 'subtitle_generate' + ? 71n + : data.task_type === 'video_render' + ? 72n + : 70n, + task_type: data.task_type ?? 'audio_generate', + input_json: data.input_json ?? {}, + input_hash: data.input_hash ?? 'hash-task', + idempotency_key: data.idempotency_key ?? 'idem-task' + }) + ), + update: vi.fn(async ({ where, data }: { where: { id: bigint }; data: Partial }) => + createTask({ + id: where.id, + task_type: where.id === 72n ? 'video_render' : where.id === 71n ? 'subtitle_generate' : 'audio_generate', + status: data.status ?? 'success', + output_asset_id: data.output_asset_id ?? 60n + }) + ) + }, + asset: { + create: vi.fn(async ({ data }: { data: Partial }) => + createAsset({ + id: + data.asset_type === 'subtitle' + ? 62n + : data.asset_type === 'video' + ? 63n + : 61n, + asset_type: data.asset_type ?? 'audio', + file_path: data.file_path ?? 'local://mock', + mime_type: data.mime_type ?? null, + duration: + data.duration === null || data.duration === undefined + ? null + : new Prisma.Decimal(data.duration), + size: data.size ?? 1024n, + hash: data.hash ?? 'hash-a', + status: data.status ?? 'active' + }) + ), + findUnique: vi.fn(async ({ where }: { where: { id: bigint } }) => + createAsset({ + id: where.id, + asset_type: where.id === 62n ? 'subtitle' : where.id === 63n ? 'video' : 'audio', + mime_type: where.id === 62n ? 'application/x-subrip' : where.id === 63n ? 'video/mp4' : 'audio/wav' + }) + ) + }, + operationLog: { + create: vi.fn().mockResolvedValue({ + id: 90n, + user_id: 1n, + operator_role: 'user', + action: 'user_audio_generate', + target_type: 'project', + target_id: 10n, + ip: null, + user_agent: null, + metadata_json: {}, + created_at: now + }) + } + }; + storage = { + storePrivateFile: vi.fn().mockResolvedValue({ + file_path: 'local://generated-media/mock', + size: 1024n, + hash: 'hash-a', + backend: 'local' + }), + readPrivateFile: vi.fn().mockResolvedValue( + Buffer.from('1\n00:00:00,000 --> 00:00:02,000\n局势从这一秒开始反转。\n') + ) + }; + providers = { + executeProvider: vi.fn(async ({ provider_type }: { provider_type: string }) => ({ + provider: { + mode: 'mock' + }, + result: + provider_type === 'VideoProvider' + ? { asset_url: 'mock://video/a.mp4', duration: 4 } + : { asset_url: 'mock://audio/a.mp3', duration: 4 }, + provider_log: { + cost_estimate: 0, + cost_actual: 0 + } + })), + executeProviderBatch: vi.fn(async (baseDto: any, items: any[]) => { + const results = []; + + for (const [index, item] of items.entries()) { + results.push( + await providers.executeProvider({ + ...baseDto, + ...item, + purpose: item.purpose ?? `${baseDto.purpose}-${index + 1}`, + input_json: item.input_json + }) + ); + } + + return { + mode: 'fallback_sequential', + count: results.length, + results + }; + }) + }; + billing = { + ensureProjectQuotaReserved: vi.fn().mockResolvedValue(undefined), + deductReservedProjectQuota: vi.fn().mockResolvedValue({ + project_payment_status: 'paid' + }) + }; + service = new MediaService( + prisma as PrismaService, + storage as StorageService, + providers as ProvidersService, + billing as BillingService + ); + }); + + it('generates mixed role audio through VoiceProvider by default', async () => { + const result = await service.generateEpisodeAudio(user, '20', { + voice: 'mock-cn-female' + }); + + expect(providers.executeProvider).toHaveBeenCalledWith( + expect.objectContaining({ + provider_type: 'VoiceProvider', + task_id: '70', + allow_fallback: false + }) + ); + expect(providers.executeProviderBatch).toHaveBeenCalledWith( + expect.objectContaining({ + provider_type: 'VoiceProvider', + task_id: '70', + allow_fallback: false + }), + expect.arrayContaining([ + expect.objectContaining({ + input_json: expect.objectContaining({ + audio_cache_key: expect.any(String), + target_duration: expect.any(Number) + }) + }) + ]) + ); + expect(prisma.operationLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + user_id: 1n, + operator_role: 'user', + action: 'user_audio_generate', + target_type: 'project', + target_id: 10n, + metadata_json: expect.objectContaining({ + task_id: '70', + episode_id: '20', + task_type: 'audio_generate' + }) + }) + }); + expect(prisma.asset.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + asset_type: 'audio', + mime_type: 'audio/wav', + status: 'mock', + visibility: 'private' + }) + }); + expect(providers.executeProvider).toHaveBeenCalledTimes(2); + expect(result.asset.asset_type).toBe('audio'); + expect((result as { dialogue_mode?: string }).dialogue_mode).toBe('mixed'); + expect((result as { segment_count?: number }).segment_count).toBe(2); + expect((result as { timeline_warnings?: unknown[] }).timeline_warnings).toEqual([]); + expect((result as { segments?: Array<{ start_seconds?: number; end_seconds?: number }> }).segments?.[0]).toEqual( + expect.objectContaining({ + start_seconds: 0, + end_seconds: expect.any(Number) + }) + ); + expect(result.next_step).toBe('subtitle_generate'); + }); + + it('reuses cached narration TTS when text and voice match', async () => { + const cachedAudio = Buffer.from('cached-narration-audio'); + const narrationText = '局势开始反转。 局势从这一秒开始反转。 这一回,我不会再退。 【第1集】林晚在会议室反击。'; + + prisma.renderTask.findMany.mockResolvedValueOnce([ + createTask({ + id: 99n, + status: 'success', + input_json: { + dialogue_mode: 'narration', + segments: [ + { + index: 1, + segment_type: 'narration', + shot_id: null, + shot_no: null, + start_seconds: 0, + end_seconds: 4, + target_duration: 4, + speaker_name: '旁白', + voice: 'mock-cn-female', + voice_provider_code: null, + voice_model: null, + voice_style: '中文短剧旁白,清晰稳定,情绪不过度夸张。', + speech_speed: null, + character_id: null, + global_character_id: null, + text: narrationText + } + ], + segment_results: [ + { + index: 1, + actual_duration: 4, + is_mock: true, + mime_type: 'audio/wav', + segment_file_path: 'local://generated-audio-segments/cached-narration.wav', + segment_size: cachedAudio.length.toString(), + segment_hash: 'cached-narration-hash' + } + ] + } + }) + ]); + storage.readPrivateFile.mockResolvedValueOnce(cachedAudio); + + await service.generateEpisodeAudio(user, '20', { + dialogue_mode: 'narration', + voice: 'mock-cn-female' + }); + + expect(storage.readPrivateFile).toHaveBeenCalledWith( + 'local://generated-audio-segments/cached-narration.wav' + ); + expect(providers.executeProviderBatch).not.toHaveBeenCalled(); + expect(providers.executeProvider).not.toHaveBeenCalled(); + expect(storage.storePrivateFile).toHaveBeenCalledWith( + expect.objectContaining({ + originalname: 'episode-20-narration.wav', + mimetype: 'audio/wav', + size: cachedAudio.length, + buffer: cachedAudio + }), + 'generated-audio' + ); + const inputUpdateCall = prisma.renderTask.update.mock.calls.find( + ([arg]: [{ data?: { input_json?: unknown } }]) => arg.data?.input_json + ); + const inputJson = inputUpdateCall?.[0].data.input_json as { + cost_estimate?: { cached_segments?: number; generated_segments?: number }; + segment_results?: Array<{ index: number; cache_hit: boolean }>; + }; + + expect(inputJson.cost_estimate).toEqual( + expect.objectContaining({ + cached_segments: 1, + generated_segments: 0 + }) + ); + expect(inputJson.segment_results).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + index: 1, + cache_hit: true + }) + ]) + ); + }); + + it('does not create silent mock audio when a real TTS provider returns no audio content', async () => { + providers.executeProvider.mockResolvedValueOnce({ + provider: { + mode: 'real' + }, + result: { + asset_url: 'openai://audio/empty.mp3', + duration: 5 + }, + provider_log: { + cost_estimate: 0, + cost_actual: 0 + } + }); + + await expect(service.generateEpisodeAudio(user, '20', { dialogue_mode: 'narration' })).rejects.toBeInstanceOf( + BadRequestException + ); + expect(prisma.asset.create).not.toHaveBeenCalled(); + expect(prisma.renderTask.update).toHaveBeenCalledWith({ + where: { id: 70n }, + data: expect.objectContaining({ + status: 'failed' + }) + }); + }); + + it('stores real TTS provider audio bytes when available', async () => { + const mp3 = Buffer.from('real-audio-bytes'); + providers.executeProvider.mockResolvedValueOnce({ + provider: { + mode: 'real' + }, + result: { + asset_url: 'openai://audio/tts-1.mp3', + content_base64: mp3.toString('base64'), + mime_type: 'audio/mpeg', + duration: 5 + }, + provider_log: { + cost_estimate: 0.03, + cost_actual: 0.03 + } + }); + storage.storePrivateFile.mockResolvedValueOnce({ + file_path: 'local://generated-audio-segments/real-segment.mp3', + size: BigInt(mp3.length), + hash: 'real-audio-segment-hash', + backend: 'local' + }); + storage.storePrivateFile.mockResolvedValueOnce({ + file_path: 'local://generated-audio/real.mp3', + size: BigInt(mp3.length), + hash: 'real-audio-hash', + backend: 'local' + }); + + await service.generateEpisodeAudio(user, '20', { dialogue_mode: 'narration' }); + + expect(storage.storePrivateFile).toHaveBeenCalledWith( + expect.objectContaining({ + originalname: 'episode-20-narration.mp3', + mimetype: 'audio/mpeg', + size: mp3.length, + buffer: mp3 + }), + 'generated-audio' + ); + expect(prisma.asset.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + asset_type: 'audio', + file_path: 'local://generated-audio/real.mp3', + file_url: 'openai://audio/tts-1.mp3', + mime_type: 'audio/mpeg', + status: 'active' + }) + }); + }); + + it('generates dialogue-level SRT subtitles from confirmed storyboard shots by default', async () => { + const result = await service.generateEpisodeSubtitle(user, '20', { + max_chars_per_line: 12 + }); + + expect(prisma.renderTask.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + task_type: 'subtitle_generate' + }) + }); + expect(prisma.asset.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + asset_type: 'subtitle', + mime_type: 'application/x-subrip' + }) + }); + expect('cues' in result).toBe(true); + if ('cues' in result) { + expect(result.cues[0].start).toBe('00:00:00,000'); + expect(result.cues).toHaveLength(2); + expect(result.cues[0]).toEqual( + expect.objectContaining({ + segment_type: 'narration', + speaker_name: '旁白' + }) + ); + expect(result.cues[1]).toEqual( + expect.objectContaining({ + segment_type: 'dialogue', + speaker_name: '林晚' + }) + ); + } + expect((result as { subtitle_mode?: string }).subtitle_mode).toBe('dialogue'); + expect(result.asset.asset_type).toBe('subtitle'); + }); + + it('keeps legacy shot-level SRT subtitles when requested', async () => { + const result = await service.generateEpisodeSubtitle(user, '20', { + max_chars_per_line: 12, + subtitle_mode: 'shot' + }); + + expect('cues' in result).toBe(true); + if ('cues' in result) { + expect(result.cues).toHaveLength(1); + expect(result.cues[0]).toEqual( + expect.objectContaining({ + segment_type: 'shot', + shot_no: 1 + }) + ); + } + expect((result as { subtitle_mode?: string }).subtitle_mode).toBe('shot'); + }); + + it('lists media assets with audio timeline and subtitle cue previews', async () => { + prisma.renderTask.findMany.mockResolvedValueOnce([ + createTask({ + task_type: 'audio_generate', + output_asset_id: 61n, + input_json: { + dialogue_mode: 'mixed', + segment_count: 1, + voice_count: 1, + estimated_tts_characters: 12, + segments: [ + { + index: 1, + segment_type: 'dialogue', + shot_id: '40', + shot_no: 1, + start_seconds: 0, + end_seconds: 2, + target_duration: 2, + speaker_name: '林晚', + voice: 'heroine-calm-a', + text: '这一回,我不会再退。' + } + ], + segment_results: [{ index: 1, actual_duration: 2.6, is_mock: false, mime_type: 'audio/mpeg' }], + timeline_warnings: [ + { + index: 1, + shot_no: 1, + speaker_name: '林晚', + text_preview: '这一回,我不会再退。', + target_duration: 2, + actual_duration: 2.6, + over_seconds: 0.6 + } + ] + } + }), + createTask({ + id: 71n, + task_type: 'subtitle_generate', + output_asset_id: 62n, + input_json: { + subtitle_mode: 'dialogue', + cue_count: 1 + } + }) + ]); + + const result = await service.listEpisodeMediaAssets(user, '20'); + const rows = result as Array<{ + task_type: string; + timeline?: { type: string; segments?: unknown[]; warnings?: unknown[]; cues?: unknown[] }; + stats?: { total_characters?: number; warning_count?: number; cue_count?: number }; + }>; + const audio = rows.find((row) => row.task_type === 'audio_generate'); + const subtitle = rows.find((row) => row.task_type === 'subtitle_generate'); + + expect(audio?.timeline?.type).toBe('audio'); + expect(audio?.timeline?.segments).toHaveLength(1); + expect(audio?.timeline?.warnings).toHaveLength(1); + expect(audio?.stats).toEqual( + expect.objectContaining({ + total_characters: 12, + warning_count: 1 + }) + ); + expect(subtitle?.timeline?.type).toBe('subtitle'); + expect(subtitle?.timeline?.cues).toHaveLength(1); + }); + + it('retries one mixed TTS segment and reuses stored segment files for the rest', async () => { + prisma.renderTask.findFirst.mockResolvedValueOnce( + createTask({ + id: 88n, + task_type: 'audio_generate', + output_asset_id: 61n, + input_json: { + dialogue_mode: 'mixed', + segment_count: 2, + voice_count: 2, + segments: [ + { + index: 1, + segment_type: 'narration', + shot_id: '40', + shot_no: 1, + start_seconds: 0, + end_seconds: 2, + target_duration: 2, + speaker_name: '旁白', + voice: 'coral', + text: '局势开始反转。' + }, + { + index: 2, + segment_type: 'dialogue', + shot_id: '40', + shot_no: 1, + start_seconds: 2, + end_seconds: 4, + target_duration: 2, + speaker_name: '林晚', + voice: 'heroine-calm-a', + voice_style: '冷静克制。', + text: '这一回,我不会再退。' + } + ], + segment_results: [ + { + index: 1, + actual_duration: 2, + is_mock: true, + mime_type: 'audio/wav', + segment_file_path: 'local://generated-audio-segments/segment-1.wav', + segment_size: '1234', + segment_hash: 'old-segment-1-hash' + }, + { + index: 2, + actual_duration: 2, + is_mock: true, + mime_type: 'audio/wav', + segment_file_path: 'local://generated-audio-segments/segment-2.wav' + } + ] + } + }) + ); + storage.readPrivateFile.mockResolvedValueOnce(Buffer.from('old-segment-1')); + const mixSpy = vi + .spyOn( + service as unknown as { + createMixedAudioOutputFromFiles: (files: unknown[]) => Promise<{ + buffer: Buffer; + mimeType: string; + duration: number; + isMock: boolean; + segmentFiles: unknown[]; + timelineWarnings: unknown[]; + }>; + }, + 'createMixedAudioOutputFromFiles' + ) + .mockImplementationOnce(async (files) => ({ + buffer: Buffer.from('mixed-retry'), + mimeType: 'audio/wav', + duration: 4, + isMock: true, + segmentFiles: files, + timelineWarnings: [] + })); + + const result = await service.retryEpisodeAudioSegment(user, '20', '2', { + voice: 'heroine-fast', + speech_speed: '1.2' + }); + + expect(providers.executeProvider).toHaveBeenCalledTimes(1); + expect(providers.executeProvider).toHaveBeenCalledWith( + expect.objectContaining({ + provider_type: 'VoiceProvider', + purpose: 'episode-20-tts-retry-2', + input_json: expect.objectContaining({ + text: '这一回,我不会再退。', + voice: 'heroine-fast', + speech_speed: 1.2 + }) + }) + ); + expect(storage.readPrivateFile).toHaveBeenCalledWith('local://generated-audio-segments/segment-1.wav'); + expect(mixSpy.mock.calls[0][0]).toHaveLength(2); + expect((result as { retry_segment_index?: number }).retry_segment_index).toBe(2); + const inputUpdateCall = prisma.renderTask.update.mock.calls.find( + ([arg]: [{ data?: { input_json?: unknown } }]) => arg.data?.input_json + ); + const inputJson = inputUpdateCall?.[0].data.input_json as { + segment_results?: Array<{ + index: number; + segment_file_path: string | null; + segment_size: string | null; + segment_hash: string | null; + }>; + }; + expect(inputJson.segment_results).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + index: 1, + segment_file_path: 'local://generated-audio-segments/segment-1.wav', + segment_size: '1234', + segment_hash: 'old-segment-1-hash' + }), + expect.objectContaining({ + index: 2, + segment_file_path: 'local://generated-media/mock' + }) + ]) + ); + expect(inputJson.segment_results?.every((segment) => Boolean(segment.segment_file_path))).toBe(true); + expect(prisma.asset.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + asset_type: 'audio', + mime_type: 'audio/wav' + }) + }); + }); + + it('renders local FFmpeg video without calling VideoProvider by default', async () => { + prisma.renderTask.findFirst.mockImplementation(({ where }: { where: { task_type: string } }) => { + if (where.task_type === 'audio_generate') { + return Promise.resolve(createTask({ task_type: 'audio_generate', output_asset_id: 61n })); + } + if (where.task_type === 'subtitle_generate') { + return Promise.resolve(createTask({ id: 71n, task_type: 'subtitle_generate', output_asset_id: 62n })); + } + return Promise.resolve(null); + }); + vi.spyOn(service as unknown as { createVideoBuffer: MediaService['createVideoBuffer'] }, 'createVideoBuffer') + .mockResolvedValueOnce({ + buffer: Buffer.from('ffmpeg-video'), + ffmpegUsed: true, + backend: 'ffmpeg' + }); + + const result = await service.renderEpisodeVideo(user, '20', { + prefer_ffmpeg: true + }); + + expect(providers.executeProvider).not.toHaveBeenCalled(); + expect(prisma.asset.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + asset_type: 'video', + file_url: null, + status: 'active' + }) + }); + expect('render_backend' in result).toBe(true); + if ('render_backend' in result) { + expect(result.render_backend).toBe('ffmpeg'); + } + }); + + it('renders a mock MP4 asset from generated shot images and media assets', async () => { + prisma.renderTask.findFirst.mockImplementation(({ where }: { where: { task_type: string } }) => { + if (where.task_type === 'audio_generate') { + return Promise.resolve(createTask({ task_type: 'audio_generate', output_asset_id: 61n })); + } + if (where.task_type === 'subtitle_generate') { + return Promise.resolve(createTask({ id: 71n, task_type: 'subtitle_generate', output_asset_id: 62n })); + } + return Promise.resolve(null); + }); + + const result = await service.renderEpisodeVideo(user, '20', { + prefer_ffmpeg: false + }); + + expect(providers.executeProvider).toHaveBeenCalledWith( + expect.objectContaining({ + provider_type: 'VideoProvider', + task_id: '72', + allow_fallback: false + }) + ); + expect(billing.ensureProjectQuotaReserved).toHaveBeenCalled(); + expect(billing.deductReservedProjectQuota).toHaveBeenCalledWith( + expect.objectContaining({ id: 10n }), + 72n, + 'video_render_success' + ); + expect(prisma.asset.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + asset_type: 'video', + mime_type: 'video/mp4', + status: 'mock' + }) + }); + expect(result.asset.asset_type).toBe('video'); + expect('render_backend' in result).toBe(true); + if ('render_backend' in result) { + expect(result.render_backend).toBe('mock_ffmpeg_unavailable'); + } + }); + + it('stores provider-rendered video bytes when VideoProvider returns content', async () => { + const mp4 = Buffer.from('real-video-bytes'); + prisma.renderTask.findFirst.mockImplementation(({ where }: { where: { task_type: string } }) => { + if (where.task_type === 'audio_generate') { + return Promise.resolve(createTask({ task_type: 'audio_generate', output_asset_id: 61n })); + } + if (where.task_type === 'subtitle_generate') { + return Promise.resolve(createTask({ id: 71n, task_type: 'subtitle_generate', output_asset_id: 62n })); + } + return Promise.resolve(null); + }); + providers.executeProvider.mockResolvedValueOnce({ + provider: { + mode: 'real' + }, + result: { + asset_url: 'provider://video/render-1.mp4', + content_base64: mp4.toString('base64'), + mime_type: 'video/mp4', + duration: 4 + }, + provider_log: { + cost_estimate: 0.5, + cost_actual: 0.5 + } + }); + storage.storePrivateFile.mockResolvedValueOnce({ + file_path: 'local://rendered-videos/provider.mp4', + size: BigInt(mp4.length), + hash: 'real-video-hash', + backend: 'local' + }); + + const result = await service.renderEpisodeVideo(user, '20', { + prefer_ffmpeg: false + }); + + expect(storage.storePrivateFile).toHaveBeenCalledWith( + expect.objectContaining({ + originalname: 'episode-20-render.mp4', + mimetype: 'video/mp4', + size: mp4.length, + buffer: mp4 + }), + 'rendered-videos' + ); + expect(prisma.asset.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + asset_type: 'video', + file_path: 'local://rendered-videos/provider.mp4', + file_url: 'provider://video/render-1.mp4', + status: 'active' + }) + }); + expect('render_backend' in result).toBe(true); + if ('render_backend' in result) { + expect(result.render_backend).toBe('provider_binary'); + } + }); + + it('does not create mock video when a real VideoProvider returns no content and FFmpeg is disabled', async () => { + prisma.renderTask.findFirst.mockImplementation(({ where }: { where: { task_type: string } }) => { + if (where.task_type === 'audio_generate') { + return Promise.resolve(createTask({ task_type: 'audio_generate', output_asset_id: 61n })); + } + if (where.task_type === 'subtitle_generate') { + return Promise.resolve(createTask({ id: 71n, task_type: 'subtitle_generate', output_asset_id: 62n })); + } + return Promise.resolve(null); + }); + providers.executeProvider.mockResolvedValueOnce({ + provider: { + mode: 'real' + }, + result: { + asset_url: 'provider://video/empty.mp4', + duration: 4 + }, + provider_log: { + cost_estimate: 0, + cost_actual: 0 + } + }); + + await expect(service.renderEpisodeVideo(user, '20', { prefer_ffmpeg: false })).rejects.toBeInstanceOf( + BadRequestException + ); + expect(prisma.asset.create).not.toHaveBeenCalledWith({ + data: expect.objectContaining({ + asset_type: 'video', + status: 'mock' + }) + }); + expect(prisma.renderTask.update).toHaveBeenCalledWith({ + where: { id: 72n }, + data: expect.objectContaining({ + status: 'failed' + }) + }); + }); + + it('rejects video render before shot images are generated', async () => { + prisma.shotImage.findFirst.mockResolvedValue(null); + + await expect(service.renderEpisodeVideo(user, '20', {})).rejects.toBeInstanceOf( + BadRequestException + ); + }); + + it('rejects access to another user project', async () => { + prisma.project.findUnique.mockResolvedValue(createProject({ user_id: 2n })); + + await expect(service.generateEpisodeSubtitle(user, '20', {})).rejects.toBeInstanceOf( + ForbiddenException + ); + }); +}); diff --git a/backend/src/media/media.service.ts b/backend/src/media/media.service.ts new file mode 100644 index 0000000..b56edcc --- /dev/null +++ b/backend/src/media/media.service.ts @@ -0,0 +1,2872 @@ +import { + BadRequestException, + ForbiddenException, + Inject, + Injectable, + NotFoundException +} from '@nestjs/common'; +import type { + Asset, + Character, + Episode, + EpisodeScript, + Prisma, + Project, + RenderTask, + ShotImage, + StoryboardShot +} from '@prisma/client'; +import { Prisma as PrismaNamespace } from '@prisma/client'; +import { createHash } from 'node:crypto'; +import { execFile } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import type { AuthRequestUser } from '../auth/auth.types'; +import { StorageService } from '../assets/storage.service'; +import { toSafeAsset } from '../assets/asset.types'; +import { BillingService } from '../billing/billing.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { ProvidersService } from '../providers/providers.service'; +import { toSafeRenderTask } from '../queues/task.types'; +import { + GenerateEpisodeAudioDto, + GenerateEpisodeSubtitleDto, + RetryEpisodeAudioSegmentDto, + RenderEpisodeVideoDto +} from './media.dto'; +import { toSafeMediaTaskResult, type SrtCue } from './media.types'; + +const execFileAsync = promisify(execFile); +const DEFAULT_AUDIO_VOICE = 'coral'; +const VIDEO_WIDTH = 1080; +const VIDEO_HEIGHT = 1920; + +interface AudioDialogueSegment { + index: number; + segment_type: 'narration' | 'dialogue'; + shot_id: string | null; + shot_no: number | null; + start_seconds: number; + end_seconds: number; + target_duration: number; + speaker_name: string; + text: string; + voice: string; + voice_provider_code: string | null; + voice_model: string | null; + voice_style: string | null; + speech_speed: number | null; + character_id: string | null; + global_character_id: string | null; +} + +interface GeneratedAudioSegmentFile { + index: number; + buffer: Buffer; + mimeType: string; + duration: number; + isMock: boolean; + segment: AudioDialogueSegment; + filePath?: string; + size?: bigint; + hash?: string; + cacheHit?: boolean; +} + +interface AudioTimelineWarning { + index: number; + shot_no: number | null; + speaker_name: string; + text_preview: string; + target_duration: number; + actual_duration: number; + over_seconds: number; +} + +interface StoredAudioSegmentResult { + actual_duration: number; + is_mock: boolean; + mime_type: string; + segment_file_path: string; + segment_size?: bigint; + segment_hash?: string; + audio_cache_key?: string; + cache_hit?: boolean; +} + +interface AudioSegmentCacheEntry { + segment: AudioDialogueSegment; + result: StoredAudioSegmentResult; +} + +@Injectable() +export class MediaService { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(StorageService) private readonly storage: StorageService, + @Inject(ProvidersService) private readonly providersService: ProvidersService, + @Inject(BillingService) private readonly billingService: BillingService + ) {} + + async generateEpisodeAudio( + user: AuthRequestUser, + episodeId: string, + dto: GenerateEpisodeAudioDto + ) { + const { episode, project } = await this.loadEpisodeForUser(episodeId, user); + const existing = dto.force ? null : await this.findLatestOutputAsset(episode.id, 'audio_generate'); + + if (existing) { + return { + ...toSafeMediaTaskResult(existing.asset, existing.task, true), + next_step: 'subtitle_generate' + }; + } + + const script = await this.findConfirmedScript(episode.id); + + if (!script) { + throw new BadRequestException('Confirmed episode script is required before audio generation'); + } + + const shots = await this.loadConfirmedShots(episode.id); + const dialogueMode = this.resolveAudioDialogueMode(dto.dialogue_mode); + + if (dialogueMode === 'mixed') { + const characters = await this.loadEpisodeCharacters(project.id); + const segments = this.buildAudioDialogueSegments(script, shots, characters, dto); + + if (segments.length > 0) { + return this.generateMixedEpisodeAudio(user, project, episode, segments); + } + } + + const narrationText = this.buildNarrationText(script, shots); + const voice = dto.voice?.trim() || DEFAULT_AUDIO_VOICE; + const narrationSegment = this.createNarrationAudioSegment(narrationText, voice); + const task = await this.createRenderTask( + project.id, + episode.id, + null, + 'audio_generate', + { + episode_id: episode.id.toString(), + dialogue_mode: 'narration', + voice, + text: narrationText, + segment_count: 1, + voice_count: 1, + segments: [this.toTaskAudioSegment(narrationSegment)] + }, + user + ); + let providerOutput: Record | null = null; + let asset: Asset; + let updatedTask: RenderTask; + let duration: number; + + try { + const cache = await this.loadAudioSegmentCache(project); + let segmentFile = await this.readCachedAudioSegment( + narrationSegment, + cache.get(this.audioSegmentCacheKey(narrationSegment)) + ); + + if (!segmentFile) { + const batch = await this.providersService.executeProviderBatch( + { + provider_type: 'VoiceProvider', + purpose: `episode-${episode.id.toString()}-tts-batch`, + project_id: project.id.toString(), + task_id: task.id.toString(), + allow_fallback: false, + return_binary: true + }, + [ + { + purpose: `episode-${episode.id.toString()}-tts`, + input_json: this.createAudioSegmentProviderInput(narrationSegment) + } + ] + ); + const providerResult = batch.results[0]; + + providerOutput = this.jsonObject(providerResult.result); + const providerDuration = this.normalizeDuration(Number(providerOutput.duration) || narrationText.length / 8); + duration = providerResult.provider.mode === 'mock' + ? this.normalizeDuration(narrationSegment.target_duration) + : providerDuration; + const audioFile = await this.createGeneratedAudioFile( + providerOutput, + episode.id, + duration, + providerResult.provider.mode === 'mock' + ); + + segmentFile = { + index: narrationSegment.index, + buffer: audioFile.buffer, + mimeType: audioFile.mimetype, + duration, + isMock: audioFile.isMock, + segment: narrationSegment, + cacheHit: false + }; + } + + duration = segmentFile.duration; + await this.storeGeneratedAudioSegmentFiles(episode, [segmentFile]); + const stored = await this.storage.storePrivateFile( + { + originalname: `episode-${episode.id.toString()}-narration${this.audioExtensionFromMime(segmentFile.mimeType)}`, + mimetype: segmentFile.mimeType, + size: segmentFile.buffer.length, + buffer: segmentFile.buffer + } as Express.Multer.File, + 'generated-audio' + ); + asset = await this.prisma.asset.create({ + data: { + user_id: project.user_id, + project_id: project.id, + asset_type: 'audio', + file_path: stored.file_path, + file_url: this.stringifyText(providerOutput?.asset_url) || null, + mime_type: segmentFile.mimeType, + duration: duration, + size: stored.size, + hash: stored.hash, + visibility: 'private', + status: segmentFile.isMock ? 'mock' : 'active' + } + }); + await this.prisma.renderTask.update({ + where: { id: task.id }, + data: { + input_json: this.createMixedAudioTaskInput(task.input_json, { + duration, + segmentFiles: [segmentFile], + timelineWarnings: [] + }) as unknown as Prisma.InputJsonObject + } + }); + updatedTask = await this.markTaskOutput(task.id, asset.id, 'success'); + } catch (error) { + await this.markTaskFailed(task.id, error); + throw error; + } + + await this.prisma.project.update({ + where: { id: project.id }, + data: { status: 'audio_generated' } + }); + + return { + ...toSafeMediaTaskResult(asset, updatedTask, false), + voice, + duration, + dialogue_mode: 'narration', + segment_count: 1, + voice_count: 1, + next_step: 'subtitle_generate' + }; + } + + private async generateMixedEpisodeAudio( + user: AuthRequestUser, + project: Project, + episode: Episode, + segments: AudioDialogueSegment[] + ) { + const task = await this.createRenderTask( + project.id, + episode.id, + null, + 'audio_generate', + { + episode_id: episode.id.toString(), + dialogue_mode: 'mixed', + segment_count: segments.length, + voice_count: new Set(segments.map((segment) => segment.voice)).size, + segments: segments.map((segment) => this.toTaskAudioSegment(segment)) + }, + user + ); + let asset: Asset; + let updatedTask: RenderTask; + let mixed: { + buffer: Buffer; + mimeType: string; + duration: number; + isMock: boolean; + segmentFiles: GeneratedAudioSegmentFile[]; + timelineWarnings: AudioTimelineWarning[]; + }; + + try { + mixed = await this.createMixedAudioBuffer(project, episode, task, segments); + const stored = await this.storage.storePrivateFile( + { + originalname: `episode-${episode.id.toString()}-dialogue-mix${this.audioExtensionFromMime(mixed.mimeType)}`, + mimetype: mixed.mimeType, + size: mixed.buffer.length, + buffer: mixed.buffer + } as Express.Multer.File, + 'generated-audio' + ); + + asset = await this.prisma.asset.create({ + data: { + user_id: project.user_id, + project_id: project.id, + asset_type: 'audio', + file_path: stored.file_path, + file_url: null, + mime_type: mixed.mimeType, + duration: mixed.duration, + size: stored.size, + hash: stored.hash, + visibility: 'private', + status: mixed.isMock ? 'mock' : 'active' + } + }); + await this.prisma.renderTask.update({ + where: { id: task.id }, + data: { + input_json: this.createMixedAudioTaskInput(task.input_json, mixed) as unknown as Prisma.InputJsonObject + } + }); + updatedTask = await this.markTaskOutput(task.id, asset.id, 'success'); + } catch (error) { + await this.markTaskFailed(task.id, error); + throw error; + } + + await this.prisma.project.update({ + where: { id: project.id }, + data: { status: 'audio_generated' } + }); + + return { + ...toSafeMediaTaskResult(asset, updatedTask, false), + dialogue_mode: 'mixed', + segment_count: segments.length, + voice_count: new Set(segments.map((segment) => segment.voice)).size, + segments: segments.map((segment) => this.toSafeAudioSegment(segment)), + timeline_warnings: mixed.timelineWarnings, + duration: mixed.duration, + next_step: 'subtitle_generate' + }; + } + + async generateEpisodeSubtitle( + user: AuthRequestUser, + episodeId: string, + dto: GenerateEpisodeSubtitleDto + ) { + const { episode, project } = await this.loadEpisodeForUser(episodeId, user); + const existing = dto.force ? null : await this.findLatestOutputAsset(episode.id, 'subtitle_generate'); + + if (existing) { + return { + ...toSafeMediaTaskResult(existing.asset, existing.task, true), + next_step: 'video_render' + }; + } + + const shots = await this.loadConfirmedShots(episode.id); + + if (shots.length === 0) { + throw new BadRequestException('Confirmed storyboard shots are required before subtitle generation'); + } + + const maxChars = this.normalizePositiveInt(dto.max_chars_per_line, 'max_chars_per_line', 8, 24, 18); + const subtitleMode = this.resolveSubtitleMode(dto.subtitle_mode); + const cues = + subtitleMode === 'shot' + ? this.buildSrtCues(shots, maxChars) + : await this.buildDialogueSubtitleCues(project.id, episode.id, shots, maxChars, dto.include_speaker === true); + const srt = this.stringifySrt(cues); + const task = await this.createRenderTask( + project.id, + episode.id, + null, + 'subtitle_generate', + { + episode_id: episode.id.toString(), + subtitle_mode: subtitleMode, + max_chars_per_line: maxChars, + cue_count: cues.length, + timeline_duration: this.subtitleTimelineDuration(cues) + }, + user + ); + const stored = await this.storage.storePrivateFile( + { + originalname: `episode-${episode.id.toString()}.srt`, + mimetype: 'application/x-subrip', + size: Buffer.byteLength(srt), + buffer: Buffer.from(srt) + } as Express.Multer.File, + 'generated-subtitles' + ); + const asset = await this.prisma.asset.create({ + data: { + user_id: project.user_id, + project_id: project.id, + asset_type: 'subtitle', + file_path: stored.file_path, + file_url: null, + mime_type: 'application/x-subrip', + duration: this.totalShotDuration(shots), + size: stored.size, + hash: stored.hash, + visibility: 'private', + status: 'active' + } + }); + const updatedTask = await this.markTaskOutput(task.id, asset.id, 'success'); + + await this.prisma.project.update({ + where: { id: project.id }, + data: { status: 'subtitle_generated' } + }); + + return { + ...toSafeMediaTaskResult(asset, updatedTask, false), + subtitle_mode: subtitleMode, + cues, + cue_count: cues.length, + timeline_duration: this.subtitleTimelineDuration(cues), + next_step: 'video_render' + }; + } + + async retryEpisodeAudioSegment( + user: AuthRequestUser, + episodeId: string, + segmentIndex: string, + dto: RetryEpisodeAudioSegmentDto + ) { + const { episode, project } = await this.loadEpisodeForUser(episodeId, user); + const latest = await this.findLatestOutputAsset(episode.id, 'audio_generate'); + + if (!latest) { + throw new BadRequestException('Mixed audio must be generated before retrying a single TTS segment'); + } + + const previousInput = this.jsonObject(latest.task.input_json); + const segments = this.readTaskAudioSegmentsForRetry(previousInput); + const index = this.parsePositiveInt(segmentIndex, 'Invalid segment index'); + const targetSegment = segments.find((segment) => segment.index === index); + + if (!targetSegment) { + throw new BadRequestException(`Audio segment ${index} was not found in the latest mixed audio task`); + } + + const segmentResults = this.readTaskAudioSegmentResults(previousInput); + const previousFiles = await this.loadStoredAudioSegmentFiles(segments, segmentResults, index); + const retrySegment = { + ...targetSegment, + voice: dto.voice?.trim() || targetSegment.voice, + voice_style: dto.voice_style?.trim() || targetSegment.voice_style, + speech_speed: this.normalizeSpeechSpeed(dto.speech_speed, targetSegment.speech_speed) + }; + const task = await this.createRenderTask( + project.id, + episode.id, + null, + 'audio_generate', + { + episode_id: episode.id.toString(), + dialogue_mode: 'mixed', + retry_from_task_id: latest.task.id.toString(), + retry_segment_index: index, + segment_count: segments.length, + voice_count: new Set(segments.map((segment) => (segment.index === index ? retrySegment.voice : segment.voice))).size, + segments: segments.map((segment) => this.toTaskAudioSegment(segment.index === index ? retrySegment : segment)) + }, + user + ); + let asset: Asset; + let updatedTask: RenderTask; + let mixed: Awaited>; + + try { + const retriedFile = await this.synthesizeAudioSegment( + project, + episode, + task, + retrySegment, + `episode-${episode.id.toString()}-tts-retry-${index}` + ); + await this.storeGeneratedAudioSegmentFiles(episode, [retriedFile]); + + const segmentFiles = segments + .map((segment) => (segment.index === index ? retriedFile : previousFiles.get(segment.index))) + .filter((file): file is GeneratedAudioSegmentFile => Boolean(file)); + + if (segmentFiles.length !== segments.length) { + throw new BadRequestException('Some stored TTS segment files are missing; regenerate the full audio once before single-segment retry'); + } + + mixed = await this.createMixedAudioOutputFromFiles(segmentFiles); + const stored = await this.storage.storePrivateFile( + { + originalname: `episode-${episode.id.toString()}-dialogue-mix-retry-${index}${this.audioExtensionFromMime(mixed.mimeType)}`, + mimetype: mixed.mimeType, + size: mixed.buffer.length, + buffer: mixed.buffer + } as Express.Multer.File, + 'generated-audio' + ); + + asset = await this.prisma.asset.create({ + data: { + user_id: project.user_id, + project_id: project.id, + asset_type: 'audio', + file_path: stored.file_path, + file_url: null, + mime_type: mixed.mimeType, + duration: mixed.duration, + size: stored.size, + hash: stored.hash, + visibility: 'private', + status: mixed.isMock ? 'mock' : 'active' + } + }); + await this.prisma.renderTask.update({ + where: { id: task.id }, + data: { + input_json: { + ...this.createMixedAudioTaskInput(task.input_json, mixed), + retry_from_task_id: latest.task.id.toString(), + retry_segment_index: index + } as unknown as Prisma.InputJsonObject + } + }); + updatedTask = await this.markTaskOutput(task.id, asset.id, 'success'); + } catch (error) { + await this.markTaskFailed(task.id, error); + throw error; + } + + await this.prisma.project.update({ + where: { id: project.id }, + data: { status: 'audio_generated' } + }); + + return { + ...toSafeMediaTaskResult(asset, updatedTask, false), + dialogue_mode: 'mixed', + retry_segment_index: index, + segment_count: segments.length, + voice_count: new Set(mixed.segmentFiles.map((file) => file.segment.voice)).size, + segments: mixed.segmentFiles.map((file) => this.toSafeAudioSegment(file.segment)), + timeline_warnings: mixed.timelineWarnings, + duration: mixed.duration, + next_step: 'video_render' + }; + } + + async renderEpisodeVideo(user: AuthRequestUser, episodeId: string, dto: RenderEpisodeVideoDto) { + const { episode, project } = await this.loadEpisodeForUser(episodeId, user); + const existing = dto.force ? null : await this.findLatestOutputAsset(episode.id, 'video_render'); + + if (existing) { + return { + ...toSafeMediaTaskResult(existing.asset, existing.task, true), + ffmpeg_used: false, + next_step: 'download_or_review' + }; + } + + await this.billingService.ensureProjectQuotaReserved(project); + + const shots = await this.loadConfirmedShots(episode.id); + + if (shots.length === 0) { + throw new BadRequestException('Confirmed storyboard shots are required before video render'); + } + + const shotImages = await this.loadShotImagesForVideo(shots); + + if (shotImages.length < shots.length) { + throw new BadRequestException('Generated shot images are required before video render'); + } + + const includeAudio = dto.include_audio !== false; + const includeSubtitle = dto.include_subtitle !== false; + const audio = includeAudio + ? (await this.findLatestOutputAsset(episode.id, 'audio_generate')) ?? + (await this.generateEpisodeAudio(user, episode.id.toString(), {})) + : null; + const subtitle = includeSubtitle + ? (await this.findLatestOutputAsset(episode.id, 'subtitle_generate')) ?? + (await this.generateEpisodeSubtitle(user, episode.id.toString(), {})) + : null; + const task = await this.createRenderTask( + project.id, + episode.id, + null, + 'video_render', + { + episode_id: episode.id.toString(), + shot_count: shots.length, + shot_image_asset_ids: shotImages + .map((image) => image.asset_id?.toString()) + .filter((assetId): assetId is string => Boolean(assetId)), + audio_asset_id: this.outputAssetId(audio), + subtitle_asset_id: this.outputAssetId(subtitle), + width: VIDEO_WIDTH, + height: VIDEO_HEIGHT, + fps: 30 + }, + user + ); + let render: Awaited>; + let providerOutput: Record; + let asset: Asset; + let updatedTask: RenderTask; + + try { + if (dto.prefer_ffmpeg !== false) { + providerOutput = {}; + render = await this.createVideoBuffer(project, episode, shots, shotImages, { + preferFfmpeg: true, + providerAssetUrl: '', + providerContentBase64: '', + providerMimeType: '', + allowMockOutput: false, + includeAudio, + includeSubtitle, + audio_asset_id: this.outputAssetId(audio), + subtitle_asset_id: this.outputAssetId(subtitle) + }); + } else { + const providerResult = await this.providersService.executeProvider({ + provider_type: 'VideoProvider', + purpose: `episode-${episode.id.toString()}-video-render`, + project_id: project.id.toString(), + task_id: task.id.toString(), + allow_fallback: false, + return_binary: true, + input_json: { + duration: this.totalShotDuration(shots), + motion: shots.map((shot) => shot.camera_motion ?? 'zoom_in').join(','), + shot_count: shots.length + } + }); + providerOutput = this.jsonObject(providerResult.result); + render = await this.createVideoBuffer(project, episode, shots, shotImages, { + preferFfmpeg: false, + providerAssetUrl: this.stringifyText(providerOutput.asset_url), + providerContentBase64: this.stringifyText(providerOutput.content_base64), + providerMimeType: this.stringifyText(providerOutput.mime_type), + allowMockOutput: providerResult.provider.mode === 'mock', + includeAudio, + includeSubtitle, + audio_asset_id: this.outputAssetId(audio), + subtitle_asset_id: this.outputAssetId(subtitle) + }); + } + + const stored = await this.storage.storePrivateFile( + { + originalname: `episode-${episode.id.toString()}-${render.backend === 'mock_ffmpeg_unavailable' ? 'mock' : 'render'}.mp4`, + mimetype: 'video/mp4', + size: render.buffer.length, + buffer: render.buffer + } as Express.Multer.File, + 'rendered-videos' + ); + + asset = await this.prisma.asset.create({ + data: { + user_id: project.user_id, + project_id: project.id, + asset_type: 'video', + file_path: stored.file_path, + file_url: this.stringifyText(providerOutput.asset_url) || null, + mime_type: 'video/mp4', + width: VIDEO_WIDTH, + height: VIDEO_HEIGHT, + duration: this.totalShotDuration(shots), + size: stored.size, + hash: stored.hash, + visibility: 'private', + status: render.backend === 'mock_ffmpeg_unavailable' ? 'mock' : 'active' + } + }); + updatedTask = await this.markTaskOutput(task.id, asset.id, 'success'); + } catch (error) { + await this.markTaskFailed(task.id, error); + throw error; + } + + await this.prisma.project.update({ + where: { id: project.id }, + data: { status: 'video_rendered' } + }); + await this.billingService.deductReservedProjectQuota( + project, + updatedTask.id, + 'video_render_success' + ); + + return { + ...toSafeMediaTaskResult(asset, updatedTask, false), + ffmpeg_used: render.ffmpegUsed, + render_backend: render.backend, + audio_asset_id: this.outputAssetId(audio), + subtitle_asset_id: this.outputAssetId(subtitle), + shot_image_count: shotImages.length, + next_step: 'download_or_review' + }; + } + + async listEpisodeMediaAssets(user: AuthRequestUser, episodeId: string) { + const { episode } = await this.loadEpisodeForUser(episodeId, user); + const tasks = await this.prisma.renderTask.findMany({ + where: { + episode_id: episode.id, + task_type: { in: ['audio_generate', 'subtitle_generate', 'video_render'] }, + output_asset_id: { not: null } + }, + orderBy: { created_at: 'desc' } + }); + const assets = await Promise.all( + tasks.map(async (task) => { + const asset = task.output_asset_id + ? await this.prisma.asset.findUnique({ where: { id: task.output_asset_id } }) + : null; + + return asset + ? { + task_type: task.task_type, + task_id: task.id.toString(), + task: toSafeRenderTask(task), + asset: toSafeAsset(asset), + timeline: await this.createMediaTimeline(task, asset), + stats: this.createMediaTaskStats(task, asset) + } + : null; + }) + ); + + return assets.filter(Boolean); + } + + private async createMediaTimeline(task: RenderTask, asset: Asset) { + if (task.task_type === 'audio_generate') { + const input = this.jsonObject(task.input_json); + + return { + type: 'audio', + segments: this.readTaskAudioSegments(input), + warnings: this.readTaskTimelineWarnings(input) + }; + } + + if (task.task_type === 'subtitle_generate') { + return { + type: 'subtitle', + cues: await this.readSubtitleCuesFromAsset(asset) + }; + } + + return null; + } + + private createMediaTaskStats(task: RenderTask, asset: Asset) { + const input = this.jsonObject(task.input_json); + + if (task.task_type === 'audio_generate') { + const segments = this.readTaskAudioSegments(input); + const totalCharacters = + this.numberFromUnknown(input.estimated_tts_characters) || + segments.reduce((sum, segment) => sum + this.stringifyText(segment.text).length, 0); + const warnings = this.readTaskTimelineWarnings(input); + const resultMap = this.readTaskAudioSegmentResults(input); + const missingSegmentFileCount = segments.filter( + (segment) => !resultMap.get(segment.index)?.segment_file_path + ).length; + + return { + segment_count: this.numberFromUnknown(input.segment_count) || segments.length || 1, + voice_count: this.numberFromUnknown(input.voice_count) || new Set(segments.map((segment) => segment.voice)).size || 1, + total_characters: totalCharacters, + duration_seconds: this.assetDuration(asset), + warning_count: warnings.length, + segment_retry_ready: segments.length > 0 && missingSegmentFileCount === 0, + missing_segment_file_count: missingSegmentFileCount, + cost_estimate: { + unit: 'characters', + total_characters: totalCharacters, + note: '真实 TTS 通常按字符、模型或 Provider usage 计费;准确金额以后台 Provider 日志和平台账单为准。' + } + }; + } + + if (task.task_type === 'subtitle_generate') { + return { + subtitle_mode: this.stringifyText(input.subtitle_mode) || 'shot', + cue_count: this.numberFromUnknown(input.cue_count), + duration_seconds: this.assetDuration(asset) + }; + } + + if (task.task_type === 'video_render') { + return { + duration_seconds: this.assetDuration(asset), + width: asset.width, + height: asset.height + }; + } + + return null; + } + + private createMixedAudioTaskInput( + inputJson: Prisma.JsonValue | null, + mixed: { + duration: number; + segmentFiles: GeneratedAudioSegmentFile[]; + timelineWarnings: AudioTimelineWarning[]; + } + ) { + const input = this.jsonObject(inputJson); + const segments = this.readTaskAudioSegments(input); + const segmentResults = mixed.segmentFiles.map((file) => ({ + index: file.segment.index, + actual_duration: file.duration, + is_mock: file.isMock, + mime_type: file.mimeType, + segment_file_path: file.filePath ?? null, + segment_size: file.size?.toString() ?? null, + segment_hash: file.hash ?? null, + audio_cache_key: this.audioSegmentCacheKey(file.segment), + cache_hit: file.cacheHit === true, + start_seconds: file.segment.start_seconds, + end_seconds: file.segment.end_seconds, + target_duration: file.segment.target_duration + })); + + return { + ...input, + segment_count: mixed.segmentFiles.length, + voice_count: new Set(mixed.segmentFiles.map((file) => file.segment.voice)).size, + duration_seconds: mixed.duration, + estimated_tts_characters: mixed.segmentFiles.reduce((sum, file) => sum + file.segment.text.length, 0), + segments: segments.length > 0 ? segments : mixed.segmentFiles.map((file) => this.toTaskAudioSegment(file.segment)), + segment_results: segmentResults, + timeline_warnings: mixed.timelineWarnings, + cost_estimate: { + unit: 'characters', + total_characters: mixed.segmentFiles.reduce((sum, file) => sum + file.segment.text.length, 0), + cached_segments: mixed.segmentFiles.filter((file) => file.cacheHit).length, + generated_segments: mixed.segmentFiles.filter((file) => !file.cacheHit).length, + note: '真实 TTS 通常按字符、模型或 Provider usage 计费;准确金额以后台 Provider 日志和平台账单为准。' + } + }; + } + + private readTaskAudioSegments(input: Record) { + const segments = Array.isArray(input.segments) ? input.segments : []; + const results = Array.isArray(input.segment_results) ? input.segment_results : []; + const resultByIndex = new Map( + results + .map((item) => { + const value = this.jsonObject(item); + const index = this.numberFromUnknown(value.index); + + return index > 0 ? [index, value] : null; + }) + .filter((item): item is [number, Record] => Boolean(item)) + ); + + return segments + .map((item) => { + const segment = this.jsonObject(item); + const index = this.numberFromUnknown(segment.index); + + if (index <= 0) return null; + + const result = resultByIndex.get(index) ?? {}; + return { + index, + segment_type: this.stringifyText(segment.segment_type) || 'dialogue', + shot_id: this.stringifyText(segment.shot_id) || null, + shot_no: this.numberFromUnknown(segment.shot_no) || null, + start_seconds: this.numberFromUnknown(segment.start_seconds), + end_seconds: this.numberFromUnknown(segment.end_seconds), + target_duration: this.numberFromUnknown(segment.target_duration), + actual_duration: this.numberFromUnknown(result.actual_duration) || null, + speaker_name: this.stringifyText(segment.speaker_name) || '角色', + voice: this.stringifyText(segment.voice) || DEFAULT_AUDIO_VOICE, + voice_provider_code: this.stringifyText(segment.voice_provider_code) || null, + voice_model: this.stringifyText(segment.voice_model) || null, + voice_style: this.stringifyText(segment.voice_style) || null, + speech_speed: this.numberFromUnknown(segment.speech_speed) || null, + character_id: this.stringifyText(segment.character_id) || null, + global_character_id: this.stringifyText(segment.global_character_id) || null, + text: this.stringifyText(segment.text) || this.stringifyText(segment.text_preview), + is_mock: typeof result.is_mock === 'boolean' ? result.is_mock : null + }; + }) + .filter((item): item is NonNullable => Boolean(item)); + } + + private readTaskAudioSegmentsForRetry(input: Record): AudioDialogueSegment[] { + return this.readTaskAudioSegments(input).map((segment) => ({ + index: segment.index, + segment_type: segment.segment_type === 'narration' ? 'narration' : 'dialogue', + shot_id: segment.shot_id, + shot_no: segment.shot_no, + start_seconds: segment.start_seconds, + end_seconds: segment.end_seconds, + target_duration: segment.target_duration, + speaker_name: segment.speaker_name, + text: segment.text, + voice: segment.voice, + voice_provider_code: segment.voice_provider_code, + voice_model: segment.voice_model, + voice_style: segment.voice_style, + speech_speed: segment.speech_speed, + character_id: segment.character_id, + global_character_id: segment.global_character_id + })); + } + + private readTaskAudioSegmentResults(input: Record) { + const results = Array.isArray(input.segment_results) ? input.segment_results : []; + const entries: Array<[number, StoredAudioSegmentResult]> = []; + + for (const item of results) { + const result = this.jsonObject(item); + const index = this.numberFromUnknown(result.index); + + if (index <= 0) continue; + + entries.push([ + index, + { + actual_duration: this.numberFromUnknown(result.actual_duration), + is_mock: typeof result.is_mock === 'boolean' ? result.is_mock : false, + mime_type: this.normalizeAudioMimeType(this.stringifyText(result.mime_type)), + segment_file_path: this.stringifyText(result.segment_file_path), + segment_size: this.bigintFromUnknown(result.segment_size), + segment_hash: this.stringifyText(result.segment_hash) || undefined, + audio_cache_key: this.stringifyText(result.audio_cache_key) || undefined, + cache_hit: result.cache_hit === true + } + ]); + } + + return new Map(entries); + } + + private async loadStoredAudioSegmentFiles( + segments: AudioDialogueSegment[], + results: Map, + retryIndex: number + ) { + const files = new Map(); + + for (const segment of segments) { + if (segment.index === retryIndex) continue; + + const result = results.get(segment.index); + + if (!result?.segment_file_path) { + throw new BadRequestException('This audio was generated before segment files were stored; regenerate the full audio once before single-segment retry'); + } + + files.set(segment.index, { + index: segment.index, + buffer: await this.storage.readPrivateFile(result.segment_file_path), + mimeType: result.mime_type, + duration: this.normalizeDuration(result.actual_duration || segment.target_duration), + isMock: result.is_mock, + segment, + filePath: result.segment_file_path, + size: result.segment_size, + hash: result.segment_hash, + cacheHit: false + }); + } + + return files; + } + + private async loadAudioSegmentCache(project: Project) { + const userProjects = await this.prisma.project.findMany({ + where: { user_id: project.user_id }, + select: { id: true }, + orderBy: { created_at: 'desc' }, + take: 100 + }); + const projectIds = userProjects.map((item) => item.id); + const cache = new Map(); + + if (projectIds.length === 0) return cache; + + const tasks = await this.prisma.renderTask.findMany({ + where: { + project_id: { in: projectIds }, + task_type: 'audio_generate', + status: 'success' + }, + orderBy: { created_at: 'desc' }, + take: 200 + }); + + for (const task of tasks) { + const input = this.jsonObject(task.input_json); + const segments = this.readTaskAudioSegmentsForRetry(input); + const results = this.readTaskAudioSegmentResults(input); + + for (const segment of segments) { + const result = results.get(segment.index); + const cacheKey = result?.audio_cache_key || this.audioSegmentCacheKey(segment); + + if (!result?.segment_file_path || cache.has(cacheKey)) continue; + + cache.set(cacheKey, { segment, result }); + } + } + + return cache; + } + + private async readCachedAudioSegment( + segment: AudioDialogueSegment, + entry: AudioSegmentCacheEntry | undefined + ): Promise { + if (!entry?.result.segment_file_path) return null; + + try { + return { + index: segment.index, + buffer: await this.storage.readPrivateFile(entry.result.segment_file_path), + mimeType: entry.result.mime_type, + duration: entry.result.is_mock + ? this.normalizeDuration(segment.target_duration) + : this.normalizeDuration(entry.result.actual_duration || segment.target_duration), + isMock: entry.result.is_mock, + segment, + filePath: entry.result.segment_file_path, + size: entry.result.segment_size, + hash: entry.result.segment_hash, + cacheHit: true + }; + } catch { + return null; + } + } + + private audioSegmentCacheKey(segment: AudioDialogueSegment) { + return this.hashJson({ + kind: 'tts_segment_cache_v1', + text: segment.text.trim(), + voice: segment.voice, + voice_provider_code: segment.voice_provider_code, + voice_model: segment.voice_model, + voice_style: segment.voice_style, + speech_speed: segment.speech_speed, + segment_type: segment.segment_type + }); + } + + private readTaskTimelineWarnings(input: Record) { + const warnings = Array.isArray(input.timeline_warnings) ? input.timeline_warnings : []; + + return warnings + .map((item) => { + const warning = this.jsonObject(item); + const index = this.numberFromUnknown(warning.index); + + if (index <= 0) return null; + + return { + index, + shot_no: this.numberFromUnknown(warning.shot_no) || null, + speaker_name: this.stringifyText(warning.speaker_name) || '角色', + text_preview: this.stringifyText(warning.text_preview), + target_duration: this.numberFromUnknown(warning.target_duration), + actual_duration: this.numberFromUnknown(warning.actual_duration), + over_seconds: this.numberFromUnknown(warning.over_seconds) + }; + }) + .filter((item): item is NonNullable => Boolean(item)); + } + + private async readSubtitleCuesFromAsset(asset: Asset) { + if (asset.asset_type !== 'subtitle') return []; + + try { + const content = await this.storage.readPrivateFile(asset.file_path); + + return this.parseSrtContent(content.toString('utf8')).map((cue) => ({ + ...cue, + start_seconds: this.srtTimeToSeconds(cue.start), + end_seconds: this.srtTimeToSeconds(cue.end) + })); + } catch { + return []; + } + } + + private async findLatestOutputAsset(episodeId: bigint, taskType: string) { + const task = await this.prisma.renderTask.findFirst({ + where: { + episode_id: episodeId, + task_type: taskType, + status: 'success', + output_asset_id: { not: null } + }, + orderBy: { created_at: 'desc' } + }); + + if (!task?.output_asset_id) { + return null; + } + + const asset = await this.prisma.asset.findUnique({ + where: { id: task.output_asset_id } + }); + + return asset ? { task, asset } : null; + } + + private async findConfirmedScript(episodeId: bigint) { + return this.prisma.episodeScript.findFirst({ + where: { + episode_id: episodeId, + status: 'confirmed' + }, + orderBy: { version: 'desc' } + }); + } + + private async loadConfirmedShots(episodeId: bigint) { + return this.prisma.storyboardShot.findMany({ + where: { + episode_id: episodeId, + status: 'confirmed' + }, + orderBy: { shot_no: 'asc' } + }); + } + + private async loadEpisodeCharacters(projectId: bigint) { + return this.prisma.character.findMany({ + where: { + project_id: projectId, + status: { not: 'deleted' } + }, + orderBy: [{ importance_level: 'desc' }, { id: 'asc' }] + }); + } + + private async loadShotImagesForVideo(shots: StoryboardShot[]) { + const images: ShotImage[] = []; + + for (const shot of shots) { + const image = await this.prisma.shotImage.findFirst({ + where: { + shot_id: shot.id, + status: 'generated', + asset_id: { not: null } + }, + orderBy: [{ image_type: 'desc' }, { created_at: 'asc' }] + }); + + if (image) { + images.push(image); + } + } + + return images; + } + + private buildNarrationText(script: EpisodeScript, shots: StoryboardShot[]) { + const shotText = shots + .map((shot) => [shot.narration_text, shot.dialogue_text].filter(Boolean).join(' ')) + .filter(Boolean) + .join('\n'); + + return [script.narration_text, shotText, script.script_text] + .filter(Boolean) + .join('\n') + .slice(0, 4000); + } + + private buildAudioDialogueSegments( + script: EpisodeScript | null, + shots: StoryboardShot[], + characters: Character[], + dto: GenerateEpisodeAudioDto + ): AudioDialogueSegment[] { + const maxSegments = this.normalizePositiveInt(dto.max_segments, 'max_segments', 1, 120, 80); + const characterByName = new Map(characters.map((character) => [character.name, character])); + const segments: AudioDialogueSegment[] = []; + let shotCursor = 0; + + for (const shot of shots) { + const inferredSpeaker = this.inferShotSpeaker(shot, characters); + const shotStart = shotCursor; + const shotDuration = this.normalizeDuration(Number(shot.duration?.toString()) || 4); + const shotItems: Array<{ + segmentType: 'narration' | 'dialogue'; + speakerName: string; + text: string; + character: Character | null; + }> = []; + + shotCursor = Number((shotCursor + shotDuration).toFixed(3)); + + if (shot.narration_text) { + shotItems.push({ + segmentType: 'narration', + speakerName: '旁白', + text: shot.narration_text, + character: null + }); + } + + for (const line of this.splitDialogueLines(shot.dialogue_text)) { + const parsed = this.parseDialogueLine(line); + const speakerName = parsed.speaker || inferredSpeaker?.name || this.inferDialogueSpeaker(script, parsed.text) || '角色'; + const character = characterByName.get(speakerName) ?? inferredSpeaker ?? null; + + shotItems.push({ + segmentType: 'dialogue', + speakerName, + text: parsed.text, + character + }); + } + + const slots = this.allocateShotSpeechSlots(shotStart, shotDuration, shotItems.map((item) => item.text)); + + shotItems.forEach((item, itemIndex) => { + segments.push( + this.createAudioDialogueSegment( + segments.length + 1, + item.segmentType, + shot, + item.speakerName, + item.text, + item.character, + dto, + slots[itemIndex] + ) + ); + }); + } + + if (segments.length === 0) { + let cursor = 0; + for (const item of this.readScriptDialogue(script)) { + const character = characterByName.get(item.speaker) ?? null; + const duration = this.estimateSpeechDuration(item.line); + const start = cursor; + const end = Number((cursor + duration).toFixed(3)); + + segments.push( + this.createAudioDialogueSegment( + segments.length + 1, + 'dialogue', + null, + item.speaker, + item.line, + character, + dto, + { + start_seconds: start, + end_seconds: end, + target_duration: duration + } + ) + ); + cursor = end; + } + } + + if (segments.length > maxSegments) { + throw new BadRequestException(`audio segment count ${segments.length} exceeds max_segments ${maxSegments}`); + } + + return segments.filter((segment) => segment.text.length > 0); + } + + private createAudioDialogueSegment( + index: number, + segmentType: 'narration' | 'dialogue', + shot: StoryboardShot | null, + speakerName: string, + text: string, + character: Character | null, + dto: GenerateEpisodeAudioDto, + timing?: { + start_seconds: number; + end_seconds: number; + target_duration: number; + } + ): AudioDialogueSegment { + const cleanedText = this.normalizeSpeechText(text); + const targetDuration = timing?.target_duration ?? this.estimateSpeechDuration(cleanedText); + const startSeconds = timing?.start_seconds ?? 0; + const endSeconds = timing?.end_seconds ?? Number((startSeconds + targetDuration).toFixed(3)); + const speechSpeed = this.recommendedSpeechSpeed(cleanedText, targetDuration); + const voice = + segmentType === 'narration' + ? (dto.narration_voice?.trim() || dto.voice?.trim() || DEFAULT_AUDIO_VOICE) + : (character?.voice_id?.trim() || dto.voice?.trim() || DEFAULT_AUDIO_VOICE); + const voiceStyle = + segmentType === 'narration' + ? '中文短剧旁白,清晰稳定,情绪不过度夸张。' + : character?.voice_style || character?.speech_style || null; + + return { + index, + segment_type: segmentType, + shot_id: shot?.id.toString() ?? null, + shot_no: shot?.shot_no ?? null, + start_seconds: startSeconds, + end_seconds: endSeconds, + target_duration: targetDuration, + speaker_name: speakerName, + text: cleanedText.slice(0, 800), + voice, + voice_provider_code: character?.voice_provider_code ?? null, + voice_model: character?.voice_model ?? null, + voice_style: voiceStyle, + speech_speed: speechSpeed, + character_id: character?.id.toString() ?? null, + global_character_id: character?.global_character_id?.toString() ?? null + }; + } + + private createNarrationAudioSegment(text: string, voice: string): AudioDialogueSegment { + const cleanedText = this.normalizeSpeechText(text); + const targetDuration = this.estimateSpeechDuration(cleanedText); + + return { + index: 1, + segment_type: 'narration', + shot_id: null, + shot_no: null, + start_seconds: 0, + end_seconds: targetDuration, + target_duration: targetDuration, + speaker_name: '旁白', + text: cleanedText, + voice, + voice_provider_code: null, + voice_model: null, + voice_style: '中文短剧旁白,清晰稳定,情绪不过度夸张。', + speech_speed: null, + character_id: null, + global_character_id: null + }; + } + + private splitDialogueLines(value: string | null) { + return (value ?? '') + .split(/\n+/) + .map((line) => line.trim()) + .filter(Boolean); + } + + private parseDialogueLine(value: string) { + const match = /^([^::]{1,16})[::](.+)$/.exec(value.trim()); + + if (!match) { + return { + speaker: null, + text: this.normalizeSpeechText(value) + }; + } + + return { + speaker: match[1].trim(), + text: this.normalizeSpeechText(match[2]) + }; + } + + private inferShotSpeaker(shot: StoryboardShot, characters: Character[]) { + const names = this.readShotCharacterNames(shot); + + for (const name of names) { + const matched = characters.find((character) => character.name === name); + if (matched) return matched; + } + + return characters.find((character) => ['protagonist', 'lead'].includes(character.role_type)) ?? characters[0] ?? null; + } + + private inferDialogueSpeaker(script: EpisodeScript | null, line: string) { + return this.readScriptDialogue(script).find((item) => item.line === line || item.line.includes(line))?.speaker ?? null; + } + + private readShotCharacterNames(shot: StoryboardShot) { + const value = shot.characters_json; + + if (!Array.isArray(value)) return []; + + return value + .map((item) => { + if (typeof item === 'string') return item; + if (item && typeof item === 'object' && 'name' in item) return String(item.name); + return ''; + }) + .map((name) => name.trim()) + .filter(Boolean); + } + + private readScriptDialogue(script: EpisodeScript | null) { + const value = script?.dialogue_json; + + if (!Array.isArray(value)) return []; + + return value + .map((item) => { + if (!item || typeof item !== 'object' || !('speaker' in item) || !('line' in item)) return null; + const speaker = String(item.speaker).trim(); + const line = this.normalizeSpeechText(String(item.line)); + + return speaker && line ? { speaker, line } : null; + }) + .filter((item): item is { speaker: string; line: string } => Boolean(item)); + } + + private normalizeSpeechText(value: string) { + return value.replace(/\s+/g, ' ').trim(); + } + + private allocateShotSpeechSlots(shotStart: number, shotDuration: number, texts: string[]) { + if (texts.length === 0) return []; + + const gap = texts.length > 1 ? Math.min(0.15, shotDuration / (texts.length * 8)) : 0; + const usableDuration = Math.max(0.2, shotDuration - gap * (texts.length - 1)); + const weights = texts.map((text) => Math.max(1, this.normalizeSpeechText(text).length)); + const totalWeight = weights.reduce((sum, weight) => sum + weight, 0) || texts.length; + const slots = []; + let used = 0; + + for (let index = 0; index < texts.length; index += 1) { + const isLast = index === texts.length - 1; + const rawDuration = isLast ? usableDuration - used : (usableDuration * weights[index]) / totalWeight; + const targetDuration = this.roundSeconds(Math.max(0.2, rawDuration)); + const startSeconds = this.roundSeconds(shotStart + used + gap * index); + const endSeconds = this.roundSeconds(isLast ? shotStart + shotDuration : startSeconds + targetDuration); + + slots.push({ + start_seconds: startSeconds, + end_seconds: endSeconds, + target_duration: this.roundSeconds(Math.max(0.2, endSeconds - startSeconds)) + }); + used = this.roundSeconds(used + targetDuration); + } + + return slots; + } + + private estimateSpeechDuration(text: string) { + return this.roundSeconds(Math.min(20, Math.max(1.2, this.normalizeSpeechText(text).length / 5))); + } + + private recommendedSpeechSpeed(text: string, targetDuration: number) { + const normalizedTarget = this.roundSeconds(targetDuration); + + if (normalizedTarget <= 0) return null; + + const estimatedDuration = this.estimateSpeechDuration(text); + + if (estimatedDuration <= normalizedTarget * 1.08) return null; + + return Number(Math.min(1.45, Math.max(1.05, estimatedDuration / normalizedTarget)).toFixed(2)); + } + + private roundSeconds(value: number) { + return Number(Math.max(0, value).toFixed(3)); + } + + private async buildDialogueSubtitleCues( + projectId: bigint, + episodeId: bigint, + shots: StoryboardShot[], + maxChars: number, + includeSpeaker: boolean + ): Promise { + const script = await this.findConfirmedScript(episodeId); + const characters = await this.loadEpisodeCharacters(projectId); + const segments = this.buildAudioDialogueSegments(script, shots, characters, { max_segments: 120 }); + const cues = segments + .filter((segment) => segment.text.length > 0) + .map((segment, index) => ({ + index: index + 1, + start: this.formatSrtTime(segment.start_seconds), + end: this.formatSrtTime(segment.end_seconds), + text: this.wrapSubtitleText(this.formatSubtitleSegmentText(segment, includeSpeaker), maxChars), + start_seconds: segment.start_seconds, + end_seconds: segment.end_seconds, + shot_id: segment.shot_id, + shot_no: segment.shot_no, + segment_type: segment.segment_type, + speaker_name: segment.speaker_name + })); + + return cues.length > 0 ? cues : this.buildSrtCues(shots, maxChars); + } + + private formatSubtitleSegmentText(segment: AudioDialogueSegment, includeSpeaker: boolean) { + if (!includeSpeaker) return segment.text; + if (segment.segment_type === 'narration') return `旁白:${segment.text}`; + + return `${segment.speaker_name}:${segment.text}`; + } + + private buildSrtCues(shots: StoryboardShot[], maxChars: number): SrtCue[] { + let cursor = 0; + + return shots.map((shot, index) => { + const duration = this.normalizeDuration(Number(shot.duration?.toString()) || 4); + const text = this.wrapSubtitleText( + [shot.dialogue_text, shot.narration_text, shot.action_desc].filter(Boolean).join(' '), + maxChars + ); + const cue = { + index: index + 1, + start: this.formatSrtTime(cursor), + end: this.formatSrtTime(cursor + duration), + text: text || `第${shot.shot_no}镜`, + start_seconds: this.roundSeconds(cursor), + end_seconds: this.roundSeconds(cursor + duration), + shot_id: shot.id.toString(), + shot_no: shot.shot_no, + segment_type: 'shot' as const, + speaker_name: null + }; + + cursor += duration; + return cue; + }); + } + + private subtitleTimelineDuration(cues: SrtCue[]) { + return this.roundSeconds( + cues.reduce((duration, cue) => Math.max(duration, cue.end_seconds ?? this.srtTimeToSeconds(cue.end)), 0) + ); + } + + private stringifySrt(cues: SrtCue[]) { + return `${cues + .map((cue) => `${cue.index}\n${cue.start} --> ${cue.end}\n${cue.text}\n`) + .join('\n')}\n`; + } + + private wrapSubtitleText(text: string, maxChars: number) { + const normalized = text.replace(/\s+/g, ''); + const lines = []; + + for (let index = 0; index < normalized.length; index += maxChars) { + lines.push(normalized.slice(index, index + maxChars)); + } + + return lines.slice(0, 2).join('\n'); + } + + private async createVideoBuffer( + project: Project, + episode: Episode, + shots: StoryboardShot[], + shotImages: ShotImage[], + options: { + preferFfmpeg: boolean; + providerAssetUrl: string; + providerContentBase64: string; + providerMimeType: string; + allowMockOutput: boolean; + includeAudio: boolean; + includeSubtitle: boolean; + audio_asset_id: string | null; + subtitle_asset_id: string | null; + } + ) { + if (options.providerContentBase64) { + return { + buffer: this.decodeBase64(options.providerContentBase64, 'VideoProvider content_base64'), + ffmpegUsed: false, + backend: 'provider_binary' + }; + } + + if (/^https?:\/\//i.test(options.providerAssetUrl)) { + const downloaded = await this.downloadProviderAsset(options.providerAssetUrl, 'video'); + + return { + buffer: downloaded.buffer, + ffmpegUsed: false, + backend: 'provider_url' + }; + } + + if (options.preferFfmpeg && (await this.hasFfmpeg())) { + return this.createFfmpegVideoBuffer(project, episode, shots, shotImages, options); + } + + if (!options.allowMockOutput) { + throw new BadRequestException('Video render requires FFmpeg or provider video content in production mode'); + } + + return { + buffer: this.createMockMp4Buffer(project, episode, shots, shotImages, options), + ffmpegUsed: false, + backend: 'mock_ffmpeg_unavailable' + }; + } + + private async createFfmpegVideoBuffer( + project: Project, + episode: Episode, + shots: StoryboardShot[], + shotImages: ShotImage[], + options: { + providerAssetUrl: string; + includeAudio: boolean; + includeSubtitle: boolean; + audio_asset_id: string | null; + subtitle_asset_id: string | null; + } + ) { + const tempDir = await mkdtemp(join(tmpdir(), 'ai-manga-render-')); + + try { + const imageInputs = await this.writeShotImagesForFfmpeg(tempDir, shots, shotImages); + const audioPath = + options.includeAudio && options.audio_asset_id + ? await this.writeAssetForFfmpeg(tempDir, options.audio_asset_id, 'audio', 'audio') + : null; + const rawSubtitlePath = + options.includeSubtitle && options.subtitle_asset_id + ? await this.writeAssetForFfmpeg(tempDir, options.subtitle_asset_id, 'subtitle', 'subtitle') + : null; + const subtitlePath = rawSubtitlePath + ? await this.writeAssSubtitleForFfmpeg(tempDir, rawSubtitlePath) + : null; + const segmentPaths = []; + + for (let index = 0; index < imageInputs.length; index += 1) { + const input = imageInputs[index]; + const segmentPath = join(tempDir, `segment-${String(index + 1).padStart(3, '0')}.mp4`); + + await this.runFfmpeg([ + '-y', + '-hide_banner', + '-loglevel', + 'error', + '-loop', + '1', + '-t', + String(input.duration), + '-i', + input.path, + '-vf', + this.imageVideoFilter(), + '-r', + '30', + '-an', + '-c:v', + 'libx264', + '-preset', + 'veryfast', + '-crf', + '23', + '-pix_fmt', + 'yuv420p', + segmentPath + ]); + segmentPaths.push(segmentPath); + } + + const concatPath = join(tempDir, 'segments.txt'); + const videoTrackPath = join(tempDir, 'video-track.mp4'); + await writeFile( + concatPath, + `${segmentPaths.map((path) => `file '${this.escapeConcatPath(path)}'`).join('\n')}\n` + ); + await this.runFfmpeg([ + '-y', + '-hide_banner', + '-loglevel', + 'error', + '-f', + 'concat', + '-safe', + '0', + '-i', + concatPath, + '-c', + 'copy', + videoTrackPath + ]); + + const outputPath = join(tempDir, 'episode.mp4'); + const finalArgs = [ + '-y', + '-hide_banner', + '-loglevel', + 'error', + '-i', + videoTrackPath + ]; + + if (audioPath) { + finalArgs.push('-i', audioPath); + } + if (subtitlePath) { + finalArgs.push('-vf', this.subtitleFilter(subtitlePath)); + } + + finalArgs.push('-map', '0:v:0'); + if (audioPath) { + finalArgs.push('-map', '1:a:0'); + } + + finalArgs.push( + '-t', + String(this.totalShotDuration(shots)), + '-c:v', + 'libx264', + '-preset', + 'veryfast', + '-crf', + '23', + '-pix_fmt', + 'yuv420p' + ); + + if (audioPath) { + finalArgs.push( + '-af', + 'loudnorm=I=-15:TP=-1.0:LRA=11,volume=3dB', + '-c:a', + 'aac', + '-b:a', + '128k', + '-ar', + '44100', + '-ac', + '2', + '-disposition:a:0', + 'default', + '-metadata:s:a:0', + 'language=chi' + ); + } + + finalArgs.push('-movflags', '+faststart', outputPath); + await this.runFfmpeg(finalArgs); + + return { + buffer: await readFile(outputPath), + ffmpegUsed: true, + backend: 'ffmpeg' + }; + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + } + + private async createMixedAudioBuffer( + project: Project, + episode: Episode, + task: RenderTask, + segments: AudioDialogueSegment[] + ) { + const segmentFiles = await this.synthesizeAudioSegmentsWithCache(project, episode, task, segments); + + await this.storeGeneratedAudioSegmentFiles(episode, segmentFiles); + + return this.createMixedAudioOutputFromFiles(segmentFiles); + } + + private async synthesizeAudioSegmentsWithCache( + project: Project, + episode: Episode, + task: RenderTask, + segments: AudioDialogueSegment[] + ) { + const cache = await this.loadAudioSegmentCache(project); + const filesByIndex = new Map(); + const missingSegments: AudioDialogueSegment[] = []; + + for (const segment of segments) { + const cached = await this.readCachedAudioSegment(segment, cache.get(this.audioSegmentCacheKey(segment))); + + if (cached) { + filesByIndex.set(segment.index, cached); + } else { + missingSegments.push(segment); + } + } + + if (missingSegments.length > 0) { + const batch = await this.providersService.executeProviderBatch( + { + provider_type: 'VoiceProvider', + purpose: `episode-${episode.id.toString()}-tts-batch`, + project_id: project.id.toString(), + task_id: task.id.toString(), + allow_fallback: false, + return_binary: true + }, + missingSegments.map((segment) => ({ + preferred_provider_code: segment.voice_provider_code ?? undefined, + purpose: `episode-${episode.id.toString()}-tts-${segment.index}`, + input_json: this.createAudioSegmentProviderInput(segment) + })) + ); + + for (const [index, providerResult] of batch.results.entries()) { + const segment = missingSegments[index]; + const providerOutput = this.jsonObject(providerResult.result); + const providerDuration = this.normalizeDuration(Number(providerOutput.duration) || segment.text.length / 8); + const duration = providerResult.provider.mode === 'mock' + ? this.normalizeDuration(segment.target_duration) + : providerDuration; + const audioFile = await this.createGeneratedAudioFile( + providerOutput, + episode.id, + duration, + providerResult.provider.mode === 'mock' + ); + + filesByIndex.set(segment.index, { + index: segment.index, + buffer: audioFile.buffer, + mimeType: audioFile.mimetype, + duration, + isMock: audioFile.isMock, + segment, + cacheHit: false + }); + } + } + + return segments.map((segment) => { + const file = filesByIndex.get(segment.index); + + if (!file) { + throw new BadRequestException(`Audio segment ${segment.index} was not generated`); + } + + return file; + }); + } + + private async synthesizeAudioSegment( + project: Project, + episode: Episode, + task: RenderTask, + segment: AudioDialogueSegment, + purpose: string + ): Promise { + const providerResult = await this.providersService.executeProvider({ + provider_type: 'VoiceProvider', + preferred_provider_code: segment.voice_provider_code ?? undefined, + purpose, + project_id: project.id.toString(), + task_id: task.id.toString(), + allow_fallback: false, + return_binary: true, + input_json: this.createAudioSegmentProviderInput(segment) + }); + const providerOutput = this.jsonObject(providerResult.result); + const providerDuration = this.normalizeDuration(Number(providerOutput.duration) || segment.text.length / 8); + const duration = providerResult.provider.mode === 'mock' + ? this.normalizeDuration(segment.target_duration) + : providerDuration; + const audioFile = await this.createGeneratedAudioFile( + providerOutput, + episode.id, + duration, + providerResult.provider.mode === 'mock' + ); + + return { + index: segment.index, + buffer: audioFile.buffer, + mimeType: audioFile.mimetype, + duration, + isMock: audioFile.isMock, + segment, + cacheHit: false + }; + } + + private createAudioSegmentProviderInput(segment: AudioDialogueSegment): Prisma.InputJsonObject { + return { + text: segment.text, + voice: segment.voice, + voice_id: segment.voice, + speaker: segment.speaker_name, + segment_type: segment.segment_type, + target_duration: segment.target_duration, + voice_model: segment.voice_model, + instructions: this.speechInstructions(segment), + speech_speed: segment.speech_speed, + character_id: segment.character_id, + global_character_id: segment.global_character_id, + audio_cache_key: this.audioSegmentCacheKey(segment) + }; + } + + private speechInstructions(segment: AudioDialogueSegment) { + const instructions = segment.voice_style?.trim() || null; + + if (!segment.speech_speed) { + return instructions; + } + + return [instructions, `语速倍率:${segment.speech_speed.toFixed(2)}。`].filter(Boolean).join('\n'); + } + + private async createMixedAudioOutputFromFiles(segmentFiles: GeneratedAudioSegmentFile[]) { + if (segmentFiles.length === 0) { + throw new BadRequestException('Audio segments are required for mixing'); + } + + const duration = this.audioTimelineDuration(segmentFiles); + const timelineWarnings = this.buildAudioTimelineWarnings(segmentFiles); + + if ( + segmentFiles.length === 1 && + segmentFiles[0].segment.start_seconds === 0 && + Math.abs(segmentFiles[0].duration - duration) <= 0.05 + ) { + return { + buffer: segmentFiles[0].buffer, + mimeType: segmentFiles[0].mimeType, + duration, + isMock: segmentFiles[0].isMock, + segmentFiles, + timelineWarnings + }; + } + + if (await this.hasFfmpeg()) { + const mixed = await this.mixAudioSegmentsOnTimelineWithFfmpeg(segmentFiles, duration); + + return { + buffer: mixed.buffer, + mimeType: mixed.mimeType, + duration, + isMock: segmentFiles.every((file) => file.isMock), + segmentFiles, + timelineWarnings + }; + } + + if (segmentFiles.every((file) => file.isMock)) { + const wav = this.createSilentWav(duration); + + return { + buffer: wav, + mimeType: 'audio/wav', + duration, + isMock: true, + segmentFiles, + timelineWarnings + }; + } + + throw new BadRequestException('FFmpeg is required to mix multi-character TTS audio'); + } + + private async storeGeneratedAudioSegmentFiles(episode: Episode, segmentFiles: GeneratedAudioSegmentFile[]) { + for (const file of segmentFiles) { + if (file.filePath) continue; + + const stored = await this.storage.storePrivateFile( + { + originalname: `episode-${episode.id.toString()}-segment-${String(file.index).padStart(3, '0')}${this.audioExtensionFromMime(file.mimeType)}`, + mimetype: file.mimeType, + size: file.buffer.length, + buffer: file.buffer + } as Express.Multer.File, + 'generated-audio-segments' + ); + + file.filePath = stored.file_path; + file.size = stored.size; + file.hash = stored.hash; + } + } + + private async mixAudioSegmentsOnTimelineWithFfmpeg( + segmentFiles: GeneratedAudioSegmentFile[], + timelineDuration: number + ) { + const tempDir = await mkdtemp(join(tmpdir(), 'ai-manga-audio-')); + + try { + const normalizedPaths = []; + + for (const file of segmentFiles) { + const inputPath = join( + tempDir, + `input-${String(file.index).padStart(3, '0')}${this.audioExtensionFromMime(file.mimeType)}` + ); + const normalizedPath = join(tempDir, `normalized-${String(file.index).padStart(3, '0')}.wav`); + + await writeFile(inputPath, file.buffer); + await this.runFfmpeg([ + '-y', + '-hide_banner', + '-loglevel', + 'error', + '-i', + inputPath, + '-ar', + '44100', + '-ac', + '2', + normalizedPath + ]); + normalizedPaths.push(normalizedPath); + } + + const outputPath = join(tempDir, 'dialogue-mix.wav'); + const ffmpegArgs = ['-y', '-hide_banner', '-loglevel', 'error']; + const filterParts = []; + + normalizedPaths.forEach((path) => { + ffmpegArgs.push('-i', path); + }); + segmentFiles.forEach((file, index) => { + const delayMs = Math.max(0, Math.round(file.segment.start_seconds * 1000)); + + filterParts.push( + `[${index}:a]adelay=${delayMs}|${delayMs},apad,atrim=0:${timelineDuration.toFixed(3)}[a${index}]` + ); + }); + + if (segmentFiles.length === 1) { + filterParts.push( + `[a0]loudnorm=I=-16:TP=-1.5:LRA=11,atrim=0:${timelineDuration.toFixed(3)},asetpts=N/SR/TB[out]` + ); + } else { + const labels = segmentFiles.map((_, index) => `[a${index}]`).join(''); + + filterParts.push( + `${labels}amix=inputs=${segmentFiles.length}:normalize=0:duration=longest,loudnorm=I=-16:TP=-1.5:LRA=11,atrim=0:${timelineDuration.toFixed(3)},asetpts=N/SR/TB[out]` + ); + } + + ffmpegArgs.push('-filter_complex', filterParts.join(';'), '-map', '[out]', '-c:a', 'pcm_s16le', outputPath); + await this.runFfmpeg(ffmpegArgs); + + return { + buffer: await readFile(outputPath), + mimeType: 'audio/wav' + }; + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + } + + private audioTimelineDuration(segmentFiles: GeneratedAudioSegmentFile[]) { + const targetDuration = segmentFiles.reduce( + (duration, file) => Math.max(duration, file.segment.end_seconds), + 0 + ); + + return this.roundSeconds(Math.max(1, targetDuration)); + } + + private buildAudioTimelineWarnings(segmentFiles: GeneratedAudioSegmentFile[]): AudioTimelineWarning[] { + return segmentFiles + .map((file) => { + const overSeconds = this.roundSeconds(file.duration - file.segment.target_duration); + + if (overSeconds <= 0.35) return null; + + return { + index: file.segment.index, + shot_no: file.segment.shot_no, + speaker_name: file.segment.speaker_name, + text_preview: file.segment.text.slice(0, 40), + target_duration: file.segment.target_duration, + actual_duration: file.duration, + over_seconds: overSeconds + }; + }) + .filter((item): item is AudioTimelineWarning => Boolean(item)); + } + + private async createGeneratedAudioFile( + providerOutput: Record, + episodeId: bigint, + duration: number, + allowMockOutput: boolean + ) { + const contentBase64 = this.stringifyText(providerOutput.content_base64); + const mimeType = this.normalizeAudioMimeType(this.stringifyText(providerOutput.mime_type)); + + if (contentBase64) { + const buffer = this.decodeBase64(contentBase64, 'VoiceProvider content_base64'); + + return { + originalname: `episode-${episodeId.toString()}-narration${this.audioExtensionFromMime(mimeType)}`, + mimetype: mimeType, + size: buffer.length, + buffer, + isMock: false + }; + } + + const assetUrl = this.stringifyText(providerOutput.asset_url); + + if (/^https?:\/\//i.test(assetUrl)) { + const downloaded = await this.downloadProviderAsset(assetUrl, 'audio'); + const downloadedMime = this.normalizeAudioMimeType(downloaded.mimeType); + + return { + originalname: `episode-${episodeId.toString()}-narration${this.audioExtensionFromMime(downloadedMime)}`, + mimetype: downloadedMime, + size: downloaded.buffer.length, + buffer: downloaded.buffer, + isMock: false + }; + } + + if (!allowMockOutput) { + throw new BadRequestException('VoiceProvider did not return audio content or downloadable URL'); + } + + const wav = this.createSilentWav(duration); + return { + originalname: `episode-${episodeId.toString()}-narration.wav`, + mimetype: 'audio/wav', + size: wav.length, + buffer: wav, + isMock: true + }; + } + + private async downloadProviderAsset(url: string, expectedType: 'audio' | 'video') { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 60000); + + try { + const response = await fetch(url, { signal: controller.signal }); + + if (!response.ok) { + throw new BadRequestException(`Provider ${expectedType} download failed: HTTP ${response.status}`); + } + + const mimeType = response.headers.get('content-type') || ''; + const buffer = Buffer.from(await response.arrayBuffer()); + + if (!buffer.length) { + throw new BadRequestException(`Provider ${expectedType} download returned empty content`); + } + + return { buffer, mimeType }; + } catch (error) { + throw new BadRequestException(`Provider ${expectedType} download failed: ${this.toError(error).message}`); + } finally { + clearTimeout(timeout); + } + } + + private decodeBase64(value: string, label: string) { + const buffer = Buffer.from(value, 'base64'); + + if (!buffer.length) { + throw new BadRequestException(`${label} is empty`); + } + + return buffer; + } + + private normalizeAudioMimeType(value: string | undefined) { + const normalized = value?.split(';')[0]?.trim().toLowerCase(); + + if (normalized === 'audio/wav' || normalized === 'audio/x-wav') return 'audio/wav'; + if (normalized === 'audio/aac') return 'audio/aac'; + if (normalized === 'audio/flac') return 'audio/flac'; + if (normalized === 'audio/ogg' || normalized === 'audio/opus') return 'audio/ogg'; + + return 'audio/mpeg'; + } + + private audioExtensionFromMime(mimeType: string) { + switch (mimeType) { + case 'audio/wav': + return '.wav'; + case 'audio/aac': + return '.aac'; + case 'audio/flac': + return '.flac'; + case 'audio/ogg': + return '.ogg'; + case 'audio/mpeg': + default: + return '.mp3'; + } + } + + private createMockMp4Buffer( + project: Project, + episode: Episode, + shots: StoryboardShot[], + shotImages: ShotImage[], + options: { + providerAssetUrl: string; + includeAudio: boolean; + includeSubtitle: boolean; + audio_asset_id: string | null; + subtitle_asset_id: string | null; + } + ) { + const manifest = JSON.stringify( + { + mock_video: true, + container: 'mp4-placeholder', + project_id: project.id.toString(), + episode_id: episode.id.toString(), + episode_no: episode.episode_no, + title: episode.title, + width: VIDEO_WIDTH, + height: VIDEO_HEIGHT, + fps: 30, + duration: this.totalShotDuration(shots), + shot_count: shots.length, + shot_image_asset_ids: shotImages.map((image) => image.asset_id?.toString()), + provider_asset_url: options.providerAssetUrl, + include_audio: options.includeAudio, + include_subtitle: options.includeSubtitle, + audio_asset_id: options.audio_asset_id, + subtitle_asset_id: options.subtitle_asset_id + }, + null, + 2 + ); + + return Buffer.concat([ + Buffer.from('000000186674797069736f6d0000020069736f6d69736f32', 'hex'), + Buffer.from(`\n${manifest}\n`) + ]); + } + + private createSilentWav(durationSeconds: number) { + const sampleRate = 8000; + const channels = 1; + const bitsPerSample = 16; + const samples = Math.max(1, Math.ceil(durationSeconds * sampleRate)); + const dataSize = samples * channels * (bitsPerSample / 8); + const buffer = Buffer.alloc(44 + dataSize); + + buffer.write('RIFF', 0); + buffer.writeUInt32LE(36 + dataSize, 4); + buffer.write('WAVE', 8); + buffer.write('fmt ', 12); + buffer.writeUInt32LE(16, 16); + buffer.writeUInt16LE(1, 20); + buffer.writeUInt16LE(channels, 22); + buffer.writeUInt32LE(sampleRate, 24); + buffer.writeUInt32LE(sampleRate * channels * (bitsPerSample / 8), 28); + buffer.writeUInt16LE(channels * (bitsPerSample / 8), 32); + buffer.writeUInt16LE(bitsPerSample, 34); + buffer.write('data', 36); + buffer.writeUInt32LE(dataSize, 40); + + return buffer; + } + + private async createRenderTask( + projectId: bigint, + episodeId: bigint, + shotId: bigint | null, + taskType: 'audio_generate' | 'subtitle_generate' | 'video_render', + inputJson: Prisma.InputJsonObject, + operator?: AuthRequestUser + ) { + const inputHash = this.hashJson(inputJson); + + const task = await this.prisma.renderTask.create({ + data: { + project_id: projectId, + episode_id: episodeId, + shot_id: shotId, + task_type: taskType, + status: 'pending', + input_json: inputJson, + input_hash: inputHash, + idempotency_key: `${taskType}:${projectId.toString()}:${episodeId.toString()}:${inputHash}:${Date.now()}`, + retry_count: 0, + max_retry: taskType === 'video_render' ? 2 : 2 + } + }); + + if (operator) { + await this.writeProjectOperationLog(operator, projectId, `user_${taskType}`, { + task_id: task.id.toString(), + episode_id: episodeId.toString(), + shot_id: shotId?.toString() ?? null, + task_type: taskType, + input_hash: inputHash + }); + } + + return task; + } + + private async writeProjectOperationLog( + operator: AuthRequestUser, + projectId: bigint, + action: string, + metadata: Record + ) { + await this.prisma.operationLog.create({ + data: { + user_id: this.parseId(operator.id, 'Invalid user id'), + operator_role: operator.role, + action, + target_type: 'project', + target_id: projectId, + metadata_json: metadata as Prisma.InputJsonValue + } + }); + } + + private async markTaskOutput(taskId: bigint, assetId: bigint, status: 'success' | 'failed') { + return this.prisma.renderTask.update({ + where: { id: taskId }, + data: { + status, + output_asset_id: assetId, + finished_at: new Date() + } + }); + } + + private async markTaskFailed(taskId: bigint, error: unknown) { + const normalized = this.toError(error); + + await this.prisma.renderTask.update({ + where: { id: taskId }, + data: { + status: 'failed', + error_code: 'VIDEO_RENDER_FAILED', + error_message: normalized.message.slice(0, 1000), + finished_at: new Date() + } + }); + } + + private async loadEpisodeForUser(episodeId: string, user: AuthRequestUser) { + const episode = await this.prisma.episode.findUnique({ + where: { id: this.parseId(episodeId, 'Invalid episode id') } + }); + + if (!episode) { + throw new NotFoundException('Episode not found'); + } + + const project = await this.prisma.project.findUnique({ + where: { id: episode.project_id } + }); + + if (!project) { + throw new NotFoundException('Project not found'); + } + if (project.user_id.toString() !== user.id && user.role !== 'admin') { + throw new ForbiddenException('Project is private'); + } + + return { episode, project }; + } + + private async hasFfmpeg() { + try { + await execFileAsync('ffmpeg', ['-version'], { timeout: 1500 }); + return true; + } catch { + return false; + } + } + + private async writeShotImagesForFfmpeg( + tempDir: string, + shots: StoryboardShot[], + shotImages: ShotImage[] + ) { + const imageByShot = new Map(shotImages.map((image) => [image.shot_id.toString(), image])); + const inputs = []; + + for (const shot of shots) { + const image = imageByShot.get(shot.id.toString()); + + if (!image?.asset_id) { + throw new BadRequestException(`Shot ${shot.shot_no} has no generated image asset`); + } + + const asset = await this.loadAssetForFfmpeg(image.asset_id, 'image', `shot ${shot.shot_no} image`); + const path = await this.writeAssetBuffer( + tempDir, + asset, + `shot-${String(shot.shot_no).padStart(3, '0')}`, + 'image' + ); + + inputs.push({ + path, + duration: this.normalizeDuration(Number(shot.duration?.toString()) || 4) + }); + } + + return inputs; + } + + private async writeAssetForFfmpeg( + tempDir: string, + assetId: string, + expectedType: string, + label: string + ) { + const asset = await this.loadAssetForFfmpeg( + this.parseId(assetId, `Invalid ${label}_asset_id`), + expectedType, + label + ); + + return this.writeAssetBuffer(tempDir, asset, label, expectedType); + } + + private async loadAssetForFfmpeg(assetId: bigint, expectedType: string, label: string) { + const asset = await this.prisma.asset.findUnique({ + where: { id: assetId } + }); + + if (!asset) { + throw new BadRequestException(`${label} asset not found`); + } + if (asset.asset_type !== expectedType) { + throw new BadRequestException(`${label} asset must be ${expectedType}`); + } + + return asset; + } + + private async writeAssetBuffer(tempDir: string, asset: Asset, label: string, fallbackType: string) { + try { + const buffer = await this.storage.readPrivateFile(asset.file_path); + const filePath = join(tempDir, `${label}${this.extensionForFfmpeg(asset, fallbackType)}`); + + await writeFile(filePath, buffer); + return filePath; + } catch (error) { + throw new BadRequestException( + `${label} asset cannot be read for FFmpeg: ${this.toError(error).message}` + ); + } + } + + private async runFfmpeg(args: string[]) { + try { + await execFileAsync('ffmpeg', args, { + timeout: 180000, + maxBuffer: 1024 * 1024 + }); + } catch (error) { + const normalized = this.toError(error); + const stderr = typeof (error as { stderr?: unknown }).stderr === 'string' + ? (error as { stderr: string }).stderr + : ''; + const detail = (stderr || normalized.message).trim().split('\n').slice(-6).join('\n'); + + throw new BadRequestException(`FFmpeg render failed: ${detail}`); + } + } + + private imageVideoFilter() { + return [ + `scale=${VIDEO_WIDTH}:${VIDEO_HEIGHT}:force_original_aspect_ratio=decrease`, + `pad=${VIDEO_WIDTH}:${VIDEO_HEIGHT}:(ow-iw)/2:(oh-ih)/2`, + 'setsar=1', + 'format=yuv420p' + ].join(','); + } + + private subtitleFilter(subtitlePath: string) { + return [ + `subtitles=${this.escapeFfmpegFilterPath(subtitlePath)}`, + 'fontsdir=/usr/share/fonts/google-noto-cjk' + ].join(':'); + } + + private async writeAssSubtitleForFfmpeg(tempDir: string, subtitlePath: string) { + const content = await readFile(subtitlePath, 'utf8'); + const cues = this.parseSrtContent(content); + const assPath = join(tempDir, 'subtitles.ass'); + + await writeFile(assPath, this.stringifyAss(cues), 'utf8'); + return assPath; + } + + private parseSrtContent(content: string): SrtCue[] { + const blocks = content.replace(/\r/g, '').trim().split(/\n{2,}/); + + return blocks + .map((block, index) => { + const lines = block.split('\n').map((line) => line.trim()); + const timeIndex = lines.findIndex((line) => line.includes('-->')); + + if (timeIndex === -1) { + return null; + } + + const [start, end] = lines[timeIndex].split(/\s+-->\s+/); + const text = lines.slice(timeIndex + 1).filter(Boolean).join('\n'); + + return { + index: index + 1, + start, + end: end?.split(/\s+/)[0] ?? start, + text + }; + }) + .filter((cue): cue is SrtCue => Boolean(cue?.start && cue.end && cue.text)); + } + + private stringifyAss(cues: SrtCue[]) { + const events = cues + .map( + (cue) => + `Dialogue: 0,${this.srtTimeToAssTime(cue.start)},${this.srtTimeToAssTime(cue.end)},Default,,0,0,0,,${this.escapeAssDialogue(cue.text)}` + ) + .join('\n'); + + return `[Script Info] +ScriptType: v4.00+ +PlayResX: ${VIDEO_WIDTH} +PlayResY: ${VIDEO_HEIGHT} +WrapStyle: 0 +ScaledBorderAndShadow: yes + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Noto Sans CJK SC,12,&H00FFFFFF,&H000000FF,&H80000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,2,180,180,120,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +${events} +`; + } + + private srtTimeToAssTime(value: string) { + const match = /^(\d+):(\d{2}):(\d{2}),(\d{3})$/.exec(value.trim()); + + if (!match) { + return '0:00:00.00'; + } + + const [, hours, minutes, seconds, ms] = match; + return `${Number(hours)}:${minutes}:${seconds}.${ms.slice(0, 2)}`; + } + + private srtTimeToSeconds(value: string) { + const match = /^(\d+):(\d{2}):(\d{2}),(\d{3})$/.exec(value.trim()); + + if (!match) { + return 0; + } + + const [, hours, minutes, seconds, ms] = match; + return this.roundSeconds( + Number(hours) * 3600 + Number(minutes) * 60 + Number(seconds) + Number(ms) / 1000 + ); + } + + private escapeAssDialogue(text: string) { + return text + .replace(/\r/g, '') + .split('\n') + .map((line) => + line + .trim() + .replace(/\\/g, '\\\\') + .replace(/{/g, '\\{') + .replace(/}/g, '\\}') + ) + .filter(Boolean) + .join('\\N'); + } + + private extensionForFfmpeg(asset: Asset, fallbackType: string) { + switch (asset.mime_type) { + case 'image/svg+xml': + return '.svg'; + case 'image/png': + return '.png'; + case 'image/jpeg': + case 'image/jpg': + return '.jpg'; + case 'image/webp': + return '.webp'; + case 'audio/wav': + case 'audio/x-wav': + return '.wav'; + case 'audio/mpeg': + case 'audio/mp3': + return '.mp3'; + case 'application/x-subrip': + case 'text/srt': + return '.srt'; + default: + return fallbackType === 'image' + ? '.img' + : fallbackType === 'audio' + ? '.audio' + : fallbackType === 'subtitle' + ? '.srt' + : ''; + } + } + + private escapeConcatPath(path: string) { + return path.replace(/'/g, "'\\''"); + } + + private escapeFfmpegFilterPath(path: string) { + return path + .replace(/\\/g, '\\\\') + .replace(/:/g, '\\:') + .replace(/'/g, "\\'") + .replace(/,/g, '\\,'); + } + + private totalShotDuration(shots: StoryboardShot[]) { + return Number( + shots + .reduce((sum, shot) => sum + this.normalizeDuration(Number(shot.duration?.toString()) || 4), 0) + .toFixed(2) + ); + } + + private normalizeDuration(value: number) { + return Number(Math.min(600, Math.max(1, value)).toFixed(2)); + } + + private formatSrtTime(value: number) { + const totalMs = Math.round(value * 1000); + const hours = Math.floor(totalMs / 3600000); + const minutes = Math.floor((totalMs % 3600000) / 60000); + const seconds = Math.floor((totalMs % 60000) / 1000); + const ms = totalMs % 1000; + + return `${this.pad(hours)}:${this.pad(minutes)}:${this.pad(seconds)},${String(ms).padStart(3, '0')}`; + } + + private pad(value: number) { + return String(value).padStart(2, '0'); + } + + private outputAssetId(value: unknown) { + if (value && typeof value === 'object' && 'asset' in value) { + const asset = (value as { asset?: { id?: string | bigint | number } }).asset; + return asset?.id === undefined ? null : asset.id.toString(); + } + if (value && typeof value === 'object' && 'asset_id' in value) { + const assetId = (value as { asset_id?: string | bigint | number }).asset_id; + return assetId === undefined ? null : assetId.toString(); + } + + return null; + } + + private resolveAudioDialogueMode(value: unknown) { + return value === 'narration' ? 'narration' : 'mixed'; + } + + private resolveSubtitleMode(value: unknown) { + return value === 'shot' ? 'shot' : 'dialogue'; + } + + private toTaskAudioSegment(segment: AudioDialogueSegment): Prisma.InputJsonObject { + return { + index: segment.index, + segment_type: segment.segment_type, + shot_id: segment.shot_id, + shot_no: segment.shot_no, + start_seconds: segment.start_seconds, + end_seconds: segment.end_seconds, + target_duration: segment.target_duration, + speaker_name: segment.speaker_name, + voice: segment.voice, + voice_provider_code: segment.voice_provider_code, + voice_model: segment.voice_model, + voice_style: segment.voice_style, + speech_speed: segment.speech_speed, + character_id: segment.character_id, + global_character_id: segment.global_character_id, + text: segment.text, + text_preview: segment.text.slice(0, 120) + }; + } + + private toSafeAudioSegment(segment: AudioDialogueSegment) { + return { + index: segment.index, + segment_type: segment.segment_type, + shot_id: segment.shot_id, + shot_no: segment.shot_no, + start_seconds: segment.start_seconds, + end_seconds: segment.end_seconds, + target_duration: segment.target_duration, + speaker_name: segment.speaker_name, + voice: segment.voice, + voice_provider_code: segment.voice_provider_code, + voice_model: segment.voice_model, + voice_style: segment.voice_style, + speech_speed: segment.speech_speed, + character_id: segment.character_id, + global_character_id: segment.global_character_id, + text: segment.text + }; + } + + private normalizePositiveInt( + value: unknown, + field: string, + min: number, + max: number, + fallback: number + ) { + if (value === undefined || value === null || value === '') { + return fallback; + } + + const numberValue = Number(value); + + if (!Number.isInteger(numberValue) || numberValue < min || numberValue > max) { + throw new BadRequestException(`${field} must be an integer between ${min} and ${max}`); + } + + return numberValue; + } + + private parsePositiveInt(value: unknown, message: string) { + const numberValue = Number(value); + + if (!Number.isInteger(numberValue) || numberValue <= 0) { + throw new BadRequestException(message); + } + + return numberValue; + } + + private normalizeSpeechSpeed(value: unknown, fallback: number | null) { + if (value === undefined || value === null || value === '') { + return fallback; + } + + const numberValue = Number(value); + + if (!Number.isFinite(numberValue) || numberValue < 0.6 || numberValue > 1.4) { + throw new BadRequestException('speech_speed must be a number between 0.6 and 1.4'); + } + + return Number(numberValue.toFixed(2)); + } + + private parseId(value: string | bigint, message: string) { + try { + const id = BigInt(value); + + if (id <= 0n) { + throw new Error('ID must be positive'); + } + + return id; + } catch { + throw new BadRequestException(message); + } + } + + private hashJson(value: Prisma.InputJsonValue | Prisma.JsonValue | null) { + return createHash('sha256').update(this.stableStringify(value)).digest('hex'); + } + + private stableStringify(value: Prisma.InputJsonValue | Prisma.JsonValue | null): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((item) => this.stableStringify(item)).join(',')}]`; + } + + const entries = Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => `${JSON.stringify(key)}:${this.stableStringify(child)}`); + + return `{${entries.join(',')}}`; + } + + private assetDuration(asset: Asset) { + return asset.duration ? Number(asset.duration.toString()) : null; + } + + private numberFromUnknown(value: unknown) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'bigint') return Number(value); + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value); + + return Number.isFinite(parsed) ? parsed : 0; + } + + return 0; + } + + private bigintFromUnknown(value: unknown) { + if (typeof value === 'bigint' && value >= 0n) return value; + if (typeof value === 'number' && Number.isInteger(value) && value >= 0) return BigInt(value); + if (typeof value === 'string' && value.trim()) { + try { + const parsed = BigInt(value.trim()); + + return parsed >= 0n ? parsed : undefined; + } catch { + return undefined; + } + } + + return undefined; + } + + private jsonObject(value: unknown) { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + + return {}; + } + + private stringifyText(value: unknown) { + return typeof value === 'string' ? value.trim() : ''; + } + + private toError(error: unknown) { + return error instanceof Error ? error : new Error(String(error)); + } +} diff --git a/backend/src/media/media.types.ts b/backend/src/media/media.types.ts new file mode 100644 index 0000000..ab8afcb --- /dev/null +++ b/backend/src/media/media.types.ts @@ -0,0 +1,35 @@ +import type { Asset } from '@prisma/client'; +import { toSafeAsset, type SafeAsset } from '../assets/asset.types'; +import { toSafeRenderTask, type SafeRenderTask } from '../queues/task.types'; +import type { RenderTask } from '@prisma/client'; + +export interface SrtCue { + index: number; + start: string; + end: string; + text: string; + start_seconds?: number; + end_seconds?: number; + shot_id?: string | null; + shot_no?: number | null; + segment_type?: 'narration' | 'dialogue' | 'shot'; + speaker_name?: string | null; +} + +export interface SafeMediaTaskResult { + asset: SafeAsset; + task: SafeRenderTask; + reused: boolean; +} + +export function toSafeMediaTaskResult( + asset: Asset, + task: RenderTask, + reused = false +): SafeMediaTaskResult { + return { + asset: toSafeAsset(asset), + task: toSafeRenderTask(task), + reused + }; +} diff --git a/backend/src/memories/memories.controller.ts b/backend/src/memories/memories.controller.ts new file mode 100644 index 0000000..b60808f --- /dev/null +++ b/backend/src/memories/memories.controller.ts @@ -0,0 +1,124 @@ +import { + Body, + Controller, + Get, + Inject, + Param, + Patch, + Post, + Query, + UseGuards +} from '@nestjs/common'; +import { CurrentUser } from '../auth/current-user.decorator'; +import type { AuthRequestUser } from '../auth/auth.types'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { + ContinuityCheckDto, + CreatePlotMemoryDto, + CreatePlotThreadDto, + GeneratePlotMemoriesDto, + UpdatePlotMemoryDto, + UpdatePlotThreadDto +} from './memory.dto'; +import { MemoriesService } from './memories.service'; + +@Controller() +@UseGuards(JwtAuthGuard) +export class MemoriesController { + constructor(@Inject(MemoriesService) private readonly memoriesService: MemoriesService) {} + + @Get('projects/:projectId/plot-memories') + listPlotMemories( + @CurrentUser() user: AuthRequestUser, + @Param('projectId') projectId: string, + @Query('memory_type') memoryType?: string, + @Query('status') status?: string, + @Query('episode_id') episodeId?: string + ) { + return this.memoriesService.listPlotMemories(user, projectId, { + memory_type: memoryType, + status, + episode_id: episodeId + }); + } + + @Post('projects/:projectId/plot-memories/generate') + generatePlotMemories( + @CurrentUser() user: AuthRequestUser, + @Param('projectId') projectId: string, + @Body() dto: GeneratePlotMemoriesDto + ) { + return this.memoriesService.generatePlotMemories(user, projectId, dto); + } + + @Post('projects/:projectId/plot-memories') + createPlotMemory( + @CurrentUser() user: AuthRequestUser, + @Param('projectId') projectId: string, + @Body() dto: CreatePlotMemoryDto + ) { + return this.memoriesService.createPlotMemory(user, projectId, dto); + } + + @Patch('plot-memories/:memoryId') + updatePlotMemory( + @CurrentUser() user: AuthRequestUser, + @Param('memoryId') memoryId: string, + @Body() dto: UpdatePlotMemoryDto + ) { + return this.memoriesService.updatePlotMemory(user, memoryId, dto); + } + + @Get('projects/:projectId/memory-context') + getMemoryContext( + @CurrentUser() user: AuthRequestUser, + @Param('projectId') projectId: string, + @Query('episode_no') episodeNo?: string + ) { + return this.memoriesService.getMemoryContext(user, projectId, episodeNo); + } + + @Get('characters/:characterId/memories') + listCharacterMemories( + @CurrentUser() user: AuthRequestUser, + @Param('characterId') characterId: string + ) { + return this.memoriesService.listCharacterMemories(user, characterId); + } + + @Get('projects/:projectId/plot-threads') + listPlotThreads( + @CurrentUser() user: AuthRequestUser, + @Param('projectId') projectId: string, + @Query('status') status?: string + ) { + return this.memoriesService.listPlotThreads(user, projectId, status); + } + + @Post('projects/:projectId/plot-threads') + createPlotThread( + @CurrentUser() user: AuthRequestUser, + @Param('projectId') projectId: string, + @Body() dto: CreatePlotThreadDto + ) { + return this.memoriesService.createPlotThread(user, projectId, dto); + } + + @Patch('plot-threads/:threadId') + updatePlotThread( + @CurrentUser() user: AuthRequestUser, + @Param('threadId') threadId: string, + @Body() dto: UpdatePlotThreadDto + ) { + return this.memoriesService.updatePlotThread(user, threadId, dto); + } + + @Post('episodes/:episodeId/continuity-check') + runContinuityCheck( + @CurrentUser() user: AuthRequestUser, + @Param('episodeId') episodeId: string, + @Body() dto: ContinuityCheckDto + ) { + return this.memoriesService.runContinuityCheck(user, episodeId, dto); + } +} diff --git a/backend/src/memories/memories.module.ts b/backend/src/memories/memories.module.ts new file mode 100644 index 0000000..19fbbf4 --- /dev/null +++ b/backend/src/memories/memories.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { PrismaModule } from '../prisma/prisma.module'; +import { MemoriesController } from './memories.controller'; +import { MemoriesService } from './memories.service'; + +@Module({ + imports: [AuthModule, PrismaModule], + controllers: [MemoriesController], + providers: [MemoriesService], + exports: [MemoriesService] +}) +export class MemoriesModule {} diff --git a/backend/src/memories/memories.service.spec.ts b/backend/src/memories/memories.service.spec.ts new file mode 100644 index 0000000..a9f4431 --- /dev/null +++ b/backend/src/memories/memories.service.spec.ts @@ -0,0 +1,399 @@ +import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { + Character, + CharacterMemory, + ContinuityCheck, + Episode, + NovelChapter, + PlotMemory, + PlotThread, + Project, + StoryBible +} from '@prisma/client'; +import type { AuthRequestUser } from '../auth/auth.types'; +import type { PrismaService } from '../prisma/prisma.service'; +import { MemoriesService } from './memories.service'; + +const user: AuthRequestUser = { + id: '1', + email: 'user@example.com', + role: 'user' +}; + +const now = new Date('2026-05-31T00:00:00.000Z'); + +function createProject(overrides: Partial = {}): Project { + return { + id: 10n, + user_id: 1n, + title: '重生归来,我只搞事业', + input_mode: 'ai_original', + genre: 'urban_rebirth', + style_code: 'korean_comic', + output_type: 'short_video', + output_mode: 'image_manga', + visual_mode: 'korean_manga', + video_generation_level: 'standard', + target_episode_count: 5, + episode_duration: 60, + status: 'character_confirmed', + copyright_status: 'ai_original', + payment_status: 'unpaid', + quality_level: 'mvp', + is_long_series: false, + created_at: now, + updated_at: now, + completed_at: null, + ...overrides + }; +} + +function createStoryBible(overrides: Partial = {}): StoryBible { + return { + id: 40n, + project_id: 10n, + title: '重生归来,我只搞事业', + logline: '林晚重回命运转折点,用证据夺回项目。', + main_plot: '林晚夺回原创项目控制权,周启持续制造阻碍。', + core_conflict: '林晚必须在资本压力中守住原创项目。', + selling_points: '重生归来\n证据反杀', + tone: '克制、锋利、连续反转', + world_summary: '现代都市内容公司,不得突然加入超能力。', + ending_direction: '幕后真相继续推进。', + taboo_rules: '不得改变主角姓名。', + version: 1, + status: 'confirmed', + created_at: now, + updated_at: now, + ...overrides + }; +} + +function createChapter(overrides: Partial = {}): NovelChapter { + return { + id: 30n, + project_id: 10n, + novel_source_id: 20n, + chapter_no: 1, + title: '第1章 暴雨重启', + content: '林晚站在暴雨夜里醒来,决定重新夺回项目。', + summary: '林晚确认重生并整理证据。', + visual_summary: '暴雨夜,林晚醒来,手机录音亮起。', + word_count: 22, + status: 'generated', + created_at: now, + ...overrides + }; +} + +function createCharacter(overrides: Partial = {}): Character { + return { + id: 50n, + project_id: 10n, + global_character_id: null, + name: '林晚', + alias_names: [], + role_type: 'protagonist', + gender_label: '女', + age_group: '青年', + identity_desc: '故事主角', + appearance_desc: '眼神坚定', + face_desc: '精致脸型', + hair_desc: '深色中长发', + eye_desc: '深色眼睛', + body_desc: '身形修长', + costume_rules: '现代都市通勤装', + special_props: '手机、合同、录音证据', + personality_desc: '冷静克制', + speech_style: '短句明确', + relationship_desc: '与周启围绕项目控制权对抗', + character_arc: '从被动到主动', + negative_rules: '不得改名', + anchor_asset_id: null, + wardrobe_variant: null, + voice_provider_code: null, + voice_model: null, + voice_id: null, + voice_style: null, + performance_style: null, + importance_level: 100, + status: 'locked', + created_at: now, + updated_at: now, + ...overrides + }; +} + +function createPlotMemory(overrides: Partial = {}): PlotMemory { + return { + id: 60n, + project_id: 10n, + episode_id: null, + chapter_id: 30n, + memory_type: 'foreshadowing', + content: '录音证据会在后续揭开幕后真相。', + importance_level: 90, + status: 'active', + created_at: now, + ...overrides + }; +} + +function createCharacterMemory(overrides: Partial = {}): CharacterMemory { + return { + id: 70n, + project_id: 10n, + character_id: 50n, + episode_id: null, + memory_type: 'current_state', + content: '林晚当前持有录音证据。', + created_at: now, + ...overrides + }; +} + +function createPlotThread(overrides: Partial = {}): PlotThread { + return { + id: 80n, + project_id: 10n, + thread_name: '主线目标', + thread_type: 'main_plot', + description: '林晚夺回原创项目控制权。', + start_episode_no: 1, + expected_resolve_episode_no: 5, + resolved_episode_no: null, + status: 'open', + created_at: now, + updated_at: now, + ...overrides + }; +} + +function createEpisode(overrides: Partial = {}): Episode { + return { + id: 90n, + project_id: 10n, + episode_no: 2, + source_chapter_ids: [], + title: '第2集 会议反击', + summary: '林晚带着录音证据进入会议室。', + opening_hook: '录音证据被投到大屏。', + middle_conflict: '周启试图转移责任。', + ending_hook: '幕后投资人的名字第一次出现。', + target_duration: 60, + status: 'draft', + created_at: now, + updated_at: now, + ...overrides + }; +} + +function createContinuityCheck(overrides: Partial = {}): ContinuityCheck { + return { + id: 100n, + project_id: 10n, + episode_id: 90n, + check_type: 'character_name', + result_status: 'pass', + issue_text: null, + suggestion_text: null, + created_at: now, + ...overrides + }; +} + +describe('MemoriesService', () => { + let prisma: any; + let tx: any; + let service: MemoriesService; + + beforeEach(() => { + tx = { + plotMemory: { + createMany: vi.fn().mockResolvedValue({ count: 6 }) + }, + characterMemory: { + createMany: vi.fn().mockResolvedValue({ count: 8 }) + }, + plotThread: { + createMany: vi.fn().mockResolvedValue({ count: 3 }) + } + }; + let continuityId = 100n; + prisma = { + project: { + findUnique: vi.fn().mockResolvedValue(createProject()) + }, + storyBible: { + findFirst: vi.fn().mockResolvedValue(createStoryBible()) + }, + novelChapter: { + findMany: vi.fn().mockResolvedValue([ + createChapter(), + createChapter({ + id: 31n, + chapter_no: 2, + title: '第2章 会议反击', + summary: '林晚在会议上用证据反击周启。' + }) + ]), + findUnique: vi.fn().mockResolvedValue(createChapter()) + }, + character: { + findMany: vi.fn().mockResolvedValue([ + createCharacter(), + createCharacter({ + id: 51n, + name: '周启', + role_type: 'antagonist', + importance_level: 80 + }) + ]), + findUnique: vi.fn().mockResolvedValue(createCharacter()) + }, + plotMemory: { + findMany: vi.fn().mockResolvedValue([createPlotMemory()]), + findUnique: vi.fn().mockResolvedValue(createPlotMemory()), + create: vi.fn().mockResolvedValue(createPlotMemory({ memory_type: 'event' })), + update: vi.fn().mockResolvedValue(createPlotMemory({ status: 'resolved' })) + }, + characterMemory: { + findMany: vi.fn().mockResolvedValue([createCharacterMemory()]) + }, + plotThread: { + findMany: vi.fn().mockResolvedValue([]), + findUnique: vi.fn().mockResolvedValue(createPlotThread()), + create: vi.fn().mockResolvedValue(createPlotThread({ thread_name: '新伏笔线' })), + update: vi.fn().mockResolvedValue(createPlotThread({ status: 'resolved' })) + }, + episode: { + findUnique: vi.fn().mockResolvedValue(createEpisode()), + findMany: vi.fn().mockResolvedValue([ + createEpisode({ + id: 87n, + episode_no: 2, + ending_hook: '录音证据被周启抢走。' + }), + createEpisode({ + id: 88n, + episode_no: 3, + ending_hook: '林晚发现幕后投资人。' + }), + createEpisode({ + id: 89n, + episode_no: 4, + ending_hook: '顾南带来新线索。' + }) + ]) + }, + continuityCheck: { + create: vi.fn(async ({ data }: { data: Partial }) => + createContinuityCheck({ + id: continuityId++, + ...data + }) + ) + }, + $transaction: vi.fn(async (callback: (transaction: typeof tx) => unknown) => callback(tx)) + }; + service = new MemoriesService(prisma as PrismaService); + }); + + it('generates plot, character, and thread memories from confirmed bibles', async () => { + const result = await service.generatePlotMemories(user, '10', {}); + + expect(tx.plotMemory.createMany).toHaveBeenCalled(); + expect(tx.characterMemory.createMany).toHaveBeenCalled(); + expect(tx.plotThread.createMany).toHaveBeenCalled(); + expect(tx.plotMemory.createMany.mock.calls[0][0].data).toEqual( + expect.arrayContaining([ + expect.objectContaining({ memory_type: 'unresolved_conflict' }), + expect.objectContaining({ memory_type: 'foreshadowing' }), + expect.objectContaining({ memory_type: 'world_rule' }) + ]) + ); + expect(result.created_count.plot_threads).toBe(3); + expect(result.next_step).toBe('episode_plan_generate'); + }); + + it('requires locked characters before memory generation', async () => { + prisma.character.findMany.mockResolvedValue([createCharacter({ status: 'generated' })]); + + await expect(service.generatePlotMemories(user, '10', {})).rejects.toBeInstanceOf( + BadRequestException + ); + }); + + it('builds an episode 5 memory context with the previous three summaries', async () => { + prisma.plotThread.findMany.mockResolvedValue([createPlotThread()]); + + const result = await service.getMemoryContext(user, '10', '5'); + + expect(result.episode_no).toBe(5); + expect(result.previous_episodes).toHaveLength(3); + expect(result.previous_episode_ending_hook).toBe('顾南带来新线索。'); + expect(result.generation_inputs).toContain('前 3 集摘要'); + }); + + it('creates and resolves a manual plot memory', async () => { + const created = await service.createPlotMemory(user, '10', { + memory_type: 'foreshadowing', + content: '第2集出现的旧照片需要在第5集回收。', + importance_level: 80 + }); + const updated = await service.updatePlotMemory(user, '60', { status: 'resolved' }); + + expect(prisma.plotMemory.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + project_id: 10n, + memory_type: 'foreshadowing', + status: 'active' + }) + }); + expect(updated.status).toBe('resolved'); + expect(created.memory_type).toBe('event'); + }); + + it('creates and updates plot threads', async () => { + const created = await service.createPlotThread(user, '10', { + thread_name: '新伏笔线', + thread_type: 'mystery', + description: '旧照片来源需要持续推进。' + }); + const updated = await service.updatePlotThread(user, '80', { + status: 'resolved', + resolved_episode_no: 5 + }); + + expect(created.thread_name).toBe('新伏笔线'); + expect(prisma.plotThread.update).toHaveBeenCalledWith({ + where: { id: 80n }, + data: expect.objectContaining({ + status: 'resolved', + resolved_episode_no: 5 + }) + }); + expect(updated.status).toBe('resolved'); + }); + + it('detects continuity failures', async () => { + prisma.plotThread.findMany.mockResolvedValue([createPlotThread()]); + + const result = await service.runContinuityCheck(user, '90', { + script_text: '林晚拿出录音证据,却突然觉醒超能力,直接让周启认输。下一集真相出现。' + }); + + expect(result.result_status).toBe('fail'); + expect(prisma.continuityCheck.create).toHaveBeenCalled(); + expect(result.checks.some((check) => check.result_status === 'fail')).toBe(true); + }); + + it('rejects access to another user project', async () => { + prisma.project.findUnique.mockResolvedValue(createProject({ user_id: 2n })); + + await expect(service.listPlotMemories(user, '10', {})).rejects.toBeInstanceOf( + ForbiddenException + ); + }); +}); diff --git a/backend/src/memories/memories.service.ts b/backend/src/memories/memories.service.ts new file mode 100644 index 0000000..ed7d3cc --- /dev/null +++ b/backend/src/memories/memories.service.ts @@ -0,0 +1,1062 @@ +import { + BadRequestException, + ForbiddenException, + Inject, + Injectable, + NotFoundException +} from '@nestjs/common'; +import type { + Character, + CharacterMemory, + Episode, + NovelChapter, + PlotMemory, + PlotThread, + Prisma, + Project, + StoryBible +} from '@prisma/client'; +import type { AuthRequestUser } from '../auth/auth.types'; +import { toSafeCharacter } from '../characters/character.types'; +import { PrismaService } from '../prisma/prisma.service'; +import { + ContinuityCheckDto, + CreatePlotMemoryDto, + CreatePlotThreadDto, + GeneratePlotMemoriesDto, + UpdatePlotMemoryDto, + UpdatePlotThreadDto +} from './memory.dto'; +import { + PLOT_MEMORY_STATUSES, + PLOT_MEMORY_TYPES, + PLOT_THREAD_STATUSES, + PLOT_THREAD_TYPES, + toSafeCharacterMemory, + toSafeContinuityCheck, + toSafePlotMemory, + toSafePlotThread, + type CharacterMemoryType, + type ContinuityResult, + type PlotMemoryStatus, + type PlotMemoryType, + type PlotThreadStatus, + type PlotThreadType +} from './memory.types'; + +interface PlotMemoryDraft { + episode_id: bigint | null; + chapter_id: bigint | null; + memory_type: PlotMemoryType; + content: string; + importance_level: number; + status: PlotMemoryStatus; +} + +interface CharacterMemoryDraft { + project_id: bigint; + character_id: bigint; + episode_id: bigint | null; + memory_type: CharacterMemoryType; + content: string; +} + +interface PlotThreadDraft { + project_id: bigint; + thread_name: string; + thread_type: PlotThreadType; + description: string | null; + start_episode_no: number | null; + expected_resolve_episode_no: number | null; + resolved_episode_no: number | null; + status: PlotThreadStatus; +} + +interface ContinuityFinding { + check_type: string; + result_status: ContinuityResult; + issue_text: string | null; + suggestion_text: string | null; +} + +interface MemoryContext { + storyBible: StoryBible | null; + characters: Character[]; + plotMemories: PlotMemory[]; + plotThreads: PlotThread[]; + previousEpisodes: Episode[]; +} + +@Injectable() +export class MemoriesService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async generatePlotMemories( + user: AuthRequestUser, + projectId: string, + dto: GeneratePlotMemoriesDto + ) { + const project = await this.findProjectForUser(projectId, user); + const storyBible = await this.findConfirmedStoryBible(project.id); + + if (!storyBible) { + throw new BadRequestException('Confirmed story bible is required before memory generation'); + } + + const episode = dto.episode_id + ? await this.findEpisodeInProject(dto.episode_id, project.id) + : null; + const selectedChapter = dto.chapter_id + ? await this.findChapterInProject(dto.chapter_id, project.id) + : null; + const chapters = selectedChapter + ? [selectedChapter] + : await this.prisma.novelChapter.findMany({ + where: { project_id: project.id }, + orderBy: { chapter_no: 'asc' } + }); + + if (chapters.length === 0 && !episode) { + throw new BadRequestException('Novel chapters or an episode are required before memory generation'); + } + + const characters = await this.prisma.character.findMany({ + where: { + project_id: project.id, + status: { not: 'deleted' } + }, + orderBy: [{ importance_level: 'desc' }, { id: 'asc' }] + }); + const lockedCharacters = characters.filter((character) => character.status === 'locked'); + + if (lockedCharacters.length === 0) { + throw new BadRequestException('Confirmed characters are required before memory generation'); + } + + const [existingMemories, existingCharacterMemories, existingThreads] = await Promise.all([ + this.prisma.plotMemory.findMany({ + where: { project_id: project.id, status: 'active' } + }), + this.prisma.characterMemory.findMany({ + where: { + project_id: project.id, + episode_id: episode?.id ?? null + } + }), + this.prisma.plotThread.findMany({ + where: { project_id: project.id } + }) + ]); + + const plotDrafts = this.dedupePlotMemoryDrafts( + this.buildPlotMemoryDrafts(project, storyBible, chapters, lockedCharacters, episode), + existingMemories + ); + const characterDrafts = this.dedupeCharacterMemoryDrafts( + this.buildCharacterMemoryDrafts(project.id, lockedCharacters, episode?.id ?? null), + existingCharacterMemories + ); + const threadDrafts = + existingThreads.length === 0 + ? this.buildDefaultThreadDrafts(project, storyBible, lockedCharacters) + : []; + + await this.prisma.$transaction(async (tx) => { + if (plotDrafts.length > 0) { + await tx.plotMemory.createMany({ + data: plotDrafts.map((draft) => ({ + project_id: project.id, + ...draft + })) + }); + } + + if (characterDrafts.length > 0) { + await tx.characterMemory.createMany({ + data: characterDrafts + }); + } + + if (threadDrafts.length > 0) { + await tx.plotThread.createMany({ + data: threadDrafts + }); + } + }); + + const [plotMemories, characterMemories, plotThreads] = await Promise.all([ + this.prisma.plotMemory.findMany({ + where: { project_id: project.id, status: { not: 'archived' } }, + orderBy: [{ importance_level: 'desc' }, { id: 'asc' }] + }), + this.prisma.characterMemory.findMany({ + where: { project_id: project.id }, + orderBy: { created_at: 'desc' } + }), + this.prisma.plotThread.findMany({ + where: { project_id: project.id }, + orderBy: [{ status: 'asc' }, { id: 'asc' }] + }) + ]); + + return { + plot_memories: plotMemories.map(toSafePlotMemory), + character_memories: characterMemories.map(toSafeCharacterMemory), + plot_threads: plotThreads.map(toSafePlotThread), + created_count: { + plot_memories: plotDrafts.length, + character_memories: characterDrafts.length, + plot_threads: threadDrafts.length + }, + next_step: 'episode_plan_generate' + }; + } + + async listPlotMemories( + user: AuthRequestUser, + projectId: string, + filters: { memory_type?: string; status?: string; episode_id?: string } + ) { + const project = await this.findProjectForUser(projectId, user); + const where: Prisma.PlotMemoryWhereInput = { project_id: project.id }; + + if (filters.memory_type) { + where.memory_type = this.validatePlotMemoryType(filters.memory_type); + } + if (filters.status) { + where.status = this.validatePlotMemoryStatus(filters.status); + } + if (filters.episode_id) { + where.episode_id = this.parseId(filters.episode_id, 'Invalid episode id'); + } + + const memories = await this.prisma.plotMemory.findMany({ + where, + orderBy: [{ importance_level: 'desc' }, { id: 'asc' }] + }); + + return memories.map(toSafePlotMemory); + } + + async createPlotMemory(user: AuthRequestUser, projectId: string, dto: CreatePlotMemoryDto) { + const project = await this.findProjectForUser(projectId, user); + const episodeId = dto.episode_id + ? (await this.findEpisodeInProject(dto.episode_id, project.id)).id + : null; + const chapterId = dto.chapter_id + ? (await this.findChapterInProject(dto.chapter_id, project.id)).id + : null; + + const created = await this.prisma.plotMemory.create({ + data: { + project_id: project.id, + episode_id: episodeId, + chapter_id: chapterId, + memory_type: this.validatePlotMemoryType(dto.memory_type ?? 'event'), + content: this.requiredText(dto.content, 'content is required'), + importance_level: this.validateImportance(dto.importance_level ?? 50), + status: this.validatePlotMemoryStatus(dto.status ?? 'active') + } + }); + + return toSafePlotMemory(created); + } + + async updatePlotMemory(user: AuthRequestUser, memoryId: string, dto: UpdatePlotMemoryDto) { + const memory = await this.findPlotMemoryForUser(memoryId, user); + const data: Prisma.PlotMemoryUncheckedUpdateInput = {}; + + if ('memory_type' in dto) data.memory_type = this.validatePlotMemoryType(dto.memory_type); + if ('content' in dto) data.content = this.requiredText(dto.content, 'content is required'); + if ('importance_level' in dto) { + data.importance_level = this.validateImportance(dto.importance_level); + } + if ('status' in dto) data.status = this.validatePlotMemoryStatus(dto.status); + + if (Object.keys(data).length === 0) { + throw new BadRequestException('No plot memory fields to update'); + } + + const updated = await this.prisma.plotMemory.update({ + where: { id: memory.id }, + data + }); + + return toSafePlotMemory(updated); + } + + async listCharacterMemories(user: AuthRequestUser, characterId: string) { + const character = await this.findCharacterForUser(characterId, user); + const memories = await this.prisma.characterMemory.findMany({ + where: { project_id: character.project_id, character_id: character.id }, + orderBy: { created_at: 'desc' } + }); + + return memories.map(toSafeCharacterMemory); + } + + async getMemoryContext(user: AuthRequestUser, projectId: string, episodeNoValue?: string) { + const project = await this.findProjectForUser(projectId, user); + const episodeNo = episodeNoValue + ? this.validatePositiveInt(episodeNoValue, 'episode_no', 1, 1000) + : 1; + const context = await this.loadMemoryContext(project.id, episodeNo); + const previousEpisode = context.previousEpisodes.at(-1) ?? null; + + return { + episode_no: episodeNo, + story_bible: context.storyBible + ? { + id: context.storyBible.id.toString(), + version: context.storyBible.version, + title: context.storyBible.title, + logline: context.storyBible.logline, + main_plot: context.storyBible.main_plot, + world_summary: context.storyBible.world_summary, + taboo_rules: context.storyBible.taboo_rules + } + : null, + characters: context.characters.map(toSafeCharacter), + previous_episodes: context.previousEpisodes.map((episode) => ({ + id: episode.id.toString(), + episode_no: episode.episode_no, + title: episode.title, + summary: episode.summary, + ending_hook: episode.ending_hook + })), + previous_episode_ending_hook: previousEpisode?.ending_hook ?? null, + plot_memories: context.plotMemories.map(toSafePlotMemory), + plot_threads: context.plotThreads.map(toSafePlotThread), + generation_inputs: this.buildGenerationInputNames(context, episodeNo) + }; + } + + async listPlotThreads(user: AuthRequestUser, projectId: string, status?: string) { + const project = await this.findProjectForUser(projectId, user); + const threads = await this.prisma.plotThread.findMany({ + where: { + project_id: project.id, + ...(status ? { status: this.validatePlotThreadStatus(status) } : {}) + }, + orderBy: [{ status: 'asc' }, { id: 'asc' }] + }); + + return threads.map(toSafePlotThread); + } + + async createPlotThread(user: AuthRequestUser, projectId: string, dto: CreatePlotThreadDto) { + const project = await this.findProjectForUser(projectId, user); + const created = await this.prisma.plotThread.create({ + data: { + project_id: project.id, + thread_name: this.requiredText(dto.thread_name, 'thread_name is required'), + thread_type: this.validatePlotThreadType(dto.thread_type ?? 'main_plot'), + description: this.optionalText(dto.description), + start_episode_no: this.optionalEpisodeNo(dto.start_episode_no), + expected_resolve_episode_no: this.optionalEpisodeNo(dto.expected_resolve_episode_no), + resolved_episode_no: this.optionalEpisodeNo(dto.resolved_episode_no), + status: this.validatePlotThreadStatus(dto.status ?? 'open') + } + }); + + return toSafePlotThread(created); + } + + async updatePlotThread(user: AuthRequestUser, threadId: string, dto: UpdatePlotThreadDto) { + const thread = await this.findPlotThreadForUser(threadId, user); + const data: Prisma.PlotThreadUncheckedUpdateInput = {}; + + if ('thread_name' in dto) data.thread_name = this.requiredText(dto.thread_name, 'thread_name is required'); + if ('thread_type' in dto) data.thread_type = this.validatePlotThreadType(dto.thread_type); + if ('description' in dto) data.description = this.optionalText(dto.description); + if ('start_episode_no' in dto) data.start_episode_no = this.optionalEpisodeNo(dto.start_episode_no); + if ('expected_resolve_episode_no' in dto) { + data.expected_resolve_episode_no = this.optionalEpisodeNo(dto.expected_resolve_episode_no); + } + if ('resolved_episode_no' in dto) { + data.resolved_episode_no = this.optionalEpisodeNo(dto.resolved_episode_no); + } + if ('status' in dto) data.status = this.validatePlotThreadStatus(dto.status); + + if (Object.keys(data).length === 0) { + throw new BadRequestException('No plot thread fields to update'); + } + + const updated = await this.prisma.plotThread.update({ + where: { id: thread.id }, + data + }); + + return toSafePlotThread(updated); + } + + async runContinuityCheck(user: AuthRequestUser, episodeId: string, dto: ContinuityCheckDto) { + const episode = await this.findEpisodeById(episodeId); + await this.findProjectForUser(episode.project_id.toString(), user); + const scriptText = this.buildContinuityText(episode, dto.script_text); + + if (!scriptText) { + throw new BadRequestException('script_text or episode summary fields are required'); + } + + const context = await this.loadMemoryContext(episode.project_id, episode.episode_no); + const findings = this.buildContinuityFindings(episode, scriptText, context, dto.check_type); + const checks = []; + + for (const finding of findings) { + checks.push( + await this.prisma.continuityCheck.create({ + data: { + project_id: episode.project_id, + episode_id: episode.id, + ...finding + } + }) + ); + } + + return { + result_status: this.aggregateContinuityResult(findings), + checks: checks.map(toSafeContinuityCheck), + memory_context: { + checked_character_count: context.characters.length, + active_plot_memory_count: context.plotMemories.length, + open_thread_count: context.plotThreads.filter((thread) => + ['open', 'progressing'].includes(thread.status) + ).length, + previous_episode_count: context.previousEpisodes.length + }, + next_step: + this.aggregateContinuityResult(findings) === 'fail' + ? 'manual_revision' + : 'storyboard_or_script_confirm' + }; + } + + private buildPlotMemoryDrafts( + project: Project, + storyBible: StoryBible, + chapters: NovelChapter[], + characters: Character[], + episode: Episode | null + ): PlotMemoryDraft[] { + const protagonist = characters.find((character) => + ['protagonist', 'lead'].includes(character.role_type) + ) ?? characters[0]; + const antagonist = characters.find((character) => character.role_type === 'antagonist'); + const selectedChapters = chapters.slice(0, 8); + const lastChapter = chapters.at(-1); + const drafts: PlotMemoryDraft[] = []; + + if (episode) { + drafts.push({ + episode_id: episode.id, + chapter_id: null, + memory_type: 'episode_summary', + content: `第${episode.episode_no}集:${episode.summary || episode.title || '待补充分集摘要'}。开场:${episode.opening_hook || '待补充'};结尾:${episode.ending_hook || '待补充'}`, + importance_level: 90, + status: 'active' + }); + } + + for (const chapter of selectedChapters) { + drafts.push({ + episode_id: episode?.id ?? null, + chapter_id: chapter.id, + memory_type: 'event', + content: `第${chapter.chapter_no}章${chapter.title ? `《${chapter.title}》` : ''}:${chapter.summary || this.compact(chapter.content).slice(0, 160)}`, + importance_level: 70, + status: 'active' + }); + } + + drafts.push( + { + episode_id: episode?.id ?? null, + chapter_id: null, + memory_type: 'unresolved_conflict', + content: storyBible.core_conflict || `${protagonist.name}仍需要解决主线冲突。`, + importance_level: 95, + status: 'active' + }, + { + episode_id: episode?.id ?? null, + chapter_id: null, + memory_type: 'foreshadowing', + content: storyBible.ending_direction || lastChapter?.summary || '幕后真相与下一阶段反击仍需持续铺垫。', + importance_level: 88, + status: 'active' + }, + { + episode_id: episode?.id ?? null, + chapter_id: null, + memory_type: 'world_rule', + content: storyBible.world_summary || '世界规则沿用已确认故事圣经,禁止无铺垫跨题材设定。', + importance_level: 85, + status: 'active' + }, + { + episode_id: episode?.id ?? null, + chapter_id: null, + memory_type: 'relationship_change', + content: `${protagonist.name}与${antagonist?.name ?? '主要对手'}围绕核心目标保持对抗关系,任何关系转变都必须有明确铺垫。`, + importance_level: 75, + status: 'active' + }, + { + episode_id: episode?.id ?? null, + chapter_id: null, + memory_type: 'prop_state', + content: `${protagonist.name}的重要道具/证据:${protagonist.special_props || '手机、合同、录音或关键证据文件'},不得无因消失或改变归属。`, + importance_level: 72, + status: 'active' + }, + { + episode_id: episode?.id ?? null, + chapter_id: null, + memory_type: 'next_hook', + content: episode?.ending_hook || lastChapter?.summary || storyBible.ending_direction || '下一集需要承接上一集结尾钩子。', + importance_level: 82, + status: 'active' + } + ); + + return drafts.filter((draft) => draft.content.trim().length > 0); + } + + private buildCharacterMemoryDrafts( + projectId: bigint, + characters: Character[], + episodeId: bigint | null + ): CharacterMemoryDraft[] { + return characters.flatMap((character) => [ + { + project_id: projectId, + character_id: character.id, + episode_id: episodeId, + memory_type: 'current_state', + content: `${character.name}当前身份:${character.identity_desc || '待补充'};性格:${character.personality_desc || '待补充'}。` + }, + { + project_id: projectId, + character_id: character.id, + episode_id: episodeId, + memory_type: 'relationship', + content: character.relationship_desc || `${character.name}的人物关系以后续确认记录为准。` + }, + { + project_id: projectId, + character_id: character.id, + episode_id: episodeId, + memory_type: 'visual_rule', + content: `${character.name}外观规则:${character.appearance_desc || '保持角色圣经外观'};服装:${character.costume_rules || '保持角色圣经服装规则'}。` + }, + { + project_id: projectId, + character_id: character.id, + episode_id: episodeId, + memory_type: 'growth', + content: character.character_arc || `${character.name}的成长线以后续分集沉淀为准。` + } + ]); + } + + private buildDefaultThreadDrafts( + project: Project, + storyBible: StoryBible, + characters: Character[] + ): PlotThreadDraft[] { + const protagonist = characters.find((character) => + ['protagonist', 'lead'].includes(character.role_type) + ) ?? characters[0]; + const antagonist = characters.find((character) => character.role_type === 'antagonist'); + const expectedResolve = Math.max(1, Math.min(project.target_episode_count ?? 3, 20)); + + return [ + { + project_id: project.id, + thread_name: '主线目标', + thread_type: 'main_plot', + description: storyBible.main_plot || `${protagonist.name}推进核心目标并完成阶段性反击。`, + start_episode_no: 1, + expected_resolve_episode_no: expectedResolve, + resolved_episode_no: null, + status: 'open' + }, + { + project_id: project.id, + thread_name: '反派计划', + thread_type: 'villain_plan', + description: `${antagonist?.name ?? '主要对手'}围绕利益目标持续制造阻碍,需要分集推进并逐步暴露破绽。`, + start_episode_no: 1, + expected_resolve_episode_no: expectedResolve, + resolved_episode_no: null, + status: 'open' + }, + { + project_id: project.id, + thread_name: '角色成长线', + thread_type: 'character_growth', + description: protagonist.character_arc || `${protagonist.name}从被动防守转向主动掌控局面。`, + start_episode_no: 1, + expected_resolve_episode_no: expectedResolve, + resolved_episode_no: null, + status: 'progressing' + } + ]; + } + + private buildContinuityFindings( + episode: Episode, + scriptText: string, + context: MemoryContext, + checkType?: string + ): ContinuityFinding[] { + const characters = context.characters; + const protagonist = characters.find((character) => + ['protagonist', 'lead'].includes(character.role_type) + ); + const activeThreads = context.plotThreads.filter((thread) => + ['open', 'progressing'].includes(thread.status) + ); + const previousEpisode = context.previousEpisodes.at(-1); + const findings: ContinuityFinding[] = []; + + const mentionsKnownCharacter = + characters.length === 0 || characters.some((character) => scriptText.includes(character.name)); + findings.push({ + check_type: checkType || 'character_name', + result_status: mentionsKnownCharacter ? 'pass' : 'warning', + issue_text: mentionsKnownCharacter ? null : '本集文本没有出现已锁定角色姓名。', + suggestion_text: mentionsKnownCharacter + ? null + : `至少承接一个已锁定角色,例如 ${protagonist?.name ?? characters[0]?.name ?? '主角'}。` + }); + + const forbidden = this.findForbiddenContinuityTerm(scriptText); + findings.push({ + check_type: 'world_rule', + result_status: forbidden ? 'fail' : 'pass', + issue_text: forbidden ? `发现可能破坏世界规则的内容:${forbidden}` : null, + suggestion_text: forbidden ? '删除无铺垫跨题材设定,回到已确认故事圣经和世界规则。' : null + }); + + const foreshadowing = context.plotMemories.find( + (memory) => memory.memory_type === 'foreshadowing' && memory.status === 'active' + ); + const touchesForeshadowing = + !foreshadowing || /伏笔|线索|证据|真相|回收|幕后/.test(scriptText); + findings.push({ + check_type: 'foreshadowing', + result_status: touchesForeshadowing ? 'pass' : 'warning', + issue_text: touchesForeshadowing ? null : '本集未体现已有伏笔或线索推进。', + suggestion_text: touchesForeshadowing + ? null + : `补入伏笔推进:${this.compact(foreshadowing?.content ?? '').slice(0, 80)}` + }); + + const carriesPreviousHook = + !previousEpisode?.ending_hook || this.sharesKeyword(scriptText, previousEpisode.ending_hook); + findings.push({ + check_type: 'previous_hook', + result_status: carriesPreviousHook ? 'pass' : 'warning', + issue_text: carriesPreviousHook ? null : '本集没有明显承接上一集结尾钩子。', + suggestion_text: carriesPreviousHook + ? null + : `需要承接上一集钩子:${previousEpisode?.ending_hook ?? ''}` + }); + + const hasEndingHook = + Boolean(episode.ending_hook?.trim()) || /下一集|真相|幕后|反转|出现|敲门|电话/.test(scriptText); + findings.push({ + check_type: 'ending_hook', + result_status: hasEndingHook ? 'pass' : 'warning', + issue_text: hasEndingHook ? null : '本集缺少明确结尾钩子。', + suggestion_text: hasEndingHook ? null : '补充一个可在下一集承接的悬念或反转。' + }); + + const touchesThread = + activeThreads.length === 0 || + activeThreads.some((thread) => + [thread.thread_name, thread.description ?? ''].some((text) => + this.sharesKeyword(scriptText, text) + ) + ); + findings.push({ + check_type: 'plot_thread', + result_status: touchesThread ? 'pass' : 'warning', + issue_text: touchesThread ? null : '本集没有明显推进开放剧情线。', + suggestion_text: touchesThread + ? null + : `至少推进一条剧情线:${activeThreads[0]?.thread_name ?? '主线目标'}。` + }); + + return findings; + } + + private async loadMemoryContext(projectId: bigint, episodeNo: number): Promise { + const [storyBible, characters, plotMemories, plotThreads, previousEpisodes] = + await Promise.all([ + this.findConfirmedStoryBible(projectId), + this.prisma.character.findMany({ + where: { + project_id: projectId, + status: 'locked' + }, + orderBy: [{ importance_level: 'desc' }, { id: 'asc' }] + }), + this.prisma.plotMemory.findMany({ + where: { + project_id: projectId, + status: 'active' + }, + orderBy: [{ importance_level: 'desc' }, { id: 'asc' }], + take: 50 + }), + this.prisma.plotThread.findMany({ + where: { + project_id: projectId, + status: { in: ['open', 'progressing', 'paused'] } + }, + orderBy: [{ status: 'asc' }, { id: 'asc' }] + }), + this.prisma.episode.findMany({ + where: { + project_id: projectId, + episode_no: { + gte: Math.max(1, episodeNo - 3), + lt: episodeNo + } + }, + orderBy: { episode_no: 'asc' } + }) + ]); + + return { + storyBible, + characters, + plotMemories, + plotThreads, + previousEpisodes + }; + } + + private buildGenerationInputNames(context: MemoryContext, episodeNo: number) { + const names = [ + '故事圣经', + '主要角色圣经', + '当前相关角色状态', + '活跃剧情记忆', + '未解决剧情线', + '需要推进的伏笔', + '本集禁用事项' + ]; + + if (episodeNo > 1) { + names.push('前 3 集摘要', '第 N-1 集结尾'); + } + if (context.storyBible?.world_summary) { + names.push('世界观规则'); + } + + return names; + } + + private dedupePlotMemoryDrafts(drafts: PlotMemoryDraft[], existing: PlotMemory[]) { + const existingKeys = new Set( + existing.map((memory) => + [ + memory.memory_type, + memory.content, + memory.episode_id?.toString() ?? '', + memory.chapter_id?.toString() ?? '' + ].join('|') + ) + ); + + return drafts.filter((draft) => { + const key = [ + draft.memory_type, + draft.content, + draft.episode_id?.toString() ?? '', + draft.chapter_id?.toString() ?? '' + ].join('|'); + return !existingKeys.has(key); + }); + } + + private dedupeCharacterMemoryDrafts( + drafts: CharacterMemoryDraft[], + existing: CharacterMemory[] + ) { + const existingKeys = new Set( + existing.map((memory) => + [ + memory.character_id.toString(), + memory.memory_type, + memory.content, + memory.episode_id?.toString() ?? '' + ].join('|') + ) + ); + + return drafts.filter((draft) => { + const key = [ + draft.character_id.toString(), + draft.memory_type, + draft.content, + draft.episode_id?.toString() ?? '' + ].join('|'); + return !existingKeys.has(key); + }); + } + + private aggregateContinuityResult(findings: ContinuityFinding[]): ContinuityResult { + if (findings.some((finding) => finding.result_status === 'fail')) { + return 'fail'; + } + if (findings.some((finding) => finding.result_status === 'warning')) { + return 'warning'; + } + return 'pass'; + } + + private buildContinuityText(episode: Episode, scriptText?: string) { + return this.compact( + [ + scriptText, + episode.title, + episode.summary, + episode.opening_hook, + episode.middle_conflict, + episode.ending_hook + ] + .filter(Boolean) + .join('\n') + ); + } + + private findForbiddenContinuityTerm(scriptText: string) { + return [ + '突然觉醒超能力', + '外星血统', + '无因改名', + '脸型大变', + '年龄大变', + '无因失忆', + '无因消失' + ].find((term) => scriptText.includes(term)); + } + + private sharesKeyword(target: string, source: string) { + const keywords = this.extractKeywords(source); + return keywords.some((keyword) => target.includes(keyword)); + } + + private extractKeywords(text: string) { + const compacted = this.compact(text); + const matches = compacted.match(/[\u4e00-\u9fa5]{2,8}/g) ?? []; + const stopWords = new Set([ + '本集', + '下一集', + '主要', + '目标', + '需要', + '继续', + '推进', + '角色', + '剧情' + ]); + + return matches + .flatMap((match) => (match.length > 4 ? [match.slice(0, 4), match.slice(-4)] : [match])) + .filter((match) => match.length >= 2 && !stopWords.has(match)) + .slice(0, 20); + } + + private async findProjectForUser(projectId: string, user: AuthRequestUser) { + const project = await this.prisma.project.findUnique({ + where: { id: this.parseId(projectId, 'Invalid project id') } + }); + + if (!project) { + throw new NotFoundException('Project not found'); + } + + if (project.user_id.toString() !== user.id && user.role !== 'admin') { + throw new ForbiddenException('Project is private'); + } + + return project; + } + + private async findConfirmedStoryBible(projectId: bigint) { + return this.prisma.storyBible.findFirst({ + where: { + project_id: projectId, + status: 'confirmed' + }, + orderBy: { version: 'desc' } + }); + } + + private async findEpisodeById(episodeId: string) { + const episode = await this.prisma.episode.findUnique({ + where: { id: this.parseId(episodeId, 'Invalid episode id') } + }); + + if (!episode) { + throw new NotFoundException('Episode not found'); + } + + return episode; + } + + private async findEpisodeInProject(episodeId: string, projectId: bigint) { + const episode = await this.findEpisodeById(episodeId); + + if (episode.project_id !== projectId) { + throw new NotFoundException('Episode not found'); + } + + return episode; + } + + private async findChapterInProject(chapterId: string, projectId: bigint) { + const chapter = await this.prisma.novelChapter.findUnique({ + where: { id: this.parseId(chapterId, 'Invalid chapter id') } + }); + + if (!chapter || chapter.project_id !== projectId) { + throw new NotFoundException('Novel chapter not found'); + } + + return chapter; + } + + private async findCharacterForUser(characterId: string, user: AuthRequestUser) { + const character = await this.prisma.character.findUnique({ + where: { id: this.parseId(characterId, 'Invalid character id') } + }); + + if (!character || character.status === 'deleted') { + throw new NotFoundException('Character not found'); + } + + await this.findProjectForUser(character.project_id.toString(), user); + return character; + } + + private async findPlotMemoryForUser(memoryId: string, user: AuthRequestUser) { + const memory = await this.prisma.plotMemory.findUnique({ + where: { id: this.parseId(memoryId, 'Invalid plot memory id') } + }); + + if (!memory) { + throw new NotFoundException('Plot memory not found'); + } + + await this.findProjectForUser(memory.project_id.toString(), user); + return memory; + } + + private async findPlotThreadForUser(threadId: string, user: AuthRequestUser) { + const thread = await this.prisma.plotThread.findUnique({ + where: { id: this.parseId(threadId, 'Invalid plot thread id') } + }); + + if (!thread) { + throw new NotFoundException('Plot thread not found'); + } + + await this.findProjectForUser(thread.project_id.toString(), user); + return thread; + } + + private validatePlotMemoryType(value: string | undefined): PlotMemoryType { + if (!value || !PLOT_MEMORY_TYPES.includes(value as never)) { + throw new BadRequestException('memory_type is invalid'); + } + + return value as PlotMemoryType; + } + + private validatePlotMemoryStatus(value: string | undefined): PlotMemoryStatus { + if (!value || !PLOT_MEMORY_STATUSES.includes(value as never)) { + throw new BadRequestException('memory status is invalid'); + } + + return value as PlotMemoryStatus; + } + + private validatePlotThreadType(value: string | undefined): PlotThreadType { + if (!value || !PLOT_THREAD_TYPES.includes(value as never)) { + throw new BadRequestException('thread_type is invalid'); + } + + return value as PlotThreadType; + } + + private validatePlotThreadStatus(value: string | undefined): PlotThreadStatus { + if (!value || !PLOT_THREAD_STATUSES.includes(value as never)) { + throw new BadRequestException('thread status is invalid'); + } + + return value as PlotThreadStatus; + } + + private validateImportance(value: unknown) { + const numberValue = Number(value); + + if (!Number.isInteger(numberValue) || numberValue < 0 || numberValue > 100) { + throw new BadRequestException('importance_level must be an integer between 0 and 100'); + } + + return numberValue; + } + + private optionalEpisodeNo(value: number | undefined) { + if (value === undefined || value === null) { + return null; + } + + return this.validatePositiveInt(value, 'episode_no', 1, 1000); + } + + private validatePositiveInt(value: unknown, field: string, min: number, max: number) { + const numberValue = Number(value); + + if (!Number.isInteger(numberValue) || numberValue < min || numberValue > max) { + throw new BadRequestException(`${field} must be an integer between ${min} and ${max}`); + } + + return numberValue; + } + + private requiredText(value: string | undefined, message: string) { + const normalized = value?.trim(); + + if (!normalized) { + throw new BadRequestException(message); + } + + return normalized; + } + + private optionalText(value: string | undefined) { + const normalized = value?.trim(); + return normalized || null; + } + + private compact(value: string) { + return value.replace(/\s+/g, ' ').trim(); + } + + private parseId(id: string, message: string) { + try { + return BigInt(id); + } catch { + throw new BadRequestException(message); + } + } +} diff --git a/backend/src/memories/memory.dto.ts b/backend/src/memories/memory.dto.ts new file mode 100644 index 0000000..89d1cae --- /dev/null +++ b/backend/src/memories/memory.dto.ts @@ -0,0 +1,52 @@ +import type { + PlotMemoryStatus, + PlotMemoryType, + PlotThreadStatus, + PlotThreadType +} from './memory.types'; + +export class GeneratePlotMemoriesDto { + episode_id?: string; + chapter_id?: string; +} + +export class CreatePlotMemoryDto { + episode_id?: string; + chapter_id?: string; + memory_type?: PlotMemoryType; + content?: string; + importance_level?: number; + status?: PlotMemoryStatus; +} + +export class UpdatePlotMemoryDto { + memory_type?: PlotMemoryType; + content?: string; + importance_level?: number; + status?: PlotMemoryStatus; +} + +export class CreatePlotThreadDto { + thread_name?: string; + thread_type?: PlotThreadType; + description?: string; + start_episode_no?: number; + expected_resolve_episode_no?: number; + resolved_episode_no?: number; + status?: PlotThreadStatus; +} + +export class UpdatePlotThreadDto { + thread_name?: string; + thread_type?: PlotThreadType; + description?: string; + start_episode_no?: number; + expected_resolve_episode_no?: number; + resolved_episode_no?: number; + status?: PlotThreadStatus; +} + +export class ContinuityCheckDto { + check_type?: string; + script_text?: string; +} diff --git a/backend/src/memories/memory.types.ts b/backend/src/memories/memory.types.ts new file mode 100644 index 0000000..d9d889a --- /dev/null +++ b/backend/src/memories/memory.types.ts @@ -0,0 +1,153 @@ +import type { CharacterMemory, ContinuityCheck, PlotMemory, PlotThread } from '@prisma/client'; + +export const PLOT_MEMORY_TYPES = [ + 'episode_summary', + 'event', + 'foreshadowing', + 'unresolved_conflict', + 'resolved_conflict', + 'relationship_change', + 'prop_state', + 'scene_state', + 'world_rule', + 'next_hook' +] as const; + +export const PLOT_MEMORY_STATUSES = ['active', 'resolved', 'archived'] as const; + +export const CHARACTER_MEMORY_TYPES = [ + 'current_state', + 'relationship', + 'visual_rule', + 'growth', + 'profile_adjustment' +] as const; + +export const PLOT_THREAD_TYPES = [ + 'main_plot', + 'romance', + 'revenge', + 'mystery', + 'villain_plan', + 'character_growth', + 'world_secret' +] as const; + +export const PLOT_THREAD_STATUSES = [ + 'open', + 'progressing', + 'paused', + 'resolved', + 'abandoned' +] as const; + +export const CONTINUITY_RESULTS = ['pass', 'warning', 'fail'] as const; + +export type PlotMemoryType = (typeof PLOT_MEMORY_TYPES)[number]; +export type PlotMemoryStatus = (typeof PLOT_MEMORY_STATUSES)[number]; +export type CharacterMemoryType = (typeof CHARACTER_MEMORY_TYPES)[number]; +export type PlotThreadType = (typeof PLOT_THREAD_TYPES)[number]; +export type PlotThreadStatus = (typeof PLOT_THREAD_STATUSES)[number]; +export type ContinuityResult = (typeof CONTINUITY_RESULTS)[number]; + +export interface SafePlotMemory { + id: string; + project_id: string; + episode_id: string | null; + chapter_id: string | null; + memory_type: string; + content: string; + importance_level: number; + status: string; + created_at: string; +} + +export interface SafeCharacterMemory { + id: string; + project_id: string; + character_id: string; + episode_id: string | null; + memory_type: string; + content: string; + created_at: string; +} + +export interface SafePlotThread { + id: string; + project_id: string; + thread_name: string; + thread_type: string; + description: string | null; + start_episode_no: number | null; + expected_resolve_episode_no: number | null; + resolved_episode_no: number | null; + status: string; + created_at: string; + updated_at: string; +} + +export interface SafeContinuityCheck { + id: string; + project_id: string; + episode_id: string | null; + check_type: string; + result_status: string; + issue_text: string | null; + suggestion_text: string | null; + created_at: string; +} + +export function toSafePlotMemory(memory: PlotMemory): SafePlotMemory { + return { + id: memory.id.toString(), + project_id: memory.project_id.toString(), + episode_id: memory.episode_id?.toString() ?? null, + chapter_id: memory.chapter_id?.toString() ?? null, + memory_type: memory.memory_type, + content: memory.content, + importance_level: memory.importance_level, + status: memory.status, + created_at: memory.created_at.toISOString() + }; +} + +export function toSafeCharacterMemory(memory: CharacterMemory): SafeCharacterMemory { + return { + id: memory.id.toString(), + project_id: memory.project_id.toString(), + character_id: memory.character_id.toString(), + episode_id: memory.episode_id?.toString() ?? null, + memory_type: memory.memory_type, + content: memory.content, + created_at: memory.created_at.toISOString() + }; +} + +export function toSafePlotThread(thread: PlotThread): SafePlotThread { + return { + id: thread.id.toString(), + project_id: thread.project_id.toString(), + thread_name: thread.thread_name, + thread_type: thread.thread_type, + description: thread.description, + start_episode_no: thread.start_episode_no, + expected_resolve_episode_no: thread.expected_resolve_episode_no, + resolved_episode_no: thread.resolved_episode_no, + status: thread.status, + created_at: thread.created_at.toISOString(), + updated_at: thread.updated_at.toISOString() + }; +} + +export function toSafeContinuityCheck(check: ContinuityCheck): SafeContinuityCheck { + return { + id: check.id.toString(), + project_id: check.project_id.toString(), + episode_id: check.episode_id?.toString() ?? null, + check_type: check.check_type, + result_status: check.result_status, + issue_text: check.issue_text, + suggestion_text: check.suggestion_text, + created_at: check.created_at.toISOString() + }; +} diff --git a/backend/src/novels/novel-parser.service.spec.ts b/backend/src/novels/novel-parser.service.spec.ts new file mode 100644 index 0000000..61e0527 --- /dev/null +++ b/backend/src/novels/novel-parser.service.spec.ts @@ -0,0 +1,49 @@ +import { BadRequestException } from '@nestjs/common'; +import { describe, expect, it } from 'vitest'; +import { NovelParserService } from './novel-parser.service'; + +describe('NovelParserService', () => { + const service = new NovelParserService(); + + it('extracts plain text buffers', async () => { + const result = await service.extractText( + 'local://novels/test.txt', + 'text/plain', + Buffer.from('第1章 开始\n这是正文。') + ); + + expect(result.extractor).toBe('plain_text'); + expect(result.text).toContain('这是正文'); + }); + + it('cleans text and splits chapters by headings', () => { + const parsed = service.parseText(` +第1章 重生 +她在暴雨里醒来,决定重新夺回属于自己的事业。 +https://example.com + +第二章 反击 +会议室里,所有人都等着看她出错,她却拿出了完整方案。 +`); + + expect(parsed.chapter_count).toBe(2); + expect(parsed.parse_report.strategy).toBe('heading'); + expect(parsed.parse_report.removed_line_count).toBe(1); + expect(parsed.chapters[0].title).toBe('第1章 重生'); + expect(parsed.chapters[1].content).toContain('完整方案'); + }); + + it('falls back to chunk splitting when headings are missing', () => { + const parsed = service.parseText( + '她醒来时,窗外正在下雨。她意识到命运已经重新开始,于是把所有证据重新整理,准备迎接第一场反击。' + ); + + expect(parsed.chapter_count).toBe(1); + expect(parsed.parse_report.strategy).toBe('word_chunk'); + expect(parsed.parse_report.warnings[0]).toContain('按字数切分'); + }); + + it('rejects text that is too short', () => { + expect(() => service.parseText('太短')).toThrow(BadRequestException); + }); +}); diff --git a/backend/src/novels/novel-parser.service.ts b/backend/src/novels/novel-parser.service.ts new file mode 100644 index 0000000..ee20ada --- /dev/null +++ b/backend/src/novels/novel-parser.service.ts @@ -0,0 +1,298 @@ +import { extname } from 'node:path'; +import { BadRequestException, Injectable } from '@nestjs/common'; +import mammoth from 'mammoth'; +import { PDFParse } from 'pdf-parse'; + +const MAX_CHAPTER_CHARS = 6000; +const MIN_TEXT_CHARS = 20; + +export interface ExtractedText { + text: string; + extractor: 'plain_text' | 'docx' | 'pdf_text'; + warnings: string[]; +} + +export interface ParsedChapterDraft { + chapter_no: number; + title: string; + content: string; + summary: string; + visual_summary: string; + word_count: number; +} + +export interface ParsedNovelText { + clean_text: string; + word_count: number; + chapter_count: number; + chapters: ParsedChapterDraft[]; + parse_report: { + strategy: 'heading' | 'word_chunk'; + removed_line_count: number; + warnings: string[]; + }; +} + +interface CleanResult { + cleanText: string; + removedLineCount: number; +} + +@Injectable() +export class NovelParserService { + async extractText( + filePath: string, + mimeType: string | null | undefined, + buffer: Buffer + ): Promise { + const extension = extname(filePath.replace(/^local:\/\//, '').replace(/^minio:\/\/[^/]+\//, '')) + .toLowerCase(); + + if (extension === '.docx' || mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') { + const result = await mammoth.extractRawText({ buffer }); + return { + text: result.value, + extractor: 'docx', + warnings: result.messages.map((message) => message.message) + }; + } + + if (extension === '.pdf' || mimeType === 'application/pdf') { + const parser = new PDFParse({ data: buffer }); + try { + const result = await parser.getText(); + return { + text: result.text, + extractor: 'pdf_text', + warnings: [] + }; + } finally { + await parser.destroy(); + } + } + + return { + text: buffer.toString('utf8'), + extractor: 'plain_text', + warnings: [] + }; + } + + parseText(rawText: string, warnings: string[] = []): ParsedNovelText { + const cleaned = this.cleanText(rawText); + this.assertReadableText(cleaned.cleanText); + + const splitResult = this.splitChapters(cleaned.cleanText); + const chapters = splitResult.chapters.map((chapter, index) => ({ + chapter_no: index + 1, + title: chapter.title || `第${index + 1}段`, + content: chapter.content, + summary: this.buildSummary(chapter.content), + visual_summary: this.buildVisualSummary(chapter.content), + word_count: this.countWords(chapter.content) + })); + + return { + clean_text: cleaned.cleanText, + word_count: this.countWords(cleaned.cleanText), + chapter_count: chapters.length, + chapters, + parse_report: { + strategy: splitResult.strategy, + removed_line_count: cleaned.removedLineCount, + warnings: [ + ...warnings, + ...(splitResult.strategy === 'word_chunk' + ? ['未识别到明确章节标题,已按字数切分。'] + : []) + ] + } + }; + } + + countWords(text: string) { + const cjkCount = text.match(/[\u3400-\u9fff]/g)?.length ?? 0; + const wordCount = text.match(/[A-Za-z0-9]+(?:[-'][A-Za-z0-9]+)*/g)?.length ?? 0; + return cjkCount + wordCount; + } + + private cleanText(rawText: string): CleanResult { + const normalized = rawText + .replace(/^\uFEFF/, '') + .replace(/\r\n?/g, '\n') + .replace(/[\u200B-\u200D\uFEFF]/g, '') + .replace(/\t/g, ' '); + const lines = normalized.split('\n'); + const cleanLines: string[] = []; + let previousBlank = false; + let removedLineCount = 0; + + for (const line of lines) { + const trimmed = line.trim(); + + if (this.isNoiseLine(trimmed)) { + removedLineCount += 1; + continue; + } + + if (!trimmed) { + if (!previousBlank) { + cleanLines.push(''); + } + previousBlank = true; + continue; + } + + cleanLines.push(trimmed); + previousBlank = false; + } + + return { + cleanText: cleanLines.join('\n').replace(/\n{3,}/g, '\n\n').trim(), + removedLineCount + }; + } + + private splitChapters(cleanText: string) { + const lines = cleanText.split('\n'); + const chapters: Array<{ title: string; content: string }> = []; + let currentTitle = ''; + let currentLines: string[] = []; + let foundHeading = false; + + const flush = () => { + const content = currentLines.join('\n').trim(); + if (content) { + chapters.push({ + title: currentTitle, + content + }); + } + currentLines = []; + }; + + for (const line of lines) { + const heading = this.matchChapterHeading(line); + + if (heading) { + if (foundHeading || currentLines.join('').trim()) { + flush(); + } + currentTitle = heading; + foundHeading = true; + continue; + } + + currentLines.push(line); + } + + flush(); + + if (foundHeading && chapters.length > 0) { + return { + strategy: 'heading' as const, + chapters + }; + } + + return { + strategy: 'word_chunk' as const, + chapters: this.splitByLength(cleanText) + }; + } + + private splitByLength(cleanText: string) { + const paragraphs = cleanText.split(/\n{2,}/).map((item) => item.trim()).filter(Boolean); + const chapters: Array<{ title: string; content: string }> = []; + let chunk: string[] = []; + let chunkLength = 0; + + for (const paragraph of paragraphs) { + if (chunkLength > 0 && chunkLength + paragraph.length > MAX_CHAPTER_CHARS) { + chapters.push({ + title: `第${chapters.length + 1}段`, + content: chunk.join('\n\n') + }); + chunk = []; + chunkLength = 0; + } + + chunk.push(paragraph); + chunkLength += paragraph.length; + } + + if (chunk.length > 0) { + chapters.push({ + title: `第${chapters.length + 1}段`, + content: chunk.join('\n\n') + }); + } + + return chapters.length > 0 + ? chapters + : [{ title: '第1段', content: cleanText }]; + } + + private matchChapterHeading(line: string) { + const trimmed = line.trim(); + + if (!trimmed || trimmed.length > 80) { + return null; + } + + const patterns = [ + /^第[零一二三四五六七八九十百千万两\d]+[章节回卷集部篇][\s::、.-]*(.+)?$/, + /^chapter\s*\d+[\s::、.-]*(.+)?$/i, + /^\d{1,4}[\s、.._-]+(.+)$/, + /^(序章|楔子|前言|正文|番外(?:篇|外)?(?:\s*\d+)?|尾声|后记)$/ + ]; + + return patterns.some((pattern) => pattern.test(trimmed)) ? trimmed : null; + } + + private buildSummary(content: string) { + return this.compact(content).slice(0, 180); + } + + private buildVisualSummary(content: string) { + return this.compact(content).slice(0, 120); + } + + private compact(text: string) { + return text.replace(/\s+/g, ' ').trim(); + } + + private assertReadableText(text: string) { + if (text.length < MIN_TEXT_CHARS) { + throw new BadRequestException('Novel text is too short to parse'); + } + + const replacementCount = text.match(/\uFFFD/g)?.length ?? 0; + if (replacementCount > 0 && replacementCount / text.length > 0.01) { + throw new BadRequestException('Novel text looks garbled, please upload UTF-8 text'); + } + } + + private isNoiseLine(line: string) { + if (!line) { + return false; + } + + const noisePatterns = [ + /^本章未完.*$/i, + /^请收藏.*$/i, + /^求收藏.*$/i, + /^求推荐.*$/i, + /^--+$/, + /https?:\/\//i, + /www\./i, + /关注公众号/, + /扫码/, + /手机用户请浏览/, + /最新章节/, + /无弹窗/ + ]; + + return noisePatterns.some((pattern) => pattern.test(line)); + } +} diff --git a/backend/src/novels/novel.dto.ts b/backend/src/novels/novel.dto.ts new file mode 100644 index 0000000..8919221 --- /dev/null +++ b/backend/src/novels/novel.dto.ts @@ -0,0 +1,26 @@ +import type { AuthorizationType } from './novel.types'; + +export class ConfirmCopyrightDto { + authorization_type?: AuthorizationType; + statement_text?: string; +} + +export class PasteNovelDto { + title?: string; + author_name?: string; + text?: string; +} + +export class ParseNovelDto { + asset_id?: string; + source_id?: string; + title?: string; + author_name?: string; +} + +export class UpdateNovelChapterDto { + title?: string; + content?: string; + summary?: string; + visual_summary?: string; +} diff --git a/backend/src/novels/novel.types.ts b/backend/src/novels/novel.types.ts new file mode 100644 index 0000000..81db117 --- /dev/null +++ b/backend/src/novels/novel.types.ts @@ -0,0 +1,94 @@ +import type { CopyrightRecord, NovelChapter, NovelSource, Prisma } from '@prisma/client'; + +export const AUTHORIZATION_TYPES = [ + 'author_self', + 'licensed', + 'public_domain', + 'internal_test' +] as const; + +export type AuthorizationType = (typeof AUTHORIZATION_TYPES)[number]; + +export interface SafeNovelSource { + id: string; + project_id: string; + source_type: string; + title: string | null; + author_name: string | null; + raw_asset_id: string | null; + word_count: number | null; + chapter_count: number | null; + parse_status: string; + parse_report: Prisma.JsonValue | null; + created_at: string; +} + +export interface SafeNovelChapter { + id: string; + project_id: string; + novel_source_id: string | null; + chapter_no: number; + title: string | null; + content: string; + summary: string | null; + visual_summary: string | null; + word_count: number | null; + status: string; + created_at: string; +} + +export interface SafeCopyrightRecord { + id: string; + project_id: string; + user_id: string; + authorization_type: string; + statement_text: string; + ip: string | null; + user_agent: string | null; + confirmed_at: string; +} + +export function toSafeNovelSource(source: NovelSource): SafeNovelSource { + return { + id: source.id.toString(), + project_id: source.project_id.toString(), + source_type: source.source_type, + title: source.title, + author_name: source.author_name, + raw_asset_id: source.raw_asset_id?.toString() ?? null, + word_count: source.word_count, + chapter_count: source.chapter_count, + parse_status: source.parse_status, + parse_report: source.parse_report, + created_at: source.created_at.toISOString() + }; +} + +export function toSafeNovelChapter(chapter: NovelChapter): SafeNovelChapter { + return { + id: chapter.id.toString(), + project_id: chapter.project_id.toString(), + novel_source_id: chapter.novel_source_id?.toString() ?? null, + chapter_no: chapter.chapter_no, + title: chapter.title, + content: chapter.content, + summary: chapter.summary, + visual_summary: chapter.visual_summary, + word_count: chapter.word_count, + status: chapter.status, + created_at: chapter.created_at.toISOString() + }; +} + +export function toSafeCopyrightRecord(record: CopyrightRecord): SafeCopyrightRecord { + return { + id: record.id.toString(), + project_id: record.project_id.toString(), + user_id: record.user_id.toString(), + authorization_type: record.authorization_type, + statement_text: record.statement_text, + ip: record.ip, + user_agent: record.user_agent, + confirmed_at: record.confirmed_at.toISOString() + }; +} diff --git a/backend/src/novels/novels.controller.ts b/backend/src/novels/novels.controller.ts new file mode 100644 index 0000000..83102b0 --- /dev/null +++ b/backend/src/novels/novels.controller.ts @@ -0,0 +1,83 @@ +import { + Body, + Controller, + Get, + Inject, + Param, + Patch, + Post, + Query, + Req, + UseGuards +} from '@nestjs/common'; +import type { Request } from 'express'; +import { CurrentUser } from '../auth/current-user.decorator'; +import type { AuthRequestUser } from '../auth/auth.types'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { + ConfirmCopyrightDto, + ParseNovelDto, + PasteNovelDto, + UpdateNovelChapterDto +} from './novel.dto'; +import { NovelsService } from './novels.service'; + +@Controller() +@UseGuards(JwtAuthGuard) +export class NovelsController { + constructor(@Inject(NovelsService) private readonly novelsService: NovelsService) {} + + @Post('projects/:projectId/copyright/confirm') + confirmCopyright( + @CurrentUser() user: AuthRequestUser, + @Param('projectId') projectId: string, + @Body() dto: ConfirmCopyrightDto, + @Req() request: Request + ) { + return this.novelsService.confirmCopyright(user, projectId, dto, request); + } + + @Get('projects/:projectId/copyright') + listCopyrightRecords( + @CurrentUser() user: AuthRequestUser, + @Param('projectId') projectId: string + ) { + return this.novelsService.listCopyrightRecords(user, projectId); + } + + @Post('projects/:projectId/novel/paste') + pasteNovel( + @CurrentUser() user: AuthRequestUser, + @Param('projectId') projectId: string, + @Body() dto: PasteNovelDto + ) { + return this.novelsService.pasteNovel(user, projectId, dto); + } + + @Post('projects/:projectId/novel/parse') + parseNovel( + @CurrentUser() user: AuthRequestUser, + @Param('projectId') projectId: string, + @Body() dto: ParseNovelDto + ) { + return this.novelsService.parseNovel(user, projectId, dto); + } + + @Get('projects/:projectId/novel/parse-result') + getParseResult( + @CurrentUser() user: AuthRequestUser, + @Param('projectId') projectId: string, + @Query('source_id') sourceId?: string + ) { + return this.novelsService.getParseResult(user, projectId, sourceId); + } + + @Patch('novel-chapters/:chapterId') + updateChapter( + @CurrentUser() user: AuthRequestUser, + @Param('chapterId') chapterId: string, + @Body() dto: UpdateNovelChapterDto + ) { + return this.novelsService.updateChapter(user, chapterId, dto); + } +} diff --git a/backend/src/novels/novels.module.ts b/backend/src/novels/novels.module.ts new file mode 100644 index 0000000..7ef03b3 --- /dev/null +++ b/backend/src/novels/novels.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { AssetsModule } from '../assets/assets.module'; +import { NovelsController } from './novels.controller'; +import { NovelParserService } from './novel-parser.service'; +import { NovelsService } from './novels.service'; +import { OriginalNovelMockService } from './original-novel-mock.service'; +import { OriginalNovelsController } from './original-novels.controller'; + +@Module({ + imports: [AuthModule, AssetsModule], + controllers: [NovelsController, OriginalNovelsController], + providers: [NovelParserService, NovelsService, OriginalNovelMockService], + exports: [NovelParserService, NovelsService, OriginalNovelMockService] +}) +export class NovelsModule {} diff --git a/backend/src/novels/novels.service.spec.ts b/backend/src/novels/novels.service.spec.ts new file mode 100644 index 0000000..eee4826 --- /dev/null +++ b/backend/src/novels/novels.service.spec.ts @@ -0,0 +1,317 @@ +import { BadRequestException } from '@nestjs/common'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Asset, CopyrightRecord, NovelChapter, NovelSource, Project } from '@prisma/client'; +import type { AuthRequestUser } from '../auth/auth.types'; +import type { PrismaService } from '../prisma/prisma.service'; +import type { StorageService } from '../assets/storage.service'; +import type { NovelParserService, ParsedNovelText } from './novel-parser.service'; +import { NovelsService } from './novels.service'; + +const user: AuthRequestUser = { + id: '1', + email: 'user@example.com', + role: 'user' +}; + +function createProject(overrides: Partial = {}): Project { + return { + id: 10n, + user_id: 1n, + title: '上传小说项目', + input_mode: 'upload', + genre: 'urban_rebirth', + style_code: 'korean_comic', + output_type: 'short_video', + output_mode: 'image_manga', + visual_mode: 'korean_manga', + video_generation_level: 'standard', + target_episode_count: 1, + episode_duration: 60, + status: 'source_selecting', + copyright_status: 'confirmed', + payment_status: 'unpaid', + quality_level: 'mvp', + is_long_series: false, + created_at: new Date('2026-05-31T00:00:00.000Z'), + updated_at: new Date('2026-05-31T00:00:00.000Z'), + completed_at: null, + ...overrides + }; +} + +function createSource(overrides: Partial = {}): NovelSource { + return { + id: 20n, + project_id: 10n, + source_type: 'paste', + title: '上传小说项目', + author_name: null, + raw_asset_id: null, + raw_text: '第1章 重生\n她醒来后开始反击。', + clean_text: null, + word_count: 12, + chapter_count: null, + parse_status: 'pending', + parse_report: null, + created_at: new Date('2026-05-31T00:00:00.000Z'), + ...overrides + }; +} + +function createChapter(overrides: Partial = {}): NovelChapter { + return { + id: 30n, + project_id: 10n, + novel_source_id: 20n, + chapter_no: 1, + title: '第1章 重生', + content: '她醒来后开始反击。', + summary: '她醒来后开始反击。', + visual_summary: '她醒来后开始反击。', + word_count: 9, + status: 'parsed', + created_at: new Date('2026-05-31T00:00:00.000Z'), + ...overrides + }; +} + +function createRecord(overrides: Partial = {}): CopyrightRecord { + return { + id: 40n, + project_id: 10n, + user_id: 1n, + authorization_type: 'author_self', + statement_text: '我确认拥有该小说的合法改编权。', + ip: '127.0.0.1', + user_agent: 'vitest', + confirmed_at: new Date('2026-05-31T00:00:00.000Z'), + ...overrides + }; +} + +function createAsset(overrides: Partial = {}): Asset { + return { + id: 50n, + user_id: 1n, + project_id: 10n, + asset_type: 'novel_text', + file_path: 'local://novels/test.txt', + file_url: null, + mime_type: 'text/plain', + width: null, + height: null, + duration: null, + size: 100n, + hash: 'hash', + visibility: 'private', + status: 'active', + created_at: new Date('2026-05-31T00:00:00.000Z'), + ...overrides + }; +} + +const parsedText: ParsedNovelText = { + clean_text: '第1章 重生\n她醒来后开始反击。', + word_count: 9, + chapter_count: 1, + chapters: [ + { + chapter_no: 1, + title: '第1章 重生', + content: '她醒来后开始反击。', + summary: '她醒来后开始反击。', + visual_summary: '她醒来后开始反击。', + word_count: 9 + } + ], + parse_report: { + strategy: 'heading', + removed_line_count: 0, + warnings: [] + } +}; + +describe('NovelsService', () => { + let prisma: { + project: { findUnique: ReturnType; update: ReturnType }; + copyrightRecord: { + create: ReturnType; + findMany: ReturnType; + count: ReturnType; + }; + novelSource: { + create: ReturnType; + findUnique: ReturnType; + findFirst: ReturnType; + update: ReturnType; + }; + novelChapter: { + findUnique: ReturnType; + findMany: ReturnType; + update: ReturnType; + }; + asset: { findUnique: ReturnType }; + $transaction: ReturnType; + }; + let tx: { + project: { update: ReturnType }; + novelSource: { create: ReturnType; update: ReturnType }; + novelChapter: { + deleteMany: ReturnType; + createMany: ReturnType; + findMany: ReturnType; + }; + }; + let storage: Pick; + let parser: Pick; + let service: NovelsService; + + beforeEach(() => { + tx = { + project: { update: vi.fn().mockResolvedValue(createProject({ status: 'novel_uploaded' })) }, + novelSource: { + create: vi.fn().mockResolvedValue(createSource({ parse_status: 'parsed' })), + update: vi.fn().mockResolvedValue(createSource({ parse_status: 'parsed' })) + }, + novelChapter: { + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + createMany: vi.fn().mockResolvedValue({ count: 1 }), + findMany: vi.fn().mockResolvedValue([createChapter()]) + } + }; + prisma = { + project: { + findUnique: vi.fn().mockResolvedValue(createProject()), + update: vi.fn().mockResolvedValue(createProject()) + }, + copyrightRecord: { + create: vi.fn().mockResolvedValue(createRecord()), + findMany: vi.fn().mockResolvedValue([createRecord()]), + count: vi.fn().mockResolvedValue(1) + }, + novelSource: { + create: vi.fn().mockResolvedValue(createSource()), + findUnique: vi.fn().mockResolvedValue(createSource()), + findFirst: vi.fn().mockResolvedValue(createSource()), + update: vi.fn() + }, + novelChapter: { + findUnique: vi.fn().mockResolvedValue(createChapter()), + findMany: vi.fn().mockResolvedValue([createChapter()]), + update: vi.fn().mockResolvedValue(createChapter({ status: 'edited' })) + }, + asset: { + findUnique: vi.fn().mockResolvedValue(createAsset()) + }, + $transaction: vi.fn(async (callback: (transaction: typeof tx) => unknown) => callback(tx)) + }; + storage = { + readPrivateFile: vi.fn().mockResolvedValue(Buffer.from('第1章 重生\n她醒来后开始反击。')) + }; + parser = { + countWords: vi.fn((text: string) => text.length), + extractText: vi.fn().mockResolvedValue({ + text: '第1章 重生\n她醒来后开始反击。', + extractor: 'plain_text', + warnings: [] + }), + parseText: vi.fn().mockReturnValue(parsedText) + }; + service = new NovelsService( + prisma as unknown as PrismaService, + storage as StorageService, + parser as NovelParserService + ); + }); + + it('confirms copyright and updates the project', async () => { + const result = await service.confirmCopyright( + user, + '10', + { + authorization_type: 'author_self', + statement_text: '我确认拥有该小说的合法改编权。' + }, + { ip: '127.0.0.1', headers: { 'user-agent': 'vitest' } } + ); + + expect(prisma.copyrightRecord.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + project_id: 10n, + authorization_type: 'author_self' + }) + }); + expect(prisma.project.update).toHaveBeenCalledWith({ + where: { id: 10n }, + data: expect.objectContaining({ + copyright_status: 'confirmed', + status: 'copyright_confirmed' + }) + }); + expect(result.next_step).toBe('novel_parse'); + }); + + it('parses a pasted source into chapters', async () => { + const result = await service.parseNovel(user, '10', { source_id: '20' }); + + expect(parser.parseText).toHaveBeenCalledWith( + '第1章 重生\n她醒来后开始反击。', + [] + ); + expect(tx.novelChapter.createMany).toHaveBeenCalledWith({ + data: [ + expect.objectContaining({ + project_id: 10n, + novel_source_id: 20n, + chapter_no: 1, + status: 'parsed' + }) + ] + }); + expect(tx.project.update).toHaveBeenCalledWith({ + where: { id: 10n }, + data: { status: 'novel_uploaded' } + }); + expect(result.chapters).toHaveLength(1); + }); + + it('parses an uploaded asset into a new source', async () => { + const result = await service.parseNovel(user, '10', { asset_id: '50' }); + + expect(storage.readPrivateFile).toHaveBeenCalledWith('local://novels/test.txt'); + expect(parser.extractText).toHaveBeenCalled(); + expect(tx.novelSource.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + raw_asset_id: 50n, + parse_status: 'parsed' + }) + }); + expect(result.source.raw_asset_id).toBeNull(); + }); + + it('rejects parsing before copyright is confirmed', async () => { + prisma.project.findUnique.mockResolvedValue( + createProject({ copyright_status: 'pending' }) + ); + prisma.copyrightRecord.count.mockResolvedValue(0); + + await expect(service.parseNovel(user, '10', { source_id: '20' })).rejects.toBeInstanceOf( + BadRequestException + ); + }); + + it('updates an owned chapter manually', async () => { + const result = await service.updateChapter(user, '30', { + content: '她拿出证据,完成第一场反击。' + }); + + expect(prisma.novelChapter.update).toHaveBeenCalledWith({ + where: { id: 30n }, + data: expect.objectContaining({ + content: '她拿出证据,完成第一场反击。', + status: 'edited' + }) + }); + expect(result.status).toBe('edited'); + }); +}); diff --git a/backend/src/novels/novels.service.ts b/backend/src/novels/novels.service.ts new file mode 100644 index 0000000..1e5bf42 --- /dev/null +++ b/backend/src/novels/novels.service.ts @@ -0,0 +1,482 @@ +import { + BadRequestException, + ForbiddenException, + Inject, + Injectable, + NotFoundException +} from '@nestjs/common'; +import type { Asset, NovelChapter, NovelSource, Prisma, Project } from '@prisma/client'; +import type { Request } from 'express'; +import type { AuthRequestUser } from '../auth/auth.types'; +import { PrismaService } from '../prisma/prisma.service'; +import { StorageService } from '../assets/storage.service'; +import { + ConfirmCopyrightDto, + ParseNovelDto, + PasteNovelDto, + UpdateNovelChapterDto +} from './novel.dto'; +import { NovelParserService, type ParsedNovelText } from './novel-parser.service'; +import { + AUTHORIZATION_TYPES, + toSafeCopyrightRecord, + toSafeNovelChapter, + toSafeNovelSource +} from './novel.types'; + +const MAX_PASTE_CHARS = 2_000_000; + +@Injectable() +export class NovelsService { + constructor( + @Inject(PrismaService) + private readonly prisma: PrismaService, + @Inject(StorageService) + private readonly storage: StorageService, + @Inject(NovelParserService) + private readonly parser: NovelParserService + ) {} + + async confirmCopyright( + user: AuthRequestUser, + projectId: string, + dto: ConfirmCopyrightDto, + request: Pick + ) { + const project = await this.findProjectForUser(projectId, user); + this.assertUploadProject(project); + const authorizationType = this.validateAuthorizationType(dto.authorization_type); + const statementText = this.normalizeRequiredText( + dto.statement_text, + 'statement_text is required' + ); + const record = await this.prisma.copyrightRecord.create({ + data: { + project_id: project.id, + user_id: BigInt(user.id), + authorization_type: authorizationType, + statement_text: statementText, + ip: request.ip || null, + user_agent: this.headerToString(request.headers['user-agent']) + } + }); + + await this.prisma.project.update({ + where: { id: project.id }, + data: { + copyright_status: 'confirmed', + status: project.status === 'source_selecting' ? 'copyright_confirmed' : project.status + } + }); + + return { + record: toSafeCopyrightRecord(record), + next_step: 'novel_parse' + }; + } + + async listCopyrightRecords(user: AuthRequestUser, projectId: string) { + const project = await this.findProjectForUser(projectId, user); + const records = await this.prisma.copyrightRecord.findMany({ + where: { project_id: project.id }, + orderBy: { confirmed_at: 'desc' } + }); + + return records.map(toSafeCopyrightRecord); + } + + async pasteNovel(user: AuthRequestUser, projectId: string, dto: PasteNovelDto) { + const project = await this.findProjectForUser(projectId, user); + this.assertUploadProject(project); + const text = this.normalizeRequiredText(dto.text, 'text is required'); + + if (text.length > MAX_PASTE_CHARS) { + throw new BadRequestException('Pasted novel text is too large'); + } + + const source = await this.prisma.novelSource.create({ + data: { + project_id: project.id, + source_type: 'paste', + title: this.normalizeOptionalText(dto.title) ?? project.title, + author_name: this.normalizeOptionalText(dto.author_name), + raw_text: text, + word_count: this.parser.countWords(text), + parse_status: 'pending' + } + }); + + return { + source: toSafeNovelSource(source), + next_step: project.copyright_status === 'confirmed' ? 'novel_parse' : 'copyright_confirm' + }; + } + + async parseNovel(user: AuthRequestUser, projectId: string, dto: ParseNovelDto) { + const project = await this.findProjectForUser(projectId, user); + this.assertUploadProject(project); + await this.assertCopyrightConfirmed(project); + + const input = await this.resolveParseInput(project, user, dto); + await this.prisma.project.update({ + where: { id: project.id }, + data: { status: 'text_parsing' } + }); + + try { + const parsed = this.parser.parseText(input.rawText, input.warnings); + return await this.saveParsedNovel(project, input, parsed, dto); + } catch (error) { + await this.markParseFailed(project.id, input.source?.id, error); + throw error; + } + } + + async getParseResult(user: AuthRequestUser, projectId: string, sourceId?: string) { + const project = await this.findProjectForUser(projectId, user); + const source = sourceId + ? await this.findSourceForProject(project.id, sourceId) + : await this.prisma.novelSource.findFirst({ + where: { project_id: project.id }, + orderBy: { created_at: 'desc' } + }); + + if (!source) { + return { + source: null, + chapters: [] + }; + } + + const chapters = await this.prisma.novelChapter.findMany({ + where: { novel_source_id: source.id }, + orderBy: { chapter_no: 'asc' } + }); + + return { + source: toSafeNovelSource(source), + chapters: chapters.map(toSafeNovelChapter) + }; + } + + async updateChapter( + user: AuthRequestUser, + chapterId: string, + dto: UpdateNovelChapterDto + ) { + const chapter = await this.prisma.novelChapter.findUnique({ + where: { id: this.parseId(chapterId, 'Invalid chapter id') } + }); + + if (!chapter) { + throw new NotFoundException('Novel chapter not found'); + } + + await this.findProjectForUser(chapter.project_id.toString(), user); + const data: Partial = {}; + let edited = false; + + if ('title' in dto) { + data.title = this.normalizeOptionalText(dto.title) ?? null; + edited = true; + } + if ('content' in dto) { + data.content = this.normalizeRequiredText(dto.content, 'content is required'); + data.word_count = this.parser.countWords(data.content); + edited = true; + } + if ('summary' in dto) { + data.summary = this.normalizeOptionalText(dto.summary) ?? null; + edited = true; + } + if ('visual_summary' in dto) { + data.visual_summary = this.normalizeOptionalText(dto.visual_summary) ?? null; + edited = true; + } + + if (edited) { + data.status = 'edited'; + } + + const updated = await this.prisma.novelChapter.update({ + where: { id: chapter.id }, + data + }); + + return toSafeNovelChapter(updated); + } + + private async resolveParseInput( + project: Project, + user: AuthRequestUser, + dto: ParseNovelDto + ) { + if (dto.asset_id && dto.source_id) { + throw new BadRequestException('asset_id and source_id cannot be used together'); + } + + if (dto.asset_id) { + const asset = await this.findAssetForParse(project, user, dto.asset_id); + const buffer = await this.storage.readPrivateFile(asset.file_path); + const extracted = await this.parser.extractText(asset.file_path, asset.mime_type, buffer); + + return { + source: null, + rawText: extracted.text, + rawAssetId: asset.id, + sourceType: 'upload', + title: this.normalizeOptionalText(dto.title) ?? project.title, + authorName: this.normalizeOptionalText(dto.author_name), + extractor: extracted.extractor, + warnings: extracted.warnings + }; + } + + const source = dto.source_id + ? await this.findSourceForProject(project.id, dto.source_id) + : await this.prisma.novelSource.findFirst({ + where: { + project_id: project.id, + raw_text: { not: null } + }, + orderBy: { created_at: 'desc' } + }); + + if (!source?.raw_text) { + throw new BadRequestException('source_id or asset_id is required'); + } + + return { + source, + rawText: source.raw_text, + rawAssetId: source.raw_asset_id, + sourceType: source.source_type, + title: this.normalizeOptionalText(dto.title) ?? source.title ?? project.title, + authorName: this.normalizeOptionalText(dto.author_name) ?? source.author_name, + extractor: 'plain_text', + warnings: [] as string[] + }; + } + + private async saveParsedNovel( + project: Project, + input: Awaited>, + parsed: ParsedNovelText, + dto: ParseNovelDto + ) { + return this.prisma.$transaction(async (tx) => { + const source = input.source + ? await tx.novelSource.update({ + where: { id: input.source.id }, + data: { + title: input.title, + author_name: input.authorName, + raw_text: input.rawText, + clean_text: parsed.clean_text, + word_count: parsed.word_count, + chapter_count: parsed.chapter_count, + parse_status: 'parsed', + parse_report: this.buildParseReport(input, parsed) + } + }) + : await tx.novelSource.create({ + data: { + project_id: project.id, + source_type: input.sourceType, + title: input.title, + author_name: input.authorName, + raw_asset_id: input.rawAssetId, + raw_text: input.rawText, + clean_text: parsed.clean_text, + word_count: parsed.word_count, + chapter_count: parsed.chapter_count, + parse_status: 'parsed', + parse_report: this.buildParseReport(input, parsed) + } + }); + + await tx.novelChapter.deleteMany({ + where: { novel_source_id: source.id } + }); + await tx.novelChapter.createMany({ + data: parsed.chapters.map((chapter) => ({ + project_id: project.id, + novel_source_id: source.id, + chapter_no: chapter.chapter_no, + title: chapter.title, + content: chapter.content, + summary: chapter.summary, + visual_summary: chapter.visual_summary, + word_count: chapter.word_count, + status: 'parsed' + })) + }); + + const chapters = await tx.novelChapter.findMany({ + where: { novel_source_id: source.id }, + orderBy: { chapter_no: 'asc' } + }); + await tx.project.update({ + where: { id: project.id }, + data: { + status: 'novel_uploaded' + } + }); + + return { + source: toSafeNovelSource(source), + chapters: chapters.map(toSafeNovelChapter), + next_step: 'story_bible_generate', + request: { + asset_id: dto.asset_id ?? null, + source_id: dto.source_id ?? null + } + }; + }); + } + + private buildParseReport( + input: Awaited>, + parsed: ParsedNovelText + ): Prisma.InputJsonObject { + return { + source_type: input.sourceType, + extractor: input.extractor, + strategy: parsed.parse_report.strategy, + removed_line_count: parsed.parse_report.removed_line_count, + warnings: parsed.parse_report.warnings, + word_count: parsed.word_count, + chapter_count: parsed.chapter_count + }; + } + + private async markParseFailed(projectId: bigint, sourceId: bigint | undefined, error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown parse error'; + + await this.prisma.project.update({ + where: { id: projectId }, + data: { status: 'text_parse_failed' } + }); + + if (sourceId) { + await this.prisma.novelSource.update({ + where: { id: sourceId }, + data: { + parse_status: 'failed', + parse_report: { + error: message + } + } + }); + } + } + + private async assertCopyrightConfirmed(project: Project) { + if (project.copyright_status === 'confirmed') { + return; + } + + const count = await this.prisma.copyrightRecord.count({ + where: { project_id: project.id } + }); + + if (count === 0) { + throw new BadRequestException('Copyright must be confirmed before parsing novel'); + } + } + + private async findAssetForParse(project: Project, user: AuthRequestUser, assetId: string) { + const asset = await this.prisma.asset.findUnique({ + where: { id: this.parseId(assetId, 'Invalid asset id') } + }); + + if (!asset) { + throw new NotFoundException('Asset not found'); + } + + if (asset.user_id?.toString() !== user.id && user.role !== 'admin') { + throw new NotFoundException('Asset not found'); + } + + if (asset.project_id?.toString() !== project.id.toString()) { + throw new BadRequestException('Asset does not belong to this project'); + } + + if (asset.asset_type !== 'novel_text') { + throw new BadRequestException('Asset is not a novel text file'); + } + + return asset; + } + + private async findProjectForUser(projectId: string, user: AuthRequestUser) { + const project = await this.prisma.project.findUnique({ + where: { id: this.parseId(projectId, 'Invalid project id') } + }); + + if (!project) { + throw new NotFoundException('Project not found'); + } + + if (project.user_id.toString() !== user.id && user.role !== 'admin') { + throw new ForbiddenException('Project is private'); + } + + return project; + } + + private async findSourceForProject(projectId: bigint, sourceId: string) { + const source = await this.prisma.novelSource.findUnique({ + where: { id: this.parseId(sourceId, 'Invalid source id') } + }); + + if (!source || source.project_id !== projectId) { + throw new NotFoundException('Novel source not found'); + } + + return source; + } + + private assertUploadProject(project: Project) { + if (project.input_mode !== 'upload') { + throw new BadRequestException('Novel parsing is only available for upload projects'); + } + } + + private validateAuthorizationType(value: string | undefined) { + if (!value || !AUTHORIZATION_TYPES.includes(value as never)) { + throw new BadRequestException( + 'authorization_type must be author_self, licensed, public_domain, or internal_test' + ); + } + + return value; + } + + private normalizeRequiredText(value: string | undefined, message: string) { + const normalized = value?.trim(); + + if (!normalized) { + throw new BadRequestException(message); + } + + return normalized; + } + + private normalizeOptionalText(value: string | undefined) { + const normalized = value?.trim(); + return normalized || undefined; + } + + private parseId(id: string, message: string) { + try { + return BigInt(id); + } catch { + throw new BadRequestException(message); + } + } + + private headerToString(value: string | string[] | undefined) { + return Array.isArray(value) ? value.join(', ') : value ?? null; + } +} diff --git a/backend/src/novels/original-novel-mock.service.spec.ts b/backend/src/novels/original-novel-mock.service.spec.ts new file mode 100644 index 0000000..53c3e24 --- /dev/null +++ b/backend/src/novels/original-novel-mock.service.spec.ts @@ -0,0 +1,290 @@ +import { BadRequestException } from '@nestjs/common'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { NovelChapter, NovelSource, Prisma, Project } from '@prisma/client'; +import type { AuthRequestUser } from '../auth/auth.types'; +import type { PrismaService } from '../prisma/prisma.service'; +import type { NovelParserService } from './novel-parser.service'; +import { OriginalNovelMockService } from './original-novel-mock.service'; +import type { OriginalIdea, OriginalNovelReport, OriginalOutline } from './original-novel.types'; + +const user: AuthRequestUser = { + id: '1', + email: 'user@example.com', + role: 'user' +}; + +const idea: OriginalIdea = { + title: '重生归来,我只搞事业', + genre: 'urban_rebirth', + target_audience: '短视频用户', + protagonist_name: '林晚', + protagonist_setting: '年轻制片人', + story_mood: '高能反击', + selling_points: ['重生归来', '证据反杀'], + world_setting: '现代都市内容公司', + logline: '林晚重回命运转折点。', + core_conflict: '林晚必须夺回项目控制权。', + visual_hooks: ['暴雨夜醒来'] +}; + +const outline: OriginalOutline = { + main_plot: '林晚夺回项目控制权。', + chapter_count: 3, + chapters: [ + { + chapter_no: 1, + title: '第1章 暴雨重启', + goal: '确认重生', + conflict: '旧团队催签协议', + turning_point: '找到证据', + ending_hook: '陌生录音出现' + }, + { + chapter_no: 2, + title: '第2章 会议反击', + goal: '保住提案', + conflict: '对手抢创意', + turning_point: '时间戳反杀', + ending_hook: '投资人出现' + }, + { + chapter_no: 3, + title: '第3章 片场亮灯', + goal: '拿回试拍', + conflict: '旧友求情', + turning_point: '交给法务', + ending_hook: '背叛者出现' + } + ] +}; + +const baseReport: OriginalNovelReport = { + provider: 'mock_novel_provider', + mode: 'ai_original', + stage: 'outline', + idea, + outline +}; + +function createProject(overrides: Partial = {}): Project { + return { + id: 10n, + user_id: 1n, + title: '原创项目', + input_mode: 'ai_original', + genre: 'urban_rebirth', + style_code: 'korean_comic', + output_type: 'short_video', + output_mode: 'image_manga', + visual_mode: 'korean_manga', + video_generation_level: 'standard', + target_episode_count: 3, + episode_duration: 60, + status: 'source_selecting', + copyright_status: 'ai_original', + payment_status: 'unpaid', + quality_level: 'mvp', + is_long_series: false, + created_at: new Date('2026-05-31T00:00:00.000Z'), + updated_at: new Date('2026-05-31T00:00:00.000Z'), + completed_at: null, + ...overrides + }; +} + +function createSource(overrides: Partial = {}): NovelSource { + return { + id: 20n, + project_id: 10n, + source_type: 'ai_original', + title: '重生归来,我只搞事业', + author_name: 'AI Mock', + raw_asset_id: null, + raw_text: null, + clean_text: null, + word_count: null, + chapter_count: 3, + parse_status: 'outline_ready', + parse_report: baseReport as unknown as Prisma.JsonValue, + created_at: new Date('2026-05-31T00:00:00.000Z'), + ...overrides + }; +} + +function createChapter(overrides: Partial = {}): NovelChapter { + return { + id: 30n, + project_id: 10n, + novel_source_id: 20n, + chapter_no: 1, + title: '第1章 暴雨重启', + content: '林晚站在现代都市内容公司的中心。陌生录音出现。', + summary: '林晚确认重生并找到证据。', + visual_summary: '暴雨夜醒来,陌生录音出现。', + word_count: 20, + status: 'generated', + created_at: new Date('2026-05-31T00:00:00.000Z'), + ...overrides + }; +} + +describe('OriginalNovelMockService', () => { + let prisma: { + project: { findUnique: ReturnType; update: ReturnType }; + novelSource: { + create: ReturnType; + findUnique: ReturnType; + findFirst: ReturnType; + update: ReturnType; + }; + novelChapter: { findMany: ReturnType }; + $transaction: ReturnType; + }; + let tx: { + project: { update: ReturnType }; + novelSource: { update: ReturnType }; + novelChapter: { + deleteMany: ReturnType; + createMany: ReturnType; + findMany: ReturnType; + }; + }; + let parser: Pick; + let service: OriginalNovelMockService; + + beforeEach(() => { + tx = { + project: { + update: vi.fn().mockResolvedValue(createProject({ status: 'novel_uploaded' })) + }, + novelSource: { + update: vi.fn().mockResolvedValue(createSource({ parse_status: 'generated' })) + }, + novelChapter: { + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + createMany: vi.fn().mockResolvedValue({ count: 3 }), + findMany: vi.fn().mockResolvedValue([createChapter()]) + } + }; + prisma = { + project: { + findUnique: vi.fn().mockResolvedValue(createProject()), + update: vi.fn().mockResolvedValue(createProject({ status: 'novel_generating' })) + }, + novelSource: { + create: vi.fn().mockResolvedValue( + createSource({ + parse_status: 'idea_ready', + parse_report: { + provider: 'mock_novel_provider', + mode: 'ai_original', + stage: 'idea', + idea + } as unknown as Prisma.JsonValue + }) + ), + findUnique: vi.fn().mockResolvedValue(createSource()), + findFirst: vi.fn().mockResolvedValue(createSource()), + update: vi.fn().mockResolvedValue(createSource()) + }, + novelChapter: { + findMany: vi.fn().mockResolvedValue([createChapter()]) + }, + $transaction: vi.fn(async (callback: (transaction: typeof tx) => unknown) => callback(tx)) + }; + parser = { + countWords: vi.fn((text: string) => text.length) + }; + service = new OriginalNovelMockService( + prisma as unknown as PrismaService, + parser as NovelParserService + ); + }); + + it('generates an original idea and moves project into novel_generating', async () => { + const result = await service.generateIdea(user, '10', { + protagonist_name: '林晚', + selling_points: '重生归来,证据反杀' + }); + + expect(prisma.novelSource.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + project_id: 10n, + source_type: 'ai_original', + parse_status: 'idea_ready' + }) + }); + expect(prisma.project.update).toHaveBeenCalledWith({ + where: { id: 10n }, + data: { status: 'novel_generating' } + }); + expect(result.next_step).toBe('original_outline'); + }); + + it('rejects upload projects', async () => { + prisma.project.findUnique.mockResolvedValue(createProject({ input_mode: 'upload' })); + + await expect(service.generateIdea(user, '10', {})).rejects.toBeInstanceOf( + BadRequestException + ); + }); + + it('generates an outline after idea', async () => { + prisma.novelSource.findUnique.mockResolvedValue( + createSource({ + parse_report: { + provider: 'mock_novel_provider', + mode: 'ai_original', + stage: 'idea', + idea + } as unknown as Prisma.JsonValue + }) + ); + + const result = await service.generateOutline(user, '10', { + source_id: '20', + target_chapter_count: 3 + }); + + expect(prisma.novelSource.update).toHaveBeenCalledWith({ + where: { id: 20n }, + data: expect.objectContaining({ + parse_status: 'outline_ready', + chapter_count: 3 + }) + }); + expect(result.outline.chapters).toHaveLength(3); + }); + + it('generates chapters and saves them to novel_chapters', async () => { + const result = await service.generateChapters(user, '10', { source_id: '20' }); + + expect(tx.novelChapter.createMany).toHaveBeenCalledWith({ + data: expect.arrayContaining([ + expect.objectContaining({ + project_id: 10n, + novel_source_id: 20n, + status: 'generated' + }) + ]) + }); + expect(tx.project.update).toHaveBeenCalledWith({ + where: { id: 10n }, + data: { status: 'novel_uploaded' } + }); + expect(result.next_step).toBe('original_self_check'); + }); + + it('runs self-check after chapters are generated', async () => { + const result = await service.selfCheck(user, '10', { source_id: '20' }); + + expect(prisma.novelSource.update).toHaveBeenCalledWith({ + where: { id: 20n }, + data: expect.objectContaining({ + parse_status: 'checked' + }) + }); + expect(result.self_check.passed).toBe(true); + expect(result.next_step).toBe('story_bible_generate'); + }); +}); diff --git a/backend/src/novels/original-novel-mock.service.ts b/backend/src/novels/original-novel-mock.service.ts new file mode 100644 index 0000000..d749d54 --- /dev/null +++ b/backend/src/novels/original-novel-mock.service.ts @@ -0,0 +1,512 @@ +import { + BadRequestException, + ForbiddenException, + Inject, + Injectable, + NotFoundException +} from '@nestjs/common'; +import type { NovelChapter, NovelSource, Prisma, Project } from '@prisma/client'; +import type { AuthRequestUser } from '../auth/auth.types'; +import { PrismaService } from '../prisma/prisma.service'; +import { NovelParserService } from './novel-parser.service'; +import { + GenerateOriginalChaptersDto, + GenerateOriginalIdeaDto, + GenerateOriginalOutlineDto, + OriginalSelfCheckDto +} from './original-novel.dto'; +import type { + OriginalIdea, + OriginalNovelReport, + OriginalOutline, + OriginalSelfCheckResult +} from './original-novel.types'; +import { toSafeNovelChapter, toSafeNovelSource } from './novel.types'; + +const MIN_MOCK_CHAPTERS = 1; +const MAX_MOCK_CHAPTERS = 12; + +@Injectable() +export class OriginalNovelMockService { + constructor( + @Inject(PrismaService) + private readonly prisma: PrismaService, + @Inject(NovelParserService) + private readonly parser: NovelParserService + ) {} + + async generateIdea(user: AuthRequestUser, projectId: string, dto: GenerateOriginalIdeaDto) { + const project = await this.findProjectForUser(projectId, user); + this.assertOriginalProject(project); + const idea = this.buildIdea(project, dto); + const report: OriginalNovelReport = { + provider: 'mock_novel_provider', + mode: 'ai_original', + stage: 'idea', + idea, + warnings: ['阶段 07 使用 deterministic mock,不调用真实 AI Provider。'] + }; + const source = await this.prisma.novelSource.create({ + data: { + project_id: project.id, + source_type: 'ai_original', + title: idea.title, + author_name: 'AI Mock', + parse_status: 'idea_ready', + parse_report: report as unknown as Prisma.InputJsonObject + } + }); + + await this.prisma.project.update({ + where: { id: project.id }, + data: { status: 'novel_generating' } + }); + + return { + source: toSafeNovelSource(source), + idea, + next_step: 'original_outline' + }; + } + + async generateOutline( + user: AuthRequestUser, + projectId: string, + dto: GenerateOriginalOutlineDto + ) { + const project = await this.findProjectForUser(projectId, user); + this.assertOriginalProject(project); + const source = await this.requireOriginalSource(project.id, dto.source_id); + const report = this.readReport(source); + + if (!report.idea) { + throw new BadRequestException('Original idea must be generated before outline'); + } + + const chapterCount = this.resolveChapterCount(dto.target_chapter_count, project); + const outline = this.buildOutline(report.idea, chapterCount); + const nextReport: OriginalNovelReport = { + ...report, + stage: 'outline', + outline + }; + const updated = await this.prisma.novelSource.update({ + where: { id: source.id }, + data: { + parse_status: 'outline_ready', + chapter_count: chapterCount, + parse_report: nextReport as unknown as Prisma.InputJsonObject + } + }); + + return { + source: toSafeNovelSource(updated), + idea: report.idea, + outline, + next_step: 'original_chapters' + }; + } + + async generateChapters( + user: AuthRequestUser, + projectId: string, + dto: GenerateOriginalChaptersDto + ) { + const project = await this.findProjectForUser(projectId, user); + this.assertOriginalProject(project); + const source = await this.requireOriginalSource(project.id, dto.source_id); + const report = this.readReport(source); + + if (!report.idea) { + throw new BadRequestException('Original idea must be generated before chapters'); + } + + const outline = + report.outline ?? + this.buildOutline(report.idea, this.resolveChapterCount(dto.target_chapter_count, project)); + const chapters = outline.chapters.map((chapter) => + this.buildChapter(report.idea as OriginalIdea, chapter) + ); + const rawText = chapters + .map((chapter) => `${chapter.title}\n${chapter.content}`) + .join('\n\n'); + const wordCount = this.parser.countWords(rawText); + const nextReport: OriginalNovelReport = { + ...report, + stage: 'chapters', + outline + }; + + return this.prisma.$transaction(async (tx) => { + const updatedSource = await tx.novelSource.update({ + where: { id: source.id }, + data: { + clean_text: rawText, + raw_text: rawText, + word_count: wordCount, + chapter_count: chapters.length, + parse_status: 'generated', + parse_report: nextReport as unknown as Prisma.InputJsonObject + } + }); + + await tx.novelChapter.deleteMany({ + where: { novel_source_id: source.id } + }); + await tx.novelChapter.createMany({ + data: chapters.map((chapter) => ({ + project_id: project.id, + novel_source_id: source.id, + chapter_no: chapter.chapter_no, + title: chapter.title, + content: chapter.content, + summary: chapter.summary, + visual_summary: chapter.visual_summary, + word_count: chapter.word_count, + status: 'generated' + })) + }); + const savedChapters = await tx.novelChapter.findMany({ + where: { novel_source_id: source.id }, + orderBy: { chapter_no: 'asc' } + }); + + await tx.project.update({ + where: { id: project.id }, + data: { status: 'novel_uploaded' } + }); + + return { + source: toSafeNovelSource(updatedSource), + outline, + chapters: savedChapters.map(toSafeNovelChapter), + next_step: 'original_self_check' + }; + }); + } + + async selfCheck(user: AuthRequestUser, projectId: string, dto: OriginalSelfCheckDto) { + const project = await this.findProjectForUser(projectId, user); + this.assertOriginalProject(project); + const source = await this.requireOriginalSource(project.id, dto.source_id); + const report = this.readReport(source); + const chapters = await this.prisma.novelChapter.findMany({ + where: { novel_source_id: source.id }, + orderBy: { chapter_no: 'asc' } + }); + + if (!report.idea || chapters.length === 0) { + throw new BadRequestException('Original chapters must be generated before self-check'); + } + + const selfCheck = this.buildSelfCheck(report.idea, chapters); + const nextReport: OriginalNovelReport = { + ...report, + stage: 'self_check', + self_check: selfCheck + }; + const updated = await this.prisma.novelSource.update({ + where: { id: source.id }, + data: { + parse_status: selfCheck.passed ? 'checked' : 'check_failed', + parse_report: nextReport as unknown as Prisma.InputJsonObject + } + }); + + return { + source: toSafeNovelSource(updated), + self_check: selfCheck, + next_step: selfCheck.passed ? 'story_bible_generate' : 'original_revision' + }; + } + + async getResult(user: AuthRequestUser, projectId: string, sourceId?: string) { + const project = await this.findProjectForUser(projectId, user); + this.assertOriginalProject(project); + const source = await this.findOriginalSource(project.id, sourceId); + + if (!source) { + return { + source: null, + idea: null, + outline: null, + self_check: null, + chapters: [] + }; + } + + const report = this.readReport(source); + const chapters = await this.prisma.novelChapter.findMany({ + where: { novel_source_id: source.id }, + orderBy: { chapter_no: 'asc' } + }); + + return { + source: toSafeNovelSource(source), + idea: report.idea ?? null, + outline: report.outline ?? null, + self_check: report.self_check ?? null, + chapters: chapters.map(toSafeNovelChapter) + }; + } + + private buildIdea(project: Project, dto: GenerateOriginalIdeaDto): OriginalIdea { + const genre = this.normalizeOptionalText(dto.genre) ?? project.genre ?? 'urban_rebirth'; + const title = this.normalizeOptionalText(dto.title) ?? project.title ?? this.titleForGenre(genre); + const protagonistName = this.normalizeOptionalText(dto.protagonist_name) ?? '林晚'; + const protagonistSetting = + this.normalizeOptionalText(dto.protagonist_setting) ?? '被夺走项目成果的年轻制片人'; + const targetAudience = + this.normalizeOptionalText(dto.target_audience) ?? '喜欢高能反击和短视频爽点的用户'; + const storyMood = this.normalizeOptionalText(dto.story_mood) ?? '克制、锋利、连续反转'; + const worldSetting = + this.normalizeOptionalText(dto.world_setting) ?? '现代都市内容公司与资本局中局'; + const sellingPoints = this.splitList(dto.selling_points, [ + '重生归来', + '证据反杀', + '事业线逆袭', + '每章结尾强钩子' + ]); + + return { + title, + genre, + target_audience: targetAudience, + protagonist_name: protagonistName, + protagonist_setting: protagonistSetting, + story_mood: storyMood, + selling_points: sellingPoints, + world_setting: worldSetting, + logline: `${protagonistName}重回命运转折点,用前世记忆和手中证据夺回作品控制权。`, + core_conflict: `${protagonistName}必须在合作方、旧友和资本压力之间保护原创项目,并揭开前世失败的真相。`, + visual_hooks: [ + '暴雨夜醒来的重生瞬间', + '会议室投屏反杀', + '旧合同与隐藏录音同时曝光', + '片场灯光亮起时主角完成选择' + ] + }; + } + + private buildOutline(idea: OriginalIdea, chapterCount: number): OriginalOutline { + const templates = [ + { + title: '第1章 暴雨重启', + goal: `${idea.protagonist_name}确认自己回到关键节点。`, + conflict: '旧团队催她签下不公平协议。', + turning_point: '她发现前世被篡改的附件还没有交出去。', + ending_hook: '手机里突然收到一段来自陌生号码的录音。' + }, + { + title: '第2章 会议反击', + goal: `${idea.protagonist_name}保住项目提案。`, + conflict: '对手在全员会议上抢先展示她的创意。', + turning_point: '她当场调出时间戳和原始脚本,证明自己才是作者。', + ending_hook: '幕后投资人第一次注意到她。' + }, + { + title: '第3章 片场亮灯', + goal: `${idea.protagonist_name}拿回试拍机会。`, + conflict: '旧友试图用情分让她撤回追责。', + turning_point: '她拒绝妥协,把证据交给法务并启动试拍。', + ending_hook: '镜头开机时,她看见前世真正的背叛者站在监视器后。' + }, + { + title: '第4章 旧账翻面', + goal: `${idea.protagonist_name}逼近真相。`, + conflict: '资本方要求她用热搜换掉核心表达。', + turning_point: '她用预热视频数据反向争取话语权。', + ending_hook: '匿名人发来前世事故现场的照片。' + } + ]; + + return { + main_plot: `${idea.logline}${idea.core_conflict}`, + chapter_count: chapterCount, + chapters: Array.from({ length: chapterCount }, (_, index) => { + const template = templates[index % templates.length]; + return { + chapter_no: index + 1, + title: + index < templates.length + ? template.title + : `第${index + 1}章 新的筹码`, + goal: template.goal, + conflict: template.conflict, + turning_point: template.turning_point, + ending_hook: template.ending_hook + }; + }) + }; + } + + private buildChapter( + idea: OriginalIdea, + outline: OriginalOutline['chapters'][number] + ) { + const content = [ + `${outline.title}\n${idea.protagonist_name}站在${idea.world_setting}的中心,终于确认这不是梦。${outline.goal}她把混乱的情绪压下去,只留下一个清晰的念头:这一次不能再输。`, + `${outline.conflict}灯光、屏幕和沉默的人群把压力推到她面前。她没有急着解释,而是把前世遗漏的细节一项项放回桌面,让每个人都看见真相的边缘。`, + `${outline.turning_point}空气安静下来,对手的表情第一次失控。${idea.protagonist_name}知道自己只是赢下第一步,真正的局还藏在更深处。`, + `${outline.ending_hook}她合上电脑,抬头看向玻璃门外的倒影,那里有一个熟悉却不该出现的人。` + ].join('\n\n'); + + return { + chapter_no: outline.chapter_no, + title: outline.title, + content, + summary: `${outline.goal}${outline.turning_point}`, + visual_summary: `${outline.title}:${outline.conflict}${outline.ending_hook}`, + word_count: this.parser.countWords(content) + }; + } + + private buildSelfCheck( + idea: OriginalIdea, + chapters: NovelChapter[] + ): OriginalSelfCheckResult { + const fullText = chapters.map((chapter) => chapter.content).join('\n'); + const checks = [ + { + key: 'character_consistency', + passed: fullText.includes(idea.protagonist_name), + message: '主角姓名在章节中保持一致。' + }, + { + key: 'clear_main_line', + passed: chapters.length > 0 && Boolean(idea.core_conflict), + message: '主线目标和核心冲突已建立。' + }, + { + key: 'strong_conflict', + passed: chapters.every((chapter) => (chapter.summary ?? '').length > 10), + message: '每章保留冲突和转折摘要。' + }, + { + key: 'visual_ready', + passed: chapters.every((chapter) => (chapter.visual_summary ?? '').length > 10), + message: '每章包含可视化场景摘要。' + }, + { + key: 'short_video_hook', + passed: chapters.every((chapter) => chapter.content.includes('钩子') || chapter.content.includes('出现')), + message: '章节结尾保留短视频改编钩子。' + } + ]; + const passedCount = checks.filter((check) => check.passed).length; + + return { + passed: passedCount === checks.length, + score: Math.round((passedCount / checks.length) * 100), + checks + }; + } + + private readReport(source: NovelSource): OriginalNovelReport { + const report = source.parse_report; + + if (!report || typeof report !== 'object' || Array.isArray(report)) { + throw new BadRequestException('Original source report is invalid'); + } + + return report as unknown as OriginalNovelReport; + } + + private async requireOriginalSource(projectId: bigint, sourceId?: string) { + const source = await this.findOriginalSource(projectId, sourceId); + + if (!source) { + throw new NotFoundException('Original novel source not found'); + } + + return source; + } + + private async findOriginalSource(projectId: bigint, sourceId?: string) { + const source = sourceId + ? await this.prisma.novelSource.findUnique({ + where: { id: this.parseId(sourceId, 'Invalid source id') } + }) + : await this.prisma.novelSource.findFirst({ + where: { project_id: projectId, source_type: 'ai_original' }, + orderBy: { created_at: 'desc' } + }); + + if (!source || source.project_id !== projectId || source.source_type !== 'ai_original') { + return null; + } + + return source; + } + + private async findProjectForUser(projectId: string, user: AuthRequestUser) { + const project = await this.prisma.project.findUnique({ + where: { id: this.parseId(projectId, 'Invalid project id') } + }); + + if (!project) { + throw new NotFoundException('Project not found'); + } + + if (project.user_id.toString() !== user.id && user.role !== 'admin') { + throw new ForbiddenException('Project is private'); + } + + return project; + } + + private assertOriginalProject(project: Project) { + if (project.input_mode !== 'ai_original') { + throw new BadRequestException('Original novel mock is only available for ai_original projects'); + } + } + + private resolveChapterCount(value: number | undefined, project: Project) { + const numberValue = Number(value ?? project.target_episode_count ?? 3); + + if ( + !Number.isInteger(numberValue) || + numberValue < MIN_MOCK_CHAPTERS || + numberValue > MAX_MOCK_CHAPTERS + ) { + throw new BadRequestException( + `target_chapter_count must be an integer between ${MIN_MOCK_CHAPTERS} and ${MAX_MOCK_CHAPTERS}` + ); + } + + return numberValue; + } + + private splitList(value: string | undefined, fallback: string[]) { + const items = value + ?.split(/[,\n,、]/) + .map((item) => item.trim()) + .filter(Boolean); + + return items?.length ? items : fallback; + } + + private titleForGenre(genre: string) { + const titles: Record = { + urban_rebirth: '重生归来,我只搞事业', + revenge: '她把旧账一笔笔讨回', + sweet_romance: '合约到期前心动了', + fantasy: '灵脉重启之后' + }; + + return titles[genre] ?? '原创漫剧项目'; + } + + private normalizeOptionalText(value: string | undefined) { + const normalized = value?.trim(); + return normalized || undefined; + } + + private parseId(id: string, message: string) { + try { + return BigInt(id); + } catch { + throw new BadRequestException(message); + } + } +} diff --git a/backend/src/novels/original-novel.dto.ts b/backend/src/novels/original-novel.dto.ts new file mode 100644 index 0000000..06d99d0 --- /dev/null +++ b/backend/src/novels/original-novel.dto.ts @@ -0,0 +1,26 @@ +export class GenerateOriginalIdeaDto { + title?: string; + genre?: string; + target_audience?: string; + protagonist_name?: string; + protagonist_setting?: string; + story_mood?: string; + selling_points?: string; + world_setting?: string; + taboo_rules?: string; + target_chapter_count?: number; +} + +export class GenerateOriginalOutlineDto { + source_id?: string; + target_chapter_count?: number; +} + +export class GenerateOriginalChaptersDto { + source_id?: string; + target_chapter_count?: number; +} + +export class OriginalSelfCheckDto { + source_id?: string; +} diff --git a/backend/src/novels/original-novel.types.ts b/backend/src/novels/original-novel.types.ts new file mode 100644 index 0000000..3fb6f6c --- /dev/null +++ b/backend/src/novels/original-novel.types.ts @@ -0,0 +1,48 @@ +export interface OriginalIdea { + title: string; + genre: string; + target_audience: string; + protagonist_name: string; + protagonist_setting: string; + story_mood: string; + selling_points: string[]; + world_setting: string; + logline: string; + core_conflict: string; + visual_hooks: string[]; +} + +export interface OriginalOutlineChapter { + chapter_no: number; + title: string; + goal: string; + conflict: string; + turning_point: string; + ending_hook: string; +} + +export interface OriginalOutline { + main_plot: string; + chapter_count: number; + chapters: OriginalOutlineChapter[]; +} + +export interface OriginalSelfCheckResult { + passed: boolean; + score: number; + checks: Array<{ + key: string; + passed: boolean; + message: string; + }>; +} + +export interface OriginalNovelReport { + provider: 'mock_novel_provider'; + mode: 'ai_original'; + stage: 'idea' | 'outline' | 'chapters' | 'self_check'; + idea?: OriginalIdea; + outline?: OriginalOutline; + self_check?: OriginalSelfCheckResult; + warnings?: string[]; +} diff --git a/backend/src/novels/original-novels.controller.ts b/backend/src/novels/original-novels.controller.ts new file mode 100644 index 0000000..17e681f --- /dev/null +++ b/backend/src/novels/original-novels.controller.ts @@ -0,0 +1,65 @@ +import { Body, Controller, Get, Inject, Param, Post, Query, UseGuards } from '@nestjs/common'; +import { CurrentUser } from '../auth/current-user.decorator'; +import type { AuthRequestUser } from '../auth/auth.types'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { + GenerateOriginalChaptersDto, + GenerateOriginalIdeaDto, + GenerateOriginalOutlineDto, + OriginalSelfCheckDto +} from './original-novel.dto'; +import { OriginalNovelMockService } from './original-novel-mock.service'; + +@Controller('projects/:projectId/original') +@UseGuards(JwtAuthGuard) +export class OriginalNovelsController { + constructor( + @Inject(OriginalNovelMockService) + private readonly originalNovelMockService: OriginalNovelMockService + ) {} + + @Post('idea') + generateIdea( + @CurrentUser() user: AuthRequestUser, + @Param('projectId') projectId: string, + @Body() dto: GenerateOriginalIdeaDto + ) { + return this.originalNovelMockService.generateIdea(user, projectId, dto); + } + + @Post('outline') + generateOutline( + @CurrentUser() user: AuthRequestUser, + @Param('projectId') projectId: string, + @Body() dto: GenerateOriginalOutlineDto + ) { + return this.originalNovelMockService.generateOutline(user, projectId, dto); + } + + @Post('chapters') + generateChapters( + @CurrentUser() user: AuthRequestUser, + @Param('projectId') projectId: string, + @Body() dto: GenerateOriginalChaptersDto + ) { + return this.originalNovelMockService.generateChapters(user, projectId, dto); + } + + @Post('self-check') + selfCheck( + @CurrentUser() user: AuthRequestUser, + @Param('projectId') projectId: string, + @Body() dto: OriginalSelfCheckDto + ) { + return this.originalNovelMockService.selfCheck(user, projectId, dto); + } + + @Get('result') + getResult( + @CurrentUser() user: AuthRequestUser, + @Param('projectId') projectId: string, + @Query('source_id') sourceId?: string + ) { + return this.originalNovelMockService.getResult(user, projectId, sourceId); + } +} diff --git a/backend/src/prisma/prisma.module.ts b/backend/src/prisma/prisma.module.ts new file mode 100644 index 0000000..d80c9f3 --- /dev/null +++ b/backend/src/prisma/prisma.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService] +}) +export class PrismaModule {} diff --git a/backend/src/prisma/prisma.service.ts b/backend/src/prisma/prisma.service.ts new file mode 100644 index 0000000..6ed66a2 --- /dev/null +++ b/backend/src/prisma/prisma.service.ts @@ -0,0 +1,9 @@ +import { Injectable, OnModuleDestroy } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleDestroy { + async onModuleDestroy() { + await this.$disconnect(); + } +} diff --git a/backend/src/projects/project.dto.ts b/backend/src/projects/project.dto.ts new file mode 100644 index 0000000..afbedd6 --- /dev/null +++ b/backend/src/projects/project.dto.ts @@ -0,0 +1,41 @@ +import type { InputMode, OutputMode } from './project.types'; + +export class CreateProjectDto { + title?: string; + input_mode?: InputMode; + genre?: string; + style_code?: string; + output_type?: string; + output_mode?: OutputMode; + visual_mode?: string; + video_generation_level?: string; + target_episode_count?: number; + episode_duration?: number; + quality_level?: string; + is_long_series?: boolean; + creative_pattern_ids?: Array; +} + +export class UpdateProjectDto { + title?: string; + genre?: string; + style_code?: string; + output_type?: string; + output_mode?: OutputMode; + visual_mode?: string; + video_generation_level?: string; + target_episode_count?: number; + episode_duration?: number; + quality_level?: string; + is_long_series?: boolean; +} + +export class ListCreativePatternsQueryDto { + pattern_type?: string; + genre?: string; + limit?: string; +} + +export class UpdateProjectCreativePatternsDto { + creative_pattern_ids?: Array; +} diff --git a/backend/src/projects/project.types.ts b/backend/src/projects/project.types.ts new file mode 100644 index 0000000..dbbadac --- /dev/null +++ b/backend/src/projects/project.types.ts @@ -0,0 +1,136 @@ +import type { CreativePattern, Project, ProjectCreativePattern } from '@prisma/client'; + +export const INPUT_MODES = ['ai_original', 'upload', 'admin_import'] as const; +export const OUTPUT_MODES = ['image_manga', 'motion_comic', 'live_action_ai'] as const; +export const PROJECT_STATUSES = [ + 'draft', + 'source_selecting', + 'novel_generating', + 'novel_uploaded', + 'copyright_pending', + 'copyright_confirmed', + 'text_parsing', + 'text_parse_failed', + 'story_analyzing', + 'story_bible_generating', + 'waiting_story_confirm', + 'story_confirmed', + 'character_extracting', + 'character_generating', + 'character_image_generating', + 'waiting_character_confirm', + 'character_confirmed', + 'episode_planning', + 'waiting_episode_confirm', + 'episode_confirmed', + 'script_generating', + 'waiting_script_confirm', + 'script_confirmed', + 'storyboard_generating', + 'waiting_storyboard_confirm', + 'storyboard_confirmed', + 'character_image_generated', + 'preview_images_generated', + 'final_images_generated', + 'audio_generated', + 'subtitle_generated', + 'video_rendered', + 'actor_profile_generated', + 'live_action_shots_prepared', + 'live_action_keyframes_generated', + 'live_action_clips_generated', + 'live_action_video_rendered', + 'completed', + 'manual_required', + 'failed', + 'cancelled', + 'archived' +] as const; + +export type InputMode = (typeof INPUT_MODES)[number]; +export type OutputMode = (typeof OUTPUT_MODES)[number]; + +export interface SafeProject { + id: string; + user_id: string; + title: string | null; + input_mode: string; + genre: string | null; + style_code: string | null; + output_type: string | null; + output_mode: string; + visual_mode: string | null; + video_generation_level: string | null; + target_episode_count: number | null; + episode_duration: number | null; + status: string; + copyright_status: string; + payment_status: string; + quality_level: string | null; + is_long_series: boolean; + created_at: string; + updated_at: string; + completed_at: string | null; +} + +export function toSafeProject(project: Project): SafeProject { + return { + id: project.id.toString(), + user_id: project.user_id.toString(), + title: project.title, + input_mode: project.input_mode, + genre: project.genre, + style_code: project.style_code, + output_type: project.output_type, + output_mode: project.output_mode, + visual_mode: project.visual_mode, + video_generation_level: project.video_generation_level, + target_episode_count: project.target_episode_count, + episode_duration: project.episode_duration, + status: project.status, + copyright_status: project.copyright_status, + payment_status: project.payment_status, + quality_level: project.quality_level, + is_long_series: project.is_long_series, + created_at: project.created_at.toISOString(), + updated_at: project.updated_at.toISOString(), + completed_at: project.completed_at?.toISOString() ?? null + }; +} + +export function toSafeCreativePattern(pattern: CreativePattern) { + return { + id: pattern.id.toString(), + source_case_id: pattern.source_case_id?.toString() ?? null, + pattern_type: pattern.pattern_type, + title: pattern.title, + genre: pattern.genre, + language: pattern.language, + description: pattern.description, + structure_json: pattern.structure_json, + prompt_template: pattern.prompt_template, + negative_prompt: pattern.negative_prompt, + tags_json: pattern.tags_json, + usage_count: pattern.usage_count, + effectiveness_score: pattern.effectiveness_score ? Number(pattern.effectiveness_score.toString()) : null, + status: pattern.status, + created_at: pattern.created_at.toISOString(), + updated_at: pattern.updated_at.toISOString() + }; +} + +export function toSafeProjectCreativePattern( + binding: ProjectCreativePattern, + pattern: CreativePattern | null +) { + return { + id: binding.id.toString(), + project_id: binding.project_id.toString(), + creative_pattern_id: binding.creative_pattern_id.toString(), + source: binding.source, + snapshot_json: binding.snapshot_json, + sort_order: binding.sort_order, + created_at: binding.created_at.toISOString(), + pattern: pattern ? toSafeCreativePattern(pattern) : null + }; +} diff --git a/backend/src/projects/projects.controller.ts b/backend/src/projects/projects.controller.ts new file mode 100644 index 0000000..5601725 --- /dev/null +++ b/backend/src/projects/projects.controller.ts @@ -0,0 +1,84 @@ +import { + Body, + Controller, + Delete, + Get, + Inject, + Param, + Patch, + Post, + Query, + UseGuards +} from '@nestjs/common'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import type { AuthRequestUser } from '../auth/auth.types'; +import { + CreateProjectDto, + ListCreativePatternsQueryDto, + UpdateProjectCreativePatternsDto, + UpdateProjectDto +} from './project.dto'; +import { ProjectsService } from './projects.service'; + +@Controller('projects') +@UseGuards(JwtAuthGuard) +export class ProjectsController { + constructor(@Inject(ProjectsService) private readonly projectsService: ProjectsService) {} + + @Post() + createProject(@CurrentUser() user: AuthRequestUser, @Body() dto: CreateProjectDto) { + return this.projectsService.createProject(user, dto); + } + + @Get() + listMine(@CurrentUser() user: AuthRequestUser) { + return this.projectsService.listMine(user); + } + + @Get('creative-patterns/library') + listCreativePatternLibrary( + @CurrentUser() user: AuthRequestUser, + @Query() query: ListCreativePatternsQueryDto + ) { + return this.projectsService.listCreativePatternLibrary(user, query); + } + + @Get(':id') + getProject(@CurrentUser() user: AuthRequestUser, @Param('id') id: string) { + return this.projectsService.getProjectForUser(id, user); + } + + @Get(':id/creative-patterns') + listProjectCreativePatterns(@CurrentUser() user: AuthRequestUser, @Param('id') id: string) { + return this.projectsService.listProjectCreativePatterns(user, id); + } + + @Patch(':id') + updateProject( + @CurrentUser() user: AuthRequestUser, + @Param('id') id: string, + @Body() dto: UpdateProjectDto + ) { + return this.projectsService.updateProject(user, id, dto); + } + + @Patch(':id/creative-patterns') + updateProjectCreativePatterns( + @CurrentUser() user: AuthRequestUser, + @Param('id') id: string, + @Body() dto: UpdateProjectCreativePatternsDto + ) { + return this.projectsService.updateProjectCreativePatterns(user, id, dto); + } + + @Post(':id/cancel') + cancelProject(@CurrentUser() user: AuthRequestUser, @Param('id') id: string) { + return this.projectsService.cancelProject(user, id); + } + + @Delete(':id') + deleteProject(@CurrentUser() user: AuthRequestUser, @Param('id') id: string) { + return this.projectsService.deleteProject(user, id); + } +} diff --git a/backend/src/projects/projects.module.ts b/backend/src/projects/projects.module.ts new file mode 100644 index 0000000..02db618 --- /dev/null +++ b/backend/src/projects/projects.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { ProjectsController } from './projects.controller'; +import { ProjectsService } from './projects.service'; + +@Module({ + imports: [AuthModule], + controllers: [ProjectsController], + providers: [ProjectsService], + exports: [ProjectsService] +}) +export class ProjectsModule {} diff --git a/backend/src/projects/projects.service.spec.ts b/backend/src/projects/projects.service.spec.ts new file mode 100644 index 0000000..6d94172 --- /dev/null +++ b/backend/src/projects/projects.service.spec.ts @@ -0,0 +1,219 @@ +import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { PrismaService } from '../prisma/prisma.service'; +import type { AuthRequestUser } from '../auth/auth.types'; +import { ProjectsService } from './projects.service'; + +const user: AuthRequestUser = { + id: '1', + email: 'user@example.com', + role: 'user' +}; + +function createProject(overrides: Record = {}) { + return { + id: 10n, + user_id: 1n, + title: '测试项目', + input_mode: 'upload', + genre: 'urban_rebirth', + style_code: 'korean_comic', + output_type: 'short_video', + output_mode: 'image_manga', + visual_mode: 'korean_manga', + video_generation_level: 'standard', + target_episode_count: 1, + episode_duration: 60, + status: 'source_selecting', + copyright_status: 'pending', + payment_status: 'unpaid', + quality_level: 'mvp', + is_long_series: false, + created_at: new Date('2026-05-31T00:00:00.000Z'), + updated_at: new Date('2026-05-31T00:00:00.000Z'), + completed_at: null, + ...overrides + }; +} + +function createCreativePattern(overrides: Record = {}) { + return { + id: 100n, + source_case_id: null, + pattern_type: 'opening_hook', + title: '退婚开场钩子', + genre: 'urban_rebirth', + language: 'zh-CN', + description: '前 8 秒给出关系破裂和证据反击。', + structure_json: {}, + prompt_template: '写一个退婚现场开场钩子。', + negative_prompt: '拖慢铺垫', + tags_json: ['退婚', '打脸'], + usage_count: 0, + effectiveness_score: null, + status: 'active', + created_by_user_id: 1n, + created_at: new Date('2026-05-31T00:00:00.000Z'), + updated_at: new Date('2026-05-31T00:00:00.000Z'), + ...overrides + }; +} + +function createProjectCreativePattern(overrides: Record = {}) { + return { + id: 101n, + project_id: 10n, + creative_pattern_id: 100n, + source: 'user_selected', + snapshot_json: {}, + sort_order: 1, + created_at: new Date('2026-05-31T00:00:00.000Z'), + ...overrides + }; +} + +describe('ProjectsService', () => { + let prisma: { + project: { + create: ReturnType; + findMany: ReturnType; + findUnique: ReturnType; + update: ReturnType; + }; + creativePattern: { + findMany: ReturnType; + count: ReturnType; + updateMany: ReturnType; + }; + projectCreativePattern: { + deleteMany: ReturnType; + createMany: ReturnType; + findMany: ReturnType; + }; + }; + let service: ProjectsService; + + beforeEach(() => { + prisma = { + project: { + create: vi.fn(), + findMany: vi.fn(), + findUnique: vi.fn(), + update: vi.fn() + }, + creativePattern: { + findMany: vi.fn().mockResolvedValue([createCreativePattern()]), + count: vi.fn().mockResolvedValue(1), + updateMany: vi.fn().mockResolvedValue({ count: 1 }) + }, + projectCreativePattern: { + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + createMany: vi.fn().mockResolvedValue({ count: 1 }), + findMany: vi.fn().mockResolvedValue([createProjectCreativePattern()]) + } + }; + service = new ProjectsService(prisma as unknown as PrismaService); + }); + + it('creates a project in source_selecting status', async () => { + prisma.project.create.mockResolvedValue(createProject()); + + const result = await service.createProject(user, { + title: ' 测试项目 ', + input_mode: 'upload', + genre: 'urban_rebirth', + target_episode_count: 1, + episode_duration: 60 + }); + + expect(prisma.project.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + user_id: 1n, + title: '测试项目', + input_mode: 'upload', + status: 'source_selecting', + copyright_status: 'pending' + }) + }); + expect(prisma.projectCreativePattern.deleteMany).toHaveBeenCalledWith({ where: { project_id: 10n } }); + expect(result.status).toBe('source_selecting'); + }); + + it('binds selected creative patterns when creating a project', async () => { + prisma.project.create.mockResolvedValue(createProject()); + + await service.createProject(user, { + input_mode: 'ai_original', + creative_pattern_ids: ['100'] + }); + + expect(prisma.creativePattern.findMany).toHaveBeenCalledWith({ + where: { + id: { in: [100n] }, + status: 'active' + }, + orderBy: [{ effectiveness_score: 'desc' }, { updated_at: 'desc' }] + }); + expect(prisma.projectCreativePattern.createMany).toHaveBeenCalledWith({ + data: [ + expect.objectContaining({ + project_id: 10n, + creative_pattern_id: 100n, + source: 'user_selected', + sort_order: 1 + }) + ] + }); + }); + + it('lists creative pattern library and project bindings', async () => { + prisma.project.findUnique.mockResolvedValue(createProject()); + + const library = await service.listCreativePatternLibrary(user, { limit: '10' }); + const bindings = await service.listProjectCreativePatterns(user, '10'); + + expect(library.patterns[0].title).toBe('退婚开场钩子'); + expect(bindings.patterns[0].pattern?.title).toBe('退婚开场钩子'); + }); + + it('lists only current user projects', async () => { + prisma.project.findMany.mockResolvedValue([createProject()]); + + const result = await service.listMine(user); + + expect(prisma.project.findMany).toHaveBeenCalledWith({ + where: { user_id: 1n }, + orderBy: { created_at: 'desc' } + }); + expect(result).toHaveLength(1); + }); + + it('rejects access to projects owned by others', async () => { + prisma.project.findUnique.mockResolvedValue(createProject({ user_id: 2n })); + + await expect(service.getProjectForUser('10', user)).rejects.toBeInstanceOf( + ForbiddenException + ); + }); + + it('cancels an owned project', async () => { + prisma.project.findUnique.mockResolvedValue(createProject()); + prisma.project.update.mockResolvedValue(createProject({ status: 'cancelled' })); + + const result = await service.cancelProject(user, '10'); + + expect(prisma.project.update).toHaveBeenCalledWith({ + where: { id: 10n }, + data: { status: 'cancelled' } + }); + expect(result.status).toBe('cancelled'); + }); + + it('rejects invalid input modes', async () => { + await expect( + service.createProject(user, { + input_mode: 'bad_mode' as never + }) + ).rejects.toBeInstanceOf(BadRequestException); + }); +}); diff --git a/backend/src/projects/projects.service.ts b/backend/src/projects/projects.service.ts new file mode 100644 index 0000000..6cdf035 --- /dev/null +++ b/backend/src/projects/projects.service.ts @@ -0,0 +1,448 @@ +import { + BadRequestException, + ForbiddenException, + Inject, + Injectable, + NotFoundException +} from '@nestjs/common'; +import { Prisma, type CreativePattern, type Project } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import type { AuthRequestUser } from '../auth/auth.types'; +import { + CreateProjectDto, + ListCreativePatternsQueryDto, + UpdateProjectCreativePatternsDto, + UpdateProjectDto +} from './project.dto'; +import { + INPUT_MODES, + OUTPUT_MODES, + toSafeCreativePattern, + toSafeProject, + toSafeProjectCreativePattern +} from './project.types'; + +const MIN_EPISODES = 1; +const MAX_MVP_EPISODES = 100; +const MIN_EPISODE_DURATION = 15; +const MAX_EPISODE_DURATION = 600; +const MAX_PROJECT_PATTERN_COUNT = 8; +const EDITABLE_STATUSES = new Set([ + 'draft', + 'source_selecting', + 'copyright_confirmed', + 'novel_uploaded', + 'text_parse_failed' +]); + +@Injectable() +export class ProjectsService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async createProject(user: AuthRequestUser, dto: CreateProjectDto) { + const inputMode = this.validateInputMode(dto.input_mode); + const episodeCount = this.validatePositiveInt( + dto.target_episode_count ?? (inputMode === 'ai_original' ? 3 : 1), + 'target_episode_count', + MIN_EPISODES, + MAX_MVP_EPISODES + ); + const episodeDuration = this.validatePositiveInt( + dto.episode_duration ?? 60, + 'episode_duration', + MIN_EPISODE_DURATION, + MAX_EPISODE_DURATION + ); + const outputMode = this.validateOutputMode(dto.output_mode); + const patternIds = this.normalizePatternIds(dto.creative_pattern_ids); + const patterns = await this.findActiveCreativePatterns(patternIds); + + const project = await this.prisma.project.create({ + data: { + user_id: BigInt(user.id), + title: this.normalizeOptionalText(dto.title) ?? '未命名漫剧项目', + input_mode: inputMode, + genre: this.normalizeOptionalText(dto.genre), + style_code: this.normalizeOptionalText(dto.style_code) ?? this.defaultStyleCode(outputMode), + output_type: this.normalizeOptionalText(dto.output_type) ?? 'short_video', + output_mode: outputMode, + visual_mode: this.normalizeOptionalText(dto.visual_mode) ?? this.defaultVisualMode(outputMode), + video_generation_level: this.normalizeOptionalText(dto.video_generation_level) ?? this.defaultGenerationLevel(outputMode), + target_episode_count: episodeCount, + episode_duration: episodeDuration, + status: 'source_selecting', + copyright_status: inputMode === 'ai_original' ? 'ai_original' : 'pending', + payment_status: 'unpaid', + quality_level: this.normalizeOptionalText(dto.quality_level) ?? 'mvp', + is_long_series: dto.is_long_series ?? episodeCount > 10 + } + }); + await this.replaceProjectCreativePatterns(project.id, patterns, 'user_selected'); + + return toSafeProject(project); + } + + async listMine(user: AuthRequestUser) { + const projects = await this.prisma.project.findMany({ + where: { user_id: BigInt(user.id) }, + orderBy: { created_at: 'desc' } + }); + + return projects.map(toSafeProject); + } + + async getProjectForUser(projectId: string, user: AuthRequestUser) { + const project = await this.findProjectForUser(projectId, user); + return toSafeProject(project); + } + + async listCreativePatternLibrary(user: AuthRequestUser, query: ListCreativePatternsQueryDto) { + if (!user.id) { + throw new ForbiddenException('Authentication required'); + } + + const where: Prisma.CreativePatternWhereInput = { + status: 'active' + }; + const limit = this.validatePositiveInt(query.limit ?? 30, 'limit', 1, 100); + + if (query.pattern_type) { + where.pattern_type = this.normalizeOptionalText(query.pattern_type); + } + if (query.genre) { + where.genre = this.normalizeOptionalText(query.genre); + } + + const [patterns, total] = await Promise.all([ + this.prisma.creativePattern.findMany({ + where, + orderBy: [{ effectiveness_score: 'desc' }, { usage_count: 'desc' }, { updated_at: 'desc' }], + take: limit + }), + this.prisma.creativePattern.count({ where }) + ]); + + return { + patterns: patterns.map(toSafeCreativePattern), + total, + limit + }; + } + + async listProjectCreativePatterns(user: AuthRequestUser, projectId: string) { + const project = await this.findProjectForUser(projectId, user); + const rows = await this.loadProjectCreativePatternRows(project.id); + + return { + patterns: rows.map((row) => toSafeProjectCreativePattern(row.binding, row.pattern)) + }; + } + + async updateProject(user: AuthRequestUser, projectId: string, dto: UpdateProjectDto) { + const project = await this.findProjectForUser(projectId, user); + + if (!EDITABLE_STATUSES.has(project.status)) { + throw new BadRequestException('Project cannot be edited in current status'); + } + + const data: Partial = {}; + + if ('title' in dto) data.title = this.normalizeOptionalText(dto.title) ?? project.title; + if ('genre' in dto) data.genre = this.normalizeOptionalText(dto.genre) ?? null; + if ('style_code' in dto) data.style_code = this.normalizeOptionalText(dto.style_code) ?? null; + if ('output_type' in dto) data.output_type = this.normalizeOptionalText(dto.output_type) ?? null; + if ('output_mode' in dto) { + data.output_mode = this.validateOutputMode(dto.output_mode); + data.visual_mode = this.normalizeOptionalText(dto.visual_mode) ?? this.defaultVisualMode(data.output_mode); + data.video_generation_level = + this.normalizeOptionalText(dto.video_generation_level) ?? this.defaultGenerationLevel(data.output_mode); + } else { + if ('visual_mode' in dto) data.visual_mode = this.normalizeOptionalText(dto.visual_mode) ?? null; + if ('video_generation_level' in dto) { + data.video_generation_level = this.normalizeOptionalText(dto.video_generation_level) ?? null; + } + } + if ('quality_level' in dto) { + data.quality_level = this.normalizeOptionalText(dto.quality_level) ?? null; + } + if ('target_episode_count' in dto) { + data.target_episode_count = this.validatePositiveInt( + dto.target_episode_count, + 'target_episode_count', + MIN_EPISODES, + MAX_MVP_EPISODES + ); + data.is_long_series = dto.is_long_series ?? data.target_episode_count > 10; + } else if ('is_long_series' in dto) { + data.is_long_series = Boolean(dto.is_long_series); + } + if ('episode_duration' in dto) { + data.episode_duration = this.validatePositiveInt( + dto.episode_duration, + 'episode_duration', + MIN_EPISODE_DURATION, + MAX_EPISODE_DURATION + ); + } + + const updated = await this.prisma.project.update({ + where: { id: project.id }, + data + }); + + return toSafeProject(updated); + } + + async updateProjectCreativePatterns( + user: AuthRequestUser, + projectId: string, + dto: UpdateProjectCreativePatternsDto + ) { + const project = await this.findProjectForUser(projectId, user); + + if (['completed', 'cancelled', 'archived'].includes(project.status)) { + throw new BadRequestException('Project creative patterns cannot be edited in current status'); + } + + const patternIds = this.normalizePatternIds(dto.creative_pattern_ids); + const patterns = await this.findActiveCreativePatterns(patternIds); + await this.replaceProjectCreativePatterns(project.id, patterns, 'user_selected'); + + return this.listProjectCreativePatterns(user, projectId); + } + + async cancelProject(user: AuthRequestUser, projectId: string) { + const project = await this.findProjectForUser(projectId, user); + + if (project.status === 'completed' || project.status === 'archived') { + throw new BadRequestException('Completed or archived projects cannot be cancelled'); + } + + const updated = await this.prisma.project.update({ + where: { id: project.id }, + data: { status: 'cancelled' } + }); + + return toSafeProject(updated); + } + + async deleteProject(user: AuthRequestUser, projectId: string) { + const project = await this.findProjectForUser(projectId, user); + + if (!['draft', 'source_selecting', 'cancelled'].includes(project.status)) { + throw new BadRequestException('Only draft, source_selecting, or cancelled projects can be deleted'); + } + + const updated = await this.prisma.project.update({ + where: { id: project.id }, + data: { status: 'archived' } + }); + + return toSafeProject(updated); + } + + async assertProjectOwner(projectId: string, user: AuthRequestUser) { + const project = await this.findProjectForUser(projectId, user); + return project.id; + } + + private async findProjectForUser(projectId: string, user: AuthRequestUser) { + const id = this.parseId(projectId); + const project = await this.prisma.project.findUnique({ + where: { id } + }); + + if (!project) { + throw new NotFoundException('Project not found'); + } + + if (project.user_id.toString() !== user.id && user.role !== 'admin') { + throw new ForbiddenException('Project is private'); + } + + return project; + } + + private validateInputMode(inputMode: string | undefined) { + if (!inputMode || !INPUT_MODES.includes(inputMode as never)) { + throw new BadRequestException('input_mode must be ai_original or upload'); + } + + if (inputMode === 'admin_import') { + throw new BadRequestException('admin_import is reserved for later admin workflows'); + } + + return inputMode; + } + + private validateOutputMode(outputMode: string | undefined) { + const value = outputMode || 'image_manga'; + + if (!OUTPUT_MODES.includes(value as never)) { + throw new BadRequestException('output_mode must be image_manga, motion_comic, or live_action_ai'); + } + + return value; + } + + private defaultStyleCode(outputMode: string) { + return outputMode === 'live_action_ai' ? 'photorealistic_short_drama' : 'korean_comic'; + } + + private defaultVisualMode(outputMode: string) { + if (outputMode === 'live_action_ai') return 'photorealistic_short_drama'; + if (outputMode === 'motion_comic') return 'motion_comic'; + return 'korean_manga'; + } + + private defaultGenerationLevel(outputMode: string) { + return outputMode === 'live_action_ai' ? 'mock' : 'standard'; + } + + private validatePositiveInt( + value: unknown, + field: string, + min: number, + max: number + ) { + const numberValue = Number(value); + + if (!Number.isInteger(numberValue) || numberValue < min || numberValue > max) { + throw new BadRequestException(`${field} must be an integer between ${min} and ${max}`); + } + + return numberValue; + } + + private normalizePatternIds(value: unknown) { + if (value === undefined || value === null || value === '') { + return []; + } + if (!Array.isArray(value)) { + throw new BadRequestException('creative_pattern_ids must be an array'); + } + + const ids = [...new Set(value.map((item) => String(item).trim()).filter(Boolean))]; + if (ids.length > MAX_PROJECT_PATTERN_COUNT) { + throw new BadRequestException(`creative_pattern_ids can include at most ${MAX_PROJECT_PATTERN_COUNT} items`); + } + + return ids.map((id) => this.parseId(id)); + } + + private async findActiveCreativePatterns(patternIds: bigint[]) { + if (patternIds.length === 0) { + return []; + } + + const patterns = await this.prisma.creativePattern.findMany({ + where: { + id: { in: patternIds }, + status: 'active' + }, + orderBy: [{ effectiveness_score: 'desc' }, { updated_at: 'desc' }] + }); + + if (patterns.length !== patternIds.length) { + throw new BadRequestException('Some creative patterns are unavailable'); + } + + const order = new Map(patternIds.map((id, index) => [id.toString(), index])); + + return [...patterns].sort( + (left, right) => (order.get(left.id.toString()) ?? 0) - (order.get(right.id.toString()) ?? 0) + ); + } + + private async replaceProjectCreativePatterns( + projectId: bigint, + patterns: CreativePattern[], + source: string + ) { + await this.prisma.projectCreativePattern.deleteMany({ where: { project_id: projectId } }); + + if (patterns.length === 0) { + return; + } + + await this.prisma.projectCreativePattern.createMany({ + data: patterns.map((pattern, index) => ({ + project_id: projectId, + creative_pattern_id: pattern.id, + source, + sort_order: index + 1, + snapshot_json: this.creativePatternSnapshot(pattern) + })) + }); + await this.prisma.creativePattern.updateMany({ + where: { id: { in: patterns.map((pattern) => pattern.id) } }, + data: { usage_count: { increment: 1 } } + }); + } + + private async loadProjectCreativePatternRows(projectId: bigint) { + const bindings = await this.prisma.projectCreativePattern.findMany({ + where: { project_id: projectId }, + orderBy: [{ sort_order: 'asc' }, { id: 'asc' }] + }); + const patterns = bindings.length > 0 + ? await this.prisma.creativePattern.findMany({ + where: { id: { in: bindings.map((binding) => binding.creative_pattern_id) } } + }) + : []; + const patternMap = new Map(patterns.map((pattern) => [pattern.id.toString(), pattern])); + + return bindings.map((binding) => ({ + binding, + pattern: patternMap.get(binding.creative_pattern_id.toString()) ?? null + })); + } + + private creativePatternSnapshot(pattern: CreativePattern): Prisma.InputJsonValue { + return { + id: pattern.id.toString(), + pattern_type: pattern.pattern_type, + title: pattern.title, + genre: pattern.genre ?? '', + language: pattern.language, + description: pattern.description ?? '', + prompt_template: pattern.prompt_template ?? '', + negative_prompt: pattern.negative_prompt ?? '', + tags_json: this.toJsonSafeValue(pattern.tags_json), + effectiveness_score: pattern.effectiveness_score ? Number(pattern.effectiveness_score.toString()) : null + }; + } + + private toJsonSafeValue(value: unknown): Prisma.InputJsonValue { + if (value === null || value === undefined) return ''; + if (typeof value === 'string' || typeof value === 'boolean') return value; + if (typeof value === 'number') return Number.isFinite(value) ? value : 0; + if (Array.isArray(value)) return value.map((item) => this.toJsonSafeValue(item)); + if (typeof value === 'object') { + const output: Record = {}; + + for (const [key, child] of Object.entries(value as Record)) { + if (child !== undefined) { + output[key] = this.toJsonSafeValue(child); + } + } + + return output as Prisma.InputJsonObject; + } + + return String(value); + } + + private normalizeOptionalText(value: string | undefined) { + const normalized = value?.trim(); + return normalized || undefined; + } + + private parseId(id: string) { + try { + return BigInt(id); + } catch { + throw new BadRequestException('Invalid project id'); + } + } +} diff --git a/backend/src/providers/provider.dto.ts b/backend/src/providers/provider.dto.ts new file mode 100644 index 0000000..667b111 --- /dev/null +++ b/backend/src/providers/provider.dto.ts @@ -0,0 +1,64 @@ +import type { ProviderMode, ProviderType } from './provider.types'; + +export class ExecuteProviderDto { + provider_type?: ProviderType; + preferred_provider_code?: string; + purpose?: string; + project_id?: string; + task_id?: string; + input_json?: unknown; + allow_fallback?: boolean; + return_binary?: boolean; + confirm_paid_test?: boolean; +} + +export class ListProvidersQueryDto { + provider_type?: ProviderType; + mode?: ProviderMode; + is_enabled?: string; +} + +export class UpdateProviderConfigDto { + display_name?: string; + mode?: ProviderMode; + model_name?: string; + config_json?: unknown; + fallback_provider_id?: string | null; + is_enabled?: boolean; + priority?: number; + rate_limit_json?: unknown; + cost_rule_json?: unknown; +} + +export class UpdateProviderRuntimeConfigDto { + api_key?: string; + clear_api_key?: boolean; + sync_api_key_to_same_env?: boolean; + base_url?: string | null; + model_name?: string | null; + timeout_ms?: number | string | null; + max_cost_per_call?: number | string | null; + daily_cost_limit?: number | string | null; + is_enabled?: boolean; + priority?: number | string; +} + +export class UpdateOpenAiRuntimeConfigDto { + api_key?: string; + clear_api_key?: boolean; + base_url?: string | null; + timeout_ms?: number | string | null; + max_cost_per_call?: number | string | null; + daily_cost_limit?: number | string | null; + is_enabled?: boolean; + prefer_openai?: boolean; +} + +export class ProviderLogsQueryDto { + provider_type?: ProviderType; + provider_code?: string; + status?: string; + project_id?: string; + task_id?: string; + limit?: string; +} diff --git a/backend/src/providers/provider.types.ts b/backend/src/providers/provider.types.ts new file mode 100644 index 0000000..fdaafca --- /dev/null +++ b/backend/src/providers/provider.types.ts @@ -0,0 +1,1446 @@ +import type { Prisma, ProviderConfig, ProviderLog } from '@prisma/client'; + +export const PROVIDER_TYPES = [ + 'TextProvider', + 'NovelProvider', + 'ImageProvider', + 'VideoProvider', + 'LipSyncProvider', + 'VoiceProvider', + 'ModerationProvider', + 'QualityCheckProvider', + 'FileParseProvider', + 'EmbeddingProvider' +] as const; + +export const PROVIDER_MODES = ['mock', 'real'] as const; +export const PROVIDER_LOG_STATUSES = ['success', 'failed'] as const; + +export type ProviderType = (typeof PROVIDER_TYPES)[number]; +export type ProviderMode = (typeof PROVIDER_MODES)[number]; +export type ProviderLogStatus = (typeof PROVIDER_LOG_STATUSES)[number]; + +export interface DefaultMockProviderConfig { + provider_type: ProviderType; + provider_code: string; + display_name: string; + mode: ProviderMode; + model_name: string; + priority: number; + is_enabled?: boolean; + config_json: Prisma.InputJsonValue; + rate_limit_json: Prisma.InputJsonValue; + cost_rule_json: Prisma.InputJsonValue; +} + +export const DEFAULT_MOCK_PROVIDER_CONFIGS: DefaultMockProviderConfig[] = [ + { + provider_type: 'TextProvider', + provider_code: 'mock-text', + display_name: 'Mock Text Provider', + mode: 'mock', + model_name: 'mock-text-v1', + priority: 100, + config_json: { note: 'Deterministic mock text generation.' }, + rate_limit_json: { rpm: 600, concurrency: 20 }, + cost_rule_json: { flat_cost: 0, unit: 'mock' } + }, + { + provider_type: 'NovelProvider', + provider_code: 'mock-novel', + display_name: 'Mock Novel Provider', + mode: 'mock', + model_name: 'mock-novel-v1', + priority: 100, + config_json: { note: 'Deterministic mock novel generation.' }, + rate_limit_json: { rpm: 120, concurrency: 8 }, + cost_rule_json: { flat_cost: 0, unit: 'mock' } + }, + { + provider_type: 'ImageProvider', + provider_code: 'mock-image', + display_name: 'Mock Image Provider', + mode: 'mock', + model_name: 'mock-image-v1', + priority: 100, + config_json: { note: 'Returns mock image asset placeholders.' }, + rate_limit_json: { rpm: 120, concurrency: 8 }, + cost_rule_json: { flat_cost: 0, unit: 'mock' } + }, + { + provider_type: 'VideoProvider', + provider_code: 'mock-video', + display_name: 'Mock Video Provider', + mode: 'mock', + model_name: 'mock-video-v1', + priority: 100, + config_json: { note: 'Returns mock video asset placeholders.' }, + rate_limit_json: { rpm: 60, concurrency: 4 }, + cost_rule_json: { flat_cost: 0, unit: 'mock' } + }, + { + provider_type: 'VoiceProvider', + provider_code: 'mock-voice', + display_name: 'Mock Voice Provider', + mode: 'mock', + model_name: 'mock-voice-v1', + priority: 100, + config_json: { note: 'Returns mock TTS asset placeholders.' }, + rate_limit_json: { rpm: 180, concurrency: 10 }, + cost_rule_json: { flat_cost: 0, unit: 'mock' } + }, + { + provider_type: 'LipSyncProvider', + provider_code: 'mock-lipsync', + display_name: 'Mock Lip Sync Provider', + mode: 'mock', + model_name: 'mock-lipsync-v1', + priority: 100, + is_enabled: false, + config_json: { + note: 'Mock lip-sync provider for pipeline tests. It does not improve mouth motion.' + }, + rate_limit_json: { rpm: 60, concurrency: 2 }, + cost_rule_json: { flat_cost: 0, unit: 'mock' } + }, + { + provider_type: 'ModerationProvider', + provider_code: 'mock-moderation', + display_name: 'Mock Moderation Provider', + mode: 'mock', + model_name: 'mock-moderation-v1', + priority: 100, + config_json: { note: 'Deterministic content moderation mock.' }, + rate_limit_json: { rpm: 600, concurrency: 20 }, + cost_rule_json: { flat_cost: 0, unit: 'mock' } + }, + { + provider_type: 'QualityCheckProvider', + provider_code: 'mock-qc', + display_name: 'Mock Quality Check Provider', + mode: 'mock', + model_name: 'mock-qc-v1', + priority: 100, + config_json: { note: 'Deterministic image/video quality mock.' }, + rate_limit_json: { rpm: 240, concurrency: 12 }, + cost_rule_json: { flat_cost: 0, unit: 'mock' } + }, + { + provider_type: 'FileParseProvider', + provider_code: 'mock-file-parse', + display_name: 'Mock File Parse Provider', + mode: 'mock', + model_name: 'mock-file-parse-v1', + priority: 100, + config_json: { note: 'Deterministic file parse mock.' }, + rate_limit_json: { rpm: 120, concurrency: 6 }, + cost_rule_json: { flat_cost: 0, unit: 'mock' } + }, + { + provider_type: 'EmbeddingProvider', + provider_code: 'mock-embedding', + display_name: 'Mock Embedding Provider', + mode: 'mock', + model_name: 'mock-embedding-v1', + priority: 100, + config_json: { note: 'Deterministic vector mock.' }, + rate_limit_json: { rpm: 600, concurrency: 20 }, + cost_rule_json: { flat_cost: 0, unit: 'mock' } + } +]; + +const OPENAI_BASE_CONFIG = { + api_key_env: 'OPENAI_API_KEY', + base_url: 'https://api.openai.com/v1', + base_url_env: 'OPENAI_BASE_URL' +}; + +export const DEFAULT_OPENAI_PROVIDER_CONFIGS: DefaultMockProviderConfig[] = [ + { + provider_type: 'TextProvider', + provider_code: 'openai-responses-text', + display_name: 'OpenAI Responses Text', + mode: 'real', + model_name: 'gpt-5.5', + priority: 200, + is_enabled: true, + config_json: { + driver: 'openai_responses', + ...OPENAI_BASE_CONFIG, + model_env: 'OPENAI_TEXT_MODEL', + timeout_ms: 60000, + max_output_tokens: 1200, + instructions: '你是中文漫剧创作助手,输出可直接用于项目生产的简洁中文内容。' + }, + rate_limit_json: { rpm: 60, concurrency: 4 }, + cost_rule_json: { + flat_cost: 0, + unit: 'provider_usage_metadata', + note: '真实费用以后续账单或 usage 元数据核算。' + } + }, + { + provider_type: 'NovelProvider', + provider_code: 'openai-responses-novel', + display_name: 'OpenAI Responses Novel', + mode: 'real', + model_name: 'gpt-5.5', + priority: 200, + is_enabled: true, + config_json: { + driver: 'openai_responses', + ...OPENAI_BASE_CONFIG, + model_env: 'OPENAI_NOVEL_MODEL', + timeout_ms: 90000, + max_output_tokens: 3000, + instructions: '你是中文网文和短剧改编作者,保持人设一致、冲突清晰、章节适合继续改编成漫剧。' + }, + rate_limit_json: { rpm: 30, concurrency: 2 }, + cost_rule_json: { + flat_cost: 0, + unit: 'provider_usage_metadata', + note: '真实费用以后续账单或 usage 元数据核算。' + } + }, + { + provider_type: 'ModerationProvider', + provider_code: 'openai-moderation', + display_name: 'OpenAI Moderation', + mode: 'real', + model_name: 'omni-moderation-latest', + priority: 200, + is_enabled: true, + config_json: { + driver: 'openai_moderation', + ...OPENAI_BASE_CONFIG, + model_env: 'OPENAI_MODERATION_MODEL', + timeout_ms: 30000 + }, + rate_limit_json: { rpm: 120, concurrency: 8 }, + cost_rule_json: { + flat_cost: 0, + unit: 'provider_usage_metadata', + note: '真实费用以后续账单或 usage 元数据核算。' + } + }, + { + provider_type: 'EmbeddingProvider', + provider_code: 'openai-embedding', + display_name: 'OpenAI Embedding', + mode: 'real', + model_name: 'text-embedding-3-small', + priority: 200, + is_enabled: true, + config_json: { + driver: 'openai_embeddings', + ...OPENAI_BASE_CONFIG, + model_env: 'OPENAI_EMBEDDING_MODEL', + timeout_ms: 30000 + }, + rate_limit_json: { rpm: 300, concurrency: 12 }, + cost_rule_json: { + flat_cost: 0, + unit: 'provider_usage_metadata', + note: '真实费用以后续账单或 usage 元数据核算。' + } + }, + { + provider_type: 'ImageProvider', + provider_code: 'openai-image', + display_name: 'OpenAI Image Generation', + mode: 'real', + model_name: 'gpt-image-2', + priority: 50, + is_enabled: true, + config_json: { + driver: 'openai_image_generation', + ...OPENAI_BASE_CONFIG, + model_env: 'OPENAI_IMAGE_MODEL', + timeout_ms: 180000, + size: '1024x1536', + quality: 'medium', + output_format: 'png', + moderation: 'auto' + }, + rate_limit_json: { rpm: 20, concurrency: 2 }, + cost_rule_json: { + flat_cost: 0, + unit: 'provider_usage_metadata', + note: '真实费用以后续账单或 usage 元数据核算。默认优先级低于 mock,避免现有图片 mock 流程误调用。' + } + }, + { + provider_type: 'VideoProvider', + provider_code: 'openai-video', + display_name: 'OpenAI Sora Video', + mode: 'real', + model_name: 'sora-2', + priority: 50, + is_enabled: true, + config_json: { + driver: 'openai_video_generation', + ...OPENAI_BASE_CONFIG, + model_env: 'OPENAI_VIDEO_MODEL', + timeout_ms: 60000, + create_endpoint: '/videos', + status_endpoint_template: '/videos/{video_id}', + content_endpoint_template: '/videos/{video_id}/content', + poll_interval_ms: 10000, + max_poll_attempts: 60, + size: '1280x720', + seconds: '8' + }, + rate_limit_json: { rpm: 5, concurrency: 1 }, + cost_rule_json: { + flat_cost: 0, + unit: 'provider_usage_metadata', + note: '真实费用以后续账单或 usage 元数据核算。默认优先级低于 mock,避免现有本地视频合成流程误调用。' + } + }, + { + provider_type: 'VoiceProvider', + provider_code: 'openai-tts', + display_name: 'OpenAI Text to Speech', + mode: 'real', + model_name: 'gpt-4o-mini-tts', + priority: 50, + is_enabled: true, + config_json: { + driver: 'openai_audio_speech', + ...OPENAI_BASE_CONFIG, + model_env: 'OPENAI_TTS_MODEL', + timeout_ms: 60000, + voice: 'coral', + response_format: 'mp3', + instructions: '中文旁白,情绪自然,语速适合短剧解说。' + }, + rate_limit_json: { rpm: 60, concurrency: 3 }, + cost_rule_json: { + flat_cost: 0, + unit: 'provider_usage_metadata', + note: '真实费用以后续账单或 usage 元数据核算。默认优先级低于 mock,避免现有本地静音音频流程误调用。' + } + } +]; + +export const DEFAULT_VIDEO_PROVIDER_CONFIGS: DefaultMockProviderConfig[] = [ + { + provider_type: 'VideoProvider', + provider_code: 'minimax_hailuo_23_fast', + display_name: 'MiniMax Hailuo 2.3 Fast 图生视频', + mode: 'real', + model_name: 'MiniMax-Hailuo-2.3-Fast', + priority: 80, + is_enabled: false, + config_json: { + driver: 'configurable_image_to_video', + error_prefix: 'MINIMAX_VIDEO', + api_key_env: 'MINIMAX_API_KEY', + base_url: 'https://api.minimax.io', + timeout_ms: 300000, + create_endpoint: '/v1/video_generation', + task_endpoint_template: '/v1/query/video_generation?task_id={task_id}', + output_url_endpoint_template: '/v1/files/retrieve?file_id={file_id}', + poll_interval_ms: 10000, + max_poll_attempts: 120, + image_field: 'first_frame_image', + prompt_field: 'prompt', + model_field: 'model', + duration_field: 'duration', + resolution_field: 'resolution', + duration: 6, + allowed_durations: [6, 10], + resolution: '768P', + extra_body_json: { prompt_optimizer: true }, + supports_reference_image: true, + supports_start_end_frame: false, + supports_audio: false, + supports_lipsync: false, + supports_character_reference: false, + note: '默认禁用。优先用于低成本快速验证真人短剧动效;成功后 MiniMax 返回 file_id,系统会再取 download_url 落库。' + }, + rate_limit_json: { rpm: 5, concurrency: 1, retry_limit: 2 }, + cost_rule_json: { + flat_cost: 0, + unit: 'video_seconds', + price_per_second: 0.0317, + currency: 'USD', + max_cost_per_call: 1, + daily_cost_limit: 10, + estimated_seconds: 6, + note: '预估价仅用于后台试算,请按 MiniMax 控制台实时价格和账单调整。' + } + }, + { + provider_type: 'VideoProvider', + provider_code: 'minimax_hailuo_23', + display_name: 'MiniMax Hailuo 2.3 图生视频', + mode: 'real', + model_name: 'MiniMax-Hailuo-2.3', + priority: 78, + is_enabled: false, + config_json: { + driver: 'configurable_image_to_video', + error_prefix: 'MINIMAX_VIDEO', + api_key_env: 'MINIMAX_API_KEY', + base_url: 'https://api.minimax.io', + timeout_ms: 300000, + create_endpoint: '/v1/video_generation', + task_endpoint_template: '/v1/query/video_generation?task_id={task_id}', + output_url_endpoint_template: '/v1/files/retrieve?file_id={file_id}', + poll_interval_ms: 10000, + max_poll_attempts: 120, + image_field: 'first_frame_image', + prompt_field: 'prompt', + model_field: 'model', + duration_field: 'duration', + resolution_field: 'resolution', + duration: 6, + allowed_durations: [6], + resolution: '1080P', + extra_body_json: { prompt_optimizer: true }, + supports_reference_image: true, + supports_start_end_frame: false, + supports_audio: false, + supports_lipsync: false, + supports_character_reference: false, + note: '默认禁用。质量优先于 Fast,适合正式样片对比;真实调用前必须在业务侧确认成本。' + }, + rate_limit_json: { rpm: 5, concurrency: 1, retry_limit: 2 }, + cost_rule_json: { + flat_cost: 0, + unit: 'video_seconds', + price_per_second: 0.0467, + currency: 'USD', + max_cost_per_call: 1.5, + daily_cost_limit: 15, + estimated_seconds: 6, + note: '预估价仅用于后台试算,请按 MiniMax 控制台实时价格和账单调整。' + } + }, + { + provider_type: 'VideoProvider', + provider_code: 'alibaba_wan26_i2v_flash', + display_name: '阿里 Wan2.6 I2V Flash', + mode: 'real', + model_name: 'wan2.6-i2v-flash', + priority: 74, + is_enabled: false, + config_json: { + driver: 'configurable_image_to_video', + error_prefix: 'DASHSCOPE_VIDEO', + api_key_env: 'ALIBABA_DASHSCOPE_API_KEY', + base_url: 'https://dashscope.aliyuncs.com', + timeout_ms: 300000, + create_endpoint: '/api/v1/services/aigc/video-generation/video-synthesis', + task_endpoint_template: '/api/v1/tasks/{task_id}', + poll_interval_ms: 10000, + max_poll_attempts: 120, + body_style: 'dashscope_legacy_i2v', + headers: { 'X-DashScope-Async': 'enable' }, + duration: 5, + resolution: '720P', + prompt_extend: true, + watermark: false, + supports_reference_image: true, + supports_start_end_frame: false, + supports_audio: true, + supports_lipsync: false, + supports_character_reference: false, + note: '默认禁用。阿里/百炼不同模型版本字段可能变化,必要时在高级配置中调整 endpoint、body_style 或 extra_body_json。' + }, + rate_limit_json: { rpm: 5, concurrency: 1, retry_limit: 2 }, + cost_rule_json: { + flat_cost: 0, + unit: 'video_seconds', + price_per_second: 0.0215, + currency: 'USD', + max_cost_per_call: 1, + daily_cost_limit: 10, + estimated_seconds: 5, + note: 'Flash 预估价仅用于试算,请按阿里云/百炼实际账单调整。' + } + }, + { + provider_type: 'VideoProvider', + provider_code: 'alibaba_wan26_i2v', + display_name: '阿里 Wan2.6 I2V 标准', + mode: 'real', + model_name: 'wan2.6-i2v', + priority: 72, + is_enabled: false, + config_json: { + driver: 'configurable_image_to_video', + error_prefix: 'DASHSCOPE_VIDEO', + api_key_env: 'ALIBABA_DASHSCOPE_API_KEY', + base_url: 'https://dashscope.aliyuncs.com', + timeout_ms: 300000, + create_endpoint: '/api/v1/services/aigc/video-generation/video-synthesis', + task_endpoint_template: '/api/v1/tasks/{task_id}', + poll_interval_ms: 10000, + max_poll_attempts: 120, + body_style: 'dashscope_legacy_i2v', + headers: { 'X-DashScope-Async': 'enable' }, + duration: 5, + resolution: '1080P', + prompt_extend: true, + watermark: false, + supports_reference_image: true, + supports_start_end_frame: false, + supports_audio: true, + supports_lipsync: false, + supports_character_reference: false, + note: '默认禁用。标准模式适合正式出片对比;真实启用前请先小样本验证字段、速度和账单。' + }, + rate_limit_json: { rpm: 5, concurrency: 1, retry_limit: 2 }, + cost_rule_json: { + flat_cost: 0, + unit: 'video_seconds', + price_per_second: 0.086, + currency: 'USD', + max_cost_per_call: 2, + daily_cost_limit: 20, + estimated_seconds: 5, + note: '标准模式预估价仅用于试算,请按阿里云/百炼实际账单调整。' + } + }, + { + provider_type: 'VideoProvider', + provider_code: 'vidu_q3_turbo_reference', + display_name: 'Vidu Q3 Turbo 参考图生视频', + mode: 'real', + model_name: 'viduq3-turbo', + priority: 70, + is_enabled: false, + config_json: { + driver: 'configurable_image_to_video', + error_prefix: 'VIDU_VIDEO', + api_key_env: 'VIDU_API_KEY', + base_url: 'https://api.vidu.com', + auth_scheme: 'Token', + timeout_ms: 300000, + create_endpoint: '/ent/v2/reference2video', + task_endpoint_template: '/ent/v2/tasks/{task_id}/creations', + poll_interval_ms: 10000, + max_poll_attempts: 120, + body_style: 'vidu_reference', + duration: 5, + resolution: '720p', + aspect_ratio: '9:16', + extra_body_json: { audio: true, movement_amplitude: 'auto' }, + supports_reference_image: true, + supports_start_end_frame: true, + supports_audio: true, + supports_lipsync: true, + supports_character_reference: true, + note: '默认禁用。适合做人物一致性和中文短剧感对比,参考图需要外部可访问 URL。' + }, + rate_limit_json: { rpm: 5, concurrency: 1, retry_limit: 2 }, + cost_rule_json: { + flat_cost: 0, + unit: 'video_seconds', + price_per_second: 0.05, + currency: 'USD', + max_cost_per_call: 1.5, + daily_cost_limit: 15, + estimated_seconds: 5, + note: 'Vidu Q3 Turbo 预估价仅用于试算,请按 Vidu 控制台实时价格和 credits 消耗调整。' + } + }, + { + provider_type: 'VideoProvider', + provider_code: 'vidu_q3_pro', + display_name: 'Vidu Q3 Pro 参考图生视频', + mode: 'real', + model_name: 'viduq3', + priority: 68, + is_enabled: false, + config_json: { + driver: 'configurable_image_to_video', + error_prefix: 'VIDU_VIDEO', + api_key_env: 'VIDU_API_KEY', + base_url: 'https://api.vidu.com', + auth_scheme: 'Token', + timeout_ms: 300000, + create_endpoint: '/ent/v2/reference2video', + task_endpoint_template: '/ent/v2/tasks/{task_id}/creations', + poll_interval_ms: 10000, + max_poll_attempts: 120, + body_style: 'vidu_reference', + duration: 5, + resolution: '1080p', + aspect_ratio: '9:16', + extra_body_json: { audio: true, movement_amplitude: 'auto' }, + supports_reference_image: true, + supports_start_end_frame: true, + supports_audio: true, + supports_lipsync: true, + supports_character_reference: true, + note: '默认禁用。质量优先,适合正式样片;真实启用前请先限制单次成本。' + }, + rate_limit_json: { rpm: 5, concurrency: 1, retry_limit: 2 }, + cost_rule_json: { + flat_cost: 0, + unit: 'video_seconds', + price_per_second: 0.12, + currency: 'USD', + max_cost_per_call: 3, + daily_cost_limit: 30, + estimated_seconds: 5, + note: 'Vidu Q3 Pro 预估价仅用于试算,请按 Vidu 控制台实时价格和 credits 消耗调整。' + } + }, + { + provider_type: 'VideoProvider', + provider_code: 'jimeng_seedance', + display_name: '即梦/Seedance 图生视频', + mode: 'real', + model_name: 'doubao-seedance-1-5-pro-251215', + priority: 66, + is_enabled: false, + config_json: { + driver: 'configurable_image_to_video', + error_prefix: 'SEEDANCE_VIDEO', + api_key_env: 'VOLCENGINE_API_KEY', + base_url: 'https://ark.cn-beijing.volces.com/api/v3', + timeout_ms: 300000, + create_endpoint: '/videos/generations', + task_endpoint_template: '/videos/generations/{task_id}', + poll_interval_ms: 10000, + max_poll_attempts: 120, + image_field: 'image_url', + prompt_field: 'prompt', + model_field: 'model', + duration_field: 'duration', + resolution_field: 'resolution', + aspect_ratio_field: 'ratio', + duration: 5, + resolution: '720p', + aspect_ratio: '9:16', + extra_body_json: { fps: 24, watermark: false, camerafixed: false }, + supports_reference_image: true, + supports_start_end_frame: true, + supports_audio: true, + supports_lipsync: true, + supports_character_reference: true, + note: '默认禁用。不同火山/即梦/网关 API 字段差异较大,此配置作为可改模板,正式接入前必须用小样本验证。' + }, + rate_limit_json: { rpm: 5, concurrency: 1, retry_limit: 2 }, + cost_rule_json: { + flat_cost: 0, + unit: 'video_seconds', + price_per_second: 0, + currency: 'USD', + max_cost_per_call: 0, + daily_cost_limit: 0, + estimated_seconds: 5, + note: 'Seedance/即梦价格按具体开通渠道差异较大,启用前请手动填写 price_per_second 和成本上限。' + } + }, + { + provider_type: 'VideoProvider', + provider_code: 'runway-image-to-video', + display_name: 'Runway Image-to-Video', + mode: 'real', + model_name: 'gen4.5', + priority: 40, + is_enabled: false, + config_json: { + driver: 'runway_image_to_video', + api_key_env: 'RUNWAYML_API_SECRET', + base_url: 'https://api.dev.runwayml.com', + api_version: '2024-11-06', + timeout_ms: 180000, + create_endpoint: '/v1/image_to_video', + task_endpoint_template: '/v1/tasks/{task_id}', + poll_interval_ms: 10000, + max_poll_attempts: 90, + ratio: '720:1280', + duration: 5, + note: '默认禁用。启用后必须在业务侧显式确认真实视频生成,避免误扣费。' + }, + rate_limit_json: { rpm: 5, concurrency: 1 }, + cost_rule_json: { + flat_cost: 0, + unit: 'video_seconds', + price_per_second: 0, + currency: 'USD', + max_cost_per_call: 0, + daily_cost_limit: 0, + note: '请按 Runway 实际账单填写 price_per_second 或单次/每日成本上限。' + } + }, + { + provider_type: 'VideoProvider', + provider_code: 'kling-image-to-video', + display_name: 'Kling Image-to-Video', + mode: 'real', + model_name: 'kling-v3', + priority: 40, + is_enabled: false, + config_json: { + driver: 'kling_image_to_video', + api_key_env: 'KLING_API_KEY', + base_url: 'https://api-singapore.klingai.com', + timeout_ms: 180000, + create_endpoint: '/v1/videos/image2video', + task_endpoint_template: '/v1/videos/image2video/{task_id}', + poll_interval_ms: 10000, + max_poll_attempts: 90, + image_field: 'image', + prompt_field: 'prompt', + duration_field: 'duration', + aspect_ratio_field: 'aspect_ratio', + aspect_ratio: '9:16', + duration: 5, + note: '默认禁用。不同 Kling 官方/网关接口字段可能不同,可在高级配置里调整字段名和 Base URL。' + }, + rate_limit_json: { rpm: 5, concurrency: 1 }, + cost_rule_json: { + flat_cost: 0, + unit: 'video_seconds', + price_per_second: 0, + currency: 'USD', + max_cost_per_call: 0, + daily_cost_limit: 0, + note: '请按 Kling 实际账单填写 price_per_second 或单次/每日成本上限。' + } + } +]; + +const TEXT_MODEL_COST_RULE = { + flat_cost: 0, + unit: 'provider_usage_metadata', + estimated_output_chars: 1200, + note: '真实费用以后续账单或 usage 元数据核算;默认禁用,启用前请填写成本阈值。' +}; + +const IMAGE_MODEL_COST_RULE = { + flat_cost: 0, + unit: 'provider_usage_metadata', + estimated_output_chars: 800, + note: '图片费用按供应商账单核算;默认禁用,启用前请填写单次/每日成本阈值。' +}; + +const VOICE_MODEL_COST_RULE = { + flat_cost: 0, + unit: 'provider_usage_metadata', + estimated_output_chars: 600, + note: '语音费用按字符、时长或供应商 usage 核算;默认禁用,启用前请填写成本阈值。' +}; + +function chatProvider( + providerCode: string, + displayName: string, + modelName: string, + apiKeyEnv: string, + baseUrl: string, + overrides: Record = {} +): DefaultMockProviderConfig { + return { + provider_type: 'TextProvider', + provider_code: providerCode, + display_name: displayName, + mode: 'real', + model_name: modelName, + priority: 30, + is_enabled: false, + config_json: { + driver: 'openai_compatible_chat', + api_key_env: apiKeyEnv, + base_url: baseUrl, + chat_endpoint: '/chat/completions', + timeout_ms: 90000, + max_tokens: 1600, + temperature: 0.7, + instructions: '你是中文漫剧创作助手,输出清晰、可落地、适合继续生成分镜和视频的内容。', + ...overrides + }, + rate_limit_json: { rpm: 60, concurrency: 3 }, + cost_rule_json: TEXT_MODEL_COST_RULE + }; +} + +function novelProvider( + providerCode: string, + displayName: string, + modelName: string, + apiKeyEnv: string, + baseUrl: string, + overrides: Record = {} +): DefaultMockProviderConfig { + const base = chatProvider(providerCode, displayName, modelName, apiKeyEnv, baseUrl, { + max_tokens: 3200, + timeout_ms: 120000, + instructions: '你是中文网文和短剧改编作者,保持角色一致、冲突清晰、章节适合继续改编成漫剧。', + ...overrides + }); + + return { + ...base, + provider_type: 'NovelProvider', + rate_limit_json: { rpm: 30, concurrency: 2 } + }; +} + +function imageProvider( + providerCode: string, + displayName: string, + modelName: string, + config: Record +): DefaultMockProviderConfig { + return { + provider_type: 'ImageProvider', + provider_code: providerCode, + display_name: displayName, + mode: 'real', + model_name: modelName, + priority: 30, + is_enabled: false, + config_json: { + timeout_ms: 180000, + size: '1024x1536', + output_format: 'png', + ...config + }, + rate_limit_json: { rpm: 20, concurrency: 2 }, + cost_rule_json: IMAGE_MODEL_COST_RULE + }; +} + +function voiceProvider( + providerCode: string, + displayName: string, + modelName: string, + config: Record +): DefaultMockProviderConfig { + return { + provider_type: 'VoiceProvider', + provider_code: providerCode, + display_name: displayName, + mode: 'real', + model_name: modelName, + priority: 30, + is_enabled: false, + config_json: { + timeout_ms: 120000, + response_format: 'mp3', + ...config + }, + rate_limit_json: { rpm: 60, concurrency: 3 }, + cost_rule_json: VOICE_MODEL_COST_RULE + }; +} + +function asyncVideoProvider( + providerCode: string, + displayName: string, + modelName: string, + apiKeyEnv: string, + baseUrl: string, + config: Record +): DefaultMockProviderConfig { + return { + provider_type: 'VideoProvider', + provider_code: providerCode, + display_name: displayName, + mode: 'real', + model_name: modelName, + priority: 30, + is_enabled: false, + config_json: { + driver: 'configurable_async_asset_generation', + api_key_env: apiKeyEnv, + base_url: baseUrl, + timeout_ms: 300000, + poll_interval_ms: 10000, + max_poll_attempts: 120, + duration: 5, + aspect_ratio: '9:16', + ...config + }, + rate_limit_json: { rpm: 5, concurrency: 1, retry_limit: 2 }, + cost_rule_json: { + flat_cost: 0, + unit: 'video_seconds', + price_per_second: 0, + currency: 'USD', + max_cost_per_call: 0, + daily_cost_limit: 0, + estimated_seconds: 5, + note: '视频费用差异较大;默认禁用,启用前请核对接口字段、输出 URL、耗时和账单。' + } + }; +} + +function lipSyncProvider( + providerCode: string, + displayName: string, + modelName: string, + apiKeyEnv: string, + baseUrl: string, + config: Record = {}, + costRuleOverrides: Record = {} +): DefaultMockProviderConfig { + return { + provider_type: 'LipSyncProvider', + provider_code: providerCode, + display_name: displayName, + mode: 'real', + model_name: modelName, + priority: 30, + is_enabled: false, + config_json: { + driver: 'configurable_lip_sync', + api_key_env: apiKeyEnv, + base_url: baseUrl, + timeout_ms: 300000, + create_endpoint: '/lip-sync', + video_field: 'video', + audio_field: 'audio', + text_field: 'text', + ...config + }, + rate_limit_json: { rpm: 10, concurrency: 1, retry_limit: 1 }, + cost_rule_json: { + flat_cost: 0, + unit: 'video_seconds', + price_per_second: 0, + currency: 'USD', + max_cost_per_call: 0, + daily_cost_limit: 0, + estimated_seconds: 5, + note: 'Lip-sync 费用按具体供应商差异较大;启用前请填写 price_per_second 和成本上限。', + ...costRuleOverrides + } + }; +} + +export const DEFAULT_EXTENDED_AI_PROVIDER_CONFIGS: DefaultMockProviderConfig[] = [ + chatProvider('google-gemini-text', 'Google Gemini Text', 'gemini-3-pro-preview', 'GOOGLE_AI_API_KEY', 'https://generativelanguage.googleapis.com', { + driver: 'google_gemini_generate_content', + endpoint_template: '/v1beta/models/{model}:generateContent', + auth_header_name: 'x-goog-api-key', + auth_scheme: '', + max_output_tokens: 1600, + note: 'Gemini 文本生成默认禁用;模型名可在后台按账号可用版本调整。' + }), + novelProvider('google-gemini-novel', 'Google Gemini Novel', 'gemini-3-pro-preview', 'GOOGLE_AI_API_KEY', 'https://generativelanguage.googleapis.com', { + driver: 'google_gemini_generate_content', + endpoint_template: '/v1beta/models/{model}:generateContent', + auth_header_name: 'x-goog-api-key', + auth_scheme: '', + max_output_tokens: 3200 + }), + imageProvider('google-gemini-image', 'Google Gemini Image', 'gemini-3-pro-image-preview', { + driver: 'configurable_image_generation', + api_key_env: 'GOOGLE_AI_API_KEY', + base_url: 'https://generativelanguage.googleapis.com', + auth_header_name: 'x-goog-api-key', + auth_scheme: '', + create_endpoint: '/v1beta/models/{model}:generateContent', + body_style: 'gemini_image_content', + aspect_ratio: '9:16', + image_size: '1K' + }), + asyncVideoProvider('google-veo-video', 'Google Veo Video', 'veo-3.1-generate-preview', 'GOOGLE_AI_API_KEY', 'https://generativelanguage.googleapis.com', { + driver: 'google_veo_video_generation', + auth_header_name: 'x-goog-api-key', + auth_scheme: '', + create_endpoint: '/v1beta/models/{model}:predictLongRunning', + task_endpoint_template: '/v1beta/{task_id}', + duration: 8, + aspect_ratio: '9:16', + resolution: '720p' + }), + chatProvider('anthropic-claude-text', 'Anthropic Claude Text', 'claude-sonnet-4-20250514', 'ANTHROPIC_API_KEY', 'https://api.anthropic.com', { + driver: 'anthropic_messages', + endpoint_template: '/v1/messages', + auth_header_name: 'x-api-key', + auth_scheme: '', + headers: { 'anthropic-version': '2023-06-01' }, + max_tokens: 1600 + }), + novelProvider('anthropic-claude-novel', 'Anthropic Claude Novel', 'claude-sonnet-4-20250514', 'ANTHROPIC_API_KEY', 'https://api.anthropic.com', { + driver: 'anthropic_messages', + endpoint_template: '/v1/messages', + auth_header_name: 'x-api-key', + auth_scheme: '', + headers: { 'anthropic-version': '2023-06-01' }, + max_tokens: 3200 + }), + chatProvider('deepseek-text', 'DeepSeek Text', 'deepseek-chat', 'DEEPSEEK_API_KEY', 'https://api.deepseek.com'), + novelProvider('deepseek-novel', 'DeepSeek Novel', 'deepseek-chat', 'DEEPSEEK_API_KEY', 'https://api.deepseek.com', { max_tokens: 3200 }), + chatProvider('qwen-text', 'Alibaba Qwen Text', 'qwen-plus', 'ALIBABA_DASHSCOPE_API_KEY', 'https://dashscope.aliyuncs.com/compatible-mode/v1'), + novelProvider('qwen-novel', 'Alibaba Qwen Novel', 'qwen-plus', 'ALIBABA_DASHSCOPE_API_KEY', 'https://dashscope.aliyuncs.com/compatible-mode/v1'), + chatProvider('kimi-text', 'Moonshot Kimi Text', 'kimi-latest', 'MOONSHOT_API_KEY', 'https://api.moonshot.cn/v1'), + novelProvider('kimi-novel', 'Moonshot Kimi Novel', 'kimi-latest', 'MOONSHOT_API_KEY', 'https://api.moonshot.cn/v1'), + chatProvider('zhipu-glm-text', 'Zhipu GLM Text', 'glm-4.5', 'ZHIPU_API_KEY', 'https://open.bigmodel.cn/api/paas/v4'), + novelProvider('zhipu-glm-novel', 'Zhipu GLM Novel', 'glm-4.5', 'ZHIPU_API_KEY', 'https://open.bigmodel.cn/api/paas/v4'), + chatProvider('baidu-qianfan-text', 'Baidu Qianfan Text', 'ernie-4.5-turbo-128k', 'BAIDU_QIANFAN_API_KEY', 'https://qianfan.baidubce.com/v2'), + chatProvider('tencent-hunyuan-text', 'Tencent Hunyuan Text', 'hunyuan-turbos-latest', 'TENCENT_HUNYUAN_API_KEY', 'https://api.hunyuan.cloud.tencent.com/v1'), + chatProvider('iflytek-spark-text', 'iFlytek Spark Text', 'x1', 'IFLYTEK_SPARK_API_KEY', 'https://spark-api-open.xf-yun.com/v1'), + chatProvider('volcengine-doubao-text', 'Volcengine Doubao Text', 'doubao-seed-1-6-250615', 'VOLCENGINE_API_KEY', 'https://ark.cn-beijing.volces.com/api/v3'), + novelProvider('volcengine-doubao-novel', 'Volcengine Doubao Novel', 'doubao-seed-1-6-250615', 'VOLCENGINE_API_KEY', 'https://ark.cn-beijing.volces.com/api/v3'), + chatProvider('minimax-text', 'MiniMax Text', 'abab6.5s-chat', 'MINIMAX_API_KEY', 'https://api.minimax.io/v1'), + chatProvider('baichuan-text', 'Baichuan Text', 'Baichuan4-Turbo', 'BAICHUAN_API_KEY', 'https://api.baichuan-ai.com/v1'), + chatProvider('stepfun-text', 'StepFun Text', 'step-2-16k', 'STEPFUN_API_KEY', 'https://api.stepfun.com/v1'), + chatProvider('sensenova-text', 'SenseNova Text', 'SenseChat-5', 'SENSENOVA_API_KEY', 'https://api.sensenova.cn/compatible-mode/v1'), + chatProvider('ai360-text', '360 AI Text', '360gpt-pro', 'AI360_API_KEY', 'https://api.360.cn/v1'), + chatProvider('mistral-text', 'Mistral Text', 'mistral-large-latest', 'MISTRAL_API_KEY', 'https://api.mistral.ai/v1'), + chatProvider('cohere-command-text', 'Cohere Command Text', 'command-a', 'COHERE_API_KEY', 'https://api.cohere.com', { + driver: 'cohere_chat', + endpoint_template: '/v2/chat', + auth_header_name: 'Authorization', + auth_scheme: 'Bearer' + }), + chatProvider('xai-grok-text', 'xAI Grok Text', 'grok-4', 'XAI_API_KEY', 'https://api.x.ai/v1'), + chatProvider('openrouter-text', 'OpenRouter Text Router', 'openai/gpt-5.5', 'OPENROUTER_API_KEY', 'https://openrouter.ai/api/v1', { + headers: { 'HTTP-Referer': 'https://localhost', 'X-Title': 'AI Manga Video Platform' } + }), + chatProvider('together-llama-text', 'Together AI Llama Text', 'meta-llama/Llama-3.3-70B-Instruct-Turbo', 'TOGETHER_API_KEY', 'https://api.together.xyz/v1'), + chatProvider('fireworks-llama-text', 'Fireworks AI Text', 'accounts/fireworks/models/llama-v3p1-70b-instruct', 'FIREWORKS_API_KEY', 'https://api.fireworks.ai/inference/v1'), + chatProvider('perplexity-sonar-text', 'Perplexity Sonar Text', 'sonar-pro', 'PERPLEXITY_API_KEY', 'https://api.perplexity.ai'), + chatProvider('azure-openai-text', 'Azure OpenAI Text', 'YOUR_DEPLOYMENT_NAME', 'AZURE_OPENAI_API_KEY', 'https://example.openai.azure.com', { + chat_endpoint: '/openai/deployments/{model}/chat/completions?api-version=2025-04-01-preview', + auth_header_name: 'api-key', + auth_scheme: '', + note: 'Base URL 需改为自己的 Azure OpenAI 资源地址,model_name 填部署名。' + }), + chatProvider('aws-bedrock-openai-compatible-text', 'AWS Bedrock OpenAI-compatible Text', 'provider-model-id', 'AWS_BEDROCK_COMPAT_API_KEY', 'https://bedrock-runtime.example.com/openai/v1', { + note: 'Bedrock 原生 SigV4 未在本阶段实现;此预设用于 OpenAI-compatible 网关或企业代理。' + }), + imageProvider('stability-image', 'Stability AI Image', 'stable-diffusion-xl-1024-v1-0', { + driver: 'configurable_image_generation', + api_key_env: 'STABILITY_API_KEY', + base_url: 'https://api.stability.ai', + create_endpoint: '/v1/generation/{model}/text-to-image', + body_style: 'stability_v1', + auth_header_name: 'Authorization', + auth_scheme: 'Bearer', + width: 1024, + height: 1536 + }), + imageProvider('replicate-flux-image', 'Replicate Flux Image', 'black-forest-labs/flux-1.1-pro', { + driver: 'configurable_async_asset_generation', + api_key_env: 'REPLICATE_API_TOKEN', + base_url: 'https://api.replicate.com/v1', + create_endpoint: '/models/{model}/predictions', + task_endpoint_template: '/predictions/{task_id}', + body_style: 'replicate_prediction' + }), + imageProvider('fal-flux-image', 'fal.ai Flux Image', 'fal-ai/flux-pro/v1.1-ultra', { + driver: 'configurable_async_asset_generation', + api_key_env: 'FAL_KEY', + base_url: 'https://queue.fal.run', + create_endpoint: '/{model}', + task_endpoint_template: '/{model}/requests/{task_id}', + status_endpoint_template: '/{model}/requests/{task_id}/status', + body_style: 'flat' + }), + imageProvider('ideogram-image', 'Ideogram Image', 'V_3', { + driver: 'configurable_image_generation', + api_key_env: 'IDEOGRAM_API_KEY', + base_url: 'https://api.ideogram.ai', + create_endpoint: '/generate', + body_style: 'ideogram_generate', + auth_header_name: 'Api-Key', + auth_scheme: '' + }), + imageProvider('leonardo-image', 'Leonardo Image', 'phoenix-1.0', { + driver: 'configurable_async_asset_generation', + api_key_env: 'LEONARDO_API_KEY', + base_url: 'https://cloud.leonardo.ai/api/rest/v1', + create_endpoint: '/generations', + task_endpoint_template: '/generations/{task_id}', + body_style: 'flat' + }), + voiceProvider('elevenlabs-tts', 'ElevenLabs Text to Speech', 'eleven_multilingual_v2', { + driver: 'configurable_text_to_speech', + api_key_env: 'ELEVENLABS_API_KEY', + base_url: 'https://api.elevenlabs.io', + create_endpoint: '/v1/text-to-speech/{voice_id}', + auth_header_name: 'xi-api-key', + auth_scheme: '', + voice_id: '21m00Tcm4TlvDq8ikWAM', + output_format: 'mp3_44100_128' + }), + voiceProvider('minimax-tts', 'MiniMax Text to Speech', 'speech-02-hd', { + driver: 'configurable_text_to_speech', + api_key_env: 'MINIMAX_API_KEY', + base_url: 'https://api.minimax.io', + create_endpoint: '/v1/t2a_v2', + body_style: 'minimax_tts', + voice_id: 'female-shaonv', + response_format: 'mp3' + }), + voiceProvider('volcengine-tts', 'Volcengine TTS', 'doubao-tts', { + driver: 'configurable_text_to_speech', + api_key_env: 'VOLCENGINE_API_KEY', + base_url: 'https://openspeech.bytedance.com', + create_endpoint: '/api/v1/tts', + body_style: 'volcengine_tts', + voice_id: 'zh_female_wanwanxiaohe_moon_bigtts', + response_format: 'mp3' + }), + asyncVideoProvider('replicate-video', 'Replicate Video', 'google/veo-3', 'REPLICATE_API_TOKEN', 'https://api.replicate.com/v1', { + create_endpoint: '/models/{model}/predictions', + task_endpoint_template: '/predictions/{task_id}', + body_style: 'replicate_prediction' + }), + asyncVideoProvider('fal-video', 'fal.ai Video', 'fal-ai/veo3/fast', 'FAL_KEY', 'https://queue.fal.run', { + create_endpoint: '/{model}', + status_endpoint_template: '/{model}/requests/{task_id}/status', + task_endpoint_template: '/{model}/requests/{task_id}', + body_style: 'flat' + }), + asyncVideoProvider('luma-ray-video', 'Luma Ray Video', 'ray-2', 'LUMA_API_KEY', 'https://api.lumalabs.ai', { + create_endpoint: '/dream-machine/v1/generations', + task_endpoint_template: '/dream-machine/v1/generations/{task_id}', + body_style: 'flat', + image_field: 'image_ref' + }), + asyncVideoProvider('pika-video', 'Pika Video', 'pika-v2.2', 'PIKA_API_KEY', 'https://api.pika.art', { + create_endpoint: '/generate', + task_endpoint_template: '/generate/{task_id}', + body_style: 'flat' + }), + lipSyncProvider('minimax-lipsync', 'MiniMax Lip Sync', 'minimax-lipsync', 'MINIMAX_API_KEY', 'https://api.minimax.io', { + driver: 'configurable_async_lip_sync', + create_endpoint: '', + task_endpoint_template: '', + video_field: 'video_url', + audio_field: 'audio_url', + text_field: 'text', + requires_public_urls: true, + public_url_expires_seconds: 3600, + poll_interval_ms: 10000, + max_poll_attempts: 120, + note: 'MiniMax lip-sync 默认禁用占位:MiniMax/Hailuo 体系具备视频、语音和数字人能力,但当前开放平台文档未确认稳定“已有视频+音频口型替换”公开 API;开通前需在 MiniMax 控制台确认 endpoint、请求体、输出字段和计费后再填入。' + }), + lipSyncProvider('alibaba-videoretalk-lipsync', 'Alibaba VideoRetalk Lip Sync', 'videoretalk', 'ALIBABA_DASHSCOPE_API_KEY', 'https://dashscope.aliyuncs.com', { + driver: 'configurable_async_lip_sync', + create_endpoint: '/api/v1/services/aigc/video-generation/video-retalk', + task_endpoint_template: '/api/v1/tasks/{task_id}', + body_style: 'alibaba_videoretalk', + headers: { 'X-DashScope-Async': 'enable' }, + requires_public_urls: true, + public_url_expires_seconds: 3600, + poll_interval_ms: 10000, + max_poll_attempts: 120, + note: '阿里云百炼 VideoRetalk:官方异步任务接口,需要公网可访问 video_url/audio_url;默认禁用,开通百炼并配置素材临时公网 URL 后启用。' + }, { + currency: 'CNY', + price_per_second: 0.08, + note: '参考阿里云百炼 VideoRetalk 公开单价 0.08 元/秒;启用前请以账号实际账单为准。' + }), + lipSyncProvider('heygen-lipsync', 'HeyGen Lip Sync', 'heygen-lipsync-v1', 'HEYGEN_API_KEY', 'https://api.heygen.com', { + create_endpoint: '/v2/video/lipsync', + video_field: 'video_url', + audio_field: 'audio_url', + text_field: 'caption', + requires_public_urls: true, + public_url_expires_seconds: 3600, + auth_header_name: 'X-Api-Key', + auth_scheme: '', + note: 'HeyGen lip-sync 默认禁用;不同账号接口版本可能不同,启用前请核对 create_endpoint 和响应 video_url 字段。' + }), + lipSyncProvider('sync-labs-lipsync', 'Sync Labs Lip Sync', 'sync-lipsync-v2', 'SYNCLABS_API_KEY', 'https://api.sync.so', { + create_endpoint: '/v2/generate', + video_field: 'videoUrl', + audio_field: 'audioUrl', + text_field: 'transcript', + requires_public_urls: true, + public_url_expires_seconds: 3600, + note: 'Sync Labs lip-sync 默认禁用;启用前请核对模型名、endpoint、轮询/回调方式和账号计费。' + }), + lipSyncProvider('fal-veed-lipsync', 'fal.ai VEED Lip Sync', 'veed/lipsync', 'FAL_KEY', 'https://queue.fal.run', { + driver: 'configurable_async_lip_sync', + create_endpoint: '/veed/lipsync', + status_endpoint_template: '/veed/lipsync/requests/{task_id}/status', + task_endpoint_template: '/veed/lipsync/requests/{task_id}', + video_field: 'video_url', + audio_field: 'audio_url', + requires_public_urls: true, + public_url_expires_seconds: 3600, + poll_interval_ms: 5000, + max_poll_attempts: 120, + note: 'fal.ai VEED lip-sync 默认禁用;适合低成本小样,对接前请确认 queue API 当前路径和输出字段。' + }), + lipSyncProvider('volcengine-doubao-lipsync', 'Volcengine Doubao Lip Sync', 'doubao-lipsync', 'VOLCENGINE_API_KEY', 'https://ark.cn-beijing.volces.com', { + driver: 'configurable_async_lip_sync', + create_endpoint: '', + task_endpoint_template: '', + requires_public_urls: true, + public_url_expires_seconds: 3600, + note: '火山/豆包 lip-sync 默认禁用占位:当前未确认到稳定“已有视频+音频口型替换”公开 API;开通前需在火山控制台确认 endpoint、请求体和计费后再填入。' + }), + lipSyncProvider('generic-lipsync', 'Generic Lip Sync API', 'generic-lipsync-v1', 'LIPSYNC_API_KEY', 'https://example.com', { + note: '通用 lip-sync 适配器:请求体默认包含 video/audio/text,返回 video_url 或 content_base64 即可落盘。' + }) +]; + +export interface SafeProviderConfig { + id: string; + provider_type: string; + provider_code: string; + display_name: string | null; + mode: string; + model_name: string | null; + config_json: Prisma.JsonValue | null; + fallback_provider_id: string | null; + is_enabled: boolean; + priority: number; + rate_limit_json: Prisma.JsonValue | null; + cost_rule_json: Prisma.JsonValue | null; + cost_summary: ProviderCostSummary; + created_at: string; + updated_at: string; +} + +export interface ProviderCostSummary { + unit: string | null; + currency: string | null; + pricing_basis: string; + estimated_cost_10s: number | null; + estimated_cost_10s_min: number | null; + estimated_cost_10s_max: number | null; + estimated_cost_10s_label: string; + price_per_second: number | null; + price_per_clip: number | null; + max_cost_per_call: number | null; + daily_cost_limit: number | null; + needs_manual_pricing: boolean; + note: string | null; +} + +type ProviderCostRuleObject = Record; + +const PROVIDER_DISPLAY_10S_ESTIMATES: Record = { + jimeng_seedance: { + currency: 'CNY', + min: 1.72, + max: 3.46, + pricing_basis: 'display_estimate', + note: 'Seedance 1.5 Pro 720p 10s 估算:无声约 1.72 元,有声约 3.46 元;正式以火山方舟账单为准。' + }, + 'kling-image-to-video': { + currency: 'USD', + min: 0.75, + pricing_basis: 'display_estimate', + note: 'Kling 10s 估算价仅用于 Router 预算展示;正式开通后请用实际 credits/账单回填 price_per_second。' + } +}; + +export interface SafeProviderLog { + id: string; + provider_id: string | null; + task_id: string | null; + project_id: string | null; + provider_type: string; + provider_code: string | null; + model_name: string | null; + request_json: Prisma.JsonValue | null; + response_json: Prisma.JsonValue | null; + input_size: number | null; + output_size: number | null; + cost_estimate: number | null; + cost_actual: number | null; + status: string; + error_code: string | null; + error_message: string | null; + started_at: string | null; + finished_at: string | null; + created_at: string; +} + +export function toSafeProviderConfig(provider: ProviderConfig): SafeProviderConfig { + return { + id: provider.id.toString(), + provider_type: provider.provider_type, + provider_code: provider.provider_code, + display_name: provider.display_name, + mode: provider.mode, + model_name: provider.model_name, + config_json: redactSensitiveJson(provider.config_json), + fallback_provider_id: provider.fallback_provider_id?.toString() ?? null, + is_enabled: provider.is_enabled, + priority: provider.priority, + rate_limit_json: provider.rate_limit_json, + cost_rule_json: provider.cost_rule_json, + cost_summary: buildProviderCostSummary(provider), + created_at: provider.created_at.toISOString(), + updated_at: provider.updated_at.toISOString() + }; +} + +function jsonObject(value: unknown): ProviderCostRuleObject { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + return value as ProviderCostRuleObject; +} + +function stringifyText(value: unknown) { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function optionalNumber(value: unknown) { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + + if (typeof value === 'string' && value.trim()) { + const numberValue = Number(value); + + return Number.isFinite(numberValue) ? numberValue : null; + } + + return null; +} + +function roundMoney(value: number) { + return Number(value.toFixed(4)); +} + +function formatCost10sLabel(currency: string | null, min: number | null, max: number | null) { + if (min === null) { + return '10秒待填写'; + } + + if (max !== null && max !== min) { + return `10秒约 ${formatMoney(currency, min)}-${formatMoney(currency, max)}`; + } + + return `10秒约 ${formatMoney(currency, min)}`; +} + +function formatMoney(currency: string | null, value: number) { + const code = currency || ''; + const symbol = code === 'USD' ? '$' : code === 'CNY' ? '¥' : code ? `${code} ` : ''; + const decimals = Math.abs(value) < 1 ? 4 : 2; + + return `${symbol}${value.toFixed(decimals)}`; +} + +export function buildProviderCostSummary( + provider: Pick +): ProviderCostSummary { + const costRule = jsonObject(provider.cost_rule_json); + const fallback = PROVIDER_DISPLAY_10S_ESTIMATES[provider.provider_code]; + const unit = stringifyText(costRule.unit); + const ruleCurrency = stringifyText(costRule.currency); + const pricePerSecond = optionalNumber(costRule.price_per_second); + const pricePerClip = optionalNumber(costRule.price_per_clip) ?? 0; + const hasVideoPrice = (pricePerSecond ?? 0) > 0 || pricePerClip > 0; + const ruleCost10s = unit === 'video_seconds' && hasVideoPrice + ? roundMoney((pricePerSecond ?? 0) * 10 + pricePerClip) + : null; + const explicitCost10s = optionalNumber(costRule.estimated_cost_per_10_seconds); + const explicitMin = optionalNumber(costRule.estimated_cost_per_10_seconds_min); + const explicitMax = optionalNumber(costRule.estimated_cost_per_10_seconds_max); + const estimateComesFromRule = ruleCost10s !== null || explicitCost10s !== null || explicitMin !== null || explicitMax !== null; + const currency = estimateComesFromRule + ? ruleCurrency || fallback?.currency || null + : fallback?.currency || ruleCurrency || null; + const estimatedMin = + explicitMin ?? + explicitCost10s ?? + ruleCost10s ?? + fallback?.min ?? + null; + const estimatedMax = + explicitMax ?? + explicitCost10s ?? + ruleCost10s ?? + fallback?.max ?? + estimatedMin; + const hasEstimate = estimatedMin !== null; + const pricingBasis = + stringifyText(costRule.pricing_basis) || + (ruleCost10s !== null ? 'price_per_second' : fallback?.pricing_basis ?? 'manual_required'); + const note = + stringifyText(costRule.note) || + fallback?.note || + null; + + return { + unit: unit || null, + currency, + pricing_basis: pricingBasis, + estimated_cost_10s: hasEstimate && estimatedMax === estimatedMin ? estimatedMin : null, + estimated_cost_10s_min: estimatedMin, + estimated_cost_10s_max: estimatedMax, + estimated_cost_10s_label: formatCost10sLabel(currency, estimatedMin, estimatedMax), + price_per_second: pricePerSecond, + price_per_clip: optionalNumber(costRule.price_per_clip), + max_cost_per_call: optionalNumber(costRule.max_cost_per_call), + daily_cost_limit: optionalNumber(costRule.daily_cost_limit), + needs_manual_pricing: !hasEstimate || ['manual_required', 'display_estimate'].includes(pricingBasis), + note + }; +} + +export function toSafeProviderLog(log: ProviderLog): SafeProviderLog { + return { + id: log.id.toString(), + provider_id: log.provider_id?.toString() ?? null, + task_id: log.task_id?.toString() ?? null, + project_id: log.project_id?.toString() ?? null, + provider_type: log.provider_type, + provider_code: log.provider_code, + model_name: log.model_name, + request_json: redactSensitiveJson(log.request_json), + response_json: redactSensitiveJson(log.response_json), + input_size: log.input_size, + output_size: log.output_size, + cost_estimate: log.cost_estimate ? Number(log.cost_estimate.toString()) : null, + cost_actual: log.cost_actual ? Number(log.cost_actual.toString()) : null, + status: log.status, + error_code: log.error_code, + error_message: log.error_message, + started_at: log.started_at?.toISOString() ?? null, + finished_at: log.finished_at?.toISOString() ?? null, + created_at: log.created_at.toISOString() + }; +} + +export function redactSensitiveJson(value: Prisma.JsonValue | null): Prisma.JsonValue | null { + if (value === null || typeof value !== 'object') { + return value; + } + if (Array.isArray(value)) { + return value.map((item) => redactSensitiveJson(item)); + } + + const output: Record = {}; + + for (const [key, child] of Object.entries(value)) { + if (isSensitiveKey(key)) { + output[key] = '[REDACTED]'; + } else { + output[key] = redactSensitiveJson(child as Prisma.JsonValue) as Prisma.JsonValue; + } + } + + return output; +} + +export function isSensitiveKey(key: string) { + if (isEnvReferenceKey(key)) { + return false; + } + + return /api[_-]?key|secret|token|password|credential/i.test(key); +} + +export function isEnvReferenceKey(key: string) { + return /(?:api[_-]?key|secret|token|password|credential)[_-]?(?:env|ref)$/i.test(key); +} diff --git a/backend/src/providers/providers.controller.ts b/backend/src/providers/providers.controller.ts new file mode 100644 index 0000000..0bf6dcc --- /dev/null +++ b/backend/src/providers/providers.controller.ts @@ -0,0 +1,109 @@ +import { + Body, + Controller, + Get, + Inject, + Param, + Patch, + Post, + Query, + UseGuards +} from '@nestjs/common'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import type { AuthRequestUser } from '../auth/auth.types'; +import { + ExecuteProviderDto, + ListProvidersQueryDto, + ProviderLogsQueryDto, + UpdateOpenAiRuntimeConfigDto, + UpdateProviderRuntimeConfigDto, + UpdateProviderConfigDto +} from './provider.dto'; +import { ProvidersService } from './providers.service'; + +@Controller() +@UseGuards(JwtAuthGuard) +export class ProvidersController { + constructor(@Inject(ProvidersService) private readonly providersService: ProvidersService) {} + + @Get('admin/providers') + listProviders(@CurrentUser() user: AuthRequestUser, @Query() query: ListProvidersQueryDto) { + return this.providersService.listProviders(user, query); + } + + @Post('admin/providers/bootstrap-mocks') + bootstrapMockProviders(@CurrentUser() user: AuthRequestUser) { + return this.providersService.bootstrapMockProviders(user); + } + + @Post('admin/providers/bootstrap-openai') + bootstrapOpenAiProviders(@CurrentUser() user: AuthRequestUser) { + return this.providersService.bootstrapOpenAiProviders(user); + } + + @Post('admin/providers/bootstrap-video') + bootstrapVideoProviders(@CurrentUser() user: AuthRequestUser) { + return this.providersService.bootstrapVideoProviders(user); + } + + @Post('admin/providers/bootstrap-extended-ai') + bootstrapExtendedAiProviders(@CurrentUser() user: AuthRequestUser) { + return this.providersService.bootstrapExtendedAiProviders(user); + } + + @Post('admin/providers/execute') + executeProvider(@CurrentUser() user: AuthRequestUser, @Body() dto: ExecuteProviderDto) { + return this.providersService.executeProviderForAdmin(user, dto); + } + + @Patch('admin/providers/openai/runtime-config') + updateOpenAiRuntimeConfig( + @CurrentUser() user: AuthRequestUser, + @Body() dto: UpdateOpenAiRuntimeConfigDto + ) { + return this.providersService.updateOpenAiRuntimeConfig(user, dto); + } + + @Get('admin/providers/openai/connection-check') + checkOpenAiConnection(@CurrentUser() user: AuthRequestUser) { + return this.providersService.checkOpenAiConnection(user); + } + + @Patch('admin/providers/:providerId') + updateProviderConfig( + @CurrentUser() user: AuthRequestUser, + @Param('providerId') providerId: string, + @Body() dto: UpdateProviderConfigDto + ) { + return this.providersService.updateProviderConfig(user, providerId, dto); + } + + @Patch('admin/providers/:providerId/runtime-config') + updateProviderRuntimeConfig( + @CurrentUser() user: AuthRequestUser, + @Param('providerId') providerId: string, + @Body() dto: UpdateProviderRuntimeConfigDto + ) { + return this.providersService.updateProviderRuntimeConfig(user, providerId, dto); + } + + @Post('admin/providers/:providerId/test') + testProvider( + @CurrentUser() user: AuthRequestUser, + @Param('providerId') providerId: string, + @Body() dto: ExecuteProviderDto + ) { + return this.providersService.testProvider(user, providerId, dto); + } + + @Get('admin/provider-logs') + listProviderLogs(@CurrentUser() user: AuthRequestUser, @Query() query: ProviderLogsQueryDto) { + return this.providersService.listProviderLogs(user, query); + } + + @Get('admin/costs') + getCosts(@CurrentUser() user: AuthRequestUser, @Query() query: ProviderLogsQueryDto) { + return this.providersService.getCosts(user, query); + } +} diff --git a/backend/src/providers/providers.module.ts b/backend/src/providers/providers.module.ts new file mode 100644 index 0000000..aeb0409 --- /dev/null +++ b/backend/src/providers/providers.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { PrismaModule } from '../prisma/prisma.module'; +import { ProvidersController } from './providers.controller'; +import { ProvidersService } from './providers.service'; + +@Module({ + imports: [AuthModule, PrismaModule], + controllers: [ProvidersController], + providers: [ProvidersService], + exports: [ProvidersService] +}) +export class ProvidersModule {} diff --git a/backend/src/providers/providers.service.spec.ts b/backend/src/providers/providers.service.spec.ts new file mode 100644 index 0000000..5b6178a --- /dev/null +++ b/backend/src/providers/providers.service.spec.ts @@ -0,0 +1,1840 @@ +import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import { Prisma, type ProviderConfig, type ProviderLog, type RenderTask } from '@prisma/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AuthRequestUser } from '../auth/auth.types'; +import type { PrismaService } from '../prisma/prisma.service'; +import { + DEFAULT_EXTENDED_AI_PROVIDER_CONFIGS, + DEFAULT_MOCK_PROVIDER_CONFIGS, + DEFAULT_OPENAI_PROVIDER_CONFIGS +} from './provider.types'; +import { ProvidersService } from './providers.service'; + +const admin: AuthRequestUser = { + id: '1', + email: 'admin@example.com', + role: 'admin' +}; + +const user: AuthRequestUser = { + id: '2', + email: 'user@example.com', + role: 'user' +}; + +const now = new Date('2026-05-31T00:00:00.000Z'); + +function createProviderConfig(overrides: Partial = {}): ProviderConfig { + return { + id: 10n, + provider_type: 'TextProvider', + provider_code: 'mock-text', + display_name: 'Mock Text Provider', + mode: 'mock', + model_name: 'mock-text-v1', + config_json: { note: 'mock' }, + fallback_provider_id: null, + is_enabled: true, + priority: 100, + rate_limit_json: { rpm: 600 }, + cost_rule_json: { flat_cost: 0 }, + created_at: now, + updated_at: now, + ...overrides + }; +} + +function createProviderLog(overrides: Partial = {}): ProviderLog { + return { + id: 100n, + provider_id: 10n, + task_id: null, + project_id: null, + provider_type: 'TextProvider', + provider_code: 'mock-text', + model_name: 'mock-text-v1', + request_json: { prompt: 'test' }, + response_json: { text: 'mock' }, + input_size: 10, + output_size: 10, + cost_estimate: new Prisma.Decimal(0), + cost_actual: new Prisma.Decimal(0), + status: 'success', + error_code: null, + error_message: null, + started_at: now, + finished_at: now, + created_at: now, + ...overrides + }; +} + +function createRenderTask(overrides: Partial = {}): RenderTask { + return { + id: 200n, + project_id: 300n, + episode_id: null, + shot_id: null, + task_type: 'story_bible_generate', + provider_id: null, + status: 'pending', + input_json: { prompt: 'test' }, + input_hash: 'hash-a', + idempotency_key: 'idem-a', + output_asset_id: null, + provider_request_id: null, + retry_count: 0, + max_retry: 2, + cost_estimate: null, + cost_actual: null, + error_code: null, + error_message: null, + created_at: now, + started_at: null, + finished_at: null, + ...overrides + }; +} + +describe('ProvidersService', () => { + let prisma: any; + let service: ProvidersService; + let logId: bigint; + + beforeEach(() => { + logId = 100n; + prisma = { + providerConfig: { + upsert: vi.fn(async ({ create }: { create: Partial }) => + createProviderConfig(create) + ), + findMany: vi.fn().mockResolvedValue([createProviderConfig()]), + findUnique: vi.fn().mockResolvedValue(createProviderConfig()), + update: vi.fn(async ({ data }: { data: Partial }) => + createProviderConfig(data) + ) + }, + providerLog: { + create: vi.fn(async ({ data }: { data: Partial }) => + createProviderLog({ + ...data, + id: logId++, + cost_estimate: + data.cost_estimate === null || data.cost_estimate === undefined + ? null + : new Prisma.Decimal(data.cost_estimate), + cost_actual: + data.cost_actual === null || data.cost_actual === undefined + ? null + : new Prisma.Decimal(data.cost_actual) + }) + ), + findMany: vi.fn().mockResolvedValue([createProviderLog()]), + count: vi.fn().mockResolvedValue(1), + aggregate: vi.fn().mockResolvedValue({ + _sum: { cost_actual: new Prisma.Decimal(0) } + }) + }, + renderTask: { + findUnique: vi.fn().mockResolvedValue(createRenderTask()), + update: vi.fn().mockResolvedValue(createRenderTask({ status: 'success' })) + } + }; + service = new ProvidersService(prisma as PrismaService); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + delete process.env.OPENAI_API_KEY; + delete process.env.OPENAI_TEXT_MODEL; + delete process.env.DEEPSEEK_API_KEY; + delete process.env.ANTHROPIC_API_KEY; + delete process.env.GOOGLE_AI_API_KEY; + delete process.env.ELEVENLABS_API_KEY; + delete process.env.MINIMAX_API_KEY; + delete process.env.ALIBABA_DASHSCOPE_API_KEY; + }); + + it('bootstraps all default mock providers', async () => { + const result = await service.bootstrapMockProviders(admin); + + expect(prisma.providerConfig.upsert).toHaveBeenCalledTimes( + DEFAULT_MOCK_PROVIDER_CONFIGS.length + ); + expect(result.count).toBe(DEFAULT_MOCK_PROVIDER_CONFIGS.length); + }); + + it('bootstraps OpenAI provider configs without storing raw keys', async () => { + const result = await service.bootstrapOpenAiProviders(admin); + + expect(prisma.providerConfig.upsert).toHaveBeenCalledTimes( + DEFAULT_OPENAI_PROVIDER_CONFIGS.length + ); + expect(result.count).toBe(DEFAULT_OPENAI_PROVIDER_CONFIGS.length); + expect(prisma.providerConfig.upsert).toHaveBeenCalledWith({ + where: expect.any(Object), + update: expect.objectContaining({ + config_json: expect.objectContaining({ + api_key_env: 'OPENAI_API_KEY' + }) + }), + create: expect.objectContaining({ + config_json: expect.objectContaining({ + api_key_env: 'OPENAI_API_KEY' + }) + }) + }); + }); + + it('bootstraps extended AI provider configs disabled by default', async () => { + prisma.providerConfig.findUnique.mockResolvedValue(null); + + const result = await service.bootstrapExtendedAiProviders(admin); + + expect(prisma.providerConfig.upsert).toHaveBeenCalledTimes( + DEFAULT_EXTENDED_AI_PROVIDER_CONFIGS.length + ); + expect(result.count).toBe(DEFAULT_EXTENDED_AI_PROVIDER_CONFIGS.length); + expect(prisma.providerConfig.upsert).toHaveBeenCalledWith({ + where: expect.any(Object), + update: expect.objectContaining({ + is_enabled: false, + config_json: expect.objectContaining({ + api_key_env: expect.stringMatching(/_API_KEY|_API_TOKEN|_KEY|_SECRET$/) + }) + }), + create: expect.objectContaining({ + is_enabled: false, + config_json: expect.objectContaining({ + api_key_env: expect.stringMatching(/_API_KEY|_API_TOKEN|_KEY|_SECRET$/) + }) + }) + }); + }); + + it('includes MiniMax lip-sync as a disabled placeholder until the public endpoint is confirmed', () => { + const provider = DEFAULT_EXTENDED_AI_PROVIDER_CONFIGS.find((item) => item.provider_code === 'minimax-lipsync'); + + expect(provider).toEqual(expect.objectContaining({ + provider_type: 'LipSyncProvider', + mode: 'real', + is_enabled: false, + config_json: expect.objectContaining({ + api_key_env: 'MINIMAX_API_KEY', + driver: 'configurable_async_lip_sync', + requires_public_urls: true, + create_endpoint: '', + task_endpoint_template: '' + }) + })); + expect(String((provider?.config_json as Record).note)).toContain('未确认稳定'); + }); + + it('adds 10-second cost summaries to provider list rows', async () => { + prisma.providerConfig.findMany.mockResolvedValueOnce([ + createProviderConfig({ + provider_type: 'VideoProvider', + provider_code: 'minimax_hailuo_23_fast', + cost_rule_json: { + unit: 'video_seconds', + price_per_second: 0.0317, + currency: 'USD', + max_cost_per_call: 1, + daily_cost_limit: 10 + } + }), + createProviderConfig({ + provider_type: 'VideoProvider', + provider_code: 'jimeng_seedance', + cost_rule_json: { + unit: 'video_seconds', + price_per_second: 0, + currency: 'USD' + } + }) + ]); + + const result = await service.listProviders(admin, {}); + + expect(result[0].cost_summary).toMatchObject({ + currency: 'USD', + estimated_cost_10s: 0.317, + estimated_cost_10s_label: '10秒约 $0.3170', + needs_manual_pricing: false, + max_cost_per_call: 1, + daily_cost_limit: 10 + }); + expect(result[1].cost_summary).toMatchObject({ + currency: 'CNY', + estimated_cost_10s: null, + estimated_cost_10s_min: 1.72, + estimated_cost_10s_max: 3.46, + estimated_cost_10s_label: '10秒约 ¥1.72-¥3.46', + needs_manual_pricing: true + }); + }); + + it('executes a mock text provider and writes a success log', async () => { + const result = await service.executeProviderForAdmin(admin, { + provider_type: 'TextProvider', + input_json: { + prompt: '生成一个开头钩子' + } + }); + + expect(result.provider.provider_code).toBe('mock-text'); + expect(JSON.stringify(result.result)).toContain('生成一个开头钩子'); + expect(prisma.providerLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + provider_id: 10n, + provider_type: 'TextProvider', + provider_code: 'mock-text', + status: 'success' + }) + }); + }); + + it('executes provider batches through sequential fallback', async () => { + const result = await service.executeProviderBatch( + { + provider_type: 'TextProvider', + purpose: 'batch-text', + allow_fallback: false + }, + [ + { + input_json: { + prompt: '第一条批量文本' + } + }, + { + purpose: 'batch-text-custom', + input_json: { + prompt: '第二条批量文本' + } + } + ] + ); + + expect(result).toMatchObject({ + mode: 'fallback_sequential', + count: 2 + }); + expect(result.results).toHaveLength(2); + expect(JSON.stringify(result.results[0].result)).toContain('第一条批量文本'); + expect(JSON.stringify(result.results[1].result)).toContain('第二条批量文本'); + expect(prisma.providerLog.create).toHaveBeenCalledTimes(2); + }); + + it('falls back to the next enabled provider when the first provider fails', async () => { + prisma.providerConfig.findMany.mockResolvedValue([ + createProviderConfig({ + id: 11n, + provider_code: 'real-text', + mode: 'real', + priority: 200 + }), + createProviderConfig({ + id: 10n, + provider_code: 'mock-text', + mode: 'mock', + priority: 100 + }) + ]); + + const result = await service.executeProviderForAdmin(admin, { + provider_type: 'TextProvider', + input_json: { + prompt: 'fallback test' + } + }); + + expect(result.fallback_used).toBe(true); + expect(result.provider.provider_code).toBe('mock-text'); + expect(result.attempts.map((attempt) => attempt.status)).toEqual(['failed', 'success']); + }); + + it('executes a real OpenAI Responses provider through env-keyed config', async () => { + process.env.OPENAI_API_KEY = 'test-openai-key'; + const fetchMock = vi.fn(async (_url: string | URL | Request, _init?: RequestInit) => + new Response( + JSON.stringify({ + id: 'resp_123', + output_text: '真实 Provider 返回内容', + usage: { + input_tokens: 12, + output_tokens: 8 + } + }), + { + status: 200, + headers: { + 'content-type': 'application/json', + 'x-request-id': 'req_123' + } + } + ) + ); + + vi.stubGlobal('fetch', fetchMock); + prisma.providerConfig.findMany.mockResolvedValue([ + createProviderConfig({ + id: 12n, + provider_code: 'openai-responses-text', + mode: 'real', + model_name: 'gpt-5.5', + priority: 200, + config_json: { + driver: 'openai_responses', + api_key_env: 'OPENAI_API_KEY', + base_url: 'https://api.openai.com/v1', + timeout_ms: 1000 + } + }) + ]); + + const result = await service.executeProviderForAdmin(admin, { + provider_type: 'TextProvider', + input_json: { + prompt: '真实测试' + }, + allow_fallback: false + }); + const fetchInit = fetchMock.mock.calls[0][1] as RequestInit; + + expect(fetchMock.mock.calls[0][0]).toBe('https://api.openai.com/v1/responses'); + expect(fetchInit.headers).toMatchObject({ + Authorization: 'Bearer test-openai-key' + }); + expect(result.provider.provider_code).toBe('openai-responses-text'); + expect(JSON.stringify(result.result)).toContain('真实 Provider 返回内容'); + expect(result.provider_log.response_json).toMatchObject({ + mode: 'real', + response_id: 'resp_123' + }); + }); + + it('executes an OpenAI-compatible chat provider', async () => { + process.env.DEEPSEEK_API_KEY = 'test-deepseek-key'; + const fetchMock = vi.fn(async (_url: string | URL | Request, _init?: RequestInit) => + new Response( + JSON.stringify({ + id: 'chatcmpl_123', + choices: [ + { + message: { + role: 'assistant', + content: '兼容 Chat Provider 返回内容' + } + } + ], + usage: { total_tokens: 18 } + }), + { status: 200, headers: { 'content-type': 'application/json', 'x-request-id': 'req_chat_123' } } + ) + ); + + vi.stubGlobal('fetch', fetchMock); + prisma.providerConfig.findMany.mockResolvedValue([ + createProviderConfig({ + id: 42n, + provider_code: 'deepseek-text', + mode: 'real', + model_name: 'deepseek-chat', + config_json: { + driver: 'openai_compatible_chat', + api_key_env: 'DEEPSEEK_API_KEY', + base_url: 'https://api.deepseek.com', + chat_endpoint: '/chat/completions', + timeout_ms: 1000 + } + }) + ]); + + const result = await service.executeProviderForAdmin(admin, { + provider_type: 'TextProvider', + input_json: { prompt: '兼容接口测试' }, + allow_fallback: false + }); + const fetchInit = fetchMock.mock.calls[0][1] as RequestInit; + const body = JSON.parse(String(fetchInit.body)); + + expect(fetchMock.mock.calls[0][0]).toBe('https://api.deepseek.com/chat/completions'); + expect(fetchInit.headers).toMatchObject({ Authorization: 'Bearer test-deepseek-key' }); + expect(body).toMatchObject({ + model: 'deepseek-chat', + messages: expect.arrayContaining([{ role: 'user', content: '兼容接口测试' }]) + }); + expect(result.result).toMatchObject({ + text: '兼容 Chat Provider 返回内容', + response_id: 'chatcmpl_123' + }); + }); + + it('executes an Anthropic Messages provider', async () => { + process.env.ANTHROPIC_API_KEY = 'test-anthropic-key'; + const fetchMock = vi.fn(async (_url: string | URL | Request, _init?: RequestInit) => + new Response( + JSON.stringify({ + id: 'msg_123', + content: [{ type: 'text', text: 'Claude Provider 返回内容' }], + stop_reason: 'end_turn', + usage: { input_tokens: 10, output_tokens: 8 } + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ) + ); + + vi.stubGlobal('fetch', fetchMock); + prisma.providerConfig.findMany.mockResolvedValue([ + createProviderConfig({ + id: 43n, + provider_code: 'anthropic-claude-text', + mode: 'real', + model_name: 'claude-sonnet-4-20250514', + config_json: { + driver: 'anthropic_messages', + api_key_env: 'ANTHROPIC_API_KEY', + base_url: 'https://api.anthropic.com', + endpoint_template: '/v1/messages', + auth_header_name: 'x-api-key', + auth_scheme: '', + headers: { 'anthropic-version': '2023-06-01' }, + timeout_ms: 1000 + } + }) + ]); + + const result = await service.executeProviderForAdmin(admin, { + provider_type: 'TextProvider', + input_json: { prompt: 'Claude 测试' }, + allow_fallback: false + }); + const fetchInit = fetchMock.mock.calls[0][1] as RequestInit; + + expect(fetchMock.mock.calls[0][0]).toBe('https://api.anthropic.com/v1/messages'); + expect(fetchInit.headers).toMatchObject({ + 'x-api-key': 'test-anthropic-key', + 'anthropic-version': '2023-06-01' + }); + expect(result.result).toMatchObject({ + text: 'Claude Provider 返回内容', + response_id: 'msg_123' + }); + }); + + it('executes a Google Gemini generateContent provider', async () => { + process.env.GOOGLE_AI_API_KEY = 'test-google-key'; + const fetchMock = vi.fn(async (_url: string | URL | Request, _init?: RequestInit) => + new Response( + JSON.stringify({ + responseId: 'gemini_123', + candidates: [ + { + content: { + parts: [{ text: 'Gemini Provider 返回内容' }] + } + } + ], + usageMetadata: { totalTokenCount: 22 } + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ) + ); + + vi.stubGlobal('fetch', fetchMock); + prisma.providerConfig.findMany.mockResolvedValue([ + createProviderConfig({ + id: 44n, + provider_code: 'google-gemini-text', + mode: 'real', + model_name: 'gemini-3-pro-preview', + config_json: { + driver: 'google_gemini_generate_content', + api_key_env: 'GOOGLE_AI_API_KEY', + base_url: 'https://generativelanguage.googleapis.com', + endpoint_template: '/v1beta/models/{model}:generateContent', + auth_header_name: 'x-goog-api-key', + auth_scheme: '', + timeout_ms: 1000 + } + }) + ]); + + const result = await service.executeProviderForAdmin(admin, { + provider_type: 'TextProvider', + input_json: { prompt: 'Gemini 测试' }, + allow_fallback: false + }); + const fetchInit = fetchMock.mock.calls[0][1] as RequestInit; + + expect(fetchMock.mock.calls[0][0]).toBe( + 'https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro-preview:generateContent' + ); + expect(fetchInit.headers).toMatchObject({ 'x-goog-api-key': 'test-google-key' }); + expect(result.result).toMatchObject({ + text: 'Gemini Provider 返回内容', + response_id: 'gemini_123' + }); + }); + + it('stores admin-managed provider secrets encrypted and uses them at runtime', async () => { + prisma.providerConfig.findUnique.mockResolvedValueOnce( + createProviderConfig({ + provider_code: 'openai-responses-text', + mode: 'real', + config_json: { + driver: 'openai_responses', + base_url: 'https://api.openai.com/v1' + } + }) + ); + const saved = await service.updateProviderRuntimeConfig(admin, '10', { + api_key: 'sk-admin-managed-key', + base_url: 'https://compat.example/v1', + model_name: 'gpt-admin', + timeout_ms: '45000', + priority: 220, + is_enabled: true + }); + const updateCall = prisma.providerConfig.update.mock.calls.at(-1)?.[0]; + const configJson = updateCall.data.config_json as Record; + + expect(JSON.stringify(configJson)).not.toContain('sk-admin-managed-key'); + expect(configJson.api_key_secure).toEqual( + expect.objectContaining({ + kind: 'provider_secret_v1', + algorithm: 'aes-256-gcm' + }) + ); + expect(saved.config_json).toMatchObject({ + api_key_secure: '[REDACTED]', + base_url: 'https://compat.example/v1', + timeout_ms: 45000 + }); + expect(updateCall.data.cost_rule_json).toEqual(expect.any(Object)); + + const fetchMock = vi.fn(async (_url: string | URL | Request, _init?: RequestInit) => + new Response( + JSON.stringify({ + id: 'resp_456', + output_text: '后台密钥 Provider 返回内容', + usage: { total_tokens: 12 } + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ) + ); + vi.stubGlobal('fetch', fetchMock); + prisma.providerConfig.findMany.mockResolvedValue([ + createProviderConfig({ + provider_code: 'openai-responses-text', + mode: 'real', + model_name: 'gpt-admin', + config_json: configJson as Prisma.JsonValue + }) + ]); + + const result = await service.executeProviderForAdmin(admin, { + provider_type: 'TextProvider', + input_json: { + prompt: '后台密钥测试' + } + }); + const fetchInit = fetchMock.mock.calls[0][1] as RequestInit; + + expect(fetchInit.headers).toMatchObject({ + Authorization: 'Bearer sk-admin-managed-key' + }); + expect(JSON.stringify(result.result)).toContain('后台密钥 Provider 返回内容'); + }); + + it('allows long timeouts when saving async video provider runtime config', async () => { + prisma.providerConfig.findUnique.mockResolvedValueOnce( + createProviderConfig({ + provider_type: 'VideoProvider', + provider_code: 'minimax_hailuo_23_fast', + mode: 'real', + model_name: 'MiniMax-Hailuo-2.3-Fast', + config_json: { + driver: 'configurable_image_to_video', + base_url: 'https://api.minimax.io', + timeout_ms: 300000 + }, + cost_rule_json: { + max_cost_per_call: 1, + daily_cost_limit: 10 + } + }) + ); + + const saved = await service.updateProviderRuntimeConfig(admin, '19', { + api_key: 'minimax-test-key', + base_url: 'https://api.minimax.io', + model_name: 'MiniMax-Hailuo-2.3-Fast', + timeout_ms: '300000', + max_cost_per_call: '1', + daily_cost_limit: '10', + is_enabled: true, + priority: 80 + }); + const updateCall = prisma.providerConfig.update.mock.calls.at(-1)?.[0]; + + expect(updateCall.data).toMatchObject({ + is_enabled: true, + priority: 80, + model_name: 'MiniMax-Hailuo-2.3-Fast' + }); + expect(updateCall.data.config_json).toMatchObject({ + base_url: 'https://api.minimax.io', + timeout_ms: 300000, + api_key_secure: expect.objectContaining({ + kind: 'provider_secret_v1' + }) + }); + expect(saved.config_json).toMatchObject({ + api_key_secure: '[REDACTED]', + timeout_ms: 300000 + }); + }); + + it('syncs a saved provider key to other providers with the same api_key_env', async () => { + prisma.providerConfig.findUnique.mockResolvedValueOnce( + createProviderConfig({ + id: 31n, + provider_type: 'TextProvider', + provider_code: 'deepseek-text', + mode: 'real', + config_json: { + driver: 'openai_compatible_chat', + api_key_env: 'DEEPSEEK_API_KEY', + base_url: 'https://api.deepseek.com' + } + }) + ); + prisma.providerConfig.findMany.mockResolvedValueOnce([ + createProviderConfig({ + id: 31n, + provider_type: 'TextProvider', + provider_code: 'deepseek-text', + mode: 'real', + config_json: { + driver: 'openai_compatible_chat', + api_key_env: 'DEEPSEEK_API_KEY', + base_url: 'https://api.deepseek.com' + } + }), + createProviderConfig({ + id: 32n, + provider_type: 'NovelProvider', + provider_code: 'deepseek-novel', + mode: 'real', + is_enabled: false, + config_json: { + driver: 'openai_compatible_chat', + api_key_env: 'DEEPSEEK_API_KEY', + base_url: 'https://api.deepseek.com' + } + }), + createProviderConfig({ + id: 33n, + provider_type: 'TextProvider', + provider_code: 'qwen-text', + mode: 'real', + config_json: { + driver: 'openai_compatible_chat', + api_key_env: 'ALIBABA_DASHSCOPE_API_KEY', + base_url: 'https://dashscope.aliyuncs.com/compatible-mode/v1' + } + }) + ]); + + const saved = await service.updateProviderRuntimeConfig(admin, '31', { + api_key: 'sk-deepseek-key', + is_enabled: true + }); + const syncUpdate = prisma.providerConfig.update.mock.calls.find( + (call: any[]) => String(call[0]?.where?.id) === '32' + )?.[0]; + const unrelatedUpdate = prisma.providerConfig.update.mock.calls.find( + (call: any[]) => String(call[0]?.where?.id) === '33' + ); + + expect(saved).toMatchObject({ + synced_api_key_count: 1, + synced_api_key_env: 'DEEPSEEK_API_KEY' + }); + expect(syncUpdate?.data).toMatchObject({ + config_json: expect.objectContaining({ + api_key_env: 'DEEPSEEK_API_KEY', + api_key_secure: expect.objectContaining({ + kind: 'provider_secret_v1' + }) + }) + }); + expect(syncUpdate?.data).not.toHaveProperty('is_enabled'); + expect(unrelatedUpdate).toBeUndefined(); + }); + + it('applies one OpenAI runtime config to all OpenAI providers', async () => { + prisma.providerConfig.findMany.mockResolvedValueOnce([ + createProviderConfig({ + id: 21n, + provider_code: 'openai-responses-text', + mode: 'real', + config_json: { + driver: 'openai_responses', + base_url: 'https://api.openai.com/v1' + } + }), + createProviderConfig({ + id: 22n, + provider_type: 'VideoProvider', + provider_code: 'openai-video', + mode: 'real', + config_json: { + driver: 'openai_video_generation', + base_url: 'https://api.openai.com/v1' + } + }) + ]); + + const result = await service.updateOpenAiRuntimeConfig(admin, { + api_key: 'sk-global-openai-key', + base_url: 'https://api.openai.com/v1', + timeout_ms: '45000', + max_cost_per_call: '3', + daily_cost_limit: '30', + is_enabled: true, + prefer_openai: true + }); + const updateCalls = prisma.providerConfig.update.mock.calls.slice(-2); + + expect(result.count).toBe(2); + expect(updateCalls).toHaveLength(2); + for (const [call] of updateCalls) { + expect(call.data).toMatchObject({ + is_enabled: true, + priority: 220, + config_json: expect.objectContaining({ + base_url: 'https://api.openai.com/v1', + timeout_ms: 45000, + api_key_secure: expect.objectContaining({ + kind: 'provider_secret_v1' + }) + }), + cost_rule_json: expect.objectContaining({ + max_cost_per_call: 3, + daily_cost_limit: 30 + }) + }); + expect(JSON.stringify(call.data.config_json)).not.toContain('sk-global-openai-key'); + } + }); + + it('keeps OpenAI below mock priority unless production preference is explicit', async () => { + prisma.providerConfig.findMany.mockResolvedValueOnce([ + createProviderConfig({ + id: 23n, + provider_code: 'openai-responses-text', + mode: 'real', + priority: 220, + config_json: { + driver: 'openai_responses', + base_url: 'https://api.openai.com/v1' + } + }) + ]); + + const result = await service.updateOpenAiRuntimeConfig(admin, { + is_enabled: true, + prefer_openai: false + }); + const updateCall = prisma.providerConfig.update.mock.calls.at(-1)?.[0]; + + expect(result.count).toBe(1); + expect(updateCall.data).toMatchObject({ + is_enabled: true, + priority: 50 + }); + }); + + it('checks OpenAI connection through /models without writing provider logs', async () => { + process.env.OPENAI_API_KEY = 'test-openai-key'; + const fetchMock = vi.fn(async (_url: string | URL | Request, _init?: RequestInit) => + new Response( + JSON.stringify({ + data: [{ id: 'gpt-test' }] + }), + { + status: 200, + headers: { + 'content-type': 'application/json', + 'x-request-id': 'req_models_123' + } + } + ) + ); + + vi.stubGlobal('fetch', fetchMock); + prisma.providerConfig.findMany.mockResolvedValueOnce([ + createProviderConfig({ + id: 24n, + provider_code: 'openai-responses-text', + mode: 'real', + config_json: { + driver: 'openai_responses', + api_key_env: 'OPENAI_API_KEY', + base_url: 'https://api.openai.com/v1' + } + }) + ]); + + const result = await service.checkOpenAiConnection(admin); + const fetchInit = fetchMock.mock.calls[0][1] as RequestInit; + + expect(fetchMock.mock.calls[0][0]).toBe('https://api.openai.com/v1/models'); + expect(fetchInit.method).toBe('GET'); + expect(result).toMatchObject({ + ok: true, + billed: false, + endpoint: '/models', + provider_code: 'openai-responses-text', + request_id: 'req_models_123', + model_count: 1 + }); + expect(prisma.providerLog.create).not.toHaveBeenCalled(); + }); + + it('requires explicit confirmation before testing a real provider', async () => { + prisma.providerConfig.findUnique.mockResolvedValueOnce( + createProviderConfig({ + id: 25n, + provider_code: 'openai-responses-text', + mode: 'real', + config_json: { + driver: 'openai_responses' + } + }) + ); + + await expect( + service.testProvider(admin, '25', { + input_json: { prompt: 'paid test' } + }) + ).rejects.toThrow(/REAL_PROVIDER_TEST_CONFIRMATION_REQUIRED/); + expect(prisma.providerLog.create).not.toHaveBeenCalled(); + }); + + it('disables direct real video provider tests even with confirmation', async () => { + prisma.providerConfig.findUnique.mockResolvedValueOnce( + createProviderConfig({ + id: 26n, + provider_type: 'VideoProvider', + provider_code: 'openai-video', + mode: 'real', + config_json: { + driver: 'openai_video_generation' + } + }) + ); + + await expect( + service.testProvider(admin, '26', { + input_json: { prompt: 'video test' }, + confirm_paid_test: true + }) + ).rejects.toThrow(/REAL_VIDEO_PROVIDER_TEST_DISABLED/); + expect(prisma.providerLog.create).not.toHaveBeenCalled(); + }); + + it('maps OpenAI moderation results into review status fields', async () => { + process.env.OPENAI_API_KEY = 'test-openai-key'; + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response( + JSON.stringify({ + id: 'modr_123', + results: [ + { + flagged: true, + categories: { + violence: true, + harassment: false + }, + category_scores: { + violence: 0.91, + harassment: 0.02 + } + } + ] + }), + { + status: 200, + headers: { + 'content-type': 'application/json', + 'x-request-id': 'req_mod_123' + } + } + ) + ) + ); + prisma.providerConfig.findMany.mockResolvedValue([ + createProviderConfig({ + id: 13n, + provider_type: 'ModerationProvider', + provider_code: 'openai-moderation', + mode: 'real', + model_name: 'omni-moderation-latest', + config_json: { + driver: 'openai_moderation', + api_key_env: 'OPENAI_API_KEY', + base_url: 'https://api.openai.com/v1' + } + }) + ]); + + const result = await service.executeProviderForAdmin(admin, { + provider_type: 'ModerationProvider', + input_json: { + text: 'moderation test' + }, + allow_fallback: false + }); + + expect(result.result).toMatchObject({ + result_status: 'manual_required', + risk_level: 'high', + issues: ['violence'] + }); + }); + + it('does not flag safety-rule mentions in mock moderation', async () => { + prisma.providerConfig.findMany.mockResolvedValue([ + createProviderConfig({ + id: 14n, + provider_type: 'ModerationProvider', + provider_code: 'mock-moderation', + mode: 'mock', + model_name: 'mock-moderation-v1' + }) + ]); + + const safe = await service.executeProviderForAdmin(admin, { + provider_type: 'ModerationProvider', + input_json: { + prompt: '内容规范:不得生成违法、低俗或侵权内容。正文是普通剧情。' + } + }); + const risky = await service.executeProviderForAdmin(admin, { + provider_type: 'ModerationProvider', + input_json: { + prompt: '角色正在策划违法内容。' + } + }); + + expect(safe.result).toMatchObject({ + result_status: 'passed', + issues: [] + }); + expect(risky.result).toMatchObject({ + result_status: 'manual_required', + issues: ['mock_sensitive_keyword'] + }); + }); + + it('updates render task status and provider cost when task_id is supplied', async () => { + prisma.providerConfig.findMany.mockResolvedValue([ + createProviderConfig({ + id: 30n, + provider_type: 'ImageProvider', + provider_code: 'mock-image', + model_name: 'mock-image-v1' + }) + ]); + + await service.executeProviderForAdmin(admin, { + provider_type: 'ImageProvider', + task_id: '200', + input_json: { + prompt: '韩漫风分镜图', + width: 1080, + height: 1920 + } + }); + + expect(prisma.renderTask.update).toHaveBeenNthCalledWith(1, { + where: { id: 200n }, + data: expect.objectContaining({ + status: 'running' + }) + }); + expect(prisma.renderTask.update).toHaveBeenNthCalledWith(2, { + where: { id: 200n }, + data: expect.objectContaining({ + provider_id: 30n, + status: 'success', + provider_request_id: expect.stringContaining('mock-mock-image-') + }) + }); + }); + + it('returns OpenAI image bytes to the caller without storing base64 in provider logs', async () => { + process.env.OPENAI_API_KEY = 'test-openai-key'; + const imageBytes = Buffer.from('image-bytes'); + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response( + JSON.stringify({ + data: [ + { + b64_json: imageBytes.toString('base64'), + revised_prompt: 'revised prompt' + } + ] + }), + { + status: 200, + headers: { + 'content-type': 'application/json', + 'x-request-id': 'req_img_123' + } + } + ) + ) + ); + prisma.providerConfig.findMany.mockResolvedValue([ + createProviderConfig({ + id: 31n, + provider_type: 'ImageProvider', + provider_code: 'openai-image', + mode: 'real', + model_name: 'gpt-image-2', + config_json: { + driver: 'openai_image_generation', + api_key_env: 'OPENAI_API_KEY', + base_url: 'https://api.openai.com/v1', + output_format: 'png' + } + }) + ]); + + const result = await service.executeProvider({ + provider_type: 'ImageProvider', + input_json: { prompt: '韩漫风分镜图' }, + allow_fallback: false, + return_binary: true + }); + + expect(result.result).toMatchObject({ + mode: 'real', + content_base64: imageBytes.toString('base64'), + mime_type: 'image/png' + }); + expect(JSON.stringify(result.provider_log.response_json)).not.toContain('content_base64'); + expect(result.provider_log.response_json).toMatchObject({ + image_available: true, + image_bytes: imageBytes.length + }); + }); + + it('returns OpenAI speech bytes to the caller without storing base64 in provider logs', async () => { + process.env.OPENAI_API_KEY = 'test-openai-key'; + const audioBytes = Buffer.from('audio-bytes'); + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(audioBytes, { + status: 200, + headers: { + 'content-type': 'audio/mpeg', + 'x-request-id': 'req_audio_123' + } + }) + ) + ); + prisma.providerConfig.findMany.mockResolvedValue([ + createProviderConfig({ + id: 32n, + provider_type: 'VoiceProvider', + provider_code: 'openai-tts', + mode: 'real', + model_name: 'gpt-4o-mini-tts', + config_json: { + driver: 'openai_audio_speech', + api_key_env: 'OPENAI_API_KEY', + base_url: 'https://api.openai.com/v1', + response_format: 'mp3' + } + }) + ]); + + const result = await service.executeProvider({ + provider_type: 'VoiceProvider', + input_json: { text: '一段旁白' }, + allow_fallback: false, + return_binary: true + }); + + expect(result.result).toMatchObject({ + mode: 'real', + content_base64: audioBytes.toString('base64'), + mime_type: 'audio/mpeg' + }); + expect(JSON.stringify(result.provider_log.response_json)).not.toContain('content_base64'); + expect(result.provider_log.response_json).toMatchObject({ + audio_available: true, + audio_bytes: audioBytes.length + }); + }); + + it('records external provider response summary when async video creation is rejected', async () => { + process.env.MINIMAX_API_KEY = 'test-minimax-key'; + const fetchMock = vi.fn(async () => + new Response( + JSON.stringify({ + base_resp: { + status_code: 1008, + status_msg: 'invalid first frame image' + } + }), + { + status: 200, + headers: { 'content-type': 'application/json' } + } + ) + ); + + vi.stubGlobal('fetch', fetchMock); + prisma.providerConfig.findUnique.mockResolvedValueOnce( + createProviderConfig({ + id: 35n, + provider_type: 'VideoProvider', + provider_code: 'minimax_hailuo_23_fast', + mode: 'real', + model_name: 'MiniMax-Hailuo-2.3-Fast', + config_json: { + driver: 'configurable_image_to_video', + error_prefix: 'MINIMAX_VIDEO', + api_key_env: 'MINIMAX_API_KEY', + base_url: 'https://api.minimax.io', + create_endpoint: '/v1/video_generation', + task_endpoint_template: '/v1/query/video_generation?task_id={task_id}', + image_field: 'first_frame_image', + prompt_field: 'prompt', + model_field: 'model', + duration_field: 'duration', + resolution_field: 'resolution', + resolution: '768P' + } + }) + ); + + let thrown: unknown = null; + + try { + await service.executeProviderForAdmin(admin, { + provider_type: 'VideoProvider', + preferred_provider_code: 'minimax_hailuo_23_fast', + allow_fallback: false, + input_json: { + prompt: '真人短剧定妆镜头,轻微转头', + source_image_url: 'https://example.com/keyframe.png', + duration: 4 + } + }); + } catch (error) { + thrown = error; + } + + expect(thrown).not.toBeNull(); + expect((thrown as { getResponse: () => unknown }).getResponse()).toMatchObject({ + message: expect.stringContaining('MINIMAX_VIDEO_PROVIDER_REJECTED') + }); + const failedLogData = prisma.providerLog.create.mock.calls.at(-1)?.[0].data; + + expect(failedLogData).toMatchObject({ + provider_code: 'minimax_hailuo_23_fast', + status: 'failed', + error_code: expect.stringContaining('MINIMAX_VIDEO_PROVIDER_REJECTED') + }); + expect(failedLogData.response_json).toMatchObject({ + fallback_allowed: false, + provider_response_summary: { + base_resp_status_code: 1008, + base_resp_status_msg: 'invalid first frame image', + top_level_keys: ['base_resp'] + } + }); + }); + + it('normalizes configurable image-to-video duration to provider allowed values', async () => { + process.env.MINIMAX_API_KEY = 'test-minimax-key'; + let createBody: Record | null = null; + const fetchMock = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + createBody = JSON.parse(String(init?.body ?? '{}')); + + return new Response( + JSON.stringify({ + task_id: 'task_6s', + status: 'Success', + file_id: 'file_6s', + base_resp: { + status_code: 0, + status_msg: 'success' + } + }), + { + status: 200, + headers: { 'content-type': 'application/json' } + } + ); + }); + + vi.stubGlobal('fetch', fetchMock); + prisma.providerConfig.findUnique.mockResolvedValueOnce( + createProviderConfig({ + id: 36n, + provider_type: 'VideoProvider', + provider_code: 'minimax_hailuo_23_fast', + mode: 'real', + model_name: 'MiniMax-Hailuo-2.3-Fast', + config_json: { + driver: 'configurable_image_to_video', + error_prefix: 'MINIMAX_VIDEO', + api_key_env: 'MINIMAX_API_KEY', + base_url: 'https://api.minimax.io', + create_endpoint: '/v1/video_generation', + task_endpoint_template: '/v1/query/video_generation?task_id={task_id}', + image_field: 'first_frame_image', + prompt_field: 'prompt', + model_field: 'model', + duration_field: 'duration', + resolution_field: 'resolution', + duration: 6, + allowed_durations: [6, 10], + resolution: '768P' + } + }) + ); + + const result = await service.executeProviderForAdmin(admin, { + provider_type: 'VideoProvider', + preferred_provider_code: 'minimax_hailuo_23_fast', + allow_fallback: false, + input_json: { + prompt: '真人短剧定妆镜头,轻微转头', + source_image_url: 'https://example.com/keyframe.png', + duration: 4 + } + }); + + expect(createBody).toMatchObject({ + model: 'MiniMax-Hailuo-2.3-Fast', + first_frame_image: 'https://example.com/keyframe.png', + duration: 6, + resolution: '768P' + }); + expect(result.result).toMatchObject({ + task_id: 'task_6s', + file_id: 'file_6s', + duration: 6 + }); + }); + + it('returns configurable TTS bytes without storing base64 in provider logs', async () => { + process.env.ELEVENLABS_API_KEY = 'test-elevenlabs-key'; + const audioBytes = Buffer.from('elevenlabs-audio-bytes'); + const fetchMock = vi.fn(async (_url: string | URL | Request, _init?: RequestInit) => + new Response(audioBytes, { + status: 200, + headers: { + 'content-type': 'audio/mpeg', + 'request-id': 'req_elevenlabs_123' + } + }) + ); + + vi.stubGlobal('fetch', fetchMock); + prisma.providerConfig.findMany.mockResolvedValue([ + createProviderConfig({ + id: 45n, + provider_type: 'VoiceProvider', + provider_code: 'elevenlabs-tts', + mode: 'real', + model_name: 'eleven_multilingual_v2', + config_json: { + driver: 'configurable_text_to_speech', + api_key_env: 'ELEVENLABS_API_KEY', + base_url: 'https://api.elevenlabs.io', + create_endpoint: '/v1/text-to-speech/{voice_id}', + auth_header_name: 'xi-api-key', + auth_scheme: '', + voice_id: 'voice_123', + response_format: 'mp3', + timeout_ms: 1000 + } + }) + ]); + + const result = await service.executeProvider({ + provider_type: 'VoiceProvider', + input_json: { text: '一段 ElevenLabs 旁白' }, + allow_fallback: false, + return_binary: true + }); + const fetchInit = fetchMock.mock.calls[0][1] as RequestInit; + + expect(fetchMock.mock.calls[0][0]).toBe('https://api.elevenlabs.io/v1/text-to-speech/voice_123'); + expect(fetchInit.headers).toMatchObject({ 'xi-api-key': 'test-elevenlabs-key' }); + expect(result.result).toMatchObject({ + mode: 'real', + content_base64: audioBytes.toString('base64'), + mime_type: 'audio/mpeg' + }); + expect(JSON.stringify(result.provider_log.response_json)).not.toContain('content_base64'); + expect(result.provider_log.response_json).toMatchObject({ + audio_available: true, + audio_bytes: audioBytes.length + }); + }); + + it('maps OpenAI voice names to the configured MiniMax voice id for configurable TTS', async () => { + process.env.MINIMAX_API_KEY = 'test-minimax-key'; + const audioBytes = Buffer.from('minimax-audio-bytes'); + const fetchMock = vi.fn(async (_url: string | URL | Request, _init?: RequestInit) => + new Response( + JSON.stringify({ + data: { + audio: audioBytes.toString('base64') + }, + base_resp: { + status_code: 0, + status_msg: 'success' + } + }), + { + status: 200, + headers: { + 'content-type': 'application/json', + 'trace-id': 'trace_minimax_123' + } + } + ) + ); + + vi.stubGlobal('fetch', fetchMock); + prisma.providerConfig.findMany.mockResolvedValue([ + createProviderConfig({ + id: 65n, + provider_type: 'VoiceProvider', + provider_code: 'minimax-tts', + mode: 'real', + model_name: 'speech-02-hd', + config_json: { + driver: 'configurable_text_to_speech', + api_key_env: 'MINIMAX_API_KEY', + base_url: 'https://api.minimax.io', + create_endpoint: '/v1/t2a_v2', + body_style: 'minimax_tts', + voice_id: 'female-shaonv', + response_format: 'mp3', + timeout_ms: 1000 + } + }) + ]); + + const result = await service.executeProvider({ + provider_type: 'VoiceProvider', + input_json: { text: '一段 MiniMax 旁白', voice: 'coral' }, + allow_fallback: false, + return_binary: true + }); + const fetchInit = fetchMock.mock.calls[0][1] as RequestInit; + const requestBody = JSON.parse(String(fetchInit.body)) as Record; + + expect(requestBody.voice_setting.voice_id).toBe('female-shaonv'); + expect(result.result).toMatchObject({ + mode: 'real', + content_base64: audioBytes.toString('base64'), + mime_type: 'audio/mpeg' + }); + }); + + it('reports MiniMax TTS business errors instead of masking them as empty audio', async () => { + process.env.MINIMAX_API_KEY = 'test-minimax-key'; + const fetchMock = vi.fn(async () => + new Response( + JSON.stringify({ + base_resp: { + status_code: 1004, + status_msg: 'invalid voice id' + } + }), + { + status: 200, + headers: { + 'content-type': 'application/json', + 'trace-id': 'trace_minimax_error' + } + } + ) + ); + + vi.stubGlobal('fetch', fetchMock); + prisma.providerConfig.findMany.mockResolvedValue([ + createProviderConfig({ + id: 65n, + provider_type: 'VoiceProvider', + provider_code: 'minimax-tts', + mode: 'real', + model_name: 'speech-02-hd', + config_json: { + driver: 'configurable_text_to_speech', + api_key_env: 'MINIMAX_API_KEY', + base_url: 'https://api.minimax.io', + create_endpoint: '/v1/t2a_v2', + body_style: 'minimax_tts', + voice_id: 'female-shaonv', + response_format: 'mp3', + timeout_ms: 1000 + } + }) + ]); + + await expect(service.executeProvider({ + provider_type: 'VoiceProvider', + input_json: { text: '一段 MiniMax 旁白', voice: 'coral' }, + allow_fallback: false, + return_binary: true + })).rejects.toThrow(/MINIMAX_TTS_PROVIDER_REJECTED/); + }); + + it('creates, polls and downloads OpenAI video bytes without storing base64 in provider logs', async () => { + process.env.OPENAI_API_KEY = 'test-openai-key'; + const videoBytes = Buffer.from('video-bytes'); + const fetchMock = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + const method = init?.method ?? 'GET'; + const href = String(url); + + if (method === 'POST' && href.endsWith('/videos')) { + return new Response( + JSON.stringify({ + id: 'video_123', + object: 'video', + status: 'queued', + progress: 0 + }), + { + status: 200, + headers: { + 'content-type': 'application/json', + 'x-request-id': 'req_video_create' + } + } + ); + } + if (method === 'GET' && href.endsWith('/videos/video_123')) { + return new Response( + JSON.stringify({ + id: 'video_123', + object: 'video', + status: 'completed', + progress: 100, + seconds: '8', + size: '1280x720' + }), + { + status: 200, + headers: { + 'content-type': 'application/json', + 'x-request-id': 'req_video_status' + } + } + ); + } + if (method === 'GET' && href.endsWith('/videos/video_123/content')) { + return new Response(videoBytes, { + status: 200, + headers: { + 'content-type': 'video/mp4', + 'x-request-id': 'req_video_content' + } + }); + } + + throw new Error(`unexpected fetch ${method} ${href}`); + }); + + vi.stubGlobal('fetch', fetchMock); + prisma.providerConfig.findMany.mockResolvedValue([ + createProviderConfig({ + id: 33n, + provider_type: 'VideoProvider', + provider_code: 'openai-video', + mode: 'real', + model_name: 'sora-2', + config_json: { + driver: 'openai_video_generation', + api_key_env: 'OPENAI_API_KEY', + base_url: 'https://api.openai.com/v1', + poll_interval_ms: 0, + max_poll_attempts: 2, + size: '1280x720' + } + }) + ]); + + const result = await service.executeProvider({ + provider_type: 'VideoProvider', + input_json: { + prompt: '竖版漫剧镜头', + seconds: '8' + }, + allow_fallback: false, + return_binary: true + }); + const createBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body)); + + expect(fetchMock.mock.calls.map((call) => String(call[0]))).toEqual([ + 'https://api.openai.com/v1/videos', + 'https://api.openai.com/v1/videos/video_123', + 'https://api.openai.com/v1/videos/video_123/content' + ]); + expect(createBody).toMatchObject({ + model: 'sora-2', + prompt: '竖版漫剧镜头', + seconds: '8', + size: '1280x720' + }); + expect(result.result).toMatchObject({ + mode: 'real', + video_id: 'video_123', + content_base64: videoBytes.toString('base64'), + mime_type: 'video/mp4' + }); + expect(JSON.stringify(result.provider_log.response_json)).not.toContain('content_base64'); + expect(result.provider_log.response_json).toMatchObject({ + video_available: true, + video_bytes: videoBytes.length + }); + }); + + it('creates, polls and downloads Alibaba VideoRetalk lip-sync bytes', async () => { + process.env.ALIBABA_DASHSCOPE_API_KEY = 'test-dashscope-key'; + const videoBytes = Buffer.from('lipsync-video-bytes'); + const fetchMock = vi.fn(async (url: string | URL | Request, init?: RequestInit) => { + const method = init?.method ?? 'GET'; + const href = String(url); + + if (method === 'POST' && href.endsWith('/api/v1/services/aigc/video-generation/video-retalk')) { + return new Response( + JSON.stringify({ + output: { + task_id: 'task_videoretalk_123', + task_status: 'PENDING' + }, + request_id: 'req_videoretalk_create' + }), + { + status: 200, + headers: { + 'content-type': 'application/json', + 'x-request-id': 'req_videoretalk_create' + } + } + ); + } + if (method === 'GET' && href.endsWith('/api/v1/tasks/task_videoretalk_123')) { + return new Response( + JSON.stringify({ + output: { + task_id: 'task_videoretalk_123', + task_status: 'SUCCEEDED', + video_url: 'https://cdn.example.com/lipsync-result.mp4' + }, + request_id: 'req_videoretalk_status' + }), + { + status: 200, + headers: { + 'content-type': 'application/json', + 'x-request-id': 'req_videoretalk_status' + } + } + ); + } + if (method === 'GET' && href === 'https://cdn.example.com/lipsync-result.mp4') { + return new Response(videoBytes, { + status: 200, + headers: { + 'content-type': 'video/mp4' + } + }); + } + + throw new Error(`unexpected fetch ${method} ${href}`); + }); + + vi.stubGlobal('fetch', fetchMock); + prisma.providerConfig.findMany.mockResolvedValue([ + createProviderConfig({ + id: 44n, + provider_type: 'LipSyncProvider', + provider_code: 'alibaba-videoretalk-lipsync', + display_name: 'Alibaba VideoRetalk Lip Sync', + mode: 'real', + model_name: 'videoretalk', + config_json: { + driver: 'configurable_async_lip_sync', + api_key_env: 'ALIBABA_DASHSCOPE_API_KEY', + base_url: 'https://dashscope.aliyuncs.com', + create_endpoint: '/api/v1/services/aigc/video-generation/video-retalk', + task_endpoint_template: '/api/v1/tasks/{task_id}', + body_style: 'alibaba_videoretalk', + headers: { 'X-DashScope-Async': 'enable' }, + requires_public_urls: true, + poll_interval_ms: 0, + max_poll_attempts: 2 + } + }) + ]); + + const result = await service.executeProvider({ + provider_type: 'LipSyncProvider', + input_json: { + video_url: 'https://cdn.example.com/source.mp4', + audio_url: 'https://cdn.example.com/dialogue.wav', + text: '这一回,我不会再退。' + }, + allow_fallback: false, + return_binary: true + }); + const createInit = fetchMock.mock.calls[0][1] as RequestInit; + const createBody = JSON.parse(String(createInit.body)); + + expect(fetchMock.mock.calls.map((call) => String(call[0]))).toEqual([ + 'https://dashscope.aliyuncs.com/api/v1/services/aigc/video-generation/video-retalk', + 'https://dashscope.aliyuncs.com/api/v1/tasks/task_videoretalk_123', + 'https://cdn.example.com/lipsync-result.mp4' + ]); + expect(createInit.headers).toMatchObject({ + Authorization: 'Bearer test-dashscope-key', + 'X-DashScope-Async': 'enable' + }); + expect(createBody).toEqual({ + model: 'videoretalk', + input: { + video_url: 'https://cdn.example.com/source.mp4', + audio_url: 'https://cdn.example.com/dialogue.wav', + text: '这一回,我不会再退。' + } + }); + expect(result.result).toMatchObject({ + mode: 'real', + content_base64: videoBytes.toString('base64'), + mime_type: 'video/mp4', + video_available: true + }); + expect(JSON.stringify(result.provider_log.response_json)).not.toContain('content_base64'); + expect(result.provider_log.response_json).toMatchObject({ + video_available: true, + video_bytes: videoBytes.length, + task_id: 'task_videoretalk_123' + }); + }); + + it('blocks provider execution when per-call cost threshold would be exceeded', async () => { + prisma.providerConfig.findMany.mockResolvedValue([ + createProviderConfig({ + id: 40n, + provider_type: 'TextProvider', + provider_code: 'expensive-text', + cost_rule_json: { + flat_cost: 2, + max_cost_per_call: 1 + } + }) + ]); + + await expect( + service.executeProviderForAdmin(admin, { + provider_type: 'TextProvider', + input_json: { prompt: 'cost guard' }, + allow_fallback: false + }) + ).rejects.toThrow(/PROVIDER_COST_LIMIT_EXCEEDED/); + expect(prisma.providerLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + provider_code: 'expensive-text', + status: 'failed', + error_code: expect.stringContaining('PROVIDER_COST_LIMIT_EXCEEDED') + }) + }); + }); + + it('blocks provider execution when daily cost budget would be exceeded', async () => { + prisma.providerLog.aggregate.mockResolvedValueOnce({ + _sum: { cost_actual: new Prisma.Decimal(9.5) } + }); + prisma.providerConfig.findMany.mockResolvedValue([ + createProviderConfig({ + id: 41n, + provider_type: 'TextProvider', + provider_code: 'budgeted-text', + cost_rule_json: { + flat_cost: 1, + daily_cost_limit: 10 + } + }) + ]); + + await expect( + service.executeProviderForAdmin(admin, { + provider_type: 'TextProvider', + input_json: { prompt: 'daily budget' }, + allow_fallback: false + }) + ).rejects.toThrow(/PROVIDER_DAILY_COST_LIMIT_EXCEEDED/); + expect(prisma.providerLog.aggregate).toHaveBeenCalledWith({ + where: expect.objectContaining({ + status: 'success', + created_at: expect.any(Object) + }), + _sum: { cost_actual: true } + }); + }); + + it('rejects secret-looking keys in provider input', async () => { + await expect( + service.executeProviderForAdmin(admin, { + provider_type: 'TextProvider', + input_json: { + api_key: 'should-not-be-written' + } + }) + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('allows env references in provider config but still rejects raw secrets', async () => { + await service.updateProviderConfig(admin, '10', { + config_json: { + driver: 'openai_responses', + api_key_env: 'OPENAI_API_KEY' + } + }); + + await expect( + service.updateProviderConfig(admin, '10', { + config_json: { + api_key: 'sk-should-not-be-written' + } + }) + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('blocks admin APIs for normal users', async () => { + await expect(service.listProviders(user, {})).rejects.toBeInstanceOf(ForbiddenException); + }); +}); diff --git a/backend/src/providers/providers.service.ts b/backend/src/providers/providers.service.ts new file mode 100644 index 0000000..0732426 --- /dev/null +++ b/backend/src/providers/providers.service.ts @@ -0,0 +1,5427 @@ +import { + BadRequestException, + ForbiddenException, + Inject, + Injectable, + NotFoundException, + ServiceUnavailableException +} from '@nestjs/common'; +import type { Prisma, ProviderConfig, ProviderLog, RenderTask } from '@prisma/client'; +import { Prisma as PrismaNamespace } from '@prisma/client'; +import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'; +import type { AuthRequestUser } from '../auth/auth.types'; +import { assertPermission } from '../auth/rbac'; +import { PrismaService } from '../prisma/prisma.service'; +import { + ExecuteProviderDto, + ListProvidersQueryDto, + ProviderLogsQueryDto, + UpdateOpenAiRuntimeConfigDto, + UpdateProviderRuntimeConfigDto, + UpdateProviderConfigDto +} from './provider.dto'; +import { + DEFAULT_EXTENDED_AI_PROVIDER_CONFIGS, + DEFAULT_MOCK_PROVIDER_CONFIGS, + DEFAULT_OPENAI_PROVIDER_CONFIGS, + DEFAULT_VIDEO_PROVIDER_CONFIGS, + PROVIDER_LOG_STATUSES, + PROVIDER_MODES, + PROVIDER_TYPES, + isEnvReferenceKey, + isSensitiveKey, + toSafeProviderConfig, + toSafeProviderLog, + type ProviderLogStatus, + type ProviderMode, + type ProviderType, + type SafeProviderConfig, + type SafeProviderLog +} from './provider.types'; + +interface ProviderExecutionContext { + provider_type: ProviderType; + preferred_provider_code?: string; + purpose: string; + project_id: bigint | null; + task_id: bigint | null; + input_json: Prisma.InputJsonValue | null; + allow_fallback: boolean; + return_binary: boolean; +} + +interface ProviderRunOutput { + provider_request_id: string; + output_json: Prisma.InputJsonValue; + transient_output_json?: Prisma.InputJsonObject; +} + +const MAX_GENERIC_PROVIDER_TIMEOUT_MS = 600_000; + +type ProviderBatchExecuteItem = Omit & { + input_json: Prisma.InputJsonValue | null; + purpose?: string; +}; + +interface OpenAiJsonResult { + body: Record; + request_id: string | null; +} + +interface OpenAiBinaryResult { + buffer: Buffer; + content_type: string | null; + request_id: string | null; +} + +interface ProviderHttpJsonResult { + body: Record; + request_id: string | null; +} + +interface ProviderHttpBinaryResult { + buffer: Buffer; + content_type: string | null; + request_id: string | null; +} + +class ExternalProviderResponseError extends Error { + constructor( + message: string, + readonly providerResponseSummary: Prisma.InputJsonValue | null + ) { + super(message); + } +} + +@Injectable() +export class ProvidersService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async bootstrapMockProviders(user: AuthRequestUser) { + assertPermission(user, 'providers:write'); + const providers = []; + + for (const config of DEFAULT_MOCK_PROVIDER_CONFIGS) { + const provider = await this.prisma.providerConfig.upsert({ + where: { + provider_type_provider_code: { + provider_type: config.provider_type, + provider_code: config.provider_code + } + }, + update: { + display_name: config.display_name, + mode: config.mode, + model_name: config.model_name, + is_enabled: config.is_enabled ?? true, + priority: config.priority, + config_json: config.config_json, + rate_limit_json: config.rate_limit_json, + cost_rule_json: config.cost_rule_json + }, + create: { + provider_type: config.provider_type, + provider_code: config.provider_code, + display_name: config.display_name, + mode: config.mode, + model_name: config.model_name, + is_enabled: config.is_enabled ?? true, + priority: config.priority, + config_json: config.config_json, + rate_limit_json: config.rate_limit_json, + cost_rule_json: config.cost_rule_json + } + }); + providers.push(toSafeProviderConfig(provider)); + } + + return { + providers, + count: providers.length + }; + } + + async bootstrapOpenAiProviders(user: AuthRequestUser) { + assertPermission(user, 'providers:write'); + const providers = []; + + for (const config of DEFAULT_OPENAI_PROVIDER_CONFIGS) { + const where = { + provider_type_provider_code: { + provider_type: config.provider_type, + provider_code: config.provider_code + } + }; + const existing = await this.prisma.providerConfig.findUnique({ where }); + const configJson = this.mergeBootstrapRuntimeConfig(existing?.config_json, config.config_json); + const costRuleJson = this.mergeBootstrapCostRule(existing?.cost_rule_json, config.cost_rule_json); + const provider = await this.prisma.providerConfig.upsert({ + where, + update: { + display_name: config.display_name, + mode: config.mode, + model_name: this.resolveBootstrapModelName(config.model_name, config.config_json), + is_enabled: config.is_enabled ?? true, + priority: config.priority, + config_json: configJson, + rate_limit_json: config.rate_limit_json, + cost_rule_json: costRuleJson + }, + create: { + provider_type: config.provider_type, + provider_code: config.provider_code, + display_name: config.display_name, + mode: config.mode, + model_name: this.resolveBootstrapModelName(config.model_name, config.config_json), + is_enabled: config.is_enabled ?? true, + priority: config.priority, + config_json: configJson, + rate_limit_json: config.rate_limit_json, + cost_rule_json: costRuleJson + } + }); + providers.push(toSafeProviderConfig(provider)); + } + + return { + providers, + count: providers.length + }; + } + + async bootstrapVideoProviders(user: AuthRequestUser) { + assertPermission(user, 'providers:write'); + const providers = []; + + for (const config of DEFAULT_VIDEO_PROVIDER_CONFIGS) { + const where = { + provider_type_provider_code: { + provider_type: config.provider_type, + provider_code: config.provider_code + } + }; + const existing = await this.prisma.providerConfig.findUnique({ where }); + const configJson = this.mergeBootstrapRuntimeConfig(existing?.config_json, config.config_json); + const costRuleJson = this.mergeBootstrapCostRule(existing?.cost_rule_json, config.cost_rule_json); + const provider = await this.prisma.providerConfig.upsert({ + where, + update: { + display_name: config.display_name, + mode: config.mode, + model_name: this.resolveBootstrapModelName(config.model_name, config.config_json), + is_enabled: existing?.is_enabled ?? config.is_enabled ?? false, + priority: config.priority, + config_json: configJson, + rate_limit_json: config.rate_limit_json, + cost_rule_json: costRuleJson + }, + create: { + provider_type: config.provider_type, + provider_code: config.provider_code, + display_name: config.display_name, + mode: config.mode, + model_name: this.resolveBootstrapModelName(config.model_name, config.config_json), + is_enabled: config.is_enabled ?? false, + priority: config.priority, + config_json: configJson, + rate_limit_json: config.rate_limit_json, + cost_rule_json: costRuleJson + } + }); + providers.push(toSafeProviderConfig(provider)); + } + + return { + providers, + count: providers.length + }; + } + + async bootstrapExtendedAiProviders(user: AuthRequestUser) { + assertPermission(user, 'providers:write'); + const providers = []; + + for (const config of DEFAULT_EXTENDED_AI_PROVIDER_CONFIGS) { + const where = { + provider_type_provider_code: { + provider_type: config.provider_type, + provider_code: config.provider_code + } + }; + const existing = await this.prisma.providerConfig.findUnique({ where }); + const configJson = this.mergeBootstrapRuntimeConfig(existing?.config_json, config.config_json); + const costRuleJson = this.mergeBootstrapCostRule(existing?.cost_rule_json, config.cost_rule_json); + const provider = await this.prisma.providerConfig.upsert({ + where, + update: { + display_name: config.display_name, + mode: config.mode, + model_name: this.resolveBootstrapModelName(config.model_name, config.config_json), + is_enabled: existing?.is_enabled ?? config.is_enabled ?? false, + priority: config.priority, + config_json: configJson, + rate_limit_json: config.rate_limit_json, + cost_rule_json: costRuleJson + }, + create: { + provider_type: config.provider_type, + provider_code: config.provider_code, + display_name: config.display_name, + mode: config.mode, + model_name: this.resolveBootstrapModelName(config.model_name, config.config_json), + is_enabled: config.is_enabled ?? false, + priority: config.priority, + config_json: configJson, + rate_limit_json: config.rate_limit_json, + cost_rule_json: costRuleJson + } + }); + providers.push(toSafeProviderConfig(provider)); + } + + return { + providers, + count: providers.length + }; + } + + async listProviders(user: AuthRequestUser, query: ListProvidersQueryDto) { + assertPermission(user, 'providers:read'); + const where: Prisma.ProviderConfigWhereInput = {}; + + if (query.provider_type) { + where.provider_type = this.validateProviderType(query.provider_type); + } + if (query.mode) { + where.mode = this.validateProviderMode(query.mode); + } + if (query.is_enabled !== undefined) { + where.is_enabled = this.parseBooleanQuery(query.is_enabled, 'is_enabled'); + } + + const providers = await this.prisma.providerConfig.findMany({ + where, + orderBy: [ + { provider_type: 'asc' }, + { priority: 'desc' }, + { id: 'asc' } + ] + }); + + return providers.map(toSafeProviderConfig); + } + + private mergeBootstrapRuntimeConfig( + existingConfig: Prisma.JsonValue | null | undefined, + defaultConfig: Prisma.InputJsonValue + ) { + const existing = this.jsonObject(existingConfig ?? null); + const merged = { + ...this.jsonObject(defaultConfig) + }; + + const preservedKeys = [ + 'api_key_secure', + 'base_url', + 'timeout_ms', + 'api_key_env', + 'base_url_env', + 'model_env', + 'auth_header_name', + 'auth_scheme', + 'headers', + 'create_endpoint', + 'task_endpoint_template', + 'status_endpoint_template', + 'output_url_endpoint_template', + 'file_endpoint_template', + 'content_endpoint_template', + 'body_style', + 'model_field', + 'prompt_field', + 'image_field', + 'image_array_field', + 'duration_field', + 'aspect_ratio_field', + 'ratio_field', + 'resolution_field', + 'size_field', + 'negative_prompt_field', + 'video_field', + 'audio_field', + 'text_field', + 'requires_public_urls', + 'public_url_expires_seconds', + 'asset_url_expires_seconds', + 'parameters_json', + 'extra_body_json', + 'poll_interval_ms', + 'max_poll_attempts', + 'duration', + 'allowed_durations', + 'resolution', + 'aspect_ratio', + 'ratio', + 'prompt_extend', + 'watermark' + ]; + + for (const key of preservedKeys) { + if (existing[key] !== undefined && existing[key] !== null && existing[key] !== '') { + merged[key] = existing[key]; + } + } + + return merged as Prisma.InputJsonObject; + } + + private mergeBootstrapCostRule( + existingCostRule: Prisma.JsonValue | null | undefined, + defaultCostRule: Prisma.InputJsonValue + ) { + const existing = this.jsonObject(existingCostRule ?? null); + const merged = { + ...this.jsonObject(defaultCostRule) + }; + + for (const key of ['max_cost_per_call', 'daily_cost_limit']) { + if (existing[key] !== undefined && existing[key] !== null && existing[key] !== '') { + merged[key] = existing[key]; + } + } + + return merged as Prisma.InputJsonObject; + } + + async updateProviderConfig( + user: AuthRequestUser, + providerId: string, + dto: UpdateProviderConfigDto + ) { + assertPermission(user, 'providers:write'); + const provider = await this.findProviderConfigOrThrow(providerId); + const data: Prisma.ProviderConfigUpdateInput = {}; + + if ('display_name' in dto) { + data.display_name = this.normalizeOptionalText(dto.display_name, 100) ?? null; + } + if ('mode' in dto) { + data.mode = this.validateProviderMode(dto.mode); + } + if ('model_name' in dto) { + data.model_name = this.normalizeOptionalText(dto.model_name, 100) ?? null; + } + if ('config_json' in dto) { + this.assertNoSecrets(dto.config_json, 'config_json'); + data.config_json = this.toNullableJsonInput(dto.config_json, 'config_json'); + } + if ('fallback_provider_id' in dto) { + data.fallback_provider_id = + dto.fallback_provider_id === null || dto.fallback_provider_id === undefined + ? null + : this.parseId(dto.fallback_provider_id, 'Invalid fallback_provider_id'); + } + if ('is_enabled' in dto) { + data.is_enabled = Boolean(dto.is_enabled); + } + if ('priority' in dto) { + data.priority = this.normalizeInt(dto.priority, 'priority', -1000, 1000); + } + if ('rate_limit_json' in dto) { + data.rate_limit_json = this.toNullableJsonInput(dto.rate_limit_json, 'rate_limit_json'); + } + if ('cost_rule_json' in dto) { + data.cost_rule_json = this.toNullableJsonInput(dto.cost_rule_json, 'cost_rule_json'); + } + + if (Object.keys(data).length === 0) { + throw new BadRequestException('No provider config fields to update'); + } + + const updated = await this.prisma.providerConfig.update({ + where: { id: provider.id }, + data + }); + + return toSafeProviderConfig(updated); + } + + async updateProviderRuntimeConfig( + user: AuthRequestUser, + providerId: string, + dto: UpdateProviderRuntimeConfigDto + ) { + assertPermission(user, 'providers:write'); + const provider = await this.findProviderConfigOrThrow(providerId); + const config = { + ...this.jsonObject(provider.config_json) + }; + const costRule = { + ...this.jsonObject(provider.cost_rule_json) + }; + const data: Prisma.ProviderConfigUpdateInput = {}; + const apiKeyEnv = this.stringifyText(config.api_key_env); + let encryptedApiKey: Prisma.InputJsonObject | null = null; + let syncedApiKeyCount = 0; + + if ('api_key' in dto && typeof dto.api_key === 'string' && dto.api_key.trim()) { + encryptedApiKey = this.encryptProviderSecret(dto.api_key.trim()); + config.api_key_secure = encryptedApiKey; + delete config.api_key; + } + if (dto.clear_api_key === true) { + delete config.api_key_secure; + delete config.api_key; + } + if ('base_url' in dto) { + const baseUrl = this.normalizeOptionalText(dto.base_url ?? undefined, 500); + + if (baseUrl) { + if (!/^https?:\/\//i.test(baseUrl)) { + throw new BadRequestException('base_url must start with http:// or https://'); + } + config.base_url = baseUrl.replace(/\/+$/, ''); + } else { + delete config.base_url; + } + } + if ('timeout_ms' in dto) { + const timeoutMs = this.normalizePositiveInt( + dto.timeout_ms ?? undefined, + 'timeout_ms', + 1000, + MAX_GENERIC_PROVIDER_TIMEOUT_MS, + 60000 + ); + config.timeout_ms = timeoutMs; + } + if ('model_name' in dto) { + data.model_name = this.normalizeOptionalText(dto.model_name ?? undefined, 100) ?? null; + } + if ('is_enabled' in dto) { + data.is_enabled = Boolean(dto.is_enabled); + } + if ('priority' in dto) { + data.priority = this.normalizeInt(dto.priority, 'priority', -1000, 1000); + } + if ('max_cost_per_call' in dto) { + this.applyOptionalCostLimit(costRule, 'max_cost_per_call', dto.max_cost_per_call); + } + if ('daily_cost_limit' in dto) { + this.applyOptionalCostLimit(costRule, 'daily_cost_limit', dto.daily_cost_limit); + } + + data.config_json = this.toNullableJsonInput(config, 'config_json'); + data.cost_rule_json = this.toNullableJsonInput(costRule, 'cost_rule_json'); + + const updated = await this.prisma.providerConfig.update({ + where: { id: provider.id }, + data + }); + + if (encryptedApiKey && dto.sync_api_key_to_same_env !== false) { + syncedApiKeyCount = await this.syncProviderSecretToSameApiKeyEnv(provider, apiKeyEnv, encryptedApiKey); + } + + return { + ...toSafeProviderConfig(updated), + synced_api_key_count: syncedApiKeyCount, + synced_api_key_env: apiKeyEnv || null + }; + } + + private async syncProviderSecretToSameApiKeyEnv( + sourceProvider: ProviderConfig, + apiKeyEnv: string, + encryptedApiKey: Prisma.InputJsonObject + ) { + if (!apiKeyEnv) { + return 0; + } + + const providers = await this.prisma.providerConfig.findMany({ + where: { + mode: 'real' + }, + orderBy: [ + { provider_type: 'asc' }, + { id: 'asc' } + ] + }); + let syncedCount = 0; + + for (const provider of providers) { + if (provider.id === sourceProvider.id) { + continue; + } + + const config = { + ...this.jsonObject(provider.config_json) + }; + + if (this.stringifyText(config.api_key_env) !== apiKeyEnv) { + continue; + } + + config.api_key_secure = { ...encryptedApiKey }; + delete config.api_key; + + await this.prisma.providerConfig.update({ + where: { id: provider.id }, + data: { + config_json: this.toNullableJsonInput(config, 'config_json') + } + }); + syncedCount += 1; + } + + return syncedCount; + } + + async updateOpenAiRuntimeConfig(user: AuthRequestUser, dto: UpdateOpenAiRuntimeConfigDto) { + assertPermission(user, 'providers:write'); + await this.bootstrapOpenAiProviders(user); + const providers = await this.prisma.providerConfig.findMany({ + where: { + mode: 'real' + }, + orderBy: [ + { provider_type: 'asc' }, + { id: 'asc' } + ] + }); + const openAiProviders = providers.filter((provider) => + this.stringifyText(this.jsonObject(provider.config_json).driver).startsWith('openai_') + ); + const updatedProviders = []; + + for (const provider of openAiProviders) { + const config = { + ...this.jsonObject(provider.config_json) + }; + const costRule = { + ...this.jsonObject(provider.cost_rule_json) + }; + const data: Prisma.ProviderConfigUpdateInput = {}; + + this.applyRuntimeConfigInput(config, costRule, data, dto); + if (dto.prefer_openai === true) { + data.priority = 220; + } else { + data.priority = 50; + } + + data.config_json = this.toNullableJsonInput(config, 'config_json'); + data.cost_rule_json = this.toNullableJsonInput(costRule, 'cost_rule_json'); + + const updated = await this.prisma.providerConfig.update({ + where: { id: provider.id }, + data + }); + + updatedProviders.push(toSafeProviderConfig(updated)); + } + + return { + providers: updatedProviders, + count: updatedProviders.length + }; + } + + async checkOpenAiConnection(user: AuthRequestUser) { + assertPermission(user, 'providers:read'); + const providers = await this.prisma.providerConfig.findMany({ + where: { + mode: 'real' + }, + orderBy: [ + { provider_type: 'asc' }, + { id: 'asc' } + ] + }); + const provider = providers.find((item) => + this.stringifyText(this.jsonObject(item.config_json).driver).startsWith('openai_') + ); + + if (!provider) { + return { + ok: false, + billed: false, + endpoint: '/models', + status: 'not_initialized', + message: '未找到 OpenAI Provider,请先保存统一配置或初始化 OpenAI 接入。' + }; + } + + try { + const response = await this.getOpenAiJson(this.jsonObject(provider.config_json), '/models'); + const models = Array.isArray(response.body.data) ? response.body.data : []; + + return { + ok: true, + billed: false, + endpoint: '/models', + provider_code: provider.provider_code, + request_id: response.request_id, + model_count: models.length, + message: '连接检查通过:只验证 Key 和网络,未生成内容。' + }; + } catch (error) { + const errorMessage = this.toError(error).message; + + return { + ok: false, + billed: false, + endpoint: '/models', + provider_code: provider.provider_code, + status: errorMessage === 'PROVIDER_SECRET_DECRYPT_FAILED' ? 'secret_decrypt_failed' : 'failed', + error_message: errorMessage, + message: + errorMessage === 'PROVIDER_SECRET_DECRYPT_FAILED' + ? '已保存 Key 无法用当前服务密钥解密,请保持 PROVIDER_SECRET_KEY/JWT_SECRET 稳定或重新保存 Key;未触发内容生成。' + : '连接检查失败:未触发内容生成。' + }; + } + } + + private applyRuntimeConfigInput( + config: Record, + costRule: Record, + data: Prisma.ProviderConfigUpdateInput, + dto: UpdateProviderRuntimeConfigDto | UpdateOpenAiRuntimeConfigDto + ) { + if ('api_key' in dto && typeof dto.api_key === 'string' && dto.api_key.trim()) { + config.api_key_secure = this.encryptProviderSecret(dto.api_key.trim()); + delete config.api_key; + } + if (dto.clear_api_key === true) { + delete config.api_key_secure; + delete config.api_key; + } + if ('base_url' in dto) { + const baseUrl = this.normalizeOptionalText(dto.base_url ?? undefined, 500); + + if (baseUrl) { + if (!/^https?:\/\//i.test(baseUrl)) { + throw new BadRequestException('base_url must start with http:// or https://'); + } + config.base_url = baseUrl.replace(/\/+$/, ''); + } else { + delete config.base_url; + } + } + if ('timeout_ms' in dto) { + const timeoutMs = this.normalizePositiveInt(dto.timeout_ms ?? undefined, 'timeout_ms', 1000, 180000, 60000); + config.timeout_ms = timeoutMs; + } + if ('model_name' in dto) { + data.model_name = this.normalizeOptionalText(dto.model_name ?? undefined, 100) ?? null; + } + if ('is_enabled' in dto) { + data.is_enabled = Boolean(dto.is_enabled); + } + if ('priority' in dto) { + data.priority = this.normalizeInt(dto.priority, 'priority', -1000, 1000); + } + if ('max_cost_per_call' in dto) { + this.applyOptionalCostLimit(costRule, 'max_cost_per_call', dto.max_cost_per_call); + } + if ('daily_cost_limit' in dto) { + this.applyOptionalCostLimit(costRule, 'daily_cost_limit', dto.daily_cost_limit); + } + } + + async executeProviderForAdmin(user: AuthRequestUser, dto: ExecuteProviderDto) { + assertPermission(user, 'providers:write'); + return this.executeProvider({ + ...dto, + return_binary: false + }); + } + + async testProvider(user: AuthRequestUser, providerId: string, dto: ExecuteProviderDto) { + assertPermission(user, 'providers:write'); + const provider = await this.findProviderConfigOrThrow(providerId); + + if (provider.mode === 'real' && provider.provider_type === 'VideoProvider') { + throw new BadRequestException('REAL_VIDEO_PROVIDER_TEST_DISABLED'); + } + if (provider.mode === 'real' && dto.confirm_paid_test !== true) { + throw new BadRequestException('REAL_PROVIDER_TEST_CONFIRMATION_REQUIRED'); + } + + return this.executeProvider({ + ...dto, + provider_type: provider.provider_type as ProviderType, + preferred_provider_code: provider.provider_code, + allow_fallback: false, + return_binary: false + }); + } + + async executeProvider(dto: ExecuteProviderDto) { + const context = await this.createExecutionContext(dto); + const task = context.task_id ? await this.findRenderTaskOrThrow(context.task_id) : null; + const projectId = context.project_id ?? task?.project_id ?? null; + const executionContext = { ...context, project_id: projectId }; + const candidates = await this.loadProviderCandidates( + executionContext.provider_type, + executionContext.preferred_provider_code, + executionContext.allow_fallback + ); + + if (task) { + await this.prisma.renderTask.update({ + where: { id: task.id }, + data: { + status: 'running', + started_at: new Date(), + finished_at: null, + error_code: null, + error_message: null + } + }); + } + + const attempts: SafeProviderLog[] = []; + let lastError: Error | null = null; + + for (const provider of candidates) { + const startedAt = new Date(); + const requestJson = this.createRequestJson(executionContext, provider); + const inputSize = this.estimateJsonSize(requestJson); + + try { + await this.assertProviderCostAllowed(provider, inputSize, 0, undefined, executionContext.input_json); + const output = await this.runProvider(provider, executionContext); + const outputSize = this.estimateJsonSize(output.output_json); + const costEstimate = this.calculateCost( + provider.cost_rule_json, + inputSize, + outputSize, + executionContext.input_json, + output.output_json + ); + await this.assertProviderCostAllowed( + provider, + inputSize, + outputSize, + costEstimate, + executionContext.input_json, + output.output_json + ); + const finishedAt = new Date(); + const log = await this.prisma.providerLog.create({ + data: { + provider_id: provider.id, + task_id: executionContext.task_id, + project_id: executionContext.project_id, + provider_type: provider.provider_type, + provider_code: provider.provider_code, + model_name: provider.model_name, + request_json: requestJson, + response_json: output.output_json, + input_size: inputSize, + output_size: outputSize, + cost_estimate: costEstimate, + cost_actual: costEstimate, + status: 'success', + started_at: startedAt, + finished_at: finishedAt + } + }); + const safeLog = toSafeProviderLog(log); + + attempts.push(safeLog); + + if (task) { + await this.prisma.renderTask.update({ + where: { id: task.id }, + data: { + provider_id: provider.id, + status: 'success', + provider_request_id: output.provider_request_id, + cost_estimate: costEstimate, + cost_actual: costEstimate, + error_code: null, + error_message: null, + finished_at: finishedAt + } + }); + } + + const resultJson = executionContext.return_binary + ? this.mergeTransientOutput(output.output_json, output.transient_output_json) + : output.output_json; + + return { + provider: toSafeProviderConfig(provider), + result: resultJson, + provider_log: safeLog, + fallback_used: attempts.length > 1, + attempts + }; + } catch (error) { + const normalizedError = this.toError(error); + lastError = normalizedError; + const finishedAt = new Date(); + const failedLog = await this.prisma.providerLog.create({ + data: { + provider_id: provider.id, + task_id: executionContext.task_id, + project_id: executionContext.project_id, + provider_type: provider.provider_type, + provider_code: provider.provider_code, + model_name: provider.model_name, + request_json: requestJson, + response_json: this.createFailureResponseJson( + provider, + executionContext, + normalizedError + ), + input_size: inputSize, + output_size: 0, + cost_estimate: 0, + cost_actual: 0, + status: 'failed', + error_code: this.errorCodeFromError(normalizedError), + error_message: normalizedError.message, + started_at: startedAt, + finished_at: finishedAt + } + }); + attempts.push(toSafeProviderLog(failedLog)); + } + } + + if (task) { + await this.prisma.renderTask.update({ + where: { id: task.id }, + data: { + status: 'failed', + error_code: this.errorCodeFromError(lastError), + error_message: lastError?.message ?? 'Provider execution failed', + finished_at: new Date() + } + }); + } + + throw new ServiceUnavailableException({ + message: lastError?.message ?? 'No provider is available', + attempts + }); + } + + async executeProviderBatch( + baseDto: Omit & { purpose: string }, + items: ProviderBatchExecuteItem[] + ) { + const results = []; + + for (const [index, item] of items.entries()) { + results.push( + await this.executeProvider({ + ...baseDto, + ...item, + purpose: item.purpose ?? `${baseDto.purpose}-${index + 1}`, + input_json: item.input_json + }) + ); + } + + return { + mode: 'fallback_sequential', + count: results.length, + results + }; + } + + async listProviderLogs(user: AuthRequestUser, query: ProviderLogsQueryDto) { + assertPermission(user, 'costs:read'); + const where = this.createLogWhere(query); + const limit = this.normalizePositiveInt(query.limit, 'limit', 1, 100, 50); + const [logs, total] = await Promise.all([ + this.prisma.providerLog.findMany({ + where, + orderBy: { created_at: 'desc' }, + take: limit + }), + this.prisma.providerLog.count({ where }) + ]); + + return { + logs: logs.map(toSafeProviderLog), + total, + limit + }; + } + + async getCosts(user: AuthRequestUser, query: ProviderLogsQueryDto) { + assertPermission(user, 'costs:read'); + const where = { + ...this.createLogWhere(query), + status: 'success' + }; + const logs = await this.prisma.providerLog.findMany({ + where, + orderBy: { created_at: 'desc' }, + take: 500 + }); + const byProvider = new Map(); + let totalCost = 0; + + for (const log of logs) { + const cost = log.cost_actual ? Number(log.cost_actual.toString()) : 0; + const key = `${log.provider_type}:${log.provider_code ?? 'unknown'}`; + const item = + byProvider.get(key) ?? + { + provider_type: log.provider_type, + provider_code: log.provider_code, + count: 0, + cost_actual: 0 + }; + + item.count += 1; + item.cost_actual += cost; + totalCost += cost; + byProvider.set(key, item); + } + + return { + total_cost_actual: Number(totalCost.toFixed(4)), + log_count: logs.length, + by_provider: [...byProvider.values()].map((item) => ({ + ...item, + cost_actual: Number(item.cost_actual.toFixed(4)) + })) + }; + } + + private async createExecutionContext(dto: ExecuteProviderDto): Promise { + const providerType = this.validateProviderType(dto.provider_type); + const inputJson = dto.input_json === undefined ? {} : this.toJsonValue(dto.input_json, 'input_json'); + + this.assertNoSecrets(inputJson, 'input_json'); + + return { + provider_type: providerType, + preferred_provider_code: this.normalizeOptionalText(dto.preferred_provider_code, 100), + purpose: this.normalizeOptionalText(dto.purpose, 100) ?? 'provider_execute', + project_id: this.parseOptionalId(dto.project_id, 'Invalid project_id'), + task_id: this.parseOptionalId(dto.task_id, 'Invalid task_id'), + input_json: inputJson, + allow_fallback: dto.allow_fallback !== false, + return_binary: dto.return_binary === true + }; + } + + private async loadProviderCandidates( + providerType: ProviderType, + preferredProviderCode: string | undefined, + allowFallback: boolean + ): Promise { + if (preferredProviderCode) { + const preferred = await this.prisma.providerConfig.findUnique({ + where: { + provider_type_provider_code: { + provider_type: providerType, + provider_code: preferredProviderCode + } + } + }); + + if (!preferred || !preferred.is_enabled) { + throw new NotFoundException('Preferred provider is not available'); + } + + const fallbackProviders = allowFallback + ? await this.loadFallbackProviders(providerType, preferred) + : []; + + return this.uniqueProviders([preferred, ...fallbackProviders]); + } + + let providers = await this.prisma.providerConfig.findMany({ + where: { + provider_type: providerType, + is_enabled: true + }, + orderBy: [ + { priority: 'desc' }, + { id: 'asc' } + ] + }); + + if (providers.length === 0) { + await this.upsertDefaultProvider(providerType); + providers = await this.prisma.providerConfig.findMany({ + where: { + provider_type: providerType, + is_enabled: true + }, + orderBy: [ + { priority: 'desc' }, + { id: 'asc' } + ] + }); + } + + if (providers.length === 0) { + throw new ServiceUnavailableException('No enabled provider config found'); + } + + return allowFallback ? providers : [providers[0]]; + } + + private async loadFallbackProviders(providerType: ProviderType, preferred: ProviderConfig) { + const providers: ProviderConfig[] = []; + + if (preferred.fallback_provider_id) { + const fallback = await this.prisma.providerConfig.findUnique({ + where: { id: preferred.fallback_provider_id } + }); + + if (fallback?.is_enabled && fallback.provider_type === providerType) { + providers.push(fallback); + } + } + + providers.push( + ...(await this.prisma.providerConfig.findMany({ + where: { + provider_type: providerType, + is_enabled: true, + id: { not: preferred.id } + }, + orderBy: [ + { priority: 'desc' }, + { id: 'asc' } + ] + })) + ); + + return providers; + } + + private uniqueProviders(providers: ProviderConfig[]) { + const seen = new Set(); + const unique: ProviderConfig[] = []; + + for (const provider of providers) { + const id = provider.id.toString(); + + if (!seen.has(id)) { + unique.push(provider); + seen.add(id); + } + } + + return unique; + } + + private async upsertDefaultProvider(providerType: ProviderType) { + const config = DEFAULT_MOCK_PROVIDER_CONFIGS.find((item) => item.provider_type === providerType); + + if (!config) { + return; + } + + await this.prisma.providerConfig.upsert({ + where: { + provider_type_provider_code: { + provider_type: config.provider_type, + provider_code: config.provider_code + } + }, + update: { + is_enabled: config.is_enabled ?? true, + priority: config.priority + }, + create: { + provider_type: config.provider_type, + provider_code: config.provider_code, + display_name: config.display_name, + mode: config.mode, + model_name: config.model_name, + is_enabled: config.is_enabled ?? true, + priority: config.priority, + config_json: config.config_json, + rate_limit_json: config.rate_limit_json, + cost_rule_json: config.cost_rule_json + } + }); + } + + private async runProvider( + provider: ProviderConfig, + context: ProviderExecutionContext + ): Promise { + if (provider.mode === 'real') { + return this.runRealProvider(provider, context); + } + if (provider.mode !== 'mock') { + throw new Error('PROVIDER_MODE_NOT_SUPPORTED'); + } + if (this.hasForceError(provider.config_json) || this.hasMockFail(context.input_json)) { + throw new Error('MOCK_PROVIDER_FAILURE'); + } + + const providerRequestId = this.createProviderRequestId(provider, context.input_json); + const fingerprint = providerRequestId.split('-').at(-1) ?? 'mock'; + + return { + provider_request_id: providerRequestId, + output_json: { + provider_request_id: providerRequestId, + provider_type: provider.provider_type, + provider_code: provider.provider_code, + model_name: provider.model_name, + mode: 'mock', + purpose: context.purpose, + ...this.createMockOutput(provider.provider_type as ProviderType, context.input_json, fingerprint) + } + }; + } + + private async runRealProvider( + provider: ProviderConfig, + context: ProviderExecutionContext + ): Promise { + const config = this.jsonObject(provider.config_json); + const driver = this.stringifyText(config.driver); + + switch (driver) { + case 'openai_responses': + return this.runOpenAiResponsesProvider(provider, context, config); + case 'openai_moderation': + return this.runOpenAiModerationProvider(provider, context, config); + case 'openai_embeddings': + return this.runOpenAiEmbeddingProvider(provider, context, config); + case 'openai_image_generation': + return this.runOpenAiImageProvider(provider, context, config); + case 'openai_video_generation': + return this.runOpenAiVideoProvider(provider, context, config); + case 'runway_image_to_video': + return this.runRunwayImageToVideoProvider(provider, context, config); + case 'kling_image_to_video': + return this.runKlingImageToVideoProvider(provider, context, config); + case 'configurable_image_to_video': + return this.runConfigurableImageToVideoProvider(provider, context, config); + case 'openai_audio_speech': + return this.runOpenAiSpeechProvider(provider, context, config); + case 'openai_compatible_chat': + return this.runOpenAiCompatibleChatProvider(provider, context, config); + case 'anthropic_messages': + return this.runAnthropicMessagesProvider(provider, context, config); + case 'google_gemini_generate_content': + return this.runGoogleGeminiGenerateContentProvider(provider, context, config); + case 'cohere_chat': + return this.runCohereChatProvider(provider, context, config); + case 'configurable_image_generation': + return this.runConfigurableImageProvider(provider, context, config); + case 'configurable_text_to_speech': + return this.runConfigurableSpeechProvider(provider, context, config); + case 'configurable_lip_sync': + return this.runConfigurableLipSyncProvider(provider, context, config); + case 'configurable_async_lip_sync': + return this.runConfigurableAsyncLipSyncProvider(provider, context, config); + case 'configurable_async_asset_generation': + return this.runConfigurableAsyncAssetProvider(provider, context, config); + case 'google_veo_video_generation': + return this.runGoogleVeoVideoProvider(provider, context, config); + default: + throw new Error('REAL_PROVIDER_DRIVER_NOT_CONFIGURED'); + } + } + + private createMockOutput( + providerType: ProviderType, + inputJson: Prisma.InputJsonValue | null, + fingerprint: string + ): Prisma.InputJsonObject { + const input = this.jsonObject(inputJson); + const prompt = this.stringifyText(input.prompt ?? input.text ?? input.title ?? input.content); + + switch (providerType) { + case 'TextProvider': + return { + text: `Mock text result ${fingerprint}: ${prompt || '生成故事、脚本或分镜文本。'}`, + sections: ['hook', 'conflict', 'turning_point', 'ending_hook'], + warnings: ['mock_text_provider_no_real_model'] + }; + case 'NovelProvider': + return { + idea: `Mock novel idea ${fingerprint}`, + outline: ['开局反击', '关系升级', '结尾反转'], + chapter_text: `第1章 Mock ${fingerprint}\n主角在关键节点做出选择,故事进入下一轮冲突。` + }; + case 'ImageProvider': + return { + asset_url: `mock://image/${fingerprint}.png`, + width: Number(input.width) || 1080, + height: Number(input.height) || 1920, + prompt: prompt || '高质量韩漫风角色或分镜图', + negative_prompt: this.stringifyText(input.negative_prompt) || '低清晰度,多余手指,文字乱码' + }; + case 'VideoProvider': + return { + asset_url: `mock://video/${fingerprint}.mp4`, + duration: Number(input.duration) || 4, + motion: this.stringifyText(input.motion) || 'subtle_zoom', + source_image_url: this.stringifyText(input.source_image_url) || null + }; + case 'VoiceProvider': + return { + asset_url: `mock://audio/${fingerprint}.mp3`, + voice: this.stringifyText(input.voice) || 'mock-cn-female', + duration: Math.max(1, Math.ceil((prompt.length || 30) / 8)) + }; + case 'LipSyncProvider': + return { + asset_url: `mock://lipsync/${fingerprint}.mp4`, + video_available: false, + lip_sync_applied: true, + mock_passthrough: true, + duration: Number(input.duration) || Number(input.target_duration) || 4, + warnings: ['mock_lipsync_provider_does_not_modify_video'] + }; + case 'ModerationProvider': { + const issues = this.findMockModerationIssues(prompt); + const blocked = issues.length > 0; + + return { + result_status: blocked ? 'manual_required' : 'passed', + risk_level: blocked ? 'high' : 'low', + issues + }; + } + case 'QualityCheckProvider': { + const weak = ['低清晰度', '模糊', '乱码', '崩坏'].some((word) => prompt.includes(word)); + + return { + result_status: weak ? 'needs_retry' : 'passed', + quality_score: weak ? 72 : 94, + issues: weak ? ['mock_quality_keyword'] : [] + }; + } + case 'FileParseProvider': { + const text = prompt || 'Mock file parse text.'; + + return { + text, + word_count: text.length, + chapter_count: Math.max(1, (text.match(/第.+章/g) ?? []).length) + }; + } + case 'EmbeddingProvider': + return { + dimension: 8, + vector: this.createMockVector(fingerprint), + checksum: fingerprint + }; + default: + return { + result: `Mock provider result ${fingerprint}` + }; + } + } + + private async runOpenAiResponsesProvider( + provider: ProviderConfig, + context: ProviderExecutionContext, + config: Record + ): Promise { + const prompt = this.createTextProviderInput(context.input_json); + + if (!prompt) { + throw new Error('OPENAI_INPUT_EMPTY'); + } + + const body: Record = { + model: this.resolveModelName(provider, config), + input: prompt + }; + const instructions = this.stringifyText(config.instructions); + const maxOutputTokens = this.numberFromJson(config.max_output_tokens); + const temperature = this.optionalNumberFromJson(config.temperature); + + if (instructions) body.instructions = instructions; + if (maxOutputTokens > 0) body.max_output_tokens = maxOutputTokens; + if (temperature !== null) body.temperature = temperature; + + const response = await this.postOpenAiJson(config, '/responses', body); + const text = this.extractOpenAiResponseText(response.body); + + if (!text) { + throw new Error('OPENAI_EMPTY_RESPONSE_TEXT'); + } + + const providerRequestId = + this.stringifyText(response.body.id) || + response.request_id || + this.createProviderRequestId(provider, context.input_json); + + return { + provider_request_id: providerRequestId, + output_json: this.createRealProviderOutput(provider, context, providerRequestId, { + ...this.createRealTextOutput(provider.provider_type as ProviderType, text), + response_id: this.stringifyText(response.body.id) || null, + usage: this.toJsonValueOrNull(response.body.usage) + }) + }; + } + + private async runOpenAiModerationProvider( + provider: ProviderConfig, + context: ProviderExecutionContext, + config: Record + ): Promise { + const input = this.createTextProviderInput(context.input_json); + + if (!input) { + throw new Error('OPENAI_INPUT_EMPTY'); + } + + const response = await this.postOpenAiJson(config, '/moderations', { + model: this.resolveModelName(provider, config), + input + }); + const results = Array.isArray(response.body.results) ? response.body.results : []; + const first = this.objectFromUnknown(results[0]); + const categories = this.objectFromUnknown(first.categories); + const scores = this.objectFromUnknown(first.category_scores); + const issues = Object.entries(categories) + .filter(([, value]) => value === true) + .map(([key]) => key); + const maxScore = Math.max( + 0, + ...Object.values(scores) + .map((value) => Number(value)) + .filter((value) => Number.isFinite(value)) + ); + const flagged = first.flagged === true || issues.length > 0; + const providerRequestId = + this.stringifyText(response.body.id) || + response.request_id || + this.createProviderRequestId(provider, context.input_json); + + return { + provider_request_id: providerRequestId, + output_json: this.createRealProviderOutput(provider, context, providerRequestId, { + result_status: flagged ? 'manual_required' : 'passed', + risk_level: flagged ? (maxScore >= 0.85 ? 'high' : 'medium') : 'low', + flagged, + issues, + categories: this.toJsonValueOrNull(categories), + category_scores: this.toJsonValueOrNull(scores), + response_id: this.stringifyText(response.body.id) || null, + usage: this.toJsonValueOrNull(response.body.usage) + }) + }; + } + + private async runOpenAiEmbeddingProvider( + provider: ProviderConfig, + context: ProviderExecutionContext, + config: Record + ): Promise { + const input = this.createTextProviderInput(context.input_json); + + if (!input) { + throw new Error('OPENAI_INPUT_EMPTY'); + } + + const response = await this.postOpenAiJson(config, '/embeddings', { + model: this.resolveModelName(provider, config), + input + }); + const data = Array.isArray(response.body.data) ? response.body.data : []; + const first = this.objectFromUnknown(data[0]); + const vector = Array.isArray(first.embedding) + ? first.embedding.map((value) => Number(value)).filter((value) => Number.isFinite(value)) + : []; + + if (vector.length === 0) { + throw new Error('OPENAI_EMPTY_EMBEDDING'); + } + + const providerRequestId = response.request_id || this.createProviderRequestId(provider, context.input_json); + + return { + provider_request_id: providerRequestId, + output_json: this.createRealProviderOutput(provider, context, providerRequestId, { + dimension: vector.length, + vector, + checksum: createHash('sha256').update(JSON.stringify(vector)).digest('hex').slice(0, 16), + usage: this.toJsonValueOrNull(response.body.usage) + }) + }; + } + + private async runOpenAiImageProvider( + provider: ProviderConfig, + context: ProviderExecutionContext, + config: Record + ): Promise { + const input = this.jsonObject(context.input_json); + const prompt = this.createTextProviderInput(context.input_json); + + if (!prompt) { + throw new Error('OPENAI_INPUT_EMPTY'); + } + + const body: Record = { + model: this.resolveModelName(provider, config), + prompt, + n: 1 + }; + const size = this.stringifyText(input.size) || this.stringifyText(config.size); + const quality = this.stringifyText(input.quality) || this.stringifyText(config.quality); + const outputFormat = + this.stringifyText(input.output_format) || this.stringifyText(config.output_format); + const moderation = this.stringifyText(input.moderation) || this.stringifyText(config.moderation); + + if (size) body.size = size; + if (quality) body.quality = quality; + if (outputFormat) body.output_format = outputFormat; + if (moderation) body.moderation = moderation; + + const response = await this.postOpenAiJson(config, '/images/generations', body); + const data = Array.isArray(response.body.data) ? response.body.data : []; + const first = this.objectFromUnknown(data[0]); + const b64 = this.stringifyText(first.b64_json); + const url = this.stringifyText(first.url); + + if (!b64 && !url) { + throw new Error('OPENAI_EMPTY_IMAGE'); + } + + const providerRequestId = response.request_id || this.createProviderRequestId(provider, context.input_json); + const decodedSize = b64 ? Buffer.byteLength(b64, 'base64') : null; + + return { + provider_request_id: providerRequestId, + output_json: this.createRealProviderOutput(provider, context, providerRequestId, { + asset_url: url || `openai://image/${providerRequestId}.${outputFormat || 'png'}`, + image_available: true, + image_b64_sha256: b64 ? createHash('sha256').update(b64).digest('hex') : null, + image_b64_size: b64.length || null, + image_bytes: decodedSize, + prompt, + revised_prompt: this.stringifyText(first.revised_prompt) || null, + size: size || null, + quality: quality || null, + output_format: outputFormat || null + }), + transient_output_json: b64 + ? { + content_base64: b64, + mime_type: this.imageMimeType(outputFormat) + } + : undefined + }; + } + + private async runOpenAiVideoProvider( + provider: ProviderConfig, + context: ProviderExecutionContext, + config: Record + ): Promise { + const input = this.jsonObject(context.input_json); + const prompt = this.createVideoPrompt(context.input_json); + + if (!prompt) { + throw new Error('OPENAI_INPUT_EMPTY'); + } + + const body: Record = { + model: this.resolveModelName(provider, config), + prompt + }; + const size = this.stringifyText(input.size) || this.stringifyText(config.size); + const numericSeconds = + this.optionalNumberFromJson(input.seconds) ?? + this.optionalNumberFromJson(input.duration_seconds) ?? + this.optionalNumberFromJson(input.duration); + const seconds = + this.stringifyText(input.seconds) || + this.stringifyText(input.duration_seconds) || + this.stringifyText(input.duration) || + (numericSeconds !== null ? String(numericSeconds) : '') || + this.stringifyText(config.seconds); + const inputReference = this.createVideoInputReference(input); + const characters = Array.isArray(input.characters) ? input.characters : null; + + if (size) body.size = size; + if (seconds) body.seconds = seconds; + if (inputReference) body.input_reference = inputReference; + if (characters) body.characters = characters; + + const createEndpoint = this.stringifyText(config.create_endpoint) || '/videos'; + const createResponse = await this.postOpenAiJson(config, createEndpoint, body); + let video = createResponse.body; + const videoId = this.stringifyText(video.id); + + if (!videoId) { + throw new Error('OPENAI_VIDEO_ID_MISSING'); + } + + const providerRequestId = + videoId || createResponse.request_id || this.createProviderRequestId(provider, context.input_json); + const statusEndpointTemplate = + this.stringifyText(config.status_endpoint_template) || '/videos/{video_id}'; + const maxPollAttempts = this.normalizePositiveNumber(config.max_poll_attempts, 1, 360, 60); + const pollIntervalMs = this.normalizePositiveNumber(config.poll_interval_ms, 0, 60000, 10000); + let status = this.stringifyText(video.status) || 'queued'; + + for (let attempt = 0; !this.isOpenAiVideoTerminalStatus(status); attempt += 1) { + if (attempt >= maxPollAttempts) { + throw new Error(`OPENAI_VIDEO_TIMEOUT: status ${status}`); + } + if (pollIntervalMs > 0) { + await this.sleep(pollIntervalMs); + } + + const statusResponse = await this.getOpenAiJson( + config, + this.templateEndpoint(statusEndpointTemplate, videoId) + ); + + video = statusResponse.body; + status = this.stringifyText(video.status) || status; + } + + if (!this.isOpenAiVideoCompletedStatus(status)) { + const error = this.objectFromUnknown(video.error); + const message = + this.stringifyText(error.message) || + this.stringifyText(video.error_message) || + `Video generation ended with status ${status}`; + + throw new Error(`OPENAI_VIDEO_FAILED: ${this.sanitizeErrorText(message)}`); + } + + const outputPayload: Record = { + asset_url: `openai://video/${videoId}.mp4`, + video_available: true, + video_id: videoId, + status, + progress: this.optionalNumberFromJson(video.progress), + prompt, + size: this.stringifyText(video.size) || size || null, + seconds: this.stringifyText(video.seconds) || seconds || null, + created_at: this.optionalNumberFromJson(video.created_at), + usage: this.toJsonValueOrNull(video.usage) + }; + + if (!context.return_binary) { + return { + provider_request_id: providerRequestId, + output_json: this.createRealProviderOutput(provider, context, providerRequestId, outputPayload) + }; + } + + const contentEndpointTemplate = + this.stringifyText(config.content_endpoint_template) || '/videos/{video_id}/content'; + const contentResponse = await this.getOpenAiBinary( + config, + this.templateEndpoint(contentEndpointTemplate, videoId) + ); + const contentType = contentResponse.content_type || 'video/mp4'; + const videoSha256 = createHash('sha256').update(contentResponse.buffer).digest('hex'); + + return { + provider_request_id: providerRequestId, + output_json: this.createRealProviderOutput(provider, context, providerRequestId, { + ...outputPayload, + video_bytes: contentResponse.buffer.length, + video_sha256: videoSha256, + mime_type: contentType + }), + transient_output_json: { + content_base64: contentResponse.buffer.toString('base64'), + mime_type: contentType + } + }; + } + + private async runRunwayImageToVideoProvider( + provider: ProviderConfig, + context: ProviderExecutionContext, + config: Record + ): Promise { + const input = this.jsonObject(context.input_json); + const prompt = this.createVideoPrompt(context.input_json); + const promptImage = this.createImageToVideoReference(input); + + if (!prompt) { + throw new Error('RUNWAY_INPUT_EMPTY'); + } + if (!promptImage) { + throw new Error('RUNWAY_IMAGE_REQUIRED'); + } + + const duration = this.normalizeConfigurableVideoDuration( + this.optionalNumberFromJson(input.duration) ?? + this.optionalNumberFromJson(input.duration_seconds) ?? + this.optionalNumberFromJson(input.seconds) ?? + this.optionalNumberFromJson(config.duration) ?? + 5, + config + ); + const body: Record = { + model: this.resolveModelName(provider, config), + promptImage, + promptText: prompt.slice(0, 1000), + duration + }; + const ratio = this.stringifyText(input.ratio) || this.stringifyText(config.ratio); + + if (ratio) body.ratio = ratio; + + const headers = this.runwayHeaders(config); + const createEndpoint = this.stringifyText(config.create_endpoint) || '/v1/image_to_video'; + const createResponse = await this.postProviderJson(config, createEndpoint, body, headers, 'RUNWAY'); + let task = createResponse.body; + const taskId = this.extractProviderTaskId(task); + + if (!taskId) { + throw new Error('RUNWAY_TASK_ID_MISSING'); + } + + const providerRequestId = + taskId || createResponse.request_id || this.createProviderRequestId(provider, context.input_json); + const taskEndpointTemplate = + this.stringifyText(config.task_endpoint_template) || + this.stringifyText(config.status_endpoint_template) || + '/v1/tasks/{task_id}'; + const maxPollAttempts = this.normalizePositiveNumber(config.max_poll_attempts, 1, 360, 90); + const pollIntervalMs = this.normalizePositiveNumber(config.poll_interval_ms, 0, 60000, 10000); + let status = this.extractProviderTaskStatus(task) || 'queued'; + + for (let attempt = 0; !this.isExternalVideoTerminalStatus(status); attempt += 1) { + if (attempt >= maxPollAttempts) { + throw new Error(`RUNWAY_VIDEO_TIMEOUT: status ${status}`); + } + if (pollIntervalMs > 0) { + await this.sleep(pollIntervalMs); + } + + const statusResponse = await this.getProviderJson( + config, + this.templateTaskEndpoint(taskEndpointTemplate, taskId), + headers, + 'RUNWAY' + ); + + task = statusResponse.body; + status = this.extractProviderTaskStatus(task) || status; + } + + if (!this.isExternalVideoCompletedStatus(status)) { + throw new Error(`RUNWAY_VIDEO_FAILED: ${this.extractProviderErrorMessage(task, status)}`); + } + + return this.createExternalVideoProviderOutput(provider, context, config, { + providerRequestId, + taskId, + status, + task, + prompt, + duration, + assetUrlPrefix: 'runway' + }); + } + + private async runKlingImageToVideoProvider( + provider: ProviderConfig, + context: ProviderExecutionContext, + config: Record + ): Promise { + const input = this.jsonObject(context.input_json); + const prompt = this.createVideoPrompt(context.input_json); + const sourceImage = this.createImageToVideoReference(input); + + if (!prompt) { + throw new Error('KLING_INPUT_EMPTY'); + } + if (!sourceImage) { + throw new Error('KLING_IMAGE_REQUIRED'); + } + + const duration = this.normalizeConfigurableVideoDuration( + this.optionalNumberFromJson(input.duration) ?? + this.optionalNumberFromJson(input.duration_seconds) ?? + this.optionalNumberFromJson(input.seconds) ?? + this.optionalNumberFromJson(config.duration) ?? + 5, + config + ); + const body: Record = { + model: this.resolveModelName(provider, config) + }; + const promptField = this.stringifyText(config.prompt_field) || 'prompt'; + const imageField = this.stringifyText(config.image_field) || 'image'; + const durationField = this.stringifyText(config.duration_field) || 'duration'; + const aspectRatioField = this.stringifyText(config.aspect_ratio_field) || 'aspect_ratio'; + const aspectRatio = + this.stringifyText(input.aspect_ratio) || + this.stringifyText(input.ratio) || + this.stringifyText(config.aspect_ratio); + + body[promptField] = prompt; + body[imageField] = sourceImage; + body[durationField] = duration; + if (aspectRatio) body[aspectRatioField] = aspectRatio; + + const headers = this.externalVideoHeaders(config); + const createEndpoint = this.stringifyText(config.create_endpoint) || '/v1/videos/image2video'; + const createResponse = await this.postProviderJson(config, createEndpoint, body, headers, 'KLING'); + let task = createResponse.body; + const taskId = this.extractProviderTaskId(task); + + if (!taskId) { + throw new Error('KLING_TASK_ID_MISSING'); + } + + const providerRequestId = + taskId || createResponse.request_id || this.createProviderRequestId(provider, context.input_json); + const taskEndpointTemplate = + this.stringifyText(config.task_endpoint_template) || + this.stringifyText(config.status_endpoint_template) || + '/v1/videos/image2video/{task_id}'; + const maxPollAttempts = this.normalizePositiveNumber(config.max_poll_attempts, 1, 360, 90); + const pollIntervalMs = this.normalizePositiveNumber(config.poll_interval_ms, 0, 60000, 10000); + let status = this.extractProviderTaskStatus(task) || 'submitted'; + + for (let attempt = 0; !this.isExternalVideoTerminalStatus(status); attempt += 1) { + if (attempt >= maxPollAttempts) { + throw new Error(`KLING_VIDEO_TIMEOUT: status ${status}`); + } + if (pollIntervalMs > 0) { + await this.sleep(pollIntervalMs); + } + + const statusResponse = await this.getProviderJson( + config, + this.templateTaskEndpoint(taskEndpointTemplate, taskId), + headers, + 'KLING' + ); + + task = statusResponse.body; + status = this.extractProviderTaskStatus(task) || status; + } + + if (!this.isExternalVideoCompletedStatus(status)) { + throw new Error(`KLING_VIDEO_FAILED: ${this.extractProviderErrorMessage(task, status)}`); + } + + return this.createExternalVideoProviderOutput(provider, context, config, { + providerRequestId, + taskId, + status, + task, + prompt, + duration, + assetUrlPrefix: 'kling' + }); + } + + private async runConfigurableImageToVideoProvider( + provider: ProviderConfig, + context: ProviderExecutionContext, + config: Record + ): Promise { + const input = this.jsonObject(context.input_json); + const prompt = this.createVideoPrompt(context.input_json); + const sourceImage = this.createImageToVideoReference(input); + const errorPrefix = this.externalProviderErrorPrefix(provider, config); + + if (!prompt) { + throw new Error(`${errorPrefix}_INPUT_EMPTY`); + } + if (!sourceImage) { + throw new Error(`${errorPrefix}_IMAGE_REQUIRED`); + } + + const duration = this.normalizeConfigurableVideoDuration( + this.optionalNumberFromJson(input.duration) ?? + this.optionalNumberFromJson(input.duration_seconds) ?? + this.optionalNumberFromJson(input.seconds) ?? + this.optionalNumberFromJson(config.duration) ?? + 5, + config + ); + const body = this.createConfigurableImageToVideoBody(provider, config, input, { + prompt, + sourceImage, + duration + }); + const headers = this.externalVideoHeaders(config); + const createEndpoint = this.stringifyText(config.create_endpoint); + + if (!createEndpoint) { + throw new Error(`${errorPrefix}_CREATE_ENDPOINT_NOT_CONFIGURED`); + } + + const createResponse = await this.postProviderJson( + config, + createEndpoint, + body, + headers, + errorPrefix + ); + let task = createResponse.body; + this.assertExternalProviderBusinessOk(errorPrefix, task); + const taskId = this.extractProviderTaskId(task); + + if (!taskId) { + throw this.externalProviderResponseError(`${errorPrefix}_TASK_ID_MISSING`, task); + } + + const providerRequestId = + taskId || createResponse.request_id || this.createProviderRequestId(provider, context.input_json); + const taskEndpointTemplate = + this.stringifyText(config.task_endpoint_template) || + this.stringifyText(config.status_endpoint_template); + + if (!taskEndpointTemplate) { + throw new Error(`${errorPrefix}_TASK_ENDPOINT_NOT_CONFIGURED`); + } + + const maxPollAttempts = this.normalizePositiveNumber(config.max_poll_attempts, 1, 360, 90); + const pollIntervalMs = this.normalizePositiveNumber(config.poll_interval_ms, 0, 60000, 10000); + let status = this.extractProviderTaskStatus(task) || 'submitted'; + + for (let attempt = 0; !this.isExternalVideoTerminalStatus(status); attempt += 1) { + if (attempt >= maxPollAttempts) { + throw new Error(`${errorPrefix}_VIDEO_TIMEOUT: status ${status}`); + } + if (pollIntervalMs > 0) { + await this.sleep(pollIntervalMs); + } + + const statusResponse = await this.getProviderJson( + config, + this.templateTaskEndpoint(taskEndpointTemplate, taskId), + headers, + errorPrefix + ); + + task = statusResponse.body; + this.assertExternalProviderBusinessOk(errorPrefix, task); + status = this.extractProviderTaskStatus(task) || status; + } + + if (!this.isExternalVideoCompletedStatus(status)) { + throw new Error(`${errorPrefix}_VIDEO_FAILED: ${this.extractProviderErrorMessage(task, status)}`); + } + + return this.createExternalVideoProviderOutput(provider, context, config, { + providerRequestId, + taskId, + status, + task, + prompt, + duration, + assetUrlPrefix: this.providerAssetUrlPrefix(provider.provider_code) + }); + } + + private async runOpenAiSpeechProvider( + provider: ProviderConfig, + context: ProviderExecutionContext, + config: Record + ): Promise { + const input = this.jsonObject(context.input_json); + const text = this.createTextProviderInput(context.input_json); + + if (!text) { + throw new Error('OPENAI_INPUT_EMPTY'); + } + + const voice = this.normalizeOpenAiSpeechVoice( + this.stringifyText(input.voice) || this.stringifyText(config.voice) || 'coral', + config + ); + const responseFormat = + this.stringifyText(input.response_format) || this.stringifyText(config.response_format) || 'mp3'; + const instructions = + this.stringifyText(input.instructions) || this.stringifyText(config.instructions); + const body: Record = { + model: this.resolveModelName(provider, config), + input: text, + voice, + response_format: responseFormat + }; + + if (instructions) body.instructions = instructions; + + const response = await this.postOpenAiBinary(config, '/audio/speech', body); + const providerRequestId = response.request_id || this.createProviderRequestId(provider, context.input_json); + + return { + provider_request_id: providerRequestId, + output_json: this.createRealProviderOutput(provider, context, providerRequestId, { + asset_url: `openai://audio/${providerRequestId}.${responseFormat}`, + audio_available: true, + audio_bytes: response.buffer.length, + audio_sha256: createHash('sha256').update(response.buffer).digest('hex'), + mime_type: response.content_type || `audio/${responseFormat}`, + voice, + response_format: responseFormat, + duration: Math.max(1, Math.ceil(text.length / 8)) + }), + transient_output_json: { + content_base64: response.buffer.toString('base64'), + mime_type: response.content_type || this.audioMimeType(responseFormat) + } + }; + } + + private async runOpenAiCompatibleChatProvider( + provider: ProviderConfig, + context: ProviderExecutionContext, + config: Record + ): Promise { + const prompt = this.createTextProviderInput(context.input_json); + const errorPrefix = this.externalProviderErrorPrefix(provider, config); + + if (!prompt) { + throw new Error(`${errorPrefix}_INPUT_EMPTY`); + } + + const body = this.createOpenAiCompatibleChatBody(provider, context, config, prompt); + const endpoint = this.configuredProviderEndpoint(provider, config, 'chat_endpoint', '/chat/completions'); + const response = await this.postProviderJson(config, endpoint, body, this.externalProviderHeaders(config), errorPrefix); + const text = this.extractOpenAiCompatibleChatText(response.body); + + if (!text) { + throw new Error(`${errorPrefix}_EMPTY_RESPONSE_TEXT`); + } + + const providerRequestId = + this.stringifyText(response.body.id) || + response.request_id || + this.createProviderRequestId(provider, context.input_json); + + return { + provider_request_id: providerRequestId, + output_json: this.createRealProviderOutput(provider, context, providerRequestId, { + ...this.createRealTextOutput(provider.provider_type as ProviderType, text), + response_id: this.stringifyText(response.body.id) || null, + usage: this.toJsonValueOrNull(response.body.usage) + }) + }; + } + + private async runAnthropicMessagesProvider( + provider: ProviderConfig, + context: ProviderExecutionContext, + config: Record + ): Promise { + const input = this.jsonObject(context.input_json); + const prompt = this.createTextProviderInput(context.input_json); + const errorPrefix = this.externalProviderErrorPrefix(provider, config); + + if (!prompt) { + throw new Error(`${errorPrefix}_INPUT_EMPTY`); + } + + const maxTokens = + this.numberFromJson(input.max_tokens) || + this.numberFromJson(config.max_tokens) || + this.numberFromJson(config.max_output_tokens) || + 1600; + const body: Record = { + model: this.resolveModelName(provider, config), + max_tokens: maxTokens, + messages: [{ role: 'user', content: prompt }] + }; + const instructions = this.stringifyText(input.instructions) || this.stringifyText(config.instructions); + const temperature = this.optionalNumberFromJson(input.temperature ?? config.temperature); + + if (instructions) body.system = instructions; + if (temperature !== null) body.temperature = temperature; + + const response = await this.postProviderJson( + config, + this.configuredProviderEndpoint(provider, config, 'endpoint_template', '/v1/messages'), + this.mergePlainJsonObjects(body, this.jsonObject(config.extra_body_json), this.jsonObject(input.extra_body_json)), + this.externalProviderHeaders(config), + errorPrefix + ); + const text = this.extractAnthropicMessageText(response.body); + + if (!text) { + throw new Error(`${errorPrefix}_EMPTY_RESPONSE_TEXT`); + } + + const providerRequestId = + this.stringifyText(response.body.id) || + response.request_id || + this.createProviderRequestId(provider, context.input_json); + + return { + provider_request_id: providerRequestId, + output_json: this.createRealProviderOutput(provider, context, providerRequestId, { + ...this.createRealTextOutput(provider.provider_type as ProviderType, text), + response_id: this.stringifyText(response.body.id) || null, + stop_reason: this.stringifyText(response.body.stop_reason) || null, + usage: this.toJsonValueOrNull(response.body.usage) + }) + }; + } + + private async runGoogleGeminiGenerateContentProvider( + provider: ProviderConfig, + context: ProviderExecutionContext, + config: Record + ): Promise { + const input = this.jsonObject(context.input_json); + const prompt = this.createTextProviderInput(context.input_json); + const errorPrefix = this.externalProviderErrorPrefix(provider, config); + + if (!prompt) { + throw new Error(`${errorPrefix}_INPUT_EMPTY`); + } + + const generationConfig: Record = {}; + const maxOutputTokens = + this.numberFromJson(input.max_output_tokens) || + this.numberFromJson(input.max_tokens) || + this.numberFromJson(config.max_output_tokens) || + this.numberFromJson(config.max_tokens); + const temperature = this.optionalNumberFromJson(input.temperature ?? config.temperature); + + if (maxOutputTokens > 0) generationConfig.maxOutputTokens = maxOutputTokens; + if (temperature !== null) generationConfig.temperature = temperature; + + const body: Record = { + contents: [{ role: 'user', parts: [{ text: prompt }] }] + }; + const instructions = this.stringifyText(input.instructions) || this.stringifyText(config.instructions); + + if (instructions) { + body.systemInstruction = { parts: [{ text: instructions }] }; + } + if (Object.keys(generationConfig).length > 0) { + body.generationConfig = generationConfig; + } + + const response = await this.postProviderJson( + config, + this.configuredProviderEndpoint(provider, config, 'endpoint_template', '/v1beta/models/{model}:generateContent'), + this.mergePlainJsonObjects(body, this.jsonObject(config.extra_body_json), this.jsonObject(input.extra_body_json)), + this.externalProviderHeaders(config), + errorPrefix + ); + const text = this.extractGeminiText(response.body); + + if (!text) { + throw new Error(`${errorPrefix}_EMPTY_RESPONSE_TEXT`); + } + + const providerRequestId = + response.request_id || + this.stringifyText(response.body.responseId) || + this.createProviderRequestId(provider, context.input_json); + + return { + provider_request_id: providerRequestId, + output_json: this.createRealProviderOutput(provider, context, providerRequestId, { + ...this.createRealTextOutput(provider.provider_type as ProviderType, text), + response_id: this.stringifyText(response.body.responseId) || null, + usage: this.toJsonValueOrNull(response.body.usageMetadata) + }) + }; + } + + private async runCohereChatProvider( + provider: ProviderConfig, + context: ProviderExecutionContext, + config: Record + ): Promise { + const input = this.jsonObject(context.input_json); + const prompt = this.createTextProviderInput(context.input_json); + const errorPrefix = this.externalProviderErrorPrefix(provider, config); + + if (!prompt) { + throw new Error(`${errorPrefix}_INPUT_EMPTY`); + } + + const body: Record = { + model: this.resolveModelName(provider, config), + messages: [{ role: 'user', content: prompt }] + }; + const instructions = this.stringifyText(input.instructions) || this.stringifyText(config.instructions); + const maxTokens = + this.numberFromJson(input.max_tokens) || + this.numberFromJson(config.max_tokens) || + this.numberFromJson(config.max_output_tokens); + const temperature = this.optionalNumberFromJson(input.temperature ?? config.temperature); + + if (instructions) body.preamble = instructions; + if (maxTokens > 0) body.max_tokens = maxTokens; + if (temperature !== null) body.temperature = temperature; + + const response = await this.postProviderJson( + config, + this.configuredProviderEndpoint(provider, config, 'endpoint_template', '/v2/chat'), + this.mergePlainJsonObjects(body, this.jsonObject(config.extra_body_json), this.jsonObject(input.extra_body_json)), + this.externalProviderHeaders(config), + errorPrefix + ); + const text = this.extractCohereText(response.body); + + if (!text) { + throw new Error(`${errorPrefix}_EMPTY_RESPONSE_TEXT`); + } + + const providerRequestId = + this.stringifyText(response.body.id) || + response.request_id || + this.createProviderRequestId(provider, context.input_json); + + return { + provider_request_id: providerRequestId, + output_json: this.createRealProviderOutput(provider, context, providerRequestId, { + ...this.createRealTextOutput(provider.provider_type as ProviderType, text), + response_id: this.stringifyText(response.body.id) || null, + usage: this.toJsonValueOrNull(response.body.usage) + }) + }; + } + + private async runConfigurableImageProvider( + provider: ProviderConfig, + context: ProviderExecutionContext, + config: Record + ): Promise { + const input = this.jsonObject(context.input_json); + const prompt = this.createTextProviderInput(context.input_json); + const errorPrefix = this.externalProviderErrorPrefix(provider, config); + + if (!prompt) { + throw new Error(`${errorPrefix}_INPUT_EMPTY`); + } + + const body = this.createConfigurableImageBody(provider, config, input, prompt); + const response = await this.postProviderJson( + config, + this.configuredProviderEndpoint(provider, config, 'create_endpoint', '/images/generations'), + body, + this.externalProviderHeaders(config), + errorPrefix + ); + const b64 = this.extractProviderImageBase64(response.body); + const url = this.extractProviderImageUrl(response.body); + + if (!b64 && !url) { + throw new Error(`${errorPrefix}_EMPTY_IMAGE`); + } + + const providerRequestId = + this.stringifyText(response.body.id) || + response.request_id || + this.createProviderRequestId(provider, context.input_json); + const outputFormat = + this.stringifyText(input.output_format) || + this.stringifyText(config.output_format) || + this.stringifyText(config.response_format) || + 'png'; + const outputPayload: Record = { + asset_url: url || `${this.providerAssetUrlPrefix(provider.provider_code)}://image/${providerRequestId}.${outputFormat}`, + image_available: Boolean(b64 || url), + image_b64_sha256: b64 ? createHash('sha256').update(b64).digest('hex') : null, + image_b64_size: b64.length || null, + image_bytes: b64 ? Buffer.byteLength(b64, 'base64') : null, + prompt, + output_format: outputFormat, + usage: this.toJsonValueOrNull(response.body.usage ?? response.body.usageMetadata) + }; + + if (!context.return_binary) { + return { + provider_request_id: providerRequestId, + output_json: this.createRealProviderOutput(provider, context, providerRequestId, outputPayload) + }; + } + if (b64) { + return { + provider_request_id: providerRequestId, + output_json: this.createRealProviderOutput(provider, context, providerRequestId, outputPayload), + transient_output_json: { + content_base64: b64, + mime_type: this.imageMimeType(outputFormat) + } + }; + } + if (!/^https?:\/\//i.test(url)) { + throw new Error(`${errorPrefix}_IMAGE_URL_MISSING`); + } + + const contentResponse = await this.getProviderBinaryByUrl(config, url, errorPrefix); + const contentType = contentResponse.content_type || this.imageMimeType(outputFormat); + + return { + provider_request_id: providerRequestId, + output_json: this.createRealProviderOutput(provider, context, providerRequestId, { + ...outputPayload, + image_bytes: contentResponse.buffer.length, + image_sha256: createHash('sha256').update(contentResponse.buffer).digest('hex'), + mime_type: contentType + }), + transient_output_json: { + content_base64: contentResponse.buffer.toString('base64'), + mime_type: contentType + } + }; + } + + private async runConfigurableSpeechProvider( + provider: ProviderConfig, + context: ProviderExecutionContext, + config: Record + ): Promise { + const input = this.jsonObject(context.input_json); + const text = this.createTextProviderInput(context.input_json); + const errorPrefix = this.externalProviderErrorPrefix(provider, config); + + if (!text) { + throw new Error(`${errorPrefix}_INPUT_EMPTY`); + } + + const responseFormat = + this.stringifyText(input.response_format) || + this.stringifyText(input.output_format) || + this.stringifyText(config.response_format) || + 'mp3'; + const body = this.createConfigurableSpeechBody(provider, config, input, text, responseFormat); + const response = await this.postProviderBinary( + config, + this.configuredProviderEndpoint(provider, config, 'create_endpoint', '/v1/text-to-speech/{voice_id}'), + body, + this.externalProviderHeaders(config), + errorPrefix + ); + const providerRequestId = response.request_id || this.createProviderRequestId(provider, context.input_json); + let audioBuffer = response.buffer; + let contentType = response.content_type || this.audioMimeType(responseFormat); + + if (/json/i.test(contentType) || this.stringifyText(config.response_body_style) === 'json_base64') { + const parsed = this.parseProviderJsonResponse(audioBuffer.toString('utf8'), errorPrefix); + const parsedObject = this.objectFromUnknown(parsed); + this.assertExternalProviderBusinessOk(errorPrefix, parsedObject); + const audioB64 = this.extractProviderAudioBase64(parsedObject); + const audioUrl = this.firstUrlFromUnknown(parsedObject.audio_url ?? parsedObject.url ?? parsedObject.data ?? parsedObject.output); + + if (audioB64) { + audioBuffer = this.decodeProviderAudioPayload(audioB64); + contentType = this.audioMimeType(responseFormat); + } else if (/^https?:\/\//i.test(audioUrl)) { + const downloaded = await this.getProviderBinaryByUrl(config, audioUrl, errorPrefix); + audioBuffer = downloaded.buffer; + contentType = downloaded.content_type || this.audioMimeType(responseFormat); + } else { + throw new ExternalProviderResponseError( + `${errorPrefix}_EMPTY_AUDIO: provider returned JSON without audio payload`, + this.summarizeExternalProviderResponse(parsedObject) + ); + } + } + + return { + provider_request_id: providerRequestId, + output_json: this.createRealProviderOutput(provider, context, providerRequestId, { + asset_url: `${this.providerAssetUrlPrefix(provider.provider_code)}://audio/${providerRequestId}.${this.audioFileExtension(responseFormat)}`, + audio_available: true, + audio_bytes: audioBuffer.length, + audio_sha256: createHash('sha256').update(audioBuffer).digest('hex'), + mime_type: contentType, + voice: this.stringifyText(input.voice) || this.stringifyText(config.voice_id) || null, + response_format: responseFormat, + duration: Math.max(1, Math.ceil(text.length / 8)) + }), + transient_output_json: { + content_base64: audioBuffer.toString('base64'), + mime_type: contentType + } + }; + } + + private async runConfigurableLipSyncProvider( + provider: ProviderConfig, + context: ProviderExecutionContext, + config: Record + ): Promise { + const input = this.jsonObject(context.input_json); + const errorPrefix = this.externalProviderErrorPrefix(provider, config); + const body = this.createConfigurableLipSyncBody(provider, config, input); + const response = await this.postProviderJson( + config, + this.configuredProviderEndpoint(provider, config, 'create_endpoint', '/lip-sync'), + body, + this.externalProviderHeaders(config), + errorPrefix + ); + + this.assertExternalProviderBusinessOk(errorPrefix, response.body); + + const providerRequestId = + this.stringifyText(response.body.id) || + this.extractProviderTaskId(response.body) || + response.request_id || + this.createProviderRequestId(provider, context.input_json); + const videoUrl = this.extractProviderVideoUrl(response.body); + const videoBase64 = this.extractProviderVideoBase64(response.body); + const contentType = this.stringifyText(response.body.mime_type) || 'video/mp4'; + const outputPayload: Record = { + asset_url: videoUrl || `${this.providerAssetUrlPrefix(provider.provider_code)}://video/${providerRequestId}.mp4`, + video_available: Boolean(videoUrl || videoBase64), + lip_sync_applied: Boolean(videoUrl || videoBase64), + duration: + this.optionalNumberFromJson(input.duration) ?? + this.optionalNumberFromJson(input.target_duration) ?? + this.optionalNumberFromJson(config.duration), + raw_response_summary: this.summarizeExternalProviderResponse(response.body) + }; + + if (videoBase64) { + outputPayload.video_b64_sha256 = createHash('sha256').update(videoBase64).digest('hex'); + outputPayload.video_b64_size = Buffer.byteLength(videoBase64, 'base64'); + outputPayload.mime_type = contentType; + } + + if (!context.return_binary) { + return { + provider_request_id: providerRequestId, + output_json: this.createRealProviderOutput(provider, context, providerRequestId, outputPayload) + }; + } + + if (videoBase64) { + return { + provider_request_id: providerRequestId, + output_json: this.createRealProviderOutput(provider, context, providerRequestId, outputPayload), + transient_output_json: { + content_base64: videoBase64, + mime_type: contentType + } + }; + } + if (/^https?:\/\//i.test(videoUrl)) { + const contentResponse = await this.getProviderBinaryByUrl(config, videoUrl, errorPrefix); + const binaryContentType = contentResponse.content_type || contentType; + + return { + provider_request_id: providerRequestId, + output_json: this.createRealProviderOutput(provider, context, providerRequestId, { + ...outputPayload, + video_bytes: contentResponse.buffer.length, + video_sha256: createHash('sha256').update(contentResponse.buffer).digest('hex'), + mime_type: binaryContentType + }), + transient_output_json: { + content_base64: contentResponse.buffer.toString('base64'), + mime_type: binaryContentType + } + }; + } + + throw new Error(`${errorPrefix}_OUTPUT_VIDEO_MISSING`); + } + + private async runConfigurableAsyncLipSyncProvider( + provider: ProviderConfig, + context: ProviderExecutionContext, + config: Record + ): Promise { + const input = this.jsonObject(context.input_json); + const errorPrefix = this.externalProviderErrorPrefix(provider, config); + const body = this.createConfigurableLipSyncBody(provider, config, input); + const headers = this.externalProviderHeaders(config); + const createEndpoint = this.stringifyText(config.create_endpoint); + + if (!createEndpoint) { + throw new Error(`${errorPrefix}_CREATE_ENDPOINT_NOT_CONFIGURED`); + } + + const createResponse = await this.postProviderJson( + config, + this.configuredProviderEndpoint(provider, config, 'create_endpoint', createEndpoint), + body, + headers, + errorPrefix + ); + let task = createResponse.body; + + this.assertExternalProviderBusinessOk(errorPrefix, task); + const taskId = this.extractProviderTaskId(task); + + if (!taskId) { + throw this.externalProviderResponseError(`${errorPrefix}_TASK_ID_MISSING`, task); + } + + const providerRequestId = + taskId || createResponse.request_id || this.createProviderRequestId(provider, context.input_json); + const statusEndpointTemplate = + this.stringifyText(config.status_endpoint_template) || + this.stringifyText(config.task_endpoint_template); + + if (!statusEndpointTemplate) { + throw new Error(`${errorPrefix}_TASK_ENDPOINT_NOT_CONFIGURED`); + } + + const maxPollAttempts = this.normalizePositiveNumber(config.max_poll_attempts, 1, 360, 90); + const pollIntervalMs = this.normalizePositiveNumber(config.poll_interval_ms, 0, 60000, 10000); + let status = this.extractProviderTaskStatus(task) || 'submitted'; + + for (let attempt = 0; !this.isExternalVideoTerminalStatus(status); attempt += 1) { + if (attempt >= maxPollAttempts) { + throw new Error(`${errorPrefix}_LIP_SYNC_TIMEOUT: status ${status}`); + } + if (pollIntervalMs > 0) { + await this.sleep(pollIntervalMs); + } + + const statusResponse = await this.getProviderJson( + config, + this.configuredProviderEndpoint(provider, config, 'status_endpoint_template', statusEndpointTemplate, { + task_id: taskId, + id: taskId + }), + headers, + errorPrefix + ); + + task = statusResponse.body; + this.assertExternalProviderBusinessOk(errorPrefix, task); + status = this.extractProviderTaskStatus(task) || status; + } + + if (!this.isExternalVideoCompletedStatus(status)) { + throw new Error(`${errorPrefix}_LIP_SYNC_FAILED: ${this.extractProviderErrorMessage(task, status)}`); + } + + const taskEndpointTemplate = this.stringifyText(config.task_endpoint_template); + if (taskEndpointTemplate && taskEndpointTemplate !== statusEndpointTemplate) { + const resultResponse = await this.getProviderJson( + config, + this.configuredProviderEndpoint(provider, config, 'task_endpoint_template', taskEndpointTemplate, { + task_id: taskId, + id: taskId + }), + headers, + errorPrefix + ); + + task = resultResponse.body; + this.assertExternalProviderBusinessOk(errorPrefix, task); + } + + return this.createGenericAssetProviderOutput(provider, context, config, { + providerRequestId, + taskId, + task, + prompt: this.stringifyText(input.text) || this.stringifyText(input.dialogue_text) || 'lip-sync' + }); + } + + private async runConfigurableAsyncAssetProvider( + provider: ProviderConfig, + context: ProviderExecutionContext, + config: Record + ): Promise { + const input = this.jsonObject(context.input_json); + const prompt = + provider.provider_type === 'VideoProvider' + ? this.createVideoPrompt(context.input_json) + : this.createTextProviderInput(context.input_json); + const errorPrefix = this.externalProviderErrorPrefix(provider, config); + + if (!prompt) { + throw new Error(`${errorPrefix}_INPUT_EMPTY`); + } + + const body = this.createConfigurableAsyncAssetBody(provider, config, input, prompt); + const headers = this.externalProviderHeaders(config); + const createResponse = await this.postProviderJson( + config, + this.configuredProviderEndpoint(provider, config, 'create_endpoint', '/predictions'), + body, + headers, + errorPrefix + ); + let task = createResponse.body; + const taskId = this.extractProviderTaskId(task); + + if (!taskId) { + throw new Error(`${errorPrefix}_TASK_ID_MISSING`); + } + + const providerRequestId = + taskId || createResponse.request_id || this.createProviderRequestId(provider, context.input_json); + const statusEndpointTemplate = + this.stringifyText(config.status_endpoint_template) || + this.stringifyText(config.task_endpoint_template); + + if (statusEndpointTemplate) { + const maxPollAttempts = this.normalizePositiveNumber(config.max_poll_attempts, 1, 360, 90); + const pollIntervalMs = this.normalizePositiveNumber(config.poll_interval_ms, 0, 60000, 10000); + let status = this.extractProviderTaskStatus(task) || 'submitted'; + + for (let attempt = 0; !this.isExternalVideoTerminalStatus(status); attempt += 1) { + if (attempt >= maxPollAttempts) { + throw new Error(`${errorPrefix}_ASSET_TIMEOUT: status ${status}`); + } + if (pollIntervalMs > 0) { + await this.sleep(pollIntervalMs); + } + + const statusResponse = await this.getProviderJson( + config, + this.configuredProviderEndpoint(provider, config, 'status_endpoint_template', statusEndpointTemplate, { + task_id: taskId, + id: taskId + }), + headers, + errorPrefix + ); + + task = statusResponse.body; + status = this.extractProviderTaskStatus(task) || status; + } + + if (!this.isExternalVideoCompletedStatus(status)) { + throw new Error(`${errorPrefix}_ASSET_FAILED: ${this.extractProviderErrorMessage(task, status)}`); + } + + const taskEndpointTemplate = this.stringifyText(config.task_endpoint_template); + if (taskEndpointTemplate && taskEndpointTemplate !== statusEndpointTemplate) { + const resultResponse = await this.getProviderJson( + config, + this.configuredProviderEndpoint(provider, config, 'task_endpoint_template', taskEndpointTemplate, { + task_id: taskId, + id: taskId + }), + headers, + errorPrefix + ); + task = resultResponse.body; + } + } + + return this.createGenericAssetProviderOutput(provider, context, config, { + providerRequestId, + taskId, + task, + prompt + }); + } + + private async runGoogleVeoVideoProvider( + provider: ProviderConfig, + context: ProviderExecutionContext, + config: Record + ): Promise { + const input = this.jsonObject(context.input_json); + const prompt = this.createVideoPrompt(context.input_json); + const errorPrefix = this.externalProviderErrorPrefix(provider, config); + + if (!prompt) { + throw new Error(`${errorPrefix}_INPUT_EMPTY`); + } + + const instance: Record = { prompt }; + const sourceImage = this.createImageToVideoReference(input); + + if (sourceImage) { + instance.image = { bytesBase64Encoded: this.stripDataUriPrefix(sourceImage) }; + } + + const parameters: Record = {}; + const aspectRatio = + this.stringifyText(input.aspect_ratio) || + this.stringifyText(input.ratio) || + this.stringifyText(config.aspect_ratio); + const negativePrompt = + this.stringifyText(input.negative_prompt) || this.stringifyText(config.negative_prompt); + const duration = + this.optionalNumberFromJson(input.duration) ?? + this.optionalNumberFromJson(input.seconds) ?? + this.optionalNumberFromJson(config.duration); + + if (aspectRatio) parameters.aspectRatio = aspectRatio; + if (negativePrompt) parameters.negativePrompt = negativePrompt; + if (duration !== null) parameters.durationSeconds = duration; + + const createResponse = await this.postProviderJson( + config, + this.configuredProviderEndpoint(provider, config, 'create_endpoint', '/v1beta/models/{model}:predictLongRunning'), + this.mergePlainJsonObjects( + { + instances: [instance], + parameters + }, + this.jsonObject(config.extra_body_json), + this.jsonObject(input.extra_body_json) + ), + this.externalProviderHeaders(config), + errorPrefix + ); + let task = createResponse.body; + const taskId = this.stringifyText(task.name) || this.extractProviderTaskId(task); + + if (!taskId) { + throw new Error(`${errorPrefix}_TASK_ID_MISSING`); + } + + const providerRequestId = + taskId || createResponse.request_id || this.createProviderRequestId(provider, context.input_json); + const maxPollAttempts = this.normalizePositiveNumber(config.max_poll_attempts, 1, 360, 120); + const pollIntervalMs = this.normalizePositiveNumber(config.poll_interval_ms, 0, 60000, 10000); + const taskEndpointTemplate = this.stringifyText(config.task_endpoint_template) || '/v1beta/{task_id}'; + + for (let attempt = 0; task.done !== true; attempt += 1) { + if (attempt >= maxPollAttempts) { + throw new Error(`${errorPrefix}_VIDEO_TIMEOUT`); + } + if (pollIntervalMs > 0) { + await this.sleep(pollIntervalMs); + } + + const statusResponse = await this.getProviderJson( + config, + this.configuredProviderEndpoint(provider, config, 'task_endpoint_template', taskEndpointTemplate, { + task_id: taskId, + id: taskId + }, false), + this.externalProviderHeaders(config), + errorPrefix + ); + task = statusResponse.body; + } + + const error = this.objectFromUnknown(task.error); + if (Object.keys(error).length > 0) { + throw new Error(`${errorPrefix}_VIDEO_FAILED: ${this.extractProviderErrorMessage(task, 'failed')}`); + } + + return this.createGenericAssetProviderOutput(provider, context, config, { + providerRequestId, + taskId, + task, + prompt + }); + } + + private mergeTransientOutput( + outputJson: Prisma.InputJsonValue, + transientOutputJson: Prisma.InputJsonObject | undefined + ) { + if (!transientOutputJson) { + return outputJson; + } + + return this.toJsonValue( + { + ...this.jsonObject(outputJson), + ...transientOutputJson + }, + 'provider_result' + ) as Prisma.InputJsonObject; + } + + private imageMimeType(format: string | null) { + switch ((format || 'png').toLowerCase()) { + case 'jpg': + case 'jpeg': + return 'image/jpeg'; + case 'webp': + return 'image/webp'; + case 'png': + default: + return 'image/png'; + } + } + + private audioMimeType(format: string | null) { + switch ((format || 'mp3').toLowerCase()) { + case 'wav': + return 'audio/wav'; + case 'opus': + return 'audio/ogg'; + case 'aac': + return 'audio/aac'; + case 'flac': + return 'audio/flac'; + case 'mp3': + default: + return 'audio/mpeg'; + } + } + + private createRealTextOutput(providerType: ProviderType, text: string) { + switch (providerType) { + case 'NovelProvider': + return { + text, + chapter_text: text + }; + case 'FileParseProvider': + return { + text, + word_count: text.length, + chapter_count: Math.max(1, (text.match(/第.+章/g) ?? []).length) + }; + case 'QualityCheckProvider': + return { + raw_text: text, + result_status: 'passed', + quality_score: null, + issues: [] + }; + default: + return { text }; + } + } + + private createRealProviderOutput( + provider: ProviderConfig, + context: ProviderExecutionContext, + providerRequestId: string, + payload: Record + ): Prisma.InputJsonObject { + return this.toJsonValue( + { + provider_request_id: providerRequestId, + provider_type: provider.provider_type, + provider_code: provider.provider_code, + model_name: provider.model_name, + mode: 'real', + purpose: context.purpose, + ...payload + }, + 'provider_output' + ) as Prisma.InputJsonObject; + } + + private createFailureResponseJson( + provider: ProviderConfig, + context: ProviderExecutionContext, + error: Error + ): Prisma.InputJsonObject { + const response: Record = { + provider_request_id: this.createProviderRequestId(provider, context.input_json), + fallback_allowed: context.allow_fallback + }; + const providerResponseSummary = this.providerResponseSummaryFromError(error); + + if (providerResponseSummary !== null) { + response.provider_response_summary = providerResponseSummary; + } + + return this.toJsonValue(response, 'provider_failure_response') as Prisma.InputJsonObject; + } + + private providerResponseSummaryFromError(error: Error) { + if (error instanceof ExternalProviderResponseError) { + return error.providerResponseSummary; + } + + return null; + } + + private createTextProviderInput(inputJson: Prisma.InputJsonValue | null) { + const input = this.jsonObject(inputJson); + const direct = this.stringifyText( + input.prompt ?? input.text ?? input.title ?? input.content ?? input.script ?? input.novel + ); + + if (direct) { + return direct; + } + + const serialized = this.stableStringify(inputJson); + + return serialized === '{}' || serialized === 'null' ? '' : serialized; + } + + private createOpenAiCompatibleChatBody( + provider: ProviderConfig, + context: ProviderExecutionContext, + config: Record, + prompt: string + ) { + const input = this.jsonObject(context.input_json); + const messages = Array.isArray(input.messages) + ? input.messages + : [ + ...( + this.stringifyText(input.instructions) || this.stringifyText(config.instructions) + ? [ + { + role: 'system', + content: this.stringifyText(input.instructions) || this.stringifyText(config.instructions) + } + ] + : [] + ), + { role: 'user', content: prompt } + ]; + const body: Record = { + model: this.resolveModelName(provider, config), + messages + }; + const maxTokens = + this.numberFromJson(input.max_tokens) || + this.numberFromJson(config.max_tokens) || + this.numberFromJson(config.max_output_tokens); + const temperature = this.optionalNumberFromJson(input.temperature ?? config.temperature); + + if (maxTokens > 0) body.max_tokens = maxTokens; + if (temperature !== null) body.temperature = temperature; + + return this.mergePlainJsonObjects( + body, + this.jsonObject(config.extra_body_json), + this.jsonObject(input.extra_body_json) + ); + } + + private createConfigurableImageBody( + provider: ProviderConfig, + config: Record, + input: Record, + prompt: string + ) { + const model = this.resolveModelName(provider, config); + const bodyStyle = this.stringifyText(config.body_style) || 'flat'; + const width = + this.optionalNumberFromJson(input.width) ?? + this.optionalNumberFromJson(config.width); + const height = + this.optionalNumberFromJson(input.height) ?? + this.optionalNumberFromJson(config.height); + const size = + this.stringifyText(input.size) || + this.stringifyText(config.size); + const aspectRatio = + this.stringifyText(input.aspect_ratio) || + this.stringifyText(input.ratio) || + this.stringifyText(config.aspect_ratio); + const negativePrompt = + this.stringifyText(input.negative_prompt) || this.stringifyText(config.negative_prompt); + let body: Record; + + if (bodyStyle === 'gemini_image_content') { + const generationConfig: Record = { + responseModalities: ['TEXT', 'IMAGE'] + }; + const imageConfig: Record = {}; + + if (aspectRatio) imageConfig.aspectRatio = aspectRatio; + if (size) imageConfig.imageSize = size; + if (this.stringifyText(config.image_size)) imageConfig.imageSize = this.stringifyText(config.image_size); + if (Object.keys(imageConfig).length > 0) generationConfig.imageConfig = imageConfig; + + body = { + contents: [{ role: 'user', parts: [{ text: prompt }] }], + generationConfig + }; + } else if (bodyStyle === 'stability_v1') { + body = { + text_prompts: [ + { text: prompt, weight: 1 }, + ...(negativePrompt ? [{ text: negativePrompt, weight: -1 }] : []) + ], + samples: 1 + }; + if (width !== null) body.width = width; + if (height !== null) body.height = height; + if (this.stringifyText(input.cfg_scale) || this.stringifyText(config.cfg_scale)) { + body.cfg_scale = this.numberFromJson(input.cfg_scale ?? config.cfg_scale); + } + if (this.stringifyText(input.style_preset) || this.stringifyText(config.style_preset)) { + body.style_preset = this.stringifyText(input.style_preset) || this.stringifyText(config.style_preset); + } + } else if (bodyStyle === 'ideogram_generate') { + body = { + image_request: { + model, + prompt, + aspect_ratio: aspectRatio || 'ASPECT_9_16' + } + }; + if (negativePrompt) { + (body.image_request as Record).negative_prompt = negativePrompt; + } + } else { + body = { + model, + prompt + }; + if (size) body.size = size; + if (width !== null) body.width = width; + if (height !== null) body.height = height; + if (aspectRatio) body.aspect_ratio = aspectRatio; + if (negativePrompt) body.negative_prompt = negativePrompt; + } + + return this.mergePlainJsonObjects( + body, + this.jsonObject(config.extra_body_json), + this.jsonObject(input.extra_body_json) + ); + } + + private createConfigurableSpeechBody( + provider: ProviderConfig, + config: Record, + input: Record, + text: string, + responseFormat: string + ) { + const bodyStyle = this.stringifyText(config.body_style) || 'elevenlabs_tts'; + const rawVoiceId = + this.stringifyText(input.voice_id) || + this.stringifyText(input.voice) || + this.stringifyText(config.voice_id) || + this.stringifyText(config.voice) || + 'default'; + const voiceId = this.normalizeConfigurableSpeechVoiceId(bodyStyle, rawVoiceId, config); + let body: Record; + + if (bodyStyle === 'minimax_tts') { + body = { + model: this.resolveModelName(provider, config), + text, + stream: false, + voice_setting: { + voice_id: voiceId, + speed: this.optionalNumberFromJson(input.speed ?? config.speed) ?? 1, + vol: this.optionalNumberFromJson(input.volume ?? config.volume) ?? 1, + pitch: this.optionalNumberFromJson(input.pitch ?? config.pitch) ?? 0 + }, + audio_setting: { + audio_sample_rate: this.optionalNumberFromJson(config.audio_sample_rate) ?? 32000, + bitrate: this.optionalNumberFromJson(config.bitrate) ?? 128000, + format: responseFormat.includes('wav') ? 'wav' : 'mp3', + channel: 1 + } + }; + } else if (bodyStyle === 'volcengine_tts') { + body = { + app: this.jsonObject(config.app), + user: this.jsonObject(config.user), + audio: { + voice_type: voiceId, + encoding: responseFormat.includes('wav') ? 'wav' : 'mp3' + }, + request: { + reqid: this.createProviderRequestId(provider, text), + text, + operation: 'query' + } + }; + } else { + body = { + text, + model_id: this.resolveModelName(provider, config), + output_format: responseFormat, + voice_settings: { + stability: this.optionalNumberFromJson(input.stability ?? config.stability) ?? 0.5, + similarity_boost: this.optionalNumberFromJson(input.similarity_boost ?? config.similarity_boost) ?? 0.75 + } + }; + } + + return this.mergePlainJsonObjects( + body, + this.jsonObject(config.extra_body_json), + this.jsonObject(input.extra_body_json) + ); + } + + private normalizeConfigurableSpeechVoiceId( + bodyStyle: string, + voiceId: string, + config: Record + ) { + if (bodyStyle !== 'minimax_tts') { + return voiceId; + } + + if (this.isOpenAiTtsVoice(voiceId)) { + return this.stringifyText(config.voice_id) || this.stringifyText(config.voice) || 'female-shaonv'; + } + + return voiceId; + } + + private normalizeOpenAiSpeechVoice( + voice: string, + config: Record + ) { + if (this.isOpenAiTtsVoice(voice)) { + return voice; + } + + return this.stringifyText(config.voice) || 'coral'; + } + + private isOpenAiTtsVoice(value: string) { + return new Set([ + 'alloy', + 'ash', + 'ballad', + 'coral', + 'echo', + 'fable', + 'nova', + 'onyx', + 'sage', + 'shimmer', + 'verse' + ]).has(value.trim().toLowerCase()); + } + + private createConfigurableLipSyncBody( + provider: ProviderConfig, + config: Record, + input: Record + ) { + const video = + this.stringifyText(input.video_data_uri) || + this.stringifyText(input.source_video_data_uri) || + this.stringifyText(input.video_url) || + this.stringifyText(input.source_video_url) || + this.stringifyText(input.video); + const audio = + this.stringifyText(input.audio_data_uri) || + this.stringifyText(input.source_audio_data_uri) || + this.stringifyText(input.audio_url) || + this.stringifyText(input.source_audio_url) || + this.stringifyText(input.audio); + const text = this.stringifyText(input.text) || this.stringifyText(input.dialogue_text); + + if (!video) { + throw new Error(`${this.externalProviderErrorPrefix(provider, config)}_VIDEO_INPUT_EMPTY`); + } + if (!audio) { + throw new Error(`${this.externalProviderErrorPrefix(provider, config)}_AUDIO_INPUT_EMPTY`); + } + + const videoField = this.stringifyText(config.video_field) || 'video'; + const audioField = this.stringifyText(config.audio_field) || 'audio'; + const textField = this.stringifyText(config.text_field) || 'text'; + const bodyStyle = this.stringifyText(config.body_style) || 'flat'; + const requiresPublicUrls = this.optionalBooleanFromJson(config.requires_public_urls) ?? false; + + if (requiresPublicUrls && (!/^https?:\/\//i.test(video) || !/^https?:\/\//i.test(audio))) { + throw new Error(`${this.externalProviderErrorPrefix(provider, config)}_PUBLIC_VIDEO_AUDIO_URL_REQUIRED`); + } + + const payload: Record = { + [videoField]: video, + [audioField]: audio + }; + const model = this.resolveModelName(provider, config); + const duration = + this.optionalNumberFromJson(input.duration) ?? + this.optionalNumberFromJson(input.target_duration) ?? + this.optionalNumberFromJson(config.duration); + + if (text) payload[textField] = text; + if (model) payload.model = model; + if (duration !== null) payload.duration = duration; + if (input.start_seconds !== undefined) payload.start_seconds = this.optionalNumberFromJson(input.start_seconds); + if (input.shot_id !== undefined) payload.shot_id = this.stringifyText(input.shot_id); + + let body: Record; + + if (bodyStyle === 'alibaba_videoretalk') { + const parameters = this.mergePlainJsonObjects( + this.jsonObject(config.parameters_json), + this.jsonObject(input.parameters_json) + ); + + body = { + model, + input: { + video_url: video, + audio_url: audio, + ...(text ? { text } : {}) + } + }; + if (Object.keys(parameters).length > 0) { + body.parameters = parameters; + } + } else if (bodyStyle === 'replicate_prediction') { + body = { input: payload }; + } else { + body = payload; + } + + return this.mergePlainJsonObjects( + body, + this.jsonObject(config.extra_body_json), + this.jsonObject(input.extra_body_json) + ); + } + + private createConfigurableAsyncAssetBody( + provider: ProviderConfig, + config: Record, + input: Record, + prompt: string + ) { + const model = this.resolveModelName(provider, config); + const bodyStyle = this.stringifyText(config.body_style) || 'flat'; + const sourceImage = this.createImageToVideoReference(input); + const duration = + this.optionalNumberFromJson(input.duration) ?? + this.optionalNumberFromJson(input.seconds) ?? + this.optionalNumberFromJson(input.duration_seconds) ?? + this.optionalNumberFromJson(config.duration); + const aspectRatio = + this.stringifyText(input.aspect_ratio) || + this.stringifyText(input.ratio) || + this.stringifyText(config.aspect_ratio); + let body: Record; + + if (bodyStyle === 'replicate_prediction') { + const requestInput: Record = { prompt }; + + if (sourceImage) requestInput.image = sourceImage; + if (duration !== null) requestInput.duration = duration; + if (aspectRatio) requestInput.aspect_ratio = aspectRatio; + + body = { input: requestInput }; + if (!this.stringifyText(config.create_endpoint).includes('/models/{model}/')) { + body.version = model; + } + } else { + body = { + model, + prompt + }; + if (sourceImage) { + const imageField = this.stringifyText(config.image_field) || 'image_url'; + body[imageField] = sourceImage; + } + if (duration !== null) { + const durationField = this.stringifyText(config.duration_field) || 'duration'; + body[durationField] = duration; + } + if (aspectRatio) { + const aspectRatioField = this.stringifyText(config.aspect_ratio_field) || 'aspect_ratio'; + body[aspectRatioField] = aspectRatio; + } + } + + return this.mergePlainJsonObjects( + body, + this.jsonObject(config.extra_body_json), + this.jsonObject(input.extra_body_json) + ); + } + + private createVideoPrompt(inputJson: Prisma.InputJsonValue | null) { + const input = this.jsonObject(inputJson); + const scalarText = (value: unknown) => + typeof value === 'number' || typeof value === 'boolean' + ? String(value) + : this.stringifyText(value); + const direct = this.stringifyText( + input.prompt ?? input.text ?? input.visual_desc ?? input.description ?? input.script + ); + + if (direct) { + return direct; + } + + const shotCount = scalarText(input.shot_count); + const motion = scalarText(input.motion); + const duration = + scalarText(input.seconds) || + scalarText(input.duration_seconds) || + scalarText(input.duration); + const parts = [ + '生成一段竖版中文漫剧分镜视频,画面干净,运动自然,适合短剧成片。', + shotCount ? `镜头数量:${shotCount}` : '', + duration ? `时长:${duration} 秒` : '', + motion ? `镜头运动:${motion}` : '' + ].filter(Boolean); + + return parts.join('\n'); + } + + private createVideoInputReference(input: Record) { + const rawReference = input.input_reference; + + if (rawReference && typeof rawReference === 'object' && !Array.isArray(rawReference)) { + return rawReference; + } + + const imageUrl = this.stringifyText(input.source_image_url ?? input.image_url); + const fileId = this.stringifyText(input.file_id); + + if (fileId) { + return { file_id: fileId }; + } + if (imageUrl) { + return { image_url: imageUrl }; + } + + return null; + } + + private createImageToVideoReference(input: Record) { + return this.stringifyText( + input.promptImage ?? + input.prompt_image ?? + input.source_image_data_uri ?? + input.source_image_url ?? + input.image_url ?? + input.image + ); + } + + private createConfigurableImageToVideoBody( + provider: ProviderConfig, + config: Record, + input: Record, + request: { + prompt: string; + sourceImage: string; + duration: number; + } + ) { + const model = this.resolveModelName(provider, config); + const bodyStyle = this.stringifyText(config.body_style) || 'flat'; + const resolution = + this.stringifyText(input.resolution) || + this.stringifyText(input.size) || + this.stringifyText(config.resolution) || + this.stringifyText(config.size); + const aspectRatio = + this.stringifyText(input.aspect_ratio) || + this.stringifyText(input.ratio) || + this.stringifyText(config.aspect_ratio) || + this.stringifyText(config.ratio); + const negativePrompt = + this.stringifyText(input.negative_prompt) || this.stringifyText(config.negative_prompt); + const audioUrl = this.stringifyText(input.audio_url) || this.stringifyText(config.audio_url); + const images = this.createImageToVideoReferenceList(input, request.sourceImage); + let body: Record; + + if (bodyStyle === 'dashscope_legacy_i2v') { + const inputBody: Record = { + prompt: request.prompt, + img_url: request.sourceImage + }; + const parameters: Record = {}; + + if (negativePrompt) inputBody.negative_prompt = negativePrompt; + if (resolution) parameters.resolution = resolution; + if (request.duration > 0) parameters.duration = request.duration; + this.assignOptionalBoolean(parameters, 'prompt_extend', input.prompt_extend ?? config.prompt_extend); + this.assignOptionalBoolean(parameters, 'watermark', input.watermark ?? config.watermark); + this.assignOptionalNumber(parameters, 'seed', input.seed ?? config.seed); + if (audioUrl) parameters.audio_url = audioUrl; + + body = { + model, + input: inputBody, + parameters + }; + } else if (bodyStyle === 'dashscope_media_i2v') { + const inputBody: Record = { + prompt: request.prompt, + media: images.map((url, index) => ({ + type: index === 0 ? 'first_frame' : 'reference_image', + url + })) + }; + const parameters: Record = {}; + + if (negativePrompt) inputBody.negative_prompt = negativePrompt; + if (resolution) parameters.resolution = resolution; + if (request.duration > 0) parameters.duration = request.duration; + if (aspectRatio) parameters.aspect_ratio = aspectRatio; + this.assignOptionalBoolean(parameters, 'prompt_extend', input.prompt_extend ?? config.prompt_extend); + this.assignOptionalBoolean(parameters, 'watermark', input.watermark ?? config.watermark); + this.assignOptionalNumber(parameters, 'seed', input.seed ?? config.seed); + if (audioUrl) { + inputBody.audio = { url: audioUrl }; + } + + body = { + model, + input: inputBody, + parameters + }; + } else if (bodyStyle === 'vidu_reference') { + body = { + model, + prompt: request.prompt, + images, + duration: request.duration + }; + if (resolution) body.resolution = resolution; + if (aspectRatio) body.aspect_ratio = aspectRatio; + if (negativePrompt) body.negative_prompt = negativePrompt; + if (audioUrl) body.audio_url = audioUrl; + } else { + body = {}; + const modelField = this.stringifyText(config.model_field) || 'model'; + const promptField = this.stringifyText(config.prompt_field) || 'prompt'; + const imageField = this.stringifyText(config.image_field) || 'image'; + const imageArrayField = this.stringifyText(config.image_array_field); + const durationField = this.stringifyText(config.duration_field) || 'duration'; + const resolutionField = + this.stringifyText(config.resolution_field) || this.stringifyText(config.size_field); + const aspectRatioField = + this.stringifyText(config.aspect_ratio_field) || this.stringifyText(config.ratio_field); + + if (modelField !== '-') body[modelField] = model; + if (promptField !== '-') body[promptField] = request.prompt; + if (imageArrayField) { + body[imageArrayField] = images; + } else if (imageField !== '-') { + body[imageField] = request.sourceImage; + } + if (durationField !== '-') body[durationField] = request.duration; + if (resolution && resolutionField) body[resolutionField] = resolution; + if (aspectRatio && aspectRatioField) body[aspectRatioField] = aspectRatio; + if (negativePrompt) { + const negativePromptField = + this.stringifyText(config.negative_prompt_field) || 'negative_prompt'; + body[negativePromptField] = negativePrompt; + } + if (audioUrl) { + const audioField = this.stringifyText(config.audio_field) || 'audio_url'; + body[audioField] = audioUrl; + } + } + + return this.mergePlainJsonObjects( + body, + this.jsonObject(config.extra_body_json), + this.jsonObject(input.extra_body_json) + ); + } + + private normalizeConfigurableVideoDuration( + requestedDuration: number, + config: Record + ) { + const allowedDurations = this.numberArrayFromJson(config.allowed_durations) + .filter((duration) => duration > 0) + .sort((left, right) => left - right); + + if (allowedDurations.length === 0) { + return requestedDuration; + } + + const roundedDuration = Math.max(1, Math.round(requestedDuration)); + const exact = allowedDurations.find((duration) => duration === roundedDuration); + + if (exact !== undefined) { + return exact; + } + + return ( + allowedDurations.find((duration) => duration >= roundedDuration) ?? + allowedDurations[allowedDurations.length - 1] + ); + } + + private createImageToVideoReferenceList( + input: Record, + fallback: string + ) { + const raw = + input.reference_images ?? + input.reference_image_urls ?? + input.image_urls ?? + input.images ?? + input.source_images; + const values = Array.isArray(raw) + ? raw.map((item) => this.stringifyText(item)).filter(Boolean) + : []; + + return values.length > 0 ? values : [fallback]; + } + + private assignOptionalBoolean(target: Record, key: string, value: unknown) { + const parsed = this.optionalBooleanFromJson(value); + + if (parsed !== null) { + target[key] = parsed; + } + } + + private assignOptionalNumber(target: Record, key: string, value: unknown) { + const parsed = this.optionalNumberFromJson(value); + + if (parsed !== null) { + target[key] = parsed; + } + } + + private mergePlainJsonObjects( + base: Record, + ...extras: Record[] + ) { + const output: Record = { ...base }; + + for (const extra of extras) { + for (const [key, value] of Object.entries(extra)) { + if ( + value && + typeof value === 'object' && + !Array.isArray(value) && + output[key] && + typeof output[key] === 'object' && + !Array.isArray(output[key]) + ) { + output[key] = this.mergePlainJsonObjects( + output[key] as Record, + value as Record + ); + } else { + output[key] = value; + } + } + } + + return output; + } + + private async createExternalVideoProviderOutput( + provider: ProviderConfig, + context: ProviderExecutionContext, + config: Record, + data: { + providerRequestId: string; + taskId: string; + status: string; + task: Record; + prompt: string; + duration: number; + assetUrlPrefix: string; + } + ): Promise { + let outputUrl = this.extractProviderVideoUrl(data.task); + let outputFileId = this.extractProviderFileId(data.task); + let outputLookupSummary: Prisma.InputJsonValue | null = null; + + if (!outputUrl) { + const outputEndpointTemplate = + this.stringifyText(config.output_url_endpoint_template) || + this.stringifyText(config.file_endpoint_template); + + if (outputEndpointTemplate && (!outputEndpointTemplate.includes('{file_id}') || outputFileId)) { + const outputResponse = await this.getProviderJson( + config, + this.templateProviderEndpoint(outputEndpointTemplate, { + task_id: data.taskId, + id: data.taskId, + file_id: outputFileId || data.taskId + }), + this.externalVideoHeaders(config), + 'VIDEO_PROVIDER' + ); + + outputUrl = this.extractProviderVideoUrl(outputResponse.body); + outputFileId = outputFileId || this.extractProviderFileId(outputResponse.body); + outputLookupSummary = this.toJsonValueOrNull({ + id: outputFileId || null, + video_url_present: Boolean(outputUrl) + }); + } + } + + const outputPayload: Record = { + asset_url: outputUrl || `${data.assetUrlPrefix}://video/${data.taskId}.mp4`, + video_available: Boolean(outputUrl), + task_id: data.taskId, + file_id: outputFileId || null, + status: data.status, + prompt: data.prompt, + duration: data.duration, + raw_task_summary: this.summarizeExternalVideoTask(data.task), + output_lookup_summary: outputLookupSummary + }; + + if (!context.return_binary) { + return { + provider_request_id: data.providerRequestId, + output_json: this.createRealProviderOutput(provider, context, data.providerRequestId, outputPayload) + }; + } + if (!/^https?:\/\//i.test(outputUrl)) { + throw new Error('VIDEO_PROVIDER_OUTPUT_URL_MISSING'); + } + + const contentResponse = await this.getProviderBinaryByUrl(config, outputUrl, 'VIDEO_PROVIDER'); + const contentType = contentResponse.content_type || 'video/mp4'; + const videoSha256 = createHash('sha256').update(contentResponse.buffer).digest('hex'); + + return { + provider_request_id: data.providerRequestId, + output_json: this.createRealProviderOutput(provider, context, data.providerRequestId, { + ...outputPayload, + video_bytes: contentResponse.buffer.length, + video_sha256: videoSha256, + mime_type: contentType + }), + transient_output_json: { + content_base64: contentResponse.buffer.toString('base64'), + mime_type: contentType + } + }; + } + + private extractProviderTaskId(body: Record) { + const data = this.objectFromUnknown(body.data); + const output = this.objectFromUnknown(body.output); + + return ( + this.stringifyText(body.id) || + this.stringifyText(body.task_id) || + this.stringifyText(body.taskId) || + this.stringifyText(data.id) || + this.stringifyText(data.task_id) || + this.stringifyText(data.taskId) || + this.stringifyText(output.task_id) || + this.stringifyText(output.taskId) + ); + } + + private extractProviderTaskStatus(body: Record) { + const data = this.objectFromUnknown(body.data); + const output = this.objectFromUnknown(body.output); + + return ( + this.stringifyText(body.status) || + this.stringifyText(body.state) || + this.stringifyText(body.task_status) || + this.stringifyText(body.taskStatus) || + this.stringifyText(data.status) || + this.stringifyText(data.state) || + this.stringifyText(data.task_status) || + this.stringifyText(data.taskStatus) || + this.stringifyText(output.status) || + this.stringifyText(output.state) || + this.stringifyText(output.task_status) + ).toLowerCase(); + } + + private assertExternalProviderBusinessOk(errorPrefix: string, body: Record) { + const baseResp = this.objectFromUnknown(body.base_resp); + const data = this.objectFromUnknown(body.data); + const dataBaseResp = this.objectFromUnknown(data.base_resp); + const effectiveBaseResp = Object.keys(baseResp).length > 0 ? baseResp : dataBaseResp; + const statusCode = this.optionalNumberFromJson( + effectiveBaseResp.status_code ?? effectiveBaseResp.code + ); + + if (statusCode !== null && statusCode !== 0) { + const statusMessage = + this.stringifyText(effectiveBaseResp.status_msg) || + this.stringifyText(effectiveBaseResp.message) || + this.stringifyText(effectiveBaseResp.error_msg) || + this.stringifyText(body.message) || + this.stringifyText(data.message) || + `Provider business status ${statusCode}`; + + throw new ExternalProviderResponseError( + `${errorPrefix}_PROVIDER_REJECTED: ${this.sanitizeErrorText(statusMessage)}`, + this.summarizeExternalProviderResponse(body) + ); + } + } + + private externalProviderResponseError(errorCode: string, body: Record) { + const message = this.externalProviderResponseErrorMessage(errorCode, body); + + return new ExternalProviderResponseError( + message, + this.summarizeExternalProviderResponse(body) + ); + } + + private externalProviderResponseErrorMessage(errorCode: string, body: Record) { + const data = this.objectFromUnknown(body.data); + const baseResp = this.objectFromUnknown(body.base_resp ?? data.base_resp); + const error = this.objectFromUnknown(body.error ?? data.error); + const message = + this.stringifyText(error.message) || + this.stringifyText(body.error_message) || + this.stringifyText(body.error_msg) || + this.stringifyText(data.error_message) || + this.stringifyText(data.error_msg) || + this.stringifyText(body.message) || + this.stringifyText(data.message) || + this.stringifyText(baseResp.status_msg) || + this.stringifyText(baseResp.message); + + return message ? `${errorCode}: ${this.sanitizeErrorText(message)}` : errorCode; + } + + private summarizeExternalProviderResponse(body: Record) { + const data = this.objectFromUnknown(body.data); + const output = this.objectFromUnknown(body.output); + const baseResp = this.objectFromUnknown(body.base_resp ?? data.base_resp); + const error = this.objectFromUnknown(body.error ?? data.error); + const summary = { + task_id: this.extractProviderTaskId(body) || null, + status: this.extractProviderTaskStatus(body) || null, + file_id: this.extractProviderFileId(body) || null, + video_url_present: Boolean(this.extractProviderVideoUrl(body)), + base_resp_status_code: + this.optionalNumberFromJson(baseResp.status_code ?? baseResp.code), + base_resp_status_msg: + this.safeProviderText(baseResp.status_msg ?? baseResp.message ?? baseResp.error_msg), + error_message: + this.safeProviderText( + error.message ?? + body.error_message ?? + body.error_msg ?? + data.error_message ?? + data.error_msg ?? + body.message ?? + data.message ?? + output.message + ), + top_level_keys: Object.keys(body).slice(0, 30) + }; + + return this.toJsonValueOrNull(summary); + } + + private extractProviderVideoUrl(body: Record) { + const direct = this.firstUrlFromUnknown( + body.output ?? body.outputs ?? body.video_url ?? body.url ?? body.asset_url ?? body.result_url + ); + + if (direct) return direct; + + const data = this.objectFromUnknown(body.data); + const dataDirect = this.firstUrlFromUnknown( + data.output ?? data.outputs ?? data.video_url ?? data.url ?? data.asset_url ?? data.result_url + ); + + if (dataDirect) return dataDirect; + + const taskResult = this.objectFromUnknown(data.task_result ?? body.task_result); + const videos = taskResult.videos ?? taskResult.video ?? taskResult.output; + + return this.firstUrlFromUnknown(videos) || this.firstUrlFromUnknown(body); + } + + private extractProviderVideoBase64(body: Record) { + const direct = + this.stringifyText(body.content_base64) || + this.stringifyText(body.video_base64) || + this.stringifyText(body.video_b64) || + this.stringifyText(body.base64); + + if (direct) return this.stripDataUriPrefix(direct); + + const data = this.objectFromUnknown(body.data); + const dataDirect = + this.stringifyText(data.content_base64) || + this.stringifyText(data.video_base64) || + this.stringifyText(data.video_b64) || + this.stringifyText(data.base64); + + if (dataDirect) return this.stripDataUriPrefix(dataDirect); + + const output = this.objectFromUnknown(body.output); + const outputDirect = + this.stringifyText(output.content_base64) || + this.stringifyText(output.video_base64) || + this.stringifyText(output.video_b64) || + this.stringifyText(output.base64); + + return outputDirect ? this.stripDataUriPrefix(outputDirect) : ''; + } + + private extractProviderFileId(body: Record) { + const data = this.objectFromUnknown(body.data); + const output = this.objectFromUnknown(body.output); + const file = this.objectFromUnknown(body.file ?? data.file ?? output.file); + const taskResult = this.objectFromUnknown(body.task_result ?? data.task_result); + + return ( + this.stringifyText(body.file_id) || + this.stringifyText(body.fileId) || + this.stringifyText(data.file_id) || + this.stringifyText(data.fileId) || + this.stringifyText(output.file_id) || + this.stringifyText(output.fileId) || + this.stringifyText(taskResult.file_id) || + this.stringifyText(taskResult.fileId) || + this.stringifyText(file.id) || + this.stringifyText(file.file_id) || + this.stringifyText(file.fileId) + ); + } + + private firstUrlFromUnknown(value: unknown): string { + const direct = this.stringifyText(value); + + if (/^https?:\/\//i.test(direct)) { + return direct; + } + if (Array.isArray(value)) { + for (const item of value) { + const url = this.firstUrlFromUnknown(item); + + if (url) return url; + } + return ''; + } + if (value && typeof value === 'object') { + const object = value as Record; + const candidates = [ + object.url, + object.uri, + object.video_url, + object.asset_url, + object.download_url, + object.output_url, + object.file, + object.files, + object.video, + object.videos, + object.result, + object.results, + object.output, + object.outputs, + object.task_result, + object.data, + object.creation, + object.creations + ]; + + for (const candidate of candidates) { + const url = this.firstUrlFromUnknown(candidate); + + if (url) return url; + } + } + + return ''; + } + + private extractProviderImageUrl(body: Record) { + const direct = this.firstUrlFromUnknown( + body.image_url ?? body.url ?? body.asset_url ?? body.result_url ?? body.output ?? body.outputs + ); + + if (direct) return direct; + + const data = this.objectFromUnknown(body.data); + const artifacts = Array.isArray(body.artifacts) ? body.artifacts : []; + const predictions = Array.isArray(body.predictions) ? body.predictions : []; + const candidates = Array.isArray(body.candidates) ? body.candidates : []; + + return ( + this.firstUrlFromUnknown(data.images ?? data.image ?? data.url ?? data.output ?? data.outputs) || + this.firstUrlFromUnknown(artifacts) || + this.firstUrlFromUnknown(predictions) || + this.firstUrlFromUnknown(candidates) || + this.firstUrlFromUnknown(body) + ); + } + + private extractProviderImageBase64(body: Record) { + const direct = + this.stringifyText(body.b64_json) || + this.stringifyText(body.image_b64) || + this.stringifyText(body.image_base64) || + this.stringifyText(body.base64) || + this.stringifyText(body.bytesBase64Encoded); + + if (direct) return this.stripDataUriPrefix(direct); + + const data = Array.isArray(body.data) ? body.data : []; + for (const item of data) { + const itemObject = this.objectFromUnknown(item); + const value = + this.stringifyText(itemObject.b64_json) || + this.stringifyText(itemObject.image_b64) || + this.stringifyText(itemObject.image_base64) || + this.stringifyText(itemObject.base64) || + this.stringifyText(itemObject.bytesBase64Encoded); + + if (value) return this.stripDataUriPrefix(value); + } + + const artifacts = Array.isArray(body.artifacts) ? body.artifacts : []; + for (const item of artifacts) { + const itemObject = this.objectFromUnknown(item); + const value = this.stringifyText(itemObject.base64) || this.stringifyText(itemObject.bytesBase64Encoded); + + if (value) return this.stripDataUriPrefix(value); + } + + const candidates = Array.isArray(body.candidates) ? body.candidates : []; + for (const candidate of candidates) { + const candidateObject = this.objectFromUnknown(candidate); + const content = this.objectFromUnknown(candidateObject.content); + const parts = Array.isArray(content.parts) ? content.parts : []; + + for (const part of parts) { + const partObject = this.objectFromUnknown(part); + const inlineData = this.objectFromUnknown(partObject.inlineData ?? partObject.inline_data); + const value = this.stringifyText(inlineData.data); + + if (value) return this.stripDataUriPrefix(value); + } + } + + return ''; + } + + private extractProviderAudioBase64(body: Record) { + const direct = + this.stringifyText(body.audio_base64) || + this.stringifyText(body.audio_b64) || + this.stringifyText(body.base64) || + this.stringifyText(body.audio); + + if (direct) return this.stripDataUriPrefix(direct); + + const data = this.objectFromUnknown(body.data); + const result = this.objectFromUnknown(body.result); + const output = this.objectFromUnknown(body.output); + const candidates = [ + data.audio, + data.audio_base64, + data.audio_b64, + data.base64, + result.audio, + result.audio_base64, + output.audio, + output.audio_base64 + ]; + + for (const candidate of candidates) { + const value = this.stringifyText(candidate); + + if (value) return this.stripDataUriPrefix(value); + } + + return ''; + } + + private decodeProviderAudioPayload(value: string) { + const payload = this.stripDataUriPrefix(value).trim(); + + if (/^[0-9a-fA-F]+$/.test(payload) && payload.length % 2 === 0 && payload.length > 64) { + return Buffer.from(payload, 'hex'); + } + + return Buffer.from(payload, 'base64'); + } + + private async createGenericAssetProviderOutput( + provider: ProviderConfig, + context: ProviderExecutionContext, + config: Record, + data: { + providerRequestId: string; + taskId: string; + task: Record; + prompt: string; + } + ): Promise { + const isVideo = provider.provider_type === 'VideoProvider' || provider.provider_type === 'LipSyncProvider'; + const outputUrl = isVideo ? this.extractProviderVideoUrl(data.task) : this.extractProviderImageUrl(data.task); + const imageB64 = isVideo ? '' : this.extractProviderImageBase64(data.task); + const outputFormat = + this.stringifyText(config.output_format) || + this.stringifyText(config.response_format) || + (isVideo ? 'mp4' : 'png'); + const outputPayload: Record = { + asset_url: + outputUrl || + `${this.providerAssetUrlPrefix(provider.provider_code)}://${isVideo ? 'video' : 'image'}/${data.taskId}.${outputFormat}`, + task_id: data.taskId, + prompt: data.prompt, + raw_task_summary: this.summarizeExternalVideoTask(data.task) + }; + + if (isVideo) { + outputPayload.video_available = Boolean(outputUrl); + outputPayload.duration = + this.optionalNumberFromJson(data.task.duration) ?? + this.optionalNumberFromJson(this.jsonObject(context.input_json).duration) ?? + this.optionalNumberFromJson(config.duration); + } else { + outputPayload.image_available = Boolean(outputUrl || imageB64); + outputPayload.image_b64_sha256 = imageB64 ? createHash('sha256').update(imageB64).digest('hex') : null; + outputPayload.image_b64_size = imageB64.length || null; + outputPayload.image_bytes = imageB64 ? Buffer.byteLength(imageB64, 'base64') : null; + outputPayload.output_format = outputFormat; + } + + if (!context.return_binary) { + return { + provider_request_id: data.providerRequestId, + output_json: this.createRealProviderOutput(provider, context, data.providerRequestId, outputPayload) + }; + } + if (!isVideo && imageB64) { + return { + provider_request_id: data.providerRequestId, + output_json: this.createRealProviderOutput(provider, context, data.providerRequestId, outputPayload), + transient_output_json: { + content_base64: imageB64, + mime_type: this.imageMimeType(outputFormat) + } + }; + } + if (!/^https?:\/\//i.test(outputUrl)) { + throw new Error(`${this.externalProviderErrorPrefix(provider, config)}_OUTPUT_URL_MISSING`); + } + + const contentResponse = await this.getProviderBinaryByUrl(config, outputUrl, this.externalProviderErrorPrefix(provider, config)); + const contentType = contentResponse.content_type || (isVideo ? 'video/mp4' : this.imageMimeType(outputFormat)); + const sha256 = createHash('sha256').update(contentResponse.buffer).digest('hex'); + + return { + provider_request_id: data.providerRequestId, + output_json: this.createRealProviderOutput(provider, context, data.providerRequestId, { + ...outputPayload, + ...(isVideo + ? { video_bytes: contentResponse.buffer.length, video_sha256: sha256, mime_type: contentType } + : { image_bytes: contentResponse.buffer.length, image_sha256: sha256, mime_type: contentType }) + }), + transient_output_json: { + content_base64: contentResponse.buffer.toString('base64'), + mime_type: contentType + } + }; + } + + private isExternalVideoTerminalStatus(status: string) { + return [ + 'completed', + 'complete', + 'succeeded', + 'success', + 'done', + 'finished', + 'failed', + 'fail', + 'failure', + 'error', + 'cancelled', + 'canceled', + 'expired', + 'rejected' + ].includes(status.toLowerCase()); + } + + private isExternalVideoCompletedStatus(status: string) { + return ['completed', 'complete', 'succeeded', 'success', 'done', 'finished'].includes(status.toLowerCase()); + } + + private extractProviderErrorMessage(task: Record, status: string) { + const data = this.objectFromUnknown(task.data); + const error = this.objectFromUnknown(task.error ?? data.error); + const message = + this.stringifyText(error.message) || + this.stringifyText(task.error_message) || + this.stringifyText(task.error_msg) || + this.stringifyText(data.error_message) || + this.stringifyText(data.error_msg) || + this.stringifyText(task.message) || + this.stringifyText(data.message) || + `Video generation ended with status ${status}`; + + return this.sanitizeErrorText(message); + } + + private summarizeExternalVideoTask(task: Record) { + return this.toJsonValueOrNull({ + id: this.extractProviderTaskId(task) || null, + status: this.extractProviderTaskStatus(task) || null, + video_url_present: Boolean(this.extractProviderVideoUrl(task)) + }); + } + + private isOpenAiVideoTerminalStatus(status: string) { + return [ + 'completed', + 'succeeded', + 'success', + 'failed', + 'cancelled', + 'canceled', + 'expired' + ].includes(status.toLowerCase()); + } + + private isOpenAiVideoCompletedStatus(status: string) { + return ['completed', 'succeeded', 'success'].includes(status.toLowerCase()); + } + + private templateEndpoint(template: string, videoId: string) { + return this.templateProviderEndpoint(template, { + video_id: videoId, + task_id: videoId, + id: videoId + }); + } + + private templateProviderEndpoint( + template: string, + replacements: Record, + encodeValues = true + ) { + let endpoint = template; + + for (const [key, value] of Object.entries(replacements)) { + endpoint = endpoint.replace( + new RegExp(`\\{${key}\\}`, 'g'), + encodeValues ? encodeURIComponent(value) : value + ); + } + + return endpoint.startsWith('/') ? endpoint : `/${endpoint}`; + } + + private templateTaskEndpoint(template: string, taskId: string) { + return this.templateEndpoint(template, taskId); + } + + private resolveBootstrapModelName( + defaultModel: string, + configJson: Prisma.InputJsonValue + ) { + const config = this.jsonObject(configJson); + + return this.resolveModelName( + { + model_name: defaultModel + } as ProviderConfig, + config + ); + } + + private resolveModelName( + provider: Pick, + config: Record + ) { + const modelEnvName = this.stringifyText(config.model_env); + const envModel = modelEnvName ? process.env[modelEnvName]?.trim() : ''; + const model = envModel || provider.model_name || this.stringifyText(config.model); + + if (!model) { + throw new Error('OPENAI_MODEL_NOT_CONFIGURED'); + } + + return model; + } + + private async postOpenAiJson( + config: Record, + endpoint: string, + body: Record + ): Promise { + const response = await this.postOpenAi(config, endpoint, body); + const requestId = response.headers.get('x-request-id'); + const raw = await response.text(); + const parsed = this.parseJsonResponse(raw); + + if (!response.ok) { + throw new Error(this.createOpenAiHttpError(response.status, parsed, raw)); + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('OPENAI_RESPONSE_NOT_OBJECT'); + } + + return { + body: parsed as Record, + request_id: requestId + }; + } + + private async getOpenAiJson( + config: Record, + endpoint: string + ): Promise { + const response = await this.requestOpenAi(config, endpoint, 'GET'); + const requestId = response.headers.get('x-request-id'); + const raw = await response.text(); + const parsed = this.parseJsonResponse(raw); + + if (!response.ok) { + throw new Error(this.createOpenAiHttpError(response.status, parsed, raw)); + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('OPENAI_RESPONSE_NOT_OBJECT'); + } + + return { + body: parsed as Record, + request_id: requestId + }; + } + + private async postOpenAiBinary( + config: Record, + endpoint: string, + body: Record + ): Promise { + const response = await this.postOpenAi(config, endpoint, body); + const requestId = response.headers.get('x-request-id'); + + if (!response.ok) { + const raw = await response.text(); + const parsed = this.parseJsonResponse(raw, true); + + throw new Error(this.createOpenAiHttpError(response.status, parsed, raw)); + } + + return { + buffer: Buffer.from(await response.arrayBuffer()), + content_type: response.headers.get('content-type'), + request_id: requestId + }; + } + + private async getOpenAiBinary( + config: Record, + endpoint: string + ): Promise { + const response = await this.requestOpenAi(config, endpoint, 'GET'); + const requestId = response.headers.get('x-request-id'); + + if (!response.ok) { + const raw = await response.text(); + const parsed = this.parseJsonResponse(raw, true); + + throw new Error(this.createOpenAiHttpError(response.status, parsed, raw)); + } + + return { + buffer: Buffer.from(await response.arrayBuffer()), + content_type: response.headers.get('content-type'), + request_id: requestId + }; + } + + private async postOpenAi( + config: Record, + endpoint: string, + body: Record + ) { + return this.requestOpenAi(config, endpoint, 'POST', body); + } + + private async requestOpenAi( + config: Record, + endpoint: string, + method: 'GET' | 'POST', + body?: Record + ) { + const apiKey = this.resolveOpenAiApiKey(config); + const baseUrl = this.resolveOpenAiBaseUrl(config); + const timeoutMs = this.resolveOpenAiTimeoutMs(config); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + const normalizedEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`; + const headers: Record = { + Authorization: `Bearer ${apiKey}` + }; + + if (body !== undefined) { + headers['Content-Type'] = 'application/json'; + } + + try { + return await fetch(`${baseUrl}${normalizedEndpoint}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + signal: controller.signal + }); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error('OPENAI_REQUEST_TIMEOUT'); + } + + throw new Error(`OPENAI_REQUEST_FAILED: ${this.sanitizeErrorText(this.toError(error).message)}`); + } finally { + clearTimeout(timeout); + } + } + + private async postProviderJson( + config: Record, + endpoint: string, + body: Record, + extraHeaders: Record, + errorPrefix: string + ): Promise { + const response = await this.requestExternalProvider(config, endpoint, 'POST', body, extraHeaders, errorPrefix); + return this.readProviderJsonResponse(response, errorPrefix); + } + + private async postProviderBinary( + config: Record, + endpoint: string, + body: Record, + extraHeaders: Record, + errorPrefix: string + ): Promise { + const response = await this.requestExternalProvider(config, endpoint, 'POST', body, extraHeaders, errorPrefix); + const requestId = this.providerRequestIdFromHeaders(response.headers); + + if (!response.ok) { + const raw = await response.text(); + const parsed = this.parseProviderJsonResponse(raw, errorPrefix, true); + + throw new Error(this.createProviderHttpError(errorPrefix, response.status, parsed, raw)); + } + + return { + buffer: Buffer.from(await response.arrayBuffer()), + content_type: response.headers.get('content-type'), + request_id: requestId + }; + } + + private async getProviderJson( + config: Record, + endpoint: string, + extraHeaders: Record, + errorPrefix: string + ): Promise { + const response = await this.requestExternalProvider(config, endpoint, 'GET', undefined, extraHeaders, errorPrefix); + return this.readProviderJsonResponse(response, errorPrefix); + } + + private async getProviderBinaryByUrl( + config: Record, + url: string, + errorPrefix: string + ): Promise { + const timeoutMs = this.resolveProviderTimeoutMs(config, 180000); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(url, { method: 'GET', signal: controller.signal }); + const requestId = this.providerRequestIdFromHeaders(response.headers); + + if (!response.ok) { + const raw = await response.text(); + const parsed = this.parseProviderJsonResponse(raw, errorPrefix, true); + + throw new Error(this.createProviderHttpError(errorPrefix, response.status, parsed, raw)); + } + + return { + buffer: Buffer.from(await response.arrayBuffer()), + content_type: response.headers.get('content-type'), + request_id: requestId + }; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error(`${errorPrefix}_DOWNLOAD_TIMEOUT`); + } + + throw new Error(`${errorPrefix}_DOWNLOAD_FAILED: ${this.sanitizeErrorText(this.toError(error).message)}`); + } finally { + clearTimeout(timeout); + } + } + + private async requestExternalProvider( + config: Record, + endpoint: string, + method: 'GET' | 'POST', + body: Record | undefined, + extraHeaders: Record, + errorPrefix: string + ) { + const apiKey = this.resolveProviderApiKey(config, errorPrefix); + const baseUrl = this.resolveProviderBaseUrl(config, errorPrefix); + const timeoutMs = this.resolveProviderTimeoutMs(config, 180000); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + const normalizedEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`; + const authHeaderName = this.stringifyText(config.auth_header_name) || 'Authorization'; + const authScheme = + typeof config.auth_scheme === 'string' ? config.auth_scheme.trim() : 'Bearer'; + const headers: Record = { + ...extraHeaders, + [authHeaderName]: authScheme ? `${authScheme} ${apiKey}` : apiKey + }; + + if (body !== undefined) { + headers['Content-Type'] = 'application/json'; + } + + try { + return await fetch(`${baseUrl}${normalizedEndpoint}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + signal: controller.signal + }); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new Error(`${errorPrefix}_REQUEST_TIMEOUT`); + } + + throw new Error(`${errorPrefix}_REQUEST_FAILED: ${this.sanitizeErrorText(this.toError(error).message)}`); + } finally { + clearTimeout(timeout); + } + } + + private async readProviderJsonResponse(response: Response, errorPrefix: string) { + const requestId = this.providerRequestIdFromHeaders(response.headers); + const raw = await response.text(); + const parsed = this.parseProviderJsonResponse(raw, errorPrefix); + + if (!response.ok) { + throw new Error(this.createProviderHttpError(errorPrefix, response.status, parsed, raw)); + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`${errorPrefix}_RESPONSE_NOT_OBJECT`); + } + + return { + body: parsed as Record, + request_id: requestId + }; + } + + private parseProviderJsonResponse(raw: string, errorPrefix: string, allowEmpty = false) { + if (!raw.trim()) { + return allowEmpty ? {} : null; + } + + try { + return JSON.parse(raw) as unknown; + } catch { + throw new Error(`${errorPrefix}_RESPONSE_NOT_JSON`); + } + } + + private createProviderHttpError(errorPrefix: string, status: number, parsed: unknown, raw: string) { + const body = this.objectFromUnknown(parsed); + const data = this.objectFromUnknown(body.data); + const error = this.objectFromUnknown(body.error ?? data.error); + const message = + this.stringifyText(error.message) || + this.stringifyText(body.message) || + this.stringifyText(data.message) || + raw.slice(0, 240) || + 'Provider request failed'; + + return `${errorPrefix}_REQUEST_FAILED_${status}: ${this.sanitizeErrorText(message)}`; + } + + private resolveProviderApiKey( + config: Record, + errorPrefix: string + ) { + const managedSecret = this.decryptProviderSecret(config.api_key_secure); + + if (managedSecret) { + return managedSecret; + } + + const envName = this.stringifyText(config.api_key_env); + + if (!envName) { + throw new Error(`${errorPrefix}_API_KEY_ENV_NOT_CONFIGURED`); + } + + this.assertEnvReferenceName(envName, 'config_json.api_key_env'); + + const value = process.env[envName]?.trim(); + + if (!value) { + throw new Error(`${errorPrefix}_API_KEY_NOT_CONFIGURED`); + } + + return value; + } + + private resolveProviderBaseUrl( + config: Record, + errorPrefix: string + ) { + const envName = this.stringifyText(config.base_url_env); + const envValue = envName ? process.env[envName]?.trim() : ''; + const baseUrl = envValue || this.stringifyText(config.base_url); + + if (!/^https?:\/\//i.test(baseUrl)) { + throw new Error(`${errorPrefix}_BASE_URL_INVALID`); + } + + return baseUrl.replace(/\/+$/, ''); + } + + private resolveProviderTimeoutMs( + config: Record, + fallback: number + ) { + const timeoutMs = this.numberFromJson(config.timeout_ms); + + if (timeoutMs <= 0) { + return fallback; + } + + return Math.min(Math.max(Math.round(timeoutMs), 1000), 600000); + } + + private runwayHeaders(config: Record) { + const version = this.stringifyText(config.api_version) || '2024-11-06'; + + return { + 'X-Runway-Version': version + }; + } + + private configuredProviderEndpoint( + provider: Pick, + config: Record, + key: string, + fallback: string, + replacements: Record = {}, + encodeValues = false + ) { + const template = this.stringifyText(config[key]) || fallback; + const model = this.resolveModelName(provider, config); + + return this.templateProviderEndpoint( + template, + { + model, + model_id: model, + deployment: model, + voice_id: this.stringifyText(config.voice_id) || this.stringifyText(config.voice) || 'default', + ...replacements + }, + encodeValues + ); + } + + private externalProviderHeaders(config: Record) { + return this.externalVideoHeaders(config); + } + + private externalVideoHeaders(config: Record) { + const headers = this.jsonObject(config.headers); + const output: Record = {}; + + for (const [key, value] of Object.entries(headers)) { + const header = this.stringifyText(value); + + if (header && /^[a-zA-Z0-9_.:-]+$/.test(key)) { + output[key] = header; + } + } + + return output; + } + + private externalProviderErrorPrefix( + provider: Pick, + config: Record + ) { + const configured = this.stringifyText(config.error_prefix); + + if (configured && /^[A-Z][A-Z0-9_]{1,80}$/.test(configured)) { + return configured; + } + + return provider.provider_code.replace(/[^a-zA-Z0-9]+/g, '_').toUpperCase().slice(0, 80); + } + + private providerAssetUrlPrefix(providerCode: string) { + return providerCode.replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'video-provider'; + } + + private providerRequestIdFromHeaders(headers: Headers) { + return ( + headers.get('x-request-id') || + headers.get('x-runway-request-id') || + headers.get('request-id') || + headers.get('trace-id') + ); + } + + private resolveOpenAiApiKey(config: Record) { + const managedSecret = this.decryptProviderSecret(config.api_key_secure); + + if (managedSecret) { + return managedSecret; + } + + const envName = this.stringifyText(config.api_key_env) || 'OPENAI_API_KEY'; + + this.assertEnvReferenceName(envName, 'config_json.api_key_env'); + + const value = process.env[envName]?.trim(); + + if (!value) { + throw new Error('OPENAI_API_KEY_NOT_CONFIGURED'); + } + + return value; + } + + private resolveOpenAiBaseUrl(config: Record) { + const envName = this.stringifyText(config.base_url_env); + const envValue = envName ? process.env[envName]?.trim() : ''; + const baseUrl = envValue || this.stringifyText(config.base_url) || 'https://api.openai.com/v1'; + + if (!/^https?:\/\//i.test(baseUrl)) { + throw new Error('OPENAI_BASE_URL_INVALID'); + } + + return baseUrl.replace(/\/+$/, ''); + } + + private resolveOpenAiTimeoutMs(config: Record) { + const timeoutMs = this.numberFromJson(config.timeout_ms); + + if (timeoutMs <= 0) { + return 60000; + } + + return Math.min(Math.max(Math.round(timeoutMs), 1000), 180000); + } + + private parseJsonResponse(raw: string, allowEmpty = false) { + if (!raw.trim()) { + return allowEmpty ? {} : null; + } + + try { + return JSON.parse(raw) as unknown; + } catch { + throw new Error('OPENAI_RESPONSE_NOT_JSON'); + } + } + + private createOpenAiHttpError(status: number, parsed: unknown, raw: string) { + const body = this.objectFromUnknown(parsed); + const error = this.objectFromUnknown(body.error); + const message = + this.stringifyText(error.message) || + this.stringifyText(body.message) || + raw.slice(0, 240) || + 'OpenAI request failed'; + + return `OPENAI_REQUEST_FAILED_${status}: ${this.sanitizeErrorText(message)}`; + } + + private extractOpenAiResponseText(body: Record) { + const outputText = this.stringifyText(body.output_text); + + if (outputText) { + return outputText; + } + + const texts: string[] = []; + const output = Array.isArray(body.output) ? body.output : []; + + for (const item of output) { + const itemObject = this.objectFromUnknown(item); + const content = Array.isArray(itemObject.content) ? itemObject.content : []; + + for (const child of content) { + const childObject = this.objectFromUnknown(child); + const text = this.stringifyText(childObject.text); + + if (text) { + texts.push(text); + } + } + } + + return texts.join('\n').trim(); + } + + private extractOpenAiCompatibleChatText(body: Record) { + const direct = this.stringifyText(body.text) || this.stringifyText(body.output_text); + + if (direct) return direct; + + const choices = Array.isArray(body.choices) ? body.choices : []; + const texts: string[] = []; + + for (const choice of choices) { + const choiceObject = this.objectFromUnknown(choice); + const message = this.objectFromUnknown(choiceObject.message); + const delta = this.objectFromUnknown(choiceObject.delta); + const content = + this.stringifyText(message.content) || + this.stringifyText(delta.content) || + this.stringifyText(choiceObject.text); + + if (content) { + texts.push(content); + } + } + + return texts.join('\n').trim(); + } + + private extractAnthropicMessageText(body: Record) { + const content = Array.isArray(body.content) ? body.content : []; + const texts: string[] = []; + + for (const item of content) { + const itemObject = this.objectFromUnknown(item); + const text = this.stringifyText(itemObject.text); + + if (text) { + texts.push(text); + } + } + + return texts.join('\n').trim(); + } + + private extractGeminiText(body: Record) { + const candidates = Array.isArray(body.candidates) ? body.candidates : []; + const texts: string[] = []; + + for (const candidate of candidates) { + const candidateObject = this.objectFromUnknown(candidate); + const content = this.objectFromUnknown(candidateObject.content); + const parts = Array.isArray(content.parts) ? content.parts : []; + + for (const part of parts) { + const partObject = this.objectFromUnknown(part); + const text = this.stringifyText(partObject.text); + + if (text) { + texts.push(text); + } + } + } + + return texts.join('\n').trim(); + } + + private extractCohereText(body: Record) { + const direct = this.stringifyText(body.text); + + if (direct) return direct; + + const message = this.objectFromUnknown(body.message); + const content = Array.isArray(message.content) ? message.content : []; + const texts: string[] = []; + + for (const item of content) { + const itemObject = this.objectFromUnknown(item); + const text = this.stringifyText(itemObject.text); + + if (text) { + texts.push(text); + } + } + + return texts.join('\n').trim(); + } + + private stripDataUriPrefix(value: string) { + return value.replace(/^data:[^;]+;base64,/i, '').trim(); + } + + private audioFileExtension(format: string | null) { + const normalized = (format || 'mp3').toLowerCase(); + + if (normalized.includes('wav')) return 'wav'; + if (normalized.includes('opus')) return 'opus'; + if (normalized.includes('aac')) return 'aac'; + if (normalized.includes('flac')) return 'flac'; + + return 'mp3'; + } + + private toJsonValueOrNull(value: unknown) { + if (value === undefined) { + return null; + } + + return this.toJsonValue(value, 'provider_output'); + } + + private objectFromUnknown(value: unknown): Record { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + + return {}; + } + + private sanitizeErrorText(value: string) { + return value + .replace(/sk-[a-zA-Z0-9_-]+/g, '[REDACTED]') + .replace(/Bearer\s+[a-zA-Z0-9._-]+/g, 'Bearer [REDACTED]') + .slice(0, 300); + } + + private safeProviderText(value: unknown) { + const text = this.stringifyText(value); + + return text ? this.sanitizeErrorText(text) : null; + } + + private async findProviderConfigOrThrow(providerId: string) { + const provider = await this.prisma.providerConfig.findUnique({ + where: { id: this.parseId(providerId, 'Invalid provider id') } + }); + + if (!provider) { + throw new NotFoundException('Provider config not found'); + } + + return provider; + } + + private async findRenderTaskOrThrow(taskId: bigint) { + const task = await this.prisma.renderTask.findUnique({ + where: { id: taskId } + }); + + if (!task) { + throw new NotFoundException('Render task not found'); + } + + return task; + } + + private createRequestJson( + context: ProviderExecutionContext, + provider: ProviderConfig + ): Prisma.InputJsonObject { + return { + purpose: context.purpose, + provider_type: context.provider_type, + preferred_provider_code: context.preferred_provider_code ?? null, + selected_provider_code: provider.provider_code, + allow_fallback: context.allow_fallback, + project_id: context.project_id?.toString() ?? null, + task_id: context.task_id?.toString() ?? null, + input_json: this.redactProviderLogJson(context.input_json) + }; + } + + private createLogWhere(query: ProviderLogsQueryDto): Prisma.ProviderLogWhereInput { + const where: Prisma.ProviderLogWhereInput = {}; + + if (query.provider_type) { + where.provider_type = this.validateProviderType(query.provider_type); + } + if (query.provider_code) { + where.provider_code = this.normalizeOptionalText(query.provider_code, 100); + } + if (query.status) { + where.status = this.validateProviderLogStatus(query.status); + } + if (query.project_id) { + where.project_id = this.parseId(query.project_id, 'Invalid project_id'); + } + if (query.task_id) { + where.task_id = this.parseId(query.task_id, 'Invalid task_id'); + } + + return where; + } + + private calculateCost( + rule: Prisma.JsonValue | null, + inputSize: number, + outputSize: number, + inputJson?: Prisma.InputJsonValue | null, + outputJson?: Prisma.InputJsonValue | null + ) { + const costRule = this.jsonObject(rule); + const flatCost = this.numberFromJson(costRule.flat_cost); + const per1kInput = this.numberFromJson(costRule.per_1k_input_chars); + const per1kOutput = this.numberFromJson(costRule.per_1k_output_chars); + const unit = this.stringifyText(costRule.unit); + let cost = flatCost + (inputSize / 1000) * per1kInput + (outputSize / 1000) * per1kOutput; + + if (unit === 'video_seconds') { + const seconds = this.resolveCostSeconds(costRule, inputJson, outputJson); + const pricePerSecond = this.numberFromJson(costRule.price_per_second); + const pricePerClip = this.numberFromJson(costRule.price_per_clip); + + cost += seconds * pricePerSecond + pricePerClip; + } + + return Number(cost.toFixed(4)); + } + + private calculatePreflightCost( + rule: Prisma.JsonValue | null, + inputSize: number, + inputJson?: Prisma.InputJsonValue | null + ) { + const costRule = this.jsonObject(rule); + const estimatedOutputSize = Math.max(0, this.numberFromJson(costRule.estimated_output_chars)); + + return this.calculateCost(rule, inputSize, estimatedOutputSize, inputJson); + } + + private async assertProviderCostAllowed( + provider: ProviderConfig, + inputSize: number, + outputSize: number, + actualCost?: number, + inputJson?: Prisma.InputJsonValue | null, + outputJson?: Prisma.InputJsonValue | null + ) { + const costRule = this.jsonObject(provider.cost_rule_json); + const estimatedCost = + actualCost ?? this.calculatePreflightCost(provider.cost_rule_json, inputSize, inputJson); + const maxCostPerCall = this.resolveCostLimit(costRule.max_cost_per_call, 'PROVIDER_MAX_COST_PER_CALL'); + + if (maxCostPerCall > 0 && estimatedCost > maxCostPerCall) { + throw new Error( + `PROVIDER_COST_LIMIT_EXCEEDED: estimated ${estimatedCost.toFixed(4)} > per-call limit ${maxCostPerCall.toFixed(4)}` + ); + } + + const dailyCostLimit = this.resolveCostLimit(costRule.daily_cost_limit, 'PROVIDER_DAILY_COST_LIMIT'); + + if (dailyCostLimit > 0) { + const usedToday = await this.getTodayProviderCost(); + + if (usedToday + estimatedCost > dailyCostLimit) { + throw new Error( + `PROVIDER_DAILY_COST_LIMIT_EXCEEDED: used ${usedToday.toFixed(4)} + estimated ${estimatedCost.toFixed(4)} > daily limit ${dailyCostLimit.toFixed(4)}` + ); + } + } + + if (outputSize > 0) { + const maxOutputSize = this.numberFromJson(costRule.max_output_chars); + + if (maxOutputSize > 0 && outputSize > maxOutputSize) { + throw new Error( + `PROVIDER_OUTPUT_SIZE_LIMIT_EXCEEDED: output ${outputSize} > limit ${Math.round(maxOutputSize)}` + ); + } + } + } + + private resolveCostLimit(value: unknown, envName: string) { + const configured = this.numberFromJson(value); + + if (configured > 0) { + return configured; + } + + const fromEnv = Number(process.env[envName] ?? 0); + + return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : 0; + } + + private async getTodayProviderCost() { + const today = new Date(); + + today.setHours(0, 0, 0, 0); + + const result = await this.prisma.providerLog.aggregate({ + where: { + status: 'success', + created_at: { gte: today } + }, + _sum: { cost_actual: true } + }); + + return this.decimalToNumber(result._sum.cost_actual); + } + + private applyOptionalCostLimit( + costRule: Record, + key: 'max_cost_per_call' | 'daily_cost_limit', + value: unknown + ) { + if (value === null || value === undefined || value === '') { + delete costRule[key]; + return; + } + + const numberValue = Number(value); + + if (!Number.isFinite(numberValue) || numberValue < 0 || numberValue > 10000000) { + throw new BadRequestException(`${key} must be a number between 0 and 10000000`); + } + + costRule[key] = Number(numberValue.toFixed(4)); + } + + private createProviderRequestId(provider: ProviderConfig, inputJson: Prisma.InputJsonValue | null) { + const prefix = provider.mode === 'real' ? 'real' : 'mock'; + + return `${prefix}-${provider.provider_code}-${this.hashJson(inputJson).slice(0, 12)}`; + } + + private resolveCostSeconds( + costRule: Record, + inputJson?: Prisma.InputJsonValue | null, + outputJson?: Prisma.InputJsonValue | null + ) { + const input = this.jsonObject(inputJson ?? null); + const output = this.jsonObject(outputJson ?? null); + const seconds = + this.optionalNumberFromJson(output.duration) ?? + this.optionalNumberFromJson(output.seconds) ?? + this.optionalNumberFromJson(output.duration_seconds) ?? + this.optionalNumberFromJson(input.duration) ?? + this.optionalNumberFromJson(input.seconds) ?? + this.optionalNumberFromJson(input.duration_seconds) ?? + this.optionalNumberFromJson(costRule.estimated_seconds) ?? + 0; + + return Math.max(0, seconds); + } + + private redactProviderLogJson(value: Prisma.InputJsonValue | Prisma.JsonValue | null): Prisma.InputJsonValue | null { + if (value === null || value === undefined) { + return null; + } + if (Array.isArray(value)) { + return value.map((item) => this.redactProviderLogJson(item as Prisma.InputJsonValue)); + } + if (typeof value === 'object') { + const output: Record = {}; + + for (const [key, child] of Object.entries(value)) { + if (/url/i.test(key) && typeof child === 'string' && child.includes('/public-temp-assets/')) { + output[key] = '[REDACTED_TEMP_PUBLIC_ASSET_URL]'; + continue; + } + if (/base64|data_uri|promptImage|prompt_image/i.test(key) && typeof child === 'string' && child.length > 240) { + output[key] = `[REDACTED_MEDIA_${child.length}_CHARS]`; + continue; + } + output[key] = this.redactProviderLogJson(child as Prisma.InputJsonValue); + } + + return output as Prisma.InputJsonObject; + } + if (typeof value === 'string' && /^data:[^;]+;base64,/i.test(value) && value.length > 240) { + return `[REDACTED_MEDIA_${value.length}_CHARS]`; + } + if (typeof value === 'string' && value.includes('/public-temp-assets/')) { + return '[REDACTED_TEMP_PUBLIC_ASSET_URL]'; + } + + return value as Prisma.InputJsonValue; + } + + private createMockVector(fingerprint: string) { + const hash = createHash('sha256').update(fingerprint).digest(); + + return Array.from({ length: 8 }, (_, index) => Number((hash[index] / 255).toFixed(4))); + } + + private findMockModerationIssues(prompt: string) { + const lower = prompt.toLowerCase(); + const issues = new Set(); + + for (const word of ['blood', 'explicit']) { + if (lower.includes(word)) { + issues.add('mock_sensitive_keyword'); + } + } + + for (const word of ['违规', '违法']) { + let start = 0; + + while (start < prompt.length) { + const index = prompt.indexOf(word, start); + + if (index === -1) { + break; + } + if (!this.isNegatedSafetyMention(prompt, index, word.length)) { + issues.add('mock_sensitive_keyword'); + } + + start = index + word.length; + } + } + + return [...issues]; + } + + private isNegatedSafetyMention(text: string, index: number, length: number) { + const context = text.slice(Math.max(0, index - 10), Math.min(text.length, index + length + 10)); + + return /不得|不能|不要|不应|禁止|严禁|避免|不涉及|无|拒绝|杜绝|合规|安全/.test(context); + } + + private estimateJsonSize(value: Prisma.InputJsonValue | Prisma.JsonValue | null) { + return Buffer.byteLength(this.stableStringify(value as Prisma.InputJsonValue | null), 'utf8'); + } + + private hashJson(value: Prisma.InputJsonValue | null) { + return createHash('sha256').update(this.stableStringify(value)).digest('hex'); + } + + private stableStringify(value: Prisma.InputJsonValue | Prisma.JsonValue | null): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((item) => this.stableStringify(item)).join(',')}]`; + } + + const entries = Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => `${JSON.stringify(key)}:${this.stableStringify(child)}`); + + return `{${entries.join(',')}}`; + } + + private toJsonValue(value: unknown, path: string): Prisma.InputJsonValue | null { + if (value === null) { + return null; + } + if (typeof value === 'string' || typeof value === 'boolean') { + return value; + } + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + throw new BadRequestException(`${path} must contain finite numbers only`); + } + + return value; + } + if (Array.isArray(value)) { + return value.map((item, index) => this.toJsonValue(item, `${path}[${index}]`)); + } + if (typeof value === 'object') { + if (value instanceof Date) { + return value.toISOString(); + } + + const output: Record = {}; + + for (const [key, child] of Object.entries(value as Record)) { + if (child === undefined) { + throw new BadRequestException(`${path}.${key} cannot be undefined`); + } + + output[key] = this.toJsonValue(child, `${path}.${key}`); + } + + return output as Prisma.InputJsonValue; + } + + throw new BadRequestException(`${path} must be valid JSON`); + } + + private toNullableJsonInput(value: unknown, path: string) { + const json = this.toJsonValue(value, path); + + return json === null ? PrismaNamespace.JsonNull : json; + } + + private assertNoSecrets(value: unknown, path: string) { + if (value === null || value === undefined) { + return; + } + if (Array.isArray(value)) { + value.forEach((item, index) => this.assertNoSecrets(item, `${path}[${index}]`)); + return; + } + if (typeof value === 'object') { + for (const [key, child] of Object.entries(value as Record)) { + if (isEnvReferenceKey(key)) { + this.assertEnvReferenceName(child, `${path}.${key}`); + this.assertNoSecrets(child, `${path}.${key}`); + continue; + } + if (isSensitiveKey(key)) { + throw new BadRequestException(`${path}.${key} cannot contain secrets`); + } + + this.assertNoSecrets(child, `${path}.${key}`); + } + } + } + + private validateProviderType(value: unknown): ProviderType { + if (typeof value !== 'string' || !(PROVIDER_TYPES as readonly string[]).includes(value)) { + throw new BadRequestException('provider_type is not supported'); + } + + return value as ProviderType; + } + + private validateProviderMode(value: unknown): ProviderMode { + if (typeof value !== 'string' || !(PROVIDER_MODES as readonly string[]).includes(value)) { + throw new BadRequestException('mode is not supported'); + } + + return value as ProviderMode; + } + + private validateProviderLogStatus(value: unknown): ProviderLogStatus { + if (typeof value !== 'string' || !(PROVIDER_LOG_STATUSES as readonly string[]).includes(value)) { + throw new BadRequestException('status is not supported'); + } + + return value as ProviderLogStatus; + } + + private parseBooleanQuery(value: string, field: string) { + if (value === 'true') return true; + if (value === 'false') return false; + throw new BadRequestException(`${field} must be true or false`); + } + + private normalizePositiveInt( + value: unknown, + field: string, + min: number, + max: number, + fallback: number + ) { + if (value === undefined || value === null || value === '') { + return fallback; + } + + return this.normalizeInt(value, field, min, max); + } + + private normalizeInt(value: unknown, field: string, min: number, max: number) { + const numberValue = Number(value); + + if (!Number.isInteger(numberValue) || numberValue < min || numberValue > max) { + throw new BadRequestException(`${field} must be an integer between ${min} and ${max}`); + } + + return numberValue; + } + + private normalizeOptionalText(value: string | undefined, maxLength: number) { + if (typeof value !== 'string') { + return undefined; + } + + const normalized = value.trim(); + + if (!normalized) { + return undefined; + } + if (normalized.length > maxLength) { + throw new BadRequestException(`Text value must be ${maxLength} characters or less`); + } + + return normalized; + } + + private parseOptionalId(value: string | undefined, message: string) { + if (value === undefined || value === null || String(value).trim() === '') { + return null; + } + + return this.parseId(value, message); + } + + private parseId(value: string | bigint, message: string) { + try { + const id = BigInt(value); + + if (id <= 0n) { + throw new Error('ID must be positive'); + } + + return id; + } catch { + throw new BadRequestException(message); + } + } + + private assertAdmin(user: AuthRequestUser) { + if (user.role !== 'admin') { + throw new ForbiddenException('Admin permission required'); + } + } + + private hasForceError(value: Prisma.JsonValue | null) { + return this.jsonObject(value).force_error === true; + } + + private hasMockFail(value: Prisma.InputJsonValue | null) { + return this.jsonObject(value).mock_fail === true; + } + + private encryptProviderSecret(value: string): Prisma.InputJsonObject { + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', this.providerSecretKey(), iv); + const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + + return { + kind: 'provider_secret_v1', + algorithm: 'aes-256-gcm', + iv: iv.toString('base64'), + tag: tag.toString('base64'), + value: encrypted.toString('base64') + }; + } + + private decryptProviderSecret(value: unknown) { + const payload = this.jsonObject(value as Prisma.InputJsonValue | Prisma.JsonValue | null); + + if ( + payload.kind !== 'provider_secret_v1' || + payload.algorithm !== 'aes-256-gcm' || + typeof payload.iv !== 'string' || + typeof payload.tag !== 'string' || + typeof payload.value !== 'string' + ) { + return ''; + } + + try { + const decipher = createDecipheriv( + 'aes-256-gcm', + this.providerSecretKey(), + Buffer.from(payload.iv, 'base64') + ); + decipher.setAuthTag(Buffer.from(payload.tag, 'base64')); + + return Buffer.concat([ + decipher.update(Buffer.from(payload.value, 'base64')), + decipher.final() + ]).toString('utf8'); + } catch { + throw new Error('PROVIDER_SECRET_DECRYPT_FAILED'); + } + } + + private providerSecretKey() { + const secret = + process.env.PROVIDER_SECRET_KEY?.trim() || + process.env.JWT_SECRET?.trim() || + 'local-development-provider-secret'; + + return createHash('sha256').update(secret).digest(); + } + + private jsonObject(value: Prisma.InputJsonValue | Prisma.JsonValue | null) { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + + return {}; + } + + private stringifyText(value: unknown) { + return typeof value === 'string' ? value.trim() : ''; + } + + private numberFromJson(value: unknown) { + const numberValue = Number(value ?? 0); + + return Number.isFinite(numberValue) ? numberValue : 0; + } + + private optionalNumberFromJson(value: unknown) { + if (value === undefined || value === null || value === '') { + return null; + } + + const numberValue = Number(value); + + return Number.isFinite(numberValue) ? numberValue : null; + } + + private numberArrayFromJson(value: unknown) { + if (!Array.isArray(value)) { + return []; + } + + return value + .map((item) => this.optionalNumberFromJson(item)) + .filter((item): item is number => item !== null); + } + + private optionalBooleanFromJson(value: unknown) { + if (value === undefined || value === null || value === '') { + return null; + } + if (typeof value === 'boolean') { + return value; + } + if (typeof value === 'number') { + if (value === 1) return true; + if (value === 0) return false; + return null; + } + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + + if (['true', '1', 'yes', 'y'].includes(normalized)) return true; + if (['false', '0', 'no', 'n'].includes(normalized)) return false; + } + + return null; + } + + private normalizePositiveNumber(value: unknown, min: number, max: number, fallback: number) { + const numberValue = Number(value ?? fallback); + + if (!Number.isFinite(numberValue)) { + return fallback; + } + + return Math.min(Math.max(Math.round(numberValue), min), max); + } + + private sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + private decimalToNumber(value: PrismaNamespace.Decimal | null | undefined) { + return value ? Number(value.toString()) : 0; + } + + private assertEnvReferenceName(value: unknown, path: string) { + if (typeof value !== 'string' || !/^[A-Z][A-Z0-9_]{0,127}$/.test(value)) { + throw new BadRequestException(`${path} must be an environment variable name`); + } + } + + private errorCodeFromError(error: Error | null) { + return error?.message.replace(/[^a-zA-Z0-9_]+/g, '_').slice(0, 100) || 'PROVIDER_FAILED'; + } + + private toError(error: unknown) { + return error instanceof Error ? error : new Error('Provider execution failed'); + } +} diff --git a/backend/src/queues/queues.controller.ts b/backend/src/queues/queues.controller.ts new file mode 100644 index 0000000..788ca7a --- /dev/null +++ b/backend/src/queues/queues.controller.ts @@ -0,0 +1,83 @@ +import { + Body, + Controller, + Get, + Inject, + Param, + Post, + Query, + UseGuards +} from '@nestjs/common'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import type { AuthRequestUser } from '../auth/auth.types'; +import { + CreateRenderTaskDto, + ListRenderTasksQueryDto, + RecoverStaleTasksDto, + TaskManualRequiredDto +} from './task.dto'; +import { QueuesService } from './queues.service'; + +@Controller() +@UseGuards(JwtAuthGuard) +export class QueuesController { + constructor(@Inject(QueuesService) private readonly queuesService: QueuesService) {} + + @Post('projects/:projectId/tasks') + createProjectTask( + @CurrentUser() user: AuthRequestUser, + @Param('projectId') projectId: string, + @Body() dto: CreateRenderTaskDto + ) { + return this.queuesService.createProjectTask(user, projectId, dto); + } + + @Get('projects/:projectId/tasks') + listProjectTasks( + @CurrentUser() user: AuthRequestUser, + @Param('projectId') projectId: string, + @Query() query: ListRenderTasksQueryDto + ) { + return this.queuesService.listProjectTasks(user, projectId, query); + } + + @Get('tasks/:taskId') + getTask(@CurrentUser() user: AuthRequestUser, @Param('taskId') taskId: string) { + return this.queuesService.getTask(user, taskId); + } + + @Get('admin/tasks') + listAdminTasks(@CurrentUser() user: AuthRequestUser, @Query() query: ListRenderTasksQueryDto) { + return this.queuesService.listAdminTasks(user, query); + } + + @Post('admin/tasks/recover-stale') + recoverStaleTasks(@CurrentUser() user: AuthRequestUser, @Body() dto: RecoverStaleTasksDto) { + return this.queuesService.recoverStaleTasks(user, dto); + } + + @Post('admin/tasks/:taskId/retry') + retryTask(@CurrentUser() user: AuthRequestUser, @Param('taskId') taskId: string) { + return this.queuesService.retryTask(user, taskId); + } + + @Post('admin/tasks/:taskId/cancel') + cancelTask(@CurrentUser() user: AuthRequestUser, @Param('taskId') taskId: string) { + return this.queuesService.cancelTask(user, taskId); + } + + @Post('admin/tasks/:taskId/manual-required') + markTaskManualRequired( + @CurrentUser() user: AuthRequestUser, + @Param('taskId') taskId: string, + @Body() dto: TaskManualRequiredDto + ) { + return this.queuesService.markTaskManualRequired(user, taskId, dto); + } + + @Get('admin/queues') + getQueueStats(@CurrentUser() user: AuthRequestUser) { + return this.queuesService.getQueueStats(user); + } +} diff --git a/backend/src/queues/queues.module.ts b/backend/src/queues/queues.module.ts new file mode 100644 index 0000000..a187908 --- /dev/null +++ b/backend/src/queues/queues.module.ts @@ -0,0 +1,16 @@ +import { forwardRef, Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { LiveActionModule } from '../live-action/live-action.module'; +import { PrismaModule } from '../prisma/prisma.module'; +import { ProvidersModule } from '../providers/providers.module'; +import { QueuesController } from './queues.controller'; +import { WorkerTasksController } from './worker-tasks.controller'; +import { QueuesService } from './queues.service'; + +@Module({ + imports: [AuthModule, PrismaModule, ProvidersModule, forwardRef(() => LiveActionModule)], + controllers: [QueuesController, WorkerTasksController], + providers: [QueuesService], + exports: [QueuesService] +}) +export class QueuesModule {} diff --git a/backend/src/queues/queues.service.spec.ts b/backend/src/queues/queues.service.spec.ts new file mode 100644 index 0000000..95cdd8b --- /dev/null +++ b/backend/src/queues/queues.service.spec.ts @@ -0,0 +1,306 @@ +import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { RenderTask } from '@prisma/client'; +import type { AuthRequestUser } from '../auth/auth.types'; +import type { PrismaService } from '../prisma/prisma.service'; +import { QueuesService, type TaskQueueAdapter } from './queues.service'; + +const user: AuthRequestUser = { + id: '1', + email: 'user@example.com', + role: 'user' +}; + +const admin: AuthRequestUser = { + id: '2', + email: 'admin@example.com', + role: 'admin' +}; + +const now = new Date('2026-05-31T00:00:00.000Z'); + +function createProject(overrides: Record = {}) { + return { + id: 10n, + user_id: 1n, + title: '测试项目', + input_mode: 'ai_original', + genre: 'urban_rebirth', + style_code: 'korean_comic', + output_type: 'short_video', + output_mode: 'image_manga', + visual_mode: 'korean_manga', + video_generation_level: 'standard', + target_episode_count: 3, + episode_duration: 60, + status: 'storyboard_confirmed', + copyright_status: 'ai_original', + payment_status: 'unpaid', + quality_level: 'mvp', + is_long_series: false, + created_at: now, + updated_at: now, + completed_at: null, + ...overrides + }; +} + +function createTask(overrides: Partial = {}): RenderTask { + return { + id: 100n, + project_id: 10n, + episode_id: null, + shot_id: null, + task_type: 'story_bible_generate', + provider_id: null, + status: 'pending', + input_json: { prompt: '测试' }, + input_hash: 'hash-a', + idempotency_key: '10:none:none:story_bible_generate:hash-a', + output_asset_id: null, + provider_request_id: null, + retry_count: 0, + max_retry: 2, + cost_estimate: null, + cost_actual: null, + error_code: null, + error_message: null, + created_at: now, + started_at: null, + finished_at: null, + ...overrides + }; +} + +describe('QueuesService', () => { + let prisma: any; + let queueAdapter: TaskQueueAdapter; + let providers: any; + let service: QueuesService; + + beforeEach(() => { + prisma = { + project: { + findUnique: vi.fn().mockResolvedValue(createProject()) + }, + episode: { + findUnique: vi.fn() + }, + storyboardShot: { + findUnique: vi.fn() + }, + renderTask: { + findUnique: vi.fn().mockResolvedValue(null), + findMany: vi.fn().mockResolvedValue([createTask()]), + count: vi.fn().mockResolvedValue(1), + create: vi.fn().mockResolvedValue(createTask()), + update: vi.fn().mockResolvedValue(createTask({ provider_request_id: 'job-1' })) + } + }; + queueAdapter = { + enqueue: vi.fn().mockResolvedValue('job-1'), + removeJob: vi.fn().mockResolvedValue(true), + getStats: vi.fn().mockResolvedValue({ + waiting: 1, + active: 0, + delayed: 0, + failed: 0, + completed: 0, + paused: 0 + }), + close: vi.fn().mockResolvedValue(undefined) + }; + providers = { + executeProvider: vi.fn().mockResolvedValue({ + provider_log: { id: 'log-1', status: 'success' }, + attempts: [] + }) + }; + service = new QueuesService(prisma as PrismaService, queueAdapter, providers); + }); + + it('creates a render task and enqueues it to the mapped BullMQ queue', async () => { + const result = await service.createProjectTask(user, '10', { + task_type: 'story_bible_generate', + input_json: { prompt: '测试' } + }); + + expect(prisma.renderTask.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + project_id: 10n, + task_type: 'story_bible_generate', + status: 'pending', + retry_count: 0, + max_retry: 2 + }) + }); + expect(queueAdapter.enqueue).toHaveBeenCalledWith( + 'story_queue', + 'story_bible_generate', + 'task-100-attempt-0', + expect.objectContaining({ + task_id: '100', + project_id: '10', + task_type: 'story_bible_generate' + }) + ); + expect(result.queue).toMatchObject({ + queue_name: 'story_queue', + queue_backend: 'bullmq', + enqueued: true + }); + expect(result.idempotent).toBe(false); + }); + + it('returns an existing task for the same idempotency key', async () => { + prisma.renderTask.findUnique.mockResolvedValue(createTask({ provider_request_id: 'job-old' })); + + const result = await service.createProjectTask(user, '10', { + task_type: 'story_bible_generate', + input_hash: 'hash-a' + }); + + expect(prisma.renderTask.create).not.toHaveBeenCalled(); + expect(queueAdapter.enqueue).not.toHaveBeenCalled(); + expect(result.idempotent).toBe(true); + expect(result.task.provider_request_id).toBe('job-old'); + }); + + it('allows admins to retry failed tasks within the retry limit', async () => { + prisma.renderTask.findUnique.mockResolvedValue( + createTask({ + status: 'failed', + error_code: 'PROVIDER_TIMEOUT', + error_message: 'timeout' + }) + ); + prisma.renderTask.update + .mockResolvedValueOnce(createTask({ status: 'retrying', retry_count: 1 })) + .mockResolvedValueOnce( + createTask({ + status: 'retrying', + retry_count: 1, + provider_request_id: 'job-retry' + }) + ); + vi.mocked(queueAdapter.enqueue).mockResolvedValueOnce('job-retry'); + + const result = await service.retryTask(admin, '100'); + + expect(prisma.renderTask.update).toHaveBeenCalledWith({ + where: { id: 100n }, + data: expect.objectContaining({ + status: 'retrying', + retry_count: 1, + error_code: null, + error_message: null + }) + }); + expect(result.task.status).toBe('retrying'); + expect(result.queue.job_id).toBe('job-retry'); + }); + + it('cancels tasks and removes every known attempt job', async () => { + prisma.renderTask.findUnique.mockResolvedValue( + createTask({ + status: 'retrying', + retry_count: 1, + provider_request_id: 'task-100-attempt-1' + }) + ); + prisma.renderTask.update.mockResolvedValue(createTask({ status: 'cancelled' })); + + const result = await service.cancelTask(admin, '100'); + + expect(queueAdapter.removeJob).toHaveBeenCalledWith('story_queue', 'task-100-attempt-1'); + expect(queueAdapter.removeJob).toHaveBeenCalledWith('story_queue', 'task-100-attempt-0'); + expect(result.task.status).toBe('cancelled'); + expect((result.queue as { removed_job?: boolean }).removed_job).toBe(true); + }); + + it('blocks admin APIs for normal users', async () => { + await expect(service.listAdminTasks(user, {})).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('rejects retry after max_retry is reached', async () => { + prisma.renderTask.findUnique.mockResolvedValue( + createTask({ + status: 'failed', + retry_count: 2, + max_retry: 2 + }) + ); + + await expect(service.retryTask(admin, '100')).rejects.toBeInstanceOf(BadRequestException); + }); + + it('worker executes queued provider tasks through the matching provider', async () => { + prisma.renderTask.findUnique + .mockResolvedValueOnce(createTask({ id: 100n, task_type: 'story_bible_generate' })) + .mockResolvedValueOnce(createTask({ id: 100n, task_type: 'story_bible_generate', status: 'success' })); + + const result = (await service.executeQueuedTask('100')) as { + auto_retried?: boolean; + queue?: { enqueued?: boolean }; + task: { status: string }; + }; + + expect(providers.executeProvider).toHaveBeenCalledWith({ + provider_type: 'TextProvider', + purpose: 'story_bible_generate', + project_id: '10', + task_id: '100', + input_json: { prompt: '测试' }, + allow_fallback: false, + return_binary: false + }); + expect(result.task.status).toBe('success'); + }); + + it('worker auto-retries failed tasks until retry limit is reached', async () => { + providers.executeProvider.mockRejectedValueOnce(new Error('provider timeout')); + prisma.renderTask.findUnique + .mockResolvedValueOnce(createTask({ id: 100n, status: 'pending', retry_count: 0, max_retry: 2 })) + .mockResolvedValueOnce(createTask({ id: 100n, status: 'failed', retry_count: 0, max_retry: 2 })); + prisma.renderTask.update + .mockResolvedValueOnce(createTask({ id: 100n, status: 'retrying', retry_count: 1 })) + .mockResolvedValueOnce( + createTask({ id: 100n, status: 'retrying', retry_count: 1, provider_request_id: 'job-retry' }) + ); + vi.mocked(queueAdapter.enqueue).mockResolvedValueOnce('job-retry'); + + const result = (await service.executeQueuedTask('100')) as { + auto_retried?: boolean; + queue?: { enqueued?: boolean }; + task: { status: string }; + }; + + expect(result.auto_retried).toBe(true); + expect(result.queue?.enqueued).toBe(true); + expect(prisma.renderTask.update).toHaveBeenCalledWith({ + where: { id: 100n }, + data: expect.objectContaining({ + status: 'retrying', + retry_count: 1 + }) + }); + }); + + it('worker moves exhausted failures to manual_required', async () => { + providers.executeProvider.mockRejectedValueOnce(new Error('provider timeout')); + prisma.renderTask.findUnique + .mockResolvedValueOnce(createTask({ id: 100n, status: 'pending', retry_count: 2, max_retry: 2 })) + .mockResolvedValueOnce(createTask({ id: 100n, status: 'failed', retry_count: 2, max_retry: 2 })); + prisma.renderTask.update.mockResolvedValueOnce( + createTask({ id: 100n, status: 'manual_required', retry_count: 2, max_retry: 2 }) + ); + + const result = (await service.executeQueuedTask('100')) as { + manual_required?: boolean; + task: { status: string }; + }; + + expect(result.manual_required).toBe(true); + expect(result.task.status).toBe('manual_required'); + }); +}); diff --git a/backend/src/queues/queues.service.ts b/backend/src/queues/queues.service.ts new file mode 100644 index 0000000..a7c6d12 --- /dev/null +++ b/backend/src/queues/queues.service.ts @@ -0,0 +1,1168 @@ +import { + BadRequestException, + forwardRef, + ForbiddenException, + Inject, + Injectable, + NotFoundException, + OnModuleDestroy, + Optional, + ServiceUnavailableException +} from '@nestjs/common'; +import type { Prisma, RenderTask } from '@prisma/client'; +import { Prisma as PrismaNamespace } from '@prisma/client'; +import { Queue, type ConnectionOptions } from 'bullmq'; +import { createHash } from 'node:crypto'; +import type { AuthRequestUser } from '../auth/auth.types'; +import { assertPermission } from '../auth/rbac'; +import { LiveActionService } from '../live-action/live-action.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { ProvidersService } from '../providers/providers.service'; +import type { ProviderType } from '../providers/provider.types'; +import { + CreateRenderTaskDto, + ListRenderTasksQueryDto, + RecoverStaleTasksDto, + TaskManualRequiredDto +} from './task.dto'; +import { + DEFAULT_MAX_RETRY_BY_TYPE, + QUEUE_NAMES, + TASK_QUEUE_BY_TYPE, + TASK_STATUSES, + TASK_TYPES, + toSafeRenderTask, + type QueueName, + type SafeRenderTask, + type TaskQueueMeta, + type TaskStatus, + type TaskType +} from './task.types'; + +export const TASK_QUEUE_ADAPTER = 'TASK_QUEUE_ADAPTER'; + +interface EnqueueTaskPayload { + task_id: string; + project_id: string; + episode_id: string | null; + shot_id: string | null; + task_type: TaskType; + input_json: Prisma.JsonValue | null; + input_hash: string | null; + retry_count: number; +} + +interface QueueCounts { + waiting: number; + active: number; + delayed: number; + failed: number; + completed: number; + paused: number; +} + +interface CreateInternalTaskInput { + projectId: bigint; + episodeId: bigint | null; + shotId: bigint | null; + taskType: TaskType; + inputJson: Prisma.InputJsonValue | null; + idempotencyKey?: string; + inputHash?: string; + maxRetry?: number; +} + +export interface TaskQueueAdapter { + enqueue( + queueName: QueueName, + taskType: TaskType, + jobId: string, + payload: EnqueueTaskPayload + ): Promise; + removeJob(queueName: QueueName, jobId: string): Promise; + getStats(queueName: QueueName): Promise; + close(): Promise; +} + +class BullMqTaskQueueAdapter implements TaskQueueAdapter { + private readonly connection: ConnectionOptions; + private readonly queues = new Map(); + + constructor(redisUrl: string) { + this.connection = this.createConnectionOptions(redisUrl); + + for (const queueName of QUEUE_NAMES) { + this.queues.set( + queueName, + new Queue(queueName, { + connection: this.connection, + defaultJobOptions: { + removeOnComplete: 1000, + removeOnFail: 5000 + } + }) + ); + } + } + + async enqueue( + queueName: QueueName, + taskType: TaskType, + jobId: string, + payload: EnqueueTaskPayload + ) { + const job = await this.getQueue(queueName).add(taskType, payload, { + jobId, + attempts: 1 + }); + + return job.id ?? jobId; + } + + async removeJob(queueName: QueueName, jobId: string) { + const job = await this.getQueue(queueName).getJob(jobId); + + if (!job) { + return false; + } + + await job.remove(); + return true; + } + + async getStats(queueName: QueueName) { + const counts = await this.getQueue(queueName).getJobCounts( + 'waiting', + 'active', + 'delayed', + 'failed', + 'completed', + 'paused' + ); + + return { + waiting: counts.waiting ?? 0, + active: counts.active ?? 0, + delayed: counts.delayed ?? 0, + failed: counts.failed ?? 0, + completed: counts.completed ?? 0, + paused: counts.paused ?? 0 + }; + } + + async close() { + await Promise.all([...this.queues.values()].map((queue) => queue.close())); + } + + private getQueue(queueName: QueueName) { + const queue = this.queues.get(queueName); + + if (!queue) { + throw new Error(`Unknown queue: ${queueName}`); + } + + return queue; + } + + private createConnectionOptions(redisUrl: string): ConnectionOptions { + try { + const parsed = new URL(redisUrl); + const db = parsed.pathname ? Number(parsed.pathname.slice(1)) : 0; + + return { + host: parsed.hostname || '127.0.0.1', + port: parsed.port ? Number(parsed.port) : 6379, + username: parsed.username || undefined, + password: parsed.password || undefined, + db: Number.isInteger(db) ? db : 0, + lazyConnect: true, + connectTimeout: 1000, + maxRetriesPerRequest: 1, + enableReadyCheck: false, + retryStrategy: () => null + } as ConnectionOptions; + } catch { + return { + host: '127.0.0.1', + port: 6379, + lazyConnect: true, + connectTimeout: 1000, + maxRetriesPerRequest: 1, + enableReadyCheck: false, + retryStrategy: () => null + } as ConnectionOptions; + } + } +} + +@Injectable() +export class QueuesService implements OnModuleDestroy { + private readonly queueAdapter: TaskQueueAdapter; + private readonly redisUrl: string; + + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Optional() @Inject(TASK_QUEUE_ADAPTER) queueAdapter?: TaskQueueAdapter, + @Optional() @Inject(ProvidersService) private readonly providers?: ProvidersService, + @Optional() @Inject(forwardRef(() => LiveActionService)) private readonly liveAction?: LiveActionService + ) { + this.redisUrl = process.env.REDIS_URL ?? 'redis://127.0.0.1:6379'; + this.queueAdapter = queueAdapter ?? new BullMqTaskQueueAdapter(this.redisUrl); + } + + async onModuleDestroy() { + await this.queueAdapter.close(); + } + + async createProjectTask( + user: AuthRequestUser, + projectId: string, + dto: CreateRenderTaskDto + ) { + const project = await this.findProjectForUser(projectId, user); + const taskType = this.validateTaskType(dto.task_type); + const refs = await this.validateTaskReferences(project.id, dto.episode_id, dto.shot_id); + const inputJson = this.normalizeJsonInput(dto.input_json); + const inputHash = this.normalizeInputHash(dto.input_hash) ?? this.hashJson(inputJson); + const idempotencyKey = + this.normalizeIdempotencyKey(dto.idempotency_key) ?? + this.buildIdempotencyKey(project.id, refs.episodeId, refs.shotId, taskType, inputHash); + const maxRetry = this.normalizeMaxRetry( + dto.max_retry, + DEFAULT_MAX_RETRY_BY_TYPE[taskType] + ); + const existing = await this.findTaskByIdempotencyKey(idempotencyKey); + + if (existing) { + return { + task: toSafeRenderTask(existing), + queue: this.createQueueMeta(existing, false, null), + idempotent: true + }; + } + + const task = await this.createRenderTaskWithIdempotency({ + projectId: project.id, + episodeId: refs.episodeId, + shotId: refs.shotId, + taskType, + inputJson, + inputHash, + idempotencyKey, + maxRetry + }); + const queued = await this.enqueueTask(task); + + return { + task: queued.task, + queue: queued.queue, + idempotent: false + }; + } + + async listProjectTasks( + user: AuthRequestUser, + projectId: string, + query: ListRenderTasksQueryDto + ) { + const project = await this.findProjectForUser(projectId, user); + return this.listTasks({ ...query, project_id: project.id.toString() }); + } + + async createInternalTask(input: CreateInternalTaskInput) { + const taskType = this.validateTaskType(input.taskType); + const inputJson = input.inputJson ?? {}; + const inputHash = input.inputHash ?? this.hashJson(inputJson); + const idempotencyKey = + input.idempotencyKey ?? + this.buildIdempotencyKey(input.projectId, input.episodeId, input.shotId, taskType, inputHash); + const maxRetry = this.normalizeMaxRetry( + input.maxRetry, + DEFAULT_MAX_RETRY_BY_TYPE[taskType] + ); + const task = await this.createRenderTaskWithIdempotency({ + projectId: input.projectId, + episodeId: input.episodeId, + shotId: input.shotId, + taskType, + inputJson, + inputHash, + idempotencyKey, + maxRetry + }); + const queued = await this.enqueueTask(task); + + return { + task: queued.task, + queue: queued.queue, + idempotent: task.idempotency_key === idempotencyKey && task.status !== 'pending' + }; + } + + async getTask(user: AuthRequestUser, taskId: string) { + const task = await this.findTaskOrThrow(taskId); + await this.findProjectForUser(task.project_id.toString(), user); + + return { + task: toSafeRenderTask(task), + queue_name: this.getQueueName(task.task_type) + }; + } + + async listAdminTasks(user: AuthRequestUser, query: ListRenderTasksQueryDto) { + assertPermission(user, 'tasks:read'); + return this.listTasks(query); + } + + async retryTask(user: AuthRequestUser, taskId: string) { + assertPermission(user, 'tasks:write'); + const task = await this.findTaskOrThrow(taskId); + + if (!['failed', 'manual_required'].includes(task.status)) { + throw new BadRequestException('Only failed or manual_required tasks can be retried'); + } + if (task.retry_count >= task.max_retry) { + throw new BadRequestException('Task retry limit reached'); + } + + const updated = await this.prisma.renderTask.update({ + where: { id: task.id }, + data: { + status: 'retrying', + retry_count: task.retry_count + 1, + provider_request_id: null, + error_code: null, + error_message: null, + started_at: null, + finished_at: null + } + }); + const queued = await this.enqueueTask(updated); + + return { + task: queued.task, + queue: queued.queue + }; + } + + async cancelTask(user: AuthRequestUser, taskId: string) { + assertPermission(user, 'tasks:write'); + const task = await this.findTaskOrThrow(taskId); + + if (task.status === 'success') { + throw new BadRequestException('Successful tasks cannot be cancelled'); + } + + const removedJob = await this.removeQueuedJob(task); + const updated = await this.prisma.renderTask.update({ + where: { id: task.id }, + data: { + status: 'cancelled', + finished_at: new Date() + } + }); + + return { + task: toSafeRenderTask(updated), + queue: this.createQueueMeta(updated, false, null, { removed_job: removedJob }) + }; + } + + async markTaskManualRequired( + user: AuthRequestUser, + taskId: string, + dto: TaskManualRequiredDto + ) { + assertPermission(user, 'tasks:write'); + const task = await this.findTaskOrThrow(taskId); + const removedJob = await this.removeQueuedJob(task); + const updated = await this.prisma.renderTask.update({ + where: { id: task.id }, + data: { + status: 'manual_required', + error_code: this.normalizeOptionalText(dto.error_code, 100) ?? 'MANUAL_REQUIRED', + error_message: + this.normalizeOptionalText(dto.error_message, 1000) ?? + 'Task was moved to manual_required by admin', + finished_at: new Date() + } + }); + + return { + task: toSafeRenderTask(updated), + queue: this.createQueueMeta(updated, false, null, { removed_job: removedJob }) + }; + } + + async recoverStaleTasks(user: AuthRequestUser, dto: RecoverStaleTasksDto) { + assertPermission(user, 'tasks:write'); + const olderThanMinutes = this.normalizePositiveInt( + dto.older_than_minutes, + 'older_than_minutes', + 1, + 24 * 60, + 30 + ); + const limit = this.normalizePositiveInt(dto.limit, 'limit', 1, 100, 50); + const cutoff = new Date(Date.now() - olderThanMinutes * 60 * 1000); + const staleTasks = await this.prisma.renderTask.findMany({ + where: { + status: { in: ['running', 'retrying'] }, + OR: [ + { started_at: { lt: cutoff } }, + { + started_at: null, + created_at: { lt: cutoff } + } + ] + }, + orderBy: { created_at: 'asc' }, + take: limit + }); + const recovered: SafeRenderTask[] = []; + const manualRequired: SafeRenderTask[] = []; + + for (const task of staleTasks) { + if (task.retry_count < task.max_retry) { + const updated = await this.prisma.renderTask.update({ + where: { id: task.id }, + data: { + status: 'retrying', + retry_count: task.retry_count + 1, + provider_request_id: null, + error_code: null, + error_message: null, + started_at: null, + finished_at: null + } + }); + const queued = await this.enqueueTask(updated); + recovered.push(queued.task); + } else { + const updated = await this.prisma.renderTask.update({ + where: { id: task.id }, + data: { + status: 'manual_required', + error_code: 'STALE_TASK_RETRY_EXHAUSTED', + error_message: 'Stale task reached retry limit during recovery', + finished_at: new Date() + } + }); + manualRequired.push(toSafeRenderTask(updated)); + } + } + + return { + recovered, + manual_required: manualRequired, + scanned: staleTasks.length, + older_than_minutes: olderThanMinutes + }; + } + + async getQueueStats(user: AuthRequestUser) { + assertPermission(user, 'tasks:read'); + const queues = []; + + for (const queueName of QUEUE_NAMES) { + try { + queues.push({ + name: queueName, + status: 'ok', + counts: await this.queueAdapter.getStats(queueName) + }); + } catch (error) { + queues.push({ + name: queueName, + status: 'unavailable', + error_message: this.errorToMessage(error) + }); + } + } + + return { + backend: 'bullmq', + redis_url: this.sanitizeRedisUrl(this.redisUrl), + queues + }; + } + + async executeQueuedTask(taskId: string) { + if (!this.providers) { + throw new ServiceUnavailableException('Provider executor is not available'); + } + + const task = await this.findTaskOrThrow(taskId); + + if (task.status === 'success' || task.status === 'cancelled') { + return { + task: toSafeRenderTask(task), + skipped: true, + reason: `Task already ${task.status}` + }; + } + if (!['pending', 'retrying', 'running', 'failed'].includes(task.status)) { + throw new BadRequestException('Task status cannot be executed by worker'); + } + + const providerType = this.providerTypeForTask(task.task_type); + + if (this.isLiveActionBusinessTask(task.task_type)) { + return this.executeLiveActionBusinessTask(task); + } + + if (!providerType) { + const updated = await this.prisma.renderTask.update({ + where: { id: task.id }, + data: { + status: 'skipped', + error_code: null, + error_message: 'Task type does not require provider execution', + finished_at: new Date() + } + }); + + return { + task: toSafeRenderTask(updated), + skipped: true, + reason: 'Task type does not require provider execution' + }; + } + + try { + const providerResult = await this.providers.executeProvider({ + provider_type: providerType, + purpose: task.task_type, + project_id: task.project_id.toString(), + task_id: task.id.toString(), + input_json: task.input_json ?? {}, + allow_fallback: false, + return_binary: false + }); + const updated = await this.findTaskOrThrow(task.id.toString()); + + return { + task: toSafeRenderTask(updated), + provider_log: providerResult.provider_log, + attempts: providerResult.attempts + }; + } catch (error) { + const failed = await this.findTaskOrThrow(task.id.toString()); + const message = this.errorToMessage(error); + + if (failed.retry_count < failed.max_retry) { + const retrying = await this.prisma.renderTask.update({ + where: { id: failed.id }, + data: { + status: 'retrying', + retry_count: failed.retry_count + 1, + provider_request_id: null, + error_code: null, + error_message: null, + started_at: null, + finished_at: null + } + }); + const queued = await this.enqueueTask(retrying); + + return { + task: queued.task, + queue: queued.queue, + auto_retried: true, + previous_error: message + }; + } + + const manualRequired = await this.prisma.renderTask.update({ + where: { id: failed.id }, + data: { + status: 'manual_required', + error_code: 'WORKER_RETRY_EXHAUSTED', + error_message: message, + finished_at: new Date() + } + }); + + return { + task: toSafeRenderTask(manualRequired), + auto_retried: false, + manual_required: true, + previous_error: message + }; + } + } + + private async listTasks(query: ListRenderTasksQueryDto) { + const where: Prisma.RenderTaskWhereInput = {}; + const limit = this.normalizePositiveInt(query.limit, 'limit', 1, 100, 50); + + if (query.project_id) { + where.project_id = this.parseId(query.project_id, 'Invalid project_id'); + } + if (query.episode_id) { + where.episode_id = this.parseId(query.episode_id, 'Invalid episode_id'); + } + if (query.shot_id) { + where.shot_id = this.parseId(query.shot_id, 'Invalid shot_id'); + } + if (query.task_type) { + where.task_type = this.validateTaskType(query.task_type); + } + if (query.status) { + where.status = this.validateTaskStatus(query.status); + } + + const [tasks, total] = await Promise.all([ + this.prisma.renderTask.findMany({ + where, + orderBy: { created_at: 'desc' }, + take: limit + }), + this.prisma.renderTask.count({ where }) + ]); + + return { + tasks: tasks.map(toSafeRenderTask), + total, + limit + }; + } + + private async createRenderTaskWithIdempotency(input: { + projectId: bigint; + episodeId: bigint | null; + shotId: bigint | null; + taskType: TaskType; + inputJson: Prisma.InputJsonValue | null; + inputHash: string; + idempotencyKey: string; + maxRetry: number; + }) { + try { + return await this.prisma.renderTask.create({ + data: { + project_id: input.projectId, + episode_id: input.episodeId, + shot_id: input.shotId, + task_type: input.taskType, + status: 'pending', + input_json: input.inputJson === null ? PrismaNamespace.JsonNull : input.inputJson, + input_hash: input.inputHash, + idempotency_key: input.idempotencyKey, + retry_count: 0, + max_retry: input.maxRetry + } + }); + } catch (error) { + if (this.isUniqueConstraintError(error)) { + const existing = await this.findTaskByIdempotencyKey(input.idempotencyKey); + + if (existing) { + return existing; + } + } + + throw error; + } + } + + private async enqueueTask(task: RenderTask): Promise<{ + task: SafeRenderTask; + queue: TaskQueueMeta; + }> { + const taskType = this.validateTaskType(task.task_type); + const queueName = TASK_QUEUE_BY_TYPE[taskType]; + const jobId = `task-${task.id.toString()}-attempt-${task.retry_count}`; + + try { + const providerRequestId = await this.queueAdapter.enqueue(queueName, taskType, jobId, { + task_id: task.id.toString(), + project_id: task.project_id.toString(), + episode_id: task.episode_id?.toString() ?? null, + shot_id: task.shot_id?.toString() ?? null, + task_type: taskType, + input_json: task.input_json, + input_hash: task.input_hash, + retry_count: task.retry_count + }); + const updated = await this.prisma.renderTask.update({ + where: { id: task.id }, + data: { provider_request_id: providerRequestId } + }); + + return { + task: toSafeRenderTask(updated), + queue: { + queue_name: queueName, + queue_backend: 'bullmq', + job_id: providerRequestId, + enqueued: true + } + }; + } catch (error) { + return { + task: toSafeRenderTask(task), + queue: { + queue_name: queueName, + queue_backend: 'bullmq_unavailable', + job_id: null, + enqueued: false, + error_message: this.errorToMessage(error) + } + }; + } + } + + private async executeLiveActionBusinessTask(task: RenderTask) { + if (!this.liveAction) { + throw new ServiceUnavailableException('Live action executor is not available'); + } + + try { + const result = await this.liveAction.executeQueuedRouterTask(task); + const updated = await this.findTaskOrThrow(task.id.toString()); + + return { + task: toSafeRenderTask(updated), + result + }; + } catch (error) { + return this.handleQueuedTaskFailure(task.id.toString(), error); + } + } + + private async handleQueuedTaskFailure(taskId: string, error: unknown) { + const latest = await this.findTaskOrThrow(taskId); + const message = this.errorToMessage(error); + const failed = await this.prisma.renderTask.update({ + where: { id: latest.id }, + data: { + status: 'failed', + error_code: message.replace(/[^a-zA-Z0-9_]+/g, '_').slice(0, 100) || 'TASK_FAILED', + error_message: message, + finished_at: new Date() + } + }); + + if (failed.retry_count < failed.max_retry) { + const retrying = await this.prisma.renderTask.update({ + where: { id: failed.id }, + data: { + status: 'retrying', + retry_count: failed.retry_count + 1, + provider_request_id: null, + error_code: null, + error_message: null, + started_at: null, + finished_at: null + } + }); + const queued = await this.enqueueTask(retrying); + + return { + task: queued.task, + queue: queued.queue, + auto_retried: true, + previous_error: message + }; + } + + const manualRequired = await this.prisma.renderTask.update({ + where: { id: failed.id }, + data: { + status: 'manual_required', + error_code: 'WORKER_RETRY_EXHAUSTED', + error_message: message, + finished_at: new Date() + } + }); + + return { + task: toSafeRenderTask(manualRequired), + auto_retried: false, + manual_required: true, + previous_error: message + }; + } + + private async removeQueuedJob(task: RenderTask) { + const queueName = this.getQueueName(task.task_type); + const jobIds = new Set(); + + if (task.provider_request_id) { + jobIds.add(task.provider_request_id); + } + for (let attempt = 0; attempt <= task.retry_count; attempt += 1) { + jobIds.add(`task-${task.id.toString()}-attempt-${attempt}`); + } + + let removed = false; + + for (const jobId of jobIds) { + try { + removed = (await this.queueAdapter.removeJob(queueName, jobId)) || removed; + } catch { + // Best-effort cleanup: DB status is still the source of truth. + } + } + + return removed; + } + + private createQueueMeta( + task: RenderTask, + enqueued: boolean, + errorMessage: string | null, + extras: Record = {} + ) { + return { + queue_name: this.getQueueName(task.task_type), + queue_backend: 'bullmq' as const, + job_id: task.provider_request_id, + enqueued, + ...(errorMessage ? { error_message: errorMessage } : {}), + ...extras + }; + } + + private async validateTaskReferences( + projectId: bigint, + episodeIdInput: string | undefined, + shotIdInput: string | undefined + ) { + let episodeId = this.parseOptionalId(episodeIdInput, 'Invalid episode_id'); + const shotId = this.parseOptionalId(shotIdInput, 'Invalid shot_id'); + + if (episodeId !== null) { + const episode = await this.prisma.episode.findUnique({ + where: { id: episodeId } + }); + + if (!episode) { + throw new NotFoundException('Episode not found'); + } + if (episode.project_id !== projectId) { + throw new BadRequestException('episode_id does not belong to project'); + } + } + + if (shotId !== null) { + const shot = await this.prisma.storyboardShot.findUnique({ + where: { id: shotId } + }); + + if (!shot) { + throw new NotFoundException('Storyboard shot not found'); + } + if (shot.project_id !== projectId) { + throw new BadRequestException('shot_id does not belong to project'); + } + if (episodeId !== null && shot.episode_id !== episodeId) { + throw new BadRequestException('shot_id does not belong to episode_id'); + } + + episodeId = episodeId ?? shot.episode_id; + } + + return { episodeId, shotId }; + } + + private async findProjectForUser(projectId: string, user: AuthRequestUser) { + const project = await this.prisma.project.findUnique({ + where: { id: this.parseId(projectId, 'Invalid project id') } + }); + + if (!project) { + throw new NotFoundException('Project not found'); + } + if (project.user_id.toString() !== user.id && user.role !== 'admin') { + throw new ForbiddenException('Project is private'); + } + + return project; + } + + private async findTaskOrThrow(taskId: string) { + const task = await this.prisma.renderTask.findUnique({ + where: { id: this.parseId(taskId, 'Invalid task id') } + }); + + if (!task) { + throw new NotFoundException('Task not found'); + } + + return task; + } + + private findTaskByIdempotencyKey(idempotencyKey: string) { + return this.prisma.renderTask.findUnique({ + where: { idempotency_key: idempotencyKey } + }); + } + + private validateTaskType(value: unknown): TaskType { + if (typeof value !== 'string' || !(TASK_TYPES as readonly string[]).includes(value)) { + throw new BadRequestException('task_type is not supported'); + } + + return value as TaskType; + } + + private validateTaskStatus(value: unknown): TaskStatus { + if (typeof value !== 'string' || !(TASK_STATUSES as readonly string[]).includes(value)) { + throw new BadRequestException('status is not supported'); + } + + return value as TaskStatus; + } + + private getQueueName(taskType: string): QueueName { + return TASK_QUEUE_BY_TYPE[this.validateTaskType(taskType)]; + } + + private providerTypeForTask(taskType: string): ProviderType | null { + const normalized = this.validateTaskType(taskType); + + switch (normalized) { + case 'novel_generate': + return 'NovelProvider'; + case 'novel_parse': + return 'FileParseProvider'; + case 'story_bible_generate': + case 'character_extract': + case 'episode_plan_generate': + case 'script_generate': + case 'storyboard_generate': + return 'TextProvider'; + case 'character_image_generate': + case 'shot_image_generate': + return 'ImageProvider'; + case 'audio_generate': + return 'VoiceProvider'; + case 'video_render': + return 'VideoProvider'; + case 'live_action_keyframe_generate': + case 'live_action_video_clip_generate': + case 'live_action_video_clip_retry': + case 'live_action_video_clip_quality_check': + case 'live_action_video_render': + return null; + case 'qc_check': + return 'QualityCheckProvider'; + case 'manual_review': + return 'ModerationProvider'; + case 'analytics_event': + return null; + default: + return null; + } + } + + private isLiveActionBusinessTask(taskType: string) { + return ['live_action_video_clip_retry', 'live_action_video_clip_quality_check'].includes(taskType); + } + + private normalizeJsonInput(value: unknown): Prisma.InputJsonValue | null { + if (value === undefined) { + return {}; + } + + return this.toJsonValue(value, 'input_json'); + } + + private toJsonValue(value: unknown, path: string): Prisma.InputJsonValue | null { + if (value === null) { + return null; + } + if (typeof value === 'string' || typeof value === 'boolean') { + return value; + } + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + throw new BadRequestException(`${path} must contain finite numbers only`); + } + + return value; + } + if (Array.isArray(value)) { + return value.map((item, index) => this.toJsonValue(item, `${path}[${index}]`)); + } + if (typeof value === 'object') { + if (value instanceof Date) { + return value.toISOString(); + } + + const output: Record = {}; + + for (const [key, child] of Object.entries(value as Record)) { + if (child === undefined) { + throw new BadRequestException(`${path}.${key} cannot be undefined`); + } + + output[key] = this.toJsonValue(child, `${path}.${key}`); + } + + return output as Prisma.InputJsonValue; + } + + throw new BadRequestException(`${path} must be valid JSON`); + } + + private hashJson(value: Prisma.InputJsonValue | null) { + return createHash('sha256').update(this.stableStringify(value)).digest('hex'); + } + + private stableStringify(value: Prisma.InputJsonValue | null): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((item) => this.stableStringify(item as Prisma.InputJsonValue | null)).join(',')}]`; + } + + const entries = Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => `${JSON.stringify(key)}:${this.stableStringify(child)}`); + + return `{${entries.join(',')}}`; + } + + private buildIdempotencyKey( + projectId: bigint, + episodeId: bigint | null, + shotId: bigint | null, + taskType: TaskType, + inputHash: string + ) { + return [ + projectId.toString(), + episodeId?.toString() ?? 'none', + shotId?.toString() ?? 'none', + taskType, + inputHash + ].join(':'); + } + + private normalizeInputHash(value: string | undefined) { + const normalized = this.normalizeOptionalText(value, 128); + + if (!normalized) { + return null; + } + if (!/^[a-zA-Z0-9:_-]+$/.test(normalized)) { + throw new BadRequestException('input_hash contains unsupported characters'); + } + + return normalized; + } + + private normalizeIdempotencyKey(value: string | undefined) { + const normalized = this.normalizeOptionalText(value, 191); + + if (!normalized) { + return null; + } + if (!/^[a-zA-Z0-9:_-]+$/.test(normalized)) { + throw new BadRequestException('idempotency_key contains unsupported characters'); + } + + return normalized; + } + + private normalizeMaxRetry(value: unknown, fallback: number) { + return this.normalizePositiveInt(value, 'max_retry', 0, 10, fallback); + } + + private normalizePositiveInt( + value: unknown, + field: string, + min: number, + max: number, + fallback: number + ) { + if (value === undefined || value === null || value === '') { + return fallback; + } + + const numberValue = Number(value); + + if (!Number.isInteger(numberValue) || numberValue < min || numberValue > max) { + throw new BadRequestException(`${field} must be an integer between ${min} and ${max}`); + } + + return numberValue; + } + + private normalizeOptionalText(value: string | undefined, maxLength: number) { + if (typeof value !== 'string') { + return undefined; + } + + const normalized = value.trim(); + + if (!normalized) { + return undefined; + } + if (normalized.length > maxLength) { + throw new BadRequestException(`Text value must be ${maxLength} characters or less`); + } + + return normalized; + } + + private parseOptionalId(value: string | undefined, message: string) { + if (value === undefined || value === null || String(value).trim() === '') { + return null; + } + + return this.parseId(value, message); + } + + private parseId(value: string | bigint, message: string) { + try { + const id = BigInt(value); + + if (id <= 0n) { + throw new Error('ID must be positive'); + } + + return id; + } catch { + throw new BadRequestException(message); + } + } + + private assertAdmin(user: AuthRequestUser) { + if (user.role !== 'admin') { + throw new ForbiddenException('Admin permission required'); + } + } + + private isUniqueConstraintError(error: unknown) { + return ( + error instanceof PrismaNamespace.PrismaClientKnownRequestError && + error.code === 'P2002' + ); + } + + private errorToMessage(error: unknown) { + return error instanceof Error ? error.message : 'Unknown queue error'; + } + + private sanitizeRedisUrl(redisUrl: string) { + try { + const parsed = new URL(redisUrl); + + if (parsed.password) { + parsed.password = '***'; + } + if (parsed.username) { + parsed.username = '***'; + } + + return parsed.toString(); + } catch { + return 'redis://127.0.0.1:6379'; + } + } +} diff --git a/backend/src/queues/task.dto.ts b/backend/src/queues/task.dto.ts new file mode 100644 index 0000000..aba5619 --- /dev/null +++ b/backend/src/queues/task.dto.ts @@ -0,0 +1,30 @@ +import type { TaskStatus, TaskType } from './task.types'; + +export class CreateRenderTaskDto { + task_type?: TaskType; + episode_id?: string; + shot_id?: string; + input_json?: unknown; + input_hash?: string; + idempotency_key?: string; + max_retry?: number; +} + +export class ListRenderTasksQueryDto { + project_id?: string; + episode_id?: string; + shot_id?: string; + task_type?: TaskType; + status?: TaskStatus; + limit?: string; +} + +export class TaskManualRequiredDto { + error_code?: string; + error_message?: string; +} + +export class RecoverStaleTasksDto { + older_than_minutes?: number; + limit?: number; +} diff --git a/backend/src/queues/task.types.ts b/backend/src/queues/task.types.ts new file mode 100644 index 0000000..97ab00a --- /dev/null +++ b/backend/src/queues/task.types.ts @@ -0,0 +1,160 @@ +import type { Prisma, RenderTask } from '@prisma/client'; + +export const QUEUE_NAMES = [ + 'novel_queue', + 'parse_queue', + 'story_queue', + 'character_queue', + 'episode_queue', + 'script_queue', + 'storyboard_queue', + 'image_queue', + 'audio_queue', + 'subtitle_queue', + 'video_queue', + 'qc_queue', + 'review_queue', + 'analytics_queue' +] as const; + +export const TASK_STATUSES = [ + 'pending', + 'running', + 'success', + 'failed', + 'retrying', + 'cancelled', + 'manual_required', + 'skipped' +] as const; + +export const TASK_TYPES = [ + 'novel_generate', + 'novel_parse', + 'story_bible_generate', + 'character_extract', + 'episode_plan_generate', + 'script_generate', + 'storyboard_generate', + 'character_image_generate', + 'shot_image_generate', + 'audio_generate', + 'subtitle_generate', + 'video_render', + 'live_action_keyframe_generate', + 'live_action_video_clip_generate', + 'live_action_video_clip_retry', + 'live_action_video_clip_quality_check', + 'live_action_video_render', + 'qc_check', + 'manual_review', + 'analytics_event' +] as const; + +export const TASK_QUEUE_BY_TYPE: Record = { + novel_generate: 'novel_queue', + novel_parse: 'parse_queue', + story_bible_generate: 'story_queue', + character_extract: 'character_queue', + episode_plan_generate: 'episode_queue', + script_generate: 'script_queue', + storyboard_generate: 'storyboard_queue', + character_image_generate: 'image_queue', + shot_image_generate: 'image_queue', + audio_generate: 'audio_queue', + subtitle_generate: 'subtitle_queue', + video_render: 'video_queue', + live_action_keyframe_generate: 'image_queue', + live_action_video_clip_generate: 'video_queue', + live_action_video_clip_retry: 'video_queue', + live_action_video_clip_quality_check: 'qc_queue', + live_action_video_render: 'video_queue', + qc_check: 'qc_queue', + manual_review: 'review_queue', + analytics_event: 'analytics_queue' +}; + +export const DEFAULT_MAX_RETRY_BY_TYPE: Record = { + novel_generate: 2, + novel_parse: 2, + story_bible_generate: 2, + character_extract: 2, + episode_plan_generate: 2, + script_generate: 2, + storyboard_generate: 2, + character_image_generate: 3, + shot_image_generate: 3, + audio_generate: 2, + subtitle_generate: 2, + video_render: 2, + live_action_keyframe_generate: 2, + live_action_video_clip_generate: 2, + live_action_video_clip_retry: 1, + live_action_video_clip_quality_check: 1, + live_action_video_render: 2, + qc_check: 1, + manual_review: 0, + analytics_event: 1 +}; + +export type QueueName = (typeof QUEUE_NAMES)[number]; +export type TaskStatus = (typeof TASK_STATUSES)[number]; +export type TaskType = (typeof TASK_TYPES)[number]; + +export interface SafeRenderTask { + id: string; + project_id: string; + episode_id: string | null; + shot_id: string | null; + task_type: string; + provider_id: string | null; + status: string; + input_json: Prisma.JsonValue | null; + input_hash: string | null; + idempotency_key: string | null; + output_asset_id: string | null; + provider_request_id: string | null; + retry_count: number; + max_retry: number; + cost_estimate: number | null; + cost_actual: number | null; + error_code: string | null; + error_message: string | null; + created_at: string; + started_at: string | null; + finished_at: string | null; +} + +export interface TaskQueueMeta { + queue_name: QueueName; + queue_backend: 'bullmq' | 'bullmq_unavailable'; + job_id: string | null; + enqueued: boolean; + error_message?: string; +} + +export function toSafeRenderTask(task: RenderTask): SafeRenderTask { + return { + id: task.id.toString(), + project_id: task.project_id.toString(), + episode_id: task.episode_id?.toString() ?? null, + shot_id: task.shot_id?.toString() ?? null, + task_type: task.task_type, + provider_id: task.provider_id?.toString() ?? null, + status: task.status, + input_json: task.input_json, + input_hash: task.input_hash, + idempotency_key: task.idempotency_key, + output_asset_id: task.output_asset_id?.toString() ?? null, + provider_request_id: task.provider_request_id, + retry_count: task.retry_count, + max_retry: task.max_retry, + cost_estimate: task.cost_estimate ? Number(task.cost_estimate.toString()) : null, + cost_actual: task.cost_actual ? Number(task.cost_actual.toString()) : null, + error_code: task.error_code, + error_message: task.error_message, + created_at: task.created_at.toISOString(), + started_at: task.started_at?.toISOString() ?? null, + finished_at: task.finished_at?.toISOString() ?? null + }; +} diff --git a/backend/src/queues/worker-tasks.controller.ts b/backend/src/queues/worker-tasks.controller.ts new file mode 100644 index 0000000..c6a794e --- /dev/null +++ b/backend/src/queues/worker-tasks.controller.ts @@ -0,0 +1,40 @@ +import { Body, Controller, Headers, Inject, Param, Post, UnauthorizedException } from '@nestjs/common'; +import { timingSafeEqual } from 'node:crypto'; +import { QueuesService } from './queues.service'; + +interface ExecuteWorkerTaskBody { + job_id?: string; + queue_name?: string; +} + +@Controller('internal/worker') +export class WorkerTasksController { + constructor(@Inject(QueuesService) private readonly queuesService: QueuesService) {} + + @Post('tasks/:taskId/execute') + executeTask( + @Param('taskId') taskId: string, + @Headers('x-worker-secret') workerSecret: string | undefined, + @Body() _body: ExecuteWorkerTaskBody + ) { + this.assertWorkerSecret(workerSecret); + return this.queuesService.executeQueuedTask(taskId); + } + + private assertWorkerSecret(value: string | undefined) { + const expected = + process.env.WORKER_SECRET?.trim() || + (process.env.NODE_ENV === 'production' ? '' : 'local_worker_secret_change_me'); + + if (!expected || !value || !this.secureEquals(value, expected)) { + throw new UnauthorizedException('Invalid worker secret'); + } + } + + private secureEquals(left: string, right: string) { + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + + return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer); + } +} diff --git a/backend/src/reviews/review.dto.ts b/backend/src/reviews/review.dto.ts new file mode 100644 index 0000000..1fb94fd --- /dev/null +++ b/backend/src/reviews/review.dto.ts @@ -0,0 +1,50 @@ +import type { ReviewResultStatus, ReviewType } from './review.types'; + +export class RunProjectTextReviewDto { + review_type?: ReviewType; + content?: string; + target_type?: string; + target_id?: string; +} + +export class RunAssetReviewDto { + review_type?: ReviewType; + content_excerpt?: string; +} + +export class ListReviewsQueryDto { + project_id?: string; + result_status?: ReviewResultStatus; + review_type?: ReviewType; + target_type?: string; + limit?: string; +} + +export class AdminUpdateReviewDto { + result_status?: ReviewResultStatus; + risk_level?: string | null; + issue_text?: string | null; + suggestion_text?: string | null; +} + +export class AuthorizeShowcaseDto { + title?: string; + cover_asset_id?: string | null; + video_asset_id?: string | null; +} + +export class AdminListShowcasesQueryDto { + project_id?: string; + authorization_status?: string; + visibility?: string; + limit?: string; +} + +export class AdminUpdateShowcaseDto { + title?: string; + cover_asset_id?: string | null; + video_asset_id?: string | null; + authorization_status?: string; + visibility?: string; + sort_order?: number; +} diff --git a/backend/src/reviews/review.types.ts b/backend/src/reviews/review.types.ts new file mode 100644 index 0000000..06a1713 --- /dev/null +++ b/backend/src/reviews/review.types.ts @@ -0,0 +1,83 @@ +import type { CaseShowcase, ContentReview } from '@prisma/client'; + +export const REVIEW_RESULT_STATUSES = [ + 'pending', + 'passed', + 'rejected', + 'needs_revision', + 'blocked', + 'manual_required' +] as const; + +export const REVIEW_TYPES = ['text', 'image', 'video', 'audio', 'subtitle', 'case_showcase'] as const; + +export type ReviewResultStatus = (typeof REVIEW_RESULT_STATUSES)[number]; +export type ReviewType = (typeof REVIEW_TYPES)[number]; + +export interface SafeContentReview { + id: string; + project_id: string | null; + user_id: string | null; + target_type: string; + target_id: string | null; + review_type: string; + result_status: string; + risk_level: string | null; + issue_text: string | null; + suggestion_text: string | null; + reviewer_id: string | null; + reviewed_at: string | null; + created_at: string; + updated_at: string; +} + +export interface SafeCaseShowcase { + id: string; + project_id: string; + user_id: string | null; + title: string; + cover_asset_id: string | null; + video_asset_id: string | null; + authorization_status: string; + visibility: string; + sort_order: number; + published_at: string | null; + created_at: string; + updated_at: string; +} + +export function toSafeContentReview(review: ContentReview): SafeContentReview { + return { + id: review.id.toString(), + project_id: review.project_id?.toString() ?? null, + user_id: review.user_id?.toString() ?? null, + target_type: review.target_type, + target_id: review.target_id?.toString() ?? null, + review_type: review.review_type, + result_status: review.result_status, + risk_level: review.risk_level, + issue_text: review.issue_text, + suggestion_text: review.suggestion_text, + reviewer_id: review.reviewer_id?.toString() ?? null, + reviewed_at: review.reviewed_at?.toISOString() ?? null, + created_at: review.created_at.toISOString(), + updated_at: review.updated_at.toISOString() + }; +} + +export function toSafeCaseShowcase(showcase: CaseShowcase): SafeCaseShowcase { + return { + id: showcase.id.toString(), + project_id: showcase.project_id.toString(), + user_id: showcase.user_id?.toString() ?? null, + title: showcase.title, + cover_asset_id: showcase.cover_asset_id?.toString() ?? null, + video_asset_id: showcase.video_asset_id?.toString() ?? null, + authorization_status: showcase.authorization_status, + visibility: showcase.visibility, + sort_order: showcase.sort_order, + published_at: showcase.published_at?.toISOString() ?? null, + created_at: showcase.created_at.toISOString(), + updated_at: showcase.updated_at.toISOString() + }; +} diff --git a/backend/src/reviews/reviews.controller.ts b/backend/src/reviews/reviews.controller.ts new file mode 100644 index 0000000..9770c47 --- /dev/null +++ b/backend/src/reviews/reviews.controller.ts @@ -0,0 +1,102 @@ +import { + Body, + Controller, + Get, + Inject, + Param, + Patch, + Post, + Query, + UseGuards +} from '@nestjs/common'; +import type { AuthRequestUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { + AdminListShowcasesQueryDto, + AdminUpdateReviewDto, + AdminUpdateShowcaseDto, + AuthorizeShowcaseDto, + ListReviewsQueryDto, + RunAssetReviewDto, + RunProjectTextReviewDto +} from './review.dto'; +import { ReviewsService } from './reviews.service'; + +@Controller() +@UseGuards(JwtAuthGuard) +export class ReviewsController { + constructor(@Inject(ReviewsService) private readonly reviewsService: ReviewsService) {} + + @Post('projects/:projectId/reviews/text') + runProjectTextReview( + @CurrentUser() user: AuthRequestUser, + @Param('projectId') projectId: string, + @Body() dto: RunProjectTextReviewDto + ) { + return this.reviewsService.runProjectTextReview(user, projectId, dto); + } + + @Get('projects/:projectId/reviews') + listProjectReviews( + @CurrentUser() user: AuthRequestUser, + @Param('projectId') projectId: string, + @Query() query: ListReviewsQueryDto + ) { + return this.reviewsService.listProjectReviews(user, projectId, query); + } + + @Post('assets/:assetId/review') + runAssetReview( + @CurrentUser() user: AuthRequestUser, + @Param('assetId') assetId: string, + @Body() dto: RunAssetReviewDto + ) { + return this.reviewsService.runAssetReview(user, assetId, dto); + } + + @Post('projects/:projectId/showcase/authorize') + authorizeShowcase( + @CurrentUser() user: AuthRequestUser, + @Param('projectId') projectId: string, + @Body() dto: AuthorizeShowcaseDto + ) { + return this.reviewsService.authorizeShowcase(user, projectId, dto); + } + + @Get('projects/:projectId/showcase') + listProjectShowcases(@CurrentUser() user: AuthRequestUser, @Param('projectId') projectId: string) { + return this.reviewsService.listProjectShowcases(user, projectId); + } + + @Get('admin/content-reviews') + listAdminReviews(@CurrentUser() user: AuthRequestUser, @Query() query: ListReviewsQueryDto) { + return this.reviewsService.listAdminReviews(user, query); + } + + @Patch('admin/content-reviews/:reviewId') + updateReview( + @CurrentUser() user: AuthRequestUser, + @Param('reviewId') reviewId: string, + @Body() dto: AdminUpdateReviewDto + ) { + return this.reviewsService.updateReview(user, reviewId, dto); + } + + @Get('admin/case-showcases') + listAdminShowcases( + @CurrentUser() user: AuthRequestUser, + @Query() query: AdminListShowcasesQueryDto + ) { + return this.reviewsService.listAdminShowcases(user, query); + } + + @Patch('admin/case-showcases/:showcaseId') + updateShowcase( + @CurrentUser() user: AuthRequestUser, + @Param('showcaseId') showcaseId: string, + @Body() dto: AdminUpdateShowcaseDto + ) { + return this.reviewsService.updateShowcase(user, showcaseId, dto); + } +} diff --git a/backend/src/reviews/reviews.module.ts b/backend/src/reviews/reviews.module.ts new file mode 100644 index 0000000..0f863a3 --- /dev/null +++ b/backend/src/reviews/reviews.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { PrismaModule } from '../prisma/prisma.module'; +import { ProvidersModule } from '../providers/providers.module'; +import { ReviewsController } from './reviews.controller'; +import { ReviewsService } from './reviews.service'; + +@Module({ + imports: [AuthModule, PrismaModule, ProvidersModule], + controllers: [ReviewsController], + providers: [ReviewsService], + exports: [ReviewsService] +}) +export class ReviewsModule {} diff --git a/backend/src/reviews/reviews.service.spec.ts b/backend/src/reviews/reviews.service.spec.ts new file mode 100644 index 0000000..f869ee2 --- /dev/null +++ b/backend/src/reviews/reviews.service.spec.ts @@ -0,0 +1,328 @@ +import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; +import type { Asset, CaseShowcase, ContentReview, Project } from '@prisma/client'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AuthRequestUser } from '../auth/auth.types'; +import type { PrismaService } from '../prisma/prisma.service'; +import type { ProvidersService } from '../providers/providers.service'; +import { ReviewsService } from './reviews.service'; + +const user: AuthRequestUser = { + id: '1', + email: 'user@example.com', + role: 'user' +}; + +const otherUser: AuthRequestUser = { + id: '2', + email: 'other@example.com', + role: 'user' +}; + +const admin: AuthRequestUser = { + id: '9', + email: 'admin@example.com', + role: 'admin' +}; + +const now = new Date('2026-05-31T00:00:00.000Z'); + +function createProject(overrides: Partial = {}): Project { + return { + id: 10n, + user_id: 1n, + title: '审核测试项目', + input_mode: 'ai_original', + genre: '都市逆袭', + style_code: 'korean_comic', + output_type: 'short_video', + output_mode: 'image_manga', + visual_mode: 'korean_manga', + video_generation_level: 'standard', + target_episode_count: 3, + episode_duration: 60, + status: 'video_rendered', + copyright_status: 'ai_original', + payment_status: 'paid', + quality_level: 'mvp', + is_long_series: false, + created_at: now, + updated_at: now, + completed_at: null, + ...overrides + }; +} + +function createAsset(overrides: Partial = {}): Asset { + return { + id: 20n, + user_id: 1n, + project_id: 10n, + asset_type: 'video', + file_path: 'local://rendered-videos/test.mp4', + file_url: null, + mime_type: 'video/mp4', + width: 1080, + height: 1920, + duration: null, + size: 1024n, + hash: 'hash-a', + visibility: 'private', + status: 'active', + created_at: now, + ...overrides + }; +} + +function createReview(overrides: Partial = {}): ContentReview { + return { + id: 30n, + project_id: 10n, + user_id: 1n, + target_type: 'project_text', + target_id: 10n, + review_type: 'text', + result_status: 'pending', + risk_level: null, + issue_text: null, + suggestion_text: null, + reviewer_id: null, + reviewed_at: null, + created_at: now, + updated_at: now, + ...overrides + }; +} + +function createShowcase(overrides: Partial = {}): CaseShowcase { + return { + id: 40n, + project_id: 10n, + user_id: 1n, + title: '公开案例', + cover_asset_id: null, + video_asset_id: null, + authorization_status: 'authorized', + visibility: 'private', + sort_order: 0, + published_at: null, + created_at: now, + updated_at: now, + ...overrides + }; +} + +function createProviderResult(result: Record) { + return { + provider: { + id: '1', + provider_type: 'ModerationProvider', + provider_code: 'mock-moderation' + }, + result, + provider_log: { + id: '99', + status: 'success' + }, + fallback_used: false, + attempts: [] + }; +} + +describe('ReviewsService', () => { + let prisma: any; + let providers: any; + let service: ReviewsService; + + beforeEach(() => { + prisma = { + project: { + findUnique: vi.fn().mockResolvedValue(createProject()), + update: vi.fn().mockResolvedValue(createProject({ status: 'manual_required' })) + }, + asset: { + findUnique: vi.fn().mockResolvedValue(createAsset()) + }, + novelSource: { + findMany: vi.fn().mockResolvedValue([]) + }, + novelChapter: { + findMany: vi.fn().mockResolvedValue([]) + }, + storyBible: { + findFirst: vi.fn().mockResolvedValue(null) + }, + character: { + findMany: vi.fn().mockResolvedValue([]) + }, + episode: { + findMany: vi.fn().mockResolvedValue([]) + }, + episodeScript: { + findMany: vi.fn().mockResolvedValue([]) + }, + storyboardShot: { + findMany: vi.fn().mockResolvedValue([]) + }, + contentReview: { + create: vi.fn(async ({ data }: { data: Partial }) => + createReview({ + ...data, + id: data.target_type === 'asset' ? 31n : 30n + }) + ), + findMany: vi.fn().mockResolvedValue([createReview()]), + count: vi.fn().mockResolvedValue(1), + findUnique: vi.fn().mockResolvedValue(createReview()), + update: vi.fn(async ({ data }: { data: Partial }) => + createReview({ + ...data, + reviewer_id: data.reviewer_id ?? 9n + }) + ) + }, + caseShowcase: { + findFirst: vi.fn().mockResolvedValue(null), + create: vi.fn(async ({ data }: { data: Partial }) => + createShowcase(data) + ), + update: vi.fn(async ({ data }: { data: Partial }) => + createShowcase(data) + ), + findMany: vi.fn().mockResolvedValue([createShowcase()]), + count: vi.fn().mockResolvedValue(1), + findUnique: vi.fn().mockResolvedValue(createShowcase()) + }, + user: { + findUnique: vi.fn().mockResolvedValue(null) + } + }; + providers = { + executeProvider: vi.fn().mockResolvedValue( + createProviderResult({ + result_status: 'passed', + risk_level: 'low', + issues: [] + }) + ) + }; + service = new ReviewsService(prisma as PrismaService, providers as ProvidersService); + }); + + it('runs project text review and creates a passed review', async () => { + const result = await service.runProjectTextReview(user, '10', { + content: '健康合规的原创故事内容' + }); + + expect(providers.executeProvider).toHaveBeenCalledWith( + expect.objectContaining({ + provider_type: 'ModerationProvider', + project_id: '10' + }) + ); + expect(prisma.contentReview.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + project_id: 10n, + user_id: 1n, + result_status: 'passed', + risk_level: 'low' + }) + }); + expect(prisma.project.update).not.toHaveBeenCalled(); + expect(result.review.result_status).toBe('passed'); + }); + + it('marks project manual_required when moderation finds risk', async () => { + providers.executeProvider.mockResolvedValueOnce( + createProviderResult({ + result_status: 'manual_required', + risk_level: 'high', + issues: ['mock_sensitive_keyword'] + }) + ); + + const result = await service.runProjectTextReview(user, '10', { + content: '包含违规关键词的测试内容' + }); + + expect(result.review.result_status).toBe('manual_required'); + expect(result.review.issue_text).toBe('mock_sensitive_keyword'); + expect(prisma.project.update).toHaveBeenCalledWith({ + where: { id: 10n }, + data: { status: 'manual_required' } + }); + }); + + it('runs asset review for owned assets', async () => { + const result = await service.runAssetReview(user, '20', { + content_excerpt: '成品视频画面和字幕说明' + }); + + expect(prisma.contentReview.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + target_type: 'asset', + target_id: 20n, + review_type: 'video', + result_status: 'passed' + }) + }); + expect(result.next_step).toBe('asset_approved'); + }); + + it('does not expose private assets owned by another user', async () => { + prisma.asset.findUnique.mockResolvedValueOnce(createAsset({ user_id: 1n })); + + await expect(service.runAssetReview(otherUser, '20', {})).rejects.toBeInstanceOf( + NotFoundException + ); + }); + + it('lets admins update review decisions with reviewer metadata', async () => { + const result = await service.updateReview(admin, '30', { + result_status: 'passed', + suggestion_text: '人工复核通过' + }); + + expect(prisma.contentReview.update).toHaveBeenCalledWith({ + where: { id: 30n }, + data: expect.objectContaining({ + result_status: 'passed', + reviewer_id: 9n, + reviewed_at: expect.any(Date) + }) + }); + expect(result.reviewer_id).toBe('9'); + }); + + it('creates showcase authorization and a pending case review', async () => { + const result = await service.authorizeShowcase(user, '10', { + title: '测试公开案例' + }); + + expect(prisma.caseShowcase.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + authorization_status: 'authorized', + visibility: 'private' + }) + }); + expect(prisma.contentReview.create).toHaveBeenLastCalledWith({ + data: expect.objectContaining({ + target_type: 'case_showcase', + review_type: 'case_showcase', + result_status: 'pending' + }) + }); + expect(result.next_step).toBe('admin_case_review'); + }); + + it('blocks admin review lists for normal users', async () => { + await expect(service.listAdminReviews(user, {})).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('validates unsupported review status', async () => { + await expect( + service.updateReview(admin, '30', { + result_status: 'unknown' as never + }) + ).rejects.toBeInstanceOf(BadRequestException); + }); +}); diff --git a/backend/src/reviews/reviews.service.ts b/backend/src/reviews/reviews.service.ts new file mode 100644 index 0000000..47cc0c9 --- /dev/null +++ b/backend/src/reviews/reviews.service.ts @@ -0,0 +1,732 @@ +import { + BadRequestException, + ForbiddenException, + Inject, + Injectable, + NotFoundException +} from '@nestjs/common'; +import type { Asset, Prisma, Project } from '@prisma/client'; +import type { AuthRequestUser } from '../auth/auth.types'; +import { assertPermission } from '../auth/rbac'; +import { PrismaService } from '../prisma/prisma.service'; +import { ProvidersService } from '../providers/providers.service'; +import { + AdminListShowcasesQueryDto, + AdminUpdateReviewDto, + AdminUpdateShowcaseDto, + AuthorizeShowcaseDto, + ListReviewsQueryDto, + RunAssetReviewDto, + RunProjectTextReviewDto +} from './review.dto'; +import { + REVIEW_RESULT_STATUSES, + REVIEW_TYPES, + toSafeCaseShowcase, + toSafeContentReview, + type ReviewResultStatus, + type ReviewType +} from './review.types'; + +const RISK_LEVELS = ['low', 'medium', 'high', 'critical'] as const; +const SHOWCASE_AUTHORIZATION_STATUSES = ['pending', 'authorized', 'rejected', 'published'] as const; +const SHOWCASE_VISIBILITIES = ['private', 'public', 'hidden'] as const; +const MANUAL_REVIEW_STATUSES = new Set(['manual_required', 'rejected', 'needs_revision', 'blocked']); + +@Injectable() +export class ReviewsService { + constructor( + @Inject(PrismaService) private readonly prisma: PrismaService, + @Inject(ProvidersService) private readonly providers: ProvidersService + ) {} + + async runProjectTextReview( + user: AuthRequestUser, + projectId: string, + dto: RunProjectTextReviewDto + ) { + const project = await this.findProjectForUser(projectId, user); + const reviewType = this.validateReviewType(dto.review_type, 'text'); + const targetType = this.normalizeOptionalText(dto.target_type, 80) ?? 'project_text'; + const targetId = dto.target_id + ? this.parseId(dto.target_id, 'Invalid target_id') + : project.id; + const content = + this.normalizeOptionalText(dto.content, 12000) ?? (await this.buildProjectReviewText(project)); + + if (!content) { + throw new BadRequestException('No project content is available for review'); + } + + const moderation = await this.executeModeration(project.id, targetType, reviewType, content); + const review = await this.prisma.contentReview.create({ + data: { + project_id: project.id, + user_id: BigInt(user.id), + target_type: targetType, + target_id: targetId, + review_type: reviewType, + result_status: moderation.result_status, + risk_level: moderation.risk_level, + issue_text: moderation.issue_text, + suggestion_text: moderation.suggestion_text, + reviewed_at: moderation.result_status === 'passed' ? new Date() : null + } + }); + + await this.markProjectManualIfNeeded(project.id, moderation.result_status); + + return { + review: toSafeContentReview(review), + provider_result: moderation.provider_result, + next_step: moderation.result_status === 'passed' ? 'continue_pipeline' : 'admin_review' + }; + } + + async runAssetReview(user: AuthRequestUser, assetId: string, dto: RunAssetReviewDto) { + const asset = await this.findAssetForUser(assetId, user); + const reviewType = this.validateReviewType(dto.review_type, this.reviewTypeFromAsset(asset)); + const prompt = [ + `asset_type: ${asset.asset_type}`, + `mime_type: ${asset.mime_type ?? '-'}`, + `path: ${asset.file_path}`, + this.normalizeOptionalText(dto.content_excerpt, 4000) ?? '' + ] + .filter(Boolean) + .join('\n'); + const moderation = await this.executeModeration( + asset.project_id, + 'asset', + reviewType, + prompt + ); + const review = await this.prisma.contentReview.create({ + data: { + project_id: asset.project_id, + user_id: asset.user_id, + target_type: 'asset', + target_id: asset.id, + review_type: reviewType, + result_status: moderation.result_status, + risk_level: moderation.risk_level, + issue_text: moderation.issue_text, + suggestion_text: moderation.suggestion_text, + reviewed_at: moderation.result_status === 'passed' ? new Date() : null + } + }); + + if (asset.project_id) { + await this.markProjectManualIfNeeded(asset.project_id, moderation.result_status); + } + + return { + review: toSafeContentReview(review), + provider_result: moderation.provider_result, + next_step: moderation.result_status === 'passed' ? 'asset_approved' : 'admin_review' + }; + } + + async listProjectReviews( + user: AuthRequestUser, + projectId: string, + query: ListReviewsQueryDto + ) { + const project = await this.findProjectForUser(projectId, user); + const where: Prisma.ContentReviewWhereInput = { + project_id: project.id + }; + const limit = this.normalizePositiveInt(query.limit, 'limit', 1, 100, 50); + + if (query.result_status) { + where.result_status = this.validateReviewStatus(query.result_status); + } + if (query.review_type) { + where.review_type = this.validateReviewType(query.review_type); + } + if (query.target_type) { + where.target_type = this.normalizeOptionalText(query.target_type, 80); + } + + const [reviews, total] = await Promise.all([ + this.prisma.contentReview.findMany({ + where, + orderBy: { created_at: 'desc' }, + take: limit + }), + this.prisma.contentReview.count({ where }) + ]); + + return { + reviews: reviews.map(toSafeContentReview), + total, + limit + }; + } + + async authorizeShowcase(user: AuthRequestUser, projectId: string, dto: AuthorizeShowcaseDto) { + const project = await this.findProjectForUser(projectId, user); + const title = this.normalizeOptionalText(dto.title, 255) ?? project.title ?? '未命名案例'; + const coverAssetId = await this.parseOptionalProjectAssetId( + dto.cover_asset_id, + project.id, + 'Invalid cover_asset_id' + ); + const videoAssetId = await this.parseOptionalProjectAssetId( + dto.video_asset_id, + project.id, + 'Invalid video_asset_id' + ); + const existing = await this.prisma.caseShowcase.findFirst({ + where: { + project_id: project.id, + user_id: BigInt(user.id) + }, + orderBy: { created_at: 'desc' } + }); + const showcase = existing + ? await this.prisma.caseShowcase.update({ + where: { id: existing.id }, + data: { + title, + cover_asset_id: coverAssetId, + video_asset_id: videoAssetId, + authorization_status: 'authorized', + visibility: 'private' + } + }) + : await this.prisma.caseShowcase.create({ + data: { + project_id: project.id, + user_id: BigInt(user.id), + title, + cover_asset_id: coverAssetId, + video_asset_id: videoAssetId, + authorization_status: 'authorized', + visibility: 'private' + } + }); + const review = await this.prisma.contentReview.create({ + data: { + project_id: project.id, + user_id: BigInt(user.id), + target_type: 'case_showcase', + target_id: showcase.id, + review_type: 'case_showcase', + result_status: 'pending', + risk_level: 'medium', + issue_text: null, + suggestion_text: '公开案例授权已提交,等待后台发布审核' + } + }); + + return { + showcase: toSafeCaseShowcase(showcase), + review: toSafeContentReview(review), + next_step: 'admin_case_review' + }; + } + + async listProjectShowcases(user: AuthRequestUser, projectId: string) { + const project = await this.findProjectForUser(projectId, user); + const showcases = await this.prisma.caseShowcase.findMany({ + where: { project_id: project.id }, + orderBy: { created_at: 'desc' }, + take: 20 + }); + + return { + showcases: showcases.map(toSafeCaseShowcase), + total: showcases.length + }; + } + + async listAdminReviews(user: AuthRequestUser, query: ListReviewsQueryDto) { + assertPermission(user, 'reviews:read'); + const where: Prisma.ContentReviewWhereInput = {}; + const limit = this.normalizePositiveInt(query.limit, 'limit', 1, 200, 80); + + if (query.project_id) { + where.project_id = this.parseId(query.project_id, 'Invalid project_id'); + } + if (query.result_status) { + where.result_status = this.validateReviewStatus(query.result_status); + } + if (query.review_type) { + where.review_type = this.validateReviewType(query.review_type); + } + if (query.target_type) { + where.target_type = this.normalizeOptionalText(query.target_type, 80); + } + + const [reviews, total] = await Promise.all([ + this.prisma.contentReview.findMany({ + where, + orderBy: { created_at: 'desc' }, + take: limit + }), + this.prisma.contentReview.count({ where }) + ]); + + return { + reviews: reviews.map(toSafeContentReview), + total, + limit + }; + } + + async updateReview(user: AuthRequestUser, reviewId: string, dto: AdminUpdateReviewDto) { + assertPermission(user, 'reviews:write'); + const review = await this.prisma.contentReview.findUnique({ + where: { id: this.parseId(reviewId, 'Invalid review id') } + }); + + if (!review) { + throw new NotFoundException('Content review not found'); + } + + const resultStatus = this.validateReviewStatus(dto.result_status ?? review.result_status); + const riskLevel = + 'risk_level' in dto + ? this.validateOptionalRiskLevel(dto.risk_level) + : review.risk_level; + const updated = await this.prisma.contentReview.update({ + where: { id: review.id }, + data: { + result_status: resultStatus, + risk_level: riskLevel, + issue_text: + 'issue_text' in dto + ? this.normalizeNullableText(dto.issue_text, 1000) + : review.issue_text, + suggestion_text: + 'suggestion_text' in dto + ? this.normalizeNullableText(dto.suggestion_text, 1000) + : review.suggestion_text, + reviewer_id: BigInt(user.id), + reviewed_at: new Date() + } + }); + + if (updated.project_id) { + await this.markProjectManualIfNeeded(updated.project_id, resultStatus); + } + + return toSafeContentReview(updated); + } + + async listAdminShowcases(user: AuthRequestUser, query: AdminListShowcasesQueryDto) { + assertPermission(user, 'reviews:read'); + const where: Prisma.CaseShowcaseWhereInput = {}; + const limit = this.normalizePositiveInt(query.limit, 'limit', 1, 100, 50); + + if (query.project_id) { + where.project_id = this.parseId(query.project_id, 'Invalid project_id'); + } + if (query.authorization_status) { + where.authorization_status = this.validateShowcaseAuthorizationStatus( + query.authorization_status + ); + } + if (query.visibility) { + where.visibility = this.validateShowcaseVisibility(query.visibility); + } + + const [showcases, total] = await Promise.all([ + this.prisma.caseShowcase.findMany({ + where, + orderBy: [{ authorization_status: 'asc' }, { created_at: 'desc' }], + take: limit + }), + this.prisma.caseShowcase.count({ where }) + ]); + const rows = await Promise.all( + showcases.map(async (showcase) => { + const [project, owner] = await Promise.all([ + this.prisma.project.findUnique({ where: { id: showcase.project_id } }), + showcase.user_id ? this.prisma.user.findUnique({ where: { id: showcase.user_id } }) : null + ]); + + return { + showcase: toSafeCaseShowcase(showcase), + project: project + ? { + id: project.id.toString(), + title: project.title, + status: project.status + } + : null, + owner: owner + ? { + id: owner.id.toString(), + email: owner.email, + nickname: owner.nickname + } + : null + }; + }) + ); + + return { + showcases: rows, + total, + limit + }; + } + + async updateShowcase(user: AuthRequestUser, showcaseId: string, dto: AdminUpdateShowcaseDto) { + assertPermission(user, 'reviews:write'); + const showcase = await this.prisma.caseShowcase.findUnique({ + where: { id: this.parseId(showcaseId, 'Invalid showcase id') } + }); + + if (!showcase) { + throw new NotFoundException('Case showcase not found'); + } + + const authorizationStatus = dto.authorization_status + ? this.validateShowcaseAuthorizationStatus(dto.authorization_status) + : showcase.authorization_status; + const visibility = dto.visibility + ? this.validateShowcaseVisibility(dto.visibility) + : showcase.visibility; + const coverAssetId = + 'cover_asset_id' in dto + ? await this.parseOptionalProjectAssetId( + dto.cover_asset_id, + showcase.project_id, + 'Invalid cover_asset_id' + ) + : showcase.cover_asset_id; + const videoAssetId = + 'video_asset_id' in dto + ? await this.parseOptionalProjectAssetId( + dto.video_asset_id, + showcase.project_id, + 'Invalid video_asset_id' + ) + : showcase.video_asset_id; + const updated = await this.prisma.caseShowcase.update({ + where: { id: showcase.id }, + data: { + title: + 'title' in dto + ? this.normalizeOptionalText(dto.title, 255) ?? showcase.title + : showcase.title, + cover_asset_id: coverAssetId, + video_asset_id: videoAssetId, + authorization_status: authorizationStatus, + visibility, + sort_order: + 'sort_order' in dto + ? this.normalizePositiveInt(dto.sort_order, 'sort_order', 0, 999999, showcase.sort_order) + : showcase.sort_order, + published_at: + visibility === 'public' && ['authorized', 'published'].includes(authorizationStatus) + ? new Date() + : visibility === 'public' + ? showcase.published_at + : null + } + }); + + return toSafeCaseShowcase(updated); + } + + private async executeModeration( + projectId: bigint | null, + targetType: string, + reviewType: ReviewType, + content: string + ) { + const providerResult = await this.providers.executeProvider({ + provider_type: 'ModerationProvider', + purpose: `content_review:${reviewType}`, + project_id: projectId?.toString(), + allow_fallback: false, + input_json: { + prompt: content, + target_type: targetType, + review_type: reviewType + } + }); + const output = this.jsonObject(providerResult.result); + const rawStatus = this.stringify(output.result_status); + const resultStatus: ReviewResultStatus = rawStatus === 'passed' ? 'passed' : 'manual_required'; + const riskLevel = + this.validateOptionalRiskLevel(this.stringify(output.risk_level)) ?? + (resultStatus === 'passed' ? 'low' : 'high'); + const issues = Array.isArray(output.issues) + ? output.issues.map((item) => this.stringify(item)).filter(Boolean) + : []; + + return { + result_status: resultStatus, + risk_level: riskLevel, + issue_text: issues.length > 0 ? issues.join(', ') : null, + suggestion_text: + resultStatus === 'passed' + ? 'Mock moderation passed' + : '需要人工复核后继续', + provider_result: output + }; + } + + private async buildProjectReviewText(project: Project) { + const [sources, chapters, storyBible, characters, episodes, scripts, shots] = await Promise.all([ + this.prisma.novelSource.findMany({ + where: { project_id: project.id }, + orderBy: { created_at: 'desc' }, + take: 3 + }), + this.prisma.novelChapter.findMany({ + where: { project_id: project.id }, + orderBy: { chapter_no: 'asc' }, + take: 20 + }), + this.prisma.storyBible.findFirst({ + where: { project_id: project.id }, + orderBy: { version: 'desc' } + }), + this.prisma.character.findMany({ + where: { project_id: project.id, status: { not: 'deleted' } }, + orderBy: [{ importance_level: 'desc' }, { id: 'asc' }], + take: 30 + }), + this.prisma.episode.findMany({ + where: { project_id: project.id }, + orderBy: { episode_no: 'asc' }, + take: 50 + }), + this.prisma.episodeScript.findMany({ + where: { project_id: project.id }, + orderBy: { updated_at: 'desc' }, + take: 20 + }), + this.prisma.storyboardShot.findMany({ + where: { project_id: project.id }, + orderBy: [{ episode_id: 'asc' }, { shot_no: 'asc' }], + take: 80 + }) + ]); + const lines = [ + `项目:${project.title ?? project.id.toString()}`, + `题材:${project.genre ?? '-'}`, + ...sources.map((source) => + `小说源:${source.title ?? '-'} ${source.clean_text ?? source.raw_text ?? ''}` + ), + ...chapters.map((chapter) => + `章节${chapter.chapter_no}:${chapter.title ?? ''} ${chapter.summary ?? chapter.content}` + ), + storyBible + ? `故事圣经:${storyBible.logline ?? ''} ${storyBible.main_plot ?? ''} ${storyBible.taboo_rules ?? ''}` + : '', + ...characters.map((character) => + `角色:${character.name} ${character.role_type} ${character.identity_desc ?? ''} ${character.appearance_desc ?? ''}` + ), + ...episodes.map((episode) => + `分集${episode.episode_no}:${episode.title ?? ''} ${episode.summary ?? ''} ${episode.opening_hook ?? ''} ${episode.ending_hook ?? ''}` + ), + ...scripts.map((script) => `脚本:${script.narration_text ?? script.script_text ?? ''}`), + ...shots.map((shot) => + `分镜${shot.shot_no}:${shot.visual_desc ?? ''} ${shot.dialogue_text ?? ''} ${shot.narration_text ?? ''}` + ) + ]; + + return lines.filter(Boolean).join('\n').slice(0, 12000); + } + + private async findProjectForUser(projectId: string, user: AuthRequestUser) { + const project = await this.prisma.project.findUnique({ + where: { id: this.parseId(projectId, 'Invalid project id') } + }); + + if (!project) { + throw new NotFoundException('Project not found'); + } + if (project.user_id.toString() !== user.id && user.role !== 'admin') { + throw new ForbiddenException('Project is private'); + } + + return project; + } + + private async findAssetForUser(assetId: string, user: AuthRequestUser) { + const asset = await this.prisma.asset.findUnique({ + where: { id: this.parseId(assetId, 'Invalid asset id') } + }); + + if (!asset) { + throw new NotFoundException('Asset not found'); + } + if (asset.user_id?.toString() !== user.id && user.role !== 'admin') { + throw new NotFoundException('Asset not found'); + } + + return asset; + } + + private async parseOptionalProjectAssetId( + value: string | null | undefined, + projectId: bigint, + message: string + ) { + if (value === null || value === undefined || String(value).trim() === '') { + return null; + } + const assetId = this.parseId(value, message); + const asset = await this.prisma.asset.findUnique({ where: { id: assetId } }); + + if (!asset || asset.project_id?.toString() !== projectId.toString()) { + throw new BadRequestException(message); + } + + return asset.id; + } + + private async markProjectManualIfNeeded(projectId: bigint, status: ReviewResultStatus) { + if (!MANUAL_REVIEW_STATUSES.has(status)) { + return; + } + + await this.prisma.project.update({ + where: { id: projectId }, + data: { status: 'manual_required' } + }); + } + + private reviewTypeFromAsset(asset: Asset): ReviewType { + if (asset.asset_type === 'image') return 'image'; + if (asset.asset_type === 'video') return 'video'; + if (asset.asset_type === 'audio') return 'audio'; + if (asset.asset_type === 'subtitle') return 'subtitle'; + return 'text'; + } + + private validateReviewType(value: unknown, fallback?: ReviewType) { + if (value === undefined || value === null || value === '') { + if (fallback) return fallback; + throw new BadRequestException('review_type is required'); + } + if (typeof value !== 'string' || !(REVIEW_TYPES as readonly string[]).includes(value)) { + throw new BadRequestException('review_type is not supported'); + } + + return value as ReviewType; + } + + private validateReviewStatus(value: unknown) { + if ( + typeof value !== 'string' || + !(REVIEW_RESULT_STATUSES as readonly string[]).includes(value) + ) { + throw new BadRequestException('result_status is not supported'); + } + + return value as ReviewResultStatus; + } + + private validateOptionalRiskLevel(value: unknown) { + if (value === null || value === undefined || value === '') { + return null; + } + if (typeof value !== 'string' || !(RISK_LEVELS as readonly string[]).includes(value)) { + throw new BadRequestException('risk_level is not supported'); + } + + return value; + } + + private validateShowcaseAuthorizationStatus(value: unknown) { + if ( + typeof value !== 'string' || + !(SHOWCASE_AUTHORIZATION_STATUSES as readonly string[]).includes(value) + ) { + throw new BadRequestException('authorization_status is not supported'); + } + + return value; + } + + private validateShowcaseVisibility(value: unknown) { + if ( + typeof value !== 'string' || + !(SHOWCASE_VISIBILITIES as readonly string[]).includes(value) + ) { + throw new BadRequestException('visibility is not supported'); + } + + return value; + } + + private normalizePositiveInt( + value: unknown, + field: string, + min: number, + max: number, + fallback: number + ) { + if (value === undefined || value === null || value === '') { + return fallback; + } + const numberValue = Number(value); + + if (!Number.isInteger(numberValue) || numberValue < min || numberValue > max) { + throw new BadRequestException(`${field} must be an integer between ${min} and ${max}`); + } + + return numberValue; + } + + private normalizeOptionalText(value: string | undefined, maxLength: number) { + const normalized = value?.trim(); + + if (!normalized) { + return undefined; + } + if (normalized.length > maxLength) { + throw new BadRequestException(`Text must be at most ${maxLength} characters`); + } + + return normalized; + } + + private normalizeNullableText(value: string | null | undefined, maxLength: number) { + if (value === null || value === undefined) { + return null; + } + + return this.normalizeOptionalText(value, maxLength) ?? null; + } + + private parseId(value: string | bigint, message: string) { + try { + const id = BigInt(value); + + if (id <= 0n) { + throw new Error('ID must be positive'); + } + + return id; + } catch { + throw new BadRequestException(message); + } + } + + private assertAdmin(user: AuthRequestUser) { + if (user.role !== 'admin') { + throw new ForbiddenException('Admin role is required'); + } + } + + private jsonObject(value: unknown) { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + + return {}; + } + + private stringify(value: unknown) { + return typeof value === 'string' ? value.trim() : ''; + } +} diff --git a/backend/src/scripts/script.dto.ts b/backend/src/scripts/script.dto.ts new file mode 100644 index 0000000..b3bb4c7 --- /dev/null +++ b/backend/src/scripts/script.dto.ts @@ -0,0 +1,25 @@ +import type { ScriptStatus, StoryboardStatus } from './script.types'; + +export class UpdateEpisodeScriptDto { + script_text?: string; + narration_text?: string; + dialogue_json?: unknown; + status?: ScriptStatus; +} + +export class UpdateStoryboardShotDto { + shot_no?: number; + scene_name?: string; + location_desc?: string; + characters_json?: unknown; + visual_desc?: string; + action_desc?: string; + dialogue_text?: string; + narration_text?: string; + camera_motion?: string; + effect_type?: string; + duration?: number; + prompt_text?: string; + negative_prompt?: string; + status?: StoryboardStatus; +} diff --git a/backend/src/scripts/script.types.ts b/backend/src/scripts/script.types.ts new file mode 100644 index 0000000..27100de --- /dev/null +++ b/backend/src/scripts/script.types.ts @@ -0,0 +1,81 @@ +import type { EpisodeScript, Prisma, StoryboardShot } from '@prisma/client'; + +export const SCRIPT_STATUSES = ['draft', 'generated', 'edited', 'confirmed', 'superseded'] as const; +export const STORYBOARD_STATUSES = ['draft', 'generated', 'edited', 'confirmed'] as const; + +export type ScriptStatus = (typeof SCRIPT_STATUSES)[number]; +export type StoryboardStatus = (typeof STORYBOARD_STATUSES)[number]; + +export interface SafeEpisodeScript { + id: string; + project_id: string; + episode_id: string; + script_text: string | null; + narration_text: string | null; + dialogue_json: Prisma.JsonValue | null; + version: number; + status: string; + created_at: string; + updated_at: string; +} + +export interface SafeStoryboardShot { + id: string; + project_id: string; + episode_id: string; + shot_no: number; + scene_name: string | null; + location_desc: string | null; + characters_json: Prisma.JsonValue | null; + visual_desc: string | null; + action_desc: string | null; + dialogue_text: string | null; + narration_text: string | null; + camera_motion: string | null; + effect_type: string | null; + duration: number | null; + prompt_text: string | null; + negative_prompt: string | null; + status: string; + created_at: string; + updated_at: string; +} + +export function toSafeEpisodeScript(script: EpisodeScript): SafeEpisodeScript { + return { + id: script.id.toString(), + project_id: script.project_id.toString(), + episode_id: script.episode_id.toString(), + script_text: script.script_text, + narration_text: script.narration_text, + dialogue_json: script.dialogue_json, + version: script.version, + status: script.status, + created_at: script.created_at.toISOString(), + updated_at: script.updated_at.toISOString() + }; +} + +export function toSafeStoryboardShot(shot: StoryboardShot): SafeStoryboardShot { + return { + id: shot.id.toString(), + project_id: shot.project_id.toString(), + episode_id: shot.episode_id.toString(), + shot_no: shot.shot_no, + scene_name: shot.scene_name, + location_desc: shot.location_desc, + characters_json: shot.characters_json, + visual_desc: shot.visual_desc, + action_desc: shot.action_desc, + dialogue_text: shot.dialogue_text, + narration_text: shot.narration_text, + camera_motion: shot.camera_motion, + effect_type: shot.effect_type, + duration: shot.duration ? Number(shot.duration.toString()) : null, + prompt_text: shot.prompt_text, + negative_prompt: shot.negative_prompt, + status: shot.status, + created_at: shot.created_at.toISOString(), + updated_at: shot.updated_at.toISOString() + }; +} diff --git a/backend/src/scripts/scripts.controller.ts b/backend/src/scripts/scripts.controller.ts new file mode 100644 index 0000000..908eabb --- /dev/null +++ b/backend/src/scripts/scripts.controller.ts @@ -0,0 +1,104 @@ +import { + Body, + Controller, + Delete, + Get, + Inject, + Param, + Patch, + Post, + UseGuards +} from '@nestjs/common'; +import { CurrentUser } from '../auth/current-user.decorator'; +import type { AuthRequestUser } from '../auth/auth.types'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { UpdateEpisodeScriptDto, UpdateStoryboardShotDto } from './script.dto'; +import { ScriptsService } from './scripts.service'; + +@Controller() +@UseGuards(JwtAuthGuard) +export class ScriptsController { + constructor(@Inject(ScriptsService) private readonly scriptsService: ScriptsService) {} + + @Post('episodes/:episodeId/script/generate') + generateScript( + @CurrentUser() user: AuthRequestUser, + @Param('episodeId') episodeId: string + ) { + return this.scriptsService.generateScript(user, episodeId); + } + + @Get('episodes/:episodeId/script') + getScript( + @CurrentUser() user: AuthRequestUser, + @Param('episodeId') episodeId: string + ) { + return this.scriptsService.getScript(user, episodeId); + } + + @Patch('episodes/:episodeId/script') + updateScript( + @CurrentUser() user: AuthRequestUser, + @Param('episodeId') episodeId: string, + @Body() dto: UpdateEpisodeScriptDto + ) { + return this.scriptsService.updateScript(user, episodeId, dto); + } + + @Post('episodes/:episodeId/script/confirm') + confirmScript( + @CurrentUser() user: AuthRequestUser, + @Param('episodeId') episodeId: string + ) { + return this.scriptsService.confirmScript(user, episodeId); + } + + @Post('episodes/:episodeId/storyboard/generate') + generateStoryboard( + @CurrentUser() user: AuthRequestUser, + @Param('episodeId') episodeId: string + ) { + return this.scriptsService.generateStoryboard(user, episodeId); + } + + @Get('episodes/:episodeId/storyboard') + getStoryboard( + @CurrentUser() user: AuthRequestUser, + @Param('episodeId') episodeId: string + ) { + return this.scriptsService.getStoryboard(user, episodeId); + } + + @Patch('storyboard-shots/:shotId') + updateStoryboardShot( + @CurrentUser() user: AuthRequestUser, + @Param('shotId') shotId: string, + @Body() dto: UpdateStoryboardShotDto + ) { + return this.scriptsService.updateStoryboardShot(user, shotId, dto); + } + + @Delete('storyboard-shots/:shotId') + deleteStoryboardShot( + @CurrentUser() user: AuthRequestUser, + @Param('shotId') shotId: string + ) { + return this.scriptsService.deleteStoryboardShot(user, shotId); + } + + @Post('episodes/:episodeId/storyboard/confirm') + confirmStoryboard( + @CurrentUser() user: AuthRequestUser, + @Param('episodeId') episodeId: string + ) { + return this.scriptsService.confirmStoryboard(user, episodeId); + } + + @Post('storyboard-shots/:shotId/regenerate-prompt') + regenerateShotPrompt( + @CurrentUser() user: AuthRequestUser, + @Param('shotId') shotId: string + ) { + return this.scriptsService.regenerateShotPrompt(user, shotId); + } +} diff --git a/backend/src/scripts/scripts.module.ts b/backend/src/scripts/scripts.module.ts new file mode 100644 index 0000000..9f1e586 --- /dev/null +++ b/backend/src/scripts/scripts.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { PrismaModule } from '../prisma/prisma.module'; +import { ScriptsController } from './scripts.controller'; +import { ScriptsService } from './scripts.service'; + +@Module({ + imports: [AuthModule, PrismaModule], + controllers: [ScriptsController], + providers: [ScriptsService], + exports: [ScriptsService] +}) +export class ScriptsModule {} diff --git a/backend/src/scripts/scripts.service.spec.ts b/backend/src/scripts/scripts.service.spec.ts new file mode 100644 index 0000000..011eabe --- /dev/null +++ b/backend/src/scripts/scripts.service.spec.ts @@ -0,0 +1,474 @@ +import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { + Character, + Episode, + EpisodeScript, + PlotMemory, + Project, + StoryBible, + StoryboardShot +} from '@prisma/client'; +import { Prisma } from '@prisma/client'; +import type { AuthRequestUser } from '../auth/auth.types'; +import type { PrismaService } from '../prisma/prisma.service'; +import { ScriptsService } from './scripts.service'; + +const user: AuthRequestUser = { + id: '1', + email: 'user@example.com', + role: 'user' +}; + +const now = new Date('2026-05-31T00:00:00.000Z'); + +function createProject(overrides: Partial = {}): Project { + return { + id: 10n, + user_id: 1n, + title: '重生归来,我只搞事业', + input_mode: 'ai_original', + genre: 'urban_rebirth', + style_code: 'korean_comic', + output_type: 'short_video', + output_mode: 'image_manga', + visual_mode: 'korean_manga', + video_generation_level: 'standard', + target_episode_count: 3, + episode_duration: 60, + status: 'episode_confirmed', + copyright_status: 'ai_original', + payment_status: 'unpaid', + quality_level: 'mvp', + is_long_series: false, + created_at: now, + updated_at: now, + completed_at: null, + ...overrides + }; +} + +function createEpisode(overrides: Partial = {}): Episode { + return { + id: 20n, + project_id: 10n, + episode_no: 1, + source_chapter_ids: ['30'], + title: '第1集 暴雨开局', + summary: '林晚用录音证据逼近真相。', + opening_hook: '林晚睁眼时,会议室大屏已经开始播放她的录音。', + middle_conflict: '周启试图转移责任。', + ending_hook: '幕后投资人的车停在楼下。', + target_duration: 60, + status: 'confirmed', + created_at: now, + updated_at: now, + ...overrides + }; +} + +function createStoryBible(overrides: Partial = {}): StoryBible { + return { + id: 40n, + project_id: 10n, + title: '重生归来,我只搞事业', + logline: '林晚重回命运转折点,用证据夺回项目。', + main_plot: '林晚夺回原创项目控制权,周启持续制造阻碍。', + core_conflict: '林晚必须在资本压力中守住原创项目。', + selling_points: '重生归来\n证据反杀', + tone: '克制、锋利、连续反转', + world_summary: '现代都市内容公司', + ending_direction: '幕后真相继续推进。', + taboo_rules: '不得改变主角姓名。', + version: 1, + status: 'confirmed', + created_at: now, + updated_at: now, + ...overrides + }; +} + +function createCharacter(overrides: Partial = {}): Character { + return { + id: 50n, + project_id: 10n, + global_character_id: null, + name: '林晚', + alias_names: [], + role_type: 'protagonist', + gender_label: '女', + age_group: '青年', + identity_desc: '故事主角', + appearance_desc: '眼神坚定', + face_desc: '精致鹅蛋脸', + hair_desc: '深色中长发', + eye_desc: '深色眼睛', + body_desc: '身形修长', + costume_rules: '现代都市通勤装', + special_props: '手机、录音证据', + personality_desc: '冷静克制', + speech_style: '短句明确', + relationship_desc: '与周启围绕项目控制权对抗', + character_arc: '从被动到主动', + negative_rules: '不得改名', + anchor_asset_id: null, + wardrobe_variant: null, + voice_provider_code: null, + voice_model: null, + voice_id: null, + voice_style: null, + performance_style: null, + importance_level: 100, + status: 'locked', + created_at: now, + updated_at: now, + ...overrides + }; +} + +function createPlotMemory(overrides: Partial = {}): PlotMemory { + return { + id: 60n, + project_id: 10n, + episode_id: null, + chapter_id: 30n, + memory_type: 'foreshadowing', + content: '录音证据会在后续揭开幕后真相。', + importance_level: 90, + status: 'active', + created_at: now, + ...overrides + }; +} + +function createScript(overrides: Partial = {}): EpisodeScript { + return { + id: 70n, + project_id: 10n, + episode_id: 20n, + script_text: '【第1集】林晚在会议室反击。', + narration_text: '林晚压住情绪,拿出录音证据。', + dialogue_json: [ + { speaker: '林晚', line: '这一回,我不会再退。' }, + { speaker: '周启', line: '你以为这样就够了吗?' } + ], + version: 1, + status: 'generated', + created_at: now, + updated_at: now, + ...overrides + }; +} + +function createShot(overrides: Partial = {}): StoryboardShot { + return { + id: 80n, + project_id: 10n, + episode_id: 20n, + shot_no: 1, + scene_name: '开局压迫', + location_desc: '会议室,竖屏构图', + characters_json: [{ id: '50', name: '林晚' }], + visual_desc: '林晚处于画面中心,眼神冷静。', + action_desc: '林晚抬眼看向镜头。', + dialogue_text: '这一回,我不会再退。', + narration_text: '会议室大屏开始播放录音。', + camera_motion: '近景缓慢推进', + effect_type: 'subtle_zoom', + duration: new Prisma.Decimal(4), + scene_type: null, + importance_score: null, + emotion_score: null, + action_score: null, + route_tier: null, + prompt_text: '高质量韩漫风,林晚会议室反击。', + negative_prompt: '低清晰度,多人混脸。', + live_action_desc: null, + actor_action: null, + camera_instruction: null, + performance_instruction: null, + video_prompt: null, + keyframe_asset_id: null, + video_clip_asset_id: null, + video_status: null, + status: 'generated', + created_at: now, + updated_at: now, + ...overrides + }; +} + +function createCreativePattern(overrides: Record = {}) { + return { + id: 100n, + source_case_id: null, + pattern_type: 'opening_hook', + title: '退婚开场钩子', + genre: 'urban_rebirth', + language: 'zh-CN', + description: '前 8 秒建立关系破裂和证据反击。', + structure_json: {}, + prompt_template: '写一个退婚现场开场钩子。', + negative_prompt: '拖慢铺垫', + tags_json: ['退婚', '打脸'], + usage_count: 0, + effectiveness_score: null, + status: 'active', + created_by_user_id: 1n, + created_at: now, + updated_at: now, + ...overrides + }; +} + +function createProjectCreativePattern(overrides: Record = {}) { + return { + id: 101n, + project_id: 10n, + creative_pattern_id: 100n, + source: 'user_selected', + snapshot_json: {}, + sort_order: 1, + created_at: now, + ...overrides + }; +} + +describe('ScriptsService', () => { + let prisma: any; + let tx: any; + let service: ScriptsService; + + beforeEach(() => { + tx = { + episodeScript: { + create: vi.fn().mockResolvedValue(createScript()), + updateMany: vi.fn().mockResolvedValue({ count: 0 }), + update: vi.fn().mockResolvedValue(createScript({ status: 'confirmed' })) + }, + storyboardShot: { + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + createMany: vi.fn().mockResolvedValue({ count: 10 }), + findMany: vi.fn().mockResolvedValue([ + createShot(), + createShot({ id: 81n, shot_no: 2, scene_name: '证据出现' }) + ]), + updateMany: vi.fn().mockResolvedValue({ count: 2 }) + }, + project: { + update: vi.fn().mockResolvedValue(createProject()) + } + }; + prisma = { + project: { + findUnique: vi.fn().mockResolvedValue(createProject()), + update: vi.fn().mockResolvedValue(createProject()) + }, + episode: { + findUnique: vi.fn().mockResolvedValue(createEpisode()) + }, + storyBible: { + findFirst: vi.fn().mockResolvedValue(createStoryBible()) + }, + character: { + findMany: vi.fn().mockResolvedValue([ + createCharacter(), + createCharacter({ + id: 51n, + name: '周启', + role_type: 'antagonist', + importance_level: 80 + }) + ]) + }, + plotMemory: { + findMany: vi.fn().mockResolvedValue([createPlotMemory()]) + }, + projectCreativePattern: { + findMany: vi.fn().mockResolvedValue([]) + }, + creativePattern: { + findMany: vi.fn().mockResolvedValue([]) + }, + episodeScript: { + findFirst: vi.fn().mockResolvedValue(null), + findMany: vi.fn().mockResolvedValue([createScript()]), + update: vi.fn().mockResolvedValue(createScript({ status: 'edited' })), + updateMany: vi.fn() + }, + storyboardShot: { + count: vi.fn().mockResolvedValue(0), + findMany: vi.fn().mockResolvedValue([createShot()]), + findUnique: vi.fn().mockResolvedValue(createShot()), + findFirst: vi.fn().mockResolvedValue(null), + update: vi.fn().mockResolvedValue(createShot({ status: 'edited' })), + delete: vi.fn().mockResolvedValue(createShot()) + }, + $transaction: vi.fn(async (callback: (transaction: typeof tx) => unknown) => callback(tx)) + }; + service = new ScriptsService(prisma as PrismaService); + }); + + it('generates a script for a confirmed episode', async () => { + const result = await service.generateScript(user, '20'); + + expect(prisma.project.update).toHaveBeenCalledWith({ + where: { id: 10n }, + data: { status: 'script_generating' } + }); + expect(tx.episodeScript.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + project_id: 10n, + episode_id: 20n, + version: 1, + status: 'generated' + }) + }); + expect(tx.project.update).toHaveBeenCalledWith({ + where: { id: 10n }, + data: { status: 'waiting_script_confirm' } + }); + expect(result.next_step).toBe('script_confirm'); + }); + + it('injects selected creative patterns into script and storyboard prompts', async () => { + prisma.projectCreativePattern.findMany.mockResolvedValue([ + createProjectCreativePattern(), + createProjectCreativePattern({ id: 103n, creative_pattern_id: 102n, sort_order: 2 }) + ]); + prisma.creativePattern.findMany.mockResolvedValue([ + createCreativePattern(), + createCreativePattern({ + id: 102n, + pattern_type: 'visual_prompt', + title: '会议室权力构图', + prompt_template: '竖版会议室强对峙,证据特写后切人物反应。', + negative_prompt: '站桩闲聊' + }) + ]); + prisma.episodeScript.findFirst.mockResolvedValueOnce(null).mockResolvedValueOnce(createScript({ status: 'confirmed' })); + + await service.generateScript(user, '20'); + await service.generateStoryboard(user, '20'); + + expect(tx.episodeScript.create.mock.calls[0][0].data.script_text).toContain('【题材套路库】'); + expect(tx.episodeScript.create.mock.calls[0][0].data.script_text).toContain('退婚开场钩子'); + expect(tx.storyboardShot.createMany.mock.calls[0][0].data[0].prompt_text).toContain('题材套路/视觉Prompt参考'); + expect(tx.storyboardShot.createMany.mock.calls[0][0].data[0].negative_prompt).toContain('站桩闲聊'); + }); + + it('requires confirmed episode before script generation', async () => { + prisma.episode.findUnique.mockResolvedValue(createEpisode({ status: 'generated' })); + + await expect(service.generateScript(user, '20')).rejects.toBeInstanceOf(BadRequestException); + }); + + it('updates and confirms a script', async () => { + prisma.episodeScript.findFirst.mockResolvedValue(createScript()); + + const updated = await service.updateScript(user, '20', { + narration_text: '新的旁白。' + }); + const confirmed = await service.confirmScript(user, '20'); + + expect(prisma.episodeScript.update).toHaveBeenCalledWith({ + where: { id: 70n }, + data: expect.objectContaining({ + narration_text: '新的旁白。', + status: 'edited' + }) + }); + expect(tx.episodeScript.update).toHaveBeenCalledWith({ + where: { id: 70n }, + data: { status: 'confirmed' } + }); + expect(tx.project.update).toHaveBeenCalledWith({ + where: { id: 10n }, + data: { status: 'script_confirmed' } + }); + expect(updated.status).toBe('edited'); + expect(confirmed.next_step).toBe('storyboard_generate'); + }); + + it('generates storyboard shots from a confirmed script', async () => { + prisma.episodeScript.findFirst.mockResolvedValue(createScript({ status: 'confirmed' })); + + const result = await service.generateStoryboard(user, '20'); + + expect(prisma.project.update).toHaveBeenCalledWith({ + where: { id: 10n }, + data: { status: 'storyboard_generating' } + }); + expect(tx.storyboardShot.createMany.mock.calls[0][0].data).toHaveLength(10); + expect(tx.storyboardShot.createMany.mock.calls[0][0].data[0]).toEqual( + expect.objectContaining({ + project_id: 10n, + episode_id: 20n, + shot_no: 1, + status: 'generated' + }) + ); + expect(result.next_step).toBe('storyboard_confirm'); + }); + + it('updates, regenerates prompt, and deletes an unconfirmed shot', async () => { + const updated = await service.updateStoryboardShot(user, '80', { + visual_desc: '林晚站在会议桌前,表情更坚定。', + duration: 5 + }); + const regenerated = await service.regenerateShotPrompt(user, '80'); + const deleted = await service.deleteStoryboardShot(user, '80'); + + expect(prisma.storyboardShot.update).toHaveBeenCalledWith({ + where: { id: 80n }, + data: expect.objectContaining({ + visual_desc: '林晚站在会议桌前,表情更坚定。', + duration: 5, + status: 'edited' + }) + }); + expect(regenerated.prompt_text).toContain('高质量韩漫风'); + expect(deleted.id).toBe('80'); + expect(updated.status).toBe('edited'); + }); + + it('confirms storyboard shots', async () => { + prisma.storyboardShot.findMany.mockResolvedValue([ + createShot(), + createShot({ id: 81n, shot_no: 2 }) + ]); + + const result = await service.confirmStoryboard(user, '20'); + + expect(tx.storyboardShot.updateMany).toHaveBeenCalledWith({ + where: { + episode_id: 20n, + status: { in: ['draft', 'generated', 'edited'] } + }, + data: { status: 'confirmed' } + }); + expect(tx.project.update).toHaveBeenCalledWith({ + where: { id: 10n }, + data: { status: 'storyboard_confirmed' } + }); + expect(result.next_step).toBe('image_generation'); + }); + + it('blocks editing confirmed scripts and shots', async () => { + prisma.episodeScript.findFirst.mockResolvedValue(createScript({ status: 'confirmed' })); + prisma.storyboardShot.findUnique.mockResolvedValue(createShot({ status: 'confirmed' })); + + await expect(service.updateScript(user, '20', { script_text: '改不了' })).rejects.toBeInstanceOf( + BadRequestException + ); + await expect( + service.updateStoryboardShot(user, '80', { visual_desc: '改不了' }) + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('rejects access to another user project', async () => { + prisma.project.findUnique.mockResolvedValue(createProject({ user_id: 2n })); + + await expect(service.getScript(user, '20')).rejects.toBeInstanceOf(ForbiddenException); + }); +}); diff --git a/backend/src/scripts/scripts.service.ts b/backend/src/scripts/scripts.service.ts new file mode 100644 index 0000000..60ab41b --- /dev/null +++ b/backend/src/scripts/scripts.service.ts @@ -0,0 +1,1009 @@ +import { + BadRequestException, + ForbiddenException, + Inject, + Injectable, + NotFoundException +} from '@nestjs/common'; +import type { + Character, + CreativePattern, + Episode, + EpisodeScript, + PlotMemory, + Prisma, + Project, + StoryBible, + StoryboardShot +} from '@prisma/client'; +import type { AuthRequestUser } from '../auth/auth.types'; +import { PrismaService } from '../prisma/prisma.service'; +import { UpdateEpisodeScriptDto, UpdateStoryboardShotDto } from './script.dto'; +import { + SCRIPT_STATUSES, + STORYBOARD_STATUSES, + toSafeEpisodeScript, + toSafeStoryboardShot, + type ScriptStatus, + type StoryboardStatus +} from './script.types'; + +const MIN_SHOT_DURATION = 2; +const MAX_SHOT_DURATION = 5; +const DEFAULT_SHOT_COUNT = 10; + +interface ScriptContext { + episode: Episode; + project: Project; + storyBible: StoryBible; + characters: Character[]; + plotMemories: PlotMemory[]; + creativePatterns: CreativePattern[]; +} + +interface ScriptDraft { + script_text: string; + narration_text: string; + dialogue_json: Prisma.InputJsonValue; +} + +interface StoryboardDraft { + shot_no: number; + scene_name: string; + location_desc: string; + characters_json: Prisma.InputJsonValue; + visual_desc: string; + action_desc: string; + dialogue_text: string | null; + narration_text: string | null; + camera_motion: string; + effect_type: string; + duration: number; + prompt_text: string; + negative_prompt: string; + status: StoryboardStatus; +} + +@Injectable() +export class ScriptsService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async generateScript(user: AuthRequestUser, episodeId: string) { + const context = await this.loadScriptContext(episodeId, user); + + if (context.episode.status !== 'confirmed') { + throw new BadRequestException('Confirmed episode is required before script generation'); + } + + const nextVersion = await this.nextScriptVersion(context.episode.id); + const draft = this.buildScriptDraft(context); + + await this.prisma.project.update({ + where: { id: context.project.id }, + data: { status: 'script_generating' } + }); + + const script = await this.prisma.$transaction(async (tx) => { + const created = await tx.episodeScript.create({ + data: { + project_id: context.project.id, + episode_id: context.episode.id, + ...draft, + version: nextVersion, + status: 'generated' + } + }); + await tx.project.update({ + where: { id: context.project.id }, + data: { status: 'waiting_script_confirm' } + }); + return created; + }); + + return { + script: toSafeEpisodeScript(script), + next_step: 'script_confirm' + }; + } + + async getScript(user: AuthRequestUser, episodeId: string) { + const { episode } = await this.loadEpisodeForUser(episodeId, user); + const [latest, versions] = await Promise.all([ + this.findLatestScript(episode.id), + this.prisma.episodeScript.findMany({ + where: { episode_id: episode.id }, + orderBy: { version: 'desc' } + }) + ]); + + return { + script: latest ? toSafeEpisodeScript(latest) : null, + versions: versions.map((script) => ({ + id: script.id.toString(), + version: script.version, + status: script.status, + updated_at: script.updated_at.toISOString() + })) + }; + } + + async updateScript(user: AuthRequestUser, episodeId: string, dto: UpdateEpisodeScriptDto) { + const { episode, project } = await this.loadEpisodeForUser(episodeId, user); + const script = await this.findLatestScript(episode.id); + + if (!script) { + throw new NotFoundException('Episode script not found'); + } + + if (script.status === 'confirmed') { + throw new BadRequestException('Confirmed scripts cannot be edited'); + } + + const data = this.createScriptUpdateData(dto); + + if (Object.keys(data).length === 0) { + throw new BadRequestException('No script fields to update'); + } + + if (data.status !== 'confirmed') { + data.status = data.status ?? 'edited'; + } + + const updated = await this.prisma.episodeScript.update({ + where: { id: script.id }, + data + }); + + await this.prisma.project.update({ + where: { id: project.id }, + data: { status: 'waiting_script_confirm' } + }); + + return toSafeEpisodeScript(updated); + } + + async confirmScript(user: AuthRequestUser, episodeId: string) { + const { episode, project } = await this.loadEpisodeForUser(episodeId, user); + const script = await this.findLatestScript(episode.id); + + if (!script) { + throw new NotFoundException('Episode script not found'); + } + + this.assertScriptReady(script); + + const confirmed = await this.prisma.$transaction(async (tx) => { + await tx.episodeScript.updateMany({ + where: { + episode_id: episode.id, + status: 'confirmed', + id: { not: script.id } + }, + data: { status: 'superseded' } + }); + const updated = await tx.episodeScript.update({ + where: { id: script.id }, + data: { status: 'confirmed' } + }); + await tx.project.update({ + where: { id: project.id }, + data: { status: 'script_confirmed' } + }); + return updated; + }); + + return { + script: toSafeEpisodeScript(confirmed), + next_step: 'storyboard_generate' + }; + } + + async generateStoryboard(user: AuthRequestUser, episodeId: string) { + const { episode, project } = await this.loadEpisodeForUser(episodeId, user); + const script = await this.findConfirmedScript(episode.id); + + if (!script) { + throw new BadRequestException('Confirmed episode script is required before storyboard generation'); + } + + const confirmedShotCount = await this.prisma.storyboardShot.count({ + where: { + episode_id: episode.id, + status: 'confirmed' + } + }); + + if (confirmedShotCount > 0) { + throw new BadRequestException('Confirmed storyboard cannot be regenerated'); + } + + const characters = await this.prisma.character.findMany({ + where: { + project_id: project.id, + status: 'locked' + }, + orderBy: [{ importance_level: 'desc' }, { id: 'asc' }] + }); + const creativePatterns = await this.loadProjectCreativePatterns(project.id); + + if (characters.length === 0) { + throw new BadRequestException('Locked characters are required before storyboard generation'); + } + + const drafts = this.buildStoryboardDrafts(project, episode, script, characters, creativePatterns); + + await this.prisma.project.update({ + where: { id: project.id }, + data: { status: 'storyboard_generating' } + }); + + const shots = await this.prisma.$transaction(async (tx) => { + await tx.storyboardShot.deleteMany({ + where: { episode_id: episode.id } + }); + await tx.storyboardShot.createMany({ + data: drafts.map((draft) => ({ + project_id: project.id, + episode_id: episode.id, + ...draft + })) + }); + const saved = await tx.storyboardShot.findMany({ + where: { episode_id: episode.id }, + orderBy: { shot_no: 'asc' } + }); + await tx.project.update({ + where: { id: project.id }, + data: { status: 'waiting_storyboard_confirm' } + }); + return saved; + }); + + return { + shots: shots.map(toSafeStoryboardShot), + next_step: 'storyboard_confirm' + }; + } + + async getStoryboard(user: AuthRequestUser, episodeId: string) { + const { episode } = await this.loadEpisodeForUser(episodeId, user); + const shots = await this.prisma.storyboardShot.findMany({ + where: { episode_id: episode.id }, + orderBy: { shot_no: 'asc' } + }); + + return shots.map(toSafeStoryboardShot); + } + + async updateStoryboardShot( + user: AuthRequestUser, + shotId: string, + dto: UpdateStoryboardShotDto + ) { + const shot = await this.findShotForUser(shotId, user); + + if (shot.status === 'confirmed') { + throw new BadRequestException('Confirmed storyboard shots cannot be edited'); + } + + const data = await this.createShotUpdateData(shot, dto); + + if (Object.keys(data).length === 0) { + throw new BadRequestException('No storyboard shot fields to update'); + } + + if (data.status !== 'confirmed') { + data.status = data.status ?? 'edited'; + } + + const updated = await this.prisma.storyboardShot.update({ + where: { id: shot.id }, + data + }); + + await this.prisma.project.update({ + where: { id: shot.project_id }, + data: { status: 'waiting_storyboard_confirm' } + }); + + return toSafeStoryboardShot(updated); + } + + async deleteStoryboardShot(user: AuthRequestUser, shotId: string) { + const shot = await this.findShotForUser(shotId, user); + + if (shot.status === 'confirmed') { + throw new BadRequestException('Confirmed storyboard shots cannot be deleted'); + } + + const deleted = await this.prisma.storyboardShot.delete({ + where: { id: shot.id } + }); + + await this.prisma.project.update({ + where: { id: shot.project_id }, + data: { status: 'waiting_storyboard_confirm' } + }); + + return toSafeStoryboardShot(deleted); + } + + async regenerateShotPrompt(user: AuthRequestUser, shotId: string) { + const shot = await this.findShotForUser(shotId, user); + + if (shot.status === 'confirmed') { + throw new BadRequestException('Confirmed storyboard shots cannot regenerate prompt'); + } + + const characters = await this.prisma.character.findMany({ + where: { + project_id: shot.project_id, + status: 'locked' + }, + orderBy: [{ importance_level: 'desc' }, { id: 'asc' }] + }); + const creativePatterns = await this.loadProjectCreativePatterns(shot.project_id); + const promptContext = this.createStoryboardPatternPromptContext(creativePatterns); + const updated = await this.prisma.storyboardShot.update({ + where: { id: shot.id }, + data: { + prompt_text: this.buildPromptText(shot, characters, promptContext.promptSuffix), + negative_prompt: this.buildNegativePrompt(characters, promptContext.negativeSuffix), + status: 'edited' + } + }); + + return toSafeStoryboardShot(updated); + } + + async confirmStoryboard(user: AuthRequestUser, episodeId: string) { + const { episode, project } = await this.loadEpisodeForUser(episodeId, user); + const shots = await this.prisma.storyboardShot.findMany({ + where: { episode_id: episode.id }, + orderBy: { shot_no: 'asc' } + }); + + this.assertStoryboardReady(shots); + + const confirmed = await this.prisma.$transaction(async (tx) => { + await tx.storyboardShot.updateMany({ + where: { + episode_id: episode.id, + status: { in: ['draft', 'generated', 'edited'] } + }, + data: { status: 'confirmed' } + }); + const saved = await tx.storyboardShot.findMany({ + where: { episode_id: episode.id }, + orderBy: { shot_no: 'asc' } + }); + await tx.project.update({ + where: { id: project.id }, + data: { status: 'storyboard_confirmed' } + }); + return saved; + }); + + return { + shots: confirmed.map(toSafeStoryboardShot), + next_step: 'image_generation' + }; + } + + private async loadScriptContext(episodeId: string, user: AuthRequestUser): Promise { + const { episode, project } = await this.loadEpisodeForUser(episodeId, user); + const [storyBible, characters, plotMemories, creativePatterns] = await Promise.all([ + this.prisma.storyBible.findFirst({ + where: { + project_id: project.id, + status: 'confirmed' + }, + orderBy: { version: 'desc' } + }), + this.prisma.character.findMany({ + where: { + project_id: project.id, + status: 'locked' + }, + orderBy: [{ importance_level: 'desc' }, { id: 'asc' }] + }), + this.prisma.plotMemory.findMany({ + where: { + project_id: project.id, + status: 'active' + }, + orderBy: [{ importance_level: 'desc' }, { id: 'asc' }], + take: 20 + }), + this.loadProjectCreativePatterns(project.id) + ]); + + if (!storyBible) { + throw new BadRequestException('Confirmed story bible is required before script generation'); + } + if (characters.length === 0) { + throw new BadRequestException('Locked characters are required before script generation'); + } + + return { + episode, + project, + storyBible, + characters, + plotMemories, + creativePatterns + }; + } + + private async loadEpisodeForUser(episodeId: string, user: AuthRequestUser) { + const episode = await this.prisma.episode.findUnique({ + where: { id: this.parseId(episodeId, 'Invalid episode id') } + }); + + if (!episode) { + throw new NotFoundException('Episode not found'); + } + + const project = await this.prisma.project.findUnique({ + where: { id: episode.project_id } + }); + + if (!project) { + throw new NotFoundException('Project not found'); + } + + if (project.user_id.toString() !== user.id && user.role !== 'admin') { + throw new ForbiddenException('Project is private'); + } + + return { episode, project }; + } + + private buildScriptDraft(context: ScriptContext): ScriptDraft { + const protagonist = + context.characters.find((character) => ['protagonist', 'lead'].includes(character.role_type)) ?? + context.characters[0]; + const antagonist = context.characters.find((character) => character.role_type === 'antagonist'); + const helper = context.characters.find((character) => character.role_type === 'supporting'); + const foreshadowing = context.plotMemories.find((memory) => memory.memory_type === 'foreshadowing'); + const conflict = context.episode.middle_conflict || context.storyBible.core_conflict || '核心冲突升级。'; + const patternGuide = this.createScriptPatternGuide(context.creativePatterns); + const dialogue = [ + { + beat: 'opening', + speaker: protagonist.name, + line: '这一回,我不会再让你们拿走我的东西。' + }, + { + beat: 'conflict', + speaker: antagonist?.name ?? '对手', + line: '你以为一份证据就能改变结果吗?' + }, + { + beat: 'turning', + speaker: helper?.name ?? protagonist.name, + line: helper ? '备份还在,我已经找到了新的时间戳。' : '真正的证据,还没有公开。' + }, + { + beat: 'ending', + speaker: protagonist.name, + line: '下一次见面,该轮到我提条件了。' + } + ]; + const narration = [ + context.episode.opening_hook, + `${protagonist.name}把所有情绪压回眼底,只留下一个明确目标。`, + conflict, + patternGuide.narrationHint, + foreshadowing?.content, + context.episode.ending_hook + ].filter(Boolean).join('\n'); + const scriptText = [ + `【第${context.episode.episode_no}集】${context.episode.title ?? '未命名分集'}`, + `【本集摘要】${context.episode.summary ?? '待补充分集摘要'}`, + `【开头钩子】${context.episode.opening_hook ?? '主角进入高压场景。'}`, + `【中段冲突】${conflict}`, + patternGuide.scriptBlock, + `【脚本】`, + `1. ${protagonist.name}进入画面中心,场景压力直接压到观众面前。`, + `2. ${antagonist?.name ?? '主要对手'}试图用规则和舆论压制局面。`, + `3. ${helper?.name ?? protagonist.name}抛出关键线索,推动局势反转。`, + `4. ${protagonist.name}用短句完成反击,保留下一集悬念。`, + `【结尾悬念】${context.episode.ending_hook ?? context.storyBible.ending_direction ?? '幕后真相继续推进。'}` + ].join('\n'); + + return { + script_text: scriptText, + narration_text: narration, + dialogue_json: dialogue + }; + } + + private buildStoryboardDrafts( + project: Project, + episode: Episode, + script: EpisodeScript, + characters: Character[], + creativePatterns: CreativePattern[] + ): StoryboardDraft[] { + const protagonist = + characters.find((character) => ['protagonist', 'lead'].includes(character.role_type)) ?? + characters[0]; + const antagonist = characters.find((character) => character.role_type === 'antagonist'); + const helper = characters.find((character) => character.role_type === 'supporting'); + const duration = Math.min( + MAX_SHOT_DURATION, + Math.max(MIN_SHOT_DURATION, Math.floor((episode.target_duration ?? 50) / DEFAULT_SHOT_COUNT)) + ); + const patternPromptContext = this.createStoryboardPatternPromptContext(creativePatterns); + const rhythmHint = this.patternPromptForType(creativePatterns, 'episode_rhythm'); + const openingHint = this.patternPromptForType(creativePatterns, 'opening_hook'); + const shotSeeds = [ + { + scene: '开局压迫', + location: '会议室或雨夜室内,竖屏构图', + chars: [protagonist], + visual: `${protagonist.name}处于画面中心,眼神冷静,背景压暗。${openingHint ? ` 套路参考:${openingHint}` : ''}`, + action: '主角抬眼看向镜头,情绪从压抑转为坚定。', + dialogue: this.pickDialogue(script, protagonist.name) ?? '这一回,我不会再退。', + narration: episode.opening_hook, + camera: '近景缓慢推进', + effect: 'subtle_zoom' + }, + { + scene: '证据出现', + location: '桌面、手机屏幕或投屏前', + chars: [protagonist], + visual: '手机录音、合同或关键证据占据画面前景。', + action: `${protagonist.name}把证据推到众人面前。`, + dialogue: null, + narration: '关键证据第一次进入画面。', + camera: '俯拍定格', + effect: 'flash_cut' + }, + { + scene: '反派压制', + location: '会议桌对面', + chars: [antagonist ?? protagonist], + visual: `${antagonist?.name ?? '主要对手'}面部特写,表情克制但带压迫感。`, + action: '对手身体前倾,用冷静语气施压。', + dialogue: this.pickDialogue(script, antagonist?.name ?? '对手') ?? '你以为这样就够了吗?', + narration: null, + camera: '面部特写', + effect: 'speed_line' + }, + { + scene: '主角反击', + location: '会议室中心', + chars: [protagonist, antagonist ?? protagonist], + visual: `${protagonist.name}与${antagonist?.name ?? '对手'}分立画面两侧,形成强对抗。`, + action: `${protagonist.name}说出关键台词,对手表情第一次动摇。`, + dialogue: this.pickDialogue(script, protagonist.name) ?? '真正的证据,还没有公开。', + narration: episode.middle_conflict, + camera: '过肩对峙镜头', + effect: 'comic_impact' + }, + { + scene: '支援入场', + location: '门口或走廊', + chars: [helper ?? protagonist], + visual: `${helper?.name ?? protagonist.name}拿着资料袋或手机快步进入。`, + action: '支援角色带来新线索,打断现场僵局。', + dialogue: this.pickDialogue(script, helper?.name ?? protagonist.name) ?? '备份还在。', + narration: null, + camera: '中景横移', + effect: 'quick_pan' + }, + { + scene: '线索放大', + location: '投屏画面前', + chars: [protagonist], + visual: '屏幕上的时间戳、照片或合同细节被放大。', + action: `${protagonist.name}指向关键细节,现场气氛冻结。`, + dialogue: null, + narration: '伏笔被推进,但真相还未完全揭开。', + camera: '特写切入', + effect: 'freeze_frame' + }, + { + scene: '反派震惊', + location: '对手席位', + chars: [antagonist ?? protagonist], + visual: `${antagonist?.name ?? '对手'}眼睛睁大,背景速度线,表情失控一瞬。`, + action: '对手短暂停顿,暴露破绽。', + dialogue: null, + narration: '局势第一次倒向主角。', + camera: '极近特写', + effect: 'shock_line' + }, + { + scene: '主角掌控', + location: '画面中心', + chars: [protagonist], + visual: `${protagonist.name}站稳,服装和发型保持角色圣经规则。`, + action: '主角收起证据,语气平静地提出条件。', + dialogue: this.pickDialogue(script, protagonist.name) ?? '现在,轮到我提条件。', + narration: null, + camera: '低角度中近景', + effect: 'hero_light' + }, + { + scene: '悬念前奏', + location: '走廊阴影或窗边', + chars: [helper ?? protagonist], + visual: '新消息弹出,屏幕只露出半句关键内容。', + action: '角色低头看手机,表情突然凝住。', + dialogue: null, + narration: episode.ending_hook, + camera: '手机屏幕特写', + effect: 'suspense_blink' + }, + { + scene: '结尾钩子', + location: '阴影中的门口或车内', + chars: [protagonist], + visual: '神秘人物只露出半张脸或一只手,画面留白强。', + action: `关键人物或道具在最后一秒出现。${rhythmHint ? ` 节奏参考:${rhythmHint}` : ''}`, + dialogue: null, + narration: episode.ending_hook ?? '幕后真相继续推进。', + camera: '远景定格', + effect: 'cliffhanger' + } + ]; + + return shotSeeds.map((seed, index) => { + const characterPayload = this.toCharacterPayload(seed.chars.filter(Boolean) as Character[]); + const baseShot = { + id: 0n, + project_id: project.id, + episode_id: episode.id, + shot_no: index + 1, + scene_name: seed.scene, + location_desc: seed.location, + characters_json: characterPayload, + visual_desc: seed.visual, + action_desc: seed.action, + dialogue_text: seed.dialogue, + narration_text: seed.narration ?? null, + camera_motion: seed.camera, + effect_type: seed.effect, + duration: { toString: () => String(duration) }, + prompt_text: null, + negative_prompt: null, + status: 'generated', + created_at: new Date(), + updated_at: new Date() + } as unknown as StoryboardShot; + + return { + shot_no: index + 1, + scene_name: seed.scene, + location_desc: seed.location, + characters_json: characterPayload, + visual_desc: seed.visual, + action_desc: seed.action, + dialogue_text: seed.dialogue, + narration_text: seed.narration ?? null, + camera_motion: seed.camera, + effect_type: seed.effect, + duration, + prompt_text: this.buildPromptText(baseShot, characters, patternPromptContext.promptSuffix), + negative_prompt: this.buildNegativePrompt(characters, patternPromptContext.negativeSuffix), + status: 'generated' + }; + }); + } + + private createScriptUpdateData(dto: UpdateEpisodeScriptDto): Prisma.EpisodeScriptUncheckedUpdateInput { + const data: Prisma.EpisodeScriptUncheckedUpdateInput = {}; + + if ('script_text' in dto) data.script_text = this.optionalText(dto.script_text); + if ('narration_text' in dto) data.narration_text = this.optionalText(dto.narration_text); + if ('dialogue_json' in dto) data.dialogue_json = this.normalizeJson(dto.dialogue_json); + if ('status' in dto) data.status = this.validateScriptStatus(dto.status); + + return data; + } + + private async createShotUpdateData( + shot: StoryboardShot, + dto: UpdateStoryboardShotDto + ): Promise { + const data: Prisma.StoryboardShotUncheckedUpdateInput = {}; + + if ('shot_no' in dto) data.shot_no = await this.validateShotNoForUpdate(shot, dto.shot_no); + if ('scene_name' in dto) data.scene_name = this.optionalText(dto.scene_name); + if ('location_desc' in dto) data.location_desc = this.optionalText(dto.location_desc); + if ('characters_json' in dto) data.characters_json = this.normalizeJson(dto.characters_json); + if ('visual_desc' in dto) data.visual_desc = this.optionalText(dto.visual_desc); + if ('action_desc' in dto) data.action_desc = this.optionalText(dto.action_desc); + if ('dialogue_text' in dto) data.dialogue_text = this.optionalText(dto.dialogue_text); + if ('narration_text' in dto) data.narration_text = this.optionalText(dto.narration_text); + if ('camera_motion' in dto) data.camera_motion = this.optionalText(dto.camera_motion); + if ('effect_type' in dto) data.effect_type = this.optionalText(dto.effect_type); + if ('duration' in dto) data.duration = this.validateShotDuration(dto.duration); + if ('prompt_text' in dto) data.prompt_text = this.optionalText(dto.prompt_text); + if ('negative_prompt' in dto) data.negative_prompt = this.optionalText(dto.negative_prompt); + if ('status' in dto) data.status = this.validateStoryboardStatus(dto.status); + + return data; + } + + private assertScriptReady(script: EpisodeScript) { + if (!script.script_text || !script.narration_text || !script.dialogue_json) { + throw new BadRequestException('Script text, narration, and dialogue are required before confirmation'); + } + } + + private assertStoryboardReady(shots: StoryboardShot[]) { + if (shots.length === 0) { + throw new BadRequestException('Storyboard shots are required before confirmation'); + } + + for (const shot of shots) { + if ( + !shot.visual_desc || + !shot.action_desc || + !shot.duration || + !shot.prompt_text || + !shot.negative_prompt + ) { + throw new BadRequestException('Each storyboard shot must include visual, action, duration, prompt, and negative prompt'); + } + + const duration = Number(shot.duration.toString()); + if (duration < MIN_SHOT_DURATION || duration > MAX_SHOT_DURATION) { + throw new BadRequestException('Each storyboard shot duration must be between 2 and 5 seconds'); + } + } + } + + private buildPromptText(shot: StoryboardShot, characters: Character[], patternPromptSuffix = '') { + const shotCharacters = this.readShotCharacterNames(shot); + const fixedCharacterText = characters + .filter((character) => shotCharacters.length === 0 || shotCharacters.includes(character.name)) + .slice(0, 3) + .map((character) => + `${character.name}:${character.global_character_id ? `全局角色#${character.global_character_id.toString()},` : ''}${character.age_group ?? '固定年龄段'},${character.face_desc ?? character.appearance_desc ?? '固定脸型'},${character.hair_desc ?? '固定发型'},${character.costume_rules ?? '固定服装范围'}${character.wardrobe_variant ? `,本项目服装=${character.wardrobe_variant}` : ''}${character.performance_style ? `,表演=${character.performance_style}` : ''}` + ) + .join(';'); + + return [ + '高质量韩漫风,竖屏9:16,电影光影,人物五官精致,背景清晰', + shot.location_desc, + shot.visual_desc, + shot.action_desc, + shot.camera_motion ? `镜头:${shot.camera_motion}` : null, + shot.effect_type ? `效果:${shot.effect_type}` : null, + fixedCharacterText ? `角色固定设定:${fixedCharacterText}` : null, + patternPromptSuffix ? `题材套路/视觉Prompt参考:${patternPromptSuffix}` : null, + '一个画面中心,一个主要动作,一个清晰情绪点' + ].filter(Boolean).join(','); + } + + private buildNegativePrompt(characters: Character[], patternNegativeSuffix = '') { + const names = characters.map((character) => character.name).join('、'); + + return [ + '低清晰度,崩坏手指,五官扭曲,多余肢体,文字水印,画面模糊', + '多人混脸,年龄漂移,发色无原因变化,服装完全跑偏', + names ? `禁止把${names}混合成同一个角色` : null, + patternNegativeSuffix ? `题材套路禁区:${patternNegativeSuffix}` : null, + '一个镜头超过4个主要人物,同镜头同时打斗,复杂手部互动' + ].filter(Boolean).join(','); + } + + private async loadProjectCreativePatterns(projectId: bigint) { + const bindings = await this.prisma.projectCreativePattern.findMany({ + where: { project_id: projectId }, + orderBy: [{ sort_order: 'asc' }, { id: 'asc' }] + }); + + if (bindings.length === 0) { + return []; + } + + const patterns = await this.prisma.creativePattern.findMany({ + where: { + id: { in: bindings.map((binding) => binding.creative_pattern_id) }, + status: 'active' + } + }); + const patternMap = new Map(patterns.map((pattern) => [pattern.id.toString(), pattern])); + + return bindings + .map((binding) => patternMap.get(binding.creative_pattern_id.toString()) ?? null) + .filter((pattern): pattern is CreativePattern => Boolean(pattern)); + } + + private createScriptPatternGuide(patterns: CreativePattern[]) { + if (patterns.length === 0) { + return { + scriptBlock: '', + narrationHint: '' + }; + } + + const items = patterns + .slice(0, 5) + .map((pattern) => `- ${pattern.title}(${pattern.pattern_type}):${pattern.prompt_template ?? pattern.description ?? '复用该模式。'}`) + .join('\n'); + const narrationHint = patterns + .map((pattern) => pattern.description ?? pattern.prompt_template) + .filter((value): value is string => Boolean(value)) + .slice(0, 2) + .join('\n'); + + return { + scriptBlock: `【题材套路库】\n${items}`, + narrationHint + }; + } + + private createStoryboardPatternPromptContext(patterns: CreativePattern[]) { + const promptSuffix = patterns + .filter((pattern) => ['visual_prompt', 'opening_hook', 'episode_rhythm'].includes(pattern.pattern_type)) + .map((pattern) => pattern.prompt_template ?? pattern.description) + .filter((value): value is string => Boolean(value)) + .slice(0, 3) + .join(';'); + const negativeSuffix = patterns + .map((pattern) => pattern.negative_prompt) + .filter((value): value is string => Boolean(value)) + .slice(0, 3) + .join(';'); + + return { promptSuffix, negativeSuffix }; + } + + private patternPromptForType(patterns: CreativePattern[], patternType: string) { + const pattern = patterns.find((item) => item.pattern_type === patternType); + + return pattern?.prompt_template ?? pattern?.description ?? ''; + } + + private toCharacterPayload(characters: Character[]) { + return characters.slice(0, 3).map((character) => ({ + id: character.id.toString(), + name: character.name, + role_type: character.role_type, + fixed_desc: character.appearance_desc, + costume_rules: character.costume_rules + })); + } + + private pickDialogue(script: EpisodeScript, speaker: string) { + const dialogue = Array.isArray(script.dialogue_json) ? script.dialogue_json : []; + const item = dialogue.find((entry) => + typeof entry === 'object' && + entry !== null && + 'speaker' in entry && + String(entry.speaker) === speaker && + 'line' in entry + ); + + return item && typeof item === 'object' && 'line' in item ? String(item.line) : null; + } + + private readShotCharacterNames(shot: StoryboardShot) { + const value = shot.characters_json; + + if (!Array.isArray(value)) { + return []; + } + + return value + .map((item) => + typeof item === 'object' && item !== null && 'name' in item ? String(item.name) : '' + ) + .filter(Boolean); + } + + private async findShotForUser(shotId: string, user: AuthRequestUser) { + const shot = await this.prisma.storyboardShot.findUnique({ + where: { id: this.parseId(shotId, 'Invalid storyboard shot id') } + }); + + if (!shot) { + throw new NotFoundException('Storyboard shot not found'); + } + + await this.loadEpisodeForUser(shot.episode_id.toString(), user); + return shot; + } + + private async findLatestScript(episodeId: bigint) { + return this.prisma.episodeScript.findFirst({ + where: { episode_id: episodeId }, + orderBy: { version: 'desc' } + }); + } + + private async findConfirmedScript(episodeId: bigint) { + return this.prisma.episodeScript.findFirst({ + where: { + episode_id: episodeId, + status: 'confirmed' + }, + orderBy: { version: 'desc' } + }); + } + + private async nextScriptVersion(episodeId: bigint) { + const latest = await this.findLatestScript(episodeId); + return (latest?.version ?? 0) + 1; + } + + private async validateShotNoForUpdate(shot: StoryboardShot, value: number | undefined) { + const shotNo = this.validatePositiveInt(value, 'shot_no', 1, 500); + + if (shotNo === shot.shot_no) { + return shotNo; + } + + const existing = await this.prisma.storyboardShot.findFirst({ + where: { + episode_id: shot.episode_id, + shot_no: shotNo, + id: { not: shot.id } + } + }); + + if (existing) { + throw new BadRequestException('shot_no already exists in this episode'); + } + + return shotNo; + } + + private validateShotDuration(value: number | undefined) { + return this.validatePositiveInt(value, 'duration', MIN_SHOT_DURATION, MAX_SHOT_DURATION); + } + + private validatePositiveInt(value: unknown, field: string, min: number, max: number) { + const numberValue = Number(value); + + if (!Number.isInteger(numberValue) || numberValue < min || numberValue > max) { + throw new BadRequestException(`${field} must be an integer between ${min} and ${max}`); + } + + return numberValue; + } + + private validateScriptStatus(value: string | undefined): ScriptStatus { + if (!value || !SCRIPT_STATUSES.includes(value as never)) { + throw new BadRequestException('script status is invalid'); + } + + return value as ScriptStatus; + } + + private validateStoryboardStatus(value: string | undefined): StoryboardStatus { + if (!value || !STORYBOARD_STATUSES.includes(value as never)) { + throw new BadRequestException('storyboard status is invalid'); + } + + return value as StoryboardStatus; + } + + private normalizeJson(value: unknown): Prisma.InputJsonValue { + if (value === undefined) { + throw new BadRequestException('json value is required'); + } + + return value as Prisma.InputJsonValue; + } + + private optionalText(value: string | undefined) { + const normalized = value?.trim(); + return normalized || null; + } + + private parseId(id: string, message: string) { + try { + return BigInt(id); + } catch { + throw new BadRequestException(message); + } + } +} diff --git a/backend/src/story-bibles/story-bible.dto.ts b/backend/src/story-bibles/story-bible.dto.ts new file mode 100644 index 0000000..aea889c --- /dev/null +++ b/backend/src/story-bibles/story-bible.dto.ts @@ -0,0 +1,19 @@ +export class GenerateStoryBibleDto { + source_id?: string; +} + +export class UpdateStoryBibleDto { + title?: string; + logline?: string; + main_plot?: string; + core_conflict?: string; + selling_points?: string; + tone?: string; + world_summary?: string; + ending_direction?: string; + taboo_rules?: string; +} + +export class ConfirmStoryBibleDto { + story_bible_id?: string; +} diff --git a/backend/src/story-bibles/story-bible.types.ts b/backend/src/story-bibles/story-bible.types.ts new file mode 100644 index 0000000..723dc7e --- /dev/null +++ b/backend/src/story-bibles/story-bible.types.ts @@ -0,0 +1,39 @@ +import type { StoryBible } from '@prisma/client'; + +export interface SafeStoryBible { + id: string; + project_id: string; + title: string | null; + logline: string | null; + main_plot: string | null; + core_conflict: string | null; + selling_points: string | null; + tone: string | null; + world_summary: string | null; + ending_direction: string | null; + taboo_rules: string | null; + version: number; + status: string; + created_at: string; + updated_at: string; +} + +export function toSafeStoryBible(storyBible: StoryBible): SafeStoryBible { + return { + id: storyBible.id.toString(), + project_id: storyBible.project_id.toString(), + title: storyBible.title, + logline: storyBible.logline, + main_plot: storyBible.main_plot, + core_conflict: storyBible.core_conflict, + selling_points: storyBible.selling_points, + tone: storyBible.tone, + world_summary: storyBible.world_summary, + ending_direction: storyBible.ending_direction, + taboo_rules: storyBible.taboo_rules, + version: storyBible.version, + status: storyBible.status, + created_at: storyBible.created_at.toISOString(), + updated_at: storyBible.updated_at.toISOString() + }; +} diff --git a/backend/src/story-bibles/story-bibles.controller.ts b/backend/src/story-bibles/story-bibles.controller.ts new file mode 100644 index 0000000..f18c192 --- /dev/null +++ b/backend/src/story-bibles/story-bibles.controller.ts @@ -0,0 +1,48 @@ +import { Body, Controller, Get, Inject, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; +import { CurrentUser } from '../auth/current-user.decorator'; +import type { AuthRequestUser } from '../auth/auth.types'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { ConfirmStoryBibleDto, GenerateStoryBibleDto, UpdateStoryBibleDto } from './story-bible.dto'; +import { StoryBiblesService } from './story-bibles.service'; + +@Controller('projects/:projectId/story-bible') +@UseGuards(JwtAuthGuard) +export class StoryBiblesController { + constructor(@Inject(StoryBiblesService) private readonly storyBiblesService: StoryBiblesService) {} + + @Post('generate') + generateStoryBible( + @CurrentUser() user: AuthRequestUser, + @Param('projectId') projectId: string, + @Body() dto: GenerateStoryBibleDto + ) { + return this.storyBiblesService.generateStoryBible(user, projectId, dto); + } + + @Get() + getStoryBible( + @CurrentUser() user: AuthRequestUser, + @Param('projectId') projectId: string, + @Query('version') version?: string + ) { + return this.storyBiblesService.getStoryBible(user, projectId, version); + } + + @Patch() + updateStoryBible( + @CurrentUser() user: AuthRequestUser, + @Param('projectId') projectId: string, + @Body() dto: UpdateStoryBibleDto + ) { + return this.storyBiblesService.updateStoryBible(user, projectId, dto); + } + + @Post('confirm') + confirmStoryBible( + @CurrentUser() user: AuthRequestUser, + @Param('projectId') projectId: string, + @Body() dto: ConfirmStoryBibleDto + ) { + return this.storyBiblesService.confirmStoryBible(user, projectId, dto); + } +} diff --git a/backend/src/story-bibles/story-bibles.module.ts b/backend/src/story-bibles/story-bibles.module.ts new file mode 100644 index 0000000..d8f42c3 --- /dev/null +++ b/backend/src/story-bibles/story-bibles.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { StoryBiblesController } from './story-bibles.controller'; +import { StoryBiblesService } from './story-bibles.service'; + +@Module({ + imports: [AuthModule], + controllers: [StoryBiblesController], + providers: [StoryBiblesService], + exports: [StoryBiblesService] +}) +export class StoryBiblesModule {} diff --git a/backend/src/story-bibles/story-bibles.service.spec.ts b/backend/src/story-bibles/story-bibles.service.spec.ts new file mode 100644 index 0000000..554c7bb --- /dev/null +++ b/backend/src/story-bibles/story-bibles.service.spec.ts @@ -0,0 +1,316 @@ +import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { NovelChapter, NovelSource, Prisma, Project, StoryBible } from '@prisma/client'; +import type { AuthRequestUser } from '../auth/auth.types'; +import type { PrismaService } from '../prisma/prisma.service'; +import { StoryBiblesService } from './story-bibles.service'; + +const user: AuthRequestUser = { + id: '1', + email: 'user@example.com', + role: 'user' +}; + +function createProject(overrides: Partial = {}): Project { + return { + id: 10n, + user_id: 1n, + title: '重生归来,我只搞事业', + input_mode: 'ai_original', + genre: 'urban_rebirth', + style_code: 'korean_comic', + output_type: 'short_video', + output_mode: 'image_manga', + visual_mode: 'korean_manga', + video_generation_level: 'standard', + target_episode_count: 3, + episode_duration: 60, + status: 'novel_uploaded', + copyright_status: 'ai_original', + payment_status: 'unpaid', + quality_level: 'mvp', + is_long_series: false, + created_at: new Date('2026-05-31T00:00:00.000Z'), + updated_at: new Date('2026-05-31T00:00:00.000Z'), + completed_at: null, + ...overrides + }; +} + +function createSource(overrides: Partial = {}): NovelSource { + return { + id: 20n, + project_id: 10n, + source_type: 'ai_original', + title: '重生归来,我只搞事业', + author_name: 'AI Mock', + raw_asset_id: null, + raw_text: '第1章 暴雨重启', + clean_text: '第1章 暴雨重启', + word_count: 100, + chapter_count: 3, + parse_status: 'checked', + parse_report: { + provider: 'mock_novel_provider', + idea: { + title: '重生归来,我只搞事业', + protagonist_name: '林晚', + logline: '林晚重回命运转折点,用证据夺回项目。', + core_conflict: '林晚必须在资本压力中守住原创项目。', + selling_points: ['重生归来', '证据反杀'], + story_mood: '克制、锋利、连续反转', + world_setting: '现代都市内容公司' + }, + outline: { + main_plot: '林晚夺回原创项目控制权。' + } + } as unknown as Prisma.JsonValue, + created_at: new Date('2026-05-31T00:00:00.000Z'), + ...overrides + }; +} + +function createChapter(overrides: Partial = {}): NovelChapter { + return { + id: 30n, + project_id: 10n, + novel_source_id: 20n, + chapter_no: 1, + title: '第1章 暴雨重启', + content: '林晚站在暴雨夜里醒来,决定重新夺回项目。', + summary: '林晚确认重生并整理证据。', + visual_summary: '暴雨夜,林晚醒来,手机录音亮起。', + word_count: 22, + status: 'generated', + created_at: new Date('2026-05-31T00:00:00.000Z'), + ...overrides + }; +} + +function createStoryBible(overrides: Partial = {}): StoryBible { + return { + id: 40n, + project_id: 10n, + title: '重生归来,我只搞事业', + logline: '林晚重回命运转折点,用证据夺回项目。', + main_plot: '主线目标:林晚夺回原创项目控制权。', + core_conflict: '林晚必须在资本压力中守住原创项目。', + selling_points: '重生归来\n证据反杀', + tone: '克制、锋利、连续反转', + world_summary: '现代都市内容公司', + ending_direction: '幕后真相继续推进。', + taboo_rules: '不得改变主角姓名。', + version: 1, + status: 'waiting_confirm', + created_at: new Date('2026-05-31T00:00:00.000Z'), + updated_at: new Date('2026-05-31T00:00:00.000Z'), + ...overrides + }; +} + +function createCreativePattern(overrides: Record = {}) { + return { + id: 100n, + source_case_id: null, + pattern_type: 'opening_hook', + title: '退婚开场钩子', + genre: 'urban_rebirth', + language: 'zh-CN', + description: '前 8 秒建立关系破裂和证据反击。', + structure_json: {}, + prompt_template: '写一个退婚现场开场钩子。', + negative_prompt: '拖慢铺垫', + tags_json: ['退婚', '打脸'], + usage_count: 0, + effectiveness_score: null, + status: 'active', + created_by_user_id: 1n, + created_at: new Date('2026-05-31T00:00:00.000Z'), + updated_at: new Date('2026-05-31T00:00:00.000Z'), + ...overrides + }; +} + +function createProjectCreativePattern(overrides: Record = {}) { + return { + id: 101n, + project_id: 10n, + creative_pattern_id: 100n, + source: 'user_selected', + snapshot_json: {}, + sort_order: 1, + created_at: new Date('2026-05-31T00:00:00.000Z'), + ...overrides + }; +} + +describe('StoryBiblesService', () => { + let prisma: { + project: { findUnique: ReturnType; update: ReturnType }; + novelSource: { + findUnique: ReturnType; + findFirst: ReturnType; + }; + novelChapter: { findMany: ReturnType }; + projectCreativePattern: { findMany: ReturnType }; + creativePattern: { findMany: ReturnType }; + storyBible: { + create: ReturnType; + findFirst: ReturnType; + findMany: ReturnType; + findUnique: ReturnType; + update: ReturnType; + updateMany: ReturnType; + }; + $transaction: ReturnType; + }; + let tx: { + project: { update: ReturnType }; + storyBible: { + create: ReturnType; + update: ReturnType; + updateMany: ReturnType; + }; + }; + let service: StoryBiblesService; + + beforeEach(() => { + tx = { + project: { + update: vi.fn().mockResolvedValue(createProject({ status: 'waiting_story_confirm' })) + }, + storyBible: { + create: vi.fn().mockResolvedValue(createStoryBible()), + update: vi.fn().mockResolvedValue(createStoryBible({ status: 'confirmed' })), + updateMany: vi.fn().mockResolvedValue({ count: 0 }) + } + }; + prisma = { + project: { + findUnique: vi.fn().mockResolvedValue(createProject()), + update: vi.fn().mockResolvedValue(createProject({ status: 'story_bible_generating' })) + }, + novelSource: { + findUnique: vi.fn().mockResolvedValue(createSource()), + findFirst: vi.fn().mockResolvedValue(createSource()) + }, + novelChapter: { + findMany: vi.fn().mockResolvedValue([ + createChapter(), + createChapter({ + id: 31n, + chapter_no: 2, + title: '第2章 会议反击', + summary: '林晚在会议上用证据反击。', + visual_summary: '会议室投屏,证据时间戳出现。' + }) + ]) + }, + projectCreativePattern: { + findMany: vi.fn().mockResolvedValue([]) + }, + creativePattern: { + findMany: vi.fn().mockResolvedValue([]) + }, + storyBible: { + create: vi.fn(), + findFirst: vi.fn().mockResolvedValue(null), + findMany: vi.fn().mockResolvedValue([createStoryBible()]), + findUnique: vi.fn().mockResolvedValue(createStoryBible()), + update: vi.fn(), + updateMany: vi.fn() + }, + $transaction: vi.fn(async (callback: (transaction: typeof tx) => unknown) => callback(tx)) + }; + service = new StoryBiblesService(prisma as unknown as PrismaService); + }); + + it('generates a story bible from novel chapters', async () => { + const result = await service.generateStoryBible(user, '10', { source_id: '20' }); + + expect(prisma.project.update).toHaveBeenCalledWith({ + where: { id: 10n }, + data: { status: 'story_bible_generating' } + }); + expect(tx.storyBible.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + project_id: 10n, + version: 1, + status: 'waiting_confirm', + title: '重生归来,我只搞事业' + }) + }); + expect(tx.project.update).toHaveBeenCalledWith({ + where: { id: 10n }, + data: { status: 'waiting_story_confirm' } + }); + expect(result.next_step).toBe('story_bible_confirm'); + }); + + it('injects selected creative patterns into generated story bible', async () => { + prisma.projectCreativePattern.findMany.mockResolvedValue([createProjectCreativePattern()]); + prisma.creativePattern.findMany.mockResolvedValue([createCreativePattern()]); + + await service.generateStoryBible(user, '10', { source_id: '20' }); + + const createCall = tx.storyBible.create.mock.calls[0][0]; + expect(createCall.data.selling_points).toContain('题材套路库'); + expect(createCall.data.selling_points).toContain('退婚开场钩子'); + expect(createCall.data.world_summary).toContain('套路 / Prompt 规则'); + expect(createCall.data.taboo_rules).toContain('拖慢铺垫'); + }); + + it('rejects generation when no chapters exist', async () => { + prisma.novelChapter.findMany.mockResolvedValue([]); + + await expect(service.generateStoryBible(user, '10', {})).rejects.toBeInstanceOf( + BadRequestException + ); + }); + + it('creates a new version when editing', async () => { + prisma.storyBible.findFirst.mockResolvedValue(createStoryBible()); + tx.storyBible.create.mockResolvedValue( + createStoryBible({ id: 41n, version: 2, logline: '新的故事一句话简介。' }) + ); + + const result = await service.updateStoryBible(user, '10', { + logline: '新的故事一句话简介。' + }); + + expect(tx.storyBible.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + version: 2, + logline: '新的故事一句话简介。', + status: 'waiting_confirm' + }) + }); + expect(result.previous_version).toBe(1); + expect(result.story_bible.version).toBe(2); + }); + + it('confirms the latest story bible and advances project status', async () => { + prisma.storyBible.findFirst.mockResolvedValue(createStoryBible()); + + const result = await service.confirmStoryBible(user, '10', {}); + + expect(tx.storyBible.updateMany).toHaveBeenCalled(); + expect(tx.storyBible.update).toHaveBeenCalledWith({ + where: { id: 40n }, + data: { status: 'confirmed' } + }); + expect(tx.project.update).toHaveBeenCalledWith({ + where: { id: 10n }, + data: { status: 'story_confirmed' } + }); + expect(result.next_step).toBe('character_bible_extract'); + }); + + it('rejects access to another user project', async () => { + prisma.project.findUnique.mockResolvedValue(createProject({ user_id: 2n })); + + await expect(service.getStoryBible(user, '10')).rejects.toBeInstanceOf( + ForbiddenException + ); + }); +}); diff --git a/backend/src/story-bibles/story-bibles.service.ts b/backend/src/story-bibles/story-bibles.service.ts new file mode 100644 index 0000000..48b7a6e --- /dev/null +++ b/backend/src/story-bibles/story-bibles.service.ts @@ -0,0 +1,452 @@ +import { + BadRequestException, + ForbiddenException, + Inject, + Injectable, + NotFoundException +} from '@nestjs/common'; +import type { CreativePattern, NovelChapter, NovelSource, Prisma, Project, StoryBible } from '@prisma/client'; +import type { AuthRequestUser } from '../auth/auth.types'; +import { PrismaService } from '../prisma/prisma.service'; +import { ConfirmStoryBibleDto, GenerateStoryBibleDto, UpdateStoryBibleDto } from './story-bible.dto'; +import { toSafeStoryBible } from './story-bible.types'; + +type StoryBibleDraft = Pick< + StoryBible, + | 'title' + | 'logline' + | 'main_plot' + | 'core_conflict' + | 'selling_points' + | 'tone' + | 'world_summary' + | 'ending_direction' + | 'taboo_rules' +>; + +interface SourceContext { + source: NovelSource; + chapters: NovelChapter[]; + report: Record; + creativePatterns: CreativePattern[]; +} + +@Injectable() +export class StoryBiblesService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async generateStoryBible( + user: AuthRequestUser, + projectId: string, + dto: GenerateStoryBibleDto + ) { + const project = await this.findProjectForUser(projectId, user); + const context = await this.resolveSourceContext(project.id, dto.source_id); + const nextVersion = await this.nextVersion(project.id); + const draft = this.buildDraft(project, context); + + await this.prisma.project.update({ + where: { id: project.id }, + data: { status: 'story_bible_generating' } + }); + + const storyBible = await this.prisma.$transaction(async (tx) => { + const created = await tx.storyBible.create({ + data: { + project_id: project.id, + ...draft, + version: nextVersion, + status: 'waiting_confirm' + } + }); + await tx.project.update({ + where: { id: project.id }, + data: { status: 'waiting_story_confirm' } + }); + return created; + }); + + return { + story_bible: toSafeStoryBible(storyBible), + source: { + id: context.source.id.toString(), + source_type: context.source.source_type, + parse_status: context.source.parse_status + }, + next_step: 'story_bible_confirm' + }; + } + + async getStoryBible(user: AuthRequestUser, projectId: string, version?: string) { + const project = await this.findProjectForUser(projectId, user); + const target = version + ? await this.prisma.storyBible.findFirst({ + where: { + project_id: project.id, + version: this.parseVersion(version) + } + }) + : await this.findLatestStoryBible(project.id); + const versions = await this.prisma.storyBible.findMany({ + where: { project_id: project.id }, + orderBy: { version: 'desc' } + }); + + return { + story_bible: target ? toSafeStoryBible(target) : null, + versions: versions.map((storyBible) => ({ + id: storyBible.id.toString(), + version: storyBible.version, + status: storyBible.status, + updated_at: storyBible.updated_at.toISOString() + })) + }; + } + + async updateStoryBible( + user: AuthRequestUser, + projectId: string, + dto: UpdateStoryBibleDto + ) { + const project = await this.findProjectForUser(projectId, user); + const current = await this.findLatestStoryBible(project.id); + + if (!current) { + throw new NotFoundException('Story bible not found'); + } + + if (!this.hasEditableField(dto)) { + throw new BadRequestException('No story bible fields to update'); + } + + const nextVersion = await this.nextVersion(project.id); + const updatedDraft = this.applyPatch(current, dto); + const storyBible = await this.prisma.$transaction(async (tx) => { + const created = await tx.storyBible.create({ + data: { + project_id: project.id, + ...updatedDraft, + version: nextVersion, + status: 'waiting_confirm' + } + }); + await tx.project.update({ + where: { id: project.id }, + data: { status: 'waiting_story_confirm' } + }); + return created; + }); + + return { + story_bible: toSafeStoryBible(storyBible), + previous_version: current.version, + next_step: 'story_bible_confirm' + }; + } + + async confirmStoryBible( + user: AuthRequestUser, + projectId: string, + dto: ConfirmStoryBibleDto + ) { + const project = await this.findProjectForUser(projectId, user); + const storyBible = dto.story_bible_id + ? await this.findStoryBibleById(project.id, dto.story_bible_id) + : await this.findLatestStoryBible(project.id); + + if (!storyBible) { + throw new NotFoundException('Story bible not found'); + } + + const confirmed = await this.prisma.$transaction(async (tx) => { + await tx.storyBible.updateMany({ + where: { + project_id: project.id, + status: 'confirmed', + id: { not: storyBible.id } + }, + data: { status: 'superseded' } + }); + const updated = await tx.storyBible.update({ + where: { id: storyBible.id }, + data: { status: 'confirmed' } + }); + await tx.project.update({ + where: { id: project.id }, + data: { status: 'story_confirmed' } + }); + return updated; + }); + + return { + story_bible: toSafeStoryBible(confirmed), + next_step: 'character_bible_extract' + }; + } + + private buildDraft(project: Project, context: SourceContext): StoryBibleDraft { + const idea = this.readObject(context.report.idea); + const outline = this.readObject(context.report.outline); + const chapters = context.chapters; + const title = this.readString(idea.title) ?? context.source.title ?? project.title ?? '未命名故事'; + const protagonist = this.readString(idea.protagonist_name) ?? this.guessProtagonist(chapters); + const firstChapter = chapters[0]; + const lastChapter = chapters[chapters.length - 1]; + const chapterSummaries = chapters + .map((chapter) => `第${chapter.chapter_no}章:${chapter.summary || this.compact(chapter.content).slice(0, 80)}`) + .join('\n'); + const outlineMainPlot = this.readString(outline.main_plot); + const sellingPoints = this.readStringArray(idea.selling_points); + const visualSummaries = chapters + .map((chapter) => chapter.visual_summary) + .filter((summary): summary is string => Boolean(summary)) + .slice(0, 5) + .join('\n'); + const creativePatternSummary = this.createCreativePatternSummary(context.creativePatterns); + + return { + title, + logline: + this.readString(idea.logline) ?? + `${title}讲述${protagonist}在关键转折后重新掌握主动权的高能故事。`, + main_plot: [ + `主线目标:${outlineMainPlot ?? `${protagonist}从危机中夺回主动权,并推动故事进入下一阶段。`}`, + `主要人物:${protagonist};其对手、旧友、合作者将在后续角色圣经中细化。`, + `人物关系:${protagonist}与旧团队存在利益冲突,与潜在合作者存在信任考验。`, + `时间线:\n${chapterSummaries}`, + `重要伏笔:${lastChapter?.summary || lastChapter?.title || '保留关键证据和幕后人物伏笔。'}` + ].join('\n\n'), + core_conflict: + this.readString(idea.core_conflict) ?? + `${protagonist}必须在外部压力和内部信任危机中完成反击,同时守住核心目标。`, + selling_points: [ + ...(sellingPoints.length > 0 + ? sellingPoints + : ['强开局', '清晰反击线', '章节结尾钩子', '适合短视频改编']), + `章节数量:${chapters.length}`, + `首章钩子:${firstChapter?.summary || firstChapter?.title || '开局冲突明确'}`, + creativePatternSummary.sellingPoints ? `题材套路库:\n${creativePatternSummary.sellingPoints}` : null + ].filter(Boolean).join('\n'), + tone: + this.readString(idea.story_mood) ?? + `${project.genre || '通用题材'},节奏紧凑,冲突明确,韩漫短剧质感。${creativePatternSummary.toneSuffix}`, + world_summary: [ + `世界规则:${this.readString(idea.world_setting) ?? '以项目原文/原创章节建立的现实逻辑为准,避免突然引入未铺垫设定。'}`, + `场景规则:优先选择可视化强的室内会议、雨夜街景、片场/工作室、关键证据展示场景。`, + `视觉规则:${visualSummaries || '人物表情清晰,冲突关系明确,场景服务剧情推进。'}`, + creativePatternSummary.promptRules ? `套路 / Prompt 规则:\n${creativePatternSummary.promptRules}` : null, + `时间线规则:后续分集必须承接已生成章节顺序,不跳过关键转折。` + ].filter(Boolean).join('\n\n'), + ending_direction: + lastChapter?.summary ?? + `${protagonist}阶段性完成反击,但幕后真相仍保留到后续分集推进。`, + taboo_rules: [ + '不得改变已确认主角姓名、核心目标和主要冲突。', + '不得突然加入未铺垫的超能力、亲缘反转或跨题材设定。', + '不得让关键证据无因消失,不得让角色动机前后矛盾。', + creativePatternSummary.negativeRules ? `不得违背已选题材套路禁区:${creativePatternSummary.negativeRules}` : null, + '不得生成违法、低俗、仇恨、侵权或不适合公开发布的内容。' + ].filter(Boolean).join('\n') + }; + } + + private async resolveSourceContext(projectId: bigint, sourceId?: string): Promise { + const source = sourceId + ? await this.prisma.novelSource.findUnique({ + where: { id: this.parseId(sourceId, 'Invalid source id') } + }) + : await this.prisma.novelSource.findFirst({ + where: { + project_id: projectId, + parse_status: { in: ['parsed', 'generated', 'checked'] } + }, + orderBy: { created_at: 'desc' } + }); + + if (!source || source.project_id !== projectId) { + throw new NotFoundException('Novel source not found'); + } + + const chapters = await this.prisma.novelChapter.findMany({ + where: { novel_source_id: source.id }, + orderBy: { chapter_no: 'asc' } + }); + + if (chapters.length === 0) { + throw new BadRequestException('Novel chapters are required before story bible generation'); + } + + return { + source, + chapters, + report: this.readReport(source), + creativePatterns: await this.loadProjectCreativePatterns(projectId) + }; + } + + private async loadProjectCreativePatterns(projectId: bigint) { + const bindings = await this.prisma.projectCreativePattern.findMany({ + where: { project_id: projectId }, + orderBy: [{ sort_order: 'asc' }, { id: 'asc' }] + }); + + if (bindings.length === 0) { + return []; + } + + const patterns = await this.prisma.creativePattern.findMany({ + where: { + id: { in: bindings.map((binding) => binding.creative_pattern_id) }, + status: 'active' + } + }); + const patternMap = new Map(patterns.map((pattern) => [pattern.id.toString(), pattern])); + + return bindings + .map((binding) => patternMap.get(binding.creative_pattern_id.toString()) ?? null) + .filter((pattern): pattern is CreativePattern => Boolean(pattern)); + } + + private createCreativePatternSummary(patterns: CreativePattern[]) { + if (patterns.length === 0) { + return { + sellingPoints: '', + promptRules: '', + negativeRules: '', + toneSuffix: '' + }; + } + + const sellingPoints = patterns + .map((pattern) => `- ${pattern.title}(${pattern.pattern_type}):${pattern.description ?? pattern.prompt_template ?? '复用该题材套路。'}`) + .join('\n'); + const promptRules = patterns + .map((pattern) => pattern.prompt_template ? `- ${pattern.title}:${pattern.prompt_template}` : '') + .filter(Boolean) + .join('\n'); + const negativeRules = patterns + .map((pattern) => pattern.negative_prompt) + .filter((value): value is string => Boolean(value)) + .join(';'); + + return { + sellingPoints, + promptRules, + negativeRules, + toneSuffix: ` 已绑定 ${patterns.length} 条题材套路,生成时优先复用其钩子、反转和镜头节奏。` + }; + } + + private async findProjectForUser(projectId: string, user: AuthRequestUser) { + const project = await this.prisma.project.findUnique({ + where: { id: this.parseId(projectId, 'Invalid project id') } + }); + + if (!project) { + throw new NotFoundException('Project not found'); + } + + if (project.user_id.toString() !== user.id && user.role !== 'admin') { + throw new ForbiddenException('Project is private'); + } + + return project; + } + + private async findLatestStoryBible(projectId: bigint) { + return this.prisma.storyBible.findFirst({ + where: { project_id: projectId }, + orderBy: { version: 'desc' } + }); + } + + private async findStoryBibleById(projectId: bigint, storyBibleId: string) { + const storyBible = await this.prisma.storyBible.findUnique({ + where: { id: this.parseId(storyBibleId, 'Invalid story bible id') } + }); + + if (!storyBible || storyBible.project_id !== projectId) { + throw new NotFoundException('Story bible not found'); + } + + return storyBible; + } + + private async nextVersion(projectId: bigint) { + const latest = await this.findLatestStoryBible(projectId); + return (latest?.version ?? 0) + 1; + } + + private applyPatch(current: StoryBible, dto: UpdateStoryBibleDto): StoryBibleDraft { + return { + title: this.pickText(dto.title, current.title), + logline: this.pickText(dto.logline, current.logline), + main_plot: this.pickText(dto.main_plot, current.main_plot), + core_conflict: this.pickText(dto.core_conflict, current.core_conflict), + selling_points: this.pickText(dto.selling_points, current.selling_points), + tone: this.pickText(dto.tone, current.tone), + world_summary: this.pickText(dto.world_summary, current.world_summary), + ending_direction: this.pickText(dto.ending_direction, current.ending_direction), + taboo_rules: this.pickText(dto.taboo_rules, current.taboo_rules) + }; + } + + private hasEditableField(dto: UpdateStoryBibleDto) { + return Object.entries(dto).some(([, value]) => typeof value === 'string' && value.trim()); + } + + private readReport(source: NovelSource): Record { + const report = source.parse_report; + return report && typeof report === 'object' && !Array.isArray(report) + ? (report as Record) + : {}; + } + + private readObject(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {}; + } + + private readString(value: unknown) { + return typeof value === 'string' && value.trim() ? value.trim() : undefined; + } + + private readStringArray(value: unknown) { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string' && Boolean(item.trim())) + : []; + } + + private pickText(value: string | undefined, fallback: string | null) { + return value === undefined ? fallback : value.trim() || null; + } + + private guessProtagonist(chapters: NovelChapter[]) { + const text = chapters.map((chapter) => chapter.content).join('\n'); + const match = /[\u4e00-\u9fa5]{2,4}(?=站在|醒来|必须|决定|知道|拿出)/.exec(text); + return match?.[0] ?? '主角'; + } + + private compact(text: string) { + return text.replace(/\s+/g, ' ').trim(); + } + + private parseVersion(version: string) { + const numberValue = Number(version); + + if (!Number.isInteger(numberValue) || numberValue <= 0) { + throw new BadRequestException('Invalid story bible version'); + } + + return numberValue; + } + + private parseId(id: string, message: string) { + try { + return BigInt(id); + } catch { + throw new BadRequestException(message); + } + } +} diff --git a/backend/src/users/user.types.ts b/backend/src/users/user.types.ts new file mode 100644 index 0000000..1117fbb --- /dev/null +++ b/backend/src/users/user.types.ts @@ -0,0 +1,34 @@ +import type { User } from '@prisma/client'; + +export interface SafeUser { + id: string; + email: string | null; + phone: string | null; + nickname: string | null; + avatar_url: string | null; + role: string; + status: string; + wechat_openid: string | null; + created_at: string; +} + +export interface CreateUserInput { + email: string; + password_hash: string; + nickname?: string; + role?: string; +} + +export function toSafeUser(user: User): SafeUser { + return { + id: user.id.toString(), + email: user.email, + phone: user.phone, + nickname: user.nickname, + avatar_url: user.avatar_url, + role: user.role, + status: user.status, + wechat_openid: user.wechat_openid, + created_at: user.created_at.toISOString() + }; +} diff --git a/backend/src/users/users.module.ts b/backend/src/users/users.module.ts new file mode 100644 index 0000000..bd42599 --- /dev/null +++ b/backend/src/users/users.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { UsersService } from './users.service'; + +@Module({ + providers: [UsersService], + exports: [UsersService] +}) +export class UsersModule {} diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts new file mode 100644 index 0000000..2cf15f5 --- /dev/null +++ b/backend/src/users/users.service.ts @@ -0,0 +1,48 @@ +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import type { User } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import type { CreateUserInput } from './user.types'; +import { toSafeUser } from './user.types'; + +@Injectable() +export class UsersService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + findByEmail(email: string) { + return this.prisma.user.findUnique({ + where: { email: email.trim().toLowerCase() } + }); + } + + findById(id: string) { + return this.prisma.user.findUnique({ + where: { id: this.parseId(id) } + }); + } + + async createUser(input: CreateUserInput) { + const user = await this.prisma.user.create({ + data: { + email: input.email.trim().toLowerCase(), + password_hash: input.password_hash, + nickname: input.nickname, + role: input.role ?? 'user', + status: 'active' + } + }); + + return toSafeUser(user); + } + + toSafeUser(user: User) { + return toSafeUser(user); + } + + private parseId(id: string) { + try { + return BigInt(id); + } catch { + throw new BadRequestException('Invalid user id'); + } + } +} diff --git a/backend/tsconfig.build.json b/backend/tsconfig.build.json new file mode 100644 index 0000000..f625323 --- /dev/null +++ b/backend/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "exclude": [ + "src/**/*.spec.ts", + "src/**/*.test.ts" + ] +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..6864dd9 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "module": "CommonJS", + "outDir": "dist", + "rootDir": "src", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "types": [ + "node" + ] + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..50aa454 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,57 @@ +# Deploy + +This directory is reserved for local dependencies, Docker, Nginx, PM2/systemd, backup, and release scripts. + +Stage 01 does not start infrastructure automatically. + +Planned services: + +- backend-api +- queue-worker +- ffmpeg-worker +- admin-web +- user-web +- mysql +- redis +- minio +- nginx + +## HTTPS / API transport + +Production API traffic must be served over HTTPS, even though JSON request and response bodies also use an application-layer encrypted envelope. The recommended layout is: + +- Nginx terminates TLS on `443`. +- Frontend bundles call same-origin `/api` by default. +- Nginx proxies `/api/` to `http://127.0.0.1:3000/api/`. +- Nginx sets `X-Forwarded-Proto: https`. +- Backend runs with `HTTPS_REQUIRED=true` and `TRUST_PROXY=true`. +- Browser clients negotiate short-lived API crypto sessions through `GET /api/crypto/handshake`. + +Example config: + +```text +deploy/nginx.https.example.conf +``` + +Production environment example: + +```bash +NODE_ENV=production +HTTPS_REQUIRED=true +HTTPS_ALLOW_LOCAL_HTTP=false +TRUST_PROXY=true +CORS_ORIGINS=https://manga.example.com,https://admin.manga.example.com +API_CRYPTO_ENABLED=auto +API_CRYPTO_SESSION_TTL_SECONDS=900 +VITE_API_CRYPTO_ENABLED=auto +VITE_API_BASE_URL=/api +``` + +For local development, keep `HTTPS_REQUIRED=false` or leave `HTTPS_ALLOW_LOCAL_HTTP=true` so `http://127.0.0.1:3000/api` continues to work. +API crypto is off by default through `security.api_crypto_enabled=false`; enable it from the admin settings page after production deployment. + +Notes: + +- The encrypted envelope covers JSON request bodies, JSON responses, error responses, encrypted novel upload payloads, and encrypted private asset download payloads. +- HTTP method, path, host, and query string remain transport metadata. Do not place sensitive content in query parameters. +- The API crypto session store is in memory. Use sticky sessions or move the session store to Redis before horizontal backend scaling. diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml new file mode 100644 index 0000000..25b3a91 --- /dev/null +++ b/deploy/docker-compose.dev.yml @@ -0,0 +1,33 @@ +services: + mysql: + image: mysql:8.0 + environment: + MYSQL_DATABASE: ai_manga + MYSQL_USER: ai_manga + MYSQL_PASSWORD: ai_manga_password + MYSQL_ROOT_PASSWORD: root_password + ports: + - "3306:3306" + volumes: + - mysql_data:/var/lib/mysql + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + + minio: + image: minio/minio:latest + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + ports: + - "9000:9000" + - "9001:9001" + volumes: + - minio_data:/data + +volumes: + mysql_data: + minio_data: diff --git a/deploy/nginx.https.example.conf b/deploy/nginx.https.example.conf new file mode 100644 index 0000000..c4c34cf --- /dev/null +++ b/deploy/nginx.https.example.conf @@ -0,0 +1,91 @@ +# Replace these placeholders before enabling: +# - manga.example.com +# - admin.manga.example.com +# - /etc/letsencrypt/live/... certificate paths +# - frontend dist paths if your release directory differs + +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 80; + server_name manga.example.com admin.manga.example.com; + + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + server_name manga.example.com; + + ssl_certificate /etc/letsencrypt/live/manga.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/manga.example.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers off; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "no-referrer" always; + + client_max_body_size 100m; + + root /www/wwwroot/ai/user-app/dist; + index index.html; + + location /api/ { + proxy_pass http://127.0.0.1:3000/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_read_timeout 300s; + } + + location / { + try_files $uri $uri/ /index.html; + } +} + +server { + listen 443 ssl http2; + server_name admin.manga.example.com; + + ssl_certificate /etc/letsencrypt/live/admin.manga.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/admin.manga.example.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers off; + + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "no-referrer" always; + + client_max_body_size 100m; + + root /www/wwwroot/ai/admin/dist; + index index.html; + + location /api/ { + proxy_pass http://127.0.0.1:3000/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_read_timeout 300s; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/docs/system_a/00_上下文难点清单_已接入设计.md b/docs/system_a/00_上下文难点清单_已接入设计.md new file mode 100755 index 0000000..5120e81 --- /dev/null +++ b/docs/system_a/00_上下文难点清单_已接入设计.md @@ -0,0 +1,96 @@ +# 00_上下文难点清单_已接入设计 + +## 1. 文档目的 + +本文件把前面讨论过的系统 A 关键难点全部列出来,并标明已经接入哪些文档和模块,避免后续开发时遗漏。 + +--- + +# 2. 已讨论难点总表 + +| 难点 | 风险 | 已接入设计位置 | +|---|---|---| +| AI 原创小说质量不稳定 | 剧情平淡、逻辑断裂、人物前后矛盾 | 13、12、09、10 | +| 上传小说版权风险 | 未授权改编、商业发布侵权 | 17、02、06 | +| 小说不能直接转漫剧 | 心理描写多、画面不可视化 | 13、14、09 | +| 角色一致性 | 变脸、换发型、换年龄、男女混脸 | 11、05、09、15 | +| 长篇剧情记忆 | 30-100 集后剧情忘记、伏笔丢失 | 12、05、09 | +| 分集节奏差 | 不像短视频、开头不抓人、结尾无钩子 | 13、14、10 | +| 分镜质量差 | 画面太抽象、单镜头动作太多、不可生成 | 14、13、10 | +| 画风漂移 | 第 1 集韩漫,第 2 集变写实 | 10、11、09 | +| 图片生成成本高 | 批量生成费用失控 | 16、15、09 | +| AI 视频成本高且不稳定 | 每镜头视频化成本高、动作不稳 | 14、16 | +| 配音字幕不同步 | 成品廉价、视频节奏差 | 14、09、20 | +| 批量生成失败恢复 | 中途失败导致项目废掉 | 15、05 | +| 人工审核缺失 | 崩图、违规内容、剧情错误直接交付 | 08、15、17 | +| 数据反馈缺失 | 不知道哪个题材/封面/剧情有效 | 18、05 | +| 与系统 B 重复开发 | 两套系统无法合并,成本高 | 22、04 | +| Provider 写死 | 模型变化后系统重构 | 09、04、21 | +| 最高模型成本 | 调试阶段费用暴涨 | 16、09、21 | +| Codex 一次性开发失控 | 生成大量半成品代码 | 21 | +| 长篇连载续写 | 角色成长线、伏笔、冲突线丢失 | 12 | +| 特效策略不清楚 | 不知道哪些用 FFmpeg、哪些用 AI 视频 | 14 | +| 封面标题弱 | 发布后点击率低 | 18、13 | +| 敏感内容审核 | 文本、图片、视频违规 | 17 | +| 音乐版权 | 商业交付 BGM 侵权 | 17、14 | +| 小说导入格式 | txt/docx/pdf/md 解析不稳 | 13、06 | +| 章节识别 | 章节标题混乱、广告水印 | 13、05 | +| 多角色场景 | 角色混乱,生成质量下降 | 11、14 | +| 打斗/复杂动作 | 连续动作不可控 | 14、13 | +| 全自动无人值守 | 质量和合规风险高 | 08、15、17 | + +--- + +# 3. 本版新增重点 + +相比 V1,本版新增或强化: + +```text +角色一致性专项设计 +长篇连载记忆专项设计 +小说上传解析质量标准 +分镜镜头质量标准 +视频合成和特效分级 +批量生成和数据反馈 +任务幂等和失败恢复 +成本/额度/重试控制 +版权授权和内容审核 +系统 A/B 合并架构 +Codex 分阶段开发规则 +``` + +--- + +# 4. 第一阶段不追求的事情 + +为了减少返工,第一阶段明确不做: + +```text +一键生成 100 集全自动发布 +每个镜头都 AI 视频化 +复杂多人打斗连续动画 +自动保证爆款 +自动绕过版权问题 +全无人审核 +多商户分销 +小说收费阅读平台 +IP 交易平台 +``` + +这些后续可以做,但不能塞进第一阶段 MVP。 + +--- + +# 5. 正确开发原则 + +```text +先让 1 部小说生成 3 集可用漫剧 +再让 1 部小说生成 10 集 +再做 20-100 集长篇连载 +先用图片 + FFmpeg 做视频 +再只在爆点镜头接 AI 视频 +先人工审核 +再逐步提高自动化比例 +先做 Provider mock +再接最高模型 +``` diff --git a/docs/system_a/01_系统A总需求文档_v2_生产级基线.md b/docs/system_a/01_系统A总需求文档_v2_生产级基线.md new file mode 100755 index 0000000..c0f73e1 --- /dev/null +++ b/docs/system_a/01_系统A总需求文档_v2_生产级基线.md @@ -0,0 +1,446 @@ +# 01_系统A总需求文档_v2_生产级基线 + +# 系统 A:原创小说 / 上传小说 → 韩漫 / 漫剧生成系统 + +## 1. 项目定位 + +系统 A 是一套 AI 内容生产系统,目标是将: + +```text +AI 原创小说 +用户上传小说 +后台导入授权小说 +``` + +自动转换成: + +```text +韩漫图集 +漫画分镜 +短视频漫剧 +连载剧集 +封面 +标题 +配音 +字幕 +BGM +发布素材包 +``` + +系统 A 不只是“生成几张图”,而是一个从文本故事到视觉内容的完整生产流水线。 + +--- + +## 2. 两种核心输入模式 + +### 2.1 AI 原创小说模式 + +用户选择题材和要求,系统自动生成完整故事。 + +用户输入: + +```text +题材 +目标受众 +主角设定 +故事情绪 +爽点类型 +预计章节数 +预计集数 +画风 +输出格式 +``` + +系统输出: + +```text +故事创意 +故事大纲 +故事圣经 +角色圣经 +章节正文 +分集计划 +单集脚本 +分镜脚本 +图片和视频 +``` + +适合: + +```text +批量测试题材 +快速孵化原创 IP +规避使用他人小说的版权风险 +做内容号初期素材 +``` + +--- + +### 2.2 上传小说改编模式 + +用户上传自己拥有合法权利的小说。 + +支持输入: + +```text +txt +docx +md +pdf +粘贴文本 +章节压缩包,后续 +后台批量导入,后续 +``` + +系统处理: + +```text +文件解析 +文本清洗 +章节识别 +版权确认 +故事结构分析 +角色提取 +章节改编 +分集生成 +分镜生成 +漫剧生成 +``` + +适合: + +```text +小说作者改编自己的作品 +工作室改编已授权小说 +内部测试小说内容 +公版作品二次创作,需用户确认权利 +``` + +--- + +## 3. 核心产品价值 + +```text +降低小说漫改成本 +缩短从文本到视频的周期 +保持角色和画风统一 +支持连续剧集生产 +支持封面标题生成 +支持短视频平台内容测试 +支持后续 IP 沉淀 +支持批量内容生产 +支持人机协同审核 +``` + +--- + +## 4. 第一阶段主打题材 + +第一阶段选择容易视觉化、角色少、冲突强的题材: + +```text +都市重生 +复仇逆袭 +霸总甜宠 +真假千金 +豪门虐恋 +校园暗恋 +赘婿打脸 +神医下山 +古风重生 +修仙爽文 +``` + +暂不主打: + +```text +复杂机甲 +大规模战争 +超多角色权谋 +硬科幻 +复杂群像 +连续多人打斗 +超长设定流玄幻 +``` + +--- + +## 5. 第一阶段 MVP 目标 + +MVP 不追求全自动百集,而是跑通两条闭环。 + +### 5.1 AI 原创小说闭环 + +```text +输入题材 +→ 生成故事创意 +→ 生成故事圣经 +→ 生成角色圣经 +→ 生成 3 集分集计划 +→ 生成每集分镜 +→ 生成每镜头图片 +→ 自动配音字幕 +→ 合成 3 个 MP4 +``` + +### 5.2 上传小说闭环 + +```text +上传小说 +→ 版权确认 +→ 解析章节 +→ 提取角色 +→ 生成故事圣经 +→ 生成 1-3 集分集计划 +→ 生成分镜 +→ 合成 MP4 +``` + +--- + +## 6. 长期生产级目标 + +```text +支持 20-100 集连续剧集 +支持长篇剧情记忆 +支持角色跨集一致 +支持批量队列生成 +支持人工审核和返工 +支持分集封面标题 +支持平台数据反馈 +支持多模型切换 +支持成本统计和额度控制 +支持系统 A 与系统 B 合并 +``` + +--- + +## 7. 系统核心对象 + +```text +NovelProject:小说漫剧项目 +NovelSource:小说来源 +NovelChapter:章节 +StoryBible:故事圣经 +WorldBible:世界观圣经 +CharacterBible:角色圣经 +CharacterAsset:角色素材 +Episode:分集 +EpisodeScript:单集脚本 +StoryboardShot:分镜镜头 +ShotImage:镜头图片 +AudioAsset:音频 +SubtitleAsset:字幕 +VideoAsset:视频 +RenderTask:生成任务 +PlotMemory:剧情记忆 +CharacterMemory:角色记忆 +ContinuityCheck:连续性检查 +``` + +--- + +## 8. 故事圣经 + +故事圣经用于保持全项目一致性。 + +必须包含: + +```text +故事一句话简介 +题材 +核心卖点 +主线目标 +核心冲突 +主要人物 +人物关系 +世界规则 +时间线 +重要伏笔 +禁用设定 +结局方向 +风格基调 +``` + +--- + +## 9. 角色圣经 + +角色圣经用于保持角色稳定。 + +必须包含: + +```text +姓名 +角色定位 +年龄段 +身份 +外貌 +发型 +服装规则 +性格 +说话风格 +人物关系 +人物弧光 +参考图 +锚点图 +表情图 +负面约束 +出场记录 +``` + +--- + +## 10. 长篇记忆 + +系统必须支持长篇连载记忆: + +```text +每章摘要 +每集摘要 +已发生事件 +未解决冲突 +伏笔 +人物关系变化 +角色成长线 +道具状态 +场景状态 +下一集衔接点 +``` + +长篇记忆不是后期补丁,第一版数据库和流程就要预留。 + +--- + +## 11. 改编原则 + +小说改编为漫剧时,不是照抄原文,而是转为: + +```text +画面 +动作 +台词 +旁白 +冲突 +转折 +钩子 +镜头 +特效 +``` + +每集结构建议: + +```text +0-3 秒:强冲突 +3-15 秒:交代矛盾 +15-40 秒:主角行动 +40-55 秒:反击/转折 +结尾:悬念钩子 +``` + +--- + +## 12. 视频形态 + +第一阶段采用: + +```text +韩漫图片 ++ FFmpeg 运镜 ++ 配音 ++ 字幕 ++ BGM ++ 闪白/震屏/转场 += 短视频漫剧 +``` + +高级阶段: + +```text +关键爆点镜头接 AI 图生视频 +``` + +不建议第一版每个镜头都 AI 视频化。 + +--- + +## 13. 人工审核策略 + +系统 A 必须支持人机协同,不做完全无人值守。 + +需要审核: + +```text +上传小说版权 +故事圣经 +角色锚点图 +分集计划 +关键分镜 +图片质量 +视频成品 +内容合规 +用户修改申请 +``` + +--- + +## 14. 与系统 B 的关系 + +系统 A 独立开发,但与系统 B 共享: + +```text +用户体系 +订单体系 +额度体系 +素材系统 +AI Provider +任务队列 +视频合成 +内容审核 +后台框架 +日志监控 +部署运维 +``` + +系统 A 独有: + +```text +小说管理 +故事圣经 +角色圣经 +分集计划 +分镜脚本 +长篇记忆 +连载发布 +剧情数据反馈 +``` + +系统 B 独有: + +```text +真人照片 +人像档案 +人生主题 +写真模板 +纪念视频模板 +``` + +--- + +## 15. 成功标准 + +系统 A MVP 验收标准: + +```text +能 AI 原创生成一篇短篇小说 +能上传一篇小说并解析章节 +能生成故事圣经和角色圣经 +能生成 3 个主要角色图 +能生成 3 集分集计划 +能生成每集 10-20 个分镜 +能生成分镜图片 +能生成配音字幕 +能合成 MP4 +后台可查看任务并重试失败 +用户可下载成品 +``` diff --git a/docs/system_a/02_业务流程_状态流转_权限设计.md b/docs/system_a/02_业务流程_状态流转_权限设计.md new file mode 100755 index 0000000..b981d5b --- /dev/null +++ b/docs/system_a/02_业务流程_状态流转_权限设计.md @@ -0,0 +1,300 @@ +# 02_业务流程_状态流转_权限设计 + +## 1. 用户完整流程 + +```text +游客访问首页 +→ 查看案例 +→ 登录注册 +→ 创建项目 +→ 选择输入模式 +→ AI 原创小说 / 上传小说 +→ 版权或原创声明 +→ 文本生成 / 文本解析 +→ 故事圣经生成 +→ 用户确认故事圣经 +→ 角色圣经生成 +→ 角色参考图生成 +→ 用户确认角色锚点 +→ 分集计划生成 +→ 用户确认分集 +→ 单集脚本生成 +→ 分镜生成 +→ 用户确认分镜 +→ 支付 / 冻结额度 +→ 生成预览图 +→ 用户确认预览 +→ 正式生成图片 +→ 图片质检 +→ 音频生成 +→ 字幕生成 +→ 视频合成 +→ 最终质检 +→ 人工审核 +→ 用户确认 +→ 下载交付 +→ 项目归档 +``` + +--- + +## 2. AI 原创小说流程 + +```text +创建项目 +→ 选择 AI 原创 +→ 选择题材 +→ 填写故事偏好 +→ 生成故事创意 +→ 生成故事大纲 +→ 生成章节大纲 +→ 生成正文 +→ 自检 +→ 故事圣经 +``` + +自检包括: + +```text +角色名是否一致 +主线是否明确 +冲突是否够强 +是否适合视觉化 +是否有敏感内容 +是否有明显抄袭风险 +``` + +--- + +## 3. 上传小说流程 + +```text +创建项目 +→ 选择上传小说 +→ 上传文件 / 粘贴文本 +→ 选择来源类型 +→ 勾选版权授权 +→ 文本提取 +→ 文本清洗 +→ 章节识别 +→ 角色提取 +→ 故事分析 +→ 生成故事圣经 +``` + +来源类型: + +```text +author_self:我是作者 +licensed:我已获得授权 +public_domain:公版文本 +internal_test:仅内部测试 +``` + +--- + +## 4. 项目主状态 + +```text +draft +source_selecting +novel_generating +novel_uploaded +copyright_pending +copyright_confirmed +text_parsing +text_parse_failed +story_analyzing +story_bible_generating +waiting_story_confirm +story_confirmed +character_extracting +character_generating +character_image_generating +waiting_character_confirm +character_confirmed +episode_planning +waiting_episode_confirm +episode_confirmed +script_generating +storyboard_generating +waiting_storyboard_confirm +storyboard_confirmed +payment_pending +payment_paid +preview_generating +waiting_preview_confirm +preview_confirmed +final_image_generating +image_qc +audio_generating +subtitle_generating +video_rendering +final_qc +manual_review +waiting_user_confirm +revision_requested +revising +completed +failed +cancelled +archived +``` + +--- + +## 5. 主流程状态流转 + +```text +draft +→ source_selecting +→ novel_generating / novel_uploaded +→ copyright_pending +→ copyright_confirmed +→ text_parsing +→ story_analyzing +→ story_bible_generating +→ waiting_story_confirm +→ story_confirmed +→ character_extracting +→ character_generating +→ character_image_generating +→ waiting_character_confirm +→ character_confirmed +→ episode_planning +→ waiting_episode_confirm +→ episode_confirmed +→ script_generating +→ storyboard_generating +→ waiting_storyboard_confirm +→ storyboard_confirmed +→ payment_pending +→ payment_paid +→ preview_generating +→ waiting_preview_confirm +→ preview_confirmed +→ final_image_generating +→ image_qc +→ audio_generating +→ subtitle_generating +→ video_rendering +→ final_qc +→ manual_review +→ waiting_user_confirm +→ completed +→ archived +``` + +--- + +## 6. 失败分支 + +```text +任意生成状态 +→ failed +→ 后台重试 / 转人工 / 取消 / 退款或返还额度 +``` + +任务失败不应该让整个项目不可恢复。 + +--- + +## 7. 修改分支 + +```text +waiting_story_confirm +→ 编辑故事圣经 +→ story_bible_generating / waiting_story_confirm + +waiting_character_confirm +→ 重生角色图 / 编辑角色 +→ waiting_character_confirm + +waiting_storyboard_confirm +→ 编辑分镜 / 重生分镜 +→ waiting_storyboard_confirm + +waiting_user_confirm +→ revision_requested +→ revising +→ final_image_generating / video_rendering / manual_review +→ waiting_user_confirm +``` + +--- + +## 8. 权限设计 + +### 游客 + +可访问: + +```text +首页 +案例 +套餐 +FAQ +登录注册 +``` + +不可访问: + +```text +创建项目 +上传小说 +生成小说 +下载成品 +``` + +### 登录用户 + +可访问: + +```text +创建项目 +上传小说 +AI 原创小说 +查看项目进度 +下载自己的成品 +申请修改 +删除自己的项目 +``` + +### 运营人员 + +可访问: + +```text +项目查看 +任务查看 +内容审核 +案例管理 +模板管理 +失败任务重试 +``` + +### 管理员 + +可访问: + +```text +所有功能 +AI Provider 配置 +成本配置 +系统配置 +权限配置 +日志审计 +``` + +--- + +## 9. 操作约束 + +```text +未确认版权,不能解析上传小说 +未确认角色锚点,不能批量生成正式图 +未确认分镜,不能正式生成 +未支付或未冻结额度,不能正式生成 +未通过审核,不能公开案例 +失败任务未处理,不能标记完成 +``` diff --git a/docs/system_a/03_功能清单_页面清单.md b/docs/system_a/03_功能清单_页面清单.md new file mode 100755 index 0000000..e8949fd --- /dev/null +++ b/docs/system_a/03_功能清单_页面清单.md @@ -0,0 +1,363 @@ +# 03_功能清单_页面清单 + +## 1. 用户端功能清单 + +```text +首页 +案例列表 +案例详情 +登录注册 +创建项目 +输入模式选择 +AI原创小说设置 +上传小说 +版权确认 +文本解析结果 +故事圣经确认 +角色圣经确认 +角色图确认 +分集计划确认 +单集脚本确认 +分镜确认 +支付/额度确认 +预览确认 +生成进度 +成品确认 +修改申请 +我的项目 +用户中心 +``` + +--- + +## 2. 首页 + +模块: + +```text +顶部 Banner +AI 原创小说入口 +上传小说改编入口 +热门漫剧案例 +热门题材 +生成流程说明 +套餐说明 +常见问题 +开始制作按钮 +``` + +按钮: + +```text +AI 原创小说 +上传小说 +查看案例 +选择题材 +查看套餐 +开始制作 +``` + +--- + +## 3. AI 原创小说设置页 + +字段: + +```text +题材 +主角性别 +主角身份 +主角性格 +女主/男主类型 +反派类型 +目标受众 +爽点类型 +情绪风格 +故事长度 +目标集数 +每集时长 +结局方向 +禁用内容 +``` + +按钮: + +```text +生成故事创意 +重新生成 +编辑创意 +确认创意 +``` + +--- + +## 4. 上传小说页 + +支持: + +```text +txt +docx +md +pdf +粘贴文本 +``` + +字段: + +```text +小说标题 +作者名 +来源类型 +文本语言 +是否连载中 +是否需要改名 +``` + +按钮: + +```text +上传文件 +粘贴文本 +确认上传 +进入版权确认 +``` + +--- + +## 5. 版权确认页 + +选项: + +```text +我是作者本人 +我已获得改编授权 +这是公版作品 +仅用于内部测试 +``` + +勾选: + +```text +我确认拥有该文本合法使用权 +我授权平台为本项目进行 AI 改编 +我理解未经授权不得商业发布 +``` + +--- + +## 6. 文本解析结果页 + +展示: + +```text +总字数 +章节数量 +章节列表 +疑似广告/水印 +主要角色候选 +场景候选 +敏感风险 +解析质量评分 +``` + +操作: + +```text +确认解析 +重新解析 +手动编辑章节 +删除无关文本 +返回重新上传 +``` + +--- + +## 7. 故事圣经确认页 + +展示: + +```text +故事标题 +一句话卖点 +主线 +核心冲突 +世界观 +人物关系 +爽点 +伏笔 +结局方向 +禁用规则 +``` + +操作: + +```text +确认 +编辑 +重新生成 +添加伏笔 +修改基调 +``` + +--- + +## 8. 角色圣经确认页 + +展示角色卡: + +```text +姓名 +角色定位 +身份 +年龄 +外貌 +发型 +服装 +性格 +关系 +说话风格 +角色弧光 +参考图 +锚点图 +``` + +操作: + +```text +编辑角色 +生成角色参考图 +重生角色图 +选择锚点图 +设置主角 +删除无关角色 +确认角色库 +``` + +--- + +## 9. 分集计划页 + +展示: + +```text +第几集 +标题 +剧情摘要 +开头钩子 +中段冲突 +结尾悬念 +出场角色 +关联章节 +预计时长 +``` + +操作: + +```text +确认分集 +编辑分集 +重生单集 +调整顺序 +拆分/合并集数 +``` + +--- + +## 10. 分镜确认页 + +展示每个镜头: + +```text +镜头号 +画面描述 +人物 +场景 +动作 +台词 +旁白 +镜头运动 +特效 +时长 +Prompt +负面 Prompt +生成状态 +``` + +操作: + +```text +编辑镜头 +新增镜头 +删除镜头 +调整顺序 +重生 Prompt +生成预览图 +确认分镜 +``` + +--- + +## 11. 生成进度页 + +节点: + +```text +故事分析 +角色生成 +分集生成 +分镜生成 +预览图 +正式图片 +图片质检 +音频 +字幕 +视频合成 +人工审核 +完成 +``` + +展示: + +```text +当前阶段 +进度百分比 +完成任务数 +失败任务数 +预计剩余 +失败原因 +联系客服 +``` + +--- + +## 12. 后台功能清单 + +```text +仪表盘 +用户管理 +项目管理 +小说源管理 +版权记录 +故事圣经管理 +角色库管理 +角色素材管理 +章节管理 +分集管理 +分镜管理 +任务管理 +素材管理 +内容审核 +案例管理 +题材模板管理 +风格模板管理 +Prompt模板管理 +视频模板管理 +音乐管理 +订单管理 +额度管理 +AI Provider管理 +成本日志 +系统配置 +操作日志 +``` diff --git a/docs/system_a/04_技术架构设计_模块拆分.md b/docs/system_a/04_技术架构设计_模块拆分.md new file mode 100755 index 0000000..85ef579 --- /dev/null +++ b/docs/system_a/04_技术架构设计_模块拆分.md @@ -0,0 +1,201 @@ +# 04_技术架构设计_模块拆分 + +## 1. 推荐技术栈 + +```text +用户端:uni-app +后台端:Geeker-Admin +后端:Node.js + NestJS +数据库:MySQL 8 +队列:Redis + BullMQ +对象存储:MinIO +视频合成:FFmpeg +AI辅助:Python Worker 可选 +进程管理:PM2 / systemd +反向代理:Nginx +容器化:Docker 可选 +``` + +--- + +## 2. 总体架构 + +```text +uni-app 用户端 + ↓ +NestJS API Gateway + ↓ +业务服务层 + ↓ +AI 编排层 / 任务队列层 + ↓ +TextProvider / ImageProvider / VoiceProvider / VideoProvider / ModerationProvider + ↓ +MySQL / Redis / MinIO / FFmpeg + ↓ +Geeker-Admin 后台管理 +``` + +--- + +## 3. 后端模块 + +```text +AuthModule +UserModule +ProjectModule +NovelModule +CopyrightModule +TextParseModule +StoryBibleModule +WorldBibleModule +CharacterModule +CharacterAssetModule +EpisodeModule +ScriptModule +StoryboardModule +PromptModule +AssetModule +TaskModule +ProviderModule +ImageModule +AudioModule +SubtitleModule +VideoModule +OrderModule +QuotaModule +ReviewModule +CaseModule +AnalyticsModule +SystemConfigModule +LogModule +``` + +--- + +## 4. 系统 A 独有模块 + +```text +NovelModule:小说生成/上传/解析 +StoryBibleModule:故事圣经 +WorldBibleModule:世界观规则 +CharacterModule:角色圣经 +EpisodeModule:分集计划 +ScriptModule:单集脚本 +StoryboardModule:分镜 +PlotMemoryModule:长篇记忆 +ContinuityModule:连续性检查 +``` + +--- + +## 5. 与系统 B 共用模块 + +```text +AuthModule +UserModule +OrderModule +QuotaModule +AssetModule +TaskModule +ProviderModule +ImageModule +AudioModule +SubtitleModule +VideoModule +ReviewModule +CaseModule +AnalyticsModule +SystemConfigModule +LogModule +``` + +--- + +## 6. AI Provider 抽象 + +Provider 不允许写死模型。 + +```text +TextProvider +NovelProvider +ImageProvider +VideoProvider +VoiceProvider +ModerationProvider +QualityCheckProvider +FileParseProvider +EmbeddingProvider +``` + +每个 Provider 需要支持: + +```text +mock模式 +真实模式 +fallback +限流 +成本记录 +失败重试 +请求日志 +``` + +--- + +## 7. Worker 队列 + +```text +novel_queue +parse_queue +story_queue +character_queue +script_queue +storyboard_queue +image_queue +audio_queue +subtitle_queue +video_queue +qc_queue +review_queue +analytics_queue +``` + +不同队列隔离,避免视频合成堵住文本和图片任务。 + +--- + +## 8. 存储策略 + +```text +原始小说文件:MinIO private bucket +清洗文本:数据库 LONGTEXT + 备份文件 +角色参考图:MinIO private +分镜图:MinIO private +视频成品:MinIO private +公开案例:单独 public 或 CDN bucket +``` + +原始小说和用户素材默认私有。 + +--- + +## 9. 事件驱动 + +系统内部关键事件: + +```text +ProjectCreated +NovelParsed +StoryBibleConfirmed +CharacterBibleConfirmed +EpisodesConfirmed +StoryboardConfirmed +PaymentPaid +PreviewConfirmed +ImageGenerated +VideoRendered +ProjectCompleted +RevisionRequested +``` + +可用于后续扩展数据统计、通知、运营分析。 diff --git a/docs/system_a/05_数据库表结构设计.md b/docs/system_a/05_数据库表结构设计.md new file mode 100755 index 0000000..afddbe9 --- /dev/null +++ b/docs/system_a/05_数据库表结构设计.md @@ -0,0 +1,489 @@ +# 05_数据库表结构设计 + +## 1. 核心表清单 + +```text +users +projects +novel_sources +novel_chapters +copyright_records +story_bibles +world_bibles +characters +character_images +character_memories +episodes +episode_scripts +storyboard_shots +shot_images +plot_memories +plot_threads +continuity_checks +assets +render_tasks +provider_configs +provider_logs +orders +quota_accounts +quota_logs +revision_requests +content_reviews +case_showcases +analytics_events +system_configs +operation_logs +``` + +--- + +## 2. projects + +```sql +id BIGINT PRIMARY KEY +user_id BIGINT NOT NULL +title VARCHAR(255) +input_mode VARCHAR(50) -- ai_original/upload/admin_import +genre VARCHAR(100) +style_code VARCHAR(100) +output_type VARCHAR(50) +target_episode_count INT +episode_duration INT +status VARCHAR(80) +copyright_status VARCHAR(80) +payment_status VARCHAR(80) +quality_level VARCHAR(50) +is_long_series BOOLEAN DEFAULT FALSE +created_at DATETIME +updated_at DATETIME +completed_at DATETIME +``` + +索引: + +```text +user_id,status +genre,status +created_at +``` + +--- + +## 3. novel_sources + +```sql +id BIGINT PRIMARY KEY +project_id BIGINT NOT NULL +source_type VARCHAR(50) +title VARCHAR(255) +author_name VARCHAR(100) +raw_asset_id BIGINT +raw_text LONGTEXT +clean_text LONGTEXT +word_count INT +chapter_count INT +parse_status VARCHAR(50) +parse_report JSON +created_at DATETIME +``` + +--- + +## 4. novel_chapters + +```sql +id BIGINT PRIMARY KEY +project_id BIGINT NOT NULL +novel_source_id BIGINT +chapter_no INT +title VARCHAR(255) +content LONGTEXT +summary TEXT +visual_summary TEXT +word_count INT +status VARCHAR(50) +created_at DATETIME +``` + +`visual_summary` 用于记录该章节可视化场景摘要。 + +--- + +## 5. copyright_records + +```sql +id BIGINT PRIMARY KEY +project_id BIGINT +user_id BIGINT +authorization_type VARCHAR(50) +statement_text TEXT +ip VARCHAR(80) +user_agent TEXT +confirmed_at DATETIME +``` + +authorization_type: + +```text +author_self +licensed +public_domain +internal_test +ai_original +``` + +--- + +## 6. story_bibles + +```sql +id BIGINT PRIMARY KEY +project_id BIGINT +title VARCHAR(255) +logline TEXT +main_plot TEXT +core_conflict TEXT +selling_points TEXT +tone VARCHAR(100) +world_summary TEXT +ending_direction TEXT +taboo_rules TEXT +version INT +status VARCHAR(50) +created_at DATETIME +updated_at DATETIME +``` + +--- + +## 7. world_bibles + +```sql +id BIGINT PRIMARY KEY +project_id BIGINT +world_type VARCHAR(100) +setting_text TEXT +rules_text TEXT +power_system TEXT +social_structure TEXT +time_period TEXT +visual_rules TEXT +forbidden_rules TEXT +status VARCHAR(50) +created_at DATETIME +``` + +--- + +## 8. characters + +```sql +id BIGINT PRIMARY KEY +project_id BIGINT +name VARCHAR(100) +role_type VARCHAR(50) +gender_label VARCHAR(50) +age_group VARCHAR(50) +identity_desc TEXT +appearance_desc TEXT +hair_desc TEXT +costume_rules TEXT +personality_desc TEXT +speech_style TEXT +relationship_desc TEXT +character_arc TEXT +negative_rules TEXT +anchor_asset_id BIGINT +importance_level INT +status VARCHAR(50) +created_at DATETIME +``` + +role_type: + +```text +lead_male +lead_female +villain +supporting +extra +``` + +--- + +## 9. character_images + +```sql +id BIGINT PRIMARY KEY +project_id BIGINT +character_id BIGINT +asset_id BIGINT +image_type VARCHAR(50) +prompt_text TEXT +negative_prompt TEXT +is_anchor BOOLEAN +quality_score DECIMAL(5,2) +status VARCHAR(50) +created_at DATETIME +``` + +image_type: + +```text +front_reference +side_reference +expression +costume +anchor +episode_variant +``` + +--- + +## 10. character_memories + +```sql +id BIGINT PRIMARY KEY +project_id BIGINT +character_id BIGINT +episode_id BIGINT +memory_type VARCHAR(50) +content TEXT +created_at DATETIME +``` + +memory_type: + +```text +appearance_state +relationship_state +emotion_state +goal_state +injury_state +costume_state +``` + +--- + +## 11. episodes + +```sql +id BIGINT PRIMARY KEY +project_id BIGINT +episode_no INT +source_chapter_ids JSON +title VARCHAR(255) +summary TEXT +opening_hook TEXT +middle_conflict TEXT +ending_hook TEXT +target_duration INT +status VARCHAR(50) +created_at DATETIME +``` + +--- + +## 12. episode_scripts + +```sql +id BIGINT PRIMARY KEY +project_id BIGINT +episode_id BIGINT +script_text LONGTEXT +narration_text LONGTEXT +dialogue_json JSON +version INT +status VARCHAR(50) +created_at DATETIME +``` + +--- + +## 13. storyboard_shots + +```sql +id BIGINT PRIMARY KEY +project_id BIGINT +episode_id BIGINT +shot_no INT +scene_name VARCHAR(255) +location_desc TEXT +characters_json JSON +visual_desc TEXT +action_desc TEXT +dialogue_text TEXT +narration_text TEXT +camera_motion VARCHAR(100) +effect_type VARCHAR(100) +duration DECIMAL(6,2) +prompt_text TEXT +negative_prompt TEXT +status VARCHAR(50) +created_at DATETIME +``` + +--- + +## 14. plot_memories + +```sql +id BIGINT PRIMARY KEY +project_id BIGINT +episode_id BIGINT +chapter_id BIGINT +memory_type VARCHAR(50) +content TEXT +importance_level INT +status VARCHAR(50) +created_at DATETIME +``` + +memory_type: + +```text +event +clue +foreshadowing +resolved_conflict +unresolved_conflict +relationship_change +world_rule +item_state +``` + +--- + +## 15. plot_threads + +```sql +id BIGINT PRIMARY KEY +project_id BIGINT +thread_name VARCHAR(255) +thread_type VARCHAR(80) +description TEXT +start_episode_no INT +expected_resolve_episode_no INT +resolved_episode_no INT +status VARCHAR(50) +created_at DATETIME +``` + +thread_type: + +```text +main_plot +romance +revenge +mystery +villain_plan +character_growth +``` + +--- + +## 16. continuity_checks + +```sql +id BIGINT PRIMARY KEY +project_id BIGINT +episode_id BIGINT +check_type VARCHAR(80) +result_status VARCHAR(50) +issue_text TEXT +suggestion_text TEXT +created_at DATETIME +``` + +check_type: + +```text +character_name +relationship +timeline +appearance +plot_thread +world_rule +``` + +--- + +## 17. assets + +```sql +id BIGINT PRIMARY KEY +user_id BIGINT +project_id BIGINT +asset_type VARCHAR(50) +file_path VARCHAR(500) +file_url VARCHAR(500) +mime_type VARCHAR(100) +width INT +height INT +duration DECIMAL(10,2) +size BIGINT +hash VARCHAR(128) +visibility VARCHAR(30) +status VARCHAR(50) +created_at DATETIME +``` + +--- + +## 18. render_tasks + +```sql +id BIGINT PRIMARY KEY +project_id BIGINT +episode_id BIGINT +shot_id BIGINT +task_type VARCHAR(80) +provider_id BIGINT +status VARCHAR(50) +input_json JSON +input_hash VARCHAR(128) +output_asset_id BIGINT +provider_request_id VARCHAR(255) +retry_count INT +max_retry INT +cost_estimate DECIMAL(12,4) +cost_actual DECIMAL(12,4) +error_code VARCHAR(100) +error_message TEXT +created_at DATETIME +started_at DATETIME +finished_at DATETIME +``` + +--- + +## 19. analytics_events + +```sql +id BIGINT PRIMARY KEY +project_id BIGINT +episode_id BIGINT +event_type VARCHAR(80) +platform VARCHAR(80) +metric_json JSON +created_at DATETIME +``` + +用于后续记录播放量、完播率、点赞率、评论反馈等。 + +--- + +## 20. 索引建议 + +```text +projects(user_id,status) +novel_chapters(project_id,chapter_no) +characters(project_id,role_type) +episodes(project_id,episode_no) +storyboard_shots(project_id,episode_id,shot_no) +plot_memories(project_id,episode_id) +plot_threads(project_id,status) +render_tasks(project_id,status) +render_tasks(task_type,status) +assets(project_id,asset_type) +``` diff --git a/docs/system_a/06_API接口设计文档.md b/docs/system_a/06_API接口设计文档.md new file mode 100755 index 0000000..ef170c6 --- /dev/null +++ b/docs/system_a/06_API接口设计文档.md @@ -0,0 +1,218 @@ +# 06_API接口设计文档 + +## 1. 通用返回格式 + +```json +{ + "code": 0, + "message": "success", + "data": {}, + "request_id": "req_xxx" +} +``` + +--- + +## 2. 认证接口 + +```text +POST /api/auth/register +POST /api/auth/login +POST /api/auth/logout +GET /api/auth/profile +``` + +--- + +## 3. 项目接口 + +```text +POST /api/projects +GET /api/projects +GET /api/projects/:id +PATCH /api/projects/:id +POST /api/projects/:id/cancel +DELETE /api/projects/:id +``` + +创建项目: + +```json +{ + "title": "重生归来,我不再爱你", + "input_mode": "ai_original", + "genre": "urban_rebirth", + "style_code": "korean_comic", + "output_type": "short_video", + "target_episode_count": 3, + "episode_duration": 60 +} +``` + +--- + +## 4. AI 原创小说接口 + +```text +POST /api/projects/:id/original/idea +POST /api/projects/:id/original/outline +POST /api/projects/:id/original/chapters +POST /api/projects/:id/original/self-check +GET /api/projects/:id/original/result +``` + +--- + +## 5. 上传小说接口 + +```text +POST /api/projects/:id/novel/upload +POST /api/projects/:id/novel/paste +POST /api/projects/:id/novel/parse +GET /api/projects/:id/novel/parse-result +PATCH /api/novel-chapters/:chapterId +``` + +--- + +## 6. 版权确认接口 + +```text +POST /api/projects/:id/copyright/confirm +GET /api/projects/:id/copyright +``` + +请求: + +```json +{ + "authorization_type": "author_self", + "statement_text": "我确认拥有该小说的合法改编权。" +} +``` + +--- + +## 7. 故事圣经接口 + +```text +POST /api/projects/:id/story-bible/generate +GET /api/projects/:id/story-bible +PATCH /api/projects/:id/story-bible +POST /api/projects/:id/story-bible/confirm +``` + +--- + +## 8. 角色接口 + +```text +POST /api/projects/:id/characters/extract +GET /api/projects/:id/characters +POST /api/projects/:id/characters +PATCH /api/characters/:characterId +DELETE /api/characters/:characterId +POST /api/characters/:characterId/generate-images +POST /api/characters/:characterId/set-anchor +GET /api/characters/:characterId/memories +``` + +--- + +## 9. 分集接口 + +```text +POST /api/projects/:id/episodes/generate-plan +GET /api/projects/:id/episodes +PATCH /api/episodes/:episodeId +POST /api/projects/:id/episodes/confirm +``` + +--- + +## 10. 脚本接口 + +```text +POST /api/episodes/:episodeId/script/generate +GET /api/episodes/:episodeId/script +PATCH /api/episodes/:episodeId/script +POST /api/episodes/:episodeId/script/confirm +``` + +--- + +## 11. 分镜接口 + +```text +POST /api/episodes/:episodeId/storyboard/generate +GET /api/episodes/:episodeId/storyboard +PATCH /api/storyboard-shots/:shotId +DELETE /api/storyboard-shots/:shotId +POST /api/episodes/:episodeId/storyboard/confirm +POST /api/storyboard-shots/:shotId/regenerate-prompt +``` + +--- + +## 12. 长篇记忆接口 + +```text +GET /api/projects/:id/plot-memories +POST /api/projects/:id/plot-memories/generate +GET /api/projects/:id/plot-threads +POST /api/projects/:id/plot-threads +PATCH /api/plot-threads/:threadId +POST /api/episodes/:episodeId/continuity-check +``` + +--- + +## 13. 生成接口 + +```text +POST /api/episodes/:episodeId/preview/generate +POST /api/episodes/:episodeId/final/generate-images +POST /api/episodes/:episodeId/audio/generate +POST /api/episodes/:episodeId/subtitle/generate +POST /api/episodes/:episodeId/video/render +GET /api/projects/:id/progress +GET /api/episodes/:episodeId/assets +``` + +--- + +## 14. 修改申请接口 + +```text +POST /api/projects/:id/revisions +GET /api/projects/:id/revisions +``` + +--- + +## 15. 后台接口 + +```text +GET /api/admin/projects +GET /api/admin/projects/:id +POST /api/admin/tasks/:id/retry +POST /api/admin/tasks/:id/cancel +POST /api/admin/tasks/:id/manual-required +GET /api/admin/tasks +GET /api/admin/provider-logs +GET /api/admin/costs +POST /api/admin/projects/:id/manual-review +``` + +--- + +## 16. 案例和数据接口 + +```text +GET /api/cases +GET /api/cases/:id +POST /api/admin/cases +PATCH /api/admin/cases/:id +POST /api/projects/:id/analytics/import +GET /api/projects/:id/analytics +``` diff --git a/docs/system_a/07_uniapp用户端页面交互文档.md b/docs/system_a/07_uniapp用户端页面交互文档.md new file mode 100755 index 0000000..7064b79 --- /dev/null +++ b/docs/system_a/07_uniapp用户端页面交互文档.md @@ -0,0 +1,174 @@ +# 07_uniapp用户端页面交互文档 + +## 1. 页面路由建议 + +```text +/pages/index/index +/pages/cases/list +/pages/cases/detail +/pages/auth/login +/pages/projects/create +/pages/projects/source-select +/pages/projects/original-setting +/pages/projects/upload-novel +/pages/projects/copyright +/pages/projects/parse-result +/pages/projects/story-bible +/pages/projects/characters +/pages/projects/episodes +/pages/projects/script +/pages/projects/storyboard +/pages/projects/payment +/pages/projects/preview +/pages/projects/progress +/pages/projects/result +/pages/projects/revision +/pages/user/projects +/pages/user/profile +``` + +--- + +## 2. 页面流程 + +```text +首页 +→ 登录 +→ 创建项目 +→ 选择 AI 原创 / 上传小说 +→ 版权或原创声明 +→ 故事圣经 +→ 角色圣经 +→ 分集计划 +→ 分镜 +→ 支付/额度 +→ 预览 +→ 正式生成 +→ 成品下载 +``` + +--- + +## 3. 项目恢复 + +用户中途退出后,从“我的项目”继续。 + +每个项目需要保存: + +```text +当前状态 +已填写数据 +已生成结果 +待确认步骤 +失败任务 +下一步按钮 +``` + +--- + +## 4. 角色确认交互 + +角色卡必须支持: + +```text +编辑文字设定 +生成参考图 +查看多张候选图 +选择锚点图 +锁定角色 +重生 +删除低价值配角 +``` + +未锁定主角锚点图,不允许批量正式生成。 + +--- + +## 5. 分镜确认交互 + +分镜卡片展示: + +```text +镜头画面 +人物 +场景 +台词 +时长 +运镜 +特效 +Prompt +预览图 +``` + +允许: + +```text +编辑 +删除 +新增 +重排 +重生 Prompt +生成预览图 +``` + +--- + +## 6. 长篇连载交互 + +当项目集数超过 10 集时,显示“剧情记忆”入口: + +```text +主线进度 +人物关系变化 +伏笔列表 +未解决冲突 +下一集衔接 +``` + +用户可查看但普通用户不建议直接大量编辑,避免破坏结构。 + +--- + +## 7. 生成进度交互 + +必须显示: + +```text +当前阶段 +任务进度 +失败数量 +预计剩余 +后台处理中提示 +联系客服 +``` + +如果某任务进入人工处理: + +```text +显示“需要人工审核,不影响已完成内容” +``` + +--- + +## 8. 成品页交互 + +展示: + +```text +视频播放器 +封面图 +图片包 +字幕文件 +旁白音频 +下载按钮 +申请修改 +授权公开案例 +``` + +修改申请需要区分: + +```text +小改:字幕、标题、BGM +中改:部分镜头、图片 +大改:重新分集、重做风格、重做角色 +``` diff --git a/docs/system_a/08_GeekerAdmin后台管理设计.md b/docs/system_a/08_GeekerAdmin后台管理设计.md new file mode 100755 index 0000000..82f17c4 --- /dev/null +++ b/docs/system_a/08_GeekerAdmin后台管理设计.md @@ -0,0 +1,249 @@ +# 08_GeekerAdmin后台管理设计 + +## 1. 后台菜单 + +```text +仪表盘 +用户管理 +项目管理 +小说源管理 +版权记录 +故事圣经 +世界观圣经 +角色库 +角色素材 +章节管理 +分集管理 +分镜管理 +剧情记忆 +连续性检查 +素材管理 +生成任务 +内容审核 +案例管理 +订单管理 +额度管理 +模板管理 +AI Provider +成本日志 +数据反馈 +系统配置 +操作日志 +``` + +--- + +## 2. 仪表盘 + +指标: + +```text +今日项目数 +今日生成集数 +今日完成视频 +失败任务数 +待人工审核数 +AI 成本 +平均单集成本 +热门题材 +热门风格 +角色生成失败率 +视频合成失败率 +队列积压数 +``` + +--- + +## 3. 项目管理 + +操作: + +```text +查看项目详情 +查看小说原文 +查看清洗文本 +查看故事圣经 +查看角色库 +查看分集 +查看分镜 +查看素材 +查看任务 +手动重试 +转人工审核 +取消项目 +导出素材 +``` + +--- + +## 4. 小说源管理 + +操作: + +```text +查看原始文件 +查看解析文本 +查看章节列表 +重新解析 +手动编辑章节 +删除广告水印 +标记文本异常 +``` + +--- + +## 5. 角色库管理 + +操作: + +```text +编辑角色设定 +生成角色参考图 +选择锚点图 +锁定角色 +查看角色跨集出场 +查看角色记忆 +重生角色图 +``` + +--- + +## 6. 分镜管理 + +操作: + +```text +查看镜头 +编辑画面描述 +编辑台词 +编辑 Prompt +重生单镜头图片 +替换图片 +调整顺序 +标记不合格 +``` + +--- + +## 7. 剧情记忆管理 + +展示: + +```text +主线 +支线 +伏笔 +未解决冲突 +已解决冲突 +角色关系变化 +重要道具状态 +世界规则 +``` + +操作: + +```text +新增记忆 +编辑记忆 +标记已解决 +关联分集 +执行连续性检查 +``` + +--- + +## 8. 任务管理 + +字段: + +```text +task_id +project_id +episode_id +shot_id +task_type +provider +status +retry_count +cost +error_message +created_at +finished_at +``` + +操作: + +```text +查看输入 +查看输出 +重试 +取消 +跳过 +转人工 +复制 Prompt +查看 Provider 日志 +``` + +--- + +## 9. 内容审核 + +审核对象: + +```text +小说文本 +故事圣经 +角色设定 +分集脚本 +分镜脚本 +图片 +视频 +封面 +标题 +字幕 +旁白 +``` + +审核操作: + +```text +通过 +驳回 +要求修改 +屏蔽 +转人工 +``` + +--- + +## 10. 模板管理 + +```text +题材模板 +画风模板 +角色模板 +场景模板 +镜头模板 +Prompt模板 +视频模板 +BGM模板 +字幕样式 +套餐模板 +``` + +--- + +## 11. 数据反馈 + +用于后续优化: + +```text +导入播放量 +导入完播率 +导入点赞评论 +记录封面标题 +记录题材表现 +记录每集转化 +``` + +后续可让系统根据数据优化新项目。 diff --git a/docs/system_a/09_AI生成流水线_Provider抽象设计.md b/docs/system_a/09_AI生成流水线_Provider抽象设计.md new file mode 100755 index 0000000..6169cd9 --- /dev/null +++ b/docs/system_a/09_AI生成流水线_Provider抽象设计.md @@ -0,0 +1,182 @@ +# 09_AI生成流水线_Provider抽象设计 + +## 1. Provider 类型 + +```text +TextProvider:故事、脚本、分镜、标题 +NovelProvider:原创小说生成 +ImageProvider:角色图、分镜图、封面图 +VideoProvider:关键镜头图生视频 +VoiceProvider:TTS +ModerationProvider:内容审核 +QualityCheckProvider:图片/视频质检 +FileParseProvider:文件解析 +EmbeddingProvider:长篇记忆检索 +``` + +--- + +## 2. Provider 必备能力 + +```text +mock 实现 +真实实现 +fallback +限流 +重试 +成本记录 +请求日志 +失败日志 +输入输出保存 +``` + +开发阶段全部先用 mock,流程跑通后再接真实最高模型。 + +--- + +## 3. AI 原创小说流水线 + +```text +用户设定 +→ 故事创意 +→ 故事大纲 +→ 故事圣经 +→ 角色圣经 +→ 章节大纲 +→ 正文生成 +→ 自检 +→ 章节摘要 +→ 改编准备 +``` + +--- + +## 4. 上传小说流水线 + +```text +文件上传 +→ 文本提取 +→ 清洗 +→ 章节识别 +→ 章节摘要 +→ 角色抽取 +→ 场景抽取 +→ 世界观抽取 +→ 故事圣经 +→ 改编准备 +``` + +--- + +## 5. 改编流水线 + +```text +故事圣经 ++ 角色圣经 ++ 剧情记忆 ++ 章节内容 +→ 分集计划 +→ 单集脚本 +→ 分镜脚本 +→ 镜头 Prompt +→ 图片生成 +→ 质检 +→ 配音 +→ 字幕 +→ 视频合成 +``` + +--- + +## 6. 长篇记忆参与生成 + +生成第 N 集时输入: + +```text +故事圣经 +角色圣经 +世界观规则 +前 3 集摘要 +当前相关章节摘要 +未解决伏笔 +角色当前状态 +上一集结尾钩子 +本集目标 +``` + +避免每次把全文塞给模型。 + +--- + +## 7. Prompt 分层 + +```text +BaseStyle:画风 +ProjectWorld:世界观 +Character:角色 +Scene:场景 +Shot:镜头 +Emotion:情绪 +Camera:运镜 +Effect:特效 +Quality:质量 +Negative:禁止项 +``` + +--- + +## 8. 图片生成顺序 + +```text +角色立绘 +→ 角色锚点图 +→ 场景锚点图 +→ 封面图 +→ 分镜预览图 +→ 正式分镜图 +``` + +--- + +## 9. 视频生成顺序 + +```text +镜头图片 +→ 图片质检 +→ 分配时长 +→ 生成字幕 +→ 生成旁白 +→ 选择BGM +→ FFmpeg运镜 +→ 合成MP4 +→ 最终质检 +``` + +--- + +## 10. 质检 Provider + +自动检查: + +```text +角色一致性 +画风一致性 +人脸崩坏 +手部异常 +多余人物 +文字乱码 +敏感内容 +分辨率 +视频可播放 +字幕是否溢出 +音画是否同步 +``` + +质检不通过: + +```text +自动重试 +转人工 +降低镜头复杂度 +替换模板 +``` diff --git a/docs/system_a/10_Prompt模板_题材世界观_韩漫规范.md b/docs/system_a/10_Prompt模板_题材世界观_韩漫规范.md new file mode 100755 index 0000000..d618b5a --- /dev/null +++ b/docs/system_a/10_Prompt模板_题材世界观_韩漫规范.md @@ -0,0 +1,182 @@ +# 10_Prompt模板_题材世界观_韩漫规范 + +## 1. Prompt 总原则 + +Prompt 必须模板化、版本化、可审计。 + +禁止: + +```text +每次自由拼接 +把整章小说直接塞进图片 Prompt +角色描述不固定 +画风描述每集变化 +负面约束缺失 +``` + +--- + +## 2. 标准 Prompt 结构 + +```text +[画风层] +[角色层] +[世界观层] +[场景层] +[镜头层] +[动作层] +[情绪层] +[光影层] +[质量层] +[负面约束层] +``` + +--- + +## 3. 韩漫风基础模板 + +```text +高质量韩漫风格,精致人物五官,干净线条,细腻光影,商业漫画质感,竖屏构图,人物表情清晰,背景细节完整,色彩统一,高级画面质感 +``` + +负面: + +```text +低质量,脸部崩坏,五官变形,手指异常,多余人物,文字乱码,风格漂移,过度欧美化,年龄变化,角色换脸,服装错误 +``` + +--- + +## 4. 都市重生模板 + +世界关键词: + +```text +现代都市 +医院病房 +豪门别墅 +公司办公室 +雨夜街头 +冷色电影感 +复仇感 +命运重启 +强烈冲突 +``` + +常用镜头: + +```text +病床惊醒 +离婚协议特写 +雨夜背影 +办公室对峙 +反派震惊 +主角眼神变冷 +``` + +--- + +## 5. 霸总甜宠模板 + +关键词: + +```text +高级办公室 +豪车 +晚宴 +西装 +礼服 +落地窗 +城市夜景 +浪漫光影 +强烈对视 +情绪拉扯 +``` + +--- + +## 6. 真假千金模板 + +关键词: + +```text +豪门客厅 +亲子鉴定报告 +晚宴 +真假身份 +众人震惊 +被赶出家门 +华丽逆袭 +``` + +--- + +## 7. 修仙爽文模板 + +关键词: + +```text +仙山云海 +宗门大殿 +剑光 +灵气 +法阵 +古风长袍 +金色光效 +史诗氛围 +强者归来 +``` + +--- + +## 8. 镜头模板 + +### 8.1 开局强冲突 + +```text +主角处于画面中心,周围人露出嘲讽或震惊表情,强烈压迫感构图,背景暗色,韩漫风,竖屏 +``` + +### 8.2 反派震惊 + +```text +反派面部特写,眼睛睁大,表情震惊,背景速度线,漫画冲击效果,强烈情绪张力 +``` + +### 8.3 主角觉醒 + +```text +主角眼神冷静坚定,身后光芒爆发,衣摆微动,低角度构图,强烈逆袭感,韩漫风 +``` + +### 8.4 结尾钩子 + +```text +神秘人物站在阴影中,只露出半张脸,手中拿着关键物品,氛围悬疑,画面留白,适合下一集悬念 +``` + +--- + +## 9. 角色固定片段 + +每个角色 Prompt 必须带: + +```text +固定年龄段 +固定脸型 +固定发型 +固定气质 +固定身份 +固定服装范围 +禁止改变发色 +禁止改变年龄 +禁止变成其他角色 +``` + +--- + +## 10. 分镜 Prompt 示例 + +```text +高质量韩漫风,现代都市医院病房,年轻男主从病床猛然坐起,额头冒汗,眼神震惊,短黑发,病号服,窗外雨夜,冷色电影光影,近景构图,强烈重生感,人物五官精致,背景清晰,竖屏9:16 +``` diff --git a/docs/system_a/11_角色一致性专项设计.md b/docs/system_a/11_角色一致性专项设计.md new file mode 100755 index 0000000..dc2c28a --- /dev/null +++ b/docs/system_a/11_角色一致性专项设计.md @@ -0,0 +1,205 @@ +# 11_角色一致性专项设计 + +## 1. 为什么必须专项设计 + +系统 A 最大质量风险是角色不一致: + +```text +第一集男主短黑发 +第二集变棕发 +第三集脸型变了 +女主和女配混脸 +反派年龄漂移 +同一角色服装无规则变化 +``` + +所以角色一致性必须作为核心模块,而不是 Prompt 里的几句话。 + +--- + +## 2. 角色生产流程 + +```text +角色提取 +→ 角色资料生成 +→ 人工/自动确认角色 +→ 角色立绘生成 +→ 多张候选图 +→ 选择锚点图 +→ 生成表情图 +→ 生成服装规则 +→ 锁定角色 +→ 分镜图引用角色锚点 +``` + +--- + +## 3. 角色圣经字段 + +```text +姓名 +别名 +角色定位 +性别 +年龄段 +身份 +脸型 +发型 +眼睛 +气质 +身材 +常用服装 +特殊道具 +说话风格 +人物关系 +情绪基调 +人物弧光 +禁止变化项 +参考图 +锚点图 +出场集数 +当前状态 +``` + +--- + +## 4. 角色图类型 + +```text +front_reference:正面立绘 +side_reference:侧脸 +expression_pack:表情包 +costume_default:默认服装 +costume_special:特殊服装 +anchor:锚点图 +scene_variant:场景变体 +``` + +--- + +## 5. 锚点图规则 + +主角必须有锚点图。 + +建议数量: + +```text +男主:1-3 张锚点 +女主:1-3 张锚点 +反派:1 张锚点 +重要配角:1 张锚点 +普通配角:可选 +``` + +锚点图需要人工确认或后台审核。 + +--- + +## 6. 角色锁定机制 + +角色确认后进入 locked 状态。 + +locked 后: + +```text +不能随意改姓名 +不能改年龄段 +不能改核心外貌 +不能改身份 +可以新增服装变体 +可以新增表情图 +可以根据剧情更新状态 +``` + +--- + +## 7. 跨集角色状态 + +角色状态随剧情变化,但要记录: + +```text +当前服装 +是否受伤 +情绪状态 +关系状态 +是否隐藏身份 +是否觉醒能力 +持有什么道具 +``` + +例如: + +```text +第5集男主被打伤,第6集脸上应有轻微伤痕。 +第8集女主知道真相,第9集对男主态度变化。 +``` + +--- + +## 8. 防混脸策略 + +系统生成分镜时必须传入: + +```text +本镜头出现角色列表 +每个角色固定描述 +每个角色锚点图 +角色之间差异化描述 +禁止角色混合 +``` + +多角色镜头尽量控制在 2-3 人,避免一次生成太多人。 + +--- + +## 9. 角色一致性质检 + +自动检查: + +```text +脸型是否明显变化 +发型是否明显变化 +年龄是否漂移 +服装是否不符合规则 +男女是否混脸 +角色是否缺失 +多出不相关人物 +``` + +不合格处理: + +```text +自动重生 +降低镜头复杂度 +拆成多个镜头 +转人工 +``` + +--- + +## 10. 长篇角色成长 + +角色不是永远不变,而是有受控变化。 + +允许变化: + +```text +服装 +表情 +情绪 +轻微伤痕 +身份暴露后的气质 +修仙升级后的气场 +``` + +禁止变化: + +```text +脸型大变 +年龄大变 +发色无原因变化 +身份无原因变化 +人物关系无记录变化 +``` + +所有允许变化都要记录到 `character_memories`。 diff --git a/docs/system_a/12_长篇连载记忆_剧情一致性专项设计.md b/docs/system_a/12_长篇连载记忆_剧情一致性专项设计.md new file mode 100755 index 0000000..4090458 --- /dev/null +++ b/docs/system_a/12_长篇连载记忆_剧情一致性专项设计.md @@ -0,0 +1,231 @@ +# 12_长篇连载记忆_剧情一致性专项设计 + +## 1. 为什么必须设计长篇记忆 + +系统 A 如果只做 3 集,简单摘要够用。 +如果要做 20-100 集,必须有长篇记忆,否则会出现: + +```text +角色名字变了 +人物关系忘了 +伏笔丢了 +反派计划断了 +前面发生的事后面不承认 +道具状态错乱 +世界规则前后矛盾 +``` + +--- + +## 2. 长篇记忆核心对象 + +```text +StoryBible:全局故事规则 +WorldBible:世界规则 +CharacterBible:角色固定资料 +CharacterMemory:角色当前状态 +EpisodeSummary:每集摘要 +PlotMemory:关键事件 +PlotThread:剧情线 +ContinuityCheck:连续性检查 +``` + +--- + +## 3. 每集生成前必须输入 + +生成第 N 集时,模型输入不应是全文,而是: + +```text +故事圣经 +世界观规则 +主要角色圣经 +当前相关角色状态 +前 3 集摘要 +第 N-1 集结尾 +当前章节摘要 +未解决剧情线 +需要推进的伏笔 +本集目标 +本集禁用事项 +``` + +--- + +## 4. 每集生成后必须沉淀 + +每集完成后生成: + +```text +本集摘要 +新增事件 +角色关系变化 +新增伏笔 +解决的伏笔 +未解决冲突 +重要道具状态 +下一集衔接点 +人物状态变化 +``` + +写入: + +```text +plot_memories +plot_threads +character_memories +episodes.summary +``` + +--- + +## 5. PlotThread 剧情线 + +剧情线类型: + +```text +main_plot:主线 +romance:感情线 +revenge:复仇线 +mystery:悬疑线 +villain_plan:反派计划 +character_growth:人物成长 +world_secret:世界秘密 +``` + +每条线记录: + +```text +起始集 +目标 +当前进度 +关联角色 +预计解决集 +实际解决集 +状态 +``` + +状态: + +```text +open +progressing +paused +resolved +abandoned +``` + +--- + +## 6. 伏笔管理 + +伏笔必须记录: + +```text +伏笔内容 +出现集数 +关联角色 +预计回收集数 +是否已回收 +回收方式 +``` + +示例: + +```text +第2集出现神秘玉佩 +第8集揭示玉佩是女主母亲留下的线索 +第15集成为打开密室的钥匙 +``` + +--- + +## 7. 连续性检查 + +每集脚本生成后执行检查: + +```text +角色名字一致 +人物关系一致 +时间线一致 +道具状态一致 +世界规则一致 +伏笔没有冲突 +上一集钩子被承接 +本集结尾有新钩子 +``` + +检查结果: + +```text +pass +warning +fail +``` + +fail 必须重新生成或人工修改。 + +--- + +## 8. 长篇分批策略 + +20 集以内: + +```text +整部故事大纲 + 每集摘要即可 +``` + +20-100 集: + +```text +分卷管理 +每卷 10-20 集 +每卷有阶段目标 +每集有独立钩子 +每 5 集有小高潮 +每 10-20 集有大转折 +``` + +--- + +## 9. 分卷设计 + +长篇项目需要: + +```text +Volume 1:开局冲突和身份建立 +Volume 2:主角第一次反击 +Volume 3:反派升级 +Volume 4:真相揭露 +Volume 5:最终反击 +``` + +每卷字段: + +```text +卷标题 +卷目标 +主要冲突 +核心角色 +高潮集 +结尾钩子 +``` + +--- + +## 10. 长篇生成规则 + +禁止一次性生成 100 集完整脚本。 + +正确方式: + +```text +先生成全局大纲 +再生成分卷大纲 +再生成 5 集批次计划 +再生成单集脚本 +再生成单集分镜 +每集完成后更新记忆 +``` + +这样能减少后期重构和剧情崩坏。 diff --git a/docs/system_a/13_小说生成_上传解析_改编质量标准.md b/docs/system_a/13_小说生成_上传解析_改编质量标准.md new file mode 100755 index 0000000..ccd2455 --- /dev/null +++ b/docs/system_a/13_小说生成_上传解析_改编质量标准.md @@ -0,0 +1,216 @@ +# 13_小说生成_上传解析_改编质量标准 + +## 1. AI 原创小说质量标准 + +原创小说必须满足: + +```text +主线清晰 +冲突明确 +角色数量可控 +场景可视化 +每章有转折 +每章有钩子 +适合短视频改编 +无明显逻辑断裂 +无敏感违规内容 +``` + +--- + +## 2. 原创小说生成阶段 + +```text +创意 +→ 大纲 +→ 故事圣经 +→ 角色圣经 +→ 分章大纲 +→ 正文 +→ 自检 +``` + +不要直接从一句话生成完整长篇正文。 + +--- + +## 3. 上传小说解析标准 + +支持: + +```text +txt +docx +md +pdf,第一阶段可限制为文本型 PDF +粘贴文本 +``` + +清洗内容: + +```text +广告 +水印 +页眉页脚 +重复章节标题 +乱码 +多余空行 +无关说明 +``` + +--- + +## 4. 章节识别规则 + +识别: + +```text +第1章 +第一章 +Chapter 1 +001 +楔子 +序章 +番外 +正文 +``` + +如果章节识别失败: + +```text +按字数切分 +提示用户手动确认 +后台可编辑章节 +``` + +--- + +## 5. 小说改编原则 + +小说原文常见问题: + +```text +心理描写多 +旁白多 +动作少 +场景不明确 +对话太长 +节奏慢 +``` + +改编时必须转成: + +```text +可视化画面 +明确动作 +短台词 +强冲突 +镜头切换 +结尾钩子 +``` + +--- + +## 6. 每集标准结构 + +45-60 秒: + +```text +0-3 秒:强冲突 +3-12 秒:交代处境 +12-30 秒:矛盾升级 +30-45 秒:主角行动 +45-55 秒:反转 +55-60 秒:悬念钩子 +``` + +90 秒: + +```text +0-5 秒:强钩子 +5-25 秒:背景 +25-55 秒:冲突 +55-75 秒:反击 +75-90 秒:新危机 +``` + +--- + +## 7. 分镜质量标准 + +每个镜头必须有: + +```text +一个画面中心 +一个主要动作 +一个情绪点 +一段短台词或旁白 +2-5 秒时长 +明确人物 +明确场景 +明确构图 +``` + +避免: + +```text +一个镜头包含连续动作 +一个镜头超过 4 个主要人物 +抽象心理活动 +无画面对象 +场景模糊 +台词太长 +``` + +--- + +## 8. 不可视化内容改写 + +原文: + +```text +他心里像被刀割一样。 +``` + +改写: + +```text +画面:男主站在雨中,望着女主离开的背影,拳头攥紧。 +台词:你真的一点机会都不给我吗? +``` + +--- + +## 9. 改编保真度 + +上传小说改编需要平衡: + +```text +保留核心剧情 +保留人物关系 +保留名场面 +压缩旁白 +加快节奏 +增强冲突 +减少不可视化内容 +``` + +不能擅自改掉主线和人物关系。 + +--- + +## 10. 质量评分 + +每集生成后评分: + +```text +开头钩子:0-10 +冲突强度:0-10 +视觉化程度:0-10 +台词简洁度:0-10 +结尾悬念:0-10 +角色一致性:0-10 +剧情连续性:0-10 +``` + +低于阈值进入重生或人工调整。 diff --git a/docs/system_a/14_分镜镜头_视频合成_特效设计.md b/docs/system_a/14_分镜镜头_视频合成_特效设计.md new file mode 100755 index 0000000..d6c2a29 --- /dev/null +++ b/docs/system_a/14_分镜镜头_视频合成_特效设计.md @@ -0,0 +1,195 @@ +# 14_分镜镜头_视频合成_特效设计 + +## 1. 漫剧视频形态 + +第一阶段视频采用: + +```text +静态韩漫图片 ++ FFmpeg 运镜 ++ 字幕 ++ AI 配音 ++ BGM ++ 音效 ++ 转场 += MP4 漫剧 +``` + +这是最稳的商业 MVP 方案。 + +--- + +## 2. 镜头类型 + +```text +人物特写 +双人对峙 +远景环境 +道具特写 +回忆镜头 +反派压迫 +主角觉醒 +群体震惊 +结尾悬念 +``` + +--- + +## 3. 运镜类型 + +```text +zoom_in:慢慢推近 +zoom_out:慢慢拉远 +pan_left:左移 +pan_right:右移 +shake:震屏 +flash:闪白 +fade:淡入淡出 +cut:硬切 +blur_memory:回忆模糊 +``` + +--- + +## 4. 特效分级 + +### L1:FFmpeg 基础特效 + +```text +推近 +拉远 +平移 +震屏 +闪白 +黑场 +字幕弹出 +转场 +``` + +低成本,第一阶段主力。 + +### L2:素材叠加特效 + +```text +花瓣 +雨雪 +火光 +光粒子 +速度线 +红绸 +雷电贴片 +``` + +可用透明视频素材或后期叠加。 + +### L3:AI 图生视频特效 + +```text +火焰动起来 +雷电闪烁 +衣摆飘动 +头发飘动 +角色转头 +光效爆发 +``` + +只用于关键镜头。 + +### L4:高级视频生成 + +```text +复杂打斗 +镜头环绕 +追车爆炸 +多人动作 +``` + +第一阶段不做常规能力,仅高端套餐后续考虑。 + +--- + +## 5. 推荐比例 + +```text +80% L1 静态图 + 运镜 +15% L2 轻特效 +5% L3 AI 图生视频爆点 +``` + +不要第一版每个镜头都 AI 视频化。 + +--- + +## 6. 单集结构 + +60 秒示例: + +```text +片头钩子:3 秒 +剧情镜头:45 秒 +反转镜头:7 秒 +结尾钩子:5 秒 +``` + +图片数量: + +```text +10-20 张 +每张 2-5 秒 +``` + +--- + +## 7. 字幕规范 + +```text +竖屏底部安全区 +每行不超过 14-18 个中文 +避免遮挡人物脸 +高对比描边 +可选逐句字幕 +后续支持逐字高亮 +``` + +--- + +## 8. 音频规范 + +```text +旁白音量清晰 +BGM 不盖过人声 +关键反转加音效 +震惊镜头加短促音效 +结尾钩子音乐停顿 +``` + +--- + +## 9. 视频输出规格 + +```text +9:16 竖屏 +1080x1920 +MP4 H.264 +AAC 音频 +30fps +封面图 +有字幕版 +无字幕版,可选 +``` + +--- + +## 10. 镜头复杂度控制 + +避免: + +```text +一个镜头 5 人以上 +同镜头同时打斗 +人物大幅转身 +复杂手部互动 +大量文字招牌 +``` + +复杂场景拆成多个镜头。 diff --git a/docs/system_a/15_任务队列_错误重试_稳定性设计.md b/docs/system_a/15_任务队列_错误重试_稳定性设计.md new file mode 100755 index 0000000..8aa651a --- /dev/null +++ b/docs/system_a/15_任务队列_错误重试_稳定性设计.md @@ -0,0 +1,146 @@ +# 15_任务队列_错误重试_稳定性设计 + +## 1. 队列列表 + +```text +novel_queue +parse_queue +story_queue +character_queue +episode_queue +script_queue +storyboard_queue +image_queue +audio_queue +subtitle_queue +video_queue +qc_queue +review_queue +analytics_queue +``` + +--- + +## 2. 任务状态 + +```text +pending +running +success +failed +retrying +cancelled +manual_required +skipped +``` + +--- + +## 3. 幂等设计 + +幂等 key: + +```text +project_id + episode_id + shot_id + task_type + input_hash +``` + +重复提交时: + +```text +已有成功结果 → 返回结果 +任务运行中 → 返回任务状态 +失败可重试 → 按规则重试 +``` + +--- + +## 4. 重试规则 + +```text +文本任务:2 次 +图片任务:3 次 +视频合成:2 次 +TTS:2 次 +质检:1 次 +``` + +连续失败进入: + +```text +manual_required +``` + +--- + +## 5. 失败处理 + +失败必须记录: + +```text +错误码 +错误信息 +输入参数 +Provider 响应 +重试次数 +是否扣额度 +是否可恢复 +``` + +--- + +## 6. 队列隔离 + +图片生成和视频合成不能共用队列,避免视频任务堵塞所有任务。 + +--- + +## 7. 并发限制 + +```text +每用户同时 1-2 个正式项目 +每项目图片并发 3-5 +每 Provider 独立限流 +视频合成单独限流 +``` + +--- + +## 8. 服务器重启恢复 + +系统启动后扫描: + +```text +running 超时任务 +retrying 超时任务 +video_rendering 中断任务 +``` + +处理: + +```text +重新入队 +标记失败 +转人工 +``` + +--- + +## 9. 成本安全 + +系统错误导致的重试不重复扣用户额度。 +用户主动重生、主动大改,需要扣额度或重新计费。 + +--- + +## 10. 队列监控 + +后台显示: + +```text +等待任务数 +运行任务数 +失败任务数 +平均耗时 +Provider 错误率 +队列积压 +``` diff --git a/docs/system_a/16_订单支付_额度_成本控制设计.md b/docs/system_a/16_订单支付_额度_成本控制设计.md new file mode 100755 index 0000000..3640cf4 --- /dev/null +++ b/docs/system_a/16_订单支付_额度_成本控制设计.md @@ -0,0 +1,160 @@ +# 16_订单支付_额度_成本控制设计 + +## 1. 为什么必须设计成本控制 + +系统 A 成本高于普通文本工具,因为每集需要: + +```text +文本生成 +角色图 +分镜图 +图片重试 +TTS +字幕 +BGM +视频合成 +质检 +存储 +人工审核 +``` + +如果使用最高模型,必须先设计额度和成本规则。 + +--- + +## 2. 套餐建议 + +### 试用版 + +```text +1 集 +低清水印 +少量图片 +不可商用 +``` + +### 标准短剧版 + +```text +3 集 +每集 45-60 秒 +每集 10-20 张图 +正式 MP4 +1 次小改 +``` + +### 连载测试版 + +```text +10 集 +批量生成 +封面标题 +人工审核 +``` + +### 高端定制版 + +```text +20 集以上 +角色精修 +关键镜头动态 +更多重试 +人工审核 +``` + +--- + +## 3. 额度消耗点 + +```text +原创小说生成 +上传小说解析 +故事圣经生成 +角色图生成 +分集计划 +脚本生成 +分镜生成 +预览图 +正式图 +TTS +字幕 +视频合成 +AI视频镜头 +``` + +--- + +## 4. 预览规则 + +建议: + +```text +预览图低清水印 +预览数量限制 +正式生成前必须支付或冻结额度 +``` + +--- + +## 5. 重试扣费规则 + +```text +系统失败重试:不扣用户额度 +用户不满意主动重生:扣额度 +人工判定质量失败重生:不扣或少扣 +大改:重新计费 +``` + +--- + +## 6. 成本日志 + +记录: + +```text +task_id +project_id +episode_id +provider +model +input_size +output_size +cost_estimate +cost_actual +user_charge +created_at +``` + +--- + +## 7. 成本预警 + +后台必须有: + +```text +单项目成本超限 +单用户成本异常 +Provider 日成本超限 +失败重试率异常 +视频任务成本异常 +``` + +--- + +## 8. 最高模型策略 + +开发阶段: + +```text +全部 mock +少量真实接口联调 +限制额度 +``` + +正式阶段: + +```text +正式生成用最高模型 +预览限制数量和分辨率 +视频模型只用于关键镜头 +``` diff --git a/docs/system_a/17_版权授权_内容审核_合规设计.md b/docs/system_a/17_版权授权_内容审核_合规设计.md new file mode 100755 index 0000000..ccd0aff --- /dev/null +++ b/docs/system_a/17_版权授权_内容审核_合规设计.md @@ -0,0 +1,135 @@ +# 17_版权授权_内容审核_合规设计 + +## 1. 上传小说版权确认 + +上传小说必须选择: + +```text +我是作者本人 +我已获得改编授权 +这是公版作品 +仅用于内部测试 +``` + +必须勾选: + +```text +我确认拥有该文本的合法使用权 +我授权平台为本项目进行 AI 改编和生成 +我理解未经授权不得用于商业发布 +``` + +--- + +## 2. 版权记录 + +保存: + +```text +项目ID +用户ID +授权类型 +授权声明 +IP +User-Agent +确认时间 +``` + +--- + +## 3. AI 原创小说记录 + +AI 原创内容也要记录: + +```text +用户输入 +生成时间 +模型 +Prompt版本 +项目ID +``` + +用于内容来源追踪。 + +--- + +## 4. 内容审核对象 + +```text +上传小说文本 +AI 原创小说 +故事圣经 +角色圣经 +分集脚本 +分镜脚本 +图片 +封面 +标题 +配音文本 +字幕 +最终视频 +``` + +--- + +## 5. 禁止内容 + +```text +违法内容 +色情低俗 +仇恨攻击 +未成年人不当内容 +未经授权名人冒用 +政治人物冒充 +侵犯版权 +极端暴力血腥 +平台规则敏感内容 +``` + +--- + +## 6. 公版作品提醒 + +用户选择公版作品时提示: + +```text +不同地区版权期限和改编权规则不同,用户需自行确认商业使用权。 +``` + +--- + +## 7. 音乐版权 + +BGM 来源: + +```text +平台授权音乐 +可商用音乐库 +用户自上传并承诺有权使用 +``` + +每首音乐记录: + +```text +来源 +授权类型 +使用范围 +授权文件 +``` + +--- + +## 8. 公开案例授权 + +默认不公开用户项目。 +公开案例必须单独授权。 + +--- + +## 9. 商业发布提醒 + +下载成品时提示: + +```text +请确认你拥有小说文本、音乐、图片和视频的商业发布权。 +``` diff --git a/docs/system_a/18_批量生产_运营发布_数据反馈设计.md b/docs/system_a/18_批量生产_运营发布_数据反馈设计.md new file mode 100755 index 0000000..5c80762 --- /dev/null +++ b/docs/system_a/18_批量生产_运营发布_数据反馈设计.md @@ -0,0 +1,127 @@ +# 18_批量生产_运营发布_数据反馈设计 + +## 1. 批量生产不是第一阶段核心 + +第一阶段目标是 1 个项目生成 1-3 集。 +但数据库和架构必须预留批量能力。 + +--- + +## 2. 批量生产流程 + +```text +选择小说项目 +→ 批量生成分集计划 +→ 每 5 集一批确认 +→ 批量生成分镜 +→ 角色一致性检查 +→ 批量图片生成 +→ 自动质检 +→ 人工抽检 +→ 批量视频合成 +→ 导出发布包 +``` + +--- + +## 3. 发布素材包 + +每集输出: + +```text +MP4视频 +封面图 +标题候选 +简介文案 +话题标签 +字幕文件 +图片素材包 +``` + +--- + +## 4. 标题封面生成 + +每集生成 3-5 个标题候选: + +```text +震惊型 +反转型 +情绪型 +悬念型 +爽点型 +``` + +封面图要求: + +```text +主角清晰 +冲突明显 +文字少 +情绪强 +适合竖屏 +``` + +--- + +## 5. 数据反馈 + +可记录平台数据: + +```text +播放量 +完播率 +点赞率 +评论数 +收藏数 +关注转化 +用户催更评论 +``` + +--- + +## 6. 数据反哺 + +后续系统可根据数据优化: + +```text +题材选择 +开头钩子 +封面风格 +标题风格 +剧情节奏 +单集时长 +人物设定 +``` + +--- + +## 7. A/B 测试 + +支持同一集生成: + +```text +不同标题 +不同封面 +不同开头 +不同视频节奏 +``` + +记录数据后选择更优版本。 + +--- + +## 8. 自动发布 + +第一阶段暂缓自动发布。 +后续可做: + +```text +抖音 +快手 +小红书 +视频号 +B站 +``` + +但平台接口和规则变化大,应单独设计。 diff --git a/docs/system_a/19_部署运维_日志监控_备份设计.md b/docs/system_a/19_部署运维_日志监控_备份设计.md new file mode 100755 index 0000000..ce8a912 --- /dev/null +++ b/docs/system_a/19_部署运维_日志监控_备份设计.md @@ -0,0 +1,115 @@ +# 19_部署运维_日志监控_备份设计 + +## 1. 目录结构 + +```text +ai-manga-system-a/ +├── backend/ +├── admin/ +├── user-app/ +├── workers/ +├── deploy/ +├── docs/ +└── storage/ +``` + +--- + +## 2. 环境变量 + +```text +DATABASE_URL +REDIS_URL +MINIO_ENDPOINT +MINIO_ACCESS_KEY +MINIO_SECRET_KEY +OPENAI_API_KEY +JWT_SECRET +FFMPEG_PATH +NODE_ENV +``` + +禁止把密钥写进代码。 + +--- + +## 3. 服务组成 + +```text +backend-api +queue-worker +ffmpeg-worker +admin-web +user-web +mysql +redis +minio +nginx +``` + +--- + +## 4. 日志类型 + +```text +API访问日志 +用户操作日志 +任务日志 +AI调用日志 +错误日志 +支付日志 +下载日志 +审核日志 +成本日志 +``` + +--- + +## 5. 备份策略 + +```text +MySQL 每日备份 +MinIO 重要素材备份 +Prompt 模板备份 +系统配置备份 +授权记录重点备份 +``` + +--- + +## 6. 清理策略 + +```text +草稿项目 30 天未操作可清理 +失败临时文件 7 天清理 +预览图 30 天清理 +正式成品按套餐保存 +日志按周期归档 +``` + +--- + +## 7. 告警 + +```text +AI成本超阈值 +任务失败率超阈值 +视频合成失败 +Redis队列积压 +磁盘空间不足 +数据库备份失败 +Provider连续失败 +``` + +--- + +## 8. 安全 + +```text +后台必须登录 +管理员操作留日志 +下载链接有有效期 +原始小说私有存储 +用户素材私有存储 +公开案例单独授权 +``` diff --git a/docs/system_a/20_测试用例_验收标准.md b/docs/system_a/20_测试用例_验收标准.md new file mode 100755 index 0000000..d37f080 --- /dev/null +++ b/docs/system_a/20_测试用例_验收标准.md @@ -0,0 +1,153 @@ +# 20_测试用例_验收标准 + +## 1. MVP 总体验收 + +必须跑通: + +```text +AI 原创小说 → 3 集漫剧 MP4 +上传小说 → 1 集漫剧 MP4 +后台能查看任务和重试失败 +用户能下载成品 +``` + +--- + +## 2. 登录测试 + +```text +注册成功 +登录成功 +Token过期 +未登录禁止创建项目 +用户只能看自己的项目 +``` + +--- + +## 3. AI 原创小说测试 + +```text +生成故事创意 +生成大纲 +生成故事圣经 +生成角色圣经 +生成章节 +自检通过 +``` + +--- + +## 4. 上传小说测试 + +```text +txt解析 +docx解析 +md解析 +pdf文本型解析 +乱码提示 +章节识别 +章节手动编辑 +``` + +--- + +## 5. 版权测试 + +```text +未确认版权不能继续 +确认后保存记录 +后台可查授权记录 +``` + +--- + +## 6. 角色一致性测试 + +```text +主角有锚点图 +角色锁定后不能改核心设定 +分镜图引用角色设定 +角色跨集不明显变脸 +``` + +--- + +## 7. 长篇记忆测试 + +```text +生成第5集时能引用前集摘要 +伏笔能记录 +人物关系变化能记录 +连续性检查能发现冲突 +``` + +--- + +## 8. 分集和分镜测试 + +```text +每集有开头钩子 +每集有结尾悬念 +每个镜头有画面描述 +每个镜头有时长 +每个镜头有 Prompt +复杂镜头能拆分 +``` + +--- + +## 9. 图片生成测试 + +```text +角色图生成 +分镜预览图生成 +正式图生成 +失败可重试 +图片关联镜头 +图片质检记录 +``` + +--- + +## 10. 视频合成测试 + +```text +图片转视频 +字幕正常 +旁白正常 +BGM正常 +MP4可播放 +封面正常 +``` + +--- + +## 11. 后台测试 + +```text +项目列表 +任务列表 +任务重试 +人工审核 +角色管理 +分镜管理 +成本日志 +模板管理 +``` + +--- + +## 12. 验收标准 + +项目完成标准: + +```text +一条 AI 原创链路完整可用 +一条上传小说链路完整可用 +任务失败可恢复 +用户可下载成品 +后台可管理项目 +成本可统计 +核心数据可追踪 +``` diff --git a/docs/system_a/21_Codex开发任务拆解文档.md b/docs/system_a/21_Codex开发任务拆解文档.md new file mode 100755 index 0000000..39a8b01 --- /dev/null +++ b/docs/system_a/21_Codex开发任务拆解文档.md @@ -0,0 +1,308 @@ +# 21_Codex开发任务拆解文档 + +## 1. Codex 开发规则 + +```text +每次只完成一个任务 +完成后等待人工审核 +不得一次性开发整个系统 +不得擅自删除 docs +不得把真实 API Key 写入代码 +所有 AI Provider 先 mock +所有状态必须写入数据库 +所有任务必须支持失败记录 +``` + +--- + +## 2. 推荐项目结构 + +```text +ai-manga-system-a/ +├── docs/ +├── backend/ +├── admin/ +├── user-app/ +├── workers/ +├── deploy/ +└── README.md +``` + +--- + +## 3. 任务 01:读取文档并输出计划 + +要求: + +```text +只读 docs +不写代码 +输出开发理解 +输出 MVP 范围 +输出目录结构 +输出疑问 +``` + +--- + +## 4. 任务 02:初始化项目骨架 + +```text +创建 monorepo +初始化 backend +初始化 admin +初始化 user-app +创建 workers +创建 deploy +创建 .env.example +``` + +--- + +## 5. 任务 03:数据库 schema + +按 `05_数据库表结构设计.md` 创建表。 + +验收: + +```text +迁移可执行 +索引存在 +基础表可写入 +``` + +--- + +## 6. 任务 04:认证和用户 + +```text +注册 +登录 +JWT +用户信息 +权限守卫 +``` + +--- + +## 7. 任务 05:文件上传和资产 + +```text +MinIO +小说文件上传 +图片/视频资产表 +私有访问 +``` + +--- + +## 8. 任务 06:项目创建流程 + +```text +创建项目 +选择输入模式 +选择题材 +保存目标集数 +状态流转 +``` + +--- + +## 9. 任务 07:上传小说解析 + +```text +txt/docx/md解析 +章节识别 +文本清洗 +章节保存 +``` + +--- + +## 10. 任务 08:AI 原创小说 Mock + +```text +Mock 故事创意 +Mock 大纲 +Mock 章节 +Mock 自检 +``` + +--- + +## 11. 任务 09:故事圣经模块 + +```text +生成 +编辑 +确认 +版本管理 +``` + +--- + +## 12. 任务 10:角色圣经和锚点图 + +```text +角色提取 +角色编辑 +角色图生成任务 +锚点图选择 +角色锁定 +``` + +--- + +## 13. 任务 11:长篇记忆模块 + +```text +plot_memories +plot_threads +character_memories +continuity_checks +``` + +--- + +## 14. 任务 12:分集计划 + +```text +生成分集 +编辑分集 +确认分集 +``` + +--- + +## 15. 任务 13:脚本和分镜 + +```text +生成单集脚本 +生成分镜 +编辑镜头 +Prompt生成 +``` + +--- + +## 16. 任务 14:BullMQ 队列 + +```text +任务创建 +任务状态 +重试 +幂等key +失败记录 +``` + +--- + +## 17. 任务 15:AI Provider 抽象 + +```text +TextProvider +ImageProvider +VoiceProvider +VideoProvider +ModerationProvider +Mock实现 +``` + +--- + +## 18. 任务 16:图片生成 + +```text +角色图 +分镜预览图 +正式图 +质检记录 +``` + +--- + +## 19. 任务 17:TTS 字幕 FFmpeg + +```text +生成旁白 +生成SRT +图片转视频 +字幕/BGM合成 +导出MP4 +``` + +--- + +## 20. 任务 18:后台管理 + +```text +项目管理 +小说管理 +角色管理 +分镜管理 +任务管理 +审核管理 +模板管理 +成本日志 +``` + +--- + +## 21. 任务 19:uni-app 用户端 + +```text +首页 +创建项目 +AI原创设置 +上传小说 +版权确认 +故事圣经 +角色库 +分集 +分镜 +进度 +成品 +``` + +--- + +## 22. 任务 20:订单额度 + +```text +套餐 +订单 +额度冻结 +额度扣减 +成本日志 +``` + +--- + +## 23. 任务 21:内容审核 + +```text +版权确认 +文本审核 +图片审核 +视频审核 +公开案例授权 +``` + +--- + +## 24. 任务 22:真实 AI Provider 接入 + +在所有 mock 流程跑通后再接入真实模型。 + +--- + +## 25. 任务 23:MVP 验收 + +必须完成: + +```text +AI原创小说生成3集MP4 +上传小说生成1集MP4 +后台可重试失败任务 +用户可下载成品 +``` diff --git a/docs/system_a/22_系统A与系统B后续合并架构设计.md b/docs/system_a/22_系统A与系统B后续合并架构设计.md new file mode 100755 index 0000000..4f99844 --- /dev/null +++ b/docs/system_a/22_系统A与系统B后续合并架构设计.md @@ -0,0 +1,159 @@ +# 22_系统A与系统B后续合并架构设计 + +## 1. 合并目标 + +系统 A 和系统 B 第一阶段分开开发,但后续应合并为: + +```text +AI 内容影像生成平台 +``` + +系统 A: + +```text +小说 / 文本故事 → 韩漫 / 漫剧 +``` + +系统 B: + +```text +真人照片 → 写真 / 婚礼 / 纪念视频 +``` + +--- + +## 2. 公共平台模块 + +```text +用户系统 +权限系统 +订单系统 +额度系统 +套餐系统 +素材系统 +AI Provider +任务队列 +视频合成 +内容审核 +案例管理 +成本统计 +日志监控 +部署运维 +``` + +--- + +## 3. 系统 A 独有模块 + +```text +小说源 +章节 +故事圣经 +世界观圣经 +角色圣经 +长篇记忆 +分集计划 +分镜脚本 +连载管理 +剧情数据反馈 +``` + +--- + +## 4. 系统 B 独有模块 + +```text +真人照片 +人像档案 +人生主题 +世界模板 +场景模板 +写真镜头 +人像一致性 +纪念视频模板 +``` + +--- + +## 5. 后续统一数据模型 + +公共实体: + +```text +User +Project +Asset +Task +Order +Quota +Provider +Review +Case +Analytics +``` + +系统 A Project type: + +```text +novel_manga +``` + +系统 B Project type: + +```text +portrait_video +``` + +--- + +## 6. Provider 共用 + +```text +TextProvider +ImageProvider +VideoProvider +VoiceProvider +ModerationProvider +QualityCheckProvider +``` + +系统 A 和 B 都不允许直接写死模型。 + +--- + +## 7. 视频合成共用 + +系统 A 和 B 都可以使用: + +```text +图片排序 +运镜 +字幕 +BGM +TTS +FFmpeg合成 +封面生成 +``` + +区别: + +```text +系统 A 偏剧情分镜 +系统 B 偏写真镜头和纪念视频 +``` + +--- + +## 8. 合并顺序建议 + +```text +第一步:统一用户和订单 +第二步:统一素材系统 +第三步:统一 AI Provider +第四步:统一任务队列 +第五步:统一视频合成 +第六步:统一后台权限和日志 +第七步:合并前台入口 +``` + +不要一开始就强行写成一个复杂系统。 diff --git a/docs/system_a/README.md b/docs/system_a/README.md new file mode 100755 index 0000000..75d260f --- /dev/null +++ b/docs/system_a/README.md @@ -0,0 +1,134 @@ +# 系统 A:原创小说 / 上传小说 → 韩漫 / 漫剧生成系统 V2 Production 文档包 + +## 1. 文档包定位 + +本包是系统 A 的 **生产级基础需求 + 工程落地文档**,用于替代上一版 `system_a_docs_v1.zip` 作为 Codex / 开发团队的开发基线。 + +上一版 V1 的定位: + +```text +能跑通 MVP +能证明小说 → 漫剧链路可行 +需求颗粒度不够细 +长篇连载、角色一致性、批量稳定性不足 +``` + +本版 V2 Production 的定位: + +```text +作为系统 A 正式开发基线 +覆盖原创小说、上传小说、授权小说改编 +覆盖短篇 MVP、长篇连载、批量生成 +覆盖角色一致性、剧情记忆、分镜质量、视频合成、成本控制、版权合规 +为后续与系统 B 合并预留公共平台架构 +``` + +--- + +## 2. 系统 A 一句话定位 + +> 系统 A 是一套把 AI 原创小说或已授权上传小说,自动改编成韩漫图集、短视频漫剧和连载剧集的 AI 内容工厂。 + +--- + +## 3. 第一阶段落地目标 + +第一阶段不要追求全自动 100 集。正式 MVP 目标是: + +```text +AI 原创小说模式: +1 个小说项目 +→ 生成故事圣经 +→ 生成 3 个主要角色 +→ 生成 3 集短漫剧 +→ 每集 45-60 秒 +→ 每集 10-20 张图 +→ 自动配音、字幕、BGM +→ 自动合成 MP4 + +上传小说模式: +上传 1 篇已授权小说 +→ 解析章节 +→ 生成故事圣经 +→ 提取角色 +→ 生成 1-3 集漫剧 +→ 下载 MP4 +``` + +--- + +## 4. 后续生产级目标 + +```text +支持 20-100 集连载剧集 +支持批量生成 +支持角色跨集稳定 +支持剧情长线记忆 +支持人工审核和重试 +支持高质量封面标题 +支持不同题材 A/B 测试 +支持多模型 Provider 替换 +支持成本监控和额度管理 +支持与系统 B 合并为统一 AI 影像平台 +``` + +--- + +## 5. 本文档包清单 + +```text +00_上下文难点清单_已接入设计.md +01_系统A总需求文档_v2_生产级基线.md +02_业务流程_状态流转_权限设计.md +03_功能清单_页面清单.md +04_技术架构设计_模块拆分.md +05_数据库表结构设计.md +06_API接口设计文档.md +07_uniapp用户端页面交互文档.md +08_GeekerAdmin后台管理设计.md +09_AI生成流水线_Provider抽象设计.md +10_Prompt模板_题材世界观_韩漫规范.md +11_角色一致性专项设计.md +12_长篇连载记忆_剧情一致性专项设计.md +13_小说生成_上传解析_改编质量标准.md +14_分镜镜头_视频合成_特效设计.md +15_任务队列_错误重试_稳定性设计.md +16_订单支付_额度_成本控制设计.md +17_版权授权_内容审核_合规设计.md +18_批量生产_运营发布_数据反馈设计.md +19_部署运维_日志监控_备份设计.md +20_测试用例_验收标准.md +21_Codex开发任务拆解文档.md +22_系统A与系统B后续合并架构设计.md +文件行数统计.json +``` + +--- + +## 6. 使用规则 + +开发时以这些文档为主: + +```text +01_系统A总需求文档_v2_生产级基线.md +02_业务流程_状态流转_权限设计.md +04_技术架构设计_模块拆分.md +05_数据库表结构设计.md +09_AI生成流水线_Provider抽象设计.md +11_角色一致性专项设计.md +12_长篇连载记忆_剧情一致性专项设计.md +14_分镜镜头_视频合成_特效设计.md +15_任务队列_错误重试_稳定性设计.md +21_Codex开发任务拆解文档.md +``` + +Codex 开发时禁止一口气做完整系统。必须按 `21_Codex开发任务拆解文档.md` 分阶段执行。 + +--- + +## 7. 旧版处理 + +```text +system_a_docs_v1.zip:只作为历史稿保存 +system_a_docs_v2_production.zip:作为当前开发基线 +``` diff --git a/docs/system_a/文件行数统计.json b/docs/system_a/文件行数统计.json new file mode 100755 index 0000000..88e8fc9 --- /dev/null +++ b/docs/system_a/文件行数统计.json @@ -0,0 +1,122 @@ +[ + { + "file": "00_上下文难点清单_已接入设计.md", + "lines": 97, + "chars": 1660 + }, + { + "file": "01_系统A总需求文档_v2_生产级基线.md", + "lines": 447, + "chars": 2962 + }, + { + "file": "02_业务流程_状态流转_权限设计.md", + "lines": 301, + "chars": 3188 + }, + { + "file": "03_功能清单_页面清单.md", + "lines": 364, + "chars": 1774 + }, + { + "file": "04_技术架构设计_模块拆分.md", + "lines": 202, + "chars": 2325 + }, + { + "file": "05_数据库表结构设计.md", + "lines": 490, + "chars": 6814 + }, + { + "file": "06_API接口设计文档.md", + "lines": 219, + "chars": 3712 + }, + { + "file": "07_uniapp用户端页面交互文档.md", + "lines": 175, + "chars": 1412 + }, + { + "file": "08_GeekerAdmin后台管理设计.md", + "lines": 250, + "chars": 1306 + }, + { + "file": "09_AI生成流水线_Provider抽象设计.md", + "lines": 183, + "chars": 1305 + }, + { + "file": "10_Prompt模板_题材世界观_韩漫规范.md", + "lines": 183, + "chars": 1295 + }, + { + "file": "11_角色一致性专项设计.md", + "lines": 206, + "chars": 1384 + }, + { + "file": "12_长篇连载记忆_剧情一致性专项设计.md", + "lines": 232, + "chars": 1661 + }, + { + "file": "13_小说生成_上传解析_改编质量标准.md", + "lines": 217, + "chars": 1280 + }, + { + "file": "14_分镜镜头_视频合成_特效设计.md", + "lines": 196, + "chars": 1188 + }, + { + "file": "15_任务队列_错误重试_稳定性设计.md", + "lines": 147, + "chars": 1085 + }, + { + "file": "16_订单支付_额度_成本控制设计.md", + "lines": 161, + "chars": 967 + }, + { + "file": "17_版权授权_内容审核_合规设计.md", + "lines": 136, + "chars": 801 + }, + { + "file": "18_批量生产_运营发布_数据反馈设计.md", + "lines": 128, + "chars": 710 + }, + { + "file": "19_部署运维_日志监控_备份设计.md", + "lines": 116, + "chars": 845 + }, + { + "file": "20_测试用例_验收标准.md", + "lines": 154, + "chars": 932 + }, + { + "file": "21_Codex开发任务拆解文档.md", + "lines": 309, + "chars": 2045 + }, + { + "file": "22_系统A与系统B后续合并架构设计.md", + "lines": 160, + "chars": 1064 + }, + { + "file": "README.md", + "lines": 135, + "chars": 1773 + } +] \ No newline at end of file diff --git a/docs/system_b/00_系统B升级说明_真人动态视频.md b/docs/system_b/00_系统B升级说明_真人动态视频.md new file mode 100644 index 0000000..e553bbe --- /dev/null +++ b/docs/system_b/00_系统B升级说明_真人动态视频.md @@ -0,0 +1,126 @@ +# 00_系统B升级说明_真人动态视频 + +## 1. 为什么要升级 + +系统 B V1/V2 的主线是: + +```text +真人照片 +→ 多人生主题 / 多世界模板 +→ 写真图 / 韩漫图 +→ 图片运镜 + BGM + 字幕 +→ 纪念视频 +``` + +这条链路适合稳定交付写真图集、婚礼相册视频和轻动态纪念片。 + +现在新增目标是: + +```text +真人照片 +→ 保持本人长相和身份 +→ 换世界 / 换朝代 / 换服装 +→ 人物会动、有表情、有动作、可轻口型 +→ 像抖音真人短剧 / 真人婚礼电影 / 真人穿越纪念片 +``` + +因此系统 B 不需要推翻,但必须升级为: + +```text +系统 B V3:真人动态视频版 +``` + +## 2. V3 的核心定位 + +系统 B V3 是真人照片驱动的 AI 多人生主题影像平台,支持: + +- 真人写真图集 +- 图片纪念视频 +- 动态写真视频 +- AI 真人动态视频 +- 高端真人纪念片 + +系统 B 与系统 A 的关键区别: + +```text +系统 A:虚构小说角色 → 真人短剧 +系统 B:真实用户照片 → 真人动态纪念视频 +``` + +系统 B 必须优先保证本人相似度、肖像授权、隐私保护、未成年人保护、身份不漂移和公开案例二次授权。 + +## 3. V3 不推翻的能力 + +以下 V2 设计继续保留: + +- 真人照片上传 +- 照片质检 +- 人物档案 +- 人生主题 +- 世界模板 +- 场景模板 +- 镜头模板 +- 图片生成 +- FFmpeg 图片视频合成 +- 隐私授权 +- 后台审核 +- 订单和成本控制 +- Provider 抽象 + +V3 是在上述基础上新增真人动态视频链路。 + +## 4. V3 新增能力 + +```text +AI 真人动态视频模式 +真人身份锁定 +身份锚点图 +动作模板 +关键帧生成 +图生视频 / 参考图生视频 +视频片段生成 +视频片段质检 +口型同步任务 +FaceConsistencyProvider +MotionPortraitProvider +LipSyncProvider +国内 VideoProvider 策略 +更严格肖像权和未成年人保护 +更细成本阈值和人工审核 +``` + +## 5. 推荐真实视频 Provider 顺序 + +第一轮真实小样建议按以下顺序测试: + +1. MiniMax Hailuo 2.3 Fast:速度和成本更适合小样验证。 +2. 阿里 Wan2.6 I2V Flash:对比稳定性、清晰度和成本。 +3. Vidu Q3 Turbo Reference:重点看参考图人物一致性、表情和音画能力。 +4. Seedance / 即梦:重点看真人感、镜头语言和中文短剧感。 +5. Kling / Runway:作为备用和质量对比。 + +每次只测同一个项目、同一个人物、同一个镜头,避免变量过多。 + +## 6. V3 最小验收目标 + +第一版不要求整片都是真 AI 动态视频。推荐最小可上线策略: + +```text +写真图集版:稳定生成 6-12 张图 +图片视频版:稳定合成 30-60 秒 MP4 +动态写真版:关键图可做轻微眨眼/微笑/背景动效 +AI 真人动态视频版:1-3 个关键镜头使用真实图生视频 +``` + +高端真人纪念片再扩展到全片段 AI 视频化、口型、誓言、人工精修和多轮修改。 + +## 7. 不能省略的安全规则 + +- 默认不公开用户作品。 +- 默认不把用户照片用于训练或展示。 +- 公开案例必须二次授权。 +- 未成年人默认不公开,必须监护人授权。 +- 真实视频 Provider 默认禁用。 +- 真实动态视频必须先成本预估,再用户确认,再生成。 +- 真实 Provider 失败不能回落 mock 假成功。 +- 视频片段必须做本人相似度和合规质检。 diff --git a/docs/system_b/01_系统B总需求文档_v3_真人动态视频版.md b/docs/system_b/01_系统B总需求文档_v3_真人动态视频版.md new file mode 100644 index 0000000..83c65d4 --- /dev/null +++ b/docs/system_b/01_系统B总需求文档_v3_真人动态视频版.md @@ -0,0 +1,384 @@ +# 01_系统B总需求文档_v3_真人动态视频版 + +## 1. 项目名称 + +**AI 真人多人生主题写真 / 动态视频 / 纪念短片生成系统** + +简称: + +```text +系统 B V3 +真人照片 → 多人生主题 → 多世界模板 → 写真图集 / 图片视频 / 动态写真 / AI 真人动态视频 +``` + +## 2. 产品一句话 + +用户上传本人、情侣、夫妻或家庭成员照片后,系统在授权和质检通过的前提下,生成专属多世界写真图集、纪念视频,以及带表情、动作、轻口型和镜头运动的 AI 真人动态视频。 + +## 3. V3 输出模式 + +### 3.1 高清写真图集 + +```text +真人照片 +→ 人物身份档案 +→ 多世界写真图 +→ 高清图片交付 +``` + +适合: + +- 情侣写真 +- 婚礼照片 +- 个人形象 +- 父母纪念照 + +### 3.2 图片纪念视频 + +```text +写真图 +→ FFmpeg 推拉运镜 +→ 配乐 / 旁白 / 字幕 +→ MP4 +``` + +效果类似高级相册视频、婚礼纪念片、照片动效视频。人物本身不做真实行动。 + +### 3.3 动态写真视频 + +```text +写真图 +→ 眨眼 / 微笑 / 头发衣服轻微动 +→ 背景动效 / 花瓣光效 +→ 轻口型,可选 +→ MP4 +``` + +适合婚礼纪念、情侣纪念、父母金婚银婚、个人形象短片。 + +### 3.4 AI 真人动态视频 + +```text +真人照片 +→ 真人身份档案 +→ 身份锚点 +→ 世界 / 场景 / 服装设定 +→ 关键帧 +→ 图生视频 / 参考图生视频 +→ 人物动作 / 表情变化 +→ 旁白 / 字幕 / BGM / 口型,可选 +→ 合成成片 +``` + +效果目标: + +- 像抖音真人短剧 +- 像真人婚礼电影 +- 像真人穿越短片 +- 人物有动作、表情和镜头运动 + +### 3.5 高端真人纪念片 + +高端版本支持: + +- 多世界 +- 多场景 +- 关键镜头真人动态 +- 誓言 / 旁白 / 口型 +- 人工审核 +- 人工精修 +- 多轮修改 + +## 4. 第一阶段主打场景 + +第一阶段重点做: + +- 结婚纪念 +- 恋爱纪念 +- 父母银婚金婚 +- 情侣写真 +- 个人形象定制 + +后续扩展: + +- 家庭全家福 +- 宝宝百日 +- 儿童成长 +- 闺蜜写真 +- 亲子纪念 + +涉及未成年人时,必须启用更严格授权、审核和公开限制。 + +## 5. V3 标准流程 + +```text +首页浏览 +→ 登录 +→ 创建项目 +→ 选择人生主题 +→ 选择套餐 +→ 选择输出模式 +→ 选择视觉风格 +→ 选择世界观 +→ 选择场景 +→ 上传照片 +→ 肖像和隐私授权确认 +→ 照片质检 +→ 真人身份档案 +→ 生成身份锚点图 +→ 用户确认像不像 +→ 生成创作方案 +→ 成本预估 / 支付 / 额度冻结 +→ 生成预览 +→ 用户确认 +→ 正式图片 / 关键帧生成 +→ 真人动态视频片段生成,可选 +→ 视频片段质检 +→ 配音 / 字幕 / BGM +→ 口型同步,可选 +→ 合成成片 +→ 人工审核 +→ 用户确认 +→ 下载交付 +``` + +## 6. 核心业务对象 + +V3 在 V2 基础上新增或增强: + +```text +Project.output_mode +PersonProfile.identity_lock_status +PersonProfile.face_consistency_score +IdentityAnchor +MotionTemplate +VideoClip +LipSyncTask +FaceConsistencyCheck +ProviderConfig +ProviderLog +``` + +## 7. 真人身份锁定 + +系统必须建立“真实人物身份锁定”流程: + +1. 用户上传多张参考照片。 +2. 系统去 EXIF,私有存储。 +3. 检测清晰度、人脸数量、遮挡、角度和过度美颜。 +4. 生成人物档案。 +5. 生成身份锚点图。 +6. 用户确认“像本人”后才能进入正式生成。 +7. 后续图片和视频都必须引用身份锚点和人物档案。 + +身份锁定字段: + +```text +identity_lock_status +primary_reference_asset_id +approved_anchor_asset_id +face_consistency_score +age_preserve_rule +beautify_level +style_transform_level +privacy_level +``` + +## 8. 动作与视频片段 + +V3 需要动作模板: + +```text +smile +blink +turn_head +walk_forward +hold_hands +look_at_each_other +bow_ceremony +lift_veil +hug +wave +stand_still_cinematic +slow_camera_push +``` + +每个视频片段单独保存,记录: + +```text +project_id +scene_id +shot_id +person_ids +provider_id +input_asset_id +output_asset_id +prompt_text +duration +resolution +motion_type +lipsync_enabled +status +retry_count +cost_actual +quality_score +``` + +## 9. Provider 策略 + +系统 B V3 不直接依赖单一模型,必须通过 Provider 抽象管理: + +```text +TextProvider +ImageProvider +VideoProvider +VoiceProvider +MotionPortraitProvider +LipSyncProvider +FaceIdentityProvider +FaceConsistencyProvider +QualityCheckProvider +ModerationProvider +``` + +短剧向视频 Provider 预设: + +```text +MiniMax Hailuo 2.3 Fast +MiniMax Hailuo 2.3 +Alibaba Wan2.6 I2V Flash +Alibaba Wan2.6 I2V +Vidu Q3 Turbo Reference +Vidu Q3 Pro +Jimeng / Seedance +Kling +Runway +MockVideoProvider +``` + +默认策略: + +- 真实视频 Provider 默认禁用。 +- 测试先启用一个 Provider。 +- 先跑 1 个镜头小样。 +- 设置单次和每日成本上限。 +- 真实 Provider 失败不能回落 mock 假成功。 + +## 10. Prompt 方向 + +系统 B Prompt 必须强调真人身份保持: + +```text +真实短剧风格,保持参考照片中人物的五官、脸型、年龄感和气质,不能换脸,不能变成其他人。 +人物穿着指定主题服装,在指定场景中自然微笑、转头、牵手或行礼。 +镜头竖屏 9:16,电影感光影,真实表情,动作自然,适合抖音短视频纪念片。 +``` + +负面约束: + +```text +不要改变人物身份 +不要变年轻太多 +不要变成欧美脸 +不要多出第三人 +不要脸部扭曲 +不要手部异常 +不要表情僵硬 +不要恐怖感 +不要过度美颜 +不要低俗姿势 +``` + +## 11. 成本控制 + +真人动态视频成本必须按以下维度估算: + +- 视频片段秒数 +- 视频模型 +- 分辨率 +- 候选数量 +- 重试次数 +- 口型任务 +- 人工审核 + +后台配置: + +```text +max_video_seconds_per_project +max_clip_duration +max_clip_candidates +max_video_retry_per_clip +default_video_resolution +max_cost_per_project +max_cost_per_call +daily_cost_limit +``` + +示例: + +```text +AI 真人动态视频 40 秒: +8 个镜头 +每个 5 秒 +每镜头最多 1 次重试 +默认 720P +``` + +## 12. 隐私与肖像权 + +系统 B V3 必须比系统 A 更严格: + +- 用户作品默认私密。 +- 用户照片默认不公开、不进案例库、不用于训练。 +- 上传照片前必须确认拥有照片中所有人物授权。 +- 涉及未成年人必须确认监护人授权。 +- 公开案例必须单独授权。 +- 后台查看、下载、删除用户原图必须记录审计日志。 + +新增授权文案: + +```text +我确认拥有上传照片中所有人物授权。 +我授权平台仅为本项目生成图像和视频。 +我理解 AI 生成结果可能与本人存在差异。 +我确认不得上传未经授权的他人照片。 +如涉及未成年人,我确认我是监护人或已获得监护人授权。 +``` + +## 13. 后台运营能力 + +后台项目详情需新增: + +- 人物身份锚点 +- 人脸一致性评分 +- 视频片段列表 +- 动作模板 +- 口型任务 +- 视频 Provider +- 片段重试 +- 片段替换 +- 片段质检 +- 成本统计 + +后台审核需新增: + +- 像不像本人 +- 是否变脸 +- 是否男女混脸 +- 是否年龄变化过大 +- 动作是否自然 +- 表情是否怪异 +- 是否有不合适姿势 +- 是否适合公开案例 + +## 14. MVP 验收标准 + +V3 第一版达到以下标准才可进入真实付费小样: + +- 图集版可稳定生成 6-12 张图。 +- 图片视频版可稳定合成 30-60 秒 MP4。 +- 动态写真版可对关键图做轻微动效。 +- AI 真人动态视频版可用 1 个真实 Provider 生成 1-3 个关键镜头。 +- 每个真实视频片段可预估成本、生成、失败重试、质检、预览和替换。 +- 真实视频失败时任务失败并提示原因,不能显示 mock 成功。 +- 未成年人、公开案例、用户原图访问均有明确授权和审计。 diff --git a/docs/system_b/01_需求文档第一版本.md b/docs/system_b/01_需求文档第一版本.md new file mode 100755 index 0000000..64d53cb --- /dev/null +++ b/docs/system_b/01_需求文档第一版本.md @@ -0,0 +1,1389 @@ +# 系统 B 需求设计文档(V1 完整版) + +## 项目名称 + +**AI 多世界婚礼韩漫 / 婚礼视频定制系统(系统 B)** + +--- + +## 文档目标 + +本文档用于稳定系统 B 的第一版产品需求,指导后续: + +- 产品需求确认 +- 系统架构设计 +- Codex 开发落地 +- 模块拆分与开发顺序 +- 后续与系统 A「原创小说 → 韩漫 / 漫剧生成系统」合并时的兼容规划 + +本版本是 V1,重点是把「真人照片 → 婚礼韩漫 / 婚礼视频」这条业务链路先完整梳理清楚。 + +--- + +# 1. 项目定位 + +## 1.1 系统 B 是做什么的 + +系统 B 是一个面向 **情侣 / 夫妻 / 婚庆客户 / 定制礼品客户 / 影楼 / 婚庆工作室** 的 AI 定制生成系统。 + +用户上传真人照片后,系统根据用户选择的: + +- 视觉风格 +- 世界观模板 +- 朝代 / 幻想世界 / 科幻世界 +- 场景模板 +- 镜头模板 +- 文案 / 旁白 / 音乐 + +自动生成: + +- 婚礼韩漫图集 +- 多朝代婚礼图集 +- 多世界穿越婚礼图集 +- 婚礼短视频 +- 多世界婚礼纪念片 +- 半真人 / 真人感婚礼视频 + +--- + +## 1.2 核心卖点 + +系统 B 卖的不是简单的「AI 图片」,而是: + +- 定制感 +- 纪念价值 +- 情绪价值 +- 仪式感 +- 时空穿越创意 +- 韩漫 / 国风 / 幻想世界沉浸感 +- 可以发朋友圈、小红书、抖音的社交传播内容 +- 可以作为礼物、婚礼现场播放、纪念日惊喜的作品 + +--- + +## 1.3 系统边界 + +当前系统 B 只做: + +> 真人照片 → 婚礼韩漫 / 婚礼图集 / 婚礼视频 / 多世界纪念视频 + +### 当前纳入范围 + +- 真人照片上传 +- 照片质量检测 +- 人物身份建立 +- 人像一致性控制 +- 风格模板选择 +- 世界观模板选择 +- 场景模板选择 +- 分镜 / 镜头生成 +- 图像生成 +- 简单视频合成 +- 字幕 / 旁白 / 音乐 +- 用户预览确认 +- 成品交付下载 +- 修改 / 返工流程 +- 后台模板管理 +- 后台任务管理 + +### 当前不优先做 + +- 自动发布到抖音、小红书、快手 +- 完整商城系统 +- 小程序分销 +- 多商户系统 +- 真人语音克隆 +- 超复杂电影级长视频 +- 直播带货链路 +- 企业级 API 开放平台 + +--- + +# 2. 长期架构原则:为后续与系统 A 合并做准备 + +虽然当前按两个系统开发: + +- 系统 A:原创小说 → 韩漫 / 漫剧 +- 系统 B:真人照片 → 婚礼韩漫 / 视频 + +但系统 B 从第一天开始就要遵守平台化设计,后续可以合并到统一的 AI 内容生成平台。 + +--- + +## 2.1 可复用公共能力 + +后续系统 A 和系统 B 可共用: + +- 用户系统 +- 订单系统 +- 文件上传系统 +- 资源管理系统 +- 模板系统 +- Prompt 组装系统 +- AI Provider 抽象层 +- 图像生成任务队列 +- 视频合成任务队列 +- TTS / BGM / 字幕模块 +- 审核与返工流程 +- 存储与交付系统 +- 日志与成本统计系统 + +--- + +## 2.2 系统 B 特有能力 + +系统 B 特有能力包括: + +- 真人照片质量检测 +- 双人人物身份映射 +- 人脸一致性控制 +- 婚礼世界观模板 +- 婚礼场景模板 +- 婚礼文案模板 +- 多世界时空婚礼编排 +- 套餐化交付逻辑 +- 用户肖像授权逻辑 + +这些能力未来可抽成「真人照片定制插件模块」。 + +--- + +# 3. 用户对象与使用场景 + +## 3.1 用户对象 + +1. 情侣 +2. 夫妻 +3. 婚礼新人 +4. 婚庆公司 +5. 影楼 / 摄影工作室 +6. 纪念日礼物用户 +7. 父母金婚银婚纪念用户 +8. 自媒体案例展示与接单方 + +--- + +## 3.2 核心使用场景 + +1. 结婚纪念视频 +2. 婚礼现场播放视频 +3. 求婚预热视频 +4. 情侣纪念日礼物 +5. 父母金婚 / 银婚纪念片 +6. 情侣韩漫写真 +7. 多朝代穿越婚礼视频 +8. 修仙 / 科幻 / 架空世界婚礼图集 +9. 小红书 / 抖音案例展示 +10. 婚庆公司增值服务 + +--- + +# 4. 产品形态与套餐形态 + +## 4.1 输出形态 A:图集版 + +输出内容: + +- 6~20 张成品图 +- 高清图下载 +- 封面图 +- 可选水印版 / 无水印版 + +适合: + +- 情侣写真 +- 古风写真 +- 韩漫情侣图 +- 朋友圈 / 小红书展示 + +--- + +## 4.2 输出形态 B:短视频版 + +输出内容: + +- 30~60 秒短视频 +- 9:16 / 16:9 / 1:1 +- 配乐 +- 字幕 +- 简单运镜 +- 简单转场 + +适合: + +- 抖音 +- 小红书 +- 视频号 +- 纪念日礼物 + +--- + +## 4.3 输出形态 C:时空婚礼纪念片 + +输出内容: + +- 1~3 分钟视频 +- 多世界 / 多朝代组合 +- 专属旁白 +- 誓言文案 +- 片头片尾 +- 图集 + 视频交付 + +适合: + +- 婚礼现场播放 +- 父母金婚银婚 +- 高客单价定制 +- 婚庆公司合作 + +--- + +## 4.4 套餐建议 + +### 套餐 1:标准图集 + +- 1 个主题 +- 1 个世界观 +- 3 个场景 +- 6~12 张图 +- 1 次小修改 + +### 套餐 2:短视频版 + +- 1~3 个世界观 +- 3~6 个场景 +- 10~20 张图 +- 30~60 秒视频 +- BGM + 字幕 +- 1 次小修改 + +### 套餐 3:时空婚礼版 + +- 5~10 个世界观 +- 每个世界 1~3 个场景 +- 20~50 张图 +- 1~3 分钟视频 +- 旁白 + 字幕 + 片头片尾 +- 2 次修改 + +### 套餐 4:高端定制版 + +- 用户自由描述 +- 专属文案 +- 专属誓言 +- 多轮修改 +- 高质量精修图 +- 高质量视频 +- 人工审核 + +--- + +# 5. 风格与世界观体系设计 + +## 5.1 视觉风格层 Style + +第一阶段建议主打: + +1. 韩漫风 +2. 半写实古风 +3. 国风插画风 +4. 电影写实风 + +后续可扩展: + +1. Q 版趣味风 +2. 日漫风 +3. 儿童绘本风 +4. 史诗奇幻风 +5. 赛博朋克风 + +--- + +## 5.2 世界观层 World Template + +系统不能只做「朝代」,而要做「世界观模板」。 + +### 类目 1:历史朝代系 + +- 汉朝婚礼 +- 唐朝婚礼 +- 宋朝婚礼 +- 明朝婚礼 +- 清朝婚礼 +- 民国婚礼 + +### 类目 2:仙侠修真系 + +- 仙门大婚 +- 九重天婚礼 +- 桃花仙境婚礼 +- 宗门大典 +- 凤族神婚 +- 龙宫婚礼 +- 剑仙世界 + +### 类目 3:未来科幻系 + +- 星际婚礼 +- 宇宙飞船婚礼 +- 月球基地婚礼 +- 全息圣殿婚礼 +- 赛博都市婚礼 +- 未来机械王朝 + +### 类目 4:趣味脑洞系 + +- 恐龙时代婚礼 +- 原始部落婚礼 +- 海底王国婚礼 +- 云端王国婚礼 +- 精灵森林婚礼 +- 猫猫王国婚礼 + +### 类目 5:架空奇幻系 + +- 魔法王朝婚礼 +- 蒸汽王国婚礼 +- 神话天宫婚礼 +- 神秘雪国婚礼 +- 暗夜王族婚礼 + +--- + +# 6. 场景模板设计 + +## 6.1 场景模板是什么 + +场景模板不是简单图片,而是一套可复用的生成规则。 + +每个场景包含: + +- 场景名称 +- 场景描述 +- 服装要求 +- 人物站位 +- 镜头推荐 +- 光影风格 +- 氛围元素 +- 支持特效 +- 适用视觉风格 +- 适用人生主题 + +--- + +## 6.2 明制婚礼场景示例 + +- 王府喜堂 +- 红绸长廊 +- 花轿迎亲 +- 园林仪式台 +- 洞房花烛 +- 夜宴烟花 + +--- + +## 6.3 修仙婚礼场景示例 + +- 云海仙宫 +- 桃花林誓言台 +- 宗门大殿 +- 仙舟婚典 +- 灵兽守护山门 +- 星河天台 + +--- + +## 6.4 未来婚礼场景示例 + +- 银河观景台 +- 太空礼堂 +- 光之穹顶 +- 星舰大厅 +- 全息圣殿 +- 月面婚礼台 + +--- + +# 7. 镜头模板设计 + +每个场景下面可以有多个镜头模板。 + +常见镜头: + +1. 双人正面主婚照 +2. 牵手远景 +3. 对视特写 +4. 誓言镜头 +5. 行礼镜头 +6. 环境氛围镜头 +7. 走向礼台镜头 +8. 花瓣 / 光效氛围镜头 +9. 男方单人特写 +10. 女方单人特写 +11. 仪式全景 +12. 片尾定格镜头 + +--- + +# 8. 用户下单流程 + +## 8.1 流程总览 + +```text +创建项目/订单 +→ 上传照片 +→ 照片检测与筛选 +→ 选择套餐 +→ 选择风格 +→ 选择世界观 +→ 选择场景 +→ 填写定制信息 +→ 系统生成脚本规划 +→ 预览确认 +→ 正式生成图片 +→ 一致性检查与补生成 +→ 生成视频 +→ 质检 +→ 用户确认 +→ 修改/返工 +→ 最终导出交付 +→ 项目归档 +``` + +--- + +# 9. 每一步详细需求 + +## 9.1 创建项目 / 订单 + +用户输入: + +- 项目名称 +- 用户昵称 / 联系方式 +- 用途 +- 作品类型 + +用途包括: + +- 婚礼纪念 +- 纪念日 +- 求婚 +- 生日礼物 +- 父母金婚银婚 + +系统处理: + +- 生成 project_id +- 生成默认工作流 +- 绑定套餐配置 +- 初始化项目状态 + +--- + +## 9.2 上传照片 + +### 双人主题建议上传 + +男方: + +- 正脸清晰照 3~8 张 +- 半身照 1~3 张 +- 侧脸照 1~2 张,可选 + +女方: + +- 正脸清晰照 3~8 张 +- 半身照 1~3 张 +- 侧脸照 1~2 张,可选 + +双人合照: + +- 1~5 张,可选但推荐 + +### 用户提示 + +不要上传: + +- 模糊照片 +- 过度美颜 +- 墨镜遮脸 +- 戴口罩 +- 逆光严重 +- 脸部遮挡 +- 多人混乱 +- 分辨率太低 +- 表情过度夸张 + +--- + +## 9.3 照片预检测与筛选 + +必须检测: + +1. 清晰度 +2. 人脸完整度 +3. 光照质量 +4. 人脸角度 +5. 遮挡情况 +6. 是否多人混入 +7. 重复图 +8. 低质量图 +9. 疑似角色混淆 + +输出结果: + +- 合格照片列表 +- 警告照片列表 +- 不合格照片列表 +- 不合格原因 +- 补图建议 + +如果跳过这一步,后续会出现: + +- 人不像本人 +- 五官失真 +- 男女搞混 +- 多张图不一致 +- 返工成本上升 + +--- + +## 9.4 人物身份建立 + +目标:建立系统内部的双主角身份对象。 + +- Person A:男方 / 主体 A +- Person B:女方 / 主体 B + +处理内容: + +- 汇总高质量人像样本 +- 生成外貌描述 +- 生成气质描述 +- 记录发型、脸型、眼睛、肤色、是否戴眼镜、是否胡须 +- 生成参考图列表 +- 生成主锚点图 + +产出: + +- 人物档案 +- 人物参考图库 +- 人物一致性锚点 +- Prompt 人物描述 + +未来系统 A 合并时,这部分可抽象为「角色库」。 + +--- + +## 9.5 套餐与输出规格选择 + +用户选择: + +- 图集 / 视频 / 长视频 +- 画幅:9:16 / 16:9 / 1:1 +- 分辨率等级 +- 生成数量 +- 是否配旁白 +- 是否配字幕 +- 是否配背景音乐 +- 是否带片头片尾 +- 是否加入名字、日期、纪念文案 + +--- + +## 9.6 世界观与风格选择 + +用户选择: + +1. 视觉风格 +2. 世界观主题 +3. 是否自由组合多世界 +4. 每个世界选几个场景 +5. 是否固定世界顺序 + +组合模式: + +### 模式 A:单主题深度版 + +只做一个世界,例如修仙婚礼,但做多个场景。 + +### 模式 B:多世界穿越版 + +例如: + +- 汉朝 +- 明朝 +- 修仙 +- 未来科技 +- 海底王国 + +--- + +## 9.7 定制信息填写 + +可选输入: + +- 双方名字 +- 纪念日 / 结婚日期 +- 一句话誓言 +- 想表达的主题 +- 文案风格 +- 是否显示名字 +- 是否显示日期 +- 是否允许公开展示 +- 特别要求 + +文案风格: + +- 浪漫 +- 庄重 +- 梦幻 +- 神秘 +- 温馨 +- 搞笑 +- 古风 +- 史诗 + +--- + +## 9.8 自动生成创作方案 + +系统生成: + +1. 作品标题 +2. 作品大纲 +3. 世界顺序 +4. 场景顺序 +5. 每个场景的镜头计划 +6. 每个镜头的画面说明 +7. 每个镜头的 Prompt +8. 视频时长规划 +9. 旁白草稿 +10. 字幕草稿 +11. 片头片尾文案 + +输出给用户或运营确认。 + +--- + +## 9.9 预览确认机制 + +预览内容: + +- 世界观顺序 +- 代表风格图 +- 人物试装预览图 +- 场景缩略预览 +- 文案草稿 +- 作品预计时长 + +确认项: + +- 风格是否满意 +- 人像是否像本人 +- 世界顺序是否满意 +- 场景是否需要替换 +- 文案是否需要修改 + +未确认前,不进入高成本正式生成。 + +--- + +## 9.10 正式图片生成 + +系统按镜头逐一生成: + +- 主婚照镜头 +- 双人仪式镜头 +- 对视镜头 +- 氛围镜头 +- 特写镜头 +- 片尾镜头 + +生成要求: + +- 男女主长相稳定 +- 同一世界服装稳定 +- 场景设定不跑偏 +- 画风统一 +- 不出现多余人物 +- 不出现乱码文字 + +策略: + +- 每个世界先生成锚点主图 +- 后续同世界镜头参考锚点图延展 +- 重要镜头支持多次生成和人工选图 +- 同场景多图引用相同人物设定和服装设定 + +--- + +## 9.11 一致性检查与补生成 + +自动检查: + +1. 男女主是否像本人 +2. 是否人脸崩坏 +3. 是否男女混脸 +4. 手部是否异常 +5. 服饰是否跑偏 +6. 场景是否错题 +7. 是否有乱码文字 +8. 是否多出第三人 +9. 肢体是否不合理 + +处理逻辑: + +- 合格:入选 +- 轻微问题:人工复核 +- 严重问题:自动重生 +- 多次失败:转人工处理或改模板 + +--- + +## 9.12 视频生成 + +视频类型分层: + +### A. 基础视频 + +- 图片 + 推拉镜头 +- 转场 +- 字幕 +- BGM + +### B. 轻动态视频 + +- 图片 + 简单图生视频 +- 花瓣 / 光效 / 粒子 +- 镜头轻运动 + +### C. 高级视频 + +- 多关键帧动态 +- 旁白 +- 高级特效 +- 高成本套餐 + +视频合成内容: + +- 片头 +- 主体 +- 片尾 +- 背景音乐 +- 字幕 +- 旁白 +- 世界切换转场 + +--- + +## 9.13 音频与文案处理 + +### 背景音乐 + +音乐模板: + +- 古风 +- 浪漫钢琴 +- 梦幻 +- 史诗 +- 科幻氛围 +- 温馨家庭 + +注意:必须使用可商用音乐。 + +### 旁白 + +可选: + +- 无旁白 +- 系统旁白 +- 情侣誓言型旁白 +- 纪念文案型旁白 +- 父母祝福型旁白 + +### 字幕 + +- 自动生成 +- 支持名字 +- 支持日期 +- 支持誓言 +- 字数必须控制,避免画面拥挤 + +--- + +## 9.14 质检 + +自动质检: + +- 视频是否合成成功 +- 音画是否同步 +- 字幕是否出框 +- 分辨率是否正确 +- 文件是否可播放 +- 封面是否生成 + +人工质检: + +- 人像是否满意 +- 风格是否符合要求 +- 是否有明显穿帮 +- 片段节奏是否顺 +- 文案是否合理 + +高客单价套餐必须人工质检。 + +--- + +## 9.15 交付与下载 + +交付内容: + +- 成品视频 MP4 +- 封面图 +- 图集原图 +- 可选文案文本 +- 可选无字幕版本 +- 可选有字幕版本 + +下载方式: + +- 项目页下载 +- 有效期下载链接 +- 后台归档 + +--- + +## 9.16 修改与返工机制 + +必须提前定义修改范围。 + +### 小改 + +- 改字幕 +- 改名字 +- 改日期 +- 换音乐 +- 换片尾文案 + +### 中改 + +- 替换 1~2 个场景 +- 重做个别镜头 +- 微调风格 + +### 大改 + +- 全部换主题 +- 全部重做人设 +- 更换整体风格 +- 整条视频重做 + +规则: + +- 标准版允许 1 次小改 +- 高级版允许 2~3 次修改 +- 大改重新计费或重新下单 + +--- + +# 10. 系统功能模块拆分 + +## 10.1 前台 / 用户端 + +- 创建项目 +- 上传照片 +- 选套餐 +- 选风格 +- 选世界 +- 选场景 +- 填文案 +- 预览确认 +- 查看进度 +- 下载交付 +- 申请修改 + +--- + +## 10.2 后台管理端 + +- 用户管理 +- 订单管理 +- 项目管理 +- 模板管理 +- 作品审核 +- 返工处理 +- 素材管理 +- 视频导出管理 +- 日志与错误处理 + +--- + +## 10.3 核心业务模块 + +1. 项目编排模块 +2. 照片检测模块 +3. 人物档案模块 +4. 世界观模板模块 +5. 场景模板模块 +6. Prompt 组装模块 +7. 图像生成模块 +8. 一致性检查模块 +9. 视频合成模块 +10. 音频模块 +11. 字幕模块 +12. 交付模块 +13. 修改申请模块 +14. 授权与隐私模块 + +--- + +# 11. 核心数据模型建议 + +## 11.1 Project + +- project_id +- user_id +- package_type +- output_type +- style_type +- status +- total_duration +- created_at + +## 11.2 PersonProfile + +- person_id +- project_id +- role_type +- appearance_summary +- reference_asset_ids +- quality_score + +## 11.3 Asset + +- asset_id +- project_id +- type +- path +- width +- height +- status +- tags + +## 11.4 WorldTemplate + +- world_id +- category +- title +- prompt_base +- style_rules +- taboo_rules + +## 11.5 SceneTemplate + +- scene_id +- world_id +- title +- prompt_scene +- shot_recommendation +- visual_keywords + +## 11.6 ShotPlan + +- shot_id +- project_id +- world_id +- scene_id +- shot_type +- prompt_text +- duration +- sort_order + +## 11.7 RenderTask + +- task_id +- project_id +- task_type +- input_json +- output_asset_id +- status +- retry_count +- error_message + +## 11.8 RevisionRequest + +- revision_id +- project_id +- type +- request_text +- status + +--- + +# 12. 技术架构建议 + +## 12.1 推荐技术栈 + +后端: + +- Node.js +- NestJS +- REST API +- WebSocket,后续用于进度推送 + +前端: + +- uni-app 用户端 +- Vue3 + Geeker-Admin 后台 + +数据库: + +- MySQL 8 + +队列: + +- Redis + BullMQ + +文件存储: + +- MinIO +- 后续可切 OSS / COS / S3 + +视频处理: + +- FFmpeg + +AI 模型接入: + +- 文本模型接口层 +- 图像模型接口层 +- TTS 接口层 +- 图生视频接口层 +- 审核接口层 + +部署环境: + +- Linux 服务器 +- Nginx +- PM2 / systemd +- Docker 可选 + +--- + +## 12.2 分层架构 + +### 接入层 + +- uni-app 用户端 +- 后台管理端 +- API 网关 + +### 业务层 + +- 用户服务 +- 项目服务 +- 订单服务 +- 模板服务 +- 人物服务 +- 创作编排服务 + +### AI 编排层 + +- Prompt 服务 +- 图像生成服务 +- 音频生成服务 +- 视频生成服务 +- 质检服务 + +### 基础设施层 + +- MySQL +- Redis +- Object Storage +- FFmpeg Worker +- 日志系统 + +--- + +# 13. AI 能力抽象层设计 + +从第一天开始不要把模型写死,要做 Provider 抽象。 + +## 13.1 TextProvider + +负责: + +- 文案生成 +- 分镜生成 +- 旁白生成 +- 字幕草稿生成 + +## 13.2 ImageProvider + +负责: + +- 预览图生成 +- 正式图生成 +- 局部修图 +- 重绘 + +## 13.3 VideoProvider + +负责: + +- 图生短动态片段 +- 高级动态镜头 + +## 13.4 VoiceProvider + +负责: + +- 旁白音频 +- 祝福语音频 + +## 13.5 QualityCheckProvider + +负责: + +- 图片质量检测 +- 人像一致性检测 +- 视频质检 + +--- + +# 14. 开发阶段规划 + +## 第一阶段:MVP + +目标:跑通最小闭环。 + +范围: + +- 上传照片 +- 选择 1 个风格 +- 选择 1~3 个世界模板 +- 选择固定场景 +- 生成图集 +- 合成简单视频 +- 下载成品 + +特点: + +- 不做太多自由组合 +- 不做复杂返工系统 +- 先让系统能交付 + +--- + +## 第二阶段:标准版 + +增加: + +- 多世界组合 +- 预览确认机制 +- 完整模板系统 +- 旁白与字幕 +- 简单返工 +- 更好的质检 + +--- + +## 第三阶段:商业版 + +增加: + +- 套餐计费 +- 修改次数控制 +- 后台审核工作流 +- 模板管理系统 +- 运营案例管理 +- 多风格输出 +- 更高阶视频动态效果 +- 成本统计 +- 隐私删除流程 + +--- + +# 15. 关键踩坑点清单 + +## 15.1 人像一致性不稳 + +风险: + +- 生成结果不像本人 +- 男女脸互串 +- 同一人多张图长相不一致 + +解决: + +- 上传多张高质量参考图 +- 做人物档案与锚点图 +- 同一世界先生成主图再延展 +- 重要场景增加人工选图 + +--- + +## 15.2 照片质量差导致全链路崩 + +风险: + +- 人不像本人 +- 生成失败 +- 返工多 + +解决: + +- 必做照片质检 +- 明确上传规范 +- 不达标必须补传 + +--- + +## 15.3 模板过度自由导致系统失控 + +风险: + +- 用户全自由输入 +- 结果风格乱 +- 成本失控 +- 交付不可控 + +解决: + +- 前期以模板 + 少量定制为主 +- 自由输入只做补充,不直接放到底层 + +--- + +## 15.4 返工无限膨胀 + +风险: + +- 用户反复修改 +- 生成成本爆炸 + +解决: + +- 套餐定义修改次数 +- 小改 / 中改 / 大改分级 +- 大改重新计费 + +--- + +## 15.5 音乐版权风险 + +风险: + +- 商业交付时 BGM 侵权 + +解决: + +- 只使用可商用音乐库 +- 每首音乐记录授权来源 + +--- + +## 15.6 隐私与肖像权风险 + +风险: + +- 上传真人照片涉及隐私与肖像权 + +解决: + +- 用户授权协议 +- 不得默认公开展示 +- 案例展示需单独授权 +- 支持隐藏与删除 + +--- + +## 15.7 历史朝代风格不准确 + +风险: + +- 用户对汉唐宋明清有期待 +- 服饰乱搭会降低质感 + +解决: + +- 每个历史模板做专业设定 +- 服装、头饰、礼仪、色调固定化 + +--- + +## 15.8 视频生成成本失控 + +风险: + +- 全部做高动态视频,成本、速度、稳定性都爆 + +解决: + +- 第一版以图集 + FFmpeg 视频为主 +- 关键镜头再用高级动态 +- 高级动态只放高客单套餐 + +--- + +## 15.9 文案廉价 + +风险: + +- 文案太土、太长、太网感 + +解决: + +- 文案模板分风格 +- 控制字幕长度 +- 提供浪漫、庄重、梦幻、古风等文案模板 + +--- + +# 16. 推荐 MVP 工作流 + +```text +1. 用户创建项目 +2. 上传男女照片 +3. 系统做照片质检 +4. 用户选择套餐、风格、世界模板、场景 +5. 用户填写名字、日期、文案偏好 +6. 系统生成创作方案 +7. 系统生成低成本预览图 +8. 用户确认 +9. 系统生成正式图 +10. 系统合成视频 +11. 系统质检 +12. 用户下载成品 +``` + +--- + +# 17. V1 结论 + +系统 B 第一版可以作为: + +- 产品需求初稿 +- 系统设计基线 +- Codex 开发说明骨架 +- 后续继续细化的主文档 + +这版已经兼顾: + +1. 系统 B 独立运行 +2. 后续与系统 A 合并 +3. 商业定制可行 +4. 模板化与自由定制平衡 +5. 降低返工和踩坑风险 + +V1 后续需要继续细化: + +- 功能清单 +- 页面清单 +- 状态流转 +- 技术架构 +- 数据库表结构 +- API 接口 +- AI 生成流水线 +- Codex 开发任务 diff --git a/docs/system_b/02_需求文档修改v2.md b/docs/system_b/02_需求文档修改v2.md new file mode 100755 index 0000000..8415174 --- /dev/null +++ b/docs/system_b/02_需求文档修改v2.md @@ -0,0 +1,2745 @@ +# 系统 B 完整项目落地需求设计文档 V2 + +## 项目名称 + +**AI 多人生主题写真 / 韩漫 / 纪念视频生成系统** + +简称: + +```text +系统 B +真人照片 → 多人生主题 → 多世界模板 → 韩漫写真 / 纪念视频 +``` + +--- + +# 0. 文档说明 + +本文档是系统 B 的 V2 版本,是在 V1「真人照片 → 婚礼韩漫 / 视频」基础上升级后的完整落地需求设计。 + +V2 的核心变化: + +1. 不再只做婚礼,而是升级为多人生主题平台。 +2. 第一阶段仍然主打婚礼、恋爱纪念、银婚金婚、情侣写真、个人形象。 +3. 后续可以扩展到家庭全家福、宝宝百日、儿童成长、闺蜜写真、亲子纪念等。 +4. 技术上按独立系统 B 开发,但保留后续与系统 A 合并的公共架构。 +5. 模型策略按高质量优先,但所有模型都必须通过 Provider 抽象层管理,不写死单一模型。 + +V3 增量说明: + +```text +系统 B V2 可稳定覆盖写真图集和图片纪念视频。 +如果目标升级为“真人会动、有表情、有动作、像抖音短剧/真人纪念电影”,开发必须按 V3 真人动态视频版执行。 +``` + +V3 不推翻 V2,而是在 V2 上新增: + +- 四档输出模式:高清写真图集、图片纪念视频、动态写真视频、AI 真人动态视频。 +- 真人身份锁定:身份锚点图、本人相似度评分、人脸一致性质检。 +- 动作与视频片段:动作模板、关键帧、图生视频片段、片段质检、片段重试。 +- 可选口型链路:LipSyncTask、音频对齐、失败回退到旁白字幕版本。 +- 国内短剧向 VideoProvider:MiniMax Hailuo、阿里 Wan、Vidu、Seedance、Kling、Runway。 +- 更严格肖像合规:未成年人保护、公开案例二次授权、原图私密存储、后台访问审计。 +- 更强成本控制:按片段秒数、分辨率、候选数、重试、口型和人工审核估算成本。 + +后续开发主依据: + +```text +00_系统B升级说明_真人动态视频.md +01_系统B总需求文档_v3_真人动态视频版.md +``` + +本 V2 文档作为历史基础和非动态视频链路参考保留。 + +--- + +# 1. 重新审核后的关键修正 + +## 1.1 不能只叫婚礼系统 + +V1 的方向可行,但过于偏婚礼。 + +V2 正式改成: + +```text +真人照片驱动的 AI 多人生主题定制影像平台 +``` + +婚礼只是第一个重点场景,不是系统上限。 + +底层业务对象不能叫 `WeddingProject`,应该统一叫: + +```text +Project +LifeTheme +WorldTemplate +SceneTemplate +ShotTemplate +PersonProfile +RenderTask +Asset +Order +RevisionRequest +``` + +这样以后扩展宝宝百日、儿童成长、全家福时,不需要重构核心业务。 + +--- + +## 1.2 用户流程顺序修正 + +正式版推荐流程: + +```text +首页浏览 +→ 登录 +→ 创建项目 +→ 选择人生主题 +→ 选择套餐 +→ 选择视觉风格 +→ 选择世界观 +→ 选择场景 +→ 上传照片 +→ 授权确认 +→ 照片质检 +→ 建立人物档案 +→ 填写定制信息 +→ 生成创作方案 +→ 成本锁定 / 支付 +→ 生成预览 +→ 用户确认 +→ 正式高质量生成 +→ 质检 +→ 视频合成 +→ 人工审核 +→ 用户确认 +→ 交付下载 +``` + +原因: + +- 用户先看主题和模板,更容易转化。 +- 真正进入高成本生成前,必须完成照片质检、授权和支付。 +- 预览阶段要控制成本,正式阶段再使用最高质量生成。 + +--- + +## 1.3 必须加入支付、额度、成本锁定 + +如果使用高质量模型,成本会明显增加。 + +系统必须加入: + +```text +套餐价格 +用户余额 / 订单支付 +预览额度 +正式生成额度 +重试次数 +修改次数 +成本日志 +``` + +正式生成前必须满足: + +```text +照片合格 +授权已确认 +订单已支付 / 额度已冻结 +创作方案已确认 +``` + +否则禁止进入高成本生成流程。 + +--- + +## 1.4 必须加入隐私授权与肖像授权 + +用户上传真人照片,必须有授权流程。 + +上传照片前必须勾选: + +```text +我确认拥有上传照片的合法使用权 +我确认已获得照片中人物授权 +我授权平台为本次项目生成图片和视频 +我知道作品默认不公开展示 +如涉及未成年人,我确认我是监护人或已获得监护人授权 +``` + +公开案例必须单独授权,不能默认展示。 + +--- + +## 1.5 必须加入内容审核 + +系统要审核: + +```text +上传照片 +用户自定义文案 +生成 Prompt +生成图片 +生成视频封面 +最终视频 +``` + +审核模块必须从第一版就设计进去,至少保留接口和状态。 + +--- + +## 1.6 不能把视频模型写死 + +你选择高质量模型没问题,但视频模块一定要抽象成: + +```text +VideoProvider +``` + +原因: + +- 视频模型更新快。 +- API 生命周期可能变化。 +- 成本和速度差异大。 +- 商业系统必须允许切换供应商。 + +所有模型都要通过 Provider 配置,不要硬编码。 + +--- + +## 1.7 必须加入任务幂等和失败恢复 + +AI 生成链路很长: + +```text +照片检测 +人物档案 +方案生成 +预览图 +正式图 +视频片段 +音频 +字幕 +合成 +审核 +``` + +任意一步失败,都不能让整个项目废掉。 + +每个任务必须有: + +```text +task_id +status +retry_count +max_retry +input_hash +provider_request_id +error_code +error_message +can_resume +``` + +同一个任务重复提交,不能重复扣费,必须支持幂等。 + +--- + +# 2. 最终产品定位 + +## 2.1 产品一句话 + +用户上传真人照片,选择人生主题、世界观、场景和风格,系统生成专属韩漫写真、国风写真、多世界穿越纪念视频。 + +--- + +## 2.2 第一阶段主打场景 + +第一阶段重点做 5 类: + +```text +结婚纪念 +恋爱纪念 +父母银婚金婚 +情侣写真 +个人形象定制 +``` + +这 5 类最适合变现,也最适合做案例展示。 + +--- + +## 2.3 后续扩展场景 + +后续可扩展: + +```text +家庭全家福 +宝宝百日 +儿童成长 +闺蜜写真 +亲子纪念 +生日纪念 +毕业纪念 +企业形象写真 +节日主题写真 +``` + +底层模板系统必须支持这些扩展。 + +--- + +# 3. 最高模型策略 + +## 3.1 模型使用原则 + +你选择「都使用最高质量」可以,但系统不能浪费成本。 + +建议策略: + +```text +文字策划:最高推理模型 +图像生成:最高图像模型,高质量档 +视频生成:最高视频模型,但必须可替换 +语音合成:稳定 TTS 模型 +审核:文本 + 图片审核模型 +``` + +--- + +## 3.2 Provider 配置建议 + +系统配置成: + +```text +TextProvider: + quality: highest + +ImageProvider: + quality: high + +VideoProvider: + quality: highest + fallback: other_video_provider + must_be_replaceable: true + +VoiceProvider: + quality: stable + +ModerationProvider: + text_and_image: true +``` + +不要把具体模型名写死在业务代码中。 + +--- + +## 3.3 高质量模型不等于每一步都高成本 + +流程分为: + +```text +方案阶段 +预览阶段 +正式阶段 +返工阶段 +交付阶段 +``` + +正式图和正式视频用最高质量。 +预览图可以: + +- 少数量 +- 低清 +- 加水印 +- 只生成关键场景 + +否则用户不确认就生成大量高清成品,成本会失控。 + +--- + +# 4. 系统总体架构 + +## 4.1 用户端 + +用户端使用: + +```text +uni-app +``` + +支持: + +```text +H5 +微信小程序 +后续 App +``` + +--- + +## 4.2 后台管理端 + +后台使用: + +```text +Geeker-Admin +``` + +用途: + +- 用户管理 +- 项目管理 +- 订单管理 +- 模板管理 +- 任务管理 +- 案例管理 +- 修改申请管理 +- AI Provider 管理 +- 日志管理 +- 系统配置 + +--- + +## 4.3 后端 + +推荐: + +```text +Node.js + NestJS +``` + +原因: + +```text +适合异步任务编排 +适合 BullMQ 队列 +适合 WebSocket 进度推送 +适合对接 uni-app 和后台 +适合 AI Provider 抽象 +``` + +Python 不作为主后端,但作为 AI Worker 辅助: + +```text +照片质量检测 +人脸角度检测 +图像相似度检测 +图片后处理 +局部修复 +视频辅助处理 +``` + +PHP 不建议作为此系统主后端。 + +--- + +## 4.4 基础设施 + +```text +MySQL 8:业务数据 +Redis:队列、缓存、锁 +BullMQ:任务队列 +MinIO:本地对象存储 +FFmpeg:视频合成 +Nginx:反向代理 +PM2 / systemd:进程管理 +Docker:建议使用,但第一版可选 +``` + +--- + +# 5. 完整业务流程 + +## 5.1 游客浏览流程 + +```text +访问首页 +→ 查看案例 +→ 查看主题 +→ 查看世界模板 +→ 查看套餐 +→ 点击开始制作 +→ 登录 / 注册 +``` + +游客可以看: + +```text +首页 +案例 +套餐 +玩法说明 +FAQ +公开案例 +``` + +游客不能: + +```text +创建项目 +上传照片 +生成预览 +下载作品 +申请修改 +``` + +--- + +## 5.2 用户正式制作流程 + +```text +登录 +→ 创建项目 +→ 选择人生主题 +→ 选择套餐 +→ 选择视觉风格 +→ 选择世界观 +→ 选择场景 +→ 上传照片 +→ 勾选授权协议 +→ 照片质检 +→ 人物身份建立 +→ 填写定制信息 +→ 生成创作方案 +→ 用户确认方案 +→ 支付 / 冻结额度 +→ 生成预览图 +→ 用户确认预览 +→ 正式高质量生成图片 +→ 图片质检 +→ 生成音频 / 字幕 +→ 视频合成 +→ 最终质检 +→ 人工审核 +→ 用户确认成品 +→ 下载交付 +→ 项目归档 +``` + +--- + +## 5.3 支付模式建议 + +### 模式 A:先支付后预览 + +适合标准套餐: + +```text +选套餐 +→ 支付 +→ 生成预览 +→ 确认 +→ 正式生成 +``` + +优点:避免白嫖预览。 + +### 模式 B:免费低清预览,正式生成前支付 + +适合引流: + +```text +生成低清水印预览 +→ 用户满意 +→ 支付 +→ 正式高清生成 +``` + +建议第一阶段采用: + +```text +登录用户每天可免费生成 1 次低清水印预览 +正式生成必须支付 +``` + +--- + +# 6. 人生主题设计 + +## 6.1 第一阶段主题 + +| 编码 | 名称 | 人物结构 | 输出建议 | +|---|---|---|---| +| wedding | 结婚纪念 | 双人 | 图集 / 视频 | +| love | 恋爱纪念 | 双人 | 图集 / 短视频 | +| silver_gold_wedding | 银婚金婚 | 双人 / 家庭 | 视频优先 | +| couple_portrait | 情侣写真 | 双人 | 图集优先 | +| personal_portrait | 个人形象定制 | 单人 | 图集 / 头像 / 短视频 | + +--- + +## 6.2 第二阶段主题 + +| 编码 | 名称 | 人物结构 | +|---|---|---| +| family | 家庭全家福 | 多人 | +| baby_100_days | 宝宝百日 | 宝宝 / 父母 | +| child_growth | 儿童成长 | 儿童 | +| best_friends | 闺蜜写真 | 双人 / 多人 | +| parent_child | 亲子纪念 | 成人 + 儿童 | +| birthday | 生日纪念 | 单人 / 多人 | + +--- + +## 6.3 主题必须决定的规则 + +每个主题必须配置: + +```text +需要几个人 +照片上传规则 +可选世界观 +可选场景 +推荐风格 +推荐文案 +推荐视频节奏 +是否允许公开案例 +是否涉及未成年人 +是否需要加强隐私提醒 +``` + +例如宝宝百日、儿童成长、亲子纪念,都必须触发未成年人授权提示。 + +--- + +# 7. 世界观模板设计 + +## 7.1 世界观分类 + +```text +历史朝代系 +仙侠修真系 +未来科技系 +趣味脑洞系 +架空奇幻系 +现代纪念系 +家庭温情系 +儿童童话系 +``` + +--- + +## 7.2 第一阶段建议上线世界 + +第一阶段不要一次上太多,建议先做 20 个精品世界。 + +### 历史朝代系 + +```text +汉朝 +唐朝 +宋朝 +明朝 +清朝 +民国 +``` + +### 仙侠修真系 + +```text +仙宫大婚 +宗门大典 +桃花仙境 +龙宫婚礼 +凤族神婚 +剑仙世界 +``` + +### 未来科技系 + +```text +星际婚礼 +月球基地 +全息圣殿 +未来都市 +赛博城市 +``` + +### 趣味脑洞系 + +```text +恐龙时代 +海底王国 +精灵森林 +云端王国 +``` + +### 现代纪念系 + +```text +现代婚礼 +浪漫海边 +城市夜景 +花园仪式 +``` + +--- + +## 7.3 世界模板字段 + +每个世界模板需要配置: + +```text +world_id +world_code +world_name +category +cover_image +preview_video +description +supported_life_themes +supported_styles +prompt_base +costume_rules +scene_rules +color_rules +lighting_rules +negative_rules +is_premium +status +sort_order +``` + +--- + +# 8. 场景模板设计 + +## 8.1 场景不是图片,是可复用生产规则 + +每个场景包含: + +```text +场景描述 +服装约束 +人物站位 +镜头推荐 +光影 +氛围元素 +可选特效 +适用主题 +适用风格 +生成难度 +成本等级 +``` + +--- + +## 8.2 示例:明制婚礼 + +```text +世界:明朝 +场景: +1. 王府喜堂 +2. 花轿迎亲 +3. 红绸长廊 +4. 园林拜堂 +5. 洞房花烛 +6. 夜宴烟花 +``` + +--- + +## 8.3 示例:修仙婚礼 + +```text +世界:仙宫大婚 +场景: +1. 云海仙宫 +2. 桃花林誓言台 +3. 宗门大殿 +4. 仙舟婚典 +5. 凤凰环绕礼台 +6. 星河天台 +``` + +--- + +## 8.4 示例:未来科技 + +```text +世界:星际婚礼 +场景: +1. 星舰大厅 +2. 银河观景台 +3. 全息圣殿 +4. 月球基地礼堂 +5. 光之穹顶 +6. 星际花园 +``` + +--- + +# 9. 视觉风格设计 + +## 9.1 第一阶段风格 + +```text +韩漫风 +半写实写真风 +国风插画风 +电影写实风 +``` + +--- + +## 9.2 推荐优先级 + +商业落地优先做: + +```text +半写实写真风 +韩漫风 +国风插画风 +``` + +电影写实风虽然高级,但最容易暴露人脸不像、手部异常、动态不自然的问题,所以放在高端套餐更合适。 + +--- + +# 10. 套餐设计 + +## 10.1 标准图集版 + +```text +适合:情侣写真、个人形象 +主题:1 个 +世界:1 个 +场景:3 个 +成品图:6-12 张 +视频:无 +修改:1 次小改 +生成质量:高 +``` + +--- + +## 10.2 短视频版 + +```text +适合:恋爱纪念、结婚纪念 +主题:1 个 +世界:1-3 个 +场景:3-6 个 +成品图:12-24 张 +视频:30-60 秒 +字幕:有 +BGM:有 +旁白:可选 +修改:1 次小改 +``` + +--- + +## 10.3 多世界纪念片 + +```text +适合:跨朝代婚礼、修仙+未来+古代穿越 +主题:1 个 +世界:5-10 个 +场景:5-20 个 +成品图:25-60 张 +视频:1-3 分钟 +字幕:有 +BGM:有 +旁白:有 +片头片尾:有 +修改:2 次 +``` + +--- + +## 10.4 高端定制版 + +```text +适合:婚庆公司、影楼、金婚银婚、婚礼现场播放 +主题:自由定制 +世界:自由组合 +场景:可定制 +图片:高精修 +视频:1-5 分钟 +动态镜头:可选 +人工审核:必须 +修改:按订单配置 +``` + +--- + +# 11. 用户端页面设计 + +用户端使用 uni-app。 + +## 11.1 首页 + +不需要登录。 + +模块: + +```text +顶部 Banner +产品卖点 +热门案例 +热门人生主题 +热门世界模板 +套餐说明 +制作流程 +用户评价 +FAQ +开始制作按钮 +``` + +按钮: + +```text +查看案例 +用同款制作 +选择主题 +查看套餐 +开始制作 +联系客服 +``` + +--- + +## 11.2 案例列表页 + +不需要登录。 + +筛选: + +```text +人生主题 +世界观 +视觉风格 +图集 / 视频 +最新 / 热门 +``` + +只展示用户授权公开的案例。 + +--- + +## 11.3 案例详情页 + +展示: + +```text +案例视频 +案例图集 +使用主题 +使用世界 +使用风格 +套餐推荐 +同款制作按钮 +``` + +--- + +## 11.4 登录注册页 + +支持: + +```text +手机号验证码 +微信授权 +邮箱注册,后续可选 +``` + +--- + +## 11.5 创建项目页 + +必须登录。 + +字段: + +```text +项目名称 +人生主题 +作品用途 +输出类型 +``` + +作品用途: + +```text +自己留念 +送礼物 +婚礼现场播放 +小红书/抖音发布 +父母纪念 +``` + +--- + +## 11.6 主题选择页 + +字段: + +```text +主题名称 +主题封面 +主题说明 +适合人群 +案例数量 +是否推荐 +``` + +--- + +## 11.7 套餐选择页 + +字段: + +```text +套餐名称 +价格 +输出图片数量 +输出视频时长 +世界数量 +场景数量 +修改次数 +是否人工审核 +是否支持高级动态 +``` + +--- + +## 11.8 风格选择页 + +字段: + +```text +风格名称 +预览图 +适合主题 +适合世界 +是否高级风格 +``` + +--- + +## 11.9 世界观选择页 + +支持: + +```text +单世界 +多世界 +自由排序 +同款案例选择 +``` + +限制: + +```text +标准图集:1 个世界 +短视频:1-3 个世界 +多世界纪念片:5-10 个世界 +高端定制:按配置 +``` + +--- + +## 11.10 场景选择页 + +展示结构: + +```text +世界 A + 场景 1 + 场景 2 + 场景 3 + +世界 B + 场景 1 + 场景 2 +``` + +每个场景展示: + +```text +预览图 +适合镜头数 +是否支持视频动态 +是否高级场景 +``` + +--- + +## 11.11 照片上传页 + +根据主题动态变化。 + +### 双人主题 + +```text +男方照片:3-8 张 +女方照片:3-8 张 +双人合照:1-5 张,可选 +``` + +### 单人主题 + +```text +本人照片:3-10 张 +``` + +### 家庭主题 + +```text +每个成员至少 2-5 张 +家庭合照 1-5 张 +``` + +### 儿童 / 宝宝主题 + +```text +宝宝照片 3-10 张 +父母照片可选 +必须勾选监护人授权 +``` + +--- + +## 11.12 授权确认页 + +必须有。 + +勾选项: + +```text +我确认拥有上传照片的合法使用权 +我确认已获得照片中人物授权 +我授权平台为本次项目生成图片和视频 +我知道作品默认不公开展示 +如涉及未成年人,我确认我是监护人或已获得监护人授权 +``` + +--- + +## 11.13 照片质检页 + +检测: + +```text +清晰度 +人脸完整度 +光照 +遮挡 +角度 +多人混入 +重复图片 +角色归属 +疑似低质量 +疑似过度美颜 +``` + +状态: + +```text +pass +warning +fail +``` + +fail 必须补图,warning 可继续但提示风险。 + +--- + +## 11.14 定制信息页 + +字段: + +```text +人物姓名 +关系类型 +纪念日期 +文案风格 +一句话誓言 +是否显示名字 +是否显示日期 +特别要求 +是否允许公开展示 +``` + +文案风格: + +```text +浪漫 +庄重 +温馨 +梦幻 +史诗 +搞笑 +高级电影感 +古风诗意 +``` + +--- + +## 11.15 创作方案预览页 + +展示: + +```text +作品标题 +主题 +风格 +世界顺序 +场景顺序 +镜头数量 +图片数量 +预计视频时长 +旁白草稿 +字幕草稿 +片头片尾文案 +预计消耗额度 +``` + +按钮: + +```text +确认方案 +修改世界 +修改场景 +修改文案 +重新生成方案 +取消项目 +``` + +--- + +## 11.16 支付 / 额度确认页 + +字段: + +```text +套餐价格 +已用优惠 +需支付金额 +预览额度 +正式生成额度 +修改次数 +预计成本说明 +``` + +按钮: + +```text +立即支付 +使用余额 +取消订单 +``` + +--- + +## 11.17 预览生成页 + +展示: + +```text +低清水印预览图 +部分关键镜头 +人物像不像反馈 +风格是否满意 +``` + +按钮: + +```text +满意,进入正式生成 +不满意,重新生成预览 +修改模板 +联系客服 +``` + +--- + +## 11.18 生成进度页 + +用 WebSocket 或轮询。 + +状态节点: + +```text +项目已创建 +照片检测完成 +人物档案建立中 +方案生成中 +等待方案确认 +预览生成中 +等待预览确认 +正式图生成中 +图片质检中 +音频生成中 +字幕生成中 +视频合成中 +人工审核中 +等待用户确认 +已完成 +``` + +--- + +## 11.19 成品确认页 + +展示: + +```text +成品视频 +成品图集 +封面图 +有字幕版本 +无字幕版本 +下载入口 +剩余修改次数 +``` + +按钮: + +```text +确认完成 +申请修改 +下载视频 +下载图片 +删除作品 +授权公开为案例 +``` + +--- + +## 11.20 修改申请页 + +修改分级: + +```text +小改 +中改 +大改 +``` + +小改: + +```text +改名字 +改日期 +改字幕 +换音乐 +改片尾文案 +``` + +中改: + +```text +替换个别图片 +重做 1-2 个场景 +调整部分镜头 +``` + +大改: + +```text +换整体风格 +换全部世界 +重建人设 +整条视频重做 +``` + +大改必须重新计费。 + +--- + +# 12. 后台管理端设计 + +后台使用 Geeker-Admin 二开。 + +## 12.1 仪表盘 + +指标: + +```text +今日订单数 +今日支付金额 +今日生成项目数 +今日完成项目数 +失败任务数 +待审核项目数 +待处理修改数 +AI 调用成本 +存储占用 +热门主题排行 +热门世界排行 +转化率 +``` + +--- + +## 12.2 用户管理 + +字段: + +```text +用户 ID +昵称 +手机号 +微信 openid +注册时间 +项目数 +订单数 +消费金额 +状态 +``` + +--- + +## 12.3 项目管理 + +字段: + +```text +project_id +user_id +life_theme +package_id +style_id +world_count +scene_count +status +payment_status +created_at +completed_at +allow_public_case +``` + +操作: + +```text +查看详情 +查看素材 +查看任务 +手动重试 +转人工 +标记异常 +强制完成 +取消项目 +``` + +--- + +## 12.4 订单管理 + +字段: + +```text +order_id +project_id +user_id +package_id +amount +pay_status +refund_status +pay_time +created_at +``` + +状态: + +```text +pending +paid +cancelled +refunding +refunded +failed +``` + +--- + +## 12.5 模板管理 + +子模块: + +```text +人生主题管理 +世界观模板管理 +场景模板管理 +镜头模板管理 +视觉风格管理 +文案模板管理 +音乐模板管理 +视频模板管理 +套餐管理 +``` + +--- + +## 12.6 任务管理 + +任务类型: + +```text +photo_check +person_profile +plan_generate +prompt_generate +preview_image +final_image +image_qc +video_clip_generate +audio_generate +subtitle_generate +video_render +final_qc +manual_review +``` + +操作: + +```text +查看输入 +查看输出 +重试 +终止 +跳过 +标记人工处理 +查看错误日志 +``` + +--- + +## 12.7 AI Provider 管理 + +Provider 类型: + +```text +TextProvider +ImageProvider +VideoProvider +VoiceProvider +ModerationProvider +FaceCheckProvider +QualityCheckProvider +StorageProvider +``` + +字段: + +```text +provider_id +provider_type +provider_name +model_name +api_base +priority +quality_level +cost_rule +rate_limit +status +fallback_provider_id +``` + +--- + +## 12.8 案例管理 + +字段: + +```text +case_id +project_id +title +cover_image +video_url +theme_id +style_id +world_ids +sort_order +is_featured +status +authorization_record_id +``` + +注意:无授权不能上架。 + +--- + +## 12.9 修改申请管理 + +字段: + +```text +revision_id +project_id +user_id +revision_type +request_text +remaining_count +status +created_at +handled_by +``` + +操作: + +```text +接受 +拒绝 +转人工 +创建重做任务 +标记完成 +``` + +--- + +## 12.10 隐私与授权管理 + +必须独立管理: + +```text +用户授权记录 +公开案例授权 +未成年人授权确认 +删除申请 +数据导出申请 +素材清理记录 +``` + +--- + +# 13. 项目状态流转设计 + +## 13.1 Project 状态 + +```text +draft +template_selecting +photo_uploading +authorization_pending +photo_checking +photo_rejected +person_profiling +info_filling +plan_generating +waiting_plan_confirm +payment_pending +payment_paid +preview_generating +waiting_preview_confirm +final_generating +image_qc +audio_generating +subtitle_generating +video_rendering +final_qc +manual_review +waiting_user_confirm +revision_requested +revising +completed +cancelled +failed +archived +``` + +--- + +## 13.2 主流程 + +```text +draft +→ template_selecting +→ photo_uploading +→ authorization_pending +→ photo_checking +→ person_profiling +→ info_filling +→ plan_generating +→ waiting_plan_confirm +→ payment_pending +→ payment_paid +→ preview_generating +→ waiting_preview_confirm +→ final_generating +→ image_qc +→ audio_generating +→ subtitle_generating +→ video_rendering +→ final_qc +→ manual_review +→ waiting_user_confirm +→ completed +→ archived +``` + +--- + +## 13.3 照片失败分支 + +```text +photo_checking +→ photo_rejected +→ photo_uploading +→ photo_checking +``` + +--- + +## 13.4 支付失败分支 + +```text +payment_pending +→ payment_failed +→ payment_pending / cancelled +``` + +--- + +## 13.5 预览不满意分支 + +```text +waiting_preview_confirm +→ template_selecting / info_filling / preview_generating +``` + +--- + +## 13.6 成品修改分支 + +```text +waiting_user_confirm +→ revision_requested +→ revising +→ final_generating / video_rendering / manual_review +→ waiting_user_confirm +``` + +--- + +# 14. 核心数据表设计 + +## 14.1 users + +```text +id +nickname +phone +email +wechat_openid +avatar +status +created_at +updated_at +``` + +--- + +## 14.2 projects + +```text +id +user_id +title +life_theme_id +package_id +style_id +output_type +status +payment_status +total_duration +image_count +video_url +cover_asset_id +allow_public_case +created_at +updated_at +completed_at +``` + +--- + +## 14.3 orders + +```text +id +user_id +project_id +package_id +amount +pay_status +pay_method +transaction_id +refund_status +created_at +paid_at +``` + +--- + +## 14.4 life_themes + +```text +id +code +name +description +cover_asset_id +person_schema +status +sort_order +``` + +`person_schema` 示例: + +```json +{ + "type": "couple", + "roles": ["person_a", "person_b"], + "min_photos_each": 3, + "max_photos_each": 8 +} +``` + +--- + +## 14.5 person_profiles + +```text +id +project_id +role +name +gender_label +age_group +appearance_summary +reference_asset_ids +anchor_asset_id +quality_score +status +created_at +``` + +--- + +## 14.6 assets + +```text +id +user_id +project_id +asset_type +file_path +file_url +mime_type +width +height +duration +size +hash +visibility +status +created_at +``` + +asset_type: + +```text +upload_photo +preview_image +final_image +audio +subtitle +video +cover +case_asset +``` + +--- + +## 14.7 world_templates + +```text +id +code +name +category +description +cover_asset_id +prompt_base +costume_rules +scene_rules +style_rules +negative_rules +supported_theme_ids +supported_style_ids +is_premium +status +sort_order +``` + +--- + +## 14.8 scene_templates + +```text +id +world_id +code +name +description +cover_asset_id +scene_prompt +composition_rules +lighting_rules +effect_type +recommended_shot_count +is_premium +status +sort_order +``` + +--- + +## 14.9 shot_templates + +```text +id +scene_id +code +name +shot_type +camera_angle +composition +prompt_rule +duration +effect_type +status +sort_order +``` + +--- + +## 14.10 project_worlds + +```text +id +project_id +world_id +sort_order +``` + +--- + +## 14.11 project_scenes + +```text +id +project_id +world_id +scene_id +sort_order +``` + +--- + +## 14.12 shot_plans + +```text +id +project_id +world_id +scene_id +shot_template_id +title +description +prompt_text +negative_prompt +duration +sort_order +status +``` + +--- + +## 14.13 render_tasks + +```text +id +project_id +task_type +provider_id +status +input_json +input_hash +output_asset_id +provider_request_id +retry_count +max_retry +cost_estimate +cost_actual +error_code +error_message +created_at +started_at +finished_at +``` + +--- + +## 14.14 revision_requests + +```text +id +project_id +user_id +revision_type +request_text +status +remaining_count_before +handled_by +created_at +updated_at +``` + +--- + +## 14.15 authorizations + +```text +id +user_id +project_id +authorization_type +content +ip +user_agent +confirmed_at +``` + +authorization_type: + +```text +photo_usage +public_case +minor_guardian +privacy_policy +terms +``` + +--- + +## 14.16 provider_logs + +```text +id +provider_id +project_id +task_id +request_payload +response_payload +status +cost +latency_ms +created_at +``` + +--- + +# 15. AI 生成流水线设计 + +## 15.1 照片输入处理 + +```text +用户上传原图 +→ 去 EXIF +→ 生成缩略图 +→ 存储原图 +→ 人脸检测 +→ 清晰度评分 +→ 角色归属确认 +→ 合格图进入人物档案 +``` + +必须去 EXIF,避免暴露用户位置信息。 + +--- + +## 15.2 人物档案生成 + +每个人生成: + +```text +外貌摘要 +气质描述 +发型描述 +脸型描述 +五官特征 +参考图列表 +主锚点图 +禁用变化点 +``` + +示例: + +```text +person_a: +30岁左右男性,短黑发,脸型偏长,五官清晰,气质沉稳,不要改变发际线,不要变成欧美脸。 +``` + +--- + +## 15.3 创作方案生成 + +输入: + +```text +人生主题 +套餐 +风格 +世界观 +场景 +人物档案 +定制信息 +``` + +输出: + +```text +作品标题 +世界顺序 +场景顺序 +镜头计划 +旁白 +字幕 +片头 +片尾 +总时长 +预计图片数 +预计视频片段数 +``` + +--- + +## 15.4 Prompt 生成 + +Prompt 必须分层组合: + +```text +人物层 +主题层 +世界观层 +场景层 +镜头层 +风格层 +质量层 +限制层 +``` + +最终 Prompt 结构: + +```text +人物描述 ++ 关系描述 ++ 世界观设定 ++ 场景设定 ++ 镜头构图 ++ 光影氛围 ++ 视觉风格 ++ 质量要求 ++ 禁止项 +``` + +--- + +## 15.5 预览图生成 + +预览图规则: + +```text +数量少 +加水印 +低清或中清 +只生成关键场景 +用于确认人物和风格 +``` + +不建议预览阶段生成完整视频。 + +--- + +## 15.6 正式图生成 + +正式图规则: + +```text +高质量图像模型 +高质量档 +按镜头生成 +每张图记录 Prompt +每张图记录 Provider +每张图记录版本 +失败可重试 +``` + +--- + +## 15.7 图像质检 + +自动检测: + +```text +人脸是否明显崩坏 +男女是否混脸 +是否不像本人 +是否多出第三人 +是否手部异常 +是否服装跑偏 +是否场景错误 +是否文字乱入 +是否敏感内容 +``` + +处理: + +```text +合格 → 入选 +轻微问题 → 人工复核 +严重问题 → 自动重生 +连续失败 → 转人工 +``` + +--- + +## 15.8 音频生成 + +音频类型: + +```text +旁白 +祝福语 +誓言 +片头语 +片尾语 +``` + +TTS 参数: + +```text +voice +speed +emotion +tone +format +``` + +--- + +## 15.9 视频合成 + +第一版以 FFmpeg 为核心: + +```text +图片转视频 +推拉镜头 +转场 +字幕 +BGM +旁白 +片头片尾 +封面 +导出 MP4 +``` + +视频动态分级: + +```text +L1:静态图片 + 运镜 +L2:图片 + 粒子 / 花瓣 / 光效 +L3:关键镜头图生视频 +L4:高级动态视频 +``` + +第一阶段推荐: + +```text +80% L1 +15% L2 +5% L3 +``` + +不要第一版就全量 AI 视频化。 + +--- + +# 16. 质量控制标准 + +## 16.1 照片质量标准 + +合格要求: + +```text +人脸清晰 +无遮挡 +不过度美颜 +光照正常 +分辨率足够 +主体明确 +``` + +--- + +## 16.2 人像一致性标准 + +成品应满足: + +```text +同一个人在不同场景中五官稳定 +男女不混脸 +年龄不严重漂移 +气质不严重偏离 +发型可适配世界,但不能完全换人 +``` + +--- + +## 16.3 风格一致性标准 + +同一个项目内: + +```text +画风统一 +色调统一 +人物服饰符合世界设定 +镜头节奏一致 +字幕风格一致 +``` + +--- + +## 16.4 视频交付标准 + +```text +视频能正常播放 +无明显黑屏 +无音画错位 +字幕不出框 +BGM 音量不过大 +旁白清晰 +封面正常 +分辨率符合套餐 +``` + +--- + +# 17. 稳定性设计 + +## 17.1 队列隔离 + +不同任务用不同队列: + +```text +photo_check_queue +text_queue +image_queue +video_queue +audio_queue +ffmpeg_queue +qc_queue +``` + +避免视频任务堵住图片任务。 + +--- + +## 17.2 并发限制 + +按用户、项目、Provider 控制: + +```text +每用户同时最多 1-2 个正式生成项目 +每项目同时最多 N 个图片任务 +每 Provider 设置并发上限 +``` + +--- + +## 17.3 重试策略 + +每个任务: + +```text +默认重试 2-3 次 +失败记录原因 +连续失败转人工 +重试不得重复扣用户额度 +``` + +--- + +## 17.4 幂等设计 + +同一个任务用: + +```text +project_id + task_type + input_hash +``` + +作为幂等 key。 + +如果重复提交: + +```text +已有成功结果 → 直接返回 +已有运行任务 → 返回当前任务 +失败任务 → 允许按规则重试 +``` + +--- + +## 17.5 成本监控 + +必须记录: + +```text +每个任务预计成本 +每个任务实际成本 +每个项目总成本 +每个用户总成本 +每天 Provider 成本 +``` + +--- + +# 18. 隐私、安全、合规设计 + +## 18.1 用户照片隐私 + +必须做到: + +```text +默认私密 +公开案例需单独授权 +支持用户删除作品 +后台敏感操作留日志 +下载链接有有效期 +原图不直接暴露公网 +``` + +--- + +## 18.2 未成年人内容 + +涉及: + +```text +宝宝百日 +儿童成长 +亲子纪念 +家庭全家福 +``` + +必须增加: + +```text +监护人授权确认 +禁止公开展示默认关闭 +后台审核更严格 +``` + +--- + +## 18.3 防滥用 + +禁止: + +```text +上传未经授权的他人照片 +生成侮辱性内容 +生成色情内容 +生成政治人物冒充内容 +生成名人商业冒用内容 +生成违法内容 +``` + +--- + +## 18.4 音乐版权 + +BGM 必须来自: + +```text +平台自有授权音乐 +可商用音乐库 +用户自己上传且承诺有授权 +``` + +每首音乐记录: + +```text +source +license_type +license_file +usage_scope +``` + +--- + +# 19. MVP 范围 + +## 19.1 MVP 必须做 + +```text +用户注册登录 +首页案例展示 +创建项目 +主题选择 +套餐选择 +风格选择 +世界选择 +场景选择 +照片上传 +授权确认 +照片质检 +人物档案 +定制信息 +创作方案 +支付/额度锁定 +预览图生成 +正式图生成 +简单视频合成 +成品下载 +修改申请 +后台项目管理 +后台模板管理 +后台任务管理 +后台案例管理 +AI Provider 管理 +``` + +--- + +## 19.2 MVP 暂缓 + +```text +复杂分销 +多商户 +自动发布短视频 +完整 App +真人语音克隆 +复杂图生视频批量化 +高级会员体系 +全自动退款 +企业 API 开放平台 +``` + +--- + +# 20. 开发阶段规划 + +## 第 1 阶段:基础框架 + +```text +Node.js + NestJS 项目 +MySQL +Redis +BullMQ +MinIO +uni-app 用户端 +Geeker-Admin 后台 +登录注册 +文件上传 +基础项目表 +``` + +--- + +## 第 2 阶段:模板系统 + +```text +人生主题 +套餐 +视觉风格 +世界观 +场景 +镜头 +文案 +音乐 +``` + +--- + +## 第 3 阶段:项目创建流程 + +```text +创建项目 +选择主题 +选择套餐 +选择风格 +选择世界 +选择场景 +上传照片 +授权确认 +填写定制信息 +``` + +--- + +## 第 4 阶段:AI 生成流程 + +```text +照片质检 +人物档案 +创作方案 +Prompt 生成 +预览图 +正式图 +图片质检 +``` + +--- + +## 第 5 阶段:视频合成 + +```text +图片排序 +字幕生成 +TTS +BGM +FFmpeg 合成 +封面生成 +MP4 导出 +``` + +--- + +## 第 6 阶段:商业闭环 + +```text +订单支付 +额度冻结 +成本记录 +成品下载 +修改申请 +人工审核 +案例授权 +``` + +--- + +## 第 7 阶段:稳定性增强 + +```text +任务幂等 +失败重试 +Provider 切换 +成本看板 +错误告警 +素材清理 +隐私删除 +日志审计 +``` + +--- + +# 21. Codex 开发拆解方向 + +后续可以按下面顺序交给 Codex: + +```text +任务 1:初始化 NestJS 后端项目 +任务 2:设计 MySQL 表结构和 Prisma/TypeORM 模型 +任务 3:实现用户登录注册 +任务 4:实现文件上传到 MinIO +任务 5:实现项目创建流程 API +任务 6:实现模板管理 API +任务 7:接入 Geeker-Admin 后台页面 +任务 8:实现 uni-app 用户端页面 +任务 9:实现 BullMQ 任务队列 +任务 10:实现 AI Provider 抽象层 +任务 11:实现照片质检 Worker +任务 12:实现创作方案生成 +任务 13:实现图片生成任务 +任务 14:实现图片质检任务 +任务 15:实现 TTS 和字幕任务 +任务 16:实现 FFmpeg 视频合成 +任务 17:实现订单和额度系统 +任务 18:实现修改申请和人工审核 +任务 19:实现案例展示和授权公开 +任务 20:实现日志、成本、告警、清理 +``` + +每个任务都要写: + +```text +目标 +输入 +输出 +涉及文件 +涉及表 +涉及接口 +验收标准 +注意事项 +``` + +--- + +# 22. 最终审核结论 + +V2 比 V1 更适合真实落地,核心修正是: + +```text +1. 从婚礼工具升级成多人生主题平台 +2. 修正用户流程顺序 +3. 加入支付 / 额度 / 成本锁定 +4. 加入肖像授权和隐私体系 +5. 加入内容审核 +6. 加入任务幂等和失败恢复 +7. 加入 Provider 可替换设计 +8. 避免绑定单一视频模型 +9. 增强照片质检和人物档案 +10. 明确 MVP 边界和开发阶段 +``` + +建议第一版不要把所有人生主题都上线,只上线: + +```text +结婚纪念 +恋爱纪念 +银婚金婚 +情侣写真 +个人形象定制 +``` + +其它主题先在后台模板体系里预留,不在前端主推。 + +--- + +# 23. V2 开发主线建议 + +按以下优先级执行: + +```text +先做能跑通的闭环 +再做生成质量 +再做商业支付 +再做后台运营 +再做稳定性和成本控制 +最后做多主题扩展 +``` + +第一版真正验收目标: + +```text +用户注册登录 +→ 创建项目 +→ 选择主题/套餐/风格/世界/场景 +→ 上传照片 +→ 照片质检 +→ 生成创作方案 +→ 支付/额度确认 +→ 生成预览 +→ 正式生成图 +→ 合成视频 +→ 下载成品 +``` + +只要这条链路稳定跑通,系统 B 就具备商业试单能力。 diff --git a/docs/system_b/03_功能清单_页面清单_状态流转设计.md b/docs/system_b/03_功能清单_页面清单_状态流转设计.md new file mode 100755 index 0000000..dfd910a --- /dev/null +++ b/docs/system_b/03_功能清单_页面清单_状态流转设计.md @@ -0,0 +1,769 @@ +# 03_功能清单_页面清单_状态流转设计 + +## 1. 文档目标 + +本文档定义系统 B 的功能边界、用户端页面、后台页面、状态流转、权限边界和每个页面的关键操作。 + +## 2. 角色定义 + +| 角色 | 说明 | +|---|---| +| 游客 | 未登录用户,可浏览首页、案例、套餐 | +| 普通用户 | 已登录用户,可创建项目、上传照片、生成作品 | +| 运营人员 | 管理案例、模板、审核作品、处理修改 | +| 管理员 | 管理用户、订单、Provider、系统配置 | +| 超级管理员 | 拥有全部权限,包括密钥配置、数据删除、系统设置 | + +## 3. 用户端页面清单 + +### 3.1 首页 + +功能:展示产品价值、案例、套餐、开始制作入口。 + +模块: + +- Banner +- 热门案例 +- 热门主题 +- 热门世界观 +- 制作流程 +- 套餐对比 +- 用户评价 +- FAQ + +按钮: + +- 查看案例 +- 用同款制作 +- 选择主题 +- 查看套餐 +- 开始制作 +- 联系客服 + +登录要求:不需要。 + +### 3.2 案例列表页 + +筛选条件: + +- 人生主题 +- 世界观 +- 视觉风格 +- 图集 / 视频 +- 最新 / 热门 + +注意:只展示已授权公开案例。 + +### 3.3 案例详情页 + +展示: + +- 案例视频 +- 案例图集 +- 使用主题 +- 使用世界观 +- 使用风格 +- 套餐推荐 + +按钮: + +- 播放视频 +- 查看图集 +- 用同款制作 +- 收藏案例 + +### 3.4 登录/注册页 + +第一阶段支持: + +- 手机号验证码 +- 微信授权 + +后续可加: + +- 邮箱注册 +- 账号密码 + +### 3.5 创建项目页 + +字段: + +- 项目名称 +- 人生主题 +- 作品用途 +- 输出类型 + +作品用途: + +- 自己留念 +- 送礼物 +- 婚礼现场播放 +- 社交平台发布 +- 父母纪念 + +### 3.6 主题选择页 + +字段: + +- 主题名称 +- 主题封面 +- 主题说明 +- 适合人群 +- 案例数量 +- 是否推荐 + +第一阶段主题: + +- 结婚纪念 +- 恋爱纪念 +- 银婚金婚 +- 情侣写真 +- 个人形象定制 + +### 3.7 套餐选择页 + +字段: + +- 套餐名称 +- 价格 +- 图片数量 +- 视频时长 +- 世界数量 +- 场景数量 +- 修改次数 +- 是否人工审核 +- 是否支持高级动态 + +按钮: + +- 选择套餐 +- 查看套餐详情 +- 下一步 + +### 3.8 风格选择页 + +风格: + +- 韩漫风 +- 半写实写真风 +- 国风插画风 +- 电影写实风 + +字段: + +- 风格预览图 +- 适合主题 +- 适合世界 +- 是否高级风格 + +### 3.9 世界观选择页 + +模式: + +- 单世界 +- 多世界 +- 自由排序 +- 同款案例带入 + +套餐限制: + +| 套餐 | 世界数量 | +|---|---| +| 标准图集 | 1 个 | +| 短视频 | 1-3 个 | +| 多世界纪念片 | 5-10 个 | +| 高端定制 | 按订单配置 | + +### 3.10 场景选择页 + +每个世界下选择场景。 + +字段: + +- 场景名称 +- 场景预览图 +- 推荐镜头数 +- 是否支持视频动态 +- 是否高级场景 + +### 3.11 照片上传页 + +双人主题: + +- 人物 A:3-8 张 +- 人物 B:3-8 张 +- 双人合照:1-5 张,可选 + +单人主题: + +- 本人照片:3-10 张 + +家庭主题: + +- 每位成员 2-5 张 +- 家庭合照 1-5 张 + +禁止上传: + +- 模糊照片 +- 戴墨镜/口罩 +- 过度美颜 +- 逆光严重 +- 脸部遮挡 +- 多人混乱 + +### 3.12 授权确认页 + +必须勾选: + +- 我确认拥有上传照片的合法使用权 +- 我确认已获得照片中人物授权 +- 我授权平台为本次项目生成图片和视频 +- 我知道作品默认不公开展示 +- 如涉及未成年人,我确认我是监护人或已获得监护人授权 + +### 3.13 照片质检页 + +检测: + +- 清晰度 +- 人脸完整度 +- 光照 +- 遮挡 +- 角度 +- 多人混入 +- 重复图片 +- 角色归属 +- 过度美颜 + +状态: + +- pass:合格 +- warning:可用但有风险 +- fail:必须替换 + +### 3.14 定制信息页 + +字段: + +- 人物姓名 +- 关系类型 +- 纪念日期 +- 文案风格 +- 一句话誓言 +- 是否显示名字 +- 是否显示日期 +- 特别要求 +- 是否允许公开展示 + +### 3.15 创作方案预览页 + +展示: + +- 作品标题 +- 主题 +- 风格 +- 世界顺序 +- 场景顺序 +- 镜头数量 +- 图片数量 +- 预计视频时长 +- 旁白草稿 +- 字幕草稿 +- 预计消耗额度 + +按钮: + +- 确认方案 +- 修改世界 +- 修改场景 +- 修改文案 +- 重新生成方案 +- 取消项目 + +### 3.16 支付/额度确认页 + +字段: + +- 套餐价格 +- 优惠金额 +- 需支付金额 +- 预览额度 +- 正式生成额度 +- 修改次数 + +按钮: + +- 立即支付 +- 使用余额 +- 取消订单 + +### 3.17 预览确认页 + +展示: + +- 低清水印预览图 +- 关键镜头 +- 人物像不像反馈入口 +- 风格满意度反馈入口 + +按钮: + +- 满意,进入正式生成 +- 不满意,重新生成预览 +- 修改模板 +- 联系客服 + +### 3.18 生成进度页 + +进度节点: + +```text +项目已创建 +照片检测完成 +人物档案建立中 +方案生成中 +等待方案确认 +预览生成中 +等待预览确认 +正式图生成中 +图片质检中 +音频生成中 +字幕生成中 +视频合成中 +人工审核中 +等待用户确认 +已完成 +``` + +### 3.19 成品确认页 + +展示: + +- 成品视频 +- 成品图集 +- 封面图 +- 有字幕版本 +- 无字幕版本 +- 下载入口 +- 剩余修改次数 + +按钮: + +- 确认完成 +- 申请修改 +- 下载视频 +- 下载图片 +- 删除作品 +- 授权公开为案例 + +### 3.20 修改申请页 + +修改类型: + +- 小改:名字、日期、字幕、音乐、片尾文案 +- 中改:替换个别图片、重做 1-2 个场景 +- 大改:换整体风格、换全部世界、整条重做 + +规则:大改必须重新计费。 + +### 3.21 我的项目页 + +字段: + +- 项目名称 +- 缩略图 +- 主题 +- 套餐 +- 状态 +- 创建时间 +- 是否可下载 + +### 3.22 用户中心页 + +功能: + +- 个人信息 +- 我的项目 +- 我的订单 +- 我的收藏 +- 隐私设置 +- 删除作品申请 +- 联系客服 +- 退出登录 + +## 4. 后台页面清单 + +后台使用 Geeker-Admin 二开。 + +### 4.1 仪表盘 + +指标: + +- 今日订单数 +- 今日支付金额 +- 今日生成项目数 +- 今日完成项目数 +- 失败任务数 +- 待审核项目数 +- 待处理修改数 +- AI 调用成本 +- 存储占用 +- 热门主题排行 +- 热门世界排行 + +### 4.2 用户管理 + +操作: + +- 查看用户 +- 禁用用户 +- 恢复用户 +- 查看用户项目 +- 查看用户订单 + +### 4.3 项目管理 + +操作: + +- 查看详情 +- 查看素材 +- 查看任务 +- 手动重试 +- 转人工 +- 标记异常 +- 强制完成 +- 取消项目 + +### 4.4 订单管理 + +操作: + +- 查看订单 +- 标记支付 +- 退款记录 +- 修改套餐 +- 查看关联项目 + +### 4.5 模板管理 + +子模块: + +- 人生主题管理 +- 世界观模板管理 +- 场景模板管理 +- 镜头模板管理 +- 视觉风格管理 +- 文案模板管理 +- 音乐模板管理 +- 视频模板管理 +- 套餐管理 + +### 4.6 任务管理 + +操作: + +- 查看输入 +- 查看输出 +- 重试 +- 终止 +- 跳过 +- 标记人工处理 +- 查看错误日志 + +### 4.7 AI Provider 管理 + +管理: + +- TextProvider +- ImageProvider +- VideoProvider +- VoiceProvider +- ModerationProvider +- FaceCheckProvider +- QualityCheckProvider + +### 4.8 案例管理 + +操作: + +- 从项目生成案例 +- 设置首页推荐 +- 上架/下架 +- 排序 +- 查看授权记录 + +### 4.9 修改申请管理 + +操作: + +- 接受 +- 拒绝 +- 转人工 +- 创建重做任务 +- 标记完成 + +### 4.10 隐私与授权管理 + +管理: + +- 用户授权记录 +- 公开案例授权 +- 未成年人授权确认 +- 删除申请 +- 数据导出申请 +- 素材清理记录 + +## 5. 项目状态流转 + +### 5.1 Project 状态枚举 + +```text +draft +template_selecting +photo_uploading +authorization_pending +photo_checking +photo_rejected +person_profiling +info_filling +plan_generating +waiting_plan_confirm +payment_pending +payment_paid +preview_generating +waiting_preview_confirm +final_generating +image_qc +audio_generating +subtitle_generating +video_rendering +final_qc +manual_review +waiting_user_confirm +revision_requested +revising +completed +cancelled +failed +archived +``` + +### 5.2 主流程 + +```text +draft +→ template_selecting +→ photo_uploading +→ authorization_pending +→ photo_checking +→ person_profiling +→ info_filling +→ plan_generating +→ waiting_plan_confirm +→ payment_pending +→ payment_paid +→ preview_generating +→ waiting_preview_confirm +→ final_generating +→ image_qc +→ audio_generating +→ subtitle_generating +→ video_rendering +→ final_qc +→ manual_review +→ waiting_user_confirm +→ completed +→ archived +``` + +### 5.3 异常分支 + +照片失败: + +```text +photo_checking → photo_rejected → photo_uploading → photo_checking +``` + +支付失败: + +```text +payment_pending → payment_failed → payment_pending / cancelled +``` + +预览不满意: + +```text +waiting_preview_confirm → template_selecting / info_filling / preview_generating +``` + +成品修改: + +```text +waiting_user_confirm → revision_requested → revising → final_generating / video_rendering / manual_review → waiting_user_confirm +``` + +## 6. 任务状态 + +```text +pending +running +success +failed +retrying +cancelled +manual_required +``` + +任务类型: + +```text +photo_check +person_profile +plan_generate +prompt_generate +preview_image +final_image +image_qc +video_clip_generate +audio_generate +subtitle_generate +video_render +final_qc +manual_review +``` + +## 7. V3 真人动态视频页面增量 + +### 7.1 输出模式选择 + +创建项目时必须让用户明确选择输出模式: + +```text +photo_album:高清写真图集 +image_video:图片纪念视频 +motion_portrait:动态写真视频 +real_video:AI 真人动态视频 +premium_film:高端真人纪念片 +``` + +页面必须提示: + +- AI 真人动态视频生成时间更长。 +- AI 真人动态视频费用更高。 +- 真实视频可能需要人工审核和多次重试。 +- 不保证每个动作完全自然。 +- 真实视频失败时不会用 mock 假成功。 + +### 7.2 V3 照片上传要求 + +真人动态视频比图集更依赖参考照片,上传页应提高要求: + +```text +单人:正脸清晰照 5-10 张,半身照 2-5 张,不同表情 2-5 张。 +情侣/夫妻:男方 5-10 张,女方 5-10 张,双人合照 2-5 张。 +家庭:每位成员 3-8 张,家庭合照 2-5 张。 +``` + +禁止: + +- 过度美颜 +- 脸部遮挡 +- 多人混乱 +- 低清晰度 +- 强滤镜 +- 明显年龄不符照片 + +### 7.3 身份锚点确认页 + +新增页面或步骤: + +- 展示每个人物的主参考照。 +- 展示 AI 生成的身份锚点图。 +- 显示人脸一致性评分。 +- 用户选择“像本人 / 不像本人 / 需要重生”。 +- 未确认身份锚点,不允许进入正式动态视频生成。 + +### 7.4 视频片段页 + +AI 真人动态视频项目新增片段管理: + +- 镜头列表。 +- 每个镜头动作模板。 +- 输入关键帧。 +- 输出视频片段。 +- Provider、分辨率、时长、预计成本。 +- 生成中 / 成功 / 失败 / 待质检状态。 +- 片段预览。 +- 重试、替换、转人工。 + +### 7.5 口型任务页,可选 + +如果项目启用口型: + +- 选择需要口型的片段。 +- 绑定音频资产。 +- 生成 lip-sync 任务。 +- 失败时可回退为旁白字幕版本。 + +## 8. V3 后台页面增量 + +后台项目详情页新增: + +- 人物身份档案。 +- 身份锚点图和用户确认状态。 +- 人脸一致性评分。 +- 动作模板。 +- 视频片段列表。 +- 口型任务列表。 +- 视频 Provider 和成本。 +- 片段质检结果。 +- 片段重试和替换入口。 + +后台审核页新增: + +- 是否像本人。 +- 是否变脸。 +- 是否男女混脸。 +- 是否年龄变化过大。 +- 动作是否自然。 +- 表情是否怪异。 +- 是否有不合适姿势。 +- 是否允许公开案例。 + +## 9. V3 状态流转增量 + +AI 真人动态视频完整状态建议: + +```text +photo_checking +→ person_profiling +→ identity_anchor_generating +→ waiting_identity_confirm +→ plan_generating +→ payment_paid +→ keyframe_generating +→ video_clip_estimating +→ video_clip_generating +→ video_clip_qc +→ audio_generating +→ lipsync_generating,可选 +→ subtitle_generating +→ video_rendering +→ final_qc +→ manual_review +→ waiting_user_confirm +→ completed +``` + +新增任务类型: + +```text +identity_anchor_generate +face_consistency_check +keyframe_generate +motion_portrait_generate +real_video_clip_generate +video_clip_qc +lipsync_generate +``` diff --git a/docs/system_b/04_技术架构设计_模块拆分.md b/docs/system_b/04_技术架构设计_模块拆分.md new file mode 100755 index 0000000..4de8c4f --- /dev/null +++ b/docs/system_b/04_技术架构设计_模块拆分.md @@ -0,0 +1,368 @@ +# 04_技术架构设计_模块拆分 + +## 1. 文档目标 + +本文档定义系统 B 的技术架构、模块拆分、服务边界、基础设施和后续与系统 A 合并的预留方式。 + +## 2. 总体架构 + +```text +uni-app 用户端 + ↓ +NestJS API 服务 + ↓ +MySQL / Redis / MinIO + ↓ +BullMQ 任务队列 + ↓ +AI Provider Workers / FFmpeg Worker / Python Worker + ↓ +成品资源存储与交付 + +Geeker-Admin 后台 + ↓ +NestJS Admin API + ↓ +模板、项目、订单、任务、Provider、日志管理 +``` + +## 3. 技术栈 + +| 层级 | 技术 | +|---|---| +| 用户端 | uni-app | +| 后台端 | Geeker-Admin | +| 后端 | Node.js + NestJS | +| 数据库 | MySQL 8 | +| 队列 | Redis + BullMQ | +| 对象存储 | MinIO,本地优先,后续可换 OSS/COS/S3 | +| 视频合成 | FFmpeg | +| AI 辅助 | Python Worker 可选 | +| 进程管理 | PM2 或 systemd | +| 反向代理 | Nginx | +| 部署 | AlmaLinux / Ubuntu 均可,推荐 Docker 化 | + +## 4. 后端模块拆分 + +### 4.1 AuthModule + +负责: + +- 手机号验证码登录 +- 微信授权登录 +- JWT 令牌 +- 后台管理员登录 +- 权限校验 + +### 4.2 UserModule + +负责: + +- 用户信息 +- 用户状态 +- 用户项目列表 +- 用户订单列表 +- 用户隐私设置 + +### 4.3 ProjectModule + +负责: + +- 创建项目 +- 项目状态流转 +- 项目详情 +- 项目进度 +- 项目归档 + +### 4.4 TemplateModule + +负责: + +- 人生主题 +- 套餐 +- 风格 +- 世界观 +- 场景 +- 镜头 +- 文案模板 +- 音乐模板 + +### 4.5 AssetModule + +负责: + +- 文件上传 +- 图片/视频/音频资源管理 +- 缩略图 +- 下载链接 +- 水印资源 +- 素材删除 + +### 4.6 PhotoModule + +负责: + +- 照片上传规则 +- 照片质检任务创建 +- 人物角色归属 +- 人物档案创建 + +### 4.7 AIProviderModule + +负责: + +- TextProvider +- ImageProvider +- VideoProvider +- VoiceProvider +- ModerationProvider +- FaceCheckProvider +- QualityCheckProvider + +业务代码调用统一接口,不直接调用具体模型。 + +### 4.8 WorkflowModule + +负责编排: + +- 创作方案生成 +- 预览图生成 +- 正式图生成 +- 音频/字幕生成 +- 视频合成 +- 最终质检 + +### 4.9 QueueModule + +负责: + +- BullMQ 队列注册 +- 任务创建 +- 任务重试 +- 任务取消 +- 任务进度更新 + +### 4.10 OrderModule + +负责: + +- 订单创建 +- 支付状态 +- 额度冻结 +- 额度扣减 +- 退款记录 + +### 4.11 RevisionModule + +负责: + +- 修改申请 +- 修改次数校验 +- 修改任务创建 +- 人工处理记录 + +### 4.12 CaseModule + +负责: + +- 首页案例 +- 案例上架/下架 +- 同款制作 +- 公开授权校验 + +### 4.13 AdminModule + +负责后台管理接口,包括: + +- 用户管理 +- 项目管理 +- 订单管理 +- 模板管理 +- 任务管理 +- Provider 管理 +- 日志管理 + +### 4.14 ComplianceModule + +负责: + +- 授权记录 +- 隐私协议确认 +- 未成年人授权确认 +- 内容审核 +- 用户删除申请 + +### 4.15 LogModule + +负责: + +- 操作日志 +- 登录日志 +- AI 调用日志 +- 成本日志 +- 错误日志 + +## 5. AI Provider 抽象 + +Provider 接口: + +```ts +interface TextProvider { generate(input): Promise } +interface ImageProvider { generate(input): Promise } +interface VideoProvider { generate(input): Promise } +interface VoiceProvider { synthesize(input): Promise } +interface ModerationProvider { check(input): Promise } +``` + +配置来自数据库或环境变量,支持: + +- 优先级 +- 限流 +- 失败切换 +- 成本规则 +- 启用/禁用 + +## 6. 队列设计 + +队列: + +```text +photo_check_queue +text_queue +image_queue +video_queue +audio_queue +ffmpeg_queue +qc_queue +cleanup_queue +``` + +设计原则: + +1. 图像、视频、音频队列隔离。 +2. 高成本任务必须检查订单/额度。 +3. 任务必须有幂等 key。 +4. 失败后按策略重试。 +5. 多次失败转人工处理。 + +## 7. 存储设计 + +MinIO Bucket 建议: + +```text +uploads/ 用户上传原图 +previews/ 预览图 +generated/ 正式生成图 +videos/ 成品视频 +audios/ TTS 音频 +subtitles/ 字幕文件 +cases/ 公开案例资源 +temp/ 临时文件 +``` + +原则: + +- 用户原图默认私密。 +- 成品下载使用签名链接。 +- 下载链接设置有效期。 +- 公开案例单独复制到 cases 路径。 + +## 8. 视频合成架构 + +第一阶段使用 FFmpeg: + +```text +图片素材 ++ 运镜参数 ++ 字幕 SRT ++ 旁白音频 ++ BGM ++ 转场配置 +→ FFmpeg Worker +→ MP4 成品 +``` + +视频等级: + +- L1:图片 + 运镜 +- L2:图片 + 简单粒子/光效叠加 +- L3:关键镜头图生视频 +- L4:高级动态视频 + +第一阶段建议主做 L1 + L2。 + +## 9. Python Worker 定位 + +Python 只做辅助: + +- 人脸检测 +- 清晰度检测 +- 图片相似度 +- 人像一致性评分 +- 图片裁切/缩放 +- 视频后处理 + +主业务不放 Python,避免双主系统复杂。 + +## 10. 后续与系统 A 合并预留 + +未来系统 A「原创小说 → 韩漫 / 漫剧」可复用: + +- AuthModule +- UserModule +- AssetModule +- TemplateModule +- AIProviderModule +- QueueModule +- WorkflowModule +- OrderModule +- LogModule +- AdminModule + +系统 B 独有: + +- 真人照片质检 +- 人物档案 +- 肖像授权 +- 人生主题写真模板 + +系统 A 独有: + +- 小说生成 +- 剧情大纲 +- 分集分镜 +- 漫剧角色库 +- 连载发布 + +## 11. V3 真人动态视频架构增量 + +V3 在系统 B 中新增以下模块: + +```text +IdentityAnchorModule:身份锚点、用户确认、后台确认 +FaceConsistencyModule:本人相似度、人脸一致性质检、混脸检测 +MotionTemplateModule:动作模板、动作 Prompt、动作成本等级 +VideoClipModule:真人动态视频片段生成、预览、重试、替换、质检 +LipSyncModule:口型任务、音频绑定、失败回退 +RealVideoProviderModule:Hailuo/Wan/Vidu/Seedance/Kling/Runway 配置与调用 +``` + +架构原则: + +- 图片纪念视频继续走 FFmpeg,成本可控。 +- 动态写真和 AI 真人动态视频独立成片段任务,不复用最终视频合成接口直接调用高成本 Provider。 +- 真实视频 Provider 默认禁用,必须配置成本阈值和用户确认。 +- 每个视频片段单独落库,支持单片段重试和替换。 +- 口型是可选高级任务,失败时回退旁白字幕版本。 + +V3 推荐链路: + +```text +身份锚点 +→ 关键帧 +→ VideoProvider 生成片段 +→ 片段质检 +→ 可选口型 +→ FFmpeg 合成 +→ 最终质检 +``` diff --git a/docs/system_b/05_数据库表结构设计.md b/docs/system_b/05_数据库表结构设计.md new file mode 100755 index 0000000..7e86efe --- /dev/null +++ b/docs/system_b/05_数据库表结构设计.md @@ -0,0 +1,577 @@ +# 05_数据库表结构设计 + +## 1. 文档目标 + +本文档定义系统 B 的 MySQL 核心表结构。字段类型为建议值,实际开发时可根据 ORM 选型调整。 + +## 2. 命名原则 + +- 表名使用复数:`users`, `projects` +- 主键统一为 `id BIGINT` 或 UUID,根据实现决定 +- 时间字段统一:`created_at`, `updated_at`, `deleted_at` +- 状态字段统一使用字符串枚举 +- 金额使用整数分:`amount_cent` +- JSON 配置使用 `JSON` 类型 + +## 3. users 用户表 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | BIGINT PK | 用户 ID | +| nickname | VARCHAR(100) | 昵称 | +| phone | VARCHAR(30) | 手机号 | +| email | VARCHAR(100) | 邮箱 | +| wechat_openid | VARCHAR(100) | 微信 openid | +| avatar_asset_id | BIGINT | 头像资源 | +| status | VARCHAR(30) | active/disabled | +| created_at | DATETIME | 创建时间 | +| updated_at | DATETIME | 更新时间 | + +索引: + +- UNIQUE(phone) +- UNIQUE(wechat_openid) + +## 4. admin_users 后台用户表 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | BIGINT PK | 管理员 ID | +| username | VARCHAR(100) | 用户名 | +| password_hash | VARCHAR(255) | 密码哈希 | +| role_id | BIGINT | 角色 ID | +| status | VARCHAR(30) | 状态 | +| created_at | DATETIME | 创建时间 | + +## 5. roles 角色表 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | BIGINT PK | 角色 ID | +| name | VARCHAR(100) | 角色名称 | +| code | VARCHAR(100) | 角色编码 | +| permissions | JSON | 权限列表 | +| status | VARCHAR(30) | 状态 | + +## 6. projects 项目表 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | BIGINT PK | 项目 ID | +| user_id | BIGINT | 用户 ID | +| title | VARCHAR(200) | 项目标题 | +| life_theme_id | BIGINT | 人生主题 | +| package_id | BIGINT | 套餐 | +| style_id | BIGINT | 视觉风格 | +| output_type | VARCHAR(30) | image/video/both | +| status | VARCHAR(50) | 项目状态 | +| payment_status | VARCHAR(50) | 支付状态 | +| total_duration | INT | 视频秒数 | +| image_count | INT | 图片数量 | +| cover_asset_id | BIGINT | 封面 | +| video_asset_id | BIGINT | 视频 | +| allow_public_case | BOOLEAN | 是否允许公开案例 | +| custom_info | JSON | 用户定制信息 | +| created_at | DATETIME | 创建时间 | +| updated_at | DATETIME | 更新时间 | +| completed_at | DATETIME | 完成时间 | + +索引: + +- INDEX(user_id, status) +- INDEX(created_at) +- INDEX(life_theme_id) + +## 7. orders 订单表 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | BIGINT PK | 订单 ID | +| user_id | BIGINT | 用户 ID | +| project_id | BIGINT | 项目 ID | +| package_id | BIGINT | 套餐 ID | +| amount_cent | INT | 金额,单位分 | +| pay_status | VARCHAR(30) | pending/paid/failed/cancelled | +| pay_method | VARCHAR(30) | wechat/alipay/balance/manual | +| transaction_id | VARCHAR(100) | 第三方交易号 | +| refund_status | VARCHAR(30) | none/refunding/refunded | +| created_at | DATETIME | 创建时间 | +| paid_at | DATETIME | 支付时间 | + +## 8. packages 套餐表 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | BIGINT PK | 套餐 ID | +| code | VARCHAR(100) | 编码 | +| name | VARCHAR(100) | 名称 | +| price_cent | INT | 价格 | +| world_min | INT | 最少世界数 | +| world_max | INT | 最多世界数 | +| scene_min | INT | 最少场景数 | +| scene_max | INT | 最多场景数 | +| image_min | INT | 最少图片数 | +| image_max | INT | 最多图片数 | +| video_duration_min | INT | 最短视频秒数 | +| video_duration_max | INT | 最长视频秒数 | +| revision_count | INT | 修改次数 | +| allow_video | BOOLEAN | 是否视频 | +| allow_voice | BOOLEAN | 是否旁白 | +| allow_advanced_video | BOOLEAN | 是否高级动态 | +| manual_review_required | BOOLEAN | 是否人工审核 | +| config_json | JSON | 扩展配置 | +| status | VARCHAR(30) | 状态 | + +## 9. life_themes 人生主题表 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | BIGINT PK | 主题 ID | +| code | VARCHAR(100) | 编码 | +| name | VARCHAR(100) | 名称 | +| description | TEXT | 说明 | +| cover_asset_id | BIGINT | 封面 | +| person_schema | JSON | 人物结构规则 | +| default_copy_moods | JSON | 默认文案风格 | +| status | VARCHAR(30) | 状态 | +| sort_order | INT | 排序 | + +person_schema 示例: + +```json +{ + "type": "couple", + "roles": ["person_a", "person_b"], + "min_photos_each": 3, + "max_photos_each": 8, + "minor_sensitive": false +} +``` + +## 10. style_templates 风格模板表 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | BIGINT PK | 风格 ID | +| code | VARCHAR(100) | 编码 | +| name | VARCHAR(100) | 名称 | +| description | TEXT | 说明 | +| cover_asset_id | BIGINT | 封面 | +| prompt_style | TEXT | 风格 Prompt | +| negative_rules | TEXT | 禁止项 | +| supported_theme_ids | JSON | 支持主题 | +| status | VARCHAR(30) | 状态 | + +## 11. world_templates 世界观模板表 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | BIGINT PK | 世界 ID | +| code | VARCHAR(100) | 编码 | +| name | VARCHAR(100) | 名称 | +| category | VARCHAR(100) | 分类 | +| description | TEXT | 说明 | +| cover_asset_id | BIGINT | 封面 | +| prompt_base | TEXT | 世界基础 Prompt | +| costume_rules | TEXT | 服装规则 | +| scene_rules | TEXT | 场景规则 | +| style_rules | TEXT | 风格规则 | +| negative_rules | TEXT | 禁止项 | +| supported_theme_ids | JSON | 支持主题 | +| supported_style_ids | JSON | 支持风格 | +| is_premium | BOOLEAN | 高级模板 | +| status | VARCHAR(30) | 状态 | +| sort_order | INT | 排序 | + +## 12. scene_templates 场景模板表 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | BIGINT PK | 场景 ID | +| world_id | BIGINT | 所属世界 | +| code | VARCHAR(100) | 编码 | +| name | VARCHAR(100) | 名称 | +| description | TEXT | 说明 | +| cover_asset_id | BIGINT | 封面 | +| scene_prompt | TEXT | 场景 Prompt | +| composition_rules | TEXT | 构图规则 | +| lighting_rules | TEXT | 光影规则 | +| effect_type | VARCHAR(100) | 特效类型 | +| recommended_shot_count | INT | 推荐镜头数 | +| is_premium | BOOLEAN | 高级场景 | +| status | VARCHAR(30) | 状态 | +| sort_order | INT | 排序 | + +## 13. shot_templates 镜头模板表 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | BIGINT PK | 镜头模板 ID | +| scene_id | BIGINT | 所属场景 | +| code | VARCHAR(100) | 编码 | +| name | VARCHAR(100) | 名称 | +| shot_type | VARCHAR(100) | 镜头类型 | +| camera_angle | VARCHAR(100) | 视角 | +| composition | TEXT | 构图 | +| prompt_rule | TEXT | Prompt 规则 | +| duration | INT | 默认秒数 | +| effect_type | VARCHAR(100) | 运镜/特效 | +| status | VARCHAR(30) | 状态 | +| sort_order | INT | 排序 | + +## 14. project_worlds 项目世界关系表 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | BIGINT PK | ID | +| project_id | BIGINT | 项目 ID | +| world_id | BIGINT | 世界 ID | +| sort_order | INT | 顺序 | + +## 15. project_scenes 项目场景关系表 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | BIGINT PK | ID | +| project_id | BIGINT | 项目 ID | +| world_id | BIGINT | 世界 ID | +| scene_id | BIGINT | 场景 ID | +| sort_order | INT | 顺序 | + +## 16. person_profiles 人物档案表 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | BIGINT PK | 人物档案 ID | +| project_id | BIGINT | 项目 ID | +| role | VARCHAR(50) | person_a/person_b/child 等 | +| name | VARCHAR(100) | 名称 | +| gender_label | VARCHAR(50) | 性别标签,可选 | +| age_group | VARCHAR(50) | 年龄段 | +| appearance_summary | TEXT | 外貌摘要 | +| reference_asset_ids | JSON | 参考照片 | +| anchor_asset_id | BIGINT | 锚点图 | +| quality_score | DECIMAL(5,2) | 质量分 | +| status | VARCHAR(30) | 状态 | +| created_at | DATETIME | 创建时间 | + +V3 真人动态视频增强字段: + +| 字段 | 类型 | 说明 | +|---|---|---| +| identity_lock_status | VARCHAR(50) | unlocked/generating_anchor/waiting_user_confirm/locked/rejected | +| face_consistency_score | DECIMAL(5,2) | 本人相似度评分 | +| primary_reference_asset_id | BIGINT | 主参考照片 | +| approved_anchor_asset_id | BIGINT | 用户确认的身份锚点 | +| age_preserve_rule | VARCHAR(100) | 年龄保持规则:strict/soft/allow_younger 等 | +| beautify_level | VARCHAR(50) | 美化强度:none/light/medium/high | +| style_transform_level | VARCHAR(50) | 风格转换强度:realistic/semi_real/comic | +| privacy_level | VARCHAR(50) | 隐私等级:normal/sensitive/minor | + +## 17. assets 素材表 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | BIGINT PK | 素材 ID | +| user_id | BIGINT | 用户 ID | +| project_id | BIGINT | 项目 ID | +| asset_type | VARCHAR(50) | 类型 | +| file_path | VARCHAR(500) | 存储路径 | +| file_url | VARCHAR(500) | 访问 URL,可为空 | +| mime_type | VARCHAR(100) | MIME | +| width | INT | 宽 | +| height | INT | 高 | +| duration | INT | 音视频秒数 | +| size | BIGINT | 文件大小 | +| hash | VARCHAR(100) | 文件哈希 | +| visibility | VARCHAR(30) | private/public/case | +| status | VARCHAR(30) | 状态 | +| created_at | DATETIME | 创建时间 | + +asset_type: + +```text +upload_photo +preview_image +final_image +audio +subtitle +video +cover +case_asset +music +``` + +## 18. shot_plans 镜头计划表 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | BIGINT PK | 镜头计划 ID | +| project_id | BIGINT | 项目 ID | +| world_id | BIGINT | 世界 ID | +| scene_id | BIGINT | 场景 ID | +| shot_template_id | BIGINT | 镜头模板 ID | +| title | VARCHAR(200) | 标题 | +| description | TEXT | 画面描述 | +| prompt_text | TEXT | 正向 Prompt | +| negative_prompt | TEXT | 负向 Prompt | +| duration | INT | 秒数 | +| output_asset_id | BIGINT | 生成图 | +| sort_order | INT | 顺序 | +| status | VARCHAR(30) | 状态 | + +## 19. render_tasks 生成任务表 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | BIGINT PK | 任务 ID | +| project_id | BIGINT | 项目 ID | +| task_type | VARCHAR(100) | 任务类型 | +| provider_id | BIGINT | Provider | +| status | VARCHAR(30) | 状态 | +| input_json | JSON | 输入 | +| input_hash | VARCHAR(100) | 幂等哈希 | +| output_asset_id | BIGINT | 输出素材 | +| provider_request_id | VARCHAR(200) | 三方请求 ID | +| retry_count | INT | 重试次数 | +| max_retry | INT | 最大重试 | +| cost_estimate_cent | INT | 预估成本 | +| cost_actual_cent | INT | 实际成本 | +| error_code | VARCHAR(100) | 错误码 | +| error_message | TEXT | 错误信息 | +| created_at | DATETIME | 创建时间 | +| started_at | DATETIME | 开始时间 | +| finished_at | DATETIME | 完成时间 | + +索引: + +- UNIQUE(project_id, task_type, input_hash) +- INDEX(status, created_at) + +## 20. provider_configs Provider 配置表 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | BIGINT PK | Provider ID | +| provider_type | VARCHAR(50) | text/image/video/voice/moderation | +| provider_name | VARCHAR(100) | 名称 | +| model_name | VARCHAR(100) | 模型名 | +| api_base | VARCHAR(500) | API Base | +| api_key_ref | VARCHAR(100) | 密钥引用,不直接存明文 | +| priority | INT | 优先级 | +| quality_level | VARCHAR(50) | quality/speed/cost | +| rate_limit_json | JSON | 限流配置 | +| cost_rule_json | JSON | 成本规则 | +| fallback_provider_id | BIGINT | 备用 Provider | +| status | VARCHAR(30) | 状态 | + +## 21. provider_logs Provider 日志表 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | BIGINT PK | 日志 ID | +| provider_id | BIGINT | Provider | +| project_id | BIGINT | 项目 | +| task_id | BIGINT | 任务 | +| request_payload | JSON | 请求,可脱敏 | +| response_payload | JSON | 响应,可脱敏 | +| status | VARCHAR(30) | 状态 | +| cost_cent | INT | 成本 | +| latency_ms | INT | 耗时 | +| created_at | DATETIME | 创建时间 | + +## 22. revision_requests 修改申请表 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | BIGINT PK | 修改 ID | +| project_id | BIGINT | 项目 ID | +| user_id | BIGINT | 用户 ID | +| revision_type | VARCHAR(30) | small/medium/major | +| request_text | TEXT | 修改内容 | +| status | VARCHAR(30) | 状态 | +| remaining_count_before | INT | 修改前剩余次数 | +| handled_by | BIGINT | 处理人 | +| created_at | DATETIME | 创建时间 | +| updated_at | DATETIME | 更新时间 | + +## 23. authorizations 授权记录表 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | BIGINT PK | 授权 ID | +| user_id | BIGINT | 用户 ID | +| project_id | BIGINT | 项目 ID | +| authorization_type | VARCHAR(50) | 类型 | +| content | TEXT | 授权文本 | +| ip | VARCHAR(100) | IP | +| user_agent | TEXT | UA | +| confirmed_at | DATETIME | 确认时间 | + +类型: + +```text +photo_usage +public_case +minor_guardian +privacy_policy +terms +``` + +## 24. case_showcases 案例表 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | BIGINT PK | 案例 ID | +| project_id | BIGINT | 来源项目 | +| title | VARCHAR(200) | 标题 | +| cover_asset_id | BIGINT | 封面 | +| video_asset_id | BIGINT | 视频 | +| theme_id | BIGINT | 主题 | +| style_id | BIGINT | 风格 | +| world_ids | JSON | 世界列表 | +| sort_order | INT | 排序 | +| is_featured | BOOLEAN | 首页推荐 | +| status | VARCHAR(30) | 状态 | +| authorization_id | BIGINT | 授权记录 | + +## 25. music_assets 音乐素材表 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | BIGINT PK | 音乐 ID | +| name | VARCHAR(200) | 名称 | +| mood | VARCHAR(100) | 情绪 | +| asset_id | BIGINT | 音频资源 | +| duration | INT | 秒数 | +| license_type | VARCHAR(100) | 授权类型 | +| source | VARCHAR(200) | 来源 | +| usage_scope | TEXT | 使用范围 | +| status | VARCHAR(30) | 状态 | + +## 26. system_configs 系统配置表 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | BIGINT PK | 配置 ID | +| config_key | VARCHAR(100) | Key | +| config_value | JSON | Value | +| description | TEXT | 说明 | +| updated_at | DATETIME | 更新时间 | + +## 27. audit_logs 操作日志表 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | BIGINT PK | 日志 ID | +| actor_type | VARCHAR(30) | user/admin/system | +| actor_id | BIGINT | 操作人 | +| action | VARCHAR(100) | 动作 | +| target_type | VARCHAR(100) | 目标类型 | +| target_id | BIGINT | 目标 ID | +| detail_json | JSON | 详情 | +| ip | VARCHAR(100) | IP | +| created_at | DATETIME | 创建时间 | + +## 28. V3 identity_anchors 身份锚点表 + +用于保存真人身份锚点,确保后续图片和视频尽量像本人。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | BIGINT PK | 锚点 ID | +| project_id | BIGINT | 项目 ID | +| person_id | BIGINT | 人物档案 ID | +| anchor_asset_id | BIGINT | 锚点素材 | +| anchor_type | VARCHAR(80) | real_photo_anchor/semi_realistic_anchor/korean_comic_anchor/ancient_costume_anchor/future_style_anchor | +| quality_score | DECIMAL(5,2) | 锚点质量分 | +| face_consistency_score | DECIMAL(5,2) | 与本人相似度 | +| approved_by_user | BOOLEAN | 用户是否确认 | +| approved_by_admin | BOOLEAN | 后台是否确认 | +| status | VARCHAR(50) | generating/waiting_confirm/approved/rejected | +| created_at | DATETIME | 创建时间 | +| updated_at | DATETIME | 更新时间 | + +## 29. V3 motion_templates 动作模板表 + +用于控制动态写真和真人视频片段的动作范围。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | BIGINT PK | 动作模板 ID | +| name | VARCHAR(100) | 动作名称 | +| motion_type | VARCHAR(100) | smile/blink/turn_head/walk_forward/hold_hands 等 | +| description | TEXT | 说明 | +| supported_themes | JSON | 支持人生主题 | +| supported_worlds | JSON | 支持世界模板 | +| supported_styles | JSON | 支持视觉风格 | +| prompt_rule | TEXT | 动作 Prompt 规则 | +| duration | INT | 默认片段秒数 | +| difficulty_level | VARCHAR(50) | easy/medium/hard | +| cost_level | VARCHAR(50) | low/medium/high | +| status | VARCHAR(30) | 状态 | +| sort_order | INT | 排序 | + +推荐动作: + +```text +smile +blink +turn_head +walk_forward +hold_hands +look_at_each_other +bow_ceremony +lift_veil +hug +wave +stand_still_cinematic +slow_camera_push +``` + +## 30. V3 video_clips 视频片段表 + +每个 AI 动态视频片段单独存储,便于重试、替换、质检和成本追踪。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | BIGINT PK | 视频片段 ID | +| project_id | BIGINT | 项目 ID | +| scene_id | BIGINT | 场景 ID | +| shot_id | BIGINT | 镜头 ID | +| person_ids | JSON | 涉及人物 ID | +| provider_id | BIGINT | Provider 配置 ID | +| input_asset_id | BIGINT | 输入关键帧 / 参考图 | +| output_asset_id | BIGINT | 输出视频素材 | +| prompt_text | TEXT | 视频 Prompt | +| duration | INT | 秒数 | +| resolution | VARCHAR(50) | 720p/1080p | +| motion_type | VARCHAR(100) | 动作类型 | +| lipsync_enabled | BOOLEAN | 是否启用口型 | +| status | VARCHAR(50) | pending/running/generated/failed/manual_required | +| retry_count | INT | 重试次数 | +| cost_actual | DECIMAL(12,4) | 实际成本 | +| quality_score | DECIMAL(5,2) | 质检分 | +| quality_issues | JSON | 质检问题 | +| created_at | DATETIME | 创建时间 | +| updated_at | DATETIME | 更新时间 | + +## 31. V3 lipsync_tasks 口型任务表 + +口型任务可选,不应阻塞基础交付;失败时可回退旁白字幕版本。 + +| 字段 | 类型 | 说明 | +|---|---|---| +| id | BIGINT PK | 任务 ID | +| project_id | BIGINT | 项目 ID | +| video_clip_id | BIGINT | 视频片段 ID | +| audio_asset_id | BIGINT | 输入音频 | +| output_asset_id | BIGINT | 口型后视频 | +| provider_id | BIGINT | LipSync Provider | +| status | VARCHAR(50) | pending/running/success/failed/manual_required | +| cost_actual | DECIMAL(12,4) | 实际成本 | +| retry_count | INT | 重试次数 | +| error_code | VARCHAR(100) | 错误码 | +| error_message | TEXT | 错误信息 | +| created_at | DATETIME | 创建时间 | +| updated_at | DATETIME | 更新时间 | diff --git a/docs/system_b/06_API接口设计文档.md b/docs/system_b/06_API接口设计文档.md new file mode 100755 index 0000000..2e867be --- /dev/null +++ b/docs/system_b/06_API接口设计文档.md @@ -0,0 +1,649 @@ +# 06_API接口设计文档 + +## 1. 文档目标 + +本文档定义系统 B 的 API 接口结构、请求参数、返回格式、错误码和权限要求。 + +## 2. 通用规范 + +### 2.1 Base URL + +```text +/api +``` + +后台接口: + +```text +/api/admin +``` + +### 2.2 通用返回格式 + +```json +{ + "code": 0, + "message": "ok", + "data": {} +} +``` + +错误示例: + +```json +{ + "code": 40001, + "message": "照片质量不合格", + "data": { + "reason": "face_blur" + } +} +``` + +### 2.3 通用错误码 + +| code | 含义 | +|---|---| +| 0 | 成功 | +| 40000 | 参数错误 | +| 40100 | 未登录 | +| 40300 | 无权限 | +| 40400 | 资源不存在 | +| 40900 | 状态冲突 | +| 42900 | 请求过于频繁 | +| 50000 | 系统错误 | +| 60000 | AI 生成失败 | +| 60001 | Provider 不可用 | +| 70000 | 支付失败 | +| 80000 | 审核不通过 | + +## 3. 认证接口 + +### 3.1 发送验证码 + +```text +POST /api/auth/send-code +``` + +请求: + +```json +{ "phone": "13800000000" } +``` + +返回: + +```json +{ "success": true } +``` + +### 3.2 手机号登录 + +```text +POST /api/auth/login-phone +``` + +请求: + +```json +{ + "phone": "13800000000", + "code": "123456" +} +``` + +返回: + +```json +{ + "token": "jwt-token", + "user": { "id": 1, "nickname": "用户" } +} +``` + +### 3.3 微信登录 + +```text +POST /api/auth/login-wechat +``` + +请求: + +```json +{ "code": "wechat_code" } +``` + +## 4. 首页与案例接口 + +### 4.1 首页配置 + +```text +GET /api/home +``` + +返回: + +```json +{ + "banners": [], + "featured_cases": [], + "themes": [], + "packages": [] +} +``` + +### 4.2 案例列表 + +```text +GET /api/cases?theme_id=1&style_id=2&page=1&page_size=20 +``` + +### 4.3 案例详情 + +```text +GET /api/cases/:id +``` + +## 5. 模板查询接口 + +### 5.1 人生主题列表 + +```text +GET /api/life-themes +``` + +### 5.2 套餐列表 + +```text +GET /api/packages?theme_id=1 +``` + +### 5.3 风格列表 + +```text +GET /api/styles?theme_id=1 +``` + +### 5.4 世界观列表 + +```text +GET /api/worlds?theme_id=1&style_id=2 +``` + +### 5.5 场景列表 + +```text +GET /api/worlds/:world_id/scenes +``` + +## 6. 项目接口 + +### 6.1 创建项目 + +```text +POST /api/projects +``` + +请求: + +```json +{ + "title": "我们的时空纪念片", + "life_theme_id": 1, + "output_type": "video" +} +``` + +返回: + +```json +{ + "project_id": 1001, + "status": "draft" +} +``` + +### 6.2 获取项目详情 + +```text +GET /api/projects/:id +``` + +### 6.3 保存套餐 + +```text +POST /api/projects/:id/package +``` + +请求: + +```json +{ "package_id": 2 } +``` + +### 6.4 保存风格 + +```text +POST /api/projects/:id/style +``` + +请求: + +```json +{ "style_id": 3 } +``` + +### 6.5 保存世界观 + +```text +POST /api/projects/:id/worlds +``` + +请求: + +```json +{ + "selected_worlds": [ + { "world_id": 1, "sort_order": 1 }, + { "world_id": 5, "sort_order": 2 } + ] +} +``` + +### 6.6 保存场景 + +```text +POST /api/projects/:id/scenes +``` + +请求: + +```json +{ + "selected_scenes": [ + { "world_id": 1, "scene_id": 11, "sort_order": 1 }, + { "world_id": 5, "scene_id": 51, "sort_order": 2 } + ] +} +``` + +### 6.7 保存定制信息 + +```text +POST /api/projects/:id/custom-info +``` + +请求: + +```json +{ + "names": { "person_a": "男方", "person_b": "女方" }, + "relationship_type": "couple", + "anniversary_date": "2026-05-20", + "copy_mood": "romantic", + "show_names": true, + "show_date": true, + "vow_text": "愿此生与你共赴山海", + "special_requirements": "整体梦幻,不要太搞笑", + "allow_public_case": false +} +``` + +### 6.8 项目进度 + +```text +GET /api/projects/:id/progress +``` + +返回: + +```json +{ + "status": "final_generating", + "percent": 65, + "current_step": "正式图片生成中", + "tasks": [ + { "task_type": "final_image", "success": 12, "total": 20 } + ] +} +``` + +### 6.9 我的项目列表 + +```text +GET /api/my/projects?page=1&page_size=20 +``` + +## 7. 文件与照片接口 + +### 7.1 上传照片 + +```text +POST /api/projects/:id/photos +Content-Type: multipart/form-data +``` + +字段: + +```text +person_role: person_a/person_b/child/self +file: image +``` + +返回: + +```json +{ + "asset_id": 2001, + "url": "signed-url", + "status": "uploaded" +} +``` + +### 7.2 删除照片 + +```text +DELETE /api/assets/:asset_id +``` + +### 7.3 触发照片质检 + +```text +POST /api/projects/:id/photo-check +``` + +### 7.4 获取照片质检结果 + +```text +GET /api/projects/:id/photo-check-result +``` + +## 8. 授权接口 + +### 8.1 提交授权确认 + +```text +POST /api/projects/:id/authorizations +``` + +请求: + +```json +{ + "types": ["photo_usage", "privacy_policy", "terms"], + "minor_guardian_confirmed": false +} +``` + +### 8.2 公开案例授权 + +```text +POST /api/projects/:id/public-case-authorization +``` + +## 9. AI 生成流程接口 + +### 9.1 生成创作方案 + +```text +POST /api/projects/:id/generate-plan +``` + +返回: + +```json +{ + "task_id": 3001, + "status": "pending" +} +``` + +### 9.2 获取创作方案 + +```text +GET /api/projects/:id/plan +``` + +### 9.3 确认创作方案 + +```text +POST /api/projects/:id/confirm-plan +``` + +### 9.4 生成预览 + +```text +POST /api/projects/:id/generate-preview +``` + +### 9.5 确认预览 + +```text +POST /api/projects/:id/confirm-preview +``` + +### 9.6 正式生成 + +```text +POST /api/projects/:id/generate-final +``` + +### 9.7 获取项目素材 + +```text +GET /api/projects/:id/assets?asset_type=final_image +``` + +## 10. 订单支付接口 + +### 10.1 创建订单 + +```text +POST /api/projects/:id/orders +``` + +请求: + +```json +{ "package_id": 2, "pay_method": "wechat" } +``` + +### 10.2 获取支付状态 + +```text +GET /api/orders/:id +``` + +### 10.3 支付回调 + +```text +POST /api/payments/wechat/callback +``` + +## 11. 修改申请接口 + +### 11.1 提交修改申请 + +```text +POST /api/projects/:id/revisions +``` + +请求: + +```json +{ + "revision_type": "small", + "request_text": "请把片尾日期改成 2026-05-20" +} +``` + +### 11.2 修改申请列表 + +```text +GET /api/projects/:id/revisions +``` + +## 12. 下载接口 + +### 12.1 获取下载链接 + +```text +GET /api/assets/:asset_id/download-url +``` + +返回: + +```json +{ + "url": "signed-download-url", + "expires_in": 3600 +} +``` + +## 13. 后台接口示例 + +### 13.1 后台项目列表 + +```text +GET /api/admin/projects?status=manual_review&page=1&page_size=20 +``` + +### 13.2 后台项目详情 + +```text +GET /api/admin/projects/:id +``` + +### 13.3 后台任务重试 + +```text +POST /api/admin/tasks/:id/retry +``` + +### 13.4 后台任务终止 + +```text +POST /api/admin/tasks/:id/cancel +``` + +### 13.5 后台转人工 + +```text +POST /api/admin/projects/:id/manual-review +``` + +### 13.6 后台模板新增/编辑 + +```text +POST /api/admin/world-templates +PUT /api/admin/world-templates/:id +POST /api/admin/scene-templates +PUT /api/admin/scene-templates/:id +``` + +### 13.7 后台 Provider 测试 + +```text +POST /api/admin/providers/:id/test +``` + +## 14. WebSocket 进度推送 + +连接: + +```text +/ws/projects/:project_id +``` + +事件: + +```json +{ + "event": "project_progress", + "data": { + "project_id": 1001, + "status": "video_rendering", + "percent": 82, + "message": "视频合成中" + } +} +``` + +## 15. 权限规则 + +- 用户只能访问自己的项目、素材、订单。 +- 后台管理员按角色权限访问后台接口。 +- Provider 密钥不可通过接口返回明文。 +- 公开案例必须检查授权记录。 + +## 16. V3 真人动态视频接口增量 + +### 16.1 输出模式 + +```text +PATCH /api/projects/:id/output-mode +``` + +请求: + +```json +{ + "output_mode": "real_video" +} +``` + +### 16.2 身份锚点 + +```text +POST /api/projects/:id/identity-anchors/generate +GET /api/projects/:id/identity-anchors +POST /api/identity-anchors/:anchor_id/confirm +POST /api/identity-anchors/:anchor_id/reject +``` + +### 16.3 人脸一致性质检 + +```text +POST /api/person-profiles/:person_id/face-consistency/check +GET /api/person-profiles/:person_id/face-consistency/latest +``` + +### 16.4 动作模板 + +```text +GET /api/motion-templates +GET /api/admin/motion-templates +POST /api/admin/motion-templates +PUT /api/admin/motion-templates/:id +``` + +### 16.5 真人动态视频片段 + +```text +GET /api/projects/:id/video-clips +GET /api/projects/:id/video-clips/cost-estimate +POST /api/projects/:id/video-clips/generate +POST /api/video-clips/:clip_id/retry +POST /api/video-clips/:clip_id/quality-check +POST /api/video-clips/:clip_id/replace +``` + +真实生成请求必须包含: + +```json +{ + "provider_code": "minimax_hailuo_23_fast", + "confirm_real_video": true, + "max_cost_per_clip": 1.0 +} +``` + +未确认或 Provider 未启用时,应返回明确错误,不允许回落 mock。 + +### 16.6 口型任务 + +```text +POST /api/video-clips/:clip_id/lipsync +GET /api/projects/:id/lipsync-tasks +POST /api/lipsync-tasks/:task_id/retry +``` diff --git a/docs/system_b/07_uniapp用户端页面交互文档.md b/docs/system_b/07_uniapp用户端页面交互文档.md new file mode 100755 index 0000000..128f0cf --- /dev/null +++ b/docs/system_b/07_uniapp用户端页面交互文档.md @@ -0,0 +1,479 @@ +# 07_uniapp用户端页面交互文档 + +## 1. 文档目标 + +本文档定义 uni-app 用户端的页面结构、跳转流程、页面字段、按钮动作和异常处理。 + +## 2. 页面路由建议 + +```text +/pages/index/index 首页 +/pages/case/list 案例列表 +/pages/case/detail 案例详情 +/pages/auth/login 登录注册 +/pages/project/create 创建项目 +/pages/project/theme 选择主题 +/pages/project/package 选择套餐 +/pages/project/style 选择风格 +/pages/project/world 选择世界观 +/pages/project/scene 选择场景 +/pages/project/upload 上传照片 +/pages/project/authorization 授权确认 +/pages/project/photo-check 照片质检 +/pages/project/custom-info 定制信息 +/pages/project/plan 创作方案 +/pages/project/payment 支付确认 +/pages/project/preview 预览确认 +/pages/project/progress 生成进度 +/pages/project/result 成品确认 +/pages/project/revision 修改申请 +/pages/my/projects 我的项目 +/pages/my/orders 我的订单 +/pages/my/profile 用户中心 +``` + +## 3. 全局交互原则 + +1. 未登录点击制作类按钮,跳转登录页。 +2. 用户返回上一步时,不删除已选数据。 +3. 所有选择流程都保存到项目草稿。 +4. 高成本生成动作必须二次确认。 +5. 生成中页面不允许重复触发同一任务。 +6. 页面异常时提供“重试”和“联系客服”。 + +## 4. 首页 + +### 数据来源 + +```text +GET /api/home +``` + +### 页面模块 + +- 顶部 Banner +- 热门案例横滑 +- 热门主题宫格 +- 热门世界观 +- 套餐展示 +- 制作流程 +- FAQ + +### 主要交互 + +| 操作 | 行为 | +|---|---| +| 点击开始制作 | 未登录跳登录;已登录跳创建项目 | +| 点击案例 | 跳案例详情 | +| 点击主题 | 跳主题选择,自动带入主题 | +| 点击用同款 | 登录后创建项目并带入案例模板 | + +## 5. 登录页 + +### 支持 + +- 手机号验证码 +- 微信授权 + +### 登录成功后 + +如果有 redirect 参数,回到之前页面;否则进入首页。 + +## 6. 创建项目页 + +### 字段 + +- 项目名称 +- 作品用途 +- 输出类型 + +### 按钮 + +- 下一步:调用 `POST /api/projects`,成功后进入主题选择页。 + +### 校验 + +- 项目名称可为空,系统自动生成。 +- 输出类型必须选择。 + +## 7. 主题选择页 + +### 数据来源 + +```text +GET /api/life-themes +``` + +### 交互 + +- 选择主题后保存到项目。 +- 下一步进入套餐选择。 + +### 异常 + +如果主题涉及未成年人,后续上传页和授权页必须增加未成年人提示。 + +## 8. 套餐选择页 + +### 数据来源 + +```text +GET /api/packages?theme_id=xxx +``` + +### 交互 + +- 用户选择套餐后调用保存套餐接口。 +- 套餐决定后续世界数、场景数、图片数、视频时长限制。 + +### 显示重点 + +- 价格 +- 图片数量 +- 视频时长 +- 修改次数 +- 是否人工审核 + +## 9. 风格选择页 + +### 数据来源 + +```text +GET /api/styles?theme_id=xxx +``` + +### 交互 + +- 点击风格卡片选中。 +- 点击预览案例跳案例列表并带 style_id。 +- 下一步保存风格并进入世界观选择。 + +## 10. 世界观选择页 + +### 数据来源 + +```text +GET /api/worlds?theme_id=xxx&style_id=xxx +``` + +### 交互 + +- 用户可多选世界观。 +- 选中数量受套餐限制。 +- 支持拖拽排序。 +- 点击世界可查看世界详情和场景预览。 + +### 校验 + +- 少于套餐最小数量,不允许下一步。 +- 超过套餐最大数量,弹窗提示升级套餐。 + +## 11. 场景选择页 + +### 数据来源 + +```text +GET /api/worlds/:world_id/scenes +``` + +### 交互 + +- 每个世界至少选择 1 个场景。 +- 可查看场景案例。 +- 高级场景如果套餐不支持,提示升级。 + +## 12. 照片上传页 + +### 页面逻辑 + +根据 life_theme 的 person_schema 渲染上传区域。 + +双人主题: + +- 人物 A 上传区 +- 人物 B 上传区 +- 双人合照上传区 + +单人主题: + +- 本人照片上传区 + +家庭主题: + +- 可添加家庭成员 +- 每个成员独立上传 + +### 上传接口 + +```text +POST /api/projects/:id/photos +``` + +### 前端预检 + +- 文件大小 +- 文件格式 +- 图片数量 + +### 交互提示 + +必须用示例图告诉用户什么照片合格、什么照片不合格。 + +## 13. 授权确认页 + +### 交互 + +所有必选授权项勾选后,才允许下一步。 + +### 记录 + +调用: + +```text +POST /api/projects/:id/authorizations +``` + +## 14. 照片质检页 + +### 触发 + +```text +POST /api/projects/:id/photo-check +``` + +### 显示 + +每张照片显示: + +- 缩略图 +- 检测状态 +- 分数 +- 问题原因 + +### 操作 + +- 替换照片 +- 忽略 warning 继续 +- 重新检测 + +fail 照片必须替换。 + +## 15. 定制信息页 + +### 字段 + +- 人物姓名 +- 纪念日期 +- 文案风格 +- 一句话誓言 +- 特别要求 +- 是否显示名字 +- 是否显示日期 +- 是否允许公开展示 + +### 提交 + +调用: + +```text +POST /api/projects/:id/custom-info +``` + +## 16. 创作方案页 + +### 触发生成 + +```text +POST /api/projects/:id/generate-plan +``` + +### 加载方式 + +轮询或 WebSocket 获取任务进度。 + +### 展示 + +- 标题 +- 镜头列表 +- 旁白 +- 字幕 +- 世界顺序 +- 场景顺序 + +### 操作 + +- 确认方案 +- 重新生成方案 +- 返回修改世界/场景/文案 + +## 17. 支付确认页 + +### 显示 + +- 套餐价格 +- 优惠金额 +- 应付金额 +- 生成内容说明 +- 修改次数 + +### 交互 + +- 立即支付 +- 使用余额 +- 返回修改套餐 + +## 18. 预览确认页 + +### 展示 + +- 关键场景预览图 +- 水印 +- 人物与风格反馈按钮 + +### 操作 + +- 满意,确认预览 +- 不满意,重新生成 +- 返回修改模板 + +注意:预览重新生成要限制次数。 + +## 19. 生成进度页 + +### 数据来源 + +```text +GET /api/projects/:id/progress +``` + +或 WebSocket。 + +### 交互 + +- 显示进度条 +- 显示当前步骤 +- 显示已完成任务 +- 失败时显示原因和联系客服入口 + +## 20. 成品确认页 + +### 展示 + +- 成品视频播放器 +- 图集 +- 下载按钮 +- 修改按钮 +- 公开案例授权按钮 + +### 操作 + +- 确认完成:项目 completed +- 申请修改:进入修改申请页 +- 下载视频:获取签名链接 +- 授权公开:创建 public_case 授权记录 + +## 21. 修改申请页 + +### 校验 + +- 检查剩余修改次数 +- 检查修改类型是否属于套餐范围 +- 大改提示重新计费 + +### 提交 + +```text +POST /api/projects/:id/revisions +``` + +## 22. 我的项目页 + +### 列表字段 + +- 缩略图 +- 标题 +- 状态 +- 创建时间 +- 是否可下载 + +### 操作 + +- 查看项目 +- 继续制作 +- 下载作品 +- 申请修改 +- 删除项目 + +## 23. 异常状态处理 + +| 场景 | 处理 | +|---|---| +| 网络失败 | 弹出重试按钮 | +| 登录过期 | 跳登录并保留 redirect | +| 项目状态冲突 | 刷新项目状态 | +| 支付失败 | 返回支付页 | +| 任务失败 | 显示失败原因和联系客服 | +| 无权限 | 跳首页或提示无权限 | + +## 24. V3 真人动态视频页面增量 + +### 24.1 输出模式选择 + +创建项目时新增输出模式: + +```text +高清写真图集 +图片纪念视频 +动态写真视频 +AI 真人动态视频 +``` + +选择 AI 真人动态视频时必须提示: + +- 生成时间更长。 +- 成本更高。 +- 可能需要人工审核。 +- 可能需要重试。 +- 动作自然度无法 100% 保证。 + +### 24.2 身份锚点确认 + +页面展示: + +- 原始参考照。 +- 身份锚点图。 +- 本人相似度评分。 +- “像本人 / 不像本人 / 重生”按钮。 + +未确认锚点,不允许进入真实视频片段生成。 + +### 24.3 视频片段制作页 + +展示: + +- 镜头列表。 +- 动作模板。 +- 输入关键帧。 +- 输出视频片段。 +- 预计成本。 +- Provider 名称。 +- 生成状态。 +- 质检结果。 + +操作: + +- 成本估算。 +- 勾选真实视频确认。 +- 生成片段。 +- 预览片段。 +- 重试片段。 +- 进入合成。 + +### 24.4 口型任务,可选 + +仅高端或用户选择口型时展示: + +- 选择片段。 +- 选择音频。 +- 生成口型。 +- 失败回退旁白字幕版。 diff --git a/docs/system_b/08_GeekerAdmin后台管理设计.md b/docs/system_b/08_GeekerAdmin后台管理设计.md new file mode 100755 index 0000000..83e145f --- /dev/null +++ b/docs/system_b/08_GeekerAdmin后台管理设计.md @@ -0,0 +1,533 @@ +# 08_GeekerAdmin后台管理设计 + +## 1. 文档目标 + +本文档定义基于 Geeker-Admin 二开的后台管理系统菜单、页面字段、操作按钮、权限和审核流程。 + +## 2. 后台角色 + +| 角色 | 权限 | +|---|---| +| 超级管理员 | 全部权限,包括系统配置和 Provider 密钥 | +| 管理员 | 用户、订单、项目、任务、模板管理 | +| 运营 | 案例、模板、项目审核、修改申请 | +| 审核员 | 照片审核、成品审核、内容审核 | +| 财务 | 订单、支付、退款、成本报表 | + +## 3. 菜单结构 + +```text +仪表盘 +用户管理 +项目管理 +订单管理 +生成任务 +素材管理 +模板中心 + 人生主题 + 套餐管理 + 视觉风格 + 世界观模板 + 场景模板 + 镜头模板 + 文案模板 + 音乐素材 +案例管理 +修改申请 +授权与隐私 +AI Provider +日志中心 +系统配置 +``` + +## 4. 仪表盘 + +### 指标卡片 + +- 今日订单数 +- 今日收入 +- 今日生成项目数 +- 今日完成项目数 +- 待审核项目数 +- 失败任务数 +- AI 成本 +- 毛利估算 + +### 图表 + +- 订单趋势 +- 成本趋势 +- 热门主题排行 +- 热门世界排行 +- 失败任务类型排行 + +## 5. 用户管理 + +### 表格字段 + +- 用户 ID +- 昵称 +- 手机号 +- 微信 openid +- 注册时间 +- 项目数 +- 订单数 +- 消费金额 +- 状态 + +### 操作 + +- 查看详情 +- 禁用用户 +- 恢复用户 +- 查看项目 +- 查看订单 +- 查看授权记录 + +## 6. 项目管理 + +### 筛选 + +- 项目状态 +- 人生主题 +- 套餐 +- 风格 +- 用户手机号 +- 创建时间 +- 是否公开授权 + +### 表格字段 + +- 项目 ID +- 用户 +- 标题 +- 主题 +- 套餐 +- 风格 +- 世界数量 +- 当前状态 +- 支付状态 +- 创建时间 +- 完成时间 + +### 操作 + +- 查看详情 +- 查看素材 +- 查看任务 +- 手动重试 +- 转人工 +- 标记异常 +- 强制完成 +- 取消项目 + +### 项目详情页 + +Tab: + +- 基本信息 +- 用户照片 +- 人物档案 +- 世界/场景 +- 镜头计划 +- 生成素材 +- 任务记录 +- 订单信息 +- 修改记录 +- 授权记录 + +## 7. 订单管理 + +### 字段 + +- 订单 ID +- 用户 +- 项目 ID +- 套餐 +- 金额 +- 支付方式 +- 支付状态 +- 退款状态 +- 创建时间 +- 支付时间 + +### 操作 + +- 查看订单 +- 手动标记支付 +- 创建退款记录 +- 查看项目 +- 导出订单 + +## 8. 生成任务管理 + +### 筛选 + +- 任务类型 +- 状态 +- Provider +- 项目 ID +- 创建时间 + +### 字段 + +- 任务 ID +- 项目 ID +- 类型 +- Provider +- 状态 +- 重试次数 +- 成本 +- 耗时 +- 错误码 +- 创建时间 + +### 操作 + +- 查看输入 +- 查看输出 +- 重试 +- 终止 +- 跳过 +- 转人工 +- 查看 Provider 日志 + +## 9. 素材管理 + +### 字段 + +- 素材 ID +- 项目 ID +- 用户 +- 类型 +- 缩略图 +- 尺寸 +- 文件大小 +- 可见性 +- 创建时间 + +### 操作 + +- 预览 +- 下载 +- 生成签名链接 +- 删除 +- 标记公开案例资源 + +## 10. 模板中心 + +### 10.1 人生主题管理 + +字段: + +- 编码 +- 名称 +- 封面 +- 人物结构规则 +- 是否启用 +- 排序 + +操作:新增、编辑、启用、禁用、排序。 + +### 10.2 套餐管理 + +字段: + +- 套餐编码 +- 套餐名称 +- 价格 +- 世界数量 +- 场景数量 +- 图片数量 +- 视频时长 +- 修改次数 +- 是否人工审核 +- 是否支持高级动态 + +### 10.3 视觉风格管理 + +字段: + +- 风格编码 +- 名称 +- 预览图 +- 风格 Prompt +- 禁止项 +- 支持主题 +- 是否启用 + +### 10.4 世界观模板管理 + +字段: + +- 世界编码 +- 名称 +- 分类 +- 封面 +- Prompt Base +- 服装规则 +- 场景规则 +- 禁止项 +- 支持主题 +- 支持风格 +- 是否高级 + +操作: + +- 新增世界 +- 编辑世界 +- 配置 Prompt +- 测试生成 +- 启用/禁用 + +### 10.5 场景模板管理 + +字段: + +- 场景编码 +- 所属世界 +- 名称 +- 封面 +- 场景 Prompt +- 构图规则 +- 光影规则 +- 特效类型 +- 推荐镜头数 + +### 10.6 镜头模板管理 + +字段: + +- 镜头编码 +- 所属场景 +- 镜头类型 +- 视角 +- 构图 +- Prompt 规则 +- 默认时长 +- 特效类型 + +### 10.7 文案模板管理 + +字段: + +- 主题 +- 文案风格 +- 文案类型 +- 模板内容 +- 变量 +- 状态 + +文案类型: + +- 片头 +- 旁白 +- 字幕 +- 片尾 +- 誓言 +- 祝福语 + +### 10.8 音乐素材管理 + +字段: + +- 音乐名称 +- 情绪 +- 文件 +- 时长 +- 授权类型 +- 来源 +- 使用范围 + +必须记录版权来源。 + +## 11. 案例管理 + +### 字段 + +- 案例 ID +- 来源项目 +- 标题 +- 封面 +- 视频 +- 主题 +- 风格 +- 世界 +- 是否首页推荐 +- 授权记录 +- 状态 + +### 操作 + +- 从项目生成案例 +- 编辑标题和封面 +- 设置推荐 +- 上架/下架 +- 排序 + +规则:无公开授权记录,不允许上架。 + +## 12. 修改申请管理 + +### 字段 + +- 修改 ID +- 项目 ID +- 用户 +- 修改类型 +- 修改内容 +- 剩余次数 +- 状态 +- 处理人 +- 创建时间 + +### 操作 + +- 接受修改 +- 拒绝修改 +- 转人工 +- 创建重做任务 +- 标记完成 + +## 13. 授权与隐私管理 + +管理: + +- 照片使用授权 +- 公开案例授权 +- 未成年人授权 +- 隐私协议确认 +- 用户删除申请 + +操作: + +- 查看授权详情 +- 导出授权记录 +- 处理删除申请 + +## 14. AI Provider 管理 + +字段: + +- Provider 类型 +- 名称 +- 模型名 +- API Base +- 密钥引用 +- 优先级 +- 质量等级 +- 限流配置 +- 成本规则 +- 备用 Provider +- 状态 + +操作: + +- 新增 Provider +- 编辑 Provider +- 测试连接 +- 启用/禁用 +- 设置优先级 + +密钥不允许明文展示。 + +## 15. 日志中心 + +日志类型: + +- 登录日志 +- 操作日志 +- AI 调用日志 +- 支付日志 +- 下载日志 +- 错误日志 + +## 16. 系统配置 + +配置项: + +- 上传文件大小限制 +- 下载链接有效期 +- 默认重试次数 +- 每用户每日预览次数 +- 项目保留天数 +- 是否开启人工审核 +- 是否开启案例授权 +- 水印配置 +- 成本告警阈值 + +## 17. V3 真人动态视频后台增量 + +### 17.1 身份锚点管理 + +项目详情新增: + +- 人物参考照。 +- 身份锚点图。 +- 用户确认状态。 +- 管理员确认状态。 +- 本人相似度评分。 +- 重生 / 标记通过 / 标记拒绝。 + +### 17.2 视频片段管理 + +项目详情新增视频片段列表: + +- 镜头号。 +- 动作模板。 +- Provider。 +- 输入关键帧。 +- 输出视频。 +- 片段时长。 +- 预计成本 / 实际成本。 +- 状态。 +- 质检分。 +- 重试次数。 + +操作: + +- 预览片段。 +- 重试片段。 +- 替换片段。 +- 执行质检。 +- 转人工。 + +### 17.3 动作模板管理 + +后台新增动作模板菜单: + +- 动作名称。 +- motion_type。 +- 支持主题。 +- 支持世界。 +- Prompt 规则。 +- 默认时长。 +- 难度等级。 +- 成本等级。 + +### 17.4 视频 Provider 管理 + +AI Provider 管理需支持: + +- MiniMax Hailuo。 +- 阿里 Wan。 +- Vidu。 +- Seedance / 即梦。 +- Kling。 +- Runway。 +- MockVideoProvider。 + +真实视频 Provider 默认禁用;后台测试真实视频 Provider 也应禁用或必须二次确认,避免误扣费。 + +### 17.5 V3 审核项 + +审核页新增: + +- 是否像本人。 +- 是否变脸。 +- 是否男女混脸。 +- 是否年龄变化过大。 +- 动作是否自然。 +- 表情是否怪异。 +- 是否有不合适姿势。 +- 是否适合公开案例。 diff --git a/docs/system_b/09_AI生成流水线_Provider抽象设计.md b/docs/system_b/09_AI生成流水线_Provider抽象设计.md new file mode 100755 index 0000000..545a0c7 --- /dev/null +++ b/docs/system_b/09_AI生成流水线_Provider抽象设计.md @@ -0,0 +1,518 @@ +# 09_AI生成流水线_Provider抽象设计 + +## 1. 文档目标 + +本文档定义系统 B 的 AI 核心流水线、Provider 抽象、任务输入输出、质量控制和模型可替换策略。 + +## 2. 核心原则 + +1. 不把任何模型写死到业务代码。 +2. 所有 AI 能力通过 Provider 调用。 +3. 高成本任务必须检查订单/额度。 +4. 所有 AI 调用必须记录日志和成本。 +5. 任务失败可重试,可切换备用 Provider。 +6. 预览和正式生成分离。 + +## 3. Provider 类型 + +```text +TextProvider:文案、创作方案、Prompt 生成 +ImageProvider:预览图、正式图、局部重绘 +VideoProvider:图生视频、关键动态镜头 +VoiceProvider:旁白、誓言、祝福语 TTS +ModerationProvider:文本/图片审核 +FaceCheckProvider:人脸检测、角色归属、清晰度 +QualityCheckProvider:图片质量、人像一致性、视频质检 +``` + +V3 真人动态视频新增 Provider: + +```text +FaceIdentityProvider:真人身份档案、身份特征摘要、身份锚点建议 +FaceConsistencyProvider:本人相似度、人脸一致性、男女混脸检测 +MotionPortraitProvider:动态写真,眨眼、微笑、头发衣服轻微动 +LipSyncProvider:口型同步、誓言口播、祝福语口播 +``` + +VideoProvider 在 V3 中不只用于“关键动态镜头”,还要支持: + +```text +image_to_video:首帧图生视频 +reference_to_video:多参考图保持人物一致性 +start_end_to_video:首尾帧视频 +speech_to_video / lipsync:带音频或口型的视频任务,可选 +``` + +## 4. Provider 通用配置 + +字段: + +```text +provider_type +provider_name +model_name +api_base +api_key_ref +priority +quality_level +rate_limit +cost_rule +fallback_provider_id +status +``` + +## 5. AI 流水线总览 + +```text +用户上传照片 +→ 原图去 EXIF +→ 照片质检 +→ 人物档案生成 +→ 用户填写定制信息 +→ 创作方案生成 +→ 镜头计划生成 +→ Prompt 分层组装 +→ 预览图生成 +→ 用户确认预览 +→ 正式图生成 +→ 图片质检 +→ 旁白/字幕生成 +→ 视频合成 +→ 最终质检 +→ 人工审核 +→ 交付 +``` + +## 5.1 V3 真人动态视频流水线 + +```text +用户上传照片 +→ 授权确认 +→ 原图去 EXIF / 私有存储 +→ 照片质检 +→ FaceIdentityProvider 生成人物身份档案 +→ 身份锚点图生成 +→ FaceConsistencyProvider 评分 +→ 用户确认像不像 +→ 创作方案 / 镜头计划 +→ 关键帧生成 +→ 成本预估和额度冻结 +→ VideoProvider 生成真人动态片段 +→ 视频片段质检 +→ VoiceProvider 生成旁白 / 誓言 +→ LipSyncProvider 口型同步,可选 +→ FFmpeg 合成成片 +→ 最终质检 +→ 人工审核 +→ 用户确认 +→ 交付 +``` + +关键规则: + +- 未完成授权,不允许照片处理和生成。 +- 未确认身份锚点,不允许正式动态视频生成。 +- 真实视频 Provider 默认关闭,必须运营启用、用户确认和成本通过后才调用。 +- 真实 Provider 失败不能回落 mock 假成功。 +- 每个视频片段必须可单独预览、重试、替换、质检和计费。 + +## 6. 照片处理流程 + +### 6.1 输入 + +- 用户原始照片 +- 人物角色:person_a/person_b/self/child/family_member +- 项目主题 + +### 6.2 处理 + +```text +保存原图 +去除 EXIF +生成缩略图 +检测人脸数量 +检测清晰度 +检测遮挡 +检测角度 +检测重复图片 +检测是否多人混入 +检测过度美颜风险 +``` + +### 6.3 输出 + +```json +{ + "asset_id": 1001, + "quality_status": "pass", + "quality_score": 86.5, + "face_count": 1, + "issues": [] +} +``` + +## 7. 人物档案生成 + +### 7.1 目标 + +将多张参考照片整理成人物档案,供后续 Prompt 和一致性控制使用。 + +### 7.2 输出字段 + +```text +role +name +gender_label +age_group +appearance_summary +face_features +hair_features +temperament +reference_asset_ids +anchor_asset_id +avoid_changes +quality_score +``` + +### 7.3 示例 + +```text +person_a:30岁左右男性,短黑发,脸型偏长,五官清晰,气质沉稳。生成时保持发型、脸型和年龄感,不要变成欧美脸,不要明显年轻化或老化。 +``` + +## 8. 创作方案生成 + +### 8.1 输入 + +```text +人生主题 +套餐 +视觉风格 +世界观列表 +场景列表 +人物档案 +用户定制信息 +输出类型 +``` + +### 8.2 输出 + +```text +作品标题 +作品简介 +世界顺序 +场景顺序 +镜头计划 +旁白草稿 +字幕草稿 +片头文案 +片尾文案 +总时长 +预计图片数 +预计视频片段数 +``` + +### 8.3 生成约束 + +- 不要生成用户未选择的世界。 +- 不要改变人物关系。 +- 文案不要过长。 +- 如果是父母银婚金婚,语气要庄重温馨。 +- 如果是情侣写真,可以更浪漫轻盈。 + +## 9. Prompt 分层组装 + +Prompt 由以下层组成: + +```text +人物层 +关系层 +人生主题层 +世界观层 +场景层 +镜头层 +视觉风格层 +质量层 +限制层 +``` + +### 9.1 人物层 + +来自 person_profile。 + +### 9.2 世界观层 + +来自 world_template。 + +### 9.3 场景层 + +来自 scene_template。 + +### 9.4 镜头层 + +来自 shot_template。 + +### 9.5 质量层 + +根据套餐决定: + +- high quality +- commercial portrait quality +- detailed lighting +- clean composition + +### 9.6 限制层 + +必须包含: + +```text +不要多出无关人物 +不要文字乱入 +不要明显畸形手 +不要改变人物年龄 +不要男女混脸 +不要改变人物核心五官 +``` + +## 10. 预览图生成 + +### 10.1 目标 + +让用户低成本确认: + +- 人物像不像 +- 风格是否满意 +- 世界方向是否正确 + +### 10.2 规则 + +- 数量少 +- 加水印 +- 低清或中清 +- 只生成关键场景 +- 预览重生次数受限 + +## 11. 正式图生成 + +### 11.1 输入 + +- 确认后的镜头计划 +- 人物档案 +- 场景模板 +- 风格模板 +- 预览锚点图,可选 + +### 11.2 规则 + +- 使用高质量 Provider 配置 +- 每张图记录 Prompt +- 每张图记录 Provider +- 每张图记录版本 +- 失败可重试 +- 多次失败转人工 + +## 12. 图片质检 + +自动检查: + +```text +人脸是否崩坏 +人物是否不像本人 +男女是否混脸 +是否多出第三人 +手部是否异常 +服装是否跑偏 +场景是否错误 +是否有乱码文字 +是否违规 +``` + +输出: + +```text +pass +warning +fail +``` + +处理: + +- pass:进入视频合成 +- warning:人工复核 +- fail:自动重生 + +## 13. TTS 和字幕生成 + +### 13.1 文案来源 + +- 片头 +- 旁白 +- 誓言 +- 祝福语 +- 片尾 + +### 13.2 字幕规则 + +- 每屏字数控制 +- 不遮挡人物脸部 +- 字幕和旁白时间对齐 +- 支持有字幕/无字幕版本 + +## 14. 视频合成 + +### 14.1 输入 + +```text +final_images +shot_plans +subtitle_srt +voice_audio +bgm_audio +video_template +``` + +### 14.2 FFmpeg 处理 + +- 图片转视频片段 +- 推拉运镜 +- 转场 +- 字幕烧录 +- 音频混合 +- 片头片尾 +- 封面生成 + +### 14.3 视频等级 + +```text +L1:静态图片 + 推拉运镜 +L2:图片 + 简单光效/花瓣/粒子 +L3:关键镜头图生视频 +L4:高级动态视频 +``` + +第一阶段建议:L1 + L2 为主。 + +## 15. 最终质检 + +检查: + +- 视频可播放 +- 音画同步 +- 字幕不出框 +- BGM 音量合适 +- 旁白清晰 +- 分辨率正确 +- 文件大小合理 +- 无违规内容 + +## 16. Provider 失败切换 + +策略: + +```text +主 Provider 失败 +→ 记录错误 +→ 判断是否可重试 +→ 重试 N 次 +→ 切换 fallback Provider +→ 仍失败则 manual_required +``` + +## 17. 成本记录 + +每次 AI 调用记录: + +```text +project_id +task_id +provider_id +model_name +input_size +output_size +estimated_cost +actual_cost +latency_ms +status +``` + +## 18. 需要人工介入的场景 + +- 人像连续不像 +- 多次生成崩坏 +- 视频合成失败 +- 用户申请中改/大改 +- 审核警告 +- 高端定制项目 + +## 19. V3 VideoProvider 策略 + +系统 B V3 优先测试图生视频、参考图生视频和人物动作视频,不建议直接依赖文生视频。 + +推荐 Provider 预设: + +| Provider | 适合用途 | 默认状态 | +|---|---|---| +| MiniMax Hailuo 2.3 Fast | 低成本快速小样、真人动效验证 | 禁用 | +| MiniMax Hailuo 2.3 | 质量更高的小样 / 正式片段 | 禁用 | +| Alibaba Wan2.6 I2V Flash | 快速对比稳定性和成本 | 禁用 | +| Alibaba Wan2.6 I2V | 标准质量图生视频 | 禁用 | +| Vidu Q3 Turbo Reference | 多参考图人物一致性、音画能力 | 禁用 | +| Vidu Q3 Pro | 高质量参考图生视频 | 禁用 | +| Jimeng / Seedance | 中文短剧感、真人镜头语言 | 禁用 | +| Kling / Runway | 备用对比和部分高质量场景 | 禁用 | +| MockVideoProvider | 流程演练和不扣费测试 | 启用 | + +每个 Provider 配置至少包含: + +```text +provider_name +model_name +mode: image2video / reference2video / start_end2video / lipsync +resolution +max_duration_per_clip +cost_per_second 或 cost_per_clip +supports_reference_image +supports_start_end_frame +supports_audio +supports_lipsync +supports_character_reference +retry_limit +priority +fallback_provider +status +``` + +## 20. V3 视频片段质检 + +每个真实视频片段必须检查: + +- 是否像本人。 +- 是否男女混脸。 +- 是否年龄变化过大。 +- 是否多出第三人。 +- 动作是否自然。 +- 表情是否怪异。 +- 手部和身体是否畸形。 +- 是否有不合适姿势。 +- 是否有水印、乱码、logo。 +- 是否适合公开案例。 + +质检结果: + +```text +passed:可进入合成 +needs_retry:建议重试 +manual_review:转人工 +rejected:不可用 +``` + +## 21. V3 口型策略 + +口型属于高级能力,不作为基础交付强依赖。 + +规则: + +- 只有高端真人纪念片或用户选择口型套餐时启用。 +- 口型失败时允许回退为旁白字幕版本。 +- 口型任务必须单独记录 Provider、成本、状态和错误。 +- 涉及未成年人时,口型内容必须更严格审核。 diff --git a/docs/system_b/10_Prompt模板_世界观模板规范.md b/docs/system_b/10_Prompt模板_世界观模板规范.md new file mode 100755 index 0000000..6992bc5 --- /dev/null +++ b/docs/system_b/10_Prompt模板_世界观模板规范.md @@ -0,0 +1,371 @@ +# 10_Prompt模板_世界观模板规范 + +## 1. 文档目标 + +本文档定义系统 B 的 Prompt 结构、世界观模板、场景模板、镜头模板和风格模板规范,保证生成效果稳定、可维护、可复用。 + +V3 增量:系统 B 支持 AI 真人动态视频后,Prompt 必须从“生成好看的写真图”升级为“保持真实用户身份并生成自然动作”。真人动态视频 Prompt 必须明确身份保持、年龄保持、动作边界和负面约束。 + +## 2. Prompt 总体结构 + +最终 Prompt 由系统拼接,不建议让用户自由输入完全控制。 + +```text +[人物层] +[关系层] +[人生主题层] +[世界观层] +[场景层] +[镜头层] +[视觉风格层] +[光影与构图层] +[质量层] +[限制层] +``` + +## 3. 人物层模板 + +```text +根据参考照片保留人物核心五官、脸型、年龄感和气质。 +人物A:{person_a_summary} +人物B:{person_b_summary} +不要改变人物的核心外貌,不要使人物明显年轻化或老化。 +``` + +## 4. 关系层模板 + +婚礼/恋爱: + +```text +两人是亲密情侣/夫妻关系,画面应体现自然、温柔、信任、纪念感。 +``` + +银婚金婚: + +```text +两人是多年相伴的夫妻,画面应体现温暖、庄重、岁月感和纪念价值。 +``` + +个人形象: + +```text +单人形象定制,突出人物气质和主题世界观,不要出现无关人物。 +``` + +## 5. 风格模板 + +### 5.1 韩漫风 korean_comic + +```text +高质量韩漫风格,精致人物五官,干净线条,柔和光影,浪漫氛围,现代韩漫审美,画面清晰,人物面部稳定。 +``` + +禁用: + +```text +低质量,脸部变形,五官漂移,过度夸张,杂乱背景,错误文字,第三人乱入。 +``` + +### 5.2 半写实写真风 semi_realistic + +```text +半写实精修写真风,保留真人特征,电影感光影,高级质感,真实但略带艺术化,适合商业纪念照。 +``` + +### 5.3 国风插画 chinese_illustration + +```text +国风插画风,东方美学,细腻服饰纹理,柔和色彩,古典构图,雅致氛围,适合古风和仙侠主题。 +``` + +### 5.4 电影写实 cinematic_realistic + +```text +电影写实风,真实摄影质感,电影级灯光,真实布料与场景,人物保留参考照片特征,高端纪念片风格。 +``` + +注意:电影写实最容易暴露人脸不一致,应放高端套餐并人工审核。 + +## 6. 世界观模板字段规范 + +每个世界观必须包含: + +```text +world_code +world_name +category +description +prompt_base +costume_rules +scene_rules +color_rules +lighting_rules +negative_rules +supported_themes +supported_styles +``` + +## 7. 历史朝代系模板示例 + +### 7.1 明制婚礼 ming_dynasty + +prompt_base: + +```text +明代中式婚礼世界,庄重华丽,传统中式礼制,大红喜庆色调,精致木质建筑,红灯笼,红绸,喜堂,东方古典美学。 +``` + +costume_rules: + +```text +新郎穿明制婚服,端庄正式。新娘穿明制凤冠霞帔或传统中式婚服,华丽但不过度夸张。 +``` + +scene_rules: + +```text +场景可包含王府喜堂、红绸长廊、花轿、庭院、洞房花烛、夜宴烟花。 +``` + +negative_rules: + +```text +不要现代西式婚纱,不要清代旗装,不要民国服饰,不要混入其它朝代元素。 +``` + +### 7.2 唐朝婚礼 tang_dynasty + +prompt_base: + +```text +盛唐婚礼世界,华丽、大气、富贵,宫廷感,暖金色调,开放盛大的东方审美。 +``` + +costume_rules: + +```text +新郎新娘穿唐风华丽婚服,服饰层次丰富,色彩明艳,发饰精致。 +``` + +## 8. 仙侠修真系模板示例 + +### 8.1 仙宫大婚 xianxia_palace + +prompt_base: + +```text +仙侠世界的云海仙宫婚礼,漂浮宫殿,云雾缭绕,仙鹤、灵光、花瓣,梦幻而庄重。 +``` + +costume_rules: + +```text +人物穿仙侠婚服,轻盈飘逸,带有东方仙气,不要现代服装。 +``` + +scene_rules: + +```text +云海仙宫、桃花仙境、宗门大殿、仙舟、星河天台、凤凰环绕礼台。 +``` + +negative_rules: + +```text +不要西方魔法袍,不要机械科幻元素,不要恐怖暗黑风。 +``` + +### 8.2 龙宫婚礼 dragon_palace + +```text +海底龙宫婚礼,水晶宫殿,蓝金色调,水下光影,东方龙纹装饰,神秘华丽。 +``` + +## 9. 未来科技系模板示例 + +### 9.1 星际婚礼 space_wedding + +prompt_base: + +```text +未来星际婚礼,宇宙星空背景,星舰大厅,银河观景台,全息光效,高级科技感,浪漫与未来感结合。 +``` + +costume_rules: + +```text +未来礼服,干净利落,高级材质,带少量光效装饰,不要过度机甲化。 +``` + +negative_rules: + +```text +不要古代服饰,不要脏乱废土,不要恐怖外星元素。 +``` + +## 10. 趣味脑洞系模板示例 + +### 10.1 恐龙时代 dinosaur_age + +prompt_base: + +```text +远古恐龙时代的浪漫纪念场景,史前森林,巨大恐龙在远处温和出现,金色夕阳,奇幻浪漫,不恐怖。 +``` + +rules: + +```text +恐龙只作为背景氛围,不攻击人物。画面应浪漫、奇妙、适合纪念,不要血腥。 +``` + +### 10.2 海底王国 underwater_kingdom + +```text +海底王国,水晶宫殿,发光珊瑚,蓝色梦幻光影,鱼群环绕,浪漫神秘。 +``` + +## 11. 场景模板规范 + +场景模板必须包含: + +```text +scene_code +scene_name +scene_prompt +composition_rules +lighting_rules +effect_type +recommended_shot_count +negative_rules +``` + +示例:明朝王府喜堂 + +```text +scene_prompt:传统中式喜堂,红色帷幔,木质梁柱,喜字装饰,红灯笼,两位新人站在中央。 +composition_rules:双人正面构图,人物居中,背景对称,庄重仪式感。 +lighting_rules:暖色室内光,柔和但喜庆。 +effect_type:slow_zoom_in +``` + +## 12. 镜头模板规范 + +镜头类型: + +```text +双人正面主图 +牵手远景 +对视特写 +仪式镜头 +氛围镜头 +单人特写 +家庭合照 +儿童成长镜头 +``` + +示例:对视特写 + +```text +两位主角近景对视,表情温柔自然,背景虚化,突出眼神和情感,不要夸张表情。 +``` + +## 13. 通用负面 Prompt + +```text +低质量,模糊,五官变形,脸部崩坏,年龄漂移,男女混脸,第三人乱入,多余手指,畸形手,文字乱码,水印,logo,现代物品乱入,服装风格错误,背景杂乱,恐怖,血腥,色情,侮辱性内容。 +``` + +## 14. Prompt 生成规则 + +1. 用户自由输入只能作为补充要求。 +2. 用户输入不得覆盖安全限制。 +3. 历史朝代模板要严格避免混搭。 +4. 单个 Prompt 不要过长到失控。 +5. 每次生成要保存 Prompt 和版本号。 +6. 同一项目内同一人物描述必须一致。 +7. 同一世界内服装和色调尽量保持一致。 + +## 15. 模板测试标准 + +每个新世界模板上线前,至少测试: + +- 单人图 +- 双人图 +- 远景 +- 特写 +- 同一人物多场景一致性 +- 与 2 种不同视觉风格组合 +- 是否容易出现违规或错题 + +## 16. V3 真人动态视频 Prompt 模板 + +### 16.1 中文主模板 + +```text +真实短剧风格,保持参考照片中人物的五官、脸型、年龄感和气质,不能换脸,不能变成其他人。 +人物穿着 {theme_costume},在 {scene_description} 中 {motion_description}。 +镜头竖屏 9:16,电影感光影,真实表情,动作自然,适合抖音短视频纪念片。 +人物身份必须与参考照片一致,保留性别、年龄段、脸型、发型和整体气质。 +``` + +### 16.2 英文辅助词 + +```text +identity preservation, same person as reference, natural facial expression, +realistic body movement, photorealistic cinematic short video, +vertical 9:16, natural camera movement, no identity change +``` + +### 16.3 动作模板片段 + +```text +自然微笑:the person smiles naturally with subtle eye movement, gentle expression. +转头:the person slowly turns head toward the camera, natural neck and shoulder movement. +牵手:the couple gently holds hands and looks at each other, warm and respectful. +行礼:the couple performs a gentle ceremonial bow, stable body posture, respectful mood. +拥抱:the couple gives a gentle hug, natural arms and calm facial expression. +``` + +### 16.4 真人动态负面约束 + +```text +不要改变人物身份 +不要变年轻太多 +不要变成欧美脸 +不要多出第三人 +不要脸部扭曲 +不要手部异常 +不要表情僵硬 +不要恐怖感 +不要过度美颜 +不要低俗姿势 +不要夸张肢体 +不要改变性别 +不要男女混脸 +不要生成未授权名人脸 +``` + +### 16.5 未成年人额外约束 + +```text +健康、温馨、日常、家庭纪念风格。 +禁止成人化服装、成人化姿势、暧昧表达、危险动作和任何不适合儿童的内容。 +``` + +## 17. V3 Prompt 保存要求 + +每个关键帧和视频片段必须保存: + +```text +prompt_text +negative_prompt +person_profile_version +identity_anchor_id +motion_template_id +provider_code +model_name +seed,可选 +``` + +这样后续才能复现、重试、对比 Provider 和排查“为什么不像本人”。 diff --git a/docs/system_b/11_订单支付_额度_成本控制设计.md b/docs/system_b/11_订单支付_额度_成本控制设计.md new file mode 100755 index 0000000..ff39c2f --- /dev/null +++ b/docs/system_b/11_订单支付_额度_成本控制设计.md @@ -0,0 +1,358 @@ +# 11_订单支付_额度_成本控制设计 + +## 1. 文档目标 + +本文档定义系统 B 的订单、支付、额度、退款、修改计费和 AI 成本控制。由于系统使用高质量模型,成本控制是商业落地的核心。 + +## 2. 核心原则 + +1. 高成本正式生成前必须支付或冻结额度。 +2. 预览生成必须限制次数。 +3. 失败重试不重复扣用户额度。 +4. 用户主动大改需要重新计费。 +5. 每个任务必须记录预估成本和实际成本。 +6. 后台必须能看到项目毛利。 + +## 3. 套餐计费 + +### 3.1 标准图集版 + +包含: + +- 1 个主题 +- 1 个世界 +- 3 个场景 +- 6-12 张图 +- 1 次小改 + +### 3.2 短视频版 + +包含: + +- 1-3 个世界 +- 30-60 秒视频 +- 12-24 张图 +- 字幕、BGM +- 1 次小改 + +### 3.3 多世界纪念片 + +包含: + +- 5-10 个世界 +- 1-3 分钟视频 +- 25-60 张图 +- 旁白、字幕、片头片尾 +- 2 次修改 + +### 3.4 高端定制版 + +按人工报价,支持: + +- 自由主题 +- 专属文案 +- 高级动态 +- 人工精修 +- 多轮修改 + +### 3.5 V3 动态写真版 + +包含: + +- 1-3 个世界 +- 6-18 张写真图 +- 3-8 个轻动态片段 +- 眨眼、微笑、背景动效、花瓣光效等轻动作 +- 30-60 秒视频 +- 不默认启用完整真人短剧动作 + +### 3.6 V3 AI 真人动态视频版 + +包含: + +- 1-3 个世界 +- 4-10 个真人动态镜头 +- 每个镜头 3-6 秒 +- 默认 720P +- 人物表情、转头、牵手、行礼、慢走等动作 +- 片段质检 +- 真实视频生成前必须二次确认成本 + +### 3.7 V3 高端真人纪念片 + +按人工报价,支持: + +- 多世界 +- 多场景 +- 关键镜头真人动态 +- 口型 / 誓言 / 旁白 +- 人工精修 +- 人工审核 +- 多轮修改 + +## 4. 订单状态 + +```text +pending:待支付 +paid:已支付 +cancelled:已取消 +failed:支付失败 +refunding:退款中 +refunded:已退款 +closed:已关闭 +``` + +## 5. 支付流程 + +```text +用户选择套餐 +→ 创建订单 +→ 用户支付 +→ 支付回调 +→ 订单标记 paid +→ 项目标记 payment_paid +→ 开放预览/正式生成 +``` + +## 6. 预览策略 + +推荐策略: + +```text +登录用户每天可免费生成 1 次低清水印预览。 +超过免费次数需支付小额预览费或先购买套餐。 +正式高清生成必须支付。 +``` + +预览规则: + +- 加水印 +- 低清或中清 +- 数量少 +- 不提供无水印下载 +- 预览图仅用于确认人物和风格 + +## 7. 额度模型 + +每个订单生成以下额度: + +```text +preview_quota:预览次数 +image_quota:正式图片生成额度 +video_quota:视频生成额度 +revision_quota:修改次数 +advanced_video_quota:高级动态镜头额度 +``` + +## 8. 额度扣减规则 + +### 8.1 正常成功 + +任务成功后扣减对应额度。 + +### 8.2 AI 失败 + +如果 Provider 失败、系统错误、超时导致未产生成果,不扣用户额度。 + +### 8.3 用户不满意 + +如果成果符合套餐说明但用户主观不满意,按修改次数或重新生成次数扣减。 + +### 8.4 大改 + +以下属于大改,需重新计费: + +- 更换整体风格 +- 更换全部世界观 +- 重建人物档案 +- 整条视频重做 +- 超出套餐范围的场景替换 + +## 9. 成本记录 + +每个任务记录: + +```text +task_id +project_id +provider_id +task_type +estimated_cost_cent +actual_cost_cent +input_size +output_size +latency_ms +status +``` + +每个项目汇总: + +```text +订单收入 +AI 文本成本 +AI 图片成本 +AI 视频成本 +TTS 成本 +存储成本估算 +人工成本估算 +总成本 +毛利 +``` + +## 10. 成本控制策略 + +### 10.1 预览和正式分离 + +预览阶段: + +- 少量生成 +- 水印 +- 低清 +- 限制次数 + +正式阶段: + +- 高质量 +- 无水印 +- 完整生成 + +### 10.2 重试限制 + +每个任务默认: + +```text +max_retry = 2 或 3 +``` + +多次失败后转人工,不无限重试。 + +### 10.3 高级动态限制 + +AI 视频成本高,应按套餐限制: + +- 标准图集:不支持 +- 短视频:可选 0-1 个 +- 多世界纪念片:2-5 个 +- 高端定制:按报价 + +### 10.5 V3 真人动态视频成本规则 + +真人动态视频成本按以下维度估算: + +```text +视频片段秒数 +视频 Provider +模型 +分辨率 +候选数量 +重试次数 +口型任务 +人工审核 +``` + +后台配置项: + +```text +max_video_seconds_per_project +max_clip_duration +max_clip_candidates +max_video_retry_per_clip +default_video_resolution +max_cost_per_project +max_cost_per_call +daily_cost_limit +``` + +示例: + +```text +AI 真人动态视频 40 秒: +8 个镜头 +每个 5 秒 +每镜头最多 1 次重试 +默认 720P +``` + +真实视频 Provider 必须满足: + +- 默认禁用。 +- 运营手动启用。 +- 填写 `price_per_second` 或 `price_per_clip`。 +- 设置单次成本上限和当日成本上限。 +- 用户端展示预计费用。 +- 用户确认后才执行。 +- Provider 失败不回落 mock 假成功。 + +### 10.6 V3 片段级成本展示 + +后台和用户端应展示: + +- 每个片段预计成本。 +- 每个片段实际成本。 +- 每个项目视频总成本。 +- 每个用户累计成本。 +- 每个 Provider 今日成本。 +- 重试造成的额外成本。 + +### 10.4 复用素材 + +同一项目中可复用: + +- 人物锚点图 +- 同一世界服装锚点 +- 背景图 +- 片头片尾模板 + +## 11. 退款规则 + +建议规则: + +| 阶段 | 退款建议 | +|---|---| +| 未开始生成 | 可全额退款 | +| 已生成预览 | 可部分退款 | +| 正式生成中 | 一般不退款,可协商 | +| 已交付成品 | 不支持退款,支持套餐内修改 | +| 系统失败无法交付 | 可退款或补偿额度 | + +## 12. 修改计费规则 + +### 小改 + +套餐内可用修改次数。 + +### 中改 + +消耗 1 次修改,必要时消耗部分生成额度。 + +### 大改 + +重新报价或重新下单。 + +## 13. 后台成本看板 + +指标: + +- 今日收入 +- 今日 AI 成本 +- 今日毛利 +- 项目平均成本 +- 图片平均成本 +- 视频平均成本 +- Provider 成本排行 +- 高成本项目提醒 + +## 14. 成本告警 + +触发条件: + +- 单项目成本超过套餐价格的设定比例 +- Provider 日成本超过阈值 +- 失败重试成本异常 +- 用户短时间大量生成预览 + +处理: + +- 暂停项目自动生成 +- 通知管理员 +- 转人工审核 diff --git a/docs/system_b/12_任务队列_错误重试_稳定性设计.md b/docs/system_b/12_任务队列_错误重试_稳定性设计.md new file mode 100755 index 0000000..d179585 --- /dev/null +++ b/docs/system_b/12_任务队列_错误重试_稳定性设计.md @@ -0,0 +1,337 @@ +# 12_任务队列_错误重试_稳定性设计 + +## 1. 文档目标 + +本文档定义系统 B 的任务队列、状态机、幂等、重试、错误码、恢复机制和并发控制。 + +## 2. 为什么必须队列化 + +系统 B 的生成链路长且耗时: + +```text +照片检测 +人物档案 +方案生成 +预览图 +正式图 +图片质检 +TTS +字幕 +视频合成 +最终质检 +人工审核 +``` + +如果同步执行,容易导致: + +- 请求超时 +- 用户重复点击 +- 服务阻塞 +- 失败无法恢复 +- 成本无法追踪 + +## 3. 队列划分 + +```text +photo_check_queue:照片检测 +text_queue:方案、文案、Prompt +image_queue:预览图、正式图 +video_queue:AI 图生视频 +voice_queue:TTS +audio_queue:BGM 处理 +ffmpeg_queue:视频合成 +qc_queue:质量检测 +cleanup_queue:文件清理 +notification_queue:通知 +``` + +## 4. 任务状态 + +```text +pending:等待执行 +running:执行中 +success:成功 +failed:失败 +retrying:重试中 +cancelled:取消 +manual_required:需要人工处理 +``` + +## 5. 任务字段 + +每个任务必须有: + +```text +task_id +project_id +task_type +provider_id +status +input_json +input_hash +output_asset_id +provider_request_id +retry_count +max_retry +cost_estimate +cost_actual +error_code +error_message +created_at +started_at +finished_at +``` + +## 6. 幂等设计 + +幂等 key: + +```text +project_id + task_type + input_hash +``` + +规则: + +- 如果已有成功任务,直接返回成功结果。 +- 如果已有运行任务,返回当前任务状态。 +- 如果已有失败任务,按重试规则处理。 +- 同一请求不能重复创建多个高成本任务。 + +## 7. 项目级锁 + +某些任务必须串行: + +- 创作方案生成 +- 视频合成 +- 最终交付 + +使用 Redis lock: + +```text +lock:project:{project_id}:workflow +``` + +避免并发触发导致状态混乱。 + +## 8. 重试策略 + +默认: + +```text +max_retry = 3 +backoff = exponential +``` + +例如: + +```text +第 1 次失败:30 秒后重试 +第 2 次失败:2 分钟后重试 +第 3 次失败:5 分钟后重试 +``` + +不可重试错误: + +- 用户照片不合格 +- 余额不足 +- 授权未确认 +- 内容审核不通过 +- 套餐限制冲突 + +可重试错误: + +- Provider 超时 +- 网络错误 +- 速率限制 +- 临时服务异常 +- FFmpeg 临时失败 + +## 9. 失败转人工 + +满足任一条件转人工: + +- 同一任务连续失败超过 max_retry +- 图片质检连续失败 +- 人像一致性评分过低 +- 视频合成失败 +- 审核 warning +- 高端定制项目 + +状态: + +```text +manual_required +``` + +## 10. 并发限制 + +### 用户级 + +```text +每个用户最多同时 1 个正式生成项目 +``` + +### 项目级 + +```text +每个项目最多同时 N 个图片任务 +视频合成任务只能 1 个 +``` + +### Provider 级 + +```text +按 Provider 设置 QPS 和并发数 +``` + +## 11. 队列优先级 + +优先级建议: + +1. 高端定制项目 +2. 已支付正式生成 +3. 预览生成 +4. 免费预览 +5. 清理任务 + +## 12. 错误码设计 + +| 错误码 | 含义 | +|---|---| +| TASK_TIMEOUT | 任务超时 | +| PROVIDER_TIMEOUT | Provider 超时 | +| PROVIDER_RATE_LIMIT | Provider 限流 | +| PROVIDER_ERROR | Provider 错误 | +| INPUT_INVALID | 输入参数错误 | +| PHOTO_QUALITY_FAIL | 照片质量不合格 | +| PAYMENT_REQUIRED | 需要支付 | +| QUOTA_NOT_ENOUGH | 额度不足 | +| MODERATION_REJECTED | 审核不通过 | +| FFMPEG_FAILED | 视频合成失败 | +| STORAGE_FAILED | 存储失败 | +| UNKNOWN_ERROR | 未知错误 | + +## 13. 任务恢复 + +服务重启后: + +1. 扫描 running 超时任务。 +2. 判断是否有 Provider request_id。 +3. 查询 Provider 状态,能恢复则恢复。 +4. 无法恢复则标记 failed 并按规则重试。 + +## 14. 状态一致性 + +项目状态由 WorkflowService 统一更新。不要让 Worker 随意改最终状态。 + +Worker 只上报: + +```text +task success/failed +output asset +progress +error +``` + +WorkflowService 根据任务完成情况推进项目状态。 + +## 15. 防重复点击 + +前端:按钮 loading,防抖。 +后端:幂等 key + 状态校验。 + +例如: + +- 已在 preview_generating,不允许再次 generate-preview。 +- 已 payment_paid,不允许重复创建同一套餐订单。 + +## 16. 任务超时设置 + +建议: + +```text +photo_check:2 分钟 +text_generate:3 分钟 +image_generate:10 分钟 +video_generate:30 分钟 +voice_generate:5 分钟 +ffmpeg_render:30 分钟 +final_qc:10 分钟 +``` + +## 17. 告警 + +触发告警: + +- 失败任务数超过阈值 +- 某 Provider 连续失败 +- 队列积压过多 +- 视频合成失败率过高 +- 单项目成本异常 +- 磁盘/MinIO 容量不足 + +## 18. 日志要求 + +每个任务记录: + +- 输入摘要 +- 输出摘要 +- Provider +- 成本 +- 耗时 +- 错误 +- 重试次数 + +敏感数据脱敏保存。 + +## 19. V3 真人动态视频队列增量 + +新增队列: + +```text +identity_anchor_queue:身份锚点生成 +face_consistency_queue:本人相似度检查 +motion_portrait_queue:动态写真轻动效 +real_video_clip_queue:真人动态视频片段 +video_clip_qc_queue:视频片段质检 +lipsync_queue:口型同步 +``` + +隔离原则: + +- `real_video_clip_queue` 必须独立限流,避免高成本视频任务堵住图片和普通合成。 +- `lipsync_queue` 独立限流,失败不影响基础旁白字幕交付。 +- `face_consistency_queue` 可优先级较高,因为它决定是否能继续生成。 + +V3 超时建议: + +```text +identity_anchor_generate:10 分钟 +face_consistency_check:3 分钟 +motion_portrait_generate:20 分钟 +real_video_clip_generate:60 分钟 +video_clip_qc:10 分钟 +lipsync_generate:45 分钟 +``` + +V3 错误码: + +```text +IDENTITY_ANCHOR_NOT_CONFIRMED +FACE_CONSISTENCY_LOW +REAL_VIDEO_PROVIDER_DISABLED +REAL_VIDEO_CONFIRMATION_REQUIRED +REAL_VIDEO_COST_LIMIT_EXCEEDED +REAL_VIDEO_CLIP_FAILED +VIDEO_CLIP_QC_FAILED +LIPSYNC_FAILED +MINOR_MANUAL_REVIEW_REQUIRED +``` + +真实视频任务失败时: + +1. 记录 Provider 错误。 +2. 标记片段 failed 或 needs_retry。 +3. 不回落 mock 假成功。 +4. 不重复扣用户额度。 +5. 用户端显示失败原因和重试入口。 diff --git a/docs/system_b/13_隐私授权_内容审核_合规设计.md b/docs/system_b/13_隐私授权_内容审核_合规设计.md new file mode 100755 index 0000000..1553630 --- /dev/null +++ b/docs/system_b/13_隐私授权_内容审核_合规设计.md @@ -0,0 +1,295 @@ +# 13_隐私授权_内容审核_合规设计 + +## 1. 文档目标 + +系统 B 处理真人照片、婚恋关系、家庭成员、儿童照片和定制视频,必须从第一版就设计隐私授权、内容审核、删除机制和版权合规。 + +## 2. 基本原则 + +1. 用户作品默认私密。 +2. 用户照片只用于本次项目生成。 +3. 公开展示案例必须单独授权。 +4. 未成年人照片必须确认监护人授权。 +5. 用户可以申请删除作品和素材。 +6. 后台访问敏感数据必须记录日志。 +7. 音乐和素材必须有商业授权来源。 + +## 3. 上传前授权 + +用户上传照片前必须勾选: + +```text +我确认拥有上传照片的合法使用权。 +我确认已获得照片中人物授权。 +我授权平台为本次项目生成图片和视频。 +我知道作品默认不公开展示。 +如涉及未成年人,我确认我是监护人或已获得监护人授权。 +``` + +V3 真人动态视频新增授权: + +```text +我确认拥有上传照片中所有人物授权。 +我授权平台仅为本项目生成图像和视频。 +我理解 AI 生成结果可能与本人存在差异。 +我确认不得上传未经授权的他人照片。 +我理解 AI 真人动态视频可能产生表情、动作、服装和场景变化。 +如涉及未成年人,我确认我是监护人或已获得监护人授权。 +``` + +授权记录保存: + +```text +user_id +project_id +authorization_type +content +ip +user_agent +confirmed_at +``` + +## 4. 公开案例授权 + +默认不公开。 + +用户在成品页主动点击“授权公开为案例”后,才允许后台加入案例库。 + +授权内容应说明: + +- 可在首页、案例页、宣传材料展示。 +- 可展示成品图/视频。 +- 不展示用户手机号等隐私信息。 +- 用户可申请撤回授权。 + +## 5. 未成年人规则 + +涉及主题: + +- 宝宝百日 +- 儿童成长 +- 亲子纪念 +- 家庭全家福 + +必须: + +- 默认不公开展示。 +- 增加监护人确认。 +- 后台审核更严格。 +- 禁止生成不适合儿童的内容。 + +## 6. 用户删除权 + +用户可申请: + +- 删除项目 +- 删除上传照片 +- 删除成品 +- 删除公开案例 + +删除策略: + +```text +前台删除:用户不可见,进入删除队列。 +后台删除:运营确认后清理 MinIO 文件和数据库状态。 +备份删除:按备份保留策略到期清理。 +``` + +## 7. 内容审核范围 + +必须审核: + +```text +用户上传照片 +用户输入文案 +系统生成 Prompt +生成图片 +生成视频封面 +最终视频 +公开案例 +``` + +## 8. 禁止内容 + +禁止生成: + +- 色情或露骨内容 +- 侮辱性内容 +- 暴力血腥内容 +- 未成年人不当内容 +- 未授权名人/公众人物冒用 +- 政治人物冒充 +- 违法犯罪宣传 +- 恐吓、诈骗、仇恨内容 + +## 9. 文案审核 + +用户输入: + +- 一句话誓言 +- 特别要求 +- 片尾文案 +- 自定义世界描述 + +必须先走文本审核。 + +审核失败: + +- 提示用户修改 +- 不进入生成流程 + +## 10. 图片审核 + +阶段: + +1. 上传照片审核 +2. 预览图审核 +3. 正式图审核 +4. 案例公开前审核 + +审核结果: + +```text +pass +warning +reject +``` + +## 11. 视频审核 + +最终视频合成后必须检查: + +- 画面是否违规 +- 字幕是否违规 +- 封面是否违规 +- 是否含未经授权 logo 或水印 + +V3 真人动态视频片段还必须检查: + +- 是否像本人。 +- 是否变脸。 +- 是否男女混脸。 +- 是否年龄变化过大。 +- 动作是否自然。 +- 表情是否怪异。 +- 是否有低俗、不尊重或不合适姿势。 +- 是否适合公开案例。 + +结果为 `warning` 或 `manual_review` 的片段,不允许自动进入公开案例。 + +## 12. 后台权限控制 + +原则: + +- 普通运营不能查看 Provider 密钥。 +- 审核员只能看审核相关素材。 +- 财务只能看订单和成本,不看用户原图。 +- 超级管理员敏感操作要记录日志。 + +## 13. 文件访问安全 + +要求: + +- 原图不直接暴露公网。 +- 下载使用签名 URL。 +- 下载链接有效期可配置。 +- 公开案例资源复制到公开 bucket 或 public path。 +- 删除项目后停止下载链接生成。 + +## 14. EXIF 清理 + +用户上传照片后必须去除 EXIF 信息,避免泄露: + +- 拍摄地点 +- 设备信息 +- 拍摄时间 + +## 15. 音乐版权 + +音乐素材必须记录: + +```text +音乐名称 +来源 +授权类型 +授权文件 +使用范围 +有效期 +``` + +不允许使用来源不明音乐进行商业交付。 + +## 16. 用户协议与隐私协议 + +至少需要: + +- 用户服务协议 +- 隐私政策 +- 肖像授权说明 +- 公开案例授权说明 +- 未成年人监护人确认说明 +- 退款和修改规则 + +## 17. 审计日志 + +记录: + +- 管理员查看用户照片 +- 管理员下载素材 +- 管理员删除素材 +- 修改项目状态 +- 上架公开案例 +- 修改 Provider 配置 + +## 18. 风险处理 + +如果发现用户上传未授权照片或生成侵权内容: + +1. 暂停项目。 +2. 隐藏作品。 +3. 通知用户补充授权或删除。 +4. 必要时关闭账号。 + +## 19. V3 真人身份和原图保护 + +系统 B V3 涉及真实用户人脸,必须强化: + +- 原图私有存储,不直接暴露公网。 +- 原图下载和后台预览必须记录审计日志。 +- 公开案例不使用原始照片,除非用户单独授权。 +- 默认不把原图、锚点图、视频片段用于模型训练。 +- 身份锚点图属于敏感资产,访问权限等同原图。 +- 删除项目时,原图、锚点图、关键帧、视频片段都进入清理队列。 + +## 20. V3 未成年人保护 + +涉及儿童成长、宝宝百日、亲子纪念、家庭全家福时: + +- 必须确认监护人授权。 +- 默认不公开案例。 +- 不允许成人化服装、成人化姿势、暧昧表达、危险动作。 +- 动态视频和口型内容必须人工复核。 +- 后台公开案例审核必须二次确认未成年人风险。 + +## 21. V3 公开案例二次授权 + +公开案例授权必须独立于项目生成授权。 + +授权内容必须明确: + +```text +可展示哪些图片。 +可展示哪些视频片段。 +是否展示完整成片。 +是否允许展示人物昵称或纪念主题。 +用户可随时撤回公开授权。 +``` + +默认值: + +```text +不公开 +不进案例库 +不用于宣传材料 +不用于模型训练 +``` diff --git a/docs/system_b/14_部署运维_日志监控_备份设计.md b/docs/system_b/14_部署运维_日志监控_备份设计.md new file mode 100755 index 0000000..bcc7278 --- /dev/null +++ b/docs/system_b/14_部署运维_日志监控_备份设计.md @@ -0,0 +1,258 @@ +# 14_部署运维_日志监控_备份设计 + +## 1. 文档目标 + +本文档定义系统 B 的部署结构、服务目录、环境变量、日志、监控、备份、告警和清理策略。 + +## 2. 推荐部署架构 + +第一阶段单服务器即可: + +```text +Nginx +NestJS API +BullMQ Workers +MySQL +Redis +MinIO +FFmpeg +Geeker-Admin 静态文件 +uni-app H5 静态文件 +``` + +后续可拆分: + +```text +API 服务器 +Worker 服务器 +数据库服务器 +对象存储 +视频渲染服务器 +``` + +## 3. 目录结构建议 + +```text +/opt/system-b/ + backend/ + admin-web/ + user-web/ + workers/ + logs/ + ffmpeg-temp/ + docker-compose.yml + .env +``` + +## 4. 环境变量 + +```text +NODE_ENV=production +APP_PORT=3000 +MYSQL_HOST=127.0.0.1 +MYSQL_PORT=3306 +MYSQL_USER=system_b +MYSQL_PASSWORD=xxx +MYSQL_DATABASE=system_b +REDIS_HOST=127.0.0.1 +REDIS_PORT=6379 +MINIO_ENDPOINT=127.0.0.1 +MINIO_PORT=9000 +MINIO_ACCESS_KEY=xxx +MINIO_SECRET_KEY=xxx +JWT_SECRET=xxx +OPENAI_API_KEY=xxx +``` + +Provider 密钥建议使用密钥管理或环境变量,不直接写入数据库明文。 + +## 5. Nginx 配置目标 + +反代: + +```text +/api → NestJS +/admin → Geeker-Admin +/ → uni-app H5 +/ws → WebSocket +``` + +静态文件不直接暴露 MinIO 私密 bucket。 + +## 6. 进程管理 + +可选: + +- PM2 +- systemd +- Docker Compose + +服务: + +```text +api-server +worker-photo +worker-text +worker-image +worker-video +worker-ffmpeg +worker-qc +``` + +## 7. 日志设计 + +日志类型: + +```text +api.log +worker.log +error.log +provider.log +payment.log +ffmpeg.log +audit.log +``` + +日志要求: + +- 按天切割 +- 保留 30-90 天 +- 敏感信息脱敏 +- 重要错误报警 + +## 8. 监控指标 + +应用指标: + +- API 响应时间 +- API 错误率 +- 队列积压数 +- 任务失败率 +- Provider 失败率 +- 视频合成耗时 + +系统指标: + +- CPU +- 内存 +- 磁盘 +- 网络 +- MinIO 容量 +- MySQL 连接数 +- Redis 内存 + +业务指标: + +- 今日订单数 +- 今日收入 +- 今日 AI 成本 +- 项目完成数 +- 待审核数 + +## 9. 告警规则 + +触发: + +- API 5xx 错误率过高 +- 队列积压超过阈值 +- Provider 连续失败 +- 磁盘剩余空间低于 20% +- MinIO 容量不足 +- MySQL 备份失败 +- 单日 AI 成本超阈值 + +## 10. 数据库备份 + +建议: + +```text +每日全量备份 +每小时 binlog 增量 +备份保留 7-30 天 +定期恢复演练 +``` + +备份目录: + +```text +/backups/mysql/YYYY-MM-DD/ +``` + +## 11. MinIO 备份 + +策略: + +- 重要成品每日同步 +- 用户原图按隐私策略备份 +- 临时文件不备份 +- 公开案例单独备份 + +## 12. Redis 持久化 + +Redis 用于队列和缓存: + +- 开启 AOF 或 RDB +- 定期检查内存 +- 设置合适 maxmemory 策略 + +## 13. 临时文件清理 + +清理对象: + +- FFmpeg 临时文件 +- 预览临时素材 +- 失败任务中间文件 +- 过期下载包 + +策略: + +```text +每晚 cleanup_queue 执行 +临时文件保留 1-3 天 +失败任务文件保留 7 天 +``` + +## 14. 项目素材保留策略 + +建议: + +| 类型 | 保留 | +|---|---| +| 用户上传原图 | 按用户协议,默认 90-180 天或项目删除后清理 | +| 预览图 | 30 天 | +| 正式图 | 180 天或长期,按套餐 | +| 成品视频 | 180 天或长期,按套餐 | +| 公开案例 | 授权有效期间保留 | + +## 15. 发布流程 + +```text +代码提交 +→ 自动测试 +→ 构建后端 +→ 构建后台 +→ 构建用户端 +→ 数据库迁移 +→ 灰度发布 +→ 检查日志 +→ 正式发布 +``` + +## 16. 回滚方案 + +必须保留: + +- 上一个后端版本 +- 上一个前端静态包 +- 数据库迁移回滚脚本 +- 配置备份 + +## 17. 安全要求 + +- HTTPS +- 后台登录二次验证,后续可加 +- 密钥不提交代码仓库 +- 上传文件类型限制 +- API 限流 +- 防刷验证码 +- 后台操作日志 diff --git a/docs/system_b/15_测试用例_验收标准.md b/docs/system_b/15_测试用例_验收标准.md new file mode 100755 index 0000000..6ed57bc --- /dev/null +++ b/docs/system_b/15_测试用例_验收标准.md @@ -0,0 +1,438 @@ +# 15_测试用例_验收标准 + +## 1. 文档目标 + +本文档定义系统 B 的测试范围、测试用例和验收标准,确保项目不仅能跑通,还能稳定交付。 + +## 2. 验收原则 + +1. 主流程必须完整跑通。 +2. 异常流程必须可恢复。 +3. 任务失败不能导致项目死锁。 +4. 支付、额度、成本必须准确。 +5. 用户隐私和授权流程必须完整。 +6. 后台必须能处理失败、审核、修改。 + +## 3. 用户注册登录测试 + +### 用例 1:手机号验证码登录 + +步骤: + +1. 输入手机号。 +2. 获取验证码。 +3. 输入验证码登录。 + +预期: + +- 登录成功。 +- 返回 token。 +- 用户信息正常。 + +### 用例 2:未登录访问制作流程 + +步骤: + +1. 游客点击开始制作。 + +预期: + +- 跳转登录页。 +- 登录后回到创建项目页。 + +## 4. 首页案例测试 + +### 用例 + +1. 首页加载。 +2. 查看案例列表。 +3. 打开案例详情。 +4. 点击用同款制作。 + +预期: + +- 公开案例正常展示。 +- 未授权项目不出现在案例页。 +- 用同款制作能带入模板。 + +## 5. 项目创建测试 + +### 主流程 + +1. 创建项目。 +2. 选择主题。 +3. 选择套餐。 +4. 选择风格。 +5. 选择世界。 +6. 选择场景。 + +预期: + +- 项目状态正确推进。 +- 选择数据正确保存。 +- 套餐限制生效。 + +### 异常 + +- 世界选择超出套餐限制。 +- 场景数量不足。 +- 返回上一步后数据保留。 + +## 6. 照片上传测试 + +### 合格照片 + +预期:上传成功,质检 pass。 + +### 不合格照片 + +测试: + +- 模糊 +- 戴墨镜 +- 多人混入 +- 无人脸 +- 低分辨率 + +预期:质检 fail 或 warning,并给出原因。 + +## 7. 授权测试 + +### 未勾选授权 + +预期:不能继续下一步。 + +### 涉及未成年人主题 + +预期:必须出现监护人授权确认。 + +## 8. 创作方案测试 + +步骤: + +1. 提交定制信息。 +2. 生成创作方案。 +3. 查看镜头计划。 + +预期: + +- 方案与主题、世界、场景一致。 +- 不出现未选择的世界。 +- 字幕和旁白不超长。 + +## 9. 支付与额度测试 + +### 正常支付 + +预期:订单 paid,项目进入 payment_paid。 + +### 未支付直接生成正式图 + +预期:拒绝,提示需要支付。 + +### 失败重试 + +预期:系统错误重试不重复扣用户额度。 + +## 10. 预览生成测试 + +步骤: + +1. 触发预览。 +2. 等待任务完成。 + +预期: + +- 生成低清水印预览。 +- 用户可确认或重新生成。 +- 预览次数受限。 + +## 11. 正式图生成测试 + +预期: + +- 按镜头计划生成图片。 +- 图片保存为 asset。 +- render_task 状态 success。 +- 成本日志记录。 + +异常: + +- Provider 超时,应重试。 +- 多次失败转人工。 + +## 12. 图片质检测试 + +测试内容: + +- 多出第三人 +- 手部异常 +- 人脸崩坏 +- 场景错误 +- 文字乱入 + +预期: + +- fail 自动重生。 +- warning 转人工复核。 + +## 13. 视频合成测试 + +步骤: + +1. 准备图片、字幕、BGM、旁白。 +2. 触发 FFmpeg 合成。 + +预期: + +- 输出 MP4。 +- 视频可播放。 +- 字幕不出框。 +- 音画同步。 +- 封面生成正常。 + +## 14. 成品确认测试 + +操作: + +- 下载视频。 +- 下载图集。 +- 确认完成。 +- 申请修改。 + +预期: + +- 下载链接有效期正常。 +- 确认后项目 completed。 +- 修改次数正确扣减。 + +## 15. 修改申请测试 + +### 小改 + +预期:允许套餐内修改。 + +### 中改 + +预期:消耗修改次数,创建重做任务。 + +### 大改 + +预期:提示重新计费。 + +## 16. 后台任务测试 + +操作: + +- 查看任务。 +- 重试任务。 +- 终止任务。 +- 转人工。 + +预期: + +- 状态正确变化。 +- 重试不重复创建相同任务。 + +## 17. 权限测试 + +- 用户不能看他人项目。 +- 运营不能看 Provider 密钥。 +- 财务不能看用户原图。 +- 超级管理员可以配置系统。 + +## 18. 隐私删除测试 + +步骤: + +1. 用户申请删除作品。 +2. 后台处理。 +3. 检查下载链接。 + +预期: + +- 前台不可见。 +- 下载链接失效。 +- 素材进入清理队列。 + +## 19. 压力测试 + +测试: + +- 100 个用户同时浏览首页。 +- 20 个项目同时生成预览。 +- 5 个项目同时正式生成视频。 + +关注: + +- API 延迟 +- 队列积压 +- Provider 限流 +- 服务器 CPU/内存 + +## 20. 最终验收标准 + +MVP 完成标准: + +```text +用户可以注册登录。 +用户可以完整创建项目。 +用户可以上传照片并质检。 +用户可以选择主题、套餐、风格、世界、场景。 +系统可以生成创作方案。 +系统可以生成预览图。 +支付/额度逻辑可用。 +系统可以生成正式图。 +系统可以合成基础视频。 +用户可以下载成品。 +用户可以申请修改。 +后台可以管理项目、模板、任务、订单、案例。 +失败任务可以重试或转人工。 +授权和公开案例逻辑可用。 +``` + +## 21. V3 输出模式测试 + +### 用例:四档输出模式创建 + +分别创建: + +- 高清写真图集 +- 图片纪念视频 +- 动态写真视频 +- AI 真人动态视频 + +预期: + +- 项目 `output_mode` 保存正确。 +- 套餐限制正确。 +- AI 真人动态视频模式展示费用高、耗时长、需确认的提示。 +- 未选择真实视频确认时,不允许进入真实视频生成。 + +## 22. V3 真人身份锚点测试 + +步骤: + +1. 上传同一人物多张清晰照片。 +2. 生成身份档案。 +3. 生成身份锚点图。 +4. 做人脸一致性评分。 +5. 用户确认锚点。 + +预期: + +- 身份锚点图可预览。 +- `face_consistency_score` 有记录。 +- 用户未确认锚点前,不能正式生成真人动态视频。 +- 锚点不满意可重生或转人工。 + +## 23. V3 视频片段生成测试 + +步骤: + +1. 准备已确认身份锚点。 +2. 准备关键帧。 +3. 选择启用的 VideoProvider。 +4. 查看成本估算。 +5. 勾选真实视频确认。 +6. 生成 1 个 3-6 秒视频片段。 + +预期: + +- 生成前展示预计成本。 +- 生成后可预览片段。 +- 片段写入 `video_clips`。 +- Provider 日志记录成本、耗时、状态。 +- 禁用 Provider 返回明确错误。 +- 真实 Provider 失败不会回落 mock 假成功。 + +## 24. V3 视频片段质检测试 + +测试内容: + +- 人脸不像本人。 +- 男女混脸。 +- 年龄变化过大。 +- 动作不自然。 +- 表情怪异。 +- 多出第三人。 +- 手部畸形。 + +预期: + +- passed 可进入合成。 +- needs_retry 可重试。 +- manual_review 转人工。 +- rejected 不进入成片和公开案例。 + +## 25. V3 口型任务测试 + +步骤: + +1. 选择一个已生成视频片段。 +2. 绑定音频。 +3. 触发口型任务。 + +预期: + +- `lipsync_tasks` 状态正确。 +- 成本单独记录。 +- 失败时可回退旁白字幕版本。 +- 涉及未成年人时必须人工复核。 + +## 26. V3 成本保护测试 + +测试: + +- 单片段成本超过上限。 +- 项目视频秒数超过上限。 +- Provider 日成本超过阈值。 +- 重试次数超过上限。 + +预期: + +- 调用前拦截。 +- 写入失败任务和错误信息。 +- 不调用外部真实 Provider。 +- 用户端和后台都能看到原因。 + +## 27. V3 隐私和公开案例测试 + +测试: + +- 未授权公开案例。 +- 涉及未成年人公开案例。 +- 后台查看原图。 +- 用户撤回公开授权。 + +预期: + +- 默认不公开。 +- 未成年人默认不进入案例库。 +- 后台查看原图写审计日志。 +- 撤回授权后公开案例下架,下载链接失效或停止生成。 + +## 28. V3 真实视频小样验收标准 + +第一轮真实付费小样只验收 1 个镜头: + +```text +同一人物 +同一世界 +同一动作 +同一时长 +分别测试 MiniMax Hailuo / Wan / Vidu / Seedance 等 Provider +``` + +评分维度: + +- 本人相似度。 +- 面部表情。 +- 动作自然度。 +- 镜头语言。 +- 中文短剧感。 +- 清晰度。 +- Prompt 可控性。 +- 失败率。 +- 生成耗时。 +- 实际成本。 diff --git a/docs/system_b/16_Codex开发任务拆解文档.md b/docs/system_b/16_Codex开发任务拆解文档.md new file mode 100755 index 0000000..8f33f6d --- /dev/null +++ b/docs/system_b/16_Codex开发任务拆解文档.md @@ -0,0 +1,635 @@ +# 16_Codex开发任务拆解文档 + +## 1. 文档目标 + +本文档用于把系统 B 拆成适合 Codex 执行的开发任务。每个任务应该尽量独立、可验证、可回滚。 + +## 2. 开发原则 + +1. 先跑通主流程,再优化细节。 +2. 每个任务必须有验收标准。 +3. 不要一次让 Codex 写完整系统。 +4. 数据库、接口、页面、任务队列分阶段完成。 +5. AI Provider 先做抽象,再接具体模型。 + +## 3. 阶段 1:项目基础框架 + +### 任务 01:初始化后端项目 + +目标:创建 NestJS 后端项目。 + +要求: + +- TypeScript +- 环境变量配置 +- 全局异常处理 +- 日志基础封装 +- Swagger 可选 + +验收: + +- `npm run start` 正常启动。 +- `/api/health` 返回 ok。 + +### 任务 02:配置数据库 ORM + +目标:接入 MySQL。 + +要求: + +- TypeORM 或 Prisma 二选一 +- 配置迁移 +- 创建基础 users/projects 表 + +验收: + +- 可以执行迁移。 +- 可以读写测试数据。 + +### 任务 03:配置 Redis 和 BullMQ + +目标:接入 Redis 队列。 + +要求: + +- QueueModule +- 测试队列 +- Worker 示例 + +验收: + +- 能创建任务。 +- Worker 能消费任务。 + +### 任务 04:配置 MinIO 文件上传 + +目标:实现对象存储。 + +要求: + +- 上传文件 +- 生成签名 URL +- 删除文件 +- 缩略图字段预留 + +验收: + +- 上传图片成功。 +- 数据库 asset 记录生成。 + +## 4. 阶段 2:用户与权限 + +### 任务 05:实现用户注册登录 + +要求: + +- 手机号验证码模拟版 +- JWT 登录 +- 获取当前用户 + +验收: + +- 登录成功返回 token。 +- 受保护接口能识别用户。 + +### 任务 06:实现后台管理员登录 + +要求: + +- admin_users +- roles +- 权限中间件 + +验收: + +- 管理员可登录。 +- 不同角色权限可区分。 + +## 5. 阶段 3:模板系统 + +### 任务 07:实现人生主题 API + +接口: + +- GET /api/life-themes +- POST /api/admin/life-themes +- PUT /api/admin/life-themes/:id + +验收: + +- 前台可读取主题。 +- 后台可新增编辑。 + +### 任务 08:实现套餐 API + +接口: + +- GET /api/packages +- 后台增删改查 + +验收: + +- 套餐限制字段可配置。 + +### 任务 09:实现风格、世界、场景、镜头模板 API + +要求: + +- style_templates +- world_templates +- scene_templates +- shot_templates + +验收: + +- 前台按主题/风格查询世界。 +- 后台可维护模板。 + +## 6. 阶段 4:项目创建流程 + +### 任务 10:实现项目创建 API + +接口: + +- POST /api/projects +- GET /api/projects/:id +- GET /api/my/projects + +验收: + +- 用户可创建项目。 +- 只能查看自己的项目。 + +### 任务 11:实现项目选择流程 API + +接口: + +- POST /api/projects/:id/package +- POST /api/projects/:id/style +- POST /api/projects/:id/worlds +- POST /api/projects/:id/scenes +- POST /api/projects/:id/custom-info + +验收: + +- 项目选择数据可保存。 +- 套餐限制生效。 + +### 任务 12:实现授权记录 + +接口: + +- POST /api/projects/:id/authorizations + +验收: + +- 未授权不能进入照片质检。 +- 授权记录包含 IP 和 UA。 + +## 7. 阶段 5:照片与人物档案 + +### 任务 13:实现照片上传 + +要求: + +- 按 person_role 上传 +- 创建 asset +- 绑定 project + +验收: + +- 上传成功。 +- 角色归属正确。 + +### 任务 14:实现基础照片质检 Worker + +第一版可先实现基础检测: + +- 文件格式 +- 尺寸 +- 文件大小 +- 是否可读取 + +后续接 Python 人脸检测。 + +验收: + +- 合格返回 pass。 +- 不合格返回 fail。 + +### 任务 15:实现人物档案生成 + +要求: + +- 汇总合格照片 +- 创建 person_profiles +- 外貌摘要先可用文本模型生成,或手动占位 + +验收: + +- 每个角色有 person_profile。 + +## 8. 阶段 6:AI Provider 抽象 + +### 任务 16:实现 Provider 配置表和管理 API + +要求: + +- provider_configs +- provider_logs +- 后台可新增编辑测试 + +验收: + +- Provider 不写死在代码。 + +### 任务 17:实现 TextProvider 接口 + +功能: + +- generatePlan +- generatePrompt +- generateCopy + +验收: + +- 输入项目数据,输出创作方案 JSON。 + +### 任务 18:实现 ImageProvider 接口 + +功能: + +- generatePreviewImage +- generateFinalImage + +验收: + +- 可生成图片并保存 asset。 + +### 任务 19:实现 VoiceProvider 接口 + +功能: + +- 文本转音频 + +验收: + +- 生成音频 asset。 + +## 9. 阶段 7:生成工作流 + +### 任务 20:创作方案生成 + +接口: + +- POST /api/projects/:id/generate-plan +- GET /api/projects/:id/plan +- POST /api/projects/:id/confirm-plan + +验收: + +- 任务队列化。 +- 方案保存为 shot_plans。 + +### 任务 21:预览图生成 + +接口: + +- POST /api/projects/:id/generate-preview +- POST /api/projects/:id/confirm-preview + +验收: + +- 生成水印预览图。 +- 用户可确认。 + +### 任务 22:正式图生成 + +接口: + +- POST /api/projects/:id/generate-final + +验收: + +- 按 shot_plans 生成正式图。 +- 图片 asset 绑定镜头。 + +### 任务 23:图片质检任务 + +要求: + +- 先做基础检查 +- 后续接高级检测 + +验收: + +- fail 任务可重试。 + +## 10. 阶段 8:视频合成 + +### 任务 24:字幕生成 + +要求: + +- 根据旁白/文案生成 SRT + +验收: + +- SRT 文件保存为 asset。 + +### 任务 25:FFmpeg 视频合成 Worker + +要求: + +- 图片转视频 +- 简单推拉 +- BGM +- 字幕 +- 输出 MP4 + +验收: + +- 生成可播放视频。 +- 项目 video_asset_id 更新。 + +### 任务 26:成品下载 + +接口: + +- GET /api/assets/:id/download-url + +验收: + +- 签名链接有效。 +- 非本人不能下载。 + +## 11. 阶段 9:订单和额度 + +### 任务 27:订单创建 + +接口: + +- POST /api/projects/:id/orders + +验收: + +- 创建 pending 订单。 + +### 任务 28:支付模拟和状态推进 + +第一版可做后台手动标记支付。 + +验收: + +- paid 后项目进入 payment_paid。 +- 未支付不能正式生成。 + +### 任务 29:额度和成本日志 + +要求: + +- 任务成本记录 +- 项目成本汇总 + +验收: + +- 后台能看到项目成本。 + +## 12. 阶段 10:后台管理 + +### 任务 30:接入 Geeker-Admin + +要求: + +- 登录 +- 菜单 +- 权限 +- 基础布局 + +验收: + +- 后台可登录。 + +### 任务 31:项目管理页 + +功能: + +- 项目列表 +- 项目详情 +- 任务查看 +- 素材查看 + +### 任务 32:模板管理页 + +功能: + +- 主题 +- 套餐 +- 风格 +- 世界 +- 场景 +- 镜头 + +### 任务 33:任务管理页 + +功能: + +- 查看任务 +- 重试 +- 终止 +- 转人工 + +### 任务 34:案例管理页 + +功能: + +- 从项目生成案例 +- 上架/下架 +- 设置首页推荐 + +## 13. 阶段 11:修改与审核 + +### 任务 35:修改申请 + +接口: + +- POST /api/projects/:id/revisions +- 后台处理修改申请 + +验收: + +- 修改次数扣减。 +- 大改提示重新计费。 + +### 任务 36:人工审核流程 + +要求: + +- manual_review 状态 +- 审核通过/驳回 + +验收: + +- 高端项目可进入人工审核。 + +## 14. 阶段 12:稳定性和运维 + +### 任务 37:任务幂等 + +要求: + +- input_hash +- 重复请求不重复生成 + +### 任务 38:错误码和异常处理 + +要求: + +- 统一错误返回 +- 日志记录 + +### 任务 39:清理任务 + +要求: + +- 临时文件清理 +- 过期下载链接 + +### 任务 40:部署脚本 + +要求: + +- Docker Compose 或 PM2 +- Nginx 配置示例 +- 环境变量模板 + +## 15. 建议开发顺序 + +```text +基础框架 +→ 用户登录 +→ 模板系统 +→ 项目创建 +→ 文件上传 +→ 照片质检 +→ AI Provider +→ 创作方案 +→ 图片生成 +→ 视频合成 +→ 后台管理 +→ 订单支付 +→ 修改审核 +→ 稳定性增强 +``` + +## 15.1 V3 真人动态视频追加阶段 + +V3 不建议一开始就全片真实视频化,应按以下顺序追加: + +```text +V2 图集/图片视频主流程 +→ 输出模式分层 +→ 真人身份锚点 +→ 本人相似度质检 +→ 动态写真轻动效 +→ 真实 VideoProvider 配置 +→ 单镜头真实视频小样 +→ 视频片段列表/重试/质检 +→ 片段合成 +→ 口型任务,可选 +→ 隐私/未成年人/公开案例强化验收 +``` + +### 阶段 13:输出模式和身份锚点 + +任务 41:项目输出模式 + +- 增加 `output_mode`:photo_album/image_video/motion_portrait/real_video/premium_film。 +- 用户端创建项目时明确选择。 +- 后台项目列表展示输出模式。 + +任务 42:身份锚点表和接口 + +- 增加 `identity_anchors`。 +- 支持生成锚点、用户确认、后台确认。 +- 未确认锚点不允许真实视频生成。 + +任务 43:FaceConsistencyProvider + +- 抽象本人相似度检查。 +- 第一版可 mock,后续接真实人脸一致性服务。 +- 结果写入 `face_consistency_score`。 + +### 阶段 14:动态写真和动作模板 + +任务 44:动作模板管理 + +- 增加 `motion_templates`。 +- 后台可维护动作名称、Prompt 规则、时长、难度和成本等级。 + +任务 45:动态写真片段 + +- 支持眨眼、微笑、头发衣服轻微动、背景动效。 +- 先用 mock 或轻量 Provider 跑通,不直接上全片真实视频。 + +### 阶段 15:AI 真人动态视频片段 + +任务 46:VideoProvider 国内预设 + +- 预置 MiniMax Hailuo、阿里 Wan、Vidu、Seedance、Kling、Runway。 +- 默认禁用。 +- 后台可配置 Key、Base URL、模型、单价、成本阈值。 + +任务 47:视频片段生成 + +- 增加 `video_clips`。 +- 按镜头生成 3-6 秒片段。 +- 生成前做成本估算。 +- 用户确认后才真实生成。 +- 失败不能回落 mock 假成功。 + +任务 48:视频片段质检 + +- 检查像不像本人、动作、表情、年龄变化、混脸、多出第三人。 +- 支持 passed/needs_retry/manual_review/rejected。 + +任务 49:视频片段合成 + +- 使用 FFmpeg concat 合成多个片段。 +- 加旁白、字幕、BGM。 +- 支持片段替换后重新合成。 + +### 阶段 16:口型和高端纪念片 + +任务 50:LipSyncProvider + +- 增加 `lipsync_tasks`。 +- 绑定视频片段和音频资产。 +- 口型失败可回退旁白字幕版本。 + +任务 51:高端真人纪念片后台流程 + +- 人工报价。 +- 人工审核。 +- 多轮修改。 +- 片段精修和替换。 + +任务 52:V3 合规验收 + +- 未成年人保护。 +- 公开案例二次授权。 +- 后台访问原图审计。 +- 删除项目清理原图、锚点、关键帧、视频片段。 + +## 16. Codex 使用建议 + +每次只给 Codex 一个任务,附带: + +- 目标 +- 相关表 +- 相关接口 +- 相关文件路径 +- 验收标准 +- 注意事项 + +不要一次让 Codex “开发完整系统”。 diff --git a/docs/system_b/README.md b/docs/system_b/README.md new file mode 100755 index 0000000..837c326 --- /dev/null +++ b/docs/system_b/README.md @@ -0,0 +1,67 @@ +# 系统B:真人照片 → 多人生主题写真 / 韩漫 / 纪念视频生成系统 文档包 + +版本:V3 真人动态视频升级版 +整理日期:2026-06-02 + +## 使用说明 + +这套文档用于把系统 B 从产品想法推进到可开发、可测试、可部署的完整项目。建议先按顺序阅读: + +1. `00_系统B升级说明_真人动态视频.md`:说明为什么从 V2 升级到 V3。 +2. `01_系统B总需求文档_v3_真人动态视频版.md`:当前主需求基线,后续开发以此为准。 +3. `01_需求文档第一版本.md`:保留最初产品思路。 +4. `02_需求文档修改v2.md`:V2 基础版,作为写真图集和图片纪念视频链路参考。 +5. `03_功能清单_页面清单_状态流转设计.md`:前端、后台、状态机的产品实现依据。 +6. `04_技术架构设计_模块拆分.md`:系统整体技术方案。 +7. `05_数据库表结构设计.md`:MySQL 表结构草案。 +8. `06_API接口设计文档.md`:前后台接口规范。 +9. `07_uniapp用户端页面交互文档.md`:uni-app 页面流程。 +10. `08_GeekerAdmin后台管理设计.md`:后台管理端设计。 +11. `09_AI生成流水线_Provider抽象设计.md`:AI 核心流水线。 +12. `10_Prompt模板_世界观模板规范.md`:世界观、场景、Prompt 模板规范。 +13. `11_订单支付_额度_成本控制设计.md`:商业闭环和成本控制。 +14. `12_任务队列_错误重试_稳定性设计.md`:稳定性与故障恢复。 +15. `13_隐私授权_内容审核_合规设计.md`:真人照片、肖像权、隐私合规。 +16. `14_部署运维_日志监控_备份设计.md`:服务器部署和运维。 +17. `15_测试用例_验收标准.md`:测试与验收。 +18. `16_Codex开发任务拆解文档.md`:交给 Codex 的开发拆解。 + +当前系统 B 必须明确分成 4 档输出: + +```text +高清写真图集 +图片纪念视频 +动态写真视频 +AI 真人动态视频 +``` + +高端真人纪念片作为人工报价和多轮精修套餐处理。 + +## 当前推荐技术栈 + +- 用户端:uni-app +- 后台端:Geeker-Admin 二开 +- 后端:Node.js + NestJS +- 数据库:MySQL 8 +- 队列:Redis + BullMQ +- 存储:MinIO / 后续可切云 OSS +- 视频合成:FFmpeg +- AI 辅助 Worker:Python 可选 +- AI 接入:统一 Provider 抽象层,不把任何模型名写死到业务代码 + +## 重要原则 + +1. 第一阶段主打婚礼、恋爱纪念、银婚金婚、情侣写真、个人形象定制。 +2. 系统底层按“人生主题 + 世界观模板 + 场景模板 + 镜头模板”设计,不要写死婚礼。 +3. 所有 AI 模型通过 Provider 管理,支持最高质量模型和后续替换。 +4. AI 真人动态视频默认不自动开启真实 Provider,必须先成本预估、用户确认、后台启用和阈值保护。 +5. 真人照片必须有授权、隐私、删除、公开案例二次授权机制。 +6. 系统 B 比系统 A 更重视本人相似度、肖像权、未成年人保护、身份锚点和后台审计。 +7. 生成流程必须队列化、可重试、可恢复、可追踪成本。 + + +## 修正说明 + +本包已升级为 V3 真人动态视频版:V1/V2 保留为历史与基础链路参考,V3 用于指导“真人会动、有表情、有动作、像真人短剧/纪念电影”的新目标。 + +开发主依据:优先使用 `01_系统B总需求文档_v3_真人动态视频版.md`。`02_需求文档修改v2.md` 作为基础链路参考,`01_需求文档第一版本.md` 作为历史需求版本与对照参考。 diff --git a/docs/system_b/manifest.json b/docs/system_b/manifest.json new file mode 100755 index 0000000..f0adb27 --- /dev/null +++ b/docs/system_b/manifest.json @@ -0,0 +1,26 @@ +{ + "project": "系统B:真人照片 → 多人生主题写真 / 动态视频 / AI真人纪念片生成系统", + "version": "V3-real-video-docs-package", + "date": "2026-06-02", + "files": [ + "00_系统B升级说明_真人动态视频.md", + "01_系统B总需求文档_v3_真人动态视频版.md", + "01_需求文档第一版本.md", + "02_需求文档修改v2.md", + "03_功能清单_页面清单_状态流转设计.md", + "04_技术架构设计_模块拆分.md", + "05_数据库表结构设计.md", + "06_API接口设计文档.md", + "07_uniapp用户端页面交互文档.md", + "08_GeekerAdmin后台管理设计.md", + "09_AI生成流水线_Provider抽象设计.md", + "10_Prompt模板_世界观模板规范.md", + "11_订单支付_额度_成本控制设计.md", + "12_任务队列_错误重试_稳定性设计.md", + "13_隐私授权_内容审核_合规设计.md", + "14_部署运维_日志监控_备份设计.md", + "15_测试用例_验收标准.md", + "16_Codex开发任务拆解文档.md", + "README.md" + ] +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b230418 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,5537 @@ +{ + "name": "ai-manga-platform", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ai-manga-platform", + "version": "0.1.0", + "workspaces": [ + "backend", + "admin", + "user-app", + "workers" + ], + "devDependencies": { + "@types/node": "^20.0.0", + "@vitejs/plugin-vue": "^5.2.4", + "tsx": "^4.20.3", + "typescript": "^5.8.3", + "vite": "^5.4.19", + "vitest": "^2.1.9", + "vue-tsc": "^2.2.10" + } + }, + "admin": { + "version": "0.1.0", + "dependencies": { + "vue": "^3.5.16" + } + }, + "backend": { + "version": "0.1.0", + "dependencies": { + "@nestjs/common": "^10.4.20", + "@nestjs/core": "^10.4.20", + "@nestjs/jwt": "^11.0.2", + "@nestjs/platform-express": "^10.4.20", + "@prisma/client": "^6.19.3", + "bcryptjs": "^3.0.3", + "bullmq": "^5.77.6", + "ioredis": "^5.11.0", + "mammoth": "^1.12.0", + "minio": "^8.0.7", + "multer": "^2.1.1", + "pdf-parse": "^2.4.5", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2" + }, + "devDependencies": { + "@types/express": "^5.0.6", + "@types/multer": "^2.1.0", + "prisma": "^6.19.3" + } + }, + "backend/node_modules/@prisma/client": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz", + "integrity": "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "backend/node_modules/@prisma/config": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz", + "integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.21.0", + "empathic": "2.0.0" + } + }, + "backend/node_modules/@prisma/debug": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz", + "integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==", + "devOptional": true, + "license": "Apache-2.0" + }, + "backend/node_modules/@prisma/engines": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz", + "integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/fetch-engine": "6.19.3", + "@prisma/get-platform": "6.19.3" + } + }, + "backend/node_modules/@prisma/engines-version": { + "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", + "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", + "devOptional": true, + "license": "Apache-2.0" + }, + "backend/node_modules/@prisma/fetch-engine": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz", + "integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/get-platform": "6.19.3" + } + }, + "backend/node_modules/@prisma/get-platform": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz", + "integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3" + } + }, + "backend/node_modules/c12": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", + "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "backend/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "backend/node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "backend/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "devOptional": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "backend/node_modules/effect": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", + "integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "backend/node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, + "backend/node_modules/multer": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "backend/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "devOptional": true, + "license": "MIT" + }, + "backend/node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "devOptional": true, + "license": "MIT" + }, + "backend/node_modules/prisma": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz", + "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "6.19.3", + "@prisma/engines": "6.19.3" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "backend/node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, + "backend/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@ioredis/commands": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", + "integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@lukeed/csprng": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", + "integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz", + "integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==", + "license": "MIT", + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.80", + "@napi-rs/canvas-darwin-arm64": "0.1.80", + "@napi-rs/canvas-darwin-x64": "0.1.80", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.80", + "@napi-rs/canvas-linux-arm64-musl": "0.1.80", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.80", + "@napi-rs/canvas-linux-x64-gnu": "0.1.80", + "@napi-rs/canvas-linux-x64-musl": "0.1.80", + "@napi-rs/canvas-win32-x64-msvc": "0.1.80" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz", + "integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz", + "integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz", + "integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz", + "integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz", + "integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz", + "integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz", + "integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz", + "integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz", + "integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz", + "integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nestjs/common": { + "version": "10.4.22", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.22.tgz", + "integrity": "sha512-fxJ4v85nDHaqT1PmfNCQ37b/jcv2OojtXTaK1P2uAXhzLf9qq6WNUOFvxBrV4fhQek1EQoT1o9oj5xAZmv3NRw==", + "license": "MIT", + "dependencies": { + "file-type": "20.4.1", + "iterare": "1.2.1", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "class-transformer": "*", + "class-validator": "*", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/core": { + "version": "10.4.22", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.22.tgz", + "integrity": "sha512-6IX9+VwjiKtCjx+mXVPncpkQ5ZjKfmssOZPFexmT+6T9H9wZ3svpYACAo7+9e7Nr9DZSoRZw3pffkJP7Z0UjaA==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@nuxtjs/opencollective": "0.3.2", + "fast-safe-stringify": "2.1.1", + "iterare": "1.2.1", + "path-to-regexp": "3.3.0", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/microservices": "^10.0.0", + "@nestjs/platform-express": "^10.0.0", + "@nestjs/websockets": "^10.0.0", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/platform-express": { + "optional": true + }, + "@nestjs/websockets": { + "optional": true + } + } + }, + "node_modules/@nestjs/jwt": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.2.tgz", + "integrity": "sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "9.0.10", + "jsonwebtoken": "9.0.3" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0" + } + }, + "node_modules/@nestjs/platform-express": { + "version": "10.4.22", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.22.tgz", + "integrity": "sha512-ySSq7Py/DFozzZdNDH67m/vHoeVdphDniWBnl6q5QVoXldDdrZIHLXLRMPayTDh5A95nt7jjJzmD4qpTbNQ6tA==", + "license": "MIT", + "dependencies": { + "body-parser": "1.20.4", + "cors": "2.8.5", + "express": "4.22.1", + "multer": "2.0.2", + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0" + } + }, + "node_modules/@nodable/entities": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.1.tgz", + "integrity": "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@nuxtjs/opencollective": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@nuxtjs/opencollective/-/opencollective-0.3.2.tgz", + "integrity": "sha512-um0xL3fO7Mf4fDxcqx9KryrB7zgRM5JSlvGN5AGkP6JLM5XEKyjeAiPbNxdXVXQ16isuAhYpvP88NgL2BGd6aA==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "consola": "^2.15.0", + "node-fetch": "^2.6.1" + }, + "bin": { + "opencollective": "bin/opencollective.js" + }, + "engines": { + "node": ">=8.0.0", + "npm": ">=5.0.0" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tokenizer/inflate": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", + "integrity": "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "fflate": "^0.8.2", + "token-types": "^6.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/multer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz", + "integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/node": { + "version": "20.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.35.tgz", + "integrity": "sha512-BUmHaR1J+O+CKZ9uJucdVTEr1LHsdyvv7vG3eNRhK3CczEHeMd/LtsHAuD7PbrxvI2envCY2v7HI1vC1aBRzKw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/shared": "3.5.35", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-core/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.35.tgz", + "integrity": "sha512-k+bprkXxuqhVajgTx5mUHuir7TwQzUKOWR40ng1ncAqQRPnrLngGGgqVEEhOnTMlc8btHYVKmrP8s5Qyg0hvYA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.35", + "@vue/shared": "3.5.35" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.35.tgz", + "integrity": "sha512-G5VPMcXTSywXBgtFOZOnHKBxKSrwXUcvY1iaF5/hRcy7t0J6CH/d8ha9F4nzi00Fax1eLV0QHM7v4mQu68jydw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/compiler-core": "3.5.35", + "@vue/compiler-dom": "3.5.35", + "@vue/compiler-ssr": "3.5.35", + "@vue/shared": "3.5.35", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.15", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-sfc/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.35.tgz", + "integrity": "sha512-rGhAeXgdM7/ffTJGXT69rCCdTmjDewnFuUZfBQQHTdcEBeWdT5HCGY60y2ytLJr9/Dsu7IntUi5z/w0h6Rjnzw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.35", + "@vue/shared": "3.5.35" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.35.tgz", + "integrity": "sha512-tVc+SsHConvh/Lz64qq1pP3rYArBmK42xonovEcxY74SQtvctZodG/zhq54P5dr38cVuw25d27cPNRdlMidpGQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.35" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.35.tgz", + "integrity": "sha512-A/xFNX9loIcWDygeQuNCfKuh0CoYBzxhqEMNah5TSFg9Z53DrFYEN2qi5CU9necjM1OWYegYREUTHmXTmhfXtg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.35", + "@vue/shared": "3.5.35" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.35.tgz", + "integrity": "sha512-odrJ1C391dbGnyDRh8U+rnP7J2amIEzfmRk5vXy7xi3aZhEXofTvpi0T4HJb6jlNqQZTNPR5MPHSB3RHNkIORA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.35", + "@vue/runtime-core": "3.5.35", + "@vue/shared": "3.5.35", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.35.tgz", + "integrity": "sha512-NkebSOYdB97wi8OQcO3HqzZSlymJi/aWsN/7h74OSVhRTm6qGs3Jp3e0rCXynmWwSlKeRrnlIug+ilYoHBmQDA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.35", + "@vue/shared": "3.5.35" + }, + "peerDependencies": { + "vue": "3.5.35" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.35.tgz", + "integrity": "sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA==", + "license": "MIT" + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/admin": { + "resolved": "admin", + "link": true + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/backend": { + "resolved": "backend", + "link": true + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bcryptjs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz", + "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, + "node_modules/block-stream2": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/block-stream2/-/block-stream2-2.1.0.tgz", + "integrity": "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==", + "license": "MIT", + "dependencies": { + "readable-stream": "^3.4.0" + } + }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/browser-or-node": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/browser-or-node/-/browser-or-node-2.1.1.tgz", + "integrity": "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==", + "license": "MIT" + }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/bullmq": { + "version": "5.77.6", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.77.6.tgz", + "integrity": "sha512-WCpSoCD4vWyRD+btOsFrO7iBGInrTgG155gTZCV8qY0Yex2KtsbVtFERx6V1WZ2xWl/5ZxnLar8Z8ufnS4f5jg==", + "license": "MIT", + "dependencies": { + "cron-parser": "4.9.0", + "ioredis": "5.10.1", + "msgpackr": "2.0.1", + "node-abort-controller": "3.1.1", + "semver": "7.8.0", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=12.22.0" + }, + "peerDependencies": { + "redis": ">=5.0.0" + }, + "peerDependenciesMeta": { + "redis": { + "optional": true + } + } + }, + "node_modules/bullmq/node_modules/@ioredis/commands": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", + "license": "MIT" + }, + "node_modules/bullmq/node_modules/ioredis": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", + "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/bullmq/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, + "node_modules/citty/node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", + "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "2.15.3", + "resolved": "https://registry.npmjs.org/consola/-/consola-2.15.3.tgz", + "integrity": "sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==", + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-uri-component": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dingbat-to-unicode": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz", + "integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==", + "license": "BSD-2-Clause" + }, + "node_modules/duck": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz", + "integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==", + "license": "BSD", + "dependencies": { + "underscore": "^1.13.1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/express/node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "license": "MIT" + }, + "node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.8.0.tgz", + "integrity": "sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.2.0", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.3.0", + "xml-naming": "^0.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, + "node_modules/file-type": { + "version": "20.4.1", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-20.4.1.tgz", + "integrity": "sha512-hw9gNZXUfZ02Jo0uafWLaFVPter5/k2rfcrjFJJHX/77xtSDOfJuEFb6oKlFV86FLP1SuyHMW1PSk0U9M5tKkQ==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.2.6", + "strtok3": "^10.2.0", + "token-types": "^6.0.0", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/filter-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", + "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ioredis": { + "version": "5.11.0", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.0.tgz", + "integrity": "sha512-EZBErytyVovD8f6pDfG3Kb37N6Y3lmDA9NNj+4+IP13CzzHGeX+OyeRM2Um13khRzoBSzzL+5lVnCX8V2RLeMg==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.10.0", + "cluster-key-slot": "1.1.1", + "debug": "4.4.3", + "denque": "2.1.0", + "redis-errors": "1.2.0", + "redis-parser": "3.0.0", + "standard-as-callback": "2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/iterare": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iterare/-/iterare-1.2.1.tgz", + "integrity": "sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==", + "license": "ISC", + "engines": { + "node": ">=6" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/lop": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz", + "integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==", + "license": "BSD-2-Clause", + "dependencies": { + "duck": "^0.1.12", + "option": "~0.2.1", + "underscore": "^1.13.1" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mammoth": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.0.tgz", + "integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==", + "license": "BSD-2-Clause", + "dependencies": { + "@xmldom/xmldom": "^0.8.6", + "argparse": "~1.0.3", + "base64-js": "^1.5.1", + "bluebird": "~3.4.0", + "dingbat-to-unicode": "^1.0.1", + "jszip": "^3.7.1", + "lop": "^0.4.2", + "path-is-absolute": "^1.0.0", + "underscore": "^1.13.1", + "xmlbuilder": "^10.0.0" + }, + "bin": { + "mammoth": "bin/mammoth" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/mammoth/node_modules/xmlbuilder": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", + "integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minio": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/minio/-/minio-8.0.7.tgz", + "integrity": "sha512-E737MgufW8CeQAsTAtnEMrxZ9scMSf29kkhZoXzDTKj/Jszzo2SfeZUH9wbDQH2Rsq6TCtl/yQL0+XdVKZansQ==", + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.4", + "block-stream2": "^2.1.0", + "browser-or-node": "^2.1.1", + "buffer-crc32": "^1.0.0", + "eventemitter3": "^5.0.1", + "fast-xml-parser": "^5.3.4", + "ipaddr.js": "^2.0.1", + "lodash": "^4.17.21", + "mime-types": "^2.1.35", + "query-string": "^7.1.3", + "stream-json": "^1.8.0", + "through2": "^4.0.2", + "xml2js": "^0.5.0 || ^0.6.2" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, + "node_modules/minio/node_modules/ipaddr.js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", + "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/msgpackr": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.1.tgz", + "integrity": "sha512-9J+tqTEsbHqY8YohazYgty7LgerFIWxvMLpUjqETSmjHojtJm2WnX2kK/2a1fLI7CO7ERP1YSEUXMucz4j+yBA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", + "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", + "object-assign": "^4.1.1", + "type-is": "^1.6.18", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/nypm": { + "version": "0.6.6", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.6.tgz", + "integrity": "sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.2.2", + "pathe": "^2.0.3", + "tinyexec": "^1.1.1" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/nypm/node_modules/citty": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", + "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/nypm/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/nypm/node_modules/tinyexec": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.3.tgz", + "integrity": "sha512-g62dB+w1/OEFnPvmX0yd/HnetYITOL+1nJW7kitOycOeAvmbWC/nu0fwmmQ/kupNojqExzyC/T++pST/jRJ2mQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/option": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz", + "integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==", + "license": "BSD-2-Clause" + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-to-regexp": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", + "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==", + "license": "MIT" + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/pdf-parse": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz", + "integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==", + "license": "Apache-2.0", + "dependencies": { + "@napi-rs/canvas": "0.1.80", + "pdfjs-dist": "5.4.296" + }, + "bin": { + "pdf-parse": "bin/cli.mjs" + }, + "engines": { + "node": ">=20.16.0 <21 || >=22.3.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/mehmet-kozan" + } + }, + "node_modules/pdfjs-dist": { + "version": "5.4.296", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz", + "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.16.0 || >=22.3.0" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.80" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, + "node_modules/pkg-types/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/query-string": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", + "integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==", + "license": "MIT", + "dependencies": { + "decode-uri-component": "^0.2.2", + "filter-obj": "^1.1.0", + "split-on-first": "^1.0.0", + "strict-uri-encode": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split-on-first": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", + "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/stream-chain": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz", + "integrity": "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==", + "license": "BSD-3-Clause" + }, + "node_modules/stream-json": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/stream-json/-/stream-json-1.9.1.tgz", + "integrity": "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==", + "license": "BSD-3-Clause", + "dependencies": { + "stream-chain": "^2.2.5" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/strict-uri-encode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", + "integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strnum": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/strtok3": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/through2": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "license": "MIT", + "dependencies": { + "readable-stream": "3" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", + "integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uid": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/uid/-/uid-2.0.2.tgz", + "integrity": "sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==", + "license": "MIT", + "dependencies": { + "@lukeed/csprng": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/user-app": { + "resolved": "user-app", + "link": true + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.35", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.35.tgz", + "integrity": "sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.35", + "@vue/compiler-sfc": "3.5.35", + "@vue/runtime-dom": "3.5.35", + "@vue/server-renderer": "3.5.35", + "@vue/shared": "3.5.35" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/workers": { + "resolved": "workers", + "link": true + }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "user-app": { + "version": "0.1.0", + "dependencies": { + "vue": "^3.5.16" + } + }, + "workers": { + "version": "0.1.0", + "dependencies": { + "bullmq": "^5.77.6", + "ioredis": "^5.11.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6b2e476 --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "name": "ai-manga-platform", + "version": "0.1.0", + "private": true, + "workspaces": [ + "backend", + "admin", + "user-app", + "workers" + ], + "scripts": { + "dev:backend": "npm run start:dev -w backend", + "dev:admin": "npm run dev -w admin", + "dev:user": "npm run dev:h5 -w user-app", + "dev:workers": "npm run start:dev -w workers", + "db:generate": "npm run prisma:generate -w backend", + "db:validate": "npm run prisma:validate -w backend", + "db:migrate": "npm run prisma:migrate -w backend", + "db:deploy": "npm run prisma:deploy -w backend", + "db:seed": "npm run prisma:seed -w backend", + "live-action:acceptance": "npm run live-action:acceptance -w backend", + "build": "npm --workspaces --if-present run build", + "lint": "npm --workspaces --if-present run lint", + "typecheck": "npm --workspaces --if-present run typecheck", + "test": "npm --workspaces --if-present run test" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@vitejs/plugin-vue": "^5.2.4", + "tsx": "^4.20.3", + "typescript": "^5.8.3", + "vite": "^5.4.19", + "vitest": "^2.1.9", + "vue-tsc": "^2.2.10" + } +} diff --git a/storage/.gitkeep b/storage/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/storage/.gitkeep @@ -0,0 +1 @@ + diff --git a/tools/frontend-business-e2e.mjs b/tools/frontend-business-e2e.mjs new file mode 100644 index 0000000..1d4b152 --- /dev/null +++ b/tools/frontend-business-e2e.mjs @@ -0,0 +1,593 @@ +import { createRequire } from 'node:module'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +const require = createRequire(import.meta.url); +const { chromium } = require('playwright'); + +const outputDir = resolve(process.env.FRONTEND_E2E_DIR || 'storage/private/frontend-business-e2e'); +const userUrl = process.env.USER_APP_URL || 'http://127.0.0.1:5174/'; +const adminUrl = process.env.ADMIN_URL || 'http://127.0.0.1:5175/'; +const apiBase = process.env.API_BASE_URL || 'http://127.0.0.1:3000/api'; +const userEmail = process.env.FRONTEND_E2E_USER_EMAIL || 'business-e2e@example.com'; +const userPassword = process.env.FRONTEND_E2E_USER_PASSWORD || 'Business123!'; +const adminEmail = process.env.FRONTEND_E2E_ADMIN_EMAIL || 'admin@example.com'; +const adminPassword = process.env.FRONTEND_E2E_ADMIN_PASSWORD || 'Admin123!'; +const runId = process.env.FRONTEND_E2E_RUN_ID || new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14); +const reportPath = `${outputDir}/report-${runId}.json`; +const markdownPath = `${outputDir}/report-${runId}.md`; +const latestReportPath = `${outputDir}/latest-report.json`; +const latestMarkdownPath = `${outputDir}/latest-report.md`; + +const report = { + run_id: runId, + generated_at: new Date().toISOString(), + user_url: userUrl, + admin_url: adminUrl, + api_base: apiBase, + project_id: null, + episode_id: null, + video_asset_id: null, + status: 'running', + steps: [], + failures: [], + artifacts: { + report_json: reportPath, + report_markdown: markdownPath, + screenshots: [] + }, + summary: {} +}; + +let browser; +let userPage; +let adminPage; +let userAuth; +let adminAuth; +let project; +let episode; +let videoAsset; +let activeStep = null; + +try { + await mkdir(outputDir, { recursive: true }); + userAuth = await loginOrRegister(userEmail, userPassword, '业务E2E用户'); + adminAuth = await login(adminEmail, adminPassword); + + browser = await chromium.launch({ headless: true }); + const userContext = await browser.newContext({ viewport: { width: 1440, height: 1000 } }); + const adminContext = await browser.newContext({ viewport: { width: 1440, height: 1000 } }); + + await userContext.addInitScript(({ token }) => { + localStorage.setItem('ai_manga_user_token', token); + }, { token: userAuth.access_token }); + await adminContext.addInitScript(({ token, user, email }) => { + localStorage.setItem('admin_token', token); + localStorage.setItem('admin_user', JSON.stringify(user)); + localStorage.setItem('admin_email', email); + }, { token: adminAuth.access_token, user: adminAuth.user, email: adminEmail }); + + userPage = await userContext.newPage(); + adminPage = await adminContext.newPage(); + wirePageDiagnostics(userPage, 'user'); + wirePageDiagnostics(adminPage, 'admin'); + + await runStep('user.open', async () => { + await userPage.goto(userUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }); + await userPage.waitForTimeout(1000); + await screenshot(userPage, '01-user-open'); + }); + + await runStep('user.create_project_ui', async () => { + const title = `业务E2E上传小说闭环-${runId}`; + await clickButton(userPage, '新建'); + await fillByLabel(userPage, '项目名', title); + await selectByLabel(userPage, '类型', 'upload'); + await selectByLabel(userPage, '生成类型', 'image_manga'); + await fillByLabel(userPage, '题材', '都市逆袭业务验收'); + await fillByLabel(userPage, '目标集数', '1'); + await fillByLabel(userPage, '单集秒数', '20'); + await clickButton(userPage, '创建项目'); + project = await waitForProjectByTitle(title); + report.project_id = project.id; + await userPage.evaluate((projectId) => { + localStorage.setItem('ai_manga_selected_project_id', projectId); + }, project.id); + await userPage.waitForTimeout(1000); + await screenshot(userPage, '02-user-project-created'); + return { project_id: project.id, title: project.title }; + }); + + await runStep('api.prepare_quota_mock_pay', async () => { + const packages = await api('/billing/packages'); + const selectedPackage = packages.packages.find((item) => item.code === 'standard_3ep') ?? packages.packages[0]; + if (!selectedPackage) throw new Error('No billing package is available'); + const order = await api('/billing/orders', { + method: 'POST', + token: userAuth.access_token, + body: { + package_code: selectedPackage.code, + project_id: project.id, + payment_method: 'mock_pay' + } + }); + const paid = await api(`/billing/orders/${order.order.id}/mock-pay`, { + method: 'POST', + token: userAuth.access_token, + body: {} + }); + return { + package_code: selectedPackage.code, + quota_amount: selectedPackage.quota_amount, + available_quota: paid.account.available_quota + }; + }); + + await runStep('user.paste_confirm_parse_ui', async () => { + await ensureStudio(userPage); + await fillByLabel(userPage, '书名', `测试小说-${runId}`); + await fillByLabel(userPage, '作者', 'Codex E2E'); + await fillByLabel(userPage, '粘贴文本', sampleNovelText(runId)); + await clickButton(userPage, '保存粘贴文本'); + await waitForActionFinished(userPage); + await clickInPanel(userPage, '版权确认', '确认'); + await waitForActionFinished(userPage); + await clickButton(userPage, '解析小说'); + await waitForActionFinished(userPage, 30000); + const parsed = await api(`/projects/${project.id}/novel/parse-result`, { token: userAuth.access_token }); + if (!parsed.chapters?.length) throw new Error('Novel parse produced no chapters'); + await screenshot(userPage, '03-user-novel-parsed'); + return { chapter_count: parsed.chapters.length, source_id: parsed.source?.id ?? null }; + }); + + await runStep('user.story_bible_ui', async () => { + await clickInPanel(userPage, '故事圣经', '生成'); + await waitForActionFinished(userPage, 30000); + await clickInPanel(userPage, '故事圣经', '确认'); + await waitForActionFinished(userPage, 30000); + const story = await api(`/projects/${project.id}/story-bible`, { token: userAuth.access_token }); + if (story.story_bible?.status !== 'confirmed') throw new Error('Story bible was not confirmed'); + await screenshot(userPage, '04-user-story-bible'); + return { story_bible_id: story.story_bible.id, status: story.story_bible.status }; + }); + + await runStep('user.characters_ui', async () => { + await clickInPanel(userPage, '角色库', '抽取'); + await waitForActionFinished(userPage, 30000); + await clickInPanel(userPage, '角色库', '确认'); + await waitForActionFinished(userPage, 30000); + const characters = await api(`/projects/${project.id}/characters`, { token: userAuth.access_token }); + if (!characters.length) throw new Error('Character extraction produced no characters'); + await screenshot(userPage, '05-user-characters'); + return { character_count: characters.length, confirmed_count: characters.filter((item) => item.status === 'locked').length }; + }); + + await runStep('user.memory_episodes_script_storyboard_ui', async () => { + await clickInPanel(userPage, '长篇记忆', '生成'); + await waitForActionFinished(userPage, 30000); + await clickInPanel(userPage, '分集计划', '生成'); + await waitForActionFinished(userPage, 30000); + await clickInPanel(userPage, '分集计划', '确认'); + await waitForActionFinished(userPage, 30000); + const episodes = await api(`/projects/${project.id}/episodes`, { token: userAuth.access_token }); + episode = episodes[0]; + if (!episode) throw new Error('Episode generation produced no episode'); + report.episode_id = episode.id; + await clickInPanel(userPage, '脚本和分镜', '脚本'); + await waitForActionFinished(userPage, 30000); + await clickInPanel(userPage, '脚本和分镜', '确认脚本'); + await waitForActionFinished(userPage, 30000); + await clickInPanel(userPage, '脚本和分镜', '分镜'); + await waitForActionFinished(userPage, 30000); + await clickInPanel(userPage, '脚本和分镜', '确认分镜'); + await waitForActionFinished(userPage, 30000); + const shots = await api(`/episodes/${episode.id}/storyboard`, { token: userAuth.access_token }); + if (!shots.length) throw new Error('Storyboard generation produced no shots'); + await screenshot(userPage, '06-user-storyboard'); + return { episode_id: episode.id, shot_count: shots.length }; + }); + + await runStep('user.generate_media_render_review_preview_ui', async () => { + await clickInPanel(userPage, '图片、音频和视频', '分镜图'); + await waitForActionFinished(userPage, 60000); + await clickInPanel(userPage, '图片、音频和视频', '多角色音频'); + await waitForActionFinished(userPage, 60000, [/TTS 超出分配时长/]); + await clickInPanel(userPage, '图片、音频和视频', '合成'); + await waitForActionFinished(userPage, 90000, [/TTS 超出分配时长/]); + const mediaRows = await api(`/episodes/${episode.id}/media-assets`, { token: userAuth.access_token }); + const videoRow = mediaRows.find((row) => row.task_type === 'video_render' && row.asset?.asset_type === 'video'); + if (!videoRow?.asset?.id) throw new Error('Video render did not produce a video asset'); + videoAsset = videoRow.asset; + report.video_asset_id = videoAsset.id; + const autoPreviewVisible = await userPage.locator('.asset-preview-modal').isVisible().catch(() => false); + if (autoPreviewVisible) { + await screenshot(userPage, '07-user-render-auto-preview'); + await closePreviewIfOpen(userPage); + } + await clickButton(userPage, '审核'); + await clickButton(userPage, '文本审核'); + await waitForActionFinished(userPage, 30000); + await clickButton(userPage, '视频审核'); + await waitForActionFinished(userPage, 30000); + await clickButton(userPage, '成品'); + await userPage.getByRole('button', { name: '预览', exact: true }).first().click({ timeout: 10000 }); + await userPage.waitForTimeout(1500); + const previewVisible = await userPage.locator('.asset-preview-modal').isVisible().catch(() => false); + if (!previewVisible) throw new Error('Asset preview modal did not open'); + await screenshot(userPage, '07-user-result-preview'); + const reviews = await api(`/projects/${project.id}/reviews?limit=50`, { token: userAuth.access_token }); + return { + video_asset_id: videoAsset.id, + media_count: mediaRows.length, + review_count: reviews.reviews.length + }; + }); + + await runStep('admin.tasks_audit_ui', async () => { + await adminPage.goto(adminUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }); + await adminPage.waitForTimeout(1000); + await clickButton(adminPage, '任务管理'); + await adminPage.waitForTimeout(1000); + await screenshot(adminPage, '08-admin-tasks'); + await clickButton(adminPage, '内容审核'); + await adminPage.waitForTimeout(1000); + await screenshot(adminPage, '09-admin-reviews'); + await clickButton(adminPage, '审计日志'); + await adminPage.waitForTimeout(1000); + await screenshot(adminPage, '10-admin-audit'); + + const tasks = await api(`/projects/${project.id}/tasks?limit=100`, { token: userAuth.access_token }); + const adminTasks = await api(`/admin/tasks?project_id=${encodeURIComponent(project.id)}&limit=100`, { token: adminAuth.access_token }); + const adminReviews = await api(`/admin/content-reviews?project_id=${encodeURIComponent(project.id)}&limit=100`, { + token: adminAuth.access_token + }); + const audit = await api(`/admin/operation-logs?target_type=project&target_id=${encodeURIComponent(project.id)}&limit=100`, { + token: adminAuth.access_token + }); + if (!tasks.tasks.length) throw new Error('Project task list is empty'); + if (!adminTasks.tasks.length) throw new Error('Admin task list is empty for project'); + if (!adminReviews.reviews?.length) throw new Error('Admin content review list is empty for project'); + if (!audit.logs?.length) { + const step = report.steps.at(-1); + step?.warnings.push({ + type: 'operation_log', + message: 'No project-scoped operation_logs were written for normal user generation actions.' + }); + } + return { + user_task_count: tasks.tasks.length, + admin_task_count: adminTasks.tasks.length, + admin_review_count: adminReviews.reviews.length, + operation_log_count: audit.logs?.length ?? 0, + failed_tasks: tasks.tasks.filter((task) => task.status === 'failed').length + }; + }); + + report.status = report.failures.length ? 'failed' : 'passed'; +} catch (error) { + report.status = 'failed'; + const normalized = normalizeError(error); + const duplicated = report.failures.some((item) => + item.step === currentStepName() && item.message === normalized.message + ); + if (!duplicated) { + report.failures.push({ + step: currentStepName(), + message: normalized.message, + stack: normalized.stack + }); + } + if (userPage) { + await screenshot(userPage, 'failure-user').catch(() => {}); + } + if (adminPage) { + await screenshot(adminPage, 'failure-admin').catch(() => {}); + } + process.exitCode = 1; +} finally { + report.summary = summarizeReport(); + await writeReports(); + if (browser) await browser.close(); + console.log(reportPath); + console.log(markdownPath); + console.log(JSON.stringify(report.summary)); +} + +async function runStep(name, fn) { + activeStep = name; + const startedAt = new Date(); + const step = { + name, + status: 'running', + started_at: startedAt.toISOString(), + finished_at: null, + duration_ms: null, + result: null, + errors: [], + warnings: [], + screenshots: [] + }; + report.steps.push(step); + console.log(`e2e ${name}`); + + try { + const result = await fn(); + step.status = 'passed'; + step.result = result ?? null; + } catch (error) { + const normalized = normalizeError(error); + step.status = 'failed'; + step.errors.push({ message: normalized.message, stack: normalized.stack }); + report.failures.push({ step: name, message: normalized.message, stack: normalized.stack }); + throw error; + } finally { + step.finished_at = new Date().toISOString(); + step.duration_ms = new Date(step.finished_at).getTime() - startedAt.getTime(); + activeStep = null; + } +} + +function currentStepName() { + return activeStep ?? report.steps.at(-1)?.name ?? 'unknown'; +} + +function wirePageDiagnostics(page, target) { + page.on('console', (message) => { + if (message.type() !== 'error') return; + const step = report.steps.at(-1); + const error = { target, type: 'console', message: message.text() }; + if (step) step.errors.push(error); + report.failures.push({ step: currentStepName(), ...error }); + }); + page.on('pageerror', (error) => { + const step = report.steps.at(-1); + const item = { target, type: 'pageerror', message: error.message }; + if (step) step.errors.push(item); + report.failures.push({ step: currentStepName(), ...item }); + }); +} + +async function screenshot(page, name) { + const file = `${outputDir}/${runId}-${name}.png`; + await page.screenshot({ path: file, fullPage: false, timeout: 10000 }); + report.artifacts.screenshots.push(file); + const step = report.steps.at(-1); + if (step) step.screenshots.push(file); + return file; +} + +async function ensureStudio(page) { + await clickButton(page, '制作'); + await page.waitForTimeout(600); +} + +async function closePreviewIfOpen(page) { + const modal = page.locator('.asset-preview-modal').first(); + const visible = await modal.isVisible().catch(() => false); + if (!visible) return; + await modal.getByRole('button', { name: '关闭', exact: true }).click({ timeout: 10000 }); + await page.waitForTimeout(500); +} + +async function clickButton(page, name) { + const button = page.getByRole('button', { name, exact: true }).first(); + await button.waitFor({ state: 'visible', timeout: 15000 }); + await button.click({ timeout: 15000 }); +} + +async function clickInPanel(page, heading, buttonName) { + const panel = page.locator('section.panel, div.panel').filter({ has: page.getByRole('heading', { name: heading, exact: true }) }).first(); + await panel.waitFor({ state: 'visible', timeout: 15000 }); + await panel.scrollIntoViewIfNeeded(); + const button = panel.getByRole('button', { name: buttonName, exact: true }).first(); + await button.waitFor({ state: 'visible', timeout: 15000 }); + await button.click({ timeout: 15000 }); +} + +async function fillByLabel(page, label, value) { + const field = formFieldByLabel(page, label); + await field.waitFor({ state: 'visible', timeout: 15000 }); + await field.fill(String(value)); +} + +async function selectByLabel(page, label, value) { + const field = formFieldByLabel(page, label); + await field.waitFor({ state: 'visible', timeout: 15000 }); + await field.selectOption(value); +} + +function formFieldByLabel(page, label) { + return page + .locator('label') + .filter({ has: page.locator('span', { hasText: new RegExp(`^${escapeRegExp(label)}$`) }) }) + .locator('input, select, textarea') + .first(); +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +async function waitForActionFinished(page, timeout = 45000, allowedDangerPatterns = []) { + await page.waitForFunction(() => { + const text = document.body.innerText || ''; + return !text.includes('创建中') && + !text.includes('正在生成') && + !text.includes('正在合成') && + !text.includes('正在解析') && + !text.includes('已等待'); + }, { timeout }); + await page.waitForTimeout(800); + const errors = await page.locator('.notice.danger').allTextContents().catch(() => []); + const visibleErrors = errors.map((item) => item.trim()).filter(Boolean); + if (visibleErrors.length) { + const blockingErrors = visibleErrors.filter((message) => + !allowedDangerPatterns.some((pattern) => pattern.test(message)) + ); + const allowedWarnings = visibleErrors.filter((message) => + allowedDangerPatterns.some((pattern) => pattern.test(message)) + ); + const step = report.steps.at(-1); + if (step && allowedWarnings.length) { + step.warnings.push(...allowedWarnings.map((message) => ({ type: 'ui_notice', message }))); + } + if (blockingErrors.length) { + throw new Error(`UI error notice: ${blockingErrors.join(' / ')}`); + } + } +} + +async function waitForProjectByTitle(title) { + const timeoutAt = Date.now() + 20000; + + while (Date.now() < timeoutAt) { + const rows = await api('/projects', { token: userAuth.access_token }); + const found = rows.find((item) => item.title === title); + if (found) return found; + await delay(800); + } + + throw new Error(`Project was not created: ${title}`); +} + +async function loginOrRegister(email, password, nickname) { + try { + return await login(email, password); + } catch { + return api('/auth/register', { + method: 'POST', + body: { email, password, nickname } + }); + } +} + +async function login(email, password) { + return api('/auth/login', { + method: 'POST', + body: { email, password } + }); +} + +async function api(path, options = {}) { + const response = await fetch(`${apiBase}${path}`, { + method: options.method || 'GET', + headers: { + 'content-type': 'application/json', + ...(options.token ? { authorization: `Bearer ${options.token}` } : {}) + }, + body: options.body ? JSON.stringify(options.body) : undefined + }); + const payload = await response.json().catch(() => null); + + if (!response.ok || !payload || payload.code !== 0) { + throw new Error(payload?.message || `API ${path} failed: HTTP ${response.status}`); + } + + return payload.data; +} + +function sampleNovelText(id) { + const paragraphs = [ + `第1章 重回低谷 ${id}`, + '林澈被合伙人当众抢走项目,所有人都以为他会低头认输。', + '他没有争辩,只是收起旧电脑,回到那间漏雨的出租屋。', + '半夜,母亲的病危电话打来,林澈终于决定启用自己封存三年的算法系统。', + '第二天,城市最大的短剧平台突然崩溃,只有林澈留下的备份模型能恢复数据。', + '昔日看不起他的投资人排队等在楼下,合伙人也带着合同跪求合作。', + '林澈只说了一句话:这一次,规则由我来写。', + '第2章 第一场反击', + '女主沈知夏发现林澈真正的能力,主动提出帮他重新搭建团队。', + '两人在废弃会议室里用一台旧服务器,完成了足以改变行业的 Demo。', + '当晚发布会上,反派准备再次羞辱林澈,却被大屏幕上的实时数据彻底打脸。', + '所有镜头都对准林澈,他终于从阴影里走到光下。' + ]; + + return paragraphs.join('\n\n'); +} + +function normalizeError(error) { + return error instanceof Error ? error : new Error(String(error)); +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function summarizeReport() { + const failedSteps = report.steps.filter((step) => step.status === 'failed'); + const passedSteps = report.steps.filter((step) => step.status === 'passed'); + + return { + status: report.status, + total_steps: report.steps.length, + passed_steps: passedSteps.length, + failed_steps: failedSteps.length, + failure_count: report.failures.length, + project_id: report.project_id, + episode_id: report.episode_id, + video_asset_id: report.video_asset_id, + screenshot_count: report.artifacts.screenshots.length + }; +} + +async function writeReports() { + const markdown = createMarkdownReport(); + await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`); + await writeFile(markdownPath, markdown); + await writeFile(latestReportPath, `${JSON.stringify(report, null, 2)}\n`); + await writeFile(latestMarkdownPath, markdown); +} + +function createMarkdownReport() { + const lines = [ + `# Frontend Business E2E Report`, + '', + `- Run ID: ${report.run_id}`, + `- Status: ${report.status}`, + `- Project ID: ${report.project_id ?? '-'}`, + `- Episode ID: ${report.episode_id ?? '-'}`, + `- Video Asset ID: ${report.video_asset_id ?? '-'}`, + `- Generated At: ${report.generated_at}`, + '', + '## Summary', + '', + '```json', + JSON.stringify(report.summary, null, 2), + '```', + '', + '## Steps', + '' + ]; + + for (const step of report.steps) { + lines.push(`### ${step.status === 'passed' ? 'PASS' : 'FAIL'} ${step.name}`); + lines.push(''); + lines.push(`- Duration: ${step.duration_ms ?? '-'} ms`); + if (step.result) { + lines.push('- Result:'); + lines.push('```json'); + lines.push(JSON.stringify(step.result, null, 2)); + lines.push('```'); + } + if (step.errors.length) { + lines.push('- Errors:'); + lines.push('```json'); + lines.push(JSON.stringify(step.errors, null, 2)); + lines.push('```'); + } + if (step.screenshots.length) { + lines.push(`- Screenshots: ${step.screenshots.join(', ')}`); + } + lines.push(''); + } + + if (report.failures.length) { + lines.push('## Failures'); + lines.push(''); + lines.push('```json'); + lines.push(JSON.stringify(report.failures, null, 2)); + lines.push('```'); + } + + return `${lines.join('\n')}\n`; +} diff --git a/tools/frontend-visual-audit.mjs b/tools/frontend-visual-audit.mjs new file mode 100644 index 0000000..ee4ca1e --- /dev/null +++ b/tools/frontend-visual-audit.mjs @@ -0,0 +1,356 @@ +import { createRequire } from 'node:module'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +const require = createRequire(import.meta.url); +const { chromium } = require('playwright'); + +const targets = [ + { name: 'user-pc', url: process.env.USER_APP_URL || 'http://127.0.0.1:5174/', width: 1440, height: 1000 }, + { name: 'user-h5', url: process.env.USER_APP_URL || 'http://127.0.0.1:5174/', width: 390, height: 844, isMobile: true }, + { name: 'admin-pc', url: process.env.ADMIN_URL || 'http://127.0.0.1:5175/', width: 1440, height: 1000 }, + { name: 'admin-h5', url: process.env.ADMIN_URL || 'http://127.0.0.1:5175/', width: 390, height: 844, isMobile: true } +]; + +const outputDir = resolve(process.env.FRONTEND_AUDIT_DIR || 'storage/private/frontend-visual-audit'); +const apiBase = process.env.API_BASE_URL || 'http://127.0.0.1:3000/api'; +const userEmail = process.env.FRONTEND_AUDIT_USER_EMAIL || 'visual-audit@example.com'; +const userPassword = process.env.FRONTEND_AUDIT_USER_PASSWORD || 'Audit123!'; +const adminEmail = process.env.FRONTEND_AUDIT_ADMIN_EMAIL || 'admin@example.com'; +const adminPassword = process.env.FRONTEND_AUDIT_ADMIN_PASSWORD || 'Admin123!'; + +const browser = await chromium.launch({ headless: true }); +const report = []; + +try { + await mkdir(outputDir, { recursive: true }); + const userAuth = await loginOrRegister(userEmail, userPassword, '视觉验收'); + const adminAuth = await login(adminEmail, adminPassword); + const project = await ensureLiveActionProject(userAuth.access_token); + + for (const target of targets) { + const context = await browser.newContext({ + viewport: { width: target.width, height: target.height }, + isMobile: Boolean(target.isMobile), + deviceScaleFactor: target.isMobile ? 2 : 1 + }); + + if (target.name.startsWith('user')) { + await context.addInitScript(({ token, projectId }) => { + localStorage.setItem('ai_manga_user_token', token); + localStorage.setItem('ai_manga_selected_project_id', projectId); + }, { token: userAuth.access_token, projectId: project.id }); + await auditInteractiveTarget(context, target, [ + { label: 'create', action: async (page) => clickButton(page, '新建') }, + { label: 'projects', action: async (page) => clickButton(page, '项目') }, + { label: 'studio', action: async (page) => clickButton(page, '制作') }, + { label: 'quota', action: async (page) => clickButton(page, '额度') }, + { label: 'review', action: async (page) => clickButton(page, '审核') }, + { label: 'progress', action: async (page) => clickButton(page, '进度') }, + { label: 'result', action: async (page) => clickButton(page, '成品') }, + { label: 'tutorial', action: async (page) => clickButton(page, '教程') }, + { label: 'profile', action: async (page) => clickButton(page, '我的') } + ]); + } else { + await context.addInitScript(({ token, user, email }) => { + localStorage.setItem('admin_token', token); + localStorage.setItem('admin_user', JSON.stringify(user)); + localStorage.setItem('admin_email', email); + }, { token: adminAuth.access_token, user: adminAuth.user, email: adminEmail }); + await auditInteractiveTarget(context, target, [ + { label: 'dashboard' }, + { label: 'projects', action: async (page) => clickButton(page, '项目管理') }, + { label: 'tasks', action: async (page) => clickButton(page, '任务管理') }, + { label: 'routerAudit', action: async (page) => clickButton(page, 'Router 审计') }, + { label: 'hitAnalysis', action: async (page) => clickButton(page, '爆款诊断') }, + { label: 'aiPlatforms', action: async (page) => clickButton(page, 'AI 平台入口') }, + { label: 'providers', action: async (page) => clickButton(page, 'AI 接入') }, + { label: 'costs', action: async (page) => clickButton(page, '成本日志') }, + { label: 'audit', action: async (page) => clickButton(page, '审计日志') } + ]); + } + + await context.close(); + } + + const reportPath = `${outputDir}/report.json`; + await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`); + console.log(reportPath); + for (const item of report) { + console.log([ + item.target.name, + item.step, + `pageOverflow=${item.audit.horizontalPageOverflow}`, + `textOverflow=${item.audit.overflowElements.length}`, + `overlaps=${item.audit.overlaps.length}`, + `consoleErrors=${item.consoleErrors.length}`, + `screenshot=${item.screenshot}` + ].join(' | ')); + } +} finally { + await browser.close(); +} + +async function auditInteractiveTarget(context, target, steps) { + const page = await context.newPage(); + const consoleErrors = []; + const pageErrors = []; + + page.on('console', (message) => { + if (message.type() === 'error') consoleErrors.push(message.text()); + }); + page.on('pageerror', (error) => pageErrors.push(error.message)); + + await page.goto(target.url, { waitUntil: 'domcontentloaded', timeout: 30000 }); + await page.waitForTimeout(900); + + for (const step of steps) { + console.log(`audit ${target.name}:${step.label}`); + if (step.action) { + await step.action(page).catch((error) => { + consoleErrors.push(`step ${step.label}: ${error.message}`); + }); + await page.waitForTimeout(900); + } + + const screenshot = `${outputDir}/${target.name}-${step.label}.png`; + await page.screenshot({ path: screenshot, fullPage: false, timeout: 5000 }).catch((error) => { + consoleErrors.push(`screenshot ${step.label}: ${error.message}`); + }); + const audit = await page.evaluate(() => { + const viewportWidth = document.documentElement.clientWidth; + const viewportHeight = document.documentElement.clientHeight; + const bodyWidth = Math.max(document.body.scrollWidth, document.documentElement.scrollWidth); + const interactiveSelector = [ + 'button', + 'a[href]', + 'input', + 'select', + 'textarea', + '[role="button"]', + '.primary-action', + '.ghost-button', + '.nav-item', + '.rail-nav button' + ].join(','); + const textSelector = [ + 'button', + 'a', + 'span', + 'strong', + 'small', + 'p', + 'h1', + 'h2', + 'h3', + 'label' + ].join(','); + + function visible(element) { + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + + return style.display !== 'none' && + style.visibility !== 'hidden' && + Number(style.opacity) !== 0 && + rect.bottom >= 0 && + rect.top <= viewportHeight && + rect.right >= 0 && + rect.left <= viewportWidth && + rect.width > 1 && + rect.height > 1; + } + + function pathOf(element) { + const parts = []; + let current = element; + + while (current && current.nodeType === Node.ELEMENT_NODE && parts.length < 4) { + const id = current.id ? `#${current.id}` : ''; + const cls = current.className && typeof current.className === 'string' + ? `.${current.className.trim().split(/\s+/).slice(0, 2).join('.')}` + : ''; + parts.unshift(`${current.tagName.toLowerCase()}${id}${cls}`); + current = current.parentElement; + } + + return parts.join(' > '); + } + + const overflowElements = [...document.querySelectorAll(textSelector)] + .filter((element) => visible(element)) + .map((element) => { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + const horizontalOverflow = element.scrollWidth - element.clientWidth; + const verticalOverflow = element.scrollHeight - element.clientHeight; + const viewportOverflow = Math.max(0, rect.right - viewportWidth, -rect.left); + const text = (element.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 120); + + return { + selector: pathOf(element), + tag: element.tagName.toLowerCase(), + text, + className: typeof element.className === 'string' ? element.className : '', + rect: { + x: Math.round(rect.x), + y: Math.round(rect.y), + width: Math.round(rect.width), + height: Math.round(rect.height) + }, + overflow: { + horizontal: Math.round(horizontalOverflow), + vertical: Math.round(verticalOverflow), + viewport: Math.round(viewportOverflow) + }, + whiteSpace: style.whiteSpace, + webkitLineClamp: style.webkitLineClamp, + textOverflow: style.textOverflow, + fontSize: style.fontSize + }; + }) + .filter((item) => { + const intentionallyClamped = item.webkitLineClamp && item.webkitLineClamp !== 'none'; + const intentionallyEllipsized = item.textOverflow === 'ellipsis'; + + if ((intentionallyClamped || intentionallyEllipsized) && item.overflow.viewport <= 2) { + return false; + } + + return item.overflow.horizontal > 2 || item.overflow.vertical > 4 || item.overflow.viewport > 2; + }) + .slice(0, 80); + + const interactive = [...document.querySelectorAll(interactiveSelector)] + .filter((element) => visible(element)) + .slice(0, 220) + .map((element) => { + const rect = element.getBoundingClientRect(); + + return { + element, + selector: pathOf(element), + text: (element.textContent || element.getAttribute('aria-label') || '').trim().replace(/\s+/g, ' ').slice(0, 80), + rect: { + left: rect.left, + right: rect.right, + top: rect.top, + bottom: rect.bottom, + width: rect.width, + height: rect.height + } + }; + }); + const overlaps = []; + + for (let i = 0; i < interactive.length; i += 1) { + for (let j = i + 1; j < interactive.length; j += 1) { + if (overlaps.length >= 80) break; + const a = interactive[i]; + const b = interactive[j]; + + if (a.element.contains(b.element) || b.element.contains(a.element)) continue; + + const xOverlap = Math.min(a.rect.right, b.rect.right) - Math.max(a.rect.left, b.rect.left); + const yOverlap = Math.min(a.rect.bottom, b.rect.bottom) - Math.max(a.rect.top, b.rect.top); + + if (xOverlap > 2 && yOverlap > 2) { + overlaps.push({ + a: { selector: a.selector, text: a.text }, + b: { selector: b.selector, text: b.text }, + overlap: { x: Math.round(xOverlap), y: Math.round(yOverlap) } + }); + } + } + if (overlaps.length >= 80) break; + } + + return { + title: document.title, + url: window.location.href, + viewport: { width: viewportWidth, height: viewportHeight }, + bodyWidth, + horizontalPageOverflow: Math.max(0, bodyWidth - viewportWidth), + overflowElements, + overlaps: overlaps.slice(0, 80) + }; + }); + + report.push({ + target, + step: step.label, + screenshot, + consoleErrors: [...consoleErrors], + pageErrors: [...pageErrors], + audit + }); + } + await page.close(); +} + +async function clickButton(page, name) { + const button = page.getByRole('button', { name, exact: true }).first(); + await button.waitFor({ state: 'visible', timeout: 5000 }); + await button.click({ timeout: 5000 }); +} + +async function loginOrRegister(email, password, nickname) { + try { + return await login(email, password); + } catch { + return api('/auth/register', { + method: 'POST', + body: { email, password, nickname } + }); + } +} + +async function login(email, password) { + return api('/auth/login', { + method: 'POST', + body: { email, password } + }); +} + +async function ensureLiveActionProject(token) { + const projects = await api('/projects', { token }); + const existing = projects.find((item) => + item.output_mode === 'live_action_ai' && + String(item.title || '').includes('视觉验收') + ); + + if (existing) return existing; + + return api('/projects', { + method: 'POST', + token, + body: { + title: '真人小样视觉验收项目-超长标题用于检测按钮标签重叠与二号字体换行', + input_mode: 'ai_original', + output_mode: 'live_action_ai', + genre: '都市逆袭爽剧-长标签-用于压力测试', + style_code: 'photorealistic_short_drama', + target_episode_count: 3, + episode_duration: 60, + quality_level: 'mvp' + } + }); +} + +async function api(path, options = {}) { + const response = await fetch(`${apiBase}${path}`, { + method: options.method || 'GET', + headers: { + 'content-type': 'application/json', + ...(options.token ? { authorization: `Bearer ${options.token}` } : {}) + }, + body: options.body ? JSON.stringify(options.body) : undefined + }); + const payload = await response.json().catch(() => null); + + if (!response.ok || !payload || payload.code !== 0) { + throw new Error(payload?.message || `API ${path} failed: HTTP ${response.status}`); + } + + return payload.data; +} diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..82d18a6 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "moduleResolution": "Node", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + } +} diff --git a/user-app/index.html b/user-app/index.html new file mode 100644 index 0000000..dc9117d --- /dev/null +++ b/user-app/index.html @@ -0,0 +1,12 @@ + + + + + + AI Manga User App + + +
+ + + diff --git a/user-app/manifest.json b/user-app/manifest.json new file mode 100644 index 0000000..91e3c5d --- /dev/null +++ b/user-app/manifest.json @@ -0,0 +1,24 @@ +{ + "name": "AI Manga", + "appid": "__UNI__AI_MANGA", + "description": "AI manga short drama user app, H5 first with mini program and app route reservations", + "versionName": "0.1.0", + "versionCode": "100", + "transformPx": false, + "h5": { + "title": "AI Manga" + }, + "mp-weixin": { + "appid": "", + "setting": { + "urlCheck": false + } + }, + "app-plus": { + "safearea": { + "bottom": { + "offset": "auto" + } + } + } +} diff --git a/user-app/package.json b/user-app/package.json new file mode 100644 index 0000000..89541e6 --- /dev/null +++ b/user-app/package.json @@ -0,0 +1,15 @@ +{ + "name": "user-app", + "version": "0.1.0", + "private": true, + "scripts": { + "dev:h5": "vite --host 0.0.0.0 --port ${USER_APP_PORT:-5174}", + "build": "vue-tsc --noEmit -p tsconfig.json && vite build", + "lint": "vue-tsc --noEmit -p tsconfig.json", + "typecheck": "vue-tsc --noEmit -p tsconfig.json", + "test": "vitest run --passWithNoTests" + }, + "dependencies": { + "vue": "^3.5.16" + } +} diff --git a/user-app/pages.json b/user-app/pages.json new file mode 100644 index 0000000..5fcb4be --- /dev/null +++ b/user-app/pages.json @@ -0,0 +1,106 @@ +{ + "pages": [ + { + "path": "src/pages/index/index", + "style": { + "navigationBarTitleText": "漫剧制作台" + } + }, + { + "path": "src/pages/auth/login", + "style": { + "navigationBarTitleText": "登录" + } + }, + { + "path": "src/pages/help/tutorial", + "style": { + "navigationBarTitleText": "新手教程" + } + }, + { + "path": "src/pages/projects/create", + "style": { + "navigationBarTitleText": "新建项目" + } + }, + { + "path": "src/pages/user/projects", + "style": { + "navigationBarTitleText": "我的项目" + } + }, + { + "path": "src/pages/projects/source-select", + "style": { + "navigationBarTitleText": "来源选择" + } + }, + { + "path": "src/pages/projects/original-setting", + "style": { + "navigationBarTitleText": "原创设置" + } + }, + { + "path": "src/pages/projects/upload-novel", + "style": { + "navigationBarTitleText": "上传小说" + } + }, + { + "path": "src/pages/projects/copyright", + "style": { + "navigationBarTitleText": "版权确认" + } + }, + { + "path": "src/pages/projects/story-bible", + "style": { + "navigationBarTitleText": "故事圣经" + } + }, + { + "path": "src/pages/projects/characters", + "style": { + "navigationBarTitleText": "角色库" + } + }, + { + "path": "src/pages/projects/episodes", + "style": { + "navigationBarTitleText": "分集计划" + } + }, + { + "path": "src/pages/projects/storyboard", + "style": { + "navigationBarTitleText": "脚本分镜" + } + }, + { + "path": "src/pages/projects/progress", + "style": { + "navigationBarTitleText": "生成进度" + } + }, + { + "path": "src/pages/projects/result", + "style": { + "navigationBarTitleText": "成品漫剧" + } + }, + { + "path": "src/pages/user/profile", + "style": { + "navigationBarTitleText": "用户中心" + } + } + ], + "globalStyle": { + "navigationBarTextStyle": "black", + "navigationBarTitleText": "漫剧制作台", + "navigationBarBackgroundColor": "#ffffff", + "backgroundColor": "#f4f6f8" + } +} diff --git a/user-app/src/App.vue b/user-app/src/App.vue new file mode 100644 index 0000000..33f7127 --- /dev/null +++ b/user-app/src/App.vue @@ -0,0 +1,7 @@ + + + diff --git a/user-app/src/api/client.ts b/user-app/src/api/client.ts new file mode 100644 index 0000000..4f9d7b6 --- /dev/null +++ b/user-app/src/api/client.ts @@ -0,0 +1,1404 @@ +import { + ApiCryptoClient, + base64ToBytes, + fileToEncryptedUploadPayload +} from './crypto'; + +export interface ApiEnvelope { + code: number; + message: string; + data: T; + request_id: string; +} + +const ERROR_MESSAGE_LABELS: Record = { + 'Copyright must be confirmed before parsing novel': '请先完成版权确认,再解析小说。', + 'source_id or asset_id is required': '请先保存粘贴文本或上传小说文件,再解析小说。', + 'asset_id and source_id cannot be used together': '小说解析参数异常,请刷新后重试。', + 'Novel text is too short to parse': '小说文本太短,无法解析,请补充正文内容。', + 'Novel text looks garbled, please upload UTF-8 text': '小说文本疑似乱码,请上传 UTF-8 编码文本。', + 'text is required': '请先填写小说正文。', + 'Locked character is required before image generation': '请先确认角色库,再生成角色锚点图。', + OPENAI_REQUEST_TIMEOUT: 'OpenAI 请求超时,真实素材未生成,请稍后重试或在后台调高超时时间。', + REAL_VIDEO_GENERATION_CONFIRMATION_REQUIRED: '真实视频生成会消耗额度,请先勾选确认后再开始。', + LIVE_ACTION_SAMPLE_SHOT_NOT_FOUND: '未找到要测试的真人镜头,请刷新后重新选择。', + LIVE_ACTION_KEYFRAME_RASTER_REQUIRED: '真实视频 Provider 需要 PNG/JPG/WebP 关键帧,请先生成真实关键帧,不能使用 mock SVG。', + LIVE_ACTION_KEYFRAME_TOO_LARGE_FOR_VIDEO_PROVIDER: '关键帧图片太大,视频 Provider 无法接收,请换小一些的关键帧。', + LIVE_ACTION_VIDEO_COST_LIMIT_EXCEEDED: '本次片段预估成本超过你设置的单片段上限。', + 'asset_id is required': '请先上传或选择一张关键帧图片。', + 'Asset belongs to another project': '该素材属于其它项目,不能绑定到当前镜头。', + 'Invalid manual review status': '人工验收状态无效,请刷新后重试。', + 'VideoProvider did not return video content or downloadable URL': '视频 Provider 没有返回真实视频内容或可下载地址。', + 'ImageProvider did not return image content or downloadable URL': '图片 Provider 没有返回真实图片内容,未生成占位图。', + 'VoiceProvider did not return audio content or downloadable URL': 'TTS Provider 没有返回真实音频内容,未生成静音占位。', + MINIMAX_TTS_EMPTY_AUDIO: 'MiniMax TTS 没有返回音频,请检查 voice_id / group_id / 账号权限后重试。', + PayloadTooLargeError: '上传内容太大,请换小文件或联系管理员调高上传限制。', + 'request entity too large': '上传内容太大,请换小文件或联系管理员调高上传限制。', + 'Video render requires FFmpeg or provider video content in production mode': '视频合成需要 FFmpeg 或真实视频 Provider 内容,未生成占位视频。', + 'This audio was generated before segment files were stored; regenerate the full audio once before single-segment retry': '这条音频是旧版本生成的,缺少逐句音频片段。请先点“重生成音频”,再重试单句。' +}; + +function friendlyApiMessage(message?: string) { + if (!message) return ''; + + if (message.startsWith('MINIMAX_TTS_PROVIDER_REJECTED')) { + return `MiniMax TTS 拒绝请求:${message.replace(/^MINIMAX_TTS_PROVIDER_REJECTED:\s*/, '')}`; + } + if (message.startsWith('MINIMAX_TTS_EMPTY_AUDIO')) { + return 'MiniMax TTS 没有返回音频,请检查 voice_id / group_id / 账号权限后重试。'; + } + + return ERROR_MESSAGE_LABELS[message] ?? message; +} + +export interface UserProfile { + id: string; + email: string; + nickname: string | null; + role: string; + status: string; + created_at?: string; +} + +export interface AuthResult { + access_token: string; + token_type: 'Bearer'; + expires_in: number; + user: UserProfile; +} + +export interface SafeProject { + id: string; + user_id: string; + title: string | null; + input_mode: string; + genre: string | null; + style_code: string | null; + output_type: string | null; + output_mode: string; + visual_mode: string | null; + video_generation_level: string | null; + target_episode_count: number | null; + episode_duration: number | null; + status: string; + copyright_status: string; + payment_status: string; + quality_level: string | null; + is_long_series: boolean; + created_at: string; + updated_at: string; + completed_at: string | null; +} + +export interface SafeAsset { + id: string; + user_id: string | null; + project_id: string | null; + asset_type: string; + file_path: string; + mime_type: string | null; + width: number | null; + height: number | null; + duration: string | null; + size: string | null; + hash: string | null; + visibility: string; + status: string; + created_at: string; +} + +export interface SafeNovelSource { + id: string; + project_id: string; + source_type: string; + title: string | null; + author_name: string | null; + raw_asset_id: string | null; + word_count: number | null; + chapter_count: number | null; + parse_status: string; + parse_report: Record | null; + created_at: string; +} + +export interface SafeNovelChapter { + id: string; + project_id: string; + novel_source_id: string | null; + chapter_no: number; + title: string | null; + content: string; + summary: string | null; + visual_summary: string | null; + word_count: number | null; + status: string; + created_at: string; +} + +export interface SafeStoryBible { + id: string; + project_id: string; + title: string | null; + logline: string | null; + main_plot: string | null; + core_conflict: string | null; + selling_points: string | null; + tone: string | null; + world_summary: string | null; + ending_direction: string | null; + taboo_rules: string | null; + version: number; + status: string; + created_at: string; + updated_at: string; +} + +export interface SafeCharacter { + id: string; + project_id: string; + global_character_id: string | null; + name: string; + alias_names?: unknown; + role_type: string; + gender_label: string | null; + age_group: string | null; + identity_desc: string | null; + appearance_desc: string | null; + face_desc?: string | null; + hair_desc?: string | null; + eye_desc?: string | null; + body_desc?: string | null; + costume_rules?: string | null; + special_props?: string | null; + personality_desc: string | null; + speech_style?: string | null; + relationship_desc: string | null; + character_arc: string | null; + negative_rules?: string | null; + anchor_asset_id: string | null; + wardrobe_variant?: string | null; + voice_provider_code?: string | null; + voice_model?: string | null; + voice_id?: string | null; + voice_style?: string | null; + performance_style?: string | null; + importance_level: number; + status: string; + created_at: string; + updated_at: string; +} + +export interface SafeCharacterImage { + id: string; + project_id: string; + character_id: string; + asset_id: string | null; + image_type: string; + prompt_text: string | null; + negative_prompt: string | null; + is_anchor: boolean; + quality_score: number | null; + status: string; + created_at: string; + asset?: SafeAsset | null; +} + +export interface SafeEpisode { + id: string; + project_id: string; + episode_no: number; + title: string | null; + summary: string | null; + opening_hook: string | null; + middle_conflict: string | null; + ending_hook: string | null; + target_duration: number | null; + status: string; + created_at: string; + updated_at: string; +} + +export interface SafeEpisodeScript { + id: string; + project_id: string; + episode_id: string; + script_text: string | null; + narration_text: string | null; + version: number; + status: string; + created_at: string; + updated_at: string; +} + +export interface SafeStoryboardShot { + id: string; + project_id: string; + episode_id: string; + shot_no: number; + scene_name: string | null; + location_desc: string | null; + visual_desc: string | null; + action_desc: string | null; + dialogue_text: string | null; + narration_text: string | null; + camera_motion: string | null; + effect_type: string | null; + duration: number | null; + prompt_text: string | null; + negative_prompt: string | null; + status: string; + created_at: string; + updated_at: string; +} + +export interface SafeCreativePattern { + id: string; + source_case_id: string | null; + pattern_type: string; + title: string; + genre: string | null; + language: string; + description: string | null; + structure_json: unknown; + prompt_template: string | null; + negative_prompt: string | null; + tags_json: unknown; + usage_count: number; + effectiveness_score: number | null; + status: string; + created_at: string; + updated_at: string; +} + +export interface SafeProjectCreativePattern { + id: string; + project_id: string; + creative_pattern_id: string; + source: string; + snapshot_json: unknown; + sort_order: number; + created_at: string; + pattern: SafeCreativePattern | null; +} + +export interface SafeRenderTask { + id: string; + project_id: string; + episode_id: string | null; + shot_id: string | null; + task_type: string; + provider_id?: string | null; + status: string; + input_json?: unknown; + input_hash?: string | null; + idempotency_key?: string | null; + output_asset_id: string | null; + provider_request_id?: string | null; + retry_count: number; + max_retry: number; + cost_estimate?: number | null; + cost_actual?: number | null; + error_code: string | null; + error_message: string | null; + created_at: string; + started_at: string | null; + finished_at: string | null; +} + +export interface BillingPackage { + code: string; + name: string; + description: string; + amount: number; + currency: string; + quota_amount: number; + included_episodes: number; + features: string[]; + recommended?: boolean; +} + +export interface SafeQuotaAccount { + id: string; + user_id: string; + total_quota: number; + available_quota: number; + frozen_quota: number; + used_quota: number; + status: string; + created_at: string; + updated_at: string; +} + +export interface SafeOrder { + id: string; + user_id: string; + project_id: string | null; + order_no: string; + package_code: string | null; + amount: number; + currency: string; + payment_method: string | null; + payment_status: string; + paid_at: string | null; + created_at: string; + updated_at: string; +} + +export interface SafeContentReview { + id: string; + project_id: string | null; + user_id: string | null; + target_type: string; + target_id: string | null; + review_type: string; + result_status: string; + risk_level: string | null; + issue_text: string | null; + suggestion_text: string | null; + reviewer_id: string | null; + reviewed_at: string | null; + created_at: string; + updated_at: string; +} + +export interface SafeCaseShowcase { + id: string; + project_id: string; + user_id: string | null; + title: string; + cover_asset_id: string | null; + video_asset_id: string | null; + authorization_status: string; + visibility: string; + sort_order: number; + published_at: string | null; + created_at: string; + updated_at: string; +} + +export interface ProjectQuotaEstimate { + project_id: string; + input_mode: string; + target_episode_count: number; + estimated_shot_count: number; + total_quota: number; + breakdown: Array<{ key: string; label: string; quota: number }>; +} + +export interface SafePlotMemory { + id: string; + project_id: string; + episode_id: string | null; + chapter_id: string | null; + memory_type: string; + content: string; + importance_level: number; + status: string; + created_at: string; +} + +export interface SafePlotThread { + id: string; + project_id: string; + thread_name: string; + thread_type: string; + description: string | null; + start_episode_no: number | null; + expected_resolve_episode_no: number | null; + resolved_episode_no: number | null; + status: string; + created_at: string; + updated_at: string; +} + +export interface MediaAssetRow { + task_type: string; + task_id: string; + task?: SafeRenderTask; + asset: SafeAsset; + timeline?: MediaTimeline | null; + stats?: MediaTaskStats | null; +} + +export interface MediaTimelineSegment { + index: number; + segment_type: string; + shot_id: string | null; + shot_no: number | null; + start_seconds: number; + end_seconds: number; + target_duration: number; + actual_duration: number | null; + speaker_name: string; + voice: string; + voice_provider_code: string | null; + voice_model: string | null; + voice_style: string | null; + speech_speed: number | null; + character_id: string | null; + global_character_id: string | null; + text: string; + is_mock: boolean | null; +} + +export interface MediaTimelineWarning { + index: number; + shot_no: number | null; + speaker_name: string; + text_preview: string; + target_duration: number; + actual_duration: number; + over_seconds: number; +} + +export interface MediaSubtitleCue { + index: number; + start: string; + end: string; + text: string; + start_seconds?: number; + end_seconds?: number; +} + +export type MediaTimeline = + | { type: 'audio'; segments: MediaTimelineSegment[]; warnings: MediaTimelineWarning[] } + | { type: 'subtitle'; cues: MediaSubtitleCue[] }; + +export interface MediaTaskStats { + segment_count?: number; + voice_count?: number; + total_characters?: number; + duration_seconds?: number | null; + warning_count?: number; + segment_retry_ready?: boolean; + missing_segment_file_count?: number; + subtitle_mode?: string; + cue_count?: number; + width?: number | null; + height?: number | null; + cost_estimate?: { + unit: string; + total_characters?: number; + note?: string; + }; +} + +export interface SafeActorProfile { + id: string; + project_id: string; + character_id: string; + actor_desc: string | null; + appearance_rules: string | null; + wardrobe_rules: string | null; + performance_style: string | null; + voice_style: string | null; + reference_asset_ids: unknown; + anchor_asset_id: string | null; + status: string; + created_at: string; + updated_at: string; +} + +export interface SafeLiveActionShot { + id: string; + project_id: string; + episode_id: string; + shot_no: number; + scene_name: string | null; + live_action_desc: string | null; + actor_action: string | null; + camera_instruction: string | null; + performance_instruction: string | null; + scene_type: string | null; + importance_score: number | null; + emotion_score: number | null; + action_score: number | null; + route_tier: string | null; + video_prompt: string | null; + keyframe_asset_id: string | null; + video_clip_asset_id: string | null; + video_status: string | null; + duration: string | null; + status: string; + updated_at: string; +} + +export interface SafeVideoClip { + id: string; + project_id: string; + episode_id: string; + shot_id: string; + provider_id: string | null; + input_asset_id: string | null; + output_asset_id: string | null; + duration: string | null; + prompt_text: string | null; + status: string; + cost_actual: number | null; + retry_count: number; + quality_status: string | null; + quality_score: number | null; + quality_issues: unknown; + created_at: string; + updated_at: string; +} + +export type LiveActionRepairAction = 'passed' | 'needs_retry' | 'retry_same_provider' | 'switch_provider' | 'manual_required'; + +export interface LiveActionQualityCheckResult { + video_clip: SafeVideoClip; + provider_result: Record; + repair_action: LiveActionRepairAction; + repair_history: Array<{ + action: LiveActionRepairAction; + clip_id: string; + provider_code?: string | null; + reason?: string; + }>; + repaired_clip?: SafeVideoClip; + next_step: string; +} + +export interface SafeLiveActionVideoProvider { + provider_code: string; + display_name: string | null; + mode: string; + model_name: string | null; + is_enabled: boolean; + currency: string; + price_per_second: number; + price_per_clip: number; + max_cost_per_call: number; + daily_cost_limit: number; +} + +export interface LiveActionCostEstimate { + provider_code: string; + provider_name: string | null; + provider_mode: string; + provider_enabled: boolean; + currency: string; + clip_count: number; + total_seconds: number; + estimated_cost: number; + max_cost_per_call: number; + daily_cost_limit: number; + breakdown: Array<{ + shot_id: string; + shot_no: number; + duration: number; + estimated_cost: number; + }>; +} + +export interface LiveActionPreflightIssue { + code: string; + message: string; + severity: 'blocker' | 'warning'; + shot_id?: string; + shot_no?: number | null; +} + +export interface LiveActionPreflightReport { + ready: boolean; + next_step: string; + shot_id: string | null; + requested_provider_code: string | null; + manual_override_allowed: boolean; + confirm_real_video: boolean; + requires_real_video_confirmation: boolean; + max_cost_per_clip: number | null; + summary: { + shot_count: number; + prepared_shot_count: number; + keyframe_count: number; + raster_keyframe_count: number; + provider_clip_count: number; + total_seconds: number; + estimated_cost: number; + currency: string; + }; + blockers: LiveActionPreflightIssue[]; + warnings: LiveActionPreflightIssue[]; + breakdown: Array<{ + shot_id: string; + shot_no: number; + scene_name: string | null; + duration: number; + provider_clip_count: number; + provider_clip_durations: number[]; + provider_code: string; + provider_mode: string; + route_tier: string | null; + decision_reason: string; + estimated_cost: number; + keyframe_asset_id: string | null; + keyframe_mime_type: string | null; + keyframe_status: string | null; + keyframe_ready: boolean; + source_image_required: boolean; + source_image_ready: boolean; + issues: LiveActionPreflightIssue[]; + }>; +} + +export interface SafeMediaTaskResult { + asset: SafeAsset; + task: SafeRenderTask; + reused: boolean; + ffmpeg_used?: boolean; + render_backend?: string; + next_step?: string; +} + +export interface UploadedAssetResult { + asset: SafeAsset; + storage_backend: string; + next_step?: string; +} + +type RequestBody = Record | FormData | undefined; + +function trimTrailingSlash(value: string) { + return value.replace(/\/+$/, ''); +} + +function assertSecureProductionApiUrl(value: string) { + if (import.meta.env.PROD && /^http:\/\//i.test(value)) { + throw new Error('生产环境 VITE_API_BASE_URL 必须使用 HTTPS,或使用同源 /api。'); + } + + return value; +} + +function resolveApiBaseUrl() { + const configured = (import.meta.env.VITE_API_BASE_URL as string | undefined)?.trim(); + + if (configured) { + return assertSecureProductionApiUrl(trimTrailingSlash(configured)); + } + + if (import.meta.env.PROD) { + return '/api'; + } + + if (typeof window !== 'undefined') { + const { protocol, hostname, origin } = window.location; + + if (protocol === 'https:') { + return `${origin}/api`; + } + + if (hostname && hostname !== 'localhost' && hostname !== '127.0.0.1') { + return `${protocol}//${hostname}:3000/api`; + } + } + + return 'http://127.0.0.1:3000/api'; +} + +export class UserApiClient { + private readonly baseUrl: string; + private readonly crypto: ApiCryptoClient; + + constructor(private token: string | null) { + this.baseUrl = resolveApiBaseUrl(); + this.crypto = new ApiCryptoClient(this.baseUrl); + } + + setToken(token: string | null) { + this.token = token; + } + + register(payload: { email: string; password: string; nickname?: string }) { + return this.request('/auth/register', { method: 'POST', body: payload }); + } + + login(payload: { email: string; password: string }) { + return this.request('/auth/login', { method: 'POST', body: payload }); + } + + profile() { + return this.request('/auth/profile'); + } + + listBillingPackages() { + return this.request<{ packages: BillingPackage[] }>('/billing/packages'); + } + + getQuota() { + return this.request('/billing/quota'); + } + + listOrders() { + return this.request<{ orders: SafeOrder[]; total: number; limit: number }>('/billing/orders?limit=20'); + } + + createOrder(packageCode: string, projectId?: string | null) { + return this.request<{ order: SafeOrder; package: BillingPackage; next_step: string }>('/billing/orders', { + method: 'POST', + body: { + package_code: packageCode, + project_id: projectId || undefined, + payment_method: 'mock_pay' + } + }); + } + + mockPayOrder(orderId: string) { + return this.request<{ order: SafeOrder; account: SafeQuotaAccount; package: BillingPackage }>( + `/billing/orders/${orderId}/mock-pay`, + { method: 'POST', body: {} } + ); + } + + runProjectTextReview(projectId: string, content?: string) { + return this.request<{ review: SafeContentReview; provider_result: Record; next_step: string }>( + `/projects/${projectId}/reviews/text`, + { + method: 'POST', + body: { + review_type: 'text', + content + } + } + ); + } + + listProjectReviews(projectId: string) { + return this.request<{ reviews: SafeContentReview[]; total: number; limit: number }>( + `/projects/${projectId}/reviews?limit=50` + ); + } + + runAssetReview(assetId: string, contentExcerpt?: string) { + return this.request<{ review: SafeContentReview; provider_result: Record; next_step: string }>( + `/assets/${assetId}/review`, + { + method: 'POST', + body: { + content_excerpt: contentExcerpt + } + } + ); + } + + authorizeShowcase(projectId: string, payload: { title?: string; cover_asset_id?: string | null; video_asset_id?: string | null }) { + return this.request<{ showcase: SafeCaseShowcase; review: SafeContentReview; next_step: string }>( + `/projects/${projectId}/showcase/authorize`, + { + method: 'POST', + body: payload + } + ); + } + + listShowcases(projectId: string) { + return this.request<{ showcases: SafeCaseShowcase[]; total: number }>( + `/projects/${projectId}/showcase` + ); + } + + listProjects() { + return this.request('/projects'); + } + + createProject(payload: Record) { + return this.request('/projects', { method: 'POST', body: payload }); + } + + listCreativePatternLibrary(params: Record = {}) { + const query = new URLSearchParams({ limit: '30', ...params }); + + return this.request<{ patterns: SafeCreativePattern[]; total: number; limit: number }>( + `/projects/creative-patterns/library?${query.toString()}` + ); + } + + getProject(projectId: string) { + return this.request(`/projects/${projectId}`); + } + + listProjectCreativePatterns(projectId: string) { + return this.request<{ patterns: SafeProjectCreativePattern[] }>(`/projects/${projectId}/creative-patterns`); + } + + updateProjectCreativePatterns(projectId: string, creativePatternIds: string[]) { + return this.request<{ patterns: SafeProjectCreativePattern[] }>(`/projects/${projectId}/creative-patterns`, { + method: 'PATCH', + body: { creative_pattern_ids: creativePatternIds } + }); + } + + getProjectQuotaEstimate(projectId: string) { + return this.request(`/projects/${projectId}/quota/estimate`); + } + + freezeProjectQuota(projectId: string) { + return this.request<{ + account: SafeQuotaAccount; + estimate: ProjectQuotaEstimate; + amount: number; + project_payment_status: string; + reused?: boolean; + }>(`/projects/${projectId}/quota/freeze`, { + method: 'POST', + body: { reason: 'user_confirm_before_video_render' } + }); + } + + confirmCopyright(projectId: string, payload: Record) { + return this.request(`/projects/${projectId}/copyright/confirm`, { method: 'POST', body: payload }); + } + + pasteNovel(projectId: string, payload: Record) { + return this.request<{ source: SafeNovelSource; next_step: string }>(`/projects/${projectId}/novel/paste`, { + method: 'POST', + body: payload + }); + } + + parseNovel(projectId: string, payload: Record) { + return this.request<{ source: SafeNovelSource; chapters: SafeNovelChapter[]; next_step: string }>( + `/projects/${projectId}/novel/parse`, + { method: 'POST', body: payload } + ); + } + + async uploadNovelFile(projectId: string, file: File) { + return this.request(`/projects/${projectId}/novel/upload`, { + method: 'POST', + body: { + file: await fileToEncryptedUploadPayload(file) + } + }); + } + + async uploadAsset( + file: File, + assetType: 'image' | 'audio' | 'video' | 'document' = 'document', + projectId?: string + ) { + return this.request('/assets/upload', { + method: 'POST', + body: { + asset_type: assetType, + project_id: projectId, + file: await fileToEncryptedUploadPayload(file) + } + }); + } + + getParseResult(projectId: string) { + return this.request<{ source: SafeNovelSource | null; chapters: SafeNovelChapter[] }>( + `/projects/${projectId}/novel/parse-result` + ); + } + + generateOriginalIdea(projectId: string, payload: Record) { + return this.request<{ source: SafeNovelSource; idea: Record; next_step: string }>( + `/projects/${projectId}/original/idea`, + { method: 'POST', body: payload } + ); + } + + generateOriginalOutline(projectId: string, payload: Record) { + return this.request<{ source: SafeNovelSource; outline: Record; next_step: string }>( + `/projects/${projectId}/original/outline`, + { method: 'POST', body: payload } + ); + } + + generateOriginalChapters(projectId: string, payload: Record) { + return this.request<{ source: SafeNovelSource; chapters: SafeNovelChapter[]; next_step: string }>( + `/projects/${projectId}/original/chapters`, + { method: 'POST', body: payload } + ); + } + + originalSelfCheck(projectId: string, payload: Record) { + return this.request<{ source: SafeNovelSource; self_check: Record; next_step: string }>( + `/projects/${projectId}/original/self-check`, + { method: 'POST', body: payload } + ); + } + + getOriginalResult(projectId: string) { + return this.request<{ + source: SafeNovelSource | null; + idea: Record | null; + outline: Record | null; + self_check: Record | null; + chapters: SafeNovelChapter[]; + }>(`/projects/${projectId}/original/result`); + } + + generateStoryBible(projectId: string) { + return this.request<{ story_bible: SafeStoryBible; next_step: string }>( + `/projects/${projectId}/story-bible/generate`, + { method: 'POST', body: {} } + ); + } + + getStoryBible(projectId: string) { + return this.request<{ story_bible: SafeStoryBible | null; versions: Array> }>( + `/projects/${projectId}/story-bible` + ); + } + + confirmStoryBible(projectId: string) { + return this.request<{ story_bible: SafeStoryBible; next_step: string }>( + `/projects/${projectId}/story-bible/confirm`, + { method: 'POST', body: {} } + ); + } + + extractCharacters(projectId: string) { + return this.request<{ characters: SafeCharacter[]; next_step: string }>( + `/projects/${projectId}/characters/extract`, + { method: 'POST', body: {} } + ); + } + + listCharacters(projectId: string) { + return this.request(`/projects/${projectId}/characters`); + } + + confirmCharacters(projectId: string) { + return this.request<{ characters: SafeCharacter[]; next_step: string }>( + `/projects/${projectId}/characters/confirm`, + { method: 'POST', body: {} } + ); + } + + generatePlotMemories(projectId: string) { + return this.request<{ + plot_memories: SafePlotMemory[]; + plot_threads: SafePlotThread[]; + created_count: Record; + next_step: string; + }>(`/projects/${projectId}/plot-memories/generate`, { method: 'POST', body: {} }); + } + + listPlotMemories(projectId: string) { + return this.request(`/projects/${projectId}/plot-memories?status=active`); + } + + listPlotThreads(projectId: string) { + return this.request(`/projects/${projectId}/plot-threads`); + } + + updateCharacter(characterId: string, payload: Record) { + return this.request(`/characters/${characterId}`, { + method: 'PATCH', + body: payload + }); + } + + listCharacterImages(characterId: string) { + return this.request(`/characters/${characterId}/images`); + } + + generateCharacterImages( + characterId: string, + options: { + image_types?: string[]; + count_per_type?: number; + force?: boolean; + set_first_as_anchor?: boolean; + } = {} + ) { + return this.request(`/characters/${characterId}/generate-images`, { + method: 'POST', + body: { + image_types: options.image_types ?? ['anchor'], + count_per_type: options.count_per_type ?? 1, + set_first_as_anchor: options.set_first_as_anchor ?? true, + force: options.force ?? false + } + }); + } + + setCharacterAnchor(characterId: string, payload: { character_image_id?: string; asset_id?: string }) { + return this.request(`/characters/${characterId}/set-anchor`, { + method: 'POST', + body: payload + }); + } + + generateEpisodes(projectId: string, targetEpisodeCount?: number | null) { + return this.request<{ episodes: SafeEpisode[]; next_step: string }>( + `/projects/${projectId}/episodes/generate-plan`, + { method: 'POST', body: { target_episode_count: targetEpisodeCount ?? undefined } } + ); + } + + listEpisodes(projectId: string) { + return this.request(`/projects/${projectId}/episodes`); + } + + confirmEpisodes(projectId: string) { + return this.request<{ episodes: SafeEpisode[]; next_step: string }>( + `/projects/${projectId}/episodes/confirm`, + { method: 'POST', body: {} } + ); + } + + generateScript(episodeId: string) { + return this.request<{ script: SafeEpisodeScript; next_step: string }>( + `/episodes/${episodeId}/script/generate`, + { method: 'POST', body: {} } + ); + } + + getScript(episodeId: string) { + return this.request<{ script: SafeEpisodeScript | null }>(`/episodes/${episodeId}/script`); + } + + confirmScript(episodeId: string) { + return this.request<{ script: SafeEpisodeScript; next_step: string }>( + `/episodes/${episodeId}/script/confirm`, + { method: 'POST', body: {} } + ); + } + + generateStoryboard(episodeId: string) { + return this.request<{ shots: SafeStoryboardShot[]; next_step: string }>( + `/episodes/${episodeId}/storyboard/generate`, + { method: 'POST', body: {} } + ); + } + + getStoryboard(episodeId: string) { + return this.request(`/episodes/${episodeId}/storyboard`); + } + + confirmStoryboard(episodeId: string) { + return this.request<{ shots: SafeStoryboardShot[]; next_step: string }>( + `/episodes/${episodeId}/storyboard/confirm`, + { method: 'POST', body: {} } + ); + } + + generateShotImages(episodeId: string) { + return this.request(`/episodes/${episodeId}/shot-images/generate`, { + method: 'POST', + body: { image_type: 'final', only_missing: true } + }); + } + + generateAudio(episodeId: string, options: { force?: boolean } = {}) { + return this.request(`/episodes/${episodeId}/audio/generate`, { + method: 'POST', + body: { dialogue_mode: 'mixed', force: options.force ?? false } + }); + } + + retryAudioSegment( + episodeId: string, + segmentIndex: number, + payload: { + voice?: string; + voice_style?: string; + speech_speed?: number | string | null; + } + ) { + return this.request(`/episodes/${episodeId}/audio/segments/${segmentIndex}/retry`, { + method: 'POST', + body: payload + }); + } + + generateSubtitle(episodeId: string) { + return this.request(`/episodes/${episodeId}/subtitle/generate`, { + method: 'POST', + body: { subtitle_mode: 'dialogue' } + }); + } + + renderVideo(episodeId: string) { + return this.request(`/episodes/${episodeId}/video/render`, { + method: 'POST', + body: { include_audio: true, include_subtitle: true, prefer_ffmpeg: true, force: true } + }); + } + + listActorProfiles(projectId: string) { + return this.request(`/projects/${projectId}/live-action/actor-profiles`); + } + + generateActorProfiles(projectId: string) { + return this.request<{ actor_profiles: SafeActorProfile[]; next_step: string }>( + `/projects/${projectId}/live-action/actor-profiles/generate`, + { method: 'POST', body: {} } + ); + } + + listLiveActionShots(episodeId: string) { + return this.request(`/episodes/${episodeId}/live-action/shots`); + } + + prepareLiveActionShots(episodeId: string) { + return this.request<{ shots: SafeLiveActionShot[]; next_step: string }>( + `/episodes/${episodeId}/live-action/shots/prepare`, + { method: 'POST', body: {} } + ); + } + + generateLiveActionKeyframes(episodeId: string) { + return this.request<{ assets: SafeAsset[]; next_step: string }>( + `/episodes/${episodeId}/live-action/keyframes/generate`, + { method: 'POST', body: {} } + ); + } + + listLiveActionVideoClips(episodeId: string) { + return this.request(`/episodes/${episodeId}/live-action/video-clips`); + } + + listLiveActionVideoProviders() { + return this.request('/live-action/video-providers'); + } + + estimateLiveActionVideoCost(episodeId: string, providerCode?: string) { + const query = providerCode ? `?provider_code=${encodeURIComponent(providerCode)}` : ''; + + return this.request( + `/episodes/${episodeId}/live-action/video-clips/cost-estimate${query}` + ); + } + + preflightLiveActionVideoClips( + episodeId: string, + payload: { + provider_code?: string; + confirm_real_video?: boolean; + max_cost_per_clip?: number | string | null; + shot_id?: string; + action_beat_mode?: boolean | string; + action_beat_count?: number | string | null; + } = {} + ) { + const query = new URLSearchParams(); + + if (payload.provider_code) query.set('provider_code', payload.provider_code); + if (payload.confirm_real_video !== undefined) query.set('confirm_real_video', String(payload.confirm_real_video)); + if (payload.shot_id) query.set('shot_id', payload.shot_id); + if (payload.action_beat_mode !== undefined) query.set('action_beat_mode', String(payload.action_beat_mode)); + if (payload.action_beat_count !== undefined && payload.action_beat_count !== null && payload.action_beat_count !== '') { + query.set('action_beat_count', String(payload.action_beat_count)); + } + if (payload.max_cost_per_clip !== undefined && payload.max_cost_per_clip !== null && payload.max_cost_per_clip !== '') { + query.set('max_cost_per_clip', String(payload.max_cost_per_clip)); + } + const suffix = query.toString() ? `?${query.toString()}` : ''; + + return this.request( + `/episodes/${episodeId}/live-action/video-clips/preflight${suffix}` + ); + } + + attachLiveActionShotKeyframe(episodeId: string, shotId: string, assetId: string) { + return this.request<{ shot: SafeLiveActionShot; keyframe: SafeAsset; next_step: string }>( + `/episodes/${episodeId}/live-action/shots/${shotId}/keyframe`, + { method: 'POST', body: { asset_id: assetId } } + ); + } + + generateLiveActionShotVideoClip( + episodeId: string, + shotId: string, + payload: { + provider_code?: string; + confirm_real_video?: boolean; + force?: boolean; + max_cost_per_clip?: number | string | null; + action_beat_mode?: boolean | string; + action_beat_count?: number | string | null; + } = {} + ) { + return this.request<{ + video_clip: SafeVideoClip; + preflight: LiveActionPreflightReport; + reused: boolean; + next_step: string; + }>( + `/episodes/${episodeId}/live-action/shots/${shotId}/video-clip/generate`, + { method: 'POST', body: payload } + ); + } + + generateLiveActionVideoClips( + episodeId: string, + payload: { + provider_code?: string; + confirm_real_video?: boolean; + force?: boolean; + max_cost_per_clip?: number | string | null; + action_beat_mode?: boolean | string; + action_beat_count?: number | string | null; + } = {} + ) { + return this.request<{ video_clips: SafeVideoClip[]; next_step: string }>( + `/episodes/${episodeId}/live-action/video-clips/generate`, + { method: 'POST', body: payload } + ); + } + + retryLiveActionVideoClip( + clipId: string, + payload: { + provider_code?: string; + confirm_real_video?: boolean; + max_cost_per_clip?: number | string | null; + action_beat_mode?: boolean | string; + action_beat_count?: number | string | null; + } = {} + ) { + return this.request<{ video_clip: SafeVideoClip; next_step: string }>( + `/live-action/video-clips/${clipId}/retry`, + { method: 'POST', body: payload } + ); + } + + checkLiveActionVideoClipQuality( + clipId: string, + payload: { + auto_repair?: boolean; + min_quality_score?: number | string | null; + confirm_real_video?: boolean; + max_cost_per_clip?: number | string | null; + } = {} + ) { + return this.request( + `/live-action/video-clips/${clipId}/quality-check`, + { method: 'POST', body: payload } + ); + } + + manualReviewLiveActionVideoClip( + clipId: string, + payload: { + result_status: 'passed' | 'rejected' | 'manual_required' | 'needs_retry'; + reason?: string; + quality_score?: number | string | null; + } + ) { + return this.request<{ video_clip: SafeVideoClip; next_step: string }>( + `/live-action/video-clips/${clipId}/manual-review`, + { method: 'POST', body: payload } + ); + } + + renderLiveActionEpisode(episodeId: string) { + return this.request<{ asset: SafeAsset; reused: boolean; next_step: string }>( + `/episodes/${episodeId}/live-action/render`, + { method: 'POST', body: { force: true } } + ); + } + + listMediaAssets(episodeId: string) { + return this.request(`/episodes/${episodeId}/media-assets`); + } + + listProjectTasks(projectId: string) { + return this.request<{ tasks: SafeRenderTask[]; total: number; limit: number }>( + `/projects/${projectId}/tasks?limit=50` + ); + } + + async downloadAssetBlob(assetId: string) { + const response = await fetch(`${this.baseUrl}/assets/${assetId}/download`, { + headers: { + ...this.authHeaders(), + ...(await this.crypto.encryptionHeaders()) + } + }); + const contentType = response.headers.get('content-type') ?? ''; + + if (contentType.includes('application/json')) { + const text = await response.text(); + const rawPayload = text ? (JSON.parse(text) as unknown) : null; + const payload = rawPayload + ? await this.crypto.decryptResponse< + ApiEnvelope<{ + filename: string; + mime_type: string; + size: number; + content_base64: string; + }> + >(rawPayload) + : null; + + if (!response.ok || !payload || payload.code !== 0) { + throw new Error(payload?.message || `下载失败:HTTP ${response.status}`); + } + + const bytes = base64ToBytes(payload.data.content_base64); + const blobPart = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + + return { + blob: new Blob([blobPart], { type: payload.data.mime_type || 'application/octet-stream' }), + filename: payload.data.filename || `asset-${assetId}` + }; + } + + if (!response.ok) { + throw new Error(`下载失败:HTTP ${response.status}`); + } + + const blob = await response.blob(); + const disposition = response.headers.get('content-disposition') ?? ''; + const filename = /filename="([^"]+)"/.exec(disposition)?.[1] ?? `asset-${assetId}`; + + return { blob, filename }; + } + + private async request(path: string, options: { method?: string; body?: RequestBody } = {}) { + const isForm = options.body instanceof FormData; + const method = (options.method ?? 'GET').toUpperCase(); + const shouldSendEmptyJsonBody = + !isForm && options.body === undefined && method !== 'GET' && method !== 'HEAD'; + const bodyPayload = + !isForm && (options.body !== undefined || shouldSendEmptyJsonBody) + ? (options.body ?? {}) + : undefined; + const encryptedBody = + bodyPayload !== undefined ? await this.crypto.encryptBody(bodyPayload) : null; + const encryptedHeaders = encryptedBody?.headers ?? (await this.crypto.encryptionHeaders()); + const headers: Record = { + ...encryptedHeaders, + ...this.authHeaders(), + ...(isForm ? {} : { 'content-type': 'application/json' }) + }; + let body: BodyInit | undefined; + if (encryptedBody) { + body = JSON.stringify(encryptedBody.body); + } else if (isForm) { + body = options.body as FormData; + } else if (options.body) { + body = JSON.stringify(options.body); + } + const response = await fetch(`${this.baseUrl}${path}`, { + method, + headers, + body + }); + + const text = await response.text(); + const rawPayload = text ? (JSON.parse(text) as unknown) : null; + const payload = rawPayload + ? await this.crypto.decryptResponse>(rawPayload) + : null; + + if (!response.ok || !payload || payload.code !== 0) { + throw new Error(friendlyApiMessage(payload?.message) || `请求失败:HTTP ${response.status}`); + } + + return payload.data; + } + + private authHeaders(): Record { + return this.token ? { authorization: `Bearer ${this.token}` } : {}; + } +} diff --git a/user-app/src/api/crypto.ts b/user-app/src/api/crypto.ts new file mode 100644 index 0000000..9508253 --- /dev/null +++ b/user-app/src/api/crypto.ts @@ -0,0 +1,307 @@ +interface ApiEnvelope { + code: number; + message: string; + data: T; + request_id: string; +} + +interface ApiCryptoHandshake { + version: number; + algorithm: string; + session_id: string; + server_public_key: JsonWebKey; + salt: string; + expires_at: string; +} + +interface ClientConfig { + api_crypto_enabled: boolean; + api_crypto_mode: string; + api_crypto_session_ttl_seconds: number; +} + +interface ApiCryptoEnvelope { + encrypted?: boolean; + version: number; + session_id: string; + client_public_key?: JsonWebKey; + iv: string; + ciphertext: string; +} + +interface ApiCryptoSession { + sessionId: string; + clientPublicKey: JsonWebKey; + aesKey: CryptoKey; + expiresAt: number; +} + +export interface EncryptedUploadFilePayload { + original_name: string; + mime_type: string; + size: number; + content_base64: string; +} + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +function getWebCrypto() { + if (!globalThis.crypto?.subtle) { + throw new Error('当前浏览器不支持 API 加密所需的 WebCrypto。'); + } + + return globalThis.crypto; +} + +function bytesToBinary(bytes: Uint8Array) { + let binary = ''; + const chunkSize = 0x8000; + + for (let index = 0; index < bytes.length; index += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize)); + } + + return binary; +} + +function binaryToBytes(binary: string) { + const bytes = new Uint8Array(binary.length); + + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + + return bytes; +} + +function bytesToBase64Url(bytes: Uint8Array) { + return btoa(bytesToBinary(bytes)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); +} + +function base64UrlToBytes(value: string) { + const normalized = value.replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '='); + return binaryToBytes(atob(padded)); +} + +export function bytesToBase64(bytes: Uint8Array) { + return btoa(bytesToBinary(bytes)); +} + +export function base64ToBytes(value: string) { + return binaryToBytes(atob(value)); +} + +export async function fileToEncryptedUploadPayload(file: File): Promise { + return { + original_name: file.name, + mime_type: file.type || 'application/octet-stream', + size: file.size, + content_base64: bytesToBase64(new Uint8Array(await file.arrayBuffer())) + }; +} + +function jsonToBase64Url(value: unknown) { + return bytesToBase64Url(encoder.encode(JSON.stringify(value))); +} + +function isEncryptedEnvelope(value: unknown): value is ApiCryptoEnvelope { + if (typeof value !== 'object' || value === null) return false; + const record = value as Record; + + return ( + record.version === 1 && + typeof record.session_id === 'string' && + typeof record.iv === 'string' && + typeof record.ciphertext === 'string' + ); +} + +export class ApiCryptoClient { + private session: ApiCryptoSession | null = null; + private pendingSession: Promise | null = null; + private enabledCache: { value: boolean; expiresAt: number } | null = null; + + constructor(private readonly baseUrl: string) {} + + async encryptionHeaders() { + if (!(await this.isEnabled())) { + return {}; + } + + const session = await this.getSession(); + return this.buildHeaders(session); + } + + async encryptBody(body: unknown) { + if (!(await this.isEnabled())) { + return null; + } + + const session = await this.getSession(); + const crypto = getWebCrypto(); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + session.aesKey, + encoder.encode(JSON.stringify(body ?? null)) + ); + + return { + body: { + version: 1, + session_id: session.sessionId, + client_public_key: session.clientPublicKey, + iv: bytesToBase64Url(iv), + ciphertext: bytesToBase64Url(new Uint8Array(ciphertext)) + }, + headers: this.buildHeaders(session) + }; + } + + async isEnabled() { + const mode = this.configuredMode(); + + if (['1', 'true', 'yes', 'on'].includes(mode)) return true; + if (['0', 'false', 'no', 'off'].includes(mode)) return false; + + const now = Date.now(); + + if (this.enabledCache && this.enabledCache.expiresAt > now) { + return this.enabledCache.value; + } + + try { + const response = await fetch(`${this.baseUrl}/client-config`, { + headers: { accept: 'application/json' } + }); + const envelope = (await response.json()) as ApiEnvelope; + const value = Boolean(response.ok && envelope.code === 0 && envelope.data.api_crypto_enabled); + + this.enabledCache = { + value, + expiresAt: now + 3000 + }; + + return value; + } catch { + this.enabledCache = { + value: false, + expiresAt: now + 3000 + }; + + return false; + } + } + + async decryptResponse(payload: unknown) { + if (!isEncryptedEnvelope(payload)) { + return payload as T; + } + + const session = await this.getSession(payload.session_id); + const plaintext = await getWebCrypto().subtle.decrypt( + { name: 'AES-GCM', iv: base64UrlToBytes(payload.iv) }, + session.aesKey, + base64UrlToBytes(payload.ciphertext) + ); + + return JSON.parse(decoder.decode(plaintext)) as T; + } + + private async getSession(expectedSessionId?: string) { + const now = Date.now(); + + if ( + this.session && + this.session.expiresAt > now && + (!expectedSessionId || this.session.sessionId === expectedSessionId) + ) { + return this.session; + } + + if (expectedSessionId) { + throw new Error('API 加密会话已失效,请刷新页面后重试。'); + } + + if (!this.pendingSession) { + this.pendingSession = this.createSession().finally(() => { + this.pendingSession = null; + }); + } + + this.session = await this.pendingSession; + return this.session; + } + + private async createSession() { + const response = await fetch(`${this.baseUrl}/crypto/handshake`, { + headers: { accept: 'application/json' } + }); + const envelope = (await response.json()) as ApiEnvelope; + + if (!response.ok || envelope.code !== 0) { + throw new Error(envelope.message || `API 加密握手失败:HTTP ${response.status}`); + } + + const crypto = getWebCrypto(); + const keyPair = await crypto.subtle.generateKey( + { name: 'ECDH', namedCurve: 'P-256' }, + true, + ['deriveBits'] + ); + const serverPublicKey = await crypto.subtle.importKey( + 'jwk', + envelope.data.server_public_key, + { name: 'ECDH', namedCurve: 'P-256' }, + false, + [] + ); + const sharedBits = await crypto.subtle.deriveBits( + { name: 'ECDH', public: serverPublicKey }, + keyPair.privateKey, + 256 + ); + const hkdfKey = await crypto.subtle.importKey('raw', sharedBits, 'HKDF', false, [ + 'deriveKey' + ]); + const aesKey = await crypto.subtle.deriveKey( + { + name: 'HKDF', + hash: 'SHA-256', + salt: base64UrlToBytes(envelope.data.salt), + info: encoder.encode(`ai-manga-api-v1:${envelope.data.session_id}`) + }, + hkdfKey, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ); + const clientPublicKey = await crypto.subtle.exportKey('jwk', keyPair.publicKey); + + return { + sessionId: envelope.data.session_id, + clientPublicKey, + aesKey, + expiresAt: Date.parse(envelope.data.expires_at) - 30_000 + }; + } + + private buildHeaders(session: ApiCryptoSession) { + return { + 'x-api-encrypted': 'v1', + 'x-api-session-id': session.sessionId, + 'x-api-client-public-key': jsonToBase64Url(session.clientPublicKey) + }; + } + + private configuredMode() { + return ((import.meta.env.VITE_API_CRYPTO_ENABLED as string | undefined) || 'auto') + .trim() + .toLowerCase(); + } +} diff --git a/user-app/src/env.d.ts b/user-app/src/env.d.ts new file mode 100644 index 0000000..b82632e --- /dev/null +++ b/user-app/src/env.d.ts @@ -0,0 +1,6 @@ +declare module '*.vue' { + import type { DefineComponent } from 'vue'; + + const component: DefineComponent, Record, unknown>; + export default component; +} diff --git a/user-app/src/main.ts b/user-app/src/main.ts new file mode 100644 index 0000000..27a79bf --- /dev/null +++ b/user-app/src/main.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue'; +import App from './App.vue'; +import './styles.css'; + +createApp(App).mount('#app'); diff --git a/user-app/src/pages/auth/login.vue b/user-app/src/pages/auth/login.vue new file mode 100644 index 0000000..b8bfdcf --- /dev/null +++ b/user-app/src/pages/auth/login.vue @@ -0,0 +1,7 @@ + + + diff --git a/user-app/src/pages/help/tutorial.vue b/user-app/src/pages/help/tutorial.vue new file mode 100644 index 0000000..b0cc018 --- /dev/null +++ b/user-app/src/pages/help/tutorial.vue @@ -0,0 +1,77 @@ + + + diff --git a/user-app/src/pages/index/index.vue b/user-app/src/pages/index/index.vue new file mode 100644 index 0000000..178b80e --- /dev/null +++ b/user-app/src/pages/index/index.vue @@ -0,0 +1,3385 @@ + + + diff --git a/user-app/src/pages/projects/characters.vue b/user-app/src/pages/projects/characters.vue new file mode 100644 index 0000000..b8bfdcf --- /dev/null +++ b/user-app/src/pages/projects/characters.vue @@ -0,0 +1,7 @@ + + + diff --git a/user-app/src/pages/projects/copyright.vue b/user-app/src/pages/projects/copyright.vue new file mode 100644 index 0000000..b8bfdcf --- /dev/null +++ b/user-app/src/pages/projects/copyright.vue @@ -0,0 +1,7 @@ + + + diff --git a/user-app/src/pages/projects/create.vue b/user-app/src/pages/projects/create.vue new file mode 100644 index 0000000..b8bfdcf --- /dev/null +++ b/user-app/src/pages/projects/create.vue @@ -0,0 +1,7 @@ + + + diff --git a/user-app/src/pages/projects/episodes.vue b/user-app/src/pages/projects/episodes.vue new file mode 100644 index 0000000..b8bfdcf --- /dev/null +++ b/user-app/src/pages/projects/episodes.vue @@ -0,0 +1,7 @@ + + + diff --git a/user-app/src/pages/projects/original-setting.vue b/user-app/src/pages/projects/original-setting.vue new file mode 100644 index 0000000..b8bfdcf --- /dev/null +++ b/user-app/src/pages/projects/original-setting.vue @@ -0,0 +1,7 @@ + + + diff --git a/user-app/src/pages/projects/progress.vue b/user-app/src/pages/projects/progress.vue new file mode 100644 index 0000000..b8bfdcf --- /dev/null +++ b/user-app/src/pages/projects/progress.vue @@ -0,0 +1,7 @@ + + + diff --git a/user-app/src/pages/projects/result.vue b/user-app/src/pages/projects/result.vue new file mode 100644 index 0000000..b8bfdcf --- /dev/null +++ b/user-app/src/pages/projects/result.vue @@ -0,0 +1,7 @@ + + + diff --git a/user-app/src/pages/projects/source-select.vue b/user-app/src/pages/projects/source-select.vue new file mode 100644 index 0000000..b8bfdcf --- /dev/null +++ b/user-app/src/pages/projects/source-select.vue @@ -0,0 +1,7 @@ + + + diff --git a/user-app/src/pages/projects/story-bible.vue b/user-app/src/pages/projects/story-bible.vue new file mode 100644 index 0000000..b8bfdcf --- /dev/null +++ b/user-app/src/pages/projects/story-bible.vue @@ -0,0 +1,7 @@ + + + diff --git a/user-app/src/pages/projects/storyboard.vue b/user-app/src/pages/projects/storyboard.vue new file mode 100644 index 0000000..b8bfdcf --- /dev/null +++ b/user-app/src/pages/projects/storyboard.vue @@ -0,0 +1,7 @@ + + + diff --git a/user-app/src/pages/projects/upload-novel.vue b/user-app/src/pages/projects/upload-novel.vue new file mode 100644 index 0000000..b8bfdcf --- /dev/null +++ b/user-app/src/pages/projects/upload-novel.vue @@ -0,0 +1,7 @@ + + + diff --git a/user-app/src/pages/user/profile.vue b/user-app/src/pages/user/profile.vue new file mode 100644 index 0000000..b8bfdcf --- /dev/null +++ b/user-app/src/pages/user/profile.vue @@ -0,0 +1,7 @@ + + + diff --git a/user-app/src/pages/user/projects.vue b/user-app/src/pages/user/projects.vue new file mode 100644 index 0000000..b8bfdcf --- /dev/null +++ b/user-app/src/pages/user/projects.vue @@ -0,0 +1,7 @@ + + + diff --git a/user-app/src/styles.css b/user-app/src/styles.css new file mode 100644 index 0000000..b7030f5 --- /dev/null +++ b/user-app/src/styles.css @@ -0,0 +1,1973 @@ +:root { + color: #172033; + background: #f4f6f8; + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; + font-size: 16px; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button { + cursor: pointer; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.56; +} + +.user-shell { + min-height: 100vh; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.86), rgba(244, 246, 248, 0.96)), + #f4f6f8; +} + +.auth-screen { + align-items: center; + display: grid; + gap: 20px; + grid-template-columns: minmax(0, 1fr); + margin: 0 auto; + max-width: 1060px; + min-height: 100vh; + padding: 18px; +} + +.brand-block { + align-self: end; + min-width: 0; +} + +.brand-kicker, +.eyebrow { + color: #64748b; + display: inline-block; + font-size: 12px; + font-weight: 700; + letter-spacing: 0; + text-transform: uppercase; +} + +.brand-block h1, +.topbar h1 { + font-size: 34px; + line-height: 1.08; + margin: 8px 0 0; +} + +.brand-stats { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 18px; +} + +.tutorial-toggle { + margin-top: 14px; +} + +.auth-tutorial { + margin-top: 14px; +} + +.brand-stats span, +.workflow-strip span, +.data-row span { + background: #ffffff; + border: 1px solid #d8dee9; + border-radius: 6px; + color: #334155; + display: inline-flex; + min-height: 34px; + padding: 8px 10px; +} + +.auth-panel, +.panel { + background: #ffffff; + border: 1px solid #d8dee9; + border-radius: 8px; + box-shadow: 0 18px 50px rgba(15, 23, 42, 0.07); +} + +.auth-panel { + display: grid; + gap: 14px; + padding: 18px; +} + +.segment-control { + background: #edf2f7; + border-radius: 8px; + display: grid; + gap: 4px; + grid-template-columns: 1fr 1fr; + padding: 4px; +} + +.segment-control button, +.rail-nav button, +.episode-tabs button { + background: transparent; + border: 0; + border-radius: 6px; + color: #475569; + min-height: 40px; + padding: 9px 10px; +} + +.segment-control button.active, +.rail-nav button.active, +.episode-tabs button.active { + background: #ffffff; + color: #0f172a; + font-weight: 700; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.1); +} + +label { + color: #334155; + display: grid; + gap: 7px; + min-width: 0; +} + +label span { + font-size: 13px; + font-weight: 700; +} + +input, +select, +textarea { + background: #ffffff; + border: 1px solid #cbd5e1; + border-radius: 6px; + color: #172033; + min-height: 42px; + min-width: 0; + padding: 10px 11px; + width: 100%; +} + +textarea { + line-height: 1.55; + resize: vertical; +} + +.primary-action, +.ghost-button { + align-items: center; + border-radius: 6px; + display: inline-flex; + font-weight: 700; + justify-content: center; + min-height: 42px; + padding: 10px 13px; +} + +.primary-action { + background: #0f766e; + border: 1px solid #0f766e; + color: #ffffff; +} + +.primary-action.small, +.ghost-button.small { + min-height: 36px; + padding: 8px 10px; +} + +.ghost-button { + background: #ffffff; + border: 1px solid #cbd5e1; + color: #334155; +} + +.notice { + border-radius: 8px; + font-weight: 700; + padding: 12px 14px; +} + +.notice.danger { + background: #fff1f2; + border: 1px solid #fecdd3; + color: #be123c; +} + +.notice.success { + background: #ecfdf5; + border: 1px solid #bbf7d0; + color: #047857; +} + +.notice.guide:not(.success) { + background: #f8fafc; + border: 1px solid #d8dee9; + color: #475569; +} + +.live-video-control { + display: grid; + gap: 10px; +} + +.control-row { + align-items: end; + display: flex; + flex-wrap: wrap; + gap: 10px; + min-width: 0; +} + +.control-row label { + display: grid; + flex: 1 1 180px; + gap: 6px; + min-width: 0; +} + +.control-row select, +.control-row input { + min-height: 38px; +} + +.status-pill { + border: 1px solid currentColor; + border-radius: 999px; + display: inline-flex; + font-size: 12px; + font-weight: 700; + line-height: 1.25; + max-width: 100%; + overflow-wrap: anywhere; + padding: 5px 9px; +} + +.tone-warning { + color: #b45309; +} + +.preflight-panel { + background: rgba(255, 255, 255, 0.72); + border: 1px solid #d8dee9; + border-radius: 8px; + display: grid; + gap: 8px; + padding: 10px; +} + +.preflight-head, +.preflight-issues, +.preflight-breakdown { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 8px; + min-width: 0; +} + +.preflight-head strong, +.preflight-head small, +.preflight-breakdown span { + overflow-wrap: anywhere; +} + +.preflight-breakdown span { + background: #eef2f7; + border: 1px solid #d8dee9; + border-radius: 6px; + color: #475569; + font-size: 12px; + padding: 5px 8px; +} + +.sample-panel { + background: rgba(248, 250, 252, 0.82); + border: 1px solid #d8dee9; + border-radius: 8px; + display: grid; + gap: 12px; + padding: 12px; +} + +.sample-head { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: space-between; + min-width: 0; +} + +.sample-head h3 { + font-size: 18px; + margin: 2px 0 0; +} + +.sample-grid { + display: grid; + gap: 10px; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.sample-card { + background: #ffffff; + border: 1px solid #d8dee9; + border-radius: 8px; + display: grid; + gap: 6px; + min-width: 0; + padding: 12px; +} + +.sample-card span, +.sample-card small { + color: #64748b; + font-size: 12px; +} + +.sample-card strong, +.sample-card p, +.sample-card small { + overflow-wrap: anywhere; +} + +.sample-card p { + color: #475569; + line-height: 1.55; + margin: 0; +} + +.sample-review-reason { + display: grid; + gap: 6px; +} + +.file-button { + cursor: pointer; + position: relative; +} + +.file-button input { + inset: 0; + opacity: 0; + pointer-events: none; + position: absolute; +} + +.file-button.disabled { + cursor: not-allowed; + opacity: 0.58; +} + +.danger-button { + border-color: #fecdd3; + color: #be123c; +} + +.compact-check { + align-items: center; + display: flex !important; + flex: 0 1 auto !important; + grid-template-columns: auto 1fr; +} + +input[type="checkbox"] { + appearance: none; + background: #071426; + border: 1px solid #3b638a; + border-radius: 4px; + display: inline-grid; + flex: 0 0 auto; + height: 18px; + margin: 0; + min-height: 18px; + min-width: 18px; + padding: 0; + place-content: center; + width: 18px; +} + +input[type="checkbox"]::before { + background: #ffffff; + clip-path: polygon(14% 44%, 0 65%, 40% 100%, 100% 18%, 82% 0, 38% 62%); + content: ""; + height: 10px; + transform: scale(0); + transition: transform 0.12s ease; + width: 10px; +} + +input[type="checkbox"]:checked { + background: #2673d9; + border-color: #58a6ff; +} + +input[type="checkbox"]:checked::before { + transform: scale(1); +} + +input[type="checkbox"]:focus-visible { + outline: 2px solid rgba(88, 166, 255, 0.54); + outline-offset: 2px; +} + +.danger-check { + color: #be123c; +} + +.action-inline { + align-items: center; + background: #f0fdfa; + border: 1px solid #99f6e4; + color: #115e59; + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: space-between; +} + +.action-inline strong, +.action-inline span { + overflow-wrap: anywhere; +} + +.action-progress-toast { + background: #ffffff; + border: 1px solid #0f766e; + border-radius: 8px; + bottom: 148px; + box-shadow: 0 16px 38px rgba(15, 118, 110, 0.16); + display: grid; + gap: 10px; + left: 14px; + overflow: hidden; + padding: 12px; + position: fixed; + right: 14px; + z-index: 32; +} + +.action-progress-toast h2 { + color: #0f766e; + font-size: 20px; + line-height: 1.2; + margin: 2px 0 6px; +} + +.action-progress-toast p { + color: #334155; + line-height: 1.55; + margin: 0 0 6px; + overflow-wrap: anywhere; +} + +.action-progress-toast strong { + color: #0f766e; +} + +.action-progress-bar { + background: #ccfbf1; + border-radius: 999px; + height: 5px; + overflow: hidden; +} + +.action-progress-bar span { + animation: progress-slide 1.15s ease-in-out infinite; + background: #0f766e; + border-radius: inherit; + display: block; + height: 100%; + width: 42%; +} + +@keyframes progress-slide { + 0% { + transform: translateX(-110%); + } + + 100% { + transform: translateX(250%); + } +} + +.next-guide-toast { + align-items: start; + background: #ffffff; + border-color: #ef4444; + border-radius: 8px; + border-style: solid; + border-width: 1px; + bottom: auto; + box-shadow: + inset 0 0 0 1px #ef4444, + 0 16px 36px rgba(220, 38, 38, 0.12); + display: grid; + gap: 8px; + grid-template-columns: minmax(0, 1fr) auto; + left: auto; + max-height: 96px; + overflow: hidden; + padding: 8px 10px; + position: fixed; + bottom: 108px; + right: 14px; + top: auto; + width: min(340px, calc(100vw - 28px)); + z-index: 28; +} + +.next-guide-toast > div { + min-width: 0; + overflow: hidden; +} + +.next-guide-toast .eyebrow { + display: none; +} + +.next-guide-toast h2 { + color: #b91c1c; + display: -webkit-box; + font-size: 13px; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + line-height: 1.3; + margin: 0 0 2px; + overflow: hidden; + overflow-wrap: anywhere; +} + +.next-guide-toast p { + color: #334155; + display: -webkit-box; + font-size: 12px; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + line-height: 1.35; + margin: 0; + overflow: hidden; + overflow-wrap: anywhere; +} + +.next-guide-close { + align-self: start; + background: #fff1f2; + border-color: #fecdd3; + color: #b91c1c; + flex-shrink: 0; + min-height: 32px; + padding: 6px 9px; + white-space: nowrap; +} + +.app-layout { + display: grid; + grid-template-columns: 1fr; + margin: 0 auto; + max-width: 1280px; + min-height: 100vh; +} + +.app-rail { + background: #ffffff; + border-bottom: 1px solid #d8dee9; + bottom: 0; + display: grid; + gap: 10px; + left: 0; + padding: 10px; + position: fixed; + right: 0; + z-index: 20; +} + +.rail-brand { + display: none; +} + +.rail-nav { + display: grid; + gap: 6px; + grid-template-columns: repeat(auto-fit, minmax(48px, 1fr)); +} + +.rail-nav button { + min-height: 42px; + padding: 6px 4px; + white-space: nowrap; +} + +.app-main { + display: grid; + gap: 14px; + min-width: 0; + padding: 14px 14px 156px; +} + +.tutorial-page { + margin: 0 auto; + max-width: 980px; + padding-bottom: 24px; +} + +.topbar { + align-items: center; + display: flex; + gap: 12px; + justify-content: space-between; + min-width: 0; +} + +.topbar h1 { + font-size: 24px; + overflow-wrap: anywhere; +} + +.topbar-actions, +.button-pair, +.data-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + min-width: 0; +} + +.panel, +.studio-stack { + display: grid; + gap: 14px; +} + +.tutorial-stack { + display: grid; + gap: 14px; +} + +.panel { + padding: 14px; +} + +.panel-heading { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 12px; + justify-content: space-between; + min-width: 0; +} + +.panel-heading h2 { + font-size: 19px; + line-height: 1.2; + margin: 2px 0 0; +} + +.form-grid { + display: grid; + gap: 12px; + grid-template-columns: 1fr; +} + +.wide { + grid-column: 1 / -1; +} + +.file-line { + align-items: center; + background: #f8fafc; + border: 1px dashed #cbd5e1; + border-radius: 8px; + display: flex; + min-height: 54px; + padding: 10px; +} + +.project-list, +.card-grid, +.chapter-preview-list, +.asset-list, +.render-step-list, +.task-table, +.shot-list { + display: grid; + gap: 10px; + min-width: 0; +} + +.project-card, +.mini-card, +.text-card, +.asset-row, +.render-step-card, +.task-row, +.shot-card { + background: #f8fafc; + border: 1px solid #d8dee9; + border-radius: 8px; + color: inherit; + display: grid; + gap: 6px; + min-width: 0; + padding: 12px; + text-align: left; +} + +.project-card.active { + border-color: #0f766e; + box-shadow: inset 0 0 0 1px #0f766e; +} + +.project-card span, +.mini-card span, +.asset-row span, +.render-step-card span, +.task-row span, +.shot-card span, +.project-card small, +.asset-row small, +.task-row small, +.text-card small { + color: #64748b; + font-size: 12px; +} + +.project-card strong, +.mini-card strong, +.text-card strong, +.asset-row strong, +.render-step-card strong, +.task-row strong, +.shot-card strong { + min-width: 0; + overflow-wrap: anywhere; +} + +.mini-card p, +.text-card p, +.render-step-card p, +.shot-card p { + color: #475569; + line-height: 1.55; + margin: 0; + overflow-wrap: anywhere; +} + +.character-card { + align-content: start; +} + +.character-card-top { + align-items: start; + display: grid; + gap: 10px; + grid-template-columns: minmax(0, 1fr); +} + +.character-anchor-line { + align-items: center; + background: #fff7ed; + border: 1px solid #fed7aa; + border-radius: 6px; + color: #9a3412; + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: space-between; + min-height: 38px; + padding: 8px 10px; +} + +.character-anchor-line.ready { + background: #ecfdf5; + border-color: #bbf7d0; + color: #047857; +} + +.character-anchor-line small { + color: inherit; + opacity: 0.78; +} + +.character-working-line { + background: #f0fdfa; + border: 1px solid #99f6e4; + border-radius: 6px; + color: #0f766e; + display: grid; + gap: 3px; + padding: 8px 10px; +} + +.character-working-line span { + font-weight: 800; +} + +.character-working-line small { + color: #115e59; + line-height: 1.4; +} + +.candidate-images { + border-top: 1px solid #d8dee9; + padding-top: 8px; +} + +.candidate-images summary { + color: #0f766e; + cursor: pointer; + font-size: 13px; + font-weight: 700; +} + +.candidate-image-list { + display: grid; + gap: 0; + margin-top: 8px; +} + +.candidate-image-card { + align-items: center; + border-top: 1px solid #e2e8f0; + display: grid; + gap: 8px; + grid-template-columns: minmax(0, 1fr); + padding: 9px 0; +} + +.candidate-image-card:first-child { + border-top: 0; +} + +.candidate-image-card strong, +.candidate-image-card small { + display: block; + overflow-wrap: anywhere; +} + +.chapter-preview-list { + max-height: 420px; + overflow: auto; +} + +.render-step-card { + align-items: center; + grid-template-columns: minmax(0, 1fr) auto; +} + +.render-step-card p { + font-size: 13px; +} + +.timeline-panel { + background: #f8fafc; + border: 1px solid #d8dee9; + border-radius: 8px; + display: grid; + gap: 10px; + padding: 12px; +} + +.timeline-heading { + align-items: start; + display: grid; + gap: 10px; + grid-template-columns: minmax(0, 1fr) auto; +} + +.timeline-heading h3 { + font-size: 16px; + margin: 2px 0 4px; +} + +.timeline-heading p { + color: #64748b; + font-size: 13px; + line-height: 1.5; + margin: 0; +} + +.timeline-list { + display: grid; + gap: 8px; +} + +.timeline-list.compact { + margin-top: 8px; + max-height: 260px; + overflow: auto; +} + +.timeline-row { + background: #ffffff; + border: 1px solid #d8dee9; + border-radius: 8px; + display: grid; + gap: 5px; + padding: 10px; +} + +.timeline-row.danger { + border-color: #ef4444; + box-shadow: inset 3px 0 0 #ef4444; +} + +.timeline-row span, +.timeline-row small { + color: #64748b; + font-size: 12px; +} + +.timeline-row strong, +.timeline-row p { + min-width: 0; + overflow-wrap: anywhere; +} + +.timeline-row p { + color: #334155; + line-height: 1.5; + margin: 0; +} + +.timeline-warning { + color: #b91c1c !important; + font-weight: 800; +} + +.timeline-retry-form { + background: #f1f5f9; + border: 1px solid #cbd5e1; + border-radius: 8px; + display: grid; + gap: 8px; + grid-template-columns: minmax(0, 1fr) minmax(96px, 0.35fr); + padding: 10px; +} + +.timeline-retry-form label { + display: grid; + gap: 5px; + min-width: 0; +} + +.timeline-retry-form label span { + color: #64748b; + font-size: 12px; + font-weight: 800; +} + +.timeline-retry-form .wide { + grid-column: 1 / -1; +} + +.subtitle-cue-preview summary { + color: #0f766e; + cursor: pointer; + font-size: 13px; + font-weight: 800; +} + +.asset-preview-panel, +.asset-preview-modal { + background: #f8fafc; + border: 1px solid #d8dee9; + border-radius: 8px; + display: grid; + gap: 10px; + padding: 12px; +} + +.asset-preview-backdrop { + align-items: center; + background: rgba(15, 23, 42, 0.42); + display: flex; + inset: 0; + justify-content: center; + padding: 14px; + position: fixed; + z-index: 36; +} + +.asset-preview-modal { + background: #ffffff; + max-height: calc(100vh - 28px); + max-width: 980px; + overflow: auto; + width: min(100%, 980px); +} + +.asset-preview-modal .panel-heading p { + color: #64748b; + font-size: 12px; + margin: 4px 0 0; + overflow-wrap: anywhere; +} + +.asset-preview-panel img, +.asset-preview-panel video, +.asset-preview-modal img, +.asset-preview-modal video { + background: #0f172a; + border-radius: 8px; + max-height: 68vh; + object-fit: contain; + width: 100%; +} + +.asset-preview-panel audio, +.asset-preview-modal audio { + width: 100%; +} + +.tutorial-hero { + background: #ffffff; +} + +.tutorial-hero p { + color: #475569; + line-height: 1.6; + margin: 0; +} + +.tutorial-card { + align-content: start; + min-height: 128px; +} + +.tutorial-steps { + display: grid; + gap: 10px; +} + +.tutorial-step { + background: #f8fafc; + border: 1px solid #d8dee9; + border-radius: 8px; + display: grid; + gap: 10px; + grid-template-columns: 36px minmax(0, 1fr); + padding: 12px; +} + +.tutorial-step > span { + align-items: center; + background: #0f766e; + border-radius: 6px; + color: #ffffff; + display: inline-flex; + font-weight: 800; + height: 30px; + justify-content: center; + width: 30px; +} + +.tutorial-step strong { + display: block; + margin-bottom: 4px; +} + +.tutorial-step p { + color: #475569; + line-height: 1.6; + margin: 0; + overflow-wrap: anywhere; +} + +.progress-panel { + align-items: center; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; +} + +.quota-inline { + align-items: start; + grid-template-columns: 1fr; +} + +.quota-inline > div:first-child { + min-width: 0; +} + +.quota-inline h2 { + font-size: 19px; + line-height: 1.2; + margin: 2px 0 0; + overflow-wrap: anywhere; +} + +.quota-inline .button-pair { + align-self: stretch; + justify-content: flex-start; +} + +.quota-numbers { + display: grid; + gap: 8px; + grid-template-columns: repeat(auto-fit, minmax(112px, 1fr)); + min-width: 0; +} + +.quota-board { + display: grid; + gap: 8px; + grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); + min-width: 0; +} + +.quota-numbers span, +.quota-board article, +.estimate-list article, +.estimate-total { + background: #f8fafc; + border: 1px solid #d8dee9; + border-radius: 8px; + display: grid; + gap: 4px; + min-width: 0; + padding: 10px; + overflow-wrap: anywhere; +} + +.quota-board span, +.estimate-list span, +.estimate-total span { + color: #64748b; + font-size: 12px; +} + +.quota-board strong, +.estimate-total strong { + color: #0f766e; + font-size: 24px; +} + +.estimate-list { + display: grid; + gap: 8px; +} + +.estimate-list article, +.estimate-total { + align-items: center; + grid-template-columns: minmax(0, 1fr) auto; +} + +.package-card.recommended { + border-color: #0f766e; + box-shadow: inset 0 0 0 1px #0f766e; +} + +.package-card small { + color: #64748b; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.package-card .primary-action { + width: 100%; +} + +.progress-copy { + min-width: 0; +} + +.progress-meter { + background: #e2e8f0; + border-radius: 999px; + grid-column: 1 / -1; + height: 10px; + overflow: hidden; +} + +.progress-meter span { + background: linear-gradient(90deg, #0f766e, #2563eb); + display: block; + height: 100%; + transition: width 180ms ease; +} + +.workflow-strip { + display: grid; + gap: 8px; + grid-template-columns: repeat(5, minmax(0, 1fr)); +} + +.workflow-strip span { + justify-content: center; + min-width: 0; + overflow: hidden; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; +} + +.workflow-strip span.done { + background: #ecfdf5; + border-color: #99f6e4; + color: #0f766e; +} + +.workflow-strip span.active { + background: #eff6ff; + border-color: #bfdbfe; + color: #1d4ed8; + font-weight: 800; +} + +.episode-tabs { + display: flex; + gap: 8px; + overflow-x: auto; + padding-bottom: 2px; +} + +.episode-tabs button { + background: #edf2f7; + flex: 0 0 auto; + min-width: 92px; +} + +.asset-row { + align-items: center; + grid-template-columns: minmax(92px, 0.55fr) minmax(140px, 1fr) minmax(160px, 1.15fr); +} + +.asset-row .button-pair { + grid-column: 1 / -1; +} + +.task-row { + grid-template-columns: minmax(0, 1fr) auto; +} + +.task-row small { + grid-column: 1 / -1; + overflow-wrap: anywhere; +} + +.progress-summary { + align-items: center; + display: grid; + gap: 8px; + grid-template-columns: auto 1fr 1fr; +} + +.progress-summary strong { + color: #0f766e; + font-size: 28px; +} + +.progress-summary span { + background: #f8fafc; + border: 1px solid #d8dee9; + border-radius: 8px; + padding: 10px; + text-align: center; +} + +.tone-success { + color: #047857; +} + +.tone-danger { + color: #be123c; +} + +.tone-running { + color: #1d4ed8; +} + +.tone-muted { + color: #64748b; +} + +.video-preview { + aspect-ratio: 9 / 16; + background: #0f172a; + border-radius: 8px; + max-height: 72vh; + width: 100%; +} + +.profile-grid { + display: grid; + gap: 10px; + grid-template-columns: auto minmax(0, 1fr); +} + +.profile-grid span { + color: #64748b; +} + +.profile-grid strong { + min-width: 0; + overflow-wrap: anywhere; +} + +.empty-state { + align-items: center; + background: #f8fafc; + border: 1px dashed #cbd5e1; + border-radius: 8px; + color: #64748b; + display: flex; + justify-content: center; + min-height: 78px; + padding: 14px; +} + +@media (min-width: 720px) { + .auth-screen { + grid-template-columns: minmax(0, 1.1fr) minmax(340px, 0.9fr); + padding: 32px; + } + + .brand-block { + align-self: center; + } + + .brand-block h1 { + font-size: 54px; + } + + .auth-panel { + align-self: center; + padding: 24px; + } + + .app-layout { + grid-template-columns: 220px minmax(0, 1fr); + } + + .app-rail { + align-content: start; + border-bottom: 0; + border-right: 1px solid #d8dee9; + min-height: 100vh; + padding: 18px; + position: sticky; + top: 0; + } + + .rail-brand { + display: grid; + gap: 4px; + margin-bottom: 8px; + min-width: 0; + } + + .rail-brand span { + color: #64748b; + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .rail-nav { + grid-template-columns: 1fr; + } + + .rail-nav button { + justify-content: flex-start; + padding: 10px 12px; + text-align: left; + } + + .app-main { + padding: 22px 22px 126px; + } + + .character-card-top, + .candidate-image-card { + grid-template-columns: minmax(0, 1fr) auto; + } + + .next-guide-toast { + bottom: 22px; + left: 242px; + max-height: min(42vh, 220px); + padding: 12px; + right: 22px; + top: auto; + width: auto; + } + + .next-guide-toast .eyebrow { + display: inline-block; + line-height: 1.2; + } + + .next-guide-toast h2 { + font-size: 18px; + -webkit-line-clamp: 2; + margin: 2px 0 4px; + } + + .next-guide-toast p { + font-size: 14px; + -webkit-line-clamp: 2; + } + + .action-progress-toast { + bottom: 132px; + left: 242px; + right: 22px; + } + + .topbar h1 { + font-size: 30px; + } + + .panel { + padding: 18px; + } + + .form-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .card-grid { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); + } + + .workflow-strip { + grid-template-columns: repeat(10, minmax(0, 1fr)); + } + + .quota-inline { + align-items: center; + grid-template-columns: minmax(120px, 0.65fr) minmax(260px, 1fr) minmax(176px, auto); + } + + .quota-inline .button-pair { + justify-content: flex-end; + } +} + +@media (min-width: 1080px) { + .studio-stack { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .progress-panel, + .quota-inline, + .workflow-strip { + grid-column: 1 / -1; + } + + .quota-inline { + grid-template-columns: minmax(160px, 0.55fr) minmax(360px, 1fr) minmax(220px, auto); + } + + .quota-inline .button-pair { + flex-wrap: nowrap; + } + + .card-grid { + grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); + } +} + +@media (max-width: 640px) { + .next-guide-toast { + display: none; + } + + .topbar h1 { + font-size: 18px; + line-height: 1.28; + } + + .asset-row { + grid-template-columns: 1fr; + } + + .sample-grid { + grid-template-columns: 1fr; + } + + .timeline-heading { + grid-template-columns: 1fr; + } + + .timeline-retry-form { + grid-template-columns: 1fr; + } +} + +@media (max-width: 420px) { + .topbar { + align-items: stretch; + flex-direction: column; + } + + .topbar-actions, + .button-pair { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .control-row { + display: grid; + grid-template-columns: 1fr; + } + + .button-pair button, + .topbar-actions button { + width: 100%; + } + + .workflow-strip { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +:root { + color: #edf6ff; + background: #06101b; +} + +body { + background: #06101b; +} + +.user-shell { + background: + radial-gradient(circle at top left, rgba(47, 131, 238, 0.16), transparent 34%), + linear-gradient(180deg, #08111d 0%, #050914 100%); + color: #edf6ff; +} + +.auth-screen, +.app-layout { + max-width: 1320px; +} + +.brand-kicker, +.eyebrow { + color: #78a9ff; +} + +.brand-block h1, +.topbar h1, +.panel-heading h2, +.tutorial-step strong, +.project-card strong, +.mini-card strong, +.text-card strong, +.asset-row strong, +.render-step-card strong, +.task-row strong, +.shot-card strong { + color: #f7fbff; +} + +.brand-block p, +.tutorial-hero p, +.panel-heading p, +.mini-card p, +.text-card p, +.render-step-card p, +.shot-card p, +.timeline-heading p, +.timeline-row p, +.next-guide-toast p, +.action-progress-toast p { + color: #b6c6d9; +} + +.auth-panel, +.panel, +.tutorial-hero, +.app-rail, +.asset-preview-modal, +.asset-preview-panel, +.next-guide-toast, +.action-progress-toast { + background: rgba(10, 19, 33, 0.95); + border-color: #244163; + box-shadow: 0 18px 42px rgba(0, 0, 0, 0.24); + color: #e8f1fb; +} + +.app-rail { + border-color: #1e3552; +} + +.rail-brand span, +.project-card span, +.mini-card span, +.asset-row span, +.render-step-card span, +.task-row span, +.shot-card span, +.project-card small, +.asset-row small, +.task-row small, +.text-card small, +.package-card small, +.profile-grid span, +.timeline-row span, +.timeline-row small, +.quota-board span, +.estimate-list span, +.estimate-total span, +.asset-preview-modal .panel-heading p { + color: #95a9bf; +} + +.brand-stats span, +.workflow-strip span, +.data-row span, +.project-card, +.mini-card, +.text-card, +.asset-row, +.render-step-card, +.task-row, +.shot-card, +.timeline-panel, +.timeline-row, +.timeline-retry-form, +.tutorial-step, +.quota-numbers span, +.quota-board article, +.estimate-list article, +.estimate-total, +.progress-summary span, +.file-line, +.empty-state { + background: rgba(12, 27, 47, 0.84); + border-color: #294c72; + color: #dcecff; +} + +.project-card.active, +.package-card.recommended, +.pattern-option.selected { + border-color: #4ea1ff; + box-shadow: inset 0 0 0 1px #4ea1ff; +} + +.pattern-picker { + display: grid; + gap: 12px; +} + +.pattern-picker-head, +.pattern-chip-list { + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: space-between; +} + +.pattern-picker-head strong { + color: #f4f9ff; + display: block; + margin-top: 4px; +} + +.pattern-grid { + display: grid; + gap: 10px; + grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); +} + +.pattern-option, +.pattern-chip { + background: rgba(12, 27, 47, 0.84); + border: 1px solid #294c72; + border-radius: 8px; + color: #dcecff; + display: grid; + gap: 6px; + min-height: 118px; + padding: 12px; + text-align: left; +} + +.pattern-option span, +.pattern-option small, +.pattern-chip span, +.pattern-chip small, +.pattern-picker-head small, +.form-hint { + color: #95a9bf; + font-size: 12px; + line-height: 1.45; +} + +.pattern-option em { + display: flex; + flex-wrap: wrap; + font-style: normal; + gap: 6px; +} + +.pattern-option i { + background: #10243b; + border: 1px solid #315a83; + border-radius: 999px; + color: #b9d7ff; + font-size: 11px; + font-style: normal; + padding: 3px 7px; +} + +.pattern-chip-list { + justify-content: flex-start; +} + +.pattern-chip { + min-height: 0; + width: min(100%, 280px); +} + +.segment-control { + background: #071426; + border: 1px solid #244163; +} + +.segment-control button, +.rail-nav button, +.episode-tabs button { + color: #b7c7d9; +} + +.segment-control button.active, +.rail-nav button.active, +.episode-tabs button.active { + background: #10243b; + color: #ffffff; + box-shadow: inset 0 0 0 1px #315a83; +} + +input, +select, +textarea { + background: #081527; + border-color: #2d5278; + color: #f4f9ff; +} + +input::placeholder, +textarea::placeholder { + color: #697f98; +} + +label, +label span { + color: #c7d7e9; +} + +.primary-action { + background: #2673d9; + border-color: #3287f4; + color: #ffffff; +} + +.ghost-button, +.next-guide-close { + background: #0d1c31; + border-color: #2d5278; + color: #dcecff; +} + +.ghost-button:hover, +.primary-action:hover, +.next-guide-close:hover { + border-color: #58a6ff; +} + +.notice.success, +.action-inline, +.character-working-line, +.character-anchor-line.ready, +.workflow-strip span.done { + background: rgba(20, 83, 45, 0.42); + border-color: #22c55e; + color: #d1fae5; +} + +.notice.danger, +.timeline-row.danger, +.character-anchor-line, +.next-guide-toast { + background: rgba(127, 29, 29, 0.28); + border-color: #ef4444; + color: #fee2e2; +} + +.next-guide-toast { + background: rgba(10, 19, 33, 0.98); +} + +.notice.guide:not(.success) { + background: rgba(12, 27, 47, 0.84); + border-color: #294c72; + color: #c7d7e9; +} + +.live-action-panel .live-video-control, +.live-action-panel .preflight-panel, +.live-action-panel .sample-panel, +.live-action-panel .sample-card { + background: rgba(10, 19, 33, 0.78); + border-color: #244163; + color: #e8f1fb; +} + +.live-action-panel .sample-card p, +.live-action-panel .sample-card span, +.live-action-panel .sample-card small, +.live-action-panel .preflight-head small { + color: #a9bdd3; +} + +.live-action-panel .preflight-breakdown span { + background: rgba(10, 22, 38, 0.86); + border-color: #315679; + color: #a9bdd3; +} + +.live-action-panel .file-button { + background: #0d1c31; + border-color: #2d5278; + color: #dcecff; +} + +.workflow-strip span.active { + background: rgba(47, 131, 238, 0.22); + border-color: #4ea1ff; + color: #b9d7ff; +} + +.progress-meter, +.action-progress-bar { + background: #14243a; +} + +.progress-meter span, +.action-progress-bar span { + background: linear-gradient(90deg, #2f8cff, #62db75); +} + +.quota-board strong, +.estimate-total strong, +.progress-summary strong, +.action-progress-toast h2, +.action-progress-toast strong, +.candidate-images summary, +.subtitle-cue-preview summary, +.character-working-line span { + color: #7ee58e; +} + +.tone-success { + color: #7ee58e; +} + +.tone-danger, +.timeline-warning { + color: #fca5a5 !important; +} + +.tone-warning { + color: #facc15 !important; +} + +.tone-running { + color: #78a9ff; +} + +.tone-muted { + color: #95a9bf; +} + +.app-dark .preflight-panel { + background: rgba(10, 19, 33, 0.78); + border-color: #244163; +} + +.app-dark .sample-panel, +.app-dark .sample-card { + background: rgba(10, 19, 33, 0.78); + border-color: #244163; +} + +.app-dark .sample-card p, +.app-dark .sample-card span, +.app-dark .sample-card small { + color: #a9bdd3; +} + +.app-dark .preflight-breakdown span { + background: rgba(10, 22, 38, 0.86); + border-color: #315679; + color: #a9bdd3; +} + +.candidate-images, +.candidate-image-card { + border-color: #244163; +} + +.asset-preview-backdrop { + background: rgba(2, 6, 12, 0.72); +} + +.video-preview, +.asset-preview-panel img, +.asset-preview-panel video, +.asset-preview-modal img, +.asset-preview-modal video { + background: #050914; + border: 1px solid #244163; +} + +.studio-stack.studio-flow { + grid-template-columns: minmax(0, 1fr); + margin: 0 auto; + max-width: 1120px; + width: 100%; +} + +.studio-stack.studio-flow > .panel, +.studio-stack.studio-flow > .workflow-strip { + grid-column: 1; +} + +.studio-flow [data-studio-step] { + scroll-margin-top: 20px; +} + +.studio-flow .form-grid { + grid-template-columns: minmax(0, 1fr); +} diff --git a/user-app/src/workflow.ts b/user-app/src/workflow.ts new file mode 100644 index 0000000..06d1e12 --- /dev/null +++ b/user-app/src/workflow.ts @@ -0,0 +1,147 @@ +import type { MediaAssetRow, SafeProject, SafeRenderTask } from './api/client'; + +export const projectStatusLabels: Record = { + source_selecting: '选择来源', + novel_generating: '原创小说', + novel_uploaded: '文本就绪', + copyright_pending: '版权确认', + copyright_confirmed: '版权已确认', + text_parsing: '文本解析', + text_parse_failed: '解析失败', + story_bible_generating: '故事圣经', + waiting_story_confirm: '待确认故事', + story_confirmed: '故事已确认', + character_extracting: '角色抽取', + character_generating: '角色生成', + waiting_character_confirm: '待确认角色', + character_confirmed: '角色已确认', + episode_planning: '分集计划', + waiting_episode_confirm: '待确认分集', + episode_confirmed: '分集已确认', + script_generating: '脚本生成', + waiting_script_confirm: '待确认脚本', + script_confirmed: '脚本已确认', + storyboard_generating: '分镜生成', + waiting_storyboard_confirm: '待确认分镜', + storyboard_confirmed: '分镜已确认', + preview_images_generated: '预览图完成', + final_images_generated: '正式图完成', + audio_generated: '音频完成', + subtitle_generated: '字幕完成', + video_rendered: '视频完成', + actor_profile_generated: '演员定妆', + live_action_shots_prepared: '真人分镜', + live_action_keyframes_generated: '关键帧', + live_action_clips_generated: '视频片段', + live_action_video_rendered: '真人短剧', + completed: '已完成', + manual_required: '人工介入', + failed: '失败', + cancelled: '已取消', + archived: '已归档' +}; + +export const taskTypeLabels: Record = { + novel_generate: '小说生成', + novel_parse: '小说解析', + story_bible_generate: '故事圣经', + character_extract: '角色抽取', + episode_plan_generate: '分集计划', + script_generate: '单集脚本', + storyboard_generate: '分镜脚本', + character_image_generate: '角色图', + shot_image_generate: '分镜图', + audio_generate: '多角色音频', + subtitle_generate: '字幕', + video_render: '视频合成', + live_action_keyframe_generate: '真人关键帧', + live_action_video_clip_generate: '真人视频片段', + live_action_video_render: '真人短剧合成', + qc_check: '质检', + manual_review: '人工审核' +}; + +export const workflowSteps = [ + { key: 'source', label: '来源' }, + { key: 'copyright', label: '版权' }, + { key: 'story', label: '故事' }, + { key: 'characters', label: '角色' }, + { key: 'memory', label: '记忆' }, + { key: 'episodes', label: '分集' }, + { key: 'script', label: '脚本' }, + { key: 'storyboard', label: '分镜' }, + { key: 'images', label: '图片' }, + { key: 'media', label: '音频字幕' }, + { key: 'result', label: '成品' } +] as const; + +const statusStepIndex: Record = { + source_selecting: 0, + novel_generating: 0, + novel_uploaded: 1, + copyright_pending: 1, + copyright_confirmed: 1, + text_parsing: 1, + story_bible_generating: 2, + waiting_story_confirm: 2, + story_confirmed: 3, + character_extracting: 3, + character_generating: 3, + waiting_character_confirm: 3, + character_confirmed: 4, + episode_planning: 5, + waiting_episode_confirm: 5, + episode_confirmed: 6, + script_generating: 6, + waiting_script_confirm: 6, + script_confirmed: 7, + storyboard_generating: 7, + waiting_storyboard_confirm: 7, + storyboard_confirmed: 8, + character_image_generated: 8, + preview_images_generated: 8, + final_images_generated: 9, + audio_generated: 9, + subtitle_generated: 9, + video_rendered: 10, + actor_profile_generated: 4, + live_action_shots_prepared: 8, + live_action_keyframes_generated: 8, + live_action_clips_generated: 9, + live_action_video_rendered: 10, + completed: 10 +}; + +export function labelProjectStatus(status?: string | null) { + return status ? projectStatusLabels[status] ?? status : '未开始'; +} + +export function labelTaskType(taskType: string) { + return taskTypeLabels[taskType] ?? taskType; +} + +export function taskStatusTone(status: string) { + if (status === 'success') return 'success'; + if (status === 'failed' || status === 'manual_required') return 'danger'; + if (status === 'running' || status === 'retrying') return 'running'; + return 'muted'; +} + +export function progressOf(project: SafeProject | null, tasks: SafeRenderTask[], mediaAssets: MediaAssetRow[]) { + if (!project) return 0; + const baseIndex = statusStepIndex[project.status] ?? 0; + const base = Math.round((baseIndex / (workflowSteps.length - 1)) * 100); + const successfulTasks = tasks.filter((task) => task.status === 'success').length; + const taskBonus = Math.min(12, successfulTasks * 2); + const hasVideo = mediaAssets.some((row) => row.asset.asset_type === 'video'); + + return Math.min(100, Math.max(base, hasVideo ? 100 : base + taskBonus)); +} + +export function formatBytes(size: string | null) { + const value = Number(size ?? 0); + if (!Number.isFinite(value) || value <= 0) return '-'; + if (value < 1024) return `${value} B`; + if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`; + return `${(value / 1024 / 1024).toFixed(1)} MB`; +} diff --git a/user-app/tsconfig.json b/user-app/tsconfig.json new file mode 100644 index 0000000..3b77dea --- /dev/null +++ b/user-app/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "module": "ESNext", + "jsx": "preserve", + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "types": [ + "vite/client", + "vitest" + ], + "allowImportingTsExtensions": true, + "noEmit": true + }, + "include": [ + "src/**/*.ts", + "src/**/*.vue", + "vite.config.ts" + ] +} diff --git a/user-app/vite.config.ts b/user-app/vite.config.ts new file mode 100644 index 0000000..e722762 --- /dev/null +++ b/user-app/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; + +export default defineConfig({ + plugins: [vue()], + server: { + host: '0.0.0.0', + port: Number(process.env.USER_APP_PORT ?? 5174) + } +}); diff --git a/workers/package.json b/workers/package.json new file mode 100644 index 0000000..c2a6e2d --- /dev/null +++ b/workers/package.json @@ -0,0 +1,17 @@ +{ + "name": "workers", + "version": "0.1.0", + "private": true, + "scripts": { + "start": "node dist/main.js", + "start:dev": "tsx watch src/main.ts", + "build": "tsc -p tsconfig.build.json", + "lint": "tsc --noEmit -p tsconfig.json", + "typecheck": "tsc --noEmit -p tsconfig.json", + "test": "vitest run" + }, + "dependencies": { + "bullmq": "^5.77.6", + "ioredis": "^5.11.0" + } +} diff --git a/workers/src/main.spec.ts b/workers/src/main.spec.ts new file mode 100644 index 0000000..ce131b2 --- /dev/null +++ b/workers/src/main.spec.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { getWorkerStatus } from './main'; + +describe('getWorkerStatus', () => { + it('defaults to mock mode', () => { + expect(getWorkerStatus()).toMatchObject({ + service: 'queue-worker', + mode: 'mock', + backend: 'bullmq' + }); + expect(getWorkerStatus().queues).toContainEqual({ + name: 'image_queue', + processor: 'backend_delegate' + }); + }); +}); diff --git a/workers/src/main.ts b/workers/src/main.ts new file mode 100644 index 0000000..57caf28 --- /dev/null +++ b/workers/src/main.ts @@ -0,0 +1,174 @@ +import { Worker, type ConnectionOptions, type Job } from 'bullmq'; + +const QUEUE_NAMES = [ + 'novel_queue', + 'parse_queue', + 'story_queue', + 'character_queue', + 'episode_queue', + 'script_queue', + 'storyboard_queue', + 'image_queue', + 'audio_queue', + 'subtitle_queue', + 'video_queue', + 'qc_queue', + 'review_queue', + 'analytics_queue' +] as const; + +type QueueName = (typeof QUEUE_NAMES)[number]; + +interface WorkerTaskPayload { + task_id?: string; + project_id?: string; + task_type?: string; + retry_count?: number; +} + +interface WorkerRuntime { + backendUrl: string; + workerSecret: string; + redisUrl: string; + concurrency: number; +} + +function sanitizeRedisUrl(redisUrl: string) { + try { + const parsed = new URL(redisUrl); + + if (parsed.username) parsed.username = '***'; + if (parsed.password) parsed.password = '***'; + + return parsed.toString(); + } catch { + return 'redis://127.0.0.1:6379'; + } +} + +function runtimeFromEnv(): WorkerRuntime { + return { + backendUrl: trimTrailingSlash(process.env.WORKER_BACKEND_URL || 'http://127.0.0.1:3000/api'), + workerSecret: process.env.WORKER_SECRET || 'local_worker_secret_change_me', + redisUrl: process.env.REDIS_URL || 'redis://127.0.0.1:6379', + concurrency: normalizeConcurrency(process.env.WORKER_CONCURRENCY) + }; +} + +export function getWorkerStatus() { + const runtime = runtimeFromEnv(); + + return { + service: 'queue-worker', + mode: process.env.AI_PROVIDER_MODE ?? 'mock', + backend: 'bullmq', + backend_url: runtime.backendUrl, + redis_url: sanitizeRedisUrl(runtime.redisUrl), + concurrency: runtime.concurrency, + queues: QUEUE_NAMES.map((name) => ({ + name, + processor: 'backend_delegate' + })) + }; +} + +export function startWorkers(runtime: WorkerRuntime = runtimeFromEnv()) { + const connection = createConnectionOptions(runtime.redisUrl); + const workers = QUEUE_NAMES.map( + (queueName) => + new Worker( + queueName, + (job) => executeJob(queueName, job as Job, runtime), + { + connection, + concurrency: runtime.concurrency + } + ) + ); + + return { + workers, + async close() { + await Promise.all(workers.map((worker) => worker.close())); + } + }; +} + +async function executeJob(queueName: QueueName, job: Job, runtime: WorkerRuntime) { + const taskId = job.data.task_id; + + if (!taskId) { + throw new Error('Worker job missing task_id'); + } + + const response = await fetch(`${runtime.backendUrl}/internal/worker/tasks/${encodeURIComponent(taskId)}/execute`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-worker-secret': runtime.workerSecret + }, + body: JSON.stringify({ + job_id: job.id, + queue_name: queueName, + retry_count: job.data.retry_count ?? 0 + }) + }); + const payload = (await response.json().catch(() => null)) as + | { code?: number; message?: string; data?: unknown } + | null; + + if (!response.ok || !payload || payload.code !== 0) { + throw new Error(payload?.message || `Backend worker execution failed: HTTP ${response.status}`); + } + + return payload.data; +} + +function createConnectionOptions(redisUrl: string): ConnectionOptions { + try { + const parsed = new URL(redisUrl); + const db = parsed.pathname ? Number(parsed.pathname.slice(1)) : 0; + + return { + host: parsed.hostname || '127.0.0.1', + port: parsed.port ? Number(parsed.port) : 6379, + username: parsed.username || undefined, + password: parsed.password || undefined, + db: Number.isInteger(db) ? db : 0 + }; + } catch { + return { + host: '127.0.0.1', + port: 6379 + }; + } +} + +function trimTrailingSlash(value: string) { + return value.replace(/\/+$/, ''); +} + +function normalizeConcurrency(value: string | undefined) { + const numberValue = Number(value ?? 2); + + return Number.isInteger(numberValue) && numberValue >= 1 && numberValue <= 20 ? numberValue : 2; +} + +if (require.main === module) { + const runner = startWorkers(); + + // eslint-disable-next-line no-console + console.log(JSON.stringify(getWorkerStatus(), null, 2)); + + const shutdown = async () => { + await runner.close(); + process.exit(0); + }; + + process.on('SIGINT', () => { + void shutdown(); + }); + process.on('SIGTERM', () => { + void shutdown(); + }); +} diff --git a/workers/tsconfig.build.json b/workers/tsconfig.build.json new file mode 100644 index 0000000..f625323 --- /dev/null +++ b/workers/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "exclude": [ + "src/**/*.spec.ts", + "src/**/*.test.ts" + ] +} diff --git a/workers/tsconfig.json b/workers/tsconfig.json new file mode 100644 index 0000000..a8daf6f --- /dev/null +++ b/workers/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "module": "CommonJS", + "outDir": "dist", + "rootDir": "src", + "types": [ + "node" + ] + }, + "include": [ + "src/**/*.ts" + ] +}