新增图片生成像素艺术后处理

新增确定性像素网格规整模块并保留第三方 MIT 许可证。

为普通图片、角色和图标图集接入像素艺术选项、并发预算和失败降级。

同步前端交互、请求契约、OpenAPI、项目文档与测试。
This commit is contained in:
2026-07-29 03:46:03 +00:00
parent 271d5d7bd3
commit 732fcf0b45
25 changed files with 2343 additions and 72 deletions
@@ -836,6 +836,10 @@ describe('ImageCanvasEditorView generation integration', () => {
name: '生成图片模型 nanobanana2',
}).className,
).toContain('platform-inline-option-button');
const pixelArtToggle = within(generateDialog).getByRole('checkbox', {
name: '像素艺术',
}) as HTMLInputElement;
expect(pixelArtToggle.checked).toBe(false);
expect(
within(generateDialog).getByRole('button', { name: '生成' }).className,
).toContain('platform-button');
@@ -847,6 +851,7 @@ describe('ImageCanvasEditorView generation integration', () => {
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '一张明亮的拼图主视觉' },
});
fireEvent.click(pixelArtToggle);
fireEvent.click(
within(generateDialog).getByRole('button', { name: '生成' }),
);
@@ -857,6 +862,7 @@ describe('ImageCanvasEditorView generation integration', () => {
expect.objectContaining({
prompt: '一张明亮的拼图主视觉',
model: 'gemini-3.1-flash-image-preview',
style: 'pixelArt',
aspectRatio: '1:1',
imageSize: '1K',
projectId: 'editor-project-default',
@@ -865,6 +871,11 @@ describe('ImageCanvasEditorView generation integration', () => {
}),
);
});
const submittedGenerationInputs =
generateEditorImageMock.mock.calls[0]?.[0]?.generationInputs;
expect(JSON.stringify(submittedGenerationInputs)).not.toContain(
'pixelArt',
);
await waitFor(() => {
expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy();
@@ -7,6 +7,7 @@ import {
createLayerFromAsset,
DEFAULT_CANVAS_BACKGROUND_COLOR,
formatCanvasDisplayScalePercent,
hydrateCanvasGenerationDialog,
hydrateLayer,
normalizeAssetLibrary,
normalizeCanvasBackgroundHex,
@@ -631,6 +632,7 @@ describe('ImageCanvasEditorModel', () => {
composerOpen: false,
generatedLayerId: 'layer-generated',
imageModel: 'gpt-image-2',
style: 'pixelArt',
generationStartedAt: 1_771_400_000_000,
generationFinishedAt: 1_771_400_004_000,
placeholder: {
@@ -688,6 +690,7 @@ describe('ImageCanvasEditorModel', () => {
status: 'generating',
generatedLayerId: 'layer-generated',
imageModel: 'gpt-image-2',
style: 'pixelArt',
generationStartedAt: 1_771_400_000_000,
generationFinishedAt: 1_771_400_004_000,
placeholder: {
@@ -704,6 +707,35 @@ describe('ImageCanvasEditorModel', () => {
});
});
it('defaults restored supported image styles to none and drops them from other modes', () => {
expect(
hydrateCanvasGenerationDialog({
id: 'generation-dialog-legacy',
mode: 'generate',
prompt: '旧任务',
status: 'idle',
})?.style,
).toBe('none');
expect(
hydrateCanvasGenerationDialog({
id: 'generation-dialog-unknown-style',
mode: 'character',
prompt: '未知风格',
status: 'idle',
style: 'futureStyle',
})?.style,
).toBe('none');
expect(
hydrateCanvasGenerationDialog({
id: 'generation-dialog-spec',
mode: 'spec',
prompt: '规范任务',
status: 'idle',
style: 'pixelArt',
})?.style,
).toBeUndefined();
});
it('drops restored generator references owned by another user', () => {
const dialog: CanvasGenerationDialogState = {
id: 'generation-dialog-owner',
@@ -537,6 +537,14 @@ export function hydrateCanvasGenerationDialog(
if (!id || !isCanvasGenerationDialogMode(snapshot.mode)) {
return null;
}
const style =
snapshot.mode === 'generate' ||
snapshot.mode === 'character' ||
snapshot.mode === 'icon'
? snapshot.style === 'pixelArt'
? 'pixelArt'
: 'none'
: undefined;
return {
id,
@@ -601,6 +609,7 @@ export function hydrateCanvasGenerationDialog(
currentUserId,
),
imageModel: stringOrUndefined(snapshot.imageModel),
style,
videoModel:
typeof snapshot.videoModel === 'string'
? snapshot.videoModel
@@ -5,6 +5,7 @@ import type {
EditorCharacterAnimationGenerationResult,
EditorCharacterAnimationRatio,
EditorCharacterAnimationResolution,
EditorImageGenerationStyle,
EditorVideoAspectRatio,
EditorVideoModel,
EditorVideoResolution,
@@ -215,6 +216,7 @@ export type GenerateDialogState = {
publicationReferences?: CharacterReferenceImage[];
uiDesignSpecReference?: CharacterReferenceImage | null;
imageModel?: string;
style?: EditorImageGenerationStyle;
videoModel?: EditorVideoModel;
videoAspectRatio?: EditorVideoAspectRatio;
videoResolution?: EditorVideoResolution;
@@ -69,6 +69,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
mode: 'generate',
status: 'idle',
composerOpen: true,
style: 'none',
placeholder: {
x: -312,
y: -332,
@@ -156,6 +157,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
}),
).toMatchObject({
mode: 'character',
style: 'none',
imageModel: 'gpt-image-2',
aspectRatio: '1:1',
imageSize: '1K',
@@ -176,6 +178,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
}),
).toMatchObject({
mode: 'icon',
style: 'none',
imageModel: 'unknown-model',
aspectRatio: '1:1',
imageSize: '1K',
@@ -609,6 +612,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
imageModel: 'gpt-image-2',
aspectRatio: '2:3',
imageSize: '2K',
style: 'pixelArt',
},
mode: 'redraw',
}),
@@ -619,6 +623,7 @@ describe('ImageCanvasGenerationDialogModel', () => {
imageModel: 'gpt-image-2',
aspectRatio: '2:3',
imageSize: '2K',
style: 'none',
placeholder: {
x: 472,
y: 140,
@@ -196,6 +196,7 @@ export function createGenerateDialogDraft({
prompt: '',
status: 'idle',
composerOpen: true,
style: 'none',
imageModel: DEFAULT_IMAGE_MODEL,
aspectRatio: dimensionDefaults.aspectRatio,
imageSize: dimensionDefaults.imageSize,
@@ -269,6 +270,7 @@ export function createCharacterGenerationDialogDraft({
prompt: '',
status: 'idle',
composerOpen: true,
style: 'none',
characterSpecReference: null,
characterReferences: [],
imageModel: normalizedImageModel,
@@ -307,6 +309,7 @@ export function createIconGenerationDialogDraft({
prompt: '',
status: 'idle',
composerOpen: true,
style: 'none',
iconSpecReference: null,
generationReferences: [],
iconDescriptions: [],
@@ -47,6 +47,7 @@ function ImageOptionsHarness({
<output aria-label="当前模型">{dialog.imageModel}</output>
<output aria-label="当前比例">{dialog.aspectRatio}</output>
<output aria-label="当前尺寸">{dialog.imageSize}</output>
<output aria-label="当前风格">{dialog.style ?? '-'}</output>
<output aria-label="当前状态">{dialog.status}</output>
<output aria-label="当前错误">{dialog.errorMessage ?? '-'}</output>
<output aria-label="当前占位">
@@ -66,6 +67,67 @@ function ImageOptionsHarness({
}
describe('ImageCanvasGenerationImageOptionsView', () => {
it.each(['generate', 'character', 'icon'] as const)(
'shows and updates the pixel art style for %s generation',
(mode) => {
render(
<ImageOptionsHarness
initialDialog={{
mode,
prompt: '',
status: 'failed',
errorMessage: '旧错误',
style: 'none',
imageModel: IMAGE_MODEL_NANOBANANA2,
aspectRatio: '1:1',
imageSize: '1K',
}}
/>,
);
const toggle = screen.getByRole('checkbox', {
name: '像素艺术',
}) as HTMLInputElement;
const modelButton = screen.getByRole('button', {
name: '生成图片模型 nanobanana2',
});
expect(toggle.checked).toBe(false);
expect(
toggle.compareDocumentPosition(modelButton) &
Node.DOCUMENT_POSITION_FOLLOWING,
).not.toBe(0);
fireEvent.click(toggle);
expect(toggle.checked).toBe(true);
expect(screen.getByLabelText('当前风格').textContent).toBe('pixelArt');
expect(screen.getByLabelText('当前状态').textContent).toBe('idle');
expect(screen.getByLabelText('当前错误').textContent).toBe('-');
},
);
it.each(['spec', 'quick-edit', 'ui-design', 'publication'] as const)(
'does not show the pixel art style for %s generation',
(mode) => {
render(
<ImageOptionsHarness
initialDialog={{
mode,
prompt: '',
status: 'idle',
imageModel: IMAGE_MODEL_NANOBANANA2,
aspectRatio: '1:1',
imageSize: '1K',
}}
/>,
);
expect(
screen.queryByRole('checkbox', { name: '像素艺术' }),
).toBeNull();
},
);
it('updates dimensions from a menu, keeps the menu open and marks the selection', () => {
render(
<ImageOptionsHarness
@@ -137,6 +137,10 @@ export function ImageCanvasGenerationImageOptionsView({
const dimensionsButtonRef = useRef<HTMLButtonElement | null>(null);
const modelButtonRef = useRef<HTMLButtonElement | null>(null);
const isQuickEdit = dialog.mode === 'quick-edit';
const supportsImageStyle =
dialog.mode === 'generate' ||
dialog.mode === 'character' ||
dialog.mode === 'icon';
const normalizedLockedModel = lockedModel
? normalizeEditorImageModel(lockedModel)
: null;
@@ -306,6 +310,21 @@ export function ImageCanvasGenerationImageOptionsView({
: null}
</div>
) : null}
{supportsImageStyle ? (
<label className="image-canvas-editor__image-style-toggle">
<input
type="checkbox"
checked={dialog.style === 'pixelArt'}
disabled={isGenerating}
onChange={(event) =>
updateDialog({
style: event.target.checked ? 'pixelArt' : 'none',
})
}
/>
<span></span>
</label>
) : null}
{includeModel ? (
<div
className={[
@@ -47,6 +47,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
input: {
prompt: '一张发光主视觉',
model: 'gemini-3.1-flash-image-preview',
style: 'none',
aspectRatio: '1:1',
imageSize: '1K',
},
@@ -193,6 +194,24 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
});
});
it('does not send image style from an incomplete quick-edit snapshot', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'quick-edit',
prompt: '把当前图改成雨天',
status: 'idle',
style: 'pixelArt',
},
layers: [],
nextGeneratedIndex: 4,
});
expect(plan.kind).toBe('image');
expect(plan.kind === 'image' ? plan.input : null).not.toHaveProperty(
'style',
);
});
it('builds spec generation plans with reference prompt semantics', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
@@ -294,6 +313,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
prompt: ' 白发骑士 ',
status: 'idle',
imageModel: 'gpt-image-2',
style: 'pixelArt',
aspectRatio: '2:3',
imageSize: '2K',
characterSpecReference: {
@@ -324,6 +344,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
model: 'gpt-image-2',
screenColor: 'auto',
segModel: 'birefnet',
style: 'pixelArt',
aspectRatio: '2:3',
imageSize: '2K',
referenceImageSrcs: [
@@ -432,6 +453,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
mode: 'publication',
prompt: '',
status: 'idle',
style: 'pixelArt',
publicationGameInfo: {
gameName: ' 重庆洪崖洞火锅 ',
gameCategories: '抓大鹅、休闲、治愈、手绘风',
@@ -665,6 +687,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
prompt: ' 返回按钮 \n\n设置按钮',
status: 'idle',
imageModel: 'gpt-image-2',
style: 'pixelArt',
aspectRatio: '3:2',
imageSize: '2K',
assetLabel: ' 冒险游戏图标 ',
@@ -695,6 +718,7 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
model: 'gpt-image-2',
screenColor: 'auto',
segModel: 'birefnet',
style: 'pixelArt',
aspectRatio: '3:2',
imageSize: '2K',
},
@@ -354,6 +354,7 @@ export function buildImageGenerationSubmissionPlan({
model: imageModel,
screenColor,
segModel,
style: dialog.style === 'pixelArt' ? 'pixelArt' : 'none',
aspectRatio: dialog.aspectRatio ?? '1:1',
imageSize: dialog.imageSize ?? '1K',
...(referenceImageSrcs.length ? { referenceImageSrcs } : {}),
@@ -564,6 +565,9 @@ export function buildImageGenerationSubmissionPlan({
input: {
prompt: normalizedPrompt,
model: imageModel,
...(dialog.mode === 'generate'
? { style: dialog.style === 'pixelArt' ? 'pixelArt' : 'none' }
: {}),
aspectRatio: dialog.aspectRatio ?? '1:1',
imageSize: dialog.imageSize ?? '1K',
...(dialog.generationReferences?.length
@@ -639,6 +643,7 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
model: rememberImageModel,
screenColor,
segModel,
style: dialog.style === 'pixelArt' ? 'pixelArt' : 'none',
aspectRatio: dialog.aspectRatio ?? '1:1',
imageSize: dialog.imageSize ?? '1K',
},
+32
View File
@@ -7079,6 +7079,33 @@ button.image-canvas-editor__reference-chip:disabled {
width: 8.5rem;
}
.image-canvas-editor__image-style-toggle {
grid-column: 3;
display: inline-flex;
min-height: 2.25rem;
align-items: center;
justify-self: end;
gap: 0.38rem;
padding: 0 0.5rem;
color: #475569;
font-size: 0.78rem;
font-weight: 760;
white-space: nowrap;
cursor: pointer;
}
.image-canvas-editor__image-style-toggle input {
width: 0.95rem;
height: 0.95rem;
margin: 0;
accent-color: var(--image-canvas-brand-fill);
}
.image-canvas-editor__image-style-toggle:has(input:disabled) {
color: #94a3b8;
cursor: wait;
}
.image-canvas-editor__model-trigger-label {
display: inline-flex;
min-width: 0;
@@ -8904,6 +8931,7 @@ button.image-canvas-editor__reference-chip:disabled {
.image-canvas-editor__option-cluster--dimensions,
.image-canvas-editor__option-cluster--model,
.image-canvas-editor__image-style-toggle,
.image-canvas-editor__readonly-generation-option,
.image-canvas-editor__generation-submit,
.image-canvas-editor__character-animation-submit {
@@ -8913,6 +8941,10 @@ button.image-canvas-editor__reference-chip:disabled {
justify-self: stretch;
}
.image-canvas-editor__image-style-toggle {
justify-content: flex-start;
}
.image-canvas-editor__generation-submit {
width: 100%;
}
@@ -838,6 +838,7 @@ describe('editorProjectClient', () => {
model: 'gpt-image-2',
screenColor: '#FFD6C2',
segModel: 'anime-seg',
style: 'pixelArt',
aspectRatio: '2:3',
imageSize: '1K',
projectId: 'editor-project-1',
@@ -862,6 +863,7 @@ describe('editorProjectClient', () => {
model: 'gpt-image-2',
screenColor: '#FFD6C2',
segModel: 'anime-seg',
style: 'pixelArt',
aspectRatio: '2:3',
imageSize: '1K',
projectId: 'editor-project-1',
@@ -1015,6 +1017,7 @@ describe('editorProjectClient', () => {
model: 'gpt-image-2',
screenColor: '#E6D8FF',
segModel: 'anime-seg',
style: 'none',
aspectRatio: '1:1',
imageSize: '2K',
projectId: 'editor-project-1',
@@ -1036,6 +1039,7 @@ describe('editorProjectClient', () => {
model: 'gpt-image-2',
screenColor: '#E6D8FF',
segModel: 'anime-seg',
style: 'none',
aspectRatio: '1:1',
imageSize: '2K',
projectId: 'editor-project-1',
@@ -184,6 +184,8 @@ export type EditorAssetLibrarySnapshot = {
assets: EditorAssetSnapshot[];
};
export type EditorImageGenerationStyle = 'none' | 'pixelArt';
export type EditorImageGenerationInput = {
prompt: string;
size?: string;
@@ -196,6 +198,7 @@ export type EditorImageGenerationInput = {
model?: string;
screenColor?: string;
segModel?: string;
style?: EditorImageGenerationStyle;
aspectRatio?: string;
imageSize?: string;
referenceImageSrcs?: string[];
@@ -228,6 +231,7 @@ export type EditorIconSpritesheetGenerationInput = {
model?: string;
screenColor?: string;
segModel?: string;
style?: EditorImageGenerationStyle;
aspectRatio?: string;
imageSize?: string;
projectId?: string | null;
@@ -920,6 +924,7 @@ export async function generateEditorImage(input: EditorImageGenerationInput) {
...(input.model ? { model: input.model } : {}),
...(input.screenColor ? { screenColor: input.screenColor } : {}),
...(input.segModel ? { segModel: input.segModel } : {}),
...(input.style ? { style: input.style } : {}),
...(input.aspectRatio ? { aspectRatio: input.aspectRatio } : {}),
...(input.imageSize ? { imageSize: input.imageSize } : {}),
...(input.referenceImageSrcs?.length
@@ -966,6 +971,7 @@ export async function generateEditorIconSpritesheet(
model: input.model?.trim() || EDITOR_IMAGE_MODEL_NANOBANANA2,
...(input.screenColor ? { screenColor: input.screenColor } : {}),
...(input.segModel ? { segModel: input.segModel } : {}),
...(input.style ? { style: input.style } : {}),
...(input.aspectRatio ? { aspectRatio: input.aspectRatio } : {}),
...(input.imageSize ? { imageSize: input.imageSize } : {}),
...(input.projectId ? { projectId: input.projectId } : {}),