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

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

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

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

同步更新图片画布技术方案与项目决策记录。
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
@@ -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', () => {
@@ -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 → 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<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 },
);