拆分编辑器生成提交模型

抽出图片生成请求与结果快照构建逻辑

补充生成提交模型单测

更新 TRACKING.md 记录第三十五阶段验证
This commit is contained in:
2026-06-17 17:57:34 +08:00
parent d8b935317d
commit 4abf00d007
4 changed files with 374 additions and 81 deletions

View File

@@ -0,0 +1,212 @@
import { describe, expect, it } from 'vitest';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
import { buildImageGenerationSubmissionPlan } from './ImageCanvasGenerationSubmissionModel';
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
return {
id: 'layer-source',
resourceId: 'resource-source',
title: '源图',
src: 'data:image/png;base64,source',
x: 120,
y: 140,
width: 320,
height: 240,
originalWidth: 1024,
originalHeight: 768,
zIndex: 2,
sourceType: 'uploaded',
...overrides,
};
}
describe('ImageCanvasGenerationSubmissionModel', () => {
it('builds normal image generation submission plans', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'generate',
prompt: ' 一张发光主视觉 ',
status: 'idle',
},
layers: [],
nextGeneratedIndex: 3,
});
expect(plan).toEqual({
kind: 'image',
normalizedPrompt: '一张发光主视觉',
input: {
prompt: '一张发光主视觉',
},
result: {
generationInputs: {
fields: [{ title: '生成提示词', value: '一张发光主视觉' }],
references: [],
},
},
});
});
it('builds edit submission plans with the source layer snapshot', () => {
const sourceLayer = createLayer();
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'edit',
prompt: '',
status: 'idle',
sourceLayerId: sourceLayer.id,
},
layers: [sourceLayer],
nextGeneratedIndex: 4,
});
expect(plan).toMatchObject({
kind: 'edit',
normalizedPrompt: '修改当前图片',
sourceLayer,
generationInputs: {
fields: [{ title: '修改要求', value: '修改当前图片' }],
references: [
{
title: '参考图',
label: '源图',
src: 'data:image/png;base64,source',
},
],
},
});
});
it('builds spec generation plans with reference prompt semantics', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'spec',
prompt: '',
status: 'idle',
specType: 'icon',
specValues: {
playSetting: '休闲消除',
artStyle: '清爽卡通',
bodyRatio: '3',
characterView: '',
customPrompt: '',
},
specReference: {
id: 'spec-ref',
label: '参考.png',
src: 'data:image/png;base64,ref',
},
},
layers: [],
nextGeneratedIndex: 8,
});
expect(plan).toMatchObject({
kind: 'image',
normalizedPrompt: 'AI 生成图片',
input: {
size: '2048x1152',
kind: 'spec',
referenceImageSrcs: ['data:image/png;base64,ref'],
prompt: expect.stringContaining('参考图生成规范'),
},
result: {
assetKind: 'icon-spec',
title: '图标素材规范 8',
},
});
expect(plan.kind === 'image' ? plan.result.generationInputs : null).toEqual({
fields: [
{ title: '玩法设定', value: '休闲消除' },
{ title: '美术风格', value: '清爽卡通' },
],
references: [
{
title: '参考图',
label: '参考.png',
src: 'data:image/png;base64,ref',
},
],
});
});
it('builds character plans with references and remembered model', () => {
const plan = buildImageGenerationSubmissionPlan({
dialog: {
mode: 'character',
prompt: ' 白发骑士 ',
status: 'idle',
imageModel: 'gpt-image-2',
aspectRatio: '2:3',
imageSize: '2K',
characterSpecReference: {
id: 'spec',
label: '角色规范',
src: 'data:image/png;base64,spec',
},
characterReferences: [
{
id: 'ref-1',
label: '盔甲参考',
src: 'data:image/png;base64,armor',
},
],
},
layers: [],
nextGeneratedIndex: 9,
});
expect(plan).toMatchObject({
kind: 'image',
normalizedPrompt: '白发骑士',
input: {
prompt: '白发骑士',
kind: 'character',
model: 'gpt-image-2',
aspectRatio: '2:3',
imageSize: '2K',
referenceImageSrcs: [
'data:image/png;base64,spec',
'data:image/png;base64,armor',
],
},
result: {
assetKind: 'character',
title: '角色形象 9',
},
rememberImageModel: 'gpt-image-2',
});
expect(plan.kind === 'image' ? plan.result.generationInputs : null).toEqual({
fields: [{ title: '角色设定', value: '白发骑士' }],
references: [
{
title: '角色形象规范',
label: '角色规范',
src: 'data:image/png;base64,spec',
},
{
title: '常规参考图 1',
label: '盔甲参考',
src: 'data:image/png;base64,armor',
},
],
});
});
it('throws when edit source layer is missing', () => {
expect(() =>
buildImageGenerationSubmissionPlan({
dialog: {
mode: 'edit',
prompt: '修图',
status: 'idle',
sourceLayerId: 'missing-layer',
},
layers: [],
nextGeneratedIndex: 1,
}),
).toThrow('未找到要修改的图片');
});
});

