From 526333f13062102b07875ff6524405529f07d9ac Mon Sep 17 00:00:00 2001 From: Linghong Date: Tue, 4 Aug 2026 10:28:58 +0000 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AE=8C=E7=BE=8E=E5=83=8F?= =?UTF-8?q?=E7=B4=A0=E6=BA=90=E5=9B=BE=E7=A1=AE=E8=AE=A4=E5=90=8E=E7=9A=84?= =?UTF-8?q?=E5=8E=9F=E6=93=8D=E4=BD=9C=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 拆分源准备与严格布局保存预算,并让布局请求覆盖完整生命周期。 严格保存失败时保留原操作身份,阻止重新上传和创建第二条操作。 补充原样重试、迟到保存和删除保护的定向测试。 同步更新图片画布技术方案与项目决策记录。 --- .../shared-memory/decision-log.md | 9 + ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 1 + .../ImageCanvasEditorModel.test.ts | 29 +- ...ImageCanvasGenerationComposerView.test.tsx | 251 +++++++----- .../ImageCanvasGenerationComposerView.tsx | 39 +- .../useCanvasGenerationDialogs.test.tsx | 4 +- .../useCanvasGenerationDialogs.ts | 4 +- .../useImageCanvasGenerationWorkflow.test.tsx | 72 +++- .../useImageCanvasGenerationWorkflow.ts | 72 ++-- .../useImageCanvasLayerCommands.test.tsx | 12 +- .../useImageCanvasProjectPersistence.test.tsx | 75 ++++ .../useImageCanvasProjectPersistence.ts | 384 +++++++++++++----- .../image-editor/editorProjectClient.test.ts | 27 +- .../image-editor/editorProjectClient.ts | 39 +- 14 files changed, 712 insertions(+), 306 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index e8ec2041f..4e6dc4200 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6236,3 +6236,12 @@ - 对账边界:窗口内每次 GET 的 deadline 为 `min(reconcileUntil, readStartedAt + 10 秒)`;operation 已过期但从未读取时仍执行一次即时 GET,该例外最多 10 秒。deadline 只把本次读取视为失败,不穿透成未处理 rejection;轮询随后返回 `pending-confirmation` 并让首次提交 / hydrate 恢复的既有清理链执行。 - 验证范围:`apiClient` 定向覆盖缺 token 与 401 refresh 永久等待、响应体永久等待;`editorProjectClient` 钉住 deadline 透传且普通读取仍保留 60 秒默认 timeout;generation workflow 钉住窗口内和过期单次读取的 deadline。未扩大为全站请求超时迁移。 - 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-04 完美像素 confirm 后 strict 保存失败保留原 operation + +- 缺陷:源图 `ticket → PUT → confirm` 成功后,首次 strict layout flush 仍沿用从上传开始计算的 90 秒预算。预算耗尽会把占位标成普通 `failed` 并解锁;既有重试只接受 `pending-confirmation`,普通按钮遂创建新 dialog、重新上传并换 operation identity。旧 confirmed source object 无法再由用户路径引用。 +- 决策:源准备 90 秒与 operation journal strict save 60 秒拆开。confirm 后立即形成稳定 `perfectPixelOperation`,strict deadline 覆盖等待活动保存、PATCH、revision conflict reload 和 transport retry;到期会取消 strict request/waiter、阻止自动转移或继续 POST,并释放本地活动保存槽。浏览器 abort 不等于远端撤销,迟到 PATCH 仍可能提交,但其本地 Promise 不再触发 POST;后续重试依靠 revision CAS/reload 收口。 +- 状态与重试:POST 尚未发出时的 strict 失败保留 `failed + perfectPixelOperation`,不做结果 GET;原占位提供 exact retry,复用同一 request、source objectKey、dialog/operation/task identity,不重新执行 ticket、PUT 或 confirm。普通完美像素入口把该状态视为未收口,不能创建第二条 operation;普通删除和随源图删除同样保留 identity。 +- 预算不变式:源准备 90 秒加 strict journal 60 秒仍小于 legacy inline 占位 240 秒窗口;POST 只会在 operation journal ACK 后开始,因此不再计入该 legacy TTL。布局保存本身使用请求全生命周期绝对 deadline,覆盖鉴权等待、业务 fetch 与响应体读取。 +- 剩余边界:confirm 成功后浏览器立即崩溃、且 operation 首次 PATCH 尚未落库时,仍可能留下 object-only 记录。彻底消除需服务端 durable upload journal;当前修复不声称撤销已发送的 PATCH 或清理历史孤儿对象。 +- 关联文档:`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 06879a4d1..a40d5ce10 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -50,6 +50,7 @@ - 选中已有静态栅格图层后的 `完美像素` 是独立的一键派生操作,不等同于生成请求上的 `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 的右侧生成占位,再解析或上传源图以取得稳定引用,随后把版本化 `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 归属。 +- 源准备与 operation journal 使用两段绝对预算:`ticket → PUT → confirm` 连同源解析共用 90 秒;confirm 成功并形成稳定 `perfectPixelOperation` 后,strict layout save 另有 60 秒,覆盖等待既有保存、PATCH、冲突 GET 和重试退避。strict waiter 到期必须协作取消当前请求并释放本地保存队列;远端已经收到的迟到 PATCH 无法撤销,但不得再触发后续完美像素 POST。strict revision ACK 前 POST 和结果对账 GET 均为零。此阶段失败持久化为 `failed + perfectPixelOperation`,保留同一 `sourceImageSrc / dialogId / taskId / request`,普通按钮不得创建第二个 operation;用户只能从原占位重试,重试请求必须与 journal 中的 POST JSON byte-for-byte 一致且不得重新上传。confirm 成功后浏览器在 operation 首次 PATCH 落库前立即崩溃仍可能留下 object-only 记录;完全消除该窗口需要服务端 durable upload journal,不属于当前前端修复。 - 该已有图片入口使用 strict 语义:只接受静态 PNG / JPEG / WebP,GIF、APNG、动画 WebP、图片序列及其它非静态媒体必须在处理前拒绝。strict 与生成风格复用完全相同的 legacy profile、峰值估算、单轴步长补全、walker、采样和编码;仅当横纵两轴都未检测到步长、legacy 即将使用 `min(width,height)/64` 统一网格兜底时拒绝。任一轴已检测到步长时,两条路径行为和输出必须一致。源图读取、解码、尺寸校验、排队、像素规整或 PNG 编码任一步失败 / 超时 / 不适用时,请求失败,不保留原图副本冒充成功,不执行最终 OSS PUT,也不创建 project resource、账号素材或结果图层。成功时只对最终 PNG 执行一次 OSS PUT,并至多各创建一个 `editor_project_resource` 和一个 `editor_asset`,再按 `canvasCompletion` 写回一个派生图层;不得保存逻辑低分辨率图、诊断图或前后对比图。 - strict 的本次结果事实零写入边界截至首个最终 PNG PUT:所有可预判的引用、归属、类型、静态编码、元数据、网格适用性和 CPU 处理错误必须在此前失败;前置 owner-scoped 项目 / 素材读取仍可能按既有语义懒建默认 canvas / folder,这些基础记录不属于本次完美像素结果。最终 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 后的既有认证恢复不属于业务副作用重放,保持通用行为。POST 回包中的 `project / resource / asset` 不是结果 verdict;首次成功回包、未知异常、人工 exact replay 和刷新恢复都只读取项目 GET。`perfectPixelOperation.submittedAt / reconcileUntil` 从稳定请求快照写入时建立统一 75 秒绝对窗口,POST 回包不能续期;读取必须立即执行一次,随后退避间隔不超过 5 秒,窗口已过期时仍执行一次即时 GET。每次项目读取使用 `requestJson.deadlineAt` 覆盖缺 token 补票、业务 fetch、401 refresh、重试退避与响应体读取;窗口内单次最多 10 秒且不得越过 `reconcileUntil`,过期后的唯一即时读取最多额外 10 秒。固定判据为:匹配 task 的唯一 resource 加已收口 dialog / 关联图层才是画布成功;dialog 不存在但存在匹配 task resource 才是 asset-only 成功;dialog 仍 generating、dialog 不存在且无匹配 resource、项目始终不可读或窗口耗尽均保持 unknown。素材库刷新只在项目终态后 fire-and-forget,同步抛错、异步拒绝或永久挂起都不得阻塞 verdict、项目快照应用和执行锁释放。 diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index 2c5067ffd..4a3259c22 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -33,7 +33,10 @@ import type { EditorAsset, PerfectPixelOperationSnapshot, } from './ImageCanvasEditorTypes'; -import { PERFECT_PIXEL_PRE_POST_BUDGET_MS } from './useImageCanvasGenerationWorkflow'; +import { + PERFECT_PIXEL_SOURCE_PREPARATION_BUDGET_MS, + PERFECT_PIXEL_STRICT_LAYOUT_SAVE_BUDGET_MS, +} from './useImageCanvasGenerationWorkflow'; function buildPerfectPixelOperation( dialogId: string, @@ -1300,23 +1303,21 @@ describe('ImageCanvasEditorModel', () => { }); }); - it('keeps the placeholder live window above the whole client-side upper bound', () => { - // 中文注释:这条不等式散在三个常量里——提交前置预算、客户端 POST 超时、占位存活窗口—— - // 任何一处被单独调大都会破坏它,而破坏的后果是跨标签页误删:B 标签会清掉 A 标签仍在 - // 合法执行的占位,随后 A 的 POST 因占位不存在返回 409。本会话自己的占位另有归属登记 - // 豁免,不靠这个窗口,所以窗口只需覆盖跨标签页那一侧。 - const CLIENT_POST_TIMEOUT_MS = 120_000; - const clientUpperBoundMs = - PERFECT_PIXEL_PRE_POST_BUDGET_MS + CLIENT_POST_TIMEOUT_MS; + it('keeps the legacy placeholder window above source preparation and strict journaling', () => { + // operation 首次 strict journal 成功后会退出 legacy requiresLiveSession 清理;窗口只需 + // 覆盖源准备与该次 journal,POST 和对账由 durable operation 自己保护。 + const durableOperationJournalUpperBoundMs = + PERFECT_PIXEL_SOURCE_PREPARATION_BUDGET_MS + + PERFECT_PIXEL_STRICT_LAYOUT_SAVE_BUDGET_MS; - expect(clientUpperBoundMs).toBe(210_000); + expect(durableOperationJournalUpperBoundMs).toBe(150_000); expect(INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS).toBeGreaterThan( - clientUpperBoundMs, + durableOperationJournalUpperBoundMs, ); - // 中文注释:余量不能只是「大于」。网络往返与标签页挂起后的时钟漂移都要吃掉一部分。 expect( - INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS - clientUpperBoundMs, - ).toBeGreaterThanOrEqual(30_000); + INLINE_GENERATION_PLACEHOLDER_LIVE_WINDOW_MS - + durableOperationJournalUpperBoundMs, + ).toBeGreaterThanOrEqual(90_000); }); describe('inline placeholder expiry helpers', () => { diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx index c3b0649fb..b27b7be9e 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx @@ -17,7 +17,6 @@ function mockStateSetter() { return vi.fn() as unknown as Dispatch>; } - function createComposerProps( generateDialog: GenerateDialogState, overrides: Partial< @@ -117,8 +116,17 @@ function renderComposer( } describe('ImageCanvasGenerationComposerView', () => { - it('待确认的完美像素操作只展示原操作重试,不回退到普通生成表单', () => { - const dialogId = 'dialog-perfect-pixel-pending'; + it.each([ + { + status: 'pending-confirmation' as const, + message: '结果尚未确认,系统不会自动重复提交。', + }, + { + status: 'failed' as const, + message: '完美像素请求尚未发出,请重试同一操作。', + }, + ])('$status 的完美像素操作只展示原操作重试', ({ status, message }) => { + const dialogId = `dialog-perfect-pixel-${status}`; const onRetryPerfectPixelOperation = vi.fn(); const onSubmitImageGeneration = vi.fn(); @@ -127,9 +135,10 @@ describe('ImageCanvasGenerationComposerView', () => { id: dialogId, mode: 'generate', prompt: '不应重新提交的普通提示词', - status: 'pending-confirmation', + status, composerOpen: true, imageModel: 'gpt-image-2', + ...(status === 'failed' ? { errorMessage: message } : {}), perfectPixelOperation: { version: 1, kind: 'perfect-pixel', @@ -165,8 +174,7 @@ describe('ImageCanvasGenerationComposerView', () => { ); const panel = screen.getByRole('dialog', { name: '完美像素操作' }); - expect(within(panel).getByText('结果尚未确认,系统不会自动重复提交。')) - .toBeTruthy(); + expect(within(panel).getByText(message)).toBeTruthy(); expect(within(panel).queryByRole('textbox')).toBeNull(); expect(within(panel).queryByText('不应重新提交的普通提示词')).toBeNull(); expect(within(panel).queryByText('gpt-image-2')).toBeNull(); @@ -206,9 +214,9 @@ describe('ImageCanvasGenerationComposerView', () => { ); const panel = screen.getByRole('dialog', { name: '完美像素操作' }); - expect( - within(panel).getByRole('alert').textContent, - ).toBe('完美像素操作快照身份不匹配。'); + expect(within(panel).getByRole('alert').textContent).toBe( + '完美像素操作快照身份不匹配。', + ); expect(within(panel).queryByRole('textbox')).toBeNull(); expect(within(panel).queryByRole('button')).toBeNull(); expect(within(panel).queryByText('不得重新生成')).toBeNull(); @@ -243,8 +251,9 @@ describe('ImageCanvasGenerationComposerView', () => { expect(panel.className).not.toContain( 'image-canvas-editor__quick-edit-panel', ); - expect(within(panel).getByRole('textbox', { name: '快速编辑提示词' })) - .toBeTruthy(); + expect( + within(panel).getByRole('textbox', { name: '快速编辑提示词' }), + ).toBeTruthy(); expect( within(panel).queryByRole('button', { name: '添加参考图' }), ).toBeNull(); @@ -266,22 +275,24 @@ describe('ImageCanvasGenerationComposerView', () => { it('让生成UI设计图面板复用普通图片生成面板的纵向结构', () => { const setGenerateDialog = vi.fn(); - renderComposer({ - mode: 'ui-design', - prompt: '', - status: 'idle', - composerOpen: true, - uiDesignSpecReference: null, - imageModel: 'gpt-image-2', - aspectRatio: '16:9', - imageSize: '1K', - }, { - isUiDesignSpecMenuOpen: true, - setGenerateDialog: - setGenerateDialog as unknown as Dispatch< + renderComposer( + { + mode: 'ui-design', + prompt: '', + status: 'idle', + composerOpen: true, + uiDesignSpecReference: null, + imageModel: 'gpt-image-2', + aspectRatio: '16:9', + imageSize: '1K', + }, + { + isUiDesignSpecMenuOpen: true, + setGenerateDialog: setGenerateDialog as unknown as Dispatch< SetStateAction >, - }); + }, + ); const panel = screen.getByRole('dialog', { name: '生成UI设计图' }); expect( @@ -304,9 +315,12 @@ describe('ImageCanvasGenerationComposerView', () => { expect( panel.querySelector('.image-canvas-editor__generation-composer-footer'), ).toBeTruthy(); - fireEvent.change(within(panel).getByRole('textbox', { name: 'UI设计要求' }), { - target: { value: '主界面和结算弹窗' }, - }); + fireEvent.change( + within(panel).getByRole('textbox', { name: 'UI设计要求' }), + { + target: { value: '主界面和结算弹窗' }, + }, + ); expect(setGenerateDialog).toHaveBeenCalled(); const menu = screen.getByRole('menu', { name: '参考图来源' }); expect( @@ -382,47 +396,56 @@ describe('ImageCanvasGenerationComposerView', () => { ['publication-cover-image', '游戏首图', '720 x 540'], ['publication-detail-gallery', '详情五图', '720 x 1280'], ['publication-promo-poster', '运营海报', '1280 x 720'], - ] as const)('让%s 宣发素材面板对齐生成角色面板结构', (workflowId, label, sizeLabel) => { - renderComposer({ - mode: 'publication', - prompt: '', - status: 'idle', - composerOpen: true, - publicationWorkflowId: workflowId, - publicationGameInfo: { - gameName: '', - gameCategories: '', - gameDescription: '', - }, - publicationReferences: [], - imageModel: 'gpt-image-2', - aspectRatio: '16:9', - imageSize: '1K', - }); + ] as const)( + '让%s 宣发素材面板对齐生成角色面板结构', + (workflowId, label, sizeLabel) => { + renderComposer({ + mode: 'publication', + prompt: '', + status: 'idle', + composerOpen: true, + publicationWorkflowId: workflowId, + publicationGameInfo: { + gameName: '', + gameCategories: '', + gameDescription: '', + }, + publicationReferences: [], + imageModel: 'gpt-image-2', + aspectRatio: '16:9', + imageSize: '1K', + }); - const panel = screen.getByRole('dialog', { name: `${label}生成卡片` }); - expect(panel.className).toContain('image-canvas-editor__character-composer'); - expect(panel.className).toContain('image-canvas-editor__publication-composer'); - expect( - panel.firstElementChild?.className.includes( - 'image-canvas-editor__reference-strip', - ), - ).toBe(true); - expect(within(panel).getByRole('button', { name: '添加参考图' })).toBeTruthy(); - expect( - within(panel).getByRole('textbox', { name: `${label}游戏名` }), - ).toBeTruthy(); - expect( - within(panel).getByRole('textbox', { name: `${label}一句话描述游戏` }), - ).toBeTruthy(); - expect(panel.textContent).toContain(sizeLabel); - expect( - panel.querySelector('.image-canvas-editor__generation-composer-footer'), - ).toBeTruthy(); - expect(within(panel).getByRole('button', { name: '生成' }).textContent).toBe( - '生成3泥点', - ); - }); + const panel = screen.getByRole('dialog', { name: `${label}生成卡片` }); + expect(panel.className).toContain( + 'image-canvas-editor__character-composer', + ); + expect(panel.className).toContain( + 'image-canvas-editor__publication-composer', + ); + expect( + panel.firstElementChild?.className.includes( + 'image-canvas-editor__reference-strip', + ), + ).toBe(true); + expect( + within(panel).getByRole('button', { name: '添加参考图' }), + ).toBeTruthy(); + expect( + within(panel).getByRole('textbox', { name: `${label}游戏名` }), + ).toBeTruthy(); + expect( + within(panel).getByRole('textbox', { name: `${label}一句话描述游戏` }), + ).toBeTruthy(); + expect(panel.textContent).toContain(sizeLabel); + expect( + panel.querySelector('.image-canvas-editor__generation-composer-footer'), + ).toBeTruthy(); + expect( + within(panel).getByRole('button', { name: '生成' }).textContent, + ).toBe('生成3泥点'); + }, + ); it('恢复宣发素材生成卡片的字段和参考图', () => { renderComposer({ @@ -450,19 +473,25 @@ describe('ImageCanvasGenerationComposerView', () => { const panel = screen.getByRole('dialog', { name: '运营海报生成卡片' }); expect( - (within(panel).getByRole('textbox', { - name: '运营海报游戏名', - }) as HTMLInputElement).value, + ( + within(panel).getByRole('textbox', { + name: '运营海报游戏名', + }) as HTMLInputElement + ).value, ).toBe('马戏团午夜惊魂'); expect( - (within(panel).getByRole('textbox', { - name: '运营海报游戏分类', - }) as HTMLInputElement).value, + ( + within(panel).getByRole('textbox', { + name: '运营海报游戏分类', + }) as HTMLInputElement + ).value, ).toBe('非对称对抗'); expect( - (within(panel).getByRole('textbox', { - name: '运营海报一句话描述游戏', - }) as HTMLTextAreaElement).value, + ( + within(panel).getByRole('textbox', { + name: '运营海报一句话描述游戏', + }) as HTMLTextAreaElement + ).value, ).toBe('找到钥匙,开门逃离马戏团'); expect(within(panel).getByLabelText('首图参考')).toBeTruthy(); expect( @@ -613,10 +642,14 @@ describe('ImageCanvasGenerationComposerView', () => { <> >} + setGenerateDialog={ + setDialog as Dispatch> + } /> {dialog.videoModel} - {dialog.videoDurationSeconds} + + {dialog.videoDurationSeconds} + {dialog.videoResolution} {dialog.placeholder @@ -648,9 +681,9 @@ describe('ImageCanvasGenerationComposerView', () => { expect( within(panel).getByRole('button', { name: '模型 Seedance 2.0 Fast' }), ).toBeTruthy(); - expect(within(panel).getByRole('button', { name: '生成视频' }).textContent).toBe( - '生成40泥点', - ); + expect( + within(panel).getByRole('button', { name: '生成视频' }).textContent, + ).toBe('生成40泥点'); fireEvent.click( within(panel).getByRole('button', { @@ -682,15 +715,18 @@ describe('ImageCanvasGenerationComposerView', () => { .querySelector('.image-canvas-editor__ratio-wireframe') ?.getAttribute('data-ratio'), ).toBe('21:9'); - fireEvent.change(within(paramsPanel).getByRole('slider', { name: '视频时长' }), { - target: { value: '5' }, - }); - fireEvent.click(within(paramsPanel).getByRole('button', { name: '清晰度 720p' })); + fireEvent.change( + within(paramsPanel).getByRole('slider', { name: '视频时长' }), + { + target: { value: '5' }, + }, + ); + fireEvent.click( + within(paramsPanel).getByRole('button', { name: '清晰度 720p' }), + ); fireEvent.click(within(paramsPanel).getByRole('button', { name: '静音' })); - expect( - screen.getByRole('menu', { name: '视频参数选项' }), - ).toBeTruthy(); + expect(screen.getByRole('menu', { name: '视频参数选项' })).toBeTruthy(); expect(screen.getByLabelText('当前视频时长').textContent).toBe('5'); expect(screen.getByLabelText('当前视频清晰度').textContent).toBe('720p'); expect(screen.getByLabelText('当前视频占位').textContent).toBe( @@ -708,10 +744,16 @@ describe('ImageCanvasGenerationComposerView', () => { '生成100泥点', ); - fireEvent.click(screen.getByRole('button', { name: '模型 Seedance 2.0 Fast' })); + fireEvent.click( + screen.getByRole('button', { name: '模型 Seedance 2.0 Fast' }), + ); const modelPanel = screen.getByRole('menu', { name: '视频模型选项' }); - expect(within(modelPanel).queryByRole('button', { name: /Veo/i })).toBeNull(); - fireEvent.click(within(modelPanel).getByRole('button', { name: 'Kling 3.0' })); + expect( + within(modelPanel).queryByRole('button', { name: /Veo/i }), + ).toBeNull(); + fireEvent.click( + within(modelPanel).getByRole('button', { name: 'Kling 3.0' }), + ); expect(screen.getByRole('menu', { name: '视频模型选项' })).toBeTruthy(); expect(screen.getByLabelText('当前视频模型').textContent).toBe('kling3.0'); @@ -780,9 +822,9 @@ describe('ImageCanvasGenerationComposerView', () => { expect(within(panel).queryByText('单次')).toBeNull(); expect(within(panel).queryByText('循环')).toBeNull(); expect(within(panel).queryByText(/BPM/u)).toBeNull(); - expect(within(panel).getByRole('button', { name: '生成游戏音效' }).textContent).toBe( - '生成5泥点', - ); + expect( + within(panel).getByRole('button', { name: '生成游戏音效' }).textContent, + ).toBe('生成5泥点'); fireEvent.click( within(panel).getByRole('button', { name: '音效时长 5秒' }), @@ -802,9 +844,7 @@ describe('ImageCanvasGenerationComposerView', () => { fireEvent.change(durationSlider, { target: { value: '8' } }); expect(screen.getByLabelText('当前音效时长').textContent).toBe('8'); - expect( - screen.getByRole('button', { name: '音效时长 8秒' }), - ).toBeTruthy(); + expect(screen.getByRole('button', { name: '音效时长 8秒' })).toBeTruthy(); fireEvent.submit(panel); expect(onSubmitImageGeneration).toHaveBeenCalledWith( @@ -845,9 +885,10 @@ describe('ImageCanvasGenerationComposerView', () => { ); expect(within(panel).getByText('Suno')).toBeTruthy(); expect(within(panel).queryByText('make_instrumental')).toBeNull(); - expect(within(panel).getByRole('button', { name: '生成游戏背景音乐' }).textContent).toBe( - '生成12泥点', - ); + expect( + within(panel).getByRole('button', { name: '生成游戏背景音乐' }) + .textContent, + ).toBe('生成12泥点'); }); it('生成规范参考图点击先弹来源菜单,不直接打开上传', () => { const onRequestUpload = vi.fn(); @@ -882,7 +923,11 @@ describe('ImageCanvasGenerationComposerView', () => { expect(onRequestUpload).not.toHaveBeenCalled(); expect(setIsGenerationReferenceMenuOpen).toHaveBeenCalled(); const menu = screen.getByRole('menu', { name: '参考图来源' }); - expect(within(menu).getByRole('menuitem', { name: '从画布中选择' })).toBeTruthy(); - expect(within(menu).getByRole('menuitem', { name: '上传图片' })).toBeTruthy(); + expect( + within(menu).getByRole('menuitem', { name: '从画布中选择' }), + ).toBeTruthy(); + expect( + within(menu).getByRole('menuitem', { name: '上传图片' }), + ).toBeTruthy(); }); }); diff --git a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx index 7b4532189..3567b1c73 100644 --- a/src/components/image-editor/ImageCanvasGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasGenerationComposerView.tsx @@ -108,9 +108,7 @@ type ImageCanvasGenerationComposerViewProps = { setIsUiDesignSpecMenuOpen: Dispatch>; setIsPickingGenerationReferenceFromCanvas: Dispatch>; setIsPickingQuickEditReferenceFromCanvas: Dispatch>; - setIsPickingPublicationReferenceFromCanvas: Dispatch< - SetStateAction - >; + setIsPickingPublicationReferenceFromCanvas: Dispatch>; setIsPickingCharacterSpecFromCanvas: Dispatch>; setIsPickingCharacterReferenceFromCanvas: Dispatch>; setIsPickingIconSpecFromCanvas: Dispatch>; @@ -141,13 +139,14 @@ function PerfectPixelOperationPanel({ onRetry?: (dialogId: string) => void; }) { const isPending = dialog.status === 'pending-confirmation'; + const isPreparedFailure = + dialog.status === 'failed' && Boolean(dialog.perfectPixelOperation); const isGenerating = dialog.status === 'generating'; const isInvalid = dialog.perfectPixelOperationInvalid === true; const message = isInvalid ? (dialog.errorMessage ?? '完美像素操作快照无效,禁止自动重试。') : isPending - ? (dialog.errorMessage ?? - '结果尚未确认,系统不会自动重复提交。') + ? (dialog.errorMessage ?? '结果尚未确认,系统不会自动重复提交。') : isGenerating ? '完美像素处理中' : dialog.status === 'failed' @@ -171,7 +170,10 @@ function PerfectPixelOperationPanel({ > {message} - {isPending && dialog.id && dialog.perfectPixelOperation ? ( + {!isInvalid && + (isPending || isPreparedFailure) && + dialog.id && + dialog.perfectPixelOperation ? ( {EDITOR_VIDEO_RESOLUTION_OPTIONS.map((option) => { const available = - currentModel.value !== VIDEO_MODEL_SEEDANCE_2_FAST || + currentModel.value !== + VIDEO_MODEL_SEEDANCE_2_FAST || option !== '1080p'; return ( updateVideoDialog({ - videoSound: - soundMode === 'off' ? 'on' : 'off', + videoSound: soundMode === 'off' ? 'on' : 'off', }) } > @@ -805,7 +808,9 @@ function ImageCanvasAudioGenerationComposerView({ }; const updateSoundDuration = (durationSeconds: number) => { - updateAudioDialog({ soundDurationSeconds: normalizeSoundDuration(durationSeconds) }); + updateAudioDialog({ + soundDurationSeconds: normalizeSoundDuration(durationSeconds), + }); }; return ( @@ -827,9 +832,7 @@ function ImageCanvasAudioGenerationComposerView({ value={dialog.prompt} disabled={isGenerating} placeholder={ - isSoundEffect - ? '你希望生成什么音效?' - : '你希望生成什么音乐?' + isSoundEffect ? '你希望生成什么音效?' : '你希望生成什么音乐?' } className="image-canvas-editor__generation-prompt" onChange={(event) => updateAudioDialog({ prompt: event.target.value })} @@ -1108,9 +1111,7 @@ export function ImageCanvasGenerationComposerView({ publicationReferenceButtonRef={publicationReferenceButtonRef} isPublicationReferenceMenuOpen={isPublicationReferenceMenuOpen} setGenerateDialog={setGenerateDialog} - setIsPublicationReferenceMenuOpen={ - setIsPublicationReferenceMenuOpen - } + setIsPublicationReferenceMenuOpen={setIsPublicationReferenceMenuOpen} setIsPickingPublicationReferenceFromCanvas={ setIsPickingPublicationReferenceFromCanvas } @@ -1282,9 +1283,7 @@ export function ImageCanvasGenerationComposerView({ /> ) : null} - {cropExpandPanel && - cropExpandSourceLayer && - cropExpandPanelStyle ? ( + {cropExpandPanel && cropExpandSourceLayer && cropExpandPanelStyle ? ( { id: dialogId, mode: 'quick-edit', prompt: '完美像素', - status: 'pending-confirmation', + status: 'failed', composerOpen: true, sourceLayerId: 'layer-source', perfectPixelOperation: { @@ -355,7 +355,7 @@ describe('useCanvasGenerationDialogs', () => { expect(result.current.activeCanvasGenerationDialog).toEqual( expect.objectContaining({ id: dialogId, - status: 'pending-confirmation', + status: 'failed', perfectPixelOperation: expect.objectContaining({ operationId: dialogId, }), diff --git a/src/components/image-editor/useCanvasGenerationDialogs.ts b/src/components/image-editor/useCanvasGenerationDialogs.ts index 61b032ea3..ae9215990 100644 --- a/src/components/image-editor/useCanvasGenerationDialogs.ts +++ b/src/components/image-editor/useCanvasGenerationDialogs.ts @@ -29,7 +29,9 @@ export function isUnsettledPerfectPixelOperationDialog( ) { return ( Boolean(dialog.perfectPixelOperation) && - (dialog.status === 'generating' || dialog.status === 'pending-confirmation') + (dialog.status === 'generating' || + dialog.status === 'pending-confirmation' || + dialog.status === 'failed') ); } diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index af32df1ad..61f355698 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -2603,6 +2603,7 @@ describe('useImageCanvasGenerationWorkflow', () => { expect(flushProjectPersistence).toHaveBeenCalledWith({ requireSuccess: true, requiredDialogId: flushedOperation!.operationId, + deadlineAt: expect.any(Number), }); expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled(); expect(flushedOperation).toEqual( @@ -2888,10 +2889,12 @@ describe('useImageCanvasGenerationWorkflow', () => { expect(flushProjectPersistence).toHaveBeenNthCalledWith(1, { requireSuccess: true, requiredDialogId: firstOperation.operationId, + deadlineAt: expect.any(Number), }); expect(flushProjectPersistence).toHaveBeenNthCalledWith(2, { requireSuccess: true, requiredDialogId: firstOperation.operationId, + deadlineAt: expect.any(Number), }); }); @@ -3114,25 +3117,40 @@ describe('useImageCanvasGenerationWorkflow', () => { firstDialogList, ); expect(screen.getByTestId('reference-pick-warning').textContent).toBe( - '该素材已有一条待确认的完美像素操作,请在原占位上继续核对或重试。', + '该素材已有一条未收口的完美像素操作,请在原占位上继续核对或重试。', ); }); - it('does not reconcile when the perfect-pixel request never left the client', async () => { - // 中文注释:占位创建、源图解析和 flush 都在 POST 之前。它们失败时请求根本没发出, - // 说「素材库可能已存在派生图」是反向谎报,也不该白打一次权威读取。 + it('keeps a confirmed source operation for exact retry when strict layout persistence fails before POST', async () => { const applyProjectSnapshot = vi.fn(); const flushProjectPersistence = vi .fn() - .mockRejectedValueOnce(new Error('画布保存失败')); + .mockRejectedValueOnce(new Error('画布保存失败')) + .mockResolvedValueOnce(undefined); + uploadEditorMediaAssetObjectFileMock.mockResolvedValueOnce({ + objectKey: 'generated-images/editor/confirmed-source.png', + assetObjectId: 'asset-object-confirmed-source', + legacyPublicPath: '/generated-images/editor/confirmed-source.png', + }); + snapImageToPerfectPixelsMock.mockImplementationOnce( + async (request: EditorPixelArtSnapInput) => + createMismatchedPerfectPixelResult(request), + ); + loadEditorProjectMock.mockImplementationOnce(async () => { + const request = snapImageToPerfectPixelsMock.mock.calls[0]?.[0] as + | EditorPixelArtSnapInput + | undefined; + return createConflictingPerfectPixelProject( + request!.canvasCompletion.dialogId, + ); + }); render( { await waitFor(() => { expect(screen.getByTestId('dialog').textContent).toContain('failed'); }); + const failedOperation = JSON.parse( + screen.getByTestId('perfect-pixel-operation').textContent!, + ) as { + operationId: string; + taskId: string; + request: EditorPixelArtSnapInput; + }; + expect(failedOperation.request.sourceImageSrc).toBe( + 'generated-images/editor/confirmed-source.png', + ); + expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledTimes(1); expect(snapImageToPerfectPixelsMock).not.toHaveBeenCalled(); expect(loadEditorProjectMock).not.toHaveBeenCalled(); expect(applyProjectSnapshot).not.toHaveBeenCalled(); - expect(screen.getByTestId('dialog-error').textContent).toBe('画布保存失败'); + expect(screen.getByTestId('dialog-error').textContent).toBe( + '完美像素请求尚未发出:画布保存失败 请在原占位重试同一操作。', + ); + + fireEvent.click(screen.getByRole('button', { name: '完美像素' })); + expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('reference-pick-warning').textContent).toBe( + '该素材已有一条未收口的完美像素操作,请在原占位上继续核对或重试。', + ); + + fireEvent.click(screen.getByRole('button', { name: '重试完美像素' })); + await waitFor(() => { + expect(snapImageToPerfectPixelsMock).toHaveBeenCalledTimes(1); + }); + const retriedRequest = snapImageToPerfectPixelsMock.mock.calls[0]?.[0] as + | EditorPixelArtSnapInput + | undefined; + expect(JSON.stringify(retriedRequest)).toBe( + JSON.stringify(failedOperation.request), + ); + expect(retriedRequest?.canvasCompletion.dialogId).toBe( + failedOperation.operationId, + ); + expect(failedOperation.taskId).toBe( + `pixel-art-snap-${failedOperation.operationId}`, + ); + expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledTimes(1); + expect(flushProjectPersistence).toHaveBeenCalledTimes(2); }); it('reconciles a responded failure that the server marked as post-persistence', async () => { diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index 5f0189700..13158615f 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -220,24 +220,17 @@ function createProjectLayerSnapshotFromLayer( }; } -// 中文注释:占位创建到 POST 发出之间的整段预算。 +// 中文注释:源图解析/直传与 strict operation journal 使用两段独立预算。 // -// 这段做的是源图解析/直传和布局保存,此前**完全无界**:直传是裸 fetch 没有 signal, -// 布局保存的 requestJson 没传 timeoutMs(同文件其余接口都写了),两者各自还有重试。 -// 而 `snapEditorImageToPixelArt` 的 120 秒只从最终 POST 开始计,所以「客户端 120 秒封顶」 -// 这条曾被写进到期清理注释的保证,对这段并不成立。 +// 源图可能需要 ticket → PUT → confirm,90 秒预算和同一 AbortSignal 覆盖完整上传;confirm +// 成功后已经有稳定 objectKey,operation 请求随即形成。此后 strict layout save 使用独立 +// 60 秒绝对 deadline,不能继续消耗源上传的余额,也不能在本地放弃等待后继续占住保存队列。 // -// 给整段一个预算而不是给每次请求加超时:上传最多三次尝试、布局保存最多四次,逐个加超时 -// 会让最坏总时长无界累加。源引用解析完成后会写入 durable operation;从那一刻起,剩余 -// 布局保存、最终 POST 与项目 GET 共用另一个 75 秒绝对对账窗口,POST 回包不会替它续期。 -// -// 前置超时会 abort 底层上传;此时最终 POST 尚未发出,不进入项目结果对账,也不能声称素材 -// 可能已落库。极端的 confirm 响应丢失仍可能留下不可见 asset object,但不会创建账号素材。 -// -// 取 90 秒而不是更紧:这段在源图是 inline/未登记时会真的直传一张画布图层,几 MB 的图在 -// 较差移动网络下就要几十秒,卡到 40 秒会把原本能成功的操作改判为失败。它只约束 POST 前置 -// 阶段,不参与、也不能延长后续 75 秒的权威项目对账窗口。 -export const PERFECT_PIXEL_PRE_POST_BUDGET_MS = 90_000; +// strict ACK 之前 POST 必须为零。此阶段失败保留 `failed + perfectPixelOperation`,只允许 +// 原样重试同一 request/source object;不能退回普通按钮创建新 identity,也不能执行结果 GET +// 或声称派生素材可能已落库。 +export const PERFECT_PIXEL_SOURCE_PREPARATION_BUDGET_MS = 90_000; +export const PERFECT_PIXEL_STRICT_LAYOUT_SAVE_BUDGET_MS = 60_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]; @@ -440,7 +433,7 @@ function resolveConfirmedPerfectPixelAsset( return result.asset; } -async function withPerfectPixelPrePostBudget( +async function withPerfectPixelSourcePreparationBudget( work: Promise, deadlineAt: number, timeoutMessage: string, @@ -937,6 +930,8 @@ type GenerationWorkflowOptions = { flushProjectPersistence?: (options?: { requireSuccess?: boolean; requiredDialogId?: string; + deadlineAt?: number; + signal?: AbortSignal; }) => Promise; refreshAssetLibrary?: () => Promise | void; onWalletBalanceMayHaveChanged?: () => void; @@ -2203,7 +2198,8 @@ export function useImageCanvasGenerationWorkflow({ dialog.perfectPixelOperation.request.sourceResourceId === sourceLayer.resourceId)) && (dialog.status === 'generating' || - dialog.status === 'pending-confirmation'), + dialog.status === 'pending-confirmation' || + dialog.status === 'failed'), ); if ( !isStaticRasterLayer || @@ -2215,7 +2211,7 @@ export function useImageCanvasGenerationWorkflow({ ) { if (existingOperation) { showGenerationWarning( - '该素材已有一条待确认的完美像素操作,请在原占位上继续核对或重试。', + '该素材已有一条未收口的完美像素操作,请在原占位上继续核对或重试。', ); return; } @@ -2281,21 +2277,22 @@ export function useImageCanvasGenerationWorkflow({ throw new Error('无法创建完美像素处理占位'); } - const prePostDeadlineAt = Date.now() + PERFECT_PIXEL_PRE_POST_BUDGET_MS; - const prePostAbort = new AbortController(); - const sourceImageSrc = await withPerfectPixelPrePostBudget( + const sourcePreparationDeadlineAt = + Date.now() + PERFECT_PIXEL_SOURCE_PREPARATION_BUDGET_MS; + const sourcePreparationAbort = new AbortController(); + const sourceImageSrc = await withPerfectPixelSourcePreparationBudget( resolveEditorGenerationMediaReference( sourceLayer, 'image', normalizedProjectId, { - signal: prePostAbort.signal, + signal: sourcePreparationAbort.signal, uploadId: perfectPixelDialogId, }, ), - prePostDeadlineAt, + sourcePreparationDeadlineAt, '完美像素源图准备超时。', - prePostAbort, + sourcePreparationAbort, ); if (!isPerfectPixelAuthorityCurrent(operationAuthority)) { return; @@ -2355,14 +2352,11 @@ export function useImageCanvasGenerationWorkflow({ requiresLiveSession: undefined, perfectPixelOperation, })); - await withPerfectPixelPrePostBudget( - flushProjectPersistence({ - requireSuccess: true, - requiredDialogId: perfectPixelDialogId, - }), - prePostDeadlineAt, - '完美像素提交前的画布保存超时。', - ); + await flushProjectPersistence({ + requireSuccess: true, + requiredDialogId: perfectPixelDialogId, + deadlineAt: Date.now() + PERFECT_PIXEL_STRICT_LAYOUT_SAVE_BUDGET_MS, + }); if (!isPerfectPixelAuthorityCurrent(operationAuthority)) { return; } @@ -2469,7 +2463,9 @@ export function useImageCanvasGenerationWorkflow({ // 谎报成失败,也不会开放一条会创建新 identity 的普通重提路径。 const errorMessage = reconciledMessage ? `${serverMessage ? `${serverMessage} ` : ''}${reconciledMessage}请稍后继续核对,或显式重试同一操作。` - : (serverMessage ?? '完美像素处理失败'); + : perfectPixelOperation && !perfectPixelPostAttempted + ? `完美像素请求尚未发出:${serverMessage ?? '提交前画布保存失败'} 请在原占位重试同一操作。` + : (serverMessage ?? '完美像素处理失败'); if ( perfectPixelDialogId && hasCanvasGenerationDialogById(perfectPixelDialogId) @@ -2541,7 +2537,8 @@ export function useImageCanvasGenerationWorkflow({ operation.request.projectId !== normalizedProjectId || operation.request.canvasCompletion.dialogId !== normalizedDialogId || operation.taskId !== `pixel-art-snap-${normalizedDialogId}` || - dialog.status !== 'pending-confirmation' || + (dialog.status !== 'pending-confirmation' && + dialog.status !== 'failed') || !flushProjectPersistence || !applyProjectSnapshot ) { @@ -2591,6 +2588,7 @@ export function useImageCanvasGenerationWorkflow({ await flushProjectPersistence({ requireSuccess: true, requiredDialogId: normalizedDialogId, + deadlineAt: Date.now() + PERFECT_PIXEL_STRICT_LAYOUT_SAVE_BUDGET_MS, }); if (!isPerfectPixelAuthorityCurrent(operationAuthority)) { return; @@ -2673,7 +2671,9 @@ export function useImageCanvasGenerationWorkflow({ updateCanvasGenerationDialogById(normalizedDialogId, (current) => ({ ...current, status: 'failed', - errorMessage, + errorMessage: postAttempted + ? errorMessage + : `完美像素请求尚未发出:${errorMessage} 请在原占位重试同一操作。`, })); } finally { releaseActiveInlineGenerationDialog(normalizedDialogId); diff --git a/src/components/image-editor/useImageCanvasLayerCommands.test.tsx b/src/components/image-editor/useImageCanvasLayerCommands.test.tsx index 3c1350357..32a334f3c 100644 --- a/src/components/image-editor/useImageCanvasLayerCommands.test.tsx +++ b/src/components/image-editor/useImageCanvasLayerCommands.test.tsx @@ -43,7 +43,7 @@ function createLayer(id: string, x: number, zIndex: number): CanvasLayer { function createDurablePerfectPixelDialog( id: string, - status: 'generating' | 'pending-confirmation', + status: 'generating' | 'pending-confirmation' | 'failed', ): CanvasGenerationDialogState { const submittedAt = 1_700_000_000_000; return { @@ -222,9 +222,7 @@ function LayerCommandsHarness({ {hoveredLayerId ?? '-'} {contextMenu ? 'open' : 'closed'} {historyCount} - - {imageContextClosedCount} - + {imageContextClosedCount} {activeTool} {commands.canvasClipboard @@ -612,7 +610,9 @@ describe('useImageCanvasLayerCommands', () => { render( , ); @@ -628,7 +628,7 @@ describe('useImageCanvasLayerCommands', () => { ); }); - it.each(['generating', 'pending-confirmation'] as const)( + it.each(['generating', 'pending-confirmation', 'failed'] as const)( 'keeps a selected durable perfect-pixel dialog as a complete no-op while %s', (status) => { const dialog = createDurablePerfectPixelDialog( diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx index 18034a36b..495d66c29 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx +++ b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx @@ -803,9 +803,11 @@ const STRICT_PLACEHOLDER = { function StrictProjectPersistenceHarness({ canAccessProtectedData = true, operationSourceImageSrc = 'resource-source', + strictSaveBudgetMs = 60_000, }: { canAccessProtectedData?: boolean; operationSourceImageSrc?: string; + strictSaveBudgetMs?: number; }) { const [layers, setLayers] = useState([]); const [viewport, setViewport] = useState({ @@ -950,6 +952,7 @@ function StrictProjectPersistenceHarness({ .flushProjectPersistence({ requireSuccess: true, requiredDialogId: STRICT_DIALOG_ID, + deadlineAt: Date.now() + strictSaveBudgetMs, }) .then( () => { @@ -1091,6 +1094,10 @@ describe('useImageCanvasProjectPersistence', () => { }), ]), }), + expect.objectContaining({ + signal: expect.any(AbortSignal), + deadlineAt: expect.any(Number), + }), ); expect(screen.getByTestId('strict-save-status').textContent).toBe( 'pending', @@ -1119,6 +1126,70 @@ describe('useImageCanvasProjectPersistence', () => { ); }); + it('releases a timed-out strict save locally and ignores its late resolution', async () => { + const lateLayoutAck = createDeferred<{ + projectId: string; + canvasId: string; + revision: number; + updatedAt: string; + }>(); + saveEditorProjectLayoutMock.mockReturnValueOnce(lateLayoutAck.promise); + + render(); + await waitFor(() => { + expect(screen.getByTestId('strict-project-id').textContent).toBe( + 'editor-project-default', + ); + }); + + act(() => { + screen + .getByRole('button', { + name: 'open and strictly flush perfect pixel', + }) + .click(); + }); + await waitFor(() => { + expect(screen.getByTestId('strict-save-status').textContent).toBe( + 'rejected', + ); + }); + expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('strict-downstream-post-count').textContent).toBe( + '0', + ); + + act(() => { + screen + .getByRole('button', { + name: 'open and strictly flush perfect pixel', + }) + .click(); + }); + await waitFor(() => { + expect(screen.getByTestId('strict-save-status').textContent).toBe( + 'resolved', + ); + }); + expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(2); + expect(screen.getByTestId('strict-downstream-post-count').textContent).toBe( + '1', + ); + + await act(async () => { + lateLayoutAck.resolve({ + projectId: 'editor-project-default', + canvasId: 'editor-project-default:canvas:default', + revision: 1, + updatedAt: '2026-08-04T00:00:00.000Z', + }); + await lateLayoutAck.promise; + }); + expect(screen.getByTestId('strict-downstream-post-count').textContent).toBe( + '1', + ); + }); + it.each([400, 403])( 'rejects strict layout persistence on HTTP %s without retrying or posting', async (status) => { @@ -1360,6 +1431,10 @@ describe('useImageCanvasProjectPersistence', () => { }), ]), }), + expect.objectContaining({ + signal: expect.any(AbortSignal), + deadlineAt: expect.any(Number), + }), ); expect(screen.getByTestId('strict-save-status').textContent).toBe( 'pending', diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.ts b/src/components/image-editor/useImageCanvasProjectPersistence.ts index 63d3ad57b..9bc2af750 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.ts +++ b/src/components/image-editor/useImageCanvasProjectPersistence.ts @@ -75,9 +75,13 @@ type PendingCreatedProjectResourceLayer = { type StrictProjectLayoutSaveCompletion = { requiredDialogId: string; requiredProjectId: string; + deadlineAt: number; + signal: AbortSignal; promise: Promise; resolve: () => void; reject: (error: unknown) => void; + abort: (reason: unknown) => void; + cleanup: () => void; settled: boolean; }; @@ -276,6 +280,10 @@ function isRetryableEditorProjectLayoutSaveError(error: unknown) { function createStrictProjectLayoutSaveCompletion( requiredDialogId: string, requiredProjectId: string, + options: { + deadlineAt: number; + signal?: AbortSignal; + }, ): StrictProjectLayoutSaveCompletion { let resolvePromise!: () => void; let rejectPromise!: (error: unknown) => void; @@ -283,14 +291,71 @@ function createStrictProjectLayoutSaveCompletion( resolvePromise = resolve; rejectPromise = reject; }); - return { + // strict completion 可能在进入队列前因 deadline、authority 或输入校验失败;外层 flush + // 会返回同一错误,但内部 promise 此时尚无 await,先登记 rejection handler 避免误报。 + void promise.catch(() => {}); + const controller = new AbortController(); + let timeoutId: ReturnType | undefined; + const completion: StrictProjectLayoutSaveCompletion = { requiredDialogId, requiredProjectId, + deadlineAt: options.deadlineAt, + signal: controller.signal, promise, resolve: resolvePromise, reject: rejectPromise, + abort: (reason) => { + if (!controller.signal.aborted) { + controller.abort( + reason instanceof Error + ? reason + : new Error('图片画布严格布局保存已取消'), + ); + } + }, + cleanup: () => {}, settled: false, }; + const onSourceAbort = () => { + completion.abort( + options.signal?.reason ?? new Error('图片画布严格布局保存已取消'), + ); + }; + const onCompletionAbort = () => { + rejectStrictProjectLayoutSave( + { strictCompletion: completion }, + controller.signal.reason, + ); + }; + completion.cleanup = () => { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + options.signal?.removeEventListener('abort', onSourceAbort); + controller.signal.removeEventListener('abort', onCompletionAbort); + }; + controller.signal.addEventListener('abort', onCompletionAbort, { + once: true, + }); + if (options.signal?.aborted) { + onSourceAbort(); + } else { + options.signal?.addEventListener('abort', onSourceAbort, { once: true }); + } + if (completion.settled) { + return completion; + } + const remainingMs = options.deadlineAt - Date.now(); + const timeoutError = new Error('完美像素提交前的画布保存超时。'); + timeoutError.name = 'TimeoutError'; + if (remainingMs <= 0) { + completion.abort(timeoutError); + } else { + timeoutId = setTimeout(() => { + completion.abort(timeoutError); + }, remainingMs); + } + return completion; } function resolveStrictProjectLayoutSave( @@ -301,6 +366,7 @@ function resolveStrictProjectLayoutSave( return; } completion.settled = true; + completion.cleanup(); completion.resolve(); } @@ -313,9 +379,62 @@ function rejectStrictProjectLayoutSave( return; } completion.settled = true; - completion.reject( - error instanceof Error ? error : new Error('图片画布严格布局保存失败'), - ); + const normalizedError = + error instanceof Error ? error : new Error('图片画布严格布局保存失败'); + completion.cleanup(); + completion.abort(normalizedError); + completion.reject(normalizedError); +} + +function strictProjectLayoutSaveCanContinue( + completion: StrictProjectLayoutSaveCompletion | undefined, +) { + if (!completion) { + return true; + } + if (completion.settled || completion.signal.aborted) { + return false; + } + if (completion.deadlineAt <= Date.now()) { + const error = new Error('完美像素提交前的画布保存超时。'); + error.name = 'TimeoutError'; + completion.abort(error); + return false; + } + return true; +} + +function awaitProjectLayoutSaveAttempt( + work: Promise, + signal: AbortSignal, +): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const cleanup = () => { + signal.removeEventListener('abort', onAbort); + }; + const settle = (callback: () => void) => { + if (settled) { + return; + } + settled = true; + cleanup(); + callback(); + }; + const onAbort = () => { + settle(() => + reject(signal.reason ?? new Error('图片画布严格布局保存已取消')), + ); + }; + signal.addEventListener('abort', onAbort, { once: true }); + work.then( + (value) => settle(() => resolve(value)), + (error: unknown) => settle(() => reject(error)), + ); + if (signal.aborted) { + onAbort(); + } + }); } function projectLayoutContainsRequiredDialogOperation( @@ -667,6 +786,10 @@ export function useImageCanvasProjectPersistence({ if (!pendingSave) { return; } + if (!strictProjectLayoutSaveCanContinue(pendingSave.strictCompletion)) { + pendingProjectLayoutSaveRef.current = null; + return; + } const expectedRevision = pendingSave.attemptExpectedRevision ?? projectRevisionRef.current; if ( @@ -702,10 +825,23 @@ export function useImageCanvasProjectPersistence({ hasAuthoritativeProjectSnapshotRef.current && authoritativeProjectIdRef.current === pendingSave.projectId; let runNextSave = false; - const savePromise = saveEditorProjectLayout(pendingSave.projectId, { + const saveInput = { ...pendingSave.input, expectedRevision, - }) + }; + const saveRequest = attemptedSave.strictCompletion + ? saveEditorProjectLayout(pendingSave.projectId, saveInput, { + signal: attemptedSave.strictCompletion.signal, + deadlineAt: attemptedSave.strictCompletion.deadlineAt, + }) + : saveEditorProjectLayout(pendingSave.projectId, saveInput); + const boundedSaveRequest = attemptedSave.strictCompletion + ? awaitProjectLayoutSaveAttempt( + saveRequest, + attemptedSave.strictCompletion.signal, + ) + : saveRequest; + const savePromise = boundedSaveRequest .then((result) => { const acknowledgedRevision = result && typeof result.revision === 'number' @@ -759,6 +895,12 @@ export function useImageCanvasProjectPersistence({ runNextSave = Boolean(pendingProjectLayoutSaveRef.current); }) .catch(async (error: unknown) => { + if ( + !strictProjectLayoutSaveCanContinue(attemptedSave.strictCompletion) + ) { + runNextSave = Boolean(pendingProjectLayoutSaveRef.current); + return; + } if (!saveStillBelongsToCurrentAuthority()) { rejectStrictProjectLayoutSave( attemptedSave, @@ -815,9 +957,12 @@ export function useImageCanvasProjectPersistence({ ); }; try { - const latestProject = await loadEditorProject( - pendingSave.projectId, - ); + const latestProject = attemptedSave.strictCompletion + ? await loadEditorProject(pendingSave.projectId, { + signal: attemptedSave.strictCompletion.signal, + deadlineAt: attemptedSave.strictCompletion.deadlineAt, + }) + : await loadEditorProject(pendingSave.projectId); const applied = applyLatestProject(latestProject); if ( attemptedSave.strictCompletion && @@ -846,8 +991,8 @@ export function useImageCanvasProjectPersistence({ } if (!isRetryableEditorProjectLayoutSaveError(error)) { rejectStrictProjectLayoutSave(attemptedSave, error); - if (pendingProjectLayoutSaveRef.current) { - runNextSave = true; + if (pendingProjectLayoutSaveRef.current) { + runNextSave = true; } return; } @@ -1069,6 +1214,9 @@ export function useImageCanvasProjectPersistence({ return false; } const previousPendingSave = pendingProjectLayoutSaveRef.current; + if (!strictProjectLayoutSaveCanContinue(options.strictCompletion)) { + return false; + } if ( previousPendingSave?.strictCompletion && previousPendingSave.strictCompletion !== options.strictCompletion @@ -1142,104 +1290,156 @@ export function useImageCanvasProjectPersistence({ options: { requireSuccess?: boolean; requiredDialogId?: string; + deadlineAt?: number; + signal?: AbortSignal; } = {}, ) => { - while (activeProjectLayoutSavePromiseRef.current) { - await activeProjectLayoutSavePromiseRef.current; - } - - const nextProjectId = projectIdRef.current; const requireSuccess = options.requireSuccess === true; const requiredDialogId = options.requiredDialogId?.trim() ?? ''; if (requireSuccess && !requiredDialogId) { throw new Error('严格布局保存必须指定 generation dialog'); } - if ( - !nextProjectId || - !canAccessProtectedDataRef.current || - !hasAuthoritativeProjectSnapshotRef.current || - authoritativeProjectIdRef.current !== nextProjectId || - projectRevisionRef.current === null - ) { - if (requireSuccess) { - throw new Error('图片画布缺少可用的项目 authority'); - } - return; - } - - const coverDisplayViewport = viewportToCanvasDisplayViewport( - refs.viewportRef.current, - ); - const generationDialogs = - requireSuccess && refs.getCanvasGenerationDialogsSnapshot - ? refs.getCanvasGenerationDialogsSnapshot() - : refs.canvasGenerationDialogsRef.current; - const layoutInput = { - viewport: coverDisplayViewport, - layers: serializeCanvasLayout({ - layers: refs.layersRef.current, - canvasGenerationDialogs: generationDialogs, - canvasBackgroundColor: refs.canvasBackgroundColorRef.current, - }), - }; if ( requireSuccess && - !projectLayoutContainsRequiredDialogOperation( - layoutInput.layers, - requiredDialogId, - nextProjectId, - ) + (typeof options.deadlineAt !== 'number' || + !Number.isFinite(options.deadlineAt)) ) { - throw new Error(`严格布局保存未找到完美像素操作 ${requiredDialogId}`); + throw new Error('严格布局保存必须指定绝对 deadline'); } - const strictCompletion = requireSuccess - ? createStrictProjectLayoutSaveCompletion( + const initialProjectId = projectIdRef.current; + if ( + requireSuccess && + (!initialProjectId || + !canAccessProtectedDataRef.current || + !hasAuthoritativeProjectSnapshotRef.current || + authoritativeProjectIdRef.current !== initialProjectId || + projectRevisionRef.current === null) + ) { + throw new Error('图片画布缺少可用的项目 authority'); + } + const strictCompletion = + requireSuccess && initialProjectId + ? createStrictProjectLayoutSaveCompletion( + requiredDialogId, + initialProjectId, + { + deadlineAt: options.deadlineAt!, + signal: options.signal, + }, + ) + : undefined; + + try { + while (activeProjectLayoutSavePromiseRef.current) { + const activeSave = activeProjectLayoutSavePromiseRef.current; + if (strictCompletion) { + await awaitProjectLayoutSaveAttempt( + activeSave, + strictCompletion.signal, + ); + } else { + await activeSave; + } + } + + const nextProjectId = projectIdRef.current; + if ( + !nextProjectId || + !canAccessProtectedDataRef.current || + !hasAuthoritativeProjectSnapshotRef.current || + authoritativeProjectIdRef.current !== nextProjectId || + projectRevisionRef.current === null || + (strictCompletion && + strictCompletion.requiredProjectId !== nextProjectId) + ) { + if (requireSuccess) { + throw new Error('图片画布缺少可用的项目 authority'); + } + return; + } + + const coverDisplayViewport = viewportToCanvasDisplayViewport( + refs.viewportRef.current, + ); + const generationDialogs = + requireSuccess && refs.getCanvasGenerationDialogsSnapshot + ? refs.getCanvasGenerationDialogsSnapshot() + : refs.canvasGenerationDialogsRef.current; + const layoutInput = { + viewport: coverDisplayViewport, + layers: serializeCanvasLayout({ + layers: refs.layersRef.current, + canvasGenerationDialogs: generationDialogs, + canvasBackgroundColor: refs.canvasBackgroundColorRef.current, + }), + }; + if ( + requireSuccess && + !projectLayoutContainsRequiredDialogOperation( + layoutInput.layers, requiredDialogId, nextProjectId, ) - : undefined; - const queued = queueProjectLayoutSave(nextProjectId, layoutInput, { - persistCover: false, - strictCompletion, - }); - if (requireSuccess) { - if (!queued || !strictCompletion) { - throw new Error('图片画布严格布局保存未能进入提交队列'); + ) { + throw new Error(`严格布局保存未找到完美像素操作 ${requiredDialogId}`); + } + if ( + strictCompletion && + !strictProjectLayoutSaveCanContinue(strictCompletion) + ) { + throw ( + strictCompletion.signal.reason ?? + new Error('图片画布严格布局保存已取消') + ); + } + const queued = queueProjectLayoutSave(nextProjectId, layoutInput, { + persistCover: false, + strictCompletion, + }); + if (requireSuccess) { + if (!queued || !strictCompletion) { + throw new Error('图片画布严格布局保存未能进入提交队列'); + } + await strictCompletion.promise; + return; } - await strictCompletion.promise; - return; - } - const coverSave = persistProjectCoverSnapshot( - nextProjectId, - coverDisplayViewport, - refs.layersRef.current, - ); - while ( - activeProjectLayoutSavePromiseRef.current || - pendingProjectLayoutSaveRef.current - ) { - const activeLayoutSave = activeProjectLayoutSavePromiseRef.current; - if (activeLayoutSave) { - await activeLayoutSave; - continue; + const coverSave = persistProjectCoverSnapshot( + nextProjectId, + coverDisplayViewport, + refs.layersRef.current, + ); + while ( + activeProjectLayoutSavePromiseRef.current || + pendingProjectLayoutSaveRef.current + ) { + const activeLayoutSave = activeProjectLayoutSavePromiseRef.current; + if (activeLayoutSave) { + await activeLayoutSave; + continue; + } + if (saveTimerRef.current !== null) { + window.clearTimeout(saveTimerRef.current); + saveTimerRef.current = null; + } + runPendingProjectLayoutSave(); + if (!activeProjectLayoutSavePromiseRef.current) { + break; + } + } + await coverSave; + } catch (error) { + if (strictCompletion && !strictCompletion.settled) { + rejectStrictProjectLayoutSave({ strictCompletion }, error); + } + throw error; } - if (saveTimerRef.current !== null) { - window.clearTimeout(saveTimerRef.current); - saveTimerRef.current = null; - } - runPendingProjectLayoutSave(); - if (!activeProjectLayoutSavePromiseRef.current) { - break; - } - } - await coverSave; }, [ - persistProjectCoverSnapshot, - queueProjectLayoutSave, - refs, - runPendingProjectLayoutSave, + persistProjectCoverSnapshot, + queueProjectLayoutSave, + refs, + runPendingProjectLayoutSave, ], ); @@ -1586,8 +1786,8 @@ export function useImageCanvasProjectPersistence({ queueProjectLayoutSave( project.projectId, { - viewport: appliedViewport, - layers: appliedLayoutItems, + viewport: appliedViewport, + layers: appliedLayoutItems, }, { strictCompletion: replayStrictCompletion }, ); diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index c30557edc..45eb175e9 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -155,6 +155,7 @@ describe('editorProjectClient', () => { }); it('saves viewport and layer layout through the project API', async () => { + const startedAt = Date.now(); requestJsonMock.mockResolvedValueOnce({ projectId: 'editor-project-1', canvasId: 'editor-project-1:canvas:default', @@ -188,11 +189,15 @@ describe('editorProjectClient', () => { }), }), '保存图片画布工程失败', - // 中文注释:超时是契约的一部分,写死在断言里而不是放宽成 anything()。这个接口被 - // flushProjectPersistence 同步等待在生成提交路径上,没有上界会把在途占位拖过存活 - // 窗口;断言写死之后,谁删掉它测试就会红。 - { timeoutMs: 60_000 }, + { deadlineAt: expect.any(Number) }, ); + const requestOptions = requestJsonMock.mock.calls[0]?.[3] as + | { deadlineAt?: number } + | undefined; + expect(requestOptions?.deadlineAt).toBeGreaterThanOrEqual( + startedAt + 60_000, + ); + expect(requestOptions?.deadlineAt).toBeLessThanOrEqual(Date.now() + 60_000); }); it('lists editor projects from the project API', async () => { @@ -287,7 +292,9 @@ describe('editorProjectClient', () => { nextCursor: null, }); - const page = await listPublicEditorProjectResources({ cursor: ' cursor-1 ' }); + const page = await listPublicEditorProjectResources({ + cursor: ' cursor-1 ', + }); expect(page.resources).toEqual([]); expect(page.nextCursor).toBeNull(); @@ -818,8 +825,7 @@ describe('editorProjectClient', () => { taskId: 'vector-task-1', warning: { code: 'postprocess-failed-source-preserved', - reason: - '生成任务成功,后处理失败。', + reason: '生成任务成功,后处理失败。', }, }); @@ -830,8 +836,7 @@ describe('editorProjectClient', () => { expect(result.taskId).toBe('vector-task-1'); expect(result.warning).toEqual({ code: 'postprocess-failed-source-preserved', - reason: - '生成任务成功,后处理失败。', + reason: '生成任务成功,后处理失败。', }); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/images/generations', @@ -881,7 +886,9 @@ describe('editorProjectClient', () => { resolution: '720p', mode: 'std', sound: 'off', - referenceVideoSrcs: [`https://assets.example.test/${'a'.repeat(256 * 1024)}`], + referenceVideoSrcs: [ + `https://assets.example.test/${'a'.repeat(256 * 1024)}`, + ], }), ).rejects.toThrow('稳定引用字段总长度不能超过 256KB'); diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index d81484ca8..8f84e5b23 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -42,7 +42,9 @@ function assertStableEditorMediaReferences( values: readonly string[] | undefined, fieldLabel: string, ) { - values?.forEach((value) => assertStableEditorMediaReference(value, fieldLabel)); + values?.forEach((value) => + assertStableEditorMediaReference(value, fieldLabel), + ); } function assertStableEditorVideoReferences(input: EditorVideoGenerationInput) { @@ -593,6 +595,11 @@ export type EditorProjectLayoutSaveInput = { expectedRevision: number; }; +export type EditorProjectLayoutSaveOptions = { + signal?: AbortSignal; + deadlineAt?: number; +}; + export type EditorProjectLayoutSaveResult = { projectId: string; canvasId: string; @@ -830,19 +837,26 @@ export async function deleteEditorProject(projectId: string) { export async function saveEditorProjectLayout( projectId: string, input: EditorProjectLayoutSaveInput, + options: EditorProjectLayoutSaveOptions = {}, ) { + const deadlineAt = + typeof options.deadlineAt === 'number' && + Number.isFinite(options.deadlineAt) + ? options.deadlineAt + : Date.now() + 60_000; return requestJson( `${EDITOR_PROJECT_API_BASE}/${encodeURIComponent(projectId)}`, - jsonRequest('PATCH', { - viewport: input.viewport, - layers: input.layers, - expectedRevision: input.expectedRevision, - }), + { + ...jsonRequest('PATCH', { + viewport: input.viewport, + layers: input.layers, + expectedRevision: input.expectedRevision, + }), + ...(options.signal ? { signal: options.signal } : {}), + }, '保存图片画布工程失败', - // 中文注释:本文件其余接口都显式写了超时,唯独这里没有——而 composeAbortSignal 在 - // timeoutMs 缺失时不设任何默认值,于是布局保存可以无限期挂住。它被 flushProjectPersistence - // 同步等待在生成提交路径上,挂住会连带把占位拖过存活窗口。 - { timeoutMs: 60_000 }, + // 布局保存会被生成提交链同步等待,必须连鉴权恢复和响应体读取一起有界。 + { deadlineAt }, ); } @@ -1006,10 +1020,7 @@ export async function generateEditorIconSpritesheet( input: EditorIconSpritesheetGenerationInput, ) { assertStableEditorMediaReference(input.referenceImageSrc, '图标素材规范'); - assertStableEditorMediaReferences( - input.referenceImageSrcs, - '图标素材参考图', - ); + assertStableEditorMediaReferences(input.referenceImageSrcs, '图标素材参考图'); return requestJson( EDITOR_ICON_SPRITESHEET_GENERATION_API, jsonRequest('POST', {