feat: 同步角色选角与首帧身份约束

This commit is contained in:
GouJ
2026-08-31 19:14:58 +08:00
parent aeb3ce2986
commit 53917268d3
20 changed files with 940 additions and 112 deletions
+34 -15
View File
@@ -1,6 +1,6 @@
# Short Drama Agent Front # Short Drama Agent Front
短剧 Agent 的前端工作台。支持剧本创作、拆解、视觉风格、主体身份、形态图片与分镜设计,使用真实后端 API,不包含演示数据或浏览器端模型调用。 短剧 Agent 的前端工作台。支持剧本创作、拆解、视觉风格、角色选角、主体形态、分镜设计、镜头首帧与视频生产,使用真实后端 API,不包含演示数据或浏览器端模型调用。
## 启动 ## 启动
@@ -32,14 +32,16 @@ API_PROXY_TARGET=http://localhost:3412
| 拆解结果 | 主体、别名、视觉形态、Episode → Beat → Shot、主体绑定、抽取任务状态、校验问题、JSON 导出 | | 拆解结果 | 主体、别名、视觉形态、Episode → Beat → Shot、主体绑定、抽取任务状态、校验问题、JSON 导出 |
| 拆解恢复 | 失败抽取重试、缺失剧集镜头补齐、分镜引用与 Form 绑定修复 | | 拆解恢复 | 失败抽取重试、缺失剧集镜头补齐、分镜引用与 Form 绑定修复 |
| 视觉风格 | AI 生成、人工编辑、分类 Prompt、JSON 硬约束、锁定;风格参考图地址登记、启停和删除 | | 视觉风格 | AI 生成、人工编辑、分类 Prompt、JSON 硬约束、锁定;风格参考图地址登记、启停和删除 |
| 主体身份 | 正式主体归并形态,单个/批量生成身份文本人工编辑锁定,部分失败诊断 | | 主体身份 | 正式主体目录、单个/批量身份文本人工编辑锁定;无形态 Character 也可先完成身份 |
| 身份母版 | Seedream 生图、四种参考视角、指定身份参考图、母版候选与切换、实际 Prompt 和来源追溯 | | 角色选角 | 选角就绪检查、角色身份专用批量生成、Seedream 候选小批量生成、确认演员并原子锁定 Identity |
| 身份母版 | 普通身份图与辅助视角、实际 Prompt 和来源追溯;Character 母版通过确认选角确定 |
| 分镜检查 | 按剧集、Beat、正式 Shot ID 浏览;导演设计、环境与主体即时状态;单集覆盖率与 JSON 导出 | | 分镜检查 | 按剧集、Beat、正式 Shot ID 浏览;导演设计、环境与主体即时状态;单集覆盖率与 JSON 导出 |
| 分镜生成 | Direction / VisualState 单集生成、项目批量补齐、覆盖重生成;并发和自动修复次数;部分失败诊断 | | 分镜生成 | Direction / VisualState 单集生成、项目批量补齐、覆盖重生成;并发和自动修复次数;部分失败诊断 |
| 镜头工具 | 主体参考图与缺失原因、确定性 GenerationSpec 查询与导出、单镜头/批量视频提示词 | | 镜头工具 | 主体参考图与缺失原因、确定性 GenerationSpec 查询与导出、单镜头/批量视频提示词 |
| 形态图片 | 正式形态图库、主图/候选图预览、图片历史与失败记录、Seedream 单图/批量生图、切换主参考图 | | 形态图片 | 正式形态图库、主图/候选图预览、图片历史与失败记录、Seedream 单图/批量生图、切换主参考图 |
| 镜头生产 | Prompt/首帧/视频就绪诊断、Seedream 首帧候选、Seedance 任务、成片播放、主资产切换与失败重试 |
尚未实现:手工编辑剧本或分镜设计(后端暂无对应写接口)、实际视频生成、用户登录。模型输出按纯文本显示,避免执行不可信 HTML。参考图只接受 HTTP(S) 或后端 `/storage/` 路径。 尚未实现:手工编辑剧本或分镜设计(后端暂无对应写接口)、用户登录。模型输出按纯文本显示,避免执行不可信 HTML。图片和视频只接受 HTTP(S) 或后端 `/storage/` 路径。
## 技术与规范 ## 技术与规范
@@ -72,6 +74,7 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
| `src/features/subject-images` | 正式形态图库、图片生成参数、历史图片、主图选择与批量回执 | | `src/features/subject-images` | 正式形态图库、图片生成参数、历史图片、主图选择与批量回执 |
| `src/features/visual-style` | 项目视觉风格 API、类型、编辑器、锁定和风格参考图管理 | | `src/features/visual-style` | 项目视觉风格 API、类型、编辑器、锁定和风格参考图管理 |
| `src/features/subject-identity` | 稳定身份 API、正式主体目录、文本编辑、生图配置、身份图库和母版切换 | | `src/features/subject-identity` | 稳定身份 API、正式主体目录、文本编辑、生图配置、身份图库和母版切换 |
| `src/features/production` | 视频提示词、镜头首帧、视频任务、就绪诊断、资产候选与批量回执 |
| `src/features/workflows` | checkpoint 选择、执行记录、长请求互斥与操作确认 | | `src/features/workflows` | checkpoint 选择、执行记录、长请求互斥与操作确认 |
| `src/components/ui` | 跨业务使用的 Reka UI 封装与公共展示组件 | | `src/components/ui` | 跨业务使用的 Reka UI 封装与公共展示组件 |
| `src/composables` | 具备取消和竞态保护的轮询 | | `src/composables` | 具备取消和竞态保护的轮询 |
@@ -83,7 +86,7 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
## 接口基线 ## 接口基线
对齐后端 `qianlanse/short-drama-agent``dev` 对齐后端 `qianlanse/short-drama-agent``dev`
`059e59709359dccd08b08cb35c6aff95c5e03f2b`2026-08-28),包含 VisualStyle、SubjectIdentity、身份母版、人物/场景形态自动引用母版及道具稳定特征提取。本轮只修改前端,请确保后端依照其开发流程同步了新 Prisma 结构并重启服务。 `c50d3f536fbca40ea07d36e00bace7bb886fabfa`2026-08-31),包含 Character Casting、身份/形态双参考首帧、首帧与视频生产就绪检查。本轮只修改前端,请确保后端依照其开发流程同步 Prisma migration 并重启服务。
| HTTP | 路径(以 /api 为前缀) | 用途 | | HTTP | 路径(以 /api 为前缀) | 用途 |
| --- | --- | --- | | --- | --- | --- |
@@ -120,8 +123,22 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
| GET / PUT | `/subjects/:subjectId/identity` | 查询/人工编辑稳定身份;使用正式 Subject ID | | GET / PUT | `/subjects/:subjectId/identity` | 查询/人工编辑稳定身份;使用正式 Subject ID |
| POST | `/subjects/:subjectId/identity/generate` | AI 生成身份文本,需要项目视觉风格 | | POST | `/subjects/:subjectId/identity/generate` | AI 生成身份文本,需要项目视觉风格 |
| POST | `/projects/:id/subject-identities/generate` | 项目批量身份文本;`force``concurrency`,已锁定始终跳过 | | POST | `/projects/:id/subject-identities/generate` | 项目批量身份文本;`force``concurrency`,已锁定始终跳过 |
| POST | `/projects/:id/character-identities/generate` | 只批量生成 Character Identity 文本 |
| GET | `/projects/:id/character-casting/readiness` | 角色身份、候选、Anchor 与锁定状态汇总 |
| POST | `/projects/:id/character-casting/candidates/generate` | 为缺少 Anchor 的角色小批量生成停用候选 |
| GET / POST | `/subjects/:subjectId/identity/images` | 查询带 `isAnchor` 的身份图片/Seedream 生图 | | GET / POST | `/subjects/:subjectId/identity/images` | 查询带 `isAnchor` 的身份图片/Seedream 生图 |
| 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 |
| GET | `/projects/:id/video-prompts/readiness` | 项目视频提示词前置条件检查 |
| GET / POST | `/projects/:id/keyframes/readiness``/keyframes/generate` | 首帧就绪检查与批量 Seedream 生图 |
| GET / POST | `/storyboard-shots/:shotId/keyframes``/keyframe` | 单镜头首帧历史与生图 |
| PUT | `/storyboard-shots/:shotId/keyframes/:keyframeId/primary` | 切换主首帧 |
| GET / POST | `/projects/:id/videos/readiness``/videos/generate` | 视频前置检查与批量 Seedance 任务 |
| GET | `/projects/:id/videos/status` | 项目最近视频任务状态 |
| POST | `/projects/:id/videos/retry-failed` | 重试最近失败的视频任务 |
| GET / POST | `/storyboard-shots/:shotId/videos` | 单镜头视频历史与任务创建 |
| PUT | `/storyboard-shots/:shotId/videos/:videoId/primary` | 切换主视频 |
API 层也提供 `state``breakdown/latest` 方法。页面通过共享 checkpoint 查询取得阶段状态,避免重复拉取同样内容。 API 层也提供 `state``breakdown/latest` 方法。页面通过共享 checkpoint 查询取得阶段状态,避免重复拉取同样内容。
@@ -147,7 +164,7 @@ API 层也提供 `state` 与 `breakdown/latest` 方法。页面通过共享 chec
7. 上游重生成**不会自动使下游旧数据失效**。覆盖 Direction 后请重生成 VisualState,修改设计/状态/参考图后按需覆盖 Prompt。覆盖率只说明字段已保存,不能证明下游与新版本一致。 7. 上游重生成**不会自动使下游旧数据失效**。覆盖 Direction 后请重生成 VisualState,修改设计/状态/参考图后按需覆盖 Prompt。覆盖率只说明字段已保存,不能证明下游与新版本一致。
8. 每 6 秒查询当前剧集的两类正式结果;这不是全项目实时进度。后端没有独立 Storyboard running / executionId 查询。回执只保留当前浏览器会话,切换页面不丢失,刷新浏览器会丢失。 8. 每 6 秒查询当前剧集的两类正式结果;这不是全项目实时进度。后端没有独立 Storyboard running / executionId 查询。回执只保留当前浏览器会话,切换页面不丢失,刷新浏览器会丢失。
9. 参考图和 GenerationSpec 按需 GET,切换镜头或设计变化时取消旧查询。GenerationSpec 需要有效的 Direction、VisualState 和形态关联,不生成图片或视频。 9. 参考图和 GenerationSpec 按需 GET,切换镜头或设计变化时取消旧查询。GenerationSpec 需要有效的 Direction、VisualState 和形态关联,不生成图片或视频。
10. 后端当前的 Video Prompt 仍使用镜头描述和参考图,**尚未消费 GenerationSpec**。没有独立 GET Prompt 接口,“读取/生成提示词”仍是 POST;不存在 Prompt 时会调用模型,因此必须确认。`force: false` 复用已有正文;批量响应只有计数和失败列表,不能凭计数构造 Prompt 正文。`generated` 是后端处理成功数,可能包含已存在但状态不为 `prompt_ready` 的复用项 10. Video Prompt、Keyframe 与 Video 都有项目级 readiness。页面只向 `ready` 镜头批量提交;`force` 会重写 Prompt 或新增首帧/视频候选,但不会静默替换主首帧和主视频。视频任务创建成功不代表成片完成,最终状态以后端轮询和持久化资产为准
### 形态图片约定 ### 形态图片约定
@@ -163,20 +180,21 @@ API 层也提供 `state` 与 `breakdown/latest` 方法。页面通过共享 chec
### 视觉风格、身份与形态的衔接 ### 视觉风格、身份与形态的衔接
建议操作顺序:**剧本创作 → 剧本拆解 → 视觉风格 → 主体身份 → 形态图片 → 分镜设计**。视觉风格也可以在拆解前准备;这不是前端强制启动的整条生产流程 建议操作顺序:**剧本创作 → 剧本拆解 → 视觉风格 → 角色身份与选角 → 形态图片 → 分镜设计 → 镜头生产**。视觉风格也可以在拆解前准备;页面不会自动重生成已存在的下游资产
| 层级 | 职责 | 本次可操作内容 | | 层级 | 职责 | 本次可操作内容 |
| --- | --- | --- | | --- | --- | --- |
| VisualStyle | 项目整体美术与视觉语言 | 整体/人物/场景/道具 Prompt、JSON 字符串数组硬约束、锁定、风格图片记录 | | VisualStyle | 项目整体美术与视觉语言 | 整体/人物/场景/道具 Prompt、JSON 字符串数组硬约束、锁定、风格图片记录 |
| SubjectIdentity | 同一主体跨形态不变的特征 | 稳定身份描述、身份 Prompt、锁定、单个及批量 AI 文本生成 | | SubjectIdentity | 同一主体跨形态不变的特征 | 稳定身份描述、身份 Prompt、锁定、单个及批量 AI 文本生成 |
| Identity Image | 固定主体身份,人物主要固定脸部与体型 | primary、front、three-quarter、full-body 四类生图、身份锚定来源、母版选择 | | Character Casting | 确认具体演员身份 | 候选图、人工选择 Anchor、Identity 原子锁定、项目就绪检查 |
| Identity Image | 固定主体身份或补充辅助视角 | Scene / Prop 母版及 Character front、three-quarter、full-body 辅助图 |
| SubjectForm Image | 某个具体造型或状态的图片 | 原有单图/批量生图与主图选择;人物/场景图自动引用当前身份母版 | | SubjectForm Image | 某个具体造型或状态的图片 | 原有单图/批量生图与主图选择;人物/场景图自动引用当前身份母版 |
1. 风格与身份的 `isLocked` 只禁止 AI 自动覆盖文本。已锁定记录仍可人工编辑并保存;身份生图和母版切换也不受文字锁定限制。需要 AI 重生成时,先取消锁定并保存 1. 风格锁定仍用于阻止 AI 覆盖。Character 的 `isLocked` 同时是正式选角前置条件:必须具有 Identity、已确认的 primary Anchor 并锁定,Keyframe 才会就绪;确认选角接口会原子完成后两项
2. 身份 AI 生成需要已创建 VisualStyle;身份图生成还需要已保存的非空 `generationPrompt`。不存在身份时不调用会报错的图片 GET。404 或网络失败不会被当作“尚未创建”。 2. 身份 AI 生成需要已创建 VisualStyle;身份图生成还需要已保存的非空 `generationPrompt`。不存在身份时不调用会报错的图片 GET。404 或网络失败不会被当作“尚未创建”。
3. 后端暂无全项目主体/身份的 GET。前端正式 `subject-forms` 目录`subjectId` 去重,选中主体后按需读取身份和专用图库;无形态主体暂不在目录展示。批量仍由后端处理项目全部主体,可能多于目录可见数,不受当前筛选影响。未使用 checkpoint 或 `DramaCharacter.id` 拼造 Subject ID。 3. 前端正式 `subject-forms` 目录为基础,并合并 Character Casting readiness,因此尚无形态的角色也可先完成 Identity 和选角。Scene / Prop 若还没有 Form,仍不会凭 checkpoint 伪造正式 Subject ID。
4. 身份文本批量回执显示 `total``targetCount``generated``skipped``skippedLocked``failed` 和逐主体失败原因。它不代表图片已生成,也不是实时任务进度。后端没有批量身份生图接口,前端不自行循环触发有费用的图片请求 4. 身份文本批量分为 Character 专用入口和全部主体高级入口。选角候选批量只处理 `missing_anchor`,受本批上限控制;`candidate_pending` 必须人工确认,不会由批量任务静默决定演员
5. 身份图片的 `isAnchor` 来自专用 GET。`enabled: true` 的正面、侧面或全身图不是母版;只有成功且有地址的 `primary` 图可经 PUT 设为母版。第一张 primary 图自动启用;已有母版后,新 primary 图作为停用候选保留,不静默替换。辅助视图不能直接为母版,但可以作为新 primary 图的参考来源 5. 身份图片的 `isAnchor` 来自专用 GET。Character primary 通过 Casting Candidate 接口生成且默认停用;点击确认演员后才成为 Anchor 并锁定 Identity。辅助视图不能直接为母版。Scene / Prop 保留普通母版切换接口
6. 身份生图不传 `referenceImageId` 时,由后端自动选择当前母版;无母版时按文本生成。指定时只能选择当前身份已完成的图片;候选图即使停用也可作为显式参考。后端没有“有母版但忽略它”的参数。尺寸成对留空或提供正整数。自定义 Prompt 会替换后端编译的完整文本,建议保留默认。 6. 身份生图不传 `referenceImageId` 时,由后端自动选择当前母版;无母版时按文本生成。指定时只能选择当前身份已完成的图片;候选图即使停用也可作为显式参考。后端没有“有母版但忽略它”的参数。尺寸成对留空或提供正整数。自定义 Prompt 会替换后端编译的完整文本,建议保留默认。
7. 目前**人物与场景形态生图**会自动注入当前身份母版,分别固定人物身份和空间骨架;道具暂不自动引用。没有身份或母版时,原形态生图流程仍可使用。已有母版但缺少 Provider 可访问远程地址时,后端会报错;本地 `/storage/` 能显示并不证明原始 Provider URL 仍可访问。前端显示后端错误,不自动重试消耗额度。 7. 目前**人物与场景形态生图**会自动注入当前身份母版,分别固定人物身份和空间骨架;道具暂不自动引用。没有身份或母版时,原形态生图流程仍可使用。已有母版但缺少 Provider 可访问远程地址时,后端会报错;本地 `/storage/` 能显示并不证明原始 Provider URL 仍可访问。前端显示后端错误,不自动重试消耗额度。
8. 修改风格、身份文本或母版,不会自动使旧图失效或重生成。已有形态主图仍会被分镜使用;需要保持新身份时,主动生成候选图并设为形态主图,再刷新分镜参考图,按需覆盖视频提示词。图库详情展示当次 `identityAnchorImageId``referenceImageId`;旧记录没有该字段时明确显示未知,不推断它使用了当前母版。 8. 修改风格、身份文本或母版,不会自动使旧图失效或重生成。已有形态主图仍会被分镜使用;需要保持新身份时,主动生成候选图并设为形态主图,再刷新分镜参考图,按需覆盖视频提示词。图库详情展示当次 `identityAnchorImageId``referenceImageId`;旧记录没有该字段时明确显示未知,不推断它使用了当前母版。
@@ -237,13 +255,14 @@ server {
5. 在可控测试项目中触发故障,确认 retry / resume-shots / resume-storyboard 各自对应正确失败阶段。 5. 在可控测试项目中触发故障,确认 retry / resume-shots / resume-storyboard 各自对应正确失败阶段。
6. 进入分镜设计,确认正式镜头 ID 与 Beat 对应;先生成单集 Direction,再生成 VisualState,检查覆盖率和主体状态。 6. 进入分镜设计,确认正式镜头 ID 与 Beat 对应;先生成单集 Direction,再生成 VisualState,检查覆盖率和主体状态。
7. 用小型测试项目验证批量补齐、跳过完整集、失败诊断和明确覆盖;确认关闭覆盖后不会重生成完整设计。 7. 用小型测试项目验证批量补齐、跳过完整集、失败诊断和明确覆盖;确认关闭覆盖后不会重生成完整设计。
8. 给主体形态准备参考图,读取镜头参考图和 GenerationSpec确认 Prompt 无正文时经确认生成、有正文且不覆盖时复用。不提交实际视频任务 8. 给主体形态准备参考图,读取镜头参考图和 GenerationSpec在镜头生产页核对 Prompt、首帧和视频三类 readiness
9. 打开“形态图片”,检查已有主图自动显示;单个形态新增候选图,查看历史、失败原因和实际 Prompt,再设为主图。 9. 打开“形态图片”,检查已有主图自动显示;单个形态新增候选图,查看历史、失败原因和实际 Prompt,再设为主图。
10. 在小型测试项目中验证批量补齐与“已有主图也新增候选图”;回到分镜刷新参考图,确认使用新选择的主图。 10. 在小型测试项目中验证批量补齐与“已有主图也新增候选图”;回到分镜刷新参考图,确认使用新选择的主图。
11. 进入“视觉风格”,保存或生成风格,检查分类 Prompt 和硬约束;锁定后 AI 入口禁用,人工编辑仍可保存。登记一张已有风格图,检查启停和移除确认。 11. 进入“视觉风格”,保存或生成风格,检查分类 Prompt 和硬约束;锁定后 AI 入口禁用,人工编辑仍可保存。登记一张已有风格图,检查启停和移除确认。
12. 进入“主体身份”,检查同一主体的多形态归并。生成/保存身份文本并锁定,再生成第一张 primary 图;生成第二张后,确认旧母版未被替换 12. 进入“主体身份”,先补齐 Character Identity,再小批量生成选角候选。确认候选不会自动成为 Anchor,人工确认演员后 Identity 同时锁定且 readiness 变为 ready
13.同一主体选择参考来源并生成 front / three-quarter / full-body 图;确认辅助图不能直接设为母版。将第二张 primary 候选设为母版后,检查旧母版停用且保留 13.已确认演员生成 front / three-quarter / full-body 辅助图;确认辅助图不能直接设为母版。角色需要更多演员候选时使用单角色候选入口
14. 对人物和场景形态分别新增图片,在图片详情检查当次母版 ID;旧主图应保持不变,手动切换后再刷新分镜参考图。道具不应显示自动继承母版已生效。 14. 对人物和场景形态分别新增图片,在图片详情检查当次母版 ID;旧主图应保持不变,手动切换后再刷新分镜参考图。道具不应显示自动继承母版已生效。
15. 在镜头生产页生成单镜头首帧,确认规格同时列出 `identity-anchor``form-primary`。切换主首帧后再创建 Seedance 任务,等待完成后播放并选择主视频。
本轮新增测试覆盖风格 JSON/锁定/图片 DELETE、身份归并/前置条件/尺寸/显式参考图/母版切换、部分失败、旧响应、草稿保护与费用确认。模型调用均使用模拟 API。当前执行环境未能通过浏览器访问本地开发服务,**未完成浏览器视觉验收及真实模型联调**,请按上述步骤在本地验收。 本轮新增测试覆盖风格 JSON/锁定/图片 DELETE、身份归并/前置条件/尺寸/显式参考图/母版切换、部分失败、旧响应、草稿保护与费用确认。模型调用均使用模拟 API。当前执行环境未能通过浏览器访问本地开发服务,**未完成浏览器视觉验收及真实模型联调**,请按上述步骤在本地验收。
@@ -75,6 +75,12 @@ const videoRunning = computed(
(query.data.value?.videoStatus.queued ?? 0) + (query.data.value?.videoStatus.queued ?? 0) +
(query.data.value?.videoStatus.running ?? 0) (query.data.value?.videoStatus.running ?? 0)
) )
const identityBlocked = computed(
() =>
(query.data.value?.keyframes.missingIdentity ?? 0) +
(query.data.value?.keyframes.missingIdentityAnchor ?? 0) +
(query.data.value?.keyframes.identityUnlocked ?? 0)
)
/** 父页查询包含整个项目的就绪统计,单镜头变更后立即刷新。 */ /** 父页查询包含整个项目的就绪统计,单镜头变更后立即刷新。 */
function refreshProject() { function refreshProject() {
@@ -149,6 +155,11 @@ function refreshProject() {
<p v-if="videoRunning" class="alert mt-4" role="status"> <p v-if="videoRunning" class="alert mt-4" role="status">
当前有 {{ videoRunning }} 个视频任务等待或生成中后端正在轮询 Provider同一镜头不会重复提交活动任务 当前有 {{ videoRunning }} 个视频任务等待或生成中后端正在轮询 Provider同一镜头不会重复提交活动任务
</p> </p>
<p v-if="identityBlocked" class="alert mt-4">
当前有 {{ identityBlocked }} 项角色身份前置问题Character 必须生成
Identity确认演员母版并锁定后首帧才能就绪
<RouterLink :to="`/projects/${id}/subject-identity`" class="text-button ml-2">前往角色选角 </RouterLink>
</p>
<ProductionReceipt v-if="session.receipt" :receipt="session.receipt" /> <ProductionReceipt v-if="session.receipt" :receipt="session.receipt" />
<div v-if="query.data.value" class="production-pipeline mt-5"> <div v-if="query.data.value" class="production-pipeline mt-5">
@@ -191,6 +202,11 @@ function refreshProject() {
<dd>{{ stage.data?.blocked ?? 0 }}</dd> <dd>{{ stage.data?.blocked ?? 0 }}</dd>
</div> </div>
</dl> </dl>
<p v-if="stage.key === 'keyframes' && identityBlocked" class="mt-3 text-[11px] leading-5 text-danger">
身份缺失 {{ query.data.value?.keyframes.missingIdentity ?? 0 }} · 母版缺失
{{ query.data.value?.keyframes.missingIdentityAnchor ?? 0 }} · 未锁定
{{ query.data.value?.keyframes.identityUnlocked ?? 0 }}
</p>
<ConfirmAction <ConfirmAction
class="mt-4" class="mt-4"
:label="stage.action" :label="stage.action"
@@ -387,6 +387,16 @@ async function inspectSpecs() {
<div v-if="keyframeSpec"> <div v-if="keyframeSpec">
<h5 class="font-medium">首帧 Prompt · {{ keyframeSpec.references.length }} 张参考图</h5> <h5 class="font-medium">首帧 Prompt · {{ keyframeSpec.references.length }} 张参考图</h5>
<p class="mt-3 whitespace-pre-wrap leading-6 text-muted">{{ keyframeSpec.prompt }}</p> <p class="mt-3 whitespace-pre-wrap leading-6 text-muted">{{ keyframeSpec.prompt }}</p>
<ul v-if="keyframeSpec.references.length" class="mt-3 space-y-1 text-[11px] text-muted">
<li
v-for="reference in keyframeSpec.references"
:key="`${reference.role}:${reference.imageId}`"
>
{{ reference.subjectRef }} ·
{{ reference.role === 'identity-anchor' ? '演员身份母版' : '形态主图' }} ·
<code>{{ reference.imageId }}</code>
</li>
</ul>
</div> </div>
<div v-if="videoSpec"> <div v-if="videoSpec">
<h5 class="font-medium">视频 Prompt · {{ videoSpec.durationSeconds }}s</h5> <h5 class="font-medium">视频 Prompt · {{ videoSpec.durationSeconds }}s</h5>
+3
View File
@@ -43,6 +43,9 @@ export function issueLabel(code: ProductionIssueCode): string {
invalid_generation_spec: '生成规格不完整', invalid_generation_spec: '生成规格不完整',
missing_visual_style: '缺少视觉风格', missing_visual_style: '缺少视觉风格',
missing_reference: '缺少主体参考图', missing_reference: '缺少主体参考图',
missing_identity: '缺少角色身份',
missing_identity_anchor: '缺少演员母版',
identity_unlocked: '演员身份未锁定',
missing_prompt: '缺少视频提示词', missing_prompt: '缺少视频提示词',
missing_keyframe: '缺少主首帧' missing_keyframe: '缺少主首帧'
} }
@@ -51,6 +51,8 @@ describe('镜头生产数据契约', () => {
it('就绪问题和异步任务状态提供中文标签', () => { it('就绪问题和异步任务状态提供中文标签', () => {
expect(issueLabel('missing_keyframe')).toBe('缺少主首帧') expect(issueLabel('missing_keyframe')).toBe('缺少主首帧')
expect(issueLabel('invalid_generation_spec')).toBe('生成规格不完整') expect(issueLabel('invalid_generation_spec')).toBe('生成规格不完整')
expect(issueLabel('missing_identity_anchor')).toBe('缺少演员母版')
expect(issueLabel('identity_unlocked')).toBe('演员身份未锁定')
expect(productionStatusLabel('in_progress')).toBe('任务进行中') expect(productionStatusLabel('in_progress')).toBe('任务进行中')
expect(productionStatusLabel('unknown')).toBe('unknown') expect(productionStatusLabel('unknown')).toBe('unknown')
}) })
+18 -2
View File
@@ -6,6 +6,9 @@ export type ProductionIssueCode =
| 'invalid_generation_spec' | 'invalid_generation_spec'
| 'missing_visual_style' | 'missing_visual_style'
| 'missing_reference' | 'missing_reference'
| 'missing_identity'
| 'missing_identity_anchor'
| 'identity_unlocked'
| 'missing_prompt' | 'missing_prompt'
| 'missing_keyframe' | 'missing_keyframe'
@@ -37,12 +40,17 @@ export interface PromptReadiness {
/** 镜头首帧就绪检查的逐镜结果。 */ /** 镜头首帧就绪检查的逐镜结果。 */
export interface KeyframeReadinessItem extends PromptReadinessItem { export interface KeyframeReadinessItem extends PromptReadinessItem {
episodeNo: number
beatNo: number
primaryKeyframeId?: string | null primaryKeyframeId?: string | null
} }
/** 镜头首帧项目级就绪汇总。 */ /** 镜头首帧项目级就绪汇总。 */
export interface KeyframeReadiness extends PromptReadiness { export interface KeyframeReadiness extends PromptReadiness {
missingVisualStyle: number missingVisualStyle: number
missingIdentity: number
missingIdentityAnchor: number
identityUnlocked: number
items: KeyframeReadinessItem[] items: KeyframeReadinessItem[]
} }
@@ -111,7 +119,8 @@ export interface KeyframeReference {
subjectId: string subjectId: string
subjectRef: string subjectRef: string
module: string module: string
subjectFormId: string role: 'identity-anchor' | 'form-primary'
subjectFormId?: string
imageId: string imageId: string
imageUrl: string imageUrl: string
providerImageUrl?: string providerImageUrl?: string
@@ -129,9 +138,16 @@ export interface KeyframeSpec {
} }
/** 视频生成规格中的主体参考图。 */ /** 视频生成规格中的主体参考图。 */
export interface VideoReference extends KeyframeReference { export interface VideoReference {
subjectId: string
subjectRef: string
subjectName: string subjectName: string
module: string
subjectFormId: string
subjectFormName: string subjectFormName: string
imageId: string
imageUrl: string
providerImageUrl?: string
} }
/** 后端实际提交 Seedance 前编译的完整视频生成规格。 */ /** 后端实际提交 Seedance 前编译的完整视频生成规格。 */
@@ -174,8 +174,8 @@ function exportResult(kind: 'spec' | 'prompt') {
</button> </button>
</div> </div>
<p class="mt-2 text-xs leading-6 text-muted"> <p class="mt-2 text-xs leading-6 text-muted">
后端合并主体形态摄影设计与即时状态编译为 GenerationSpec不调用模型需要本镜头已有 Direction 后端合并主体稳定身份具体形态摄影设计与即时状态编译为 GenerationSpec不调用模型需要本镜头已有
VisualState Direction VisualState
</p> </p>
<p v-if="errors.spec" class="alert alert-error mt-3" role="alert">{{ errors.spec }}</p> <p v-if="errors.spec" class="alert alert-error mt-3" role="alert">{{ errors.spec }}</p>
<template v-if="spec" <template v-if="spec"
@@ -190,8 +190,8 @@ function exportResult(kind: 'spec' | 'prompt') {
<section class="border-t border-line pt-6"> <section class="border-t border-line pt-6">
<h3 class="text-sm font-semibold">视频提示词</h3> <h3 class="text-sm font-semibold">视频提示词</h3>
<p class="mt-2 text-xs leading-6 text-muted"> <p class="mt-2 text-xs leading-6 text-muted">
已有提示词直接读取没有则调用模型生成当前后端仍使用镜头描述和参考图生成 Prompt尚未接入上方 已有提示词直接复用没有则依据 GenerationSpec 与参考图调用模型生成此处只处理
GenerationSpec此操作不会生成视频 Prompt首帧和实际视频任务请到镜头生产
</p> </p>
<div class="mt-4 flex flex-wrap items-center gap-4"> <div class="mt-4 flex flex-wrap items-center gap-4">
<ConfirmAction <ConfirmAction
@@ -6,6 +6,8 @@ export interface ShotGenerationSubjectSpec {
subjectRef: string subjectRef: string
subjectName: string subjectName: string
module: string module: string
/** 跨形态稳定身份;Character 首帧会据此校验选角前置条件。 */
identityDescription: string
subjectFormId: string subjectFormId: string
subjectFormName: string subjectFormName: string
appearancePrompt: string appearancePrompt: string
@@ -1,14 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { AssetImage, EmptyState } from '../../components/ui' import { UserCheck } from '@lucide/vue'
import { AssetImage, EmptyState, StatusBadge } 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'
import { currentAnchor } from './model' import { castingStatusLabel, currentAnchor } from './model'
import { useSubjectIdentity } from './useSubjectIdentity' import { useSubjectIdentity } from './useSubjectIdentity'
import IdentityEditor from './components/IdentityEditor.vue' import IdentityEditor from './components/IdentityEditor.vue'
import IdentityGallery from './components/IdentityGallery.vue' import IdentityGallery from './components/IdentityGallery.vue'
import IdentityImageDialog from './components/IdentityImageDialog.vue' import IdentityImageDialog from './components/IdentityImageDialog.vue'
import CastingCandidateDialog from './components/CastingCandidateDialog.vue'
/** 主体身份工作区按正式主体聚合,不把同一主体的多个 Form 当成不同身份。 */ /** 主体身份工作区按正式主体聚合,不把同一主体的多个 Form 当成不同身份。 */
const route = useRoute() const route = useRoute()
@@ -19,32 +21,46 @@ const {
subject, subject,
subjects, subjects,
catalog, catalog,
castingQuery,
styleQuery, styleQuery,
detail, detail,
identity, identity,
images, images,
castingItem,
session, session,
concurrency, concurrency,
candidateLimit,
force, force,
editorRevision, editorRevision,
dirty, dirty,
blocked, blocked,
castingBlocked,
detailBlocked, detailBlocked,
hasStyle, hasStyle,
canGenerateText, canGenerateText,
canGenerateImage, canGenerateImage,
canGenerateCandidate,
batchValid, batchValid,
candidateLimitValid,
save, save,
generate, generate,
generateProject, generateProject,
generateCastingCandidates,
generateImage, generateImage,
setAnchor generateCastingCandidate,
setAnchor,
confirmCasting
} = useSubjectIdentity() } = useSubjectIdentity()
const search = ref('') const search = ref('')
const module = ref('all') const module = ref('all')
const imageOpen = ref(false) const imageOpen = ref(false)
const castingOpen = ref(false)
const pendingSelection = ref('') const pendingSelection = ref('')
const anchor = computed(() => currentAnchor(images.value)) const anchor = computed(() => currentAnchor(images.value))
const casting = computed(() => {
const value = castingQuery.data.value
return value && Array.isArray(value.items) ? value : null
})
const filtered = computed(() => const filtered = computed(() =>
subjects.value.filter( subjects.value.filter(
item => item =>
@@ -66,6 +82,7 @@ watch(
) )
watch(selectedId, () => { watch(selectedId, () => {
imageOpen.value = false imageOpen.value = false
castingOpen.value = false
pendingSelection.value = '' pendingSelection.value = ''
}) })
@@ -87,6 +104,17 @@ function discardAndSelect() {
function exportReceipt() { function exportReceipt() {
downloadText('subject-identities-receipt.json', JSON.stringify(session.value.receipt, null, 2), 'application/json') downloadText('subject-identities-receipt.json', JSON.stringify(session.value.receipt, null, 2), 'application/json')
} }
/** 目录刷新同时读取正式形态和 Character Casting,避免无形态角色状态滞后。 */
function refreshDirectory() {
void Promise.all([catalog.refresh(), castingQuery.refresh()])
}
/** Character 使用确认选角事务;Scene / Prop 保留普通母版切换。 */
function chooseAnchor(imageId: string) {
if (subject.value?.module === 'character') void confirmCasting(imageId)
else void setAnchor(imageId)
}
</script> </script>
<template> <template>
@@ -101,10 +129,16 @@ function exportReceipt() {
> >
</div> </div>
<p class="alert mt-5 text-xs"> <p class="alert mt-5 text-xs">
人物与场景形态图在有身份母版时自动引用分别保持人物身份与空间结构没有母版仍可按原逻辑生图道具暂不自动引用旧图不会因母版切换而自动更新 Character 现在需要依次完成身份文本选角候选确认演员并锁定
Identity之后首帧才能就绪场景和道具仍使用普通身份母版流程旧形态图首帧与视频不会因母版切换自动更新
</p> </p>
<p v-if="catalog.error.value || styleQuery.error.value" class="alert alert-error mt-4" role="alert"> <p
{{ catalog.error.value || styleQuery.error.value }} 当前操作已暂停请刷新核对后端 v-if="catalog.error.value || styleQuery.error.value || castingQuery.error.value"
class="alert alert-error mt-4"
role="alert"
>
{{ catalog.error.value || styleQuery.error.value || castingQuery.error.value }}
当前相关操作已暂停请刷新核对后端
</p> </p>
<p v-if="styleQuery.data.value && !hasStyle" class="alert mt-4"> <p v-if="styleQuery.data.value && !hasStyle" class="alert mt-4">
先创建项目视觉风格才能 AI 生成身份或身份图片<RouterLink 先创建项目视觉风格才能 AI 生成身份或身份图片<RouterLink
@@ -113,55 +147,141 @@ function exportReceipt() {
>设置视觉风格 </RouterLink >设置视觉风格 </RouterLink
> >
</p> </p>
<details class="panel my-5 p-5"> <section v-if="casting" class="panel my-5 p-5" aria-label="角色选角流程">
<summary class="cursor-pointer text-sm font-medium">项目批量生成身份文本</summary> <div class="flex flex-wrap items-start justify-between gap-4">
<div class="mt-4 flex flex-wrap items-end gap-4"> <div class="flex items-start gap-3">
<label class="w-28" <span class="production-step"><UserCheck :size="17" /></span>
><span class="field-label">并发数</span <div>
><input <h3 class="text-sm font-semibold">角色选角</h3>
<p class="mt-2 text-xs leading-6 text-muted">
只处理 Character候选图不会自动成为演员确认选择时才会同时切换 Anchor 并锁定 Identity
</p>
</div>
</div>
<span class="font-mono text-xs text-muted">{{ casting.ready }} / {{ casting.total }} 已完成</span>
</div>
<dl class="casting-stats mt-4">
<div>
<dt>缺身份文本</dt>
<dd>{{ casting.missingIdentity }}</dd>
</div>
<div>
<dt>缺候选图</dt>
<dd>{{ casting.missingAnchor }}</dd>
</div>
<div>
<dt>待确认演员</dt>
<dd>{{ casting.candidatePending }}</dd>
</div>
<div>
<dt>母版未锁定</dt>
<dd>{{ casting.unlocked }}</dd>
</div>
<div>
<dt>选角完成</dt>
<dd>{{ casting.ready }}</dd>
</div>
</dl>
<div class="mt-5 flex flex-wrap items-end gap-4 border-t border-line pt-4">
<label class="w-28">
<span class="field-label">并发数</span>
<input
v-model.number="concurrency" v-model.number="concurrency"
type="number" type="number"
min="1" min="1"
step="1" step="1"
class="input" class="input"
:disabled="operation.pending" :disabled="operation.pending"
aria-label="身份批量并发" />
/></label> </label>
<label class="flex gap-2 pb-2 text-xs" <label class="w-28">
><input v-model="force" type="checkbox" :disabled="operation.pending" />覆盖未锁定的已有身份</label <span class="field-label">本批上限</span>
> <input
v-model.number="candidateLimit"
type="number"
min="1"
step="1"
class="input"
:disabled="operation.pending"
/>
</label>
<label class="flex gap-2 pb-2 text-xs">
<input v-model="force" type="checkbox" :disabled="operation.pending" />覆盖未锁定身份文本
</label>
<ConfirmAction <ConfirmAction
:label="force ? '重生成未锁定身份' : '补齐项目身份文本'" :label="force ? '重生成角色身份文本' : '补齐角色身份文本'"
:disabled="blocked || !hasStyle || !batchValid || dirty || !subjects.length" :disabled="blocked || !hasStyle || !batchValid || dirty || !casting.total"
acknowledgement acknowledgement
description="只生成身份描述与提示词,不生成图片。操作面向整个项目,不受筛选影响;已锁定身份始终跳过覆盖文本不会自动更新旧身份图与形态图。" description="只生成 Character Identity 文本,不生成图片。已锁定角色始终跳过覆盖文本不会自动更新已有候选图。"
@confirm="generateProject" @confirm="generateProject(true)"
/>
<ConfirmAction
label="生成选角候选"
:disabled="
castingBlocked || !hasStyle || !batchValid || !candidateLimitValid || !casting.missingAnchor
"
acknowledgement
description="最多按本批上限为缺少 Anchor 的 Character 调用 Seedream,各生成一张停用的 primary 候选。已有候选、已有母版或已完成选角的角色不会重复处理。"
@confirm="generateCastingCandidates"
/> />
</div> </div>
<p v-if="!batchValid" class="mt-2 text-xs text-danger">并发必须是正整数</p> <p v-if="!batchValid || !candidateLimitValid" class="mt-3 text-xs text-danger">
<p class="mt-3 text-xs text-muted"> 并发数与本批上限都必须是正整数
后端没有全项目身份查询接口左侧从正式形态目录归并主体点击后读取其身份无形态主体暂不在目录展示但后端批量会处理项目全部主体
</p> </p>
<div class="casting-list mt-5">
<button
v-for="item in casting.items"
:key="item.subjectId"
class="casting-item"
:class="{ selected: selectedId === item.subjectId }"
@click="select(item.subjectId)"
>
<span class="min-w-0">
<span class="block truncate text-xs font-medium">{{ item.subjectName }}</span>
<span class="mt-1 block font-mono text-[10px] text-muted">{{ item.subjectRef }}</span>
</span>
<StatusBadge :status="item.status" :label="castingStatusLabel(item.status)" />
</button>
</div>
</section>
<details class="panel mb-5 p-5">
<summary class="cursor-pointer text-sm font-medium">高级全部主体身份文本</summary>
<p class="mt-3 text-xs leading-6 text-muted">
同时处理 CharacterScene Prop角色选角建议优先使用上方专用入口已锁定身份始终跳过
</p>
<ConfirmAction
class="mt-4"
:label="force ? '重生成全部未锁定身份' : '补齐全部主体身份文本'"
:disabled="blocked || !hasStyle || !batchValid || dirty || !subjects.length"
acknowledgement
description="生成全项目主体身份描述与提示词,不生成图片。覆盖不会自动更新旧身份图、形态图、首帧或视频。"
@confirm="generateProject(false)"
/>
</details> </details>
<section v-if="session.receipt" class="panel mb-5 p-5" aria-label="身份文本批量回执"> <section v-if="session.receipt" class="panel mb-5 p-5" aria-label="身份文本批量回执">
<div class="flex justify-between gap-3"> <div class="flex justify-between gap-3">
<h3 class="text-sm font-medium">身份文本批量回执</h3> <h3 class="text-sm font-medium">{{ session.receipt.title }} · 本次回执</h3>
<button class="text-button" @click="exportReceipt">导出诊断</button> <button class="text-button" @click="exportReceipt">导出诊断</button>
</div> </div>
<p class="mt-3 text-xs text-muted"> <p v-if="session.receipt.kind === 'identities'" class="mt-3 text-xs text-muted">
{{ session.receipt.total }} 个主体 · 目标 {{ session.receipt.targetCount }} · 生成 {{ session.receipt.result.total }} 个主体 · 目标 {{ session.receipt.result.targetCount }} · 生成
{{ session.receipt.generated }} · 跳过 {{ session.receipt.skipped }}含锁定 {{ session.receipt.result.generated }} · 跳过 {{ session.receipt.result.skipped }}含锁定
{{ session.receipt.skippedLocked }}· 失败 {{ session.receipt.failed }} {{ session.receipt.result.skippedLocked }}· 失败 {{ session.receipt.result.failed }}
</p> </p>
<p v-if="session.receipt.failed" class="alert alert-error mt-3" role="alert"> <p v-else class="mt-3 text-xs text-muted">
部分主体生成失败已成功结果保留请检查失败原因再补齐或单独生成 {{ session.receipt.result.totalCharacters }} 个角色 · 缺候选
{{ session.receipt.result.missingAnchor }} · 本批目标 {{ session.receipt.result.targetCount }} · 已生成
{{ session.receipt.result.generated }} · 失败 {{ session.receipt.result.failed }}
</p>
<p v-if="session.receipt.result.failed" class="alert alert-error mt-3" role="alert">
本次存在部分失败成功结果不会回滚请检查逐角色原因后再重试
</p> </p>
<ul class="mt-3 space-y-2 text-xs text-danger"> <ul class="mt-3 space-y-2 text-xs text-danger">
<li v-for="failure in session.receipt.failures" :key="failure.subjectId"> <li v-for="failure in session.receipt.result.failures" :key="failure.subjectId">
{{ failure.subjectRef }} · {{ failure.subjectId }}{{ failure.error }} {{ failure.subjectRef }} · {{ failure.subjectId }}{{ failure.error }}
</li> </li>
</ul> </ul>
<p class="mt-3 text-[11px] text-muted">回执仅保留于当前浏览器会话不代表图片已生成</p> <p class="mt-3 text-[11px] text-muted">回执仅保留于当前浏览器会话最终状态以刷新后的正式记录为准</p>
</section> </section>
<div v-if="subjects.length" class="identity-workspace"> <div v-if="subjects.length" class="identity-workspace">
<aside class="panel min-w-0 p-4"> <aside class="panel min-w-0 p-4">
@@ -186,12 +306,23 @@ function exportReceipt() {
> >
<span class="block font-medium">{{ item.name }}</span <span class="block font-medium">{{ item.name }}</span
><span class="mt-1 block text-[11px] text-muted" ><span class="mt-1 block text-[11px] text-muted"
>{{ item.ref }} · {{ item.forms.length }} 个形态</span >{{ item.ref }} · {{ item.forms.length }} 个形态<span
v-if="casting?.items.find(row => row.subjectId === item.id)"
>
·
{{
castingStatusLabel(casting.items.find(row => row.subjectId === item.id)!.status)
}}</span
></span
> >
</button> </button>
<p v-if="!filtered.length" class="py-6 text-xs text-muted">没有匹配主体请调整筛选</p> <p v-if="!filtered.length" class="py-6 text-xs text-muted">没有匹配主体请调整筛选</p>
</div> </div>
<button class="text-button mt-4" :disabled="catalog.loading.value" @click="catalog.refresh"> <button
class="text-button mt-4"
:disabled="catalog.loading.value || castingQuery.loading.value"
@click="refreshDirectory"
>
刷新主体目录 刷新主体目录
</button> </button>
</aside> </aside>
@@ -210,13 +341,18 @@ function exportReceipt() {
{{ subject.name }} <code class="subject-ref ml-2">{{ subject.ref }}</code> {{ subject.name }} <code class="subject-ref ml-2">{{ subject.ref }}</code>
</h3> </h3>
<p class="mt-2 text-xs text-muted"> <p class="mt-2 text-xs text-muted">
{{ <template v-if="subject.module === 'character' && castingItem">
identity 选角状态{{ castingStatusLabel(castingItem.status) }}
? identity.isLocked </template>
? '身份文本已锁定' <template v-else>
: '身份文本未锁定' {{
: '尚未创建身份' identity
}} ? identity.isLocked
? '身份文本已锁定'
: '身份文本未锁定'
: '尚未创建身份'
}}
</template>
</p> </p>
</div> </div>
<button class="text-button" :disabled="detail.loading.value" @click="detail.refresh"> <button class="text-button" :disabled="detail.loading.value" @click="detail.refresh">
@@ -238,11 +374,19 @@ function exportReceipt() {
description="调用文本模型生成稳定身份描述和提示词。旧图片不会自动重生成;已锁定身份需先解锁并保存。" description="调用文本模型生成稳定身份描述和提示词。旧图片不会自动重生成;已锁定身份需先解锁并保存。"
@confirm="generate" @confirm="generate"
/><button /><button
v-if="subject.module === 'character'"
class="button button-secondary"
:disabled="!canGenerateCandidate"
@click="castingOpen = true"
>
生成选角候选</button
><button
v-if="subject.module !== 'character' || !!anchor"
class="button button-secondary" class="button button-secondary"
:disabled="!canGenerateImage" :disabled="!canGenerateImage"
@click="imageOpen = true" @click="imageOpen = true"
> >
生成身份参考图 {{ subject.module === 'character' ? '生成辅助身份图' : '生成身份参考图' }}
</button> </button>
</div> </div>
<p v-if="dirty" class="mt-3 text-xs text-muted">请先保存身份修改再使用 AI 或生成图片</p> <p v-if="dirty" class="mt-3 text-xs text-muted">请先保存身份修改再使用 AI 或生成图片</p>
@@ -264,9 +408,13 @@ function exportReceipt() {
<p class="text-sm font-medium">当前身份母版</p> <p class="text-sm font-medium">当前身份母版</p>
<p class="mt-2 text-xs text-muted"> <p class="mt-2 text-xs text-muted">
{{ {{
subject.module === 'prop' subject.module === 'character'
? '道具形态生图暂不自动引用此母版。' ? identity?.isLocked
: '后续人物/场景形态图会继承此图的身份或空间结构。' ? '演员身份已确认并锁定,可用于形态图与镜头首帧。'
: '已有演员母版但 Identity 尚未锁定,镜头首帧仍会被阻塞。'
: subject.module === 'prop'
? '道具形态生图暂不自动引用此母版。'
: '后续场景形态图会继承此图的空间结构。'
}} }}
</p> </p>
</div> </div>
@@ -275,8 +423,10 @@ function exportReceipt() {
:key="selectedId" :key="selectedId"
:images="images" :images="images"
:subject-name="subject.name" :subject-name="subject.name"
:disabled="detailBlocked" :module="subject.module"
@anchor="setAnchor" :identity-locked="!!identity?.isLocked"
:disabled="detailBlocked || (subject.module === 'character' && !!castingQuery.error.value)"
@anchor="chooseAnchor"
/> />
</template> </template>
<div class="mt-5 border-t border-line pt-5"> <div class="mt-5 border-t border-line pt-5">
@@ -286,6 +436,9 @@ function exportReceipt() {
>{{ form.name }}{{ form.isDefault ? ' · 默认' : '' }}</span >{{ form.name }}{{ form.isDefault ? ' · 默认' : '' }}</span
> >
</div> </div>
<p v-if="!subject.forms.length" class="mt-3 text-xs text-muted">
此角色尚无正式形态但可以先完成 Identity 与选角
</p>
<RouterLink <RouterLink
:to="{ path: `/projects/${projectId}/subject-images`, query: { subjectRef: subject.ref } }" :to="{ path: `/projects/${projectId}/subject-images`, query: { subjectRef: subject.ref } }"
class="text-button mt-3" class="text-button mt-3"
@@ -309,9 +462,18 @@ function exportReceipt() {
v-model:open="imageOpen" v-model:open="imageOpen"
:subject-id="subject.id" :subject-id="subject.id"
:subject-name="subject.name" :subject-name="subject.name"
:module="subject.module"
:images="images" :images="images"
:disabled="!canGenerateImage" :disabled="!canGenerateImage"
@generate="generateImage" @generate="generateImage"
/> />
<CastingCandidateDialog
v-if="subject?.module === 'character'"
v-model:open="castingOpen"
:subject-id="subject.id"
:subject-name="subject.name"
:disabled="!canGenerateCandidate"
@generate="generateCastingCandidate"
/>
</section> </section>
</template> </template>
+30
View File
@@ -1,5 +1,10 @@
import { request } from '../../lib/http' import { request } from '../../lib/http'
import type { import type {
CastingBatchResult,
CharacterCastingReadiness,
ConfirmCastingResult,
GenerateCastingCandidateInput,
GenerateCastingCandidatesInput,
GenerateIdentityImageInput, GenerateIdentityImageInput,
IdentityBatchResult, IdentityBatchResult,
IdentityImage, IdentityImage,
@@ -30,6 +35,27 @@ export const subjectIdentityApi = {
body: input, body: input,
timeoutMs: 0 timeoutMs: 0
}), }),
generateCharacters: (projectId: string, input: { force: boolean; concurrency: number }) =>
request<IdentityBatchResult>(`/projects/${encodeURIComponent(projectId)}/character-identities/generate`, {
method: 'POST',
body: input,
timeoutMs: 0
}),
castingReadiness: (projectId: string, signal?: AbortSignal) =>
request<CharacterCastingReadiness>(`/projects/${encodeURIComponent(projectId)}/character-casting/readiness`, {
signal
}),
generateCastingCandidate: (subjectId: string, input: GenerateCastingCandidateInput) =>
request<IdentityImage>(`${identityPath(subjectId)}/casting-candidates`, {
method: 'POST',
body: input,
timeoutMs: 0
}),
generateCastingCandidates: (projectId: string, input: GenerateCastingCandidatesInput) =>
request<CastingBatchResult>(
`/projects/${encodeURIComponent(projectId)}/character-casting/candidates/generate`,
{ method: 'POST', body: input, timeoutMs: 0 }
),
listImages: (subjectId: string, signal?: AbortSignal) => listImages: (subjectId: string, signal?: AbortSignal) =>
request<IdentityImage[]>(`${identityPath(subjectId)}/images`, { signal }), request<IdentityImage[]>(`${identityPath(subjectId)}/images`, { signal }),
generateImage: (subjectId: string, input: GenerateIdentityImageInput) => generateImage: (subjectId: string, input: GenerateIdentityImageInput) =>
@@ -37,5 +63,9 @@ export const subjectIdentityApi = {
setAnchor: (subjectId: string, imageId: string) => setAnchor: (subjectId: string, imageId: string) =>
request<IdentityImage>(`${identityPath(subjectId)}/images/${encodeURIComponent(imageId)}/anchor`, { request<IdentityImage>(`${identityPath(subjectId)}/images/${encodeURIComponent(imageId)}/anchor`, {
method: 'PUT' method: 'PUT'
}),
confirmCasting: (subjectId: string, imageId: string) =>
request<ConfirmCastingResult>(`${identityPath(subjectId)}/images/${encodeURIComponent(imageId)}/casting`, {
method: 'PUT'
}) })
} }
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { AppDialog } from '../../../components/ui'
import { validImageSize } from '../../subject-images/model'
import type { GenerateCastingCandidateInput } from '../types'
/** Character 选角候选表单固定使用 primary,且不会自动选择演员。 */
const props = defineProps<{ subjectId: string; subjectName: string; disabled: boolean }>()
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ generate: [input: GenerateCastingCandidateInput] }>()
const prompt = ref('')
const width = ref<number | ''>('')
const height = ref<number | ''>('')
const acknowledged = ref(false)
const valid = computed(() => validImageSize(width.value, height.value))
/** 每次切换角色或重新打开都清空自定义覆盖与费用确认。 */
function reset() {
prompt.value = ''
width.value = ''
height.value = ''
acknowledged.value = false
}
watch([() => props.subjectId, open], reset)
/** 只发送选角候选接口接受的字段,不携带普通身份图视角参数。 */
function submit() {
if (props.disabled || !valid.value || !acknowledged.value) return
emit('generate', {
provider: 'seedream',
...(prompt.value.trim() ? { prompt: prompt.value.trim() } : {}),
...(width.value !== '' && height.value !== '' ? { width: width.value, height: height.value } : {})
})
open.value = false
}
</script>
<template>
<AppDialog v-model:open="open" title="生成角色选角候选" :description="`${subjectName} · 候选不会自动成为身份母版`">
<form class="mt-5 space-y-4" @submit.prevent="submit">
<p class="alert text-xs">
后端会依据角色 Identity项目视觉风格和默认选角约束生成一张 primary
候选生成完成后仍需人工点击确认选角
</p>
<label class="block">
<span class="field-label">覆盖本次完整提示词可选</span>
<textarea
id="casting-candidate-prompt"
v-model="prompt"
class="input min-h-24"
placeholder="建议留空,使用后端编译的稳定身份和选角约束"
/>
</label>
<p v-if="prompt.trim()" class="text-xs text-danger">
自定义文本会替换后端完整提示词请自行包含人物身份基础服装和中性参考图要求
</p>
<div class="grid grid-cols-2 gap-4">
<label
><span class="field-label">宽度px</span
><input v-model.number="width" class="input" type="number" min="1" placeholder="后端默认"
/></label>
<label
><span class="field-label">高度px</span
><input v-model.number="height" class="input" type="number" min="1" placeholder="后端默认"
/></label>
</div>
<p v-if="!valid" class="text-xs text-danger">宽高需要同时留空或同时填写正整数</p>
<label class="flex items-start gap-2 text-xs leading-6">
<input
id="casting-candidate-cost"
v-model="acknowledged"
type="checkbox"
class="mt-1.5 accent-accent"
/>我已确认调用 Seedream 可能产生费用且当前没有同项目生成任务
</label>
<div class="dialog-footer">
<button type="button" class="button button-secondary" @click="open = false">取消</button>
<button type="submit" class="button button-primary" :disabled="disabled || !valid || !acknowledged">
确认生成候选
</button>
</div>
</form>
</AppDialog>
</template>
@@ -61,12 +61,11 @@ function save() {
这里保存身份事实背景视角和参考图约束由后端另行编译不是最终整段生图 Prompt 这里保存身份事实背景视角和参考图约束由后端另行编译不是最终整段生图 Prompt
</p> </p>
<label class="flex items-start gap-2 text-xs leading-6" <label class="flex items-start gap-2 text-xs leading-6"
><input ><input id="identity-lock" v-model="form.isLocked" type="checkbox" class="mt-1.5 accent-accent" />{{
id="identity-lock" module === 'character'
v-model="form.isLocked" ? '确认并锁定演员身份。Character 必须同时具有身份母版并锁定 Identity,首帧才能进入就绪状态。'
type="checkbox" : '确认并锁定身份文本。锁定后仍可人工保存、生成图片及切换母版。'
class="mt-1.5 accent-accent" }}</label
/>确认并锁定身份文本锁定后仍可人工保存生成图片及切换母版</label
> >
<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">{{
@@ -7,7 +7,13 @@ import { canBeAnchor, currentAnchor, identityViewLabels, readImageProvenance } f
import type { IdentityImage } from '../types' import type { IdentityImage } from '../types'
/** 身份图库区分权威母版、primary 候选和辅助视图,保留后端原始状态。 */ /** 身份图库区分权威母版、primary 候选和辅助视图,保留后端原始状态。 */
const props = defineProps<{ images: IdentityImage[]; subjectName: string; disabled: boolean }>() const props = defineProps<{
images: IdentityImage[]
subjectName: string
module: string
identityLocked: boolean
disabled: boolean
}>()
const emit = defineEmits<{ anchor: [imageId: string] }>() const emit = defineEmits<{ anchor: [imageId: string] }>()
const selectedId = ref('') const selectedId = ref('')
const confirming = ref(false) const confirming = ref(false)
@@ -15,8 +21,13 @@ const anchor = computed(() => currentAnchor(props.images))
const selected = computed( const selected = computed(
() => props.images.find(image => image.id === selectedId.value) ?? anchor.value ?? props.images[0] () => props.images.find(image => image.id === selectedId.value) ?? anchor.value ?? props.images[0]
) )
const character = computed(() => props.module === 'character')
const canSelect = computed( const canSelect = computed(
() => !props.disabled && !!selected.value && canBeAnchor(selected.value) && !selected.value.isAnchor () =>
!props.disabled &&
!!selected.value &&
canBeAnchor(selected.value) &&
(!selected.value.isAnchor || (character.value && !props.identityLocked))
) )
const imageUrl = computed(() => referenceImageUrl(selected.value?.imageUrl ?? '')) const imageUrl = computed(() => referenceImageUrl(selected.value?.imageUrl ?? ''))
watch( watch(
@@ -76,12 +87,12 @@ function chooseAnchor() {
<p v-if="selected.error" class="alert alert-error mt-3" role="alert">{{ selected.error }}</p> <p v-if="selected.error" class="alert alert-error mt-3" role="alert">{{ selected.error }}</p>
<div class="mt-3 flex flex-wrap items-center gap-3"> <div class="mt-3 flex flex-wrap items-center gap-3">
<button <button
v-if="!selected.isAnchor" v-if="!selected.isAnchor || (character && !identityLocked)"
class="button button-secondary" class="button button-secondary"
:disabled="!canSelect" :disabled="!canSelect"
@click="confirming = true" @click="confirming = true"
> >
设为身份母版</button {{ character ? '确认选角并锁定' : '设为身份母版' }}</button
><span class="text-[11px] text-muted" ><span class="text-[11px] text-muted"
>{{ selected.provider }} · {{ selected.model }} · {{ formatDate(selected.createdAt) }}</span >{{ selected.provider }} · {{ selected.model }} · {{ formatDate(selected.createdAt) }}</span
> >
@@ -91,12 +102,15 @@ function chooseAnchor() {
</p> </p>
<div v-if="confirming" class="alert mt-3"> <div v-if="confirming" class="alert mt-3">
<p class="text-xs"> <p class="text-xs">
确认切换身份母版 primary {{
母版将停用并保留后续人物场景形态生图会引用新母版道具暂不自动引用此操作不调用模型 character
? '确认选择这张图片作为正式演员?后端会在同一事务中切换身份母版并锁定 Identity,完成后首帧才具备稳定人物身份。'
: '确认切换身份母版?旧 primary 母版将停用并保留。后续场景形态生图会引用新母版。此操作不调用模型。'
}}
</p> </p>
<div class="mt-3 flex gap-3"> <div class="mt-3 flex gap-3">
<button class="button button-primary" :disabled="!canSelect" @click="chooseAnchor"> <button class="button button-primary" :disabled="!canSelect" @click="chooseAnchor">
确认切换身份母版</button {{ character ? '确认演员选择' : '确认切换身份母版' }}</button
><button class="text-button" @click="confirming = false">取消</button> ><button class="text-button" @click="confirming = false">取消</button>
</div> </div>
</div> </div>
@@ -6,7 +6,13 @@ import { currentAnchor, identityViewLabels } from '../model'
import type { GenerateIdentityImageInput, IdentityImage, IdentityViewType } from '../types' import type { GenerateIdentityImageInput, IdentityImage, IdentityViewType } from '../types'
/** 身份图生成配置;默认自动引用母版,不提供后端不存在的“忽略母版”开关。 */ /** 身份图生成配置;默认自动引用母版,不提供后端不存在的“忽略母版”开关。 */
const props = defineProps<{ subjectId: string; subjectName: string; images: IdentityImage[]; disabled: boolean }>() const props = defineProps<{
subjectId: string
subjectName: string
module?: string
images: IdentityImage[]
disabled: boolean
}>()
const open = defineModel<boolean>('open', { default: false }) const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ generate: [input: GenerateIdentityImageInput] }>() const emit = defineEmits<{ generate: [input: GenerateIdentityImageInput] }>()
const viewType = ref<IdentityViewType>('primary') const viewType = ref<IdentityViewType>('primary')
@@ -16,6 +22,9 @@ const width = ref<number | ''>('')
const height = ref<number | ''>('') const height = ref<number | ''>('')
const acknowledged = ref(false) const acknowledged = ref(false)
const anchor = computed(() => currentAnchor(props.images)) const anchor = computed(() => currentAnchor(props.images))
const allowedViews = computed(() =>
Object.entries(identityViewLabels).filter(([value]) => props.module !== 'character' || value !== 'primary')
)
const references = computed(() => props.images.filter(image => image.status === 'completed' && image.imageUrl)) const references = computed(() => props.images.filter(image => image.status === 'completed' && image.imageUrl))
const valid = computed( const valid = computed(
() => () =>
@@ -25,13 +34,14 @@ const valid = computed(
watch( watch(
() => [open.value, props.subjectId], () => [open.value, props.subjectId],
() => { () => {
viewType.value = 'primary' viewType.value = props.module === 'character' ? 'front' : 'primary'
referenceImageId.value = '' referenceImageId.value = ''
prompt.value = '' prompt.value = ''
width.value = '' width.value = ''
height.value = '' height.value = ''
acknowledged.value = false acknowledged.value = false
} },
{ immediate: true }
) )
/** 提交显式的 Seedream 参数;省略空参考 ID 以保留后端自动选择逻辑。 */ /** 提交显式的 Seedream 参数;省略空参考 ID 以保留后端自动选择逻辑。 */
@@ -58,7 +68,7 @@ function submit() {
<label class="block" <label class="block"
><span class="field-label">参考视角</span ><span class="field-label">参考视角</span
><select id="identity-view" v-model="viewType" class="input"> ><select id="identity-view" v-model="viewType" class="input">
<option v-for="(label, value) in identityViewLabels" :key="value" :value="value"> <option v-for="[value, label] in allowedViews" :key="value" :value="value">
{{ label }}{{ value }} {{ label }}{{ value }}
</option> </option>
</select></label </select></label
+115 -3
View File
@@ -1,8 +1,17 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import IdentityImageDialog from './components/IdentityImageDialog.vue' import IdentityImageDialog from './components/IdentityImageDialog.vue'
import IdentityGallery from './components/IdentityGallery.vue' import IdentityGallery from './components/IdentityGallery.vue'
import { canBeAnchor, currentAnchor, groupIdentitySubjects, readImageProvenance } from './model' import CastingCandidateDialog from './components/CastingCandidateDialog.vue'
import { subjectIdentityApi } from './api'
import {
canBeAnchor,
castingStatusLabel,
currentAnchor,
groupIdentitySubjects,
mergeCastingSubjects,
readImageProvenance
} from './model'
import { identityImageFixture } from './testing/fixtures' import { identityImageFixture } from './testing/fixtures'
import { formFixture } from '../subject-images/testing/fixtures' import { formFixture } from '../subject-images/testing/fixtures'
@@ -11,6 +20,7 @@ afterEach(() => {
wrapper?.unmount() wrapper?.unmount()
wrapper = undefined wrapper = undefined
document.body.innerHTML = '' document.body.innerHTML = ''
vi.unstubAllGlobals()
}) })
/** 从真实 Reka Portal 内获取确认按钮。 */ /** 从真实 Reka Portal 内获取确认按钮。 */
@@ -48,6 +58,29 @@ describe('身份图与母版契约', () => {
}) })
}) })
it('选角就绪结果可以补入尚无形态的角色目录', () => {
const rows = mergeCastingSubjects(
groupIdentitySubjects([formFixture()]),
[
{
subjectId: 'character-without-form',
subjectRef: '@CH0002',
subjectName: '陆川',
status: 'missing_identity',
isLocked: false
}
],
'project-1'
)
expect(rows).toHaveLength(2)
expect(rows.find(item => item.id === 'character-without-form')).toMatchObject({
projectId: 'project-1',
module: 'character',
forms: []
})
expect(castingStatusLabel('candidate_pending')).toBe('等待确认演员')
})
it('辅助视角传递明确参考图、成对尺寸和本次 Prompt,不传形态生图字段', async () => { it('辅助视角传递明确参考图、成对尺寸和本次 Prompt,不传形态生图字段', async () => {
wrapper = mount(IdentityImageDialog, { wrapper = mount(IdentityImageDialog, {
attachTo: document.body, attachTo: document.body,
@@ -107,18 +140,97 @@ describe('身份图与母版契约', () => {
expect(document.querySelector<HTMLInputElement>('#identity-image-cost')!.checked).toBe(false) expect(document.querySelector<HTMLInputElement>('#identity-image-cost')!.checked).toBe(false)
}) })
it('Character 普通身份图入口只提供辅助视角,primary 必须走选角候选接口', async () => {
wrapper = mount(IdentityImageDialog, {
attachTo: document.body,
props: {
open: true,
subjectId: 's1',
subjectName: '林夏',
module: 'character',
images: [identityImageFixture()],
disabled: false
}
})
await flushPromises()
const options = [...document.querySelectorAll<HTMLOptionElement>('#identity-view option')]
expect(options.map(item => item.value)).toEqual(['front', 'three-quarter', 'full-body'])
expect(document.querySelector<HTMLSelectElement>('#identity-view')!.value).toBe('front')
})
it('辅助图不能切换母版,提示词按纯文本展示', async () => { it('辅助图不能切换母版,提示词按纯文本展示', async () => {
wrapper = mount(IdentityGallery, { wrapper = mount(IdentityGallery, {
attachTo: document.body, attachTo: document.body,
props: { props: {
subjectName: '林夏', subjectName: '林夏',
module: 'character',
identityLocked: false,
disabled: false, disabled: false,
images: [identityImageFixture({ viewType: 'front', isAnchor: false })] images: [identityImageFixture({ viewType: 'front', isAnchor: false })]
} }
}) })
expect(button('设为身份母版').disabled).toBe(true) expect(button('确认选角并锁定').disabled).toBe(true)
expect(wrapper.text()).toContain('<script>不执行</script>') expect(wrapper.text()).toContain('<script>不执行</script>')
expect(wrapper.find('script').exists()).toBe(false) expect(wrapper.find('script').exists()).toBe(false)
expect(wrapper.emitted('anchor')).toBeUndefined() expect(wrapper.emitted('anchor')).toBeUndefined()
}) })
it('角色已有母版但未锁定时仍可确认选角,确认文案不冒充普通母版切换', async () => {
wrapper = mount(IdentityGallery, {
attachTo: document.body,
props: {
subjectName: '林夏',
module: 'character',
identityLocked: false,
disabled: false,
images: [identityImageFixture({ isAnchor: true })]
}
})
button('确认选角并锁定').click()
await flushPromises()
expect(document.body.textContent).toContain('同一事务中切换身份母版并锁定 Identity')
button('确认演员选择').click()
expect(wrapper.emitted('anchor')).toEqual([['identity-image-1']])
})
it('选角候选固定发送 Seedream,不携带普通身份图视角字段', async () => {
wrapper = mount(CastingCandidateDialog, {
attachTo: document.body,
props: { open: true, subjectId: 'subject-1', subjectName: '林夏', disabled: false }
})
await flushPromises()
document.querySelector<HTMLInputElement>('#casting-candidate-cost')!.click()
await flushPromises()
button('确认生成候选').click()
expect(wrapper.emitted('generate')).toEqual([[{ provider: 'seedream' }]])
})
it('角色选角接口区分批量身份、候选生图与确认事务', async () => {
const fetcher = vi.fn<typeof fetch>().mockImplementation(
async () =>
new Response(
JSON.stringify({
data: {
total: 0,
targetCount: 0,
generated: 0,
skipped: 0,
skippedLocked: 0,
failed: 0,
failures: []
}
})
)
)
vi.stubGlobal('fetch', fetcher)
await subjectIdentityApi.generateCharacters('project/1', { force: false, concurrency: 2 })
await subjectIdentityApi.generateCastingCandidate('subject/1', { provider: 'seedream' })
await subjectIdentityApi.confirmCasting('subject/1', 'image/1')
expect(fetcher.mock.calls.map(([url]) => url)).toEqual([
'/api/projects/project%2F1/character-identities/generate',
'/api/subjects/subject%2F1/identity/casting-candidates',
'/api/subjects/subject%2F1/identity/images/image%2F1/casting'
])
expect(fetcher.mock.calls.map(([, init]) => init?.method)).toEqual(['POST', 'POST', 'PUT'])
})
}) })
+43 -3
View File
@@ -1,6 +1,12 @@
import { reactive } from 'vue' import { reactive } from 'vue'
import type { SubjectFormAsset } from '../subject-images/types' import type { SubjectFormAsset } from '../subject-images/types'
import type { IdentityBatchResult, IdentityImage, IdentitySubject } from './types' import type {
CharacterCastingItem,
CharacterCastingStatus,
IdentityImage,
IdentityReceipt,
IdentitySubject
} from './types'
/** 视角名称保持与后端四种固定值对应。 */ /** 视角名称保持与后端四种固定值对应。 */
export const identityViewLabels = { export const identityViewLabels = {
@@ -22,6 +28,40 @@ export function groupIdentitySubjects(forms: SubjectFormAsset[]): IdentitySubjec
return [...subjects.values()] return [...subjects.values()]
} }
/** 将选角就绪接口中的无形态 Character 补入主体目录,确保选角早于 Form 生图也可操作。 */
export function mergeCastingSubjects(
subjects: IdentitySubject[],
casting: CharacterCastingItem[],
projectId: string
): IdentitySubject[] {
const rows = new Map(subjects.map(subject => [subject.id, subject]))
for (const item of casting) {
if (!rows.has(item.subjectId)) {
rows.set(item.subjectId, {
id: item.subjectId,
projectId,
ref: item.subjectRef,
name: item.subjectName,
module: 'character',
forms: []
})
}
}
return [...rows.values()].toSorted((a, b) => a.ref.localeCompare(b.ref))
}
/** 角色选角状态中文标签。 */
export function castingStatusLabel(status: CharacterCastingStatus): string {
const labels: Record<CharacterCastingStatus, string> = {
missing_identity: '缺少身份文本',
missing_anchor: '缺少候选图',
candidate_pending: '等待确认演员',
unlocked: '演员尚未锁定',
ready: '选角已完成'
}
return labels[status]
}
/** 只有成功且有图片的 primary 候选可成为母版;停用候选也允许重新启用。 */ /** 只有成功且有图片的 primary 候选可成为母版;停用候选也允许重新启用。 */
export function canBeAnchor(image: IdentityImage): boolean { export function canBeAnchor(image: IdentityImage): boolean {
return image.viewType === 'primary' && image.status === 'completed' && !!image.imageUrl return image.viewType === 'primary' && image.status === 'completed' && !!image.imageUrl
@@ -52,9 +92,9 @@ export function readImageProvenance(rawJson?: string | null): {
} }
/** 批量回执按项目保存在当前会话,切页不丢失,刷新浏览器后不伪造恢复。 */ /** 批量回执按项目保存在当前会话,切页不丢失,刷新浏览器后不伪造恢复。 */
const sessions = reactive<Record<string, { receipt: IdentityBatchResult | null }>>({}) const sessions = reactive<Record<string, { receipt: IdentityReceipt | null }>>({})
/** 取得指定项目的身份文本批量回执。 */ /** 取得指定项目的身份文本或选角候选批量回执。 */
export function getIdentitySession(projectId: string) { export function getIdentitySession(projectId: string) {
return (sessions[projectId] ??= { receipt: null }) return (sessions[projectId] ??= { receipt: null })
} }
+87 -2
View File
@@ -36,12 +36,12 @@ export interface SubjectIdentity {
updatedAt: string updatedAt: string
} }
/** 从正式形态目录归并主体,不使用 checkpoint 的候选 ID。 */ /** 从正式形态与 Character Casting 归并主体,不使用 checkpoint 的候选 ID。 */
export type IdentitySubject = SubjectFormAsset['subject'] & { export type IdentitySubject = SubjectFormAsset['subject'] & {
forms: SubjectFormAsset[] forms: SubjectFormAsset[]
} }
/** 人工保存身份;锁定只保护 AI 文本重生成,不限制人工保存或生图。 */ /** 人工保存身份;Character 锁定同时是确认演员后的首帧前置条件。 */
export type SaveIdentityInput = Partial<Pick<SubjectIdentity, 'description' | 'generationPrompt' | 'isLocked'>> export type SaveIdentityInput = Partial<Pick<SubjectIdentity, 'description' | 'generationPrompt' | 'isLocked'>>
/** 单张身份生图;空 referenceImageId 应省略,由后端自动引用当前母版。 */ /** 单张身份生图;空 referenceImageId 应省略,由后端自动引用当前母版。 */
@@ -54,6 +54,14 @@ export interface GenerateIdentityImageInput {
height?: number height?: number
} }
/** Character 选角候选固定生成 primary,且不会自动成为 Anchor。 */
export interface GenerateCastingCandidateInput {
provider: 'seedream'
prompt?: string
width?: number
height?: number
}
/** 身份文本批量生成回执,不代表已生成参考图。 */ /** 身份文本批量生成回执,不代表已生成参考图。 */
export interface IdentityBatchResult { export interface IdentityBatchResult {
total: number total: number
@@ -64,3 +72,80 @@ export interface IdentityBatchResult {
failed: number failed: number
failures: { subjectId: string; subjectRef: string; error: string }[] failures: { subjectId: string; subjectRef: string; error: string }[]
} }
/** Character 的正式选角阶段状态。 */
export type CharacterCastingStatus = 'missing_identity' | 'missing_anchor' | 'candidate_pending' | 'unlocked' | 'ready'
/** 就绪接口返回的轻量候选图。 */
export interface CastingCandidateImage {
id: string
imageUrl: string
createdAt: string
}
/** 单个 Character 的选角就绪记录。 */
export interface CharacterCastingItem {
subjectId: string
subjectRef: string
subjectName: string
status: CharacterCastingStatus
identityId?: string
anchorImageId?: string
anchorImageUrl?: string
candidateImages?: CastingCandidateImage[]
isLocked: boolean
}
/** 项目角色选角汇总;locked 与 ready 语义不同,锁定但无 Anchor 仍不可生产首帧。 */
export interface CharacterCastingReadiness {
total: number
ready: number
missingIdentity: number
missingAnchor: number
candidatePending: number
unlocked: number
locked: number
items: CharacterCastingItem[]
}
/** 小批量生成选角候选的配置。 */
export interface GenerateCastingCandidatesInput {
provider: 'seedream'
limit: number
concurrency: number
width?: number
height?: number
}
/** 选角候选批量结果中的逐主体记录。 */
export interface CastingCandidateResult {
subjectId: string
subjectRef: string
subjectName: string
success: boolean
imageId?: string
imageUrl?: string | null
error?: string
}
/** 小批量选角候选生图回执。 */
export interface CastingBatchResult {
totalCharacters: number
missingAnchor: number
targetCount: number
generated: number
failed: number
failures: { subjectId: string; subjectRef: string; subjectName: string; error: string }[]
results: CastingCandidateResult[]
}
/** 确认选角会在同一事务中切换 Anchor 并锁定 Identity。 */
export interface ConfirmCastingResult {
identity: Omit<SubjectIdentity, 'images'>
anchor: IdentityImage
}
/** 主体身份页的批量操作回执。 */
export type IdentityReceipt =
| { kind: 'identities'; title: string; result: IdentityBatchResult }
| { kind: 'casting'; title: string; result: CastingBatchResult }
@@ -6,8 +6,14 @@ import { hasRunningImages } from '../subject-images/model'
import { runOperation } from '../workflows/operations' import { runOperation } from '../workflows/operations'
import { useProjectMutationGuard } from '../workflows/useProjectMutationGuard' import { useProjectMutationGuard } from '../workflows/useProjectMutationGuard'
import { subjectIdentityApi } from './api' import { subjectIdentityApi } from './api'
import { canBeAnchor, getIdentitySession, groupIdentitySubjects } from './model' import { canBeAnchor, getIdentitySession, groupIdentitySubjects, mergeCastingSubjects } from './model'
import type { GenerateIdentityImageInput, IdentityImage, SaveIdentityInput, SubjectIdentity } from './types' import type {
GenerateCastingCandidateInput,
GenerateIdentityImageInput,
IdentityImage,
SaveIdentityInput,
SubjectIdentity
} from './types'
/** 单主体保存响应必须与提交时固定的正式 ID 一致。 */ /** 单主体保存响应必须与提交时固定的正式 ID 一致。 */
function assertIdentity(value: SubjectIdentity, id: string) { function assertIdentity(value: SubjectIdentity, id: string) {
@@ -22,6 +28,7 @@ export function useSubjectIdentity() {
const editorRevision = ref(0) const editorRevision = ref(0)
const dirty = ref(false) const dirty = ref(false)
const concurrency = ref(3) const concurrency = ref(3)
const candidateLimit = ref(3)
const force = ref(false) const force = ref(false)
const catalog = usePolling(projectId, async (id, signal) => { const catalog = usePolling(projectId, async (id, signal) => {
const forms = await subjectImagesApi.listForms(id, signal) const forms = await subjectImagesApi.listForms(id, signal)
@@ -38,7 +45,13 @@ export function useSubjectIdentity() {
if (style && style.projectId !== id) throw new Error('视觉风格与当前项目不匹配。') if (style && style.projectId !== id) throw new Error('视觉风格与当前项目不匹配。')
return { style } return { style }
}) })
const subjects = computed(() => catalog.data.value?.subjects ?? []) const castingQuery = usePolling(projectId, async (id, signal) => {
const readiness = await subjectIdentityApi.castingReadiness(id, signal)
return readiness
})
const subjects = computed(() =>
mergeCastingSubjects(catalog.data.value?.subjects ?? [], castingQuery.data.value?.items ?? [], projectId.value)
)
const subject = computed(() => subjects.value.find(item => item.id === selectedId.value)) const subject = computed(() => subjects.value.find(item => item.id === selectedId.value))
const selectionKey = computed(() => subject.value?.id ?? '') const selectionKey = computed(() => subject.value?.id ?? '')
const detail = usePolling(selectionKey, async (id, signal) => { const detail = usePolling(selectionKey, async (id, signal) => {
@@ -52,6 +65,9 @@ export function useSubjectIdentity() {
}) })
const identity = computed(() => detail.data.value?.identity ?? null) const identity = computed(() => detail.data.value?.identity ?? null)
const images = computed(() => detail.data.value?.images ?? []) const images = computed(() => detail.data.value?.images ?? [])
const castingItem = computed(() =>
castingQuery.data.value?.items?.find(item => item.subjectId === subject.value?.id)
)
const session = computed(() => getIdentitySession(projectId.value)) const session = computed(() => getIdentitySession(projectId.value))
const blocked = computed( const blocked = computed(
() => () =>
@@ -77,7 +93,18 @@ export function useSubjectIdentity() {
const canGenerateImage = computed( const canGenerateImage = computed(
() => !detailBlocked.value && hasStyle.value && !!identity.value?.generationPrompt?.trim() && !dirty.value () => !detailBlocked.value && hasStyle.value && !!identity.value?.generationPrompt?.trim() && !dirty.value
) )
const canGenerateCandidate = computed(
() =>
canGenerateImage.value &&
subject.value?.module === 'character' &&
!!castingQuery.data.value &&
!castingQuery.error.value
)
const batchValid = computed(() => Number.isSafeInteger(concurrency.value) && concurrency.value > 0) const batchValid = computed(() => Number.isSafeInteger(concurrency.value) && concurrency.value > 0)
const candidateLimitValid = computed(() => Number.isSafeInteger(candidateLimit.value) && candidateLimit.value > 0)
const castingBlocked = computed(
() => blocked.value || !!castingQuery.error.value || !castingQuery.data.value || dirty.value
)
watch(subjects, rows => { watch(subjects, rows => {
if (!rows.some(item => item.id === selectedId.value)) selectedId.value = rows[0]?.id ?? '' if (!rows.some(item => item.id === selectedId.value)) selectedId.value = rows[0]?.id ?? ''
@@ -102,7 +129,7 @@ export function useSubjectIdentity() {
dirty.value = false dirty.value = false
} }
}) })
if (ok) await detail.refresh() if (ok) await Promise.all([detail.refresh(), castingQuery.refresh()])
} }
/** 人工保存可修改已锁定身份;锁定只阻止自动重生成。 */ /** 人工保存可修改已锁定身份;锁定只阻止自动重生成。 */
@@ -118,16 +145,51 @@ export function useSubjectIdentity() {
} }
/** 批量仅生成身份文本,已锁定项由后端跳过,回执保留部分失败。 */ /** 批量仅生成身份文本,已锁定项由后端跳过,回执保留部分失败。 */
async function generateProject() { async function generateProject(charactersOnly = false) {
if (blocked.value || !hasStyle.value || !batchValid.value || dirty.value || !subjects.value.length) return if (blocked.value || !hasStyle.value || !batchValid.value || dirty.value || !subjects.value.length) return
const id = projectId.value const id = projectId.value
const target = getIdentitySession(id) const target = getIdentitySession(id)
const input = { concurrency: concurrency.value, force: force.value } const input = { concurrency: concurrency.value, force: force.value }
target.receipt = null target.receipt = null
await runOperation(id, '批量生成主体身份文本', async () => { const title = charactersOnly ? '批量生成角色身份文本' : '批量生成全部主体身份文本'
target.receipt = await subjectIdentityApi.generateProject(id, input) await runOperation(id, title, async () => {
target.receipt = {
kind: 'identities',
title,
result: charactersOnly
? await subjectIdentityApi.generateCharacters(id, input)
: await subjectIdentityApi.generateProject(id, input)
}
}) })
await detail.refresh() await Promise.all([detail.refresh(), castingQuery.refresh()])
}
/** 小批量只为 missing_anchor 的 Character 生成候选,不自动选择演员。 */
async function generateCastingCandidates() {
if (
castingBlocked.value ||
!hasStyle.value ||
!batchValid.value ||
!candidateLimitValid.value ||
!castingQuery.data.value?.missingAnchor
)
return
const id = projectId.value
const target = getIdentitySession(id)
const title = '批量生成角色选角候选'
target.receipt = null
await runOperation(id, title, async () => {
target.receipt = {
kind: 'casting',
title,
result: await subjectIdentityApi.generateCastingCandidates(id, {
provider: 'seedream',
limit: candidateLimit.value,
concurrency: concurrency.value
})
}
})
await Promise.all([detail.refresh(), castingQuery.refresh()])
} }
/** 生图不修改文字锁定;即使有自定义 Prompt,也需已有身份 Prompt 与项目风格。 */ /** 生图不修改文字锁定;即使有自定义 Prompt,也需已有身份 Prompt 与项目风格。 */
@@ -150,6 +212,19 @@ export function useSubjectIdentity() {
await detail.refresh() await detail.refresh()
} }
/** 单角色候选图固定为 primary,生成成功后仍需人工确认选角。 */
async function generateCastingCandidate(input: GenerateCastingCandidateInput) {
if (!canGenerateCandidate.value || !subject.value || !identity.value) return
const id = subject.value.id
const identityId = identity.value.id
await runOperation(projectId.value, `生成 ${subject.value.name} 选角候选`, async () => {
const image = await subjectIdentityApi.generateCastingCandidate(id, input)
if (!image || image.identityId !== identityId || image.status !== 'completed' || !image.imageUrl)
throw new Error(image?.error || '后端未返回已完成的选角候选,请刷新核对。')
})
await Promise.all([detail.refresh(), castingQuery.refresh()])
}
/** 候选切换母版只允许成功的 primary 图,不能将辅助视角直接升级为母版。 */ /** 候选切换母版只允许成功的 primary 图,不能将辅助视角直接升级为母版。 */
async function setAnchor(imageId: string) { async function setAnchor(imageId: string) {
const image = images.value.find(item => item.id === imageId) const image = images.value.find(item => item.id === imageId)
@@ -170,6 +245,34 @@ export function useSubjectIdentity() {
await detail.refresh() await detail.refresh()
} }
/** Character 确认选角必须原子切换 Anchor 并锁定 Identity。 */
async function confirmCasting(imageId: string) {
const image = images.value.find(item => item.id === imageId)
if (
detailBlocked.value ||
subject.value?.module !== 'character' ||
!!castingQuery.error.value ||
!identity.value ||
!image ||
!canBeAnchor(image)
)
return
const subjectId = subject.value.id
const identityId = identity.value.id
await runOperation(projectId.value, `确认 ${subject.value.name} 角色选角`, async () => {
const result = await subjectIdentityApi.confirmCasting(subjectId, imageId)
if (
!result ||
result.identity.id !== identityId ||
!result.identity.isLocked ||
result.anchor.id !== imageId ||
!result.anchor.enabled
)
throw new Error('接口未确认演员母版与身份锁定,请刷新核对。')
})
await Promise.all([detail.refresh(), castingQuery.refresh()])
}
return { return {
projectId, projectId,
operation, operation,
@@ -177,25 +280,34 @@ export function useSubjectIdentity() {
subject, subject,
subjects, subjects,
catalog, catalog,
castingQuery,
styleQuery, styleQuery,
detail, detail,
identity, identity,
images, images,
castingItem,
session, session,
concurrency, concurrency,
candidateLimit,
force, force,
editorRevision, editorRevision,
dirty, dirty,
blocked, blocked,
castingBlocked,
detailBlocked, detailBlocked,
hasStyle, hasStyle,
canGenerateText, canGenerateText,
canGenerateImage, canGenerateImage,
canGenerateCandidate,
batchValid, batchValid,
candidateLimitValid,
save, save,
generate, generate,
generateProject, generateProject,
generateCastingCandidates,
generateImage, generateImage,
setAnchor generateCastingCandidate,
setAnchor,
confirmCasting
} }
} }
+67 -8
View File
@@ -337,7 +337,7 @@ describe('视觉风格与主体身份工作区', () => {
expect(wrapper!.text()).toContain('2 个形态') expect(wrapper!.text()).toContain('2 个形态')
expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('/subjects/subject-db-1/identity'))).toBe(true) expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('/subjects/subject-db-1/identity'))).toBe(true)
expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('/identity/images'))).toBe(false) expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('/identity/images'))).toBe(false)
expect(button('生成身份参考图').disabled).toBe(true) expect(button('生成选角候选').disabled).toBe(true)
expect(button('AI 生成身份').disabled).toBe(false) expect(button('AI 生成身份').disabled).toBe(false)
}) })
@@ -353,7 +353,7 @@ describe('视觉风格与主体身份工作区', () => {
await mountAssets('identity') await mountAssets('identity')
expect(button('AI 重新生成身份').disabled).toBe(true) expect(button('AI 重新生成身份').disabled).toBe(true)
expect(button('保存主体身份').disabled).toBe(false) expect(button('保存主体身份').disabled).toBe(false)
button('生成身份参考图').click() button('生成辅助身份图').click()
await flushPromises() await flushPromises()
expect(button('确认生成身份图').disabled).toBe(true) expect(button('确认生成身份图').disabled).toBe(true)
document.querySelector<HTMLInputElement>('#identity-image-cost')!.click() document.querySelector<HTMLInputElement>('#identity-image-cost')!.click()
@@ -362,7 +362,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: 'primary' }) expect(JSON.parse(post[1]!.body as string)).toEqual({ provider: 'seedream', viewType: 'front' })
}) })
it('无项目风格时禁止生成身份和图片,但仍可人工保存身份', async () => { it('无项目风格时禁止生成身份和图片,但仍可人工保存身份', async () => {
@@ -377,7 +377,7 @@ describe('视觉风格与主体身份工作区', () => {
) )
await mountAssets('identity') await mountAssets('identity')
expect(button('AI 重新生成身份').disabled).toBe(true) expect(button('AI 重新生成身份').disabled).toBe(true)
expect(button('生成身份参考图').disabled).toBe(true) expect(button('生成选角候选').disabled).toBe(true)
expect(button('保存主体身份').disabled).toBe(false) expect(button('保存主体身份').disabled).toBe(false)
}) })
@@ -399,19 +399,21 @@ describe('视觉风格与主体身份工作区', () => {
}) })
vi.stubGlobal('fetch', fetcher) vi.stubGlobal('fetch', fetcher)
await mountAssets('identity') await mountAssets('identity')
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(post[0]).toBe('/api/projects/page-test-project/subject-identities/generate') expect(post[0]).toBe('/api/projects/page-test-project/subject-identities/generate')
expect(JSON.parse(post[1]!.body as string)).toEqual({ force: false, concurrency: 3 }) expect(JSON.parse(post[1]!.body as string)).toEqual({ force: false, concurrency: 3 })
expect(wrapper!.text()).toContain('部分主体生成失败') expect(wrapper!.text()).toContain('本次存在部分失败')
expect(wrapper!.text()).toContain('含锁定 1') expect(wrapper!.text()).toContain('含锁定 1')
expect(wrapper!.text()).toContain('文本模型超时') expect(wrapper!.text()).toContain('文本模型超时')
}) })
it('身份候选母版切换需确认,只发送正确 Subject ID 的 anchor PUT', async () => { it('身份候选母版切换需确认,只发送正确 Subject ID 的 anchor PUT', async () => {
let selected = false let selected = false
const form = formFixture()
form.subject.module = 'scene'
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => { const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
if (String(url).endsWith('/subject-forms')) return jsonResponse([formFixture()]) if (String(url).endsWith('/subject-forms')) return jsonResponse([form])
if (String(url).endsWith('/visual-style')) return jsonResponse(styleFixture()) if (String(url).endsWith('/visual-style')) return jsonResponse(styleFixture())
if (init?.method === 'PUT') { if (init?.method === 'PUT') {
selected = true selected = true
@@ -434,6 +436,63 @@ describe('视觉风格与主体身份工作区', () => {
expect(wrapper!.text()).toContain('当前身份母版') expect(wrapper!.text()).toContain('当前身份母版')
}) })
it('Character 确认选角原子切换 Anchor 并锁定 Identity,不调用普通母版接口', async () => {
let locked = false
let selected = false
const candidate = identityImageFixture({ id: 'casting-1', enabled: false, isAnchor: false })
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
const path = String(url)
if (path.endsWith('/subject-forms')) return jsonResponse([formFixture()])
if (path.endsWith('/visual-style')) return jsonResponse(styleFixture())
if (path.endsWith('/character-casting/readiness'))
return jsonResponse({
total: 1,
ready: locked ? 1 : 0,
missingIdentity: 0,
missingAnchor: 0,
candidatePending: locked ? 0 : 1,
unlocked: 0,
locked: locked ? 1 : 0,
items: [
{
subjectId: 'subject-db-1',
subjectRef: '@CH0001',
subjectName: '林知夏',
status: locked ? 'ready' : 'candidate_pending',
identityId: 'identity-db-1',
candidateImages: locked ? undefined : [{ id: 'casting-1', imageUrl: candidate.imageUrl }],
anchorImageId: locked ? 'casting-1' : undefined,
anchorImageUrl: locked ? candidate.imageUrl : undefined,
isLocked: locked
}
]
})
if (path.endsWith('/identity/images'))
return jsonResponse([{ ...candidate, enabled: selected, isAnchor: selected }])
if (init?.method === 'PUT' && path.endsWith('/casting')) {
locked = true
selected = true
return jsonResponse({
identity: { ...identityFixture({ isLocked: true }), images: undefined },
anchor: { ...candidate, enabled: true }
})
}
return jsonResponse(identityFixture({ isLocked: locked }))
})
vi.stubGlobal('fetch', fetcher)
await mountAssets('identity')
expect(wrapper!.text()).toContain('等待确认演员')
button('确认选角并锁定').click()
await flushPromises()
button('确认演员选择').click()
await flushPromises()
const put = fetcher.mock.calls.find(([, init]) => init?.method === 'PUT')!
expect(put[0]).toBe('/api/subjects/subject-db-1/identity/images/casting-1/casting')
expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('/anchor'))).toBe(false)
expect(wrapper!.text()).toContain('选角状态:选角已完成')
expect(wrapper!.text()).toContain('演员身份已确认并锁定')
})
it('切换主体会取消旧查询,迟到的身份结果不覆盖当前主体', async () => { it('切换主体会取消旧查询,迟到的身份结果不覆盖当前主体', async () => {
let finish!: (value: Response) => void let finish!: (value: Response) => void
const form = formFixture() const form = formFixture()
@@ -485,7 +544,7 @@ describe('视觉风格与主体身份工作区', () => {
provided.data.value!.project = { ...fixture, status: 'generating' } provided.data.value!.project = { ...fixture, status: 'generating' }
await flushPromises() await flushPromises()
expect(button('保存主体身份').disabled).toBe(true) expect(button('保存主体身份').disabled).toBe(true)
expect(button('生成身份参考图').disabled).toBe(true) expect(button('生成选角候选').disabled).toBe(true)
}) })
}) })
+54 -1
View File
@@ -88,6 +88,47 @@
background: #f1e8df; background: #f1e8df;
color: var(--color-accent); color: var(--color-accent);
} }
.casting-stats {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 10px;
}
.casting-stats div {
padding: 10px 12px;
border-radius: 4px;
background: #f7f8f4;
}
.casting-stats dt {
color: var(--color-muted);
font-size: 10px;
}
.casting-stats dd {
margin-top: 5px;
font-family: ui-monospace, monospace;
font-size: 13px;
}
.casting-list {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.casting-item {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 11px 12px;
border: 1px solid var(--color-line);
border-radius: 5px;
background: #fafbf7;
text-align: left;
}
.casting-item:hover,
.casting-item.selected {
border-color: #c9d0be;
background: #eef1e7;
}
@media (max-width: 900px) { @media (max-width: 900px) {
.identity-workspace { .identity-workspace {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
@@ -95,6 +136,12 @@
.identity-subject-list { .identity-subject-list {
max-height: 220px; max-height: 220px;
} }
.casting-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.casting-list {
grid-template-columns: 1fr;
}
} }
.app-shell { .app-shell {
display: grid; display: grid;
@@ -446,9 +493,15 @@
color: #557b50; color: #557b50;
} }
.status-badge[data-status='blocked'], .status-badge[data-status='blocked'],
.status-badge[data-status='cancelled'] { .status-badge[data-status='cancelled'],
.status-badge[data-status='missing_identity'],
.status-badge[data-status='missing_anchor'] {
color: #8b6f64; color: #8b6f64;
} }
.status-badge[data-status='candidate_pending'],
.status-badge[data-status='unlocked'] {
color: #aa8137;
}
.production-controls { .production-controls {
display: grid; display: grid;