diff --git a/README.md b/README.md index 7393a20..58280a5 100644 --- a/README.md +++ b/README.md @@ -126,13 +126,14 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S ## 接口基线 对齐后端 `qianlanse/short-drama-agent` 的 `dev`: -`40c700f47d578888105484cfb317f52568855e0e`(2026-09-01),包含 Character Casting、身份/形态双参考首帧、首帧与视频生产就绪检查,以及独立候选生成和中国人物默认选角基线。本轮只修改前端,请确保后端依照其开发流程同步 Prisma migration 并重启服务。 +`537dba89a18d571a6df24dae17494b09ce635c15`(2026-09-02 核对),包含正式形态提示词、三类主体母版继承、数量上限、身份/形态双参考首帧与视频就绪检查。本轮只修改前端,请确保后端依照其开发流程同步 Prisma migration 并重启服务。完整映射、已补能力和后端限制见 [后端功能覆盖说明](docs/backend-coverage.md)。 | HTTP | 路径(以 /api 为前缀) | 用途 | | --- | --- | --- | | GET / POST | `/projects` | 项目列表 / 创建剧本 | | GET | `/projects/:id` | 正式数据库详情、剧集、角色、世界观与审核 | | GET | `/projects/:id/checkpoints` | 两条 graph 的 checkpoint;前端按 workflowName 过滤 | +| GET | `/projects/:id/metrics`、`/projects/:id/timeline/grouped` | 全量运行诊断;按需查询,支持工作流/节点筛选与 JSON 导出 | | POST | `/projects/:id/resume-generation` | 继续生成未完成剧集 | | POST | `/projects/:id/resume-rewrite` | 恢复审核与改写 | | GET | `/projects/:id/breakdown-preview?groupSize=3&modules=character,scene,prop` | 真实分组与任务预览 | @@ -154,7 +155,9 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S | GET | `/subject-forms/:formId/images` | 此形态的全部图片历史及实际 Prompt | | POST | `/subject-forms/:formId/images` | 单图生成;显式 `provider: seedream`,可选 Prompt 和成对尺寸,`setPrimary` | | PUT | `/subject-forms/:formId/images/:imageId/primary` | 选择已完成图片作为主参考图,不调用模型 | -| POST | `/projects/:id/subject-images/generate` | 全项目批量生图;`provider`、`concurrency`、`force` | +| POST | `/subject-forms/:formId/generation-prompt` | 单个正式提示词生成/重生成;`force` | +| POST | `/projects/:id/subject-forms/generation-prompts` | 全项目提示词补齐/覆盖;`concurrency`、`force` | +| POST | `/projects/:id/subject-images/generate` | 全项目批量生图;`provider`、`concurrency`、`force`、可选正整数 `limit` | | GET / PUT | `/projects/:id/visual-style` | 查询/人工保存视觉风格,包含分类 Prompt、硬约束和锁定 | | POST | `/projects/:id/visual-style/generate` | AI 生成风格;已有风格重生成显式 `force: true`,锁定时禁止 | | POST | `/projects/:id/visual-style/images` | 登记已有风格图片地址,不上传文件 | @@ -179,6 +182,7 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S | POST | `/projects/:id/videos/retry-failed` | 重试最近失败的视频任务 | | GET / POST | `/storyboard-shots/:shotId/videos` | 单镜头视频历史与任务创建 | | PUT | `/storyboard-shots/:shotId/videos/:videoId/primary` | 切换主视频 | +| POST | `/projects/:id/production/start` | 高级串联入口;重复预检、费用确认,仅传两种 Provider,不生成首帧、不等待成片 | API 层也提供 `state` 与 `breakdown/latest` 方法。页面通过共享 checkpoint 查询取得阶段状态,避免重复拉取同样内容。 @@ -204,19 +208,21 @@ API 层也提供 `state` 与 `breakdown/latest` 方法。页面通过共享 chec 7. 上游重生成**不会自动使下游旧数据失效**。覆盖 Direction 后请重生成 VisualState,修改设计/状态/参考图后按需覆盖 Prompt。覆盖率只说明字段已保存,不能证明下游与新版本一致。 8. 每 6 秒查询当前剧集的两类正式结果;这不是全项目实时进度。后端没有独立 Storyboard running / executionId 查询。回执只保留当前浏览器会话,切换页面不丢失,刷新浏览器会丢失。 9. 参考图和 GenerationSpec 按需 GET,切换镜头或设计变化时取消旧查询。GenerationSpec 需要有效的 Direction、VisualState 和形态关联,不生成图片或视频。 -10. Video Prompt、Keyframe 与 Video 都有项目级 readiness。页面只向 `ready` 镜头批量提交;`force` 会重写 Prompt 或新增首帧/视频候选,但不会静默替换主首帧和主视频。视频任务创建成功不代表成片完成,最终状态以后端轮询和持久化资产为准。 +10. Video Prompt、Keyframe 与 Video 都有项目级 readiness。`force` 会重写 Prompt,普通首帧/视频新增候选;**过期首帧即使 force 也会更新主图**,非 force 的过期视频完成后会接替旧主视频。确认文案明确此差异。首帧支持可选 `limit` 和成对 `width`/`height`,只影响首帧,不污染提示词和视频参数。视频任务创建成功不代表成片完成,最终状态以后端轮询和持久化资产为准。 ### 形态图片约定 1. 入口为 `/projects/:projectId/subject-images`;项目导航、拆解主体列表和分镜参考图缺失提示均可进入。图库自动读取后端已生成图片,不需要重新生图才能显示。 -2. 生图使用正式数据库 `SubjectForm.id`。Checkpoint 的领域 `formId` 不是数据库 ID,不能直接调用图片接口。新增查询从项目关联读取正式形态,返回轻量图片元数据,不包含图片的 `rawJson` 或长 Prompt;打开历史弹窗后再读取详细记录。 -3. 单图明确发送 `provider: 'seedream'`,避免后端单图入口默认使用 mock。模型和凭证仅配置在后端,前端不保存 API Key。默认使用后端的 generationPrompt,其次 appearancePrompt;可为本次输入自定义 Prompt,详情显示后端最终实际使用的文本。 +2. 生图使用正式数据库 `SubjectForm.id`。Checkpoint 的领域 `formId` 不是数据库 ID,不能直接调用图片接口。项目列表包含 Identity Anchor 及图片的 `rawJson` 追溯字段,用于识别母版过期;完整实际 Prompt 在历史弹窗读取。 +3. 单图明确发送 `provider: 'seedream'`,避免后端单图入口默认使用 mock。模型和凭证仅配置在后端,前端不保存 API Key。自定义 Prompt 优先,其次正式 `generationPrompt`;缺少时后端先调用文本模型生成并保存。`appearancePrompt` 仅为原始素材,不能直接充当最终 Prompt。可在形态卡片“提示词与生成”先生成、检查与导出正式提示词;后端尚无手工编辑保存接口。 4. 单图宽高同时留空使用后端默认 2K,自定义时必须同时提供正整数,具体尺寸限制仍由模型校验。当前 Seedream Provider 没有使用 negativePrompt,所以页面不提供无效的负向提示词输入。 5. 单图生成新增历史记录,不删除旧图。无主图时默认勾选“生成成功后设为主图”;已有主图时默认不替换。候选图可以在历史弹窗二次确认后通过 PUT 设为主图,只允许已完成图片。 6. 批量默认跳过已有主图的形态;勾选“已有主图也新增候选图”发送 `force: true`。后端批量 force 只生成新图,**不会替换已有主图**。批量作用于整个项目,不受类型/文字/缺图筛选影响,也不接收单图的尺寸与自定义 Prompt。 7. 图库每 6 秒读取一次真实记录,弹窗只在打开时查询该形态图片。数据库存在 pending / generating 时暂停再次生图;网络错误不自动重试有费用的请求。HTTP 200 中的批量部分失败单独展示。 8. 图片使用持久化后的 `/storage/...` 地址,开发代理已配置。生产环境也必须代理 `/storage/`,不能让 SPA 回退返回 index.html。无效 URL、加载失败、无图和后端生成失败有各自提示;不会自动使用可能过期的 Provider 临时地址。 9. 设为主图后,回到分镜点击“刷新参考图”读取最新绑定。已有视频提示词不会自动重生成,需要按需覆盖。本页面不会调用 `/production/start` 或实际视频生成。 +10. 人物、场景、道具均仅继承已锁定身份的当前母版;无母版或未锁定不继承。刷新过期形态只生成候选,验图后手动切换主图。批量图片可限制本次数量,回执独立展示因上限未执行数,不把它算作已有结果的跳过数。 +11. 提示词批量入口有独立并发、覆盖和回执;默认补缺,覆盖会重写已有正式文本,但不改变历史图片、实际 Prompt 或主图。未完成剧本、运行中的任务及查询错误均阻止生成。 ### 视觉风格、身份与形态的衔接 @@ -237,7 +243,7 @@ API 层也提供 `state` 与 `breakdown/latest` 方法。页面通过共享 chec 5. 身份图片的 `isAnchor` 来自专用 GET。Character primary 通过 Casting Candidate 接口生成且默认停用;点击确认演员后才成为 Anchor 并锁定 Identity。辅助视图不能直接成为母版。Scene / Prop 保留普通母版切换接口。 6. 普通身份生图不传 `referenceImageId` 时,由后端自动选择当前母版;无母版时按文本生成。指定时只能选择当前身份已完成的图片;候选图即使停用也可作为显式参考。Character Casting Candidate 是独立选角入口,即使已有 Anchor 也不会复用旧母版,避免旧演员身份污染新候选。尺寸成对留空或提供正整数。自定义 Prompt 会替换后端编译的完整文本,建议保留默认。 7. 目前**人物与场景形态生图**会自动注入当前身份母版,分别固定人物身份和空间骨架;道具暂不自动引用。没有身份或母版时,原形态生图流程仍可使用。已有母版但缺少 Provider 可访问远程地址时,后端会报错;本地 `/storage/` 能显示并不证明原始 Provider URL 仍可访问。前端显示后端错误,不自动重试消耗额度。 -8. 修改风格、身份文本或母版,不会自动使旧图失效或重生成。已有形态主图仍会被分镜使用;需要保持新身份时,主动生成候选图并设为形态主图,再刷新分镜参考图,按需覆盖视频提示词。图库详情展示当次 `identityAnchorImageId` 或 `referenceImageId`;旧记录没有该字段时明确显示未知,不推断它使用了当前母版。 +8. 修改风格或身份文本不会自动重生成旧图。更换已锁定母版后,形态图库会根据 `identityAnchorImageId` 标记旧主图过期;主动生成候选、验图并切换主图,再更新下游首帧。Keyframe 就绪检查也会比对母版与形态主图引用,不应绕过过期提示继续生产。旧记录无追溯字段时不推断它已匹配当前母版。 9. 风格参考图支持登记已有 HTTP(S)/`/storage/` 地址、分类、排序、启停和删除记录;**没有文件上传接口,也没有风格图 AI 生成接口**。当前身份图/形态图 Provider 调用不会自动消费风格参考图片,页面不暗示登记后已生效。 10. 编辑草稿与轮询分离。保存成功才清空草稿,失败保留;主体切换会提示放弃未保存修改。离开页面不会自动保存。所有新写操作沿用项目级会话互斥,生成前需要费用与后台任务确认,无法替代后端跨浏览器锁。 11. AI 生成 VisualStyle 与 Character Identity 时,若项目、世界观和角色事实都未指定人物背景,默认采用自然真实的中国人物选角基线。项目明确配置和角色事实优先于系统默认值,不限制后续使用其它国家、地区、族裔或肤色人物。 diff --git a/docs/backend-coverage.md b/docs/backend-coverage.md new file mode 100644 index 0000000..079dbae --- /dev/null +++ b/docs/backend-coverage.md @@ -0,0 +1,61 @@ +# 后端功能覆盖说明 + +核对日期:2026-09-02。后端:`qianlanse/short-drama-agent`,`dev@537dba89a18d571a6df24dae17494b09ce635c15`。以 API 路由和实际 service/workflow 实现为依据,不只根据接口名称推断功能。本轮不修改后端。 + +## 页面与接口能力 + +所有路径以 `/api` 为前缀;`id`、`formId`、`shotId` 必须取正式数据库 ID。 + +| 能力 | 前端入口 | 对应后端 | 本轮结果 | +| --- | --- | --- | --- | +| 项目列表、创建与详情 | 我的剧本/剧本创作 | `/projects`、`/projects/:id` | 保留状态筛选、创建、正文、角色、世界观、审核与导出 | +| 剧本恢复 | 剧本创作操作面板 | `resume-generation`、`resume-rewrite` | 保留费用确认、项目互斥与最近可用 checkpoint | +| 拆解与恢复 | 拆解设置与恢复 | `breakdown-preview`、`breakdown/start`、`retry`、`resume-shots`、`resume-storyboard` | 保留正式剧集分组、模块选择、预览、失败恢复与 JSON 导出 | +| 运行诊断 | 创作/拆解的执行记录 → 查看完整运行诊断 | GET `metrics`、`timeline/grouped`、共享 `checkpoints` | 新增全量时间线、阶段分组、工作流与节点筛选、独立失败提示、JSON 导出 | +| 视觉风格 | 视觉风格 | GET/PUT/POST `visual-style` 及图片登记、启停、删除 | 保留锁定、分类提示词、硬约束、AI 生成与图片记录 | +| 稳定身份 | 主体身份 | `subjects/:id/identity`、单个/项目/角色文本生成 | 保留人工修改、锁定及批量文本生成 | +| 角色选角 | 主体身份角色模块 | `character-casting/readiness`、批量/单个 candidates、图片 `casting` | 保留候选、检查、确认母版并原子锁定 | +| 身份参考图 | 主体身份图库 | `identity/images`、图片 `anchor` | 保留完整预览、历史、辅助视角与母版切换;修正道具继承说明 | +| 正式形态提示词 | 形态卡片 → 提示词与生成;批量工具 | POST `subject-forms/:id/generation-prompt`、项目 `subject-forms/generation-prompts` | 新增单个生成/重生成、原始素材与正式文本对照、导出、批量补齐/覆盖及失败回执 | +| 形态图片 | 形态图片及图库 | 项目 `subject-forms`、形态 `images`、主图 PUT、项目 `subject-images/generate` | 增加本批数量上限;支持后端自动补正式 Prompt;人物/场景/道具已锁定母版继承;过期刷新只生成候选 | +| 导演设计与即时状态 | 分镜设计 | directions/visual-states 查询、单集 generate-test、项目 generate | 保留单集持久化(`persist: true`)、项目批量、覆盖、校验修复次数、逐集回执 | +| 参考图与规格 | 分镜参考图/生产单镜头 | `references`、`generation-spec`、`keyframe-spec`、`video-generation-spec` | 保留按需只读检查与缺失原因,查询不调用生成模型 | +| 视频提示词 | 分镜设计/镜头生产 | 单镜头 `video-prompt`、项目 `video-prompts/readiness` 与 `generate` | 保留单镜头/项目生成,重写需确认 | +| 镜头首帧 | 镜头生产 | 项目 `keyframes/readiness` 与 `generate`、单镜头 keyframe/keyframes、主图 PUT | 新增批量 `limit` 与成对尺寸;保留当前集/过期项/覆盖范围;显示因上限未执行数 | +| 视频任务与主视频 | 镜头生产 | 项目 videos readiness/generate/status/retry-failed,逐镜 videos,单任务 status,主视频 PUT | 保留任务提交、轮询、播放、失败重试与主视频切换;不把 HTTP 完成当成片完成 | +| 高级串联生产 | 镜头生产批量面板底部(默认折叠) | POST `/projects/:id/production/start` | 新增预检、再次校验、费用确认、分阶段回执与诊断导出;明确不是全链自动制片 | + +## 高级串联生产边界 + +实际后端顺序:形态正式提示词 → 形态图片 → 视频提示词 → 视频任务提交。**没有视觉风格、身份、导演设计、即时状态或首帧生成节点,也不会等待成片。** + +- 仅剧本 `completed` 可启动;先检查项目/工作流任务、形态活动图片、当前母版引用、镜头规格、主首帧有效性和活动视频。 +- 为避免中途生成主图使既有首帧失效,前端要求先确认全部形态主图并补齐有效主首帧;因此该入口通常跳过形态生图,只串联仍缺少的文本与视频任务。 +- 点击预检只有 GET;付费确认后再次 GET 校验。查询失败或条件变更不提交 POST。单次项目锁仅覆盖本浏览器,不能代替服务器事务和跨客户端互斥。 +- 参数固定为 `imageProvider: seedream`、`videoProvider: seedance`。后端内部文字并发 3、图片与视频并发 2;不接受批量面板的覆盖、数量上限或尺寸配置,不应让用户误以为可小批量执行。 +- 图工作流 `completed` 仅说明经过 finalize,可能仍有错误;`needsManualReview`、`stopReason` 和所有 `errors` 原样显示。视频统计用“已提交”,不标“成片已完成”。 +- 此工作流没有持久 checkpoint/恢复/取消接口。网络断开不能推断后端停止,禁止自动重发。已提交的长请求回执按原项目隔离;页面刷新后需从资产状态核对。 + +## 观测指标边界 + +后端 metrics 将 checkpoint 数当 completedNodeCount,并给非空记录返回 successRate 100;并非模型任务成功率。前端只展示保存记录数、累计节点耗时、最近 checkpoint 重试数;累计耗时不等于墙钟耗时。明确的失败取 checkpoint 中的执行字段,其他记录仅标“记录已保存”。原始统计保留在导出 JSON 中。 + +时间线分组是节点阶段,不是独立 executionId。未识别的节点被后端归为“其它”;前端用共享 checkpoint 的 workflowName 进一步筛选。两个观测接口独立报错,关闭弹窗不继续发起 API 查询,旧项目迟到响应不会覆盖新项目。 + +## 不添加无效或重复入口 + +| 后端路由/能力 | 处理方式与理由 | +| --- | --- | +| `episode-groups` | 正式 `breakdown-preview` 已包含分组与任务,不重复增加一个分组页 | +| `checkpoints/latest`、`state`、`breakdown/latest` | 页面已按 workflowName 从共享 checkpoint 取状态及最近可用快照;保留现有 API 方法,避免重复查询 | +| `timeline` | 新诊断从 `timeline/grouped` 展平并按 index 排序,保持同一份数据 | +| 图片/首帧/视频的 GET primary | 从后端完整列表的 primary 标志选取,无需再次发相同内容请求 | +| POST `/projects/:id/resume` | 实际只返回恢复信息,并不启动恢复工作流;不显示虚假的“继续执行”按钮 | +| `projects/stream-test`、`breakdown/start-test` | 测试专用,不暴露生产入口;Storyboard 的 generate-test 是现有正式单集生成入口,仍传 `persist: true` | +| 手工保存形态 generationPrompt、取消运行任务、删除项目、合并最终视频 | 后端没有对应正式路由,本轮不虚构按钮、成功提示或客户端模拟实现 | + +## 回归验证 + +- 自动测试全部模拟 API,覆盖正式 ID/参数、提示词与图片职责分离、批量部分失败、数量和尺寸校验、候选不覆盖、未完成剧本禁用、费用确认、预检后条件变化、项目切换与旧响应隔离。 +- 已通过 lint、格式、TypeScript、Vitest(22 个文件、220 项测试)和生产构建。不触发真实文本/图片/视频模型,不提交真实生产任务。 +- 当前环境无法打开应用预览,未进行浏览器视觉验收;需在可访问的开发环境检查浅/暗主题和窄屏布局,并由用户明确批准后进行真实小批量模型联调。 diff --git a/src/admin.css b/src/admin.css index 9221e43..3766ab1 100644 --- a/src/admin.css +++ b/src/admin.css @@ -1440,6 +1440,26 @@ body { font-size: 12px; white-space: nowrap; } +/* 诊断筛选按实际空间换行,长 checkpoint ID 不撑开弹窗。 */ +.diagnostics-filters { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; +} +.diagnostics-filters > .n-input { + flex: 1 1 240px; + min-width: 0; +} +.diagnostics-filters > .n-select { + flex: 0 1 180px; + min-width: 0; +} +.diagnostics-records { + display: grid; + gap: 12px; + min-width: 0; +} .select-control { min-width: 130px; } diff --git a/src/features/breakdown/BreakdownPage.vue b/src/features/breakdown/BreakdownPage.vue index 967b736..da0c281 100644 --- a/src/features/breakdown/BreakdownPage.vue +++ b/src/features/breakdown/BreakdownPage.vue @@ -480,7 +480,7 @@ function exportResult() { - + diff --git a/src/features/create-drama/CreateDramaPage.vue b/src/features/create-drama/CreateDramaPage.vue index 37e6efb..cc45c27 100644 --- a/src/features/create-drama/CreateDramaPage.vue +++ b/src/features/create-drama/CreateDramaPage.vue @@ -236,7 +236,12 @@ function exportScript() { - + diff --git a/src/features/production/ProductionPage.vue b/src/features/production/ProductionPage.vue index c1ed4ea..57423c2 100644 --- a/src/features/production/ProductionPage.vue +++ b/src/features/production/ProductionPage.vue @@ -21,6 +21,7 @@ import { issueLabel, productionStatusLabel } from './model' import { useProduction } from './useProduction' import ProductionReceipt from './components/ProductionReceipt.vue' import ShotProductionAssets from './components/ShotProductionAssets.vue' +import AdvancedProduction from './components/AdvancedProduction.vue' /** 镜头生产页将项目批处理与单镜头资产管理放在同一条可核验链路中。 */ const { @@ -40,6 +41,10 @@ const { session, concurrency, force, + keyframeLimit, + keyframeWidth, + keyframeHeight, + keyframeValid, batchValid, blocked, run @@ -117,6 +122,12 @@ watch( } } ) +watch( + () => session.value.pipelineReceipt, + receipt => { + if (receipt) toolsOpen.value = true + } +) + {{ query.error.value diff --git a/src/features/production/api.ts b/src/features/production/api.ts index e597301..b032907 100644 --- a/src/features/production/api.ts +++ b/src/features/production/api.ts @@ -9,6 +9,7 @@ import type { KeyframeSpec, ProjectVideoStatus, PromptReadiness, + ProductionPipelineResult, RetryVideosResult, ShotKeyframe, ShotVideo, @@ -29,6 +30,12 @@ function shotPath(id: string) { /** 生产链 API;生成请求不设置客户端短超时,也不自动重试。 */ export const productionApi = { + startPipeline: (projectId: string) => + request(`${projectPath(projectId)}/production/start`, { + method: 'POST', + body: { imageProvider: 'seedream', videoProvider: 'seedance' }, + timeoutMs: 0 + }), promptReadiness: (projectId: string, force: boolean, signal?: AbortSignal) => request(`${projectPath(projectId)}/video-prompts/readiness?force=${force}`, { signal }), generatePrompts: (projectId: string, input: StoryboardBatchInput) => diff --git a/src/features/production/capabilities.test.ts b/src/features/production/capabilities.test.ts new file mode 100644 index 0000000..d618179 --- /dev/null +++ b/src/features/production/capabilities.test.ts @@ -0,0 +1,274 @@ +import { defineComponent } from 'vue' +import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { projectContextKey } from '../projects/context' +import { testProjectContext } from '../../testing/project-context' +import { getOperation } from '../workflows/operations' +import { formFixture } from '../subject-images/testing/fixtures' +import { directionsResult } from '../storyboard/testing/fixtures' +import { checkPipeline, useAdvancedProduction } from './useAdvancedProduction' +import { useProduction } from './useProduction' +import { getProductionSession } from './model' +import AdvancedProduction from './components/AdvancedProduction.vue' +import { expandSections } from '../../testing/naive' + +let wrapper: VueWrapper | undefined +/** 从实际挂载的确认弹窗查找操作按钮。 */ +function button(label: string) { + return [...document.querySelectorAll('button')].find(item => item.textContent?.trim() === label)! +} +afterEach(() => { + wrapper?.unmount() + wrapper = undefined + document.body.innerHTML = '' + vi.unstubAllGlobals() + for (const id of ['capability-test', 'new-project']) { + Object.assign(getOperation(id), { pending: false, error: '', notice: '', label: '' }) + Object.assign(getProductionSession(id), { receipt: null, pipelineReceipt: null }) + } +}) + +/** 所有预检均是模拟 GET;生成接口只记录契约,不调用真实 Provider。 */ +function server() { + const context = testProjectContext() + const form = formFixture('capability-test') + const readiness = { + total: 1, + ready: 1, + skipped: 0, + blocked: 0, + inProgress: 0, + stalePrimaryKeyframe: 0, + items: [ + { + shotId: 'shot-1', + shotNo: 1, + episodeNo: 1, + beatNo: 1, + status: 'ready', + issues: [] as { code: string; reason: string }[], + primaryKeyframeId: 'keyframe-1', + primaryKeyframeStale: false + } + ] + } + const receipt = { + projectId: 'capability-test', + completed: true, + needsManualReview: false, + stopReason: '', + errors: ['一个提示词未生成'], + videoGenerationResult: { total: 1, created: 1, skipped: 0, failed: 0 } + } + const fetcher = vi.fn().mockImplementation(async (url, init) => { + const path = String(url) + let data: unknown = context.project.value + if (path.endsWith('/checkpoints')) data = context.checkpoints.value + else if (path.endsWith('/subject-forms')) data = [form] + else if (path.includes('/readiness')) data = readiness + else if (path.includes('/storyboard-directions')) data = directionsResult('capability-test') + else if (path.endsWith('/videos/status')) + data = { + total: 1, + completed: 0, + queued: 0, + running: 0, + pending: 0, + failed: 0, + cancelled: 0, + notStarted: 1, + items: [] + } + else if (init?.method === 'POST') + data = path.endsWith('/production/start') + ? receipt + : { total: 1, targetCount: 1, generated: 1, skipped: 0, blocked: 0, failed: 0, failures: [] } + return new Response(JSON.stringify({ data })) + }) + vi.stubGlobal('fetch', fetcher) + return { + context, + form, + readiness, + receipt, + fetcher, + posts: () => fetcher.mock.calls.filter(([, init]) => init?.method === 'POST') + } +} + +async function advanced() { + const data = server() + let service!: ReturnType + wrapper = mount( + defineComponent({ + setup() { + service = useAdvancedProduction() + return () => null + } + }), + { global: { provide: { [projectContextKey as symbol]: data.context } } } + ) + await flushPromises() + return { ...data, service } +} + +describe('高级串联生产的安全边界', () => { + it('挂载不查询或生成,通过预检仍需显式启动,再次预检后提交固定 Provider', async () => { + const { service, fetcher, posts } = await advanced() + expect(fetcher).not.toHaveBeenCalled() + await service.start() + expect(posts()).toHaveLength(0) + await service.preflight() + expect(service.check.value?.issues).toEqual([]) + expect(posts()).toHaveLength(0) + await service.start() + expect(posts()).toHaveLength(1) + expect(posts()[0]?.[0]).toBe('/api/projects/capability-test/production/start') + expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({ + imageProvider: 'seedream', + videoProvider: 'seedance' + }) + expect( + fetcher.mock.calls.filter(([url]) => String(url).includes('/keyframes/readiness?force=true')) + ).toHaveLength(2) + expect(service.session.value.pipelineReceipt?.errors).toEqual(['一个提示词未生成']) + expect(service.check.value).toBeNull() + }) + it('预检通过后主首帧变旧,确认时再次预检会拦截,不提交', async () => { + const { service, readiness, posts } = await advanced() + await service.preflight() + readiness.items[0]!.primaryKeyframeStale = true + await service.start() + expect(posts()).toHaveLength(0) + expect(service.check.value?.issues.join()).toContain('有效主首帧') + expect(getOperation('capability-test').error).toContain('条件变化') + }) + it('后台活动视频、缺失主图与剧本未完成均阻止预检通过', async () => { + const { context, form, readiness } = server() + context.data.value!.project.status = 'need_review' + form.images = [] + readiness.inProgress = 1 + const result = await checkPipeline('capability-test') + expect(result.issues.join()).toContain('剧本尚未完成') + expect(result.issues.join()).toContain('所有形态主图') + expect(result.issues.join()).toContain('活动视频任务') + }) + it('已有视频的 skipped 也不能掩盖缺失首帧,只有缺少提示词允许本流程补齐', async () => { + const { readiness } = server() + readiness.items[0]!.status = 'skipped' + readiness.items[0]!.issues = [{ code: 'missing_keyframe', reason: '无首帧' }] + const result = await checkPipeline('capability-test') + expect(result.issues.join()).toContain('视频前置检查未通过') + }) + it('项目锁和未完成剧本下不启动,切项目清空旧预检', async () => { + const { service, context, posts } = await advanced() + await service.preflight() + getOperation('capability-test').pending = true + await service.start() + expect(posts()).toHaveLength(0) + getOperation('capability-test').pending = false + context.data.value!.project.status = 'failed' + await service.start() + expect(posts()).toHaveLength(0) + context.data.value!.project.id = 'new-project' + await flushPromises() + expect(service.check.value).toBeNull() + }) + it('已提交的长请求只写回原项目的回执,不污染新项目', async () => { + const { service, context, fetcher, receipt } = await advanced() + await service.preflight() + const original = fetcher.getMockImplementation()! + let finish!: (value: Response) => void + fetcher.mockImplementation((url, init) => + String(url).endsWith('/production/start') + ? new Promise(resolve => { + finish = resolve + }) + : original(url, init) + ) + const pending = service.start() + await flushPromises() + context.data.value!.project.id = 'new-project' + await flushPromises() + finish(new Response(JSON.stringify({ data: receipt }))) + await pending + expect(service.session.value.pipelineReceipt).toBeNull() + expect(getProductionSession('capability-test').pipelineReceipt?.projectId).toBe('capability-test') + }) + it('回执项目不匹配时不发布成功回执', async () => { + const { service, receipt } = await advanced() + await service.preflight() + receipt.projectId = 'wrong' + await service.start() + expect(service.session.value.pipelineReceipt).toBeNull() + expect(getOperation('capability-test').error).toContain('回执项目不匹配') + }) + it('界面区分流程返回与视频完成,显示部分错误,启动需要费用确认', async () => { + const { context, receipt, posts } = server() + wrapper = mount(AdvancedProduction, { + attachTo: document.body, + global: { provide: { [projectContextKey as symbol]: context } } + }) + await flushPromises() + await expandSections() + expect(button('启动串联生产').disabled).toBe(true) + button('检查串联生产条件').click() + await flushPromises() + button('启动串联生产').click() + await flushPromises() + expect(button('确认启动串联生产').disabled).toBe(true) + expect(posts()).toHaveLength(0) + document.querySelector('.app-dialog [role="checkbox"]')!.click() + await flushPromises() + button('确认启动串联生产').click() + await flushPromises() + expect(posts()).toHaveLength(1) + expect(document.body.textContent).toContain('流程返回不等于成片完成') + expect(document.body.textContent).toContain(receipt.errors[0]) + expect(document.body.textContent).toContain('已提交 1') + }) +}) + +describe('首帧批量参数', () => { + it('只向首帧传上限和成对尺寸,默认范围仍是当前剧集', async () => { + const { context, posts } = server() + let service!: ReturnType + wrapper = mount( + defineComponent({ + setup() { + service = useProduction() + return () => null + } + }), + { global: { provide: { [projectContextKey as symbol]: context } } } + ) + await flushPromises() + service.keyframeLimit.value = 2 + service.keyframeWidth.value = 2048 + await service.run('keyframes') + expect(posts()).toHaveLength(0) + service.keyframeHeight.value = 2048 + await service.run('keyframes') + expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({ + provider: 'seedream', + concurrency: 2, + force: false, + episodeNo: 1, + limit: 2, + width: 2048, + height: 2048 + }) + service.keyframeLimit.value = -1 + await service.run('keyframes') + expect(posts()).toHaveLength(1) + await service.run('prompts') + expect(JSON.parse(String(posts()[1]?.[1]?.body))).toEqual({ concurrency: 2, force: false }) + service.keyframeLimit.value = '' + service.keyframeWidth.value = '' + service.keyframeHeight.value = '' + service.force.value = true + await flushPromises() + await service.run('keyframes') + expect(JSON.parse(String(posts()[2]?.[1]?.body))).toEqual({ provider: 'seedream', concurrency: 2, force: true }) + }) +}) diff --git a/src/features/production/components/AdvancedProduction.vue b/src/features/production/components/AdvancedProduction.vue new file mode 100644 index 0000000..3030fb0 --- /dev/null +++ b/src/features/production/components/AdvancedProduction.vue @@ -0,0 +1,108 @@ + + diff --git a/src/features/production/components/ProductionReceipt.vue b/src/features/production/components/ProductionReceipt.vue index 15145c9..8a457fb 100644 --- a/src/features/production/components/ProductionReceipt.vue +++ b/src/features/production/components/ProductionReceipt.vue @@ -28,6 +28,9 @@ function exportReceipt() { {{ receipt.result.generated }} · 跳过 {{ receipt.result.skipped }} · 阻塞 {{ receipt.result.blocked }} · 失败 {{ receipt.result.failed }} +

