修复编辑器生成幂等与参考图契约
生成队列按稳定请求标识去重并保持外部幂等哈希兼容 统一拒绝参考图超限并同步前端、后端、Provider 与 OpenAPI 契约 按真实归属重建生成引用并阻止直接持久化伪造来源 锁定参考图在途上传上下文并保留批量部分成功结果 关闭内部生成 POST 自动重试并补齐回归测试与项目文档 修正最新主线开发者密钥弹窗的导入排序门禁
This commit is contained in:
@@ -3047,7 +3047,7 @@
|
|||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS 再提交。禁止 Data URL / Blob URL。普通生成最多使用前 5 张,数组上限为 9。"
|
"description": "当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS 再提交。禁止 Data URL / Blob URL。普通生成最多 5 张;kind=quick-edit 时 gpt-image-2 最多 5 张、nanobanana2 最多 9 张。超限返回 400,不会静默截断。"
|
||||||
},
|
},
|
||||||
"maxItems": 9
|
"maxItems": 9
|
||||||
},
|
},
|
||||||
@@ -3241,7 +3241,7 @@
|
|||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。"
|
"description": "当前账号的 objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。sourceImageSrc 占用 1 张 provider 容量,因此 gpt-image-2 最多再提交 4 张、nanobanana2 最多再提交 8 张;超限返回 400,不会静默截断。"
|
||||||
},
|
},
|
||||||
"maxItems": 8
|
"maxItems": 8
|
||||||
},
|
},
|
||||||
@@ -3372,7 +3372,7 @@
|
|||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "额外图标素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。最多 8 张。"
|
"description": "额外图标素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。referenceImageSrc 占用 1 张 provider 容量,因此 gpt-image-2 最多再提交 4 张、nanobanana2 最多再提交 8 张;超限返回 400,不会静默截断。"
|
||||||
},
|
},
|
||||||
"maxItems": 8
|
"maxItems": 8
|
||||||
},
|
},
|
||||||
@@ -3496,7 +3496,7 @@
|
|||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "额外 UI 素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。最多 5 张。"
|
"description": "额外 UI 素材参考图的稳定引用:objectKey、项目资源 ID 或素材 ID;本地临时图必须先上传 OSS。禁止 Data URL / Blob URL。sourceImageSrc 占用 1 张 provider 容量,因此 gpt-image-2 最多再提交 4 张、nanobanana2 最多再提交 5 张;超限返回 400,不会静默截断。"
|
||||||
},
|
},
|
||||||
"maxItems": 5
|
"maxItems": 5
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6574,3 +6574,10 @@
|
|||||||
- 遗留(建议单开,不在本次范围):`loadProjectCoverImage` 里无 timeout / 无 AbortSignal 的 `new Image()` 本身仍是隐患,自动保存路径一样会踩。本次只是把它移出生成链的关键路径,没有消除它。
|
- 遗留(建议单开,不在本次范围):`loadProjectCoverImage` 里无 timeout / 无 AbortSignal 的 `new Image()` 本身仍是隐患,自动保存路径一样会踩。本次只是把它移出生成链的关键路径,没有消除它。
|
||||||
- 影响范围:`useImageCanvasProjectPersistence.ts` 的 `flushProjectPersistence`。不改服务端、不改契约。
|
- 影响范围:`useImageCanvasProjectPersistence.ts` 的 `flushProjectPersistence`。不改服务端、不改契约。
|
||||||
- 验证方式:既有用例「flush 等待封面缓存」翻转为「flush 不等封面、但封面链照常跑完并完成上传与资源登记」;新增「封面永不 settle 时 flush 仍返回」——用永不 resolve 的 blob 模拟 `new Image()` 不 settle,并断言 `createProjectCoverSnapshotBlob` 确实被调用过以防用例空过。已实证:回退修复后新用例报 `expected 'false' to be 'true'`。运行 `npx vitest run src/components/image-editor src/components/platform-entry src/services`(101 文件 / 1241 项)、`npm run typecheck`、`npm run lint:eslint`、`npm run check:encoding`。
|
- 验证方式:既有用例「flush 等待封面缓存」翻转为「flush 不等封面、但封面链照常跑完并完成上传与资源登记」;新增「封面永不 settle 时 flush 仍返回」——用永不 resolve 的 blob 模拟 `new Image()` 不 settle,并断言 `createProjectCoverSnapshotBlob` 确实被调用过以防用例空过。已实证:回退修复后新用例报 `expected 'false' to be 'true'`。运行 `npx vitest run src/components/image-editor src/components/platform-entry src/services`(101 文件 / 1241 项)、`npm run typecheck`、`npm run lint:eslint`、`npm run check:encoding`。
|
||||||
|
## 2026-08-05 编辑器生成请求与参考图权威契约
|
||||||
|
|
||||||
|
- 主站编辑器生成 POST 不做浏览器自动重试,避免 inline 模式在响应丢失后重复调用 provider;api-server 仍使用独立 namespace + owner + job kind + request id 生成队列 `dedupe_key`,让显式复用同一请求标识的队列重放原子返回已存在任务,并对同键不同 payload 返回 `409`。外部 v1 的 `Idempotency-Key` 保持独立 namespace。
|
||||||
|
- 参考图数量以产品上限与 provider 容量的较小值为准,前端添加 / 上传 / 提交、api-server 入队与执行、`platform-image` provider 边界均明确拒绝超限;任何层都不再用 `.take(...)` 把第 N+1 张静默丢弃。角色 / UI 从一开始预留主图槽位;并发上传计入在途数量并在持久化前复验。任一参考图上传批次在途时锁定模型切换、画布选图、提交生成、关联源图删除 / 剪切 / 素材删除及生成面板切换 / 关闭,并以原面板上下文标识在持久化前后复验,完成或失败并释放 reservation 后才允许继续操作;批量部分失败时仍挂接成功项并刷新素材库。模型降容或后补主图会超限时拒绝操作并保留现有引用。
|
||||||
|
- 图片类最终 `generationInputs.references` 不信任客户端输入;队列 payload、完美像素及直接创建资源 / 素材入口删除客户端 references,worker / inline 路径按本次真实参考源与 owner 范围内的项目资源、账号素材重建 `refType/refId`。只有 owned objectKey 但没有正式行时不生成伪 provenance。完美像素继续使用升级前 canonical 客户端输入计算 operation fingerprint;新操作只持久化权威重建值,历史同 task/resource 重放复用服务端既存 metadata 通过精确比较。
|
||||||
|
- 升级前 External 幂等任务可能仍在 payload 中保留客户端 references;重放比较只对白名单内已迁移的图片生成、图片修改、去背景、图标图集和 UI 提取任务,在旧侧有 references、当前侧已删除时移除旧字段,其他字段变化仍返回 `409`。音频 / 视频 / 角色动作等未迁移 job kind 始终完整比较,不能扩大兼容面。
|
||||||
|
- 本次复用既有 `external_generation_job.dedupe_key` 唯一索引和 `spacetime-client` 查询,不改 SpacetimeDB schema、迁移或 bindings。
|
||||||
|
|||||||
@@ -4195,3 +4195,10 @@
|
|||||||
- 原因:两个跨模块测试读写同一进程全局状态,却没有共用隔离边界;只给 accept 后取得的 stream 设置 read timeout 无法约束 accept 本身,payload 读取也缺少总 deadline。
|
- 原因:两个跨模块测试读写同一进程全局状态,却没有共用隔离边界;只给 accept 后取得的 stream 设置 read timeout 无法约束 accept 本身,payload 读取也缺少总 deadline。
|
||||||
- 处理:全部全局 sink 测试共用一把 test-only 串行锁,并由 RAII guard 在 `Drop` 中无条件清空;测试统一使用 `manifest_invalidation_sink_isolation_` 前缀。relay fixture 对 accept 和 payload 分别使用非阻塞轮询与总 deadline,不使用固定 sleep;生产 loopback、token、连接 / 写入超时和 payload 大小校验保持不变。
|
- 处理:全部全局 sink 测试共用一把 test-only 串行锁,并由 RAII guard 在 `Drop` 中无条件清空;测试统一使用 `manifest_invalidation_sink_isolation_` 前缀。relay fixture 对 accept 和 payload 分别使用非阻塞轮询与总 deadline,不使用固定 sleep;生产 loopback、token、连接 / 写入超时和 payload 大小校验保持不变。
|
||||||
- 验证:用 `--test-threads=2` 重复运行统一 filter,覆盖正常 relay、无事件 accept 超时、不完整 payload 超时、panic 展开清理,以及 GUI owner attach 配置与 guard 清理。
|
- 验证:用 `--test-threads=2` 重复运行统一 filter,覆盖正常 relay、无事件 accept 超时、不完整 payload 超时、panic 展开清理,以及 GUI owner attach 配置与 guard 清理。
|
||||||
|
|
||||||
|
## 编辑器生成不能把传输重试、参考图截断和客户端 provenance 当成独立小问题(2026-08-05)
|
||||||
|
|
||||||
|
- 现象:生成 POST 首次已经入队但响应丢失时,客户端自动重试产生第二个任务;第 6 张或更多参考图仍显示在 UI / 元数据里,却没有送给 provider;直接构造请求还能把任意资源 ID 写成最终素材引用。
|
||||||
|
- 原因:客户端虽在重试中复用 `x-request-id`,队列入口却用随机 job id 生成 dedupe key;前端允许无限追加,api-server 和 provider 用 `.take(...)` 静默截断;`generationInputs.references` 被当成可信持久 provenance。
|
||||||
|
- 处理:主站生成 POST 禁止自动重试,把显式复用的稳定 request id 接到队列唯一键并校验 replay payload;所有边界显式拒绝超限,前端还要预留主图槽位、统计在途上传,并在上传完成前拒绝模型切换、画布选图、提交生成、关联源图删除 / 剪切 / 素材删除和面板切换 / 关闭;reservation 必须绑定原面板上下文,批量部分失败时不能丢弃已经持久化的成功项。入队、完美像素及直接创建资源 / 素材时删除客户端 references,执行时按真实参考源和 owner 资源记录重建权威引用。历史任务比较必须兼容仅差已删除 references 的旧 payload,不能只保留旧 hash 却让 payload 比较误报冲突。
|
||||||
|
- 验证:覆盖同键同 payload / 不同 payload、普通图片第 6 张、带主图的 GPT-image-2 第 5 张额外引用、provider 6 / 15 张边界、伪造引用删除和 owned 资源 / 素材重建。
|
||||||
|
|||||||
@@ -34,6 +34,10 @@
|
|||||||
- 画布底部工具栏 / 面板 Dock 提供“画布 Agent”入口。点击后打开右侧独立 Agent 对话面板;桌面端为右侧窄面板,移动端占满可用宽度。该面板只与右上角任务侧栏互斥;素材 / 图层侧栏允许与 Agent 同时展开,切换左侧栏不得关闭 Agent。Agent 面板不得在当前画布内容下方追加内联内容,也不默认展示大段功能说明文案。
|
- 画布底部工具栏 / 面板 Dock 提供“画布 Agent”入口。点击后打开右侧独立 Agent 对话面板;桌面端为右侧窄面板,移动端占满可用宽度。该面板只与右上角任务侧栏互斥;素材 / 图层侧栏允许与 Agent 同时展开,切换左侧栏不得关闭 Agent。Agent 面板不得在当前画布内容下方追加内联内容,也不默认展示大段功能说明文案。
|
||||||
- 所有会新建画布生成占位的入口必须先创建 draft,再统一经过 `ImageCanvasGenerationPlacementModel` 计算落点,禁止各入口自行使用当前视口中心裸坐标或原图右侧固定偏移。当前覆盖入口包括 `生成图片`、`生成规范`、`生成角色形象`、`生成图标素材`、`生成视频`、`生成UI设计图` 和 `生成角色动作`。placement 模型的避让对象为所有未隐藏画布图层,以及当前 active / inactive generation dialogs 中仍存在的 placeholder;每个避让矩形按 32px 画布世界坐标间距外扩。候选落点以当前视口世界中心为距离目标,优先选择离视口中心最近且不重叠的占位位置;若中心被占用,会按上下左右和环形候选继续寻找。打开生成面板时必须把避让后的 placeholder 写入 `openCanvasGenerationDialog(...)`,并立即调用 `centerViewportOnPlacement(...)` 居中到新占位中心,保持原 viewport scale 不变;图片快速编辑不属于新建占位入口,提交后覆盖源图。
|
- 所有会新建画布生成占位的入口必须先创建 draft,再统一经过 `ImageCanvasGenerationPlacementModel` 计算落点,禁止各入口自行使用当前视口中心裸坐标或原图右侧固定偏移。当前覆盖入口包括 `生成图片`、`生成规范`、`生成角色形象`、`生成图标素材`、`生成视频`、`生成UI设计图` 和 `生成角色动作`。placement 模型的避让对象为所有未隐藏画布图层,以及当前 active / inactive generation dialogs 中仍存在的 placeholder;每个避让矩形按 32px 画布世界坐标间距外扩。候选落点以当前视口世界中心为距离目标,优先选择离视口中心最近且不重叠的占位位置;若中心被占用,会按上下左右和环形候选继续寻找。打开生成面板时必须把避让后的 placeholder 写入 `openCanvasGenerationDialog(...)`,并立即调用 `centerViewportOnPlacement(...)` 居中到新占位中心,保持原 viewport scale 不变;图片快速编辑不属于新建占位入口,提交后覆盖源图。
|
||||||
|
|
||||||
|
- `generationInputs.references` 的 `refType/refId` 是服务端权威行引用:客户端提交的 references 只属于非权威展示候选,api-server 入队及直接创建资源 / 素材时删除,生成执行时按真实参考图和当前 owner 的资源 / 素材记录重建后再持久化;裸 owned objectKey 找不到正式资源或素材行时可以参与生成,但不得制造伪引用。`title/label` 只作为展示快照,不提升为资源身份。
|
||||||
|
- 普通图片生成最多选择 5 张参考图;带主图 / 规范图的图片修改、图标素材和 UI 素材提取需要从打开面板起预留这 1 张主引用,再与 provider 容量取最小值(GPT-image-2 总计 5 张,nanobanana2 总计 14 张)。画布选择、上传和最终提交都必须阻止第 N+1 张进入请求;并发上传要把在途批次计入容量,并在创建项目资源 / 账号素材前按最新模型复验。reservation 必须绑定发起上传的 dialog / 快速编辑 / UI 提取上下文;任一参考图上传批次在途时,所有图片模型切换、从画布添加主图 / 规范图 / 参考图、提交生成、删除 / 剪切关联源图、删除其来源素材以及生成面板切换 / 关闭都要明确拒绝,批次持久化前后还要复验上下文未变化。批次部分失败时要保留并挂接已经成功持久化的引用,不能因其中一项失败而丢弃整批成功项;完成或失败并释放 reservation 后才允许继续操作。模型降容或后补主图若在操作当下已经超限,应保留原模型 / 原参考图并明确提示用户先删除,不得用 `slice` 静默丢弃;旧 dialog 或直接 API 请求由后端返回明确超限错误。
|
||||||
|
- 主站编辑器生成 POST 在浏览器端不自动重试;队列模式仍按同一 `x-request-id` 幂等重放,External v1 使用显式 `Idempotency-Key`。inline 模式没有结果级幂等时,不得因 408 / 429 / 5xx 或传输异常自动再次调用 provider。
|
||||||
|
|
||||||
### 静态图片风格与像素规整边界
|
### 静态图片风格与像素规整边界
|
||||||
|
|
||||||
- 普通 `生成图片`、`生成角色形象` 和 `生成图标素材` 三个面板增加紧凑的 `像素艺术` 勾选项;移动端可独占一行,但不增加功能说明文案。当前生成对象以 `style: "none" | "pixelArt"` 保存选择并随现有请求 / 队列 payload 传递;该字段不写入用户可见 `generationInputs`,也不新增素材元数据字段。其它生成、编辑、UI 素材提取、角色动画及画布 Agent 入口不展示或设置该选项。
|
- 普通 `生成图片`、`生成角色形象` 和 `生成图标素材` 三个面板增加紧凑的 `像素艺术` 勾选项;移动端可独占一行,但不增加功能说明文案。当前生成对象以 `style: "none" | "pixelArt"` 保存选择并随现有请求 / 队列 payload 传递;该字段不写入用户可见 `generationInputs`,也不新增素材元数据字段。其它生成、编辑、UI 素材提取、角色动画及画布 Agent 入口不展示或设置该选项。
|
||||||
|
|||||||
@@ -243,8 +243,10 @@ npm run check:server-rs-ddd
|
|||||||
6. 编辑器图片生成 / 图片修改 / 图标 spritesheet / UI 设计图提取素材 / 视频 / 角色动作 / 音效 / 背景音乐必须在后端计算模型价格后使用 `execute_billable_asset_operation_with_cost` 预扣泥点;预扣失败必须 fail-closed,不得继续提交 VectorEngine、Ark、Suno 或 Vidu 上游任务。
|
6. 编辑器图片生成 / 图片修改 / 图标 spritesheet / UI 设计图提取素材 / 视频 / 角色动作 / 音效 / 背景音乐必须在后端计算模型价格后使用 `execute_billable_asset_operation_with_cost` 预扣泥点;预扣失败必须 fail-closed,不得继续提交 VectorEngine、Ark、Suno 或 Vidu 上游任务。
|
||||||
7. 队列任务按 `job_id + claim_attempt` 使用独立 consume/refund ledger。新 attempt 结算旧 attempt 时必须先写 `asset_operation_wallet_settlement`:旧 consume 已存在则原子退款,尚不存在则写取消 intent;迟到 consume 在同一 SpacetimeDB 事务内看到 intent 后必须失败关闭。重复 consume/refund 只有用户、金额、来源和配对 ledger 全部一致时才可视为幂等成功。lease 过期时只有 `attempt < max_attempts` 才能递增并重领;最终 attempt 已耗尽时,claim transaction 必须直接把 job 收口为 `failed`、清理 lease、写失败事件并结算当前 attempt,不能再把任务返回 worker 或调用 provider。
|
7. 队列任务按 `job_id + claim_attempt` 使用独立 consume/refund ledger。新 attempt 结算旧 attempt 时必须先写 `asset_operation_wallet_settlement`:旧 consume 已存在则原子退款,尚不存在则写取消 intent;迟到 consume 在同一 SpacetimeDB 事务内看到 intent 后必须失败关闭。重复 consume/refund 只有用户、金额、来源和配对 ledger 全部一致时才可视为幂等成功。lease 过期时只有 `attempt < max_attempts` 才能递增并重领;最终 attempt 已耗尽时,claim transaction 必须直接把 job 收口为 `failed`、清理 lease、写失败事件并结算当前 attempt,不能再把任务返回 worker 或调用 provider。
|
||||||
8. 音频生成的编辑器链路虽然任务提交和结果发布分离,仍必须把提交时后端计算出的模型价格写入 `AudioAssetBindingTarget.billing_points_cost`,最终发布落资产时按该价格扣费;创作音频目标未提供该字段时才使用旧的创作音频固定成本。
|
8. 音频生成的编辑器链路虽然任务提交和结果发布分离,仍必须把提交时后端计算出的模型价格写入 `AudioAssetBindingTarget.billing_points_cost`,最终发布落资产时按该价格扣费;创作音频目标未提供该字段时才使用旧的创作音频固定成本。
|
||||||
9. 编辑器进入外部生成持久队列的图片生成、图片修改、去背景、图标 spritesheet、UI 设计图提取、角色动作和视频参考图,调用方必须提交 `objectKey` / `resourceId` / `assetId` 候选引用;BFF 只做内联媒体与 payload 门禁,登记状态和归属由 worker 统一解析。任务 `request_payload_json` / `result_payload_json` 任意层级都禁止 `data:` / `blob:`,并受统一字节上限保护。无效普通字符串可以入队,但必须在签名和 provider 调用前失败;本次不增加 API 侧数据库查询或同步 owner 校验。若以后要求无效引用同步返回 400,应作为独立改造。objectKey 最终必须归属于当前账号的 `editor_project_resource`、`editor_asset` 或 `asset_object`,由 worker 在解析后、签名读取 OSS 前完成归属校验。本地红框序号标注图必须先上传并确认对象,再把 objectKey 入队;不得把既有 objectKey 下载成 Data URL 后写入任务。图标素材和 UI 素材提取的额外参考图必须真正传入 provider,不得只写入 `generationInputs` 展示快照;图片快速编辑当前不开放额外参考图。UI 素材提取额外参考图上限为 5 张,普通图片生成上限 5 张,图标素材上限 8 张额外参考图。同步且不持久化的历史兼容入口即使仍能解析 Data URL,也不能把该值转存到工程、素材、元数据、审计或任务表。
|
9. 编辑器进入外部生成持久队列的图片生成、图片修改、去背景、图标 spritesheet、UI 设计图提取、角色动作和视频参考图,调用方必须提交 `objectKey` / `resourceId` / `assetId` 候选引用;BFF 只做内联媒体与 payload 门禁,登记状态和归属由 worker 统一解析。任务 `request_payload_json` / `result_payload_json` 任意层级都禁止 `data:` / `blob:`,并受统一字节上限保护。无效普通字符串可以入队,但必须在签名和 provider 调用前失败;本次不增加 API 侧数据库查询或同步 owner 校验。若以后要求无效引用同步返回 400,应作为独立改造。objectKey 最终必须归属于当前账号的 `editor_project_resource`、`editor_asset` 或 `asset_object`,由 worker 在解析后、签名读取 OSS 前完成归属校验。本地红框序号标注图必须先上传并确认对象,再把 objectKey 入队;不得把既有 objectKey 下载成 Data URL 后写入任务。图标素材、图片快速编辑和 UI 素材提取的额外参考图必须真正传入 provider,不得只写入 `generationInputs` 展示快照。普通图片生成最多 5 张参考图;图片修改、图标素材和 UI 提取的额外参考图上限还必须与所选 provider 的总容量共同取最小值:GPT-image-2 总计 5 张,nanobanana2 总计 14 张。前端添加和提交、api-server 入队 / 扣费前以及 `platform-image` provider 边界都必须明确拒绝超限,禁止用 `.take(...)` 静默截断。同步且不持久化的历史兼容入口即使仍能解析 Data URL,也不能把该值转存到工程、素材、元数据、审计或任务表。
|
||||||
10. 已有静态图片的 `POST /api/editor/images/pixel-art-snaps` 是免费 inline 派生操作,不调用外部 provider、不创建 `external_generation_job`、不读写泥点 ledger,也不进入任务侧栏。免费不放宽 owner、稳定引用、输入上限、持久化或处理阶段零持久化门禁。
|
10. 已有静态图片的 `POST /api/editor/images/pixel-art-snaps` 是免费 inline 派生操作,不调用外部 provider、不创建 `external_generation_job`、不读写泥点 ledger,也不进入任务侧栏。免费不放宽 owner、稳定引用、输入上限、持久化或处理阶段零持久化门禁。
|
||||||
|
11. 主站编辑器生成队列使用同一次前端请求稳定复用的 `x-request-id`,按 namespace + owner + job kind + request id 生成唯一 `dedupe_key`;首次请求已入队但响应丢失时,重试必须返回原任务。同一幂等键携带不同 payload 返回 `409`,不得创建第二个任务或串到旧结果。外部 v1 的 `Idempotency-Key` 使用独立 namespace,不能与主站请求标识碰撞。幂等 payload 比较只对本次已迁移 sanitizer 的图片生成、图片修改、去背景、图标图集和 UI 提取任务,兼容“升级前旧任务仍含客户端 `generationInputs.references`、当前请求已删除该字段”的单向形状;当前请求仍含 references,或 job kind 属于音频 / 视频 / 角色动作等未迁移任务时必须完整比较,其余请求字段始终完全一致。
|
||||||
|
12. `generationInputs.references` 是最终资产的服务端权威行引用,不接受客户端自报 provenance。图片生成类请求入队、完美像素及直接创建资源 / 素材时删除客户端 references;worker 和 inline 路径按本次真实参考图、当前 owner 的项目资源 / 素材记录重建 `refType/refId` 后再持久化。仅能证明 owned objectKey、但找不到对应资源或素材行时可以参与生成,不得制造虚假行引用;`title/label` 只作为展示快照,不提升为资源身份。完美像素为兼容升级前的未知结果重放,可继续用旧版 canonical 客户端输入计算 operation fingerprint;新操作持久化元数据只能使用服务端重建值,检测到 owner 项目中已存在同一稳定 task/resource 的历史结果时则复用该服务端既存 metadata 完成精确 compare-and-return。
|
||||||
|
|
||||||
## 外部服务与资产
|
## 外部服务与资产
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ pub(crate) const EDITOR_GENERATION_QUEUE_SOURCE_MODULE: &str = "editor-canvas";
|
|||||||
const EDITOR_GENERATION_QUEUE_PROVIDER: &str = "editor-generation-worker";
|
const EDITOR_GENERATION_QUEUE_PROVIDER: &str = "editor-generation-worker";
|
||||||
const MAX_EDITOR_GENERATION_JOB_PAYLOAD_BYTES: usize = 512 * 1024;
|
const MAX_EDITOR_GENERATION_JOB_PAYLOAD_BYTES: usize = 512 * 1024;
|
||||||
const EXTERNAL_API_GENERATION_DEDUPE_PREFIX: &str = "external-api-generation";
|
const EXTERNAL_API_GENERATION_DEDUPE_PREFIX: &str = "external-api-generation";
|
||||||
|
const EDITOR_API_REQUEST_GENERATION_DEDUPE_PREFIX: &str = "editor-api-request-generation";
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -37,7 +38,7 @@ pub(crate) struct EditorGenerationQueuedResponse {
|
|||||||
|
|
||||||
pub(crate) async fn enqueue_editor_generation_job<T>(
|
pub(crate) async fn enqueue_editor_generation_job<T>(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
_request_context: &RequestContext,
|
request_context: &RequestContext,
|
||||||
owner_user_id: &str,
|
owner_user_id: &str,
|
||||||
job_kind: &str,
|
job_kind: &str,
|
||||||
source_entity_id: impl Into<String>,
|
source_entity_id: impl Into<String>,
|
||||||
@@ -49,6 +50,12 @@ where
|
|||||||
T: Serialize,
|
T: Serialize,
|
||||||
{
|
{
|
||||||
let job_id = build_prefixed_uuid_id("task-");
|
let job_id = build_prefixed_uuid_id("task-");
|
||||||
|
let dedupe_key = build_editor_generation_dedupe_key(
|
||||||
|
EDITOR_API_REQUEST_GENERATION_DEDUPE_PREFIX,
|
||||||
|
owner_user_id,
|
||||||
|
job_kind,
|
||||||
|
request_context.request_id().trim(),
|
||||||
|
);
|
||||||
enqueue_editor_generation_job_with_identity(
|
enqueue_editor_generation_job_with_identity(
|
||||||
state,
|
state,
|
||||||
owner_user_id,
|
owner_user_id,
|
||||||
@@ -58,11 +65,26 @@ where
|
|||||||
price_mud_points,
|
price_mud_points,
|
||||||
payload,
|
payload,
|
||||||
job_id.clone(),
|
job_id.clone(),
|
||||||
format!("editor-canvas:{job_kind}:{job_id}"),
|
dedupe_key,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_editor_generation_dedupe_key(
|
||||||
|
namespace: &str,
|
||||||
|
owner_user_id: &str,
|
||||||
|
job_kind: &str,
|
||||||
|
stable_key: &str,
|
||||||
|
) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(owner_user_id.trim().as_bytes());
|
||||||
|
hasher.update(b"\0");
|
||||||
|
hasher.update(job_kind.trim().as_bytes());
|
||||||
|
hasher.update(b"\0");
|
||||||
|
hasher.update(stable_key.as_bytes());
|
||||||
|
format!("{namespace}:{job_kind}:{:x}", hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub(crate) async fn enqueue_editor_generation_job_with_identity<T>(
|
pub(crate) async fn enqueue_editor_generation_job_with_identity<T>(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
@@ -79,18 +101,25 @@ where
|
|||||||
T: Serialize,
|
T: Serialize,
|
||||||
{
|
{
|
||||||
let request_payload_json = serialize_editor_generation_job_payload(payload)?;
|
let request_payload_json = serialize_editor_generation_job_payload(payload)?;
|
||||||
enqueue_serialized_editor_generation_job_with_identity(
|
let job = enqueue_serialized_editor_generation_job_with_identity(
|
||||||
state,
|
state,
|
||||||
owner_user_id,
|
owner_user_id,
|
||||||
job_kind,
|
job_kind,
|
||||||
source_entity_id,
|
source_entity_id,
|
||||||
request_label,
|
request_label,
|
||||||
price_mud_points,
|
price_mud_points,
|
||||||
request_payload_json,
|
request_payload_json.clone(),
|
||||||
job_id,
|
job_id,
|
||||||
dedupe_key,
|
dedupe_key,
|
||||||
)
|
)
|
||||||
.await
|
.await?;
|
||||||
|
ensure_editor_generation_job_matches_request(
|
||||||
|
job,
|
||||||
|
owner_user_id,
|
||||||
|
job_kind,
|
||||||
|
request_payload_json.as_str(),
|
||||||
|
"请求幂等键已用于不同的生成请求,请复用原请求参数或更换请求标识。",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
@@ -108,15 +137,11 @@ where
|
|||||||
T: Serialize,
|
T: Serialize,
|
||||||
{
|
{
|
||||||
let request_payload_json = serialize_editor_generation_job_payload(payload)?;
|
let request_payload_json = serialize_editor_generation_job_payload(payload)?;
|
||||||
let mut hasher = Sha256::new();
|
let dedupe_key = build_editor_generation_dedupe_key(
|
||||||
hasher.update(owner_user_id.trim().as_bytes());
|
EXTERNAL_API_GENERATION_DEDUPE_PREFIX,
|
||||||
hasher.update(b"\0");
|
owner_user_id,
|
||||||
hasher.update(job_kind.trim().as_bytes());
|
job_kind,
|
||||||
hasher.update(b"\0");
|
idempotency_key,
|
||||||
hasher.update(idempotency_key.as_bytes());
|
|
||||||
let dedupe_key = format!(
|
|
||||||
"{EXTERNAL_API_GENERATION_DEDUPE_PREFIX}:{job_kind}:{:x}",
|
|
||||||
hasher.finalize()
|
|
||||||
);
|
);
|
||||||
let requested_job_id = build_prefixed_uuid_id("task-");
|
let requested_job_id = build_prefixed_uuid_id("task-");
|
||||||
let job = enqueue_serialized_editor_generation_job_with_identity(
|
let job = enqueue_serialized_editor_generation_job_with_identity(
|
||||||
@@ -132,20 +157,97 @@ where
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
ensure_editor_generation_job_matches_request(
|
||||||
|
job,
|
||||||
|
owner_user_id,
|
||||||
|
job_kind,
|
||||||
|
request_payload_json.as_str(),
|
||||||
|
"Idempotency-Key 已用于不同的生成请求,请复用原请求参数或更换幂等键。",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_editor_generation_job_matches_request(
|
||||||
|
job: ExternalGenerationJobRecord,
|
||||||
|
owner_user_id: &str,
|
||||||
|
job_kind: &str,
|
||||||
|
request_payload_json: &str,
|
||||||
|
conflict_message: &str,
|
||||||
|
) -> Result<ExternalGenerationJobRecord, AppError> {
|
||||||
if job.job_kind != job_kind
|
if job.job_kind != job_kind
|
||||||
|| job.owner_user_id != owner_user_id
|
|| job.owner_user_id != owner_user_id
|
||||||
|| job.request_payload_json != request_payload_json
|
|| !editor_generation_request_payloads_match(
|
||||||
|
job_kind,
|
||||||
|
job.request_payload_json.as_str(),
|
||||||
|
request_payload_json,
|
||||||
|
)
|
||||||
{
|
{
|
||||||
return Err(
|
return Err(
|
||||||
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
|
AppError::from_status(StatusCode::CONFLICT).with_details(json!({
|
||||||
"provider": EDITOR_GENERATION_QUEUE_PROVIDER,
|
"provider": EDITOR_GENERATION_QUEUE_PROVIDER,
|
||||||
"message": "Idempotency-Key 已用于不同的生成请求,请复用原请求参数或更换幂等键。",
|
"message": conflict_message,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Ok(job)
|
Ok(job)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn generation_input_references(value: &Value) -> Option<&Value> {
|
||||||
|
value
|
||||||
|
.as_object()
|
||||||
|
.and_then(|payload| payload.get("generationInputs"))
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.and_then(|generation_inputs| generation_inputs.get("references"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_untrusted_generation_input_references_from_payload(value: &mut Value) -> bool {
|
||||||
|
let Some(generation_inputs) = value
|
||||||
|
.as_object_mut()
|
||||||
|
.and_then(|payload| payload.get_mut("generationInputs"))
|
||||||
|
.and_then(Value::as_object_mut)
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
generation_inputs.remove("references").is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn job_kind_migrated_away_from_client_generation_references(job_kind: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
job_kind,
|
||||||
|
EDITOR_IMAGE_GENERATION_JOB_KIND
|
||||||
|
| EDITOR_IMAGE_EDIT_JOB_KIND
|
||||||
|
| EDITOR_BACKGROUND_REMOVAL_JOB_KIND
|
||||||
|
| EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND
|
||||||
|
| EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn editor_generation_request_payloads_match(
|
||||||
|
job_kind: &str,
|
||||||
|
existing: &str,
|
||||||
|
requested: &str,
|
||||||
|
) -> bool {
|
||||||
|
if existing == requested {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let (Ok(mut existing), Ok(requested)) = (
|
||||||
|
serde_json::from_str::<Value>(existing),
|
||||||
|
serde_json::from_str::<Value>(requested),
|
||||||
|
) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
// 只兼容部署前旧 payload 有 references、当前 sanitizer 已删除该字段的单向迁移。
|
||||||
|
// 音频、视频、角色动作等仍会保留 references;如果当前请求也带该字段,就必须完整
|
||||||
|
// 比较,不能把两个不同请求错误复用成同一任务。
|
||||||
|
if !job_kind_migrated_away_from_client_generation_references(job_kind)
|
||||||
|
|| generation_input_references(&existing).is_none()
|
||||||
|
|| generation_input_references(&requested).is_some()
|
||||||
|
|| !strip_untrusted_generation_input_references_from_payload(&mut existing)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
existing == requested
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub(crate) async fn enqueue_editor_generation_job_for_caller<T>(
|
pub(crate) async fn enqueue_editor_generation_job_for_caller<T>(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
@@ -363,6 +465,190 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn editor_api_request_dedupe_key_is_stable_and_namespaced() {
|
||||||
|
let first = build_editor_generation_dedupe_key(
|
||||||
|
EDITOR_API_REQUEST_GENERATION_DEDUPE_PREFIX,
|
||||||
|
"user-1",
|
||||||
|
EDITOR_IMAGE_GENERATION_JOB_KIND,
|
||||||
|
"request-1",
|
||||||
|
);
|
||||||
|
let replay = build_editor_generation_dedupe_key(
|
||||||
|
EDITOR_API_REQUEST_GENERATION_DEDUPE_PREFIX,
|
||||||
|
" user-1 ",
|
||||||
|
EDITOR_IMAGE_GENERATION_JOB_KIND,
|
||||||
|
"request-1",
|
||||||
|
);
|
||||||
|
let other_owner = build_editor_generation_dedupe_key(
|
||||||
|
EDITOR_API_REQUEST_GENERATION_DEDUPE_PREFIX,
|
||||||
|
"user-2",
|
||||||
|
EDITOR_IMAGE_GENERATION_JOB_KIND,
|
||||||
|
"request-1",
|
||||||
|
);
|
||||||
|
let external = build_editor_generation_dedupe_key(
|
||||||
|
EXTERNAL_API_GENERATION_DEDUPE_PREFIX,
|
||||||
|
"user-1",
|
||||||
|
EDITOR_IMAGE_GENERATION_JOB_KIND,
|
||||||
|
"request-1",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(first, replay);
|
||||||
|
assert_ne!(first, other_owner);
|
||||||
|
assert_ne!(first, external);
|
||||||
|
assert!(first.starts_with("editor-api-request-generation:editor_image_generation:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn external_api_dedupe_key_preserves_legacy_hash_bytes() {
|
||||||
|
let dedupe_key = build_editor_generation_dedupe_key(
|
||||||
|
EXTERNAL_API_GENERATION_DEDUPE_PREFIX,
|
||||||
|
" user-1 ",
|
||||||
|
EDITOR_IMAGE_GENERATION_JOB_KIND,
|
||||||
|
" request-1 ",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
dedupe_key,
|
||||||
|
"external-api-generation:editor_image_generation:81e38a8eace5f098041b3c240f99053c555ccfa18139295576c9b4ba3d9acff6"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replayed_editor_generation_job_must_match_original_request() {
|
||||||
|
let mut job = queue_job_fixture("queued", None);
|
||||||
|
job.owner_user_id = "user-1".to_string();
|
||||||
|
job.job_kind = EDITOR_IMAGE_GENERATION_JOB_KIND.to_string();
|
||||||
|
job.request_payload_json = r#"{"prompt":"same"}"#.to_string();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
ensure_editor_generation_job_matches_request(
|
||||||
|
job.clone(),
|
||||||
|
"user-1",
|
||||||
|
EDITOR_IMAGE_GENERATION_JOB_KIND,
|
||||||
|
r#"{"prompt":"same"}"#,
|
||||||
|
"幂等冲突",
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
let error = ensure_editor_generation_job_matches_request(
|
||||||
|
job,
|
||||||
|
"user-1",
|
||||||
|
EDITOR_IMAGE_GENERATION_JOB_KIND,
|
||||||
|
r#"{"prompt":"changed"}"#,
|
||||||
|
"幂等冲突",
|
||||||
|
)
|
||||||
|
.expect_err("same request id must reject a different payload");
|
||||||
|
assert_eq!(error.status_code(), StatusCode::CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replayed_legacy_external_job_ignores_only_removed_client_references() {
|
||||||
|
let mut job = queue_job_fixture("queued", None);
|
||||||
|
job.owner_user_id = "user-1".to_string();
|
||||||
|
job.job_kind = EDITOR_IMAGE_GENERATION_JOB_KIND.to_string();
|
||||||
|
job.request_payload_json = serde_json::to_string(&json!({
|
||||||
|
"prompt": "same",
|
||||||
|
"generationInputs": {
|
||||||
|
"fields": [{"title": "提示词", "value": "same"}],
|
||||||
|
"references": [{
|
||||||
|
"title": "旧客户端引用",
|
||||||
|
"refType": "asset",
|
||||||
|
"refId": "asset-forged"
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.expect("legacy payload should serialize");
|
||||||
|
let requested = serde_json::to_string(&json!({
|
||||||
|
"prompt": "same",
|
||||||
|
"generationInputs": {
|
||||||
|
"fields": [{"title": "提示词", "value": "same"}]
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.expect("current payload should serialize");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
ensure_editor_generation_job_matches_request(
|
||||||
|
job.clone(),
|
||||||
|
"user-1",
|
||||||
|
EDITOR_IMAGE_GENERATION_JOB_KIND,
|
||||||
|
requested.as_str(),
|
||||||
|
"幂等冲突",
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
|
);
|
||||||
|
|
||||||
|
let changed = requested.replace("same", "changed");
|
||||||
|
let error = ensure_editor_generation_job_matches_request(
|
||||||
|
job,
|
||||||
|
"user-1",
|
||||||
|
EDITOR_IMAGE_GENERATION_JOB_KIND,
|
||||||
|
changed.as_str(),
|
||||||
|
"幂等冲突",
|
||||||
|
)
|
||||||
|
.expect_err("non-reference payload changes must still conflict");
|
||||||
|
assert_eq!(error.status_code(), StatusCode::CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replayed_jobs_with_references_on_both_sides_compare_them_strictly() {
|
||||||
|
let mut job = queue_job_fixture("queued", None);
|
||||||
|
job.owner_user_id = "user-1".to_string();
|
||||||
|
job.job_kind = "editor_video_generation".to_string();
|
||||||
|
job.request_payload_json = serde_json::to_string(&json!({
|
||||||
|
"prompt": "same",
|
||||||
|
"generationInputs": {
|
||||||
|
"references": [{"refType": "asset", "refId": "asset-1"}]
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.expect("existing video payload should serialize");
|
||||||
|
let requested = serde_json::to_string(&json!({
|
||||||
|
"prompt": "same",
|
||||||
|
"generationInputs": {
|
||||||
|
"references": [{"refType": "asset", "refId": "asset-2"}]
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.expect("requested video payload should serialize");
|
||||||
|
|
||||||
|
let error = ensure_editor_generation_job_matches_request(
|
||||||
|
job,
|
||||||
|
"user-1",
|
||||||
|
"editor_video_generation",
|
||||||
|
requested.as_str(),
|
||||||
|
"幂等冲突",
|
||||||
|
)
|
||||||
|
.expect_err("different retained references must conflict");
|
||||||
|
assert_eq!(error.status_code(), StatusCode::CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn replayed_video_job_does_not_use_image_reference_migration_compatibility() {
|
||||||
|
let mut job = queue_job_fixture("queued", None);
|
||||||
|
job.owner_user_id = "user-1".to_string();
|
||||||
|
job.job_kind = EDITOR_VIDEO_GENERATION_JOB_KIND.to_string();
|
||||||
|
job.request_payload_json = serde_json::to_string(&json!({
|
||||||
|
"prompt": "same",
|
||||||
|
"generationInputs": {
|
||||||
|
"references": [{"refType": "asset", "refId": "asset-1"}]
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.expect("existing video payload should serialize");
|
||||||
|
let requested = serde_json::to_string(&json!({
|
||||||
|
"prompt": "same",
|
||||||
|
"generationInputs": {}
|
||||||
|
}))
|
||||||
|
.expect("requested video payload should serialize");
|
||||||
|
|
||||||
|
let error = ensure_editor_generation_job_matches_request(
|
||||||
|
job,
|
||||||
|
"user-1",
|
||||||
|
EDITOR_VIDEO_GENERATION_JOB_KIND,
|
||||||
|
requested.as_str(),
|
||||||
|
"幂等冲突",
|
||||||
|
)
|
||||||
|
.expect_err("video payloads did not migrate away from references");
|
||||||
|
assert_eq!(error.status_code(), StatusCode::CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn serialize_payload_accepts_persistable_media_references() {
|
fn serialize_payload_accepts_persistable_media_references() {
|
||||||
let payload = json!({
|
let payload = json!({
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -41,7 +41,7 @@ use crate::{
|
|||||||
enqueue_editor_image_generation_for_owner,
|
enqueue_editor_image_generation_for_owner,
|
||||||
enqueue_editor_ui_design_asset_extraction_for_owner, map_editor_project_error,
|
enqueue_editor_ui_design_asset_extraction_for_owner, map_editor_project_error,
|
||||||
normalize_editor_persisted_media_src, normalize_optional_string,
|
normalize_editor_persisted_media_src, normalize_optional_string,
|
||||||
parse_editor_generation_json_payload, sanitize_editor_client_generation_inputs,
|
parse_editor_generation_json_payload, sanitize_editor_untrusted_generation_inputs,
|
||||||
save_editor_project_layout_with_revision_and_get, serialize_editor_asset_metadata,
|
save_editor_project_layout_with_revision_and_get, serialize_editor_asset_metadata,
|
||||||
},
|
},
|
||||||
external_api_auth::ExternalApiPrincipal,
|
external_api_auth::ExternalApiPrincipal,
|
||||||
@@ -991,7 +991,9 @@ fn normalize_project_title(title: Option<String>) -> String {
|
|||||||
fn serialize_external_editor_generation_inputs(
|
fn serialize_external_editor_generation_inputs(
|
||||||
generation_inputs: Option<Value>,
|
generation_inputs: Option<Value>,
|
||||||
) -> Result<Option<String>, AppError> {
|
) -> Result<Option<String>, AppError> {
|
||||||
serialize_editor_asset_metadata(sanitize_editor_client_generation_inputs(generation_inputs))
|
serialize_editor_asset_metadata(sanitize_editor_untrusted_generation_inputs(
|
||||||
|
generation_inputs,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -1064,6 +1066,12 @@ mod tests {
|
|||||||
"screenColorHex": "#00FF00",
|
"screenColorHex": "#00FF00",
|
||||||
"mattingProvider": "forged-provider",
|
"mattingProvider": "forged-provider",
|
||||||
"mattingModel": "forged-model",
|
"mattingModel": "forged-model",
|
||||||
|
"references": [{
|
||||||
|
"title": "伪造引用",
|
||||||
|
"label": "其他用户素材",
|
||||||
|
"refType": "asset",
|
||||||
|
"refId": "asset-forged"
|
||||||
|
}],
|
||||||
"characterAnimation": {"durationSeconds": 4}
|
"characterAnimation": {"durationSeconds": 4}
|
||||||
})))
|
})))
|
||||||
.expect("外部编辑器生成输入应可序列化")
|
.expect("外部编辑器生成输入应可序列化")
|
||||||
@@ -1075,6 +1083,7 @@ mod tests {
|
|||||||
assert!(parsed.get("screenColorHex").is_none());
|
assert!(parsed.get("screenColorHex").is_none());
|
||||||
assert!(parsed.get("mattingProvider").is_none());
|
assert!(parsed.get("mattingProvider").is_none());
|
||||||
assert!(parsed.get("mattingModel").is_none());
|
assert!(parsed.get("mattingModel").is_none());
|
||||||
|
assert!(parsed.get("references").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1300,6 +1309,14 @@ mod tests {
|
|||||||
.get("default")
|
.get("default")
|
||||||
.is_none()
|
.is_none()
|
||||||
);
|
);
|
||||||
|
let generation_references = &parsed["components"]["schemas"]["EditorImageGenerationRequest"]
|
||||||
|
["properties"]["referenceImageSrcs"];
|
||||||
|
assert_eq!(generation_references["maxItems"], 9);
|
||||||
|
assert!(
|
||||||
|
generation_references["items"]["description"]
|
||||||
|
.as_str()
|
||||||
|
.is_some_and(|description| description.contains("超限返回 400"))
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parsed["components"]["schemas"]["EditorProject"]["properties"]["layers"]["type"],
|
parsed["components"]["schemas"]["EditorProject"]["properties"]["layers"]["type"],
|
||||||
"array"
|
"array"
|
||||||
@@ -1333,6 +1350,21 @@ mod tests {
|
|||||||
.get("targetLayerId")
|
.get("targetLayerId")
|
||||||
.is_some()
|
.is_some()
|
||||||
);
|
);
|
||||||
|
for (schema, max_items) in [
|
||||||
|
("EditorImageEditRequest", 8),
|
||||||
|
("EditorIconSpritesheetGenerationRequest", 8),
|
||||||
|
("EditorUiDesignAssetExtractionRequest", 5),
|
||||||
|
] {
|
||||||
|
let references =
|
||||||
|
&parsed["components"]["schemas"][schema]["properties"]["referenceImageSrcs"];
|
||||||
|
assert_eq!(references["maxItems"], max_items, "{schema}");
|
||||||
|
assert!(
|
||||||
|
references["items"]["description"]
|
||||||
|
.as_str()
|
||||||
|
.is_some_and(|description| description.contains("超限返回 400")),
|
||||||
|
"{schema}"
|
||||||
|
);
|
||||||
|
}
|
||||||
assert!(
|
assert!(
|
||||||
parsed["paths"]
|
parsed["paths"]
|
||||||
.get("/api/external/v1/editor/icon-spritesheets/generations")
|
.get("/api/external/v1/editor/icon-spritesheets/generations")
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ use super::{
|
|||||||
effective_request_timeout_ms, request_budget_exhausted_error,
|
effective_request_timeout_ms, request_budget_exhausted_error,
|
||||||
retry_delay_fits_request_deadline,
|
retry_delay_fits_request_deadline,
|
||||||
},
|
},
|
||||||
constants::{GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, VECTOR_ENGINE_PROVIDER},
|
constants::{
|
||||||
|
GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES,
|
||||||
|
VECTOR_ENGINE_NANOBANANA_MAX_REFERENCE_IMAGES, VECTOR_ENGINE_PROVIDER,
|
||||||
|
},
|
||||||
curl_transport::{
|
curl_transport::{
|
||||||
map_curl_error, send_vector_engine_json_request_with_curl,
|
map_curl_error, send_vector_engine_json_request_with_curl,
|
||||||
send_vector_engine_multipart_edit_request_with_curl,
|
send_vector_engine_multipart_edit_request_with_curl,
|
||||||
@@ -320,6 +323,15 @@ pub async fn create_vector_engine_nanobanana_generate_content(
|
|||||||
reference_images: &[ReferenceImage],
|
reference_images: &[ReferenceImage],
|
||||||
failure_context: &str,
|
failure_context: &str,
|
||||||
) -> Result<GeneratedImages, PlatformImageError> {
|
) -> Result<GeneratedImages, PlatformImageError> {
|
||||||
|
if reference_images.len() > VECTOR_ENGINE_NANOBANANA_MAX_REFERENCE_IMAGES {
|
||||||
|
return Err(PlatformImageError::InvalidRequest {
|
||||||
|
provider: VECTOR_ENGINE_PROVIDER,
|
||||||
|
message: format!(
|
||||||
|
"{failure_context}:参考图最多允许 {VECTOR_ENGINE_NANOBANANA_MAX_REFERENCE_IMAGES} 张,当前提交 {} 张。",
|
||||||
|
reference_images.len()
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
let model = normalize_vector_engine_image_model(model);
|
let model = normalize_vector_engine_image_model(model);
|
||||||
let request_url = vector_engine_nanobanana_generate_content_url(settings, model);
|
let request_url = vector_engine_nanobanana_generate_content_url(settings, model);
|
||||||
let request_body = build_vector_engine_nanobanana_generate_content_request_body(
|
let request_body = build_vector_engine_nanobanana_generate_content_request_body(
|
||||||
@@ -329,12 +341,9 @@ pub async fn create_vector_engine_nanobanana_generate_content(
|
|||||||
image_size,
|
image_size,
|
||||||
reference_images,
|
reference_images,
|
||||||
);
|
);
|
||||||
let reference_image_count = reference_images.iter().take(14).count();
|
let reference_image_count = reference_images.len();
|
||||||
let reference_image_bytes_total: usize = reference_images
|
let reference_image_bytes_total: usize =
|
||||||
.iter()
|
reference_images.iter().map(|image| image.bytes.len()).sum();
|
||||||
.take(14)
|
|
||||||
.map(|image| image.bytes.len())
|
|
||||||
.sum();
|
|
||||||
let request_params = serde_json::json!({
|
let request_params = serde_json::json!({
|
||||||
"model": model,
|
"model": model,
|
||||||
"promptChars": prompt.trim().chars().count(),
|
"promptChars": prompt.trim().chars().count(),
|
||||||
@@ -536,16 +545,22 @@ pub async fn create_vector_engine_image_edit_with_references_and_model(
|
|||||||
message: format!("{failure_context}:缺少参考图,图片编辑需要至少一张参考图。"),
|
message: format!("{failure_context}:缺少参考图,图片编辑需要至少一张参考图。"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if reference_images.len() > VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES {
|
||||||
|
return Err(PlatformImageError::InvalidRequest {
|
||||||
|
provider: VECTOR_ENGINE_PROVIDER,
|
||||||
|
message: format!(
|
||||||
|
"{failure_context}:参考图最多允许 {VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES} 张,当前提交 {} 张。",
|
||||||
|
reference_images.len()
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let request_url = vector_engine_images_edit_url(settings);
|
let request_url = vector_engine_images_edit_url(settings);
|
||||||
let normalized_size = normalize_image_size_for_model(requested_model, size);
|
let normalized_size = normalize_image_size_for_model(requested_model, size);
|
||||||
|
|
||||||
let reference_image_count = reference_images.iter().take(5).count();
|
let reference_image_count = reference_images.len();
|
||||||
let reference_image_bytes_total: usize = reference_images
|
let reference_image_bytes_total: usize =
|
||||||
.iter()
|
reference_images.iter().map(|image| image.bytes.len()).sum();
|
||||||
.take(5)
|
|
||||||
.map(|image| image.bytes.len())
|
|
||||||
.sum();
|
|
||||||
let started_at = std::time::Instant::now();
|
let started_at = std::time::Instant::now();
|
||||||
let mut upstream_model = preferred_vector_engine_upstream_model(requested_model);
|
let mut upstream_model = preferred_vector_engine_upstream_model(requested_model);
|
||||||
let mut recovered_failure_audits = Vec::new();
|
let mut recovered_failure_audits = Vec::new();
|
||||||
@@ -1090,6 +1105,63 @@ fn vector_engine_send_retry_jitter_ms() -> u64 {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn reference_image(index: usize) -> ReferenceImage {
|
||||||
|
ReferenceImage {
|
||||||
|
bytes: vec![index as u8],
|
||||||
|
mime_type: "image/png".to_string(),
|
||||||
|
file_name: format!("reference-{index}.png"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_settings() -> VectorEngineImageSettings {
|
||||||
|
VectorEngineImageSettings {
|
||||||
|
base_url: "http://127.0.0.1:9".to_string(),
|
||||||
|
api_key: "test-key".to_string(),
|
||||||
|
request_timeout_ms: 1_000,
|
||||||
|
request_deadline: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn gpt_image_edit_rejects_six_references_before_network_send() {
|
||||||
|
let references = (0..6).map(reference_image).collect::<Vec<_>>();
|
||||||
|
let error = create_vector_engine_image_edit_with_references_and_model(
|
||||||
|
&reqwest::Client::new(),
|
||||||
|
&test_settings(),
|
||||||
|
GPT_IMAGE_2_MODEL,
|
||||||
|
"测试提示词",
|
||||||
|
None,
|
||||||
|
"1024x1024",
|
||||||
|
1,
|
||||||
|
references.as_slice(),
|
||||||
|
"测试图片编辑失败",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("the provider boundary must reject the sixth reference");
|
||||||
|
|
||||||
|
assert!(matches!(error, PlatformImageError::InvalidRequest { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn nanobanana_rejects_fifteen_references_before_network_send() {
|
||||||
|
let references = (0..15).map(reference_image).collect::<Vec<_>>();
|
||||||
|
let error = create_vector_engine_nanobanana_generate_content(
|
||||||
|
&reqwest::Client::new(),
|
||||||
|
&test_settings(),
|
||||||
|
super::super::constants::NANOBANANA_2_MODEL,
|
||||||
|
"测试提示词",
|
||||||
|
None,
|
||||||
|
"1:1",
|
||||||
|
"1K",
|
||||||
|
references.as_slice(),
|
||||||
|
"测试图片生成失败",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect_err("the provider boundary must reject the fifteenth reference");
|
||||||
|
|
||||||
|
assert!(matches!(error, PlatformImageError::InvalidRequest { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn expired_deadline_stops_generation_before_network_send() {
|
async fn expired_deadline_stops_generation_before_network_send() {
|
||||||
let settings = VectorEngineImageSettings {
|
let settings = VectorEngineImageSettings {
|
||||||
|
|||||||
@@ -3,3 +3,5 @@ pub const GPT_IMAGE_2_C_MODEL: &str = "gpt-image-2-c";
|
|||||||
pub const NANOBANANA_2_MODEL: &str = "gemini-3.1-flash-image-preview";
|
pub const NANOBANANA_2_MODEL: &str = "gemini-3.1-flash-image-preview";
|
||||||
pub const VECTOR_ENGINE_GPT_IMAGE_2_MODEL: &str = GPT_IMAGE_2_MODEL;
|
pub const VECTOR_ENGINE_GPT_IMAGE_2_MODEL: &str = GPT_IMAGE_2_MODEL;
|
||||||
pub const VECTOR_ENGINE_PROVIDER: &str = "vector-engine";
|
pub const VECTOR_ENGINE_PROVIDER: &str = "vector-engine";
|
||||||
|
pub const VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES: usize = 5;
|
||||||
|
pub const VECTOR_ENGINE_NANOBANANA_MAX_REFERENCE_IMAGES: usize = 14;
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl(
|
|||||||
let prompt = prompt.to_string();
|
let prompt = prompt.to_string();
|
||||||
let negative_prompt = negative_prompt.map(str::to_string);
|
let negative_prompt = negative_prompt.map(str::to_string);
|
||||||
let normalized_size = normalized_size.to_string();
|
let normalized_size = normalized_size.to_string();
|
||||||
let reference_images = reference_images.iter().take(5).cloned().collect::<Vec<_>>();
|
let reference_images = reference_images.to_vec();
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
send_multipart_edit_request_with_curl_blocking(
|
send_multipart_edit_request_with_curl_blocking(
|
||||||
request_url.as_str(),
|
request_url.as_str(),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use std::time::Instant;
|
|||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
budget::request_budget_exhausted_error,
|
budget::request_budget_exhausted_error,
|
||||||
constants::VECTOR_ENGINE_PROVIDER,
|
constants::{VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES, VECTOR_ENGINE_PROVIDER},
|
||||||
error::PlatformImageError,
|
error::PlatformImageError,
|
||||||
types::{DownloadedImage, GeneratedImages, ReferenceImage},
|
types::{DownloadedImage, GeneratedImages, ReferenceImage},
|
||||||
};
|
};
|
||||||
@@ -129,8 +129,20 @@ pub(crate) async fn resolve_reference_images(
|
|||||||
failure_context: &str,
|
failure_context: &str,
|
||||||
request_deadline: Option<Instant>,
|
request_deadline: Option<Instant>,
|
||||||
) -> Result<Vec<ReferenceImage>, PlatformImageError> {
|
) -> Result<Vec<ReferenceImage>, PlatformImageError> {
|
||||||
|
let reference_count = reference_images
|
||||||
|
.iter()
|
||||||
|
.filter(|source| !source.trim().is_empty())
|
||||||
|
.count();
|
||||||
|
if reference_count > VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES {
|
||||||
|
return Err(PlatformImageError::InvalidRequest {
|
||||||
|
provider: VECTOR_ENGINE_PROVIDER,
|
||||||
|
message: format!(
|
||||||
|
"{failure_context}:参考图最多允许 {VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES} 张,当前提交 {reference_count} 张。"
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
let mut resolved = Vec::new();
|
let mut resolved = Vec::new();
|
||||||
for (index, source) in reference_images.iter().take(5).enumerate() {
|
for (index, source) in reference_images.iter().enumerate() {
|
||||||
let source = source.trim();
|
let source = source.trim();
|
||||||
if source.is_empty() {
|
if source.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ pub fn build_vector_engine_nanobanana_generate_content_request_body(
|
|||||||
) -> Value {
|
) -> Value {
|
||||||
let prompt = build_prompt_with_negative(prompt, negative_prompt);
|
let prompt = build_prompt_with_negative(prompt, negative_prompt);
|
||||||
let mut parts = vec![json!({ "text": prompt })];
|
let mut parts = vec![json!({ "text": prompt })];
|
||||||
for reference_image in reference_images.iter().take(14) {
|
for reference_image in reference_images {
|
||||||
parts.push(json!({
|
parts.push(json!({
|
||||||
"inline_data": {
|
"inline_data": {
|
||||||
"mime_type": reference_image.mime_type,
|
"mime_type": reference_image.mime_type,
|
||||||
@@ -287,7 +287,6 @@ pub(crate) fn build_vector_engine_image_edit_request_log_params(
|
|||||||
.filter(|value| !value.is_empty());
|
.filter(|value| !value.is_empty());
|
||||||
let references: Vec<Value> = reference_images
|
let references: Vec<Value> = reference_images
|
||||||
.iter()
|
.iter()
|
||||||
.take(5)
|
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(index, image)| {
|
.map(|(index, image)| {
|
||||||
json!({
|
json!({
|
||||||
@@ -299,11 +298,8 @@ pub(crate) fn build_vector_engine_image_edit_request_log_params(
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let reference_image_bytes_total: usize = reference_images
|
let reference_image_bytes_total: usize =
|
||||||
.iter()
|
reference_images.iter().map(|image| image.bytes.len()).sum();
|
||||||
.take(5)
|
|
||||||
.map(|image| image.bytes.len())
|
|
||||||
.sum();
|
|
||||||
|
|
||||||
json!({
|
json!({
|
||||||
"model": model,
|
"model": model,
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ type ImageCanvasBasicGenerationComposerViewProps = {
|
|||||||
onPickReferenceFromCanvas?: () => void;
|
onPickReferenceFromCanvas?: () => void;
|
||||||
onToggleReferenceMenu?: () => void;
|
onToggleReferenceMenu?: () => void;
|
||||||
onRememberImageModel?: (model: string) => void;
|
onRememberImageModel?: (model: string) => void;
|
||||||
|
hasPendingImageReferenceUploads?: boolean;
|
||||||
onSubmit: (dialog: GenerateDialogState) => void;
|
onSubmit: (dialog: GenerateDialogState) => void;
|
||||||
dialogLabel?: string;
|
dialogLabel?: string;
|
||||||
includeDimensions?: boolean;
|
includeDimensions?: boolean;
|
||||||
@@ -107,6 +108,7 @@ export function ImageCanvasBasicGenerationComposerView({
|
|||||||
onPickReferenceFromCanvas,
|
onPickReferenceFromCanvas,
|
||||||
onToggleReferenceMenu,
|
onToggleReferenceMenu,
|
||||||
onRememberImageModel = () => {},
|
onRememberImageModel = () => {},
|
||||||
|
hasPendingImageReferenceUploads = false,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
dialogLabel,
|
dialogLabel,
|
||||||
includeDimensions = true,
|
includeDimensions = true,
|
||||||
@@ -271,6 +273,7 @@ export function ImageCanvasBasicGenerationComposerView({
|
|||||||
includeDimensions={shouldIncludeDimensions}
|
includeDimensions={shouldIncludeDimensions}
|
||||||
includeModel={includeModel}
|
includeModel={includeModel}
|
||||||
onRememberImageModel={onRememberImageModel}
|
onRememberImageModel={onRememberImageModel}
|
||||||
|
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||||
dimensionRatioAriaLabelPrefix={dimensionRatioAriaLabelPrefix}
|
dimensionRatioAriaLabelPrefix={dimensionRatioAriaLabelPrefix}
|
||||||
dimensionSizeAriaLabelPrefix={dimensionSizeAriaLabelPrefix}
|
dimensionSizeAriaLabelPrefix={dimensionSizeAriaLabelPrefix}
|
||||||
optionLabelPrefix={resolvedOptionLabelPrefix}
|
optionLabelPrefix={resolvedOptionLabelPrefix}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ type ImageCanvasCharacterGenerationComposerViewProps = {
|
|||||||
onOpenSpecDialog: (specType: SpecGenerationType) => void;
|
onOpenSpecDialog: (specType: SpecGenerationType) => void;
|
||||||
onRequestUpload: (target: UploadTarget) => void;
|
onRequestUpload: (target: UploadTarget) => void;
|
||||||
onRememberImageModel: (model: string) => void;
|
onRememberImageModel: (model: string) => void;
|
||||||
|
hasPendingImageReferenceUploads?: boolean;
|
||||||
onSubmit: (dialog: GenerateDialogState) => void;
|
onSubmit: (dialog: GenerateDialogState) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -97,6 +98,7 @@ export function ImageCanvasCharacterGenerationComposerView({
|
|||||||
onOpenSpecDialog,
|
onOpenSpecDialog,
|
||||||
onRequestUpload,
|
onRequestUpload,
|
||||||
onRememberImageModel,
|
onRememberImageModel,
|
||||||
|
hasPendingImageReferenceUploads = false,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: ImageCanvasCharacterGenerationComposerViewProps) {
|
}: ImageCanvasCharacterGenerationComposerViewProps) {
|
||||||
useImageCanvasFloatingOptionDismiss({
|
useImageCanvasFloatingOptionDismiss({
|
||||||
@@ -289,6 +291,7 @@ export function ImageCanvasCharacterGenerationComposerView({
|
|||||||
setGenerateDialog={setGenerateDialog}
|
setGenerateDialog={setGenerateDialog}
|
||||||
includeDimensions
|
includeDimensions
|
||||||
onRememberImageModel={onRememberImageModel}
|
onRememberImageModel={onRememberImageModel}
|
||||||
|
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||||
optionLabelPrefix="生成图片"
|
optionLabelPrefix="生成图片"
|
||||||
cost={calculateEditorImageGenerationPrice({
|
cost={calculateEditorImageGenerationPrice({
|
||||||
kind: 'character',
|
kind: 'character',
|
||||||
|
|||||||
@@ -390,6 +390,7 @@ export type CanvasContextMenuState =
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type QuickEditPanelState = {
|
export type QuickEditPanelState = {
|
||||||
|
referenceUploadContextId?: string;
|
||||||
mode?: 'quick-edit' | 'redraw';
|
mode?: 'quick-edit' | 'redraw';
|
||||||
sourceLayerId: string;
|
sourceLayerId: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
|
|||||||
@@ -58,12 +58,17 @@ import type {
|
|||||||
ImageContextMenuState,
|
ImageContextMenuState,
|
||||||
QuickEditPanelState,
|
QuickEditPanelState,
|
||||||
SidebarPanel,
|
SidebarPanel,
|
||||||
|
UploadTarget,
|
||||||
} from './ImageCanvasEditorTypes';
|
} from './ImageCanvasEditorTypes';
|
||||||
import { getEditorUploadAccept, isImageFile } from './ImageCanvasFileModel';
|
import { getEditorUploadAccept, isImageFile } from './ImageCanvasFileModel';
|
||||||
import { createLayerGenerationDialogDraft } from './ImageCanvasGenerationDialogModel';
|
import { createLayerGenerationDialogDraft } from './ImageCanvasGenerationDialogModel';
|
||||||
import {
|
import {
|
||||||
applyEditorGenerationPricingConfig,
|
applyEditorGenerationPricingConfig,
|
||||||
isCanvasGenerationDialog,
|
isCanvasGenerationDialog,
|
||||||
|
QUICK_EDIT_REFERENCE_LIMIT,
|
||||||
|
resolveDialogExtraImageReferenceLimit,
|
||||||
|
resolveExtraImageReferenceLimit,
|
||||||
|
UI_EXTRA_REFERENCE_LIMIT,
|
||||||
} from './ImageCanvasGenerationModel';
|
} from './ImageCanvasGenerationModel';
|
||||||
import { formatCanvasHistoryAction } from './ImageCanvasHistoryModel';
|
import { formatCanvasHistoryAction } from './ImageCanvasHistoryModel';
|
||||||
import { fitViewportToBounds } from './ImageCanvasInteractionModel';
|
import { fitViewportToBounds } from './ImageCanvasInteractionModel';
|
||||||
@@ -97,7 +102,10 @@ import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands';
|
|||||||
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
|
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
|
||||||
import { useImageCanvasStageController } from './useImageCanvasStageController';
|
import { useImageCanvasStageController } from './useImageCanvasStageController';
|
||||||
import { useImageCanvasStageInteractions } from './useImageCanvasStageInteractions';
|
import { useImageCanvasStageInteractions } from './useImageCanvasStageInteractions';
|
||||||
import { useImageCanvasUploadWorkflow } from './useImageCanvasUploadWorkflow';
|
import {
|
||||||
|
type ImageReferenceUploadCapacity,
|
||||||
|
useImageCanvasUploadWorkflow,
|
||||||
|
} from './useImageCanvasUploadWorkflow';
|
||||||
import {
|
import {
|
||||||
DEFAULT_IMAGE_CANVAS_VIEWPORT,
|
DEFAULT_IMAGE_CANVAS_VIEWPORT,
|
||||||
useImageCanvasViewportControls,
|
useImageCanvasViewportControls,
|
||||||
@@ -430,6 +438,9 @@ export function ImageCanvasEditorView({
|
|||||||
const appendUiAssetExtractionReferencesRef = useRef<
|
const appendUiAssetExtractionReferencesRef = useRef<
|
||||||
(references: CharacterReferenceImage[]) => void
|
(references: CharacterReferenceImage[]) => void
|
||||||
>(() => {});
|
>(() => {});
|
||||||
|
const resolveImageReferenceUploadCapacityRef = useRef<
|
||||||
|
(target: UploadTarget) => ImageReferenceUploadCapacity | null
|
||||||
|
>(() => null);
|
||||||
const deleteLayerByIdRef = useRef<(targetLayerId: string | null) => void>(
|
const deleteLayerByIdRef = useRef<(targetLayerId: string | null) => void>(
|
||||||
() => {},
|
() => {},
|
||||||
);
|
);
|
||||||
@@ -672,6 +683,9 @@ export function ImageCanvasEditorView({
|
|||||||
discardCanvasHistoryEntriesContainingLayerRef.current(matchesLayer),
|
discardCanvasHistoryEntriesContainingLayerRef.current(matchesLayer),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
const canDeleteLayersRef = useRef<(targetLayerIds: string[]) => boolean>(
|
||||||
|
() => true,
|
||||||
|
);
|
||||||
const removeCanvasLayersLinkedToAssets = useImageCanvasAssetLayerCleanup({
|
const removeCanvasLayersLinkedToAssets = useImageCanvasAssetLayerCleanup({
|
||||||
layers,
|
layers,
|
||||||
setLayers,
|
setLayers,
|
||||||
@@ -679,6 +693,8 @@ export function ImageCanvasEditorView({
|
|||||||
setSelectedLayerIds,
|
setSelectedLayerIds,
|
||||||
onDeleteLayerSideEffects: (layerId) =>
|
onDeleteLayerSideEffects: (layerId) =>
|
||||||
clearDeletedLayerGenerationStateRef.current(layerId),
|
clearDeletedLayerGenerationStateRef.current(layerId),
|
||||||
|
canDeleteLayers: (targetLayerIds) =>
|
||||||
|
canDeleteLayersRef.current(targetLayerIds),
|
||||||
discardHistoryEntriesContainingLayer: discardAssetRelatedCanvasHistory,
|
discardHistoryEntriesContainingLayer: discardAssetRelatedCanvasHistory,
|
||||||
});
|
});
|
||||||
const {
|
const {
|
||||||
@@ -734,6 +750,14 @@ export function ImageCanvasEditorView({
|
|||||||
onDeleteAssets: removeCanvasLayersLinkedToAssets,
|
onDeleteAssets: removeCanvasLayersLinkedToAssets,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const pendingImageReferenceUploadCountRef = useRef(0);
|
||||||
|
const isPendingReferenceUploadContextMutationLocked = useCallback(
|
||||||
|
() => pendingImageReferenceUploadCountRef.current > 0,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const rejectPendingReferenceUploadContextMutation = useCallback(() => {
|
||||||
|
window.alert('参考图正在上传,请等待上传完成后再切换或关闭生成面板');
|
||||||
|
}, []);
|
||||||
const handleActivateCanvasGenerationDialog = useCallback(() => {
|
const handleActivateCanvasGenerationDialog = useCallback(() => {
|
||||||
closeGenerationTransientStateRef.current();
|
closeGenerationTransientStateRef.current();
|
||||||
setSelectedLayerId(null);
|
setSelectedLayerId(null);
|
||||||
@@ -761,6 +785,8 @@ export function ImageCanvasEditorView({
|
|||||||
getGeneratingDialogPlaceholder,
|
getGeneratingDialogPlaceholder,
|
||||||
} = useCanvasGenerationDialogs({
|
} = useCanvasGenerationDialogs({
|
||||||
onActivate: handleActivateCanvasGenerationDialog,
|
onActivate: handleActivateCanvasGenerationDialog,
|
||||||
|
isContextMutationLocked: isPendingReferenceUploadContextMutationLocked,
|
||||||
|
onContextMutationRejected: rejectPendingReferenceUploadContextMutation,
|
||||||
});
|
});
|
||||||
canvasGenerationDialogsRef.current = canvasGenerationDialogs;
|
canvasGenerationDialogsRef.current = canvasGenerationDialogs;
|
||||||
const canvasHistoryRefs = useMemo(
|
const canvasHistoryRefs = useMemo(
|
||||||
@@ -1238,7 +1264,7 @@ export function ImageCanvasEditorView({
|
|||||||
captureCanvasHistory(action);
|
captureCanvasHistory(action);
|
||||||
applyProjectSnapshot(project);
|
applyProjectSnapshot(project);
|
||||||
if (action.type !== 'perfect-pixel') {
|
if (action.type !== 'perfect-pixel') {
|
||||||
void refreshAssetLibrary();
|
void refreshAssetLibrary();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[applyProjectSnapshot, captureCanvasHistory, refreshAssetLibrary],
|
[applyProjectSnapshot, captureCanvasHistory, refreshAssetLibrary],
|
||||||
@@ -1344,6 +1370,7 @@ export function ImageCanvasEditorView({
|
|||||||
const {
|
const {
|
||||||
uploadInputRef,
|
uploadInputRef,
|
||||||
uploadTarget,
|
uploadTarget,
|
||||||
|
pendingImageReferenceUploadCount,
|
||||||
requestUpload,
|
requestUpload,
|
||||||
handleUploadInputChange,
|
handleUploadInputChange,
|
||||||
addUploadedFiles,
|
addUploadedFiles,
|
||||||
@@ -1365,12 +1392,17 @@ export function ImageCanvasEditorView({
|
|||||||
setLayers,
|
setLayers,
|
||||||
setGenerateDialog,
|
setGenerateDialog,
|
||||||
setQuickEditPanel: (updater) => setQuickEditPanelRef.current(updater),
|
setQuickEditPanel: (updater) => setQuickEditPanelRef.current(updater),
|
||||||
|
resolveImageReferenceUploadCapacity: (target) =>
|
||||||
|
resolveImageReferenceUploadCapacityRef.current(target),
|
||||||
appendUiAssetExtractionReferences: (references) =>
|
appendUiAssetExtractionReferences: (references) =>
|
||||||
appendUiAssetExtractionReferencesRef.current(references),
|
appendUiAssetExtractionReferencesRef.current(references),
|
||||||
appendCanvasLayersWithResources,
|
appendCanvasLayersWithResources,
|
||||||
captureCanvasHistory,
|
captureCanvasHistory,
|
||||||
selectSingleLayer,
|
selectSingleLayer,
|
||||||
|
refreshAssetLibrary,
|
||||||
});
|
});
|
||||||
|
pendingImageReferenceUploadCountRef.current =
|
||||||
|
pendingImageReferenceUploadCount;
|
||||||
const generationSurface = useImageCanvasGenerationSurface({
|
const generationSurface = useImageCanvasGenerationSurface({
|
||||||
layers,
|
layers,
|
||||||
canvasSize,
|
canvasSize,
|
||||||
@@ -1387,6 +1419,7 @@ export function ImageCanvasEditorView({
|
|||||||
generationReferenceButtonRef,
|
generationReferenceButtonRef,
|
||||||
publicationReferenceButtonRef,
|
publicationReferenceButtonRef,
|
||||||
iconSpecButtonRef,
|
iconSpecButtonRef,
|
||||||
|
hasPendingImageReferenceUploads: pendingImageReferenceUploadCount > 0,
|
||||||
generateDialog,
|
generateDialog,
|
||||||
setGenerateDialog,
|
setGenerateDialog,
|
||||||
activeCanvasGenerationDialog,
|
activeCanvasGenerationDialog,
|
||||||
@@ -1549,6 +1582,7 @@ export function ImageCanvasEditorView({
|
|||||||
clearDeletedLayerGenerationState,
|
clearDeletedLayerGenerationState,
|
||||||
uiAssetExtractionState,
|
uiAssetExtractionState,
|
||||||
uiAssetExtractionSourceLayer,
|
uiAssetExtractionSourceLayer,
|
||||||
|
quickEditPanel,
|
||||||
quickEditSelectionState,
|
quickEditSelectionState,
|
||||||
quickEditSelectionSourceLayer,
|
quickEditSelectionSourceLayer,
|
||||||
changeUiAssetExtractionTool,
|
changeUiAssetExtractionTool,
|
||||||
@@ -1593,6 +1627,137 @@ export function ImageCanvasEditorView({
|
|||||||
clearDeletedLayerGenerationState;
|
clearDeletedLayerGenerationState;
|
||||||
appendUiAssetExtractionReferencesRef.current =
|
appendUiAssetExtractionReferencesRef.current =
|
||||||
appendUiAssetExtractionReferences;
|
appendUiAssetExtractionReferences;
|
||||||
|
resolveImageReferenceUploadCapacityRef.current = (target) => {
|
||||||
|
const imageReferenceCount = (references: CharacterReferenceImage[] = []) =>
|
||||||
|
references.filter(
|
||||||
|
(reference) => (reference.mediaType ?? 'image') === 'image',
|
||||||
|
).length;
|
||||||
|
const generateDialogContextId = generateDialog
|
||||||
|
? (generateDialog.id ??
|
||||||
|
`${generateDialog.mode}:${generateDialog.sourceLayerId ?? 'draft'}`)
|
||||||
|
: undefined;
|
||||||
|
if (target === 'spec-reference') {
|
||||||
|
return generateDialog?.mode === 'spec'
|
||||||
|
? {
|
||||||
|
label: '规范参考图',
|
||||||
|
currentCount: 0,
|
||||||
|
limit: 1,
|
||||||
|
contextId: generateDialogContextId,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
if (target === 'character-spec') {
|
||||||
|
if (generateDialog?.mode !== 'character') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const referencesFit =
|
||||||
|
imageReferenceCount(generateDialog.characterReferences) <=
|
||||||
|
resolveDialogExtraImageReferenceLimit(generateDialog);
|
||||||
|
return {
|
||||||
|
label: '角色主图',
|
||||||
|
currentCount: 0,
|
||||||
|
limit: referencesFit ? 1 : 0,
|
||||||
|
contextId: generateDialogContextId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (target === 'icon-spec') {
|
||||||
|
if (generateDialog?.mode !== 'icon') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const referencesFit =
|
||||||
|
imageReferenceCount(generateDialog.generationReferences) <=
|
||||||
|
resolveDialogExtraImageReferenceLimit(generateDialog);
|
||||||
|
return {
|
||||||
|
label: '图标规范图',
|
||||||
|
currentCount: 0,
|
||||||
|
limit: referencesFit ? 1 : 0,
|
||||||
|
contextId: generateDialogContextId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (target === 'ui-design-icon-spec') {
|
||||||
|
if (generateDialog?.mode !== 'ui-design') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const referencesFit =
|
||||||
|
imageReferenceCount(generateDialog.generationReferences) <=
|
||||||
|
resolveDialogExtraImageReferenceLimit(generateDialog);
|
||||||
|
return {
|
||||||
|
label: 'UI设计规范图',
|
||||||
|
currentCount: 0,
|
||||||
|
limit: referencesFit ? 1 : 0,
|
||||||
|
contextId: generateDialogContextId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (target === 'character-reference') {
|
||||||
|
return generateDialog?.mode === 'character'
|
||||||
|
? {
|
||||||
|
label: '角色参考图',
|
||||||
|
currentCount: imageReferenceCount(
|
||||||
|
generateDialog.characterReferences,
|
||||||
|
),
|
||||||
|
limit: resolveDialogExtraImageReferenceLimit(generateDialog),
|
||||||
|
contextId: generateDialogContextId,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
if (target === 'generation-reference') {
|
||||||
|
return generateDialog && generateDialog.mode !== 'video'
|
||||||
|
? {
|
||||||
|
label: '生成参考图',
|
||||||
|
currentCount: imageReferenceCount(
|
||||||
|
generateDialog.generationReferences,
|
||||||
|
),
|
||||||
|
limit: resolveDialogExtraImageReferenceLimit(generateDialog),
|
||||||
|
contextId: generateDialogContextId,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
if (target === 'publication-reference') {
|
||||||
|
return generateDialog?.mode === 'publication'
|
||||||
|
? {
|
||||||
|
label: '宣发参考图',
|
||||||
|
currentCount: imageReferenceCount(
|
||||||
|
generateDialog.publicationReferences,
|
||||||
|
),
|
||||||
|
limit: resolveDialogExtraImageReferenceLimit(generateDialog),
|
||||||
|
contextId: generateDialogContextId,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
if (target === 'quick-edit-reference') {
|
||||||
|
return quickEditPanel && quickEditPanel.mode !== 'redraw'
|
||||||
|
? {
|
||||||
|
label: '快速编辑参考图',
|
||||||
|
currentCount: imageReferenceCount(
|
||||||
|
quickEditPanel.quickEditReferences,
|
||||||
|
),
|
||||||
|
limit: resolveExtraImageReferenceLimit(
|
||||||
|
quickEditPanel.model,
|
||||||
|
QUICK_EDIT_REFERENCE_LIMIT,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
contextId: quickEditPanel.referenceUploadContextId,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
if (target === 'ui-asset-extraction-reference') {
|
||||||
|
return uiAssetExtractionState
|
||||||
|
? {
|
||||||
|
label: 'UI素材参考图',
|
||||||
|
currentCount: imageReferenceCount(
|
||||||
|
uiAssetExtractionState.references,
|
||||||
|
),
|
||||||
|
limit: resolveExtraImageReferenceLimit(
|
||||||
|
uiAssetExtractionState.model,
|
||||||
|
UI_EXTRA_REFERENCE_LIMIT,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
contextId: uiAssetExtractionState.referenceUploadContextId,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isProjectReady || startupIntentConsumedRef.current) {
|
if (!isProjectReady || startupIntentConsumedRef.current) {
|
||||||
return;
|
return;
|
||||||
@@ -1735,6 +1900,33 @@ export function ImageCanvasEditorView({
|
|||||||
contextMenu?.kind === 'layer'
|
contextMenu?.kind === 'layer'
|
||||||
? (layers.find((layer) => layer.id === contextMenu.layerId) ?? null)
|
? (layers.find((layer) => layer.id === contextMenu.layerId) ?? null)
|
||||||
: null;
|
: null;
|
||||||
|
const canDeleteLayersDuringReferenceUpload = useCallback(
|
||||||
|
(targetLayerIds: string[]) => {
|
||||||
|
if (pendingImageReferenceUploadCountRef.current <= 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const protectedSourceLayerIds = [
|
||||||
|
generateDialog?.sourceLayerId,
|
||||||
|
generationSurface.quickEditPanel?.sourceLayerId,
|
||||||
|
uiAssetExtractionState?.sourceLayerId,
|
||||||
|
].filter((layerId): layerId is string => Boolean(layerId));
|
||||||
|
if (
|
||||||
|
!targetLayerIds.some((layerId) =>
|
||||||
|
protectedSourceLayerIds.includes(layerId),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
window.alert('参考图正在上传,请等待上传完成后再删除当前生成源图');
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
[
|
||||||
|
generateDialog?.sourceLayerId,
|
||||||
|
generationSurface.quickEditPanel?.sourceLayerId,
|
||||||
|
uiAssetExtractionState?.sourceLayerId,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
canDeleteLayersRef.current = canDeleteLayersDuringReferenceUpload;
|
||||||
const {
|
const {
|
||||||
canvasClipboard,
|
canvasClipboard,
|
||||||
canCopyContextLayers,
|
canCopyContextLayers,
|
||||||
@@ -1779,6 +1971,8 @@ export function ImageCanvasEditorView({
|
|||||||
onRequestDeleteGenerationDialog: requestRemoveCanvasGenerationDialog,
|
onRequestDeleteGenerationDialog: requestRemoveCanvasGenerationDialog,
|
||||||
exportLayerImage,
|
exportLayerImage,
|
||||||
onCanvasLayerCopyBlocked: showCanvasLayerCopyWarning,
|
onCanvasLayerCopyBlocked: showCanvasLayerCopyWarning,
|
||||||
|
canDeleteLayers: (targetLayerIds) =>
|
||||||
|
canDeleteLayersRef.current(targetLayerIds),
|
||||||
});
|
});
|
||||||
const {
|
const {
|
||||||
canvasMarquee,
|
canvasMarquee,
|
||||||
@@ -2328,6 +2522,7 @@ export function ImageCanvasEditorView({
|
|||||||
: uiAssetExtractionSourceLayer,
|
: uiAssetExtractionSourceLayer,
|
||||||
quickEditSelectionState,
|
quickEditSelectionState,
|
||||||
quickEditSelectionSourceLayer,
|
quickEditSelectionSourceLayer,
|
||||||
|
hasPendingImageReferenceUploads: pendingImageReferenceUploadCount > 0,
|
||||||
generationComposerStyle,
|
generationComposerStyle,
|
||||||
selectedToolbarStyle,
|
selectedToolbarStyle,
|
||||||
perfectPixelLayerIds,
|
perfectPixelLayerIds,
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ type ImageCanvasGenerationComposerViewProps = {
|
|||||||
isPickingCharacterReferenceFromCanvas: boolean;
|
isPickingCharacterReferenceFromCanvas: boolean;
|
||||||
isPickingIconSpecFromCanvas: boolean;
|
isPickingIconSpecFromCanvas: boolean;
|
||||||
isPickingUiDesignSpecFromCanvas: boolean;
|
isPickingUiDesignSpecFromCanvas: boolean;
|
||||||
|
hasPendingImageReferenceUploads?: boolean;
|
||||||
generateDialog: GenerateDialogState | null;
|
generateDialog: GenerateDialogState | null;
|
||||||
generationComposerStyle: CSSProperties | null;
|
generationComposerStyle: CSSProperties | null;
|
||||||
iconComposerStyle: CSSProperties | null;
|
iconComposerStyle: CSSProperties | null;
|
||||||
@@ -994,6 +995,7 @@ export function ImageCanvasGenerationComposerView({
|
|||||||
setIsPickingCharacterReferenceFromCanvas,
|
setIsPickingCharacterReferenceFromCanvas,
|
||||||
setIsPickingIconSpecFromCanvas,
|
setIsPickingIconSpecFromCanvas,
|
||||||
setIsPickingUiDesignSpecFromCanvas,
|
setIsPickingUiDesignSpecFromCanvas,
|
||||||
|
hasPendingImageReferenceUploads = false,
|
||||||
onOpenSpecDialog,
|
onOpenSpecDialog,
|
||||||
onRequestUpload,
|
onRequestUpload,
|
||||||
onSubmitImageGeneration,
|
onSubmitImageGeneration,
|
||||||
@@ -1073,6 +1075,7 @@ export function ImageCanvasGenerationComposerView({
|
|||||||
setIsGenerationReferenceMenuOpen((open) => !open)
|
setIsGenerationReferenceMenuOpen((open) => !open)
|
||||||
}
|
}
|
||||||
onRememberImageModel={onRememberImageModel}
|
onRememberImageModel={onRememberImageModel}
|
||||||
|
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||||
onSubmit={onSubmitImageGeneration}
|
onSubmit={onSubmitImageGeneration}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1095,6 +1098,7 @@ export function ImageCanvasGenerationComposerView({
|
|||||||
onOpenSpecDialog={onOpenSpecDialog}
|
onOpenSpecDialog={onOpenSpecDialog}
|
||||||
onUpdateSpecFormValue={onUpdateSpecFormValue}
|
onUpdateSpecFormValue={onUpdateSpecFormValue}
|
||||||
onRequestUpload={onRequestUpload}
|
onRequestUpload={onRequestUpload}
|
||||||
|
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||||
onSubmit={onSubmitImageGeneration}
|
onSubmit={onSubmitImageGeneration}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1119,6 +1123,7 @@ export function ImageCanvasGenerationComposerView({
|
|||||||
buildPortalMenuStyle={buildPortalMenuStyle}
|
buildPortalMenuStyle={buildPortalMenuStyle}
|
||||||
onRequestUpload={onRequestUpload}
|
onRequestUpload={onRequestUpload}
|
||||||
onRememberImageModel={onRememberImageModel}
|
onRememberImageModel={onRememberImageModel}
|
||||||
|
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||||
onSubmit={onSubmitImageGeneration}
|
onSubmit={onSubmitImageGeneration}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1142,6 +1147,7 @@ export function ImageCanvasGenerationComposerView({
|
|||||||
onUpdateSpecFormValue={onUpdateSpecFormValue}
|
onUpdateSpecFormValue={onUpdateSpecFormValue}
|
||||||
onRequestUpload={onRequestUpload}
|
onRequestUpload={onRequestUpload}
|
||||||
onRememberImageModel={onRememberImageModel}
|
onRememberImageModel={onRememberImageModel}
|
||||||
|
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||||
onSubmit={onSubmitImageGeneration}
|
onSubmit={onSubmitImageGeneration}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1202,6 +1208,7 @@ export function ImageCanvasGenerationComposerView({
|
|||||||
onOpenSpecDialog={onOpenSpecDialog}
|
onOpenSpecDialog={onOpenSpecDialog}
|
||||||
onRequestUpload={onRequestUpload}
|
onRequestUpload={onRequestUpload}
|
||||||
onRememberImageModel={onRememberImageModel}
|
onRememberImageModel={onRememberImageModel}
|
||||||
|
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||||
onSubmit={onSubmitImageGeneration}
|
onSubmit={onSubmitImageGeneration}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1223,6 +1230,7 @@ export function ImageCanvasGenerationComposerView({
|
|||||||
onRequestUpload={onRequestUpload}
|
onRequestUpload={onRequestUpload}
|
||||||
onUpdateIconDescriptionText={onUpdateIconDescriptionText}
|
onUpdateIconDescriptionText={onUpdateIconDescriptionText}
|
||||||
onRememberImageModel={onRememberImageModel}
|
onRememberImageModel={onRememberImageModel}
|
||||||
|
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||||
onSubmit={onSubmitIconSpritesheetGeneration}
|
onSubmit={onSubmitIconSpritesheetGeneration}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1278,6 +1286,7 @@ export function ImageCanvasGenerationComposerView({
|
|||||||
buildPortalMenuStyle={buildPortalMenuStyle}
|
buildPortalMenuStyle={buildPortalMenuStyle}
|
||||||
onRequestUpload={onRequestUpload}
|
onRequestUpload={onRequestUpload}
|
||||||
onRememberImageModel={onRememberImageModel}
|
onRememberImageModel={onRememberImageModel}
|
||||||
|
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||||
setQuickEditPanel={setQuickEditPanel}
|
setQuickEditPanel={setQuickEditPanel}
|
||||||
onSubmit={onSubmitQuickEdit}
|
onSubmit={onSubmitQuickEdit}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1134,6 +1134,93 @@ describe('ImageCanvasGenerationDialogModel', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('caps image references before a sixth reference can enter submission state', () => {
|
||||||
|
const existingReferences = Array.from({ length: 5 }, (_, index) => ({
|
||||||
|
id: `reference-${index}`,
|
||||||
|
label: `参考图${index + 1}`,
|
||||||
|
src: `generated-character-drafts/editor/reference-${index}.png`,
|
||||||
|
}));
|
||||||
|
const imageDialog: GenerateDialogState = {
|
||||||
|
mode: 'generate',
|
||||||
|
prompt: '',
|
||||||
|
status: 'idle',
|
||||||
|
generationReferences: existingReferences,
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
appendGenerationReference(
|
||||||
|
imageDialog,
|
||||||
|
createLayer({ id: 'reference-6', title: '参考图6' }),
|
||||||
|
),
|
||||||
|
).toMatchObject({
|
||||||
|
generationReferences: existingReferences,
|
||||||
|
});
|
||||||
|
|
||||||
|
const characterDialog: GenerateDialogState = {
|
||||||
|
mode: 'character',
|
||||||
|
prompt: '',
|
||||||
|
status: 'idle',
|
||||||
|
characterSpecReference: existingReferences[0],
|
||||||
|
characterReferences: existingReferences.slice(1),
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
appendCharacterReference(
|
||||||
|
characterDialog,
|
||||||
|
createLayer({ id: 'character-reference-6', title: '角色参考图5' }),
|
||||||
|
),
|
||||||
|
).toMatchObject({
|
||||||
|
characterReferences: existingReferences.slice(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const iconDialog: GenerateDialogState = {
|
||||||
|
mode: 'icon',
|
||||||
|
prompt: '',
|
||||||
|
status: 'idle',
|
||||||
|
imageModel: IMAGE_MODEL_GPT_IMAGE_2,
|
||||||
|
generationReferences: existingReferences.slice(0, 4),
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
appendGenerationReference(
|
||||||
|
iconDialog,
|
||||||
|
createLayer({ id: 'icon-reference-5', title: '图标参考图5' }),
|
||||||
|
),
|
||||||
|
).toMatchObject({
|
||||||
|
generationReferences: existingReferences.slice(0, 4),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reserves the primary-image slot before character and UI specs are selected', () => {
|
||||||
|
const references = Array.from({ length: 5 }, (_, index) => ({
|
||||||
|
id: `reference-${index}`,
|
||||||
|
label: `参考图${index + 1}`,
|
||||||
|
src: `/reference-${index}.png`,
|
||||||
|
}));
|
||||||
|
const characterDialog: GenerateDialogState = {
|
||||||
|
mode: 'character',
|
||||||
|
prompt: '',
|
||||||
|
status: 'idle',
|
||||||
|
characterReferences: references.slice(0, 4),
|
||||||
|
};
|
||||||
|
const uiDialog: GenerateDialogState = {
|
||||||
|
mode: 'ui-design',
|
||||||
|
prompt: '',
|
||||||
|
status: 'idle',
|
||||||
|
generationReferences: references.slice(0, 4),
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
appendCharacterReference(
|
||||||
|
characterDialog,
|
||||||
|
createLayer({ id: 'character-reference-5' }),
|
||||||
|
),
|
||||||
|
).toMatchObject({ characterReferences: references.slice(0, 4) });
|
||||||
|
expect(
|
||||||
|
appendGenerationReference(
|
||||||
|
uiDialog,
|
||||||
|
createLayer({ id: 'ui-reference-5' }),
|
||||||
|
),
|
||||||
|
).toMatchObject({ generationReferences: references.slice(0, 4) });
|
||||||
|
});
|
||||||
|
|
||||||
it('updates failed spec and icon dialog fields back to idle state', () => {
|
it('updates failed spec and icon dialog fields back to idle state', () => {
|
||||||
const specDialog: GenerateDialogState = {
|
const specDialog: GenerateDialogState = {
|
||||||
mode: 'spec',
|
mode: 'spec',
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
SpecGenerationType,
|
SpecGenerationType,
|
||||||
} from './ImageCanvasEditorTypes';
|
} from './ImageCanvasEditorTypes';
|
||||||
import {
|
import {
|
||||||
|
appendLimitedImageReferences,
|
||||||
appendLimitedQuickEditReferences,
|
appendLimitedQuickEditReferences,
|
||||||
AUDIO_FRAME_DISPLAY_SIZE,
|
AUDIO_FRAME_DISPLAY_SIZE,
|
||||||
AUDIO_FRAME_ORIGINAL_SIZE,
|
AUDIO_FRAME_ORIGINAL_SIZE,
|
||||||
@@ -36,6 +37,7 @@ import {
|
|||||||
normalizeEditorImageModel,
|
normalizeEditorImageModel,
|
||||||
PUBLICATION_FRAME_ORIGINAL_SIZE,
|
PUBLICATION_FRAME_ORIGINAL_SIZE,
|
||||||
resizeGenerationPlaceholderToImageSelection,
|
resizeGenerationPlaceholderToImageSelection,
|
||||||
|
resolveDialogExtraImageReferenceLimit,
|
||||||
resolveEditorImageGenerationPixelSize,
|
resolveEditorImageGenerationPixelSize,
|
||||||
resolveEditorVideoGenerationPixelSize,
|
resolveEditorVideoGenerationPixelSize,
|
||||||
SPEC_FRAME_ORIGINAL_SIZE,
|
SPEC_FRAME_ORIGINAL_SIZE,
|
||||||
@@ -644,10 +646,7 @@ function resolveGeneratedSourceDialogMode({
|
|||||||
if (sourceLayer.assetKind === 'character-animation') {
|
if (sourceLayer.assetKind === 'character-animation') {
|
||||||
return 'character-animation';
|
return 'character-animation';
|
||||||
}
|
}
|
||||||
if (
|
if (sourceLayer.assetKind === 'video' || sourceLayer.mediaType === 'video') {
|
||||||
sourceLayer.assetKind === 'video' ||
|
|
||||||
sourceLayer.mediaType === 'video'
|
|
||||||
) {
|
|
||||||
return 'video';
|
return 'video';
|
||||||
}
|
}
|
||||||
if (sourceLayer.assetKind === 'sound-effect') {
|
if (sourceLayer.assetKind === 'sound-effect') {
|
||||||
@@ -1005,8 +1004,7 @@ export function createSameSourceGenerationDialogDraft({
|
|||||||
videoModel: sourceDialog?.videoModel ?? draft.videoModel,
|
videoModel: sourceDialog?.videoModel ?? draft.videoModel,
|
||||||
videoAspectRatio:
|
videoAspectRatio:
|
||||||
sourceDialog?.videoAspectRatio ?? draft.videoAspectRatio,
|
sourceDialog?.videoAspectRatio ?? draft.videoAspectRatio,
|
||||||
videoResolution:
|
videoResolution: sourceDialog?.videoResolution ?? draft.videoResolution,
|
||||||
sourceDialog?.videoResolution ?? draft.videoResolution,
|
|
||||||
videoDurationSeconds:
|
videoDurationSeconds:
|
||||||
sourceDialog?.videoDurationSeconds ?? draft.videoDurationSeconds,
|
sourceDialog?.videoDurationSeconds ?? draft.videoDurationSeconds,
|
||||||
videoMode: sourceDialog?.videoMode ?? draft.videoMode,
|
videoMode: sourceDialog?.videoMode ?? draft.videoMode,
|
||||||
@@ -1424,6 +1422,7 @@ export function assignCharacterSpecReference(
|
|||||||
? {
|
? {
|
||||||
...resetFailedGenerationDialog(dialog),
|
...resetFailedGenerationDialog(dialog),
|
||||||
characterSpecReference: createCanvasLayerReference(layer),
|
characterSpecReference: createCanvasLayerReference(layer),
|
||||||
|
characterReferences: dialog.characterReferences,
|
||||||
composerOpen: true,
|
composerOpen: true,
|
||||||
}
|
}
|
||||||
: dialog;
|
: dialog;
|
||||||
@@ -1437,10 +1436,11 @@ export function appendCharacterReference(
|
|||||||
getReferenceMediaType(layer) === 'image'
|
getReferenceMediaType(layer) === 'image'
|
||||||
? {
|
? {
|
||||||
...resetFailedGenerationDialog(dialog),
|
...resetFailedGenerationDialog(dialog),
|
||||||
characterReferences: [
|
characterReferences: appendLimitedImageReferences(
|
||||||
...(dialog.characterReferences ?? []),
|
dialog.characterReferences,
|
||||||
createCanvasLayerReference(layer),
|
[createCanvasLayerReference(layer)],
|
||||||
],
|
resolveDialogExtraImageReferenceLimit(dialog),
|
||||||
|
),
|
||||||
composerOpen: true,
|
composerOpen: true,
|
||||||
}
|
}
|
||||||
: dialog;
|
: dialog;
|
||||||
@@ -1474,10 +1474,11 @@ export function appendGenerationReference(
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...resetFailedGenerationDialog(dialog),
|
...resetFailedGenerationDialog(dialog),
|
||||||
generationReferences: [
|
generationReferences: appendLimitedImageReferences(
|
||||||
...(dialog.generationReferences ?? []),
|
dialog.generationReferences,
|
||||||
createCanvasLayerReference(layer),
|
[createCanvasLayerReference(layer)],
|
||||||
],
|
resolveDialogExtraImageReferenceLimit(dialog),
|
||||||
|
),
|
||||||
composerOpen: true,
|
composerOpen: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1501,10 +1502,11 @@ export function appendPublicationReference(
|
|||||||
getReferenceMediaType(layer) === 'image'
|
getReferenceMediaType(layer) === 'image'
|
||||||
? {
|
? {
|
||||||
...resetFailedGenerationDialog(dialog),
|
...resetFailedGenerationDialog(dialog),
|
||||||
publicationReferences: [
|
publicationReferences: appendLimitedImageReferences(
|
||||||
...(dialog.publicationReferences ?? []),
|
dialog.publicationReferences,
|
||||||
createCanvasLayerReference(layer),
|
[createCanvasLayerReference(layer)],
|
||||||
],
|
resolveDialogExtraImageReferenceLimit(dialog),
|
||||||
|
),
|
||||||
composerOpen: true,
|
composerOpen: true,
|
||||||
}
|
}
|
||||||
: dialog;
|
: dialog;
|
||||||
@@ -1521,6 +1523,11 @@ export function assignIconSpecReference(
|
|||||||
? {
|
? {
|
||||||
...resetFailedGenerationDialog(dialog),
|
...resetFailedGenerationDialog(dialog),
|
||||||
iconSpecReference: createCanvasLayerReference(layer),
|
iconSpecReference: createCanvasLayerReference(layer),
|
||||||
|
generationReferences: appendLimitedImageReferences(
|
||||||
|
[],
|
||||||
|
dialog.generationReferences ?? [],
|
||||||
|
resolveDialogExtraImageReferenceLimit(dialog),
|
||||||
|
),
|
||||||
composerOpen: true,
|
composerOpen: true,
|
||||||
}
|
}
|
||||||
: dialog;
|
: dialog;
|
||||||
@@ -1537,6 +1544,7 @@ export function assignUiDesignSpecReference(
|
|||||||
? {
|
? {
|
||||||
...resetFailedGenerationDialog(dialog),
|
...resetFailedGenerationDialog(dialog),
|
||||||
uiDesignSpecReference: createCanvasLayerReference(layer),
|
uiDesignSpecReference: createCanvasLayerReference(layer),
|
||||||
|
generationReferences: dialog.generationReferences,
|
||||||
composerOpen: true,
|
composerOpen: true,
|
||||||
}
|
}
|
||||||
: dialog;
|
: dialog;
|
||||||
|
|||||||
@@ -15,13 +15,13 @@ import {
|
|||||||
function ImageOptionsHarness({
|
function ImageOptionsHarness({
|
||||||
initialDialog,
|
initialDialog,
|
||||||
includeDimensions = true,
|
includeDimensions = true,
|
||||||
|
hasPendingImageReferenceUploads = false,
|
||||||
onRememberImageModel = vi.fn(),
|
onRememberImageModel = vi.fn(),
|
||||||
lockedModel,
|
lockedModel,
|
||||||
}: {
|
}: {
|
||||||
initialDialog: GenerateDialogState;
|
initialDialog: GenerateDialogState;
|
||||||
includeDimensions?: boolean;
|
includeDimensions?: boolean;
|
||||||
|
hasPendingImageReferenceUploads?: boolean;
|
||||||
onRememberImageModel?: (model: string) => void;
|
onRememberImageModel?: (model: string) => void;
|
||||||
lockedModel?: string;
|
lockedModel?: string;
|
||||||
}) {
|
}) {
|
||||||
@@ -35,6 +35,7 @@ function ImageOptionsHarness({
|
|||||||
dialog={dialog}
|
dialog={dialog}
|
||||||
setGenerateDialog={setDialog}
|
setGenerateDialog={setDialog}
|
||||||
includeDimensions={includeDimensions}
|
includeDimensions={includeDimensions}
|
||||||
|
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||||
onRememberImageModel={onRememberImageModel}
|
onRememberImageModel={onRememberImageModel}
|
||||||
lockedModel={lockedModel}
|
lockedModel={lockedModel}
|
||||||
cost={calculateEditorImageGenerationPrice({
|
cost={calculateEditorImageGenerationPrice({
|
||||||
@@ -50,6 +51,13 @@ function ImageOptionsHarness({
|
|||||||
<output aria-label="当前风格">{dialog.style ?? '-'}</output>
|
<output aria-label="当前风格">{dialog.style ?? '-'}</output>
|
||||||
<output aria-label="当前状态">{dialog.status}</output>
|
<output aria-label="当前状态">{dialog.status}</output>
|
||||||
<output aria-label="当前错误">{dialog.errorMessage ?? '-'}</output>
|
<output aria-label="当前错误">{dialog.errorMessage ?? '-'}</output>
|
||||||
|
<output aria-label="当前参考图数量">
|
||||||
|
{dialog.mode === 'character'
|
||||||
|
? (dialog.characterReferences?.length ?? 0)
|
||||||
|
: dialog.mode === 'publication'
|
||||||
|
? (dialog.publicationReferences?.length ?? 0)
|
||||||
|
: (dialog.generationReferences?.length ?? 0)}
|
||||||
|
</output>
|
||||||
<output aria-label="当前占位">
|
<output aria-label="当前占位">
|
||||||
{dialog.placeholder
|
{dialog.placeholder
|
||||||
? [
|
? [
|
||||||
@@ -122,9 +130,7 @@ describe('ImageCanvasGenerationImageOptionsView', () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
expect(screen.queryByRole('checkbox', { name: '像素艺术' })).toBeNull();
|
||||||
screen.queryByRole('checkbox', { name: '像素艺术' }),
|
|
||||||
).toBeNull();
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -267,6 +273,90 @@ describe('ImageCanvasGenerationImageOptionsView', () => {
|
|||||||
expect(rememberImageModel).not.toHaveBeenCalled();
|
expect(rememberImageModel).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects a model switch that would discard existing references', () => {
|
||||||
|
const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
||||||
|
const rememberImageModel = vi.fn();
|
||||||
|
render(
|
||||||
|
<ImageOptionsHarness
|
||||||
|
initialDialog={{
|
||||||
|
mode: 'icon',
|
||||||
|
prompt: '',
|
||||||
|
status: 'idle',
|
||||||
|
imageModel: IMAGE_MODEL_NANOBANANA2,
|
||||||
|
generationReferences: Array.from({ length: 8 }, (_, index) => ({
|
||||||
|
id: `reference-${index + 1}`,
|
||||||
|
label: `参考图${index + 1}`,
|
||||||
|
src: `/reference-${index + 1}.png`,
|
||||||
|
})),
|
||||||
|
}}
|
||||||
|
includeDimensions={false}
|
||||||
|
onRememberImageModel={rememberImageModel}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText('当前参考图数量').textContent).toBe('8');
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole('button', { name: '生成图片模型 nanobanana2' }),
|
||||||
|
);
|
||||||
|
fireEvent.click(
|
||||||
|
within(screen.getByRole('menu', { name: '生成图片模型选项' })).getByRole(
|
||||||
|
'button',
|
||||||
|
{ name: 'gpt-image-2' },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(alertMock).toHaveBeenCalledWith(
|
||||||
|
'当前已有 8 张参考图,gpt-image-2 最多允许 4 张,请先删除多余参考图后再切换',
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText('当前参考图数量').textContent).toBe('8');
|
||||||
|
expect(screen.getByLabelText('当前模型').textContent).toBe(
|
||||||
|
IMAGE_MODEL_NANOBANANA2,
|
||||||
|
);
|
||||||
|
expect(rememberImageModel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects model switching while reference uploads are pending', () => {
|
||||||
|
const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
||||||
|
const rememberImageModel = vi.fn();
|
||||||
|
render(
|
||||||
|
<ImageOptionsHarness
|
||||||
|
initialDialog={{
|
||||||
|
mode: 'generate',
|
||||||
|
prompt: '',
|
||||||
|
status: 'idle',
|
||||||
|
imageModel: IMAGE_MODEL_NANOBANANA2,
|
||||||
|
}}
|
||||||
|
hasPendingImageReferenceUploads
|
||||||
|
onRememberImageModel={rememberImageModel}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole('button', { name: '生成图片模型 nanobanana2' }),
|
||||||
|
);
|
||||||
|
fireEvent.click(
|
||||||
|
within(screen.getByRole('menu', { name: '生成图片模型选项' })).getByRole(
|
||||||
|
'button',
|
||||||
|
{ name: 'gpt-image-2' },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(alertMock).toHaveBeenCalledWith(
|
||||||
|
'参考图正在上传,请等待上传完成后再切换模型',
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText('当前模型').textContent).toBe(
|
||||||
|
IMAGE_MODEL_NANOBANANA2,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
screen.getByRole('button', {
|
||||||
|
name: '生成角色形象',
|
||||||
|
}) as HTMLButtonElement
|
||||||
|
).disabled,
|
||||||
|
).toBe(true);
|
||||||
|
expect(rememberImageModel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('renders option menus and shows mud point text in the submit button', () => {
|
it('renders option menus and shows mud point text in the submit button', () => {
|
||||||
render(
|
render(
|
||||||
<ImageOptionsHarness
|
<ImageOptionsHarness
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { PlatformFloatingMenu } from '../common/PlatformFloatingMenu';
|
|||||||
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
|
import { PlatformInlineOptionButton } from '../common/PlatformInlineOptionButton';
|
||||||
import type { GenerateDialogState } from './ImageCanvasEditorTypes';
|
import type { GenerateDialogState } from './ImageCanvasEditorTypes';
|
||||||
import {
|
import {
|
||||||
|
countDialogExtraImageReferences,
|
||||||
EDITOR_IMAGE_DIMENSION_OPTIONS,
|
EDITOR_IMAGE_DIMENSION_OPTIONS,
|
||||||
EDITOR_IMAGE_MODEL_OPTIONS,
|
EDITOR_IMAGE_MODEL_OPTIONS,
|
||||||
getEditorImageModelDisplayName,
|
getEditorImageModelDisplayName,
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
normalizeEditorImageModel,
|
normalizeEditorImageModel,
|
||||||
QUICK_EDIT_MODEL_OPTIONS,
|
QUICK_EDIT_MODEL_OPTIONS,
|
||||||
resizeGenerationPlaceholderToImageSelection,
|
resizeGenerationPlaceholderToImageSelection,
|
||||||
|
resolveDialogExtraImageReferenceLimit,
|
||||||
resolveEditorImageSizeLabel,
|
resolveEditorImageSizeLabel,
|
||||||
} from './ImageCanvasGenerationModel';
|
} from './ImageCanvasGenerationModel';
|
||||||
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
|
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
|
||||||
@@ -30,6 +32,7 @@ type ImageCanvasGenerationImageOptionsViewProps = {
|
|||||||
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
|
setGenerateDialog: Dispatch<SetStateAction<GenerateDialogState | null>>;
|
||||||
includeDimensions: boolean;
|
includeDimensions: boolean;
|
||||||
includeModel?: boolean;
|
includeModel?: boolean;
|
||||||
|
hasPendingImageReferenceUploads?: boolean;
|
||||||
onRememberImageModel: (model: string) => void;
|
onRememberImageModel: (model: string) => void;
|
||||||
optionLabelPrefix?: string;
|
optionLabelPrefix?: string;
|
||||||
dimensionRatioAriaLabelPrefix?: string;
|
dimensionRatioAriaLabelPrefix?: string;
|
||||||
@@ -121,6 +124,7 @@ export function ImageCanvasGenerationImageOptionsView({
|
|||||||
setGenerateDialog,
|
setGenerateDialog,
|
||||||
includeDimensions,
|
includeDimensions,
|
||||||
includeModel = true,
|
includeModel = true,
|
||||||
|
hasPendingImageReferenceUploads = false,
|
||||||
onRememberImageModel,
|
onRememberImageModel,
|
||||||
optionLabelPrefix,
|
optionLabelPrefix,
|
||||||
dimensionRatioAriaLabelPrefix,
|
dimensionRatioAriaLabelPrefix,
|
||||||
@@ -192,7 +196,18 @@ export function ImageCanvasGenerationImageOptionsView({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const updateImageModel = (model: string) => {
|
const updateImageModel = (model: string) => {
|
||||||
onRememberImageModel(model);
|
if (hasPendingImageReferenceUploads) {
|
||||||
|
window.alert('参考图正在上传,请等待上传完成后再切换模型');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const referenceLimit = resolveDialogExtraImageReferenceLimit(dialog, model);
|
||||||
|
const referenceCount = countDialogExtraImageReferences(dialog);
|
||||||
|
if (referenceCount > referenceLimit) {
|
||||||
|
window.alert(
|
||||||
|
`当前已有 ${referenceCount} 张参考图,${getEditorImageModelDisplayName(model)} 最多允许 ${referenceLimit} 张,请先删除多余参考图后再切换`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setGenerateDialog((currentDialog) => {
|
setGenerateDialog((currentDialog) => {
|
||||||
if (!currentDialog || currentDialog.mode !== dialog.mode) {
|
if (!currentDialog || currentDialog.mode !== dialog.mode) {
|
||||||
return currentDialog;
|
return currentDialog;
|
||||||
@@ -224,6 +239,7 @@ export function ImageCanvasGenerationImageOptionsView({
|
|||||||
};
|
};
|
||||||
return resizeGenerationPlaceholderToImageSelection(nextDialog);
|
return resizeGenerationPlaceholderToImageSelection(nextDialog);
|
||||||
});
|
});
|
||||||
|
onRememberImageModel(model);
|
||||||
};
|
};
|
||||||
|
|
||||||
const togglePanel = (panel: Exclude<OpenPanel, null>) => {
|
const togglePanel = (panel: Exclude<OpenPanel, null>) => {
|
||||||
@@ -408,7 +424,7 @@ export function ImageCanvasGenerationImageOptionsView({
|
|||||||
size="xs"
|
size="xs"
|
||||||
shape="pill"
|
shape="pill"
|
||||||
className={submitButtonClassName}
|
className={submitButtonClassName}
|
||||||
disabled={isGenerating}
|
disabled={isGenerating || hasPendingImageReferenceUploads}
|
||||||
aria-label={submitAriaLabel}
|
aria-label={submitAriaLabel}
|
||||||
>
|
>
|
||||||
{isGenerating ? (
|
{isGenerating ? (
|
||||||
|
|||||||
@@ -416,6 +416,79 @@ export const SEEDANCE_VIDEO_REFERENCE_LIMITS = {
|
|||||||
audio: 3,
|
audio: 3,
|
||||||
} as const;
|
} as const;
|
||||||
export const QUICK_EDIT_REFERENCE_LIMIT = 8;
|
export const QUICK_EDIT_REFERENCE_LIMIT = 8;
|
||||||
|
export const IMAGE_GENERATION_REFERENCE_LIMIT = 5;
|
||||||
|
export const ICON_EXTRA_REFERENCE_LIMIT = 8;
|
||||||
|
export const UI_EXTRA_REFERENCE_LIMIT = 5;
|
||||||
|
|
||||||
|
export function resolveImageProviderReferenceLimit(
|
||||||
|
model: string | null | undefined,
|
||||||
|
) {
|
||||||
|
return normalizeEditorImageModel(model) === IMAGE_MODEL_NANOBANANA2 ? 14 : 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveExtraImageReferenceLimit(
|
||||||
|
model: string | null | undefined,
|
||||||
|
productLimit: number,
|
||||||
|
primaryReferenceCount = 0,
|
||||||
|
) {
|
||||||
|
return Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(
|
||||||
|
productLimit,
|
||||||
|
resolveImageProviderReferenceLimit(model) - primaryReferenceCount,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDialogExtraImageReferenceLimit(
|
||||||
|
dialog: GenerateDialogState,
|
||||||
|
model: string | null | undefined = dialog.imageModel,
|
||||||
|
) {
|
||||||
|
if (dialog.mode === 'icon') {
|
||||||
|
return resolveExtraImageReferenceLimit(
|
||||||
|
model,
|
||||||
|
ICON_EXTRA_REFERENCE_LIMIT,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (dialog.mode === 'ui-design') {
|
||||||
|
return resolveExtraImageReferenceLimit(
|
||||||
|
model,
|
||||||
|
IMAGE_GENERATION_REFERENCE_LIMIT - 1,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (dialog.mode === 'quick-edit') {
|
||||||
|
return resolveExtraImageReferenceLimit(
|
||||||
|
model,
|
||||||
|
QUICK_EDIT_REFERENCE_LIMIT,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (dialog.mode === 'character') {
|
||||||
|
return resolveExtraImageReferenceLimit(
|
||||||
|
model,
|
||||||
|
IMAGE_GENERATION_REFERENCE_LIMIT - 1,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return IMAGE_GENERATION_REFERENCE_LIMIT;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countDialogExtraImageReferences(dialog: GenerateDialogState) {
|
||||||
|
const references =
|
||||||
|
dialog.mode === 'character'
|
||||||
|
? dialog.characterReferences
|
||||||
|
: dialog.mode === 'publication'
|
||||||
|
? dialog.publicationReferences
|
||||||
|
: dialog.mode === 'video'
|
||||||
|
? []
|
||||||
|
: dialog.generationReferences;
|
||||||
|
return (references ?? []).filter(
|
||||||
|
(reference) => (reference.mediaType ?? 'image') === 'image',
|
||||||
|
).length;
|
||||||
|
}
|
||||||
|
|
||||||
export const CHARACTER_ANIMATION_ACTION_PROMPTS = [
|
export const CHARACTER_ANIMATION_ACTION_PROMPTS = [
|
||||||
{ label: '待机', text: '待机动作,轻微呼吸起伏。' },
|
{ label: '待机', text: '待机动作,轻微呼吸起伏。' },
|
||||||
{ label: '行走', text: '循环行走动作,步伐稳定。' },
|
{ label: '行走', text: '循环行走动作,步伐稳定。' },
|
||||||
@@ -1004,6 +1077,20 @@ export function appendLimitedQuickEditReferences(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function appendLimitedImageReferences(
|
||||||
|
references: CharacterReferenceImage[] | undefined,
|
||||||
|
nextReferences: CharacterReferenceImage[],
|
||||||
|
limit: number,
|
||||||
|
) {
|
||||||
|
const imageReferences = nextReferences.filter(
|
||||||
|
(reference) => (reference.mediaType ?? 'image') === 'image',
|
||||||
|
);
|
||||||
|
return [...(references ?? []), ...imageReferences].slice(
|
||||||
|
0,
|
||||||
|
Math.max(0, limit),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function createGenerationInputField(
|
export function createGenerationInputField(
|
||||||
title: string,
|
title: string,
|
||||||
value: string | null | undefined,
|
value: string | null | undefined,
|
||||||
@@ -1298,17 +1385,17 @@ export function isCanvasGenerationDialog(
|
|||||||
): dialog is CanvasGenerationDialogState {
|
): dialog is CanvasGenerationDialogState {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
dialog?.id &&
|
dialog?.id &&
|
||||||
(dialog.mode === 'generate' ||
|
(dialog.mode === 'generate' ||
|
||||||
dialog.mode === 'spec' ||
|
dialog.mode === 'spec' ||
|
||||||
dialog.mode === 'character' ||
|
dialog.mode === 'character' ||
|
||||||
dialog.mode === 'icon' ||
|
dialog.mode === 'icon' ||
|
||||||
dialog.mode === 'publication' ||
|
dialog.mode === 'publication' ||
|
||||||
dialog.mode === 'ui-design' ||
|
dialog.mode === 'ui-design' ||
|
||||||
dialog.mode === 'quick-edit' ||
|
dialog.mode === 'quick-edit' ||
|
||||||
dialog.mode === 'character-animation' ||
|
dialog.mode === 'character-animation' ||
|
||||||
dialog.mode === 'video' ||
|
dialog.mode === 'video' ||
|
||||||
dialog.mode === 'audio-sound-effect' ||
|
dialog.mode === 'audio-sound-effect' ||
|
||||||
dialog.mode === 'audio-background-music'),
|
dialog.mode === 'audio-background-music'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,9 +19,7 @@ import type {
|
|||||||
UploadTarget,
|
UploadTarget,
|
||||||
} from './ImageCanvasEditorTypes';
|
} from './ImageCanvasEditorTypes';
|
||||||
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
|
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
|
||||||
import {
|
import { calculateEditorIconSpritesheetPrice } from './ImageCanvasGenerationModel';
|
||||||
calculateEditorIconSpritesheetPrice,
|
|
||||||
} from './ImageCanvasGenerationModel';
|
|
||||||
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
|
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
|
||||||
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
|
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
|
||||||
|
|
||||||
@@ -42,6 +40,7 @@ type ImageCanvasIconSpritesheetComposerViewProps = {
|
|||||||
onRequestUpload: (target: UploadTarget) => void;
|
onRequestUpload: (target: UploadTarget) => void;
|
||||||
onUpdateIconDescriptionText: (value: string) => void;
|
onUpdateIconDescriptionText: (value: string) => void;
|
||||||
onRememberImageModel: (model: string) => void;
|
onRememberImageModel: (model: string) => void;
|
||||||
|
hasPendingImageReferenceUploads?: boolean;
|
||||||
onSubmit: (dialog: GenerateDialogState) => void;
|
onSubmit: (dialog: GenerateDialogState) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -59,6 +58,7 @@ export function ImageCanvasIconSpritesheetComposerView({
|
|||||||
onRequestUpload,
|
onRequestUpload,
|
||||||
onUpdateIconDescriptionText,
|
onUpdateIconDescriptionText,
|
||||||
onRememberImageModel,
|
onRememberImageModel,
|
||||||
|
hasPendingImageReferenceUploads = false,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: ImageCanvasIconSpritesheetComposerViewProps) {
|
}: ImageCanvasIconSpritesheetComposerViewProps) {
|
||||||
const descriptionText =
|
const descriptionText =
|
||||||
@@ -236,6 +236,7 @@ export function ImageCanvasIconSpritesheetComposerView({
|
|||||||
setGenerateDialog={setGenerateDialog}
|
setGenerateDialog={setGenerateDialog}
|
||||||
includeDimensions
|
includeDimensions
|
||||||
onRememberImageModel={onRememberImageModel}
|
onRememberImageModel={onRememberImageModel}
|
||||||
|
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||||
optionLabelPrefix="生成图片"
|
optionLabelPrefix="生成图片"
|
||||||
cost={calculateEditorIconSpritesheetPrice(
|
cost={calculateEditorIconSpritesheetPrice(
|
||||||
dialog.imageModel,
|
dialog.imageModel,
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ type ImageCanvasPublicationMaterialsDemoPanelViewProps = {
|
|||||||
) => CSSProperties;
|
) => CSSProperties;
|
||||||
onRequestUpload: (target: UploadTarget) => void;
|
onRequestUpload: (target: UploadTarget) => void;
|
||||||
onRememberImageModel: (model: string) => void;
|
onRememberImageModel: (model: string) => void;
|
||||||
|
hasPendingImageReferenceUploads?: boolean;
|
||||||
onSubmit: (dialog: GenerateDialogState) => void;
|
onSubmit: (dialog: GenerateDialogState) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -116,6 +117,7 @@ export function ImageCanvasPublicationMaterialsDemoPanelView({
|
|||||||
buildPortalMenuStyle,
|
buildPortalMenuStyle,
|
||||||
onRequestUpload,
|
onRequestUpload,
|
||||||
onRememberImageModel,
|
onRememberImageModel,
|
||||||
|
hasPendingImageReferenceUploads = false,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: ImageCanvasPublicationMaterialsDemoPanelViewProps) {
|
}: ImageCanvasPublicationMaterialsDemoPanelViewProps) {
|
||||||
const references = dialog.publicationReferences ?? [];
|
const references = dialog.publicationReferences ?? [];
|
||||||
@@ -302,6 +304,7 @@ export function ImageCanvasPublicationMaterialsDemoPanelView({
|
|||||||
setGenerateDialog={setGenerateDialog}
|
setGenerateDialog={setGenerateDialog}
|
||||||
includeDimensions={false}
|
includeDimensions={false}
|
||||||
onRememberImageModel={onRememberImageModel}
|
onRememberImageModel={onRememberImageModel}
|
||||||
|
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
||||||
lockedModel={IMAGE_MODEL_GPT_IMAGE_2}
|
lockedModel={IMAGE_MODEL_GPT_IMAGE_2}
|
||||||
optionLabelPrefix="宣发素材"
|
optionLabelPrefix="宣发素材"
|
||||||
cost={calculateEditorImageModelPrice(
|
cost={calculateEditorImageModelPrice(
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user