feat: 同步角色选角与首帧身份约束
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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">
|
||||||
|
同时处理 Character、Scene 和 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,6 +341,10 @@ 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">
|
||||||
|
选角状态:{{ castingStatusLabel(castingItem.status) }}
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
{{
|
{{
|
||||||
identity
|
identity
|
||||||
? identity.isLocked
|
? identity.isLocked
|
||||||
@@ -217,6 +352,7 @@ function exportReceipt() {
|
|||||||
: '身份文本未锁定'
|
: '身份文本未锁定'
|
||||||
: '尚未创建身份'
|
: '尚未创建身份'
|
||||||
}}
|
}}
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user