From f99abce1098d2909bb2831b90f5ddff14d2676a2 Mon Sep 17 00:00:00 2001 From: Linghong Date: Tue, 4 Aug 2026 03:59:59 +0000 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90=E5=AE=8C=E7=BE=8E=E5=83=8F?= =?UTF-8?q?=E7=B4=A0=E5=89=8D=E7=AB=AF=E6=9C=AA=E7=9F=A5=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E6=94=B6=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改为 object-only 源图上传并贯穿取消信号 以项目 GET 正向证据判定画布成功与 asset-only 持久化并原样重放 operation 请求,阻止未收口身份被删除 统一 75 秒绝对窗口并兼容旧 240 秒 journal 补齐时钟偏差、删除保护与 byte-for-byte 回归测试 同步更新图片画布契约与共享决策记录 --- .../shared-memory/decision-log.md | 12 +- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 16 +- .../ImageCanvasEditorModel.test.ts | 90 ++- .../image-editor/ImageCanvasEditorModel.ts | 25 +- .../image-editor/ImageCanvasEditorView.tsx | 18 +- .../useCanvasGenerationDialogs.test.tsx | 68 ++- .../useCanvasGenerationDialogs.ts | 18 +- ...anvasGenerationSubmissionWorkflow.test.tsx | 184 +++++- ...ImageCanvasGenerationSubmissionWorkflow.ts | 53 +- .../useImageCanvasGenerationWorkflow.test.tsx | 536 ++++++++++++++---- .../useImageCanvasGenerationWorkflow.ts | 420 +++++++------- .../useImageCanvasLayerCommands.test.tsx | 167 +++++- .../useImageCanvasLayerCommands.ts | 9 +- .../useImageCanvasProjectPersistence.test.tsx | 4 +- .../editorMediaAssetUploadClient.test.ts | 22 +- .../editorMediaAssetUploadClient.ts | 2 +- 16 files changed, 1219 insertions(+), 425 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 115767b26..47fa957b9 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6193,5 +6193,15 @@ - 未知结果语义:本地 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 后对有效 `generating` / `pending-confirmation` operation 只做 GET-only 恢复,禁止自动 POST、上传或重建请求;切换 owner/project、卸载或权威 revision 前进时取消旧观察。v1 快照跨度不得超过固定 240 秒,读取时面对未来时钟偏差也至多从当前再观察一个窗口。有效 durable operation 退出 legacy `requiresLiveSession` TTL,任何标签页都不得清理;无 operation 快照的历史 inline 孤儿继续按 TTL 兼容,且剥离时同步顶层与 `canvas.layers` 两份布局。轮询耗尽仍保留 operation 和待确认状态,只有显式重试进入第二批 exact replay。 +- 2026-08-03 第三批边界: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`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 19959e0e3..02d1903fa 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -49,10 +49,11 @@ - 像素后处理属于 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 部分。 -- 前端提交前先创建关闭 composer 的右侧生成占位,再解析或上传源图以取得稳定引用,随后 flush 包含该占位的当前项目布局,最后把承载稳定候选引用的 `sourceImageSrc`、必填 `projectId / canvasCompletion`(其中 `canvasCompletion.dialogId` 必须非空)以及可选 `sourceResourceId / assetFolderId / assetLabel` 一次提交。`sourceImageSrc` 优先由当前图层已有的 `objectKey / resourceId / sourceAssetId` 解析;尚未登记的浏览器本地图片必须先上传,正式请求不得包含 `data:` / `blob:`、signed URL 或普通外链。后端在读取源图前必须把该字段解析为当前 owner 已登记的私有 OSS object key,并核对 project / resource / asset 归属。 +- 前端提交前先创建关闭 composer 的右侧生成占位,再解析或上传源图以取得稳定引用,随后把版本化 `perfectPixelOperation` 请求快照写入该占位并 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 归属。 - 该已有图片入口使用 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,这些基础记录不属于本次完美像素结果。最终 PNG 的 OSS PUT / HEAD 位于数据库事务外;验证上传结果后,asset object、project resource、账号素材与可选 canvas completion 由单个受 runtime service identity 保护的 SpacetimeDB procedure 在一次事务中原子提交。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 后的既有认证恢复不属于业务副作用重放,保持通用行为。结果未知时先重新读取项目 / 素材快照,由用户显式决定是否再次执行。处理成功后若对应 generation dialog 的删除已先持久化,后端沿用现有 completion 语义跳过画布插入,不得复活占位或结果图层;回包时本地占位已删除则前端不应用完成快照,已经成功持久化的 project resource / 账号素材可以保留。现有布局 CAS 没有 deletion tombstone,completion 先提交、删除保存后冲突的极端竞态仍按权威快照收口。 +- `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` 从稳定请求快照写入时建立统一 75 秒绝对窗口,POST 回包不能续期;读取必须立即执行一次,随后退避间隔不超过 5 秒,窗口已过期时仍执行一次即时 GET。固定判据为:匹配 task 的唯一 resource 加已收口 dialog / 关联图层才是画布成功;dialog 不存在但存在匹配 task resource 才是 asset-only 成功;dialog 仍 generating、dialog 不存在且无匹配 resource、项目始终不可读或窗口耗尽均保持 unknown。素材库刷新只在项目终态后 fire-and-forget,同步抛错、异步拒绝或永久挂起都不得阻塞 verdict、项目快照应用和执行锁释放。 +- unknown 状态持久化为原 generation dialog 上的 `pending-confirmation + perfectPixelOperation`,普通删除和随源图层清理不得移除该 operation;用户只能继续 GET 对账或显式按原 identity 重放。人工重试只刷新观察窗口,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 守卫收口;尚未防抖落库的本地编辑合并不在本批范围。 ### 角色动作帧抠图像素边界 @@ -169,11 +170,12 @@ - 发送消息后,面板先展示本地用户消息和请求等待态,再应用普通 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设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。完美像素只对当前静态栅格图层一键执行,按钮在请求期间按 layer id 进入 disabled / busy,首个 await 前用同步 ref 抢占,连续点击不得重复提交;完成后保留源图并在右侧显示派生 PNG,失败占位保留明确错误且释放 busy。该路由是 unsafe POST 且不得配置自动重放,因此 catch 必须区分已知与未知结果:服务端在第一次 OSS PUT 之后的失败路径上置 `details.resultPersistenceStarted = true`;未带该标记的响应(纯校验 `400`、排队 `503`、预算 `504` 等)表示尚未落库,直接标失败;transport 异常、abort、客户端超时以及带该标记的响应属于结果可能已落库,必须先 GET 权威项目快照对账——占位已被 completion 消费掉则按快照收口并写入正常的完美像素历史,占位仍存活则只同步快照、不写历史,并明确告知结果未知且素材库可能已有派生图,由用户先核对再决定是否重试。刷新后停在 `generating` 的占位目前仍无自动收口,需要用户手动处理。该图层的素材类型保存在途时(`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 和拆分素材都按后端快照保留为画布图层;透明处理失败时仅原图作为主结果,既不要求透明图也不要求切片;透明图成功但拆分失败时保留整张透明图并展示拆分告警。三种完成结果都以后端项目快照为准。 -- 第一批原子持久化只收口服务端事实,不宣称前端恢复已完成:当前前端仍可能把项目 GET 与素材刷新绑在同一个等待窗口,也尚未把 operation 请求快照、`pending-confirmation` 状态和刷新后的 GET-only 恢复持久化。后续前端批次必须让素材刷新退出 verdict,按稳定 task / resource 有界轮询项目,unknown 到期保持待确认而非标记普通失败,人工重试复用原 dialog / operation / 请求快照;在这些条件交付前,后端具备 exact replay 能力不等于 UI 已经端到端防重复。 -- 完美像素前端第二批以 durable operation 为提交边界:生成占位必须持久化 `perfectPixelOperation = { version: 1, kind: "perfect-pixel", operationId, taskId, request, submittedAt, reconcileUntil }`,其中 `operationId` 等于规范化 dialog id、`taskId` 固定为 `pixel-art-snap-{operationId}`,`request` 是稳定源引用解析完成后的完整 `EditorPixelArtSnapInput`,`submittedAt / reconcileUntil` 固定本轮绝对观察窗口。完美像素 dialog id 使用跨标签随机 identity,不能复用每个标签页都会从 1 开始的局部计数器。快照不得包含 Data URL、Blob URL 或 signed URL;POST 前必须取得包含该 dialog 与 operation 快照的布局保存成功确认,保存冲突、鉴权失败、重试耗尽或无法确认时 POST 必须为零。人工重试只能原样重放该快照与同一 operation,不得重新 placement、上传、读取当前图层字段或暗中换 identity;快照缺失、损坏或与 dialog / project / task / completion 不匹配时失败关闭。首次提交或人工重试在途期间若 owner、project 或组件生命周期已经变化,旧响应的素材写入、项目应用、提示与对账副作用必须全部忽略,不能把前一账号的结果写入当前账号状态。 -- 完美像素 unknown-result 的 verdict 只来自项目 GET:找到稳定 task resource 且 dialog 已收口、结果层精确指向该 resource 时为 `Applied`;resource 存在且 dialog 不存在时为 `DialogMissing`,结果只在素材库;两者都不存在或 dialog 仍未收口时继续有界轮询;resource 与 dialog / layer 出现原子事务不可能产生的错配时保持待确认并提示冲突,禁止自动 POST。`refreshAssetLibrary` 只在终态后 best-effort 触发,不进入轮询 deadline、`Promise.all` 或成功判断。轮询到期或 GET 失败后 dialog 转 `pending-confirmation`,保留 operation 与请求快照并释放页面 busy,不得伪装成普通失败。 -- 完美像素前端第三批负责恢复而不负责重新执行:项目 hydrate 后识别带有效 operation 快照的 `generating` / `pending-confirmation` dialog,只按稳定 task/resource 做 GET-only 轮询,绝不 POST、重新上传、重新准备来源或为了恢复而先写布局;owner/project 切换、卸载或更高 revision 到来时旧轮询结果不得生效。v1 operation 的 `reconcileUntil - submittedAt` 不得超过固定 240 秒,跨设备时钟偏差也只能让当前页面再观察至多一个完整窗口,不能信任远未来时间戳形成长期轮询。新 durable operation 无论在哪个标签页、是否超过 legacy TTL 都不得被 `requiresLiveSession` 清理;TTL 只兼容没有有效 operation 快照的历史 inline 孤儿,清理时必须同时更新 `project.layers` 与 `project.canvas.layers`。恢复到期仍持久保持 `pending-confirmation`,只有用户明确点击重试才进入第二批的 exact replay。 +- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、完美像素按钮、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 }`,其中 `operationId` 等于规范化 dialog id、`taskId` 固定为 `pixel-art-snap-{operationId}`,`request` 是稳定源引用解析完成后的完整 `EditorPixelArtSnapInput`,`submittedAt / reconcileUntil` 构成从快照写入起算、不得被 POST 回包续期的 75 秒整链绝对窗口。完美像素 dialog id 使用跨标签随机 identity,不能复用每个标签页都会从 1 开始的局部计数器。inline 源图以该 identity 作为稳定 upload ID,只执行 object-only 上传,不等待 signed URL;快照不得包含 Data URL、Blob URL 或 signed URL。POST 前必须取得包含该 dialog 与 operation 快照的布局保存成功确认,保存冲突、鉴权失败、重试耗尽或无法确认时 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 秒;即使绝对窗口已过期也必须读取一次。`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 到来时旧轮询结果不得生效。新写入的 v1 operation 固定使用 75 秒跨度;为兼容第一批和滚动升级中的旧标签页,hydrate 仍接受跨度及未来时钟偏差不超过 240 秒的旧 v1 journal。若旧 `submittedAt` 位于可接受的未来区间,先把它规范化到当前时间,再把 `reconcileUntil` 压到 `min(持久截止, 规范化 submittedAt + 75 秒, 当前时间 + 75 秒)`;写回形状必须继续满足 `reconcileUntil >= submittedAt`,确保下次 hydrate 仍保留同一 identity。新 durable operation 无论在哪个标签页、是否超过 legacy TTL 都不得被 `requiresLiveSession` 或普通删除路径清理;TTL 只兼容完全没有 operation journal 字段的历史 inline 孤儿,字段存在但内容损坏时必须保留并失败关闭,清理 legacy 孤儿时必须同时更新 `project.layers` 与 `project.canvas.layers`。恢复到期仍持久保持 `pending-confirmation`,只有用户明确点击重试才进入 exact replay。 - 重绘生成资源后,右侧出现新生成结果图层,并自动 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/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index 248545a62..2c5067ffd 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -75,7 +75,7 @@ function buildPerfectPixelOperation( }, }, submittedAt: 1_700_000_000_000, - reconcileUntil: 1_700_000_240_000, + reconcileUntil: 1_700_000_075_000, }; } @@ -858,6 +858,59 @@ describe('ImageCanvasEditorModel', () => { ); }); + 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], + }), + ); + + 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); @@ -896,8 +949,7 @@ describe('ImageCanvasEditorModel', () => { }, { ...operation, - reconcileUntil: - operation.submittedAt + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS + 1, + reconcileUntil: operation.submittedAt + 240_000 + 1, }, { ...operation, unknownFutureField: true }, ]; @@ -921,14 +973,19 @@ describe('ImageCanvasEditorModel', () => { } }); - it('caps a clock-skewed perfect-pixel recovery to one current observation window', () => { + 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 + 60_000; + 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', @@ -937,14 +994,27 @@ describe('ImageCanvasEditorModel', () => { perfectPixelOperation: { ...operation, submittedAt, - reconcileUntil: submittedAt + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS, + reconcileUntil: submittedAt + 240_000, }, }); - expect(hydrated?.perfectPixelOperation).toMatchObject({ - submittedAt, - reconcileUntil: now + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS, - }); + expect(hydrated?.perfectPixelOperation).toEqual(expectedOperation); + expect(hydrated).not.toHaveProperty('perfectPixelOperationInvalid'); + + const { generationDialogs } = splitCanvasLayoutItems( + serializeCanvasLayout({ + layers: [], + canvasGenerationDialogs: [hydrated as CanvasGenerationDialogState], + }), + ); + + expect(generationDialogs).toHaveLength(1); + expect(generationDialogs[0]?.perfectPixelOperation).toEqual( + expectedOperation, + ); + expect(generationDialogs[0]).not.toHaveProperty( + 'perfectPixelOperationInvalid', + ); } finally { vi.useRealTimers(); } diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index 1f4591c56..b147abf67 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -335,9 +335,13 @@ export type CanvasLayoutItems = EditorProjectLayerSnapshot[]; const CANVAS_SETTINGS_LAYOUT_ITEM_ID = 'canvas-settings:default'; const PERFECT_PIXEL_OPERATION_TASK_ID_PREFIX = 'pixel-art-snap-'; -// 中文注释:从稳定请求快照写入开始计算整条提交/对账窗口。绝对截止时间会持久化并在 -// hydrate 后继续沿用;读取侧还会把跨设备时钟偏差限制在“从当前最多再观察一个窗口”。 -export const PERFECT_PIXEL_RECONCILIATION_WINDOW_MS = 240_000; +// 中文注释:从稳定请求快照写入开始,提交与项目事实对账共用 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; export const INVALID_PERFECT_PIXEL_OPERATION_ERROR_MESSAGE = '完美像素操作快照无效,禁止自动重试。'; @@ -506,11 +510,13 @@ export function hydratePerfectPixelOperation( !Number.isFinite(value.reconcileUntil) || value.reconcileUntil < value.submittedAt || value.reconcileUntil - value.submittedAt > - PERFECT_PIXEL_RECONCILIATION_WINDOW_MS || - value.submittedAt > now + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS + 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) || @@ -602,12 +608,13 @@ export function hydratePerfectPixelOperation( operationId: dialogId, taskId: `${PERFECT_PIXEL_OPERATION_TASK_ID_PREFIX}${dialogId}`, request: hydratedRequest, - submittedAt: value.submittedAt, - // 中文注释:跨设备时钟可能有小幅偏差,所以合法固定跨度的快照不因“当前时钟稍慢” - // 直接失效;但恢复从当前时刻起最多再观察一个完整窗口,不能信任远未来绝对时间让 GET - // 轮询持续数小时。 + // 中文注释:把未来时间规范到当前时刻,确保收紧后的快照再次序列化、hydrate 时仍合法; + // 过去时间保持不变,不能借刷新给 operation 续期。兼容读入的旧 240 秒快照同样只保留 + // 当前 75 秒绝对窗口。 + submittedAt: normalizedSubmittedAt, reconcileUntil: Math.min( value.reconcileUntil, + normalizedSubmittedAt + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS, now + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS, ), }; diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index cacfdd478..116a5214f 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -76,7 +76,10 @@ import { getSelectedLayerIds, } from './ImageCanvasSelectionModel'; import { ImageCanvasShortcutDialogView } from './ImageCanvasShortcutDialogView'; -import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs'; +import { + isUnsettledPerfectPixelOperationDialog, + useCanvasGenerationDialogs, +} from './useCanvasGenerationDialogs'; import { useCanvasHistory } from './useCanvasHistory'; import { useImageCanvasAssetCanvasBridge, @@ -1968,6 +1971,13 @@ export function ImageCanvasEditorView({ const requestRemoveCanvasGenerationDialog = useCallback( (dialog: CanvasGenerationDialogState) => { + if (isUnsettledPerfectPixelOperationDialog(dialog)) { + activateCanvasGenerationDialog(dialog); + showGenerationWarning( + '完美像素结果尚未确认,当前占位用于保留原操作身份;请继续核对或按原操作重试。', + ); + return; + } if (dialog.status === 'generating') { activateCanvasGenerationDialog(dialog); setPendingGenerationDeleteDialog(dialog); @@ -1975,7 +1985,11 @@ export function ImageCanvasEditorView({ } removeCanvasGenerationDialog(dialog.id); }, - [activateCanvasGenerationDialog, removeCanvasGenerationDialog], + [ + activateCanvasGenerationDialog, + removeCanvasGenerationDialog, + showGenerationWarning, + ], ); const confirmRemoveGeneratingDialog = useCallback(() => { const dialog = pendingGenerationDeleteDialog; diff --git a/src/components/image-editor/useCanvasGenerationDialogs.test.tsx b/src/components/image-editor/useCanvasGenerationDialogs.test.tsx index 42ec445ba..6bffc7955 100644 --- a/src/components/image-editor/useCanvasGenerationDialogs.test.tsx +++ b/src/components/image-editor/useCanvasGenerationDialogs.test.tsx @@ -1,6 +1,6 @@ /* @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'; @@ -305,4 +305,70 @@ describe('useCanvasGenerationDialogs', () => { }), ]); }); + + it('preserves unsettled durable perfect-pixel operations across ordinary delete paths', () => { + const { result } = renderHook(() => useCanvasGenerationDialogs()); + const dialogId = 'perfect-pixel-durable'; + const submittedAt = 1_700_000_000_000; + + act(() => { + result.current.restoreCanvasGenerationDialogs([ + { + id: dialogId, + mode: 'quick-edit', + prompt: '完美像素', + status: 'pending-confirmation', + composerOpen: true, + sourceLayerId: 'layer-source', + 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, + }, + }, + ]); + }); + + act(() => { + result.current.removeCanvasGenerationDialogById(dialogId); + result.current.removeCanvasGenerationDialogsByLayerId('layer-source'); + }); + expect(result.current.activeCanvasGenerationDialog).toEqual( + expect.objectContaining({ + id: dialogId, + status: 'pending-confirmation', + perfectPixelOperation: expect.objectContaining({ + operationId: dialogId, + }), + }), + ); + + act(() => { + result.current.updateCanvasGenerationDialogById(dialogId, (dialog) => ({ + ...dialog, + status: 'idle', + })); + result.current.removeCanvasGenerationDialogById(dialogId); + }); + expect(result.current.activeCanvasGenerationDialog).toBeNull(); + }); }); diff --git a/src/components/image-editor/useCanvasGenerationDialogs.ts b/src/components/image-editor/useCanvasGenerationDialogs.ts index 8b639003d..61b032ea3 100644 --- a/src/components/image-editor/useCanvasGenerationDialogs.ts +++ b/src/components/image-editor/useCanvasGenerationDialogs.ts @@ -24,6 +24,15 @@ export type CanvasGenerationDialogDraft = Omit< id?: string; }; +export function isUnsettledPerfectPixelOperationDialog( + dialog: CanvasGenerationDialogState, +) { + return ( + Boolean(dialog.perfectPixelOperation) && + (dialog.status === 'generating' || dialog.status === 'pending-confirmation') + ); +} + function withGenerationTimestamps( nextDialog: T, previousDialog?: GenerateDialogState | null, @@ -218,7 +227,9 @@ export function useCanvasGenerationDialogs({ const removeCanvasGenerationDialogById = useCallback( (dialogId: string) => { - updateCanvasGenerationDialogById(dialogId, () => null); + updateCanvasGenerationDialogById(dialogId, (dialog) => + isUnsettledPerfectPixelOperationDialog(dialog) ? dialog : null, + ); }, [updateCanvasGenerationDialogById], ); @@ -299,8 +310,9 @@ export function useCanvasGenerationDialogs({ const removeCanvasGenerationDialogsByLayerId = useCallback( (targetLayerId: string) => { const keepDialog = (dialog: CanvasGenerationDialogState) => - dialog.sourceLayerId !== targetLayerId && - dialog.generatedLayerId !== targetLayerId; + isUnsettledPerfectPixelOperationDialog(dialog) || + (dialog.sourceLayerId !== targetLayerId && + dialog.generatedLayerId !== targetLayerId); const currentDialog = generateDialogRef.current; const nextActiveDialog = isCanvasGenerationDialog(currentDialog) && !keepDialog(currentDialog) diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index 038ecf126..2f11c8537 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()); @@ -72,6 +73,7 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => { }); vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({ + uploadEditorMediaAssetObjectFile: uploadEditorMediaAssetObjectFileMock, uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock, })); @@ -500,6 +502,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { beforeEach(() => { resolveEditorImageReferenceDataUrlMock.mockReset(); resolveEditorImageReferenceDataUrlForGenerationMock.mockReset(); + uploadEditorMediaAssetObjectFileMock.mockReset(); uploadEditorMediaAssetFileMock.mockReset(); editEditorImageMock.mockReset(); extractEditorUiDesignAssetsMock.mockReset(); @@ -518,8 +521,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', @@ -550,16 +552,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==', ); @@ -567,23 +609,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({ @@ -738,9 +881,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { ); render( { ); }); await waitFor(() => { - expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith( + expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledWith( expect.any(File), 'image', expect.objectContaining({ @@ -1002,7 +1143,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', @@ -1126,8 +1267,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { prompt: '角色换成蓝色披风', warning: { code: 'postprocess-failed-source-preserved', - reason: - '生成任务成功,后处理失败。', + reason: '生成任务成功,后处理失败。', }, }), ); @@ -1327,7 +1467,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); await waitFor(() => { - expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith( + expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledWith( expect.any(File), 'video', expect.objectContaining({ @@ -1797,7 +1937,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 () => { @@ -1821,8 +1961,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { prompt: '队列角色生成', }), queueState: createQueueState({ - warning: - '生成任务成功,后处理失败。', + warning: '生成任务成功,后处理失败。', }), }); render( @@ -1958,8 +2097,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { await applyQueuedEditorGenerationProject( { queueState: createQueueState({ - warning: - '生成任务成功,后处理失败。', + warning: '生成任务成功,后处理失败。', }), }, 'editor-project-1', @@ -2234,7 +2372,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 () => { @@ -2616,8 +2754,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => { taskId: 'task-ui-assets', warning: { code: 'postprocess-failed-source-preserved', - reason: - '生成任务成功,后处理失败。', + reason: '生成任务成功,后处理失败。', }, }); render( @@ -2795,8 +2932,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 4d28b0b12..55481e706 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, @@ -114,8 +114,11 @@ type EditorGenerationMediaReference = { type EditorGenerationMediaReferenceOptions = { allowRegisteredIds?: boolean; requireImageObjectReference?: boolean; - // 中文注释:由调用方的阶段预算驱动。仅在最外层停止 await 不会取消已经启动的上传, - // 被放弃的那次会继续跑完并注册对象,用户重试再产生一份。 + // 中文注释:需要 unknown 重放的同步操作由调用方传入稳定 id;普通入口不传时仍为每次 + // 上传生成随机路径,避免并发参考图互相覆盖。 + uploadId?: string; + // 中文注释:由调用方的阶段预算驱动,并贯穿源读取、Data URL 转换以及 + // ticket → PUT → confirm,不能只在最外层停止 await。 signal?: AbortSignal; }; @@ -154,7 +157,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) { @@ -162,10 +174,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 }); } @@ -173,18 +187,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}/*`, }); @@ -195,11 +213,18 @@ async function uploadEditorGenerationInlineMediaSource( 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}`, @@ -225,6 +250,7 @@ export async function resolveEditorGenerationMediaReference( projectId?: string | null, options: EditorGenerationMediaReferenceOptions = {}, ) { + throwIfEditorGenerationMediaUploadAborted(options.signal); const resourceId = reference.resourceId?.trim(); const hasRegisteredReference = options.allowRegisteredIds !== false && @@ -245,14 +271,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, ); } diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index 95eccac06..b76b2390f 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -15,6 +15,7 @@ import type { EditorPixelArtSnapInput, EditorProjectSnapshot, } from '../../services/image-editor/editorProjectClient'; +import { PERFECT_PIXEL_RECONCILIATION_WINDOW_MS } from './ImageCanvasEditorModel'; import type { CanvasGenerationDialogState, CanvasLayer, @@ -25,7 +26,10 @@ import type { } from './ImageCanvasEditorTypes'; import { IMAGE_MODEL_GPT_IMAGE_2 } from './ImageCanvasGenerationModel'; 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()); @@ -35,6 +39,7 @@ 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()); @@ -74,6 +79,7 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => { }); vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({ + uploadEditorMediaAssetObjectFile: uploadEditorMediaAssetObjectFileMock, uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock, })); @@ -194,6 +200,33 @@ function createPerfectPixelProject( }; } +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, @@ -232,7 +265,7 @@ function createHydratedPerfectPixelDialog({ operationId, projectId = 'project-1', status = 'generating', - reconcileUntil = Date.now() + 240_000, + reconcileUntil = Date.now() + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS, }: { operationId: string; projectId?: string; @@ -1169,12 +1202,21 @@ function GenerationWorkflowHarness({ describe('useImageCanvasGenerationWorkflow', () => { beforeEach(() => { vi.clearAllMocks(); + uploadEditorMediaAssetObjectFileMock.mockReset(); + uploadEditorMediaAssetFileMock.mockReset(); // 中文注释:未知结果的对账 GET 默认返回 null,等价于"权威快照读不到"。需要具体 // 对账结果的用例各自 mockResolvedValueOnce 覆盖。 loadEditorProjectMock.mockResolvedValue(null); resolveEditorImageReferenceDataUrlMock.mockImplementation( async (src: string) => src, ); + uploadEditorMediaAssetObjectFileMock.mockResolvedValue({ + objectKey: + 'generated-character-drafts/editor/generation-references/reference.png', + assetObjectId: 'asset-object-generation-reference', + legacyPublicPath: + '/generated-character-drafts/editor/generation-references/reference.png', + }); uploadEditorMediaAssetFileMock.mockResolvedValue({ src: 'https://signed.example.test/crop-expand.png', objectKey: @@ -1199,6 +1241,47 @@ describe('useImageCanvasGenerationWorkflow', () => { window.localStorage.clear(); }); + it('keeps a generating dialog pending even after its matching task resource appears', () => { + const operationId = 'perfect-pixel-resource-before-dialog'; + const project = createPerfectPixelProject(operationId, 'pending'); + project.resources = [createPerfectPixelResource(operationId)]; + + expect( + inspectPerfectPixelProjectSnapshot(project, { + operationId, + taskId: `pixel-art-snap-${operationId}`, + }), + ).toMatchObject({ kind: 'pending', project }); + }); + + it('requires a matching task resource before a missing dialog can prove asset-only success', () => { + const operationId = 'perfect-pixel-missing-dialog'; + const matchingProject = createPerfectPixelProject( + operationId, + 'dialog-missing', + ); + const foreignProject = { + ...matchingProject, + resources: [ + { + ...createPerfectPixelResource('foreign-operation'), + projectId: 'project-1', + }, + ], + }; + const identity = { + operationId, + taskId: `pixel-art-snap-${operationId}`, + }; + + expect( + inspectPerfectPixelProjectSnapshot(matchingProject, identity), + ).toMatchObject({ kind: 'dialog-missing' }); + expect( + inspectPerfectPixelProjectSnapshot(foreignProject, identity), + ).toMatchObject({ kind: 'pending', project: foreignProject }); + }); + it('opens a movable canvas generation placeholder and keeps toolbar state active', () => { render(); @@ -2397,7 +2480,7 @@ describe('useImageCanvasGenerationWorkflow', () => { expect(screen.getByTestId('task-refresh-key').textContent).toBe('0'); }); - it('flushes a closed perfect-pixel placeholder before submitting the stable source', async () => { + it('flushes the stable operation before POST and only applies the later project GET verdict', async () => { const events: string[] = []; const applyProjectSnapshot = vi.fn(); const upsertGeneratedAsset = vi.fn(); @@ -2419,18 +2502,43 @@ describe('useImageCanvasGenerationWorkflow', () => { ); }); let postedRequest: EditorPixelArtSnapInput | undefined; + let postResponseProject: EditorProjectSnapshot | undefined; let completedProject: EditorProjectSnapshot | undefined; snapImageToPerfectPixelsMock.mockImplementationOnce( async (request: EditorPixelArtSnapInput) => { postedRequest = request; - completedProject = createPerfectPixelProject( + postResponseProject = createPerfectPixelProject( request.canvasCompletion.dialogId, 'applied', ); - events.push('post'); - return createPerfectPixelResult(request, completedProject); + postResponseProject = { + ...postResponseProject, + title: 'POST 响应快照', + }; + events.push('post'); + return createPerfectPixelResult(request, postResponseProject); }, ); + loadEditorProjectMock + .mockImplementationOnce(async () => { + events.push('get-pending'); + return createPerfectPixelProject( + postedRequest!.canvasCompletion.dialogId, + 'pending', + ); + }) + .mockImplementationOnce(async () => { + events.push('get-applied'); + completedProject = createPerfectPixelProject( + postedRequest!.canvasCompletion.dialogId, + 'applied', + ); + completedProject = { + ...completedProject, + title: 'GET 权威快照', + }; + return completedProject; + }); const sourceLayer = createLayer({ objectKey: 'generated-images/editor/source.png', src: '/generated-images/editor/source.png', @@ -2462,7 +2570,7 @@ describe('useImageCanvasGenerationWorkflow', () => { await waitFor(() => { expect(snapImageToPerfectPixelsMock).toHaveBeenCalledTimes(1); }); - expect(events).toEqual(['snapshot', 'flush', 'post']); + expect(events.slice(0, 3)).toEqual(['snapshot', 'flush', 'post']); expect(flushProjectPersistence).toHaveBeenCalledTimes(1); expect(flushProjectPersistence).toHaveBeenCalledWith({ requireSuccess: true, @@ -2481,6 +2589,9 @@ describe('useImageCanvasGenerationWorkflow', () => { expect(flushedOperation!.taskId).toBe( `pixel-art-snap-${flushedOperation!.operationId}`, ); + expect( + flushedOperation!.reconcileUntil - flushedOperation!.submittedAt, + ).toBe(75_000); expect(flushedOperation!.request).toEqual(postedRequest); expect(postedRequest).toEqual( expect.objectContaining({ @@ -2501,10 +2612,20 @@ describe('useImageCanvasGenerationWorkflow', () => { }), ); expect(screen.getByTestId('live-session-dialogs').textContent).toBe('-'); - expect(upsertGeneratedAsset).toHaveBeenCalledWith( - createPerfectPixelAsset(flushedOperation!.operationId), + await waitFor( + () => { + expect(loadEditorProjectMock).toHaveBeenCalledTimes(2); + expect(upsertGeneratedAsset).toHaveBeenCalledWith( + createPerfectPixelAsset(flushedOperation!.operationId), + ); + expect(applyProjectSnapshot).toHaveBeenCalledWith(completedProject, { + type: 'perfect-pixel', + count: 1, + }); + }, + { timeout: 3_000 }, ); - expect(applyProjectSnapshot).toHaveBeenCalledWith(completedProject, { + expect(applyProjectSnapshot).not.toHaveBeenCalledWith(postResponseProject, { type: 'perfect-pixel', count: 1, }); @@ -2581,42 +2702,59 @@ describe('useImageCanvasGenerationWorkflow', () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-08-03T00:00:00.000Z')); try { - const applyProjectSnapshot = vi.fn(); + const startedAt = Date.now(); + let unknownObservedAt = startedAt; + const readTimes: number[] = []; + const applyProjectSnapshot = vi.fn(); const refreshAssetLibrary = vi.fn(); - const emptyProject: EditorProjectSnapshot = { - projectId: 'project-1', - title: '未命名画布', - viewport: { x: 0, y: 0, scale: 1 }, - layers: [], - resources: [], - updatedAt: '2026-08-03T00:00:00.000Z', - }; - snapImageToPerfectPixelsMock.mockRejectedValueOnce(new Error('网络中断')); - loadEditorProjectMock.mockResolvedValue(emptyProject); - - render( - + new Promise((_resolve, reject) => { + setTimeout(() => { + unknownObservedAt = Date.now(); + reject(new Error('网络中断')); + }, 70_000); }), - ]} - applyProjectSnapshot={applyProjectSnapshot} - flushProjectPersistence={vi.fn(async () => {})} - refreshAssetLibrary={refreshAssetLibrary} - />, - ); + ); + loadEditorProjectMock.mockImplementation(async () => { + readTimes.push(Date.now()); + return createEmptyPerfectPixelProject(); + }); - fireEvent.click(screen.getByRole('button', { name: '完美像素' })); + render( + {})} + refreshAssetLibrary={refreshAssetLibrary} + />, + ); + + fireEvent.click(screen.getByRole('button', { name: '完美像素' })); await act(async () => { - await vi.advanceTimersByTimeAsync(240_001); - }); + await vi.advanceTimersByTimeAsync( + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS + 1, + ); + }); expect(loadEditorProjectMock.mock.calls.length).toBeGreaterThan(1); + expect(readTimes[0]! - unknownObservedAt).toBeLessThan(1_000); + expect(readTimes.at(-1)!).toBeLessThanOrEqual( + startedAt + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS, + ); + for (let index = 1; index < readTimes.length; index += 1) { + expect(readTimes[index]! - readTimes[index - 1]!).toBeLessThanOrEqual( + 5_000, + ); + } expect(snapImageToPerfectPixelsMock).toHaveBeenCalledTimes(1); - expect(applyProjectSnapshot).not.toHaveBeenCalled(); + expect(applyProjectSnapshot).not.toHaveBeenCalled(); expect(refreshAssetLibrary).not.toHaveBeenCalled(); expect(screen.getByTestId('dialog').textContent).toContain( 'pending-confirmation', @@ -2630,6 +2768,13 @@ describe('useImageCanvasGenerationWorkflow', () => { expect(screen.getByTestId('generation-dialogs').textContent).not.toBe( '-', ); + const completedReadCount = readTimes.length; + await act(async () => { + await vi.advanceTimersByTimeAsync( + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS, + ); + }); + expect(readTimes).toHaveLength(completedReadCount); } finally { vi.useRealTimers(); } @@ -2644,6 +2789,14 @@ describe('useImageCanvasGenerationWorkflow', () => { .mockImplementationOnce(async (request: EditorPixelArtSnapInput) => createMismatchedPerfectPixelResult(request), ); + loadEditorProjectMock.mockImplementation(async () => { + const request = snapImageToPerfectPixelsMock.mock.calls.at(-1)?.[0] as + | EditorPixelArtSnapInput + | undefined; + return createConflictingPerfectPixelProject( + request!.canvasCompletion.dialogId, + ); + }); render( { taskId: string; request: EditorPixelArtSnapInput; }; + const firstRequestBytes = JSON.stringify(firstRequest); fireEvent.click(screen.getByRole('button', { name: '重试完美像素' })); await waitFor(() => { @@ -2685,7 +2839,7 @@ describe('useImageCanvasGenerationWorkflow', () => { const retryRequest = snapImageToPerfectPixelsMock.mock.calls[1]?.[0] as | EditorPixelArtSnapInput | undefined; - expect(JSON.stringify(retryRequest)).toBe(JSON.stringify(firstRequest)); + expect(JSON.stringify(retryRequest)).toBe(firstRequestBytes); expect(retryRequest).toEqual(firstOperation.request); expect(retryRequest?.canvasCompletion.dialogId).toBe( firstOperation.operationId, @@ -2713,6 +2867,94 @@ describe('useImageCanvasGenerationWorkflow', () => { }); }); + it('reuses one inline upload and the exact persisted POST bytes when an unknown operation is retried', async () => { + const flushProjectPersistence = vi.fn().mockResolvedValue(undefined); + uploadEditorMediaAssetObjectFileMock.mockResolvedValueOnce({ + objectKey: 'generated-images/editor/inline-perfect-pixel-source.png', + assetObjectId: 'asset-object-inline-perfect-pixel-source', + legacyPublicPath: + '/generated-images/editor/inline-perfect-pixel-source.png', + }); + snapImageToPerfectPixelsMock + .mockImplementationOnce(async (request: EditorPixelArtSnapInput) => + createMismatchedPerfectPixelResult(request), + ) + .mockImplementationOnce(async (request: EditorPixelArtSnapInput) => + createMismatchedPerfectPixelResult(request), + ); + loadEditorProjectMock.mockImplementation(async () => { + const request = snapImageToPerfectPixelsMock.mock.calls.at(-1)?.[0] as + | EditorPixelArtSnapInput + | undefined; + return createConflictingPerfectPixelProject( + request!.canvasCompletion.dialogId, + ); + }); + + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '完美像素' })); + await waitFor(() => { + expect(screen.getByTestId('dialog').textContent).toContain( + 'pending-confirmation', + ); + }); + + const firstRequest = snapImageToPerfectPixelsMock.mock.calls[0]?.[0] as + | EditorPixelArtSnapInput + | undefined; + const firstRequestBytes = JSON.stringify(firstRequest); + const operation = JSON.parse( + screen.getByTestId('perfect-pixel-operation').textContent!, + ) as { + operationId: string; + request: EditorPixelArtSnapInput; + }; + expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledTimes(1); + expect(uploadEditorMediaAssetObjectFileMock.mock.calls[0]?.[2]).toEqual( + expect.objectContaining({ + pathSegments: [ + 'editor', + 'generation-references', + 'project-1', + operation.operationId, + ], + }), + ); + expect(firstRequest?.sourceImageSrc).toBe( + 'generated-images/editor/inline-perfect-pixel-source.png', + ); + expect(firstRequest?.canvasCompletion.dialogId).toBe(operation.operationId); + + fireEvent.click(screen.getByRole('button', { name: '重试完美像素' })); + await waitFor(() => { + expect(snapImageToPerfectPixelsMock).toHaveBeenCalledTimes(2); + expect(screen.getByTestId('dialog').textContent).toContain( + 'pending-confirmation', + ); + }); + + const retryRequest = snapImageToPerfectPixelsMock.mock.calls[1]?.[0] as + | EditorPixelArtSnapInput + | undefined; + expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledTimes(1); + expect(JSON.stringify(retryRequest)).toBe(firstRequestBytes); + expect(retryRequest?.canvasCompletion.dialogId).toBe(operation.operationId); + expect(retryRequest).toEqual(operation.request); + expect(flushProjectPersistence).toHaveBeenCalledTimes(2); + }); + it('ignores an in-flight retry response after the active owner changes', async () => { const retryResult = createDeferred>(); @@ -2724,6 +2966,14 @@ describe('useImageCanvasGenerationWorkflow', () => { createMismatchedPerfectPixelResult(request), ) .mockImplementationOnce(() => retryResult.promise); + loadEditorProjectMock.mockImplementation(async () => { + const request = snapImageToPerfectPixelsMock.mock.calls[0]?.[0] as + | EditorPixelArtSnapInput + | undefined; + return createConflictingPerfectPixelProject( + request!.canvasCompletion.dialogId, + ); + }); const { rerender } = render( { createLayer({ objectKey: 'generated-images/editor/source.png', src: '/generated-images/editor/source.png', - }), + }), ]} applyProjectSnapshot={applyProjectSnapshot} flushProjectPersistence={vi.fn().mockResolvedValue(undefined)} @@ -2791,6 +3041,14 @@ describe('useImageCanvasGenerationWorkflow', () => { async (request: EditorPixelArtSnapInput) => createMismatchedPerfectPixelResult(request), ); + loadEditorProjectMock.mockImplementation(async () => { + const request = snapImageToPerfectPixelsMock.mock.calls[0]?.[0] as + | EditorPixelArtSnapInput + | undefined; + return createConflictingPerfectPixelProject( + request!.canvasCompletion.dialogId, + ); + }); render( { ); }); - it('drops a reconciled perfect-pixel result when the user deleted the placeholder', async () => { + it('reports asset-only success only when project GET contains the matching task resource', async () => { const applyProjectSnapshot = vi.fn(); let operationId = ''; const timeoutError = new Error('The operation timed out.'); @@ -2969,8 +3227,7 @@ describe('useImageCanvasGenerationWorkflow', () => { snapImageToPerfectPixelsMock.mockImplementationOnce( async (request: EditorPixelArtSnapInput) => { operationId = request.canvasCompletion.dialogId; - fireEvent.click(screen.getByRole('button', { name: '删除处理占位' })); - throw timeoutError; + throw timeoutError; }, ); loadEditorProjectMock.mockImplementationOnce(async () => @@ -3007,7 +3264,7 @@ describe('useImageCanvasGenerationWorkflow', () => { expect(screen.getByTestId('dialog').textContent).toBe('-'); }); - it('tells the user the reconciled result already landed remotely after they deleted the placeholder', async () => { + it('keeps the durable operation when deletion is attempted and applies a later GET result', async () => { const applyProjectSnapshot = vi.fn(); let operationId = ''; let rejectPerfectPixel: ((error: unknown) => void) | undefined; @@ -3042,22 +3299,26 @@ describe('useImageCanvasGenerationWorkflow', () => { ); }); fireEvent.click(screen.getByRole('button', { name: '删除处理占位' })); - await waitFor(() => { - expect(screen.getByTestId('generation-dialogs').textContent).toBe('-'); - }); + expect(screen.getByTestId('generation-dialogs').textContent).not.toBe('-'); rejectPerfectPixel?.(new Error('网络中断')); await waitFor(() => { - expect(screen.getByTestId('reference-pick-warning').textContent).toBe( - '完美像素结果已生成并保存到素材库。画布占位已被删除,结果未落入当前画布,重新加载后可见。', + expect(applyProjectSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: 'project-1', + }), + { + type: 'perfect-pixel', + count: 1, + }, ); }); - expect(applyProjectSnapshot).not.toHaveBeenCalled(); + expect(screen.getByTestId('reference-pick-warning').textContent).toBe('-'); }); it('does not let a never-settling asset refresh block a terminal reconciliation verdict', async () => { - const applyProjectSnapshot = vi.fn(); + const applyProjectSnapshot = vi.fn(); const refreshAssetLibrary = vi.fn(() => new Promise(() => {})); let operationId = ''; snapImageToPerfectPixelsMock.mockImplementationOnce( @@ -3069,12 +3330,65 @@ describe('useImageCanvasGenerationWorkflow', () => { loadEditorProjectMock.mockImplementationOnce(async () => createPerfectPixelProject(operationId, 'dialog-missing'), ); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '完美像素' })); + + await waitFor(() => { + expect(screen.getByTestId('reference-pick-warning').textContent).toBe( + '完美像素结果已保存到素材库,画布占位已不存在。', + ); + }); + expect(refreshAssetLibrary).toHaveBeenCalledTimes(1); + expect(applyProjectSnapshot).not.toHaveBeenCalled(); + expect( + screen.getByRole('status', { name: '完美像素状态' }).textContent, + ).toBe('空闲'); + }); + + it.each([ + [ + '同步抛错', + () => { + throw new Error('同步刷新失败'); + }, + ], + ['异步拒绝', () => Promise.reject(new Error('异步刷新失败'))], + ])( + 'keeps an applied GET verdict when asset refresh %s', + async (_label, refreshImplementation) => { + const applyProjectSnapshot = vi.fn(); + const refreshAssetLibrary = vi.fn(refreshImplementation); + let operationId = ''; + snapImageToPerfectPixelsMock.mockImplementationOnce( + async (request: EditorPixelArtSnapInput) => { + operationId = request.canvasCompletion.dialogId; + throw new Error('网络中断'); + }, + ); + loadEditorProjectMock.mockImplementationOnce(async () => + createPerfectPixelProject(operationId, 'applied'), + ); + render( { fireEvent.click(screen.getByRole('button', { name: '完美像素' })); - await waitFor(() => { - expect(screen.getByTestId('reference-pick-warning').textContent).toBe( - '完美像素结果已保存到素材库,画布占位已不存在。', - ); + await waitFor(() => { + expect(applyProjectSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ projectId: 'project-1' }), + { + type: 'perfect-pixel', + count: 1, + }, + ); + expect( + screen.getByRole('status', { name: '完美像素状态' }).textContent, + ).toBe('空闲'); }); - expect(refreshAssetLibrary).toHaveBeenCalledTimes(1); - expect(applyProjectSnapshot).not.toHaveBeenCalled(); - expect( - screen.getByRole('status', { name: '完美像素状态' }).textContent, - ).toBe('空闲'); - }); + expect(refreshAssetLibrary).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('dialog').textContent).not.toContain('failed'); + }, + ); it('treats a gateway-synthesized timeout as an unknown outcome and reconciles', async () => { const applyProjectSnapshot = vi.fn(); @@ -3106,9 +3425,9 @@ describe('useImageCanvasGenerationWorkflow', () => { async (request: EditorPixelArtSnapInput) => { operationId = request.canvasCompletion.dialogId; throw new ApiClientError({ - message: '上游服务请求超时', - status: 504, - code: 'GATEWAY_UPSTREAM_TIMEOUT', + message: '上游服务请求超时', + status: 504, + code: 'GATEWAY_UPSTREAM_TIMEOUT', }); }, ); @@ -3133,12 +3452,12 @@ describe('useImageCanvasGenerationWorkflow', () => { fireEvent.click(screen.getByRole('button', { name: '完美像素' })); await waitFor(() => { - expect(applyProjectSnapshot).toHaveBeenCalledWith(reconciledProject, { - type: 'perfect-pixel', - count: 1, + expect(applyProjectSnapshot).toHaveBeenCalledWith(reconciledProject, { + type: 'perfect-pixel', + count: 1, + }); }); }); - }); it('still treats a gateway rate limit as a known failure', async () => { // 中文注释:对照用例。限流是在网关就被拒、根本没到应用,属于确定失败。把它也算未知会让 @@ -3257,10 +3576,9 @@ describe('useImageCanvasGenerationWorkflow', () => { it('uploads an inline perfect-pixel source before flushing and posting', async () => { const order: string[] = []; - uploadEditorMediaAssetFileMock.mockImplementationOnce(async () => { + uploadEditorMediaAssetObjectFileMock.mockImplementationOnce(async () => { order.push('upload'); return { - src: 'https://signed.example.test/perfect-pixel-source.png', objectKey: 'generated-images/editor/uploaded-source.png', assetObjectId: 'asset-object-uploaded-source', legacyPublicPath: '/generated-images/editor/uploaded-source.png', @@ -3273,10 +3591,16 @@ describe('useImageCanvasGenerationWorkflow', () => { snapImageToPerfectPixelsMock.mockImplementationOnce( async (request: EditorPixelArtSnapInput) => { postedRequest = request; - order.push('post'); + order.push('post'); return createPerfectPixelResult(request, null); }, ); + loadEditorProjectMock.mockImplementationOnce(async () => + createPerfectPixelProject( + postedRequest!.canvasCompletion.dialogId, + 'dialog-missing', + ), + ); render( { }), ); expect(postedRequest).not.toHaveProperty('sourceResourceId'); + const uploadOptions = + uploadEditorMediaAssetObjectFileMock.mock.calls[0]?.[2]; + expect(uploadOptions).toEqual( + expect.objectContaining({ + signal: expect.any(AbortSignal), + pathSegments: [ + 'editor', + 'generation-references', + 'project-1', + postedRequest!.canvasCompletion.dialogId, + ], + }), + ); + expect(uploadEditorMediaAssetFileMock).not.toHaveBeenCalled(); await waitFor(() => { expect(screen.getByTestId('dialog').textContent).toBe('-'); expect(screen.getByTestId('reference-pick-warning').textContent).toBe( @@ -3347,10 +3685,7 @@ describe('useImageCanvasGenerationWorkflow', () => { ).toBe('空闲'); }); - it('still surfaces the perfect-pixel failure after the placeholder was deleted mid-flight', async () => { - // 中文注释:删除生成中占位是产品支持的流程(会先弹确认)。删完之后请求才失败时, - // dialogId 有值但占位已不在,没有地方挂错误——必须退回全局提示,否则界面零反馈, - // 用户会在不知道「素材库可能已有派生图」的情况下重试,再造一份对象与素材。 + it('refuses to delete a durable in-flight operation and keeps a later known failure visible', async () => { let rejectPerfectPixel: ((error: unknown) => void) | undefined; snapImageToPerfectPixelsMock.mockImplementationOnce( () => @@ -3380,9 +3715,7 @@ describe('useImageCanvasGenerationWorkflow', () => { }); fireEvent.click(screen.getByRole('button', { name: '删除处理占位' })); - await waitFor(() => { - expect(screen.getByTestId('generation-dialogs').textContent).toBe('-'); - }); + expect(screen.getByTestId('generation-dialogs').textContent).not.toBe('-'); // 中文注释:用 ApiClientError(已知结果)避免走对账分支,把用例聚焦在「占位没了还要不要说话」。 rejectPerfectPixel?.( @@ -3394,22 +3727,25 @@ describe('useImageCanvasGenerationWorkflow', () => { ); await waitFor(() => { - expect(screen.getByTestId('reference-pick-warning').textContent).toBe( + expect(screen.getByTestId('dialog-error').textContent).toBe( '完美像素画布占位不存在或尚未保存,请重试。', ); }); + expect(screen.getByTestId('dialog').textContent).toContain('failed'); + expect(screen.getByTestId('reference-pick-warning').textContent).toBe('-'); expect( screen.getByRole('status', { name: '完美像素状态' }).textContent, ).toBe('空闲'); }); - it('does not apply a completed project after the perfect-pixel placeholder was deleted', async () => { + it('keeps the operation identity through a successful POST and applies only the project GET snapshot', async () => { let resolvePerfectPixel: | ((value: ReturnType) => void) | undefined; let postedRequest: EditorPixelArtSnapInput | undefined; const applyProjectSnapshot = vi.fn(); const upsertGeneratedAsset = vi.fn(); + let getProject: EditorProjectSnapshot | undefined; snapImageToPerfectPixelsMock.mockImplementationOnce( (request: EditorPixelArtSnapInput) => new Promise((resolve) => { @@ -3417,6 +3753,13 @@ describe('useImageCanvasGenerationWorkflow', () => { resolvePerfectPixel = resolve; }), ); + loadEditorProjectMock.mockImplementationOnce(async () => { + getProject = createPerfectPixelProject( + postedRequest!.canvasCompletion.dialogId, + 'applied', + ); + return getProject; + }); render( { expect(snapImageToPerfectPixelsMock).toHaveBeenCalled(); }); fireEvent.click(screen.getByRole('button', { name: '删除处理占位' })); - expect(screen.getByTestId('dialog').textContent).toBe('-'); + expect(screen.getByTestId('generation-dialogs').textContent).not.toBe('-'); resolvePerfectPixel?.( createPerfectPixelResult( @@ -3457,15 +3800,12 @@ describe('useImageCanvasGenerationWorkflow', () => { expect(upsertGeneratedAsset).toHaveBeenCalledWith( createPerfectPixelAsset(postedRequest!.canvasCompletion.dialogId), ); - expect(applyProjectSnapshot).not.toHaveBeenCalled(); - expect(screen.getByTestId('dialog').textContent).toBe('-'); - // 中文注释:不套用是对的,但不能静默——服务端已经完成并把结果落到画布上,只是本地按 - // 删除意图没同步。文案必须说清「重新加载后可见」,不能沿用「只在素材库」那条:后者会让 - // 用户以为画布上没有,再做一遍。 - expect(screen.getByTestId('reference-pick-warning').textContent).toBe( - '完美像素结果已生成并保存到素材库。画布占位已被删除,结果未落入当前画布,重新加载后可见。', - ); + expect(applyProjectSnapshot).toHaveBeenCalledWith(getProject, { + type: 'perfect-pixel', + count: 1, }); + expect(screen.getByTestId('reference-pick-warning').textContent).toBe('-'); + }); it('recovers a hydrated generating operation with GET only and applies it without perfect-pixel history', async () => { const operationId = 'perfect-pixel-hydrated-applied'; @@ -3500,7 +3840,7 @@ describe('useImageCanvasGenerationWorkflow', () => { expect(snapImageToPerfectPixelsMock).not.toHaveBeenCalled(); expect(applyProjectSnapshotWithoutHistory).toHaveBeenCalledTimes(1); expect(applyProjectSnapshot).not.toHaveBeenCalled(); - }); + }); it('continues GET-only hydration recovery after the first project snapshot is still pending', async () => { const operationId = 'perfect-pixel-hydrated-eventual'; @@ -3540,11 +3880,11 @@ describe('useImageCanvasGenerationWorkflow', () => { }); const applyProjectSnapshotWithoutHistory = vi.fn(); loadEditorProjectMock.mockResolvedValueOnce({ - projectId: 'project-1', - title: '未命名画布', - viewport: { x: 0, y: 0, scale: 1 }, - layers: [], - resources: [], + projectId: 'project-1', + title: '未命名画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], updatedAt: '2026-08-03T00:00:00.000Z', }); @@ -3567,7 +3907,7 @@ describe('useImageCanvasGenerationWorkflow', () => { expect(screen.getByTestId('dialog-error').textContent).toBe( '完美像素结果仍待确认。系统不会在刷新后自动重复提交。', ); - }); + }); it('never reconstructs a POST when hydration may have happened after snapshot persistence but before submission', async () => { const operationId = 'perfect-pixel-hydrated-before-post'; diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index 4f6318c00..cc4c7179d 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -20,6 +20,7 @@ import { createEditorProjectResource, type EditorAssetSnapshot, type EditorPixelArtSnapInput, + type EditorPixelArtSnapResult, type EditorProjectLayerSnapshot, type EditorProjectResourceSnapshot, type EditorProjectSnapshot, @@ -226,23 +227,31 @@ function createProjectLayerSnapshotFromLayer( // 这条曾被写进到期清理注释的保证,对这段并不成立。 // // 给整段一个预算而不是给每次请求加超时:上传最多三次尝试、布局保存最多四次,逐个加超时 -// 的最坏总时长会累加到远超占位存活窗口,窗口的前提仍然不成立。整段封顶 90 秒之后, -// 客户端最坏 90 + 120 = 210 秒,落在 240 秒窗口内并留 30 秒余量。 +// 会让最坏总时长无界累加。源引用解析完成后会写入 durable operation;从那一刻起,剩余 +// 布局保存、最终 POST 与项目 GET 共用另一个 75 秒绝对对账窗口,POST 回包不会替它续期。 // -// 超时抛的是裸 Error 而非 ApiClientError,会被归入未知结果走对账——上传可能已经完成、 -// 素材可能已经落库,这正是对账要处理的情形。 +// 前置超时会 abort 底层上传;此时最终 POST 尚未发出,不进入项目结果对账,也不能声称素材 +// 可能已落库。极端的 confirm 响应丢失仍可能留下不可见 asset object,但不会创建账号素材。 // // 取 90 秒而不是更紧:这段在源图是 inline/未登记时会真的直传一张画布图层,几 MB 的图在 -// 较差移动网络下就要几十秒,卡到 40 秒会把原本能成功的操作改判为失败——那是用「无界」换 -// 「过紧」,同样是回归。90 + 120 = 210 秒,落在 240 秒存活窗口内并留 30 秒余量。 +// 较差移动网络下就要几十秒,卡到 40 秒会把原本能成功的操作改判为失败。它只约束 POST 前置 +// 阶段,不参与、也不能延长后续 75 秒的权威项目对账窗口。 export const PERFECT_PIXEL_PRE_POST_BUDGET_MS = 90_000; const PERFECT_PIXEL_PROJECT_READ_TIMEOUT_MS = 10_000; const PERFECT_PIXEL_RECONCILIATION_DELAYS_MS = [0, 1_000, 2_000, 4_000, 5_000]; export type PerfectPixelProjectVerdict = - | { kind: 'applied'; project: EditorProjectSnapshot } - | { kind: 'dialog-missing'; project: EditorProjectSnapshot } + | { + kind: 'applied'; + project: EditorProjectSnapshot; + resource: EditorProjectResourceSnapshot; + } + | { + kind: 'dialog-missing'; + project: EditorProjectSnapshot; + resource: EditorProjectResourceSnapshot; + } | { kind: 'pending'; project: EditorProjectSnapshot | null } | { kind: 'conflict'; @@ -256,7 +265,7 @@ export function inspectPerfectPixelProjectSnapshot( ): PerfectPixelProjectVerdict { const matchingResources = project.resources.filter( (resource) => resource.taskId?.trim() === operation.taskId, - ); + ); const dialog = findCanvasGenerationDialogRecord( project, operation.operationId, @@ -281,14 +290,10 @@ export function inspectPerfectPixelProjectSnapshot( }; } if (!dialog) { - return { kind: 'dialog-missing', project }; + return { kind: 'dialog-missing', project, resource }; } if (isUnresolvedCanvasGenerationDialogRecord(dialog)) { - return { - kind: 'conflict', - project, - message: '完美像素任务资源已存在,但画布占位仍未收口。', - }; + return { kind: 'pending', project }; } const generatedLayerId = typeof dialog.generatedLayerId === 'string' @@ -308,7 +313,18 @@ export function inspectPerfectPixelProjectSnapshot( message: '完美像素占位与任务资源的画布关联不一致,无法自动应用。', }; } - return { kind: 'applied', project }; + return { kind: 'applied', project, resource }; +} + +export function createPerfectPixelReconciliationOperation( + operation: PerfectPixelOperationSnapshot, + startedAt = Date.now(), +): PerfectPixelOperationSnapshot { + return { + ...operation, + submittedAt: startedAt, + reconcileUntil: startedAt + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS, + }; } function waitForPerfectPixelReconciliationDelay( @@ -340,14 +356,14 @@ function waitForPerfectPixelReconciliationDelay( export async function reconcilePerfectPixelProject( projectId: string, operation: PerfectPixelOperationSnapshot, - options: { signal?: AbortSignal; observeAtLeastOnce?: boolean } = {}, + options: { signal?: AbortSignal } = {}, ): Promise { let attempt = 0; let hasAttemptedRead = false; let latestProject: EditorProjectSnapshot | null = null; while (!options.signal?.aborted) { const remainingMs = operation.reconcileUntil - Date.now(); - if (remainingMs <= 0 && (!options.observeAtLeastOnce || hasAttemptedRead)) { + if (remainingMs <= 0 && hasAttemptedRead) { return { kind: 'pending', project: latestProject }; } const delayMs = @@ -361,10 +377,7 @@ export async function reconcilePerfectPixelProject( ); } const remainingAfterDelayMs = operation.reconcileUntil - Date.now(); - if ( - remainingAfterDelayMs <= 0 && - (!options.observeAtLeastOnce || hasAttemptedRead) - ) { + if (remainingAfterDelayMs <= 0 && hasAttemptedRead) { return { kind: 'pending', project: latestProject }; } try { @@ -391,13 +404,32 @@ export async function reconcilePerfectPixelProject( } catch (error) { if (options.signal?.aborted) { throw error; + } } - } attempt += 1; } throw options.signal?.reason ?? new DOMException('操作已取消', 'AbortError'); } +function resolveConfirmedPerfectPixelAsset( + result: EditorPixelArtSnapResult | null, + operation: PerfectPixelOperationSnapshot, + resource: EditorProjectResourceSnapshot, +): EditorAssetSnapshot | null { + if ( + !result || + result.taskId !== operation.taskId || + result.resource.taskId?.trim() !== operation.taskId || + result.resource.resourceId !== resource.resourceId || + result.resource.projectId !== resource.projectId || + result.asset.taskId?.trim() !== operation.taskId || + (resource.assetId && result.asset.assetId !== resource.assetId) + ) { + return null; + } + return result.asset; +} + async function withPerfectPixelPrePostBudget( work: Promise, deadlineAt: number, @@ -413,8 +445,8 @@ async function withPerfectPixelPrePostBudget( new Promise((_resolve, reject) => { timer = setTimeout( () => { - abortOnTimeout?.abort(new Error(timeoutMessage)); - reject(new Error(timeoutMessage)); + abortOnTimeout?.abort(new Error(timeoutMessage)); + reject(new Error(timeoutMessage)); }, Math.max(0, deadlineAt - Date.now()), ); @@ -500,16 +532,16 @@ function findSourceGenerationDialog( sourceLayer: CanvasLayer, ) { return [...dialogs].reverse().find( - (dialog) => - dialog.generatedLayerId === sourceLayer.id || - // sourceLayerId 也用于派生生成器的输入关系,类型不匹配时不能当作图层来源。 - (!dialog.generatedLayerId && - dialog.sourceLayerId === sourceLayer.id && - isGenerationDialogModeCompatibleWithSourceLayer( - dialog.mode, - sourceLayer, - )), - ); + (dialog) => + dialog.generatedLayerId === sourceLayer.id || + // sourceLayerId 也用于派生生成器的输入关系,类型不匹配时不能当作图层来源。 + (!dialog.generatedLayerId && + dialog.sourceLayerId === sourceLayer.id && + isGenerationDialogModeCompatibleWithSourceLayer( + dialog.mode, + sourceLayer, + )), + ); } function isGenerationDialogModeCompatibleWithSourceLayer( @@ -2068,8 +2100,8 @@ export function useImageCanvasGenerationWorkflow({ // 中文注释:两条文案对应两种**不同的事实**,不能共用。 // 「未落入画布」:服务端 completion 返回 Ok(None)——占位在处理期间被删掉,服务端画布上 // 没有结果图层,结果只在素材库。 - // 「未落入当前画布」:服务端已经完成回填,画布上**有**结果图层,只是本地占位已被用户删除、 - // 按删除意图不套用快照,所以本地看不到;重新加载即可见。 + // 「未落入当前画布」:服务端已经完成回填,画布上**有**结果图层,只是本地占位因其它 + // 权威更新而不存在,当前页面不套用快照,所以本地看不到;重新加载即可见。 // 早先这两种情况共用前一条文案,会让「结果其实已在画布上」的用户以为只进了素材库, // 进而重做一遍——正是本链路要消除的重复创建。 const PERFECT_PIXEL_ASSET_ONLY_NOTICE = @@ -2085,6 +2117,58 @@ export function useImageCanvasGenerationWorkflow({ } }, [refreshAssetLibrary]); + const settleLivePerfectPixelVerdict = useCallback( + ( + dialogId: string, + operation: PerfectPixelOperationSnapshot, + verdict: PerfectPixelProjectVerdict, + result: EditorPixelArtSnapResult | null, + ) => { + if (verdict.kind !== 'applied' && verdict.kind !== 'dialog-missing') { + return false; + } + const confirmedAsset = resolveConfirmedPerfectPixelAsset( + result, + operation, + verdict.resource, + ); + if (confirmedAsset) { + upsertGeneratedAsset?.(confirmedAsset); + } + if (verdict.kind === 'dialog-missing') { + if (hasCanvasGenerationDialogById(dialogId)) { + updateCanvasGenerationDialogById(dialogId, () => null); + } + refreshPerfectPixelAssetLibrary(); + showGenerationWarning(PERFECT_PIXEL_ASSET_ONLY_NOTICE); + return true; + } + if (!hasCanvasGenerationDialogById(dialogId)) { + refreshPerfectPixelAssetLibrary(); + showGenerationWarning(PERFECT_PIXEL_APPLIED_REMOTELY_NOTICE); + return true; + } + applyProjectSnapshot?.(verdict.project, { + type: 'perfect-pixel', + count: 1, + }); + refreshPerfectPixelAssetLibrary(); + setActiveTool('select'); + setActiveSidebarPanel('layers'); + return true; + }, + [ + applyProjectSnapshot, + hasCanvasGenerationDialogById, + refreshPerfectPixelAssetLibrary, + setActiveSidebarPanel, + setActiveTool, + showGenerationWarning, + updateCanvasGenerationDialogById, + upsertGeneratedAsset, + ], + ); + const snapSelectedLayerToPerfectPixels = useCallback( async (sourceLayer: CanvasLayer) => { const normalizedProjectId = projectId?.trim(); @@ -2185,7 +2269,10 @@ export function useImageCanvasGenerationWorkflow({ sourceLayer, 'image', normalizedProjectId, - { signal: prePostAbort.signal }, + { + signal: prePostAbort.signal, + uploadId: perfectPixelDialogId, + }, ), prePostDeadlineAt, '完美像素源图准备超时。', @@ -2199,8 +2286,8 @@ export function useImageCanvasGenerationWorkflow({ sourceImageSrc, projectId: normalizedProjectId, ...(sourceResourceId && - !sourceResourceId.startsWith('local-resource-') && - !sourceResourceId.startsWith('generation-dialog:') + !sourceResourceId.startsWith('local-resource-') && + !sourceResourceId.startsWith('generation-dialog:') ? { sourceResourceId } : {}), ...(sourceLayer.assetKind @@ -2267,67 +2354,34 @@ export function useImageCanvasGenerationWorkflow({ if (!isPerfectPixelAuthorityCurrent(operationAuthority)) { return; } - upsertGeneratedAsset?.(result.asset); - const responseIdentityMatches = - result.taskId === perfectPixelOperation.taskId && - result.resource.taskId === perfectPixelOperation.taskId; - if (!responseIdentityMatches) { - updateCanvasGenerationDialogById(perfectPixelDialogId, (dialog) => ({ - ...dialog, - status: 'pending-confirmation', - errorMessage: - '完美像素响应与已提交 operation 身份不一致,已停止自动应用。', - })); - return; - } - if (!result.project) { - // 中文注释:提示不能挂在「占位还在」这个条件里。占位已被删除时结果同样已经落库, - // 而且此时用户更需要知道——他删掉的是占位,不是已经生成的素材。原先提示与移除 - // 绑在同一个 if 里,占位不在就整段静默,用户不知道素材库多了一份。 - if (hasCanvasGenerationDialogById(perfectPixelDialogId)) { - updateCanvasGenerationDialogById(perfectPixelDialogId, () => null); - } - refreshPerfectPixelAssetLibrary(); - showGenerationWarning(PERFECT_PIXEL_ASSET_ONLY_NOTICE); - return; - } - const directVerdict = inspectPerfectPixelProjectSnapshot( - result.project, + // 中文注释:同步 POST 的响应体不是提交事实。即使它带 project / asset,也可能是网关 + // 断连前后的时点快照;只让项目 GET 决定终态。对账继续使用 POST 前已持久化的绝对 + // deadline,响应返回不能替同一次 operation 续期。 + const verdict = await reconcilePerfectPixelProject( + normalizedProjectId, perfectPixelOperation, ); - if (directVerdict.kind === 'dialog-missing') { - if (hasCanvasGenerationDialogById(perfectPixelDialogId)) { - updateCanvasGenerationDialogById(perfectPixelDialogId, () => null); - } - refreshPerfectPixelAssetLibrary(); - showGenerationWarning(PERFECT_PIXEL_ASSET_ONLY_NOTICE); + if (!isPerfectPixelAuthorityCurrent(operationAuthority)) { return; } - if (directVerdict.kind !== 'applied') { - updateCanvasGenerationDialogById(perfectPixelDialogId, (dialog) => ({ - ...dialog, - status: 'pending-confirmation', - errorMessage: - directVerdict.kind === 'conflict' - ? directVerdict.message - : '完美像素响应尚未包含可确认的项目结果。', - })); + if ( + settleLivePerfectPixelVerdict( + perfectPixelDialogId, + perfectPixelOperation, + verdict, + result, + ) + ) { return; } - if (!hasCanvasGenerationDialogById(perfectPixelDialogId)) { - // 中文注释:不套用是对的(删除意图胜出),但不能静默。服务端已经完成并把结果落到了 - // 画布上,只是本地按用户的删除意图没有同步——必须说清楚,否则用户既看不到结果、 - // 也不知道它已经存在,会再做一遍。这是本链路同一形状的第三处,前两处已修。 - showGenerationWarning(PERFECT_PIXEL_APPLIED_REMOTELY_NOTICE); - return; - } - applyProjectSnapshot(directVerdict.project, { - type: 'perfect-pixel', - count: 1, - }); - refreshPerfectPixelAssetLibrary(); - setActiveTool('select'); - setActiveSidebarPanel('layers'); + updateCanvasGenerationDialogById(perfectPixelDialogId, (dialog) => ({ + ...dialog, + status: 'pending-confirmation', + errorMessage: + verdict.kind === 'conflict' + ? verdict.message + : '权威项目尚未出现可确认的完美像素结果。系统不会自动重复提交。', + })); } catch (error) { if (!isPerfectPixelAuthorityCurrent(operationAuthority)) { return; @@ -2367,36 +2421,20 @@ export function useImageCanvasGenerationWorkflow({ const verdict = await reconcilePerfectPixelProject( normalizedProjectId, perfectPixelOperation, - ); + ); if (!isPerfectPixelAuthorityCurrent(operationAuthority)) { return; } - if (verdict.kind === 'dialog-missing') { - if (hasCanvasGenerationDialogById(perfectPixelDialogId)) { - updateCanvasGenerationDialogById( - perfectPixelDialogId, - () => null, - ); - } - refreshPerfectPixelAssetLibrary(); - showGenerationWarning(PERFECT_PIXEL_ASSET_ONLY_NOTICE); - return; - } - if (verdict.kind === 'applied') { - if (!hasCanvasGenerationDialogById(perfectPixelDialogId)) { - refreshPerfectPixelAssetLibrary(); - showGenerationWarning(PERFECT_PIXEL_APPLIED_REMOTELY_NOTICE); - return; - } - applyProjectSnapshot(verdict.project, { - type: 'perfect-pixel', - count: 1, - }); - refreshPerfectPixelAssetLibrary(); - setActiveTool('select'); - setActiveSidebarPanel('layers'); - return; - } + if ( + settleLivePerfectPixelVerdict( + perfectPixelDialogId, + perfectPixelOperation, + verdict, + null, + ) + ) { + return; + } keepPendingConfirmation = true; reconciledMessage = verdict.kind === 'conflict' @@ -2423,13 +2461,9 @@ export function useImageCanvasGenerationWorkflow({ errorMessage, })); } else { - // 中文注释:占位不存在时同样要说话。除了「占位还没建起来就失败」,还有一种是 - // 用户在请求期间主动删掉了生成中占位(`requestRemoveCanvasGenerationDialog` - // 对 generating 会先弹确认,是产品支持的流程)。此时 dialogId 有值但占位已经 - // 不在,没有地方挂错误——早先这里只判 `!perfectPixelDialogId`,该分支整条 - // 落空,errorMessage 被算出来又丢掉,界面零反馈。丢的还不只是失败提示:对账 - // 得出的「请确认素材库是否已生成派生图」也在同一句里,用户会在毫不知情的 - // 情况下重试,再造一份 OSS 对象与素材。 + // 中文注释:durable operation 的普通删除路径已被禁止,但占位仍可能在快照写入前 + // 失败、被其它权威项目更新移除,或来自旧会话状态。没有本地 dialog 可挂错误时必须 + // 退回全局提示,不能静默开放一条新 identity 的重提路径。 showGenerationWarning(errorMessage); } } finally { @@ -2452,24 +2486,22 @@ export function useImageCanvasGenerationWorkflow({ [ applyProjectSnapshot, assetFolderId, + canvasGenerationDialogs, closeGenerationTransientState, currentUserId, flushProjectPersistence, hasCanvasGenerationDialogById, isPerfectPixelAuthorityCurrent, openPlacedCanvasGenerationDialog, - refreshPerfectPixelAssetLibrary, projectId, - setActiveSidebarPanel, - setActiveTool, setCharacterAnimationPanel, setCropExpandPanel, setImageContextMenu, setMetadataLayer, setQuickEditPanel, showGenerationWarning, + settleLivePerfectPixelVerdict, updateCanvasGenerationDialogById, - upsertGeneratedAsset, ], ); @@ -2519,16 +2551,8 @@ export function useImageCanvasGenerationWorkflow({ return nextLayerIds; }); } - const retrySubmittedAt = Date.now(); - const retriedOperation: PerfectPixelOperationSnapshot = { - ...operation, - // 中文注释:人工重试只刷新 operation envelope 的观察时钟;request / operationId / - // taskId 原样保留,因而 POST JSON 与首次提交完全一致,服务端 exact replay 仍命中 - // 同一身份。submittedAt 与 reconcileUntil 必须一起移动,保持 v1 固定窗口。 - submittedAt: retrySubmittedAt, - reconcileUntil: - retrySubmittedAt + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS, - }; + const retriedOperation = + createPerfectPixelReconciliationOperation(operation); observedPerfectPixelRecoveryKeysRef.current.add( perfectPixelRecoveryKey( currentUserId, @@ -2557,62 +2581,31 @@ export function useImageCanvasGenerationWorkflow({ if (!isPerfectPixelAuthorityCurrent(operationAuthority)) { return; } - upsertGeneratedAsset?.(result.asset); - if ( - result.taskId !== retriedOperation.taskId || - result.resource.taskId !== retriedOperation.taskId - ) { - updateCanvasGenerationDialogById(normalizedDialogId, (current) => ({ - ...current, - status: 'pending-confirmation', - errorMessage: - '完美像素响应与原 operation 身份不一致,已停止自动应用。', - })); - return; - } - if (!result.project) { - if (hasCanvasGenerationDialogById(normalizedDialogId)) { - updateCanvasGenerationDialogById(normalizedDialogId, () => null); - } - refreshPerfectPixelAssetLibrary(); - showGenerationWarning(PERFECT_PIXEL_ASSET_ONLY_NOTICE); - return; - } - const verdict = inspectPerfectPixelProjectSnapshot( - result.project, + const verdict = await reconcilePerfectPixelProject( + normalizedProjectId, retriedOperation, ); - if (verdict.kind === 'dialog-missing') { - if (hasCanvasGenerationDialogById(normalizedDialogId)) { - updateCanvasGenerationDialogById(normalizedDialogId, () => null); - } - refreshPerfectPixelAssetLibrary(); - showGenerationWarning(PERFECT_PIXEL_ASSET_ONLY_NOTICE); + if (!isPerfectPixelAuthorityCurrent(operationAuthority)) { return; } - if (verdict.kind !== 'applied') { - updateCanvasGenerationDialogById(normalizedDialogId, (current) => ({ - ...current, - status: 'pending-confirmation', - errorMessage: - verdict.kind === 'conflict' - ? verdict.message - : '完美像素响应尚未包含可确认的项目结果。', - })); + if ( + settleLivePerfectPixelVerdict( + normalizedDialogId, + retriedOperation, + verdict, + result, + ) + ) { return; } - if (!hasCanvasGenerationDialogById(normalizedDialogId)) { - refreshPerfectPixelAssetLibrary(); - showGenerationWarning(PERFECT_PIXEL_APPLIED_REMOTELY_NOTICE); - return; - } - applyProjectSnapshot(verdict.project, { - type: 'perfect-pixel', - count: 1, - }); - refreshPerfectPixelAssetLibrary(); - setActiveTool('select'); - setActiveSidebarPanel('layers'); + updateCanvasGenerationDialogById(normalizedDialogId, (current) => ({ + ...current, + status: 'pending-confirmation', + errorMessage: + verdict.kind === 'conflict' + ? verdict.message + : '权威项目尚未出现可确认的完美像素结果。系统不会自动重复提交。', + })); } catch (error) { if (!isPerfectPixelAuthorityCurrent(operationAuthority)) { return; @@ -2634,27 +2627,14 @@ export function useImageCanvasGenerationWorkflow({ if (!isPerfectPixelAuthorityCurrent(operationAuthority)) { return; } - if (verdict.kind === 'applied') { - if (!hasCanvasGenerationDialogById(normalizedDialogId)) { - refreshPerfectPixelAssetLibrary(); - showGenerationWarning(PERFECT_PIXEL_APPLIED_REMOTELY_NOTICE); - return; - } - applyProjectSnapshot(verdict.project, { - type: 'perfect-pixel', - count: 1, - }); - refreshPerfectPixelAssetLibrary(); - setActiveTool('select'); - setActiveSidebarPanel('layers'); - return; - } - if (verdict.kind === 'dialog-missing') { - if (hasCanvasGenerationDialogById(normalizedDialogId)) { - updateCanvasGenerationDialogById(normalizedDialogId, () => null); - } - refreshPerfectPixelAssetLibrary(); - showGenerationWarning(PERFECT_PIXEL_ASSET_ONLY_NOTICE); + if ( + settleLivePerfectPixelVerdict( + normalizedDialogId, + retriedOperation, + verdict, + null, + ) + ) { return; } updateCanvasGenerationDialogById(normalizedDialogId, (current) => ({ @@ -2696,15 +2676,11 @@ export function useImageCanvasGenerationWorkflow({ canvasGenerationDialogs, currentUserId, flushProjectPersistence, - hasCanvasGenerationDialogById, isPerfectPixelAuthorityCurrent, projectId, - refreshPerfectPixelAssetLibrary, - setActiveSidebarPanel, - setActiveTool, showGenerationWarning, + settleLivePerfectPixelVerdict, updateCanvasGenerationDialogById, - upsertGeneratedAsset, ], ); @@ -2796,7 +2772,6 @@ export function useImageCanvasGenerationWorkflow({ operation, { signal: controller.signal, - observeAtLeastOnce: true, }, ); if ( @@ -2910,13 +2885,14 @@ export function useImageCanvasGenerationWorkflow({ ]); useEffect(() => { + const recoveryControllers = perfectPixelRecoveryControllersRef.current; generationWorkflowMountedRef.current = true; return () => { generationWorkflowMountedRef.current = false; - for (const controller of perfectPixelRecoveryControllersRef.current.values()) { + for (const controller of recoveryControllers.values()) { controller.abort(); } - perfectPixelRecoveryControllersRef.current.clear(); + recoveryControllers.clear(); }; }, []); diff --git a/src/components/image-editor/useImageCanvasLayerCommands.test.tsx b/src/components/image-editor/useImageCanvasLayerCommands.test.tsx index 3f063c6aa..3c1350357 100644 --- a/src/components/image-editor/useImageCanvasLayerCommands.test.tsx +++ b/src/components/image-editor/useImageCanvasLayerCommands.test.tsx @@ -7,6 +7,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { CanvasContextMenuState, CanvasGenerationDialogState, + CanvasHistoryAction, CanvasLayer, } from './ImageCanvasEditorTypes'; import { readLayerImageBlob } from './ImageCanvasExportModel'; @@ -40,6 +41,43 @@ function createLayer(id: string, x: number, zIndex: number): CanvasLayer { }; } +function createDurablePerfectPixelDialog( + id: string, + status: 'generating' | 'pending-confirmation', +): CanvasGenerationDialogState { + const submittedAt = 1_700_000_000_000; + return { + id, + mode: 'quick-edit', + prompt: '完美像素', + status, + perfectPixelOperation: { + version: 1, + kind: 'perfect-pixel', + operationId: id, + taskId: `pixel-art-snap-${id}`, + request: { + sourceImageSrc: 'generated-images/editor/source.png', + projectId: 'project-1', + canvasCompletion: { + dialogId: id, + title: '源图 · 完美像素', + placeholder: { + x: 20, + y: 20, + width: 100, + height: 80, + originalWidth: 100, + originalHeight: 80, + }, + }, + }, + submittedAt, + reconcileUntil: submittedAt + 75_000, + }, + }; +} + const originalClipboard = navigator.clipboard; const originalClipboardItem = globalThis.ClipboardItem; const originalCreateImageBitmap = globalThis.createImageBitmap; @@ -71,7 +109,11 @@ function LayerCommandsHarness({ exportLayerImage = vi.fn(), onDeleteLayerSideEffects = vi.fn(), onDeleteGenerationDialogSideEffects = vi.fn(), + onCaptureCanvasHistory = vi.fn(), initialLayers, + initialSelectedLayerId = 'first', + initialSelectedLayerIds = ['first', 'second'], + initialCanvasGenerationDialogs, }: { exportLayerImage?: ( layer: CanvasLayer | null, @@ -79,7 +121,11 @@ function LayerCommandsHarness({ ) => void; onDeleteLayerSideEffects?: (layerId: string) => void; onDeleteGenerationDialogSideEffects?: (dialogId: string) => void; + onCaptureCanvasHistory?: (action: CanvasHistoryAction) => void; initialLayers?: CanvasLayer[]; + initialSelectedLayerId?: string | null; + initialSelectedLayerIds?: string[]; + initialCanvasGenerationDialogs?: CanvasGenerationDialogState[]; }) { const [layers, setLayers] = useState( initialLayers ?? [ @@ -89,28 +135,29 @@ function LayerCommandsHarness({ ], ); const [selectedLayerId, setSelectedLayerId] = useState( - 'first', + initialSelectedLayerId, ); - const [selectedLayerIds, setSelectedLayerIds] = useState([ - 'first', - 'second', - ]); - const [canvasGenerationDialogs] = useState([ - { - id: 'dialog-1', - mode: 'generate', - prompt: '生成', - status: 'idle', - placeholder: { - x: 20, - y: 20, - width: 100, - height: 80, - originalWidth: 100, - originalHeight: 80, + const [selectedLayerIds, setSelectedLayerIds] = useState( + initialSelectedLayerIds, + ); + const [canvasGenerationDialogs] = useState( + initialCanvasGenerationDialogs ?? [ + { + id: 'dialog-1', + mode: 'generate', + prompt: '生成', + status: 'idle', + placeholder: { + x: 20, + y: 20, + width: 100, + height: 80, + originalWidth: 100, + originalHeight: 80, + }, }, - }, - ]); + ], + ); const [metadataLayer, setMetadataLayer] = useState( layers[0] ?? null, ); @@ -148,8 +195,10 @@ function LayerCommandsHarness({ setImageContextMenu: () => setImageContextClosedCount((currentCount) => currentCount + 1), setActiveTool, - captureCanvasHistory: () => - setHistoryCount((currentCount) => currentCount + 1), + captureCanvasHistory: (action) => { + setHistoryCount((currentCount) => currentCount + 1); + onCaptureCanvasHistory(action); + }, selectSingleLayer, onDeleteLayerSideEffects, onDeleteGenerationDialogSideEffects, @@ -578,4 +627,78 @@ describe('useImageCanvasLayerCommands', () => { 'dialog-1', ); }); + + it.each(['generating', 'pending-confirmation'] as const)( + 'keeps a selected durable perfect-pixel dialog as a complete no-op while %s', + (status) => { + const dialog = createDurablePerfectPixelDialog( + `perfect-pixel-${status}`, + status, + ); + const selectionId = `generation-dialog:${dialog.id}`; + const onCaptureCanvasHistory = vi.fn(); + const onDeleteLayerSideEffects = vi.fn(); + const onDeleteGenerationDialogSideEffects = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '删除选中' })); + + expect(screen.getByTestId('history').textContent).toBe('0'); + expect(screen.getByTestId('selection').textContent).toBe( + `${selectionId}:${selectionId}`, + ); + expect(screen.getByTestId('context').textContent).toBe('open'); + expect(screen.getByTestId('image-context-closed').textContent).toBe('0'); + expect(onCaptureCanvasHistory).not.toHaveBeenCalled(); + expect(onDeleteLayerSideEffects).not.toHaveBeenCalled(); + expect(onDeleteGenerationDialogSideEffects).not.toHaveBeenCalled(); + }, + ); + + it('deletes and counts only deletable targets in a mixed durable-dialog selection', () => { + const dialog = createDurablePerfectPixelDialog( + 'perfect-pixel-pending', + 'pending-confirmation', + ); + const dialogSelectionId = `generation-dialog:${dialog.id}`; + const onCaptureCanvasHistory = vi.fn(); + const onDeleteLayerSideEffects = vi.fn(); + const onDeleteGenerationDialogSideEffects = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '删除选中' })); + + expect(screen.getByTestId('layers').textContent).not.toContain('first'); + expect(screen.getByTestId('selection').textContent).toBe('third:third'); + expect(screen.getByTestId('history').textContent).toBe('1'); + expect(onCaptureCanvasHistory).toHaveBeenCalledWith({ + type: 'delete-image', + count: 1, + }); + expect(onDeleteLayerSideEffects).toHaveBeenCalledTimes(1); + expect(onDeleteLayerSideEffects).toHaveBeenCalledWith('first'); + expect(onDeleteGenerationDialogSideEffects).not.toHaveBeenCalled(); + }); }); diff --git a/src/components/image-editor/useImageCanvasLayerCommands.ts b/src/components/image-editor/useImageCanvasLayerCommands.ts index 4aa6ecc98..ba99193df 100644 --- a/src/components/image-editor/useImageCanvasLayerCommands.ts +++ b/src/components/image-editor/useImageCanvasLayerCommands.ts @@ -34,6 +34,9 @@ import { getCanvasGenerationDialogIdFromSelectionId, getSelectedLayerIds, } from './ImageCanvasSelectionModel'; +import { + isUnsettledPerfectPixelOperationDialog, +} from './useCanvasGenerationDialogs'; import type { ImageSequenceExportMode } from './useImageCanvasAssetExportWorkflow'; type LayerCommandsOptions = { @@ -633,7 +636,11 @@ export function useImageCanvasLayerCommands({ .map(getCanvasGenerationDialogIdFromSelectionId) .filter((dialogId): dialogId is string => Boolean(dialogId)) .filter((dialogId) => - canvasGenerationDialogs.some((dialog) => dialog.id === dialogId), + canvasGenerationDialogs.some( + (dialog) => + dialog.id === dialogId && + !isUnsettledPerfectPixelOperationDialog(dialog), + ), ); if (!targetLayerIds.length && !targetDialogIds.length) { return; diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx index 66696ab38..18034a36b 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx +++ b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx @@ -930,7 +930,7 @@ function StrictProjectPersistenceHarness({ operationId: STRICT_DIALOG_ID, taskId: STRICT_TASK_ID, submittedAt: 1_771_400_000_000, - reconcileUntil: 1_771_400_240_000, + reconcileUntil: 1_771_400_075_000, request: { sourceImageSrc: operationSourceImageSrc, projectId: 'editor-project-default', @@ -1073,7 +1073,7 @@ describe('useImageCanvasProjectPersistence', () => { operationId: STRICT_DIALOG_ID, taskId: STRICT_TASK_ID, submittedAt: 1_771_400_000_000, - reconcileUntil: 1_771_400_240_000, + reconcileUntil: 1_771_400_075_000, request: { sourceImageSrc: 'resource-source', projectId: 'editor-project-default', 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 5f5ed6d7a..2437878a6 100644 --- a/src/services/image-editor/editorMediaAssetUploadClient.ts +++ b/src/services/image-editor/editorMediaAssetUploadClient.ts @@ -166,7 +166,7 @@ export async function uploadEditorMediaAssetFile( objectKey: uploaded.objectKey, expireSeconds: EDITOR_MEDIA_READ_EXPIRE_SECONDS, }, - undefined, + options.signal, { bypassCache: true }, ); return {