diff --git a/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md b/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md index cbd26dfa7..fe62ee1ee 100644 --- a/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md +++ b/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md @@ -138,10 +138,10 @@ Carry the current art spec in `generationInputs.artSpec` and reflect important c } ``` -The top-level `style` field is not the art spec's visual-style prose. It controls deterministic post-processing: +The top-level `style` field is not the art spec's visual-style prose. It appends a short server-side clause to the prompt sent to the provider and enables deterministic post-processing: -- Omitted, `null`, empty string, or `"none"`: disable post-processing without warning. -- `"pixelArt"`: enable pixel-art snapping for ordinary image generation, `kind: "character"`, and icon spritesheet generation. +- Omitted, `null`, empty string, or `"none"`: no clause is appended and no post-processing runs, without warning. +- `"pixelArt"`: append one short pixel-art line to the end of the prompt sent to the provider, and enable pixel-art snapping, for ordinary image generation, `kind: "character"`, and icon spritesheet generation. The line is appended, not substituted — the rest of your prompt is unchanged. For the exact per-kind wording, read the `style` field description in the OpenAPI document; it is the contract, and this guide deliberately does not copy it. - Unknown strings, or `"pixelArt"` on unsupported kinds such as `spec`, `quick-edit`, `ui-design`, or `publication-material`: continue without style processing and return `warning.code: "unsupported-image-style"`. - Non-string JSON values: malformed request, HTTP `400`. diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index 0d7a2692a..1ddc9543d 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -2991,7 +2991,7 @@ "none", "pixelArt" ], - "description": "可选生成后处理风格,当前识别 none 与 pixelArt。省略、null、空字符串或 none 按无风格处理;pixelArt 仅支持普通图片(kind 省略)和 character。未知字符串或不支持该风格的 kind 按 none 继续生成并返回 unsupported-image-style 告警;非字符串值返回 400。" + "description": "可选生成风格,当前识别 none 与 pixelArt。省略、null、空字符串或 none 按无风格处理,提交给 provider 的提示词与未带该字段时逐字一致;pixelArt 仅支持普通图片(kind 省略)和 character,会在提示词末尾追加一行像素风约束(普通图片为「画面为像素风格」,character 为「角色主体为像素风格」)并在回图后执行像素规整。未知字符串或不支持该风格的 kind 按 none 继续生成并返回 unsupported-image-style 告警;非字符串值返回 400。" }, "size": { "type": "string", @@ -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 }, @@ -3405,7 +3405,7 @@ "none", "pixelArt" ], - "description": "可选生成后处理风格,当前识别 none 与 pixelArt。省略、null、空字符串或 none 按无风格处理;pixelArt 启用图标图集像素规整。未知字符串按 none 继续生成并返回 unsupported-image-style 告警;非字符串值返回 400。" + "description": "可选生成风格,当前识别 none 与 pixelArt。省略、null、空字符串或 none 按无风格处理,提交给 provider 的提示词与未带该字段时逐字一致;pixelArt 会在提示词末尾追加一行「每个图标素材均为像素风格」并启用图标图集像素规整。未知字符串按 none 继续生成并返回 unsupported-image-style 告警;非字符串值返回 400。" }, "model": { "type": "string", @@ -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 00b604029..0aa45df6a 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -67,6 +67,18 @@ --- +## 2026-07-31 图集切片按需编码并批量确认持久化 + +- 背景:`2026-07-29 图集切片必须受前置容量和有界 CPU 保护` 收口了连通域数量与 CPU 并发,但切片仍在一次循环里全部裁剪并编码,最多 64 份 PNG 字节连同整张 RGBA 同时驻留内存;持久化又按切片逐个调用 procedure,N 片至少 2N 次写入外加一次 cohort 完成,任一片失败都会留下已确认的部分记录。手动拆分入口另有一处重复鉴权:`get_editor_project` 已经取回并定位了来源资源,随后仍走 `parse_editor_reference_image` 按注册 ID 再解析一次,触发全账号项目与素材库扫描。 +- 编码与内存决策:`platform-image` 把切片拆成 `prepare` 与 `encode` 两步,`prepare` 只计算带 padding 的裁剪边界并持有 `Arc`,`encode(index)` 被调用时才裁剪并编码单片。裁剪阶段累计 padding 后像素,超过调用方传入的上限即在任何编码前返回 `TotalCropPixelLimitExceeded`;api-server 传 `EDITOR_ICON_SPRITESHEET_MAX_TOTAL_CROP_PIXELS = EDITOR_ICON_SPRITESHEET_MAX_PIXELS * 4`(`16777216` 像素),映射为 `422` 与 `crop-pixel-limit-exceeded`。编码与上传由 `buffer_unordered(EDITOR_ICON_SPRITESHEET_UPLOAD_MAX_CONCURRENCY)`(`2`)串起,同时最多两片 PNG 在内存中。 +- 准入决策:新增独立于既有 CPU 信号量的 `EDITOR_ICON_SPRITESHEET_MEMORY_LIMITER`(`EDITOR_ICON_SPRITESHEET_MEMORY_MAX_CONCURRENCY = 2`)。手动拆分在创建下载客户端和发起下载**之前**取得该许可,许可覆盖「下载 → prepare → 逐片编码 → 逐片上传」整段,在进入 SpacetimeDB 批量调用前显式释放,避免数据库慢调用继续占用整张 RGBA。门限不可用返回 `503`、等待超预算返回 `504`,两者共用既有 `slice-processing-timeout` code。自动生成路径复用同一许可,但其源图此前已在内存中,该许可只保护解码与连通域阶段,不覆盖下载。 +- 持久化决策:新增 procedure `persist_editor_spritesheet_slice_batch_and_return`,在单个事务内依次确认每片的 asset object、可选项目资源、可选账号素材,并在存在 `group_task_id` 时一并完成 cohort;每次拆分请求只调用一次。批次上限 `EDITOR_SPRITESHEET_SLICE_BATCH_MAX_ITEMS = 64`,写入前校验数量与 `expected_asset_count` 一致、批内 `assetObjectId / objectKey / resourceId / assetId` 不重复、`source_resource_id` 指向的既有资源存在且同 owner 同 project;需要完成 cohort 的批次必须每项都创建素材。切片记录 ID 由 `(ownerUserId, taskId, 切片序号)` 经 SHA-256 确定性派生,重放得到相同 ID,且只有既有记录与新输入逐字段一致时才幂等复用,否则报幂等键冲突。 +- 鉴权决策:手动拆分不再调用 `parse_editor_reference_image`,直接用已随 owner-scoped 项目读取取得的 `source_resource` 取 objectKey,典型路径的 SpacetimeDB 调用从 3 次降为 1 次。作为替代,新增显式三重断言——项目属于当前 owner、资源属于当前 owner、资源属于当前 project——任一不符返回 `403`。结构断言禁止该区间再出现 `parse_editor_reference_image` 或 `list_editor_projects`。 +- 传输边界:新增 `build_editor_spritesheet_http_client(connect, request)`,下载与上传共用同一组常量 `EDITOR_ICON_SPRITESHEET_UPLOAD_CONNECT_TIMEOUT = 10s`、`EDITOR_ICON_SPRITESHEET_UPLOAD_REQUEST_TIMEOUT = 60s`。 +- 影响范围:`server-rs/crates/platform-image/src/generated_asset_sheets/`、`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`server-rs/crates/spacetime-client/src/editor_project.rs` 及生成的 module bindings;图标图集手动拆分与自动拆分链路。新增 SpacetimeDB procedure 与输入输出类型,需要重新生成绑定。 +- 验证方式:`platform-image` 覆盖 prepare 不编码且 `Send + Sync`、并发编码多个 index 结果不变、累计裁剪像素在编码前拒绝;`api-server` 覆盖切片记录 ID 稳定且按 owner / index 分区、自动路径保留处理超时告警码、上传超时释放内存许可;`spacetime-module` 覆盖批次校验的完整 cohort、重复 objectKey、来源资源同 owner 同 project、部分 cohort 拒绝与重放只在内容一致时复用。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、本文件 `2026-07-29 图集切片必须受前置容量和有界 CPU 保护`。 +- 补记说明:本条为事后补写,记录提交 `cf1a02312` 已落地的行为,不改变其任何决策。 ## 2026-07-31 AI 游戏创作资源依赖图采用 Rust 只读拓扑与前端派生 SVG > 状态:其中资源卡 Pointer Move 拖动预览与局部更新验收已由 2026-08-03 mentor 最新决定暂缓;只读拓扑、SVG 派生展示、搜索与选择高亮合同继续生效。 @@ -5950,6 +5962,16 @@ - 扩展边界:新 Provider 可直接实现 core `ProviderAdapter` 并注册,不修改 core enum/match。`platform-llm` 当前 DTO 不支持的 tool role/result、toolChoice none/specific 和 reasoning minimal/x-high 在 adapter 转换层零网络失败关闭;工具调用仍以最终 response 为权威。 - 关联文档:`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md` V1.50。 +## 2026-07-30 已有静态图片增加免费一键完美像素化 + +- UI 决策:图片选中浮动工具栏的栅格处理顺序固定为 `裁扩 → 去除背景 → 完美像素`。完美像素只对当前活动的静态栅格图层一键执行,不打开参数面板;音频、视频、图片序列和 `character-animation` 不显示。请求期间按 layer id 禁用并显示 busy,首个 await 前用同步 ref 防双击重复提交;结果保留源图并在右侧新增同尺寸 PNG。 +- API 与执行边界:新增登录态 `POST /api/editor/images/pixel-art-snaps`,复用 `platform-image` 纯内存 snapper、进程级 CPU 并发 2 以及既有输入尺寸上限。该入口免费 inline,不调用外部 provider,不创建 `external_generation_job`,不打开或刷新任务侧栏,也不进入泥点扣费 / 退款;它与生成请求 `style="pixelArt"` 的 best-effort 后处理是两个契约。2026-07-31 修订:并发控制改为两层——端点级并发闸最大 4、等待队列上限 2048,必须在首次 IO 之前取得,队列满返回 `503` 并带 `Retry-After`,等待超预算返回 `504`;内层仍是共享的 CPU 并发 2。30 秒预算的起算点同时从「下载完成后」前移到 handler 入口,现在覆盖归属校验的 SpacetimeDB 读取、OSS 下载、两层排队与规整全过程,而不再只是 CPU 排队加处理。该端点的 OSS 读写共用带 `connect 10s / total 120s` 的进程级 HTTP 客户端,不再每次新建无超时客户端。来源解析同时对齐图集拆分:带 `sourceResourceId` 且 `sourceImageSrc` 能免查确认指向同一张图时,来源资源已随 owner-scoped 项目读取完成鉴权,改为显式断言 `resource.ownerUserId` 与 `resource.projectId` 后直接取用其 objectKey,不再做全账号项目与素材库扫描;两个字段指向不同图片直接拒绝,不退回扫描路径。跨记录 asset_kind 扫描随之省略,存储类型点查保留,动图仍由下载后的静态编码门禁按实际字节拒绝。 +- 媒体与归属:前端先创建关闭 composer 的右侧占位,再解析或上传源图以取得稳定引用,随后 flush 包含该占位的当前项目布局;正式请求使用 `sourceImageSrc` 承载源图 `objectKey / resourceId / assetId` 候选稳定引用,`projectId / canvasCompletion` 必填且 `canvasCompletion.dialogId` 必须非空,并可携带 `sourceResourceId / assetKind / generationInputs / assetFolderId / assetLabel`。请求禁止 `data:` / `blob:`、signed URL 和普通外链。BFF 下载前必须将候选解析为当前 owner 已登记的私有 OSS object key,并校验 project / resource / asset 归属。 +- 失败与持久化:已有图片入口使用 strict 语义,只接受静态 PNG / JPEG / WebP,拒绝 GIF、APNG、动画 WebP 和非静态素材。strict 完全复用生成风格的 legacy profile、峰值估算、单轴步长补全、walker、采样与编码;唯一差异是横纵两轴都未检测到步长时,不执行 `min(width,height)/64` 统一网格兜底而返回不适用。任一轴已检测到步长时,strict 与 legacy 行为及输出必须一致。读取、解码、输入校验、并发排队、像素规整或 PNG 编码失败 / 超时 / 不适用时,不保存原图副本冒充成功,不执行最终 OSS PUT,也不创建 asset object、project resource、账号素材或结果 layer。成功时只对最终 PNG 做一次 PUT,至多各创建一个 `editor_project_resource` 和一个 `editor_asset`;源图已有正式 project resource 时,结果以 `source_resource_id` 关联该资源,再由 `canvasCompletion` 写入至多一个右侧派生 layer;不保存逻辑低分辨率图、诊断图或前后对比图。 +- 非事务边界:strict 零写入只覆盖首个最终 PNG PUT 前的引用 / owner / 项目 / 类型 / 静态编码 / 元数据 / 网格适用性 / CPU 处理门禁。进入持久化后沿用现有 `OSS + asset object → project resource → editor asset → canvas completion` 非事务顺序,后段失败可能保留此前已确认对象或记录;不做删除补偿或 unsafe POST 自动重放,按 `task_id / object_key / resource_id` 读取权威快照排障,跨系统单事务留待独立 procedure 方案。 +- 占位删除与重试:completion 必须读取当前权威 dialog;若删除已先持久化,只跳过画布 layer / dialog 写回,不得使用请求中的旧 placeholder 复活图层,已经成功持久化的 project resource / 账号素材允许保留。若回包时本地占位已删除,前端不得应用完成快照或写历史;现有布局 CAS 没有 deletion tombstone,因此 completion 先提交、删除保存后冲突的极端竞态仍按权威快照收口,绝对“删除意图胜出”留待 targeted delete / tombstone 方案。该路由是 unsafe POST,客户端不得配置 `EDITOR_REQUEST_RETRY_OPTIONS`;请求字节可能已发出后不因 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 自动重放,Bearer 中间件在 handler 前拒绝请求后的既有认证恢复继续保留。结果未知时先 GET 权威项目 / 素材快照,由用户显式决定是否再次执行。 +- 历史边界:成功加入画布时写一条 `perfect-pixel` 历史,中文标签为“完美像素”,并纳入新增结果保护;撤销不得让派生 PNG 消失。像素处理失败或 completion 因占位删除未落画布时不写该历史。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【图片画布】撤销范围与操作提示方案-2026-07-17.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md`。 ## 2026-07-31 game-chat 每条输出入聊天、试玩后收束与平台图集引用 - 背景:game-chat 的 ready response 之前只作为 transient stream 展示,专业 Agent 的 `final-reply` 只进入各自私有 conversation,刷新或事件 / 轮询重放时项目聊天可能丢失这些输出;自主构建完成后仍可能继续进入发布任务;配置 External Editor API 时,原型 HTML 也可能不实际使用平台生成的 Canvas 美术资源。 @@ -5966,6 +5988,45 @@ - 决策:game-chat 的 client-owned Runner 在活动任务期间禁止关闭客户端;关闭前复用既有 durable idle 真相源,避免另建 UI busy 状态。用户明确暂停/取消并达到 idle 后再退出,不能靠重启后自动重放未知 Provider 结果。 - 决策:规范 Agent reasoning 默认由角色职责分层,显式 per-Agent patch 优先;配置状态对外展示实际 timing/retry,避免全局文件、per-Agent resolver 与历史 run snapshot 混淆。 +## 2026-08-01 生成风格 pixelArt 同时约束提示词 + +- 背景:`style="pixelArt"` 此前只驱动 provider 返回后的确定性像素规整,完全不参与提示词拼接。但 `platform-image` 的 snapper 是几何对齐器——先检测网格步长再按格重采样;provider 交一张柔和渐变图时横纵两轴都检测不到步长,生成路径使用的 legacy profile 会退到 `min(width,height)/64` 统一网格兜底,产出的是马赛克而不是像素画。也就是原语义等于「随便生成什么,然后强行网格化」。 +- 决策:`pixelArt` 从「纯后处理风格」改为「提示词约束 + 后处理」。注入点固定在 `generate_editor_image_for_owner` 与 `generate_editor_icon_spritesheet_for_owner` 各自构造 `submitted_prompt` / `prompt` 的位置,包住既有 builder 的返回值,builder 签名与其既有输出契约不变。三个入口(登录态路由、外部 API v1、异步 job worker)都汇聚到这两个函数,一处注入全覆盖。`None` 必须原样返回原提示词。 +- 作用域按链路分三条措辞,不共用同一句:普通图片没有抠像底色,用「画面为像素风格」;角色形象与图标图集生成后都要按纯色抠像,绿幕底必须保持平整,分别用「角色主体为像素风格」和「每个图标素材均为像素风格」,都不得出现「画面」级别的像素化要求,否则与同一段提示词里既有的「纯色背景必须平整无纹理、无渐变」互相拆台。角色形象的提示词已禁止出现角色以外的场景内容,因此只点名角色;图标图集一张图内是多个彼此分离的素材,需要逐个点名。 +- 强度边界:实测只提「像素风格」效果已可接受,因此不注入网格密度、色板色数、抗锯齿等约束。约束句一律追加在提示词末尾并独立成行,不前置、不改写 builder 内部语句。`kind` 为 `spec / quick-edit / ui-design / publication-material` 时 `pixel_art_supported` 已把 `pixelArt` 降级为 `None`,注入对它们不生效;「修改图片」链路的 DTO 没有 `style` 字段,完全不受影响。 +- 反向提示词:同步从画布四个生图入口(普通图片、角色形象共用一条,UI 设计图,修改图片两个 provider 分支)的 negative prompt 中移除「低清晰度」——该词按字面否定低分辨率,与以低分辨率重采样为本质的像素风直接对冲。其余玩法(拼图、消除、跳跃、方洞、大鱼、吠叫、自定义世界场景图)的同名词条不动,本次只收口画布项目。 +- 持久化影响按链路不同,不能一概而论:`output_prompt` 初值是 `submitted_prompt`,但只有普通图片会保持到最后写入 `editor_project_resource` 的 prompt 列,该列因此从存用户原文变为存原文加一行约束句。角色形象链路的 `output_prompt` 在抠图成功后被无条件覆盖为 `"去除纯色背景"`,其原图 project resource 存的是 `role_setting`(用户原文),因此约束句在角色的任何 project resource 里都不出现。图标图集链路的原图 spritesheet resource 存工程化提示词(含约束句),透明结果存 `"去除纯色背景"`,自动拆分的切片存 `"自动拆分图集"`。不新增 OSS PUT、项目资源、素材记录或画布图层。 +- 角色链路的完整提交提示词是否留存取决于 provider:`persist_editor_provider_source_image` 写 asset object 元数据时用的是 `actual_prompt.unwrap_or(prompt)`,provider 未回 `actualPrompt` 时才存 `submitted_prompt`(含约束句),回了就存 provider 改写后的文本。因此 provider 回 `actualPrompt` 的场景下 `submitted_prompt` 在系统内一处都不落——外部 API 审计的 `request_payload` 只记 `promptChars` 字符数,没有提示词原文。排障时按 `object_key` 查 asset object 元数据只在前一种场景下有效。该行为与 `web/master` 逐行一致,属既有可观测性缺口,本次未改。 +- 响应体三条链路并不一致:普通图片和角色形象返回 `role_setting`(用户原文),前端显示不变;图标图集返回的是 builder 构造并追加约束句后的工程化 `prompt`,即调用方(含外部 API v1)能直接看到绿幕子句、间距要求和本次新增的像素约束。图标请求本身没有 `prompt` 字段(收的是 `iconDescriptions`),「返回用户原文」对它不成立。该响应字段行为同样与 `web/master` 一致,本次只是让被回传的模板多了一行。 +- 上述 prompt 列的写入规则全部是既有行为,与 `web/master` 逐行一致,本次未改动一行。但该列同时被用户侧素材库搜索(`buildAssetSearchValues` 把 `asset.prompt` 计入匹配项)和后台素材查询页读取,而它当前混着三种语义:用户输入、工程化提示词、以及派生步骤描述。由此带来的模板噪声污染搜索(角色模板含「绿幕」「纯色背景」等词)、派生产物按源提示词搜不到(透明图的 prompt 列是 `"去除纯色背景"`)等问题均为存量,需单独立项与原设计者对齐后再动,不在本次范围内。 +- 未覆盖:图标图集尚未约束各素材共用同一像素块大小(`estimate_step_size` 取全图相邻峰间距的第 30 百分位,块大小不一时步长估计会偏);角色形象提示词里既有的「严格基于图1的角色美术视觉规范的美术风格」与像素约束存在潜在冲突,未改写。两项都等实测。snapper 当前无任何日志,`resolve_step_sizes` 走检测还是走统一网格兜底在外部不可观测,注入效果暂时只能靠人工看图判断。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`、`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`、`docs/openapi/genarrative-external-v1.openapi.json`。 + +## 2026-08-01 完美像素端点补齐保留审计字段剥离 + +- 缺陷:`POST /api/editor/images/pixel-art-snaps` 自新增之日起未调用 `sanitize_editor_client_generation_inputs`,只对 `generationInputs` 做了可序列化性校验(`serialize_editor_asset_metadata`)便原样写入 `editor_project_resource` 与 `editor_asset`。登录用户因此可以自行声明 `screenColorHex / mattingProvider / mattingModel`,让后台 raw mapper 看到伪造的处理元数据。该 sanitizer 与其余 14 个生产调用点(`editor_project.rs` 13 处覆盖普通图片、角色、图标图集、UI 设计、快速编辑、抠图、上传等,`external_editor_api.rs` 1 处)在此端点加入前就已存在,属于新端点漏配既有约定,不是设计取舍。补上本端点后生产调用点为 15 个。 +- 决策:在 handler 解析 payload 之后、任何 IO 之前调用同一个 sanitizer,位置与其余入口一致。这三个字段是服务端产出的处理事实——`screenColorHex` 由背景色决策写入,`mattingProvider / mattingModel` 由 `apply_editor_matting_metadata_to_generation_inputs` 在 bgfilter 实际执行后写入——一律不接受客户端声明。完美像素是纯几何规整、不抠图(`model = "Perfect Pixel"`、`provider = "Genarrative"`),任何 matting 元数据出现在这类记录上本身就是伪造。 +- 影响边界:只能污染攻击者自己的记录(`owner_user_id` 取自 access token,不可控),不构成越权、信息泄露或计费漏洞;该端点 `generation_cost_mud_points = 0`。危害限于按这些字段做的后台统计、排障与审计出现假数据。 +- 验证:sanitizer 单元测试 `editor_client_generation_inputs_cannot_forge_internal_audit_fields` 已覆盖字段剥离与其余字段保留;端点接线由 `explicit_pixel_art_snap_is_inline_strict_and_persists_only_after_processing` 的顺序断言钉住,`sanitize_editor_client_generation_inputs` 必须排在 `resolve_editor_pixel_art_processing_deadline` 及之后全部 IO 之前,被挪到 IO 之后会直接失败。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-01 完美像素按钮补齐素材类型保存门禁 + +- 缺陷:完美像素按钮自新增之日起未接入 `isPersistingAssetKind`,handler 也未复用 `persistingAssetKindLayerIdsRef` 同步守卫。把图层的非空 `assetKind` 改成另一个非空值后,在异步资源保存完成前点击该按钮,请求会同时带上新 `assetKind` 和旧 `sourceResourceId`,后端 `resolve_editor_pixel_art_snap_asset_kind` 检出请求类型与来源权威类型不一致直接返回 `400`。两道防护与相邻的拆分图集按钮在完美像素按钮加入前就已存在,属于新入口漏配既有约定。 +- 决策:完美像素按钮的 `disabled / aria-busy` 与拆分图集共用同一套门禁(`isPersistingAssetKind || isPerfectPixelProcessing`),handler 侧同样先查 `persistingAssetKindLayerIdsRef` 再提交——`disabled` 只挡下一帧,同步 ref 才挡得住 `setState` 生效前的那一次点击。 +- 无障碍:保存态名称不得直接复用拆分图集的「素材类型保存中」。`icon-spritesheet` 图层会同时渲染两个按钮,撞名后读屏用户无法区分控件,既有测试也会因 `getByRole` 命中多个元素而失败。完美像素改用与「完美像素处理中」同构的「完美像素等待素材类型保存」。 +- 影响边界:`400` 发生在纯函数前置校验阶段,此时尚无 OSS PUT 与资源创建,不写坏数据;用户可在资源保存完成后重试成功,代价是需要手动清理失败占位。仅当「非空类型改为另一个非空类型」时触发——权威类型为空时走兜底分支不比较。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-01 完美像素归属校验单次取数并纳入处理预算 + +- 缺陷:`POST /api/editor/images/pixel-art-snaps` 在合法 `assetId` 且不带 `sourceResourceId` 时,取得端点准入许可后到 OSS 下载之间会顺序执行 8 次 SpacetimeDB 调用,且全是裸 `await`,第一次真正应用 30 秒预算的是下载。其中 `list_editor_projects` + `get_editor_asset_library` 这对全账号扫描重复了三轮——`resolve_editor_reference_object_key_for_owner` 内部两个子函数各扫一轮,`resolve_editor_pixel_art_source_for_owner` 再扫第三轮,拉的是同一份数据。 +- 契约澄清:预算从 handler 入口「起算」不等于「覆盖」。此前文档写成覆盖 SpacetimeDB 读取是错的:deadline 是绝对时刻,早起算只让后续余额更少,中间不检查就不会在该阶段返回 `504`,请求会一路走到下载才失败并返回下载相关文案,误导排障。同时端点准入许可全程被这些无界调用占用,把并发闸自身变成瓶颈。 +- 决策一(去重):参照抠图入口 `resolve_editor_background_removal_source` 的既有范式——扫一次,注册 ID 解析、归属校验和跨记录 `asset_kind` 收集全部交给 `_from_records` 纯函数在内存里完成。`resolve_editor_pixel_art_source_for_owner` 自行取一次 owner 快照后复用,不再调用 `resolve_editor_reference_object_key_for_owner`;该包装是给没有任何上下文的调用方用的,保持不动。归属校验命中已登记记录即短路,只有两份记录都查不到时才回落 `ensure_editor_reference_asset_object_owned` 点查。全账号 RPC 由 6 次降为 2 次。整段合法 `assetId` 链由 8 次降为 4 次——`get_editor_project`、`list_editor_projects`、`get_editor_asset_library`,加上恒定执行的 `get_asset_object_by_location`(承担存储 taxonomy 的非静态门禁,成本与账号规模无关,两条路径都保留);objectKey 未登记在 owner 任何记录里时多一次 `ensure_editor_reference_asset_object_owned` 的点查,最多 5 次。该兜底分支上同一个 objectKey 会被点查两次(归属校验一次、存储门禁一次,参数相同),可合并但收益远小于已削掉的全账号扫描,暂不处理。 +- 决策二(预算):`get_editor_project` 到来源解析结束整体包进 `tokio::time::timeout_at`,超时返回 `504` 且文案指向归属校验而非下载。不重复写 `Instant::now() >= deadline` 预检——紧邻的 `acquire_editor_pixel_art_snap_permit` 已做该预检,成功即意味着未超预算,且本块首个 await 是网络 IO 不会立即就绪,不构成 `timeout_at` 先 poll 再判超时的陷阱。 +- 验证:顺序断言新增 `tokio::time::timeout_at(` 与超时文案;参照 `937378ab9` 的做法用 `assert_function_occurrence_count` 把 `resolve_editor_pixel_art_source_for_owner` 内的 `.list_editor_projects(` 和 `.get_editor_asset_library(` 各钉为 1 次,并用 `assert_function_not_contains` 禁止该函数重新调用取数包装。后者断言的是调用形式 `resolve_editor_reference_object_key_for_owner(state` 而非裸函数名,否则会命中生产代码里说明「老包装保持不动」的注释——该陷阱在编写时即由测试抓出。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + ## 2026-08-01 game-chat 首版四阶段快车道与美术硬门 - 决策:game-chat 首版只投影 `art-director`、`code-prototype`、`preview-readiness`、`preview-playtest` 四个阶段,页面进度显示 `x/4`;四个专业 Agent 的安全 `final-reply` 均逐条进入项目聊天,`art-asset-plan` 不进入进度、阶段记录或 final-reply 投影。完整 GUI / CLI 任务图仍保留原有节点和执行语义,内部 Provider / child loop 不作为用户轮次。 @@ -5978,6 +6039,40 @@ - 每条输出入聊天:事件文件中的原始 `summary / detail` 仍是私有 Runtime 证据,不可由前端直接持久化。Rust 只对白名单用户进度生成 `publicText`,同时为每次真实追加生成 `eventId`;action 重放沿用 action 身份,普通事件使用进程、毫秒与单调序列组成唯一身份。前端把 `eventId + publicText` 和四阶段专业 Agent 的 durable final reply 作为独立 assistant 消息,按顶层 `messageId` 幂等写入项目 conversation;重载恢复、轮询与实时事件并发不得重复或漏掉当前已观察输出。 - 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`、`docs/project-memory/shared-memory/development-workflow.md`。 +## 2026-08-01 完美像素未知结果先对账再定性 + +- 缺陷:`snapImageToPerfectPixels` 的 catch 对所有错误一视同仁——标 `failed`、`finally` 解锁、按钮恢复可点。transport 异常、abort 和 120 秒客户端超时因此被谎报成明确失败,而服务端此时很可能已经完成 OSS PUT、asset object、project resource、账号素材和画布回填,只是响应没回来。用户按提示重试就再造一整份对象、资源与素材。这直接违反本功能自己立下的契约:「结果未知时先 GET 权威项目 / 素材快照,由用户显式决定是否再次执行」。客户端未配 `EDITOR_REQUEST_RETRY_OPTIONS`(禁自动重放)这半条一直是达标的。 +- 判别依据:`ApiClientError` 只在拿到服务端 `Response` 时由 `buildApiClientError` 构造,transport 异常、`AbortError` 和 `TimeoutError` 在重试判定后原样抛出。因此 `error instanceof ApiClientError` 即「服务端明确响应过、结果已知」,其余一律按未知处理。已知结果不发对账 GET,避免每个 `400` 都多打一次权威读取。 +- 决策:未知结果先 `loadEditorProject` 取权威快照,再按占位是否存活分流。占位已被 completion 消费掉说明这次其实成功,按快照收口并写入正常的 `perfect-pixel` 历史,不报错。占位仍在说明画布没收到结果,同步快照消除本地与服务端偏差但**不写历史**,文案明确告知结果未知且素材库可能已有派生图、要求用户先核对再决定是否重试——持久化是非事务的,OSS 对象与账号素材可能已落库而画布回填未完成。对账 GET 本身失败时给出「权威快照读取失败」的独立文案,不退回谎报。 +- 未覆盖:刷新页面后停在 `generating` 的占位仍无自动收口。占位在 POST 前已由 `flushProjectPersistence` 落库,`hydrateCanvasGenerationDialog` 原样恢复 `generating`,而该链路不进 `external_generation_job`,任务侧栏轮询看不到它,inline POST 的 Promise 随旧页面销毁。需要在 hydration 后加对账,且要先给 dialog 增加「属于无 durable job 的 inline 链路」标记,改动面大于本次,单独立项。客户端 120 秒超时相对服务端 30 秒预算是四倍冗余,调小可让对账更早发生,未处理。 +- 验证:新增三条用例分别覆盖「未知但实际成功→按快照收口并写历史」「未知且占位存活→只同步快照、标失败、文案要求先核对」「`ApiClientError` 已知失败→不发对账 GET、不动快照」。既有用例 `keeps a failed perfect-pixel placeholder` 原本用裸 `Error` 表达「服务端识别不到网格」,语义不准且会误入对账路径,改为 `ApiClientError`。测试 harness 新增 `dialog-error` 输出,否则对账文案不可观测。 +- 补充(同日):对账只对真正发出过 POST 的失败生效。占位创建、源图解析和 `flushProjectPersistence` 都在 POST 之前,它们失败时请求根本没发出,此时给出「素材库可能已存在派生图」是反向谎报,与本条要修的谎报是镜像关系;用 `perfectPixelPostAttempted` 标记划界,同时省掉一次无意义的权威读取。占位存活分支也不再调用 `applyProjectSnapshot`:传给该 hook 的是 `ImageCanvasEditorView` 的 `applyGeneratedProjectSnapshot`,其 action 默认值为 `generate-image`,不传 action 会写一条类型错误且受撤销保护的历史,而权威快照此刻与本地一致(占位都在),套用只会覆盖用户在请求期间的未保存编辑。这次 GET 的用途是判定,不是同步。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-01 完美像素持久化阶段由服务端显式告知客户端 + +- 背景:上一版对账用 `error instanceof ApiClientError` 判定「结果已知」,即「服务端响应过就等于没落库」。这个等式不成立——持久化非事务,`complete_editor_canvas_generation` 走 CAS 写入,冲突时 `spacetime-module` 抛「图片画布版本冲突」,经 `map_editor_project_error` 变成 `409`;`403 / 404 / 400` 同理。也就是说带响应的 4xx 同样可能发生在 OSS 对象、asset object、project resource 和账号素材全部落库之后。完美像素要跑满 30 秒,用户在这期间改动画布把 revision 推进并不罕见,因此该场景触发频率高于最初估计。 +- 否决的两个方案:把所有 POST 后失败都当未知(每次常见校验失败多两次读取,且给不可能产生素材的场景附上「请核对素材库」的不适用提示);按状态码分类(`409` 确实只来自写操作,但 `403 / 404` 和 `5xx` 在持久化前后都会出现,分不干净,等于把猜测写进代码)。 +- 决策:由服务端显式告知。`AppError` 新增 `with_detail_field`,在已有 details 上补字段而不是像 `with_details` 那样整体替换,保留下游写入的 provider / message——客户端要靠 message 定位、靠新字段决策。`snap_editor_image_to_pixel_art` 在第一次 OSS PUT 之后的四条失败路径(账号素材持久化失败、项目资源缺失、账号素材缺失、画布回填失败)置 `resultPersistenceStarted: true`。客户端只对「完全无响应」和「带该标记」的失败做对账,常见的纯校验 `400`、排队 `503`、预算 `504` 既不多打读取也不附加提示。 +- 配套修复:对账成功分支补上 `hasCanvasGenerationDialogById` 检查——权威快照里占位消失有两种原因,服务端消费掉或用户在请求期间删除,后者契约要求不应用完成快照、不写历史,成功路径同一处早有这道检查而对账路径漏了。对账同时刷新素材库(新增可选 `refreshAssetLibrary` 贯穿 `ImageCanvasEditorView` → surface → workflow),否则只 GET 项目却让用户核对素材库,他看到的仍是旧列表,不满足契约的「项目 / 素材快照」。占位存活分支刻意不调 `applyProjectSnapshot`:传入的是 `applyGeneratedProjectSnapshot`,其 action 默认值为 `generate-image`,不传 action 会写一条类型错误且受撤销保护的历史,而权威快照此刻与本地一致,套用只会覆盖未保存编辑。 +- 验证:服务端 `assert_function_occurrence_count` 把标记钉为 4 处,并用顺序断言要求它只出现在 `persist_editor_generated_image_owned` 之后——漏标一处或误标在校验阶段都会失败。客户端新增用例覆盖「带标记的 409 触发对账并刷新素材库」「未带标记的 400 不对账、文案保持服务端原文」「用户删除占位则不应用快照不写历史」。 +- 文案边界:对账尾句只下指令、不断言素材库已刷新。`refreshAssetLibrary` 在 `canAccessProtectedData` 为 false 时直接 return,读取失败也只在鉴权错误时弹登录框、其余一律吞掉,返回 `Promise` 不带成败信号,且它本身是可选 prop——三种情况下「已刷新」都是假话,会让用户对着旧列表判定「没有派生图,可以重试」,重新走回这条修复要避免的重复创建。刷新照旧调用(成功时用户白赚一份新列表),但文案在刷新失效时也必须成立。 +- 未覆盖:刷新页面后停在 `generating` 的占位仍无自动收口(已由 2026-08-03 的 `requiresLiveSession` 条目解决)。客户端 120 秒超时相对服务端 30 秒预算是四倍冗余,未调整。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-03 完美像素刷新后的孤儿占位由归属标记收口 + +- 缺陷:完美像素在 POST 前 `flushProjectPersistence()` 把 `status: 'generating'` 的占位落库,随后走同步 HTTP。此时刷新页面,浏览器断连、handler future 被丢弃,服务端不会走完画布回填;新页面 hydrate 时 `status` 被原样还原(`isGenerationStatus` 认 `generating`),而加载期没有任何对账、轮询或重试 GET,占位就永久停在转圈状态。它还消不掉——Esc 被 `useImageCanvasKeyboardShortcuts` 的 `status === 'generating'` 挡,composer 关闭被 `closeCanvasGenerationComposer` 的同一判断挡。 +- 归因:序列化设施是存量,但这个组合是本分支首次出现。同类路径逐条比对——去除背景的 `EditorBackgroundRemovalResult` 的 `queueState` 是非可选字段,恒走 durable job,worker 会在服务端替换占位;拆分图集根本不创建占位;图片生成等提交类链路在默认 `ExternalGenerationMode::Queue` 下同样入队,只有显式设 `GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 的部署才同步执行。完美像素的 handler 353 行全同步,`tokio::spawn` / `enqueue_` 各 0 次,`EditorPixelArtSnapResult` 无 `queueState`,是唯一「持久化 generating + 无 durable job」的链路。 +- 方案取舍:不能从写入侧解决。`validate_editor_pixel_art_snap_placeholder_exists` 要求占位必须已落库,否则返回 `409`「完美像素画布占位不存在或尚未保存,请重试」——不持久化占位会让每一次完美像素都失败。POST 前那句 flush 正是为满足该门禁而存在。因此只能在读取侧收口,纯前端。 +- 决策:给 dialog 增加 `requiresLiveSession` 标记,加载时由 `dropDeadInlineGenerationPlaceholders` 剥离置位且仍为 `generating` 的占位。判据是结构性不变量而非时间阈值:这类占位的收口只能由创建它的会话完成,而活着的那一份始终在内存里、永远不经过快照 hydrate,所以凡是从服务端快照读回来的必然属于已死会话。因此不需要时间戳,也不必猜阈值。队列型占位一律不置位——它们的 job 在服务端继续跑,误清会让用户以为操作没发生而重复提交。hydrate 只认布尔 `true`,缺字段的历史占位按队列型处理,不会被误清。 +- 作用域:剥离只用在项目首次加载的两个调用点(会话缓存与权威快照都要,否则首屏会先闪一个永远转圈的占位)。**不能**下沉进 `hydrateCanvasGenerationDialog`——会话内 `applyQueuedEditorGenerationProject` 也会重新 GET 项目并套用,那时候占位对应的操作正在进行,套用剥离会把自己的活占位清掉。唯一置位点是完美像素占位的创建处。 +- 提示文案:服务端持久化顺序 `OSS PUT → asset object → project resource → editor asset → 画布回填` 是非事务的,加载时还看到 `generating` 只说明最后一步没做完,前面几步可能已成功。所以不能断言「什么都没发生」,只提示「画布占位已清理,请确认素材库是否已生成派生图」,与同链路的对账文案同一口径。计数用累计值而非布尔——同一会话可能连着切换多个项目,布尔只提示一次。 +- 已知残留:剥离是本地的,不主动回写。`applyProjectSnapshot` 会置 `skipNextProjectLayoutSaveRef`,加载后的第一次 effect 被消费掉,所以清理要等用户下一次布局变更才随防抖落库;在此之前重复打开会重复提示。刻意不强制回写:那会加剧多标签页问题——B 标签加载时会误判 A 标签正在跑的占位为孤儿,只在本地剥离时 A 的回填仍能成功,一旦立即回写就会让 A 撞上 `409` 占位不存在。 +- 更正(2026-08-03):上一条里「多标签页另有 CAS `expected_revision` 兜底」是错的。CAS 挡的是基于陈旧 revision 的覆盖写,而 B 是以**当前** revision 写入一份合法布局,必然放行。触发也不需要 B 去点完美像素——B 加载后任何布局改动都会触发防抖保存,把「不含 A 占位」的布局写回服务端。后果已核到底:`complete_editor_canvas_generation` 在占位缺失时不报错,走 `Ok(None)`,A 的响应是 200 且 `project: null`,A 的前端据此移除本地占位并提示「完美像素结果已保存到素材库,画布占位已不存在」。所以 A 的 OSS 对象、项目资源、素材库记录三样都在,用户也被准确告知,丢的只是画布自动落位,需手动从素材库拖回。定级 P3,真正的修法是给占位加会话归属标识、只允许创建者剥离,单独立项。 +- 验证:`dropDeadInlineGenerationPlaceholders` 四条单测覆盖「剥离已死 inline 占位」「保留队列型占位(缺字段与显式 false 两种)」「保留已终态的 inline 占位与普通图层」「标记经 hydrate 与序列化往返不丢失」——最后一条钉住白名单式 hydrate 漏字段会让标记在一次「加载→保存」后消失。工作流测试新增 `live-session-dialogs` 探针,正向断言完美像素占位置位、反向断言去除背景占位不置位。`vitest src/components/image-editor` 893 通过 / 72 文件,typecheck、eslint、check:encoding 通过。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + ## 2026-08-03 game-chat 开发态前后端同源与快车道恢复 - 启动决策:`npm run agc` 与 `npm run agc:game-chat` 统一先经外层 Node 启动器预检 `3080`。在 marker 尚不能证明 worktree 归属时,任何已占用的 3080 都不得复用,并必须在原生窗口创建前失败关闭;Tauri CLI 退出后必须收束已启动的客户端进程树,不允许终端已退出但窗口与 Runner 仍假在线。 @@ -5997,6 +6092,61 @@ - Tetris 连续性补充:明确俄罗斯方块任务固定分类为 `tetris-v1`,不再回退 generic 可选遥测。HTML tokenizer 只把 TAB / LF / FF / CR / SPACE 视为标签空白,并按 `type / language / nomodule` 判断 Chromium 中的可执行脚本;静态门移除字符串、注释、`template / noscript / textarea / title / style / xmp / iframe / noembed / plaintext`、带 `src` 脚本的非执行正文、非 JavaScript script、不可达匿名或命名函数、短路表达式、恒真分支的 else、顶层无条件 return / throw 后正文和 `if(false)` / 明显恒假分支诱饵,并要求有标识符边界的可达 `fall -> lock -> clear` 调用链;filter / splice 消行必须由满行判断实际控制且绑定未被局部变量或函数参数遮蔽的正式棋盘。同项目 `game/*.js / game/*.mjs` 外部脚本及本地 module 依赖图只按显式 export/import binding 传递语义,side-effect import 不暴露被导入模块的局部绑定,ASI 换行与 template `${...}` 内真实 import 仍参与依赖解析;文件按去重数量和累计 2 MiB 上限有界读取,对象属性 `import / from` 与控制块后的正则正文不得伪造依赖。浏览器状态固定含 `activePieceId / rotation / row / lockedPieces / lineClearChecks / clearedLines / occupiedCells`,同时允许 `score / nextPieceId` 等不影响固定合同的扩展 telemetry;受控试玩在 Chromium 隔离执行上下文的 Promise 闭包中保留点击前基线,MutationObserver 只冻结 trusted 输入 listener 同步产生的最后状态,CDP 点击返回后再收口该冻结值,因此后注册的同步 click listener 仍会计入,而 RAF / timer 任务不会污染因果证据。页面全局对象不能改写隔离世界证据,capture-phase、stopPropagation 与真实 window bubble 处理器均应正确验收。探针 fingerprint 必须覆盖 install、ready 与 finish 三段真实脚本。锁定、消行与 restart 的既有严格约束保持不变。旧合同或纯继续 successor 以及 game-chat 快车道在读取回执前按有效原任务重新分类、重算 fingerprint 并回读迁移结果,旧 generic 回执只能视为 stale,不能交付完成。 - 关联:`apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs`、`start-dev-stack.mjs`、`src-tauri/src/agent/runtime_protocol/autonomous_completion.rs`、`response_stream.rs`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 +## 2026-08-03 完美像素对抗性审查第一批修复 + +- 范围:本分支相对 `web/master` 的 17 条审查发现里,只修其中三条——它们互相独立、改动小、无需设计决策。其余按链路分批,队列化改造(把完美像素接进 `enqueue_editor_generation_job`)因改动面超出本分支预期而未采纳。 +- A1 持久化标记漏洞:`snap_editor_image_to_pixel_art` 对 `persist_editor_generated_image_owned` 用的是裸 `?`,而该 helper 内部顺序是 `PUT → HEAD → confirm_asset_object`。HEAD 或 confirm 失败时 OSS 对象已存在,错误却不带 `resultPersistenceStarted`,客户端的 `outcomeMayBePersisted` 因此为假、对账根本不执行,直接报普通失败;用户重试会用新 `task_id` 生成新 object key,首个对象成为无从发现的孤儿。这是三条里唯一会让收口机制完全不触发的。 +- A1 的标记边界:标在 helper 内部而不是调用点——调用方拿到的是同一个 `AppError`,无法自行判断内部走到了哪一步。边界取在第一次 PUT:`prepare_put_object` 与「OSS 未配置」这两处失败都在 PUT 之前,标了会让客户端对着什么都没落库的失败去核对素材库,是与本条镜像的反向谎报。PUT 自身也标——响应丢失时字节可能已落盘,属于契约要覆盖的未知结果。共四处:PUT、HEAD、asset object 入参构造、`confirm_asset_object`。该 helper 为 9 条编辑器持久化流程共用,新增的 details 字段对其余调用方语义同样成立(持久化确实已开始),只是目前只有完美像素前端消费。 +- A3 失败反馈第三态:catch 尾部只处理「有占位」与「没有 dialogId」,缺「有 dialogId 但占位已被删」。删除生成中占位是产品支持的流程(`requestRemoveCanvasGenerationDialog` 对 `generating` 会先弹确认),用户删完之后请求才失败时,`errorMessage` 被算出来又整段丢弃,界面零反馈。丢的不只是失败提示——对账得出的「请确认素材库是否已生成派生图」在同一句里,用户会在毫不知情的情况下重试。改为 `else` 兜底走全局提示。兄弟路径 `split-atlas` 无条件 alert、`remove-background` 直接 rethrow,都不存在这个第三态。 +- E1 测试并行竞态:`pixel_art_snap_permit_reports_exhausted_budget_without_waiting` 对进程级 `EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH` 做绝对断言 `== 0`,而相邻用例会在自己的作用域里持有两个 guard,Rust 测试默认并行,两者撞上就随机变红。当时改为 before/after 相对断言,但后续第三批确认两次 load 之间仍可被并行用例插入,该方案未修复竞态并已撤回。过期预算用例只应断言 `504`;guard Drop 由相邻独立用例负责。 +- 验证方法:两条修复都先回退生产代码确认测试变红,再恢复。前端新用例在缺 `else` 分支时超时失败;服务端守卫在去掉任一处标记时报 `left: 3, right: 4`。首次验证时跑错了测试名——`explicit_pixel_art_snap_is_inline_strict_and_persists_only_after_processing` 里已有一组同名断言(钉的是 handler 内四处),新加的这组在 `editor_matting_releases_source_buffers_at_oss_boundaries`,两者同名不同域。 +- 验证结果:api-server 676 通过 / 3 失败(`wallet_refund_outbox` 本机环境失败,与基线一致);`vitest src/components/image-editor` 894 通过 / 72 文件;`cargo fmt --check`、typecheck、eslint、`check:encoding` 通过。 +- 未修(已立项):A2 客户端 120s 早于服务端最坏合法时长(约 270s);B1 并发闸许可跨越无预算的持久化阶段;C1/C2/C3 `requiresLiveSession` 链路;D、E 组其余清理项。E1 当时仅改成相对断言,后续第三批重新打开并完成修正。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-03 完美像素静默路径收口(审查第一批续) + +- 背景:第一批只修了失败尾部那一处静默(`else` 兜底)。复审指出对账分支还有一处早返回同样不说话,追查后发现它有个孪生分支在成功路径上,两处形状一致——操作其实已经落库、用户删掉了占位、没人告诉他素材库多了一份。 +- 决策:提示与「移除占位」解绑。成功路径 `!result.project` 分支原先把提示写在 `if (hasCanvasGenerationDialogById(...))` 里面,占位不在就整段静默;改为无条件提示、条件移除。对账分支 `!placeholderSurvived` 且本地占位也没了时,不应用快照仍然正确(删除意图胜出),但要补同一句提示——走到这里意味着权威快照里占位已被 completion 消费,本分支下一步正是据此把结果当成功套用,结论一致:结果已落库。 +- A4 的取舍:会话缓存里剥掉的占位数**不能**直接并入提示计数。完美像素成功后 `applyProjectSnapshot` 会置 `skipNextProjectLayoutSaveRef`,加载后的第一次 effect 不落库,所以会话缓存可能停留在完成之前的版本;重新加载时缓存里那个陈旧占位被剥掉、计数加一,而权威快照其实是成功的,并入就会报一条「上次处理未完成」的假告警。改为单独计数,只在权威加载失败、没有第二个来源可以纠正这幅画面时才提示。 +- A4 的可观测性核实:`isProjectReady` 只被启动意图消费和自动保存 effect 使用,不参与画布渲染门禁,所以权威加载失败时画布照常显示,用户看到的确实是一张静默少了占位的画布,提示有必要。鉴权失败与项目失访两条路径各自弹窗或跳转,不在这里重复打扰——为此在 `replaceAppHistoryPath` 后补了 `return`,该分支原本就没有后续语句,行为不变。 +- 验证:新增用例覆盖「对账发现两侧占位都没了 → 仍提示素材库结论」,去掉提示后该用例失败。`vitest src/components/image-editor` 895 通过 / 72 文件,typecheck、eslint 通过。 +- 未覆盖:A4 没有专用测试。`readEditorProjectSessionCache` 是持久化 hook 内的局部函数而非可 mock 的模块,要测得在 jsdom 里按缓存键格式播种存储再让权威加载失败,成本高于这三行改动本身;改动本身是「捕获计数 + 失败分支上报」,无分支逻辑变化,暂按未覆盖记录。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-03 完美像素持久化阶段纳入预算,服务端最坏时长收进客户端超时 + +- 缺陷:处理预算(30 秒)只覆盖到规整为止,持久化阶段完全无界,仅受 OSS 客户端每请求 120 秒约束,而 PUT 与 HEAD 各自独立计时,再加三次无超时 SpacetimeDB 调用,服务端最坏合法时长可达 270 秒以上,远超客户端 `snapEditorImageToPixelArt` 的 120 秒。客户端因此会在服务端仍在合法工作时先 abort:对账虽然照常执行(abort 不是 `ApiClientError`,`outcomeMayBePersisted` 为真),但它采样的是一个仍在途的操作——占位还在、`confirm_asset_object` 未跑完所以素材库还空,用户照提示核对什么也看不到,重试就用新 `task_id` 造出孤儿 OSS 对象。 +- 决策:给持久化整段套独立预算 `EDITOR_PIXEL_ART_MAX_PERSISTENCE_DURATION = 60` 秒,用第二个 `tokio::time::timeout_at` 包住从 `persist_editor_generated_image_owned` 到 `complete_editor_canvas_generation` 的全部写入。服务端最坏 30 + 60 = 90 秒,落在客户端 120 秒内并留 30 秒余量给网络往返与计时精度。 +- 为什么独立起算而不与处理预算取 min:持久化已经付出了 OSS PUT 的代价,因下载慢而被砍预算、中途放弃只会留下孤儿对象。取 min 会让「下载越慢、越容易留孤儿」,方向正好反了。 +- 超时必须带标记:这条超时发生在 PUT 已经发出之后,对象可能已落盘也可能没有,正是 `resultPersistenceStarted` 契约要覆盖的未知结果。不带标记客户端会判成确定失败、直接诱使用户重试。handler 内该标记的钉定计数因此由 4 升为 5,注释同步说明第五处是什么——这个升级由既有守卫自己报出来(`left: 5, right: 4`),不是事后补记。 +- 验证:新增 `pixel_art_server_worst_case_fits_inside_the_client_timeout` 钉住跨端不变式,把两侧数值和 30 秒余量都写死;顺序守卫新增「预算在前、写入在后」与超时文案 + 标记两项,任何把 persist 挪到 `timeout_at` 之前的改动都会失败。把持久化预算临时调到 120 秒可确认该测试变红。api-server 677 通过 / 3 失败(`wallet_refund_outbox` 本机环境失败,与基线一致),`cargo fmt --check` 通过。 +- 未覆盖:跨端不变式靠常量断言维系,客户端那侧的 120 秒仍是 `editorProjectClient.ts` 里的字面量,改动它不会让 Rust 测试失败。真正的双向钉定需要共享契约常量,本次未做。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-03 完美像素孤儿占位判据由结构不变式改为有界时间窗 + +- 缺陷:`dropDeadInlineGenerationPlaceholders` 原先依赖一条结构性不变式——置位 `requiresLiveSession` 的占位其收口只能由创建它的会话完成,而活着的那份始终在内存里、永不经过 hydrate,所以从服务端快照读回来的必然属于已死会话。这条在单标签页下成立,多标签页下是假的:B 标签打开同一项目会 hydrate 到 A 标签正在用的活占位,据此剥离,再由 B 下一次布局保存以**当前** revision 合法写回,把 A 的占位删掉。 +- CAS 的作用要说准:它挡的是基于陈旧 revision 的覆盖写。B 在 A 完成**前**写入时 revision 是当前的,CAS 放行——这是有害的那一半;B 在 A 完成**后**写入时 revision 已陈旧,CAS 拒绝——所以「B 把 A 的成品图层写没」这种更严重的情况本来就不会发生。此前 decision-log 笼统写「CAS 兜底」是错的,纠正后也不应反过来说 CAS 完全无用。 +- 决策:判据改为有界时间窗,只有超过 180 秒才判定为孤儿。窗口上界由两侧共同封死——服务端最坏合法时长是处理 30 秒加持久化 60 秒(都由 `timeout_at` 强制,见同日持久化预算条目),客户端整个 POST 又被 120 秒超时封顶;120 秒之后客户端必已 abort 并把占位改成 `failed` 或移除。取 180 = 120 客户端上限 + 60 余量(网络往返、标签页挂起后的时钟漂移)。 +- 关键依赖:这个方案在持久化预算落地**之前**不成立。那时服务端最坏时长无界,任何时间窗都是拍脑袋;把最坏时长收进 90 秒之后,时间窗才有硬依据。 +- 复用既有字段:时间戳用 `generationStartedAt`,由 `withGenerationTimestamps` 在占位进入 `generating` 时自动打戳,已序列化、已 hydrate,无需新增字段。 +- 撤回先前方案:此前多次记录「真正的修法是给占位加会话归属标识、只允许创建者剥离」。该方案不成立——B 拿到一个不同的会话 id,推不出 A 是死是活,照样只能猜。会话 id 只能识别「不是我的」,不能识别「已经没人要了」。 +- 兜底方向:缺 `generationStartedAt` 时按可剥离处理。实践中不会出现(两个字段同一次创建一起写),但按「保留」会让这类占位永久留在画布上,按「剥离」最坏只是退回引入时间窗之前的行为。 +- 时钟:取读取方的 `Date.now()`。同机多标签共享时钟,正是要修的场景,判定精确;跨设备有偏移风险,但此前是无条件剥离,任何时间窗都不会比原行为更差。 +- 已知残留:A 真死了而用户在 180 秒内重新加载时,占位会继续转到窗口过后的下一次加载才清掉。可以加客户端定时器在剩余时间后自行收口,但要多一套定时器生命周期管理,先不加,观察实际是否困扰。 +- 验证:新增四条用例覆盖窗口内保留、边界包含式、超窗剥离、缺时间戳兜底;去掉时间窗判断后其中两条变红。`vitest src/components/image-editor` 899 通过 / 72 文件,typecheck、eslint 通过。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-03 完美像素并发闸竞争路径与 snapper 错误分档补测 + +- 背景:审查留下的两处覆盖缺口。并发闸的三条竞争路径(队列满 503 + `retry-after`、等待槽位超时 504、信号量关闭 503)此前零测试——`retry-after` 在整个 crate 里只出现在生产代码一处;`map_editor_pixel_art_snapper_error` 的四档状态码只有 `GridNotDetected → 422` 被间接覆盖。 +- 测试方式的取舍:不去把全局信号量或队列计数打满。两者都是进程级 `static`,在测试里填满会让并行跑的其他用例连带失败——这与当时误以为已修掉、后续第三批才真正纠正的 E1 属于同类竞态,不能一边修一边再造一个。改为把三条路径的错误各自抽成构造函数,直接断言状态码、文案和 `retry-after` 头;「哪条路径用哪个构造函数」由 `snap_editor_image_to_pixel_art` 的既有顺序守卫钉住,CAS 边界本来就有本地计数器的用例覆盖。 +- 覆盖边界要说清:这样覆盖的是错误形状与分档,不是端到端的竞争行为。真要覆盖后者需要把限流器与队列计数改成依赖注入,改动面超出补测本身,未做。 +- 分档的双向后果写进了断言注释:把用户上传的坏图(`Decode`)报成 500 会让客户端当服务端故障去重试;把服务端自身失败(`Encode` / `Processing`)报成 400 又会让用户以为是自己的输入有问题。另断言底层文案原样带上,否则「识别不到网格」与「解码失败」在用户侧无法区分。 +- 验证:删掉 `retry-after` 或把 `Decode` 改判 500,两条新用例分别精确变红。api-server 679 通过 / 3 失败(`wallet_refund_outbox` 本机环境失败,与基线一致),`cargo fmt --check` 通过。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + ## 2026-08-03 托管 MCP 未鉴权响应提供安全接入引导 - 决策:`/api/external/v1/mcp` 缺少、格式错误或无法验证 Bearer API Key 时继续返回相同 HTTP `401`,并增加 `WWW-Authenticate: Bearer realm="genarrative-external-editor"` 与机器可读 `details.guide`。引导只说明 Bearer Header 格式、登录后在「开发者 API Key」创建密钥、原始密钥只显示一次、凭据不得进入聊天或仓库、配置后重试 `initialize`,以及公开 manifest、Skill 与 OpenAPI 地址。 @@ -6026,6 +6176,179 @@ - 兼容边界:这是基于「截至 2026-07-31 尚无外部第三方存量调用方」接受的 v1 原地 breaking change;一旦出现外部活跃 Key、公开契约或联调方,后续破坏性变更必须保留兼容、经过弃用期或升级 `/api/external/v2`。 - 关联文档:`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`.codex/skills/genarrative-external-editor-api/SKILL.md`。 +## 2026-08-03 完美像素对账判据改看 dialog 收口状态,网关合成响应归入未知结果 + +- 缺陷一(对账把真成功判成失败):对账用「同 ID 的 generation-dialog 是否还在权威快照里」判定成败,而服务端成功回填时**保留**该 dialog 并就地改写——`apply_editor_canvas_generation_items` 置 `status: "idle"`、`composerOpen: false`、写入 `generatedLayerId`、清掉 `errorMessage`,该行为另有服务端测试断言 `dialog["generatedLayerId"]` 钉住。所以响应丢失但服务端其实已完成时,判据反向:用户被告知「画布未收到完美像素结果,请确认素材库」,而结果早已在画布上,重做一遍就造出第二份;这条分支还刻意不套用快照,本地也看不到那个新图层。 +- 逃过测试的原因要单独记:那条「未知但实际成功」的用例夹具写的是 `layers: []`,是服务端永远不会产生的形状。**测试不是漏了,是主动为错误判据背书**——用一个假前提把反向逻辑测成了正确的。修复顺序因此定为「先改夹具、看它变红、再改判据」,让这件事显式暴露一次而不是被新判据顺手掩盖。 +- 决策一:判据改看 `status` / `generatedLayerId`,并复用既有语义。`projectHasUnresolvedGenerationDialog` 早就是本仓库对「这个生成收口了没有」的定义,只是原先埋在队列轮询里;原始 record 查询现由收集全部同 ID 记录的 `findCanvasGenerationDialogRecords` 与 `isUnresolvedCanvasGenerationDialogRecord` 两处共用,不自创新判据——自创正是本次出错的起点。取原始 record 而不 hydrate:hydrate 会给缺失 status 补 `idle`,把「服务端没写」和「服务端写了 idle」混成一种。 +- 三态处置:dialog 不存在 → 占位在处理期间被删(本会话或另一标签页),completion 返回 `Ok(None)`,资源与素材已落库但快照不含结果图层,清本地占位并提示素材库,**不套用快照**(此前会套用一份不含结果的快照并写受撤销保护的历史,用户既看不到结果也撤不回);dialog 在且未收口 → 画布确实没收到,只给文案不同步;dialog 在且已收口 → 真成功,套用快照并写 `perfect-pixel` 历史。前两态都保留「用户已删本地占位则删除意图胜出」的检查。 +- 缺陷二(网关合成响应被当确定失败):分类前提是「拿到 `ApiClientError` ⇒ 服务端明确表态过 ⇒ 结果已知」。该前提对 Pingora 自造的错误体不成立——它只有 `code` / `message`、没有 `details`,因此既不是 transport 异常也拿不到 `resultPersistenceStarted`,直接跳过对账;而 `ConnectTimedout / ReadTimedout / WriteTimedout → 504`、`ErrorSource::Upstream → 502` 都可能发生在 api-server 已完成 OSS PUT 之后。 +- 决策二:新增 `isGatewayUnknownOutcomeError`,放在 `services/apiClient.ts` 而不是 image-editor——网关在所有接口前面,任何有副作用的 inline 写接口都有同一问题。只收 `GATEWAY_UPSTREAM_ERROR` / `GATEWAY_UPSTREAM_TIMEOUT` / `GATEWAY_PROXY_ERROR` 三类。`GATEWAY_RATE_LIMITED` / `GATEWAY_CONCURRENCY_LIMITED` / `PAYLOAD_TOO_LARGE` 是在网关就被拒、根本没到应用,属于确定失败,收进来会让普通节流也弹出「请核对素材库」,变成与本条镜像的反向谎报。 +- 两条共性:都是用代理信号代替事实——用「占位在不在」代替「操作完成没有」,用「有没有 HTTP 响应」代替「应用层有没有表态」。两处都是没有去读被代理的那个事实的真实形状。 +- 验证:新增四条用例(网关 504 触发对账、网关 429 不触发、应用层无标记 502 不触发、快照无 dialog 时不套用快照)。同时破坏两处修复后,四条精确变红。后两条是对照用例,专门守住「放宽判据不得退回反向谎报」。`vitest src/components/image-editor` 907 通过 / 72 文件,typecheck、eslint、check:encoding 通过。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-03 完美像素链路复查:补齐第三处静默分支并区分两条结果文案 + +- 背景:对账判据与网关分类修完之后做的整链复查,目标是找遗留与新引入的问题,不是重复已知项。 +- 遗留(第三处静默):成功路径拿到 `result.project` 后,若本地占位已被用户删除则直接 `return`。不套用快照是对的(删除意图胜出),但什么都不说。此前已修过同一形状的两处(`!result.project` 分支、对账早返回),这是第三处,复查才发现。既有用例 `does not apply a completed project after the perfect-pixel placeholder was deleted` 只断言「不套用」,没断言「要说话」,所以也没挡住。 +- 新引入(文案混用):上一次修复让「对账发现已收口 + 本地占位已删」这一支沿用了「结果已保存到素材库,画布占位已不存在」。**两种情况的事实不同**——快照里没有 dialog 时服务端 completion 返回 `Ok(None)`,画布上确实没有结果图层;而 dialog 已收口时服务端画布上**有**结果图层,只是本地按删除意图没套用,重新加载即可见。用前一条文案会让用户以为画布上没有,再做一遍,正是本链路要消除的重复创建。 +- 决策:两条文案抽成常量并按事实分派——`PERFECT_PIXEL_ASSET_ONLY_NOTICE` 用于「服务端画布也没有」,`PERFECT_PIXEL_APPLIED_REMOTELY_NOTICE` 用于「服务端画布已有、本地未同步」。四个使用点各归其位。 +- 测试补位过程值得记:第一次回归验证只有 1 条变红——说明「对账已收口 + 本地已删」这条分支根本没有用例,文案改动是无覆盖的。补上该用例后再破坏,2 条同时变红。**如果止步于第一次验证,就会把一处无覆盖的改动当成已验证。** +- 复查中核过、确认不是问题的两点:其一,网关放宽的作用域正确——`/api/*` 走 `is_generic_api_proxy_path`,读超时默认 `3600` 秒,远高于服务端 90 秒预算与客户端 120 秒,次序是 `90 < 120 < 3600`,网关不会在服务端合法工作期间截断,它合成 502/504 只可能是连接失败或进程不可用,确属未知结果;唯一变数是有人把 `GENARRATIVE_PINGORA_GATEWAY_UPSTREAM_API_READ_TIMEOUT_SECONDS` 调到 90 秒以下。其二,`projectHasUnresolvedGenerationDialog` 由 `some(...)` 改为「取首个匹配再判」存在极低风险的语义收窄,同 id 多 dialog 时行为不同,但 id 唯一,实际不可达。 +- 验证:`vitest src/components/image-editor` 908 通过 / 72 文件,typecheck、eslint、check:encoding 通过。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-03 inline 占位补到期清理,收掉存活窗口引入的回归 + +- 缺陷:存活窗口把「立即刷新也能清掉孤儿占位」这条旧行为换掉了。剥离只在项目首次加载执行一次,窗口内被保留的占位再没有任何东西会重新判定——页面保持打开就一直转,必须等到用户下一次加载且距创建已满窗口才收口。这是引入 TTL 时的已知取舍,本次补上。 +- 复查中发现的关键事实:**手动收口入口本来就存在**——`requestRemoveCanvasGenerationDialog` 已接入键盘快捷键,对 `generating` 占位会先弹确认再删。所以缺的从来不是删除手段,而是「它已经死了」这个信号;占位看起来和正在干活一模一样。 +- 决策:加一次性到期定时器,到点走与加载期**完全一致**的处置——移除 + 同一条文案(抽成 `DEAD_INLINE_PLACEHOLDER_NOTICE` 共用)。未采纳「标记 failed 而不移除」:`failed` 会被自动保存持久化,而剥离只处理 `generating`,那张卡片会跨刷新长期存在,与当初选「刷新后占位消失」的用意相反,把一次性噪音变成永久残留。 +- 三条必须守住的实现约束,都写进了 hook 的文档注释:其一,到期回调只推进 tick 让 effect 重跑,判定始终在 effect 体里用当前 dialogs 和当前时间做——挂上定时器之后占位可能已被拥有者会话正常收口,按闭包旧值行动会清掉一个已完成的占位;其二,清理用底层 `removeCanvasGenerationDialogById` 而不是 View 的 `removeCanvasGenerationDialog`,后者是用户主动删除的语义(写 `delete-generation-result` 历史、清空选中、切回选择工具),自动清理记用户没做过的历史、抢走当前选中态都是错的,加载期剥离同样不做这些;其三,本会话自己在途的占位不会被误清(客户端 120 秒就 abort,catch 会把它推离 `generating`),但不依赖该推理,靠第一条的重新判定兜住。 +- 作用面划分:`dropDeadInlineGenerationPlaceholders` 跑在**快照**上、只在加载时执行;`collectExpiredInlineGenerationDialogIds` / `resolveNextInlineGenerationDialogExpiryAt` 跑在**内存 dialog** 上、供页面打开期间使用。同一条规则、两种数据形状,边界(到期时刻含等号不算过期)与缺时间戳的兜底方向都保持一致。 +- 到期时刻可精确计算,所以挂一次性定时器而不是轮询;多给 50ms 余量,避免贴着到期时刻醒来判定为未到期、白白多挂一轮。 +- 验证:纯函数四条用例覆盖边界、队列型与已终态不到期、缺时间戳立即到期、最早到期时刻;hook 四条覆盖到期清理、醒来重新判定(占位期间被收口则不清)、队列型永不挂定时器、已超窗立即清理。去掉定时器重挂逻辑后第一条变红。`vitest src/components/image-editor` 916 通过 / 73 文件,typecheck、eslint、check:encoding 通过。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-03 到期清理链路复查:一个被证伪的假设与它留下的用例 + +- 复查怀疑:到期定时器要熬三分钟,而 `canvasGenerationDialogs` 变动很频繁(提交工作流三十余处变更点,队列型生成轮询期间持续更新状态)。把 effect 依赖挂在数组身份上,看起来会被无关变动不断重挂定时器、永远等不到触发,整个机制静默失效。据此把定时器改挂在计算出的到期时刻上,并加 ref 读取当前 dialogs。 +- 结论:**假设是错的**。为它写的回归用例(每秒一次无关变动、持续到超窗)在改回数组依赖后仍然通过——数组身份变化会让 effect 重跑,而 effect 体每次都重新判定到期,频繁变动带来的是更频繁的判定,不比定时器差;不变动时数组稳定,定时器正常存活。两条路径都收口。 +- 处置:撤回 `useMemo` + `useRef` 的改动,回到更简单的数组依赖版本——既然简单版本本来就正确,多出来的间接层没有收益。用例保留,但注释改写为它**实际证明**的性质:判定必须留在 effect 体里;将来若把它挪出去(例如只在定时器回调里判定),这条不变式才会真的失效,用例届时会变红。 +- 记这一条是因为过程本身有价值:先假设、再写用例、用例证伪假设、据此撤回改动。若跳过验证直接保留那次「修复」,就会在没有缺陷的地方永久留下一层多余的间接。本次会话里同类错误(凭推断得出结论而不验证)已出现多次,这次是验证挡住了。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-03 外部 API skill 文档同步 style 的提示词注入语义 + +- 缺陷:`references/requests-and-outputs.md` 把 `style` 描述为「只控制 deterministic post-processing」,缺了本分支给它加的另一半语义——`pixelArt` 会在发给 provider 的提示词末尾追加一行像素风约束。 +- 归因是文档漂移而非遗漏:`docs/openapi/genarrative-external-v1.openapi.json` 的两处 schema **一直是对的**,连具体子句都写了。master 的 `c00dd099e` 把 `api-selection.md` 拆成四篇新参考文档时是从注入之前的版本重写的,于是 skill 文档退回旧语义,而 OpenAPI 保持正确。 +- 我上一轮合并时的核查不到位:只 grep 了「`byte-for-byte` 那个错误说法有没有复活」,确认没有就收工。**验证旧错误的缺席不等于验证新事实的在场**,两者要分别查。 +- 决策:让 skill 文档与 OpenAPI 对齐,措辞不新造。改动限于三行——说明它同时追加提示词子句与启用后处理、`none` 一档不追加也不后处理、`pixelArt` 一档追加一行且是追加而非替换。 +- 刻意不复制子句字面文本:Rust 常量是真值源,OpenAPI 已复制一份,skill 文档再抄第三份就是把同一事实摊到三处——这次漂移正是这么发生的,只是方向相反。文档改为指向 OpenAPI 并写明「本指南刻意不复制」,让下一个读到的人知道那是有意为之而非遗漏。 +- 校验面已确认:这批文档由 `external_skill_api.rs` / `external_mcp.rs` 以 `include_str!` 编译期内联,SHA 在运行时从内容算出、测试只断言「算出的与返回的一致」,没有钉死具体摘要,改文档无需同步任何清单。api-server 700 通过 / 3 失败(`wallet_refund_outbox` 本机环境失败,与基线一致)。 +- 关联文档:`docs/openapi/genarrative-external-v1.openapi.json`。 + +## 2026-08-03 到期清理误删本会话在途占位:补归属登记与前置阶段预算 + +- 缺陷:到期清理只按 `generationStartedAt + 180 秒` 删除 `requiresLiveSession` 且 `generating` 的占位,区分不出它属于已死会话还是本会话仍在执行。占位在创建后还要走源图解析/直传和 `flushProjectPersistence` 才轮到 POST,而 `snapEditorImageToPixelArt` 的 120 秒**只从最终 POST 开始计**。前置阶段慢起来越过窗口时,定时器会删掉本会话正在用的占位并把删除持久化,随后 POST 因占位不存在返回 `409`;若删除的落库晚于 POST 到达,则 completion 找不到占位返回 `Ok(None)`,结果只进素材库、不落画布。 +- 我写在 hook 注释里的安全性论证是错的,两条都错:其一「客户端 120 秒就 abort,180 秒时不可能还是 generating」——120 秒不覆盖前置阶段;其二「不依赖该推理,到期重新判定本身兜得住」——重新判定只能识别**已经收口**的占位,识别不出**仍在合法运行**的占位,后者正处于要被删除的那个状态。第二条错得更本质:它给了自己和读者一道并不存在的第二防线。 +- 前置阶段此前完全无界:直传 `postEditorDirectUploadFile` 是裸 `fetch`、没有 signal,`saveEditorProjectLayout` 的 `requestJson` 没传 `timeoutMs`(同文件其余接口都写了),而 `composeAbortSignal` 在缺失时不设任何默认值。两者各自还有重试(上传最多 3 次尝试、布局保存最多 4 次)。 +- 决策一(归属登记):`activeInlineGenerationDialogIdsRef` 记录本会话仍在执行的占位 id,创建后**紧挨着**注册(中间不能有 await,否则留出「已存在但未登记」的窗口),`finally` 释放;到期清理跳过其中的 id。到期清理本来就只该针对别人留下的孤儿。 +- 决策二(整段预算而非逐请求超时):给「占位创建 → POST 发出」整段 40 秒预算。逐个请求加超时的最坏总时长会因重试累加到远超 180 秒窗口,窗口的前提仍不成立;整段封顶后客户端最坏 40 + 120 = 160 秒,落在窗口内并留 20 秒余量。超时抛裸 `Error` 而非 `ApiClientError`,归入未知结果走对账——上传可能已完成、素材可能已落库,正是对账要处理的情形。 +- 决策三:`saveEditorProjectLayout` 补 `timeoutMs: 60_000`。这是独立缺陷,与本条无关也该修——它被 `flushProjectPersistence` 同步等待在提交路径上,挂住会连带把占位拖过窗口。 +- 「窗口计时起点应改为 POST 发出时刻」未采纳:归属登记之后窗口不再需要覆盖本会话,只用于跨标签页;而对孤儿占位只有创建时刻这一个可用时间戳,改起点无从实现。整段预算已经让窗口的前提重新成立。 +- 验证:新增两条用例——本会话持有期间超窗不清理、释放归属后同一超窗占位立即清理。去掉归属过滤后两条同时变红。`vitest src/components/image-editor` 923 通过 / 74 文件,typecheck、eslint、check:encoding 通过。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-03 归属登记后的链路复查:一处新引入的忙等、两处无界读写、一处过紧预算 + +- 复查对象是上一条修复本身,找的是新引入的问题,结果三处新增、一处遗留。 +- 新引入(忙等,最严重):归属过滤只加在了 `expiredIds`,没加在 `resolveNextInlineGenerationDialogExpiryAt`。被本会话持有的超窗占位不进 `expiredIds`,却仍被算出一个**已经过去**的到期时刻,`delayMs` 塌成 50ms,定时器触发 → tick → effect 重跑 → 状态没变 → 再挂 50ms,变成每 50 毫秒一次 `setState` 的忙等,持续整个持有期。改为先按归属过滤出 `unownedDialogs`,两处判定共用。 +- 该忙等的可达性不是理论的:前置预算加 POST 之后,catch 里还要做对账 `loadEditorProject`,而归属要到 `finally` 才释放,这段完全可能越过存活窗口。 +- 遗留(无界读取):`loadEditorProject` 同样没传 `timeoutMs`,而 `composeAbortSignal` 在缺失时不设默认值。它正是对账路径上的读取,挂住会让 catch 迟迟不结束,连带把占位拖过窗口——即上一条忙等的直接助推。补 `60_000`。至此该文件里落在完美像素链路上的三个接口(POST、布局保存、项目读取)都有了显式上界。 +- 新引入(预算过紧):上一条把提交前置阶段封顶在 40 秒。该阶段在源图是 inline / 未登记时会真的直传一张画布图层,几 MB 的图在较差移动网络下要几十秒,40 秒会把原本能成功的操作改判为失败——**用「无界」换「过紧」同样是回归**。改为 90 秒,并把存活窗口从 180 秒同步提到 240 秒,维持 90 + 120 = 210 < 240 且留 30 秒余量。 +- 三个常量构成一条跨文件不等式(提交前置预算、客户端 POST 超时、占位存活窗口),任一处被单独调大都会破坏它,后果是跨标签页误删。新增用例把这条不等式连同 30 秒余量一起钉住。本会话自己的占位另有归属登记豁免、不依赖该窗口,所以窗口只需覆盖跨标签页那一侧——这一点也写进了常量注释。 +- 验证:忙等用例用 `vi.getTimerCount()` 直接断言「不该挂定时器」,把 `resolveNext...` 改回未过滤版本后该用例变红。`vitest src/components/image-editor` 924 通过 / 74 文件,typecheck、eslint、check:encoding 通过。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-03 补齐 editorProjectClient 超时契约的断言 + +- 缺陷:给 `saveEditorProjectLayout` 与 `loadEditorProject` 补超时后,`editorProjectClient.test.ts` 的两条精确参数断言失败。`toHaveBeenCalledWith` 要求参数个数与内容完全匹配,新增第四个参数即不匹配。CI(任务 1645)在 `aa8ea401a` 上报出其中一条,另一条由本地复跑发现。 +- 处置:更新断言把 `{ timeoutMs: 60_000 }` 写进去,而不是放宽成 `expect.anything()`。超时是契约的一部分——这两个接口一个被 `flushProjectPersistence` 同步等待在提交路径上、一个在未知结果对账路径上,没有上界会把在途占位拖过存活窗口。断言写死之后谁删掉它测试就会红;放宽则等于让刚建立的上界失去看守。已验证:去掉生产代码里的两个超时,两条断言同时变红。 +- 真正的问题是验证方式而非测试:改的是 `src/services/image-editor/editorProjectClient.ts`,验证却只跑了 `vitest src/components/image-editor`,改动面与验证面完全对不上。这个盲区在本次会话中期分析另一份 CI 日志时已由我自己指出过,却没有改掉习惯,于是同一个盲区再次漏出——而且这次不是难复现的跨文件竞态,是本地一跑就红的确定性失败。 +- 约定:这条链路横跨 `src/components/image-editor/` 与 `src/services/image-editor/`,往后验证至少同时覆盖两处。不跑全量套件——本机有九条稳定的环境失败(符号链接、`0600` 权限模式、缺客户端 AppData 配置),噪音大于收益。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-03 对账整段设界,并把提交前置预算变成真正的取消 + +- 缺陷一(对账可被素材库读取永久挂住):对账用 `Promise.all` 同时等项目快照与 `refreshAssetLibrary`,而 `loadEditorAssetLibrary` 至今没有 `timeoutMs`(`composeAbortSignal` 缺失时不设默认值)。`.catch()` 只接住拒绝、接不住永不 settle;catch 体内的 await 不返回,`finally` 就永远不执行——占位归属登记与源图层锁都释放不掉,而到期清理又豁免已登记的占位,页面永久停在 `generating`,同一源图也无法再次操作,刷新前无解。 +- 这个洞是上一条修复留下的:给 `loadEditorProject` 加界时写的注释已经把机制说对了(「挂住会让 catch 迟迟不结束」),却只给同一个 `Promise.all` 里两个 await 中的一个加了界。逐个接口补超时这条路已经漏过一次。 +- 决策一:给**整段对账**设 75 秒上界,而不是继续逐个接口补。往对账里加任何新的 await 都自动受约束。超时必须**解析为 null 而不是拒绝**——这段代码本身位于 catch 内,抛出会穿出整个 async 函数,而调用方是 `void snapSelectedLayerToPerfectPixels(...)`,结果是未处理的 rejection;解析为 null 则落进既有的「权威项目快照读取失败」分支,语义正好一致。 +- 缺陷二(预算只停止等待、不取消):`withPerfectPixelPrePostBudget` 原先只是 `Promise.race`,超时后底层继续跑。直传 `fetch` 没有 signal,被放弃的上传会一路走到 confirm 并注册对象,用户重试再产生一份。 +- 决策二:把同一个 `AbortSignal` 贯穿凭证请求、直传 POST 与 confirm 三步,由前置预算到期时 `abort`。只中止直传会留下未 confirm 的 OSS 对象,只中止 confirm 又会让实体已写入却无记录——要停就整条链一起停。`requestJson` 从 `init.signal` 取信号并与自身超时合成,所以凭证与 confirm 只需在 init 里传入。 +- 未采纳评审建议的全量贯穿(再覆盖重试等待与 flush/save):那要再动两个模块,而收益只是少产生一些用户不可见的存储孤儿;`flushProjectPersistence` 现已有 60 秒上界,最多多挂 60 秒后自行结束。改动面从四个模块降到两个,绝大部分收益保留。 +- 后果分级要说清:缺陷一是永久性的 UI 卡死,缺陷二只是存储层孤儿对象(confirm 注册的是 asset object,不是素材库条目,用户基本不可见)。两者同为 P2 但不同量级。 +- 验证:新增用例让项目快照读取永不 settle,断言 120 秒后完美像素状态回到「空闲」——即 `finally` 确实执行。去掉对账 deadline 后该用例变红。`vitest src/components/image-editor src/services/image-editor` 974 通过,typecheck、eslint、check:encoding 通过。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-03 完美像素第一批:稳定 operation 与单事务数据库提交 + +- 被纠正的旧边界:完美像素原先在 OSS PUT / HEAD 后顺序执行 asset object confirm、project resource、账号素材与 canvas completion 四次独立数据库调用。任一后段失败或本地 timeout/drop 都可能留下可见的部分事实;重试又使用随机 task / object / resource / asset ID,无法把同一次逻辑操作识别为重放。历史 decision 条目保留为当时证据,本条取代其“继续沿用非事务顺序”的现役结论。 +- operation identity:规范化 `canvasCompletion.dialogId` 即 operationId,由 owner + project 共同限定作用域。`taskId = pixel-art-snap-{operationId}`,让响应丢失时的客户端无需服务端回包即可计算;asset object、project resource 与账号素材 ID 用 `SHA256(editor-pixel-art-result-v1 + owner + project + operation + record kind)` 的前 16 bytes 稳定派生。请求 fingerprint 为 64 位 SHA-256,覆盖来源 object key、来源和输出字节摘要、来源资源、素材类型、规范目录 / 标签、canonical generationInputs、canvas completion 与算法版本;最终 OSS object key 必须携带该 fingerprint。 +- 原子边界:OSS PUT / HEAD 仍在事务外。验证上传后,`persist_editor_pixel_art_result_and_return` 受 editor generation runtime service identity 保护,在一次 `try_with_tx` 中校验并写入 asset object、project resource、editor asset,并在同一事务内读取最新 canvas、按当前 revision 完成 dialog。handler 禁止在 procedure 前调用旧 `confirm_asset_object`、resource、asset 或 completion helper。该保证只覆盖这四类结果事实;前置 owner-scoped 项目 / 素材读取仍可能沿用既有 `ensure_default_canvas / ensure_default_asset_folder` 懒建基础记录,不宣称整个 preflight 对数据库零写入。 +- 重放与冲突:三条稳定记录完整且业务内容相同才返回 `AlreadyApplied`,重放不得再次执行 layout CAS 或推进 revision;任一稳定 ID 指向不同内容、同 object location 被其他 ID 占用、同 operation 输入漂移或三条记录只有部分存在都失败关闭并映射 HTTP `409`。时间字段不参与 exact replay 内容比较。权威 dialog 已删除时三条记录同事务提交、canvas / revision 不变并返回 `DialogMissing`。 +- 未知结果语义:本地 procedure future 的 timeout/drop 不能撤销远端事务,所以首个 PUT 后继续设置 `resultPersistenceStarted=true`;该标记现在表示“OSS 或整笔数据库事务的结果未知”,不再表示数据库可能部分提交。事务失败后允许留下无引用 OSS object,本批不做破坏性删除或历史孤儿清理。 +- 明确延期:本批只交付后端原子性与可重放身份。前端仍需后续批次持久化 operation 请求快照、让素材刷新退出 verdict、轮询项目事实、引入 `pending-confirmation`、刷新后只恢复 GET,并让人工重试复用原 operation;在此之前不能宣称 unknown-result 已端到端闭环。 +- 2026-08-03 第二批边界:generation dialog 持久化版本化 `perfectPixelOperation`,绑定规范化 dialog/operation、固定 `pixel-art-snap-{operationId}` task、稳定来源解析后的完整 POST 请求以及 `submittedAt / reconcileUntil` 整链绝对窗口。只有布局 PATCH 已确认包含该快照才允许首次 POST;素材刷新退出 verdict。响应未知后按稳定 task resource 与 dialog/layer 的原子事务形状有界轮询项目 GET,未终态或读取到期统一保持 `pending-confirmation`,不标普通失败、不自动重放。显式人工重试必须 byte-for-byte 复用持久请求和同一 identity,当前 UI、来源、目录、类型或标题变化不得改变请求;无效快照失败关闭。首次提交或重试在途时 owner、project 或组件生命周期改变后,旧响应的素材、项目、提示和对账副作用全部忽略。 +- 2026-08-03 前端 hydrate 收口边界:hydrate 后对有效 `generating` / `pending-confirmation` operation 只做 GET-only 恢复,禁止自动 POST、上传或重建请求;切换 owner/project、卸载或权威 revision 前进时取消旧观察。新写入的 v1 快照固定使用 75 秒跨度;读取侧兼容第一批曾写入的 240 秒 v1 形状以保留 operation identity。跨设备时钟让 `submittedAt` 落在可接受的未来区间时,先把它规范化到当前时间,再把实际截止压到 `min(持久截止, 规范化 submittedAt + 75 秒, 当前时间 + 75 秒)`;这样既不借兼容延长观察,也不会写出 `reconcileUntil < submittedAt` 的二次 hydrate 无效形状。有效 durable operation 退出 legacy `requiresLiveSession` TTL,任何标签页都不得清理;无 operation journal 字段的历史 inline 孤儿继续按 TTL 兼容,且剥离时同步顶层与 `canvas.layers` 两份布局。轮询耗尽仍保留 operation 和待确认状态,只有显式重试进入第二批 exact replay。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-04 完美像素第二批:object-only 上传与 GET-only unknown 收口 + +- 上传边界:inline 源图不再调用会在 confirm 后继续换签的完整上传 helper,只执行 `ticket → OSS PUT → confirm → objectKey`。完美像素在创建占位后立即确定 dialog / operation ID,并把它作为稳定 upload ID;源 fetch、图片解析边界、ticket、PUT、confirm 共用前置预算的同一个 `AbortSignal`。完整 helper 的 signed URL 调用也防御性透传 signal。这样 confirm 成功后没有新的换签失败窗口,同一 operation 的内部重试也不会换对象路径。 +- verdict 边界:POST 成功不再直接采用响应体的 `project`,POST 中的 `asset` 也只有在项目 GET 已确认终态且 response task / resource 与 GET resource 一致时才允许本地 upsert。项目 GET 是唯一 verdict 来源;匹配 task resource + 已收口 dialog/layer 为画布成功,无 dialog + 匹配 task resource 为 asset-only,resource 已出现但 dialog 仍 generating 继续等待,无 dialog 且无匹配 resource 也继续等待。重复匹配 resource 或已收口 dialog 与 resource / layer 不一致失败关闭为 conflict,不猜测成功。 +- 时间边界:`submittedAt / reconcileUntil` 从稳定请求快照写入时形成单个 75 秒整链绝对窗口;POST 正常回包或异常都不能替同一次 operation 续期,只有用户显式 exact replay 才开启新的 75 秒窗口。每轮先立即 GET,一次读取即使发现窗口已过期也必须执行;随后退避上限 5 秒。读取始终失败或窗口耗尽时保持 `pending-confirmation`,不声称素材已保存。滚动升级时兼容读取旧 240 秒 v1 journal;hydrate 会先把可接受的未来 `submittedAt` 规范化到当前时间,再把截止收紧到规范化提交时间和当前时间各自允许的 75 秒上限,并在下一次布局持久化时写回仍可再次 hydrate 的收紧形状。 +- identity 与删除:unknown 保留原 dialog 上的完整 `perfectPixelOperation`,人工重试原样发送持久化 request;普通按 ID 删除和随源图层删除均保留未收口 durable operation。对话框删除入口会激活原占位并提示继续核对 / 原样重试;Delete 快捷键若只命中受保护 operation 则在写历史、清选择或执行副作用前完整 no-op,混合选择只统计并删除其它可删除目标。刷新恢复只做 GET,owner / project 切换或卸载会取消旧观察。完全没有 operation journal 字段的 legacy inline 占位仍沿用既有 TTL;字段存在但损坏时保留失败关闭标记,不能降级成可清理的旧占位。 +- 投影刷新:`refreshAssetLibrary` 只在项目终态后 best-effort 触发,并同时吞掉同步 throw 与异步 reject;永不 settle 的刷新 Promise 也不参与 await,因此不能阻塞项目应用、提示或 `finally` 解锁。 +- 验证:第二批定向覆盖 POST 成功后仍走 GET、unknown 的 pending → completed、no-dialog 正反证据、75 秒绝对截止与 5 秒退避、过期后至少一次 GET、stable upload ID、object-only 上传、整条 signal、刷新永挂 / 同步抛错 / 异步拒绝、删除保护、hydrate GET-only 与 byte-for-byte replay。Atomic 全局相对断言及其它文档清理在本次第二批提交时尚未纳入,后续由下一条第三批完成。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-04 完美像素第三批:移除全局 Atomic 相对断言并完成文档收口 + +- 竞态根因:过期预算用例先读取进程级 `EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH`,再在断言前读取一次;相邻 Drop 用例可在两次 load 之间创建或释放 guard。相对 before/after 与绝对 `== 0` 一样没有跨测试隔离,Rust 默认并行时仍会随机失败。 +- 测试边界:`pixel_art_snap_permit_reports_exhausted_budget_without_waiting` 只构造过期 deadline 并断言 `504`,不再观察全局队列深度。`pixel_art_snap_queue_depth_returns_to_zero_after_guards_drop` 继续作为独立 Drop 契约用例;未引入 `--test-threads=1`、全局串行锁或其它掩盖手段。 +- 文档收口:后端数据契约、连接池 Drop 说明、图片画布方案、decision log 与 pitfalls 同步撤回“相对断言可消除并行竞态”的错误保证。连接池 lease 的 Drop 只保证本地 slot / permit 可回收,不表示 handler timeout/drop 能取消或回滚已经发出的远端 procedure。 +- 验证结果:`cargo test --manifest-path server-rs/Cargo.toml -p api-server pixel_art_snap` 为 17 通过 / 0 失败;`npm run check:rustfmt`、`npm run check:encoding`(5153 个文件)与 `git diff --check` 通过。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-04 inline 占位 ownership 释放改为可观察信号 + +- 覆盖缺口:2026-08-03 的“释放归属后同一超窗占位立即清理”测试在 `rerender` 时同时创建了新的 dialogs 数组和 callback,effect 实际由这些依赖变化唤醒;它没有证明 `finally` 里单独执行 `Set.delete()` 会重新判定。生产实现把稳定 ref 对象放进依赖,但 React 不观察 `.current` 内容变化,因此旧测试与旧实现之间存在同一个盲区。 +- 决策:Set 继续作为首个 await 前同步可见的 ownership 真值,但封装进 `useInlineGenerationPlaceholderOwnership`,不再向 View 暴露可变 ref。`claim / release` 只有在 membership 真变化时才推进 version;到期 effect 同时依赖稳定 `has` 和 version。首次提交与人工 exact replay 通过同一份 ownership 登记 / 释放,hydrate GET-only 恢复只查询这份 ownership 来避开本页 live Promise,不能在 View 创建第二份 registry。 +- 时序边界:`claim` 仍紧挨占位创建且早于任何 await;`release` 仍位于 `finally`。version 只负责 React 通知,不替代同步 Set,也不清理 observed recovery key;否则可能在 live Promise 尚未退出时启动第二条 GET。重复 claim / release 为幂等 no-op,不额外触发 effect。 +- 验证:hook 定向测试使用生产 ownership hook、固定 dialogs 数组和固定 callbacks,并包在 StrictMode 中;单次 render 后先 claim 取消到期 timer,推进到超窗仍不清理,再仅 release 唤醒 effect并清理一次。重复 claim / release 分别保持 version `1 / 2`,删除与通知均只发生一次。hook 定向测试 10 条、generation workflow 定向测试 81 条通过;`npm run typecheck`、全仓 `npm run lint:eslint`、`npm run check:encoding` 与 `git diff --check` 通过。未追加其它测试或全量测试套件。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-04 项目快照对账不再按重复 dialog ID 首项短路 + +- 缺陷:项目快照查询 helper 只返回第一个同 ID generation dialog。legacy / External API 画布若含重复 ID,首条已收口、后条仍 unresolved 时,通用 queued completion 会漏掉用于收口的第二次 GET;完美像素还可能把不满足后端唯一性契约的快照误判为成功。 +- 决策:快照 helper 返回全部同 ID 原始记录。通用 queued completion 只要任一记录未收口就执行既有第二次 GET;完美像素要求 operation dialog 唯一,命中多条时失败关闭为 `conflict`,不按其中任意一条猜测结果。无需改变 hydrate、删除、后端或 OpenAPI。 +- 验证:两条 `duplicate` 定向用例通过;`npm run typecheck`、改动文件级 ESLint 与 `npm run check:encoding` 通过,未运行目录或全仓测试套件。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-04 完美像素对账读取改用请求全生命周期绝对截止 + +- 缺陷:`loadEditorProject` 的 `timeoutMs` 只包住业务 `fetch` 等响应头。缺 token 时的登录恢复发生在该 timer 建立前,401 后的共享 refresh 等待也不受它限制;收到响应头后 timer 已清理,成功和错误响应的 `response.text()` 又可继续永久挂起。任一环节不 settle,完美像素对账都无法重新检查 75 秒窗口,首次提交的 dialog ownership 与图层锁也无法进入 `finally` 释放。 +- 决策:`requestJson` 新增 opt-in `deadlineAt`,从函数入口建立一次 lifecycle signal,覆盖缺 token 补票、业务 fetch、401 refresh 等待、所有 GET attempt、退避和成功 / 错误响应体读取。等待共享 refresh 只取消当前调用者,不把该 signal 传入共享 refresh 请求,避免一次图片对账超时取消 AuthGate 或其它请求正在复用的刷新;signal 到期也不得被鉴权 catch 吞掉后继续发业务请求、清 token 或广播登录态变化。未传 `deadlineAt` 的调用保持既有 `timeoutMs` 行为。 +- 对账边界:窗口内每次 GET 的 deadline 为 `min(reconcileUntil, readStartedAt + 10 秒)`;operation 已过期但从未读取时仍执行一次即时 GET,该例外最多 10 秒。deadline 只把本次读取视为失败,不穿透成未处理 rejection;轮询随后返回 `pending-confirmation` 并让首次提交 / hydrate 恢复的既有清理链执行。 +- 验证范围:`apiClient` 定向覆盖缺 token 与 401 refresh 永久等待、响应体永久等待;`editorProjectClient` 钉住 deadline 透传且普通读取仍保留 60 秒默认 timeout;generation workflow 钉住窗口内和过期单次读取的 deadline。未扩大为全站请求超时迁移。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-04 完美像素 confirm 后 strict 保存失败保留原 operation + +- 缺陷:源图 `ticket → PUT → confirm` 成功后,首次 strict layout flush 仍沿用从上传开始计算的 90 秒预算。预算耗尽会把占位标成普通 `failed` 并解锁;既有重试只接受 `pending-confirmation`,普通按钮遂创建新 dialog、重新上传并换 operation identity。旧 confirmed source object 无法再由用户路径引用。 +- 决策:源准备 90 秒与 operation journal strict save 60 秒拆开。confirm 后立即形成稳定 `perfectPixelOperation`,strict deadline 覆盖等待活动保存、PATCH、revision conflict reload 和 transport retry;到期会取消 strict request/waiter、阻止自动转移或继续 POST,并释放本地活动保存槽。浏览器 abort 不等于远端撤销,迟到 PATCH 仍可能提交,但其本地 Promise 不再触发 POST;后续重试依靠 revision CAS/reload 收口。 +- 状态与重试:POST 尚未发出时的 strict 失败保留 `failed + perfectPixelOperation`,不做结果 GET;原占位提供 exact retry,复用同一 request、source objectKey、dialog/operation/task identity,不重新执行 ticket、PUT 或 confirm。普通完美像素入口把该状态视为未收口,不能创建第二条 operation;普通删除和随源图删除同样保留 identity。 +- 预算不变式:源准备 90 秒加 strict journal 60 秒仍小于 legacy inline 占位 240 秒窗口;POST 只会在 operation journal ACK 后开始,因此不再计入该 legacy TTL。布局保存本身使用请求全生命周期绝对 deadline,覆盖鉴权等待、业务 fetch 与响应体读取。 +- 剩余边界:confirm 成功后浏览器立即崩溃、且 operation 首次 PATCH 尚未落库时,仍可能留下 object-only 记录。这里的 object-only 是指 OSS 中已有私有源图文件、数据库也已有对应 `asset_object / objectKey` 登记,但尚无 `perfectPixelOperation` journal、项目 resource、素材库 asset、完美像素结果或画布结果图层。用户界面不可见且刷新后无法复用该 identity,再次点击可能重新上传;影响限于不可达的源图存储与垃圾记录累积,不代表结果已生成、重复扣费、越权或数据泄露。 +- 本 PR 的修复边界到此为止:只保证 operation 已形成后,strict 保存失败或超时不会丢失 identity、不会重新上传,并且迟到 PATCH 不会继续触发 POST;不继续引入服务端 durable upload journal、上传 reservation、孤儿对象扫描/回收或历史数据清理,也不宣称撤销已发送的 PATCH。彻底消除上述崩溃窗口需要独立设计、评审和交付,不作为本 PR 的合并阻断项。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-04 generation 占位右键删除复用统一请求保护 + +- 缺陷:快捷键删除已在写历史前过滤未收口完美像素 operation,但 generation 占位的右键菜单仍直接调用低层 `removeCanvasGenerationDialogById`。低层会保留受保护 operation,上层却已写入 `delete-generation-result` 历史、清空选择并关闭交互,形成“占位未删但出现伪历史和 UI 副作用”的不一致;普通 generating dialog 也会绕过既有删除确认。 +- 决策:纯 generation-dialog 的右键删除在任何历史或选择副作用前委托给 `requestRemoveCanvasGenerationDialog`。未收口完美像素只激活原占位并显示继续对账/原样重试提示;普通 generating 进入现有确认弹窗;终态占位才执行真实删除。层命令保留低层回调给快捷键和混合选择的既有可删除目标,不扩大本次改动为删除系统重构。 +- 验证:层命令定向测试构造 `pending-confirmation + perfectPixelOperation` 右键目标,断言请求保护入口只调用一次、历史为零、选择保持、低层删除未调用且菜单收口。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-04 完美像素最终 PNG 在 PUT 前执行只读 preflight + +- 缺陷:最终 PNG object key 虽已稳定,目录、画布 completion 和布局大小门禁仍只在 OSS PUT / HEAD 之后的原子 procedure 内判定。可预知的自定义目录缺失、目录越权、重复 dialog 或 2 MiB / 512 KiB 布局拒绝会先产生无引用 OSS object,再返回确定失败。 +- 决策:上传 helper 拆成纯 prepare 与 execute。prepare 只生成精确 object key / request,不访问 OSS;handler 用同一 object key 构造候选 project resource,调用受 runtime service identity 保护的只读 `preflight_editor_pixel_art_result_and_return`。preflight 允许尚未创建的默认目录,要求自定义目录存在且属于 owner,复用 `plan_editor_pixel_art_canvas_completion`,并对 legacy / structured 结果布局执行 2 MiB 总量与 512 KiB 单项门禁。通过后才执行 PUT / HEAD,再调用既有原子 persist。 +- 预算与 unknown 边界:preflight、PUT / HEAD 和最终 persist 共用既有 60 秒绝对 deadline。preflight 失败或超时发生在第一次 PUT 之前,不带 `resultPersistenceStarted`;从第一次 PUT 发出开始继续沿用 unknown 标记和项目 GET 对账。 +- 权威性与剩余风险:preflight 不创建锁、reservation 或新表记录;最终 `persist_editor_pixel_art_result_and_return` 仍在同一事务内重复目录、布局、幂等 identity 和 revision 校验。preflight 通过后若目录或画布并发漂移,最终事务仍可能在 PUT 后拒绝并留下无引用 OSS object;彻底消除该 TOCTOU 需要 durable reservation / journal 或事务协调,不在本 PR 的最小修复边界内。 +- 契约影响:只新增 SpacetimeDB procedure ABI 与生成 bindings;没有表字段、index、migration、HTTP DTO、路由、状态码、OpenAPI 或 shared-contracts 变化。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 ## 2026-08-04 AI 游戏项目 manifest 存储与工作台实时投影 - 存储决策:`.agent/manifest.json` 的版本追加不可变约束由同目录持久专用锁保护,读取旧状态、校验版本前缀、安装临时文件和安装后回读必须处于同一临界区;进程内 Mutex 不能替代跨进程文件锁。 @@ -6114,3 +6437,147 @@ - telemetry 只扫描可见 DOM 文本和 AST 可达的 JavaScript:hidden DOM、字符串/注释、恒假分支、未调用函数和 inert/raw-text 内容不得补齐状态字段;已链接 classic/module 单元沿同一可达扫描口径判定。玩法 identity 保留独立的现有识别口径,不能反向补齐 telemetry。 - CSS `url(...)` 的资产路径保持原始大小写解析,stylesheet 证据必须同时命中实际可见元素;未命中 selector、元素自身或祖先 hidden、以及匹配隐藏规则的节点均不作证。 - 关联:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion.rs`、`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 + +## 2026-08-05 画布图层元数据以资源行为准,读边界补齐 sourceType + +- 背景:结构化画布保存要求图层布局项里的资源权威字段与 `editor_project_resource` 行逐字相等,否则整次 PATCH 报「与项目资源不一致」,而该 400 属于 non-retryable,会被前端保存队列静默吞掉。但读边界并不把这些值原样下发:`sanitize_editor_user_model` 会脱敏内部处理模型、`provider` 被无条件省略(见 2026-07-31 修正抠图内部元数据的普通用户读取边界),`sourceType` 则在结构化保存校验通过后被归还资源行、图层列置空,读回时整个键不存在。客户端拿不到权威值只能自己补——`resolveHydratedLayerModel` 沿来源链推导出展示用生图模型,`hydrateLayer` 把缺失的 `sourceType` 猜成 `uploaded`——再原样回写,判等于是必然失败。前者命中含 2026-07-30 之前抠图派生资源的画布,后者命中所有 generated 图层;两者都在项目重新加载后的首次保存触发,用户侧表现为「改动悄悄没保存」,完美像素因为提交前是严格保存才把服务端原文暴露出来。 +- 决策:被读边界脱敏或不下发的字段,一律以资源行为准,客户端不参与回写。`serializeLayer` 对**挂着项目资源行**的图层(`resourcePersistenceState === 'registered'`)不再输出 `model` / `provider`;缺资源行的自包含 legacy 本地图片序列(角色动画逐帧层等)必须继续输出——服务端 `normalize_structured_canvas_layer_against_resource` 对 `resource == None` 走早退分支,只摘 `assetKind` 就把 item 原样写回,`item_json` 是这类图层元数据的唯一存储,停发会让模型信息在下一次保存后永久丢失。`normalize_structured_canvas_layer_against_resource` 对这两个字段改为直接丢弃而不判等——它们属于纯丢弃字段,判等通过与否都不写回资源行(区别于会合并回资源的 `assetKind` / `generationInputs`),放宽不影响任何持久化状态。`sourceType` 属于意外丢失而非有意脱敏,改为在读边界按图层自己声明的 `resourceId` 回填权威值,口径与既有 `objectKey` / `assetObjectId` 一致;客户端 `hydrateLayer` 同时把缺键回落到资源值作为兜底,不再猜 `uploaded`。 +- 不变式:凡是 owner 读边界会脱敏或省略的图层字段,写边界不得对其判等;凡是写边界要判等的图层字段,读边界必须原样下发或可由资源行回填。改动任一侧时必须同时检查另一侧,只改一侧即构成本条缺陷的复发。 +- 影响范围:`src/components/image-editor/ImageCanvasEditorModel.ts` 的 `serializeLayer` 与 `hydrateLayer`、`server-rs/crates/api-server/src/editor_project.rs` 的 `EditorPayloadMediaReference` 与 `sanitize_editor_payload_media_value`、`server-rs/crates/spacetime-module/src/editor_project_storage.rs` 的 `normalize_structured_canvas_layer_against_resource`。不修改 SpacetimeDB schema、迁移或绑定,不改动历史数据,不改变对外契约。 +- 遗留:历史资源行的 `model` 列仍存有 2026-07-30 之前写入的内部处理模型,读边界继续脱敏它。把该列回填为源生图模型、原值移入 `generationInputs.mattingModel`,并据此删掉两侧的脱敏与推导逻辑,另行排期,不在本次范围。 +- 验证方式:前端覆盖已登记资源的图层产物不含 `model` / `provider`、自包含本地序列仍保留并可往返,以及「序列化后去掉 sourceType → hydrate → 再序列化」仍为 `generated` 的往返不变式;api-server 覆盖读边界按 `resourceId` 回填 `sourceType`、且缺资源行的 legacy 本地序列保持自带值;spacetime-module 覆盖资源行存内部处理模型而图层带推导值时不再报错、读回时 `sourceType` 键确实被丢弃、以及显式冲突的 `sourceType` 仍失败关闭。运行 `npx vitest run src/components/image-editor`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_project::`、`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml --all-targets`、`npm run typecheck`、`npm run check:encoding`、`npm run check:rustfmt`。spacetime-module 的单测二进制在 Windows 本机链接失败(缺 SpacetimeDB 宿主符号),本机只能做到 `cargo check --all-targets`。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-05 完美像素占位恢复为可删除,删除保护条款作废 + +- 背景:2026-08-04「完美像素第二批:object-only 上传与 GET-only unknown 收口」把「未收口的完美像素 operation」定为不可删除,理由是结果 unknown 时要保留 identity 供原样重试;同日「confirm 后 strict 保存失败保留原 operation」又把 POST 尚未发出的 `failed` 一并纳入,理由是从源图重开会重新执行 ticket / PUT / confirm,让上一次已 confirm 的源图对象失去引用。两条叠加后,右键删除、Delete 快捷键、随源图层删除、多选删除全部豁免该占位,到期清理也不覆盖它,用户画布上出现了删不掉的元素。 +- 缺陷判定:删除占位**不撤销任何在途请求**——完美像素没有取消接口,结果照常落库并进素材库;服务端 completion 发现 dialog 已不在会返回 `DialogMissing`,客户端本就有对应提示。封锁买到的只是「结果自动回填画布」这一便利,代价却是用户文档不可编辑。至于孤儿源图对象,「confirm 后 strict 保存失败保留原 operation」自己的「剩余边界」一节已把同类残留定性为「影响限于不可达的源图存储与垃圾记录累积……不作为本 PR 的合并阻断项」;为避免同一种残留而禁止用户删除自己画布上的元素,权衡不自洽。 +- 决策:本条取代上述两条中关于**删除**的条款。完美像素占位在 `generating` / `pending-confirmation` / `failed` 任何状态都可删,且不弹确认。低层 `removeCanvasGenerationDialogById` 恢复为无条件删除——低层对上层抗命正是「占位未删却写出伪历史」的根因;随源图层删除不再豁免;Delete 快捷键与多选删除不再过滤该目标。删除确认的判据收敛为具名的 `requiresGenerationDeleteConfirmation`:现成弹窗讲的是「已消耗的泥点不会返还」,只对计费生成成立,而完美像素 `generation_cost_mud_points = 0`。运行时标记 `perfectPixelOperationInvalid` 时必须同时丢弃 `perfectPixelOperation`,与 `hydrateCanvasGenerationDialog` 口径一致,不再留下「重试按钮因 invalid 消失、快照却还挂着」的矛盾态。 +- 保留不变:保留 operation identity 供「在原占位原样重试」的能力不变,重试仍复用同一 request、source objectKey 与 dialog / operation / task identity。从源图重新发起仍被 `existingOperation` 闸拦住,另行处理。带 operation 的占位继续豁免 inline 占位 TTL 到期清理——用户主动删除与系统替用户删除是两回事。 +- 已知后果:删掉 `pending-confirmation` 占位后,刷新恢复不再对账这条 operation;结果若已生成只会出现在素材库,不回填画布。这是用户主动放弃的结果,不是回归,不得据此判定为缺陷。 +- 影响范围:`src/components/image-editor/useCanvasGenerationDialogs.ts`(删除 `isUnsettledPerfectPixelOperationDialog`,新增 `requiresGenerationDeleteConfirmation`)、`ImageCanvasEditorView.tsx` 的 `requestRemoveCanvasGenerationDialog`、`useImageCanvasLayerCommands.ts` 的 `deleteSelectedLayer`、`useImageCanvasGenerationWorkflow.ts` 的恢复失效分支。不修改服务端、契约或数据。 +- 验证方式:覆盖三种状态下按 id 删除与随源图层删除均真正移除、完美像素占位任何状态都不要求确认而普通 `generating` 占位仍要求、快捷键与混合选择删除会写入历史并触发副作用、在途删除后已知失败退回全局提示、删除后 applied verdict 走 asset-only 提示且不回填画布、标记失效时快照被丢弃。运行 `npx vitest run src/components/image-editor`、`npx vitest run src/components/platform-entry`、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-05 完美像素请求账本移出项目布局,严格布局保存整体删除 + +- 背景:完美像素是唯一没有 durable job 的生成路径——免费、同步、不走 `enqueue_editor_generation_job`,服务端没有任何一行记录「这次请求发出过」。为了让刷新后还能 GET-only 对账,请求账本 `perfectPixelOperation` 被写进了**用户的画布布局**,并由此派生出一条严格布局保存通道:发 POST 前必须拿到布局保存的 revision ack,否则整条链路中止。该耦合直接造成两类缺陷:一是账本寄生在用户数据上,占位一度被禁止删除(已由同日「完美像素占位恢复为可删除」作废);二是任何布局校验失败都会升级成完美像素的硬阻断,「画布图层元数据以资源行为准」那条缺陷正是因为严格保存才从静默重试变成用户可见的死锁。 +- 决策:账本改由 `src/components/image-editor/perfectPixelOperationStore.ts` 存在本机 localStorage,按 owner + project 双键隔离;布局里只留 `perfectPixelOperationId` 标记,用来把这类占位与队列型占位区分开。发 POST 前先同步写本机账本,再**尽力而为**地保存布局;布局保存失败不再让客户端拒发 POST。(**更正**:本条原文写作「布局保存失败不再拦 POST,只是把结果降级为『只进素材库』」,不准确——服务端要求占位此前已持久化,占位从未落库时 POST 会被 409 拒收。被解除的是「客户端在拿到 revision ack 前拒发」这一**客户端侧**阻断,不是端到端依赖;见 2026-08-05「pre-POST flush 是服务端硬前置」。)严格布局保存通道(`strictCompletion` 全套机制、`flushProjectPersistence` 的 `requireSuccess` / `requiredDialogId` / `deadlineAt` 选项、`PERFECT_PIXEL_STRICT_LAYOUT_SAVE_BUDGET_MS`)整体删除,只保留一个不改变失败语义的 `preferLatestGenerationDialogs`,用于取到刚创建、尚未回流到 ref 的占位。 +- 为什么这样仍然安全:本机写入是同步的、不过网络、不受服务端校验影响,因此它能提供严格布局保存想提供的那个保证——「请求可被追溯」——却不引入任何阻断点。由此新出现的「账本有、占位没写进布局」窗口,由恢复 effect 覆盖:它同时遍历内存占位与孤儿账本条目,对后者照常 GET 对账,终态给出 asset-only 提示并清账本。 +- **本机账本是明确设计,缺失只降级、不得构成阻断**:换设备、换浏览器、清缓存、隐私模式、配额写满都会读不到账本。这些情况下带标记的**未收口**占位一律 hydrate 成 `failed + perfectPixelOperationInvalid` 的**可删除**占位,用户删掉重来即可(收口态占位不适用,见下一条决策)。任何代码路径都不得因为「读不到账本」而拒绝用户发起、重试或删除,也不得把占位停在无从收口的处理中态。跨设备不再自动收口是本决策已知且接受的代价:完美像素免费且同步,重做成本极低,用它换掉「用户数据里混着系统对账状态」的耦合是划算的。后续复查发现该行为时,应对照本条确认为设计,不得判定为回归。 +- 兼容与生命周期:布局内联账本是 legacy 形状,hydrate 侧继续认,滚动部署期间的在途操作不会被一次性判死;写入侧不再产生新的内联账本。本机账本按 7 天保留期与 32 条上限裁剪,终态(applied / dialog-missing / 快照与项目不匹配 / 无占位可挂错误)立即清除。读取沿用与布局快照相同的 v1 白名单校验,任何字段漂移失败关闭,绝不据一份可疑账本重放 POST。 +- 影响范围:新增 `perfectPixelOperationStore.ts`;`ImageCanvasEditorTypes.ts` 新增 `perfectPixelOperationId`;`ImageCanvasEditorModel.ts` 的 `serializeDialogReferences` / `hydrateCanvasGenerationDialog` / `splitCanvasLayoutItems` / `dropDeadInlineGenerationPlaceholders`;`useImageCanvasProjectPersistence.ts` 删除严格保存机制并在 hydrate 时读账本;`useImageCanvasGenerationWorkflow.ts` 的提交、重试与恢复 effect;`useImageCanvasGenerationSurface.tsx` 的 props 类型。不修改服务端、SpacetimeDB schema 或对外契约——服务端从来不认识这个字段。 +- 验证方式:账本单测覆盖往返、owner / project 隔离、跨账号整条丢弃、被篡改条目失败关闭、保留期与条数裁剪、终态清除、以及存储不可用时静默降级;模型层覆盖「布局只留标记且不含源图地址」「标记在而账本缺失时收口为可删除失败态」「账本 id 与占位不符时失败关闭」;工作流覆盖「布局保存失败仍照发 POST 并保留可重试的 operation」与「孤儿账本条目照常对账并在终态清账本」;持久化层覆盖「布局保存 400 / 403 与缺 authority 时 flush 均不抛、下游照常执行」。运行 `npx vitest run src/components/image-editor`、`npm run typecheck`、`npm run lint:eslint`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-05 账本寿命短于标记寿命:收口态不需要账本,孤儿账本不看对账窗口 + +- 背景:上一条把请求账本移到本机后引入了一条判据——「布局里有 `perfectPixelOperationId` 标记、本机没有账本 ⇒ 该占位无效」。这条判据按构造就是错的,因为两者寿命根本不对称:标记写进布局后**寿命无限**(服务端完成 completion 时只做字段级改写,置 `status: "idle"`、`composerOpen: false`、写入 `generatedLayerId`、清 `errorMessage`,从不摘掉标记,见 `editor_project_storage.rs` 的 `plan_editor_pixel_art_canvas_layout`),而账本**寿命很短**(收口即清、75 秒对账窗口、7 天保留期、换设备即无)。账本消失是正常终态,不是异常。同一个不对称还以第二种形态出现在恢复 effect 里:孤儿账本按 `reconcileUntil` 短路。 +- 缺陷一(每一次成功都被判成失败):`settleLivePerfectPixelVerdict` 在 applied 终态先清账本,紧接着 `applyProjectSnapshot` 用真实 hydrate 重新套用权威快照;此时布局里标记还在、账本已清,于是成功结果被判成 `failed + perfectPixelOperationInvalid`,用户点开刚生成的图层会看到「操作快照无效」。更糟的是这个状态会被下一次自动保存序列化回服务端,覆盖服务端正确的 `idle`;`perfectPixelOperationInvalid` 一旦落库,此后单凭它就能强制 `failed`,**自我固化**。历史上早已完成的完美像素占位(当时带内联账本)在标记化改写后同样中招。 +- 缺陷二(兜底分支在唯一目标场景下失效):孤儿账本对账是「删掉严格布局保存仍然安全」的全部依据,目标场景是「POST 已发、布局没落盘、浏览器关闭、稍后重开」——而重开几乎必然晚于 75 秒对账窗口,`operation.reconcileUntil <= now` 的短路让这条分支基本永不生效,条目还会在本机躺满整个保留期反复被跳过。 +- 决策一:凡是「缺账本 ⇒ 无效」的判据,一律先排除**收口态**。收口的定义复用既有的 `isUnresolvedCanvasGenerationDialogRecord` 取反:带非空 `generatedLayerId` 且状态不是 `generating` / `pending-confirmation`。收口态占位不需要账本——`generatedLayerId` 本身就是服务端已回填的证据;它同时**无条件忽略**已落库的 `perfectPixelOperationInvalid` 标记与残留 `errorMessage`,并把状态强制归位到 `idle`,让被上一版写脏的行在下一次 hydrate 时自愈。写边界同步收窄:`serializeDialogReferences` 对收口态占位不再输出 `perfectPixelOperationId`,让标记的寿命与账本对齐,不再在布局里堆积。 +- 决策二:孤儿账本**不按 `reconcileUntil` 短路**。`reconcileUntil` 的语义是「结果可能还在飞,值得多读几次」,而孤儿是上一个会话留下的、发出它的标签页早已不在,需要的是一次能回答「到底落没落」的确定性读,不是轮询。为此新增 `readPerfectPixelOrphanVerdict`(单次 `loadEditorProject` + `inspectPerfectPixelProjectSnapshot`),不复用 `reconcilePerfectPixelProject` 的轮询循环——后者在窗口耗尽时确实会因 `hasAttemptedRead` 初值为 false 而强制读一次,但那是实现副作用而非契约,寄生在上面迟早被重构静默破坏。收口口径:`dialog-missing` → 刷新素材库 + asset-only 提示(这正是布局尽力保存失败的典型结局);`applied` → 静默刷新素材库(本次读到的就是当前项目的权威状态,结果本就在用户眼前,弹提示只是噪音);`pending` / `conflict` → 完全静默(什么都没落库,用户无需知道)。三者都清账本;**读失败不清**——那是「不知道」而非「知道没有」,留给下次加载。 +- 保留不变:未收口占位在账本缺失时仍然收口成可删除的失败态,上一条决策的「缺失只降级、不得构成阻断」原样有效。本条只是把「缺失」的适用范围限定在它本来就该管的那一半。 +- 同类判据的通用要求:本仓库中任何「持久化标记 + 短寿命本地状态」的组合,判据都必须先问「这个标记所指的事情是不是已经结束了」。只要标记比它依赖的状态活得久,`marker && !state ⇒ invalid` 就一定会把正常终态误判成异常。 +- 影响范围:`ImageCanvasEditorModel.ts` 新增 `isSettledPerfectPixelDialogRecord` 并改写 `serializeDialogReferences` / `hydrateCanvasGenerationDialog`;`useImageCanvasGenerationWorkflow.ts` 新增 `readPerfectPixelOrphanVerdict` 并改写恢复 effect 的孤儿分支。不修改服务端、SpacetimeDB schema 或对外契约。 +- 测试缺口的根因与补救:缺陷一能溜过整套测试,是因为工作流用例里所有 applied 场景的 `applyProjectSnapshot` 都是空桩,「收口 → 清账本 → 真实 hydrate 重新套用」这条**跨 hook 协作**从未被跑过;模型层用例又只覆盖了 `generating` + 账本缺失,没有 `idle + generatedLayerId` 这一真实终态形状。补救不是多加两条断言,而是新增一条把 `verdict.project` 真正喂进 `splitCanvasLayoutItems` 的集成用例,并把共享 fixture `createPerfectPixelProject` 补上服务端真实会保留的 `perfectPixelOperationId`——fixture 不还原真实形状,下游所有用例都在测一个不存在的世界。 +- 验证方式:模型层覆盖「收口态无账本仍有效且序列化不再输出标记」「被上一版写脏的行自愈成 idle 且清掉残留错误文案」「收口态的内联 legacy 账本被剥离且不留标记」;工作流层覆盖「applied 结果经真实 hydrate 回来仍是 idle」(该用例已实证:回退修复后报 `expected 'failed' to be 'idle'`)、「过期孤儿仍做且只做一次读并清账本」、「未落库的孤儿静默清账本、不提示、不刷新素材库」。运行 `npx vitest run src/components/image-editor src/components/platform-entry`、`npm run typecheck`、`npm run lint:eslint`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-05 pre-POST flush 是服务端硬前置,对账窗口改在 flush 之后锚定 + +- 事实更正:`server-rs/crates/api-server/src/editor_project.rs` 的 `validate_editor_pixel_art_snap_placeholder_exists` 在处理前检查占位是否**已经持久化**到项目布局;既没有同 ID 的已持久化 dialog、也没有同 operation 的稳定 resource 时返回 **409**。因此 POST 前那次 `await flushProjectPersistence` 不是可省的画布同步,而是服务端硬前置,不能简单取消。上一条决策里「布局保存失败不再拦 POST,只是把结果降级为『只进素材库』」的说法就此更正:准确表述是——**占位从未持久化时服务端返回 409;best-effort flush 不再提供成功 ACK,因此客户端无法证明该前置条件已经满足,只能提高满足它的概率**(占位可能已被此前的 450ms 自动保存落库,PATCH 也可能成功而 ACK 丢失)。被解除的是客户端侧「拿不到 revision ack 就拒发」的阻断,不是端到端依赖。 +- 缺陷:首次提交与人工 exact retry 都在这次 flush **之前**就算好 `submittedAt / reconcileUntil`。该 flush 没有整体上限(单次 PATCH 60 秒 × 最多 4 次尝试,且 flush 的等待循环会清掉退避定时器立刻重跑),慢保存足以在 POST 发出前烧光整个 75 秒窗口,请求带着已过期的 reconciliation deadline 发出,对账退化成「强制读一次即以 pending 收尾」。 +- 决策:窗口一律锚在 POST 发出的时刻。flush 返回且 authority 复核通过之后,调用 `createPerfectPixelReconciliationOperation` 重新设置 `submittedAt = 当前时间`、`reconcileUntil = 当前时间 + 75 秒`,按同一 `operationId` 覆盖本机账本与 dialog,并登记新的 recovery key(key 含 `reconcileUntil`),随后立即 POST。只覆盖时间字段:`request` 与 dialog / operation / task identity 逐字节不变,也不产生第二条账本。 +- 为什么保留 flush 前的预写而不是整体后移:flush 期间另一标签页可能加载同一项目,此时服务端已有带 `perfectPixelOperationId` 的占位,而 localStorage 跨标签共享——本机若还没有账本,那条占位会被直接 hydrate 成 `failed + invalid`。预写的 provisional 账本正好堵住这个可长达数分钟的窗口,因此采用「预写 + flush 后重新锚定」,不采用「把首次账本写入整体挪到 flush 之后」。 +- 人工 exact retry 同此口径:flush 之前继续沿用旧 operation(UI 可以先切到 `generating` 让用户看到重试已开始),flush 完成、authority 复核通过后才重新锚定、覆盖账本与 dialog,然后 POST。先刷新窗口再等 flush 等于把窗口烧在等待上。 +- 明确不在本次范围:flush 本身的无上限等待,以及删除 `strictCompletion` 后每次 PATCH 重起 60 秒 deadline 的连带效果。既然等待是硬前置,给它加上限只会把「慢」换成「409 失败」,不构成改善;真要治需要服务端接受「占位随请求一起提交」,属于接口契约变更。 +- 影响范围:`useImageCanvasGenerationWorkflow.ts` 的 `snapSelectedLayerToPerfectPixels` 与 `retryPerfectPixelOperation`。不修改服务端、SpacetimeDB schema 或对外契约。 +- 同步更新的文档:本文件上一条的错误声明已就地更正;`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md` 中「strict layout save 60 秒预算 / strict revision ACK 前 POST 为零」「未收口 operation 不可删除、不写 delete-generation-result 历史」「durable operation 不得被普通删除路径清理」等已被近几个提交推翻的条款一并修正。历史 commit message 只能靠重写 Git 历史才能改动,不为此改写历史,以本条追加说明为准。 +- 验证方式:两条受控时钟用例分别覆盖首次提交与 exact retry——让 pre-POST flush 期间时钟前进 90 秒(超过整个 75 秒窗口),断言 POST 那一刻账本里是刚建立的完整 75 秒窗口、`submittedAt` 等于 POST 时刻、账本仍只有一条、`taskId` 与 `request` 逐字节未变;retry 用例另断言 flush 期间 dialog 上挂的仍是旧 `submittedAt`,证明窗口没有被提前刷新。两条用例均已实证:回退修复后报 `expected 1800000000000 to be 1800000090000`。运行 `npx vitest run src/components/image-editor src/components/platform-entry`、`npm run typecheck`、`npm run lint:eslint`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-05 专题文档补齐同步:账本位置、窗口锚点、删除权与孤儿对账 + +- 背景:近五个提交连续翻转了完美像素的多条前端契约,但只追加了 decision-log。`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md` 是这些条目自己声明的「关联文档」,其中仍写着已被推翻的旧契约,实现与验收依据互相矛盾——按旧文档做验收会把当前正确行为判成缺陷。 +- 已更正的条款:①「请求快照写入占位并 flush 布局」→ 账本写本机 `perfectPixelOperationStore`,布局只留 `perfectPixelOperationId` 标记;②「strict layout save 60 秒预算 / strict revision ACK 前 POST 为零」→ 通道已删除,改为 best-effort flush,并写明服务端要求占位此前已持久化(否则 409)、客户端无法证明该前置只能提高概率;③「`submittedAt / reconcileUntil` 从快照写入起算」→ 从 POST 发出时刻起算,首次提交与人工重试同口径;④「POST 前必须取得布局保存成功确认,否则 POST 为零」→ 保存失败不再让 POST 为零;⑤「未收口 operation 不可删除、不写 `delete-generation-result` 伪历史」与「durable operation 不得被普通删除路径清理」→ 任何状态可删且不弹确认,确认只对计费生成成立;TTL 豁免(系统不替用户删)与用户主动删除是两回事。 +- 新增到文档的不变式:标记与账本寿命必须对齐——收口态占位不再写出标记,也不得因「有标记、没账本」被判无效;账本读不到时只有**未收口**占位收口成可删除失败态。恢复必须覆盖孤儿账本,孤儿走一次确定性的读而非轮询,不按 `reconcileUntil` 短路,三种结论的提示口径与清账本规则一并写明。 +- 保留为已知缺口而非静默修正:「普通按钮不得创建第二个 operation」原文是绝对断言,但该保证只由 `existingOperation` 闸提供,而它只扫描内存 dialog 列表;占位可删之后,删掉再从源图发起会产生第二个 identity。文档改为如实描述现状并标注缺口与闭合方向(让本机账本参与防重),代码侧不在本次范围。文档的职责是描述系统实际行为,写一条做不到的保证比留一个标注清楚的缺口更糟。 +- 影响范围:仅文档。不改代码、不改测试。 +- 验证方式:`npm run check:encoding`;核对文档中不再残留「布局保存成功确认」「strict revision ACK」「从快照写入起算」等已推翻表述。 + +## 2026-08-05 legacy 内联账本一次性迁入本机;Undo 复活占位记为已知限制 + +- 背景:账本移出布局后,`hydrateCanvasGenerationDialog` 仍然认布局里的 legacy 内联快照,但**没有任何路径把它写进本机账本**;而 `serializeDialogReferences` 会在下一次保存时把内联快照剥成 `perfectPixelOperationId` 标记。先前提交声称「滚动部署期间的在途操作不会被一次性判死」只对了一半:**第一次 hydrate 活下来,第二次就变成 `failed + invalid`**,永久失去 exact retry 的 identity。 +- 决策:在 `applyProjectSnapshot` 读账本、`splitCanvasLayoutItems` 之后补一次性迁移——把带内联账本、本机却读不到、且**仍未收口**(`generating` / `pending-confirmation`)的 operation 写进本机账本。三个条件都必要:只补写缺失的(本机那份可能刚在 pre-POST flush 之后被重新锚定过,比布局里的新,不能覆盖);只补写未收口的(收口态本就不需要账本,迁移只会造出立刻被裁剪的垃圾条目)。影响范围一次性且有界,仅限部署那一刻仍在途的历史操作。 +- 未采纳:「删除占位后立即 flush 布局,压缩『被放弃的 operation 仍可能往画布插入图层』的竞态窗口」。核查后发现删除**已经**触发既有的 450ms 防抖自动保存(布局自动保存 effect 的依赖里就有 `canvasGenerationDialogs`),所以该改动只能在一个由服务端处理耗时(数秒)主导的竞态里省下 450 毫秒,代价却是让一个高频操作绕过防抖、增加 PATCH 量。收益与代价不成比例,不做。 +- 未采纳:「用户删除未收口占位后从源图重做时弹确认框」。完美像素免费,重复的最坏后果是素材库多一份;为此在常用路径上加一次确认属于给用户制造摩擦。另外账本里能用来匹配同源的只有 `request.sourceResourceId`,纯本地图层根本匹配不到——一个覆盖不全的提醒比没有提醒更容易让人误以为安全。 +- 记为已知限制而非缺陷:删除仍在处理中的占位、待原请求收口后再 `Ctrl+Z` 撤销删除,复活的占位在**当前会话内**不再被对账,会一直显示处理中;刷新即自愈,用户也可以再删一次。根因是 `observedPerfectPixelRecoveryKeysRef` 同时承担「并发保护」和「本会话已驱动过」两种语义。已推演的四种修法各有硬伤:删除瞬间剪 key 会被同一轮的 claim 检查重新标记;改成「重新出现时剪」会被 `applyProjectSnapshot` 的整批替换误触发;由删除路径显式清观察记录需要向四个删除入口铺跨 hook 通路;不把未收口 operation 放进可撤销历史则直接砍掉「误删可撤销」。为一个刷新即愈的限制付上述任一代价都不划算,留到重构该记账时一并解决。 +- 影响范围:`useImageCanvasProjectPersistence.ts` 的 `applyProjectSnapshot`。不修改服务端、SpacetimeDB schema 或对外契约。 +- 验证方式:新增「legacy 内联账本在加载后被迁入本机,且剥离内联快照后仍能凭本机账本往返回有效的 `pending-confirmation` 占位」用例,已实证:回退迁移后报 `expected +0 to be 1`。运行 `npx vitest run src/components/image-editor src/components/platform-entry`、`npm run typecheck`、`npm run lint:eslint`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`(已同步 legacy 迁移要求与 Undo 已知限制)。 + +## 2026-08-05 孤儿账本只在正向终态清除;legacy 迁移判据对齐 exact retry + +- 缺陷一(孤儿过早清账):上一条把孤儿改成「读到任何结论就清账本」,但 `pending` / `conflict` 不是结论。浏览器关掉不会中止服务端处理——api-server 的处理与持久化预算合计可达 90 秒,远端 procedure 也可能还在跑;重开项目时单次 GET 没看见 resource 只说明「还不知道」。此刻清账,服务端稍后落库便再无对账凭据,用户永远等不到「结果已进素材库」的提示,同时又多开一个 identity 的口子。 +- 决策一:只有 `applied` / `dialog-missing` 这两个**正向终态**才清账本;`pending` / `conflict` 与读失败一律保留,留给下次加载重读。残留由 7 天保留期与 32 条上限兜住,代价是极少数永不落库的条目每个会话多一次 GET——比丢失凭据便宜得多。 +- 缺陷二(legacy 迁移漏 `failed`):迁移判据按状态白名单列举了 `generating` / `pending-confirmation`,漏掉 `failed + perfectPixelOperation`。那是旧严格保存失败的合法持久化形状(请求已备好、POST 从未发出),`retryPerfectPixelOperation` 明确接受该状态,面板上的「重试同一完美像素操作」也正是在这个形状下出现。漏迁的后果与缺陷本体一致:下一次保存剥成 marker 后再加载即 `failed + invalid`,重试按钮消失,用户只剩删掉重做——而那正是新 identity,正是 exact retry 存在的意义所在。 +- 决策二:迁移判据改用 `isUnresolvedCanvasGenerationDialogRecord` 取反,与 `hydrateCanvasGenerationDialog` 判定收口态用的是同一个函数,两处不会漂移。**通用要求**:涉及「这条 operation 还需不需要账本」的判断一律问「它收口了没有」,不要列举状态——状态白名单会随着新增状态或语义变化而静默漏项,本条就是实例。 +- exact retry 的价值必须记清楚,它不是「省一次操作」:原样重放同一 identity 时服务端幂等生效(稳定 task / object / resource / asset ID 由 `owner + project + dialogId` 派生,请求带 fingerprint),同内容重放返回 `AlreadyApplied` 而不是再造一份。删掉它意味着对账查不出结论时用户只能新建 identity,旧的若其实成功就会重复。曾评估过「直接删除该功能以减少用户困扰」,权衡后保留——困扰来自文案与状态不清晰,可以单独治理,而幂等保证一旦删掉无法用文案补回。 +- 同步更正的文档:专题文档三处残留矛盾——`submittedAt / reconcileUntil` 仍称「从稳定请求快照写入时建立」(应为 pre-POST flush 之后、POST 之前)、仍称「不会让布局校验失败升级成硬阻断」(应限定为解除了客户端侧拒发,端到端 409 依赖仍在)、以及「删除后不再对账」(应限定为结果不再自动回填画布,对账本身继续进行)。 +- 影响范围:`useImageCanvasGenerationWorkflow.ts` 的恢复 effect 孤儿分支、`useImageCanvasProjectPersistence.ts` 的 legacy 迁移判据。不修改服务端、SpacetimeDB schema 或对外契约。 +- 验证方式:孤儿用例翻转为「单次读仍无法判定时保留账本、不提示、不刷新素材库」;新增「legacy `failed + operation` 被迁入本机」用例。两条均已实证:回退修复后各报 `expected +0 to be 1`。运行 `npx vitest run src/components/image-editor src/components/platform-entry`、`npm run typecheck`、`npm run lint:eslint`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-05 完美像素的优先级判据:低成本操作不为「丢资源」加限制 + +- 背景:完美像素链路上反复出现同一种压力——为了防止「已经生成的结果丢失关联」,不断有人提议加限制:禁止删除未收口占位、禁止从源图重新发起、让本机账本参与防重。历史上前两条真的加过(2026-08-04 两条决策),造成用户画布上出现删不掉的元素,后来被逐条作废;最近一轮评审又把「删除后可创建第二个 identity」报成必须闭合的 P2 缺口,闭合方向正是第三条。这种压力不会自己停,必须写成判据。 +- 事实前提:该操作免费(`generation_cost_mud_points = 0`)、同步、纯几何规整、不进外部生成队列,重做一次的代价接近于零。它与计费生成的风险结构根本不同,不能套用同一套「必须防止重复提交」的直觉。 +- 决策(优先级判据):凡是「已生成的资源在意外情况下丢失关联、需要用户重做或自行去素材库取回」这一类问题,一律**不构成阻断项**。只有主链路本身出问题才阻断——发起被拒、处理失败、结果没落库、已落库的结果既不回填画布也不进素材库。评审与复查把前一类现象报成必须修复的缺陷之前,先确认它落在主链路上。 +- 决策(禁止的补救方向):不得为了防止上述丢失而新增任何限制用户操作的特性,具体包括但不限于「禁止用户删除占位」「禁止用户重试」「禁止同一张图被处理两遍」。用户对自己画布上的元素始终保有删除与重做的权利;重复处理的最坏后果只是素材库多一份、用户可自行删除,这个代价远小于剥夺用户操作权。已作废的同类封锁不得以任何理由重新引入。 +- 连带处置:先前记为「已知缺口、闭合方向是让本机账本参与防重」的那条,改记为**明确接受的行为**——该闭合方向正是本条禁止的内容。既有的 `existingOperation` 闸是本条确立之前的遗留,方向与本条相反,后续应放宽而非加固。 +- 与 exact retry 的关系:本条不否定 exact retry。它是**用户自愿选择**的幂等路径(原样重放同一 identity,服务端同内容重放返回 `AlreadyApplied`),属于给用户多一个选项,不是限制;被禁止的是把它变成用户唯一能走的路。 +- 影响范围:仅文档与后续评审口径。不改代码、不改测试。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`(已写入同名判据与禁止清单)。 + +## 2026-08-05 完美像素跨会话续命层记为「迁移到 durable job 时整层清除」 + +- 背景:本分支相对 master 新增约 16758 行,其中测试占 45%(Rust 内联 `#[cfg(test)]` 按行号切开重算:`editor_project.rs` 新增 2882 行里 1221 行是测试,`editor_project_storage.rs` 是 24%)。功能本体极小——像素规整算法在 `pixel_art_snapper.rs` 只改了 100 行。体量几乎全部来自「这条链路没有 durable job」这一个架构选择:免费 + 同步 + 不进生成队列,服务端不留任何「这次请求发出过」的记录,于是「结果是否落库」这个在其它生成路径由 job 行免费回答的问题,必须由客户端自造一整套机制回答。 +- 三层划分(本条的核心结论):**A 层**服务端正确性(单事务原子落库、preflight、归属校验、稳定 object key、预算边界,约 2800 行)与是否有 job 无关,任何形态都保留;**C 层**POST 后一次 GET 对账(applied / dialog-missing / unknown 三档,约 180 行)保护的是「结果未知却谎报失败」,属于主链路,保留;**B 层**跨会话续命(本机账本、刷新恢复与孤儿对账、75 秒窗口与锚点、marker 与账本的寿命对齐、exact retry、inline 占位到期与归属登记,约 1100 行生产 + 2500 行测试)只因为没有 job 而存在。 +- 决策:**现在不删 B 层**——它刚写完、刚测过、刚修完六个缺陷,删除本身是有风险的改动,收益兑现在未来的维护成本上。但**迁移到 `enqueue_editor_generation_job` 时必须整层清除,不得与队列并存**:两套收口机制并行会产生「谁是终态权威」的二义性,比任何一套单独存在都糟。该要求已写入专题文档,作为阶段 4 的验收条件之一。 +- 支撑该结论的实测(免得后来者重新推导):B 层只服务完美像素——`requiresLiveSession: true` 全仓仅一处置位,`claimActiveInlineGenerationDialog` / `releaseActiveInlineGenerationDialog` / `hasActiveInlineGenerationDialog` 的全部五个调用点都在完美像素的提交、重试与恢复路径上。因此整层删除的边界清晰、不会波及其它生成链路。 +- 缺陷密度佐证:2026-08-05 那轮对抗性复查的七条发现里,F1–F6 六条**全部**落在 B 层(F7 是文档同步)。B 层的核心不变式「持久化标记的寿命必须与短寿命本地状态对齐」反直觉且容易写错,是这条链路缺陷最密集的地方。 +- 仓库内既有的廉价答案:手动图集拆分同样免费、同步、无 durable job,客户端只有约 85 行——失败即 `window.alert` 报错,`taskId` 用 `build_prefixed_uuid_id("editor-atlas-split-")` 随机生成,结构上不可能幂等,也没有任何对账。它符合上文的优先级判据,**完美像素的 B 层才是特例**,不得据它给其它链路加同样的机制。 +- 顺带记录的待办(不在本次范围):图集拆分的 catch 只做 `window.alert('拆分图集失败')`,不区分「确定失败」与「网关合成的未知结果」。服务端可能已经切完并落库 N 个 asset 却报失败,用户照提示重拆就会拿到双份——这属于谎报,落在主链路一侧,与「丢资源不阻断」不是一类问题。最小修法是复用现成的 `isGatewayUnknownOutcomeError` 改文案,十几行,不需要照搬 B 层。 +- 影响范围:仅文档。不改代码、不改测试。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`(已写入三层划分、B 层边界实测与清除条件)。 + +## 2026-08-05 删除确认判据改看 marker,覆盖源准备阶段的免费占位 + +- 缺陷:`requiresGenerationDeleteConfirmation` 的判据是 `status === 'generating' && !dialog.perfectPixelOperation`,而 `perfectPixelOperation` 要到源图解析 / 直传完成后才写入。未登记的本地图片要走 `ticket → PUT → confirm`,预算上限 90 秒;这段窗口里占位是 `generating`、只有 `requiresLiveSession: true`、没有账本,于是判据返回 true——用户删一个**免费**操作会被告知「已消耗的泥点不会返还」。与同日「完美像素占位恢复为可删除」里「任何状态直接删、不弹确认」的决策直接矛盾。已登记资源走短路解析、窗口接近于零,暴露只在未登记本地图片上成立。 +- 决策:`perfectPixelOperationId` marker 从占位**创建那一刻**就写上(`operationId === dialogId` 在创建时已知),判据改看 marker:`status === 'generating' && !dialog.perfectPixelOperationId`。marker 的语义也因此更准确——它表示「这个占位属于一次完美像素操作」,而不是「账本已存在」。 +- 未采纳「删掉弹窗入口」:删除入口全仓只有 `requestRemoveCanvasGenerationDialog` 一条,右键、Delete 快捷键、工具栏全部汇入,完美像素没有自己的删除路径可以摘除。「从本链路删除入口」在实现上等价于「让判据认得出本链路」,绕不开识别问题。真正删掉弹窗只能对所有生成占位一起做,那是另一个产品决定(对计费生成而言该文案是真实信息),不作为修此缺陷的副产品。 +- 未采纳「新增 `generationCostMudPoints` 字段让判据直接问是否计费」:语义上最正,但今天唯一的生产者只有完美像素,图集拆分根本不创建占位,第二个消费者并不存在,属于为一个调用方过度设计。 +- 未采纳「判据加 `requiresLiveSession === true`」:该字段全仓确实只有完美像素一处置位,一行即可修,但它的含义是「只能由本会话收口」而非「免费」。换一个代理不解决问题——**用短寿命字段的存在性判断长期属性**正是本缺陷(以及 F5)的成因模式。 +- 已核过的连带影响:会话内到期清理不受影响,`inlineGenerationPlaceholderExpiryAt` 看的是 `perfectPixelOperation` 而非 marker,源准备阶段被放弃的占位照常到期消失。受影响的只有加载时的快照清理 `dropDeadInlineGenerationPlaceholders`——它的豁免判据接受 marker,因此「源准备中途关标签页」的占位不再被静默清掉,而是在下次加载显示为可删的失败卡。这与「TTL 豁免 = 系统不替用户删,用户主动删除始终允许」一致,判为改善而非退化。id 撞车时 `openCanvasGenerationDialog` 会另生成 id,marker 会暂时指向旧值;它此刻只被当作存在性标记使用(效果仍是不弹确认),写 request 时按真实 dialogId 纠正,不影响任何判等。 +- 测试缺口的根因:原用例的夹具 `durablePerfectPixelDialog` 带着 `perfectPixelOperation`,编码了与判据相同的错误假设,结构上不可能覆盖 operation 形成之前的窗口。夹具已补上 marker 以还原真实形状,并新增「只有 marker、尚无账本」的用例,已实证:回退判据后报 `expected true to be false`。 +- 影响范围:`useCanvasGenerationDialogs.ts` 的判据、`useImageCanvasGenerationWorkflow.ts` 的占位创建。不修改服务端、SpacetimeDB schema 或对外契约。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-05 布局 flush 不再等待封面链 + +- 缺陷:`flushProjectPersistence` 显式关掉 `queueProjectLayoutSave` 内建的 fire-and-forget 封面分支(传 `persistCover: false`),自己另起一份并在函数最后 `await coverSave`。于是每一个 `await flushProjectPersistence(...)` 的调用方都被挂在封面链后面。封面渲染要为**每个可绘制图层**取 signed URL、再用 `new Image()` 加载——那个 Image 只有 `onload` / `onerror`,**没有 timeout、没有 AbortSignal**,外层的 `try { } catch { }` 只接得住 reject、接不住「永不 settle」。一张图不 settle,生成 POST 就永远发不出去。 +- 归因:这是 2026-08-05「完美像素请求账本移出项目布局」把严格通道与普通 flush 合并成一条路径时引入的**回归**。改动前 `if (requireSuccess) { …; await strictCompletion.promise; return; }` 在封面链启动之前就返回,封面与 pre-POST 路径是结构性隔离的。图集拆分(`void flushProjectPersistence().then(() => splitSelectedIconSpritesheet(layer))`)一直走非严格路径,因此它的暴露是既有的、不是本次引入;但两者同源,一并解开。 +- 影响面分级:**必然发生**的是延迟——封面签名含未量化的 `viewport.x/y/scale`,而完美像素创建占位时 `openPlacedCanvasGenerationDialog` 会 `setViewport(centerViewportOnPlacement(...))`,所以几乎每次调用都会触发全量重渲染(逐图层取 signed URL + 加载 + 渲染 + 上传 OSS + 登记资源),这些与服务端那个 409 前置毫无关系。**可能发生**的是永久挂死,此时 `snapSelectedLayerToPerfectPixels` 的 `finally` 永不执行,图层锁与 inline 占位归属登记被永久持有;用户即使删掉占位,闸的另一半 `perfectPixelLayerIdsRef.current.has(sourceLayer.id)` 仍为真且**静默 return**,本会话内再点完美像素不会有任何反应。图集拆分没有这层脏状态——它的锁在 `splitSelectedIconSpritesheet` 函数体内才取,flush 挂住时根本没被调用,表现只是「点击无反馈」。 +- 决策(删除式修复):删掉 flush 里的 `persistCover: false` 覆盖、独立的 `const coverSave = persistProjectCoverSnapshot(...)` 与末尾的 `await coverSave`,让封面回到 `queueProjectLayoutSave` 内建的 fire-and-forget 分支。参数逐项等价(`layoutInput.viewport` 即 `coverDisplayViewport`、同一个 `refs.layersRef.current`、flush 不传 `delayMs` 故走立即分支),**封面照存,只是不再有人等它**。未采纳「给 flush 加一个跳过封面的选项」:那会把同一个结构性问题留在图集拆分身上,并且多一个需要每个调用方正确设置的开关。 +- 代价:`returnToProjects` 不再等封面就跳转。该保护本就很薄——跳转是 SPA 路由切换而非页面卸载,fire-and-forget 的 promise 在同一 JS 上下文里会跑完;真正会打断它的是浏览器关闭/硬刷新,而 `await` 在 `beforeunload` 里同样救不了。实际损失只是「点返回后立刻关标签页」这个窄窗口里封面可能没传完,而封面是缩略图、下次任意保存会重新生成。 +- 遗留(建议单开,不在本次范围):`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 cfe8ceb4f..fe42622ad 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -14,6 +14,21 @@ - 关联:相关文件、文档、提交或 Issue ``` +## `timeout_at` 不能替代显式的预算耗尽预检 + +- 现象:给完美像素加端点级并发闸后,预算已经耗尽的请求仍然能拿到许可,白占一个名额继续去打几轮全账号 SpacetimeDB 扫描,直到下载那步才失败。 +- 原因:`tokio::time::timeout_at` 会先 poll 一次内层 future 再判超时。信号量有空闲许可时 `acquire_owned()` 首次 poll 就绪,于是即使 deadline 早已过去,返回的仍是 `Ok(Ok(permit))` 而不是超时。既有 `acquire_editor_pixel_art_cpu_permit` 里那句 `if Instant::now() >= processing_deadline` 正是为此存在,新写的许可函数漏掉后被单测抓出。 +- 处理:所有「先判预算、再等资源」的获取函数都必须在 `timeout_at` 之前显式判一次 `Instant::now() >= deadline` 并直接返回超时错误;这句不是冗余防御。同理,进入排队计数之前也要先做这个预检,避免为注定失败的请求占用队列名额。 +- 验证:在有空闲许可时用已过期的 deadline 调用获取函数,只断言返回 `504` 而不是许可;`504` 已足以证明显式预检没有被 `timeout_at` 的首次 poll 绕过。禁止在该用例里读取进程级队列 Atomic 的 before/after;相对断言同样会被并行测试插入。仅靠「信号量占满时超时」的用例发现不了这个问题。 +- 关联:`server-rs/crates/api-server/src/editor_project.rs`(`acquire_editor_pixel_art_snap_permit`、`acquire_editor_pixel_art_cpu_permit`)。 + +## 有界等待队列的计数递减必须写在 Drop 里 + +- 现象:给同步端点加「最多 N 个等待者」的保险丝时,若把计数递减写在正常返回路径上,客户端断连或超时触发会让等待中的 future 被丢弃而跳过递减;计数只增不减,最终队列永久判定为满,接口对所有人返回 `503` 且不会自愈。 +- 原因:Rust 的 async future 可以在任意 await 点被取消,取消时只保证 `Drop` 会跑,不保证后续代码会执行。有界队列的入场与离场天然不对称。 +- 处理:把递增封进一个 guard 结构体,递减放在它的 `Drop` 实现里;递增本身用 `fetch_update` 的 CAS,不能用「先读后加」——两个线程同时读到 `max - 1` 各自加一就会越界。拿到资源后立即 `drop(guard)` 让出队列名额,不要让它跟着许可一起活到请求结束。 +- 验证:单测覆盖 CAS 边界(满了返回失败且计数不越界、上限为 0 时任何进入都失败),并由独立用例覆盖 guard 离开作用域后的计数归还。预算耗尽路径只断言 `504`,不得通过另一个测试也会修改的进程级 static before/after 来推断“未入队”,也不得用串行锁或 `--test-threads=1` 掩盖隔离问题。 +- 关联:`server-rs/crates/api-server/src/editor_project.rs`(`try_enter_bounded_queue`、`EditorPixelArtSnapQueueGuard`)。 ## Linux 生产脚本门禁不能假设本地也是 GNU userland - 现象:macOS 本地运行维护页、生产 API 部署和 Rust 产物门禁时,依次出现 `mv: illegal option -- T`、`mapfile: command not found`、`/usr/bin/cp` / `/usr/bin/chmod` 不存在,以及 `.rlib` 明明含有 `.o` 却报告“没有可扫描成员”;安全修复计划还会把 `/var/folders` 到 `/private/var/folders` 的系统别名误判为用户符号链接。 @@ -3236,9 +3251,9 @@ - 现象:release 上 api-server 周期性出现全量 `spacetime_stage="pool_acquire" elapsed_ms=45000` 业务超时,`/readyz` 503(`reason=spacetime_unhealthy, stage=pool_acquire`),`/healthz` 仍 200,只有重启能恢复,过若干小时复发。 - 原因:旧 `PooledConnectionLease` 只能显式 `release_connection` 归还;HTTP 请求方在等待 StDB 回包期间断开时 handler future 被取消,permit 自动归还但槽位 `in_use` 永不复位。后续 acquire 在拿到 permit 后进入无界 `loop + yield_now` 扫描空闲槽位,泄漏积累到 pool_size 后整池挂死。 -- 处理:租约持有 `Arc` 并实现 `Drop` 统一复位槽位/归还连接;槽位改 `AtomicBool` CAS 抢占,删除自旋循环(持有 permit 必然命中空闲槽位)。任何新的"显式归还"资源在 async 取消语义下都要先想 Drop 兜底。 +- 处理:租约持有 `Arc` 并实现 `Drop` 统一复位槽位/归还连接;槽位改 `AtomicBool` CAS 抢占,删除自旋循环(持有 permit 必然命中空闲槽位)。任何新的"显式归还"资源在 async 取消语义下都要先想 Drop 兜底。该保证只覆盖本地 lease / slot / permit 回收;RPC 已发出后,handler timeout/drop 不会取消或回滚远端 procedure,结果仍须按 unknown 读取权威事实。 - 验证:`cargo test -p spacetime-client --manifest-path server-rs/Cargo.toml --lib`(`dropped_lease_releases_slot_and_permit`、`acquire_times_out_at_pool_acquire_when_pool_is_busy`)。 -- 关联:`server-rs/crates/spacetime-client/src/lib.rs`、`docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md`。 +- 关联:`server-rs/crates/spacetime-client/src/active.rs`、`docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md`。 ## 后台灰度配置不能从 SpacetimeDB 本地表缓存读取 @@ -4180,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 738f539e3..e5fdfaf35 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -34,17 +34,38 @@ - 画布底部工具栏 / 面板 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 入口不展示或设置该选项。 - `style` 是可选字符串兼容边界。省略、`null`、空字符串和 `"none"` 统一归一为内部 `None`,不返回告警;`"pixelArt"` 仅允许普通图片(`kind` 省略)与 `kind="character"`,图标图集请求单独允许该值。未知字符串或在 `spec / quick-edit / ui-design / publication-material` 等不支持的图片 `kind` 上请求 `"pixelArt"` 时,按 `None` 完成原管线并通过既有通用 `warning` 返回 `unsupported-image-style`;非字符串 JSON 仍是畸形请求并返回 `400`。旧 payload 缺少字段时等价于 `None`。 -- `None` 必须保持现有生成、尺寸处理、BgFilter、上传、资源和画布链路不变。`PixelArt` 只增加父流程内的纯内存 Rust 后处理,不启动 Python 或独立服务,也不改变 BgFilter 的 `flat` 参数、Alpha 回贴、`cross_check`、fallback 或默认关闭 despill 的现有行为。 +- `None` 必须保持现有生成、尺寸处理、BgFilter、上传、资源和画布链路不变,包括提交给 provider 的提示词必须与未带该字段时逐字一致。`PixelArt` 在父流程内做两件事:向提交给 provider 的提示词追加一行像素风约束,以及在 provider 回图后执行纯内存 Rust 像素规整。两者都不启动 Python 或独立服务,也不改变 BgFilter 的 `flat` 参数、Alpha 回贴、`cross_check`、fallback 或默认关闭 despill 的现有行为。 +- 2026-08-01 修订:`PixelArt` 增加提示词端约束。snapper 是几何对齐器,横纵两轴都检测不到网格步长时会退到 `min(width,height)/64` 统一网格兜底,产出马赛克而非像素画;因此在提示词端要求 provider 本身就输出块状结构。注入点固定为 `generate_editor_image_for_owner` 与 `generate_editor_icon_spritesheet_for_owner` 构造提交提示词的位置,包住既有 builder 的返回值,builder 签名和既有输出契约不变,三个入口(登录态路由、外部 API v1、异步 job worker)一处覆盖。约束句按链路分三条、追加在末尾并独立成行:普通图片用「画面为像素风格」;角色形象和图标图集生成后都要按纯色抠像,绿幕底必须保持平整,分别用「角色主体为像素风格」和「每个图标素材均为像素风格」,不得出现「画面」级别的像素化要求。实测只提「像素风格」已可接受,不注入网格密度、色板色数和抗锯齿等约束。约束句是否进入 `editor_project_resource` 的 prompt 列按链路而定:普通图片会进;角色形象在抠图成功后 `output_prompt` 被无条件覆盖为 `"去除纯色背景"`,其原图 resource 存的是用户原文,因此约束句不进角色的任何 project resource;图标图集的原图 spritesheet resource 会进,透明结果和切片则分别存 `"去除纯色背景"` 与 `"自动拆分图集"`。角色链路的完整提交提示词是否留存取决于 provider——asset object 元数据写的是 `actual_prompt.unwrap_or(prompt)`,provider 回了 `actualPrompt` 就存 provider 改写后的文本,此时 `submitted_prompt` 在系统内一处都不落(审计只记 `promptChars`)。响应体也不一致:普通图片和角色形象返回用户原文,前端显示不变;图标图集返回的是含约束句的工程化 `prompt`,调用方可直接看到模板内容。不新增 OSS PUT、项目资源、素材记录或画布图层。以上 prompt 列写入、asset object 元数据和响应字段规则全部是既有行为,本次未改动。 +- 2026-08-01 修订:画布四个生图入口(普通图片与角色形象共用一条、UI 设计图、修改图片的两个 provider 分支)的 negative prompt 移除「低清晰度」。该词按字面否定低分辨率,与以低分辨率重采样为本质的像素风直接对冲。其余玩法的同名词条不在本次范围内。 - 普通图片与角色在 provider 回图后先按统一业务像素矩阵尝试交付尺寸归一:允许无放大恢复时使用 Lanczos 重采样并居中裁切,无法安全恢复时保留 provider 实际尺寸并返回非阻断告警。普通图片随后以这张实际交付尺寸图同时作为网格分析源和 RGBA 采样源;角色先持久化同尺寸平底原图并交给 BgFilter,正常成功后把 Alpha 蒙版回贴到该平底原图,再以平底原图分析网格、以透明 RGBA 图采样。图标仍以已持久化的平底 provider 图尺寸为基准,BgFilter 成功并回贴 Alpha 后执行同样的双输入规整。固定首版参数为:分析色数 `16`、Alpha 覆盖阈值 `0.375`、像素格尺寸自动检测、相邻边缘峰间距使用线性插值 `P30` 估算步长、固定色板关闭、K-means 最大采样 `262144`。 - 像素规整 CPU 工作使用进程级最大并发 `2`;取得并发许可的排队时间与实际处理时间共享最多 `30` 秒预算,同时不得晚于当前请求 deadline,最终以两者中更早者为准。输入图片任一边不得超过 `10000` 像素,总像素不得超过 `8294400`;超限、排队超时或处理超时均按像素后处理失败的 best-effort 规则保留进入该步骤前的图片。 - 单格颜色按 `Σ(A × RGB) / ΣA` 进行 Alpha 加权;单格覆盖率按 `Σ(A / 255) / N` 计算。覆盖率大于等于 `0.375` 且 `ΣA > 0` 时输出硬 Alpha `255`,否则输出严格的 `[0,0,0,0]`;最终 Alpha 只允许 `0 / 255`。分析用 16 色只负责网格识别,不限制最终输出色数。 - 逻辑低分辨率图只存在于内存;snapper 在规整内部使用 nearest 恢复到当前 RGBA 输入尺寸,并直接替换原本即将持久化的最终图片字节。普通图片和角色的该输入已经过前置 Lanczos 交付尺寸归一,或在无法安全归一时保留 provider 实际尺寸;图标输入以已持久化平底原图的实际尺寸为准。nearest 不替代前置尺寸归一,规整完成后不再执行第二次 Lanczos 或其它尺寸恢复。角色和图标应复用 Alpha 回贴阶段已经读取的平底原图;确需重新读取时,最多增加一次对已有 provider 对象的 OSS GET,不得新增 OSS PUT。 - 像素模式的持久化增量必须为零:普通图片仍只上传原有一张最终主图;角色仍只保留原有 provider 原图与透明主图;图标仍只保留原有 provider 原图、透明图集和实际成功的切片。禁止保存逻辑低分辨率图、像素化前后双份主图、预览图、网格诊断图或报告,禁止新增 asset / resource 类型、项目资源、画布 item、队列 job kind 或数据库字段。 - 像素后处理属于 best-effort:失败时保留进入该步骤前的图片,继续原有最终上传与画布完成,并通过既有通用 `warning` 返回非阻断原因,不把任务改为失败或退款。BgFilter 自身失败时仍按原 source-only fallback 收口,像素处理不运行;图标后处理成功后再执行原有自动拆分,拆分告警继续使用现有 `sliceWarning` 语义。 +- 选中已有静态栅格图层后的 `完美像素` 是独立的一键派生操作,不等同于生成请求上的 `style="pixelArt"`。它不打开参数面板,只处理当前活动图层,保留源图,并在源图右侧创建同尺寸 PNG 派生结果;音频、视频、图片序列和 `character-animation` 不显示该按钮。 +- 已有图片像素规整固定调用登录态同源 `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` 闸(占位仍在时拦住从源图重新发起)是本条确立之前的遗留,方向与本条相反,后续应放宽而不是加固——尤其不得改成「让本机账本也参与防重」,那正是被本条禁止的「禁止一张图处理两遍」。 +- **跨会话续命层的存废条件(迁移到 durable job 时必须整层清除)**:完美像素的客户端机制按存在理由分三层。**A 层**——服务端单事务原子落库、preflight、归属校验、稳定 object key、预算边界——与是否有 job 无关,任何形态都要保留。**C 层**——POST 之后立刻一次 GET 对账,把结果分成 applied / dialog-missing / unknown 三档——保护的是「结果未知却告诉用户失败」这类谎报,属于主链路,也要保留(约 180 行)。**B 层**——本机请求账本 `perfectPixelOperationStore`、刷新后的 GET-only 恢复与孤儿对账、75 秒绝对窗口与它的锚点、`perfectPixelOperationId` 标记与账本的寿命对齐、exact retry、inline 占位到期与跨标签页归属登记(`useInlineGenerationPlaceholderExpiry`)——**只因为这条链路没有 durable job 而存在**,它提供的全部能力可概括为「关掉浏览器后还能找回结果 / 不重复」,而这两件事已被上文的优先级判据明确判为可接受损失。一旦完美像素改为走 `enqueue_editor_generation_job`,B 层的能力由队列与 job 行提供,**必须整层删除,不得与队列并存**——两套收口机制并行会产生「谁是终态权威」的二义性,比任何一套单独存在都糟。 +- B 层的边界是可验证的、且已确认只服务完美像素:`requiresLiveSession: true` 全仓仅有一处置位(完美像素提交路径),`claimActiveInlineGenerationDialog` / `releaseActiveInlineGenerationDialog` / `hasActiveInlineGenerationDialog` 的全部五个调用点也都在完美像素的提交、重试与恢复上。直接体量:`perfectPixelOperationStore.ts` 203 行(测试 228 行)、`useInlineGenerationPlaceholderExpiry.ts` 140 行(测试 401 行)、`hydratePerfectPixelOperation` 128 行,加上工作流里的恢复 effect 与窗口锚定,生产代码约 1100 行、测试约 2500 行(后两个数字是估算,前面几个是实测)。同为免费、同步、无 durable job 的手动图集拆分只用约 85 行客户端代码(失败即报错,`taskId` 用随机 UUID,无幂等、无对账),是本仓库对同类问题的既有廉价答案;完美像素额外的 B 层是**特例而非范式**,不得据它给其它链路加同样的机制。 +- 前端提交前先创建关闭 composer 的右侧生成占位,再解析或上传源图以取得稳定引用,随后把版本化 `perfectPixelOperation` 请求快照写入**本机账本**(占位本身只带 `perfectPixelOperationId` 标记)并 flush 当前项目布局,最后才发送 POST。`canvasCompletion.dialogId` 同时作为 operation identity、稳定 task identity 的输入和本地源图上传 ID;同一 operation 的上传路径与后续 POST 请求都不得随机漂移。`sourceImageSrc` 优先由当前图层已有的 `objectKey / resourceId / sourceAssetId` 解析;尚未登记的浏览器本地图片只执行 `ticket → OSS PUT → confirm → objectKey`,不为这条持久化输入换取 signed URL。一个 `AbortSignal` 必须贯穿源文件 fetch / 图片解析边界、ticket、PUT、confirm,完整上传 helper 的可选换签也必须透传同一 signal。正式请求不得包含 `data:` / `blob:`、signed URL 或普通外链。后端在读取源图前必须把该字段解析为当前 owner 已登记的私有 OSS object key,并核对 project / resource / asset 归属。 +- 源准备与 operation journal 使用两段绝对预算:`ticket → PUT → confirm` 连同源解析共用 90 秒;confirm 成功后形成稳定 `perfectPixelOperation` 并**同步写入本机账本**(`perfectPixelOperationStore`,owner + project 双键的 localStorage),布局里只留 `perfectPixelOperationId` 标记。原先的 strict layout save 通道(60 秒绝对预算、revision ACK 前 POST 为零)已整体删除:账本不再寄生在用户布局上,本机写入不过网络也不受服务端校验影响,同样能保证请求可被追溯。被解除的是**客户端侧**「拿不到 revision ack 就拒发」这一层阻断;端到端依赖仍在——布局 PATCH 被校验拒绝、占位因此从未落库时,POST 仍会被服务端以 409 拒收。POST 前仍然 `await` 一次 best-effort 布局保存——服务端要求占位**此前已经持久化**,否则 `validate_editor_pixel_art_snap_placeholder_exists` 直接 409;但 best-effort 不再提供成功 ACK,因此客户端**无法证明**该前置已满足,只能提高满足它的概率(占位可能已由此前的自动保存落库,PATCH 也可能成功而 ACK 丢失)。该 flush 没有整体上限,所以 75 秒对账窗口必须在 flush 返回、authority 复核通过之后才锚定,且首次提交与人工重试同此口径;锚定只覆盖 `submittedAt / reconcileUntil`,按同一 `operationId` 覆盖账本,request 与 dialog / operation / task identity 逐字节不变。此阶段失败持久化为 `failed + perfectPixelOperation`,保留同一 `sourceImageSrc / dialogId / taskId / request`;重试请求必须与账本中的 POST JSON byte-for-byte 一致且不得重新上传。**明确接受的行为,不是缺口**:占位恢复可删除之后,用户删掉未收口占位再从源图发起会得到第二个 identity,旧的服务端操作若迟到落库就会多出一份素材,两个 `taskId` 无法幂等合并。按上文的优先级判据,这属于「已生成资源丢失关联」而非主链路故障,代价是用户自行删掉多余素材,**不得**通过让本机账本参与防重来「闭合」——那是被明令禁止的「禁止一张图处理两遍」。confirm 成功后浏览器在 operation 首次 PATCH 落库前立即崩溃仍可能留下 object-only 记录;完全消除该窗口需要服务端 durable upload journal,不属于当前前端修复。 +- 该已有图片入口使用 strict 语义:只接受静态 PNG / JPEG / WebP,GIF、APNG、动画 WebP、图片序列及其它非静态媒体必须在处理前拒绝。strict 与生成风格复用完全相同的 legacy profile、峰值估算、单轴步长补全、walker、采样和编码;仅当横纵两轴都未检测到步长、legacy 即将使用 `min(width,height)/64` 统一网格兜底时拒绝。任一轴已检测到步长时,两条路径行为和输出必须一致。源图读取、解码、尺寸校验、排队、像素规整或 PNG 编码任一步失败 / 超时 / 不适用时,请求失败,不保留原图副本冒充成功,不执行最终 OSS PUT,也不创建 project resource、账号素材或结果图层。成功时只对最终 PNG 执行一次 OSS PUT,并至多各创建一个 `editor_project_resource` 和一个 `editor_asset`,再按 `canvasCompletion` 写回一个派生图层;不得保存逻辑低分辨率图、诊断图或前后对比图。 +- strict 的本次结果事实零写入边界截至首个最终 PNG PUT:所有可预判的引用、归属、类型、静态编码、元数据、网格适用性和 CPU 处理错误必须在此前失败;前置 owner-scoped 项目 / 素材读取仍可能按既有语义懒建默认 canvas / folder,这些基础记录不属于本次完美像素结果。后端先纯计算精确 object key 和候选 project resource,再调用只读 SpacetimeDB preflight 校验自定义素材目录归属、复用权威 completion planner,并执行 legacy / structured 的 2 MiB 总量与 512 KiB 单项门禁;默认目录尚未创建时允许通过,preflight 不写库。preflight 与 PUT / HEAD / 原子 persist 共用 60 秒绝对 deadline;preflight 失败或超时不得 PUT,也不得带 `resultPersistenceStarted`。最终 PNG 的 OSS PUT / HEAD 位于数据库事务外;验证上传结果后,asset object、project resource、账号素材与可选 canvas completion 由单个受 runtime service identity 保护的 SpacetimeDB procedure 在一次事务中原子提交,并重新校验目录、布局、幂等身份与 revision。preflight 不加锁或 reservation,所以通过后若目录或画布并发漂移,最终事务仍可能在 PUT 后拒绝并留下 OSS 孤儿对象;这是本次最小修复明确保留的 TOCTOU 边界。operation 以 `owner + project + canvasCompletion.dialogId` 为作用域,task / object / resource / asset ID 稳定派生,object key 携带规范请求与输入 / 输出摘要形成的 fingerprint;同内容重放只返回原结果,输入漂移或部分既有事实失败关闭。HTTP timeout/drop 不能撤销已发往远端的 procedure,客户端仍须按稳定 `taskId / objectKey / resourceId` 对账,不能把未收到回包等同于未提交。 +- `POST /api/editor/images/pixel-art-snaps` 是有副作用的 unsafe POST。客户端不得为它配置 `EDITOR_REQUEST_RETRY_OPTIONS`,请求字节可能已发出后不因 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 自动重放;Bearer 中间件在 handler 前以 `401` 拒绝、刷新 token 后的既有认证恢复不属于业务副作用重放,保持通用行为。POST 回包中的 `project / resource / asset` 不是结果 verdict;首次成功回包、未知异常、人工 exact replay 和刷新恢复都只读取项目 GET。`perfectPixelOperation.submittedAt / reconcileUntil` 在 pre-POST flush 返回、authority 复核通过之后、POST 发出之前建立统一 75 秒绝对窗口(该 flush 没有整体上限,锚在它之前会让窗口在请求发出前就烧光),POST 回包不能续期;读取必须立即执行一次,随后退避间隔不超过 5 秒,窗口已过期时仍执行一次即时 GET。每次项目读取使用 `requestJson.deadlineAt` 覆盖缺 token 补票、业务 fetch、401 refresh、重试退避与响应体读取;窗口内单次最多 10 秒且不得越过 `reconcileUntil`,过期后的唯一即时读取最多额外 10 秒。固定判据为:匹配 task 的唯一 resource 加已收口 dialog / 关联图层才是画布成功;dialog 不存在但存在匹配 task resource 才是 asset-only 成功;dialog 仍 generating、dialog 不存在且无匹配 resource、项目始终不可读或窗口耗尽均保持 unknown。素材库刷新只在项目终态后 fire-and-forget,同步抛错、异步拒绝或永久挂起都不得阻塞 verdict、项目快照应用和执行锁释放。 +- unknown 状态持久化为原 generation dialog 上的 `pending-confirmation + perfectPixelOperation`(账本在本机,布局只留 `perfectPixelOperationId`)。**用户可以随时删除该占位**,任何状态都不例外、也不弹确认:删除不撤销任何在途请求,结果照常落库并进素材库,服务端发现 dialog 已不在会返回 `DialogMissing`;封锁用户删除自己画布上的元素不是可接受的代价。删除后**结果不再自动回填画布**(服务端发现 dialog 已不在会返回 `DialogMissing`),这是用户主动放弃的结果,不得判定为缺陷;但对账本身不会因此停止——当前标签页已经在飞的 Promise 会继续读到终态,本机账本也会以孤儿身份在下次加载被读一次,结果确已落库时仍会提示用户去素材库取。未删除时用户可继续 GET 对账或显式按原 identity 重放。人工重试在 pre-POST flush **之后**才刷新观察窗口(同上一节的锚定口径),POST JSON 必须与持久请求 byte-for-byte 一致,不得按当前画布、目录、类型或标题重建,也不得创建第二个 dialog / task / object / resource / asset。hydrate 后只做 GET,不自动 POST、上传或重建请求。处理成功但事务内权威 dialog 已删除时,后端保留 object / resource / asset 并返回 asset-only 事实,canvas / revision 不变;前端只有在项目 GET 看见匹配 task resource 后才能提示“已保存到素材库”。现有布局 CAS 没有 deletion tombstone,completion 与其它已持久化布局编辑冲突时继续按权威 revision 守卫收口;尚未防抖落库的本地编辑合并不在本批范围。 +- 删除 generation dialog 的按钮、快捷键和右键菜单必须在写画布历史、清选择或执行低层移除前经过同一请求保护入口。未收口完美像素 operation 与其它占位同样可被立即删除,写正常的 `delete-generation-result` 历史并清理 identity;删除确认只对**计费**生成成立(现成弹窗讲的是「已消耗的泥点不会返还」,而完美像素 `generation_cost_mud_points = 0`),判据收敛为具名的 `requiresGenerationDeleteConfirmation`。低层 `removeCanvasGenerationDialogById` 必须无条件删除——低层对上层抗命正是「占位未删却写出伪历史」的根因。 +- 完美像素并发闸回归测试不得通过进程级队列 Atomic 的 before/after 判断“本用例未入队”。过期 deadline 用例只断言 `504`;queue guard 的 Drop 归还由独立用例覆盖,不引入 `--test-threads=1`、全局串行锁或其它串行化兜底。 +- 项目快照对账生成占位时必须检查全部同 ID 原始记录,不得用首项短路:通用 queued completion 只要任一记录未收口就执行既有第二次 GET;完美像素要求 operation dialog 唯一,命中多条时失败关闭为 `conflict`,不得按首条记录猜测成功。 ### 角色动作帧抠图像素边界 @@ -68,6 +89,7 @@ ## 数据与持久化 +- 完美像素派生结果仍复用既有 `asset_object / editor_project_resource / editor_asset / canvasCompletion` 数据模型,不新增表、资源类型或队列类型,但四类数据库事实改由单个 procedure 原子持久化。源图已有正式 project resource 时,成功结果的 `sourceResourceId` 指向该资源;最终 PNG 只 PUT 一次,同一 operation 至多对应一个 project resource 和一个账号素材。像素处理本身失败时三类业务写入都不发生;像素处理成功但事务内读取的权威占位已删除时,object / resource / asset 同事务提交并返回 `DialogMissing`,canvas / revision 保持不变,不能用提交时旧 placeholder 把图层重新插回。 - 新增 `editor_project` 表保存图片画布工程:`projectId`、`ownerUserId`、标题、创建时间和更新时间;历史 layout 字段暂保留为兼容列,不再作为权威画布数据。 - 新增 `editor_canvas` 表保存工程下的画布:`canvasId`、`projectId`、`ownerUserId`、标题、viewport、图层布局 JSON、创建时间和更新时间。当前编辑器使用项目默认画布,后续可扩展为一个 project 下多个 canvas。 - 新增 `editor_asset_folder` 表保存账号级素材文件夹:`folderId`、`ownerUserId`、名称、排序、折叠状态、系统默认标记、创建时间和更新时间。素材文件夹不归属于 project,同一个账号进入任一项目都能看到。 @@ -115,6 +137,7 @@ - `POST /api/editor/images/background-removals`:接收当前图片的 `objectKey`、`resourceId` 或 `assetId` 候选引用,登录态和稳定引用入口校验通过后创建外部生成任务,响应只返回 `queueState`。父 `external-generation-worker` 负责把候选引用解析为已登记、已校验当前账号归属的私有 OSS object key,只向唯一 `bgfilter-worker` 发起一次内部 HTTP RPC,传递 object key、`maxQueueWaitMs`、公式化 `callBudgetMs` 以及固定的 `background_mode=complex + seg_model=birefnet + cross_check=off`;父侧不下载原图、不签发 URL,也不发送 `file` 或 `screen_color`。子 worker 在每次真实 provider attempt 前签发 600 秒 OSS URL,以默认 `Q=2048` admission 保险丝和 provider 并发 `N=16` 限流,取得 provider permit 后才启动 `callBudgetMs`,并对同一次逻辑调用最多执行两次顺序 provider attempt;成功图片以内部 HTTP 二进制 body 返回父流程,父侧不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。complex 任意最终失败都直接使父任务失败,不进入阿里云或本地键色 fallback。请求可携带 `projectId`、`targetLayerId`、`assetFolderId`、`assetLabel`、`sourceResourceId` 和 `canvasCompletion`;成功后仍由父流程完成最终 OSS / project resource 持久化,有 `canvasCompletion` 时按生成占位写入结果图层,否则沿用旧的目标图层替换路径。provider 令牌只在子 worker 服务端通过 `GENARRATIVE_EDITOR_BGFILTER_TOKEN` 注入,未配置时兼容回退旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`;父子内部调用另使用独立内部 Token。 - `POST /api/editor/icon-spritesheets/generations`:按图标规范图和完整用户需求生成 spritesheet;为兼容现有契约,画布前端把完整文本作为 `iconDescriptions` 的唯一数组元素提交,不按分隔符或语义枚举解析数量。api-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 spritesheet,并与手动 `POST /api/editor/icon-spritesheets/slices` 复用同一套全连通域识别:识别多少个有效素材就拆多少个,按视觉阅读顺序命名为 `素材 N`,不读取 `iconDescriptions` 数量决定切片数。两条拆分路径共同限制单边 `4096`、总像素 `2048×2048`、最多 `64` 个切片。切片只在有界管线中按需编码,共享单个 HTTP client 并以最多 `2` 路并发执行 OSS `PUT + HEAD`;client 的连接与单请求超时分别固定为 `10s / 60s`,手动入口在下载最大 `32 MiB` 来源对象前取得 memory admission,上传收齐后立即释放整图 admission,不跨数据库等待持有。所有对象验证通过后,由单个受 runtime service identity 保护的 SpacetimeDB procedure 在一次事务中批量确认 `asset_object`、创建 project resource / account asset 并写入 cohort 完成事实,不得逐片发起三组 procedure 或在部分素材落库后伪造完整批次。resource / asset ID 由 owner、task 与切片序号稳定派生;同一批次不确定结果后重放只能复用内容完全一致的素材,冲突内容必须拒绝,来源资源还必须存在且与派生资源属于同一 owner / project。请求支持 `model`、`screenColor`、`segModel`、`aspectRatio`、`imageSize`、`priceMudPoints`、`projectId`、`assetFolderId` 和 `generationInputs`;`priceMudPoints` 必须来自编辑器生成计费配置中对应生图模型的尺寸档位(如 `nanobanana2` 的 `0.5K / 1K / 2K` 或 `gpt-image-2` 的 `1K / 2K`),后端用 `editor_generation_config` 校验后才调用上游;`nanobanana2` 走原生 `generateContent` 并写入 `generationConfig.imageConfig.aspectRatio/imageSize`,`0.5K` 传 `"512"`;`gpt-image-2` 走 `/v1/images/edits`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但自动拆分失败时保留整张透明图并返回非阻断 `sliceWarning`,手动拆分失败时返回接口错误。响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`。 - `POST /api/editor/images/generations` 与 `POST /api/editor/icon-spritesheets/generations` 还可携带可选 `style`;公开合法字符串为 `none / pixelArt`,兼容归一化、支持的 `kind`、非阻断告警和零新增持久化规则以“静态图片风格与像素规整边界”为准。`POST /api/editor/ui-designs/assets/extractions` 不接受该字段。 +- `POST /api/editor/images/pixel-art-snaps`:对已登记的静态图片执行免费的同步完美像素化。请求使用 `sourceImageSrc` 承载当前 owner 可读取的 `objectKey / resourceId / assetId` 候选稳定引用,`projectId / canvasCompletion` 必填且 `canvasCompletion.dialogId` 必须非空,`sourceResourceId / assetKind / generationInputs / assetFolderId / assetLabel` 可选;拒绝内联媒体、未登记对象和非静态栅格素材。客户端提交的 `generationInputs` 必须与其余生成入口一样先经 `sanitize_editor_client_generation_inputs` 剥离 `screenColorHex / mattingProvider / mattingModel` 三个服务端保留审计字段,再进入任何 IO——这三项是背景色决策与 bgfilter 实际执行后由服务端写入的处理事实,不接受客户端声明;本端点是纯几何规整、不抠图,任何 matting 元数据出现在这类记录上本身就是伪造。源图已有正式 project resource 时前端应带上 `sourceResourceId`:该资源随 owner-scoped 项目读取一并鉴权,服务端可直接取用其 objectKey,省去按注册 ID 的全账号项目与素材库扫描;此时 `sourceImageSrc` 应传该 objectKey 或同一个 `resourceId`,两者指向不同图片会被直接拒绝。不带 `sourceResourceId` 时仍需按注册 ID 解析,但全账号项目与素材库只取一次快照,注册 ID 解析、归属校验和跨记录 `assetKind` 收集全部在该快照上用 `_from_records` 纯函数完成,命中已登记记录即短路、两份记录都查不到才回落 asset object 点查;不得再调用内部自带两轮扫描的 `resolve_editor_reference_object_key_for_owner`。`get_editor_project` 到来源解析结束整体套同一份绝对处理预算,超时返回 `504` 且文案指向归属校验——预算从 handler 入口起算不等于覆盖该阶段,裸 `await` 会让请求一路走到下载才发现预算耗尽,并全程占用端点准入名额。像素处理使用 strict 失败语义且不进入外部生成队列;成功时只持久化一张最终 PNG,并返回对应 project / resource / asset 快照。服务端把规范化 `canvasCompletion.dialogId` 作为 operationId,以 owner / project 共同限定作用域,并从该 operation 稳定派生 task、asset object、resource、asset 身份;请求 fingerprint 覆盖来源 object key、来源与输出字节摘要、来源资源、素材类型、规范目录 / 标签、规范 generationInputs、completion 和算法版本。OSS PUT / HEAD 之后只调用一次原子 SpacetimeDB procedure;权威 dialog 仍存在时在源图右侧完成占位,已删除时只提交 object / resource / asset 而不推进 canvas revision。完整同内容重放返回 `AlreadyApplied`,同 operation 输入漂移或只有部分记录存在返回幂等冲突。 - `POST /api/editor/ui-designs/assets/extractions`:前端把红色框选轮廓绘入本地临时图后,先将该图上传 OSS 并确认 asset object,再以返回的 `objectKey` 作为参考图入队;Data URL / Blob URL 只允许停留在上传前的浏览器临时态。接口固定 `gpt-image-2` 和自动决策纯色背景素材提取提示词生成素材 spritesheet;api-server 先保存带纯色背景 spritesheet 源图,透明处理成功后再保存透明 spritesheet 并按连通域尝试拆分为 `素材 1..N`,返回结构复用图标 spritesheet 响应。请求必须携带 `screenColor`、`segModel`、`aspectRatio: "1:1"`、`imageSize: "1K" | "2K"` 和 `priceMudPoints`;框选数量不超过 6 个时前端按 `1:1·1K` 与 gpt-image-2 1K 价格提交,超过 6 个时按 `1:1·2K` 与 2K 价格提交。后端必须在调用上游前校验比例、尺寸和泥点价格,只允许 `1:1 / 1K / 2K`。透明处理最终失败时只保存并返回原图主结果,不生成透明图或切片;透明图成功但拆分失败时保留整张透明图并返回 `sliceWarning`。请求可携带 `projectId`、`assetFolderId`、`generationInputs` 和 `spritesheetLabel`,响应只返回实际产物对应的 project / resource / asset 快照及可选通用 `warning`;前端按后端快照落画布,不补造缺失产物。 - 图片生成请求边界:角色生成、图标 spritesheet 和 UI 素材提取的同源画布 request DTO 保留 `segModel`,前端不提供选择控件而是自动提交默认 `birefnet`;api-server 继续校验并在字段缺失时回落默认值。`background_mode`、`cross_check` 与角色动作逐帧去背的 `seg_model` 不属于前端请求字段,只在 api-server 到 loopback worker 的内部 RPC 中传递。请求中的 `segModel` 不进入用户可见 `generationInputs` 或任何普通用户响应。 - `POST /api/editor/images/edits`:按提示词和当前图片的已登记 `objectKey` / `resourceId` 修改图片,返回新的生成图片元数据;图片快速编辑当前只提交 `sourceImageSrc`,不提交隐藏的 `referenceImageSrcs`,并随用户当前选择提交 `model / aspectRatio / imageSize / size`。api-server 必须先归一模型再选择 VectorEngine 协议:`nanobanana2` 调用 `/v1beta/models/{model}:generateContent` 并把原图作为 `inline_data`、比例和清晰度写入 `generationConfig.imageConfig`;`gpt-image-2` 调用 `/v1/images/edits` multipart。gpt-image-2 路径在 provider 边界把目标尺寸和所有 multipart 参考图临时补齐到 16 的倍数;nanobanana2 路径保留 provider 的比例 / 清晰度请求,但两条路径回图后都以统一业务目标尺寸尝试归一。只允许缩小和轻微裁切;回图任意一边小于目标或比例偏差过大时保留 provider 实际回图及尺寸,并返回通用 `warning`,不得放大伪造所选档位。无论是否发生尺寸恢复都只创建一个 project resource / 账号素材,不显示重复“原始输出”。provider 对齐尺寸或原生 K 档像素不得泄漏到正常完成的最终响应、资源或图层 Resolution;变换失败降级时以实际 provider 原图尺寸为准。本地红框标记图必须先上传再提交 objectKey;请求携带 project / asset 上下文时由后端创建新 resource / asset,前端只消费响应快照。 @@ -160,7 +183,13 @@ - 发送消息后,面板先展示本地用户消息和请求等待态,再应用普通 JSON 响应中的 `deltaMessages`;客户端取消等待只终止本次 transport 等待,不把已经确认入队的外部生成任务改成停止态。 - Agent 工具任务完成并懒回填后,消息内缩略图不显示名称;前端通过编辑器作用域 Action Context 的 `refreshCanvas()` 直接重新读取工程快照和素材库,不从 Editor 经 Stage、Panel 和 MessageBubble 透传刷新 callback。图片、视频和音频结果携带有效 `resourceId` 时,在素材右键菜单显示“在画布中定位”;有效图片结果的普通单击也直接通过同一 Context 的 `focusResource(resourceId)` 请求画布在 `420ms` 内平滑 fit 到对应图层。结果卡片不声明按钮语义或 `tabIndex`,Enter 和 Space 不得触发定位;视频和音频的普通点击及原生播放器交互保持独立。定位只改变 viewport,不选择图层、不切换工具或侧栏、不收起 Agent 面板,也不避让面板覆盖区。缺少 `resourceId` 时单击无动作且不显示定位菜单项,目标图层已删除时保持无动作。对话入口触发生成时不创建“即将生成”画布占位,生成完成后由后端 `canvasCompletion` 落新图层。规划或工具失败时消息内必须保留可回读的失败状态和错误气泡,不能只弹一次性 toast 或返回瞬时 `errorMessage`。 - 画布 Agent 会话刷新后能从后端恢复会话标题、消息、附件和生成记录;前端不得根据本地临时状态伪造会话持久化结果。 -- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景调用同源 BFF `POST /api/editor/images/background-removals`;父流程解析并校验私有 OSS object key 后只调用一次唯一内部 `bgfilter-worker` 的 complex 链路,子 worker 负责签发 600 秒 URL、`N / Q` 限流和最多两次顺序 provider attempt,complex 失败不接入 fallback,成功二进制返回后仍由父流程完成最终持久化。有项目上下文时先在画布创建关闭面板的去背景生成占位,完成后由后端通过 `canvasCompletion` 把新 project resource 写入该占位并返回快照,无占位上下文时才用新的 project resource 引用替换当前图层。画布任务侧栏按“排队/生成中”和“已完成”分页,生成中排在排队前,生成中耗时从任务开始时间戳实时计算,排队中不计时;进行中任务只显示阶段文本和已用时,不显示百分比;完成态生成任务副标题显示用户提示词并单行截断;点击任务只聚焦对应画布内容,不激活生成面板或改变任务顺序,聚焦时必须预留图片上方工具栏、底部工具栏和可见生成对话框空间。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和自动决策纯色背景素材提取提示词。透明处理及拆分正常完成时,透明 spritesheet 和拆分素材都按后端快照保留为画布图层;透明处理失败时仅原图作为主结果,既不要求透明图也不要求切片;透明图成功但拆分失败时保留整张透明图并展示拆分告警。三种完成结果都以后端项目快照为准。 +- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、完美像素按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。完美像素只对当前静态栅格图层一键执行,按钮在请求期间按 layer id 进入 disabled / busy,首个 await 前用同步 ref 抢占,连续点击不得重复提交;完成后保留源图并在右侧显示派生 PNG,明确失败的占位保留错误且释放 busy。该路由是 unsafe POST 且不得配置自动重放;纯校验、排队或预算等明确未进入结果持久化的响应可直接失败,transport、网关、abort、客户端超时或 `details.resultPersistenceStarted = true` 属于未知结果,必须按下列 durable operation 与 GET-only 契约收口。该图层的素材类型保存在途时(`persistingAssetKindLayerIds`)完美像素按钮同样必须 disabled / busy,并在 handler 里用同步 ref 二次拦截——请求同时携带 `assetKind` 与 `sourceResourceId`,本地类型已改而资源尚未落库时两者不一致,后端 `resolve_editor_pixel_art_snap_asset_kind` 直接返回 `400`,只留下需要手动清理的失败占位。这与相邻的拆分图集按钮共用同一套门禁,但保存态的无障碍名称必须区分(完美像素用 `完美像素等待素材类型保存`),否则 `icon-spritesheet` 图层上两个按钮会同时叫「素材类型保存中」。 +- 裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景调用同源 BFF `POST /api/editor/images/background-removals`;父流程解析并校验私有 OSS object key 后只调用一次唯一内部 `bgfilter-worker` 的 complex 链路,子 worker 负责签发 600 秒 URL、`N / Q` 限流和最多两次顺序 provider attempt,complex 失败不接入 fallback,成功二进制返回后仍由父流程完成最终持久化。有项目上下文时先在画布创建关闭面板的去背景生成占位,完成后由后端通过 `canvasCompletion` 把新 project resource 写入该占位并返回快照,无占位上下文时才用新的 project resource 引用替换当前图层。画布任务侧栏按“排队/生成中”和“已完成”分页,生成中排在排队前,生成中耗时从任务开始时间戳实时计算,排队中不计时;进行中任务只显示阶段文本和已用时,不显示百分比;完成态生成任务副标题显示用户提示词并单行截断;点击任务只聚焦对应画布内容,不激活生成面板或改变任务顺序,聚焦时必须预留图片上方工具栏、底部工具栏和可见生成对话框空间。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和自动决策纯色背景素材提取提示词。透明处理及拆分正常完成时,透明 spritesheet 和拆分素材都按后端快照保留为画布图层;透明处理失败时仅原图作为主结果,既不要求透明图也不要求切片;透明图成功但拆分失败时保留整张透明图并展示拆分告警。三种完成结果都以后端项目快照为准。 +- 2026-08-04 修订:完美像素前端已经让素材刷新退出 verdict,持久化 operation 请求快照与 `pending-confirmation`,并接入刷新后的 GET-only 恢复;是否成功只能由下面的项目 GET 正向证据判定。 +- 完美像素以 durable operation 为提交边界:请求账本 `perfectPixelOperation = { version: 1, kind: "perfect-pixel", operationId, taskId, request, submittedAt, reconcileUntil }` 存在**本机** `perfectPixelOperationStore`(owner + project 双键的 localStorage),其中 `operationId` 等于规范化 dialog id、`taskId` 固定为 `pixel-art-snap-{operationId}`,`request` 是稳定源引用解析完成后的完整 `EditorPixelArtSnapInput`,`submittedAt / reconcileUntil` 构成 **从 POST 发出时刻起算**、不得被 POST 回包续期的 75 秒整链绝对窗口——pre-POST flush 没有整体上限,锚在它之前会让窗口在请求发出前就烧光。项目布局里只保留 `perfectPixelOperationId` 标记,用于把这类占位与队列型占位区分开。**标记与账本的寿命必须对齐**:账本在收口那一刻清除,因此收口态占位(带非空 `generatedLayerId` 且状态不是 `generating` / `pending-confirmation`)既不再写出标记,也不得因为「有标记、没账本」被判成无效——服务端完成 completion 时只做字段级改写、从不摘标记,任何忽略这一点的判据都会把每一次成功判成失败。账本读不到(换设备、清缓存、隐私模式、配额写满)时,**未收口**占位收口成可删除的失败态,不得据此阻断用户删除或重做。完美像素 dialog id 使用跨标签随机 identity,不能复用每个标签页都会从 1 开始的局部计数器。inline 源图以该 identity 作为稳定 upload ID,只执行 object-only 上传,不等待 signed URL;快照不得包含 Data URL、Blob URL 或 signed URL。POST 前仍需 `await` 一次 best-effort 布局保存(服务端要求占位此前已持久化,见上文 409 条款),但保存冲突、鉴权失败或重试耗尽**不再让 POST 为零**——账本已在本机、请求可被追溯,客户端照常发出,由服务端裁决。人工重试只能原样重放该快照与同一 operation,不得重新 placement、上传、读取当前图层字段或暗中换 identity;快照缺失、损坏或与 dialog / project / task / completion 不匹配时失败关闭。首次提交或人工重试在途期间若 owner、project 或组件生命周期已经变化,旧响应的素材写入、项目应用、提示与对账副作用必须全部忽略,不能把前一账号的结果写入当前账号状态。 +- 完美像素 unknown-result 的 verdict 只来自项目 GET,POST 响应体不得直接判成功:找到唯一稳定 task resource 且 dialog 已收口、结果层精确指向该 resource 时为 `Applied`;resource 存在且 dialog 不存在时为 `DialogMissing`,结果只在素材库;dialog 仍 generating(包括匹配 resource 已先可见)或 dialog 不存在且无匹配 resource 时继续有界轮询;resource 与 dialog / layer 出现原子事务不可能产生的错配时保持待确认并提示冲突,禁止自动 POST。首个 GET 立即执行,此后退避不超过 5 秒;即使绝对窗口已过期也必须读取一次。GET 的绝对 deadline 从进入 `requestJson` 起覆盖鉴权恢复、所有 attempt 和响应体读取;不能把只覆盖响应头的 `timeoutMs` 当成整次读取上界。单次 deadline 到期按一次读取失败处理,随后由轮询返回 `pending`,首次提交必须进入 `finally` 释放 dialog ownership 与图层锁,hydrate 恢复必须清理 recovery controller。`refreshAssetLibrary` 只在终态后 best-effort 触发,不进入轮询 deadline、`Promise.all` 或成功判断,同步 throw、异步 reject 和永久挂起均不得阻塞。轮询到期或 GET 失败后 dialog 转 `pending-confirmation`,保留 operation 与请求快照并释放页面 busy,不得伪装成普通失败或声称素材已保存。 +- 完美像素恢复只对账、不重新执行:项目 hydrate 后识别带有效 operation 账本的 `generating` / `pending-confirmation` dialog,只按稳定 task/resource 做 GET-only 轮询,绝不 POST、重新上传、重新准备来源或为了恢复而先写布局;owner/project 切换、卸载或更高 revision 到来时旧轮询结果不得生效。恢复还必须覆盖**孤儿账本**——本机有账本、布局里却没有对应占位,这正是「POST 已发、布局尽力保存没落盘、标签页关闭」的结局。孤儿走一次确定性的读(不轮询):`reconcileUntil` 只描述「结果可能还在飞」,而孤儿来自已经消失的会话,按它短路会让这条兜底分支在唯一的目标场景(稍后重开,必然晚于 75 秒)下永不生效。收口口径:`dialog-missing` 提示结果只进素材库;`applied` 静默刷新素材库(读到的就是当前权威状态,结果本就在眼前);未落库完全静默。三者都清账本,读失败不清——那是「不知道」而非「知道没有」。新写入的 v1 operation 固定使用 75 秒跨度;为兼容第一批和滚动升级中的旧标签页,hydrate 仍接受跨度及未来时钟偏差不超过 240 秒的旧 v1 journal。若旧 `submittedAt` 位于可接受的未来区间,先把它规范化到当前时间,再把 `reconcileUntil` 压到 `min(持久截止, 规范化 submittedAt + 75 秒, 当前时间 + 75 秒)`;写回形状必须继续满足 `reconcileUntil >= submittedAt`,确保下次 hydrate 仍保留同一 identity。带 operation 的占位不受 `requiresLiveSession` TTL 清理(系统不替用户删),但**用户主动删除始终允许**——两者是不同的事。TTL 只兼容完全没有 operation 标记字段的历史 inline 孤儿,字段存在但内容损坏时必须保留并失败关闭,清理 legacy 孤儿时必须同时更新 `project.layers` 与 `project.canvas.layers`。恢复到期仍持久保持 `pending-confirmation`,只有用户明确点击重试才进入 exact replay。滚动升级期间从布局读到的 legacy 内联账本必须在 hydrate 后一次性迁入本机账本——布局里的内联快照会在下一次保存时被剥成标记,不迁移就再没有任何路径能补写,部署那一刻仍在途的操作会在第二次加载失去 exact retry identity。**已知限制**:本会话的「已观察」记账按 operation 记录,用于避免同一次 operation 被并发轮询两遍;若用户删除仍在处理中的占位、待原请求收口后再 `Ctrl+Z` 撤销删除,复活的占位在**当前会话内**不会被重新对账,会一直显示处理中。刷新页面即自愈(hydrate 会按标记与账本重新判定),且用户随时可以再删一次。该记账同时承担「并发保护」与「本会话已驱动过」两种语义,要根治需先拆开这两件事;在此之前不接受以「删除路径显式清观察记录」等跨 hook 埋线的方式局部绕过。 +- legacy inline 占位的本会话归属必须由同一份封装 ownership 管理:同步 Set 在首个 await 前完成 `claim`,保证到期判定即时可见;`claim / release` 仅在 membership 真变化时推进 React 可观察的 version,`release` 即使发生时 dialogs 与 callbacks identity 都不变,也必须立即唤醒到期 effect 重新判定。禁止重新暴露可变 Set ref 或直接修改 `.current`,React 不会因为 ref 内容变化而重跑 effect。 - 重绘生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图,且重绘面板保持打开。 - 快速编辑 / 重绘站内 public 示例图、历史 generated 图或 OSS generated 图时,优先复用当前图层已有 `objectKey` / `resourceId` / `sourceAssetId`;尚未登记且没有稳定引用的浏览器本地图片或普通 public 图片路径都必须先上传并取得 objectKey。前端不得再把正式对象下载成 `data:image/*;base64,...` 后提交,也不得把 Data URL / Blob URL 写入外部生成持久任务 JSON;后端收到引用后统一做 owner 归属校验并签名读取。 - 快速编辑不保留额外参考图入口;点击修改时只把原图或红框序号标注图作为 `/api/editor/images/edits` 的 `sourceImageSrc` 提交给后端。 diff --git a/docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md b/docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md index c8045545f..586b88971 100644 --- a/docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md +++ b/docs/【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md @@ -2,7 +2,7 @@ - 日期:2026-06-11 - 关联故障:release 环境 api-server 周期性全量 `spacetime_stage="pool_acquire" elapsed_ms=45000` 超时,`/readyz` 503(`reason=spacetime_unhealthy, stage=pool_acquire`),重启后临时恢复。 -- 涉及代码:`server-rs/crates/spacetime-client/src/lib.rs` +- 涉及代码:`server-rs/crates/spacetime-client/src/active.rs` ## 故障根因 @@ -28,6 +28,8 @@ 3. acquire 改为 CAS 抢占槽位:持有 permit 即保证并发持有者不超过 `pool_size`,扫描一轮必然命中空闲槽位,彻底删除自旋循环;建连失败直接返回错误,槽位由租约 Drop 复位。 4. `release_connection` 退化为 `drop(lease)`,显式与隐式归还共用同一条兜底路径。 +这里的“取消安全”只指本地连接租约、槽位与 permit 可回收。RPC 已发出后,handler timeout/drop 不会取消或回滚远端 procedure,结果必须按 unknown 读取权威事实对账;`dropped_lease_releases_slot_and_permit` 只覆盖本地 Drop,不构成远端取消测试。 + ## 验收 - `cargo test -p spacetime-client --manifest-path server-rs/Cargo.toml --lib`(44 通过,含上述连接池与缓存连接测试) diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 8e145d8fc..05bb5a2c5 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -243,10 +243,16 @@ 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。 ## 外部服务与资产 +- 已有图片完美像素化:登录态 `POST /api/editor/images/pixel-art-snaps` 使用 `sourceImageSrc` 承载 `objectKey / resourceId / assetId` 候选稳定引用,要求 `projectId / canvasCompletion` 且 `canvasCompletion.dialogId` 必须非空,并可携带 `sourceResourceId / assetKind / generationInputs / assetFolderId / assetLabel`;BFF 必须在下载前将候选解析为当前 owner 已登记的私有 OSS object key,并校验 project / resource / asset 归属,拒绝 `data:` / `blob:`、signed URL、普通外链和音频、视频、图片序列等非静态栅格输入。归属校验有两条等价路径:带 `sourceResourceId` 且 `sourceImageSrc` 能免查确认指向同一张图(本身即该 objectKey 或就是该 resourceId)时,来源资源已随 owner-scoped 项目读取完成鉴权,直接断言 `resource.ownerUserId` 与 `resource.projectId` 后取用其 objectKey,不再按注册 ID 做全账号项目与素材库扫描;两个字段指向不同图片必须直接拒绝而不是退回扫描。其余情况仍走完整解析。跨记录的 asset_kind 扫描随扫描一并省略,按 `(bucket, objectKey)` 的存储类型点查两条路径都保留,动图仍由下载后的静态编码门禁按实际字节拒绝。编码门禁只接受静态 PNG / JPEG / WebP,明确拒绝 GIF、带 `acTL` 的 APNG 及带动画标志 / `ANIM` / `ANMF` chunk 的 WebP。处理复用 `platform-image` 纯内存 snapper、单边 `10000` 与总像素 `8294400` 上限,并发控制分两层:端点级并发闸最大 `4`、等待队列上限 `2048`,在首次 IO 之前取得,队列满返回 `503` 并带 `Retry-After`,等待超预算返回 `504`;内层是与生成风格共享的进程级 CPU 并发 `2`。30 秒总预算从 handler 入口起算,覆盖归属校验读取、OSS 下载、两层排队与规整全过程。OSS 读写共用带 `connect 10s / total 120s` 的进程级 HTTP 客户端。strict 与生成风格使用完全相同的 legacy profile、峰值估算、单轴步长补全、walker、采样和编码,唯一差异是横纵两轴都未检测到步长时,不执行 `min(width,height)/64` 统一网格兜底而返回不适用。任一轴已检测到步长时,两条路径行为和输出必须一致。读取、解码、校验、排队、规整、PNG 编码任一步失败 / 超时 / 不适用时,在最终持久化前返回错误,OSS PUT、asset object、project resource、账号素材和画布 layer 增量都必须为零。成功结果保留源图,只对最终 PNG 做一次 OSS PUT,并至多各创建一个 `editor_project_resource` 和一个 `editor_asset`;源图已有正式 project resource 时,结果资源以 `source_resource_id` 关联该资源,再按 `canvasCompletion` 尝试写入一个右侧派生 layer。completion 读取的权威 dialog 已删除时沿用现有语义跳过画布写入,不得用请求中的旧 placeholder 复活图层;已经成功落库的 resource / asset 可以保留。客户端回包时若本地 dialog 已删除,不应用完成快照;现有布局 CAS 没有 deletion tombstone,completion 先提交、删除保存后冲突的极端竞态仍按权威快照收口。客户端不得为该 unsafe POST 配置 `EDITOR_REQUEST_RETRY_OPTIONS`,请求字节可能已发送后不因 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 自动重放;Bearer 中间件在 handler 前拒绝请求后的既有认证恢复继续保留。结果未知时先 GET 权威项目 / 素材快照。 +- 完美像素持久化边界:所有可判定的稳定引用、owner、项目、来源资源、素材类型、静态编码、元数据、网格适用性、排队、CPU、解码、规整和编码校验都必须在首个最终 PNG PUT 前完成。handler 先用纯 prepare 生成精确 object key 和候选 project resource,再调用只读 `preflight_editor_pixel_art_result_and_return`;preflight 校验自定义素材目录归属(尚未创建的默认目录允许通过)、复用权威 canvas completion planner,并对 legacy / structured 候选布局执行 2 MiB 总量和 512 KiB 单项门禁。preflight 与后续 PUT / HEAD / 原子 persist 共用同一份 60 秒绝对 deadline;preflight 失败或超时不得发送 PUT,也不得附加 `resultPersistenceStarted`。最终 PNG 的 OSS PUT / HEAD 仍位于数据库事务外;确认上传结果后,`asset_object + editor_project_resource + editor_asset + optional canvas completion` 必须由 `persist_editor_pixel_art_result_and_return` 在一次 `try_with_tx` 中原子提交,handler 不得先调用 `confirm_asset_object` 或三个旧分段 helper。最终 procedure 必须重新校验目录、布局、幂等身份和 revision,不能把 preflight 结果当成提交凭证。preflight 不创建锁或 reservation,因此通过后若目录或画布被并发修改,最终事务仍可能在 PUT 后拒绝并留下无引用 OSS object;当前不做破坏性删除补偿或历史孤儿清理。该原子保证只覆盖本次结果事实;前置 owner-scoped 项目 / 素材读取仍可沿用既有默认 canvas / folder 懒建语义,不把整个请求声明为数据库只读。operation 以规范化 `canvasCompletion.dialogId` 表示并由 owner / project 限定作用域;task ID 可由前端直接推导,object / resource / asset ID 按同一 operation 稳定派生,object key 必须包含覆盖规范输入、来源 / 输出摘要与算法版本的 64 位 fingerprint。完整同内容既有记录只读返回 `AlreadyApplied`,不得再次执行 layout CAS 或推进 revision;同 operation 输入漂移、稳定 ID / object location 冲突或 object/resource/asset 只有部分存在时必须整笔失败关闭并映射 `409`,不得补写或覆盖第一次事实。权威 dialog 已删除时 object/resource/asset 仍在同一事务提交,canvas / revision 不变并返回 `DialogMissing`。HTTP timeout/drop 不能撤销已经发往远端的 procedure,因此首个 PUT 后仍设置 `resultPersistenceStarted=true` 并按稳定身份对账;该标记不再表示数据库可能部分提交。 +- 完美像素 unknown 与并发闸测试边界:上一条末句“结果未知时先 GET 权威项目 / 素材快照”的旧表述已撤回,项目 GET 才是唯一结果 verdict;素材刷新只允许在项目终态后 best-effort 触发,不能参与成功判断。无 dialog 只有同时存在匹配稳定 task 的唯一 resource 时才是 asset-only 成功,否则保持 unknown。过期预算用例只断言返回 `504`,不得读取进程级 `EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH` 的 before/after;queue guard 的 Drop 归还由独立用例覆盖。不得用相对断言、`--test-threads=1` 或全局串行锁掩盖并行竞态。 - LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;`platform-llm` 文本请求默认走 Responses,旧 `/api/llm/chat/completions` 代理和少数旧运行态聊天显式保留 Chat Completions 兼容协议;创意 Agent `gpt-5` Responses / Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/responses`。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5 客户端来源;后续排障时优先确认 VectorEngine `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。 - LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;创意 Agent `gpt-5.4-mini` Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/chat/completions`。通用 `/api/llm/chat/completions` 代理使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible`、`GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1`、`GENARRATIVE_LLM_MODEL=gpt-5.4-mini`;未单独配置 `GENARRATIVE_LLM_API_KEY` 时可复用 `VECTOR_ENGINE_API_KEY`。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5.4-mini 客户端来源;后续排障时优先确认 VectorEngine `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。 @@ -605,6 +611,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - Rust 结构体:`EditorCanvasGenerationDialog` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` - 说明:图片画布生成对话框表,保存 canvas / project / owner、生成模式、状态、可选 source / generated layer、占位几何和有界扩展字段;typed 列为快照真相,`dialog_json` 只保留未结构化参数。当前 worker completion 以读取时 revision 调用 CAS 保存,冲突时拒绝覆盖;`job_id + worker_id + lease_token` 栅栏下的资源 / layer / dialog / job 单事务完成仍是后续收口。 +- 完美像素 completion:同步处理成功后按当前 revision 重新读取权威 dialog;目标 dialog 存在时只写入一个派生 layer 并关联结果,目标 dialog 的删除已先持久化时跳过 layer / dialog 写回,不得按请求快照重建占位。该分支不属于 external job completion,允许此前已成功创建的 resource / asset 保留。 - 索引:按 canvas 和 project 读取结构化行;当前未建立 external job 二级索引。 ### `editor_canvas_layout_migration` @@ -619,6 +626,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - Rust 结构体:`EditorProjectResource` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` - 说明:图片画布工程资源元数据表,保存已经放入某个 project 画布的上传 / 生成图片资源快照、OSS 引用、尺寸、来源类型、prompt、provider、task、源资源关系、`asset_kind`、`generation_inputs_json` 和历史 `public_showcase_enabled`。`asset_kind` 是跨布局共享的资源默认素材类型;单个结构化图层的差异只写 `editor_canvas_layer.asset_kind_override`,不能通过新增资源行模拟标签修改。`generation_inputs_json` 保存用户可见生成输入快照,供图片信息页刷新后恢复。`public_showcase_enabled` 只保留旧接口兼容,不再作为 `/creation` 的 `陶泥儿精选` 事实源;精选公开改由账号级生成素材提交 `editor_showcase_asset` 审核决定。图片 / 图标 / UI 提取等生成 BFF 在请求携带 `project_id` 时负责创建该表记录并把 resource 快照返回前端;前端只保存稳定 `resource_id` 布局引用,不能把同一生成结果再次作为正式业务真相写入。项目封面快照也落在该表,使用 `asset_kind = project-cover-snapshot`、`source_type = uploaded` 和私有 OSS / asset object 引用,代表画布当前视口栅格化后的静态封面;项目列表和创作主页最近项目只读取最新封面快照资源,不在列表页根据 layout 临时拼画布。从账号级素材库把同一生成素材拖回同一项目画布时,后端优先复用同项目内同源同媒体资源,避免每个图层实例都插入新的资源行。账号级素材删除不级联删除该表,避免历史画布丢图。结构化 canvas 的几何、层级、分组和资源引用以 `editor_canvas_layer` 为权威,生成器对象以 `editor_canvas_generation_dialog` 为权威;legacy canvas 才在 2 MiB 上限内从 `editor_canvas.layers_json` 兼容读取。新写入不再把素材生成输入快照作为图层布局真相保存。历史普通图层缺资源只能由 migration operator 调用 `repair_editor_canvas_resources_and_return` 定向修复:procedure 每次只处理一个尚无迁移记录的 legacy canvas,校验 owner/project、revision、canvas/project 两份 raw layout SHA-256、精确 layer/resource/sourceResourceId、同工程替换资源与 private asset_object 谱系;图片只替换引用,音频只恢复经核验的 `420x120` 项目资源行。运维入口 `npm run spacetime:editor-canvas-resources:repair` 默认 dry-run,apply 必须绑定 plan SHA-256 并在成功后自动复核 already-repaired,禁止手工 SQL 绕过事务 guard。 +- 完美像素资源:处理成功时只允许一个最终 PNG 对象对应一个新 project resource;源图已有正式 project resource 时,`source_resource_id` 指向该资源;结果尺寸与最终 PNG 一致,不写逻辑低分辨率图、诊断图或前后对比图。成功时同时创建一个同源 `editor_asset`,请求省略素材文件夹时落入默认素材文件夹;completion 因权威 dialog 的删除已先持久化而跳过画布写回时,这两类已确认资源无需回滚。 - `generation_inputs_json` 包络契约:`fields` / `references` 是图片信息读取的用户可见生成输入快照;顶层允许保存后端内部结果扩展。现有 `screenColorHex` 保存实际背景色,角色、图标图集和 UI 图集抠图派生资产使用 `mattingProvider` / `mattingModel` 保存实际成功的处理后端与模型。BgFilter 保存本次 `seg_model`,阿里云通用抠图保存 `Aliyun Matting / segment-common-image`,本地键色保存 `Genarrative Local / screen-color-keying`。同源画布 BFF 的角色、图标和 UI 请求由前端自动提交 `screenColor=auto` 与默认 `segModel=birefnet`,其中 `segModel` 是不可由用户选择的请求控制字段,不进入 `generationInputs`;`background_mode` 和 `cross_check` 只属于 api-server 到 worker 的内部 RPC。External OpenAPI 不开放 `segModel`。上述内部结果字段不写入 `fields`,普通用户(包括素材 owner)与匿名公开读取均不得取得;普通用户响应还必须省略素材顶层 `provider` 和内部处理 `model`,但保留正常用户可见 `model` 与其他合法的顶层功能字段。后台管理和服务端审计可读取原始值。过滤只作用于普通用户 / 公开响应边界,不修改素材或精选快照,因此历史数据无需迁移。 - 普通用户生成结果契约:图片、图标图集、视频、音频和角色动画的完成响应与新建画布图层均不返回或写入生成 provider;项目资源、素材、精选和 Agent 紧凑结果使用同一读取边界。真实 provider 只保留在持久化、tracking / tracing 和后台管理原始审计中。该规则针对生成供应商元数据,不改变直传票据等必须由客户端执行的存储协议字段。 - 普通用户错误契约:手动去背景和角色动作透明化的原始服务端错误可能包含 BgFilter、分割模型或 provider 细节;Owner HTTP 响应与外部任务状态必须按 job kind 返回稳定业务文案,原始错误只保留在任务记录、tracing 与后台审计。 diff --git a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md index 876b2e86b..37a438a64 100644 --- a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md +++ b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md @@ -123,7 +123,7 @@ POST /api/profile/api-keys DELETE /api/profile/api-keys/{keyId} ``` -前端入口位于登录后个人中心的 `我的 → 开发者 API Key`,用于查看当前 Key、创建新 Key、复制一次性明文和撤销已创建 Key。外部 OpenAPI 只描述 `/api/external/v1` 下可由 API Key 调用的接口,不混入登录态 API Key 管理接口。 +前端入口位于登录后个人中心的 `我的 → 开发者 API Key`,用于查看当前 Key、创建新 Key、复制一次性明文和撤销已创建 Key。Key 卡片中的创建时间和最近使用时间必须格式化为 `YYYY-MM-DD`,不得直接展示后端时间戳。外部 OpenAPI 只描述 `/api/external/v1` 下可由 API Key 调用的接口,不混入登录态 API Key 管理接口。 ## 版本与兼容策略 diff --git a/docs/【图片画布】撤销范围与操作提示方案-2026-07-17.md b/docs/【图片画布】撤销范围与操作提示方案-2026-07-17.md index 1385fc039..57c2aef3c 100644 --- a/docs/【图片画布】撤销范围与操作提示方案-2026-07-17.md +++ b/docs/【图片画布】撤销范围与操作提示方案-2026-07-17.md @@ -15,7 +15,7 @@ 允许撤销的典型操作包括移动图片、移动生成结果、调整层级、组合与取消组合、删除或剪切图片、隐藏图片、锁定与解锁、翻转、修改素材类型和调整画布视图。删除、剪切和隐藏允许撤销,是因为目标只会让内容重新出现。 -添加素材、上传到画布、粘贴、创建副本、生成图片、扩图新增结果、显示隐藏图片、替换图片以及其它会让当前结果消失的撤销必须被安全检查阻止。`Ctrl+C`、选择变化、滚轮或抓手视口移动、导出下载、项目重命名、素材库后端删除和生成任务副作用不进入画布历史;素材库后端删除发生后,同时剪除撤销栈和恢复栈中所有包含关联图层的目标快照,不能让更早的画布历史复活已删除素材。 +添加素材、上传到画布、粘贴、创建副本、生成图片、扩图新增结果、完美像素新增结果、显示隐藏图片、替换图片以及其它会让当前结果消失的撤销必须被安全检查阻止。`Ctrl+C`、选择变化、滚轮或抓手视口移动、导出下载、项目重命名、素材库后端删除和生成任务副作用不进入画布历史;素材库后端删除发生后,同时剪除撤销栈和恢复栈中所有包含关联图层的目标快照,不能让更早的画布历史复活已删除素材。 恢复同样执行动态安全检查。移动、层级、分组、锁定、翻转和视图等不会减少内容的操作可以恢复;重新执行删除、剪切、隐藏、删除生成结果或替换当前素材时必须被阻止。 @@ -24,6 +24,7 @@ - 撤销栈和恢复栈均保存操作类型、目标 `CanvasHistorySnapshot` 和创建时间,分别最多保留 60 条。 - 新画布操作把操作前快照写入撤销栈并清空恢复栈;成功撤销把当前快照写入恢复栈,成功恢复把当前快照写回撤销栈。 - 操作类型用于生成用户提示,并对添加、上传、生成和替换等明确会移除当前结果的撤销做保护;其它操作能否应用由当前快照与目标快照的差异检查决定。 +- 已有图片完美像素化成功落入画布前记录独立 `perfect-pixel` 历史类型,中文提示使用“完美像素”。该类型与 `generate-image`、`expand-image`、`remove-background` 一样属于新增结果保护操作:撤销不得删除派生 PNG,源图继续保留也不改变这一保护语义。像素处理失败、超时或不适用时不写历史。 - 安全检查以稳定的 `layer.id` 判断当前图层是否仍存在;内容身份优先比较对象存储 key、对象标识和媒体地址,序列帧结果比较完整帧列表。`resourceId`、`sourceResourceId`、`sourceAssetId` 等内部关联 ID 的延迟回填不视为图片替换。 - 恢复历史快照时,相同 ID 的图层以当前对象为权威,只从目标快照覆盖 `x`、`y`、`zIndex`、`groupId`、`assetKind`、`hidden`、`locked`、`flipX`、`flipY`。当前图层的资源关联、内容、媒体、生成元数据、`width` / `height` / `originalWidth` / `originalHeight` 和标题必须保留,不能被异步回填前的旧快照覆盖。 - 当前生成对话框和非活动生成结果按稳定 ID 纳入内容存在性检查,避免恢复操作删除当前生成结果。相同 ID 的生成对话框只从目标快照恢复占位框 `x` / `y` 以及 active / inactive 槽位对应的 `composerOpen`,当前占位框的 `width` / `height` / `originalWidth` / `originalHeight`、当前比例与清晰度等参数、`generating` / `failed` / 完成态、提示词、参考图和任务结果继续以当前状态为准,不能被旧历史快照降级。 @@ -34,6 +35,7 @@ - 修改素材类型的撤销与恢复仍以当前图层内容为权威,但每次成功恢复类型后都要创建与恢复类型一致的正式项目 resource,再回填新 `resourceId`。同一图层的 resource 创建请求使用单调版本号,迟到的旧类型响应不得覆盖更晚的 undo / redo 结果。 - 鼠标拖动在按下时暂存操作前快照;屏幕位移达到点击阈值后才开始改变画布坐标并只提交一条历史,阈值内的指针抖动和单击都不产生位移或历史记录。 - 本地即时结果与后端项目快照结果都必须在生成图层加入画布前写入一条生成历史;生成完成后的自动适合视图不再额外压入视口历史,保证用户第一次撤销就命中生成保护。 +- 完美像素的关闭 composer 占位沿用 generation dialog 的内容存在性保护。占位删除已先持久化时,后端 completion 不得用旧 placeholder 复活占位或派生图层;回包时本地占位已删除则前端不应用完成快照或写 `perfect-pixel` 历史,已经持久化的 project resource / 账号素材仍可由资源或素材入口读取。现有布局 CAS 没有 deletion tombstone,completion 先提交、删除保存后冲突的极端竞态仍按权威快照收口。 - 顶部消息复用 `PlatformRuntimeStatusToast`,成功使用中性色,被阻止使用警告色;连续触发会替换消息并重新开始 3 秒计时。 ## 验收重点 @@ -55,3 +57,5 @@ 15. 图层移动后从素材库删除关联素材,随后撤销或恢复都不得把已删除图层重新加入画布;普通画布删除未伴随素材库删除时仍可撤销。 16. 打开“修改图片”并输入未提交提示词后,从按钮等非输入控件触发撤销不得关闭弹窗或回退当前草稿。 17. 修改素材类型后立即撤销或快速撤销再恢复,最终只允许最新类型的 resource 响应回填;刷新项目后类型与最后一次成功历史操作一致。 +18. 完美像素结果加入画布后只写一条 `perfect-pixel` 历史;第一次撤销命中保护提示,源图和派生 PNG 都不消失。 +19. 完美像素处理中删除占位后,本次完成回包不得在本地重新应用结果或写 `perfect-pixel` 历史;删除已先持久化时,后端不得复活占位或结果图层;已成功持久化的资源或账号素材允许保留。 diff --git a/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md b/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md index e04e3b0b2..6c4abd51a 100644 --- a/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md +++ b/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md @@ -69,6 +69,21 @@ worker 完成生成任务时,本次先用读取时 revision 调用 CAS 保存 重复 completion 必须返回同一资源、layer 和 dialog 终态,不得重复插入,也不能因 dialog 暂时缺失而返回 `changed=false` 后仍把任务标记完成。任一步失败时整笔业务写回回滚,任务保留可诊断的失败或可重试状态。 +### 3.6 免费同步栅格派生完成 + +`POST /api/editor/images/pixel-art-snaps` 的完美像素化不是 external job completion:它免费、在当前 HTTP 请求内 inline 执行,不创建任务行,也没有 `job_id / worker_id / lease_token`。前端仍须先创建关闭 composer 的右侧 generation dialog,再解析或上传源图以取得稳定引用,随后 flush 包含该占位的当前布局,最后把稳定源媒体引用和带非空 `dialogId` 的 `canvasCompletion` 一次提交;结构化 / legacy canvas 的完成分流继续由后端决定,前端不能直接写表或本地补造正式 layer。 + +端点级并发排队、像素读取、静态 PNG / JPEG / WebP 编码门禁、解码、输入限制、legacy 网格步长估算、CPU 并发排队、规整和 PNG 编码全部发生在持久化前。两层排队的位置不同:端点级闸在首次 IO 之前,因此队列满的 `503` 早于任何 SpacetimeDB 读取和 OSS 下载返回;CPU 排队仍在下载之后、规整之前,等待超预算返回 `504`。两者都在持久化前失败,零写入结论不变。strict 与生成风格使用同一 profile、峰值估算、单轴步长补全、walker、采样和编码;仅在横纵两轴都未检测到步长、legacy 即将进入统一网格兜底时拒绝,任一轴已检测到步长时行为和输出完全一致。任一步失败、超时或不适用时不执行最终 OSS PUT,不创建 asset object、`editor_project_resource`、`editor_asset` 或结果 layer;不得保存原图副本、逻辑低分辨率图、诊断图或前后对比图冒充结果。处理成功时只 PUT 一张最终 PNG,并至多各创建一个 project resource 和一个账号素材;源图已有正式 project resource 时,结果资源的 `source_resource_id` 指向该资源。 + +该零写入保证只覆盖首个最终 PNG PUT 前的可预判与处理阶段。进入持久化后,PNG / asset object、project resource、账号素材与 canvas completion 仍跨 OSS 和多个 SpacetimeDB procedure,沿用既有非事务顺序;后段失败可以保留此前已经确认的对象或记录,不做自动删除补偿,也不由客户端重放请求。调用方应按 `task_id / object_key / resource_id` 重新读取权威项目和素材快照后显式收口。 + +同步 completion 写画布前必须读取当前 revision 和 dialog,而不能信任请求中提交时的旧 placeholder: + +1. dialog 仍存在时,在同一当前布局上完成一个结果 layer,保留源图,并关联结果 resource; +2. dialog 的删除已先持久化时,跳过 layer / dialog 写回,不重建占位、不复活派生图层;已成功持久化的 project resource / 账号素材允许保留; +3. CAS 冲突时不得拿新 revision 原样重放旧整包;按当前项目保存冲突规则重新读取权威快照并显式收口; +4. 客户端回包时若本地 dialog 已删除,不应用完成快照或写历史;现有布局 CAS 没有 deletion tombstone,completion 先提交、删除保存后冲突的极端竞态仍按权威快照收口。客户端不得为该 unsafe POST 配置 `EDITOR_REQUEST_RETRY_OPTIONS`。请求字节可能已发出后的 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 不自动重放,先通过 GET 核对项目 / 素材快照,再由用户显式决定是否再次执行;Bearer 中间件在 handler 前拒绝请求后的既有认证恢复继续保留。 + ## 4. 存量迁移 迁移按 canvas 执行 `backfill → hash 核对 → activate`,并保持幂等: @@ -110,5 +125,6 @@ SpacetimeDB 必须先于依赖新 procedure / bindings 的 API 发布;前端 - structured 模式下 typed 列而非扩展 JSON 决定几何、层级、分组、显示 / 锁定、资源引用、`asset_kind_override` 和 dialog 状态;标签展示和类型能力判断统一按 `override ?? resource default`。修改当前图层标签与清除覆盖都保持 `resource_id` 和资源行数量不变;复制共享同一资源并复制 override,随后各副本可独立修改 override。两个客户端基于同一 revision 写入时只允许一个成功,冲突方重载后端最新快照,不换上新 revision 原样重放旧整包。细粒度 batch mutation 是取消 2 MiB 兼容入口的后续项,不冒充为本次已完成。 - worker completion 当前以读取时 revision 做 CAS,冲突时拒绝覆盖;V2 保存和保存后快照在同一 procedure 结果内返回,避免“已提交但后续 GET 失败”的不确定结果。lease-fenced 资源 / layer / dialog / job 单事务 completion 仍是后续收口项。 - structured 快照刷新后,上传参考图、生成结果、占位与 dialog 状态均可恢复;资源存在但布局写入失败时不会伪装为保存成功。 +- 完美像素处理失败 / 超时时 OSS、resource、asset 和 layer 均无新增;成功时只有一个最终 PNG、至多一个 project resource 和一个账号素材。处理中占位删除已先持久化时,完成请求不复活 dialog 或结果 layer;回包时本地占位已删除则不应用完成快照,已成功创建的资源 / 素材仍可读取;传输结果未知时客户端不自动重放 unsafe POST。 - 回滚重组结果经 schema 校验、canonical hash / 资源引用核对且不超过 2 MiB;超限或不一致时明确拒绝且 structured 快照仍可读取。 - 完成 `npm run spacetime:generate`,确认 Rust 表字段、migration、生成 bindings、HTTP DTO 与前端 `assetKindOverride` 形状一致;再运行 `npm run check:spacetime-runtime-access`、`npm run check:spacetime-schema`、相关 Rust / API / 前端定向测试、`npm run check:encoding` 和 `git diff --check`。 diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index 93ef6b36a..25459c1a8 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -46,7 +46,7 @@ - `model`:支持 `gemini-3.1-flash-image-preview`(UI 显示 `nanobanana2`)和 `gpt-image-2`,默认 `nanobanana2`。 - `aspectRatio`:按 `x:y` 展示,选项跟随模型。 - `imageSize`:按 `0.5K / 1K / 2K` 展示,选项跟随模型。 - - `style`:可选生成后处理风格;未勾选像素艺术时传 `"none"`,勾选时传 `"pixelArt"`。 + - `style`:可选生成风格,同时影响提交给 provider 的提示词和回图后的像素规整;未勾选像素艺术时传 `"none"`,勾选时传 `"pixelArt"`。 - `priceMudPoints`:按当前模型和尺寸从编辑器生成计费配置计算;`nanobanana2 1K` 为 `12`,`gpt-image-2 1K` 为 `3`、`gpt-image-2 2K` 为 `5`。前端只提交配置函数计算值,后端用 `editor_generation_config` 校验,不允许素材生成面板自行写死价格。 - 模型与尺寸选项: - `nanobanana2`:比例 `1:1 / 4:3 / 3:2 / 2:3 / 9:16 / 16:9`;大小 `0.5K / 1K / 2K`。后端走 `/v1beta/models/{model}:generateContent`,把图标规范图作为 `inline_data`,并把 `aspectRatio` / `imageSize` 写入 `generationConfig.imageConfig`;`0.5K` 按 VectorEngine 文档传 `"512"`。 @@ -65,6 +65,7 @@ - 图标素材面板增加紧凑的 `像素艺术` 勾选项。选择保存于现有生成器快照,并可随现有请求和队列 payload 传递;不写入用户可见 `generationInputs`、素材元数据或新建的持久化记录。 - `style` 省略、为 `null`、空字符串或 `"none"` 时按内部 `None` 处理且不告警;`"pixelArt"` 启用像素规整。未知字符串按 `None` 继续生成,并通过既有通用 `warning` 返回 `unsupported-image-style`;非字符串 JSON 仍返回 `400`。 +- 2026-08-01 修订:`"pixelArt"` 不再只是后处理,同时向提交给 provider 的提示词末尾追加独立一行约束。图标链路使用「每个图标素材均为像素风格」,**不得**使用「画面为像素风格」——图集生成后要按纯色抠像,绿幕底必须保持平整,画面级像素化要求会与同一段提示词里的「纯色背景必须平整无纹理、无渐变」互相拆台;一张图内是多个彼此分离的素材,需要逐个点名,避免模型只把其中一部分做成像素块。注入发生在 `build_editor_icon_spritesheet_prompt` 返回之后,该函数签名和输出契约不变。约束句只随工程化提示词写入**原图 spritesheet** 的 `editor_project_resource` prompt 列;透明结果的 prompt 列是 `"去除纯色背景"`,自动拆分的切片是 `"自动拆分图集"`,两者都不含约束句。与普通图片和角色形象不同,本链路**响应体**的 `prompt` 字段返回的也是含约束句的工程化提示词,而不是用户输入——图标请求本身没有 `prompt` 字段(收的是 `iconDescriptions`),因此调用方(含外部 API v1)能直接看到绿幕子句、间距要求和本次新增的像素约束。以上 prompt 列写入与响应字段规则都是既有行为,与 `web/master` 一致,本次只是让被回传的模板多了一行。不新增 OSS PUT、项目资源、图集画布项或切片画布项。尚未约束各素材共用同一像素块大小(`estimate_step_size` 取全图相邻峰间距的第 30 百分位,块大小不一时步长估计会偏),等实测。 - 图标链路以已持久化的带纯色背景 provider 原图实际尺寸为基准;BgFilter 正常成功后,把 Alpha 蒙版回贴到该同尺寸平底原图,再执行像素规整。网格分析源使用平底 provider 原图,RGBA 采样源使用 Alpha 已回贴的透明图;规整结果不再经过独立的最终尺寸处理,直接上传透明 spritesheet,成功后才进入原有连通域自动拆分。 - 首版固定参数为分析色数 `16`、Alpha 覆盖阈值 `0.375`、像素格尺寸自动检测、固定色板关闭、K-means 最大采样 `262144`。单格颜色按 `Σ(A × RGB) / ΣA` 进行 Alpha 加权;覆盖率 `Σ(A / 255) / N >= 0.375` 且 `ΣA > 0` 时输出硬 Alpha `255`,否则输出严格 `[0,0,0,0]`。分析色数不限制最终输出色数。 - 像素规整 CPU 工作使用进程级最大并发 `2`;取得并发许可的排队时间与实际处理时间共享最多 `30` 秒预算,同时不得晚于当前请求 deadline,最终以两者中更早者为准。输入图片任一边不得超过 `10000` 像素,总像素不得超过 `8294400`;超限、排队超时或处理超时均保留 Alpha 已回贴的透明图并走非致命降级,随后仍可进入原有自动拆分。 @@ -93,7 +94,7 @@ - 默认提示文本会完整进入 prompt;用户输入不再被解析为素材数量。例如“各种敌人头像:骷髅 哥布林 强盗 龙 蝙蝠等”只是一段完整需求,不代表必须生成或拆出 `6` 个素材。 - 默认打开图标素材面板时选中 `nanobanana2 / 1:1 / 1K`;模型切换后,角色和图标素材面板之间沿用上次选择的模型。 - 图标素材生成请求必须带 `model`、`aspectRatio` 和 `imageSize`;`nanobanana2` 请求体必须包含 `generationConfig.imageConfig.aspectRatio/imageSize`,`gpt-image-2` 请求必须包含文档映射后的 `size`。 -- 图标素材面板可选择 `style: "none" | "pixelArt"`;`none` 完整保持原处理路径,`pixelArt` 在 Alpha 回贴后、自动拆分前执行内存像素规整,最终 OSS PUT、项目资源、图集画布项和切片画布项数量不得因此增加。 +- 图标素材面板可选择 `style: "none" | "pixelArt"`;`none` 完整保持原处理路径,且提交给 provider 的提示词与未带该字段时逐字一致,`pixelArt` 在提示词末尾追加「每个图标素材均为像素风格」并在 Alpha 回贴后、自动拆分前执行内存像素规整,最终 OSS PUT、项目资源、图集画布项和切片画布项数量不得因此增加。 - 图标素材生成可以上传普通参考图;提交时图标规范图仍走 `referenceImageSrc`,普通参考图走 `referenceImageSrcs`,二者都必须是稳定引用(`objectKey` / 项目资源 ID / 素材 ID),禁止 Data URL / Blob URL,并写入 `generationInputs.references`。 - 透明背景处理和自动拆分都成功后,画布同时出现透明 spritesheet 主图、其右侧的 provider 原图,以及从原图右侧铺开的全部有效连通域图标图层,图标依次命名为 `素材 N`;透明图集成功但拆分失败时仍出现透明主图与右侧原图,透明背景处理最终失败时只出现 provider 原图。 - 选中透明图集图层时显示 `拆分图集`;点击后源图集显示扫描蒙层与 `拆图中` 状态,工具栏按钮同步切换为旋转图标和 `拆图中` 并禁用重复提交。完成后恢复工具栏,不新增第二张图集,只在 provider 原图右侧追加自动识别的独立素材,并同步写入素材库。 diff --git a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md index c705be75a..9bbf89281 100644 --- a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md @@ -65,6 +65,7 @@ - 角色面板增加紧凑的 `像素艺术` 勾选项,请求使用可选字符串字段 `style`:未勾选传 `"none"`,勾选传 `"pixelArt"`。该选择可以随现有生成器快照和队列 payload 保存,但不写入用户可见 `generationInputs`、素材元数据或新建的持久化记录。 - `style` 省略、为 `null`、空字符串或 `"none"` 时按内部 `None` 处理且不告警;`"pixelArt"` 在 `kind="character"` 时启用像素规整。未知字符串按 `None` 继续生成,并通过既有通用 `warning` 返回 `unsupported-image-style`;非字符串 JSON 仍返回 `400`。同一图片生成请求 DTO 被其它 `kind` 复用时,只有普通图片和 `character` 支持 `"pixelArt"`,其它 `kind` 收到该值也按不支持风格降级。 +- 2026-08-01 修订:`"pixelArt"` 不再只是后处理,同时向提交给 provider 的提示词末尾追加独立一行约束。角色链路使用「角色主体为像素风格」,**不得**使用「画面为像素风格」——角色生成后要按纯色抠像,绿幕底必须保持平整,同一段提示词里已写死「纯色背景必须平整无纹理、无渐变」,画面级像素化要求会与之互相拆台;且该提示词已禁止出现角色以外的场景内容,因此只需点名角色本身。注入发生在 `build_editor_character_image_prompt` 返回之后,该函数签名和输出契约不变。约束句**不会**进入角色链路的任何 `editor_project_resource`:原图 resource 的 prompt 列存的是 `role_setting`(用户原文),透明结果的 `output_prompt` 在抠图成功后被无条件覆盖为 `"去除纯色背景"`;完整提交提示词是否留存取决于 provider:`persist_editor_provider_source_image` 写原图 asset object 元数据时用的是 `actual_prompt.unwrap_or(prompt)`,provider 未回 `actualPrompt` 时才存 `submitted_prompt`(含约束句),此时排障可按 `object_key` 查;provider 回了 `actualPrompt` 就存 provider 改写后的文本,该次生成的 `submitted_prompt` 在系统内一处都不落——外部 API 审计的 `request_payload` 只记 `promptChars` 字符数,没有提示词原文。响应体返回的是用户原文,前端显示不变。以上 prompt 列写入与 asset object 元数据规则都是既有行为,与 `web/master` 逐行一致,本次未改动。角色提示词里既有的「严格基于图1的角色美术视觉规范的美术风格」与像素约束存在潜在冲突,本次未改写,等实测。 - 角色 provider 回图先按统一业务像素矩阵执行交付尺寸归一:允许无放大恢复时使用 Lanczos 重采样并居中裁切,无法安全恢复时保留 provider 实际尺寸并返回非阻断告警。归一后的带纯色背景图先持久化并作为 BgFilter 输入;BgFilter 正常成功后,把 Alpha 蒙版回贴到这张同尺寸平底原图,再执行像素规整并上传透明主图。网格分析源使用已收口到实际交付尺寸的平底原图,RGBA 采样源使用 Alpha 已回贴的透明图;软 Alpha 只参与单格覆盖率和 Alpha 加权 RGB 计算,输出 Alpha 硬化为 `0 / 255`。 - 首版参数固定为分析色数 `16`、Alpha 覆盖阈值 `0.375`、像素格尺寸自动检测、固定色板关闭、K-means 最大采样 `262144`。单格覆盖率 `Σ(A / 255) / N >= 0.375` 且 `ΣA > 0` 时输出 `A=255`,颜色按 `Σ(A × RGB) / ΣA` 计算;否则输出 `[0,0,0,0]`。分析色数不限制最终输出色数。 - 像素规整 CPU 工作使用进程级最大并发 `2`;取得并发许可的排队时间与实际处理时间共享最多 `30` 秒预算,同时不得晚于当前请求 deadline,最终以两者中更早者为准。输入图片任一边不得超过 `10000` 像素,总像素不得超过 `8294400`;超限、排队超时或处理超时均保留 Alpha 已回贴的透明图并走非致命降级。 @@ -118,7 +119,7 @@ - `从画布中选择` 后点击已有画布图片可绑定为角色规范,`Esc` 可退出点选状态。 - 上传常规参考图后缩略图右下角显示序号。 - 输入角色设定并生成时,请求包含 `kind: "character"`、角色设定 prompt、参考图数组、`model`、`screenColor`、`aspectRatio` 和 `imageSize`。 -- 角色面板可选择 `style: "none" | "pixelArt"`;`none` 的处理路径和产物保持不变,`pixelArt` 在 Alpha 回贴后执行内存像素规整,最终 OSS PUT、项目资源和画布图层数量不得增加。 +- 角色面板可选择 `style: "none" | "pixelArt"`;`none` 的处理路径和产物保持不变,且提交给 provider 的提示词与未带该字段时逐字一致,`pixelArt` 在提示词末尾追加「角色主体为像素风格」并在 Alpha 回贴后执行内存像素规整,最终 OSS PUT、项目资源和画布图层数量不得增加。 - 默认打开角色生成面板时选中 `nanobanana2 / 1:1 / 1K`;切换到 `gpt-image-2` 后再次打开角色或图标素材面板应沿用该模型。 - 生成成功后在占位图位置创建 `assetKind: "character"` 图层,右上角显示 `角色` 标签,布局保存包含该字段。 diff --git a/scripts/check-module-runtime-artifact.mjs b/scripts/check-module-runtime-artifact.mjs index ad3ea0dc4..01cfec302 100644 --- a/scripts/check-module-runtime-artifact.mjs +++ b/scripts/check-module-runtime-artifact.mjs @@ -152,8 +152,15 @@ function parseArchiveObjectMembers(artifact) { longNameTable = artifact.subarray(contentStart, contentEnd); } else if (/^\/\d+$/u.test(rawName) && longNameTable) { const nameOffset = Number.parseInt(rawName.slice(1), 10); - const nameEnd = longNameTable.indexOf(0x0a, nameOffset); - const resolvedEnd = nameEnd >= 0 ? nameEnd : longNameTable.length; + // GNU archives use newline-terminated names; MSVC COFF archives use NUL. + const candidateNameEnds = [ + longNameTable.indexOf(0x00, nameOffset), + longNameTable.indexOf(0x0a, nameOffset), + ].filter((nameEnd) => nameEnd >= 0); + const resolvedEnd = + candidateNameEnds.length > 0 + ? Math.min(...candidateNameEnds) + : longNameTable.length; memberName = longNameTable .subarray(nameOffset, resolvedEnd) .toString('utf8') diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 6e56a92f0..06a15d232 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -2102,6 +2102,147 @@ mod tests { } } + #[tokio::test] + async fn editor_pixel_art_snap_requires_bearer_auth() { + let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); + let request_body = serde_json::json!({ + "sourceImageSrc": "editor-resource-source", + "projectId": "proj-source", + "canvasCompletion": { + "dialogId": "dialog-pixel-art", + "title": "完美像素", + "placeholder": { + "x": 0, + "y": 0, + "width": 128, + "height": 128, + "originalWidth": 128, + "originalHeight": 128 + } + } + }); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/editor/images/pixel-art-snaps") + .header("content-type", "application/json") + .body(Body::from(request_body.to_string())) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn editor_pixel_art_snap_rejects_inline_source_before_processing() { + let state = AppState::new(AppConfig { + external_generation_mode: ExternalGenerationMode::Queue, + ..AppConfig::default() + }) + .expect("state should build"); + let seed_user = seed_phone_user_with_password(&state, "13800138230", TEST_PASSWORD).await; + let token = sign_test_user_token(&state, &seed_user, "sess_editor_pixel_snap_body"); + let app = build_router(state); + let request_body = serde_json::json!({ + "sourceImageSrc": "data:image/png;base64,AAAA", + "projectId": "proj-source", + "sourceResourceId": "editor-resource-source", + "assetKind": "character", + "generationInputs": { "fields": [], "references": [] }, + "canvasCompletion": { + "dialogId": "dialog-pixel-art", + "title": "完美像素", + "placeholder": { + "x": 0, + "y": 0, + "width": 128, + "height": 128, + "originalWidth": 128, + "originalHeight": 128 + } + } + }); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/editor/images/pixel-art-snaps") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from(request_body.to_string())) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = response + .into_body() + .collect() + .await + .expect("response body should collect") + .to_bytes(); + let body_text = String::from_utf8_lossy(&body); + assert!( + body_text.contains("先上传 OSS"), + "handler should reject inline pixel-art sources: {body_text}" + ); + } + + #[tokio::test] + async fn editor_pixel_art_snap_requires_dialog_id_before_project_or_media_work() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + let seed_user = seed_phone_user_with_password(&state, "13800138231", TEST_PASSWORD).await; + let token = sign_test_user_token(&state, &seed_user, "sess_editor_pixel_snap_dialog"); + let app = build_router(state); + let request_body = serde_json::json!({ + "sourceImageSrc": "editor-resource-source", + "projectId": "proj-does-not-exist", + "canvasCompletion": { + "title": "完美像素", + "placeholder": { + "x": 0, + "y": 0, + "width": 128, + "height": 128, + "originalWidth": 128, + "originalHeight": 128 + } + } + }); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/editor/images/pixel-art-snaps") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .body(Body::from(request_body.to_string())) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = response + .into_body() + .collect() + .await + .expect("response body should collect") + .to_bytes(); + let body_text = String::from_utf8_lossy(&body); + assert!( + body_text.contains("canvasCompletion.dialogId"), + "handler should reject missing dialog ids before project lookup: {body_text}" + ); + } + #[tokio::test] async fn editor_generation_json_validation_preserves_unsupported_media_type() { let state = AppState::new(AppConfig { 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 fa6155dbb..b40f41334 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -2,18 +2,21 @@ use std::{ borrow::Cow, collections::{BTreeMap, HashSet}, io::Cursor, - sync::{Arc, LazyLock}, + sync::{ + Arc, LazyLock, + atomic::{AtomicBool, AtomicUsize, Ordering}, + }, time::{Duration, Instant}, }; use axum::{ Json, extract::{Extension, Path, Query, State, rejection::JsonRejection}, - http::StatusCode, + http::{HeaderValue, StatusCode}, }; use module_assets::{ - AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_object_upsert_input, - generate_asset_object_id, + AssetObjectAccessPolicy, AssetObjectFieldError, AssetObjectUpsertInput, + build_asset_object_upsert_input, generate_asset_object_id, }; use platform_image::{ DownloadedImage, @@ -35,6 +38,8 @@ use shared_contracts::assets::{ }; use shared_kernel::build_prefixed_uuid_id; use spacetime_client::editor_project::{ + EditorPixelArtCanvasCompletionRecordInput, EditorPixelArtCanvasPlaceholderRecordInput, + EditorPixelArtResultPersistRecordInput, EditorPixelArtResultPreflightRecordInput, EditorSpritesheetSliceBatchPersistRecordInput, EditorSpritesheetSlicePersistItemRecordInput, }; use spacetime_client::{ @@ -76,7 +81,10 @@ use crate::{ }, generated_image_assets::{ GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl, - adapter::{GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput}, + adapter::{ + GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput, + GeneratedImageAssetPreparedPut, + }, decode_generated_image_asset_data_url, normalize_generated_image_asset_mime, }, http_error::AppError, @@ -119,7 +127,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"; @@ -143,12 +163,49 @@ const EDITOR_GENERATION_UNSUPPORTED_STYLE_WARNING_CODE: &str = "unsupported-imag pub(crate) const EDITOR_GENERATION_MULTIPLE_WARNINGS_CODE: &str = "multiple-generation-warnings"; const EDITOR_GENERATION_MAX_ASPECT_RATIO_DRIFT: f64 = 0.05; const EDITOR_PIXEL_ART_CPU_MAX_CONCURRENCY: usize = 2; +/// 中文注释:inline 完美像素的端点级并发闸。它是仓库里唯一「用户主动触发 + 同步执行 + +/// 下载大图 + 吃 CPU」且不经生成队列的路径——其余像素规整入口都由 job + worker 承担准入。 +/// 闸此前只有 CPU 许可那一道,而它设在下载之后:请求先把最多 32 MiB 读进内存、先打完 +/// 几轮全账号 SpacetimeDB 扫描,才被拦下排队,等于闸在资源已被消耗之后才检查。 +/// 客户端只发几百字节 JSON 就能让服务端拉取 32 MiB,放大比约 64000 : 1,且本操作免费 +/// (generation_cost_mud_points = 0)、无 per-user 配额。 +/// +/// 这道闸覆盖从第一次 IO 到 handler 结束的全过程,一个数字同时封住并发 SpacetimeDB +/// 扫描数、并发源图缓冲数与并发 OSS PUT 数。取 4 而不是等于 CPU 槽的 2:2 会让下载完全 +/// 串在 snap 后面,4 才能让两个请求下载的同时另两个在算,把下载延迟藏进 CPU 时间里。 +const EDITOR_PIXEL_ART_SNAP_MAX_CONCURRENCY: usize = 4; +/// 中文注释:等待队列保险丝,对齐 BgFilter 的 Q。部署配置里全局准入是 512,小于这个值, +/// 所以正常部署下它打不到;它兜的是 max_concurrent_requests 未配置(代码默认 None, +/// 即无全局上限)时的连接风暴。它只防雪崩,不做流量整形。 +const EDITOR_PIXEL_ART_SNAP_MAX_QUEUE_DEPTH: usize = 2048; const EDITOR_PIXEL_ART_MAX_PROCESSING_DURATION: Duration = Duration::from_secs(30); +/// 中文注释:持久化阶段的独立预算。它与上面的处理预算相加就是服务端最坏合法时长,必须小于 +/// 客户端 `snapEditorImageToPixelArt` 的 120 秒超时——否则客户端会在服务端仍在合法工作时 +/// 先 abort,对账采样到仍在途的操作(占位还在、素材库还空),用户照提示核对却什么也看不到, +/// 重试就造出孤儿对象。30 + 60 = 90,余 30 秒给网络往返。 +/// 独立起算而不与处理预算取 min:持久化已经付出了 OSS PUT 的代价,因下载慢而被砍预算、 +/// 中途放弃只会留下孤儿对象。 +const EDITOR_PIXEL_ART_MAX_PERSISTENCE_DURATION: Duration = Duration::from_secs(60); +/// 中文注释:完美像素的 OSS PUT/HEAD 位于数据库事务外;随后 asset object、project +/// resource、editor asset 与可选 canvas completion 由单个 SpacetimeDB procedure 原子提交。 +/// HTTP timeout/drop 不能撤销已经发往远端的 procedure,因此第一次 OSS PUT 之后仍属于 +/// 未知结果边界。这个 detail 字段只在该边界之后置位,客户端据此先读权威快照对账。 +pub(crate) const EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL: &str = "resultPersistenceStarted"; +const EDITOR_PIXEL_ART_SNAP_ASSET_KIND: &str = "editor_pixel_art_snap"; +const EDITOR_PIXEL_ART_SNAP_MODEL: &str = "Perfect Pixel"; +const EDITOR_PIXEL_ART_SNAP_PROVIDER: &str = "Genarrative"; +const EDITOR_PIXEL_ART_SNAP_ALGORITHM_VERSION: &str = "perfect-pixel-v1"; static EDITOR_PIXEL_ART_CPU_LIMITER: LazyLock> = LazyLock::new(|| { Arc::new(tokio::sync::Semaphore::new( EDITOR_PIXEL_ART_CPU_MAX_CONCURRENCY, )) }); +static EDITOR_PIXEL_ART_SNAP_LIMITER: LazyLock> = LazyLock::new(|| { + Arc::new(tokio::sync::Semaphore::new( + EDITOR_PIXEL_ART_SNAP_MAX_CONCURRENCY, + )) +}); +static EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH: AtomicUsize = AtomicUsize::new(0); static EDITOR_ICON_SPRITESHEET_CPU_LIMITER: LazyLock> = LazyLock::new(|| { Arc::new(tokio::sync::Semaphore::new( @@ -334,6 +391,19 @@ pub struct EditorBackgroundRemovalRequest { pub(crate) canvas_completion: Option, } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorPixelArtSnapRequest { + pub(crate) source_image_src: String, + pub(crate) project_id: String, + pub(crate) source_resource_id: Option, + pub(crate) asset_kind: Option, + pub(crate) generation_inputs: Option, + pub(crate) asset_folder_id: Option, + pub(crate) asset_label: Option, + pub(crate) canvas_completion: EditorCanvasGenerationCompletionRequest, +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorIconSpritesheetGenerationRequest { @@ -815,6 +885,23 @@ pub struct EditorBackgroundRemovalResponse { project: Option, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorPixelArtSnapResponse { + image_src: String, + object_key: String, + asset_object_id: String, + width: u32, + height: u32, + source_type: &'static str, + task_id: String, + elapsed_ms: u64, + provider: &'static str, + resource: EditorProjectResourcePayload, + asset: EditorAssetPayload, + project: Option, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorIconSpritesheetIconResponse { @@ -1278,7 +1365,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())?; @@ -1447,7 +1534,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())?; @@ -1734,7 +1821,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", @@ -1754,6 +1841,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() @@ -1817,6 +1917,13 @@ pub(crate) async fn generate_editor_image_for_owner( let pixel_art_supported = matches!(normalized_kind, None | Some("") | Some("character")); let (image_style, mut generation_warning) = normalize_editor_image_generation_style(payload.style.as_deref(), pixel_art_supported); + // 中文注释:角色形象带绿幕抠像底色,只像素化角色本身;普通图片没有抠像底色,整幅画面像素化。 + // ui-design 等 kind 走不到 PixelArt(pixel_art_supported 已挡掉),落到 Image 分支无副作用。 + let pixel_art_prompt_scope = if is_character_generation { + EditorPixelArtPromptScope::Character + } else { + EditorPixelArtPromptScope::Image + }; // 背景色决策挪到预扣泥点之后(见下方 execute_billable 闭包),避免余额不足 / 生成注定失败时 // 仍白发一次 gpt-5-mini 决策。这里先固化决策需要、但随后会被 payload 消费掉的输入。 let requested_screen_color = payload.screen_color.clone(); @@ -1840,6 +1947,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, @@ -1862,18 +1993,12 @@ pub(crate) async fn generate_editor_image_for_owner( Some("scene") => "图片画布生成游戏场景", _ => "图片画布生成图片", }; - 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 的「是否带参考图」布尔。 @@ -1963,16 +2088,20 @@ pub(crate) async fn generate_editor_image_for_owner( let screen_color = screen_background_decision .as_ref() .map(|decision| decision.color); - let submitted_prompt = if is_character_generation { - build_editor_character_image_prompt( - role_setting.as_str(), - screen_color.expect("character generation should have screen color"), - ) - } else if is_ui_design_generation { - build_editor_ui_design_prompt(role_setting.as_str(), ui_has_references) - } else { - role_setting.clone() - }; + let submitted_prompt = apply_editor_pixel_art_style_prompt( + if is_character_generation { + build_editor_character_image_prompt( + role_setting.as_str(), + screen_color.expect("character generation should have screen color"), + ) + } else if is_ui_design_generation { + build_editor_ui_design_prompt(role_setting.as_str(), ui_has_references) + } else { + role_setting.clone() + }, + image_style, + pixel_art_prompt_scope, + ); let generated = if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 { create_openai_nanobanana_generate_content( &http_client, @@ -3376,6 +3505,93 @@ fn take_arc_downloaded_image(image: Arc) -> DownloadedOpe Arc::try_unwrap(image).unwrap_or_else(|image| image.as_ref().clone()) } +// 中文注释:只在 depth < max_depth 时递增,用 CAS 而不是「先读后加」——两个线程同时读到 +// max_depth - 1 各自加一就会越界。抽成自由函数是为了能直接单测边界与并发行为。 +fn try_enter_bounded_queue(depth: &AtomicUsize, max_depth: usize) -> bool { + depth + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| { + (current < max_depth).then_some(current + 1) + }) + .is_ok() +} + +/// 中文注释:递减必须放在 Drop 里。等待中的 future 随时可能被丢弃(客户端断连、超时触发、 +/// 上层取消),若把递减写在正常返回路径上,计数就会只增不减,最终队列永久「满」、接口 +/// 彻底不可用——这是本改动里唯一一处写错会造成永久性故障的地方。 +struct EditorPixelArtSnapQueueGuard; + +impl EditorPixelArtSnapQueueGuard { + fn try_enter() -> Option { + try_enter_bounded_queue( + &EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH, + EDITOR_PIXEL_ART_SNAP_MAX_QUEUE_DEPTH, + ) + .then_some(Self) + } +} + +impl Drop for EditorPixelArtSnapQueueGuard { + fn drop(&mut self) { + EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH.fetch_sub(1, Ordering::AcqRel); + } +} + +// 中文注释:端点级并发闸,必须在第一次 IO 之前取得,许可持有到 handler 结束。等待时间与 +// 后续处理共用同一份 processing_deadline,所以排队不会让请求重新获得完整预算。 +// 中文注释:三条竞争路径的错误各自抽成构造函数。它们要靠全局信号量被打满或队列计数到顶 +// 才走得到,而那两个都是进程级 static——在测试里把它们填满会让并行跑的其他用例连带失败, +// 正是本文件刚修掉的那类竞态。抽出来之后状态码、文案和 retry-after 可以直接断言, +// 「哪条路径用哪个构造函数」则由 snap_editor_image_to_pixel_art 的顺序守卫钉住。 +fn editor_pixel_art_snap_queue_full_error() -> AppError { + editor_pixel_art_snap_failure( + StatusCode::SERVICE_UNAVAILABLE, + "完美像素排队已满,请稍后重试。", + ) + .with_header("retry-after", HeaderValue::from_static("1")) +} + +fn editor_pixel_art_snap_limiter_unavailable_error(error: impl std::fmt::Display) -> AppError { + editor_pixel_art_snap_failure( + StatusCode::SERVICE_UNAVAILABLE, + format!("完美像素并发门限不可用:{error}"), + ) +} + +fn editor_pixel_art_snap_wait_timeout_error() -> AppError { + editor_pixel_art_snap_failure( + StatusCode::GATEWAY_TIMEOUT, + "完美像素等待处理槽位时预算已耗尽。", + ) +} + +async fn acquire_editor_pixel_art_snap_permit( + processing_deadline: Instant, +) -> Result { + // 中文注释:这句预检不能省。timeout_at 会先 poll 一次内层 future,许可空闲时 + // acquire_owned 立刻就绪,于是预算已耗尽的请求照样拿到许可,白占一个名额再去打 + // 几轮全账号扫描,直到下载那步才失败。既有 CPU 许可的同名预检也是为此存在。 + if Instant::now() >= processing_deadline { + return Err(editor_pixel_art_snap_failure( + StatusCode::GATEWAY_TIMEOUT, + "完美像素处理预算已耗尽。", + )); + } + let queue_guard = EditorPixelArtSnapQueueGuard::try_enter() + .ok_or_else(editor_pixel_art_snap_queue_full_error)?; + let acquired = tokio::time::timeout_at( + tokio::time::Instant::from_std(processing_deadline), + Arc::clone(&*EDITOR_PIXEL_ART_SNAP_LIMITER).acquire_owned(), + ) + .await; + // 中文注释:排队阶段到此结束,先让出队列位再判定结果,避免持有许可期间还占着队列名额。 + drop(queue_guard); + match acquired { + Ok(Ok(permit)) => Ok(permit), + Ok(Err(error)) => Err(editor_pixel_art_snap_limiter_unavailable_error(error)), + Err(_) => Err(editor_pixel_art_snap_wait_timeout_error()), + } +} + async fn acquire_editor_pixel_art_cpu_permit( processing_deadline: Instant, ) -> Result { @@ -3394,51 +3610,306 @@ async fn acquire_editor_pixel_art_cpu_permit( } } -async fn snap_editor_pixel_art_or_original( - rgba_source: DownloadedOpenAiImage, +fn editor_pixel_art_snap_failure(status: StatusCode, message: impl Into) -> AppError { + AppError::from_status(status).with_details(json!({ + "provider": "pixel-art-snapper", + "message": message.into(), + })) +} + +fn map_editor_pixel_art_snapper_error(error: platform_image::PixelArtSnapError) -> AppError { + let status = match &error { + platform_image::PixelArtSnapError::InvalidInput(_) + | platform_image::PixelArtSnapError::Decode { .. } => StatusCode::BAD_REQUEST, + platform_image::PixelArtSnapError::GridNotDetected => StatusCode::UNPROCESSABLE_ENTITY, + platform_image::PixelArtSnapError::DeadlineExceeded { .. } => StatusCode::GATEWAY_TIMEOUT, + platform_image::PixelArtSnapError::Encode(_) + | platform_image::PixelArtSnapError::Processing(_) => StatusCode::INTERNAL_SERVER_ERROR, + }; + editor_pixel_art_snap_failure(status, error.to_string()) +} + +fn validate_editor_pixel_art_static_asset_kind(asset_kind: Option<&str>) -> Result<(), AppError> { + let Some(asset_kind) = asset_kind.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(()); + }; + let normalized = asset_kind.to_ascii_lowercase(); + let is_non_static = normalized.contains("video") + || normalized.contains("audio") + || normalized.contains("animation") + || normalized.contains("image-sequence") + || matches!(normalized.as_str(), "sound-effect" | "background-music"); + if !is_non_static { + return Ok(()); + } + Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "pixel-art-snapper", + "field": "assetKind", + "assetKind": asset_kind, + "message": "完美像素只支持静态图片素材。", + })), + ) +} + +fn resolve_editor_pixel_art_snap_asset_kind( + requested_asset_kind: Option<&str>, + source_asset_kind: Option>, + discovered_source_asset_kinds: &[String], + storage_asset_kinds: &[String], +) -> Result, AppError> { + let requested_asset_kind = requested_asset_kind + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + let preferred_source_asset_kind = source_asset_kind + .flatten() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + let discovered_source_asset_kinds = discovered_source_asset_kinds + .iter() + .map(String::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .collect::>(); + let storage_asset_kinds = storage_asset_kinds + .iter() + .map(String::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .collect::>(); + for authoritative_asset_kind in preferred_source_asset_kind + .iter() + .chain(discovered_source_asset_kinds.iter()) + { + // 中文注释:同一个 objectKey 可能同时留在项目资源与素材库。请求字段即使 + // 省略,也不能掩盖任一语义权威记录里的动画/音视频类型。 + validate_editor_pixel_art_static_asset_kind(Some(authoritative_asset_kind.as_str()))?; + } + for storage_asset_kind in storage_asset_kinds { + // 中文注释:asset_object.asset_kind 使用存储 taxonomy(例如 + // editor_generation_reference_image),只承担非静态媒体门禁,不能覆盖项目 + // 资源/素材库使用的语义 taxonomy,也不能拿来和请求 assetKind 做精确比较。 + validate_editor_pixel_art_static_asset_kind(Some(storage_asset_kind))?; + } + let authoritative_asset_kind = + preferred_source_asset_kind.or_else(|| discovered_source_asset_kinds.into_iter().next()); + if authoritative_asset_kind.is_some() + && requested_asset_kind.is_some() + && requested_asset_kind != authoritative_asset_kind + { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "pixel-art-snapper", + "field": "assetKind", + "requestedAssetKind": requested_asset_kind, + "sourceAssetKind": authoritative_asset_kind, + "message": "assetKind 与来源素材权威类型不一致。", + })), + ); + } + let asset_kind = authoritative_asset_kind.or(requested_asset_kind); + validate_editor_pixel_art_static_asset_kind(asset_kind.as_deref())?; + Ok(asset_kind) +} + +fn validate_editor_pixel_art_static_raster(image: &DownloadedOpenAiImage) -> Result<(), AppError> { + validate_editor_pixel_art_static_raster_bytes(image.bytes.as_slice()).map_err(|message| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "pixel-art-snapper", + "field": "sourceImageSrc", + "message": message, + })) + }) +} + +fn validate_editor_pixel_art_static_raster_bytes(bytes: &[u8]) -> Result<(), &'static str> { + if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + return validate_editor_pixel_art_static_png(bytes); + } + if bytes.starts_with(&[0xff, 0xd8, 0xff]) { + return Ok(()); + } + if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP" { + return validate_editor_pixel_art_static_webp(bytes); + } + if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { + return Err("完美像素不支持 GIF;请先转换为静态 PNG、JPEG 或 WebP。"); + } + Err("完美像素只支持静态 PNG、JPEG 或 WebP 图片。") +} + +fn validate_editor_pixel_art_static_png(bytes: &[u8]) -> Result<(), &'static str> { + let mut offset = 8usize; + let mut saw_iend = false; + while offset < bytes.len() { + let header_end = offset + .checked_add(8) + .filter(|end| *end <= bytes.len()) + .ok_or("PNG 文件结构不完整。")?; + let length = u32::from_be_bytes( + bytes[offset..offset + 4] + .try_into() + .map_err(|_| "PNG 文件结构不完整。")?, + ) as usize; + let chunk_type = &bytes[offset + 4..header_end]; + let chunk_end = header_end + .checked_add(length) + .and_then(|end| end.checked_add(4)) + .filter(|end| *end <= bytes.len()) + .ok_or("PNG 文件结构不完整。")?; + if chunk_type == b"acTL" { + return Err("完美像素不支持 APNG 动图;请先转换为静态 PNG。"); + } + offset = chunk_end; + if chunk_type == b"IEND" { + saw_iend = true; + break; + } + } + if !saw_iend { + return Err("PNG 文件结构不完整。"); + } + Ok(()) +} + +fn validate_editor_pixel_art_static_webp(bytes: &[u8]) -> Result<(), &'static str> { + let riff_size = u32::from_le_bytes( + bytes[4..8] + .try_into() + .map_err(|_| "WebP 文件结构不完整。")?, + ) as usize; + let riff_end = riff_size + .checked_add(8) + .filter(|end| *end <= bytes.len()) + .ok_or("WebP 文件结构不完整。")?; + let mut offset = 12usize; + let mut saw_image_chunk = false; + while offset < riff_end { + let header_end = offset + .checked_add(8) + .filter(|end| *end <= riff_end) + .ok_or("WebP 文件结构不完整。")?; + let chunk_type = &bytes[offset..offset + 4]; + let length = u32::from_le_bytes( + bytes[offset + 4..header_end] + .try_into() + .map_err(|_| "WebP 文件结构不完整。")?, + ) as usize; + let data_end = header_end + .checked_add(length) + .filter(|end| *end <= riff_end) + .ok_or("WebP 文件结构不完整。")?; + if chunk_type == b"ANIM" || chunk_type == b"ANMF" { + return Err("完美像素不支持动画 WebP;请先转换为静态 WebP。"); + } + if chunk_type == b"VP8X" + && bytes + .get(header_end) + .is_some_and(|feature_flags| feature_flags & 0x02 != 0) + { + return Err("完美像素不支持动画 WebP;请先转换为静态 WebP。"); + } + if chunk_type == b"VP8 " || chunk_type == b"VP8L" { + saw_image_chunk = true; + } + offset = data_end + .checked_add(length % 2) + .filter(|end| *end <= riff_end) + .ok_or("WebP 文件结构不完整。")?; + } + if !saw_image_chunk { + return Err("WebP 文件结构不完整。"); + } + Ok(()) +} + +async fn snap_editor_pixel_art_strict( + rgba_source: Arc, request_deadline: Option, -) -> (DownloadedOpenAiImage, Option) { +) -> Result { + snap_editor_pixel_art_with_grid_policy(rgba_source, request_deadline, true).await +} + +async fn snap_editor_pixel_art_with_grid_policy( + rgba_source: Arc, + request_deadline: Option, + require_detected_grid: bool, +) -> Result { let processing_deadline = resolve_editor_pixel_art_processing_deadline(Instant::now(), request_deadline); if Instant::now() >= processing_deadline { - return ( - rgba_source, - Some("像素规整处理预算已耗尽,已保留原始生成结果。".to_string()), - ); + return Err(editor_pixel_art_snap_failure( + StatusCode::GATEWAY_TIMEOUT, + "像素规整处理预算已耗尽。", + )); } - let permit = match acquire_editor_pixel_art_cpu_permit(processing_deadline).await { - Ok(permit) => permit, - Err(error) => return (rgba_source, Some(error)), - }; + let permit = acquire_editor_pixel_art_cpu_permit(processing_deadline) + .await + .map_err(|message| { + let status = if message.contains("预算已耗尽") { + StatusCode::GATEWAY_TIMEOUT + } else { + StatusCode::SERVICE_UNAVAILABLE + }; + editor_pixel_art_snap_failure(status, message.replace(",已保留原始生成结果。", "。")) + })?; - let rgba_source = Arc::new(rgba_source); let worker_rgba_source = Arc::clone(&rgba_source); let worker = tokio::task::spawn_blocking(move || { // 中文注释:permit 必须由 blocking 闭包持有,而不是只保护 spawn; // 即使上层 future 因 worker deadline 被取消,仍会限制尚未结束的 CPU 任务数量。 let _permit = permit; - platform_image::snap_pixel_art_with_deadline( - worker_rgba_source.as_ref(), - worker_rgba_source.as_ref(), - Some(processing_deadline), - ) + if require_detected_grid { + platform_image::snap_pixel_art_strict_with_deadline( + worker_rgba_source.as_ref(), + worker_rgba_source.as_ref(), + Some(processing_deadline), + ) + } else { + platform_image::snap_pixel_art_with_deadline( + worker_rgba_source.as_ref(), + worker_rgba_source.as_ref(), + Some(processing_deadline), + ) + } }); let result = tokio::time::timeout_at(tokio::time::Instant::from_std(processing_deadline), worker).await; match result { - Ok(Ok(Ok(image))) => (image, None), - Ok(Ok(Err(error))) => ( - take_arc_downloaded_image(rgba_source), - Some(error.to_string()), - ), - Ok(Err(error)) => ( - take_arc_downloaded_image(rgba_source), - Some(format!("像素规整工作线程异常:{error}")), - ), - Err(_) => ( - take_arc_downloaded_image(rgba_source), - Some("像素规整处理超时,已保留原始生成结果。".to_string()), - ), + Ok(Ok(Ok(image))) => Ok(image), + Ok(Ok(Err(error))) => Err(map_editor_pixel_art_snapper_error(error)), + Ok(Err(error)) => Err(editor_pixel_art_snap_failure( + StatusCode::INTERNAL_SERVER_ERROR, + format!("像素规整工作线程异常:{error}"), + )), + Err(_) => Err(editor_pixel_art_snap_failure( + StatusCode::GATEWAY_TIMEOUT, + "像素规整处理超时。", + )), + } +} + +async fn snap_editor_pixel_art_or_original( + rgba_source: DownloadedOpenAiImage, + request_deadline: Option, +) -> (DownloadedOpenAiImage, Option) { + let rgba_source = Arc::new(rgba_source); + match snap_editor_pixel_art_with_grid_policy(Arc::clone(&rgba_source), request_deadline, false) + .await + { + Ok(image) => (image, None), + Err(error) => { + let reason = error.body_text(); + let reason = + reason.trim_end_matches(|character| matches!(character, '。' | '!' | '!')); + ( + take_arc_downloaded_image(rgba_source), + Some(format!("{reason},已保留原始生成结果。")), + ) + } } } @@ -3746,7 +4217,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", @@ -3766,6 +4237,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( @@ -3828,6 +4308,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 { @@ -3850,8 +4350,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?, @@ -3903,7 +4402,7 @@ pub(crate) async fn edit_editor_image_for_owner( &settings, generation_options.model, prompt.as_str(), - Some("文字、水印、边框、按钮、UI 控件、低清晰度、变形主体"), + Some("文字、水印、边框、按钮、UI 控件、变形主体"), generation_options.aspect_ratio, provider_size.as_str(), reference_images.as_slice(), @@ -3923,7 +4422,7 @@ pub(crate) async fn edit_editor_image_for_owner( &settings, generation_options.model, prompt.as_str(), - Some("文字、水印、边框、按钮、UI 控件、低清晰度、变形主体"), + Some("文字、水印、边框、按钮、UI 控件、变形主体"), provider_size.as_str(), 1, reference_images.as_slice(), @@ -4071,7 +4570,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(), @@ -4110,6 +4609,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( @@ -4233,6 +4743,941 @@ struct EditorBackgroundRemovalImage { height: u32, } +fn validate_editor_pixel_art_snap_canvas_completion( + completion: &EditorCanvasGenerationCompletionRequest, +) -> Result<(), AppError> { + let Some(dialog_id) = normalized_canvas_completion_dialog_id(completion) else { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "pixel-art-snapper", + "field": "canvasCompletion.dialogId", + "message": "完美像素必须关联有效的画布生成占位。", + })), + ); + }; + if dialog_id.contains('\0') { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "pixel-art-snapper", + "field": "canvasCompletion.dialogId", + "message": "完美像素画布生成占位 ID 不得包含 NUL。", + })), + ); + } + if completion.title.trim().is_empty() { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "pixel-art-snapper", + "field": "canvasCompletion.title", + "message": "完美像素画布结果标题不能为空。", + })), + ); + } + let placeholder = &completion.placeholder; + if !placeholder.x.is_finite() + || !placeholder.y.is_finite() + || !placeholder.width.is_finite() + || !placeholder.height.is_finite() + || !placeholder.original_width.is_finite() + || !placeholder.original_height.is_finite() + || placeholder.width <= 0.0 + || placeholder.height <= 0.0 + || placeholder.original_width <= 0.0 + || placeholder.original_height <= 0.0 + { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "pixel-art-snapper", + "field": "canvasCompletion.placeholder", + "message": "完美像素画布占位的坐标与尺寸必须有效。", + })), + ); + } + Ok(()) +} + +fn validate_editor_pixel_art_snap_placeholder_exists( + layers: &Value, + resources: &[EditorProjectResourcePayload], + owner_user_id: &str, + project_id: &str, + completion: &EditorCanvasGenerationCompletionRequest, +) -> Result<(), AppError> { + let dialog_id = normalized_canvas_completion_dialog_id(completion).ok_or_else(|| { + editor_pixel_art_snap_failure( + StatusCode::BAD_REQUEST, + "完美像素必须关联有效的画布生成占位。", + ) + })?; + if layers.as_array().is_some_and(|items| { + items + .iter() + .any(|item| generation_dialog_item_matches(item, dialog_id.as_str())) + }) { + return Ok(()); + } + // 中文注释:首个事务若以 DialogMissing 成功、但 HTTP 响应丢失,同 operation 的稳定 + // project resource 已存在,而占位按定义仍然不存在。该形状必须允许继续走到原子 procedure + // 的 exact compare-and-return;否则幂等重放会被这个处理前门禁反向拦成 409。 + let expected_task_id = format!("pixel-art-snap-{dialog_id}"); + let expected_resource_id = format!( + "{EDITOR_RESOURCE_ID_PREFIX}{}", + editor_pixel_art_stable_record_suffix( + owner_user_id, + project_id, + dialog_id.as_str(), + "project-resource", + ) + ); + if resources.iter().any(|resource| { + resource.resource_id == expected_resource_id + && resource.owner_user_id == owner_user_id + && resource.project_id == project_id + && resource.task_id.as_deref() == Some(expected_task_id.as_str()) + }) { + return Ok(()); + } + Err( + AppError::from_status(StatusCode::CONFLICT).with_details(json!({ + "provider": "pixel-art-snapper", + "field": "canvasCompletion.dialogId", + "dialogId": dialog_id, + "message": "完美像素画布占位不存在或尚未保存,请重试。", + })), + ) +} + +#[derive(Debug)] +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( + asset_kinds: &mut Vec, + asset_kind: Option<&str>, +) { + let Some(asset_kind) = asset_kind + .map(str::trim) + .filter(|asset_kind| !asset_kind.is_empty()) + else { + return; + }; + if !asset_kinds.iter().any(|candidate| candidate == asset_kind) { + asset_kinds.push(asset_kind.to_string()); + } +} + +// 中文注释:source_resource 来自 owner-scoped 的 get_editor_project,已经完成鉴权, +// 因此可以直接用它的 objectKey,不必再按注册 ID 做两轮全账号项目与素材库扫描。做法 +// 对齐图集拆分:用对资源的显式归属断言替代扫描,而不是省掉校验。 +// 前提是 sourceImageSrc 能在不发 RPC 的前提下确认指向同一张图——它要么本身就是 +// objectKey,要么就是这个 resourceId。否则落回完整解析路径。 +fn resolve_editor_pixel_art_source_without_lookup( + owner_user_id: &str, + project_id: &str, + source: &str, + source_resource: &EditorProjectResourcePayload, +) -> Result, AppError> { + if source_resource.owner_user_id != owner_user_id || source_resource.project_id != project_id { + return Err(editor_pixel_art_snap_failure( + StatusCode::FORBIDDEN, + "来源项目资源不属于当前账号或当前项目。", + )); + } + let source_resource_object_key = normalize_editor_record_object_key( + source_resource.object_key.as_deref(), + source_resource.image_src.as_str(), + ) + .ok_or_else(|| { + editor_pixel_art_snap_failure( + StatusCode::BAD_REQUEST, + "来源项目资源没有可用的稳定图片引用。", + ) + })?; + let source = source.trim(); + if source == source_resource.resource_id.trim() { + return Ok(Some(source_resource_object_key)); + } + match normalize_editor_reference_object_key(source) { + // 中文注释:sourceImageSrc 已是 objectKey,直接比对即可判定一致性;不一致必须 + // 报错而不是落回慢路径,否则「两个字段指向不同图片」会被慢路径静默接受。 + Ok(source_object_key) => { + if source_object_key != source_resource_object_key { + return Err(editor_pixel_art_snap_failure( + StatusCode::BAD_REQUEST, + "sourceImageSrc 与 sourceResourceId 指向不同图片。", + )); + } + Ok(Some(source_resource_object_key)) + } + // sourceImageSrc 是别的注册 ID,只能走完整解析。 + Err(_) => Ok(None), + } +} + +async fn resolve_editor_pixel_art_source_for_owner( + state: &AppState, + owner_user_id: &str, + project_id: &str, + source: &str, + 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, + project_id, + source, + source_resource, + )?, + None => None, + }; + // 中文注释:没有已鉴权 source_resource 时,注册 ID 解析、归属校验和下面的跨记录 + // asset_kind 扫描需要的是同一份全账号快照。此前这三件事各自取数—— + // `resolve_editor_reference_object_key_for_owner` 内部两个子函数各扫一轮,本函数再扫 + // 第三轮——同一份数据被 `list_editor_projects` + `get_editor_asset_library` 拉了 6 次, + // 全部在 30 秒预算和端点准入名额之内顺序执行。参照抠图入口 + // `resolve_editor_background_removal_source` 的做法:扫一次,后续解析全部交给 + // `_from_records` 纯函数在内存里完成。老包装 `resolve_editor_reference_object_key_for_owner` + // 保持不动,它服务的是没有任何上下文的调用方。 + let owner_records = if resolved_without_lookup.is_some() { + None + } 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)?; + Some((projects, library)) + }; + let object_key = match resolved_without_lookup.clone() { + Some(object_key) => object_key, + None => { + let (projects, library) = owner_records + .as_ref() + .expect("owner records are loaded whenever the lookup-free path is unavailable"); + let object_key = match normalize_editor_reference_object_key(source) { + Ok(object_key) => object_key, + Err(error) => find_editor_reference_object_key_by_registered_id_from_records( + projects.as_slice(), + library.assets.as_slice(), + source.trim(), + ) + .ok_or(error)?, + }; + // 中文注释:命中 owner 已登记记录就不再发 RPC;只有两份记录都查不到时才回落到 + // asset object 点查兜底,与抠图入口的短路一致。 + if !editor_reference_object_key_is_registered_for_owner( + projects.as_slice(), + library.assets.as_slice(), + object_key.as_str(), + ) { + ensure_editor_reference_asset_object_owned( + state, + owner_user_id, + object_key.as_str(), + ) + .await?; + } + object_key + } + }; + if resolved_without_lookup.is_none() + && let Some(source_resource) = source_resource + { + let source_resource_object_key = normalize_editor_record_object_key( + source_resource.object_key.as_deref(), + source_resource.image_src.as_str(), + ) + .ok_or_else(|| { + editor_pixel_art_snap_failure( + StatusCode::BAD_REQUEST, + "来源项目资源没有可用的稳定图片引用。", + ) + })?; + if source_resource_object_key != object_key { + return Err(editor_pixel_art_snap_failure( + StatusCode::BAD_REQUEST, + "sourceImageSrc 与 sourceResourceId 指向不同图片。", + )); + } + } + + let mut discovered_asset_kinds = Vec::new(); + let mut storage_asset_kinds = Vec::new(); + for resource in project.resources.iter().filter(|resource| { + editor_record_object_key_matches( + resource.object_key.as_deref(), + resource.image_src.as_str(), + object_key.as_str(), + ) + }) { + push_editor_pixel_art_source_asset_kind( + &mut discovered_asset_kinds, + resource.asset_kind.as_deref(), + ); + } + // 中文注释:跨记录扫描只在没有已鉴权 source_resource 时才需要。它的作用是防止同一 + // objectKey 在别处登记为动画/音视频时被静态请求掩盖;有 source_resource 时该语义 + // 权威已经确定,当前项目内的同键记录仍在上面按内存扫过。字节级门禁 + // (validate_editor_pixel_art_static_raster)在下载后照常执行,不受此影响。 + if let Some((projects, library)) = owner_records.as_ref() { + for resource in projects + .iter() + .flat_map(|project| project.resources.iter()) + .filter(|resource| { + editor_record_object_key_matches( + resource.object_key.as_deref(), + resource.image_src.as_str(), + object_key.as_str(), + ) + }) + { + push_editor_pixel_art_source_asset_kind( + &mut discovered_asset_kinds, + resource.asset_kind.as_deref(), + ); + } + for asset in library.assets.iter().filter(|asset| { + editor_record_object_key_matches( + asset.object_key.as_deref(), + asset.image_src.as_str(), + object_key.as_str(), + ) + }) { + push_editor_pixel_art_source_asset_kind( + &mut discovered_asset_kinds, + asset.asset_kind.as_deref(), + ); + } + } + // 中文注释:这一处是按 (bucket, objectKey) 的点查而不是全账号扫描,成本与账号规模 + // 无关,两条路径都保留——它承担存储 taxonomy 的非静态门禁。 + if let Some(oss_client) = state.oss_client() + && let Some(asset_object) = state + .spacetime_client() + .get_asset_object_by_location(module_assets::AssetObjectLocationInput { + bucket: oss_client.config_bucket().to_string(), + object_key: object_key.clone(), + }) + .await + .map_err(map_editor_project_error)? + { + validate_editor_reference_asset_object( + &asset_object, + owner_user_id, + state, + object_key.as_str(), + )?; + push_editor_pixel_art_source_asset_kind( + &mut storage_asset_kinds, + Some(asset_object.asset_kind.as_str()), + ); + } + let asset_kind = resolve_editor_pixel_art_snap_asset_kind( + requested_asset_kind, + source_resource.map(|resource| resource.asset_kind.as_deref()), + 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, + }) +} + +fn resolve_editor_pixel_art_asset_folder_id(asset_folder_id: Option) -> Option { + normalize_optional_string(asset_folder_id) + .or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())) +} + +#[derive(Debug, PartialEq, Eq)] +struct EditorPixelArtPersistenceIdentity { + operation_id: String, + operation_fingerprint: String, + task_id: String, + asset_object_id: String, + resource_id: String, + asset_id: String, +} + +fn canonicalize_editor_json_value(value: Value) -> Value { + match value { + Value::Array(items) => Value::Array( + items + .into_iter() + .map(canonicalize_editor_json_value) + .collect(), + ), + Value::Object(object) => { + let sorted = object.into_iter().collect::>(); + Value::Object( + sorted + .into_iter() + .map(|(key, value)| (key, canonicalize_editor_json_value(value))) + .collect(), + ) + } + other => other, + } +} + +fn editor_pixel_art_sha256_hex(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn editor_pixel_art_stable_record_suffix( + owner_user_id: &str, + project_id: &str, + operation_id: &str, + record_kind: &str, +) -> String { + let input = format!( + "editor-pixel-art-result-v1\0{owner_user_id}\0{project_id}\0{operation_id}\0{record_kind}" + ); + editor_pixel_art_sha256_hex(input.as_bytes())[..32].to_string() +} + +#[allow(clippy::too_many_arguments)] +fn build_editor_pixel_art_persistence_identity( + owner_user_id: &str, + project_id: &str, + dialog_id: &str, + source_object_key: &str, + source_image_sha256: &str, + output_image_sha256: &str, + source_resource_id: Option<&str>, + asset_kind: Option<&str>, + asset_folder_id: &str, + asset_label: &str, + generation_inputs: Option<&Value>, + canvas_completion: &EditorCanvasGenerationCompletionRequest, +) -> Result { + let operation_id = dialog_id.to_string(); + let fingerprint_payload = canonicalize_editor_json_value(json!({ + "algorithmVersion": EDITOR_PIXEL_ART_SNAP_ALGORITHM_VERSION, + "ownerUserId": owner_user_id, + "projectId": project_id, + "dialogId": dialog_id, + "sourceObjectKey": source_object_key, + "sourceImageSha256": source_image_sha256, + "outputImageSha256": output_image_sha256, + "sourceResourceId": source_resource_id, + "assetKind": asset_kind, + "assetFolderId": asset_folder_id, + "assetLabel": asset_label, + "generationInputs": generation_inputs, + "canvasCompletion": { + "dialogId": dialog_id, + "title": canvas_completion.title.trim(), + "placeholder": { + "x": canvas_completion.placeholder.x, + "y": canvas_completion.placeholder.y, + "width": canvas_completion.placeholder.width, + "height": canvas_completion.placeholder.height, + "originalWidth": canvas_completion.placeholder.original_width, + "originalHeight": canvas_completion.placeholder.original_height, + }, + }, + })); + let fingerprint_bytes = serde_json::to_vec(&fingerprint_payload).map_err(|error| { + editor_pixel_art_snap_failure( + StatusCode::INTERNAL_SERVER_ERROR, + format!("完美像素幂等指纹无法序列化:{error}"), + ) + })?; + let operation_fingerprint = editor_pixel_art_sha256_hex(fingerprint_bytes.as_slice()); + let stable_record_id = |prefix: &str, record_kind: &str| { + format!( + "{prefix}{}", + editor_pixel_art_stable_record_suffix( + owner_user_id, + project_id, + operation_id.as_str(), + record_kind, + ) + ) + }; + let asset_object_id = stable_record_id("assetobj_", "asset-object"); + let resource_id = stable_record_id(EDITOR_RESOURCE_ID_PREFIX, "project-resource"); + let asset_id = stable_record_id(EDITOR_ASSET_ID_PREFIX, "asset"); + + Ok(EditorPixelArtPersistenceIdentity { + operation_id, + operation_fingerprint, + task_id: format!("pixel-art-snap-{dialog_id}"), + asset_object_id, + resource_id, + asset_id, + }) +} + +pub async fn snap_editor_image_to_pixel_art( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + 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_untrusted_generation_inputs(payload.generation_inputs.take()); + payload.generation_inputs = payload + .generation_inputs + .take() + .map(canonicalize_editor_json_value); + let started_at = Instant::now(); + // 中文注释:处理预算必须从进入 handler 起算,而不是等下载完成后才起算。inline HTTP + // 请求的 RequestContext 默认没有 external_call_deadline(只有队列 worker 会设), + // 所以下载阶段此前完全落在 30s 之外:请求可以先挂在 OSS GET 上,再持着最多 32 MiB + // 排队等 CPU 许可,预算形同虚设。这里一次性派生绝对 deadline,下载与规整共用同一份。 + let processing_deadline = resolve_editor_pixel_art_processing_deadline( + started_at, + request_context.external_call_deadline(), + ); + ensure_editor_reference_image_source_is_stable( + payload.source_image_src.as_str(), + "pixel-art-snapper", + "sourceImageSrc", + "待像素规整图片", + )?; + let project_id = + normalize_optional_string(Some(payload.project_id.clone())).ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "pixel-art-snapper", + "field": "projectId", + "message": "完美像素必须关联有效画布项目。", + })) + })?; + 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())?; + // 中文注释:端点级并发闸设在第一次 IO 之前——上面几步都是纯内存校验,让畸形请求也去 + // 排队既浪费名额又让 400 拖到 30 秒。闸之后的全部 IO(全账号扫描、下载、规整、持久化) + // 都在许可覆盖范围内,许可随 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 慢时请求会一路走到下载才发现预算早已耗尽,返回的还是下载相关 + // 文案,同时全程占着端点准入名额,把并发闸变成瓶颈。 + // 这里不重复写 `Instant::now() >= deadline` 预检:紧邻上一行的 + // `acquire_editor_pixel_art_snap_permit` 已经做过该预检,成功即意味着尚未超预算, + // 且本块首个 await 是网络 IO 不会立即就绪,不构成 timeout_at 先 poll 再判超时的陷阱。 + let source_resource_id = normalize_optional_string(payload.source_resource_id.clone()); + let source = + tokio::time::timeout_at(tokio::time::Instant::from_std(processing_deadline), async { + let project = state + .spacetime_client() + .get_editor_project(EditorProjectGetRecordInput { + project_id: project_id.clone(), + owner_user_id: owner_user_id.clone(), + }) + .await + .map_err(map_editor_project_error)?; + let project = editor_project_payload_from_record(project); + validate_editor_pixel_art_snap_placeholder_exists( + &project.layers, + project.resources.as_slice(), + owner_user_id.as_str(), + project_id.as_str(), + &payload.canvas_completion, + )?; + let source_resource = if let Some(source_resource_id) = source_resource_id.as_deref() { + Some( + project + .resources + .iter() + .find(|resource| resource.resource_id.trim() == source_resource_id) + .ok_or_else(|| { + editor_pixel_art_snap_failure( + StatusCode::NOT_FOUND, + "来源项目资源不存在或不属于当前画布项目。", + ) + })?, + ) + } else { + None + }; + resolve_editor_pixel_art_source_for_owner( + &state, + owner_user_id.as_str(), + project_id.as_str(), + payload.source_image_src.as_str(), + &project, + source_resource, + payload.asset_kind.as_deref(), + expected_result_resource_id.as_str(), + expected_result_task_id.as_str(), + ) + .await + }) + .await + .map_err(|_| { + editor_pixel_art_snap_failure( + StatusCode::GATEWAY_TIMEOUT, + "完美像素来源归属校验超出处理预算。", + ) + })??; + 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(), + processing_deadline, + ) + .await?; + validate_editor_pixel_art_static_raster(&source_image)?; + let source_image_sha256 = editor_pixel_art_sha256_hex(source_image.bytes.as_slice()); + // 中文注释:传已派生的绝对 deadline 而不是原始 request_deadline。 + // resolve_editor_pixel_art_processing_deadline 取 min,所以内层拿到的仍是这一份, + // 下载耗掉的时间会如实从规整预算里扣除,而不是让规整重新获得完整 30s。 + let snapped_image = + snap_editor_pixel_art_strict(Arc::new(source_image), Some(processing_deadline)).await?; + // 中文注释:只读 PNG 头取尺寸,不做整图解码。这里的字节是本进程 snapper 刚 encode + // 出来的内存缓冲:没有网络截断风险,编码失败会在上一步直接返回 Err,所以不需要像 + // editor_postprocessed_alpha_matches_delivery_dimensions 那样为识别截断像素数据而 + // 整图解码——那条路径处理的是 provider 经网络送来的图。在这里整图解码是纯浪费: + // 输出上限 8294400 像素,一次解码要多分配约 33 MiB 并全量 inflate,而且发生在 + // CPU 许可之外。 + let mut reader = image::ImageReader::new(Cursor::new(snapped_image.bytes.as_slice())); + reader.set_format(image::ImageFormat::Png); + let (width, height) = reader.into_dimensions().map_err(|error| { + editor_pixel_art_snap_failure( + StatusCode::INTERNAL_SERVER_ERROR, + format!("完美像素输出不是有效 PNG:{error}"), + ) + })?; + + let output_image_sha256 = editor_pixel_art_sha256_hex(snapped_image.bytes.as_slice()); + 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(), + ) + .ok_or_else(|| { + editor_pixel_art_snap_failure( + StatusCode::INTERNAL_SERVER_ERROR, + "完美像素结果缺少账号素材目录。", + ) + })?; + let asset_label = resolve_editor_generated_asset_label(payload.asset_label.take(), "完美像素"); + let persistence_identity = build_editor_pixel_art_persistence_identity( + owner_user_id.as_str(), + project_id.as_str(), + dialog_id.as_str(), + source_object_key.as_str(), + source_image_sha256.as_str(), + output_image_sha256.as_str(), + source_resource_id.as_deref(), + asset_kind.as_deref(), + asset_folder_id.as_str(), + asset_label.as_str(), + fingerprint_generation_inputs.as_ref(), + &payload.canvas_completion, + )?; + let response_task_id = persistence_identity.task_id.clone(); + let generation_inputs_json = serialize_editor_asset_metadata(payload.generation_inputs.take())?; + let canvas_completion = EditorPixelArtCanvasCompletionRecordInput { + dialog_id, + title: payload.canvas_completion.title.trim().to_string(), + placeholder: EditorPixelArtCanvasPlaceholderRecordInput { + x: payload.canvas_completion.placeholder.x, + y: payload.canvas_completion.placeholder.y, + width: payload.canvas_completion.placeholder.width, + height: payload.canvas_completion.placeholder.height, + original_width: payload.canvas_completion.placeholder.original_width, + original_height: payload.canvas_completion.placeholder.original_height, + }, + }; + let fingerprint_file_stem = format!( + "perfect-pixel-{}", + persistence_identity.operation_fingerprint + ); + let prepared_upload = prepare_editor_generated_image_object_data( + owner_user_id.as_str(), + persistence_identity.task_id.as_str(), + GeneratedImageAssetDataUrl { + format: normalize_generated_image_asset_mime(snapped_image.mime_type.as_str()), + bytes: snapped_image.bytes, + }, + EDITOR_PIXEL_ART_SNAP_ASSET_KIND, + "pixel-art-snaps", + fingerprint_file_stem.as_str(), + "result", + "genarrative", + )?; + let prepared_object_key = prepared_upload.storage_paths.object_key.clone(); + let image_src = editor_media_src_from_object_key(prepared_object_key.as_str()); + let mut project_resource = EditorProjectResourceCreateRecordInput { + resource_id: persistence_identity.resource_id.clone(), + project_id: project_id.clone(), + owner_user_id: owner_user_id.clone(), + asset_object_id: Some(persistence_identity.asset_object_id.clone()), + image_src: image_src.clone(), + object_key: Some(prepared_object_key), + width, + height, + source_type: "generated".to_string(), + prompt: Some("完美像素".to_string()), + actual_prompt: None, + model: Some(EDITOR_PIXEL_ART_SNAP_MODEL.to_string()), + provider: Some(EDITOR_PIXEL_ART_SNAP_PROVIDER.to_string()), + task_id: Some(persistence_identity.task_id.clone()), + source_resource_id: source_resource_id.clone(), + asset_kind: asset_kind.clone(), + generation_inputs_json: generation_inputs_json.clone(), + updated_at_micros: current_utc_micros(), + }; + // 中文注释:持久化阶段此前完全无界,只受 OSS 客户端每请求 120 秒约束,而 PUT 与 HEAD + // 各自独立计时,加上多次无超时 SpacetimeDB 调用,服务端最坏合法时长可达 270 秒以上, + // 远超客户端 120 秒——客户端会在服务端仍在合法工作时先放弃,对账因此采样到一个仍在 + // 途的操作:占位还在、素材库还空,用户照提示去查什么也看不到,重试就造出孤儿对象。 + // + // 这里独立起算 60 秒,不与处理预算取 min。OSS PUT/HEAD 后只调用一次原子 procedure; + // procedure future 被本地 timeout 丢弃并不能撤销远端事务,因此同一 dialog 的 operation、 + // record IDs 与 fingerprint 都保持稳定,让响应丢失后的同输入重放只能得到同一份结果。 + let persistence_started_at = Instant::now(); + let persistence_deadline = persistence_started_at + .checked_add(EDITOR_PIXEL_ART_MAX_PERSISTENCE_DURATION) + .unwrap_or(persistence_started_at); + // 中文注释:preflight 与 PUT/HEAD/原子 persist 共用同一个绝对 deadline。preflight + // 是只读 procedure,失败或超时证明第一次 PUT 尚未发出,因此不得附加 unknown 标记。 + tokio::time::timeout_at( + tokio::time::Instant::from_std(persistence_deadline), + state.spacetime_client().preflight_editor_pixel_art_result( + EditorPixelArtResultPreflightRecordInput { + owner_user_id: owner_user_id.clone(), + project_id: project_id.clone(), + asset_folder_id: asset_folder_id.clone(), + project_resource: project_resource.clone(), + canvas_completion: canvas_completion.clone(), + }, + ), + ) + .await + .map_err(|_| { + editor_pixel_art_snap_failure( + StatusCode::GATEWAY_TIMEOUT, + "完美像素结果持久化预检超出处理预算。", + ) + })? + .map_err(map_editor_project_error)?; + // 中文注释:Tokio Timeout 会先 poll 内层 future;preflight 与截止同时 ready 时, + // timeout_at 仍可能返回成功。进入任何上传 future 前必须再检查一次绝对截止,避免 + // 第二个 timeout_at 首次 poll 内层并发出已经超预算的 PUT。 + if Instant::now() >= persistence_deadline { + return Err(editor_pixel_art_snap_failure( + StatusCode::GATEWAY_TIMEOUT, + "完美像素结果持久化预检超出处理预算。", + )); + } + + let persistence_task_id = response_task_id.clone(); + let result_persistence_started = Arc::new(AtomicBool::new(false)); + let upload_persistence_started = Arc::clone(&result_persistence_started); + let persisted = tokio::select! { + // 中文注释:截止与上传同时 ready 时先选 timer,确保尚未开始的 PUT 不会被首轮 poll。 + biased; + _ = tokio::time::sleep_until(tokio::time::Instant::from_std(persistence_deadline)) => { + let error = editor_pixel_art_snap_failure( + StatusCode::GATEWAY_TIMEOUT, + "完美像素结果持久化超出处理预算。", + ); + Err(if result_persistence_started.load(Ordering::Acquire) { + error.with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true)) + } else { + error + }) + } + result = async move { + let mut uploaded = upload_editor_generated_image_object_prepared( + &state, + owner_user_id.as_str(), + persistence_identity.task_id.as_str(), + prepared_upload, + "完美像素", + EDITOR_PIXEL_ART_SNAP_ASSET_KIND, + Some(persistence_identity.asset_object_id.clone()), + Some(upload_persistence_started.as_ref()), + ) + .await?; + let completed_at_micros = current_utc_micros(); + uploaded.asset_object.content_type = Some("image/png".to_string()); + uploaded.asset_object.content_hash = Some(output_image_sha256); + uploaded.asset_object.updated_at_micros = completed_at_micros; + project_resource.updated_at_micros = completed_at_micros; + state + .spacetime_client() + .persist_editor_pixel_art_result(EditorPixelArtResultPersistRecordInput { + owner_user_id: owner_user_id.clone(), + project_id: project_id.clone(), + operation_id: persistence_identity.operation_id, + operation_fingerprint: persistence_identity.operation_fingerprint, + asset_object: uploaded.asset_object, + project_resource, + asset: EditorAssetCreateRecordInput { + asset_id: persistence_identity.asset_id, + owner_user_id: owner_user_id.clone(), + folder_id: asset_folder_id, + label: asset_label, + asset_object_id: Some(persistence_identity.asset_object_id), + image_src, + object_key: Some(uploaded.object_key), + width, + height, + source_type: "generated".to_string(), + prompt: Some("完美像素".to_string()), + actual_prompt: None, + model: Some(EDITOR_PIXEL_ART_SNAP_MODEL.to_string()), + provider: Some(EDITOR_PIXEL_ART_SNAP_PROVIDER.to_string()), + task_id: Some(persistence_identity.task_id), + asset_kind, + generation_inputs_json, + source_resource_id: Some(persistence_identity.resource_id), + now_micros: completed_at_micros, + thumbnail_src: None, + generation_cost_mud_points: 0, + group_task_id: None, + group_task_expected_asset_count: None, + }, + canvas_completion, + completed_at_micros, + }) + .await + .map_err(|error| { + tracing::warn!( + provider = EDITOR_PIXEL_ART_SNAP_PROVIDER, + task_id = %persistence_task_id, + error = %error, + "editor_pixel_art_snap_atomic_persistence_failed_after_object_put" + ); + map_editor_project_error(error) + .with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true)) + }) + } => result, + }?; + + let image_src = editor_media_src_from_object_key(persisted.asset_object.object_key.as_str()); + let resource = editor_project_resource_payload_from_record(persisted.project_resource); + let asset = editor_asset_payload_from_record(persisted.asset); + let completed_project = persisted.project.map(editor_project_payload_from_record); + + Ok(json_success_body( + Some(&request_context), + EditorPixelArtSnapResponse { + image_src, + object_key: persisted.asset_object.object_key, + asset_object_id: persisted.asset_object.asset_object_id, + width, + height, + source_type: "generated", + task_id: response_task_id, + elapsed_ms: u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX), + provider: EDITOR_PIXEL_ART_SNAP_PROVIDER, + resource, + asset, + project: completed_project, + }, + )) +} + async fn validate_editor_background_removal_source( state: &AppState, source_object_key: &str, @@ -4993,7 +6438,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", @@ -5011,6 +6456,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, @@ -5057,6 +6511,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) = @@ -5083,8 +6562,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) @@ -5098,11 +6576,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), @@ -5155,7 +6628,11 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( ) .await?; let screen_color = screen_background_decision.color; - let prompt = build_editor_icon_spritesheet_prompt(&icon_descriptions, screen_color); + let prompt = apply_editor_pixel_art_style_prompt( + build_editor_icon_spritesheet_prompt(&icon_descriptions, screen_color), + image_style, + EditorPixelArtPromptScope::IconSpritesheet, + ); let generated = if generation_options.model == EDITOR_IMAGE_MODEL_NANOBANANA2 { create_openai_nanobanana_generate_content( &http_client, @@ -6139,7 +7616,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", @@ -6157,6 +7634,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, @@ -6203,6 +7689,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(); @@ -6226,10 +7737,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 @@ -6242,11 +7751,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), @@ -7057,6 +8561,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)) } @@ -7170,6 +8813,7 @@ struct EditorPayloadMediaReference { object_key: Option, asset_object_id: Option, asset_kind: Option, + source_type: Option, } fn sanitize_editor_payload_media( @@ -7187,6 +8831,7 @@ fn sanitize_editor_payload_media( object_key: resource.object_key.clone(), asset_object_id: resource.asset_object_id.clone(), asset_kind: resource.asset_kind.clone(), + source_type: normalize_optional_string(Some(resource.source_type.clone())), }, ) }) @@ -7238,6 +8883,21 @@ fn sanitize_editor_payload_media_value( { object.remove("model"); } + // 中文注释:结构化保存校验完 sourceType 后会把图层列置空(资源行才是权威), + // 于是布局读回时整个键都不存在,客户端只能猜一个默认值再原样回写,下一次保存 + // 就被判成「sourceType 与项目资源不一致」。这里按图层自己声明的 resourceId + // 回填权威值,口径与上面的 objectKey / assetObjectId 一致;缺资源行的 + // legacy 本地序列取不到映射,保持它自带的值不动。 + let resource_source_type = object + .get("resourceId") + .and_then(Value::as_str) + .and_then(|resource_id| resource_media.get(resource_id)) + .and_then(|media| media.source_type.clone()); + fill_missing_media_identity_field( + object, + "sourceType", + resource_source_type.as_ref(), + ); if let Some(generation_inputs) = object.get_mut("generationInputs") { let sanitized = sanitize_editor_reserved_generation_inputs(generation_inputs.take()); @@ -8569,9 +10229,9 @@ fn build_editor_ui_design_prompt(user_input: &str, has_icon_spec_reference: bool fn editor_image_generation_negative_prompt(is_ui_design_generation: bool) -> &'static str { if is_ui_design_generation { - "水印、无界面的纯场景插画、海报、地图、低清晰度、变形主体、不可读布局" + "水印、无界面的纯场景插画、海报、地图、变形主体、不可读布局" } else { - "文字、水印、边框、按钮、UI 控件、低清晰度、变形主体" + "文字、水印、边框、按钮、UI 控件、变形主体" } } @@ -8586,6 +10246,49 @@ fn build_editor_character_image_prompt( .join("\n") } +/// 中文注释:`style="pixelArt"` 此前只驱动 provider 返回后的确定性像素规整,完全不参与提示词。 +/// snapper 是几何对齐器——检测网格步长后按格重采样;provider 交一张柔和渐变图时两轴都测不到 +/// 步长,生成路径用的 legacy profile 会退到 `min(w,h)/64` 统一网格,产出的是马赛克而不是像素 +/// 画。这里在提示词端补一句,让 provider 本身就输出块状结构,snapper 从「抢救」变成「对齐」。 +/// 实测只提「像素风格」效果已可接受,因此不叠加网格密度、色板和抗锯齿等约束。 +/// +/// 普通图片没有抠像底色,整幅画面都该像素化。 +const EDITOR_PIXEL_ART_IMAGE_PROMPT: &str = "画面为像素风格"; +/// 中文注释:角色形象生成后要用 bgfilter 按纯色抠像,绿幕底必须保持平整;同一段提示词里已经 +/// 写死了「纯色背景必须平整无纹理、无渐变」和「禁止出现建筑、室内布景、风景、地面道具、漂浮 +/// 物」,也就是画面里除角色本身没有别的内容。所以只点名角色,不说「画面」。 +const EDITOR_PIXEL_ART_CHARACTER_PROMPT: &str = "角色主体为像素风格"; +/// 中文注释:图标图集同样要抠绿幕底,但一张图里是多个彼此分离的图标素材,逐个点名避免模型 +/// 只把其中一部分做成像素块。 +const EDITOR_PIXEL_ART_ICON_SPRITESHEET_PROMPT: &str = "每个图标素材均为像素风格"; + +/// 中文注释:按链路命名而不是按「整幅画面 / 只有主体」这类抽象作用域分类——三条链路的差异 +/// 不只是作用域,措辞本身还各自绑定各自提示词里已有的约束。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum EditorPixelArtPromptScope { + Image, + Character, + IconSpritesheet, +} + +/// 中文注释:追加在末尾而不是前置——风格约束靠后更容易被遵守,也不会和「生成游戏角色立绘」 +/// 这类任务声明抢第一位。非 PixelArt 必须原样返回:普通图片路径的提示词就是用户原文。 +fn apply_editor_pixel_art_style_prompt( + prompt: String, + style: EditorImageGenerationStyle, + scope: EditorPixelArtPromptScope, +) -> String { + if style != EditorImageGenerationStyle::PixelArt { + return prompt; + } + let clause = match scope { + EditorPixelArtPromptScope::Image => EDITOR_PIXEL_ART_IMAGE_PROMPT, + EditorPixelArtPromptScope::Character => EDITOR_PIXEL_ART_CHARACTER_PROMPT, + EditorPixelArtPromptScope::IconSpritesheet => EDITOR_PIXEL_ART_ICON_SPRITESHEET_PROMPT, + }; + format!("{prompt}\n{clause}") +} + #[derive(Debug, Clone, PartialEq, Eq)] struct EditorGenerationOptions { model: &'static str, @@ -8602,6 +10305,51 @@ pub(crate) struct PersistedEditorGeneratedImage { pub(crate) asset_object_id: String, } +struct UploadedEditorGeneratedImageObject { + object_key: String, + asset_object: AssetObjectUpsertInput, +} + +#[allow(clippy::too_many_arguments)] +fn prepare_editor_generated_image_object_data( + owner_user_id: &str, + task_id: &str, + image: GeneratedImageAssetDataUrl, + asset_kind: &str, + path_kind: &str, + file_stem: &str, + slot: &str, + provider: &str, +) -> Result { + GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput { + prefix: LegacyAssetPrefix::CharacterDrafts, + path_segments: vec![ + "editor".to_string(), + sanitize_editor_storage_segment(path_kind, "generated-images"), + sanitize_editor_storage_segment(task_id, "task"), + ], + file_stem: sanitize_editor_storage_segment(file_stem, "image"), + image, + access: OssObjectAccess::Private, + metadata: GeneratedImageAssetAdapterMetadata { + asset_kind: Some(asset_kind.to_string()), + owner_user_id: Some(owner_user_id.to_string()), + entity_kind: Some(EDITOR_CHARACTER_IMAGE_ENTITY_KIND.to_string()), + entity_id: Some(task_id.to_string()), + slot: Some(slot.to_string()), + provider: Some(provider.to_string()), + task_id: Some(task_id.to_string()), + }, + extra_metadata: BTreeMap::from([("source".to_string(), "image-canvas-editor".to_string())]), + }) + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "generated-image-assets", + "message": format!("准备画板生成图片 OSS 上传请求失败:{error:?}"), + })) + }) +} + struct PersistEditorProviderSourceResourceInput { project_id: Option, owner_user_id: String, @@ -8697,92 +10445,150 @@ async fn persist_editor_generated_image_data( slot: &str, provider: &str, ) -> Result { + let uploaded = upload_editor_generated_image_object_data( + state, + owner_user_id, + task_id, + image, + prompt, + asset_kind, + path_kind, + file_stem, + slot, + provider, + None, + ) + .await?; + let asset_object = state + .spacetime_client() + .confirm_asset_object(uploaded.asset_object) + .await + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY) + .with_details(json!({ + "provider": "spacetimedb", + "message": error.to_string(), + })) + .with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true)) + })?; + + Ok(PersistedEditorGeneratedImage { + object_key: uploaded.object_key, + asset_object_id: asset_object.asset_object_id, + }) +} + +#[allow(clippy::too_many_arguments)] +async fn upload_editor_generated_image_object_data( + state: &AppState, + owner_user_id: &str, + task_id: &str, + image: GeneratedImageAssetDataUrl, + prompt: &str, + asset_kind: &str, + path_kind: &str, + file_stem: &str, + slot: &str, + provider: &str, + asset_object_id: Option, +) -> Result { + let prepared = prepare_editor_generated_image_object_data( + owner_user_id, + task_id, + image, + asset_kind, + path_kind, + file_stem, + slot, + provider, + )?; + upload_editor_generated_image_object_prepared( + state, + owner_user_id, + task_id, + prepared, + prompt, + asset_kind, + asset_object_id, + None, + ) + .await +} + +async fn upload_editor_generated_image_object_prepared( + state: &AppState, + owner_user_id: &str, + task_id: &str, + prepared: GeneratedImageAssetPreparedPut, + prompt: &str, + asset_kind: &str, + asset_object_id: Option, + result_persistence_started: Option<&AtomicBool>, +) -> Result { let oss_client = state.oss_client().ok_or_else(|| { AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ "provider": "aliyun-oss", "reason": "OSS 未完成环境变量配置", })) })?; - let prepared = - GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput { - prefix: LegacyAssetPrefix::CharacterDrafts, - path_segments: vec![ - "editor".to_string(), - sanitize_editor_storage_segment(path_kind, "generated-images"), - sanitize_editor_storage_segment(task_id, "task"), - ], - file_stem: sanitize_editor_storage_segment(file_stem, "image"), - image, - access: OssObjectAccess::Private, - metadata: GeneratedImageAssetAdapterMetadata { - asset_kind: Some(asset_kind.to_string()), - owner_user_id: Some(owner_user_id.to_string()), - entity_kind: Some(EDITOR_CHARACTER_IMAGE_ENTITY_KIND.to_string()), - entity_id: Some(task_id.to_string()), - slot: Some(slot.to_string()), - provider: Some(provider.to_string()), - task_id: Some(task_id.to_string()), - }, - extra_metadata: BTreeMap::from([( - "source".to_string(), - "image-canvas-editor".to_string(), - )]), - }) - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": "generated-image-assets", - "message": format!("准备画板生成图片 OSS 上传请求失败:{error:?}"), - })) - })?; let persisted_mime_type = prepared.format.mime_type.clone(); - let http_client = reqwest::Client::new(); + // 中文注释:写路径此前也是每次新建 client,PUT 与 HEAD 都没有超时——请求可以在 + // 上传阶段无限期挂住,而这一段发生在 CPU 处理之后,任何按处理预算派生的 deadline + // 都已经不适用(余额多半为零,传进来只会把算完的结果丢掉)。这里的界只能来自 + // 客户端级超时,与读路径共用同一个进程级客户端。 + let http_client = state.editor_oss_http_client(); + // 中文注释:标记边界就在这一行。上面两处失败(OSS 未配置、prepare_put_object)都发生在 + // 第一次 PUT 之前,标了会让客户端对着什么都没落库的失败去核对素材库,是反向谎报;从 + // PUT 开始(含 PUT 自身——响应丢失时字节可能已经落盘)到本函数返回,一律标记。 + // 调用方拿到的是同一个 AppError,无法自行区分内部走到了哪一步,所以只能在这里标。 + if let Some(result_persistence_started) = result_persistence_started { + result_persistence_started.store(true, Ordering::Release); + } let put_result = oss_client - .put_object(&http_client, prepared.request) + .put_object(http_client, prepared.request) .await - .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + .map_err(|error| { + map_oss_error(error, "aliyun-oss") + .with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true)) + })?; let head = oss_client .head_object( - &http_client, + http_client, OssHeadObjectRequest { object_key: put_result.object_key.clone(), }, ) .await - .map_err(|error| map_oss_error(error, "aliyun-oss"))?; - let now_micros = current_utc_micros(); - let asset_object = state - .spacetime_client() - .confirm_asset_object( - build_asset_object_upsert_input( - generate_asset_object_id(now_micros), - head.bucket, - head.object_key.clone(), - AssetObjectAccessPolicy::Private, - head.content_type.or(Some(persisted_mime_type)), - head.content_length, - // asset_object.prompt 是跨资源检索用的用户意图,不承载 provider - // actual/system prompt;后者仍只保存在 resource/asset 审计字段。 - Some(prompt.to_string()), - asset_kind.to_string(), - Some(task_id.to_string()), - Some(owner_user_id.to_string()), - None, - Some(task_id.to_string()), - now_micros, - ) - .map_err(map_editor_asset_field_error)?, - ) - .await .map_err(|error| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "spacetimedb", - "message": error.to_string(), - })) + map_oss_error(error, "aliyun-oss") + .with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true)) })?; + let now_micros = current_utc_micros(); + let asset_object = build_asset_object_upsert_input( + asset_object_id.unwrap_or_else(|| generate_asset_object_id(now_micros)), + head.bucket, + head.object_key.clone(), + AssetObjectAccessPolicy::Private, + head.content_type.or(Some(persisted_mime_type)), + head.content_length, + // asset_object.prompt 是跨资源检索用的用户意图,不承载 provider + // actual/system prompt;后者仍只保存在 resource/asset 审计字段。 + Some(prompt.to_string()), + asset_kind.to_string(), + Some(task_id.to_string()), + Some(owner_user_id.to_string()), + None, + Some(task_id.to_string()), + now_micros, + ) + .map_err(|error| { + map_editor_asset_field_error(error) + .with_detail_field(EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL, json!(true)) + })?; - Ok(PersistedEditorGeneratedImage { + Ok(UploadedEditorGeneratedImageObject { object_key: head.object_key, - asset_object_id: asset_object.asset_object_id, + asset_object, }) } @@ -9127,17 +10933,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() { @@ -9397,8 +11252,11 @@ async fn read_editor_reference_image_object( state: &AppState, source: &str, ) -> Result { - let http_client = reqwest::Client::new(); - read_editor_reference_image_object_with_client(state, source, &http_client).await + // 中文注释:默认调用方走进程级共享客户端。它自带 connect / total 超时,GET 不再 + // 可能无界挂起,顺带复用连接池,避免每次读参考图都重新做一次 TLS 握手。需要按链路 + // 单独调超时的调用方(如图集拆分)改走 `_with_client` 自带客户端。 + read_editor_reference_image_object_with_client(state, source, state.editor_oss_http_client()) + .await } async fn read_editor_reference_image_object_with_client( @@ -9511,6 +11369,28 @@ async fn download_editor_persisted_image_object( }) } +// 中文注释:客户端级超时兜住所有调用方,但像素规整还要求下载本身计入 30s 处理预算。 +// 否则预算只从下载完成后起算:请求可以先花掉客户端的 60s 上限,再持着完整图片去排 +// CPU 许可的队,实际占用远超预算。这里用调用方给出的绝对 deadline 把整段 GET 收进 +// 同一个预算,两层保护各司其职——客户端超时是兜底,deadline 是本次请求的真实上界。 +async fn download_editor_persisted_image_object_within_deadline( + state: &AppState, + object_key: &str, + processing_deadline: Instant, +) -> Result { + tokio::time::timeout_at( + tokio::time::Instant::from_std(processing_deadline), + download_editor_persisted_image_object(state, object_key), + ) + .await + .unwrap_or_else(|_| { + Err(editor_pixel_art_snap_failure( + StatusCode::GATEWAY_TIMEOUT, + "读取待像素规整图片超时。", + )) + }) +} + fn normalize_editor_reference_image_mime_type(content_type: &str) -> Option<&str> { let mime_type = content_type.split(';').next()?.trim(); mime_type.starts_with("image/").then_some(mime_type) @@ -9553,14 +11433,16 @@ pub(crate) fn map_editor_project_error(error: SpacetimeClientError) -> AppError "message": message, })) } - SpacetimeClientError::Procedure(message) if message.contains("不存在") => { - AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({ + SpacetimeClientError::Procedure(message) + if message.contains("版本冲突") || message.contains("幂等") => + { + AppError::from_status(StatusCode::CONFLICT).with_details(json!({ "provider": "editor-project", "message": message, })) } - SpacetimeClientError::Procedure(message) if message.contains("版本冲突") => { - AppError::from_status(StatusCode::CONFLICT).with_details(json!({ + SpacetimeClientError::Procedure(message) if message.contains("不存在") => { + AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({ "provider": "editor-project", "message": message, })) @@ -9759,6 +11641,75 @@ mod tests { } } + #[test] + fn pixel_art_authorized_source_resource_skips_account_wide_lookup() { + let resource = editor_project_resource_for_canvas_test("res-1", "character", 128, 128); + let object_key = "generated-character-drafts/editor/res-1.png"; + + // sourceImageSrc 就是该 resourceId:无需任何 RPC 即可定位。 + assert_eq!( + resolve_editor_pixel_art_source_without_lookup( + "user-1", + "editor-project-1", + "res-1", + &resource, + ) + .expect("authorized resource should resolve"), + Some(object_key.to_string()) + ); + + // sourceImageSrc 已是同一个 objectKey:同样免 RPC。 + assert_eq!( + resolve_editor_pixel_art_source_without_lookup( + "user-1", + "editor-project-1", + object_key, + &resource, + ) + .expect("matching object key should resolve"), + Some(object_key.to_string()) + ); + + // 中文注释:两个字段指向不同图片必须直接报错,不能落回慢路径——慢路径会用 + // sourceImageSrc 解析出的 key 继续,等于让 sourceResourceId 形同虚设。 + let mismatched = resolve_editor_pixel_art_source_without_lookup( + "user-1", + "editor-project-1", + "generated-character-drafts/editor/other.png", + &resource, + ) + .expect_err("mismatched object key must fail"); + assert_eq!(mismatched.status_code(), StatusCode::BAD_REQUEST); + + // sourceImageSrc 是别的注册 ID:无法免 RPC 判定,落回完整解析。 + assert_eq!( + resolve_editor_pixel_art_source_without_lookup( + "user-1", + "editor-project-1", + "editor-asset-42", + &resource, + ) + .expect("unknown registered id should fall back"), + None + ); + } + + #[test] + fn pixel_art_source_resource_ownership_is_asserted_before_skipping_lookup() { + let resource = editor_project_resource_for_canvas_test("res-1", "character", 128, 128); + + // 中文注释:跳过全账号扫描的前提是这条断言,不能省。 + for (owner, project) in [ + ("other-user", "editor-project-1"), + ("user-1", "other-project"), + ] { + let error = + resolve_editor_pixel_art_source_without_lookup(owner, project, "res-1", &resource) + .expect_err("foreign resource must be rejected"); + assert_eq!(error.status_code(), StatusCode::FORBIDDEN); + } + } + #[test] fn editor_project_procedure_errors_preserve_business_reason_as_bad_request() { let error = map_editor_project_error(SpacetimeClientError::Procedure( @@ -9781,6 +11732,28 @@ mod tests { assert_eq!(error.status_code(), StatusCode::CONFLICT); } + #[test] + fn editor_project_idempotency_conflicts_map_to_http_conflict() { + for message in [ + "完美像素 asset_object 幂等键已被其他内容占用", + "完美像素幂等冲突:dialog 指向的结果图层不存在", + ] { + let error = + map_editor_project_error(SpacetimeClientError::Procedure(message.to_string())); + assert_eq!(error.status_code(), StatusCode::CONFLICT); + } + + let missing_source = map_editor_project_error(SpacetimeClientError::Procedure( + "完美像素来源项目资源不存在".to_string(), + )); + assert_eq!(missing_source.status_code(), StatusCode::NOT_FOUND); + + let invalid_fingerprint = map_editor_project_error(SpacetimeClientError::Procedure( + "完美像素 operation_fingerprint 必须是 64 位小写十六进制".to_string(), + )); + assert_eq!(invalid_fingerprint.status_code(), StatusCode::BAD_REQUEST); + } + #[test] fn editor_project_layout_save_request_requires_expected_revision() { let missing_revision = serde_json::from_value::(json!({ @@ -10327,6 +12300,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"; @@ -10612,6 +12738,62 @@ mod tests { assert_eq!(sanitized[1]["dialog"]["provider"], json!("dialog-provider")); } + #[test] + fn editor_payload_sanitizer_refills_layer_source_type_from_resource() { + // 中文注释:结构化保存校验完 sourceType 后会把图层列置空,布局读回时整个键都不存在, + // 客户端只能猜一个默认值再原样回写,下一次保存就被判成「sourceType 与项目资源不一致」。 + // 读边界必须按图层自己声明的 resourceId 回填权威值,口径与 objectKey / assetObjectId 一致。 + let resources = vec![EditorProjectResourcePayload { + resource_id: "resource-1".to_string(), + showcase_id: None, + asset_id: None, + label: None, + project_id: "project-1".to_string(), + owner_user_id: "user-1".to_string(), + author_display_name: None, + author_public_user_code: None, + image_src: "/generated-character-drafts/editor/spec.png".to_string(), + object_key: Some("generated-character-drafts/editor/spec.png".to_string()), + asset_object_id: Some("asset-object-1".to_string()), + width: 512, + height: 512, + source_type: "generated".to_string(), + prompt: None, + actual_prompt: None, + model: None, + provider: None, + task_id: None, + source_resource_id: None, + asset_kind: Some("spec".to_string()), + showcase_category: None, + generation_inputs: None, + public_showcase_enabled: true, + review_status: None, + display_enabled: None, + like_count: None, + generation_cost_mud_points: 0, + refund_mud_points: None, + created_at: "2026-06-23T00:00:00.000Z".to_string(), + updated_at: "2026-06-23T00:00:00.000Z".to_string(), + }]; + + let sanitized = sanitize_editor_payload_media( + json!([ + { "layerId": "layer-1", "resourceId": "resource-1" }, + { + "layerId": "layer-local", + "resourceId": "local-resource-sequence", + "sourceType": "generated" + } + ]), + resources.as_slice(), + ); + + assert_eq!(sanitized[0]["sourceType"], json!("generated")); + // 缺资源行的 legacy 本地序列取不到映射,保持它自带的值不动。 + assert_eq!(sanitized[1]["sourceType"], json!("generated")); + } + #[test] fn editor_canvas_generation_completion_saves_after_sanitizing_legacy_inline_layers() { let existing_resources = vec![EditorProjectResourcePayload { @@ -11169,10 +13351,116 @@ mod tests { assert!( error .expect("expired budget should warn") - .contains("预算已耗尽") + .contains("预算已耗尽,已保留原始生成结果") ); } + #[tokio::test] + async fn pixel_art_strict_expired_budget_returns_error_without_fallback_wording() { + let original = Arc::new(DownloadedOpenAiImage { + bytes: vec![1, 2, 3, 4], + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }); + let expired = Instant::now() + .checked_sub(Duration::from_millis(1)) + .expect("expired test deadline should be representable"); + + let error = snap_editor_pixel_art_strict(original, Some(expired)) + .await + .expect_err("strict pixel snap should surface exhausted budgets"); + + assert_eq!(error.status_code(), StatusCode::GATEWAY_TIMEOUT); + assert!(error.body_text().contains("预算已耗尽")); + assert!(!error.body_text().contains("已保留原始生成结果")); + } + + #[tokio::test] + async fn pixel_art_strict_rejects_only_when_both_legacy_axes_are_undetected() { + let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 128, + 128, + image::Rgba([20, 30, 40, 255]), + )); + let mut bytes = Cursor::new(Vec::new()); + image + .write_to(&mut bytes, image::ImageFormat::Png) + .expect("test PNG should encode"); + let source = DownloadedOpenAiImage { + bytes: bytes.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + + let strict_error = snap_editor_pixel_art_strict(Arc::new(source.clone()), None) + .await + .expect_err("strict action should reject the legacy uniform-grid fallback"); + let (legacy_output, legacy_error) = + snap_editor_pixel_art_or_original(source.clone(), None).await; + + assert_eq!(strict_error.status_code(), StatusCode::UNPROCESSABLE_ENTITY); + assert!(strict_error.body_text().contains("未识别到")); + assert!(legacy_error.is_none()); + let legacy_output = image::load_from_memory(legacy_output.bytes.as_slice()) + .expect("legacy output should remain a valid image"); + assert_eq!((legacy_output.width(), legacy_output.height()), (128, 128)); + } + + #[test] + fn pixel_art_best_effort_warning_contract_stays_stable() { + let warning = editor_pixel_art_fallback_warning(); + + assert_eq!(warning.code, EDITOR_GENERATION_POSTPROCESS_WARNING_CODE); + assert_eq!(warning.reason, "像素规整未完成,已保留原始生成结果。"); + } + + #[test] + fn pixel_art_snap_queue_guard_is_bounded_and_released_on_drop() { + assert_eq!(EDITOR_PIXEL_ART_SNAP_MAX_CONCURRENCY, 4); + assert_eq!(EDITOR_PIXEL_ART_SNAP_MAX_QUEUE_DEPTH, 2048); + + let depth = AtomicUsize::new(0); + assert!(try_enter_bounded_queue(&depth, 2)); + assert!(try_enter_bounded_queue(&depth, 2)); + // 中文注释:满了必须拒绝,且拒绝时不得把计数推过上限。 + assert!(!try_enter_bounded_queue(&depth, 2)); + assert_eq!(depth.load(Ordering::Acquire), 2); + + // 上限为 0 时任何进入都必须失败。 + let closed = AtomicUsize::new(0); + assert!(!try_enter_bounded_queue(&closed, 0)); + assert_eq!(closed.load(Ordering::Acquire), 0); + } + + #[test] + fn pixel_art_snap_queue_depth_returns_to_zero_after_guards_drop() { + // 中文注释:递减写在 Drop 里,被取消的等待者也必须归还名额;否则计数只增不减, + // 队列会永久「满」,接口彻底不可用。 + let before = EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH.load(Ordering::Acquire); + { + let _first = EditorPixelArtSnapQueueGuard::try_enter().expect("queue should accept"); + let _second = EditorPixelArtSnapQueueGuard::try_enter().expect("queue should accept"); + assert_eq!( + EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH.load(Ordering::Acquire), + before + 2 + ); + } + assert_eq!( + EDITOR_PIXEL_ART_SNAP_QUEUE_DEPTH.load(Ordering::Acquire), + before + ); + } + + #[tokio::test] + async fn pixel_art_snap_permit_reports_exhausted_budget_without_waiting() { + let expired = Instant::now() - Duration::from_secs(1); + let error = acquire_editor_pixel_art_snap_permit(expired) + .await + .expect_err("expired budget should not acquire a permit"); + + assert_eq!(error.status_code(), StatusCode::GATEWAY_TIMEOUT); + } + #[test] fn pixel_art_processing_deadline_uses_earlier_local_or_request_budget() { assert_eq!(EDITOR_PIXEL_ART_CPU_MAX_CONCURRENCY, 2); @@ -11195,6 +13483,688 @@ mod tests { ); } + #[test] + fn pixel_art_snap_contended_paths_carry_their_documented_responses() { + use axum::response::IntoResponse; + + // 中文注释:队列满必须是 503 且带 retry-after——客户端与网关据此决定退避,删掉这个头 + // 会让重试立刻打回来,把保险丝变成放大器。此前 `retry-after` 在整个 crate 里只出现在 + // 生产代码一处,零断言。 + let queue_full = editor_pixel_art_snap_queue_full_error(); + assert_eq!(queue_full.status_code(), StatusCode::SERVICE_UNAVAILABLE); + assert!(queue_full.body_text().contains("完美像素排队已满")); + let queue_full_response = queue_full.into_response(); + assert_eq!( + queue_full_response.headers().get("retry-after"), + Some(&HeaderValue::from_static("1")), + "queue-full responses must tell the caller when to retry" + ); + + // 中文注释:等待槽位超时是 504 而不是 503——预算耗尽属于超时语义,错成 503 会让客户端 + // 把「这次来不及了」当成「服务暂时不可用」而立刻重试。 + let wait_timeout = editor_pixel_art_snap_wait_timeout_error(); + assert_eq!(wait_timeout.status_code(), StatusCode::GATEWAY_TIMEOUT); + assert!(wait_timeout.body_text().contains("预算已耗尽")); + + // 中文注释:信号量被关闭是服务端自身不可用,503;同时必须把底层错误带进文案,否则 + // 这条实践中极难复现的路径在排障时只剩一句无信息的通用文案。 + let unavailable = editor_pixel_art_snap_limiter_unavailable_error("semaphore closed"); + assert_eq!(unavailable.status_code(), StatusCode::SERVICE_UNAVAILABLE); + assert!(unavailable.body_text().contains("完美像素并发门限不可用")); + assert!(unavailable.body_text().contains("semaphore closed")); + } + + #[test] + fn pixel_art_snapper_errors_map_to_their_documented_status_codes() { + // 中文注释:四个状态码分档此前只有 GridNotDetected 被间接覆盖。分档错位的后果是双向的: + // 把用户上传的坏图(Decode)报成 500 会让客户端当作服务端故障去重试,把服务端自身失败 + // (Encode / Processing)报成 400 又会让用户以为是自己的输入有问题。 + for (error, expected_status) in [ + ( + platform_image::PixelArtSnapError::InvalidInput("空输入".to_string()), + StatusCode::BAD_REQUEST, + ), + ( + platform_image::PixelArtSnapError::Decode { + input: "source", + message: "corrupt png".to_string(), + }, + StatusCode::BAD_REQUEST, + ), + ( + platform_image::PixelArtSnapError::GridNotDetected, + StatusCode::UNPROCESSABLE_ENTITY, + ), + ( + platform_image::PixelArtSnapError::DeadlineExceeded { + stage: "输入解码" + }, + StatusCode::GATEWAY_TIMEOUT, + ), + ( + platform_image::PixelArtSnapError::Encode("encode failed".to_string()), + StatusCode::INTERNAL_SERVER_ERROR, + ), + ( + platform_image::PixelArtSnapError::Processing("resize failed".to_string()), + StatusCode::INTERNAL_SERVER_ERROR, + ), + ] { + let rendered = error.to_string(); + let mapped = map_editor_pixel_art_snapper_error(error); + assert_eq!( + mapped.status_code(), + expected_status, + "unexpected status for {rendered}" + ); + // 中文注释:底层文案必须原样带上。丢掉它,客户端只会看到一句通用失败, + // 「识别不到网格」和「解码失败」在用户侧变得无法区分。 + assert!( + mapped.body_text().contains(rendered.as_str()), + "mapped error should carry the snapper message: {rendered}" + ); + } + } + + #[test] + fn pixel_art_server_worst_case_fits_inside_the_client_timeout() { + // 中文注释:这条不变式是跨端的,钉住它才能防止任一侧被单独调大。客户端 + // `snapEditorImageToPixelArt` 配的是 120 秒;服务端最坏合法时长是处理预算加持久化 + // 预算之和——处理段(准入、归属校验、下载、规整)由 processing_deadline 封顶, + // 持久化段独立起算由 persistence_deadline 封顶。两者相加必须真小于客户端超时, + // 否则客户端会在服务端仍在合法工作时先 abort:对账采样到仍在途的操作,占位还在、 + // 素材库还空,用户照提示核对却什么也看不到,重试就造出孤儿 OSS 对象。 + const CLIENT_TIMEOUT: Duration = Duration::from_secs(120); + let server_worst_case = + EDITOR_PIXEL_ART_MAX_PROCESSING_DURATION + EDITOR_PIXEL_ART_MAX_PERSISTENCE_DURATION; + + assert_eq!(server_worst_case, Duration::from_secs(90)); + assert!( + server_worst_case < CLIENT_TIMEOUT, + "server worst case {server_worst_case:?} must stay under the client timeout {CLIENT_TIMEOUT:?}" + ); + // 中文注释:余量不能只是「小于」。网络往返、代理缓冲和客户端计时精度都要吃掉一部分, + // 贴着上限等于没有余量。 + assert!( + CLIENT_TIMEOUT - server_worst_case >= Duration::from_secs(30), + "client timeout should keep at least 30s of headroom over the server worst case" + ); + } + + #[test] + fn explicit_pixel_art_snap_contract_preserves_source_metadata() { + let request: EditorPixelArtSnapRequest = serde_json::from_value(json!({ + "sourceImageSrc": "editor-resource-source", + "projectId": "proj-source", + "sourceResourceId": "editor-resource-source", + "assetKind": "character", + "generationInputs": { + "fields": [{ "title": "角色", "value": "陶罐精灵" }], + "references": [], + }, + "assetFolderId": "project", + "assetLabel": "像素角色", + "canvasCompletion": { + "dialogId": "dialog-pixel-art", + "title": "完美像素", + "placeholder": { + "x": 10.0, + "y": 20.0, + "width": 128.0, + "height": 128.0, + "originalWidth": 128.0, + "originalHeight": 128.0 + } + } + })) + .expect("pixel-art snap request should deserialize"); + + assert_eq!(request.project_id, "proj-source"); + assert_eq!( + request.source_resource_id.as_deref(), + Some("editor-resource-source") + ); + assert_eq!(request.asset_kind.as_deref(), Some("character")); + assert_eq!( + request + .generation_inputs + .as_ref() + .and_then(|value| value["fields"][0]["value"].as_str()), + Some("陶罐精灵") + ); + assert_eq!( + request.canvas_completion.dialog_id.as_deref(), + Some("dialog-pixel-art") + ); + } + + #[test] + fn explicit_pixel_art_snap_rejects_non_static_or_forged_asset_kinds() { + assert_eq!( + resolve_editor_pixel_art_snap_asset_kind( + Some("character"), + Some(Some("character")), + &[], + &[], + ) + .expect("matching source kind should be preserved") + .as_deref(), + Some("character") + ); + assert_eq!( + resolve_editor_pixel_art_snap_asset_kind(Some("character"), Some(None), &[], &[]) + .expect("legacy source without a kind may adopt a validated static kind") + .as_deref(), + Some("character") + ); + let mismatch = resolve_editor_pixel_art_snap_asset_kind( + Some("image"), + Some(Some("character")), + &[], + &[], + ) + .expect_err("request kind must not override authoritative source metadata"); + assert_eq!(mismatch.status_code(), StatusCode::BAD_REQUEST); + + for asset_kind in [ + "video", + "editor_uploaded_video", + "audio", + "sound-effect", + "background-music", + "image-sequence", + "character-animation", + ] { + let error = resolve_editor_pixel_art_snap_asset_kind(Some(asset_kind), None, &[], &[]) + .expect_err("non-static source kinds must be rejected"); + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + } + let discovered_animation = vec!["character-animation".to_string()]; + let omitted_kind_error = + resolve_editor_pixel_art_snap_asset_kind(None, None, &discovered_animation, &[]) + .expect_err("omitting both request metadata fields must not hide source metadata"); + assert_eq!(omitted_kind_error.status_code(), StatusCode::BAD_REQUEST); + + let local_reference_storage_kind = vec!["editor_generation_reference_image".to_string()]; + assert_eq!( + resolve_editor_pixel_art_snap_asset_kind( + Some("character"), + None, + &[], + &local_reference_storage_kind, + ) + .expect("storage taxonomy must not conflict with requested semantic kind") + .as_deref(), + Some("character") + ); + let animated_storage_kind = vec!["character-animation".to_string()]; + let omitted_storage_kind_error = + resolve_editor_pixel_art_snap_asset_kind(None, None, &[], &animated_storage_kind) + .expect_err("omitted request metadata must not bypass asset_object media guard"); + assert_eq!( + omitted_storage_kind_error.status_code(), + StatusCode::BAD_REQUEST + ); + + let image_kind = resolve_editor_pixel_art_snap_asset_kind(Some("image"), None, &[], &[]) + .expect("static image kind should be accepted"); + assert_eq!(generated_canvas_media_type(image_kind.as_deref()), "image"); + } + + #[test] + fn explicit_pixel_art_snap_blank_asset_folder_falls_back_to_project() { + for asset_folder_id in [None, Some(" ".to_string())] { + assert_eq!( + resolve_editor_pixel_art_asset_folder_id(asset_folder_id).as_deref(), + Some(EDITOR_ASSET_DEFAULT_FOLDER_ID) + ); + } + assert_eq!( + resolve_editor_pixel_art_asset_folder_id(Some( + " user-1:asset-folder:custom ".to_string() + )) + .as_deref(), + Some("user-1:asset-folder:custom") + ); + } + + #[test] + fn explicit_pixel_art_snap_identity_is_stable_but_input_drift_changes_fingerprint() { + for (record_kind, expected) in [ + ("asset-object", "8a264e1086ee3d6878d753aec254e0a5"), + ("project-resource", "9eee84b77e8828ca8f5042919198ac1c"), + ("asset", "c7e7e66538f4613226e68001b8408104"), + ] { + assert_eq!( + editor_pixel_art_stable_record_suffix( + "owner-1", + "project-1", + "dialog-1", + record_kind, + ), + expected, + "API 与 SpacetimeDB module 必须共享逐字相同的稳定 ID 算法", + ); + } + let completion: EditorCanvasGenerationCompletionRequest = serde_json::from_value(json!({ + "dialogId": "generation-dialog-7", + "title": "完美像素", + "placeholder": { + "x": 10.0, + "y": 20.0, + "width": 128.0, + "height": 128.0, + "originalWidth": 128.0, + "originalHeight": 128.0 + } + })) + .expect("completion request should deserialize"); + let first_inputs: Value = serde_json::from_str(r#"{"z":1,"nested":{"b":2,"a":1}}"#) + .expect("first generation inputs should parse"); + let reordered_inputs: Value = serde_json::from_str(r#"{"nested":{"a":1,"b":2},"z":1}"#) + .expect("reordered generation inputs should parse"); + let build = |source_hash: &str, + inputs: &Value, + completion: &EditorCanvasGenerationCompletionRequest| { + build_editor_pixel_art_persistence_identity( + "user-1", + "project-1", + "generation-dialog-7", + "generated/editor/source.png", + source_hash, + "output-sha256", + Some("source-resource-1"), + Some("character"), + "user-1:asset-folder:project", + "完美像素", + Some(inputs), + completion, + ) + .expect("pixel-art identity should build") + }; + + let first = build("source-sha256", &first_inputs, &completion); + let reordered = build("source-sha256", &reordered_inputs, &completion); + assert_eq!(first, reordered, "JSON key order must not alter identity"); + assert_eq!(first.operation_id, "generation-dialog-7"); + assert_eq!(first.task_id, "pixel-art-snap-generation-dialog-7"); + assert!(first.asset_object_id.starts_with("assetobj_")); + assert!(first.resource_id.starts_with(EDITOR_RESOURCE_ID_PREFIX)); + assert!(first.asset_id.starts_with(EDITOR_ASSET_ID_PREFIX)); + + let mut padded_completion = completion.clone(); + padded_completion.dialog_id = Some(" generation-dialog-7 ".to_string()); + padded_completion.title = " 完美像素 ".to_string(); + let padded = build("source-sha256", &first_inputs, &padded_completion); + assert_eq!( + first.operation_fingerprint, padded.operation_fingerprint, + "completion fields interpreted with trim semantics must fingerprint identically" + ); + + let drifted = build("different-source-sha256", &first_inputs, &completion); + assert_eq!(first.operation_id, drifted.operation_id); + assert_eq!(first.task_id, drifted.task_id); + assert_eq!(first.asset_object_id, drifted.asset_object_id); + assert_eq!(first.resource_id, drifted.resource_id); + assert_eq!(first.asset_id, drifted.asset_id); + assert_ne!(first.operation_fingerprint, drifted.operation_fingerprint); + } + + #[test] + fn explicit_pixel_art_snap_accepts_only_static_png_jpeg_or_webp_bytes() { + let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 4, + 4, + image::Rgba([20, 30, 40, 255]), + )); + let mut png = Cursor::new(Vec::new()); + image + .write_to(&mut png, image::ImageFormat::Png) + .expect("test PNG should encode"); + assert!(validate_editor_pixel_art_static_raster_bytes(png.get_ref()).is_ok()); + assert!(validate_editor_pixel_art_static_raster_bytes(&[0xff, 0xd8, 0xff, 0xd9]).is_ok()); + let mut webp = Cursor::new(Vec::new()); + image + .write_to(&mut webp, image::ImageFormat::WebP) + .expect("test WebP should encode"); + assert!(validate_editor_pixel_art_static_raster_bytes(webp.get_ref()).is_ok()); + + let mut apng = b"\x89PNG\r\n\x1a\n".to_vec(); + apng.extend_from_slice(&8u32.to_be_bytes()); + apng.extend_from_slice(b"acTL"); + apng.extend_from_slice(&[0; 8]); + apng.extend_from_slice(&[0; 4]); + assert!( + validate_editor_pixel_art_static_raster_bytes(apng.as_slice()) + .expect_err("APNG should be rejected") + .contains("APNG") + ); + + let mut animated_webp = b"RIFF".to_vec(); + animated_webp.extend_from_slice(&12u32.to_le_bytes()); + animated_webp.extend_from_slice(b"WEBP"); + animated_webp.extend_from_slice(b"ANIM"); + animated_webp.extend_from_slice(&0u32.to_le_bytes()); + assert!( + validate_editor_pixel_art_static_raster_bytes(animated_webp.as_slice()) + .expect_err("animated WebP should be rejected") + .contains("动画 WebP") + ); + let mut animated_vp8x_webp = b"RIFF".to_vec(); + animated_vp8x_webp.extend_from_slice(&30u32.to_le_bytes()); + animated_vp8x_webp.extend_from_slice(b"WEBP"); + animated_vp8x_webp.extend_from_slice(b"VP8X"); + animated_vp8x_webp.extend_from_slice(&10u32.to_le_bytes()); + animated_vp8x_webp.extend_from_slice(&[0x02, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + animated_vp8x_webp.extend_from_slice(b"VP8L"); + animated_vp8x_webp.extend_from_slice(&0u32.to_le_bytes()); + assert!( + validate_editor_pixel_art_static_raster_bytes(animated_vp8x_webp.as_slice()) + .expect_err("VP8X animation flag should be rejected") + .contains("动画 WebP") + ); + assert!( + validate_editor_pixel_art_static_raster_bytes(b"GIF89a") + .expect_err("GIF should be rejected") + .contains("GIF") + ); + assert!( + validate_editor_pixel_art_static_raster_bytes(b"not-an-image") + .expect_err("unknown image bytes should be rejected") + .contains("PNG、JPEG 或 WebP") + ); + } + + #[test] + fn explicit_pixel_art_snap_requires_non_empty_canvas_dialog_id() { + for dialog_id in [None, Some(" "), Some("dialog\0id")] { + let completion: EditorCanvasGenerationCompletionRequest = + serde_json::from_value(json!({ + "dialogId": dialog_id, + "title": "完美像素", + "placeholder": { + "x": 10.0, + "y": 20.0, + "width": 128.0, + "height": 128.0, + "originalWidth": 128.0, + "originalHeight": 128.0 + } + })) + .expect("completion request should deserialize"); + + let error = validate_editor_pixel_art_snap_canvas_completion(&completion) + .expect_err("pixel-art snap must require a stable dialog id"); + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + assert_eq!( + error.details().and_then(|details| details.get("field")), + Some(&json!("canvasCompletion.dialogId")) + ); + } + } + + #[test] + fn explicit_pixel_art_snap_validates_completion_before_persistence() { + let mut completion: EditorCanvasGenerationCompletionRequest = + serde_json::from_value(json!({ + "dialogId": "dialog-pixel-art", + "title": "完美像素", + "placeholder": { + "x": 10.0, + "y": 20.0, + "width": 128.0, + "height": 128.0, + "originalWidth": 128.0, + "originalHeight": 128.0 + } + })) + .expect("completion request should deserialize"); + + completion.title = " ".to_string(); + let blank_title = validate_editor_pixel_art_snap_canvas_completion(&completion) + .expect_err("blank result title must fail before persistence"); + assert_eq!( + blank_title + .details() + .and_then(|details| details.get("field")), + Some(&json!("canvasCompletion.title")) + ); + + completion.title = "完美像素".to_string(); + completion.placeholder.width = 0.0; + let invalid_placeholder = validate_editor_pixel_art_snap_canvas_completion(&completion) + .expect_err("invalid placeholder must fail before persistence"); + assert_eq!( + invalid_placeholder + .details() + .and_then(|details| details.get("field")), + Some(&json!("canvasCompletion.placeholder")) + ); + } + + #[test] + fn explicit_pixel_art_snap_requires_a_persisted_placeholder_before_processing() { + let completion: EditorCanvasGenerationCompletionRequest = serde_json::from_value(json!({ + "dialogId": "dialog-pixel-art", + "title": "完美像素", + "placeholder": { + "x": 10.0, + "y": 20.0, + "width": 128.0, + "height": 128.0, + "originalWidth": 128.0, + "originalHeight": 128.0 + } + })) + .expect("completion request should deserialize"); + let missing = validate_editor_pixel_art_snap_placeholder_exists( + &json!([]), + &[], + "user-1", + "editor-project-1", + &completion, + ) + .expect_err("missing server placeholder should fail before processing"); + assert_eq!(missing.status_code(), StatusCode::CONFLICT); + + let layers = json!([{ + "itemType": "generation-dialog", + "dialog": { + "id": "dialog-pixel-art", + "placeholder": completion.placeholder.clone() + } + }]); + assert!( + validate_editor_pixel_art_snap_placeholder_exists( + &layers, + &[], + "user-1", + "editor-project-1", + &completion, + ) + .is_ok() + ); + + let mut replay_resource = + editor_project_resource_for_canvas_test("placeholder", "image", 128, 128); + replay_resource.resource_id = format!( + "{EDITOR_RESOURCE_ID_PREFIX}{}", + editor_pixel_art_stable_record_suffix( + "user-1", + "editor-project-1", + "dialog-pixel-art", + "project-resource", + ) + ); + replay_resource.task_id = Some("pixel-art-snap-dialog-pixel-art".to_string()); + assert!( + validate_editor_pixel_art_snap_placeholder_exists( + &json!([]), + &[replay_resource], + "user-1", + "editor-project-1", + &completion, + ) + .is_ok(), + "DialogMissing 的同 operation 重放必须进入 procedure 做 exact compare" + ); + } + + #[test] + fn explicit_pixel_art_snap_is_inline_strict_and_persists_only_after_processing() { + let source = include_str!("editor_project.rs"); + assert_function_contains_in_order( + source, + "pub async fn snap_editor_image_to_pixel_art(", + "async fn validate_editor_background_removal_source", + &[ + // 中文注释:保留审计字段的剥离必须排在最前——它和其余生成入口共用同一个 + // sanitizer,漏掉这一步用户就能给自己的记录伪造 mattingModel 等处理事实。 + // 本端点是纯几何规整、不抠图,任何 matting 元数据出现在这里都是伪造。 + "sanitize_editor_client_generation_inputs", + // 中文注释:处理预算在任何 IO 之前派生,下载与规整共用同一份绝对 deadline; + // 一旦预算改回下载之后起算,下面的顺序断言会先失败。 + "resolve_editor_pixel_art_processing_deadline", + "ensure_editor_reference_image_source_is_stable", + "validate_editor_pixel_art_snap_canvas_completion", + "serialize_editor_asset_metadata", + // 中文注释:并发闸必须排在第一次 IO 之前。挪到 .get_editor_project 之后, + // 全账号扫描与下载就重新回到闸外,等于闸在资源被消耗之后才检查。 + "acquire_editor_pixel_art_snap_permit", + // 中文注释:归属校验阶段必须自己套绝对 deadline。预算只是从 handler 入口 + // 起算,起算不等于覆盖——这段里的 SpacetimeDB 调用一旦退回裸 await,第一次 + // 真正应用预算就又变成下载,请求会一路走到下载才发现预算早已耗尽,而且全程 + // 占着端点准入名额。 + "tokio::time::timeout_at(", + ".get_editor_project", + "validate_editor_pixel_art_snap_placeholder_exists", + "resolve_editor_pixel_art_source_for_owner", + "完美像素来源归属校验超出处理预算。", + "download_editor_persisted_image_object_within_deadline", + "validate_editor_pixel_art_static_raster", + "snap_editor_pixel_art_strict", + "Some(processing_deadline)", + // 中文注释:prepare 只计算精确 object key;只读 preflight 与后续 + // PUT/HEAD/原子 persist 共用第二份 60 秒绝对 deadline。preflight 必须发生 + // 在第一次外部写之前,避免已知的目录/布局拒绝留下 OSS 孤儿对象。 + "prepare_editor_generated_image_object_data(", + "EDITOR_PIXEL_ART_MAX_PERSISTENCE_DURATION", + "tokio::time::timeout_at(", + ".preflight_editor_pixel_art_result(", + "if Instant::now() >= persistence_deadline", + "tokio::select!", + "biased;", + "upload_editor_generated_image_object_prepared(", + ".persist_editor_pixel_art_result(", + ], + ); + assert_function_contains_in_order( + source, + "pub async fn snap_editor_image_to_pixel_art(", + "async fn validate_editor_background_removal_source", + &[ + // 中文注释:timer 分支不能因为 preflight 已成功就谎报已 PUT;只有执行 + // helper 在第一次 PUT 前置位后,持久化超时才附加 unknown 标记。 + "AtomicBool::new(false)", + "tokio::select!", + "result_persistence_started.load(Ordering::Acquire)", + "EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL", + "upload_editor_generated_image_object_prepared(", + ], + ); + assert_function_occurrence_count( + source, + "pub async fn snap_editor_image_to_pixel_art(", + "async fn validate_editor_background_removal_source", + ".preflight_editor_pixel_art_result(", + 1, + ); + assert_function_occurrence_count( + source, + "pub async fn snap_editor_image_to_pixel_art(", + "async fn validate_editor_background_removal_source", + ".persist_editor_pixel_art_result(", + 1, + ); + // 中文注释:全账号扫描各只能出现一次。此前解析链路是三个各自取数的函数串联 + // (resolve_editor_reference_object_key + ensure_editor_reference_object_key_owned + // 各扫一轮,本解析器再扫第三轮),同一份数据被拉了 6 次全账号 RPC,全部顺序执行 + // 在预算和准入名额之内。改为扫一次后交给 `_from_records` 纯函数在内存里解析; + // 一旦有人重新引入取数包装,这两条计数会立刻失败。 + for (scan, expected_occurrences) in [ + (".list_editor_projects(", 1), + (".get_editor_asset_library(", 1), + ] { + assert_function_occurrence_count( + source, + "async fn resolve_editor_pixel_art_source_for_owner", + "fn resolve_editor_pixel_art_asset_folder_id", + scan, + expected_occurrences, + ); + } + assert_function_not_contains( + source, + "async fn resolve_editor_pixel_art_source_for_owner", + "fn resolve_editor_pixel_art_asset_folder_id", + &[ + // 中文注释:这个包装内部自带两轮全账号扫描,已经持有 owner 快照的调用方 + // 不能再走它,否则同一份数据会被重复拉取三倍。断言调用形式而不是裸函数名, + // 否则会命中生产代码里说明「老包装保持不动」的那句注释。 + "resolve_editor_reference_object_key_for_owner(state", + ], + ); + // 中文注释:第一次 OSS PUT 之后的 procedure 错误与整段 timeout 都必须带 + // resultPersistenceStarted。远端 procedure 是单事务,但本地 future 被 timeout/drop + // 不能撤销远端调用,因此这两条仍是未知结果;PUT/HEAD/helper 构造错误由 helper 测试 + // 单独钉住。 + assert_function_occurrence_count( + source, + "pub async fn snap_editor_image_to_pixel_art(", + "async fn validate_editor_background_removal_source", + "EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL", + 2, + ); + assert_function_contains_in_order( + source, + "pub async fn snap_editor_image_to_pixel_art(", + "async fn validate_editor_background_removal_source", + &[ + // 中文注释:标记只能出现在第一次 PUT 之后。出现在 persist 之前说明有纯 + // 校验失败被误标成「可能已落库」,会让常见的 400 也触发多余的对账读取。 + ".preflight_editor_pixel_art_result(", + "upload_editor_generated_image_object_prepared(", + "EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL", + ], + ); + assert_function_not_contains( + source, + "pub async fn snap_editor_image_to_pixel_art(", + "async fn validate_editor_background_removal_source", + &[ + "enqueue_editor_generation_job", + "external_generation_job", + "queue_state", + "snap_editor_pixel_art_or_original", + "confirm_asset_object(", + "persist_editor_generated_asset(", + "complete_editor_canvas_generation(", + "create_editor_project_resource(", + "create_editor_asset(", + // 中文注释:下载必须走带 deadline 的包装。退回裸调用会让 OSS GET 重新 + // 落在 30s 预算之外,请求可以持着最多 32 MiB 无限期排队等 CPU 许可。 + "download_editor_persisted_image_object(&state", + // 中文注释:完美像素必须走 upload-only helper;这些旧包装会在新原子 + // procedure 之前单独 confirm asset object,重新制造 object-only 部分状态。 + "persist_editor_generated_image(", + "persist_editor_generated_image_owned(", + // 中文注释:取输出尺寸只读 PNG 头。整图解码发生在 CPU 许可之外, + // 对 8294400 像素上限的输出要多分配约 33 MiB,且不换来任何保证。 + "image::load_from_memory", + ], + ); + } + #[test] fn editor_image_generation_returns_never_drop_accumulated_warnings() { let source = include_str!("editor_project.rs"); @@ -11771,7 +14741,85 @@ mod tests { assert_eq!( editor_image_generation_negative_prompt(false), - "文字、水印、边框、按钮、UI 控件、低清晰度、变形主体" + "文字、水印、边框、按钮、UI 控件、变形主体" + ); + } + + #[test] + fn pixel_art_style_prompt_is_scoped_per_generation_path() { + // 中文注释:非 PixelArt 必须原样返回——普通图片路径的提示词就是用户原文, + // 任何多余追加都会直接落进 editor_project_resource 的 prompt 列。 + for scope in [ + EditorPixelArtPromptScope::Image, + EditorPixelArtPromptScope::Character, + EditorPixelArtPromptScope::IconSpritesheet, + ] { + assert_eq!( + apply_editor_pixel_art_style_prompt( + "一只猫".to_string(), + EditorImageGenerationStyle::None, + scope, + ), + "一只猫" + ); + } + + let image_prompt = apply_editor_pixel_art_style_prompt( + "一只猫".to_string(), + EditorImageGenerationStyle::PixelArt, + EditorPixelArtPromptScope::Image, + ); + assert_eq!(image_prompt, "一只猫\n画面为像素风格"); + + // 中文注释:角色形象与图标图集生成后都要按纯色抠像,绿幕底必须保持平整, + // 因此这两条都不能出现「画面」级别的像素化要求,否则和同一段提示词里的 + // 「纯色背景必须平整无纹理、无渐变」互相拆台。 + let character_prompt = apply_editor_pixel_art_style_prompt( + "一只猫".to_string(), + EditorImageGenerationStyle::PixelArt, + EditorPixelArtPromptScope::Character, + ); + assert_eq!(character_prompt, "一只猫\n角色主体为像素风格"); + + let icon_prompt = apply_editor_pixel_art_style_prompt( + "一只猫".to_string(), + EditorImageGenerationStyle::PixelArt, + EditorPixelArtPromptScope::IconSpritesheet, + ); + assert_eq!(icon_prompt, "一只猫\n每个图标素材均为像素风格"); + + for scoped_prompt in [character_prompt.as_str(), icon_prompt.as_str()] { + assert!(!scoped_prompt.contains(EDITOR_PIXEL_ART_IMAGE_PROMPT)); + } + } + + #[test] + fn pixel_art_style_prompt_is_applied_before_provider_call() { + let source = include_str!("editor_project.rs"); + + // 中文注释:注入必须发生在 provider 调用之前——一旦被挪到之后,提示词约束就完全 + // 不起作用,而产物看起来仍然「成功」,只是退化成 snapper 的统一网格马赛克。 + assert_function_contains_in_order( + source, + "pub(crate) async fn generate_editor_image_for_owner", + "fn normalize_editor_image_generation_size", + &[ + "let pixel_art_prompt_scope = if is_character_generation {", + "EditorPixelArtPromptScope::Character", + "EditorPixelArtPromptScope::Image", + "apply_editor_pixel_art_style_prompt(", + "create_openai_", + ], + ); + assert_function_contains_in_order( + source, + "pub(crate) async fn generate_editor_icon_spritesheet_for_owner", + "pub async fn extract_editor_ui_design_assets", + &[ + "apply_editor_pixel_art_style_prompt(", + "EditorPixelArtPromptScope::IconSpritesheet", + "create_openai_", + ], ); } @@ -13094,7 +16142,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(), @@ -13103,10 +16151,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" ] ); } @@ -14910,13 +17959,103 @@ mod tests { source, "async fn read_editor_reference_image_object", "async fn download_editor_persisted_image_object", - &["response.chunk().await", "bytes.extend_from_slice"], + &[ + "response.chunk().await", + "bytes.extend_from_slice", + // 中文注释:必须走进程级共享客户端。它自带 connect / total 超时, + // 是所有 OSS 读取调用方(含没有 deadline 可传的降级路径)的兜底上界。 + ".editor_oss_http_client()", + ], ); assert_function_not_contains( source, "async fn read_editor_reference_image_object", "async fn download_editor_persisted_image_object", - &["response.bytes().await", "bytes.to_vec()"], + &[ + "response.bytes().await", + "bytes.to_vec()", + // 中文注释:每次新建客户端会同时丢掉超时与连接复用,等于把无界 GET 放回来。 + "reqwest::Client::new()", + ], + ); + // 中文注释:写路径与读路径同一条不变式。persist 发生在 CPU 处理之后,处理预算 + // 已经不适用,客户端级超时是它唯一的界;退回裸客户端会让 PUT / HEAD 重新无界, + // 而这一段是全部 9 条编辑器图片持久化流程共用的。 + assert_function_contains( + source, + "async fn persist_editor_generated_image_data", + "async fn persist_editor_provider_source_image", + &["state.editor_oss_http_client()"], + ); + assert_function_not_contains( + source, + "async fn persist_editor_generated_image_data", + "async fn persist_editor_provider_source_image", + &["reqwest::Client::new()"], + ); + // 中文注释:旧包装仍在 upload-only helper 之后 confirm asset object,供兄弟链路 + // 保持原行为;完美像素只走 upload-only helper,再把 object/resource/asset/canvas + // 交给单个原子 procedure。两层边界必须分别钉住,防止 confirm 再次溜回新链路。 + assert_function_contains_in_order( + source, + "async fn persist_editor_generated_image_data", + "async fn upload_editor_generated_image_object_data", + &[ + "upload_editor_generated_image_object_data", + ".confirm_asset_object(uploaded.asset_object)", + ], + ); + assert_function_not_contains( + source, + "async fn upload_editor_generated_image_object_data", + "async fn persist_editor_provider_source_image", + &["confirm_asset_object("], + ); + // 中文注释:调用方拿到的是同一个 AppError,无法自行判断本函数内部走到了哪一步, + // 所以「失败发生在持久化开始之后」只能在这里标。整个旧包装的四处分别是 PUT + // (响应丢失时字节可能已落盘)、HEAD、asset object 入参构造与旧 confirm。 + assert_function_occurrence_count( + source, + "async fn persist_editor_generated_image_data", + "async fn persist_editor_provider_source_image", + "EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL", + 4, + ); + assert_function_occurrence_count( + source, + "async fn upload_editor_generated_image_object_data", + "async fn persist_editor_provider_source_image", + "EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL", + 3, + ); + // 中文注释:prepare 只能计算 request/object key,不得访问 OSS 或谎报已经开始持久化。 + assert_function_contains( + source, + "fn prepare_editor_generated_image_object_data", + "struct PersistEditorProviderSourceResourceInput", + &["prepare_put_object"], + ); + assert_function_not_contains( + source, + "fn prepare_editor_generated_image_object_data", + "struct PersistEditorProviderSourceResourceInput", + &[ + "EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL", + ".put_object(", + ".head_object(", + ], + ); + // 中文注释:执行 helper 的标记必须在第一次 PUT 之后才出现。OSS 未配置仍发生在 PUT + // 之前,标了是反向谎报——会让客户端对着什么都没落库的失败去核对素材库。 + assert_function_contains_in_order( + source, + "async fn upload_editor_generated_image_object_prepared", + "async fn persist_editor_provider_source_image", + &[ + "result_persistence_started.store(true, Ordering::Release)", + ".put_object(http_client, prepared.request)", + "EDITOR_RESULT_PERSISTENCE_STARTED_DETAIL", + ], ); } 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/api-server/src/http_error.rs b/server-rs/crates/api-server/src/http_error.rs index ac061d6d9..9ce320983 100644 --- a/server-rs/crates/api-server/src/http_error.rs +++ b/server-rs/crates/api-server/src/http_error.rs @@ -68,6 +68,24 @@ impl AppError { self } + /// 中文注释:在已有 details 上补一个字段,而不是像 `with_details` 那样整体替换。 + /// 用于给沿途传上来的错误追加旁路信息(例如「失败发生在持久化开始之后」),同时保留 + /// 下游原本写入的 provider / message 等字段——客户端要靠 message 定位,靠新字段决策。 + pub fn with_detail_field(mut self, key: &'static str, value: Value) -> Self { + match self.details.take() { + Some(Value::Object(mut object)) => { + object.insert(key.to_string(), value); + self.details = Some(Value::Object(object)); + } + // 中文注释:details 为空或不是对象时按对象重建。本仓库的 details 一律是对象, + // 走到后一个分支说明调用方写法有变,宁可保留标记也不静默丢弃。 + _ => { + self.details = Some(serde_json::json!({ key: value })); + } + } + self + } + pub fn with_header(mut self, name: &'static str, value: HeaderValue) -> Self { self.headers.insert(name, value); self diff --git a/server-rs/crates/api-server/src/modules/editor_project.rs b/server-rs/crates/api-server/src/modules/editor_project.rs index ec0e32a3d..baf272f14 100644 --- a/server-rs/crates/api-server/src/modules/editor_project.rs +++ b/server-rs/crates/api-server/src/modules/editor_project.rs @@ -22,7 +22,8 @@ use crate::{ generate_editor_scene, get_editor_asset_library, get_editor_generation_pricing, get_editor_project, list_editor_projects, list_public_editor_project_resources, load_recent_editor_project, remove_editor_image_background, rename_editor_project, - save_editor_project_layout, split_editor_icon_spritesheet, submit_editor_asset_showcase, + save_editor_project_layout, snap_editor_image_to_pixel_art, + split_editor_icon_spritesheet, submit_editor_asset_showcase, toggle_editor_showcase_asset_like, update_editor_asset, update_editor_asset_folder, update_editor_project_resource_showcase, }, @@ -212,6 +213,13 @@ pub fn router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/editor/images/pixel-art-snaps", + post(snap_editor_image_to_pixel_art).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/editor/icon-spritesheets/generations", post(generate_editor_icon_spritesheet).route_layer(middleware::from_fn_with_state( diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 745819c6e..af376db99 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -272,6 +272,7 @@ pub struct AppStateInner { bgfilter_image_validation_limiter: Arc, character_animation_oss_http_client: reqwest::Client, character_animation_oss_io_limiter: Arc, + editor_oss_http_client: reqwest::Client, #[cfg(any())] creative_agent_executor: Arc, // Phase 1 任务 E 的 creative session facade 暂存在 api-server。 @@ -530,6 +531,7 @@ impl AppState { let character_animation_oss_http_client = build_character_animation_oss_http_client()?; let character_animation_oss_io_limiter = Arc::new(Semaphore::new(CHARACTER_ANIMATION_OSS_MAX_CONCURRENCY)); + let editor_oss_http_client = build_editor_oss_http_client()?; let http_request_permit_pools = HttpRequestPermitPools::from_config(&config); let (profile_recharge_order_updates, _) = broadcast::channel(128); @@ -577,6 +579,7 @@ impl AppState { bgfilter_image_validation_limiter, character_animation_oss_http_client, character_animation_oss_io_limiter, + editor_oss_http_client, #[cfg(any())] creative_agent_executor: Arc::new(MockLangChainRustAgentExecutor), #[cfg(any())] @@ -1317,6 +1320,10 @@ impl AppState { self.character_animation_oss_io_limiter.clone() } + pub fn editor_oss_http_client(&self) -> &reqwest::Client { + &self.editor_oss_http_client + } + #[cfg(any())] pub fn creative_agent_executor(&self) -> Arc { self.creative_agent_executor.clone() @@ -2089,6 +2096,31 @@ fn build_character_animation_oss_http_client() -> Result Result { + reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(10)) + .timeout(std::time::Duration::from_secs(120)) + .pool_idle_timeout(std::time::Duration::from_secs(300)) + .pool_max_idle_per_host(8) + .tcp_keepalive(std::time::Duration::from_secs(60)) + .build() + .map_err(|error| { + AppStateInitError::DependencyUnavailable(format!( + "初始化编辑器 OSS HTTP 客户端失败:{error}" + )) + }) +} + fn build_wechat_client(config: &AppConfig) -> WechatClient { WechatClient::new(WechatConfig { app_id: config.wechat_mini_program_app_id.clone(), @@ -2236,6 +2268,16 @@ mod tests { ); } + #[test] + fn app_state_reuses_editor_oss_client() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + + assert!(std::ptr::eq( + state.editor_oss_http_client(), + state.editor_oss_http_client(), + )); + } + #[test] fn bgfilter_image_validation_limiter_is_bounded_per_process_role() { let parent = AppState::new(AppConfig::default()).expect("parent state should build"); diff --git a/server-rs/crates/platform-image/src/lib.rs b/server-rs/crates/platform-image/src/lib.rs index 41289d985..8db6a9c97 100644 --- a/server-rs/crates/platform-image/src/lib.rs +++ b/server-rs/crates/platform-image/src/lib.rs @@ -5,7 +5,8 @@ pub mod vector_engine; pub use pixel_art_snapper::{ PIXEL_ART_ALPHA_COVERAGE_THRESHOLD, PIXEL_ART_ANALYSIS_COLORS, PIXEL_ART_KMEANS_SAMPLE_LIMIT, - PIXEL_ART_MAX_IMAGE_PIXELS, PixelArtSnapError, snap_pixel_art, snap_pixel_art_with_deadline, + PIXEL_ART_MAX_IMAGE_PIXELS, PixelArtSnapError, snap_pixel_art_strict_with_deadline, + snap_pixel_art_with_deadline, }; pub use vector_engine::{ DownloadedImage, GPT_IMAGE_2_C_MODEL, GPT_IMAGE_2_MODEL, GeneratedImages, NANOBANANA_2_MODEL, diff --git a/server-rs/crates/platform-image/src/pixel_art_snapper.rs b/server-rs/crates/platform-image/src/pixel_art_snapper.rs index 8ca7635b8..8e46a169d 100644 --- a/server-rs/crates/platform-image/src/pixel_art_snapper.rs +++ b/server-rs/crates/platform-image/src/pixel_art_snapper.rs @@ -48,6 +48,7 @@ const DEADLINE_CHECK_INTERVAL: usize = 4_096; #[derive(Debug)] pub enum PixelArtSnapError { InvalidInput(String), + GridNotDetected, Decode { input: &'static str, message: String, @@ -63,6 +64,7 @@ impl fmt::Display for PixelArtSnapError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::InvalidInput(message) => write!(formatter, "像素规整输入无效:{message}"), + Self::GridNotDetected => write!(formatter, "像素规整未识别到可规整的像素网格"), Self::Decode { input, message } => { write!(formatter, "像素规整无法解码 {input}:{message}") } @@ -154,23 +156,38 @@ impl SnapConfig { /// image is always a PNG at the original `rgba_source` dimensions. Its alpha /// channel contains only `0` or `255`, and fully transparent pixels are /// canonical `[0, 0, 0, 0]`. -pub fn snap_pixel_art( - grid_source: &DownloadedImage, - rgba_source: &DownloadedImage, -) -> Result { - snap_pixel_art_with_deadline(grid_source, rgba_source, None) -} - -/// Deadline-aware variant of [`snap_pixel_art`]. /// /// The deadline is checked before and after non-cooperative codec/resize /// operations, and periodically inside the K-means, profile, and cell-sampling /// loops. Exceeding it returns [`PixelArtSnapError::DeadlineExceeded`] without -/// producing a partial image. +/// producing a partial image. Pass `None` to opt out of deadline checks. pub fn snap_pixel_art_with_deadline( grid_source: &DownloadedImage, rgba_source: &DownloadedImage, deadline: Option, +) -> Result { + snap_pixel_art_with_grid_policy(grid_source, rgba_source, deadline, false) +} + +/// Snap an image unless legacy analysis detects no grid step on either axis. +/// +/// Unlike [`snap_pixel_art_with_deadline`], this entry does not synthesize a +/// uniform min-dimension/64 grid when neither axis contains a detectable step. +/// Every other detection, walking, sampling, and encoding behavior — including +/// the deadline semantics — is identical. +pub fn snap_pixel_art_strict_with_deadline( + grid_source: &DownloadedImage, + rgba_source: &DownloadedImage, + deadline: Option, +) -> Result { + snap_pixel_art_with_grid_policy(grid_source, rgba_source, deadline, true) +} + +fn snap_pixel_art_with_grid_policy( + grid_source: &DownloadedImage, + rgba_source: &DownloadedImage, + deadline: Option, + reject_uniform_grid_fallback: bool, ) -> Result { let deadline = DeadlineGuard::new(deadline); deadline.check("输入解码")?; @@ -197,6 +214,9 @@ pub fn snap_pixel_art_with_deadline( let (profile_x, profile_y) = compute_profiles(&quantized_grid, deadline)?; let estimated_x = estimate_step_size(&profile_x, config); let estimated_y = estimate_step_size(&profile_y, config); + if reject_uniform_grid_fallback && estimated_x.is_none() && estimated_y.is_none() { + return Err(PixelArtSnapError::GridNotDetected); + } let (step_x, step_y) = resolve_step_sizes( estimated_x, estimated_y, @@ -1076,7 +1096,8 @@ mod tests { let grid = downloaded_png(RgbaImage::from_pixel(8, 8, Rgba([10, 20, 30, 255]))); let rgba = downloaded_png(RgbaImage::from_pixel(8, 9, Rgba([10, 20, 30, 255]))); - let error = snap_pixel_art(&grid, &rgba).expect_err("dimensions should mismatch"); + let error = snap_pixel_art_with_deadline(&grid, &rgba, None) + .expect_err("dimensions should mismatch"); assert!( error .to_string() @@ -1102,6 +1123,58 @@ mod tests { )); } + #[test] + fn strict_mode_rejects_only_when_legacy_would_use_uniform_fallback() { + let source = downloaded_png(RgbaImage::from_pixel(128, 128, Rgba([10, 20, 30, 255]))); + + let legacy = snap_pixel_art_with_deadline(&source, &source, None) + .expect("legacy generation style should retain its uniform fallback"); + let strict = snap_pixel_art_strict_with_deadline(&source, &source, None) + .expect_err("explicit strict action should reject an undetected grid"); + + assert_eq!(decode_output(&legacy).dimensions(), (128, 128)); + assert!(matches!(strict, PixelArtSnapError::GridNotDetected)); + } + + #[test] + fn strict_mode_matches_legacy_when_either_axis_has_a_detected_step() { + for (vertical_lines, horizontal_lines, expected_axes) in [ + (true, false, (true, false)), + (false, true, (false, true)), + (true, true, (true, true)), + ] { + let mut image = RgbaImage::from_pixel(128, 128, Rgba([10, 20, 30, 255])); + for y in 0..128 { + for x in 0..128 { + if (vertical_lines && x % 8 == 0) || (horizontal_lines && y % 8 == 0) { + image.put_pixel(x, y, Rgba([240, 220, 80, 255])); + } + } + } + let config = SnapConfig::PRODUCTION; + let quantized = quantize_for_analysis(&image, config, DeadlineGuard::new(None)) + .expect("test image should quantize"); + let (profile_x, profile_y) = compute_profiles(&quantized, DeadlineGuard::new(None)) + .expect("test profiles should compute"); + assert_eq!( + ( + estimate_step_size(&profile_x, config).is_some(), + estimate_step_size(&profile_y, config).is_some(), + ), + expected_axes, + ); + + let source = downloaded_png(image); + let legacy = snap_pixel_art_with_deadline(&source, &source, None) + .expect("legacy processing should succeed with a detected step"); + let strict = snap_pixel_art_strict_with_deadline(&source, &source, None) + .expect("strict processing should reuse the detected legacy step"); + assert_eq!(strict.bytes, legacy.bytes); + assert_eq!(strict.mime_type, legacy.mime_type); + assert_eq!(strict.extension, legacy.extension); + } + } + #[test] fn output_keeps_physical_size_and_uses_nearest_blocks() { let grid_image = RgbaImage::from_pixel(128, 128, Rgba([0, 0, 0, 255])); @@ -1121,9 +1194,10 @@ mod tests { } } - let output = snap_pixel_art( + let output = snap_pixel_art_with_deadline( &downloaded_png(grid_image), &downloaded_png(rgba_image.clone()), + None, ) .expect("pixel snapping should succeed"); let decoded = decode_output(&output); @@ -1150,8 +1224,10 @@ mod tests { } let rgba = downloaded_png(rgba); - let first = snap_pixel_art(&grid, &rgba).expect("first snap should succeed"); - let second = snap_pixel_art(&grid, &rgba).expect("second snap should succeed"); + let first = + snap_pixel_art_with_deadline(&grid, &rgba, None).expect("first snap should succeed"); + let second = + snap_pixel_art_with_deadline(&grid, &rgba, None).expect("second snap should succeed"); assert_eq!(first.bytes, second.bytes); for pixel in decode_output(&first).pixels() { 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/server-rs/crates/spacetime-client/src/editor_project.rs b/server-rs/crates/spacetime-client/src/editor_project.rs index 2e991dc7c..bcb1c3237 100644 --- a/server-rs/crates/spacetime-client/src/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/editor_project.rs @@ -1,5 +1,111 @@ use super::*; +#[derive(Clone, Debug, PartialEq)] +pub struct EditorPixelArtCanvasPlaceholderRecordInput { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, + pub original_width: f64, + pub original_height: f64, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct EditorPixelArtCanvasCompletionRecordInput { + pub dialog_id: String, + pub title: String, + pub placeholder: EditorPixelArtCanvasPlaceholderRecordInput, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct EditorPixelArtResultPreflightRecordInput { + pub owner_user_id: String, + pub project_id: String, + pub asset_folder_id: String, + pub project_resource: EditorProjectResourceCreateRecordInput, + pub canvas_completion: EditorPixelArtCanvasCompletionRecordInput, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct EditorPixelArtResultPersistRecordInput { + pub owner_user_id: String, + pub project_id: String, + pub operation_id: String, + pub operation_fingerprint: String, + pub asset_object: module_assets::AssetObjectUpsertInput, + pub project_resource: EditorProjectResourceCreateRecordInput, + pub asset: EditorAssetCreateRecordInput, + pub canvas_completion: EditorPixelArtCanvasCompletionRecordInput, + pub completed_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct EditorPixelArtResultPersistRecord { + pub asset_object: module_assets::AssetObjectUpsertSnapshot, + pub project_resource: EditorProjectResourceRecord, + pub asset: EditorAssetRecord, + pub project: Option, +} + +impl From + for crate::module_bindings::EditorPixelArtCanvasPlaceholderInput +{ + fn from(input: EditorPixelArtCanvasPlaceholderRecordInput) -> Self { + Self { + x: input.x, + y: input.y, + width: input.width, + height: input.height, + original_width: input.original_width, + original_height: input.original_height, + } + } +} + +impl From + for crate::module_bindings::EditorPixelArtCanvasCompletionInput +{ + fn from(input: EditorPixelArtCanvasCompletionRecordInput) -> Self { + Self { + dialog_id: input.dialog_id, + title: input.title, + placeholder: input.placeholder.into(), + } + } +} + +impl From + for crate::module_bindings::EditorPixelArtResultPreflightInput +{ + fn from(input: EditorPixelArtResultPreflightRecordInput) -> Self { + Self { + owner_user_id: input.owner_user_id, + project_id: input.project_id, + asset_folder_id: input.asset_folder_id, + project_resource: input.project_resource.into(), + canvas_completion: input.canvas_completion.into(), + } + } +} + +impl From + for crate::module_bindings::EditorPixelArtResultPersistInput +{ + fn from(input: EditorPixelArtResultPersistRecordInput) -> Self { + Self { + owner_user_id: input.owner_user_id, + project_id: input.project_id, + operation_id: input.operation_id, + operation_fingerprint: input.operation_fingerprint, + asset_object: input.asset_object.into(), + project_resource: input.project_resource.into(), + asset: input.asset.into(), + canvas_completion: input.canvas_completion.into(), + completed_at_micros: input.completed_at_micros, + } + } +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct EditorSpritesheetSlicePersistItemRecordInput { pub asset_object: module_assets::AssetObjectUpsertInput, @@ -57,6 +163,56 @@ impl From } impl SpacetimeClient { + pub async fn preflight_editor_pixel_art_result( + &self, + input: EditorPixelArtResultPreflightRecordInput, + ) -> Result<(), SpacetimeClientError> { + let procedure_input = input.into(); + + self.call_after_connect( + "preflight_editor_pixel_art_result_and_return", + move |connection, sender| { + connection + .procedures() + .preflight_editor_pixel_art_result_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_editor_pixel_art_result_preflight_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn persist_editor_pixel_art_result( + &self, + input: EditorPixelArtResultPersistRecordInput, + ) -> Result { + let procedure_input = input.into(); + + self.call_after_connect( + "persist_editor_pixel_art_result_and_return", + move |connection, sender| { + connection + .procedures() + .persist_editor_pixel_art_result_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_editor_pixel_art_result_persist_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + pub async fn persist_editor_spritesheet_slice_batch( &self, input: EditorSpritesheetSliceBatchPersistRecordInput, @@ -981,6 +1137,59 @@ impl SpacetimeClient { } } +fn map_editor_pixel_art_result_preflight_result( + result: crate::module_bindings::EditorPixelArtResultPreflightResult, +) -> Result<(), SpacetimeClientError> { + if result.ok { + return Ok(()); + } + Err(SpacetimeClientError::procedure_failed(result.error_message)) +} + +fn map_editor_pixel_art_result_persist_result( + result: crate::module_bindings::EditorPixelArtResultPersistResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + // 中文注释:三种落库状态(Applied / DialogMissing / AlreadyApplied)目前没有任何 + // 上层消费方,不再向上透传;但状态缺失说明 procedure 返回体不完整,仍要拦在这里。 + if result.status.is_none() { + return Err(SpacetimeClientError::validation_failed( + "完美像素持久化结果缺少状态", + )); + } + let asset_object = result.asset_object.ok_or_else(|| { + SpacetimeClientError::validation_failed("完美像素持久化结果缺少 asset object") + })?; + let project_resource = result + .project_resource + .ok_or_else(|| SpacetimeClientError::validation_failed("完美像素持久化结果缺少项目资源"))?; + let asset = result + .asset + .ok_or_else(|| SpacetimeClientError::validation_failed("完美像素持久化结果缺少账号素材"))?; + let project = result + .project + .map(|project| { + map_editor_project_optional_procedure_result( + crate::module_bindings::EditorProjectProcedureResult { + ok: true, + project: Some(project), + error_message: None, + }, + ) + }) + .transpose()? + .flatten(); + + Ok(EditorPixelArtResultPersistRecord { + asset_object: map_editor_spritesheet_asset_object_snapshot(asset_object), + project_resource: map_editor_spritesheet_project_resource_snapshot(project_resource)?, + asset: map_editor_spritesheet_asset_snapshot(asset)?, + project, + }) +} + fn map_editor_spritesheet_slice_batch_persist_result( result: crate::module_bindings::EditorSpritesheetSliceBatchPersistResult, ) -> Result { @@ -1113,3 +1322,181 @@ fn parse_editor_spritesheet_generation_inputs( }) .transpose() } + +#[cfg(test)] +mod pixel_art_persist_mapper_tests { + use super::*; + + fn empty_pixel_art_persist_result( + ok: bool, + ) -> crate::module_bindings::EditorPixelArtResultPersistResult { + crate::module_bindings::EditorPixelArtResultPersistResult { + ok, + status: None, + asset_object: None, + project_resource: None, + asset: None, + project: None, + error_message: None, + } + } + + fn pixel_art_asset_object_snapshot() -> crate::module_bindings::AssetObjectUpsertSnapshot { + crate::module_bindings::AssetObjectUpsertSnapshot { + asset_object_id: "assetobj_pixel".to_string(), + bucket: "private".to_string(), + object_key: "editor/pixel.png".to_string(), + access_policy: crate::module_bindings::AssetObjectAccessPolicy::Private, + content_type: Some("image/png".to_string()), + content_length: 16, + content_hash: Some("output-sha256".to_string()), + version: 1, + source_job_id: Some("pixel-art-snap-dialog-1".to_string()), + owner_user_id: Some("user-1".to_string()), + profile_id: None, + entity_id: Some("project-1".to_string()), + asset_kind: "image".to_string(), + created_at_micros: 10, + updated_at_micros: 11, + } + } + + fn pixel_art_project_resource_snapshot() -> crate::module_bindings::EditorProjectResourceSnapshot + { + crate::module_bindings::EditorProjectResourceSnapshot { + resource_id: "editor-resource-pixel".to_string(), + project_id: "project-1".to_string(), + owner_user_id: "user-1".to_string(), + asset_object_id: Some("assetobj_pixel".to_string()), + image_src: "/api/assets/assetobj_pixel/content".to_string(), + object_key: Some("editor/pixel.png".to_string()), + width: 32, + height: 32, + source_type: "perfect-pixel".to_string(), + prompt: None, + actual_prompt: None, + model: None, + provider: None, + task_id: Some("pixel-art-snap-dialog-1".to_string()), + source_resource_id: Some("source-resource".to_string()), + asset_kind: Some("image".to_string()), + generation_inputs_json: Some(r#"{"source":"canvas"}"#.to_string()), + public_showcase_enabled: false, + created_at_micros: 10, + updated_at_micros: 11, + } + } + + fn pixel_art_asset_snapshot() -> crate::module_bindings::EditorAssetSnapshot { + crate::module_bindings::EditorAssetSnapshot { + asset_id: "editor-asset-pixel".to_string(), + folder_id: "folder-1".to_string(), + label: "Pixel result".to_string(), + asset_object_id: Some("assetobj_pixel".to_string()), + image_src: "/api/assets/assetobj_pixel/content".to_string(), + object_key: Some("editor/pixel.png".to_string()), + width: 32, + height: 32, + source_type: "perfect-pixel".to_string(), + prompt: None, + actual_prompt: None, + model: None, + provider: None, + task_id: Some("pixel-art-snap-dialog-1".to_string()), + asset_kind: Some("image".to_string()), + generation_inputs_json: Some(r#"{"source":"canvas"}"#.to_string()), + source_resource_id: Some("source-resource".to_string()), + public_showcase_enabled: Some(false), + created_at_micros: 10, + updated_at_micros: 11, + thumbnail_src: None, + generation_cost_mud_points: 0, + showcase_id: None, + showcase_review_status: None, + showcase_display_enabled: None, + showcase_like_count: None, + group_task_id: None, + } + } + + fn successful_pixel_art_persist_result( + status: crate::module_bindings::EditorPixelArtResultPersistStatus, + ) -> crate::module_bindings::EditorPixelArtResultPersistResult { + crate::module_bindings::EditorPixelArtResultPersistResult { + ok: true, + status: Some(status), + asset_object: Some(pixel_art_asset_object_snapshot()), + project_resource: Some(pixel_art_project_resource_snapshot()), + asset: Some(pixel_art_asset_snapshot()), + project: None, + error_message: None, + } + } + + #[test] + fn editor_pixel_art_persist_mapper_rejects_procedure_failure_before_snapshots() { + let mut result = empty_pixel_art_persist_result(false); + result.error_message = Some("事务提交失败".to_string()); + + let error = map_editor_pixel_art_result_persist_result(result) + .expect_err("ok=false 必须保留 procedure 失败"); + + assert!(matches!( + error, + SpacetimeClientError::Procedure(message) if message == "事务提交失败" + )); + } + + #[test] + fn editor_pixel_art_persist_mapper_rejects_missing_required_snapshot() { + let mut result = empty_pixel_art_persist_result(true); + result.status = Some(crate::module_bindings::EditorPixelArtResultPersistStatus::Applied); + + let error = map_editor_pixel_art_result_persist_result(result) + .expect_err("成功结果缺少 asset object 时必须拒绝"); + + assert!(matches!( + error, + SpacetimeClientError::Runtime(message) + if message == "完美像素持久化结果缺少 asset object" + )); + } + + #[test] + fn editor_pixel_art_persist_mapper_rejects_missing_status() { + let mut result = successful_pixel_art_persist_result( + crate::module_bindings::EditorPixelArtResultPersistStatus::Applied, + ); + result.status = None; + + let error = map_editor_pixel_art_result_persist_result(result) + .expect_err("成功结果缺少状态时必须拒绝"); + + assert!(matches!( + error, + SpacetimeClientError::Runtime(message) + if message == "完美像素持久化结果缺少状态" + )); + } + + #[test] + fn editor_pixel_art_persist_mapper_accepts_every_status_and_missing_project() { + let cases = [ + crate::module_bindings::EditorPixelArtResultPersistStatus::Applied, + crate::module_bindings::EditorPixelArtResultPersistStatus::DialogMissing, + crate::module_bindings::EditorPixelArtResultPersistStatus::AlreadyApplied, + ]; + + for binding_status in cases { + let mapped = map_editor_pixel_art_result_persist_result( + successful_pixel_art_persist_result(binding_status), + ) + .expect("project=None 仍应映射成功"); + + assert_eq!(mapped.asset_object.asset_object_id, "assetobj_pixel"); + assert_eq!(mapped.project_resource.resource_id, "editor-resource-pixel"); + assert_eq!(mapped.asset.asset_id, "editor-asset-pixel"); + assert!(mapped.project.is_none()); + } + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index e0100906a..9259c2b63 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -297,6 +297,13 @@ pub mod editor_generation_pricing_tier_type; pub mod editor_generation_runtime_identity_rotate_input_type; pub mod editor_generation_runtime_identity_rotation_table; pub mod editor_generation_runtime_identity_rotation_type; +pub mod editor_pixel_art_canvas_completion_input_type; +pub mod editor_pixel_art_canvas_placeholder_input_type; +pub mod editor_pixel_art_result_persist_input_type; +pub mod editor_pixel_art_result_persist_result_type; +pub mod editor_pixel_art_result_persist_status_type; +pub mod editor_pixel_art_result_preflight_input_type; +pub mod editor_pixel_art_result_preflight_result_type; pub mod editor_project_create_input_type; pub mod editor_project_delete_input_type; pub mod editor_project_delete_procedure_result_type; @@ -475,10 +482,12 @@ pub mod npc_relation_state_type; pub mod npc_stance_profile_type; pub mod npc_state_table; pub mod npc_state_type; +pub mod persist_editor_pixel_art_result_and_return_procedure; pub mod persist_editor_spritesheet_slice_batch_and_return_procedure; pub mod player_progression_grant_source_type; pub mod player_progression_table; pub mod player_progression_type; +pub mod preflight_editor_pixel_art_result_and_return_procedure; pub mod prepare_profile_recharge_refund_hold_and_return_procedure; pub mod preview_profile_recharge_refund_hold_and_return_procedure; pub mod profile_code_operation_table; @@ -1120,6 +1129,13 @@ pub use editor_generation_pricing_tier_type::EditorGenerationPricingTier; pub use editor_generation_runtime_identity_rotate_input_type::EditorGenerationRuntimeIdentityRotateInput; pub use editor_generation_runtime_identity_rotation_table::*; pub use editor_generation_runtime_identity_rotation_type::EditorGenerationRuntimeIdentityRotation; +pub use editor_pixel_art_canvas_completion_input_type::EditorPixelArtCanvasCompletionInput; +pub use editor_pixel_art_canvas_placeholder_input_type::EditorPixelArtCanvasPlaceholderInput; +pub use editor_pixel_art_result_persist_input_type::EditorPixelArtResultPersistInput; +pub use editor_pixel_art_result_persist_result_type::EditorPixelArtResultPersistResult; +pub use editor_pixel_art_result_persist_status_type::EditorPixelArtResultPersistStatus; +pub use editor_pixel_art_result_preflight_input_type::EditorPixelArtResultPreflightInput; +pub use editor_pixel_art_result_preflight_result_type::EditorPixelArtResultPreflightResult; pub use editor_project_create_input_type::EditorProjectCreateInput; pub use editor_project_delete_input_type::EditorProjectDeleteInput; pub use editor_project_delete_procedure_result_type::EditorProjectDeleteProcedureResult; @@ -1298,10 +1314,12 @@ pub use npc_relation_state_type::NpcRelationState; pub use npc_stance_profile_type::NpcStanceProfile; pub use npc_state_table::*; pub use npc_state_type::NpcState; +pub use persist_editor_pixel_art_result_and_return_procedure::persist_editor_pixel_art_result_and_return; pub use persist_editor_spritesheet_slice_batch_and_return_procedure::persist_editor_spritesheet_slice_batch_and_return; pub use player_progression_grant_source_type::PlayerProgressionGrantSource; pub use player_progression_table::*; pub use player_progression_type::PlayerProgression; +pub use preflight_editor_pixel_art_result_and_return_procedure::preflight_editor_pixel_art_result_and_return; pub use prepare_profile_recharge_refund_hold_and_return_procedure::prepare_profile_recharge_refund_hold_and_return; pub use preview_profile_recharge_refund_hold_and_return_procedure::preview_profile_recharge_refund_hold_and_return; pub use profile_code_operation_table::*; @@ -5061,19 +5079,19 @@ impl __sdk::SubscriptionHandle for SubscriptionHandle { /// either a [`DbConnection`] or an [`EventContext`] and operate on either. pub trait RemoteDbContext: __sdk::DbContext< - DbView = RemoteTables, - Reducers = RemoteReducers, - SubscriptionBuilder = __sdk::SubscriptionBuilder, -> + DbView = RemoteTables, + Reducers = RemoteReducers, + SubscriptionBuilder = __sdk::SubscriptionBuilder, + > { } impl< - Ctx: __sdk::DbContext< + Ctx: __sdk::DbContext< DbView = RemoteTables, Reducers = RemoteReducers, SubscriptionBuilder = __sdk::SubscriptionBuilder, >, - > RemoteDbContext for Ctx +> RemoteDbContext for Ctx { } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_canvas_completion_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_canvas_completion_input_type.rs new file mode 100644 index 000000000..bf42b848c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_canvas_completion_input_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_pixel_art_canvas_placeholder_input_type::EditorPixelArtCanvasPlaceholderInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorPixelArtCanvasCompletionInput { + pub dialog_id: String, + pub title: String, + pub placeholder: EditorPixelArtCanvasPlaceholderInput, +} + +impl __sdk::InModule for EditorPixelArtCanvasCompletionInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_canvas_placeholder_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_canvas_placeholder_input_type.rs new file mode 100644 index 000000000..560b919f2 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_canvas_placeholder_input_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorPixelArtCanvasPlaceholderInput { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, + pub original_width: f64, + pub original_height: f64, +} + +impl __sdk::InModule for EditorPixelArtCanvasPlaceholderInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_persist_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_persist_input_type.rs new file mode 100644 index 000000000..9ee40e575 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_persist_input_type.rs @@ -0,0 +1,28 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::asset_object_upsert_input_type::AssetObjectUpsertInput; +use super::editor_asset_create_input_type::EditorAssetCreateInput; +use super::editor_pixel_art_canvas_completion_input_type::EditorPixelArtCanvasCompletionInput; +use super::editor_project_resource_create_input_type::EditorProjectResourceCreateInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorPixelArtResultPersistInput { + pub owner_user_id: String, + pub project_id: String, + pub operation_id: String, + pub operation_fingerprint: String, + pub asset_object: AssetObjectUpsertInput, + pub project_resource: EditorProjectResourceCreateInput, + pub asset: EditorAssetCreateInput, + pub canvas_completion: EditorPixelArtCanvasCompletionInput, + pub completed_at_micros: i64, +} + +impl __sdk::InModule for EditorPixelArtResultPersistInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_persist_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_persist_result_type.rs new file mode 100644 index 000000000..e9b04d65d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_persist_result_type.rs @@ -0,0 +1,27 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::asset_object_upsert_snapshot_type::AssetObjectUpsertSnapshot; +use super::editor_asset_snapshot_type::EditorAssetSnapshot; +use super::editor_pixel_art_result_persist_status_type::EditorPixelArtResultPersistStatus; +use super::editor_project_resource_snapshot_type::EditorProjectResourceSnapshot; +use super::editor_project_snapshot_type::EditorProjectSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorPixelArtResultPersistResult { + pub ok: bool, + pub status: Option, + pub asset_object: Option, + pub project_resource: Option, + pub asset: Option, + pub project: Option, + pub error_message: Option, +} + +impl __sdk::InModule for EditorPixelArtResultPersistResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_persist_status_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_persist_status_type.rs new file mode 100644 index 000000000..942e61bf0 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_persist_status_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum EditorPixelArtResultPersistStatus { + Applied, + + DialogMissing, + + AlreadyApplied, +} + +impl __sdk::InModule for EditorPixelArtResultPersistStatus { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_preflight_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_preflight_input_type.rs new file mode 100644 index 000000000..0699166f6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_preflight_input_type.rs @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_pixel_art_canvas_completion_input_type::EditorPixelArtCanvasCompletionInput; +use super::editor_project_resource_create_input_type::EditorProjectResourceCreateInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorPixelArtResultPreflightInput { + pub owner_user_id: String, + pub project_id: String, + pub asset_folder_id: String, + pub project_resource: EditorProjectResourceCreateInput, + pub canvas_completion: EditorPixelArtCanvasCompletionInput, +} + +impl __sdk::InModule for EditorPixelArtResultPreflightInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_preflight_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_preflight_result_type.rs new file mode 100644 index 000000000..5b089e7ff --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_pixel_art_result_preflight_result_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorPixelArtResultPreflightResult { + pub ok: bool, + pub error_message: Option, +} + +impl __sdk::InModule for EditorPixelArtResultPreflightResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/persist_editor_pixel_art_result_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/persist_editor_pixel_art_result_and_return_procedure.rs new file mode 100644 index 000000000..c25ee2e1c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/persist_editor_pixel_art_result_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_pixel_art_result_persist_input_type::EditorPixelArtResultPersistInput; +use super::editor_pixel_art_result_persist_result_type::EditorPixelArtResultPersistResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct PersistEditorPixelArtResultAndReturnArgs { + pub input: EditorPixelArtResultPersistInput, +} + +impl __sdk::InModule for PersistEditorPixelArtResultAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `persist_editor_pixel_art_result_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait persist_editor_pixel_art_result_and_return { + fn persist_editor_pixel_art_result_and_return(&self, input: EditorPixelArtResultPersistInput) { + self.persist_editor_pixel_art_result_and_return_then(input, |_, _| {}); + } + + fn persist_editor_pixel_art_result_and_return_then( + &self, + input: EditorPixelArtResultPersistInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl persist_editor_pixel_art_result_and_return for super::RemoteProcedures { + fn persist_editor_pixel_art_result_and_return_then( + &self, + input: EditorPixelArtResultPersistInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorPixelArtResultPersistResult>( + "persist_editor_pixel_art_result_and_return", + PersistEditorPixelArtResultAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/preflight_editor_pixel_art_result_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/preflight_editor_pixel_art_result_and_return_procedure.rs new file mode 100644 index 000000000..f38144b63 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/preflight_editor_pixel_art_result_and_return_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_pixel_art_result_preflight_input_type::EditorPixelArtResultPreflightInput; +use super::editor_pixel_art_result_preflight_result_type::EditorPixelArtResultPreflightResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct PreflightEditorPixelArtResultAndReturnArgs { + pub input: EditorPixelArtResultPreflightInput, +} + +impl __sdk::InModule for PreflightEditorPixelArtResultAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `preflight_editor_pixel_art_result_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait preflight_editor_pixel_art_result_and_return { + fn preflight_editor_pixel_art_result_and_return( + &self, + input: EditorPixelArtResultPreflightInput, + ) { + self.preflight_editor_pixel_art_result_and_return_then(input, |_, _| {}); + } + + fn preflight_editor_pixel_art_result_and_return_then( + &self, + input: EditorPixelArtResultPreflightInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl preflight_editor_pixel_art_result_and_return for super::RemoteProcedures { + fn preflight_editor_pixel_art_result_and_return_then( + &self, + input: EditorPixelArtResultPreflightInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorPixelArtResultPreflightResult>( + "preflight_editor_pixel_art_result_and_return", + PreflightEditorPixelArtResultAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index 48fb89c49..25c30c134 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -897,6 +897,69 @@ pub struct EditorSpritesheetSliceBatchPersistResult { pub error_message: Option, } +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +pub struct EditorPixelArtCanvasPlaceholderInput { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, + pub original_width: f64, + pub original_height: f64, +} + +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +pub struct EditorPixelArtCanvasCompletionInput { + pub dialog_id: String, + pub title: String, + pub placeholder: EditorPixelArtCanvasPlaceholderInput, +} + +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +pub struct EditorPixelArtResultPreflightInput { + pub owner_user_id: String, + pub project_id: String, + pub asset_folder_id: String, + pub project_resource: EditorProjectResourceCreateInput, + pub canvas_completion: EditorPixelArtCanvasCompletionInput, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorPixelArtResultPreflightResult { + pub ok: bool, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +pub struct EditorPixelArtResultPersistInput { + pub owner_user_id: String, + pub project_id: String, + pub operation_id: String, + pub operation_fingerprint: String, + pub asset_object: AssetObjectUpsertInput, + pub project_resource: EditorProjectResourceCreateInput, + pub asset: EditorAssetCreateInput, + pub canvas_completion: EditorPixelArtCanvasCompletionInput, + pub completed_at_micros: i64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, SpacetimeType)] +pub enum EditorPixelArtResultPersistStatus { + Applied, + DialogMissing, + AlreadyApplied, +} + +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +pub struct EditorPixelArtResultPersistResult { + pub ok: bool, + pub status: Option, + pub asset_object: Option, + pub project_resource: Option, + pub asset: Option, + pub project: Option, + pub error_message: Option, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct EditorAssetUpdateInput { pub asset_id: String, @@ -1543,6 +1606,52 @@ pub fn persist_editor_spritesheet_slice_batch_and_return( } } +#[spacetimedb::procedure] +pub fn preflight_editor_pixel_art_result_and_return( + ctx: &mut ProcedureContext, + input: EditorPixelArtResultPreflightInput, +) -> EditorPixelArtResultPreflightResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| preflight_editor_pixel_art_result(tx, caller, input.clone())) { + Ok(()) => EditorPixelArtResultPreflightResult { + ok: true, + error_message: None, + }, + Err(message) => EditorPixelArtResultPreflightResult { + ok: false, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn persist_editor_pixel_art_result_and_return( + ctx: &mut ProcedureContext, + input: EditorPixelArtResultPersistInput, +) -> EditorPixelArtResultPersistResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| persist_editor_pixel_art_result(tx, caller, input.clone())) { + Ok(persisted) => EditorPixelArtResultPersistResult { + ok: true, + status: Some(persisted.status), + asset_object: Some(persisted.asset_object), + project_resource: Some(persisted.project_resource), + asset: Some(persisted.asset), + project: persisted.project, + error_message: None, + }, + Err(message) => EditorPixelArtResultPersistResult { + ok: false, + status: None, + asset_object: None, + project_resource: None, + asset: None, + project: None, + error_message: Some(message), + }, + } +} + #[spacetimedb::procedure] pub fn update_editor_asset_and_return( ctx: &mut ProcedureContext, @@ -2130,6 +2239,43 @@ fn create_editor_project_resource( ctx: &ReducerContext, input: EditorProjectResourceCreateInput, ) -> Result { + let resource = prepare_editor_project_resource(ctx, input)?; + if let Some(existing_resource) = find_reusable_project_resource_for_input( + ctx, + resource.project_id.as_str(), + resource.owner_user_id.as_str(), + resource.source_resource_id.as_ref(), + resource.asset_kind.as_deref(), + resource.asset_object_id.as_ref(), + resource.object_key.as_ref(), + resource.image_src.as_str(), + ) { + return Ok(resource_snapshot_from_row(existing_resource)); + } + if ctx + .db + .editor_project_resource() + .resource_id() + .find(&resource.resource_id) + .is_some() + { + return Err("画布资源已存在".to_string()); + } + let resource_id = resource.resource_id.clone(); + ctx.db.editor_project_resource().insert(resource); + + ctx.db + .editor_project_resource() + .resource_id() + .find(&resource_id) + .map(resource_snapshot_from_row) + .ok_or_else(|| "画布资源创建失败".to_string()) +} + +fn prepare_editor_project_resource( + ctx: &ReducerContext, + input: EditorProjectResourceCreateInput, +) -> Result { let resource_id = normalize_required(&input.resource_id, "editor_project_resource.resource_id")?; let project_id = normalize_required(&input.project_id, "editor_project_resource.project_id")?; @@ -2157,61 +2303,29 @@ fn create_editor_project_resource( let task_id = normalize_optional(input.task_id); let asset_kind = normalize_optional(input.asset_kind); let generation_inputs_json = normalize_optional(input.generation_inputs_json); - if let Some(existing_resource) = find_reusable_project_resource_for_input( - ctx, - project_id.as_str(), - owner_user_id.as_str(), - source_resource_id.as_ref(), - asset_kind.as_deref(), - asset_object_id.as_ref(), - object_key.as_ref(), - image_src.as_str(), - ) { - return Ok(resource_snapshot_from_row(existing_resource)); - } - if ctx - .db - .editor_project_resource() - .resource_id() - .find(&resource_id) - .is_some() - { - return Err("画布资源已存在".to_string()); - } - - let public_showcase_enabled = false; let now = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); - ctx.db - .editor_project_resource() - .insert(EditorProjectResource { - resource_id: resource_id.clone(), - project_id, - owner_user_id, - asset_object_id, - image_src, - object_key, - width: input.width, - height: input.height, - source_type, - prompt, - actual_prompt, - model, - provider, - task_id, - source_resource_id, - created_at: now, - updated_at: now, - asset_kind, - generation_inputs_json, - public_showcase_enabled, - }); - - ctx.db - .editor_project_resource() - .resource_id() - .find(&resource_id) - .map(resource_snapshot_from_row) - .ok_or_else(|| "画布资源创建失败".to_string()) + Ok(EditorProjectResource { + resource_id, + project_id, + owner_user_id, + asset_object_id, + image_src, + object_key, + width: input.width, + height: input.height, + source_type, + prompt, + actual_prompt, + model, + provider, + task_id, + source_resource_id, + created_at: now, + updated_at: now, + asset_kind, + generation_inputs_json, + public_showcase_enabled: false, + }) } fn repair_editor_project_resource_media( @@ -2536,6 +2650,888 @@ fn persist_editor_spritesheet_slice_batch( Ok(persisted_items) } +struct EditorPixelArtResultPersisted { + status: EditorPixelArtResultPersistStatus, + asset_object: AssetObjectUpsertSnapshot, + project_resource: EditorProjectResourceSnapshot, + asset: EditorAssetSnapshot, + project: Option, +} + +enum EditorPixelArtExistingRecords { + New, + Existing { + asset_object: AssetObjectUpsertSnapshot, + project_resource: EditorProjectResourceSnapshot, + asset: EditorAssetSnapshot, + }, +} + +enum EditorPixelArtCanvasCompletionPlan { + Apply { + canvas: EditorCanvas, + layers_json: String, + }, + DialogMissing, + AlreadyApplied, +} + +#[derive(Debug)] +enum EditorPixelArtCanvasLayoutPlan { + Apply(String), + DialogMissing, + AlreadyApplied, +} + +fn preflight_editor_pixel_art_result( + ctx: &ReducerContext, + caller: Identity, + input: EditorPixelArtResultPreflightInput, +) -> Result<(), String> { + require_editor_generation_runtime_service_identity(ctx, caller)?; + let owner_user_id = normalize_required(&input.owner_user_id, "owner_user_id")?; + let project_id = normalize_required(&input.project_id, "project_id")?; + let asset_folder_id = normalize_required(&input.asset_folder_id, "asset_folder_id")?; + require_owned_project(ctx, project_id.as_str(), owner_user_id.as_str())?; + validate_editor_pixel_art_preflight_asset_folder( + ctx, + asset_folder_id.as_str(), + owner_user_id.as_str(), + )?; + + let candidate_resource = prepare_editor_project_resource(ctx, input.project_resource)?; + if candidate_resource.project_id != project_id + || candidate_resource.owner_user_id != owner_user_id + { + return Err("完美像素 preflight 项目资源与 owner 或项目不一致".to_string()); + } + if let Some(source_resource_id) = candidate_resource.source_resource_id.as_deref() { + let source_resource = + require_owned_project_resource(ctx, source_resource_id, owner_user_id.as_str())?; + if source_resource.project_id != project_id + || source_resource.resource_id == candidate_resource.resource_id + { + return Err("完美像素来源资源不属于同一 owner 与项目".to_string()); + } + } + + let candidate_snapshot = resource_snapshot_from_row(candidate_resource.clone()); + match plan_editor_pixel_art_canvas_completion( + ctx, + project_id.as_str(), + owner_user_id.as_str(), + &input.canvas_completion, + &candidate_snapshot, + )? { + EditorPixelArtCanvasCompletionPlan::Apply { + canvas, + layers_json, + } => validate_editor_pixel_art_planned_canvas_layout( + ctx, + &canvas, + project_id.as_str(), + owner_user_id.as_str(), + layers_json, + &candidate_resource, + ), + EditorPixelArtCanvasCompletionPlan::DialogMissing + | EditorPixelArtCanvasCompletionPlan::AlreadyApplied => Ok(()), + } +} + +fn validate_editor_pixel_art_preflight_asset_folder( + ctx: &ReducerContext, + folder_id: &str, + owner_user_id: &str, +) -> Result<(), String> { + let default_folder_id = default_asset_folder_id(owner_user_id); + if folder_id == EDITOR_ASSET_DEFAULT_FOLDER_ID || folder_id == default_folder_id { + if let Some(folder) = ctx + .db + .editor_asset_folder() + .folder_id() + .find(&default_folder_id) + && folder.owner_user_id != owner_user_id + { + return Err("默认素材文件夹不属于当前 owner".to_string()); + } + return Ok(()); + } + require_owned_asset_folder(ctx, folder_id, owner_user_id).map(|_| ()) +} + +fn persist_editor_pixel_art_result( + ctx: &ReducerContext, + caller: Identity, + mut input: EditorPixelArtResultPersistInput, +) -> Result { + require_editor_generation_runtime_service_identity(ctx, caller)?; + let owner_user_id = normalize_required(&input.owner_user_id, "owner_user_id")?; + let project_id = normalize_required(&input.project_id, "project_id")?; + let operation_id = normalize_required(&input.operation_id, "operation_id")?; + let operation_fingerprint = + normalize_required(&input.operation_fingerprint, "operation_fingerprint")?; + let dialog_id = normalize_required( + &input.canvas_completion.dialog_id, + "canvas_completion.dialog_id", + )?; + + if input.asset.folder_id.trim() == EDITOR_ASSET_DEFAULT_FOLDER_ID { + input.asset.folder_id = default_asset_folder_id(owner_user_id.as_str()); + } + validate_editor_pixel_art_result_input( + ctx, + &input, + owner_user_id.as_str(), + project_id.as_str(), + operation_id.as_str(), + operation_fingerprint.as_str(), + dialog_id.as_str(), + )?; + + let existing = load_editor_pixel_art_existing_records(ctx, &input)?; + let (asset_object, project_resource, asset) = match existing { + EditorPixelArtExistingRecords::Existing { + asset_object, + project_resource, + asset, + } => { + let project = resolve_editor_pixel_art_replay_project_snapshot( + ctx, + project_id.as_str(), + owner_user_id.as_str(), + &input.canvas_completion, + &project_resource, + ) + .map_err(editor_pixel_art_idempotency_conflict)?; + return Ok(EditorPixelArtResultPersisted { + status: EditorPixelArtResultPersistStatus::AlreadyApplied, + asset_object, + project_resource, + asset, + project, + }); + } + EditorPixelArtExistingRecords::New => { + let asset_object = + crate::asset_metadata::upsert_asset_object(ctx, input.asset_object.clone())?; + let project_resource = + create_editor_project_resource(ctx, input.project_resource.clone())?; + if project_resource.resource_id.trim() != input.project_resource.resource_id.trim() { + return Err("完美像素项目资源未按稳定幂等 ID 创建".to_string()); + } + let asset = create_editor_asset(ctx, caller, input.asset.clone())?; + if asset.asset_id.trim() != input.asset.asset_id.trim() { + return Err("完美像素账号素材未按稳定幂等 ID 创建".to_string()); + } + (asset_object, project_resource, asset) + } + }; + + let completion_plan = plan_editor_pixel_art_canvas_completion( + ctx, + project_id.as_str(), + owner_user_id.as_str(), + &input.canvas_completion, + &project_resource, + ) + .map_err(editor_pixel_art_idempotency_conflict)?; + let (status, project) = match completion_plan { + EditorPixelArtCanvasCompletionPlan::DialogMissing => { + (EditorPixelArtResultPersistStatus::DialogMissing, None) + } + EditorPixelArtCanvasCompletionPlan::AlreadyApplied => { + return Err(editor_pixel_art_idempotency_conflict( + "画布已有稳定结果,但对应 object/resource/asset 记录此前不存在", + )); + } + EditorPixelArtCanvasCompletionPlan::Apply { + canvas, + layers_json, + } => { + let persisted_resource = ctx + .db + .editor_project_resource() + .resource_id() + .find(&project_resource.resource_id) + .ok_or_else(|| "完美像素项目资源不存在,拒绝完成画布".to_string())?; + // 中文注释:preflight 不持有锁或 reservation。PUT 之后进入最终原子事务时, + // 必须对当下的画布、迁移记录和 2 MiB / 512 KiB 布局门禁完整重验,不能把 + // PUT 前的只读结论当成提交凭证。 + validate_editor_pixel_art_planned_canvas_layout( + ctx, + &canvas, + project_id.as_str(), + owner_user_id.as_str(), + layers_json.clone(), + &persisted_resource, + )?; + persist_editor_project_layout_v2( + ctx, + EditorProjectLayoutSaveV2Input { + project_id: project_id.clone(), + owner_user_id: owner_user_id.clone(), + viewport: EditorProjectViewportSnapshot { + x: canvas.viewport_x, + y: canvas.viewport_y, + scale: canvas.viewport_scale, + }, + layers_json, + expected_revision: canvas.revision, + updated_at_micros: input.completed_at_micros, + }, + )?; + ( + EditorPixelArtResultPersistStatus::Applied, + Some(build_project_snapshot(ctx, project_id.as_str())?), + ) + } + }; + + Ok(EditorPixelArtResultPersisted { + status, + asset_object, + project_resource, + asset, + project, + }) +} + +fn validate_editor_pixel_art_result_input( + ctx: &ReducerContext, + input: &EditorPixelArtResultPersistInput, + owner_user_id: &str, + project_id: &str, + operation_id: &str, + operation_fingerprint: &str, + dialog_id: &str, +) -> Result<(), String> { + require_owned_project(ctx, project_id, owner_user_id)?; + let asset_object_id = normalize_required( + &input.asset_object.asset_object_id, + "asset_object.asset_object_id", + )?; + let object_key = normalize_required(&input.asset_object.object_key, "asset_object.object_key")?; + let resource_id = normalize_required( + &input.project_resource.resource_id, + "project_resource.resource_id", + )?; + let asset_id = normalize_required(&input.asset.asset_id, "asset.asset_id")?; + let task_id = normalize_optional(input.asset_object.source_job_id.clone()) + .ok_or_else(|| "完美像素 asset_object 缺少稳定 task_id".to_string())?; + let resource_task_id = normalize_optional(input.project_resource.task_id.clone()); + let asset_task_id = normalize_optional(input.asset.task_id.clone()); + let resource_asset_kind = normalize_optional(input.project_resource.asset_kind.clone()); + let asset_asset_kind = normalize_optional(input.asset.asset_kind.clone()); + let expected_task_id = format!("pixel-art-snap-{operation_id}"); + let expected_asset_object_id = editor_pixel_art_stable_record_id( + owner_user_id, + project_id, + operation_id, + "asset-object", + "assetobj_", + ); + let expected_resource_id = editor_pixel_art_stable_record_id( + owner_user_id, + project_id, + operation_id, + "project-resource", + "editor-resource-", + ); + let expected_asset_id = editor_pixel_art_stable_record_id( + owner_user_id, + project_id, + operation_id, + "asset", + "editor-asset-", + ); + + if operation_id != dialog_id + || operation_id != input.operation_id.trim() + || dialog_id != input.canvas_completion.dialog_id.trim() + { + return Err("完美像素 operation_id 必须等于规范化 dialog_id".to_string()); + } + if operation_id.contains('\0') || dialog_id.contains('\0') { + return Err("完美像素 operation_id 与 dialog_id 不得包含 NUL".to_string()); + } + if operation_fingerprint.len() != 64 + || !operation_fingerprint + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err("完美像素 operation_fingerprint 必须是 64 位小写十六进制".to_string()); + } + if task_id != expected_task_id + || asset_object_id != expected_asset_object_id + || resource_id != expected_resource_id + || asset_id != expected_asset_id + { + return Err("完美像素 task/object/resource/asset 未使用稳定 operation 身份".to_string()); + } + if input.asset_object.owner_user_id.as_deref().map(str::trim) != Some(owner_user_id) + || input.asset_object.profile_id.is_some() + || input.asset_object.access_policy != AssetObjectAccessPolicy::Private + || input.asset_object.asset_kind.trim() != "editor_pixel_art_snap" + || input.asset_object.content_length == 0 + || !input + .asset_object + .content_type + .as_deref() + .is_some_and(|value| value.trim().eq_ignore_ascii_case("image/png")) + { + return Err("完美像素 asset_object 的 owner、访问策略或媒体类型不合法".to_string()); + } + if !object_key.contains(operation_fingerprint) { + return Err("完美像素 object_key 未包含 operation_fingerprint".to_string()); + } + if input.asset_object.entity_id.as_deref().map(str::trim) != Some(task_id.as_str()) { + return Err("完美像素 asset_object.entity_id 必须等于稳定 task_id".to_string()); + } + if input.project_resource.project_id.trim() != project_id + || input.project_resource.owner_user_id.trim() != owner_user_id + || normalize_optional(input.project_resource.asset_object_id.clone()).as_deref() + != Some(asset_object_id.as_str()) + || normalize_optional(input.project_resource.object_key.clone()).as_deref() + != Some(object_key.as_str()) + || normalize_media_ref(input.project_resource.image_src.as_str()) != object_key + || input.project_resource.source_type.trim() != "generated" + || resource_task_id.as_deref() != Some(task_id.as_str()) + { + return Err("完美像素项目资源与对象的 owner、媒体或任务归属不一致".to_string()); + } + if input.asset.owner_user_id.trim() != owner_user_id + || normalize_optional(input.asset.asset_object_id.clone()).as_deref() + != Some(asset_object_id.as_str()) + || normalize_optional(input.asset.object_key.clone()).as_deref() + != Some(object_key.as_str()) + || normalize_media_ref(input.asset.image_src.as_str()) != object_key + || input.asset.source_type.trim() != "generated" + || asset_task_id.as_deref() != Some(task_id.as_str()) + || normalize_optional(input.asset.source_resource_id.clone()).as_deref() + != Some(resource_id.as_str()) + { + return Err("完美像素账号素材与对象、项目资源的 owner、媒体或任务归属不一致".to_string()); + } + if resource_asset_kind != asset_asset_kind + || normalize_optional(input.project_resource.prompt.clone()) + != normalize_optional(input.asset.prompt.clone()) + || normalize_optional(input.project_resource.actual_prompt.clone()) + != normalize_optional(input.asset.actual_prompt.clone()) + || normalize_optional(input.project_resource.model.clone()) + != normalize_optional(input.asset.model.clone()) + || normalize_optional(input.project_resource.provider.clone()) + != normalize_optional(input.asset.provider.clone()) + || normalize_optional(input.project_resource.generation_inputs_json.clone()) + != normalize_optional(input.asset.generation_inputs_json.clone()) + || input.project_resource.width != input.asset.width + || input.project_resource.height != input.asset.height + || input.asset.generation_cost_mud_points != 0 + || input.asset.group_task_id.is_some() + || input.asset.group_task_expected_asset_count.is_some() + { + return Err("完美像素项目资源与账号素材的内容不一致".to_string()); + } + if input.asset_object.updated_at_micros != input.completed_at_micros + || input.project_resource.updated_at_micros != input.completed_at_micros + || input.asset.now_micros != input.completed_at_micros + { + return Err("完美像素持久化时间必须使用同一 completed_at_micros".to_string()); + } + normalize_required(&input.canvas_completion.title, "canvas_completion.title")?; + validate_editor_pixel_art_placeholder(&input.canvas_completion.placeholder)?; + normalize_required(&input.asset.folder_id, "asset.folder_id")?; + normalize_required(&asset_id, "asset.asset_id")?; + + if let Some(source_resource_id) = + normalize_optional(input.project_resource.source_resource_id.clone()) + { + let source_resource = ctx + .db + .editor_project_resource() + .resource_id() + .find(&source_resource_id) + .ok_or_else(|| "完美像素来源项目资源不存在".to_string())?; + if source_resource.resource_id == resource_id + || source_resource.owner_user_id != owner_user_id + || source_resource.project_id != project_id + { + return Err("完美像素来源资源不属于同一 owner 与项目".to_string()); + } + } + + Ok(()) +} + +fn editor_pixel_art_stable_record_id( + owner_user_id: &str, + project_id: &str, + operation_id: &str, + record_kind: &str, + prefix: &str, +) -> String { + let digest = Sha256::digest( + format!( + "editor-pixel-art-result-v1\0{owner_user_id}\0{project_id}\0{operation_id}\0{record_kind}" + ) + .as_bytes(), + ); + let suffix = digest + .iter() + .take(16) + .map(|byte| format!("{byte:02x}")) + .collect::(); + format!("{prefix}{suffix}") +} + +fn editor_pixel_art_idempotency_conflict(reason: impl AsRef) -> String { + format!("完美像素幂等冲突:{}", reason.as_ref()) +} + +fn validate_editor_pixel_art_placeholder( + placeholder: &EditorPixelArtCanvasPlaceholderInput, +) -> Result<(), String> { + if !placeholder.x.is_finite() + || !placeholder.y.is_finite() + || !placeholder.width.is_finite() + || !placeholder.height.is_finite() + || !placeholder.original_width.is_finite() + || !placeholder.original_height.is_finite() + || placeholder.width <= 0.0 + || placeholder.height <= 0.0 + || placeholder.original_width <= 0.0 + || placeholder.original_height <= 0.0 + { + return Err("完美像素 canvas placeholder 坐标或尺寸不合法".to_string()); + } + Ok(()) +} + +fn load_editor_pixel_art_existing_records( + ctx: &ReducerContext, + input: &EditorPixelArtResultPersistInput, +) -> Result { + let candidate = &input.asset_object; + let by_id = crate::asset_metadata::find_asset_object_snapshot_by_id( + ctx, + candidate.asset_object_id.as_str(), + ); + let by_location = crate::asset_metadata::find_asset_object_by_location( + ctx, + &AssetObjectLocationInput { + bucket: candidate.bucket.clone(), + object_key: candidate.object_key.clone(), + }, + ) + .map_err(|message| { + if message.contains("重复记录") { + editor_pixel_art_idempotency_conflict(message) + } else { + message + } + })?; + if let (Some(by_id), Some(by_location)) = (&by_id, &by_location) + && by_id.asset_object_id != by_location.asset_object_id + { + return Err(editor_pixel_art_idempotency_conflict( + "对象位置已由其他 asset_object_id 占用", + )); + } + let asset_object = by_id.or(by_location); + if let Some(existing) = &asset_object + && !editor_pixel_art_asset_object_matches(existing, candidate) + { + return Err("完美像素 asset_object 幂等键已被其他内容占用".to_string()); + } + + let resource_id = input.project_resource.resource_id.trim().to_string(); + let project_resource = ctx + .db + .editor_project_resource() + .resource_id() + .find(&resource_id); + if let Some(existing) = &project_resource + && !editor_pixel_art_project_resource_matches(existing, &input.project_resource) + { + return Err("完美像素 project_resource 幂等键已被其他内容占用".to_string()); + } + let asset_id = input.asset.asset_id.trim().to_string(); + let asset = ctx.db.editor_asset().asset_id().find(&asset_id); + if let Some(existing) = &asset + && !editor_pixel_art_asset_matches(existing, &input.asset) + { + return Err("完美像素 editor_asset 幂等键已被其他内容占用".to_string()); + } + + match ( + asset_object, + project_resource.map(resource_snapshot_from_row), + asset.map(|row| asset_snapshot_from_row(ctx, row)), + ) { + (None, None, None) => Ok(EditorPixelArtExistingRecords::New), + (Some(asset_object), Some(project_resource), Some(asset)) => { + Ok(EditorPixelArtExistingRecords::Existing { + asset_object, + project_resource, + asset, + }) + } + _ => Err("完美像素幂等记录不完整,拒绝补写部分状态".to_string()), + } +} + +fn editor_pixel_art_asset_object_matches( + existing: &AssetObjectUpsertSnapshot, + input: &AssetObjectUpsertInput, +) -> bool { + existing.asset_object_id.trim() == input.asset_object_id.trim() + && existing.bucket.trim() == input.bucket.trim() + && existing.object_key.trim() == input.object_key.trim() + && existing.access_policy == input.access_policy + && normalize_optional(existing.content_type.clone()) + == normalize_optional(input.content_type.clone()) + && existing.content_length == input.content_length + && normalize_optional(existing.content_hash.clone()) + == normalize_optional(input.content_hash.clone()) + && existing.version == input.version + && normalize_optional(existing.source_job_id.clone()) + == normalize_optional(input.source_job_id.clone()) + && normalize_optional(existing.owner_user_id.clone()) + == normalize_optional(input.owner_user_id.clone()) + && normalize_optional(existing.profile_id.clone()) + == normalize_optional(input.profile_id.clone()) + && normalize_optional(existing.entity_id.clone()) + == normalize_optional(input.entity_id.clone()) + && existing.asset_kind.trim() == input.asset_kind.trim() +} + +fn editor_pixel_art_project_resource_matches( + existing: &EditorProjectResource, + input: &EditorProjectResourceCreateInput, +) -> bool { + existing.resource_id.trim() == input.resource_id.trim() + && existing.project_id.trim() == input.project_id.trim() + && existing.owner_user_id.trim() == input.owner_user_id.trim() + && existing.asset_object_id == normalize_optional(input.asset_object_id.clone()) + && existing.image_src.trim() == input.image_src.trim() + && existing.object_key == normalize_optional(input.object_key.clone()) + && existing.width == input.width + && existing.height == input.height + && existing.source_type.trim() == input.source_type.trim() + && existing.prompt == normalize_optional(input.prompt.clone()) + && existing.actual_prompt == normalize_optional(input.actual_prompt.clone()) + && existing.model == normalize_optional(input.model.clone()) + && existing.provider == normalize_optional(input.provider.clone()) + && existing.task_id == normalize_optional(input.task_id.clone()) + && existing.source_resource_id == normalize_optional(input.source_resource_id.clone()) + && existing.asset_kind == normalize_optional(input.asset_kind.clone()) + && existing.generation_inputs_json + == normalize_optional(input.generation_inputs_json.clone()) + && !existing.public_showcase_enabled +} + +fn editor_pixel_art_asset_matches(existing: &EditorAsset, input: &EditorAssetCreateInput) -> bool { + existing.asset_id.trim() == input.asset_id.trim() + && existing.owner_user_id.trim() == input.owner_user_id.trim() + && existing.folder_id.trim() == input.folder_id.trim() + && existing.label == normalize_asset_label(&input.label) + && existing.asset_object_id == normalize_optional(input.asset_object_id.clone()) + && existing.image_src.trim() == input.image_src.trim() + && existing.object_key == normalize_optional(input.object_key.clone()) + && existing.width == input.width + && existing.height == input.height + && existing.source_type.trim() == input.source_type.trim() + && existing.prompt == normalize_optional(input.prompt.clone()) + && existing.actual_prompt == normalize_optional(input.actual_prompt.clone()) + && existing.model == normalize_optional(input.model.clone()) + && existing.provider == normalize_optional(input.provider.clone()) + && existing.task_id == normalize_optional(input.task_id.clone()) + && existing.asset_kind == normalize_optional(input.asset_kind.clone()) + && existing.generation_inputs_json + == normalize_optional(input.generation_inputs_json.clone()) + && existing.source_resource_id == normalize_optional(input.source_resource_id.clone()) + && existing.thumbnail_src == normalize_optional(input.thumbnail_src.clone()) + && existing.generation_cost_mud_points == input.generation_cost_mud_points + && existing.group_task_id == normalize_optional(input.group_task_id.clone()) + && existing.group_task_expected_asset_count == input.group_task_expected_asset_count +} + +fn resolve_editor_pixel_art_replay_project_snapshot( + ctx: &ReducerContext, + project_id: &str, + owner_user_id: &str, + completion: &EditorPixelArtCanvasCompletionInput, + resource: &EditorProjectResourceSnapshot, +) -> Result, String> { + require_owned_project(ctx, project_id, owner_user_id)?; + let Some(canvas) = ctx + .db + .editor_canvas() + .canvas_id() + .find(&default_canvas_id(project_id)) + else { + return Ok(None); + }; + if canvas.owner_user_id != owner_user_id || canvas.project_id != project_id { + return Err("完美像素画布不属于当前 owner 与项目".to_string()); + } + let layers_json = + if canvas.layout_storage_version == EDITOR_CANVAS_LAYOUT_STORAGE_VERSION_STRUCTURED { + build_structured_canvas_layout_json(ctx, &canvas)? + } else { + canvas.layers_json + }; + if !validate_editor_pixel_art_canvas_replay(layers_json.as_str(), completion, resource)? { + return Ok(None); + } + build_project_snapshot(ctx, project_id).map(Some) +} + +fn validate_editor_pixel_art_canvas_replay( + layers_json: &str, + completion: &EditorPixelArtCanvasCompletionInput, + resource: &EditorProjectResourceSnapshot, +) -> Result { + match plan_editor_pixel_art_canvas_layout(layers_json, completion, resource)? { + EditorPixelArtCanvasLayoutPlan::DialogMissing => Ok(false), + EditorPixelArtCanvasLayoutPlan::AlreadyApplied => Ok(true), + EditorPixelArtCanvasLayoutPlan::Apply(_) => Err( + "稳定 object/resource/asset 已存在,但画布 dialog 尚未由该 operation 收口".to_string(), + ), + } +} + +fn plan_editor_pixel_art_canvas_completion( + ctx: &ReducerContext, + project_id: &str, + owner_user_id: &str, + completion: &EditorPixelArtCanvasCompletionInput, + resource: &EditorProjectResourceSnapshot, +) -> Result { + require_owned_project(ctx, project_id, owner_user_id)?; + let Some(canvas) = ctx + .db + .editor_canvas() + .canvas_id() + .find(&default_canvas_id(project_id)) + else { + return Ok(EditorPixelArtCanvasCompletionPlan::DialogMissing); + }; + if canvas.owner_user_id != owner_user_id || canvas.project_id != project_id { + return Err("完美像素画布不属于当前 owner 与项目".to_string()); + } + let current_layers_json = + if canvas.layout_storage_version == EDITOR_CANVAS_LAYOUT_STORAGE_VERSION_STRUCTURED { + build_structured_canvas_layout_json(ctx, &canvas)? + } else { + canvas.layers_json.clone() + }; + match plan_editor_pixel_art_canvas_layout(current_layers_json.as_str(), completion, resource)? { + EditorPixelArtCanvasLayoutPlan::Apply(layers_json) => { + Ok(EditorPixelArtCanvasCompletionPlan::Apply { + canvas, + layers_json, + }) + } + EditorPixelArtCanvasLayoutPlan::DialogMissing => { + Ok(EditorPixelArtCanvasCompletionPlan::DialogMissing) + } + EditorPixelArtCanvasLayoutPlan::AlreadyApplied => { + Ok(EditorPixelArtCanvasCompletionPlan::AlreadyApplied) + } + } +} + +fn plan_editor_pixel_art_canvas_layout( + current_layers_json: &str, + completion: &EditorPixelArtCanvasCompletionInput, + resource: &EditorProjectResourceSnapshot, +) -> Result { + let mut layout = serde_json::from_str::(current_layers_json) + .map_err(|_| "图片画布图层布局不是合法 JSON".to_string())?; + let items = layout + .as_array_mut() + .ok_or_else(|| "图片画布图层布局必须是数组".to_string())?; + let dialog_id = completion.dialog_id.trim(); + let dialog_indexes = items + .iter() + .enumerate() + .filter_map(|(index, item)| { + let dialog = canvas_generation_dialog(item)?; + (canvas_generation_dialog_id(dialog).as_deref() == Some(dialog_id)).then_some(index) + }) + .collect::>(); + if dialog_indexes.is_empty() { + return Ok(EditorPixelArtCanvasLayoutPlan::DialogMissing); + } + if dialog_indexes.len() != 1 { + return Err("完美像素画布包含重复 dialog_id".to_string()); + } + let dialog_index = dialog_indexes[0]; + let dialog = canvas_generation_dialog(&items[dialog_index]) + .cloned() + .ok_or_else(|| "完美像素画布占位缺少 dialog".to_string())?; + let generated_layer_id = format!("layer-editor-resource-{}", resource.resource_id); + let matching_layer_indexes = items + .iter() + .enumerate() + .filter_map(|(index, item)| { + (canvas_layout_layer_id(item).as_deref() == Some(generated_layer_id.as_str())) + .then_some(index) + }) + .collect::>(); + if matching_layer_indexes.len() > 1 { + return Err("完美像素画布包含重复结果图层".to_string()); + } + + let dialog_status = canvas_generation_dialog_status(&dialog); + let completed_layer_id = canvas_generation_dialog_generated_layer_id(&dialog); + if dialog_status == Some("generating") && completed_layer_id.is_some() { + return Err("完美像素旧结果不得覆盖同一 dialog 的在途重试".to_string()); + } + if let Some(completed_layer_id) = completed_layer_id { + if completed_layer_id != generated_layer_id { + return Err("完美像素 dialog 已由其他结果完成".to_string()); + } + let existing_layer = matching_layer_indexes + .first() + .and_then(|index| items.get(*index)) + .ok_or_else(|| "完美像素 dialog 指向的结果图层不存在".to_string())?; + if !editor_pixel_art_canvas_layer_matches(existing_layer, resource) { + return Err("完美像素已完成图层与持久化资源不一致".to_string()); + } + return Ok(EditorPixelArtCanvasLayoutPlan::AlreadyApplied); + } + if !matching_layer_indexes.is_empty() { + return Err("完美像素稳定 layer_id 已被未关联图层占用".to_string()); + } + + let placeholder = editor_pixel_art_dialog_placeholder(&dialog) + .filter(|placeholder| validate_editor_pixel_art_placeholder(placeholder).is_ok()) + .unwrap_or_else(|| completion.placeholder.clone()); + let next_z_index = items + .iter() + .filter_map(|item| item.get("zIndex").and_then(JsonValue::as_i64)) + .max() + .unwrap_or(0) + + 1; + let result_layer = build_editor_pixel_art_canvas_layer( + completion, + &placeholder, + resource, + generated_layer_id.as_str(), + next_z_index, + )?; + items.push(result_layer); + let dialog_object = items + .get_mut(dialog_index) + .and_then(|item| item.get_mut("dialog")) + .and_then(JsonValue::as_object_mut) + .ok_or_else(|| "完美像素画布占位缺少 dialog".to_string())?; + dialog_object.insert("status".to_string(), JsonValue::String("idle".to_string())); + dialog_object.insert("composerOpen".to_string(), JsonValue::Bool(false)); + dialog_object.insert( + "generatedLayerId".to_string(), + JsonValue::String(generated_layer_id), + ); + dialog_object.remove("errorMessage"); + + serde_json::to_string(&layout) + .map(EditorPixelArtCanvasLayoutPlan::Apply) + .map_err(|_| "完美像素完成后的画布布局无法序列化".to_string()) +} + +fn editor_pixel_art_dialog_placeholder( + dialog: &JsonValue, +) -> Option { + let placeholder = dialog.get("placeholder")?.as_object()?; + Some(EditorPixelArtCanvasPlaceholderInput { + x: json_optional_number(placeholder.get("x"))?, + y: json_optional_number(placeholder.get("y"))?, + width: json_optional_number(placeholder.get("width"))?, + height: json_optional_number(placeholder.get("height"))?, + original_width: json_optional_number(placeholder.get("originalWidth"))?, + original_height: json_optional_number(placeholder.get("originalHeight"))?, + }) +} + +fn build_editor_pixel_art_canvas_layer( + completion: &EditorPixelArtCanvasCompletionInput, + placeholder: &EditorPixelArtCanvasPlaceholderInput, + resource: &EditorProjectResourceSnapshot, + generated_layer_id: &str, + z_index: i64, +) -> Result { + let original_width = + positive_editor_pixel_art_dimension(f64::from(resource.width), placeholder.original_width); + let original_height = positive_editor_pixel_art_dimension( + f64::from(resource.height), + placeholder.original_height, + ); + let width = positive_editor_pixel_art_dimension(original_width, placeholder.width); + let height = positive_editor_pixel_art_dimension(original_height, placeholder.height); + let placeholder_width = positive_editor_pixel_art_dimension(placeholder.width, width); + let placeholder_height = positive_editor_pixel_art_dimension(placeholder.height, height); + let x = placeholder.x + placeholder_width / 2.0 - width / 2.0; + let y = placeholder.y + placeholder_height / 2.0 - height / 2.0; + let generation_inputs = resource + .generation_inputs_json + .as_deref() + .map(serde_json::from_str::) + .transpose() + .map_err(|_| "完美像素项目资源 generation_inputs_json 不是合法 JSON".to_string())? + .unwrap_or(JsonValue::Null); + + Ok(serde_json::json!({ + "layerId": generated_layer_id, + "resourceId": resource.resource_id, + "title": completion.title.trim(), + "src": resource.image_src, + "x": x, + "y": y, + "width": width, + "height": height, + "originalWidth": original_width, + "originalHeight": original_height, + "zIndex": z_index, + "sourceType": "generated", + "mediaType": "image", + "prompt": resource.prompt, + "actualPrompt": resource.actual_prompt, + "model": resource.model, + "provider": resource.provider, + "taskId": resource.task_id, + "objectKey": resource.object_key, + "assetObjectId": resource.asset_object_id, + "sourceResourceId": resource.source_resource_id, + "assetKind": resource.asset_kind, + "generationInputs": generation_inputs, + })) +} + +fn editor_pixel_art_canvas_layer_matches( + item: &JsonValue, + resource: &EditorProjectResourceSnapshot, +) -> bool { + let expected_layer_id = format!("layer-editor-resource-{}", resource.resource_id); + item.get("layerId").and_then(JsonValue::as_str) == Some(expected_layer_id.as_str()) + && item.get("resourceId").and_then(JsonValue::as_str) == Some(resource.resource_id.as_str()) + && item.get("objectKey").and_then(JsonValue::as_str) == resource.object_key.as_deref() + && item.get("assetObjectId").and_then(JsonValue::as_str) + == resource.asset_object_id.as_deref() + && item.get("taskId").and_then(JsonValue::as_str) == resource.task_id.as_deref() +} + +fn positive_editor_pixel_art_dimension(value: f64, fallback: f64) -> f64 { + if value.is_finite() && value > 0.0 { + value.round().max(1.0) + } else if fallback.is_finite() && fallback > 0.0 { + fallback.round().max(1.0) + } else { + 1.0 + } +} + fn create_or_reuse_editor_spritesheet_asset( ctx: &ReducerContext, caller: Identity, @@ -6109,18 +7105,13 @@ fn normalize_structured_canvas_layer_against_resource( resource.actual_prompt.as_deref(), layer.layer_id.as_str(), )?; - take_matching_optional_resource_string( - &mut item, - "model", - resource.model.as_deref(), - layer.layer_id.as_str(), - )?; - take_matching_optional_resource_string( - &mut item, - "provider", - resource.provider.as_deref(), - layer.layer_id.as_str(), - )?; + // 中文注释:`model` / `provider` 不做判等,一律以资源行为准。owner 读边界会脱敏内部处理 + // 模型(`model`)并无条件省略 `provider`,客户端因此拿不到权威值,这条判等对正常路径是 + // 不可满足的——留着只会让「读到什么就回写什么」的客户端整次保存 400。两者都属于纯丢弃 + // 字段,判等通过与否都不写回资源行(不同于会合并回资源的 assetKind / generationInputs), + // 因此丢弃客户端值不影响任何持久化状态。 + item.remove("model"); + item.remove("provider"); take_matching_optional_resource_string( &mut item, "taskId", @@ -7767,6 +8758,23 @@ fn validate_repaired_editor_canvas_layout_resources( owner_user_id: &str, planned_resources: &[EditorProjectResource], ) -> Result<(), String> { + normalize_editor_canvas_layout_with_planned_resources( + ctx, + layers_json, + project_id, + owner_user_id, + planned_resources, + ) + .map(|_| ()) +} + +fn normalize_editor_canvas_layout_with_planned_resources( + ctx: &ReducerContext, + layers_json: &str, + project_id: &str, + owner_user_id: &str, + planned_resources: &[EditorProjectResource], +) -> Result { let mut layout = parse_structured_canvas_layout(layers_json)?; for layer in &mut layout.layers { let stored = ctx @@ -7797,7 +8805,49 @@ fn validate_repaired_editor_canvas_layout_resources( } } } - Ok(()) + normalize_layout_json( + serialize_structured_canvas_layout(&layout) + .map_err(|_| "图片画布布局无法序列化".to_string())?, + ) +} + +fn validate_editor_pixel_art_planned_canvas_layout( + ctx: &ReducerContext, + canvas: &EditorCanvas, + project_id: &str, + owner_user_id: &str, + layers_json: String, + candidate_resource: &EditorProjectResource, +) -> Result<(), String> { + if canvas.layout_storage_version == EDITOR_CANVAS_LAYOUT_STORAGE_VERSION_STRUCTURED { + let active_migration = ctx + .db + .editor_canvas_layout_migration() + .canvas_id() + .find(&canvas.canvas_id) + .filter(|migration| migration.status == EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ACTIVE) + .ok_or_else(|| "结构化图片画布缺少 active 迁移记录,拒绝保存".to_string())?; + let current_structured_json = build_structured_canvas_layout_json(ctx, canvas)?; + let current_structured_hash = canonical_layout_sha256(current_structured_json.as_str())?; + let current_structured_integrity = + canvas_layout_integrity(current_structured_json.as_str())?; + verify_migration_layout( + &active_migration, + canvas.revision, + current_structured_hash.as_str(), + ¤t_structured_integrity, + )?; + } + normalize_layout_json(layers_json).and_then(|layers_json| { + normalize_editor_canvas_layout_with_planned_resources( + ctx, + layers_json.as_str(), + project_id, + owner_user_id, + std::slice::from_ref(candidate_resource), + ) + .map(|_| ()) + }) } fn backfill_editor_canvas_layout( @@ -8747,6 +9797,267 @@ mod tests { } } + fn pixel_art_project_resource() -> EditorProjectResourceSnapshot { + EditorProjectResourceSnapshot { + resource_id: "editor-resource-pixel".to_string(), + project_id: "project-1".to_string(), + owner_user_id: "owner-1".to_string(), + asset_object_id: Some("assetobj_pixel".to_string()), + image_src: "/generated/editor/pixel/result.png".to_string(), + object_key: Some("generated/editor/pixel/result.png".to_string()), + width: 32, + height: 16, + source_type: "generated".to_string(), + prompt: Some("完美像素".to_string()), + actual_prompt: None, + model: Some("Perfect Pixel".to_string()), + provider: Some("Genarrative".to_string()), + task_id: Some("pixel-art-snap-dialog-1".to_string()), + source_resource_id: Some("source-resource".to_string()), + asset_kind: Some("image".to_string()), + generation_inputs_json: Some("{\"source\":\"canvas\"}".to_string()), + public_showcase_enabled: false, + created_at_micros: 1_000_000, + updated_at_micros: 1_000_000, + } + } + + fn pixel_art_canvas_completion() -> EditorPixelArtCanvasCompletionInput { + EditorPixelArtCanvasCompletionInput { + dialog_id: "dialog-1".to_string(), + title: "结果".to_string(), + placeholder: EditorPixelArtCanvasPlaceholderInput { + x: 100.0, + y: 50.0, + width: 200.0, + height: 100.0, + original_width: 200.0, + original_height: 100.0, + }, + } + } + + #[test] + fn pixel_art_stable_record_ids_are_domain_separated() { + assert_eq!( + editor_pixel_art_stable_record_id( + "owner-1", + "project-1", + "dialog-1", + "asset-object", + "assetobj_", + ), + "assetobj_8a264e1086ee3d6878d753aec254e0a5" + ); + assert_eq!( + editor_pixel_art_stable_record_id( + "owner-1", + "project-1", + "dialog-1", + "project-resource", + "editor-resource-", + ), + "editor-resource-9eee84b77e8828ca8f5042919198ac1c" + ); + assert_eq!( + editor_pixel_art_stable_record_id( + "owner-1", + "project-1", + "dialog-1", + "asset", + "editor-asset-", + ), + "editor-asset-c7e7e66538f4613226e68001b8408104" + ); + } + + #[test] + fn pixel_art_preflight_is_read_only_and_final_transaction_revalidates() { + let source = include_str!("editor_project_storage.rs"); + let preflight_start = source + .find("fn preflight_editor_pixel_art_result(\n") + .expect("preflight implementation"); + let preflight_end = source[preflight_start..] + .find("fn validate_editor_pixel_art_preflight_asset_folder(") + .map(|offset| preflight_start + offset) + .expect("preflight folder validation boundary"); + let preflight = &source[preflight_start..preflight_end]; + for required in [ + "require_editor_generation_runtime_service_identity", + "validate_editor_pixel_art_preflight_asset_folder", + "prepare_editor_project_resource", + "plan_editor_pixel_art_canvas_completion", + "validate_editor_pixel_art_planned_canvas_layout", + ] { + assert!( + preflight.contains(required), + "preflight must retain {required}" + ); + } + for forbidden in [".insert(", ".update(", ".delete("] { + assert!( + !preflight.contains(forbidden), + "preflight must remain read-only: {forbidden}" + ); + } + + let persist_start = source + .find("fn persist_editor_pixel_art_result(\n") + .expect("atomic persist implementation"); + let persist_end = source[persist_start..] + .find("fn validate_editor_pixel_art_result_input(") + .map(|offset| persist_start + offset) + .expect("atomic persist validation boundary"); + let persist = &source[persist_start..persist_end]; + for required in [ + "validate_editor_pixel_art_result_input", + "load_editor_pixel_art_existing_records", + "plan_editor_pixel_art_canvas_completion", + "validate_editor_pixel_art_planned_canvas_layout", + "persist_editor_project_layout_v2", + ] { + assert!( + persist.contains(required), + "final transaction must revalidate {required}" + ); + } + } + + #[test] + fn pixel_art_canvas_completion_applies_once_and_replays_without_mutation() { + let layout = json!([ + { + "layerId": "layer-source", + "resourceId": "source-resource", + "zIndex": 4 + }, + { + "itemType": "generation-dialog", + "layerId": "generation-dialog:dialog-1", + "resourceId": "generation-dialog:dialog-1", + "dialog": { + "id": "dialog-1", + "mode": "quick-edit", + "status": "generating", + "composerOpen": false, + "errorMessage": "旧错误", + "placeholder": { + "x": 100.0, + "y": 50.0, + "width": 200.0, + "height": 100.0, + "originalWidth": 200.0, + "originalHeight": 100.0 + } + } + } + ]); + let completion = pixel_art_canvas_completion(); + let resource = pixel_art_project_resource(); + let layout_json = layout.to_string(); + let pending_replay_error = + validate_editor_pixel_art_canvas_replay(layout_json.as_str(), &completion, &resource) + .expect_err("stable records must not complete an unresolved dialog during replay"); + assert!(pending_replay_error.contains("尚未由该 operation 收口")); + let applied = + match plan_editor_pixel_art_canvas_layout(layout_json.as_str(), &completion, &resource) + .expect("pixel-art completion should apply") + { + EditorPixelArtCanvasLayoutPlan::Apply(layers_json) => layers_json, + _ => panic!("first completion must apply"), + }; + let applied_value: JsonValue = + serde_json::from_str(applied.as_str()).expect("applied layout"); + let items = applied_value.as_array().expect("layout array"); + let layer = items + .iter() + .find(|item| item["layerId"] == "layer-editor-resource-editor-resource-pixel") + .expect("generated layer"); + assert_eq!(layer["resourceId"], json!("editor-resource-pixel")); + assert_eq!(layer["x"], json!(184.0)); + assert_eq!(layer["y"], json!(92.0)); + assert_eq!(layer["zIndex"], json!(5)); + assert_eq!(layer["generationInputs"]["source"], json!("canvas")); + let dialog = items + .iter() + .find_map(canvas_generation_dialog) + .expect("generation dialog"); + assert_eq!(dialog["status"], json!("idle")); + assert_eq!(dialog["composerOpen"], json!(false)); + assert_eq!( + dialog["generatedLayerId"], + json!("layer-editor-resource-editor-resource-pixel") + ); + assert!(dialog.get("errorMessage").is_none()); + assert!( + validate_editor_pixel_art_canvas_replay(applied.as_str(), &completion, &resource) + .expect("applied replay") + ); + assert!(matches!( + plan_editor_pixel_art_canvas_layout(applied.as_str(), &completion, &resource) + .expect("exact replay"), + EditorPixelArtCanvasLayoutPlan::AlreadyApplied + )); + } + + #[test] + fn pixel_art_canvas_completion_keeps_deleted_dialog_absent() { + let layout = json!([{ + "layerId": "layer-source", + "resourceId": "source-resource" + }]) + .to_string(); + assert!( + !validate_editor_pixel_art_canvas_replay( + layout.as_str(), + &pixel_art_canvas_completion(), + &pixel_art_project_resource(), + ) + .expect("missing dialog is a committed asset-only replay outcome") + ); + } + + #[test] + fn pixel_art_late_completion_does_not_overwrite_active_retry() { + let resource = pixel_art_project_resource(); + let completion = pixel_art_canvas_completion(); + let layout = json!([ + { + "layerId": "layer-editor-resource-editor-resource-pixel", + "resourceId": "editor-resource-pixel", + "objectKey": "generated/editor/pixel/result.png", + "assetObjectId": "assetobj_pixel", + "taskId": "pixel-art-snap-dialog-1" + }, + { + "itemType": "generation-dialog", + "layerId": "generation-dialog:dialog-1", + "resourceId": "generation-dialog:dialog-1", + "dialog": { + "id": "dialog-1", + "mode": "quick-edit", + "status": "generating", + "generatedLayerId": "layer-editor-resource-editor-resource-pixel", + "placeholder": { + "x": 100.0, + "y": 50.0, + "width": 200.0, + "height": 100.0, + "originalWidth": 200.0, + "originalHeight": 100.0 + } + } + } + ]); + let error = validate_editor_pixel_art_canvas_replay( + layout.to_string().as_str(), + &completion, + &resource, + ) + .expect_err("late completion must not overwrite an active retry"); + assert!(error.contains("在途重试")); + } + #[test] fn spritesheet_slice_batch_validation_accepts_complete_owned_cohort() { let batch = spritesheet_slice_batch(); @@ -9591,6 +10902,102 @@ mod tests { .expect("legacy self-reference should also be ignored when resource truth is empty"); } + // 中文注释:模拟 2026-07-30 之前的历史行——model 列存的是内部处理模型。owner 读边界会把 + // 它连同 provider 一起脱敏,客户端拿不到权威值,只能回写一个按来源链推导出的展示值。 + fn redacted_model_resource_row() -> EditorProjectResource { + let now = Timestamp::from_micros_since_unix_epoch(1_000_000); + EditorProjectResource { + resource_id: "resource-1".to_string(), + project_id: "project-1".to_string(), + owner_user_id: "user-1".to_string(), + asset_object_id: None, + image_src: "/generated/resource-1.png".to_string(), + object_key: Some("generated/resource-1.png".to_string()), + width: 512, + height: 512, + source_type: "generated".to_string(), + prompt: None, + actual_prompt: None, + model: Some("BgFilter complex".to_string()), + provider: Some("BgFilter".to_string()), + task_id: None, + source_resource_id: None, + created_at: now, + updated_at: now, + asset_kind: None, + generation_inputs_json: None, + public_showcase_enabled: true, + } + } + + #[test] + fn structured_canvas_layer_defers_redacted_model_and_provider_to_resource_truth() { + // 中文注释:脱敏造成的差异必须以资源行为准,而不是整次保存 400。 + let resource = redacted_model_resource_row(); + let layout = json!([{ + "layerId": "layer-1", + "resourceId": "resource-1", + "sourceType": "generated", + "src": "/generated/resource-1.png", + "model": "gpt-image-2", + "provider": "VectorEngine" + }]); + let mut parsed = parse_structured_canvas_layout(layout.to_string().as_str()) + .expect("resource-backed layout should parse"); + + normalize_structured_canvas_layer_against_resource(&mut parsed.layers[0], Some(&resource)) + .expect("被读边界脱敏的字段不得阻断结构化保存"); + + assert!(!parsed.layers[0].item_json.contains("model")); + assert!(!parsed.layers[0].item_json.contains("provider")); + } + + #[test] + fn structured_canvas_layer_read_back_drops_source_type_for_resource_backed_layers() { + // 中文注释:钉住缺陷 2 的服务端根因——校验通过后 source_type 被归还资源行、图层列置空, + // 读回时整个 sourceType 键都不存在。api-server 读边界的回填正是建立在这个前提上;这里 + // 若被改成「None 也写空串」或去掉条件,客户端就会重新拿不到权威值。 + let resource = redacted_model_resource_row(); + let layout = json!([{ + "layerId": "layer-1", + "resourceId": "resource-1", + "sourceType": "generated", + "src": "/generated/resource-1.png" + }]); + let mut parsed = parse_structured_canvas_layout(layout.to_string().as_str()) + .expect("resource-backed layout should parse"); + + normalize_structured_canvas_layer_against_resource(&mut parsed.layers[0], Some(&resource)) + .expect("与资源行一致的 sourceType 必须放行"); + assert_eq!(parsed.layers[0].source_type, None); + + let serialized = serialize_structured_canvas_layout(&parsed) + .expect("structured layout should serialize"); + assert!(!serialized.contains("sourceType")); + } + + #[test] + fn structured_canvas_layer_still_rejects_conflicting_source_type() { + // 中文注释:`model` / `provider` 改成以资源行为准,是因为读边界脱敏了它们、判等不可满足。 + // `sourceType` 不同——读边界会回填权威值,所以它仍然是硬约束,不得被顺手一起放宽。 + let resource = redacted_model_resource_row(); + let layout = json!([{ + "layerId": "layer-1", + "resourceId": "resource-1", + "sourceType": "uploaded", + "src": "/generated/resource-1.png" + }]); + let mut parsed = parse_structured_canvas_layout(layout.to_string().as_str()) + .expect("resource-backed layout should parse"); + + let error = normalize_structured_canvas_layer_against_resource( + &mut parsed.layers[0], + Some(&resource), + ) + .expect_err("与资源行冲突的 sourceType 必须失败关闭"); + assert!(error.contains("sourceType")); + } + #[test] fn structured_canvas_existing_local_sequence_only_allows_typed_layout_changes() { let layout = self_contained_local_sequence_layout(); @@ -9909,18 +11316,20 @@ mod tests { json!({ "customExtension": true }) ); - let mut conflicting = json!({ "provider": "legacy-provider" }) + // 中文注释:改用 taskId 举例。model / provider 已改为以资源行为准(读边界会脱敏它们, + // 客户端拿不到权威值,判等对正常路径不可满足),仍然判等的是这一类未脱敏字段。 + let mut conflicting = json!({ "taskId": "legacy-task" }) .as_object() .expect("metadata object") .clone(); let error = take_matching_optional_resource_string( &mut conflicting, - "provider", - Some("vector-engine"), + "taskId", + Some("task-1"), "layer-1", ) - .expect_err("conflicting provider must fail closed"); - assert!(error.contains("provider")); + .expect_err("conflicting taskId must fail closed"); + assert!(error.contains("taskId")); let mut generation_inputs = Some(r#"{ "style": "clay", "seed": 7 }"#.to_string()); merge_optional_resource_json_metadata( diff --git a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx index e3ce9e4da..e0e53759d 100644 --- a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx @@ -7,8 +7,8 @@ import { type SetStateAction, } from 'react'; -import { CUSTOM_EDITOR_SCENE_STYLE_PRESET } from '../../../packages/shared/src/contracts/editorScene'; import { AutoGrowTextArea } from '../common/AutoGrowTextArea'; +import { CUSTOM_EDITOR_SCENE_STYLE_PRESET } from '../../../packages/shared/src/contracts/editorScene'; import { PlatformFloatingMenu, PlatformFloatingMenuItem, @@ -62,6 +62,7 @@ type ImageCanvasBasicGenerationComposerViewProps = { onPickReferenceFromCanvas?: () => void; onToggleReferenceMenu?: () => void; onRememberImageModel?: (model: string) => void; + hasPendingImageReferenceUploads?: boolean; onSubmit: (dialog: GenerateDialogState) => void; dialogLabel?: string; includeDimensions?: boolean; @@ -116,6 +117,7 @@ export function ImageCanvasBasicGenerationComposerView({ onPickReferenceFromCanvas, onToggleReferenceMenu, onRememberImageModel = () => {}, + hasPendingImageReferenceUploads = false, onSubmit, dialogLabel, includeDimensions = true, @@ -345,6 +347,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/ImageCanvasEditorGenerationIntegration.test.tsx b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx index 1145c58a8..79ac270a8 100644 --- a/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx @@ -40,6 +40,7 @@ const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn()); const renameEditorProjectMock = vi.hoisted(() => vi.fn()); const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn()); const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn()); +const uploadEditorMediaAssetObjectFileMock = vi.hoisted(() => vi.fn()); vi.mock('../../services/image-editor/editorProjectClient', async () => { const actual = await vi.importActual< @@ -74,7 +75,10 @@ vi.mock('./ImageCanvasUiAssetExtractionRasterModel', () => ({ vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({ uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock, - uploadEditorMediaAssetObjectFile: vi.fn(), + // 中文注释:inline 源图上传走的是 object-only 版本,不是带签名 URL 的那个。留成裸 vi.fn() + // 会返回 undefined,取 objectKey 抛的 TypeError 被 extractUiDesignAssets 的 catch 吞成 + // window.alert,最终只表现为「提取接口一次都没调」,报错里看不到任何线索。 + uploadEditorMediaAssetObjectFile: uploadEditorMediaAssetObjectFileMock, })); vi.mock('./ImageCanvasProjectCoverSnapshotRenderer', () => ({ @@ -113,6 +117,17 @@ describe('ImageCanvasEditorView generation integration', () => { objectKey: 'generated-character-drafts/editor/generation-references/marked-ui-design.png', assetObjectId: 'asset-object-marked-ui-design', + legacyPublicPath: + '/generated-character-drafts/editor/generation-references/marked-ui-design.png', + src: 'https://oss.example.com/marked-ui-design.png', + }); + uploadEditorMediaAssetObjectFileMock.mockReset(); + uploadEditorMediaAssetObjectFileMock.mockResolvedValue({ + objectKey: + 'generated-character-drafts/editor/generation-references/marked-ui-design.png', + assetObjectId: 'asset-object-marked-ui-design', + legacyPublicPath: + '/generated-character-drafts/editor/generation-references/marked-ui-design.png', }); }); @@ -3387,7 +3402,11 @@ describe('ImageCanvasEditorView generation integration', () => { 'https://assets.test/generated-character-drafts/editor/frame1.png', ); }); - expect(screen.getByText('1/48')).toBeTruthy(); + // 中文注释:分子不能钉死。序列帧图层默认自动播放(frames.length > 1 即 isPlaying), + // 48 帧 6 秒算出 125ms 的真实 setInterval,而这里用的是真实定时器下的 waitFor—— + // 上一条 waitFor 在 CI 上多轮询一次就已经越过 125ms,计数器变成 2/48。要断言的是 + // 「按序列帧播放器渲染、总帧数 48」(下一行排除视频元素),分母才是这条断言的意义。 + expect(screen.getByText(/^\d+\/48$/u)).toBeTruthy(); expect(screen.queryByLabelText('画布视频:角色动作')).toBeNull(); expect(screen.getByText('动作')).toBeTruthy(); diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index e30679a6e..4c09762c0 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -1,17 +1,26 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import type { + EditorProjectLayerSnapshot, + EditorProjectSnapshot, +} from '../../services/image-editor/editorProjectClient'; import { CANVAS_WORLD_ORIGIN, canvasDisplayScaleToViewportScale, canvasDisplayViewportToViewport, + collectExpiredInlineGenerationDialogIds, createLayerFromAsset, DEFAULT_CANVAS_BACKGROUND_COLOR, + dropDeadInlineGenerationPlaceholders, formatCanvasDisplayScalePercent, hydrateCanvasGenerationDialog, hydrateLayer, + INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS, normalizeAssetLibrary, normalizeCanvasBackgroundHex, + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS, resolveLayerResourceAssetKind, + resolveNextInlineGenerationDialogExpiryAt, resolveSnappedLayerPosition, serializeCanvasLayout, serializeLayer, @@ -23,7 +32,53 @@ import type { CanvasGenerationDialogState, CanvasLayer, EditorAsset, + PerfectPixelOperationSnapshot, } from './ImageCanvasEditorTypes'; +import { PERFECT_PIXEL_SOURCE_PREPARATION_BUDGET_MS } from './useImageCanvasGenerationWorkflow'; + +function buildPerfectPixelOperation( + dialogId: string, +): PerfectPixelOperationSnapshot { + return { + version: 1, + kind: 'perfect-pixel', + operationId: dialogId, + taskId: `pixel-art-snap-${dialogId}`, + request: { + sourceImageSrc: 'ref:project-resource:resource-source', + projectId: 'project-1', + sourceResourceId: 'resource-source', + assetKind: 'character', + generationInputs: { + fields: [{ title: '提示词', value: '像素角色' }], + references: [ + { + title: '源图', + label: '角色原图', + refType: 'project-resource', + refId: 'resource-source', + }, + ], + }, + assetFolderId: 'project', + assetLabel: '角色 · 完美像素', + canvasCompletion: { + dialogId, + title: '角色 · 完美像素', + placeholder: { + x: 100, + y: 120, + width: 320, + height: 320, + originalWidth: 640, + originalHeight: 640, + }, + }, + }, + submittedAt: 1_700_000_000_000, + reconcileUntil: 1_700_000_075_000, + }; +} describe('ImageCanvasEditorModel', () => { it('keeps the resource default kind separate from a layer override', () => { @@ -314,6 +369,7 @@ describe('ImageCanvasEditorModel', () => { originalHeight: 768, zIndex: 9, sourceType: 'generated', + resourcePersistenceState: 'registered', objectKey: 'generated/object.png', model: 'birefnet', sourceResourceId: 'resource-provider-source', @@ -332,6 +388,10 @@ describe('ImageCanvasEditorModel', () => { expect(snapshot).not.toHaveProperty('assetKind'); expect(snapshot.assetKindOverride).toBeNull(); expect(snapshot).not.toHaveProperty('generationInputs'); + // 有资源行时,服务端读边界会脱敏内部处理模型并省略 provider,客户端拿到的 model 是按来源 + // 链推导出的展示值;回写它会与资源行原值冲突并让整次结构化保存 400。 + expect(snapshot).not.toHaveProperty('model'); + expect(snapshot).not.toHaveProperty('provider'); const hydrated = hydrateLayer( snapshot, @@ -373,6 +433,77 @@ describe('ImageCanvasEditorModel', () => { expect(hydrated?.generationInputs?.fields[0]?.value).toBe('骑士'); }); + it('keeps model metadata on self-contained local sequences that have no resource row', () => { + // 中文注释:角色动画逐帧层用 local- 资源 id、image-sequence、无 objectKey,服务端 + // normalize 走 resource == None 早退分支,只摘 assetKind 就把 item 原样写回——item_json + // 是这类图层元数据的唯一存储。停发 model 会让它在下一次保存后永久丢失。 + const layer: CanvasLayer = { + id: 'layer-character-animation', + resourceId: 'local-resource-character-animation-1', + title: '角色动作', + src: '/generated/sequence/frame01.png', + x: 0, + y: 0, + width: 320, + height: 240, + originalWidth: 320, + originalHeight: 240, + zIndex: 1, + sourceType: 'generated', + mediaType: 'image-sequence', + imageSequenceFrames: [ + { + frameIndex: 1, + imageSrc: '/generated/sequence/frame01.png', + width: 320, + height: 240, + }, + ], + model: 'seedance2.0-fast', + provider: 'ark', + }; + + const snapshot = serializeLayer(layer); + expect(snapshot.model).toBe('seedance2.0-fast'); + expect(snapshot.provider).toBe('ark'); + + const hydrated = hydrateLayer(snapshot, new Map()); + expect(hydrated?.model).toBe('seedance2.0-fast'); + expect(serializeLayer(hydrated!).model).toBe('seedance2.0-fast'); + }); + + it('keeps the resource sourceType across a structured layout round trip', () => { + const layer: CanvasLayer = { + id: 'layer-generated', + resourceId: 'resource-generated', + title: '生成图', + src: '/read/generated.png', + x: 0, + y: 0, + width: 512, + height: 512, + originalWidth: 512, + originalHeight: 512, + zIndex: 1, + sourceType: 'generated', + }; + const resources = new Map([ + [ + 'resource-generated', + { imageSrc: '/read/generated.png', sourceType: 'generated' }, + ], + ]); + + // 结构化保存会把校验通过的 sourceType 归还资源行并把图层列置空,读回的布局项没有这个键。 + const { sourceType: _omitted, ...storedSnapshot } = serializeLayer(layer); + expect(storedSnapshot).not.toHaveProperty('sourceType'); + + const hydrated = hydrateLayer(storedSnapshot, resources); + expect(hydrated?.sourceType).toBe('generated'); + // 再次保存必须仍然是 generated,否则服务端会判成「sourceType 与项目资源不一致」。 + expect(serializeLayer(hydrated!).sourceType).toBe('generated'); + }); + it('distinguishes persisted self-contained sequences from unresolved local resources', () => { const validSequence = { layerId: 'layer-sequence', @@ -881,6 +1012,407 @@ describe('ImageCanvasEditorModel', () => { }); }); + it('keeps the operation ledger out of the layout and restores it from the local ledger', () => { + const dialogId = 'dialog-perfect-pixel-round-trip'; + const operation = buildPerfectPixelOperation(dialogId); + const hydrated = hydrateCanvasGenerationDialog({ + id: dialogId, + mode: 'quick-edit', + prompt: '完美像素', + status: 'pending-confirmation', + composerOpen: false, + requiresLiveSession: true, + perfectPixelOperation: operation, + }); + + expect(hydrated).toMatchObject({ + id: dialogId, + status: 'pending-confirmation', + perfectPixelOperationId: dialogId, + perfectPixelOperation: operation, + }); + expect(hydrated).not.toHaveProperty('perfectPixelOperationInvalid'); + + const layout = serializeCanvasLayout({ + layers: [], + canvasGenerationDialogs: [hydrated as CanvasGenerationDialogState], + }); + const serializedLayout = JSON.stringify(layout); + expect(serializedLayout).toContain('"perfectPixelOperationId"'); + expect(serializedLayout).not.toContain('"perfectPixelOperation"'); + expect(serializedLayout).not.toContain(operation.request.sourceImageSrc); + + const { generationDialogs } = splitCanvasLayoutItems( + layout, + new Map(), + undefined, + new Map([[dialogId, operation]]), + ); + + expect(generationDialogs).toHaveLength(1); + expect(generationDialogs[0]?.perfectPixelOperation).toEqual(operation); + expect(generationDialogs[0]).not.toHaveProperty( + 'perfectPixelOperationInvalid', + ); + }); + + it('keeps a settled perfect-pixel placeholder valid without any local ledger', () => { + // 中文注释:服务端完成 completion 后只做字段级改写,perfectPixelOperationId 会永久留在 + // 布局里;而账本在收口那一刻就被清掉了。这个组合是每一次**成功**完美像素的必然形状, + // 绝不能被判成失败。 + const dialogId = 'dialog-perfect-pixel-settled'; + const settledSnapshot = { + id: dialogId, + mode: 'quick-edit' as const, + prompt: '完美像素', + status: 'idle' as const, + composerOpen: false, + generatedLayerId: `layer-${dialogId}`, + perfectPixelOperationId: dialogId, + }; + + const hydrated = hydrateCanvasGenerationDialog(settledSnapshot); + + expect(hydrated).toMatchObject({ + id: dialogId, + status: 'idle', + generatedLayerId: `layer-${dialogId}`, + }); + expect(hydrated).not.toHaveProperty('perfectPixelOperationInvalid'); + expect(hydrated).not.toHaveProperty('perfectPixelOperationId'); + expect(hydrated?.errorMessage).toBeUndefined(); + + // 中文注释:标记的寿命必须与账本对齐——收口后不再写回布局,否则错误形状会一直堆积。 + const layout = serializeCanvasLayout({ + layers: [], + canvasGenerationDialogs: [hydrated as CanvasGenerationDialogState], + }); + expect(JSON.stringify(layout)).not.toContain('"perfectPixelOperationId"'); + }); + + it('heals a settled placeholder that a previous build wrote back as invalid', () => { + // 中文注释:上一版判据会把成功结果写成 failed + perfectPixelOperationInvalid 并落库。 + // 这类已经被写脏的行必须在下一次 hydrate 时自愈,否则错误状态会自我固化。 + const dialogId = 'dialog-perfect-pixel-poisoned'; + const hydrated = hydrateCanvasGenerationDialog({ + id: dialogId, + mode: 'quick-edit', + prompt: '完美像素', + status: 'failed', + composerOpen: false, + generatedLayerId: `layer-${dialogId}`, + perfectPixelOperationId: dialogId, + perfectPixelOperationInvalid: true, + errorMessage: '完美像素操作快照无效,禁止自动重试。', + }); + + expect(hydrated).toMatchObject({ id: dialogId, status: 'idle' }); + expect(hydrated).not.toHaveProperty('perfectPixelOperationInvalid'); + expect(hydrated?.errorMessage).toBeUndefined(); + }); + + it('drops the inline legacy ledger of an already settled placeholder without marking it', () => { + const dialogId = 'dialog-perfect-pixel-settled-legacy'; + const layout = serializeCanvasLayout({ + layers: [], + canvasGenerationDialogs: [ + { + id: dialogId, + mode: 'quick-edit', + prompt: '完美像素', + status: 'idle', + composerOpen: false, + generatedLayerId: `layer-${dialogId}`, + perfectPixelOperation: buildPerfectPixelOperation(dialogId), + } as CanvasGenerationDialogState, + ], + }); + const serializedLayout = JSON.stringify(layout); + expect(serializedLayout).not.toContain('"perfectPixelOperation"'); + expect(serializedLayout).not.toContain('"perfectPixelOperationId"'); + + const { generationDialogs } = splitCanvasLayoutItems(layout); + + expect(generationDialogs[0]).toMatchObject({ + id: dialogId, + status: 'idle', + }); + expect(generationDialogs[0]).not.toHaveProperty( + 'perfectPixelOperationInvalid', + ); + }); + + it('settles a perfect-pixel placeholder as deletable failure when the local ledger is absent', () => { + const dialogId = 'dialog-perfect-pixel-other-device'; + const operation = buildPerfectPixelOperation(dialogId); + const hydrated = hydrateCanvasGenerationDialog({ + id: dialogId, + mode: 'quick-edit', + prompt: '完美像素', + status: 'generating', + composerOpen: false, + perfectPixelOperation: operation, + }); + const layout = serializeCanvasLayout({ + layers: [], + canvasGenerationDialogs: [hydrated as CanvasGenerationDialogState], + }); + + // 中文注释:换设备 / 清缓存 / 隐私模式——布局里标记还在,本机账本读不到。这是明确 + // 设计:占位收口成可删除的失败态,绝不停在无从收口的处理中态。 + const { generationDialogs } = splitCanvasLayoutItems(layout); + + expect(generationDialogs).toHaveLength(1); + expect(generationDialogs[0]).toMatchObject({ + id: dialogId, + status: 'failed', + perfectPixelOperationInvalid: true, + }); + expect(generationDialogs[0]).not.toHaveProperty('perfectPixelOperation'); + }); + + it('ignores a local ledger entry whose id does not match the placeholder', () => { + const dialogId = 'dialog-perfect-pixel-ledger-mismatch'; + const operation = buildPerfectPixelOperation('dialog-perfect-pixel-other'); + const { generationDialogs } = splitCanvasLayoutItems( + [ + { + itemType: 'generation-dialog', + layerId: `generation-dialog:${dialogId}`, + resourceId: `generation-dialog:${dialogId}`, + dialog: { + id: dialogId, + mode: 'quick-edit', + prompt: '完美像素', + status: 'generating', + perfectPixelOperationId: dialogId, + }, + } as unknown as EditorProjectLayerSnapshot, + ], + new Map(), + undefined, + new Map([['dialog-perfect-pixel-other', operation]]), + ); + + expect(generationDialogs).toHaveLength(1); + expect(generationDialogs[0]).toMatchObject({ + id: dialogId, + status: 'failed', + perfectPixelOperationInvalid: true, + }); + expect(generationDialogs[0]).not.toHaveProperty('perfectPixelOperation'); + }); + + it('preserves legacy 240-second operation identity while clamping its deadline on round-trip', () => { + vi.useFakeTimers(); + const now = 1_700_000_010_000; + vi.setSystemTime(now); + try { + const dialogId = 'dialog-perfect-pixel-legacy-window'; + const operation = buildPerfectPixelOperation(dialogId); + const legacyOperation = { + ...operation, + reconcileUntil: operation.submittedAt + 240_000, + }; + const hydrated = hydrateCanvasGenerationDialog({ + id: dialogId, + mode: 'quick-edit', + prompt: '完美像素', + status: 'pending-confirmation', + composerOpen: false, + perfectPixelOperation: legacyOperation, + }); + const expectedOperation = { + ...legacyOperation, + reconcileUntil: + operation.submittedAt + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS, + }; + + expect(hydrated).toMatchObject({ + id: dialogId, + status: 'pending-confirmation', + perfectPixelOperation: expectedOperation, + }); + expect(hydrated).not.toHaveProperty('perfectPixelOperationInvalid'); + + const { generationDialogs } = splitCanvasLayoutItems( + serializeCanvasLayout({ + layers: [], + canvasGenerationDialogs: [hydrated as CanvasGenerationDialogState], + }), + new Map(), + undefined, + new Map([[dialogId, expectedOperation]]), + ); + + expect(generationDialogs).toHaveLength(1); + expect(generationDialogs[0]).toMatchObject({ + id: dialogId, + status: 'pending-confirmation', + perfectPixelOperation: expectedOperation, + }); + expect(generationDialogs[0]).not.toHaveProperty( + 'perfectPixelOperationInvalid', + ); + } finally { + vi.useRealTimers(); + } + }); + + it('fails closed instead of replaying an invalid perfect-pixel operation snapshot', () => { + const dialogId = 'dialog-perfect-pixel-invalid'; + const operation = buildPerfectPixelOperation(dialogId); + const invalidOperations: unknown[] = [ + { ...operation, version: 2 }, + { ...operation, operationId: 'another-dialog' }, + { ...operation, taskId: 'pixel-art-snap-another-dialog' }, + { + ...operation, + request: { ...operation.request, projectId: ' ' }, + }, + { + ...operation, + request: { + ...operation.request, + sourceImageSrc: 'data:image/png;base64,unsafe', + }, + }, + { + ...operation, + request: { + ...operation.request, + sourceImageSrc: + 'https://oss.example.test/source.png?Expires=1700000000&Signature=temporary', + }, + }, + { + ...operation, + request: { + ...operation.request, + canvasCompletion: { + ...operation.request.canvasCompletion, + dialogId: 'another-dialog', + }, + }, + }, + { + ...operation, + reconcileUntil: operation.submittedAt + 240_000 + 1, + }, + { ...operation, unknownFutureField: true }, + ]; + + for (const invalidOperation of invalidOperations) { + const hydrated = hydrateCanvasGenerationDialog({ + id: dialogId, + mode: 'quick-edit', + prompt: '完美像素', + status: 'pending-confirmation', + perfectPixelOperation: invalidOperation, + }); + + expect(hydrated).toMatchObject({ + id: dialogId, + status: 'failed', + perfectPixelOperationInvalid: true, + errorMessage: '完美像素操作快照无效,禁止自动重试。', + }); + expect(hydrated).not.toHaveProperty('perfectPixelOperation'); + } + }); + + it('preserves a legacy clock-skewed operation while capping its current observation window', () => { + vi.useFakeTimers(); + const now = 1_700_000_000_000; + vi.setSystemTime(now); + try { + const dialogId = 'dialog-perfect-pixel-clock-skew'; + const operation = buildPerfectPixelOperation(dialogId); + const submittedAt = now + 180_000; + const expectedOperation = { + ...operation, + submittedAt: now, + reconcileUntil: now + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS, + }; + const hydrated = hydrateCanvasGenerationDialog({ + id: dialogId, + mode: 'quick-edit', + prompt: '完美像素', + status: 'pending-confirmation', + perfectPixelOperation: { + ...operation, + submittedAt, + reconcileUntil: submittedAt + 240_000, + }, + }); + + expect(hydrated?.perfectPixelOperation).toEqual(expectedOperation); + expect(hydrated).not.toHaveProperty('perfectPixelOperationInvalid'); + + const { generationDialogs } = splitCanvasLayoutItems( + serializeCanvasLayout({ + layers: [], + canvasGenerationDialogs: [hydrated as CanvasGenerationDialogState], + }), + new Map(), + undefined, + new Map([[dialogId, expectedOperation]]), + ); + + expect(generationDialogs).toHaveLength(1); + expect(generationDialogs[0]?.perfectPixelOperation).toEqual( + expectedOperation, + ); + expect(generationDialogs[0]).not.toHaveProperty( + 'perfectPixelOperationInvalid', + ); + } finally { + vi.useRealTimers(); + } + }); + + it('round-trips the blocked marker after clearing an invalid operation snapshot', () => { + const dialogId = 'dialog-perfect-pixel-blocked'; + const operation = buildPerfectPixelOperation(dialogId); + const failedClosed = hydrateCanvasGenerationDialog({ + id: dialogId, + mode: 'quick-edit', + prompt: '完美像素', + status: 'generating', + perfectPixelOperation: { ...operation, taskId: 'wrong-task' }, + }); + const { generationDialogs } = splitCanvasLayoutItems( + serializeCanvasLayout({ + layers: [], + canvasGenerationDialogs: [failedClosed as CanvasGenerationDialogState], + }), + ); + + expect(generationDialogs[0]).toMatchObject({ + id: dialogId, + status: 'failed', + perfectPixelOperationInvalid: true, + errorMessage: '完美像素操作快照无效,禁止自动重试。', + }); + expect(generationDialogs[0]).not.toHaveProperty('perfectPixelOperation'); + }); + + it('fails closed when pending-confirmation has no operation journal', () => { + const hydrated = hydrateCanvasGenerationDialog({ + id: 'dialog-perfect-pixel-orphaned-pending', + mode: 'quick-edit', + prompt: '完美像素', + status: 'pending-confirmation', + }); + + expect(hydrated).toMatchObject({ + status: 'failed', + perfectPixelOperationInvalid: true, + errorMessage: '完美像素操作快照无效,禁止自动重试。', + }); + expect(hydrated).not.toHaveProperty('perfectPixelOperation'); + }); + it('defaults restored supported image styles to none and drops them from other modes', () => { expect( hydrateCanvasGenerationDialog({ @@ -1118,4 +1650,378 @@ describe('ImageCanvasEditorModel', () => { horizontal: 160, }); }); + + it('keeps the legacy placeholder window above source preparation', () => { + // 中文注释:占位一旦挂上 perfectPixelOperationId 就退出 legacy requiresLiveSession 清理, + // 所以这个窗口只需覆盖标记写入之前的那一段——源图解析/直传。POST 与对账由账本自己保护。 + expect(INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS).toBeGreaterThan( + PERFECT_PIXEL_SOURCE_PREPARATION_BUDGET_MS, + ); + expect( + INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS - + PERFECT_PIXEL_SOURCE_PREPARATION_BUDGET_MS, + ).toBeGreaterThanOrEqual(90_000); + }); + + describe('inline placeholder expiry helpers', () => { + const dialog = ( + overrides: Partial, + ): CanvasGenerationDialogState => + ({ + id: 'dialog-1', + mode: 'quick-edit', + prompt: '完美像素', + status: 'generating', + ...overrides, + }) as CanvasGenerationDialogState; + + it('reports an inline placeholder as expired only after the window elapses', () => { + // 中文注释:与 dropDeadInlineGenerationPlaceholders 是同一条规则的两个作用面,边界 + // 必须一致——正好到期不算过期,超过一毫秒才算。 + const now = 1_700_000_000_000; + const live = [ + dialog({ + requiresLiveSession: true, + generationStartedAt: now - 240_000, + }), + ]; + const stale = [ + dialog({ + requiresLiveSession: true, + generationStartedAt: now - 240_001, + }), + ]; + + expect(collectExpiredInlineGenerationDialogIds(live, now)).toEqual([]); + expect(collectExpiredInlineGenerationDialogIds(stale, now)).toEqual([ + 'dialog-1', + ]); + }); + + it('never expires queue-backed or settled placeholders', () => { + // 中文注释:队列型占位的 job 在服务端继续跑,误清会让用户以为没发生而重复提交; + // 已终态的占位也不该被自动清掉,那是用户要自己处置的失败卡片。 + const now = 1_700_000_000_000; + const dialogs = [ + dialog({ id: 'queued', generationStartedAt: now - 999_999 }), + dialog({ + id: 'settled', + requiresLiveSession: true, + status: 'failed', + generationStartedAt: now - 999_999, + }), + ]; + + expect(collectExpiredInlineGenerationDialogIds(dialogs, now)).toEqual([]); + expect(resolveNextInlineGenerationDialogExpiryAt(dialogs)).toBeNull(); + }); + + it('never expires a generating placeholder backed by a perfect-pixel operation snapshot', () => { + const operationDialogId = 'operation-backed'; + const operationBackedDialog = dialog({ + id: operationDialogId, + requiresLiveSession: true, + generationStartedAt: 1, + perfectPixelOperation: buildPerfectPixelOperation(operationDialogId), + }); + + expect( + collectExpiredInlineGenerationDialogIds( + [operationBackedDialog], + 1_700_000_000_000, + ), + ).toEqual([]); + expect( + resolveNextInlineGenerationDialogExpiryAt([operationBackedDialog]), + ).toBeNull(); + }); + + it('expires a live-session placeholder that carries no usable timestamp', () => { + // 中文注释:兜底方向与快照侧一致——按保留会让这类占位永久转下去。 + expect( + collectExpiredInlineGenerationDialogIds( + [dialog({ requiresLiveSession: true })], + 1_700_000_000_000, + ), + ).toEqual(['dialog-1']); + }); + + it('resolves the earliest expiry so callers can arm a single timer', () => { + // 中文注释:到期时刻可以精确算出来,调用方据此挂一次性定时器而不是轮询。 + const now = 1_700_000_000_000; + const dialogs = [ + dialog({ + id: 'later', + requiresLiveSession: true, + generationStartedAt: now - 10_000, + }), + dialog({ + id: 'sooner', + requiresLiveSession: true, + generationStartedAt: now - 60_000, + }), + ]; + + expect(resolveNextInlineGenerationDialogExpiryAt(dialogs)).toBe( + now - 60_000 + 240_000, + ); + }); + }); + + describe('dropDeadInlineGenerationPlaceholders', () => { + const buildProject = ( + layers: EditorProjectLayerSnapshot[], + ): EditorProjectSnapshot => ({ + projectId: 'project-1', + title: '画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers, + resources: [], + updatedAt: '2026-08-03T00:00:00.000Z', + }); + + const withCanvasMirror = ( + project: EditorProjectSnapshot, + layers: EditorProjectLayerSnapshot[], + ): EditorProjectSnapshot => ({ + ...project, + canvas: { + canvasId: 'canvas-1', + projectId: project.projectId, + title: project.title, + viewport: project.viewport, + layers, + updatedAt: project.updatedAt, + }, + }); + + const buildDialogItem = ( + id: string, + dialog: Record, + ): EditorProjectLayerSnapshot => + ({ + itemType: 'generation-dialog', + layerId: `generation-dialog:${id}`, + resourceId: `generation-dialog:${id}`, + dialog: { id, mode: 'quick-edit', prompt: '完美像素', ...dialog }, + }) as unknown as EditorProjectLayerSnapshot; + + it('drops generating placeholders whose only owner was a dead session', () => { + const project = buildProject([ + buildDialogItem('dead', { + status: 'generating', + requiresLiveSession: true, + }), + ]); + + const result = dropDeadInlineGenerationPlaceholders(project); + + expect(result.droppedCount).toBe(1); + expect(result.project.layers).toEqual([]); + }); + + it('keeps a live-session placeholder that is still inside the live window', () => { + // 中文注释:这是多标签页的核心场景。B 标签打开同一项目时会 hydrate 到 A 标签正在用的 + // 活占位——原先的结构性判据(「从服务端读回来的必然属于已死会话」)在这里是假的,会被 + // 当成孤儿剥离,再由 B 下一次布局保存以当前 revision 合法写回,把 A 的占位删掉。 + const now = 1_700_000_000_000; + const project = buildProject([ + buildDialogItem('live', { + status: 'generating', + requiresLiveSession: true, + generationStartedAt: now - 30_000, + }), + ]); + + const result = dropDeadInlineGenerationPlaceholders(project, now); + + expect(result.droppedCount).toBe(0); + expect(result.project).toBe(project); + }); + + it('keeps a live-session placeholder right up to the window boundary', () => { + // 中文注释:窗口是「服务端最坏 90 秒 + 客户端 120 秒上限 + 余量」推出来的,边界必须是 + // 包含式:正好 180 秒时操作仍可能刚刚收口,不能剥。 + const now = 1_700_000_000_000; + const project = buildProject([ + buildDialogItem('boundary', { + status: 'generating', + requiresLiveSession: true, + generationStartedAt: now - 240_000, + }), + ]); + + expect( + dropDeadInlineGenerationPlaceholders(project, now).droppedCount, + ).toBe(0); + }); + + it('drops a live-session placeholder once the window has elapsed', () => { + // 中文注释:超过窗口意味着客户端早已 abort 并把占位改成 failed 或移除,服务端也越过了 + // 90 秒硬上限——此时还停在 generating 就确定是孤儿。 + const now = 1_700_000_000_000; + const project = buildProject([ + buildDialogItem('stale', { + status: 'generating', + requiresLiveSession: true, + generationStartedAt: now - 240_001, + }), + ]); + + const result = dropDeadInlineGenerationPlaceholders(project, now); + + expect(result.droppedCount).toBe(1); + expect(result.project.layers).toEqual([]); + }); + + it('drops a dead legacy placeholder from both layout mirrors exactly once', () => { + const now = 1_700_000_000_000; + const stale = buildDialogItem('stale-mirrored', { + status: 'generating', + requiresLiveSession: true, + generationStartedAt: now - 240_001, + }); + const project = withCanvasMirror(buildProject([stale]), [stale]); + + const result = dropDeadInlineGenerationPlaceholders(project, now); + + expect(result.droppedCount).toBe(1); + expect(result.project.layers).toEqual([]); + expect(result.project.canvas?.layers).toEqual([]); + }); + + it('drops a live-session placeholder that carries no usable timestamp', () => { + // 中文注释:兜底方向必须是剥离。按「保留」会让这类占位永久留在画布上;按「剥离」最坏 + // 只是退回引入时间窗之前的行为。 + const now = 1_700_000_000_000; + for (const generationStartedAt of [ + undefined, + Number.NaN, + 'not-a-number', + ]) { + const project = buildProject([ + buildDialogItem('no-timestamp', { + status: 'generating', + requiresLiveSession: true, + ...(generationStartedAt === undefined + ? {} + : { generationStartedAt }), + }), + ]); + + expect( + dropDeadInlineGenerationPlaceholders(project, now).droppedCount, + ).toBe(1); + } + }); + + it('keeps generating placeholders backed by a durable job', () => { + // 中文注释:去除背景恒队列、图片生成默认队列,它们的 job 在服务端继续跑,worker 会 + // 替换占位。刷新后必须原样恢复,误清会让用户以为操作没发生而重复提交。 + const project = buildProject([ + buildDialogItem('queued', { status: 'generating' }), + buildDialogItem('queued-explicit-false', { + status: 'generating', + requiresLiveSession: false, + }), + ]); + + const result = dropDeadInlineGenerationPlaceholders(project); + + expect(result.droppedCount).toBe(0); + expect(result.project).toBe(project); + }); + + it('keeps generating and pending placeholders backed by a perfect-pixel operation snapshot', () => { + const now = 1_700_000_000_000; + for (const status of ['generating', 'pending-confirmation'] as const) { + const dialogId = `operation-${status}`; + const operationBacked = buildDialogItem(dialogId, { + status, + requiresLiveSession: true, + generationStartedAt: now - 999_999, + perfectPixelOperation: buildPerfectPixelOperation(dialogId), + }); + const project = withCanvasMirror(buildProject([operationBacked]), [ + operationBacked, + ]); + + const result = dropDeadInlineGenerationPlaceholders(project, now); + + expect(result.droppedCount).toBe(0); + expect(result.project).toBe(project); + } + }); + + it('preserves an invalid operation journal long enough for hydration to fail closed', () => { + const now = 1_700_000_000_000; + const invalidJournal = buildDialogItem('invalid-operation', { + status: 'generating', + requiresLiveSession: true, + generationStartedAt: now - 999_999, + perfectPixelOperation: { + ...buildPerfectPixelOperation('invalid-operation'), + taskId: 'wrong-task', + }, + }); + const project = buildProject([invalidJournal]); + + const result = dropDeadInlineGenerationPlaceholders(project, now); + const { generationDialogs } = splitCanvasLayoutItems( + result.project.layers, + ); + + expect(result.droppedCount).toBe(0); + expect(generationDialogs).toHaveLength(1); + expect(generationDialogs[0]).toMatchObject({ + id: 'invalid-operation', + status: 'failed', + perfectPixelOperationInvalid: true, + errorMessage: '完美像素操作快照无效,禁止自动重试。', + }); + expect(generationDialogs[0]).not.toHaveProperty('perfectPixelOperation'); + }); + + it('keeps settled inline placeholders and unrelated layout items', () => { + const settled = buildDialogItem('settled', { + status: 'failed', + requiresLiveSession: true, + }); + const imageLayer = { + itemType: 'layer', + layerId: 'layer-1', + resourceId: 'resource-1', + } as unknown as EditorProjectLayerSnapshot; + const project = buildProject([settled, imageLayer]); + + const result = dropDeadInlineGenerationPlaceholders(project); + + expect(result.droppedCount).toBe(0); + expect(result.project.layers).toEqual([settled, imageLayer]); + }); + + it('round-trips the marker through hydration so reloads can still detect it', () => { + // 中文注释:hydrate 是白名单式的,漏掉这个字段会让标记在一次「加载→保存」后消失, + // 孤儿占位重新变得不可识别。 + const hydrated = hydrateCanvasGenerationDialog({ + id: 'dialog-1', + mode: 'quick-edit', + prompt: '完美像素', + status: 'generating', + requiresLiveSession: true, + }); + + expect(hydrated?.requiresLiveSession).toBe(true); + + const [item] = serializeCanvasLayout({ + layers: [], + canvasGenerationDialogs: [hydrated as CanvasGenerationDialogState], + }); + + expect( + (item as unknown as { dialog: { requiresLiveSession?: boolean } }) + .dialog.requiresLiveSession, + ).toBe(true); + }); + }); }); diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index e521d5c58..116c5c7da 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -1,8 +1,11 @@ import { isEditorSceneStylePreset } from '../../../packages/shared/src/contracts/editorScene'; import type { + EditorAssetGenerationInputs, EditorAssetLibrarySnapshot, EditorCharacterAnimationGenerationResult, + EditorPixelArtSnapInput, EditorProjectLayerSnapshot, + EditorProjectSnapshot, } from '../../services/image-editor/editorProjectClient'; import type { CanvasAssetKind, @@ -16,6 +19,7 @@ import type { CharacterReferenceImage, EditorAsset, EditorAssetFolder, + PerfectPixelOperationSnapshot, SnapCandidate, } from './ImageCanvasEditorTypes'; @@ -278,6 +282,26 @@ function serializeImageSequenceFrames( return serializedFrames.length ? serializedFrames : undefined; } +/** + * 中文注释:`model` / `provider` 只有在图层挂着项目资源行时才交给资源行权威持有。 + * + * 有资源行:服务端读边界会脱敏内部处理模型(`model`)并无条件省略 `provider`,客户端拿到的 + * `layer.model` 是脱敏后按来源链推导出的展示值,回写必然与资源行原值冲突,被结构化保存判为 + * 「与项目资源不一致」而整次 400。保存时服务端本就会剥离这两个字段,也不参与画布布局哈希, + * 因此直接不发。 + * + * 没有资源行(自包含的 legacy 本地图片序列,例如角色动画逐帧层):服务端 + * `normalize_structured_canvas_layer_against_resource` 走的是 `resource == None` 早退分支, + * 只摘掉 `assetKind` 就把 item 原样写回,item_json 是这些元数据的**唯一**存储。此时停发会让 + * 模型信息在下一次保存后永久丢失,图片信息、ZIP 导出与快速编辑默认模型一起静默退化。 + */ +function serializeResourceOwnedModelFields(layer: CanvasLayer) { + if (layer.resourcePersistenceState === 'registered') { + return {}; + } + return { model: layer.model, provider: layer.provider }; +} + export function serializeLayer(layer: CanvasLayer): EditorProjectLayerSnapshot { return { layerId: layer.id, @@ -305,8 +329,7 @@ export function serializeLayer(layer: CanvasLayer): EditorProjectLayerSnapshot { ), prompt: layer.prompt, actualPrompt: layer.actualPrompt, - model: layer.model, - provider: layer.provider, + ...serializeResourceOwnedModelFields(layer), taskId: layer.taskId, objectKey: layer.objectKey, assetObjectId: layer.assetObjectId, @@ -335,6 +358,291 @@ type CanvasSettingsLayoutSnapshot = EditorProjectLayerSnapshot & { export type CanvasLayoutItems = EditorProjectLayerSnapshot[]; const CANVAS_SETTINGS_LAYOUT_ITEM_ID = 'canvas-settings:default'; +const PERFECT_PIXEL_OPERATION_TASK_ID_PREFIX = 'pixel-art-snap-'; +// 中文注释:从稳定请求快照写入开始,提交与项目事实对账共用 75 秒绝对窗口。截止时间随 +// durable operation 持久化并在 hydrate 后继续沿用;读取侧还会把跨设备时钟偏差限制在 +// “从当前最多再观察一个窗口”。POST 回包与素材刷新都不能替同一次 operation 续期。 +export const PERFECT_PIXEL_RECONCILIATION_WINDOW_MS = 75_000; +// 中文注释:第一批曾把 v1 快照写成 240 秒。滚动部署与旧标签页仍可能持久化该形状, +// 所以读取侧保留兼容上限;它只决定快照是否可信,不会延长当前 75 秒对账窗口。 +const LEGACY_PERFECT_PIXEL_RECONCILIATION_WINDOW_MS = 240_000; +const INVALID_PERFECT_PIXEL_OPERATION_ERROR_MESSAGE = + '完美像素操作快照无效,禁止自动重试。'; + +const PERFECT_PIXEL_OPERATION_KEYS = new Set([ + 'version', + 'kind', + 'operationId', + 'taskId', + 'request', + 'submittedAt', + 'reconcileUntil', +]); +const PERFECT_PIXEL_REQUEST_KEYS = new Set([ + 'sourceImageSrc', + 'projectId', + 'sourceResourceId', + 'assetKind', + 'generationInputs', + 'assetFolderId', + 'assetLabel', + 'canvasCompletion', +]); +const PERFECT_PIXEL_COMPLETION_KEYS = new Set([ + 'dialogId', + 'title', + 'placeholder', +]); +const PERFECT_PIXEL_PLACEHOLDER_KEYS = new Set([ + 'x', + 'y', + 'width', + 'height', + 'originalWidth', + 'originalHeight', +]); +const PERFECT_PIXEL_GENERATION_INPUTS_KEYS = new Set(['fields', 'references']); +const PERFECT_PIXEL_GENERATION_FIELD_KEYS = new Set(['title', 'value']); +const PERFECT_PIXEL_GENERATION_REFERENCE_KEYS = new Set([ + 'title', + 'label', + 'refType', + 'refId', +]); + +function isSnapshotRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function hasOnlySnapshotKeys( + value: Record, + allowedKeys: ReadonlySet, +) { + return Object.keys(value).every((key) => allowedKeys.has(key)); +} + +function isOptionalNullableString(value: unknown) { + return value === undefined || value === null || typeof value === 'string'; +} + +function isStableEditorMediaReference(value: unknown): value is string { + if (typeof value !== 'string' || !value.trim()) { + return false; + } + const normalized = value.trimStart().toLowerCase(); + if (normalized.startsWith('data:') || normalized.startsWith('blob:')) { + return false; + } + try { + const url = new URL(value); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return true; + } + const signedQueryKeys = new Set([ + 'expires', + 'signature', + 'x-amz-signature', + 'x-amz-security-token', + 'x-oss-signature', + 'x-oss-security-token', + ]); + return [...url.searchParams.keys()].every( + (key) => !signedQueryKeys.has(key.toLowerCase()), + ); + } catch { + return true; + } +} + +function hydratePerfectPixelGenerationInputs( + value: unknown, +): EditorAssetGenerationInputs | null { + if ( + !isSnapshotRecord(value) || + !hasOnlySnapshotKeys(value, PERFECT_PIXEL_GENERATION_INPUTS_KEYS) || + !Array.isArray(value.fields) || + !Array.isArray(value.references) + ) { + return null; + } + const fields = value.fields.flatMap((field) => { + if ( + !isSnapshotRecord(field) || + !hasOnlySnapshotKeys(field, PERFECT_PIXEL_GENERATION_FIELD_KEYS) || + typeof field.title !== 'string' || + typeof field.value !== 'string' + ) { + return []; + } + return [{ title: field.title, value: field.value }]; + }); + const references: EditorAssetGenerationInputs['references'] = + value.references.flatMap((reference) => { + if ( + !isSnapshotRecord(reference) || + !hasOnlySnapshotKeys( + reference, + PERFECT_PIXEL_GENERATION_REFERENCE_KEYS, + ) || + typeof reference.title !== 'string' || + typeof reference.label !== 'string' || + (reference.refType !== 'project-resource' && + reference.refType !== 'asset') || + typeof reference.refId !== 'string' + ) { + return []; + } + return [ + { + title: reference.title, + label: reference.label, + refType: reference.refType as 'project-resource' | 'asset', + refId: reference.refId, + }, + ]; + }); + if ( + fields.length !== value.fields.length || + references.length !== value.references.length + ) { + return null; + } + return { fields, references }; +} + +/** + * 中文注释:完美像素没有 durable job,恢复与人工重试只能依赖这份精确请求快照。 + * 因此这里按 v1 白名单重建,并交叉校验 dialog / operation / task / completion 身份; + * 任何未知版本、字段漂移或不稳定媒体引用都失败关闭,绝不能据当前画布状态猜测并重放 POST。 + */ +export function hydratePerfectPixelOperation( + value: unknown, + dialogId: string, +): PerfectPixelOperationSnapshot | null { + const now = Date.now(); + if ( + !isSnapshotRecord(value) || + !hasOnlySnapshotKeys(value, PERFECT_PIXEL_OPERATION_KEYS) || + value.version !== 1 || + value.kind !== 'perfect-pixel' || + value.operationId !== dialogId || + value.taskId !== `${PERFECT_PIXEL_OPERATION_TASK_ID_PREFIX}${dialogId}` || + typeof value.submittedAt !== 'number' || + !Number.isFinite(value.submittedAt) || + value.submittedAt <= 0 || + typeof value.reconcileUntil !== 'number' || + !Number.isFinite(value.reconcileUntil) || + value.reconcileUntil < value.submittedAt || + value.reconcileUntil - value.submittedAt > + LEGACY_PERFECT_PIXEL_RECONCILIATION_WINDOW_MS || + value.submittedAt > + now + LEGACY_PERFECT_PIXEL_RECONCILIATION_WINDOW_MS + ) { + return null; + } + const normalizedSubmittedAt = Math.min(value.submittedAt, now); + const request = value.request; + if ( + !isSnapshotRecord(request) || + !hasOnlySnapshotKeys(request, PERFECT_PIXEL_REQUEST_KEYS) || + !isStableEditorMediaReference(request.sourceImageSrc) || + typeof request.projectId !== 'string' || + !request.projectId.trim() || + !isOptionalNullableString(request.sourceResourceId) || + !isOptionalNullableString(request.assetKind) || + !isOptionalNullableString(request.assetFolderId) || + !isOptionalNullableString(request.assetLabel) + ) { + return null; + } + const completion = request.canvasCompletion; + if ( + !isSnapshotRecord(completion) || + !hasOnlySnapshotKeys(completion, PERFECT_PIXEL_COMPLETION_KEYS) || + completion.dialogId !== dialogId || + typeof completion.title !== 'string' + ) { + return null; + } + const placeholder = completion.placeholder; + if ( + !isSnapshotRecord(placeholder) || + !hasOnlySnapshotKeys(placeholder, PERFECT_PIXEL_PLACEHOLDER_KEYS) || + ![...PERFECT_PIXEL_PLACEHOLDER_KEYS].every( + (key) => + typeof placeholder[key] === 'number' && + Number.isFinite(placeholder[key]), + ) + ) { + return null; + } + let generationInputs: EditorAssetGenerationInputs | null | undefined; + if (request.generationInputs === undefined) { + generationInputs = undefined; + } else if (request.generationInputs === null) { + generationInputs = null; + } else { + generationInputs = hydratePerfectPixelGenerationInputs( + request.generationInputs, + ); + } + if ( + request.generationInputs !== undefined && + request.generationInputs !== null && + !generationInputs + ) { + return null; + } + + const hydratedRequest: EditorPixelArtSnapInput = { + sourceImageSrc: request.sourceImageSrc, + projectId: request.projectId, + ...(request.sourceResourceId !== undefined + ? { + sourceResourceId: request.sourceResourceId as string | null, + } + : {}), + ...(request.assetKind !== undefined + ? { assetKind: request.assetKind as string | null } + : {}), + ...(generationInputs !== undefined ? { generationInputs } : {}), + ...(request.assetFolderId !== undefined + ? { assetFolderId: request.assetFolderId as string | null } + : {}), + ...(request.assetLabel !== undefined + ? { assetLabel: request.assetLabel as string | null } + : {}), + canvasCompletion: { + dialogId, + title: completion.title, + placeholder: { + x: placeholder.x as number, + y: placeholder.y as number, + width: placeholder.width as number, + height: placeholder.height as number, + originalWidth: placeholder.originalWidth as number, + originalHeight: placeholder.originalHeight as number, + }, + }, + }; + + return { + version: 1, + kind: 'perfect-pixel', + operationId: dialogId, + taskId: `${PERFECT_PIXEL_OPERATION_TASK_ID_PREFIX}${dialogId}`, + request: hydratedRequest, + // 中文注释:把未来时间规范到当前时刻,确保收紧后的快照再次序列化、hydrate 时仍合法; + // 过去时间保持不变,不能借刷新给 operation 续期。兼容读入的旧 240 秒快照同样只保留 + // 当前 75 秒绝对窗口。 + submittedAt: normalizedSubmittedAt, + reconcileUntil: Math.min( + value.reconcileUntil, + normalizedSubmittedAt + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS, + now + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS, + ), + }; +} function isPersistedReferenceResourceId(resourceId: string | null | undefined) { const normalizedResourceId = resourceId?.trim(); @@ -417,11 +725,28 @@ function serializeGenerationReferences( }); } +/** + * 中文注释:布局里只写 `perfectPixelOperationId` 标记,不写请求账本本身。 + * + * 账本记的是「本机发出过哪一次 POST」,属于对账凭据而非画布内容,改由 + * `perfectPixelOperationStore` 存在本机(见那里的长注释)。布局仍需要一个标记,否则 + * 换设备打开时无法把这类占位与队列型占位区分开,只能当成普通占位一直转下去。 + * + * 标记的寿命必须与账本对齐:收口后账本会被清掉,标记也就不该再留在布局里。否则每一次成功 + * 的完美像素都会在布局里留下一个「有标记、没账本」的占位,被 hydrate 判成无效。 + */ function serializeDialogReferences( dialog: CanvasGenerationDialogState, ): CanvasGenerationDialogState { + const { perfectPixelOperation, ...persistedDialog } = dialog; + const perfectPixelOperationId = isSettledPerfectPixelDialogRecord( + dialog as unknown as Record, + ) + ? undefined + : (dialog.perfectPixelOperationId ?? perfectPixelOperation?.operationId); return { - ...dialog, + ...persistedDialog, + ...(perfectPixelOperationId ? { perfectPixelOperationId } : {}), specReference: serializeGenerationReference(dialog.specReference), generationReferences: serializeGenerationReferences( dialog.generationReferences, @@ -506,10 +831,263 @@ function isCanvasSettingsLayoutItem( return item.itemType === 'canvas-settings'; } +/** + * 中文注释:从快照里取出指定 id 的全部生成占位原始记录,取不到返回空数组。 + * + * 用原始 record 而不是 hydrate:调用方要判的是服务端写了什么,hydrate 会给缺失字段补默认值 + * (例如 status 缺失时补 `idle`),把「服务端没写」和「服务端写了 idle」混成一种。 + */ +export function findCanvasGenerationDialogRecords( + project: EditorProjectSnapshot, + dialogId: string | null | undefined, +): Record[] { + const normalizedDialogId = dialogId?.trim(); + if (!normalizedDialogId) { + return []; + } + const matchingDialogs: Record[] = []; + for (const item of project.layers) { + if (item.itemType !== 'generation-dialog') { + continue; + } + const dialog = + (item as { dialog?: unknown }).dialog && + typeof (item as { dialog?: unknown }).dialog === 'object' + ? ((item as { dialog?: unknown }).dialog as Record) + : null; + if (dialog?.id === normalizedDialogId) { + matchingDialogs.push(dialog); + } + } + return matchingDialogs; +} + +/** + * 中文注释:占位是否仍未收口。这是本仓库对「这个生成完成了没有」的既有定义,原先只存在于 + * `useImageCanvasGenerationSubmissionWorkflow` 的队列轮询里,现在提取共用。 + * + * 判据必须是 status / generatedLayerId,**不能**是「占位还在不在」。服务端成功回填时会保留 + * 该 dialog 并就地改写(`editor_project.rs` 的 `apply_editor_canvas_generation_items`:置 + * `status: "idle"`、`composerOpen: false`、写入 `generatedLayerId`、清掉 `errorMessage`), + * 该行为另有服务端测试钉住。按「在不在」判会把真成功判成失败。 + */ +export function isUnresolvedCanvasGenerationDialogRecord( + dialog: Record | null, +): boolean { + if (!dialog) { + return false; + } + return ( + dialog.status === 'generating' || + dialog.status === 'pending-confirmation' || + typeof dialog.generatedLayerId !== 'string' || + dialog.generatedLayerId.trim() === '' + ); +} + +/** + * 中文注释:完美像素占位是否已经收口,即结果图层已被服务端回填进画布。 + * + * **本机账本只在占位未收口期间存在**:发 POST 前写入,拿到终态就清除。而布局里的 + * `perfectPixelOperationId` 标记寿命无限——服务端完成时只做字段级改写(置 `status: "idle"`、 + * 写入 `generatedLayerId`),从不摘掉这个标记。两者寿命不对称,所以任何「有标记、没账本 + * ⇒ 无效」的判据都必须先排除收口态,否则每一次**成功**的完美像素都会在下一次 hydrate 时 + * 被判成 `failed + perfectPixelOperationInvalid`,并把这个错误状态写回服务端。 + */ +function isSettledPerfectPixelDialogRecord( + dialog: Record | null, +): boolean { + return Boolean(dialog) && !isUnresolvedCanvasGenerationDialogRecord(dialog); +} + +/** + * 中文注释:从占位创建起算的存活窗口。超过它还停在 `generating` 的 inline 占位,确定是孤儿。 + * + * 上界由两侧共同封死:服务端最坏合法时长是处理预算 30 秒加持久化预算 60 秒(都由 + * `timeout_at` 强制),客户端整个 POST 又被 `snapEditorImageToPixelArt` 的 120 秒超时封顶。 + * 客户端整条链的上界是提交前置预算 90 秒加 POST 120 秒 = 210 秒,之后必已 abort 并把占位改成 + * `failed` 或直接移除,服务端也早已越过自己的 90 秒。取 240 秒 = 210 秒客户端上界 + 30 秒余量 + * (网络往返、标签页被挂起后的时钟漂移)。 + * + * 本会话自己的占位另有归属登记豁免,不依赖这个窗口;窗口只用于跨标签页——B 标签不得清掉 + * A 标签仍在合法执行的占位,所以它必须大于 A 的客户端上界。 + */ +export const INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS = 240_000; + +function inlineGenerationPlaceholderExpiryAt( + dialog: CanvasGenerationDialogState, +): number | null { + if ( + dialog.perfectPixelOperation || + dialog.requiresLiveSession !== true || + dialog.status !== 'generating' + ) { + return null; + } + const startedAt = dialog.generationStartedAt; + // 中文注释:缺时间戳按「立即到期」处理,与 dropDeadInlineGenerationPlaceholders 的兜底 + // 方向一致——按保留会让这类占位永久转下去。 + if (typeof startedAt !== 'number' || !Number.isFinite(startedAt)) { + return Number.NEGATIVE_INFINITY; + } + return startedAt + INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS; +} + +/** + * 中文注释:内存态里已经越过存活窗口的 inline 占位 id。 + * + * 与 `dropDeadInlineGenerationPlaceholders` 是同一条规则的两个作用面:那个跑在**快照**上、 + * 只在项目加载时执行一次;这个跑在**内存 dialog** 上,供页面打开期间的到期清理使用。 + * 少了后者,加载时因未到期而被保留的孤儿占位就再没有任何东西会重新判定,只能转到用户 + * 下一次加载——那正是引入存活窗口带来的回归。 + */ +export function collectExpiredInlineGenerationDialogIds( + dialogs: readonly CanvasGenerationDialogState[], + now: number = Date.now(), +): string[] { + return dialogs + .filter((dialog) => { + const expiryAt = inlineGenerationPlaceholderExpiryAt(dialog); + return expiryAt !== null && now > expiryAt; + }) + .map((dialog) => dialog.id); +} + +/** + * 中文注释:下一个 inline 占位到期的绝对时刻,没有则返回 null。调用方据此挂一次性定时器, + * 而不是轮询——到期时刻是可以精确算出来的。 + */ +export function resolveNextInlineGenerationDialogExpiryAt( + dialogs: readonly CanvasGenerationDialogState[], +): number | null { + let earliest: number | null = null; + for (const dialog of dialogs) { + const expiryAt = inlineGenerationPlaceholderExpiryAt(dialog); + if (expiryAt === null || !Number.isFinite(expiryAt)) { + continue; + } + if (earliest === null || expiryAt < earliest) { + earliest = expiryAt; + } + } + return earliest; +} + +/** + * 中文注释:剥离「只能由已死会话收口」的 generating 占位。 + * + * 原先的判据是一条结构性不变量——活着的那份始终在内存里、永远不经过 hydrate,所以从服务端 + * 读回来的必然属于已死会话。**这条在多标签页下是假的**:B 标签打开同一项目时,会 hydrate 到 + * A 标签正在用的活占位,据此剥离并在自己下一次布局保存里把它写没。CAS 挡不住——B 是以当前 + * revision 写入一份合法布局。(反方向倒是被 CAS 挡住的:A 完成后 B 再写,B 的 revision 已陈旧。) + * + * 旧 inline 占位改为有界时间窗:只有超过 `INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS` + * 才判定为孤儿。带 `perfectPixelOperation` 请求账本的新占位不再走这条 legacy 清理,而由 + * GET-only 对账收口;即便账本损坏也要留给 hydrate 失败关闭,不能在这里静默删卡。 + * + * 时钟取自读取方的 `Date.now()`。同机多标签共享时钟,正是要修的场景,判定精确;跨设备有偏移 + * 风险,但此前是无条件剥离,任何时间窗都不会比原行为更差。 + * + * 只能用在项目首次加载。会话内 applyQueuedEditorGenerationProject 会重新 GET 项目并套用, + * 那时候占位对应的操作正在进行,套用本函数会把自己的活占位清掉。 + */ +function canvasGenerationDialogRecord( + item: EditorProjectLayerSnapshot, +): Record | null { + if (item.itemType !== 'generation-dialog') { + return null; + } + const dialog = (item as { dialog?: unknown }).dialog; + return isSnapshotRecord(dialog) ? dialog : null; +} + +function canvasGenerationDialogMirrorKey(item: EditorProjectLayerSnapshot) { + const dialogId = stringOrNull(canvasGenerationDialogRecord(item)?.id); + return dialogId ? `dialog:${dialogId}` : `layer:${item.layerId}`; +} + +function isDeadLegacyInlineGenerationPlaceholder( + item: EditorProjectLayerSnapshot, + now: number, +) { + const dialog = canvasGenerationDialogRecord(item); + if (dialog?.requiresLiveSession !== true || dialog.status !== 'generating') { + return false; + } + const startedAt = dialog.generationStartedAt; + // 中文注释:缺时间戳按「可剥离」处理。实践中不会出现——`requiresLiveSession` 与 + // `generationStartedAt` 在同一次创建里一起写——但 legacy 兜底仍需避免永久孤儿。 + return ( + typeof startedAt !== 'number' || + !Number.isFinite(startedAt) || + now - startedAt > INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS + ); +} + +export function dropDeadInlineGenerationPlaceholders( + project: EditorProjectSnapshot, + now: number = Date.now(), +): { project: EditorProjectSnapshot; droppedCount: number } { + const mirroredLayers = [...project.layers, ...(project.canvas?.layers ?? [])]; + const operationBackedDialogKeys = new Set( + mirroredLayers.flatMap((item) => { + const dialog = canvasGenerationDialogRecord(item); + // 中文注释:两种形状都算 operation-backed——`perfectPixelOperationId` 是账本移出 + // 布局后的新标记,`perfectPixelOperation` 是内联账本的 legacy 形状。 + return dialog && + (Object.prototype.hasOwnProperty.call( + dialog, + 'perfectPixelOperationId', + ) || + Object.prototype.hasOwnProperty.call(dialog, 'perfectPixelOperation')) + ? [canvasGenerationDialogMirrorKey(item)] + : []; + }), + ); + const droppedDialogKeys = new Set( + mirroredLayers.flatMap((item) => { + const key = canvasGenerationDialogMirrorKey(item); + return !operationBackedDialogKeys.has(key) && + isDeadLegacyInlineGenerationPlaceholder(item, now) + ? [key] + : []; + }), + ); + const droppedCount = droppedDialogKeys.size; + if (droppedCount === 0) { + return { project, droppedCount: 0 }; + } + const dropFromMirror = (layers: EditorProjectLayerSnapshot[]) => + layers.filter( + (item) => + item.itemType !== 'generation-dialog' || + !droppedDialogKeys.has(canvasGenerationDialogMirrorKey(item)), + ); + return { + project: { + ...project, + layers: dropFromMirror(project.layers), + ...(project.canvas + ? { + canvas: { + ...project.canvas, + layers: dropFromMirror(project.canvas.layers), + }, + } + : {}), + }, + droppedCount, + }; +} + export function splitCanvasLayoutItems( items: EditorProjectLayerSnapshot[], resourcesById: Map = new Map(), currentUserId?: string | null, + localPerfectPixelOperations?: ReadonlyMap< + string, + PerfectPixelOperationSnapshot + >, ): { layerItems: EditorProjectLayerSnapshot[]; generationDialogs: CanvasGenerationDialogState[]; @@ -535,6 +1113,7 @@ export function splitCanvasLayoutItems( item.dialog, resourcesById, currentUserId, + localPerfectPixelOperations, ); if (dialog) { generationDialogs.push(dialog); @@ -551,6 +1130,10 @@ export function hydrateCanvasGenerationDialog( value: unknown, resourcesById: Map = new Map(), currentUserId?: string | null, + localPerfectPixelOperations?: ReadonlyMap< + string, + PerfectPixelOperationSnapshot + >, ): CanvasGenerationDialogState | null { if (!value || typeof value !== 'object') { return null; @@ -561,6 +1144,49 @@ export function hydrateCanvasGenerationDialog( if (!id || !isCanvasGenerationDialogMode(snapshot.mode)) { return null; } + // 中文注释:布局内联快照是 legacy 形状——账本改存本机之前写下的占位仍在库里, + // 必须继续认,否则滚动部署会把所有在途操作一次性判死。新写入只有 id 标记。 + const hasLegacyInlinePerfectPixelOperation = + Object.prototype.hasOwnProperty.call(snapshot, 'perfectPixelOperation'); + const legacyInlinePerfectPixelOperation = hasLegacyInlinePerfectPixelOperation + ? hydratePerfectPixelOperation(snapshot.perfectPixelOperation, id) + : null; + const declaredPerfectPixelOperationId = Object.prototype.hasOwnProperty.call( + snapshot, + 'perfectPixelOperationId', + ); + const isPerfectPixelPlaceholder = + hasLegacyInlinePerfectPixelOperation || declaredPerfectPixelOperationId; + const perfectPixelOperation = + legacyInlinePerfectPixelOperation ?? + // 中文注释:只认 id 与占位一致的账本。id 漂移一律当账本不可用失败关闭,绝不按 + // 当前画布状态猜一条请求出来重放。 + (declaredPerfectPixelOperationId && + stringOrNull(snapshot.perfectPixelOperationId) === id + ? (localPerfectPixelOperations?.get(id) ?? null) + : null); + const hasPersistedInvalidPerfectPixelOperationMarker = + Object.prototype.hasOwnProperty.call( + snapshot, + 'perfectPixelOperationInvalid', + ); + // 中文注释:收口态占位不需要账本——服务端已经把结果图层回填进画布,`generatedLayerId` + // 就是证据。账本在收口那一刻已被主动清除,标记却永远留在布局里(服务端不摘),所以下面 + // 这些判据必须先排除收口态,否则每一次成功都会被判成失败,并把错误状态写回服务端。 + // 已经被写脏的历史行也在这里一并纠正:收口态无条件忽略已落库的无效标记。 + const isSettledPerfectPixelPlaceholder = + isPerfectPixelPlaceholder && + isSettledPerfectPixelDialogRecord(snapshot as Record); + // 中文注释:标记在、账本不在且尚未收口,正是「换设备 / 清缓存 / 隐私模式」这条明确设计的 + // 路径。收口为可删除的失败占位即可,不得阻断用户删除或从源图重做。 + const hasInvalidPerfectPixelOperation = + !isSettledPerfectPixelPlaceholder && + ((isPerfectPixelPlaceholder && !perfectPixelOperation) || + hasPersistedInvalidPerfectPixelOperationMarker || + (snapshot.status === 'pending-confirmation' && !perfectPixelOperation)); + const trustedPerfectPixelOperation = hasInvalidPerfectPixelOperation + ? undefined + : perfectPixelOperation; const style = snapshot.mode === 'generate' || snapshot.mode === 'character' || @@ -574,7 +1200,28 @@ export function hydrateCanvasGenerationDialog( id, mode: snapshot.mode, prompt, - status: isGenerationStatus(snapshot.status) ? snapshot.status : 'idle', + status: hasInvalidPerfectPixelOperation + ? 'failed' + : // 中文注释:带 generatedLayerId 的完美像素占位按定义已被服务端回填,状态只能是 + // `idle`。这里强制归位,顺带修复被上一版判据写脏成 `failed` 的历史行。 + isSettledPerfectPixelPlaceholder + ? 'idle' + : isGenerationStatus(snapshot.status) + ? snapshot.status + : 'idle', + // 中文注释:只认布尔 true。缺字段的历史占位一律视为未置位,按队列型处理原样恢复, + // 不会被 dropDeadInlineGenerationPlaceholders 误清。 + requiresLiveSession: + snapshot.requiresLiveSession === true ? true : undefined, + ...(isPerfectPixelPlaceholder && !isSettledPerfectPixelPlaceholder + ? { perfectPixelOperationId: id } + : {}), + ...(trustedPerfectPixelOperation + ? { perfectPixelOperation: trustedPerfectPixelOperation } + : {}), + ...(hasInvalidPerfectPixelOperation + ? { perfectPixelOperationInvalid: true } + : {}), composerOpen: typeof snapshot.composerOpen === 'boolean' ? snapshot.composerOpen : true, sourceLayerId: stringOrUndefined(snapshot.sourceLayerId), @@ -699,7 +1346,14 @@ export function hydrateCanvasGenerationDialog( audioDurationSeconds: audioDurationOrNull(snapshot.audioDurationSeconds), aspectRatio: stringOrUndefined(snapshot.aspectRatio), imageSize: stringOrUndefined(snapshot.imageSize), - errorMessage: stringOrUndefined(snapshot.errorMessage), + errorMessage: hasInvalidPerfectPixelOperation + ? INVALID_PERFECT_PIXEL_OPERATION_ERROR_MESSAGE + : // 中文注释:服务端回填成功时会清掉 errorMessage,所以收口态占位身上的错误文案一定 + // 是残留——上一版判据写进去的那句「快照无效」正是这样落库的。一并清掉,否则状态 + // 已经纠正回 idle,面板上却还挂着一句失败提示。 + isSettledPerfectPixelPlaceholder + ? undefined + : stringOrUndefined(snapshot.errorMessage), generationStartedAt: numberOrUndefined(snapshot.generationStartedAt), generationFinishedAt: numberOrUndefined(snapshot.generationFinishedAt), placeholder: hydrateGenerationPlaceholder(snapshot.placeholder), @@ -734,6 +1388,15 @@ export function hydrateLayer( : canvasAssetKindOrNull(snapshot.assetKind); const isSelfContainedLocalResource = !resource && isSelfContainedLegacyLocalImageSequence(snapshot); + // 中文注释:结构化保存会把校验通过的 sourceType 归还给资源行并把图层列置空,读回时布局项 + // 里根本没有这个键。此处必须回落资源值——猜 'uploaded' 会让 generated 图层在下一次保存时 + // 被判为「sourceType 与项目资源不一致」,整块画布再也存不上。 + const resourceSourceType = resource?.sourceType; + const hydratedLayerSourceType = isCanvasSourceType(snapshot.sourceType) + ? snapshot.sourceType + : isCanvasSourceType(resourceSourceType) + ? resourceSourceType + : 'uploaded'; return { id: layerId, @@ -762,9 +1425,7 @@ export function hydrateLayer( }; })(), zIndex: numberFromSnapshot(snapshot.zIndex, 1), - sourceType: isCanvasSourceType(snapshot.sourceType) - ? snapshot.sourceType - : 'uploaded', + sourceType: hydratedLayerSourceType, mediaType: resolveHydratedLayerMediaType(snapshot, imageSequenceFrames), thumbnailSrc: stringOrNull(snapshot.thumbnailSrc), imageSequenceFrames: imageSequenceFrames.length @@ -1395,7 +2056,12 @@ function isCanvasGenerationDialogMode( function isGenerationStatus( value: unknown, ): value is CanvasGenerationDialogState['status'] { - return value === 'idle' || value === 'generating' || value === 'failed'; + return ( + value === 'idle' || + value === 'generating' || + value === 'pending-confirmation' || + value === 'failed' + ); } function isSpecGenerationType( diff --git a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx index 8e65ec9af..a83bc9c28 100644 --- a/src/components/image-editor/ImageCanvasEditorShellView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorShellView.test.tsx @@ -216,6 +216,7 @@ function createStageProps(): ImageCanvasStageViewProps { onOpenRedrawPanel: vi.fn(), onOpenCropExpandPanel: vi.fn(), onRemoveBackground: vi.fn(), + onPerfectPixel: vi.fn(), onSplitIconSpritesheet: vi.fn(), onExtractUiDesignAssets: vi.fn(), onUiAssetExtractionToolChange: vi.fn(), diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index b09d81508..14c267d7c 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -7,6 +7,7 @@ import type { EditorCharacterAnimationRatio, EditorCharacterAnimationResolution, EditorImageGenerationStyle, + EditorPixelArtSnapInput, EditorVideoAspectRatio, EditorVideoModel, EditorVideoResolution, @@ -189,6 +190,16 @@ export type PublicationMaterialsWorkflowId = | 'publication-detail-gallery' | 'publication-promo-poster'; +export type PerfectPixelOperationSnapshot = { + version: 1; + kind: 'perfect-pixel'; + operationId: string; + taskId: string; + request: EditorPixelArtSnapInput; + submittedAt: number; + reconcileUntil: number; +}; + export type GenerateDialogState = { id?: string; mode: @@ -207,7 +218,7 @@ export type GenerateDialogState = { | 'scene'; prompt: string; assetLabel?: string; - status: 'idle' | 'generating' | 'failed'; + status: 'idle' | 'generating' | 'pending-confirmation' | 'failed'; composerOpen?: boolean; sourceLayerId?: string; generatedLayerId?: string; @@ -247,6 +258,17 @@ export type GenerateDialogState = { aspectRatio?: string; imageSize?: string; errorMessage?: string; + // 中文注释:标记该占位的收口只能由创建它的页面会话完成——链路是同步 HTTP、服务端没有 + // durable job,进程一死就再没有任何东西会把它推向终态。队列型占位(去除背景恒队列、 + // 图片生成默认队列)不得置位:它们的 job 在服务端继续跑,worker 会替换占位,刷新后 + // 必须原样恢复。 + requiresLiveSession?: boolean; + // 中文注释:请求账本只存在于本机(见 perfectPixelOperationStore),布局里只留这个 id + // 标记「该占位是一次完美像素操作」。换设备打开时标记还在、账本读不到,占位收口为可删除 + // 的失败态——这是明确设计,不得据此阻断用户删除或重做。 + perfectPixelOperationId?: string; + perfectPixelOperation?: PerfectPixelOperationSnapshot; + perfectPixelOperationInvalid?: boolean; generationStartedAt?: number; generationFinishedAt?: number; placeholder?: { @@ -299,6 +321,7 @@ export type CanvasHistoryActionType = | 'generate-image' | 'expand-image' | 'remove-background' + | 'perfect-pixel' | 'split-atlas' | 'replace-image' | 'show-image' @@ -373,6 +396,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 546c5a90a..e7d364a38 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'; @@ -78,7 +83,10 @@ import { getSelectedLayerIds, } from './ImageCanvasSelectionModel'; import { ImageCanvasShortcutDialogView } from './ImageCanvasShortcutDialogView'; -import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs'; +import { + requiresGenerationDeleteConfirmation, + useCanvasGenerationDialogs, +} from './useCanvasGenerationDialogs'; import { useCanvasHistory } from './useCanvasHistory'; import { useImageCanvasAssetCanvasBridge, @@ -94,11 +102,15 @@ 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, } from './useImageCanvasViewportControls'; +import { useInlineGenerationPlaceholderExpiry } from './useInlineGenerationPlaceholderExpiry'; const TASK_FOCUS_HORIZONTAL_INSET = 28; const TASK_FOCUS_TOP_INSET = 82; @@ -303,6 +315,13 @@ function createAssetActionLayer(asset: EditorAsset): CanvasLayer { }; } +// 中文注释:服务端持久化顺序是 OSS PUT → asset object → project resource → editor asset +// → 画布收口,非事务。看到孤儿 generating 占位只能说明最后一步没做完,前面几步可能已经 +// 成功。所以不能断言「什么都没发生」,只能指路让用户自己核对素材库。 +// 加载期剥离与页面打开期的到期清理共用这一条,两条路径对用户完全一致。 +const DEAD_INLINE_PLACEHOLDER_NOTICE = + '上次的完美像素处理未完成,画布占位已清理。请确认素材库是否已生成派生图。'; + export function ImageCanvasEditorView({ onProjectAccessLost, }: ImageCanvasEditorViewProps = {}) { @@ -425,6 +444,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>( () => {}, ); @@ -667,6 +689,9 @@ export function ImageCanvasEditorView({ discardCanvasHistoryEntriesContainingLayerRef.current(matchesLayer), [], ); + const canDeleteLayersRef = useRef<(targetLayerIds: string[]) => boolean>( + () => true, + ); const removeCanvasLayersLinkedToAssets = useImageCanvasAssetLayerCleanup({ layers, setLayers, @@ -674,6 +699,8 @@ export function ImageCanvasEditorView({ setSelectedLayerIds, onDeleteLayerSideEffects: (layerId) => clearDeletedLayerGenerationStateRef.current(layerId), + canDeleteLayers: (targetLayerIds) => + canDeleteLayersRef.current(targetLayerIds), discardHistoryEntriesContainingLayer: discardAssetRelatedCanvasHistory, }); const { @@ -729,6 +756,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); @@ -744,6 +779,7 @@ export function ImageCanvasEditorView({ inactiveGenerateDialogsRef, activeCanvasGenerationDialog, canvasGenerationDialogs, + getCanvasGenerationDialogsSnapshot, openCanvasGenerationDialog, updateCanvasGenerationDialogById, removeCanvasGenerationDialogById, @@ -755,6 +791,8 @@ export function ImageCanvasEditorView({ getGeneratingDialogPlaceholder, } = useCanvasGenerationDialogs({ onActivate: handleActivateCanvasGenerationDialog, + isContextMutationLocked: isPendingReferenceUploadContextMutationLocked, + onContextMutationRejected: rejectPendingReferenceUploadContextMutation, }); canvasGenerationDialogsRef.current = canvasGenerationDialogs; const canvasHistoryRefs = useMemo( @@ -1158,11 +1196,12 @@ export function ImageCanvasEditorView({ layersRef, viewportRef, canvasGenerationDialogsRef, + getCanvasGenerationDialogsSnapshot, canvasBackgroundColorRef, selectedLayerIdRef, selectedLayerIdsRef, }), - [], + [getCanvasGenerationDialogsSnapshot], ); const projectPersistenceSetters = useMemo( () => ({ @@ -1197,6 +1236,7 @@ export function ImageCanvasEditorView({ appendCanvasLayersWithResources, applyProjectSnapshot, flushProjectPersistence, + deadInlinePlaceholderDropCount, } = useImageCanvasProjectPersistence({ refs: projectPersistenceRefs, setters: projectPersistenceSetters, @@ -1229,7 +1269,9 @@ export function ImageCanvasEditorView({ ) => { captureCanvasHistory(action); applyProjectSnapshot(project); - void refreshAssetLibrary(); + if (action.type !== 'perfect-pixel') { + void refreshAssetLibrary(); + } }, [applyProjectSnapshot, captureCanvasHistory, refreshAssetLibrary], ); @@ -1334,6 +1376,7 @@ export function ImageCanvasEditorView({ const { uploadInputRef, uploadTarget, + pendingImageReferenceUploadCount, requestUpload, handleUploadInputChange, addUploadedFiles, @@ -1355,12 +1398,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, @@ -1377,11 +1425,13 @@ export function ImageCanvasEditorView({ generationReferenceButtonRef, publicationReferenceButtonRef, iconSpecButtonRef, + hasPendingImageReferenceUploads: pendingImageReferenceUploadCount > 0, generateDialog, setGenerateDialog, activeCanvasGenerationDialog, canvasGenerationDialogs, openCanvasGenerationDialog, + activateCanvasGenerationDialog, updateCanvasGenerationDialogById, hasCanvasGenerationDialogById, archiveActiveCanvasGenerationDialog, @@ -1403,12 +1453,24 @@ export function ImageCanvasEditorView({ assetFolderId: activeUploadFolderId, upsertGeneratedAsset, applyProjectSnapshot: applyGeneratedProjectSnapshot, + applyProjectSnapshotWithoutHistory: applyProjectSnapshot, + flushProjectPersistence, + refreshAssetLibrary, onWalletBalanceMayHaveChanged: refreshEditorWalletState, }); const handleEditorAgentConfirmSent = useCallback(() => { generationSurface.refreshTaskList(); }, [generationSurface]); const showGenerationWarning = generationSurface.showGenerationWarning; + useEffect(() => { + if (deadInlinePlaceholderDropCount === 0) { + return; + } + showGenerationWarning(DEAD_INLINE_PLACEHOLDER_NOTICE); + }, [deadInlinePlaceholderDropCount, showGenerationWarning]); + const handleInlinePlaceholdersExpired = useCallback(() => { + showGenerationWarning(DEAD_INLINE_PLACEHOLDER_NOTICE); + }, [showGenerationWarning]); const handleExternalGenerationTasksCompleted = useCallback( (tasks: ExternalGenerationTaskRecord[]) => { if (!projectId || tasks.length === 0) { @@ -1508,6 +1570,10 @@ export function ImageCanvasEditorView({ openRedrawPanel, openCropExpandPanel, removeSelectedLayerBackground, + snapSelectedLayerToPerfectPixels, + activeInlineGenerationDialogOwnership, + perfectPixelLayerIds, + pendingPerfectPixelLayerIds, splitSelectedIconSpritesheet, splittingIconSpritesheetLayerIds, extractUiDesignAssets, @@ -1522,6 +1588,7 @@ export function ImageCanvasEditorView({ clearDeletedLayerGenerationState, uiAssetExtractionState, uiAssetExtractionSourceLayer, + quickEditPanel, quickEditSelectionState, quickEditSelectionSourceLayer, changeUiAssetExtractionTool, @@ -1566,6 +1633,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; @@ -1673,10 +1871,68 @@ export function ImageCanvasEditorView({ }, [openLayerGenerationDialog], ); + const removeCanvasGenerationDialog = useCallback( + (dialogId: string) => { + captureCanvasHistory({ type: 'delete-generation-result', count: 1 }); + removeCanvasGenerationDialogById(dialogId); + setSelectedLayerId(null); + setSelectedLayerIds([]); + setImageContextMenu(null); + setContextMenu(null); + setActiveTool('select'); + }, + [ + captureCanvasHistory, + removeCanvasGenerationDialogById, + setActiveTool, + setContextMenu, + setImageContextMenu, + setSelectedLayerId, + setSelectedLayerIds, + ], + ); + const requestRemoveCanvasGenerationDialog = useCallback( + (dialog: CanvasGenerationDialogState) => { + if (requiresGenerationDeleteConfirmation(dialog)) { + activateCanvasGenerationDialog(dialog); + setPendingGenerationDeleteDialog(dialog); + return; + } + removeCanvasGenerationDialog(dialog.id); + }, + [activateCanvasGenerationDialog, removeCanvasGenerationDialog], + ); const contextMenuLayer = 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, @@ -1718,8 +1974,11 @@ export function ImageCanvasEditorView({ selectSingleLayer, onDeleteLayerSideEffects: clearDeletedLayerGenerationState, onDeleteGenerationDialogSideEffects: removeCanvasGenerationDialogById, + onRequestDeleteGenerationDialog: requestRemoveCanvasGenerationDialog, exportLayerImage, onCanvasLayerCopyBlocked: showCanvasLayerCopyWarning, + canDeleteLayers: (targetLayerIds) => + canDeleteLayersRef.current(targetLayerIds), }); const { canvasMarquee, @@ -1817,37 +2076,15 @@ export function ImageCanvasEditorView({ (layerId: string | null) => deleteLayerByIdRef.current(layerId), [], ); - const removeCanvasGenerationDialog = useCallback( - (dialogId: string) => { - captureCanvasHistory({ type: 'delete-generation-result', count: 1 }); - removeCanvasGenerationDialogById(dialogId); - setSelectedLayerId(null); - setSelectedLayerIds([]); - setImageContextMenu(null); - setContextMenu(null); - setActiveTool('select'); - }, - [ - captureCanvasHistory, - removeCanvasGenerationDialogById, - setActiveTool, - setContextMenu, - setImageContextMenu, - setSelectedLayerId, - setSelectedLayerIds, - ], - ); - const requestRemoveCanvasGenerationDialog = useCallback( - (dialog: CanvasGenerationDialogState) => { - if (dialog.status === 'generating') { - activateCanvasGenerationDialog(dialog); - setPendingGenerationDeleteDialog(dialog); - return; - } - removeCanvasGenerationDialog(dialog.id); - }, - [activateCanvasGenerationDialog, removeCanvasGenerationDialog], - ); + // 中文注释:加载期的剥离只跑一次,当时未到期而被保留的孤儿占位需要这里补上到期清理, + // 否则它会一直转到用户下一次加载。两条路径共用同一条文案,用户感知一致。 + useInlineGenerationPlaceholderExpiry({ + canvasGenerationDialogs, + activeInlineGenerationDialogOwnership, + removeCanvasGenerationDialogById, + onPlaceholdersExpired: handleInlinePlaceholdersExpired, + }); + const confirmRemoveGeneratingDialog = useCallback(() => { const dialog = pendingGenerationDeleteDialog; if (!dialog) { @@ -2291,8 +2528,11 @@ export function ImageCanvasEditorView({ : uiAssetExtractionSourceLayer, quickEditSelectionState, quickEditSelectionSourceLayer, + hasPendingImageReferenceUploads: pendingImageReferenceUploadCount > 0, generationComposerStyle, selectedToolbarStyle, + perfectPixelLayerIds, + pendingPerfectPixelLayerIds, splittingIconSpritesheetLayerIds, uploadDropTarget, contextMenu, @@ -2346,6 +2586,9 @@ export function ImageCanvasEditorView({ onOpenRedrawPanel: openRedrawPanel, onOpenCropExpandPanel: openCropExpandPanel, onRemoveBackground: removeSelectedLayerBackground, + onPerfectPixel: (layer: CanvasLayer) => { + void snapSelectedLayerToPerfectPixels(layer); + }, onSplitIconSpritesheet: (layer: CanvasLayer) => { void flushProjectPersistence().then(() => splitSelectedIconSpritesheet(layer), diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx index 7afcc2509..bb2100335 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx @@ -19,7 +19,6 @@ function mockStateSetter() { return vi.fn() as unknown as Dispatch>; } - function createComposerProps( generateDialog: GenerateDialogState, overrides: Partial< @@ -154,6 +153,118 @@ function renderComposer( } describe('ImageCanvasGenerationComposerView', () => { + it.each([ + { + status: 'pending-confirmation' as const, + message: '结果尚未确认,系统不会自动重复提交。', + }, + { + status: 'failed' as const, + message: '完美像素请求尚未发出,请重试同一操作。', + }, + ])('$status 的完美像素操作只展示原操作重试', ({ status, message }) => { + const dialogId = `dialog-perfect-pixel-${status}`; + const onRetryPerfectPixelOperation = vi.fn(); + const onSubmitImageGeneration = vi.fn(); + + renderComposer( + { + id: dialogId, + mode: 'generate', + prompt: '不应重新提交的普通提示词', + status, + composerOpen: true, + imageModel: 'gpt-image-2', + ...(status === 'failed' ? { errorMessage: message } : {}), + perfectPixelOperation: { + version: 1, + kind: 'perfect-pixel', + operationId: dialogId, + taskId: `pixel-art-snap-${dialogId}`, + request: { + sourceImageSrc: 'ref:project-resource:resource-source', + projectId: 'project-1', + sourceResourceId: 'resource-source', + assetKind: 'character', + assetLabel: '角色 · 完美像素', + canvasCompletion: { + dialogId, + title: '角色 · 完美像素', + placeholder: { + x: 100, + y: 120, + width: 320, + height: 320, + originalWidth: 640, + originalHeight: 640, + }, + }, + }, + submittedAt: 1_700_000_000_000, + reconcileUntil: 1_700_000_120_000, + }, + }, + { + onRetryPerfectPixelOperation, + onSubmitImageGeneration, + }, + ); + + const panel = screen.getByRole('dialog', { name: '完美像素操作' }); + expect(within(panel).getByText(message)).toBeTruthy(); + expect(within(panel).queryByRole('textbox')).toBeNull(); + expect(within(panel).queryByText('不应重新提交的普通提示词')).toBeNull(); + expect(within(panel).queryByText('gpt-image-2')).toBeNull(); + expect(within(panel).queryByRole('button', { name: '修改' })).toBeNull(); + + const retryButton = within(panel).getByRole('button', { + name: '重试同一完美像素操作', + }); + expect(within(panel).getAllByRole('button')).toEqual([retryButton]); + + fireEvent.click(retryButton); + + expect(onRetryPerfectPixelOperation).toHaveBeenCalledOnce(); + expect(onRetryPerfectPixelOperation).toHaveBeenCalledWith(dialogId); + expect(onSubmitImageGeneration).not.toHaveBeenCalled(); + }); + + it('无效完美像素标记只读展示错误,不暴露普通生成提交入口', () => { + const onRetryPerfectPixelOperation = vi.fn(); + const onSubmitImageGeneration = vi.fn(); + + renderComposer( + { + id: 'dialog-perfect-pixel-invalid', + mode: 'generate', + prompt: '不得重新生成', + status: 'failed', + composerOpen: true, + imageModel: 'gpt-image-2', + perfectPixelOperationInvalid: true, + errorMessage: '完美像素操作快照身份不匹配。', + }, + { + onRetryPerfectPixelOperation, + onSubmitImageGeneration, + }, + ); + + const panel = screen.getByRole('dialog', { name: '完美像素操作' }); + expect(within(panel).getByRole('alert').textContent).toBe( + '完美像素操作快照身份不匹配。', + ); + expect(within(panel).queryByRole('textbox')).toBeNull(); + expect(within(panel).queryByRole('button')).toBeNull(); + expect(within(panel).queryByText('不得重新生成')).toBeNull(); + expect(within(panel).queryByText('gpt-image-2')).toBeNull(); + + fireEvent.submit(panel); + + expect(onRetryPerfectPixelOperation).not.toHaveBeenCalled(); + expect(onSubmitImageGeneration).not.toHaveBeenCalled(); + }); + it('让快速编辑显示提示词、尺寸和模型选择', () => { renderComposer({ mode: 'quick-edit', @@ -177,8 +288,9 @@ describe('ImageCanvasGenerationComposerView', () => { expect(panel.className).not.toContain( 'image-canvas-editor__quick-edit-panel', ); - expect(within(panel).getByRole('textbox', { name: '快速编辑提示词' })) - .toBeTruthy(); + expect( + within(panel).getByRole('textbox', { name: '快速编辑提示词' }), + ).toBeTruthy(); expect( within(panel).queryByRole('button', { name: '添加参考图' }), ).toBeNull(); @@ -341,22 +453,24 @@ describe('ImageCanvasGenerationComposerView', () => { it('让生成UI设计图面板复用普通图片生成面板的纵向结构', () => { const setGenerateDialog = vi.fn(); - renderComposer({ - mode: 'ui-design', - prompt: '', - status: 'idle', - composerOpen: true, - uiDesignSpecReference: null, - imageModel: 'gpt-image-2', - aspectRatio: '16:9', - imageSize: '1K', - }, { - isUiDesignSpecMenuOpen: true, - setGenerateDialog: - setGenerateDialog as unknown as Dispatch< + renderComposer( + { + mode: 'ui-design', + prompt: '', + status: 'idle', + composerOpen: true, + uiDesignSpecReference: null, + imageModel: 'gpt-image-2', + aspectRatio: '16:9', + imageSize: '1K', + }, + { + isUiDesignSpecMenuOpen: true, + setGenerateDialog: setGenerateDialog as unknown as Dispatch< SetStateAction >, - }); + }, + ); const panel = screen.getByRole('dialog', { name: '生成UI设计图' }); expect( @@ -379,9 +493,12 @@ describe('ImageCanvasGenerationComposerView', () => { expect( panel.querySelector('.image-canvas-editor__generation-composer-footer'), ).toBeTruthy(); - fireEvent.change(within(panel).getByRole('textbox', { name: 'UI设计要求' }), { - target: { value: '主界面和结算弹窗' }, - }); + fireEvent.change( + within(panel).getByRole('textbox', { name: 'UI设计要求' }), + { + target: { value: '主界面和结算弹窗' }, + }, + ); expect(setGenerateDialog).toHaveBeenCalled(); const menu = screen.getByRole('menu', { name: '参考图来源' }); expect( @@ -457,47 +574,56 @@ describe('ImageCanvasGenerationComposerView', () => { ['publication-cover-image', '游戏首图', '720 x 540'], ['publication-detail-gallery', '详情五图', '720 x 1280'], ['publication-promo-poster', '运营海报', '1280 x 720'], - ] as const)('让%s 宣发素材面板对齐生成角色面板结构', (workflowId, label, sizeLabel) => { - renderComposer({ - mode: 'publication', - prompt: '', - status: 'idle', - composerOpen: true, - publicationWorkflowId: workflowId, - publicationGameInfo: { - gameName: '', - gameCategories: '', - gameDescription: '', - }, - publicationReferences: [], - imageModel: 'gpt-image-2', - aspectRatio: '16:9', - imageSize: '1K', - }); + ] as const)( + '让%s 宣发素材面板对齐生成角色面板结构', + (workflowId, label, sizeLabel) => { + renderComposer({ + mode: 'publication', + prompt: '', + status: 'idle', + composerOpen: true, + publicationWorkflowId: workflowId, + publicationGameInfo: { + gameName: '', + gameCategories: '', + gameDescription: '', + }, + publicationReferences: [], + imageModel: 'gpt-image-2', + aspectRatio: '16:9', + imageSize: '1K', + }); - const panel = screen.getByRole('dialog', { name: `${label}生成卡片` }); - expect(panel.className).toContain('image-canvas-editor__character-composer'); - expect(panel.className).toContain('image-canvas-editor__publication-composer'); - expect( - panel.firstElementChild?.className.includes( - 'image-canvas-editor__reference-strip', - ), - ).toBe(true); - expect(within(panel).getByRole('button', { name: '添加参考图' })).toBeTruthy(); - expect( - within(panel).getByRole('textbox', { name: `${label}游戏名` }), - ).toBeTruthy(); - expect( - within(panel).getByRole('textbox', { name: `${label}一句话描述游戏` }), - ).toBeTruthy(); - expect(panel.textContent).toContain(sizeLabel); - expect( - panel.querySelector('.image-canvas-editor__generation-composer-footer'), - ).toBeTruthy(); - expect(within(panel).getByRole('button', { name: '生成' }).textContent).toBe( - '生成3泥点', - ); - }); + const panel = screen.getByRole('dialog', { name: `${label}生成卡片` }); + expect(panel.className).toContain( + 'image-canvas-editor__character-composer', + ); + expect(panel.className).toContain( + 'image-canvas-editor__publication-composer', + ); + expect( + panel.firstElementChild?.className.includes( + 'image-canvas-editor__reference-strip', + ), + ).toBe(true); + expect( + within(panel).getByRole('button', { name: '添加参考图' }), + ).toBeTruthy(); + expect( + within(panel).getByRole('textbox', { name: `${label}游戏名` }), + ).toBeTruthy(); + expect( + within(panel).getByRole('textbox', { name: `${label}一句话描述游戏` }), + ).toBeTruthy(); + expect(panel.textContent).toContain(sizeLabel); + expect( + panel.querySelector('.image-canvas-editor__generation-composer-footer'), + ).toBeTruthy(); + expect( + within(panel).getByRole('button', { name: '生成' }).textContent, + ).toBe('生成3泥点'); + }, + ); it('恢复宣发素材生成卡片的字段和参考图', () => { renderComposer({ @@ -525,19 +651,25 @@ describe('ImageCanvasGenerationComposerView', () => { const panel = screen.getByRole('dialog', { name: '运营海报生成卡片' }); expect( - (within(panel).getByRole('textbox', { - name: '运营海报游戏名', - }) as HTMLInputElement).value, + ( + within(panel).getByRole('textbox', { + name: '运营海报游戏名', + }) as HTMLInputElement + ).value, ).toBe('马戏团午夜惊魂'); expect( - (within(panel).getByRole('textbox', { - name: '运营海报游戏分类', - }) as HTMLInputElement).value, + ( + within(panel).getByRole('textbox', { + name: '运营海报游戏分类', + }) as HTMLInputElement + ).value, ).toBe('非对称对抗'); expect( - (within(panel).getByRole('textbox', { - name: '运营海报一句话描述游戏', - }) as HTMLTextAreaElement).value, + ( + within(panel).getByRole('textbox', { + name: '运营海报一句话描述游戏', + }) as HTMLTextAreaElement + ).value, ).toBe('找到钥匙,开门逃离马戏团'); expect(within(panel).getByLabelText('首图参考')).toBeTruthy(); expect( @@ -688,10 +820,14 @@ describe('ImageCanvasGenerationComposerView', () => { <> >} + setGenerateDialog={ + setDialog as Dispatch> + } /> {dialog.videoModel} - {dialog.videoDurationSeconds} + + {dialog.videoDurationSeconds} + {dialog.videoResolution} {dialog.placeholder @@ -723,9 +859,9 @@ describe('ImageCanvasGenerationComposerView', () => { expect( within(panel).getByRole('button', { name: '模型 Seedance 2.0 Fast' }), ).toBeTruthy(); - expect(within(panel).getByRole('button', { name: '生成视频' }).textContent).toBe( - '生成40泥点', - ); + expect( + within(panel).getByRole('button', { name: '生成视频' }).textContent, + ).toBe('生成40泥点'); fireEvent.click( within(panel).getByRole('button', { @@ -757,15 +893,18 @@ describe('ImageCanvasGenerationComposerView', () => { .querySelector('.image-canvas-editor__ratio-wireframe') ?.getAttribute('data-ratio'), ).toBe('21:9'); - fireEvent.change(within(paramsPanel).getByRole('slider', { name: '视频时长' }), { - target: { value: '5' }, - }); - fireEvent.click(within(paramsPanel).getByRole('button', { name: '清晰度 720p' })); + fireEvent.change( + within(paramsPanel).getByRole('slider', { name: '视频时长' }), + { + target: { value: '5' }, + }, + ); + fireEvent.click( + within(paramsPanel).getByRole('button', { name: '清晰度 720p' }), + ); fireEvent.click(within(paramsPanel).getByRole('button', { name: '静音' })); - expect( - screen.getByRole('menu', { name: '视频参数选项' }), - ).toBeTruthy(); + expect(screen.getByRole('menu', { name: '视频参数选项' })).toBeTruthy(); expect(screen.getByLabelText('当前视频时长').textContent).toBe('5'); expect(screen.getByLabelText('当前视频清晰度').textContent).toBe('720p'); expect(screen.getByLabelText('当前视频占位').textContent).toBe( @@ -783,10 +922,16 @@ describe('ImageCanvasGenerationComposerView', () => { '生成100泥点', ); - fireEvent.click(screen.getByRole('button', { name: '模型 Seedance 2.0 Fast' })); + fireEvent.click( + screen.getByRole('button', { name: '模型 Seedance 2.0 Fast' }), + ); const modelPanel = screen.getByRole('menu', { name: '视频模型选项' }); - expect(within(modelPanel).queryByRole('button', { name: /Veo/i })).toBeNull(); - fireEvent.click(within(modelPanel).getByRole('button', { name: 'Kling 3.0' })); + expect( + within(modelPanel).queryByRole('button', { name: /Veo/i }), + ).toBeNull(); + fireEvent.click( + within(modelPanel).getByRole('button', { name: 'Kling 3.0' }), + ); expect(screen.getByRole('menu', { name: '视频模型选项' })).toBeTruthy(); expect(screen.getByLabelText('当前视频模型').textContent).toBe('kling3.0'); @@ -855,9 +1000,9 @@ describe('ImageCanvasGenerationComposerView', () => { expect(within(panel).queryByText('单次')).toBeNull(); expect(within(panel).queryByText('循环')).toBeNull(); expect(within(panel).queryByText(/BPM/u)).toBeNull(); - expect(within(panel).getByRole('button', { name: '生成游戏音效' }).textContent).toBe( - '生成5泥点', - ); + expect( + within(panel).getByRole('button', { name: '生成游戏音效' }).textContent, + ).toBe('生成5泥点'); fireEvent.click( within(panel).getByRole('button', { name: '音效时长 5秒' }), @@ -877,9 +1022,7 @@ describe('ImageCanvasGenerationComposerView', () => { fireEvent.change(durationSlider, { target: { value: '8' } }); expect(screen.getByLabelText('当前音效时长').textContent).toBe('8'); - expect( - screen.getByRole('button', { name: '音效时长 8秒' }), - ).toBeTruthy(); + expect(screen.getByRole('button', { name: '音效时长 8秒' })).toBeTruthy(); fireEvent.submit(panel); expect(onSubmitImageGeneration).toHaveBeenCalledWith( @@ -920,9 +1063,10 @@ describe('ImageCanvasGenerationComposerView', () => { ); expect(within(panel).getByText('Suno')).toBeTruthy(); expect(within(panel).queryByText('make_instrumental')).toBeNull(); - expect(within(panel).getByRole('button', { name: '生成游戏背景音乐' }).textContent).toBe( - '生成12泥点', - ); + expect( + within(panel).getByRole('button', { name: '生成游戏背景音乐' }) + .textContent, + ).toBe('生成12泥点'); }); it('生成规范参考图点击先弹来源菜单,不直接打开上传', () => { const onRequestUpload = vi.fn(); @@ -957,7 +1101,11 @@ describe('ImageCanvasGenerationComposerView', () => { expect(onRequestUpload).not.toHaveBeenCalled(); expect(setIsGenerationReferenceMenuOpen).toHaveBeenCalled(); const menu = screen.getByRole('menu', { name: '参考图来源' }); - expect(within(menu).getByRole('menuitem', { name: '从画布中选择' })).toBeTruthy(); - expect(within(menu).getByRole('menuitem', { name: '上传图片' })).toBeTruthy(); + expect( + within(menu).getByRole('menuitem', { name: '从画布中选择' }), + ).toBeTruthy(); + expect( + within(menu).getByRole('menuitem', { name: '上传图片' }), + ).toBeTruthy(); }); }); diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx index 81d24d530..619bdc99d 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx @@ -82,6 +82,7 @@ type ImageCanvasGenerationComposerViewProps = { isPickingCharacterReferenceFromCanvas: boolean; isPickingIconSpecFromCanvas: boolean; isPickingUiDesignSpecFromCanvas: boolean; + hasPendingImageReferenceUploads?: boolean; generateDialog: GenerateDialogState | null; generationComposerStyle: CSSProperties | null; iconComposerStyle: CSSProperties | null; @@ -109,9 +110,7 @@ type ImageCanvasGenerationComposerViewProps = { setIsUiDesignSpecMenuOpen: Dispatch>; setIsPickingGenerationReferenceFromCanvas: Dispatch>; setIsPickingQuickEditReferenceFromCanvas: Dispatch>; - setIsPickingPublicationReferenceFromCanvas: Dispatch< - SetStateAction - >; + setIsPickingPublicationReferenceFromCanvas: Dispatch>; setIsPickingCharacterSpecFromCanvas: Dispatch>; setIsPickingCharacterReferenceFromCanvas: Dispatch>; setIsPickingIconSpecFromCanvas: Dispatch>; @@ -123,6 +122,7 @@ type ImageCanvasGenerationComposerViewProps = { onSubmitQuickEdit: () => void; onSubmitCropExpand: () => void; onSubmitCharacterAnimation: () => void; + onRetryPerfectPixelOperation?: (dialogId: string) => void; onUpdateSpecFormValue: (key: keyof SpecFormValues, value: string) => void; onUpdateIconDescriptionText: (value: string) => void; onUpdateCharacterAnimationDuration: (frameCountValue: string) => void; @@ -131,6 +131,66 @@ type ImageCanvasGenerationComposerViewProps = { onSpecMenuPointerLeave?: () => void; }; +function PerfectPixelOperationPanel({ + dialog, + style, + onRetry, +}: { + dialog: GenerateDialogState; + style: CSSProperties; + onRetry?: (dialogId: string) => void; +}) { + const isPending = dialog.status === 'pending-confirmation'; + const isPreparedFailure = + dialog.status === 'failed' && Boolean(dialog.perfectPixelOperation); + const isGenerating = dialog.status === 'generating'; + const isInvalid = dialog.perfectPixelOperationInvalid === true; + const message = isInvalid + ? (dialog.errorMessage ?? '完美像素操作快照无效,禁止自动重试。') + : isPending + ? (dialog.errorMessage ?? '结果尚未确认,系统不会自动重复提交。') + : isGenerating + ? '完美像素处理中' + : dialog.status === 'failed' + ? (dialog.errorMessage ?? '完美像素处理失败') + : '完美像素处理已完成'; + return ( +
event.stopPropagation()} + > + 完美像素 + + {message} + + {!isInvalid && + (isPending || isPreparedFailure) && + dialog.id && + dialog.perfectPixelOperation ? ( + onRetry?.(dialog.id!)} + > + 重试同一完美像素操作 + + ) : null} +
+ ); +} + function buildPortalMenuStyle( anchor: HTMLElement | null, placement: 'above' | 'below', @@ -335,7 +395,8 @@ function ImageCanvasVideoGenerationComposerView({ updateVideoDialog({ videoModel: model, videoResolution: - model === VIDEO_MODEL_SEEDANCE_2_FAST && dialog.videoResolution === '1080p' + model === VIDEO_MODEL_SEEDANCE_2_FAST && + dialog.videoResolution === '1080p' ? '720p' : dialog.videoResolution, ...(isSeedanceVideoModel(model) ? {} : { generationReferences: [] }), @@ -485,7 +546,8 @@ function ImageCanvasVideoGenerationComposerView({
{EDITOR_VIDEO_RESOLUTION_OPTIONS.map((option) => { const available = - currentModel.value !== VIDEO_MODEL_SEEDANCE_2_FAST || + currentModel.value !== + VIDEO_MODEL_SEEDANCE_2_FAST || option !== '1080p'; return ( updateVideoDialog({ - videoSound: - soundMode === 'off' ? 'on' : 'off', + videoSound: soundMode === 'off' ? 'on' : 'off', }) } > @@ -749,7 +810,9 @@ function ImageCanvasAudioGenerationComposerView({ }; const updateSoundDuration = (durationSeconds: number) => { - updateAudioDialog({ soundDurationSeconds: normalizeSoundDuration(durationSeconds) }); + updateAudioDialog({ + soundDurationSeconds: normalizeSoundDuration(durationSeconds), + }); }; return ( @@ -771,9 +834,7 @@ function ImageCanvasAudioGenerationComposerView({ value={dialog.prompt} disabled={isGenerating} placeholder={ - isSoundEffect - ? '你希望生成什么音效?' - : '你希望生成什么音乐?' + isSoundEffect ? '你希望生成什么音效?' : '你希望生成什么音乐?' } className="image-canvas-editor__generation-prompt" onChange={(event) => updateAudioDialog({ prompt: event.target.value })} @@ -935,6 +996,7 @@ export function ImageCanvasGenerationComposerView({ setIsPickingCharacterReferenceFromCanvas, setIsPickingIconSpecFromCanvas, setIsPickingUiDesignSpecFromCanvas, + hasPendingImageReferenceUploads = false, onOpenSpecDialog, onRequestUpload, onSubmitImageGeneration, @@ -942,6 +1004,7 @@ export function ImageCanvasGenerationComposerView({ onSubmitQuickEdit, onSubmitCropExpand, onSubmitCharacterAnimation, + onRetryPerfectPixelOperation, onUpdateSpecFormValue, onUpdateIconDescriptionText, onUpdateCharacterAnimationDuration, @@ -949,6 +1012,10 @@ export function ImageCanvasGenerationComposerView({ onSpecMenuPointerEnter, onSpecMenuPointerLeave, }: ImageCanvasGenerationComposerViewProps) { + const isPerfectPixelDialog = Boolean( + generateDialog?.perfectPixelOperation || + generateDialog?.perfectPixelOperationInvalid, + ); return ( <> {isSpecMenuOpen @@ -976,7 +1043,19 @@ export function ImageCanvasGenerationComposerView({ ) : null} - {(generateDialog?.mode === 'generate' || + {isPerfectPixelDialog && + generateDialog && + generateDialog.composerOpen !== false && + generationComposerStyle ? ( + + ) : null} + + {!isPerfectPixelDialog && + (generateDialog?.mode === 'generate' || generateDialog?.mode === 'quick-edit' || generateDialog?.mode === 'scene') && generateDialog.composerOpen !== false && @@ -998,6 +1077,7 @@ export function ImageCanvasGenerationComposerView({ setIsGenerationReferenceMenuOpen((open) => !open) } onRememberImageModel={onRememberImageModel} + hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} onSubmit={onSubmitImageGeneration} dialogLabel={ generateDialog.mode === 'scene' ? '游戏场景生成器' : undefined @@ -1045,6 +1125,7 @@ export function ImageCanvasGenerationComposerView({ onOpenSpecDialog={onOpenSpecDialog} onUpdateSpecFormValue={onUpdateSpecFormValue} onRequestUpload={onRequestUpload} + hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} onSubmit={onSubmitImageGeneration} /> ) : null} @@ -1061,9 +1142,7 @@ export function ImageCanvasGenerationComposerView({ publicationReferenceButtonRef={publicationReferenceButtonRef} isPublicationReferenceMenuOpen={isPublicationReferenceMenuOpen} setGenerateDialog={setGenerateDialog} - setIsPublicationReferenceMenuOpen={ - setIsPublicationReferenceMenuOpen - } + setIsPublicationReferenceMenuOpen={setIsPublicationReferenceMenuOpen} setIsPickingPublicationReferenceFromCanvas={ setIsPickingPublicationReferenceFromCanvas } @@ -1071,6 +1150,7 @@ export function ImageCanvasGenerationComposerView({ buildPortalMenuStyle={buildPortalMenuStyle} onRequestUpload={onRequestUpload} onRememberImageModel={onRememberImageModel} + hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} onSubmit={onSubmitImageGeneration} /> ) : null} @@ -1094,6 +1174,7 @@ export function ImageCanvasGenerationComposerView({ onUpdateSpecFormValue={onUpdateSpecFormValue} onRequestUpload={onRequestUpload} onRememberImageModel={onRememberImageModel} + hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} onSubmit={onSubmitImageGeneration} /> ) : null} @@ -1154,6 +1235,7 @@ export function ImageCanvasGenerationComposerView({ onOpenSpecDialog={onOpenSpecDialog} onRequestUpload={onRequestUpload} onRememberImageModel={onRememberImageModel} + hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} onSubmit={onSubmitImageGeneration} /> ) : null} @@ -1175,6 +1257,7 @@ export function ImageCanvasGenerationComposerView({ onRequestUpload={onRequestUpload} onUpdateIconDescriptionText={onUpdateIconDescriptionText} onRememberImageModel={onRememberImageModel} + hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} onSubmit={onSubmitIconSpritesheetGeneration} /> ) : null} @@ -1230,14 +1313,13 @@ export function ImageCanvasGenerationComposerView({ buildPortalMenuStyle={buildPortalMenuStyle} onRequestUpload={onRequestUpload} onRememberImageModel={onRememberImageModel} + hasPendingImageReferenceUploads={hasPendingImageReferenceUploads} setQuickEditPanel={setQuickEditPanel} onSubmit={onSubmitQuickEdit} /> ) : null} - {cropExpandPanel && - cropExpandSourceLayer && - cropExpandPanelStyle ? ( + {cropExpandPanel && cropExpandSourceLayer && cropExpandPanelStyle ? ( { }); }); + 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 0c93b9f22..0a7bfd131 100644 --- a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts @@ -15,6 +15,7 @@ import type { SpecGenerationType, } from './ImageCanvasEditorTypes'; import { + appendLimitedImageReferences, appendLimitedQuickEditReferences, AUDIO_FRAME_DISPLAY_SIZE, AUDIO_FRAME_ORIGINAL_SIZE, @@ -40,6 +41,7 @@ import { normalizeEditorImageModel, PUBLICATION_FRAME_ORIGINAL_SIZE, resizeGenerationPlaceholderToImageSelection, + resolveDialogExtraImageReferenceLimit, resolveEditorImageGenerationPixelSize, resolveEditorVideoGenerationPixelSize, SPEC_FRAME_ORIGINAL_SIZE, @@ -690,10 +692,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') { @@ -1078,8 +1077,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, @@ -1497,6 +1495,7 @@ export function assignCharacterSpecReference( ? { ...resetFailedGenerationDialog(dialog), characterSpecReference: createCanvasLayerReference(layer), + characterReferences: dialog.characterReferences, composerOpen: true, } : dialog; @@ -1510,10 +1509,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; @@ -1548,10 +1548,11 @@ export function appendGenerationReference( } return { ...resetFailedGenerationDialog(dialog), - generationReferences: [ - ...(dialog.generationReferences ?? []), - createCanvasLayerReference(layer), - ], + generationReferences: appendLimitedImageReferences( + dialog.generationReferences, + [createCanvasLayerReference(layer)], + resolveDialogExtraImageReferenceLimit(dialog), + ), composerOpen: true, }; } @@ -1575,10 +1576,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; @@ -1595,6 +1597,11 @@ export function assignIconSpecReference( ? { ...resetFailedGenerationDialog(dialog), iconSpecReference: createCanvasLayerReference(layer), + generationReferences: appendLimitedImageReferences( + [], + dialog.generationReferences ?? [], + resolveDialogExtraImageReferenceLimit(dialog), + ), composerOpen: true, } : dialog; @@ -1611,6 +1618,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; @@ -122,6 +125,7 @@ export function ImageCanvasGenerationImageOptionsView({ setGenerateDialog, includeDimensions, includeModel = true, + hasPendingImageReferenceUploads = false, onRememberImageModel, optionLabelPrefix, dimensionRatioAriaLabelPrefix, @@ -194,7 +198,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; @@ -226,6 +241,7 @@ export function ImageCanvasGenerationImageOptionsView({ }; return resizeGenerationPlaceholderToImageSelection(nextDialog); }); + onRememberImageModel(model); }; const togglePanel = (panel: Exclude) => { @@ -411,7 +427,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 21bd1ae32..8ad464ef4 100644 --- a/src/components/image-editor/ImageCanvasGenerationModel.ts +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -420,6 +420,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: '循环行走动作,步伐稳定。' }, @@ -1014,6 +1087,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, @@ -1329,18 +1416,18 @@ export function isCanvasGenerationDialog( ): dialog is CanvasGenerationDialogState { return Boolean( dialog?.id && - (dialog.mode === 'generate' || - dialog.mode === 'scene' || - 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 === 'scene' || + 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/ImageCanvasHistoryModel.test.ts b/src/components/image-editor/ImageCanvasHistoryModel.test.ts index 25401a6f4..abdde1cac 100644 --- a/src/components/image-editor/ImageCanvasHistoryModel.test.ts +++ b/src/components/image-editor/ImageCanvasHistoryModel.test.ts @@ -625,6 +625,12 @@ describe('ImageCanvasHistoryModel', () => { expect(isProtectedCanvasHistoryAction({ type: 'replace-image' })).toBe( true, ); + expect(formatCanvasHistoryAction({ type: 'perfect-pixel' })).toBe( + '完美像素', + ); + expect(isProtectedCanvasHistoryAction({ type: 'perfect-pixel' })).toBe( + true, + ); expect(isProtectedCanvasHistoryAction({ type: 'move-image' })).toBe(false); }); }); diff --git a/src/components/image-editor/ImageCanvasHistoryModel.ts b/src/components/image-editor/ImageCanvasHistoryModel.ts index adce85b25..b5f8e7e31 100644 --- a/src/components/image-editor/ImageCanvasHistoryModel.ts +++ b/src/components/image-editor/ImageCanvasHistoryModel.ts @@ -22,6 +22,7 @@ const CANVAS_HISTORY_ACTION_LABELS: Record< 'generate-image': '生成图片', 'expand-image': '扩展图片', 'remove-background': '移除背景', + 'perfect-pixel': '完美像素', 'split-atlas': '拆分图集', 'replace-image': '替换图片', 'show-image': '显示图片', @@ -46,6 +47,7 @@ const PROTECTED_CANVAS_HISTORY_ACTION_TYPES = new Set< 'generate-image', 'expand-image', 'remove-background', + 'perfect-pixel', 'split-atlas', 'replace-image', ]); 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 f59103f95..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; }; @@ -78,7 +82,11 @@ function syncPanelFromDialogUpdate( model: normalizedDialog.imageModel ?? normalizedPanel.model, quickEditReferences: normalizedDialog.generationReferences, assetLabel: normalizedDialog.assetLabel, - status: normalizedDialog.status, + // 中文注释:dialog 由 createQuickEditDialog 从面板自身派生,组合器只会做 + // failed→idle 的重置,不会写入 'pending-confirmation' 这类完美像素专属状态。 + // 因此归一化后的 dialog 状态恒等于归一化后的面板状态,直接取面板侧即可, + // 也让面板的三态联合类型不必在这里做运行时收窄。 + status: normalizedPanel.status, errorMessage: normalizedDialog.errorMessage, }; } @@ -96,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 ( @@ -137,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/ImageCanvasRasterEditModel.ts b/src/components/image-editor/ImageCanvasRasterEditModel.ts index 6facd9461..c46c29343 100644 --- a/src/components/image-editor/ImageCanvasRasterEditModel.ts +++ b/src/components/image-editor/ImageCanvasRasterEditModel.ts @@ -1,7 +1,10 @@ import { type EditorBackgroundRemovalInput, type EditorBackgroundRemovalResult, + type EditorPixelArtSnapInput, + type EditorPixelArtSnapResult, removeEditorImageBackground, + snapEditorImageToPixelArt, } from '../../services/image-editor/editorProjectClient'; export type CropExpandInsets = { @@ -222,3 +225,9 @@ export async function removeImageBackground( typeof input === 'string' ? { sourceImageSrc: input } : input, ); } + +export async function snapImageToPerfectPixels( + input: EditorPixelArtSnapInput, +): Promise { + return snapEditorImageToPixelArt(input); +} diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx index b06f0013e..bdced3445 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx @@ -42,6 +42,9 @@ function renderSelectedToolbar( onOpenRedrawPanel: vi.fn(), onOpenCropExpandPanel: vi.fn(), onRemoveBackground: vi.fn(), + onPerfectPixel: vi.fn(), + isPerfectPixelProcessing: false, + isPerfectPixelPendingConfirmation: false, isSplittingIconSpritesheet: false, isPersistingAssetKind: false, onSplitIconSpritesheet: vi.fn(), @@ -66,6 +69,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { '快速编辑', '裁扩按钮', '去除背景按钮', + '完美像素', '改造', '下载按钮', ]); @@ -75,6 +79,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { fireEvent.click( within(toolbar).getByRole('button', { name: '去除背景按钮' }), ); + fireEvent.click(within(toolbar).getByRole('button', { name: '完美像素' })); fireEvent.click(within(toolbar).getByRole('button', { name: '改造' })); fireEvent.click(within(toolbar).getByRole('button', { name: '下载按钮' })); @@ -85,6 +90,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { props.selectedLayer, ); expect(props.onRemoveBackground).toHaveBeenCalledWith(props.selectedLayer); + expect(props.onPerfectPixel).toHaveBeenCalledWith(props.selectedLayer); expect(props.onOpenRedrawPanel).toHaveBeenCalledWith(props.selectedLayer); expect(props.onDownloadLayer).toHaveBeenCalledWith(props.selectedLayer); expect( @@ -104,6 +110,19 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { .getByRole('button', { name: '去除背景按钮' }) .querySelector('.lucide-image-off'), ).toBeTruthy(); + expect( + within(toolbar) + .getByRole('button', { name: '完美像素' }) + .querySelector('.lucide-grid-2x2'), + ).toBeTruthy(); + expect( + within(toolbar).getByRole('button', { name: '完美像素' }).textContent, + ).toContain('完美像素'); + expect( + within(toolbar) + .getByRole('button', { name: '完美像素' }) + .getAttribute('title'), + ).toBe('自动识别并规整像素网格'); }); it('renders UI design asset extraction after remove background', () => { @@ -119,6 +138,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { '快速编辑', '裁扩按钮', '去除背景按钮', + '完美像素', '提取素材', '改造', '下载按钮', @@ -142,6 +162,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { '快速编辑', '裁扩按钮', '去除背景按钮', + '完美像素', '拆分图集', '改造', '下载按钮', @@ -184,6 +205,74 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { expect(props.onSplitIconSpritesheet).not.toHaveBeenCalled(); }); + it('renders a disabled loading state while perfect pixel is processing', () => { + const props = renderSelectedToolbar({ + isPerfectPixelProcessing: true, + }); + const button = screen.getByRole('button', { + name: '完美像素处理中', + }); + + expect(button.getAttribute('aria-busy')).toBe('true'); + expect((button as HTMLButtonElement).disabled).toBe(true); + expect( + button.querySelector('.lucide-loader-circle.animate-spin'), + ).toBeTruthy(); + expect(button.querySelector('.lucide-grid-2x2')).toBeNull(); + expect(button.textContent).toContain('处理中'); + + fireEvent.click(button); + fireEvent.click(button); + + expect(props.onPerfectPixel).not.toHaveBeenCalled(); + }); + + it('renders a distinct disabled state while perfect pixel is pending confirmation', () => { + const props = renderSelectedToolbar({ + isPerfectPixelPendingConfirmation: true, + }); + const button = screen.getByRole('button', { + name: '完美像素结果待确认', + }); + + expect(button.getAttribute('title')).toBe( + '结果待确认,双击原占位继续核对或重试', + ); + expect(button.getAttribute('aria-busy')).toBe('false'); + expect((button as HTMLButtonElement).disabled).toBe(true); + expect(button.textContent).toContain('待确认'); + expect(button.textContent).not.toContain('处理中'); + expect(button.querySelector('.lucide-grid-2x2')).toBeTruthy(); + expect(button.querySelector('.lucide-loader-circle')).toBeNull(); + + fireEvent.click(button); + + expect(props.onPerfectPixel).not.toHaveBeenCalled(); + }); + + it('does not invoke perfect pixel while the selected asset kind is persisting', () => { + // 中文注释:请求同时带 assetKind 与 sourceResourceId,本地类型已改但资源尚未落库时 + // 两者不一致,后端会直接 400 并留下失败占位。名称与拆分图集按钮的「素材类型保存中」 + // 必须区分开——icon-spritesheet 图层上两个按钮会同时进入保存态。 + const layer = createLayer({ assetKind: 'icon-spritesheet' }); + const props = renderSelectedToolbar({ + selectedLayer: layer, + isPersistingAssetKind: true, + }); + const button = screen.getByRole('button', { + name: '完美像素等待素材类型保存', + }); + + expect(button.getAttribute('aria-busy')).toBe('true'); + expect((button as HTMLButtonElement).disabled).toBe(true); + expect(button.querySelector('.lucide-grid-2x2')).toBeNull(); + expect(button.textContent).toContain('保存中'); + + fireEvent.click(button); + + expect(props.onPerfectPixel).not.toHaveBeenCalled(); + }); + it('does not invoke atlas splitting while the selected asset kind is persisting', () => { const layer = createLayer({ assetKind: 'icon-spritesheet' }); const props = renderSelectedToolbar({ @@ -227,6 +316,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { expect( within(toolbar).queryByRole('button', { name: '去除背景按钮' }), ).toBeNull(); + expect( + within(toolbar).queryByRole('button', { name: '完美像素' }), + ).toBeNull(); expect( within(toolbar).queryByRole('button', { name: '生成动画' }), ).toBeNull(); @@ -255,6 +347,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { expect( within(videoToolbar).queryByRole('button', { name: '去除背景按钮' }), ).toBeNull(); + expect( + within(videoToolbar).queryByRole('button', { name: '完美像素' }), + ).toBeNull(); expect( within(videoToolbar) .getAllByRole('button') @@ -278,6 +373,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { expect( within(actionToolbar).queryByRole('button', { name: '去除背景按钮' }), ).toBeNull(); + expect( + within(actionToolbar).queryByRole('button', { name: '完美像素' }), + ).toBeNull(); expect( within(actionToolbar) .getAllByRole('button') @@ -341,6 +439,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { onOpenRedrawPanel={vi.fn()} onOpenCropExpandPanel={vi.fn()} onRemoveBackground={vi.fn()} + onPerfectPixel={vi.fn()} + isPerfectPixelProcessing={false} + isPerfectPixelPendingConfirmation={false} isSplittingIconSpritesheet={false} isPersistingAssetKind={false} onSplitIconSpritesheet={vi.fn()} @@ -360,6 +461,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { onOpenRedrawPanel={vi.fn()} onOpenCropExpandPanel={vi.fn()} onRemoveBackground={vi.fn()} + onPerfectPixel={vi.fn()} + isPerfectPixelProcessing={false} + isPerfectPixelPendingConfirmation={false} isSplittingIconSpritesheet={false} isPersistingAssetKind={false} onSplitIconSpritesheet={vi.fn()} diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx index b1a423324..3cb296897 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx @@ -1,6 +1,7 @@ import { Crop, Download, + Grid2X2, ImageOff, Loader2, PersonStanding, @@ -22,6 +23,9 @@ type ImageCanvasSelectedLayerToolbarViewProps = { onOpenRedrawPanel: (layer: CanvasLayer) => void; onOpenCropExpandPanel: (layer: CanvasLayer) => void; onRemoveBackground: (layer: CanvasLayer) => void; + onPerfectPixel: (layer: CanvasLayer) => void; + isPerfectPixelProcessing: boolean; + isPerfectPixelPendingConfirmation: boolean; isSplittingIconSpritesheet: boolean; isPersistingAssetKind: boolean; onSplitIconSpritesheet: (layer: CanvasLayer) => void; @@ -37,6 +41,9 @@ export function ImageCanvasSelectedLayerToolbarView({ onOpenRedrawPanel, onOpenCropExpandPanel, onRemoveBackground, + onPerfectPixel, + isPerfectPixelProcessing, + isPerfectPixelPendingConfirmation, isSplittingIconSpritesheet, isPersistingAssetKind, onSplitIconSpritesheet, @@ -120,6 +127,57 @@ export function ImageCanvasSelectedLayerToolbarView({ icon={ImageOff} onClick={() => onRemoveBackground(selectedLayer)} /> + + ) : ( + + ) + } + // 中文注释:素材类型保存在途时必须一并禁用。请求同时带 assetKind 和 + // sourceResourceId,本地类型已改但资源尚未落库时两者不一致,后端 + // resolve_editor_pixel_art_snap_asset_kind 会直接 400,只留下失败占位。 + // 与相邻的拆分图集按钮保持同一套门禁。 + disabled={ + isPersistingAssetKind || + isPerfectPixelProcessing || + isPerfectPixelPendingConfirmation + } + aria-busy={isPersistingAssetKind || isPerfectPixelProcessing} + onClick={() => onPerfectPixel(selectedLayer)} + > + + {isPersistingAssetKind + ? '保存中' + : isPerfectPixelProcessing + ? '处理中' + : isPerfectPixelPendingConfirmation + ? '待确认' + : '完美像素'} + + ) : null} {selectedLayer.assetKind === 'icon-spritesheet' ? ( 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 1a639d507..eac4df1a2 100644 --- a/src/components/image-editor/ImageCanvasStageView.tsx +++ b/src/components/image-editor/ImageCanvasStageView.tsx @@ -76,9 +76,12 @@ export type ImageCanvasStageViewProps = { uiAssetExtractionSourceLayer: CanvasLayer | null; quickEditSelectionState: UiAssetExtractionState | null; quickEditSelectionSourceLayer: CanvasLayer | null; + hasPendingImageReferenceUploads?: boolean; generationComposerStyle: CSSProperties | null; selectedToolbarStyle: CSSProperties | null; splittingIconSpritesheetLayerIds?: ReadonlySet; + perfectPixelLayerIds?: ReadonlySet; + pendingPerfectPixelLayerIds?: ReadonlySet; persistingAssetKindLayerIds?: ReadonlySet; uploadDropTarget: 'canvas' | 'assets' | null; contextMenu: CanvasContextMenuState | null; @@ -149,6 +152,7 @@ export type ImageCanvasStageViewProps = { onOpenRedrawPanel: (layer: CanvasLayer) => void; onOpenCropExpandPanel: (layer: CanvasLayer) => void; onRemoveBackground: (layer: CanvasLayer) => void; + onPerfectPixel: (layer: CanvasLayer) => void; onSplitIconSpritesheet: (layer: CanvasLayer) => void; onExtractUiDesignAssets: (layer: CanvasLayer) => void; onUiAssetExtractionToolChange: (tool: UiAssetExtractionTool | null) => void; @@ -233,9 +237,12 @@ export function ImageCanvasStageView({ uiAssetExtractionSourceLayer, quickEditSelectionState, quickEditSelectionSourceLayer, + hasPendingImageReferenceUploads = false, generationComposerStyle, selectedToolbarStyle, splittingIconSpritesheetLayerIds = EMPTY_LAYER_ID_SET, + perfectPixelLayerIds = EMPTY_LAYER_ID_SET, + pendingPerfectPixelLayerIds = EMPTY_LAYER_ID_SET, persistingAssetKindLayerIds = EMPTY_LAYER_ID_SET, uploadDropTarget, contextMenu, @@ -285,6 +292,7 @@ export function ImageCanvasStageView({ onOpenRedrawPanel, onOpenCropExpandPanel, onRemoveBackground, + onPerfectPixel, onSplitIconSpritesheet, onExtractUiDesignAssets, onUiAssetExtractionToolChange, @@ -409,10 +417,18 @@ export function ImageCanvasStageView({ isPersistingAssetKind={Boolean( selectedLayer && persistingAssetKindLayerIds.has(selectedLayer.id), )} + isPerfectPixelProcessing={Boolean( + selectedLayer && perfectPixelLayerIds.has(selectedLayer.id), + )} + isPerfectPixelPendingConfirmation={Boolean( + selectedLayer && + pendingPerfectPixelLayerIds.has(selectedLayer.id), + )} onOpenQuickEditPanel={onOpenQuickEditPanel} onOpenRedrawPanel={onOpenRedrawPanel} onOpenCropExpandPanel={onOpenCropExpandPanel} onRemoveBackground={onRemoveBackground} + onPerfectPixel={onPerfectPixel} onSplitIconSpritesheet={onSplitIconSpritesheet} onExtractUiDesignAssets={onExtractUiDesignAssets} onOpenCharacterAnimationPanel={onOpenCharacterAnimationPanel} @@ -431,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 28376cbb3..1e85eff5c 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; @@ -213,10 +226,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; } @@ -246,10 +267,11 @@ export function applyGenerationReferenceUpload({ return dialog?.mode === 'character' ? { ...setFailedGenerationIdle(dialog), - characterReferences: [ - ...(dialog.characterReferences ?? []), - ...references, - ], + characterReferences: appendLimitedImageReferences( + dialog.characterReferences, + references, + resolveDialogExtraImageReferenceLimit(dialog), + ), } : dialog; } @@ -268,9 +290,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/ImageCanvasWorldView.test.tsx b/src/components/image-editor/ImageCanvasWorldView.test.tsx index 002fe65a2..e8a0cdbd0 100644 --- a/src/components/image-editor/ImageCanvasWorldView.test.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.test.tsx @@ -226,7 +226,8 @@ describe('ImageCanvasWorldView', () => { useResolvedAssetReadUrlMock.mockImplementation( (_source: string, options?: { objectKey?: string | null }) => ({ resolvedUrl: - options?.objectKey === 'generated-character-drafts/editor/uploaded.png' + options?.objectKey === + 'generated-character-drafts/editor/uploaded.png' ? 'https://oss.example.com/uploaded.png?signature=1' : _source, isResolving: false, @@ -353,7 +354,9 @@ describe('ImageCanvasWorldView', () => { expect(screen.getByText('生成中')).toBeTruthy(); const world = document.querySelector('.image-canvas-editor__world'); - const marquee = world?.querySelector('.image-canvas-editor__canvas-marquee'); + const marquee = world?.querySelector( + '.image-canvas-editor__canvas-marquee', + ); expect(world?.getAttribute('style')).toContain( 'transform: translate(10px, 20px) scale(1.5)', @@ -386,7 +389,11 @@ describe('ImageCanvasWorldView', () => { expect(within(frame).getByText('Icon Generator')).toBeTruthy(); expect(within(frame).getByText('图标')).toBeTruthy(); expect(within(frame).getByText('1024 x 768')).toBeTruthy(); - expect(within(frame).getByRole('status').textContent).toBe('生成中'); + const generatingStatus = within(frame).getByRole('status'); + expect(generatingStatus.textContent).toBe('生成中'); + expect(generatingStatus.className).not.toContain( + 'image-canvas-editor__generation-frame-progress--pending-confirmation', + ); fireEvent.pointerDown(frame); fireEvent.doubleClick(frame); @@ -402,6 +409,30 @@ describe('ImageCanvasWorldView', () => { expect(screen.queryByText('dialog-without-placeholder')).toBeNull(); }); + it('keeps a pending perfect-pixel placeholder visible with a confirmation verdict', () => { + const dialog = createGenerationDialog({ + id: 'dialog-perfect-pixel-pending', + status: 'pending-confirmation', + }); + + renderWorldView({ + canvasGenerationDialogs: [dialog], + generateDialog: null, + }); + + const frame = screen.getByRole('button', { name: '图像生成占位图' }); + expect(frame.className).toContain( + 'image-canvas-editor__generation-frame--pending-confirmation', + ); + expect(within(frame).getByText('Image Generator')).toBeTruthy(); + const pendingStatus = within(frame).getByRole('status'); + expect(pendingStatus.textContent).toBe('结果待确认'); + expect(pendingStatus.className).toContain( + 'image-canvas-editor__generation-frame-progress--pending-confirmation', + ); + expect(within(frame).queryByText('生成中')).toBeNull(); + }); + it('renders crop-expand frame handles and forwards drag actions', () => { const layer = createLayer(); const { props } = renderWorldView({ @@ -612,9 +643,9 @@ describe('ImageCanvasWorldView', () => { const inverseScale = '4'; expect( - (within(frame).getByText(generatorLabel) as HTMLElement).style.getPropertyValue( - '--image-canvas-editor-inverse-scale', - ), + ( + within(frame).getByText(generatorLabel) as HTMLElement + ).style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); expect( mode === 'audio-sound-effect' || mode === 'audio-background-music' @@ -628,9 +659,9 @@ describe('ImageCanvasWorldView', () => { : inverseScale, ); expect( - (within(frame).getByText(badgeLabel) as HTMLElement).style.getPropertyValue( - '--image-canvas-editor-inverse-scale', - ), + ( + within(frame).getByText(badgeLabel) as HTMLElement + ).style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); }, ); @@ -716,14 +747,14 @@ describe('ImageCanvasWorldView', () => { const inverseScale = '4'; expect( - (within(layerButton).getByText('角色') as HTMLElement).style.getPropertyValue( - '--image-canvas-editor-inverse-scale', - ), + ( + within(layerButton).getByText('角色') as HTMLElement + ).style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); expect( - (within(layerButton).getByText('640 x 480 px') as HTMLElement).style.getPropertyValue( - '--image-canvas-editor-inverse-scale', - ), + ( + within(layerButton).getByText('640 x 480 px') as HTMLElement + ).style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); expect( within(layerButton) @@ -731,14 +762,14 @@ describe('ImageCanvasWorldView', () => { .style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); expect( - (within(frame).getByText('Image Generator') as HTMLElement).style.getPropertyValue( - '--image-canvas-editor-inverse-scale', - ), + ( + within(frame).getByText('Image Generator') as HTMLElement + ).style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); expect( - (within(frame).getByText('1024 x 768') as HTMLElement).style.getPropertyValue( - '--image-canvas-editor-inverse-scale', - ), + ( + within(frame).getByText('1024 x 768') as HTMLElement + ).style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe(inverseScale); }); @@ -753,8 +784,11 @@ describe('ImageCanvasWorldView', () => { const frame = screen.getByRole('button', { name: '图像生成占位图' }); expect( - (frame.querySelector('.image-canvas-editor__generation-frame-icon') as HTMLElement) - .style.getPropertyValue('--image-canvas-editor-inverse-scale'), + ( + frame.querySelector( + '.image-canvas-editor__generation-frame-icon', + ) as HTMLElement + ).style.getPropertyValue('--image-canvas-editor-inverse-scale'), ).toBe('4'); }); @@ -811,9 +845,9 @@ describe('ImageCanvasWorldView', () => { name: '查看角色主图图片信息', }); - expect(metadataButton.querySelector('svg')?.getAttribute('class')).toContain( - 'lucide-info', - ); + expect( + metadataButton.querySelector('svg')?.getAttribute('class'), + ).toContain('lucide-info'); }); it('renders audio layers with an audio control card instead of an image', () => { @@ -847,7 +881,8 @@ describe('ImageCanvasWorldView', () => { within(layerButton).getAllByRole('button', { name: /播放|暂停/u }), ).toHaveLength(1); expect( - within(layerButton).getByRole('button', { name: '播放游戏音效' }) + within(layerButton) + .getByRole('button', { name: '播放游戏音效' }) .querySelector('svg') ?.getAttribute('class'), ).toContain('lucide-volume-2'); @@ -870,9 +905,7 @@ describe('ImageCanvasWorldView', () => { ).toBeTruthy(); expect(within(layerButton).getByLabelText('静音游戏音效')).toBeTruthy(); expect(within(layerButton).getByLabelText('调整游戏音效音量')).toBeTruthy(); - expect( - within(layerButton).queryByText('420 x 120 px'), - ).toBeNull(); + expect(within(layerButton).queryByText('420 x 120 px')).toBeNull(); }); it('keeps audio controls interactive and switches icon, play and pause states', () => { @@ -893,7 +926,9 @@ describe('ImageCanvasWorldView', () => { selectedLayerIds: [layer.id], }); - const layerButton = screen.getByRole('button', { name: '选择游戏背景音乐' }); + const layerButton = screen.getByRole('button', { + name: '选择游戏背景音乐', + }); const audio = within(layerButton).getByLabelText('画布音频:游戏背景音乐'); const playbackButton = within(layerButton).getByRole('button', { name: '播放游戏背景音乐', @@ -930,9 +965,8 @@ describe('ImageCanvasWorldView', () => { ?.getAttribute('class'), ).toContain('lucide-play'); - const hoveredAudio = within(hoveredLayerButton).getByLabelText( - '画布音频:游戏背景音乐', - ); + const hoveredAudio = + within(hoveredLayerButton).getByLabelText('画布音频:游戏背景音乐'); fireEvent.play(hoveredAudio); expect( within(hoveredLayerButton) @@ -1030,9 +1064,7 @@ describe('ImageCanvasWorldView', () => { expect(screen.getByRole('menu', { name: '选择素材标签' })).toBeTruthy(); fireEvent.pointerDown(document.body); - expect( - screen.queryByRole('menu', { name: '选择素材标签' }), - ).toBeNull(); + expect(screen.queryByRole('menu', { name: '选择素材标签' })).toBeNull(); } finally { document.removeEventListener('wheel', documentWheel); } @@ -1098,7 +1130,9 @@ describe('ImageCanvasWorldView', () => { layerButton.querySelector('.image-canvas-editor__media-preview--video') ?.className, ).toContain('image-canvas-editor__media-preview--variant-'); - expect(within(layerButton).queryByAltText('画布视频:生成视频 7')).toBeNull(); + expect( + within(layerButton).queryByAltText('画布视频:生成视频 7'), + ).toBeNull(); expect(within(layerButton).getByText('视频')).toBeTruthy(); expect(props.onLayerPointerDown).toHaveBeenCalledWith( expect.any(Object), @@ -1199,8 +1233,7 @@ describe('ImageCanvasWorldView', () => { it('keeps the last loaded sequence frame visible while the next frame is resolving', () => { vi.useFakeTimers(); try { - useResolvedAssetReadUrlMock.mockImplementation( - (source: string) => ({ + useResolvedAssetReadUrlMock.mockImplementation((source: string) => ({ resolvedUrl: source === '/generated-character-drafts/editor/frame01.png' ? 'https://oss.example.com/frame01.png?signature=1' @@ -1208,8 +1241,7 @@ describe('ImageCanvasWorldView', () => { isResolving: source === '/generated-character-drafts/editor/frame02.png', shouldResolve: true, - }), - ); + })); const layer = createLayer({ title: '角色动作', src: '/generated-character-drafts/editor/frame01.png', @@ -1238,9 +1270,8 @@ describe('ImageCanvasWorldView', () => { renderWorldView({ layers: [layer] }); const layerButton = screen.getByRole('button', { name: '选择角色动作' }); - const firstFrame = within(layerButton).getByAltText( - '画布序列帧:角色动作', - ); + const firstFrame = + within(layerButton).getByAltText('画布序列帧:角色动作'); fireEvent.load(firstFrame); expect( @@ -1290,8 +1321,6 @@ describe('ImageCanvasWorldView', () => { }); expect(screen.getByRole('button', { name: '选择生成图片' })).toBeTruthy(); - expect( - screen.queryByRole('button', { name: '图像生成占位图' }), - ).toBeNull(); + expect(screen.queryByRole('button', { name: '图像生成占位图' })).toBeNull(); }); }); diff --git a/src/components/image-editor/ImageCanvasWorldView.tsx b/src/components/image-editor/ImageCanvasWorldView.tsx index 4e7770f04..0efceef1b 100644 --- a/src/components/image-editor/ImageCanvasWorldView.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.tsx @@ -326,9 +326,7 @@ function selectMediaLayerWithoutDrag( onSelectLayer(); } -function handleLayerKeyboardActivation( - event: ReactKeyboardEvent, -) { +function handleLayerKeyboardActivation(event: ReactKeyboardEvent) { if (event.key !== 'Enter' && event.key !== ' ') { return; } @@ -374,7 +372,9 @@ function getAudioLayerIcon(layer: CanvasLayer) { ); } -function getAudioDurationSeconds(durationSeconds: CanvasLayer['durationSeconds']) { +function getAudioDurationSeconds( + durationSeconds: CanvasLayer['durationSeconds'], +) { return typeof durationSeconds === 'number' && Number.isFinite(durationSeconds) && durationSeconds > 0 @@ -495,7 +495,9 @@ function ImageCanvasAudioLayerCard({ event.stopPropagation(); void togglePlayback(); }} - onPointerDown={(event) => selectMediaLayerWithoutDrag(event, onSelectLayer)} + onPointerDown={(event) => + selectMediaLayerWithoutDrag(event, onSelectLayer) + } > {showPlaybackButton ? ( isPlaying ? ( @@ -514,7 +516,9 @@ function ImageCanvasAudioLayerCard({ className="image-canvas-editor__audio-card-controls image-canvas-editor__audio-card-controls--integrated" onClick={stopMediaEventPropagation} onKeyDown={stopMediaControlKeyPropagation} - onPointerDown={(event) => selectMediaLayerWithoutDrag(event, onSelectLayer)} + onPointerDown={(event) => + selectMediaLayerWithoutDrag(event, onSelectLayer) + } >
); })() diff --git a/src/components/image-editor/perfectPixelOperationStore.test.ts b/src/components/image-editor/perfectPixelOperationStore.test.ts new file mode 100644 index 000000000..8860263df --- /dev/null +++ b/src/components/image-editor/perfectPixelOperationStore.test.ts @@ -0,0 +1,228 @@ +/** + * @vitest-environment jsdom + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { PerfectPixelOperationSnapshot } from './ImageCanvasEditorTypes'; +import { + forgetPerfectPixelOperation, + PERFECT_PIXEL_OPERATION_RETENTION_LIMIT, + PERFECT_PIXEL_OPERATION_RETENTION_MS, + readPerfectPixelOperations, + savePerfectPixelOperation, +} from './perfectPixelOperationStore'; + +const OWNER_USER_ID = 'user-a'; +const PROJECT_ID = 'project-1'; +const NOW = 1_785_715_270_000; + +function buildOperation( + dialogId: string, + overrides: Partial = {}, +): PerfectPixelOperationSnapshot { + return { + version: 1, + kind: 'perfect-pixel', + operationId: dialogId, + taskId: `pixel-art-snap-${dialogId}`, + request: { + sourceImageSrc: 'ref:project-resource:resource-source', + projectId: PROJECT_ID, + sourceResourceId: 'resource-source', + assetKind: 'character', + assetLabel: '角色 · 完美像素', + canvasCompletion: { + dialogId, + title: '角色 · 完美像素', + placeholder: { + x: 100, + y: 120, + width: 320, + height: 320, + originalWidth: 640, + originalHeight: 640, + }, + }, + }, + submittedAt: NOW, + reconcileUntil: NOW + 75_000, + ...overrides, + }; +} + +describe('perfectPixelOperationStore', () => { + beforeEach(() => { + window.localStorage.clear(); + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('round-trips an operation for the same owner and project', () => { + const operation = buildOperation('dialog-1'); + savePerfectPixelOperation(OWNER_USER_ID, PROJECT_ID, operation); + + const ledger = readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID); + + expect(ledger.get('dialog-1')).toEqual(operation); + }); + + it('keeps ledgers separated by project and drops another owner entirely', () => { + savePerfectPixelOperation( + OWNER_USER_ID, + PROJECT_ID, + buildOperation('dialog-1'), + ); + + expect(readPerfectPixelOperations(OWNER_USER_ID, 'project-2').size).toBe(0); + // 中文注释:同一台机器换账号后,上一个账号的请求(含源图直传地址)必须整条丢弃。 + expect(readPerfectPixelOperations('user-b', PROJECT_ID).size).toBe(0); + expect(readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID).size).toBe(0); + }); + + it('refuses to store an operation whose request targets another project', () => { + savePerfectPixelOperation( + OWNER_USER_ID, + 'project-2', + buildOperation('dialog-1'), + ); + + expect(readPerfectPixelOperations(OWNER_USER_ID, 'project-2').size).toBe(0); + }); + + it('fails closed on a tampered ledger entry instead of replaying it', () => { + savePerfectPixelOperation( + OWNER_USER_ID, + PROJECT_ID, + buildOperation('dialog-1'), + ); + savePerfectPixelOperation( + OWNER_USER_ID, + PROJECT_ID, + buildOperation('dialog-2'), + ); + const key = `genarrative.imageCanvas.perfectPixelOperations.${PROJECT_ID}`; + const stored = JSON.parse(window.localStorage.getItem(key)!) as { + ownerUserId: string; + operations: Record; + }; + stored.operations['dialog-1']!.taskId = 'pixel-art-snap-somewhere-else'; + window.localStorage.setItem(key, JSON.stringify(stored)); + + const ledger = readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID); + + expect(ledger.has('dialog-1')).toBe(false); + expect(ledger.has('dialog-2')).toBe(true); + }); + + it('drops entries past the retention window', () => { + savePerfectPixelOperation( + OWNER_USER_ID, + PROJECT_ID, + buildOperation('dialog-old', { + submittedAt: NOW - PERFECT_PIXEL_OPERATION_RETENTION_MS - 1, + reconcileUntil: NOW - PERFECT_PIXEL_OPERATION_RETENTION_MS + 74_999, + }), + ); + savePerfectPixelOperation( + OWNER_USER_ID, + PROJECT_ID, + buildOperation('dialog-fresh'), + ); + + const ledger = readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID); + + expect([...ledger.keys()]).toEqual(['dialog-fresh']); + }); + + it('caps the ledger size by keeping the newest submissions', () => { + for ( + let index = 0; + index <= PERFECT_PIXEL_OPERATION_RETENTION_LIMIT; + index += 1 + ) { + savePerfectPixelOperation( + OWNER_USER_ID, + PROJECT_ID, + buildOperation(`dialog-${index}`, { + submittedAt: NOW - (PERFECT_PIXEL_OPERATION_RETENTION_LIMIT - index), + reconcileUntil: + NOW - (PERFECT_PIXEL_OPERATION_RETENTION_LIMIT - index) + 75_000, + }), + ); + } + + const ledger = readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID); + + expect(ledger.size).toBe(PERFECT_PIXEL_OPERATION_RETENTION_LIMIT); + expect(ledger.has('dialog-0')).toBe(false); + expect( + ledger.has(`dialog-${PERFECT_PIXEL_OPERATION_RETENTION_LIMIT}`), + ).toBe(true); + }); + + it('forgets a settled operation and clears the key once empty', () => { + savePerfectPixelOperation( + OWNER_USER_ID, + PROJECT_ID, + buildOperation('dialog-1'), + ); + + forgetPerfectPixelOperation(OWNER_USER_ID, PROJECT_ID, 'dialog-1'); + + expect(readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID).size).toBe(0); + expect( + window.localStorage.getItem( + `genarrative.imageCanvas.perfectPixelOperations.${PROJECT_ID}`, + ), + ).toBeNull(); + }); + + it('degrades to an empty ledger without throwing when storage is unavailable', () => { + const setItem = vi + .spyOn(Storage.prototype, 'setItem') + .mockImplementation(() => { + throw new Error('QuotaExceededError'); + }); + const getItem = vi + .spyOn(Storage.prototype, 'getItem') + .mockImplementation(() => { + throw new Error('SecurityError'); + }); + + try { + // 中文注释:隐私模式 / 配额写满时账本读写都会抛。这里必须静默降级——账本缺失只 + // 意味着刷新后不能自动收口,绝不能反过来阻断发起、重试或删除。 + expect(() => + savePerfectPixelOperation( + OWNER_USER_ID, + PROJECT_ID, + buildOperation('dialog-1'), + ), + ).not.toThrow(); + expect(() => + forgetPerfectPixelOperation(OWNER_USER_ID, PROJECT_ID, 'dialog-1'), + ).not.toThrow(); + expect(readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID).size).toBe( + 0, + ); + } finally { + setItem.mockRestore(); + getItem.mockRestore(); + } + }); + + it('returns an empty ledger without an owner or project', () => { + savePerfectPixelOperation( + OWNER_USER_ID, + PROJECT_ID, + buildOperation('dialog-1'), + ); + + expect(readPerfectPixelOperations(null, PROJECT_ID).size).toBe(0); + expect(readPerfectPixelOperations(OWNER_USER_ID, ' ').size).toBe(0); + }); +}); diff --git a/src/components/image-editor/perfectPixelOperationStore.ts b/src/components/image-editor/perfectPixelOperationStore.ts new file mode 100644 index 000000000..324841c85 --- /dev/null +++ b/src/components/image-editor/perfectPixelOperationStore.ts @@ -0,0 +1,203 @@ +import { hydratePerfectPixelOperation } from './ImageCanvasEditorModel'; +import type { PerfectPixelOperationSnapshot } from './ImageCanvasEditorTypes'; + +/** + * 中文注释:完美像素操作账本的本机存储。 + * + * **这是明确设计,不是降级方案**:账本记录的是「本机这次会话发出过哪一次 POST」, + * 它是对账凭据,不是用户的画布内容,因此不进项目布局。由此得到两条硬性质: + * + * 1. **写入同步、不依赖网络、不依赖服务端校验。** 发 POST 前先落本机账本即可获得 + * 「请求可被追溯」的保证,不必再用严格布局保存去换同一个保证。布局校验(例如 + * 资源元数据读写不对称)从此不可能阻断完美像素的发起或重试。 + * 2. **账本缺失只降级、绝不阻断。** 换设备、换浏览器、清缓存、隐私模式、配额写满, + * 都会读不到账本。那种情况下占位收口为可删除的失败态,用户可以删掉重来; + * 任何路径都不得因为「读不到账本」而拒绝用户发起、重试或删除。 + * + * 代价是跨设备不再自动收口:在 A 机发起、到 B 机打开同一项目时,B 机看到的是失败占位 + * 而不是对账中的占位。完美像素是免费同步操作,重做成本极低,用这点换掉「用户数据里 + * 混着系统对账状态」的耦合是划算的。 + */ + +const PERFECT_PIXEL_OPERATION_STORAGE_KEY_PREFIX = + 'genarrative.imageCanvas.perfectPixelOperations'; + +/** + * 中文注释:账本保留期。对账窗口只有 75 秒,但 `pending-confirmation` 占位允许用户在很久 + * 之后手动重试同一次 operation,那条路径同样需要账本,所以保留期必须远长于对账窗口。 + */ +export const PERFECT_PIXEL_OPERATION_RETENTION_MS = 7 * 24 * 60 * 60 * 1_000; + +/** + * 中文注释:单个项目最多保留的账本条数,超出时丢弃最旧的。防止长期使用把 localStorage + * 配额吃满——配额写满会连带影响同域下其它本地缓存,而账本本身是可丢弃的。 + */ +export const PERFECT_PIXEL_OPERATION_RETENTION_LIMIT = 32; + +type PerfectPixelOperationLedger = Map; + +function getPerfectPixelOperationStorage() { + if (typeof window === 'undefined') { + return null; + } + try { + return window.localStorage; + } catch { + return null; + } +} + +function perfectPixelOperationStorageKey(projectId: string | null | undefined) { + const normalizedProjectId = projectId?.trim(); + if (!normalizedProjectId) { + return null; + } + return `${PERFECT_PIXEL_OPERATION_STORAGE_KEY_PREFIX}.${normalizedProjectId}`; +} + +/** + * 中文注释:读账本时同时校验归属。同一台机器可能先后登录不同账号,账本里带着上一个 + * 账号的请求(含源图直传地址),换人后必须整条丢弃而不是原样返回。 + */ +function readLedgerEntries( + storage: Storage, + key: string, + ownerUserId: string, +): PerfectPixelOperationLedger { + const ledger: PerfectPixelOperationLedger = new Map(); + const rawValue = storage.getItem(key); + if (!rawValue) { + return ledger; + } + const parsedValue: unknown = JSON.parse(rawValue); + if (!parsedValue || typeof parsedValue !== 'object') { + return ledger; + } + const record = parsedValue as Record; + const recordOwnerUserId = + typeof record.ownerUserId === 'string' ? record.ownerUserId.trim() : ''; + if (recordOwnerUserId !== ownerUserId) { + storage.removeItem(key); + return ledger; + } + const operations = record.operations; + if (!operations || typeof operations !== 'object') { + return ledger; + } + const now = Date.now(); + for (const [operationId, value] of Object.entries( + operations as Record, + )) { + // 中文注释:本机账本与布局快照走同一套 v1 白名单校验。存储可被用户或其它脚本改写, + // 任何字段漂移都必须失败关闭——绝不能据一份可疑账本重放 POST。 + const operation = hydratePerfectPixelOperation(value, operationId); + if (!operation) { + continue; + } + if (now - operation.submittedAt > PERFECT_PIXEL_OPERATION_RETENTION_MS) { + continue; + } + ledger.set(operationId, operation); + } + return ledger; +} + +function writeLedgerEntries( + storage: Storage, + key: string, + ownerUserId: string, + ledger: PerfectPixelOperationLedger, +) { + if (ledger.size === 0) { + storage.removeItem(key); + return; + } + const retained = [...ledger.values()] + .sort((left, right) => right.submittedAt - left.submittedAt) + .slice(0, PERFECT_PIXEL_OPERATION_RETENTION_LIMIT); + storage.setItem( + key, + JSON.stringify({ + ownerUserId, + operations: Object.fromEntries( + retained.map((operation) => [operation.operationId, operation]), + ), + }), + ); +} + +/** + * 中文注释:读取某项目在本机的全部有效账本。任何异常都返回空账本——读不到账本只意味着 + * 「刷新后不能自动收口」,调用方必须能在空账本下继续工作。 + */ +export function readPerfectPixelOperations( + currentUserId: string | null | undefined, + projectId: string | null | undefined, +): PerfectPixelOperationLedger { + const ownerUserId = currentUserId?.trim(); + const key = perfectPixelOperationStorageKey(projectId); + const storage = getPerfectPixelOperationStorage(); + if (!ownerUserId || !key || !storage) { + return new Map(); + } + try { + return readLedgerEntries(storage, key, ownerUserId); + } catch { + return new Map(); + } +} + +/** + * 中文注释:写入一条账本。必须在发 POST 之前调用——这是整条链路里唯一「请求已发出」的 + * 本地证据。写入失败(配额、隐私模式)同样不阻断:调用方照常发 POST,只是丢掉刷新后 + * 自动收口的能力。 + */ +export function savePerfectPixelOperation( + currentUserId: string | null | undefined, + projectId: string | null | undefined, + operation: PerfectPixelOperationSnapshot, +) { + const ownerUserId = currentUserId?.trim(); + const key = perfectPixelOperationStorageKey(projectId); + const storage = getPerfectPixelOperationStorage(); + if (!ownerUserId || !key || !storage) { + return; + } + if (operation.request.projectId !== projectId?.trim()) { + return; + } + try { + const ledger = readLedgerEntries(storage, key, ownerUserId); + ledger.set(operation.operationId, operation); + writeLedgerEntries(storage, key, ownerUserId, ledger); + } catch { + // 中文注释:账本是尽力而为的本机便利,写失败不得影响本次提交。 + } +} + +/** + * 中文注释:操作收口(结果已套用 / 只落素材库 / 快照判定失效)后清账本,避免过期条目 + * 在下次加载时再发一次无谓的对账 GET。 + */ +export function forgetPerfectPixelOperation( + currentUserId: string | null | undefined, + projectId: string | null | undefined, + operationId: string, +) { + const ownerUserId = currentUserId?.trim(); + const key = perfectPixelOperationStorageKey(projectId); + const storage = getPerfectPixelOperationStorage(); + const normalizedOperationId = operationId.trim(); + if (!ownerUserId || !key || !storage || !normalizedOperationId) { + return; + } + try { + const ledger = readLedgerEntries(storage, key, ownerUserId); + if (!ledger.delete(normalizedOperationId)) { + return; + } + writeLedgerEntries(storage, key, ownerUserId, ledger); + } catch { + // 中文注释:清理失败最多留下一条过期账本,保留期会兜底。 + } +} diff --git a/src/components/image-editor/useCanvasGenerationDialogs.test.tsx b/src/components/image-editor/useCanvasGenerationDialogs.test.tsx index fba896056..b18f151d9 100644 --- a/src/components/image-editor/useCanvasGenerationDialogs.test.tsx +++ b/src/components/image-editor/useCanvasGenerationDialogs.test.tsx @@ -1,10 +1,13 @@ /* @vitest-environment jsdom */ -import { act,renderHook } from '@testing-library/react'; +import { act, renderHook } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import type { CanvasGenerationDialogState } from './ImageCanvasEditorTypes'; -import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs'; +import { + requiresGenerationDeleteConfirmation, + useCanvasGenerationDialogs, +} from './useCanvasGenerationDialogs'; function createDialog( mode: CanvasGenerationDialogState['mode'], @@ -26,7 +29,121 @@ function createDialog( }; } +function durablePerfectPixelDialog( + dialogId: string, + status: 'generating' | 'pending-confirmation' | 'failed', +): CanvasGenerationDialogState { + const submittedAt = 1_700_000_000_000; + return { + id: dialogId, + mode: 'quick-edit', + prompt: '完美像素', + status, + composerOpen: true, + sourceLayerId: 'layer-source', + // 中文注释:marker 从占位创建那一刻就存在,账本形成后也一直在。夹具必须还原这个形状, + // 否则下游用例是在测一个真实链路里不存在的状态。 + perfectPixelOperationId: dialogId, + perfectPixelOperation: { + version: 1, + kind: 'perfect-pixel', + operationId: dialogId, + taskId: `pixel-art-snap-${dialogId}`, + request: { + sourceImageSrc: 'generated-images/editor/source.png', + projectId: 'project-1', + canvasCompletion: { + dialogId, + title: '源图 · 完美像素', + placeholder: { + x: 0, + y: 0, + width: 320, + height: 240, + originalWidth: 320, + originalHeight: 240, + }, + }, + }, + submittedAt, + reconcileUntil: submittedAt + 75_000, + }, + }; +} + 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(() => @@ -280,4 +397,116 @@ describe('useCanvasGenerationDialogs', () => { }), ]); }); + + it('returns a newly opened explicit dialog from the synchronous snapshot getter in the same action', () => { + const { result } = renderHook(() => useCanvasGenerationDialogs()); + let openedDialogId = ''; + let immediateSnapshot: CanvasGenerationDialogState[] = []; + + act(() => { + openedDialogId = result.current.openCanvasGenerationDialog({ + ...createDialog('quick-edit', '完美像素'), + id: 'generation-dialog-perfect-pixel', + status: 'generating', + }); + immediateSnapshot = result.current.getCanvasGenerationDialogsSnapshot(); + }); + + expect(openedDialogId).toBe('generation-dialog-perfect-pixel'); + expect(immediateSnapshot).toEqual([ + expect.objectContaining({ + id: 'generation-dialog-perfect-pixel', + mode: 'quick-edit', + prompt: '完美像素', + status: 'generating', + }), + ]); + }); + + // 中文注释:占位是用户文档的一部分,删除它不撤销任何在途请求——完美像素没有取消接口, + // 结果照常落库并进素材库。低层删除因此不得对未收口 operation 抗命,否则上层会写出 + // 「历史记了一笔、占位还在」的伪历史。 + it.each(['generating', 'pending-confirmation', 'failed'] as const)( + 'deletes an unsettled durable perfect-pixel operation by id while %s', + (status) => { + const { result } = renderHook(() => useCanvasGenerationDialogs()); + const dialogId = 'perfect-pixel-durable'; + + act(() => { + result.current.restoreCanvasGenerationDialogs([ + durablePerfectPixelDialog(dialogId, status), + ]); + }); + act(() => { + result.current.removeCanvasGenerationDialogById(dialogId); + }); + + expect(result.current.activeCanvasGenerationDialog).toBeNull(); + expect(result.current.canvasGenerationDialogs).toEqual([]); + }, + ); + + // 中文注释:确认弹窗讲的是「已消耗的泥点不会返还」,只对计费生成成立;完美像素免费且删除 + // 占位不撤销在途请求,所以它在任何状态都直接删。 + it.each(['generating', 'pending-confirmation', 'failed'] as const)( + 'never asks for delete confirmation on a perfect-pixel placeholder while %s', + (status) => { + expect( + requiresGenerationDeleteConfirmation( + durablePerfectPixelDialog('perfect-pixel-confirm', status), + ), + ).toBe(false); + }, + ); + + it('never asks for delete confirmation before the perfect-pixel ledger exists', () => { + // 中文注释:源图解析 / 直传最长 90 秒,期间占位只有 marker、还没有账本。此前判据按账本 + // 判,用户在这段窗口里删一个免费操作会看到「已消耗的泥点不会返还」。 + expect( + requiresGenerationDeleteConfirmation({ + id: 'perfect-pixel-preparing', + mode: 'quick-edit', + prompt: '完美像素', + status: 'generating', + composerOpen: false, + sourceLayerId: 'layer-source', + requiresLiveSession: true, + perfectPixelOperationId: 'perfect-pixel-preparing', + }), + ).toBe(false); + }); + + it('still asks for delete confirmation on an ordinary generating placeholder', () => { + expect( + requiresGenerationDeleteConfirmation({ + id: 'generation-dialog-1', + ...createDialog('generate', '一只猫'), + status: 'generating', + }), + ).toBe(true); + expect( + requiresGenerationDeleteConfirmation({ + id: 'generation-dialog-2', + ...createDialog('generate', '一只猫'), + status: 'failed', + }), + ).toBe(false); + }); + + it('drops unsettled durable perfect-pixel operations together with their source layer', () => { + const { result } = renderHook(() => useCanvasGenerationDialogs()); + const dialogId = 'perfect-pixel-durable'; + + act(() => { + result.current.restoreCanvasGenerationDialogs([ + durablePerfectPixelDialog(dialogId, 'pending-confirmation'), + ]); + }); + act(() => { + result.current.removeCanvasGenerationDialogsByLayerId('layer-source'); + }); + + expect(result.current.activeCanvasGenerationDialog).toBeNull(); + expect(result.current.canvasGenerationDialogs).toEqual([]); + }); }); diff --git a/src/components/image-editor/useCanvasGenerationDialogs.ts b/src/components/image-editor/useCanvasGenerationDialogs.ts index c386825e7..485c39f28 100644 --- a/src/components/image-editor/useCanvasGenerationDialogs.ts +++ b/src/components/image-editor/useCanvasGenerationDialogs.ts @@ -17,6 +17,35 @@ type CanvasGenerationDialogUpdater = ( dialog: CanvasGenerationDialogState, ) => CanvasGenerationDialogState | null; +export type CanvasGenerationDialogDraft = Omit< + CanvasGenerationDialogState, + 'id' +> & { + id?: string; +}; + +/** + * 中文注释:删除生成占位前是否需要二次确认。 + * + * 确认弹窗讲的是「已消耗的泥点不会返还」,只对计费生成成立。完美像素 + * `generation_cost_mud_points = 0`,删除占位也不撤销任何在途请求——它没有取消接口,结果照常 + * 落库并进素材库,服务端 completion 发现 dialog 已不在会返回 DialogMissing 并由客户端提示。 + * 所以完美像素占位在任何状态都直接删,不额外解释。 + * + * 判据必须看 `perfectPixelOperationId` 而**不是** `perfectPixelOperation`:后者要到源图解析 / + * 直传完成后才写入,那一段预算最长 90 秒(未登记的本地图片要走 ticket → PUT → confirm), + * 期间占位是 `generating` 且没有账本,按账本判会让用户删一个免费操作时看到「已消耗的泥点 + * 不会返还」。marker 在占位创建那一刻就写上,覆盖完整生命周期。 + * + * 更一般地:这里问的是「**这次生成计不计费**」,账本的有无只是它在某一段时间内的代理。 + * 用短寿命字段的存在性去判断长期属性,正是本仓库反复出错的形状。 + */ +export function requiresGenerationDeleteConfirmation( + dialog: CanvasGenerationDialogState, +) { + return dialog.status === 'generating' && !dialog.perfectPixelOperationId; +} + function withGenerationTimestamps( nextDialog: T, previousDialog?: GenerateDialogState | null, @@ -46,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); @@ -67,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 @@ -90,6 +148,14 @@ export function useCanvasGenerationDialogs({ [activeCanvasGenerationDialog, inactiveGenerateDialogs], ); + const getCanvasGenerationDialogsSnapshot = useCallback(() => { + const currentDialog = generateDialogRef.current; + return [ + ...inactiveGenerateDialogsRef.current, + ...(isCanvasGenerationDialog(currentDialog) ? [currentDialog] : []), + ]; + }, []); + const createGenerationDialogId = useCallback(() => { generationDialogCounterRef.current += 1; return `generation-dialog-${generationDialogCounterRef.current}`; @@ -100,6 +166,9 @@ export function useCanvasGenerationDialogs({ if (!isCanvasGenerationDialog(currentDialog)) { return; } + if (rejectContextMutation()) { + return; + } const nextInactiveDialogs = inactiveGenerateDialogsRef.current.some( (dialog) => dialog.id === currentDialog.id, ) @@ -113,11 +182,14 @@ export function useCanvasGenerationDialogs({ ]; inactiveGenerateDialogsRef.current = nextInactiveDialogs; setInactiveGenerateDialogs(nextInactiveDialogs); - }, []); + }, [rejectContextMutation]); const openCanvasGenerationDialog = useCallback( - (dialog: Omit) => { + (dialog: CanvasGenerationDialogDraft) => { const currentDialog = generateDialogRef.current; + if (rejectContextMutation()) { + return isCanvasGenerationDialog(currentDialog) ? currentDialog.id : ''; + } if (isCanvasGenerationDialog(currentDialog)) { inactiveGenerateDialogsRef.current = inactiveGenerateDialogsRef.current.some( @@ -133,21 +205,44 @@ export function useCanvasGenerationDialogs({ ]; } archiveActiveCanvasGenerationDialog(); - const id = createGenerationDialogId(); + const requestedId = dialog.id?.trim(); + const requestedIdAlreadyExists = + Boolean(requestedId) && + getCanvasGenerationDialogsSnapshot().some( + (existingDialog) => existingDialog.id === requestedId, + ); + const id = + requestedId && !requestedIdAlreadyExists + ? requestedId + : createGenerationDialogId(); + const { id: _requestedId, ...dialogWithoutId } = dialog; const nextDialog = withGenerationTimestamps({ - ...dialog, + ...dialogWithoutId, id, }); generateDialogRef.current = nextDialog; setGenerateDialogState(nextDialog); return id; }, - [archiveActiveCanvasGenerationDialog, createGenerationDialogId], + [ + 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 @@ -184,9 +279,13 @@ export function useCanvasGenerationDialogs({ }), ); }, - [], + [rejectContextMutation], ); + // 中文注释:低层删除不再对未收口的完美像素 operation 抗命。删除占位不撤销任何在途请求 + // ——完美像素没有取消接口,结果照常落库并进素材库,服务端 completion 发现 dialog 已不在 + // 会返回 DialogMissing,客户端有对应提示。封锁换来的只是「结果自动回填画布」这一便利, + // 代价却是用户画布上出现删不掉的对象;低层偷偷保留还会让上层写出伪历史。 const removeCanvasGenerationDialogById = useCallback( (dialogId: string) => { updateCanvasGenerationDialogById(dialogId, () => null); @@ -205,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, ); @@ -227,7 +333,7 @@ export function useCanvasGenerationDialogs({ setGenerateDialogState(nextActiveDialog); onActivate?.(); }, - [onActivate], + [onActivate, rejectContextMutation], ); const restoreCanvasGenerationDialogs = useCallback( @@ -242,7 +348,9 @@ export function useCanvasGenerationDialogs({ nextCounter, ); const activeDialog = - [...dialogs].reverse().find((dialog) => dialog.composerOpen !== false) ?? + [...dialogs] + .reverse() + .find((dialog) => dialog.composerOpen !== false) ?? dialogs[dialogs.length - 1] ?? null; const nextActiveDialog = activeDialog @@ -271,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 @@ -282,7 +397,7 @@ export function useCanvasGenerationDialogs({ setGenerateDialogState(nextActiveDialog); setInactiveGenerateDialogs(nextInactiveDialogs); }, - [], + [rejectContextMutation], ); const getGeneratingDialogPlaceholder = useCallback( @@ -318,6 +433,7 @@ export function useCanvasGenerationDialogs({ inactiveGenerateDialogsRef, activeCanvasGenerationDialog, canvasGenerationDialogs, + getCanvasGenerationDialogsSnapshot, archiveActiveCanvasGenerationDialog, openCanvasGenerationDialog, updateCanvasGenerationDialogById, 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/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index e71e4cf18..4ca2cc7b3 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -34,6 +34,7 @@ const resolveEditorImageReferenceDataUrlMock = vi.hoisted(() => vi.fn()); const resolveEditorImageReferenceDataUrlForGenerationMock = vi.hoisted(() => vi.fn(), ); +const uploadEditorMediaAssetObjectFileMock = vi.hoisted(() => vi.fn()); const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn()); const editEditorImageMock = vi.hoisted(() => vi.fn()); const extractEditorUiDesignAssetsMock = vi.hoisted(() => vi.fn()); @@ -74,6 +75,7 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => { }); vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({ + uploadEditorMediaAssetObjectFile: uploadEditorMediaAssetObjectFileMock, uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock, })); @@ -502,6 +504,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { beforeEach(() => { resolveEditorImageReferenceDataUrlMock.mockReset(); resolveEditorImageReferenceDataUrlForGenerationMock.mockReset(); + uploadEditorMediaAssetObjectFileMock.mockReset(); uploadEditorMediaAssetFileMock.mockReset(); editEditorImageMock.mockReset(); extractEditorUiDesignAssetsMock.mockReset(); @@ -521,8 +524,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { resolveEditorImageReferenceDataUrlForGenerationMock.mockImplementation( async (src: string) => src, ); - uploadEditorMediaAssetFileMock.mockResolvedValue({ - src: 'https://signed.example.test/generation-reference.png', + uploadEditorMediaAssetObjectFileMock.mockResolvedValue({ objectKey: 'generated-character-drafts/editor/generation-references/reference.png', assetObjectId: 'asset-object-generation-reference', @@ -553,16 +555,56 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { dateNowSpy.mockRestore(); } - expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledTimes(2); + expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledTimes(2); const [firstFile, , firstOptions] = - uploadEditorMediaAssetFileMock.mock.calls[0] ?? []; + uploadEditorMediaAssetObjectFileMock.mock.calls[0] ?? []; const [secondFile, , secondOptions] = - uploadEditorMediaAssetFileMock.mock.calls[1] ?? []; + uploadEditorMediaAssetObjectFileMock.mock.calls[1] ?? []; expect((firstFile as File).name).not.toBe((secondFile as File).name); expect(firstOptions.pathSegments).not.toEqual(secondOptions.pathSegments); }); + it('reuses a caller supplied upload id for the same inline operation', async () => { + const options = { uploadId: 'perfect-pixel-operation-1' }; + + await resolveEditorGenerationMediaReference( + { src: 'data:image/png;base64,YQ==' }, + 'image', + 'project-1', + options, + ); + await resolveEditorGenerationMediaReference( + { src: 'data:image/png;base64,YQ==' }, + 'image', + 'project-1', + options, + ); + + expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledTimes(2); + for (const [ + file, + mediaType, + uploadOptions, + ] of uploadEditorMediaAssetObjectFileMock.mock.calls) { + expect((file as File).name).toBe( + 'generation-reference-perfect-pixel-operation-1.png', + ); + expect(mediaType).toBe('image'); + expect(uploadOptions).toEqual( + expect.objectContaining({ + pathSegments: [ + 'editor', + 'generation-references', + 'project-1', + 'perfect-pixel-operation-1', + ], + }), + ); + } + }); + it('uploads image references without an object reference before generation', async () => { + const controller = new AbortController(); resolveEditorImageReferenceDataUrlMock.mockResolvedValueOnce( 'data:image/png;base64,ZXhhbXBsZQ==', ); @@ -570,23 +612,124 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { { src: '/creation-type-references/example.webp' }, 'image', 'project-1', + { + signal: controller.signal, + uploadId: 'perfect-pixel-operation-1', + }, ); expect(resolveEditorImageReferenceDataUrlMock).toHaveBeenCalledWith( '/creation-type-references/example.webp', + controller.signal, ); - expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith( + expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledWith( expect.any(File), 'image', expect.objectContaining({ assetKind: 'editor_generation_reference_image', + signal: controller.signal, }), ); + expect(uploadEditorMediaAssetFileMock).not.toHaveBeenCalled(); expect(result).toBe( 'generated-character-drafts/editor/generation-references/reference.png', ); }); + it('passes the abort signal through blob fetch and object registration', async () => { + const controller = new AbortController(); + const blobMock = vi + .fn() + .mockResolvedValue(new Blob(['video'], { type: 'video/mp4' })); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + blob: blobMock, + }); + vi.stubGlobal('fetch', fetchMock); + + try { + await resolveEditorGenerationMediaReference( + { src: 'blob:https://editor.example.test/reference-video' }, + 'video', + 'project-1', + { + signal: controller.signal, + uploadId: 'video-operation-1', + }, + ); + } finally { + vi.unstubAllGlobals(); + } + + expect(fetchMock).toHaveBeenCalledWith( + 'blob:https://editor.example.test/reference-video', + { signal: controller.signal }, + ); + expect(blobMock).toHaveBeenCalledTimes(1); + expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledWith( + expect.any(File), + 'video', + expect.objectContaining({ + pathSegments: [ + 'editor', + 'generation-references', + 'project-1', + 'video-operation-1', + ], + signal: controller.signal, + }), + ); + }); + + it('does not parse or upload an inline Data URL after cancellation', async () => { + const controller = new AbortController(); + controller.abort(new DOMException('已取消', 'AbortError')); + + await expect( + resolveEditorGenerationMediaReference( + { src: 'data:image/png;base64,YQ==' }, + 'image', + 'project-1', + { + signal: controller.signal, + uploadId: 'cancelled-operation', + }, + ), + ).rejects.toMatchObject({ name: 'AbortError' }); + + expect(uploadEditorMediaAssetObjectFileMock).not.toHaveBeenCalled(); + }); + + it('stops after image source parsing when cancellation wins the boundary', async () => { + const controller = new AbortController(); + let finishImageParsing!: (value: string) => void; + resolveEditorImageReferenceDataUrlMock.mockImplementationOnce( + () => + new Promise((resolve) => { + finishImageParsing = resolve; + }), + ); + + const resolution = resolveEditorGenerationMediaReference( + { src: '/creation-type-references/slow.webp' }, + 'image', + 'project-1', + { + signal: controller.signal, + uploadId: 'cancelled-after-parse', + }, + ); + controller.abort(new DOMException('已取消', 'AbortError')); + finishImageParsing('data:image/png;base64,YQ=='); + + await expect(resolution).rejects.toMatchObject({ name: 'AbortError' }); + expect(resolveEditorImageReferenceDataUrlMock).toHaveBeenCalledWith( + '/creation-type-references/slow.webp', + controller.signal, + ); + expect(uploadEditorMediaAssetObjectFileMock).not.toHaveBeenCalled(); + }); + it('submits quick edits and updates the source layer directly', async () => { editEditorImageMock.mockResolvedValueOnce( createGenerated({ @@ -741,9 +884,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { ); render( { ); }); await waitFor(() => { - expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith( + expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledWith( expect.any(File), 'image', expect.objectContaining({ @@ -1005,7 +1146,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { ); }); expect( - uploadEditorMediaAssetFileMock.mock.invocationCallOrder[0], + uploadEditorMediaAssetObjectFileMock.mock.invocationCallOrder[0], ).toBeLessThan(editEditorImageMock.mock.invocationCallOrder[0] ?? 0); expect(editEditorImageMock.mock.calls[0]?.[0]).not.toHaveProperty( 'referenceImageSrcs', @@ -1129,8 +1270,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { prompt: '角色换成蓝色披风', warning: { code: 'postprocess-failed-source-preserved', - reason: - '生成任务成功,后处理失败。', + reason: '生成任务成功,后处理失败。', }, }), ); @@ -1330,7 +1470,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); await waitFor(() => { - expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith( + expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledWith( expect.any(File), 'video', expect.objectContaining({ @@ -1864,7 +2004,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { ); }); expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled(); - expect(uploadEditorMediaAssetFileMock).not.toHaveBeenCalled(); + expect(uploadEditorMediaAssetObjectFileMock).not.toHaveBeenCalled(); }); it('refreshes the wallet and shows the warning after a queued character generation completes', async () => { @@ -1888,8 +2028,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { prompt: '队列角色生成', }), queueState: createQueueState({ - warning: - '生成任务成功,后处理失败。', + warning: '生成任务成功,后处理失败。', }), }); render( @@ -1937,7 +2076,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { ); }); - it('reloads a queued project snapshot again when the completion dialog is still unresolved', async () => { + it('reloads a queued project snapshot when a later duplicate completion dialog is unresolved', async () => { vi.useFakeTimers(); try { const applyProjectSnapshot = vi.fn(); @@ -1948,6 +2087,16 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { title: '队列项目', viewport: { x: 0, y: 0, scale: 1 }, layers: [ + { + layerId: 'generation-dialog-background-removal-completed', + resourceId: 'generation-dialog-background-removal-completed', + itemType: 'generation-dialog', + dialog: { + id: 'dialog-background-removal', + status: 'idle', + generatedLayerId: 'layer-background-removal-result', + }, + }, { layerId: 'generation-dialog-background-removal', resourceId: 'generation-dialog-background-removal', @@ -2025,8 +2174,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { await applyQueuedEditorGenerationProject( { queueState: createQueueState({ - warning: - '生成任务成功,后处理失败。', + warning: '生成任务成功,后处理失败。', }), }, 'editor-project-1', @@ -2301,7 +2449,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { ); }); expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled(); - expect(uploadEditorMediaAssetFileMock).not.toHaveBeenCalled(); + expect(uploadEditorMediaAssetObjectFileMock).not.toHaveBeenCalled(); }); it('submits icon spec objects without requiring an icon spec reference', async () => { @@ -2683,8 +2831,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { taskId: 'task-ui-assets', warning: { code: 'postprocess-failed-source-preserved', - reason: - '生成任务成功,后处理失败。', + reason: '生成任务成功,后处理失败。', }, }); render( @@ -2862,8 +3009,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { height: 768, warning: { code: 'postprocess-failed-source-preserved', - reason: - '生成任务成功,后处理失败。', + reason: '生成任务成功,后处理失败。', }, }), ); diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts index 29f2955c6..a37c74ad1 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts @@ -11,7 +11,7 @@ import { getExternalGenerationJobStatus } from '../../services/external-generati import { resolveEditorImageReferenceDataUrl } from '../../services/image-editor/editorImageReference'; import { type EditorMediaAssetUploadType, - uploadEditorMediaAssetFile, + uploadEditorMediaAssetObjectFile, } from '../../services/image-editor/editorMediaAssetUploadClient'; import type { EditorAssetSnapshot, @@ -30,6 +30,10 @@ import { generateEditorVideo, loadEditorProject, } from '../../services/image-editor/editorProjectClient'; +import { + findCanvasGenerationDialogRecords, + isUnresolvedCanvasGenerationDialogRecord, +} from './ImageCanvasEditorModel'; import type { CanvasGenerationDialogState, CanvasGenerationInputs, @@ -112,6 +116,12 @@ type EditorGenerationMediaReference = { type EditorGenerationMediaReferenceOptions = { allowRegisteredIds?: boolean; requireImageObjectReference?: boolean; + // 中文注释:需要 unknown 重放的同步操作由调用方传入稳定 id;普通入口不传时仍为每次 + // 上传生成随机路径,避免并发参考图互相覆盖。 + uploadId?: string; + // 中文注释:由调用方的阶段预算驱动,并贯穿源读取、Data URL 转换以及 + // ticket → PUT → confirm,不能只在最外层停止 await。 + signal?: AbortSignal; }; let editorGenerationUploadFallbackCounter = 0; @@ -149,7 +159,16 @@ function resolveEditorGenerationMediaReferenceSource( ); } -function dataUrlToEditorGenerationFile(dataUrl: string, fileName: string) { +function throwIfEditorGenerationMediaUploadAborted(signal?: AbortSignal) { + signal?.throwIfAborted(); +} + +function dataUrlToEditorGenerationFile( + dataUrl: string, + fileName: string, + signal?: AbortSignal, +) { + throwIfEditorGenerationMediaUploadAborted(signal); const [header = '', payload = ''] = dataUrl.split(','); const mimeMatch = /^data:([^;]+)(;base64)?$/iu.exec(header); if (!mimeMatch) { @@ -157,10 +176,12 @@ function dataUrlToEditorGenerationFile(dataUrl: string, fileName: string) { } const type = mimeMatch[1] ?? 'image/png'; const binary = mimeMatch[2] ? atob(payload) : decodeURIComponent(payload); + throwIfEditorGenerationMediaUploadAborted(signal); const bytes = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index += 1) { bytes[index] = binary.charCodeAt(index); } + throwIfEditorGenerationMediaUploadAborted(signal); return new File([bytes], fileName, { type }); } @@ -168,18 +189,22 @@ async function inlineMediaSourceToEditorGenerationFile( source: string, mediaType: EditorMediaAssetUploadType, uploadId: string, + signal?: AbortSignal, ) { + throwIfEditorGenerationMediaUploadAborted(signal); const fileName = `generation-reference-${uploadId}.${ mediaType === 'video' ? 'mp4' : mediaType === 'audio' ? 'mp3' : 'png' }`; if (/^data:/iu.test(source)) { - return dataUrlToEditorGenerationFile(source, fileName); + return dataUrlToEditorGenerationFile(source, fileName, signal); } - const response = await fetch(source); + const response = await fetch(source, { signal }); + throwIfEditorGenerationMediaUploadAborted(signal); if (!response.ok) { throw new Error('读取本地生成参考素材失败'); } const blob = await response.blob(); + throwIfEditorGenerationMediaUploadAborted(signal); return new File([blob], fileName, { type: blob.type || `${mediaType}/*`, }); @@ -189,11 +214,19 @@ async function uploadEditorGenerationInlineMediaSource( source: string, mediaType: EditorMediaAssetUploadType, projectId?: string | null, + signal?: AbortSignal, + stableUploadId?: string | null, ) { const normalizedProjectId = projectId?.trim() || 'unscoped'; - const uploadId = createEditorGenerationMediaUploadId(); - const uploaded = await uploadEditorMediaAssetFile( - await inlineMediaSourceToEditorGenerationFile(source, mediaType, uploadId), + const uploadId = + stableUploadId?.trim() || createEditorGenerationMediaUploadId(); + const uploaded = await uploadEditorMediaAssetObjectFile( + await inlineMediaSourceToEditorGenerationFile( + source, + mediaType, + uploadId, + signal, + ), mediaType, { assetKind: `editor_generation_reference_${mediaType}`, @@ -204,6 +237,7 @@ async function uploadEditorGenerationInlineMediaSource( uploadId, ], entityId: normalizedProjectId, + signal, ...(projectId?.trim() ? { metadata: { editor_project_id: projectId.trim() } } : {}), @@ -218,6 +252,7 @@ export async function resolveEditorGenerationMediaReference( projectId?: string | null, options: EditorGenerationMediaReferenceOptions = {}, ) { + throwIfEditorGenerationMediaUploadAborted(options.signal); const resourceId = reference.resourceId?.trim(); const hasRegisteredReference = options.allowRegisteredIds !== false && @@ -238,13 +273,17 @@ export async function resolveEditorGenerationMediaReference( if (!inlineSource && !imageSourceRequiresUpload) { return source; } - const uploadSource = imageSourceRequiresUpload && !inlineSource - ? await resolveEditorImageReferenceDataUrl(source) - : source; + const uploadSource = + imageSourceRequiresUpload && !inlineSource + ? await resolveEditorImageReferenceDataUrl(source, options.signal) + : source; + throwIfEditorGenerationMediaUploadAborted(options.signal); return uploadEditorGenerationInlineMediaSource( uploadSource, mediaType, projectId, + options.signal, + options.uploadId, ); } @@ -421,25 +460,9 @@ function projectHasUnresolvedGenerationDialog( project: EditorProjectSnapshot, dialogId: string | null | undefined, ) { - const normalizedDialogId = dialogId?.trim(); - if (!normalizedDialogId) { - return false; - } - return project.layers.some((item) => { - if (item.itemType !== 'generation-dialog') { - return false; - } - const dialog = - item.dialog && typeof item.dialog === 'object' - ? (item.dialog as Record) - : null; - return ( - dialog?.id === normalizedDialogId && - (dialog.status === 'generating' || - typeof dialog.generatedLayerId !== 'string' || - dialog.generatedLayerId.trim() === '') - ); - }); + return findCanvasGenerationDialogRecords(project, dialogId).some( + isUnresolvedCanvasGenerationDialogRecord, + ); } function notifyWalletBalanceMayHaveChanged(callback?: () => void) { diff --git a/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx b/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx index fab101b69..7d60180ee 100644 --- a/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSurface.test.tsx @@ -152,6 +152,7 @@ function GenerationSurfaceHarness() { activeCanvasGenerationDialog: activeCanvasDialog, canvasGenerationDialogs: dialogs.canvasGenerationDialogs, openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog, + activateCanvasGenerationDialog: dialogs.activateCanvasGenerationDialog, updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById, hasCanvasGenerationDialogById: dialogs.hasCanvasGenerationDialogById, archiveActiveCanvasGenerationDialog: diff --git a/src/components/image-editor/useImageCanvasGenerationSurface.tsx b/src/components/image-editor/useImageCanvasGenerationSurface.tsx index ff81bc9aa..76c0cfa90 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; @@ -74,6 +75,9 @@ type ImageCanvasGenerationSurfaceOptions = { openCanvasGenerationDialog: ( dialog: Omit, ) => string; + activateCanvasGenerationDialog: ( + targetDialog: CanvasGenerationDialogState, + ) => void; updateCanvasGenerationDialogById: ( dialogId: string, updater: CanvasGenerationDialogUpdater, @@ -106,6 +110,13 @@ type ImageCanvasGenerationSurfaceOptions = { project: EditorProjectSnapshot, action?: CanvasHistoryAction, ) => void; + applyProjectSnapshotWithoutHistory?: ( + project: EditorProjectSnapshot, + ) => boolean | void; + flushProjectPersistence?: (options?: { + preferLatestGenerationDialogs?: boolean; + }) => Promise; + refreshAssetLibrary?: () => Promise | void; onWalletBalanceMayHaveChanged?: () => void; }; @@ -156,11 +167,13 @@ export function useImageCanvasGenerationSurface({ iconSpecButtonRef, generationReferenceButtonRef, publicationReferenceButtonRef, + hasPendingImageReferenceUploads = false, generateDialog, setGenerateDialog, activeCanvasGenerationDialog, canvasGenerationDialogs, openCanvasGenerationDialog, + activateCanvasGenerationDialog, updateCanvasGenerationDialogById, hasCanvasGenerationDialogById, archiveActiveCanvasGenerationDialog, @@ -182,6 +195,9 @@ export function useImageCanvasGenerationSurface({ assetFolderId, upsertGeneratedAsset, applyProjectSnapshot, + applyProjectSnapshotWithoutHistory, + flushProjectPersistence, + refreshAssetLibrary, onWalletBalanceMayHaveChanged, }: ImageCanvasGenerationSurfaceOptions) { const toolbarOptionCloseTimerRef = useRef void generationWorkflow.submitCharacterAnimation() } + onRetryPerfectPixelOperation={(dialogId) => + void generationWorkflow.retryPerfectPixelOperation(dialogId) + } onUpdateSpecFormValue={generationWorkflow.updateSpecFormValue} onUpdateIconDescriptionText={ generationWorkflow.updateIconDescriptionsText diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index a73718193..7d489de68 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -7,9 +7,18 @@ import { screen, waitFor, } from '@testing-library/react'; -import { useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ApiClientError } from '../../services/apiClient'; +import type { + EditorPixelArtSnapInput, + EditorProjectSnapshot, +} from '../../services/image-editor/editorProjectClient'; +import { + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS, + splitCanvasLayoutItems, +} from './ImageCanvasEditorModel'; import type { CanvasGenerationDialogState, CanvasLayer, @@ -19,8 +28,15 @@ import type { SidebarPanel, } from './ImageCanvasEditorTypes'; import { IMAGE_MODEL_GPT_IMAGE_2 } from './ImageCanvasGenerationModel'; +import { + readPerfectPixelOperations, + savePerfectPixelOperation, +} from './perfectPixelOperationStore'; import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs'; -import { useImageCanvasGenerationWorkflow } from './useImageCanvasGenerationWorkflow'; +import { + inspectPerfectPixelProjectSnapshot, + useImageCanvasGenerationWorkflow, +} from './useImageCanvasGenerationWorkflow'; const generateEditorImageMock = vi.hoisted(() => vi.fn()); const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn()); @@ -30,9 +46,12 @@ const generateEditorBackgroundMusicMock = vi.hoisted(() => vi.fn()); const editEditorImageMock = vi.hoisted(() => vi.fn()); const createEditorProjectResourceMock = vi.hoisted(() => vi.fn()); const splitEditorIconSpritesheetMock = vi.hoisted(() => vi.fn()); +const uploadEditorMediaAssetObjectFileMock = vi.hoisted(() => vi.fn()); const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn()); const renderCropExpandImageMock = vi.hoisted(() => vi.fn()); const removeImageBackgroundMock = vi.hoisted(() => vi.fn()); +const snapImageToPerfectPixelsMock = vi.hoisted(() => vi.fn()); +const loadEditorProjectMock = vi.hoisted(() => vi.fn()); const resolveEditorImageReferenceDataUrlMock = vi.hoisted(() => vi.fn()); vi.mock('../../services/image-editor/editorImageReference', async () => { @@ -61,11 +80,13 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => { generateEditorIconSpritesheet: generateEditorIconSpritesheetMock, generateEditorImage: generateEditorImageMock, generateEditorSoundEffect: generateEditorSoundEffectMock, + loadEditorProject: loadEditorProjectMock, splitEditorIconSpritesheet: splitEditorIconSpritesheetMock, }; }); vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({ + uploadEditorMediaAssetObjectFile: uploadEditorMediaAssetObjectFileMock, uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock, })); @@ -77,6 +98,7 @@ vi.mock('./ImageCanvasRasterEditModel', async () => { ...actual, removeImageBackground: removeImageBackgroundMock, renderCropExpandImage: renderCropExpandImageMock, + snapImageToPerfectPixels: snapImageToPerfectPixelsMock, }; }); @@ -113,20 +135,241 @@ function createGenerated(overrides = {}) { }; } +function createPerfectPixelResource(operationId: string) { + return { + resourceId: `resource-${operationId}`, + projectId: 'project-1', + imageSrc: `/generated-images/editor/${operationId}.png`, + objectKey: `generated-images/editor/${operationId}.png`, + assetObjectId: `asset-object-${operationId}`, + width: 1024, + height: 768, + sourceType: 'generated' as const, + taskId: `pixel-art-snap-${operationId}`, + }; +} + +function createPerfectPixelAsset(operationId: string) { + return { + assetId: `asset-${operationId}`, + folderId: 'default', + label: '源图 · 完美像素', + imageSrc: `/generated-images/editor/${operationId}.png`, + objectKey: `generated-images/editor/${operationId}.png`, + assetObjectId: `asset-object-${operationId}`, + width: 1024, + height: 768, + sourceType: 'generated' as const, + taskId: `pixel-art-snap-${operationId}`, + }; +} + +function createPerfectPixelProject( + operationId: string, + state: 'pending' | 'applied' | 'dialog-missing', +): EditorProjectSnapshot { + const resource = createPerfectPixelResource(operationId); + const generatedLayerId = `layer-${operationId}`; + const dialogLayer = { + itemType: 'generation-dialog', + layerId: `generation-dialog:${operationId}`, + resourceId: `generation-dialog:${operationId}`, + dialog: { + id: operationId, + mode: 'quick-edit', + prompt: '完美像素', + status: state === 'pending' ? 'generating' : 'idle', + composerOpen: false, + // 中文注释:服务端完成 completion 时只做字段级改写(status/composerOpen/ + // generatedLayerId/errorMessage),从不摘掉 perfectPixelOperationId。fixture 必须 + // 保留这个标记,否则「收口态占位 + 账本已清」这条真实形状永远测不到。 + perfectPixelOperationId: operationId, + ...(state === 'applied' ? { generatedLayerId } : {}), + }, + }; + return { + projectId: 'project-1', + title: '未命名画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: + state === 'dialog-missing' + ? [] + : [ + ...(state === 'applied' + ? [ + { + itemType: 'image', + layerId: generatedLayerId, + resourceId: resource.resourceId, + }, + ] + : []), + dialogLayer, + ], + resources: state === 'pending' ? [] : [resource], + updatedAt: '2026-08-03T00:00:00.000Z', + }; +} + +function createEmptyPerfectPixelProject(): EditorProjectSnapshot { + return { + projectId: 'project-1', + title: '未命名画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-08-03T00:00:00.000Z', + }; +} + +function createConflictingPerfectPixelProject( + operationId: string, +): EditorProjectSnapshot { + const project = createPerfectPixelProject(operationId, 'applied'); + return { + ...project, + resources: [ + ...project.resources, + { + ...createPerfectPixelResource(operationId), + resourceId: `resource-duplicate-${operationId}`, + }, + ], + }; +} + +function createPerfectPixelResult( + request: EditorPixelArtSnapInput, + project: EditorProjectSnapshot | null, +) { + const operationId = request.canvasCompletion.dialogId; + const resource = createPerfectPixelResource(operationId); + return { + imageSrc: resource.imageSrc, + objectKey: resource.objectKey, + assetObjectId: resource.assetObjectId, + width: resource.width, + height: resource.height, + sourceType: 'generated' as const, + taskId: resource.taskId, + elapsedMs: 250, + provider: 'Genarrative' as const, + resource, + asset: createPerfectPixelAsset(operationId), + project, + }; +} + +function createMismatchedPerfectPixelResult(request: EditorPixelArtSnapInput) { + const result = createPerfectPixelResult(request, null); + return { + ...result, + taskId: 'unexpected-task', + resource: { + ...result.resource, + taskId: 'unexpected-task', + }, + }; +} + +function createHydratedPerfectPixelDialog({ + operationId, + projectId = 'project-1', + status = 'generating', + reconcileUntil = Date.now() + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS, +}: { + operationId: string; + projectId?: string; + status?: CanvasGenerationDialogState['status']; + reconcileUntil?: number; +}): CanvasGenerationDialogState { + const request: EditorPixelArtSnapInput = { + sourceImageSrc: 'generated-images/editor/source.png', + projectId, + sourceResourceId: 'resource-source', + assetLabel: '源图 · 完美像素', + canvasCompletion: { + dialogId: operationId, + title: '源图 · 完美像素', + placeholder: { + x: 60, + y: -132, + width: 320, + height: 240, + originalWidth: 320, + originalHeight: 240, + }, + }, + }; + return { + id: operationId, + mode: 'quick-edit', + prompt: '完美像素', + assetLabel: '源图 · 完美像素', + status, + composerOpen: false, + sourceLayerId: 'layer-source', + placeholder: { ...request.canvasCompletion.placeholder }, + perfectPixelOperation: { + version: 1, + kind: 'perfect-pixel', + operationId, + taskId: `pixel-art-snap-${operationId}`, + request, + submittedAt: Date.now() - 1_000, + reconcileUntil, + }, + }; +} + +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + function GenerationWorkflowHarness({ initialLayers = [createLayer()], + initialCanvasGenerationDialogs, initialViewport = { x: 10, y: 20, scale: 2 }, projectId, currentUserId, + hasPendingImageReferenceUploads = false, applyProjectSnapshot, + applyProjectSnapshotWithoutHistory, + flushProjectPersistence, + onFlushProjectPersistenceSnapshot, + refreshAssetLibrary, + upsertGeneratedAsset, }: { initialLayers?: CanvasLayer[]; + initialCanvasGenerationDialogs?: CanvasGenerationDialogState[]; initialViewport?: { x: number; y: number; scale: number }; projectId?: string; currentUserId?: string; + hasPendingImageReferenceUploads?: boolean; applyProjectSnapshot?: Parameters< typeof useImageCanvasGenerationWorkflow >[0]['applyProjectSnapshot']; + applyProjectSnapshotWithoutHistory?: Parameters< + typeof useImageCanvasGenerationWorkflow + >[0]['applyProjectSnapshotWithoutHistory']; + refreshAssetLibrary?: Parameters< + typeof useImageCanvasGenerationWorkflow + >[0]['refreshAssetLibrary']; + flushProjectPersistence?: Parameters< + typeof useImageCanvasGenerationWorkflow + >[0]['flushProjectPersistence']; + onFlushProjectPersistenceSnapshot?: ( + dialogs: CanvasGenerationDialogState[], + ) => void; + upsertGeneratedAsset?: Parameters< + typeof useImageCanvasGenerationWorkflow + >[0]['upsertGeneratedAsset']; }) { const [layers, setLayers] = useState(initialLayers); const [viewport, setViewport] = useState(initialViewport); @@ -144,6 +387,17 @@ function GenerationWorkflowHarness({ const fitLayersMockRef = useRef(vi.fn()); const layerCounterRef = useRef(0); const dialogs = useCanvasGenerationDialogs(); + const restoredInitialDialogsRef = useRef(false); + useEffect(() => { + if ( + restoredInitialDialogsRef.current || + !initialCanvasGenerationDialogs?.length + ) { + return; + } + restoredInitialDialogsRef.current = true; + dialogs.restoreCanvasGenerationDialogs(initialCanvasGenerationDialogs); + }, [dialogs, initialCanvasGenerationDialogs]); const activeDialogRef = useRef(null); const workflow = useImageCanvasGenerationWorkflow({ layers, @@ -155,7 +409,9 @@ function GenerationWorkflowHarness({ layerCounterRef, generateDialog: dialogs.generateDialog, setGenerateDialog: dialogs.setGenerateDialog, + hasPendingImageReferenceUploads, openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog, + activateCanvasGenerationDialog: dialogs.activateCanvasGenerationDialog, updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById, hasCanvasGenerationDialogById: dialogs.hasCanvasGenerationDialogById, removeCanvasGenerationDialogsByLayerId: @@ -175,6 +431,17 @@ function GenerationWorkflowHarness({ projectId, currentUserId, applyProjectSnapshot, + applyProjectSnapshotWithoutHistory, + flushProjectPersistence: flushProjectPersistence + ? (options) => { + onFlushProjectPersistenceSnapshot?.( + dialogs.getCanvasGenerationDialogsSnapshot(), + ); + return flushProjectPersistence(options); + } + : undefined, + refreshAssetLibrary, + upsertGeneratedAsset, }); const activeDialog = dialogs.generateDialog; @@ -219,6 +486,9 @@ function GenerationWorkflowHarness({ : '-'} {activeDialog?.prompt || '-'} + + {activeDialog?.errorMessage || '-'} + {activeDialog ? `${activeDialog.mode}:${activeDialog.imageModel ?? '-'}:${activeDialog.aspectRatio ?? '-'}:${activeDialog.imageSize ?? '-'}` @@ -232,6 +502,20 @@ function GenerationWorkflowHarness({ ) .join('|') || '-'} + + {dialogs.canvasGenerationDialogs + .filter((dialog) => dialog.requiresLiveSession === true) + .map((dialog) => dialog.id) + .join('|') || '-'} + + + {activeDialog?.perfectPixelOperation + ? JSON.stringify(activeDialog.perfectPixelOperation) + : '-'} + + + {activeDialog?.perfectPixelOperationInvalid === true ? 'invalid' : '-'} + {activeDialog?.generationReferences ?.map((reference) => reference.label) @@ -249,6 +533,9 @@ function GenerationWorkflowHarness({ {workflow.generationWarning ?? '-'} + + {workflow.isPickingGenerationReferenceFromCanvas ? 'picking' : 'idle'} + {workflow.quickEditPanel ? `${workflow.quickEditPanel.sourceLayerId}:${workflow.quickEditPanel.status}:${workflow.quickEditPanel.prompt || '-'}` @@ -292,6 +579,9 @@ function GenerationWorkflowHarness({ {fitLayersMockRef.current.mock.calls.length} {workflow.taskListRefreshKey} + + {workflow.perfectPixelLayerIds.has(layers[0]!.id) ? '处理中' : '空闲'} + @@ -529,6 +819,56 @@ function GenerationWorkflowHarness({ > 去除背景 + + + + + + + + + + + ); +} + describe('useImageCanvasProjectPersistence', () => { afterEach(() => { vi.useRealTimers(); @@ -822,6 +1002,310 @@ describe('useImageCanvasProjectPersistence', () => { }); }); + it('migrates a legacy inline ledger on a failed placeholder that exact retry still accepts', async () => { + // 中文注释:`failed + perfectPixelOperation` 是旧严格保存失败的合法持久化形状——请求 + // 已备好但 POST 从未发出,`retryPerfectPixelOperation` 明确接受该状态,面板上的 + // 「重试同一完美像素操作」也只在它带 operation 时才出现。按状态白名单列举会把这批漏掉: + // 下一次保存剥成 marker 后再加载就是 failed + invalid,重试按钮消失,用户只剩删掉重做 + // ——而那正是新 identity,正是 exact retry 要避免的重复。 + window.localStorage.clear(); + const dialogId = 'generation-dialog-legacy-prepared-failure'; + const submittedAt = Date.now(); + const legacyOperation = { + version: 1 as const, + kind: 'perfect-pixel' as const, + operationId: dialogId, + taskId: `pixel-art-snap-${dialogId}`, + request: { + sourceImageSrc: 'generated-images/editor/legacy-prepared.png', + projectId: 'editor-project-default', + assetLabel: '源图 · 完美像素', + canvasCompletion: { + dialogId, + title: '源图 · 完美像素', + placeholder: { ...STRICT_PLACEHOLDER }, + }, + }, + submittedAt, + reconcileUntil: submittedAt + 75_000, + }; + const legacyDialogItem = { + itemType: 'generation-dialog', + layerId: `generation-dialog:${dialogId}`, + resourceId: `generation-dialog:${dialogId}`, + dialog: { + id: dialogId, + mode: 'quick-edit', + prompt: '完美像素', + // 中文注释:失败但未收口——没有 generatedLayerId,账本仍是它唯一的重试凭据。 + status: 'failed', + composerOpen: false, + errorMessage: '完美像素请求尚未发出:提交前画布保存失败', + perfectPixelOperation: legacyOperation, + placeholder: { ...STRICT_PLACEHOLDER }, + }, + } as unknown as EditorProjectLayerSnapshot; + loadOrCreateRecentEditorProjectMock.mockResolvedValue({ + projectId: 'editor-project-default', + title: '空画布项目', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [legacyDialogItem], + revision: 4, + layoutStorageVersion: 0, + updatedAt: '2026-08-05T00:00:00.000Z', + }, + viewport: { x: 0, y: 0, scale: 1 }, + layers: [legacyDialogItem], + resources: [], + updatedAt: '2026-08-05T00:00:00.000Z', + }); + + render(); + await waitFor(() => { + expect(screen.getByTestId('strict-project-id').textContent).toBe( + 'editor-project-default', + ); + }); + + await waitFor(() => { + expect( + readPerfectPixelOperations('user-test', 'editor-project-default').size, + ).toBe(1); + }); + expect( + readPerfectPixelOperations('user-test', 'editor-project-default').get( + dialogId, + ), + ).toEqual(legacyOperation); + }); + + it('migrates a legacy inline perfect-pixel ledger into local storage on load', async () => { + // 中文注释:布局内联账本是账本移出布局之前的 legacy 形状。第一次 hydrate 还能认它,但 + // 下一次保存会把它剥成 perfectPixelOperationId 标记,此后再没有路径能补写本机账本—— + // 不迁移的话,部署那一刻仍在途的操作会在第二次加载变成 failed + invalid,永久失去 + // exact retry 的 identity。 + window.localStorage.clear(); + const dialogId = 'generation-dialog-legacy-inflight'; + const submittedAt = Date.now(); + const legacyOperation = { + version: 1 as const, + kind: 'perfect-pixel' as const, + operationId: dialogId, + taskId: `pixel-art-snap-${dialogId}`, + request: { + sourceImageSrc: 'generated-images/editor/legacy-source.png', + projectId: 'editor-project-default', + assetLabel: '源图 · 完美像素', + canvasCompletion: { + dialogId, + title: '源图 · 完美像素', + placeholder: { ...STRICT_PLACEHOLDER }, + }, + }, + submittedAt, + reconcileUntil: submittedAt + 75_000, + }; + const legacyDialogItem = { + itemType: 'generation-dialog', + layerId: `generation-dialog:${dialogId}`, + resourceId: `generation-dialog:${dialogId}`, + dialog: { + id: dialogId, + mode: 'quick-edit', + prompt: '完美像素', + status: 'pending-confirmation', + composerOpen: false, + sourceLayerId: 'layer-source', + perfectPixelOperation: legacyOperation, + placeholder: { ...STRICT_PLACEHOLDER }, + }, + } as unknown as EditorProjectLayerSnapshot; + loadOrCreateRecentEditorProjectMock.mockResolvedValue({ + projectId: 'editor-project-default', + title: '空画布项目', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [legacyDialogItem], + revision: 3, + layoutStorageVersion: 0, + updatedAt: '2026-08-05T00:00:00.000Z', + }, + viewport: { x: 0, y: 0, scale: 1 }, + layers: [legacyDialogItem], + resources: [], + updatedAt: '2026-08-05T00:00:00.000Z', + }); + + render(); + await waitFor(() => { + expect(screen.getByTestId('strict-project-id').textContent).toBe( + 'editor-project-default', + ); + }); + + await waitFor(() => { + expect( + readPerfectPixelOperations('user-test', 'editor-project-default').size, + ).toBe(1); + }); + const migrated = readPerfectPixelOperations( + 'user-test', + 'editor-project-default', + ).get(dialogId); + expect(migrated).toEqual(legacyOperation); + + // 中文注释:往返验证——布局再保存一次只剩标记,此时只有本机账本能把它救回来。 + const strippedLayout = serializeCanvasLayout({ + layers: [], + canvasGenerationDialogs: [ + { + id: dialogId, + mode: 'quick-edit', + prompt: '完美像素', + status: 'pending-confirmation', + composerOpen: false, + perfectPixelOperationId: dialogId, + perfectPixelOperation: migrated, + } as unknown as CanvasGenerationDialogState, + ], + }); + expect(JSON.stringify(strippedLayout)).not.toContain( + '"perfectPixelOperation"', + ); + const { generationDialogs } = splitCanvasLayoutItems( + strippedLayout, + new Map(), + 'user-test', + readPerfectPixelOperations('user-test', 'editor-project-default'), + ); + expect(generationDialogs[0]).toMatchObject({ + id: dialogId, + status: 'pending-confirmation', + }); + expect(generationDialogs[0]?.perfectPixelOperation).toEqual( + legacyOperation, + ); + expect(generationDialogs[0]).not.toHaveProperty( + 'perfectPixelOperationInvalid', + ); + }); + + it('persists the perfect-pixel marker without leaking the request ledger into the layout', async () => { + render(); + await waitFor(() => { + expect(screen.getByTestId('strict-project-id').textContent).toBe( + 'editor-project-default', + ); + }); + + act(() => { + screen + .getByRole('button', { name: 'open and flush perfect pixel' }) + .click(); + }); + + await waitFor(() => { + expect(saveEditorProjectLayoutMock).toHaveBeenCalled(); + }); + const [, layoutInput] = saveEditorProjectLayoutMock.mock.calls[0] as [ + string, + { layers: unknown[] }, + ]; + const dialogItem = layoutInput.layers.find( + (item) => + (item as { layerId?: string }).layerId === + `generation-dialog:${STRICT_DIALOG_ID}`, + ); + // 中文注释:`preferLatestGenerationDialogs` 让刚创建的占位不会因为 ref 落后一帧而漏存。 + expect(dialogItem).toBeTruthy(); + expect( + (dialogItem as { dialog: Record }).dialog, + ).toMatchObject({ + id: STRICT_DIALOG_ID, + status: 'generating', + perfectPixelOperationId: STRICT_DIALOG_ID, + }); + // 中文注释:请求账本只在本机,布局里连源图地址都不该出现。 + expect(JSON.stringify(layoutInput.layers)).not.toContain( + '"perfectPixelOperation"', + ); + expect(JSON.stringify(layoutInput.layers)).not.toContain('resource-source'); + expect(saveEditorProjectLayoutMock).toHaveBeenNthCalledWith( + 1, + 'editor-project-default', + expect.anything(), + ); + }); + + it.each([ + ['400', 400], + ['403', 403], + ])( + 'never blocks the caller when layout persistence fails with HTTP %s', + async (_label, status) => { + saveEditorProjectLayoutMock.mockRejectedValue( + new ApiClientError({ + message: '布局保存被拒绝', + status, + code: 'layout_rejected', + }), + ); + + render(); + await waitFor(() => { + expect(screen.getByTestId('strict-project-id').textContent).toBe( + 'editor-project-default', + ); + }); + + act(() => { + screen + .getByRole('button', { name: 'open and flush perfect pixel' }) + .click(); + }); + + // 中文注释:阶段 3 的核心断言——布局保存失败只是画布没同步上去,绝不能把下游 + // 完美像素 POST 拦下来。账本已经先落本机,请求仍然可追溯。 + await waitFor(() => { + expect(screen.getByTestId('strict-save-status').textContent).toBe( + 'resolved', + ); + }); + expect( + screen.getByTestId('strict-downstream-post-count').textContent, + ).toBe('1'); + }, + ); + + it('never blocks the caller when the project has no authority', async () => { + render( + , + ); + + act(() => { + screen + .getByRole('button', { name: 'open and flush perfect pixel' }) + .click(); + }); + + await waitFor(() => { + expect(screen.getByTestId('strict-save-status').textContent).toBe( + 'resolved', + ); + }); + expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled(); + expect(screen.getByTestId('strict-downstream-post-count').textContent).toBe( + '1', + ); + }); + it('saves appended layers with the server resource id immediately after resource creation', async () => { render(); @@ -2043,7 +2527,12 @@ describe('useImageCanvasProjectPersistence', () => { ); }); - it('flushes the latest layout and waits for the local cover cache before returning', async () => { + it('flushes the latest layout without waiting for the cover snapshot chain', async () => { + // 中文注释:flush 曾经 `await` 封面持久化,于是每个 await flush 的调用方(完美像素提交、 + // 人工重试、图集拆分)都被挂在封面链后面。封面渲染要为每个可绘制图层取 signed URL 再 + // `new Image()` 加载,而那个 Image 没有 timeout 也没有 AbortSignal——一张图不 settle, + // 生成 POST 就永远发不出去。await flush 的调用方要的只是「布局已持久化」这一个前置, + // 封面与它无关。本用例钉住:封面仍然被发起,但 flush 不等它。 const coverBlob = new Blob(['cover'], { type: 'image/webp' }); const coverCacheWrite = createDeferred(); createProjectCoverSnapshotBlobMock.mockResolvedValue(coverBlob); @@ -2083,19 +2572,52 @@ describe('useImageCanvasProjectPersistence', () => { blob: coverBlob, }); }); - expect(screen.getByTestId('flush-completed').textContent).toBe('false'); - coverCacheWrite.resolve(); + // 中文注释:封面缓存写入仍然挂着,flush 必须已经返回——这正是删除 `await coverSave` + // 要钉住的行为。 await waitFor(() => { expect(screen.getByTestId('flush-completed').textContent).toBe('true'); }); - expect(uploadEditorMediaAssetFileMock).toHaveBeenCalled(); + + // 中文注释:不等 ≠ 不做。封面链继续跑到底,上传与资源登记照常发生。 + coverCacheWrite.resolve(); + await waitFor(() => { + expect(uploadEditorMediaAssetFileMock).toHaveBeenCalled(); + }); expect(createEditorProjectResourceMock).toHaveBeenCalledWith( 'editor-project-default', expect.objectContaining({ assetKind: 'project-cover-snapshot' }), ); }); + it('returns from flush even when the cover snapshot never settles', async () => { + // 中文注释:真实渲染器里的 `new Image()` 无 timeout / 无 AbortSignal,一张图既不 load + // 也不 error 时,封面链的 Promise 永不 settle。用永不 resolve 的 blob 模拟该形状—— + // 若 flush 仍 `await` 封面,本用例会挂到超时。 + createProjectCoverSnapshotBlobMock.mockReturnValue(new Promise(() => {})); + + render(); + + expect(await screen.findByText('editor-project-default')).toBeTruthy(); + act(() => { + screen.getByRole('button', { name: 'append server resource' }).click(); + screen.getByRole('button', { name: 'move viewport' }).click(); + }); + await waitFor(() => { + expect(screen.getByTestId('viewport').textContent).toBe('10,5,0.5'); + }); + + act(() => { + screen.getByRole('button', { name: 'flush project persistence' }).click(); + }); + + await waitFor(() => { + expect(screen.getByTestId('flush-completed').textContent).toBe('true'); + }); + expect(createProjectCoverSnapshotBlobMock).toHaveBeenCalled(); + expect(putEditorProjectCoverCacheMock).not.toHaveBeenCalled(); + }); + it('loads and saves viewport scale with display zoom semantics', async () => { render(); diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.ts b/src/components/image-editor/useImageCanvasProjectPersistence.ts index 9d94a4fb0..f4e1509c8 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.ts +++ b/src/components/image-editor/useImageCanvasProjectPersistence.ts @@ -23,8 +23,10 @@ import { canvasDisplayViewportToViewport, type CanvasLayerResourceMetadata, DEFAULT_CANVAS_BACKGROUND_COLOR, + dropDeadInlineGenerationPlaceholders, hydrateLayer, isInlineEditorMediaSource, + isUnresolvedCanvasGenerationDialogRecord, resolveLayerResourceAssetKind, serializeCanvasLayout, splitCanvasLayoutItems, @@ -48,6 +50,10 @@ import { firstSelectedLayerId, normalizeCanvasSelectionIds, } from './ImageCanvasSelectionModel'; +import { + readPerfectPixelOperations, + savePerfectPixelOperation, +} from './perfectPixelOperationStore'; type ProjectResourceOptions = { onCreated?: (resourceId: string) => void; @@ -201,6 +207,7 @@ type ImageCanvasProjectPersistenceRefs = { layersRef: RefObject; viewportRef: RefObject; canvasGenerationDialogsRef: RefObject; + getCanvasGenerationDialogsSnapshot?: () => CanvasGenerationDialogState[]; canvasBackgroundColorRef: RefObject; selectedLayerIdRef: RefObject; selectedLayerIdsRef: RefObject; @@ -551,6 +558,10 @@ export function useImageCanvasProjectPersistence({ const coverSnapshotViewportSizeRef = useRef(canvasSize); const [projectId, setProjectId] = useState(null); const [isProjectReady, setIsProjectReady] = useState(false); + // 中文注释:累计而不是布尔——同一次会话里可能连着切换多个项目,每个都可能留有孤儿占位, + // 布尔只会提示一次。调用方以计数变化为触发条件。 + const [deadInlinePlaceholderDropCount, setDeadInlinePlaceholderDropCount] = + useState(0); const { setProjectTitle, setProjectRenameValue, @@ -613,18 +624,25 @@ export function useImageCanvasProjectPersistence({ hasAuthoritativeProjectSnapshotRef.current && authoritativeProjectIdRef.current === pendingSave.projectId; let runNextSave = false; - const savePromise = saveEditorProjectLayout(pendingSave.projectId, { + const saveInput = { ...pendingSave.input, expectedRevision, - }) + }; + const savePromise = saveEditorProjectLayout( + pendingSave.projectId, + saveInput, + ) .then((result) => { + const acknowledgedRevision = + result && typeof result.revision === 'number' + ? result.revision + : null; if ( saveStillBelongsToCurrentAuthority() && - result && - typeof result.revision === 'number' && - result.revision > (projectRevisionRef.current ?? -1) + acknowledgedRevision !== null && + acknowledgedRevision > (projectRevisionRef.current ?? -1) ) { - projectRevisionRef.current = result.revision; + projectRevisionRef.current = acknowledgedRevision; authoritativeLayoutItemIdsRef.current = new Set( attemptedSave.input.layers.flatMap((item) => { const id = canvasLayoutItemId(item); @@ -648,10 +666,12 @@ export function useImageCanvasProjectPersistence({ latestProject: EditorProjectSnapshot, ) => { if (!saveStillBelongsToCurrentAuthority()) { - return; + return false; } - applyProjectSnapshotRef.current?.(latestProject); + const applied = + applyProjectSnapshotRef.current?.(latestProject) === true; runNextSave = Boolean(pendingProjectLayoutSaveRef.current); + return applied; }; const scheduleAuthoritativeReload = (retryCount: number) => { if ( @@ -697,11 +717,14 @@ export function useImageCanvasProjectPersistence({ } return; } - if (pendingProjectLayoutSaveRef.current) { - runNextSave = true; + if (!isRetryableEditorProjectLayoutSaveError(error)) { + if (pendingProjectLayoutSaveRef.current) { + runNextSave = true; + } return; } - if (!isRetryableEditorProjectLayoutSaveError(error)) { + if (pendingProjectLayoutSaveRef.current) { + runNextSave = true; return; } const transportRetries = pendingSave.transportRetries ?? 0; @@ -868,7 +891,10 @@ export function useImageCanvasProjectPersistence({ Parameters[1], 'expectedRevision' >, - options: { delayMs?: number; persistCover?: boolean } = {}, + options: { + delayMs?: number; + persistCover?: boolean; + } = {}, ) => { const revision = projectRevisionRef.current; if ( @@ -876,7 +902,7 @@ export function useImageCanvasProjectPersistence({ authoritativeProjectIdRef.current !== nextProjectId || revision === null ) { - return; + return false; } pendingProjectLayoutSaveRef.current = { projectId: nextProjectId, @@ -915,7 +941,7 @@ export function useImageCanvasProjectPersistence({ } runPendingProjectLayoutSave(); }, delayMs); - return; + return true; } if (shouldPersistCover) { @@ -926,6 +952,7 @@ export function useImageCanvasProjectPersistence({ ); } runPendingProjectLayoutSave(); + return true; }, [ currentUserId, @@ -935,68 +962,82 @@ export function useImageCanvasProjectPersistence({ ], ); - const flushProjectPersistence = useCallback(async () => { - while (activeProjectLayoutSavePromiseRef.current) { - await activeProjectLayoutSavePromiseRef.current; - } + /** + * 中文注释:布局保存只有「尽力而为」这一种语义。 + * + * 曾经存在一条 strict 变体:完美像素在发 POST 前必须拿到布局保存的 revision ack,因为 + * 请求账本当时写在布局里。账本移到本机之后(见 perfectPixelOperationStore)那个前置 + * 条件不再成立,strict 通道连同它引入的阻断一起删除——布局保存失败此后只是「这次画布 + * 状态没同步上去」,不再能拦住任何生成操作。 + * + * `preferLatestGenerationDialogs` 只影响取哪一份占位快照:置位时取本轮 render 之前已 + * 提交的最新占位,避免刚创建的占位因为 ref 落后一帧而漏存。它不改变失败语义。 + */ + const flushProjectPersistence = useCallback( + async ( + options: { + preferLatestGenerationDialogs?: boolean; + } = {}, + ) => { + while (activeProjectLayoutSavePromiseRef.current) { + await activeProjectLayoutSavePromiseRef.current; + } - const nextProjectId = projectIdRef.current; - if ( - !nextProjectId || - !canAccessProtectedDataRef.current || - !hasAuthoritativeProjectSnapshotRef.current || - authoritativeProjectIdRef.current !== nextProjectId || - projectRevisionRef.current === null - ) { - return; - } + const nextProjectId = projectIdRef.current; + if ( + !nextProjectId || + !canAccessProtectedDataRef.current || + !hasAuthoritativeProjectSnapshotRef.current || + authoritativeProjectIdRef.current !== nextProjectId || + projectRevisionRef.current === null + ) { + return; + } - const coverDisplayViewport = viewportToCanvasDisplayViewport( - refs.viewportRef.current, - ); - queueProjectLayoutSave( - nextProjectId, - { + const coverDisplayViewport = viewportToCanvasDisplayViewport( + refs.viewportRef.current, + ); + const generationDialogs = + options.preferLatestGenerationDialogs && + refs.getCanvasGenerationDialogsSnapshot + ? refs.getCanvasGenerationDialogsSnapshot() + : refs.canvasGenerationDialogsRef.current; + const layoutInput = { viewport: coverDisplayViewport, layers: serializeCanvasLayout({ layers: refs.layersRef.current, - canvasGenerationDialogs: refs.canvasGenerationDialogsRef.current, + canvasGenerationDialogs: generationDialogs, canvasBackgroundColor: refs.canvasBackgroundColorRef.current, }), - }, - { persistCover: false }, - ); - - const coverSave = persistProjectCoverSnapshot( - nextProjectId, - coverDisplayViewport, - refs.layersRef.current, - ); - while ( - activeProjectLayoutSavePromiseRef.current || - pendingProjectLayoutSaveRef.current - ) { - const activeLayoutSave = activeProjectLayoutSavePromiseRef.current; - if (activeLayoutSave) { - await activeLayoutSave; - continue; + }; + // 中文注释:封面走 `queueProjectLayoutSave` 内建的 fire-and-forget 分支,flush **不等** + // 它。此前 flush 显式关掉那条分支、自己起一份并在最后 `await`,于是任何 await flush 的 + // 调用方都被挂在封面链后面——而封面渲染要为每个可绘制图层取 signed URL 再 `new Image()` + // 加载,那个 Image 没有 timeout 也没有 AbortSignal,一张图不 settle 就永远不 settle。 + // 完美像素与图集拆分 await flush 只为满足一个服务端前置:占位/图层布局已经持久化。那个 + // 前置在下面的队列排干时就已满足,封面与它无关,不该挡在 POST 前面。 + queueProjectLayoutSave(nextProjectId, layoutInput); + while ( + activeProjectLayoutSavePromiseRef.current || + pendingProjectLayoutSaveRef.current + ) { + const activeLayoutSave = activeProjectLayoutSavePromiseRef.current; + if (activeLayoutSave) { + await activeLayoutSave; + continue; + } + if (saveTimerRef.current !== null) { + window.clearTimeout(saveTimerRef.current); + saveTimerRef.current = null; + } + runPendingProjectLayoutSave(); + if (!activeProjectLayoutSavePromiseRef.current) { + break; + } } - if (saveTimerRef.current !== null) { - window.clearTimeout(saveTimerRef.current); - saveTimerRef.current = null; - } - runPendingProjectLayoutSave(); - if (!activeProjectLayoutSavePromiseRef.current) { - break; - } - } - await coverSave; - }, [ - persistProjectCoverSnapshot, - queueProjectLayoutSave, - refs, - runPendingProjectLayoutSave, - ]); + }, + [queueProjectLayoutSave, refs, runPendingProjectLayoutSave], + ); const applyCreatedProjectResourceLayer = useCallback( (pendingLayer: PendingCreatedProjectResourceLayer) => { @@ -1196,15 +1237,17 @@ export function useImageCanvasProjectPersistence({ } const pendingSave = pendingProjectLayoutSaveRef.current; const activeSaveAttempt = activeProjectLayoutSaveAttemptRef.current; + const activeLocalSave = + authoritative && + activeSaveAttempt?.save.projectId === project.projectId && + activeSaveAttempt.authorityEpoch === projectAuthorityEpochRef.current && + activeSaveAttempt.ownerUserId === currentUserIdRef.current + ? activeSaveAttempt.save + : null; const pendingLocalLayout = authoritative ? pendingSave?.projectId === project.projectId ? pendingSave - : activeSaveAttempt?.save.projectId === project.projectId && - activeSaveAttempt.authorityEpoch === - projectAuthorityEpochRef.current && - activeSaveAttempt.ownerUserId === currentUserIdRef.current - ? activeSaveAttempt.save - : null + : activeLocalSave : null; const previousAuthoritativeItemIds = authoritativeLayoutItemIdsRef.current; @@ -1283,12 +1326,45 @@ export function useImageCanvasProjectPersistence({ }, ]), ); + // 中文注释:请求账本只在本机。读不到(换设备、清缓存、隐私模式)时占位会被 + // hydrate 收口成可删除的失败态——这是明确设计,不阻断任何后续操作。 + const localPerfectPixelOperations = readPerfectPixelOperations( + currentUserId, + project.projectId, + ); const { layerItems, generationDialogs, canvasBackgroundColor } = splitCanvasLayoutItems( appliedLayoutItems, resourcesById, currentUserId, + localPerfectPixelOperations, ); + // 中文注释:legacy 内联账本一次性迁到本机。布局里的内联快照会在下一次保存时被剥成 + // `perfectPixelOperationId` 标记,此后再没有任何路径能把它补写进本机账本——不迁移 + // 的话,部署那一刻仍在途的操作会在第二次加载变成 `failed + invalid`,永久失去 exact + // retry 的 identity。 + // + // 判据用「未收口」而不是列举状态:`failed + perfectPixelOperation` 是旧严格保存失败 + // 的合法持久化形状,`retryPerfectPixelOperation` 也明确接受 `failed`,按状态白名单 + // 列举会把这批仍能 exact retry 的占位漏掉。收口态(带 generatedLayerId)本就不需要 + // 账本,与 `hydrateCanvasGenerationDialog` 同用一个判据,两处不会漂移。 + // 只补写本机缺失的:本机那份可能刚在 pre-POST flush 之后被重新锚定过,比布局里的新。 + for (const dialog of generationDialogs) { + const operation = dialog.perfectPixelOperation; + if ( + operation && + !localPerfectPixelOperations.has(dialog.id) && + isUnresolvedCanvasGenerationDialogRecord( + dialog as unknown as Record, + ) + ) { + savePerfectPixelOperation( + currentUserId, + project.projectId, + operation, + ); + } + } const hydratedLayers = layerItems .map((layer) => hydrateLayer(layer, resourcesById)) .filter((layer): layer is CanvasLayer => Boolean(layer)); @@ -1417,18 +1493,38 @@ export function useImageCanvasProjectPersistence({ projectIdFromQuery, currentUserId, ); + // 中文注释:缓存里剥掉的数量单独记,不能直接并入提示计数。会话缓存可能停留在完美像素 + // 完成之前的版本(`applyProjectSnapshot` 置了 skipNext,成功后的第一次 effect 不落库), + // 此时缓存里的占位是陈旧的而权威快照其实已经成功——并入就会报一条「上次处理未完成」的 + // 假告警。只有权威加载没能纠正这幅画面时,这个计数才代表真的有孤儿占位被静默清掉。 + let cachedDeadPlaceholderCount = 0; if (cachedProject) { - applyProjectSnapshot(cachedProject.project, { authoritative: false }); + // 中文注释:会话缓存写于占位落库之后,同样可能带着已死会话的 generating 占位, + // 必须和权威快照走同一道剥离,否则首屏会先闪一个永远转圈的占位。 + const cached = dropDeadInlineGenerationPlaceholders( + cachedProject.project, + ); + cachedDeadPlaceholderCount = cached.droppedCount; + applyProjectSnapshot(cached.project, { authoritative: false }); } const loadProject = projectIdFromQuery ? loadEditorProject(projectIdFromQuery) : loadOrCreateRecentEditorProject(); loadProject - .then((project) => { + .then((loadedProject) => { if (cancelled) { return; } + // 中文注释:只在这里剥离。会话内 applyQueuedEditorGenerationProject 也会重新 GET + // 项目并套用,那时候占位对应的操作正在进行,套用剥离会把自己的活占位清掉。 + const { project, droppedCount } = + dropDeadInlineGenerationPlaceholders(loadedProject); + if (droppedCount > 0) { + setDeadInlinePlaceholderDropCount( + (currentCount) => currentCount + droppedCount, + ); + } const projectIsAuthoritative = applyProjectSnapshot(project, { allowProjectSwitch: true, }); @@ -1468,6 +1564,16 @@ export function useImageCanvasProjectPersistence({ return; } replaceAppHistoryPath('/project'); + return; + } + // 中文注释:权威快照没能到达,缓存里剥掉的占位就没有第二个来源可以纠正。此时画布 + // 仍在渲染(isProjectReady 只管启动意图与自动保存,不挡画面),用户看到的是一张 + // 静默少了占位的画布——必须提示,否则他既不知道占位被清掉,也不知道素材库可能已有 + // 派生图。鉴权失败与项目失访这两条路径已各自跳转或弹窗,不在这里重复打扰。 + if (cachedDeadPlaceholderCount > 0) { + setDeadInlinePlaceholderDropCount( + (currentCount) => currentCount + cachedDeadPlaceholderCount, + ); } }); @@ -1552,5 +1658,6 @@ export function useImageCanvasProjectPersistence({ appendCanvasLayersWithResources, applyProjectSnapshot, flushProjectPersistence, + deadInlinePlaceholderDropCount, }; } diff --git a/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx b/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx index 1c51171fe..eedb53492 100644 --- a/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx @@ -1,4 +1,4 @@ -/* @vitest-environment jsdom */ +/* @vitest-environment jsdom */ import { act, @@ -19,8 +19,12 @@ import type { GenerateDialogState, QuickEditPanelState, SidebarPanel, + UploadTarget, } from './ImageCanvasEditorTypes'; -import { useImageCanvasUploadWorkflow } from './useImageCanvasUploadWorkflow'; +import { + type ImageReferenceUploadCapacity, + useImageCanvasUploadWorkflow, +} from './useImageCanvasUploadWorkflow'; const createEditorAssetMock = vi.hoisted(() => vi.fn()); const createEditorAssetFolderMock = vi.hoisted(() => vi.fn()); @@ -132,11 +136,17 @@ function UploadWorkflowHarness({ openEditorLoginModal = vi.fn(), activeTool = 'select', projectId = 'project-1', + resolveImageReferenceUploadCapacity = () => null, + refreshAssetLibrary, }: { canAccessProtectedData?: boolean; openEditorLoginModal?: (postLoginAction?: (() => void) | null) => void; activeTool?: CanvasTool; projectId?: string | null; + resolveImageReferenceUploadCapacity?: ( + target: UploadTarget, + ) => ImageReferenceUploadCapacity | null; + refreshAssetLibrary?: () => Promise | void; }) { const [assetFolders, setAssetFolders] = useState([ createDefaultFolder(), @@ -170,6 +180,7 @@ function UploadWorkflowHarness({ setLayers, setGenerateDialog, setQuickEditPanel, + resolveImageReferenceUploadCapacity, appendUiAssetExtractionReferences: (references) => setUiAssetExtractionReferences((currentReferences) => [ ...currentReferences, @@ -180,6 +191,7 @@ function UploadWorkflowHarness({ }, captureCanvasHistory: vi.fn(), selectSingleLayer: setSelectedLayerId, + refreshAssetLibrary, }); return ( @@ -217,6 +229,9 @@ function UploadWorkflowHarness({ {activeSidebarPanel ?? '-'} {selectedLayerId ?? '-'} + + {workflow.pendingImageReferenceUploadCount} + {generateDialog ? `${generateDialog.mode}:${generateDialog.status}:${generateDialog.characterSpecReference?.label ?? '-'}:${generateDialog.characterReferences?.length ?? 0}:${generateDialog.iconSpecReference?.label ?? '-'}:${generateDialog.specReference?.label ?? '-'}:${generateDialog.generationReferences?.length ?? 0}:${generateDialog.generationReferences?.[0]?.label ?? '-'}:${generateDialog.generationReferences?.map((reference) => `${reference.mediaType ?? 'image'}:${reference.src}:${reference.objectKey ?? '-'}:${reference.resourceId ?? '-'}:${reference.sourceAssetId ?? '-'}`).join(',') || '-'}` @@ -821,6 +836,295 @@ describe('useImageCanvasUploadWorkflow', () => { ); }); + it('rejects reference overflow before reading or uploading selected files', async () => { + const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {}); + render( + + target === 'generation-reference' + ? { label: '生成参考图', currentCount: 0, limit: 5 } + : null + } + />, + ); + + fireEvent.click(screen.getByRole('button', { name: '准备图片生成' })); + fireEvent.click(screen.getByRole('button', { name: '选择生成参考图' })); + fireEvent.change(screen.getByLabelText('上传图片文件'), { + target: { + files: Array.from({ length: 6 }, (_, index) => + createTestFile(`超限参考图-${index + 1}.png`), + ), + }, + }); + + await waitFor(() => { + expect(alertMock).toHaveBeenCalledWith( + '生成参考图最多允许 5 张,当前已有 0 张,本次最多还能选择 5 张', + ); + }); + expect(uploadEditorMediaAssetFileMock).not.toHaveBeenCalled(); + expect(createEditorProjectResourceMock).not.toHaveBeenCalled(); + }); + + it('counts in-flight reservations when overlapping reference batches are selected', async () => { + const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {}); + const uploadGate = createDeferred(); + uploadEditorMediaAssetFileMock.mockImplementation(async (file: File) => { + await uploadGate.promise; + return { + src: `https://signed.example.com/editor/image/${file.name}`, + legacyPublicPath: `/generated-character-drafts/editor/asset-library/image/${file.name}`, + objectKey: `generated-character-drafts/editor/asset-library/image/${file.name}`, + assetObjectId: 'assetobj-editor-image', + }; + }); + render( + + target === 'generation-reference' + ? { label: '生成参考图', currentCount: 0, limit: 5 } + : null + } + />, + ); + + fireEvent.click(screen.getByRole('button', { name: '准备图片生成' })); + fireEvent.click(screen.getByRole('button', { name: '选择生成参考图' })); + fireEvent.change(screen.getByLabelText('上传图片文件'), { + target: { + files: Array.from({ length: 3 }, (_, index) => + createTestFile(`第一批-${index + 1}.png`), + ), + }, + }); + await waitFor(() => { + expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledTimes(3); + expect(screen.getByTestId('pending-reference-uploads').textContent).toBe( + '3', + ); + }); + + fireEvent.click(screen.getByRole('button', { name: '选择生成参考图' })); + fireEvent.change(screen.getByLabelText('上传图片文件'), { + target: { + files: Array.from({ length: 3 }, (_, index) => + createTestFile(`第二批-${index + 1}.png`), + ), + }, + }); + await waitFor(() => { + expect(alertMock).toHaveBeenCalledWith( + '生成参考图最多允许 5 张,当前已有 0 张,本次最多还能选择 2 张', + ); + }); + expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledTimes(3); + + await act(async () => { + uploadGate.resolve(); + await uploadGate.promise; + }); + await waitFor(() => { + expect(screen.getByTestId('dialog').textContent).toContain( + 'generate:idle:-:0:-:-:3', + ); + expect(createEditorProjectResourceMock).toHaveBeenCalledTimes(3); + expect(screen.getByTestId('pending-reference-uploads').textContent).toBe( + '0', + ); + }); + }); + + it('rejects an in-flight upload when the original dialog context changes', async () => { + const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {}); + const uploadGate = createDeferred(); + let contextId = 'dialog-1'; + uploadEditorMediaAssetFileMock.mockImplementation(async (file: File) => { + await uploadGate.promise; + return { + src: `https://signed.example.com/editor/image/${file.name}`, + legacyPublicPath: `/generated-character-drafts/editor/asset-library/image/${file.name}`, + objectKey: `generated-character-drafts/editor/asset-library/image/${file.name}`, + assetObjectId: 'assetobj-editor-image', + }; + }); + render( + + target === 'generation-reference' + ? { + label: '生成参考图', + currentCount: 0, + limit: 5, + contextId, + } + : null + } + />, + ); + + fireEvent.click(screen.getByRole('button', { name: '准备图片生成' })); + fireEvent.click(screen.getByRole('button', { name: '选择生成参考图' })); + fireEvent.change(screen.getByLabelText('上传图片文件'), { + target: { files: [createTestFile('原面板参考图.png')] }, + }); + await waitFor(() => { + expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledTimes(1); + }); + + contextId = 'dialog-2'; + await act(async () => { + uploadGate.resolve(); + await uploadGate.promise; + }); + + await waitFor(() => { + expect(alertMock).toHaveBeenCalledWith( + '参考图上传目标已关闭或切换,本次结果不会添加到其他面板', + ); + expect(screen.getByTestId('dialog').textContent).toContain( + 'generate:failed:-:0:-:-:0', + ); + expect(screen.getByTestId('pending-reference-uploads').textContent).toBe( + '0', + ); + }); + expect(createEditorProjectResourceMock).not.toHaveBeenCalled(); + }); + + it('keeps successful references visible when part of a batch fails', async () => { + const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {}); + const refreshAssetLibrary = vi.fn().mockResolvedValue(undefined); + createEditorProjectResourceMock.mockImplementation( + async (projectId, input) => { + if (input.imageSrc.includes('失败')) { + throw new Error('resource create failed'); + } + return { + resourceId: `resource-${input.objectKey}`, + projectId, + imageSrc: input.imageSrc, + width: input.width, + height: input.height, + sourceType: input.sourceType, + objectKey: input.objectKey, + assetObjectId: input.assetObjectId, + }; + }, + ); + render( + + target === 'generation-reference' + ? { + label: '生成参考图', + currentCount: 0, + limit: 5, + contextId: 'dialog-1', + } + : null + } + />, + ); + + fireEvent.click(screen.getByRole('button', { name: '准备图片生成' })); + fireEvent.click(screen.getByRole('button', { name: '选择生成参考图' })); + fireEvent.change(screen.getByLabelText('上传图片文件'), { + target: { + files: [ + createTestFile('成功参考图.png'), + createTestFile('失败参考图.png'), + ], + }, + }); + + await waitFor(() => { + expect(alertMock).toHaveBeenCalledWith( + '部分参考图保存失败,已保留成功的 1 张', + ); + expect(screen.getByTestId('dialog').textContent).toContain( + 'generate:idle:-:0:-:-:1:成功参考图.png', + ); + expect(refreshAssetLibrary).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('pending-reference-uploads').textContent).toBe( + '0', + ); + }); + expect(createEditorProjectResourceMock).toHaveBeenCalledTimes(2); + }); + + it('rechecks the latest capacity before persisting after a model shrinks', async () => { + const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {}); + let capacityChecks = 0; + render( + + target === 'generation-reference' + ? { + label: '生成参考图', + currentCount: 0, + limit: ++capacityChecks === 1 ? 14 : 5, + } + : null + } + />, + ); + + fireEvent.click(screen.getByRole('button', { name: '准备图片生成' })); + fireEvent.click(screen.getByRole('button', { name: '选择生成参考图' })); + fireEvent.change(screen.getByLabelText('上传图片文件'), { + target: { + files: Array.from({ length: 6 }, (_, index) => + createTestFile(`缩容参考图-${index + 1}.png`), + ), + }, + }); + await waitFor(() => { + expect(alertMock).toHaveBeenCalledWith( + '生成参考图最多允许 5 张,当前已有 0 张,本次最多还能选择 5 张', + ); + }); + expect(uploadEditorMediaAssetFileMock).not.toHaveBeenCalled(); + expect(createEditorProjectResourceMock).not.toHaveBeenCalled(); + }); + + it('rechecks capacity after object upload and before creating project resources', async () => { + const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {}); + let capacityChecks = 0; + render( + + target === 'generation-reference' + ? { + label: '生成参考图', + currentCount: 0, + limit: ++capacityChecks <= 2 ? 14 : 5, + } + : null + } + />, + ); + + fireEvent.click(screen.getByRole('button', { name: '准备图片生成' })); + fireEvent.click(screen.getByRole('button', { name: '选择生成参考图' })); + fireEvent.change(screen.getByLabelText('上传图片文件'), { + target: { + files: Array.from({ length: 6 }, (_, index) => + createTestFile(`持久化前缩容-${index + 1}.png`), + ), + }, + }); + + await waitFor(() => { + expect(alertMock).toHaveBeenCalledWith( + '生成参考图最多允许 5 张,当前已有 0 张,本次最多还能选择 5 张', + ); + }); + expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledTimes(6); + expect(createEditorProjectResourceMock).not.toHaveBeenCalled(); + }); + it('dispatches file input uploads to UI asset extraction references', async () => { render(); @@ -888,6 +1192,69 @@ describe('useImageCanvasUploadWorkflow', () => { expect(createEditorAssetMock).not.toHaveBeenCalled(); }); + it('reserves the single spec reference slot across overlapping uploads', async () => { + const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {}); + const uploadGate = createDeferred(); + uploadEditorMediaAssetFileMock.mockImplementation(async (file: File) => { + await uploadGate.promise; + return { + src: `https://signed.example.com/editor/image/${file.name}`, + legacyPublicPath: `/generated-character-drafts/editor/asset-library/image/${file.name}`, + objectKey: `generated-character-drafts/editor/asset-library/image/${file.name}`, + assetObjectId: 'assetobj-editor-image', + }; + }); + render( + + target === 'spec-reference' + ? { + label: '规范参考图', + currentCount: 0, + limit: 1, + contextId: 'dialog-spec', + } + : null + } + />, + ); + + fireEvent.click(screen.getByRole('button', { name: '准备规范生成' })); + fireEvent.click(screen.getByRole('button', { name: '选择规范参考图' })); + fireEvent.change(screen.getByLabelText('上传图片文件'), { + target: { files: [createTestFile('第一张规范.png')] }, + }); + await waitFor(() => { + expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('pending-reference-uploads').textContent).toBe( + '1', + ); + }); + + fireEvent.click(screen.getByRole('button', { name: '选择规范参考图' })); + fireEvent.change(screen.getByLabelText('上传图片文件'), { + target: { files: [createTestFile('第二张规范.png')] }, + }); + expect(alertMock).toHaveBeenCalledWith( + '规范参考图最多允许 1 张,当前已有 0 张,本次最多还能选择 0 张', + ); + expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledTimes(1); + + await act(async () => { + uploadGate.resolve(); + await uploadGate.promise; + }); + await waitFor(() => { + expect(screen.getByTestId('dialog').textContent).toContain( + 'spec:idle:-:0:-:第一张规范.png', + ); + expect(screen.getByTestId('pending-reference-uploads').textContent).toBe( + '0', + ); + }); + expect(createEditorProjectResourceMock).toHaveBeenCalledTimes(1); + }); + it('uploads Seedance reference videos through OSS and stores object keys instead of data URLs', async () => { render(); diff --git a/src/components/image-editor/useImageCanvasUploadWorkflow.ts b/src/components/image-editor/useImageCanvasUploadWorkflow.ts index 10a4e5389..584e10d2f 100644 --- a/src/components/image-editor/useImageCanvasUploadWorkflow.ts +++ b/src/components/image-editor/useImageCanvasUploadWorkflow.ts @@ -79,6 +79,24 @@ type UploadAssetFileOptions = UploadFilesOptions & { uploadIndex: number; }; +class ImageReferenceCapacityChangedError extends Error {} + +export type ImageReferenceUploadCapacity = { + label: string; + currentCount: number; + limit: number; + contextId?: string; +}; + +type ImageReferenceUploadReservation = { + target: UploadTarget; + count: number; + contextId?: string; + key: string; + release: () => void; + contextWarningShown: boolean; +}; + type UseImageCanvasUploadWorkflowOptions = { canAccessProtectedData: boolean; openEditorLoginModal: (postLoginAction?: (() => void) | null) => void; @@ -94,12 +112,16 @@ type UseImageCanvasUploadWorkflowOptions = { setLayers: Dispatch>; setGenerateDialog: Dispatch>; setQuickEditPanel: Dispatch>; + resolveImageReferenceUploadCapacity: ( + target: UploadTarget, + ) => ImageReferenceUploadCapacity | null; appendUiAssetExtractionReferences: ( references: CharacterReferenceImage[], ) => void; appendCanvasLayersWithResources: (nextLayers: CanvasLayer[]) => void; captureCanvasHistory: (action: CanvasHistoryAction) => void; selectSingleLayer: (layerId: string | null) => void; + refreshAssetLibrary?: () => Promise | void; }; function isEditorAuthError(error: unknown) { @@ -134,19 +156,26 @@ export function useImageCanvasUploadWorkflow({ setLayers, setGenerateDialog, setQuickEditPanel, + resolveImageReferenceUploadCapacity, appendUiAssetExtractionReferences, appendCanvasLayersWithResources, captureCanvasHistory, selectSingleLayer, + refreshAssetLibrary, }: UseImageCanvasUploadWorkflowOptions) { const uploadInputRef = useRef(null); const canAccessProtectedDataRef = useRef(canAccessProtectedData); const uploadTargetRef = useRef('asset'); const uploadRequestOptionsRef = useRef({}); + const pendingImageReferenceUploadsRef = useRef>({}); const uploadAssetFolderCreatePromiseRef = useRef | null>( null, ); const [uploadTarget, setUploadTargetState] = useState('asset'); + const [ + pendingImageReferenceUploadCount, + setPendingImageReferenceUploadCount, + ] = useState(0); canAccessProtectedDataRef.current = canAccessProtectedData; @@ -162,6 +191,92 @@ export function useImageCanvasUploadWorkflow({ [], ); + const showImageReferenceCapacityWarning = useCallback( + (capacity: ImageReferenceUploadCapacity, remaining: number) => { + window.alert( + `${capacity.label}最多允许 ${capacity.limit} 张,当前已有 ${capacity.currentCount} 张,本次最多还能选择 ${Math.max(0, remaining)} 张`, + ); + }, + [], + ); + + const reserveImageReferenceUpload = useCallback( + (target: UploadTarget, count: number) => { + const capacity = resolveImageReferenceUploadCapacity(target); + const key = `${target}\u0000${capacity?.contextId ?? ''}`; + const pending = pendingImageReferenceUploadsRef.current[key] ?? 0; + if (capacity) { + const remaining = capacity.limit - capacity.currentCount - pending; + if (count > remaining) { + showImageReferenceCapacityWarning(capacity, remaining); + return null; + } + } + pendingImageReferenceUploadsRef.current[key] = pending + count; + setPendingImageReferenceUploadCount((current) => current + count); + let released = false; + const reservation: ImageReferenceUploadReservation = { + target, + count, + contextId: capacity?.contextId, + key, + contextWarningShown: false, + release: () => { + if (released) { + return; + } + released = true; + const current = pendingImageReferenceUploadsRef.current[key] ?? 0; + pendingImageReferenceUploadsRef.current[key] = Math.max( + 0, + current - count, + ); + setPendingImageReferenceUploadCount((currentCount) => + Math.max(0, currentCount - count), + ); + }, + }; + return reservation; + }, + [resolveImageReferenceUploadCapacity, showImageReferenceCapacityWarning], + ); + + const validateImageReferenceUploadReservation = useCallback( + (reservation: ImageReferenceUploadReservation) => { + const capacity = resolveImageReferenceUploadCapacity(reservation.target); + if (!capacity) { + if (reservation.contextId === undefined) { + return true; + } + if (!reservation.contextWarningShown) { + reservation.contextWarningShown = true; + window.alert( + '参考图上传目标已关闭或切换,本次结果不会添加到其他面板', + ); + } + return false; + } + if (capacity.contextId !== reservation.contextId) { + if (!reservation.contextWarningShown) { + reservation.contextWarningShown = true; + window.alert( + '参考图上传目标已关闭或切换,本次结果不会添加到其他面板', + ); + } + return false; + } + const pending = + pendingImageReferenceUploadsRef.current[reservation.key] ?? 0; + const remaining = capacity.limit - capacity.currentCount; + if (pending <= remaining) { + return true; + } + showImageReferenceCapacityWarning(capacity, remaining); + return false; + }, + [resolveImageReferenceUploadCapacity, showImageReferenceCapacityWarning], + ); + const createPersistedReference = useCallback( async ({ idPrefix, @@ -175,6 +290,7 @@ export function useImageCanvasUploadWorkflow({ durationSeconds, objectKey, assetObjectId, + reservation, }: { idPrefix: string; index?: number; @@ -187,6 +303,7 @@ export function useImageCanvasUploadWorkflow({ durationSeconds?: number; objectKey?: string | null; assetObjectId?: string | null; + reservation?: ImageReferenceUploadReservation; }): Promise => { const resolvedMediaType = mediaType ?? 'image'; let persistedImageSrc = imageSrc; @@ -205,6 +322,12 @@ export function useImageCanvasUploadWorkflow({ : null; const width = imageDimensions?.width ?? fallbackSize.width; const height = imageDimensions?.height ?? fallbackSize.height; + if ( + reservation && + !validateImageReferenceUploadReservation(reservation) + ) { + throw new ImageReferenceCapacityChangedError(); + } if (projectId) { const resource = await createEditorProjectResource(projectId, { imageSrc: persistedImageSrc, @@ -265,7 +388,12 @@ export function useImageCanvasUploadWorkflow({ sourceAssetId: asset.assetId, }); }, - [activeUploadFolderId, assetFolders, projectId], + [ + activeUploadFolderId, + assetFolders, + projectId, + validateImageReferenceUploadReservation, + ], ); const persistReferenceUploads = useCallback( @@ -283,20 +411,47 @@ export function useImageCanvasUploadWorkflow({ objectKey?: string | null; assetObjectId?: string | null; }>, + reservation?: ImageReferenceUploadReservation, ) => { - try { - return await Promise.all(inputs.map(createPersistedReference)); - } catch (error) { - if (isEditorAuthError(error)) { - openEditorLoginModal(); - window.alert('请先登录后再上传参考图'); - } else { - window.alert('参考图保存失败,请稍后重试'); - } - return null; + const results = await Promise.allSettled( + inputs.map((input) => + createPersistedReference({ ...input, reservation }), + ), + ); + const references = results.flatMap((result) => + result.status === 'fulfilled' ? [result.value] : [], + ); + const errors = results.flatMap((result) => + result.status === 'rejected' ? [result.reason] : [], + ); + if (references.length > 0) { + void Promise.resolve(refreshAssetLibrary?.()).catch(() => undefined); } + if (errors.length === 0) { + return references; + } + const authError = errors.find(isEditorAuthError); + if (authError) { + openEditorLoginModal(); + window.alert( + references.length > 0 + ? `部分参考图保存失败,已保留成功的 ${references.length} 张;请登录后重试其余图片` + : '请先登录后再上传参考图', + ); + } else if ( + !errors.every( + (error) => error instanceof ImageReferenceCapacityChangedError, + ) + ) { + window.alert( + references.length > 0 + ? `部分参考图保存失败,已保留成功的 ${references.length} 张` + : '参考图保存失败,请稍后重试', + ); + } + return references; }, - [createPersistedReference, openEditorLoginModal], + [createPersistedReference, openEditorLoginModal, refreshAssetLibrary], ); const addCharacterSpecReferenceFiles = useCallback( @@ -306,28 +461,49 @@ export function useImageCanvasUploadWorkflow({ window.alert('请选择图片文件'); return; } - - const imageSrc = await readImageFileAsDataUrl(imageFile); - const references = await persistReferenceUploads([ - { - idPrefix: 'upload-character-spec', - file: imageFile, - fallbackLabel: '角色规范', - imageSrc, - }, - ]); - if (!references) { + const reservation = reserveImageReferenceUpload('character-spec', 1); + if (!reservation) { return; } - setGenerateDialog((currentDialog) => - applyGenerationReferenceUpload({ - dialog: currentDialog, - target: 'character-spec', - references, - }), - ); + try { + const imageSrc = await readImageFileAsDataUrl(imageFile); + if (!validateImageReferenceUploadReservation(reservation)) { + return; + } + const references = await persistReferenceUploads( + [ + { + idPrefix: 'upload-character-spec', + file: imageFile, + fallbackLabel: '角色规范', + imageSrc, + }, + ], + reservation, + ); + if ( + references.length === 0 || + !validateImageReferenceUploadReservation(reservation) + ) { + return; + } + setGenerateDialog((currentDialog) => + applyGenerationReferenceUpload({ + dialog: currentDialog, + target: 'character-spec', + references, + }), + ); + } finally { + reservation.release(); + } }, - [persistReferenceUploads, setGenerateDialog], + [ + persistReferenceUploads, + reserveImageReferenceUpload, + setGenerateDialog, + validateImageReferenceUploadReservation, + ], ); const addSpecReferenceFiles = useCallback( @@ -338,27 +514,49 @@ export function useImageCanvasUploadWorkflow({ return; } - const imageSrc = await readImageFileAsDataUrl(imageFile); - const references = await persistReferenceUploads([ - { - idPrefix: 'upload-spec-reference', - file: imageFile, - fallbackLabel: '参考图', - imageSrc, - }, - ]); - if (!references) { + const reservation = reserveImageReferenceUpload('spec-reference', 1); + if (!reservation) { return; } - setGenerateDialog((currentDialog) => - applyGenerationReferenceUpload({ - dialog: currentDialog, - target: 'spec-reference', - references, - }), - ); + try { + const imageSrc = await readImageFileAsDataUrl(imageFile); + if (!validateImageReferenceUploadReservation(reservation)) { + return; + } + const references = await persistReferenceUploads( + [ + { + idPrefix: 'upload-spec-reference', + file: imageFile, + fallbackLabel: '参考图', + imageSrc, + }, + ], + reservation, + ); + if ( + references.length === 0 || + !validateImageReferenceUploadReservation(reservation) + ) { + return; + } + setGenerateDialog((currentDialog) => + applyGenerationReferenceUpload({ + dialog: currentDialog, + target: 'spec-reference', + references, + }), + ); + } finally { + reservation.release(); + } }, - [persistReferenceUploads, setGenerateDialog], + [ + persistReferenceUploads, + reserveImageReferenceUpload, + setGenerateDialog, + validateImageReferenceUploadReservation, + ], ); const addCharacterReferenceFiles = useCallback( @@ -368,35 +566,57 @@ export function useImageCanvasUploadWorkflow({ window.alert('请选择图片文件'); return; } - - const references = await Promise.all( - imageFiles.map(async (file, index) => ({ - file, - index, - imageSrc: await readImageFileAsDataUrl(file), - })), + const reservation = reserveImageReferenceUpload( + 'character-reference', + imageFiles.length, ); - const persistedReferences = await persistReferenceUploads( - references.map(({ file, imageSrc, index }) => ({ - idPrefix: 'upload-character-reference', - index, - file, - fallbackLabel: `参考图${index + 1}`, - imageSrc, - })), - ); - if (!persistedReferences) { + if (!reservation) { return; } - setGenerateDialog((currentDialog) => - applyGenerationReferenceUpload({ - dialog: currentDialog, - target: 'character-reference', - references: persistedReferences, - }), - ); + try { + const references = await Promise.all( + imageFiles.map(async (file, index) => ({ + file, + index, + imageSrc: await readImageFileAsDataUrl(file), + })), + ); + if (!validateImageReferenceUploadReservation(reservation)) { + return; + } + const persistedReferences = await persistReferenceUploads( + references.map(({ file, imageSrc, index }) => ({ + idPrefix: 'upload-character-reference', + index, + file, + fallbackLabel: `参考图${index + 1}`, + imageSrc, + })), + reservation, + ); + if ( + persistedReferences.length === 0 || + !validateImageReferenceUploadReservation(reservation) + ) { + return; + } + setGenerateDialog((currentDialog) => + applyGenerationReferenceUpload({ + dialog: currentDialog, + target: 'character-reference', + references: persistedReferences, + }), + ); + } finally { + reservation.release(); + } }, - [persistReferenceUploads, setGenerateDialog], + [ + persistReferenceUploads, + reserveImageReferenceUpload, + setGenerateDialog, + validateImageReferenceUploadReservation, + ], ); const addGenerationReferenceFiles = useCallback( @@ -406,35 +626,57 @@ export function useImageCanvasUploadWorkflow({ window.alert('请选择图片文件'); return; } - - const references = await Promise.all( - imageFiles.map(async (file, index) => ({ - file, - index, - imageSrc: await readImageFileAsDataUrl(file), - })), + const reservation = reserveImageReferenceUpload( + 'generation-reference', + imageFiles.length, ); - const persistedReferences = await persistReferenceUploads( - references.map(({ file, imageSrc, index }) => ({ - idPrefix: 'upload-generation-reference', - index, - file, - fallbackLabel: `参考图${index + 1}`, - imageSrc, - })), - ); - if (!persistedReferences) { + if (!reservation) { return; } - setGenerateDialog((currentDialog) => - applyGenerationReferenceUpload({ - dialog: currentDialog, - target: 'generation-reference', - references: persistedReferences, - }), - ); + try { + const references = await Promise.all( + imageFiles.map(async (file, index) => ({ + file, + index, + imageSrc: await readImageFileAsDataUrl(file), + })), + ); + if (!validateImageReferenceUploadReservation(reservation)) { + return; + } + const persistedReferences = await persistReferenceUploads( + references.map(({ file, imageSrc, index }) => ({ + idPrefix: 'upload-generation-reference', + index, + file, + fallbackLabel: `参考图${index + 1}`, + imageSrc, + })), + reservation, + ); + if ( + persistedReferences.length === 0 || + !validateImageReferenceUploadReservation(reservation) + ) { + return; + } + setGenerateDialog((currentDialog) => + applyGenerationReferenceUpload({ + dialog: currentDialog, + target: 'generation-reference', + references: persistedReferences, + }), + ); + } finally { + reservation.release(); + } }, - [persistReferenceUploads, setGenerateDialog], + [ + persistReferenceUploads, + reserveImageReferenceUpload, + setGenerateDialog, + validateImageReferenceUploadReservation, + ], ); const addQuickEditReferenceFiles = useCallback( @@ -444,34 +686,56 @@ export function useImageCanvasUploadWorkflow({ window.alert('请选择图片文件'); return; } - - const references = await Promise.all( - imageFiles.map(async (file, index) => ({ - file, - index, - imageSrc: await readImageFileAsDataUrl(file), - })), + const reservation = reserveImageReferenceUpload( + 'quick-edit-reference', + imageFiles.length, ); - const persistedReferences = await persistReferenceUploads( - references.map(({ file, imageSrc, index }) => ({ - idPrefix: 'upload-quick-edit-reference', - index, - file, - fallbackLabel: `参考图${index + 1}`, - imageSrc, - })), - ); - if (!persistedReferences) { + if (!reservation) { return; } - setQuickEditPanel((currentPanel) => - applyQuickEditReferenceUpload({ - panel: currentPanel, - references: persistedReferences, - }), - ); + try { + const references = await Promise.all( + imageFiles.map(async (file, index) => ({ + file, + index, + imageSrc: await readImageFileAsDataUrl(file), + })), + ); + if (!validateImageReferenceUploadReservation(reservation)) { + return; + } + const persistedReferences = await persistReferenceUploads( + references.map(({ file, imageSrc, index }) => ({ + idPrefix: 'upload-quick-edit-reference', + index, + file, + fallbackLabel: `参考图${index + 1}`, + imageSrc, + })), + reservation, + ); + if ( + persistedReferences.length === 0 || + !validateImageReferenceUploadReservation(reservation) + ) { + return; + } + setQuickEditPanel((currentPanel) => + applyQuickEditReferenceUpload({ + panel: currentPanel, + references: persistedReferences, + }), + ); + } finally { + reservation.release(); + } }, - [persistReferenceUploads, setQuickEditPanel], + [ + persistReferenceUploads, + reserveImageReferenceUpload, + setQuickEditPanel, + validateImageReferenceUploadReservation, + ], ); const addUiAssetExtractionReferenceFiles = useCallback( @@ -481,29 +745,51 @@ export function useImageCanvasUploadWorkflow({ window.alert('请选择图片文件'); return; } - - const references = await Promise.all( - imageFiles.map(async (file, index) => ({ - file, - index, - imageSrc: await readImageFileAsDataUrl(file), - })), + const reservation = reserveImageReferenceUpload( + 'ui-asset-extraction-reference', + imageFiles.length, ); - const persistedReferences = await persistReferenceUploads( - references.map(({ file, imageSrc, index }) => ({ - idPrefix: 'upload-ui-asset-extraction-reference', - index, - file, - fallbackLabel: `UI素材参考图${index + 1}`, - imageSrc, - })), - ); - if (!persistedReferences) { + if (!reservation) { return; } - appendUiAssetExtractionReferences(persistedReferences); + try { + const references = await Promise.all( + imageFiles.map(async (file, index) => ({ + file, + index, + imageSrc: await readImageFileAsDataUrl(file), + })), + ); + if (!validateImageReferenceUploadReservation(reservation)) { + return; + } + const persistedReferences = await persistReferenceUploads( + references.map(({ file, imageSrc, index }) => ({ + idPrefix: 'upload-ui-asset-extraction-reference', + index, + file, + fallbackLabel: `UI素材参考图${index + 1}`, + imageSrc, + })), + reservation, + ); + if ( + persistedReferences.length === 0 || + !validateImageReferenceUploadReservation(reservation) + ) { + return; + } + appendUiAssetExtractionReferences(persistedReferences); + } finally { + reservation.release(); + } }, - [appendUiAssetExtractionReferences, persistReferenceUploads], + [ + appendUiAssetExtractionReferences, + persistReferenceUploads, + reserveImageReferenceUpload, + validateImageReferenceUploadReservation, + ], ); const addPublicationReferenceFiles = useCallback( @@ -513,35 +799,57 @@ export function useImageCanvasUploadWorkflow({ window.alert('请选择图片文件'); return; } - - const references = await Promise.all( - imageFiles.map(async (file, index) => ({ - file, - index, - imageSrc: await readImageFileAsDataUrl(file), - })), + const reservation = reserveImageReferenceUpload( + 'publication-reference', + imageFiles.length, ); - const persistedReferences = await persistReferenceUploads( - references.map(({ file, imageSrc, index }) => ({ - idPrefix: 'upload-publication-reference', - index, - file, - fallbackLabel: `宣发参考图${index + 1}`, - imageSrc, - })), - ); - if (!persistedReferences) { + if (!reservation) { return; } - setGenerateDialog((currentDialog) => - applyGenerationReferenceUpload({ - dialog: currentDialog, - target: 'publication-reference', - references: persistedReferences, - }), - ); + try { + const references = await Promise.all( + imageFiles.map(async (file, index) => ({ + file, + index, + imageSrc: await readImageFileAsDataUrl(file), + })), + ); + if (!validateImageReferenceUploadReservation(reservation)) { + return; + } + const persistedReferences = await persistReferenceUploads( + references.map(({ file, imageSrc, index }) => ({ + idPrefix: 'upload-publication-reference', + index, + file, + fallbackLabel: `宣发参考图${index + 1}`, + imageSrc, + })), + reservation, + ); + if ( + persistedReferences.length === 0 || + !validateImageReferenceUploadReservation(reservation) + ) { + return; + } + setGenerateDialog((currentDialog) => + applyGenerationReferenceUpload({ + dialog: currentDialog, + target: 'publication-reference', + references: persistedReferences, + }), + ); + } finally { + reservation.release(); + } }, - [persistReferenceUploads, setGenerateDialog], + [ + persistReferenceUploads, + reserveImageReferenceUpload, + setGenerateDialog, + validateImageReferenceUploadReservation, + ], ); const addVideoReferenceImageFiles = useCallback( @@ -578,7 +886,7 @@ export function useImageCanvasUploadWorkflow({ sizeBytes: file.size, })), ); - if (!persistedReferences) { + if (persistedReferences.length === 0) { return; } setGenerateDialog((currentDialog) => @@ -699,7 +1007,7 @@ export function useImageCanvasUploadWorkflow({ }), ), ); - if (!persistedReferences) { + if (persistedReferences.length === 0) { return; } setGenerateDialog((currentDialog) => @@ -820,7 +1128,7 @@ export function useImageCanvasUploadWorkflow({ }), ), ); - if (!persistedReferences) { + if (persistedReferences.length === 0) { return; } setGenerateDialog((currentDialog) => @@ -841,28 +1149,49 @@ export function useImageCanvasUploadWorkflow({ window.alert('请选择图片文件'); return; } - - const imageSrc = await readImageFileAsDataUrl(imageFile); - const references = await persistReferenceUploads([ - { - idPrefix: 'upload-icon-spec', - file: imageFile, - fallbackLabel: '图标规范', - imageSrc, - }, - ]); - if (!references) { + const reservation = reserveImageReferenceUpload('icon-spec', 1); + if (!reservation) { return; } - setGenerateDialog((currentDialog) => - applyGenerationReferenceUpload({ - dialog: currentDialog, - target: 'icon-spec', - references, - }), - ); + try { + const imageSrc = await readImageFileAsDataUrl(imageFile); + if (!validateImageReferenceUploadReservation(reservation)) { + return; + } + const references = await persistReferenceUploads( + [ + { + idPrefix: 'upload-icon-spec', + file: imageFile, + fallbackLabel: '图标规范', + imageSrc, + }, + ], + reservation, + ); + if ( + references.length === 0 || + !validateImageReferenceUploadReservation(reservation) + ) { + return; + } + setGenerateDialog((currentDialog) => + applyGenerationReferenceUpload({ + dialog: currentDialog, + target: 'icon-spec', + references, + }), + ); + } finally { + reservation.release(); + } }, - [persistReferenceUploads, setGenerateDialog], + [ + persistReferenceUploads, + reserveImageReferenceUpload, + setGenerateDialog, + validateImageReferenceUploadReservation, + ], ); const addUiDesignSpecReferenceFiles = useCallback( @@ -872,28 +1201,49 @@ export function useImageCanvasUploadWorkflow({ window.alert('请选择图片文件'); return; } - - const imageSrc = await readImageFileAsDataUrl(imageFile); - const references = await persistReferenceUploads([ - { - idPrefix: 'upload-ui-design-icon-spec', - file: imageFile, - fallbackLabel: 'UI设计图标规范', - imageSrc, - }, - ]); - if (!references) { + const reservation = reserveImageReferenceUpload('ui-design-icon-spec', 1); + if (!reservation) { return; } - setGenerateDialog((currentDialog) => - applyGenerationReferenceUpload({ - dialog: currentDialog, - target: 'ui-design-icon-spec', - references, - }), - ); + try { + const imageSrc = await readImageFileAsDataUrl(imageFile); + if (!validateImageReferenceUploadReservation(reservation)) { + return; + } + const references = await persistReferenceUploads( + [ + { + idPrefix: 'upload-ui-design-icon-spec', + file: imageFile, + fallbackLabel: 'UI设计图标规范', + imageSrc, + }, + ], + reservation, + ); + if ( + references.length === 0 || + !validateImageReferenceUploadReservation(reservation) + ) { + return; + } + setGenerateDialog((currentDialog) => + applyGenerationReferenceUpload({ + dialog: currentDialog, + target: 'ui-design-icon-spec', + references, + }), + ); + } finally { + reservation.release(); + } }, - [persistReferenceUploads, setGenerateDialog], + [ + persistReferenceUploads, + reserveImageReferenceUpload, + setGenerateDialog, + validateImageReferenceUploadReservation, + ], ); const uploadAssetFile = useCallback( @@ -1244,6 +1594,7 @@ export function useImageCanvasUploadWorkflow({ return { uploadInputRef, uploadTarget, + pendingImageReferenceUploadCount, setUploadTarget, requestUpload, handleUploadInputChange, diff --git a/src/components/image-editor/useInlineGenerationPlaceholderExpiry.test.tsx b/src/components/image-editor/useInlineGenerationPlaceholderExpiry.test.tsx new file mode 100644 index 000000000..638a0c19c --- /dev/null +++ b/src/components/image-editor/useInlineGenerationPlaceholderExpiry.test.tsx @@ -0,0 +1,401 @@ +/* @vitest-environment jsdom */ + +import { act, render, renderHook } from '@testing-library/react'; +import { StrictMode } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { + CanvasGenerationDialogState, + PerfectPixelOperationSnapshot, +} from './ImageCanvasEditorTypes'; +import { + useInlineGenerationPlaceholderExpiry, + useInlineGenerationPlaceholderOwnership, +} from './useInlineGenerationPlaceholderExpiry'; + +const WINDOW_MS = 240_000; + +function perfectPixelOperation( + dialogId: string, +): PerfectPixelOperationSnapshot { + return { + version: 1, + kind: 'perfect-pixel', + operationId: dialogId, + taskId: `pixel-art-snap-${dialogId}`, + request: { + sourceImageSrc: 'ref:project-resource:resource-source', + projectId: 'project-1', + sourceResourceId: 'resource-source', + assetKind: 'character', + assetLabel: '角色 · 完美像素', + canvasCompletion: { + dialogId, + title: '角色 · 完美像素', + placeholder: { + x: 100, + y: 120, + width: 320, + height: 320, + originalWidth: 640, + originalHeight: 640, + }, + }, + }, + submittedAt: 1_700_000_000_000, + reconcileUntil: 1_700_000_120_000, + }; +} + +function dialog( + overrides: Partial, +): CanvasGenerationDialogState { + return { + id: 'dialog-1', + mode: 'quick-edit', + prompt: '完美像素', + status: 'generating', + ...overrides, + } as CanvasGenerationDialogState; +} + +function Harness({ + dialogs, + removeCanvasGenerationDialogById, + onPlaceholdersExpired, +}: { + dialogs: CanvasGenerationDialogState[]; + removeCanvasGenerationDialogById: (dialogId: string) => void; + onPlaceholdersExpired: (expiredCount: number) => void; +}) { + const activeInlineGenerationDialogOwnership = + useInlineGenerationPlaceholderOwnership(); + useInlineGenerationPlaceholderExpiry({ + canvasGenerationDialogs: dialogs, + activeInlineGenerationDialogOwnership, + removeCanvasGenerationDialogById, + onPlaceholdersExpired, + }); + return null; +} + +describe('useInlineGenerationPlaceholderExpiry', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-08-03T00:00:00.000Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it.each(['generating', 'pending-confirmation'] as const)( + 'never expires a durable perfect-pixel placeholder in %s state', + (status) => { + const dialogId = `dialog-perfect-pixel-${status}`; + const removeCanvasGenerationDialogById = vi.fn(); + const onPlaceholdersExpired = vi.fn(); + + render( + , + ); + + // 中文注释:请求快照是 durable operation 凭证,只能通过 GET 对账收口。即使远超 + // legacy 240 秒窗口,也不能挂旧占位删除定时器,更不能触发删除后的布局持久化。 + expect(vi.getTimerCount()).toBe(0); + + act(() => { + vi.advanceTimersByTime(WINDOW_MS * 100); + }); + + expect(removeCanvasGenerationDialogById).not.toHaveBeenCalled(); + expect(onPlaceholdersExpired).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }, + ); + + it('still expires a legacy inline placeholder without an operation snapshot', () => { + const removeCanvasGenerationDialogById = vi.fn(); + const onPlaceholdersExpired = vi.fn(); + + render( + , + ); + + expect(vi.getTimerCount()).toBe(1); + + act(() => { + vi.advanceTimersByTime(WINDOW_MS * 100); + }); + + expect(removeCanvasGenerationDialogById).toHaveBeenCalledOnce(); + expect(removeCanvasGenerationDialogById).toHaveBeenCalledWith('dialog-1'); + expect(onPlaceholdersExpired).toHaveBeenCalledOnce(); + expect(onPlaceholdersExpired).toHaveBeenCalledWith(1); + }); + + it('clears an inline placeholder once its live window elapses', () => { + // 中文注释:加载期剥离只跑一次,当时未到期而被保留的孤儿占位靠这里补收口,否则会一直 + // 转到用户下一次加载——那正是引入存活窗口带来的回归。 + const removeCanvasGenerationDialogById = vi.fn(); + const onPlaceholdersExpired = vi.fn(); + render( + , + ); + + expect(removeCanvasGenerationDialogById).not.toHaveBeenCalled(); + + // 中文注释:定时器触发的是 setState,必须包 act 才会同步刷新出重新判定那一轮。 + act(() => { + vi.advanceTimersByTime(2_000); + }); + + expect(removeCanvasGenerationDialogById).toHaveBeenCalledWith('dialog-1'); + expect(onPlaceholdersExpired).toHaveBeenCalledWith(1); + }); + + it('re-evaluates on wake instead of acting on the armed snapshot', () => { + // 中文注释:挂上定时器之后占位可能已被拥有者会话正常收口。到期回调若按闭包里的旧值行动, + // 就会清掉一个已经完成的占位——判定必须在醒来那一刻用当前 dialogs 重做。 + const removeCanvasGenerationDialogById = vi.fn(); + const onPlaceholdersExpired = vi.fn(); + const startedAt = Date.now() - WINDOW_MS + 1_000; + const { rerender } = render( + , + ); + + rerender( + , + ); + + act(() => { + vi.advanceTimersByTime(10_000); + }); + + expect(removeCanvasGenerationDialogById).not.toHaveBeenCalled(); + expect(onPlaceholdersExpired).not.toHaveBeenCalled(); + }); + + it('still expires while unrelated dialogs keep churning', () => { + // 中文注释:dialogs 变动很频繁(提交工作流三十余处变更点,队列型生成轮询期间持续更新), + // 而这个定时器要熬三分钟。曾怀疑无关变动会不断重挂定时器、让它永远等不到触发;本用例 + // 证明不会:数组身份变化会让 effect 重跑,而 effect 体每次都重新判定到期,频繁变动带来 + // 的是更频繁的判定,不比定时器差;不变动时数组稳定,定时器正常存活。两条路径都收口。 + // 保留这条用例是为了钉住这个性质——将来若把判定挪出 effect 体,就会真的失效。 + const removeCanvasGenerationDialogById = vi.fn(); + const startedAt = Date.now() - WINDOW_MS + 30_000; + const inlineDialog = dialog({ + requiresLiveSession: true, + generationStartedAt: startedAt, + }); + const renderWith = (queuedProgress: number) => ( + + ); + const { rerender } = render(renderWith(0)); + + // 中文注释:每秒一次无关变动,持续到该占位到期之后。 + for (let tick = 1; tick <= 40; tick += 1) { + act(() => { + vi.advanceTimersByTime(1_000); + }); + rerender(renderWith(tick)); + } + + expect(removeCanvasGenerationDialogById).toHaveBeenCalledWith('dialog-1'); + }); + + it('never expires a placeholder the current session still owns', () => { + // 中文注释:占位创建后还要走源图解析/直传和 flush 才轮到受超时保护的 POST,这段慢起来 + // 会越过存活窗口。此时占位仍是 requiresLiveSession + generating,与已死会话留下的孤儿 + // 在状态上完全一致——只能靠显式登记归属区分。删掉自己正在用的占位会让随后的 POST 因 + // 占位不存在返回 409。 + const removeCanvasGenerationDialogById = vi.fn(); + const onPlaceholdersExpired = vi.fn(); + const dialogs = [ + dialog({ + requiresLiveSession: true, + generationStartedAt: Date.now() - WINDOW_MS + 1_000, + }), + ]; + const { result } = renderHook( + () => { + const ownership = useInlineGenerationPlaceholderOwnership(); + useInlineGenerationPlaceholderExpiry({ + canvasGenerationDialogs: dialogs, + activeInlineGenerationDialogOwnership: ownership, + removeCanvasGenerationDialogById, + onPlaceholdersExpired, + }); + return ownership; + }, + { wrapper: StrictMode }, + ); + + expect(vi.getTimerCount()).toBe(1); + act(() => { + expect(result.current.claim('dialog-1')).toBe(true); + }); + expect(result.current.version).toBe(1); + + // 中文注释:claim 只改变 ownership 的私有 Set 与 version;dialogs 和两个 callback + // identity 始终不变。归属过滤必须同时取消下一到期 timer,不能在超窗后进入 50ms 忙等。 + expect(vi.getTimerCount()).toBe(0); + + act(() => { + vi.advanceTimersByTime(WINDOW_MS * 2); + }); + + expect(removeCanvasGenerationDialogById).not.toHaveBeenCalled(); + expect(onPlaceholdersExpired).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('expires the placeholder once the session releases ownership', () => { + // 中文注释:归属在 finally 里释放。此用例不 rerender,也不更换 dialogs / callbacks; + // 唯一唤醒 effect 的必须是生产 ownership hook 在 release 时推进的 version。 + const removeCanvasGenerationDialogById = vi.fn(); + const onPlaceholdersExpired = vi.fn(); + const dialogs = [ + dialog({ + requiresLiveSession: true, + generationStartedAt: Date.now() - WINDOW_MS + 1_000, + }), + ]; + const { result } = renderHook( + () => { + const ownership = useInlineGenerationPlaceholderOwnership(); + useInlineGenerationPlaceholderExpiry({ + canvasGenerationDialogs: dialogs, + activeInlineGenerationDialogOwnership: ownership, + removeCanvasGenerationDialogById, + onPlaceholdersExpired, + }); + return ownership; + }, + { wrapper: StrictMode }, + ); + + act(() => { + expect(result.current.claim('dialog-1')).toBe(true); + expect(result.current.claim('dialog-1')).toBe(false); + }); + expect(result.current.version).toBe(1); + act(() => { + vi.advanceTimersByTime(2_000); + }); + expect(removeCanvasGenerationDialogById).not.toHaveBeenCalled(); + + act(() => { + expect(result.current.release('dialog-1')).toBe(true); + }); + expect(result.current.version).toBe(2); + expect(removeCanvasGenerationDialogById).toHaveBeenCalledWith('dialog-1'); + expect(removeCanvasGenerationDialogById).toHaveBeenCalledOnce(); + expect(onPlaceholdersExpired).toHaveBeenCalledWith(1); + expect(onPlaceholdersExpired).toHaveBeenCalledOnce(); + + act(() => { + expect(result.current.release('dialog-1')).toBe(false); + }); + expect(result.current.version).toBe(2); + expect(removeCanvasGenerationDialogById).toHaveBeenCalledOnce(); + expect(onPlaceholdersExpired).toHaveBeenCalledOnce(); + }); + + it('never arms a timer for queue-backed placeholders', () => { + // 中文注释:队列型占位的 job 在服务端继续跑,worker 会替换占位。自动清理会让用户以为 + // 操作没发生而重复提交。 + const removeCanvasGenerationDialogById = vi.fn(); + render( + , + ); + + act(() => { + vi.advanceTimersByTime(WINDOW_MS * 20); + }); + + expect(removeCanvasGenerationDialogById).not.toHaveBeenCalled(); + }); + + it('clears an already-expired placeholder without waiting for a timer', () => { + // 中文注释:加载时就已超窗的情形由快照侧剥离处理,但内存态也可能直接拿到超窗占位 + // (例如会话内切换项目),此时不该再等一轮定时器。 + const removeCanvasGenerationDialogById = vi.fn(); + render( + , + ); + + expect(removeCanvasGenerationDialogById).toHaveBeenCalledWith('dialog-1'); + }); +}); diff --git a/src/components/image-editor/useInlineGenerationPlaceholderExpiry.ts b/src/components/image-editor/useInlineGenerationPlaceholderExpiry.ts new file mode 100644 index 000000000..7e1f07921 --- /dev/null +++ b/src/components/image-editor/useInlineGenerationPlaceholderExpiry.ts @@ -0,0 +1,140 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { + collectExpiredInlineGenerationDialogIds, + resolveNextInlineGenerationDialogExpiryAt, +} from './ImageCanvasEditorModel'; +import type { CanvasGenerationDialogState } from './ImageCanvasEditorTypes'; + +export type InlineGenerationPlaceholderOwnership = Readonly<{ + version: number; + claim: (dialogId: string) => boolean; + release: (dialogId: string) => boolean; + has: (dialogId: string) => boolean; +}>; + +/** + * 中文注释:同步 Set 是本会话 ownership 的即时真值;version 只负责把 membership 变化 + * 通知给 React。所有写入都必须经过 claim / release,避免直接修改 ref 后 effect 无法观察。 + */ +export function useInlineGenerationPlaceholderOwnership(): InlineGenerationPlaceholderOwnership { + const activeDialogIdsRef = useRef(new Set()); + const [version, setVersion] = useState(0); + const claim = useCallback((dialogId: string) => { + if (activeDialogIdsRef.current.has(dialogId)) { + return false; + } + activeDialogIdsRef.current.add(dialogId); + setVersion((currentVersion) => currentVersion + 1); + return true; + }, []); + const release = useCallback((dialogId: string) => { + if (!activeDialogIdsRef.current.delete(dialogId)) { + return false; + } + setVersion((currentVersion) => currentVersion + 1); + return true; + }, []); + const has = useCallback( + (dialogId: string) => activeDialogIdsRef.current.has(dialogId), + [], + ); + + return useMemo( + () => ({ + version, + claim, + release, + has, + }), + [claim, has, release, version], + ); +} + +type InlineGenerationPlaceholderExpiryOptions = { + canvasGenerationDialogs: CanvasGenerationDialogState[]; + activeInlineGenerationDialogOwnership: InlineGenerationPlaceholderOwnership; + removeCanvasGenerationDialogById: (dialogId: string) => void; + onPlaceholdersExpired: (expiredCount: number) => void; +}; + +/** + * 中文注释:页面打开期间的 inline 占位到期清理。 + * + * 加载期的 `dropDeadInlineGenerationPlaceholders` 只在项目首次加载执行一次,所以当时还没 + * 越过存活窗口、被保留下来的孤儿占位再没有任何东西会重新判定——它会一直转到用户下一次 + * 加载为止。本 hook 补上这一段:算出下一个到期时刻挂一次性定时器,到点按同一条规则处置。 + * + * 三个必须守住的点: + * + * 一、到期回调**不做判定**,只推进 tick 让 effect 重跑,判定始终在 effect 体里用当前的 + * dialogs 和当前时间做。挂上定时器之后占位可能已被拥有者会话正常收口、或被用户删掉, + * 按闭包里的旧值行动会清掉一个已经完成的占位。 + * + * 二、清理用底层的 `removeCanvasGenerationDialogById`,不是 View 的 + * `removeCanvasGenerationDialog`。后者是用户主动删除的语义:写一条 + * `delete-generation-result` 历史、清空选中、切回选择工具。自动清理记用户没做过的历史、 + * 抢走用户当前的选中态和工具,都是错的;加载期剥离同样不做这些。 + * + * 三、本会话自己在途的占位不会被误清。ownership 的同步 Set 负责在首个 await 前立即 + * 挡住清理;claim / release 同时推进可观察 version,让 membership 变化必然触发重新判定。 + * 不能把可变 ref 对象本身放进依赖后直接修改 `.current`,React 不会观察这种变化。 + */ +export function useInlineGenerationPlaceholderExpiry({ + canvasGenerationDialogs, + activeInlineGenerationDialogOwnership, + removeCanvasGenerationDialogById, + onPlaceholdersExpired, +}: InlineGenerationPlaceholderExpiryOptions) { + const [expiryTick, setExpiryTick] = useState(0); + const { + has: isDialogOwnedByCurrentSession, + version: activeInlineGenerationOwnershipVersion, + } = activeInlineGenerationDialogOwnership; + useEffect(() => { + // 中文注释:本会话仍在执行的占位一律跳过。到期清理只针对已死会话留下的孤儿,而 + // dialog 状态区分不出这两者——本会话在源图直传或布局保存阶段慢起来时,它的占位同样 + // 是 requiresLiveSession + generating,按状态判定会把自己正在用的占位删掉,随后 POST + // 因占位不存在返回 409。 + // + // 这一条不能靠「到期重新判定」兜住:重新判定只能识别「已经收口的占位」,识别不出 + // 「仍在合法运行的占位」——后者正处于要被删除的那个状态。归属必须显式登记。 + // 中文注释:归属过滤必须先于两处判定同时生效。只过滤 expiredIds 会留下一个死循环: + // 被本会话持有的超窗占位不进 expiredIds,却仍被 resolveNext... 算出一个**已经过去**的 + // 到期时刻,delayMs 塌成 50ms,定时器触发 → tick → 重跑 → 状态没变 → 再挂 50ms, + // 变成每 50 毫秒一次 setState 的忙等。可达路径是真实的:前置 90 秒 + POST 120 秒之后 + // catch 里还要做对账 GET,而归属要到 finally 才释放。 + const unownedDialogs = canvasGenerationDialogs.filter( + (dialog) => !isDialogOwnedByCurrentSession(dialog.id), + ); + const expiredIds = collectExpiredInlineGenerationDialogIds(unownedDialogs); + if (expiredIds.length > 0) { + for (const dialogId of expiredIds) { + removeCanvasGenerationDialogById(dialogId); + } + onPlaceholdersExpired(expiredIds.length); + return undefined; + } + const nextExpiryAt = + resolveNextInlineGenerationDialogExpiryAt(unownedDialogs); + if (nextExpiryAt === null) { + return undefined; + } + // 中文注释:多给 50ms 余量。定时器只保证「不早于」,贴着到期时刻醒来会让 effect 重跑一次 + // 却判定为未到期,白白多挂一轮。 + const delayMs = Math.max(0, nextExpiryAt - Date.now()) + 50; + const timer = window.setTimeout(() => { + setExpiryTick((currentTick) => currentTick + 1); + }, delayMs); + return () => { + window.clearTimeout(timer); + }; + }, [ + activeInlineGenerationOwnershipVersion, + canvasGenerationDialogs, + expiryTick, + isDialogOwnedByCurrentSession, + onPlaceholdersExpired, + removeCanvasGenerationDialogById, + ]); +} diff --git a/src/components/platform-entry/PlatformProfileApiKeysModal.tsx b/src/components/platform-entry/PlatformProfileApiKeysModal.tsx index 1ee0a4758..53262e002 100644 --- a/src/components/platform-entry/PlatformProfileApiKeysModal.tsx +++ b/src/components/platform-entry/PlatformProfileApiKeysModal.tsx @@ -20,6 +20,7 @@ import { PlatformProfileSummaryHeader } from '../common/PlatformProfileSummaryHe import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { PlatformTextField } from '../common/PlatformTextField'; import { useCopyFeedback } from '../common/useCopyFeedback'; +import { formatPlatformProfileTime } from './platformProfileFundsModel'; import { PlatformProfileSecondaryModalShell } from './PlatformProfileModalShell'; type PlatformProfileApiKeysModalProps = { @@ -34,14 +35,7 @@ function buildApiKeyTimeLabel(value: string | null) { if (!value) { return '尚未使用'; } - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return value; - } - const year = date.getUTCFullYear(); - const month = String(date.getUTCMonth() + 1).padStart(2, '0'); - const day = String(date.getUTCDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; + return formatPlatformProfileTime(value); } function buildApiKeyScopeLabel(scopes: string[]) { diff --git a/src/components/platform-entry/platformProfileApiKeysModal.test.ts b/src/components/platform-entry/platformProfileApiKeysModal.test.ts new file mode 100644 index 000000000..ebd15fec5 --- /dev/null +++ b/src/components/platform-entry/platformProfileApiKeysModal.test.ts @@ -0,0 +1,45 @@ +/* @vitest-environment jsdom */ + +import { render, screen } from '@testing-library/react'; +import { createElement } from 'react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { PlatformProfileApiKeysModal } from './PlatformProfileApiKeysModal'; + +const listExternalApiKeysMock = vi.hoisted(() => vi.fn()); + +vi.mock('../../services/platform-entry/platformProfileClient', () => ({ + createPlatformProfileExternalApiKey: vi.fn(), + listPlatformProfileExternalApiKeys: listExternalApiKeysMock, + revokePlatformProfileExternalApiKey: vi.fn(), +})); + +describe('PlatformProfileApiKeysModal', () => { + beforeEach(() => { + listExternalApiKeysMock.mockReset(); + }); + + test('将 API Key 时间戳格式化为日期', async () => { + listExternalApiKeysMock.mockResolvedValueOnce({ + keys: [ + { + keyId: 'api-key-1', + name: '外部 API Key', + keyPrefix: 'tnr_sk_example', + scopes: ['editor:project'], + createdAt: '1785913407.182731Z', + lastUsedAt: null, + revokedAt: null, + updatedAt: '1785913407.182731Z', + }, + ], + }); + + render(createElement(PlatformProfileApiKeysModal, { onClose: vi.fn() })); + + expect( + await screen.findByText('创建 2026-08-05 · 最近使用 尚未使用'), + ).toBeTruthy(); + expect(screen.queryByText(/1785913407\.182731Z/u)).toBeNull(); + }); +}); diff --git a/src/index.css b/src/index.css index 46d8e7c6e..3c49bc11d 100644 --- a/src/index.css +++ b/src/index.css @@ -5558,6 +5558,14 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { box-shadow: 0 18px 32px var(--image-canvas-brand-shadow); } +.image-canvas-editor__generation-frame--pending-confirmation { + border-color: var(--image-canvas-brand-accent); + cursor: pointer; + box-shadow: + 0 18px 42px rgba(15, 23, 42, 0.1), + inset 0 0 0 1px rgba(255, 255, 255, 0.72); +} + .image-canvas-editor__generation-frame-progress { position: absolute; left: 50%; @@ -5586,6 +5594,10 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { animation: image-canvas-generation-pulse 1s ease-in-out infinite; } +.image-canvas-editor__generation-frame-progress--pending-confirmation::before { + animation: none; +} + @keyframes image-canvas-generation-scan { from { transform: translateX(-100%); diff --git a/src/services/apiClient.test.ts b/src/services/apiClient.test.ts index d411171bf..8bd1ba54a 100644 --- a/src/services/apiClient.test.ts +++ b/src/services/apiClient.test.ts @@ -55,6 +55,16 @@ function createResponseMock(params: { }; } +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + describe('apiClient', () => { const fetchMock = vi.fn(); const dispatchEventMock = vi.fn(); @@ -687,6 +697,83 @@ describe('apiClient', () => { expect(capturedError).toBeInstanceOf(Error); }); + it.each(['missing-token', 'unauthorized'] as const)( + 'bounds the %s refresh wait with an absolute request deadline', + async (refreshMode) => { + const refreshResponse = createDeferred< + ReturnType + >(); + if (refreshMode === 'unauthorized') { + setStoredAccessToken('expired-token', { emit: false }); + fetchMock + .mockResolvedValueOnce(createResponseMock({ status: 401 })) + .mockImplementationOnce(() => refreshResponse.promise); + } else { + fetchMock.mockImplementationOnce(() => refreshResponse.promise); + } + + const request = requestJson( + '/api/runtime/protected', + { method: 'GET' }, + '读取受保护数据失败', + { deadlineAt: Date.now() + 20 }, + ); + + await expect(request).rejects.toMatchObject({ name: 'TimeoutError' }); + expect(fetchMock).toHaveBeenCalledTimes( + refreshMode === 'unauthorized' ? 2 : 1, + ); + expect(fetchMock.mock.calls.at(-1)?.[0]).toBe('/api/auth/refresh'); + if (refreshMode === 'unauthorized') { + expect(getStoredAccessToken()).toBe('expired-token'); + } else { + expect(getStoredAccessToken()).toBe(''); + } + expect(dispatchEventMock).not.toHaveBeenCalled(); + + refreshResponse.resolve( + createResponseMock({ + status: 200, + body: JSON.stringify({ + ok: true, + data: { token: 'late-refresh-token' }, + error: null, + meta: { apiVersion: '2026-06-16' }, + }), + }), + ); + await vi.waitFor(() => { + expect(getStoredAccessToken()).toBe('late-refresh-token'); + }); + expect(dispatchEventMock).not.toHaveBeenCalled(); + }, + ); + + it.each([200, 400])( + 'bounds a %i response body read with the same absolute request deadline', + async (status) => { + setStoredAccessToken('body-timeout-token', { emit: false }); + const responseBody = createDeferred(); + const response = createResponseMock({ status }); + response.text.mockImplementationOnce(() => responseBody.promise); + fetchMock.mockResolvedValueOnce(response); + + const request = requestJson( + '/api/runtime/protected', + { method: 'GET' }, + '读取受保护数据失败', + { deadlineAt: Date.now() + 20 }, + ); + + await expect(request).rejects.toMatchObject({ name: 'TimeoutError' }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(response.text).toHaveBeenCalledTimes(1); + + responseBody.resolve(''); + await responseBody.promise; + }, + ); + it('surfaces response metadata through ApiClientError', async () => { setStoredAccessToken('metadata-token', { emit: false }); fetchMock.mockResolvedValueOnce( diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index 1c19e8f16..94211f5e0 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -49,6 +49,12 @@ export type ApiRequestOptions = { requestId?: string; }; +export type ApiJsonRequestOptions = ApiRequestOptions & { + // 从 requestJson 入口起覆盖鉴权、重试、业务请求和响应体读取的绝对截止时间。 + // 未传时保持既有 timeoutMs 仅约束单次业务 fetch 的语义。 + deadlineAt?: number; +}; + export const BACKGROUND_AUTH_REQUEST_OPTIONS = { authImpact: 'local', skipRefresh: true, @@ -317,6 +323,93 @@ function composeAbortSignal( }; } +function composeAbsoluteDeadlineSignal( + signal: AbortSignal | undefined, + deadlineAt: number | undefined, +) { + const hasDeadline = + typeof deadlineAt === 'number' && Number.isFinite(deadlineAt); + if (!hasDeadline) { + return { + signal, + hasDeadline: false, + cleanup: () => {}, + }; + } + + const controller = new AbortController(); + const remainingMs = Math.max(0, deadlineAt - Date.now()); + let timeoutId: ReturnType | undefined; + const cleanup = () => { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + signal?.removeEventListener('abort', onAbort); + }; + const onAbort = () => { + controller.abort(signal?.reason ?? createAbortError()); + }; + + if (signal?.aborted) { + controller.abort(signal.reason ?? createAbortError()); + } else { + signal?.addEventListener('abort', onAbort, { once: true }); + if (remainingMs <= 0) { + controller.abort(createTimeoutError(0)); + } else { + timeoutId = setTimeout(() => { + controller.abort(createTimeoutError(remainingMs)); + }, remainingMs); + } + } + + return { + signal: controller.signal, + hasDeadline: true, + cleanup, + }; +} + +function awaitWithAbortSignal( + work: Promise, + signal?: AbortSignal, +): Promise { + if (!signal) { + return work; + } + + return new Promise((resolve, reject) => { + let settled = false; + const cleanup = () => { + signal.removeEventListener('abort', onAbort); + }; + const settle = (callback: () => void) => { + if (settled) { + return; + } + settled = true; + cleanup(); + callback(); + }; + const onAbort = () => { + settle(() => reject(signal.reason ?? createAbortError())); + }; + + signal.addEventListener('abort', onAbort, { once: true }); + work.then( + (value) => { + settle(() => resolve(value)); + }, + (error: unknown) => { + settle(() => reject(error)); + }, + ); + if (signal.aborted) { + onAbort(); + } + }); +} + async function waitForRetry(ms: number, signal?: AbortSignal) { if (ms <= 0) { return; @@ -451,6 +544,31 @@ function resolveAuthFailurePolicy( }; } +// 中文注释:这些错误体由 Pingora 网关自己合成,应用层从未产生过——它们只有 code / message, +// 没有 details,所以任何「有 details 标记就是已知结果」的判定对它们都不成立。 +// 只收上游/代理这三类:请求可能已经到达 api-server 并被执行完,只是响应没回到客户端, +// 属于未知结果。GATEWAY_RATE_LIMITED / GATEWAY_CONCURRENCY_LIMITED / PAYLOAD_TOO_LARGE +// 是在网关就被拒、根本没到应用,属于确定失败,收进来会让普通节流也弹出「请核对素材库」, +// 变成反向谎报。 +const GATEWAY_UNKNOWN_OUTCOME_ERROR_CODES = new Set([ + 'GATEWAY_UPSTREAM_ERROR', + 'GATEWAY_UPSTREAM_TIMEOUT', + 'GATEWAY_PROXY_ERROR', +]); + +/** + * 中文注释:该错误是否由网关合成、因而无法证明应用层没有执行过这次请求。 + * + * 有副作用的写接口在判断「结果是否已知」时必须把它算作未知:拿到 HTTP 响应不等于服务端 + * 明确表态过,网关超时或上游断连时 api-server 可能已经完成了持久化。 + */ +export function isGatewayUnknownOutcomeError(error: unknown): boolean { + return ( + error instanceof ApiClientError && + GATEWAY_UNKNOWN_OUTCOME_ERROR_CODES.has(error.code) + ); +} + export class ApiClientError extends Error { status: number; code: string; @@ -677,14 +795,17 @@ export async function fetchWithApiAuth( try { // 受保护请求在本地 access token 缺失时,先尝试用 refresh cookie 静默补票, // 避免把后端原始 “缺少 Bearer Token” 直接暴露给业务 UI。 - await ensureStoredAccessToken(); + await awaitWithAbortSignal(ensureStoredAccessToken(), requestSignal); requestHeaders = withAuthorizationHeaders(init.headers, options); requestHeaders[REQUEST_ID_HEADER] = requestId; hasAuthHeader = Boolean( requestHeaders.Authorization?.trim() || requestHeaders.authorization?.trim(), ); - } catch { + } catch (error) { + if (requestSignal?.aborted) { + throw requestSignal.reason ?? error; + } // 补票失败时继续走原始请求,让调用方按真实 401 分支处理。 } } @@ -710,13 +831,16 @@ export async function fetchWithApiAuth( !refreshAttempted ) { try { - await refreshAccessToken(); + await awaitWithAbortSignal(refreshAccessToken(), requestSignal); refreshAttempted = true; // refresh 成功只代表 access token 已补票成功, // 不能把当前业务请求的首次 401 直接放大成全局鉴权变更, // 否则像 Puzzle works 这类受保护列表会把单接口失败放大成整个平台重复 hydrate。 continue; } catch (refreshError) { + if (requestSignal?.aborted) { + throw requestSignal.reason ?? refreshError; + } const shouldClearAuth = hasAuthHeader && authFailurePolicy.clearAuthOnUnauthorized && @@ -746,6 +870,9 @@ export async function fetchWithApiAuth( return response; } } catch (error) { + if (requestSignal?.aborted) { + throw requestSignal.reason ?? error; + } if (!shouldRetryError(error, attempt, retry)) { throw error; } @@ -759,8 +886,9 @@ export async function fetchWithApiAuth( async function buildApiClientError( response: Response, fallbackMessage: string, + signal?: AbortSignal, ) { - const responseText = await response.text(); + const responseText = await awaitWithAbortSignal(response.text(), signal); const parsedError = parseApiErrorShape(responseText); const requestId = parsedError?.meta.requestId ?? @@ -795,17 +923,42 @@ export async function requestJson( url: string, init: RequestInit, fallbackMessage: string, - options: ApiRequestOptions = {}, + options: ApiJsonRequestOptions = {}, ): Promise { - const response = await fetchWithApiAuth(url, init, options); + const lifecycle = composeAbsoluteDeadlineSignal( + init.signal ?? undefined, + options.deadlineAt, + ); + const requestOptions = lifecycle.hasDeadline + ? { ...options, timeoutMs: undefined } + : options; + const requestInit = lifecycle.signal + ? { ...init, signal: lifecycle.signal } + : init; - if (!response.ok) { - throw await buildApiClientError(response, fallbackMessage); + try { + const response = await fetchWithApiAuth(url, requestInit, requestOptions); + + if (!response.ok) { + throw await buildApiClientError( + response, + fallbackMessage, + lifecycle.signal, + ); + } + + const responseText = await awaitWithAbortSignal( + response.text(), + lifecycle.signal, + ); + if (lifecycle.signal?.aborted) { + throw lifecycle.signal.reason ?? createAbortError(); + } + + return responseText + ? unwrapApiResponse(JSON.parse(responseText) as T) + : (null as T); + } finally { + lifecycle.cleanup(); } - - const responseText = await response.text(); - - return responseText - ? unwrapApiResponse(JSON.parse(responseText) as T) - : (null as T); } diff --git a/src/services/image-editor/editorMediaAssetUploadClient.test.ts b/src/services/image-editor/editorMediaAssetUploadClient.test.ts index 37d1268bb..4309d79a6 100644 --- a/src/services/image-editor/editorMediaAssetUploadClient.test.ts +++ b/src/services/image-editor/editorMediaAssetUploadClient.test.ts @@ -35,7 +35,7 @@ describe('editorMediaAssetUploadClient', () => { vi.unstubAllGlobals(); }); - it('uploads editor MP4 assets through direct OSS upload and confirms object metadata', async () => { + it('uploads editor MP4 assets and forwards one abort signal through signed-url resolution', async () => { requestJsonMock .mockResolvedValueOnce({ upload: { @@ -63,16 +63,19 @@ describe('editorMediaAssetUploadClient', () => { getSignedAssetReadUrlMock.mockResolvedValueOnce( 'https://signed.example.com/demo.mp4', ); + const abortController = new AbortController(); const result = await uploadEditorMediaAssetFile( new File(['video'], 'demo.mp4', { type: 'video/mp4' }), 'video', + { signal: abortController.signal }, ); expect(requestJsonMock).toHaveBeenNthCalledWith( 1, '/api/assets/direct-upload-tickets', expect.objectContaining({ + signal: abortController.signal, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: expect.any(String), @@ -99,6 +102,7 @@ describe('editorMediaAssetUploadClient', () => { expect(fetch).toHaveBeenCalledWith( 'https://oss.example.com', expect.objectContaining({ + signal: abortController.signal, method: 'POST', body: expect.any(FormData), }), @@ -110,6 +114,7 @@ describe('editorMediaAssetUploadClient', () => { 2, '/api/assets/objects/confirm', expect.objectContaining({ + signal: abortController.signal, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: expect.any(String), @@ -119,7 +124,8 @@ describe('editorMediaAssetUploadClient', () => { ); expect(confirmBody).toMatchObject({ bucket: 'bucket', - objectKey: 'generated-character-drafts/editor/asset-library/video/demo.mp4', + objectKey: + 'generated-character-drafts/editor/asset-library/video/demo.mp4', contentType: 'video/mp4', contentLength: 5, assetKind: 'editor_uploaded_video', @@ -132,7 +138,7 @@ describe('editorMediaAssetUploadClient', () => { 'generated-character-drafts/editor/asset-library/video/demo.mp4', expireSeconds: 3600, }, - undefined, + abortController.signal, { bypassCache: true }, ); expect(result).toEqual({ @@ -194,7 +200,8 @@ describe('editorMediaAssetUploadClient', () => { (requestJsonMock.mock.calls[1]?.[1] as RequestInit).body as string, ); expect(confirmBody).toMatchObject({ - objectKey: 'generated-character-drafts/editor/asset-library/image/spec.png', + objectKey: + 'generated-character-drafts/editor/asset-library/image/spec.png', contentType: 'image/png', contentLength: 5, assetKind: 'editor_uploaded_image', @@ -260,12 +267,7 @@ describe('editorMediaAssetUploadClient', () => { (requestJsonMock.mock.calls[0]?.[1] as RequestInit).body as string, ); expect(ticketBody).toMatchObject({ - pathSegments: [ - 'editor', - 'project-covers', - 'project-1', - '1771400000000', - ], + pathSegments: ['editor', 'project-covers', 'project-1', '1771400000000'], fileName: 'project-1-cover.png', metadata: { asset_kind: 'editor_project_cover_snapshot', diff --git a/src/services/image-editor/editorMediaAssetUploadClient.ts b/src/services/image-editor/editorMediaAssetUploadClient.ts index 366676891..2437878a6 100644 --- a/src/services/image-editor/editorMediaAssetUploadClient.ts +++ b/src/services/image-editor/editorMediaAssetUploadClient.ts @@ -42,6 +42,9 @@ export type EditorMediaAssetUploadOptions = { pathSegments?: string[]; entityId?: string; metadata?: Record; + // 中文注释:贯穿凭证、直传与 confirm 三步。只中止直传会留下未 confirm 的 OSS 对象, + // 只中止 confirm 又会让实体已写入却无记录——要停就整条链一起停。 + signal?: AbortSignal; }; const EDITOR_MEDIA_READ_EXPIRE_SECONDS = 60 * 60; @@ -86,6 +89,7 @@ export async function uploadEditorMediaAssetObjectFile( const ticket = await requestJson( '/api/assets/direct-upload-tickets', { + signal: options.signal, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -112,11 +116,17 @@ export async function uploadEditorMediaAssetObjectFile( { retry: EDITOR_REQUEST_RETRY_OPTIONS }, ); - await postEditorDirectUploadFile(ticket.upload, file, '上传素材失败'); + await postEditorDirectUploadFile( + ticket.upload, + file, + '上传素材失败', + options.signal, + ); const confirmed = await requestJson( '/api/assets/objects/confirm', { + signal: options.signal, method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -156,7 +166,7 @@ export async function uploadEditorMediaAssetFile( objectKey: uploaded.objectKey, expireSeconds: EDITOR_MEDIA_READ_EXPIRE_SECONDS, }, - undefined, + options.signal, { bypassCache: true }, ); return { diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index 138867a8e..919f07013 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -26,6 +26,7 @@ import { removeEditorImageBackground, renameEditorProject, saveEditorProjectLayout, + snapEditorImageToPixelArt, splitEditorIconSpritesheet, submitEditorAssetShowcase, toggleEditorShowcaseAssetLike, @@ -36,9 +37,8 @@ import { const requestJsonMock = vi.hoisted(() => vi.fn()); const editorRetryOptionsExpectation = expect.objectContaining({ - maxRetries: 2, - retryUnsafeMethods: true, - retryableStatusCodes: expect.arrayContaining([429]), + maxRetries: 0, + retryUnsafeMethods: false, }); vi.mock('../apiClient', () => ({ @@ -155,6 +155,7 @@ describe('editorProjectClient', () => { }); it('saves viewport and layer layout through the project API', async () => { + const startedAt = Date.now(); requestJsonMock.mockResolvedValueOnce({ projectId: 'editor-project-1', canvasId: 'editor-project-1:canvas:default', @@ -188,7 +189,15 @@ describe('editorProjectClient', () => { }), }), '保存图片画布工程失败', + { deadlineAt: expect.any(Number) }, ); + const requestOptions = requestJsonMock.mock.calls[0]?.[3] as + | { deadlineAt?: number } + | undefined; + expect(requestOptions?.deadlineAt).toBeGreaterThanOrEqual( + startedAt + 60_000, + ); + expect(requestOptions?.deadlineAt).toBeLessThanOrEqual(Date.now() + 60_000); }); it('lists editor projects from the project API', async () => { @@ -329,6 +338,70 @@ describe('editorProjectClient', () => { '/api/editor/projects/editor-project-1', { method: 'GET' }, '读取图片画布工程失败', + // 中文注释:同上,超时写死在断言里。这是未知结果对账路径上的读取,挂住会让 catch + // 迟迟不结束,连带把本会话对占位的归属登记一起拖过存活窗口。 + { timeoutMs: 60_000 }, + ); + }); + + it('forwards an abort signal and custom timeout when loading a project', async () => { + const controller = new AbortController(); + requestJsonMock.mockResolvedValueOnce({ + project: { + projectId: 'editor-project-1', + title: '角色设定板', + canvas: { + canvasId: 'editor-project-1:canvas:default', + projectId: 'editor-project-1', + title: '默认画布', + viewport: { x: 8, y: 9, scale: 1.5 }, + layers: [], + updatedAt: '2026-06-12T00:00:00.000Z', + }, + viewport: { x: 8, y: 9, scale: 1.5 }, + layers: [], + resources: [], + updatedAt: '2026-06-12T00:00:00.000Z', + }, + }); + + await loadEditorProject('editor-project-1', { + signal: controller.signal, + timeoutMs: 5_000, + }); + + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/editor/projects/editor-project-1', + { method: 'GET', signal: controller.signal }, + '读取图片画布工程失败', + { timeoutMs: 5_000 }, + ); + }); + + it('forwards an absolute lifecycle deadline when loading a project', async () => { + const controller = new AbortController(); + const deadlineAt = Date.now() + 10_000; + requestJsonMock.mockResolvedValueOnce({ + project: { + projectId: 'editor-project-1', + title: '角色设定板', + viewport: { x: 8, y: 9, scale: 1.5 }, + layers: [], + resources: [], + updatedAt: '2026-06-12T00:00:00.000Z', + }, + }); + + await loadEditorProject('editor-project-1', { + signal: controller.signal, + deadlineAt, + }); + + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/editor/projects/editor-project-1', + { method: 'GET', signal: controller.signal }, + '读取图片画布工程失败', + { deadlineAt }, ); }); @@ -838,6 +911,79 @@ describe('editorProjectClient', () => { expect(requestJsonMock).not.toHaveBeenCalled(); }); + it('rejects image reference overflow before submitting generation requests', async () => { + const references = Array.from( + { length: 6 }, + (_, index) => `/generated-images/editor/reference-${index + 1}.png`, + ); + + await expect( + generateEditorImage({ + prompt: '一张画布图片', + referenceImageSrcs: references, + }), + ).rejects.toThrow('生成参考图最多允许 5 张'); + await expect( + generateEditorImage({ + prompt: '快速编辑画布图片', + kind: 'quick-edit', + referenceImageSrcs: references, + }), + ).rejects.toThrow('生成参考图最多允许 5 张'); + await expect( + generateEditorIconSpritesheet({ + referenceImageSrc: '/generated-images/editor/spec.png', + referenceImageSrcs: references.slice(0, 5), + iconDescriptions: ['返回按钮'], + model: 'gpt-image-2', + }), + ).rejects.toThrow('图标素材参考图最多允许 4 张'); + await expect( + editEditorImage({ + prompt: '修改图片', + sourceImageSrc: '/generated-images/editor/source.png', + referenceImageSrcs: references.slice(0, 5), + model: 'gpt-image-2', + }), + ).rejects.toThrow('修改参考图最多允许 4 张'); + await expect( + extractEditorUiDesignAssets({ + sourceImageSrc: '/generated-images/editor/ui.png', + referenceImageSrcs: references.slice(0, 5), + model: 'gpt-image-2', + aspectRatio: '1:1', + imageSize: '1K', + }), + ).rejects.toThrow('UI素材参考图最多允许 4 张'); + + expect(requestJsonMock).not.toHaveBeenCalled(); + }); + + it('uses the nanobanana2 capacity when icon generation omits the model', async () => { + requestJsonMock.mockResolvedValue({ queueState: { status: 'queued' } }); + const references = Array.from( + { length: 8 }, + (_, index) => `/generated-images/editor/icon-reference-${index + 1}.png`, + ); + + await generateEditorIconSpritesheet({ + referenceImageSrc: '/generated-images/editor/icon-spec.png', + referenceImageSrcs: references, + iconDescriptions: ['返回按钮'], + }); + + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/editor/icon-spritesheets/generations', + expect.objectContaining({ + body: expect.stringContaining( + '"model":"gemini-3.1-flash-image-preview"', + ), + }), + '生成图标素材失败', + expect.anything(), + ); + }); + it('rejects oversized stable video reference fields before submission', async () => { await expect( generateEditorVideo({ @@ -1781,4 +1927,103 @@ describe('editorProjectClient', () => { }), ); }); + + it('submits a stable perfect-pixel source without unsafe POST retries', async () => { + requestJsonMock.mockResolvedValueOnce({ + imageSrc: '/generated-images/editor/perfect-pixel.png', + objectKey: 'generated-images/editor/perfect-pixel.png', + assetObjectId: 'asset-object-perfect-pixel', + width: 1024, + height: 768, + sourceType: 'generated', + taskId: 'perfect-pixel-1', + elapsedMs: 321, + provider: 'Genarrative', + resource: { resourceId: 'resource-perfect-pixel' }, + asset: { assetId: 'asset-perfect-pixel' }, + project: null, + }); + + const result = await snapEditorImageToPixelArt({ + sourceImageSrc: 'generated-images/editor/source.png', + projectId: 'editor-project-1', + sourceResourceId: 'resource-source', + assetKind: 'character', + generationInputs: { + fields: [{ title: '角色设定', value: '红发骑士' }], + references: [], + }, + assetFolderId: 'project', + assetLabel: '源图 · 完美像素', + canvasCompletion: { + dialogId: 'generation-dialog-perfect-pixel', + title: '源图 · 完美像素', + placeholder: { + x: 472, + y: 140, + width: 320, + height: 240, + originalWidth: 320, + originalHeight: 240, + }, + }, + }); + + expect(result.taskId).toBe('perfect-pixel-1'); + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/editor/images/pixel-art-snaps', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sourceImageSrc: 'generated-images/editor/source.png', + projectId: 'editor-project-1', + sourceResourceId: 'resource-source', + assetKind: 'character', + generationInputs: { + fields: [{ title: '角色设定', value: '红发骑士' }], + references: [], + }, + assetFolderId: 'project', + assetLabel: '源图 · 完美像素', + canvasCompletion: { + dialogId: 'generation-dialog-perfect-pixel', + title: '源图 · 完美像素', + placeholder: { + x: 472, + y: 140, + width: 320, + height: 240, + originalWidth: 320, + originalHeight: 240, + }, + }, + }), + }, + '完美像素处理失败', + { timeoutMs: 120_000 }, + ); + }); + + it('rejects inline perfect-pixel media before sending the request', async () => { + await expect( + snapEditorImageToPixelArt({ + sourceImageSrc: 'data:image/png;base64,source', + projectId: 'editor-project-1', + canvasCompletion: { + dialogId: 'generation-dialog-perfect-pixel', + title: '源图 · 完美像素', + placeholder: { + x: 0, + y: 0, + width: 320, + height: 240, + originalWidth: 320, + originalHeight: 240, + }, + }, + }), + ).rejects.toThrow('待完美像素处理图片必须先上传 OSS'); + expect(requestJsonMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index bcdc0e54f..b8aeee00a 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -1,7 +1,7 @@ import type { EditorSceneGenerationRequest } from '../../../packages/shared/src/contracts/editorScene'; import type { ExternalGenerationJobStatusRecord } from '../../../packages/shared/src/contracts/externalGeneration'; import { requestJson } from '../apiClient'; -import { EDITOR_REQUEST_RETRY_OPTIONS } from './editorRetryOptions'; +import { EDITOR_GENERATION_REQUEST_RETRY_OPTIONS } from './editorRetryOptions'; const EDITOR_PROJECT_API_BASE = '/api/editor/projects'; const EDITOR_ASSET_API_BASE = '/api/editor/assets'; @@ -12,6 +12,7 @@ const EDITOR_IMAGE_GENERATION_API = '/api/editor/images/generations'; const EDITOR_SCENE_GENERATION_API = '/api/editor/scenes/generations'; const EDITOR_IMAGE_EDIT_API = '/api/editor/images/edits'; const EDITOR_BACKGROUND_REMOVAL_API = '/api/editor/images/background-removals'; +const EDITOR_PIXEL_ART_SNAP_API = '/api/editor/images/pixel-art-snaps'; const EDITOR_ICON_SPRITESHEET_GENERATION_API = '/api/editor/icon-spritesheets/generations'; const EDITOR_ICON_SPRITESHEET_SLICE_API = @@ -27,6 +28,11 @@ const EDITOR_BACKGROUND_MUSIC_GENERATION_API = '/api/editor/audios/background-music/generations'; const EDITOR_GENERATION_PRICING_API = '/api/editor/generation-pricing'; const EDITOR_IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview'; +const EDITOR_IMAGE_REFERENCE_LIMIT = 5; +const EDITOR_QUICK_EDIT_REFERENCE_LIMIT = 9; +const EDITOR_IMAGE_EDIT_EXTRA_REFERENCE_LIMIT = 8; +const EDITOR_ICON_EXTRA_REFERENCE_LIMIT = 8; +const EDITOR_UI_EXTRA_REFERENCE_LIMIT = 5; const EDITOR_VIDEO_REFERENCE_REQUEST_LIMIT_BYTES = 256 * 1024; const DEFAULT_PROJECT_TITLE = '未命名画布'; @@ -48,6 +54,26 @@ function assertStableEditorMediaReferences( ); } +function resolveEditorProviderReferenceLimit(model: string | null | undefined) { + const normalized = model?.trim(); + return normalized === EDITOR_IMAGE_MODEL_NANOBANANA2 || + normalized === 'nanobanana2' || + normalized === 'nano-banana' + ? 14 + : 5; +} + +function assertEditorReferenceLimit( + values: readonly string[] | undefined, + limit: number, + fieldLabel: string, +) { + const count = values?.filter((value) => value.trim()).length ?? 0; + if (count > limit) { + throw new Error(`${fieldLabel}最多允许 ${limit} 张,当前选择 ${count} 张`); + } +} + function assertStableEditorVideoReferences(input: EditorVideoGenerationInput) { const references = [ ...(input.referenceImageSrcs ?? []), @@ -91,7 +117,9 @@ export type EditorAssetGenerationInputs = { }; export type EditorProjectResourceSourceType = - 'uploaded' | 'generated' | 'mock_generated'; + | 'uploaded' + | 'generated' + | 'mock_generated'; export type EditorProjectResourceSnapshot = { resourceId: string; @@ -193,7 +221,11 @@ export type EditorImageGenerationInput = { prompt: string; size?: string; kind?: - 'spec' | 'character' | 'quick-edit' | 'ui-design' | 'publication-material'; + | 'spec' + | 'character' + | 'quick-edit' + | 'ui-design' + | 'publication-material'; model?: string; screenColor?: string; segModel?: string; @@ -294,6 +326,19 @@ export type EditorBackgroundRemovalInput = { canvasCompletion?: EditorCanvasGenerationCompletionInput | null; }; +export type EditorPixelArtSnapInput = { + sourceImageSrc: string; + projectId: string; + sourceResourceId?: string | null; + assetKind?: string | null; + generationInputs?: EditorAssetGenerationInputs | null; + assetFolderId?: string | null; + assetLabel?: string | null; + canvasCompletion: EditorCanvasGenerationCompletionInput & { + dialogId: string; + }; +}; + export type EditorImageGenerationResult = { imageSrc: string; objectKey?: string | null; @@ -316,6 +361,21 @@ export type EditorBackgroundRemovalResult = { queueState: ExternalGenerationJobStatusRecord; }; +export type EditorPixelArtSnapResult = { + imageSrc: string; + objectKey: string; + assetObjectId: string; + width: number; + height: number; + sourceType: 'generated'; + taskId: string; + elapsedMs: number; + provider: 'Genarrative'; + resource: EditorProjectResourceSnapshot; + asset: EditorAssetSnapshot; + project: EditorProjectSnapshot | null; +}; + export type EditorIconSpritesheetIconResult = { name: string; imageSrc: string; @@ -358,7 +418,12 @@ export type EditorIconSpritesheetSliceResult = { export type EditorCharacterAnimationResolution = '480p' | '720p'; export type EditorCharacterAnimationRatio = - 'same' | '1:1' | '4:3' | '16:9' | '9:16' | '3:4'; + | 'same' + | '1:1' + | '4:3' + | '16:9' + | '9:16' + | '3:4'; export type EditorCharacterAnimationFrameCount = 32 | 40 | 48; @@ -419,7 +484,12 @@ export type EditorVideoModel = export type EditorVideoResolution = '480p' | '720p' | '1080p'; export type EditorVideoSoundMode = 'on' | 'off'; export type EditorVideoAspectRatio = - '16:9' | '9:16' | '1:1' | '4:3' | '3:4' | '21:9'; + | '16:9' + | '9:16' + | '1:1' + | '4:3' + | '3:4' + | '21:9'; export type EditorVideoGenerationInput = { prompt: string; @@ -538,6 +608,12 @@ export type EditorCanvasSnapshot = { updatedAt: string; }; +export type EditorProjectLoadOptions = { + signal?: AbortSignal; + timeoutMs?: number; + deadlineAt?: number; +}; + export type EditorProjectCreateInput = { title?: string; }; @@ -645,6 +721,7 @@ type EditorShowcaseAssetResponse = { type EditorImageGenerationResponse = EditorImageGenerationResult; type EditorBackgroundRemovalResponse = EditorBackgroundRemovalResult; +type EditorPixelArtSnapResponse = EditorPixelArtSnapResult; type EditorIconSpritesheetGenerationResponse = EditorIconSpritesheetGenerationResult; type EditorVideoGenerationResponse = EditorVideoGenerationResult; @@ -740,11 +817,25 @@ export async function loadEditorGenerationPricing() { ); } -export async function loadEditorProject(projectId: string) { +export async function loadEditorProject( + projectId: string, + options: EditorProjectLoadOptions = {}, +) { + const hasAbsoluteDeadline = + typeof options.deadlineAt === 'number' && + Number.isFinite(options.deadlineAt); const response = await requestJson( `${EDITOR_PROJECT_API_BASE}/${encodeURIComponent(projectId)}`, - { method: 'GET' }, + { + method: 'GET', + ...(options.signal ? { signal: options.signal } : {}), + }, '读取图片画布工程失败', + hasAbsoluteDeadline + ? { deadlineAt: options.deadlineAt } + : // 中文注释:普通项目读取继续保留既有单次 fetch timeout;完美像素对账显式传 + // deadlineAt,改由 requestJson 从鉴权恢复到响应体读取约束完整生命周期。 + { timeoutMs: options.timeoutMs ?? 60_000 }, ); return response.project; } @@ -767,6 +858,9 @@ export async function deleteEditorProject(projectId: string) { return response.deletedProjectId; } +// 中文注释:这里曾接受第三参数(调用方注入的 `signal` / `deadlineAt`),只服务于已删除的 +// 严格布局保存通道;该通道删除后全部调用点都只传两个参数,注入分支恒不生效。一并摘掉, +// 免得后来者以为调用方还能控制这次保存的取消与截止。 export async function saveEditorProjectLayout( projectId: string, input: EditorProjectLayoutSaveInput, @@ -779,6 +873,8 @@ export async function saveEditorProjectLayout( expectedRevision: input.expectedRevision, }), '保存图片画布工程失败', + // 布局保存会被生成提交链同步等待,必须连鉴权恢复和响应体读取一起有界。 + { deadlineAt: Date.now() + 60_000 }, ); } @@ -935,6 +1031,18 @@ export async function generateEditorScene(input: EditorSceneGenerationInput) { export async function generateEditorImage(input: EditorImageGenerationInput) { assertStableEditorMediaReferences(input.referenceImageSrcs, '生成参考图'); + const referenceLimit = + input.kind === 'quick-edit' + ? Math.min( + EDITOR_QUICK_EDIT_REFERENCE_LIMIT, + resolveEditorProviderReferenceLimit(input.model), + ) + : EDITOR_IMAGE_REFERENCE_LIMIT; + assertEditorReferenceLimit( + input.referenceImageSrcs, + referenceLimit, + '生成参考图', + ); return requestJson( EDITOR_IMAGE_GENERATION_API, jsonRequest('POST', { @@ -967,7 +1075,7 @@ export async function generateEditorImage(input: EditorImageGenerationInput) { '生成图片失败', { timeoutMs: 1_200_000, - retry: EDITOR_REQUEST_RETRY_OPTIONS, + retry: EDITOR_GENERATION_REQUEST_RETRY_OPTIONS, }, ); } @@ -975,8 +1083,17 @@ export async function generateEditorImage(input: EditorImageGenerationInput) { export async function generateEditorIconSpritesheet( input: EditorIconSpritesheetGenerationInput, ) { + const model = input.model?.trim() || EDITOR_IMAGE_MODEL_NANOBANANA2; assertStableEditorMediaReference(input.referenceImageSrc, '图标素材规范'); assertStableEditorMediaReferences(input.referenceImageSrcs, '图标素材参考图'); + assertEditorReferenceLimit( + input.referenceImageSrcs, + Math.min( + EDITOR_ICON_EXTRA_REFERENCE_LIMIT, + resolveEditorProviderReferenceLimit(model) - 1, + ), + '图标素材参考图', + ); return requestJson( EDITOR_ICON_SPRITESHEET_GENERATION_API, jsonRequest('POST', { @@ -985,7 +1102,7 @@ export async function generateEditorIconSpritesheet( ? { referenceImageSrcs: input.referenceImageSrcs } : {}), iconDescriptions: input.iconDescriptions, - model: input.model?.trim() || EDITOR_IMAGE_MODEL_NANOBANANA2, + model, ...(input.screenColor ? { screenColor: input.screenColor } : {}), ...(input.segModel ? { segModel: input.segModel } : {}), ...(input.style ? { style: input.style } : {}), @@ -1004,7 +1121,7 @@ export async function generateEditorIconSpritesheet( '生成图标素材失败', { timeoutMs: 1_200_000, - retry: EDITOR_REQUEST_RETRY_OPTIONS, + retry: EDITOR_GENERATION_REQUEST_RETRY_OPTIONS, }, ); } @@ -1030,6 +1147,14 @@ export async function extractEditorUiDesignAssets( ) { assertStableEditorMediaReference(input.sourceImageSrc, 'UI设计图'); assertStableEditorMediaReferences(input.referenceImageSrcs, 'UI素材参考图'); + assertEditorReferenceLimit( + input.referenceImageSrcs, + Math.min( + EDITOR_UI_EXTRA_REFERENCE_LIMIT, + resolveEditorProviderReferenceLimit(input.model) - 1, + ), + 'UI素材参考图', + ); return requestJson( EDITOR_UI_DESIGN_ASSET_EXTRACTION_API, jsonRequest('POST', { @@ -1057,7 +1182,7 @@ export async function extractEditorUiDesignAssets( '提取UI设计图素材失败', { timeoutMs: 1_200_000, - retry: EDITOR_REQUEST_RETRY_OPTIONS, + retry: EDITOR_GENERATION_REQUEST_RETRY_OPTIONS, }, ); } @@ -1065,6 +1190,14 @@ export async function extractEditorUiDesignAssets( export async function editEditorImage(input: EditorImageEditInput) { assertStableEditorMediaReference(input.sourceImageSrc, '待修改图片'); assertStableEditorMediaReferences(input.referenceImageSrcs, '修改参考图'); + assertEditorReferenceLimit( + input.referenceImageSrcs, + Math.min( + EDITOR_IMAGE_EDIT_EXTRA_REFERENCE_LIMIT, + resolveEditorProviderReferenceLimit(input.model) - 1, + ), + '修改参考图', + ); return requestJson( EDITOR_IMAGE_EDIT_API, jsonRequest('POST', { @@ -1095,7 +1228,7 @@ export async function editEditorImage(input: EditorImageEditInput) { '修改图片失败', { timeoutMs: 1_200_000, - retry: EDITOR_REQUEST_RETRY_OPTIONS, + retry: EDITOR_GENERATION_REQUEST_RETRY_OPTIONS, }, ); } @@ -1127,7 +1260,34 @@ export async function removeEditorImageBackground( '去除背景失败', { timeoutMs: 1_200_000, - retry: EDITOR_REQUEST_RETRY_OPTIONS, + retry: EDITOR_GENERATION_REQUEST_RETRY_OPTIONS, + }, + ); +} + +export async function snapEditorImageToPixelArt( + input: EditorPixelArtSnapInput, +) { + assertStableEditorMediaReference(input.sourceImageSrc, '待完美像素处理图片'); + return requestJson( + EDITOR_PIXEL_ART_SNAP_API, + jsonRequest('POST', { + sourceImageSrc: input.sourceImageSrc, + projectId: input.projectId, + ...(input.sourceResourceId + ? { sourceResourceId: input.sourceResourceId } + : {}), + ...(input.assetKind ? { assetKind: input.assetKind } : {}), + ...(input.generationInputs + ? { generationInputs: input.generationInputs } + : {}), + ...(input.assetFolderId ? { assetFolderId: input.assetFolderId } : {}), + ...(input.assetLabel ? { assetLabel: input.assetLabel } : {}), + canvasCompletion: input.canvasCompletion, + }), + '完美像素处理失败', + { + timeoutMs: 120_000, }, ); } @@ -1154,7 +1314,7 @@ export async function generateEditorCharacterAnimation( '生成角色动画失败', { timeoutMs: 1_200_000, - retry: EDITOR_REQUEST_RETRY_OPTIONS, + retry: EDITOR_GENERATION_REQUEST_RETRY_OPTIONS, }, ); } @@ -1198,7 +1358,7 @@ export async function generateEditorVideo(input: EditorVideoGenerationInput) { '生成视频失败', { timeoutMs: 1_200_000, - retry: EDITOR_REQUEST_RETRY_OPTIONS, + retry: EDITOR_GENERATION_REQUEST_RETRY_OPTIONS, }, ); } @@ -1225,7 +1385,7 @@ export async function generateEditorSoundEffect( '生成游戏音效失败', { timeoutMs: 1_200_000, - retry: EDITOR_REQUEST_RETRY_OPTIONS, + retry: EDITOR_GENERATION_REQUEST_RETRY_OPTIONS, }, ); } @@ -1251,7 +1411,7 @@ export async function generateEditorBackgroundMusic( '生成游戏背景音乐失败', { timeoutMs: 1_200_000, - retry: EDITOR_REQUEST_RETRY_OPTIONS, + retry: EDITOR_GENERATION_REQUEST_RETRY_OPTIONS, }, ); } diff --git a/src/services/image-editor/editorRetryOptions.ts b/src/services/image-editor/editorRetryOptions.ts index ecbd86d15..f3cc8040b 100644 --- a/src/services/image-editor/editorRetryOptions.ts +++ b/src/services/image-editor/editorRetryOptions.ts @@ -13,6 +13,13 @@ export const EDITOR_REQUEST_RETRY_OPTIONS = { retryableStatusCodes: EDITOR_RETRYABLE_STATUS_CODES, } satisfies ApiRetryOptions; +// 生成接口在 inline 模式下会直接调用 provider;服务端尚无结果级幂等前, +// 浏览器不能自动重试 POST,否则响应丢失会重复生成与持久化。 +export const EDITOR_GENERATION_REQUEST_RETRY_OPTIONS = { + maxRetries: 0, + retryUnsafeMethods: false, +} satisfies ApiRetryOptions; + type EditorDirectUploadTarget = { host: string; formFields: Record; @@ -53,11 +60,15 @@ export async function postEditorDirectUploadFile( upload: EditorDirectUploadTarget, file: File, errorMessage: string, + // 中文注释:调用方超时后仅停止 await 是不够的——这一步是唯一往 OSS 写实体的动作, + // 不取消的话被放弃的上传会继续跑完并注册对象,用户重试再产生一份,留下不可见的孤儿。 + signal?: AbortSignal, ) { for (let attempt = 0; ; attempt += 1) { const response = await fetch(upload.host, { method: 'POST', body: buildDirectUploadFormData(upload, file), + signal, }); if (response.ok) {