View File

@@ -0,0 +1,143 @@
import type {
EditorImageGenerationInput,
} from '../../services/image-editor/editorProjectClient';
import type {
CanvasGenerationInputs,
CanvasLayer,
GenerateDialogState,
} from './ImageCanvasEditorTypes';
import {
DEFAULT_IMAGE_MODEL,
DEFAULT_SPEC_FORM_VALUES,
SPEC_GENERATION_SIZE,
SPEC_TYPE_LABEL,
buildCharacterGenerationInputs,
buildEditGenerationInputs,
buildImageGenerationInputs,
buildSpecGenerationInputs,
buildSpecPrompt,
} from './ImageCanvasGenerationModel';
type ImageGenerationSubmissionOptions = {
dialog: GenerateDialogState;
layers: CanvasLayer[];
nextGeneratedIndex: number;
};
export type ImageGenerationSubmissionPlan =
| {
kind: 'edit';
normalizedPrompt: string;
sourceLayer: CanvasLayer;
generationInputs: CanvasGenerationInputs;
}
| {
kind: 'image';
normalizedPrompt: string;
input: EditorImageGenerationInput;
result: {
assetKind?: CanvasLayer['assetKind'];
title?: string;
generationInputs: CanvasGenerationInputs;
};
rememberImageModel?: string;
};
export function buildImageGenerationSubmissionPlan({
dialog,
layers,
nextGeneratedIndex,
}: ImageGenerationSubmissionOptions): ImageGenerationSubmissionPlan {
const normalizedPrompt =
dialog.prompt.trim() ||
(dialog.mode === 'edit' ? '修改当前图片' : 'AI 生成图片');
if (dialog.mode === 'edit') {
const sourceLayer = layers.find((layer) => layer.id === dialog.sourceLayerId);
if (!sourceLayer) {
throw new Error('未找到要修改的图片');
}
return {
kind: 'edit',
normalizedPrompt,
sourceLayer,
generationInputs: buildEditGenerationInputs(
'修改要求',
normalizedPrompt,
sourceLayer,
),
};
}
if (dialog.mode === 'spec') {
const specType = dialog.specType ?? 'custom';
const specValues = dialog.specValues ?? DEFAULT_SPEC_FORM_VALUES[specType];
return {
kind: 'image',
normalizedPrompt,
input: {
prompt: buildSpecPrompt(
specType,
specValues,
Boolean(dialog.specReference?.src),
),
size: SPEC_GENERATION_SIZE,
model: DEFAULT_IMAGE_MODEL,
kind: 'spec',
...(dialog.specReference?.src
? { referenceImageSrcs: [dialog.specReference.src] }
: {}),
},
result: {
assetKind: specType === 'icon' ? 'icon-spec' : 'spec',
title: `${SPEC_TYPE_LABEL[specType]} ${nextGeneratedIndex}`,
generationInputs: buildSpecGenerationInputs(
specType,
specValues,
dialog.specReference,
),
},
};
}
if (dialog.mode === 'character') {
const referenceImageSrcs = [
dialog.characterSpecReference?.src,
...(dialog.characterReferences ?? []).map((reference) => reference.src),
].filter((src): src is string => Boolean(src));
const imageModel = dialog.imageModel ?? DEFAULT_IMAGE_MODEL;
return {
kind: 'image',
normalizedPrompt,
input: {
prompt: normalizedPrompt,
kind: 'character',
model: imageModel,
aspectRatio: dialog.aspectRatio ?? '1:1',
imageSize: dialog.imageSize ?? '1K',
...(referenceImageSrcs.length ? { referenceImageSrcs } : {}),
},
result: {
assetKind: 'character',
title: `角色形象 ${nextGeneratedIndex}`,
generationInputs: buildCharacterGenerationInputs(
normalizedPrompt,
dialog.characterSpecReference,
dialog.characterReferences,
),
},
rememberImageModel: imageModel,
};
}
return {
kind: 'image',
normalizedPrompt,
input: {
prompt: normalizedPrompt,
},
result: {
generationInputs: buildImageGenerationInputs(normalizedPrompt),
},
};
}

View File

