feat: 补齐前端主流程并收紧工作台布局

统一工作台紧凑头部与形态图库间距,修复主体身份布局,并接入正式严格视频质量流水线。
This commit is contained in:
GouJ
2026-09-08 09:25:14 +08:00
committed by GitHub
parent 0f8b2074e4
commit 4edb0ff6cc
25 changed files with 355 additions and 233 deletions
+10 -11
View File
@@ -2,10 +2,11 @@
短剧 Agent 的前端工作台。支持剧本创作、拆解、视觉风格、角色选角、主体形态、分镜设计、镜头首帧与视频生产,使用真实后端 API,不包含演示数据或浏览器端模型调用。 短剧 Agent 的前端工作台。支持剧本创作、拆解、视觉风格、角色选角、主体形态、分镜设计、镜头首帧与视频生产,使用真实后端 API,不包含演示数据或浏览器端模型调用。
## 最新同步(2026-09-04 ## 最新同步(2026-09-07
- 对齐后端 `dev@05bb9e8b7e0a0e85303b16eb3a2ac634e01dd7eb`:图片/视频模型由后端统一配置,前端不再发送固定 Provider。 - 对齐后端 `dev@697cf864e1e8d6f66e3ad7beeebdfd0ae0d6c16c`:图片/视频模型由后端统一配置,前端不再发送固定 Provider。
- 新增首帧视觉校验、有限自动修复、当前集小批量质量生成、视频抽样帧质量检查、视频修复候选与复检晋升、只读模型能力查询。默认不调用模型,付费操作需确认,结果可定位和导出 - 视频主入口使用严格质量流水线:候选生成完成后自动校验、有限修复,通过后才晋升主视频;首帧质量生成和手工抽样检查仍保留
- 分镜只使用正式项目级生成接口,不把 `generate-test``start-test``stream-test` 暴露为生产入口。
- 保留主要布局和业务能力,导出/执行记录/提示词等次要入口收进菜单,长介绍和高级规则按需展开;关键错误、过期、未保存与费用提示继续直显。 - 保留主要布局和业务能力,导出/执行记录/提示词等次要入口收进菜单,长介绍和高级规则按需展开;关键错误、过期、未保存与费用提示继续直显。
- 全部业务抽屉统一挂载到 `body`,侧栏及遮罩覆盖完整视口,不受 `main/content` 裁切;保持原有宽度、正文内部滚动和关闭后配置草稿。 - 全部业务抽屉统一挂载到 `body`,侧栏及遮罩覆盖完整视口,不受 `main/content` 裁切;保持原有宽度、正文内部滚动和关闭后配置草稿。
@@ -153,10 +154,8 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
| POST | `/projects/:id/breakdown/resume-shots` | 复用已成功剧集镜头,生成缺失剧集 | | POST | `/projects/:id/breakdown/resume-shots` | 复用已成功剧集镜头,生成缺失剧集 |
| POST | `/projects/:id/breakdown/resume-storyboard` | 修复已有镜头的 SubjectRef / Form,再持久化 | | POST | `/projects/:id/breakdown/resume-storyboard` | 修复已有镜头的 SubjectRef / Form,再持久化 |
| GET | `/projects/:id/storyboard-directions?episodeNo=N` | 正式 Shot ID、导演设计、单集覆盖率 | | GET | `/projects/:id/storyboard-directions?episodeNo=N` | 正式 Shot ID、导演设计、单集覆盖率 |
| POST | `/projects/:id/storyboard-directions/generate-test` | 单集导演设计;明确发送 `persist: true` |
| POST | `/projects/:id/storyboard-directions/generate` | 项目批量;`concurrency``force` | | POST | `/projects/:id/storyboard-directions/generate` | 项目批量;`concurrency``force` |
| GET | `/projects/:id/storyboard-visual-states?episodeNo=N` | 已持久化的环境、主体状态、单集覆盖率 | | GET | `/projects/:id/storyboard-visual-states?episodeNo=N` | 已持久化的环境、主体状态、单集覆盖率 |
| POST | `/projects/:id/storyboard-visual-states/generate-test` | 单集即时状态;`persist: true``maxRepairAttempts` |
| POST | `/projects/:id/storyboard-visual-states/generate` | 项目批量;增加 `maxRepairAttempts` | | POST | `/projects/:id/storyboard-visual-states/generate` | 项目批量;增加 `maxRepairAttempts` |
| GET | `/storyboard-shots/:shotId/references` | 主体形态参考图、缺失原因 | | GET | `/storyboard-shots/:shotId/references` | 主体形态参考图、缺失原因 |
| GET | `/storyboard-shots/:shotId/generation-spec` | 后端确定性编译结果,不调用模型 | | GET | `/storyboard-shots/:shotId/generation-spec` | 后端确定性编译结果,不调用模型 |
@@ -188,10 +187,11 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
| GET / POST | `/projects/:id/keyframes/readiness``/keyframes/generate` | 首帧就绪检查与批量 后端图片模型 生图 | | 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` | 视频前置检查与批量 后端视频模型 任务 | | GET / POST | `/projects/:id/videos/readiness``/videos/generate-quality` | 视频前置检查与批量严格质量任务 |
| 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 | `/storyboard-shots/:shotId/videos` | 单镜头视频历史 |
| POST | `/storyboard-shots/:shotId/videos/generate-quality` | 单镜头严格质量候选;后台自动校验与有限修复 |
| PUT | `/storyboard-shots/:shotId/videos/:videoId/primary` | 切换主视频 | | PUT | `/storyboard-shots/:shotId/videos/:videoId/primary` | 切换主视频 |
| POST | `/projects/:id/production/start` | 高级串联入口;重复预检、费用确认,仅传两种 Provider,不生成首帧、不等待成片 | | POST | `/projects/:id/production/start` | 高级串联入口;重复预检、费用确认,仅传两种 Provider,不生成首帧、不等待成片 |
@@ -213,9 +213,8 @@ API 层也提供 `state` 与 `breakdown/latest` 方法。页面通过共享 chec
1. 入口为 `/projects/:projectId/storyboard`。先完成 Breakdown 的主体、形态、镜头与持久化,再生成 Direction,最后生成 VisualState。 1. 入口为 `/projects/:projectId/storyboard`。先完成 Breakdown 的主体、形态、镜头与持久化,再生成 Direction,最后生成 VisualState。
2. 当前 Direction 服务读取**最新** Breakdown 的 `state.breakdownResult.storyboardEpisodeShots`VisualState 读取顶层 `state.storyboardEpisodeShots``subjectCandidates``subjectForms`。页面不使用历史可恢复快照代替这些生成前置条件。历史快照只补充镜头标题和剧情描述。 2. 当前 Direction 服务读取**最新** Breakdown 的 `state.breakdownResult.storyboardEpisodeShots`VisualState 读取顶层 `state.storyboardEpisodeShots``subjectCandidates``subjectForms`。页面不使用历史可恢复快照代替这些生成前置条件。历史快照只补充镜头标题和剧情描述。
3. 镜头 ID 来自正式数据库 GET,按 `shotId` 关联 Direction 和 VisualState。`shotNo` 仅在 Beat 内唯一,不能用它拼装接口 ID。VisualState 查询响应的环境字段是扁平结构,与单集生成返回的领域结构不同。 3. 镜头 ID 来自正式数据库 GET,按 `shotId` 关联 Direction 和 VisualState。`shotNo` 仅在 Beat 内唯一,不能用它拼装接口 ID。VisualState 查询响应的环境字段是扁平结构,与单集生成返回的领域结构不同。
4. 单集入口虽然名为 `generate-test`,也是后端目前唯一的单集生成接口。页面发送 `persist: true`,通过校验才保存;**单集始终重生成**,不受批量的覆盖开关影响 4. 页面只使用正式项目批量接口;`generate-test` 暂不作为前端入口。批量默认 `force: false`,跳过完整剧集;`force: true` 才覆盖全部。VisualState 可设置非负整数修复次数,`0` 表示不修复
5. 批量默认 `force: false`,跳过完整剧集。重新点击“补齐”用于重试未完成项,没有虚构独立 retry 接口。`force: true` 才覆盖全部。VisualState 可设置非负整数修复次数,`0` 表示不修复 5. VisualState 批量接口由后端逐集检查;缺少完整 Direction 等前置条件的集数会单独失败。HTTP 200 也可能包含校验失败或部分失败,页面展示逐集诊断和修复次数,不视为全成功
6. VisualState 单集生成需要该集所有正式镜头都有 Direction;批量接口由后端逐集检查,缺少前置条件的集数会单独失败。HTTP 200 也可能包含校验失败或部分失败,页面展示逐集诊断和修复次数,不视为全成功。
7. 上游重生成**不会自动使下游旧数据失效**。覆盖 Direction 后请重生成 VisualState,修改设计/状态/参考图后按需覆盖 Prompt。覆盖率只说明字段已保存,不能证明下游与新版本一致。 7. 上游重生成**不会自动使下游旧数据失效**。覆盖 Direction 后请重生成 VisualState,修改设计/状态/参考图后按需覆盖 Prompt。覆盖率只说明字段已保存,不能证明下游与新版本一致。
8. 分镜页用刷新按钮查询当前剧集的两类正式结果;这不是全项目实时进度。后端没有独立 Storyboard running / executionId 查询。回执只保留当前浏览器会话,切换页面不丢失,刷新浏览器会丢失。 8. 分镜页用刷新按钮查询当前剧集的两类正式结果;这不是全项目实时进度。后端没有独立 Storyboard running / executionId 查询。回执只保留当前浏览器会话,切换页面不丢失,刷新浏览器会丢失。
9. 参考图和 GenerationSpec 按需 GET,切换镜头或设计变化时取消旧查询。GenerationSpec 需要有效的 Direction、VisualState 和形态关联,不生成图片或视频。 9. 参考图和 GenerationSpec 按需 GET,切换镜头或设计变化时取消旧查询。GenerationSpec 需要有效的 Direction、VisualState 和形态关联,不生成图片或视频。
@@ -270,7 +269,7 @@ API 层也提供 `state` 与 `breakdown/latest` 方法。页面通过共享 chec
- 精简的进度接口及按 graph 分页的 checkpoint - 精简的进度接口及按 graph 分页的 checkpoint
- 将 Breakdown 改为 202 异步任务提交,再以轮询或 SSE 读取进度。 - 将 Breakdown 改为 202 异步任务提交,再以轮询或 SSE 读取进度。
前端未模拟这些能力,不调用 `stream-test` 来伪造进度。Storyboard 的单集 `generate-test` 仅在用户明确确认生成后调用 前端未模拟这些能力,不调用 `stream-test``start-test`Storyboard `generate-test` 来伪造生产功能
## 部署 ## 部署
+64
View File
@@ -0,0 +1,64 @@
# Design QA
- Source visual truth:
- `/workspace/scratch/6e52996114b8/upload/83e0987d-5b6e-46b1-b39b-0674bf374f43.png`
- `/workspace/scratch/6e52996114b8/upload/60e23391-685b-4538-95a2-46f6334f5f6d.png`
- `/workspace/scratch/6e52996114b8/upload/430bec04-93fd-429f-a820-1edefdafc792.png`
- Source pixel dimensions: `2048 × 819`, `447 × 203`, `238 × 198`.
- Intended implementation viewport: desktop dark theme; the primary reference is `2048 × 819`.
- Implementation screenshot: unavailable.
- CSS size and density normalization: unavailable because the implementation could not be opened in the cloud browser.
- State: 主体身份详情页已有身份母版;形态图库默认筛选和布局切换状态。
## Full-view comparison evidence
The source images were opened and inspected. The first source shows the generic `.asset-image` width expanding the identity-anchor summary image across the detail panel and squeezing its explanatory copy into a narrow column on the far right. The second source shows the gallery summary/actions and filter panel consuming too much vertical space. The third source establishes the compact dark workspace density used by the surrounding pages.
The implementation passed lint, formatting, TypeScript, component/unit tests, and the production build. A local Sites preview started successfully at the required port, but the cloud browser rejected `terminal.local` with `net::ERR_BLOCKED_BY_CLIENT` on both the existing tab and one fresh tab. Therefore no browser-rendered implementation evidence is available.
## Focused region comparison evidence
- Identity anchor: `.identity-anchor > .asset-image` now has a component-local `80 × 80` square constraint with higher selector specificity than the generic AssetImage width rule. The adjacent copy has `min-width: 0`.
- Identity detail container: the detail scrollbar, its container/content, and the panel now explicitly use `width: 100%`, `min-width: 0`, and `max-width: 100%` to contain long content.
- Gallery density: the summary/actions moved into the `WorkspacePage` header and the filter region's vertical padding changed from `14px` to `5px`.
- Page headers: every `WorkspacePage` caller now uses compact mode.
These are code-level checks only and do not replace a rendered focused-region comparison.
## Required fidelity surfaces
- Fonts and typography: existing project font stack, sizes, weights, and wrapping were preserved; browser comparison is blocked.
- Spacing and layout rhythm: the requested header compaction and `5px` gallery filter padding are implemented; browser measurement is blocked.
- Colors and visual tokens: existing dark/light CSS variables were preserved; browser comparison is blocked.
- Image quality and asset fidelity: no source assets were replaced or generated; existing backend images remain in use. Browser crop verification is blocked.
- Copy and content: existing product copy is retained except where video generation now describes the formal strict-quality workflow.
## Findings
- [P1] Browser-rendered comparison unavailable
- Location: all three target states.
- Evidence: the preview process is healthy, but the required cloud browser returns `net::ERR_BLOCKED_BY_CLIENT` for `http://terminal.local:4173/`.
- Impact: exact visual fidelity, responsive behavior, interactions, and console state cannot be certified from the browser.
- Fix: open the same preview in an available Work Mode cloud browser, reproduce the target data/theme, capture the three states, and rerun this QA.
## Primary interactions tested
Automated component tests cover filters, layout switching, workspace drawers, project-level formal storyboard actions, video quality API parameters, disabled states, receipts, and navigation. Browser interactions were not available.
## Console errors checked
Not checked because the page could not be opened in the cloud browser.
## Comparison history
- Iteration 1: identified the P1 implementation-capture blocker after the local preview started. No visual iteration could be completed.
## Implementation checklist
- [x] Constrain the identity anchor thumbnail and detail width.
- [x] Move gallery summary/actions into the compact header.
- [x] Change gallery filter vertical padding from 14px to 5px.
- [x] Apply compact mode to all workspace pages.
- [ ] Capture and compare the rendered identity and gallery states in the cloud browser.
final result: blocked
+5 -5
View File
@@ -1,6 +1,6 @@
# 后端功能覆盖说明 # 后端功能覆盖说明
核对日期:2026-09-03。后端:`qianlanse/short-drama-agent``dev@05bb9e8b7e0a0e85303b16eb3a2ac634e01dd7eb`。以 API 路由和实际 service/workflow 实现为依据,不只根据接口名称推断功能。本轮不修改后端。 核对日期:2026-09-07。后端:`qianlanse/short-drama-agent``dev@697cf864e1e8d6f66e3ad7beeebdfd0ae0d6c16c`。以 API 路由和实际 service/workflow 实现为依据,不只根据接口名称推断功能。本轮不修改后端。
## 页面与接口能力 ## 页面与接口能力
@@ -18,16 +18,16 @@
| 身份参考图 | 主体身份图库 | `identity/images`、图片 `anchor` | 保留完整预览、历史、辅助视角与母版切换;修正道具继承说明 | | 身份参考图 | 主体身份图库 | `identity/images`、图片 `anchor` | 保留完整预览、历史、辅助视角与母版切换;修正道具继承说明 |
| 正式形态提示词 | 形态卡片 → 提示词与生成;批量工具 | POST `subject-forms/:id/generation-prompt`、项目 `subject-forms/generation-prompts` | 新增单个生成/重生成、原始素材与正式文本对照、导出、批量补齐/覆盖及失败回执 | | 正式形态提示词 | 形态卡片 → 提示词与生成;批量工具 | POST `subject-forms/:id/generation-prompt`、项目 `subject-forms/generation-prompts` | 新增单个生成/重生成、原始素材与正式文本对照、导出、批量补齐/覆盖及失败回执 |
| 形态图片 | 形态图片及图库 | 项目 `subject-forms`、形态 `images`、主图 PUT、项目 `subject-images/generate` | 增加本批数量上限;支持后端自动补正式 Prompt;人物/场景/道具已锁定母版继承;过期刷新只生成候选 | | 形态图片 | 形态图片及图库 | 项目 `subject-forms`、形态 `images`、主图 PUT、项目 `subject-images/generate` | 增加本批数量上限;支持后端自动补正式 Prompt;人物/场景/道具已锁定母版继承;过期刷新只生成候选 |
| 导演设计与即时状态 | 分镜设计 | directionsvisual-states 查询、单集 generate-test、项目 generate | 保留单集持久化(`persist: true`)、项目批量、覆盖、校验修复次数逐集回执 | | 导演设计与即时状态 | 分镜设计 | directionsvisual-states 查询、项目 `generate` | 保留正式项目批量、覆盖、校验修复次数逐集回执;不暴露 `generate-test` |
| 参考图与规格 | 分镜参考图/生产单镜头 | `references``generation-spec``keyframe-spec``video-generation-spec` | 保留按需只读检查与缺失原因,查询不调用生成模型 | | 参考图与规格 | 分镜参考图/生产单镜头 | `references``generation-spec``keyframe-spec``video-generation-spec` | 保留按需只读检查与缺失原因,查询不调用生成模型 |
| 视频提示词 | 分镜设计/镜头生产 | 单镜头 `video-prompt`、项目 `video-prompts/readiness``generate` | 保留单镜头/项目生成,重写需确认 | | 视频提示词 | 分镜设计/镜头生产 | 单镜头 `video-prompt`、项目 `video-prompts/readiness``generate` | 保留单镜头/项目生成,重写需确认 |
| 镜头首帧 | 镜头生产 | 项目 `keyframes/readiness``generate`、单镜头 keyframekeyframes、主图 PUT | 新增批量 `limit` 与成对尺寸;保留当前集/过期项/覆盖范围;显示因上限未执行数 | | 镜头首帧 | 镜头生产 | 项目 `keyframes/readiness``generate`、单镜头 keyframekeyframes、主图 PUT | 新增批量 `limit` 与成对尺寸;保留当前集/过期项/覆盖范围;显示因上限未执行数 |
| 视频任务与主视频 | 镜头生产 | 项目 videos readinessgeneratestatusretry-failed,逐镜 videos,单任务 status,主视频 PUT | 保留任务提交、轮询、播放失败重试与主视频切换;不把 HTTP 完成当成片完成 | | 视频任务与主视频 | 镜头生产 | 项目 videos readinessgenerate-qualitystatusretry-failed,逐镜 generate-qualityvideos,单任务 status,主视频 PUT | 主入口改为严格质量候选:后台自动校验、有限修复,通过后晋升;保留状态刷新、播放失败重试 |
| 高级串联生产 | 镜头生产批量面板底部(默认折叠) | 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` 读取。 已接入:首帧 `validate``repair`,项目 `keyframes/generate-quality`,视频 `validate``repair`以及单镜头和项目级 `videos/generate-quality`。严格视频候选由后端 Poller 自动执行 Validator,失败时按上限创建 Repair Candidate,只有 PASS 才晋升 Primary;前端识别 `rawJson.qualityCandidate`,禁止未通过候选绕过校验手动设为主视频。生成请求不再传固定 Provider。质量接口的详细参数、默认一镜范围、费用确认、主图晋升、三点视频抽样和会话回执限制见 [前端简化与质量功能同步](frontend-simplification.md)。首帧校验历史尚无 GET,不虚构读取接口;视频已有结果从 `rawJson.videoValidation` 读取。
## 高级串联生产边界 ## 高级串联生产边界
@@ -63,7 +63,7 @@
| `timeline` | 新诊断从 `timeline/grouped` 展平并按 index 排序,保持同一份数据 | | `timeline` | 新诊断从 `timeline/grouped` 展平并按 index 排序,保持同一份数据 |
| 图片/首帧/视频的 GET primary | 从后端完整列表的 primary 标志选取,无需再次发相同内容请求 | | 图片/首帧/视频的 GET primary | 从后端完整列表的 primary 标志选取,无需再次发相同内容请求 |
| POST `/projects/:id/resume` | 实际只返回恢复信息,并不启动恢复工作流;不显示虚假的“继续执行”按钮 | | POST `/projects/:id/resume` | 实际只返回恢复信息,并不启动恢复工作流;不显示虚假的“继续执行”按钮 |
| `projects/stream-test``breakdown/start-test` | 测试专用,不暴露生产入口;Storyboard generate-test 是现有正式单集生成入口,仍传 `persist: true` | | `projects/stream-test``breakdown/start-test`Storyboard `generate-test` | 测试或试运行路由,不暴露生产入口;分镜只使用正式项目级 `generate` |
| 手工保存形态 generationPrompt、取消运行任务、删除项目、合并最终视频 | 后端没有对应正式路由,本轮不虚构按钮、成功提示或客户端模拟实现 | | 手工保存形态 generationPrompt、取消运行任务、删除项目、合并最终视频 | 后端没有对应正式路由,本轮不虚构按钮、成功提示或客户端模拟实现 |
## 回归验证 ## 回归验证
+5 -1
View File
@@ -35,13 +35,17 @@
| 已完成首帧 → 质量检查 → 仅校验 | POST `/storyboard-shots/:shotId/keyframes/:keyframeId/validate` | 调用视觉模型,不重生成、不切换主首帧 | | 已完成首帧 → 质量检查 → 仅校验 | POST `/storyboard-shots/:shotId/keyframes/:keyframeId/validate` | 调用视觉模型,不重生成、不切换主首帧 |
| 首帧 → 质量检查 → 校验并修复 | POST 同路径 `/repair` | 有限重生成;新候选通过才晋升主首帧;原图直接通过不会自动晋升 | | 首帧 → 质量检查 → 校验并修复 | POST 同路径 `/repair` | 有限重生成;新候选通过才晋升主首帧;原图直接通过不会自动晋升 |
| 生产工具栏 → 首帧质量生成 | POST `/projects/:projectId/keyframes/generate-quality` | 当前集、默认最多一镜、并发 1、最多修复 1 次;每镜候选通过后才设主图 | | 生产工具栏 → 首帧质量生成 | POST `/projects/:projectId/keyframes/generate-quality` | 当前集、默认最多一镜、并发 1、最多修复 1 次;每镜候选通过后才设主图 |
| 单镜头 → 提交视频质量任务 | POST `/storyboard-shots/:shotId/videos/generate-quality` | 创建严格质量候选;生成完成后由后端自动校验,失败时最多自动修复 2 次,通过后才晋升主视频 |
| 批量生产 → 视频成片 | POST `/projects/:projectId/videos/generate-quality` | 显式限制本批镜头数和每镜修复次数;只提交 ready 镜头,后续 ValidatorRepair 由 Poller 推进 |
| 已完成视频 → 质量检查 | POST `/storyboard-shots/:shotId/videos/:videoId/validate` | 三点抽帧视觉检查,不检测音频/口型/完整连续运动;普通候选不切换主视频,修复候选通过后自动晋升 | | 已完成视频 → 质量检查 | POST `/storyboard-shots/:shotId/videos/:videoId/validate` | 三点抽帧视觉检查,不检测音频/口型/完整连续运动;普通候选不切换主视频,修复候选通过后自动晋升 |
批量质量配置与普通批量面板独立。后端非 force 先根据全项目是否有过期项决定筛选,再筛当前集;因此其他集存在过期项而当前集无过期项时,本集不会先补缺失首帧。前端明确提示切换剧集,不悄悄扩大范围。 批量质量配置与普通批量面板独立。后端非 force 先根据全项目是否有过期项决定筛选,再筛当前集;因此其他集存在过期项而当前集无过期项时,本集不会先补缺失首帧。前端明确提示切换剧集,不悄悄扩大范围。
允许文字按行输入并清理空白/重复;未填写不自行授权文字。宽高同时留空或填写正整数,修复次数可为 0。确认区显示本次最多生图/视觉校验调用次数,修改参数或目标后撤销费用确认。 允许文字按行输入并清理空白/重复;未填写不自行授权文字。宽高同时留空或填写正整数,修复次数可为 0。确认区显示本次最多生图/视觉校验调用次数,修改参数或目标后撤销费用确认。
### 视频修复与复检晋升(本轮后端追加更新) ### 视频自动质量链与手工复检
生产页的单镜和批量视频主入口使用 `generate-quality`。HTTP 回执只表示初始 Candidate 已创建;后台 Poller 会在 Provider 完成后自动执行 Validator,失败时按 `maxRepairAttempts` 创建下一代 Repair Candidate,只有通过校验的版本才会晋升 Primary。前端读取 `rawJson.qualityCandidate``qualityPipeline` 标记质量候选,并禁止未通过候选使用普通“设为主视频”绕过校验。
视频质量面板新增“生成修复候选”,使用 POST `/storyboard-shots/:shotId/videos/:videoId/repair`,只接受 `maxRepairAttempts`(默认 2,正整数)。该参数是修复链的上限,不是本次自动生成次数。本次只提交一个视频候选,不自动等待、复检或循环重试;修复约束和允许文字沿用已保存的失败校验。 视频质量面板新增“生成修复候选”,使用 POST `/storyboard-shots/:shotId/videos/:videoId/repair`,只接受 `maxRepairAttempts`(默认 2,正整数)。该参数是修复链的上限,不是本次自动生成次数。本次只提交一个视频候选,不自动等待、复检或循环重试;修复约束和允许文字沿用已保存的失败校验。
@@ -69,7 +69,7 @@ function exportScript() {
</script> </script>
<template> <template>
<WorkspacePage split class="script-workspace-page" <WorkspacePage split compact class="script-workspace-page"
><template #header ><template #header
><div class="stage-strip"> ><div class="stage-strip">
<!-- 以下是创作阶段进度 --> <!-- 以下是创作阶段进度 -->
+34 -8
View File
@@ -50,7 +50,10 @@ const {
keyframeLimit, keyframeLimit,
keyframeWidth, keyframeWidth,
keyframeHeight, keyframeHeight,
videoLimit,
videoRepairAttempts,
keyframeValid, keyframeValid,
videoQualityValid,
batchValid, batchValid,
blocked, blocked,
run run
@@ -100,15 +103,15 @@ const stages = computed(() => {
key: 'videos' as const, key: 'videos' as const,
code: '03', code: '03',
title: '视频成片', title: '视频成片',
description: '以主首帧作为第一帧,连同提示词和主体参考图提交视频模型。', description: '生成质量候选,完成后自动校验;失败时有限修复,通过后才晋升主视频。',
icon: Film, icon: Film,
data: data?.videos, data: data?.videos,
done: data?.videoStatus.completed ?? 0, done: data?.videos.skipped ?? 0,
action: force.value action: force.value
? '为全部就绪镜头新增视频' ? '新增项目质量视频候选'
: staleVideos.value > 0 : staleVideos.value > 0
? `更新 ${staleVideos.value} 个过期主视频` ? `更新 ${staleVideos.value} 个过期主视频`
: '提交就绪视频任务' : '提交视频质量任务'
} }
] ]
}) })
@@ -237,8 +240,7 @@ watch(
</div> </div>
<DetailDisclosure title="处理范围与覆盖规则" class="mt-3" <DetailDisclosure title="处理范围与覆盖规则" class="mt-3"
><p class="text-xs leading-6 text-muted"> ><p class="text-xs leading-6 text-muted">
提示词与视频批量操作面向全项目首帧默认补当前剧集存在过期主首帧时优先更新全项目过期项覆盖模式处理全项目过期首帧成功后会自动接替旧主图其余新增候选图片模型 提示词与视频就绪检查面向全项目视频实际提交受本批镜头上限控制并使用严格质量流水线首帧默认补当前剧集存在过期主首帧时优先更新全项目过期项覆盖模式处理全项目过期首帧成功后会自动接替旧主图其余新增候选图片模型视频模型与视觉校验均可能产生费用
与视频模型均可能产生费用
</p></DetailDisclosure </p></DetailDisclosure
> >
</section> </section>
@@ -375,14 +377,15 @@ watch(
blocked || blocked ||
!batchValid || !batchValid ||
(stage.key === 'keyframes' && !keyframeValid) || (stage.key === 'keyframes' && !keyframeValid) ||
(stage.key === 'videos' && !videoQualityValid) ||
!(stage.data?.ready ?? 0) !(stage.data?.ready ?? 0)
" "
acknowledgement acknowledgement
:description=" :description="
stage.key === 'videos' stage.key === 'videos'
? staleVideos && !force ? staleVideos && !force
? '当前存在过期主视频,非覆盖模式只会为这些镜头创建新的视频模型任务;新视频完成后会自动接替过期主视频。' ? `当前存在过期主视频,非覆盖模式只会为其中至多 ${videoLimit || '—'} 个镜头启动严格质量流水线;候选自动校验并有限修复,通过后才接替旧主视频。`
: '只为当前就绪镜头创建视频模型异步任务创建成功不代表视频已经完成,任务会在后台继续运行。' : `只为至多 ${videoLimit || '—'} 个就绪镜头启动严格质量流水线。任务创建后由后端自动校验,最多修复 ${videoRepairAttempts || '—'} 次,通过后才晋升主视频。`
: stage.key === 'keyframes' : stage.key === 'keyframes'
? `处理缺失或过期主首帧。过期首帧成功后自动接替旧主图(覆盖模式也如此),其余已有主图只新增候选。本批上限:${keyframeLimit || '不限制'};尺寸:${keyframeWidth && keyframeHeight ? `${keyframeWidth} × ${keyframeHeight}` : '后端默认'}。` ? `处理缺失或过期主首帧。过期首帧成功后自动接替旧主图(覆盖模式也如此),其余已有主图只新增候选。本批上限:${keyframeLimit || '不限制'};尺寸:${keyframeWidth && keyframeHeight ? `${keyframeWidth} × ${keyframeHeight}` : '后端默认'}。`
: '只处理通过视频提示词就绪检查的镜头;覆盖模式会重写已有提示词。' : '只处理通过视频提示词就绪检查的镜头;覆盖模式会重写已有提示词。'
@@ -424,6 +427,29 @@ watch(
上限须为正整数宽高须同时留空或填写正整数 上限须为正整数宽高须同时留空或填写正整数
</p> </p>
</div> </div>
<div v-if="stage.key === 'videos'" class="mt-4 grid gap-3">
<label
><span class="field-label">本批镜头上限</span
><NInputNumber
v-model:value="videoLimit"
:min="1"
:precision="0"
:disabled="operation.pending"
:input-props="{ 'aria-label': '视频质量任务镜头上限' }"
/></label>
<label
><span class="field-label">每镜最多自动修复</span
><NInputNumber
v-model:value="videoRepairAttempts"
:min="1"
:precision="0"
:disabled="operation.pending"
:input-props="{ 'aria-label': '视频自动修复次数' }"
/></label>
<p v-if="!videoQualityValid" class="text-xs text-danger">
本批上限和自动修复次数都必须是正整数
</p>
</div>
</article> </article>
</div> </div>
<div v-if="query.data.value" class="panel mt-4 p-5"> <div v-if="query.data.value" class="panel mt-4 p-5">
+11 -7
View File
@@ -3,7 +3,7 @@ import type { PromptBatchResult, StoryboardBatchInput } from '../storyboard/type
import type { import type {
GenerateKeyframeInput, GenerateKeyframeInput,
GenerateKeyframesInput, GenerateKeyframesInput,
GenerateVideosInput, GenerateQualityVideosInput,
KeyframeBatchResult, KeyframeBatchResult,
KeyframeReadiness, KeyframeReadiness,
KeyframeSpec, KeyframeSpec,
@@ -13,7 +13,9 @@ import type {
RetryVideosResult, RetryVideosResult,
ShotKeyframe, ShotKeyframe,
ShotVideo, ShotVideo,
VideoBatchResult, ShotVideoQualityResult,
VideoQualityBatchResult,
VideoQualityConfig,
VideoGenerationSpec, VideoGenerationSpec,
VideoReadiness VideoReadiness
} from './types' } from './types'
@@ -54,8 +56,9 @@ export const productionApi = {
}), }),
videoReadiness: (projectId: string, force: boolean, signal?: AbortSignal) => videoReadiness: (projectId: string, force: boolean, signal?: AbortSignal) =>
request<VideoReadiness>(`${projectPath(projectId)}/videos/readiness?force=${force}`, { signal }), request<VideoReadiness>(`${projectPath(projectId)}/videos/readiness?force=${force}`, { signal }),
generateVideos: (projectId: string, input: GenerateVideosInput) => /** 启动项目级严格视频质量流水线;创建后由后端自动完成校验、有限修复与晋升。 */
request<VideoBatchResult>(`${projectPath(projectId)}/videos/generate`, { generateQualityVideos: (projectId: string, input: GenerateQualityVideosInput) =>
request<VideoQualityBatchResult>(`${projectPath(projectId)}/videos/generate-quality`, {
method: 'POST', method: 'POST',
body: input, body: input,
timeoutMs: 0 timeoutMs: 0
@@ -86,10 +89,11 @@ export const productionApi = {
request<VideoGenerationSpec>(`${shotPath(shotId)}/video-generation-spec`, { signal }), request<VideoGenerationSpec>(`${shotPath(shotId)}/video-generation-spec`, { signal }),
listVideos: (shotId: string, signal?: AbortSignal) => listVideos: (shotId: string, signal?: AbortSignal) =>
request<ShotVideo[]>(`${shotPath(shotId)}/videos`, { signal }), request<ShotVideo[]>(`${shotPath(shotId)}/videos`, { signal }),
generateVideo: (shotId: string) => /** 启动单镜头严格视频质量流水线;返回初始候选,最终主视频以后端状态为准。 */
request<ShotVideo | null>(`${shotPath(shotId)}/videos`, { generateQualityVideo: (shotId: string, input: VideoQualityConfig) =>
request<ShotVideoQualityResult>(`${shotPath(shotId)}/videos/generate-quality`, {
method: 'POST', method: 'POST',
body: {}, body: input,
timeoutMs: 0 timeoutMs: 0
}), }),
refreshVideo: (videoId: string, signal?: AbortSignal) => refreshVideo: (videoId: string, signal?: AbortSignal) =>
@@ -33,8 +33,11 @@ function exportReceipt() {
> >
</p> </p>
<p v-else-if="receipt.kind === 'videos'" class="mt-3 text-xs text-muted"> <p v-else-if="receipt.kind === 'videos'" class="mt-3 text-xs text-muted">
全项目 {{ receipt.result.total }} · 本次目标 {{ receipt.result.targetCount }} · 已创建任务 全项目 {{ receipt.result.total }} · 就绪 {{ receipt.result.readyTargetCount }} · 本次目标
{{ receipt.result.created }} · 跳过 {{ receipt.result.skipped }} · 创建失败 {{ receipt.result.failed }} {{ receipt.result.targetCount }} · 已创建质量候选 {{ receipt.result.created }} · 待后续提交
{{ receipt.result.remainingReady }} · 跳过 {{ receipt.result.skipped }} · 进行中
{{ receipt.result.inProgress }} · 阻塞 {{ receipt.result.blocked }} · 创建失败
{{ receipt.result.failed }}
</p> </p>
<p v-else class="mt-3 text-xs text-muted"> <p v-else class="mt-3 text-xs text-muted">
检测到 {{ receipt.result.totalFailed }} 个失败镜头 · 已重新提交 {{ receipt.result.retried }} · 提交失败 检测到 {{ receipt.result.totalFailed }} 个失败镜头 · 已重新提交 {{ receipt.result.retried }} · 提交失败
@@ -47,7 +50,7 @@ function exportReceipt() {
:show-icon="false" :show-icon="false"
class="mt-3" class="mt-3"
> >
视频任务已经创建但尚未代表成片完成后台会继续查询 视频模型以下方任务状态和视频资产为准 严格质量候选已经创建但尚未代表成片完成后台会继续查询视频模型执行视觉校验并在失败时有限修复只有校验通过的候选才会晋升主视频
</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">
本次存在部分失败成功提交或生成的结果不会回滚请查看逐镜错误后再重试 本次存在部分失败成功提交或生成的结果不会回滚请查看逐镜错误后再重试
@@ -27,7 +27,7 @@ import ActionMenu from '../../../components/ui/ActionMenu.vue'
import { AppDialog } from '../../../components/ui' import { AppDialog } from '../../../components/ui'
import ModelCapabilities from './ModelCapabilities.vue' import ModelCapabilities from './ModelCapabilities.vue'
import type { QualityTarget } from '../quality.types' import type { QualityTarget } from '../quality.types'
import { savedVideoValidation, videoRepairInfo } from '../quality' import { savedVideoValidation, videoQualityInfo, videoRepairInfo } from '../quality'
/** 单镜头生产面板只负责正式资产,不修改上游导演设计与即时状态。 */ /** 单镜头生产面板只负责正式资产,不修改上游导演设计与即时状态。 */
const props = defineProps<{ const props = defineProps<{
@@ -162,14 +162,17 @@ async function setPrimaryKeyframe() {
await refreshAll() await refreshAll()
} }
/** 创建单镜头视频模型视频任务;任务完成由后台轮询确认。 */ /** 创建单镜头严格质量候选;后端完成生成后自动校验、有限修复并按结果晋升。 */
async function generateVideo() { async function generateVideo() {
if (!canGenerateVideo.value) return if (!canGenerateVideo.value) return
const shotId = props.shot.shotId const shotId = props.shot.shotId
await runOperation(props.projectId, `提交镜头 ${props.shot.shotNo} 视频任务`, async () => { await runOperation(props.projectId, `提交镜头 ${props.shot.shotNo} 视频质量任务`, async () => {
const result = await productionApi.generateVideo(shotId) const result = await productionApi.generateQualityVideo(shotId, {
if (!result || result.shotId !== shotId || !isActiveVideo(result)) maxRepairAttempts: 2,
throw new Error('接口未返回有效的视频生成任务。') allowedTexts: []
})
if (!result || result.shotId !== shotId || !result.candidate || !isActiveVideo(result.candidate))
throw new Error('接口未返回有效的视频质量候选任务。')
}) })
await refreshAll() await refreshAll()
} }
@@ -190,7 +193,12 @@ 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) const video = videos.value.find(item => item.id === videoId)
if (!video || (videoRepairInfo(video.rawJson) && savedVideoValidation(video.rawJson)?.passed !== true)) return if (
!video ||
((videoRepairInfo(video.rawJson) || videoQualityInfo(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 () => {
@@ -396,6 +404,19 @@ async function inspectSpecs() {
<NTag v-if="videoRepairInfo(item.rawJson) && !item.isPrimary" size="small" :bordered="false" <NTag v-if="videoRepairInfo(item.rawJson) && !item.isPrimary" size="small" :bordered="false"
>修复候选 · 待复检</NTag >修复候选 · 待复检</NTag
> >
<NTag
v-else-if="videoQualityInfo(item.rawJson) && !item.isPrimary"
size="small"
:bordered="false"
>质量候选 ·
{{
savedVideoValidation(item.rawJson)
? savedVideoValidation(item.rawJson)?.passed
? '已通过'
: '未通过'
: '自动校验中'
}}</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
@@ -430,7 +451,7 @@ async function inspectSpecs() {
item.status === 'completed' && item.status === 'completed' &&
item.videoUrl && item.videoUrl &&
!item.isPrimary && !item.isPrimary &&
(!videoRepairInfo(item.rawJson) || (!(videoRepairInfo(item.rawJson) || videoQualityInfo(item.rawJson)) ||
savedVideoValidation(item.rawJson)?.passed === true) savedVideoValidation(item.rawJson)?.passed === true)
" "
:disabled="assetBlocked" :disabled="assetBlocked"
@@ -454,15 +475,17 @@ async function inspectSpecs() {
<p v-else class="mt-4 text-xs text-muted">尚无视频任务记录。</p> <p v-else class="mt-4 text-xs text-muted">尚无视频任务记录。</p>
<div class="mt-4 flex flex-wrap gap-3"> <div class="mt-4 flex flex-wrap gap-3">
<ConfirmAction <ConfirmAction
:label="videoStale ? '重新生成主视频' : currentVideo ? '再生成一个视频候选' : '提交本镜视频任务'" :label="
videoStale ? '重新生成质量视频' : currentVideo ? '再生成一个质量候选' : '提交本镜视频质量任务'
"
:disabled="!canGenerateVideo" :disabled="!canGenerateVideo"
acknowledgement acknowledgement
:description=" :description="
videoStale videoStale
? '使用当前视频提示词、主首帧和主体参考图重新创建视频模型任务;完成后会自动接替当前过期主视频。' ? '使用当前视频提示词、主首帧和主体参考图启动严格质量流水线;最多自动修复 2 次,通过后才接替当前过期主视频。'
: currentVideo : currentVideo
? '使用当前视频提示词、主首帧和主体参考图新增一个视频模型视频候选,不自动替换主视频。' ? '新增一个严格质量候选;后端会自动校验并最多修复 2 次,通过后才替换主视频。'
: '使用当前视频提示词、主首帧和主体参考图创建视频模型任务;任务在后台继续运行。' : '启动严格视频质量流水线;任务在后台生成、校验并有限修复,通过后才晋升主视频。'
" "
primary primary
@confirm="generateVideo" @confirm="generateVideo"
+26
View File
@@ -104,3 +104,29 @@ export function videoRepairInfo(rawJson?: string | null): { attempt: number } |
return null return null
} }
} }
/** 识别严格质量候选及其自动修复配置,防止未通过候选被手动绕过校验设为主视频。 */
export function videoQualityInfo(
rawJson?: string | null
): { allowedTexts: string[]; maxRepairAttempts: number } | null {
try {
const snapshot: unknown = JSON.parse(rawJson || '{}')
if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) return null
const record = snapshot as Record<string, unknown>
if (record.qualityCandidate !== true) return null
const pipeline = record.qualityPipeline
if (!pipeline || typeof pipeline !== 'object' || Array.isArray(pipeline)) return null
const config = pipeline as Record<string, unknown>
const maxRepairAttempts = config.maxRepairAttempts
if (typeof maxRepairAttempts !== 'number' || !Number.isSafeInteger(maxRepairAttempts) || maxRepairAttempts <= 0)
return null
return {
allowedTexts: Array.isArray(config.allowedTexts)
? config.allowedTexts.filter((item): item is string => typeof item === 'string')
: [],
maxRepairAttempts
}
} catch {
return null
}
}
+32 -8
View File
@@ -208,21 +208,45 @@ export interface KeyframeBatchResult {
failures: { shotId: string; error: string }[] failures: { shotId: string; error: string }[]
} }
/** 项目视频批量生成参数。 */ /** 严格视频质量流水线的共用参数;候选必须校验通过后才会晋升主视频。 */
export interface GenerateVideosInput { export interface VideoQualityConfig {
concurrency: number allowedTexts?: string[]
force: boolean maxRepairAttempts: number
} }
/** 视频批量任务创建回执;created 只表示已提交任务,不表示视频已完成。 */ /** 项目级严格视频质量生成参数;limit 限制本次实际提交的镜头数。 */
export interface VideoBatchResult { export interface GenerateQualityVideosInput extends VideoQualityConfig {
concurrency: number
force: boolean
limit?: number
}
/** 单镜头严格视频质量任务回执;candidate 只是异步候选,不代表已经通过校验。 */
export interface ShotVideoQualityResult {
shotId: string
qualityPipeline: Required<VideoQualityConfig>
candidate: ShotVideo | null
}
/** 项目级严格视频质量任务回执;后续校验与修复由后端 Poller 自动推进。 */
export interface VideoQualityBatchResult {
projectId: string
concurrency: number
limit: number | null
force: boolean
qualityPipeline: Required<VideoQualityConfig>
total: number total: number
readyTargetCount: number
targetCount: number targetCount: number
remainingReady: number
created: number created: number
skipped: number skipped: number
readiness: Omit<VideoReadiness, 'total' | 'items'> inProgress: number
blocked: number
failed: number failed: number
readiness: Omit<VideoReadiness, 'total' | 'items'>
failures: { shotId: string; error: string }[] failures: { shotId: string; error: string }[]
candidateIds: string[]
} }
/** 项目最近一次视频任务的逐镜状态。 */ /** 项目最近一次视频任务的逐镜状态。 */
@@ -260,7 +284,7 @@ export interface RetryVideosResult {
export type ProductionReceipt = export type ProductionReceipt =
| { kind: 'prompts'; title: string; result: import('../storyboard/types').PromptBatchResult } | { kind: 'prompts'; title: string; result: import('../storyboard/types').PromptBatchResult }
| { kind: 'keyframes'; title: string; result: KeyframeBatchResult } | { kind: 'keyframes'; title: string; result: KeyframeBatchResult }
| { kind: 'videos'; title: string; result: VideoBatchResult } | { kind: 'videos'; title: string; result: VideoQualityBatchResult }
| { kind: 'retry'; title: string; result: RetryVideosResult } | { kind: 'retry'; title: string; result: RetryVideosResult }
/** 项目生产页的浏览器会话数据。 */ /** 项目生产页的浏览器会话数据。 */
+27 -4
View File
@@ -22,6 +22,8 @@ export function useProduction() {
const keyframeLimit = ref<number | ''>('') const keyframeLimit = ref<number | ''>('')
const keyframeWidth = ref<number | ''>('') const keyframeWidth = ref<number | ''>('')
const keyframeHeight = ref<number | ''>('') const keyframeHeight = ref<number | ''>('')
const videoLimit = ref<number | null>(1)
const videoRepairAttempts = ref<number | null>(2)
const snapshot = computed(() => breakdownSnapshot(context.checkpoints.value)) const snapshot = computed(() => breakdownSnapshot(context.checkpoints.value))
const sourceEpisodes = computed( const sourceEpisodes = computed(
() => snapshot.value?.storyboardEpisodeShots ?? snapshot.value?.breakdownResult?.storyboardEpisodeShots ?? [] () => snapshot.value?.storyboardEpisodeShots ?? snapshot.value?.breakdownResult?.storyboardEpisodeShots ?? []
@@ -80,6 +82,14 @@ export function useProduction() {
(keyframeLimit.value === '' || (Number.isSafeInteger(keyframeLimit.value) && keyframeLimit.value > 0)) && (keyframeLimit.value === '' || (Number.isSafeInteger(keyframeLimit.value) && keyframeLimit.value > 0)) &&
validOptionalSize(keyframeWidth.value, keyframeHeight.value) validOptionalSize(keyframeWidth.value, keyframeHeight.value)
) )
/** 视频质量流水线要求有限的提交范围和至少一次自动修复额度。 */
const videoQualityValid = computed(
() =>
Number.isSafeInteger(videoLimit.value) &&
(videoLimit.value ?? 0) > 0 &&
Number.isSafeInteger(videoRepairAttempts.value) &&
(videoRepairAttempts.value ?? 0) > 0
)
const blocked = computed( const blocked = computed(
() => () =>
operation.value.pending || operation.value.pending ||
@@ -91,7 +101,13 @@ export function useProduction() {
/** 批量操作只向就绪镜头提交;最终资产状态由轮询查询确认。 */ /** 批量操作只向就绪镜头提交;最终资产状态由轮询查询确认。 */
async function run(command: ProductionCommand) { async function run(command: ProductionCommand) {
if (blocked.value || !batchValid.value || (command === 'keyframes' && !keyframeValid.value)) return if (
blocked.value ||
!batchValid.value ||
(command === 'keyframes' && !keyframeValid.value) ||
(command === 'videos' && !videoQualityValid.value)
)
return
const projectId = id.value const projectId = id.value
const input = { concurrency: concurrency.value, force: force.value } const input = { concurrency: concurrency.value, force: force.value }
const target = getProductionSession(projectId) const target = getProductionSession(projectId)
@@ -106,10 +122,10 @@ export function useProduction() {
? `更新 ${staleKeyframes} 个过期主首帧` ? `更新 ${staleKeyframes} 个过期主首帧`
: `补齐第 ${episodeNo.value} 集主首帧`, : `补齐第 ${episodeNo.value} 集主首帧`,
videos: force.value videos: force.value
? '新增项目视频候选' ? '新增项目质量视频候选'
: staleVideos > 0 : staleVideos > 0
? `更新 ${staleVideos} 个过期主视频` ? `更新 ${staleVideos} 个过期主视频`
: '提交就绪视频任务', : '提交视频质量任务',
'retry-videos': '重试失败视频任务' 'retry-videos': '重试失败视频任务'
} }
await runOperation(projectId, labels[command], async () => { await runOperation(projectId, labels[command], async () => {
@@ -137,7 +153,11 @@ export function useProduction() {
target.receipt = { target.receipt = {
kind: 'videos', kind: 'videos',
title: labels[command], title: labels[command],
result: await productionApi.generateVideos(projectId, input) result: await productionApi.generateQualityVideos(projectId, {
...input,
limit: videoLimit.value ?? undefined,
maxRepairAttempts: videoRepairAttempts.value ?? 0
})
} }
} else { } else {
target.receipt = { target.receipt = {
@@ -170,7 +190,10 @@ export function useProduction() {
keyframeLimit, keyframeLimit,
keyframeWidth, keyframeWidth,
keyframeHeight, keyframeHeight,
videoLimit,
videoRepairAttempts,
keyframeValid, keyframeValid,
videoQualityValid,
batchValid, batchValid,
blocked, blocked,
run run
+1 -1
View File
@@ -65,7 +65,7 @@ function clearFilters() {
} }
</script> </script>
<template> <template>
<WorkspacePage split> <WorkspacePage split compact>
<template #header> <template #header>
<div class="project-index-chrome"> <div class="project-index-chrome">
<!-- 以下是标题与新建 --> <!-- 以下是标题与新建 -->
+1 -13
View File
@@ -224,17 +224,11 @@ watch(
>导演摄影设计 >导演摄影设计
</h3> </h3>
<p class="mt-2 text-xs leading-6 text-muted"> <p class="mt-2 text-xs leading-6 text-muted">
景别机位运镜与构图单集操作会重新生成并覆盖批量默认跳过完整剧集 景别机位运镜与构图正式项目生成默认跳过完整剧集覆盖模式会重新生成全部剧集
</p> </p>
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<ConfirmAction <ConfirmAction
label="生成本集导演设计"
:disabled="!allowed['direction-one']"
acknowledgement
description="调用模型生成当前整集的导演设计,校验通过后保存。如果此集已有设计,将覆盖原结果。"
@confirm="run('direction-one')"
/><ConfirmAction
:label="force ? '重生成全部导演设计' : '补齐项目导演设计'" :label="force ? '重生成全部导演设计' : '补齐项目导演设计'"
:disabled="!allowed['direction-all']" :disabled="!allowed['direction-all']"
acknowledgement acknowledgement
@@ -259,12 +253,6 @@ watch(
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<ConfirmAction <ConfirmAction
label="生成本集即时状态"
:disabled="!allowed['visual-one']"
acknowledgement
description="依据本集导演设计和主体形态生成即时视觉状态,按配置尝试自动修复。校验通过才保存,已有状态将被覆盖。"
@confirm="run('visual-one')"
/><ConfirmAction
:label="force ? '重生成全部即时状态' : '补齐项目即时状态'" :label="force ? '重生成全部即时状态' : '补齐项目即时状态'"
:disabled="!allowed['visual-all']" :disabled="!allowed['visual-all']"
acknowledgement acknowledgement
+2 -16
View File
@@ -1,6 +1,5 @@
import { request } from '../../lib/http' import { request } from '../../lib/http'
import type { import type {
DirectionGeneration,
EpisodeDirections, EpisodeDirections,
EpisodeVisualStates, EpisodeVisualStates,
PromptBatchResult, PromptBatchResult,
@@ -8,8 +7,7 @@ import type {
ShotReferences, ShotReferences,
StoryboardBatchInput, StoryboardBatchInput,
StoryboardBatchResult, StoryboardBatchResult,
VisualStateBatchInput, VisualStateBatchInput
VisualStateGeneration
} from './types' } from './types'
import type { ShotGenerationSpec } from './generation-spec.types' import type { ShotGenerationSpec } from './generation-spec.types'
@@ -23,30 +21,18 @@ function shotPath(id: string) {
return `/storyboard-shots/${encodeURIComponent(id)}` return `/storyboard-shots/${encodeURIComponent(id)}`
} }
/** Storyboard 路由契约;单集入口虽名为 generate-test,但 persist=true 才适用于工作台。 */ /** Storyboard 正式路由契约;测试路由不暴露为前端生产入口。 */
export const storyboardApi = { export const storyboardApi = {
directions: (id: string, episodeNo: number, signal?: AbortSignal) => directions: (id: string, episodeNo: number, signal?: AbortSignal) =>
request<EpisodeDirections>(`${projectPath(id)}/storyboard-directions?episodeNo=${episodeNo}`, { signal }), request<EpisodeDirections>(`${projectPath(id)}/storyboard-directions?episodeNo=${episodeNo}`, { signal }),
visualStates: (id: string, episodeNo: number, signal?: AbortSignal) => visualStates: (id: string, episodeNo: number, signal?: AbortSignal) =>
request<EpisodeVisualStates>(`${projectPath(id)}/storyboard-visual-states?episodeNo=${episodeNo}`, { signal }), request<EpisodeVisualStates>(`${projectPath(id)}/storyboard-visual-states?episodeNo=${episodeNo}`, { signal }),
generateDirection: (id: string, episodeNo: number) =>
request<DirectionGeneration>(`${projectPath(id)}/storyboard-directions/generate-test`, {
method: 'POST',
body: { episodeNo, persist: true },
timeoutMs: 0
}),
generateDirections: (id: string, input: StoryboardBatchInput) => generateDirections: (id: string, input: StoryboardBatchInput) =>
request<StoryboardBatchResult>(`${projectPath(id)}/storyboard-directions/generate`, { request<StoryboardBatchResult>(`${projectPath(id)}/storyboard-directions/generate`, {
method: 'POST', method: 'POST',
body: input, body: input,
timeoutMs: 0 timeoutMs: 0
}), }),
generateVisualState: (id: string, episodeNo: number, maxRepairAttempts: number) =>
request<VisualStateGeneration>(`${projectPath(id)}/storyboard-visual-states/generate-test`, {
method: 'POST',
body: { episodeNo, persist: true, maxRepairAttempts },
timeoutMs: 0
}),
generateVisualStates: (id: string, input: VisualStateBatchInput) => generateVisualStates: (id: string, input: VisualStateBatchInput) =>
request<StoryboardBatchResult>(`${projectPath(id)}/storyboard-visual-states/generate`, { request<StoryboardBatchResult>(`${projectPath(id)}/storyboard-visual-states/generate`, {
method: 'POST', method: 'POST',
@@ -3,24 +3,13 @@ import { NScrollbar, NAlert, NButton, NTable } from 'naive-ui'
import { computed } from 'vue' import { computed } from 'vue'
import { Download } from '@lucide/vue' import { Download } from '@lucide/vue'
import { downloadText } from '../../../lib/format' import { downloadText } from '../../../lib/format'
import type { StoryboardIssue, StoryboardReceipt } from '../types' import type { StoryboardReceipt } from '../types'
/** 独立展示提交结果,不能将 HTTP 成功或局部成功显示为整批完成。 */ /** 独立展示提交结果,不能将 HTTP 成功或局部成功显示为整批完成。 */
const props = defineProps<{ receipt: StoryboardReceipt }>() const props = defineProps<{ receipt: StoryboardReceipt }>()
const issues = computed<StoryboardIssue[]>(() => { const failed = computed(() =>
if (props.receipt.kind === 'direction') return props.receipt.result.validation.issues props.receipt.kind === 'batch' ? props.receipt.result.failedEpisodes > 0 : props.receipt.result.failed > 0
if (props.receipt.kind === 'visual-state') )
return props.receipt.result.remainingIssues.length
? props.receipt.result.remainingIssues
: props.receipt.result.validation.issues
return []
})
const failed = computed(() => {
if (props.receipt.kind === 'batch') return props.receipt.result.failedEpisodes > 0
if (props.receipt.kind === 'prompts') return props.receipt.result.failed > 0
if (props.receipt.kind === 'visual-state' && !props.receipt.result.persisted) return true
return !props.receipt.result.validation.valid
})
/** 导出原始回执,保留后端提供的完整失败诊断。 */ /** 导出原始回执,保留后端提供的完整失败诊断。 */
function exportReceipt() { function exportReceipt() {
@@ -91,24 +80,6 @@ function exportReceipt() {
当前批量接口不返回成功镜头的 Prompt 正文选择镜头后点击读取生成提示词已有内容会直接复用 当前批量接口不返回成功镜头的 Prompt 正文选择镜头后点击读取生成提示词已有内容会直接复用
</p> </p>
</template> </template>
<template v-else>
<p class="mt-4 text-sm" :class="failed ? 'text-danger' : 'text-success'">
{{ receipt.result.validation.valid ? '生成校验通过' : '校验失败,未保存本次生成结果'
}}<span v-if="receipt.kind === 'visual-state'">
· 自动修复 {{ receipt.result.repairAttempts }} ·
{{ receipt.result.persisted ? '已持久化' : '未持久化' }}</span
>
</p>
<p v-if="receipt.kind === 'direction' && receipt.result.validation.valid" class="mt-2 text-xs text-muted">
已请求持久化数据库实际覆盖率以下方刷新后的数据为准
</p>
<ul class="mt-3 space-y-2 text-xs text-danger">
<li v-for="(issue, index) in issues" :key="index">
{{ issue.episodeNo }} / Beat {{ issue.beatNo ?? '' }} / Shot {{ issue.shotNo ?? '' }}
{{ issue.subjectRef }}{{ issue.message }}
</li>
</ul>
</template>
<p class="mt-4 text-[11px] text-muted">回执仅保留在当前浏览器会话中数据库结果是覆盖率和镜头详情的依据</p> <p class="mt-4 text-[11px] text-muted">回执仅保留在当前浏览器会话中数据库结果是覆盖率和镜头详情的依据</p>
</section> </section>
</template> </template>
+1 -30
View File
@@ -23,7 +23,7 @@ export interface SubjectVisualState {
transientState: string transientState: string
} }
/** GET 返回扁平化环境字段,与 generate-test 的嵌套 environment 不同。 */ /** GET 返回已经持久化的扁平化环境字段。 */
export interface ShotVisualState { export interface ShotVisualState {
timeOfDay: string timeOfDay: string
weather: string weather: string
@@ -62,33 +62,6 @@ export interface StoryboardIssue {
message: string message: string
} }
/** 单集生成结果中的校验信息,HTTP 200 不代表 valid。 */
export interface StoryboardValidation {
valid: boolean
issues: StoryboardIssue[]
}
/** 单集导演设计返回领域结构;是否落库应再查 GET 覆盖率确认。 */
export interface DirectionGeneration {
episodeNo: number
direction: {
episodeNo: number
beatDirections: { beatNo: number; shots: (ShotDirection & { shotNo: number })[] }[]
}
validation: StoryboardValidation
}
/** 单集 VisualState 的自动修复与落库回执。 */
export interface VisualStateGeneration {
episodeNo: number
visualState: unknown
validation: StoryboardValidation
repaired: boolean
repairAttempts: number
remainingIssues: StoryboardIssue[]
persisted: boolean
}
/** 项目批量生成中的单集结果;保留失败原因和修复诊断。 */ /** 项目批量生成中的单集结果;保留失败原因和修复诊断。 */
export interface EpisodeGenerationResult { export interface EpisodeGenerationResult {
episodeNo: number episodeNo: number
@@ -178,8 +151,6 @@ export interface DesignedShot {
/** 生成回执按操作区分,切换剧集时仍保留原始目标信息。 */ /** 生成回执按操作区分,切换剧集时仍保留原始目标信息。 */
export type StoryboardReceipt = export type StoryboardReceipt =
| { kind: 'direction'; title: string; episodeNo: number; result: DirectionGeneration }
| { kind: 'visual-state'; title: string; episodeNo: number; result: VisualStateGeneration }
| { kind: 'batch'; title: string; result: StoryboardBatchResult } | { kind: 'batch'; title: string; result: StoryboardBatchResult }
| { kind: 'prompts'; title: string; result: PromptBatchResult } | { kind: 'prompts'; title: string; result: PromptBatchResult }
+2 -24
View File
@@ -6,8 +6,8 @@ import { getOperation, runOperation } from '../workflows/operations'
import { storyboardApi } from './api' import { storyboardApi } from './api'
import { getStoryboardSession, mergeDesignedShots, storyboardPrerequisites } from './model' import { getStoryboardSession, mergeDesignedShots, storyboardPrerequisites } from './model'
/** 分镜页允许的生成命令,保持单集与批量参数边界。 */ /** 分镜页只提供正式项目级生成命令,不暴露后端测试路由。 */
export type StoryboardCommand = 'direction-one' | 'direction-all' | 'visual-one' | 'visual-all' | 'prompts' export type StoryboardCommand = 'direction-all' | 'visual-all' | 'prompts'
/** 分镜查询、依赖判断与操作入口;不修改后端 graph。 */ /** 分镜查询、依赖判断与操作入口;不修改后端 graph。 */
export function useStoryboard() { export function useStoryboard() {
@@ -87,10 +87,7 @@ export function useStoryboard() {
data.value.directions.coverage === 1 data.value.directions.coverage === 1
) )
const allowed = computed<Record<StoryboardCommand, boolean>>(() => ({ const allowed = computed<Record<StoryboardCommand, boolean>>(() => ({
'direction-one': !blocked.value && prerequisites.value.directionEpisode && !!data.value?.directions.shotCount,
'direction-all': !blocked.value && prerequisites.value.directionProject && batchValid.value, 'direction-all': !blocked.value && prerequisites.value.directionProject && batchValid.value,
'visual-one':
!blocked.value && prerequisites.value.visualEpisode && directionComplete.value && repairsValid.value,
'visual-all': !blocked.value && prerequisites.value.visualProject && batchValid.value && repairsValid.value, 'visual-all': !blocked.value && prerequisites.value.visualProject && batchValid.value && repairsValid.value,
prompts: !blocked.value && shots.value.length > 0 && batchValid.value prompts: !blocked.value && shots.value.length > 0 && batchValid.value
})) }))
@@ -99,28 +96,17 @@ export function useStoryboard() {
async function run(command: StoryboardCommand) { async function run(command: StoryboardCommand) {
if (!allowed.value[command]) return if (!allowed.value[command]) return
const projectId = id.value const projectId = id.value
const number = episodeNo.value
const batch = { concurrency: concurrency.value, force: force.value } const batch = { concurrency: concurrency.value, force: force.value }
const repairs = maxRepairAttempts.value const repairs = maxRepairAttempts.value
const target = getStoryboardSession(projectId) const target = getStoryboardSession(projectId)
const titles: Record<StoryboardCommand, string> = { const titles: Record<StoryboardCommand, string> = {
'direction-one': `${number} 集导演设计`,
'direction-all': '项目导演设计', 'direction-all': '项目导演设计',
'visual-one': `${number} 集即时状态`,
'visual-all': '项目即时状态', 'visual-all': '项目即时状态',
prompts: '项目视频提示词' prompts: '项目视频提示词'
} }
target.receipt = null target.receipt = null
await runOperation(projectId, `生成${titles[command]}`, async () => { await runOperation(projectId, `生成${titles[command]}`, async () => {
switch (command) { switch (command) {
case 'direction-one':
target.receipt = {
kind: 'direction',
title: titles[command],
episodeNo: number,
result: await storyboardApi.generateDirection(projectId, number)
}
break
case 'direction-all': case 'direction-all':
target.receipt = { target.receipt = {
kind: 'batch', kind: 'batch',
@@ -128,14 +114,6 @@ export function useStoryboard() {
result: await storyboardApi.generateDirections(projectId, batch) result: await storyboardApi.generateDirections(projectId, batch)
} }
break break
case 'visual-one':
target.receipt = {
kind: 'visual-state',
title: titles[command],
episodeNo: number,
result: await storyboardApi.generateVisualState(projectId, number, repairs)
}
break
case 'visual-all': case 'visual-all':
target.receipt = { target.receipt = {
kind: 'batch', kind: 'batch',
@@ -545,7 +545,7 @@ watch(
@save="save" @save="save"
@dirty="dirty = $event" @dirty="dirty = $event"
/> />
<div v-if="anchor" class="mt-5 flex items-center gap-4 pt-5"> <div v-if="anchor" class="identity-anchor mt-5 flex items-center gap-4 pt-5">
<AssetImage <AssetImage
:src="anchor.imageUrl" :src="anchor.imageUrl"
:alt="`${subject.name}当前身份母版`" :alt="`${subject.name}当前身份母版`"
@@ -677,6 +677,25 @@ watch(
.identity-workspace > div { .identity-workspace > div {
@apply min-h-0 overflow-hidden overscroll-contain; @apply min-h-0 overflow-hidden overscroll-contain;
} }
.identity-detail,
.identity-detail > .n-scrollbar-container,
.identity-detail .n-scrollbar-content,
.identity-detail .panel {
@apply w-full min-w-0 max-w-full;
}
.identity-detail .n-scrollbar-content {
@apply wrap-anywhere;
}
.identity-detail .panel {
@apply box-border;
}
/* AssetImage 的通用 width:100% 不得撑满母版摘要行,否则会把说明文字挤到最右侧。 */
.identity-anchor > .asset-image {
@apply h-20 w-20 shrink-0 aspect-square;
}
.identity-anchor > div {
@apply min-w-0;
}
.identity-subject-list { .identity-subject-list {
@apply max-h-none min-h-0 flex-1; @apply max-h-none min-h-0 flex-1;
} }
@@ -305,8 +305,8 @@ async function showStaleForms() {
<template> <template>
<WorkspacePage compact class="gallery-workspace-page" <WorkspacePage compact class="gallery-workspace-page"
><template #default> ><template #header>
<!-- 筛选关联说明和图片共用正文滚动区避免镜头切换时两块滚动区域高度不同步 --> <!-- 图库统计和固定操作使用与其他工作台一致的紧凑头部 -->
<div class="workspace-toolbar"> <div class="workspace-toolbar">
<span class="toolbar-summary">主图 {{ completeCount }} / {{ forms.length }}</span> <span class="toolbar-summary">主图 {{ completeCount }} / {{ forms.length }}</span>
<NButton v-if="staleForms.length" text type="error" size="small" @click="toolsOpen = true" <NButton v-if="staleForms.length" text type="error" size="small" @click="toolsOpen = true"
@@ -476,7 +476,9 @@ async function showStaleForms() {
</section> </section>
</WorkspaceTools> </WorkspaceTools>
</div> </div>
</div> </div></template
><template #default>
<!-- 关联说明筛选和图片共用正文滚动区避免镜头切换时两块滚动区域高度不同步 -->
<NAlert v-if="staleForms.length" type="error" :show-icon="false" class="mt-3" title="形态主图需要更新"> <NAlert v-if="staleForms.length" type="error" :show-icon="false" class="mt-3" title="形态主图需要更新">
{{ staleForms.length }} {{ staleForms.length }}
个形态主图与当前已锁定身份母版不一致请生成候选验图并切换主图再重建相关首帧 个形态主图与当前已锁定身份母版不一致请生成候选验图并切换主图再重建相关首帧
@@ -851,7 +853,7 @@ async function showStaleForms() {
@apply [overflow-anchor:none]; @apply [overflow-anchor:none];
} }
.form-image-filter-region { .form-image-filter-region {
@apply my-4 py-3.5 px-4 bg-(--app-subtle); @apply my-4 py-[5px] px-4 bg-(--app-subtle);
container-type: inline-size; container-type: inline-size;
} }
.form-image-toolbar { .form-image-toolbar {
+2 -2
View File
@@ -162,7 +162,7 @@ describe('全站平面主题', () => {
) )
expect(css).toMatch(/\.asset-impact-links-scroll\.n-scrollbar\s*\{[^}]*height:\s*auto;[^}]*max-width:\s*100%;/) expect(css).toMatch(/\.asset-impact-links-scroll\.n-scrollbar\s*\{[^}]*height:\s*auto;[^}]*max-width:\s*100%;/)
expect(readFileSync('src/features/subject-images/SubjectImagesPage.vue', 'utf8')).toMatch( expect(readFileSync('src/features/subject-images/SubjectImagesPage.vue', 'utf8')).toMatch(
/<WorkspacePage\b[^>]*>\s*<template #default>/ /<WorkspacePage\b[^>]*>\s*<template #header>/
) )
}) })
@@ -184,7 +184,7 @@ describe('全站平面主题', () => {
// DOM 环境不计算几何;保护容器宽度断点和居中契约,真实坐标仍需浏览器验收。 // DOM 环境不计算几何;保护容器宽度断点和居中契约,真实坐标仍需浏览器验收。
const css = readAllStyles() const css = readAllStyles()
expect(css).toMatch( expect(css).toMatch(
/\.form-image-filter-region\s*\{[^}]*padding(?:-block)?:\s*14px;[^}]*background:\s*var\(--app-subtle\);/ /\.form-image-filter-region\s*\{[^}]*padding(?:-block)?:\s*5px;[^}]*background:\s*var\(--app-subtle\);/
) )
expect(css).toMatch(/\.form-image-toolbar\s*\{[^}]*align-items:\s*center;[^}]*gap:\s*12px 16px;/) expect(css).toMatch(/\.form-image-toolbar\s*\{[^}]*align-items:\s*center;[^}]*gap:\s*12px 16px;/)
expect(css).toMatch( expect(css).toMatch(
+10 -3
View File
@@ -99,12 +99,19 @@ 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', { concurrency: 3, force: true }) await productionApi.generateQualityVideos('project/1', {
concurrency: 3,
force: true,
limit: 2,
maxRepairAttempts: 2
})
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-quality')
expect(JSON.parse(String(fetcher.mock.calls[1]?.[1]?.body))).toEqual({ expect(JSON.parse(String(fetcher.mock.calls[1]?.[1]?.body))).toEqual({
concurrency: 3, concurrency: 3,
force: true force: true,
limit: 2,
maxRepairAttempts: 2
}) })
}) })
@@ -181,7 +181,7 @@ describe('过期首帧到具体素材定位', () => {
it('切换镜头时筛选、关联说明和图片共用稳定纵向容器,长关联列表单独横向滚动', async () => { it('切换镜头时筛选、关联说明和图片共用稳定纵向容器,长关联列表单独横向滚动', async () => {
const { keyframes, videos, references, fetcher } = await gallery('?sourceShotId=shot-target') const { keyframes, videos, references, fetcher } = await gallery('?sourceShotId=shot-target')
expect(wrapper!.find('.workspace-heading-scroll').exists()).toBe(false) expect(wrapper!.find('.workspace-heading-scroll').exists()).toBe(true)
const scroll = wrapper!.get<HTMLElement>('.workspace-scroll > .n-scrollbar-container').element const scroll = wrapper!.get<HTMLElement>('.workspace-scroll > .n-scrollbar-container').element
const content = wrapper!.get('.workspace-scroll-content').element const content = wrapper!.get('.workspace-scroll-content').element
const sticky = wrapper!.get('.gallery-sticky-controls').element const sticky = wrapper!.get('.gallery-sticky-controls').element
+38 -30
View File
@@ -1111,7 +1111,7 @@ describe('工作台页面交互', () => {
vi.stubGlobal('fetch', fetcher) vi.stubGlobal('fetch', fetcher)
await mountStoryboard() await mountStoryboard()
await flushPromises() await flushPromises()
await confirmGeneration('生成本集导演设计') await confirmGeneration('补齐项目导演设计')
expect(button('补齐项目导演设计').disabled).toBe(true) expect(button('补齐项目导演设计').disabled).toBe(true)
await drawerPanel().get('[aria-label="关闭操作面板"]').trigger('click') await drawerPanel().get('[aria-label="关闭操作面板"]').trigger('click')
await flushPromises() await flushPromises()
@@ -1123,61 +1123,69 @@ describe('工作台页面交互', () => {
wrapper = undefined wrapper = undefined
finish( finish(
new Response( new Response(
'{"data":{"episodeNo":1,"direction":{"episodeNo":1,"beatDirections":[]},"validation":{"valid":true,"issues":[]}}}' `{"data":{"projectId":"${fixture.id}","episodeCount":2,"completedEpisodes":1,"skippedEpisodes":1,"failedEpisodes":0,"shotCount":4,"directionCount":4,"coverage":1,"episodes":[]}}`
) )
) )
await flushPromises() await flushPromises()
expect(getStoryboardSession(fixture.id).receipt).toMatchObject({ expect(getStoryboardSession(fixture.id).receipt).toMatchObject({
kind: 'direction', kind: 'batch',
episodeNo: 1, title: '项目导演设计'
title: '第 1 集导演设计'
}) })
expect(getOperation(fixture.id).pending).toBe(false) expect(getOperation(fixture.id).pending).toBe(false)
const posts = fetcher.mock.calls.filter(([, init]) => init?.method === 'POST') const posts = fetcher.mock.calls.filter(([, init]) => init?.method === 'POST')
expect(posts).toHaveLength(1) expect(posts).toHaveLength(1)
expect(JSON.parse(posts[0]![1]!.body as string)).toEqual({ episodeNo: 1, persist: true }) expect(JSON.parse(posts[0]![1]!.body as string)).toEqual({ concurrency: 2, force: false })
}) })
it('分镜必须先补齐导演设计,校验失败显示未保存,修复次数 0 不被默认值覆盖', async () => { it('分镜只提供正式项目生成,逐集失败回执保留零次修复参数', async () => {
let complete = false
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => { const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
if (init?.method === 'POST') if (init?.method === 'POST')
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
data: { data: {
episodeNo: 1, projectId: fixture.id,
visualState: {}, episodeCount: 1,
validation: { completedEpisodes: 0,
valid: false, skippedEpisodes: 0,
issues: [{ episodeNo: 1, beatNo: 1, shotNo: 1, message: '缺少主体状态' }] failedEpisodes: 1,
}, shotCount: 2,
repaired: false, visualStateCount: 0,
repairAttempts: 0, coverage: 0,
remainingIssues: [], episodes: [
persisted: false {
episodeNo: 1,
status: 'failed',
shotCount: 2,
visualStateCount: 0,
repairAttempts: 0,
error: '缺少主体状态'
}
]
} }
}) })
) )
const result = String(url).includes('storyboard-directions') const result = String(url).includes('storyboard-directions')
? directionsResult(fixture.id, 1, complete) ? directionsResult(fixture.id, 1, true)
: visualStatesResult(fixture.id) : visualStatesResult(fixture.id)
return new Response(JSON.stringify({ data: result })) return new Response(JSON.stringify({ data: result }))
}) })
vi.stubGlobal('fetch', fetcher) vi.stubGlobal('fetch', fetcher)
await mountStoryboard() await mountStoryboard()
await flushPromises() await flushPromises()
expect(button('生成本集导演设计').disabled).toBe(false) expect(wrapper!.text()).not.toContain('生成本集导演设计')
expect(button('生成本集即时状态').disabled).toBe(true) expect(wrapper!.text()).not.toContain('生成本集即时状态')
expect(button('补齐项目导演设计').disabled).toBe(false)
expect(button('补齐项目即时状态').disabled).toBe(false)
expect(fetcher.mock.calls.every(([, init]) => init?.method !== 'POST')).toBe(true) expect(fetcher.mock.calls.every(([, init]) => init?.method !== 'POST')).toBe(true)
complete = true
button('刷新分镜').click()
await flushPromises()
expect(button('生成本集即时状态').disabled).toBe(false)
await drawerPanel().get('#storyboard-repairs').setValue('0') await drawerPanel().get('#storyboard-repairs').setValue('0')
await confirmGeneration('生成本集即时状态') await confirmGeneration('补齐项目即时状态')
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({ episodeNo: 1, persist: true, maxRepairAttempts: 0 }) expect(JSON.parse(post[1]!.body as string)).toEqual({
expect(drawerPanel().text()).toContain('校验失败,未保存本次生成结果') concurrency: 2,
force: false,
maxRepairAttempts: 0
})
expect(drawerPanel().text()).toContain('本次存在失败')
expect(drawerPanel().text()).toContain('缺少主体状态') expect(drawerPanel().text()).toContain('缺少主体状态')
}) })
@@ -1291,11 +1299,11 @@ describe('工作台页面交互', () => {
await flushPromises() await flushPromises()
expect(wrapper!.text()).toContain('shot-db-1-1') expect(wrapper!.text()).toContain('shot-db-1-1')
expect(wrapper!.text()).toContain('数据库暂不可用') expect(wrapper!.text()).toContain('数据库暂不可用')
expect(button('生成本集导演设计').disabled).toBe(true) expect(button('补齐项目导演设计').disabled).toBe(true)
fail = false fail = false
button('重试查询').click() button('重试查询').click()
await flushPromises() await flushPromises()
expect(button('生成本集导演设计').disabled).toBe(false) expect(button('补齐项目导演设计').disabled).toBe(false)
}) })
it('镜头工具按需 GET,切换镜头会废弃旧参考图,未完成设计不能编译规格', async () => { it('镜头工具按需 GET,切换镜头会废弃旧参考图,未完成设计不能编译规格', async () => {
+11 -15
View File
@@ -43,21 +43,13 @@ describe('后端 API 契约', () => {
}) })
expect(fetcher.mock.calls[4]![1]!.body).toBeUndefined() expect(fetcher.mock.calls[4]![1]!.body).toBeUndefined()
}) })
it('分镜单集显式持久化,批量保持 force 和零次修复参数', async () => { it('分镜只调用正式项目接口,并保持 force 和零次修复参数', 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 storyboardApi.generateDirection('a/b', 2) await storyboardApi.generateDirections('a/b', { concurrency: 2, force: false })
await storyboardApi.generateDirections('p', { concurrency: 2, force: false })
await storyboardApi.generateVisualState('p', 2, 0)
await storyboardApi.generateVisualStates('p', { concurrency: 3, force: true, maxRepairAttempts: 0 }) await storyboardApi.generateVisualStates('p', { concurrency: 3, force: true, maxRepairAttempts: 0 })
expect(fetcher.mock.calls.map(([url, init]) => [url, init?.method, JSON.parse(init!.body as string)])).toEqual([ expect(fetcher.mock.calls.map(([url, init]) => [url, init?.method, JSON.parse(init!.body as string)])).toEqual([
['/api/projects/a%2Fb/storyboard-directions/generate-test', 'POST', { episodeNo: 2, persist: true }], ['/api/projects/a%2Fb/storyboard-directions/generate', 'POST', { concurrency: 2, force: false }],
['/api/projects/p/storyboard-directions/generate', 'POST', { concurrency: 2, force: false }],
[
'/api/projects/p/storyboard-visual-states/generate-test',
'POST',
{ episodeNo: 2, persist: true, maxRepairAttempts: 0 }
],
[ [
'/api/projects/p/storyboard-visual-states/generate', '/api/projects/p/storyboard-visual-states/generate',
'POST', 'POST',
@@ -86,7 +78,7 @@ describe('后端 API 契约', () => {
expect(JSON.parse(fetcher.mock.calls[4]![1]!.body as string)).toEqual({ force: false }) expect(JSON.parse(fetcher.mock.calls[4]![1]!.body as string)).toEqual({ force: false })
}) })
it('分镜生成保留 200 中的校验失败和持久化标志,长请求不自动超时或重试', async () => { it('分镜正式批量生成保留部分失败回执,长请求不自动超时或重试', async () => {
vi.useFakeTimers() vi.useFakeTimers()
let finish!: (response: Response) => void let finish!: (response: Response) => void
const fetcher = vi.fn<typeof fetch>().mockImplementation( const fetcher = vi.fn<typeof fetch>().mockImplementation(
@@ -96,12 +88,16 @@ describe('后端 API 契约', () => {
}) })
) )
vi.stubGlobal('fetch', fetcher) vi.stubGlobal('fetch', fetcher)
const pending = storyboardApi.generateVisualState('p', 1, 2) const pending = storyboardApi.generateVisualStates('p', {
concurrency: 1,
force: false,
maxRepairAttempts: 2
})
await vi.advanceTimersByTimeAsync(120_000) await vi.advanceTimersByTimeAsync(120_000)
expect(fetcher).toHaveBeenCalledOnce() expect(fetcher).toHaveBeenCalledOnce()
expect(fetcher.mock.calls[0]?.[1]?.signal?.aborted).toBe(false) expect(fetcher.mock.calls[0]?.[1]?.signal?.aborted).toBe(false)
finish(new Response('{"data":{"validation":{"valid":false,"issues":[]},"persisted":false}}')) finish(new Response('{"data":{"failedEpisodes":1,"episodes":[{"episodeNo":1,"status":"failed"}]}}'))
await expect(pending).resolves.toMatchObject({ validation: { valid: false }, persisted: false }) await expect(pending).resolves.toMatchObject({ failedEpisodes: 1, episodes: [{ status: 'failed' }] })
}) })
it('解包 data,同时保留创建项目的顶层 202 结构', async () => { it('解包 data,同时保留创建项目的顶层 202 结构', async () => {
const fetcher = vi const fetcher = vi