feat: expand novel IP and production workflows

This commit is contained in:
www
2026-09-18 08:14:05 +02:00
parent b2ae4600b4
commit d9c81a3ac0
235 changed files with 117971 additions and 2721 deletions
+6
View File
@@ -37,6 +37,12 @@ admin/storage/
user-app/storage/ user-app/storage/
workers/storage/ workers/storage/
novels/*
# Local working data and generated quality-control artifacts
data/
tmp/
# Generated archives / local exports # Generated archives / local exports
*.zip *.zip
*.tar *.tar
+754 -768
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+879
View File
@@ -0,0 +1,879 @@
# AI 内容生产平台项目现状 V1
> 文档类型:项目状态回灌 / 技术审计快照
> 审计时间:2026-07-15Europe/Berlin
> 审计对象:`/www/wwwroot/ai` 当前工作区、当前 MySQL 数据库、当前 systemd 运行服务
> 文档用途:作为 Work 的“代码现实”基线,不替代产品设计文档,也不替代 Git 版本记录。
## 0. 审计口径
本文件按以下优先级判断项目真实状态:
1. 当前工作区源码与配置结构。
2. Prisma schema、迁移记录和数据库只读统计。
3. 当前 systemd、Nginx、MySQL、Redis 运行状态。
4. 构建与自动测试结果。
5. `README.md``CURRENT_ARCHITECTURE.md``CODEX_PROGRESS.md``docs/system_a``docs/system_b` 等历史文档。
状态标记:
| 标记 | 含义 |
| --- | --- |
| 已实现且验证 | 代码存在,当前可构建,并有运行数据、测试或实际调用记录佐证 |
| 已实现但验证不足 | 主体代码已存在,但自动测试、运行配置或完整 E2E 尚未达到发布基线 |
| 部分接入 | 只有部分入口、部分 Provider 或部分流程可用 |
| 仅设计/预留 | 有文档、数据结构或界面占位,但未形成完整可用链路 |
注意:本文中的 V1 是“状态文档版本”,不是 npm 包版本。当前各 workspace 的代码版本仍为 `0.1.0`,不应仅凭功能变多就直接对外宣布产品 V5.0。
## 1. 执行摘要
### 1.1 当前项目真实定位
当前系统已经不再只是“AI 小说系统”或“AI 漫剧系统”,实际代码覆盖:
- 小说导入、阅读、原创生成、章节质量循环与版本管理。
- 故事圣经、世界观、角色、剧情记忆与 IP 资产管理。
- 分集、剧本、分镜、关键帧、图生视频和候选片段选择。
- 真人/仿真人短剧生产、原生音频视频、字幕、BGM、SFX、转场与 FFmpeg 合成。
- AI Provider 目录、模型偏好、成本记录、路由、实验台与回退。
- 队列、任务重试、人工介入、额度、审核、运营后台和审计日志。
- 面向生产的独立创作工具,当前已开放“人物三视图”。
因此,面向 Work 的项目名称建议使用:
```text
AI 内容生产平台
```
### 1.2 当前健康度
| 检查项 | 当前结论 |
| --- | --- |
| 后端 API | `ai-backend.service` 正在运行,健康检查返回 `ok` |
| 队列 Worker | `ai-workers.service` 正在运行,消费 14 个 BullMQ 队列 |
| 数据库 | MySQL 正在运行,33 个 Prisma 迁移全部已部署 |
| Redis | 正在运行,Worker 与 API 均配置为使用 Redis |
| 前台/后台 | Vue 3 + Vite 生产构建通过,Nginx 直接托管静态产物 |
| 全仓构建 | 通过:backend、admin、user-app、workers 均成功构建 |
| 自动测试 | 未通过:后端 273 条测试中 238 通过、35 失败;Worker 1 条通过;前台和后台无测试文件 |
| Git 基线 | 高风险:`main` 仅 1 个提交,当前有 85 个修改项、47 个未跟踪项 |
| 运行版本一致性 | 需处理:审计构建产物晚于 backend/worker 进程启动时间,当前进程尚未加载最新构建 |
| 存储 | 当前为本机私有存储;MinIO 代码已实现但当前未完整配置 |
| AI 默认模式 | 全局 `AI_PROVIDER_MODE=mock`,但数据库中有大量真实 Provider 可被显式选择 |
### 1.3 结论
当前项目已经是一个有真实数据、真实调用和真实视频资产的内部生产平台,不是原型空壳;但它还不是可安全标记为“稳定生产版”的发布基线。
当前最重要的工作顺序应为:
1. 固化 Git 与数据库备份基线。
2. 修复测试和状态枚举漂移。
3. 对齐当前构建与运行进程。
4. 补齐队列任务执行覆盖。
5. 再继续扩展新功能。
## 2. 当前项目实际架构
### 2.1 仓库形态
项目为 npm workspaces 单仓库:
```text
ai/
├── backend/ NestJS API、Prisma、业务编排、Provider、FFmpeg 调用
├── workers/ BullMQ Worker,消费队列后委托 backend 执行业务
├── user-app/ Vue 3 + Vite 用户端 H5
├── admin/ Vue 3 + Vite 运营管理后台
├── deploy/ 开发依赖与 Nginx 示例,生产脚本尚不完整
├── docs/ 原系统 A/B、AI 生产经验、教程和成本资料
├── data/ 项目内容、剧本、分镜与生成过程资料
├── storage/ 当前本地私有素材存储
└── tmp/ 临时处理文件
```
### 2.2 运行拓扑
```mermaid
flowchart TB
User[用户端 Vue H5] --> Nginx[Nginx]
Admin[管理端 Vue H5] --> Nginx
Nginx --> API[NestJS API :3010]
API --> MySQL[(MySQL ai_manga)]
API --> Redis[(Redis)]
API --> Storage[本地私有存储\nMinIO 可选]
API --> Providers[AI Providers]
API --> FFmpeg[FFmpeg / FFprobe]
API --> BullMQ[BullMQ 14 队列]
BullMQ --> Worker[独立 Worker\n并发 2]
Worker --> InternalAPI[受 WORKER_SECRET 保护的内部接口]
InternalAPI --> API
```
### 2.3 后端基础设施
- 框架:NestJS 10。
- ORMPrisma 6MySQL。
- 队列:BullMQ 5 + Redis。
- 文件:本地私有文件为当前主存储,MinIO 为可选实现。
- 媒体:FFmpeg、FFprobe。
- 文件解析:PDF、DOCX、纯文本。
- 鉴权:JWT。
- 后台权限:`admin``operator``finance``auditor` 角色与细粒度 permission。
- API 前缀:`/api`
- 请求体上限:当前 160 MB。
- 中间件:请求 ID、安全响应头、HTTPS/代理判断、应用层加密请求处理。
- API 返回:统一 envelope 和统一异常过滤。
### 2.4 当前部署事实
- 后端由 `ai-backend.service` 托管。
- Worker 由 `ai-workers.service` 托管。
- Nginx 分别托管用户端与管理端,并反向代理 `/api/`
- MySQL、Redis 为本机服务。
- 当前 `NODE_ENV=production``STORAGE_DRIVER=local`、Worker 并发为 2。
- 后端没有强制 `HTTPS_REQUIRED`;传输安全依赖 Nginx 与部署配置。
- `PUBLIC_ASSET_BASE_URL` 类配置当前未设置,依赖外部 Provider 获取临时素材 URL 的链路需要专项复核。
## 3. 已实现模块
### 3.1 后端模块
当前 `AppModule` 注册的业务模块:
| 模块 | 主要职责 | 状态 |
| --- | --- | --- |
| `AuthModule` | 注册、登录、JWT、账号状态 | 已实现且验证 |
| `UsersModule` | 用户资料 | 已实现 |
| `BillingModule` | 额度、订单、冻结、释放、后台人工调整 | 已实现但支付仅为 mock |
| `ProjectsModule` | 项目、作品库、小说库、创意模式、流水线配置 | 已实现 |
| `ProviderLabModule` | Provider 参数试跑与状态轮询 | 已实现 |
| `AssetsModule` | 上传、下载、预览、Range 流、别名、选择状态 | 已实现且有大量真实素材 |
| `NovelsModule` | 导入、解析、阅读、原创小说、Agent 与章节质量流程 | 已实现但测试不足 |
| `StoryBiblesModule` | 故事圣经、制作规则、确认锁定 | 已实现 |
| `CharactersModule` | 项目角色、全局角色、版本、状态、Prompt 审核与优化经验 | 已实现但复杂度较高 |
| `MemoriesModule` | 剧情、角色、伏笔与连续性记忆 | 已实现但测试桩未同步 |
| `EpisodesModule` | 分集计划、请求预览、质量闸门 | 已实现但质量测试有漂移 |
| `ScriptsModule` | 单集剧本、分镜、动态时长、提示词、转场 | 已实现但测试不足 |
| `ImagesModule` | 角色图、锚点图、三视图、关键帧、视觉质检 | 已实现 |
| `LiveActionModule` | 真人短剧准备、关键帧、视频、QC、合并、后期 | 已实现但仍处于生产化磨合 |
| `MediaModule` | TTS、字幕、传统漫剧合成、音频片段重试 | 已实现 |
| `ProvidersModule` | Provider 目录、配置、调用、日志、成本、异步视频轮询 | 已实现且有真实调用 |
| `QueuesModule` | RenderTask、BullMQ、重试、取消、恢复、Worker 委托 | 已实现但执行覆盖不完整 |
| `ReviewsModule` | 文本/素材审核、返修、案例授权 | 已实现但当前数据库尚无审核记录 |
| `AdminModule` | 运营后台聚合 API、审计、配置、Router 审计 | 已实现 |
`AiRouterModule` 没有作为顶层模块单独注册,但被 `LiveActionModule` 导入并实际用于真人视频路由。当前它不是覆盖全平台所有 AI 请求的统一 Router。
### 3.2 用户端模块
用户端实际为 Vue 3 单页应用,不是旧文档描述的 uni-app 多路由实现。
顶级导航:
- 创作:新建、提示词直出、API 快测。
- 工具:独立创作工具。
- 项目:项目列表、制作台、IP 资产中心、进度。
- 作品:视频、小说、素材。
- 任务:运行任务、审核、额度与成本。
- 我的:账号、角色资产、教程。
制作台主流程:
```text
来源 -> 版权 -> 故事圣经 -> 角色 -> 记忆 -> 分集
-> 剧本/分镜 -> 真人视频 -> 合成 -> 审核
```
独立创作工具当前状态:
| 工具 | 状态 |
| --- | --- |
| 人物三视图 | 已开放;支持主锚点参考或输入本次唯一角色描述;生成后可下载和视觉质检 |
| 影视场景专家 | 待接入 |
| 9 宫格分镜 | 待接入 |
| 16 宫格分镜 | 待接入 |
| 25 宫格分镜 | 待接入 |
| 原创剧本 | 待接入 |
虽然 `user-app/src/pages/` 中保留了多页面文件,但当前 `App.vue` 实际只加载大型 `pages/index/index.vue`,没有 Vue Router。多数业务都集中在一个约 1.35 万行的页面组件中。
### 3.3 管理端模块
管理端实际为自研 Vue 3 单页后台,不是完整 GeekerAdmin 集成。
当前菜单包括:
- 仪表盘、使用教程。
- 项目、小说、全局角色、项目角色、分镜、成品。
- 订单额度、内容审核、任务。
- Router 审计、爆款诊断、AI 平台入口。
- Provider、成本、用户、素材。
- 系统配置、版权、审计日志。
后台同样集中在一个约 1.03 万行的 `App.vue` 中,没有实际 Router、Store 和拆分后的 Views。
## 4. 已完成功能与完成度
| 业务域 | 当前能力 | 完成度判断 |
| --- | --- | --- |
| 账号与安全 | 注册、登录、JWT、账号禁用、密码重置、RBAC、操作审计、应用层加密 | 已实现且验证 |
| 项目 | 新建、列表、删除、取消、进度、作品库、提示词直出分镜 | 已实现 |
| 小说导入 | 粘贴、文件上传、章节切割、编辑、版权确认 | 已实现 |
| 小说阅读 | 目录、分页/翻页、阅读进度、书签、批注 | 已实现 |
| 原创小说 | 创作向导、生成计划、章节 Agent、上下文、质检、修复、版本、批次 | 已实现但自动测试未稳定 |
| 故事与世界观 | StoryBible、WorldBible、制作文本和锁定 | 已实现 |
| 角色资产 | 项目角色、全局演员、外观版本、状态、服装、声音、授权范围 | 已实现 |
| 角色提示词闭环 | Prompt 版本、审核、优化经验、下一次生成吸收 | 已实现 |
| 人物三视图 | 主锚点模式、纯描述覆盖模式、旗舰模板、下载、视觉评分 | 已实现 |
| IP 资产中心 | 场景/道具提取、主资产、版本、Prompt、冲突检查 | 已实现但 UI/测试仍在磨合 |
| 长篇记忆 | 剧情记忆、角色记忆、伏笔线程、连续性检查 | 已实现但测试未同步 |
| 分集 | 动态数量、质量闸门、请求预览与 Prompt 覆盖 | 已实现但当前一条质量测试失败 |
| 剧本 | 剧本生成、人工编辑、确认、请求预览 | 已实现 |
| 分镜 | 动态时长、关键帧/视频 Prompt、前后镜衔接、转场字段、视觉板 | 已实现 |
| 关键帧 | 角色/场景/道具参考、单镜/批量、视觉审核 | 已实现且有真实图片调用 |
| 视频 | 单镜/批量、多候选、首帧/首尾帧/参考图、原生音频 Provider | 已实现且有真实视频调用 |
| 视频 QC | 自动评分、同模型重试、Fallback、人工确认、候选选择 | 已实现但测试和策略仍在变化 |
| 视频合并 | 硬切/转场、画面归一、源音轨、字幕、BGM、SFX、FFmpeg | 已实现但产品质量仍需持续验收 |
| 原创音乐 | 音乐圣经、MusicProvider、BGM 对齐、资产包 | 部分接入,默认关闭 |
| 口型 | LipSync Provider 抽象、策略与桥接 | 部分接入;当前真实 LipSync Provider 全部禁用 |
| 素材库 | 类型、别名、分镜号/范围、选中/候选/淘汰、Range 播放、下载 | 已实现 |
| 队列 | 14 队列、任务幂等、重试、取消、过期恢复、人工介入 | 已实现但部分任务类型无 Worker 执行器 |
| 成本 | Provider 成本规则、调用日志、人民币展示、额度冻结/释放 | 已实现;财务口径仍需统一 |
| 支付 | 套餐、订单模型与 mock-pay | 仅测试,不是生产支付 |
| 内容审核 | 文本、素材、后台复核、案例授权 | API/UI 已实现,但当前 `ContentReview` 为 0 条 |
| 爆款诊断 | 样本、片段、创意模式、项目绑定 | 已实现,属于原设计后的扩展 |
## 5. 数据库结构
### 5.1 总体规模
- Prisma Model61 个。
- Prisma Enum0 个。
- 数据库迁移:33 个,当前全部已部署。
- 索引、唯一约束和 JSON 字段大量使用。
- 业务状态主要使用字符串字段,而不是数据库枚举。
### 5.2 模型分组
#### 用户与项目
- `User`
- `UserModelPreference`
- `Project`
- `ProjectPipelineConfig`
- `ProjectCreativePattern`
- `CreativePattern`
#### 小说与 Agent
- `NovelSource`
- `NovelChapter`
- `NovelReadingProgress`
- `NovelBookmark`
- `NovelAnnotation`
- `NovelGenerationPlan`
- `NovelChapterVersion`
- `NovelContextMemory`
- `NovelQualityReport`
- `NovelVersionSnapshot`
- `NovelDerivativeJob`
- `AgentPrompt`
- `AgentRun`
#### 故事、版权与世界观
- `CopyrightRecord`
- `StoryBible`
- `WorldBible`
#### 角色与 IP 资产
- `Character`
- `StoryCharacter`
- `CharacterExtractionVersion`
- `GlobalCharacter`
- `GlobalCharacterAsset`
- `GlobalCharacterLookVersion`
- `CharacterImage`
- `CharacterImageQualityReview`
- `CharacterMemory`
- `CharacterDesignVersion`
- `CharacterPromptVersion`
- `CharacterPromptReview`
- `CharacterPromptOptimizationLesson`
- `CharacterState`
- `ActorProfile`
- `ProjectVisualAsset`
#### 剧集生产
- `Episode`
- `EpisodeScript`
- `StoryboardShot`
- `ShotImage`
- `VideoClip`
- `PlotMemory`
- `PlotThread`
- `ContinuityCheck`
#### 素材、任务与 Provider
- `Asset`
- `RenderTask`
- `ProviderConfig`
- `ProviderLog`
#### 计费、审核与运营
- `Order`
- `QuotaAccount`
- `QuotaLog`
- `RevisionRequest`
- `ContentReview`
- `CaseShowcase`
- `HitAnalysisCase`
- `HitAnalysisSegment`
- `AnalyticsEvent`
- `SystemConfig`
- `OperationLog`
### 5.3 当前数据库数据快照
| 数据 | 数量 |
| --- | ---: |
| 用户 | 2 |
| 项目 | 20 |
| 小说源 | 10 |
| 小说章节 | 256 |
| 故事圣经 | 13 |
| 项目角色 | 87 |
| 全局角色 | 14 |
| 分集 | 87 |
| 单集剧本 | 21 |
| 分镜 | 114 |
| 素材 | 977 |
| 任务 | 748 |
| Provider 配置 | 112 |
| Provider 调用日志 | 1002 |
| 内容审核记录 | 0 |
| 操作审计记录 | 44 |
素材构成:
| 类型 | 数量 |
| --- | ---: |
| image | 600 |
| video_clip | 215 |
| video | 74 |
| audio | 50 |
| subtitle | 33 |
| 其他角色图、片段音频、成品类型 | 5 |
任务状态:
| 状态 | 数量 |
| --- | ---: |
| success | 646 |
| failed | 77 |
| manual_required | 22 |
| pending | 2 |
| completed | 1 |
`completed` 不在当前 `TASK_STATUSES` 常量中,属于历史状态漂移,需要迁移或兼容处理。
## 6. API 接口现状
### 6.1 总量
- Controller25 个。
- HTTP 方法装饰器:289 个。
- API 全局前缀:`/api`
- 当前没有生成 OpenAPI/Swagger 规范,接口事实依赖 Controller、DTO 与前端 client。
### 6.2 分组
| 接口域 | 代表能力 | 路由规模 |
| --- | --- | ---: |
| Admin | 仪表盘、项目、用户、素材、作品、Provider、成本、审计、Router | 47 |
| Characters | 项目角色、全局角色、版本、状态、IP 资产、Prompt、声音 | 47 |
| Projects | 项目、库、阅读入口、创意模式、流水线配置 | 28 |
| Live Action | 角色档案、关键帧、视频、QC、候选、渲染、音乐 | 21 |
| Novel Generation | 生成计划、IP 圣经、章节、批次、暂停恢复 | 17 |
| Providers | 目录、配置、执行、日志、成本、偏好 | 16 |
| Billing | 套餐、订单、额度、冻结/释放、后台调整 | 13 |
| Scripts | 剧本、分镜、请求预览、确认、重生 | 12 |
| Memories | 剧情、角色、伏笔、上下文、连续性 | 11 |
| Images | 角色图、关键帧、锚点、视觉质检 | 9 |
| Queues | 创建、列表、重试、取消、恢复、统计 | 9 |
| Reviews | 文本/素材审核、案例授权、后台复核 | 9 |
| Novels / Wizard / Original | 导入、解析、原创、创作向导 | 16 |
| Assets | 上传、预览、Range 下载、临时 URL | 7 |
| Auth / Crypto | 登录注册、用户信息、加密会话 | 7 |
| Episodes / StoryBible / Media | 分集、故事圣经、音频字幕视频 | 14 |
### 6.3 安全边界
- 业务接口主要使用 JWT Guard。
- 管理能力在 Service 层执行 permission 检查。
- Worker 内部接口使用 `WORKER_SECRET` 和 timing-safe 比较。
- 生产环境缺失 Worker secret 时不会使用本地默认值。
- 素材为私有存储,支持鉴权下载、Range 请求和带签名的临时 URL。
- API Key 经服务端密钥加密存储,后台不回显明文。
## 7. 前后端目录结构
### 7.1 后端
```text
backend/src/
├── admin/
├── ai-router/
├── assets/
├── auth/
├── billing/
├── characters/
├── common/
├── config/
├── episodes/
├── images/
├── live-action/
├── media/
├── memories/
├── novels/
├── prisma/
├── projects/
├── provider-lab/
├── providers/
├── queues/
├── reviews/
├── scripts/
├── story-bibles/
└── users/
```
当前后端约 9.39 万行 TypeScript。最大文件:
- `live-action.service.ts`:约 1.42 万行。
- `characters.service.ts`:约 7443 行。
- `providers.service.ts`:约 6776 行。
- `admin.service.ts`:约 6096 行。
- `scripts.service.ts`:约 4600 行。
### 7.2 用户端
```text
user-app/src/
├── api/
├── components/tools/CreativeTools.vue
├── pages/index/index.vue
├── pages/auth/
├── pages/help/
├── pages/projects/
├── pages/user/
├── App.vue
├── workflow.ts
└── styles.css
```
当前约 2.37 万行,主要功能集中在 `index.vue``api/client.ts` 和全局 CSS。
### 7.3 管理端
```text
admin/src/
├── api/client.ts
├── api/crypto.ts
├── App.vue
├── main.ts
└── styles.css
```
`router/``stores/``views/` 当前只有占位文件。管理端约 1.34 万行。
### 7.4 Worker
```text
workers/src/main.ts
```
每个队列由 BullMQ Worker 消费,任务本体通过内部 HTTP 接口委托后端执行,Worker 本身不重复实现业务逻辑。
## 8. 当前 AI 调用流程
### 8.1 通用 Provider 流程
```mermaid
flowchart LR
Input[业务输入] --> Preference[用户/项目模型偏好]
Preference --> Config[ProviderConfig]
Config --> Limit[启停、优先级、成本上限]
Limit --> Execute[ProvidersService]
Execute --> Sync[同步文本/图片/音频]
Execute --> Async[异步视频提交与轮询]
Sync --> Log[ProviderLog]
Async --> Log
Log --> Asset[Asset / VideoClip]
Log --> Task[RenderTask 状态与成本]
Execute --> Fallback[失败回退或人工介入]
```
### 8.2 Provider 现状
- Provider 配置共 112 个。
- Mock 配置 11 个,其中 10 个启用、1 个 LipSync mock 关闭。
- Real 配置 101 个,其中 64 个启用、37 个关闭。
- 当前已启用的真实 Provider 覆盖 Text、Novel、Image、Video、Voice、Music、Embedding、Moderation。
- 真实 LipSync Provider 当前全部关闭。
- 代表性已启用模型族:OpenAI、豆包/火山、DeepSeek、Qwen、Kimi、智谱、MiniMax、Kling、Seedance、Seedream、Sora 等。
- 全局默认仍为 mock,显式选择真实 Provider 时可产生真实调用。
Provider 日志当前共 1002 条:
| 状态 | 数量 |
| --- | ---: |
| success | 825 |
| failed | 170 |
| running | 5 |
| cancelled | 2 |
数据库 `cost_actual` 原始累计值为 354.9416。由于 Provider 成本币种和历史规则可能不同,该值只能用于技术对账,不能直接视为财务账单。
近期真实使用主要集中在:
- `openai-image`
- `kling-v3-native-audio-720p-video`
- `openai-responses-text`
- `volcengine_seedance_20_mini`
- `volcengine-seedream-50-image`
- `openai-tts`
### 8.3 真人短剧流程
```text
剧本/外部提示词
-> 动态分镜与前后镜关系
-> 角色/场景/道具资产计划
-> Prompt Engine
-> 关键帧
-> 视频 Provider 预检与成本估算
-> 单镜或批量视频、多候选
-> 自动 QC / 重试 / Fallback / 人工选择
-> Scene Composer 计划
-> 字幕 / 源音轨 / TTS / BGM / SFX / 转场
-> FFmpeg 合成
-> 成品与素材库
```
当前支持的后期参数包括:源音轨、额外音频、字幕、BGM、SFX、环境音、无源音轨回退 SFX、口型、字幕模式和音量控制。原创音乐与 Scene Composer 由项目配置控制,默认并非全部开启。
### 8.4 小说流程
```text
创作 Brief / 导入小说
-> 生成计划
-> IP/故事/世界观规则
-> Agent Prompt
-> 章节草稿
-> 质量报告
-> 自动修复或重写
-> 章节版本与上下文记忆
-> 小说快照
-> 听书/短剧派生任务
```
小说新引擎代码已形成,但目前自动测试和管理体验还没有达到可无人值守批量生产的程度。
## 9. 队列任务流程
### 9.1 队列与任务
BullMQ 队列共 14 个:
```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
```
任务类型共 21 个,状态包括:
```text
pending / running / success / failed / retrying
cancelled / manual_required / skipped
```
### 9.2 执行链
```mermaid
flowchart LR
API[API 创建 RenderTask] --> Idempotency[输入哈希 / 幂等键]
Idempotency --> Queue[BullMQ 入队]
Queue --> Worker[独立 Worker]
Worker --> Secret[WORKER_SECRET]
Secret --> Delegate[backend internal/worker execute]
Delegate --> Provider[通用 Provider 执行]
Delegate --> Live[真人视频业务执行]
Provider --> Result[日志 / 素材 / 状态]
Live --> Result
Result --> Retry[按类型自动重试]
Retry --> Manual[耗尽后 manual_required]
```
已实现:
- 幂等键与输入哈希。
- 不同任务类型的默认重试次数。
- 任务取消与队列 Job 清理。
- stale running 任务恢复。
- 失败后自动重试与人工介入。
- 队列统计。
### 9.3 当前队列覆盖缺口
任务目录比 Worker 执行器更宽。以下任务在当前 `providerTypeForTask` 中没有实际 Provider 映射,也不属于真人业务执行器,走通用 Worker 时会被标记为 `skipped`
- `long_memory_generate`
- `subtitle_generate`
- `live_action_keyframe_generate`
- `live_action_video_render`
- `analytics_event`
其中部分能力目前由同步 Service 方法直接执行,因此功能本身不一定不可用;但“全部已队列化”的说法不准确。`video_render` 在通用任务映射中指向 `VideoProvider`,与 FFmpeg 合成语义也需要重新核对。
## 10. 已实现但未完整写入原设计文档的功能
相较 `docs/system_a``docs/system_b` 的早期设计,当前代码额外出现或显著深化了:
1. 小说创作向导、章节 Agent、上下文构建、质量修复、版本快照与派生任务。
2. 小说阅读器、书签、批注、阅读进度。
3. 全局角色资产、外观版本、角色状态、角色 Prompt 版本与审核经验闭环。
4. 人物三视图独立工具,以及“主锚点参考/本次描述覆盖”两种模式。
5. 三视图视觉质量评分、问题提取、优化经验进入下一次生成。
6. 项目级场景/道具/IP 资产中心和主资产版本。
7. 动态分镜时长、前后镜衔接、转场字段、请求预览与 Prompt 人工覆盖。
8. 真人短剧 AI Router、镜头评分、成本预检、候选视频与自动质量闭环。
9. Kling 原生音频、Seedance、Seedream、Hailuo、Sora 等多模型目录。
10. Scene Composer、字幕/BGM/SFX/环境音开关、源音轨策略和原创音乐包。
11. 素材别名、选中/候选/淘汰状态、按分镜号和范围联合筛选。
12. Provider Lab、模型偏好、人民币成本标签和 Provider 调用审计。
13. 爆款诊断、拉片片段、创意模式和项目模式绑定。
14. API 应用层加密、临时素材签名 URL、视频 Range 流式播放。
## 11. 与原设计文档的主要差异
| 领域 | 原设计世界 | 当前代码现实 |
| --- | --- | --- |
| 产品定位 | 系统 A 小说转漫剧 + 系统 B 真人写真 | 已合并演化为小说、短剧、视频、角色/IP 资产、创作工具平台 |
| 用户端 | uni-app 多页面 | Vue 3 + Vite H5,核心集中在单一大页面 |
| 管理端 | GeekerAdmin | 自研 Vue 单页后台,未引入完整 GeekerAdmin 架构 |
| 数据库 | 约 20 个核心表 | 61 个 Model、33 个迁移 |
| Provider | 抽象层与少量真实模型 | 112 个配置,覆盖大量国内外文本/图像/视频/声音模型 |
| AI Router | 后续路由能力 | 已用于真人视频,但未成为全平台统一入口 |
| 小说 | 原创 mock + 导入改编 | 已加入 Agent、质量循环、版本、阅读器和派生任务 |
| 分镜 | 固定镜头/宫格倾向 | 动态时长、上下镜关系、首尾帧/多图、转场与声音层 |
| 视频 | 图片、TTS、字幕、简单合成 | 真人视频候选、QC、原生音频、Scene Composer、复杂 FFmpeg 后期 |
| 队列 | 所有生产任务统一异步 | Worker 已运行,但部分声明任务没有异步执行器,部分流程仍同步 |
| 存储 | MinIO 优先设计 | 当前实际使用本地私有存储,MinIO 未完整配置 |
| 支付 | 订单支付闭环 | 额度与 mock-pay 可用,真实支付未接入 |
| 审核 | 内容审核闭环 | API/UI 已有,但数据库没有实际审核记录 |
| 测试 | 文档定义生产验收 | 后端单测有覆盖但当前 35 条失败,前后端无自动测试 |
| 版本管理 | 设计文档持续推进 | Git 只有初始提交,大量现实代码尚未形成版本基线 |
## 12. 当前待优化问题
### P0:必须先处理
#### 12.1 Git 无法代表当前系统
- `main` 只有 1 个提交。
- 当前有 85 个修改项、47 个未跟踪项。
- 多个数据库迁移、小说新引擎、Provider Lab、人物三视图、数据与文档都未纳入提交。
风险:服务器故障、误操作或换机后,无法从 Git 恢复当前系统。
建议:先做数据库与素材备份,再把当前状态拆成可审阅提交;不要把生成素材和临时文件混入源码提交。
#### 12.2 测试未达到发布基线
后端结果:
```text
Test Files: 19 passed / 8 failed
Tests: 238 passed / 35 failed
```
失败主要分为:
1. 新增 Prisma 依赖后,旧测试 mock 没有补齐。
2. 动态分镜、质量闸门、BGM/SFX 策略改变后,旧期望未更新。
3. Provider 成本标签新增人民币后,断言仍使用旧文案。
4. Seedance 真人参考图预检策略出现测试与实现不一致。
应逐条判断“测试旧了”还是“代码回归”,不能简单批量改断言。
#### 12.3 当前运行进程未加载最新构建
审计期间全仓构建通过,但 backend 和 worker 进程启动时间早于最新构建产物。需要在备份、测试和变更确认后执行受控重启,并做健康检查与核心链路冒烟测试。
### P1:高优先级
#### 12.4 队列执行覆盖不完整
补齐或明确移出队列:长篇记忆、字幕、真人关键帧、真人合并、分析事件。避免任务显示已入队,Worker 最后却标记 `skipped`
#### 12.5 状态字段漂移
数据库中存在历史 `completed`,当前代码只认 `success`。项目、任务、素材、审核等大量状态均为自由字符串。建议建立状态字典、迁移脚本和兼容测试。
#### 12.6 本地素材是单机风险点
当前 977 个素材使用本地私有存储,MinIO 未完整配置,部署目录中也没有可执行备份脚本。至少需要:
- 数据库定时备份。
- `storage/private` 增量备份。
- 恢复演练。
- 磁盘容量与失败告警。
#### 12.7 任务和调用失败积压
- RenderTask77 failed、22 manual_required、2 pending。
- ProviderLog170 failed、5 running。
需要区分历史测试垃圾、外部异步任务和真实待处理任务,并提供批量归档/恢复策略。
#### 12.8 Provider 配置过多且默认策略不清晰
数据库中有 112 个 Provider 配置,但全局默认仍是 mock。真实 Provider 启停、用户偏好、项目偏好、Router 和显式 provider_code 同时存在,容易出现“页面选了 A,实际走 B”的理解成本。
建议为每次调用固定记录并展示:请求 Provider、路由原因、实际 Provider、模型、参数、输入参考图、成本和回退链。
#### 12.9 外部 Provider 素材 URL 配置需复核
当前公开素材基础 URL 未设置,而部分第三方视频/图片 Provider 需要可访问的临时参考图 URL。应把此项纳入 Provider preflight,而不是运行到提交时才失败。
#### 12.10 审核闭环尚未实际使用
`ContentReview` 当前为 0 条。需要确认是入口未触发、审核结果写到了其他字段,还是生产流程绕过了正式审核。
### P2:结构性优化
#### 12.11 超大文件与前端单体化
- `live-action.service.ts` 约 1.42 万行。
- 用户端主页面约 1.35 万行。
- 管理端主页面约 1.03 万行。
这会放大回归风险和协作冲突。建议按稳定业务边界逐步拆分,不做一次性大重构。
#### 12.12 缺少前后端自动测试与正式 E2E
用户端、管理端目前没有测试文件。后端以单元测试为主,没有覆盖“登录 -> 项目 -> 分镜 -> 真实 Provider -> 素材 -> 合并”的稳定 E2E。
#### 12.13 API 缺少机器可读契约
当前 289 个接口依赖 TypeScript DTO 和手写 client,没有 OpenAPI 文档与自动 client 生成。接口数量继续增长后,前后端容易漂移。
#### 12.14 生产运维不完整
`deploy/` 只有开发 compose、Nginx 示例和说明,没有落地的发布、回滚、备份、日志轮转、监控和告警脚本。当前主要依靠 systemd 日志与人工检查。
#### 12.15 真实支付、多租户和财务对账未完成
- 支付仍为 mock-pay。
- 当前是用户级数据隔离,没有组织/租户模型。
- `cost_actual` 的币种与财务账单口径需统一。
## 13. 建议建立的唯一真相机制
### 13.1 Work 负责
```text
产品定位
业务规则
架构决策
未来路线
功能设计文档
```
### 13.2 Git + Codex 负责
```text
当前代码
数据库迁移
实现细节
测试结果
部署与运行事实
```
### 13.3 同步规则
每个重要功能建议使用以下闭环:
```text
Work 讨论
-> FEATURE_xxx_V1.md
-> Codex 实现
-> 测试与部署
-> 更新 CHANGELOG.md
-> 更新 PROJECT_STATUS_Vn.md
-> 回灌 Work
```
状态文档不应每次全量覆盖,而应保留 V1、V2、V3,便于追踪系统如何演化。
## 14. 建议放入 Work 的首批资料
```text
AI 内容生产平台/
├── 01_项目现状/
│ └── PROJECT_STATUS_V1.md
├── 02_架构设计/
│ └── SYSTEM_ARCHITECTURE.md
├── 03_小说引擎/
│ └── NOVEL_ENGINE.md
├── 04_短剧引擎/
│ └── DRAMA_ENGINE.md
├── 05_AI流水线/
│ └── AI_PIPELINE.md
├── 06_数据库/
│ └── DATABASE.md
└── 07_版本记录/
└── CHANGELOG.md
```
本次只生成真实状态基线。其余文档应以本文件为依据重新整理,而不是直接复制旧系统 A/B 文档并假定其全部已实现。
## 15. 本次验证记录
已执行并确认:
- 全仓生产构建通过。
- Prisma schema 可用。
- 33 个迁移与当前数据库一致。
- 后端健康检查通过。
- backend 与 worker systemd 服务正在运行。
- MySQL、Redis、Nginx 正在运行。
- 数据库只读数量与 Provider/任务状态统计完成。
- 自动测试执行完成,并如实记录 35 条失败。
未执行:
- 未重启生产服务。
- 未修改数据库数据。
- 未触发新的付费 AI 生成。
- 未做浏览器端完整 E2E。
- 未做数据库或素材恢复演练。
+4137 -54
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -1,4 +1,10 @@
import { ApiCryptoClient, base64ToBytes } from './crypto'; import { ApiCryptoClient, base64ToBytes } from './crypto';
import {
beginAiRequestInspection,
completeAiRequestInspection,
failAiRequestInspection,
isInspectableAiRequest
} from '../../../shared/frontend/ai-request-inspector/ai-request-inspector';
export interface ApiEnvelope<T> { export interface ApiEnvelope<T> {
code: number; code: number;
@@ -89,6 +95,23 @@ export class ApiClient {
}); });
} }
async delete<T>(path: string) {
return this.request<T>(path, {
method: 'DELETE'
});
}
async getAssetPreviewUrl(assetId: string) {
const result = await this.request<{ asset: unknown; url: string; expires_in_seconds: number }>(
`/assets/${assetId}/preview-url`
);
return {
...result,
url: this.localizePreviewUrl(result.url)
};
}
async downloadAssetBlob(assetId: string) { async downloadAssetBlob(assetId: string) {
const response = await fetch(`${API_BASE_URL}/assets/${assetId}/download`, { const response = await fetch(`${API_BASE_URL}/assets/${assetId}/download`, {
headers: { headers: {
@@ -136,6 +159,17 @@ export class ApiClient {
return { blob, filename }; return { blob, filename };
} }
private localizePreviewUrl(url: string) {
if (!API_BASE_URL.startsWith('/')) return url;
try {
const parsed = new URL(url);
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
} catch {
return url;
}
}
private async request<T>(path: string, init: RequestInit = {}) { private async request<T>(path: string, init: RequestInit = {}) {
const method = (init.method ?? 'GET').toUpperCase(); const method = (init.method ?? 'GET').toUpperCase();
const shouldSendEmptyJsonBody = const shouldSendEmptyJsonBody =
@@ -146,6 +180,11 @@ export class ApiClient {
: shouldSendEmptyJsonBody : shouldSendEmptyJsonBody
? {} ? {}
: undefined; : undefined;
const inspectionId = isInspectableAiRequest(path, method)
? beginAiRequestInspection({ path, method, body: bodyPayload })
: null;
try {
const encryptedBody = const encryptedBody =
bodyPayload !== undefined ? await apiCrypto.encryptBody(bodyPayload) : null; bodyPayload !== undefined ? await apiCrypto.encryptBody(bodyPayload) : null;
const encryptedHeaders = encryptedBody?.headers ?? (await apiCrypto.encryptionHeaders()); const encryptedHeaders = encryptedBody?.headers ?? (await apiCrypto.encryptionHeaders());
@@ -168,7 +207,15 @@ export class ApiClient {
throw new Error(payload?.message || `Request failed: ${response.status}`); throw new Error(payload?.message || `Request failed: ${response.status}`);
} }
completeAiRequestInspection(inspectionId, {
requestId: payload.request_id,
responseData: payload.data
});
return payload.data; return payload.data;
} catch (error) {
failAiRequestInspection(inspectionId, error);
throw error;
}
} }
} }
+536
View File
@@ -351,6 +351,23 @@ nav button:hover {
justify-content: space-between; justify-content: space-between;
} }
.pagination-bar {
align-items: center;
border-top: 1px solid #e7edf5;
color: #607086;
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: space-between;
margin-top: 12px;
padding-top: 12px;
}
.pagination-bar strong {
color: #243044;
font-size: 13px;
}
.guide-card { .guide-card {
align-content: start; align-content: start;
min-height: 112px; min-height: 112px;
@@ -438,6 +455,16 @@ nav button:hover {
min-width: 0; min-width: 0;
} }
.form-hint {
color: #718096;
font-size: 12px;
line-height: 1.45;
}
.warning-text {
color: #b7791f;
}
.quota-grant-form input, .quota-grant-form input,
.quota-grant-form select { .quota-grant-form select {
min-width: 0; min-width: 0;
@@ -536,6 +563,353 @@ nav button:hover {
grid-template-columns: minmax(360px, 1.1fr) minmax(360px, 0.9fr); grid-template-columns: minmax(360px, 1.1fr) minmax(360px, 0.9fr);
} }
.novel-command-bar {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 12;
}
.novel-command-bar > div:first-child {
display: grid;
gap: 3px;
}
.novel-command-bar strong {
color: #243044;
font-size: 15px;
}
.novel-command-bar span {
color: #64748b;
font-size: 12px;
}
.novel-export-mini-form {
align-items: end;
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.novel-export-mini-form label {
display: grid;
gap: 4px;
}
.novel-export-mini-form input {
min-width: 112px;
width: 128px;
}
.novel-action-drawer {
width: min(900px, calc(100vw - 36px));
}
.novel-action-drawer .provider-form {
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
}
.novel-action-drawer .wide-field {
grid-column: 1 / -1;
}
.drawer-proposal-grid {
grid-template-columns: 1fr;
}
.novel-generation-panel {
display: grid;
gap: 14px;
}
.novel-generation-form {
border-bottom: 1px solid #e7edf5;
padding-bottom: 14px;
}
.novel-generation-runner {
display: grid;
gap: 10px;
}
.novel-generation-runner h3,
.novel-generation-summary h3 {
color: #243044;
font-size: 14px;
margin-bottom: 8px;
}
.novel-generation-summary {
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.novel-wizard-panel {
border-color: #bfd1ee;
display: grid;
gap: 14px;
}
.novel-wizard-form {
border-bottom: 1px solid #e7edf5;
padding-bottom: 14px;
}
.wizard-targets {
align-content: start;
display: flex;
flex-wrap: wrap;
gap: 10px 14px;
}
.wizard-targets > span {
flex-basis: 100%;
}
.wizard-progress {
background: #f7fafc;
border: 1px solid #d7e2ef;
border-radius: 8px;
display: grid;
gap: 8px;
grid-column: 1 / -1;
padding: 12px;
}
.wizard-progress-head {
align-items: center;
display: flex;
gap: 10px;
justify-content: space-between;
}
.wizard-progress-head strong {
color: #1f2a3a;
font-size: 14px;
}
.wizard-progress-head span,
.wizard-progress p,
.wizard-progress dd,
.wizard-progress dt {
color: #5c6d82;
font-size: 12px;
}
.wizard-progress p {
margin: 0;
}
.wizard-progress-track {
background: #e6edf5;
border-radius: 999px;
height: 8px;
overflow: hidden;
}
.wizard-progress-track span {
background: #2f6fdd;
border-radius: inherit;
display: block;
height: 100%;
transition: width 0.3s ease;
}
.wizard-progress dl {
display: grid;
gap: 4px 10px;
grid-template-columns: 64px minmax(0, 1fr);
margin: 0;
}
.wizard-progress dt {
font-weight: 700;
}
.wizard-progress dd {
margin: 0;
overflow-wrap: anywhere;
}
.wizard-progress.status-success {
background: #f2fbf6;
border-color: #bfe8cf;
}
.wizard-progress.status-success .wizard-progress-track span {
background: #2f9e61;
}
.wizard-progress.status-fallback {
background: #fffaf0;
border-color: #f2d39a;
}
.wizard-progress.status-fallback .wizard-progress-track span {
background: #d69e2e;
}
.wizard-progress.status-failed {
background: #fff5f5;
border-color: #f0b8b8;
}
.wizard-progress.status-failed .wizard-progress-track span {
background: #d64545;
}
.wizard-progress.status-paused {
background: #fffaf0;
border-color: #e8c46c;
}
.wizard-progress.status-paused .wizard-progress-track span {
background: #b7791f;
}
.wizard-proposal-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.wizard-proposal-card {
background: #f8fbff;
border: 1px solid #d4e0ef;
border-radius: 8px;
display: grid;
gap: 10px;
min-width: 0;
padding: 13px;
}
.wizard-proposal-card.selected {
background: #f1f6ff;
border-color: #6d96d8;
}
.proposal-card-head {
align-items: start;
display: flex;
gap: 10px;
justify-content: space-between;
}
.proposal-card-head strong {
color: #18202c;
display: block;
font-size: 15px;
overflow-wrap: anywhere;
}
.proposal-card-head span,
.wizard-proposal-card p,
.proposal-detail-list dd {
color: #526379;
font-size: 12px;
line-height: 1.55;
}
.proposal-detail-list {
display: grid;
gap: 6px 10px;
grid-template-columns: 64px minmax(0, 1fr);
margin: 0;
}
.proposal-detail-list dt {
color: #243044;
font-size: 12px;
font-weight: 700;
}
.proposal-detail-list dd {
margin: 0;
overflow-wrap: anywhere;
}
.wizard-debug {
display: grid;
gap: 8px;
}
.wizard-debug summary {
color: #526379;
cursor: pointer;
font-size: 13px;
}
.wizard-idea-log-panel {
border-top: 1px solid #e7edf5;
display: grid;
gap: 10px;
padding-top: 12px;
}
.compact-heading {
margin-bottom: 0;
}
.compact-heading h3 {
color: #243044;
font-size: 14px;
margin: 0;
}
.wizard-idea-log-list {
display: grid;
gap: 10px;
}
.wizard-idea-log-card {
background: #ffffff;
border: 1px solid #dbe5f0;
border-radius: 8px;
display: grid;
gap: 8px;
padding: 12px;
}
.wizard-idea-log-head {
align-items: start;
display: flex;
gap: 10px;
justify-content: space-between;
}
.wizard-idea-log-head strong {
color: #1f2a3a;
font-size: 13px;
line-height: 1.45;
overflow-wrap: anywhere;
}
.wizard-idea-log-card dl {
display: grid;
gap: 5px 10px;
grid-template-columns: 52px minmax(0, 1fr);
margin: 0;
}
.wizard-idea-log-card dt {
color: #243044;
font-size: 12px;
font-weight: 700;
}
.wizard-idea-log-card dd {
color: #526379;
font-size: 12px;
line-height: 1.5;
margin: 0;
overflow-wrap: anywhere;
}
.selected { .selected {
background: #f1f6ff; background: #f1f6ff;
} }
@@ -714,6 +1088,35 @@ nav button:hover {
width: min(820px, calc(100vw - 36px)); width: min(820px, calc(100vw - 36px));
} }
.ip-bible-drawer {
background: #f8fafc;
border-color: #b9c9dc;
color: #243044;
gap: 16px;
padding: 18px;
width: min(920px, calc(100vw - 36px));
}
.ip-bible-drawer .preview-header,
.ip-bible-drawer .drawer-section {
background: #ffffff;
border: 1px solid #dbe5f0;
border-radius: 8px;
padding: 14px;
}
.ip-bible-drawer .preview-header h2 {
color: #17243a;
font-size: 20px;
line-height: 1.25;
}
.ip-bible-drawer .preview-header p {
color: #5f7086;
line-height: 1.55;
margin: 3px 0 0;
}
.preview-header { .preview-header {
align-items: start; align-items: start;
display: flex; display: flex;
@@ -740,6 +1143,139 @@ nav button:hover {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.inline-actions {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.ip-bible-hero {
background: #eef6ff;
border: 1px solid #c9ddf2;
border-radius: 8px;
border-left: 4px solid #3272c7;
display: grid;
gap: 6px;
padding: 15px 16px;
}
.ip-bible-hero span,
.ip-bible-summary-grid span {
color: #5b6d84;
font-size: 12px;
font-weight: 700;
}
.ip-bible-hero strong {
color: #16243a;
font-size: 16px;
line-height: 1.6;
overflow-wrap: anywhere;
}
.ip-bible-summary-grid {
display: grid;
gap: 10px;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.ip-bible-rule-grid {
display: grid;
gap: 10px;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.ip-bible-summary-grid article,
.ip-bible-rule-grid article,
.ip-bible-list article {
align-content: start;
align-items: start;
background: #ffffff;
border: 1px solid #dbe3ed;
border-radius: 8px;
display: grid;
gap: 6px;
grid-auto-rows: max-content;
min-width: 0;
padding: 12px;
}
.ip-bible-summary-grid article {
min-height: 174px;
}
.ip-bible-summary-grid p,
.ip-bible-rule-grid p {
color: #28364d;
font-size: 13px;
line-height: 1.6;
margin: 0;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.ip-bible-list {
display: grid;
gap: 8px;
}
.ip-bible-list pre,
.ip-bible-readable {
color: #28364d;
font-family: inherit;
font-size: 13px;
line-height: 1.6;
margin: 0;
overflow-wrap: anywhere;
white-space: pre-wrap;
word-break: break-word;
}
.ip-bible-rule-grid strong {
color: #17243a;
font-size: 13px;
}
.ip-bible-rule-grid ul {
display: grid;
gap: 6px;
margin: 0;
padding-left: 18px;
}
.ip-bible-rule-grid li {
overflow-wrap: anywhere;
}
.ip-bible-editor-section textarea {
background: #fbfdff;
border-color: #cbd7e6;
color: #20304a;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 12px;
line-height: 1.55;
min-height: 320px;
resize: vertical;
width: 100%;
}
@media (max-width: 860px) {
.ip-bible-summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 560px) {
.ip-bible-summary-grid {
grid-template-columns: 1fr;
}
.ip-bible-summary-grid article {
min-height: 0;
}
}
.media-preview { .media-preview {
display: grid; display: grid;
gap: 10px; gap: 10px;
@@ -0,0 +1,41 @@
CREATE TABLE `character_design_versions` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`character_id` BIGINT NOT NULL,
`version_no` INTEGER NOT NULL,
`prompt_text` TEXT NULL,
`negative_prompt` TEXT NULL,
`image_asset_id` BIGINT NULL,
`notes` TEXT NULL,
`source` VARCHAR(50) NOT NULL DEFAULT 'workshop',
`is_final` BOOLEAN NOT NULL DEFAULT false,
`created_by_user_id` BIGINT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `character_design_versions_character_id_version_no_key`(`character_id`, `version_no`),
INDEX `character_design_versions_project_id_character_id_idx`(`project_id`, `character_id`),
INDEX `character_design_versions_character_id_is_final_idx`(`character_id`, `is_final`),
INDEX `character_design_versions_image_asset_id_idx`(`image_asset_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `character_states` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`character_id` BIGINT NOT NULL,
`state_code` VARCHAR(80) NOT NULL,
`display_name` VARCHAR(120) NULL,
`description` TEXT NULL,
`wardrobe_rules` TEXT NULL,
`emotion_rules` TEXT NULL,
`prompt_suffix` TEXT NULL,
`negative_rules` TEXT NULL,
`reference_asset_id` BIGINT NULL,
`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 `character_states_character_id_state_code_key`(`character_id`, `state_code`),
INDEX `character_states_project_id_character_id_idx`(`project_id`, `character_id`),
INDEX `character_states_status_idx`(`status`),
INDEX `character_states_reference_asset_id_idx`(`reference_asset_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -0,0 +1,51 @@
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',
'volcengine_seedance_20_fast',
'火山方舟 Seedance 2.0 Fast 图生视频',
'real',
'doubao-seedance-2.0-fast',
'{"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":150,"image_field":"image","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":{"watermark":false,"audio":false},"supports_reference_image":true,"supports_start_end_frame":true,"supports_audio":true,"supports_lipsync":true,"supports_character_reference":true,"note":"默认禁用。Seedance 2.0 Fast 适合先做快速小样;模型 ID 和字段请以火山方舟控制台实际开通为准。"}',
false,
77,
'{"rpm":5,"concurrency":1,"retry_limit":2}',
'{"flat_cost":0,"unit":"video_seconds","price_per_second":0,"currency":"CNY","max_cost_per_call":0,"daily_cost_limit":0,"estimated_seconds":5,"note":"Seedance 2.0 Fast 价格按火山方舟实际账单回填;启用前请设置单次和每日成本上限。"}',
NOW(3),
NOW(3)
),
(
'VideoProvider',
'volcengine_seedance_20',
'火山方舟 Seedance 2.0 图生视频',
'real',
'doubao-seedance-2.0',
'{"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":180,"image_field":"image","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":{"watermark":false,"audio":false},"supports_reference_image":true,"supports_start_end_frame":true,"supports_audio":true,"supports_lipsync":true,"supports_character_reference":true,"note":"默认禁用。质量优先的 Seedance 2.0 配置;正式调用前先用单镜头验证请求字段、返回结构和账单。"}',
false,
76,
'{"rpm":5,"concurrency":1,"retry_limit":2}',
'{"flat_cost":0,"unit":"video_seconds","price_per_second":0,"currency":"CNY","max_cost_per_call":0,"daily_cost_limit":0,"estimated_seconds":5,"note":"Seedance 2.0 价格按火山方舟实际账单回填;启用前请设置单次和每日成本上限。"}',
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);
UPDATE `system_configs`
SET
`config_value` = JSON_SET(
`config_value`,
'$."live_action_video"."zh-CN"."normal"."fallback_chain"',
JSON_ARRAY('minimax_hailuo_23_fast', 'volcengine_seedance_20_fast', 'volcengine_seedance_20', 'jimeng_seedance', 'mock-video'),
'$."live_action_video"."zh-CN"."premium"."fallback_chain"',
JSON_ARRAY('kling-image-to-video', 'volcengine_seedance_20', 'minimax_hailuo_23_fast', 'volcengine_seedance_20_fast', 'jimeng_seedance', 'mock-video')
),
`updated_at` = NOW(3)
WHERE `config_key` = 'ai.router.v1'
AND JSON_TYPE(`config_value`) = 'OBJECT';
@@ -0,0 +1,105 @@
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
(
'TextProvider',
'volcengine-doubao-seed20-pro-text',
'Volcengine Doubao Seed 2.0 Pro Text',
'real',
'doubao-seed-2-0-pro-260215',
'{"driver":"openai_compatible_chat","api_key_env":"VOLCENGINE_API_KEY","base_url":"https://ark.cn-beijing.volces.com/api/v3","chat_endpoint":"/chat/completions","timeout_ms":120000,"max_tokens":3200,"temperature":0.7,"instructions":"你是中文短剧总编剧,负责高质量剧情策划、世界观、人设、分集大纲和可拍摄脚本。输出结构清晰、冲突强、方便继续生成分镜。"}',
false,
30,
'{"rpm":60,"concurrency":3}',
'{"flat_cost":0,"unit":"provider_usage_metadata","estimated_output_chars":1200,"note":"真实费用以后续账单或 usage 元数据核算;默认禁用,启用前请填写成本阈值。"}',
NOW(3),
NOW(3)
),
(
'TextProvider',
'volcengine-doubao-seed20-lite-text',
'Volcengine Doubao Seed 2.0 Lite Text',
'real',
'doubao-seed-2-0-lite-260215',
'{"driver":"openai_compatible_chat","api_key_env":"VOLCENGINE_API_KEY","base_url":"https://ark.cn-beijing.volces.com/api/v3","chat_endpoint":"/chat/completions","timeout_ms":90000,"max_tokens":2400,"temperature":0.7,"instructions":"你是中文短剧生产助手,适合高频改写、分镜描述、标题钩子和运营文案。"}',
false,
30,
'{"rpm":60,"concurrency":3}',
'{"flat_cost":0,"unit":"provider_usage_metadata","estimated_output_chars":1200,"note":"真实费用以后续账单或 usage 元数据核算;默认禁用,启用前请填写成本阈值。"}',
NOW(3),
NOW(3)
),
(
'NovelProvider',
'volcengine-doubao-seed20-pro-novel',
'Volcengine Doubao Seed 2.0 Pro Novel',
'real',
'doubao-seed-2-0-pro-260215',
'{"driver":"openai_compatible_chat","api_key_env":"VOLCENGINE_API_KEY","base_url":"https://ark.cn-beijing.volces.com/api/v3","chat_endpoint":"/chat/completions","timeout_ms":150000,"max_tokens":5000,"temperature":0.7,"instructions":"你是中文网文与短剧改编主笔,擅长长上下文改编、人物弧光、爽点节奏和连续剧集结构。"}',
false,
30,
'{"rpm":30,"concurrency":2}',
'{"flat_cost":0,"unit":"provider_usage_metadata","estimated_output_chars":1200,"note":"真实费用以后续账单或 usage 元数据核算;默认禁用,启用前请填写成本阈值。"}',
NOW(3),
NOW(3)
),
(
'ImageProvider',
'volcengine-seedream-50-image',
'Volcengine Seedream 5.0 Image',
'real',
'doubao-seedream-5-0-260128',
'{"driver":"configurable_image_generation","api_key_env":"VOLCENGINE_API_KEY","base_url":"https://ark.cn-beijing.volces.com/api/v3","timeout_ms":600000,"create_endpoint":"/images/generations","size":"1440x2560","output_format":"png","response_format":"b64_json","aspect_ratio":"9:16","extra_body_json":{"watermark":false},"note":"默认禁用。用于角色定妆、场景概念图、分镜关键帧;启用前先单图验证返回格式和账单。"}',
false,
30,
'{"rpm":20,"concurrency":2}',
'{"flat_cost":0,"unit":"provider_usage_metadata","estimated_output_chars":800,"note":"图片费用按供应商账单核算;默认禁用,启用前请填写单次/每日成本阈值。"}',
NOW(3),
NOW(3)
),
(
'ImageProvider',
'volcengine-seedream-50-lite-image',
'Volcengine Seedream 5.0 Lite Image',
'real',
'doubao-seedream-5-0-lite-260128',
'{"driver":"configurable_image_generation","api_key_env":"VOLCENGINE_API_KEY","base_url":"https://ark.cn-beijing.volces.com/api/v3","timeout_ms":600000,"create_endpoint":"/images/generations","size":"1440x2560","output_format":"png","response_format":"b64_json","aspect_ratio":"9:16","extra_body_json":{"watermark":false},"note":"默认禁用。用于低成本高频角色草图、分镜草图和运营配图。"}',
false,
30,
'{"rpm":20,"concurrency":2}',
'{"flat_cost":0,"unit":"provider_usage_metadata","estimated_output_chars":800,"note":"图片费用按供应商账单核算;默认禁用,启用前请填写单次/每日成本阈值。"}',
NOW(3),
NOW(3)
),
(
'ImageProvider',
'volcengine-seedream-45-image',
'Volcengine Seedream 4.5 Image',
'real',
'doubao-seedream-4-5-251128',
'{"driver":"configurable_image_generation","api_key_env":"VOLCENGINE_API_KEY","base_url":"https://ark.cn-beijing.volces.com/api/v3","timeout_ms":600000,"create_endpoint":"/images/generations","size":"1440x2560","output_format":"png","response_format":"b64_json","aspect_ratio":"9:16","extra_body_json":{"watermark":false},"note":"默认禁用。作为 Seedream 5.0 不可用时的图片生成 fallback。"}',
false,
30,
'{"rpm":20,"concurrency":2}',
'{"flat_cost":0,"unit":"provider_usage_metadata","estimated_output_chars":800,"note":"图片费用按供应商账单核算;默认禁用,启用前请填写单次/每日成本阈值。"}',
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);
UPDATE `provider_configs`
SET
`model_name` = CASE
WHEN `provider_code` = 'volcengine_seedance_20' THEN 'doubao-seedance-2-0-260128'
WHEN `provider_code` = 'volcengine_seedance_20_fast' THEN 'doubao-seedance-2-0-fast-260128'
ELSE `model_name`
END,
`updated_at` = NOW(3)
WHERE `provider_type` = 'VideoProvider'
AND `provider_code` IN ('volcengine_seedance_20', 'volcengine_seedance_20_fast');
@@ -0,0 +1,164 @@
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
(
'TextProvider',
'openai-gpt54-text',
'OpenAI GPT-5.4 Text',
'real',
'gpt-5.4',
'{"driver":"openai_responses","api_key_env":"OPENAI_API_KEY","base_url":"https://api.openai.com/v1","base_url_env":"OPENAI_BASE_URL","timeout_ms":90000,"max_output_tokens":1800,"instructions":"你是中文漫剧创作助手,适合剧情策划、脚本润色、分镜提示词优化和复杂改写。"}',
false,
58,
'{"rpm":60,"concurrency":4}',
'{"flat_cost":0,"unit":"provider_usage_metadata","note":"真实费用以后续账单或 usage 元数据核算;默认禁用,用于后台多模型测试。"}',
NOW(3),
NOW(3)
),
(
'TextProvider',
'openai-gpt54-mini-text',
'OpenAI GPT-5.4 Mini Text',
'real',
'gpt-5.4-mini',
'{"driver":"openai_responses","api_key_env":"OPENAI_API_KEY","base_url":"https://api.openai.com/v1","base_url_env":"OPENAI_BASE_URL","timeout_ms":60000,"max_output_tokens":1600,"instructions":"你是中文短剧生产助手,适合高频、低延迟的分镜草稿、标题、简介和运营文案。"}',
false,
57,
'{"rpm":60,"concurrency":4}',
'{"flat_cost":0,"unit":"provider_usage_metadata","note":"真实费用以后续账单或 usage 元数据核算;默认禁用,用于后台多模型测试。"}',
NOW(3),
NOW(3)
),
(
'TextProvider',
'openai-gpt54-nano-text',
'OpenAI GPT-5.4 Nano Text',
'real',
'gpt-5.4-nano',
'{"driver":"openai_responses","api_key_env":"OPENAI_API_KEY","base_url":"https://api.openai.com/v1","base_url_env":"OPENAI_BASE_URL","timeout_ms":45000,"max_output_tokens":1200,"instructions":"你是中文短剧轻量助手,适合分类、短提示词改写、标题和低成本批处理。"}',
false,
56,
'{"rpm":60,"concurrency":4}',
'{"flat_cost":0,"unit":"provider_usage_metadata","note":"真实费用以后续账单或 usage 元数据核算;默认禁用,用于后台多模型测试。"}',
NOW(3),
NOW(3)
),
(
'TextProvider',
'openai-gpt5-text',
'OpenAI GPT-5 Text',
'real',
'gpt-5',
'{"driver":"openai_responses","api_key_env":"OPENAI_API_KEY","base_url":"https://api.openai.com/v1","base_url_env":"OPENAI_BASE_URL","timeout_ms":90000,"max_output_tokens":1800,"instructions":"你是中文漫剧创作助手,用于和 GPT-5.4/5.5 做质量、成本、稳定性对比。"}',
false,
55,
'{"rpm":60,"concurrency":4}',
'{"flat_cost":0,"unit":"provider_usage_metadata","note":"真实费用以后续账单或 usage 元数据核算;默认禁用,用于后台多模型测试。"}',
NOW(3),
NOW(3)
),
(
'TextProvider',
'openai-gpt41-text',
'OpenAI GPT-4.1 Text',
'real',
'gpt-4.1',
'{"driver":"openai_responses","api_key_env":"OPENAI_API_KEY","base_url":"https://api.openai.com/v1","base_url_env":"OPENAI_BASE_URL","timeout_ms":60000,"max_output_tokens":1600,"instructions":"你是中文漫剧创作助手,用于非推理模型基线测试、脚本润色和结构化输出。"}',
false,
54,
'{"rpm":60,"concurrency":4}',
'{"flat_cost":0,"unit":"provider_usage_metadata","note":"真实费用以后续账单或 usage 元数据核算;默认禁用,用于后台多模型测试。"}',
NOW(3),
NOW(3)
),
(
'TextProvider',
'openai-gpt41-mini-text',
'OpenAI GPT-4.1 Mini Text',
'real',
'gpt-4.1-mini',
'{"driver":"openai_responses","api_key_env":"OPENAI_API_KEY","base_url":"https://api.openai.com/v1","base_url_env":"OPENAI_BASE_URL","timeout_ms":45000,"max_output_tokens":1200,"instructions":"你是中文短剧轻量助手,用于低成本文案、分镜草稿和结构化摘要。"}',
false,
53,
'{"rpm":60,"concurrency":4}',
'{"flat_cost":0,"unit":"provider_usage_metadata","note":"真实费用以后续账单或 usage 元数据核算;默认禁用,用于后台多模型测试。"}',
NOW(3),
NOW(3)
),
(
'NovelProvider',
'openai-gpt54-novel',
'OpenAI GPT-5.4 Novel',
'real',
'gpt-5.4',
'{"driver":"openai_responses","api_key_env":"OPENAI_API_KEY","base_url":"https://api.openai.com/v1","base_url_env":"OPENAI_BASE_URL","timeout_ms":150000,"max_output_tokens":5000,"instructions":"你是中文网文和短剧改编主笔,擅长长上下文改编、分集大纲、角色弧光和连续剧结构。"}',
false,
58,
'{"rpm":30,"concurrency":2}',
'{"flat_cost":0,"unit":"provider_usage_metadata","note":"真实费用以后续账单或 usage 元数据核算;默认禁用,用于后台多模型测试。"}',
NOW(3),
NOW(3)
),
(
'NovelProvider',
'openai-gpt54-mini-novel',
'OpenAI GPT-5.4 Mini Novel',
'real',
'gpt-5.4-mini',
'{"driver":"openai_responses","api_key_env":"OPENAI_API_KEY","base_url":"https://api.openai.com/v1","base_url_env":"OPENAI_BASE_URL","timeout_ms":120000,"max_output_tokens":3600,"instructions":"你是中文网文改编助手,适合低成本生成章节摘要、分集计划和脚本草稿。"}',
false,
57,
'{"rpm":30,"concurrency":2}',
'{"flat_cost":0,"unit":"provider_usage_metadata","note":"真实费用以后续账单或 usage 元数据核算;默认禁用,用于后台多模型测试。"}',
NOW(3),
NOW(3)
),
(
'NovelProvider',
'openai-gpt5-novel',
'OpenAI GPT-5 Novel',
'real',
'gpt-5',
'{"driver":"openai_responses","api_key_env":"OPENAI_API_KEY","base_url":"https://api.openai.com/v1","base_url_env":"OPENAI_BASE_URL","timeout_ms":150000,"max_output_tokens":4200,"instructions":"你是中文网文和短剧改编主笔,用于和 GPT-5.4/5.5 做长文改编对比。"}',
false,
55,
'{"rpm":30,"concurrency":2}',
'{"flat_cost":0,"unit":"provider_usage_metadata","note":"真实费用以后续账单或 usage 元数据核算;默认禁用,用于后台多模型测试。"}',
NOW(3),
NOW(3)
),
(
'NovelProvider',
'openai-gpt41-novel',
'OpenAI GPT-4.1 Novel',
'real',
'gpt-4.1',
'{"driver":"openai_responses","api_key_env":"OPENAI_API_KEY","base_url":"https://api.openai.com/v1","base_url_env":"OPENAI_BASE_URL","timeout_ms":120000,"max_output_tokens":3200,"instructions":"你是中文网文改编助手,用于非推理模型基线测试和长文本结构化整理。"}',
false,
54,
'{"rpm":30,"concurrency":2}',
'{"flat_cost":0,"unit":"provider_usage_metadata","note":"真实费用以后续账单或 usage 元数据核算;默认禁用,用于后台多模型测试。"}',
NOW(3),
NOW(3)
),
(
'VideoProvider',
'openai-sora-2-pro-video',
'OpenAI Sora 2 Pro Video',
'real',
'sora-2-pro',
'{"driver":"openai_video_generation","api_key_env":"OPENAI_API_KEY","base_url":"https://api.openai.com/v1","base_url_env":"OPENAI_BASE_URL","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":90,"size":"1280x720","seconds":"8"}',
false,
45,
'{"rpm":3,"concurrency":1}',
'{"flat_cost":0,"unit":"provider_usage_metadata","note":"Sora Pro 费用较高;默认禁用,启用前必须设置单次和每日成本上限。"}',
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);
@@ -0,0 +1,22 @@
UPDATE `provider_configs`
SET `config_json` = JSON_SET(
`config_json`,
'$.body_style', 'volcengine_content_generation',
'$.create_endpoint', '/contents/generations/tasks',
'$.task_endpoint_template', '/contents/generations/tasks/{task_id}'
)
WHERE `provider_type` = 'VideoProvider'
AND `provider_code` IN ('volcengine_seedance_20_fast', 'volcengine_seedance_20', 'jimeng_seedance');
UPDATE `provider_configs`
SET `config_json` = JSON_REMOVE(
`config_json`,
'$.image_field',
'$.prompt_field',
'$.model_field',
'$.duration_field',
'$.resolution_field',
'$.aspect_ratio_field'
)
WHERE `provider_type` = 'VideoProvider'
AND `provider_code` IN ('volcengine_seedance_20_fast', 'volcengine_seedance_20', 'jimeng_seedance');
@@ -0,0 +1,11 @@
UPDATE `provider_configs`
SET `cost_rule_json` = JSON_SET(
`cost_rule_json`,
'$.price_per_second', 0.9936,
'$.currency', 'CNY',
'$.max_cost_per_call', 20,
'$.daily_cost_limit', 500,
'$.note', '按火山方舟公开价格估算:720p、24fps、无视频输入约 46 元/百万 token,即约 0.9936 元/秒;最终以账单为准。'
)
WHERE `provider_type` = 'VideoProvider'
AND `provider_code` IN ('volcengine_seedance_20_fast', 'volcengine_seedance_20');
@@ -0,0 +1,10 @@
UPDATE `provider_configs`
SET
`config_json` = JSON_SET(COALESCE(`config_json`, JSON_OBJECT()), '$.size', '1440x2560'),
`updated_at` = NOW(3)
WHERE `provider_type` = 'ImageProvider'
AND `provider_code` IN (
'volcengine-seedream-50-image',
'volcengine-seedream-50-lite-image',
'volcengine-seedream-45-image'
);
@@ -0,0 +1,10 @@
UPDATE `provider_configs`
SET
`config_json` = JSON_SET(COALESCE(`config_json`, JSON_OBJECT()), '$.timeout_ms', 600000),
`updated_at` = NOW(3)
WHERE `provider_type` = 'ImageProvider'
AND `provider_code` IN (
'volcengine-seedream-50-image',
'volcengine-seedream-50-lite-image',
'volcengine-seedream-45-image'
);
@@ -0,0 +1,6 @@
ALTER TABLE `novel_chapters`
ADD COLUMN `volume_no` INT NULL AFTER `novel_source_id`,
ADD COLUMN `volume_title` VARCHAR(255) NULL AFTER `volume_no`;
CREATE INDEX `novel_chapters_novel_source_id_volume_no_idx`
ON `novel_chapters`(`novel_source_id`, `volume_no`);
@@ -0,0 +1,44 @@
CREATE TABLE `novel_reading_progress` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`user_id` BIGINT NOT NULL,
`novel_source_id` BIGINT NOT NULL,
`chapter_id` BIGINT NOT NULL,
`page_index` INT NOT NULL DEFAULT 0,
`page_count` INT NOT NULL DEFAULT 1,
`progress_percent` DECIMAL(6, 3) NULL,
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `novel_reading_progress_user_id_novel_source_id_key` (`user_id`, `novel_source_id`),
KEY `novel_reading_progress_user_id_updated_at_idx` (`user_id`, `updated_at`),
KEY `novel_reading_progress_novel_source_id_idx` (`novel_source_id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `novel_bookmarks` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`user_id` BIGINT NOT NULL,
`novel_source_id` BIGINT NOT NULL,
`chapter_id` BIGINT NOT NULL,
`page_index` INT NOT NULL DEFAULT 0,
`title` VARCHAR(255) NULL,
`note_text` TEXT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
KEY `novel_bookmarks_user_id_novel_source_id_created_at_idx` (`user_id`, `novel_source_id`, `created_at`),
KEY `novel_bookmarks_chapter_id_idx` (`chapter_id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `novel_annotations` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`user_id` BIGINT NOT NULL,
`novel_source_id` BIGINT NOT NULL,
`chapter_id` BIGINT NOT NULL,
`page_index` INT NOT NULL DEFAULT 0,
`selected_text` TEXT NOT NULL,
`note_text` TEXT NULL,
`color` VARCHAR(30) 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),
PRIMARY KEY (`id`),
KEY `novel_annotations_user_id_novel_source_id_created_at_idx` (`user_id`, `novel_source_id`, `created_at`),
KEY `novel_annotations_chapter_id_idx` (`chapter_id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -0,0 +1,187 @@
CREATE TABLE `novel_generation_plans` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`novel_source_id` BIGINT NULL,
`user_id` BIGINT NOT NULL,
`novel_scale` VARCHAR(30) NOT NULL DEFAULT 'medium',
`target_words` INT NULL,
`target_chapters` INT NULL,
`genre` VARCHAR(100) NULL,
`style_code` VARCHAR(100) NULL,
`brief_json` JSON NULL,
`ip_bible_json` JSON NULL,
`volume_plan_json` JSON NULL,
`pipeline_config_json` JSON NULL,
`quality_threshold_json` JSON NULL,
`automation_level` VARCHAR(20) NOT NULL DEFAULT 'L1',
`status` VARCHAR(50) NOT NULL DEFAULT 'draft',
`current_chapter_no` INT NOT NULL DEFAULT 0,
`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),
PRIMARY KEY (`id`),
KEY `npg_project_status_idx` (`project_id`, `status`),
KEY `npg_source_idx` (`novel_source_id`),
KEY `npg_user_status_idx` (`user_id`, `status`),
KEY `npg_scale_level_idx` (`novel_scale`, `automation_level`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `novel_chapter_versions` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`novel_source_id` BIGINT NULL,
`novel_chapter_id` BIGINT NULL,
`chapter_no` INT NOT NULL,
`version_no` INT NOT NULL,
`version_type` VARCHAR(50) NOT NULL,
`title` VARCHAR(255) NULL,
`content_text` LONGTEXT NULL,
`content_json` JSON NULL,
`source_agent` VARCHAR(100) NULL,
`provider_code` VARCHAR(100) NULL,
`model_name` VARCHAR(100) NULL,
`quality_score` DECIMAL(5, 2) NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'draft',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `ncv_source_chapter_version_type_key` (`novel_source_id`, `chapter_no`, `version_no`, `version_type`),
KEY `ncv_project_chapter_idx` (`project_id`, `chapter_no`),
KEY `ncv_chapter_idx` (`novel_chapter_id`),
KEY `ncv_agent_idx` (`source_agent`),
KEY `ncv_status_idx` (`status`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `novel_context_memories` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`novel_source_id` BIGINT NULL,
`chapter_no` INT NULL,
`memory_type` VARCHAR(80) NOT NULL,
`memory_text` LONGTEXT NULL,
`memory_json` JSON NULL,
`importance_level` INT NOT NULL DEFAULT 0,
`status` VARCHAR(50) NOT NULL DEFAULT 'active',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
KEY `ncm_project_type_idx` (`project_id`, `memory_type`),
KEY `ncm_source_chapter_idx` (`novel_source_id`, `chapter_no`),
KEY `ncm_type_status_idx` (`memory_type`, `status`),
KEY `ncm_importance_idx` (`importance_level`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `novel_quality_reports` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`novel_source_id` BIGINT NULL,
`novel_chapter_id` BIGINT NULL,
`chapter_no` INT NULL,
`report_type` VARCHAR(50) NOT NULL,
`total_score` DECIMAL(5, 2) NULL,
`score_json` JSON NULL,
`problems_json` JSON NULL,
`suggestions_json` JSON NULL,
`pass_status` BOOLEAN NOT NULL DEFAULT false,
`rewrite_required` BOOLEAN NOT NULL DEFAULT false,
`source_agent` VARCHAR(100) NULL,
`provider_code` VARCHAR(100) NULL,
`model_name` VARCHAR(100) NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
KEY `nqr_project_chapter_idx` (`project_id`, `chapter_no`),
KEY `nqr_source_chapter_idx` (`novel_source_id`, `chapter_no`),
KEY `nqr_chapter_idx` (`novel_chapter_id`),
KEY `nqr_pass_idx` (`pass_status`),
KEY `nqr_agent_idx` (`source_agent`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `novel_version_snapshots` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`novel_source_id` BIGINT NOT NULL,
`version_no` INT NOT NULL,
`title` VARCHAR(255) NULL,
`snapshot_scope` VARCHAR(50) NOT NULL,
`chapter_start` INT NULL,
`chapter_end` INT NULL,
`source_hash` VARCHAR(128) NULL,
`snapshot_text` LONGTEXT NULL,
`snapshot_json` JSON NULL,
`created_for` VARCHAR(50) NULL,
`created_by_user_id` BIGINT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `nvs_source_version_key` (`novel_source_id`, `version_no`),
KEY `nvs_project_idx` (`project_id`),
KEY `nvs_for_idx` (`created_for`),
KEY `nvs_range_idx` (`chapter_start`, `chapter_end`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `novel_derivative_jobs` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`novel_source_id` BIGINT NOT NULL,
`snapshot_id` BIGINT NULL,
`user_id` BIGINT NOT NULL,
`derivative_type` VARCHAR(50) NOT NULL,
`target_project_id` BIGINT NULL,
`target_ref_id` BIGINT NULL,
`chapter_start` INT NULL,
`chapter_end` INT NULL,
`config_json` JSON NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'pending',
`progress_json` JSON NULL,
`error_message` TEXT 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),
PRIMARY KEY (`id`),
KEY `ndj_project_type_idx` (`project_id`, `derivative_type`),
KEY `ndj_source_status_idx` (`novel_source_id`, `status`),
KEY `ndj_snapshot_idx` (`snapshot_id`),
KEY `ndj_user_status_idx` (`user_id`, `status`),
KEY `ndj_target_project_idx` (`target_project_id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `agent_prompts` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`agent_name` VARCHAR(100) NOT NULL,
`version` INT NOT NULL DEFAULT 1,
`provider_type` VARCHAR(80) NOT NULL DEFAULT 'NovelProvider',
`default_provider_code` VARCHAR(100) NULL,
`system_prompt` LONGTEXT NOT NULL,
`user_prompt_template` LONGTEXT NOT NULL,
`output_schema_json` JSON NULL,
`temperature` DECIMAL(4, 2) NULL,
`max_output_tokens` INT NULL,
`is_active` BOOLEAN NOT NULL DEFAULT true,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `ap_name_version_key` (`agent_name`, `version`),
KEY `ap_name_active_idx` (`agent_name`, `is_active`),
KEY `ap_provider_type_idx` (`provider_type`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `agent_runs` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NULL,
`novel_source_id` BIGINT NULL,
`chapter_no` INT NULL,
`agent_name` VARCHAR(100) NOT NULL,
`prompt_version` INT NULL,
`provider_code` VARCHAR(100) NULL,
`model_name` VARCHAR(100) NULL,
`provider_log_id` BIGINT NULL,
`input_json` JSON NULL,
`output_json` JSON NULL,
`output_text` LONGTEXT NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'running',
`quality_score` DECIMAL(5, 2) NULL,
`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),
PRIMARY KEY (`id`),
KEY `ar_project_agent_idx` (`project_id`, `agent_name`),
KEY `ar_source_chapter_idx` (`novel_source_id`, `chapter_no`),
KEY `ar_status_created_idx` (`status`, `created_at`),
KEY `ar_provider_log_idx` (`provider_log_id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -0,0 +1,13 @@
ALTER TABLE `novel_sources`
ADD COLUMN `ai_provider_code` VARCHAR(100) NULL,
ADD COLUMN `ai_model_name` VARCHAR(160) NULL,
ADD COLUMN `ai_cost_estimate` DECIMAL(12, 4) NULL,
ADD COLUMN `ai_cost_actual` DECIMAL(12, 4) NULL,
ADD INDEX `ns_ai_provider_idx` (`ai_provider_code`);
ALTER TABLE `novel_chapters`
ADD COLUMN `ai_provider_code` VARCHAR(100) NULL,
ADD COLUMN `ai_model_name` VARCHAR(160) NULL,
ADD COLUMN `ai_cost_estimate` DECIMAL(12, 4) NULL,
ADD COLUMN `ai_cost_actual` DECIMAL(12, 4) NULL,
ADD INDEX `nc_ai_provider_idx` (`ai_provider_code`);
@@ -0,0 +1,7 @@
ALTER TABLE `novel_sources`
ADD COLUMN `design_json` JSON NULL AFTER `parse_report`,
ADD COLUMN `volume_plan_json` JSON NULL AFTER `design_json`;
ALTER TABLE `novel_chapters`
ADD COLUMN `outline_json` JSON NULL AFTER `visual_summary`,
ADD COLUMN `analysis_json` JSON NULL AFTER `outline_json`;
@@ -0,0 +1,2 @@
ALTER TABLE `story_bibles`
MODIFY `tone` TEXT NULL;
@@ -0,0 +1,3 @@
ALTER TABLE `story_bibles`
ADD COLUMN `production_bible_json` JSON NULL,
ADD COLUMN `quality_report_json` JSON NULL;
@@ -0,0 +1,35 @@
CREATE TABLE `character_extraction_versions` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`story_bible_id` BIGINT NULL,
`provider_log_id` BIGINT NULL,
`source_type` VARCHAR(50) NOT NULL,
`source_label` VARCHAR(120) NULL,
`prompt_category` VARCHAR(100) NULL,
`prompt_version` VARCHAR(80) NULL,
`prompt_text` LONGTEXT NULL,
`provider_code` VARCHAR(100) NULL,
`model_name` VARCHAR(100) NULL,
`raw_text` LONGTEXT NULL,
`role_pool_plan_json` JSON NULL,
`characters_json` JSON NULL,
`quality_score` INT NULL,
`review_comment` TEXT 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 DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE INDEX `character_extraction_versions_project_id_status_idx`
ON `character_extraction_versions`(`project_id`, `status`);
CREATE INDEX `character_extraction_versions_story_bible_id_idx`
ON `character_extraction_versions`(`story_bible_id`);
CREATE INDEX `character_extraction_versions_provider_log_id_idx`
ON `character_extraction_versions`(`provider_log_id`);
CREATE INDEX `character_extraction_versions_source_type_idx`
ON `character_extraction_versions`(`source_type`);
@@ -0,0 +1,58 @@
ALTER TABLE `characters`
ADD COLUMN `global_character_look_version_id` BIGINT NULL,
ADD COLUMN `global_character_asset_id` BIGINT NULL,
ADD COLUMN `inherit_voice_from_global` BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN `inherit_digital_human_from_global` BOOLEAN NOT NULL DEFAULT false,
ADD INDEX `characters_global_character_look_version_id_idx` (`global_character_look_version_id`),
ADD INDEX `characters_global_character_asset_id_idx` (`global_character_asset_id`);
CREATE TABLE `global_character_look_versions` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`global_character_id` BIGINT NOT NULL,
`version_name` VARCHAR(160) NOT NULL,
`style_type` VARCHAR(80) NULL,
`appearance_desc` TEXT NULL,
`hair_desc` TEXT NULL,
`makeup_desc` TEXT NULL,
`body_desc` TEXT NULL,
`costume_rules` TEXT NULL,
`color_palette` TEXT NULL,
`key_props` TEXT NULL,
`negative_rules` TEXT NULL,
`main_anchor_asset_id` BIGINT NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'candidate',
`quality_score` DECIMAL(5,2) NULL,
`reviewer_comment` TEXT NULL,
`source_project_id` BIGINT NULL,
`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),
PRIMARY KEY (`id`),
INDEX `global_character_look_versions_global_character_id_status_idx` (`global_character_id`, `status`),
INDEX `global_character_look_versions_main_anchor_asset_id_idx` (`main_anchor_asset_id`),
INDEX `global_character_look_versions_source_project_id_idx` (`source_project_id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE `global_character_assets`
ADD COLUMN `look_version_id` BIGINT NULL,
ADD COLUMN `source_type` VARCHAR(50) NOT NULL DEFAULT 'upload',
ADD COLUMN `source_label` VARCHAR(160) NULL,
ADD COLUMN `negative_prompt` TEXT NULL,
ADD COLUMN `model_name` VARCHAR(160) NULL,
ADD COLUMN `seed` VARCHAR(100) NULL,
ADD COLUMN `resolution` VARCHAR(50) NULL,
ADD COLUMN `aspect_ratio` VARCHAR(50) NULL,
ADD COLUMN `cost_estimate` DECIMAL(12,4) NULL,
ADD COLUMN `cost_actual` DECIMAL(12,4) NULL,
ADD COLUMN `quality_score` DECIMAL(5,2) NULL,
ADD COLUMN `consistency_score` DECIMAL(5,2) NULL,
ADD COLUMN `face_similarity_score` DECIMAL(5,2) NULL,
ADD COLUMN `license_status` VARCHAR(50) NOT NULL DEFAULT 'internal_test',
ADD COLUMN `commercial_allowed` BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN `review_status` VARCHAR(50) NOT NULL DEFAULT 'pending',
ADD COLUMN `reviewer_note` TEXT NULL,
ADD COLUMN `usage_count` INT NOT NULL DEFAULT 0,
ADD COLUMN `metadata_json` JSON NULL,
ADD INDEX `global_character_assets_look_version_id_idx` (`look_version_id`),
ADD INDEX `global_character_assets_source_type_idx` (`source_type`),
ADD INDEX `global_character_assets_review_status_idx` (`review_status`);
@@ -0,0 +1,100 @@
ALTER TABLE `characters`
ADD COLUMN `story_character_id` BIGINT NULL,
ADD INDEX `characters_story_character_id_idx` (`story_character_id`);
CREATE TABLE `story_characters` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`novel_source_id` BIGINT NULL,
`story_bible_id` BIGINT NULL,
`global_character_id` BIGINT NULL,
`name` VARCHAR(100) NOT NULL,
`alias_names` JSON NULL,
`role_type` VARCHAR(50) NOT NULL DEFAULT 'supporting',
`identity_desc` TEXT NULL,
`background_desc` TEXT NULL,
`personality_desc` TEXT NULL,
`relationship_json` JSON NULL,
`character_arc` TEXT NULL,
`world_role_desc` TEXT NULL,
`negative_rules` TEXT NULL,
`source_type` VARCHAR(50) NOT NULL DEFAULT 'character_extraction',
`source_version_id` BIGINT NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'active',
`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),
PRIMARY KEY (`id`),
INDEX `story_characters_project_id_status_idx` (`project_id`, `status`),
INDEX `story_characters_novel_source_id_idx` (`novel_source_id`),
INDEX `story_characters_story_bible_id_idx` (`story_bible_id`),
INDEX `story_characters_global_character_id_idx` (`global_character_id`),
INDEX `story_characters_source_type_idx` (`source_type`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE `video_clips`
ADD COLUMN `engine_version` VARCHAR(80) NULL,
ADD COLUMN `asset_lock_json` JSON NULL,
ADD COLUMN `motion_control_json` JSON NULL,
ADD COLUMN `camera_control_json` JSON NULL,
ADD COLUMN `composer_usage_json` JSON NULL,
ADD INDEX `video_clips_engine_version_idx` (`engine_version`);
CREATE TABLE `project_pipeline_configs` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`ip_isolation_enabled` BOOLEAN NOT NULL DEFAULT true,
`video_engine_enabled` BOOLEAN NOT NULL DEFAULT true,
`scene_composer_enabled` BOOLEAN NOT NULL DEFAULT false,
`default_generation_mode` VARCHAR(50) NOT NULL DEFAULT 'single_model',
`video_engine_version` VARCHAR(80) NOT NULL DEFAULT 'video_engine_v1',
`scene_composer_version` VARCHAR(80) NOT NULL DEFAULT 'scene_composer_v1_reserved',
`ip_rules_json` JSON NULL,
`video_engine_config_json` JSON NULL,
`scene_composer_config_json` JSON 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),
PRIMARY KEY (`id`),
UNIQUE INDEX `project_pipeline_configs_project_id_key` (`project_id`),
INDEX `project_pipeline_configs_scene_composer_enabled_idx` (`scene_composer_enabled`),
INDEX `project_pipeline_configs_default_generation_mode_idx` (`default_generation_mode`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
INSERT INTO `project_pipeline_configs` (
`project_id`,
`ip_isolation_enabled`,
`video_engine_enabled`,
`scene_composer_enabled`,
`default_generation_mode`,
`video_engine_version`,
`scene_composer_version`,
`ip_rules_json`,
`video_engine_config_json`,
`scene_composer_config_json`
)
SELECT
`id`,
true,
true,
false,
'single_model',
'video_engine_v1',
'scene_composer_v1_reserved',
JSON_OBJECT(
'character_ip_policy', 'clean_reusable_assets_only',
'story_character_policy', 'story_bible_and_relationships_only',
'project_character_policy', 'project_look_anchor_scene_state_only',
'forbidden_ip_fields', JSON_ARRAY('episode_plot', 'shot_state', 'project_costume', 'scene_action')
),
JSON_OBJECT(
'mode', 'single_model_first',
'candidate_count_default', 1,
'multi_model_compare_default_enabled', false,
'requires_human_clip_selection', true
),
JSON_OBJECT(
'enabled', false,
'reserved_stage', 'phase_5',
'notes', 'Scene Composer is recorded for future automatic ordering/emotion/transition logic. Current production keeps human clip selection and FFmpeg render.'
)
FROM `projects`
ON DUPLICATE KEY UPDATE `updated_at` = CURRENT_TIMESTAMP(3);
@@ -0,0 +1,34 @@
CREATE TABLE `character_prompt_versions` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NULL,
`character_id` BIGINT NULL,
`global_character_id` BIGINT NULL,
`look_version_id` BIGINT NULL,
`version_no` INTEGER NOT NULL,
`layer_code` VARCHAR(80) NOT NULL DEFAULT 'main_anchor',
`channel` VARCHAR(80) NOT NULL DEFAULT 'chatgpt_web',
`title` VARCHAR(160) NULL,
`source_type` VARCHAR(80) NOT NULL DEFAULT 'system_refresh',
`source_label` VARCHAR(160) NULL,
`prompt_engine_version` VARCHAR(80) NULL,
`prompt_text` LONGTEXT NOT NULL,
`negative_prompt` LONGTEXT NULL,
`model_name` VARCHAR(160) NULL,
`usage_note` TEXT NULL,
`quality_score` DECIMAL(5, 2) NULL,
`review_comment` TEXT NULL,
`is_active` BOOLEAN NOT NULL DEFAULT false,
`metadata_json` JSON NULL,
`created_by_user_id` BIGINT NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'active',
`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),
PRIMARY KEY (`id`),
UNIQUE INDEX `cpv_character_version_key` (`character_id`, `version_no`),
UNIQUE INDEX `cpv_global_version_key` (`global_character_id`, `version_no`),
INDEX `cpv_project_idx` (`project_id`),
INDEX `cpv_character_layer_channel_idx` (`character_id`, `layer_code`, `channel`),
INDEX `cpv_global_layer_channel_idx` (`global_character_id`, `layer_code`, `channel`),
INDEX `cpv_look_version_idx` (`look_version_id`),
INDEX `cpv_active_status_idx` (`is_active`, `status`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -0,0 +1,60 @@
CREATE TABLE `character_prompt_reviews` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`task_id` BIGINT NULL,
`character_id` BIGINT NULL,
`global_character_id` BIGINT NULL,
`prompt_version_id` BIGINT NULL,
`layer_code` VARCHAR(80) NOT NULL,
`image_type` VARCHAR(80) NULL,
`review_round` INTEGER NOT NULL DEFAULT 1,
`review_mode` VARCHAR(80) NOT NULL DEFAULT 'api_preflight',
`prompt_engine_version` VARCHAR(80) NULL,
`threshold_score` DECIMAL(5, 2) NOT NULL DEFAULT 92.00,
`score` DECIMAL(5, 2) NULL,
`passed` BOOLEAN NOT NULL DEFAULT false,
`status` VARCHAR(50) NOT NULL DEFAULT 'pending',
`source_prompt` LONGTEXT NULL,
`optimized_prompt` LONGTEXT NULL,
`negative_prompt` LONGTEXT NULL,
`issues_json` JSON NULL,
`suggestions_json` JSON NULL,
`reusable_rules_json` JSON NULL,
`quality_gate_json` JSON NULL,
`provider_log_id` BIGINT NULL,
`provider_code` VARCHAR(100) NULL,
`model_name` VARCHAR(160) NULL,
`raw_text` LONGTEXT NULL,
`error_message` TEXT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
INDEX `cpr_project_created_idx` (`project_id`, `created_at`),
INDEX `cpr_task_idx` (`task_id`),
INDEX `cpr_character_layer_idx` (`character_id`, `layer_code`),
INDEX `cpr_global_layer_idx` (`global_character_id`, `layer_code`),
INDEX `cpr_prompt_version_idx` (`prompt_version_id`),
INDEX `cpr_layer_passed_idx` (`layer_code`, `passed`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `character_prompt_optimization_lessons` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`scope_type` VARCHAR(50) NOT NULL DEFAULT 'global',
`layer_code` VARCHAR(80) NOT NULL,
`lesson_type` VARCHAR(80) NOT NULL DEFAULT 'prompt_rule',
`title` VARCHAR(160) NULL,
`rule_text` TEXT NOT NULL,
`prompt_engine_version` VARCHAR(80) NULL,
`source_review_id` BIGINT NULL,
`source_character_id` BIGINT NULL,
`source_prompt_version_id` BIGINT NULL,
`quality_score` DECIMAL(5, 2) NULL,
`usage_count` INTEGER NOT NULL DEFAULT 0,
`metadata_json` JSON NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'active',
`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),
PRIMARY KEY (`id`),
INDEX `cpol_scope_layer_status_idx` (`scope_type`, `layer_code`, `status`),
INDEX `cpol_review_idx` (`source_review_id`),
INDEX `cpol_character_idx` (`source_character_id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -0,0 +1,48 @@
CREATE TABLE `user_model_preferences` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`user_id` BIGINT NOT NULL,
`scope_key` VARCHAR(120) NOT NULL DEFAULT 'global',
`project_id` BIGINT NULL,
`preference_key` VARCHAR(120) NOT NULL,
`text_provider_code` VARCHAR(100) NULL,
`image_provider_code` VARCHAR(100) NULL,
`video_provider_code` VARCHAR(100) NULL,
`voice_provider_code` VARCHAR(100) NULL,
`metadata_json` JSON 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),
PRIMARY KEY (`id`),
UNIQUE INDEX `ump_user_scope_key_unique` (`user_id`, `scope_key`, `preference_key`),
INDEX `ump_user_scope_idx` (`user_id`, `scope_key`),
INDEX `ump_project_idx` (`project_id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `project_visual_assets` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`user_id` BIGINT NOT NULL,
`asset_id` BIGINT NULL,
`asset_kind` VARCHAR(50) NOT NULL,
`asset_type` VARCHAR(80) NOT NULL,
`name` VARCHAR(160) NOT NULL,
`label` VARCHAR(160) NULL,
`ownership_type` VARCHAR(50) NOT NULL DEFAULT 'shared_story',
`owner_character_id` BIGINT NULL,
`source_type` VARCHAR(80) NOT NULL DEFAULT 'manual',
`source_label` VARCHAR(160) NULL,
`prompt_text` LONGTEXT NULL,
`negative_prompt` LONGTEXT NULL,
`usage_tags_json` JSON NULL,
`quality_score` DECIMAL(5, 2) NULL,
`is_primary` BOOLEAN NOT NULL DEFAULT false,
`metadata_json` JSON NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'active',
`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),
PRIMARY KEY (`id`),
INDEX `pva_project_kind_status_idx` (`project_id`, `asset_kind`, `status`),
INDEX `pva_project_type_idx` (`project_id`, `asset_type`),
INDEX `pva_owner_character_idx` (`owner_character_id`),
INDEX `pva_asset_idx` (`asset_id`),
INDEX `pva_user_kind_idx` (`user_id`, `asset_kind`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -0,0 +1,20 @@
ALTER TABLE `project_visual_assets`
ADD COLUMN `aliases_json` JSON NULL,
ADD COLUMN `allowed_roles_json` JSON NULL,
ADD COLUMN `detected_from` VARCHAR(160) NULL,
ADD COLUMN `importance` INT NOT NULL DEFAULT 50,
ADD COLUMN `visual_lock` LONGTEXT NULL,
ADD COLUMN `key_objects_json` JSON NULL,
ADD COLUMN `prompt_block` LONGTEXT NULL,
ADD COLUMN `anchor_prompt` LONGTEXT NULL,
ADD COLUMN `reference_images_json` JSON NULL,
ADD COLUMN `anchor_images_json` JSON NULL,
ADD COLUMN `render_variants_json` JSON NULL,
ADD COLUMN `linked_assets_json` JSON NULL,
ADD COLUMN `reuse_rule` LONGTEXT NULL,
ADD COLUMN `story_scope` VARCHAR(120) NULL,
ADD COLUMN `version_note` LONGTEXT NULL,
ADD COLUMN `notes` LONGTEXT NULL;
CREATE INDEX `pva_project_ownership_idx` ON `project_visual_assets`(`project_id`, `ownership_type`);
CREATE INDEX `pva_project_status_importance_idx` ON `project_visual_assets`(`project_id`, `status`, `importance`);
@@ -0,0 +1,7 @@
ALTER TABLE `assets`
ADD COLUMN `display_name` VARCHAR(255) NULL,
ADD COLUMN `selection_status` VARCHAR(30) NOT NULL DEFAULT 'candidate',
ADD COLUMN `selection_note` TEXT NULL,
ADD COLUMN `metadata_json` JSON NULL;
CREATE INDEX `assets_project_id_selection_status_idx` ON `assets`(`project_id`, `selection_status`);
@@ -0,0 +1,4 @@
ALTER TABLE `storyboard_shots`
ADD COLUMN `transition_to_next` VARCHAR(50) NULL,
ADD COLUMN `transition_to_next_duration` DECIMAL(5, 2) NULL,
ADD COLUMN `transition_to_next_reason` TEXT NULL;
@@ -0,0 +1,42 @@
ALTER TABLE `character_images`
ADD COLUMN `prompt_quality_score` DECIMAL(5, 2) NULL AFTER `is_anchor`;
UPDATE `character_images`
SET `prompt_quality_score` = `quality_score`,
`quality_score` = NULL
WHERE `quality_score` IS NOT NULL;
CREATE TABLE `character_image_quality_reviews` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`character_id` BIGINT NOT NULL,
`character_image_id` BIGINT NOT NULL,
`asset_id` BIGINT NOT NULL,
`image_type` VARCHAR(80) NOT NULL,
`review_round` INTEGER NOT NULL DEFAULT 1,
`review_mode` VARCHAR(80) NOT NULL DEFAULT 'post_generation_visual',
`threshold_score` DECIMAL(5, 2) NOT NULL DEFAULT 96.00,
`score` DECIMAL(5, 2) NULL,
`grade` VARCHAR(20) NULL,
`passed` BOOLEAN NOT NULL DEFAULT false,
`status` VARCHAR(50) NOT NULL DEFAULT 'pending',
`dimensions_json` JSON NULL,
`strengths_json` JSON NULL,
`issues_json` JSON NULL,
`improvement_rules_json` JSON NULL,
`character_observations_json` JSON NULL,
`quality_gate_json` JSON NULL,
`provider_log_id` BIGINT NULL,
`provider_code` VARCHAR(100) NULL,
`model_name` VARCHAR(160) NULL,
`raw_text` LONGTEXT NULL,
`error_message` TEXT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
INDEX `ciqr_project_created_idx` (`project_id`, `created_at`),
INDEX `ciqr_character_type_idx` (`character_id`, `image_type`),
INDEX `ciqr_image_round_idx` (`character_image_id`, `review_round`),
INDEX `ciqr_asset_idx` (`asset_id`),
INDEX `ciqr_type_passed_idx` (`image_type`, `passed`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -0,0 +1,79 @@
ALTER TABLE `projects`
ADD COLUMN `engine_version` VARCHAR(50) NOT NULL DEFAULT 'legacy_v1',
ADD COLUMN `production_lifecycle` VARCHAR(80) NOT NULL DEFAULT 'legacy_snapshot';
CREATE INDEX `project_engine_lifecycle_idx`
ON `projects`(`engine_version`, `production_lifecycle`);
CREATE TABLE `production_contracts` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`contract_type` VARCHAR(50) NOT NULL,
`schema_version` VARCHAR(80) NOT NULL,
`version` INTEGER NOT NULL,
`scope_key` VARCHAR(100) NOT NULL DEFAULT 'project',
`parent_contract_id` BIGINT NULL,
`source_snapshot_id` VARCHAR(160) NULL,
`input_hash` VARCHAR(64) NOT NULL,
`input_snapshot_json` JSON NULL,
`payload_json` JSON NOT NULL,
`source_refs_json` JSON NULL,
`quality_result_json` JSON NULL,
`reviewer_delta_json` JSON NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'draft',
`downstream_status` VARCHAR(50) NOT NULL DEFAULT 'blocked',
`created_by_user_id` BIGINT NULL,
`confirmed_at` DATETIME(3) NULL,
`released_at` DATETIME(3) NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
UNIQUE INDEX `pc_project_type_scope_version_key`(`project_id`, `contract_type`, `scope_key`, `version`),
INDEX `pc_project_type_status_idx`(`project_id`, `contract_type`, `status`),
INDEX `pc_parent_idx`(`parent_contract_id`),
INDEX `pc_input_hash_idx`(`input_hash`),
INDEX `pc_downstream_status_idx`(`downstream_status`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `production_source_snapshots` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`novel_source_id` BIGINT NULL,
`snapshot_version` INTEGER NOT NULL,
`source_type` VARCHAR(50) NOT NULL,
`title` VARCHAR(255) NULL,
`content_hash` VARCHAR(64) NOT NULL,
`content_text` LONGTEXT NOT NULL,
`chapter_manifest_json` JSON NULL,
`metadata_json` JSON NULL,
`created_by_user_id` BIGINT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `pss_project_version_key`(`project_id`, `snapshot_version`),
INDEX `pss_project_source_idx`(`project_id`, `novel_source_id`),
INDEX `pss_content_hash_idx`(`content_hash`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `production_contract_reviews` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`contract_id` BIGINT NOT NULL,
`reviewer_type` VARCHAR(80) NOT NULL,
`reviewer_version` VARCHAR(80) NOT NULL,
`review_no` INTEGER NOT NULL,
`passed` BOOLEAN NOT NULL DEFAULT false,
`quality_score` DECIMAL(5, 2) NULL,
`issues_json` JSON NULL,
`delta_json` JSON NULL,
`provider_log_id` BIGINT NULL,
`created_by_user_id` BIGINT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `pcr_contract_reviewer_no_key`(`contract_id`, `reviewer_type`, `review_no`),
INDEX `pcr_project_reviewer_idx`(`project_id`, `reviewer_type`),
INDEX `pcr_contract_passed_idx`(`contract_id`, `passed`),
INDEX `pcr_provider_log_idx`(`provider_log_id`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -0,0 +1,213 @@
UPDATE `provider_configs`
SET
`display_name` = CASE
WHEN `provider_code` = 'kling-v3-omni-native-audio-720p-video' THEN '可灵 v3 Omni 原生音频 720P 视频'
ELSE '可灵 v3 Omni 原生音频 1080P 视频'
END,
`config_json` = JSON_REMOVE(
JSON_SET(
COALESCE(`config_json`, JSON_OBJECT()),
'$.driver', 'kling_omni_video',
'$.auth_type', 'bearer',
'$.api_key_env', 'KLING_API_KEY',
'$.base_url', 'https://api-singapore.klingai.com',
'$.timeout_ms', 600000,
'$.create_endpoint', '/v1/videos/omni-video',
'$.task_endpoint_template', '/v1/videos/omni-video/{task_id}',
'$.poll_interval_ms', 10000,
'$.max_poll_attempts', 120,
'$.model_field', 'model_name',
'$.prompt_field', 'prompt',
'$.duration_field', 'duration',
'$.aspect_ratio_field', 'aspect_ratio',
'$.mode_field', 'mode',
'$.aspect_ratio', '9:16',
'$.mode', CASE
WHEN `provider_code` = 'kling-v3-omni-native-audio-720p-video' THEN 'std'
ELSE 'pro'
END,
'$.resolution', CASE
WHEN `provider_code` = 'kling-v3-omni-native-audio-720p-video' THEN '720p'
ELSE '1080p'
END,
'$.duration', 5,
'$.allowed_modes', JSON_ARRAY('std', 'pro', '4k'),
'$.allowed_resolutions', JSON_ARRAY('720p', '1080p', '4k'),
'$.allowed_durations', JSON_ARRAY(3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15),
'$.max_prompt_length', 2500,
'$.max_multi_prompt_length', 512,
'$.max_multi_shots', 6,
'$.max_image_inputs', 7,
'$.web_max_image_inputs', 7,
'$.max_reference_images', 6,
'$.reference_image_limit', 6,
'$.max_image_inputs_without_video', 7,
'$.max_image_inputs_with_video', 4,
'$.max_video_inputs', 1,
'$.max_elements_with_start_end_frames', 3,
'$.input_image_formats', JSON_ARRAY('jpg', 'jpeg', 'png'),
'$.input_image_mime_types', JSON_ARRAY('image/jpeg', 'image/png'),
'$.input_image_max_mb', 10,
'$.input_image_min_dimension', 300,
'$.input_image_min_aspect_ratio', 0.4,
'$.input_image_max_aspect_ratio', 2.5,
'$.video_input_formats', JSON_ARRAY('mp4', 'mov'),
'$.video_input_min_seconds', 3,
'$.video_input_max_seconds', 15.5,
'$.video_input_max_mb', 200,
'$.video_input_min_fps', 24,
'$.video_input_max_fps', 60,
'$.video_input_min_dimension', 700,
'$.video_input_max_dimension', 4553,
'$.video_input_max_area', 8294400,
'$.video_modes', JSON_ARRAY('text_to_video', 'first_frame', 'start_end_frame', 'multi_image_reference', 'video_reference'),
'$.extra_body_json', JSON_OBJECT('sound', 'on', 'watermark_info', JSON_OBJECT('enabled', false)),
'$.supports_4k', true,
'$.supports_reference_image', true,
'$.supports_start_end_frame', true,
'$.supports_multi_image_reference', true,
'$.supports_reference_video', true,
'$.supports_multi_shot', true,
'$.supports_audio', true,
'$.supports_lipsync', true,
'$.supports_character_reference', true,
'$.supports_multi_character', true,
'$.supports_style_reference', true,
'$.note', 'Kling 3.0 Omni 官方新接口:支持原生 4K、最多 7 个图片与元素参考、单个参考视频、多镜头及原生音频。'
),
'$.access_key_env',
'$.secret_key_env',
'$.jwt_ttl_seconds',
'$.jwt_nbf_skew_seconds',
'$.image_field',
'$.reference_images_field',
'$.resolution_field'
),
`cost_rule_json` = JSON_SET(
COALESCE(`cost_rule_json`, JSON_OBJECT()),
'$.unit', 'video_seconds',
'$.currency', 'USD',
'$.price_per_second', CASE
WHEN `provider_code` = 'kling-v3-omni-native-audio-720p-video' THEN 0.112
ELSE 0.14
END,
'$.cny_fx_rate', 6.79,
'$.max_cost_per_call', CASE
WHEN `provider_code` = 'kling-v3-omni-native-audio-720p-video' THEN 2
ELSE 3
END,
'$.daily_cost_limit', CASE
WHEN `provider_code` = 'kling-v3-omni-native-audio-720p-video' THEN 30
ELSE 50
END,
'$.estimated_seconds', 10,
'$.note', CASE
WHEN `provider_code` = 'kling-v3-omni-native-audio-720p-video'
THEN 'Kling v3 Omni 原生音频 720P$0.112/s10s 约 $1.12 / ¥7.61;最终以 Kling 账单为准。'
ELSE 'Kling v3 Omni 原生音频 1080P$0.14/s10s 约 $1.40 / ¥9.51;最终以 Kling 账单为准。'
END
),
`updated_at` = NOW(3)
WHERE `provider_type` = 'VideoProvider'
AND `provider_code` IN (
'kling-v3-omni-native-audio-720p-video',
'kling-v3-omni-native-audio-1080p-video'
);
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',
'kling-v3-omni-native-audio-4k-video',
'可灵 v3 Omni 原生音频 4K 视频',
'real',
'kling-v3-omni',
JSON_OBJECT(
'driver', 'kling_omni_video',
'auth_type', 'bearer',
'api_key_env', 'KLING_API_KEY',
'base_url', 'https://api-singapore.klingai.com',
'timeout_ms', 600000,
'create_endpoint', '/v1/videos/omni-video',
'task_endpoint_template', '/v1/videos/omni-video/{task_id}',
'poll_interval_ms', 10000,
'max_poll_attempts', 120,
'model_field', 'model_name',
'prompt_field', 'prompt',
'duration_field', 'duration',
'aspect_ratio_field', 'aspect_ratio',
'mode_field', 'mode',
'aspect_ratio', '9:16',
'mode', '4k',
'resolution', '4k',
'duration', 5,
'allowed_modes', JSON_ARRAY('std', 'pro', '4k'),
'allowed_resolutions', JSON_ARRAY('720p', '1080p', '4k'),
'allowed_durations', JSON_ARRAY(3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15),
'max_prompt_length', 2500,
'max_multi_prompt_length', 512,
'max_multi_shots', 6,
'max_image_inputs', 7,
'web_max_image_inputs', 7,
'max_reference_images', 6,
'reference_image_limit', 6,
'max_image_inputs_without_video', 7,
'max_image_inputs_with_video', 4,
'max_video_inputs', 1,
'max_elements_with_start_end_frames', 3,
'input_image_formats', JSON_ARRAY('jpg', 'jpeg', 'png'),
'input_image_mime_types', JSON_ARRAY('image/jpeg', 'image/png'),
'input_image_max_mb', 10,
'input_image_min_dimension', 300,
'input_image_min_aspect_ratio', 0.4,
'input_image_max_aspect_ratio', 2.5,
'video_input_formats', JSON_ARRAY('mp4', 'mov'),
'video_input_min_seconds', 3,
'video_input_max_seconds', 15.5,
'video_input_max_mb', 200,
'video_input_min_fps', 24,
'video_input_max_fps', 60,
'video_input_min_dimension', 700,
'video_input_max_dimension', 4553,
'video_input_max_area', 8294400,
'video_modes', JSON_ARRAY('text_to_video', 'first_frame', 'start_end_frame', 'multi_image_reference', 'video_reference'),
'extra_body_json', JSON_OBJECT('sound', 'on', 'watermark_info', JSON_OBJECT('enabled', false)),
'supports_4k', true,
'supports_reference_image', true,
'supports_start_end_frame', true,
'supports_multi_image_reference', true,
'supports_reference_video', true,
'supports_multi_shot', true,
'supports_audio', true,
'supports_lipsync', true,
'supports_character_reference', true,
'supports_multi_character', true,
'supports_style_reference', true,
'note', '默认禁用。Kling 3.0 Omni 官方原生 4K 模式,支持最多 7 个图片与元素参考;4K 成本较高,正式批量前需单镜头验证。'
),
false,
41,
JSON_OBJECT('rpm', 5, 'concurrency', 1),
JSON_OBJECT(
'flat_cost', 0,
'unit', 'video_seconds',
'price_per_second', 0.42,
'currency', 'USD',
'cny_fx_rate', 6.79,
'max_cost_per_call', 7,
'daily_cost_limit', 100,
'estimated_seconds', 10,
'note', 'Kling v3 Omni 原生音频 4K$0.42/s10s 约 $4.20 / ¥28.52;最终以 Kling 账单为准。'
),
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`),
`cost_rule_json` = VALUES(`cost_rule_json`),
`updated_at` = NOW(3);
@@ -0,0 +1,230 @@
UPDATE `provider_configs`
SET
`config_json` = JSON_REMOVE(
JSON_SET(
COALESCE(`config_json`, JSON_OBJECT()),
'$.driver', 'kling_v3_image_to_video',
'$.auth_type', 'bearer',
'$.api_key_env', 'KLING_API_KEY',
'$.base_url', 'https://api-singapore.klingai.com',
'$.timeout_ms', 600000,
'$.create_endpoint', '/v1/videos/image2video',
'$.task_endpoint_template', '/v1/videos/image2video/{task_id}',
'$.poll_interval_ms', 10000,
'$.max_poll_attempts', 120,
'$.aspect_ratio', '9:16',
'$.mode', CASE
WHEN `provider_code` = 'kling-v3-native-audio-720p-video' THEN 'std'
ELSE 'pro'
END,
'$.resolution', CASE
WHEN `provider_code` = 'kling-v3-native-audio-720p-video' THEN '720p'
ELSE '1080p'
END,
'$.duration', 5,
'$.allowed_modes', JSON_ARRAY('std', 'pro', '4k'),
'$.allowed_resolutions', JSON_ARRAY('720p', '1080p', '4k'),
'$.allowed_durations', JSON_ARRAY(3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15),
'$.max_prompt_length', 2500,
'$.max_multi_prompt_length', 512,
'$.max_multi_shots', 6,
'$.max_image_inputs', 2,
'$.web_max_image_inputs', 2,
'$.max_elements', 3,
'$.max_voices', 2,
'$.input_image_formats', JSON_ARRAY('jpg', 'jpeg', 'png'),
'$.input_image_mime_types', JSON_ARRAY('image/jpeg', 'image/png'),
'$.input_image_max_mb', 10,
'$.input_image_min_dimension', 300,
'$.input_image_min_aspect_ratio', 0.4,
'$.input_image_max_aspect_ratio', 2.5,
'$.video_modes', JSON_ARRAY('first_frame', 'end_frame', 'start_end_frame'),
'$.extra_body_json', JSON_OBJECT('sound', 'on', 'watermark_info', JSON_OBJECT('enabled', false)),
'$.supports_4k', true,
'$.supports_reference_image', true,
'$.supports_start_end_frame', true,
'$.supports_elements', true,
'$.supports_voices', true,
'$.supports_multi_shot', true,
'$.supports_audio', true,
'$.supports_lipsync', true,
'$.supports_character_reference', true,
'$.supports_multi_character', true,
'$.supports_style_reference', false,
'$.capability_version', 'kling_video_capabilities_2026-07-15',
'$.formal_splus_model', true,
'$.official_doc_url', 'https://kling.ai/document-api/api/video/3-0-omni/image-to-video',
'$.note', 'Kling v3 官方 image2video 适配器:首帧/尾帧、最多3个元素、最多2个音色、自定义或智能多镜头、原生音频与4K。'
),
'$.access_key_env',
'$.secret_key_env',
'$.jwt_ttl_seconds',
'$.jwt_nbf_skew_seconds',
'$.model_field',
'$.image_field',
'$.prompt_field',
'$.duration_field',
'$.aspect_ratio_field',
'$.resolution_field',
'$.reference_images_field',
'$.max_reference_images',
'$.reference_image_limit'
),
`updated_at` = NOW(3)
WHERE `provider_type` = 'VideoProvider'
AND `provider_code` IN (
'kling-v3-native-audio-720p-video',
'kling-v3-native-audio-video'
);
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',
'kling-v3-native-audio-4k-video',
'可灵 v3 原生音频 4K 图生视频',
'real',
'kling-v3',
JSON_OBJECT(
'driver', 'kling_v3_image_to_video',
'auth_type', 'bearer',
'api_key_env', 'KLING_API_KEY',
'base_url', 'https://api-singapore.klingai.com',
'timeout_ms', 600000,
'create_endpoint', '/v1/videos/image2video',
'task_endpoint_template', '/v1/videos/image2video/{task_id}',
'poll_interval_ms', 10000,
'max_poll_attempts', 120,
'aspect_ratio', '9:16',
'mode', '4k',
'resolution', '4k',
'duration', 5,
'allowed_modes', JSON_ARRAY('std', 'pro', '4k'),
'allowed_resolutions', JSON_ARRAY('720p', '1080p', '4k'),
'allowed_durations', JSON_ARRAY(3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15),
'max_prompt_length', 2500,
'max_multi_prompt_length', 512,
'max_multi_shots', 6,
'max_image_inputs', 2,
'web_max_image_inputs', 2,
'max_elements', 3,
'max_voices', 2,
'input_image_formats', JSON_ARRAY('jpg', 'jpeg', 'png'),
'input_image_mime_types', JSON_ARRAY('image/jpeg', 'image/png'),
'input_image_max_mb', 10,
'input_image_min_dimension', 300,
'input_image_min_aspect_ratio', 0.4,
'input_image_max_aspect_ratio', 2.5,
'video_modes', JSON_ARRAY('first_frame', 'end_frame', 'start_end_frame'),
'extra_body_json', JSON_OBJECT('sound', 'on', 'watermark_info', JSON_OBJECT('enabled', false)),
'supports_4k', true,
'supports_reference_image', true,
'supports_start_end_frame', true,
'supports_elements', true,
'supports_voices', true,
'supports_multi_shot', true,
'supports_audio', true,
'supports_lipsync', true,
'supports_character_reference', true,
'supports_multi_character', true,
'supports_style_reference', false,
'capability_version', 'kling_video_capabilities_2026-07-15',
'formal_splus_model', true,
'official_doc_url', 'https://kling.ai/document-api/api/video/3-0-omni/image-to-video',
'note', '默认禁用。Kling v3 官方图生视频4K模式;正式批量前需单镜头验证。'
),
false,
43,
JSON_OBJECT('rpm', 5, 'concurrency', 1),
JSON_OBJECT(
'flat_cost', 0,
'unit', 'video_seconds',
'price_per_second', 0.504,
'currency', 'USD',
'cny_fx_rate', 6.79,
'max_cost_per_call', 8,
'daily_cost_limit', 120,
'estimated_seconds', 10,
'note', 'Kling v3 原生音频4K暂估 $0.504/s10s约 $5.04 / ¥34.22;最终以Kling账单为准。'
),
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`),
`cost_rule_json` = VALUES(`cost_rule_json`),
`updated_at` = NOW(3);
UPDATE `provider_configs`
SET
`config_json` = JSON_SET(
COALESCE(`config_json`, JSON_OBJECT()),
'$.capability_version', 'kling_video_capabilities_2026-07-15',
'$.formal_splus_model', true,
'$.official_doc_url', 'https://kling.ai/document-api/api/video/3-0-omni/video-omni',
'$.reference_video_default_type', 'base',
'$.reference_video_character_policy', 'reject_with_reference_video',
'$.custom_multi_shot_omits_top_prompt', true
),
`updated_at` = NOW(3)
WHERE `provider_type` = 'VideoProvider'
AND `provider_code` IN (
'kling-v3-omni-native-audio-720p-video',
'kling-v3-omni-native-audio-1080p-video',
'kling-v3-omni-native-audio-4k-video'
);
UPDATE `provider_configs`
SET
`config_json` = JSON_SET(
COALESCE(`config_json`, JSON_OBJECT()),
'$.formal_splus_model', false,
'$.splus_usage', 'legacy_or_experiment_only'
),
`updated_at` = NOW(3)
WHERE `provider_type` = 'VideoProvider'
AND `provider_code` LIKE 'kling%'
AND `provider_code` NOT IN (
'kling-v3-native-audio-720p-video',
'kling-v3-native-audio-video',
'kling-v3-native-audio-4k-video',
'kling-v3-omni-native-audio-720p-video',
'kling-v3-omni-native-audio-1080p-video',
'kling-v3-omni-native-audio-4k-video'
);
UPDATE `system_configs`
SET
`config_value` = JSON_SET(
COALESCE(`config_value`, JSON_OBJECT()),
'$.splus_live_action_video',
JSON_OBJECT(
'capability_version', 'kling_video_capabilities_2026-07-15',
'formal_provider_codes', JSON_ARRAY(
'kling-v3-omni-native-audio-720p-video',
'kling-v3-omni-native-audio-1080p-video',
'kling-v3-omni-native-audio-4k-video',
'kling-v3-native-audio-720p-video',
'kling-v3-native-audio-video',
'kling-v3-native-audio-4k-video'
),
'zh-CN', JSON_OBJECT(
'thresholds', JSON_OBJECT('premium_importance_gt', 7, 'premium_action_gt', 5),
'normal', JSON_OBJECT(
'provider_code', 'kling-v3-omni-native-audio-1080p-video',
'fallback_chain', JSON_ARRAY('kling-v3-omni-native-audio-1080p-video', 'kling-v3-native-audio-video')
),
'premium', JSON_OBJECT(
'provider_code', 'kling-v3-omni-native-audio-1080p-video',
'fallback_chain', JSON_ARRAY('kling-v3-omni-native-audio-1080p-video', 'kling-v3-native-audio-video')
)
)
)
),
`updated_at` = NOW(3)
WHERE `config_key` = 'ai.router.v1';
@@ -0,0 +1,15 @@
UPDATE `provider_configs`
SET
`cost_rule_json` = JSON_SET(
COALESCE(`cost_rule_json`, JSON_OBJECT()),
'$.price_per_second', 0.42,
'$.currency', 'USD',
'$.cny_fx_rate', 6.79,
'$.max_cost_per_call', 7,
'$.daily_cost_limit', 100,
'$.estimated_seconds', 10,
'$.note', 'Kling v3 原生音频4K $0.42/s10s约 $4.20 / ¥28.52;最终以Kling账单为准。'
),
`updated_at` = NOW(3)
WHERE `provider_type` = 'VideoProvider'
AND `provider_code` = 'kling-v3-native-audio-4k-video';
@@ -0,0 +1,64 @@
CREATE TABLE `shot_generation_plans` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`project_id` BIGINT NOT NULL,
`episode_id` BIGINT NOT NULL,
`shot_id` BIGINT NOT NULL,
`revision` INTEGER NOT NULL,
`plan_version` VARCHAR(80) NOT NULL,
`plan_hash` VARCHAR(64) NOT NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'frozen',
`source_engine_version` VARCHAR(50) NOT NULL,
`provider_code` VARCHAR(100) NOT NULL,
`provider_id` BIGINT NULL,
`model_name` VARCHAR(160) NULL,
`endpoint` VARCHAR(255) NULL,
`capability_version` VARCHAR(100) NULL,
`route_tier` VARCHAR(50) NULL,
`effective_mode` VARCHAR(30) NULL,
`resolution_recommendation` VARCHAR(30) NULL,
`effective_generation_resolution` VARCHAR(30) NULL,
`aspect_ratio` VARCHAR(30) NULL,
`duration` DECIMAL(6, 2) NULL,
`sound_enabled` BOOLEAN NOT NULL DEFAULT true,
`multi_shot` BOOLEAN NOT NULL DEFAULT false,
`native_4k_candidate` BOOLEAN NOT NULL DEFAULT false,
`project_native_4k_enabled` BOOLEAN NOT NULL DEFAULT false,
`required_assets_json` JSON NULL,
`element_plan_json` JSON NULL,
`voice_plan_json` JSON NULL,
`keyframe_plan_json` JSON NULL,
`video_request_json` JSON NULL,
`router_decision_json` JSON NOT NULL,
`quality_policy_json` JSON NOT NULL,
`retry_policy_json` JSON NOT NULL,
`fallback_chain_json` JSON NOT NULL,
`cost_policy_json` JSON NULL,
`provider_snapshot_json` JSON NULL,
`prompt_snapshot_json` JSON NULL,
`frozen_by_user_id` BIGINT NULL,
`frozen_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX `sgp_shot_revision_key` (`shot_id`, `revision`),
UNIQUE INDEX `sgp_shot_hash_key` (`shot_id`, `plan_hash`),
INDEX `sgp_project_episode_shot_idx` (`project_id`, `episode_id`, `shot_id`),
INDEX `sgp_provider_capability_idx` (`provider_code`, `capability_version`),
INDEX `sgp_status_created_idx` (`status`, `created_at`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
ALTER TABLE `storyboard_shots`
ADD COLUMN `active_generation_plan_id` BIGINT NULL,
ADD INDEX `ss_active_generation_plan_idx` (`active_generation_plan_id`);
ALTER TABLE `shot_images`
ADD COLUMN `generation_plan_id` BIGINT NULL,
ADD INDEX `si_generation_plan_idx` (`generation_plan_id`);
ALTER TABLE `video_clips`
ADD COLUMN `generation_plan_id` BIGINT NULL,
ADD INDEX `vc_generation_plan_idx` (`generation_plan_id`);
ALTER TABLE `render_tasks`
ADD COLUMN `generation_plan_id` BIGINT NULL,
ADD INDEX `rt_generation_plan_idx` (`generation_plan_id`);
@@ -0,0 +1,71 @@
ALTER TABLE `shot_generation_plans`
ADD COLUMN `capability_registry_version_id` BIGINT NULL AFTER `provider_id`,
ADD COLUMN `parameter_schema_version_id` BIGINT NULL AFTER `capability_registry_version_id`,
ADD COLUMN `pricing_version_id` BIGINT NULL AFTER `parameter_schema_version_id`;
CREATE INDEX `sgp_capability_registry_idx`
ON `shot_generation_plans`(`capability_registry_version_id`);
CREATE INDEX `sgp_parameter_schema_idx`
ON `shot_generation_plans`(`parameter_schema_version_id`);
CREATE INDEX `sgp_pricing_version_idx`
ON `shot_generation_plans`(`pricing_version_id`);
CREATE TABLE `model_capability_versions` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`provider_type` VARCHAR(80) NOT NULL,
`provider_code` VARCHAR(100) NOT NULL,
`model_name` VARCHAR(160) NULL,
`revision` INTEGER NOT NULL,
`version_key` VARCHAR(160) NOT NULL,
`content_hash` VARCHAR(64) NOT NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'published',
`capability_json` JSON NOT NULL,
`source_json` JSON NULL,
`effective_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`created_by_user_id` BIGINT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE INDEX `mcv_provider_revision_key`(`provider_type`, `provider_code`, `revision`),
UNIQUE INDEX `mcv_provider_hash_key`(`provider_type`, `provider_code`, `content_hash`),
INDEX `mcv_provider_status_effective_idx`(`provider_code`, `status`, `effective_at`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `model_parameter_schema_versions` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`provider_type` VARCHAR(80) NOT NULL,
`provider_code` VARCHAR(100) NOT NULL,
`model_name` VARCHAR(160) NULL,
`revision` INTEGER NOT NULL,
`version_key` VARCHAR(160) NOT NULL,
`content_hash` VARCHAR(64) NOT NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'published',
`schema_json` JSON NOT NULL,
`source_json` JSON NULL,
`effective_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`created_by_user_id` BIGINT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE INDEX `mpsv_provider_revision_key`(`provider_type`, `provider_code`, `revision`),
UNIQUE INDEX `mpsv_provider_hash_key`(`provider_type`, `provider_code`, `content_hash`),
INDEX `mpsv_provider_status_effective_idx`(`provider_code`, `status`, `effective_at`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE `model_pricing_versions` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`provider_type` VARCHAR(80) NOT NULL,
`provider_code` VARCHAR(100) NOT NULL,
`model_name` VARCHAR(160) NULL,
`revision` INTEGER NOT NULL,
`version_key` VARCHAR(160) NOT NULL,
`content_hash` VARCHAR(64) NOT NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'published',
`pricing_json` JSON NOT NULL,
`source_json` JSON NULL,
`effective_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`created_by_user_id` BIGINT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE INDEX `mpv_provider_revision_key`(`provider_type`, `provider_code`, `revision`),
UNIQUE INDEX `mpv_provider_hash_key`(`provider_type`, `provider_code`, `content_hash`),
INDEX `mpv_provider_status_effective_idx`(`provider_code`, `status`, `effective_at`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -0,0 +1,60 @@
CREATE TABLE `character_provider_bindings` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`global_character_id` BIGINT NOT NULL,
`look_version_id` BIGINT NULL,
`source_asset_id` BIGINT NULL,
`provider_code` VARCHAR(100) NOT NULL,
`provider_asset_type` VARCHAR(50) NOT NULL DEFAULT 'video_character_element',
`provider_element_id` VARCHAR(191) NOT NULL,
`element_name` VARCHAR(160) NOT NULL,
`element_description` TEXT NULL,
`source_duration` DECIMAL(5,2) NULL,
`voice_bound` BOOLEAN NOT NULL DEFAULT false,
`voice_id` VARCHAR(160) NULL,
`voice_description` TEXT NULL,
`binding_version` INTEGER NOT NULL DEFAULT 1,
`validation_score` DECIMAL(5,2) NULL,
`validation_report_json` JSON NULL,
`status` VARCHAR(50) NOT NULL DEFAULT 'candidate',
`is_primary` BOOLEAN NOT NULL DEFAULT false,
`created_by_user_id` BIGINT NULL,
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL,
UNIQUE INDEX `cpb_provider_element_key`(`provider_code`, `provider_element_id`),
INDEX `cpb_character_provider_status_idx`(`global_character_id`, `provider_code`, `status`),
INDEX `cpb_look_version_idx`(`look_version_id`),
INDEX `cpb_source_asset_idx`(`source_asset_id`),
INDEX `cpb_primary_status_idx`(`is_primary`, `status`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
UPDATE `provider_configs`
SET `config_json` = JSON_SET(
COALESCE(`config_json`, JSON_OBJECT()),
'$.aspect_ratio', '16:9',
'$.allowed_aspect_ratios', JSON_ARRAY('16:9'),
'$.production_aspect_ratio', '16:9'
)
WHERE `provider_type` IN ('ImageProvider', 'VideoProvider');
UPDATE `provider_configs`
SET `config_json` = JSON_SET(`config_json`, '$.size', '2560x1440')
WHERE `provider_type` = 'ImageProvider'
AND JSON_UNQUOTE(JSON_EXTRACT(`config_json`, '$.size')) = '1440x2560';
UPDATE `provider_configs`
SET `config_json` = JSON_SET(`config_json`, '$.size', '1536x1024')
WHERE `provider_type` = 'ImageProvider'
AND JSON_UNQUOTE(JSON_EXTRACT(`config_json`, '$.size')) = '1024x1792';
UPDATE `provider_configs`
SET `config_json` = JSON_SET(
`config_json`,
'$.width', CAST(JSON_UNQUOTE(JSON_EXTRACT(`config_json`, '$.height')) AS UNSIGNED),
'$.height', CAST(JSON_UNQUOTE(JSON_EXTRACT(`config_json`, '$.width')) AS UNSIGNED)
)
WHERE `provider_type` IN ('ImageProvider', 'VideoProvider')
AND JSON_TYPE(JSON_EXTRACT(`config_json`, '$.width')) IN ('INTEGER', 'DOUBLE')
AND JSON_TYPE(JSON_EXTRACT(`config_json`, '$.height')) IN ('INTEGER', 'DOUBLE')
AND CAST(JSON_UNQUOTE(JSON_EXTRACT(`config_json`, '$.width')) AS UNSIGNED)
< CAST(JSON_UNQUOTE(JSON_EXTRACT(`config_json`, '$.height')) AS UNSIGNED);
@@ -0,0 +1,45 @@
UPDATE `provider_configs`
SET
`config_json` = JSON_SET(
COALESCE(`config_json`, JSON_OBJECT()),
'$.size', '2560x1440',
'$.quality', 'medium',
'$.allowed_sizes', JSON_ARRAY('1920x1080', '2560x1440', '3840x2160'),
'$.allowed_qualities', JSON_ARRAY('low', 'medium', 'high', 'auto'),
'$.supports_custom_size', true,
'$.supports_4k', true,
'$.max_width', 3840,
'$.max_height', 3840,
'$.max_pixels', 8294400,
'$.max_aspect_ratio', 3,
'$.experimental_above_size', '2560x1440',
'$.official_doc_url', 'https://developers.openai.com/api/docs/guides/image-generation'
),
`cost_rule_json` = JSON_REMOVE(
JSON_SET(
COALESCE(`cost_rule_json`, JSON_OBJECT()),
'$.flat_cost', 0,
'$.unit', 'openai_image_tokens',
'$.currency', 'USD',
'$.cny_fx_rate', 7.2,
'$.pricing_basis', 'official_openai_gpt_image_2_token_formula_2026_07_16',
'$.default_quality', 'medium',
'$.default_size', '2560x1440',
'$.estimated_cost_per_image', 0.05529,
'$.price_per_1m_text_input_tokens', 5,
'$.price_per_1m_cached_text_input_tokens', 1.25,
'$.price_per_1m_image_input_tokens', 8,
'$.price_per_1m_cached_image_input_tokens', 2,
'$.price_per_1m_image_output_tokens', 30,
'$.output_token_formula', 'ceil(long_quality_tokens*round(long_quality_tokens*short_edge/long_edge)*(2000000+width*height)/4000000)',
'$.output_price_examples', JSON_OBJECT(
'2560x1440_high', 0.2211,
'3840x2160_high', 0.40026
),
'$.note', 'GPT Image 2 按文字输入、参考图输入和图片输出 token 计费;2560×1440 High 纯输出约 $0.22113840×2160 High 纯输出约 $0.4003。参考图与提示词输入另计,以 OpenAI usage 和账单为准。'
),
'$.price_per_image_by_quality',
'$.comparable_models'
)
WHERE `provider_type` = 'ImageProvider'
AND `provider_code` = 'openai-image';
@@ -0,0 +1,2 @@
ALTER TABLE `project_pipeline_configs`
ALTER COLUMN `scene_composer_version` SET DEFAULT 'scene_composer_plan_v2';
@@ -0,0 +1,26 @@
UPDATE `project_pipeline_configs`
SET
`scene_composer_version` = 'scene_composer_plan_v2',
`scene_composer_config_json` = JSON_SET(
COALESCE(`scene_composer_config_json`, JSON_OBJECT()),
'$.plan_version', 'scene_composer_plan_v2',
'$.transition_contract_version', 'transition_contract_v2',
'$.picture_edit_policy', 'hard_picture_by_default_explicit_blends_only',
'$.audio_edit_policy', 'independent_j_cut_l_cut_and_audio_bridge',
'$.planned_features', JSON_ARRAY(
'shot_ordering',
'emotion_curve',
'transition_contract_v2',
'boundary_frame_policy',
'screen_vector_continuity',
'hard_picture_cut',
'explicit_visual_blends_only',
'independent_j_cut_l_cut',
'episode_builder',
'bgm_alignment',
'original_music_bible',
'sfx_cue_plan',
'ducking_plan'
),
'$.note', 'Scene Composer Plan V2 keeps picture hard by default, permits visual overlap only for explicit blend contracts, and schedules J-cuts, L-cuts and audio bridges independently.'
);
File diff suppressed because it is too large Load Diff
+324 -1
View File
@@ -1,4 +1,4 @@
import { PrismaClient } from '@prisma/client'; import { Prisma, PrismaClient } from '@prisma/client';
import { hash } from 'bcryptjs'; import { hash } from 'bcryptjs';
import { DEFAULT_AI_ROUTER_CONFIG } from '../src/ai-router/ai-router.types'; import { DEFAULT_AI_ROUTER_CONFIG } from '../src/ai-router/ai-router.types';
@@ -17,6 +17,296 @@ const mockProviders = [
['EmbeddingProvider', 'mock-embedding', 'Mock Embedding Provider', 'mock-embedding-v1'] ['EmbeddingProvider', 'mock-embedding', 'Mock Embedding Provider', 'mock-embedding-v1']
] as const; ] as const;
const defaultAgentPrompts = [
{
agent_name: 'NovelIdeaCoachAgent',
provider_type: 'TextProvider',
default_provider_code: 'volcengine-doubao-seed20-mini-text',
system_prompt: '你是网文平台金牌主编、商业化小说策划、听书编导和短剧改编策划。你的用户是不会写小说的运营,你必须把他的白话需求变成可选择的高质量小说方案。',
user_prompt_template: [
'请根据运营输入生成 3 个高商业化小说方向卡。必须严格输出紧凑 JSON,不要 Markdown,不要解释。',
'运营不是专业作家,所以每个方案要短、准、好判断:为什么能火、主角是谁、开篇怎么抓人。',
'本步骤只做方向筛选,不写卷纲和章节蓝图;后续 IP 圣经 Agent 会补完整结构。',
'必须兼顾后续听书和短剧改编:人物关系清楚、场景可视化、每章能有钩子。',
'输出 JSON 字段:proposals。proposals 是数组,每个元素包含:',
'id, title, genre, novel_scale, target_audience, target_words, target_chapters, style_code, logline, opening_hook, main_character, core_conflict, selling_points, risk_notes, adaptation_targets, writing_rules, forbidden_rules, score, operator_reason。',
'要求:',
'1. title 要像真实网文书名,不要泛泛而谈。',
'2. logline 不超过 35 字,opening_hook 不超过 55 字。',
'3. main_character 和 core_conflict 各不超过 35 字。',
'4. selling_points 只给 3 条,每条不超过 10 字;risk_notes 只给 2 条,每条不超过 12 字。',
'5. writing_rules 只给 2 条,forbidden_rules 只给 2 条,每条不超过 12 字。',
'6. target_words 和 target_chapters 必须是数字或 null。',
'7. adaptation_targets 只输出 ["听书","短剧"]operator_reason 不超过 35 字,score 是 0-100。',
'小说长度:{{novel_scale}}',
'目标读者:{{target_audience}}',
'题材:{{genre}}',
'风格代码:{{style_code}}',
'目标字数:{{target_words}}',
'目标章节数:{{target_chapters}}',
'后续用途:{{adaptation_targets}}',
'运营灵感:{{inspiration_text}}',
'必须包含:{{must_have_text}}',
'禁止内容:{{avoid_text}}',
'参考风格/作品:{{reference_titles}}'
].join('\n\n'),
output_schema_json: {
type: 'object',
required: ['proposals'],
properties: {
proposals: {
type: 'array',
items: {
type: 'object',
required: ['title', 'logline', 'opening_hook', 'main_character', 'core_conflict', 'selling_points', 'score'],
properties: {
id: { type: 'string' },
title: { type: 'string' },
genre: { type: 'string' },
novel_scale: { type: 'string' },
target_audience: { type: 'string' },
target_words: { type: 'number' },
target_chapters: { type: 'number' },
style_code: { type: 'string' },
logline: { type: 'string' },
opening_hook: { type: 'string' },
main_character: { type: 'string' },
core_conflict: { type: 'string' },
selling_points: { type: 'array' },
risk_notes: { type: 'array' },
adaptation_targets: { type: 'array' },
writing_rules: { type: 'array' },
forbidden_rules: { type: 'array' },
score: { type: 'number' },
operator_reason: { type: 'string' }
}
}
}
}
},
temperature: 0.72,
max_output_tokens: 1200
},
{
agent_name: 'IPBibleAgent',
provider_type: 'NovelProvider',
default_provider_code: 'openai-responses-novel',
system_prompt: '你是专业长篇小说总策划、网文主编、文学作家、剧作结构师和影视导演。你的任务是建立稳定、可长期创作、可听书、可短剧改编的 IP 圣经。',
user_prompt_template: [
'请根据用户 Brief 生成 IP 圣经。必须严格输出 JSON,不要 Markdown。',
'JSON 字段:core_logline, theme, worldbuilding, main_characters, supporting_characters, antagonist_system, volume_structure, foreshadow_plan, writing_rules, forbidden_rules, adaptation_rules。',
'要求:主线清晰,人设稳定,有禁止改动项,有伏笔规划,考虑听书和 AI 短剧改编。',
'用户 Brief{{brief}}',
'小说类型:{{novel_scale}}',
'题材:{{genre}}',
'风格:{{style}}',
'禁止内容:{{avoid}}'
].join('\n\n'),
output_schema_json: {
type: 'object',
required: ['core_logline', 'theme', 'worldbuilding', 'main_characters', 'volume_structure', 'writing_rules', 'forbidden_rules'],
properties: {
core_logline: { type: 'string' },
theme: { type: 'string' },
worldbuilding: { type: 'object' },
main_characters: { type: 'array' },
supporting_characters: { type: 'array' },
antagonist_system: { type: 'object' },
volume_structure: { type: 'array' },
foreshadow_plan: { type: 'array' },
writing_rules: { type: 'array' },
forbidden_rules: { type: 'array' },
adaptation_rules: { type: 'object' }
}
},
temperature: 0.6,
max_output_tokens: 6000
},
{
agent_name: 'ChapterCardAgent',
provider_type: 'NovelProvider',
default_provider_code: 'openai-responses-novel',
system_prompt: '你是长篇小说章节导演。你的任务是设计下一章章节卡,不写正文,只输出可执行的章节方案。',
user_prompt_template: [
'请生成第 {{chapter_no}} 章章节卡。必须严格输出 JSON,不要 Markdown。',
'本章必须承接上一章,推动主线、人物变化或伏笔进展,不得水剧情。',
'IP 圣经摘要:{{ip_bible_summary}}',
'当前卷纲:{{volume_outline}}',
'最近章节摘要:{{recent_summaries}}',
'人物当前状态:{{character_states}}',
'当前伏笔:{{active_foreshadows}}',
'禁止事项:{{forbidden_rules}}',
'请输出:chapter_no, title, chapter_goal, scenes, must_happen, must_not_happen, foreshadows_to_add, foreshadows_to_advance, foreshadows_to_resolve, ending_hook, adaptation_notes。'
].join('\n\n'),
output_schema_json: {
type: 'object',
required: ['chapter_no', 'title', 'chapter_goal', 'scenes', 'must_happen', 'must_not_happen', 'ending_hook'],
properties: {
chapter_no: { type: 'number' },
title: { type: 'string' },
chapter_goal: { type: 'string' },
scenes: { type: 'array' },
must_happen: { type: 'array' },
must_not_happen: { type: 'array' },
foreshadows_to_add: { type: 'array' },
foreshadows_to_advance: { type: 'array' },
foreshadows_to_resolve: { type: 'array' },
ending_hook: { type: 'string' },
adaptation_notes: { type: 'object' }
}
},
temperature: 0.45,
max_output_tokens: 4000
},
{
agent_name: 'NovelWriterAgent',
provider_type: 'NovelProvider',
default_provider_code: 'anthropic-claude-novel',
system_prompt: '你是专业小说家。你必须严格按章节卡写正文,不改变 IP 圣经,不改变人物状态,不提前揭露秘密。',
user_prompt_template: [
'请创作第 {{chapter_no}} 章正文,只输出正文,不要解释。',
'目标字数:{{target_words}}',
'文风规则:{{style_rules}}',
'IP 圣经摘要:{{ip_bible_summary}}',
'人物档案:{{character_profiles}}',
'人物当前状态:{{character_states}}',
'最近章节摘要:{{recent_summaries}}',
'章节卡:{{chapter_card}}',
'禁止规则:{{forbidden_rules}}',
'要求:画面感强、对白符合身份、每场戏推动剧情/人物/伏笔,不能流水账,不能狗血。'
].join('\n\n'),
output_schema_json: null,
temperature: 0.75,
max_output_tokens: 9000
},
{
agent_name: 'NovelPolishAgent',
provider_type: 'NovelProvider',
default_provider_code: 'anthropic-claude-novel',
system_prompt: '你是小说修辞和节奏编辑。你只负责润色文风、节奏、对白和画面感,不得改变剧情事实。',
user_prompt_template: [
'请润色下面章节,输出完整润色稿,不要解释。',
'目标文风:{{style_rules}}',
'章节卡:{{chapter_card}}',
'禁止改变:{{forbidden_rules}}',
'原稿:{{draft_text}}'
].join('\n\n'),
output_schema_json: null,
temperature: 0.65,
max_output_tokens: 9000
},
{
agent_name: 'ContinuityCheckAgent',
provider_type: 'NovelProvider',
default_provider_code: 'openai-responses-novel',
system_prompt: '你是严苛的小说连续性审稿人,只检查冲突,不负责夸奖。',
user_prompt_template: [
'请检查本章是否违背设定、人设、时间线、地点、伏笔。必须严格输出 JSON,不要 Markdown。',
'IP 圣经:{{ip_bible}}',
'人物状态:{{character_states}}',
'伏笔库:{{active_foreshadows}}',
'章节卡:{{chapter_card}}',
'本章正文:{{chapter_text}}',
'请输出:has_conflict, conflicts, character_drift, timeline_errors, setting_errors, foreshadow_errors, fix_suggestions。'
].join('\n\n'),
output_schema_json: {
type: 'object',
required: ['has_conflict', 'conflicts', 'fix_suggestions'],
properties: {
has_conflict: { type: 'boolean' },
conflicts: { type: 'array' },
character_drift: { type: 'array' },
timeline_errors: { type: 'array' },
setting_errors: { type: 'array' },
foreshadow_errors: { type: 'array' },
fix_suggestions: { type: 'array' }
}
},
temperature: 0.2,
max_output_tokens: 3000
},
{
agent_name: 'QualityCheckAgent',
provider_type: 'NovelProvider',
default_provider_code: 'openai-responses-novel',
system_prompt: '你是严苛的小说主编和质量评分官。你只输出评分、问题和修复策略。',
user_prompt_template: [
'请为本章评分。必须严格输出 JSON,不要 Markdown。',
'评分维度:剧情推进、人物一致性、上下文连续性、文风质量、情绪张力、伏笔管理、场景画面感、听书适配度、短剧适配度。',
'IP 圣经:{{ip_bible}}',
'章节卡:{{chapter_card}}',
'连续性检查:{{continuity_check}}',
'本章正文:{{chapter_text}}',
'请输出:pass, total_score, scores, problems, must_fix, optional_suggestions, rewrite_required, rewrite_strategy。'
].join('\n\n'),
output_schema_json: {
type: 'object',
required: ['pass', 'total_score', 'scores', 'problems', 'rewrite_required'],
properties: {
pass: { type: 'boolean' },
total_score: { type: 'number' },
scores: { type: 'object' },
problems: { type: 'array' },
must_fix: { type: 'array' },
optional_suggestions: { type: 'array' },
rewrite_required: { type: 'boolean' },
rewrite_strategy: { type: 'array' }
}
},
temperature: 0.2,
max_output_tokens: 3000
},
{
agent_name: 'RepairAgent',
provider_type: 'NovelProvider',
default_provider_code: 'anthropic-claude-novel',
system_prompt: '你是小说修稿编辑。你只修复质检指出的问题,不新增无关设定,不破坏上下文。',
user_prompt_template: [
'请根据质检报告修复本章,输出完整修复稿,不要解释。',
'IP 圣经摘要:{{ip_bible_summary}}',
'章节卡:{{chapter_card}}',
'原正文:{{chapter_text}}',
'质检报告:{{quality_report}}',
'连续性检查:{{continuity_check}}'
].join('\n\n'),
output_schema_json: null,
temperature: 0.55,
max_output_tokens: 9000
},
{
agent_name: 'MemoryUpdateAgent',
provider_type: 'NovelProvider',
default_provider_code: 'volcengine-doubao-seed20-pro-novel',
system_prompt: '你是小说连续性档案管理员。你必须准确、简洁,不得加入正文没有发生的内容,不得猜测未来。',
user_prompt_template: [
'请根据本章正文更新记忆库。必须严格输出 JSON,不要 Markdown。',
'已有角色状态:{{character_states}}',
'已有伏笔库:{{active_foreshadows}}',
'本章正文:{{chapter_text}}',
'请输出:chapter_summary, character_updates, relationship_updates, world_updates, timeline_update, location_update, new_foreshadows, updated_foreshadows, resolved_foreshadows, next_chapter_must_continue, forbidden_to_forget。'
].join('\n\n'),
output_schema_json: {
type: 'object',
required: ['chapter_summary', 'character_updates', 'timeline_update', 'next_chapter_must_continue'],
properties: {
chapter_summary: { type: 'string' },
character_updates: { type: 'array' },
relationship_updates: { type: 'array' },
world_updates: { type: 'array' },
timeline_update: { type: 'string' },
location_update: { type: 'string' },
new_foreshadows: { type: 'array' },
updated_foreshadows: { type: 'array' },
resolved_foreshadows: { type: 'array' },
next_chapter_must_continue: { type: 'array' },
forbidden_to_forget: { type: 'array' }
}
},
temperature: 0.25,
max_output_tokens: 3500
}
] as const;
async function main() { async function main() {
const adminPassword = process.env.SEED_ADMIN_PASSWORD || 'Admin123!'; const adminPassword = process.env.SEED_ADMIN_PASSWORD || 'Admin123!';
const adminPasswordHash = await hash(adminPassword, 12); const adminPasswordHash = await hash(adminPassword, 12);
@@ -84,6 +374,39 @@ async function main() {
}); });
} }
for (const prompt of defaultAgentPrompts) {
await prisma.agentPrompt.upsert({
where: {
agent_name_version: {
agent_name: prompt.agent_name,
version: 1
}
},
update: {
provider_type: prompt.provider_type,
default_provider_code: prompt.default_provider_code ?? null,
system_prompt: prompt.system_prompt,
user_prompt_template: prompt.user_prompt_template,
output_schema_json: prompt.output_schema_json ?? Prisma.JsonNull,
temperature: prompt.temperature,
max_output_tokens: prompt.max_output_tokens,
is_active: true
},
create: {
agent_name: prompt.agent_name,
version: 1,
provider_type: prompt.provider_type,
default_provider_code: prompt.default_provider_code ?? null,
system_prompt: prompt.system_prompt,
user_prompt_template: prompt.user_prompt_template,
output_schema_json: prompt.output_schema_json ?? Prisma.JsonNull,
temperature: prompt.temperature,
max_output_tokens: prompt.max_output_tokens,
is_active: true
}
});
}
await prisma.systemConfig.upsert({ await prisma.systemConfig.upsert({
where: { config_key: 'system_a.current_stage' }, where: { config_key: 'system_a.current_stage' },
update: { update: {
+95
View File
@@ -1,6 +1,7 @@
import { import {
Body, Body,
Controller, Controller,
Delete,
Get, Get,
Inject, Inject,
Param, Param,
@@ -14,11 +15,13 @@ import { CurrentUser } from '../auth/current-user.decorator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { import {
AdminListAssetsQueryDto, AdminListAssetsQueryDto,
AdminUpdateAssetReviewStateDto,
AdminListCharactersQueryDto, AdminListCharactersQueryDto,
AdminListCopyrightRecordsQueryDto, AdminListCopyrightRecordsQueryDto,
AdminListGlobalCharactersQueryDto, AdminListGlobalCharactersQueryDto,
AdminListHitAnalysesQueryDto, AdminListHitAnalysesQueryDto,
AdminListNovelChaptersQueryDto, AdminListNovelChaptersQueryDto,
AdminExportNovelSourceQueryDto,
AdminListNovelSourcesQueryDto, AdminListNovelSourcesQueryDto,
AdminListOperationLogsQueryDto, AdminListOperationLogsQueryDto,
AdminBindCharacterGlobalDto, AdminBindCharacterGlobalDto,
@@ -30,9 +33,14 @@ import {
AdminListUsersQueryDto, AdminListUsersQueryDto,
AdminListWorksQueryDto, AdminListWorksQueryDto,
AdminListCreativePatternsQueryDto, AdminListCreativePatternsQueryDto,
AdminBatchImportNovelChaptersDto,
AdminBindNovelVolumeDto,
AdminPromoteHitCasePatternsDto, AdminPromoteHitCasePatternsDto,
AdminResetUserPasswordDto, AdminResetUserPasswordDto,
AdminSaveNovelChapterDto,
AdminSaveNovelSourceDto,
AdminSaveGlobalCharacterDto, AdminSaveGlobalCharacterDto,
AdminUpdateNovelSourceIpBibleDto,
AdminUpdateRouterAuditQualityDto, AdminUpdateRouterAuditQualityDto,
AdminUpdateCreativePatternDto, AdminUpdateCreativePatternDto,
AdminUpdateCreativePatternStatusDto, AdminUpdateCreativePatternStatusDto,
@@ -119,6 +127,15 @@ export class AdminController {
return this.adminService.listAssets(user, query); return this.adminService.listAssets(user, query);
} }
@Patch('assets/:assetId/review-state')
updateAssetReviewState(
@CurrentUser() user: AuthRequestUser,
@Param('assetId') assetId: string,
@Body() dto: AdminUpdateAssetReviewStateDto
) {
return this.adminService.updateAssetReviewState(user, assetId, dto);
}
@Get('novel-sources') @Get('novel-sources')
listNovelSources( listNovelSources(
@CurrentUser() user: AuthRequestUser, @CurrentUser() user: AuthRequestUser,
@@ -127,6 +144,70 @@ export class AdminController {
return this.adminService.listNovelSources(user, query); return this.adminService.listNovelSources(user, query);
} }
@Post('novel-sources')
createNovelSource(@CurrentUser() user: AuthRequestUser, @Body() dto: AdminSaveNovelSourceDto) {
return this.adminService.createNovelSource(user, dto);
}
@Patch('novel-sources/:sourceId')
updateNovelSource(
@CurrentUser() user: AuthRequestUser,
@Param('sourceId') sourceId: string,
@Body() dto: AdminSaveNovelSourceDto
) {
return this.adminService.updateNovelSource(user, sourceId, dto);
}
@Patch('novel-sources/:sourceId/ip-bible')
updateNovelSourceIpBible(
@CurrentUser() user: AuthRequestUser,
@Param('sourceId') sourceId: string,
@Body() dto: AdminUpdateNovelSourceIpBibleDto
) {
return this.adminService.updateNovelSourceIpBible(user, sourceId, dto);
}
@Get('novel-sources/:sourceId/export')
exportNovelSource(
@CurrentUser() user: AuthRequestUser,
@Param('sourceId') sourceId: string,
@Query() query: AdminExportNovelSourceQueryDto
) {
return this.adminService.exportNovelSource(user, sourceId, query);
}
@Delete('novel-sources/:sourceId')
deleteNovelSource(@CurrentUser() user: AuthRequestUser, @Param('sourceId') sourceId: string) {
return this.adminService.deleteNovelSource(user, sourceId);
}
@Post('novel-sources/:sourceId/chapters/batch')
batchImportNovelChapters(
@CurrentUser() user: AuthRequestUser,
@Param('sourceId') sourceId: string,
@Body() dto: AdminBatchImportNovelChaptersDto
) {
return this.adminService.batchImportNovelChapters(user, sourceId, dto);
}
@Patch('novel-sources/:sourceId/chapters/volume')
bindNovelVolume(
@CurrentUser() user: AuthRequestUser,
@Param('sourceId') sourceId: string,
@Body() dto: AdminBindNovelVolumeDto
) {
return this.adminService.bindNovelVolume(user, sourceId, dto);
}
@Post('novel-sources/:sourceId/chapters')
createNovelChapter(
@CurrentUser() user: AuthRequestUser,
@Param('sourceId') sourceId: string,
@Body() dto: AdminSaveNovelChapterDto
) {
return this.adminService.createNovelChapter(user, sourceId, dto);
}
@Get('novel-chapters') @Get('novel-chapters')
listNovelChapters( listNovelChapters(
@CurrentUser() user: AuthRequestUser, @CurrentUser() user: AuthRequestUser,
@@ -135,6 +216,20 @@ export class AdminController {
return this.adminService.listNovelChapters(user, query); return this.adminService.listNovelChapters(user, query);
} }
@Patch('novel-chapters/:chapterId')
updateNovelChapter(
@CurrentUser() user: AuthRequestUser,
@Param('chapterId') chapterId: string,
@Body() dto: AdminSaveNovelChapterDto
) {
return this.adminService.updateNovelChapter(user, chapterId, dto);
}
@Delete('novel-chapters/:chapterId')
deleteNovelChapter(@CurrentUser() user: AuthRequestUser, @Param('chapterId') chapterId: string) {
return this.adminService.deleteNovelChapter(user, chapterId);
}
@Get('characters') @Get('characters')
listCharacters(@CurrentUser() user: AuthRequestUser, @Query() query: AdminListCharactersQueryDto) { listCharacters(@CurrentUser() user: AuthRequestUser, @Query() query: AdminListCharactersQueryDto) {
return this.adminService.listCharacters(user, query); return this.adminService.listCharacters(user, query);
+70
View File
@@ -14,26 +14,96 @@ export class AdminListUsersQueryDto {
export class AdminListAssetsQueryDto { export class AdminListAssetsQueryDto {
asset_type?: string; asset_type?: string;
status?: string; status?: string;
selection_status?: 'all' | 'candidate' | 'selected' | 'rejected';
project_id?: string; project_id?: string;
user_id?: string; user_id?: string;
q?: string;
shot_no?: string;
shot_start?: string;
shot_end?: string;
page?: string;
limit?: string; limit?: string;
} }
export class AdminUpdateAssetReviewStateDto {
display_name?: string | null;
selection_status?: 'candidate' | 'selected' | 'rejected';
selection_note?: string | null;
metadata_json?: unknown;
}
export class AdminListNovelSourcesQueryDto { export class AdminListNovelSourcesQueryDto {
project_id?: string; project_id?: string;
user_id?: string; user_id?: string;
source_type?: string; source_type?: string;
parse_status?: string; parse_status?: string;
page?: string;
limit?: string; limit?: string;
} }
export class AdminListNovelChaptersQueryDto { export class AdminListNovelChaptersQueryDto {
project_id?: string; project_id?: string;
novel_source_id?: string; novel_source_id?: string;
volume_no?: string;
status?: string; status?: string;
page?: string;
limit?: string; limit?: string;
} }
export class AdminExportNovelSourceQueryDto {
start_chapter_no?: string;
end_chapter_no?: string;
}
export class AdminSaveNovelSourceDto {
project_id?: string;
title?: string;
author_name?: string;
source_type?: string;
intro_text?: string;
hook_text?: string;
genre?: string;
chapters_per_volume?: number | string | null;
design_text?: string;
design_json?: unknown;
volume_plan_json?: unknown;
status?: string;
}
export class AdminUpdateNovelSourceIpBibleDto {
ip_bible?: unknown;
}
export class AdminSaveNovelChapterDto {
novel_source_id?: string;
volume_no?: number | string | null;
volume_title?: string | null;
chapter_no?: number | string;
title?: string;
content?: string;
summary?: string;
visual_summary?: string;
outline_json?: unknown;
analysis_json?: unknown;
status?: string;
}
export class AdminBatchImportNovelChaptersDto {
text?: string;
volume_no?: number | string | null;
volume_title?: string | null;
start_chapter_no?: number | string;
overwrite?: boolean;
status?: string;
}
export class AdminBindNovelVolumeDto {
volume_no?: number | string | null;
volume_title?: string | null;
start_chapter_no?: number | string;
end_chapter_no?: number | string;
}
export class AdminListCharactersQueryDto { export class AdminListCharactersQueryDto {
project_id?: string; project_id?: string;
global_character_id?: string; global_character_id?: string;
+358 -1
View File
@@ -69,6 +69,44 @@ function createProject(overrides: Record<string, unknown> = {}) {
}; };
} }
function createNovelSource(overrides: Record<string, unknown> = {}) {
return {
id: 50n,
project_id: 10n,
source_type: 'gpt_web',
title: '她签了,但没认输',
author_name: null,
raw_asset_id: null,
raw_text: null,
clean_text: null,
word_count: 0,
chapter_count: 0,
parse_status: 'parsed',
parse_report: {},
created_at: now,
...overrides
};
}
function createNovelChapter(overrides: Record<string, unknown> = {}) {
return {
id: 51n,
project_id: 10n,
novel_source_id: 50n,
volume_no: null,
volume_title: null,
chapter_no: 1,
title: '第1章:她签了,但没认输',
content: '她签下名字,抬头看向众人。',
summary: '她签下名字,抬头看向众人。',
visual_summary: '她签下名字,抬头看向众人。',
word_count: 14,
status: 'parsed',
created_at: now,
...overrides
};
}
function createEpisode(overrides: Record<string, unknown> = {}) { function createEpisode(overrides: Record<string, unknown> = {}) {
return { return {
id: 11n, id: 11n,
@@ -426,6 +464,7 @@ describe('AdminService', () => {
beforeEach(() => { beforeEach(() => {
prisma = { prisma = {
$transaction: vi.fn(async (callback: (tx: any) => unknown) => callback(prisma)),
user: { user: {
count: vi.fn().mockResolvedValue(2), count: vi.fn().mockResolvedValue(2),
findUnique: vi.fn().mockResolvedValue(createUser()), findUnique: vi.fn().mockResolvedValue(createUser()),
@@ -486,9 +525,19 @@ describe('AdminService', () => {
findMany: vi.fn().mockResolvedValue([]) findMany: vi.fn().mockResolvedValue([])
}, },
novelSource: { novelSource: {
findMany: vi.fn().mockResolvedValue([]) findMany: vi.fn().mockResolvedValue([]),
findUnique: vi.fn().mockResolvedValue(createNovelSource()),
count: vi.fn().mockResolvedValue(0),
update: vi.fn(async ({ data }: { data: Record<string, unknown> }) =>
createNovelSource({ ...data })
)
}, },
novelChapter: { novelChapter: {
createMany: vi.fn().mockResolvedValue({ count: 0 }),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
findFirst: vi.fn().mockResolvedValue(null),
findUnique: vi.fn().mockResolvedValue(createNovelChapter()),
findMany: vi.fn().mockResolvedValue([]), findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0) count: vi.fn().mockResolvedValue(0)
}, },
@@ -592,6 +641,314 @@ describe('AdminService', () => {
expect(result.projects[0].latest_task?.task_type).toBe('video_render'); expect(result.projects[0].latest_task?.task_type).toBe('video_render');
}); });
it('lists assets with generation provider and model details', async () => {
prisma.asset.findMany.mockResolvedValue([createAsset({ id: 31n })]);
prisma.renderTask.findMany.mockResolvedValue([
createTask({
id: 15n,
provider_id: 13n,
task_type: 'live_action_video_clip_generate',
output_asset_id: 31n,
provider_request_id: 'kling-task-1',
cost_actual: new Prisma.Decimal(0.35)
})
]);
prisma.providerLog.findMany.mockResolvedValue([
createProviderLog({
task_id: 15n,
provider_code: 'kling-image-to-video',
model_name: 'kling-v2-1'
})
]);
prisma.providerConfig.findMany.mockResolvedValue([
createProviderConfig({
id: 13n,
provider_code: 'kling-image-to-video',
display_name: '可灵图生视频',
model_name: 'kling-v2-1'
})
]);
const result = await service.listAssets(admin, { limit: '10' });
expect(result.assets[0].asset.generation).toEqual(
expect.objectContaining({
provider_name: '可灵图生视频',
provider_code: 'kling-image-to-video',
model_name: 'kling-v2-1',
task_id: '15',
provider_request_id: 'kling-task-1'
})
);
});
it('paginates assets, novel sources and novel chapters', async () => {
prisma.asset.findMany.mockResolvedValue([createAsset({ id: 31n })]);
prisma.asset.count.mockResolvedValue(101);
prisma.novelSource.findMany.mockResolvedValue([createNovelSource({ id: 50n })]);
prisma.novelSource.count.mockResolvedValue(41);
prisma.novelChapter.findMany.mockResolvedValue([createNovelChapter({ id: 51n })]);
prisma.novelChapter.count.mockResolvedValue(77);
const assetResult = await service.listAssets(admin, { page: '3', limit: '10' });
const sourceResult = await service.listNovelSources(admin, { page: '2', limit: '20' });
const chapterResult = await service.listNovelChapters(admin, { page: '4', limit: '15' });
expect(prisma.asset.findMany).toHaveBeenCalledWith(expect.objectContaining({ skip: 20, take: 10 }));
expect(prisma.novelSource.findMany).toHaveBeenCalledWith(expect.objectContaining({ skip: 20, take: 20 }));
expect(prisma.novelChapter.findMany).toHaveBeenCalledWith(expect.objectContaining({ skip: 45, take: 15 }));
expect(assetResult).toEqual(expect.objectContaining({ page: 3, limit: 10, total: 101, total_pages: 11 }));
expect(sourceResult).toEqual(expect.objectContaining({ page: 2, limit: 20, total: 41, total_pages: 3 }));
expect(chapterResult).toEqual(expect.objectContaining({ page: 4, limit: 15, total: 77, total_pages: 6 }));
});
it('batch imports GPT markdown chapter headings as separate novel chapters', async () => {
const savedChapters = Array.from({ length: 5 }, (_, index) =>
createNovelChapter({
id: BigInt(51 + index),
chapter_no: index + 1,
title: `${index + 1}章:测试章节${index + 1}`,
content: `${index + 1}章正文`
})
);
prisma.novelChapter.findMany
.mockResolvedValueOnce([])
.mockResolvedValueOnce(savedChapters)
.mockResolvedValueOnce(savedChapters);
const result = await service.batchImportNovelChapters(admin, '50', {
start_chapter_no: '1',
status: 'parsed',
text: [
'# 第一卷:离婚夜,她不装了',
'采用按卷推进,每次5章正文。',
'## 第1章—第5章正文',
'---',
'# 第1章:她签了,但没认输',
'第一章正文。',
'',
'# 第2章:她签了,但没认输',
'第二章正文。',
'',
'## 第3章:她没有回头',
'第三章正文。',
'',
'### 第4章:雨夜来客',
'第四章正文。',
'',
'# 第5章:旧账翻开',
'第五章正文。'
].join('\n')
});
const createManyArg = prisma.novelChapter.createMany.mock.calls[0][0];
expect(createManyArg.data).toHaveLength(5);
expect(createManyArg.data.map((chapter: Record<string, unknown>) => chapter.chapter_no)).toEqual([1, 2, 3, 4, 5]);
expect(createManyArg.data.map((chapter: Record<string, unknown>) => chapter.volume_title)).toEqual([
'第一卷:离婚夜,她不装了',
'第一卷:离婚夜,她不装了',
'第一卷:离婚夜,她不装了',
'第一卷:离婚夜,她不装了',
'第一卷:离婚夜,她不装了'
]);
expect(createManyArg.data[1]).toEqual(
expect.objectContaining({
title: '第2章:她签了,但没认输',
content: '第二章正文。'
})
);
expect(result.imported_count).toBe(5);
});
it('keeps volume headings as chapter metadata during batch import', async () => {
const savedChapters = [
createNovelChapter({
id: 71n,
chapter_no: 1,
volume_no: 1,
volume_title: '第一卷:离婚夜',
title: '第1章:她签了',
content: '第一章正文。'
}),
createNovelChapter({
id: 72n,
chapter_no: 2,
volume_no: 2,
volume_title: '第二卷:反击',
title: '第2章:她反击',
content: '第二章正文。'
})
];
prisma.novelChapter.findMany
.mockResolvedValueOnce([])
.mockResolvedValueOnce(savedChapters)
.mockResolvedValueOnce(savedChapters);
await service.batchImportNovelChapters(admin, '50', {
start_chapter_no: '1',
status: 'parsed',
text: [
'第一卷:离婚夜',
'第1章:她签了',
'第一章正文。',
'',
'第二卷:反击',
'第2章:她反击',
'第二章正文。'
].join('\n')
});
const createManyArg = prisma.novelChapter.createMany.mock.calls[0][0];
expect(createManyArg.data).toHaveLength(2);
expect(createManyArg.data).toEqual([
expect.objectContaining({
volume_no: 1,
volume_title: '第一卷:离婚夜',
chapter_no: 1,
title: '第1章:她签了'
}),
expect.objectContaining({
volume_no: 2,
volume_title: '第二卷:反击',
chapter_no: 2,
title: '第2章:她反击'
})
]);
});
it('auto assigns volumes from the source chapters-per-volume rule during batch import', async () => {
const source = createNovelSource({ parse_report: { chapters_per_volume: 30 } });
const savedChapters = [
createNovelChapter({ id: 91n, chapter_no: 31, volume_no: 2, volume_title: '第二卷' }),
createNovelChapter({ id: 92n, chapter_no: 32, volume_no: 2, volume_title: '第二卷' })
];
prisma.novelSource.findUnique.mockResolvedValue(source);
prisma.novelChapter.findMany
.mockResolvedValueOnce([])
.mockResolvedValueOnce(savedChapters)
.mockResolvedValueOnce(savedChapters);
const result = await service.batchImportNovelChapters(admin, '50', {
start_chapter_no: '31',
status: 'parsed',
text: [
'===== 第31章:第二卷开场 =====',
'新的危机开始。',
'',
'===== 第32章:她再下一局 =====',
'她把底牌压到最后。'
].join('\n')
});
const createManyArg = prisma.novelChapter.createMany.mock.calls[0][0];
expect(createManyArg.data).toEqual([
expect.objectContaining({
chapter_no: 31,
volume_no: 2,
volume_title: '第二卷'
}),
expect.objectContaining({
chapter_no: 32,
volume_no: 2,
volume_title: '第二卷'
})
]);
expect(result.imported_count).toBe(2);
});
it('binds a chapter range to a novel volume', async () => {
const savedChapters = [
createNovelChapter({ id: 81n, chapter_no: 1, volume_no: 1, volume_title: '第一卷:离婚夜' }),
createNovelChapter({ id: 82n, chapter_no: 2, volume_no: 1, volume_title: '第一卷:离婚夜' })
];
prisma.novelChapter.updateMany.mockResolvedValueOnce({ count: 2 });
prisma.novelChapter.findMany
.mockResolvedValueOnce(savedChapters)
.mockResolvedValueOnce(savedChapters);
const result = await service.bindNovelVolume(admin, '50', {
volume_no: '1',
volume_title: '第一卷:离婚夜',
start_chapter_no: '1',
end_chapter_no: '2'
});
expect(prisma.novelChapter.updateMany).toHaveBeenCalledWith({
where: {
novel_source_id: 50n,
chapter_no: { gte: 1, lte: 2 }
},
data: {
volume_no: 1,
volume_title: '第一卷:离婚夜'
}
});
expect(result.updated_count).toBe(2);
expect(result.chapters[0]).toMatchObject({
volume_no: 1,
volume_title: '第一卷:离婚夜'
});
});
it('batch imports chapters split by standalone GPT divider headings', async () => {
const savedChapters = Array.from({ length: 5 }, (_, index) =>
createNovelChapter({
id: BigInt(61 + index),
chapter_no: index + 6,
title: `${index + 6}章:测试章节${index + 6}`,
content: `${index + 6}章正文`
})
);
prisma.novelChapter.findMany
.mockResolvedValueOnce([])
.mockResolvedValueOnce(savedChapters)
.mockResolvedValueOnce(savedChapters);
const result = await service.batchImportNovelChapters(admin, '50', {
start_chapter_no: '6',
status: 'parsed',
text: [
'后续我统一用这一行作为自动切割标识:',
'===== 第X章:章节标题 =====',
'',
'本次继续 第6章—第10章正文。',
'已思考 6m 51s',
'',
'===== 第6章:直播里的耳光 =====',
'顾氏股价崩了。',
'',
'###===第7章:她把证据甩上桌===###',
'她抬手投屏,会议室里鸦雀无声。',
'',
'===== 第8章:旧账翻开 =====',
'旧合同被重新翻出。',
'',
'===== 第9章:深夜来电 =====',
'电话那头只剩急促呼吸。',
'',
'===== 第10章:她没有回头 =====',
'她走出大楼,没有再回头。'
].join('\n')
});
const createManyArg = prisma.novelChapter.createMany.mock.calls[0][0];
expect(createManyArg.data).toHaveLength(5);
expect(createManyArg.data.map((chapter: Record<string, unknown>) => chapter.chapter_no)).toEqual([6, 7, 8, 9, 10]);
expect(createManyArg.data[0]).toEqual(
expect.objectContaining({
title: '第6章:直播里的耳光',
content: '顾氏股价崩了。'
})
);
expect(createManyArg.data[1]).toEqual(
expect.objectContaining({
title: '第7章:她把证据甩上桌',
content: '她抬手投屏,会议室里鸦雀无声。'
})
);
expect(result.imported_count).toBe(5);
});
it('lists router quality audit rows with routing, repair and cost details', async () => { it('lists router quality audit rows with routing, repair and cost details', async () => {
prisma.project.findMany.mockResolvedValue([createProject({ output_mode: 'live_action_ai' })]); prisma.project.findMany.mockResolvedValue([createProject({ output_mode: 'live_action_ai' })]);
prisma.episode.findMany.mockResolvedValue([createEpisode()]); prisma.episode.findMany.mockResolvedValue([createEpisode()]);
File diff suppressed because it is too large Load Diff
+23
View File
@@ -21,23 +21,46 @@ export function toSafeNovelSource(source: NovelSource) {
chapter_count: source.chapter_count, chapter_count: source.chapter_count,
parse_status: source.parse_status, parse_status: source.parse_status,
parse_report: source.parse_report, parse_report: source.parse_report,
ip_bible_json: sourceIpBibleFromReport(source.parse_report),
design_json: source.design_json,
volume_plan_json: source.volume_plan_json,
ai_provider_code: source.ai_provider_code,
ai_model_name: source.ai_model_name,
ai_cost_estimate: source.ai_cost_estimate ? Number(source.ai_cost_estimate.toString()) : null,
ai_cost_actual: source.ai_cost_actual ? Number(source.ai_cost_actual.toString()) : null,
text_preview: createTextPreview(source.clean_text || source.raw_text), text_preview: createTextPreview(source.clean_text || source.raw_text),
created_at: source.created_at.toISOString() created_at: source.created_at.toISOString()
}; };
} }
function sourceIpBibleFromReport(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const ipBible = (value as Record<string, unknown>).ip_bible_json;
return ipBible && typeof ipBible === 'object' && !Array.isArray(ipBible) ? ipBible : null;
}
export function toSafeNovelChapter(chapter: NovelChapter) { export function toSafeNovelChapter(chapter: NovelChapter) {
return { return {
id: chapter.id.toString(), id: chapter.id.toString(),
project_id: chapter.project_id.toString(), project_id: chapter.project_id.toString(),
novel_source_id: chapter.novel_source_id?.toString() ?? null, novel_source_id: chapter.novel_source_id?.toString() ?? null,
volume_no: chapter.volume_no,
volume_title: chapter.volume_title,
chapter_no: chapter.chapter_no, chapter_no: chapter.chapter_no,
title: chapter.title, title: chapter.title,
summary: chapter.summary, summary: chapter.summary,
visual_summary: chapter.visual_summary, visual_summary: chapter.visual_summary,
content: chapter.content,
content_preview: createTextPreview(chapter.content, 3000), content_preview: createTextPreview(chapter.content, 3000),
word_count: chapter.word_count, word_count: chapter.word_count,
outline_json: chapter.outline_json,
analysis_json: chapter.analysis_json,
status: chapter.status, status: chapter.status,
ai_provider_code: chapter.ai_provider_code,
ai_model_name: chapter.ai_model_name,
ai_cost_estimate: chapter.ai_cost_estimate ? Number(chapter.ai_cost_estimate.toString()) : null,
ai_cost_actual: chapter.ai_cost_actual ? Number(chapter.ai_cost_actual.toString()) : null,
created_at: chapter.created_at.toISOString() created_at: chapter.created_at.toISOString()
}; };
} }
@@ -180,6 +180,16 @@ describe('AiRouterService', () => {
mode: 'real', mode: 'real',
is_enabled: false is_enabled: false
}), }),
createProvider({
provider_code: 'volcengine_seedance_20_fast',
mode: 'real',
is_enabled: false
}),
createProvider({
provider_code: 'volcengine_seedance_20',
mode: 'real',
is_enabled: false
}),
createProvider({ createProvider({
provider_code: 'jimeng_seedance', provider_code: 'jimeng_seedance',
mode: 'real', mode: 'real',
@@ -196,12 +206,87 @@ describe('AiRouterService', () => {
expect(decision.provider_code).toBe('mock-video'); expect(decision.provider_code).toBe('mock-video');
expect(decision.candidates.map((candidate) => candidate.reason)).toEqual([ expect(decision.candidates.map((candidate) => candidate.reason)).toEqual([
'provider_disabled',
'provider_disabled',
'provider_disabled', 'provider_disabled',
'provider_disabled', 'provider_disabled',
'auto_normal_route' 'auto_normal_route'
]); ]);
}); });
it('routes splus_v1 projects only through the formal Kling Omni and V3 chain', async () => {
prisma.providerConfig.findMany.mockResolvedValue([
createProvider({
id: 103n,
provider_code: 'kling-v3-omni-native-audio-1080p-video',
display_name: 'Kling v3 Omni 1080P',
mode: 'real',
is_enabled: false
}),
createProvider({
id: 104n,
provider_code: 'kling-v3-native-audio-video',
display_name: 'Kling v3 1080P',
mode: 'real',
is_enabled: true,
cost_rule_json: { unit: 'video_seconds', price_per_second: 0.168, currency: 'USD' }
})
]);
const decision = await service.resolveLiveActionVideoRoute({
project: createProject({ engine_version: 'splus_v1' }),
shot: createShot({ importance_score: 9, action_score: 7 }),
duration: 5
});
expect(decision.provider_code).toBe('kling-v3-native-audio-video');
expect(decision.fallback_chain).toEqual([
'kling-v3-omni-native-audio-1080p-video',
'kling-v3-native-audio-video'
]);
expect(decision.routing_profile).toBe('splus_kling_v1');
expect(decision.capability_version).toBe('kling_video_capabilities_2026-07-15');
expect(decision.candidates.map((candidate) => candidate.reason)).toEqual([
'provider_disabled',
'splus_kling_premium_route'
]);
});
it('rejects non-Kling manual providers for splus_v1 projects', async () => {
await expect(service.resolveLiveActionVideoRoute({
project: createProject({ engine_version: 'splus_v1' }),
shot: createShot(),
duration: 5,
manual_provider_code: 'jimeng_seedance',
allow_manual_override: true
})).rejects.toThrow('AI_ROUTER_SPLUS_PROVIDER_NOT_ALLOWED');
});
it('rejects a misconfigured splus route instead of silently selecting a legacy provider', async () => {
prisma.systemConfig.upsert.mockResolvedValueOnce({
config_key: 'ai.router.v1',
config_value: {
...DEFAULT_AI_ROUTER_CONFIG,
splus_live_action_video: {
'zh-CN': {
normal: {
provider_code: 'minimax_hailuo_23_fast',
fallback_chain: ['minimax_hailuo_23_fast', 'mock-video']
}
}
}
}
});
await expect(service.resolveLiveActionVideoRoute({
project: createProject({ engine_version: 'splus_v1' }),
shot: createShot({ route_tier: 'normal' }),
duration: 5,
language: 'zh-CN'
})).rejects.toThrow('AI_ROUTER_SPLUS_PROVIDER_CHAIN_EMPTY');
expect(prisma.providerConfig.findMany).not.toHaveBeenCalled();
});
it('keeps admin manual override as an explicit router decision', async () => { it('keeps admin manual override as an explicit router decision', async () => {
prisma.providerConfig.findMany.mockResolvedValue([ prisma.providerConfig.findMany.mockResolvedValue([
createProvider({ createProvider({
+52 -9
View File
@@ -5,6 +5,8 @@ import {
AI_ROUTER_CONFIG_KEY, AI_ROUTER_CONFIG_KEY,
AI_ROUTER_DEFAULT_LANGUAGE, AI_ROUTER_DEFAULT_LANGUAGE,
DEFAULT_AI_ROUTER_CONFIG, DEFAULT_AI_ROUTER_CONFIG,
KLING_SPLUS_CAPABILITY_VERSION,
KLING_SPLUS_FORMAL_PROVIDER_CODES,
type AiRouteDecision, type AiRouteDecision,
type AiRouteTier, type AiRouteTier,
type AiRouterShotScores type AiRouterShotScores
@@ -44,27 +46,50 @@ export class AiRouterService {
const scores = this.scoreLiveActionShot(input.shot); const scores = this.scoreLiveActionShot(input.shot);
const language = this.normalizeText(input.language) ?? (await this.resolveDefaultLanguage()); const language = this.normalizeText(input.language) ?? (await this.resolveDefaultLanguage());
const manualProviderCode = this.normalizeText(input.manual_provider_code); const manualProviderCode = this.normalizeText(input.manual_provider_code);
const isSplusProject = input.project.engine_version === 'splus_v1';
if (manualProviderCode && input.allow_manual_override) { if (manualProviderCode && input.allow_manual_override) {
return this.resolveManualVideoProvider(manualProviderCode, language, input.duration, scores); if (isSplusProject && !KLING_SPLUS_FORMAL_PROVIDER_CODES.includes(
manualProviderCode as (typeof KLING_SPLUS_FORMAL_PROVIDER_CODES)[number]
)) {
throw new BadRequestException('AI_ROUTER_SPLUS_PROVIDER_NOT_ALLOWED');
}
const decision = await this.resolveManualVideoProvider(manualProviderCode, language, input.duration, scores);
return isSplusProject ? this.withSplusMetadata(decision) : decision;
} }
if (manualProviderCode && !input.allow_manual_override) { if (manualProviderCode && !input.allow_manual_override) {
throw new BadRequestException('AI_ROUTER_MANUAL_OVERRIDE_FORBIDDEN'); throw new BadRequestException('AI_ROUTER_MANUAL_OVERRIDE_FORBIDDEN');
} }
const config = await this.loadRouterConfig(); const config = await this.loadRouterConfig();
const languageConfig = this.resolveLiveActionLanguageConfig(config, language); const languageConfig = this.resolveLiveActionLanguageConfig(
config,
language,
isSplusProject ? 'splus_live_action_video' : 'live_action_video'
);
const tierConfig = this.jsonObject(languageConfig[scores.route_tier]); const tierConfig = this.jsonObject(languageConfig[scores.route_tier]);
const primaryProviderCode = const primaryProviderCode =
this.normalizeText(tierConfig.provider_code) ?? this.normalizeText(tierConfig.provider_code) ??
(scores.route_tier === 'premium' ? 'kling-image-to-video' : 'minimax_hailuo_23_fast'); (isSplusProject
const fallbackChain = this.uniqueStrings([ ? 'kling-v3-omni-native-audio-1080p-video'
: scores.route_tier === 'premium'
? 'kling-image-to-video'
: 'minimax_hailuo_23_fast');
const configuredFallbackChain = this.uniqueStrings([
primaryProviderCode, primaryProviderCode,
...this.stringArray(tierConfig.fallback_chain), ...this.stringArray(tierConfig.fallback_chain),
'mock-video' ...(isSplusProject ? [] : ['mock-video'])
]); ]);
const fallbackChain = isSplusProject
? configuredFallbackChain.filter((providerCode) => KLING_SPLUS_FORMAL_PROVIDER_CODES.includes(
providerCode as (typeof KLING_SPLUS_FORMAL_PROVIDER_CODES)[number]
))
: configuredFallbackChain;
if (isSplusProject && fallbackChain.length === 0) {
throw new BadRequestException('AI_ROUTER_SPLUS_PROVIDER_CHAIN_EMPTY');
}
return this.selectVideoProviderFromCandidates({ const decision = await this.selectVideoProviderFromCandidates({
language, language,
duration: input.duration, duration: input.duration,
scores, scores,
@@ -72,8 +97,11 @@ export class AiRouterService {
maxCostPerClip: input.max_cost_per_clip ?? null, maxCostPerClip: input.max_cost_per_clip ?? null,
dailyBudget: this.numberFromJson(this.jsonObject(config).daily_budget), dailyBudget: this.numberFromJson(this.jsonObject(config).daily_budget),
manualOverride: false, manualOverride: false,
defaultReason: `auto_${scores.route_tier}_route` defaultReason: isSplusProject
? `splus_kling_${scores.route_tier}_route`
: `auto_${scores.route_tier}_route`
}); });
return isSplusProject ? this.withSplusMetadata(decision) : decision;
} }
private async resolveManualVideoProvider( private async resolveManualVideoProvider(
@@ -210,8 +238,14 @@ export class AiRouterService {
return this.normalizeText(config.default_language) ?? AI_ROUTER_DEFAULT_LANGUAGE; return this.normalizeText(config.default_language) ?? AI_ROUTER_DEFAULT_LANGUAGE;
} }
private resolveLiveActionLanguageConfig(config: Record<string, unknown>, language: string) { private resolveLiveActionLanguageConfig(
const liveAction = this.jsonObject(config.live_action_video); config: Record<string, unknown>,
language: string,
profileKey: 'live_action_video' | 'splus_live_action_video'
) {
const configuredProfile = this.jsonObject(config[profileKey]);
const defaultProfile = this.jsonObject(this.jsonObject(DEFAULT_AI_ROUTER_CONFIG)[profileKey]);
const liveAction = Object.keys(configuredProfile).length > 0 ? configuredProfile : defaultProfile;
const current = this.jsonObject(liveAction[language]); const current = this.jsonObject(liveAction[language]);
if (Object.keys(current).length > 0) return current; if (Object.keys(current).length > 0) return current;
@@ -219,6 +253,15 @@ export class AiRouterService {
return this.jsonObject(liveAction[AI_ROUTER_DEFAULT_LANGUAGE]); return this.jsonObject(liveAction[AI_ROUTER_DEFAULT_LANGUAGE]);
} }
private withSplusMetadata(decision: AiRouteDecision): AiRouteDecision {
return {
...decision,
engine_version: 'splus_v1',
routing_profile: 'splus_kling_v1',
capability_version: KLING_SPLUS_CAPABILITY_VERSION
};
}
private estimateVideoCost(rule: Prisma.JsonValue | null, duration: number) { private estimateVideoCost(rule: Prisma.JsonValue | null, duration: number) {
const costRule = this.jsonObject(rule); const costRule = this.jsonObject(rule);
const flatCost = this.numberFromJson(costRule.flat_cost); const flatCost = this.numberFromJson(costRule.flat_cost);
+32 -2
View File
@@ -2,6 +2,15 @@ import type { Prisma } from '@prisma/client';
export const AI_ROUTER_CONFIG_KEY = 'ai.router.v1'; export const AI_ROUTER_CONFIG_KEY = 'ai.router.v1';
export const AI_ROUTER_DEFAULT_LANGUAGE = 'zh-CN'; export const AI_ROUTER_DEFAULT_LANGUAGE = 'zh-CN';
export const KLING_SPLUS_CAPABILITY_VERSION = 'kling_video_capabilities_2026-07-15';
export const KLING_SPLUS_FORMAL_PROVIDER_CODES = [
'kling-v3-omni-native-audio-720p-video',
'kling-v3-omni-native-audio-1080p-video',
'kling-v3-omni-native-audio-4k-video',
'kling-v3-native-audio-720p-video',
'kling-v3-native-audio-video',
'kling-v3-native-audio-4k-video'
] as const;
export const DEFAULT_AI_ROUTER_CONFIG = { export const DEFAULT_AI_ROUTER_CONFIG = {
version: 1, version: 1,
@@ -16,11 +25,29 @@ export const DEFAULT_AI_ROUTER_CONFIG = {
}, },
normal: { normal: {
provider_code: 'minimax_hailuo_23_fast', provider_code: 'minimax_hailuo_23_fast',
fallback_chain: ['minimax_hailuo_23_fast', 'jimeng_seedance', 'mock-video'] fallback_chain: ['minimax_hailuo_23_fast', 'volcengine_seedance_20_fast', 'volcengine_seedance_20', 'jimeng_seedance', 'mock-video']
}, },
premium: { premium: {
provider_code: 'kling-image-to-video', provider_code: 'kling-image-to-video',
fallback_chain: ['kling-image-to-video', 'minimax_hailuo_23_fast', 'jimeng_seedance', 'mock-video'] fallback_chain: ['kling-image-to-video', 'volcengine_seedance_20', 'minimax_hailuo_23_fast', 'volcengine_seedance_20_fast', 'jimeng_seedance', 'mock-video']
}
}
},
splus_live_action_video: {
capability_version: KLING_SPLUS_CAPABILITY_VERSION,
formal_provider_codes: [...KLING_SPLUS_FORMAL_PROVIDER_CODES],
'zh-CN': {
thresholds: {
premium_importance_gt: 7,
premium_action_gt: 5
},
normal: {
provider_code: 'kling-v3-omni-native-audio-1080p-video',
fallback_chain: ['kling-v3-omni-native-audio-1080p-video', 'kling-v3-native-audio-video']
},
premium: {
provider_code: 'kling-v3-omni-native-audio-1080p-video',
fallback_chain: ['kling-v3-omni-native-audio-1080p-video', 'kling-v3-native-audio-video']
} }
} }
} }
@@ -54,5 +81,8 @@ export interface AiRouteDecision {
decision_reason: string; decision_reason: string;
estimated_cost: number; estimated_cost: number;
manual_override: boolean; manual_override: boolean;
engine_version?: string;
routing_profile?: string;
capability_version?: string;
scores: AiRouterShotScores; scores: AiRouterShotScores;
} }
+6
View File
@@ -18,9 +18,12 @@ import { ImagesModule } from './images/images.module';
import { LiveActionModule } from './live-action/live-action.module'; import { LiveActionModule } from './live-action/live-action.module';
import { MediaModule } from './media/media.module'; import { MediaModule } from './media/media.module';
import { MemoriesModule } from './memories/memories.module'; import { MemoriesModule } from './memories/memories.module';
import { ModelRegistryModule } from './model-registry/model-registry.module';
import { NovelsModule } from './novels/novels.module'; import { NovelsModule } from './novels/novels.module';
import { ProjectsModule } from './projects/projects.module'; import { ProjectsModule } from './projects/projects.module';
import { ProviderLabModule } from './provider-lab/provider-lab.module';
import { ProvidersModule } from './providers/providers.module'; import { ProvidersModule } from './providers/providers.module';
import { ProductionKernelModule } from './production-kernel/production-kernel.module';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
import { QueuesModule } from './queues/queues.module'; import { QueuesModule } from './queues/queues.module';
import { ReviewsModule } from './reviews/reviews.module'; import { ReviewsModule } from './reviews/reviews.module';
@@ -35,6 +38,9 @@ import { UsersModule } from './users/users.module';
AuthModule, AuthModule,
BillingModule, BillingModule,
ProjectsModule, ProjectsModule,
ProviderLabModule,
ModelRegistryModule,
ProductionKernelModule,
AssetsModule, AssetsModule,
NovelsModule, NovelsModule,
StoryBiblesModule, StoryBiblesModule,
+29 -2
View File
@@ -9,6 +9,23 @@ export interface StoredObject {
backend: 'local' | 'minio'; backend: 'local' | 'minio';
} }
export interface SafeAssetGeneration {
source: string;
display_name: string | null;
provider_id: string | null;
provider_type: string | null;
provider_code: string | null;
provider_name: string | null;
model_name: string | null;
task_id: string | null;
task_type: string | null;
provider_request_id: string | null;
clip_id: string | null;
status: string | null;
cost_actual: string | null;
created_at: string | null;
}
export interface SafeAsset { export interface SafeAsset {
id: string; id: string;
user_id: string | null; user_id: string | null;
@@ -21,12 +38,17 @@ export interface SafeAsset {
duration: string | null; duration: string | null;
size: string | null; size: string | null;
hash: string | null; hash: string | null;
display_name: string | null;
selection_status: string;
selection_note: string | null;
metadata_json: unknown;
visibility: string; visibility: string;
status: string; status: string;
created_at: string; created_at: string;
generation: SafeAssetGeneration | null;
} }
export function toSafeAsset(asset: Asset): SafeAsset { export function toSafeAsset(asset: Asset, generation: SafeAssetGeneration | null = null): SafeAsset {
return { return {
id: asset.id.toString(), id: asset.id.toString(),
user_id: asset.user_id?.toString() ?? null, user_id: asset.user_id?.toString() ?? null,
@@ -39,8 +61,13 @@ export function toSafeAsset(asset: Asset): SafeAsset {
duration: asset.duration?.toString() ?? null, duration: asset.duration?.toString() ?? null,
size: asset.size?.toString() ?? null, size: asset.size?.toString() ?? null,
hash: asset.hash, hash: asset.hash,
display_name: asset.display_name ?? generation?.display_name ?? null,
selection_status: asset.selection_status,
selection_note: asset.selection_note,
metadata_json: asset.metadata_json,
visibility: asset.visibility, visibility: asset.visibility,
status: asset.status, status: asset.status,
created_at: asset.created_at.toISOString() created_at: asset.created_at.toISOString(),
generation
}; };
} }
+36 -8
View File
@@ -3,8 +3,10 @@ import {
Body, Body,
Controller, Controller,
Get, Get,
Headers,
Inject, Inject,
Param, Param,
Patch,
Post, Post,
Req, Req,
Res, Res,
@@ -21,7 +23,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import type { AuthRequestUser } from '../auth/auth.types'; import type { AuthRequestUser } from '../auth/auth.types';
import type { RequestWithApiCrypto } from '../common/api-crypto.service'; import type { RequestWithApiCrypto } from '../common/api-crypto.service';
import { AssetsService } from './assets.service'; import { AssetsService } from './assets.service';
import { UploadAssetDto } from './upload.dto'; import { UpdateAssetReviewStateDto, UploadAssetDto } from './upload.dto';
const DEFAULT_MAX_UPLOAD_BYTES = 100 * 1024 * 1024; const DEFAULT_MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
const MAX_UPLOAD_BYTES = parseUploadLimitBytes(process.env.MAX_UPLOAD_BYTES, DEFAULT_MAX_UPLOAD_BYTES); const MAX_UPLOAD_BYTES = parseUploadLimitBytes(process.env.MAX_UPLOAD_BYTES, DEFAULT_MAX_UPLOAD_BYTES);
@@ -99,16 +101,30 @@ export class AssetsController {
return this.assetsService.getAssetForUser(user, assetId); return this.assetsService.getAssetForUser(user, assetId);
} }
@Get('assets/:assetId/preview-url')
getAssetPreviewUrl(@CurrentUser() user: AuthRequestUser, @Param('assetId') assetId: string) {
return this.assetsService.createPreviewUrlForUser(user, assetId);
}
@Patch('assets/:assetId/review-state')
updateAssetReviewState(
@CurrentUser() user: AuthRequestUser,
@Param('assetId') assetId: string,
@Body() dto: UpdateAssetReviewStateDto
) {
return this.assetsService.updateAssetReviewState(user, assetId, dto);
}
@Get('assets/:assetId/download') @Get('assets/:assetId/download')
async downloadAsset( async downloadAsset(
@CurrentUser() user: AuthRequestUser, @CurrentUser() user: AuthRequestUser,
@Param('assetId') assetId: string, @Param('assetId') assetId: string,
@Headers('range') range: string | undefined,
@Req() request: RequestWithApiCrypto, @Req() request: RequestWithApiCrypto,
@Res({ passthrough: true }) response: Response @Res({ passthrough: true }) response: Response
) { ) {
const result = await this.assetsService.downloadAssetForUser(user, assetId);
if (request.apiCrypto) { if (request.apiCrypto) {
const result = await this.assetsService.downloadAssetForUser(user, assetId);
response.setHeader('Cache-Control', 'private, max-age=0, must-revalidate'); response.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
return { return {
filename: result.filename, filename: result.filename,
@@ -118,15 +134,27 @@ export class AssetsController {
}; };
} }
response.setHeader('Content-Type', result.asset.mime_type || 'application/octet-stream'); const result = await this.assetsService.streamAssetForUser(user, assetId, range);
response.setHeader('Content-Length', result.buffer.length.toString()); const mimeType = result.asset.mime_type || 'application/octet-stream';
const stream = result.stream;
const asciiFilename = result.filename.replace(/[^\x20-\x7E]/g, '_').replace(/"/g, '');
const dispositionType = mimeType.startsWith('video/') || mimeType.startsWith('audio/') ? 'inline' : 'attachment';
response.setHeader('Content-Type', mimeType);
response.setHeader('Accept-Ranges', 'bytes');
response.setHeader('Content-Length', stream.contentLength.toString());
response.setHeader( response.setHeader(
'Content-Disposition', 'Content-Disposition',
`attachment; filename="${result.filename.replace(/"/g, '')}"` `${dispositionType}; filename="${asciiFilename}"; filename*=UTF-8''${encodeURIComponent(result.filename)}`
); );
response.setHeader('Cache-Control', 'private, max-age=0, must-revalidate'); if (stream.statusCode === 206) {
response.status(206);
response.setHeader('Content-Range', `bytes ${stream.start}-${stream.end}/${stream.size}`);
}
response.setHeader('Cache-Control', 'private, max-age=3600');
response.setHeader('X-Content-Type-Options', 'nosniff');
return new StreamableFile(result.buffer); return new StreamableFile(stream.stream);
} }
private fileFromEncryptedBody(body: Record<string, unknown> | undefined) { private fileFromEncryptedBody(body: Record<string, unknown> | undefined) {
+109 -4
View File
@@ -29,10 +29,7 @@ function createFile(overrides: Partial<Express.Multer.File> = {}): Express.Multe
} }
describe('AssetsService', () => { describe('AssetsService', () => {
let prisma: { let prisma: any;
project: { findUnique: ReturnType<typeof vi.fn> };
asset: { create: ReturnType<typeof vi.fn>; findUnique: ReturnType<typeof vi.fn> };
};
let storage: Pick<StorageService, 'storePrivateFile' | 'readPrivateFile'>; let storage: Pick<StorageService, 'storePrivateFile' | 'readPrivateFile'>;
let projectsService: Pick<ProjectsService, 'assertProjectOwner'>; let projectsService: Pick<ProjectsService, 'assertProjectOwner'>;
let service: AssetsService; let service: AssetsService;
@@ -45,6 +42,18 @@ describe('AssetsService', () => {
asset: { asset: {
create: vi.fn(), create: vi.fn(),
findUnique: vi.fn() findUnique: vi.fn()
},
renderTask: {
findMany: vi.fn().mockResolvedValue([])
},
providerLog: {
findMany: vi.fn().mockResolvedValue([])
},
providerConfig: {
findUnique: vi.fn().mockResolvedValue(null)
},
videoClip: {
findMany: vi.fn().mockResolvedValue([])
} }
}; };
storage = { storage = {
@@ -148,4 +157,100 @@ describe('AssetsService', () => {
expect(result.filename).toBe('video-300.mp4'); expect(result.filename).toBe('video-300.mp4');
expect(result.buffer.toString()).toBe('video bytes'); expect(result.buffer.toString()).toBe('video bytes');
}); });
it('returns generation provider and model for direct asset detail', 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')
});
prisma.renderTask.findMany.mockResolvedValue([
{
id: 20n,
project_id: 100n,
episode_id: 10n,
shot_id: 8n,
task_type: 'live_action_video_clip_generate',
provider_id: 13n,
status: 'success',
input_json: {},
input_hash: null,
idempotency_key: null,
output_asset_id: 300n,
provider_request_id: 'task-external-1',
retry_count: 0,
max_retry: 0,
cost_estimate: null,
cost_actual: { toString: () => '0.35' },
error_code: null,
error_message: null,
created_at: new Date('2026-05-31T00:00:00.000Z'),
started_at: null,
finished_at: new Date('2026-05-31T00:00:00.000Z')
}
]);
prisma.providerLog.findMany.mockResolvedValue([
{
id: 40n,
provider_id: 13n,
task_id: 20n,
project_id: 100n,
provider_type: 'VideoProvider',
provider_code: 'kling-image-to-video',
model_name: 'kling-v2-1',
request_json: {},
response_json: {},
input_size: null,
output_size: null,
cost_estimate: null,
cost_actual: null,
status: 'success',
error_code: null,
error_message: null,
started_at: null,
finished_at: null,
created_at: new Date('2026-05-31T00:00:00.000Z')
}
]);
prisma.providerConfig.findUnique.mockResolvedValue({
id: 13n,
provider_type: 'VideoProvider',
provider_code: 'kling-image-to-video',
display_name: '可灵图生视频',
mode: 'real',
model_name: 'kling-v2-1',
config_json: {},
fallback_provider_id: null,
is_enabled: true,
priority: 10,
rate_limit_json: {},
cost_rule_json: {},
created_at: new Date('2026-05-31T00:00:00.000Z'),
updated_at: new Date('2026-05-31T00:00:00.000Z')
});
const result = await service.getAssetForUser(user, '300');
expect(result.generation).toEqual(
expect.objectContaining({
provider_name: '可灵图生视频',
provider_code: 'kling-image-to-video',
model_name: 'kling-v2-1',
task_id: '20',
provider_request_id: 'task-external-1'
})
);
});
}); });
+303 -10
View File
@@ -4,12 +4,13 @@ import {
Injectable, Injectable,
NotFoundException NotFoundException
} from '@nestjs/common'; } from '@nestjs/common';
import type { Asset } from '@prisma/client'; import { Prisma, type Asset, type ProviderConfig, type ProviderLog, type RenderTask, type VideoClip } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import type { AuthRequestUser } from '../auth/auth.types'; import type { AuthRequestUser } from '../auth/auth.types';
import { ProjectsService } from '../projects/projects.service'; import { ProjectsService } from '../projects/projects.service';
import { toSafeAsset, type AssetType } from './asset.types'; import { toSafeAsset, type AssetType, type SafeAssetGeneration } from './asset.types';
import { StorageService } from './storage.service'; import { StorageService } from './storage.service';
import type { UpdateAssetReviewStateDto } from './upload.dto';
const ALLOWED_NOVEL_MIME_TYPES = new Set([ const ALLOWED_NOVEL_MIME_TYPES = new Set([
'text/plain', 'text/plain',
@@ -93,20 +94,97 @@ export class AssetsService {
async getAssetForUser(user: AuthRequestUser, assetId: string) { async getAssetForUser(user: AuthRequestUser, assetId: string) {
const asset = await this.findAssetForUser(user, assetId); const asset = await this.findAssetForUser(user, assetId);
return toSafeAsset(asset); const generation = await this.findAssetGeneration(asset);
return toSafeAsset(asset, generation);
} }
async downloadAssetForUser(user: AuthRequestUser, assetId: string) { async downloadAssetForUser(user: AuthRequestUser, assetId: string) {
const asset = await this.findAssetForUser(user, assetId); const asset = await this.findAssetForUser(user, assetId);
const generation = await this.findAssetGeneration(asset);
const buffer = await this.storage.readPrivateFile(asset.file_path); const buffer = await this.storage.readPrivateFile(asset.file_path);
return { return {
asset: toSafeAsset(asset), asset: toSafeAsset(asset, generation),
buffer, buffer,
filename: this.buildDownloadFilename(asset) filename: this.buildDownloadFilename(asset, generation)
}; };
} }
async streamAssetForUser(user: AuthRequestUser, assetId: string, rangeHeader?: string) {
const asset = await this.findAssetForUser(user, assetId);
const generation = await this.findAssetGeneration(asset);
const stream = await this.storage.streamPrivateFile(
asset.file_path,
asset.mime_type || 'application/octet-stream',
rangeHeader
);
return {
asset: toSafeAsset(asset, generation),
stream,
filename: this.buildDownloadFilename(asset, generation)
};
}
async createPreviewUrlForUser(user: AuthRequestUser, assetId: string) {
const asset = await this.findAssetForUser(user, assetId);
const expiresInSeconds = this.previewUrlExpiresInSeconds(asset);
return {
asset: toSafeAsset(asset),
url: this.storage.createTemporaryPublicUrl({
filePath: asset.file_path,
mimeType: asset.mime_type || 'application/octet-stream',
expiresInSeconds
}),
expires_in_seconds: expiresInSeconds
};
}
async updateAssetReviewState(user: AuthRequestUser, assetId: string, dto: UpdateAssetReviewStateDto) {
const asset = await this.findEditableAssetForUser(user, assetId);
const data: Prisma.AssetUpdateInput = {};
if ('display_name' in dto) {
data.display_name = this.normalizeNullableText(dto.display_name, 255);
}
if ('selection_status' in dto) {
data.selection_status = this.normalizeSelectionStatus(dto.selection_status);
}
if ('selection_note' in dto) {
data.selection_note = this.normalizeNullableText(dto.selection_note, 1000);
}
if ('metadata_json' in dto) {
data.metadata_json = this.normalizeJsonObject(dto.metadata_json);
}
if (Object.keys(data).length === 0) {
return { asset: toSafeAsset(asset, await this.findAssetGeneration(asset)) };
}
const updated = await this.prisma.asset.update({
where: { id: asset.id },
data
});
const generation = await this.findAssetGeneration(updated);
return {
asset: toSafeAsset(updated, generation),
next_step: 'asset_review_state_saved'
};
}
private previewUrlExpiresInSeconds(asset: Asset) {
const mimeType = asset.mime_type || '';
if (mimeType.startsWith('video/') || mimeType.startsWith('audio/')) {
return 24 * 60 * 60;
}
return 15 * 60;
}
private async findAssetForUser(user: AuthRequestUser, assetId: string) { private async findAssetForUser(user: AuthRequestUser, assetId: string) {
const asset = await this.prisma.asset.findUnique({ const asset = await this.prisma.asset.findUnique({
where: { id: this.parseId(assetId) } where: { id: this.parseId(assetId) }
@@ -116,20 +194,235 @@ export class AssetsService {
throw new NotFoundException('Asset not found'); throw new NotFoundException('Asset not found');
} }
if (asset.user_id?.toString() !== user.id && user.role !== 'admin') { if (user.role === 'admin' || asset.user_id?.toString() === user.id) {
throw new NotFoundException('Asset not found');
}
return asset; return asset;
} }
private buildDownloadFilename(asset: Asset) { if (asset.project_id) {
const project = await this.prisma.project.findUnique({
where: { id: asset.project_id },
select: { user_id: true }
});
if (project?.user_id.toString() === user.id) {
return asset;
}
}
throw new NotFoundException('Asset not found');
}
private async findEditableAssetForUser(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 (user.role === 'admin' || asset.user_id?.toString() === user.id) {
return asset;
}
if (asset.project_id) {
const project = await this.prisma.project.findUnique({ where: { id: asset.project_id } });
if (project?.user_id.toString() === user.id) {
return asset;
}
}
throw new NotFoundException('Asset not found');
}
private async findAssetGeneration(asset: Asset): Promise<SafeAssetGeneration | null> {
const tasks = await this.prisma.renderTask.findMany({
where: { output_asset_id: asset.id }
});
const task = tasks.sort((left, right) => right.created_at.getTime() - left.created_at.getTime())[0] ?? null;
if (task) {
const logs = await this.prisma.providerLog.findMany({
where: { task_id: task.id }
});
const log = logs.sort((left, right) => right.created_at.getTime() - left.created_at.getTime())[0] ?? null;
const provider = await this.findGenerationProvider(task.provider_id ?? log?.provider_id ?? null);
return this.createAssetGenerationFromTask(task, provider, log);
}
const clips = await this.prisma.videoClip.findMany({
where: { output_asset_id: asset.id }
});
const clip = clips.sort((left, right) => right.created_at.getTime() - left.created_at.getTime())[0] ?? null;
if (!clip) {
return null;
}
const provider = await this.findGenerationProvider(clip.provider_id);
return this.createAssetGenerationFromClip(clip, provider);
}
private async findGenerationProvider(providerId: bigint | null | undefined) {
if (!providerId) return null;
return this.prisma.providerConfig.findUnique({ where: { id: providerId } });
}
private createAssetGenerationFromTask(
task: RenderTask,
provider: ProviderConfig | null,
log: ProviderLog | null
): SafeAssetGeneration {
const response = this.jsonObject(log?.response_json ?? null);
const responseProviderRequestId =
this.stringifyJsonText(response.provider_request_id) ||
this.stringifyJsonText(response.task_id) ||
this.stringifyJsonText(response.id) ||
null;
const providerCode =
log?.provider_code ??
this.providerCodeFromTask(task) ??
provider?.provider_code ??
null;
return {
source: 'render_task',
display_name: this.displayNameFromTaskInput(task),
provider_id: provider?.id.toString() ?? task.provider_id?.toString() ?? log?.provider_id?.toString() ?? null,
provider_type: provider?.provider_type ?? log?.provider_type ?? null,
provider_code: providerCode,
provider_name: provider?.display_name ?? providerCode,
model_name: provider?.model_name ?? log?.model_name ?? this.modelNameFromTaskInput(task),
task_id: task.id.toString(),
task_type: task.task_type,
provider_request_id: task.provider_request_id ?? responseProviderRequestId,
clip_id: null,
status: task.status,
cost_actual: task.cost_actual?.toString() ?? log?.cost_actual?.toString() ?? null,
created_at: task.created_at.toISOString()
};
}
private createAssetGenerationFromClip(
clip: VideoClip,
provider: ProviderConfig | null
): SafeAssetGeneration {
return {
source: 'video_clip',
display_name: null,
provider_id: provider?.id.toString() ?? clip.provider_id?.toString() ?? null,
provider_type: provider?.provider_type ?? 'VideoProvider',
provider_code: provider?.provider_code ?? null,
provider_name: provider?.display_name ?? provider?.provider_code ?? null,
model_name: provider?.model_name ?? null,
task_id: null,
task_type: 'video_clip',
provider_request_id: null,
clip_id: clip.id.toString(),
status: clip.status,
cost_actual: clip.cost_actual?.toString() ?? null,
created_at: clip.created_at.toISOString()
};
}
private providerCodeFromTask(task: RenderTask) {
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) ||
null
);
}
private modelNameFromTaskInput(task: RenderTask) {
const inputJson = this.jsonObject(task.input_json ?? null);
const routerDecision = this.jsonObject(inputJson.router_decision ?? null);
return (
this.stringifyJsonText(inputJson.model_name) ||
this.stringifyJsonText(inputJson.model) ||
this.stringifyJsonText(routerDecision.model_name) ||
this.stringifyJsonText(routerDecision.model) ||
null
);
}
private displayNameFromTaskInput(task: RenderTask) {
const inputJson = this.jsonObject(task.input_json ?? null);
return (
this.stringifyJsonText(inputJson.render_title) ||
this.stringifyJsonText(inputJson.display_name) ||
this.stringifyJsonText(inputJson.title) ||
null
);
}
private jsonObject(value: Prisma.InputJsonValue | Prisma.JsonValue | null | undefined) {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, Prisma.InputJsonValue | Prisma.JsonValue>
: {};
}
private stringifyJsonText(value: unknown) {
if (typeof value === 'string') return value.trim();
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
return '';
}
private buildDownloadFilename(asset: Asset, generation: SafeAssetGeneration | null = null) {
const extension = this.extensionFromMime(asset.mime_type) || this.extensionFromPath(asset.file_path); const extension = this.extensionFromMime(asset.mime_type) || this.extensionFromPath(asset.file_path);
const displayName = this.safeDownloadFilenameStem(asset.display_name ?? generation?.display_name ?? '');
if (displayName) {
return `${displayName}${extension}`;
}
const safeType = asset.asset_type.replace(/[^a-z0-9_-]/gi, '_') || 'asset'; const safeType = asset.asset_type.replace(/[^a-z0-9_-]/gi, '_') || 'asset';
return `${safeType}-${asset.id.toString()}${extension}`; return `${safeType}-${asset.id.toString()}${extension}`;
} }
private safeDownloadFilenameStem(value: string) {
return value
.replace(/[\\/:*?"<>|]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 80);
}
private normalizeSelectionStatus(value: unknown) {
const normalized = this.normalizeNullableText(value, 30) ?? 'candidate';
if (!['candidate', 'selected', 'rejected'].includes(normalized)) {
throw new BadRequestException('Invalid selection_status');
}
return normalized;
}
private normalizeNullableText(value: unknown, maxLength: number) {
if (value === null || value === undefined) return null;
const text = String(value).trim();
if (!text) return null;
return text.slice(0, maxLength);
}
private normalizeJsonObject(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return Prisma.JsonNull;
}
return value as Prisma.InputJsonObject;
}
private extensionFromPath(filePath: string) { private extensionFromPath(filePath: string) {
const match = /\.([a-z0-9]+)$/i.exec(filePath); const match = /\.([a-z0-9]+)$/i.exec(filePath);
return match ? `.${match[1].toLowerCase()}` : ''; return match ? `.${match[1].toLowerCase()}` : '';
@@ -1,4 +1,4 @@
import { Controller, Get, Inject, Param, Res, StreamableFile } from '@nestjs/common'; import { Controller, Get, Headers, Inject, Param, Res, StreamableFile } from '@nestjs/common';
import type { Response } from 'express'; import type { Response } from 'express';
import { StorageService } from './storage.service'; import { StorageService } from './storage.service';
@@ -9,16 +9,23 @@ export class PublicTempAssetsController {
@Get(':token') @Get(':token')
async downloadTemporaryAsset( async downloadTemporaryAsset(
@Param('token') token: string, @Param('token') token: string,
@Headers('range') range: string | undefined,
@Res({ passthrough: true }) response: Response @Res({ passthrough: true }) response: Response
) { ) {
const result = await this.storage.readTemporaryPublicFile(token); const result = await this.storage.streamTemporaryPublicFile(token, range);
response.setHeader('Content-Type', result.mimeType); response.setHeader('Content-Type', result.mimeType);
response.setHeader('Content-Length', result.buffer.length.toString()); response.setHeader('Accept-Ranges', 'bytes');
response.setHeader('Content-Length', result.contentLength.toString());
if (result.statusCode === 206) {
response.status(206);
response.setHeader('Content-Range', `bytes ${result.start}-${result.end}/${result.size}`);
}
const cacheMaxAge = Math.max(0, Math.min(result.expiresAt - Math.floor(Date.now() / 1000), 24 * 60 * 60));
response.setHeader('Content-Disposition', 'inline'); response.setHeader('Content-Disposition', 'inline');
response.setHeader('Cache-Control', 'private, max-age=0, no-store'); response.setHeader('Cache-Control', `private, max-age=${cacheMaxAge}, immutable`);
response.setHeader('X-Content-Type-Options', 'nosniff'); response.setHeader('X-Content-Type-Options', 'nosniff');
return new StreamableFile(result.buffer); return new StreamableFile(result.stream);
} }
} }
+117 -5
View File
@@ -1,5 +1,6 @@
import { createHash, createHmac, randomUUID, timingSafeEqual } from 'node:crypto'; import { createHash, createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { createReadStream } from 'node:fs';
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
import { dirname, extname, isAbsolute, join, resolve } from 'node:path'; import { dirname, extname, isAbsolute, join, resolve } from 'node:path';
import type { Readable } from 'node:stream'; import type { Readable } from 'node:stream';
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common'; import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
@@ -12,6 +13,12 @@ type TemporaryPublicFilePayload = {
expires_at: number; expires_at: number;
nonce: string; nonce: string;
}; };
type ByteRange = {
start: number;
end: number;
contentLength: number;
statusCode: 200 | 206;
};
@Injectable() @Injectable()
export class StorageService { export class StorageService {
@@ -43,6 +50,10 @@ export class StorageService {
throw new BadRequestException('Unsupported storage path'); throw new BadRequestException('Unsupported storage path');
} }
async streamPrivateFile(filePath: string, mimeType?: string | null, rangeHeader?: string) {
return this.streamStoredFile(filePath, mimeType || 'application/octet-stream', rangeHeader);
}
createTemporaryPublicUrl(input: { createTemporaryPublicUrl(input: {
filePath: string; filePath: string;
mimeType?: string | null; mimeType?: string | null;
@@ -74,6 +85,60 @@ export class StorageService {
}; };
} }
async streamTemporaryPublicFile(token: string, rangeHeader?: string) {
const payload = this.verifyTemporaryPublicToken(token);
const result = await this.streamStoredFile(payload.file_path, payload.mime_type, rangeHeader);
return {
...result,
filePath: payload.file_path,
expiresAt: payload.expires_at
};
}
private async streamStoredFile(filePath: string, mimeType: string, rangeHeader?: string) {
if (filePath.startsWith('local://')) {
const fullPath = this.localObjectFullPath(filePath);
const stats = await stat(fullPath);
const range = this.resolveByteRange(rangeHeader, stats.size);
return {
stream: createReadStream(fullPath, { start: range.start, end: range.end }),
mimeType: mimeType || 'application/octet-stream',
size: stats.size,
start: range.start,
end: range.end,
contentLength: range.contentLength,
statusCode: range.statusCode
};
}
if (filePath.startsWith('minio://')) {
const { client, bucket, objectName } = this.minioObject(filePath);
const stats = await client.statObject(bucket, objectName);
const size = Number(stats.size);
const range = this.resolveByteRange(rangeHeader, size);
const stream = range.statusCode === 206
? await (client as unknown as {
getPartialObject: (bucketName: string, object: string, offset: number, length: number) => Promise<Readable>;
}).getPartialObject(bucket, objectName, range.start, range.contentLength)
: await client.getObject(bucket, objectName);
return {
stream,
mimeType: mimeType || 'application/octet-stream',
size,
start: range.start,
end: range.end,
contentLength: range.contentLength,
statusCode: range.statusCode
};
}
throw new BadRequestException('Unsupported storage path');
}
private async storeLocally( private async storeLocally(
file: Express.Multer.File, file: Express.Multer.File,
prefix: string prefix: string
@@ -130,15 +195,25 @@ export class StorageService {
} }
private async readLocalObject(filePath: string) { private async readLocalObject(filePath: string) {
return readFile(this.localObjectFullPath(filePath));
}
private async readMinioObject(filePath: string) {
const { client, bucket, objectName } = this.minioObject(filePath);
const stream = await client.getObject(bucket, objectName);
return this.streamToBuffer(stream);
}
private localObjectFullPath(filePath: string) {
const objectName = filePath.replace(/^local:\/\//, ''); const objectName = filePath.replace(/^local:\/\//, '');
if (!objectName || objectName.includes('..')) { if (!objectName || objectName.includes('..')) {
throw new BadRequestException('Invalid local storage path'); throw new BadRequestException('Invalid local storage path');
} }
return readFile(join(this.root, 'private', objectName)); return join(this.root, 'private', objectName);
} }
private async readMinioObject(filePath: string) { private minioObject(filePath: string) {
const match = /^minio:\/\/([^/]+)\/(.+)$/.exec(filePath); const match = /^minio:\/\/([^/]+)\/(.+)$/.exec(filePath);
if (!match) { if (!match) {
throw new BadRequestException('Invalid MinIO storage path'); throw new BadRequestException('Invalid MinIO storage path');
@@ -152,8 +227,45 @@ export class StorageService {
accessKey: process.env.MINIO_ACCESS_KEY || '', accessKey: process.env.MINIO_ACCESS_KEY || '',
secretKey: process.env.MINIO_SECRET_KEY || '' secretKey: process.env.MINIO_SECRET_KEY || ''
}); });
const stream = await client.getObject(bucket, objectName);
return this.streamToBuffer(stream); return { client, bucket, objectName };
}
private resolveByteRange(rangeHeader: string | undefined, size: number): ByteRange {
if (!rangeHeader) {
return {
start: 0,
end: Math.max(0, size - 1),
contentLength: size,
statusCode: 200
};
}
const match = /^bytes=(\d*)-(\d*)$/.exec(rangeHeader.trim());
if (!match || size <= 0) {
throw new BadRequestException('Invalid byte range');
}
const [, rawStart, rawEnd] = match;
const suffixLength = rawStart === '' ? Number(rawEnd) : null;
const start = suffixLength !== null
? Math.max(0, size - suffixLength)
: Number(rawStart);
const end = rawEnd && suffixLength === null
? Math.min(size - 1, Number(rawEnd))
: size - 1;
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < start || start >= size) {
throw new BadRequestException('Invalid byte range');
}
return {
start,
end,
contentLength: end - start + 1,
statusCode: 206
};
} }
private async streamToBuffer(stream: Readable) { private async streamToBuffer(stream: Readable) {
+7
View File
@@ -4,3 +4,10 @@ export class UploadAssetDto {
asset_type?: AssetType; asset_type?: AssetType;
project_id?: string; project_id?: string;
} }
export class UpdateAssetReviewStateDto {
display_name?: string | null;
selection_status?: 'candidate' | 'selected' | 'rejected';
selection_note?: string | null;
metadata_json?: unknown;
}
+287
View File
@@ -2,10 +2,45 @@ import type { CharacterRoleType, CharacterStatus } from './character.types';
export class ExtractCharactersDto { export class ExtractCharactersDto {
story_bible_id?: string; story_bible_id?: string;
provider_code?: string;
}
export class ExtractProjectIpAssetsDto {
source?: 'story_bible' | 'all' | string;
include_characters?: boolean | string;
refresh_existing?: boolean | string;
}
export class OptimizeProjectIpAssetPromptsDto {
asset_kind?: 'prop' | 'scene' | 'all' | string;
min_quality_score?: number | string | null;
include_pending?: boolean | string;
}
export class ImportCharacterExtractionDto {
raw_text?: string;
source_label?: string;
quality_score?: number;
review_comment?: string;
apply_to_story_bible?: boolean | string;
}
export class UpdateCharacterExtractionReviewDto {
quality_score?: number;
review_comment?: string;
status?: string;
}
export class AutoBindCharacterVoicesDto {
voice_provider_code?: string;
force?: boolean | string;
} }
export class CreateCharacterDto { export class CreateCharacterDto {
story_character_id?: string;
global_character_id?: string; global_character_id?: string;
global_character_look_version_id?: string;
global_character_asset_id?: string;
name?: string; name?: string;
alias_names?: string[]; alias_names?: string[];
role_type?: CharacterRoleType; role_type?: CharacterRoleType;
@@ -30,9 +65,261 @@ export class CreateCharacterDto {
voice_id?: string; voice_id?: string;
voice_style?: string; voice_style?: string;
performance_style?: string; performance_style?: string;
inherit_voice_from_global?: boolean | string;
inherit_digital_human_from_global?: boolean | string;
importance_level?: number; importance_level?: number;
anchor_asset_id?: string;
} }
export class UpdateCharacterDto extends CreateCharacterDto { export class UpdateCharacterDto extends CreateCharacterDto {
status?: CharacterStatus; status?: CharacterStatus;
} }
export class SaveMyGlobalCharacterDto {
name?: string;
display_name?: string;
role_archetype?: 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;
default_costume_rules?: string;
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;
status?: string;
}
export class SetMyGlobalCharacterAssetsDto {
asset_ids?: string[];
look_version_id?: string;
consent_confirmed?: boolean | string;
set_primary_anchor?: boolean | string;
label?: string;
notes?: string;
}
export class SaveGlobalCharacterLookVersionDto {
version_name?: string;
style_type?: string;
appearance_desc?: string;
hair_desc?: string;
makeup_desc?: string;
body_desc?: string;
costume_rules?: string;
color_palette?: string;
key_props?: string;
negative_rules?: string;
main_anchor_asset_id?: string;
status?: string;
quality_score?: number | string | null;
reviewer_comment?: string;
source_project_id?: string;
}
export class ImportGlobalCharacterAssetDto {
asset_id?: string;
look_version_id?: string;
asset_type?: string;
source_type?: string;
source_label?: string;
label?: string;
prompt_text?: string;
negative_prompt?: string;
model_name?: string;
seed?: string;
resolution?: string;
aspect_ratio?: string;
cost_estimate?: number | string | null;
cost_actual?: number | string | null;
quality_score?: number | string | null;
consistency_score?: number | string | null;
face_similarity_score?: number | string | null;
license_status?: string;
commercial_allowed?: boolean | string;
review_status?: string;
reviewer_note?: string;
is_primary?: boolean | string;
set_as_main_anchor?: boolean | string;
metadata_json?: unknown;
}
export class SetGlobalCharacterPrimaryAssetDto {
global_character_asset_id?: string;
scope?: 'character' | 'look_version' | 'both';
}
export class UpdateGlobalCharacterAssetReviewDto {
label?: string;
prompt_text?: string;
negative_prompt?: string;
model_name?: string;
cost_estimate?: number | string | null;
cost_actual?: number | string | null;
quality_score?: number | string | null;
consistency_score?: number | string | null;
face_similarity_score?: number | string | null;
license_status?: string;
commercial_allowed?: boolean | string;
review_status?: string;
reviewer_note?: string;
}
export class PromoteCharacterToGlobalDto {
version_name?: string;
style_type?: string;
source_label?: string;
set_project_binding?: boolean | string;
}
export class BindCharacterIpDto {
global_character_id?: string;
look_version_id?: string;
global_character_asset_id?: string;
inherit_voice?: boolean | string;
inherit_digital_human?: boolean | string;
}
export class CreateCharacterDesignVersionDto {
prompt_text?: string;
negative_prompt?: string;
image_asset_id?: string;
notes?: string;
source?: string;
is_final?: boolean;
}
export class SaveCharacterPromptVersionDto {
layer_code?: string;
channel?: string;
title?: string;
source_type?: string;
source_label?: string;
prompt_engine_version?: string;
prompt_text?: string;
negative_prompt?: string;
model_name?: string;
usage_note?: string;
quality_score?: number | string | null;
review_comment?: string;
is_active?: boolean | string;
status?: string;
look_version_id?: string;
metadata_json?: unknown;
}
export class CreateCharacterStateDto {
state_code?: string;
display_name?: string;
description?: string;
wardrobe_rules?: string;
emotion_rules?: string;
prompt_suffix?: string;
negative_rules?: string;
reference_asset_id?: string;
status?: string;
}
export class TestCharacterAnchorVideoDto {
provider_code?: string;
duration?: number | string;
force?: boolean | string;
purpose?: 'anchor_test' | 'video_character_element_source';
reference_mode?: 'first_frame' | 'omni_reference';
voice_line?: string;
generate_audio?: boolean | string;
source_asset_id?: string;
prompt_override?: string;
negative_prompt?: string;
resolution?: '720p' | '1080p' | '4k';
mode?: 'std' | 'pro' | '4k';
}
export class ImportCharacterProviderBindingDto {
look_version_id?: string;
source_asset_id?: string;
provider_code?: string;
provider_asset_type?: 'video_character_element' | 'multi_image_element';
provider_element_id?: string;
element_name?: string;
element_description?: string;
source_duration?: number | string | null;
voice_bound?: boolean | string;
voice_id?: string;
voice_description?: string;
validation_score?: number | string | null;
validation_report_json?: unknown;
status?: 'candidate' | 'approved' | 'rejected' | 'archived';
is_primary?: boolean | string;
}
export class UpdateCharacterProviderBindingDto {
element_name?: string;
element_description?: string;
voice_bound?: boolean | string;
voice_id?: string;
voice_description?: string;
validation_score?: number | string | null;
validation_report_json?: unknown;
status?: 'candidate' | 'approved' | 'rejected' | 'archived';
is_primary?: boolean | string;
}
export class CreateProjectVisualAssetDto {
asset_id?: string;
asset_kind?: 'character' | 'prop' | 'scene';
asset_type?: string;
name?: string;
label?: string;
ownership_type?:
| 'role_identity'
| 'role_exclusive'
| 'shared_story_asset'
| 'neutral_asset'
| 'scene_lock'
| 'exclusive_character'
| 'shared_story'
| 'neutral';
owner_character_id?: string | null;
exclusive_owner?: string | null;
aliases?: string[];
allowed_roles?: string[];
source_type?: string;
source_label?: string;
detected_from?: string;
importance?: number | string | null;
visual_lock?: string;
key_objects?: string[];
prompt_block?: string;
anchor_prompt?: string;
prompt_text?: string;
negative_prompt?: string;
reference_images?: string[];
anchor_images?: string[];
render_variants?: unknown;
linked_assets?: string[];
reuse_rule?: string;
story_scope?: string;
version_note?: string;
notes?: string;
tags?: string[];
usage_tags?: string[];
quality_score?: number | string | null;
is_primary?: boolean | string;
metadata_json?: unknown;
status?: string;
}
export class UpdateProjectVisualAssetDto extends CreateProjectVisualAssetDto {}
+428 -1
View File
@@ -1,4 +1,17 @@
import type { Character, GlobalCharacter, Prisma } from '@prisma/client'; import type {
Asset,
Character,
CharacterProviderBinding,
CharacterDesignVersion,
CharacterPromptVersion,
CharacterState,
GlobalCharacter,
GlobalCharacterAsset,
GlobalCharacterLookVersion,
Prisma,
ProjectVisualAsset
} from '@prisma/client';
import { toSafeAsset, type SafeAsset } from '../assets/asset.types';
export const CHARACTER_ROLE_TYPES = [ export const CHARACTER_ROLE_TYPES = [
'protagonist', 'protagonist',
@@ -22,7 +35,10 @@ export type CharacterStatus = (typeof CHARACTER_STATUSES)[number];
export interface SafeCharacter { export interface SafeCharacter {
id: string; id: string;
project_id: string; project_id: string;
story_character_id: string | null;
global_character_id: string | null; global_character_id: string | null;
global_character_look_version_id: string | null;
global_character_asset_id: string | null;
name: string; name: string;
alias_names: Prisma.JsonValue | null; alias_names: Prisma.JsonValue | null;
role_type: string; role_type: string;
@@ -48,6 +64,8 @@ export interface SafeCharacter {
voice_id: string | null; voice_id: string | null;
voice_style: string | null; voice_style: string | null;
performance_style: string | null; performance_style: string | null;
inherit_voice_from_global: boolean;
inherit_digital_human_from_global: boolean;
importance_level: number; importance_level: number;
status: string; status: string;
created_at: string; created_at: string;
@@ -88,11 +106,207 @@ export interface SafeGlobalCharacter {
updated_at: string; updated_at: string;
} }
export interface SafeGlobalCharacterAsset {
id: string;
global_character_id: string;
look_version_id: string | null;
asset_id: string | null;
asset_type: string;
source_type: string;
source_label: string | null;
label: string | null;
prompt_text: string | null;
negative_prompt: string | null;
model_name: string | null;
seed: string | null;
resolution: string | null;
aspect_ratio: string | null;
cost_estimate: string | null;
cost_actual: string | null;
quality_score: string | null;
consistency_score: string | null;
face_similarity_score: string | null;
license_status: string;
commercial_allowed: boolean;
review_status: string;
reviewer_note: string | null;
is_primary: boolean;
usage_count: number;
metadata_json: Prisma.JsonValue | null;
status: string;
created_at: string;
asset?: SafeAsset | null;
}
export interface SafeCharacterProviderBinding {
id: string;
global_character_id: string;
look_version_id: string | null;
source_asset_id: string | null;
provider_code: string;
provider_asset_type: string;
provider_element_id: string;
element_name: string;
element_description: string | null;
source_duration: string | null;
voice_bound: boolean;
voice_id: string | null;
voice_description: string | null;
binding_version: number;
validation_score: string | null;
validation_report_json: Prisma.JsonValue | null;
status: string;
is_primary: boolean;
created_by_user_id: string | null;
created_at: string;
updated_at: string;
source_asset?: SafeAsset | null;
}
export interface SafeGlobalCharacterLookVersion {
id: string;
global_character_id: string;
version_name: string;
style_type: string | null;
appearance_desc: string | null;
hair_desc: string | null;
makeup_desc: string | null;
body_desc: string | null;
costume_rules: string | null;
color_palette: string | null;
key_props: string | null;
negative_rules: string | null;
main_anchor_asset_id: string | null;
status: string;
quality_score: string | null;
reviewer_comment: string | null;
source_project_id: string | null;
created_by_user_id: string | null;
created_at: string;
updated_at: string;
}
export interface SafeCharacterDesignVersion {
id: string;
project_id: string;
character_id: string;
version_no: number;
prompt_text: string | null;
negative_prompt: string | null;
image_asset_id: string | null;
notes: string | null;
source: string;
is_final: boolean;
created_by_user_id: string | null;
created_at: string;
}
export interface SafeCharacterPromptVersion {
id: string;
project_id: string | null;
character_id: string | null;
global_character_id: string | null;
look_version_id: string | null;
version_no: number;
layer_code: string;
channel: string;
title: string | null;
source_type: string;
source_label: string | null;
prompt_engine_version: string | null;
prompt_text: string;
negative_prompt: string | null;
model_name: string | null;
usage_note: string | null;
quality_score: string | null;
review_comment: string | null;
is_active: boolean;
metadata_json: Prisma.JsonValue | null;
created_by_user_id: string | null;
status: string;
created_at: string;
updated_at: string;
}
export interface SafeCharacterState {
id: string;
project_id: string;
character_id: string;
state_code: string;
display_name: string | null;
description: string | null;
wardrobe_rules: string | null;
emotion_rules: string | null;
prompt_suffix: string | null;
negative_rules: string | null;
reference_asset_id: string | null;
status: string;
created_at: string;
updated_at: string;
}
export interface SafeProjectVisualAsset {
id: string;
project_id: string;
user_id: string;
asset_id: string | null;
asset_kind: string;
asset_type: string;
name: string;
label: string | null;
ownership_type: string;
owner_character_id: string | null;
source_type: string;
source_label: string | null;
aliases_json: Prisma.JsonValue | null;
aliases: string[];
allowed_roles_json: Prisma.JsonValue | null;
allowed_roles: string[];
detected_from: string | null;
importance: number;
visual_lock: string | null;
key_objects_json: Prisma.JsonValue | null;
key_objects: string[];
prompt_block: string | null;
anchor_prompt: string | null;
prompt_text: string | null;
negative_prompt: string | null;
reference_images_json: Prisma.JsonValue | null;
reference_images: string[];
anchor_images_json: Prisma.JsonValue | null;
anchor_images: string[];
render_variants_json: Prisma.JsonValue | null;
linked_assets_json: Prisma.JsonValue | null;
linked_assets: string[];
reuse_rule: string | null;
story_scope: string | null;
version_note: string | null;
notes: string | null;
usage_tags_json: Prisma.JsonValue | null;
usage_tags: string[];
tags: string[];
quality_score: number | null;
is_primary: boolean;
metadata_json: Prisma.JsonValue | null;
status: string;
created_at: string;
updated_at: string;
asset?: SafeAsset | null;
owner_character?: SafeCharacter | null;
}
function jsonStringArray(value: Prisma.JsonValue | null | undefined) {
return Array.isArray(value) ? value.map((item) => String(item)).filter(Boolean) : [];
}
export function toSafeCharacter(character: Character): SafeCharacter { export function toSafeCharacter(character: Character): SafeCharacter {
return { return {
id: character.id.toString(), id: character.id.toString(),
project_id: character.project_id.toString(), project_id: character.project_id.toString(),
story_character_id: character.story_character_id?.toString() ?? null,
global_character_id: character.global_character_id?.toString() ?? null, global_character_id: character.global_character_id?.toString() ?? null,
global_character_look_version_id: character.global_character_look_version_id?.toString() ?? null,
global_character_asset_id: character.global_character_asset_id?.toString() ?? null,
name: character.name, name: character.name,
alias_names: character.alias_names, alias_names: character.alias_names,
role_type: character.role_type, role_type: character.role_type,
@@ -118,6 +332,8 @@ export function toSafeCharacter(character: Character): SafeCharacter {
voice_id: character.voice_id, voice_id: character.voice_id,
voice_style: character.voice_style, voice_style: character.voice_style,
performance_style: character.performance_style, performance_style: character.performance_style,
inherit_voice_from_global: character.inherit_voice_from_global,
inherit_digital_human_from_global: character.inherit_digital_human_from_global,
importance_level: character.importance_level, importance_level: character.importance_level,
status: character.status, status: character.status,
created_at: character.created_at.toISOString(), created_at: character.created_at.toISOString(),
@@ -125,6 +341,60 @@ export function toSafeCharacter(character: Character): SafeCharacter {
}; };
} }
export function toSafeProjectVisualAsset(
row: ProjectVisualAsset & { asset?: Asset | null; owner_character?: Character | null }
): SafeProjectVisualAsset {
return {
id: row.id.toString(),
project_id: row.project_id.toString(),
user_id: row.user_id.toString(),
asset_id: row.asset_id?.toString() ?? null,
asset_kind: row.asset_kind,
asset_type: row.asset_type,
name: row.name,
label: row.label,
ownership_type: row.ownership_type,
owner_character_id: row.owner_character_id?.toString() ?? null,
source_type: row.source_type,
source_label: row.source_label,
aliases_json: row.aliases_json,
aliases: jsonStringArray(row.aliases_json),
allowed_roles_json: row.allowed_roles_json,
allowed_roles: jsonStringArray(row.allowed_roles_json),
detected_from: row.detected_from,
importance: row.importance,
visual_lock: row.visual_lock,
key_objects_json: row.key_objects_json,
key_objects: jsonStringArray(row.key_objects_json),
prompt_block: row.prompt_block,
anchor_prompt: row.anchor_prompt,
prompt_text: row.prompt_text,
negative_prompt: row.negative_prompt,
reference_images_json: row.reference_images_json,
reference_images: jsonStringArray(row.reference_images_json),
anchor_images_json: row.anchor_images_json,
anchor_images: jsonStringArray(row.anchor_images_json),
render_variants_json: row.render_variants_json,
linked_assets_json: row.linked_assets_json,
linked_assets: jsonStringArray(row.linked_assets_json),
reuse_rule: row.reuse_rule,
story_scope: row.story_scope,
version_note: row.version_note,
notes: row.notes,
usage_tags_json: row.usage_tags_json,
usage_tags: jsonStringArray(row.usage_tags_json),
tags: jsonStringArray(row.usage_tags_json),
quality_score: row.quality_score ? Number(row.quality_score.toString()) : null,
is_primary: row.is_primary,
metadata_json: row.metadata_json,
status: row.status,
created_at: row.created_at.toISOString(),
updated_at: row.updated_at.toISOString(),
asset: row.asset ? toSafeAsset(row.asset) : undefined,
owner_character: row.owner_character ? toSafeCharacter(row.owner_character) : undefined
};
}
export function toSafeGlobalCharacter(character: GlobalCharacter): SafeGlobalCharacter { export function toSafeGlobalCharacter(character: GlobalCharacter): SafeGlobalCharacter {
return { return {
id: character.id.toString(), id: character.id.toString(),
@@ -160,3 +430,160 @@ export function toSafeGlobalCharacter(character: GlobalCharacter): SafeGlobalCha
updated_at: character.updated_at.toISOString() updated_at: character.updated_at.toISOString()
}; };
} }
export function toSafeGlobalCharacterAsset(
row: GlobalCharacterAsset & { asset?: Asset | null }
): SafeGlobalCharacterAsset {
return {
id: row.id.toString(),
global_character_id: row.global_character_id.toString(),
look_version_id: row.look_version_id?.toString() ?? null,
asset_id: row.asset_id?.toString() ?? null,
asset_type: row.asset_type,
source_type: row.source_type,
source_label: row.source_label,
label: row.label,
prompt_text: row.prompt_text,
negative_prompt: row.negative_prompt,
model_name: row.model_name,
seed: row.seed,
resolution: row.resolution,
aspect_ratio: row.aspect_ratio,
cost_estimate: row.cost_estimate?.toString() ?? null,
cost_actual: row.cost_actual?.toString() ?? null,
quality_score: row.quality_score?.toString() ?? null,
consistency_score: row.consistency_score?.toString() ?? null,
face_similarity_score: row.face_similarity_score?.toString() ?? null,
license_status: row.license_status,
commercial_allowed: row.commercial_allowed,
review_status: row.review_status,
reviewer_note: row.reviewer_note,
is_primary: row.is_primary,
usage_count: row.usage_count,
metadata_json: row.metadata_json,
status: row.status,
created_at: row.created_at.toISOString(),
asset: row.asset ? toSafeAsset(row.asset) : undefined
};
}
export function toSafeCharacterProviderBinding(
row: CharacterProviderBinding & { source_asset?: Asset | null }
): SafeCharacterProviderBinding {
return {
id: row.id.toString(),
global_character_id: row.global_character_id.toString(),
look_version_id: row.look_version_id?.toString() ?? null,
source_asset_id: row.source_asset_id?.toString() ?? null,
provider_code: row.provider_code,
provider_asset_type: row.provider_asset_type,
provider_element_id: row.provider_element_id,
element_name: row.element_name,
element_description: row.element_description,
source_duration: row.source_duration?.toString() ?? null,
voice_bound: row.voice_bound,
voice_id: row.voice_id,
voice_description: row.voice_description,
binding_version: row.binding_version,
validation_score: row.validation_score?.toString() ?? null,
validation_report_json: row.validation_report_json,
status: row.status,
is_primary: row.is_primary,
created_by_user_id: row.created_by_user_id?.toString() ?? null,
created_at: row.created_at.toISOString(),
updated_at: row.updated_at.toISOString(),
source_asset: row.source_asset ? toSafeAsset(row.source_asset) : undefined
};
}
export function toSafeGlobalCharacterLookVersion(
version: GlobalCharacterLookVersion
): SafeGlobalCharacterLookVersion {
return {
id: version.id.toString(),
global_character_id: version.global_character_id.toString(),
version_name: version.version_name,
style_type: version.style_type,
appearance_desc: version.appearance_desc,
hair_desc: version.hair_desc,
makeup_desc: version.makeup_desc,
body_desc: version.body_desc,
costume_rules: version.costume_rules,
color_palette: version.color_palette,
key_props: version.key_props,
negative_rules: version.negative_rules,
main_anchor_asset_id: version.main_anchor_asset_id?.toString() ?? null,
status: version.status,
quality_score: version.quality_score?.toString() ?? null,
reviewer_comment: version.reviewer_comment,
source_project_id: version.source_project_id?.toString() ?? null,
created_by_user_id: version.created_by_user_id?.toString() ?? null,
created_at: version.created_at.toISOString(),
updated_at: version.updated_at.toISOString()
};
}
export function toSafeCharacterDesignVersion(version: CharacterDesignVersion): SafeCharacterDesignVersion {
return {
id: version.id.toString(),
project_id: version.project_id.toString(),
character_id: version.character_id.toString(),
version_no: version.version_no,
prompt_text: version.prompt_text,
negative_prompt: version.negative_prompt,
image_asset_id: version.image_asset_id?.toString() ?? null,
notes: version.notes,
source: version.source,
is_final: version.is_final,
created_by_user_id: version.created_by_user_id?.toString() ?? null,
created_at: version.created_at.toISOString()
};
}
export function toSafeCharacterPromptVersion(version: CharacterPromptVersion): SafeCharacterPromptVersion {
return {
id: version.id.toString(),
project_id: version.project_id?.toString() ?? null,
character_id: version.character_id?.toString() ?? null,
global_character_id: version.global_character_id?.toString() ?? null,
look_version_id: version.look_version_id?.toString() ?? null,
version_no: version.version_no,
layer_code: version.layer_code,
channel: version.channel,
title: version.title,
source_type: version.source_type,
source_label: version.source_label,
prompt_engine_version: version.prompt_engine_version,
prompt_text: version.prompt_text,
negative_prompt: version.negative_prompt,
model_name: version.model_name,
usage_note: version.usage_note,
quality_score: version.quality_score?.toString() ?? null,
review_comment: version.review_comment,
is_active: version.is_active,
metadata_json: version.metadata_json,
created_by_user_id: version.created_by_user_id?.toString() ?? null,
status: version.status,
created_at: version.created_at.toISOString(),
updated_at: version.updated_at.toISOString()
};
}
export function toSafeCharacterState(state: CharacterState): SafeCharacterState {
return {
id: state.id.toString(),
project_id: state.project_id.toString(),
character_id: state.character_id.toString(),
state_code: state.state_code,
display_name: state.display_name,
description: state.description,
wardrobe_rules: state.wardrobe_rules,
emotion_rules: state.emotion_rules,
prompt_suffix: state.prompt_suffix,
negative_rules: state.negative_rules,
reference_asset_id: state.reference_asset_id?.toString() ?? null,
status: state.status,
created_at: state.created_at.toISOString(),
updated_at: state.updated_at.toISOString()
};
}
+415 -1
View File
@@ -13,7 +13,32 @@ import {
import { CurrentUser } from '../auth/current-user.decorator'; import { CurrentUser } from '../auth/current-user.decorator';
import type { AuthRequestUser } from '../auth/auth.types'; import type { AuthRequestUser } from '../auth/auth.types';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CreateCharacterDto, ExtractCharactersDto, UpdateCharacterDto } from './character.dto'; import {
AutoBindCharacterVoicesDto,
BindCharacterIpDto,
CreateCharacterDesignVersionDto,
CreateCharacterDto,
CreateCharacterStateDto,
CreateProjectVisualAssetDto,
ExtractCharactersDto,
ExtractProjectIpAssetsDto,
ImportGlobalCharacterAssetDto,
ImportCharacterProviderBindingDto,
ImportCharacterExtractionDto,
OptimizeProjectIpAssetPromptsDto,
PromoteCharacterToGlobalDto,
SaveMyGlobalCharacterDto,
SaveGlobalCharacterLookVersionDto,
SaveCharacterPromptVersionDto,
SetGlobalCharacterPrimaryAssetDto,
SetMyGlobalCharacterAssetsDto,
TestCharacterAnchorVideoDto,
UpdateCharacterProviderBindingDto,
UpdateCharacterExtractionReviewDto,
UpdateGlobalCharacterAssetReviewDto,
UpdateCharacterDto,
UpdateProjectVisualAssetDto
} from './character.dto';
import { CharactersService } from './characters.service'; import { CharactersService } from './characters.service';
@Controller() @Controller()
@@ -39,6 +64,41 @@ export class CharactersController {
return this.charactersService.listCharacters(user, projectId, includeDeleted === 'true'); return this.charactersService.listCharacters(user, projectId, includeDeleted === 'true');
} }
@Get('projects/:projectId/characters/anchor-video-tests')
listCharacterAnchorVideoTests(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string
) {
return this.charactersService.listCharacterAnchorVideoTests(user, projectId);
}
@Get('projects/:projectId/characters/extraction-versions')
listCharacterExtractionVersions(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string
) {
return this.charactersService.listCharacterExtractionVersions(user, projectId);
}
@Post('projects/:projectId/characters/extraction-versions/import')
importCharacterExtraction(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: ImportCharacterExtractionDto
) {
return this.charactersService.importCharacterExtraction(user, projectId, dto);
}
@Patch('projects/:projectId/characters/extraction-versions/:versionId/review')
updateCharacterExtractionReview(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Param('versionId') versionId: string,
@Body() dto: UpdateCharacterExtractionReviewDto
) {
return this.charactersService.updateCharacterExtractionReview(user, projectId, versionId, dto);
}
@Post('projects/:projectId/characters') @Post('projects/:projectId/characters')
createCharacter( createCharacter(
@CurrentUser() user: AuthRequestUser, @CurrentUser() user: AuthRequestUser,
@@ -48,6 +108,149 @@ export class CharactersController {
return this.charactersService.createCharacter(user, projectId, dto); return this.charactersService.createCharacter(user, projectId, dto);
} }
@Get('me/global-characters')
listMyGlobalCharacters(@CurrentUser() user: AuthRequestUser) {
return this.charactersService.listMyGlobalCharacters(user);
}
@Post('me/global-characters')
createMyGlobalCharacter(
@CurrentUser() user: AuthRequestUser,
@Body() dto: SaveMyGlobalCharacterDto
) {
return this.charactersService.createMyGlobalCharacter(user, dto);
}
@Patch('me/global-characters/:globalCharacterId')
updateMyGlobalCharacter(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string,
@Body() dto: SaveMyGlobalCharacterDto
) {
return this.charactersService.updateMyGlobalCharacter(user, globalCharacterId, dto);
}
@Get('me/global-characters/:globalCharacterId/provider-bindings')
listMyCharacterProviderBindings(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string
) {
return this.charactersService.listMyCharacterProviderBindings(user, globalCharacterId);
}
@Post('me/global-characters/:globalCharacterId/provider-bindings')
importMyCharacterProviderBinding(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string,
@Body() dto: ImportCharacterProviderBindingDto
) {
return this.charactersService.importMyCharacterProviderBinding(user, globalCharacterId, dto);
}
@Patch('me/global-characters/:globalCharacterId/provider-bindings/:bindingId')
updateMyCharacterProviderBinding(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string,
@Param('bindingId') bindingId: string,
@Body() dto: UpdateCharacterProviderBindingDto
) {
return this.charactersService.updateMyCharacterProviderBinding(user, globalCharacterId, bindingId, dto);
}
@Post('me/global-characters/:globalCharacterId/assets')
setMyGlobalCharacterAssets(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string,
@Body() dto: SetMyGlobalCharacterAssetsDto
) {
return this.charactersService.setMyGlobalCharacterAssets(user, globalCharacterId, dto);
}
@Post('me/global-characters/:globalCharacterId/look-versions')
createGlobalCharacterLookVersion(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string,
@Body() dto: SaveGlobalCharacterLookVersionDto
) {
return this.charactersService.createGlobalCharacterLookVersion(user, globalCharacterId, dto);
}
@Post('me/global-characters/:globalCharacterId/assets/import')
importGlobalCharacterAsset(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string,
@Body() dto: ImportGlobalCharacterAssetDto
) {
return this.charactersService.importGlobalCharacterAsset(user, globalCharacterId, dto);
}
@Post('me/global-characters/:globalCharacterId/assets/set-primary')
setGlobalCharacterPrimaryAsset(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string,
@Body() dto: SetGlobalCharacterPrimaryAssetDto
) {
return this.charactersService.setGlobalCharacterPrimaryAsset(user, globalCharacterId, dto);
}
@Get('me/global-characters/:globalCharacterId/visual-anchor-card')
getGlobalCharacterVisualAnchorCard(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string,
@Query('look_version_id') lookVersionId?: string
) {
return this.charactersService.getGlobalCharacterVisualAnchorCard(user, globalCharacterId, lookVersionId);
}
@Get('me/global-characters/:globalCharacterId/prompt-versions')
listGlobalCharacterPromptVersions(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string
) {
return this.charactersService.listGlobalCharacterPromptVersions(user, globalCharacterId);
}
@Post('me/global-characters/:globalCharacterId/prompt-versions')
createGlobalCharacterPromptVersion(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string,
@Body() dto: SaveCharacterPromptVersionDto
) {
return this.charactersService.createGlobalCharacterPromptVersion(user, globalCharacterId, dto);
}
@Post('me/global-characters/:globalCharacterId/prompt-versions/refresh-system')
refreshGlobalCharacterSystemPrompt(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string
) {
return this.charactersService.refreshGlobalCharacterSystemPrompt(user, globalCharacterId);
}
@Post('me/global-characters/:globalCharacterId/prompt-versions/:versionId/activate')
activateGlobalCharacterPromptVersion(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string,
@Param('versionId') versionId: string
) {
return this.charactersService.activateGlobalCharacterPromptVersion(user, globalCharacterId, versionId);
}
@Patch('me/global-characters/:globalCharacterId/assets/:globalCharacterAssetId/review')
updateGlobalCharacterAssetReview(
@CurrentUser() user: AuthRequestUser,
@Param('globalCharacterId') globalCharacterId: string,
@Param('globalCharacterAssetId') globalCharacterAssetId: string,
@Body() dto: UpdateGlobalCharacterAssetReviewDto
) {
return this.charactersService.updateGlobalCharacterAssetReview(
user,
globalCharacterId,
globalCharacterAssetId,
dto
);
}
@Post('projects/:projectId/characters/confirm') @Post('projects/:projectId/characters/confirm')
confirmCharacters( confirmCharacters(
@CurrentUser() user: AuthRequestUser, @CurrentUser() user: AuthRequestUser,
@@ -56,6 +259,96 @@ export class CharactersController {
return this.charactersService.confirmCharacters(user, projectId); return this.charactersService.confirmCharacters(user, projectId);
} }
@Post('projects/:projectId/characters/voice-profiles/auto-bind')
autoBindCharacterVoices(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: AutoBindCharacterVoicesDto
) {
return this.charactersService.autoBindCharacterVoices(user, projectId, dto);
}
@Get('projects/:projectId/visual-assets')
listProjectVisualAssets(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Query('asset_kind') assetKind?: string
) {
return this.charactersService.listProjectVisualAssets(user, projectId, assetKind);
}
@Get('projects/:projectId/ops/ip-assets')
listProjectIpAssets(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Query('asset_kind') assetKind?: string
) {
return this.charactersService.listProjectIpAssets(user, projectId, assetKind);
}
@Get('projects/:projectId/ops/ip-assets/conflicts')
listProjectIpAssetConflicts(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string
) {
return this.charactersService.listProjectIpAssetConflicts(user, projectId);
}
@Post('projects/:projectId/ops/ip-assets/extract')
extractProjectIpAssets(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: ExtractProjectIpAssetsDto
) {
return this.charactersService.extractProjectIpAssets(user, projectId, dto);
}
@Post('projects/:projectId/ops/ip-assets/optimize-prompts')
optimizeProjectIpAssetPrompts(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: OptimizeProjectIpAssetPromptsDto
) {
return this.charactersService.optimizeProjectIpAssetPrompts(user, projectId, dto);
}
@Post('projects/:projectId/ops/ip-assets')
createProjectIpAsset(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: CreateProjectVisualAssetDto
) {
return this.charactersService.createProjectIpAsset(user, projectId, dto);
}
@Delete('projects/:projectId/ops/ip-assets/:visualAssetId')
deleteProjectIpAsset(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Param('visualAssetId') visualAssetId: string
) {
return this.charactersService.deleteProjectVisualAsset(user, projectId, visualAssetId);
}
@Post('projects/:projectId/visual-assets')
createProjectVisualAsset(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: CreateProjectVisualAssetDto
) {
return this.charactersService.createProjectVisualAsset(user, projectId, dto);
}
@Patch('projects/:projectId/visual-assets/:visualAssetId')
updateProjectVisualAsset(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Param('visualAssetId') visualAssetId: string,
@Body() dto: UpdateProjectVisualAssetDto
) {
return this.charactersService.updateProjectVisualAsset(user, projectId, visualAssetId, dto);
}
@Patch('characters/:characterId') @Patch('characters/:characterId')
updateCharacter( updateCharacter(
@CurrentUser() user: AuthRequestUser, @CurrentUser() user: AuthRequestUser,
@@ -65,6 +358,127 @@ export class CharactersController {
return this.charactersService.updateCharacter(user, characterId, dto); return this.charactersService.updateCharacter(user, characterId, dto);
} }
@Post('characters/:characterId/promote-to-global')
promoteCharacterToGlobal(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Body() dto: PromoteCharacterToGlobalDto
) {
return this.charactersService.promoteCharacterToGlobal(user, characterId, dto);
}
@Post('characters/:characterId/bind-global-ip')
bindCharacterIp(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Body() dto: BindCharacterIpDto
) {
return this.charactersService.bindCharacterIp(user, characterId, dto);
}
@Get('characters/:characterId/visual-anchor-card')
getCharacterVisualAnchorCard(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Query('look_version_id') lookVersionId?: string
) {
return this.charactersService.getCharacterVisualAnchorCard(user, characterId, lookVersionId);
}
@Get('characters/:characterId/anchor-video-test')
getCharacterAnchorVideoTest(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string
) {
return this.charactersService.getCharacterAnchorVideoTest(user, characterId);
}
@Post('characters/:characterId/anchor-video-test')
createCharacterAnchorVideoTest(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Body() dto: TestCharacterAnchorVideoDto
) {
return this.charactersService.createCharacterAnchorVideoTest(user, characterId, dto);
}
@Get('characters/:characterId/prompt-versions')
listCharacterPromptVersions(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string
) {
return this.charactersService.listCharacterPromptVersions(user, characterId);
}
@Post('characters/:characterId/prompt-versions')
createCharacterPromptVersion(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Body() dto: SaveCharacterPromptVersionDto
) {
return this.charactersService.createCharacterPromptVersion(user, characterId, dto);
}
@Post('characters/:characterId/prompt-versions/refresh-system')
refreshCharacterSystemPrompt(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string
) {
return this.charactersService.refreshCharacterSystemPrompt(user, characterId);
}
@Post('characters/:characterId/prompt-versions/:versionId/activate')
activateCharacterPromptVersion(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Param('versionId') versionId: string
) {
return this.charactersService.activateCharacterPromptVersion(user, characterId, versionId);
}
@Get('characters/:characterId/design-versions')
listCharacterDesignVersions(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string
) {
return this.charactersService.listCharacterDesignVersions(user, characterId);
}
@Post('characters/:characterId/design-versions')
createCharacterDesignVersion(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Body() dto: CreateCharacterDesignVersionDto
) {
return this.charactersService.createCharacterDesignVersion(user, characterId, dto);
}
@Post('characters/:characterId/design-versions/:versionId/finalize')
finalizeCharacterDesignVersion(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Param('versionId') versionId: string
) {
return this.charactersService.finalizeCharacterDesignVersion(user, characterId, versionId);
}
@Get('characters/:characterId/states')
listCharacterStates(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string
) {
return this.charactersService.listCharacterStates(user, characterId);
}
@Post('characters/:characterId/states')
upsertCharacterState(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Body() dto: CreateCharacterStateDto
) {
return this.charactersService.upsertCharacterState(user, characterId, dto);
}
@Delete('characters/:characterId') @Delete('characters/:characterId')
deleteCharacter( deleteCharacter(
@CurrentUser() user: AuthRequestUser, @CurrentUser() user: AuthRequestUser,
+3 -1
View File
@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AssetsModule } from '../assets/assets.module';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { ProvidersModule } from '../providers/providers.module';
import { CharactersController } from './characters.controller'; import { CharactersController } from './characters.controller';
import { CharactersService } from './characters.service'; import { CharactersService } from './characters.service';
@Module({ @Module({
imports: [AuthModule], imports: [AuthModule, AssetsModule, ProvidersModule],
controllers: [CharactersController], controllers: [CharactersController],
providers: [CharactersService], providers: [CharactersService],
exports: [CharactersService] exports: [CharactersService]
@@ -129,6 +129,10 @@ describe('CharactersService', () => {
updateMany: ReturnType<typeof vi.fn>; updateMany: ReturnType<typeof vi.fn>;
createMany: ReturnType<typeof vi.fn>; createMany: ReturnType<typeof vi.fn>;
}; };
providerConfig: {
findUnique: ReturnType<typeof vi.fn>;
findFirst: ReturnType<typeof vi.fn>;
};
characterMemory: { create: ReturnType<typeof vi.fn> }; characterMemory: { create: ReturnType<typeof vi.fn> };
$transaction: ReturnType<typeof vi.fn>; $transaction: ReturnType<typeof vi.fn>;
}; };
@@ -177,6 +181,25 @@ describe('CharactersService', () => {
updateMany: vi.fn(), updateMany: vi.fn(),
createMany: vi.fn() createMany: vi.fn()
}, },
providerConfig: {
findUnique: vi.fn().mockResolvedValue({
id: 1n,
provider_type: 'VoiceProvider',
provider_code: 'openai-tts',
display_name: 'OpenAI TTS',
mode: 'real',
model_name: 'gpt-4o-mini-tts',
config_json: { voice: 'coral' },
fallback_provider_id: null,
is_enabled: true,
priority: 100,
rate_limit_json: null,
cost_rule_json: null,
created_at: new Date('2026-05-31T00:00:00.000Z'),
updated_at: new Date('2026-05-31T00:00:00.000Z')
}),
findFirst: vi.fn().mockResolvedValue(null)
},
characterMemory: { characterMemory: {
create: vi.fn().mockResolvedValue({}) create: vi.fn().mockResolvedValue({})
}, },
@@ -291,4 +314,35 @@ describe('CharactersService', () => {
ForbiddenException ForbiddenException
); );
}); });
it('keeps clear fictional identity faces while rejecting copied real identities', () => {
const conflictingRules = [
'不要真人照片级清晰正脸',
'不要证件照式正面大头照',
'不要真实演员肖像照',
'不要可识别真人脸特写',
'不要高精真人脸摄影棚写真'
];
const privateService = service as unknown as {
personNegativePromptCn: (extraRules?: string | null) => string;
seedanceSafeCharacterNegativePrompt: (text: string) => string;
};
const genericNegativePrompt = privateService.personNegativePromptCn(
'不得换脸;不要真人照片级清晰正脸'
);
const seedanceNegativePrompt = privateService.seedanceSafeCharacterNegativePrompt(
'不得换脸,不要可识别真人脸特写'
);
for (const prompt of [genericNegativePrompt, seedanceNegativePrompt]) {
expect(prompt).toContain('不要复制现实演员、公众人物或未经授权真人的可识别面貌');
expect(prompt).toContain('不要生活摄影、商业写真或平台肖像模板');
for (const rule of conflictingRules) {
expect(prompt).not.toContain(rule);
}
}
expect(genericNegativePrompt).toContain('不得换脸');
expect(seedanceNegativePrompt).toContain('不得换脸');
});
}); });
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import {
buildCharacterTurnaroundPanelPrompt,
CHARACTER_TURNAROUND_PANEL_TEMPLATE_VERSION,
type CharacterTurnaroundPanelType
} from './character-turnaround-panel-template';
const profile = {
name: '诸葛亮',
genderLabel: '男',
ageGroup: '中年',
identityDesc: '五丈原时期蜀汉军师',
appearanceDesc: '清癯、沉静、睿智,具有真实中年感',
faceDesc: '长脸,轻微眼袋、法令纹,短髭与山羊胡',
hairDesc: '黑发夹灰白,高道髻',
eyeDesc: '深琥珀色眼睛',
bodyDesc: '身形修长挺拔',
costumeRules: '白色交领军师袍,浅青内层,淡金云纹,深色布靴'
};
describe('character turnaround split-panel prompt', () => {
const cases: Array<[CharacterTurnaroundPanelType, string]> = [
['turnaround_identity_panel', '身份特写'],
['turnaround_front_panel', '严格正面'],
['turnaround_side_panel', '严格90度右向侧面'],
['turnaround_back_panel', '严格180度背面']
];
it.each(cases)('builds one auditable %s contract', (panelType, requiredGeometry) => {
const prompt = buildCharacterTurnaroundPanelPrompt({
profile,
panelType,
referenceImageCount: 2,
liveAction: true
});
expect(prompt).toContain(CHARACTER_TURNAROUND_PANEL_TEMPLATE_VERSION);
expect(prompt).toContain(requiredGeometry);
expect(prompt).toContain('16:9横版');
expect(prompt).toContain('纯白无缝摄影棚背景');
expect(prompt).toContain('合成安全区');
expect(prompt).toContain('不得在同一张图中生成第二个人');
expect(prompt).toContain('双手空置');
expect(prompt).not.toContain('四个全身');
expect(prompt).not.toContain('两排');
});
it('makes side and back geometry mutually explicit', () => {
const side = buildCharacterTurnaroundPanelPrompt({
profile,
panelType: 'turnaround_side_panel',
referenceImageCount: 2,
liveAction: true
});
const back = buildCharacterTurnaroundPanelPrompt({
profile,
panelType: 'turnaround_back_panel',
referenceImageCount: 3,
liveAction: true
});
expect(side).toContain('禁止45度、三分之二侧面和回头看镜头');
expect(back).toContain('脸部完全不可见');
expect(back).toContain('禁止侧脸、回头、扭腰和三分之二背面');
});
});
@@ -0,0 +1,120 @@
export const CHARACTER_TURNAROUND_PANEL_TEMPLATE_VERSION =
'character_turnaround_split_panel_v1_1';
export const CHARACTER_TURNAROUND_PANEL_TYPES = [
'turnaround_identity_panel',
'turnaround_front_panel',
'turnaround_side_panel',
'turnaround_back_panel'
] as const;
export type CharacterTurnaroundPanelType =
(typeof CHARACTER_TURNAROUND_PANEL_TYPES)[number];
export interface CharacterTurnaroundPanelProfile {
name: string;
descriptionOverride?: string | null;
roleType?: string | null;
genderLabel?: string | null;
ageGroup?: string | null;
identityDesc?: string | null;
appearanceDesc?: string | null;
faceDesc?: string | null;
hairDesc?: string | null;
eyeDesc?: string | null;
bodyDesc?: string | null;
costumeRules?: string | null;
}
export function isCharacterTurnaroundPanelType(
value: string
): value is CharacterTurnaroundPanelType {
return (CHARACTER_TURNAROUND_PANEL_TYPES as readonly string[]).includes(value);
}
export function characterTurnaroundPanelLabel(type: CharacterTurnaroundPanelType) {
const labels: Record<CharacterTurnaroundPanelType, string> = {
turnaround_identity_panel: '身份特写',
turnaround_front_panel: '严格正面',
turnaround_side_panel: '严格90度侧面',
turnaround_back_panel: '严格180度背面'
};
return labels[type];
}
export function buildCharacterTurnaroundPanelPrompt(input: {
profile: CharacterTurnaroundPanelProfile;
panelType: CharacterTurnaroundPanelType;
referenceImageCount: number;
liveAction: boolean;
}) {
const profile = input.profile;
const profileText = profile.descriptionOverride
? `DESCRIPTION_OVERRIDE_MODE,本次唯一角色设定:${profile.descriptionOverride}`
: [
`角色名:${profile.name}`,
`身份:${profile.identityDesc || profile.roleType || ''}`,
`性别与年龄:${profile.genderLabel || ''}${profile.ageGroup || ''}`,
`外貌气质:${profile.appearanceDesc || ''}`,
`脸部:${profile.faceDesc || ''}`,
`发型与毛发:${profile.hairDesc || ''}`,
`眼睛:${profile.eyeDesc || ''}`,
`体型:${profile.bodyDesc || ''}`,
`基础服装:${profile.costumeRules || ''}`
].join('\n');
const referenceMode = input.referenceImageCount > 0
? `REFERENCE_LOCK_MODE:已附带 ${input.referenceImageCount} 张同角色参考图。参考图是身份、年龄、脸型、发型、体型与基础服装结构的最高真值;保持同一虚构数字演员,禁止换脸和重新设计服装。`
: 'DIRECT_DESIGN_MODE:未附带参考图。只按角色设定建立一个唯一、稳定、可复用的原创数字演员。';
const common = [
CHARACTER_TURNAROUND_PANEL_TEMPLATE_VERSION,
'S+影视角色母版拆分面板,16:9横版,单人,单一视角,纯白无缝摄影棚背景,均匀柔和棚拍光,低透视畸变,电影级写实与AAA角色资产质感。',
'使用当前模型与API参数允许的最高原生质量,保留适合4K放大、程序裁切和视频身份锁定的结构与微材质。',
'这不是多格设定板,不得在同一张图中生成第二个人、第二个角度、局部小窗、文字、标签、箭头、边框或水印。',
referenceMode,
profileText,
'身份、年龄、五官、发型、胡须、体型、服装版型、领口、腰带、袖口、鞋履和常驻穿戴配饰必须稳定。双手空置,不展示羽扇、武器、书卷、手机或剧情道具。',
'鞋履是跨面板硬锁定结构:必须符合角色所属时代与身份,使用同色软底传统鞋靴,鞋面、鞋底和包边保持深色同材质;禁止白色或浅色橡胶中底、运动鞋弧形鞋底、现代休闲鞋鞋头、拉链和工业胶边。',
'正面、侧面和背面必须保持完全相同的头顶至鞋底高度、肩宽、腰线、四肢长度与服装层级。中年或老年角色在侧面和背面也要通过灰白发分布、后颈、耳部、手部皮肤与克制姿态保持年龄,不得自动年轻化。',
input.liveAction
? '画面达到高预算历史电影官方角色资产母版:真实皮肤年龄纹理、清晰发丝与胡须根部、可辨布料织纹、缝线、刺绣、层叠和自然褶皱;克制写实,不偶像化,不仙侠海报化。'
: '高质量角色资产板,结构清楚,材质可辨,适合后续关键帧与视频一致性锁定。'
];
const panelRules: Record<CharacterTurnaroundPanelType, string[]> = {
turnaround_identity_panel: [
'面板任务:只生成身份特写。角色严格正对镜头,眼睛平视,相机与眼睛等高,中性克制表情,头部端正,不歪头,不做三分之二侧脸。',
'构图为头肩至胸上部特写,完整保留头顶、发髻、双耳、下巴、肩线和基础领口;脸部与肩部全部位于画面中央约27%的合成安全区,左右留出大量纯白空间。',
'两眼清晰、瞳孔方向一致,面部左右结构可信;放大后仍保留皮肤、眼周、法令纹、毛孔、胡须与发丝微细节。年龄必须贴合设定,不得比目标年龄明显年轻,也不得额外老化成高龄角色。'
],
turnaround_front_panel: [
'面板任务:只生成严格正面全身。角色身体、头部、肩线、骨盆和双脚全部正对镜头,左右对称,目视正前方。',
'标准角色建模中性站姿,双臂与躯干略微分开,双手和五指完全分离可见,双腿自然平行;从头顶到鞋底完整入画,同一水平基线。传统鞋靴必须是深色同材质软薄底,不能出现任何浅色现代鞋底边。',
'完整人物必须收纳在画面中央约22%的合成安全区,人物高度约占画面84%,两侧保持大面积纯白,不得裁手、裁脚、裁衣摆。'
],
turnaround_side_panel: [
'面板任务:只生成严格90度右向侧面全身。角色鼻尖、胸口、膝盖和脚尖统一朝画面右侧;只呈现标准侧面轮廓,禁止45度、三分之二侧面和回头看镜头。',
'相机与人物腰部等高,正交角色建模参考视角。胸腔、腰带、衣襟中线和鞋底压缩为真正侧面厚度,不展开任何正面结构;双臂自然下垂且略分离,头顶至鞋底完整入画,服装侧面层次与正面参考一致。传统鞋靴保持深色软薄底,无浅色胶边。',
'完整人物必须收纳在画面中央约22%的合成安全区,人物高度约占画面84%,两侧保持大面积纯白。'
],
turnaround_back_panel: [
'面板任务:只生成严格180度背面全身。角色后脑、双肩、脊柱、骨盆和脚跟正对镜头;脸部完全不可见,禁止侧脸、回头、扭腰和三分之二背面。',
'清晰展示发髻后部、发带、衣领后部、背部中心接缝、腰带后部、袍服垂坠、衣摆和鞋跟;长发自然分束并露出后领中心与腰带拓扑,结构必须能与正面、侧面参考对应。双手五指清晰分离,传统鞋靴保持深色同材质软薄底。',
'完整人物必须收纳在画面中央约22%的合成安全区,人物高度约占画面84%,双臂与身体略微分开,头顶至鞋底完整入画。'
]
};
return [...common, ...panelRules[input.panelType]].join('\n\n');
}
export function buildCharacterTurnaroundPanelNegativePrompt(extraRules?: string | null) {
return [
'multiple people, duplicate person, multiple views, split screen, collage, contact sheet, inset panel, text, label, arrow, watermark, logo',
'identity drift, face drift, age drift, different person, changed hairstyle, changed beard, changed costume, changed body proportion',
'45-degree view, three-quarter view, head turn, looking back, twisted torso, perspective distortion, wide-angle distortion',
'cropped head, cropped hair bun, cropped hands, cropped fingers, cropped feet, cropped shoes, cropped hem, extra limbs, extra fingers, fused hands, malformed anatomy',
'weapon, fan, book, scroll, phone, handheld prop, scene prop, dramatic environment, fantasy poster, glowing magic, aura, smoke, cinematic action pose',
'young idol face, beauty filter, plastic skin, anime, illustration, painterly, low resolution, blur, overexposure, crushed white fabric detail',
'modern sneakers, modern casual shoes, sports shoes, white rubber midsole, contrast sole edge, thick rubber sole, zipper boots, athletic curved toe',
extraRules || ''
].filter(Boolean).join(', ');
}
@@ -0,0 +1,147 @@
import { describe, expect, it } from 'vitest';
import {
buildCharacterTurnaroundNegativePrompt,
buildCharacterTurnaroundPublicPrompt,
CHARACTER_TURNAROUND_CONFLICTING_RESTRICTIONS,
CHARACTER_TURNAROUND_HARD_FAILURES,
CHARACTER_TURNAROUND_SCORE_DIMENSIONS,
CHARACTER_TURNAROUND_TEMPLATE_VERSION,
isCurrentCharacterTurnaroundPrompt
} from './character-turnaround-template';
const profile = {
name: '测试角色',
roleType: '谋士',
genderLabel: '男',
ageGroup: '48岁',
identityDesc: '古代军师',
appearanceDesc: '沉静克制',
faceDesc: '清癯长脸,眉眼锐利',
hairDesc: '束发,鬓角少量灰白',
eyeDesc: '深色眼睛',
bodyDesc: '修长挺拔',
costumeRules: '多层交领长袍,深色布靴'
};
describe('character turnaround public template', () => {
it('builds the same S+ industrial layout for reference-locked generation', () => {
const prompt = buildCharacterTurnaroundPublicPrompt({
profile,
referenceImageCount: 1,
liveAction: true
});
expect(prompt).toContain(CHARACTER_TURNAROUND_TEMPLATE_VERSION);
expect(prompt).toContain('当前模型与 API 参数允许的最高原生质量');
expect(prompt).toContain('REFERENCE_LOCK_MODE');
expect(prompt).toContain('16:9 横版角色');
expect(prompt).not.toContain('9:16');
expect(prompt).toContain('左侧约38%');
expect(prompt).toContain('严格正面全身、严格90度侧面全身、严格背面全身');
expect(prompt).toContain('中年保留适量额纹、眼周纹、法令纹');
expect(prompt).toContain('服装拓扑');
expect(prompt).toContain('默认双手自然下垂并保持空手');
expect(prompt).toContain('禁止现代运动鞋');
expect(prompt).toContain('浅色和白色服装必须压住高光');
expect(prompt).not.toContain('第一行:四个全身');
expect(prompt).not.toContain('八卦阵');
});
it('supports direct original generation without pretending a main anchor exists', () => {
const prompt = buildCharacterTurnaroundPublicPrompt({
profile,
referenceImageCount: 0,
liveAction: true
});
expect(prompt).toContain('DIRECT_DESIGN_MODE');
expect(prompt).toContain('本次不使用任何参考图片');
expect(prompt).not.toContain('已上传参考图是角色身份');
});
it('uses a direct description as the only character source and removes old profile fields', () => {
const prompt = buildCharacterTurnaroundPublicPrompt({
profile: {
...profile,
descriptionOverride: '62岁女性边关统帅,银灰短发,左眉旧伤,深红鳞甲与黑色战靴。'
},
referenceImageCount: 0,
liveAction: true
});
expect(prompt).toContain('DESCRIPTION_OVERRIDE_MODE');
expect(prompt).toContain('本次唯一角色描述:62岁女性边关统帅');
expect(prompt).toContain('禁止读取、补写、推断或混合角色库历史字段');
expect(prompt).not.toContain('角色类型:谋士');
expect(prompt).not.toContain('真实年龄或年龄段:48岁');
expect(prompt).not.toContain('清癯长脸');
expect(prompt).not.toContain('多层交领长袍');
});
it('exposes an optional-reference public copy mode for every character type', () => {
const prompt = buildCharacterTurnaroundPublicPrompt({
profile: { ...profile, name: '通用角色', roleType: '女侠', genderLabel: '女', ageGroup: '青年' },
referenceImageCount: null,
liveAction: true
});
expect(prompt).toContain('REFERENCE_OPTION_MODE');
expect(prompt).toContain('带主锚点锁定');
expect(prompt).toContain('无主锚点直接原创');
expect(prompt).not.toContain('诸葛亮');
});
it('defines hard blockers, a 100-point rubric and strict negative constraints', () => {
const negative = buildCharacterTurnaroundNegativePrompt('不要改变角色胎记');
expect(CHARACTER_TURNAROUND_HARD_FAILURES).toHaveLength(9);
expect(CHARACTER_TURNAROUND_SCORE_DIMENSIONS).toHaveLength(7);
expect(negative).toContain('45度或三分之二侧面冒充严格90度侧面');
expect(negative).toContain('服装拓扑错误:');
expect(negative).toContain('衣领');
expect(negative).toContain('手持剧情道具');
expect(negative).toContain('现代运动鞋');
expect(negative).toContain('不要改变角色胎记');
expect(negative).toContain('身份错误:');
expect(negative).toContain('摄影与材质错误:');
expect(CHARACTER_TURNAROUND_CONFLICTING_RESTRICTIONS.every((term) => !negative.includes(term))).toBe(true);
});
it('uses a dedicated structural branch for multi-head non-human characters', () => {
const prompt = buildCharacterTurnaroundPublicPrompt({
profile: {
name: '九幽鬼将',
roleType: '召唤实体',
genderLabel: '非人形男性战将意象',
ageGroup: '古老亡灵',
identityDesc: '三头六臂的中国古战场亡灵战将',
appearanceDesc: '百丈体量,腐朽古代重甲',
faceDesc: '中首为主身份头部,左右副首关系固定',
bodyDesc: '三头六臂,六臂关节清晰',
costumeRules: '腐朽中国古代重甲'
},
referenceImageCount: 0,
liveAction: true
});
expect(prompt).toContain('主身份头部超清特写');
expect(prompt).toContain('头部数量、肢体数量、关节结构');
expect(prompt).toContain('原创、稳定、可复用的非人角色资产');
expect(prompt).toContain('16:9 横版角色');
expect(prompt).toContain('photorealistic CGI creature or supernatural character asset');
expect(prompt).not.toContain('同一位原创虚构数字演员的同一套角色定妆');
expect(isCurrentCharacterTurnaroundPrompt(prompt)).toBe(true);
});
it('rejects old versions and face-suppressing conflicts as current templates', () => {
const current = buildCharacterTurnaroundPublicPrompt({
profile,
referenceImageCount: 0,
liveAction: true
});
expect(isCurrentCharacterTurnaroundPrompt(current)).toBe(true);
expect(isCurrentCharacterTurnaroundPrompt(current.replace(CHARACTER_TURNAROUND_TEMPLATE_VERSION, 'character_turnaround_public_v3_s_plus'))).toBe(false);
expect(isCurrentCharacterTurnaroundPrompt(`${current}\n不要真人照片级清晰正脸`)).toBe(false);
});
});
@@ -0,0 +1,185 @@
export const CHARACTER_TURNAROUND_TEMPLATE_VERSION =
'character_turnaround_single_sheet_v5_s_plus_lossless';
export const CHARACTER_TURNAROUND_S_PLUS_THRESHOLD = 96;
export const CHARACTER_TURNAROUND_CONFLICTING_RESTRICTIONS = [
'不要真人照片级清晰正脸',
'不要证件照式正面大头照',
'不要真实演员肖像照',
'不要可识别真人脸特写',
'不要高精真人脸摄影棚写真'
] as const;
export interface CharacterTurnaroundProfile {
name: string;
descriptionOverride?: string | null;
roleType?: string | null;
genderLabel?: string | null;
ageGroup?: string | null;
identityDesc?: string | null;
appearanceDesc?: string | null;
faceDesc?: string | null;
hairDesc?: string | null;
eyeDesc?: string | null;
bodyDesc?: string | null;
costumeRules?: string | null;
}
export interface CharacterTurnaroundPromptInput {
profile: CharacterTurnaroundProfile;
referenceImageCount: number | null;
liveAction?: boolean;
}
export const CHARACTER_TURNAROUND_HARD_FAILURES = [
'左侧身份特写与右侧任一视图不是同一角色',
'缺少、重复或错置正面、严格90度侧面、严格180度背面中的任一全身视图',
'使用45度、三分之二侧身或回头姿势冒充严格侧面或背面',
'任一全身视图的头顶、手、脚、鞋履或主要服装轮廓被裁切',
'跨视图的年龄、体型、发型、胡须、服装拓扑、配饰或鞋履明显漂移',
'皮肤、眼睛、毛发或材质呈现明显塑料CG、涂抹、过曝或伪细节',
'古代或特定时代角色出现现代运动鞋、白色橡胶中底或时代错误鞋型',
'存在严重人体结构错误、多余肢体、重影、文字、Logo或水印'
] as const;
export const CHARACTER_TURNAROUND_SCORE_DIMENSIONS = [
'同一角色身份、脸部骨相、年龄与状态一致性:25分',
'正面、严格90度侧面、严格180度背面几何:20分',
'服装裁剪拓扑、发型、配饰与鞋履对应:15分',
'皮肤或表面、眼睛、毛发与真实年龄微细节:15分',
'织物、刺绣、皮革、金属与旧化材质可信度:10分',
'人体结构、全身完整、统一比例与基线:10分',
'无缝背景、中性棚拍光线与视频母版可用性:5分'
] as const;
function clean(value: string | null | undefined) {
return value?.replace(/\s+/g, ' ').trim() || '';
}
function profileLine(label: string, value: string | null | undefined) {
const normalized = clean(value);
return normalized ? `${label}${normalized}` : null;
}
function isNonHumanProfile(profile: CharacterTurnaroundProfile) {
const source = [
profile.roleType,
profile.genderLabel,
profile.ageGroup,
profile.identityDesc,
profile.appearanceDesc,
profile.faceDesc,
profile.bodyDesc
].map(clean).join(' ');
return /(?:非人|亡灵|鬼将|神兽|妖兽|魔物|怪物|多头|多臂|三头|六臂|机械体|机器人|异形)/.test(source);
}
export function characterTurnaroundPromptConflicts(prompt: string) {
return CHARACTER_TURNAROUND_CONFLICTING_RESTRICTIONS.filter((term) => prompt.includes(term));
}
export function isCurrentCharacterTurnaroundPrompt(prompt: string) {
return prompt.includes(CHARACTER_TURNAROUND_TEMPLATE_VERSION)
&& characterTurnaroundPromptConflicts(prompt).length === 0;
}
export function buildCharacterTurnaroundPublicPrompt(input: CharacterTurnaroundPromptInput) {
const profile = input.profile;
const nonHuman = isNonHumanProfile(profile);
const descriptionOverride = clean(profile.descriptionOverride);
const hasReference = input.referenceImageCount === null
? null
: input.referenceImageCount > 0;
const profileLines = descriptionOverride
? [
profileLine('角色名', profile.name),
profileLine('本次唯一角色描述', descriptionOverride)
].filter((line): line is string => Boolean(line))
: [
profileLine('角色名', profile.name),
profileLine('角色身份', profile.identityDesc || profile.roleType),
profileLine('性别', profile.genderLabel),
profileLine('真实年龄或年龄段', profile.ageGroup),
profileLine('整体气质', profile.appearanceDesc),
profileLine('脸型、五官与稳定识别点', profile.faceDesc),
profileLine('发型、发际线与胡须', profile.hairDesc),
profileLine('眼睛与眼神', profile.eyeDesc),
profileLine('身高、体型与姿态', profile.bodyDesc),
profileLine('完整基础服装、配饰与鞋履', profile.costumeRules)
].filter((line): line is string => Boolean(line));
const sourceRules = descriptionOverride
? [
'DESCRIPTION_OVERRIDE_MODE:仅使用“本次唯一角色描述”建立角色;历史角色描述、旧锚点、旧 Prompt 和角色专属经验全部不参与。'
]
: hasReference === true
? [
`REFERENCE_LOCK_MODE:已附带 ${input.referenceImageCount} 张角色参考图。参考图是身份、脸部骨相、年龄、发型、体型和基础服装的最高视觉真值;文字只补足参考图不可见的侧面和背面结构。`
]
: hasReference === false
? [
'DIRECT_DESIGN_MODE:本次不使用参考图,仅根据下列角色资料在同一张图内建立一个唯一、原创、可重复调用的角色身份。'
]
: [
'REFERENCE_OPTION_MODE:有参考图时以参考图锁定身份;没有参考图时仅依据角色资料建立唯一原创身份。'
];
return [
`S+ 影视角色单张整板母版 / ${CHARACTER_TURNAROUND_TEMPLATE_VERSION}`,
'直接生成一张完整的 16:9 横版角色连续性定妆板。这是后续关键帧、图生视频和视频角色元素的身份母版,不是海报、插画或氛围图。角色身份、严格视图和可用微细节优先。',
'',
'【身份来源】',
...sourceRules,
'',
'【角色唯一设定】',
...profileLines,
'',
'【单张整板版式】',
'一张连续的 16:9 横图,无后期拼接感。纯白或极浅中性灰无缝摄影棚背景,不得出现边框、分割线、标题、标签、尺寸线或文字。',
nonHuman
? '左侧约38%为同一角色的主身份头部特写;右侧约62%依次排列同一角色的严格正面全身、严格90度右向侧面全身、严格180度背面全身。'
: '左侧约38%为同一角色的超清正面头肩身份特写;右侧约62%依次排列同一角色的严格正面全身、严格90度右向侧面全身、严格180度背面全身。',
'右侧只允许三个全身视图;三者等高、同基线、同尺度、同一中性站姿,头顶、手、脚、鞋履和衣摆完整入画。严格侧面不能是45度或三分之二侧身;背面不回头、不露侧脸。',
'',
'【同一角色硬锁定】',
nonHuman
? '左侧特写与右侧三视图必须是同一个角色资产:头部数量、肢体数量、头部结构、眼位、身体比例、表面材质、甲胄拓扑和损伤识别点完全一致。'
: '左侧特写与右侧三视图必须像同一位原创虚构演员在同一次影视服化定妆棚拍中拍摄:颅骨、脸型、五官比例、耳朵、肤色、年龄、发际线、发型、胡须或妆容、疤痕和识别点完全一致。',
'身高、头身比、肩宽、腰线、四肢长度、手脚大小和姿态完全统一。基础服装的衣领、肩线、袖口、内外叠层、腰带、绑带、刺绣、缝线、扣件、配饰、衣摆和鞋履必须在正侧背逐一对应,不换装、不增删、不改款。',
'默认中性站姿、双手空置,只保留不可从身体或基础服装分离的常驻穿戴物;不加入剧情道具、武器、扇子、书卷、手机或法器。鞋履必须符合角色的时代、身份和服装设定;禁止现代运动鞋、休闲鞋、白色橡胶中底、厚底潮鞋和时代错误鞋型。',
'',
'【真实微细节与材质】',
nonHuman
? '使用高预算影视怪物实物特效、服化制作与数字材质扫描的真实质感,结构、腐蚀、破损、内光和材质边界清晰,不糊成烟雾或随机怪脸。'
: '整体是高预算真人影视服化造型部门的角色连续性定妆摄影(live-action costume and character continuity photography),不是游戏CG渲染、蜡像、塑料数字人、概念插画或二次元。',
nonHuman
? '羽毛、毛发、皮肤、鳞片、布料、皮革、木材、金属、腐蚀和破损分别呈现各自真实的微结构与光照反应,不用噪点或过度锐化伪造细节。'
: '脸部保留与年龄匹配的真实皮肤微结构:毛孔尺度自然,细纹、眼周、法令纹、唇纹、肤色起伏与自然不完美可读,眼白、虹膜、睫毛、眉毛、发根、碎发和胡须根部清楚;不磨皮、不美颜、不网红化、不用假毛孔和噪点伪造清晰度。',
'服装材质必须是可放大检查的实物微细节:织物经纬与纱线密度、缝线、锁边、刺绣针脚、金属边缘、皮革纹理、木材纹理、旧化和自然折痕分层清楚,不能用平滑色块或简单线条代替。',
'浅色或白色服装必须保留明度层次与局部微对比:外袍、内衬、缘边、织纹、针脚、刺绣和褶皱不得过曝成纯白平面。不做柔焦、降噪涂抹、插值光滑、过度锐化、高反差边缘或廉价CG材质。',
'',
'【摄影标准】',
'中性全画幅摄影棚质感,85至105mm长焦等效、低透视畸变、相机与人物中心高度对齐;柔和均匀的大面积柔光,中性白平衡,高光不剪切,轮廓边缘与白背景清晰分离,脚下只保留很淡的真实接触阴影。无景深虚化、无戏剧性彩光、无仙气光晕、无烟雾粒子。',
'',
'【最终自检】',
'画面只能包含:左侧一张身份特写 + 右侧严格正面、严格90度侧面、严格180度背面三个完整全身视图。',
nonHuman
? '同一身份、同一头部与肢体数量、同一体型、同一材质与同一甲胄拓扑;严格正侧背;零文字、零Logo、零水印。'
: '同脸、同年龄、同发型、同体型、同服装拓扑和同鞋履;严格正侧背;不换脸、不年轻化、不现代鞋履、不塑料CG化;零文字、零Logo、零水印。',
'使用当前模型与 API 参数允许的最高原生质量。结构稳定和真实有效细节优先于装饰、氛围和锐化。'
].filter(Boolean).join('\n');
}
export function buildCharacterTurnaroundNegativePrompt(extraRules?: string | null) {
const core = [
'不同角色、换脸、年龄漂移、发型漂移、服装换款、体型或比例漂移',
'缺少或重复视图、45度或三分之二侧身、背面回头、头脚或衣摆裁切、人物不等高',
'塑料皮肤、蜡像、游戏CG渲染、插画、动漫、柔焦、降噪涂抹、过曝、噪点伪细节、过度锐化',
'平滑色块布料、画线式刺绣、缝线与织纹消失、现代运动鞋、白色橡胶中底、时代错误鞋履',
'剧情道具、武器、手持物、复杂背景、海报装饰、法阵、光环、烟雾、粒子、边框、分割线、标题、标签、文字、Logo、水印'
];
const extra = clean(extraRules);
return [...core, extra].filter(Boolean).join('');
}
+10
View File
@@ -0,0 +1,10 @@
export const PRODUCTION_ASPECT_RATIO = '16:9' as const;
export const PRODUCTION_VIDEO_WIDTH = 1920;
export const PRODUCTION_VIDEO_HEIGHT = 1080;
export const PRODUCTION_KEYFRAME_WIDTH = 2560;
export const PRODUCTION_KEYFRAME_HEIGHT = 1440;
export const PRODUCTION_VIDEO_RESOLUTION = '1080p' as const;
export const PRODUCTION_FORMAT_LABEL = '16:9 horizontal cinematic video';
export const PRODUCTION_FORMAT_LABEL_ZH = '16:9横屏,1920×1080,电影级横向构图';
+10
View File
@@ -2,6 +2,16 @@ import type { EpisodeStatus } from './episode.types';
export class GenerateEpisodePlanDto { export class GenerateEpisodePlanDto {
target_episode_count?: number; target_episode_count?: number;
episode_count_mode?: 'fixed' | 'ai_recommend';
force?: boolean;
provider_code?: string;
min_quality_score?: number;
prompt_overrides?: {
overview?: string;
batch?: string;
reconcile?: string;
};
request_params_override?: Record<string, unknown>;
} }
export class UpdateEpisodeDto { export class UpdateEpisodeDto {
@@ -19,6 +19,15 @@ export class EpisodesController {
return this.episodesService.generatePlan(user, projectId, dto); return this.episodesService.generatePlan(user, projectId, dto);
} }
@Post('projects/:projectId/episodes/plan-request-preview')
previewPlanRequest(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string,
@Body() dto: GenerateEpisodePlanDto
) {
return this.episodesService.previewPlanRequest(user, projectId, dto);
}
@Get('projects/:projectId/episodes') @Get('projects/:projectId/episodes')
listEpisodes( listEpisodes(
@CurrentUser() user: AuthRequestUser, @CurrentUser() user: AuthRequestUser,
+2 -1
View File
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { PrismaModule } from '../prisma/prisma.module'; import { PrismaModule } from '../prisma/prisma.module';
import { ProvidersModule } from '../providers/providers.module';
import { EpisodesController } from './episodes.controller'; import { EpisodesController } from './episodes.controller';
import { EpisodesService } from './episodes.service'; import { EpisodesService } from './episodes.service';
@Module({ @Module({
imports: [AuthModule, PrismaModule], imports: [AuthModule, PrismaModule, ProvidersModule],
controllers: [EpisodesController], controllers: [EpisodesController],
providers: [EpisodesService], providers: [EpisodesService],
exports: [EpisodesService] exports: [EpisodesService]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { GenerationPlanService } from './generation-plan.service';
@Module({
imports: [PrismaModule],
providers: [GenerationPlanService],
exports: [GenerationPlanService]
})
export class GenerationPlanModule {}
@@ -0,0 +1,143 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { PrismaService } from '../prisma/prisma.service';
import { GenerationPlanService } from './generation-plan.service';
describe('GenerationPlanService', () => {
let service: GenerationPlanService;
let plans: any[];
let activePlanId: bigint | null;
let tx: any;
let prisma: any;
beforeEach(() => {
plans = [];
activePlanId = null;
tx = {
storyboardShot: {
findUnique: vi.fn().mockImplementation(async () => ({
id: 30n,
project_id: 10n,
episode_id: 20n,
active_generation_plan_id: activePlanId
})),
update: vi.fn().mockImplementation(async ({ data }: any) => {
activePlanId = data.active_generation_plan_id;
return { id: 30n, active_generation_plan_id: activePlanId };
})
},
shotGenerationPlan: {
findUnique: vi.fn().mockImplementation(async ({ where }: any) =>
plans.find((plan) => plan.id === where.id) ?? null
),
findFirst: vi.fn().mockImplementation(async ({ where }: any) =>
plans.find((plan) => plan.shot_id === where.shot_id && plan.plan_hash === where.plan_hash) ?? null
),
aggregate: vi.fn().mockImplementation(async () => ({
_max: { revision: plans.length ? Math.max(...plans.map((plan) => plan.revision)) : null }
})),
create: vi.fn().mockImplementation(async ({ data }: any) => {
const plan = {
id: BigInt(plans.length + 1),
...data,
frozen_at: data.frozen_at ?? new Date(),
created_at: new Date()
};
plans.push(plan);
return plan;
})
}
};
prisma = {
$transaction: vi.fn().mockImplementation(async (callback: any) => callback(tx)),
shotGenerationPlan: {
findMany: vi.fn().mockImplementation(async ({ where }: any) => {
const ids = new Set<bigint>(where?.id?.in ?? []);
return plans.filter((plan) =>
plan.project_id === where.project_id &&
plan.episode_id === where.episode_id &&
(!ids.size || ids.has(plan.id))
);
})
}
};
service = new GenerationPlanService(prisma as unknown as PrismaService);
});
const input = (resolution: string) => ({
projectId: 10n,
episodeId: 20n,
shotId: 30n,
sourceEngineVersion: 'splus_v1',
providerCode: 'kling-v3-omni-native-audio-1080p-video',
providerId: 100n,
capabilityRegistryVersionId: 501n,
parameterSchemaVersionId: 502n,
pricingVersionId: 503n,
modelName: 'kling-v3-omni',
endpoint: '/v1/videos/omni-video',
capabilityVersion: 'kling_video_capabilities_2026-07-15',
routeTier: 'premium',
effectiveMode: 'pro',
resolutionRecommendation: resolution,
effectiveGenerationResolution: resolution,
aspectRatio: '16:9',
duration: 6,
soundEnabled: true,
multiShot: false,
native4kCandidate: resolution === '4k',
projectNative4kEnabled: false,
requiredAssets: { character_elements: ['char_1'] },
elementPlan: { selected_assets: [101] },
voicePlan: { speakers: [{ speaker_name: '陈渡', voice: 'voice_1' }] },
keyframePlan: { image_provider_code: 'openai-image' },
videoRequest: { resolution },
routerDecision: { provider_code: 'kling-v3-omni-native-audio-1080p-video' },
qualityPolicy: { target: 'S+', minimum_score: 90 },
retryPolicy: { max_retries: 2 },
fallbackChain: ['kling-v3-native-audio-video'],
costPolicy: { estimated_cost: 2.52 },
providerSnapshot: { price_version: '2026-07-15' },
promptSnapshot: { video_prompt: 'test' },
frozenByUserId: 1n
});
it('creates a new immutable revision when execution inputs change', async () => {
const first = await service.freezeShotPlan(input('1080p'));
const firstHash = first.plan_hash;
const firstResolution = first.effective_generation_resolution;
const second = await service.freezeShotPlan(input('4k'));
expect(first.revision).toBe(1);
expect(second.revision).toBe(2);
expect(second.id).not.toBe(first.id);
expect(plans).toHaveLength(2);
expect(first.plan_hash).toBe(firstHash);
expect(first.effective_generation_resolution).toBe(firstResolution);
expect(activePlanId).toBe(second.id);
});
it('reuses the active frozen revision when the canonical plan is unchanged', async () => {
const first = await service.freezeShotPlan(input('1080p'));
const second = await service.freezeShotPlan(input('1080p'));
expect(second.id).toBe(first.id);
expect(plans).toHaveLength(1);
expect(tx.shotGenerationPlan.create).toHaveBeenCalledTimes(1);
});
it('returns a field-level comparison for two revisions of the same shot', async () => {
const first = await service.freezeShotPlan(input('1080p'));
const second = await service.freezeShotPlan(input('4k'));
const report = await service.compareForEpisode(10n, 20n, first.id, second.id);
expect(report.changed_count).toBeGreaterThan(0);
expect(report.changes).toEqual(expect.arrayContaining([
expect.objectContaining({
path: 'effective_generation_resolution',
category: 'parameters',
before: '1080p',
after: '4k'
})
]));
});
});
@@ -0,0 +1,352 @@
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma, type ShotGenerationPlan } from '@prisma/client';
import { createHash } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import {
SHOT_GENERATION_PLAN_VERSION,
type FreezeShotGenerationPlanInput,
toSafeShotGenerationPlan
} from './generation-plan.types';
@Injectable()
export class GenerationPlanService {
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
async freezeShotPlan(input: FreezeShotGenerationPlanInput): Promise<ShotGenerationPlan> {
const payload = this.canonicalPayload(input);
const planHash = createHash('sha256').update(this.stableStringify(payload)).digest('hex');
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
return await this.prisma.$transaction(async (tx) => {
const shot = await tx.storyboardShot.findUnique({
where: { id: input.shotId },
select: {
id: true,
project_id: true,
episode_id: true,
active_generation_plan_id: true
}
});
if (!shot || shot.project_id !== input.projectId || shot.episode_id !== input.episodeId) {
throw new NotFoundException('SHOT_GENERATION_PLAN_SOURCE_SHOT_NOT_FOUND');
}
if (shot.active_generation_plan_id) {
const active = await tx.shotGenerationPlan.findUnique({
where: { id: shot.active_generation_plan_id }
});
if (active?.plan_hash === planHash) {
return active;
}
}
const identical = await tx.shotGenerationPlan.findFirst({
where: { shot_id: input.shotId, plan_hash: planHash }
});
if (identical) {
await tx.storyboardShot.update({
where: { id: input.shotId },
data: { active_generation_plan_id: identical.id }
});
return identical;
}
const latest = await tx.shotGenerationPlan.aggregate({
where: { shot_id: input.shotId },
_max: { revision: true }
});
const plan = await tx.shotGenerationPlan.create({
data: {
project_id: input.projectId,
episode_id: input.episodeId,
shot_id: input.shotId,
revision: (latest._max.revision ?? 0) + 1,
plan_version: SHOT_GENERATION_PLAN_VERSION,
plan_hash: planHash,
status: 'frozen',
source_engine_version: input.sourceEngineVersion,
provider_code: input.providerCode,
provider_id: input.providerId ?? null,
capability_registry_version_id: input.capabilityRegistryVersionId ?? null,
parameter_schema_version_id: input.parameterSchemaVersionId ?? null,
pricing_version_id: input.pricingVersionId ?? null,
model_name: input.modelName ?? null,
endpoint: input.endpoint ?? null,
capability_version: input.capabilityVersion ?? null,
route_tier: input.routeTier ?? null,
effective_mode: input.effectiveMode ?? null,
resolution_recommendation: input.resolutionRecommendation ?? null,
effective_generation_resolution: input.effectiveGenerationResolution ?? null,
aspect_ratio: input.aspectRatio ?? null,
duration: input.duration ?? null,
sound_enabled: input.soundEnabled,
multi_shot: input.multiShot,
native_4k_candidate: input.native4kCandidate,
project_native_4k_enabled: input.projectNative4kEnabled,
required_assets_json: input.requiredAssets,
element_plan_json: input.elementPlan,
voice_plan_json: input.voicePlan,
keyframe_plan_json: input.keyframePlan,
video_request_json: input.videoRequest,
router_decision_json: input.routerDecision,
quality_policy_json: input.qualityPolicy,
retry_policy_json: input.retryPolicy,
fallback_chain_json: input.fallbackChain,
cost_policy_json: input.costPolicy,
provider_snapshot_json: input.providerSnapshot,
prompt_snapshot_json: input.promptSnapshot,
frozen_by_user_id: input.frozenByUserId ?? null,
frozen_at: new Date()
}
});
await tx.storyboardShot.update({
where: { id: input.shotId },
data: { active_generation_plan_id: plan.id }
});
return plan;
});
} catch (error) {
const revisionConflict =
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002';
if (!revisionConflict || attempt === 2) throw error;
}
}
throw new BadRequestException('SHOT_GENERATION_PLAN_FREEZE_CONFLICT');
}
async activeForShot(shotId: bigint) {
const shot = await this.prisma.storyboardShot.findUnique({
where: { id: shotId },
select: { active_generation_plan_id: true }
});
if (!shot?.active_generation_plan_id) return null;
return this.prisma.shotGenerationPlan.findUnique({
where: { id: shot.active_generation_plan_id }
});
}
async requireActiveForShot(shotId: bigint) {
const plan = await this.activeForShot(shotId);
if (!plan || plan.status !== 'frozen') {
throw new BadRequestException('SPLUS_GENERATION_PLAN_REQUIRED');
}
return plan;
}
async listForEpisode(projectId: bigint, episodeId: bigint) {
const [plans, shots] = await Promise.all([
this.prisma.shotGenerationPlan.findMany({
where: { project_id: projectId, episode_id: episodeId },
orderBy: [{ shot_id: 'asc' }, { revision: 'desc' }]
}),
this.prisma.storyboardShot.findMany({
where: { project_id: projectId, episode_id: episodeId },
select: { id: true, shot_no: true, scene_name: true, active_generation_plan_id: true }
})
]);
const shotById = new Map(shots.map((shot) => [shot.id.toString(), shot]));
return plans.map((plan) => {
const shot = shotById.get(plan.shot_id.toString());
return {
...toSafeShotGenerationPlan(plan),
shot_no: shot?.shot_no ?? null,
scene_name: shot?.scene_name ?? null,
is_active: shot?.active_generation_plan_id === plan.id
};
});
}
async compareForEpisode(projectId: bigint, episodeId: bigint, basePlanId: bigint, targetPlanId: bigint) {
if (basePlanId === targetPlanId) {
throw new BadRequestException('GENERATION_PLAN_COMPARE_REQUIRES_TWO_REVISIONS');
}
const plans = await this.prisma.shotGenerationPlan.findMany({
where: {
project_id: projectId,
episode_id: episodeId,
id: { in: [basePlanId, targetPlanId] }
}
});
const base = plans.find((plan) => plan.id === basePlanId);
const target = plans.find((plan) => plan.id === targetPlanId);
if (!base || !target) throw new NotFoundException('GENERATION_PLAN_COMPARE_REVISION_NOT_FOUND');
if (base.shot_id !== target.shot_id) {
throw new BadRequestException('GENERATION_PLAN_COMPARE_REQUIRES_SAME_SHOT');
}
const changes: Array<{
path: string;
category: string;
before: unknown;
after: unknown;
}> = [];
this.collectDiff(this.comparableSnapshot(base), this.comparableSnapshot(target), '', changes);
return {
shot_id: base.shot_id.toString(),
base: toSafeShotGenerationPlan(base),
target: toSafeShotGenerationPlan(target),
changed_count: changes.length,
changes
};
}
snapshot(plan: ShotGenerationPlan): Prisma.InputJsonObject {
return {
id: plan.id.toString(),
revision: plan.revision,
plan_version: plan.plan_version,
plan_hash: plan.plan_hash,
status: plan.status,
source_engine_version: plan.source_engine_version,
provider_code: plan.provider_code,
provider_id: plan.provider_id?.toString() ?? '',
capability_registry_version_id: plan.capability_registry_version_id?.toString() ?? '',
parameter_schema_version_id: plan.parameter_schema_version_id?.toString() ?? '',
pricing_version_id: plan.pricing_version_id?.toString() ?? '',
model_name: plan.model_name ?? '',
endpoint: plan.endpoint ?? '',
capability_version: plan.capability_version ?? '',
route_tier: plan.route_tier ?? '',
effective_mode: plan.effective_mode ?? '',
resolution_recommendation: plan.resolution_recommendation ?? '',
effective_generation_resolution: plan.effective_generation_resolution ?? '',
aspect_ratio: plan.aspect_ratio ?? '',
duration: plan.duration?.toString() ?? '',
sound_enabled: plan.sound_enabled,
multi_shot: plan.multi_shot,
native_4k_candidate: plan.native_4k_candidate,
project_native_4k_enabled: plan.project_native_4k_enabled,
required_assets: plan.required_assets_json ?? {},
element_plan: plan.element_plan_json ?? {},
voice_plan: plan.voice_plan_json ?? {},
keyframe_plan: plan.keyframe_plan_json ?? {},
video_request: plan.video_request_json ?? {},
router_decision: plan.router_decision_json,
quality_policy: plan.quality_policy_json,
retry_policy: plan.retry_policy_json,
fallback_chain: plan.fallback_chain_json,
cost_policy: plan.cost_policy_json ?? {},
provider_snapshot: plan.provider_snapshot_json ?? {},
prompt_snapshot: plan.prompt_snapshot_json ?? {},
frozen_at: plan.frozen_at.toISOString()
};
}
private canonicalPayload(input: FreezeShotGenerationPlanInput) {
return {
plan_version: SHOT_GENERATION_PLAN_VERSION,
project_id: input.projectId.toString(),
episode_id: input.episodeId.toString(),
shot_id: input.shotId.toString(),
source_engine_version: input.sourceEngineVersion,
provider_code: input.providerCode,
provider_id: input.providerId?.toString() ?? null,
capability_registry_version_id: input.capabilityRegistryVersionId?.toString() ?? null,
parameter_schema_version_id: input.parameterSchemaVersionId?.toString() ?? null,
pricing_version_id: input.pricingVersionId?.toString() ?? null,
model_name: input.modelName ?? null,
endpoint: input.endpoint ?? null,
capability_version: input.capabilityVersion ?? null,
route_tier: input.routeTier ?? null,
effective_mode: input.effectiveMode ?? null,
resolution_recommendation: input.resolutionRecommendation ?? null,
effective_generation_resolution: input.effectiveGenerationResolution ?? null,
aspect_ratio: input.aspectRatio ?? null,
duration: input.duration ?? null,
sound_enabled: input.soundEnabled,
multi_shot: input.multiShot,
native_4k_candidate: input.native4kCandidate,
project_native_4k_enabled: input.projectNative4kEnabled,
required_assets: input.requiredAssets ?? null,
element_plan: input.elementPlan ?? null,
voice_plan: input.voicePlan ?? null,
keyframe_plan: input.keyframePlan ?? null,
video_request: input.videoRequest ?? null,
router_decision: input.routerDecision,
quality_policy: input.qualityPolicy,
retry_policy: input.retryPolicy,
fallback_chain: input.fallbackChain,
cost_policy: input.costPolicy ?? null,
provider_snapshot: input.providerSnapshot ?? null,
prompt_snapshot: input.promptSnapshot ?? null
};
}
private collectDiff(
before: unknown,
after: unknown,
path: string,
changes: Array<{ path: string; category: string; before: unknown; after: unknown }>
) {
if (this.stableStringify(before) === this.stableStringify(after)) return;
if (this.isRecord(before) && this.isRecord(after)) {
const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])].sort();
for (const key of keys) {
this.collectDiff(before[key], after[key], path ? `${path}.${key}` : key, changes);
}
return;
}
changes.push({
path: path || 'plan',
category: this.diffCategory(path),
before: before ?? null,
after: after ?? null
});
}
private comparableSnapshot(plan: ShotGenerationPlan) {
const snapshot = this.snapshot(plan) as Record<string, unknown>;
const { id: _id, revision: _revision, plan_hash: _hash, frozen_at: _frozenAt, ...comparable } = snapshot;
return comparable;
}
private diffCategory(path: string) {
if (/pricing_version|cost_policy|estimated_cost|price/i.test(path)) return 'pricing';
if (/parameter_schema|video_request|mode|resolution|duration|aspect_ratio|sound|multi_shot/i.test(path)) return 'parameters';
if (/capability/i.test(path)) return 'capability';
if (/provider|model|endpoint|route|fallback/i.test(path)) return 'model_route';
if (/required_assets|element_plan|actor_lock/i.test(path)) return 'assets';
if (/voice|lip_sync/i.test(path)) return 'voice';
if (/keyframe/i.test(path)) return 'keyframe';
if (/prompt/i.test(path)) return 'prompt';
if (/quality/i.test(path)) return 'quality';
if (/retry/i.test(path)) return 'retry';
return 'other';
}
private isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
}
private stableStringify(value: unknown): 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 as Record<string, unknown>)
.filter(([, item]) => item !== undefined)
.sort(([left], [right]) => left.localeCompare(right));
return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${this.stableStringify(item)}`).join(',')}}`;
}
}
@@ -0,0 +1,57 @@
import type { Prisma, ShotGenerationPlan } from '@prisma/client';
export const SHOT_GENERATION_PLAN_VERSION = 'shot_generation_plan_v1';
export type FreezeShotGenerationPlanInput = {
projectId: bigint;
episodeId: bigint;
shotId: bigint;
sourceEngineVersion: string;
providerCode: string;
providerId?: bigint | null;
capabilityRegistryVersionId?: bigint | null;
parameterSchemaVersionId?: bigint | null;
pricingVersionId?: bigint | null;
modelName?: string | null;
endpoint?: string | null;
capabilityVersion?: string | null;
routeTier?: string | null;
effectiveMode?: string | null;
resolutionRecommendation?: string | null;
effectiveGenerationResolution?: string | null;
aspectRatio?: string | null;
duration?: number | null;
soundEnabled: boolean;
multiShot: boolean;
native4kCandidate: boolean;
projectNative4kEnabled: boolean;
requiredAssets?: Prisma.InputJsonValue;
elementPlan?: Prisma.InputJsonValue;
voicePlan?: Prisma.InputJsonValue;
keyframePlan?: Prisma.InputJsonValue;
videoRequest?: Prisma.InputJsonValue;
routerDecision: Prisma.InputJsonValue;
qualityPolicy: Prisma.InputJsonValue;
retryPolicy: Prisma.InputJsonValue;
fallbackChain: Prisma.InputJsonValue;
costPolicy?: Prisma.InputJsonValue;
providerSnapshot?: Prisma.InputJsonValue;
promptSnapshot?: Prisma.InputJsonValue;
frozenByUserId?: bigint | null;
};
export function toSafeShotGenerationPlan(plan: ShotGenerationPlan) {
return {
...plan,
id: plan.id.toString(),
project_id: plan.project_id.toString(),
episode_id: plan.episode_id.toString(),
shot_id: plan.shot_id.toString(),
provider_id: plan.provider_id?.toString() ?? null,
capability_registry_version_id: plan.capability_registry_version_id?.toString() ?? null,
parameter_schema_version_id: plan.parameter_schema_version_id?.toString() ?? null,
pricing_version_id: plan.pricing_version_id?.toString() ?? null,
frozen_by_user_id: plan.frozen_by_user_id?.toString() ?? null,
duration: plan.duration?.toString() ?? null
};
}
@@ -0,0 +1,230 @@
import { Prisma } from '@prisma/client';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { AuthRequestUser } from '../auth/auth.types';
import { CharacterTurnaroundMasterService } from './character-turnaround-master.service';
const user: AuthRequestUser = { id: '1', email: 'user@example.com', role: 'user' };
function panel(imageType: string, id: number, assetId: number) {
return {
id: String(id),
project_id: '22',
character_id: '92',
asset_id: String(assetId),
image_type: imageType,
prompt_text: 'prompt',
negative_prompt: 'negative',
is_anchor: false,
prompt_quality_score: 99,
quality_score: 97,
status: 'quality_passed',
created_at: '2026-07-16T00:00:00.000Z',
visual_review: {
id: String(id + 1000),
score: 97,
grade: 'S+',
threshold_score: 96,
passed: true,
status: 'passed',
strengths: [],
issues: [],
improvement_rules: [],
quality_gate: {},
provider_code: 'openai-responses-text',
model_name: 'gpt-5',
error_message: null,
created_at: '2026-07-16T00:00:00.000Z'
}
};
}
describe('CharacterTurnaroundMasterService', () => {
let prisma: any;
let images: any;
let service: CharacterTurnaroundMasterService;
beforeEach(() => {
prisma = {
character: {
findUnique: vi.fn().mockResolvedValue({
id: 92n,
project_id: 22n,
name: '诸葛亮',
status: 'locked',
anchor_asset_id: null
})
},
project: {
findUnique: vi.fn().mockResolvedValue({ id: 22n, user_id: 1n })
},
characterImage: {
findFirst: vi.fn().mockResolvedValue({
id: 147n,
asset_id: 980n,
image_type: 'turnaround_reference',
quality_score: new Prisma.Decimal(87)
}),
update: vi.fn()
},
characterImageQualityReview: {
findMany: vi.fn().mockResolvedValue([])
},
providerLog: {
findMany: vi.fn().mockResolvedValue([])
},
renderTask: {
findMany: vi.fn().mockResolvedValue([])
}
};
images = {
generateCharacterImages: vi.fn()
};
const storage = {};
service = new CharacterTurnaroundMasterService(prisma, storage as any, images);
});
it('generates identity, front, strict side and back with cumulative identity evidence', async () => {
const outputs = [
panel('turnaround_identity_panel', 201, 1001),
panel('turnaround_front_panel', 202, 1002),
panel('turnaround_side_panel', 203, 1003),
panel('turnaround_back_panel', 204, 1004)
];
images.generateCharacterImages
.mockResolvedValueOnce({ images: [outputs[0]] })
.mockResolvedValueOnce({ images: [outputs[1]] })
.mockResolvedValueOnce({ images: [outputs[2]] })
.mockResolvedValueOnce({ images: [outputs[3]] });
vi.spyOn(service as any, 'composeMaster').mockResolvedValue(
panel('turnaround_reference', 205, 1005)
);
const result = await service.generate(user, '92', {
provider_code: 'openai-image',
output_size: '3840x2160',
enable_visual_quality_review: true
});
expect(images.generateCharacterImages).toHaveBeenCalledTimes(4);
const calls = images.generateCharacterImages.mock.calls;
expect(calls[0][2]).toEqual(expect.objectContaining({
image_types: ['turnaround_identity_panel'],
reference_asset_ids: ['980'],
reference_crop_mode: 'turnaround_face_panel'
}));
expect(calls[1][2]).toEqual(expect.objectContaining({
image_types: ['turnaround_front_panel'],
reference_asset_ids: ['1001', '980']
}));
expect(calls[2][2]).toEqual(expect.objectContaining({
image_types: ['turnaround_side_panel'],
reference_asset_ids: ['1001', '1002', '980']
}));
expect(calls[3][2]).toEqual(expect.objectContaining({
image_types: ['turnaround_back_panel'],
reference_asset_ids: ['1001', '1002', '1003', '980']
}));
expect(calls.every((call: any[]) => call[3]?.approveSplitPanelSystemTemplate === true)).toBe(true);
expect(result.quality_gate.approved_for_s_plus_video).toBe(true);
expect(result.identity_reference).toEqual({
assetIds: ['980'],
cropMode: 'turnaround_face_panel',
source: 'best_existing_turnaround_candidate'
});
});
it('resumes from reusable panels without paying to regenerate completed work', async () => {
const identity = panel('turnaround_identity_panel', 201, 1001);
const front = panel('turnaround_front_panel', 202, 1002);
const side = panel('turnaround_side_panel', 203, 1003);
const back = panel('turnaround_back_panel', 204, 1004);
images.listCharacterImages = vi.fn().mockResolvedValue([front, identity]);
images.generateCharacterImages
.mockResolvedValueOnce({ images: [side] })
.mockResolvedValueOnce({ images: [back] });
vi.spyOn(service as any, 'composeMaster').mockResolvedValue(
panel('turnaround_reference', 205, 1005)
);
const result = await service.generate(user, '92', {
provider_code: 'openai-image',
output_size: '3840x2160',
enable_visual_quality_review: true,
force: false
});
expect(images.generateCharacterImages).toHaveBeenCalledTimes(2);
expect(images.generateCharacterImages.mock.calls[0][2]).toEqual(expect.objectContaining({
image_types: ['turnaround_side_panel'],
reference_asset_ids: ['1001', '1002', '980']
}));
expect(images.generateCharacterImages.mock.calls[1][2]).toEqual(expect.objectContaining({
image_types: ['turnaround_back_panel'],
reference_asset_ids: ['1001', '1002', '1003', '980']
}));
expect(result.panel_images.map((image: any) => image.asset_id)).toEqual([
'1001',
'1002',
'1003',
'1004'
]);
});
it('regenerates only explicitly selected failed panels', async () => {
const identity = panel('turnaround_identity_panel', 201, 1001);
const front = panel('turnaround_front_panel', 202, 1002);
const oldSide = panel('turnaround_side_panel', 203, 1003);
const back = panel('turnaround_back_panel', 204, 1004);
const newSide = panel('turnaround_side_panel', 206, 1006);
images.listCharacterImages = vi.fn().mockResolvedValue([back, oldSide, front, identity]);
images.generateCharacterImages.mockResolvedValueOnce({ images: [newSide] });
vi.spyOn(service as any, 'composeMaster').mockResolvedValue(
panel('turnaround_reference', 205, 1005)
);
const result = await service.generate(user, '92', {
provider_code: 'openai-image',
output_size: '3840x2160',
enable_visual_quality_review: true,
force: false,
regenerate_image_types: ['turnaround_side_panel']
});
expect(images.generateCharacterImages).toHaveBeenCalledTimes(1);
expect(images.generateCharacterImages).toHaveBeenCalledWith(
user,
'92',
expect.objectContaining({
image_types: ['turnaround_side_panel'],
force: true,
reference_asset_ids: ['1001', '1002', '980']
}),
expect.any(Object)
);
expect(result.panel_images.map((image: any) => image.asset_id)).toEqual([
'1001',
'1002',
'1006',
'1004'
]);
});
it('composes four independent image buffers into one deterministic master', async () => {
const colors = ['#dbeafe', '#dcfce7', '#fef3c7', '#fee2e2'];
const sources = colors.map((color) => ({
asset: { mime_type: 'image/svg+xml' },
buffer: Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360"><rect width="640" height="360" fill="${color}"/></svg>`
)
}));
const result = await (service as any).composePanelBuffers(
sources,
[200, 120, 120, 120],
315
);
expect(result.subarray(1, 4).toString('ascii')).toBe('PNG');
expect(result.length).toBeGreaterThan(1000);
});
});
@@ -0,0 +1,558 @@
import {
BadRequestException,
ForbiddenException,
Inject,
Injectable,
NotFoundException
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { execFile } from 'node:child_process';
import { createHash, randomUUID } from 'node:crypto';
import { readFile, unlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { extname, join } from 'node:path';
import { promisify } from 'node:util';
import type { AuthRequestUser } from '../auth/auth.types';
import { StorageService } from '../assets/storage.service';
import {
CHARACTER_TURNAROUND_PANEL_TEMPLATE_VERSION,
CHARACTER_TURNAROUND_PANEL_TYPES,
characterTurnaroundPanelLabel,
type CharacterTurnaroundPanelType
} from '../common/character-turnaround-panel-template';
import { PrismaService } from '../prisma/prisma.service';
import type { GenerateCharacterTurnaroundMasterDto } from './image.dto';
import type { SafeCharacterImage } from './image.types';
import { ImagesService } from './images.service';
const execFileAsync = promisify(execFile);
const MASTER_TEMPLATE_VERSION = 'character_turnaround_programmatic_master_v1_0';
@Injectable()
export class CharacterTurnaroundMasterService {
constructor(
@Inject(PrismaService) private readonly prisma: PrismaService,
@Inject(StorageService) private readonly storage: StorageService,
@Inject(ImagesService) private readonly imagesService: ImagesService
) {}
async generate(
user: AuthRequestUser,
characterId: string,
dto: GenerateCharacterTurnaroundMasterDto
) {
const characterIdValue = this.parseId(characterId, 'Invalid character id');
const character = await this.prisma.character.findUnique({ where: { id: characterIdValue } });
if (!character) throw new NotFoundException('Character not found');
if (character.status !== 'locked') {
throw new BadRequestException('Locked character is required before image generation');
}
const project = await this.prisma.project.findUnique({ where: { id: character.project_id } });
if (!project) throw new NotFoundException('Project not found');
if (project.user_id.toString() !== user.id && user.role !== 'admin') {
throw new ForbiddenException('Project access denied');
}
const outputSize = this.normalizeOutputSize(dto.output_size);
const descriptionOverride = this.normalizeDescriptionOverride(dto.character_description_override);
const baseReference = descriptionOverride
? { assetIds: [] as string[], cropMode: 'full' as const, source: 'description_override' }
: await this.resolveIdentityReference(character.id, character.anchor_asset_id, dto.reference_asset_ids, project.id, user);
const panelImages: SafeCharacterImage[] = [];
const panelReferenceMap: Record<string, string[]> = {};
const regenerateTypes = this.normalizeRegenerateTypes(dto.regenerate_image_types);
const existingImages = dto.force === false
? await this.imagesService.listCharacterImages(user, characterId)
: [];
for (const panelType of CHARACTER_TURNAROUND_PANEL_TYPES) {
const reusablePanel = [...existingImages]
.reverse()
.find((image) => image.image_type === panelType
&& image.asset_id
&& image.status !== 'deleted'
&& !regenerateTypes.has(panelType));
if (reusablePanel) {
panelImages.push(reusablePanel);
panelReferenceMap[panelType] = this.panelReferenceAssetIds(
panelType,
panelImages.slice(0, -1),
baseReference.assetIds
);
continue;
}
const referenceAssetIds = this.panelReferenceAssetIds(
panelType,
panelImages,
baseReference.assetIds
);
panelReferenceMap[panelType] = referenceAssetIds;
const response = await this.imagesService.generateCharacterImages(
user,
characterId,
{
image_types: [panelType],
count_per_type: 1,
force: dto.force !== false || regenerateTypes.has(panelType),
set_first_as_anchor: false,
use_reference_images: referenceAssetIds.length > 0,
reference_asset_ids: referenceAssetIds,
reference_crop_mode: panelType === 'turnaround_identity_panel'
? baseReference.cropMode
: 'full',
character_description_override: descriptionOverride || undefined,
output_size: outputSize,
enable_visual_quality_review: dto.enable_visual_quality_review !== false,
provider_code: dto.provider_code,
prompt_review_provider_code: dto.prompt_review_provider_code
},
{ approveSplitPanelSystemTemplate: true }
);
const panel = response.images[0] as SafeCharacterImage | undefined;
if (!panel?.asset_id) {
throw new BadRequestException(`${characterTurnaroundPanelLabel(panelType)}生成后没有可用素材`);
}
panelImages.push(panel);
}
const master = await this.composeMaster({
user,
projectId: project.id,
characterId: character.id,
characterName: character.name,
descriptionOverride,
outputSize,
panelImages,
panelReferenceMap,
providerCode: dto.prompt_review_provider_code,
enableVisualReview: dto.enable_visual_quality_review !== false
});
const panelGatePassed = panelImages.every((image) => image.visual_review?.passed === true);
const masterGatePassed = master.visual_review?.passed === true;
const approved = panelGatePassed && masterGatePassed;
if (!approved && master.status === 'quality_passed') {
await this.prisma.characterImage.update({
where: { id: BigInt(master.id) },
data: { status: 'needs_optimization' }
});
master.status = 'needs_optimization';
}
const assetIds = [...panelImages, master]
.map((image) => image.asset_id)
.filter((assetId): assetId is string => Boolean(assetId));
const tasks = await this.prisma.renderTask.findMany({
where: { output_asset_id: { in: assetIds.map((assetId) => BigInt(assetId)) } },
orderBy: { created_at: 'asc' }
});
const totalCost = tasks.reduce(
(sum, task) => sum + Number(task.cost_actual?.toString() ?? 0),
0
);
const imageIds = [...panelImages, master].map((image) => BigInt(image.id));
const reviewRows = await this.prisma.characterImageQualityReview.findMany({
where: {
character_image_id: { in: imageIds },
provider_log_id: { not: null }
},
select: { provider_log_id: true }
});
const reviewProviderLogIds = Array.from(new Set(
reviewRows
.map((review) => review.provider_log_id?.toString())
.filter((id): id is string => Boolean(id))
)).map((id) => BigInt(id));
const reviewProviderLogs = reviewProviderLogIds.length
? await this.prisma.providerLog.findMany({
where: { id: { in: reviewProviderLogIds } },
select: { id: true, cost_actual: true }
})
: [];
const visualQcCost = reviewProviderLogs.reduce(
(sum, log) => sum + Number(log.cost_actual?.toString() ?? 0),
0
);
const overallCost = totalCost + visualQcCost;
return {
workflow: 'split_panels_then_programmatic_master',
template_version: CHARACTER_TURNAROUND_PANEL_TEMPLATE_VERSION,
master_template_version: MASTER_TEMPLATE_VERSION,
character_id: character.id.toString(),
output_size: outputSize,
identity_reference: baseReference,
panel_reference_map: panelReferenceMap,
panel_images: panelImages,
master_image: master,
quality_gate: {
s_plus_threshold: 96,
panel_gate_passed: panelGatePassed,
master_gate_passed: masterGatePassed,
approved_for_s_plus_video: approved,
panel_scores: panelImages.map((image) => ({
image_type: image.image_type,
score: image.visual_review?.score ?? image.quality_score,
passed: image.visual_review?.passed ?? false
})),
master_score: master.visual_review?.score ?? master.quality_score
},
execution_evidence: {
task_ids: tasks.map((task) => task.id.toString()),
visual_qc_provider_log_ids: reviewProviderLogs.map((log) => log.id.toString()),
source_asset_ids: panelImages.map((image) => image.asset_id),
master_asset_id: master.asset_id,
image_generation_cost_actual: Math.round(totalCost * 1_000_000) / 1_000_000,
visual_qc_cost_actual: Math.round(visualQcCost * 1_000_000) / 1_000_000,
programmatic_compose_cost_actual: 0,
total_cost_actual: Math.round(overallCost * 1_000_000) / 1_000_000
},
next_step: approved
? 'approved_for_video_identity_lock'
: 'review_failed_panels_and_regenerate_only_failed_panel'
};
}
private async resolveIdentityReference(
characterId: bigint,
anchorAssetId: bigint | null,
requestedAssetIds: string[] | undefined,
projectId: bigint,
user: AuthRequestUser
) {
const requested = this.uniqueIds(requestedAssetIds);
if (requested.length) {
await this.assertProjectAssets(requested, projectId, user);
return {
assetIds: requested,
cropMode: await this.referenceNeedsFaceCrop(requested[0], characterId)
? 'turnaround_face_panel' as const
: 'full' as const,
source: 'explicit_reference'
};
}
if (anchorAssetId) {
return {
assetIds: [anchorAssetId.toString()],
cropMode: 'full' as const,
source: 'character_anchor'
};
}
const bestTurnaround = await this.prisma.characterImage.findFirst({
where: {
character_id: characterId,
image_type: 'turnaround_reference',
asset_id: { not: null },
status: { not: 'deleted' }
},
orderBy: [{ quality_score: 'desc' }, { created_at: 'desc' }]
});
return bestTurnaround?.asset_id
? {
assetIds: [bestTurnaround.asset_id.toString()],
cropMode: 'turnaround_face_panel' as const,
source: 'best_existing_turnaround_candidate'
}
: { assetIds: [], cropMode: 'full' as const, source: 'character_profile_only' };
}
private panelReferenceAssetIds(
panelType: CharacterTurnaroundPanelType,
panels: SafeCharacterImage[],
baseReferenceAssetIds: string[]
) {
const generated = Object.fromEntries(
panels.map((image) => [image.image_type, image.asset_id]).filter((entry) => Boolean(entry[1]))
) as Record<string, string>;
if (panelType === 'turnaround_identity_panel') return baseReferenceAssetIds.slice(0, 1);
if (panelType === 'turnaround_front_panel') {
return this.uniqueIds([generated.turnaround_identity_panel, ...baseReferenceAssetIds]).slice(0, 3);
}
if (panelType === 'turnaround_side_panel') {
return this.uniqueIds([
generated.turnaround_identity_panel,
generated.turnaround_front_panel,
...baseReferenceAssetIds
]).slice(0, 4);
}
return this.uniqueIds([
generated.turnaround_identity_panel,
generated.turnaround_front_panel,
generated.turnaround_side_panel,
...baseReferenceAssetIds
]).slice(0, 4);
}
private async composeMaster(input: {
user: AuthRequestUser;
projectId: bigint;
characterId: bigint;
characterName: string;
descriptionOverride: string | null;
outputSize: '2560x1440' | '3840x2160';
panelImages: SafeCharacterImage[];
panelReferenceMap: Record<string, string[]>;
providerCode?: string;
enableVisualReview: boolean;
}) {
const [width, height] = input.outputSize.split('x').map(Number);
const identityWidth = Math.round(width * 0.3125);
const remaining = width - identityWidth;
const frontWidth = Math.floor(remaining / 3);
const sideWidth = Math.floor(remaining / 3);
const backWidth = remaining - frontWidth - sideWidth;
const sourceAssetIds = input.panelImages.map((image) => image.asset_id as string);
const composition = {
layout: 'identity_front_side_back',
output_size: input.outputSize,
widths: [identityWidth, frontWidth, sideWidth, backWidth],
source_asset_ids: sourceAssetIds,
source_image_ids: input.panelImages.map((image) => image.id),
source_scores: input.panelImages.map((image) => image.visual_review?.score ?? image.quality_score),
panel_reference_map: input.panelReferenceMap
} as Prisma.InputJsonObject;
const taskInput = {
target_type: 'character_turnaround_master',
character_id: input.characterId.toString(),
composition,
template_version: MASTER_TEMPLATE_VERSION
} as Prisma.InputJsonObject;
const inputHash = createHash('sha256').update(JSON.stringify(taskInput)).digest('hex');
const task = await this.prisma.renderTask.create({
data: {
project_id: input.projectId,
task_type: 'character_turnaround_compose',
status: 'running',
input_json: taskInput,
input_hash: inputHash,
idempotency_key: `character_turnaround_compose:${input.projectId.toString()}:${input.characterId.toString()}:${inputHash}:${Date.now()}`,
retry_count: 0,
max_retry: 0,
started_at: new Date()
}
});
try {
const buffers = [];
for (const assetId of sourceAssetIds) {
const asset = await this.prisma.asset.findUnique({ where: { id: BigInt(assetId) } });
if (!asset) throw new NotFoundException(`Source asset ${assetId} not found`);
buffers.push({ asset, buffer: await this.storage.readPrivateFile(asset.file_path) });
}
const outputBuffer = await this.composePanelBuffers(
buffers,
[identityWidth, frontWidth, sideWidth, backWidth],
height
);
const file = {
originalname: `${input.characterName}-turnaround-master-${randomUUID()}.png`,
mimetype: 'image/png',
size: outputBuffer.length,
buffer: outputBuffer
} as Express.Multer.File;
const stored = await this.storage.storePrivateFile(file, 'character-turnaround-masters');
const asset = await this.prisma.asset.create({
data: {
user_id: BigInt(input.user.id),
project_id: input.projectId,
asset_type: 'image',
file_path: stored.file_path,
file_url: null,
mime_type: 'image/png',
width,
height,
size: stored.size,
hash: stored.hash,
display_name: `${input.characterName} S+四面板人物母版`,
selection_status: 'candidate',
metadata_json: {
...composition,
workflow: 'split_panels_then_programmatic_master',
template_version: MASTER_TEMPLATE_VERSION
} as Prisma.InputJsonObject,
visibility: 'private',
status: 'active'
}
});
const promptText = [
MASTER_TEMPLATE_VERSION,
input.descriptionOverride
? `DESCRIPTION_OVERRIDE_MODE${input.descriptionOverride}`
: `角色:${input.characterName}`,
'程序合成母版:左侧身份特写,右侧严格正面、严格90度侧面、严格180度背面;四栏来自独立生成并独立质检的源图。',
`源素材:${sourceAssetIds.join(', ')}`
].join('\n');
const image = await this.prisma.characterImage.create({
data: {
project_id: input.projectId,
character_id: input.characterId,
asset_id: asset.id,
image_type: 'turnaround_reference',
prompt_text: promptText,
negative_prompt: null,
is_anchor: false,
prompt_quality_score: new Prisma.Decimal(100),
quality_score: null,
status: 'generated'
}
});
await this.prisma.renderTask.update({
where: { id: task.id },
data: { status: 'success', output_asset_id: asset.id, finished_at: new Date() }
});
if (input.enableVisualReview) {
return this.imagesService.reviewCharacterImage(
input.user,
input.characterId.toString(),
image.id.toString(),
{ provider_code: input.providerCode }
);
}
const allImages = await this.imagesService.listCharacterImages(
input.user,
input.characterId.toString()
);
return allImages.find((item) => item.id === image.id.toString()) as SafeCharacterImage;
} catch (error) {
await this.prisma.renderTask.update({
where: { id: task.id },
data: {
status: 'failed',
error_code: 'TURNAROUND_MASTER_COMPOSE_FAILED',
error_message: error instanceof Error ? error.message : String(error),
finished_at: new Date()
}
});
throw error;
}
}
private async composePanelBuffers(
sources: Array<{ asset: { mime_type: string | null }; buffer: Buffer }>,
widths: number[],
height: number
) {
const token = randomUUID();
const inputPaths = sources.map((source, index) => join(
tmpdir(),
`turnaround-panel-${token}-${index}${this.extensionForMime(source.asset.mime_type)}`
));
const outputPath = join(tmpdir(), `turnaround-master-${token}.png`);
try {
await Promise.all(sources.map((source, index) => writeFile(inputPaths[index], source.buffer)));
const filters = widths.map((panelWidth, index) =>
`[${index}:v]scale=${widths.reduce((sum, value) => sum + value, 0)}:${height}:force_original_aspect_ratio=increase,` +
`crop=${panelWidth}:${height}:(iw-${panelWidth})/2:(ih-${height})/2,setsar=1[p${index}]`
);
filters.push('[p0][p1][p2][p3]hstack=inputs=4,format=rgb24[out]');
const args = ['-y', '-hide_banner', '-loglevel', 'error'];
for (const inputPath of inputPaths) args.push('-i', inputPath);
args.push(
'-filter_complex',
filters.join(';'),
'-map',
'[out]',
'-frames:v',
'1',
outputPath
);
await execFileAsync('ffmpeg', args, { timeout: 120_000, maxBuffer: 4 * 1024 * 1024 });
const result = await readFile(outputPath);
if (!result.length) throw new Error('Turnaround master output is empty');
return result;
} finally {
await Promise.all([...inputPaths, outputPath].map((path) => unlink(path).catch(() => undefined)));
}
}
private async assertProjectAssets(assetIds: string[], projectId: bigint, user: AuthRequestUser) {
for (const assetId of assetIds) {
const asset = await this.prisma.asset.findUnique({ where: { id: BigInt(assetId) } });
if (!asset || asset.status !== 'active') throw new NotFoundException(`Reference asset ${assetId} not found`);
if (asset.project_id && asset.project_id !== projectId) {
throw new ForbiddenException(`Reference asset ${assetId} belongs to another project`);
}
if (asset.user_id?.toString() !== user.id && user.role !== 'admin') {
throw new ForbiddenException(`Reference asset ${assetId} is private`);
}
if (asset.asset_type !== 'image' || !asset.mime_type?.startsWith('image/')) {
throw new BadRequestException(`Reference asset ${assetId} is not an image`);
}
}
}
private async referenceNeedsFaceCrop(assetId: string, characterId: bigint) {
const image = await this.prisma.characterImage.findFirst({
where: { character_id: characterId, asset_id: BigInt(assetId) }
});
return image?.image_type === 'turnaround_reference';
}
private normalizeOutputSize(value: unknown): '2560x1440' | '3840x2160' {
const normalized = String(value || '3840x2160').trim().toLowerCase();
if (normalized !== '2560x1440' && normalized !== '3840x2160') {
throw new BadRequestException('output_size must be 2560x1440 or 3840x2160');
}
return normalized;
}
private normalizeDescriptionOverride(value: unknown) {
const normalized = typeof value === 'string' ? value.replace(/\r\n/g, '\n').trim() : '';
if (normalized.length > 5000) {
throw new BadRequestException('character_description_override must be at most 5000 characters');
}
return normalized || null;
}
private normalizeRegenerateTypes(values: string[] | undefined) {
const normalized = new Set<CharacterTurnaroundPanelType>();
for (const value of values || []) {
if (!(CHARACTER_TURNAROUND_PANEL_TYPES as readonly string[]).includes(value)) {
throw new BadRequestException(`Unsupported turnaround panel type: ${value}`);
}
normalized.add(value as CharacterTurnaroundPanelType);
}
return normalized;
}
private uniqueIds(values: Array<string | null | undefined> | undefined) {
return Array.from(new Set((values || [])
.map((value) => String(value || '').trim())
.filter((value) => /^\d+$/.test(value) && BigInt(value) > 0n)));
}
private extensionForMime(mimeType: string | null) {
if (mimeType === 'image/jpeg' || mimeType === 'image/jpg') return '.jpg';
if (mimeType === 'image/webp') return '.webp';
if (mimeType === 'image/svg+xml') return '.svg';
return extname(mimeType || '') || '.png';
}
private parseId(value: string, message: string) {
try {
const id = BigInt(value);
if (id <= 0n) throw new Error('ID must be positive');
return id;
} catch {
throw new BadRequestException(message);
}
}
}
+39
View File
@@ -3,6 +3,29 @@ export class GenerateCharacterImagesDto {
count_per_type?: number; count_per_type?: number;
force?: boolean; force?: boolean;
set_first_as_anchor?: boolean; set_first_as_anchor?: boolean;
use_reference_images?: boolean;
reference_asset_ids?: string[];
reference_crop_mode?: string;
character_description_override?: string;
output_size?: string;
enable_visual_quality_review?: boolean;
provider_code?: string;
prompt_review_provider_code?: string;
}
export class ReviewCharacterImageDto {
provider_code?: string;
}
export class GenerateCharacterTurnaroundMasterDto {
provider_code?: string;
prompt_review_provider_code?: string;
reference_asset_ids?: string[];
character_description_override?: string;
output_size?: string;
enable_visual_quality_review?: boolean;
force?: boolean;
regenerate_image_types?: string[];
} }
export class SetCharacterAnchorDto { export class SetCharacterAnchorDto {
@@ -10,13 +33,29 @@ export class SetCharacterAnchorDto {
asset_id?: string; asset_id?: string;
} }
export class ImportCharacterImageDto {
asset_id?: string;
image_type?: string;
set_as_anchor?: boolean | string;
prompt_text?: string;
}
export class SetCharacterFacePackDto {
asset_ids?: string[];
consent_confirmed?: boolean | string;
set_first_as_anchor?: boolean | string;
notes?: string;
}
export class GenerateShotImageDto { export class GenerateShotImageDto {
image_type?: string; image_type?: string;
force?: boolean; force?: boolean;
provider_code?: string;
} }
export class GenerateEpisodeShotImagesDto { export class GenerateEpisodeShotImagesDto {
image_type?: string; image_type?: string;
only_missing?: boolean; only_missing?: boolean;
limit?: number; limit?: number;
provider_code?: string;
} }
+28
View File
@@ -1,13 +1,19 @@
import type { Asset, CharacterImage, ShotImage } from '@prisma/client'; import type { Asset, CharacterImage, ShotImage } from '@prisma/client';
import { toSafeAsset, type SafeAsset } from '../assets/asset.types'; import { toSafeAsset, type SafeAsset } from '../assets/asset.types';
import { CHARACTER_TURNAROUND_PANEL_TYPES } from '../common/character-turnaround-panel-template';
export const CHARACTER_IMAGE_TYPES = [ export const CHARACTER_IMAGE_TYPES = [
'front_reference', 'front_reference',
'side_reference', 'side_reference',
'expression_pack', 'expression_pack',
'costume_default', 'costume_default',
'costume_special', 'costume_special',
'turnaround_reference',
...CHARACTER_TURNAROUND_PANEL_TYPES,
'prop_anchor',
'anchor', 'anchor',
'face_reference',
'scene_variant' 'scene_variant'
] as const; ] as const;
@@ -16,6 +22,23 @@ export const SHOT_IMAGE_TYPES = ['preview', 'final'] as const;
export type CharacterImageType = (typeof CHARACTER_IMAGE_TYPES)[number]; export type CharacterImageType = (typeof CHARACTER_IMAGE_TYPES)[number];
export type ShotImageType = (typeof SHOT_IMAGE_TYPES)[number]; export type ShotImageType = (typeof SHOT_IMAGE_TYPES)[number];
export interface SafeCharacterImageQualityReview {
id: string;
score: number | null;
grade: string | null;
threshold_score: number;
passed: boolean;
status: string;
strengths: string[];
issues: string[];
improvement_rules: string[];
quality_gate: unknown;
provider_code: string | null;
model_name: string | null;
error_message: string | null;
created_at: string;
}
export interface SafeCharacterImage { export interface SafeCharacterImage {
id: string; id: string;
project_id: string; project_id: string;
@@ -25,10 +48,12 @@ export interface SafeCharacterImage {
prompt_text: string | null; prompt_text: string | null;
negative_prompt: string | null; negative_prompt: string | null;
is_anchor: boolean; is_anchor: boolean;
prompt_quality_score: number | null;
quality_score: number | null; quality_score: number | null;
status: string; status: string;
created_at: string; created_at: string;
asset?: SafeAsset | null; asset?: SafeAsset | null;
visual_review?: SafeCharacterImageQualityReview | null;
} }
export interface SafeShotImage { export interface SafeShotImage {
@@ -58,6 +83,9 @@ export function toSafeCharacterImage(
prompt_text: image.prompt_text, prompt_text: image.prompt_text,
negative_prompt: image.negative_prompt, negative_prompt: image.negative_prompt,
is_anchor: image.is_anchor, is_anchor: image.is_anchor,
prompt_quality_score: image.prompt_quality_score
? Number(image.prompt_quality_score.toString())
: null,
quality_score: image.quality_score ? Number(image.quality_score.toString()) : null, quality_score: image.quality_score ? Number(image.quality_score.toString()) : null,
status: image.status, status: image.status,
created_at: image.created_at.toISOString(), created_at: image.created_at.toISOString(),
+48 -2
View File
@@ -12,16 +12,25 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import type { AuthRequestUser } from '../auth/auth.types'; import type { AuthRequestUser } from '../auth/auth.types';
import { import {
GenerateCharacterImagesDto, GenerateCharacterImagesDto,
GenerateCharacterTurnaroundMasterDto,
GenerateEpisodeShotImagesDto, GenerateEpisodeShotImagesDto,
GenerateShotImageDto, GenerateShotImageDto,
SetCharacterAnchorDto ImportCharacterImageDto,
ReviewCharacterImageDto,
SetCharacterAnchorDto,
SetCharacterFacePackDto
} from './image.dto'; } from './image.dto';
import { CharacterTurnaroundMasterService } from './character-turnaround-master.service';
import { ImagesService } from './images.service'; import { ImagesService } from './images.service';
@Controller() @Controller()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
export class ImagesController { export class ImagesController {
constructor(@Inject(ImagesService) private readonly imagesService: ImagesService) {} constructor(
@Inject(ImagesService) private readonly imagesService: ImagesService,
@Inject(CharacterTurnaroundMasterService)
private readonly turnaroundMasterService: CharacterTurnaroundMasterService
) {}
@Post('characters/:characterId/generate-images') @Post('characters/:characterId/generate-images')
generateCharacterImages( generateCharacterImages(
@@ -32,6 +41,15 @@ export class ImagesController {
return this.imagesService.generateCharacterImages(user, characterId, dto); return this.imagesService.generateCharacterImages(user, characterId, dto);
} }
@Post('characters/:characterId/turnaround-master/generate')
generateCharacterTurnaroundMaster(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Body() dto: GenerateCharacterTurnaroundMasterDto
) {
return this.turnaroundMasterService.generate(user, characterId, dto);
}
@Get('characters/:characterId/images') @Get('characters/:characterId/images')
listCharacterImages( listCharacterImages(
@CurrentUser() user: AuthRequestUser, @CurrentUser() user: AuthRequestUser,
@@ -40,6 +58,16 @@ export class ImagesController {
return this.imagesService.listCharacterImages(user, characterId); return this.imagesService.listCharacterImages(user, characterId);
} }
@Post('characters/:characterId/images/:imageId/review-visual')
reviewCharacterImage(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Param('imageId') imageId: string,
@Body() dto: ReviewCharacterImageDto
) {
return this.imagesService.reviewCharacterImage(user, characterId, imageId, dto);
}
@Post('characters/:characterId/set-anchor') @Post('characters/:characterId/set-anchor')
setCharacterAnchor( setCharacterAnchor(
@CurrentUser() user: AuthRequestUser, @CurrentUser() user: AuthRequestUser,
@@ -49,6 +77,24 @@ export class ImagesController {
return this.imagesService.setCharacterAnchor(user, characterId, dto); return this.imagesService.setCharacterAnchor(user, characterId, dto);
} }
@Post('characters/:characterId/import-image')
importCharacterImage(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Body() dto: ImportCharacterImageDto
) {
return this.imagesService.importCharacterImage(user, characterId, dto);
}
@Post('characters/:characterId/face-pack')
setCharacterFacePack(
@CurrentUser() user: AuthRequestUser,
@Param('characterId') characterId: string,
@Body() dto: SetCharacterFacePackDto
) {
return this.imagesService.setCharacterFacePack(user, characterId, dto);
}
@Post('storyboard-shots/:shotId/images/generate') @Post('storyboard-shots/:shotId/images/generate')
generateShotImage( generateShotImage(
@CurrentUser() user: AuthRequestUser, @CurrentUser() user: AuthRequestUser,
+2 -1
View File
@@ -4,12 +4,13 @@ import { AssetsModule } from '../assets/assets.module';
import { PrismaModule } from '../prisma/prisma.module'; import { PrismaModule } from '../prisma/prisma.module';
import { ProvidersModule } from '../providers/providers.module'; import { ProvidersModule } from '../providers/providers.module';
import { ImagesController } from './images.controller'; import { ImagesController } from './images.controller';
import { CharacterTurnaroundMasterService } from './character-turnaround-master.service';
import { ImagesService } from './images.service'; import { ImagesService } from './images.service';
@Module({ @Module({
imports: [AuthModule, AssetsModule, PrismaModule, ProvidersModule], imports: [AuthModule, AssetsModule, PrismaModule, ProvidersModule],
controllers: [ImagesController], controllers: [ImagesController],
providers: [ImagesService], providers: [ImagesService, CharacterTurnaroundMasterService],
exports: [ImagesService] exports: [ImagesService]
}) })
export class ImagesModule {} export class ImagesModule {}
+613 -6
View File
@@ -2,6 +2,7 @@ import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import type { import type {
Asset, Asset,
ActorProfile,
Character, Character,
CharacterImage, CharacterImage,
Episode, Episode,
@@ -15,6 +16,7 @@ import type { AuthRequestUser } from '../auth/auth.types';
import type { StorageService } from '../assets/storage.service'; import type { StorageService } from '../assets/storage.service';
import type { PrismaService } from '../prisma/prisma.service'; import type { PrismaService } from '../prisma/prisma.service';
import type { ProvidersService } from '../providers/providers.service'; import type { ProvidersService } from '../providers/providers.service';
import { CHARACTER_TURNAROUND_TEMPLATE_VERSION } from '../common/character-turnaround-template';
import { ImagesService } from './images.service'; import { ImagesService } from './images.service';
const user: AuthRequestUser = { const user: AuthRequestUser = {
@@ -177,6 +179,7 @@ function createCharacterImage(overrides: Partial<CharacterImage> = {}): Characte
prompt_text: 'prompt', prompt_text: 'prompt',
negative_prompt: 'negative', negative_prompt: 'negative',
is_anchor: false, is_anchor: false,
prompt_quality_score: new Prisma.Decimal(98),
quality_score: new Prisma.Decimal(92), quality_score: new Prisma.Decimal(92),
status: 'generated', status: 'generated',
created_at: now, created_at: now,
@@ -184,6 +187,25 @@ function createCharacterImage(overrides: Partial<CharacterImage> = {}): Characte
}; };
} }
function createActorProfile(overrides: Partial<ActorProfile> = {}): ActorProfile {
return {
id: 90n,
project_id: 10n,
character_id: 20n,
actor_desc: '眼神坚定,气质冷静',
appearance_rules: '精致鹅蛋脸;深色中长发',
wardrobe_rules: '现代都市通勤装',
performance_style: null,
voice_style: null,
reference_asset_ids: ['50'],
anchor_asset_id: 50n,
status: 'locked',
created_at: now,
updated_at: now,
...overrides
};
}
function createShotImage(overrides: Partial<ShotImage> = {}): ShotImage { function createShotImage(overrides: Partial<ShotImage> = {}): ShotImage {
return { return {
id: 70n, id: 70n,
@@ -238,11 +260,17 @@ describe('ImagesService', () => {
beforeEach(() => { beforeEach(() => {
tx = { tx = {
characterImage: { characterImage: {
findFirst: vi.fn().mockResolvedValue(null),
updateMany: vi.fn().mockResolvedValue({ count: 1 }), updateMany: vi.fn().mockResolvedValue({ count: 1 }),
update: vi.fn().mockResolvedValue(createCharacterImage({ is_anchor: true, status: 'selected' })) update: vi.fn().mockResolvedValue(createCharacterImage({ is_anchor: true, status: 'selected' })),
create: vi.fn().mockResolvedValue(createCharacterImage({ image_type: 'face_reference' }))
}, },
character: { character: {
update: vi.fn().mockResolvedValue(createCharacter({ anchor_asset_id: 50n })) update: vi.fn().mockResolvedValue(createCharacter({ anchor_asset_id: 50n }))
},
actorProfile: {
findUnique: vi.fn().mockResolvedValue(createActorProfile()),
upsert: vi.fn().mockResolvedValue(createActorProfile())
} }
}; };
prisma = { prisma = {
@@ -258,7 +286,33 @@ describe('ImagesService', () => {
findFirst: vi.fn().mockResolvedValue(null), findFirst: vi.fn().mockResolvedValue(null),
findUnique: vi.fn().mockResolvedValue(createCharacterImage()), findUnique: vi.fn().mockResolvedValue(createCharacterImage()),
findMany: vi.fn().mockResolvedValue([createCharacterImage()]), findMany: vi.fn().mockResolvedValue([createCharacterImage()]),
create: vi.fn().mockResolvedValue(createCharacterImage()) create: vi.fn().mockResolvedValue(createCharacterImage()),
update: vi.fn().mockResolvedValue(createCharacterImage())
},
characterImageQualityReview: {
findFirst: vi.fn().mockResolvedValue(null),
create: vi.fn()
},
characterPromptVersion: {
findFirst: vi.fn().mockResolvedValue({
id: 101n,
prompt_text: 'FACE_IDENTITY_LOCK professional character reference image,自然美颜但不能换脸',
negative_prompt: 'identity drift, excessive beauty filter',
source_type: 'external_web',
source_label: 'test',
layer_code: 'main_anchor',
channel: 'chatgpt_web',
quality_score: new Prisma.Decimal(98)
})
},
characterPromptOptimizationLesson: {
findMany: vi.fn().mockResolvedValue([])
},
characterPromptReview: {
create: vi.fn().mockResolvedValue({ id: 102n })
},
actorProfile: {
findUnique: vi.fn().mockResolvedValue(null)
}, },
storyboardShot: { storyboardShot: {
findUnique: vi.fn().mockResolvedValue(createShot()), findUnique: vi.fn().mockResolvedValue(createShot()),
@@ -278,12 +332,15 @@ describe('ImagesService', () => {
update: vi.fn().mockResolvedValue(createTask({ status: 'success', output_asset_id: 50n })) update: vi.fn().mockResolvedValue(createTask({ status: 'success', output_asset_id: 50n }))
}, },
asset: { asset: {
create: vi.fn().mockResolvedValue(createAsset()), create: vi.fn().mockImplementation(({ data }: any) => createAsset({ status: data.status })),
findUnique: vi.fn().mockResolvedValue(createAsset()) findUnique: vi.fn().mockResolvedValue(createAsset()),
findFirst: vi.fn().mockResolvedValue(createAsset())
}, },
$transaction: vi.fn(async (callback: (transaction: typeof tx) => unknown) => callback(tx)) $transaction: vi.fn(async (callback: (transaction: typeof tx) => unknown) => callback(tx))
}; };
storage = { storage = {
readPrivateFile: vi.fn().mockResolvedValue(Buffer.from('face-reference-image')),
createTemporaryPublicUrl: vi.fn().mockReturnValue('https://example.com/temporary-image.png'),
storePrivateFile: vi.fn().mockResolvedValue({ storePrivateFile: vi.fn().mockResolvedValue({
file_path: 'local://generated-images/mock.svg', file_path: 'local://generated-images/mock.svg',
size: 1024n, size: 1024n,
@@ -313,6 +370,266 @@ describe('ImagesService', () => {
); );
}); });
it('uses provider-compatible flagship landscape specs for character turnarounds', () => {
const renderSpec = (service as any).characterImageRenderSpec.bind(service);
const isFlagshipPrompt = (service as any).isFlagshipCharacterTurnaroundPrompt.bind(service);
expect(renderSpec('turnaround_reference', 'openai-image')).toEqual({
width: 2560,
height: 1440,
size: '2560x1440',
aspectRatio: '16:9',
quality: 'high'
});
expect(renderSpec('turnaround_reference', undefined)).toEqual({
width: 2560,
height: 1440,
size: '2560x1440',
aspectRatio: '16:9',
quality: 'high'
});
expect(renderSpec('turnaround_reference', 'openai-image', '3840x2160')).toEqual({
width: 3840,
height: 2160,
size: '3840x2160',
aspectRatio: '16:9',
quality: 'high'
});
expect(renderSpec('turnaround_reference', 'volcengine-seedream-50-image')).toEqual({
width: 2560,
height: 1440,
size: '2560x1440',
aspectRatio: '16:9',
quality: 'high'
});
expect(isFlagshipPrompt(
'横版纯白背景,左侧脸部特写,右侧严格正面、90度侧面、背面三个全身视图。'
)).toBe(true);
expect(isFlagshipPrompt(
'横版纯白背景,左侧脸部特写;第一行四个全身视图,第二行面部三视图,包含正面、侧面、背面。'
)).toBe(false);
expect(isFlagshipPrompt('白底普通人物三视图。')).toBe(false);
const directPrompt = (service as any).buildCharacterTurnaroundPrompt(
createProject({ output_mode: 'live_action_ai' }),
createCharacter(),
'turnaround_reference',
0,
0
);
expect(directPrompt).toContain('本次不使用参考图');
expect(directPrompt).toContain('DIRECT_DESIGN_MODE');
expect(directPrompt).toContain('左侧约38%');
expect(directPrompt).toContain('严格90度右向侧面全身');
expect(directPrompt).toContain('服装拓扑');
expect(directPrompt).toContain('默认中性站姿、双手空置');
expect(directPrompt).toContain('角色连续性定妆摄影');
expect(directPrompt).toContain('禁止现代运动鞋');
expect(directPrompt).not.toContain('第一行:四个全身');
expect(directPrompt).not.toContain('基于已上传并确认的主锚点图严格生成');
const overridePrompt = (service as any).buildCharacterTurnaroundPrompt(
createProject({ output_mode: 'live_action_ai' }),
createCharacter(),
'turnaround_reference',
0,
0,
'62岁女性边关统帅,银灰短发,左眉旧伤,深红鳞甲与黑色战靴。'
);
expect(overridePrompt).toContain('DESCRIPTION_OVERRIDE_MODE');
expect(overridePrompt).toContain('本次唯一角色描述:62岁女性边关统帅');
expect(overridePrompt).not.toContain('精致鹅蛋脸');
expect(overridePrompt).not.toContain('现代都市通勤装');
const reviewPrompt = (service as any).buildCharacterImagePromptReviewPrompt({
character: createCharacter(),
imageType: 'turnaround_reference',
draftPrompt: overridePrompt,
currentPrompt: overridePrompt,
negativePrompt: '无文字,无水印',
referenceImageCount: 0,
round: 1,
lessons: [],
characterDescriptionOverride: '62岁女性边关统帅,银灰短发,左眉旧伤,深红鳞甲与黑色战靴。'
});
expect(reviewPrompt).toContain('DESCRIPTION_OVERRIDE_MODE');
expect(reviewPrompt).toContain('本次唯一角色描述=62岁女性边关统帅');
expect(reviewPrompt).not.toContain('face=精致鹅蛋脸');
expect(reviewPrompt).not.toContain('costume=现代都市通勤装');
expect(reviewPrompt).not.toContain('单独负面约束:');
expect(reviewPrompt).not.toContain('无文字,无水印');
const referenceReviewPrompt = (service as any).buildCharacterImagePromptReviewPrompt({
character: createCharacter(),
imageType: 'turnaround_reference',
draftPrompt: directPrompt,
currentPrompt: directPrompt,
negativePrompt: 'unique-negative-sentinel',
referenceImageCount: 1,
round: 1,
lessons: [],
characterDescriptionOverride: null
});
expect(referenceReviewPrompt).toContain('只采用 REFERENCE_LOCK_MODE');
expect(referenceReviewPrompt).not.toContain('unique-negative-sentinel');
});
it('treats critical visual defects as S+ hard failures', () => {
const hardFailures = (service as any).characterImageVisualHardFailures({
hard_failures: ['严格侧面缺失'],
issues: [
{
category: '身份一致性',
severity: 'critical',
region: '背面视图',
evidence: '发型和体型变成另一人',
fix: '锁定同一角色身份'
},
{
category: '材质',
severity: 'minor',
evidence: '布料略软'
},
{
category: '鞋履时代适配',
severity: 'major',
evidence: '侧面出现现代厚底靴轮廓'
}
]
});
expect(hardFailures).toContain('严格侧面缺失');
expect(hardFailures.some((item: string) => item.includes('背面视图'))).toBe(true);
expect(hardFailures.some((item: string) => item.includes('鞋履时代错误'))).toBe(true);
expect(hardFailures.some((item: string) => item.includes('布料略软'))).toBe(false);
});
it('keeps turnaround microdetail rules free from the old no-pore wrapper', () => {
const ensureQuality = (service as any).ensureCharacterTurnaroundQualityPrompt.bind(service);
const prompt = ensureQuality('公共三视图角色母版');
expect(prompt).toContain('当前模型与 API 参数允许的最高原生质量');
expect(prompt).toContain('适合后续4K放大与视频锁定');
expect(prompt).toContain('皮肤或表面');
expect(prompt).not.toContain('非真实毛孔结构');
});
it('normalizes legacy 8K marketing claims into executable quality language', () => {
const normalize = (service as any).normalizeSavedPromptForImageGeneration.bind(service);
const normalized = normalize([
'8K超高清画质 / 4K可用细节的角色资产。',
'同一8K CG数字角色保持一致。',
'8K / 4K-detail semi-realistic CGI character.'
].join('\n'));
expect(normalized).toContain('当前模型最高原生质量与后续4K放大准备');
expect(normalized).toContain('最高原生质量的原创虚构CG数字角色');
expect(normalized).toContain('highest-native-quality, 4K-upscaling-ready');
expect(normalized).not.toContain('8K');
});
it('rejects a high-scoring legacy turnaround prompt and only trusts the current clean template', () => {
const shouldTrust = (service as any).shouldTrustActiveCharacterImagePrompt.bind(service);
const base = {
version_id: '103',
negativePrompt: 'identity drift, modern shoes',
source_type: 'external_web',
source_label: 'turnaround',
layer_code: 'turnaround_reference',
channel: 'chatgpt_web',
quality_score: 98
};
const layout = 'DIRECT_DESIGN_MODE,横版16:9纯白背景,左侧面部特写,右侧依次为正面全身、严格90度侧面全身、背面全身。';
expect(shouldTrust({ ...base, prompt: layout }, 'turnaround_reference')).toBe(false);
expect(shouldTrust({
...base,
prompt: `${CHARACTER_TURNAROUND_TEMPLATE_VERSION}\n${layout}`
}, 'turnaround_reference')).toBe(true);
expect(shouldTrust({
...base,
prompt: `${CHARACTER_TURNAROUND_TEMPLATE_VERSION}\n${layout}\n不要真人照片级清晰正脸`
}, 'turnaround_reference')).toBe(false);
});
it('filters project-specific, portrait-suppressing and inapplicable turnaround lessons', () => {
const isReusable = (service as any).isReusableCharacterPromptLesson.bind(service);
const human = createCharacter({
name: '测试军师',
age_group: '约48岁,中年',
identity_desc: '三国时期古代军师',
costume_rules: '白色交领长袍、浅青内层、深色布靴'
});
const nonHuman = createCharacter({
name: '九幽鬼将',
gender_label: '非人形男性战将意象',
age_group: '古老亡灵',
identity_desc: '三头六臂的亡灵鬼将',
face_desc: '中首为主身份头部',
body_desc: '三头六臂,关节结构清楚',
costume_rules: '腐朽古代重甲'
});
expect(isReusable(
'正面、严格90度侧面、背面必须同尺度、同基线、同焦距。',
'turnaround_anchor',
human,
'global'
)).toBe(true);
expect(isReusable(
'测试军师的羽扇必须在全部视图保持同一只手持握。',
'turnaround_anchor',
human,
'global'
)).toBe(false);
expect(isReusable(
'9:16竖屏,不要真人照片级清晰正脸。',
'turnaround_anchor',
human,
'global'
)).toBe(false);
expect(isReusable(
'中老年角色必须保留法令纹、眼袋与胡须根部。',
'turnaround_anchor',
nonHuman,
'global'
)).toBe(false);
expect(isReusable(
'多头角色必须锁定头部与肢体数量,禁止随机增减肢体。',
'turnaround_anchor',
nonHuman,
'global'
)).toBe(true);
const filterNegative = (service as any).filterCharacterTurnaroundNegativeRules.bind(service);
const filtered = filterNegative(
'不要换脸,复制诸葛亮或司马懿面孔,9:16竖屏,不要真人照片级清晰正脸'
);
expect(filtered).toContain('复制其他角色的面孔、脸型或五官');
expect(filtered).not.toContain('诸葛亮');
expect(filtered).not.toContain('司马懿');
expect(filtered).not.toContain('9:16');
expect(filtered).not.toContain('不要真人照片级清晰正脸');
});
it('reuses a trusted 98-point prompt even after visual lessons are recorded', async () => {
prisma.characterPromptOptimizationLesson.findMany.mockResolvedValue([
{ rule_text: '全身小脸必须继承主特写年龄纹理。' }
]);
await service.generateCharacterImages(user, '20', {
image_types: ['front_reference'],
set_first_as_anchor: false,
enable_visual_quality_review: false
});
expect(providers.executeProvider).toHaveBeenCalledTimes(1);
expect(providers.executeProvider).toHaveBeenCalledWith(expect.objectContaining({
provider_type: 'ImageProvider'
}));
});
it('generates locked character reference images through ImageProvider', async () => { it('generates locked character reference images through ImageProvider', async () => {
const result = await service.generateCharacterImages(user, '20', { const result = await service.generateCharacterImages(user, '20', {
image_types: ['front_reference'], image_types: ['front_reference'],
@@ -325,8 +642,9 @@ describe('ImagesService', () => {
task_id: '80', task_id: '80',
allow_fallback: false, allow_fallback: false,
input_json: expect.objectContaining({ input_json: expect.objectContaining({
width: 1080, width: 1920,
height: 1920 height: 1080,
aspect_ratio: '16:9'
}) })
}) })
); );
@@ -350,7 +668,213 @@ describe('ImagesService', () => {
expect(result.next_step).toBe('shot_image_generate'); expect(result.next_step).toBe('shot_image_generate');
}); });
it('does not promote a turnaround sheet to the character main anchor', async () => {
prisma.characterPromptVersion.findFirst.mockResolvedValueOnce({
id: 103n,
prompt_text: `${CHARACTER_TURNAROUND_TEMPLATE_VERSION}\nDIRECT_DESIGN_MODE,横版16:9纯白背景,左侧面部特写,右侧依次为正面全身、严格90度侧面全身、背面全身。`,
negative_prompt: 'identity drift, modern shoes',
source_type: 'external_web',
source_label: 'turnaround-v4',
layer_code: 'turnaround_reference',
channel: 'chatgpt_web',
quality_score: new Prisma.Decimal(98)
}).mockResolvedValueOnce(null);
prisma.characterImage.create.mockResolvedValueOnce(createCharacterImage({
image_type: 'turnaround_reference',
asset_id: 50n
}));
prisma.characterImage.findUnique.mockResolvedValueOnce(createCharacterImage({
image_type: 'turnaround_reference',
asset_id: 50n
}));
const result = await service.generateCharacterImages(user, '20', {
image_types: ['turnaround_reference'],
force: true,
use_reference_images: true,
enable_visual_quality_review: false
});
expect(result.anchor).toBeNull();
expect(tx.character.update).not.toHaveBeenCalled();
});
it('passes an explicit 4K turnaround size to GPT Image 2', async () => {
prisma.characterPromptVersion.findFirst.mockResolvedValueOnce({
id: 103n,
prompt_text: `${CHARACTER_TURNAROUND_TEMPLATE_VERSION}\nDIRECT_DESIGN_MODE,横版16:9纯白背景,左侧面部特写,右侧依次为正面全身、严格90度侧面全身、背面全身。`,
negative_prompt: 'identity drift, modern shoes',
source_type: 'external_web',
source_label: 'turnaround-v4',
layer_code: 'turnaround_reference',
channel: 'chatgpt_web',
quality_score: new Prisma.Decimal(98)
}).mockResolvedValueOnce(null);
prisma.characterImage.create.mockResolvedValueOnce(createCharacterImage({
image_type: 'turnaround_reference',
asset_id: 50n
}));
prisma.characterImage.findUnique.mockResolvedValueOnce(createCharacterImage({
image_type: 'turnaround_reference',
asset_id: 50n
}));
await service.generateCharacterImages(user, '20', {
image_types: ['turnaround_reference'],
force: true,
set_first_as_anchor: false,
use_reference_images: true,
enable_visual_quality_review: false,
provider_code: 'openai-image',
output_size: '3840x2160'
});
expect(providers.executeProvider).toHaveBeenCalledWith(expect.objectContaining({
preferred_provider_code: 'openai-image',
input_json: expect.objectContaining({
width: 3840,
height: 2160,
size: '3840x2160',
aspect_ratio: '16:9',
quality: 'high',
output_format: 'png'
})
}));
});
it('uses explicit prior candidates first when refining a character turnaround', async () => {
const cropSpy = vi.spyOn(service as any, 'cropTurnaroundFacePanel').mockResolvedValue(
Buffer.from('cropped-face-panel')
);
vi.spyOn(service as any, 'reviewAndOptimizeCharacterImagePrompt').mockImplementation(
async (input: any) => ({
status: 'approved',
review_id: '104',
prompt_version_id: null,
draft_prompt: input.draftPrompt,
approved_prompt: input.draftPrompt,
score: 98,
threshold: 98,
passed: true,
issues: [],
suggestions: [],
reusable_rules: [],
quality_gate: null,
provider_log_id: null,
provider_code: 'openai-responses-text',
model_name: 'gpt-5',
requested_provider_code: null,
raw_text: null,
error_message: null
})
);
prisma.asset.findUnique.mockImplementation(async ({ where }: { where: { id: bigint } }) =>
createAsset({
id: where.id,
file_path: `local://turnaround/${where.id.toString()}.jpg`,
mime_type: 'image/jpeg',
width: 2560,
height: 1440
})
);
prisma.characterImage.findMany.mockResolvedValueOnce([]);
prisma.characterImage.create.mockResolvedValueOnce(createCharacterImage({
image_type: 'turnaround_reference',
asset_id: 50n
}));
prisma.characterImage.findUnique.mockResolvedValueOnce(createCharacterImage({
image_type: 'turnaround_reference',
asset_id: 50n
}));
await service.generateCharacterImages(user, '20', {
image_types: ['turnaround_reference'],
force: true,
set_first_as_anchor: false,
use_reference_images: true,
reference_asset_ids: ['51'],
reference_crop_mode: 'turnaround_face_panel',
enable_visual_quality_review: false,
provider_code: 'volcengine-seedream-50-image'
});
expect(providers.executeProvider).toHaveBeenCalledWith(expect.objectContaining({
input_json: expect.objectContaining({
prompt: expect.stringContaining('REFERENCE_LOCK_MODE'),
reference_images: [expect.stringContaining('data:image/png;base64,')]
})
}));
expect(cropSpy).toHaveBeenCalledOnce();
expect(prisma.renderTask.create).toHaveBeenCalledWith({
data: expect.objectContaining({
input_json: expect.objectContaining({
reference_mode: 'explicit_iteration_reference',
explicit_reference_asset_ids: ['51'],
reference_crop_mode: 'turnaround_face_panel',
face_reference_asset_ids: ['51']
})
})
});
});
it('passes uploaded face pack references when generating a live-action character anchor', async () => {
prisma.project.findUnique.mockResolvedValue(createProject({ output_mode: 'live_action_ai' }));
prisma.characterImage.findMany.mockResolvedValueOnce([
createCharacterImage({
id: 61n,
image_type: 'face_reference',
asset_id: 51n,
is_anchor: false
})
]);
prisma.actorProfile.findUnique.mockResolvedValueOnce(createActorProfile({
reference_asset_ids: ['52'],
anchor_asset_id: null
}));
prisma.asset.findUnique.mockImplementation(async ({ where }: { where: { id: bigint } }) =>
createAsset({
id: where.id,
file_path: `local://face-pack/${where.id.toString()}.jpg`,
mime_type: 'image/jpeg',
file_url: null
})
);
prisma.characterImage.create.mockResolvedValueOnce(createCharacterImage({
image_type: 'anchor'
}));
prisma.characterImage.findUnique.mockResolvedValueOnce(createCharacterImage({
image_type: 'anchor'
}));
await service.generateCharacterImages(user, '20', {
image_types: ['anchor'],
count_per_type: 1,
force: true,
provider_code: 'volcengine-seedream-50-image'
});
expect(providers.executeProvider).toHaveBeenCalledWith(expect.objectContaining({
preferred_provider_code: 'volcengine-seedream-50-image',
input_json: expect.objectContaining({
prompt: expect.stringContaining('FACE_IDENTITY_LOCK'),
reference_images: [
expect.stringContaining('data:image/jpeg;base64,'),
expect.stringContaining('data:image/jpeg;base64,')
]
})
}));
const request = providers.executeProvider.mock.calls[0][0];
expect(request.input_json.prompt).toContain('自然美颜');
expect(request.input_json.prompt).toContain('不能换脸');
expect(request.input_json.negative_prompt).toContain('excessive beauty filter');
expect(storage.createTemporaryPublicUrl).not.toHaveBeenCalled();
});
it('sets a character anchor image and updates the character anchor asset', async () => { it('sets a character anchor image and updates the character anchor asset', async () => {
prisma.characterImage.findUnique.mockResolvedValueOnce(createCharacterImage({
image_type: 'anchor'
}));
const result = await service.setCharacterAnchor(user, '20', { const result = await service.setCharacterAnchor(user, '20', {
character_image_id: '60' character_image_id: '60'
}); });
@@ -373,6 +897,89 @@ describe('ImagesService', () => {
expect(result.anchor_asset_id).toBe('50'); expect(result.anchor_asset_id).toBe('50');
}); });
it('rejects setting uploaded face references as character anchors', async () => {
prisma.characterImage.findUnique.mockResolvedValueOnce(createCharacterImage({
image_type: 'face_reference'
}));
await expect(service.setCharacterAnchor(user, '20', {
character_image_id: '60'
})).rejects.toBeInstanceOf(BadRequestException);
expect(tx.character.update).not.toHaveBeenCalled();
});
it('attaches a consented face pack and syncs the actor profile references', async () => {
prisma.asset.findUnique
.mockResolvedValueOnce(createAsset({
id: 51n,
file_path: 'local://uploads/face.png',
file_url: '/assets/face.png',
mime_type: 'image/png'
}))
.mockResolvedValueOnce(createAsset({
id: 51n,
file_path: 'local://uploads/face.png',
file_url: '/assets/face.png',
mime_type: 'image/png'
}));
tx.characterImage.create.mockResolvedValueOnce(createCharacterImage({
id: 61n,
asset_id: 51n,
image_type: 'face_reference',
is_anchor: false,
status: 'generated'
}));
tx.character.update.mockResolvedValueOnce(createCharacter({ anchor_asset_id: 51n }));
tx.actorProfile.findUnique.mockResolvedValueOnce(createActorProfile({
reference_asset_ids: ['50'],
anchor_asset_id: 50n
}));
tx.actorProfile.upsert.mockResolvedValueOnce(createActorProfile({
reference_asset_ids: ['50', '51'],
anchor_asset_id: 50n
}));
const result = await service.setCharacterFacePack(user, '20', {
asset_ids: ['51'],
consent_confirmed: true
});
expect(tx.characterImage.create).toHaveBeenCalledWith({
data: expect.objectContaining({
project_id: 10n,
character_id: 20n,
asset_id: 51n,
image_type: 'face_reference',
is_anchor: false,
status: 'generated'
})
});
expect(tx.character.update).not.toHaveBeenCalled();
expect(tx.actorProfile.upsert).toHaveBeenCalledWith({
where: {
project_id_character_id: {
project_id: 10n,
character_id: 20n
}
},
update: expect.objectContaining({
reference_asset_ids: ['50', '51'],
anchor_asset_id: 50n,
status: 'locked'
}),
create: expect.objectContaining({
project_id: 10n,
character_id: 20n,
reference_asset_ids: ['50', '51'],
anchor_asset_id: 50n,
status: 'locked'
})
});
expect(result.face_reference_count).toBe(1);
expect(result.anchor_asset_id).toBeNull();
expect(result.next_step).toBe('generate_character_anchor');
});
it('generates a preview image for a confirmed storyboard shot', async () => { it('generates a preview image for a confirmed storyboard shot', async () => {
const result = await service.generateShotImage(user, '40', { const result = await service.generateShotImage(user, '40', {
image_type: 'preview' image_type: 'preview'
File diff suppressed because it is too large Load Diff
@@ -277,6 +277,17 @@ async function runProviderAcceptance(input: {
force: input.config.forceRegenerate, force: input.config.forceRegenerate,
max_cost_per_clip: input.config.maxCostPerClip max_cost_per_clip: input.config.maxCostPerClip
}); });
if (!generated.video_clip) {
return finishRow(
baseRow,
'skipped',
generated.pending_task
? `Provider task is still running: ${generated.pending_task.id}`
: 'Provider task is still running'
);
}
baseRow.clip_id = generated.video_clip.id; baseRow.clip_id = generated.video_clip.id;
baseRow.output_asset_id = generated.video_clip.output_asset_id; baseRow.output_asset_id = generated.video_clip.output_asset_id;
baseRow.cost_actual = generated.video_clip.cost_actual; baseRow.cost_actual = generated.video_clip.cost_actual;
@@ -8,7 +8,9 @@ import {
LiveActionGenerateDto, LiveActionGenerateDto,
LiveActionManualReviewDto, LiveActionManualReviewDto,
LiveActionPreflightQueryDto, LiveActionPreflightQueryDto,
LiveActionQualityCheckDto LiveActionQualityCheckDto,
LiveActionShotAssetPlanQueryDto,
LiveActionUpdateShotPromptDto
} from './live-action.dto'; } from './live-action.dto';
import { LiveActionService } from './live-action.service'; import { LiveActionService } from './live-action.service';
@@ -36,6 +38,33 @@ export class LiveActionController {
return this.liveActionService.listLiveActionShots(user, episodeId); return this.liveActionService.listLiveActionShots(user, episodeId);
} }
@Get('episodes/:episodeId/live-action/shot-asset-plans')
planLiveActionShotAssets(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string,
@Query() query: LiveActionShotAssetPlanQueryDto
) {
return this.liveActionService.planLiveActionShotAssets(user, episodeId, query);
}
@Get('episodes/:episodeId/live-action/generation-plans')
listGenerationPlans(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string
) {
return this.liveActionService.listGenerationPlans(user, episodeId);
}
@Get('episodes/:episodeId/live-action/generation-plans/compare')
compareGenerationPlans(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string,
@Query('base_plan_id') basePlanId: string,
@Query('target_plan_id') targetPlanId: string
) {
return this.liveActionService.compareGenerationPlans(user, episodeId, basePlanId, targetPlanId);
}
@Post('episodes/:episodeId/live-action/shots/prepare') @Post('episodes/:episodeId/live-action/shots/prepare')
prepareLiveActionShots( prepareLiveActionShots(
@CurrentUser() user: AuthRequestUser, @CurrentUser() user: AuthRequestUser,
@@ -92,6 +121,46 @@ export class LiveActionController {
return this.liveActionService.attachShotKeyframe(user, episodeId, shotId, dto); return this.liveActionService.attachShotKeyframe(user, episodeId, shotId, dto);
} }
@Post('episodes/:episodeId/live-action/shots/:shotId/keyframe/generate')
generateShotKeyframe(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string,
@Param('shotId') shotId: string,
@Body() dto: LiveActionGenerateDto
) {
return this.liveActionService.generateShotKeyframe(user, episodeId, shotId, dto);
}
@Post('episodes/:episodeId/live-action/shots/:shotId/keyframe/approve')
approveShotKeyframe(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string,
@Param('shotId') shotId: string,
@Body() dto: LiveActionGenerateDto
) {
return this.liveActionService.approveShotKeyframe(user, episodeId, shotId, dto);
}
@Post('episodes/:episodeId/live-action/shots/:shotId/generation-plan/freeze')
freezeShotGenerationPlan(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string,
@Param('shotId') shotId: string,
@Body() dto: LiveActionGenerateDto
) {
return this.liveActionService.freezeShotGenerationPlan(user, episodeId, shotId, dto);
}
@Post('episodes/:episodeId/live-action/shots/:shotId/prompt')
updateShotPrompt(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string,
@Param('shotId') shotId: string,
@Body() dto: LiveActionUpdateShotPromptDto
) {
return this.liveActionService.updateShotPrompt(user, episodeId, shotId, dto);
}
@Post('episodes/:episodeId/live-action/shots/:shotId/video-clip/generate') @Post('episodes/:episodeId/live-action/shots/:shotId/video-clip/generate')
generateShotVideoClip( generateShotVideoClip(
@CurrentUser() user: AuthRequestUser, @CurrentUser() user: AuthRequestUser,
@@ -151,4 +220,13 @@ export class LiveActionController {
) { ) {
return this.liveActionService.renderLiveActionEpisode(user, episodeId, dto); return this.liveActionService.renderLiveActionEpisode(user, episodeId, dto);
} }
@Post('episodes/:episodeId/live-action/music/generate')
generateOriginalMusicPackage(
@CurrentUser() user: AuthRequestUser,
@Param('episodeId') episodeId: string,
@Body() dto: LiveActionGenerateDto
) {
return this.liveActionService.generateOriginalMusicPackage(user, episodeId, dto);
}
} }
+35 -1
View File
@@ -2,32 +2,52 @@ export class LiveActionGenerateDto {
force?: boolean; force?: boolean;
only_missing?: boolean; only_missing?: boolean;
provider_code?: string; provider_code?: string;
image_provider_code?: string;
image_quality?: 'low' | 'medium' | 'high' | string;
text_provider_code?: string;
render_title?: string;
resolution?: string;
aspect_ratio?: string;
confirm_real_video?: boolean; confirm_real_video?: boolean;
allow_seedance_real_person_test?: boolean | string;
allow_character_reference_fallback?: boolean | string;
generate_audio?: boolean | string;
max_cost_per_clip?: number | string | null; max_cost_per_clip?: number | string | null;
candidate_count?: number | string | null; candidate_count?: number | string | null;
shot_id?: string; shot_id?: string;
include_source_audio?: boolean | string;
include_audio?: boolean; include_audio?: boolean;
include_subtitle?: boolean; include_subtitle?: boolean;
include_bgm?: boolean; include_bgm?: boolean;
include_sfx?: boolean; include_sfx?: boolean;
include_ambient_sfx?: boolean | string;
fallback_sfx_when_no_source_audio?: boolean | string;
include_lip_sync?: boolean; include_lip_sync?: boolean;
audio_text_mode?: 'auto' | 'dialogue' | 'title';
lip_sync_max_seconds?: number | string | null; lip_sync_max_seconds?: number | string | null;
voice?: string; voice?: string;
voice_provider_code?: string; voice_provider_code?: string;
lip_sync_provider_code?: string; lip_sync_provider_code?: string;
subtitle_mode?: 'dialogue' | 'shot'; music_provider_code?: string;
subtitle_mode?: 'auto' | 'all' | 'dialogue' | 'shot' | 'title';
max_chars_per_line?: number | string | null; max_chars_per_line?: number | string | null;
bgm_asset_id?: string; bgm_asset_id?: string;
bgm_volume?: number | string | null; bgm_volume?: number | string | null;
sfx_volume?: number | string | null; sfx_volume?: number | string | null;
action_beat_mode?: boolean | string; action_beat_mode?: boolean | string;
action_beat_count?: number | string | null; action_beat_count?: number | string | null;
reference_image_limit?: number | string | null;
soft_stitch?: boolean | string;
previous_shot_tail_reference?: boolean | string;
chain_previous_shot_tail?: boolean | string;
} }
export class LiveActionQualityCheckDto { export class LiveActionQualityCheckDto {
auto_repair?: boolean; auto_repair?: boolean;
min_quality_score?: number | string | null; min_quality_score?: number | string | null;
confirm_real_video?: boolean; confirm_real_video?: boolean;
allow_seedance_real_person_test?: boolean | string;
allow_character_reference_fallback?: boolean | string;
max_cost_per_clip?: number | string | null; max_cost_per_clip?: number | string | null;
} }
@@ -35,9 +55,18 @@ export class LiveActionCostEstimateQueryDto {
provider_code?: string; provider_code?: string;
} }
export class LiveActionShotAssetPlanQueryDto {
provider_code?: string;
}
export class LiveActionPreflightQueryDto { export class LiveActionPreflightQueryDto {
provider_code?: string; provider_code?: string;
resolution?: string;
aspect_ratio?: string;
confirm_real_video?: boolean | string; confirm_real_video?: boolean | string;
allow_seedance_real_person_test?: boolean | string;
allow_character_reference_fallback?: boolean | string;
generate_audio?: boolean | string;
max_cost_per_clip?: number | string | null; max_cost_per_clip?: number | string | null;
shot_id?: string; shot_id?: string;
action_beat_mode?: boolean | string; action_beat_mode?: boolean | string;
@@ -48,6 +77,11 @@ export class LiveActionAttachKeyframeDto {
asset_id?: string; asset_id?: string;
} }
export class LiveActionUpdateShotPromptDto {
live_action_desc?: string;
video_prompt?: string;
}
export class LiveActionManualReviewDto { export class LiveActionManualReviewDto {
result_status?: string; result_status?: string;
reason?: string; reason?: string;
@@ -2,6 +2,8 @@ import { forwardRef, Module } from '@nestjs/common';
import { AiRouterModule } from '../ai-router/ai-router.module'; import { AiRouterModule } from '../ai-router/ai-router.module';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { AssetsModule } from '../assets/assets.module'; import { AssetsModule } from '../assets/assets.module';
import { GenerationPlanModule } from '../generation-plans/generation-plan.module';
import { ModelRegistryModule } from '../model-registry/model-registry.module';
import { PrismaModule } from '../prisma/prisma.module'; import { PrismaModule } from '../prisma/prisma.module';
import { ProvidersModule } from '../providers/providers.module'; import { ProvidersModule } from '../providers/providers.module';
import { QueuesModule } from '../queues/queues.module'; import { QueuesModule } from '../queues/queues.module';
@@ -10,7 +12,7 @@ import { LiveActionService } from './live-action.service';
import { LiveActionPromptBuilderService } from './prompt-builder.service'; import { LiveActionPromptBuilderService } from './prompt-builder.service';
@Module({ @Module({
imports: [AiRouterModule, AuthModule, AssetsModule, PrismaModule, ProvidersModule, forwardRef(() => QueuesModule)], imports: [AiRouterModule, AuthModule, AssetsModule, GenerationPlanModule, ModelRegistryModule, PrismaModule, ProvidersModule, forwardRef(() => QueuesModule)],
controllers: [LiveActionController], controllers: [LiveActionController],
providers: [LiveActionService, LiveActionPromptBuilderService], providers: [LiveActionService, LiveActionPromptBuilderService],
exports: [LiveActionService, LiveActionPromptBuilderService] exports: [LiveActionService, LiveActionPromptBuilderService]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+68 -1
View File
@@ -1,4 +1,4 @@
import type { ActorProfile, Prisma, StoryboardShot, VideoClip } from '@prisma/client'; import type { ActorProfile, Prisma, ProviderConfig, StoryboardShot, VideoClip } from '@prisma/client';
export interface SafeActorProfile { export interface SafeActorProfile {
id: string; id: string;
@@ -21,16 +21,22 @@ export interface SafeLiveActionShot {
project_id: string; project_id: string;
episode_id: string; episode_id: string;
shot_no: number; shot_no: number;
active_generation_plan_id: string | null;
scene_name: string | null; scene_name: string | null;
live_action_desc: string | null; live_action_desc: string | null;
actor_action: string | null; actor_action: string | null;
camera_instruction: string | null; camera_instruction: string | null;
performance_instruction: string | null; performance_instruction: string | null;
transition_to_next: string | null;
transition_to_next_duration: string | null;
transition_to_next_reason: string | null;
scene_type: string | null; scene_type: string | null;
importance_score: number | null; importance_score: number | null;
emotion_score: number | null; emotion_score: number | null;
action_score: number | null; action_score: number | null;
route_tier: string | null; route_tier: string | null;
generation_strategy_mode: 'text_only' | 'first_frame' | 'first_last_frame' | 'multi_reference' | null;
scene_geography_version_id: string | null;
video_prompt: string | null; video_prompt: string | null;
keyframe_asset_id: string | null; keyframe_asset_id: string | null;
video_clip_asset_id: string | null; video_clip_asset_id: string | null;
@@ -45,17 +51,28 @@ export interface SafeVideoClip {
project_id: string; project_id: string;
episode_id: string; episode_id: string;
shot_id: string; shot_id: string;
generation_plan_id: string | null;
provider_id: string | null; provider_id: string | null;
provider_code: string | null;
provider_name: string | null;
model_name: string | null;
input_asset_id: string | null; input_asset_id: string | null;
output_asset_id: string | null; output_asset_id: string | null;
duration: string | null; duration: string | null;
prompt_text: string | null; prompt_text: string | null;
status: string; status: string;
cost_estimate: number | null;
cost_currency: string | null;
cost_actual: number | null; cost_actual: number | null;
retry_count: number; retry_count: number;
quality_status: string | null; quality_status: string | null;
quality_score: number | null; quality_score: number | null;
quality_issues: Prisma.JsonValue | null; quality_issues: Prisma.JsonValue | null;
engine_version: string | null;
asset_lock_json: Prisma.JsonValue | null;
motion_control_json: Prisma.JsonValue | null;
camera_control_json: Prisma.JsonValue | null;
composer_usage_json: Prisma.JsonValue | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -79,21 +96,51 @@ export function toSafeActorProfile(profile: ActorProfile): SafeActorProfile {
} }
export function toSafeLiveActionShot(shot: StoryboardShot): SafeLiveActionShot { export function toSafeLiveActionShot(shot: StoryboardShot): SafeLiveActionShot {
let generationStrategyMode: SafeLiveActionShot['generation_strategy_mode'] = null;
let sceneGeographyVersionId: string | null = null;
if (shot.prompt_text) {
try {
const parsed = JSON.parse(shot.prompt_text) as Record<string, unknown>;
const spec = parsed.shot_execution_spec && typeof parsed.shot_execution_spec === 'object'
? parsed.shot_execution_spec as Record<string, unknown>
: null;
const strategy = spec?.generation_strategy && typeof spec.generation_strategy === 'object'
? spec.generation_strategy as Record<string, unknown>
: null;
const mode = typeof strategy?.mode === 'string' ? strategy.mode : '';
if (['text_only', 'first_frame', 'first_last_frame', 'multi_reference'].includes(mode)) {
generationStrategyMode = mode as SafeLiveActionShot['generation_strategy_mode'];
}
sceneGeographyVersionId = typeof spec?.scene_geography_version_id === 'string'
? spec.scene_geography_version_id
: null;
} catch {
generationStrategyMode = null;
sceneGeographyVersionId = null;
}
}
return { return {
id: shot.id.toString(), id: shot.id.toString(),
project_id: shot.project_id.toString(), project_id: shot.project_id.toString(),
episode_id: shot.episode_id.toString(), episode_id: shot.episode_id.toString(),
shot_no: shot.shot_no, shot_no: shot.shot_no,
active_generation_plan_id: shot.active_generation_plan_id?.toString() ?? null,
scene_name: shot.scene_name, scene_name: shot.scene_name,
live_action_desc: shot.live_action_desc, live_action_desc: shot.live_action_desc,
actor_action: shot.actor_action, actor_action: shot.actor_action,
camera_instruction: shot.camera_instruction, camera_instruction: shot.camera_instruction,
performance_instruction: shot.performance_instruction, performance_instruction: shot.performance_instruction,
transition_to_next: shot.transition_to_next,
transition_to_next_duration: shot.transition_to_next_duration?.toString() ?? null,
transition_to_next_reason: shot.transition_to_next_reason,
scene_type: shot.scene_type, scene_type: shot.scene_type,
importance_score: shot.importance_score, importance_score: shot.importance_score,
emotion_score: shot.emotion_score, emotion_score: shot.emotion_score,
action_score: shot.action_score, action_score: shot.action_score,
route_tier: shot.route_tier, route_tier: shot.route_tier,
generation_strategy_mode: generationStrategyMode,
scene_geography_version_id: sceneGeographyVersionId,
video_prompt: shot.video_prompt, video_prompt: shot.video_prompt,
keyframe_asset_id: shot.keyframe_asset_id?.toString() ?? null, keyframe_asset_id: shot.keyframe_asset_id?.toString() ?? null,
video_clip_asset_id: shot.video_clip_asset_id?.toString() ?? null, video_clip_asset_id: shot.video_clip_asset_id?.toString() ?? null,
@@ -105,22 +152,42 @@ export function toSafeLiveActionShot(shot: StoryboardShot): SafeLiveActionShot {
} }
export function toSafeVideoClip(clip: VideoClip): SafeVideoClip { export function toSafeVideoClip(clip: VideoClip): SafeVideoClip {
return toSafeVideoClipWithMetadata(clip, null, null, null);
}
export function toSafeVideoClipWithMetadata(
clip: VideoClip,
provider?: Pick<ProviderConfig, 'provider_code' | 'display_name' | 'model_name'> | null,
costEstimate?: number | null,
costCurrency?: string | null
): SafeVideoClip {
return { return {
id: clip.id.toString(), id: clip.id.toString(),
project_id: clip.project_id.toString(), project_id: clip.project_id.toString(),
episode_id: clip.episode_id.toString(), episode_id: clip.episode_id.toString(),
shot_id: clip.shot_id.toString(), shot_id: clip.shot_id.toString(),
generation_plan_id: clip.generation_plan_id?.toString() ?? null,
provider_id: clip.provider_id?.toString() ?? null, provider_id: clip.provider_id?.toString() ?? null,
provider_code: provider?.provider_code ?? null,
provider_name: provider?.display_name ?? null,
model_name: provider?.model_name ?? null,
input_asset_id: clip.input_asset_id?.toString() ?? null, input_asset_id: clip.input_asset_id?.toString() ?? null,
output_asset_id: clip.output_asset_id?.toString() ?? null, output_asset_id: clip.output_asset_id?.toString() ?? null,
duration: clip.duration?.toString() ?? null, duration: clip.duration?.toString() ?? null,
prompt_text: clip.prompt_text, prompt_text: clip.prompt_text,
status: clip.status, status: clip.status,
cost_estimate: costEstimate ?? null,
cost_currency: costCurrency ?? null,
cost_actual: clip.cost_actual ? Number(clip.cost_actual.toString()) : null, cost_actual: clip.cost_actual ? Number(clip.cost_actual.toString()) : null,
retry_count: clip.retry_count, retry_count: clip.retry_count,
quality_status: clip.quality_status, quality_status: clip.quality_status,
quality_score: clip.quality_score ? Number(clip.quality_score.toString()) : null, quality_score: clip.quality_score ? Number(clip.quality_score.toString()) : null,
quality_issues: clip.quality_issues, quality_issues: clip.quality_issues,
engine_version: clip.engine_version,
asset_lock_json: clip.asset_lock_json,
motion_control_json: clip.motion_control_json,
camera_control_json: clip.camera_control_json,
composer_usage_json: clip.composer_usage_json,
created_at: clip.created_at.toISOString(), created_at: clip.created_at.toISOString(),
updated_at: clip.updated_at.toISOString() updated_at: clip.updated_at.toISOString()
}; };
@@ -48,23 +48,57 @@ describe('LiveActionPromptBuilderService', () => {
}); });
expect(result.provider_profile).toBe('hailuo'); 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('剪辑目的'); expect(result.prompt).toContain('硬性禁止');
expect(result.prompt).toContain('后期音效提示'); expect(result.prompt).not.toContain('导演分镜');
expect(result.prompt.length).toBeLessThanOrEqual(1800); expect(result.prompt).not.toContain('剪辑目的');
expect(result.prompt.length).toBeLessThanOrEqual(1200);
expect(result.components.sound_cue).toContain('digital shimmer');
expect(result.components).toEqual( expect(result.components).toEqual(
expect.objectContaining({ expect.objectContaining({
prompt_version: 'live-action-prompt-engine-v1', prompt_version: 'live-action-prompt-engine-v5-performance-chain',
provider_profile: 'hailuo', provider_profile: 'hailuo',
scene_type: 'dimensional_break', scene_type: 'dimensional_break',
template_ids: expect.arrayContaining(['scene:dimensional_break:v2']),
rules_applied: expect.arrayContaining([
'course_rule:one_main_action_per_clip',
'provider_rule:hailuo_single_action_simple_camera'
]),
route_tier: 'premium', route_tier: 'premium',
camera_tag: '[推进]' camera_tag: '[推进]'
}) })
); );
expect(result.prompt_version).toBe('live-action-prompt-engine-v5-performance-chain');
expect(result.template_ids).toContain('scene:dimensional_break:v2');
expect(result.rules_applied).toContain('motion_rule:split_or_simplify_complex_action');
expect(result.negative_prompt).toContain('anime style'); expect(result.negative_prompt).toContain('anime style');
}); });
it('maps course urban short-drama scenes into premium scene templates and audit rules', () => {
const result = builder.buildLiveActionVideoPrompt({
providerCode: 'minimax_hailuo_23_fast',
sceneType: 'rich_arrival',
routeTier: 'premium',
durationSeconds: 6,
characters: '顾辰,黑色西装,保持同一张脸',
location: '雨夜酒店门口,一辆黑色迈巴赫停在门前',
action: '管家打开车门,顾辰冷静下车,所有人转头看向他',
scores: {
importance_score: 9,
emotion_score: 7,
action_score: 3,
route_tier: 'premium'
}
});
expect(result.components.scene_type).toBe('rich_arrival');
expect(result.components.template_ids).toContain('scene:rich_arrival:v2');
expect(result.components.rules_applied).toContain('urban_short_drama_rule:premium_asset_or_reveal_beat');
expect(result.prompt).toContain('迈巴赫');
expect(result.negative_prompt).toContain('cheap luxury prop');
});
it('keeps high-risk dialogue away from frontal mouth close-ups when lip-sync falls back to TTS subtitles', () => { it('keeps high-risk dialogue away from frontal mouth close-ups when lip-sync falls back to TTS subtitles', () => {
const result = builder.buildLiveActionVideoPrompt({ const result = builder.buildLiveActionVideoPrompt({
providerCode: 'kling_21', providerCode: 'kling_21',
@@ -85,8 +119,7 @@ describe('LiveActionPromptBuilderService', () => {
}); });
expect(result.provider_profile).toBe('kling'); expect(result.provider_profile).toBe('kling');
expect(result.prompt).toContain('medium shot, three-quarter angle'); expect(result.prompt).toContain('不要正面嘴部大特写');
expect(result.prompt).toContain('post_tts_subtitle_light_mouth');
expect(result.negative_prompt).toContain('frontal mouth close-up'); expect(result.negative_prompt).toContain('frontal mouth close-up');
}); });
@@ -109,12 +142,10 @@ describe('LiveActionPromptBuilderService', () => {
}); });
expect(result.provider_profile).toBe('hailuo'); 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('核心动作');
expect(result.prompt).toContain('食指中指并拢'); expect(result.prompt).toContain('硬性禁止');
expect(result.prompt).toContain('lotus seal'); expect(result.components.motion_director?.vfx_timing).toContain('紫色光球');
expect(result.prompt).toContain('紫色光球');
expect(result.negative_prompt).toContain('random hand waving'); expect(result.negative_prompt).toContain('random hand waving');
expect(result.components.motion_director).toEqual( expect(result.components.motion_director).toEqual(
expect.objectContaining({ expect.objectContaining({
@@ -138,10 +169,9 @@ describe('LiveActionPromptBuilderService', () => {
effectType: '法相天地 千臂法身 重低音轰鸣 碎石粉化' effectType: '法相天地 千臂法身 重低音轰鸣 碎石粉化'
}); });
expect(result.prompt).toContain('Motion director'); expect(result.prompt).toContain('One action only');
expect(result.prompt).toContain('千臂法身'); expect(result.prompt).toContain('千臂法身');
expect(result.prompt).toContain('巨手依次结出不同仙印'); expect(result.prompt).toContain('Hard avoid');
expect(result.prompt).toContain('贴地低角度仰拍');
expect(result.negative_prompt).toContain('tiny dharma body'); expect(result.negative_prompt).toContain('tiny dharma body');
expect(result.components.motion_director?.time_beats.join(' ')).toContain('千只巨手'); expect(result.components.motion_director?.time_beats.join(' ')).toContain('千只巨手');
}); });
@@ -165,15 +195,51 @@ describe('LiveActionPromptBuilderService', () => {
}); });
expect(result.provider_profile).toBe('hailuo'); expect(result.provider_profile).toBe('hailuo');
expect(result.prompt).toContain('10秒'); expect(result.components.duration_seconds).toBe(10);
expect(result.prompt).toContain('一镜到底动作'); expect(result.prompt).toContain('核心动作');
expect(result.prompt).toContain('0.0-2.0s'); expect(result.prompt).toContain('千臂法身升起');
expect(result.prompt).toContain('3.0-5.0s'); expect(result.prompt.length).toBeLessThanOrEqual(1600);
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('multi-shot montage');
expect(result.negative_prompt).toContain('character identity drift'); expect(result.negative_prompt).toContain('character identity drift');
}); });
it('keeps Seedance native-audio prompts scoped to the current shot without old template pollution', () => {
const result = builder.buildLiveActionVideoPrompt({
projectTitle: '草船借箭:永远差一箭',
episodeNo: 1,
episodeTitle: '系统绑定',
shotNo: 1,
providerCode: 'volcengine_seedance_20_mini',
sceneType: 'dialog',
routeTier: 'premium',
durationSeconds: 10,
characters: '周瑜、诸葛亮',
actorConsistencyRules: '诸葛亮保持羽扇纶巾、白袍、同一张脸;周瑜保持红黑将军甲、同一张脸',
location: '东吴军帐,木案、令箭、烛火、帐帘和古代军事地图',
action: '周瑜拍桌下军令状;诸葛亮听到系统绑定提示后看向画面中上方留白位置,眼神一亮,强行压住笑意,用羽扇半遮嘴角,最后淡定回应周瑜',
visualDescription: '轻喜剧三国短剧,系统弹窗和进度条全部后期合成,画面只留干净构图空区',
cameraMotion: '中景稳定推进到诸葛亮压笑反应',
performanceInstruction: '周瑜杀气腾腾,诸葛亮先愣一下再强行装淡定,眼神发亮但嘴角憋笑',
dialogueText: '周瑜:诸葛亮!三天十万支箭,少一支,军法处置! 系统电子提示:叮!拼夕夕借箭系统绑定成功! 诸葛亮:都督放心,三天太久,今晚足矣。',
effectType: '后期系统弹窗,轻喜剧提示音',
nativeAudioDialogue: true
});
expect(result.provider_profile).toBe('seedance');
expect(result.prompt).toContain('豆包 Seedance 2.0 Mini 图生视频');
expect(result.prompt).toContain('原生对白顺序');
expect(result.prompt).toContain('周瑜:');
expect(result.prompt).toContain('系统电子提示:');
expect(result.prompt).toContain('诸葛亮:');
expect(result.prompt).toContain('不要合并到同一个角色');
expect(result.prompt).toContain('中文普通话对白');
expect(result.prompt).toContain('干净构图空区');
expect(result.prompt).not.toContain('Seedance 2.0 Pro');
expect(result.prompt).not.toContain('直播灯');
expect(result.prompt).not.toContain('宴会现场空间感');
expect(result.prompt).not.toContain('宴会厅');
expect(result.prompt).not.toContain('手机震动');
expect(result.prompt).not.toContain('medium shot');
expect(result.prompt).not.toContain('over-the-shoulder');
});
}); });
File diff suppressed because it is too large Load Diff
+2
View File
@@ -1,6 +1,7 @@
export class GenerateEpisodeAudioDto { export class GenerateEpisodeAudioDto {
voice?: string; voice?: string;
narration_voice?: string; narration_voice?: string;
voice_provider_code?: string;
dialogue_mode?: 'mixed' | 'narration'; dialogue_mode?: 'mixed' | 'narration';
max_segments?: number; max_segments?: number;
force?: boolean; force?: boolean;
@@ -10,6 +11,7 @@ export class RetryEpisodeAudioSegmentDto {
voice?: string; voice?: string;
voice_style?: string; voice_style?: string;
speech_speed?: number | string; speech_speed?: number | string;
voice_provider_code?: string;
} }
export class GenerateEpisodeSubtitleDto { export class GenerateEpisodeSubtitleDto {
+34 -10
View File
@@ -19,6 +19,7 @@ import type {
import { Prisma as PrismaNamespace } from '@prisma/client'; import { Prisma as PrismaNamespace } from '@prisma/client';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { execFile } from 'node:child_process'; import { execFile } from 'node:child_process';
import { existsSync } from 'node:fs';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
@@ -26,6 +27,7 @@ import { promisify } from 'node:util';
import type { AuthRequestUser } from '../auth/auth.types'; import type { AuthRequestUser } from '../auth/auth.types';
import { StorageService } from '../assets/storage.service'; import { StorageService } from '../assets/storage.service';
import { toSafeAsset } from '../assets/asset.types'; import { toSafeAsset } from '../assets/asset.types';
import { PRODUCTION_VIDEO_HEIGHT, PRODUCTION_VIDEO_WIDTH } from '../common/production-format';
import { BillingService } from '../billing/billing.service'; import { BillingService } from '../billing/billing.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { ProvidersService } from '../providers/providers.service'; import { ProvidersService } from '../providers/providers.service';
@@ -40,8 +42,15 @@ import { toSafeMediaTaskResult, type SrtCue } from './media.types';
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
const DEFAULT_AUDIO_VOICE = 'coral'; const DEFAULT_AUDIO_VOICE = 'coral';
const VIDEO_WIDTH = 1080; const VIDEO_WIDTH = PRODUCTION_VIDEO_WIDTH;
const VIDEO_HEIGHT = 1920; const VIDEO_HEIGHT = PRODUCTION_VIDEO_HEIGHT;
const SUBTITLE_FONT_DIR_CANDIDATES = [
process.env.SUBTITLE_FONT_DIR,
'/usr/share/fonts/google-noto-cjk',
'/usr/share/fonts/opentype/noto',
'/usr/share/fonts/truetype/noto',
'/usr/share/fonts'
].filter((value): value is string => Boolean(value));
interface AudioDialogueSegment { interface AudioDialogueSegment {
index: number; index: number;
@@ -186,6 +195,7 @@ export class MediaService {
}, },
[ [
{ {
preferred_provider_code: dto.voice_provider_code?.trim() || undefined,
purpose: `episode-${episode.id.toString()}-tts`, purpose: `episode-${episode.id.toString()}-tts`,
input_json: this.createAudioSegmentProviderInput(narrationSegment) input_json: this.createAudioSegmentProviderInput(narrationSegment)
} }
@@ -472,7 +482,8 @@ export class MediaService {
...targetSegment, ...targetSegment,
voice: dto.voice?.trim() || targetSegment.voice, voice: dto.voice?.trim() || targetSegment.voice,
voice_style: dto.voice_style?.trim() || targetSegment.voice_style, voice_style: dto.voice_style?.trim() || targetSegment.voice_style,
speech_speed: this.normalizeSpeechSpeed(dto.speech_speed, targetSegment.speech_speed) speech_speed: this.normalizeSpeechSpeed(dto.speech_speed, targetSegment.speech_speed),
voice_provider_code: dto.voice_provider_code?.trim() || targetSegment.voice_provider_code
}; };
const task = await this.createRenderTask( const task = await this.createRenderTask(
project.id, project.id,
@@ -733,7 +744,16 @@ export class MediaService {
const tasks = await this.prisma.renderTask.findMany({ const tasks = await this.prisma.renderTask.findMany({
where: { where: {
episode_id: episode.id, episode_id: episode.id,
task_type: { in: ['audio_generate', 'subtitle_generate', 'video_render'] }, task_type: {
in: [
'audio_generate',
'subtitle_generate',
'video_render',
'live_action_audio_generate',
'live_action_subtitle_generate',
'live_action_video_render'
]
},
output_asset_id: { not: null } output_asset_id: { not: null }
}, },
orderBy: { created_at: 'desc' } orderBy: { created_at: 'desc' }
@@ -761,7 +781,7 @@ export class MediaService {
} }
private async createMediaTimeline(task: RenderTask, asset: Asset) { private async createMediaTimeline(task: RenderTask, asset: Asset) {
if (task.task_type === 'audio_generate') { if (task.task_type === 'audio_generate' || task.task_type === 'live_action_audio_generate') {
const input = this.jsonObject(task.input_json); const input = this.jsonObject(task.input_json);
return { return {
@@ -771,7 +791,7 @@ export class MediaService {
}; };
} }
if (task.task_type === 'subtitle_generate') { if (task.task_type === 'subtitle_generate' || task.task_type === 'live_action_subtitle_generate') {
return { return {
type: 'subtitle', type: 'subtitle',
cues: await this.readSubtitleCuesFromAsset(asset) cues: await this.readSubtitleCuesFromAsset(asset)
@@ -784,7 +804,7 @@ export class MediaService {
private createMediaTaskStats(task: RenderTask, asset: Asset) { private createMediaTaskStats(task: RenderTask, asset: Asset) {
const input = this.jsonObject(task.input_json); const input = this.jsonObject(task.input_json);
if (task.task_type === 'audio_generate') { if (task.task_type === 'audio_generate' || task.task_type === 'live_action_audio_generate') {
const segments = this.readTaskAudioSegments(input); const segments = this.readTaskAudioSegments(input);
const totalCharacters = const totalCharacters =
this.numberFromUnknown(input.estimated_tts_characters) || this.numberFromUnknown(input.estimated_tts_characters) ||
@@ -811,7 +831,7 @@ export class MediaService {
}; };
} }
if (task.task_type === 'subtitle_generate') { if (task.task_type === 'subtitle_generate' || task.task_type === 'live_action_subtitle_generate') {
return { return {
subtitle_mode: this.stringifyText(input.subtitle_mode) || 'shot', subtitle_mode: this.stringifyText(input.subtitle_mode) || 'shot',
cue_count: this.numberFromUnknown(input.cue_count), cue_count: this.numberFromUnknown(input.cue_count),
@@ -1343,7 +1363,7 @@ export class MediaService {
speaker_name: speakerName, speaker_name: speakerName,
text: cleanedText.slice(0, 800), text: cleanedText.slice(0, 800),
voice, voice,
voice_provider_code: character?.voice_provider_code ?? null, voice_provider_code: (dto.voice_provider_code?.trim() || character?.voice_provider_code) ?? null,
voice_model: character?.voice_model ?? null, voice_model: character?.voice_model ?? null,
voice_style: voiceStyle, voice_style: voiceStyle,
speech_speed: speechSpeed, speech_speed: speechSpeed,
@@ -2510,10 +2530,14 @@ export class MediaService {
private subtitleFilter(subtitlePath: string) { private subtitleFilter(subtitlePath: string) {
return [ return [
`subtitles=${this.escapeFfmpegFilterPath(subtitlePath)}`, `subtitles=${this.escapeFfmpegFilterPath(subtitlePath)}`,
'fontsdir=/usr/share/fonts/google-noto-cjk' `fontsdir=${this.escapeFfmpegFilterPath(this.subtitleFontDir())}`
].join(':'); ].join(':');
} }
private subtitleFontDir() {
return SUBTITLE_FONT_DIR_CANDIDATES.find((dir) => existsSync(dir)) ?? '/usr/share/fonts';
}
private async writeAssSubtitleForFfmpeg(tempDir: string, subtitlePath: string) { private async writeAssSubtitleForFfmpeg(tempDir: string, subtitlePath: string) {
const content = await readFile(subtitlePath, 'utf8'); const content = await readFile(subtitlePath, 'utf8');
const cues = this.parseSrtContent(content); const cues = this.parseSrtContent(content);
+9 -1
View File
@@ -48,7 +48,7 @@ export class MemoriesController {
@Param('projectId') projectId: string, @Param('projectId') projectId: string,
@Body() dto: GeneratePlotMemoriesDto @Body() dto: GeneratePlotMemoriesDto
) { ) {
return this.memoriesService.generatePlotMemories(user, projectId, dto); return this.memoriesService.submitPlotMemoryGeneration(user, projectId, dto);
} }
@Post('projects/:projectId/plot-memories') @Post('projects/:projectId/plot-memories')
@@ -86,6 +86,14 @@ export class MemoriesController {
return this.memoriesService.listCharacterMemories(user, characterId); return this.memoriesService.listCharacterMemories(user, characterId);
} }
@Get('projects/:projectId/character-memories')
listProjectCharacterMemories(
@CurrentUser() user: AuthRequestUser,
@Param('projectId') projectId: string
) {
return this.memoriesService.listProjectCharacterMemories(user, projectId);
}
@Get('projects/:projectId/plot-threads') @Get('projects/:projectId/plot-threads')
listPlotThreads( listPlotThreads(
@CurrentUser() user: AuthRequestUser, @CurrentUser() user: AuthRequestUser,
+2 -1
View File
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { PrismaModule } from '../prisma/prisma.module'; import { PrismaModule } from '../prisma/prisma.module';
import { ProvidersModule } from '../providers/providers.module';
import { MemoriesController } from './memories.controller'; import { MemoriesController } from './memories.controller';
import { MemoriesService } from './memories.service'; import { MemoriesService } from './memories.service';
@Module({ @Module({
imports: [AuthModule, PrismaModule], imports: [AuthModule, PrismaModule, ProvidersModule],
controllers: [MemoriesController], controllers: [MemoriesController],
providers: [MemoriesService], providers: [MemoriesService],
exports: [MemoriesService] exports: [MemoriesService]

Some files were not shown because too many files have changed in this diff Show More