新增画布完美像素功能

在图片工具栏接入完美像素交互、占位流程与历史保护

新增严格像素网格检测和登录态同步处理接口

补齐素材类型校验、持久化边界、并发限制及技术文档

修复 Windows rlib 门禁长文件名解析
This commit is contained in:
2026-07-30 11:42:45 +00:00
parent b512430e53
commit ee2cad377f
25 changed files with 2469 additions and 64 deletions
@@ -195,6 +195,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(),
@@ -289,6 +289,7 @@ export type CanvasHistoryActionType =
| 'generate-image'
| 'expand-image'
| 'remove-background'
| 'perfect-pixel'
| 'split-atlas'
| 'replace-image'
| 'show-image'
@@ -1464,6 +1464,7 @@ export function ImageCanvasEditorView({
assetFolderId: activeUploadFolderId,
upsertGeneratedAsset,
applyProjectSnapshot: applyGeneratedProjectSnapshot,
flushProjectPersistence,
onWalletBalanceMayHaveChanged: refreshEditorWalletState,
});
const handleEditorAgentConfirmSent = useCallback(() => {
@@ -1581,6 +1582,8 @@ export function ImageCanvasEditorView({
openRedrawPanel,
openCropExpandPanel,
removeSelectedLayerBackground,
snapSelectedLayerToPerfectPixels,
perfectPixelLayerIds,
splitSelectedIconSpritesheet,
splittingIconSpritesheetLayerIds,
extractUiDesignAssets,
@@ -2357,6 +2360,7 @@ export function ImageCanvasEditorView({
quickEditSelectionSourceLayer,
generationComposerStyle,
selectedToolbarStyle,
perfectPixelLayerIds,
splittingIconSpritesheetLayerIds,
persistingAssetKindLayerIds,
uploadDropTarget,
@@ -2409,6 +2413,7 @@ export function ImageCanvasEditorView({
onOpenRedrawPanel: openRedrawPanel,
onOpenCropExpandPanel: openCropExpandPanel,
onRemoveBackground: removeSelectedLayerBackground,
onPerfectPixel: snapSelectedLayerToPerfectPixels,
onSplitIconSpritesheet: (layer: CanvasLayer) => {
if (persistingAssetKindLayerIdsRef.current.has(layer.id)) {
return;
@@ -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',
]);
@@ -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,8 @@ function renderSelectedToolbar(
onOpenRedrawPanel: vi.fn(),
onOpenCropExpandPanel: vi.fn(),
onRemoveBackground: vi.fn(),
onPerfectPixel: vi.fn(),
isPerfectPixelProcessing: false,
isSplittingIconSpritesheet: false,
isPersistingAssetKind: false,
onSplitIconSpritesheet: vi.fn(),
@@ -66,6 +68,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
'快速编辑',
'裁扩按钮',
'去除背景按钮',
'完美像素',
'改造',
'下载按钮',
]);
@@ -75,6 +78,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 +89,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 +109,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 +137,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
'快速编辑',
'裁扩按钮',
'去除背景按钮',
'完美像素',
'提取素材',
'改造',
'下载按钮',
@@ -142,6 +161,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
'快速编辑',
'裁扩按钮',
'去除背景按钮',
'完美像素',
'拆分图集',
'改造',
'下载按钮',
@@ -184,6 +204,28 @@ 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('does not invoke atlas splitting while the selected asset kind is persisting', () => {
const layer = createLayer({ assetKind: 'icon-spritesheet' });
const props = renderSelectedToolbar({
@@ -227,6 +269,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 +300,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
expect(
within(videoToolbar).queryByRole('button', { name: '去除背景按钮' }),
).toBeNull();
expect(
within(videoToolbar).queryByRole('button', { name: '完美像素' }),
).toBeNull();
expect(
within(videoToolbar)
.getAllByRole('button')
@@ -278,6 +326,9 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
expect(
within(actionToolbar).queryByRole('button', { name: '去除背景按钮' }),
).toBeNull();
expect(
within(actionToolbar).queryByRole('button', { name: '完美像素' }),
).toBeNull();
expect(
within(actionToolbar)
.getAllByRole('button')
@@ -341,6 +392,8 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
onOpenRedrawPanel={vi.fn()}
onOpenCropExpandPanel={vi.fn()}
onRemoveBackground={vi.fn()}
onPerfectPixel={vi.fn()}
isPerfectPixelProcessing={false}
isSplittingIconSpritesheet={false}
isPersistingAssetKind={false}
onSplitIconSpritesheet={vi.fn()}
@@ -360,6 +413,8 @@ describe('ImageCanvasSelectedLayerToolbarView', () => {
onOpenRedrawPanel={vi.fn()}
onOpenCropExpandPanel={vi.fn()}
onRemoveBackground={vi.fn()}
onPerfectPixel={vi.fn()}
isPerfectPixelProcessing={false}
isSplittingIconSpritesheet={false}
isPersistingAssetKind={false}
onSplitIconSpritesheet={vi.fn()}
@@ -1,6 +1,7 @@
import {
Crop,
Download,
Grid2X2,
ImageOff,
Loader2,
PersonStanding,
@@ -22,6 +23,8 @@ type ImageCanvasSelectedLayerToolbarViewProps = {
onOpenRedrawPanel: (layer: CanvasLayer) => void;
onOpenCropExpandPanel: (layer: CanvasLayer) => void;
onRemoveBackground: (layer: CanvasLayer) => void;
onPerfectPixel: (layer: CanvasLayer) => void;
isPerfectPixelProcessing: boolean;
isSplittingIconSpritesheet: boolean;
isPersistingAssetKind: boolean;
onSplitIconSpritesheet: (layer: CanvasLayer) => void;
@@ -37,6 +40,8 @@ export function ImageCanvasSelectedLayerToolbarView({
onOpenRedrawPanel,
onOpenCropExpandPanel,
onRemoveBackground,
onPerfectPixel,
isPerfectPixelProcessing,
isSplittingIconSpritesheet,
isPersistingAssetKind,
onSplitIconSpritesheet,
@@ -120,6 +125,27 @@ export function ImageCanvasSelectedLayerToolbarView({
icon={ImageOff}
onClick={() => onRemoveBackground(selectedLayer)}
/>
<PlatformIconButton
className="image-canvas-editor__floating-toolbar-text-button"
label={isPerfectPixelProcessing ? '完美像素处理中' : '完美像素'}
title={
isPerfectPixelProcessing
? '完美像素处理中'
: '自动识别并规整像素网格'
}
icon={
isPerfectPixelProcessing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Grid2X2 className="h-4 w-4" />
)
}
disabled={isPerfectPixelProcessing}
aria-busy={isPerfectPixelProcessing}
onClick={() => onPerfectPixel(selectedLayer)}
>
<span>{isPerfectPixelProcessing ? '处理中' : '完美像素'}</span>
</PlatformIconButton>
</>
) : null}
{selectedLayer.assetKind === 'icon-spritesheet' ? (
@@ -78,6 +78,7 @@ export type ImageCanvasStageViewProps = {
generationComposerStyle: CSSProperties | null;
selectedToolbarStyle: CSSProperties | null;
splittingIconSpritesheetLayerIds?: ReadonlySet<string>;
perfectPixelLayerIds?: ReadonlySet<string>;
persistingAssetKindLayerIds?: ReadonlySet<string>;
uploadDropTarget: 'canvas' | 'assets' | null;
contextMenu: CanvasContextMenuState | null;
@@ -146,6 +147,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;
@@ -233,6 +235,7 @@ export function ImageCanvasStageView({
generationComposerStyle,
selectedToolbarStyle,
splittingIconSpritesheetLayerIds = EMPTY_LAYER_ID_SET,
perfectPixelLayerIds = EMPTY_LAYER_ID_SET,
persistingAssetKindLayerIds = EMPTY_LAYER_ID_SET,
uploadDropTarget,
contextMenu,
@@ -280,6 +283,7 @@ export function ImageCanvasStageView({
onOpenRedrawPanel,
onOpenCropExpandPanel,
onRemoveBackground,
onPerfectPixel,
onSplitIconSpritesheet,
onExtractUiDesignAssets,
onUiAssetExtractionToolChange,
@@ -404,10 +408,14 @@ export function ImageCanvasStageView({
isPersistingAssetKind={Boolean(
selectedLayer && persistingAssetKindLayerIds.has(selectedLayer.id),
)}
isPerfectPixelProcessing={Boolean(
selectedLayer && perfectPixelLayerIds.has(selectedLayer.id),
)}
onOpenQuickEditPanel={onOpenQuickEditPanel}
onOpenRedrawPanel={onOpenRedrawPanel}
onOpenCropExpandPanel={onOpenCropExpandPanel}
onRemoveBackground={onRemoveBackground}
onPerfectPixel={onPerfectPixel}
onSplitIconSpritesheet={onSplitIconSpritesheet}
onExtractUiDesignAssets={onExtractUiDesignAssets}
onOpenCharacterAnimationPanel={onOpenCharacterAnimationPanel}
@@ -106,6 +106,7 @@ type ImageCanvasGenerationSurfaceOptions = {
project: EditorProjectSnapshot,
action?: CanvasHistoryAction,
) => void;
flushProjectPersistence?: () => Promise<void>;
onWalletBalanceMayHaveChanged?: () => void;
};
@@ -182,6 +183,7 @@ export function useImageCanvasGenerationSurface({
assetFolderId,
upsertGeneratedAsset,
applyProjectSnapshot,
flushProjectPersistence,
onWalletBalanceMayHaveChanged,
}: ImageCanvasGenerationSurfaceOptions) {
const toolbarOptionCloseTimerRef = useRef<ReturnType<
@@ -218,6 +220,7 @@ export function useImageCanvasGenerationSurface({
assetFolderId,
upsertGeneratedAsset,
applyProjectSnapshot,
flushProjectPersistence,
onWalletBalanceMayHaveChanged,
});
@@ -33,6 +33,7 @@ const splitEditorIconSpritesheetMock = vi.hoisted(() => vi.fn());
const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn());
const renderCropExpandImageMock = vi.hoisted(() => vi.fn());
const removeImageBackgroundMock = vi.hoisted(() => vi.fn());
const snapImageToPerfectPixelsMock = vi.hoisted(() => vi.fn());
const resolveEditorImageReferenceDataUrlMock = vi.hoisted(() => vi.fn());
vi.mock('../../services/image-editor/editorImageReference', async () => {
@@ -77,6 +78,7 @@ vi.mock('./ImageCanvasRasterEditModel', async () => {
...actual,
removeImageBackground: removeImageBackgroundMock,
renderCropExpandImage: renderCropExpandImageMock,
snapImageToPerfectPixels: snapImageToPerfectPixelsMock,
};
});
@@ -119,6 +121,8 @@ function GenerationWorkflowHarness({
projectId,
currentUserId,
applyProjectSnapshot,
flushProjectPersistence,
upsertGeneratedAsset,
}: {
initialLayers?: CanvasLayer[];
initialViewport?: { x: number; y: number; scale: number };
@@ -127,6 +131,12 @@ function GenerationWorkflowHarness({
applyProjectSnapshot?: Parameters<
typeof useImageCanvasGenerationWorkflow
>[0]['applyProjectSnapshot'];
flushProjectPersistence?: Parameters<
typeof useImageCanvasGenerationWorkflow
>[0]['flushProjectPersistence'];
upsertGeneratedAsset?: Parameters<
typeof useImageCanvasGenerationWorkflow
>[0]['upsertGeneratedAsset'];
}) {
const [layers, setLayers] = useState<CanvasLayer[]>(initialLayers);
const [viewport, setViewport] = useState(initialViewport);
@@ -175,6 +185,8 @@ function GenerationWorkflowHarness({
projectId,
currentUserId,
applyProjectSnapshot,
flushProjectPersistence,
upsertGeneratedAsset,
});
const activeDialog = dialogs.generateDialog;
@@ -292,6 +304,9 @@ function GenerationWorkflowHarness({
{fitLayersMockRef.current.mock.calls.length}
</span>
<span data-testid="task-refresh-key">{workflow.taskListRefreshKey}</span>
<output aria-label="完美像素状态">
{workflow.perfectPixelLayerIds.has(layers[0]!.id) ? '处理中' : '空闲'}
</output>
<button type="button" onClick={workflow.openGenerateDialog}>
</button>
@@ -529,6 +544,24 @@ function GenerationWorkflowHarness({
>
</button>
<button
type="button"
onClick={() =>
void workflow.snapSelectedLayerToPerfectPixels(layers[0]!)
}
>
</button>
<button
type="button"
onClick={() => {
if (activeCanvasDialog) {
dialogs.removeCanvasGenerationDialogById(activeCanvasDialog.id);
}
}}
>
</button>
<button
type="button"
onClick={() => void workflow.splitSelectedIconSpritesheet(layers[0]!)}
@@ -1443,7 +1476,9 @@ describe('useImageCanvasGenerationWorkflow', () => {
vi.advanceTimersByTime(1);
});
expect(screen.getByTestId('reference-pick-warning').textContent).toBe('-');
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
'-',
);
} finally {
vi.useRealTimers();
}
@@ -1474,7 +1509,9 @@ describe('useImageCanvasGenerationWorkflow', () => {
vi.advanceTimersByTime(2_999);
});
expect(screen.getByTestId('reference-pick-warning').textContent).toBe('-');
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
'-',
);
} finally {
vi.useRealTimers();
}
@@ -2010,9 +2047,9 @@ describe('useImageCanvasGenerationWorkflow', () => {
});
await waitFor(() => {
expect(
screen.getByRole('status', { name: '拆图状态' }).textContent,
).toBe('空闲');
expect(screen.getByRole('status', { name: '拆图状态' }).textContent).toBe(
'空闲',
);
});
});
@@ -2109,6 +2146,314 @@ describe('useImageCanvasGenerationWorkflow', () => {
expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled();
});
it('flushes a closed perfect-pixel placeholder before submitting the stable source', async () => {
const events: string[] = [];
const applyProjectSnapshot = vi.fn();
const upsertGeneratedAsset = vi.fn();
const project = {
projectId: 'project-1',
title: '未命名画布',
canvas: {
canvasId: 'project-1:canvas:default',
projectId: 'project-1',
title: '默认画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
updatedAt: '2026-07-30T00:00:00.000Z',
},
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-07-30T00:00:00.000Z',
};
const asset = {
assetId: 'asset-perfect-pixel',
label: '源图 · 完美像素',
imageSrc: '/generated-images/editor/perfect-pixel.png',
width: 1024,
height: 768,
sourceType: 'generated' as const,
};
const flushProjectPersistence = vi.fn(async () => {
events.push('flush');
expect(screen.getByTestId('dialog').textContent).toBe(
'quick-edit:generating:closed:-:placeholder',
);
});
snapImageToPerfectPixelsMock.mockImplementationOnce(async () => {
events.push('post');
return {
imageSrc: '/generated-images/editor/perfect-pixel.png',
objectKey: 'generated-images/editor/perfect-pixel.png',
assetObjectId: 'asset-object-perfect-pixel',
width: 1024,
height: 768,
sourceType: 'generated',
taskId: 'perfect-pixel-1',
elapsedMs: 250,
provider: 'Genarrative',
resource: { resourceId: 'resource-perfect-pixel' },
asset,
project,
};
});
const sourceLayer = createLayer({
objectKey: 'generated-images/editor/source.png',
src: '/generated-images/editor/source.png',
assetKind: 'character',
generationInputs: {
fields: [{ title: '角色设定', value: '红发骑士' }],
references: [],
},
});
render(
<GenerationWorkflowHarness
projectId="project-1"
initialLayers={[sourceLayer]}
applyProjectSnapshot={applyProjectSnapshot}
flushProjectPersistence={flushProjectPersistence}
upsertGeneratedAsset={upsertGeneratedAsset}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '完美像素' }));
fireEvent.click(screen.getByRole('button', { name: '完美像素' }));
await waitFor(() => {
expect(snapImageToPerfectPixelsMock).toHaveBeenCalledTimes(1);
});
expect(events).toEqual(['flush', 'post']);
expect(flushProjectPersistence).toHaveBeenCalledTimes(1);
expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled();
expect(snapImageToPerfectPixelsMock).toHaveBeenCalledWith(
expect.objectContaining({
sourceImageSrc: 'generated-images/editor/source.png',
projectId: 'project-1',
sourceResourceId: 'resource-source',
assetKind: 'character',
generationInputs: sourceLayer.generationInputs,
assetLabel: '源图 · 完美像素',
canvasCompletion: expect.objectContaining({
dialogId: 'generation-dialog-1',
title: '源图 · 完美像素',
placeholder: expect.objectContaining({
width: 320,
height: 240,
}),
}),
}),
);
expect(upsertGeneratedAsset).toHaveBeenCalledWith(asset);
expect(applyProjectSnapshot).toHaveBeenCalledWith(project, {
type: 'perfect-pixel',
count: 1,
});
await waitFor(() => {
expect(
screen.getByRole('status', { name: '完美像素状态' }).textContent,
).toBe('空闲');
});
});
it('uploads an inline perfect-pixel source before flushing and posting', async () => {
const order: string[] = [];
uploadEditorMediaAssetFileMock.mockImplementationOnce(async () => {
order.push('upload');
return {
src: 'https://signed.example.test/perfect-pixel-source.png',
objectKey: 'generated-images/editor/uploaded-source.png',
assetObjectId: 'asset-object-uploaded-source',
legacyPublicPath: '/generated-images/editor/uploaded-source.png',
};
});
const flushProjectPersistence = vi.fn(async () => {
order.push('flush');
});
snapImageToPerfectPixelsMock.mockImplementationOnce(async () => {
order.push('post');
return {
imageSrc: '/generated-images/editor/perfect-pixel.png',
objectKey: 'generated-images/editor/perfect-pixel.png',
assetObjectId: 'asset-object-perfect-pixel',
width: 1024,
height: 768,
sourceType: 'generated',
taskId: 'perfect-pixel-1',
elapsedMs: 250,
provider: 'Genarrative',
resource: { resourceId: 'resource-perfect-pixel' },
asset: { assetId: 'asset-perfect-pixel' },
project: null,
};
});
render(
<GenerationWorkflowHarness
projectId="project-1"
applyProjectSnapshot={vi.fn()}
flushProjectPersistence={flushProjectPersistence}
initialLayers={[createLayer({ resourceId: 'local-resource-source' })]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '完美像素' }));
await waitFor(() => {
expect(snapImageToPerfectPixelsMock).toHaveBeenCalled();
});
expect(order).toEqual(['upload', 'flush', 'post']);
expect(snapImageToPerfectPixelsMock).toHaveBeenCalledWith(
expect.objectContaining({
sourceImageSrc: 'generated-images/editor/uploaded-source.png',
sourceResourceId: undefined,
}),
);
await waitFor(() => {
expect(screen.getByTestId('dialog').textContent).toBe('-');
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
'完美像素结果已保存到素材库,画布占位已不存在。',
);
});
});
it('keeps a failed perfect-pixel placeholder and releases busy state', async () => {
const applyProjectSnapshot = vi.fn();
snapImageToPerfectPixelsMock.mockRejectedValueOnce(
new Error('像素网格无法识别'),
);
render(
<GenerationWorkflowHarness
projectId="project-1"
applyProjectSnapshot={applyProjectSnapshot}
flushProjectPersistence={vi.fn().mockResolvedValue(undefined)}
initialLayers={[
createLayer({
objectKey: 'generated-images/editor/source.png',
src: '/generated-images/editor/source.png',
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '完美像素' }));
await waitFor(() => {
expect(screen.getByTestId('dialog').textContent).toBe(
'quick-edit:failed:closed:-:placeholder',
);
});
expect(applyProjectSnapshot).not.toHaveBeenCalled();
expect(screen.getByTestId('layers').textContent).toContain(
'/generated-images/editor/source.png',
);
expect(
screen.getByRole('status', { name: '完美像素状态' }).textContent,
).toBe('空闲');
});
it('does not apply a completed project after the perfect-pixel placeholder was deleted', async () => {
let resolvePerfectPixel:
| ((value: {
imageSrc: string;
objectKey: string;
assetObjectId: string;
width: number;
height: number;
sourceType: 'generated';
taskId: string;
elapsedMs: number;
provider: 'Genarrative';
resource: { resourceId: string };
asset: { assetId: string };
project: {
projectId: string;
title: string;
canvas: {
canvasId: string;
projectId: string;
title: string;
viewport: { x: number; y: number; scale: number };
layers: never[];
updatedAt: string;
};
viewport: { x: number; y: number; scale: number };
layers: never[];
resources: never[];
updatedAt: string;
};
}) => void)
| undefined;
const applyProjectSnapshot = vi.fn();
const upsertGeneratedAsset = vi.fn();
snapImageToPerfectPixelsMock.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolvePerfectPixel = resolve;
}),
);
render(
<GenerationWorkflowHarness
projectId="project-1"
applyProjectSnapshot={applyProjectSnapshot}
flushProjectPersistence={vi.fn().mockResolvedValue(undefined)}
upsertGeneratedAsset={upsertGeneratedAsset}
initialLayers={[
createLayer({
objectKey: 'generated-images/editor/source.png',
src: '/generated-images/editor/source.png',
}),
]}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '完美像素' }));
await waitFor(() => {
expect(snapImageToPerfectPixelsMock).toHaveBeenCalled();
});
fireEvent.click(screen.getByRole('button', { name: '删除处理占位' }));
expect(screen.getByTestId('dialog').textContent).toBe('-');
resolvePerfectPixel?.({
imageSrc: '/generated-images/editor/perfect-pixel.png',
objectKey: 'generated-images/editor/perfect-pixel.png',
assetObjectId: 'asset-object-perfect-pixel',
width: 1024,
height: 768,
sourceType: 'generated',
taskId: 'perfect-pixel-1',
elapsedMs: 250,
provider: 'Genarrative',
resource: { resourceId: 'resource-perfect-pixel' },
asset: { assetId: 'asset-perfect-pixel' },
project: {
projectId: 'project-1',
title: '未命名画布',
canvas: {
canvasId: 'project-1:canvas:default',
projectId: 'project-1',
title: '默认画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
updatedAt: '2026-07-30T00:00:00.000Z',
},
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-07-30T00:00:00.000Z',
},
});
await waitFor(() => {
expect(
screen.getByRole('status', { name: '完美像素状态' }).textContent,
).toBe('空闲');
});
expect(upsertGeneratedAsset).toHaveBeenCalledWith({
assetId: 'asset-perfect-pixel',
});
expect(applyProjectSnapshot).not.toHaveBeenCalled();
expect(screen.getByTestId('dialog').textContent).toBe('-');
});
it('opens UI design extraction as a mark selection state before submitting', () => {
render(<GenerationWorkflowHarness />);
@@ -93,6 +93,7 @@ import {
buildCropExpandInsetsFromFrame,
removeImageBackground,
renderCropExpandImage,
snapImageToPerfectPixels,
} from './ImageCanvasRasterEditModel';
import {
createUiAssetExtractionDraftMark,
@@ -628,6 +629,7 @@ type GenerationWorkflowOptions = {
project: EditorProjectSnapshot,
action?: CanvasHistoryAction,
) => void;
flushProjectPersistence?: () => Promise<void>;
onWalletBalanceMayHaveChanged?: () => void;
};
@@ -662,6 +664,7 @@ export function useImageCanvasGenerationWorkflow({
assetFolderId,
upsertGeneratedAsset,
applyProjectSnapshot,
flushProjectPersistence,
onWalletBalanceMayHaveChanged,
}: GenerationWorkflowOptions) {
const [isTaskSidebarOpen, setIsTaskSidebarOpen] = useState(false);
@@ -677,6 +680,10 @@ export function useImageCanvasGenerationWorkflow({
const splittingIconSpritesheetLayerIdsRef = useRef(new Set<string>());
const [splittingIconSpritesheetLayerIds, setSplittingIconSpritesheetLayerIds] =
useState<Set<string>>(() => new Set());
const perfectPixelLayerIdsRef = useRef(new Set<string>());
const [perfectPixelLayerIds, setPerfectPixelLayerIds] = useState<Set<string>>(
() => new Set(),
);
const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(false);
const [isGenerationReferenceMenuOpen, setIsGenerationReferenceMenuOpen] =
useState(false);
@@ -1734,6 +1741,160 @@ export function useImageCanvasGenerationWorkflow({
],
);
const snapSelectedLayerToPerfectPixels = useCallback(
async (sourceLayer: CanvasLayer) => {
const normalizedProjectId = projectId?.trim();
const isStaticRasterLayer =
(sourceLayer.mediaType === undefined ||
sourceLayer.mediaType === 'image') &&
sourceLayer.assetKind !== 'character-animation';
if (
!isStaticRasterLayer ||
!normalizedProjectId ||
!applyProjectSnapshot ||
!flushProjectPersistence ||
perfectPixelLayerIdsRef.current.has(sourceLayer.id)
) {
if (
isStaticRasterLayer &&
(!normalizedProjectId ||
!applyProjectSnapshot ||
!flushProjectPersistence)
) {
showGenerationWarning('项目尚未准备好,暂时无法执行完美像素。');
}
return;
}
perfectPixelLayerIdsRef.current.add(sourceLayer.id);
setPerfectPixelLayerIds((currentLayerIds) => {
const nextLayerIds = new Set(currentLayerIds);
nextLayerIds.add(sourceLayer.id);
return nextLayerIds;
});
closeGenerationTransientState();
setImageContextMenu(null);
setMetadataLayer(null);
setCropExpandPanel(null);
setQuickEditPanel(null);
setCharacterAnimationPanel(null);
let perfectPixelDialogId: string | undefined;
try {
const assetLabel = `${sourceLayer.title} · `;
const placement = openPlacedCanvasGenerationDialog({
...createQuickEditGenerationDialogDraft({
sourceLayer,
prompt: '完美像素',
assetLabel,
status: 'generating',
frame: {
width: sourceLayer.width,
height: sourceLayer.height,
},
}),
composerOpen: false,
});
perfectPixelDialogId = placement.dialogId;
if (!placement.placeholder) {
throw new Error('无法创建完美像素处理占位');
}
const sourceImageSrc = await resolveEditorGenerationMediaReference(
sourceLayer,
'image',
normalizedProjectId,
);
await flushProjectPersistence();
const sourceResourceId = sourceLayer.resourceId.trim();
const result = await snapImageToPerfectPixels({
sourceImageSrc,
projectId: normalizedProjectId,
sourceResourceId:
sourceResourceId &&
!sourceResourceId.startsWith('local-resource-') &&
!sourceResourceId.startsWith('generation-dialog:')
? sourceResourceId
: undefined,
assetKind: sourceLayer.assetKind,
generationInputs: sourceLayer.generationInputs,
assetFolderId,
assetLabel,
canvasCompletion: {
dialogId: perfectPixelDialogId,
title: assetLabel,
placeholder: placement.placeholder,
},
});
upsertGeneratedAsset?.(result.asset);
if (!result.project) {
if (hasCanvasGenerationDialogById(perfectPixelDialogId)) {
updateCanvasGenerationDialogById(perfectPixelDialogId, () => null);
showGenerationWarning(
'完美像素结果已保存到素材库,画布占位已不存在。',
);
}
return;
}
if (!hasCanvasGenerationDialogById(perfectPixelDialogId)) {
return;
}
applyProjectSnapshot(result.project, {
type: 'perfect-pixel',
count: 1,
});
setActiveTool('select');
setActiveSidebarPanel('layers');
} catch (error) {
const errorMessage =
error instanceof Error && error.message.trim()
? error.message
: '完美像素处理失败';
if (
perfectPixelDialogId &&
hasCanvasGenerationDialogById(perfectPixelDialogId)
) {
updateCanvasGenerationDialogById(perfectPixelDialogId, (dialog) => ({
...dialog,
status: 'failed',
errorMessage,
}));
} else if (!perfectPixelDialogId) {
showGenerationWarning(errorMessage);
}
} finally {
perfectPixelLayerIdsRef.current.delete(sourceLayer.id);
setPerfectPixelLayerIds((currentLayerIds) => {
if (!currentLayerIds.has(sourceLayer.id)) {
return currentLayerIds;
}
const nextLayerIds = new Set(currentLayerIds);
nextLayerIds.delete(sourceLayer.id);
return nextLayerIds;
});
}
},
[
applyProjectSnapshot,
assetFolderId,
closeGenerationTransientState,
flushProjectPersistence,
hasCanvasGenerationDialogById,
openPlacedCanvasGenerationDialog,
projectId,
setActiveSidebarPanel,
setActiveTool,
setCharacterAnimationPanel,
setCropExpandPanel,
setImageContextMenu,
setMetadataLayer,
setQuickEditPanel,
showGenerationWarning,
updateCanvasGenerationDialogById,
upsertGeneratedAsset,
],
);
const splitSelectedIconSpritesheet = useCallback(
async (sourceLayer: CanvasLayer) => {
if (
@@ -2576,6 +2737,8 @@ export function useImageCanvasGenerationWorkflow({
openCropExpandPanel,
startCropExpandFrameResize,
removeSelectedLayerBackground,
snapSelectedLayerToPerfectPixels,
perfectPixelLayerIds,
splitSelectedIconSpritesheet,
splittingIconSpritesheetLayerIds,
taskListRefreshKey,
@@ -2679,6 +2842,8 @@ export function useImageCanvasGenerationWorkflow({
quickEditPanel,
quickEditSourceLayer,
removeSelectedLayerBackground,
snapSelectedLayerToPerfectPixels,
perfectPixelLayerIds,
splitSelectedIconSpritesheet,
splittingIconSpritesheetLayerIds,
submitCharacterAnimation,
@@ -25,6 +25,7 @@ import {
removeEditorImageBackground,
renameEditorProject,
saveEditorProjectLayout,
snapEditorImageToPixelArt,
splitEditorIconSpritesheet,
submitEditorAssetShowcase,
toggleEditorShowcaseAssetLike,
@@ -1741,4 +1742,103 @@ describe('editorProjectClient', () => {
}),
);
});
it('submits a stable perfect-pixel source without unsafe POST retries', async () => {
requestJsonMock.mockResolvedValueOnce({
imageSrc: '/generated-images/editor/perfect-pixel.png',
objectKey: 'generated-images/editor/perfect-pixel.png',
assetObjectId: 'asset-object-perfect-pixel',
width: 1024,
height: 768,
sourceType: 'generated',
taskId: 'perfect-pixel-1',
elapsedMs: 321,
provider: 'Genarrative',
resource: { resourceId: 'resource-perfect-pixel' },
asset: { assetId: 'asset-perfect-pixel' },
project: null,
});
const result = await snapEditorImageToPixelArt({
sourceImageSrc: 'generated-images/editor/source.png',
projectId: 'editor-project-1',
sourceResourceId: 'resource-source',
assetKind: 'character',
generationInputs: {
fields: [{ title: '角色设定', value: '红发骑士' }],
references: [],
},
assetFolderId: 'project',
assetLabel: '源图 · 完美像素',
canvasCompletion: {
dialogId: 'generation-dialog-perfect-pixel',
title: '源图 · 完美像素',
placeholder: {
x: 472,
y: 140,
width: 320,
height: 240,
originalWidth: 320,
originalHeight: 240,
},
},
});
expect(result.taskId).toBe('perfect-pixel-1');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/editor/images/pixel-art-snaps',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sourceImageSrc: 'generated-images/editor/source.png',
projectId: 'editor-project-1',
sourceResourceId: 'resource-source',
assetKind: 'character',
generationInputs: {
fields: [{ title: '角色设定', value: '红发骑士' }],
references: [],
},
assetFolderId: 'project',
assetLabel: '源图 · 完美像素',
canvasCompletion: {
dialogId: 'generation-dialog-perfect-pixel',
title: '源图 · 完美像素',
placeholder: {
x: 472,
y: 140,
width: 320,
height: 240,
originalWidth: 320,
originalHeight: 240,
},
},
}),
},
'完美像素处理失败',
{ timeoutMs: 120_000 },
);
});
it('rejects inline perfect-pixel media before sending the request', async () => {
await expect(
snapEditorImageToPixelArt({
sourceImageSrc: 'data:image/png;base64,source',
projectId: 'editor-project-1',
canvasCompletion: {
dialogId: 'generation-dialog-perfect-pixel',
title: '源图 · 完美像素',
placeholder: {
x: 0,
y: 0,
width: 320,
height: 240,
originalWidth: 320,
originalHeight: 240,
},
},
}),
).rejects.toThrow('待完美像素处理图片必须先上传 OSS');
expect(requestJsonMock).not.toHaveBeenCalled();
});
});
@@ -10,6 +10,7 @@ const EDITOR_PROJECT_RESOURCE_API_BASE = '/api/editor/project-resources';
const EDITOR_IMAGE_GENERATION_API = '/api/editor/images/generations';
const EDITOR_IMAGE_EDIT_API = '/api/editor/images/edits';
const EDITOR_BACKGROUND_REMOVAL_API = '/api/editor/images/background-removals';
const EDITOR_PIXEL_ART_SNAP_API = '/api/editor/images/pixel-art-snaps';
const EDITOR_ICON_SPRITESHEET_GENERATION_API =
'/api/editor/icon-spritesheets/generations';
const EDITOR_ICON_SPRITESHEET_SLICE_API =
@@ -294,6 +295,19 @@ export type EditorBackgroundRemovalInput = {
canvasCompletion?: EditorCanvasGenerationCompletionInput | null;
};
export type EditorPixelArtSnapInput = {
sourceImageSrc: string;
projectId: string;
sourceResourceId?: string | null;
assetKind?: string | null;
generationInputs?: EditorAssetGenerationInputs | null;
assetFolderId?: string | null;
assetLabel?: string | null;
canvasCompletion: EditorCanvasGenerationCompletionInput & {
dialogId: string;
};
};
export type EditorImageGenerationResult = {
imageSrc: string;
objectKey?: string | null;
@@ -317,6 +331,21 @@ export type EditorBackgroundRemovalResult = {
queueState: ExternalGenerationJobStatusRecord;
};
export type EditorPixelArtSnapResult = {
imageSrc: string;
objectKey: string;
assetObjectId: string;
width: number;
height: number;
sourceType: 'generated';
taskId: string;
elapsedMs: number;
provider: 'Genarrative';
resource: EditorProjectResourceSnapshot;
asset: EditorAssetSnapshot;
project: EditorProjectSnapshot | null;
};
export type EditorIconSpritesheetIconResult = {
name: string;
imageSrc: string;
@@ -659,6 +688,7 @@ type EditorShowcaseAssetResponse = {
type EditorImageGenerationResponse = EditorImageGenerationResult;
type EditorBackgroundRemovalResponse = EditorBackgroundRemovalResult;
type EditorPixelArtSnapResponse = EditorPixelArtSnapResult;
type EditorIconSpritesheetGenerationResponse =
EditorIconSpritesheetGenerationResult;
type EditorVideoGenerationResponse = EditorVideoGenerationResult;
@@ -1114,6 +1144,33 @@ export async function removeEditorImageBackground(
);
}
export async function snapEditorImageToPixelArt(
input: EditorPixelArtSnapInput,
) {
assertStableEditorMediaReference(input.sourceImageSrc, '待完美像素处理图片');
return requestJson<EditorPixelArtSnapResponse>(
EDITOR_PIXEL_ART_SNAP_API,
jsonRequest('POST', {
sourceImageSrc: input.sourceImageSrc,
projectId: input.projectId,
...(input.sourceResourceId
? { sourceResourceId: input.sourceResourceId }
: {}),
...(input.assetKind ? { assetKind: input.assetKind } : {}),
...(input.generationInputs
? { generationInputs: input.generationInputs }
: {}),
...(input.assetFolderId ? { assetFolderId: input.assetFolderId } : {}),
...(input.assetLabel ? { assetLabel: input.assetLabel } : {}),
canvasCompletion: input.canvasCompletion,
}),
'完美像素处理失败',
{
timeoutMs: 120_000,
},
);
}
export async function generateEditorCharacterAnimation(
input: EditorCharacterAnimationGenerationInput,
) {