新增画布完美像素功能

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

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

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

修复 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
@@ -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 />);