完成完美像素前端未知结果收口

改为 object-only 源图上传并贯穿取消信号
以项目 GET 正向证据判定画布成功与 asset-only
持久化并原样重放 operation 请求,阻止未收口身份被删除
统一 75 秒绝对窗口并兼容旧 240 秒 journal
补齐时钟偏差、删除保护与 byte-for-byte 回归测试
同步更新图片画布契约与共享决策记录
This commit is contained in:
2026-08-04 03:59:59 +00:00
parent 7f13af6135
commit f99abce109
16 changed files with 1219 additions and 425 deletions
@@ -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-onlyresource 已出现但 dialog 仍 generating 继续等待,无 dialog 且无匹配 resource 也继续等待。重复匹配 resource 或已收口 dialog 与 resource / layer 不一致失败关闭为 conflict,不猜测成功。
- 时间边界:`submittedAt / reconcileUntil` 从稳定请求快照写入时形成单个 75 秒整链绝对窗口;POST 正常回包或异常都不能替同一次 operation 续期,只有用户显式 exact replay 才开启新的 75 秒窗口。每轮先立即 GET,一次读取即使发现窗口已过期也必须执行;随后退避上限 5 秒。读取始终失败或窗口耗尽时保持 `pending-confirmation`,不声称素材已保存。滚动升级时兼容读取旧 240 秒 v1 journalhydrate 会先把可接受的未来 `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 {