修复完美像素源图确认后的原操作重试
拆分源准备与严格布局保存预算,并让布局请求覆盖完整生命周期。 严格保存失败时保留原操作身份,阻止重新上传和创建第二条操作。 补充原样重试、迟到保存和删除保护的定向测试。 同步更新图片画布技术方案与项目决策记录。
This commit is contained in:
@@ -6236,3 +6236,12 @@
|
||||
- 对账边界:窗口内每次 GET 的 deadline 为 `min(reconcileUntil, readStartedAt + 10 秒)`;operation 已过期但从未读取时仍执行一次即时 GET,该例外最多 10 秒。deadline 只把本次读取视为失败,不穿透成未处理 rejection;轮询随后返回 `pending-confirmation` 并让首次提交 / hydrate 恢复的既有清理链执行。
|
||||
- 验证范围:`apiClient` 定向覆盖缺 token 与 401 refresh 永久等待、响应体永久等待;`editorProjectClient` 钉住 deadline 透传且普通读取仍保留 60 秒默认 timeout;generation workflow 钉住窗口内和过期单次读取的 deadline。未扩大为全站请求超时迁移。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-04 完美像素 confirm 后 strict 保存失败保留原 operation
|
||||
|
||||
- 缺陷:源图 `ticket → PUT → confirm` 成功后,首次 strict layout flush 仍沿用从上传开始计算的 90 秒预算。预算耗尽会把占位标成普通 `failed` 并解锁;既有重试只接受 `pending-confirmation`,普通按钮遂创建新 dialog、重新上传并换 operation identity。旧 confirmed source object 无法再由用户路径引用。
|
||||
- 决策:源准备 90 秒与 operation journal strict save 60 秒拆开。confirm 后立即形成稳定 `perfectPixelOperation`,strict deadline 覆盖等待活动保存、PATCH、revision conflict reload 和 transport retry;到期会取消 strict request/waiter、阻止自动转移或继续 POST,并释放本地活动保存槽。浏览器 abort 不等于远端撤销,迟到 PATCH 仍可能提交,但其本地 Promise 不再触发 POST;后续重试依靠 revision CAS/reload 收口。
|
||||
- 状态与重试:POST 尚未发出时的 strict 失败保留 `failed + perfectPixelOperation`,不做结果 GET;原占位提供 exact retry,复用同一 request、source objectKey、dialog/operation/task identity,不重新执行 ticket、PUT 或 confirm。普通完美像素入口把该状态视为未收口,不能创建第二条 operation;普通删除和随源图删除同样保留 identity。
|
||||
- 预算不变式:源准备 90 秒加 strict journal 60 秒仍小于 legacy inline 占位 240 秒窗口;POST 只会在 operation journal ACK 后开始,因此不再计入该 legacy TTL。布局保存本身使用请求全生命周期绝对 deadline,覆盖鉴权等待、业务 fetch 与响应体读取。
|
||||
- 剩余边界:confirm 成功后浏览器立即崩溃、且 operation 首次 PATCH 尚未落库时,仍可能留下 object-only 记录。彻底消除需服务端 durable upload journal;当前修复不声称撤销已发送的 PATCH 或清理历史孤儿对象。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
- 选中已有静态栅格图层后的 `完美像素` 是独立的一键派生操作,不等同于生成请求上的 `style="pixelArt"`。它不打开参数面板,只处理当前活动图层,保留源图,并在源图右侧创建同尺寸 PNG 派生结果;音频、视频、图片序列和 `character-animation` 不显示该按钮。
|
||||
- 已有图片像素规整固定调用登录态同源 `POST /api/editor/images/pixel-art-snaps`,复用同一纯内存 Rust snapper、CPU 并发许可和输入尺寸上限。该入口免费、只走当前 HTTP 请求内的 inline 处理,不创建 `external_generation_job`,不刷新或自动打开任务侧栏,也不进入泥点扣费 / 退款链路。它另有一层端点级并发闸(最大 4、等待队列上限 2048),设在首次 IO 之前;队列满返回 `503` 并带 `Retry-After`,等待超预算返回 `504`。30 秒总预算从 handler 入口起算,覆盖归属校验的 SpacetimeDB 读取、OSS 下载、两层排队与规整,不是只算 CPU 部分。
|
||||
- 前端提交前先创建关闭 composer 的右侧生成占位,再解析或上传源图以取得稳定引用,随后把版本化 `perfectPixelOperation` 请求快照写入该占位并 flush 当前项目布局,最后才发送 POST。`canvasCompletion.dialogId` 同时作为 operation identity、稳定 task identity 的输入和本地源图上传 ID;同一 operation 的上传路径与后续 POST 请求都不得随机漂移。`sourceImageSrc` 优先由当前图层已有的 `objectKey / resourceId / sourceAssetId` 解析;尚未登记的浏览器本地图片只执行 `ticket → OSS PUT → confirm → objectKey`,不为这条持久化输入换取 signed URL。一个 `AbortSignal` 必须贯穿源文件 fetch / 图片解析边界、ticket、PUT、confirm,完整上传 helper 的可选换签也必须透传同一 signal。正式请求不得包含 `data:` / `blob:`、signed URL 或普通外链。后端在读取源图前必须把该字段解析为当前 owner 已登记的私有 OSS object key,并核对 project / resource / asset 归属。
|
||||
- 源准备与 operation journal 使用两段绝对预算:`ticket → PUT → confirm` 连同源解析共用 90 秒;confirm 成功并形成稳定 `perfectPixelOperation` 后,strict layout save 另有 60 秒,覆盖等待既有保存、PATCH、冲突 GET 和重试退避。strict waiter 到期必须协作取消当前请求并释放本地保存队列;远端已经收到的迟到 PATCH 无法撤销,但不得再触发后续完美像素 POST。strict revision ACK 前 POST 和结果对账 GET 均为零。此阶段失败持久化为 `failed + perfectPixelOperation`,保留同一 `sourceImageSrc / dialogId / taskId / request`,普通按钮不得创建第二个 operation;用户只能从原占位重试,重试请求必须与 journal 中的 POST JSON byte-for-byte 一致且不得重新上传。confirm 成功后浏览器在 operation 首次 PATCH 落库前立即崩溃仍可能留下 object-only 记录;完全消除该窗口需要服务端 durable upload journal,不属于当前前端修复。
|
||||
- 该已有图片入口使用 strict 语义:只接受静态 PNG / JPEG / WebP,GIF、APNG、动画 WebP、图片序列及其它非静态媒体必须在处理前拒绝。strict 与生成风格复用完全相同的 legacy profile、峰值估算、单轴步长补全、walker、采样和编码;仅当横纵两轴都未检测到步长、legacy 即将使用 `min(width,height)/64` 统一网格兜底时拒绝。任一轴已检测到步长时,两条路径行为和输出必须一致。源图读取、解码、尺寸校验、排队、像素规整或 PNG 编码任一步失败 / 超时 / 不适用时,请求失败,不保留原图副本冒充成功,不执行最终 OSS PUT,也不创建 project resource、账号素材或结果图层。成功时只对最终 PNG 执行一次 OSS PUT,并至多各创建一个 `editor_project_resource` 和一个 `editor_asset`,再按 `canvasCompletion` 写回一个派生图层;不得保存逻辑低分辨率图、诊断图或前后对比图。
|
||||
- strict 的本次结果事实零写入边界截至首个最终 PNG PUT:所有可预判的引用、归属、类型、静态编码、元数据、网格适用性和 CPU 处理错误必须在此前失败;前置 owner-scoped 项目 / 素材读取仍可能按既有语义懒建默认 canvas / folder,这些基础记录不属于本次完美像素结果。最终 PNG 的 OSS PUT / HEAD 位于数据库事务外;验证上传结果后,asset object、project resource、账号素材与可选 canvas completion 由单个受 runtime service identity 保护的 SpacetimeDB procedure 在一次事务中原子提交。operation 以 `owner + project + canvasCompletion.dialogId` 为作用域,task / object / resource / asset ID 稳定派生,object key 携带规范请求与输入 / 输出摘要形成的 fingerprint;同内容重放只返回原结果,输入漂移或部分既有事实失败关闭。HTTP timeout/drop 不能撤销已发往远端的 procedure,客户端仍须按稳定 `taskId / objectKey / resourceId` 对账,不能把未收到回包等同于未提交。
|
||||
- `POST /api/editor/images/pixel-art-snaps` 是有副作用的 unsafe POST。客户端不得为它配置 `EDITOR_REQUEST_RETRY_OPTIONS`,请求字节可能已发出后不因 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 自动重放;Bearer 中间件在 handler 前以 `401` 拒绝、刷新 token 后的既有认证恢复不属于业务副作用重放,保持通用行为。POST 回包中的 `project / resource / asset` 不是结果 verdict;首次成功回包、未知异常、人工 exact replay 和刷新恢复都只读取项目 GET。`perfectPixelOperation.submittedAt / reconcileUntil` 从稳定请求快照写入时建立统一 75 秒绝对窗口,POST 回包不能续期;读取必须立即执行一次,随后退避间隔不超过 5 秒,窗口已过期时仍执行一次即时 GET。每次项目读取使用 `requestJson.deadlineAt` 覆盖缺 token 补票、业务 fetch、401 refresh、重试退避与响应体读取;窗口内单次最多 10 秒且不得越过 `reconcileUntil`,过期后的唯一即时读取最多额外 10 秒。固定判据为:匹配 task 的唯一 resource 加已收口 dialog / 关联图层才是画布成功;dialog 不存在但存在匹配 task resource 才是 asset-only 成功;dialog 仍 generating、dialog 不存在且无匹配 resource、项目始终不可读或窗口耗尽均保持 unknown。素材库刷新只在项目终态后 fire-and-forget,同步抛错、异步拒绝或永久挂起都不得阻塞 verdict、项目快照应用和执行锁释放。
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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', {
|
||||
|
||||
Reference in New Issue
Block a user