@@ -36,22 +36,17 @@ import {
ICON_FRAME_ORIGINAL_SIZE,
SPEC_FRAME_DISPLAY_SIZE,
SPEC_FRAME_ORIGINAL_SIZE,
SPEC_GENERATION_SIZE,
SPEC_TYPE_LABEL,
buildCharacterGenerationInputs,
buildEditGenerationInputs,
buildIconGenerationInputs,
buildImageGenerationInputs,
buildQuickEditModelOptions,
buildQuickEditSizeOptions,
buildSpecGenerationInputs,
buildSpecPrompt,
calculateCharacterAnimationPrice,
createCanvasLayerReference,
isCanvasGenerationDialog,
resolveCharacterAnimationSourceImageSrc,
resolveImageGenerationErrorMessage,
} from './ImageCanvasGenerationModel';
import { buildImageGenerationSubmissionPlan } from './ImageCanvasGenerationSubmissionModel';
import { formatImageSizeValue } from './ImageCanvasEditorModel';
import type {
CanvasGenerationDialogState,
@@ -801,92 +796,34 @@ export function useImageCanvasGenerationWorkflow({
}
try {
if (dialog.mode === 'edit') {
const sourceLayer = layers.find(
(layer) => layer.id === dialog.sourceLayerId,
);
if (!sourceLayer) {
throw new Error('未找到要修改的图片');
}
const submissionPlan = buildImageGenerationSubmissionPlan({
dialog,
layers,
nextGeneratedIndex: layerCounterRef.current + 1,
});
if (submissionPlan.kind === 'edit') {
const referenceImageSrc = await resolveEditorImageReferenceDataUrl(
sourceLayer.src,
submissionPlan.sourceLayer.src,
);
const generated = await editEditorImage({
prompt: normalizedPrompt,
prompt: submissionPlan.normalizedPrompt,
sourceImageSrc: referenceImageSrc,
});
addGeneratedResultLayer(generated, {
sourceLayer,
generationInputs: buildEditGenerationInputs(
'修改要求',
normalizedPrompt,
sourceLayer,
),
});
} else if (dialog.mode === 'spec') {
const specType = dialog.specType ?? 'custom';
const specValues =
dialog.specValues ?? DEFAULT_SPEC_FORM_VALUES[specType];
const specPrompt = buildSpecPrompt(
specType,
specValues,
Boolean(dialog.specReference?.src),
);
const generated = await generateEditorImage({
prompt: specPrompt,
size: SPEC_GENERATION_SIZE,
model: DEFAULT_IMAGE_MODEL,
kind: 'spec',
...(dialog.specReference?.src
? { referenceImageSrcs: [dialog.specReference.src] }
: {}),
});
addGeneratedResultLayer(generated, {
frame: getGeneratingDialogPlaceholder(dialog),
assetKind: specType === 'icon' ? 'icon-spec' : 'spec',
title: `${SPEC_TYPE_LABEL[specType]} ${layerCounterRef.current + 1}`,
dialogId: canvasDialog?.id,
generationInputs: buildSpecGenerationInputs(
specType,
specValues,
dialog.specReference,
),
});
} else if (dialog.mode === 'character') {
const referenceImageSrcs = [
dialog.characterSpecReference?.src,
...(dialog.characterReferences ?? []).map(
(reference) => reference.src,
),
].filter((src): src is string => Boolean(src));
const generated = await generateEditorImage({
prompt: normalizedPrompt,
kind: 'character',
model: dialog.imageModel ?? DEFAULT_IMAGE_MODEL,
aspectRatio: dialog.aspectRatio ?? '1:1',
imageSize: dialog.imageSize ?? '1K',
...(referenceImageSrcs.length ? { referenceImageSrcs } : {}),
});
setLastImageModel(dialog.imageModel ?? DEFAULT_IMAGE_MODEL);
addGeneratedResultLayer(generated, {
frame: getGeneratingDialogPlaceholder(dialog),
assetKind: 'character',
title: `角色形象 ${layerCounterRef.current + 1}`,
dialogId: canvasDialog?.id,
generationInputs: buildCharacterGenerationInputs(
normalizedPrompt,
dialog.characterSpecReference,
dialog.characterReferences,
),
sourceLayer: submissionPlan.sourceLayer,
generationInputs: submissionPlan.generationInputs,
});
} else {
const generated = await generateEditorImage({
prompt: normalizedPrompt,
});
const generated = await generateEditorImage(submissionPlan.input);
if (submissionPlan.rememberImageModel) {
setLastImageModel(submissionPlan.rememberImageModel);
}
addGeneratedResultLayer(generated, {
frame: getGeneratingDialogPlaceholder(dialog),
assetKind: submissionPlan.result.assetKind,
title: submissionPlan.result.title,
dialogId: canvasDialog?.id,
generationInputs: buildImageGenerationInputs(normalizedPrompt),
generationInputs: submissionPlan.result.generationInputs,
});
}
} catch (error) {