完成完美像素前端未知结果收口
改为 object-only 源图上传并贯穿取消信号 以项目 GET 正向证据判定画布成功与 asset-only 持久化并原样重放 operation 请求,阻止未收口身份被删除 统一 75 秒绝对窗口并兼容旧 240 秒 journal 补齐时钟偏差、删除保护与 byte-for-byte 回归测试 同步更新图片画布契约与共享决策记录
This commit is contained in:
@@ -6193,5 +6193,15 @@
|
||||
- 未知结果语义:本地 procedure future 的 timeout/drop 不能撤销远端事务,所以首个 PUT 后继续设置 `resultPersistenceStarted=true`;该标记现在表示“OSS 或整笔数据库事务的结果未知”,不再表示数据库可能部分提交。事务失败后允许留下无引用 OSS object,本批不做破坏性删除或历史孤儿清理。
|
||||
- 明确延期:本批只交付后端原子性与可重放身份。前端仍需后续批次持久化 operation 请求快照、让素材刷新退出 verdict、轮询项目事实、引入 `pending-confirmation`、刷新后只恢复 GET,并让人工重试复用原 operation;在此之前不能宣称 unknown-result 已端到端闭环。
|
||||
- 2026-08-03 第二批边界:generation dialog 持久化版本化 `perfectPixelOperation`,绑定规范化 dialog/operation、固定 `pixel-art-snap-{operationId}` task、稳定来源解析后的完整 POST 请求以及 `submittedAt / reconcileUntil` 整链绝对窗口。只有布局 PATCH 已确认包含该快照才允许首次 POST;素材刷新退出 verdict。响应未知后按稳定 task resource 与 dialog/layer 的原子事务形状有界轮询项目 GET,未终态或读取到期统一保持 `pending-confirmation`,不标普通失败、不自动重放。显式人工重试必须 byte-for-byte 复用持久请求和同一 identity,当前 UI、来源、目录、类型或标题变化不得改变请求;无效快照失败关闭。首次提交或重试在途时 owner、project 或组件生命周期改变后,旧响应的素材、项目、提示和对账副作用全部忽略。
|
||||
- 2026-08-03 第三批边界:hydrate 后对有效 `generating` / `pending-confirmation` operation 只做 GET-only 恢复,禁止自动 POST、上传或重建请求;切换 owner/project、卸载或权威 revision 前进时取消旧观察。v1 快照跨度不得超过固定 240 秒,读取时面对未来时钟偏差也至多从当前再观察一个窗口。有效 durable operation 退出 legacy `requiresLiveSession` TTL,任何标签页都不得清理;无 operation 快照的历史 inline 孤儿继续按 TTL 兼容,且剥离时同步顶层与 `canvas.layers` 两份布局。轮询耗尽仍保留 operation 和待确认状态,只有显式重试进入第二批 exact replay。
|
||||
- 2026-08-03 第三批边界:hydrate 后对有效 `generating` / `pending-confirmation` operation 只做 GET-only 恢复,禁止自动 POST、上传或重建请求;切换 owner/project、卸载或权威 revision 前进时取消旧观察。新写入的 v1 快照固定使用 75 秒跨度;读取侧兼容第一批曾写入的 240 秒 v1 形状以保留 operation identity。跨设备时钟让 `submittedAt` 落在可接受的未来区间时,先把它规范化到当前时间,再把实际截止压到 `min(持久截止, 规范化 submittedAt + 75 秒, 当前时间 + 75 秒)`;这样既不借兼容延长观察,也不会写出 `reconcileUntil < submittedAt` 的二次 hydrate 无效形状。有效 durable operation 退出 legacy `requiresLiveSession` TTL,任何标签页都不得清理;无 operation journal 字段的历史 inline 孤儿继续按 TTL 兼容,且剥离时同步顶层与 `canvas.layers` 两份布局。轮询耗尽仍保留 operation 和待确认状态,只有显式重试进入第二批 exact replay。
|
||||
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-04 完美像素第二批:object-only 上传与 GET-only unknown 收口
|
||||
|
||||
- 上传边界:inline 源图不再调用会在 confirm 后继续换签的完整上传 helper,只执行 `ticket → OSS PUT → confirm → objectKey`。完美像素在创建占位后立即确定 dialog / operation ID,并把它作为稳定 upload ID;源 fetch、图片解析边界、ticket、PUT、confirm 共用前置预算的同一个 `AbortSignal`。完整 helper 的 signed URL 调用也防御性透传 signal。这样 confirm 成功后没有新的换签失败窗口,同一 operation 的内部重试也不会换对象路径。
|
||||
- verdict 边界:POST 成功不再直接采用响应体的 `project`,POST 中的 `asset` 也只有在项目 GET 已确认终态且 response task / resource 与 GET resource 一致时才允许本地 upsert。项目 GET 是唯一 verdict 来源;匹配 task resource + 已收口 dialog/layer 为画布成功,无 dialog + 匹配 task resource 为 asset-only,resource 已出现但 dialog 仍 generating 继续等待,无 dialog 且无匹配 resource 也继续等待。重复匹配 resource 或已收口 dialog 与 resource / layer 不一致失败关闭为 conflict,不猜测成功。
|
||||
- 时间边界:`submittedAt / reconcileUntil` 从稳定请求快照写入时形成单个 75 秒整链绝对窗口;POST 正常回包或异常都不能替同一次 operation 续期,只有用户显式 exact replay 才开启新的 75 秒窗口。每轮先立即 GET,一次读取即使发现窗口已过期也必须执行;随后退避上限 5 秒。读取始终失败或窗口耗尽时保持 `pending-confirmation`,不声称素材已保存。滚动升级时兼容读取旧 240 秒 v1 journal;hydrate 会先把可接受的未来 `submittedAt` 规范化到当前时间,再把截止收紧到规范化提交时间和当前时间各自允许的 75 秒上限,并在下一次布局持久化时写回仍可再次 hydrate 的收紧形状。
|
||||
- identity 与删除:unknown 保留原 dialog 上的完整 `perfectPixelOperation`,人工重试原样发送持久化 request;普通按 ID 删除和随源图层删除均保留未收口 durable operation。对话框删除入口会激活原占位并提示继续核对 / 原样重试;Delete 快捷键若只命中受保护 operation 则在写历史、清选择或执行副作用前完整 no-op,混合选择只统计并删除其它可删除目标。刷新恢复只做 GET,owner / project 切换或卸载会取消旧观察。完全没有 operation journal 字段的 legacy inline 占位仍沿用既有 TTL;字段存在但损坏时保留失败关闭标记,不能降级成可清理的旧占位。
|
||||
- 投影刷新:`refreshAssetLibrary` 只在项目终态后 best-effort 触发,并同时吞掉同步 throw 与异步 reject;永不 settle 的刷新 Promise 也不参与 await,因此不能阻塞项目应用、提示或 `finally` 解锁。
|
||||
- 验证:第二批定向覆盖 POST 成功后仍走 GET、unknown 的 pending → completed、no-dialog 正反证据、75 秒绝对截止与 5 秒退避、过期后至少一次 GET、stable upload ID、object-only 上传、整条 signal、刷新永挂 / 同步抛错 / 异步拒绝、删除保护、hydrate GET-only 与 byte-for-byte replay。Atomic 全局相对断言及其它第三批文档清理仍未纳入本批。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -75,7 +75,7 @@ function buildPerfectPixelOperation(
|
||||
},
|
||||
},
|
||||
submittedAt: 1_700_000_000_000,
|
||||
reconcileUntil: 1_700_000_240_000,
|
||||
reconcileUntil: 1_700_000_075_000,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -858,6 +858,59 @@ describe('ImageCanvasEditorModel', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves legacy 240-second operation identity while clamping its deadline on round-trip', () => {
|
||||
vi.useFakeTimers();
|
||||
const now = 1_700_000_010_000;
|
||||
vi.setSystemTime(now);
|
||||
try {
|
||||
const dialogId = 'dialog-perfect-pixel-legacy-window';
|
||||
const operation = buildPerfectPixelOperation(dialogId);
|
||||
const legacyOperation = {
|
||||
...operation,
|
||||
reconcileUntil: operation.submittedAt + 240_000,
|
||||
};
|
||||
const hydrated = hydrateCanvasGenerationDialog({
|
||||
id: dialogId,
|
||||
mode: 'quick-edit',
|
||||
prompt: '完美像素',
|
||||
status: 'pending-confirmation',
|
||||
composerOpen: false,
|
||||
perfectPixelOperation: legacyOperation,
|
||||
});
|
||||
const expectedOperation = {
|
||||
...legacyOperation,
|
||||
reconcileUntil:
|
||||
operation.submittedAt + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS,
|
||||
};
|
||||
|
||||
expect(hydrated).toMatchObject({
|
||||
id: dialogId,
|
||||
status: 'pending-confirmation',
|
||||
perfectPixelOperation: expectedOperation,
|
||||
});
|
||||
expect(hydrated).not.toHaveProperty('perfectPixelOperationInvalid');
|
||||
|
||||
const { generationDialogs } = splitCanvasLayoutItems(
|
||||
serializeCanvasLayout({
|
||||
layers: [],
|
||||
canvasGenerationDialogs: [hydrated as CanvasGenerationDialogState],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(generationDialogs).toHaveLength(1);
|
||||
expect(generationDialogs[0]).toMatchObject({
|
||||
id: dialogId,
|
||||
status: 'pending-confirmation',
|
||||
perfectPixelOperation: expectedOperation,
|
||||
});
|
||||
expect(generationDialogs[0]).not.toHaveProperty(
|
||||
'perfectPixelOperationInvalid',
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed instead of replaying an invalid perfect-pixel operation snapshot', () => {
|
||||
const dialogId = 'dialog-perfect-pixel-invalid';
|
||||
const operation = buildPerfectPixelOperation(dialogId);
|
||||
@@ -896,8 +949,7 @@ describe('ImageCanvasEditorModel', () => {
|
||||
},
|
||||
{
|
||||
...operation,
|
||||
reconcileUntil:
|
||||
operation.submittedAt + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS + 1,
|
||||
reconcileUntil: operation.submittedAt + 240_000 + 1,
|
||||
},
|
||||
{ ...operation, unknownFutureField: true },
|
||||
];
|
||||
@@ -921,14 +973,19 @@ describe('ImageCanvasEditorModel', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('caps a clock-skewed perfect-pixel recovery to one current observation window', () => {
|
||||
it('preserves a legacy clock-skewed operation while capping its current observation window', () => {
|
||||
vi.useFakeTimers();
|
||||
const now = 1_700_000_000_000;
|
||||
vi.setSystemTime(now);
|
||||
try {
|
||||
const dialogId = 'dialog-perfect-pixel-clock-skew';
|
||||
const operation = buildPerfectPixelOperation(dialogId);
|
||||
const submittedAt = now + 60_000;
|
||||
const submittedAt = now + 180_000;
|
||||
const expectedOperation = {
|
||||
...operation,
|
||||
submittedAt: now,
|
||||
reconcileUntil: now + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS,
|
||||
};
|
||||
const hydrated = hydrateCanvasGenerationDialog({
|
||||
id: dialogId,
|
||||
mode: 'quick-edit',
|
||||
@@ -937,14 +994,27 @@ describe('ImageCanvasEditorModel', () => {
|
||||
perfectPixelOperation: {
|
||||
...operation,
|
||||
submittedAt,
|
||||
reconcileUntil: submittedAt + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS,
|
||||
reconcileUntil: submittedAt + 240_000,
|
||||
},
|
||||
});
|
||||
|
||||
expect(hydrated?.perfectPixelOperation).toMatchObject({
|
||||
submittedAt,
|
||||
reconcileUntil: now + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS,
|
||||
});
|
||||
expect(hydrated?.perfectPixelOperation).toEqual(expectedOperation);
|
||||
expect(hydrated).not.toHaveProperty('perfectPixelOperationInvalid');
|
||||
|
||||
const { generationDialogs } = splitCanvasLayoutItems(
|
||||
serializeCanvasLayout({
|
||||
layers: [],
|
||||
canvasGenerationDialogs: [hydrated as CanvasGenerationDialogState],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(generationDialogs).toHaveLength(1);
|
||||
expect(generationDialogs[0]?.perfectPixelOperation).toEqual(
|
||||
expectedOperation,
|
||||
);
|
||||
expect(generationDialogs[0]).not.toHaveProperty(
|
||||
'perfectPixelOperationInvalid',
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
|
||||
@@ -335,9 +335,13 @@ export type CanvasLayoutItems = EditorProjectLayerSnapshot[];
|
||||
|
||||
const CANVAS_SETTINGS_LAYOUT_ITEM_ID = 'canvas-settings:default';
|
||||
const PERFECT_PIXEL_OPERATION_TASK_ID_PREFIX = 'pixel-art-snap-';
|
||||
// 中文注释:从稳定请求快照写入开始计算整条提交/对账窗口。绝对截止时间会持久化并在
|
||||
// hydrate 后继续沿用;读取侧还会把跨设备时钟偏差限制在“从当前最多再观察一个窗口”。
|
||||
export const PERFECT_PIXEL_RECONCILIATION_WINDOW_MS = 240_000;
|
||||
// 中文注释:从稳定请求快照写入开始,提交与项目事实对账共用 75 秒绝对窗口。截止时间随
|
||||
// durable operation 持久化并在 hydrate 后继续沿用;读取侧还会把跨设备时钟偏差限制在
|
||||
// “从当前最多再观察一个窗口”。POST 回包与素材刷新都不能替同一次 operation 续期。
|
||||
export const PERFECT_PIXEL_RECONCILIATION_WINDOW_MS = 75_000;
|
||||
// 中文注释:第一批曾把 v1 快照写成 240 秒。滚动部署与旧标签页仍可能持久化该形状,
|
||||
// 所以读取侧保留兼容上限;它只决定快照是否可信,不会延长当前 75 秒对账窗口。
|
||||
const LEGACY_PERFECT_PIXEL_RECONCILIATION_WINDOW_MS = 240_000;
|
||||
export const INVALID_PERFECT_PIXEL_OPERATION_ERROR_MESSAGE =
|
||||
'完美像素操作快照无效,禁止自动重试。';
|
||||
|
||||
@@ -506,11 +510,13 @@ export function hydratePerfectPixelOperation(
|
||||
!Number.isFinite(value.reconcileUntil) ||
|
||||
value.reconcileUntil < value.submittedAt ||
|
||||
value.reconcileUntil - value.submittedAt >
|
||||
PERFECT_PIXEL_RECONCILIATION_WINDOW_MS ||
|
||||
value.submittedAt > now + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS
|
||||
LEGACY_PERFECT_PIXEL_RECONCILIATION_WINDOW_MS ||
|
||||
value.submittedAt >
|
||||
now + LEGACY_PERFECT_PIXEL_RECONCILIATION_WINDOW_MS
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const normalizedSubmittedAt = Math.min(value.submittedAt, now);
|
||||
const request = value.request;
|
||||
if (
|
||||
!isSnapshotRecord(request) ||
|
||||
@@ -602,12 +608,13 @@ export function hydratePerfectPixelOperation(
|
||||
operationId: dialogId,
|
||||
taskId: `${PERFECT_PIXEL_OPERATION_TASK_ID_PREFIX}${dialogId}`,
|
||||
request: hydratedRequest,
|
||||
submittedAt: value.submittedAt,
|
||||
// 中文注释:跨设备时钟可能有小幅偏差,所以合法固定跨度的快照不因“当前时钟稍慢”
|
||||
// 直接失效;但恢复从当前时刻起最多再观察一个完整窗口,不能信任远未来绝对时间让 GET
|
||||
// 轮询持续数小时。
|
||||
// 中文注释:把未来时间规范到当前时刻,确保收紧后的快照再次序列化、hydrate 时仍合法;
|
||||
// 过去时间保持不变,不能借刷新给 operation 续期。兼容读入的旧 240 秒快照同样只保留
|
||||
// 当前 75 秒绝对窗口。
|
||||
submittedAt: normalizedSubmittedAt,
|
||||
reconcileUntil: Math.min(
|
||||
value.reconcileUntil,
|
||||
normalizedSubmittedAt + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS,
|
||||
now + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS,
|
||||
),
|
||||
};
|
||||
|
||||
@@ -76,7 +76,10 @@ import {
|
||||
getSelectedLayerIds,
|
||||
} from './ImageCanvasSelectionModel';
|
||||
import { ImageCanvasShortcutDialogView } from './ImageCanvasShortcutDialogView';
|
||||
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
|
||||
import {
|
||||
isUnsettledPerfectPixelOperationDialog,
|
||||
useCanvasGenerationDialogs,
|
||||
} from './useCanvasGenerationDialogs';
|
||||
import { useCanvasHistory } from './useCanvasHistory';
|
||||
import {
|
||||
useImageCanvasAssetCanvasBridge,
|
||||
@@ -1968,6 +1971,13 @@ export function ImageCanvasEditorView({
|
||||
|
||||
const requestRemoveCanvasGenerationDialog = useCallback(
|
||||
(dialog: CanvasGenerationDialogState) => {
|
||||
if (isUnsettledPerfectPixelOperationDialog(dialog)) {
|
||||
activateCanvasGenerationDialog(dialog);
|
||||
showGenerationWarning(
|
||||
'完美像素结果尚未确认,当前占位用于保留原操作身份;请继续核对或按原操作重试。',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (dialog.status === 'generating') {
|
||||
activateCanvasGenerationDialog(dialog);
|
||||
setPendingGenerationDeleteDialog(dialog);
|
||||
@@ -1975,7 +1985,11 @@ export function ImageCanvasEditorView({
|
||||
}
|
||||
removeCanvasGenerationDialog(dialog.id);
|
||||
},
|
||||
[activateCanvasGenerationDialog, removeCanvasGenerationDialog],
|
||||
[
|
||||
activateCanvasGenerationDialog,
|
||||
removeCanvasGenerationDialog,
|
||||
showGenerationWarning,
|
||||
],
|
||||
);
|
||||
const confirmRemoveGeneratingDialog = useCallback(() => {
|
||||
const dialog = pendingGenerationDeleteDialog;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act,renderHook } from '@testing-library/react';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { CanvasGenerationDialogState } from './ImageCanvasEditorTypes';
|
||||
@@ -305,4 +305,70 @@ describe('useCanvasGenerationDialogs', () => {
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves unsettled durable perfect-pixel operations across ordinary delete paths', () => {
|
||||
const { result } = renderHook(() => useCanvasGenerationDialogs());
|
||||
const dialogId = 'perfect-pixel-durable';
|
||||
const submittedAt = 1_700_000_000_000;
|
||||
|
||||
act(() => {
|
||||
result.current.restoreCanvasGenerationDialogs([
|
||||
{
|
||||
id: dialogId,
|
||||
mode: 'quick-edit',
|
||||
prompt: '完美像素',
|
||||
status: 'pending-confirmation',
|
||||
composerOpen: true,
|
||||
sourceLayerId: 'layer-source',
|
||||
perfectPixelOperation: {
|
||||
version: 1,
|
||||
kind: 'perfect-pixel',
|
||||
operationId: dialogId,
|
||||
taskId: `pixel-art-snap-${dialogId}`,
|
||||
request: {
|
||||
sourceImageSrc: 'generated-images/editor/source.png',
|
||||
projectId: 'project-1',
|
||||
canvasCompletion: {
|
||||
dialogId,
|
||||
title: '源图 · 完美像素',
|
||||
placeholder: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 320,
|
||||
height: 240,
|
||||
originalWidth: 320,
|
||||
originalHeight: 240,
|
||||
},
|
||||
},
|
||||
},
|
||||
submittedAt,
|
||||
reconcileUntil: submittedAt + 75_000,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.removeCanvasGenerationDialogById(dialogId);
|
||||
result.current.removeCanvasGenerationDialogsByLayerId('layer-source');
|
||||
});
|
||||
expect(result.current.activeCanvasGenerationDialog).toEqual(
|
||||
expect.objectContaining({
|
||||
id: dialogId,
|
||||
status: 'pending-confirmation',
|
||||
perfectPixelOperation: expect.objectContaining({
|
||||
operationId: dialogId,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.updateCanvasGenerationDialogById(dialogId, (dialog) => ({
|
||||
...dialog,
|
||||
status: 'idle',
|
||||
}));
|
||||
result.current.removeCanvasGenerationDialogById(dialogId);
|
||||
});
|
||||
expect(result.current.activeCanvasGenerationDialog).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,15 @@ export type CanvasGenerationDialogDraft = Omit<
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export function isUnsettledPerfectPixelOperationDialog(
|
||||
dialog: CanvasGenerationDialogState,
|
||||
) {
|
||||
return (
|
||||
Boolean(dialog.perfectPixelOperation) &&
|
||||
(dialog.status === 'generating' || dialog.status === 'pending-confirmation')
|
||||
);
|
||||
}
|
||||
|
||||
function withGenerationTimestamps<T extends GenerateDialogState | null>(
|
||||
nextDialog: T,
|
||||
previousDialog?: GenerateDialogState | null,
|
||||
@@ -218,7 +227,9 @@ export function useCanvasGenerationDialogs({
|
||||
|
||||
const removeCanvasGenerationDialogById = useCallback(
|
||||
(dialogId: string) => {
|
||||
updateCanvasGenerationDialogById(dialogId, () => null);
|
||||
updateCanvasGenerationDialogById(dialogId, (dialog) =>
|
||||
isUnsettledPerfectPixelOperationDialog(dialog) ? dialog : null,
|
||||
);
|
||||
},
|
||||
[updateCanvasGenerationDialogById],
|
||||
);
|
||||
@@ -299,8 +310,9 @@ export function useCanvasGenerationDialogs({
|
||||
const removeCanvasGenerationDialogsByLayerId = useCallback(
|
||||
(targetLayerId: string) => {
|
||||
const keepDialog = (dialog: CanvasGenerationDialogState) =>
|
||||
dialog.sourceLayerId !== targetLayerId &&
|
||||
dialog.generatedLayerId !== targetLayerId;
|
||||
isUnsettledPerfectPixelOperationDialog(dialog) ||
|
||||
(dialog.sourceLayerId !== targetLayerId &&
|
||||
dialog.generatedLayerId !== targetLayerId);
|
||||
const currentDialog = generateDialogRef.current;
|
||||
const nextActiveDialog =
|
||||
isCanvasGenerationDialog(currentDialog) && !keepDialog(currentDialog)
|
||||
|
||||
@@ -34,6 +34,7 @@ const resolveEditorImageReferenceDataUrlMock = vi.hoisted(() => vi.fn());
|
||||
const resolveEditorImageReferenceDataUrlForGenerationMock = vi.hoisted(() =>
|
||||
vi.fn(),
|
||||
);
|
||||
const uploadEditorMediaAssetObjectFileMock = vi.hoisted(() => vi.fn());
|
||||
const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn());
|
||||
const editEditorImageMock = vi.hoisted(() => vi.fn());
|
||||
const extractEditorUiDesignAssetsMock = vi.hoisted(() => vi.fn());
|
||||
@@ -72,6 +73,7 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
||||
});
|
||||
|
||||
vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({
|
||||
uploadEditorMediaAssetObjectFile: uploadEditorMediaAssetObjectFileMock,
|
||||
uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock,
|
||||
}));
|
||||
|
||||
@@ -500,6 +502,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
beforeEach(() => {
|
||||
resolveEditorImageReferenceDataUrlMock.mockReset();
|
||||
resolveEditorImageReferenceDataUrlForGenerationMock.mockReset();
|
||||
uploadEditorMediaAssetObjectFileMock.mockReset();
|
||||
uploadEditorMediaAssetFileMock.mockReset();
|
||||
editEditorImageMock.mockReset();
|
||||
extractEditorUiDesignAssetsMock.mockReset();
|
||||
@@ -518,8 +521,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
resolveEditorImageReferenceDataUrlForGenerationMock.mockImplementation(
|
||||
async (src: string) => src,
|
||||
);
|
||||
uploadEditorMediaAssetFileMock.mockResolvedValue({
|
||||
src: 'https://signed.example.test/generation-reference.png',
|
||||
uploadEditorMediaAssetObjectFileMock.mockResolvedValue({
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/generation-references/reference.png',
|
||||
assetObjectId: 'asset-object-generation-reference',
|
||||
@@ -550,16 +552,56 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
dateNowSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledTimes(2);
|
||||
expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledTimes(2);
|
||||
const [firstFile, , firstOptions] =
|
||||
uploadEditorMediaAssetFileMock.mock.calls[0] ?? [];
|
||||
uploadEditorMediaAssetObjectFileMock.mock.calls[0] ?? [];
|
||||
const [secondFile, , secondOptions] =
|
||||
uploadEditorMediaAssetFileMock.mock.calls[1] ?? [];
|
||||
uploadEditorMediaAssetObjectFileMock.mock.calls[1] ?? [];
|
||||
expect((firstFile as File).name).not.toBe((secondFile as File).name);
|
||||
expect(firstOptions.pathSegments).not.toEqual(secondOptions.pathSegments);
|
||||
});
|
||||
|
||||
it('reuses a caller supplied upload id for the same inline operation', async () => {
|
||||
const options = { uploadId: 'perfect-pixel-operation-1' };
|
||||
|
||||
await resolveEditorGenerationMediaReference(
|
||||
{ src: 'data:image/png;base64,YQ==' },
|
||||
'image',
|
||||
'project-1',
|
||||
options,
|
||||
);
|
||||
await resolveEditorGenerationMediaReference(
|
||||
{ src: 'data:image/png;base64,YQ==' },
|
||||
'image',
|
||||
'project-1',
|
||||
options,
|
||||
);
|
||||
|
||||
expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledTimes(2);
|
||||
for (const [
|
||||
file,
|
||||
mediaType,
|
||||
uploadOptions,
|
||||
] of uploadEditorMediaAssetObjectFileMock.mock.calls) {
|
||||
expect((file as File).name).toBe(
|
||||
'generation-reference-perfect-pixel-operation-1.png',
|
||||
);
|
||||
expect(mediaType).toBe('image');
|
||||
expect(uploadOptions).toEqual(
|
||||
expect.objectContaining({
|
||||
pathSegments: [
|
||||
'editor',
|
||||
'generation-references',
|
||||
'project-1',
|
||||
'perfect-pixel-operation-1',
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('uploads image references without an object reference before generation', async () => {
|
||||
const controller = new AbortController();
|
||||
resolveEditorImageReferenceDataUrlMock.mockResolvedValueOnce(
|
||||
'data:image/png;base64,ZXhhbXBsZQ==',
|
||||
);
|
||||
@@ -567,23 +609,124 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
{ src: '/creation-type-references/example.webp' },
|
||||
'image',
|
||||
'project-1',
|
||||
{
|
||||
signal: controller.signal,
|
||||
uploadId: 'perfect-pixel-operation-1',
|
||||
},
|
||||
);
|
||||
|
||||
expect(resolveEditorImageReferenceDataUrlMock).toHaveBeenCalledWith(
|
||||
'/creation-type-references/example.webp',
|
||||
controller.signal,
|
||||
);
|
||||
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith(
|
||||
expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledWith(
|
||||
expect.any(File),
|
||||
'image',
|
||||
expect.objectContaining({
|
||||
assetKind: 'editor_generation_reference_image',
|
||||
signal: controller.signal,
|
||||
}),
|
||||
);
|
||||
expect(uploadEditorMediaAssetFileMock).not.toHaveBeenCalled();
|
||||
expect(result).toBe(
|
||||
'generated-character-drafts/editor/generation-references/reference.png',
|
||||
);
|
||||
});
|
||||
|
||||
it('passes the abort signal through blob fetch and object registration', async () => {
|
||||
const controller = new AbortController();
|
||||
const blobMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(new Blob(['video'], { type: 'video/mp4' }));
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
blob: blobMock,
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
try {
|
||||
await resolveEditorGenerationMediaReference(
|
||||
{ src: 'blob:https://editor.example.test/reference-video' },
|
||||
'video',
|
||||
'project-1',
|
||||
{
|
||||
signal: controller.signal,
|
||||
uploadId: 'video-operation-1',
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'blob:https://editor.example.test/reference-video',
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(blobMock).toHaveBeenCalledTimes(1);
|
||||
expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledWith(
|
||||
expect.any(File),
|
||||
'video',
|
||||
expect.objectContaining({
|
||||
pathSegments: [
|
||||
'editor',
|
||||
'generation-references',
|
||||
'project-1',
|
||||
'video-operation-1',
|
||||
],
|
||||
signal: controller.signal,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not parse or upload an inline Data URL after cancellation', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort(new DOMException('已取消', 'AbortError'));
|
||||
|
||||
await expect(
|
||||
resolveEditorGenerationMediaReference(
|
||||
{ src: 'data:image/png;base64,YQ==' },
|
||||
'image',
|
||||
'project-1',
|
||||
{
|
||||
signal: controller.signal,
|
||||
uploadId: 'cancelled-operation',
|
||||
},
|
||||
),
|
||||
).rejects.toMatchObject({ name: 'AbortError' });
|
||||
|
||||
expect(uploadEditorMediaAssetObjectFileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops after image source parsing when cancellation wins the boundary', async () => {
|
||||
const controller = new AbortController();
|
||||
let finishImageParsing!: (value: string) => void;
|
||||
resolveEditorImageReferenceDataUrlMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
finishImageParsing = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const resolution = resolveEditorGenerationMediaReference(
|
||||
{ src: '/creation-type-references/slow.webp' },
|
||||
'image',
|
||||
'project-1',
|
||||
{
|
||||
signal: controller.signal,
|
||||
uploadId: 'cancelled-after-parse',
|
||||
},
|
||||
);
|
||||
controller.abort(new DOMException('已取消', 'AbortError'));
|
||||
finishImageParsing('data:image/png;base64,YQ==');
|
||||
|
||||
await expect(resolution).rejects.toMatchObject({ name: 'AbortError' });
|
||||
expect(resolveEditorImageReferenceDataUrlMock).toHaveBeenCalledWith(
|
||||
'/creation-type-references/slow.webp',
|
||||
controller.signal,
|
||||
);
|
||||
expect(uploadEditorMediaAssetObjectFileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('submits quick edits and updates the source layer directly', async () => {
|
||||
editEditorImageMock.mockResolvedValueOnce(
|
||||
createGenerated({
|
||||
@@ -738,9 +881,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
);
|
||||
render(
|
||||
<SubmissionWorkflowHarness
|
||||
initialLayers={[
|
||||
createLayer({ assetKind: 'icon-spritesheet' }),
|
||||
]}
|
||||
initialLayers={[createLayer({ assetKind: 'icon-spritesheet' })]}
|
||||
initialQuickEditPanel={{
|
||||
mode: 'quick-edit',
|
||||
sourceLayerId: 'layer-source',
|
||||
@@ -986,7 +1127,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith(
|
||||
expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledWith(
|
||||
expect.any(File),
|
||||
'image',
|
||||
expect.objectContaining({
|
||||
@@ -1002,7 +1143,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
);
|
||||
});
|
||||
expect(
|
||||
uploadEditorMediaAssetFileMock.mock.invocationCallOrder[0],
|
||||
uploadEditorMediaAssetObjectFileMock.mock.invocationCallOrder[0],
|
||||
).toBeLessThan(editEditorImageMock.mock.invocationCallOrder[0] ?? 0);
|
||||
expect(editEditorImageMock.mock.calls[0]?.[0]).not.toHaveProperty(
|
||||
'referenceImageSrcs',
|
||||
@@ -1126,8 +1267,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
prompt: '角色换成蓝色披风',
|
||||
warning: {
|
||||
code: 'postprocess-failed-source-preserved',
|
||||
reason:
|
||||
'生成任务成功,后处理失败。',
|
||||
reason: '生成任务成功,后处理失败。',
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -1327,7 +1467,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith(
|
||||
expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledWith(
|
||||
expect.any(File),
|
||||
'video',
|
||||
expect.objectContaining({
|
||||
@@ -1797,7 +1937,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
);
|
||||
});
|
||||
expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled();
|
||||
expect(uploadEditorMediaAssetFileMock).not.toHaveBeenCalled();
|
||||
expect(uploadEditorMediaAssetObjectFileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refreshes the wallet and shows the warning after a queued character generation completes', async () => {
|
||||
@@ -1821,8 +1961,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
prompt: '队列角色生成',
|
||||
}),
|
||||
queueState: createQueueState({
|
||||
warning:
|
||||
'生成任务成功,后处理失败。',
|
||||
warning: '生成任务成功,后处理失败。',
|
||||
}),
|
||||
});
|
||||
render(
|
||||
@@ -1958,8 +2097,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
await applyQueuedEditorGenerationProject(
|
||||
{
|
||||
queueState: createQueueState({
|
||||
warning:
|
||||
'生成任务成功,后处理失败。',
|
||||
warning: '生成任务成功,后处理失败。',
|
||||
}),
|
||||
},
|
||||
'editor-project-1',
|
||||
@@ -2234,7 +2372,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
);
|
||||
});
|
||||
expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled();
|
||||
expect(uploadEditorMediaAssetFileMock).not.toHaveBeenCalled();
|
||||
expect(uploadEditorMediaAssetObjectFileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('submits icon spec objects without requiring an icon spec reference', async () => {
|
||||
@@ -2616,8 +2754,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
taskId: 'task-ui-assets',
|
||||
warning: {
|
||||
code: 'postprocess-failed-source-preserved',
|
||||
reason:
|
||||
'生成任务成功,后处理失败。',
|
||||
reason: '生成任务成功,后处理失败。',
|
||||
},
|
||||
});
|
||||
render(
|
||||
@@ -2795,8 +2932,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
|
||||
height: 768,
|
||||
warning: {
|
||||
code: 'postprocess-failed-source-preserved',
|
||||
reason:
|
||||
'生成任务成功,后处理失败。',
|
||||
reason: '生成任务成功,后处理失败。',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -11,7 +11,7 @@ import { getExternalGenerationJobStatus } from '../../services/external-generati
|
||||
import { resolveEditorImageReferenceDataUrl } from '../../services/image-editor/editorImageReference';
|
||||
import {
|
||||
type EditorMediaAssetUploadType,
|
||||
uploadEditorMediaAssetFile,
|
||||
uploadEditorMediaAssetObjectFile,
|
||||
} from '../../services/image-editor/editorMediaAssetUploadClient';
|
||||
import type {
|
||||
EditorAssetSnapshot,
|
||||
@@ -114,8 +114,11 @@ type EditorGenerationMediaReference = {
|
||||
type EditorGenerationMediaReferenceOptions = {
|
||||
allowRegisteredIds?: boolean;
|
||||
requireImageObjectReference?: boolean;
|
||||
// 中文注释:由调用方的阶段预算驱动。仅在最外层停止 await 不会取消已经启动的上传,
|
||||
// 被放弃的那次会继续跑完并注册对象,用户重试再产生一份。
|
||||
// 中文注释:需要 unknown 重放的同步操作由调用方传入稳定 id;普通入口不传时仍为每次
|
||||
// 上传生成随机路径,避免并发参考图互相覆盖。
|
||||
uploadId?: string;
|
||||
// 中文注释:由调用方的阶段预算驱动,并贯穿源读取、Data URL 转换以及
|
||||
// ticket → PUT → confirm,不能只在最外层停止 await。
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
@@ -154,7 +157,16 @@ function resolveEditorGenerationMediaReferenceSource(
|
||||
);
|
||||
}
|
||||
|
||||
function dataUrlToEditorGenerationFile(dataUrl: string, fileName: string) {
|
||||
function throwIfEditorGenerationMediaUploadAborted(signal?: AbortSignal) {
|
||||
signal?.throwIfAborted();
|
||||
}
|
||||
|
||||
function dataUrlToEditorGenerationFile(
|
||||
dataUrl: string,
|
||||
fileName: string,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
throwIfEditorGenerationMediaUploadAborted(signal);
|
||||
const [header = '', payload = ''] = dataUrl.split(',');
|
||||
const mimeMatch = /^data:([^;]+)(;base64)?$/iu.exec(header);
|
||||
if (!mimeMatch) {
|
||||
@@ -162,10 +174,12 @@ function dataUrlToEditorGenerationFile(dataUrl: string, fileName: string) {
|
||||
}
|
||||
const type = mimeMatch[1] ?? 'image/png';
|
||||
const binary = mimeMatch[2] ? atob(payload) : decodeURIComponent(payload);
|
||||
throwIfEditorGenerationMediaUploadAborted(signal);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
throwIfEditorGenerationMediaUploadAborted(signal);
|
||||
return new File([bytes], fileName, { type });
|
||||
}
|
||||
|
||||
@@ -173,18 +187,22 @@ async function inlineMediaSourceToEditorGenerationFile(
|
||||
source: string,
|
||||
mediaType: EditorMediaAssetUploadType,
|
||||
uploadId: string,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
throwIfEditorGenerationMediaUploadAborted(signal);
|
||||
const fileName = `generation-reference-${uploadId}.${
|
||||
mediaType === 'video' ? 'mp4' : mediaType === 'audio' ? 'mp3' : 'png'
|
||||
}`;
|
||||
if (/^data:/iu.test(source)) {
|
||||
return dataUrlToEditorGenerationFile(source, fileName);
|
||||
return dataUrlToEditorGenerationFile(source, fileName, signal);
|
||||
}
|
||||
const response = await fetch(source);
|
||||
const response = await fetch(source, { signal });
|
||||
throwIfEditorGenerationMediaUploadAborted(signal);
|
||||
if (!response.ok) {
|
||||
throw new Error('读取本地生成参考素材失败');
|
||||
}
|
||||
const blob = await response.blob();
|
||||
throwIfEditorGenerationMediaUploadAborted(signal);
|
||||
return new File([blob], fileName, {
|
||||
type: blob.type || `${mediaType}/*`,
|
||||
});
|
||||
@@ -195,11 +213,18 @@ async function uploadEditorGenerationInlineMediaSource(
|
||||
mediaType: EditorMediaAssetUploadType,
|
||||
projectId?: string | null,
|
||||
signal?: AbortSignal,
|
||||
stableUploadId?: string | null,
|
||||
) {
|
||||
const normalizedProjectId = projectId?.trim() || 'unscoped';
|
||||
const uploadId = createEditorGenerationMediaUploadId();
|
||||
const uploaded = await uploadEditorMediaAssetFile(
|
||||
await inlineMediaSourceToEditorGenerationFile(source, mediaType, uploadId),
|
||||
const uploadId =
|
||||
stableUploadId?.trim() || createEditorGenerationMediaUploadId();
|
||||
const uploaded = await uploadEditorMediaAssetObjectFile(
|
||||
await inlineMediaSourceToEditorGenerationFile(
|
||||
source,
|
||||
mediaType,
|
||||
uploadId,
|
||||
signal,
|
||||
),
|
||||
mediaType,
|
||||
{
|
||||
assetKind: `editor_generation_reference_${mediaType}`,
|
||||
@@ -225,6 +250,7 @@ export async function resolveEditorGenerationMediaReference(
|
||||
projectId?: string | null,
|
||||
options: EditorGenerationMediaReferenceOptions = {},
|
||||
) {
|
||||
throwIfEditorGenerationMediaUploadAborted(options.signal);
|
||||
const resourceId = reference.resourceId?.trim();
|
||||
const hasRegisteredReference =
|
||||
options.allowRegisteredIds !== false &&
|
||||
@@ -245,14 +271,17 @@ export async function resolveEditorGenerationMediaReference(
|
||||
if (!inlineSource && !imageSourceRequiresUpload) {
|
||||
return source;
|
||||
}
|
||||
const uploadSource = imageSourceRequiresUpload && !inlineSource
|
||||
? await resolveEditorImageReferenceDataUrl(source)
|
||||
: source;
|
||||
const uploadSource =
|
||||
imageSourceRequiresUpload && !inlineSource
|
||||
? await resolveEditorImageReferenceDataUrl(source, options.signal)
|
||||
: source;
|
||||
throwIfEditorGenerationMediaUploadAborted(options.signal);
|
||||
return uploadEditorGenerationInlineMediaSource(
|
||||
uploadSource,
|
||||
mediaType,
|
||||
projectId,
|
||||
options.signal,
|
||||
options.uploadId,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type {
|
||||
CanvasContextMenuState,
|
||||
CanvasGenerationDialogState,
|
||||
CanvasHistoryAction,
|
||||
CanvasLayer,
|
||||
} from './ImageCanvasEditorTypes';
|
||||
import { readLayerImageBlob } from './ImageCanvasExportModel';
|
||||
@@ -40,6 +41,43 @@ function createLayer(id: string, x: number, zIndex: number): CanvasLayer {
|
||||
};
|
||||
}
|
||||
|
||||
function createDurablePerfectPixelDialog(
|
||||
id: string,
|
||||
status: 'generating' | 'pending-confirmation',
|
||||
): CanvasGenerationDialogState {
|
||||
const submittedAt = 1_700_000_000_000;
|
||||
return {
|
||||
id,
|
||||
mode: 'quick-edit',
|
||||
prompt: '完美像素',
|
||||
status,
|
||||
perfectPixelOperation: {
|
||||
version: 1,
|
||||
kind: 'perfect-pixel',
|
||||
operationId: id,
|
||||
taskId: `pixel-art-snap-${id}`,
|
||||
request: {
|
||||
sourceImageSrc: 'generated-images/editor/source.png',
|
||||
projectId: 'project-1',
|
||||
canvasCompletion: {
|
||||
dialogId: id,
|
||||
title: '源图 · 完美像素',
|
||||
placeholder: {
|
||||
x: 20,
|
||||
y: 20,
|
||||
width: 100,
|
||||
height: 80,
|
||||
originalWidth: 100,
|
||||
originalHeight: 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
submittedAt,
|
||||
reconcileUntil: submittedAt + 75_000,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const originalClipboard = navigator.clipboard;
|
||||
const originalClipboardItem = globalThis.ClipboardItem;
|
||||
const originalCreateImageBitmap = globalThis.createImageBitmap;
|
||||
@@ -71,7 +109,11 @@ function LayerCommandsHarness({
|
||||
exportLayerImage = vi.fn(),
|
||||
onDeleteLayerSideEffects = vi.fn(),
|
||||
onDeleteGenerationDialogSideEffects = vi.fn(),
|
||||
onCaptureCanvasHistory = vi.fn(),
|
||||
initialLayers,
|
||||
initialSelectedLayerId = 'first',
|
||||
initialSelectedLayerIds = ['first', 'second'],
|
||||
initialCanvasGenerationDialogs,
|
||||
}: {
|
||||
exportLayerImage?: (
|
||||
layer: CanvasLayer | null,
|
||||
@@ -79,7 +121,11 @@ function LayerCommandsHarness({
|
||||
) => void;
|
||||
onDeleteLayerSideEffects?: (layerId: string) => void;
|
||||
onDeleteGenerationDialogSideEffects?: (dialogId: string) => void;
|
||||
onCaptureCanvasHistory?: (action: CanvasHistoryAction) => void;
|
||||
initialLayers?: CanvasLayer[];
|
||||
initialSelectedLayerId?: string | null;
|
||||
initialSelectedLayerIds?: string[];
|
||||
initialCanvasGenerationDialogs?: CanvasGenerationDialogState[];
|
||||
}) {
|
||||
const [layers, setLayers] = useState<CanvasLayer[]>(
|
||||
initialLayers ?? [
|
||||
@@ -89,28 +135,29 @@ function LayerCommandsHarness({
|
||||
],
|
||||
);
|
||||
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(
|
||||
'first',
|
||||
initialSelectedLayerId,
|
||||
);
|
||||
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([
|
||||
'first',
|
||||
'second',
|
||||
]);
|
||||
const [canvasGenerationDialogs] = useState<CanvasGenerationDialogState[]>([
|
||||
{
|
||||
id: 'dialog-1',
|
||||
mode: 'generate',
|
||||
prompt: '生成',
|
||||
status: 'idle',
|
||||
placeholder: {
|
||||
x: 20,
|
||||
y: 20,
|
||||
width: 100,
|
||||
height: 80,
|
||||
originalWidth: 100,
|
||||
originalHeight: 80,
|
||||
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>(
|
||||
initialSelectedLayerIds,
|
||||
);
|
||||
const [canvasGenerationDialogs] = useState<CanvasGenerationDialogState[]>(
|
||||
initialCanvasGenerationDialogs ?? [
|
||||
{
|
||||
id: 'dialog-1',
|
||||
mode: 'generate',
|
||||
prompt: '生成',
|
||||
status: 'idle',
|
||||
placeholder: {
|
||||
x: 20,
|
||||
y: 20,
|
||||
width: 100,
|
||||
height: 80,
|
||||
originalWidth: 100,
|
||||
originalHeight: 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
],
|
||||
);
|
||||
const [metadataLayer, setMetadataLayer] = useState<CanvasLayer | null>(
|
||||
layers[0] ?? null,
|
||||
);
|
||||
@@ -148,8 +195,10 @@ function LayerCommandsHarness({
|
||||
setImageContextMenu: () =>
|
||||
setImageContextClosedCount((currentCount) => currentCount + 1),
|
||||
setActiveTool,
|
||||
captureCanvasHistory: () =>
|
||||
setHistoryCount((currentCount) => currentCount + 1),
|
||||
captureCanvasHistory: (action) => {
|
||||
setHistoryCount((currentCount) => currentCount + 1);
|
||||
onCaptureCanvasHistory(action);
|
||||
},
|
||||
selectSingleLayer,
|
||||
onDeleteLayerSideEffects,
|
||||
onDeleteGenerationDialogSideEffects,
|
||||
@@ -578,4 +627,78 @@ describe('useImageCanvasLayerCommands', () => {
|
||||
'dialog-1',
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['generating', 'pending-confirmation'] as const)(
|
||||
'keeps a selected durable perfect-pixel dialog as a complete no-op while %s',
|
||||
(status) => {
|
||||
const dialog = createDurablePerfectPixelDialog(
|
||||
`perfect-pixel-${status}`,
|
||||
status,
|
||||
);
|
||||
const selectionId = `generation-dialog:${dialog.id}`;
|
||||
const onCaptureCanvasHistory = vi.fn();
|
||||
const onDeleteLayerSideEffects = vi.fn();
|
||||
const onDeleteGenerationDialogSideEffects = vi.fn();
|
||||
render(
|
||||
<LayerCommandsHarness
|
||||
initialSelectedLayerId={selectionId}
|
||||
initialSelectedLayerIds={[selectionId]}
|
||||
initialCanvasGenerationDialogs={[dialog]}
|
||||
onCaptureCanvasHistory={onCaptureCanvasHistory}
|
||||
onDeleteLayerSideEffects={onDeleteLayerSideEffects}
|
||||
onDeleteGenerationDialogSideEffects={
|
||||
onDeleteGenerationDialogSideEffects
|
||||
}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除选中' }));
|
||||
|
||||
expect(screen.getByTestId('history').textContent).toBe('0');
|
||||
expect(screen.getByTestId('selection').textContent).toBe(
|
||||
`${selectionId}:${selectionId}`,
|
||||
);
|
||||
expect(screen.getByTestId('context').textContent).toBe('open');
|
||||
expect(screen.getByTestId('image-context-closed').textContent).toBe('0');
|
||||
expect(onCaptureCanvasHistory).not.toHaveBeenCalled();
|
||||
expect(onDeleteLayerSideEffects).not.toHaveBeenCalled();
|
||||
expect(onDeleteGenerationDialogSideEffects).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('deletes and counts only deletable targets in a mixed durable-dialog selection', () => {
|
||||
const dialog = createDurablePerfectPixelDialog(
|
||||
'perfect-pixel-pending',
|
||||
'pending-confirmation',
|
||||
);
|
||||
const dialogSelectionId = `generation-dialog:${dialog.id}`;
|
||||
const onCaptureCanvasHistory = vi.fn();
|
||||
const onDeleteLayerSideEffects = vi.fn();
|
||||
const onDeleteGenerationDialogSideEffects = vi.fn();
|
||||
render(
|
||||
<LayerCommandsHarness
|
||||
initialSelectedLayerId="first"
|
||||
initialSelectedLayerIds={['first', dialogSelectionId]}
|
||||
initialCanvasGenerationDialogs={[dialog]}
|
||||
onCaptureCanvasHistory={onCaptureCanvasHistory}
|
||||
onDeleteLayerSideEffects={onDeleteLayerSideEffects}
|
||||
onDeleteGenerationDialogSideEffects={
|
||||
onDeleteGenerationDialogSideEffects
|
||||
}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '删除选中' }));
|
||||
|
||||
expect(screen.getByTestId('layers').textContent).not.toContain('first');
|
||||
expect(screen.getByTestId('selection').textContent).toBe('third:third');
|
||||
expect(screen.getByTestId('history').textContent).toBe('1');
|
||||
expect(onCaptureCanvasHistory).toHaveBeenCalledWith({
|
||||
type: 'delete-image',
|
||||
count: 1,
|
||||
});
|
||||
expect(onDeleteLayerSideEffects).toHaveBeenCalledTimes(1);
|
||||
expect(onDeleteLayerSideEffects).toHaveBeenCalledWith('first');
|
||||
expect(onDeleteGenerationDialogSideEffects).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,9 @@ import {
|
||||
getCanvasGenerationDialogIdFromSelectionId,
|
||||
getSelectedLayerIds,
|
||||
} from './ImageCanvasSelectionModel';
|
||||
import {
|
||||
isUnsettledPerfectPixelOperationDialog,
|
||||
} from './useCanvasGenerationDialogs';
|
||||
import type { ImageSequenceExportMode } from './useImageCanvasAssetExportWorkflow';
|
||||
|
||||
type LayerCommandsOptions = {
|
||||
@@ -633,7 +636,11 @@ export function useImageCanvasLayerCommands({
|
||||
.map(getCanvasGenerationDialogIdFromSelectionId)
|
||||
.filter((dialogId): dialogId is string => Boolean(dialogId))
|
||||
.filter((dialogId) =>
|
||||
canvasGenerationDialogs.some((dialog) => dialog.id === dialogId),
|
||||
canvasGenerationDialogs.some(
|
||||
(dialog) =>
|
||||
dialog.id === dialogId &&
|
||||
!isUnsettledPerfectPixelOperationDialog(dialog),
|
||||
),
|
||||
);
|
||||
if (!targetLayerIds.length && !targetDialogIds.length) {
|
||||
return;
|
||||
|
||||
@@ -930,7 +930,7 @@ function StrictProjectPersistenceHarness({
|
||||
operationId: STRICT_DIALOG_ID,
|
||||
taskId: STRICT_TASK_ID,
|
||||
submittedAt: 1_771_400_000_000,
|
||||
reconcileUntil: 1_771_400_240_000,
|
||||
reconcileUntil: 1_771_400_075_000,
|
||||
request: {
|
||||
sourceImageSrc: operationSourceImageSrc,
|
||||
projectId: 'editor-project-default',
|
||||
@@ -1073,7 +1073,7 @@ describe('useImageCanvasProjectPersistence', () => {
|
||||
operationId: STRICT_DIALOG_ID,
|
||||
taskId: STRICT_TASK_ID,
|
||||
submittedAt: 1_771_400_000_000,
|
||||
reconcileUntil: 1_771_400_240_000,
|
||||
reconcileUntil: 1_771_400_075_000,
|
||||
request: {
|
||||
sourceImageSrc: 'resource-source',
|
||||
projectId: 'editor-project-default',
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('editorMediaAssetUploadClient', () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('uploads editor MP4 assets through direct OSS upload and confirms object metadata', async () => {
|
||||
it('uploads editor MP4 assets and forwards one abort signal through signed-url resolution', async () => {
|
||||
requestJsonMock
|
||||
.mockResolvedValueOnce({
|
||||
upload: {
|
||||
@@ -63,16 +63,19 @@ describe('editorMediaAssetUploadClient', () => {
|
||||
getSignedAssetReadUrlMock.mockResolvedValueOnce(
|
||||
'https://signed.example.com/demo.mp4',
|
||||
);
|
||||
const abortController = new AbortController();
|
||||
|
||||
const result = await uploadEditorMediaAssetFile(
|
||||
new File(['video'], 'demo.mp4', { type: 'video/mp4' }),
|
||||
'video',
|
||||
{ signal: abortController.signal },
|
||||
);
|
||||
|
||||
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/assets/direct-upload-tickets',
|
||||
expect.objectContaining({
|
||||
signal: abortController.signal,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: expect.any(String),
|
||||
@@ -99,6 +102,7 @@ describe('editorMediaAssetUploadClient', () => {
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'https://oss.example.com',
|
||||
expect.objectContaining({
|
||||
signal: abortController.signal,
|
||||
method: 'POST',
|
||||
body: expect.any(FormData),
|
||||
}),
|
||||
@@ -110,6 +114,7 @@ describe('editorMediaAssetUploadClient', () => {
|
||||
2,
|
||||
'/api/assets/objects/confirm',
|
||||
expect.objectContaining({
|
||||
signal: abortController.signal,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: expect.any(String),
|
||||
@@ -119,7 +124,8 @@ describe('editorMediaAssetUploadClient', () => {
|
||||
);
|
||||
expect(confirmBody).toMatchObject({
|
||||
bucket: 'bucket',
|
||||
objectKey: 'generated-character-drafts/editor/asset-library/video/demo.mp4',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/asset-library/video/demo.mp4',
|
||||
contentType: 'video/mp4',
|
||||
contentLength: 5,
|
||||
assetKind: 'editor_uploaded_video',
|
||||
@@ -132,7 +138,7 @@ describe('editorMediaAssetUploadClient', () => {
|
||||
'generated-character-drafts/editor/asset-library/video/demo.mp4',
|
||||
expireSeconds: 3600,
|
||||
},
|
||||
undefined,
|
||||
abortController.signal,
|
||||
{ bypassCache: true },
|
||||
);
|
||||
expect(result).toEqual({
|
||||
@@ -194,7 +200,8 @@ describe('editorMediaAssetUploadClient', () => {
|
||||
(requestJsonMock.mock.calls[1]?.[1] as RequestInit).body as string,
|
||||
);
|
||||
expect(confirmBody).toMatchObject({
|
||||
objectKey: 'generated-character-drafts/editor/asset-library/image/spec.png',
|
||||
objectKey:
|
||||
'generated-character-drafts/editor/asset-library/image/spec.png',
|
||||
contentType: 'image/png',
|
||||
contentLength: 5,
|
||||
assetKind: 'editor_uploaded_image',
|
||||
@@ -260,12 +267,7 @@ describe('editorMediaAssetUploadClient', () => {
|
||||
(requestJsonMock.mock.calls[0]?.[1] as RequestInit).body as string,
|
||||
);
|
||||
expect(ticketBody).toMatchObject({
|
||||
pathSegments: [
|
||||
'editor',
|
||||
'project-covers',
|
||||
'project-1',
|
||||
'1771400000000',
|
||||
],
|
||||
pathSegments: ['editor', 'project-covers', 'project-1', '1771400000000'],
|
||||
fileName: 'project-1-cover.png',
|
||||
metadata: {
|
||||
asset_kind: 'editor_project_cover_snapshot',
|
||||
|
||||
@@ -166,7 +166,7 @@ export async function uploadEditorMediaAssetFile(
|
||||
objectKey: uploaded.objectKey,
|
||||
expireSeconds: EDITOR_MEDIA_READ_EXPIRE_SECONDS,
|
||||
},
|
||||
undefined,
|
||||
options.signal,
|
||||
{ bypassCache: true },
|
||||
);
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user