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