修复完美像素源图确认后的原操作重试

拆分源准备与严格布局保存预算,并让布局请求覆盖完整生命周期。

严格保存失败时保留原操作身份,阻止重新上传和创建第二条操作。

补充原样重试、迟到保存和删除保护的定向测试。

同步更新图片画布技术方案与项目决策记录。
This commit is contained in:
2026-08-04 10:28:58 +00:00
parent 2c07b91c25
commit 526333f130
14 changed files with 712 additions and 306 deletions
@@ -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 秒默认 timeoutgeneration 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`
@@ -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 / WebPGIF、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、项目快照应用和执行锁释放。
@@ -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 清理;窗口只需
// 覆盖源准备与该次 journalPOST 和对账由 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', () => {
@@ -17,7 +17,6 @@ function mockStateSetter<T>() {
return vi.fn() as unknown as Dispatch<SetStateAction<T>>;
}
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<GenerateDialogState | null>
>,
});
},
);
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', () => {
<>
<ImageCanvasGenerationComposerView
{...createComposerProps(dialog)}
setGenerateDialog={setDialog as Dispatch<SetStateAction<GenerateDialogState | null>>}
setGenerateDialog={
setDialog as Dispatch<SetStateAction<GenerateDialogState | null>>
}
/>
<output aria-label="当前视频模型">{dialog.videoModel}</output>
<output aria-label="当前视频时长">{dialog.videoDurationSeconds}</output>
<output aria-label="当前视频时长">
{dialog.videoDurationSeconds}
</output>
<output aria-label="当前视频清晰度">{dialog.videoResolution}</output>
<output aria-label="当前视频占位">
{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();
});
});
@@ -108,9 +108,7 @@ type ImageCanvasGenerationComposerViewProps = {
setIsUiDesignSpecMenuOpen: Dispatch<SetStateAction<boolean>>;
setIsPickingGenerationReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
setIsPickingQuickEditReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
setIsPickingPublicationReferenceFromCanvas: Dispatch<
SetStateAction<boolean>
>;
setIsPickingPublicationReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
setIsPickingCharacterSpecFromCanvas: Dispatch<SetStateAction<boolean>>;
setIsPickingCharacterReferenceFromCanvas: Dispatch<SetStateAction<boolean>>;
setIsPickingIconSpecFromCanvas: Dispatch<SetStateAction<boolean>>;
@@ -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}
</PlatformStatusMessage>
{isPending && dialog.id && dialog.perfectPixelOperation ? (
{!isInvalid &&
(isPending || isPreparedFailure) &&
dialog.id &&
dialog.perfectPixelOperation ? (
<PlatformActionButton
type="button"
size="sm"
@@ -391,7 +393,8 @@ function ImageCanvasVideoGenerationComposerView({
updateVideoDialog({
videoModel: model,
videoResolution:
model === VIDEO_MODEL_SEEDANCE_2_FAST && dialog.videoResolution === '1080p'
model === VIDEO_MODEL_SEEDANCE_2_FAST &&
dialog.videoResolution === '1080p'
? '720p'
: dialog.videoResolution,
...(isSeedanceVideoModel(model) ? {} : { generationReferences: [] }),
@@ -541,7 +544,8 @@ function ImageCanvasVideoGenerationComposerView({
<div className="image-canvas-editor__option-popover-items">
{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 (
<VideoOptionChoice
@@ -575,8 +579,7 @@ function ImageCanvasVideoGenerationComposerView({
disabled={isGenerating}
onClick={() =>
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 ? (
<ImageCanvasCropExpandPanelView
panel={cropExpandPanel}
style={cropExpandPanelStyle}
@@ -317,7 +317,7 @@ describe('useCanvasGenerationDialogs', () => {
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,
}),
@@ -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')
);
}
@@ -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(
<GenerationWorkflowHarness
projectId="project-1"
initialLayers={[
createLayer({
objectKey: 'generated-images/editor/source.png',
src: '/generated-images/editor/source.png',
resourceId: 'local-resource-source',
}),
]}
applyProjectSnapshot={applyProjectSnapshot}
@@ -3145,10 +3163,48 @@ describe('useImageCanvasGenerationWorkflow', () => {
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 () => {
@@ -220,24 +220,17 @@ function createProjectLayerSnapshotFromLayer(
};
}
// 中文注释:占位创建到 POST 发出之间的整段预算。
// 中文注释:源图解析/直传与 strict operation journal 使用两段独立预算。
//
// 这段做的是源图解析/直传和布局保存,此前**完全无界**:直传是裸 fetch 没有 signal
// 布局保存的 requestJson 没传 timeoutMs(同文件其余接口都写了),两者各自还有重试。
// `snapEditorImageToPixelArt` 的 120 秒只从最终 POST 开始计,所以「客户端 120 秒封顶」
// 这条曾被写进到期清理注释的保证,对这段并不成立。
// 源图可能需要 ticket → PUT → confirm90 秒预算和同一 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<T>(
async function withPerfectPixelSourcePreparationBudget<T>(
work: Promise<T>,
deadlineAt: number,
timeoutMessage: string,
@@ -937,6 +930,8 @@ type GenerationWorkflowOptions = {
flushProjectPersistence?: (options?: {
requireSuccess?: boolean;
requiredDialogId?: string;
deadlineAt?: number;
signal?: AbortSignal;
}) => Promise<void>;
refreshAssetLibrary?: () => Promise<unknown> | 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);
@@ -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({
<span data-testid="hovered">{hoveredLayerId ?? '-'}</span>
<span data-testid="context">{contextMenu ? 'open' : 'closed'}</span>
<span data-testid="history">{historyCount}</span>
<span data-testid="image-context-closed">
{imageContextClosedCount}
</span>
<span data-testid="image-context-closed">{imageContextClosedCount}</span>
<span data-testid="tool">{activeTool}</span>
<span data-testid="clipboard">
{commands.canvasClipboard
@@ -612,7 +610,9 @@ describe('useImageCanvasLayerCommands', () => {
render(
<LayerCommandsHarness
onDeleteLayerSideEffects={onDeleteLayerSideEffects}
onDeleteGenerationDialogSideEffects={onDeleteGenerationDialogSideEffects}
onDeleteGenerationDialogSideEffects={
onDeleteGenerationDialogSideEffects
}
/>,
);
@@ -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(
@@ -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<CanvasLayer[]>([]);
const [viewport, setViewport] = useState<CanvasViewport>({
@@ -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(<StrictProjectPersistenceHarness strictSaveBudgetMs={20} />);
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',
@@ -75,9 +75,13 @@ type PendingCreatedProjectResourceLayer = {
type StrictProjectLayoutSaveCompletion = {
requiredDialogId: string;
requiredProjectId: string;
deadlineAt: number;
signal: AbortSignal;
promise: Promise<void>;
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<typeof setTimeout> | 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<T>(
work: Promise<T>,
signal: AbortSignal,
): Promise<T> {
return new Promise<T>((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 },
);
@@ -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');
@@ -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<EditorProjectLayoutSaveResponse>(
`${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<EditorIconSpritesheetGenerationResponse>(
EDITOR_ICON_SPRITESHEET_GENERATION_API,
jsonRequest('POST', {