新增图片生成像素艺术后处理
新增确定性像素网格规整模块并保留第三方 MIT 许可证。 为普通图片、角色和图标图集接入像素艺术选项、并发预算和失败降级。 同步前端交互、请求契约、OpenAPI、项目文档与测试。
This commit is contained in:
@@ -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',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user