diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 075c2e2cf..7da61131c 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -20,6 +20,7 @@ - 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层、生成占位框或画板的边缘与中心线;当移动元素接近两个同轴元素形成的等距位置时,支持横向或纵向等距吸附。 - 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词、重绘提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片,并提供“复制信息”复制当前可见字段。参考图输入快照只保存 `refType/refId` 行引用,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`;不得把图片 Data URL、普通 URL 或 `objectKey` 写入 `generationInputs.references`。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 - 生成类产物的工具栏动作 `改造` 不在原产物上原地修改,而是把当次生成输入恢复到对应面板,允许编辑后生成新产物。V2 恢复严格按顶层 `action`、`fields[].id` 和 `references[].id/refType/refId` 定位,`title` / `label` 只是展示快照,不作为参数键。引用只在当前已 hydrate 的画布图层中按 `refType/refId` 匹配,媒体类型取匹配图层的运行时数据,不重复写入 `generationInputs.references`,也不新增 owner-only 工程资源 / 素材库查询。面板直接上传的参考素材不进入素材库、不是画布图层,因此改造时不恢复;原参考图层已移出画布时同样不恢复。可重新选择的引用缺失时打开面板、留空对应槽位并提示重新选择,提交门禁继续校验必填槽位;角色动作、生成型图片编辑和 UI 素材提取等必须依赖原 `source` 图层才能构造面板的动作,在 source 缺失时拒绝改造并提示原因。恢复顺序为:有效 V2;完整的历史生成对话框;按 `assetKind/mediaType`、历史中英文标题别名、资源模型 / 尺寸 / 时长 / `sourceResourceId` 的 legacy adapter,最后缺失值使用当前默认值并显示旧版恢复告警;一旦识别为有效 V2,即使引用缺失也不得降级到 legacy adapter。禁止从中文标题、产物尺寸或 prompt 反推 V2 参数;legacy fallback 除外,但不得回退展示 `actualPrompt` 中的后端拼接 Prompt。当前 BGM 是字段语义上的例外:其新记录中的 `prompt` 与 `actualPrompt` 必须等于输入框已经写回的 canonical `gpt_description_prompt`,不得包含隐藏内容;但改造输入仍只从 `generationInputs.fields` 恢复,不能因此放宽为从资源审计字段回退。 +- V2 资源 / 素材 hydrate 为前向兼容读取:顶层、`fields[]` 和 `references[]` 出现未来可选属性时只复制当前已知字段,不得让整份配方消失;完美像素操作快照等精确重放路径仍执行递归白名单,并保留历史空字符串的字节级兼容。对模型、比例、尺寸、时长、开关等具有明确运行默认值的 V2 参数,缺失与非法值统一补写默认值并告警;允许为空的提示词 / 表单自由文本不因缺失产生参数失效告警。 - 图片生成 / 修改统一经 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` 交给后端。 - 编辑器自己生成的标准纯色背景抠图资产在保存源图后统一以 `background_mode=flat` 调用内部 `bgfilter-worker`。角色形象生成、图标 spritesheet 生成、UI 设计图素材提取和角色动作的用户路径都固定使用 `screenColor=auto`,但用户可见 `generationInputs.fields` 不记录 `抠图背景色` 或 `抠图模型`;api-server 在组装 prompt 前调用背景决策模块,从 12 个候选色中选择具体 hex,最多重试 3 次,失败后兜底 `#CFEFFF`。后端仍保留手动 hex 解析能力供内部兼容。最终生图 prompt、动作视频实色背景和子 worker 发往 provider 的 `screen_color` multipart 字段只接收解析后的具体 hex,不透传 `auto`。角色、图标 spritesheet 和 UI 设计图素材提取的同源画布请求由前端自动提交默认 `segModel=birefnet`,api-server 负责 allowlist 校验并在缺失时回落默认值;角色动作逐帧去背的 `seg_model` 由后端固定。四条 flat 路径再由 api-server 向 worker 显式传递 `background_mode=flat` 与 `cross_check`:角色形象生成、图标 spritesheet 和角色动作逐帧去背传 `on`,UI 设计图素材提取传 `off`,不依赖 BgFilter 服务端默认值。前端不展示抠图模型选择,`segModel` 不进入 `generationInputs`、响应、搜索、详情或导出;`background_mode`、`cross_check` 只存在于 api-server 到 worker 的内部 RPC。子 worker 为 flat / complex 分别维护独立进程级熔断,并对一次逻辑调用严格最多执行两次顺序 provider attempt;两种模式共享 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 和 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=120` 默认值,但失败和成功只更新当前模式;父侧至多让 worker 接收一次内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。flat 两次失败、熔断、overload、内部 deadline 或断连后,只要父业务预算仍有效,父流程才按同一 object key 进入“阿里云通用抠图 → 本地 `editor_green_screen` 键色”降级;阿里云 fallback 不属于 `bgfilter-worker`。角色动作生成的序列帧背景色已与生图统一:后端把源角色图合成到视觉决策出的具体 hex 后再图生视频;抽帧后逐帧进入同一条 `内部 bgfilter-worker(background_mode=flat,cross_check=on)→ 父侧阿里云 → 父侧本地键色` 链路。 @@ -34,7 +35,7 @@ - 画布底部工具栏 / 面板 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` 是服务端权威行引用:客户端提交的 `title/label/refType/refId` 都不可信;站内已迁移生成队列只允许在入队 payload 中保留按位置对应的非 provenance 槽位 `id`,直接创建资源 / 素材及 External API 仍删除整段客户端 references。生成执行时按真实参考图和当前 owner 的资源 / 素材记录重建 `id + title/label/refType/refId` 后再持久化;裸 owned objectKey 找不到正式资源或素材行时可以参与生成,但不得制造伪引用。滚动部署的队列幂等比较会忽略这些冗余展示槽位,实际 `sourceImageSrc/referenceImageSrcs` 或其它参数变化仍必须冲突。 +- `generationInputs.references` 的 `refType/refId` 是服务端权威行引用:客户端提交的 `title/label/refType/refId` 都不可信;站内已迁移生成队列只允许在入队 payload 中保留按位置对应的非 provenance 槽位 `id`,直接创建资源 / 素材及 External API 仍删除整段客户端 references。生成执行时按真实参考图和当前 owner 的资源 / 素材记录重建 `id + title/label/refType/refId` 后再持久化;裸 owned objectKey 找不到正式资源或素材行时可以参与生成,但不得制造伪引用。滚动部署的队列幂等比较只在新旧 payload 恰有一侧缺少 `references` 时忽略该字段;两侧都存在时槽位 ID / 数量 / 顺序必须严格一致,两侧都缺少时其它差异同样不得被归一,实际 `sourceImageSrc/referenceImageSrcs` 或其它参数变化始终必须冲突。 - 普通图片生成最多选择 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。 @@ -53,6 +54,7 @@ - 像素后处理属于 best-effort:失败时保留进入该步骤前的图片,继续原有最终上传与画布完成,并通过既有通用 `warning` 返回非阻断原因,不把任务改为失败或退款。BgFilter 自身失败时仍按原 source-only fallback 收口,像素处理不运行;图标后处理成功后再执行原有自动拆分,拆分告警继续使用现有 `sliceWarning` 语义。 - 选中已有静态栅格图层后的 `完美像素` 是独立的一键派生操作,不等同于生成请求上的 `style="pixelArt"`。它不打开参数面板,只处理当前活动图层,保留源图,并在源图右侧创建同尺寸 PNG 派生结果;音频、视频、图片序列和 `character-animation` 不显示该按钮。 - 完美像素、手动去背景、裁扩和所有图集切片是确定性派生操作,分别保存 `image.perfect-pixel`、`image.remove-background`、`image.crop-expand`、`spritesheet.split` 的 V2 `generationInputs`。四者固定 `fields: []`;存在正式来源资源 / 素材行时只保存服务端权威的 `references[id="source"]`,引用仅用于来源溯源,不是算法参数或可编辑槽位,没有正式行时保存空数组。改造 capability 使用独立 allowlist,四者及历史 `pixel-art-snap-*` 结果永不允许改造;自动切片的 source 是实际被切的透明图集,整张生成图集仍保留原生成 action。 +- 裁扩创建资源时,未携带 `sourceResourceId` 仍允许保存无正式来源引用的确定性结果;一旦携带该 ID,api-server 必须确认它属于当前 owner 和当前项目,否则以 `400` 拒绝,禁止同时持久化悬空 / 越权 `sourceResourceId` 和空来源配方。 - 已有图片像素规整固定调用登录态同源 `POST /api/editor/images/pixel-art-snaps`,复用同一纯内存 Rust snapper、CPU 并发许可和输入尺寸上限。该入口免费、只走当前 HTTP 请求内的 inline 处理,不创建 `external_generation_job`,不刷新或自动打开任务侧栏,也不进入泥点扣费 / 退款链路。它另有一层端点级并发闸(最大 4、等待队列上限 2048),设在首次 IO 之前;队列满返回 `503` 并带 `Retry-After`,等待超预算返回 `504`。30 秒总预算从 handler 入口起算,覆盖归属校验的 SpacetimeDB 读取、OSS 下载、两层排队与规整,不是只算 CPU 部分。 - **完美像素的优先级判据(约束实现、评审与复查)**:该操作免费(`generation_cost_mud_points = 0`)、同步、纯几何规整,重做一次的代价接近于零。因此凡是「已生成的资源在意外情况下丢失关联、需要用户重做或自行去素材库取回」这一类问题,一律**不构成阻断项**;只有主链路本身出问题才阻断——发起被拒、处理失败、结果没落库、已落库的结果既不回填画布也不进素材库。把前一类现象报成必须修复的缺陷之前,先确认它落在主链路上。 - **明确禁止的补救方向**:不得为了防止上述丢失而新增任何限制用户操作的特性,具体包括但不限于「禁止用户删除占位」「禁止用户重试」「禁止同一张图被处理两遍」。用户对自己画布上的元素始终保有删除与重做的权利;重复处理的最坏后果只是素材库多一份、用户可自行删除,这个代价远小于剥夺用户操作权。历史上引入过的同类封锁(未收口 operation 不可删除、随源图层清理豁免)已被逐条作废,不得以任何理由重新引入。既有的 `existingOperation` 闸(占位仍在时拦住从源图重新发起)是本条确立之前的遗留,方向与本条相反,后续应放宽而不是加固——尤其不得改成「让本机账本也参与防重」,那正是被本条禁止的「禁止一张图处理两遍」。 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 1dc135a83..eeac069ec 100644 --- a/server-rs/crates/api-server/src/editor_generation_queue.rs +++ b/server-rs/crates/api-server/src/editor_generation_queue.rs @@ -216,6 +216,10 @@ fn strip_untrusted_generation_input_references_from_payload(value: &mut Value) - generation_inputs.remove("references").is_some() } +fn generation_input_references(value: &Value) -> Option<&Value> { + value.pointer("/generationInputs/references") +} + fn job_kind_migrated_away_from_client_generation_references(job_kind: &str) -> bool { matches!( job_kind, @@ -248,6 +252,11 @@ fn editor_generation_request_payloads_match( if !job_kind_migrated_away_from_client_generation_references(job_kind) { return false; } + if generation_input_references(&existing).is_some() + == generation_input_references(&requested).is_some() + { + return false; + } strip_untrusted_generation_input_references_from_payload(&mut existing); strip_untrusted_generation_input_references_from_payload(&mut requested); existing == requested @@ -643,6 +652,45 @@ mod tests { ); } + #[test] + fn replayed_migrated_jobs_with_different_safe_slots_conflict() { + 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", + "referenceImageSrcs": ["asset-1"], + "generationInputs": { + "version": 2, + "action": "image.generate", + "fields": [], + "references": [{"id": "source"}] + } + })) + .expect("existing payload should serialize"); + let requested = serde_json::to_string(&json!({ + "prompt": "same", + "referenceImageSrcs": ["asset-1"], + "generationInputs": { + "version": 2, + "action": "image.generate", + "fields": [], + "references": [{"id": "specReference"}] + } + })) + .expect("requested payload should serialize"); + + let error = ensure_editor_generation_job_matches_request( + job, + "user-1", + EDITOR_IMAGE_GENERATION_JOB_KIND, + requested.as_str(), + "幂等冲突", + ) + .expect_err("different migrated safe slots must 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); diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 5c5409849..5c20e8a2b 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -2598,6 +2598,27 @@ fn build_editor_crop_expand_generation_inputs( build_editor_deterministic_generation_inputs("image.crop-expand", reference) } +fn require_editor_crop_expand_source<'a>( + resources: &'a [EditorProjectResourceRecord], + owner_user_id: &str, + project_id: &str, + source_resource_id: &str, +) -> Result<&'a EditorProjectResourceRecord, AppError> { + resources + .iter() + .find(|resource| { + resource.resource_id == source_resource_id + && resource.project_id == project_id + && resource.owner_user_id == owner_user_id + }) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-project-resource", + "message": "裁切扩图来源资源不存在或不属于当前项目", + })) + }) +} + async fn resolve_editor_project_resource_generation_inputs( state: &AppState, owner_user_id: &str, @@ -2626,12 +2647,13 @@ async fn resolve_editor_project_resource_generation_inputs( }) .await .map_err(map_editor_project_error)?; - let source = project.resources.iter().find(|resource| { - resource.resource_id == source_resource_id - && resource.project_id == project_id - && resource.owner_user_id == owner_user_id - }); - Ok(build_editor_crop_expand_generation_inputs(source)) + let source = require_editor_crop_expand_source( + project.resources.as_slice(), + owner_user_id, + project_id, + source_resource_id, + )?; + Ok(build_editor_crop_expand_generation_inputs(Some(source))) } fn editor_generated_image_storage_profile( @@ -13013,6 +13035,42 @@ mod tests { ); } + #[test] + fn crop_expand_source_must_match_owner_project_and_resource() { + let resource = test_editor_project_resource_record( + "resource-crop-source", + "project-1", + "generated-character-drafts/editor/crop-source.png", + None, + None, + ); + let resources = vec![resource]; + + assert!( + require_editor_crop_expand_source( + resources.as_slice(), + "user-1", + "project-1", + "resource-crop-source", + ) + .is_ok() + ); + for (owner_user_id, project_id, source_resource_id) in [ + ("user-2", "project-1", "resource-crop-source"), + ("user-1", "project-2", "resource-crop-source"), + ("user-1", "project-1", "resource-missing"), + ] { + let error = require_editor_crop_expand_source( + resources.as_slice(), + owner_user_id, + project_id, + source_resource_id, + ) + .expect_err("mismatched crop-expand source must fail closed"); + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + } + } + #[test] fn generation_reference_provenance_comes_from_matching_owner_records() { let resource = test_editor_project_resource_record( diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index 9d344345f..4bbbba4b1 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -119,6 +119,47 @@ describe('ImageCanvasEditorModel', () => { }); }); + it('ignores unknown nested V2 properties during non-strict resource hydration', () => { + expect( + generationInputsOrNull({ + version: 2, + action: 'image.generate', + futureTopLevelProperty: true, + fields: [ + { + id: 'prompt', + title: '生成提示词', + value: '未来配方', + futureFieldProperty: 'ignored', + }, + ], + references: [ + { + id: 'reference', + title: '参考图', + label: '未来素材', + refType: 'asset', + refId: 'asset-future', + futureReferenceProperty: 1, + }, + ], + }), + ).toEqual({ + version: 2, + action: 'image.generate', + fields: [{ id: 'prompt', title: '生成提示词', value: '未来配方' }], + references: [ + { + id: 'reference', + title: '参考图', + label: '未来素材', + refType: 'asset', + refId: 'asset-future', + }, + ], + }); + }); + it('fails malformed V2 closed while preserving legacy hydration', () => { expect( generationInputsOrNull({ @@ -1491,6 +1532,34 @@ describe('ImageCanvasEditorModel', () => { ); }); + it('keeps legacy perfect-pixel snapshots with empty string metadata recoverable', () => { + const dialogId = 'dialog-perfect-pixel-empty-legacy-metadata'; + const operation = buildPerfectPixelOperation(dialogId); + operation.request.generationInputs = { + fields: [{ title: '', value: '' }], + references: [ + { + title: '', + label: '', + refType: 'asset', + refId: '', + }, + ], + }; + + const hydrated = hydrateCanvasGenerationDialog({ + id: dialogId, + mode: 'quick-edit', + prompt: '完美像素', + status: 'pending-confirmation', + composerOpen: false, + perfectPixelOperation: operation, + }); + + expect(hydrated?.perfectPixelOperation).toEqual(operation); + expect(hydrated).not.toHaveProperty('perfectPixelOperationInvalid'); + }); + it('keeps a settled perfect-pixel placeholder valid without any local ledger', () => { // 中文注释:服务端完成 completion 后只做字段级改写,perfectPixelOperationId 会永久留在 // 布局里;而账本在收口那一刻就被清掉了。这个组合是每一次**成功**完美像素的必然形状, diff --git a/src/components/image-editor/ImageCanvasExportModel.test.ts b/src/components/image-editor/ImageCanvasExportModel.test.ts index e0702612f..70efee97e 100644 --- a/src/components/image-editor/ImageCanvasExportModel.test.ts +++ b/src/components/image-editor/ImageCanvasExportModel.test.ts @@ -171,6 +171,29 @@ describe('ImageCanvasExportModel', () => { }); }); + it('uses the reference fallback label when export metadata is blank', () => { + const metadata = buildLayerExportMetadata( + buildLayer({ + generationInputs: { + fields: [], + references: [ + { + title: '参考图', + label: ' ', + refType: 'asset', + refId: 'asset-reference', + }, + ], + }, + }), + 'images/001-layer.png', + ); + + expect(metadata.visible.generationInputs?.references[0]?.label).toBe( + '参考素材', + ); + }); + it('filters built-in prompts from exported visible generation inputs', () => { const metadata = buildLayerExportMetadata( buildLayer({ diff --git a/src/components/image-editor/ImageCanvasExportModel.ts b/src/components/image-editor/ImageCanvasExportModel.ts index 6ec567bf1..d82d968d7 100644 --- a/src/components/image-editor/ImageCanvasExportModel.ts +++ b/src/components/image-editor/ImageCanvasExportModel.ts @@ -546,7 +546,7 @@ function buildVisibleGenerationInputs(layer: CanvasLayer) { const references = layer.generationInputs?.references.map((reference) => ({ title: reference.title, - label: reference.label ?? '参考素材', + label: reference.label?.trim() || '参考素材', refType: reference.refType, refId: reference.refId, })) ?? []; diff --git a/src/components/image-editor/ImageCanvasGenerationInputsModel.ts b/src/components/image-editor/ImageCanvasGenerationInputsModel.ts index 0e729d260..a0c3f2ab6 100644 --- a/src/components/image-editor/ImageCanvasGenerationInputsModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationInputsModel.ts @@ -24,21 +24,20 @@ export const CANVAS_GENERATION_ACTIONS = [ 'image.crop-expand', ] as const satisfies readonly CanvasGenerationAction[]; -export const REMIXABLE_CANVAS_GENERATION_ACTIONS = - new Set([ - 'image.generate', - 'spec.generate', - 'character.generate', - 'icon.generate', - 'ui-design.generate', - 'publication.generate', - 'video.generate', - 'audio.sound-effect.generate', - 'audio.background-music.generate', - 'character-animation.generate', - 'image.edit', - 'ui-design.extract-assets', - ]); +const REMIXABLE_CANVAS_GENERATION_ACTIONS = new Set([ + 'image.generate', + 'spec.generate', + 'character.generate', + 'icon.generate', + 'ui-design.generate', + 'publication.generate', + 'video.generate', + 'audio.sound-effect.generate', + 'audio.background-music.generate', + 'character-animation.generate', + 'image.edit', + 'ui-design.extract-assets', +]); const CANVAS_GENERATION_ACTION_SET = new Set(CANVAS_GENERATION_ACTIONS); const V2_GENERATION_INPUT_KEYS = new Set([ @@ -95,8 +94,9 @@ export function isCanvasGenerationAction( return typeof value === 'string' && CANVAS_GENERATION_ACTION_SET.has(value); } -export function isNormalizedCanvasGenerationInputsStructure( +function hasNormalizedCanvasGenerationInputsStructure( value: unknown, + strictWhitelist: boolean, ): value is CanvasGenerationInputs & { version: 2; action: CanvasGenerationAction; @@ -114,7 +114,7 @@ export function isNormalizedCanvasGenerationInputsStructure( value.fields.every( (field) => isRecord(field) && - hasOnlyKeys(field, V2_GENERATION_FIELD_KEYS) && + (!strictWhitelist || hasOnlyKeys(field, V2_GENERATION_FIELD_KEYS)) && isPresentString(field.id) && typeof field.title === 'string' && isGenerationInputValue(field.value), @@ -122,7 +122,8 @@ export function isNormalizedCanvasGenerationInputsStructure( value.references.every( (reference) => isRecord(reference) && - hasOnlyKeys(reference, V2_GENERATION_REFERENCE_KEYS) && + (!strictWhitelist || + hasOnlyKeys(reference, V2_GENERATION_REFERENCE_KEYS)) && isPresentString(reference.id) && typeof reference.title === 'string' && (reference.label === undefined || @@ -134,21 +135,46 @@ export function isNormalizedCanvasGenerationInputsStructure( ); } +export function isNormalizedCanvasGenerationInputsStructure( + value: unknown, +): value is CanvasGenerationInputs & { + version: 2; + action: CanvasGenerationAction; +} { + return hasNormalizedCanvasGenerationInputsStructure(value, true); +} + +export function isRemixableCanvasGenerationAction( + action: CanvasGenerationAction, +) { + return REMIXABLE_CANVAS_GENERATION_ACTIONS.has(action); +} + function cloneV2GenerationInputs( value: Record, strictWhitelist: boolean, ): CanvasGenerationInputs | null { if ( (strictWhitelist && !hasOnlyKeys(value, V2_GENERATION_INPUT_KEYS)) || - !isNormalizedCanvasGenerationInputsStructure(value) + !hasNormalizedCanvasGenerationInputsStructure(value, strictWhitelist) ) { return null; } return { version: 2, action: value.action, - fields: value.fields.map((field) => ({ ...field })), - references: value.references.map((reference) => ({ ...reference })), + fields: value.fields.map((field) => ({ + id: field.id, + title: field.title, + value: field.value, + })), + references: value.references.map((reference) => ({ + id: reference.id, + title: reference.title, + ...(reference.label === undefined ? {} : { label: reference.label }), + refType: reference.refType, + refId: reference.refId, + })), }; } @@ -170,32 +196,42 @@ function hydrateLegacyGenerationInputs( if ( !isRecord(field) || (strictWhitelist && !hasOnlyKeys(field, LEGACY_GENERATION_FIELD_KEYS)) || - !isPresentString(field.title) || - !isPresentString(field.value) + (strictWhitelist + ? typeof field.title !== 'string' + : !isPresentString(field.title)) || + (strictWhitelist + ? typeof field.value !== 'string' + : !isPresentString(field.value)) ) { return []; } - return [{ title: field.title, value: field.value }]; + return [{ title: field.title as string, value: field.value as string }]; }); const references = rawReferences.flatMap((reference) => { if ( !isRecord(reference) || (strictWhitelist && !hasOnlyKeys(reference, LEGACY_GENERATION_REFERENCE_KEYS)) || - !isPresentString(reference.title) || - !isPresentString(reference.label) || + (strictWhitelist + ? typeof reference.title !== 'string' + : !isPresentString(reference.title)) || + (strictWhitelist + ? typeof reference.label !== 'string' + : !isPresentString(reference.label)) || (reference.refType !== 'project-resource' && reference.refType !== 'asset') || - !isPresentString(reference.refId) + (strictWhitelist + ? typeof reference.refId !== 'string' + : !isPresentString(reference.refId)) ) { return []; } return [ { - title: reference.title, - label: reference.label, + title: reference.title as string, + label: reference.label as string, refType: reference.refType, - refId: reference.refId, + refId: reference.refId as string, } satisfies CanvasGenerationInputReference, ]; }); diff --git a/src/components/image-editor/ImageCanvasGenerationModel.test.ts b/src/components/image-editor/ImageCanvasGenerationModel.test.ts index 86293356f..586b1de13 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.test.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.test.ts @@ -591,7 +591,10 @@ describe('ImageCanvasGenerationModel', () => { ], references: [], }); - expect(alias).toMatchObject({ ok: true, warnings: [] }); + expect(alias).toMatchObject({ + ok: true, + warnings: [expect.objectContaining({ fieldIds: ['style'] })], + }); expect( alias.ok ? alias.inputs.fields.find((field) => field.id === 'model')?.value @@ -706,6 +709,50 @@ describe('ImageCanvasGenerationModel', () => { ); }); + it('materializes and warns about missing action parameters', () => { + const decoded = decodeCanvasGenerationInputs({ + version: 2, + action: 'video.generate', + fields: [{ id: 'prompt', title: '视频描述', value: '追逐镜头' }], + references: [], + }); + + expect(decoded).toMatchObject({ + ok: true, + inputs: { + fields: expect.arrayContaining([ + expect.objectContaining({ id: 'model', value: DEFAULT_VIDEO_MODEL }), + expect.objectContaining({ id: 'durationSeconds', value: 4 }), + ]), + }, + warnings: expect.arrayContaining([ + expect.objectContaining({ fieldIds: ['model'] }), + expect.objectContaining({ fieldIds: ['durationSeconds'] }), + ]), + }); + + const animation = decodeCanvasGenerationInputs({ + version: 2, + action: 'character-animation.generate', + fields: [{ id: 'prompt', title: '动作描述', value: '挥手' }], + references: [], + }); + expect(animation).toMatchObject({ + ok: true, + inputs: { + fields: expect.arrayContaining([ + expect.objectContaining({ id: 'frameCount', value: 32 }), + expect.objectContaining({ id: 'durationSeconds', value: 4 }), + ]), + }, + warnings: expect.arrayContaining([ + expect.objectContaining({ + fieldIds: ['frameCount', 'durationSeconds'], + }), + ]), + }); + }); + it('falls invalid sound and character animation parameters back to complete defaults', () => { const sound = decodeCanvasGenerationInputs({ version: 2, diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts index ba2a17356..301a5a953 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -25,7 +25,7 @@ import type { } from './ImageCanvasEditorTypes'; import { isNormalizedCanvasGenerationInputsStructure, - REMIXABLE_CANVAS_GENERATION_ACTIONS, + isRemixableCanvasGenerationAction, } from './ImageCanvasGenerationInputsModel'; import { getPublicationMaterialsWorkflow, @@ -1259,9 +1259,14 @@ export function decodeCanvasGenerationInputs( title: string, fallback: T, resolve: (fieldValue: CanvasGenerationInputValue) => T | undefined, + materializeMissing = true, ): T => { const index = findFieldIndex(id); if (index < 0) { + if (materializeMissing) { + setFieldValue(id, title, fallback); + addFallbackWarning([id]); + } return fallback; } const resolved = resolve(fields[index]!.value); @@ -1274,8 +1279,12 @@ export function decodeCanvasGenerationInputs( return resolved; }; const normalizeStringField = (id: string, title: string, fallback = '') => - normalizeExistingField(id, title, fallback, (fieldValue) => - typeof fieldValue === 'string' ? fieldValue : undefined, + normalizeExistingField( + id, + title, + fallback, + (fieldValue) => (typeof fieldValue === 'string' ? fieldValue : undefined), + false, ); const normalizeStringOption = ( id: string, @@ -1492,6 +1501,11 @@ export function decodeCanvasGenerationInputs( ); addFallbackWarning(['frameCount', 'durationSeconds']); } + } else { + const defaultDuration = CHARACTER_ANIMATION_DURATION_OPTIONS[0]; + setFieldValue('frameCount', '帧数', defaultDuration.frameCount); + setFieldValue('durationSeconds', '时长', defaultDuration.durationSeconds); + addFallbackWarning(['frameCount', 'durationSeconds']); } } @@ -1553,7 +1567,7 @@ export function canOpenRedrawPanel( } const decodedInputs = decodeCanvasGenerationInputs(layer.generationInputs); if (decodedInputs.ok) { - if (!REMIXABLE_CANVAS_GENERATION_ACTIONS.has(decodedInputs.inputs.action)) { + if (!isRemixableCanvasGenerationAction(decodedInputs.inputs.action)) { return false; } if (!REQUIRED_SOURCE_GENERATION_ACTIONS.has(decodedInputs.inputs.action)) { diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index 282077556..66a867e31 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -1065,6 +1065,46 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { expect(screen.getByTestId('quick-edit').textContent).toBe('-'); }); + it('uploads local quick-edit references before submitting the edit request', async () => { + editEditorImageMock.mockResolvedValueOnce( + createGenerated({ prompt: '参考局部素材修图' }), + ); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); + fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); + + await waitFor(() => { + expect(editEditorImageMock).toHaveBeenCalledWith( + expect.objectContaining({ + referenceImageSrcs: [ + 'generated-character-drafts/editor/generation-references/reference.png', + ], + }), + ); + }); + expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledTimes(1); + }); + it('refreshes the wallet balance after inline image generation succeeds', async () => { const refreshWalletBalance = vi.fn(); editEditorImageMock.mockResolvedValueOnce( diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index b28f24b38..3013f1ab0 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -2062,6 +2062,21 @@ export function useImageCanvasGenerationSubmissionWorkflow({ projectId, ); } + const normalizedReferenceImageSrcs = await Promise.all( + (submissionPlan.editInput.referenceImageSrcs ?? []).map( + (referenceImageSrc, index) => + resolveEditorGenerationMediaReference( + dialog.generationReferences?.[index] + ? { + ...dialog.generationReferences[index], + src: referenceImageSrc, + } + : { src: referenceImageSrc }, + 'image', + projectId, + ), + ), + ); const quickEditPlaceholderSize = getCanvasCompletionPlaceholderSizeFromPlan({ sourceLayer: submissionPlan.sourceLayer, @@ -2072,6 +2087,9 @@ export function useImageCanvasGenerationSubmissionWorkflow({ prompt: submissionPlan.normalizedPrompt, sourceImageSrc: referenceImageSrc, ...submissionPlan.editInput, + ...(normalizedReferenceImageSrcs.length + ? { referenceImageSrcs: normalizedReferenceImageSrcs } + : {}), projectId, assetKind: submissionPlan.result.assetKind, generationInputs: submissionPlan.result.generationInputs, diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index 978b4f4f1..b58b46848 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -4938,7 +4938,7 @@ describe('useImageCanvasGenerationWorkflow', () => { ); expect(screen.getByTestId('generation-references').textContent).toBe(''); expect(screen.getByTestId('reference-pick-warning').textContent).toBe( - '部分原参考素材不在当前画布或来自面板上传,未恢复,请重新选择。', + `${CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING} 部分原参考素材不在当前画布或来自面板上传,未恢复,请重新选择。`, ); }); @@ -5001,6 +5001,29 @@ describe('useImageCanvasGenerationWorkflow', () => { }); }); + it('warns when a legacy recipe is restored successfully', () => { + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '打开图片改造' })); + + expect(screen.getByTestId('dialog').textContent).not.toBe('-'); + expect(screen.getByTestId('reference-pick-warning').textContent).toBe( + '已按旧版数据恢复,部分参数可能使用当前默认值。', + ); + }); + it('does not legacy-fallback when a V2 required source is unavailable', () => { render(