新增画布完美像素功能
在图片工具栏接入完美像素交互、占位流程与历史保护 新增严格像素网格检测和登录态同步处理接口 补齐素材类型校验、持久化边界、并发限制及技术文档 修复 Windows rlib 门禁长文件名解析
This commit is contained in:
@@ -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,
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user