全项目 {{ receipt.result.total }} 镜 · 本次目标 {{ receipt.result.targetCount }} · 已创建任务 diff --git a/src/features/production/types.ts b/src/features/production/types.ts index 89df6bc..b4707ab 100644 --- a/src/features/production/types.ts +++ b/src/features/production/types.ts @@ -196,6 +196,8 @@ export interface GenerateKeyframeInput { /** 首帧批量生成回执;详细阻塞原因应配合 readiness 查看。 */ export interface KeyframeBatchResult { total: number + /** 应用剧集范围后、数量上限前的目标数;老版本可能不返回。 */ + selected?: number targetCount: number generated: number skipped: number @@ -265,4 +267,28 @@ export type ProductionReceipt = /** 项目生产页的浏览器会话数据。 */ export interface ProductionSession { receipt: ProductionReceipt | null + pipelineReceipt?: ProductionPipelineResult | null +} + +/** 串联生产只汇总节点结果;completed 不代表视频已完成。 */ +export interface ProductionPipelineResult { + projectId: string + imageProvider: string + videoProvider: string + visualPromptResult?: PipelineBatchResult + subjectImageResult?: PipelineBatchResult + videoPromptResult?: PipelineBatchResult + videoGenerationResult?: Omit & { created: number } + completed: boolean + needsManualReview: boolean + stopReason: string + errors: string[] +} + +/** 后端串联节点只有汇总数量,逐项失败信息保留在 errors。 */ +export interface PipelineBatchResult { + total: number + generated: number + skipped: number + failed: number } diff --git a/src/features/production/useAdvancedProduction.ts b/src/features/production/useAdvancedProduction.ts new file mode 100644 index 0000000..927e479 --- /dev/null +++ b/src/features/production/useAdvancedProduction.ts @@ -0,0 +1,155 @@ +import { computed, onScopeDispose, ref, watch } from 'vue' +import { projectsApi } from '../projects/api' +import { useProjectContext } from '../projects/context' +import { subjectImagesApi } from '../subject-images/api' +import { hasRunningImages, isPrimaryIdentityStale, primaryImage } from '../subject-images/model' +import { getOperation, runOperation } from '../workflows/operations' +import { workflowCheckpoints } from '../workflows/selectors' +import { errorMessage } from '../../lib/http' +import { productionApi } from './api' +import { getProductionSession } from './model' + +/** 高级串联入口只能消费已验图的主资产,不替用户跳过选角、设计和首帧环节。 */ +export async function checkPipeline(projectId: string) { + const [project, checkpoints, forms, keyframes, prompts, videos] = await Promise.all([ + projectsApi.detail(projectId), + projectsApi.checkpoints(projectId), + subjectImagesApi.listForms(projectId), + productionApi.keyframeReadiness(projectId, true), + productionApi.promptReadiness(projectId, true), + productionApi.videoReadiness(projectId, false) + ]) + if (project.id !== projectId || forms.some(form => form.subject.projectId !== projectId)) + throw new Error('预检返回了其他项目的数据,请重新读取。') + const issues: string[] = [] + const shotIds = new Set(keyframes.items.map(item => item.shotId)) + if ( + shotIds.size !== keyframes.total || + [prompts, videos].some( + data => + new Set(data.items.map(item => item.shotId)).size !== shotIds.size || + data.items.some(item => !shotIds.has(item.shotId)) + ) + ) + issues.push('各阶段镜头列表不一致,可能正在重新拆解,请刷新后重试。') + if (project.status !== 'completed') issues.push('剧本尚未完成,请先完成剧本创作。') + if ( + project.tasks.some(task => ['pending', 'queued', 'running', 'generating'].includes(task.status)) || + [...new Set(checkpoints.map(item => item.workflowName))].some( + name => workflowCheckpoints(checkpoints, name).at(-1)?.state.workflowExecution?.status === 'running' + ) + ) + issues.push('项目仍有后台任务或工作流进行中。') + if (!forms.length) issues.push('尚无正式形态,请先完成拆解。') + if (forms.some(form => hasRunningImages(form.images))) issues.push('形态图片仍在生成中。') + if (forms.some(form => !primaryImage(form.images)?.imageUrl || isPrimaryIdentityStale(form))) + issues.push('请先补齐并确认所有形态主图,更新过期母版引用,再生成首帧。') + if ( + !keyframes.total || + keyframes.items.length !== keyframes.total || + keyframes.items.some( + item => + !item.primaryKeyframeId || item.primaryKeyframeStale || item.issues.length || item.status === 'blocked' + ) + ) + issues.push('请先为全部镜头完成设计、身份检查和有效主首帧;串联流程不会生成首帧。') + if ( + prompts.total !== keyframes.total || + prompts.items.length !== prompts.total || + prompts.items.some(item => item.issues.length || item.status === 'blocked') + ) + issues.push('视频提示词的生成规格或参考图不完整,请到分镜页修复。') + if ( + videos.total !== keyframes.total || + videos.items.length !== videos.total || + videos.items.some(item => item.issues.some(issue => issue.code !== 'missing_prompt')) + ) + issues.push('视频前置检查未通过,请检查参考图和主首帧。') + if (videos.inProgress || videos.items.some(item => item.status === 'in_progress' || item.activeVideoId)) + issues.push('存在活动视频任务,请等待结束后再操作。') + return { projectId, issues, shotCount: keyframes.total, checkedAt: new Date().toISOString() } +} + +/** 预检不付费;确认后再次预检并锁住本浏览器项目,长请求不自动重发。 */ +export function useAdvancedProduction() { + const context = useProjectContext() + const id = computed(() => context.project.value?.id ?? '') + const operation = computed(() => getOperation(id.value)) + const session = computed(() => getProductionSession(id.value)) + const checking = ref(false) + const error = ref('') + const check = ref> | null>(null) + const blocked = computed( + () => + !id.value || + operation.value.pending || + !!context.error.value || + context.project.value?.status !== 'completed' + ) + let version = 0 + let disposed = false + onScopeDispose(() => { + disposed = true + version++ + }) + watch(id, () => { + version++ + check.value = null + error.value = '' + checking.value = false + }) + + async function preflight() { + if (blocked.value || checking.value) return + const current = ++version + const projectId = id.value + checking.value = true + check.value = null + error.value = '' + try { + const result = await checkPipeline(projectId) + if (current === version && id.value === projectId) check.value = result + } catch (cause) { + if (current === version) error.value = errorMessage(cause) + } finally { + if (current === version) checking.value = false + } + } + + async function start() { + if ( + blocked.value || + checking.value || + !check.value || + check.value.projectId !== id.value || + check.value.issues.length + ) + return + const projectId = id.value + const target = getProductionSession(projectId) + let submitted = false + await runOperation(projectId, '高级串联生产', async () => { + const result = await checkPipeline(projectId) + if ( + disposed || + id.value !== projectId || + context.project.value?.status !== 'completed' || + context.error.value + ) + return + check.value = result + if (result.issues.length) throw new Error('提交前预检发现条件变化,未启动生产。请处理下方问题。') + target.pipelineReceipt = null + submitted = true + const receipt = await productionApi.startPipeline(projectId) + if (!receipt || receipt.projectId !== projectId) + throw new Error('回执项目不匹配,请读取资产状态核对,不要直接重试。') + target.pipelineReceipt = receipt + }) + if (!disposed && id.value === projectId) { + if (submitted) check.value = null + await context.refresh() + } + } + return { id, operation, session, checking, error, check, blocked, preflight, start } +} diff --git a/src/features/production/useProduction.ts b/src/features/production/useProduction.ts index c508a9d..9716442 100644 --- a/src/features/production/useProduction.ts +++ b/src/features/production/useProduction.ts @@ -6,7 +6,7 @@ import { storyboardApi } from '../storyboard/api' import { breakdownSnapshot } from '../workflows/selectors' import { getOperation, runOperation } from '../workflows/operations' import { productionApi } from './api' -import { getProductionSession } from './model' +import { getProductionSession, validOptionalSize } from './model' /** 项目级生产动作;每次请求都由统一项目锁防止重复提交。 */ export type ProductionCommand = 'prompts' | 'keyframes' | 'videos' | 'retry-videos' @@ -19,6 +19,9 @@ export function useProduction() { const selectedShot = ref('') const concurrency = ref(2) const force = ref(false) + const keyframeLimit = ref('') + const keyframeWidth = ref('') + const keyframeHeight = ref('') const snapshot = computed(() => breakdownSnapshot(context.checkpoints.value)) const sourceEpisodes = computed( () => snapshot.value?.storyboardEpisodeShots ?? snapshot.value?.breakdownResult?.storyboardEpisodeShots ?? [] @@ -67,6 +70,11 @@ export function useProduction() { const operation = computed(() => getOperation(id.value)) const session = computed(() => getProductionSession(id.value)) const batchValid = computed(() => Number.isSafeInteger(concurrency.value) && concurrency.value > 0) + const keyframeValid = computed( + () => + (keyframeLimit.value === '' || (Number.isSafeInteger(keyframeLimit.value) && keyframeLimit.value > 0)) && + validOptionalSize(keyframeWidth.value, keyframeHeight.value) + ) const blocked = computed( () => operation.value.pending || @@ -78,7 +86,7 @@ export function useProduction() { /** 批量操作只向就绪镜头提交;最终资产状态由轮询查询确认。 */ async function run(command: ProductionCommand) { - if (blocked.value || !batchValid.value) return + if (blocked.value || !batchValid.value || (command === 'keyframes' && !keyframeValid.value)) return const projectId = id.value const input = { concurrency: concurrency.value, force: force.value } const target = getProductionSession(projectId) @@ -114,7 +122,11 @@ export function useProduction() { provider: 'seedream', ...input, // 有 stale 时由后端优先处理全部 stale;否则只补当前剧集,避免误跑整个项目。 - ...(force.value || staleKeyframes > 0 ? {} : { episodeNo: episodeNo.value }) + ...(force.value || staleKeyframes > 0 ? {} : { episodeNo: episodeNo.value }), + ...(keyframeLimit.value === '' ? {} : { limit: keyframeLimit.value }), + ...(keyframeWidth.value === '' || keyframeHeight.value === '' + ? {} + : { width: keyframeWidth.value, height: keyframeHeight.value }) }) } } else if (command === 'videos') { @@ -151,6 +163,10 @@ export function useProduction() { session, concurrency, force, + keyframeLimit, + keyframeWidth, + keyframeHeight, + keyframeValid, batchValid, blocked, run diff --git a/src/features/subject-identity/components/IdentityGallery.vue b/src/features/subject-identity/components/IdentityGallery.vue index 0f6bf1a..b90d562 100644 --- a/src/features/subject-identity/components/IdentityGallery.vue +++ b/src/features/subject-identity/components/IdentityGallery.vue @@ -153,7 +153,7 @@ function chooseAnchor() { {{ character ? '确认选择这张图片作为正式演员?后端会在同一事务中切换身份母版并锁定 Identity,完成后首帧才具备稳定人物身份。' - : '确认切换身份母版?旧 primary 母版将停用并保留。后续场景形态生图会引用新母版。此操作不调用模型。' + : '确认切换身份母版?旧 primary 母版将停用并保留。身份锁定后,后续场景/道具形态生图会引用新母版。此操作不调用模型。' }}

diff --git a/src/features/subject-images/SubjectImagesPage.vue b/src/features/subject-images/SubjectImagesPage.vue index 8887d03..bde5114 100644 --- a/src/features/subject-images/SubjectImagesPage.vue +++ b/src/features/subject-images/SubjectImagesPage.vue @@ -13,6 +13,8 @@ import { coverImage, hasRunningImages, isPrimaryIdentityStale, primaryImage } fr import type { GenerateFormImageInput, SubjectFormAsset } from './types' import GenerateImageDialog from './components/GenerateImageDialog.vue' import ImageGalleryDialog from './components/ImageGalleryDialog.vue' +import FormPromptDialog from './components/FormPromptDialog.vue' +import FormPromptBatch from './components/FormPromptBatch.vue' /** 形态图库以正式数据库为准,自动显示已在后端生成的图片。 */ const route = useRoute() @@ -25,6 +27,12 @@ const { session, concurrency, force, + limit, + promptConcurrency, + promptForce, + concurrencyValid, + generatePrompt, + generatePrompts, blocked, running, batchValid, @@ -40,6 +48,12 @@ watch( if (receipt) toolsOpen.value = true } ) +watch( + () => session.value.promptReceipt, + receipt => { + if (receipt) toolsOpen.value = true + } +) const search = ref('') const module = ref('all') const onlyMissing = ref(false) @@ -48,6 +62,9 @@ const generateOpen = ref(false) const galleryOpen = ref(false) const generateId = ref('') const galleryId = ref('') +const promptOpen = ref(false) +const promptId = ref('') +const promptForm = computed(() => forms.value.find(form => form.id === promptId.value) ?? null) const generateForm = computed(() => forms.value.find(form => form.id === generateId.value) ?? null) const galleryForm = computed(() => forms.value.find(form => form.id === galleryId.value) ?? null) const completeCount = computed(() => forms.value.filter(form => primaryImage(form.images)).length) @@ -70,6 +87,12 @@ watch( { immediate: true } ) +/** 打开指定正式形态的提示词管理。 */ +function openPrompt(form: SubjectFormAsset) { + promptId.value = form.id + promptOpen.value = true +} + /** 打开指定正式形态的生图表单。 */ function openGenerate(form: SubjectFormAsset) { generateId.value = form.id @@ -111,7 +134,7 @@ function resetFilters() { >

形态图片

@@ -124,18 +147,27 @@ function resetFilters() { />
- Character 形态图会记录生成时使用的 Identity - Anchor。更换演员母版后,旧主图会标记为“身份母版已变更”,且后端会阻止它继续进入分镜首帧;请基于当前母版重新生成候选并确认新的主参考图。 + 人物、场景、道具形态图均可继承已锁定 Identity + 的母版,并记录本次引用来源。母版变化后可筛选过期图,重新生成候选并人工确认新的主参考图;不要直接沿用旧资产进入下游。 管理主体身份 → +

{{ completeCount }} / {{ forms.length }} 个形态已有主图 - {{ staleForms.length }} 个人物形态主图已过期 + {{ staleForms.length }} 个形态主图已过期 Seedream · 后端配置

@@ -147,14 +179,14 @@ function resetFilters() {
- 检测到 {{ staleForms.length }} 个 Character Form - 仍在使用旧 Identity Anchor。 + 检测到 {{ staleForms.length }} 个形态 仍在使用旧 + Identity Anchor。
@@ -173,6 +205,17 @@ function resetFilters() { :step="1" > +
-

并发必须是正整数。

+

+ 并发和填写的数量上限必须是正整数。 +

- 作用于整个项目,不受下方筛选影响。批量使用后端默认尺寸与形态提示词,不会启动视频生成。 + 作用于整个项目,不受下方筛选影响。上限只限制本次真正生图数量,其余显示为“待后续处理”,不计入跳过。默认使用后端尺寸与正式提示词,不会启动视频生成。

@@ -210,6 +255,9 @@ function resetFilters() { {{ session.receipt.result.targetCount }} · 生成 {{ session.receipt.result.generated }} · 跳过 {{ session.receipt.result.skipped }} · 失败 {{ session.receipt.result.failed }} + + · 待后续处理 {{ session.receipt.result.remaining }}

- 当前主图由旧演员母版生成,后续分镜已禁止继续引用。请基于当前 Identity Anchor + 当前主图未能匹配最新已锁定母版。请基于当前 Identity Anchor 重新生成候选图,并在确认后设置为新的主参考图。

{{ form.images[0].error }}

+ 提示词与生成 查看图片与记录 @@ -357,7 +406,11 @@ function resetFilters() { 前往剧本拆解 + request(`/subject-forms/${encodeURIComponent(formId)}/generation-prompt`, { + method: 'POST', + body: { force }, + timeoutMs: 0 + }), + /** 批量增强全项目形态,不受当前列表筛选影响。 */ + generatePrompts: (projectId: string, input: GenerateFormPromptsInput) => + request(`/projects/${encodeURIComponent(projectId)}/subject-forms/generation-prompts`, { + method: 'POST', + body: input, + timeoutMs: 0 + }), listForms: (projectId: string, signal?: AbortSignal) => request(`/projects/${encodeURIComponent(projectId)}/subject-forms`, { signal }), listImages: (formId: string, signal?: AbortSignal) => request(formPath(formId), { signal }), diff --git a/src/features/subject-images/components/FormPromptBatch.vue b/src/features/subject-images/components/FormPromptBatch.vue new file mode 100644 index 0000000..29b482a --- /dev/null +++ b/src/features/subject-images/components/FormPromptBatch.vue @@ -0,0 +1,81 @@ + + diff --git a/src/features/subject-images/components/FormPromptDialog.vue b/src/features/subject-images/components/FormPromptDialog.vue new file mode 100644 index 0000000..12eab57 --- /dev/null +++ b/src/features/subject-images/components/FormPromptDialog.vue @@ -0,0 +1,60 @@ + + + diff --git a/src/features/subject-images/components/GenerateImageDialog.vue b/src/features/subject-images/components/GenerateImageDialog.vue index 44c3848..5f66066 100644 --- a/src/features/subject-images/components/GenerateImageDialog.vue +++ b/src/features/subject-images/components/GenerateImageDialog.vue @@ -15,9 +15,6 @@ const height = ref('') const setPrimary = ref(false) const acknowledged = ref(false) const dimensionsValid = computed(() => validImageSize(width.value, height.value)) -const hasPrompt = computed( - () => !!(prompt.value.trim() || (props.form?.generationPrompt ?? props.form?.appearancePrompt ?? '').trim()) -) /** 每次打开重新建立表单,避免把另一形态的 Prompt 或尺寸带入请求。 */ watch( @@ -34,7 +31,7 @@ watch( /** 完成前置确认后只发出一次明确的生图请求,不触发整条生产流程。 */ function submit() { - if (!props.form || props.disabled || !dimensionsValid.value || !hasPrompt.value || !acknowledged.value) return + if (!props.form || props.disabled || !dimensionsValid.value || !acknowledged.value) return emit('generate', props.form.id, { provider: 'seedream', setPrimary: setPrimary.value, @@ -56,17 +53,13 @@ function submit() { >使用后端配置的 Seedream 模型,可能产生费用。每次新增一张图片,不删除历史结果。

- {{ - form?.subject.module === 'character' || form?.subject.module === 'scene' - ? '后端会自动引用此人物/场景当前的身份母版,保持身份或空间结构;没有母版时仍可按文本生成。更换母版不会自动更新已有形态图。' - : '当前道具形态生图暂不自动引用身份母版。' - }} + 人物、场景、道具均会引用已锁定身份的当前母版,保持身份、空间骨架或物件结构;身份未锁定或没有母版时不会继承该图片。更换母版不会自动更新已有形态图。 身份母版与此处的“形态主参考图”是两种不同用途的图片。