From 7927309cefccc2ca7f8130b7d430c58cb456347d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Sat, 8 Aug 2026 15:39:49 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=BF=AB=E9=80=9F=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E6=A0=87=E7=AD=BE=E8=A6=86=E7=9B=96=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端按目标图层有效素材类型执行快速编辑门禁 补充 icon 默认资源覆盖 scene 和 spec 的前后端回归 同步图片画布与后端数据契约说明 --- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- .../crates/api-server/src/editor_project.rs | 27 ++++++++++++- .../ImageCanvasGenerationModel.test.ts | 1 + ...anvasGenerationSubmissionWorkflow.test.tsx | 40 +++++++++++++++++++ 5 files changed, 68 insertions(+), 4 deletions(-) diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index e02160321..d7d8e1078 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -19,7 +19,7 @@ - 鼠标中键拖拽始终平移画布;长按 Space 临时进入抓手模式,松开后恢复原工具。 - 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层、生成占位框或画板的边缘与中心线;当移动元素接近两个同轴元素形成的等距位置时,支持横向或纵向等距吸附。 - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词、重绘提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片,并提供“复制信息”复制当前可见字段。参考图输入快照只保存 `refType/refId` 行引用,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`;不得把图片 Data URL、普通 URL 或 `objectKey` 写入 `generationInputs.references`。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 -- 快速编辑使用统一正向白名单:只支持普通静态图片、角色图、规范图、完整图标图集、图标规范、UI 设计图、宣发图、游戏场景图和视频;单个拆分图标、角色动作 / 序列帧、音效与背景音乐不支持。浮动工具栏、图层右键菜单、独立图片菜单、打开面板入口和提交门禁必须复用同一白名单,新增媒体或素材类型默认不开放。图片编辑请求只提交必填 `sourceReferenceId`,取正式 `resourceId ?? sourceAssetId`;没有正式业务 ID 的本地图层、生成对话临时图层或未登记上传图必须先走既有资源登记,否则前端失败关闭。objectKey、URL、Data URL、Blob URL 不得作为主来源身份,旧 `sourceImageSrc/sourceResourceId/assetKind` 也不得发送。带红框的快速编辑仍以原图业务 ID 为主来源,上传后的编号标注图只放入辅助 `referenceImageSrcs`。`/api/editor/images/edits` 入队前通过当前账号窄查询解析来源对象与权威类型;带 `targetLayerId` 时必须同时带 `projectId`,目标图层须关联有效项目资源,双方对象和默认类型必须一致,最终有效类型取目标覆盖值或目标资源类型。视频快速编辑只能走视频生成接口,角色动作只能走对应动作生成链路。画布 Agent 的 `edit_image` 必须从 `ImageMetadata.reference_id` 获取项目资源 ID 或素材 ID,缺失时拒绝创建任务,不得用 `image_data_key` 或 objectKey 代替。 +- 快速编辑使用统一正向白名单:只支持普通静态图片、角色图、规范图、完整图标图集、图标规范、UI 设计图、宣发图、游戏场景图和视频;单个拆分图标、角色动作 / 序列帧、音效与背景音乐不支持。浮动工具栏、图层右键菜单、独立图片菜单、打开面板入口和提交门禁必须复用同一白名单,新增媒体或素材类型默认不开放。图片编辑请求只提交必填 `sourceReferenceId`,取正式 `resourceId ?? sourceAssetId`;没有正式业务 ID 的本地图层、生成对话临时图层或未登记上传图必须先走既有资源登记,否则前端失败关闭。objectKey、URL、Data URL、Blob URL 不得作为主来源身份,旧 `sourceImageSrc/sourceResourceId/assetKind` 也不得发送。带红框的快速编辑仍以原图业务 ID 为主来源,上传后的编号标注图只放入辅助 `referenceImageSrcs`。`/api/editor/images/edits` 入队前通过当前账号窄查询解析来源对象与权威类型;带 `targetLayerId` 时必须同时带 `projectId`,目标图层须关联有效项目资源,双方对象和默认类型必须一致,最终有效类型取目标覆盖值或目标资源类型。来源默认类型只用于绑定一致性,不得在应用同媒体族标签覆盖前提前拒绝;因此默认 `icon` 且无覆盖时仍禁止快速编辑,但同一图片图层覆盖为 `scene` 或 `spec` 后必须按覆盖类型放行。视频快速编辑只能走视频生成接口,角色动作只能走对应动作生成链路。画布 Agent 的 `edit_image` 必须从 `ImageMetadata.reference_id` 获取项目资源 ID 或素材 ID,缺失时拒绝创建任务,不得用 `image_data_key` 或 objectKey 代替。 - 对生成资源执行重绘时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图;重绘面板不因提交成功自动关闭,便于连续改提示词。重绘 / 改造输入框只允许从 `generationInputs.fields` 中恢复用户可见输入快照,例如普通生成提示词、视频描述、音效 `prompt`、背景音乐 `gpt_description_prompt`、角色设定、UI 用户输入、图标素材描述、规范表单和宣发素材字段;禁止回退展示资源 `prompt` / `actualPrompt` 中可能存在的后端拼接 Prompt、固定生成模板或模型默认提示词。当前 BGM 是字段语义上的例外:其新记录中的 `prompt` 与 `actualPrompt` 必须等于输入框已经写回的 canonical `gpt_description_prompt`,不得包含隐藏内容;但改造输入仍只从 `generationInputs.fields` 恢复,不能因此放宽为从资源审计字段回退。没有用户输入快照的旧图层打开改造时保持空输入,等待用户重新填写。 - 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑统一打开框选区域 + 单提示词 + 模型选择面板,默认沿用原图模型,不展示参考图或比例 / 尺寸控件;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型;两类面板不展示抠图背景色或抠图模型选择;前端用户路径固定提交 `screenColor=auto` 和 `segModel=birefnet`,由后端自动决策具体抠图背景色,`anime-seg` 作为内部保留能力不在用户界面暴露。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材三个工作流(游戏首图、详情五图、运营海报)固定使用 `gpt-image-2`,面板模型胶囊为禁用态,不提供 `nanobanana2` 入口;前端按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;后端收到 `kind: "publication-material"` 时也强制归一为 `gpt-image-2` 生成和计费,生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,重绘在前端优先复用当前图层 objectKey;尚未登记的本地图片先上传 OSS,再把 objectKey 交给同一图片生成 BFF,并在原图右侧生成一张新图;普通图层重绘作为 `quick-edit` 参考图提交,角色图层重绘必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;普通图片、角色、图标图集、UI 设计图及其重绘 / 改造入口必须在比例或清晰度恢复、切换时同步把占位框 `width/height/originalWidth/originalHeight` 更新为目标像素尺寸,生成中不得继续显示默认 1K 框;UI 素材提取的 1K / 2K 图集占位和旧图片修改入口也分别使用本次目标尺寸与源图真实尺寸。待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片解析为已上传的 objectKey 或资源 ID;浏览器临时图片需先上传 OSS;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作的 `生成动画` 仍固定使用 `seedance2.0-fast` 动作 / 视频模型,角色动作素材不提供 `快速编辑`,只能通过角色动作生成链路继续编辑。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 - 图片画布抠图统一通过唯一、只监听 loopback 的 `bgfilter-worker` 调用 BgFilter provider。手动去除背景面向用户任意图片,仍走登录态同源 BFF `POST /api/editor/images/background-removals` 和外部生成队列;API 在入队前拒绝 `data:` / `blob:` 内联媒体,父流程将稳定引用解析为当前账号已登记且归属已校验的私有 OSS object key,并在同一轮账号项目 / 素材快照读取中同时恢复用户可见源模型,禁止为 object key、所有权和源模型分别重复拉取全量快照;随后只通过一次内部 HTTP RPC 传递 object key、排队预算 `maxQueueWaitMs`、调用预算 `callBudgetMs` 和固定的 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传源图字节、签名 URL、`file` 或 `screen_color`。子 worker 在每次真实 provider attempt 前签发 600 秒 URL,承担默认 `Q=2048` admission 保险丝、provider 并发 `N=16`、严格最多两次顺序 attempt、响应字节与图片尺寸校验,并把成功图片作为内部 HTTP 二进制 body 直接返回;父流程同步等待该响应且不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。排队只消耗 `maxQueueWaitMs`,取得 provider permit 后才启动 `callBudgetMs`;attempt 按 `N × est × 2`、调用预算按 `2 × attempt + 1s` 派生,冻结 `est=5000ms` 时分别为 `160s / 321s`。complex 的真实 provider 失败会累计并打开自身熔断,但与 flat 状态隔离;complex 任意失败或熔断仍直接返回父流程失败,不接入阿里云 / 本地键色降级。provider 配置继续统一使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL` 和 `GENARRATIVE_EDITOR_BGFILTER_TOKEN`,父子共同使用 `GENARRATIVE_BGFILTER_WORKER_CONCURRENCY` 与 `GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS` 派生预算;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只作为 provider token 兼容别名;内部调用另使用 `GENARRATIVE_BGFILTER_WORKER_BASE_URL` 和独立内部 Token。所有令牌只在服务端注入,前端不持有令牌。成功字节返回父流程后,仍由父流程完成最终处理、OSS / asset object 持久化、结果图层与最新项目快照写回;接口只向前端返回 `queueState`,有项目上下文时前端同时创建去背景生成占位并把 `canvasCompletion` 交给后端。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 3d6e649a3..67565171e 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -243,7 +243,7 @@ npm run check:server-rs-ddd 6. 编辑器图片生成 / 图片修改 / 图标 spritesheet / UI 设计图提取素材 / 视频 / 角色动作 / 音效 / 背景音乐必须在后端计算模型价格后使用 `execute_billable_asset_operation_with_cost` 预扣泥点;预扣失败必须 fail-closed,不得继续提交 VectorEngine、Ark、Suno 或 Vidu 上游任务。进入预扣或外部生成队列前,还必须调用只读 `preflight_editor_generation_target_and_return`,按认证 owner 校验可选 `projectId`,并校验归一化后的可选 `assetFolderId`。请求 JSON、纯本地格式、引用数量及 `data:` / `blob:` 稳定媒体门禁必须先于该数据库预检返回 4xx;目标预检随后执行,并且仍必须早于定价读取、引用 owner 解析、generation input rebuild、入队、扣费、provider 请求或 OSS 写入,不得为调整错误优先级把任何远端读取或副作用搬到预检前。`project`、任意旧 `folder-*` 与 owner 默认目录 ID 都统一指向当前 owner 的默认素材目录;角色图片、角色动作、图标 spritesheet 与 UI 提取在请求省略目录时也必须按最终真实写入的默认目录预检。尚未创建的默认目录允许通过,自定义目录必须已存在且归属当前 owner。预检 helper 必须返回同一份 canonical `projectId + assetFolderId`,调用方在入队、worker/provider 执行和原子结果准备中都复用这份值;禁止校验 trim/默认映射后的值却继续序列化或持久化原始请求。worker / inline 执行在首个 provider 或 OSS 写副作用前再次执行同一预检,不能只信任入队时结果;任一读取不可达、超时、项目或目录不匹配都失败关闭。该预检不创建锁或 reservation,最终资源 / 素材 procedure 仍必须重新校验归属,以处理预检后并发删除或转移。 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 统一解析。图片修改主来源是明确例外:站内 `/api/editor/images/edits` 与 External v1 API 调用方只提交必填 `sourceReferenceId`,且只接受当前账号已登记的 `editor_project_resource.resource_id` 或 `editor_asset.asset_id`;objectKey、URL、Data URL、Blob URL、未登记 ID 以及旧 `sourceImageSrc/sourceResourceId/assetKind` 字段必须在入队前返回 `400`。api-server 必须通过共享 SpacetimeDB 窄查询分别按资源 ID、素材 ID 主键定点解析,双表同时命中、未命中、跨 owner、缺失或越权 asset object、禁止或未知类型均失败关闭;objectKey 与素材类型只能来自服务端解析结果。提供 `targetLayerId` 时必须同步读取指定 `projectId`,校验目标图层关联资源、双方权威对象和默认类型;双方都有 `assetObjectId` 时必须比较 ID,任一方缺失时才回退 canonical `(bucket, objectKey)`。图片修改入队载荷保存版本化权威来源快照,worker 调用 provider 前必须按同一 `sourceReferenceId` 再次定点解析并拒绝身份或类型漂移;历史载荷只把非空旧资源 ID 或旧来源字符串本身作为业务 ID 尝试迁移,禁止按 objectKey 反查或信任旧 `assetKind`。任务 `request_payload_json` / `result_payload_json` 任意层级都禁止 `data:` / `blob:`,并受统一字节上限保护。其它候选引用的 objectKey 最终必须归属于当前账号的 `editor_project_resource`、`editor_asset` 或 `asset_object`,由 worker 在解析后、签名读取 OSS 前完成归属校验。本地红框序号标注图必须先上传并确认对象,再作为图片修改的辅助 `referenceImageSrcs` 入队,主来源仍使用原图业务 ID;不得把既有 objectKey 下载成 Data URL 后写入任务。图标素材、图片快速编辑和 UI 素材提取的额外参考图必须真正传入 provider,不得只写入 `generationInputs` 展示快照。普通图片生成最多 5 张参考图;图片修改、图标素材和 UI 提取的额外参考图上限还必须与所选 provider 的总容量共同取最小值:GPT-image-2 总计 5 张,nanobanana2 总计 14 张。前端添加和提交、api-server 入队 / 扣费前以及 `platform-image` provider 边界都必须明确拒绝超限,禁止用 `.take(...)` 静默截断。同步且不持久化的历史兼容入口即使仍能解析 Data URL,也不能把该值转存到工程、素材、元数据、审计或任务表。 +9. 编辑器进入外部生成持久队列的图片生成、去背景、图标 spritesheet、UI 设计图提取、角色动作和视频参考图,调用方必须提交 `objectKey` / `resourceId` / `assetId` 候选稳定引用;BFF 只做内联媒体与 payload 门禁,登记状态和归属由 worker 统一解析。图片修改主来源是明确例外:站内 `/api/editor/images/edits` 与 External v1 API 调用方只提交必填 `sourceReferenceId`,且只接受当前账号已登记的 `editor_project_resource.resource_id` 或 `editor_asset.asset_id`;objectKey、URL、Data URL、Blob URL、未登记 ID 以及旧 `sourceImageSrc/sourceResourceId/assetKind` 字段必须在入队前返回 `400`。api-server 必须通过共享 SpacetimeDB 窄查询分别按资源 ID、素材 ID 主键定点解析,双表同时命中、未命中、跨 owner、缺失或越权 asset object、禁止或未知类型均失败关闭;objectKey 与素材类型只能来自服务端解析结果。提供 `targetLayerId` 时必须同步读取指定 `projectId`,校验目标图层关联资源、双方权威对象和默认类型;双方都有 `assetObjectId` 时必须比较 ID,任一方缺失时才回退 canonical `(bucket, objectKey)`。来源默认类型只参与同一权威对象的绑定一致性校验;快速编辑准入必须在解析目标图层后按 `assetKindOverride ?? resource.assetKind` 的有效类型判断,不能先按来源默认类型拒绝,因此默认 `icon` 资源覆盖为 `scene/spec` 时允许,未覆盖时仍拒绝。图片修改入队载荷保存版本化权威来源快照,worker 调用 provider 前必须按同一 `sourceReferenceId` 再次定点解析并拒绝身份或类型漂移;历史载荷只把非空旧资源 ID 或旧来源字符串本身作为业务 ID 尝试迁移,禁止按 objectKey 反查或信任旧 `assetKind`。任务 `request_payload_json` / `result_payload_json` 任意层级都禁止 `data:` / `blob:`,并受统一字节上限保护。其它候选引用的 objectKey 最终必须归属于当前账号的 `editor_project_resource`、`editor_asset` 或 `asset_object`,由 worker 在解析后、签名读取 OSS 前完成归属校验。本地红框序号标注图必须先上传并确认对象,再作为图片修改的辅助 `referenceImageSrcs` 入队,主来源仍使用原图业务 ID;不得把既有 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_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 71703311b..9b8f80162 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -4285,14 +4285,13 @@ async fn resolve_editor_image_edit_source( })?; let source_asset_kind = normalize_editor_image_edit_resolved_source_kind(source_asset_kind.as_deref()); - ensure_editor_image_edit_source_kind_allowed(source_asset_kind.as_deref(), Some("image"))?; - let Some(target_layer_id) = payload .target_layer_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) else { + ensure_editor_image_edit_source_kind_allowed(source_asset_kind.as_deref(), Some("image"))?; return Ok(EditorImageEditResolvedSource { reference_kind, source_reference_id: source_reference_id.to_string(), @@ -20468,6 +20467,30 @@ mod tests { "同一对象的来源与目标默认类型冲突时失败关闭" ); + for effective_asset_kind in ["scene", "spec"] { + let icon_override_target = EditorImageEditTargetLayerSource { + bucket: "bucket".to_string(), + object_key: "generated/icon.png".to_string(), + asset_object_id: Some("object-icon".to_string()), + resource_id: "resource-icon".to_string(), + default_asset_kind: Some("icon".to_string()), + effective_asset_kind: Some(effective_asset_kind.to_string()), + media_type: "image".to_string(), + }; + assert!( + ensure_editor_image_edit_target_matches_source( + "resource-icon", + "bucket", + "generated/icon.png", + Some("object-icon"), + Some("icon"), + &icon_override_target, + ) + .is_ok(), + "同媒体族覆盖 {effective_asset_kind} 必须在默认 icon 类型校验之后生效" + ); + } + let mut forbidden_override = target.clone(); forbidden_override.effective_asset_kind = Some("icon".to_string()); assert!( diff --git a/src/components/image-editor/ImageCanvasGenerationModel.test.ts b/src/components/image-editor/ImageCanvasGenerationModel.test.ts index 19cecc4fa..dfee9ccc5 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.test.ts @@ -439,6 +439,7 @@ describe('ImageCanvasGenerationModel', () => { isQuickEditSupportedLayer({ ...buildSourceLayer(), assetKind: 'scene', + resourceAssetKind: 'icon', }), ).toBe(true); expect( diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index ec9f72e1c..050341cef 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -943,6 +943,46 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { ); }); + it.each(['scene', 'spec'] as const)( + 'submits quick edits for icon resources overridden to %s', + async (assetKind) => { + editEditorImageMock.mockResolvedValueOnce( + createGenerated({ prompt: `修改${assetKind}` }), + ); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' })); + + await waitFor(() => { + expect(editEditorImageMock).toHaveBeenCalledWith( + expect.objectContaining({ + sourceReferenceId: 'resource-source', + targetLayerId: 'layer-source', + }), + ); + }); + }, + ); + it('rejects unregistered quick-edit sources before uploading marked references', async () => { render(