feat: 同步质量校验修复并精简工作台操作
This commit is contained in:
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
短剧 Agent 的前端工作台。支持剧本创作、拆解、视觉风格、角色选角、主体形态、分镜设计、镜头首帧与视频生产,使用真实后端 API,不包含演示数据或浏览器端模型调用。
|
短剧 Agent 的前端工作台。支持剧本创作、拆解、视觉风格、角色选角、主体形态、分镜设计、镜头首帧与视频生产,使用真实后端 API,不包含演示数据或浏览器端模型调用。
|
||||||
|
|
||||||
|
## 最新同步(2026-09-03)
|
||||||
|
|
||||||
|
- 对齐后端 `dev@05bb9e8b7e0a0e85303b16eb3a2ac634e01dd7eb`:图片/视频模型由后端统一配置,前端不再发送固定 Provider。
|
||||||
|
- 新增首帧视觉校验、有限自动修复、当前集小批量质量生成、视频抽样帧质量检查、视频修复候选与复检晋升、只读模型能力查询。默认不调用模型,付费操作需确认,结果可定位和导出。
|
||||||
|
- 保留主要布局和业务能力,导出/执行记录/提示词等次要入口收进菜单,长介绍和高级规则按需展开;关键错误、过期、未保存与费用提示继续直显。
|
||||||
|
|
||||||
|
入口和边界见 [前端简化与质量功能同步](docs/frontend-simplification.md),接口覆盖见 [后端功能覆盖说明](docs/backend-coverage.md)。
|
||||||
|
|
||||||
## 启动
|
## 启动
|
||||||
|
|
||||||
需要 Node.js >= 22.18 和 pnpm 11。
|
需要 Node.js >= 22.18 和 pnpm 11。
|
||||||
@@ -33,13 +41,13 @@ API_PROXY_TARGET=http://localhost:3412
|
|||||||
| 拆解恢复 | 失败抽取重试、缺失剧集镜头补齐、分镜引用与 Form 绑定修复 |
|
| 拆解恢复 | 失败抽取重试、缺失剧集镜头补齐、分镜引用与 Form 绑定修复 |
|
||||||
| 视觉风格 | AI 生成、人工编辑、分类 Prompt、JSON 硬约束、锁定;风格参考图地址登记、启停和删除 |
|
| 视觉风格 | AI 生成、人工编辑、分类 Prompt、JSON 硬约束、锁定;风格参考图地址登记、启停和删除 |
|
||||||
| 主体身份 | 正式主体目录、单个/批量身份文本、人工编辑与锁定;无形态 Character 也可先完成身份 |
|
| 主体身份 | 正式主体目录、单个/批量身份文本、人工编辑与锁定;无形态 Character 也可先完成身份 |
|
||||||
| 角色选角 | 选角就绪检查、角色身份专用批量生成、Seedream 候选小批量生成、确认演员并原子锁定 Identity |
|
| 角色选角 | 选角就绪检查、角色身份专用批量生成、后端图片模型 候选小批量生成、确认演员并原子锁定 Identity |
|
||||||
| 身份母版 | 普通身份图与辅助视角、实际 Prompt 和来源追溯;Character 母版通过确认选角确定 |
|
| 身份母版 | 普通身份图与辅助视角、实际 Prompt 和来源追溯;Character 母版通过确认选角确定 |
|
||||||
| 分镜检查 | 按剧集、Beat、正式 Shot ID 浏览;导演设计、环境与主体即时状态;单集覆盖率与 JSON 导出 |
|
| 分镜检查 | 按剧集、Beat、正式 Shot ID 浏览;导演设计、环境与主体即时状态;单集覆盖率与 JSON 导出 |
|
||||||
| 分镜生成 | Direction / VisualState 单集生成、项目批量补齐、覆盖重生成;并发和自动修复次数;部分失败诊断 |
|
| 分镜生成 | Direction / VisualState 单集生成、项目批量补齐、覆盖重生成;并发和自动修复次数;部分失败诊断 |
|
||||||
| 镜头工具 | 主体参考图与缺失原因、确定性 GenerationSpec 查询与导出、单镜头/批量视频提示词 |
|
| 镜头工具 | 主体参考图与缺失原因、确定性 GenerationSpec 查询与导出、单镜头/批量视频提示词 |
|
||||||
| 形态图片 | 正式形态图库、主图/候选图预览、图片历史与失败记录、Seedream 单图/批量生图、切换主参考图 |
|
| 形态图片 | 正式形态图库、主图/候选图预览、图片历史与失败记录、后端图片模型 单图/批量生图、切换主参考图 |
|
||||||
| 镜头生产 | Prompt/首帧/视频就绪诊断、Seedream 首帧候选、Seedance 任务、成片播放、主资产切换与失败重试 |
|
| 镜头生产 | Prompt/首帧/视频就绪诊断、后端图片模型 首帧候选、后端视频模型 任务、成片播放、主资产切换与失败重试 |
|
||||||
|
|
||||||
尚未实现:手工编辑剧本或分镜设计(后端暂无对应写接口)、用户登录。模型输出按纯文本显示,避免执行不可信 HTML。图片和视频只接受 HTTP(S) 或后端 `/storage/` 路径。
|
尚未实现:手工编辑剧本或分镜设计(后端暂无对应写接口)、用户登录。模型输出按纯文本显示,避免执行不可信 HTML。图片和视频只接受 HTTP(S) 或后端 `/storage/` 路径。
|
||||||
|
|
||||||
@@ -169,15 +177,15 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
|
|||||||
| POST | `/projects/:id/character-identities/generate` | 只批量生成 Character Identity 文本 |
|
| POST | `/projects/:id/character-identities/generate` | 只批量生成 Character Identity 文本 |
|
||||||
| GET | `/projects/:id/character-casting/readiness` | 角色身份、候选、Anchor 与锁定状态汇总 |
|
| GET | `/projects/:id/character-casting/readiness` | 角色身份、候选、Anchor 与锁定状态汇总 |
|
||||||
| POST | `/projects/:id/character-casting/candidates/generate` | 为缺少 Anchor 的角色小批量生成停用候选 |
|
| POST | `/projects/:id/character-casting/candidates/generate` | 为缺少 Anchor 的角色小批量生成停用候选 |
|
||||||
| GET / POST | `/subjects/:subjectId/identity/images` | 查询带 `isAnchor` 的身份图片/Seedream 生图 |
|
| GET / POST | `/subjects/:subjectId/identity/images` | 查询带 `isAnchor` 的身份图片/后端图片模型 生图 |
|
||||||
| POST | `/subjects/:subjectId/identity/casting-candidates` | 单角色生成 primary 选角候选,不自动成为 Anchor |
|
| POST | `/subjects/:subjectId/identity/casting-candidates` | 单角色生成 primary 选角候选,不自动成为 Anchor |
|
||||||
| PUT | `/subjects/:subjectId/identity/images/:imageId/anchor` | 将成功的 primary 候选设为身份母版 |
|
| PUT | `/subjects/:subjectId/identity/images/:imageId/anchor` | 将成功的 primary 候选设为身份母版 |
|
||||||
| PUT | `/subjects/:subjectId/identity/images/:imageId/casting` | 确认 Character 选角:切换 Anchor 并锁定 Identity |
|
| PUT | `/subjects/:subjectId/identity/images/:imageId/casting` | 确认 Character 选角:切换 Anchor 并锁定 Identity |
|
||||||
| GET | `/projects/:id/video-prompts/readiness` | 项目视频提示词前置条件检查 |
|
| GET | `/projects/:id/video-prompts/readiness` | 项目视频提示词前置条件检查 |
|
||||||
| GET / POST | `/projects/:id/keyframes/readiness`、`/keyframes/generate` | 首帧就绪检查与批量 Seedream 生图 |
|
| GET / POST | `/projects/:id/keyframes/readiness`、`/keyframes/generate` | 首帧就绪检查与批量 后端图片模型 生图 |
|
||||||
| GET / POST | `/storyboard-shots/:shotId/keyframes`、`/keyframe` | 单镜头首帧历史与生图 |
|
| GET / POST | `/storyboard-shots/:shotId/keyframes`、`/keyframe` | 单镜头首帧历史与生图 |
|
||||||
| PUT | `/storyboard-shots/:shotId/keyframes/:keyframeId/primary` | 切换主首帧 |
|
| PUT | `/storyboard-shots/:shotId/keyframes/:keyframeId/primary` | 切换主首帧 |
|
||||||
| GET / POST | `/projects/:id/videos/readiness`、`/videos/generate` | 视频前置检查与批量 Seedance 任务 |
|
| GET / POST | `/projects/:id/videos/readiness`、`/videos/generate` | 视频前置检查与批量 后端视频模型 任务 |
|
||||||
| GET | `/projects/:id/videos/status` | 项目最近视频任务状态 |
|
| GET | `/projects/:id/videos/status` | 项目最近视频任务状态 |
|
||||||
| POST | `/projects/:id/videos/retry-failed` | 重试最近失败的视频任务 |
|
| POST | `/projects/:id/videos/retry-failed` | 重试最近失败的视频任务 |
|
||||||
| GET / POST | `/storyboard-shots/:shotId/videos` | 单镜头视频历史与任务创建 |
|
| GET / POST | `/storyboard-shots/:shotId/videos` | 单镜头视频历史与任务创建 |
|
||||||
@@ -215,7 +223,7 @@ API 层也提供 `state` 与 `breakdown/latest` 方法。页面通过共享 chec
|
|||||||
1. 入口为 `/projects/:projectId/subject-images`;项目导航、拆解主体列表和分镜参考图缺失提示均可进入。图库自动读取后端已生成图片,不需要重新生图才能显示。
|
1. 入口为 `/projects/:projectId/subject-images`;项目导航、拆解主体列表和分镜参考图缺失提示均可进入。图库自动读取后端已生成图片,不需要重新生图才能显示。
|
||||||
2. 生图使用正式数据库 `SubjectForm.id`。Checkpoint 的领域 `formId` 不是数据库 ID,不能直接调用图片接口。项目列表包含 Identity Anchor 及图片的 `rawJson` 追溯字段,用于识别母版过期;完整实际 Prompt 在历史弹窗读取。
|
2. 生图使用正式数据库 `SubjectForm.id`。Checkpoint 的领域 `formId` 不是数据库 ID,不能直接调用图片接口。项目列表包含 Identity Anchor 及图片的 `rawJson` 追溯字段,用于识别母版过期;完整实际 Prompt 在历史弹窗读取。
|
||||||
3. 单图明确发送 `provider: 'seedream'`,避免后端单图入口默认使用 mock。模型和凭证仅配置在后端,前端不保存 API Key。自定义 Prompt 优先,其次正式 `generationPrompt`;缺少时后端先调用文本模型生成并保存。`appearancePrompt` 仅为原始素材,不能直接充当最终 Prompt。可在形态卡片“提示词与生成”先生成、检查与导出正式提示词;后端尚无手工编辑保存接口。
|
3. 单图明确发送 `provider: 'seedream'`,避免后端单图入口默认使用 mock。模型和凭证仅配置在后端,前端不保存 API Key。自定义 Prompt 优先,其次正式 `generationPrompt`;缺少时后端先调用文本模型生成并保存。`appearancePrompt` 仅为原始素材,不能直接充当最终 Prompt。可在形态卡片“提示词与生成”先生成、检查与导出正式提示词;后端尚无手工编辑保存接口。
|
||||||
4. 单图宽高同时留空使用后端默认 2K,自定义时必须同时提供正整数,具体尺寸限制仍由模型校验。当前 Seedream Provider 没有使用 negativePrompt,所以页面不提供无效的负向提示词输入。
|
4. 单图宽高同时留空使用后端默认 2K,自定义时必须同时提供正整数,具体尺寸限制仍由模型校验。当前 后端图片模型 Provider 没有使用 negativePrompt,所以页面不提供无效的负向提示词输入。
|
||||||
5. 单图生成新增历史记录,不删除旧图。无主图时默认勾选“生成成功后设为主图”;已有主图时默认不替换。候选图可以在历史弹窗二次确认后通过 PUT 设为主图,只允许已完成图片。
|
5. 单图生成新增历史记录,不删除旧图。无主图时默认勾选“生成成功后设为主图”;已有主图时默认不替换。候选图可以在历史弹窗二次确认后通过 PUT 设为主图,只允许已完成图片。
|
||||||
6. 批量默认跳过已有主图的形态;勾选“已有主图也新增候选图”发送 `force: true`。后端批量 force 只生成新图,**不会替换已有主图**。批量作用于整个项目,不受类型/文字/缺图筛选影响,也不接收单图的尺寸与自定义 Prompt。
|
6. 批量默认跳过已有主图的形态;勾选“已有主图也新增候选图”发送 `force: true`。后端批量 force 只生成新图,**不会替换已有主图**。批量作用于整个项目,不受类型/文字/缺图筛选影响,也不接收单图的尺寸与自定义 Prompt。
|
||||||
7. 图库每 6 秒读取一次真实记录,弹窗只在打开时查询该形态图片。数据库存在 pending / generating 时暂停再次生图;网络错误不自动重试有费用的请求。HTTP 200 中的批量部分失败单独展示。
|
7. 图库每 6 秒读取一次真实记录,弹窗只在打开时查询该形态图片。数据库存在 pending / generating 时暂停再次生图;网络错误不自动重试有费用的请求。HTTP 200 中的批量部分失败单独展示。
|
||||||
@@ -309,8 +317,8 @@ server {
|
|||||||
11. 进入“视觉风格”,保存或生成风格,检查分类 Prompt 和硬约束;锁定后 AI 入口禁用,人工编辑仍可保存。登记一张已有风格图,检查启停和移除确认。
|
11. 进入“视觉风格”,保存或生成风格,检查分类 Prompt 和硬约束;锁定后 AI 入口禁用,人工编辑仍可保存。登记一张已有风格图,检查启停和移除确认。
|
||||||
12. 进入“主体身份”,先补齐 Character Identity,再小批量生成选角候选。确认候选不会自动成为 Anchor,人工确认演员后 Identity 同时锁定且 readiness 变为 ready。
|
12. 进入“主体身份”,先补齐 Character Identity,再小批量生成选角候选。确认候选不会自动成为 Anchor,人工确认演员后 Identity 同时锁定且 readiness 变为 ready。
|
||||||
13. 为已确认演员生成 front / three-quarter / full-body 辅助图;确认辅助图不能直接设为母版。角色需要更多演员候选时使用单角色候选入口。
|
13. 为已确认演员生成 front / three-quarter / full-body 辅助图;确认辅助图不能直接设为母版。角色需要更多演员候选时使用单角色候选入口。
|
||||||
14. 对人物和场景形态分别新增图片,在图片详情检查当次母版 ID;旧主图应保持不变,手动切换后再刷新分镜参考图。道具不应显示自动继承母版已生效。
|
14. 对人物和场景形态分别新增图片,在图片详情检查当次母版 ID;旧主图应保持不变,手动切换后再刷新分镜参考图。人物、场景、道具均应在身份已锁定时继承当前母版。
|
||||||
15. 在镜头生产页生成单镜头首帧,确认规格同时列出 `identity-anchor` 与 `form-primary`。切换主首帧后再创建 Seedance 任务,等待完成后播放并选择主视频。
|
15. 在镜头生产页生成单镜头首帧,确认规格同时列出 `identity-anchor` 与 `form-primary`。切换主首帧后再创建 后端视频模型 任务,等待完成后播放并选择主视频。
|
||||||
|
|
||||||
本轮新增测试覆盖风格 JSON/锁定/图片 DELETE、身份归并/前置条件/尺寸/显式参考图/母版切换、部分失败、旧响应、草稿保护与费用确认。模型调用均使用模拟 API。当前执行环境未能通过浏览器访问本地开发服务,**未完成浏览器视觉验收及真实模型联调**,请按上述步骤在本地验收。
|
本轮新增测试覆盖风格 JSON/锁定/图片 DELETE、身份归并/前置条件/尺寸/显式参考图/母版切换、部分失败、旧响应、草稿保护与费用确认。模型调用均使用模拟 API。当前执行环境未能通过浏览器访问本地开发服务,**未完成浏览器视觉验收及真实模型联调**,请按上述步骤在本地验收。
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 后端功能覆盖说明
|
# 后端功能覆盖说明
|
||||||
|
|
||||||
核对日期:2026-09-02。后端:`qianlanse/short-drama-agent`,`dev@537dba89a18d571a6df24dae17494b09ce635c15`。以 API 路由和实际 service/workflow 实现为依据,不只根据接口名称推断功能。本轮不修改后端。
|
核对日期:2026-09-03。后端:`qianlanse/short-drama-agent`,`dev@05bb9e8b7e0a0e85303b16eb3a2ac634e01dd7eb`。以 API 路由和实际 service/workflow 实现为依据,不只根据接口名称推断功能。本轮不修改后端。
|
||||||
|
|
||||||
## 页面与接口能力
|
## 页面与接口能力
|
||||||
|
|
||||||
@@ -25,6 +25,10 @@
|
|||||||
| 视频任务与主视频 | 镜头生产 | 项目 videos readiness/generate/status/retry-failed,逐镜 videos,单任务 status,主视频 PUT | 保留任务提交、轮询、播放、失败重试与主视频切换;不把 HTTP 完成当成片完成 |
|
| 视频任务与主视频 | 镜头生产 | 项目 videos readiness/generate/status/retry-failed,逐镜 videos,单任务 status,主视频 PUT | 保留任务提交、轮询、播放、失败重试与主视频切换;不把 HTTP 完成当成片完成 |
|
||||||
| 高级串联生产 | 镜头生产批量面板底部(默认折叠) | POST `/projects/:id/production/start` | 新增预检、再次校验、费用确认、分阶段回执与诊断导出;明确不是全链自动制片 |
|
| 高级串联生产 | 镜头生产批量面板底部(默认折叠) | POST `/projects/:id/production/start` | 新增预检、再次校验、费用确认、分阶段回执与诊断导出;明确不是全链自动制片 |
|
||||||
|
|
||||||
|
## 新增质量检查与修复
|
||||||
|
|
||||||
|
已接入:首帧 `validate`、`repair`,项目 `keyframes/generate-quality`,视频 `validate`/`repair`,公开图片模型能力及逐镜能力 GET。生成请求不再传固定 Provider。质量接口的详细参数、默认一镜范围、费用确认、主图晋升、三点视频抽样和会话回执限制见 [前端简化与质量功能同步](frontend-simplification.md)。首帧校验历史尚无 GET,不虚构读取接口;视频已有结果从 `rawJson.videoValidation` 读取。
|
||||||
|
|
||||||
## 高级串联生产边界
|
## 高级串联生产边界
|
||||||
|
|
||||||
实际后端顺序:形态正式提示词 → 形态图片 → 视频提示词 → 视频任务提交。**没有视觉风格、身份、导演设计、即时状态或首帧生成节点,也不会等待成片。**
|
实际后端顺序:形态正式提示词 → 形态图片 → 视频提示词 → 视频任务提交。**没有视觉风格、身份、导演设计、即时状态或首帧生成节点,也不会等待成片。**
|
||||||
@@ -32,7 +36,7 @@
|
|||||||
- 仅剧本 `completed` 可启动;先检查项目/工作流任务、形态活动图片、当前母版引用、镜头规格、主首帧有效性和活动视频。
|
- 仅剧本 `completed` 可启动;先检查项目/工作流任务、形态活动图片、当前母版引用、镜头规格、主首帧有效性和活动视频。
|
||||||
- 为避免中途生成主图使既有首帧失效,前端要求先确认全部形态主图并补齐有效主首帧;因此该入口通常跳过形态生图,只串联仍缺少的文本与视频任务。
|
- 为避免中途生成主图使既有首帧失效,前端要求先确认全部形态主图并补齐有效主首帧;因此该入口通常跳过形态生图,只串联仍缺少的文本与视频任务。
|
||||||
- 点击预检只有 GET;付费确认后再次 GET 校验。查询失败或条件变更不提交 POST。单次项目锁仅覆盖本浏览器,不能代替服务器事务和跨客户端互斥。
|
- 点击预检只有 GET;付费确认后再次 GET 校验。查询失败或条件变更不提交 POST。单次项目锁仅覆盖本浏览器,不能代替服务器事务和跨客户端互斥。
|
||||||
- 参数固定为 `imageProvider: seedream`、`videoProvider: seedance`。后端内部文字并发 3、图片与视频并发 2;不接受批量面板的覆盖、数量上限或尺寸配置,不应让用户误以为可小批量执行。
|
- 图片与视频模型由后端统一配置,前端不传请求级 Provider。旧串联路由仍解析默认字段,但实际执行节点已改用统一配置;不据此显示虚假模型选择。后端内部文字并发 3、图片与视频并发 2,不接受普通批量面板的覆盖、上限和尺寸配置。
|
||||||
- 图工作流 `completed` 仅说明经过 finalize,可能仍有错误;`needsManualReview`、`stopReason` 和所有 `errors` 原样显示。视频统计用“已提交”,不标“成片已完成”。
|
- 图工作流 `completed` 仅说明经过 finalize,可能仍有错误;`needsManualReview`、`stopReason` 和所有 `errors` 原样显示。视频统计用“已提交”,不标“成片已完成”。
|
||||||
- 此工作流没有持久 checkpoint/恢复/取消接口。网络断开不能推断后端停止,禁止自动重发。已提交的长请求回执按原项目隔离;页面刷新后需从资产状态核对。
|
- 此工作流没有持久 checkpoint/恢复/取消接口。网络断开不能推断后端停止,禁止自动重发。已提交的长请求回执按原项目隔离;页面刷新后需从资产状态核对。
|
||||||
|
|
||||||
@@ -48,7 +52,7 @@
|
|||||||
- 图库始终读取 keyframes 与 videos 的 readiness,显示下游过期镜头数量;选择来源镜头后才查询其参考图,避免遍历全部镜头的 N+1 请求。只允许定位当前项目列表中的镜头。
|
- 图库始终读取 keyframes 与 videos 的 readiness,显示下游过期镜头数量;选择来源镜头后才查询其参考图,避免遍历全部镜头的 N+1 请求。只允许定位当前项目列表中的镜头。
|
||||||
- 图库深链接支持 `sourceShotId`、`subjectFormId` 和 `subjectRef`;精确形态优先,切换定位清除旧筛选,丢失的形态不自动回退其他形态。返回生产页携带 `episodeNo` 与正式 `shotId`。
|
- 图库深链接支持 `sourceShotId`、`subjectFormId` 和 `subjectRef`;精确形态优先,切换定位清除旧筛选,丢失的形态不自动回退其他形态。返回生产页携带 `episodeNo` 与正式 `shotId`。
|
||||||
- “身份主图过期”与“下游首帧过期”独立展示。素材有效而首帧过期时,只需核对后重建首帧;定位、刷新、筛选不触发生成或切换主资产。
|
- “身份主图过期”与“下游首帧过期”独立展示。素材有效而首帧过期时,只需核对后重建首帧;定位、刷新、筛选不触发生成或切换主资产。
|
||||||
- 当前后端基线存在检查口径不一致风险:`video-readiness.service.ts` 与 `video-generation-compiler.service.ts` 调用 `shotKeyframeCurrentnessService.check` 时仅传形态引用,遗漏第三个当前身份母版参数。默认空列表会将快照里的身份母版视为已移除;而 `shot-keyframe-readiness.service.ts` 会正确传入当前母版。前端因此合并两种检查,并将“首帧未标过期、视频报告过期”单列为“状态待核对”,不据此判定素材失效或建议直接重复生图。本次仅修改前端,不绕过视频阻塞;后端调用链需另行修复与联调。
|
- 本次后端基线已修复视频 readiness/compiler 缺少当前身份母版参数引起的过期误报。前端保留异常时的两种检查结果对照和“状态待核对”提示,不绕过后端阻塞或自动重复生图。
|
||||||
|
|
||||||
## 不添加无效或重复入口
|
## 不添加无效或重复入口
|
||||||
|
|
||||||
@@ -65,5 +69,5 @@
|
|||||||
## 回归验证
|
## 回归验证
|
||||||
|
|
||||||
- 自动测试全部模拟 API,覆盖正式 ID/参数、提示词与图片职责分离、批量部分失败、数量和尺寸校验、候选不覆盖、未完成剧本禁用、费用确认、预检后条件变化、项目切换与旧响应隔离。
|
- 自动测试全部模拟 API,覆盖正式 ID/参数、提示词与图片职责分离、批量部分失败、数量和尺寸校验、候选不覆盖、未完成剧本禁用、费用确认、预检后条件变化、项目切换与旧响应隔离。
|
||||||
- 已通过 lint、格式、TypeScript、Vitest(23 个文件、233 项测试)和生产构建;定位回归见 `src/features/subject-images/asset-impact.test.ts`。测试不触发真实模型或生产任务。
|
- 已通过 lint、格式、TypeScript、Vitest(含质量链路及菜单/折叠交互回归)和生产构建;定位回归见 `src/features/subject-images/asset-impact.test.ts`。测试不触发真实模型或生产任务。
|
||||||
- 当前环境无法打开应用预览,未进行浏览器视觉验收;需在可访问的开发环境检查浅/暗主题和窄屏布局,并由用户明确批准后进行真实小批量模型联调。
|
- 当前环境无法打开应用预览,未进行浏览器视觉验收;需在可访问的开发环境检查浅/暗主题和窄屏布局,并由用户明确批准后进行真实小批量模型联调。
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# 前端简化与质量功能同步
|
||||||
|
|
||||||
|
核对日期:2026-09-03。后端 dev 基线:`05bb9e8b7e0a0e85303b16eb3a2ac634e01dd7eb`。本次只改前端,不更改数据库、后端配置或生成资产。
|
||||||
|
|
||||||
|
## 页面保留什么、收起什么
|
||||||
|
|
||||||
|
沿用现有路由、左侧导航、剧集/主体/镜头目录、详情分栏和灰阶主题,不另建工作台。主操作、正式内容和阻塞提示直接显示;低频入口采用 Naive UI 菜单、折叠区和弹窗。
|
||||||
|
|
||||||
|
| 工作区 | 默认显示 | 按需查看 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 剧本创作 | 正文、角色/世界观/审核标签、进入拆解 | Tabs suffix 的更多菜单:执行记录、导出 |
|
||||||
|
| 剧本拆解 | 主体、形态、分镜、任务与失败提醒 | 更多菜单:执行记录、JSON 导出;长主体介绍可展开 |
|
||||||
|
| 视觉风格 | 名称、整体风格、锁定、保存和 AI 生成 | 分类风格、硬约束、生成规则;收起不丢失草稿 |
|
||||||
|
| 主体身份 | 身份编辑、锁定、选角、母版及图片 | 身份填写规则;批量功能仍在原抽屉 |
|
||||||
|
| 形态图片 | 图片、主图状态、看图与生成 | 卡片更多菜单:提示词、主体身份与母版;批量配置在抽屉 |
|
||||||
|
| 分镜设计 | 剧集、目录、设计与状态 | 更多菜单导出本集,原抽屉生成与恢复 |
|
||||||
|
| 镜头生产 | 提示词、首帧、视频和主要操作 | 资产更多菜单查看生成规格;质量检查打开独立面板,细参数/逐轮回执可展开 |
|
||||||
|
|
||||||
|
少边框、无圆角的布局保持不变;浅色 Tabs 分隔线仍为 `#dadada`。新增刷新与更多按钮为正方形并保留可访问名称;规则折叠标题使用可键盘激活的按钮。摘要只改变显示,不截断底层内容或导出结果。
|
||||||
|
|
||||||
|
不收起:读取失败、任务失败、素材过期、未保存修改、关键禁用条件、费用说明、主图替换后果。原来的素材精确定位/返回原镜头功能保留。重复分镜前置问题只在对应生产阶段显示。
|
||||||
|
|
||||||
|
## 与新后端同步
|
||||||
|
|
||||||
|
### 模型配置
|
||||||
|
|
||||||
|
图片与视频实际使用后端 `IMAGE_*`、`VIDEO_*` 配置。身份/选角/形态/首帧/视频请求不再发送固定 `provider`;串联生产也不再传固定 `imageProvider`、`videoProvider`。旧串联路由仍保留默认字段,但新执行节点不再消费这些请求级模型值,因此界面不能把它们当实际模型展示。
|
||||||
|
|
||||||
|
质量面板或资产规格弹窗可按需读取 `/image-providers/capabilities` 和当前镜头的 `keyframe-provider-capability`。此入口只读,不提供会被后端忽略的“前端切换模型”。未声明参考图上限显示“上限未声明”,不声称无限制。
|
||||||
|
|
||||||
|
### 质量入口
|
||||||
|
|
||||||
|
| 入口 | 实际 API | 结果与边界 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 已完成首帧 → 质量检查 → 仅校验 | POST `/storyboard-shots/:shotId/keyframes/:keyframeId/validate` | 调用视觉模型,不重生成、不切换主首帧 |
|
||||||
|
| 首帧 → 质量检查 → 校验并修复 | POST 同路径 `/repair` | 有限重生成;新候选通过才晋升主首帧;原图直接通过不会自动晋升 |
|
||||||
|
| 生产工具栏 → 首帧质量生成 | POST `/projects/:projectId/keyframes/generate-quality` | 当前集、默认最多一镜、并发 1、最多修复 1 次;每镜候选通过后才设主图 |
|
||||||
|
| 已完成视频 → 质量检查 | POST `/storyboard-shots/:shotId/videos/:videoId/validate` | 三点抽帧视觉检查,不检测音频/口型/完整连续运动;普通候选不切换主视频,修复候选通过后自动晋升 |
|
||||||
|
|
||||||
|
批量质量配置与普通批量面板独立。后端非 force 先根据全项目是否有过期项决定筛选,再筛当前集;因此其他集存在过期项而当前集无过期项时,本集不会先补缺失首帧。前端明确提示切换剧集,不悄悄扩大范围。
|
||||||
|
|
||||||
|
允许文字按行输入并清理空白/重复;未填写不自行授权文字。宽高同时留空或填写正整数,修复次数可为 0。确认区显示本次最多生图/视觉校验调用次数,修改参数或目标后撤销费用确认。
|
||||||
|
|
||||||
|
### 视频修复与复检晋升(本轮后端追加更新)
|
||||||
|
|
||||||
|
视频质量面板新增“生成修复候选”,使用 POST `/storyboard-shots/:shotId/videos/:videoId/repair`,只接受 `maxRepairAttempts`(默认 2,正整数)。该参数是修复链的上限,不是本次自动生成次数。本次只提交一个视频候选,不自动等待、复检或循环重试;修复约束和允许文字沿用已保存的失败校验。
|
||||||
|
|
||||||
|
确认后重新读取视频,未校验、校验已通过、链路到达上限或镜头有活动视频时不提交。候选完成后仍不是主视频,需要对新候选显式执行视觉校验;后端通过后自动晋升,界面在付费确认前说明后果,并显示实际晋升结果。未通过复检的修复候选不提供普通“设为主视频”按钮,不能绕过验收。
|
||||||
|
|
||||||
|
### 状态与风险控制
|
||||||
|
|
||||||
|
- 打开页面、菜单、结果和模型能力不会自动调用生成/视觉模型。质量 POST 只在确认后发生,不自动超时重试。
|
||||||
|
- 确认后再次 GET 项目完成状态、活动任务、项目镜头列表与目标资产归属。视频必须有完成状态、地址和有效时长;首帧必须完成且有图片。修复和小批量质量生成先检查模型参考图能力,批次检查当前镜头是否仍在生图。
|
||||||
|
- 预检期间关闭面板或换目标不发送 POST。已提交请求继续运行,回执按原项目/镜头/资产保存,不能写入新项目。当前浏览器项目互斥不能代替后端跨客户端锁;仍需确认外部任务停止。
|
||||||
|
- 结果分别显示视觉通过、修复未通过、调用失败、未执行;不会把 HTTP 200 当质量通过。批量回执提供定位镜头,完整主体检查/逐轮耗时/原始回执可展开和导出。
|
||||||
|
- 视频历史结果直接从 `rawJson.videoValidation` 读取,不为“查看结果”重新调用视觉模型。它是历史抽样结论,不保证当前引用仍一致。
|
||||||
|
- 后端持久化首帧校验,但尚无历史读取 GET。前端只显示当前会话确实收到的首帧回执并支持导出,不虚构历史接口。
|
||||||
|
- 后端已修复视频校验漏传身份母版导致的过期误报。前端保留两种就绪结果冲突时的防御性提示,不绕过后端阻塞、不自动重复生成。
|
||||||
|
|
||||||
|
## 验证边界
|
||||||
|
|
||||||
|
单元与组件测试全部使用模拟 API,验证参数、正式 ID、禁用、费用确认、范围、归属、回执隔离和菜单访问,不触发真实模型任务。浏览器视觉与实际模型联调需要在可访问的开发环境进行;不将 DOM 测试视为视觉验收。
|
||||||
@@ -1472,6 +1472,51 @@ body {
|
|||||||
.form-image-card-focused {
|
.form-image-card-focused {
|
||||||
box-shadow: inset 3px 0 0 var(--app-accent-text);
|
box-shadow: inset 3px 0 0 var(--app-accent-text);
|
||||||
}
|
}
|
||||||
|
/* 低频记录收起后归还正文宽度,不留下历史列的占位。 */
|
||||||
|
.breakdown-results-section > .content-with-history.history-hidden {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
/* 补充说明只按需展开,继续使用平直背景层级,不增加边框或圆角。 */
|
||||||
|
.detail-disclosure.n-collapse {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--app-muted);
|
||||||
|
}
|
||||||
|
.detail-disclosure-content {
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--app-subtle);
|
||||||
|
color: var(--app-ink);
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
.quality-fields {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr));
|
||||||
|
align-items: start;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.quality-result {
|
||||||
|
margin-top: 24px;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--app-subtle);
|
||||||
|
}
|
||||||
|
.quality-result-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--app-control);
|
||||||
|
font-size: 12px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.quality-json {
|
||||||
|
margin-top: 16px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
.select-control {
|
.select-control {
|
||||||
min-width: 130px;
|
min-width: 130px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { NInput, NDropdown } from 'naive-ui'
|
||||||
|
import ActionMenu from './ActionMenu.vue'
|
||||||
|
import DetailDisclosure from './DetailDisclosure.vue'
|
||||||
|
import StyleEditor from '../../features/visual-style/components/StyleEditor.vue'
|
||||||
|
import SubjectList from '../../features/breakdown/components/SubjectList.vue'
|
||||||
|
import { selectMenu } from '../../testing/naive'
|
||||||
|
import type { SubjectCandidate } from '../../features/breakdown/types'
|
||||||
|
|
||||||
|
let wrapper: VueWrapper | undefined
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper?.unmount()
|
||||||
|
wrapper = undefined
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('精简入口保留功能', () => {
|
||||||
|
it('菜单按点击展开,禁用项不能绕过,触发器有可访问名称', async () => {
|
||||||
|
wrapper = mount(ActionMenu, {
|
||||||
|
attachTo: document.body,
|
||||||
|
props: {
|
||||||
|
label: '测试更多操作',
|
||||||
|
items: [
|
||||||
|
{ key: 'history', label: '执行记录' },
|
||||||
|
{ key: 'export', label: '导出', disabled: true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(wrapper.get('button').attributes('aria-haspopup')).toBe('menu')
|
||||||
|
expect(wrapper.get('button').attributes('aria-expanded')).toBe('false')
|
||||||
|
expect(document.querySelector('.n-dropdown-option-body')).toBeNull()
|
||||||
|
await selectMenu('测试更多操作', '执行记录')
|
||||||
|
expect(wrapper.emitted('select')).toEqual([['history']])
|
||||||
|
wrapper.getComponent(NDropdown).vm.$emit('select', 'export')
|
||||||
|
expect(wrapper.emitted('select')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('补充说明默认不展开,键盘可聚焦的标题可查看原文', async () => {
|
||||||
|
wrapper = mount(DetailDisclosure, { props: { title: '规则说明' }, slots: { default: '完整规则仍然保留' } })
|
||||||
|
expect(wrapper.text()).not.toContain('完整规则仍然保留')
|
||||||
|
await wrapper.get('.n-collapse-item__header-main').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.text()).toContain('完整规则仍然保留')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('风格高级字段收起不丢失保存值,展开后仍可修改', async () => {
|
||||||
|
wrapper = mount(StyleEditor, { props: { visualStyle: null, disabled: false } })
|
||||||
|
expect(wrapper.find('#style-constraints').exists()).toBe(false)
|
||||||
|
await wrapper.get('.n-collapse-item__header-main').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.get('#style-constraints').setValue('["保留硬约束"]')
|
||||||
|
const fields = wrapper.findAllComponents(NInput)
|
||||||
|
fields.find(item => item.props('placeholder')?.includes('人物质感'))!.vm.$emit('update:value', '人物风格')
|
||||||
|
await flushPromises()
|
||||||
|
await wrapper.get('.n-collapse-item__header-main').trigger('click')
|
||||||
|
await wrapper.get('form').trigger('submit')
|
||||||
|
expect(wrapper.emitted('save')).toMatchObject([
|
||||||
|
[{ characterPrompt: '人物风格', hardConstraints: ['保留硬约束'] }]
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('主体长文收起仅影响显示,完整内容与图片入口不丢失', async () => {
|
||||||
|
const description = '主体完整经历。'.repeat(30)
|
||||||
|
wrapper = mount(SubjectList, {
|
||||||
|
props: {
|
||||||
|
projectId: 'p',
|
||||||
|
subjects: [
|
||||||
|
{
|
||||||
|
profileId: 'p1',
|
||||||
|
ref: '@CH0001',
|
||||||
|
name: '人物',
|
||||||
|
module: 'character',
|
||||||
|
description,
|
||||||
|
appearance_prompt: '外观',
|
||||||
|
aliases: []
|
||||||
|
} as SubjectCandidate
|
||||||
|
],
|
||||||
|
forms: []
|
||||||
|
},
|
||||||
|
global: { stubs: { RouterLink: true } }
|
||||||
|
})
|
||||||
|
const text = wrapper.get('.line-clamp-3')
|
||||||
|
expect(text.text()).toBe(description)
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
expect(wrapper.find('.line-clamp-3').exists()).toBe(false)
|
||||||
|
expect(wrapper.findAll('router-link-stub')).toHaveLength(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { NButton, NDropdown } from 'naive-ui'
|
||||||
|
import { Ellipsis } from '@lucide/vue'
|
||||||
|
|
||||||
|
/** 低频操作集中到键盘可访问的菜单;选中禁用项也不会向外触发动作。 */
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{ label?: string; items: { key: string; label: string; disabled?: boolean }[] }>(),
|
||||||
|
{ label: '更多操作' }
|
||||||
|
)
|
||||||
|
const emit = defineEmits<{ select: [key: string] }>()
|
||||||
|
const open = ref(false)
|
||||||
|
function select(key: string) {
|
||||||
|
const item = props.items.find(option => option.key === key)
|
||||||
|
if (!item || item.disabled) return
|
||||||
|
emit('select', key)
|
||||||
|
open.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<NDropdown trigger="click" placement="bottom-end" :options="items" v-model:show="open" @select="select">
|
||||||
|
<NButton
|
||||||
|
quaternary
|
||||||
|
class="icon-button"
|
||||||
|
:aria-label="label"
|
||||||
|
:title="label"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
:aria-expanded="open"
|
||||||
|
><template #icon><Ellipsis :size="18" /></template
|
||||||
|
></NButton>
|
||||||
|
</NDropdown>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { NButton, NCollapse, NCollapseItem } from 'naive-ui'
|
||||||
|
|
||||||
|
/** 保留说明与诊断的可发现入口,默认不占据主内容区域;阻塞错误不应放入此组件。 */
|
||||||
|
withDefaults(defineProps<{ title?: string }>(), { title: '使用说明' })
|
||||||
|
const expanded = ref<string[]>([])
|
||||||
|
/** 原生按钮保留 Enter/Space 键盘激活;文字点击不再向外重复触发折叠。 */
|
||||||
|
function toggle() {
|
||||||
|
expanded.value = expanded.value.length ? [] : ['details']
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<NCollapse v-model:expanded-names="expanded" class="detail-disclosure"
|
||||||
|
><NCollapseItem name="details"
|
||||||
|
><template #header
|
||||||
|
><NButton text size="small" :aria-expanded="expanded.length > 0" @click.stop="toggle">{{
|
||||||
|
title
|
||||||
|
}}</NButton></template
|
||||||
|
>
|
||||||
|
<div class="detail-disclosure-content"><slot /></div></NCollapseItem
|
||||||
|
></NCollapse>
|
||||||
|
</template>
|
||||||
@@ -8,6 +8,7 @@ import { projectContextKey, type useProjectData } from '../projects/context'
|
|||||||
import type { ProjectDetail } from '../projects/types'
|
import type { ProjectDetail } from '../projects/types'
|
||||||
import type { Checkpoint } from '../workflows/types'
|
import type { Checkpoint } from '../workflows/types'
|
||||||
import type { BreakdownModule, EpisodePlan } from './types'
|
import type { BreakdownModule, EpisodePlan } from './types'
|
||||||
|
import { selectMenu } from '../../testing/naive'
|
||||||
|
|
||||||
let wrapper: VueWrapper | undefined
|
let wrapper: VueWrapper | undefined
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -195,6 +196,8 @@ describe('拆解页内容滚动', () => {
|
|||||||
it.each(['人物', '场景', '道具'])('%s 使用共用固定斑马纹样式,搜索后保持列表条目结构', async label => {
|
it.each(['人物', '场景', '道具'])('%s 使用共用固定斑马纹样式,搜索后保持列表条目结构', async label => {
|
||||||
mountPage()
|
mountPage()
|
||||||
await selectTab(label)
|
await selectTab(label)
|
||||||
|
expect(wrapper!.find('.history-panel').exists()).toBe(false)
|
||||||
|
await selectMenu('拆解更多操作', '执行记录')
|
||||||
const list = wrapper!.get('.subject-record-list')
|
const list = wrapper!.get('.subject-record-list')
|
||||||
expect(list.classes()).toContain('record-list')
|
expect(list.classes()).toContain('record-list')
|
||||||
expect(list.findAll(':scope > article')).toHaveLength(30)
|
expect(list.findAll(':scope > article')).toHaveLength(30)
|
||||||
@@ -364,6 +367,7 @@ describe('拆解页内容滚动', () => {
|
|||||||
])('%s 的末项始终放在独立结果滚动容器内', async (label, lastItem) => {
|
])('%s 的末项始终放在独立结果滚动容器内', async (label, lastItem) => {
|
||||||
mountPage()
|
mountPage()
|
||||||
await selectTab(label)
|
await selectTab(label)
|
||||||
|
await selectMenu('拆解更多操作', '执行记录')
|
||||||
expect(wrapper!.find('.workspace-scroll').exists()).toBe(false)
|
expect(wrapper!.find('.workspace-scroll').exists()).toBe(false)
|
||||||
const results = wrapper!.get('.breakdown-results-scroll .n-scrollbar-container')
|
const results = wrapper!.get('.breakdown-results-scroll .n-scrollbar-container')
|
||||||
expect(results.text()).toContain(lastItem)
|
expect(results.text()).toContain(lastItem)
|
||||||
@@ -378,6 +382,7 @@ describe('拆解页内容滚动', () => {
|
|||||||
const fetcher = vi.fn<typeof fetch>()
|
const fetcher = vi.fn<typeof fetch>()
|
||||||
vi.stubGlobal('fetch', fetcher)
|
vi.stubGlobal('fetch', fetcher)
|
||||||
const provided = mountPage()
|
const provided = mountPage()
|
||||||
|
await selectMenu('拆解更多操作', '执行记录')
|
||||||
const results = wrapper!.get<HTMLElement>('.breakdown-results-scroll .n-scrollbar-container').element
|
const results = wrapper!.get<HTMLElement>('.breakdown-results-scroll .n-scrollbar-container').element
|
||||||
const history = wrapper!.get<HTMLElement>('.history-panel .n-scrollbar-container').element
|
const history = wrapper!.get<HTMLElement>('.history-panel .n-scrollbar-container').element
|
||||||
results.scrollTop = 900
|
results.scrollTop = 900
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
||||||
|
import ActionMenu from '../../components/ui/ActionMenu.vue'
|
||||||
import { NScrollbar, NAlert, NButton, NCheckbox, NInputNumber, NProgress, NTab, NTable, NTabs, NTag } from 'naive-ui'
|
import { NScrollbar, NAlert, NButton, NCheckbox, NInputNumber, NProgress, NTab, NTable, NTabs, NTag } from 'naive-ui'
|
||||||
import { computed, onScopeDispose, ref, watch } from 'vue'
|
import { computed, onScopeDispose, ref, watch } from 'vue'
|
||||||
import { Download, Layers, LoaderCircle, ArrowRight } from '@lucide/vue'
|
import { Layers, LoaderCircle, ArrowRight } from '@lucide/vue'
|
||||||
import { EmptyState, StatusBadge, WorkspaceTools } from '../../components/ui'
|
import { EmptyState, StatusBadge, WorkspaceTools } from '../../components/ui'
|
||||||
import { useProjectContext } from '../projects/context'
|
import { useProjectContext } from '../projects/context'
|
||||||
import { breakdownApi } from './api'
|
import { breakdownApi } from './api'
|
||||||
@@ -25,6 +26,7 @@ const previewBusy = ref(false)
|
|||||||
const previewError = ref('')
|
const previewError = ref('')
|
||||||
const tab = ref('character')
|
const tab = ref('character')
|
||||||
const toolsOpen = ref(false)
|
const toolsOpen = ref(false)
|
||||||
|
const showHistory = ref(false)
|
||||||
const moduleOptions: { value: BreakdownModule; label: string; description: string }[] = [
|
const moduleOptions: { value: BreakdownModule; label: string; description: string }[] = [
|
||||||
{ value: 'character', label: '人物', description: '人物身份、外观与形态' },
|
{ value: 'character', label: '人物', description: '人物身份、外观与形态' },
|
||||||
{ value: 'scene', label: '场景', description: '故事空间与环境特征' },
|
{ value: 'scene', label: '场景', description: '故事空间与环境特征' },
|
||||||
@@ -398,14 +400,18 @@ function exportResult() {
|
|||||||
><NTab name="storyboard">分镜</NTab><NTab name="tasks">任务明细</NTab>
|
><NTab name="storyboard">分镜</NTab><NTab name="tasks">任务明细</NTab>
|
||||||
<template #suffix
|
<template #suffix
|
||||||
><div class="tabs-toolbar-actions">
|
><div class="tabs-toolbar-actions">
|
||||||
<NButton :disabled="!snapshot" @click="exportResult" text size="small"
|
<ActionMenu
|
||||||
><Download :size="14" />导出 JSON
|
label="拆解更多操作"
|
||||||
</NButton>
|
:items="[
|
||||||
</div></template
|
{ key: 'history', label: showHistory ? '收起执行记录' : '执行记录' },
|
||||||
>
|
{ key: 'export', label: '导出 JSON', disabled: !snapshot }
|
||||||
|
]"
|
||||||
|
@select="$event === 'history' ? (showHistory = !showHistory) : exportResult()"
|
||||||
|
/></div
|
||||||
|
></template>
|
||||||
</NTabs>
|
</NTabs>
|
||||||
</div>
|
</div>
|
||||||
<div class="content-with-history panel">
|
<div class="content-with-history panel" :class="{ 'history-hidden': !showHistory }">
|
||||||
<main class="min-w-0">
|
<main class="min-w-0">
|
||||||
<!-- 所有结果标签共用有界的内容滚动;切换标签回到顶部,不继承上一列表的位置。 -->
|
<!-- 所有结果标签共用有界的内容滚动;切换标签回到顶部,不继承上一列表的位置。 -->
|
||||||
<NScrollbar :key="tab" class="panel-scroll breakdown-results-scroll">
|
<NScrollbar :key="tab" class="panel-scroll breakdown-results-scroll">
|
||||||
@@ -480,7 +486,12 @@ function exportResult() {
|
|||||||
</div>
|
</div>
|
||||||
</NScrollbar>
|
</NScrollbar>
|
||||||
</main>
|
</main>
|
||||||
<HistoryPanel :project-id="project?.id" :checkpoints="checkpoints" workflow="breakdown" />
|
<HistoryPanel
|
||||||
|
v-if="showHistory"
|
||||||
|
:project-id="project?.id"
|
||||||
|
:checkpoints="checkpoints"
|
||||||
|
workflow="breakdown"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NCollapse, NCollapseItem, NInput, NTag } from 'naive-ui'
|
import { NButton, NCollapse, NCollapseItem, NInput, NTag } from 'naive-ui'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { Search, ChevronDown } from '@lucide/vue'
|
import { Search } from '@lucide/vue'
|
||||||
import { EmptyState } from '../../../components/ui'
|
import { EmptyState } from '../../../components/ui'
|
||||||
import type { SubjectCandidate, SubjectForm } from '../types'
|
import type { SubjectCandidate, SubjectForm } from '../types'
|
||||||
|
|
||||||
/** 主体列表按稳定 ref 展示;形态以 profileId 关联。 */
|
/** 主体列表按稳定 ref 展示;形态以 profileId 关联。 */
|
||||||
const props = defineProps<{ subjects: SubjectCandidate[]; forms: SubjectForm[]; projectId?: string }>()
|
const props = defineProps<{ subjects: SubjectCandidate[]; forms: SubjectForm[]; projectId?: string }>()
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
|
/** 长篇主体经历默认展示摘要,展开不丢失原始正文。 */
|
||||||
|
const expanded = ref(new Set<string>())
|
||||||
|
function toggleDescription(id: string) {
|
||||||
|
if (expanded.value.has(id)) expanded.value.delete(id)
|
||||||
|
else expanded.value.add(id)
|
||||||
|
}
|
||||||
const filtered = computed(() =>
|
const filtered = computed(() =>
|
||||||
props.subjects.filter(item =>
|
props.subjects.filter(item =>
|
||||||
`${item.name} ${item.ref} ${item.aliases?.join(' ') ?? ''}`
|
`${item.name} ${item.ref} ${item.aliases?.join(' ') ?? ''}`
|
||||||
@@ -43,7 +49,23 @@ const filtered = computed(() =>
|
|||||||
>别名:{{ subject.aliases.join('、') }}</span
|
>别名:{{ subject.aliases.join('、') }}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm leading-7">{{ subject.description }}</p>
|
<p
|
||||||
|
class="text-sm leading-7"
|
||||||
|
:class="{
|
||||||
|
'line-clamp-3': (subject.description?.length ?? 0) > 160 && !expanded.has(subject.profileId)
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ subject.description }}
|
||||||
|
</p>
|
||||||
|
<NButton
|
||||||
|
v-if="(subject.description?.length ?? 0) > 160"
|
||||||
|
text
|
||||||
|
size="small"
|
||||||
|
class="mt-2"
|
||||||
|
:aria-expanded="expanded.has(subject.profileId)"
|
||||||
|
@click="toggleDescription(subject.profileId)"
|
||||||
|
>{{ expanded.has(subject.profileId) ? '收起介绍' : '展开完整介绍' }}</NButton
|
||||||
|
>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-if="projectId"
|
v-if="projectId"
|
||||||
:to="{ path: `/projects/${projectId}/subject-identity`, query: { subjectRef: subject.ref } }"
|
:to="{ path: `/projects/${projectId}/subject-identity`, query: { subjectRef: subject.ref } }"
|
||||||
@@ -59,7 +81,7 @@ const filtered = computed(() =>
|
|||||||
<NCollapse class="subject-details mt-4"
|
<NCollapse class="subject-details mt-4"
|
||||||
><NCollapseItem name="details"
|
><NCollapseItem name="details"
|
||||||
><template #header
|
><template #header
|
||||||
><ChevronDown :size="14" />外观描述与形态
|
>外观描述与形态
|
||||||
<span class="ml-1 text-muted">{{
|
<span class="ml-1 text-muted">{{
|
||||||
forms.filter(form => form.profileId === subject.profileId).length
|
forms.filter(form => form.profileId === subject.profileId).length
|
||||||
}}</span></template
|
}}</span></template
|
||||||
@@ -77,7 +99,12 @@ const filtered = computed(() =>
|
|||||||
}}<NTag v-if="form.isDefault" size="small" :bordered="false">默认</NTag>
|
}}<NTag v-if="form.isDefault" size="small" :bordered="false">默认</NTag>
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-2 text-sm leading-6 text-muted">{{ form.description }}</p>
|
<p class="mt-2 text-sm leading-6 text-muted">{{ form.description }}</p>
|
||||||
<p class="mt-2 text-xs leading-6 text-muted">{{ form.appearancePrompt }}</p>
|
<p
|
||||||
|
v-if="form.appearancePrompt?.trim() !== form.description?.trim()"
|
||||||
|
class="mt-2 text-xs leading-6 text-muted"
|
||||||
|
>
|
||||||
|
{{ form.appearancePrompt }}
|
||||||
|
</p>
|
||||||
</div></NCollapseItem
|
</div></NCollapseItem
|
||||||
></NCollapse
|
></NCollapse
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
||||||
import { NScrollbar, NButton, NCollapse, NCollapseItem, NProgress, NTab, NTabs, NTag } from 'naive-ui'
|
import ActionMenu from '../../components/ui/ActionMenu.vue'
|
||||||
|
import { NScrollbar, NCollapse, NCollapseItem, NProgress, NTab, NTabs, NTag } from 'naive-ui'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { ArrowRight, Download, Check, Circle, History } from '@lucide/vue'
|
import { ArrowRight, Check, Circle } from '@lucide/vue'
|
||||||
import { EmptyState } from '../../components/ui'
|
import { EmptyState } from '../../components/ui'
|
||||||
import { useProjectContext } from '../projects/context'
|
import { useProjectContext } from '../projects/context'
|
||||||
import { projectsApi } from '../projects/api'
|
import { projectsApi } from '../projects/api'
|
||||||
@@ -123,12 +124,15 @@ function exportScript() {
|
|||||||
><NTab name="review">审核记录</NTab>
|
><NTab name="review">审核记录</NTab>
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
<div class="tabs-toolbar-actions">
|
<div class="tabs-toolbar-actions">
|
||||||
<NButton text size="small" :aria-expanded="showHistory" @click="showHistory = !showHistory"
|
<ActionMenu
|
||||||
><History :size="14" />{{ showHistory ? '收起执行记录' : '执行记录' }}</NButton
|
label="剧本更多操作"
|
||||||
>
|
:items="[
|
||||||
<NButton :disabled="!episodes.length" @click="exportScript" text size="small"
|
{ key: 'history', label: showHistory ? '收起执行记录' : '执行记录' },
|
||||||
><Download :size="14" />导出剧本</NButton
|
{ key: 'export', label: '导出剧本', disabled: !episodes.length }
|
||||||
><RouterLink
|
]"
|
||||||
|
@select="$event === 'history' ? (showHistory = !showHistory) : exportScript()"
|
||||||
|
/>
|
||||||
|
<RouterLink
|
||||||
v-if="complete"
|
v-if="complete"
|
||||||
class="text-button text-accent"
|
class="text-button text-accent"
|
||||||
:to="`/projects/${project?.id}/breakdown`"
|
:to="`/projects/${project?.id}/breakdown`"
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { ArrowLeft, CheckCircle2, CircleAlert, Film, Image, MessageSquareText, R
|
|||||||
import { EmptyState, StatusBadge } from '../../components/ui'
|
import { EmptyState, StatusBadge } from '../../components/ui'
|
||||||
import BeatShotDirectory from '../storyboard/components/BeatShotDirectory.vue'
|
import BeatShotDirectory from '../storyboard/components/BeatShotDirectory.vue'
|
||||||
import ConfirmAction from '../workflows/ConfirmAction.vue'
|
import ConfirmAction from '../workflows/ConfirmAction.vue'
|
||||||
import { issueLabel, productionStatusLabel } from './model'
|
import { productionStatusLabel } from './model'
|
||||||
import { useProduction } from './useProduction'
|
import { useProduction } from './useProduction'
|
||||||
import ProductionReceipt from './components/ProductionReceipt.vue'
|
import ProductionReceipt from './components/ProductionReceipt.vue'
|
||||||
import ShotProductionAssets from './components/ShotProductionAssets.vue'
|
import ShotProductionAssets from './components/ShotProductionAssets.vue'
|
||||||
@@ -25,6 +25,9 @@ import AdvancedProduction from './components/AdvancedProduction.vue'
|
|||||||
import StaleKeyframeNotice from './components/StaleKeyframeNotice.vue'
|
import StaleKeyframeNotice from './components/StaleKeyframeNotice.vue'
|
||||||
import { routeLocationKey } from 'vue-router'
|
import { routeLocationKey } from 'vue-router'
|
||||||
import { queryText } from './asset-links'
|
import { queryText } from './asset-links'
|
||||||
|
import QualityDialog from './components/QualityDialog.vue'
|
||||||
|
import DetailDisclosure from '../../components/ui/DetailDisclosure.vue'
|
||||||
|
const qualityOpen = ref(false)
|
||||||
|
|
||||||
/** 镜头生产页将项目批处理与单镜头资产管理放在同一条可核验链路中。 */
|
/** 镜头生产页将项目批处理与单镜头资产管理放在同一条可核验链路中。 */
|
||||||
const {
|
const {
|
||||||
@@ -87,7 +90,7 @@ const stages = computed(() => {
|
|||||||
key: 'keyframes' as const,
|
key: 'keyframes' as const,
|
||||||
code: '02',
|
code: '02',
|
||||||
title: '镜头首帧',
|
title: '镜头首帧',
|
||||||
description: '合并视觉风格、镜头规格和主体参考图,调用 Seedream 生成。',
|
description: '合并视觉风格、镜头规格和主体参考图,调用图片模型生成。',
|
||||||
icon: Image,
|
icon: Image,
|
||||||
data: data?.keyframes,
|
data: data?.keyframes,
|
||||||
done: data?.keyframes.skipped ?? 0,
|
done: data?.keyframes.skipped ?? 0,
|
||||||
@@ -97,7 +100,7 @@ const stages = computed(() => {
|
|||||||
key: 'videos' as const,
|
key: 'videos' as const,
|
||||||
code: '03',
|
code: '03',
|
||||||
title: '视频成片',
|
title: '视频成片',
|
||||||
description: '以主首帧作为第一帧,连同提示词和主体参考图提交 Seedance。',
|
description: '以主首帧作为第一帧,连同提示词和主体参考图提交视频模型。',
|
||||||
icon: Film,
|
icon: Film,
|
||||||
data: data?.videos,
|
data: data?.videos,
|
||||||
done: data?.videoStatus.completed ?? 0,
|
done: data?.videoStatus.completed ?? 0,
|
||||||
@@ -112,7 +115,6 @@ const stages = computed(() => {
|
|||||||
const selectedIssues = computed(() =>
|
const selectedIssues = computed(() =>
|
||||||
[promptItem.value, keyframeItem.value, videoItem.value].flatMap(item => item?.issues ?? [])
|
[promptItem.value, keyframeItem.value, videoItem.value].flatMap(item => item?.issues ?? [])
|
||||||
)
|
)
|
||||||
const remainingIssues = computed(() => selectedIssues.value.filter(issue => issue.code !== 'stale_keyframe'))
|
|
||||||
const selectedKeyframeStale = computed(
|
const selectedKeyframeStale = computed(
|
||||||
() =>
|
() =>
|
||||||
!!keyframeItem.value?.primaryKeyframeStale ||
|
!!keyframeItem.value?.primaryKeyframeStale ||
|
||||||
@@ -136,6 +138,11 @@ const staleKeyframes = computed(() => query.data.value?.keyframes.stalePrimaryKe
|
|||||||
function refreshProject() {
|
function refreshProject() {
|
||||||
void query.refresh()
|
void query.refresh()
|
||||||
}
|
}
|
||||||
|
/** 从质量回执定位本集正式镜头,关闭弹窗不会触发任何生成请求。 */
|
||||||
|
function locateQualityShot(shotId: string) {
|
||||||
|
selectedShot.value = shotId
|
||||||
|
qualityOpen.value = false
|
||||||
|
}
|
||||||
/** 新回执自动展开,避免折叠区隐藏部分失败诊断。 */
|
/** 新回执自动展开,避免折叠区隐藏部分失败诊断。 */
|
||||||
const toolsOpen = ref(false)
|
const toolsOpen = ref(false)
|
||||||
const overview = ref<string[]>(['overview'])
|
const overview = ref<string[]>(['overview'])
|
||||||
@@ -181,17 +188,20 @@ watch(
|
|||||||
></span
|
></span
|
||||||
>
|
>
|
||||||
<div class="toolbar-actions">
|
<div class="toolbar-actions">
|
||||||
|
<NButton :disabled="blocked || !episodeNo" @click="qualityOpen = true">首帧质量生成</NButton>
|
||||||
<NButton
|
<NButton
|
||||||
:disabled="query.loading.value"
|
:disabled="query.loading.value"
|
||||||
@click="query.refresh"
|
@click="query.refresh"
|
||||||
text
|
quaternary
|
||||||
size="small"
|
class="icon-button"
|
||||||
class="mb-2 ml-auto"
|
aria-label="刷新就绪状态"
|
||||||
><RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新就绪状态
|
title="刷新就绪状态"
|
||||||
|
><RefreshCw :size="16" :class="{ 'animate-spin': query.loading.value }" />
|
||||||
</NButton>
|
</NButton>
|
||||||
<WorkspaceTools
|
<WorkspaceTools
|
||||||
v-model:open="toolsOpen"
|
v-model:open="toolsOpen"
|
||||||
title="项目生产总览与批量操作"
|
title="项目生产总览与批量操作"
|
||||||
|
label="批量生产"
|
||||||
:has-receipt="!!session.receipt || !!session.pipelineReceipt"
|
:has-receipt="!!session.receipt || !!session.pipelineReceipt"
|
||||||
>
|
>
|
||||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
<div class="flex flex-wrap items-end justify-between gap-4">
|
||||||
@@ -225,18 +235,17 @@ watch(
|
|||||||
>覆盖模式:重写提示词/新增图视频候选
|
>覆盖模式:重写提示词/新增图视频候选
|
||||||
</NCheckbox>
|
</NCheckbox>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-3 text-[11px] leading-6 text-muted">
|
<DetailDisclosure title="处理范围与覆盖规则" class="mt-3"
|
||||||
提示词与视频批量操作面向全项目;首帧默认补当前剧集,存在过期主首帧时优先更新全项目过期项,覆盖模式处理全项目;过期首帧成功后会自动接替旧主图,其余新增候选。Seedream
|
><p class="text-xs leading-6 text-muted">
|
||||||
与 Seedance 均可能产生费用。
|
提示词与视频批量操作面向全项目;首帧默认补当前剧集,存在过期主首帧时优先更新全项目过期项,覆盖模式处理全项目;过期首帧成功后会自动接替旧主图,其余新增候选。图片模型
|
||||||
</p>
|
与视频模型均可能产生费用。
|
||||||
|
</p></DetailDisclosure
|
||||||
|
>
|
||||||
</section>
|
</section>
|
||||||
<div class="workspace-overview-panel">
|
<div class="workspace-overview-panel">
|
||||||
<NCollapse v-model:expanded-names="overview"
|
<NCollapse v-model:expanded-names="overview"
|
||||||
><NCollapseItem name="overview" title="项目生产总览与批量操作"
|
><NCollapseItem name="overview" title="项目生产总览与批量操作"
|
||||||
><div class="overview-content">
|
><div class="overview-content">
|
||||||
<NAlert type="info" :show-icon="false" class="mt-5 text-xs">
|
|
||||||
每一步都按后端就绪检查执行。覆盖会重写提示词;图片和视频通常新增候选,但过期首帧即使在覆盖模式也会更新为主图。上游资产变化后,请先更新过期结果再继续生产。
|
|
||||||
</NAlert>
|
|
||||||
<NAlert
|
<NAlert
|
||||||
v-if="videoRunning"
|
v-if="videoRunning"
|
||||||
role="status"
|
role="status"
|
||||||
@@ -372,8 +381,8 @@ watch(
|
|||||||
:description="
|
:description="
|
||||||
stage.key === 'videos'
|
stage.key === 'videos'
|
||||||
? staleVideos && !force
|
? staleVideos && !force
|
||||||
? '当前存在过期主视频,非覆盖模式只会为这些镜头创建新的 Seedance 任务;新视频完成后会自动接替过期主视频。'
|
? '当前存在过期主视频,非覆盖模式只会为这些镜头创建新的视频模型任务;新视频完成后会自动接替过期主视频。'
|
||||||
: '只为当前就绪镜头创建 Seedance 异步任务。创建成功不代表视频已经完成,任务会在后台继续运行。'
|
: '只为当前就绪镜头创建视频模型异步任务。创建成功不代表视频已经完成,任务会在后台继续运行。'
|
||||||
: stage.key === 'keyframes'
|
: stage.key === 'keyframes'
|
||||||
? `处理缺失或过期主首帧。过期首帧成功后自动接替旧主图(覆盖模式也如此),其余已有主图只新增候选。本批上限:${keyframeLimit || '不限制'};尺寸:${keyframeWidth && keyframeHeight ? `${keyframeWidth} × ${keyframeHeight}` : '后端默认'}。`
|
? `处理缺失或过期主首帧。过期首帧成功后自动接替旧主图(覆盖模式也如此),其余已有主图只新增候选。本批上限:${keyframeLimit || '不限制'};尺寸:${keyframeWidth && keyframeHeight ? `${keyframeWidth} × ${keyframeHeight}` : '后端默认'}。`
|
||||||
: '只处理通过视频提示词就绪检查的镜头;覆盖模式会重写已有提示词。'
|
: '只处理通过视频提示词就绪检查的镜头;覆盖模式会重写已有提示词。'
|
||||||
@@ -431,7 +440,7 @@ watch(
|
|||||||
blocked || !batchValid || !query.data.value.videoStatus.failed
|
blocked || !batchValid || !query.data.value.videoStatus.failed
|
||||||
"
|
"
|
||||||
acknowledgement
|
acknowledgement
|
||||||
description="为当前最近一次状态为失败的镜头重新创建 Seedance 任务。成功镜头和进行中的镜头不会处理。"
|
description="为当前最近一次状态为失败的镜头重新创建视频模型任务。成功镜头和进行中的镜头不会处理。"
|
||||||
@confirm="run('retry-videos')"
|
@confirm="run('retry-videos')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -576,11 +585,6 @@ watch(
|
|||||||
>
|
>
|
||||||
主视频已过期:当前主首帧或主体参考图与生成该视频时不一致。旧视频仍保留,但需要重新生成后才能作为当前有效成片。
|
主视频已过期:当前主首帧或主体参考图与生成该视频时不一致。旧视频仍保留,但需要重新生成后才能作为当前有效成片。
|
||||||
</NAlert>
|
</NAlert>
|
||||||
<ul v-if="remainingIssues.length" class="alert alert-error mt-4 space-y-1" role="alert">
|
|
||||||
<li v-for="(issue, index) in remainingIssues" :key="`${issue.code}-${index}`">
|
|
||||||
{{ issueLabel(issue.code) }}:{{ issue.reason }}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</header>
|
</header>
|
||||||
<ShotProductionAssets
|
<ShotProductionAssets
|
||||||
:key="shot.shotId"
|
:key="shot.shotId"
|
||||||
@@ -604,6 +608,14 @@ watch(
|
|||||||
</EmptyState>
|
</EmptyState>
|
||||||
<p v-else-if="query.loading.value" class="panel mt-5 p-8 text-sm text-muted" role="status">
|
<p v-else-if="query.loading.value" class="panel mt-5 p-8 text-sm text-muted" role="status">
|
||||||
正在检查全项目生产就绪状态……
|
正在检查全项目生产就绪状态……
|
||||||
</p></WorkspacePage
|
</p>
|
||||||
>
|
<QualityDialog
|
||||||
|
v-model:open="qualityOpen"
|
||||||
|
:project-id="id"
|
||||||
|
:target="episodeNo ? { kind: 'batch', episodeNo, title: `第 ${episodeNo} 集首帧` } : null"
|
||||||
|
:disabled="blocked"
|
||||||
|
@changed="refreshProject"
|
||||||
|
@locate="locateQualityShot"
|
||||||
|
/>
|
||||||
|
</WorkspacePage>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export const productionApi = {
|
|||||||
startPipeline: (projectId: string) =>
|
startPipeline: (projectId: string) =>
|
||||||
request<ProductionPipelineResult>(`${projectPath(projectId)}/production/start`, {
|
request<ProductionPipelineResult>(`${projectPath(projectId)}/production/start`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { imageProvider: 'seedream', videoProvider: 'seedance' },
|
body: {},
|
||||||
timeoutMs: 0
|
timeoutMs: 0
|
||||||
}),
|
}),
|
||||||
promptReadiness: (projectId: string, force: boolean, signal?: AbortSignal) =>
|
promptReadiness: (projectId: string, force: boolean, signal?: AbortSignal) =>
|
||||||
@@ -65,7 +65,7 @@ export const productionApi = {
|
|||||||
retryVideos: (projectId: string, concurrency: number) =>
|
retryVideos: (projectId: string, concurrency: number) =>
|
||||||
request<RetryVideosResult>(`${projectPath(projectId)}/videos/retry-failed`, {
|
request<RetryVideosResult>(`${projectPath(projectId)}/videos/retry-failed`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { provider: 'seedance', concurrency },
|
body: { concurrency },
|
||||||
timeoutMs: 0
|
timeoutMs: 0
|
||||||
}),
|
}),
|
||||||
listKeyframes: (shotId: string, signal?: AbortSignal) =>
|
listKeyframes: (shotId: string, signal?: AbortSignal) =>
|
||||||
@@ -89,7 +89,7 @@ export const productionApi = {
|
|||||||
generateVideo: (shotId: string) =>
|
generateVideo: (shotId: string) =>
|
||||||
request<ShotVideo | null>(`${shotPath(shotId)}/videos`, {
|
request<ShotVideo | null>(`${shotPath(shotId)}/videos`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { provider: 'seedance' },
|
body: {},
|
||||||
timeoutMs: 0
|
timeoutMs: 0
|
||||||
}),
|
}),
|
||||||
refreshVideo: (videoId: string, signal?: AbortSignal) =>
|
refreshVideo: (videoId: string, signal?: AbortSignal) =>
|
||||||
|
|||||||
@@ -124,10 +124,7 @@ describe('高级串联生产的安全边界', () => {
|
|||||||
await service.start()
|
await service.start()
|
||||||
expect(posts()).toHaveLength(1)
|
expect(posts()).toHaveLength(1)
|
||||||
expect(posts()[0]?.[0]).toBe('/api/projects/capability-test/production/start')
|
expect(posts()[0]?.[0]).toBe('/api/projects/capability-test/production/start')
|
||||||
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({
|
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({})
|
||||||
imageProvider: 'seedream',
|
|
||||||
videoProvider: 'seedance'
|
|
||||||
})
|
|
||||||
expect(
|
expect(
|
||||||
fetcher.mock.calls.filter(([url]) => String(url).includes('/keyframes/readiness?force=true'))
|
fetcher.mock.calls.filter(([url]) => String(url).includes('/keyframes/readiness?force=true'))
|
||||||
).toHaveLength(2)
|
).toHaveLength(2)
|
||||||
@@ -250,7 +247,6 @@ describe('首帧批量参数', () => {
|
|||||||
service.keyframeHeight.value = 2048
|
service.keyframeHeight.value = 2048
|
||||||
await service.run('keyframes')
|
await service.run('keyframes')
|
||||||
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({
|
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({
|
||||||
provider: 'seedream',
|
|
||||||
concurrency: 2,
|
concurrency: 2,
|
||||||
force: false,
|
force: false,
|
||||||
episodeNo: 1,
|
episodeNo: 1,
|
||||||
@@ -269,6 +265,6 @@ describe('首帧批量参数', () => {
|
|||||||
service.force.value = true
|
service.force.value = true
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
await service.run('keyframes')
|
await service.run('keyframes')
|
||||||
expect(JSON.parse(String(posts()[2]?.[1]?.body))).toEqual({ provider: 'seedream', concurrency: 2, force: true })
|
expect(JSON.parse(String(posts()[2]?.[1]?.body))).toEqual({ concurrency: 2, force: true })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ watch([() => props.shotId, open], reset, { immediate: true })
|
|||||||
function submit() {
|
function submit() {
|
||||||
if (!canSubmit.value) return
|
if (!canSubmit.value) return
|
||||||
emit('generate', {
|
emit('generate', {
|
||||||
provider: 'seedream',
|
|
||||||
...(width.value === '' ? {} : { width: width.value, height: height.value as number }),
|
...(width.value === '' ? {} : { width: width.value, height: height.value as number }),
|
||||||
setPrimary: setPrimary.value
|
setPrimary: setPrimary.value
|
||||||
})
|
})
|
||||||
@@ -49,7 +48,7 @@ function submit() {
|
|||||||
<AppDialog
|
<AppDialog
|
||||||
v-model:open="open"
|
v-model:open="open"
|
||||||
:title="`${replacePrimary ? '重新生成主首帧' : '生成首帧'} · ${shotTitle}`"
|
:title="`${replacePrimary ? '重新生成主首帧' : '生成首帧'} · ${shotTitle}`"
|
||||||
description="依据视觉风格、GenerationSpec 和主体主参考图编译首帧提示词,并调用 Seedream。"
|
description="依据视觉风格、GenerationSpec 和主体主参考图编译首帧提示词,并调用 图片模型。"
|
||||||
>
|
>
|
||||||
<NAlert v-if="replacePrimary" type="info" :show-icon="false" class="mt-5 text-xs">
|
<NAlert v-if="replacePrimary" type="info" :show-icon="false" class="mt-5 text-xs">
|
||||||
当前主首帧使用的主体参考资产已经变化。新图成功后应设为主首帧,旧图会继续保留为历史候选。
|
当前主首帧使用的主体参考资产已经变化。新图成功后应设为主首帧,旧图会继续保留为历史候选。
|
||||||
@@ -83,7 +82,7 @@ function submit() {
|
|||||||
id="confirm-keyframe-cost"
|
id="confirm-keyframe-cost"
|
||||||
v-model:checked="confirmed"
|
v-model:checked="confirmed"
|
||||||
class="mt-5 flex items-start gap-3 text-sm leading-6"
|
class="mt-5 flex items-start gap-3 text-sm leading-6"
|
||||||
>我已确认调用 Seedream 可能产生费用,且当前没有相同镜头的生图任务。
|
>我已确认调用图片模型可能产生费用,且当前没有相同镜头的生图任务。
|
||||||
</NCheckbox>
|
</NCheckbox>
|
||||||
<div class="dialog-footer mt-6">
|
<div class="dialog-footer mt-6">
|
||||||
<NButton @click="open = false">取消</NButton>
|
<NButton @click="open = false">取消</NButton>
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onScopeDispose, ref, watch } from 'vue'
|
||||||
|
import { NAlert, NButton } from 'naive-ui'
|
||||||
|
import { qualityApi } from '../quality-api'
|
||||||
|
import type { ImageProviderCapability, ShotProviderCapability } from '../quality.types'
|
||||||
|
import { errorMessage } from '../../../lib/http'
|
||||||
|
|
||||||
|
/** 模型由后端配置;此入口只读公开能力,不提供无效的前端模型切换。 */
|
||||||
|
const props = defineProps<{ shotId?: string }>()
|
||||||
|
const providers = ref<ImageProviderCapability[] | null>(null)
|
||||||
|
const capability = ref<ShotProviderCapability | null>(null)
|
||||||
|
const error = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
let revision = 0
|
||||||
|
onScopeDispose(() => {
|
||||||
|
revision++
|
||||||
|
})
|
||||||
|
watch(
|
||||||
|
() => props.shotId,
|
||||||
|
() => {
|
||||||
|
revision++
|
||||||
|
providers.value = null
|
||||||
|
capability.value = null
|
||||||
|
error.value = ''
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
/** 按需检查,不自动生成图片;空上限表示未声明,不冒充无限制。 */
|
||||||
|
async function inspect() {
|
||||||
|
if (loading.value) return
|
||||||
|
const version = ++revision
|
||||||
|
const shotId = props.shotId
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const list = await qualityApi.providers()
|
||||||
|
if (version !== revision) return
|
||||||
|
const result = shotId ? await qualityApi.capability(shotId) : null
|
||||||
|
if (version !== revision) return
|
||||||
|
if (!Array.isArray(list) || list.some(item => !item.capabilities?.references))
|
||||||
|
throw new Error('模型能力接口返回不完整。')
|
||||||
|
providers.value = list
|
||||||
|
capability.value = result
|
||||||
|
} catch (cause) {
|
||||||
|
if (version === revision) error.value = errorMessage(cause)
|
||||||
|
} finally {
|
||||||
|
if (version === revision) loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<section class="mt-4 text-xs">
|
||||||
|
<NButton size="small" :loading="loading" :disabled="loading" @click="inspect">查看后端模型能力</NButton>
|
||||||
|
<NAlert v-if="error" type="error" :show-icon="false" class="mt-3">{{ error }}</NAlert>
|
||||||
|
<template v-if="providers">
|
||||||
|
<p v-if="!providers.some(item => item.active)" class="mt-2 text-danger">
|
||||||
|
没有匹配的启用图片模型,请检查后端配置。
|
||||||
|
</p>
|
||||||
|
<p v-for="item in providers" :key="item.provider" class="mt-2">
|
||||||
|
{{ item.provider }} · {{ item.active ? '当前启用' : '已注册' }} ·
|
||||||
|
{{
|
||||||
|
!item.capabilities.references.supported
|
||||||
|
? '不支持参考图'
|
||||||
|
: item.capabilities.references.maxReferenceImages === null
|
||||||
|
? '参考图上限未声明'
|
||||||
|
: `最多 ${item.capabilities.references.maxReferenceImages} 张参考图`
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<NAlert v-if="capability" :type="capability.valid ? 'info' : 'error'" :show-icon="false" class="mt-3">{{
|
||||||
|
capability.valid
|
||||||
|
? `当前镜头需要 ${capability.referenceCount} 张参考图,符合 ${capability.provider} 的已声明能力。`
|
||||||
|
: capability.message || '当前镜头不满足模型能力限制。'
|
||||||
|
}}</NAlert>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -47,7 +47,7 @@ function exportReceipt() {
|
|||||||
:show-icon="false"
|
:show-icon="false"
|
||||||
class="mt-3"
|
class="mt-3"
|
||||||
>
|
>
|
||||||
视频任务已经创建,但尚未代表成片完成。后台会继续查询 Seedance,以下方任务状态和视频资产为准。
|
视频任务已经创建,但尚未代表成片完成。后台会继续查询 视频模型,以下方任务状态和视频资产为准。
|
||||||
</NAlert>
|
</NAlert>
|
||||||
<NAlert v-if="receipt.result.failed" role="alert" type="error" :show-icon="false" class="mt-3">
|
<NAlert v-if="receipt.result.failed" role="alert" type="error" :show-icon="false" class="mt-3">
|
||||||
本次存在部分失败,成功提交或生成的结果不会回滚。请查看逐镜错误后再重试。
|
本次存在部分失败,成功提交或生成的结果不会回滚。请查看逐镜错误后再重试。
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import {
|
||||||
|
NAlert,
|
||||||
|
NButton,
|
||||||
|
NCheckbox,
|
||||||
|
NCollapse,
|
||||||
|
NCollapseItem,
|
||||||
|
NInput,
|
||||||
|
NInputNumber,
|
||||||
|
NRadioButton,
|
||||||
|
NRadioGroup
|
||||||
|
} from 'naive-ui'
|
||||||
|
import { AppDialog } from '../../../components/ui'
|
||||||
|
import { allowedTextLines, savedVideoValidation, validQualityInput } from '../quality'
|
||||||
|
import type { QualityAction, QualityInput, QualityTarget } from '../quality.types'
|
||||||
|
import { useQuality } from '../useQuality'
|
||||||
|
import QualityResult from './QualityResult.vue'
|
||||||
|
import ModelCapabilities from './ModelCapabilities.vue'
|
||||||
|
|
||||||
|
/** 单素材与当前集质量生成共用一个按需打开的面板,默认小批量且不自动调用模型。 */
|
||||||
|
const props = defineProps<{
|
||||||
|
projectId: string
|
||||||
|
target: QualityTarget | null
|
||||||
|
disabled: boolean
|
||||||
|
savedRawJson?: string | null
|
||||||
|
}>()
|
||||||
|
const open = defineModel<boolean>('open', { default: false })
|
||||||
|
const emit = defineEmits<{ changed: []; locate: [shotId: string] }>()
|
||||||
|
const { key, session, operation, blocked, run } = useQuality(
|
||||||
|
props,
|
||||||
|
() => open.value,
|
||||||
|
() => emit('changed')
|
||||||
|
)
|
||||||
|
const action = ref<QualityAction>('validate')
|
||||||
|
const attempts = ref<number | null>(1)
|
||||||
|
const concurrency = ref<number | null>(1)
|
||||||
|
const limit = ref<number | null>(1)
|
||||||
|
const force = ref(false)
|
||||||
|
const width = ref<number | null>(null)
|
||||||
|
const height = ref<number | null>(null)
|
||||||
|
const texts = ref('')
|
||||||
|
const acknowledged = ref(false)
|
||||||
|
const videoRepair = computed(() => props.target?.kind === 'video' && action.value === 'repair')
|
||||||
|
const input = computed<QualityInput>(() => ({
|
||||||
|
concurrency: action.value === 'batch' ? (concurrency.value ?? 0) : 1,
|
||||||
|
limit: action.value === 'batch' ? (limit.value ?? 0) : 1,
|
||||||
|
maxRepairAttempts: action.value === 'validate' ? 0 : (attempts.value ?? -1),
|
||||||
|
force: force.value,
|
||||||
|
episodeNo: props.target?.kind === 'batch' ? props.target.episodeNo : 1,
|
||||||
|
allowedTexts: allowedTextLines(texts.value),
|
||||||
|
...(action.value === 'validate' || width.value === null ? {} : { width: width.value }),
|
||||||
|
...(action.value === 'validate' || height.value === null ? {} : { height: height.value })
|
||||||
|
}))
|
||||||
|
const valid = computed(
|
||||||
|
() => validQualityInput(input.value) && (!videoRepair.value || input.value.maxRepairAttempts > 0)
|
||||||
|
)
|
||||||
|
const generating = computed(() => action.value !== 'validate')
|
||||||
|
const label = computed(() =>
|
||||||
|
action.value === 'batch'
|
||||||
|
? '开始质量生成'
|
||||||
|
: action.value === 'repair'
|
||||||
|
? videoRepair.value
|
||||||
|
? '创建视频修复候选'
|
||||||
|
: '校验并修复'
|
||||||
|
: '开始视觉校验'
|
||||||
|
)
|
||||||
|
const maxImages = computed(() =>
|
||||||
|
action.value === 'batch'
|
||||||
|
? input.value.limit * (1 + input.value.maxRepairAttempts)
|
||||||
|
: action.value === 'repair'
|
||||||
|
? input.value.maxRepairAttempts
|
||||||
|
: 0
|
||||||
|
)
|
||||||
|
const maxValidations = computed(() =>
|
||||||
|
action.value === 'batch' ? maxImages.value : action.value === 'repair' ? input.value.maxRepairAttempts + 1 : 1
|
||||||
|
)
|
||||||
|
const saved = computed(() => (props.target?.kind === 'video' ? savedVideoValidation(props.savedRawJson) : null))
|
||||||
|
const repairAvailable = computed(
|
||||||
|
() =>
|
||||||
|
!videoRepair.value ||
|
||||||
|
(session.value.receipt?.kind === 'video'
|
||||||
|
? session.value.receipt.result.passed === false
|
||||||
|
: saved.value?.passed === false)
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 目标和费用范围变化均撤销确认;关闭不取消已经发送到后端的长请求。 */
|
||||||
|
watch(
|
||||||
|
[key, open],
|
||||||
|
() => {
|
||||||
|
action.value = props.target?.kind === 'batch' ? 'batch' : 'validate'
|
||||||
|
attempts.value = props.target?.kind === 'video' ? 2 : 1
|
||||||
|
concurrency.value = 1
|
||||||
|
limit.value = 1
|
||||||
|
force.value = false
|
||||||
|
width.value = null
|
||||||
|
height.value = null
|
||||||
|
texts.value = ''
|
||||||
|
acknowledged.value = false
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
watch([input, action], () => {
|
||||||
|
acknowledged.value = false
|
||||||
|
})
|
||||||
|
/** 付费入口保留明确确认,不因菜单选择或查看历史自动提交。 */
|
||||||
|
async function submit() {
|
||||||
|
if (!acknowledged.value || !valid.value || blocked.value || !repairAvailable.value) return
|
||||||
|
acknowledged.value = false
|
||||||
|
await run(action.value, input.value)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AppDialog
|
||||||
|
v-model:open="open"
|
||||||
|
:title="`质量检查 · ${target?.title ?? ''}`"
|
||||||
|
description="查看质量结果,或按需发起新的视觉检查。"
|
||||||
|
wide
|
||||||
|
>
|
||||||
|
<NRadioGroup
|
||||||
|
v-if="target && target.kind !== 'batch'"
|
||||||
|
v-model:value="action"
|
||||||
|
:disabled="operation.pending"
|
||||||
|
class="mt-4"
|
||||||
|
aria-label="素材质量操作"
|
||||||
|
>
|
||||||
|
<NRadioButton value="validate">视觉校验</NRadioButton
|
||||||
|
><NRadioButton value="repair">{{ target?.kind === 'video' ? '生成修复候选' : '校验并修复' }}</NRadioButton>
|
||||||
|
</NRadioGroup>
|
||||||
|
<NAlert v-if="target?.kind === 'video'" type="info" :show-icon="false" class="mt-4"
|
||||||
|
>视频校验只检查三个抽样帧,不含音频、口型和完整运动。普通候选只校验;修复候选复检通过后会自动设为主视频。</NAlert
|
||||||
|
>
|
||||||
|
<div v-if="generating" class="quality-fields mt-4">
|
||||||
|
<label
|
||||||
|
><span class="field-label">{{ videoRepair ? '修复链次数上限' : '每镜最多修复' }}</span
|
||||||
|
><NInputNumber
|
||||||
|
v-model:value="attempts"
|
||||||
|
:disabled="operation.pending"
|
||||||
|
:min="videoRepair ? 1 : 0"
|
||||||
|
:step="1"
|
||||||
|
:input-props="{ 'aria-label': '每镜最多修复次数' }"
|
||||||
|
/></label>
|
||||||
|
<label v-if="target?.kind === 'batch'"
|
||||||
|
><span class="field-label">本批镜头上限</span
|
||||||
|
><NInputNumber
|
||||||
|
v-model:value="limit"
|
||||||
|
:disabled="operation.pending"
|
||||||
|
:min="1"
|
||||||
|
:step="1"
|
||||||
|
:input-props="{ 'aria-label': '质量生成镜头上限' }"
|
||||||
|
/></label>
|
||||||
|
</div>
|
||||||
|
<NCheckbox v-if="target?.kind === 'batch'" v-model:checked="force" :disabled="operation.pending" class="mt-4"
|
||||||
|
>重做本集已有有效首帧(通过后替换主图)</NCheckbox
|
||||||
|
>
|
||||||
|
<p v-if="target?.kind === 'batch'" class="mt-3 text-xs text-muted">
|
||||||
|
仅第 {{ target.episodeNo }} 集,最多
|
||||||
|
{{ limit ?? '—' }} 镜;非重做模式优先处理过期项,若全项目无过期项则补缺失首帧。
|
||||||
|
</p>
|
||||||
|
<p v-if="videoRepair" class="mt-3 text-xs text-muted">
|
||||||
|
本次仅创建一个修复视频,不自动等待或复检。完成后请打开新候选的质量检查;修复约束沿用上次失败校验,不使用额外参数。
|
||||||
|
</p>
|
||||||
|
<NAlert v-if="!repairAvailable" type="warning" :show-icon="false" class="mt-3"
|
||||||
|
>请先执行视觉校验,只有未通过的视频可以创建修复候选。</NAlert
|
||||||
|
>
|
||||||
|
<NCollapse v-if="!videoRepair" class="mt-4">
|
||||||
|
<NCollapseItem name="options" title="更多参数与模型限制">
|
||||||
|
<div class="quality-fields">
|
||||||
|
<label v-if="target?.kind === 'batch'"
|
||||||
|
><span class="field-label">批量并发</span
|
||||||
|
><NInputNumber
|
||||||
|
v-model:value="concurrency"
|
||||||
|
:disabled="operation.pending"
|
||||||
|
:min="1"
|
||||||
|
:step="1"
|
||||||
|
:input-props="{ 'aria-label': '质量生成并发' }"
|
||||||
|
/></label>
|
||||||
|
<label v-if="generating"
|
||||||
|
><span class="field-label">宽度(可选)</span
|
||||||
|
><NInputNumber
|
||||||
|
v-model:value="width"
|
||||||
|
:disabled="operation.pending"
|
||||||
|
:min="1"
|
||||||
|
:input-props="{ 'aria-label': '质量生成宽度' }"
|
||||||
|
/></label>
|
||||||
|
<label v-if="generating"
|
||||||
|
><span class="field-label">高度(可选)</span
|
||||||
|
><NInputNumber
|
||||||
|
v-model:value="height"
|
||||||
|
:disabled="operation.pending"
|
||||||
|
:min="1"
|
||||||
|
:input-props="{ 'aria-label': '质量生成高度' }"
|
||||||
|
/></label>
|
||||||
|
</div>
|
||||||
|
<label class="mt-4 block"
|
||||||
|
><span class="field-label">额外允许的画面文字(一行一项,可留空)</span
|
||||||
|
><NInput
|
||||||
|
v-model:value="texts"
|
||||||
|
:disabled="operation.pending"
|
||||||
|
type="textarea"
|
||||||
|
:autosize="{ minRows: 2, maxRows: 5 }"
|
||||||
|
:input-props="{ 'aria-label': '额外允许的画面文字' }"
|
||||||
|
/></label>
|
||||||
|
<ModelCapabilities :shot-id="target && target.kind !== 'batch' ? target.shotId : undefined" />
|
||||||
|
</NCollapseItem>
|
||||||
|
</NCollapse>
|
||||||
|
<NAlert v-if="!valid" type="error" :show-icon="false" class="mt-4">{{
|
||||||
|
videoRepair
|
||||||
|
? '修复链次数上限需为正整数。'
|
||||||
|
: '并发、数量和集数需为正整数,修复次数需为非负整数;宽高同时留空或同时填写正整数。'
|
||||||
|
}}</NAlert>
|
||||||
|
<NAlert type="warning" :show-icon="false" class="mt-4">
|
||||||
|
<template v-if="videoRepair"
|
||||||
|
>本次最多提交 1
|
||||||
|
个视频生成任务,可能产生费用;生成完成不会切换主视频,需再次付费复检通过后才会晋升。</template
|
||||||
|
>
|
||||||
|
<template v-else-if="valid"
|
||||||
|
>本次最多调用 {{ maxImages }} 次生图、{{ maxValidations }} 次视觉校验,均可能产生费用。</template
|
||||||
|
>
|
||||||
|
<template v-if="generating && !videoRepair"
|
||||||
|
>修复/质量生成通过后可能替换主首帧;失败候选仍会保留,已有视频不会自动更新。</template
|
||||||
|
>
|
||||||
|
<template v-else-if="!generating">{{
|
||||||
|
target?.kind === 'video' ? '如果当前是修复候选,校验通过将替换当前主视频。' : '仅校验,不修改主资产。'
|
||||||
|
}}</template>
|
||||||
|
</NAlert>
|
||||||
|
<NCheckbox v-model:checked="acknowledged" :disabled="operation.pending" class="mt-4"
|
||||||
|
>我已确认费用、处理范围,且同项目没有其他生成任务。</NCheckbox
|
||||||
|
>
|
||||||
|
<div class="dialog-footer mt-4">
|
||||||
|
<NButton @click="open = false">关闭</NButton>
|
||||||
|
<NButton
|
||||||
|
type="primary"
|
||||||
|
:loading="operation.pending"
|
||||||
|
:disabled="blocked || !valid || !acknowledged || !repairAvailable"
|
||||||
|
@click="submit"
|
||||||
|
>{{ label }}</NButton
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<p v-if="operation.pending" role="status" class="mt-3 text-xs text-muted">
|
||||||
|
{{ operation.label }}进行中。关闭面板不会取消后端任务;请勿重复提交。
|
||||||
|
</p>
|
||||||
|
<NAlert v-if="session.error" role="alert" type="error" :show-icon="false" class="mt-4">{{
|
||||||
|
session.error
|
||||||
|
}}</NAlert>
|
||||||
|
<QualityResult v-if="session.receipt" :receipt="session.receipt" @locate="emit('locate', $event)" />
|
||||||
|
<NCollapse v-else-if="saved" class="mt-4"
|
||||||
|
><NCollapseItem name="saved" :title="`最近已保存校验 · ${saved.passed ? '通过' : '未通过'}`"
|
||||||
|
><p>{{ saved.summary }}</p>
|
||||||
|
<p v-for="(issue, index) in saved.issues" :key="index" class="mt-2 text-danger">
|
||||||
|
{{ issue.description }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-3 text-xs text-muted">此为历史抽样结论,不保证当前上游素材仍然一致。</p></NCollapseItem
|
||||||
|
></NCollapse
|
||||||
|
>
|
||||||
|
<p v-if="session.receipt && target?.kind !== 'video'" class="mt-3 text-xs text-muted">
|
||||||
|
回执保留在当前会话,可导出;后端暂未提供首帧校验历史读取接口。
|
||||||
|
</p>
|
||||||
|
</AppDialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { NAlert, NButton, NCollapse, NCollapseItem } from 'naive-ui'
|
||||||
|
import { downloadText } from '../../../lib/format'
|
||||||
|
import type { QualityReceipt } from '../quality.types'
|
||||||
|
|
||||||
|
/** 摘要与失败直接显示,完整逐轮信息按需展开,不用完成请求冒充质量通过。 */
|
||||||
|
const props = defineProps<{ receipt: QualityReceipt }>()
|
||||||
|
const emit = defineEmits<{ locate: [shotId: string] }>()
|
||||||
|
const passed = computed(() =>
|
||||||
|
props.receipt.kind === 'batch'
|
||||||
|
? props.receipt.result.targetCount > 0 && props.receipt.result.passed === props.receipt.result.targetCount
|
||||||
|
: props.receipt.kind === 'video-repair'
|
||||||
|
? false
|
||||||
|
: props.receipt.result.passed
|
||||||
|
)
|
||||||
|
const issues = computed(() => {
|
||||||
|
const value = props.receipt
|
||||||
|
return value.kind === 'batch' || value.kind === 'video-repair'
|
||||||
|
? []
|
||||||
|
: value.kind === 'repair'
|
||||||
|
? (value.result.attempts.at(-1)?.issues ?? [])
|
||||||
|
: value.result.issues
|
||||||
|
})
|
||||||
|
/** 导出只使用后端实际回执,便于关闭页面前留存。 */
|
||||||
|
function exportResult() {
|
||||||
|
downloadText(
|
||||||
|
`quality-${props.receipt.kind}.json`,
|
||||||
|
JSON.stringify(props.receipt.result, null, 2),
|
||||||
|
'application/json'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="quality-result" aria-label="质量检查结果">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<strong :class="receipt.kind === 'video-repair' ? '' : passed ? 'text-success' : 'text-danger'">{{
|
||||||
|
receipt.kind === 'video-repair'
|
||||||
|
? '修复任务已提交'
|
||||||
|
: passed
|
||||||
|
? '视觉校验通过'
|
||||||
|
: receipt.kind === 'batch' && !receipt.result.targetCount
|
||||||
|
? '未执行镜头'
|
||||||
|
: '仍需处理'
|
||||||
|
}}</strong>
|
||||||
|
<NButton text size="small" @click="exportResult">导出结果</NButton>
|
||||||
|
</div>
|
||||||
|
<template v-if="receipt.kind === 'batch'">
|
||||||
|
<p class="mt-3 text-xs text-muted">
|
||||||
|
目标 {{ receipt.result.targetCount }} · 通过 {{ receipt.result.passed }} · 修复未通过
|
||||||
|
{{ receipt.result.repairFailed }} · 调用失败 {{ receipt.result.failed }} · 待后续
|
||||||
|
{{ Math.max(0, receipt.result.selected - receipt.result.targetCount) }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-xs text-muted">通过的候选已设为主首帧,未通过的不会替换原主图。</p>
|
||||||
|
<div v-for="item in receipt.result.results" :key="item.shotId" class="quality-result-row">
|
||||||
|
<NButton text size="small" @click="emit('locate', item.shotId)">定位镜头</NButton>
|
||||||
|
<code class="text-xs text-muted">{{ item.shotId }}</code>
|
||||||
|
<span :class="item.success ? 'text-success' : 'text-danger'">{{
|
||||||
|
item.success ? '已通过' : item.error || '未通过'
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="receipt.kind === 'repair'">
|
||||||
|
<p class="mt-3 text-sm">
|
||||||
|
{{
|
||||||
|
receipt.result.primaryChanged
|
||||||
|
? '修复通过,新首帧已替换主图。已有视频需重新核对。'
|
||||||
|
: receipt.result.passed
|
||||||
|
? '原首帧通过检查,主图未切换。'
|
||||||
|
: '达到修复上限仍未通过,原主图保持不变。'
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-xs text-muted">
|
||||||
|
已重生成 {{ receipt.result.repairAttempts }} 次,视觉校验 {{ receipt.result.attempts.length }} 轮。
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="receipt.kind === 'video-repair'">
|
||||||
|
<p class="mt-3 text-sm">
|
||||||
|
第 {{ receipt.result.repairAttempt }} 次修复候选已创建,尚未完成质量复检;当前主视频未切换。
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-xs text-muted">
|
||||||
|
候选 ID:{{
|
||||||
|
receipt.result.candidate.id
|
||||||
|
}}。关闭此面板,在下方视频记录查看任务进度;完成后对新候选执行视觉校验。
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<p class="mt-3 text-sm">{{ receipt.result.summary || '后端未提供摘要。' }}</p>
|
||||||
|
<p v-if="receipt.kind === 'video'" class="mt-2 text-xs text-muted">
|
||||||
|
{{
|
||||||
|
receipt.promoted ? '修复候选已通过,后端已将其设为主视频。' : '本次校验未晋升主视频。'
|
||||||
|
}}仅为抽样帧视觉结果,不含音频、口型或完整运动检测。
|
||||||
|
</p>
|
||||||
|
<p v-else class="mt-2 text-xs text-muted">只校验,不重生成或切换主首帧。</p>
|
||||||
|
</template>
|
||||||
|
<NAlert
|
||||||
|
v-for="(issue, index) in issues"
|
||||||
|
:key="index"
|
||||||
|
:type="issue.severity === 'error' ? 'error' : 'warning'"
|
||||||
|
:show-icon="false"
|
||||||
|
class="mt-3"
|
||||||
|
>
|
||||||
|
{{ issue.frameLabel ? `${issue.frameLabel} · ` : '' }}{{ issue.description }}
|
||||||
|
</NAlert>
|
||||||
|
<NCollapse class="mt-4">
|
||||||
|
<NCollapseItem name="details" title="主体检查与执行详情">
|
||||||
|
<template v-if="receipt.kind === 'keyframe' || receipt.kind === 'video'">
|
||||||
|
<p class="text-xs">
|
||||||
|
主体数量:{{ receipt.result.subjectCountConsistent ? '一致' : '不一致'
|
||||||
|
}}<template v-if="receipt.kind === 'video'">
|
||||||
|
· 时间一致性:{{ receipt.result.temporalConsistency ? '通过' : '未通过' }}</template
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-xs">
|
||||||
|
未授权文字:{{
|
||||||
|
receipt.result.unauthorizedText.detected
|
||||||
|
? receipt.result.unauthorizedText.texts.join('、') || '已发现'
|
||||||
|
: '未发现'
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-for="subject in receipt.result.subjects"
|
||||||
|
:key="subject.subjectRef"
|
||||||
|
class="quality-result-row"
|
||||||
|
>
|
||||||
|
<code>{{ subject.subjectRef }}</code>
|
||||||
|
<span
|
||||||
|
>身份 {{ subject.identityConsistent ? '✓' : '✕' }} · 形态
|
||||||
|
{{ subject.formConsistent ? '✓' : '✕'
|
||||||
|
}}<template v-if="subject.stateConsistent !== undefined">
|
||||||
|
· 状态 {{ subject.stateConsistent ? '✓' : '✕' }}</template
|
||||||
|
></span
|
||||||
|
>
|
||||||
|
<p v-for="issue in subject.issues" :key="issue">{{ issue }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<pre class="quality-json">{{ JSON.stringify(receipt.result, null, 2) }}</pre>
|
||||||
|
</NCollapseItem>
|
||||||
|
</NCollapse>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NAlert, NButton, NCollapse, NCollapseItem, NTag } from 'naive-ui'
|
import { NAlert, NButton, NCollapse, NCollapseItem, NTag } from 'naive-ui'
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { ExternalLink, FileSearch, ImagePlus, RefreshCw } from '@lucide/vue'
|
import { ExternalLink, ImagePlus, RefreshCw } from '@lucide/vue'
|
||||||
import { AssetImage, StatusBadge } from '../../../components/ui'
|
import { AssetImage, StatusBadge } from '../../../components/ui'
|
||||||
import { usePolling } from '../../../composables/usePolling'
|
import { usePolling } from '../../../composables/usePolling'
|
||||||
import { mediaAssetUrl } from '../../../lib/assets'
|
import { mediaAssetUrl } from '../../../lib/assets'
|
||||||
@@ -22,6 +22,12 @@ import type {
|
|||||||
VideoReadinessItem
|
VideoReadinessItem
|
||||||
} from '../types'
|
} from '../types'
|
||||||
import KeyframeDialog from './KeyframeDialog.vue'
|
import KeyframeDialog from './KeyframeDialog.vue'
|
||||||
|
import QualityDialog from './QualityDialog.vue'
|
||||||
|
import ActionMenu from '../../../components/ui/ActionMenu.vue'
|
||||||
|
import { AppDialog } from '../../../components/ui'
|
||||||
|
import ModelCapabilities from './ModelCapabilities.vue'
|
||||||
|
import type { QualityTarget } from '../quality.types'
|
||||||
|
import { savedVideoValidation, videoRepairInfo } from '../quality'
|
||||||
|
|
||||||
/** 单镜头生产面板只负责正式资产,不修改上游导演设计与即时状态。 */
|
/** 单镜头生产面板只负责正式资产,不修改上游导演设计与即时状态。 */
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -51,6 +57,24 @@ const activeVideo = computed(() => videos.value.find(isActiveVideo))
|
|||||||
const operation = computed(() => getOperation(props.projectId))
|
const operation = computed(() => getOperation(props.projectId))
|
||||||
const assetBlocked = computed(() => props.disabled || operation.value.pending || !!query.error.value)
|
const assetBlocked = computed(() => props.disabled || operation.value.pending || !!query.error.value)
|
||||||
const keyframeOpen = ref(false)
|
const keyframeOpen = ref(false)
|
||||||
|
const qualityOpen = ref(false)
|
||||||
|
const qualityTarget = ref<QualityTarget | null>(null)
|
||||||
|
const qualityRawJson = computed(() => {
|
||||||
|
const target = qualityTarget.value
|
||||||
|
return target?.kind === 'video' ? (videos.value.find(item => item.id === target.assetId)?.rawJson ?? null) : null
|
||||||
|
})
|
||||||
|
const specsOpen = ref(false)
|
||||||
|
|
||||||
|
/** 从实际候选打开质量面板,保留单镜头主操作,不默认展开全部校验参数。 */
|
||||||
|
function openQuality(kind: 'keyframe' | 'video', assetId: string) {
|
||||||
|
qualityTarget.value = {
|
||||||
|
kind,
|
||||||
|
shotId: props.shot.shotId,
|
||||||
|
assetId,
|
||||||
|
title: `${props.shot.title} · ${kind === 'keyframe' ? '首帧' : '视频'}`
|
||||||
|
}
|
||||||
|
qualityOpen.value = true
|
||||||
|
}
|
||||||
const confirmingKeyframe = ref('')
|
const confirmingKeyframe = ref('')
|
||||||
const confirmingVideo = ref('')
|
const confirmingVideo = ref('')
|
||||||
const inspecting = ref(false)
|
const inspecting = ref(false)
|
||||||
@@ -61,7 +85,11 @@ const canGeneratePrompt = computed(
|
|||||||
() => !assetBlocked.value && !!props.promptReadiness && props.promptReadiness.issues.length === 0
|
() => !assetBlocked.value && !!props.promptReadiness && props.promptReadiness.issues.length === 0
|
||||||
)
|
)
|
||||||
const canGenerateKeyframe = computed(
|
const canGenerateKeyframe = computed(
|
||||||
() => !assetBlocked.value && !!props.keyframeReadiness && props.keyframeReadiness.issues.length === 0
|
() =>
|
||||||
|
!assetBlocked.value &&
|
||||||
|
!keyframes.value.some(item => ['pending', 'generating'].includes(item.status)) &&
|
||||||
|
!!props.keyframeReadiness &&
|
||||||
|
props.keyframeReadiness.issues.length === 0
|
||||||
)
|
)
|
||||||
const canGenerateVideo = computed(
|
const canGenerateVideo = computed(
|
||||||
() =>
|
() =>
|
||||||
@@ -71,6 +99,9 @@ const keyframeStale = computed(() => !!props.keyframeReadiness?.primaryKeyframeS
|
|||||||
const videoStale = computed(() => !!props.videoReadiness?.primaryVideoStale)
|
const videoStale = computed(() => !!props.videoReadiness?.primaryVideoStale)
|
||||||
|
|
||||||
watch(key, () => {
|
watch(key, () => {
|
||||||
|
qualityOpen.value = false
|
||||||
|
qualityTarget.value = null
|
||||||
|
specsOpen.value = false
|
||||||
confirmingKeyframe.value = ''
|
confirmingKeyframe.value = ''
|
||||||
confirmingVideo.value = ''
|
confirmingVideo.value = ''
|
||||||
keyframeSpec.value = null
|
keyframeSpec.value = null
|
||||||
@@ -101,7 +132,7 @@ async function generatePrompt() {
|
|||||||
await refreshAll()
|
await refreshAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 调用 Seedream 生成单镜头首帧;成功后重新读取候选和主图。 */
|
/** 调用图片模型生成单镜头首帧;成功后重新读取候选和主图。 */
|
||||||
async function generateKeyframe(input: GenerateKeyframeInput) {
|
async function generateKeyframe(input: GenerateKeyframeInput) {
|
||||||
if (!canGenerateKeyframe.value) return
|
if (!canGenerateKeyframe.value) return
|
||||||
const shotId = props.shot.shotId
|
const shotId = props.shot.shotId
|
||||||
@@ -126,7 +157,7 @@ async function setPrimaryKeyframe() {
|
|||||||
await refreshAll()
|
await refreshAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 创建单镜头 Seedance 视频任务;任务完成由后台轮询确认。 */
|
/** 创建单镜头视频模型视频任务;任务完成由后台轮询确认。 */
|
||||||
async function generateVideo() {
|
async function generateVideo() {
|
||||||
if (!canGenerateVideo.value) return
|
if (!canGenerateVideo.value) return
|
||||||
const shotId = props.shot.shotId
|
const shotId = props.shot.shotId
|
||||||
@@ -153,6 +184,8 @@ async function refreshActiveVideo() {
|
|||||||
async function setPrimaryVideo() {
|
async function setPrimaryVideo() {
|
||||||
const videoId = confirmingVideo.value
|
const videoId = confirmingVideo.value
|
||||||
if (assetBlocked.value || !videoId) return
|
if (assetBlocked.value || !videoId) return
|
||||||
|
const video = videos.value.find(item => item.id === videoId)
|
||||||
|
if (!video || (videoRepairInfo(video.rawJson) && savedVideoValidation(video.rawJson)?.passed !== true)) return
|
||||||
const shotId = props.shot.shotId
|
const shotId = props.shot.shotId
|
||||||
confirmingVideo.value = ''
|
confirmingVideo.value = ''
|
||||||
await runOperation(props.projectId, `设置镜头 ${props.shot.shotNo} 主视频`, async () => {
|
await runOperation(props.projectId, `设置镜头 ${props.shot.shotNo} 主视频`, async () => {
|
||||||
@@ -165,6 +198,7 @@ async function setPrimaryVideo() {
|
|||||||
|
|
||||||
/** 显式检查两份确定性生成规格;某一阶段未就绪时保留另一份可读结果。 */
|
/** 显式检查两份确定性生成规格;某一阶段未就绪时保留另一份可读结果。 */
|
||||||
async function inspectSpecs() {
|
async function inspectSpecs() {
|
||||||
|
specsOpen.value = true
|
||||||
inspecting.value = true
|
inspecting.value = true
|
||||||
specError.value = ''
|
specError.value = ''
|
||||||
const shotId = props.shot.shotId
|
const shotId = props.shot.shotId
|
||||||
@@ -172,6 +206,10 @@ async function inspectSpecs() {
|
|||||||
productionApi.keyframeSpec(shotId),
|
productionApi.keyframeSpec(shotId),
|
||||||
productionApi.videoGenerationSpec(shotId)
|
productionApi.videoGenerationSpec(shotId)
|
||||||
])
|
])
|
||||||
|
if (props.shot.shotId !== shotId) {
|
||||||
|
inspecting.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
keyframeSpec.value = keyframeResult.status === 'fulfilled' ? keyframeResult.value : null
|
keyframeSpec.value = keyframeResult.status === 'fulfilled' ? keyframeResult.value : null
|
||||||
videoSpec.value = videoResult.status === 'fulfilled' ? videoResult.value : null
|
videoSpec.value = videoResult.status === 'fulfilled' ? videoResult.value : null
|
||||||
const errors = [keyframeResult, videoResult]
|
const errors = [keyframeResult, videoResult]
|
||||||
@@ -187,27 +225,32 @@ async function inspectSpecs() {
|
|||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h4 class="text-sm font-semibold">镜头生产资产</h4>
|
<h4 class="text-sm font-semibold">镜头生产资产</h4>
|
||||||
<p class="mt-1 text-[11px] text-muted">记录每次候选与失败,主资产决定下一阶段实际输入。</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<NButton :disabled="inspecting" @click="inspectSpecs" text size="small"
|
<ActionMenu
|
||||||
><FileSearch :size="13" />{{ inspecting ? '检查中…' : '检查生成规格' }}</NButton
|
label="镜头资产更多操作"
|
||||||
>
|
:items="[{ key: 'specs', label: '检查生成规格与模型限制', disabled: inspecting }]"
|
||||||
<NButton :disabled="query.loading.value" @click="refreshAll" text size="small"
|
@select="inspectSpecs"
|
||||||
><RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新资产
|
/>
|
||||||
|
<NButton
|
||||||
|
:disabled="query.loading.value"
|
||||||
|
@click="refreshAll"
|
||||||
|
quaternary
|
||||||
|
class="icon-button"
|
||||||
|
aria-label="刷新资产"
|
||||||
|
title="刷新资产"
|
||||||
|
><RefreshCw :size="16" :class="{ 'animate-spin': query.loading.value }" />
|
||||||
</NButton>
|
</NButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mt-4"
|
<NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mt-4"
|
||||||
>{{ query.error.value }} 当前保留上次成功读取的资产,操作已暂停。
|
>{{ query.error.value }} 当前保留上次成功读取的资产,操作已暂停。
|
||||||
</NAlert>
|
</NAlert>
|
||||||
<NAlert v-if="specError" role="alert" type="error" :show-icon="false" class="mt-4">{{ specError }}</NAlert>
|
|
||||||
|
|
||||||
<div class="production-stage mt-5">
|
<div class="production-stage mt-5">
|
||||||
<div class="production-stage-heading">
|
<div class="production-stage-heading">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">01 · VIDEO PROMPT</p>
|
<h5 class="text-sm font-medium">01 · 视频提示词</h5>
|
||||||
<h5 class="mt-2 text-sm font-medium">视频提示词</h5>
|
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
v-if="promptReadiness"
|
v-if="promptReadiness"
|
||||||
@@ -238,8 +281,7 @@ async function inspectSpecs() {
|
|||||||
<div class="production-stage">
|
<div class="production-stage">
|
||||||
<div class="production-stage-heading">
|
<div class="production-stage-heading">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">02 · KEYFRAME</p>
|
<h5 class="text-sm font-medium">02 · 首帧</h5>
|
||||||
<h5 class="mt-2 text-sm font-medium">主首帧与候选图</h5>
|
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
v-if="keyframeReadiness"
|
v-if="keyframeReadiness"
|
||||||
@@ -274,6 +316,14 @@ async function inspectSpecs() {
|
|||||||
</div>
|
</div>
|
||||||
<p v-if="item.error" class="mt-2 line-clamp-3 text-xs text-danger">{{ item.error }}</p>
|
<p v-if="item.error" class="mt-2 line-clamp-3 text-xs text-danger">{{ item.error }}</p>
|
||||||
<p class="mt-2 text-[10px] text-muted">{{ formatDate(item.createdAt) }}</p>
|
<p class="mt-2 text-[10px] text-muted">{{ formatDate(item.createdAt) }}</p>
|
||||||
|
<NButton
|
||||||
|
v-if="item.status === 'completed' && item.imageUrl"
|
||||||
|
text
|
||||||
|
size="small"
|
||||||
|
class="mt-2 mr-3"
|
||||||
|
@click="openQuality('keyframe', item.id)"
|
||||||
|
>质量检查</NButton
|
||||||
|
>
|
||||||
<NButton
|
<NButton
|
||||||
v-if="item.status === 'completed' && item.imageUrl && !item.isPrimary"
|
v-if="item.status === 'completed' && item.imageUrl && !item.isPrimary"
|
||||||
:disabled="assetBlocked"
|
:disabled="assetBlocked"
|
||||||
@@ -309,8 +359,7 @@ async function inspectSpecs() {
|
|||||||
<div class="production-stage">
|
<div class="production-stage">
|
||||||
<div class="production-stage-heading">
|
<div class="production-stage-heading">
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">03 · VIDEO</p>
|
<h5 class="text-sm font-medium">03 · 视频</h5>
|
||||||
<h5 class="mt-2 text-sm font-medium">Seedance 视频任务与成片</h5>
|
|
||||||
</div>
|
</div>
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
v-if="videoReadiness"
|
v-if="videoReadiness"
|
||||||
@@ -339,6 +388,9 @@ async function inspectSpecs() {
|
|||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<StatusBadge :status="item.status" :label="productionStatusLabel(item.status)" />
|
<StatusBadge :status="item.status" :label="productionStatusLabel(item.status)" />
|
||||||
<NTag v-if="item.isPrimary" size="small" :bordered="false">主视频</NTag>
|
<NTag v-if="item.isPrimary" size="small" :bordered="false">主视频</NTag>
|
||||||
|
<NTag v-if="videoRepairInfo(item.rawJson) && !item.isPrimary" size="small" :bordered="false"
|
||||||
|
>修复候选 · 待复检</NTag
|
||||||
|
>
|
||||||
<NTag v-if="item.isPrimary && videoStale" size="small" :bordered="false">已过期</NTag>
|
<NTag v-if="item.isPrimary && videoStale" size="small" :bordered="false">已过期</NTag>
|
||||||
<span class="text-[10px] text-muted"
|
<span class="text-[10px] text-muted"
|
||||||
>{{ item.durationSeconds || shot.durationSeconds || '—' }}s</span
|
>{{ item.durationSeconds || shot.durationSeconds || '—' }}s</span
|
||||||
@@ -351,6 +403,15 @@ async function inspectSpecs() {
|
|||||||
{{ item.provider }} · {{ item.model }} · {{ formatDate(item.createdAt) }}
|
{{ item.provider }} · {{ item.model }} · {{ formatDate(item.createdAt) }}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-3 flex flex-wrap gap-3">
|
<div class="mt-3 flex flex-wrap gap-3">
|
||||||
|
<NButton
|
||||||
|
v-if="item.status === 'completed' && item.videoUrl"
|
||||||
|
text
|
||||||
|
size="small"
|
||||||
|
@click="openQuality('video', item.id)"
|
||||||
|
>质量检查<template v-if="savedVideoValidation(item.rawJson)">
|
||||||
|
· {{ savedVideoValidation(item.rawJson)?.passed ? '曾通过' : '未通过' }}</template
|
||||||
|
></NButton
|
||||||
|
>
|
||||||
<a
|
<a
|
||||||
v-if="safeVideoUrl(item.videoUrl)"
|
v-if="safeVideoUrl(item.videoUrl)"
|
||||||
:href="safeVideoUrl(item.videoUrl) || undefined"
|
:href="safeVideoUrl(item.videoUrl) || undefined"
|
||||||
@@ -360,7 +421,13 @@ async function inspectSpecs() {
|
|||||||
>打开视频<ExternalLink :size="12"
|
>打开视频<ExternalLink :size="12"
|
||||||
/></a>
|
/></a>
|
||||||
<NButton
|
<NButton
|
||||||
v-if="item.status === 'completed' && item.videoUrl && !item.isPrimary"
|
v-if="
|
||||||
|
item.status === 'completed' &&
|
||||||
|
item.videoUrl &&
|
||||||
|
!item.isPrimary &&
|
||||||
|
(!videoRepairInfo(item.rawJson) ||
|
||||||
|
savedVideoValidation(item.rawJson)?.passed === true)
|
||||||
|
"
|
||||||
:disabled="assetBlocked"
|
:disabled="assetBlocked"
|
||||||
@click="confirmingVideo = item.id"
|
@click="confirmingVideo = item.id"
|
||||||
text
|
text
|
||||||
@@ -387,10 +454,10 @@ async function inspectSpecs() {
|
|||||||
acknowledgement
|
acknowledgement
|
||||||
:description="
|
:description="
|
||||||
videoStale
|
videoStale
|
||||||
? '使用当前视频提示词、主首帧和主体参考图重新创建 Seedance 任务;完成后会自动接替当前过期主视频。'
|
? '使用当前视频提示词、主首帧和主体参考图重新创建视频模型任务;完成后会自动接替当前过期主视频。'
|
||||||
: currentVideo
|
: currentVideo
|
||||||
? '使用当前视频提示词、主首帧和主体参考图新增一个 Seedance 视频候选,不自动替换主视频。'
|
? '使用当前视频提示词、主首帧和主体参考图新增一个视频模型视频候选,不自动替换主视频。'
|
||||||
: '使用当前视频提示词、主首帧和主体参考图创建 Seedance 任务;任务会在后台继续运行。'
|
: '使用当前视频提示词、主首帧和主体参考图创建视频模型任务;任务会在后台继续运行。'
|
||||||
"
|
"
|
||||||
primary
|
primary
|
||||||
@confirm="generateVideo"
|
@confirm="generateVideo"
|
||||||
@@ -401,7 +468,11 @@ async function inspectSpecs() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NCollapse v-if="keyframeSpec || videoSpec" class="mt-5 pt-4 text-xs"
|
<AppDialog v-model:open="specsOpen" title="生成规格与模型限制" description="只读检查,不调用生成模型。" wide>
|
||||||
|
<NAlert v-if="specError" role="alert" type="error" :show-icon="false" class="mt-4">{{ specError }}</NAlert>
|
||||||
|
<p v-if="inspecting" role="status" class="mt-4 text-xs text-muted">正在读取生成规格…</p>
|
||||||
|
<ModelCapabilities :shot-id="shot.shotId" />
|
||||||
|
<NCollapse v-if="keyframeSpec || videoSpec" class="mt-5 pt-4 text-xs" :default-expanded-names="['details']"
|
||||||
><NCollapseItem name="details"
|
><NCollapseItem name="details"
|
||||||
><template #header>本镜确定性生成规格</template>
|
><template #header>本镜确定性生成规格</template>
|
||||||
<div class="mt-4 grid gap-5 lg:grid-cols-2">
|
<div class="mt-4 grid gap-5 lg:grid-cols-2">
|
||||||
@@ -432,6 +503,15 @@ async function inspectSpecs() {
|
|||||||
</div></NCollapseItem
|
</div></NCollapseItem
|
||||||
></NCollapse
|
></NCollapse
|
||||||
>
|
>
|
||||||
|
</AppDialog>
|
||||||
|
<QualityDialog
|
||||||
|
v-model:open="qualityOpen"
|
||||||
|
:project-id="projectId"
|
||||||
|
:target="qualityTarget"
|
||||||
|
:disabled="assetBlocked"
|
||||||
|
:saved-raw-json="qualityRawJson"
|
||||||
|
@changed="refreshAll"
|
||||||
|
/>
|
||||||
<KeyframeDialog
|
<KeyframeDialog
|
||||||
v-model:open="keyframeOpen"
|
v-model:open="keyframeOpen"
|
||||||
:shot-id="shot.shotId"
|
:shot-id="shot.shotId"
|
||||||
|
|||||||
@@ -99,11 +99,10 @@ describe('镜头生产数据契约', () => {
|
|||||||
})
|
})
|
||||||
vi.stubGlobal('fetch', fetcher)
|
vi.stubGlobal('fetch', fetcher)
|
||||||
await productionApi.videoReadiness('project/1', true)
|
await productionApi.videoReadiness('project/1', true)
|
||||||
await productionApi.generateVideos('project/1', { provider: 'seedance', concurrency: 3, force: true })
|
await productionApi.generateVideos('project/1', { concurrency: 3, force: true })
|
||||||
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/projects/project%2F1/videos/readiness?force=true')
|
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/projects/project%2F1/videos/readiness?force=true')
|
||||||
expect(fetcher.mock.calls[1]?.[0]).toBe('/api/projects/project%2F1/videos/generate')
|
expect(fetcher.mock.calls[1]?.[0]).toBe('/api/projects/project%2F1/videos/generate')
|
||||||
expect(JSON.parse(String(fetcher.mock.calls[1]?.[1]?.body))).toEqual({
|
expect(JSON.parse(String(fetcher.mock.calls[1]?.[1]?.body))).toEqual({
|
||||||
provider: 'seedance',
|
|
||||||
concurrency: 3,
|
concurrency: 3,
|
||||||
force: true
|
force: true
|
||||||
})
|
})
|
||||||
@@ -120,7 +119,7 @@ describe('镜头生产数据契约', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
button('确认生成首帧').click()
|
button('确认生成首帧').click()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(wrapper.emitted('generate')).toEqual([[{ provider: 'seedream', setPrimary: true }]])
|
expect(wrapper.emitted('generate')).toEqual([[{ setPrimary: true }]])
|
||||||
|
|
||||||
await wrapper.setProps({ open: false })
|
await wrapper.setProps({ open: false })
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
@@ -129,6 +128,6 @@ describe('镜头生产数据契约', () => {
|
|||||||
document.querySelector<HTMLInputElement>('#confirm-keyframe-cost')!.click()
|
document.querySelector<HTMLInputElement>('#confirm-keyframe-cost')!.click()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
button('确认生成首帧').click()
|
button('确认生成首帧').click()
|
||||||
expect(wrapper.emitted('generate')?.at(-1)).toEqual([{ provider: 'seedream', setPrimary: false }])
|
expect(wrapper.emitted('generate')?.at(-1)).toEqual([{ setPrimary: false }])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { request } from '../../lib/http'
|
||||||
|
import type {
|
||||||
|
ImageProviderCapability,
|
||||||
|
KeyframeRepair,
|
||||||
|
KeyframeValidation,
|
||||||
|
QualityBatchResult,
|
||||||
|
QualityInput,
|
||||||
|
ShotProviderCapability,
|
||||||
|
VideoValidation,
|
||||||
|
VideoRepair
|
||||||
|
} from './quality.types'
|
||||||
|
|
||||||
|
/** 校验会调用视觉模型,POST 不设短超时、不自动重试;能力检查只读。 */
|
||||||
|
export const qualityApi = {
|
||||||
|
repairVideo: (shotId: string, videoId: string, maxRepairAttempts: number) =>
|
||||||
|
request<VideoRepair>(
|
||||||
|
`/storyboard-shots/${encodeURIComponent(shotId)}/videos/${encodeURIComponent(videoId)}/repair`,
|
||||||
|
{ method: 'POST', body: { maxRepairAttempts }, timeoutMs: 0 }
|
||||||
|
),
|
||||||
|
providers: (signal?: AbortSignal) =>
|
||||||
|
request<ImageProviderCapability[]>('/image-providers/capabilities', { signal }),
|
||||||
|
capability: (shotId: string, signal?: AbortSignal) =>
|
||||||
|
request<ShotProviderCapability>(
|
||||||
|
`/storyboard-shots/${encodeURIComponent(shotId)}/keyframe-provider-capability`,
|
||||||
|
{ signal }
|
||||||
|
),
|
||||||
|
validateKeyframe: (shotId: string, assetId: string, allowedTexts: string[]) =>
|
||||||
|
request<KeyframeValidation>(
|
||||||
|
`/storyboard-shots/${encodeURIComponent(shotId)}/keyframes/${encodeURIComponent(assetId)}/validate`,
|
||||||
|
{ method: 'POST', body: { allowedTexts }, timeoutMs: 0 }
|
||||||
|
),
|
||||||
|
repair: (
|
||||||
|
shotId: string,
|
||||||
|
assetId: string,
|
||||||
|
input: Pick<QualityInput, 'maxRepairAttempts' | 'allowedTexts' | 'width' | 'height'>
|
||||||
|
) =>
|
||||||
|
request<KeyframeRepair>(
|
||||||
|
`/storyboard-shots/${encodeURIComponent(shotId)}/keyframes/${encodeURIComponent(assetId)}/repair`,
|
||||||
|
{ method: 'POST', body: input, timeoutMs: 0 }
|
||||||
|
),
|
||||||
|
validateVideo: (shotId: string, assetId: string, allowedTexts: string[]) =>
|
||||||
|
request<VideoValidation>(
|
||||||
|
`/storyboard-shots/${encodeURIComponent(shotId)}/videos/${encodeURIComponent(assetId)}/validate`,
|
||||||
|
{ method: 'POST', body: { allowedTexts }, timeoutMs: 0 }
|
||||||
|
),
|
||||||
|
generate: (projectId: string, input: QualityInput) =>
|
||||||
|
request<QualityBatchResult>(`/projects/${encodeURIComponent(projectId)}/keyframes/generate-quality`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: input,
|
||||||
|
timeoutMs: 0
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,515 @@
|
|||||||
|
import { defineComponent, reactive, ref } from 'vue'
|
||||||
|
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { NCheckbox, NInputNumber, NRadioGroup } from 'naive-ui'
|
||||||
|
import { testProjectContext } from '../../testing/project-context'
|
||||||
|
import { productionApi } from './api'
|
||||||
|
import { qualityApi } from './quality-api'
|
||||||
|
import {
|
||||||
|
allowedTextLines,
|
||||||
|
qualityKey,
|
||||||
|
qualitySession,
|
||||||
|
qualityTargets,
|
||||||
|
savedVideoValidation,
|
||||||
|
videoRepairInfo,
|
||||||
|
validQualityInput
|
||||||
|
} from './quality'
|
||||||
|
import type { KeyframeReadiness } from './types'
|
||||||
|
import type { QualityInput, QualityTarget } from './quality.types'
|
||||||
|
import { useQuality } from './useQuality'
|
||||||
|
import { keyframeFixture, videoFixture } from './testing/fixtures'
|
||||||
|
import QualityDialog from './components/QualityDialog.vue'
|
||||||
|
import QualityResult from './components/QualityResult.vue'
|
||||||
|
import ModelCapabilities from './components/ModelCapabilities.vue'
|
||||||
|
|
||||||
|
let wrapper: VueWrapper | undefined
|
||||||
|
let sequence = 0
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper?.unmount()
|
||||||
|
wrapper = undefined
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 模拟统一配置后的真实契约,测试绝不调用真实图片或视觉模型。 */
|
||||||
|
function server() {
|
||||||
|
const projectId = `quality-test-${sequence++}`
|
||||||
|
const project = testProjectContext(projectId).project.value!
|
||||||
|
const validation = {
|
||||||
|
passed: true,
|
||||||
|
summary: '身份与造型一致',
|
||||||
|
subjects: [],
|
||||||
|
subjectCountConsistent: true,
|
||||||
|
unauthorizedText: { detected: false, texts: [] },
|
||||||
|
issues: []
|
||||||
|
}
|
||||||
|
const keyframe = keyframeFixture()
|
||||||
|
const video = videoFixture()
|
||||||
|
const readiness = {
|
||||||
|
total: 1,
|
||||||
|
ready: 1,
|
||||||
|
skipped: 0,
|
||||||
|
blocked: 0,
|
||||||
|
stalePrimaryKeyframe: 0,
|
||||||
|
missingVisualStyle: 0,
|
||||||
|
missingIdentity: 0,
|
||||||
|
missingIdentityAnchor: 0,
|
||||||
|
identityUnlocked: 0,
|
||||||
|
invalidGenerationSpec: 0,
|
||||||
|
missingReference: 0,
|
||||||
|
items: [{ shotId: 'shot-1', shotNo: 1, beatNo: 1, episodeNo: 1, status: 'ready', issues: [] }]
|
||||||
|
} as KeyframeReadiness
|
||||||
|
const capability = {
|
||||||
|
provider: 'qwen-image',
|
||||||
|
referenceCount: 3,
|
||||||
|
maxReferenceImages: 3,
|
||||||
|
valid: true,
|
||||||
|
message: '参考图超限'
|
||||||
|
}
|
||||||
|
const keyframeResult = {
|
||||||
|
...validation,
|
||||||
|
shotId: 'shot-1',
|
||||||
|
keyframeId: 'keyframe-1',
|
||||||
|
validationTaskId: 'validation-1',
|
||||||
|
isPrimary: true,
|
||||||
|
imageUrl: keyframe.imageUrl!
|
||||||
|
}
|
||||||
|
const attempt = {
|
||||||
|
attempt: 0,
|
||||||
|
keyframeId: 'keyframe-1',
|
||||||
|
validationTaskId: 'validation-1',
|
||||||
|
passed: true,
|
||||||
|
issues: [],
|
||||||
|
validationDurationMs: 10
|
||||||
|
}
|
||||||
|
const repair = {
|
||||||
|
shotId: 'shot-1',
|
||||||
|
initialKeyframeId: 'keyframe-1',
|
||||||
|
finalKeyframeId: 'keyframe-1',
|
||||||
|
passed: true,
|
||||||
|
repaired: false,
|
||||||
|
primaryChanged: false,
|
||||||
|
repairAttempts: 0,
|
||||||
|
maxRepairAttempts: 1,
|
||||||
|
attempts: [attempt]
|
||||||
|
}
|
||||||
|
const batch = {
|
||||||
|
total: 1,
|
||||||
|
ready: 1,
|
||||||
|
selected: 1,
|
||||||
|
targetCount: 1,
|
||||||
|
passed: 1,
|
||||||
|
repairFailed: 0,
|
||||||
|
failed: 0,
|
||||||
|
skipped: 0,
|
||||||
|
blocked: 0,
|
||||||
|
stalePrimaryKeyframe: 0,
|
||||||
|
missingReference: 0,
|
||||||
|
results: [{ shotId: 'shot-1', success: true, status: 'passed' as const }]
|
||||||
|
}
|
||||||
|
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, options) => {
|
||||||
|
const path = String(url)
|
||||||
|
let data: unknown
|
||||||
|
if (options?.method === 'POST') {
|
||||||
|
if (path.endsWith('/generate-quality')) data = batch
|
||||||
|
else if (path.includes('/videos/') && path.endsWith('/repair'))
|
||||||
|
data = {
|
||||||
|
shotId: 'shot-1',
|
||||||
|
sourceVideoId: 'video-1',
|
||||||
|
repairAttempt: 1,
|
||||||
|
maxRepairAttempts: 2,
|
||||||
|
repairInstructions: ['修复人物漂移'],
|
||||||
|
allowedTexts: [],
|
||||||
|
candidate: videoFixture({
|
||||||
|
id: 'video-repair-1',
|
||||||
|
status: 'queued',
|
||||||
|
isPrimary: false,
|
||||||
|
videoUrl: null,
|
||||||
|
rawJson: JSON.stringify({ repair: { sourceVideoId: 'video-1', attempt: 1 } })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
else if (path.endsWith('/repair')) data = repair
|
||||||
|
else if (path.includes('/videos/'))
|
||||||
|
data = {
|
||||||
|
...validation,
|
||||||
|
shotId: 'shot-1',
|
||||||
|
videoId: 'video-1',
|
||||||
|
validatedAt: '2026-09-03',
|
||||||
|
isPrimary: validation.passed && !!videoRepairInfo(video.rawJson),
|
||||||
|
videoUrl: video.videoUrl,
|
||||||
|
sampleFrames: [{ label: '中间', timeSeconds: 2 }],
|
||||||
|
allowedTexts: []
|
||||||
|
}
|
||||||
|
else data = keyframeResult
|
||||||
|
} else if (path.endsWith(`/projects/${projectId}`)) data = project
|
||||||
|
else if (path.includes('/readiness')) data = readiness
|
||||||
|
else if (path.endsWith('/keyframes')) data = [keyframe]
|
||||||
|
else if (path.endsWith('/videos')) data = [video]
|
||||||
|
else if (path.endsWith('/keyframe-provider-capability')) data = capability
|
||||||
|
else if (path.endsWith('/image-providers/capabilities'))
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
provider: 'qwen-image',
|
||||||
|
active: true,
|
||||||
|
capabilities: { references: { supported: true, maxReferenceImages: 3 } }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
else throw new Error('意外接口:' + path)
|
||||||
|
return new Response(JSON.stringify({ data }))
|
||||||
|
})
|
||||||
|
vi.stubGlobal('fetch', fetcher)
|
||||||
|
return {
|
||||||
|
projectId,
|
||||||
|
project,
|
||||||
|
validation,
|
||||||
|
keyframe,
|
||||||
|
video,
|
||||||
|
readiness,
|
||||||
|
capability,
|
||||||
|
keyframeResult,
|
||||||
|
repair,
|
||||||
|
batch,
|
||||||
|
fetcher,
|
||||||
|
posts: () => fetcher.mock.calls.filter(([, options]) => options?.method === 'POST')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统一默认小批次,零修复和自定义允许文字均可单独覆写。 */
|
||||||
|
function input(patch: Partial<QualityInput> = {}): QualityInput {
|
||||||
|
return { concurrency: 1, limit: 1, episodeNo: 1, maxRepairAttempts: 1, force: false, allowedTexts: [], ...patch }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通过实际挂载按钮检验费用确认,避免绕过禁用状态。 */
|
||||||
|
function validationButton() {
|
||||||
|
return [...document.querySelectorAll<HTMLButtonElement>('button')].find(
|
||||||
|
item => item.textContent === '开始视觉校验'
|
||||||
|
)!
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(target: QualityTarget = { kind: 'keyframe', shotId: 'shot-1', assetId: 'keyframe-1', title: '镜头一' }) {
|
||||||
|
const data = server()
|
||||||
|
const props = reactive({ projectId: data.projectId, target: target as QualityTarget | null, disabled: false })
|
||||||
|
const visible = ref(true)
|
||||||
|
const changed = vi.fn<() => void>()
|
||||||
|
let service!: ReturnType<typeof useQuality>
|
||||||
|
wrapper = mount(
|
||||||
|
defineComponent({
|
||||||
|
setup() {
|
||||||
|
service = useQuality(props, () => visible.value, changed)
|
||||||
|
return () => null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
return { ...data, props, visible, changed, service }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('视觉质量契约与付费边界', () => {
|
||||||
|
it('打开面板不发模型请求,确认后仅校验指定首帧', async () => {
|
||||||
|
const data = setup()
|
||||||
|
expect(data.fetcher).not.toHaveBeenCalled()
|
||||||
|
await data.service.run('validate', input({ allowedTexts: ['记忆当铺'] }))
|
||||||
|
expect(data.posts()).toHaveLength(1)
|
||||||
|
expect(data.posts()[0]?.[0]).toBe('/api/storyboard-shots/shot-1/keyframes/keyframe-1/validate')
|
||||||
|
expect(JSON.parse(String(data.posts()[0]?.[1]?.body))).toEqual({ allowedTexts: ['记忆当铺'] })
|
||||||
|
expect(data.service.session.value.receipt).toMatchObject({ kind: 'keyframe', result: { passed: true } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['generating', 'failed', 'need_review'] as const)('后端项目状态为 %s 时不发付费请求', async status => {
|
||||||
|
const data = setup()
|
||||||
|
data.project.status = status
|
||||||
|
await data.service.run('validate', input())
|
||||||
|
expect(data.posts()).toHaveLength(0)
|
||||||
|
expect(data.service.session.value.error).toContain('剧本未完成')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('目标不属于项目时不查询目标素材,不生成', async () => {
|
||||||
|
const data = setup({ kind: 'keyframe', shotId: 'foreign-shot', assetId: 'foreign-image', title: '外部' })
|
||||||
|
await data.service.run('validate', input())
|
||||||
|
expect(data.posts()).toHaveLength(0)
|
||||||
|
expect(data.fetcher.mock.calls.some(([url]) => String(url).includes('foreign-shot'))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('畸形视觉结果不能当作校验通过,也不会自动重试', async () => {
|
||||||
|
const data = setup()
|
||||||
|
vi.spyOn(qualityApi, 'validateKeyframe').mockResolvedValueOnce({
|
||||||
|
...data.keyframeResult,
|
||||||
|
passed: undefined
|
||||||
|
} as never)
|
||||||
|
await data.service.run('validate', input())
|
||||||
|
expect(data.service.session.value.receipt).toBeNull()
|
||||||
|
expect(data.service.session.value.error).toContain('不完整')
|
||||||
|
expect(qualityApi.validateKeyframe).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('0 次修复保留语义;不会额外调用普通生图或切换主图接口', async () => {
|
||||||
|
const data = setup()
|
||||||
|
await data.service.run('repair', input({ maxRepairAttempts: 0 }))
|
||||||
|
expect(data.posts()).toHaveLength(1)
|
||||||
|
expect(data.posts()[0]?.[0]).toContain('/repair')
|
||||||
|
expect(JSON.parse(String(data.posts()[0]?.[1]?.body))).toEqual({ maxRepairAttempts: 0, allowedTexts: [] })
|
||||||
|
expect(data.fetcher.mock.calls.some(([, options]) => options?.method === 'PUT')).toBe(false)
|
||||||
|
expect(data.service.session.value.receipt).toMatchObject({ kind: 'repair', result: { primaryChanged: false } })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('模型参考图能力不足时阻止修复与质量批次', async () => {
|
||||||
|
const data = setup()
|
||||||
|
data.capability.valid = false
|
||||||
|
await data.service.run('repair', input())
|
||||||
|
expect(data.posts()).toHaveLength(0)
|
||||||
|
expect(data.service.session.value.error).toContain('参考图超限')
|
||||||
|
data.props.target = { kind: 'batch', title: '第一集', episodeNo: 1 }
|
||||||
|
await data.service.run('batch', input())
|
||||||
|
expect(data.posts()).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('视频需完成并有有效时长,校验不自动切换主视频', async () => {
|
||||||
|
const data = setup({ kind: 'video', shotId: 'shot-1', assetId: 'video-1', title: '视频' })
|
||||||
|
data.video.durationSeconds = null
|
||||||
|
await data.service.run('validate', input())
|
||||||
|
expect(data.posts()).toHaveLength(0)
|
||||||
|
data.video.durationSeconds = 5
|
||||||
|
await data.service.run('validate', input())
|
||||||
|
expect(data.posts()).toHaveLength(1)
|
||||||
|
expect(data.posts()[0]?.[0]).toBe('/api/storyboard-shots/shot-1/videos/video-1/validate')
|
||||||
|
expect(data.service.session.value.receipt).toMatchObject({
|
||||||
|
kind: 'video',
|
||||||
|
result: { sampleFrames: [{ timeSeconds: 2 }] }
|
||||||
|
})
|
||||||
|
expect(data.fetcher.mock.calls.some(([, options]) => options?.method === 'PUT')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('批量范围、上限、尺寸原样传递,不发送 Provider;部分失败仍显示待处理', async () => {
|
||||||
|
const data = setup({ kind: 'batch', episodeNo: 1, title: '第一集' })
|
||||||
|
Object.assign(data.batch, {
|
||||||
|
passed: 0,
|
||||||
|
repairFailed: 1,
|
||||||
|
results: [{ shotId: 'shot-1', success: false, status: 'repair_failed', error: '人物不一致' }]
|
||||||
|
})
|
||||||
|
await data.service.run('batch', input({ width: 1536, height: 1024 }))
|
||||||
|
expect(data.posts()).toHaveLength(1)
|
||||||
|
expect(JSON.parse(String(data.posts()[0]?.[1]?.body))).toEqual(input({ width: 1536, height: 1024 }))
|
||||||
|
const receipt = data.service.session.value.receipt!
|
||||||
|
wrapper!.unmount()
|
||||||
|
wrapper = mount(QualityResult, { props: { receipt } })
|
||||||
|
expect(wrapper.text()).toContain('仍需处理')
|
||||||
|
expect(wrapper.text()).toContain('人物不一致')
|
||||||
|
await wrapper
|
||||||
|
.findAll('button')
|
||||||
|
.find(button => button.text() === '定位镜头')!
|
||||||
|
.trigger('click')
|
||||||
|
expect(wrapper.emitted('locate')).toEqual([['shot-1']])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('过期优先级与后端一致,当前集无过期项时不误补其他镜头', async () => {
|
||||||
|
const data = setup({ kind: 'batch', episodeNo: 1, title: '第一集' })
|
||||||
|
data.readiness.stalePrimaryKeyframe = 1
|
||||||
|
expect(qualityTargets(data.readiness, input())).toHaveLength(0)
|
||||||
|
await data.service.run('batch', input())
|
||||||
|
expect(data.posts()).toHaveLength(0)
|
||||||
|
expect(data.service.session.value.error).toContain('切换剧集')
|
||||||
|
expect(qualityTargets(data.readiness, input({ force: true }))).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('错误输入与未完成素材不提交,已禁用操作也不能绕过', async () => {
|
||||||
|
const data = setup()
|
||||||
|
for (const values of [{ limit: 0 }, { concurrency: 1.5 }, { maxRepairAttempts: -1 }, { width: 100 }])
|
||||||
|
await data.service.run('repair', input(values))
|
||||||
|
expect(data.fetcher).not.toHaveBeenCalled()
|
||||||
|
data.keyframe.status = 'generating'
|
||||||
|
await data.service.run('repair', input())
|
||||||
|
expect(data.posts()).toHaveLength(0)
|
||||||
|
data.props.disabled = true
|
||||||
|
const count = data.fetcher.mock.calls.length
|
||||||
|
await data.service.run('validate', input())
|
||||||
|
expect(data.fetcher).toHaveBeenCalledTimes(count)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('预检中关闭或换项目不发 POST;已提交的迟到回执只写回原目标', async () => {
|
||||||
|
const data = setup()
|
||||||
|
let finishCheck!: (value: KeyframeReadiness) => void
|
||||||
|
vi.spyOn(productionApi, 'keyframeReadiness').mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise(resolve => {
|
||||||
|
finishCheck = resolve
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const pendingCheck = data.service.run('validate', input())
|
||||||
|
await flushPromises()
|
||||||
|
data.visible.value = false
|
||||||
|
finishCheck(data.readiness)
|
||||||
|
await pendingCheck
|
||||||
|
expect(data.posts()).toHaveLength(0)
|
||||||
|
data.visible.value = true
|
||||||
|
let finish!: (value: typeof data.keyframeResult) => void
|
||||||
|
vi.spyOn(qualityApi, 'validateKeyframe').mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise(resolve => {
|
||||||
|
finish = resolve
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const originalKey = data.service.key.value
|
||||||
|
const pending = data.service.run('validate', input())
|
||||||
|
await flushPromises()
|
||||||
|
await data.service.run('validate', input())
|
||||||
|
expect(qualityApi.validateKeyframe).toHaveBeenCalledTimes(1)
|
||||||
|
data.props.projectId = 'different-project'
|
||||||
|
finish(data.keyframeResult)
|
||||||
|
await pending
|
||||||
|
expect(qualitySession(originalKey).receipt).toMatchObject({ kind: 'keyframe' })
|
||||||
|
expect(data.service.session.value.receipt).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('API 对正式 ID 编码,视觉与质量长请求只发一次', async () => {
|
||||||
|
const data = server()
|
||||||
|
await qualityApi.validateKeyframe('shot/a', 'asset/b', [])
|
||||||
|
await qualityApi.repair('shot/a', 'asset/b', { maxRepairAttempts: 1 })
|
||||||
|
await qualityApi.validateVideo('shot/a', 'video/b', [])
|
||||||
|
expect(data.posts().map(([url]) => url)).toEqual([
|
||||||
|
'/api/storyboard-shots/shot%2Fa/keyframes/asset%2Fb/validate',
|
||||||
|
'/api/storyboard-shots/shot%2Fa/keyframes/asset%2Fb/repair',
|
||||||
|
'/api/storyboard-shots/shot%2Fa/videos/video%2Fb/validate'
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('质量面板渐进展示', () => {
|
||||||
|
it('默认仅校验,未确认时不可提交,打开或更改参数不产生费用', async () => {
|
||||||
|
const data = server()
|
||||||
|
wrapper = mount(QualityDialog, {
|
||||||
|
attachTo: document.body,
|
||||||
|
props: {
|
||||||
|
projectId: data.projectId,
|
||||||
|
target: { kind: 'keyframe', shotId: 'shot-1', assetId: 'keyframe-1', title: '测试镜头' },
|
||||||
|
open: true,
|
||||||
|
disabled: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
expect(validationButton().disabled).toBe(true)
|
||||||
|
expect(document.body.textContent).toContain('更多参数与模型限制')
|
||||||
|
expect(document.querySelector('[aria-label="额外允许的画面文字"]')).toBeNull()
|
||||||
|
expect(data.fetcher).not.toHaveBeenCalled()
|
||||||
|
wrapper.findAllComponents(NCheckbox).at(-1)!.vm.$emit('update:checked', true)
|
||||||
|
await flushPromises()
|
||||||
|
expect(validationButton().disabled).toBe(false)
|
||||||
|
validationButton().click()
|
||||||
|
await flushPromises()
|
||||||
|
expect(data.posts()).toHaveLength(1)
|
||||||
|
expect(document.body.textContent).toContain('视觉校验通过')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('批量默认当前集一镜,调整次数撤销费用确认', async () => {
|
||||||
|
const data = server()
|
||||||
|
wrapper = mount(QualityDialog, {
|
||||||
|
attachTo: document.body,
|
||||||
|
props: {
|
||||||
|
projectId: data.projectId,
|
||||||
|
target: { kind: 'batch', episodeNo: 2, title: '第二集' },
|
||||||
|
open: true,
|
||||||
|
disabled: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
expect(document.body.textContent).toContain('仅第 2 集,最多 1 镜')
|
||||||
|
expect(document.body.textContent).toContain('最多调用 2 次生图、2 次视觉校验')
|
||||||
|
wrapper.findAllComponents(NCheckbox).at(-1)!.vm.$emit('update:checked', true)
|
||||||
|
await flushPromises()
|
||||||
|
wrapper.findAllComponents(NInputNumber)[0]!.vm.$emit('update:value', 2)
|
||||||
|
await flushPromises()
|
||||||
|
const submit = [...document.querySelectorAll<HTMLButtonElement>('button')].find(
|
||||||
|
item => item.textContent === '开始质量生成'
|
||||||
|
)!
|
||||||
|
expect(submit.disabled).toBe(true)
|
||||||
|
expect(data.posts()).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('模型能力只读按需查询,明确当前启用模型及参考图上限', async () => {
|
||||||
|
const data = server()
|
||||||
|
wrapper = mount(ModelCapabilities, { props: { shotId: 'shot-1' } })
|
||||||
|
expect(data.fetcher).not.toHaveBeenCalled()
|
||||||
|
await wrapper.get('button').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.text()).toContain('qwen-image · 当前启用')
|
||||||
|
expect(wrapper.text()).toContain('最多 3 张参考图')
|
||||||
|
expect(data.posts()).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('视频历史读取不触发视觉模型,畸形历史不会伪造通过', () => {
|
||||||
|
const data = server()
|
||||||
|
expect(savedVideoValidation(JSON.stringify({ videoValidation: data.validation }))?.passed).toBe(true)
|
||||||
|
expect(savedVideoValidation('{broken')).toBeNull()
|
||||||
|
expect(savedVideoValidation('{"videoValidation":{"passed":true}}')).toBeNull()
|
||||||
|
expect(allowedTextLines(' 招牌\n\n招牌\n编号 ')).toEqual(['招牌', '编号'])
|
||||||
|
expect(validQualityInput(input({ maxRepairAttempts: 0 }))).toBe(true)
|
||||||
|
expect(qualityKey('a', { kind: 'batch', episodeNo: 1, title: '' })).not.toBe(
|
||||||
|
qualityKey('b', { kind: 'batch', episodeNo: 1, title: '' })
|
||||||
|
)
|
||||||
|
expect(data.fetcher).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('视频修复候选与复检晋升', () => {
|
||||||
|
it('只允许校验未通过的视频创建一个候选,不自动再次校验', async () => {
|
||||||
|
const data = setup({ kind: 'video', shotId: 'shot-1', assetId: 'video-1', title: '视频' })
|
||||||
|
await data.service.run('repair', input({ maxRepairAttempts: 2 }))
|
||||||
|
expect(data.posts()).toHaveLength(0)
|
||||||
|
data.video.rawJson = JSON.stringify({ videoValidation: { ...data.validation, passed: false } })
|
||||||
|
await data.service.run('repair', input({ maxRepairAttempts: 2 }))
|
||||||
|
expect(data.posts()).toHaveLength(1)
|
||||||
|
expect(data.posts()[0]?.[0]).toBe('/api/storyboard-shots/shot-1/videos/video-1/repair')
|
||||||
|
expect(JSON.parse(String(data.posts()[0]?.[1]?.body))).toEqual({ maxRepairAttempts: 2 })
|
||||||
|
expect(data.service.session.value.receipt).toMatchObject({
|
||||||
|
kind: 'video-repair',
|
||||||
|
result: { candidate: { status: 'queued', isPrimary: false } }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('链路次数上限不能作为零次修复,已达上限不创建任务', async () => {
|
||||||
|
const data = setup({ kind: 'video', shotId: 'shot-1', assetId: 'video-1', title: '视频' })
|
||||||
|
data.video.rawJson = JSON.stringify({
|
||||||
|
videoValidation: { ...data.validation, passed: false },
|
||||||
|
repair: { attempt: 2 }
|
||||||
|
})
|
||||||
|
await data.service.run('repair', input({ maxRepairAttempts: 0 }))
|
||||||
|
await data.service.run('repair', input({ maxRepairAttempts: 2 }))
|
||||||
|
expect(data.posts()).toHaveLength(0)
|
||||||
|
expect(data.service.session.value.error).toContain('次数上限')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('复检通过的修复候选准确显示自动晋升,前端不再另发主视频 PUT', async () => {
|
||||||
|
const data = setup({ kind: 'video', shotId: 'shot-1', assetId: 'video-1', title: '修复候选' })
|
||||||
|
data.video.rawJson = JSON.stringify({ repair: { attempt: 1, sourceVideoId: 'source' } })
|
||||||
|
data.video.isPrimary = false
|
||||||
|
await data.service.run('validate', input())
|
||||||
|
const receipt = data.service.session.value.receipt!
|
||||||
|
expect(receipt).toMatchObject({ kind: 'video', promoted: true, result: { isPrimary: true } })
|
||||||
|
expect(data.posts()).toHaveLength(1)
|
||||||
|
expect(data.fetcher.mock.calls.some(([, options]) => options?.method === 'PUT')).toBe(false)
|
||||||
|
wrapper!.unmount()
|
||||||
|
wrapper = mount(QualityResult, { props: { receipt } })
|
||||||
|
expect(wrapper.text()).toContain('后端已将其设为主视频')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('视频修复面板明确一次一任务及复检后替换,不展示生图次数', async () => {
|
||||||
|
const data = server()
|
||||||
|
wrapper = mount(QualityDialog, {
|
||||||
|
attachTo: document.body,
|
||||||
|
props: {
|
||||||
|
projectId: data.projectId,
|
||||||
|
target: { kind: 'video', shotId: 'shot-1', assetId: 'video-1', title: '视频' },
|
||||||
|
open: true,
|
||||||
|
disabled: false,
|
||||||
|
savedRawJson: JSON.stringify({ videoValidation: { ...data.validation, passed: false } })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
expect(document.body.textContent).toContain('修复候选复检通过后会自动设为主视频')
|
||||||
|
wrapper.getComponent(NRadioGroup).vm.$emit('update:value', 'repair')
|
||||||
|
await flushPromises()
|
||||||
|
expect(document.body.textContent).toContain('本次最多提交 1 个视频生成任务')
|
||||||
|
expect(document.body.textContent).not.toContain('次生图')
|
||||||
|
expect(document.body.textContent).toContain('修复链次数上限')
|
||||||
|
expect(data.posts()).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { reactive } from 'vue'
|
||||||
|
import { validOptionalSize } from './model'
|
||||||
|
import type { KeyframeReadiness } from './types'
|
||||||
|
import type { QualityInput, QualityReceipt, QualityTarget, VisualValidation } from './quality.types'
|
||||||
|
|
||||||
|
/** 未返回的操作不显示上次成功结论;网络失败不会自动重发。 */
|
||||||
|
const sessions = reactive<Record<string, { receipt: QualityReceipt | null; error: string }>>({})
|
||||||
|
|
||||||
|
/** 正式 ID 与目标类型共同隔离会话结果,批量回执按剧集隔离。 */
|
||||||
|
export function qualityKey(projectId: string, target: QualityTarget | null) {
|
||||||
|
return JSON.stringify([
|
||||||
|
projectId,
|
||||||
|
target?.kind,
|
||||||
|
target && ('assetId' in target ? [target.shotId, target.assetId] : target.episodeNo)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同一目标关闭后重新打开仍能查看本会话的回执。 */
|
||||||
|
export function qualitySession(key: string) {
|
||||||
|
return (sessions[key] ??= { receipt: null, error: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一行一个允许文字,仅清理空白与重复,不自行添加授权内容。 */
|
||||||
|
export function allowedTextLines(value: string) {
|
||||||
|
return [
|
||||||
|
...new Set(
|
||||||
|
value
|
||||||
|
.split('\n')
|
||||||
|
.map(item => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 付费前独立校验整数、尺寸与剧集范围,0 次修复代表只验不修。 */
|
||||||
|
export function validQualityInput(input: QualityInput) {
|
||||||
|
return (
|
||||||
|
[input.concurrency, input.limit, input.episodeNo].every(value => Number.isSafeInteger(value) && value > 0) &&
|
||||||
|
Number.isSafeInteger(input.maxRepairAttempts) &&
|
||||||
|
input.maxRepairAttempts >= 0 &&
|
||||||
|
validOptionalSize(input.width ?? '', input.height ?? '')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 与后端保持同一优先级:全项目有过期项时先筛过期,再按当前集筛选。 */
|
||||||
|
export function qualityTargets(readiness: KeyframeReadiness, input: QualityInput) {
|
||||||
|
return readiness.items
|
||||||
|
.filter(
|
||||||
|
item =>
|
||||||
|
item.status === 'ready' &&
|
||||||
|
(input.force ||
|
||||||
|
(readiness.stalePrimaryKeyframe > 0
|
||||||
|
? item.primaryKeyframeStale === true
|
||||||
|
: !item.primaryKeyframeId)) &&
|
||||||
|
item.episodeNo === input.episodeNo
|
||||||
|
)
|
||||||
|
.slice(0, input.limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 最小响应校验,避免畸形响应或仅 HTTP 200 被显示成视觉通过。 */
|
||||||
|
export function isVisualValidation(value: unknown): value is VisualValidation {
|
||||||
|
if (!value || typeof value !== 'object') return false
|
||||||
|
const item = value as VisualValidation
|
||||||
|
return (
|
||||||
|
typeof item.passed === 'boolean' &&
|
||||||
|
typeof item.summary === 'string' &&
|
||||||
|
typeof item.subjectCountConsistent === 'boolean' &&
|
||||||
|
Array.isArray(item.subjects) &&
|
||||||
|
item.subjects.every(
|
||||||
|
subject =>
|
||||||
|
typeof subject.subjectRef === 'string' &&
|
||||||
|
typeof subject.identityConsistent === 'boolean' &&
|
||||||
|
typeof subject.formConsistent === 'boolean' &&
|
||||||
|
Array.isArray(subject.issues) &&
|
||||||
|
subject.issues.every(issue => typeof issue === 'string')
|
||||||
|
) &&
|
||||||
|
Array.isArray(item.issues) &&
|
||||||
|
item.issues.every(
|
||||||
|
issue => typeof issue.description === 'string' && ['warning', 'error'].includes(issue.severity)
|
||||||
|
) &&
|
||||||
|
typeof item.unauthorizedText?.detected === 'boolean' &&
|
||||||
|
Array.isArray(item.unauthorizedText.texts)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取视频已保存的抽样校验,不为查看历史再次调用视觉模型。 */
|
||||||
|
export function savedVideoValidation(rawJson?: string | null): VisualValidation | null {
|
||||||
|
try {
|
||||||
|
const value: unknown = JSON.parse(rawJson || '{}').videoValidation
|
||||||
|
return isVisualValidation(value) ? value : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 识别后端修复候选标记,避免未复检候选通过普通主视频按钮绕过校验。 */
|
||||||
|
export function videoRepairInfo(rawJson?: string | null): { attempt: number } | null {
|
||||||
|
try {
|
||||||
|
const repair: unknown = JSON.parse(rawJson || '{}').repair
|
||||||
|
if (!repair || typeof repair !== 'object' || Array.isArray(repair)) return null
|
||||||
|
const attempt = (repair as { attempt?: unknown }).attempt
|
||||||
|
return { attempt: typeof attempt === 'number' && Number.isSafeInteger(attempt) && attempt >= 0 ? attempt : 0 }
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/** 校验入口绑定正式镜头与资产 ID;批量默认只处理当前集。 */
|
||||||
|
export type QualityTarget =
|
||||||
|
| { kind: 'keyframe' | 'video'; shotId: string; assetId: string; title: string }
|
||||||
|
| { kind: 'batch'; episodeNo: number; title: string }
|
||||||
|
|
||||||
|
/** 质量操作与普通生成独立,不复用生产面板的覆盖、并发或范围。 */
|
||||||
|
export type QualityAction = 'validate' | 'repair' | 'batch'
|
||||||
|
|
||||||
|
/** 三类校验问题使用同一可读列表,保留后端原始问题类型及抽样帧标记。 */
|
||||||
|
export interface QualityIssue {
|
||||||
|
type: string
|
||||||
|
severity: 'warning' | 'error'
|
||||||
|
description: string
|
||||||
|
frameLabel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 首帧与视频共同的视觉校验结果;通过不等于完整人工验收。 */
|
||||||
|
export interface VisualValidation {
|
||||||
|
passed: boolean
|
||||||
|
summary: string
|
||||||
|
subjects: {
|
||||||
|
subjectRef: string
|
||||||
|
identityConsistent: boolean
|
||||||
|
formConsistent: boolean
|
||||||
|
stateConsistent?: boolean
|
||||||
|
issues: string[]
|
||||||
|
}[]
|
||||||
|
subjectCountConsistent: boolean
|
||||||
|
temporalConsistency?: boolean
|
||||||
|
unauthorizedText: { detected: boolean; texts: string[] }
|
||||||
|
issues: QualityIssue[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 首帧校验回执;当前后端没有独立读取校验历史的 GET。 */
|
||||||
|
export interface KeyframeValidation extends VisualValidation {
|
||||||
|
shotId: string
|
||||||
|
keyframeId: string
|
||||||
|
validationTaskId: string
|
||||||
|
imageUrl: string
|
||||||
|
isPrimary: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 视频校验基于三点抽样帧;修复候选通过后后端会自动晋升为主视频。 */
|
||||||
|
export interface VideoValidation extends VisualValidation {
|
||||||
|
shotId: string
|
||||||
|
videoId: string
|
||||||
|
videoUrl: string
|
||||||
|
isPrimary: boolean
|
||||||
|
validatedAt: string
|
||||||
|
allowedTexts: string[]
|
||||||
|
sampleFrames: { label: string; timeSeconds: number }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 视频修复只创建一个异步候选任务,不自动等待视频或再次执行视觉校验。 */
|
||||||
|
export interface VideoRepair {
|
||||||
|
shotId: string
|
||||||
|
sourceVideoId: string
|
||||||
|
repairAttempt: number
|
||||||
|
maxRepairAttempts: number
|
||||||
|
repairInstructions: string[]
|
||||||
|
allowedTexts: string[]
|
||||||
|
candidate: import('./types').ShotVideo
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 每轮修复的耗时与视觉结论,不用 HTTP 成功代替校验通过。 */
|
||||||
|
export interface RepairAttempt {
|
||||||
|
attempt: number
|
||||||
|
keyframeId: string
|
||||||
|
validationTaskId: string
|
||||||
|
passed: boolean
|
||||||
|
issues: QualityIssue[]
|
||||||
|
generationDurationMs?: number
|
||||||
|
validationDurationMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单张修复只有新候选校验通过才切换主图,原图直接通过不会晋升。 */
|
||||||
|
export interface KeyframeRepair {
|
||||||
|
shotId: string
|
||||||
|
initialKeyframeId: string
|
||||||
|
finalKeyframeId: string
|
||||||
|
passed: boolean
|
||||||
|
repaired: boolean
|
||||||
|
primaryChanged: boolean
|
||||||
|
repairAttempts: number
|
||||||
|
maxRepairAttempts: number
|
||||||
|
repairInstructions?: string[]
|
||||||
|
attempts: RepairAttempt[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 项目质量生成逐镜回执,区分调用失败与达到修复上限仍不合格。 */
|
||||||
|
export interface QualityBatchResult {
|
||||||
|
total: number
|
||||||
|
ready: number
|
||||||
|
selected: number
|
||||||
|
targetCount: number
|
||||||
|
passed: number
|
||||||
|
repairFailed: number
|
||||||
|
failed: number
|
||||||
|
skipped: number
|
||||||
|
blocked: number
|
||||||
|
stalePrimaryKeyframe: number
|
||||||
|
missingReference: number
|
||||||
|
results: {
|
||||||
|
shotId: string
|
||||||
|
success: boolean
|
||||||
|
status: 'passed' | 'repair_failed' | 'failed'
|
||||||
|
initialKeyframeId?: string
|
||||||
|
finalKeyframeId?: string
|
||||||
|
repairAttempts?: number
|
||||||
|
repaired?: boolean
|
||||||
|
attempts?: RepairAttempt[]
|
||||||
|
error?: string
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 首帧质量生成配置;limit 强制由前端填写,避免无意执行全项目。 */
|
||||||
|
export interface QualityInput {
|
||||||
|
concurrency: number
|
||||||
|
maxRepairAttempts: number
|
||||||
|
force: boolean
|
||||||
|
episodeNo: number
|
||||||
|
limit: number
|
||||||
|
allowedTexts?: string[]
|
||||||
|
width?: number
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前后端启用模型的公开能力;不包含连接地址或密钥。 */
|
||||||
|
export interface ImageProviderCapability {
|
||||||
|
provider: string
|
||||||
|
active: boolean
|
||||||
|
capabilities: { references: { supported: boolean; maxReferenceImages: number | null } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只读能力检查不切换模型;无法满足参考图数量时阻止生图。 */
|
||||||
|
export interface ShotProviderCapability {
|
||||||
|
provider: string
|
||||||
|
referenceCount: number
|
||||||
|
maxReferenceImages: number | null
|
||||||
|
valid: boolean
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 会话回执按项目和目标隔离,保留关闭弹窗后的返回结果。 */
|
||||||
|
export type QualityReceipt =
|
||||||
|
| { kind: 'keyframe'; result: KeyframeValidation }
|
||||||
|
| { kind: 'video'; result: VideoValidation; promoted?: boolean }
|
||||||
|
| { kind: 'video-repair'; result: VideoRepair }
|
||||||
|
| { kind: 'repair'; result: KeyframeRepair }
|
||||||
|
| { kind: 'batch'; result: QualityBatchResult }
|
||||||
@@ -118,6 +118,8 @@ export interface ShotVideo {
|
|||||||
durationSeconds: number | null
|
durationSeconds: number | null
|
||||||
isPrimary: boolean
|
isPrimary: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
|
/** 后端保存的抽样视觉校验与生成快照,按需读取,不直接展示整段原始数据。 */
|
||||||
|
rawJson?: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
@@ -158,7 +160,7 @@ export interface VideoReference {
|
|||||||
providerImageUrl?: string
|
providerImageUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 后端实际提交 Seedance 前编译的完整视频生成规格。 */
|
/** 后端实际提交 视频模型 前编译的完整视频生成规格。 */
|
||||||
export interface VideoGenerationSpec {
|
export interface VideoGenerationSpec {
|
||||||
projectId: string
|
projectId: string
|
||||||
shotId: string
|
shotId: string
|
||||||
@@ -174,7 +176,6 @@ export interface VideoGenerationSpec {
|
|||||||
|
|
||||||
/** 项目首帧批量生成参数。 */
|
/** 项目首帧批量生成参数。 */
|
||||||
export interface GenerateKeyframesInput {
|
export interface GenerateKeyframesInput {
|
||||||
provider: 'seedream'
|
|
||||||
concurrency: number
|
concurrency: number
|
||||||
force: boolean
|
force: boolean
|
||||||
/** 缺失首帧默认只生成当前剧集,避免开发阶段误跑整个项目。 */
|
/** 缺失首帧默认只生成当前剧集,避免开发阶段误跑整个项目。 */
|
||||||
@@ -187,7 +188,6 @@ export interface GenerateKeyframesInput {
|
|||||||
|
|
||||||
/** 单镜头首帧生成参数。 */
|
/** 单镜头首帧生成参数。 */
|
||||||
export interface GenerateKeyframeInput {
|
export interface GenerateKeyframeInput {
|
||||||
provider: 'seedream'
|
|
||||||
width?: number
|
width?: number
|
||||||
height?: number
|
height?: number
|
||||||
setPrimary: boolean
|
setPrimary: boolean
|
||||||
@@ -210,7 +210,6 @@ export interface KeyframeBatchResult {
|
|||||||
|
|
||||||
/** 项目视频批量生成参数。 */
|
/** 项目视频批量生成参数。 */
|
||||||
export interface GenerateVideosInput {
|
export interface GenerateVideosInput {
|
||||||
provider: 'seedance'
|
|
||||||
concurrency: number
|
concurrency: number
|
||||||
force: boolean
|
force: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,7 +119,6 @@ export function useProduction() {
|
|||||||
kind: 'keyframes',
|
kind: 'keyframes',
|
||||||
title: labels[command],
|
title: labels[command],
|
||||||
result: await productionApi.generateKeyframes(projectId, {
|
result: await productionApi.generateKeyframes(projectId, {
|
||||||
provider: 'seedream',
|
|
||||||
...input,
|
...input,
|
||||||
// 有 stale 时由后端优先处理全部 stale;否则只补当前剧集,避免误跑整个项目。
|
// 有 stale 时由后端优先处理全部 stale;否则只补当前剧集,避免误跑整个项目。
|
||||||
...(force.value || staleKeyframes > 0 ? {} : { episodeNo: episodeNo.value }),
|
...(force.value || staleKeyframes > 0 ? {} : { episodeNo: episodeNo.value }),
|
||||||
@@ -133,7 +132,7 @@ export function useProduction() {
|
|||||||
target.receipt = {
|
target.receipt = {
|
||||||
kind: 'videos',
|
kind: 'videos',
|
||||||
title: labels[command],
|
title: labels[command],
|
||||||
result: await productionApi.generateVideos(projectId, { provider: 'seedance', ...input })
|
result: await productionApi.generateVideos(projectId, input)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
target.receipt = {
|
target.receipt = {
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { computed, onScopeDispose } from 'vue'
|
||||||
|
import { projectsApi } from '../projects/api'
|
||||||
|
import { getOperation, runOperation } from '../workflows/operations'
|
||||||
|
import { productionApi } from './api'
|
||||||
|
import { qualityApi } from './quality-api'
|
||||||
|
import {
|
||||||
|
isVisualValidation,
|
||||||
|
qualityKey,
|
||||||
|
qualitySession,
|
||||||
|
qualityTargets,
|
||||||
|
validQualityInput,
|
||||||
|
savedVideoValidation,
|
||||||
|
videoRepairInfo
|
||||||
|
} from './quality'
|
||||||
|
import type { QualityAction, QualityInput, QualityReceipt, QualityTarget, RepairAttempt } from './quality.types'
|
||||||
|
|
||||||
|
/** 校验有限修复回执的每轮结论,避免结构缺失时误报成功。 */
|
||||||
|
function validAttempts(attempts: RepairAttempt[]) {
|
||||||
|
return (
|
||||||
|
Array.isArray(attempts) &&
|
||||||
|
attempts.length > 0 &&
|
||||||
|
attempts.every(
|
||||||
|
item =>
|
||||||
|
typeof item.keyframeId === 'string' && typeof item.passed === 'boolean' && Array.isArray(item.issues)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统一质量操作:只读预检 → 显式付费请求 → 按原目标保存回执,不自动重试。 */
|
||||||
|
export function useQuality(
|
||||||
|
props: { projectId: string; target: QualityTarget | null; disabled: boolean },
|
||||||
|
visible: () => boolean,
|
||||||
|
changed: () => void
|
||||||
|
) {
|
||||||
|
const key = computed(() => qualityKey(props.projectId, props.target))
|
||||||
|
const session = computed(() => qualitySession(key.value))
|
||||||
|
const operation = computed(() => getOperation(props.projectId))
|
||||||
|
const blocked = computed(() => props.disabled || operation.value.pending || !props.target || !props.projectId)
|
||||||
|
let disposed = false
|
||||||
|
onScopeDispose(() => {
|
||||||
|
disposed = true
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 确认之后重新读取后端状态;打开弹窗和查看结果不会调用此函数。 */
|
||||||
|
async function run(action: QualityAction, input: QualityInput) {
|
||||||
|
if (blocked.value || !visible() || !props.target || !validQualityInput(input)) return
|
||||||
|
const target = { ...props.target }
|
||||||
|
if (
|
||||||
|
(target.kind === 'batch') !== (action === 'batch') ||
|
||||||
|
(target.kind === 'video' && action === 'repair' && input.maxRepairAttempts <= 0)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if (target.kind === 'batch' && input.episodeNo !== target.episodeNo) return
|
||||||
|
const projectId = props.projectId
|
||||||
|
// 固定本次确认的参数,预检期间的表单变化不能扩大已确认的生成范围。
|
||||||
|
input = { ...input, allowedTexts: input.allowedTexts?.slice() }
|
||||||
|
const originalKey = key.value
|
||||||
|
const receiptTarget = qualitySession(originalKey)
|
||||||
|
receiptTarget.receipt = null
|
||||||
|
receiptTarget.error = ''
|
||||||
|
const stillCurrent = () => !disposed && key.value === originalKey && visible()
|
||||||
|
const ok = await runOperation(
|
||||||
|
projectId,
|
||||||
|
action === 'batch'
|
||||||
|
? '首帧质量生成'
|
||||||
|
: action === 'repair'
|
||||||
|
? target.kind === 'video'
|
||||||
|
? '创建视频修复候选'
|
||||||
|
: '首帧自动修复'
|
||||||
|
: '视觉质量校验',
|
||||||
|
async () => {
|
||||||
|
const [project, readiness] = await Promise.all([
|
||||||
|
projectsApi.detail(projectId),
|
||||||
|
productionApi.keyframeReadiness(projectId, target.kind === 'batch' ? input.force : true)
|
||||||
|
])
|
||||||
|
if (project.id !== projectId || project.status !== 'completed')
|
||||||
|
throw new Error('剧本未完成或项目已变化,未提交质量任务。')
|
||||||
|
if (project.tasks.some(task => ['pending', 'queued', 'running', 'generating'].includes(task.status)))
|
||||||
|
throw new Error('项目还有后台任务,请结束后再执行质量检查。')
|
||||||
|
if (
|
||||||
|
!Array.isArray(readiness.items) ||
|
||||||
|
readiness.items.length !== readiness.total ||
|
||||||
|
new Set(readiness.items.map(item => item.shotId)).size !== readiness.total
|
||||||
|
)
|
||||||
|
throw new Error('镜头就绪列表不完整,请刷新。')
|
||||||
|
let receipt: QualityReceipt
|
||||||
|
if (target.kind === 'batch') {
|
||||||
|
const targets = qualityTargets(readiness, input)
|
||||||
|
if (!targets.length)
|
||||||
|
throw new Error(
|
||||||
|
readiness.stalePrimaryKeyframe > 0
|
||||||
|
? '当前集没有可更新的过期首帧;后端优先处理全项目过期项,请切换剧集。'
|
||||||
|
: '当前集没有符合条件的镜头;请补齐设计与参考图,或明确选择重做有效首帧。'
|
||||||
|
)
|
||||||
|
// 逐镜只读检查已选的小批量;任何模型能力不足都不启动本批付费流水线。
|
||||||
|
const capabilities = await Promise.all(targets.map(item => qualityApi.capability(item.shotId)))
|
||||||
|
const invalid = capabilities.find(item => item.valid !== true)
|
||||||
|
if (invalid) throw new Error(invalid.message || '当前图片模型不支持所需参考图。')
|
||||||
|
const assets = await Promise.all(targets.map(item => productionApi.listKeyframes(item.shotId)))
|
||||||
|
if (
|
||||||
|
assets.some((items, index) =>
|
||||||
|
items.some(
|
||||||
|
item =>
|
||||||
|
item.shotId !== targets[index]?.shotId ||
|
||||||
|
['pending', 'generating'].includes(item.status)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new Error('本批镜头有进行中的生图任务或资产归属异常,请先刷新核对。')
|
||||||
|
if (!stillCurrent()) throw new Error('已离开原操作目标,未提交质量任务。')
|
||||||
|
const result = await qualityApi.generate(projectId, input)
|
||||||
|
if (
|
||||||
|
!result ||
|
||||||
|
!Array.isArray(result.results) ||
|
||||||
|
![result.targetCount, result.passed, result.repairFailed, result.failed, result.selected].every(
|
||||||
|
value => Number.isSafeInteger(value) && value >= 0
|
||||||
|
) ||
|
||||||
|
result.targetCount > input.limit ||
|
||||||
|
result.results.length !== result.targetCount ||
|
||||||
|
result.passed + result.repairFailed + result.failed !== result.targetCount ||
|
||||||
|
result.results.some(
|
||||||
|
item =>
|
||||||
|
!readiness.items.some(
|
||||||
|
shot => shot.shotId === item.shotId && shot.episodeNo === input.episodeNo
|
||||||
|
) || typeof item.success !== 'boolean'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new Error('质量流水线回执不完整,请先核对资产,不要重复提交。')
|
||||||
|
receipt = { kind: 'batch', result }
|
||||||
|
} else {
|
||||||
|
if (!readiness.items.some(item => item.shotId === target.shotId))
|
||||||
|
throw new Error('指定镜头不属于当前项目,未提交质量任务。')
|
||||||
|
if (target.kind === 'video') {
|
||||||
|
const videos = await productionApi.listVideos(target.shotId)
|
||||||
|
const video = videos.find(item => item.id === target.assetId && item.shotId === target.shotId)
|
||||||
|
if (
|
||||||
|
!video ||
|
||||||
|
video.status !== 'completed' ||
|
||||||
|
!video.videoUrl ||
|
||||||
|
!video.durationSeconds ||
|
||||||
|
video.durationSeconds <= 0
|
||||||
|
)
|
||||||
|
throw new Error('视频未完成、缺少视频地址或有效时长,不能执行抽样校验。')
|
||||||
|
if (action === 'repair') {
|
||||||
|
if (savedVideoValidation(video.rawJson)?.passed !== false)
|
||||||
|
throw new Error('请先完成视频质量校验;只有未通过的视频才能创建修复候选。')
|
||||||
|
if (videos.some(item => ['pending', 'queued', 'running'].includes(item.status)))
|
||||||
|
throw new Error('此镜头仍有活动视频任务,请结束后再修复。')
|
||||||
|
if ((videoRepairInfo(video.rawJson)?.attempt ?? 0) + 1 > input.maxRepairAttempts)
|
||||||
|
throw new Error('当前视频修复链已达到次数上限,请核对后再调整。')
|
||||||
|
if (!stillCurrent()) throw new Error('已离开原操作目标,未提交质量任务。')
|
||||||
|
const result = await qualityApi.repairVideo(
|
||||||
|
target.shotId,
|
||||||
|
target.assetId,
|
||||||
|
input.maxRepairAttempts
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
!result ||
|
||||||
|
result.shotId !== target.shotId ||
|
||||||
|
result.sourceVideoId !== target.assetId ||
|
||||||
|
!result.candidate ||
|
||||||
|
result.candidate.shotId !== target.shotId ||
|
||||||
|
result.candidate.id === target.assetId ||
|
||||||
|
result.candidate.isPrimary ||
|
||||||
|
!Number.isSafeInteger(result.repairAttempt) ||
|
||||||
|
result.repairAttempt <= 0 ||
|
||||||
|
!Array.isArray(result.repairInstructions)
|
||||||
|
)
|
||||||
|
throw new Error('视频修复任务回执不完整,请先刷新视频记录,勿重复提交。')
|
||||||
|
receipt = { kind: 'video-repair', result }
|
||||||
|
} else {
|
||||||
|
if (!stillCurrent()) throw new Error('已离开原操作目标,未提交质量任务。')
|
||||||
|
const result = await qualityApi.validateVideo(
|
||||||
|
target.shotId,
|
||||||
|
target.assetId,
|
||||||
|
input.allowedTexts ?? []
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
!isVisualValidation(result) ||
|
||||||
|
result.shotId !== target.shotId ||
|
||||||
|
result.videoId !== target.assetId ||
|
||||||
|
!Array.isArray(result.sampleFrames)
|
||||||
|
)
|
||||||
|
throw new Error('视频校验结果与目标不匹配或不完整,请核对后端记录。')
|
||||||
|
receipt = {
|
||||||
|
kind: 'video',
|
||||||
|
result,
|
||||||
|
promoted: !!videoRepairInfo(video.rawJson) && result.passed && result.isPrimary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const keyframes = await productionApi.listKeyframes(target.shotId)
|
||||||
|
const image = keyframes.find(
|
||||||
|
item => item.id === target.assetId && item.shotId === target.shotId
|
||||||
|
)
|
||||||
|
if (!image || image.status !== 'completed' || !image.imageUrl)
|
||||||
|
throw new Error('首帧尚未完成或缺少图片,不能执行质量检查。')
|
||||||
|
if (keyframes.some(item => ['pending', 'generating'].includes(item.status)))
|
||||||
|
throw new Error('此镜头仍在生图,请结束后再校验或修复。')
|
||||||
|
if (action === 'repair' && input.maxRepairAttempts > 0) {
|
||||||
|
const capability = await qualityApi.capability(target.shotId)
|
||||||
|
if (capability.valid !== true)
|
||||||
|
throw new Error(capability.message || '当前图片模型不满足参考图限制。')
|
||||||
|
}
|
||||||
|
if (!stillCurrent()) throw new Error('已离开原操作目标,未提交质量任务。')
|
||||||
|
if (action === 'repair') {
|
||||||
|
const result = await qualityApi.repair(target.shotId, target.assetId, {
|
||||||
|
maxRepairAttempts: input.maxRepairAttempts,
|
||||||
|
allowedTexts: input.allowedTexts,
|
||||||
|
width: input.width,
|
||||||
|
height: input.height
|
||||||
|
})
|
||||||
|
if (
|
||||||
|
!result ||
|
||||||
|
result.shotId !== target.shotId ||
|
||||||
|
result.initialKeyframeId !== target.assetId ||
|
||||||
|
typeof result.passed !== 'boolean' ||
|
||||||
|
typeof result.primaryChanged !== 'boolean' ||
|
||||||
|
!validAttempts(result.attempts)
|
||||||
|
)
|
||||||
|
throw new Error('修复回执不完整,请先核对首帧与主图状态,不要重复生图。')
|
||||||
|
receipt = { kind: 'repair', result }
|
||||||
|
} else {
|
||||||
|
const result = await qualityApi.validateKeyframe(
|
||||||
|
target.shotId,
|
||||||
|
target.assetId,
|
||||||
|
input.allowedTexts ?? []
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
!isVisualValidation(result) ||
|
||||||
|
result.shotId !== target.shotId ||
|
||||||
|
result.keyframeId !== target.assetId ||
|
||||||
|
!result.validationTaskId
|
||||||
|
)
|
||||||
|
throw new Error('首帧校验结果不完整或与目标不匹配,请核对后端记录。')
|
||||||
|
receipt = { kind: 'keyframe', result }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
receiptTarget.receipt = receipt
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (!ok) receiptTarget.error = getOperation(projectId).error
|
||||||
|
// 已提交请求即使切换页面也归原会话;只刷新仍在查看的原项目。
|
||||||
|
if (!disposed && props.projectId === projectId) changed()
|
||||||
|
}
|
||||||
|
return { key, session, operation, blocked, run }
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
||||||
|
import ActionMenu from '../../components/ui/ActionMenu.vue'
|
||||||
import WorkspaceTools from '../../components/ui/WorkspaceTools.vue'
|
import WorkspaceTools from '../../components/ui/WorkspaceTools.vue'
|
||||||
import {
|
import {
|
||||||
NScrollbar,
|
NScrollbar,
|
||||||
@@ -15,7 +16,7 @@ import {
|
|||||||
NTabs
|
NTabs
|
||||||
} from 'naive-ui'
|
} from 'naive-ui'
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { ArrowLeft, Download, RefreshCw } from '@lucide/vue'
|
import { ArrowLeft, RefreshCw } from '@lucide/vue'
|
||||||
import { EmptyState } from '../../components/ui'
|
import { EmptyState } from '../../components/ui'
|
||||||
import { downloadText } from '../../lib/format'
|
import { downloadText } from '../../lib/format'
|
||||||
import ConfirmAction from '../workflows/ConfirmAction.vue'
|
import ConfirmAction from '../workflows/ConfirmAction.vue'
|
||||||
@@ -122,16 +123,26 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
<div class="toolbar-actions">
|
<div class="toolbar-actions">
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<NButton :disabled="query.loading.value" @click="query.refresh" text size="small"
|
<NButton
|
||||||
><RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新分镜</NButton
|
:disabled="query.loading.value"
|
||||||
><NButton :disabled="!shots.length" @click="exportEpisode" text size="small"
|
@click="query.refresh"
|
||||||
><Download :size="13" />导出本集
|
quaternary
|
||||||
</NButton>
|
class="icon-button"
|
||||||
|
aria-label="刷新分镜"
|
||||||
|
title="刷新分镜"
|
||||||
|
><RefreshCw :size="16" :class="{ 'animate-spin': query.loading.value }"
|
||||||
|
/></NButton>
|
||||||
|
<ActionMenu
|
||||||
|
label="分镜更多操作"
|
||||||
|
:items="[{ key: 'export', label: '导出本集', disabled: !shots.length }]"
|
||||||
|
@select="exportEpisode"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<WorkspaceTools
|
<WorkspaceTools
|
||||||
v-model:open="toolsOpen"
|
v-model:open="toolsOpen"
|
||||||
title="分镜生成配置与回执"
|
title="分镜生成配置与回执"
|
||||||
|
label="生成与恢复"
|
||||||
:has-receipt="!!session.receipt"
|
:has-receipt="!!session.receipt"
|
||||||
>
|
>
|
||||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
<div class="flex flex-wrap items-end justify-between gap-4">
|
||||||
|
|||||||
@@ -298,7 +298,7 @@ watch(
|
|||||||
!casting.missingAnchor
|
!casting.missingAnchor
|
||||||
"
|
"
|
||||||
acknowledgement
|
acknowledgement
|
||||||
description="最多按本批上限为缺少 Anchor 的 Character 调用 Seedream,各自独立生成一张停用的 primary 候选,不复用旧身份母版。已有候选、已有母版或已完成选角的角色不会重复处理。"
|
description="最多按本批上限为缺少 Anchor 的 Character 调用 图片模型,各自独立生成一张停用的 primary 候选,不复用旧身份母版。已有候选、已有母版或已完成选角的角色不会重复处理。"
|
||||||
@confirm="generateCastingCandidates"
|
@confirm="generateCastingCandidates"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -559,7 +559,7 @@ watch(
|
|||||||
? '演员身份已确认并锁定,可用于形态图与镜头首帧。'
|
? '演员身份已确认并锁定,可用于形态图与镜头首帧。'
|
||||||
: '已有演员母版但 Identity 尚未锁定,镜头首帧仍会被阻塞。'
|
: '已有演员母版但 Identity 尚未锁定,镜头首帧仍会被阻塞。'
|
||||||
: subject.module === 'prop'
|
: subject.module === 'prop'
|
||||||
? '道具形态生图暂不自动引用此母版。'
|
? '锁定后,后续道具形态图会继承当前母版。'
|
||||||
: '后续场景形态图会继承此图的空间结构。'
|
: '后续场景形态图会继承此图的空间结构。'
|
||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ watch([() => props.subjectId, open], reset)
|
|||||||
function submit() {
|
function submit() {
|
||||||
if (props.disabled || !valid.value || !acknowledged.value) return
|
if (props.disabled || !valid.value || !acknowledged.value) return
|
||||||
emit('generate', {
|
emit('generate', {
|
||||||
provider: 'seedream',
|
|
||||||
...(prompt.value.trim() ? { prompt: prompt.value.trim() } : {}),
|
...(prompt.value.trim() ? { prompt: prompt.value.trim() } : {}),
|
||||||
...(width.value !== '' && height.value !== '' ? { width: width.value, height: height.value } : {})
|
...(width.value !== '' && height.value !== '' ? { width: width.value, height: height.value } : {})
|
||||||
})
|
})
|
||||||
@@ -82,7 +81,7 @@ function submit() {
|
|||||||
id="casting-candidate-cost"
|
id="casting-candidate-cost"
|
||||||
v-model:checked="acknowledged"
|
v-model:checked="acknowledged"
|
||||||
class="flex items-start gap-2 text-xs leading-6"
|
class="flex items-start gap-2 text-xs leading-6"
|
||||||
>我已确认调用 Seedream 可能产生费用,且当前没有同项目生成任务。
|
>我已确认调用图片模型可能产生费用,且当前没有同项目生成任务。
|
||||||
</NCheckbox>
|
</NCheckbox>
|
||||||
<div class="dialog-footer">
|
<div class="dialog-footer">
|
||||||
<NButton @click="open = false">取消</NButton>
|
<NButton @click="open = false">取消</NButton>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import DetailDisclosure from '../../../components/ui/DetailDisclosure.vue'
|
||||||
import { NButton, NCheckbox, NInput } from 'naive-ui'
|
import { NButton, NCheckbox, NInput } from 'naive-ui'
|
||||||
import { computed, reactive, ref, watch } from 'vue'
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
import type { SaveIdentityInput, SubjectIdentity } from '../types'
|
import type { SaveIdentityInput, SubjectIdentity } from '../types'
|
||||||
@@ -57,7 +58,8 @@ function save() {
|
|||||||
:autosize="{ minRows: 3, maxRows: 12 }"
|
:autosize="{ minRows: 3, maxRows: 12 }"
|
||||||
></NInput>
|
></NInput>
|
||||||
</label>
|
</label>
|
||||||
<p class="text-xs leading-6 text-muted">
|
<DetailDisclosure title="身份填写与锁定规则"
|
||||||
|
><p class="text-xs leading-6 text-muted">
|
||||||
{{
|
{{
|
||||||
module === 'prop'
|
module === 'prop'
|
||||||
? '保留道具固定文字、编号、部件位置、结构与材质组合;不要泛化原有辨识细节,也不要写临时损伤或摆放状态。'
|
? '保留道具固定文字、编号、部件位置、结构与材质组合;不要泛化原有辨识细节,也不要写临时损伤或摆放状态。'
|
||||||
@@ -67,16 +69,16 @@ function save() {
|
|||||||
}}
|
}}
|
||||||
这里保存身份事实;背景、视角和参考图约束由后端另行编译,不是最终整段生图 Prompt。
|
这里保存身份事实;背景、视角和参考图约束由后端另行编译,不是最终整段生图 Prompt。
|
||||||
</p>
|
</p>
|
||||||
|
<p class="mt-2 text-xs text-muted">
|
||||||
|
人物需要同时确认母版并锁定身份,首帧才能就绪。锁定后仍可人工编辑、生成图片及切换母版。
|
||||||
|
</p></DetailDisclosure
|
||||||
|
>
|
||||||
<NCheckbox
|
<NCheckbox
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
id="identity-lock"
|
id="identity-lock"
|
||||||
v-model:checked="form.isLocked"
|
v-model:checked="form.isLocked"
|
||||||
class="flex items-start gap-2 text-xs leading-6"
|
class="flex items-start gap-2 text-xs leading-6"
|
||||||
>{{
|
>{{ module === 'character' ? '确认并锁定演员身份' : '确认并锁定身份文本' }}</NCheckbox
|
||||||
module === 'character'
|
|
||||||
? '确认并锁定演员身份。Character 必须同时具有身份母版并锁定 Identity,首帧才能进入就绪状态。'
|
|
||||||
: '确认并锁定身份文本。锁定后仍可人工保存、生成图片及切换母版。'
|
|
||||||
}}</NCheckbox
|
|
||||||
>
|
>
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<span class="text-xs text-muted">{{
|
<span class="text-xs text-muted">{{
|
||||||
|
|||||||
@@ -45,11 +45,10 @@ watch(
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 提交显式的 Seedream 参数;省略空参考 ID 以保留后端自动选择逻辑。 */
|
/** 提交生图参数;省略空参考 ID 以保留后端自动选择逻辑。 */
|
||||||
function submit() {
|
function submit() {
|
||||||
if (props.disabled || !valid.value || !acknowledged.value) return
|
if (props.disabled || !valid.value || !acknowledged.value) return
|
||||||
emit('generate', {
|
emit('generate', {
|
||||||
provider: 'seedream',
|
|
||||||
viewType: viewType.value,
|
viewType: viewType.value,
|
||||||
...(referenceImageId.value ? { referenceImageId: referenceImageId.value } : {}),
|
...(referenceImageId.value ? { referenceImageId: referenceImageId.value } : {}),
|
||||||
...(prompt.value.trim() ? { prompt: prompt.value.trim() } : {}),
|
...(prompt.value.trim() ? { prompt: prompt.value.trim() } : {}),
|
||||||
@@ -63,7 +62,7 @@ function submit() {
|
|||||||
<AppDialog v-model:open="open" title="生成身份参考图" :description="subjectName">
|
<AppDialog v-model:open="open" title="生成身份参考图" :description="subjectName">
|
||||||
<form class="mt-5 space-y-4" @submit.prevent="submit">
|
<form class="mt-5 space-y-4" @submit.prevent="submit">
|
||||||
<NAlert type="info" :show-icon="false" class="text-xs">
|
<NAlert type="info" :show-icon="false" class="text-xs">
|
||||||
调用 Seedream,每次新增一张图片,可能产生费用。第一张成功的 primary 图自动成为母版;已有母版时,新
|
调用 图片模型,每次新增一张图片,可能产生费用。第一张成功的 primary 图自动成为母版;已有母版时,新
|
||||||
primary 图仅作为停用候选保留。
|
primary 图仅作为停用候选保留。
|
||||||
</NAlert>
|
</NAlert>
|
||||||
<label class="block"
|
<label class="block"
|
||||||
|
|||||||
@@ -292,7 +292,6 @@ describe('身份图与母版契约', () => {
|
|||||||
expect(wrapper.emitted('generate')).toEqual([
|
expect(wrapper.emitted('generate')).toEqual([
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
provider: 'seedream',
|
|
||||||
viewType: 'three-quarter',
|
viewType: 'three-quarter',
|
||||||
referenceImageId: 'identity-image-1',
|
referenceImageId: 'identity-image-1',
|
||||||
width: 2048,
|
width: 2048,
|
||||||
@@ -379,7 +378,7 @@ describe('身份图与母版契约', () => {
|
|||||||
expect(wrapper.emitted('anchor')).toEqual([['identity-image-1']])
|
expect(wrapper.emitted('anchor')).toEqual([['identity-image-1']])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('选角候选固定发送 Seedream,不携带普通身份图视角字段', async () => {
|
it('选角候选不再发送 Provider,不携带普通身份图视角字段', async () => {
|
||||||
wrapper = mount(CastingCandidateDialog, {
|
wrapper = mount(CastingCandidateDialog, {
|
||||||
attachTo: document.body,
|
attachTo: document.body,
|
||||||
props: { open: true, subjectId: 'subject-1', subjectName: '林夏', disabled: false }
|
props: { open: true, subjectId: 'subject-1', subjectName: '林夏', disabled: false }
|
||||||
@@ -389,7 +388,7 @@ describe('身份图与母版契约', () => {
|
|||||||
document.querySelector<HTMLInputElement>('#casting-candidate-cost')!.click()
|
document.querySelector<HTMLInputElement>('#casting-candidate-cost')!.click()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
button('确认生成候选').click()
|
button('确认生成候选').click()
|
||||||
expect(wrapper.emitted('generate')).toEqual([[{ provider: 'seedream' }]])
|
expect(wrapper.emitted('generate')).toEqual([[{}]])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('角色选角接口区分批量身份、候选生图与确认事务', async () => {
|
it('角色选角接口区分批量身份、候选生图与确认事务', async () => {
|
||||||
@@ -411,7 +410,7 @@ describe('身份图与母版契约', () => {
|
|||||||
)
|
)
|
||||||
vi.stubGlobal('fetch', fetcher)
|
vi.stubGlobal('fetch', fetcher)
|
||||||
await subjectIdentityApi.generateCharacters('project/1', { force: false, concurrency: 2 })
|
await subjectIdentityApi.generateCharacters('project/1', { force: false, concurrency: 2 })
|
||||||
await subjectIdentityApi.generateCastingCandidate('subject/1', { provider: 'seedream' })
|
await subjectIdentityApi.generateCastingCandidate('subject/1', {})
|
||||||
await subjectIdentityApi.confirmCasting('subject/1', 'image/1')
|
await subjectIdentityApi.confirmCasting('subject/1', 'image/1')
|
||||||
expect(fetcher.mock.calls.map(([url]) => url)).toEqual([
|
expect(fetcher.mock.calls.map(([url]) => url)).toEqual([
|
||||||
'/api/projects/project%2F1/character-identities/generate',
|
'/api/projects/project%2F1/character-identities/generate',
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ export type SaveIdentityInput = Partial<Pick<SubjectIdentity, 'description' | 'g
|
|||||||
|
|
||||||
/** 单张身份生图;空 referenceImageId 应省略,由后端自动引用当前母版。 */
|
/** 单张身份生图;空 referenceImageId 应省略,由后端自动引用当前母版。 */
|
||||||
export interface GenerateIdentityImageInput {
|
export interface GenerateIdentityImageInput {
|
||||||
provider: 'seedream'
|
|
||||||
viewType: IdentityViewType
|
viewType: IdentityViewType
|
||||||
referenceImageId?: string
|
referenceImageId?: string
|
||||||
prompt?: string
|
prompt?: string
|
||||||
@@ -56,7 +55,6 @@ export interface GenerateIdentityImageInput {
|
|||||||
|
|
||||||
/** Character 选角候选固定独立生成 primary,不复用旧 Anchor,也不会自动成为新 Anchor。 */
|
/** Character 选角候选固定独立生成 primary,不复用旧 Anchor,也不会自动成为新 Anchor。 */
|
||||||
export interface GenerateCastingCandidateInput {
|
export interface GenerateCastingCandidateInput {
|
||||||
provider: 'seedream'
|
|
||||||
prompt?: string
|
prompt?: string
|
||||||
width?: number
|
width?: number
|
||||||
height?: number
|
height?: number
|
||||||
@@ -110,7 +108,6 @@ export interface CharacterCastingReadiness {
|
|||||||
|
|
||||||
/** 小批量生成选角候选的配置。 */
|
/** 小批量生成选角候选的配置。 */
|
||||||
export interface GenerateCastingCandidatesInput {
|
export interface GenerateCastingCandidatesInput {
|
||||||
provider: 'seedream'
|
|
||||||
limit: number
|
limit: number
|
||||||
concurrency: number
|
concurrency: number
|
||||||
width?: number
|
width?: number
|
||||||
|
|||||||
@@ -183,7 +183,6 @@ export function useSubjectIdentity() {
|
|||||||
kind: 'casting',
|
kind: 'casting',
|
||||||
title,
|
title,
|
||||||
result: await subjectIdentityApi.generateCastingCandidates(id, {
|
result: await subjectIdentityApi.generateCastingCandidates(id, {
|
||||||
provider: 'seedream',
|
|
||||||
limit: candidateLimit.value,
|
limit: candidateLimit.value,
|
||||||
concurrency: concurrency.value
|
concurrency: concurrency.value
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
||||||
|
import ActionMenu from '../../components/ui/ActionMenu.vue'
|
||||||
|
import DetailDisclosure from '../../components/ui/DetailDisclosure.vue'
|
||||||
import WorkspaceTools from '../../components/ui/WorkspaceTools.vue'
|
import WorkspaceTools from '../../components/ui/WorkspaceTools.vue'
|
||||||
import { NAlert, NButton, NCheckbox, NCollapse, NCollapseItem, NInput, NInputNumber, NSelect, NTag } from 'naive-ui'
|
import { NAlert, NButton, NCheckbox, NCollapse, NCollapseItem, NInput, NInputNumber, NSelect, NTag } from 'naive-ui'
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
@@ -196,6 +198,7 @@ async function showStaleForms() {
|
|||||||
><WorkspaceTools
|
><WorkspaceTools
|
||||||
v-model:open="toolsOpen"
|
v-model:open="toolsOpen"
|
||||||
title="形态生图配置与回执"
|
title="形态生图配置与回执"
|
||||||
|
label="批量生图"
|
||||||
:has-receipt="!!session.receipt || !!session.promptReceipt"
|
:has-receipt="!!session.receipt || !!session.promptReceipt"
|
||||||
><div class="flex flex-wrap items-end justify-between gap-4">
|
><div class="flex flex-wrap items-end justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -208,12 +211,12 @@ async function showStaleForms() {
|
|||||||
>去检查分镜参考图<ArrowRight :size="14"
|
>去检查分镜参考图<ArrowRight :size="14"
|
||||||
/></RouterLink>
|
/></RouterLink>
|
||||||
</div>
|
</div>
|
||||||
<NAlert type="info" :show-icon="false" class="mt-5 text-xs">
|
<DetailDisclosure title="母版继承与过期处理" class="mt-4">
|
||||||
人物、场景、道具形态图均可继承已锁定 Identity
|
人物、场景、道具形态图均可继承已锁定 Identity
|
||||||
的母版,并记录本次引用来源。母版变化后可筛选过期图,重新生成候选并人工确认新的主参考图;不要直接沿用旧资产进入下游。
|
的母版,并记录本次引用来源。母版变化后可筛选过期图,重新生成候选并人工确认新的主参考图;不要直接沿用旧资产进入下游。
|
||||||
<RouterLink :to="`/projects/${id}/subject-identity`" class="text-button ml-2"
|
<RouterLink :to="`/projects/${id}/subject-identity`" class="text-button ml-2"
|
||||||
>管理主体身份 →</RouterLink
|
>管理主体身份 →</RouterLink
|
||||||
></NAlert
|
></DetailDisclosure
|
||||||
>
|
>
|
||||||
<FormPromptBatch
|
<FormPromptBatch
|
||||||
:forms="forms"
|
:forms="forms"
|
||||||
@@ -231,7 +234,7 @@ async function showStaleForms() {
|
|||||||
<span v-if="staleForms.length" class="ml-3 text-xs text-danger">
|
<span v-if="staleForms.length" class="ml-3 text-xs text-danger">
|
||||||
{{ staleForms.length }} 个形态主图已过期
|
{{ staleForms.length }} 个形态主图已过期
|
||||||
</span>
|
</span>
|
||||||
<span class="ml-3 text-xs text-muted">Seedream · 后端配置</span>
|
<span class="ml-3 text-xs text-muted">图片模型 · 后端配置</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-[11px] leading-6 text-muted">
|
<p class="mt-2 text-[11px] leading-6 text-muted">
|
||||||
@@ -248,7 +251,7 @@ async function showStaleForms() {
|
|||||||
label="重新生成过期形态"
|
label="重新生成过期形态"
|
||||||
:disabled="blocked || !concurrencyValid || !staleForms.length"
|
:disabled="blocked || !concurrencyValid || !staleForms.length"
|
||||||
acknowledgement
|
acknowledgement
|
||||||
description="只为身份母版已经变化的形态调用 Seedream,各新增一张基于当前已锁定母版的候选图;不会自动替换旧主图,生成后请人工确认并设置新的主参考图。"
|
description="只为身份母版已经变化的形态调用 图片模型,各新增一张基于当前已锁定母版的候选图;不会自动替换旧主图,生成后请人工确认并设置新的主参考图。"
|
||||||
@confirm="generateStale"
|
@confirm="generateStale"
|
||||||
/></div
|
/></div
|
||||||
></NAlert>
|
></NAlert>
|
||||||
@@ -290,8 +293,8 @@ async function showStaleForms() {
|
|||||||
acknowledgement
|
acknowledgement
|
||||||
:description="
|
:description="
|
||||||
force
|
force
|
||||||
? '对项目全部形态调用 Seedream 新增一张图片。已有主图不会被替换,生成后可在图片记录中手动选择主图。'
|
? '对项目全部形态调用图片模型新增一张图片。已有主图不会被替换,生成后可在图片记录中手动选择主图。'
|
||||||
: '仅对没有主图的形态调用 Seedream 生图,已有主图的形态跳过。操作面向整个项目,不受列表筛选影响。'
|
: '仅对没有主图的形态调用图片模型生图,已有主图的形态跳过。操作面向整个项目,不受列表筛选影响。'
|
||||||
"
|
"
|
||||||
@confirm="generateProject"
|
@confirm="generateProject"
|
||||||
/>
|
/>
|
||||||
@@ -531,11 +534,6 @@ async function showStaleForms() {
|
|||||||
<p v-if="form.description" class="mt-3 line-clamp-2 text-xs leading-6 text-muted">
|
<p v-if="form.description" class="mt-3 line-clamp-2 text-xs leading-6 text-muted">
|
||||||
{{ form.description }}
|
{{ form.description }}
|
||||||
</p>
|
</p>
|
||||||
<RouterLink
|
|
||||||
:to="{ path: `/projects/${id}/subject-identity`, query: { subjectId: form.subjectId } }"
|
|
||||||
class="text-button mt-3"
|
|
||||||
>查看主体身份与母版 →</RouterLink
|
|
||||||
>
|
|
||||||
<div class="mt-3 flex flex-wrap items-center gap-2">
|
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||||||
<StatusBadge v-if="hasRunningImages(form.images)" status="generating" />
|
<StatusBadge v-if="hasRunningImages(form.images)" status="generating" />
|
||||||
<NTag v-if="isPrimaryIdentityStale(form)" size="small" :bordered="false">身份母版已变更</NTag>
|
<NTag v-if="isPrimaryIdentityStale(form)" size="small" :bordered="false">身份母版已变更</NTag>
|
||||||
@@ -582,8 +580,22 @@ async function showStaleForms() {
|
|||||||
{{ form.images[0].error }}
|
{{ form.images[0].error }}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-4 flex flex-wrap items-center justify-between gap-2">
|
<div class="mt-4 flex flex-wrap items-center justify-between gap-2">
|
||||||
<NButton text size="small" @click="openPrompt(form)">提示词与生成</NButton>
|
|
||||||
<NButton @click="openGallery(form)" text size="small">查看图片与记录</NButton>
|
<NButton @click="openGallery(form)" text size="small">查看图片与记录</NButton>
|
||||||
|
<ActionMenu
|
||||||
|
:label="`${form.subject.name} · ${form.name} 更多操作`"
|
||||||
|
:items="[
|
||||||
|
{ key: 'prompt', label: '提示词与生成' },
|
||||||
|
{ key: 'identity', label: '主体身份与母版' }
|
||||||
|
]"
|
||||||
|
@select="
|
||||||
|
$event === 'prompt'
|
||||||
|
? openPrompt(form)
|
||||||
|
: router.push({
|
||||||
|
path: `/projects/${id}/subject-identity`,
|
||||||
|
query: { subjectId: form.subjectId }
|
||||||
|
})
|
||||||
|
"
|
||||||
|
/>
|
||||||
<NButton :disabled="blocked" @click="openGenerate(form)"
|
<NButton :disabled="blocked" @click="openGenerate(form)"
|
||||||
><ImagePlus :size="14" />
|
><ImagePlus :size="14" />
|
||||||
{{
|
{{
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ watch(
|
|||||||
function submit() {
|
function submit() {
|
||||||
if (!props.form || props.disabled || !dimensionsValid.value || !acknowledged.value) return
|
if (!props.form || props.disabled || !dimensionsValid.value || !acknowledged.value) return
|
||||||
emit('generate', props.form.id, {
|
emit('generate', props.form.id, {
|
||||||
provider: 'seedream',
|
|
||||||
setPrimary: setPrimary.value,
|
setPrimary: setPrimary.value,
|
||||||
...(prompt.value.trim() ? { prompt: prompt.value.trim() } : {}),
|
...(prompt.value.trim() ? { prompt: prompt.value.trim() } : {}),
|
||||||
...(width.value !== '' && height.value !== '' ? { width: width.value, height: height.value } : {})
|
...(width.value !== '' && height.value !== '' ? { width: width.value, height: height.value } : {})
|
||||||
@@ -50,7 +49,7 @@ function submit() {
|
|||||||
>
|
>
|
||||||
<form class="mt-6 space-y-5" @submit.prevent="submit">
|
<form class="mt-6 space-y-5" @submit.prevent="submit">
|
||||||
<NAlert type="info" :show-icon="false" class="text-xs"
|
<NAlert type="info" :show-icon="false" class="text-xs"
|
||||||
>使用后端配置的 Seedream 模型,可能产生费用。每次新增一张图片,不删除历史结果。</NAlert
|
>使用后端配置的图片模型,可能产生费用。每次新增一张图片,不删除历史结果。</NAlert
|
||||||
>
|
>
|
||||||
<p class="text-xs leading-6 text-muted">
|
<p class="text-xs leading-6 text-muted">
|
||||||
人物、场景、道具均会引用已锁定身份的当前母版,保持身份、空间骨架或物件结构;身份未锁定或没有母版时不会继承该图片。更换母版不会自动更新已有形态图。
|
人物、场景、道具均会引用已锁定身份的当前母版,保持身份、空间骨架或物件结构;身份未锁定或没有母版时不会继承该图片。更换母版不会自动更新已有形态图。
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ describe('形态图片选择与操作', () => {
|
|||||||
expect(validImageSize(width, height)).toBe(false)
|
expect(validImageSize(width, height)).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('生图确认发送 Seedream 与正式形态 ID,默认不替换已有主图', async () => {
|
it('生图确认发送正式形态 ID,不覆盖后端模型,默认不替换已有主图', async () => {
|
||||||
wrapper = mount(GenerateImageDialog, {
|
wrapper = mount(GenerateImageDialog, {
|
||||||
attachTo: document.body,
|
attachTo: document.body,
|
||||||
props: { open: true, form: formFixture(), disabled: false }
|
props: { open: true, form: formFixture(), disabled: false }
|
||||||
@@ -108,7 +108,7 @@ describe('形态图片选择与操作', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
button('确认生成图片').click()
|
button('确认生成图片').click()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(wrapper.emitted('generate')).toEqual([['form-db-1', { provider: 'seedream', setPrimary: false }]])
|
expect(wrapper.emitted('generate')).toEqual([['form-db-1', { setPrimary: false }]])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('新形态默认设主图,填写一侧尺寸时不能提交,切换形态清空自定义提示词', async () => {
|
it('新形态默认设主图,填写一侧尺寸时不能提交,切换形态清空自定义提示词', async () => {
|
||||||
@@ -130,7 +130,7 @@ describe('形态图片选择与操作', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
button('确认生成图片').click()
|
button('确认生成图片').click()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(wrapper.emitted('generate')).toEqual([['form-db-2', { provider: 'seedream', setPrimary: true }]])
|
expect(wrapper.emitted('generate')).toEqual([['form-db-2', { setPrimary: true }]])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('图库不自动生图,主图切换经确认后 PUT,失败图片不能设主图', async () => {
|
it('图库不自动生图,主图切换经确认后 PUT,失败图片不能设主图', async () => {
|
||||||
|
|||||||
@@ -122,7 +122,6 @@ describe('形态正式提示词与批量配置', () => {
|
|||||||
service.limit.value = 1
|
service.limit.value = 1
|
||||||
await service.generateProject()
|
await service.generateProject()
|
||||||
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({
|
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({
|
||||||
provider: 'seedream',
|
|
||||||
concurrency: 2,
|
concurrency: 2,
|
||||||
force: false,
|
force: false,
|
||||||
limit: 1
|
limit: 1
|
||||||
@@ -153,7 +152,7 @@ describe('形态正式提示词与批量配置', () => {
|
|||||||
expect(isPrimaryIdentityStale(form)).toBe(true)
|
expect(isPrimaryIdentityStale(form)).toBe(true)
|
||||||
await service.query.refresh()
|
await service.query.refresh()
|
||||||
await service.generateStale()
|
await service.generateStale()
|
||||||
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({ provider: 'seedream', setPrimary: false })
|
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({ setPrimary: false })
|
||||||
})
|
})
|
||||||
it('正式提示词确认必须勾选,未确认不发送事件', async () => {
|
it('正式提示词确认必须勾选,未确认不发送事件', async () => {
|
||||||
const form = formFixture()
|
const form = formFixture()
|
||||||
@@ -183,6 +182,6 @@ describe('形态正式提示词与批量配置', () => {
|
|||||||
expect(submit.disabled).toBe(false)
|
expect(submit.disabled).toBe(false)
|
||||||
submit.click()
|
submit.click()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(wrapper.emitted('generate')).toEqual([['form-db-1', { provider: 'seedream', setPrimary: false }]])
|
expect(wrapper.emitted('generate')).toEqual([['form-db-1', { setPrimary: false }]])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -49,9 +49,8 @@ export interface SubjectFormAsset {
|
|||||||
images: SubjectImage[]
|
images: SubjectImage[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 单形态生图配置,明确指定 Seedream,避免后端单图入口默认使用 mock。 */
|
/** 单形态生图配置;实际模型统一由后端 IMAGE 配置决定。 */
|
||||||
export interface GenerateFormImageInput {
|
export interface GenerateFormImageInput {
|
||||||
provider: 'seedream'
|
|
||||||
prompt?: string
|
prompt?: string
|
||||||
width?: number
|
width?: number
|
||||||
height?: number
|
height?: number
|
||||||
@@ -60,7 +59,6 @@ export interface GenerateFormImageInput {
|
|||||||
|
|
||||||
/** 批量生图只接受这些配置;不能把单张尺寸配置误传给批量接口。 */
|
/** 批量生图只接受这些配置;不能把单张尺寸配置误传给批量接口。 */
|
||||||
export interface GenerateProjectImagesInput {
|
export interface GenerateProjectImagesInput {
|
||||||
provider: 'seedream'
|
|
||||||
concurrency: number
|
concurrency: number
|
||||||
force: boolean
|
force: boolean
|
||||||
/** 仅限制本次真正生图的数量,留空表示不限制。 */
|
/** 仅限制本次真正生图的数量,留空表示不限制。 */
|
||||||
|
|||||||
@@ -119,7 +119,6 @@ export function useSubjectImages() {
|
|||||||
if (blocked.value || !batchValid.value || !forms.value.length) return
|
if (blocked.value || !batchValid.value || !forms.value.length) return
|
||||||
const projectId = id.value
|
const projectId = id.value
|
||||||
const input = {
|
const input = {
|
||||||
provider: 'seedream' as const,
|
|
||||||
concurrency: concurrency.value,
|
concurrency: concurrency.value,
|
||||||
force: force.value,
|
force: force.value,
|
||||||
...(limit.value === '' ? {} : { limit: limit.value })
|
...(limit.value === '' ? {} : { limit: limit.value })
|
||||||
@@ -151,7 +150,6 @@ export function useSubjectImages() {
|
|||||||
const results = await runWithConcurrency(targets, concurrency.value, async form => {
|
const results = await runWithConcurrency(targets, concurrency.value, async form => {
|
||||||
try {
|
try {
|
||||||
const image = await subjectImagesApi.generate(form.id, {
|
const image = await subjectImagesApi.generate(form.id, {
|
||||||
provider: 'seedream',
|
|
||||||
setPrimary: false
|
setPrimary: false
|
||||||
})
|
})
|
||||||
if (!image || image.subjectFormId !== form.id || image.status !== 'completed' || !image.imageUrl) {
|
if (!image || image.subjectFormId !== form.id || image.status !== 'completed' || !image.imageUrl) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
||||||
|
import DetailDisclosure from '../../components/ui/DetailDisclosure.vue'
|
||||||
import { NAlert, NButton } from 'naive-ui'
|
import { NAlert, NButton } from 'naive-ui'
|
||||||
import { LoaderCircle, LockKeyhole, LockKeyholeOpen, Palette, RefreshCw, TriangleAlert } from '@lucide/vue'
|
import { LoaderCircle, LockKeyhole, LockKeyholeOpen, Palette, RefreshCw, TriangleAlert } from '@lucide/vue'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
@@ -105,7 +106,6 @@ function removeImage(image: VisualStyleImage) {
|
|||||||
><div class="flex flex-wrap items-end justify-between gap-4">
|
><div class="flex flex-wrap items-end justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-semibold">项目视觉风格</h2>
|
<h2 class="text-lg font-semibold">项目视觉风格</h2>
|
||||||
<p class="mt-2 text-sm text-muted">确定项目统一的视觉语言,再为人物、场景和道具建立稳定身份。</p>
|
|
||||||
</div>
|
</div>
|
||||||
<RouterLink :to="`/projects/${projectId}/subject-identity`" class="text-button"
|
<RouterLink :to="`/projects/${projectId}/subject-identity`" class="text-button"
|
||||||
>下一步:主体身份 →</RouterLink
|
>下一步:主体身份 →</RouterLink
|
||||||
@@ -128,9 +128,15 @@ function removeImage(image: VisualStyleImage) {
|
|||||||
description="调用文本模型生成整体及分类风格。重新生成会覆盖已保存的文本与硬约束;旧身份、旧图片和已有提示词不会自动更新。"
|
description="调用文本模型生成整体及分类风格。重新生成会覆盖已保存的文本与硬约束;旧身份、旧图片和已有提示词不会自动更新。"
|
||||||
@confirm="generate"
|
@confirm="generate"
|
||||||
/>
|
/>
|
||||||
<NButton :loading="query.loading.value" @click="query.refresh"
|
<NButton
|
||||||
><template #icon><RefreshCw :size="16" /></template>刷新风格</NButton
|
:loading="query.loading.value"
|
||||||
>
|
@click="query.refresh"
|
||||||
|
quaternary
|
||||||
|
class="icon-button"
|
||||||
|
aria-label="刷新风格"
|
||||||
|
title="刷新风格"
|
||||||
|
><template #icon><RefreshCw :size="16" /></template
|
||||||
|
></NButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mb-4"
|
<NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mb-4"
|
||||||
@@ -139,9 +145,9 @@ function removeImage(image: VisualStyleImage) {
|
|||||||
<p v-if="dirty" class="mb-4 text-xs text-muted">
|
<p v-if="dirty" class="mb-4 text-xs text-muted">
|
||||||
有未保存修改,AI 重生成暂不可用。自动刷新不会覆盖编辑内容;离开页面不会自动保存。
|
有未保存修改,AI 重生成暂不可用。自动刷新不会覆盖编辑内容;离开页面不会自动保存。
|
||||||
</p>
|
</p>
|
||||||
<NAlert type="info" :show-icon="false" class="mb-5 text-xs">
|
<DetailDisclosure title="风格生成规则" class="mb-5">
|
||||||
AI
|
AI
|
||||||
生成时,若项目和世界观没有明确其它人物背景,人物风格默认采用中国人物选角基线;人工填写的人物背景和角色事实优先。修改风格只影响之后的生成,旧资产不会自动更新。 </NAlert
|
生成时,若项目和世界观没有明确其它人物背景,人物风格默认采用中国人物选角基线;人工填写的人物背景和角色事实优先。修改风格只影响之后的生成,旧资产不会自动更新。 </DetailDisclosure
|
||||||
><StyleEditor
|
><StyleEditor
|
||||||
v-if="query.data.value"
|
v-if="query.data.value"
|
||||||
:key="editorRevision"
|
:key="editorRevision"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import DetailDisclosure from '../../../components/ui/DetailDisclosure.vue'
|
||||||
import { NButton, NCheckbox, NInput, NTag } from 'naive-ui'
|
import { NButton, NCheckbox, NInput, NTag } from 'naive-ui'
|
||||||
import { computed, reactive, ref, watch } from 'vue'
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
import type { SaveVisualStyleInput, VisualStyle } from '../types'
|
import type { SaveVisualStyleInput, VisualStyle } from '../types'
|
||||||
@@ -84,7 +85,8 @@ function save() {
|
|||||||
:autosize="{ minRows: 3, maxRows: 12 }"
|
:autosize="{ minRows: 3, maxRows: 12 }"
|
||||||
></NInput>
|
></NInput>
|
||||||
</label>
|
</label>
|
||||||
<div class="grid gap-5 lg:grid-cols-3">
|
<DetailDisclosure title="分类风格与硬约束"
|
||||||
|
><div class="grid gap-5 lg:grid-cols-3">
|
||||||
<label
|
<label
|
||||||
><span class="field-label">人物风格与选角背景</span
|
><span class="field-label">人物风格与选角背景</span
|
||||||
><NInput
|
><NInput
|
||||||
@@ -117,7 +119,7 @@ function save() {
|
|||||||
></NInput>
|
></NInput>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<label class="block"
|
<label class="mt-4 block"
|
||||||
><span class="field-label">硬约束 · JSON 字符串数组</span
|
><span class="field-label">硬约束 · JSON 字符串数组</span
|
||||||
><NInput
|
><NInput
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
@@ -129,6 +131,7 @@ function save() {
|
|||||||
:autosize="{ minRows: 3, maxRows: 12 }"
|
:autosize="{ minRows: 3, maxRows: 12 }"
|
||||||
></NInput>
|
></NInput>
|
||||||
</label>
|
</label>
|
||||||
|
</DetailDisclosure>
|
||||||
<p v-if="constraintsError" class="text-xs text-danger" role="alert">{{ constraintsError }}</p>
|
<p v-if="constraintsError" class="text-xs text-danger" role="alert">{{ constraintsError }}</p>
|
||||||
<NCheckbox
|
<NCheckbox
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { computed, nextTick, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { NImage, NRadioGroup, NScrollbar } from 'naive-ui'
|
import { NImage, NRadioGroup, NScrollbar } from 'naive-ui'
|
||||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||||
@@ -28,7 +28,7 @@ import SubjectIdentityPage from '../subject-identity/SubjectIdentityPage.vue'
|
|||||||
import { styleFixture, styleImageFixture } from '../visual-style/testing/fixtures'
|
import { styleFixture, styleImageFixture } from '../visual-style/testing/fixtures'
|
||||||
import { identityFixture, identityImageFixture } from '../subject-identity/testing/fixtures'
|
import { identityFixture, identityImageFixture } from '../subject-identity/testing/fixtures'
|
||||||
import { getIdentitySession } from '../subject-identity/model'
|
import { getIdentitySession } from '../subject-identity/model'
|
||||||
import { expandSections, selectControl } from '../../testing/naive'
|
import { expandSections, selectControl, selectMenu } from '../../testing/naive'
|
||||||
import ProductionPage from '../production/ProductionPage.vue'
|
import ProductionPage from '../production/ProductionPage.vue'
|
||||||
import { getProductionSession } from '../production/model'
|
import { getProductionSession } from '../production/model'
|
||||||
|
|
||||||
@@ -85,7 +85,9 @@ function context(): ReturnType<typeof useProjectData> {
|
|||||||
|
|
||||||
/** 找到挂载到 body 的 Naive 弹窗按钮。 */
|
/** 找到挂载到 body 的 Naive 弹窗按钮。 */
|
||||||
function button(label: string): HTMLButtonElement {
|
function button(label: string): HTMLButtonElement {
|
||||||
const element = [...document.querySelectorAll('button')].find(item => item.textContent?.trim() === label)
|
const element = [...document.querySelectorAll('button')].find(
|
||||||
|
item => item.textContent?.trim() === label || item.getAttribute('aria-label') === label
|
||||||
|
)
|
||||||
if (!element) throw new Error('找不到按钮:' + label)
|
if (!element) throw new Error('找不到按钮:' + label)
|
||||||
return element
|
return element
|
||||||
}
|
}
|
||||||
@@ -429,6 +431,7 @@ describe('视觉风格与主体身份工作区', () => {
|
|||||||
vi.stubGlobal('fetch', fetcher)
|
vi.stubGlobal('fetch', fetcher)
|
||||||
await mountAssets('style')
|
await mountAssets('style')
|
||||||
expect(wrapper!.text()).toContain('尚未创建')
|
expect(wrapper!.text()).toContain('尚未创建')
|
||||||
|
await expandSections()
|
||||||
expect(wrapper!.text()).toContain('默认采用中国人物选角基线')
|
expect(wrapper!.text()).toContain('默认采用中国人物选角基线')
|
||||||
expect(wrapper!.text()).toContain('人物风格与选角背景')
|
expect(wrapper!.text()).toContain('人物风格与选角背景')
|
||||||
await wrapper!.get('#style-constraints').setValue('{"不应静默丢弃":true}')
|
await wrapper!.get('#style-constraints').setValue('{"不应静默丢弃":true}')
|
||||||
@@ -548,7 +551,7 @@ describe('视觉风格与主体身份工作区', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')!
|
const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')!
|
||||||
expect(post[0]).toBe('/api/subjects/subject-db-1/identity/images')
|
expect(post[0]).toBe('/api/subjects/subject-db-1/identity/images')
|
||||||
expect(JSON.parse(post[1]!.body as string)).toEqual({ provider: 'seedream', viewType: 'front' })
|
expect(JSON.parse(post[1]!.body as string)).toEqual({ viewType: 'front' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('无项目风格时禁止生成身份和图片,但仍可人工保存身份', async () => {
|
it('无项目风格时禁止生成身份和图片,但仍可人工保存身份', async () => {
|
||||||
@@ -831,7 +834,7 @@ describe('工作台页面交互', () => {
|
|||||||
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
|
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('拆解导出按钮位于 Tabs suffix,且保持空快照禁用状态', () => {
|
it('拆解更多菜单位于 Tabs suffix,空快照时导出仍禁用', async () => {
|
||||||
wrapper = mount(BreakdownPage, {
|
wrapper = mount(BreakdownPage, {
|
||||||
attachTo: document.body,
|
attachTo: document.body,
|
||||||
global: { provide: { [projectContextKey as symbol]: context() }, stubs: { RouterLink: true } }
|
global: { provide: { [projectContextKey as symbol]: context() }, stubs: { RouterLink: true } }
|
||||||
@@ -839,9 +842,13 @@ describe('工作台页面交互', () => {
|
|||||||
const toolbar = wrapper.get('.result-toolbar')
|
const toolbar = wrapper.get('.result-toolbar')
|
||||||
expect(toolbar.find('[aria-label="拆解结果"]').exists()).toBe(true)
|
expect(toolbar.find('[aria-label="拆解结果"]').exists()).toBe(true)
|
||||||
expect(toolbar.find(':scope > button').exists()).toBe(false)
|
expect(toolbar.find(':scope > button').exists()).toBe(false)
|
||||||
const exportButton = toolbar.get<HTMLButtonElement>('.n-tabs-nav__suffix button')
|
expect(toolbar.get('.n-tabs-nav__suffix button').attributes('aria-label')).toBe('拆解更多操作')
|
||||||
expect(exportButton.text()).toBe('导出 JSON')
|
await toolbar.get('.n-tabs-nav__suffix button').trigger('click')
|
||||||
expect(exportButton.element.disabled).toBe(true)
|
await flushPromises()
|
||||||
|
const exportItem = [...document.querySelectorAll('.n-dropdown-option-body')].find(
|
||||||
|
item => item.textContent?.trim() === '导出 JSON'
|
||||||
|
)!
|
||||||
|
expect(exportItem.className).toContain('disabled')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('形态图库自动显示后端已有图片,不需要再次生图;筛选只改变显示', async () => {
|
it('形态图库自动显示后端已有图片,不需要再次生图;筛选只改变显示', async () => {
|
||||||
@@ -915,7 +922,7 @@ describe('工作台页面交互', () => {
|
|||||||
await flushPromises()
|
await flushPromises()
|
||||||
const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')!
|
const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')!
|
||||||
expect(post[0]).toBe('/api/subject-forms/form-db-1/images')
|
expect(post[0]).toBe('/api/subject-forms/form-db-1/images')
|
||||||
expect(JSON.parse(post[1]!.body as string)).toEqual({ provider: 'seedream', setPrimary: false })
|
expect(JSON.parse(post[1]!.body as string)).toEqual({ setPrimary: false })
|
||||||
expect(wrapper!.text()).toContain('2 条记录')
|
expect(wrapper!.text()).toContain('2 条记录')
|
||||||
expect(wrapper!.get('.form-image-card img').attributes('src')).not.toContain('/storage/new.png')
|
expect(wrapper!.get('.form-image-card img').attributes('src')).not.toContain('/storage/new.png')
|
||||||
expect(fetcher.mock.calls.some(([url]) => String(url).includes('production/start'))).toBe(false)
|
expect(fetcher.mock.calls.some(([url]) => String(url).includes('production/start'))).toBe(false)
|
||||||
@@ -947,7 +954,7 @@ describe('工作台页面交互', () => {
|
|||||||
expect(wrapper!.get('[aria-label="生图批量回执"]').text()).toContain('部分形态生图失败')
|
expect(wrapper!.get('[aria-label="生图批量回执"]').text()).toContain('部分形态生图失败')
|
||||||
expect(wrapper!.text()).toContain('服务暂不可用')
|
expect(wrapper!.text()).toContain('服务暂不可用')
|
||||||
const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')!
|
const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')!
|
||||||
expect(JSON.parse(post[1]!.body as string)).toEqual({ provider: 'seedream', concurrency: 2, force: false })
|
expect(JSON.parse(post[1]!.body as string)).toEqual({ concurrency: 2, force: false })
|
||||||
await wrapper!.get('.n-collapse [role="checkbox"]').trigger('click')
|
await wrapper!.get('.n-collapse [role="checkbox"]').trigger('click')
|
||||||
await confirmGeneration('为全项目新增候选图')
|
await confirmGeneration('为全项目新增候选图')
|
||||||
const lastPost = fetcher.mock.calls.filter(([, init]) => init?.method === 'POST').at(-1)!
|
const lastPost = fetcher.mock.calls.filter(([, init]) => init?.method === 'POST').at(-1)!
|
||||||
@@ -1428,7 +1435,7 @@ describe('工作台页面交互', () => {
|
|||||||
global: { provide: { [projectContextKey as symbol]: context() }, stubs: { RouterLink: true } }
|
global: { provide: { [projectContextKey as symbol]: context() }, stubs: { RouterLink: true } }
|
||||||
})
|
})
|
||||||
const suffix = wrapper.get('.script-section .n-tabs-nav__suffix')
|
const suffix = wrapper.get('.script-section .n-tabs-nav__suffix')
|
||||||
expect(suffix.findAll('button').map(item => item.text())).toEqual(['执行记录', '导出剧本'])
|
expect(suffix.findAll('button').map(item => item.attributes('aria-label'))).toEqual(['剧本更多操作'])
|
||||||
expect(suffix.get('router-link-stub').attributes('to')).toBe(`/projects/${fixture.id}/breakdown`)
|
expect(suffix.get('router-link-stub').attributes('to')).toBe(`/projects/${fixture.id}/breakdown`)
|
||||||
const reader = wrapper.get<HTMLElement>('.reader-scroll .n-scrollbar-container').element
|
const reader = wrapper.get<HTMLElement>('.reader-scroll .n-scrollbar-container').element
|
||||||
reader.scrollTop = 120
|
reader.scrollTop = 120
|
||||||
@@ -1445,12 +1452,10 @@ describe('工作台页面交互', () => {
|
|||||||
expect(wrapper.get('.episode-reader').isVisible()).toBe(true)
|
expect(wrapper.get('.episode-reader').isVisible()).toBe(true)
|
||||||
expect(wrapper.get('.reader-scroll .n-scrollbar-container').element).toBe(reader)
|
expect(wrapper.get('.reader-scroll .n-scrollbar-container').element).toBe(reader)
|
||||||
expect(reader.scrollTop).toBe(120)
|
expect(reader.scrollTop).toBe(120)
|
||||||
button('执行记录').click()
|
await selectMenu('剧本更多操作', '执行记录')
|
||||||
await nextTick()
|
|
||||||
expect(wrapper.find('.history-panel').exists()).toBe(true)
|
expect(wrapper.find('.history-panel').exists()).toBe(true)
|
||||||
expect(wrapper.get('.content-with-history').classes()).not.toContain('history-hidden')
|
expect(wrapper.get('.content-with-history').classes()).not.toContain('history-hidden')
|
||||||
button('收起执行记录').click()
|
await selectMenu('剧本更多操作', '收起执行记录')
|
||||||
await nextTick()
|
|
||||||
expect(wrapper.find('.history-panel').exists()).toBe(false)
|
expect(wrapper.find('.history-panel').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -11,19 +11,18 @@ afterEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('后端 API 契约', () => {
|
describe('后端 API 契约', () => {
|
||||||
it('图库读取与单图、批量和切换主图使用各自接口,默认显式 Seedream', async () => {
|
it('图库读取与单图、批量和切换主图使用各自接口,模型由后端统一配置', async () => {
|
||||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () => new Response('{"data":[]}'))
|
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () => new Response('{"data":[]}'))
|
||||||
vi.stubGlobal('fetch', fetcher)
|
vi.stubGlobal('fetch', fetcher)
|
||||||
await subjectImagesApi.listForms('a/b')
|
await subjectImagesApi.listForms('a/b')
|
||||||
await subjectImagesApi.listImages('form/id')
|
await subjectImagesApi.listImages('form/id')
|
||||||
await subjectImagesApi.generate('form/id', {
|
await subjectImagesApi.generate('form/id', {
|
||||||
provider: 'seedream',
|
|
||||||
setPrimary: false,
|
setPrimary: false,
|
||||||
width: 2048,
|
width: 2048,
|
||||||
height: 2048,
|
height: 2048,
|
||||||
prompt: '本次提示词'
|
prompt: '本次提示词'
|
||||||
})
|
})
|
||||||
await subjectImagesApi.generateProject('a/b', { provider: 'seedream', concurrency: 2, force: false })
|
await subjectImagesApi.generateProject('a/b', { concurrency: 2, force: false })
|
||||||
await subjectImagesApi.setPrimary('form/id', 'image/id')
|
await subjectImagesApi.setPrimary('form/id', 'image/id')
|
||||||
expect(fetcher.mock.calls.map(([url, init]) => [url, init?.method])).toEqual([
|
expect(fetcher.mock.calls.map(([url, init]) => [url, init?.method])).toEqual([
|
||||||
['/api/projects/a%2Fb/subject-forms', 'GET'],
|
['/api/projects/a%2Fb/subject-forms', 'GET'],
|
||||||
@@ -33,14 +32,12 @@ describe('后端 API 契约', () => {
|
|||||||
['/api/subject-forms/form%2Fid/images/image%2Fid/primary', 'PUT']
|
['/api/subject-forms/form%2Fid/images/image%2Fid/primary', 'PUT']
|
||||||
])
|
])
|
||||||
expect(JSON.parse(fetcher.mock.calls[2]![1]!.body as string)).toEqual({
|
expect(JSON.parse(fetcher.mock.calls[2]![1]!.body as string)).toEqual({
|
||||||
provider: 'seedream',
|
|
||||||
setPrimary: false,
|
setPrimary: false,
|
||||||
width: 2048,
|
width: 2048,
|
||||||
height: 2048,
|
height: 2048,
|
||||||
prompt: '本次提示词'
|
prompt: '本次提示词'
|
||||||
})
|
})
|
||||||
expect(JSON.parse(fetcher.mock.calls[3]![1]!.body as string)).toEqual({
|
expect(JSON.parse(fetcher.mock.calls[3]![1]!.body as string)).toEqual({
|
||||||
provider: 'seedream',
|
|
||||||
concurrency: 2,
|
concurrency: 2,
|
||||||
force: false
|
force: false
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,22 @@
|
|||||||
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||||
import { NSelect } from 'naive-ui'
|
import { NSelect } from 'naive-ui'
|
||||||
|
|
||||||
|
/** 通过真实菜单触发器访问低频功能,不直接调用组件内部方法绕过可见性。 */
|
||||||
|
export async function selectMenu(label: string, option: string) {
|
||||||
|
const trigger = [...document.querySelectorAll<HTMLButtonElement>('button')].find(
|
||||||
|
item => item.getAttribute('aria-label') === label
|
||||||
|
)
|
||||||
|
if (!trigger) throw new Error('找不到菜单:' + label)
|
||||||
|
trigger.click()
|
||||||
|
await flushPromises()
|
||||||
|
const item = [...document.querySelectorAll<HTMLElement>('.n-dropdown-option-body')].find(
|
||||||
|
candidate => candidate.textContent?.trim() === option
|
||||||
|
)
|
||||||
|
if (!item) throw new Error('找不到菜单项:' + option)
|
||||||
|
item.click()
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
/** 展开真实折叠面板后再测试内部操作,避免对不可见控件进行断言。 */
|
/** 展开真实折叠面板后再测试内部操作,避免对不可见控件进行断言。 */
|
||||||
export async function expandSections() {
|
export async function expandSections() {
|
||||||
const tools = document.querySelector<HTMLElement>('[data-workspace-tools][aria-expanded="false"]')
|
const tools = document.querySelector<HTMLElement>('[data-workspace-tools][aria-expanded="false"]')
|
||||||
|
|||||||
Reference in New Issue
Block a user