From 6f181b36a132c05f7dbca2de5c27feb59a6f7899 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 5 Aug 2026 21:33:02 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=BC=96=E8=BE=91=E5=99=A8?= =?UTF-8?q?=E7=94=9F=E6=88=90=E5=B9=82=E7=AD=89=E4=B8=8E=E5=8F=82=E8=80=83?= =?UTF-8?q?=E5=9B=BE=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生成队列按稳定请求标识去重并保持外部幂等哈希兼容 统一拒绝参考图超限并同步前端、后端、Provider 与 OpenAPI 契约 按真实归属重建生成引用并阻止直接持久化伪造来源 锁定参考图在途上传上下文并保留批量部分成功结果 关闭内部生成 POST 自动重试并补齐回归测试与项目文档 修正最新主线开发者密钥弹窗的导入排序门禁 --- .../genarrative-external-v1.openapi.json | 8 +- .../shared-memory/decision-log.md | 7 + docs/project-memory/shared-memory/pitfalls.md | 7 + ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 4 + ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 4 +- .../api-server/src/editor_generation_queue.rs | 318 +++++++- .../crates/api-server/src/editor_project.rs | 649 ++++++++++++++- .../api-server/src/external_editor_api.rs | 36 +- .../src/vector_engine/client.rs | 98 ++- .../src/vector_engine/constants.rs | 2 + .../src/vector_engine/curl_transport.rs | 2 +- .../src/vector_engine/image_source.rs | 16 +- .../src/vector_engine/request.rs | 10 +- ...ImageCanvasBasicGenerationComposerView.tsx | 3 + ...eCanvasCharacterGenerationComposerView.tsx | 3 + .../image-editor/ImageCanvasEditorTypes.ts | 1 + .../image-editor/ImageCanvasEditorView.tsx | 199 ++++- .../ImageCanvasGenerationComposerView.tsx | 9 + .../ImageCanvasGenerationDialogModel.test.ts | 87 ++ .../ImageCanvasGenerationDialogModel.ts | 44 +- ...eCanvasGenerationImageOptionsView.test.tsx | 100 ++- .../ImageCanvasGenerationImageOptionsView.tsx | 20 +- .../ImageCanvasGenerationModel.ts | 109 ++- ...ImageCanvasIconSpritesheetComposerView.tsx | 7 +- ...anvasPublicationMaterialsDemoPanelView.tsx | 3 + .../ImageCanvasQuickEditPanelView.tsx | 16 +- .../ImageCanvasSpecGenerationPanelView.tsx | 5 +- .../image-editor/ImageCanvasStageView.tsx | 3 + .../ImageCanvasUiAssetExtractionModel.ts | 1 + ...anvasUiAssetExtractionOverlayView.test.tsx | 73 ++ ...mageCanvasUiAssetExtractionOverlayView.tsx | 21 +- .../image-editor/ImageCanvasUploadModel.ts | 55 +- .../useCanvasGenerationDialogs.test.tsx | 72 ++ .../useCanvasGenerationDialogs.ts | 92 ++- .../useImageCanvasAssetCanvasBridge.test.tsx | 51 +- .../useImageCanvasAssetCanvasBridge.ts | 26 +- .../useImageCanvasAssetLibrary.test.tsx | 28 +- .../useImageCanvasAssetLibrary.ts | 10 +- .../useImageCanvasGenerationSurface.tsx | 4 + .../useImageCanvasGenerationWorkflow.test.tsx | 143 ++++ .../useImageCanvasGenerationWorkflow.ts | 451 ++++++++-- .../useImageCanvasLayerCommands.test.tsx | 53 +- .../useImageCanvasLayerCommands.ts | 30 + .../useImageCanvasUploadWorkflow.test.tsx | 369 ++++++++- .../useImageCanvasUploadWorkflow.ts | 767 +++++++++++++----- .../PlatformProfileApiKeysModal.tsx | 2 +- .../image-editor/editorProjectClient.test.ts | 78 +- .../image-editor/editorProjectClient.ts | 108 ++- .../image-editor/editorRetryOptions.ts | 7 + 49 files changed, 3731 insertions(+), 480 deletions(-) diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index 6c65f059f..1ddc9543d 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -3047,7 +3047,7 @@ "type": "array", "items": { "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 }, @@ -3241,7 +3241,7 @@ "type": "array", "items": { "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 }, @@ -3372,7 +3372,7 @@ "type": "array", "items": { "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 }, @@ -3496,7 +3496,7 @@ "type": "array", "items": { "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 }, diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index a8fd39af2..0aa45df6a 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6574,3 +6574,10 @@ - 遗留(建议单开,不在本次范围):`loadProjectCoverImage` 里无 timeout / 无 AbortSignal 的 `new Image()` 本身仍是隐患,自动保存路径一样会踩。本次只是把它移出生成链的关键路径,没有消除它。 - 影响范围:`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`。 +## 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。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 430d0258c..fe42622ad 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4195,3 +4195,10 @@ - 原因:两个跨模块测试读写同一进程全局状态,却没有共用隔离边界;只给 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 大小校验保持不变。 - 验证:用 `--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 资源 / 素材重建。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 4871d3a8a..e5fdfaf35 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -34,6 +34,10 @@ - 画布底部工具栏 / 面板 Dock 提供“画布 Agent”入口。点击后打开右侧独立 Agent 对话面板;桌面端为右侧窄面板,移动端占满可用宽度。该面板只与右上角任务侧栏互斥;素材 / 图层侧栏允许与 Agent 同时展开,切换左侧栏不得关闭 Agent。Agent 面板不得在当前画布内容下方追加内联内容,也不默认展示大段功能说明文案。 - 所有会新建画布生成占位的入口必须先创建 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 入口不展示或设置该选项。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 023ffc138..05bb5a2c5 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -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 上游任务。 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`,最终发布落资产时按该价格扣费;创作音频目标未提供该字段时才使用旧的创作音频固定成本。 -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、稳定引用、输入上限、持久化或处理阶段零持久化门禁。 +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。 ## 外部服务与资产 diff --git a/server-rs/crates/api-server/src/editor_generation_queue.rs b/server-rs/crates/api-server/src/editor_generation_queue.rs index 248adf54a..22db58e8b 100644 --- a/server-rs/crates/api-server/src/editor_generation_queue.rs +++ b/server-rs/crates/api-server/src/editor_generation_queue.rs @@ -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 MAX_EDITOR_GENERATION_JOB_PAYLOAD_BYTES: usize = 512 * 1024; 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)] #[serde(rename_all = "camelCase")] @@ -37,7 +38,7 @@ pub(crate) struct EditorGenerationQueuedResponse { pub(crate) async fn enqueue_editor_generation_job( state: &AppState, - _request_context: &RequestContext, + request_context: &RequestContext, owner_user_id: &str, job_kind: &str, source_entity_id: impl Into, @@ -49,6 +50,12 @@ where T: Serialize, { 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( state, owner_user_id, @@ -58,11 +65,26 @@ where price_mud_points, payload, job_id.clone(), - format!("editor-canvas:{job_kind}:{job_id}"), + dedupe_key, ) .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)] pub(crate) async fn enqueue_editor_generation_job_with_identity( state: &AppState, @@ -79,18 +101,25 @@ where T: Serialize, { 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, owner_user_id, job_kind, source_entity_id, request_label, price_mud_points, - request_payload_json, + request_payload_json.clone(), job_id, 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)] @@ -108,15 +137,11 @@ where T: Serialize, { let request_payload_json = serialize_editor_generation_job_payload(payload)?; - 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(idempotency_key.as_bytes()); - let dedupe_key = format!( - "{EXTERNAL_API_GENERATION_DEDUPE_PREFIX}:{job_kind}:{:x}", - hasher.finalize() + let dedupe_key = build_editor_generation_dedupe_key( + EXTERNAL_API_GENERATION_DEDUPE_PREFIX, + owner_user_id, + job_kind, + idempotency_key, ); let requested_job_id = build_prefixed_uuid_id("task-"); let job = enqueue_serialized_editor_generation_job_with_identity( @@ -132,20 +157,97 @@ where ) .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 { if job.job_kind != job_kind || 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( AppError::from_status(StatusCode::CONFLICT).with_details(json!({ "provider": EDITOR_GENERATION_QUEUE_PROVIDER, - "message": "Idempotency-Key 已用于不同的生成请求,请复用原请求参数或更换幂等键。", + "message": conflict_message, })), ); } 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::(existing), + serde_json::from_str::(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)] pub(crate) async fn enqueue_editor_generation_job_for_caller( 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] fn serialize_payload_accepts_persistable_media_references() { let payload = json!({ diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 1c9426b5b..8e70cac59 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -126,7 +126,19 @@ const EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS: u64 = EDITOR_ICON_SPRITESHE const EDITOR_ICON_SPRITESHEET_UPLOAD_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); const EDITOR_ICON_SPRITESHEET_UPLOAD_REQUEST_TIMEOUT: Duration = Duration::from_secs(60); const EDITOR_ICON_SPRITESHEET_MAX_PROCESSING_DURATION: Duration = Duration::from_secs(30); +const EDITOR_IMAGE_GENERATION_REFERENCE_LIMIT: usize = 5; +const EDITOR_QUICK_EDIT_REFERENCE_LIMIT: usize = 9; +const EDITOR_IMAGE_EDIT_EXTRA_REFERENCE_LIMIT: usize = 8; +const EDITOR_ICON_SPRITESHEET_EXTRA_REFERENCE_LIMIT: usize = 8; const EDITOR_UI_DESIGN_ASSET_EXTRACTION_REFERENCE_LIMIT: usize = 5; + +fn editor_provider_reference_limit(model: &str) -> usize { + if model == EDITOR_IMAGE_MODEL_NANOBANANA2 { + 14 + } else { + 5 + } +} const EDITOR_CHARACTER_IMAGE_ASSET_KIND: &str = "editor_character_image"; const EDITOR_CHARACTER_IMAGE_ENTITY_KIND: &str = "editor_project"; const EDITOR_CHARACTER_IMAGE_SLOT: &str = "character"; @@ -1351,7 +1363,7 @@ pub async fn create_editor_project_resource( Json(payload): Json, ) -> Result, AppError> { let generation_inputs_json = serialize_editor_asset_metadata( - sanitize_editor_client_generation_inputs(payload.generation_inputs.clone()), + sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.clone()), )?; let object_key = normalize_editor_object_key(payload.object_key); let image_src = normalize_editor_persisted_media_src(payload.image_src, object_key.as_deref())?; @@ -1520,7 +1532,7 @@ pub async fn create_editor_asset( Json(payload): Json, ) -> Result, AppError> { let generation_inputs_json = serialize_editor_asset_metadata( - sanitize_editor_client_generation_inputs(payload.generation_inputs.clone()), + sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.clone()), )?; let object_key = normalize_editor_object_key(payload.object_key); let image_src = normalize_editor_persisted_media_src(payload.image_src, object_key.as_deref())?; @@ -1697,7 +1709,7 @@ pub(crate) async fn enqueue_editor_image_generation_for_owner( external_idempotency_key: Option<&str>, ) -> Result { payload.generation_inputs = - sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); + sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.take()); ensure_editor_reference_image_sources_are_stable( payload.reference_image_srcs.as_deref(), "editor-image-generation", @@ -1717,6 +1729,19 @@ pub(crate) async fn enqueue_editor_image_generation_for_owner( payload.aspect_ratio.as_deref(), payload.image_size.as_deref(), ); + let reference_limit = if matches!(normalized_kind, Some("quick-edit")) { + EDITOR_QUICK_EDIT_REFERENCE_LIMIT + .min(editor_provider_reference_limit(generation_options.model)) + } else { + EDITOR_IMAGE_GENERATION_REFERENCE_LIMIT + }; + ensure_editor_reference_image_source_limit( + payload.reference_image_srcs.as_deref(), + reference_limit, + "editor-image-generation", + "referenceImageSrcs", + "生成参考图", + )?; let price_mud_points = u64::from( state .editor_generation_pricing() @@ -1810,6 +1835,30 @@ pub(crate) async fn generate_editor_image_for_owner( payload.aspect_ratio.as_deref(), payload.image_size.as_deref(), ); + let reference_limit = if matches!(normalized_kind, Some("quick-edit")) { + EDITOR_QUICK_EDIT_REFERENCE_LIMIT + .min(editor_provider_reference_limit(generation_options.model)) + } else { + EDITOR_IMAGE_GENERATION_REFERENCE_LIMIT + }; + ensure_editor_reference_image_source_limit( + payload.reference_image_srcs.as_deref(), + reference_limit, + "editor-image-generation", + "referenceImageSrcs", + "生成参考图", + )?; + payload.generation_inputs = rebuild_editor_generation_input_references( + state, + caller.owner_user_id.as_str(), + payload.generation_inputs.take(), + build_editor_generation_reference_sources( + None, + payload.reference_image_srcs.as_deref(), + "参考图", + ), + ) + .await?; let has_dimension_options = payload.aspect_ratio.is_some() || payload.image_size.is_some(); let image_size = resolve_editor_image_request_size( normalized_kind, @@ -1831,18 +1880,12 @@ pub(crate) async fn generate_editor_image_for_owner( Some("publication-material") => "图片画布生成宣发素材", _ => "图片画布生成图片", }; - let reference_limit = if matches!(normalized_kind, Some("quick-edit")) { - 9 - } else { - 5 - }; let reference_sources = payload .reference_image_srcs .unwrap_or_default() .into_iter() .map(|source| source.trim().to_string()) .filter(|source| !source.is_empty()) - .take(reference_limit) .collect::>(); // 决策移入闭包后 payload.reference_image_srcs 已被 reference_sources 消费, // 这里从过滤后的 reference_sources 预先固化 ui-design 的「是否带参考图」布尔。 @@ -4055,7 +4098,7 @@ pub(crate) async fn enqueue_editor_image_edit_for_owner( external_idempotency_key: Option<&str>, ) -> Result { payload.generation_inputs = - sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); + sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.take()); ensure_editor_reference_image_source_is_stable( payload.source_image_src.as_str(), "editor-image-edit", @@ -4075,6 +4118,15 @@ pub(crate) async fn enqueue_editor_image_edit_for_owner( payload.image_size.as_deref(), payload.size.as_deref(), ); + let extra_reference_limit = EDITOR_IMAGE_EDIT_EXTRA_REFERENCE_LIMIT + .min(editor_provider_reference_limit(generation_options.model).saturating_sub(1)); + ensure_editor_reference_image_source_limit( + payload.reference_image_srcs.as_deref(), + extra_reference_limit, + "editor-image-edit", + "referenceImageSrcs", + "修改参考图", + )?; let image_size = normalize_editor_image_generation_size(payload.size.as_deref()); let price_mud_points = u64::from( resolve_editor_image_edit_price( @@ -4137,6 +4189,26 @@ pub(crate) async fn edit_editor_image_for_owner( payload.image_size.as_deref(), payload.size.as_deref(), ); + let extra_reference_limit = EDITOR_IMAGE_EDIT_EXTRA_REFERENCE_LIMIT + .min(editor_provider_reference_limit(generation_options.model).saturating_sub(1)); + ensure_editor_reference_image_source_limit( + payload.reference_image_srcs.as_deref(), + extra_reference_limit, + "editor-image-edit", + "referenceImageSrcs", + "修改参考图", + )?; + payload.generation_inputs = rebuild_editor_generation_input_references( + state, + caller.owner_user_id.as_str(), + payload.generation_inputs.take(), + build_editor_generation_reference_sources( + Some(("原图", payload.source_image_src.as_str())), + payload.reference_image_srcs.as_deref(), + "参考图", + ), + ) + .await?; let has_dimension_options = payload.aspect_ratio.is_some() || payload.image_size.is_some(); let requested_image_size = normalize_editor_image_generation_size(payload.size.as_deref()); let delivery_size = if has_dimension_options { @@ -4159,8 +4231,7 @@ pub(crate) async fn edit_editor_image_for_owner( ) .await?, ); - for source in - normalize_editor_reference_image_sources(payload.reference_image_srcs.as_deref(), 8) + for source in normalize_editor_reference_image_sources(payload.reference_image_srcs.as_deref()) { reference_images.push( parse_editor_reference_image(state, caller.owner_user_id.as_str(), source).await?, @@ -4380,7 +4451,7 @@ pub async fn remove_editor_image_background( Json(mut payload): Json, ) -> Result, AppError> { payload.generation_inputs = - sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); + sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.take()); let caller = EditorGenerationCaller::from_authenticated(&authenticated); ensure_editor_reference_image_source_is_stable( payload.source_image_src.as_str(), @@ -4419,6 +4490,17 @@ pub(crate) async fn remove_editor_image_background_for_owner( ) -> Result, AppError> { payload.generation_inputs = sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); + payload.generation_inputs = rebuild_editor_generation_input_references( + state, + caller.owner_user_id.as_str(), + payload.generation_inputs.take(), + build_editor_generation_reference_sources( + Some(("原图", payload.source_image_src.as_str())), + None, + "参考图", + ), + ) + .await?; let started_at = Instant::now(); caller.report_processing_phase(state).await?; let source = resolve_editor_background_removal_source( @@ -4650,6 +4732,15 @@ fn validate_editor_pixel_art_snap_placeholder_exists( struct EditorPixelArtSourceResolution { object_key: String, asset_kind: Option, + generation_input_reference: Option, + existing_result_generation_inputs: Option>, +} + +fn resolve_editor_pixel_art_persisted_generation_inputs( + authoritative: Option, + existing_result: Option>, +) -> Option { + existing_result.unwrap_or(authoritative) } fn push_editor_pixel_art_source_asset_kind( @@ -4723,7 +4814,17 @@ async fn resolve_editor_pixel_art_source_for_owner( project: &EditorProjectPayload, source_resource: Option<&EditorProjectResourcePayload>, requested_asset_kind: Option<&str>, + expected_result_resource_id: &str, + expected_result_task_id: &str, ) -> Result { + let existing_result_generation_inputs = project + .resources + .iter() + .find(|resource| { + resource.resource_id.trim() == expected_result_resource_id + && resource.task_id.as_deref().map(str::trim) == Some(expected_result_task_id) + }) + .map(|resource| resource.generation_inputs.clone()); let resolved_without_lookup = match source_resource { Some(source_resource) => resolve_editor_pixel_art_source_without_lookup( owner_user_id, @@ -4886,9 +4987,40 @@ async fn resolve_editor_pixel_art_source_for_owner( discovered_asset_kinds.as_slice(), storage_asset_kinds.as_slice(), )?; + let generation_input_reference = if let Some(source_resource) = source_resource { + Some(json!({ + "title": "原图", + "label": source_resource + .label + .as_deref() + .or(source_resource.asset_kind.as_deref()) + .unwrap_or("项目资源"), + "refType": "project-resource", + "refId": source_resource.resource_id, + })) + } else if let Some((projects, library)) = owner_records.as_ref() { + editor_generation_reference_from_records( + projects.as_slice(), + library.assets.as_slice(), + "原图".to_string(), + source, + ) + .or_else(|| { + editor_generation_reference_from_records( + projects.as_slice(), + library.assets.as_slice(), + "原图".to_string(), + object_key.as_str(), + ) + }) + } else { + None + }; Ok(EditorPixelArtSourceResolution { object_key, asset_kind, + generation_input_reference, + existing_result_generation_inputs, }) } @@ -5025,13 +5157,19 @@ pub async fn snap_editor_image_to_pixel_art( payload: Result, JsonRejection>, ) -> Result, AppError> { let Json(mut payload) = parse_editor_generation_json_payload(payload)?; + // 旧版本把客户端 references 纳入完美像素 operation fingerprint。继续用同一份 + // canonical 输入计算指纹,确保升级前响应丢失的请求仍能命中原 operation;真正持久化 + // 的 generationInputs 会在下方删除客户端 references,并按已鉴权源记录重建。 + let fingerprint_generation_inputs = + sanitize_editor_client_generation_inputs(payload.generation_inputs.clone()) + .map(canonicalize_editor_json_value); // 中文注释:`screenColorHex / mattingProvider / mattingModel` 是服务端产出的处理事实 // (背景色决策与 bgfilter 实际执行后写入),不接受客户端声明,否则用户可以给自己的记录 // 伪造抠图模型等审计字段,污染后台按这些字段做的统计与排障。本端点是纯几何规整、不抠图, // 任何 matting 元数据出现在这里本身就是伪造。与其余生成入口共用同一个 sanitizer,位置也 // 保持一致:在任何 IO 之前。 payload.generation_inputs = - sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); + sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.take()); payload.generation_inputs = payload .generation_inputs .take() @@ -5060,6 +5198,13 @@ pub async fn snap_editor_image_to_pixel_art( })) })?; validate_editor_pixel_art_snap_canvas_completion(&payload.canvas_completion)?; + let dialog_id = + normalized_canvas_completion_dialog_id(&payload.canvas_completion).ok_or_else(|| { + editor_pixel_art_snap_failure( + StatusCode::BAD_REQUEST, + "完美像素必须关联有效的画布生成占位。", + ) + })?; // 中文注释:在 CPU 处理和 OSS PUT 前完成所有无副作用校验;主动操作的像素规整 // 失败必须直接返回错误,不能先创建与原图相同的派生资源。 serialize_editor_asset_metadata(payload.generation_inputs.clone())?; @@ -5068,6 +5213,16 @@ pub async fn snap_editor_image_to_pixel_art( // 都在许可覆盖范围内,许可随 handler 返回自动释放。 let _snap_permit = acquire_editor_pixel_art_snap_permit(processing_deadline).await?; let owner_user_id = current_owner_user_id(&authenticated); + let expected_result_task_id = format!("pixel-art-snap-{dialog_id}"); + let expected_result_resource_id = format!( + "{EDITOR_RESOURCE_ID_PREFIX}{}", + editor_pixel_art_stable_record_suffix( + owner_user_id.as_str(), + project_id.as_str(), + dialog_id.as_str(), + "project-resource", + ) + ); // 中文注释:归属校验阶段必须自己套绝对 deadline。预算只是从 handler 入口起算, // 起算不等于覆盖——此前这段里的 SpacetimeDB 调用全是裸 await,第一次真正应用预算 // 是下载。SpacetimeDB 慢时请求会一路走到下载才发现预算早已耗尽,返回的还是下载相关 @@ -5118,6 +5273,8 @@ pub async fn snap_editor_image_to_pixel_art( &project, source_resource, payload.asset_kind.as_deref(), + expected_result_resource_id.as_str(), + expected_result_task_id.as_str(), ) .await }) @@ -5130,6 +5287,18 @@ pub async fn snap_editor_image_to_pixel_art( })??; let source_object_key = source.object_key; let asset_kind = source.asset_kind; + let authoritative_generation_inputs = + rebuild_editor_generation_inputs_with_authoritative_references( + payload.generation_inputs.take(), + source.generation_input_reference.into_iter().collect(), + ); + // 旧结果已经落库时,重放必须携带原记录的 metadata 才能通过 SpacetimeDB 的精确 + // compare-and-return;这只复用已由服务端持久化的 owner-scoped 记录。新操作始终使用 + // 上面按已鉴权源重建的 references,不再接受客户端自报 provenance。 + payload.generation_inputs = resolve_editor_pixel_art_persisted_generation_inputs( + authoritative_generation_inputs, + source.existing_result_generation_inputs, + ); let source_image = download_editor_persisted_image_object_within_deadline( &state, source_object_key.as_str(), @@ -5159,13 +5328,6 @@ pub async fn snap_editor_image_to_pixel_art( })?; let output_image_sha256 = editor_pixel_art_sha256_hex(snapped_image.bytes.as_slice()); - let dialog_id = - normalized_canvas_completion_dialog_id(&payload.canvas_completion).ok_or_else(|| { - editor_pixel_art_snap_failure( - StatusCode::BAD_REQUEST, - "完美像素必须关联有效的画布生成占位。", - ) - })?; let asset_folder_id = normalize_generated_asset_folder_id( resolve_editor_pixel_art_asset_folder_id(payload.asset_folder_id.take()), owner_user_id.as_str(), @@ -5188,7 +5350,7 @@ pub async fn snap_editor_image_to_pixel_art( asset_kind.as_deref(), asset_folder_id.as_str(), asset_label.as_str(), - payload.generation_inputs.as_ref(), + fingerprint_generation_inputs.as_ref(), &payload.canvas_completion, )?; let response_task_id = persistence_identity.task_id.clone(); @@ -6157,7 +6319,7 @@ pub(crate) async fn enqueue_editor_icon_spritesheet_generation_for_owner( external_idempotency_key: Option<&str>, ) -> Result { payload.generation_inputs = - sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); + sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.take()); ensure_editor_reference_image_source_is_stable( payload.reference_image_src.as_str(), "editor-icon-spritesheet", @@ -6175,6 +6337,15 @@ pub(crate) async fn enqueue_editor_icon_spritesheet_generation_for_owner( payload.aspect_ratio.as_deref(), payload.image_size.as_deref(), ); + let extra_reference_limit = EDITOR_ICON_SPRITESHEET_EXTRA_REFERENCE_LIMIT + .min(editor_provider_reference_limit(generation_options.model).saturating_sub(1)); + ensure_editor_reference_image_source_limit( + payload.reference_image_srcs.as_deref(), + extra_reference_limit, + "editor-icon-spritesheet", + "referenceImageSrcs", + "图标素材参考图", + )?; let price_mud_points = u64::from( resolve_editor_icon_spritesheet_price( state, @@ -6221,6 +6392,31 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( "referenceImageSrcs", "图标素材参考图", )?; + let generation_options = normalize_editor_generation_options( + payload.model.as_deref(), + payload.aspect_ratio.as_deref(), + payload.image_size.as_deref(), + ); + let extra_reference_limit = EDITOR_ICON_SPRITESHEET_EXTRA_REFERENCE_LIMIT + .min(editor_provider_reference_limit(generation_options.model).saturating_sub(1)); + ensure_editor_reference_image_source_limit( + payload.reference_image_srcs.as_deref(), + extra_reference_limit, + "editor-icon-spritesheet", + "referenceImageSrcs", + "图标素材参考图", + )?; + payload.generation_inputs = rebuild_editor_generation_input_references( + state, + caller.owner_user_id.as_str(), + payload.generation_inputs.take(), + build_editor_generation_reference_sources( + Some(("图标规范", payload.reference_image_src.as_str())), + payload.reference_image_srcs.as_deref(), + "参考图", + ), + ) + .await?; let icon_descriptions = normalize_icon_descriptions(payload.icon_descriptions)?; let user_prompt = icon_descriptions.join("\n"); let (image_style, mut generation_warning) = @@ -6247,8 +6443,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( })) })?, ); - for source in - normalize_editor_reference_image_sources(payload.reference_image_srcs.as_deref(), 8) + for source in normalize_editor_reference_image_sources(payload.reference_image_srcs.as_deref()) { reference_images.push( parse_editor_reference_image(state, caller.owner_user_id.as_str(), source) @@ -6262,11 +6457,6 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( })?, ); } - let generation_options = normalize_editor_generation_options( - payload.model.as_deref(), - payload.aspect_ratio.as_deref(), - payload.image_size.as_deref(), - ); let expected_price_mud_points = resolve_editor_icon_spritesheet_price( state, Some(generation_options.model), @@ -7307,7 +7497,7 @@ pub(crate) async fn enqueue_editor_ui_design_asset_extraction_for_owner( external_idempotency_key: Option<&str>, ) -> Result { payload.generation_inputs = - sanitize_editor_client_generation_inputs(payload.generation_inputs.take()); + sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.take()); ensure_editor_reference_image_source_is_stable( payload.source_image_src.as_str(), "editor-ui-design-asset-extraction", @@ -7325,6 +7515,15 @@ pub(crate) async fn enqueue_editor_ui_design_asset_extraction_for_owner( payload.aspect_ratio.as_str(), payload.image_size.as_str(), )?; + let extra_reference_limit = EDITOR_UI_DESIGN_ASSET_EXTRACTION_REFERENCE_LIMIT + .min(editor_provider_reference_limit(generation_options.model).saturating_sub(1)); + ensure_editor_reference_image_source_limit( + payload.reference_image_srcs.as_deref(), + extra_reference_limit, + "editor-ui-design-asset-extraction", + "referenceImageSrcs", + "UI素材参考图", + )?; let price_mud_points = u64::from( resolve_editor_ui_design_asset_extraction_price( state, @@ -7371,6 +7570,31 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( "referenceImageSrcs", "UI素材参考图", )?; + let generation_options = normalize_editor_ui_design_asset_extraction_options( + payload.model.as_deref(), + payload.aspect_ratio.as_str(), + payload.image_size.as_str(), + )?; + let extra_reference_limit = EDITOR_UI_DESIGN_ASSET_EXTRACTION_REFERENCE_LIMIT + .min(editor_provider_reference_limit(generation_options.model).saturating_sub(1)); + ensure_editor_reference_image_source_limit( + payload.reference_image_srcs.as_deref(), + extra_reference_limit, + "editor-ui-design-asset-extraction", + "referenceImageSrcs", + "UI素材参考图", + )?; + payload.generation_inputs = rebuild_editor_generation_input_references( + state, + caller.owner_user_id.as_str(), + payload.generation_inputs.take(), + build_editor_generation_reference_sources( + Some(("UI设计图", payload.source_image_src.as_str())), + payload.reference_image_srcs.as_deref(), + "参考图", + ), + ) + .await?; // 背景色决策挪到预扣泥点之后(见下方 execute_billable 闭包),避免余额不足 / 生成注定失败时 // 仍白发一次 gpt-5-mini 决策。这里先固化决策需要的输入。 let requested_screen_color = payload.screen_color.clone(); @@ -7394,10 +7618,8 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( })) })?; let mut reference_images = vec![reference_image]; - for source in normalize_editor_reference_image_sources( - payload.reference_image_srcs.as_deref(), - EDITOR_UI_DESIGN_ASSET_EXTRACTION_REFERENCE_LIMIT, - ) { + for source in normalize_editor_reference_image_sources(payload.reference_image_srcs.as_deref()) + { reference_images.push( parse_editor_reference_image(state, caller.owner_user_id.as_str(), source) .await @@ -7410,11 +7632,6 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( })?, ); } - let generation_options = normalize_editor_ui_design_asset_extraction_options( - payload.model.as_deref(), - payload.aspect_ratio.as_str(), - payload.image_size.as_str(), - )?; let expected_price_mud_points = resolve_editor_ui_design_asset_extraction_price( state, Some(generation_options.model), @@ -8225,6 +8442,145 @@ pub(crate) fn sanitize_editor_client_generation_inputs(value: Option) -> value.map(sanitize_editor_reserved_generation_inputs) } +fn strip_editor_generation_input_references(mut value: Value) -> Value { + if let Some(object) = value.as_object_mut() { + object.remove("references"); + } + value +} + +pub(crate) fn sanitize_editor_untrusted_generation_inputs(value: Option) -> Option { + sanitize_editor_client_generation_inputs(value).map(strip_editor_generation_input_references) +} + +fn editor_generation_reference_from_records( + projects: &[EditorProjectRecord], + assets: &[EditorAssetRecord], + title: String, + source: &str, +) -> Option { + let source = source.trim(); + if source.is_empty() { + return None; + } + + if let Some(resource) = projects + .iter() + .flat_map(|project| project.resources.iter()) + .find(|resource| resource.resource_id.trim() == source) + { + return Some(json!({ + "title": title, + "label": resource.asset_kind.as_deref().unwrap_or("项目资源"), + "refType": "project-resource", + "refId": resource.resource_id, + })); + } + if let Some(asset) = assets.iter().find(|asset| asset.asset_id.trim() == source) { + return Some(json!({ + "title": title, + "label": asset.label, + "refType": "asset", + "refId": asset.asset_id, + })); + } + + let object_key = normalize_editor_reference_object_key(source).ok()?; + if let Some(resource) = projects + .iter() + .flat_map(|project| project.resources.iter()) + .find(|resource| { + editor_record_object_key_matches( + resource.object_key.as_deref(), + resource.image_src.as_str(), + object_key.as_str(), + ) + }) + { + return Some(json!({ + "title": title, + "label": resource.asset_kind.as_deref().unwrap_or("项目资源"), + "refType": "project-resource", + "refId": resource.resource_id, + })); + } + assets + .iter() + .find(|asset| { + editor_record_object_key_matches( + asset.object_key.as_deref(), + asset.image_src.as_str(), + object_key.as_str(), + ) + }) + .map(|asset| { + json!({ + "title": title, + "label": asset.label, + "refType": "asset", + "refId": asset.asset_id, + }) + }) +} + +async fn rebuild_editor_generation_input_references( + state: &AppState, + owner_user_id: &str, + generation_inputs: Option, + reference_sources: Vec<(String, String)>, +) -> Result, AppError> { + let sanitized = sanitize_editor_client_generation_inputs(generation_inputs); + if sanitized.is_none() && reference_sources.is_empty() { + return Ok(None); + } + + let references = if reference_sources.is_empty() { + Vec::new() + } else { + let projects = state + .spacetime_client() + .list_editor_projects(owner_user_id.to_string()) + .await + .map_err(map_editor_project_error)?; + let library = state + .spacetime_client() + .get_editor_asset_library(owner_user_id.to_string(), current_utc_micros()) + .await + .map_err(map_editor_project_error)?; + reference_sources + .into_iter() + .filter_map(|(title, source)| { + editor_generation_reference_from_records( + projects.as_slice(), + library.assets.as_slice(), + title, + source.as_str(), + ) + }) + .collect() + }; + + Ok(rebuild_editor_generation_inputs_with_authoritative_references(sanitized, references)) +} + +fn rebuild_editor_generation_inputs_with_authoritative_references( + generation_inputs: Option, + references: Vec, +) -> Option { + if generation_inputs.is_none() && references.is_empty() { + return None; + } + let mut value = generation_inputs.unwrap_or_else(|| json!({ "fields": [] })); + if !value.is_object() { + value = json!({ "fields": [] }); + } + value + .as_object_mut() + .expect("generation inputs should be an object") + .insert("references".to_string(), Value::Array(references)); + Some(value) +} + fn sanitize_editor_user_generation_inputs(value: Value) -> Value { sanitize_editor_reserved_generation_inputs(sanitize_editor_payload_inline_media(value)) } @@ -10458,17 +10814,66 @@ fn resolve_editor_background_removal_resource_model( None } -fn normalize_editor_reference_image_sources(sources: Option<&[String]>, limit: usize) -> Vec<&str> { +fn normalize_editor_reference_image_sources(sources: Option<&[String]>) -> Vec<&str> { sources .unwrap_or(&[]) .iter() .map(String::as_str) .map(str::trim) .filter(|source| !source.is_empty()) - .take(limit) .collect() } +fn ensure_editor_reference_image_source_limit( + sources: Option<&[String]>, + limit: usize, + provider: &str, + field: &str, + label: &str, +) -> Result<(), AppError> { + let actual_count = sources + .unwrap_or(&[]) + .iter() + .filter(|source| !source.trim().is_empty()) + .count(); + if actual_count <= limit { + return Ok(()); + } + Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": provider, + "field": field, + "message": format!("{label}最多允许 {limit} 张,当前提交 {actual_count} 张。"), + "actualCount": actual_count, + "maxCount": limit, + })), + ) +} + +fn build_editor_generation_reference_sources( + primary: Option<(&str, &str)>, + extras: Option<&[String]>, + extra_title: &str, +) -> Vec<(String, String)> { + let mut references = Vec::new(); + if let Some((title, source)) = primary { + if !source.trim().is_empty() { + references.push((title.to_string(), source.trim().to_string())); + } + } + references.extend( + extras + .unwrap_or(&[]) + .iter() + .map(String::as_str) + .map(str::trim) + .filter(|source| !source.is_empty()) + .enumerate() + .map(|(index, source)| (format!("{extra_title} {}", index + 1), source.to_string())), + ); + references +} + pub(crate) fn normalize_editor_reference_object_key(source: &str) -> Result { let object_key = source.trim().trim_start_matches('/').to_string(); if object_key.is_empty() || LegacyAssetPrefix::from_object_key(object_key.as_str()).is_none() { @@ -11776,6 +12181,159 @@ mod tests { ); } + #[test] + fn untrusted_generation_inputs_drop_client_claimed_references() { + let sanitized = sanitize_editor_untrusted_generation_inputs(Some(json!({ + "fields": [{"title": "角色设定", "value": "红发骑士"}], + "references": [{ + "title": "伪造引用", + "label": "其他用户素材", + "refType": "asset", + "refId": "asset-forged" + }] + }))) + .expect("visible fields should stay present"); + + assert_eq!( + sanitized["fields"], + json!([{"title": "角色设定", "value": "红发骑士"}]) + ); + assert!(sanitized.get("references").is_none()); + } + + #[test] + fn generation_reference_provenance_comes_from_matching_owner_records() { + let resource = test_editor_project_resource_record( + "resource-1", + "project-1", + "generated-character-drafts/editor/shared.png", + Some("gpt-image-2"), + None, + ); + let projects = vec![test_editor_project_record("project-1", vec![resource])]; + let asset = test_editor_asset_record( + "asset-1", + "generated-character-drafts/editor/asset.png", + Some("gpt-image-2"), + None, + ); + + let resource_reference = editor_generation_reference_from_records( + projects.as_slice(), + std::slice::from_ref(&asset), + "参考图 1".to_string(), + "generated-character-drafts/editor/shared.png", + ) + .expect("owned resource object key should produce provenance"); + assert_eq!(resource_reference["refType"], "project-resource"); + assert_eq!(resource_reference["refId"], "resource-1"); + + let asset_reference = editor_generation_reference_from_records( + projects.as_slice(), + std::slice::from_ref(&asset), + "参考图 2".to_string(), + "asset-1", + ) + .expect("owned asset id should produce provenance"); + assert_eq!(asset_reference["refType"], "asset"); + assert_eq!(asset_reference["refId"], "asset-1"); + + assert!( + editor_generation_reference_from_records( + projects.as_slice(), + std::slice::from_ref(&asset), + "参考图 3".to_string(), + "asset-forged", + ) + .is_none() + ); + } + + #[test] + fn perfect_pixel_replaces_client_references_with_authoritative_source() { + let untrusted = sanitize_editor_untrusted_generation_inputs(Some(json!({ + "fields": [{"title": "处理", "value": "完美像素"}], + "references": [{ + "title": "伪造跨账号引用", + "label": "其他账号素材", + "refType": "asset", + "refId": "asset-other-owner" + }] + }))); + let rebuilt = rebuild_editor_generation_inputs_with_authoritative_references( + untrusted, + vec![json!({ + "title": "原图", + "label": "当前项目资源", + "refType": "project-resource", + "refId": "resource-owned" + })], + ) + .expect("perfect pixel metadata should be rebuilt"); + + assert_eq!( + rebuilt["references"], + json!([{ + "title": "原图", + "label": "当前项目资源", + "refType": "project-resource", + "refId": "resource-owned" + }]) + ); + assert!(!rebuilt.to_string().contains("asset-other-owner")); + } + + #[test] + fn perfect_pixel_replay_uses_existing_server_metadata_for_exact_compare() { + let authoritative = Some(json!({ + "fields": [], + "references": [{"refType": "project-resource", "refId": "resource-owned"}] + })); + let historical = Some(json!({ + "fields": [], + "references": [{"refType": "asset", "refId": "legacy-client-value"}] + })); + + assert_eq!( + resolve_editor_pixel_art_persisted_generation_inputs( + authoritative, + Some(historical.clone()), + ), + historical + ); + assert_eq!( + resolve_editor_pixel_art_persisted_generation_inputs(None, Some(None)), + None + ); + } + + #[test] + fn editor_reference_limit_rejects_overflow_instead_of_truncating() { + let sources = (1..=6) + .map(|index| format!("generated-character-drafts/editor/{index}.png")) + .collect::>(); + let error = ensure_editor_reference_image_source_limit( + Some(sources.as_slice()), + 5, + "editor-image-generation", + "referenceImageSrcs", + "生成参考图", + ) + .expect_err("the sixth reference must be rejected"); + + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + assert_eq!( + error + .details() + .and_then(|details| details.get("actualCount")), + Some(&json!(6)) + ); + assert_eq!( + error.details().and_then(|details| details.get("maxCount")), + Some(&json!(5)) + ); + } + #[test] fn background_removal_source_model_recovers_normal_ancestor() { let source_key = "generated-character-drafts/editor/result.png"; @@ -15465,7 +16023,7 @@ mod tests { } #[test] - fn editor_reference_image_sources_are_trimmed_and_limited() { + fn editor_reference_image_sources_are_trimmed_without_silent_truncation() { let sources = vec![ " generated-character-drafts/editor/a.png ".to_string(), "".to_string(), @@ -15474,10 +16032,11 @@ mod tests { ]; assert_eq!( - normalize_editor_reference_image_sources(Some(sources.as_slice()), 2), + normalize_editor_reference_image_sources(Some(sources.as_slice())), vec![ "generated-character-drafts/editor/a.png", - "generated-character-drafts/editor/b.png" + "generated-character-drafts/editor/b.png", + "generated-character-drafts/editor/c.png" ] ); } diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index 1da75d7b5..4923b2b3f 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -41,7 +41,7 @@ use crate::{ enqueue_editor_image_generation_for_owner, enqueue_editor_ui_design_asset_extraction_for_owner, map_editor_project_error, 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, }, external_api_auth::ExternalApiPrincipal, @@ -991,7 +991,9 @@ fn normalize_project_title(title: Option) -> String { fn serialize_external_editor_generation_inputs( generation_inputs: Option, ) -> Result, 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)] @@ -1064,6 +1066,12 @@ mod tests { "screenColorHex": "#00FF00", "mattingProvider": "forged-provider", "mattingModel": "forged-model", + "references": [{ + "title": "伪造引用", + "label": "其他用户素材", + "refType": "asset", + "refId": "asset-forged" + }], "characterAnimation": {"durationSeconds": 4} }))) .expect("外部编辑器生成输入应可序列化") @@ -1075,6 +1083,7 @@ mod tests { assert!(parsed.get("screenColorHex").is_none()); assert!(parsed.get("mattingProvider").is_none()); assert!(parsed.get("mattingModel").is_none()); + assert!(parsed.get("references").is_none()); } #[test] @@ -1300,6 +1309,14 @@ mod tests { .get("default") .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!( parsed["components"]["schemas"]["EditorProject"]["properties"]["layers"]["type"], "array" @@ -1333,6 +1350,21 @@ mod tests { .get("targetLayerId") .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!( parsed["paths"] .get("/api/external/v1/editor/icon-spritesheets/generations") diff --git a/server-rs/crates/platform-image/src/vector_engine/client.rs b/server-rs/crates/platform-image/src/vector_engine/client.rs index c71b2c8ab..24b10196a 100644 --- a/server-rs/crates/platform-image/src/vector_engine/client.rs +++ b/server-rs/crates/platform-image/src/vector_engine/client.rs @@ -9,7 +9,10 @@ use super::{ effective_request_timeout_ms, request_budget_exhausted_error, 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::{ map_curl_error, send_vector_engine_json_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], failure_context: &str, ) -> Result { + 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 request_url = vector_engine_nanobanana_generate_content_url(settings, model); 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, reference_images, ); - let reference_image_count = reference_images.iter().take(14).count(); - let reference_image_bytes_total: usize = reference_images - .iter() - .take(14) - .map(|image| image.bytes.len()) - .sum(); + let reference_image_count = reference_images.len(); + let reference_image_bytes_total: usize = + reference_images.iter().map(|image| image.bytes.len()).sum(); let request_params = serde_json::json!({ "model": model, "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}:缺少参考图,图片编辑需要至少一张参考图。"), }); } + 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 normalized_size = normalize_image_size_for_model(requested_model, size); - let reference_image_count = reference_images.iter().take(5).count(); - let reference_image_bytes_total: usize = reference_images - .iter() - .take(5) - .map(|image| image.bytes.len()) - .sum(); + let reference_image_count = reference_images.len(); + let reference_image_bytes_total: usize = + reference_images.iter().map(|image| image.bytes.len()).sum(); let started_at = std::time::Instant::now(); let mut upstream_model = preferred_vector_engine_upstream_model(requested_model); let mut recovered_failure_audits = Vec::new(); @@ -1090,6 +1105,63 @@ fn vector_engine_send_retry_jitter_ms() -> u64 { mod tests { 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::>(); + 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::>(); + 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] async fn expired_deadline_stops_generation_before_network_send() { let settings = VectorEngineImageSettings { diff --git a/server-rs/crates/platform-image/src/vector_engine/constants.rs b/server-rs/crates/platform-image/src/vector_engine/constants.rs index 2da30eef3..6480fba73 100644 --- a/server-rs/crates/platform-image/src/vector_engine/constants.rs +++ b/server-rs/crates/platform-image/src/vector_engine/constants.rs @@ -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 VECTOR_ENGINE_GPT_IMAGE_2_MODEL: &str = GPT_IMAGE_2_MODEL; 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; diff --git a/server-rs/crates/platform-image/src/vector_engine/curl_transport.rs b/server-rs/crates/platform-image/src/vector_engine/curl_transport.rs index 2098718a3..fbe94e1b1 100644 --- a/server-rs/crates/platform-image/src/vector_engine/curl_transport.rs +++ b/server-rs/crates/platform-image/src/vector_engine/curl_transport.rs @@ -126,7 +126,7 @@ pub(crate) async fn send_vector_engine_multipart_edit_request_with_curl( let prompt = prompt.to_string(); let negative_prompt = negative_prompt.map(str::to_string); let normalized_size = normalized_size.to_string(); - let reference_images = reference_images.iter().take(5).cloned().collect::>(); + let reference_images = reference_images.to_vec(); tokio::task::spawn_blocking(move || { send_multipart_edit_request_with_curl_blocking( request_url.as_str(), diff --git a/server-rs/crates/platform-image/src/vector_engine/image_source.rs b/server-rs/crates/platform-image/src/vector_engine/image_source.rs index b9c65e853..dbc0b38a4 100644 --- a/server-rs/crates/platform-image/src/vector_engine/image_source.rs +++ b/server-rs/crates/platform-image/src/vector_engine/image_source.rs @@ -4,7 +4,7 @@ use std::time::Instant; use super::{ budget::request_budget_exhausted_error, - constants::VECTOR_ENGINE_PROVIDER, + constants::{VECTOR_ENGINE_IMAGE_EDIT_MAX_REFERENCE_IMAGES, VECTOR_ENGINE_PROVIDER}, error::PlatformImageError, types::{DownloadedImage, GeneratedImages, ReferenceImage}, }; @@ -129,8 +129,20 @@ pub(crate) async fn resolve_reference_images( failure_context: &str, request_deadline: Option, ) -> Result, 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(); - for (index, source) in reference_images.iter().take(5).enumerate() { + for (index, source) in reference_images.iter().enumerate() { let source = source.trim(); if source.is_empty() { continue; diff --git a/server-rs/crates/platform-image/src/vector_engine/request.rs b/server-rs/crates/platform-image/src/vector_engine/request.rs index a0daa53c4..af232dbc8 100644 --- a/server-rs/crates/platform-image/src/vector_engine/request.rs +++ b/server-rs/crates/platform-image/src/vector_engine/request.rs @@ -56,7 +56,7 @@ pub fn build_vector_engine_nanobanana_generate_content_request_body( ) -> Value { let prompt = build_prompt_with_negative(prompt, negative_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!({ "inline_data": { "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()); let references: Vec = reference_images .iter() - .take(5) .enumerate() .map(|(index, image)| { json!({ @@ -299,11 +298,8 @@ pub(crate) fn build_vector_engine_image_edit_request_log_params( }) }) .collect(); - let reference_image_bytes_total: usize = reference_images - .iter() - .take(5) - .map(|image| image.bytes.len()) - .sum(); + let reference_image_bytes_total: usize = + reference_images.iter().map(|image| image.bytes.len()).sum(); json!({ "model": model, diff --git a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx index 36a413994..7fc76170a 100644 --- a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx @@ -54,6 +54,7 @@ type ImageCanvasBasicGenerationComposerViewProps = { onPickReferenceFromCanvas?: () => void; onToggleReferenceMenu?: () => void; onRememberImageModel?: (model: string) => void; + hasPendingImageReferenceUploads?: boolean; onSubmit: (dialog: GenerateDialogState) => void; dialogLabel?: string; includeDimensions?: boolean; @@ -107,6 +108,7 @@ export function ImageCanvasBasicGenerationComposerView({ onPickReferenceFromCanvas, onToggleReferenceMenu, onRememberImageModel = () => {}, + hasPendingImageReferenceUploads = false, onSubmit, dialogLabel, includeDimensions = true, @@ -271,6 +273,7 @@ export function ImageCanvasBasicGenerationComposerView({ includeDimensions={shouldIncludeDimensions} includeModel={includeModel} onRememberImageModel={onRememberImageModel} + hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} dimensionRatioAriaLabelPrefix={dimensionRatioAriaLabelPrefix} dimensionSizeAriaLabelPrefix={dimensionSizeAriaLabelPrefix} optionLabelPrefix={resolvedOptionLabelPrefix} diff --git a/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.tsx index 186ccd838..73519d2d1 100644 --- a/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasCharacterGenerationComposerView.tsx @@ -44,6 +44,7 @@ type ImageCanvasCharacterGenerationComposerViewProps = { onOpenSpecDialog: (specType: SpecGenerationType) => void; onRequestUpload: (target: UploadTarget) => void; onRememberImageModel: (model: string) => void; + hasPendingImageReferenceUploads?: boolean; onSubmit: (dialog: GenerateDialogState) => void; }; @@ -97,6 +98,7 @@ export function ImageCanvasCharacterGenerationComposerView({ onOpenSpecDialog, onRequestUpload, onRememberImageModel, + hasPendingImageReferenceUploads = false, onSubmit, }: ImageCanvasCharacterGenerationComposerViewProps) { useImageCanvasFloatingOptionDismiss({ @@ -289,6 +291,7 @@ export function ImageCanvasCharacterGenerationComposerView({ setGenerateDialog={setGenerateDialog} includeDimensions onRememberImageModel={onRememberImageModel} + hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} optionLabelPrefix="生成图片" cost={calculateEditorImageGenerationPrice({ kind: 'character', diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index 51294c71a..316c8862e 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -390,6 +390,7 @@ export type CanvasContextMenuState = }; export type QuickEditPanelState = { + referenceUploadContextId?: string; mode?: 'quick-edit' | 'redraw'; sourceLayerId: string; prompt: string; diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 50d05ee39..8b6032918 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -58,12 +58,17 @@ import type { ImageContextMenuState, QuickEditPanelState, SidebarPanel, + UploadTarget, } from './ImageCanvasEditorTypes'; import { getEditorUploadAccept, isImageFile } from './ImageCanvasFileModel'; import { createLayerGenerationDialogDraft } from './ImageCanvasGenerationDialogModel'; import { applyEditorGenerationPricingConfig, isCanvasGenerationDialog, + QUICK_EDIT_REFERENCE_LIMIT, + resolveDialogExtraImageReferenceLimit, + resolveExtraImageReferenceLimit, + UI_EXTRA_REFERENCE_LIMIT, } from './ImageCanvasGenerationModel'; import { formatCanvasHistoryAction } from './ImageCanvasHistoryModel'; import { fitViewportToBounds } from './ImageCanvasInteractionModel'; @@ -97,7 +102,10 @@ import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands'; import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence'; import { useImageCanvasStageController } from './useImageCanvasStageController'; import { useImageCanvasStageInteractions } from './useImageCanvasStageInteractions'; -import { useImageCanvasUploadWorkflow } from './useImageCanvasUploadWorkflow'; +import { + type ImageReferenceUploadCapacity, + useImageCanvasUploadWorkflow, +} from './useImageCanvasUploadWorkflow'; import { DEFAULT_IMAGE_CANVAS_VIEWPORT, useImageCanvasViewportControls, @@ -430,6 +438,9 @@ export function ImageCanvasEditorView({ const appendUiAssetExtractionReferencesRef = useRef< (references: CharacterReferenceImage[]) => void >(() => {}); + const resolveImageReferenceUploadCapacityRef = useRef< + (target: UploadTarget) => ImageReferenceUploadCapacity | null + >(() => null); const deleteLayerByIdRef = useRef<(targetLayerId: string | null) => void>( () => {}, ); @@ -672,6 +683,9 @@ export function ImageCanvasEditorView({ discardCanvasHistoryEntriesContainingLayerRef.current(matchesLayer), [], ); + const canDeleteLayersRef = useRef<(targetLayerIds: string[]) => boolean>( + () => true, + ); const removeCanvasLayersLinkedToAssets = useImageCanvasAssetLayerCleanup({ layers, setLayers, @@ -679,6 +693,8 @@ export function ImageCanvasEditorView({ setSelectedLayerIds, onDeleteLayerSideEffects: (layerId) => clearDeletedLayerGenerationStateRef.current(layerId), + canDeleteLayers: (targetLayerIds) => + canDeleteLayersRef.current(targetLayerIds), discardHistoryEntriesContainingLayer: discardAssetRelatedCanvasHistory, }); const { @@ -734,6 +750,14 @@ export function ImageCanvasEditorView({ onDeleteAssets: removeCanvasLayersLinkedToAssets, }); + const pendingImageReferenceUploadCountRef = useRef(0); + const isPendingReferenceUploadContextMutationLocked = useCallback( + () => pendingImageReferenceUploadCountRef.current > 0, + [], + ); + const rejectPendingReferenceUploadContextMutation = useCallback(() => { + window.alert('参考图正在上传,请等待上传完成后再切换或关闭生成面板'); + }, []); const handleActivateCanvasGenerationDialog = useCallback(() => { closeGenerationTransientStateRef.current(); setSelectedLayerId(null); @@ -761,6 +785,8 @@ export function ImageCanvasEditorView({ getGeneratingDialogPlaceholder, } = useCanvasGenerationDialogs({ onActivate: handleActivateCanvasGenerationDialog, + isContextMutationLocked: isPendingReferenceUploadContextMutationLocked, + onContextMutationRejected: rejectPendingReferenceUploadContextMutation, }); canvasGenerationDialogsRef.current = canvasGenerationDialogs; const canvasHistoryRefs = useMemo( @@ -1238,7 +1264,7 @@ export function ImageCanvasEditorView({ captureCanvasHistory(action); applyProjectSnapshot(project); if (action.type !== 'perfect-pixel') { - void refreshAssetLibrary(); + void refreshAssetLibrary(); } }, [applyProjectSnapshot, captureCanvasHistory, refreshAssetLibrary], @@ -1344,6 +1370,7 @@ export function ImageCanvasEditorView({ const { uploadInputRef, uploadTarget, + pendingImageReferenceUploadCount, requestUpload, handleUploadInputChange, addUploadedFiles, @@ -1365,12 +1392,17 @@ export function ImageCanvasEditorView({ setLayers, setGenerateDialog, setQuickEditPanel: (updater) => setQuickEditPanelRef.current(updater), + resolveImageReferenceUploadCapacity: (target) => + resolveImageReferenceUploadCapacityRef.current(target), appendUiAssetExtractionReferences: (references) => appendUiAssetExtractionReferencesRef.current(references), appendCanvasLayersWithResources, captureCanvasHistory, selectSingleLayer, + refreshAssetLibrary, }); + pendingImageReferenceUploadCountRef.current = + pendingImageReferenceUploadCount; const generationSurface = useImageCanvasGenerationSurface({ layers, canvasSize, @@ -1387,6 +1419,7 @@ export function ImageCanvasEditorView({ generationReferenceButtonRef, publicationReferenceButtonRef, iconSpecButtonRef, + hasPendingImageReferenceUploads: pendingImageReferenceUploadCount > 0, generateDialog, setGenerateDialog, activeCanvasGenerationDialog, @@ -1549,6 +1582,7 @@ export function ImageCanvasEditorView({ clearDeletedLayerGenerationState, uiAssetExtractionState, uiAssetExtractionSourceLayer, + quickEditPanel, quickEditSelectionState, quickEditSelectionSourceLayer, changeUiAssetExtractionTool, @@ -1593,6 +1627,137 @@ export function ImageCanvasEditorView({ clearDeletedLayerGenerationState; appendUiAssetExtractionReferencesRef.current = 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(() => { if (!isProjectReady || startupIntentConsumedRef.current) { return; @@ -1735,6 +1900,33 @@ export function ImageCanvasEditorView({ contextMenu?.kind === 'layer' ? (layers.find((layer) => layer.id === contextMenu.layerId) ?? 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 { canvasClipboard, canCopyContextLayers, @@ -1779,6 +1971,8 @@ export function ImageCanvasEditorView({ onRequestDeleteGenerationDialog: requestRemoveCanvasGenerationDialog, exportLayerImage, onCanvasLayerCopyBlocked: showCanvasLayerCopyWarning, + canDeleteLayers: (targetLayerIds) => + canDeleteLayersRef.current(targetLayerIds), }); const { canvasMarquee, @@ -2328,6 +2522,7 @@ export function ImageCanvasEditorView({ : uiAssetExtractionSourceLayer, quickEditSelectionState, quickEditSelectionSourceLayer, + hasPendingImageReferenceUploads: pendingImageReferenceUploadCount > 0, generationComposerStyle, selectedToolbarStyle, perfectPixelLayerIds, diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx index 3567b1c73..2bb7cfa05 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx @@ -81,6 +81,7 @@ type ImageCanvasGenerationComposerViewProps = { isPickingCharacterReferenceFromCanvas: boolean; isPickingIconSpecFromCanvas: boolean; isPickingUiDesignSpecFromCanvas: boolean; + hasPendingImageReferenceUploads?: boolean; generateDialog: GenerateDialogState | null; generationComposerStyle: CSSProperties | null; iconComposerStyle: CSSProperties | null; @@ -994,6 +995,7 @@ export function ImageCanvasGenerationComposerView({ setIsPickingCharacterReferenceFromCanvas, setIsPickingIconSpecFromCanvas, setIsPickingUiDesignSpecFromCanvas, + hasPendingImageReferenceUploads = false, onOpenSpecDialog, onRequestUpload, onSubmitImageGeneration, @@ -1073,6 +1075,7 @@ export function ImageCanvasGenerationComposerView({ setIsGenerationReferenceMenuOpen((open) => !open) } onRememberImageModel={onRememberImageModel} + hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} onSubmit={onSubmitImageGeneration} /> ) : null} @@ -1095,6 +1098,7 @@ export function ImageCanvasGenerationComposerView({ onOpenSpecDialog={onOpenSpecDialog} onUpdateSpecFormValue={onUpdateSpecFormValue} onRequestUpload={onRequestUpload} + hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} onSubmit={onSubmitImageGeneration} /> ) : null} @@ -1119,6 +1123,7 @@ export function ImageCanvasGenerationComposerView({ buildPortalMenuStyle={buildPortalMenuStyle} onRequestUpload={onRequestUpload} onRememberImageModel={onRememberImageModel} + hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} onSubmit={onSubmitImageGeneration} /> ) : null} @@ -1142,6 +1147,7 @@ export function ImageCanvasGenerationComposerView({ onUpdateSpecFormValue={onUpdateSpecFormValue} onRequestUpload={onRequestUpload} onRememberImageModel={onRememberImageModel} + hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} onSubmit={onSubmitImageGeneration} /> ) : null} @@ -1202,6 +1208,7 @@ export function ImageCanvasGenerationComposerView({ onOpenSpecDialog={onOpenSpecDialog} onRequestUpload={onRequestUpload} onRememberImageModel={onRememberImageModel} + hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} onSubmit={onSubmitImageGeneration} /> ) : null} @@ -1223,6 +1230,7 @@ export function ImageCanvasGenerationComposerView({ onRequestUpload={onRequestUpload} onUpdateIconDescriptionText={onUpdateIconDescriptionText} onRememberImageModel={onRememberImageModel} + hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} onSubmit={onSubmitIconSpritesheetGeneration} /> ) : null} @@ -1278,6 +1286,7 @@ export function ImageCanvasGenerationComposerView({ buildPortalMenuStyle={buildPortalMenuStyle} onRequestUpload={onRequestUpload} onRememberImageModel={onRememberImageModel} + hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} setQuickEditPanel={setQuickEditPanel} onSubmit={onSubmitQuickEdit} /> diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts index c568371b7..fff458ef8 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts @@ -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', () => { const specDialog: GenerateDialogState = { mode: 'spec', diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts index 6c8019b3b..27b6e051e 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts @@ -11,6 +11,7 @@ import type { SpecGenerationType, } from './ImageCanvasEditorTypes'; import { + appendLimitedImageReferences, appendLimitedQuickEditReferences, AUDIO_FRAME_DISPLAY_SIZE, AUDIO_FRAME_ORIGINAL_SIZE, @@ -36,6 +37,7 @@ import { normalizeEditorImageModel, PUBLICATION_FRAME_ORIGINAL_SIZE, resizeGenerationPlaceholderToImageSelection, + resolveDialogExtraImageReferenceLimit, resolveEditorImageGenerationPixelSize, resolveEditorVideoGenerationPixelSize, SPEC_FRAME_ORIGINAL_SIZE, @@ -644,10 +646,7 @@ function resolveGeneratedSourceDialogMode({ if (sourceLayer.assetKind === 'character-animation') { return 'character-animation'; } - if ( - sourceLayer.assetKind === 'video' || - sourceLayer.mediaType === 'video' - ) { + if (sourceLayer.assetKind === 'video' || sourceLayer.mediaType === 'video') { return 'video'; } if (sourceLayer.assetKind === 'sound-effect') { @@ -1005,8 +1004,7 @@ export function createSameSourceGenerationDialogDraft({ videoModel: sourceDialog?.videoModel ?? draft.videoModel, videoAspectRatio: sourceDialog?.videoAspectRatio ?? draft.videoAspectRatio, - videoResolution: - sourceDialog?.videoResolution ?? draft.videoResolution, + videoResolution: sourceDialog?.videoResolution ?? draft.videoResolution, videoDurationSeconds: sourceDialog?.videoDurationSeconds ?? draft.videoDurationSeconds, videoMode: sourceDialog?.videoMode ?? draft.videoMode, @@ -1424,6 +1422,7 @@ export function assignCharacterSpecReference( ? { ...resetFailedGenerationDialog(dialog), characterSpecReference: createCanvasLayerReference(layer), + characterReferences: dialog.characterReferences, composerOpen: true, } : dialog; @@ -1437,10 +1436,11 @@ export function appendCharacterReference( getReferenceMediaType(layer) === 'image' ? { ...resetFailedGenerationDialog(dialog), - characterReferences: [ - ...(dialog.characterReferences ?? []), - createCanvasLayerReference(layer), - ], + characterReferences: appendLimitedImageReferences( + dialog.characterReferences, + [createCanvasLayerReference(layer)], + resolveDialogExtraImageReferenceLimit(dialog), + ), composerOpen: true, } : dialog; @@ -1474,10 +1474,11 @@ export function appendGenerationReference( } return { ...resetFailedGenerationDialog(dialog), - generationReferences: [ - ...(dialog.generationReferences ?? []), - createCanvasLayerReference(layer), - ], + generationReferences: appendLimitedImageReferences( + dialog.generationReferences, + [createCanvasLayerReference(layer)], + resolveDialogExtraImageReferenceLimit(dialog), + ), composerOpen: true, }; } @@ -1501,10 +1502,11 @@ export function appendPublicationReference( getReferenceMediaType(layer) === 'image' ? { ...resetFailedGenerationDialog(dialog), - publicationReferences: [ - ...(dialog.publicationReferences ?? []), - createCanvasLayerReference(layer), - ], + publicationReferences: appendLimitedImageReferences( + dialog.publicationReferences, + [createCanvasLayerReference(layer)], + resolveDialogExtraImageReferenceLimit(dialog), + ), composerOpen: true, } : dialog; @@ -1521,6 +1523,11 @@ export function assignIconSpecReference( ? { ...resetFailedGenerationDialog(dialog), iconSpecReference: createCanvasLayerReference(layer), + generationReferences: appendLimitedImageReferences( + [], + dialog.generationReferences ?? [], + resolveDialogExtraImageReferenceLimit(dialog), + ), composerOpen: true, } : dialog; @@ -1537,6 +1544,7 @@ export function assignUiDesignSpecReference( ? { ...resetFailedGenerationDialog(dialog), uiDesignSpecReference: createCanvasLayerReference(layer), + generationReferences: dialog.generationReferences, composerOpen: true, } : dialog; diff --git a/src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx b/src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx index d00774068..f12e82f76 100644 --- a/src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx +++ b/src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx @@ -15,13 +15,13 @@ import { function ImageOptionsHarness({ initialDialog, includeDimensions = true, - + hasPendingImageReferenceUploads = false, onRememberImageModel = vi.fn(), lockedModel, }: { initialDialog: GenerateDialogState; includeDimensions?: boolean; - + hasPendingImageReferenceUploads?: boolean; onRememberImageModel?: (model: string) => void; lockedModel?: string; }) { @@ -35,6 +35,7 @@ function ImageOptionsHarness({ dialog={dialog} setGenerateDialog={setDialog} includeDimensions={includeDimensions} + hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} onRememberImageModel={onRememberImageModel} lockedModel={lockedModel} cost={calculateEditorImageGenerationPrice({ @@ -50,6 +51,13 @@ function ImageOptionsHarness({ {dialog.style ?? '-'} {dialog.status} {dialog.errorMessage ?? '-'} + + {dialog.mode === 'character' + ? (dialog.characterReferences?.length ?? 0) + : dialog.mode === 'publication' + ? (dialog.publicationReferences?.length ?? 0) + : (dialog.generationReferences?.length ?? 0)} + {dialog.placeholder ? [ @@ -122,9 +130,7 @@ describe('ImageCanvasGenerationImageOptionsView', () => { />, ); - expect( - screen.queryByRole('checkbox', { name: '像素艺术' }), - ).toBeNull(); + expect(screen.queryByRole('checkbox', { name: '像素艺术' })).toBeNull(); }, ); @@ -267,6 +273,90 @@ describe('ImageCanvasGenerationImageOptionsView', () => { 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( + ({ + 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( + , + ); + + 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', () => { render( >; includeDimensions: boolean; includeModel?: boolean; + hasPendingImageReferenceUploads?: boolean; onRememberImageModel: (model: string) => void; optionLabelPrefix?: string; dimensionRatioAriaLabelPrefix?: string; @@ -121,6 +124,7 @@ export function ImageCanvasGenerationImageOptionsView({ setGenerateDialog, includeDimensions, includeModel = true, + hasPendingImageReferenceUploads = false, onRememberImageModel, optionLabelPrefix, dimensionRatioAriaLabelPrefix, @@ -192,7 +196,18 @@ export function ImageCanvasGenerationImageOptionsView({ }; 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) => { if (!currentDialog || currentDialog.mode !== dialog.mode) { return currentDialog; @@ -224,6 +239,7 @@ export function ImageCanvasGenerationImageOptionsView({ }; return resizeGenerationPlaceholderToImageSelection(nextDialog); }); + onRememberImageModel(model); }; const togglePanel = (panel: Exclude) => { @@ -408,7 +424,7 @@ export function ImageCanvasGenerationImageOptionsView({ size="xs" shape="pill" className={submitButtonClassName} - disabled={isGenerating} + disabled={isGenerating || hasPendingImageReferenceUploads} aria-label={submitAriaLabel} > {isGenerating ? ( diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index 396d10029..a4c4822a7 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -416,6 +416,79 @@ export const SEEDANCE_VIDEO_REFERENCE_LIMITS = { audio: 3, } as const; 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 = [ { 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( title: string, value: string | null | undefined, @@ -1298,17 +1385,17 @@ export function isCanvasGenerationDialog( ): dialog is CanvasGenerationDialogState { return Boolean( dialog?.id && - (dialog.mode === 'generate' || - dialog.mode === 'spec' || - dialog.mode === 'character' || - dialog.mode === 'icon' || - dialog.mode === 'publication' || - dialog.mode === 'ui-design' || - dialog.mode === 'quick-edit' || - dialog.mode === 'character-animation' || - dialog.mode === 'video' || - dialog.mode === 'audio-sound-effect' || - dialog.mode === 'audio-background-music'), + (dialog.mode === 'generate' || + dialog.mode === 'spec' || + dialog.mode === 'character' || + dialog.mode === 'icon' || + dialog.mode === 'publication' || + dialog.mode === 'ui-design' || + dialog.mode === 'quick-edit' || + dialog.mode === 'character-animation' || + dialog.mode === 'video' || + dialog.mode === 'audio-sound-effect' || + dialog.mode === 'audio-background-music'), ); } diff --git a/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.tsx b/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.tsx index e8acd1417..2e55408fe 100644 --- a/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.tsx +++ b/src/components/image-editor/ImageCanvasIconSpritesheetComposerView.tsx @@ -19,9 +19,7 @@ import type { UploadTarget, } from './ImageCanvasEditorTypes'; import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView'; -import { - calculateEditorIconSpritesheetPrice, -} from './ImageCanvasGenerationModel'; +import { calculateEditorIconSpritesheetPrice } from './ImageCanvasGenerationModel'; import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot'; import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss'; @@ -42,6 +40,7 @@ type ImageCanvasIconSpritesheetComposerViewProps = { onRequestUpload: (target: UploadTarget) => void; onUpdateIconDescriptionText: (value: string) => void; onRememberImageModel: (model: string) => void; + hasPendingImageReferenceUploads?: boolean; onSubmit: (dialog: GenerateDialogState) => void; }; @@ -59,6 +58,7 @@ export function ImageCanvasIconSpritesheetComposerView({ onRequestUpload, onUpdateIconDescriptionText, onRememberImageModel, + hasPendingImageReferenceUploads = false, onSubmit, }: ImageCanvasIconSpritesheetComposerViewProps) { const descriptionText = @@ -236,6 +236,7 @@ export function ImageCanvasIconSpritesheetComposerView({ setGenerateDialog={setGenerateDialog} includeDimensions onRememberImageModel={onRememberImageModel} + hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} optionLabelPrefix="生成图片" cost={calculateEditorIconSpritesheetPrice( dialog.imageModel, diff --git a/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.tsx b/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.tsx index 66d2348be..b8cf9bec3 100644 --- a/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.tsx +++ b/src/components/image-editor/ImageCanvasPublicationMaterialsDemoPanelView.tsx @@ -47,6 +47,7 @@ type ImageCanvasPublicationMaterialsDemoPanelViewProps = { ) => CSSProperties; onRequestUpload: (target: UploadTarget) => void; onRememberImageModel: (model: string) => void; + hasPendingImageReferenceUploads?: boolean; onSubmit: (dialog: GenerateDialogState) => void; }; @@ -116,6 +117,7 @@ export function ImageCanvasPublicationMaterialsDemoPanelView({ buildPortalMenuStyle, onRequestUpload, onRememberImageModel, + hasPendingImageReferenceUploads = false, onSubmit, }: ImageCanvasPublicationMaterialsDemoPanelViewProps) { const references = dialog.publicationReferences ?? []; @@ -302,6 +304,7 @@ export function ImageCanvasPublicationMaterialsDemoPanelView({ setGenerateDialog={setGenerateDialog} includeDimensions={false} onRememberImageModel={onRememberImageModel} + hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} lockedModel={IMAGE_MODEL_GPT_IMAGE_2} optionLabelPrefix="宣发素材" cost={calculateEditorImageModelPrice( diff --git a/src/components/image-editor/ImageCanvasQuickEditPanelView.tsx b/src/components/image-editor/ImageCanvasQuickEditPanelView.tsx index 2b200d0c9..a09b8937a 100644 --- a/src/components/image-editor/ImageCanvasQuickEditPanelView.tsx +++ b/src/components/image-editor/ImageCanvasQuickEditPanelView.tsx @@ -13,7 +13,10 @@ import type { QuickEditPanelState, UploadTarget, } from './ImageCanvasEditorTypes'; -import { QUICK_EDIT_REFERENCE_LIMIT } from './ImageCanvasGenerationModel'; +import { + QUICK_EDIT_REFERENCE_LIMIT, + resolveExtraImageReferenceLimit, +} from './ImageCanvasGenerationModel'; export type ImageCanvasQuickEditPanelViewProps = { panel: QuickEditPanelState; @@ -31,6 +34,7 @@ export type ImageCanvasQuickEditPanelViewProps = { ) => CSSProperties; onRequestUpload?: (target: UploadTarget) => void; onRememberImageModel?: (model: string) => void; + hasPendingImageReferenceUploads?: boolean; onSubmit: () => void; }; @@ -100,13 +104,20 @@ export function ImageCanvasQuickEditPanelView({ buildPortalMenuStyle = () => ({}), onRequestUpload, onRememberImageModel = () => {}, + hasPendingImageReferenceUploads = false, onSubmit, }: ImageCanvasQuickEditPanelViewProps) { const isRedraw = panel.mode === 'redraw'; const isImageQuickEdit = !isRedraw && sourceLayer.mediaType !== 'video'; const referenceCount = panel.quickEditReferences?.length ?? 0; const canAddReferences = - !isRedraw && referenceCount < QUICK_EDIT_REFERENCE_LIMIT; + !isRedraw && + referenceCount < + resolveExtraImageReferenceLimit( + panel.model, + QUICK_EDIT_REFERENCE_LIMIT, + 1, + ); const quickEditDialog = createQuickEditDialog(panel); return ( @@ -141,6 +152,7 @@ export function ImageCanvasQuickEditPanelView({ } onToggleReferenceMenu={() => setIsReferenceMenuOpen?.((open) => !open)} onRememberImageModel={onRememberImageModel} + hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} onSubmit={onSubmit} dialogLabel={isRedraw ? '重绘图片' : '快速编辑图片'} includeDimensions={isRedraw || isImageQuickEdit} diff --git a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx index ea1f2d18e..41303512b 100644 --- a/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx +++ b/src/components/image-editor/ImageCanvasSpecGenerationPanelView.tsx @@ -58,6 +58,7 @@ type ImageCanvasSpecGenerationPanelViewProps = { onUpdateSpecFormValue: (key: keyof SpecFormValues, value: string) => void; onRequestUpload: (target: UploadTarget) => void; onRememberImageModel?: (model: string) => void; + hasPendingImageReferenceUploads?: boolean; onSubmit: (dialog: GenerateDialogState) => void; }; @@ -79,6 +80,7 @@ export function ImageCanvasSpecGenerationPanelView({ onUpdateSpecFormValue, onRequestUpload, onRememberImageModel = () => {}, + hasPendingImageReferenceUploads = false, onSubmit, }: ImageCanvasSpecGenerationPanelViewProps) { const isUiDesignDialog = dialog.mode === 'ui-design'; @@ -428,6 +430,7 @@ export function ImageCanvasSpecGenerationPanelView({ setGenerateDialog={setGenerateDialog ?? (() => undefined)} includeDimensions onRememberImageModel={onRememberImageModel} + hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} lockedModel={IMAGE_MODEL_GPT_IMAGE_2} cost={calculateEditorUiDesignPrice( IMAGE_MODEL_GPT_IMAGE_2, @@ -473,7 +476,7 @@ export function ImageCanvasSpecGenerationPanelView({ size="xs" shape="pill" className="image-canvas-editor__generation-submit image-canvas-editor__spec-submit" - disabled={isGenerating} + disabled={isGenerating || hasPendingImageReferenceUploads} aria-label="提交生成规范" > {isGenerating ? ( diff --git a/src/components/image-editor/ImageCanvasStageView.tsx b/src/components/image-editor/ImageCanvasStageView.tsx index 035baf71d..eac4df1a2 100644 --- a/src/components/image-editor/ImageCanvasStageView.tsx +++ b/src/components/image-editor/ImageCanvasStageView.tsx @@ -76,6 +76,7 @@ export type ImageCanvasStageViewProps = { uiAssetExtractionSourceLayer: CanvasLayer | null; quickEditSelectionState: UiAssetExtractionState | null; quickEditSelectionSourceLayer: CanvasLayer | null; + hasPendingImageReferenceUploads?: boolean; generationComposerStyle: CSSProperties | null; selectedToolbarStyle: CSSProperties | null; splittingIconSpritesheetLayerIds?: ReadonlySet; @@ -236,6 +237,7 @@ export function ImageCanvasStageView({ uiAssetExtractionSourceLayer, quickEditSelectionState, quickEditSelectionSourceLayer, + hasPendingImageReferenceUploads = false, generationComposerStyle, selectedToolbarStyle, splittingIconSpritesheetLayerIds = EMPTY_LAYER_ID_SET, @@ -445,6 +447,7 @@ export function ImageCanvasStageView({ onRequestUpload={onRequestUiAssetExtractionReferenceUpload} onRemoveReference={onRemoveUiAssetExtractionReference} onSubmit={onSubmitUiAssetExtraction} + hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} /> { expect(onModelChange).toHaveBeenCalledWith(IMAGE_MODEL_GPT_IMAGE_2); }); + it('disables extraction submission while reference uploads are pending', () => { + render( + , + ); + + expect( + (screen.getByRole('button', { name: '提取' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + }); + + it('keeps the current UI extraction model and references when the target model is too small', () => { + const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {}); + const onModelChange = vi.fn(); + + render( + ({ + id: `reference-${index + 1}`, + label: `参考图${index + 1}`, + src: `/reference-${index + 1}.png`, + })), + })} + onToolChange={vi.fn()} + onPointerStart={vi.fn()} + onPointerMove={vi.fn()} + onPointerEnd={vi.fn()} + onModelChange={onModelChange} + onSubmit={vi.fn()} + />, + ); + + fireEvent.click( + screen.getByRole('button', { name: '提取素材模型 nanobanana2' }), + ); + fireEvent.click( + within(screen.getByRole('menu', { name: '提取素材模型选项' })).getByRole( + 'button', + { name: 'gpt-image-2' }, + ), + ); + + expect(alertMock).toHaveBeenCalledWith( + '当前已有 5 张参考图,gpt-image-2 最多允许 4 张,请先删除多余参考图后再切换', + ); + expect(onModelChange).not.toHaveBeenCalled(); + expect(screen.getByRole('menu', { name: '提取素材模型选项' })).toBeTruthy(); + }); + it('crops mark previews from the same source coordinates as the red selection', () => { render( void; onRemoveReference?: (referenceId: string) => void; onSubmit?: () => void; + hasPendingImageReferenceUploads?: boolean; }; const TOOL_OPTIONS: Array<{ @@ -275,6 +278,7 @@ export function ImageCanvasUiAssetExtractionOverlayView({ onRequestUpload, onRemoveReference, onSubmit, + hasPendingImageReferenceUploads = false, }: ImageCanvasUiAssetExtractionOverlayViewProps) { const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); if (!sourceLayer || !state) { @@ -282,7 +286,10 @@ export function ImageCanvasUiAssetExtractionOverlayView({ } const isQuickEdit = variant === 'quick-edit'; const marks = [...state.marks, ...(state.draftMark ? [state.draftMark] : [])]; - const canSubmit = state.marks.length > 0 && state.status !== 'extracting'; + const canSubmit = + state.marks.length > 0 && + state.status !== 'extracting' && + !hasPendingImageReferenceUploads; const extractionPlan = resolveUiAssetExtractionGenerationPlan( state.marks.length, ); @@ -487,6 +494,18 @@ export function ImageCanvasUiAssetExtractionOverlayView({ className="image-canvas-editor__option-popover-choice image-canvas-editor__option-popover-choice--model" aria-pressed={selected} onClick={() => { + const referenceLimit = + resolveExtraImageReferenceLimit( + option.value, + UI_EXTRA_REFERENCE_LIMIT, + 1, + ); + if (state.references.length > referenceLimit) { + window.alert( + `当前已有 ${state.references.length} 张参考图,${option.label} 最多允许 ${referenceLimit} 张,请先删除多余参考图后再切换`, + ); + return; + } onModelChange(option.value); setIsModelMenuOpen(false); }} diff --git a/src/components/image-editor/ImageCanvasUploadModel.ts b/src/components/image-editor/ImageCanvasUploadModel.ts index fed0c5e1d..c4d1a2f62 100644 --- a/src/components/image-editor/ImageCanvasUploadModel.ts +++ b/src/components/image-editor/ImageCanvasUploadModel.ts @@ -10,7 +10,12 @@ import type { GenerateDialogState, QuickEditPanelState, } from './ImageCanvasEditorTypes'; -import { appendLimitedQuickEditReferences } from './ImageCanvasGenerationModel'; +import { + appendLimitedImageReferences, + QUICK_EDIT_REFERENCE_LIMIT, + resolveDialogExtraImageReferenceLimit, + resolveExtraImageReferenceLimit, +} from './ImageCanvasGenerationModel'; type CanvasSize = { width: number; height: number }; type CanvasPoint = { x: number; y: number }; @@ -173,6 +178,7 @@ export function applyGenerationReferenceUpload({ ? { ...setFailedGenerationIdle(dialog), characterSpecReference: firstReference, + characterReferences: dialog.characterReferences, } : dialog; } @@ -181,6 +187,11 @@ export function applyGenerationReferenceUpload({ ? { ...setFailedGenerationIdle(dialog), iconSpecReference: firstReference, + generationReferences: appendLimitedImageReferences( + [], + dialog.generationReferences ?? [], + resolveDialogExtraImageReferenceLimit(dialog), + ), } : dialog; } @@ -189,6 +200,7 @@ export function applyGenerationReferenceUpload({ ? { ...setFailedGenerationIdle(dialog), uiDesignSpecReference: firstReference, + generationReferences: dialog.generationReferences, } : dialog; } @@ -196,10 +208,11 @@ export function applyGenerationReferenceUpload({ return dialog?.mode === 'publication' ? { ...setFailedGenerationIdle(dialog), - publicationReferences: [ - ...(dialog.publicationReferences ?? []), - ...references, - ], + publicationReferences: appendLimitedImageReferences( + dialog.publicationReferences, + references, + resolveDialogExtraImageReferenceLimit(dialog), + ), composerOpen: true, } : dialog; @@ -212,10 +225,18 @@ export function applyGenerationReferenceUpload({ dialog?.mode === 'ui-design' ? { ...setFailedGenerationIdle(dialog), - generationReferences: [ - ...(dialog.generationReferences ?? []), - ...references, - ], + generationReferences: + dialog.mode === 'video' + ? appendLimitedVideoReferences( + dialog.generationReferences ?? [], + references, + 'image', + ) + : appendLimitedImageReferences( + dialog.generationReferences, + references, + resolveDialogExtraImageReferenceLimit(dialog), + ), } : dialog; } @@ -245,10 +266,11 @@ export function applyGenerationReferenceUpload({ return dialog?.mode === 'character' ? { ...setFailedGenerationIdle(dialog), - characterReferences: [ - ...(dialog.characterReferences ?? []), - ...references, - ], + characterReferences: appendLimitedImageReferences( + dialog.characterReferences, + references, + resolveDialogExtraImageReferenceLimit(dialog), + ), } : dialog; } @@ -267,9 +289,14 @@ export function applyQuickEditReferenceUpload({ ...panel, status: panel.status === 'failed' ? 'idle' : panel.status, errorMessage: panel.status === 'failed' ? undefined : panel.errorMessage, - quickEditReferences: appendLimitedQuickEditReferences( + quickEditReferences: appendLimitedImageReferences( panel.quickEditReferences, references, + resolveExtraImageReferenceLimit( + panel.model, + QUICK_EDIT_REFERENCE_LIMIT, + 1, + ), ), }; } diff --git a/src/components/image-editor/useCanvasGenerationDialogs.test.tsx b/src/components/image-editor/useCanvasGenerationDialogs.test.tsx index eeb2d117f..b18f151d9 100644 --- a/src/components/image-editor/useCanvasGenerationDialogs.test.tsx +++ b/src/components/image-editor/useCanvasGenerationDialogs.test.tsx @@ -72,6 +72,78 @@ function durablePerfectPixelDialog( } describe('useCanvasGenerationDialogs', () => { + it('keeps the active dialog context stable while reference uploads are pending', () => { + let locked = false; + const onContextMutationRejected = vi.fn(); + const { result } = renderHook(() => + useCanvasGenerationDialogs({ + isContextMutationLocked: () => locked, + onContextMutationRejected, + }), + ); + + act(() => { + result.current.openCanvasGenerationDialog( + createDialog('generate', 'first'), + ); + }); + const activeId = result.current.activeCanvasGenerationDialog?.id; + expect(activeId).toBeTruthy(); + + locked = true; + act(() => { + result.current.openCanvasGenerationDialog( + createDialog('character', 'second'), + ); + result.current.setGenerateDialog(null); + }); + + expect(result.current.activeCanvasGenerationDialog).toEqual( + expect.objectContaining({ id: activeId, prompt: 'first' }), + ); + expect(result.current.inactiveGenerateDialogs).toEqual([]); + expect(onContextMutationRejected).toHaveBeenCalledTimes(2); + + act(() => { + result.current.setGenerateDialog((currentDialog) => + currentDialog ? { ...currentDialog, prompt: 'updated' } : currentDialog, + ); + }); + expect(result.current.activeCanvasGenerationDialog?.prompt).toBe('updated'); + }); + + it('rejects switching between same-mode non-canvas dialogs while uploads are pending', () => { + let locked = false; + const onContextMutationRejected = vi.fn(); + const { result } = renderHook(() => + useCanvasGenerationDialogs({ + isContextMutationLocked: () => locked, + onContextMutationRejected, + }), + ); + + act(() => { + result.current.setGenerateDialog({ + mode: 'edit', + sourceLayerId: 'layer-a', + prompt: '', + status: 'idle', + }); + }); + locked = true; + act(() => { + result.current.setGenerateDialog({ + mode: 'edit', + sourceLayerId: 'layer-b', + prompt: '', + status: 'idle', + }); + }); + + expect(result.current.generateDialog?.sourceLayerId).toBe('layer-a'); + expect(onContextMutationRejected).toHaveBeenCalledTimes(1); + }); + it('archives, activates, updates, and removes canvas generation dialogs', () => { const onActivate = vi.fn(); const { result } = renderHook(() => diff --git a/src/components/image-editor/useCanvasGenerationDialogs.ts b/src/components/image-editor/useCanvasGenerationDialogs.ts index bbcdd69cb..485c39f28 100644 --- a/src/components/image-editor/useCanvasGenerationDialogs.ts +++ b/src/components/image-editor/useCanvasGenerationDialogs.ts @@ -75,10 +75,23 @@ function withGenerationTimestamps( }; } +function generationDialogContextId(dialog: GenerateDialogState | null) { + if (!dialog) { + return null; + } + return isCanvasGenerationDialog(dialog) + ? dialog.id + : `${dialog.mode}:${dialog.sourceLayerId ?? 'draft'}`; +} + export function useCanvasGenerationDialogs({ onActivate, + isContextMutationLocked, + onContextMutationRejected, }: { onActivate?: () => void; + isContextMutationLocked?: () => boolean; + onContextMutationRejected?: () => void; } = {}) { const generationDialogCounterRef = useRef(0); const generateDialogRef = useRef(null); @@ -96,21 +109,37 @@ export function useCanvasGenerationDialogs({ ? generateDialog : null; + const rejectContextMutation = useCallback(() => { + if (!isContextMutationLocked?.()) { + return false; + } + onContextMutationRejected?.(); + return true; + }, [isContextMutationLocked, onContextMutationRejected]); + const setGenerateDialog = useCallback< Dispatch> - >((nextDialogOrUpdater) => { - const currentDialog = generateDialogRef.current; - const nextDialog = - typeof nextDialogOrUpdater === 'function' - ? nextDialogOrUpdater(currentDialog) - : nextDialogOrUpdater; - const nextDialogWithTimestamps = withGenerationTimestamps( - nextDialog, - currentDialog, - ); - generateDialogRef.current = nextDialogWithTimestamps; - setGenerateDialogState(nextDialogWithTimestamps); - }, []); + >( + (nextDialogOrUpdater) => { + const currentDialog = generateDialogRef.current; + const nextDialog = + typeof nextDialogOrUpdater === 'function' + ? nextDialogOrUpdater(currentDialog) + : nextDialogOrUpdater; + const nextDialogWithTimestamps = withGenerationTimestamps( + nextDialog, + currentDialog, + ); + const currentContextId = generationDialogContextId(currentDialog); + const nextContextId = generationDialogContextId(nextDialogWithTimestamps); + if (currentContextId !== nextContextId && rejectContextMutation()) { + return; + } + generateDialogRef.current = nextDialogWithTimestamps; + setGenerateDialogState(nextDialogWithTimestamps); + }, + [rejectContextMutation], + ); const canvasGenerationDialogs = useMemo( () => activeCanvasGenerationDialog @@ -137,6 +166,9 @@ export function useCanvasGenerationDialogs({ if (!isCanvasGenerationDialog(currentDialog)) { return; } + if (rejectContextMutation()) { + return; + } const nextInactiveDialogs = inactiveGenerateDialogsRef.current.some( (dialog) => dialog.id === currentDialog.id, ) @@ -150,11 +182,14 @@ export function useCanvasGenerationDialogs({ ]; inactiveGenerateDialogsRef.current = nextInactiveDialogs; setInactiveGenerateDialogs(nextInactiveDialogs); - }, []); + }, [rejectContextMutation]); const openCanvasGenerationDialog = useCallback( (dialog: CanvasGenerationDialogDraft) => { const currentDialog = generateDialogRef.current; + if (rejectContextMutation()) { + return isCanvasGenerationDialog(currentDialog) ? currentDialog.id : ''; + } if (isCanvasGenerationDialog(currentDialog)) { inactiveGenerateDialogsRef.current = inactiveGenerateDialogsRef.current.some( @@ -193,12 +228,21 @@ export function useCanvasGenerationDialogs({ archiveActiveCanvasGenerationDialog, createGenerationDialogId, getCanvasGenerationDialogsSnapshot, + rejectContextMutation, ], ); const updateCanvasGenerationDialogById = useCallback( (dialogId: string, updater: CanvasGenerationDialogUpdater) => { const currentDialogRef = generateDialogRef.current; + if ( + isCanvasGenerationDialog(currentDialogRef) && + currentDialogRef.id === dialogId && + updater(currentDialogRef) === null && + rejectContextMutation() + ) { + return; + } if ( isCanvasGenerationDialog(currentDialogRef) && currentDialogRef.id === dialogId @@ -235,7 +279,7 @@ export function useCanvasGenerationDialogs({ }), ); }, - [], + [rejectContextMutation], ); // 中文注释:低层删除不再对未收口的完美像素 operation 抗命。删除占位不撤销任何在途请求 @@ -260,6 +304,13 @@ export function useCanvasGenerationDialogs({ const activateCanvasGenerationDialog = useCallback( (targetDialog: CanvasGenerationDialogState) => { const currentDialog = generateDialogRef.current; + if ( + (!isCanvasGenerationDialog(currentDialog) || + currentDialog.id !== targetDialog.id) && + rejectContextMutation() + ) { + return; + } const nextInactiveDialogs = inactiveGenerateDialogsRef.current.filter( (dialog) => dialog.id !== targetDialog.id, ); @@ -282,7 +333,7 @@ export function useCanvasGenerationDialogs({ setGenerateDialogState(nextActiveDialog); onActivate?.(); }, - [onActivate], + [onActivate, rejectContextMutation], ); const restoreCanvasGenerationDialogs = useCallback( @@ -328,6 +379,13 @@ export function useCanvasGenerationDialogs({ dialog.sourceLayerId !== targetLayerId && dialog.generatedLayerId !== targetLayerId; const currentDialog = generateDialogRef.current; + if ( + isCanvasGenerationDialog(currentDialog) && + !keepDialog(currentDialog) && + rejectContextMutation() + ) { + return; + } const nextActiveDialog = isCanvasGenerationDialog(currentDialog) && !keepDialog(currentDialog) ? null @@ -339,7 +397,7 @@ export function useCanvasGenerationDialogs({ setGenerateDialogState(nextActiveDialog); setInactiveGenerateDialogs(nextInactiveDialogs); }, - [], + [rejectContextMutation], ); const getGeneratingDialogPlaceholder = useCallback( diff --git a/src/components/image-editor/useImageCanvasAssetCanvasBridge.test.tsx b/src/components/image-editor/useImageCanvasAssetCanvasBridge.test.tsx index b1d3465cf..3c036c048 100644 --- a/src/components/image-editor/useImageCanvasAssetCanvasBridge.test.tsx +++ b/src/components/image-editor/useImageCanvasAssetCanvasBridge.test.tsx @@ -76,7 +76,10 @@ function AssetCanvasBridgeHarness({ selectSingleLayer = vi.fn(), }: { asset?: EditorAsset; - resolveCanvasPoint?: (clientX: number, clientY: number) => { + resolveCanvasPoint?: ( + clientX: number, + clientY: number, + ) => { x: number; y: number; } | null; @@ -97,7 +100,9 @@ function AssetCanvasBridgeHarness({ const suppressAssetClickRef = useRef(false); const layerCounterRef = useRef(0); const [activeUploadFolderId, setActiveUploadFolderId] = useState('project'); - const [hoveredLayerId, setHoveredLayerId] = useState('hovered'); + const [hoveredLayerId, setHoveredLayerId] = useState( + 'hovered', + ); const [assetPointerDrag, setAssetPointerDrag] = useState(assetPointerDragRef.current); const [uploadDropTarget, setUploadDropTarget] = useState< @@ -132,10 +137,7 @@ function AssetCanvasBridgeHarness({ return (
- {activeUploadFolderId} @@ -158,17 +160,21 @@ function AssetCleanupHarness({ }), ], onDeleteLayerSideEffects = vi.fn(), + canDeleteLayers, discardHistoryEntriesContainingLayer = vi.fn(), }: { deletedAssets?: EditorAsset[]; initialLayers?: CanvasLayer[]; onDeleteLayerSideEffects?: (layerId: string) => void; + canDeleteLayers?: (targetLayerIds: string[]) => boolean; discardHistoryEntriesContainingLayer?: ( matchesLayer: (layer: CanvasLayer) => boolean, ) => void; }) { const [layers, setLayers] = useState(initialLayers); - const [selectedLayerId, setSelectedLayerId] = useState('linked'); + const [selectedLayerId, setSelectedLayerId] = useState( + 'linked', + ); const [selectedLayerIds, setSelectedLayerIds] = useState(['linked', 'kept']); const cleanup = useImageCanvasAssetLayerCleanup({ layers, @@ -176,6 +182,7 @@ function AssetCleanupHarness({ setSelectedLayerId, setSelectedLayerIds, onDeleteLayerSideEffects, + canDeleteLayers, discardHistoryEntriesContainingLayer, }); @@ -184,7 +191,9 @@ function AssetCleanupHarness({ - {layers.map((layer) => layer.id).join(',')} + + {layers.map((layer) => layer.id).join(',')} + {selectedLayerId ?? '-'} {selectedLayerIds.join(',')}
@@ -292,6 +301,32 @@ describe('useImageCanvasAssetCanvasBridge', () => { ).toBe(false); }); + it('keeps linked layers and history when asset cleanup is blocked', () => { + const onDeleteLayerSideEffects = vi.fn(); + const discardHistoryEntriesContainingLayer = vi.fn(); + const canDeleteLayers = vi.fn().mockReturnValue(false); + render( + , + ); + + act(() => { + screen.getByRole('button', { name: '清理素材' }).click(); + }); + + expect(canDeleteLayers).toHaveBeenCalledWith(['linked']); + expect(screen.getByTestId('layers').textContent).toBe('linked,kept'); + expect(screen.getByTestId('selected').textContent).toBe('linked'); + expect(onDeleteLayerSideEffects).not.toHaveBeenCalled(); + expect(discardHistoryEntriesContainingLayer).not.toHaveBeenCalled(); + }); + it('invalidates matching history even when no linked layer is currently mounted', () => { const discardHistoryEntriesContainingLayer = vi.fn(); render( diff --git a/src/components/image-editor/useImageCanvasAssetCanvasBridge.ts b/src/components/image-editor/useImageCanvasAssetCanvasBridge.ts index 345a2dde9..4b361aa12 100644 --- a/src/components/image-editor/useImageCanvasAssetCanvasBridge.ts +++ b/src/components/image-editor/useImageCanvasAssetCanvasBridge.ts @@ -36,6 +36,7 @@ type UseImageCanvasAssetLayerCleanupOptions = { setSelectedLayerId: Dispatch>; setSelectedLayerIds: Dispatch>; onDeleteLayerSideEffects?: (layerId: string) => void; + canDeleteLayers?: (targetLayerIds: string[]) => boolean; discardHistoryEntriesContainingLayer?: ( matchesLayer: (layer: CanvasLayer) => boolean, ) => void; @@ -73,21 +74,28 @@ export function useImageCanvasAssetLayerCleanup({ setSelectedLayerId, setSelectedLayerIds, onDeleteLayerSideEffects, + canDeleteLayers, discardHistoryEntriesContainingLayer, }: UseImageCanvasAssetLayerCleanupOptions) { return useCallback( (deletedAssets: EditorAsset[]) => { if (!deletedAssets.length) { - return; + return true; } - discardHistoryEntriesContainingLayer?.((layer) => - deletedAssets.some((asset) => isLayerLinkedToAsset(layer, asset)), - ); const deletedLayerIds = layers .filter((layer) => deletedAssets.some((asset) => isLayerLinkedToAsset(layer, asset)), ) .map((layer) => layer.id); + if ( + deletedLayerIds.length > 0 && + canDeleteLayers?.(deletedLayerIds) === false + ) { + return false; + } + discardHistoryEntriesContainingLayer?.((layer) => + deletedAssets.some((asset) => isLayerLinkedToAsset(layer, asset)), + ); setLayers((currentLayers) => currentLayers.filter( (layer) => @@ -111,16 +119,18 @@ export function useImageCanvasAssetLayerCleanup({ } const currentLayer = layers.find((layer) => layer.id === currentId); return currentLayer && - deletedAssets.some((asset) => isLayerLinkedToAsset(currentLayer, asset)) + deletedAssets.some((asset) => + isLayerLinkedToAsset(currentLayer, asset), + ) ? null : currentId; }); - deletedLayerIds.forEach((layerId) => - onDeleteLayerSideEffects?.(layerId), - ); + deletedLayerIds.forEach((layerId) => onDeleteLayerSideEffects?.(layerId)); + return true; }, [ discardHistoryEntriesContainingLayer, + canDeleteLayers, layers, onDeleteLayerSideEffects, setLayers, diff --git a/src/components/image-editor/useImageCanvasAssetLibrary.test.tsx b/src/components/image-editor/useImageCanvasAssetLibrary.test.tsx index b820c6efc..f71250921 100644 --- a/src/components/image-editor/useImageCanvasAssetLibrary.test.tsx +++ b/src/components/image-editor/useImageCanvasAssetLibrary.test.tsx @@ -36,7 +36,9 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => { }; }); -function createUploadedAsset(overrides: Partial = {}): EditorAsset { +function createUploadedAsset( + overrides: Partial = {}, +): EditorAsset { return { id: 'asset-a', label: '素材A', @@ -73,7 +75,7 @@ function AssetLibraryHarness({ }: { canAccessProtectedData?: boolean; openEditorLoginModal?: (postLoginAction?: (() => void) | null) => void; - onDeleteAssets?: (assets: EditorAsset[]) => void; + onDeleteAssets?: (assets: EditorAsset[]) => boolean | void; }) { const assetListRef = useRef(null); const assetLibrary = useImageCanvasAssetLibrary({ @@ -292,9 +294,7 @@ describe('useImageCanvasAssetLibrary', () => { }), ); - render( - , - ); + render(); await waitFor(() => { expect(openEditorLoginModal).toHaveBeenCalledTimes(1); @@ -330,9 +330,7 @@ describe('useImageCanvasAssetLibrary', () => { }); act(() => screen.getByRole('button', { name: 'commit folder' }).click()); await waitFor(() => { - expect(screen.getByTestId('folders').textContent).toContain( - 'folder-', - ); + expect(screen.getByTestId('folders').textContent).toContain('folder-'); }); act(() => { resolveCreateFolder({ @@ -402,6 +400,20 @@ describe('useImageCanvasAssetLibrary', () => { expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-a'); }); + it('keeps uploaded assets when canvas cleanup vetoes deletion', async () => { + const onDeleteAssets = vi.fn().mockReturnValue(false); + render(); + + await screen.findByText('素材A'); + act(() => screen.getByRole('button', { name: 'delete asset' }).click()); + + expect(screen.getByTestId('assets').textContent).toContain('asset-a:素材A'); + expect(onDeleteAssets).toHaveBeenCalledWith([ + expect.objectContaining({ id: 'asset-a' }), + ]); + expect(deleteEditorAssetMock).not.toHaveBeenCalled(); + }); + it('selects and deletes selected uploaded assets', async () => { const onDeleteAssets = vi.fn(); loadEditorAssetLibraryMock.mockResolvedValueOnce({ diff --git a/src/components/image-editor/useImageCanvasAssetLibrary.ts b/src/components/image-editor/useImageCanvasAssetLibrary.ts index f9a5cc191..56212a586 100644 --- a/src/components/image-editor/useImageCanvasAssetLibrary.ts +++ b/src/components/image-editor/useImageCanvasAssetLibrary.ts @@ -120,7 +120,7 @@ export function useImageCanvasAssetLibrary({ assetListRef: RefObject; canAccessProtectedData: boolean; openEditorLoginModal: (postLoginAction?: (() => void) | null) => void; - onDeleteAssets?: (assets: EditorAsset[]) => void; + onDeleteAssets?: (assets: EditorAsset[]) => boolean | void; }) { const [assetFolders, setAssetFolders] = useState(EDITOR_ASSET_FOLDERS); @@ -353,8 +353,10 @@ export function useImageCanvasAssetLibrary({ if (asset.sourceKind !== 'uploaded') { return; } + if (onDeleteAssets?.([asset]) === false) { + return; + } setAssets((currentAssets) => removeAssetById(currentAssets, asset.id)); - onDeleteAssets?.([asset]); setRenamingAsset((currentRename) => currentRename?.assetId === asset.id ? null : currentRename, ); @@ -506,10 +508,12 @@ export function useImageCanvasAssetLibrary({ const ids = [...selectedAssetIds]; const deletedAssets = removeSelectedAssets(assets, selectedAssetIds) .deletedAssets; + if (onDeleteAssets?.(deletedAssets) === false) { + return; + } setAssets( (currentAssets) => removeSelectedAssets(currentAssets, selectedAssetIds).assets, ); - onDeleteAssets?.(deletedAssets); setSelectedAssetIds(new Set()); ids.forEach((assetId) => { void deleteEditorAsset(assetId); diff --git a/src/components/image-editor/useImageCanvasGenerationSurface.tsx b/src/components/image-editor/useImageCanvasGenerationSurface.tsx index eadf6cb82..a19a9aba3 100644 --- a/src/components/image-editor/useImageCanvasGenerationSurface.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSurface.tsx @@ -67,6 +67,7 @@ type ImageCanvasGenerationSurfaceOptions = { iconSpecButtonRef: RefObject; generationReferenceButtonRef: RefObject; publicationReferenceButtonRef: RefObject; + hasPendingImageReferenceUploads?: boolean; generateDialog: GenerateDialogState | null; setGenerateDialog: Dispatch>; activeCanvasGenerationDialog: CanvasGenerationDialogState | null; @@ -166,6 +167,7 @@ export function useImageCanvasGenerationSurface({ iconSpecButtonRef, generationReferenceButtonRef, publicationReferenceButtonRef, + hasPendingImageReferenceUploads = false, generateDialog, setGenerateDialog, activeCanvasGenerationDialog, @@ -211,6 +213,7 @@ export function useImageCanvasGenerationSurface({ layerCounterRef, generateDialog, setGenerateDialog, + hasPendingImageReferenceUploads, openCanvasGenerationDialog, activateCanvasGenerationDialog, updateCanvasGenerationDialogById, @@ -427,6 +430,7 @@ export function useImageCanvasGenerationSurface({ generationWorkflow.isPickingUiDesignSpecFromCanvas } generateDialog={generateDialog} + hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} generationComposerStyle={generationComposerStyle} iconComposerStyle={iconComposerStyle} quickEditPanel={generationWorkflow.quickEditPanel} diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index e24b6ab90..7d489de68 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -338,6 +338,7 @@ function GenerationWorkflowHarness({ initialViewport = { x: 10, y: 20, scale: 2 }, projectId, currentUserId, + hasPendingImageReferenceUploads = false, applyProjectSnapshot, applyProjectSnapshotWithoutHistory, flushProjectPersistence, @@ -350,6 +351,7 @@ function GenerationWorkflowHarness({ initialViewport?: { x: number; y: number; scale: number }; projectId?: string; currentUserId?: string; + hasPendingImageReferenceUploads?: boolean; applyProjectSnapshot?: Parameters< typeof useImageCanvasGenerationWorkflow >[0]['applyProjectSnapshot']; @@ -407,6 +409,7 @@ function GenerationWorkflowHarness({ layerCounterRef, generateDialog: dialogs.generateDialog, setGenerateDialog: dialogs.setGenerateDialog, + hasPendingImageReferenceUploads, openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog, activateCanvasGenerationDialog: dialogs.activateCanvasGenerationDialog, updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById, @@ -530,6 +533,9 @@ function GenerationWorkflowHarness({ {workflow.generationWarning ?? '-'} + + {workflow.isPickingGenerationReferenceFromCanvas ? 'picking' : 'idle'} + {workflow.quickEditPanel ? `${workflow.quickEditPanel.sourceLayerId}:${workflow.quickEditPanel.status}:${workflow.quickEditPanel.prompt || '-'}` @@ -976,6 +982,12 @@ function GenerationWorkflowHarness({ > 选择画布参考图 + + + +