合并:同步最新master
Project CI / Native shell tests (pull_request) Successful in 14m13s
Project CI / Frontend tests (pull_request) Failing after 2m22s
Project CI / Backend tests (pull_request) Successful in 4m6s
Project CI / Repository checks (pull_request) Failing after 43s

合并 origin/master 8f19964e3 到 BGM 优化分支

保留 BGM 记录、完美像素改动及双方前端测试

通过冲突相关前端定向测试(61项)
This commit is contained in:
2026-08-05 13:43:10 +00:00
141 changed files with 24075 additions and 1877 deletions
@@ -40,6 +40,7 @@ const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn());
const renameEditorProjectMock = vi.hoisted(() => vi.fn());
const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn());
const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn());
const uploadEditorMediaAssetObjectFileMock = vi.hoisted(() => vi.fn());
vi.mock('../../services/image-editor/editorProjectClient', async () => {
const actual = await vi.importActual<
@@ -74,7 +75,10 @@ vi.mock('./ImageCanvasUiAssetExtractionRasterModel', () => ({
vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({
uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock,
uploadEditorMediaAssetObjectFile: vi.fn(),
// 中文注释:inline 源图上传走的是 object-only 版本,不是带签名 URL 的那个。留成裸 vi.fn()
// 会返回 undefined,取 objectKey 抛的 TypeError 被 extractUiDesignAssets 的 catch 吞成
// window.alert,最终只表现为「提取接口一次都没调」,报错里看不到任何线索。
uploadEditorMediaAssetObjectFile: uploadEditorMediaAssetObjectFileMock,
}));
vi.mock('./ImageCanvasProjectCoverSnapshotRenderer', () => ({
@@ -113,6 +117,17 @@ describe('ImageCanvasEditorView generation integration', () => {
objectKey:
'generated-character-drafts/editor/generation-references/marked-ui-design.png',
assetObjectId: 'asset-object-marked-ui-design',
legacyPublicPath:
'/generated-character-drafts/editor/generation-references/marked-ui-design.png',
src: 'https://oss.example.com/marked-ui-design.png',
});
uploadEditorMediaAssetObjectFileMock.mockReset();
uploadEditorMediaAssetObjectFileMock.mockResolvedValue({
objectKey:
'generated-character-drafts/editor/generation-references/marked-ui-design.png',
assetObjectId: 'asset-object-marked-ui-design',
legacyPublicPath:
'/generated-character-drafts/editor/generation-references/marked-ui-design.png',
});
});
@@ -3387,7 +3402,11 @@ describe('ImageCanvasEditorView generation integration', () => {
'https://assets.test/generated-character-drafts/editor/frame1.png',
);
});
expect(screen.getByText('1/48')).toBeTruthy();
// 中文注释:分子不能钉死。序列帧图层默认自动播放(frames.length > 1 即 isPlaying),
// 48 帧 6 秒算出 125ms 的真实 setInterval,而这里用的是真实定时器下的 waitFor——
// 上一条 waitFor 在 CI 上多轮询一次就已经越过 125ms,计数器变成 2/48。要断言的是
// 「按序列帧播放器渲染、总帧数 48」(下一行排除视频元素),分母才是这条断言的意义。
expect(screen.getByText(/^\d+\/48$/u)).toBeTruthy();
expect(screen.queryByLabelText('画布视频:角色动作')).toBeNull();
expect(screen.getByText('动作')).toBeTruthy();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -216,6 +216,7 @@ function createStageProps(): ImageCanvasStageViewProps {
onOpenRedrawPanel: vi.fn(),
onOpenCropExpandPanel: vi.fn(),
onRemoveBackground: vi.fn(),
onPerfectPixel: vi.fn(),
onSplitIconSpritesheet: vi.fn(),
onExtractUiDesignAssets: vi.fn(),
onUiAssetExtractionToolChange: vi.fn(),
@@ -6,6 +6,7 @@ import type {
EditorCharacterAnimationRatio,
EditorCharacterAnimationResolution,
EditorImageGenerationStyle,
EditorPixelArtSnapInput,
EditorVideoAspectRatio,
EditorVideoModel,
EditorVideoResolution,
@@ -186,6 +187,16 @@ export type PublicationMaterialsWorkflowId =
| 'publication-detail-gallery'
| 'publication-promo-poster';
export type PerfectPixelOperationSnapshot = {
version: 1;
kind: 'perfect-pixel';
operationId: string;
taskId: string;
request: EditorPixelArtSnapInput;
submittedAt: number;
reconcileUntil: number;
};
export type GenerateDialogState = {
id?: string;
mode:
@@ -203,7 +214,7 @@ export type GenerateDialogState = {
| 'audio-background-music';
prompt: string;
assetLabel?: string;
status: 'idle' | 'generating' | 'failed';
status: 'idle' | 'generating' | 'pending-confirmation' | 'failed';
composerOpen?: boolean;
sourceLayerId?: string;
generatedLayerId?: string;
@@ -241,6 +252,17 @@ export type GenerateDialogState = {
aspectRatio?: string;
imageSize?: string;
errorMessage?: string;
// 中文注释:标记该占位的收口只能由创建它的页面会话完成——链路是同步 HTTP、服务端没有
// durable job,进程一死就再没有任何东西会把它推向终态。队列型占位(去除背景恒队列、
// 图片生成默认队列)不得置位:它们的 job 在服务端继续跑,worker 会替换占位,刷新后
// 必须原样恢复。
requiresLiveSession?: boolean;
// 中文注释:请求账本只存在于本机(见 perfectPixelOperationStore),布局里只留这个 id
// 标记「该占位是一次完美像素操作」。换设备打开时标记还在、账本读不到,占位收口为可删除
// 的失败态——这是明确设计,不得据此阻断用户删除或重做。
perfectPixelOperationId?: string;
perfectPixelOperation?: PerfectPixelOperationSnapshot;
perfectPixelOperationInvalid?: boolean;
generationStartedAt?: number;
generationFinishedAt?: number;
placeholder?: {
@@ -293,6 +315,7 @@ export type CanvasHistoryActionType =
| 'generate-image'
| 'expand-image'
| 'remove-background'
| 'perfect-pixel'
| 'split-atlas'
| 'replace-image'
| 'show-image'
@@ -78,7 +78,10 @@ import {
getSelectedLayerIds,
} from './ImageCanvasSelectionModel';
import { ImageCanvasShortcutDialogView } from './ImageCanvasShortcutDialogView';
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
import {
requiresGenerationDeleteConfirmation,
useCanvasGenerationDialogs,
} from './useCanvasGenerationDialogs';
import { useCanvasHistory } from './useCanvasHistory';
import {
useImageCanvasAssetCanvasBridge,
@@ -99,6 +102,7 @@ import {
DEFAULT_IMAGE_CANVAS_VIEWPORT,
useImageCanvasViewportControls,
} from './useImageCanvasViewportControls';
import { useInlineGenerationPlaceholderExpiry } from './useInlineGenerationPlaceholderExpiry';
const TASK_FOCUS_HORIZONTAL_INSET = 28;
const TASK_FOCUS_TOP_INSET = 82;
@@ -297,6 +301,13 @@ function createAssetActionLayer(asset: EditorAsset): CanvasLayer {
};
}
// 中文注释:服务端持久化顺序是 OSS PUT → asset object → project resource → editor asset
// → 画布收口,非事务。看到孤儿 generating 占位只能说明最后一步没做完,前面几步可能已经
// 成功。所以不能断言「什么都没发生」,只能指路让用户自己核对素材库。
// 加载期剥离与页面打开期的到期清理共用这一条,两条路径对用户完全一致。
const DEAD_INLINE_PLACEHOLDER_NOTICE =
'上次的完美像素处理未完成,画布占位已清理。请确认素材库是否已生成派生图。';
export function ImageCanvasEditorView({
onProjectAccessLost,
}: ImageCanvasEditorViewProps = {}) {
@@ -538,6 +549,9 @@ export function ImageCanvasEditorView({
currentUser: authUi?.user ?? null,
});
const refreshEditorWalletState = useCallback(() => {
if (!authUiRef.current?.canAccessProtectedData || !authUiRef.current.user) {
return;
}
refreshEditorWalletBalance();
loadRechargeCenter();
}, [loadRechargeCenter, refreshEditorWalletBalance]);
@@ -735,6 +749,7 @@ export function ImageCanvasEditorView({
inactiveGenerateDialogsRef,
activeCanvasGenerationDialog,
canvasGenerationDialogs,
getCanvasGenerationDialogsSnapshot,
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
removeCanvasGenerationDialogById,
@@ -1150,11 +1165,12 @@ export function ImageCanvasEditorView({
layersRef,
viewportRef,
canvasGenerationDialogsRef,
getCanvasGenerationDialogsSnapshot,
canvasBackgroundColorRef,
selectedLayerIdRef,
selectedLayerIdsRef,
}),
[],
[getCanvasGenerationDialogsSnapshot],
);
const projectPersistenceSetters = useMemo(
() => ({
@@ -1189,6 +1205,7 @@ export function ImageCanvasEditorView({
appendCanvasLayersWithResources,
applyProjectSnapshot,
flushProjectPersistence,
deadInlinePlaceholderDropCount,
} = useImageCanvasProjectPersistence({
refs: projectPersistenceRefs,
setters: projectPersistenceSetters,
@@ -1221,7 +1238,9 @@ export function ImageCanvasEditorView({
) => {
captureCanvasHistory(action);
applyProjectSnapshot(project);
if (action.type !== 'perfect-pixel') {
void refreshAssetLibrary();
}
},
[applyProjectSnapshot, captureCanvasHistory, refreshAssetLibrary],
);
@@ -1374,6 +1393,7 @@ export function ImageCanvasEditorView({
activeCanvasGenerationDialog,
canvasGenerationDialogs,
openCanvasGenerationDialog,
activateCanvasGenerationDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
getCanvasGenerationDialogById,
@@ -1396,12 +1416,24 @@ export function ImageCanvasEditorView({
assetFolderId: activeUploadFolderId,
upsertGeneratedAsset,
applyProjectSnapshot: applyGeneratedProjectSnapshot,
applyProjectSnapshotWithoutHistory: applyProjectSnapshot,
flushProjectPersistence,
refreshAssetLibrary,
onWalletBalanceMayHaveChanged: refreshEditorWalletState,
});
const handleEditorAgentConfirmSent = useCallback(() => {
generationSurface.refreshTaskList();
}, [generationSurface]);
const showGenerationWarning = generationSurface.showGenerationWarning;
useEffect(() => {
if (deadInlinePlaceholderDropCount === 0) {
return;
}
showGenerationWarning(DEAD_INLINE_PLACEHOLDER_NOTICE);
}, [deadInlinePlaceholderDropCount, showGenerationWarning]);
const handleInlinePlaceholdersExpired = useCallback(() => {
showGenerationWarning(DEAD_INLINE_PLACEHOLDER_NOTICE);
}, [showGenerationWarning]);
const handleExternalGenerationTasksCompleted = useCallback(
(tasks: ExternalGenerationTaskRecord[]) => {
if (!projectId || tasks.length === 0) {
@@ -1501,6 +1533,10 @@ export function ImageCanvasEditorView({
openRedrawPanel,
openCropExpandPanel,
removeSelectedLayerBackground,
snapSelectedLayerToPerfectPixels,
activeInlineGenerationDialogOwnership,
perfectPixelLayerIds,
pendingPerfectPixelLayerIds,
splitSelectedIconSpritesheet,
splittingIconSpritesheetLayerIds,
extractUiDesignAssets,
@@ -1666,6 +1702,37 @@ export function ImageCanvasEditorView({
},
[openLayerGenerationDialog],
);
const removeCanvasGenerationDialog = useCallback(
(dialogId: string) => {
captureCanvasHistory({ type: 'delete-generation-result', count: 1 });
removeCanvasGenerationDialogById(dialogId);
setSelectedLayerId(null);
setSelectedLayerIds([]);
setImageContextMenu(null);
setContextMenu(null);
setActiveTool('select');
},
[
captureCanvasHistory,
removeCanvasGenerationDialogById,
setActiveTool,
setContextMenu,
setImageContextMenu,
setSelectedLayerId,
setSelectedLayerIds,
],
);
const requestRemoveCanvasGenerationDialog = useCallback(
(dialog: CanvasGenerationDialogState) => {
if (requiresGenerationDeleteConfirmation(dialog)) {
activateCanvasGenerationDialog(dialog);
setPendingGenerationDeleteDialog(dialog);
return;
}
removeCanvasGenerationDialog(dialog.id);
},
[activateCanvasGenerationDialog, removeCanvasGenerationDialog],
);
const contextMenuLayer =
contextMenu?.kind === 'layer'
? (layers.find((layer) => layer.id === contextMenu.layerId) ?? null)
@@ -1711,6 +1778,7 @@ export function ImageCanvasEditorView({
selectSingleLayer,
onDeleteLayerSideEffects: clearDeletedLayerGenerationState,
onDeleteGenerationDialogSideEffects: removeCanvasGenerationDialogById,
onRequestDeleteGenerationDialog: requestRemoveCanvasGenerationDialog,
exportLayerImage,
onCanvasLayerCopyBlocked: showCanvasLayerCopyWarning,
});
@@ -1810,37 +1878,15 @@ export function ImageCanvasEditorView({
(layerId: string | null) => deleteLayerByIdRef.current(layerId),
[],
);
const removeCanvasGenerationDialog = useCallback(
(dialogId: string) => {
captureCanvasHistory({ type: 'delete-generation-result', count: 1 });
removeCanvasGenerationDialogById(dialogId);
setSelectedLayerId(null);
setSelectedLayerIds([]);
setImageContextMenu(null);
setContextMenu(null);
setActiveTool('select');
},
[
captureCanvasHistory,
removeCanvasGenerationDialogById,
setActiveTool,
setContextMenu,
setImageContextMenu,
setSelectedLayerId,
setSelectedLayerIds,
],
);
const requestRemoveCanvasGenerationDialog = useCallback(
(dialog: CanvasGenerationDialogState) => {
if (dialog.status === 'generating') {
activateCanvasGenerationDialog(dialog);
setPendingGenerationDeleteDialog(dialog);
return;
}
removeCanvasGenerationDialog(dialog.id);
},
[activateCanvasGenerationDialog, removeCanvasGenerationDialog],
);
// 中文注释:加载期的剥离只跑一次,当时未到期而被保留的孤儿占位需要这里补上到期清理,
// 否则它会一直转到用户下一次加载。两条路径共用同一条文案,用户感知一致。
useInlineGenerationPlaceholderExpiry({
canvasGenerationDialogs,
activeInlineGenerationDialogOwnership,
removeCanvasGenerationDialogById,
onPlaceholdersExpired: handleInlinePlaceholdersExpired,
});
const confirmRemoveGeneratingDialog = useCallback(() => {
const dialog = pendingGenerationDeleteDialog;
if (!dialog) {
@@ -2286,6 +2332,8 @@ export function ImageCanvasEditorView({
quickEditSelectionSourceLayer,
generationComposerStyle,
selectedToolbarStyle,
perfectPixelLayerIds,
pendingPerfectPixelLayerIds,
splittingIconSpritesheetLayerIds,
uploadDropTarget,
contextMenu,
@@ -2339,6 +2387,9 @@ export function ImageCanvasEditorView({
onOpenRedrawPanel: openRedrawPanel,
onOpenCropExpandPanel: openCropExpandPanel,
onRemoveBackground: removeSelectedLayerBackground,
onPerfectPixel: (layer: CanvasLayer) => {
void snapSelectedLayerToPerfectPixels(layer);
},
onSplitIconSpritesheet: (layer: CanvasLayer) => {
void flushProjectPersistence().then(() =>
splitSelectedIconSpritesheet(layer),
@@ -17,7 +17,6 @@ function mockStateSetter<T>() {
return vi.fn() as unknown as Dispatch<SetStateAction<T>>;
}
function createComposerProps(
generateDialog: GenerateDialogState,
overrides: Partial<
@@ -149,6 +148,118 @@ function createBackgroundMusicPromptAssistStub(): NonNullable<
}
describe('ImageCanvasGenerationComposerView', () => {
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();
renderComposer(
{
id: dialogId,
mode: 'generate',
prompt: '不应重新提交的普通提示词',
status,
composerOpen: true,
imageModel: 'gpt-image-2',
...(status === 'failed' ? { errorMessage: message } : {}),
perfectPixelOperation: {
version: 1,
kind: 'perfect-pixel',
operationId: dialogId,
taskId: `pixel-art-snap-${dialogId}`,
request: {
sourceImageSrc: 'ref:project-resource:resource-source',
projectId: 'project-1',
sourceResourceId: 'resource-source',
assetKind: 'character',
assetLabel: '角色 · 完美像素',
canvasCompletion: {
dialogId,
title: '角色 · 完美像素',
placeholder: {
x: 100,
y: 120,
width: 320,
height: 320,
originalWidth: 640,
originalHeight: 640,
},
},
},
submittedAt: 1_700_000_000_000,
reconcileUntil: 1_700_000_120_000,
},
},
{
onRetryPerfectPixelOperation,
onSubmitImageGeneration,
},
);
const panel = screen.getByRole('dialog', { name: '完美像素操作' });
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();
expect(within(panel).queryByRole('button', { name: '修改' })).toBeNull();
const retryButton = within(panel).getByRole('button', {
name: '重试同一完美像素操作',
});
expect(within(panel).getAllByRole('button')).toEqual([retryButton]);
fireEvent.click(retryButton);
expect(onRetryPerfectPixelOperation).toHaveBeenCalledOnce();
expect(onRetryPerfectPixelOperation).toHaveBeenCalledWith(dialogId);
expect(onSubmitImageGeneration).not.toHaveBeenCalled();
});
it('无效完美像素标记只读展示错误,不暴露普通生成提交入口', () => {
const onRetryPerfectPixelOperation = vi.fn();
const onSubmitImageGeneration = vi.fn();
renderComposer(
{
id: 'dialog-perfect-pixel-invalid',
mode: 'generate',
prompt: '不得重新生成',
status: 'failed',
composerOpen: true,
imageModel: 'gpt-image-2',
perfectPixelOperationInvalid: true,
errorMessage: '完美像素操作快照身份不匹配。',
},
{
onRetryPerfectPixelOperation,
onSubmitImageGeneration,
},
);
const panel = screen.getByRole('dialog', { name: '完美像素操作' });
expect(within(panel).getByRole('alert').textContent).toBe(
'完美像素操作快照身份不匹配。',
);
expect(within(panel).queryByRole('textbox')).toBeNull();
expect(within(panel).queryByRole('button')).toBeNull();
expect(within(panel).queryByText('不得重新生成')).toBeNull();
expect(within(panel).queryByText('gpt-image-2')).toBeNull();
fireEvent.submit(panel);
expect(onRetryPerfectPixelOperation).not.toHaveBeenCalled();
expect(onSubmitImageGeneration).not.toHaveBeenCalled();
});
it('让快速编辑显示提示词、尺寸和模型选择', () => {
renderComposer({
mode: 'quick-edit',
@@ -172,8 +283,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();
@@ -195,22 +307,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(
@@ -233,9 +347,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(
@@ -311,47 +428,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({
@@ -379,19 +505,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(
@@ -542,10 +674,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
@@ -577,9 +713,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', {
@@ -611,15 +747,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(
@@ -637,10 +776,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');
@@ -709,9 +854,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秒' }),
@@ -731,9 +876,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(
@@ -780,9 +923,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();
@@ -817,7 +961,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();
});
});
@@ -117,9 +117,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>>;
@@ -131,6 +129,7 @@ type ImageCanvasGenerationComposerViewProps = {
onSubmitQuickEdit: () => void;
onSubmitCropExpand: () => void;
onSubmitCharacterAnimation: () => void;
onRetryPerfectPixelOperation?: (dialogId: string) => void;
onUpdateSpecFormValue: (key: keyof SpecFormValues, value: string) => void;
onUpdateIconDescriptionText: (value: string) => void;
onUpdateCharacterAnimationDuration: (frameCountValue: string) => void;
@@ -139,6 +138,66 @@ type ImageCanvasGenerationComposerViewProps = {
onSpecMenuPointerLeave?: () => void;
};
function PerfectPixelOperationPanel({
dialog,
style,
onRetry,
}: {
dialog: GenerateDialogState;
style: CSSProperties;
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 ?? '结果尚未确认,系统不会自动重复提交。')
: isGenerating
? '完美像素处理中'
: dialog.status === 'failed'
? (dialog.errorMessage ?? '完美像素处理失败')
: '完美像素处理已完成';
return (
<section
className="image-canvas-editor__generation-composer image-canvas-editor__generation-composer--perfect-pixel"
style={style}
role="dialog"
aria-label="完美像素操作"
onPointerDown={(event) => event.stopPropagation()}
>
<strong>完美像素</strong>
<PlatformStatusMessage
tone={isInvalid || dialog.status === 'failed' ? 'error' : 'info'}
surface="platform"
size="xs"
className="image-canvas-editor__generate-status"
role={isInvalid || dialog.status === 'failed' ? 'alert' : 'status'}
>
{message}
</PlatformStatusMessage>
{!isInvalid &&
(isPending || isPreparedFailure) &&
dialog.id &&
dialog.perfectPixelOperation ? (
<PlatformActionButton
type="button"
size="sm"
shape="pill"
className="image-canvas-editor__generation-submit"
disabled={!onRetry}
onClick={() => onRetry?.(dialog.id!)}
>
重试同一完美像素操作
</PlatformActionButton>
) : null}
</section>
);
}
function buildPortalMenuStyle(
anchor: HTMLElement | null,
placement: 'above' | 'below',
@@ -343,7 +402,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: [] }),
@@ -493,7 +553,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
@@ -527,8 +588,7 @@ function ImageCanvasVideoGenerationComposerView({
disabled={isGenerating}
onClick={() =>
updateVideoDialog({
videoSound:
soundMode === 'off' ? 'on' : 'off',
videoSound: soundMode === 'off' ? 'on' : 'off',
})
}
>
@@ -756,7 +816,9 @@ function ImageCanvasSoundEffectGenerationComposerView({
};
const updateSoundDuration = (durationSeconds: number) => {
updateAudioDialog({ soundDurationSeconds: normalizeSoundDuration(durationSeconds) });
updateAudioDialog({
soundDurationSeconds: normalizeSoundDuration(durationSeconds),
});
};
return (
@@ -777,7 +839,9 @@ function ImageCanvasSoundEffectGenerationComposerView({
aria-label="prompt"
value={dialog.prompt}
disabled={isGenerating}
placeholder="你希望生成什么音效?"
placeholder={
isSoundEffect ? '你希望生成什么音效?' : '你希望生成什么音乐?'
}
className="image-canvas-editor__generation-prompt"
onChange={(event) => updateAudioDialog({ prompt: event.target.value })}
/>
@@ -945,6 +1009,7 @@ export function ImageCanvasGenerationComposerView({
onSubmitQuickEdit,
onSubmitCropExpand,
onSubmitCharacterAnimation,
onRetryPerfectPixelOperation,
onUpdateSpecFormValue,
onUpdateIconDescriptionText,
onUpdateCharacterAnimationDuration,
@@ -954,7 +1019,10 @@ export function ImageCanvasGenerationComposerView({
}: ImageCanvasGenerationComposerViewProps) {
const backgroundMusicDialog =
toBackgroundMusicGenerationDialog(generateDialog);
const isPerfectPixelDialog = Boolean(
generateDialog?.perfectPixelOperation ||
generateDialog?.perfectPixelOperationInvalid,
);
return (
<>
{isSpecMenuOpen
@@ -982,7 +1050,19 @@ export function ImageCanvasGenerationComposerView({
)
: null}
{(generateDialog?.mode === 'generate' ||
{isPerfectPixelDialog &&
generateDialog &&
generateDialog.composerOpen !== false &&
generationComposerStyle ? (
<PerfectPixelOperationPanel
dialog={generateDialog}
style={generationComposerStyle}
onRetry={onRetryPerfectPixelOperation}
/>
) : null}
{!isPerfectPixelDialog &&
(generateDialog?.mode === 'generate' ||
generateDialog?.mode === 'quick-edit') &&
generateDialog.composerOpen !== false &&
generationComposerStyle ? (
@@ -1041,9 +1121,7 @@ export function ImageCanvasGenerationComposerView({
publicationReferenceButtonRef={publicationReferenceButtonRef}
isPublicationReferenceMenuOpen={isPublicationReferenceMenuOpen}
setGenerateDialog={setGenerateDialog}
setIsPublicationReferenceMenuOpen={
setIsPublicationReferenceMenuOpen
}
setIsPublicationReferenceMenuOpen={setIsPublicationReferenceMenuOpen}
setIsPickingPublicationReferenceFromCanvas={
setIsPickingPublicationReferenceFromCanvas
}
@@ -1230,9 +1308,7 @@ export function ImageCanvasGenerationComposerView({
/>
) : null}
{cropExpandPanel &&
cropExpandSourceLayer &&
cropExpandPanelStyle ? (
{cropExpandPanel && cropExpandSourceLayer && cropExpandPanelStyle ? (
<ImageCanvasCropExpandPanelView
panel={cropExpandPanel}
style={cropExpandPanelStyle}
@@ -625,6 +625,12 @@ describe('ImageCanvasHistoryModel', () => {
expect(isProtectedCanvasHistoryAction({ type: 'replace-image' })).toBe(
true,
);
expect(formatCanvasHistoryAction({ type: 'perfect-pixel' })).toBe(
'完美像素',
);
expect(isProtectedCanvasHistoryAction({ type: 'perfect-pixel' })).toBe(
true,
);
expect(isProtectedCanvasHistoryAction({ type: 'move-image' })).toBe(false);
});
});
@@ -22,6 +22,7 @@ const CANVAS_HISTORY_ACTION_LABELS: Record<
'generate-image': '生成图片',
'expand-image': '扩展图片',
'remove-background': '移除背景',
'perfect-pixel': '完美像素',
'split-atlas': '拆分图集',
'replace-image': '替换图片',
'show-image': '显示图片',
@@ -46,6 +47,7 @@ const PROTECTED_CANVAS_HISTORY_ACTION_TYPES = new Set<
'generate-image',
'expand-image',
'remove-background',
'perfect-pixel',
'split-atlas',
'replace-image',
]);
@@ -78,7 +78,11 @@ function syncPanelFromDialogUpdate(
model: normalizedDialog.imageModel ?? normalizedPanel.model,
quickEditReferences: normalizedDialog.generationReferences,
assetLabel: normalizedDialog.assetLabel,
status: normalizedDialog.status,
// 中文注释:dialog 由 createQuickEditDialog 从面板自身派生,组合器只会做
// failed→idle 的重置,不会写入 'pending-confirmation' 这类完美像素专属状态。
// 因此归一化后的 dialog 状态恒等于归一化后的面板状态,直接取面板侧即可,
// 也让面板的三态联合类型不必在这里做运行时收窄。
status: normalizedPanel.status,
errorMessage: normalizedDialog.errorMessage,
};
}
@@ -1,7 +1,10 @@
import {
type EditorBackgroundRemovalInput,
type EditorBackgroundRemovalResult,
type EditorPixelArtSnapInput,
type EditorPixelArtSnapResult,
removeEditorImageBackground,
snapEditorImageToPixelArt,
} from '../../services/image-editor/editorProjectClient';
export type CropExpandInsets = {
@@ -222,3 +225,9 @@ export async function removeImageBackground(
typeof input === 'string' ? { sourceImageSrc: input } : input,
);
}
export async function snapImageToPerfectPixels(
input: EditorPixelArtSnapInput,
): Promise<EditorPixelArtSnapResult> {
return snapEditorImageToPixelArt(input);
}
@@ -42,6 +42,9 @@ function renderSelectedToolbar(
onOpenRedrawPanel: vi.fn(),
onOpenCropExpandPanel: vi.fn(),
onRemoveBackground: vi.fn(),
onPerfectPixel: vi.fn(),
isPerfectPixelProcessing: false,
isPerfectPixelPendingConfirmation: false,
isSplittingIconSpritesheet: false,
isPersistingAssetKind: false,
onSplitIconSpritesheet: vi.fn(),
@@ -66,6 +69,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
'快速编辑',
'裁扩按钮',
'去除背景按钮',
'完美像素',
'改造',
'下载按钮',
]);
@@ -75,6 +79,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
fireEvent.click(
within(toolbar).getByRole('button', { name: '去除背景按钮' }),
);
fireEvent.click(within(toolbar).getByRole('button', { name: '完美像素' }));
fireEvent.click(within(toolbar).getByRole('button', { name: '改造' }));
fireEvent.click(within(toolbar).getByRole('button', { name: '下载按钮' }));
@@ -85,6 +90,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
props.selectedLayer,
);
expect(props.onRemoveBackground).toHaveBeenCalledWith(props.selectedLayer);
expect(props.onPerfectPixel).toHaveBeenCalledWith(props.selectedLayer);
expect(props.onOpenRedrawPanel).toHaveBeenCalledWith(props.selectedLayer);
expect(props.onDownloadLayer).toHaveBeenCalledWith(props.selectedLayer);
expect(
@@ -104,6 +110,19 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
.getByRole('button', { name: '去除背景按钮' })
.querySelector('.lucide-image-off'),
).toBeTruthy();
expect(
within(toolbar)
.getByRole('button', { name: '完美像素' })
.querySelector('.lucide-grid-2x2'),
).toBeTruthy();
expect(
within(toolbar).getByRole('button', { name: '完美像素' }).textContent,
).toContain('完美像素');
expect(
within(toolbar)
.getByRole('button', { name: '完美像素' })
.getAttribute('title'),
).toBe('自动识别并规整像素网格');
});
it('renders UI design asset extraction after remove background', () => {
@@ -119,6 +138,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
'快速编辑',
'裁扩按钮',
'去除背景按钮',
'完美像素',
'提取素材',
'改造',
'下载按钮',
@@ -142,6 +162,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
'快速编辑',
'裁扩按钮',
'去除背景按钮',
'完美像素',
'拆分图集',
'改造',
'下载按钮',
@@ -184,6 +205,74 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
expect(props.onSplitIconSpritesheet).not.toHaveBeenCalled();
});
it('renders a disabled loading state while perfect pixel is processing', () => {
const props = renderSelectedToolbar({
isPerfectPixelProcessing: true,
});
const button = screen.getByRole('button', {
name: '完美像素处理中',
});
expect(button.getAttribute('aria-busy')).toBe('true');
expect((button as HTMLButtonElement).disabled).toBe(true);
expect(
button.querySelector('.lucide-loader-circle.animate-spin'),
).toBeTruthy();
expect(button.querySelector('.lucide-grid-2x2')).toBeNull();
expect(button.textContent).toContain('处理中');
fireEvent.click(button);
fireEvent.click(button);
expect(props.onPerfectPixel).not.toHaveBeenCalled();
});
it('renders a distinct disabled state while perfect pixel is pending confirmation', () => {
const props = renderSelectedToolbar({
isPerfectPixelPendingConfirmation: true,
});
const button = screen.getByRole('button', {
name: '完美像素结果待确认',
});
expect(button.getAttribute('title')).toBe(
'结果待确认,双击原占位继续核对或重试',
);
expect(button.getAttribute('aria-busy')).toBe('false');
expect((button as HTMLButtonElement).disabled).toBe(true);
expect(button.textContent).toContain('待确认');
expect(button.textContent).not.toContain('处理中');
expect(button.querySelector('.lucide-grid-2x2')).toBeTruthy();
expect(button.querySelector('.lucide-loader-circle')).toBeNull();
fireEvent.click(button);
expect(props.onPerfectPixel).not.toHaveBeenCalled();
});
it('does not invoke perfect pixel while the selected asset kind is persisting', () => {
// 中文注释:请求同时带 assetKind 与 sourceResourceId,本地类型已改但资源尚未落库时
// 两者不一致,后端会直接 400 并留下失败占位。名称与拆分图集按钮的「素材类型保存中」
// 必须区分开——icon-spritesheet 图层上两个按钮会同时进入保存态。
const layer = createLayer({ assetKind: 'icon-spritesheet' });
const props = renderSelectedToolbar({
selectedLayer: layer,
isPersistingAssetKind: true,
});
const button = screen.getByRole('button', {
name: '完美像素等待素材类型保存',
});
expect(button.getAttribute('aria-busy')).toBe('true');
expect((button as HTMLButtonElement).disabled).toBe(true);
expect(button.querySelector('.lucide-grid-2x2')).toBeNull();
expect(button.textContent).toContain('保存中');
fireEvent.click(button);
expect(props.onPerfectPixel).not.toHaveBeenCalled();
});
it('does not invoke atlas splitting while the selected asset kind is persisting', () => {
const layer = createLayer({ assetKind: 'icon-spritesheet' });
const props = renderSelectedToolbar({
@@ -227,6 +316,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
expect(
within(toolbar).queryByRole('button', { name: '去除背景按钮' }),
).toBeNull();
expect(
within(toolbar).queryByRole('button', { name: '完美像素' }),
).toBeNull();
expect(
within(toolbar).queryByRole('button', { name: '生成动画' }),
).toBeNull();
@@ -255,6 +347,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
expect(
within(videoToolbar).queryByRole('button', { name: '去除背景按钮' }),
).toBeNull();
expect(
within(videoToolbar).queryByRole('button', { name: '完美像素' }),
).toBeNull();
expect(
within(videoToolbar)
.getAllByRole('button')
@@ -278,6 +373,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
expect(
within(actionToolbar).queryByRole('button', { name: '去除背景按钮' }),
).toBeNull();
expect(
within(actionToolbar).queryByRole('button', { name: '完美像素' }),
).toBeNull();
expect(
within(actionToolbar)
.getAllByRole('button')
@@ -341,6 +439,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
onOpenRedrawPanel={vi.fn()}
onOpenCropExpandPanel={vi.fn()}
onRemoveBackground={vi.fn()}
onPerfectPixel={vi.fn()}
isPerfectPixelProcessing={false}
isPerfectPixelPendingConfirmation={false}
isSplittingIconSpritesheet={false}
isPersistingAssetKind={false}
onSplitIconSpritesheet={vi.fn()}
@@ -360,6 +461,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
onOpenRedrawPanel={vi.fn()}
onOpenCropExpandPanel={vi.fn()}
onRemoveBackground={vi.fn()}
onPerfectPixel={vi.fn()}
isPerfectPixelProcessing={false}
isPerfectPixelPendingConfirmation={false}
isSplittingIconSpritesheet={false}
isPersistingAssetKind={false}
onSplitIconSpritesheet={vi.fn()}
@@ -1,6 +1,7 @@
import {
Crop,
Download,
Grid2X2,
ImageOff,
Loader2,
PersonStanding,
@@ -22,6 +23,9 @@ type ImageCanvasSelectedLayerToolbarViewProps = {
onOpenRedrawPanel: (layer: CanvasLayer) => void;
onOpenCropExpandPanel: (layer: CanvasLayer) => void;
onRemoveBackground: (layer: CanvasLayer) => void;
onPerfectPixel: (layer: CanvasLayer) => void;
isPerfectPixelProcessing: boolean;
isPerfectPixelPendingConfirmation: boolean;
isSplittingIconSpritesheet: boolean;
isPersistingAssetKind: boolean;
onSplitIconSpritesheet: (layer: CanvasLayer) => void;
@@ -37,6 +41,9 @@ export function ImageCanvasSelectedLayerToolbarView({
onOpenRedrawPanel,
onOpenCropExpandPanel,
onRemoveBackground,
onPerfectPixel,
isPerfectPixelProcessing,
isPerfectPixelPendingConfirmation,
isSplittingIconSpritesheet,
isPersistingAssetKind,
onSplitIconSpritesheet,
@@ -120,6 +127,57 @@ export function ImageCanvasSelectedLayerToolbarView({
icon={ImageOff}
onClick={() => onRemoveBackground(selectedLayer)}
/>
<PlatformIconButton
className="image-canvas-editor__floating-toolbar-text-button"
// 中文注释:保存态名称不能直接用「素材类型保存中」——icon-spritesheet 图层上
// 拆分图集按钮同时显示该文案,两个控件会撞同一个无障碍名称。
label={
isPersistingAssetKind
? '完美像素等待素材类型保存'
: isPerfectPixelProcessing
? '完美像素处理中'
: isPerfectPixelPendingConfirmation
? '完美像素结果待确认'
: '完美像素'
}
title={
isPersistingAssetKind
? '完美像素等待素材类型保存'
: isPerfectPixelProcessing
? '完美像素处理中'
: isPerfectPixelPendingConfirmation
? '结果待确认,双击原占位继续核对或重试'
: '自动识别并规整像素网格'
}
icon={
isPersistingAssetKind || isPerfectPixelProcessing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Grid2X2 className="h-4 w-4" />
)
}
// 中文注释:素材类型保存在途时必须一并禁用。请求同时带 assetKind 和
// sourceResourceId,本地类型已改但资源尚未落库时两者不一致,后端
// resolve_editor_pixel_art_snap_asset_kind 会直接 400,只留下失败占位。
// 与相邻的拆分图集按钮保持同一套门禁。
disabled={
isPersistingAssetKind ||
isPerfectPixelProcessing ||
isPerfectPixelPendingConfirmation
}
aria-busy={isPersistingAssetKind || isPerfectPixelProcessing}
onClick={() => onPerfectPixel(selectedLayer)}
>
<span>
{isPersistingAssetKind
? '保存中'
: isPerfectPixelProcessing
? '处理中'
: isPerfectPixelPendingConfirmation
? '待确认'
: '完美像素'}
</span>
</PlatformIconButton>
</>
) : null}
{selectedLayer.assetKind === 'icon-spritesheet' ? (
@@ -79,6 +79,8 @@ export type ImageCanvasStageViewProps = {
generationComposerStyle: CSSProperties | null;
selectedToolbarStyle: CSSProperties | null;
splittingIconSpritesheetLayerIds?: ReadonlySet<string>;
perfectPixelLayerIds?: ReadonlySet<string>;
pendingPerfectPixelLayerIds?: ReadonlySet<string>;
persistingAssetKindLayerIds?: ReadonlySet<string>;
uploadDropTarget: 'canvas' | 'assets' | null;
contextMenu: CanvasContextMenuState | null;
@@ -149,6 +151,7 @@ export type ImageCanvasStageViewProps = {
onOpenRedrawPanel: (layer: CanvasLayer) => void;
onOpenCropExpandPanel: (layer: CanvasLayer) => void;
onRemoveBackground: (layer: CanvasLayer) => void;
onPerfectPixel: (layer: CanvasLayer) => void;
onSplitIconSpritesheet: (layer: CanvasLayer) => void;
onExtractUiDesignAssets: (layer: CanvasLayer) => void;
onUiAssetExtractionToolChange: (tool: UiAssetExtractionTool | null) => void;
@@ -236,6 +239,8 @@ export function ImageCanvasStageView({
generationComposerStyle,
selectedToolbarStyle,
splittingIconSpritesheetLayerIds = EMPTY_LAYER_ID_SET,
perfectPixelLayerIds = EMPTY_LAYER_ID_SET,
pendingPerfectPixelLayerIds = EMPTY_LAYER_ID_SET,
persistingAssetKindLayerIds = EMPTY_LAYER_ID_SET,
uploadDropTarget,
contextMenu,
@@ -285,6 +290,7 @@ export function ImageCanvasStageView({
onOpenRedrawPanel,
onOpenCropExpandPanel,
onRemoveBackground,
onPerfectPixel,
onSplitIconSpritesheet,
onExtractUiDesignAssets,
onUiAssetExtractionToolChange,
@@ -409,10 +415,18 @@ export function ImageCanvasStageView({
isPersistingAssetKind={Boolean(
selectedLayer && persistingAssetKindLayerIds.has(selectedLayer.id),
)}
isPerfectPixelProcessing={Boolean(
selectedLayer && perfectPixelLayerIds.has(selectedLayer.id),
)}
isPerfectPixelPendingConfirmation={Boolean(
selectedLayer &&
pendingPerfectPixelLayerIds.has(selectedLayer.id),
)}
onOpenQuickEditPanel={onOpenQuickEditPanel}
onOpenRedrawPanel={onOpenRedrawPanel}
onOpenCropExpandPanel={onOpenCropExpandPanel}
onRemoveBackground={onRemoveBackground}
onPerfectPixel={onPerfectPixel}
onSplitIconSpritesheet={onSplitIconSpritesheet}
onExtractUiDesignAssets={onExtractUiDesignAssets}
onOpenCharacterAnimationPanel={onOpenCharacterAnimationPanel}
@@ -226,7 +226,8 @@ describe('ImageCanvasWorldView', () => {
useResolvedAssetReadUrlMock.mockImplementation(
(_source: string, options?: { objectKey?: string | null }) => ({
resolvedUrl:
options?.objectKey === 'generated-character-drafts/editor/uploaded.png'
options?.objectKey ===
'generated-character-drafts/editor/uploaded.png'
? 'https://oss.example.com/uploaded.png?signature=1'
: _source,
isResolving: false,
@@ -345,7 +346,9 @@ describe('ImageCanvasWorldView', () => {
expect(screen.getByText('生成中')).toBeTruthy();
const world = document.querySelector('.image-canvas-editor__world');
const marquee = world?.querySelector('.image-canvas-editor__canvas-marquee');
const marquee = world?.querySelector(
'.image-canvas-editor__canvas-marquee',
);
expect(world?.getAttribute('style')).toContain(
'transform: translate(10px, 20px) scale(1.5)',
@@ -378,7 +381,11 @@ describe('ImageCanvasWorldView', () => {
expect(within(frame).getByText('Icon Generator')).toBeTruthy();
expect(within(frame).getByText('图标')).toBeTruthy();
expect(within(frame).getByText('1024 x 768')).toBeTruthy();
expect(within(frame).getByRole('status').textContent).toBe('生成中');
const generatingStatus = within(frame).getByRole('status');
expect(generatingStatus.textContent).toBe('生成中');
expect(generatingStatus.className).not.toContain(
'image-canvas-editor__generation-frame-progress--pending-confirmation',
);
fireEvent.pointerDown(frame);
fireEvent.doubleClick(frame);
@@ -394,6 +401,30 @@ describe('ImageCanvasWorldView', () => {
expect(screen.queryByText('dialog-without-placeholder')).toBeNull();
});
it('keeps a pending perfect-pixel placeholder visible with a confirmation verdict', () => {
const dialog = createGenerationDialog({
id: 'dialog-perfect-pixel-pending',
status: 'pending-confirmation',
});
renderWorldView({
canvasGenerationDialogs: [dialog],
generateDialog: null,
});
const frame = screen.getByRole('button', { name: '图像生成占位图' });
expect(frame.className).toContain(
'image-canvas-editor__generation-frame--pending-confirmation',
);
expect(within(frame).getByText('Image Generator')).toBeTruthy();
const pendingStatus = within(frame).getByRole('status');
expect(pendingStatus.textContent).toBe('结果待确认');
expect(pendingStatus.className).toContain(
'image-canvas-editor__generation-frame-progress--pending-confirmation',
);
expect(within(frame).queryByText('生成中')).toBeNull();
});
it('renders crop-expand frame handles and forwards drag actions', () => {
const layer = createLayer();
const { props } = renderWorldView({
@@ -530,7 +561,14 @@ describe('ImageCanvasWorldView', () => {
},
])(
'renders $mode generation placeholder with its own icon and badge',
({ mode, ariaLabel, generatorLabel, badgeLabel, frameClassName, iconClassName }) => {
({
mode,
ariaLabel,
generatorLabel,
badgeLabel,
frameClassName,
iconClassName,
}) => {
const dialog = createGenerationDialog({
id: `dialog-${mode}`,
mode: mode as CanvasGenerationDialogState['mode'],
@@ -614,9 +652,9 @@ describe('ImageCanvasWorldView', () => {
const inverseScale = '4';
expect(
(within(frame).getByText(generatorLabel) as HTMLElement).style.getPropertyValue(
'--image-canvas-editor-inverse-scale',
),
(
within(frame).getByText(generatorLabel) as HTMLElement
).style.getPropertyValue('--image-canvas-editor-inverse-scale'),
).toBe(inverseScale);
expect(
mode === 'audio-sound-effect' || mode === 'audio-background-music'
@@ -630,9 +668,9 @@ describe('ImageCanvasWorldView', () => {
: inverseScale,
);
expect(
(within(frame).getByText(badgeLabel) as HTMLElement).style.getPropertyValue(
'--image-canvas-editor-inverse-scale',
),
(
within(frame).getByText(badgeLabel) as HTMLElement
).style.getPropertyValue('--image-canvas-editor-inverse-scale'),
).toBe(inverseScale);
},
);
@@ -718,14 +756,14 @@ describe('ImageCanvasWorldView', () => {
const inverseScale = '4';
expect(
(within(layerButton).getByText('角色') as HTMLElement).style.getPropertyValue(
'--image-canvas-editor-inverse-scale',
),
(
within(layerButton).getByText('角色') as HTMLElement
).style.getPropertyValue('--image-canvas-editor-inverse-scale'),
).toBe(inverseScale);
expect(
(within(layerButton).getByText('640 x 480 px') as HTMLElement).style.getPropertyValue(
'--image-canvas-editor-inverse-scale',
),
(
within(layerButton).getByText('640 x 480 px') as HTMLElement
).style.getPropertyValue('--image-canvas-editor-inverse-scale'),
).toBe(inverseScale);
expect(
within(layerButton)
@@ -733,14 +771,14 @@ describe('ImageCanvasWorldView', () => {
.style.getPropertyValue('--image-canvas-editor-inverse-scale'),
).toBe(inverseScale);
expect(
(within(frame).getByText('Image Generator') as HTMLElement).style.getPropertyValue(
'--image-canvas-editor-inverse-scale',
),
(
within(frame).getByText('Image Generator') as HTMLElement
).style.getPropertyValue('--image-canvas-editor-inverse-scale'),
).toBe(inverseScale);
expect(
(within(frame).getByText('1024 x 768') as HTMLElement).style.getPropertyValue(
'--image-canvas-editor-inverse-scale',
),
(
within(frame).getByText('1024 x 768') as HTMLElement
).style.getPropertyValue('--image-canvas-editor-inverse-scale'),
).toBe(inverseScale);
});
@@ -755,8 +793,11 @@ describe('ImageCanvasWorldView', () => {
const frame = screen.getByRole('button', { name: '图像生成占位图' });
expect(
(frame.querySelector('.image-canvas-editor__generation-frame-icon') as HTMLElement)
.style.getPropertyValue('--image-canvas-editor-inverse-scale'),
(
frame.querySelector(
'.image-canvas-editor__generation-frame-icon',
) as HTMLElement
).style.getPropertyValue('--image-canvas-editor-inverse-scale'),
).toBe('4');
});
@@ -813,9 +854,9 @@ describe('ImageCanvasWorldView', () => {
name: '查看角色主图图片信息',
});
expect(metadataButton.querySelector('svg')?.getAttribute('class')).toContain(
'lucide-info',
);
expect(
metadataButton.querySelector('svg')?.getAttribute('class'),
).toContain('lucide-info');
});
it('renders audio layers with an audio control card instead of an image', () => {
@@ -849,7 +890,8 @@ describe('ImageCanvasWorldView', () => {
within(layerButton).getAllByRole('button', { name: /播放|暂停/u }),
).toHaveLength(1);
expect(
within(layerButton).getByRole('button', { name: '播放游戏音效' })
within(layerButton)
.getByRole('button', { name: '播放游戏音效' })
.querySelector('svg')
?.getAttribute('class'),
).toContain('lucide-volume-2');
@@ -872,9 +914,7 @@ describe('ImageCanvasWorldView', () => {
).toBeTruthy();
expect(within(layerButton).getByLabelText('静音游戏音效')).toBeTruthy();
expect(within(layerButton).getByLabelText('调整游戏音效音量')).toBeTruthy();
expect(
within(layerButton).queryByText('420 x 120 px'),
).toBeNull();
expect(within(layerButton).queryByText('420 x 120 px')).toBeNull();
});
it('keeps audio controls interactive and switches icon, play and pause states', () => {
@@ -895,7 +935,9 @@ describe('ImageCanvasWorldView', () => {
selectedLayerIds: [layer.id],
});
const layerButton = screen.getByRole('button', { name: '选择游戏背景音乐' });
const layerButton = screen.getByRole('button', {
name: '选择游戏背景音乐',
});
const audio = within(layerButton).getByLabelText('画布音频:游戏背景音乐');
const playbackButton = within(layerButton).getByRole('button', {
name: '播放游戏背景音乐',
@@ -932,9 +974,8 @@ describe('ImageCanvasWorldView', () => {
?.getAttribute('class'),
).toContain('lucide-play');
const hoveredAudio = within(hoveredLayerButton).getByLabelText(
'画布音频:游戏背景音乐',
);
const hoveredAudio =
within(hoveredLayerButton).getByLabelText('画布音频:游戏背景音乐');
fireEvent.play(hoveredAudio);
expect(
within(hoveredLayerButton)
@@ -1032,9 +1073,7 @@ describe('ImageCanvasWorldView', () => {
expect(screen.getByRole('menu', { name: '选择素材标签' })).toBeTruthy();
fireEvent.pointerDown(document.body);
expect(
screen.queryByRole('menu', { name: '选择素材标签' }),
).toBeNull();
expect(screen.queryByRole('menu', { name: '选择素材标签' })).toBeNull();
} finally {
document.removeEventListener('wheel', documentWheel);
}
@@ -1100,7 +1139,9 @@ describe('ImageCanvasWorldView', () => {
layerButton.querySelector('.image-canvas-editor__media-preview--video')
?.className,
).toContain('image-canvas-editor__media-preview--variant-');
expect(within(layerButton).queryByAltText('画布视频:生成视频 7')).toBeNull();
expect(
within(layerButton).queryByAltText('画布视频:生成视频 7'),
).toBeNull();
expect(within(layerButton).getByText('视频')).toBeTruthy();
expect(props.onLayerPointerDown).toHaveBeenCalledWith(
expect.any(Object),
@@ -1201,8 +1242,7 @@ describe('ImageCanvasWorldView', () => {
it('keeps the last loaded sequence frame visible while the next frame is resolving', () => {
vi.useFakeTimers();
try {
useResolvedAssetReadUrlMock.mockImplementation(
(source: string) => ({
useResolvedAssetReadUrlMock.mockImplementation((source: string) => ({
resolvedUrl:
source === '/generated-character-drafts/editor/frame01.png'
? 'https://oss.example.com/frame01.png?signature=1'
@@ -1210,8 +1250,7 @@ describe('ImageCanvasWorldView', () => {
isResolving:
source === '/generated-character-drafts/editor/frame02.png',
shouldResolve: true,
}),
);
}));
const layer = createLayer({
title: '角色动作',
src: '/generated-character-drafts/editor/frame01.png',
@@ -1240,9 +1279,8 @@ describe('ImageCanvasWorldView', () => {
renderWorldView({ layers: [layer] });
const layerButton = screen.getByRole('button', { name: '选择角色动作' });
const firstFrame = within(layerButton).getByAltText(
'画布序列帧:角色动作',
);
const firstFrame =
within(layerButton).getByAltText('画布序列帧:角色动作');
fireEvent.load(firstFrame);
expect(
@@ -1292,8 +1330,6 @@ describe('ImageCanvasWorldView', () => {
});
expect(screen.getByRole('button', { name: '选择生成图片' })).toBeTruthy();
expect(
screen.queryByRole('button', { name: '图像生成占位图' }),
).toBeNull();
expect(screen.queryByRole('button', { name: '图像生成占位图' })).toBeNull();
});
});
@@ -316,9 +316,7 @@ function selectMediaLayerWithoutDrag(
onSelectLayer();
}
function handleLayerKeyboardActivation(
event: ReactKeyboardEvent<HTMLElement>,
) {
function handleLayerKeyboardActivation(event: ReactKeyboardEvent<HTMLElement>) {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
@@ -364,7 +362,9 @@ function getAudioLayerIcon(layer: CanvasLayer) {
);
}
function getAudioDurationSeconds(durationSeconds: CanvasLayer['durationSeconds']) {
function getAudioDurationSeconds(
durationSeconds: CanvasLayer['durationSeconds'],
) {
return typeof durationSeconds === 'number' &&
Number.isFinite(durationSeconds) &&
durationSeconds > 0
@@ -485,7 +485,9 @@ function ImageCanvasAudioLayerCard({
event.stopPropagation();
void togglePlayback();
}}
onPointerDown={(event) => selectMediaLayerWithoutDrag(event, onSelectLayer)}
onPointerDown={(event) =>
selectMediaLayerWithoutDrag(event, onSelectLayer)
}
>
{showPlaybackButton ? (
isPlaying ? (
@@ -504,7 +506,9 @@ function ImageCanvasAudioLayerCard({
className="image-canvas-editor__audio-card-controls image-canvas-editor__audio-card-controls--integrated"
onClick={stopMediaEventPropagation}
onKeyDown={stopMediaControlKeyPropagation}
onPointerDown={(event) => selectMediaLayerWithoutDrag(event, onSelectLayer)}
onPointerDown={(event) =>
selectMediaLayerWithoutDrag(event, onSelectLayer)
}
>
<audio
ref={audioRef}
@@ -513,7 +517,9 @@ function ImageCanvasAudioLayerCard({
preload="metadata"
aria-label={`画布音频:${layer.title}`}
onClick={stopMediaEventPropagation}
onPointerDown={(event) => selectMediaLayerWithoutDrag(event, onSelectLayer)}
onPointerDown={(event) =>
selectMediaLayerWithoutDrag(event, onSelectLayer)
}
onLoadedMetadata={(event) => {
const nextDuration = event.currentTarget.duration;
setDuration(
@@ -522,7 +528,9 @@ function ImageCanvasAudioLayerCard({
: fallbackDuration,
);
}}
onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)}
onTimeUpdate={(event) =>
setCurrentTime(event.currentTarget.currentTime)
}
onVolumeChange={(event) => {
setVolume(event.currentTarget.volume);
setIsMuted(event.currentTarget.muted);
@@ -556,7 +564,9 @@ function ImageCanvasAudioLayerCard({
event.stopPropagation();
toggleMute();
}}
onPointerDown={(event) => selectMediaLayerWithoutDrag(event, onSelectLayer)}
onPointerDown={(event) =>
selectMediaLayerWithoutDrag(event, onSelectLayer)
}
>
{isMuted ? (
<VolumeX className="h-3.5 w-3.5" aria-hidden="true" />
@@ -590,9 +600,12 @@ function ImageCanvasVideoLayer({
objectKey: layer.objectKey,
refreshKey: layer.taskId ?? layer.resourceId,
});
const { resolvedUrl: posterUrl } = useResolvedAssetReadUrl(layer.thumbnailSrc, {
const { resolvedUrl: posterUrl } = useResolvedAssetReadUrl(
layer.thumbnailSrc,
{
refreshKey: `${layer.taskId ?? layer.resourceId}:poster`,
});
},
);
const [showGeneratedPreview, setShowGeneratedPreview] = useState(
!layer.thumbnailSrc,
);
@@ -1242,7 +1255,10 @@ export function ImageCanvasWorldView({
getCanvasGenerationSelectionId(dialog.id),
);
const showFocusedChrome =
isFocused || isSelected || dialog.status === 'generating';
isFocused ||
isSelected ||
dialog.status === 'generating' ||
dialog.status === 'pending-confirmation';
const placeholderMeta = getGenerationPlaceholderMeta(dialog);
return (
@@ -1254,6 +1270,10 @@ export function ImageCanvasWorldView({
dialog.status === 'generating'
? 'image-canvas-editor__generation-frame--generating'
: ''
} ${
dialog.status === 'pending-confirmation'
? 'image-canvas-editor__generation-frame--pending-confirmation'
: ''
} ${
isFocused || isSelected
? 'image-canvas-editor__generation-frame--focused'
@@ -1321,6 +1341,14 @@ export function ImageCanvasWorldView({
生成中
</span>
) : null}
{dialog.status === 'pending-confirmation' ? (
<span
className="image-canvas-editor__generation-frame-progress image-canvas-editor__generation-frame-progress--pending-confirmation"
role="status"
>
结果待确认
</span>
) : null}
</div>
);
})()
@@ -0,0 +1,228 @@
/**
* @vitest-environment jsdom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { PerfectPixelOperationSnapshot } from './ImageCanvasEditorTypes';
import {
forgetPerfectPixelOperation,
PERFECT_PIXEL_OPERATION_RETENTION_LIMIT,
PERFECT_PIXEL_OPERATION_RETENTION_MS,
readPerfectPixelOperations,
savePerfectPixelOperation,
} from './perfectPixelOperationStore';
const OWNER_USER_ID = 'user-a';
const PROJECT_ID = 'project-1';
const NOW = 1_785_715_270_000;
function buildOperation(
dialogId: string,
overrides: Partial<PerfectPixelOperationSnapshot> = {},
): PerfectPixelOperationSnapshot {
return {
version: 1,
kind: 'perfect-pixel',
operationId: dialogId,
taskId: `pixel-art-snap-${dialogId}`,
request: {
sourceImageSrc: 'ref:project-resource:resource-source',
projectId: PROJECT_ID,
sourceResourceId: 'resource-source',
assetKind: 'character',
assetLabel: '角色 · 完美像素',
canvasCompletion: {
dialogId,
title: '角色 · 完美像素',
placeholder: {
x: 100,
y: 120,
width: 320,
height: 320,
originalWidth: 640,
originalHeight: 640,
},
},
},
submittedAt: NOW,
reconcileUntil: NOW + 75_000,
...overrides,
};
}
describe('perfectPixelOperationStore', () => {
beforeEach(() => {
window.localStorage.clear();
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
it('round-trips an operation for the same owner and project', () => {
const operation = buildOperation('dialog-1');
savePerfectPixelOperation(OWNER_USER_ID, PROJECT_ID, operation);
const ledger = readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID);
expect(ledger.get('dialog-1')).toEqual(operation);
});
it('keeps ledgers separated by project and drops another owner entirely', () => {
savePerfectPixelOperation(
OWNER_USER_ID,
PROJECT_ID,
buildOperation('dialog-1'),
);
expect(readPerfectPixelOperations(OWNER_USER_ID, 'project-2').size).toBe(0);
// 中文注释:同一台机器换账号后,上一个账号的请求(含源图直传地址)必须整条丢弃。
expect(readPerfectPixelOperations('user-b', PROJECT_ID).size).toBe(0);
expect(readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID).size).toBe(0);
});
it('refuses to store an operation whose request targets another project', () => {
savePerfectPixelOperation(
OWNER_USER_ID,
'project-2',
buildOperation('dialog-1'),
);
expect(readPerfectPixelOperations(OWNER_USER_ID, 'project-2').size).toBe(0);
});
it('fails closed on a tampered ledger entry instead of replaying it', () => {
savePerfectPixelOperation(
OWNER_USER_ID,
PROJECT_ID,
buildOperation('dialog-1'),
);
savePerfectPixelOperation(
OWNER_USER_ID,
PROJECT_ID,
buildOperation('dialog-2'),
);
const key = `genarrative.imageCanvas.perfectPixelOperations.${PROJECT_ID}`;
const stored = JSON.parse(window.localStorage.getItem(key)!) as {
ownerUserId: string;
operations: Record<string, PerfectPixelOperationSnapshot>;
};
stored.operations['dialog-1']!.taskId = 'pixel-art-snap-somewhere-else';
window.localStorage.setItem(key, JSON.stringify(stored));
const ledger = readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID);
expect(ledger.has('dialog-1')).toBe(false);
expect(ledger.has('dialog-2')).toBe(true);
});
it('drops entries past the retention window', () => {
savePerfectPixelOperation(
OWNER_USER_ID,
PROJECT_ID,
buildOperation('dialog-old', {
submittedAt: NOW - PERFECT_PIXEL_OPERATION_RETENTION_MS - 1,
reconcileUntil: NOW - PERFECT_PIXEL_OPERATION_RETENTION_MS + 74_999,
}),
);
savePerfectPixelOperation(
OWNER_USER_ID,
PROJECT_ID,
buildOperation('dialog-fresh'),
);
const ledger = readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID);
expect([...ledger.keys()]).toEqual(['dialog-fresh']);
});
it('caps the ledger size by keeping the newest submissions', () => {
for (
let index = 0;
index <= PERFECT_PIXEL_OPERATION_RETENTION_LIMIT;
index += 1
) {
savePerfectPixelOperation(
OWNER_USER_ID,
PROJECT_ID,
buildOperation(`dialog-${index}`, {
submittedAt: NOW - (PERFECT_PIXEL_OPERATION_RETENTION_LIMIT - index),
reconcileUntil:
NOW - (PERFECT_PIXEL_OPERATION_RETENTION_LIMIT - index) + 75_000,
}),
);
}
const ledger = readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID);
expect(ledger.size).toBe(PERFECT_PIXEL_OPERATION_RETENTION_LIMIT);
expect(ledger.has('dialog-0')).toBe(false);
expect(
ledger.has(`dialog-${PERFECT_PIXEL_OPERATION_RETENTION_LIMIT}`),
).toBe(true);
});
it('forgets a settled operation and clears the key once empty', () => {
savePerfectPixelOperation(
OWNER_USER_ID,
PROJECT_ID,
buildOperation('dialog-1'),
);
forgetPerfectPixelOperation(OWNER_USER_ID, PROJECT_ID, 'dialog-1');
expect(readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID).size).toBe(0);
expect(
window.localStorage.getItem(
`genarrative.imageCanvas.perfectPixelOperations.${PROJECT_ID}`,
),
).toBeNull();
});
it('degrades to an empty ledger without throwing when storage is unavailable', () => {
const setItem = vi
.spyOn(Storage.prototype, 'setItem')
.mockImplementation(() => {
throw new Error('QuotaExceededError');
});
const getItem = vi
.spyOn(Storage.prototype, 'getItem')
.mockImplementation(() => {
throw new Error('SecurityError');
});
try {
// 中文注释:隐私模式 / 配额写满时账本读写都会抛。这里必须静默降级——账本缺失只
// 意味着刷新后不能自动收口,绝不能反过来阻断发起、重试或删除。
expect(() =>
savePerfectPixelOperation(
OWNER_USER_ID,
PROJECT_ID,
buildOperation('dialog-1'),
),
).not.toThrow();
expect(() =>
forgetPerfectPixelOperation(OWNER_USER_ID, PROJECT_ID, 'dialog-1'),
).not.toThrow();
expect(readPerfectPixelOperations(OWNER_USER_ID, PROJECT_ID).size).toBe(
0,
);
} finally {
setItem.mockRestore();
getItem.mockRestore();
}
});
it('returns an empty ledger without an owner or project', () => {
savePerfectPixelOperation(
OWNER_USER_ID,
PROJECT_ID,
buildOperation('dialog-1'),
);
expect(readPerfectPixelOperations(null, PROJECT_ID).size).toBe(0);
expect(readPerfectPixelOperations(OWNER_USER_ID, ' ').size).toBe(0);
});
});
@@ -0,0 +1,203 @@
import { hydratePerfectPixelOperation } from './ImageCanvasEditorModel';
import type { PerfectPixelOperationSnapshot } from './ImageCanvasEditorTypes';
/**
* 中文注释:完美像素操作账本的本机存储。
*
* **这是明确设计,不是降级方案**:账本记录的是「本机这次会话发出过哪一次 POST」,
* 它是对账凭据,不是用户的画布内容,因此不进项目布局。由此得到两条硬性质:
*
* 1. **写入同步、不依赖网络、不依赖服务端校验。** 发 POST 前先落本机账本即可获得
* 「请求可被追溯」的保证,不必再用严格布局保存去换同一个保证。布局校验(例如
* 资源元数据读写不对称)从此不可能阻断完美像素的发起或重试。
* 2. **账本缺失只降级、绝不阻断。** 换设备、换浏览器、清缓存、隐私模式、配额写满,
* 都会读不到账本。那种情况下占位收口为可删除的失败态,用户可以删掉重来;
* 任何路径都不得因为「读不到账本」而拒绝用户发起、重试或删除。
*
* 代价是跨设备不再自动收口:在 A 机发起、到 B 机打开同一项目时,B 机看到的是失败占位
* 而不是对账中的占位。完美像素是免费同步操作,重做成本极低,用这点换掉「用户数据里
* 混着系统对账状态」的耦合是划算的。
*/
const PERFECT_PIXEL_OPERATION_STORAGE_KEY_PREFIX =
'genarrative.imageCanvas.perfectPixelOperations';
/**
* 中文注释:账本保留期。对账窗口只有 75 秒,但 `pending-confirmation` 占位允许用户在很久
* 之后手动重试同一次 operation,那条路径同样需要账本,所以保留期必须远长于对账窗口。
*/
export const PERFECT_PIXEL_OPERATION_RETENTION_MS = 7 * 24 * 60 * 60 * 1_000;
/**
* 中文注释:单个项目最多保留的账本条数,超出时丢弃最旧的。防止长期使用把 localStorage
* 配额吃满——配额写满会连带影响同域下其它本地缓存,而账本本身是可丢弃的。
*/
export const PERFECT_PIXEL_OPERATION_RETENTION_LIMIT = 32;
type PerfectPixelOperationLedger = Map<string, PerfectPixelOperationSnapshot>;
function getPerfectPixelOperationStorage() {
if (typeof window === 'undefined') {
return null;
}
try {
return window.localStorage;
} catch {
return null;
}
}
function perfectPixelOperationStorageKey(projectId: string | null | undefined) {
const normalizedProjectId = projectId?.trim();
if (!normalizedProjectId) {
return null;
}
return `${PERFECT_PIXEL_OPERATION_STORAGE_KEY_PREFIX}.${normalizedProjectId}`;
}
/**
* 中文注释:读账本时同时校验归属。同一台机器可能先后登录不同账号,账本里带着上一个
* 账号的请求(含源图直传地址),换人后必须整条丢弃而不是原样返回。
*/
function readLedgerEntries(
storage: Storage,
key: string,
ownerUserId: string,
): PerfectPixelOperationLedger {
const ledger: PerfectPixelOperationLedger = new Map();
const rawValue = storage.getItem(key);
if (!rawValue) {
return ledger;
}
const parsedValue: unknown = JSON.parse(rawValue);
if (!parsedValue || typeof parsedValue !== 'object') {
return ledger;
}
const record = parsedValue as Record<string, unknown>;
const recordOwnerUserId =
typeof record.ownerUserId === 'string' ? record.ownerUserId.trim() : '';
if (recordOwnerUserId !== ownerUserId) {
storage.removeItem(key);
return ledger;
}
const operations = record.operations;
if (!operations || typeof operations !== 'object') {
return ledger;
}
const now = Date.now();
for (const [operationId, value] of Object.entries(
operations as Record<string, unknown>,
)) {
// 中文注释:本机账本与布局快照走同一套 v1 白名单校验。存储可被用户或其它脚本改写,
// 任何字段漂移都必须失败关闭——绝不能据一份可疑账本重放 POST。
const operation = hydratePerfectPixelOperation(value, operationId);
if (!operation) {
continue;
}
if (now - operation.submittedAt > PERFECT_PIXEL_OPERATION_RETENTION_MS) {
continue;
}
ledger.set(operationId, operation);
}
return ledger;
}
function writeLedgerEntries(
storage: Storage,
key: string,
ownerUserId: string,
ledger: PerfectPixelOperationLedger,
) {
if (ledger.size === 0) {
storage.removeItem(key);
return;
}
const retained = [...ledger.values()]
.sort((left, right) => right.submittedAt - left.submittedAt)
.slice(0, PERFECT_PIXEL_OPERATION_RETENTION_LIMIT);
storage.setItem(
key,
JSON.stringify({
ownerUserId,
operations: Object.fromEntries(
retained.map((operation) => [operation.operationId, operation]),
),
}),
);
}
/**
* 中文注释:读取某项目在本机的全部有效账本。任何异常都返回空账本——读不到账本只意味着
* 「刷新后不能自动收口」,调用方必须能在空账本下继续工作。
*/
export function readPerfectPixelOperations(
currentUserId: string | null | undefined,
projectId: string | null | undefined,
): PerfectPixelOperationLedger {
const ownerUserId = currentUserId?.trim();
const key = perfectPixelOperationStorageKey(projectId);
const storage = getPerfectPixelOperationStorage();
if (!ownerUserId || !key || !storage) {
return new Map();
}
try {
return readLedgerEntries(storage, key, ownerUserId);
} catch {
return new Map();
}
}
/**
* 中文注释:写入一条账本。必须在发 POST 之前调用——这是整条链路里唯一「请求已发出」的
* 本地证据。写入失败(配额、隐私模式)同样不阻断:调用方照常发 POST,只是丢掉刷新后
* 自动收口的能力。
*/
export function savePerfectPixelOperation(
currentUserId: string | null | undefined,
projectId: string | null | undefined,
operation: PerfectPixelOperationSnapshot,
) {
const ownerUserId = currentUserId?.trim();
const key = perfectPixelOperationStorageKey(projectId);
const storage = getPerfectPixelOperationStorage();
if (!ownerUserId || !key || !storage) {
return;
}
if (operation.request.projectId !== projectId?.trim()) {
return;
}
try {
const ledger = readLedgerEntries(storage, key, ownerUserId);
ledger.set(operation.operationId, operation);
writeLedgerEntries(storage, key, ownerUserId, ledger);
} catch {
// 中文注释:账本是尽力而为的本机便利,写失败不得影响本次提交。
}
}
/**
* 中文注释:操作收口(结果已套用 / 只落素材库 / 快照判定失效)后清账本,避免过期条目
* 在下次加载时再发一次无谓的对账 GET。
*/
export function forgetPerfectPixelOperation(
currentUserId: string | null | undefined,
projectId: string | null | undefined,
operationId: string,
) {
const ownerUserId = currentUserId?.trim();
const key = perfectPixelOperationStorageKey(projectId);
const storage = getPerfectPixelOperationStorage();
const normalizedOperationId = operationId.trim();
if (!ownerUserId || !key || !storage || !normalizedOperationId) {
return;
}
try {
const ledger = readLedgerEntries(storage, key, ownerUserId);
if (!ledger.delete(normalizedOperationId)) {
return;
}
writeLedgerEntries(storage, key, ownerUserId, ledger);
} catch {
// 中文注释:清理失败最多留下一条过期账本,保留期会兜底。
}
}
@@ -1,10 +1,13 @@
/* @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';
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
import {
requiresGenerationDeleteConfirmation,
useCanvasGenerationDialogs,
} from './useCanvasGenerationDialogs';
function createDialog(
mode: CanvasGenerationDialogState['mode'],
@@ -26,6 +29,48 @@ function createDialog(
};
}
function durablePerfectPixelDialog(
dialogId: string,
status: 'generating' | 'pending-confirmation' | 'failed',
): CanvasGenerationDialogState {
const submittedAt = 1_700_000_000_000;
return {
id: dialogId,
mode: 'quick-edit',
prompt: '完美像素',
status,
composerOpen: true,
sourceLayerId: 'layer-source',
// 中文注释:marker 从占位创建那一刻就存在,账本形成后也一直在。夹具必须还原这个形状,
// 否则下游用例是在测一个真实链路里不存在的状态。
perfectPixelOperationId: dialogId,
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,
},
};
}
describe('useCanvasGenerationDialogs', () => {
it('archives, activates, updates, and removes canvas generation dialogs', () => {
const onActivate = vi.fn();
@@ -346,4 +391,116 @@ describe('useCanvasGenerationDialogs', () => {
result.current.getCanvasGenerationDialogById(firstDialogId),
).toBeUndefined();
});
it('returns a newly opened explicit dialog from the synchronous snapshot getter in the same action', () => {
const { result } = renderHook(() => useCanvasGenerationDialogs());
let openedDialogId = '';
let immediateSnapshot: CanvasGenerationDialogState[] = [];
act(() => {
openedDialogId = result.current.openCanvasGenerationDialog({
...createDialog('quick-edit', '完美像素'),
id: 'generation-dialog-perfect-pixel',
status: 'generating',
});
immediateSnapshot = result.current.getCanvasGenerationDialogsSnapshot();
});
expect(openedDialogId).toBe('generation-dialog-perfect-pixel');
expect(immediateSnapshot).toEqual([
expect.objectContaining({
id: 'generation-dialog-perfect-pixel',
mode: 'quick-edit',
prompt: '完美像素',
status: 'generating',
}),
]);
});
// 中文注释:占位是用户文档的一部分,删除它不撤销任何在途请求——完美像素没有取消接口,
// 结果照常落库并进素材库。低层删除因此不得对未收口 operation 抗命,否则上层会写出
// 「历史记了一笔、占位还在」的伪历史。
it.each(['generating', 'pending-confirmation', 'failed'] as const)(
'deletes an unsettled durable perfect-pixel operation by id while %s',
(status) => {
const { result } = renderHook(() => useCanvasGenerationDialogs());
const dialogId = 'perfect-pixel-durable';
act(() => {
result.current.restoreCanvasGenerationDialogs([
durablePerfectPixelDialog(dialogId, status),
]);
});
act(() => {
result.current.removeCanvasGenerationDialogById(dialogId);
});
expect(result.current.activeCanvasGenerationDialog).toBeNull();
expect(result.current.canvasGenerationDialogs).toEqual([]);
},
);
// 中文注释:确认弹窗讲的是「已消耗的泥点不会返还」,只对计费生成成立;完美像素免费且删除
// 占位不撤销在途请求,所以它在任何状态都直接删。
it.each(['generating', 'pending-confirmation', 'failed'] as const)(
'never asks for delete confirmation on a perfect-pixel placeholder while %s',
(status) => {
expect(
requiresGenerationDeleteConfirmation(
durablePerfectPixelDialog('perfect-pixel-confirm', status),
),
).toBe(false);
},
);
it('never asks for delete confirmation before the perfect-pixel ledger exists', () => {
// 中文注释:源图解析 / 直传最长 90 秒,期间占位只有 marker、还没有账本。此前判据按账本
// 判,用户在这段窗口里删一个免费操作会看到「已消耗的泥点不会返还」。
expect(
requiresGenerationDeleteConfirmation({
id: 'perfect-pixel-preparing',
mode: 'quick-edit',
prompt: '完美像素',
status: 'generating',
composerOpen: false,
sourceLayerId: 'layer-source',
requiresLiveSession: true,
perfectPixelOperationId: 'perfect-pixel-preparing',
}),
).toBe(false);
});
it('still asks for delete confirmation on an ordinary generating placeholder', () => {
expect(
requiresGenerationDeleteConfirmation({
id: 'generation-dialog-1',
...createDialog('generate', '一只猫'),
status: 'generating',
}),
).toBe(true);
expect(
requiresGenerationDeleteConfirmation({
id: 'generation-dialog-2',
...createDialog('generate', '一只猫'),
status: 'failed',
}),
).toBe(false);
});
it('drops unsettled durable perfect-pixel operations together with their source layer', () => {
const { result } = renderHook(() => useCanvasGenerationDialogs());
const dialogId = 'perfect-pixel-durable';
act(() => {
result.current.restoreCanvasGenerationDialogs([
durablePerfectPixelDialog(dialogId, 'pending-confirmation'),
]);
});
act(() => {
result.current.removeCanvasGenerationDialogsByLayerId('layer-source');
});
expect(result.current.activeCanvasGenerationDialog).toBeNull();
expect(result.current.canvasGenerationDialogs).toEqual([]);
});
});
@@ -17,6 +17,35 @@ type CanvasGenerationDialogUpdater = (
dialog: CanvasGenerationDialogState,
) => CanvasGenerationDialogState | null;
export type CanvasGenerationDialogDraft = Omit<
CanvasGenerationDialogState,
'id'
> & {
id?: string;
};
/**
* 中文注释:删除生成占位前是否需要二次确认。
*
* 确认弹窗讲的是「已消耗的泥点不会返还」,只对计费生成成立。完美像素
* `generation_cost_mud_points = 0`,删除占位也不撤销任何在途请求——它没有取消接口,结果照常
* 落库并进素材库,服务端 completion 发现 dialog 已不在会返回 DialogMissing 并由客户端提示。
* 所以完美像素占位在任何状态都直接删,不额外解释。
*
* 判据必须看 `perfectPixelOperationId` 而**不是** `perfectPixelOperation`:后者要到源图解析 /
* 直传完成后才写入,那一段预算最长 90 秒(未登记的本地图片要走 ticket → PUT → confirm),
* 期间占位是 `generating` 且没有账本,按账本判会让用户删一个免费操作时看到「已消耗的泥点
* 不会返还」。marker 在占位创建那一刻就写上,覆盖完整生命周期。
*
* 更一般地:这里问的是「**这次生成计不计费**」,账本的有无只是它在某一段时间内的代理。
* 用短寿命字段的存在性去判断长期属性,正是本仓库反复出错的形状。
*/
export function requiresGenerationDeleteConfirmation(
dialog: CanvasGenerationDialogState,
) {
return dialog.status === 'generating' && !dialog.perfectPixelOperationId;
}
function withGenerationTimestamps<T extends GenerateDialogState | null>(
nextDialog: T,
previousDialog?: GenerateDialogState | null,
@@ -90,6 +119,14 @@ export function useCanvasGenerationDialogs({
[activeCanvasGenerationDialog, inactiveGenerateDialogs],
);
const getCanvasGenerationDialogsSnapshot = useCallback(() => {
const currentDialog = generateDialogRef.current;
return [
...inactiveGenerateDialogsRef.current,
...(isCanvasGenerationDialog(currentDialog) ? [currentDialog] : []),
];
}, []);
const createGenerationDialogId = useCallback(() => {
generationDialogCounterRef.current += 1;
return `generation-dialog-${generationDialogCounterRef.current}`;
@@ -116,7 +153,7 @@ export function useCanvasGenerationDialogs({
}, []);
const openCanvasGenerationDialog = useCallback(
(dialog: Omit<CanvasGenerationDialogState, 'id'>) => {
(dialog: CanvasGenerationDialogDraft) => {
const currentDialog = generateDialogRef.current;
if (isCanvasGenerationDialog(currentDialog)) {
inactiveGenerateDialogsRef.current =
@@ -133,16 +170,30 @@ export function useCanvasGenerationDialogs({
];
}
archiveActiveCanvasGenerationDialog();
const id = createGenerationDialogId();
const requestedId = dialog.id?.trim();
const requestedIdAlreadyExists =
Boolean(requestedId) &&
getCanvasGenerationDialogsSnapshot().some(
(existingDialog) => existingDialog.id === requestedId,
);
const id =
requestedId && !requestedIdAlreadyExists
? requestedId
: createGenerationDialogId();
const { id: _requestedId, ...dialogWithoutId } = dialog;
const nextDialog = withGenerationTimestamps({
...dialog,
...dialogWithoutId,
id,
});
generateDialogRef.current = nextDialog;
setGenerateDialogState(nextDialog);
return id;
},
[archiveActiveCanvasGenerationDialog, createGenerationDialogId],
[
archiveActiveCanvasGenerationDialog,
createGenerationDialogId,
getCanvasGenerationDialogsSnapshot,
],
);
const updateCanvasGenerationDialogById = useCallback(
@@ -187,6 +238,10 @@ export function useCanvasGenerationDialogs({
[],
);
// 中文注释:低层删除不再对未收口的完美像素 operation 抗命。删除占位不撤销任何在途请求
// ——完美像素没有取消接口,结果照常落库并进素材库,服务端 completion 发现 dialog 已不在
// 会返回 DialogMissing,客户端有对应提示。封锁换来的只是「结果自动回填画布」这一便利,
// 代价却是用户画布上出现删不掉的对象;低层偷偷保留还会让上层写出伪历史。
const removeCanvasGenerationDialogById = useCallback(
(dialogId: string) => {
updateCanvasGenerationDialogById(dialogId, () => null);
@@ -255,7 +310,9 @@ export function useCanvasGenerationDialogs({
nextCounter,
);
const activeDialog =
[...dialogs].reverse().find((dialog) => dialog.composerOpen !== false) ??
[...dialogs]
.reverse()
.find((dialog) => dialog.composerOpen !== false) ??
dialogs[dialogs.length - 1] ??
null;
const nextActiveDialog = activeDialog
@@ -331,6 +388,7 @@ export function useCanvasGenerationDialogs({
inactiveGenerateDialogsRef,
activeCanvasGenerationDialog,
canvasGenerationDialogs,
getCanvasGenerationDialogsSnapshot,
archiveActiveCanvasGenerationDialog,
openCanvasGenerationDialog,
updateCanvasGenerationDialogById,
@@ -36,6 +36,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());
@@ -74,6 +75,7 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
});
vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({
uploadEditorMediaAssetObjectFile: uploadEditorMediaAssetObjectFileMock,
uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock,
}));
@@ -565,6 +567,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
beforeEach(() => {
resolveEditorImageReferenceDataUrlMock.mockReset();
resolveEditorImageReferenceDataUrlForGenerationMock.mockReset();
uploadEditorMediaAssetObjectFileMock.mockReset();
uploadEditorMediaAssetFileMock.mockReset();
editEditorImageMock.mockReset();
extractEditorUiDesignAssetsMock.mockReset();
@@ -583,8 +586,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',
@@ -615,16 +617,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==',
);
@@ -632,23 +674,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({
@@ -803,9 +946,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
);
render(
<SubmissionWorkflowHarness
initialLayers={[
createLayer({ assetKind: 'icon-spritesheet' }),
]}
initialLayers={[createLayer({ assetKind: 'icon-spritesheet' })]}
initialQuickEditPanel={{
mode: 'quick-edit',
sourceLayerId: 'layer-source',
@@ -1051,7 +1192,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
);
});
await waitFor(() => {
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith(
expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledWith(
expect.any(File),
'image',
expect.objectContaining({
@@ -1067,7 +1208,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',
@@ -1191,8 +1332,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
prompt: '角色换成蓝色披风',
warning: {
code: 'postprocess-failed-source-preserved',
reason:
'生成任务成功,后处理失败。',
reason: '生成任务成功,后处理失败。',
},
}),
);
@@ -1392,7 +1532,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
fireEvent.click(screen.getByRole('button', { name: '提交当前生成' }));
await waitFor(() => {
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith(
expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledWith(
expect.any(File),
'video',
expect.objectContaining({
@@ -2616,7 +2756,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 () => {
@@ -2640,8 +2780,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
prompt: '队列角色生成',
}),
queueState: createQueueState({
warning:
'生成任务成功,后处理失败。',
warning: '生成任务成功,后处理失败。',
}),
});
render(
@@ -2689,7 +2828,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
);
});
it('reloads a queued project snapshot again when the completion dialog is still unresolved', async () => {
it('reloads a queued project snapshot when a later duplicate completion dialog is unresolved', async () => {
vi.useFakeTimers();
try {
const applyProjectSnapshot = vi.fn();
@@ -2700,6 +2839,16 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
title: '队列项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'generation-dialog-background-removal-completed',
resourceId: 'generation-dialog-background-removal-completed',
itemType: 'generation-dialog',
dialog: {
id: 'dialog-background-removal',
status: 'idle',
generatedLayerId: 'layer-background-removal-result',
},
},
{
layerId: 'generation-dialog-background-removal',
resourceId: 'generation-dialog-background-removal',
@@ -2777,8 +2926,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
await applyQueuedEditorGenerationProject(
{
queueState: createQueueState({
warning:
'生成任务成功,后处理失败。',
warning: '生成任务成功,后处理失败。',
}),
},
'editor-project-1',
@@ -3053,7 +3201,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 () => {
@@ -3435,8 +3583,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
taskId: 'task-ui-assets',
warning: {
code: 'postprocess-failed-source-preserved',
reason:
'生成任务成功,后处理失败。',
reason: '生成任务成功,后处理失败。',
},
});
render(
@@ -3614,8 +3761,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
height: 768,
warning: {
code: 'postprocess-failed-source-preserved',
reason:
'生成任务成功,后处理失败。',
reason: '生成任务成功,后处理失败。',
},
}),
);
@@ -13,7 +13,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,
@@ -31,6 +31,10 @@ import {
generateEditorVideo,
loadEditorProject,
} from '../../services/image-editor/editorProjectClient';
import {
findCanvasGenerationDialogRecords,
isUnresolvedCanvasGenerationDialogRecord,
} from './ImageCanvasEditorModel';
import type {
CanvasGenerationDialogState,
CanvasGenerationInputs,
@@ -113,6 +117,12 @@ type EditorGenerationMediaReference = {
type EditorGenerationMediaReferenceOptions = {
allowRegisteredIds?: boolean;
requireImageObjectReference?: boolean;
// 中文注释:需要 unknown 重放的同步操作由调用方传入稳定 id;普通入口不传时仍为每次
// 上传生成随机路径,避免并发参考图互相覆盖。
uploadId?: string;
// 中文注释:由调用方的阶段预算驱动,并贯穿源读取、Data URL 转换以及
// ticket → PUT → confirm,不能只在最外层停止 await。
signal?: AbortSignal;
};
let editorGenerationUploadFallbackCounter = 0;
@@ -150,7 +160,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) {
@@ -158,10 +177,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 });
}
@@ -169,18 +190,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}/*`,
});
@@ -190,11 +215,19 @@ async function uploadEditorGenerationInlineMediaSource(
source: string,
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}`,
@@ -205,6 +238,7 @@ async function uploadEditorGenerationInlineMediaSource(
uploadId,
],
entityId: normalizedProjectId,
signal,
...(projectId?.trim()
? { metadata: { editor_project_id: projectId.trim() } }
: {}),
@@ -219,6 +253,7 @@ export async function resolveEditorGenerationMediaReference(
projectId?: string | null,
options: EditorGenerationMediaReferenceOptions = {},
) {
throwIfEditorGenerationMediaUploadAborted(options.signal);
const resourceId = reference.resourceId?.trim();
const hasRegisteredReference =
options.allowRegisteredIds !== false &&
@@ -239,13 +274,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,
);
}
@@ -426,25 +465,9 @@ function projectHasUnresolvedGenerationDialog(
project: EditorProjectSnapshot,
dialogId: string | null | undefined,
) {
const normalizedDialogId = dialogId?.trim();
if (!normalizedDialogId) {
return false;
}
return project.layers.some((item) => {
if (item.itemType !== 'generation-dialog') {
return false;
}
const dialog =
item.dialog && typeof item.dialog === 'object'
? (item.dialog as Record<string, unknown>)
: null;
return (
dialog?.id === normalizedDialogId &&
(dialog.status === 'generating' ||
typeof dialog.generatedLayerId !== 'string' ||
dialog.generatedLayerId.trim() === '')
);
});
return findCanvasGenerationDialogRecords(project, dialogId).some(
isUnresolvedCanvasGenerationDialogRecord,
);
}
function notifyWalletBalanceMayHaveChanged(callback?: () => void) {
@@ -152,6 +152,7 @@ function GenerationSurfaceHarness() {
activeCanvasGenerationDialog: activeCanvasDialog,
canvasGenerationDialogs: dialogs.canvasGenerationDialogs,
openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog,
activateCanvasGenerationDialog: dialogs.activateCanvasGenerationDialog,
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById: dialogs.hasCanvasGenerationDialogById,
getCanvasGenerationDialogById: dialogs.getCanvasGenerationDialogById,
@@ -74,6 +74,9 @@ type ImageCanvasGenerationSurfaceOptions = {
openCanvasGenerationDialog: (
dialog: Omit<CanvasGenerationDialogState, 'id'>,
) => string;
activateCanvasGenerationDialog: (
targetDialog: CanvasGenerationDialogState,
) => void;
updateCanvasGenerationDialogById: (
dialogId: string,
updater: CanvasGenerationDialogUpdater,
@@ -109,6 +112,13 @@ type ImageCanvasGenerationSurfaceOptions = {
project: EditorProjectSnapshot,
action?: CanvasHistoryAction,
) => void;
applyProjectSnapshotWithoutHistory?: (
project: EditorProjectSnapshot,
) => boolean | void;
flushProjectPersistence?: (options?: {
preferLatestGenerationDialogs?: boolean;
}) => Promise<void>;
refreshAssetLibrary?: () => Promise<unknown> | void;
onWalletBalanceMayHaveChanged?: () => void;
};
@@ -164,6 +174,7 @@ export function useImageCanvasGenerationSurface({
activeCanvasGenerationDialog,
canvasGenerationDialogs,
openCanvasGenerationDialog,
activateCanvasGenerationDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
getCanvasGenerationDialogById,
@@ -186,6 +197,9 @@ export function useImageCanvasGenerationSurface({
assetFolderId,
upsertGeneratedAsset,
applyProjectSnapshot,
applyProjectSnapshotWithoutHistory,
flushProjectPersistence,
refreshAssetLibrary,
onWalletBalanceMayHaveChanged,
}: ImageCanvasGenerationSurfaceOptions) {
const toolbarOptionCloseTimerRef = useRef<ReturnType<
@@ -202,6 +216,7 @@ export function useImageCanvasGenerationSurface({
generateDialog,
setGenerateDialog,
openCanvasGenerationDialog,
activateCanvasGenerationDialog,
updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById,
getCanvasGenerationDialogById,
@@ -223,6 +238,9 @@ export function useImageCanvasGenerationSurface({
assetFolderId,
upsertGeneratedAsset,
applyProjectSnapshot,
applyProjectSnapshotWithoutHistory,
flushProjectPersistence,
refreshAssetLibrary,
onWalletBalanceMayHaveChanged,
});
@@ -486,6 +504,9 @@ export function useImageCanvasGenerationSurface({
onSubmitCharacterAnimation={() =>
void generationWorkflow.submitCharacterAnimation()
}
onRetryPerfectPixelOperation={(dialogId) =>
void generationWorkflow.retryPerfectPixelOperation(dialogId)
}
onUpdateSpecFormValue={generationWorkflow.updateSpecFormValue}
onUpdateIconDescriptionText={
generationWorkflow.updateIconDescriptionsText

Some files were not shown because too many files have changed in this diff Show More