Files
Genarrative/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts
T
JenkenB ea29887d9c 完善快速编辑参考图与生成占位
- 快速编辑支持最多8张额外参考图,并将原图自动作为最后一张参考图提交

- 快速编辑尺寸和模型沿用原图配置,按禁用态参数胶囊展示

- 快速编辑提交前同步改写原图引用为实际图序号

- 快速编辑生成改为创建独立画布生成占位,并支持键盘删除生成中对象

- 对齐生成类面板参考图图标、规范类固定参数和相关文档测试
2026-06-18 22:50:42 +08:00

484 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
EditorBackgroundMusicGenerationInput,
EditorCharacterAnimationGenerationInput,
EditorIconSpritesheetGenerationInput,
EditorImageGenerationInput,
EditorSoundEffectGenerationInput,
EditorVideoGenerationInput,
} from '../../services/image-editor/editorProjectClient';
import type {
CanvasGenerationInputs,
CanvasLayer,
CharacterAnimationPanelState,
GenerateDialogState,
} from './ImageCanvasEditorTypes';
import {
buildBackgroundMusicGenerationInputs,
buildCharacterGenerationInputs,
buildEditGenerationInputs,
buildIconGenerationInputs,
buildImageGenerationInputs,
buildSoundEffectGenerationInputs,
buildSpecGenerationInputs,
buildSpecPrompt,
buildUiDesignGenerationInputs,
buildVideoGenerationInputs,
calculateCharacterAnimationPrice,
calculateEditorVideoPrice,
CHARACTER_ANIMATION_MODEL,
DEFAULT_ICON_DESCRIPTIONS,
DEFAULT_IMAGE_MODEL,
DEFAULT_SPEC_FORM_VALUES,
DEFAULT_VIDEO_MODEL,
EDITOR_GENERATION_MUD_POINT_CONFIG,
ICON_DESCRIPTION_LIMIT,
resolveCharacterAnimationSourceImageSrc,
SEEDANCE_VIDEO_REFERENCE_LIMITS,
SPEC_GENERATION_ASPECT_RATIO,
SPEC_GENERATION_IMAGE_SIZE,
SPEC_GENERATION_MODEL,
SPEC_GENERATION_SIZE,
SPEC_TYPE_LABEL,
} from './ImageCanvasGenerationModel';
type ImageGenerationSubmissionOptions = {
dialog: GenerateDialogState;
layers: CanvasLayer[];
nextGeneratedIndex: number;
};
function isSeedanceVideoModel(model: string) {
return model === 'seedance2.0' || model === 'seedance2.0-fast';
}
function estimateReferenceRequestBytes(references: Array<{ src: string }>) {
// 中文注释:JSON 包装字段还有少量额外字节;这里按 src 字符串长度估算,前后端都会再兜底校验 64MB。
return references.reduce((sum, reference) => sum + reference.src.length, 0);
}
function buildSeedanceVideoReferenceInput(
references: NonNullable<GenerateDialogState['generationReferences']>,
) {
const resolveReferenceSource = (reference: { src: string; objectKey?: string | null }) =>
reference.objectKey?.trim() || reference.src;
const imageReferences = references.filter(
(reference) => (reference.mediaType ?? 'image') === 'image',
);
const videoReferences = references.filter(
(reference) => reference.mediaType === 'video',
);
const audioReferences = references.filter(
(reference) => reference.mediaType === 'audio',
);
if (imageReferences.length > SEEDANCE_VIDEO_REFERENCE_LIMITS.image) {
throw new Error('参考图片最多 9 张');
}
if (videoReferences.length > SEEDANCE_VIDEO_REFERENCE_LIMITS.video) {
throw new Error('参考视频最多 3 个');
}
if (audioReferences.length > SEEDANCE_VIDEO_REFERENCE_LIMITS.audio) {
throw new Error('参考音频最多 3 个');
}
if (audioReferences.length && !imageReferences.length && !videoReferences.length) {
throw new Error('参考音频必须搭配参考图片或参考视频');
}
if (
estimateReferenceRequestBytes(references) >
SEEDANCE_VIDEO_REFERENCE_LIMITS.requestBytes
) {
throw new Error('参考文件总请求体不能超过 64MB');
}
return {
referenceImageSrcs: imageReferences.map(resolveReferenceSource),
referenceVideoSrcs: videoReferences.map(resolveReferenceSource),
referenceAudioSrcs: audioReferences.map(resolveReferenceSource),
};
}
function getDialogDefaultPrompt(mode: GenerateDialogState['mode']) {
if (mode === 'edit') {
return '修改当前图片';
}
if (mode === 'audio-sound-effect') {
return '游戏音效';
}
if (mode === 'audio-background-music') {
return '游戏背景音乐';
}
return 'AI 生成图片';
}
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;
}
| {
kind: 'video';
normalizedPrompt: string;
input: EditorVideoGenerationInput;
result: {
title: string;
generationInputs: CanvasGenerationInputs;
};
}
| {
kind: 'audio';
audioKind: 'sound-effect';
normalizedPrompt: string;
input: EditorSoundEffectGenerationInput;
result: {
title: string;
generationInputs: CanvasGenerationInputs;
};
}
| {
kind: 'audio';
audioKind: 'background-music';
normalizedPrompt: string;
input: EditorBackgroundMusicGenerationInput;
result: {
title: string;
generationInputs: CanvasGenerationInputs;
};
};
export type IconSpritesheetGenerationSubmissionPlan =
| {
ok: false;
errorMessage: string;
}
| {
ok: true;
iconDescriptions: string[];
input: EditorIconSpritesheetGenerationInput;
generationInputs: CanvasGenerationInputs;
rememberImageModel: string;
};
export type CharacterAnimationSubmissionPlan = {
promptText: string;
input: EditorCharacterAnimationGenerationInput;
};
export function buildImageGenerationSubmissionPlan({
dialog,
layers,
nextGeneratedIndex,
}: ImageGenerationSubmissionOptions): ImageGenerationSubmissionPlan {
const normalizedPrompt =
dialog.prompt.trim() || getDialogDefaultPrompt(dialog.mode);
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: SPEC_GENERATION_MODEL,
aspectRatio: SPEC_GENERATION_ASPECT_RATIO,
imageSize: SPEC_GENERATION_IMAGE_SIZE,
kind: 'spec',
...(dialog.specReference?.src
? { referenceImageSrcs: [dialog.specReference.src] }
: {}),
},
result: {
// 中文注释:生成规范菜单里的“图标规范”沿用历史 ui specType,但产物语义应作为图标规范供图标素材引用。
assetKind:
specType === 'ui' || 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,
};
}
if (dialog.mode === 'ui-design') {
const imageModel = dialog.imageModel ?? DEFAULT_IMAGE_MODEL;
const referenceImageSrcs = [dialog.uiDesignSpecReference?.src].filter(
(src): src is string => Boolean(src),
);
return {
kind: 'image',
normalizedPrompt,
input: {
prompt: normalizedPrompt,
kind: 'ui-design',
model: imageModel,
aspectRatio: dialog.aspectRatio ?? '16:9',
imageSize: dialog.imageSize ?? '1K',
...(referenceImageSrcs.length ? { referenceImageSrcs } : {}),
},
result: {
assetKind: 'ui-design',
title: `UI设计图 ${nextGeneratedIndex}`,
generationInputs: buildUiDesignGenerationInputs(
normalizedPrompt,
dialog.uiDesignSpecReference,
),
},
rememberImageModel: imageModel,
};
}
if (dialog.mode === 'video') {
const resolution = dialog.videoResolution ?? '480p';
const durationSeconds = dialog.videoDurationSeconds ?? 4;
const model = dialog.videoModel ?? DEFAULT_VIDEO_MODEL;
const seedanceReferences = isSeedanceVideoModel(model)
? buildSeedanceVideoReferenceInput(dialog.generationReferences ?? [])
: {
referenceImageSrcs: [],
referenceVideoSrcs: [],
referenceAudioSrcs: [],
};
return {
kind: 'video',
normalizedPrompt,
input: {
prompt: normalizedPrompt,
model,
aspectRatio: '16:9',
durationSeconds,
resolution,
mode: 'std',
sound: 'off',
priceMudPoints: calculateEditorVideoPrice(
resolution,
durationSeconds,
),
...(seedanceReferences.referenceImageSrcs.length
? { referenceImageSrcs: seedanceReferences.referenceImageSrcs }
: {}),
...(seedanceReferences.referenceVideoSrcs.length
? { referenceVideoSrcs: seedanceReferences.referenceVideoSrcs }
: {}),
...(seedanceReferences.referenceAudioSrcs.length
? { referenceAudioSrcs: seedanceReferences.referenceAudioSrcs }
: {}),
},
result: {
title: `生成视频 ${nextGeneratedIndex}`,
generationInputs: buildVideoGenerationInputs(
normalizedPrompt,
dialog.generationReferences,
),
},
};
}
if (dialog.mode === 'audio-sound-effect') {
const soundType = dialog.soundType ?? 'one-shot';
const soundTempo = dialog.soundTempo ?? null;
return {
kind: 'audio',
audioKind: 'sound-effect',
normalizedPrompt,
input: {
sound: normalizedPrompt,
type: soundType,
tempo: soundTempo,
priceMudPoints: EDITOR_GENERATION_MUD_POINT_CONFIG.soundEffect,
},
result: {
title: `游戏音效 ${nextGeneratedIndex}`,
generationInputs: buildSoundEffectGenerationInputs(
normalizedPrompt,
soundType,
soundTempo,
),
},
};
}
if (dialog.mode === 'audio-background-music') {
return {
kind: 'audio',
audioKind: 'background-music',
normalizedPrompt,
input: {
gptDescriptionPrompt: normalizedPrompt,
makeInstrumental: true,
priceMudPoints: EDITOR_GENERATION_MUD_POINT_CONFIG.backgroundMusic,
},
result: {
title: `游戏背景音乐 ${nextGeneratedIndex}`,
generationInputs: buildBackgroundMusicGenerationInputs(normalizedPrompt),
},
};
}
const imageModel = dialog.imageModel ?? DEFAULT_IMAGE_MODEL;
return {
kind: 'image',
normalizedPrompt,
input: {
prompt: normalizedPrompt,
model: imageModel,
aspectRatio: dialog.aspectRatio ?? '1:1',
imageSize: dialog.imageSize ?? '1K',
...(dialog.generationReferences?.length
? {
referenceImageSrcs: dialog.generationReferences.map(
(reference) => reference.src,
),
}
: {}),
},
result: {
generationInputs: buildImageGenerationInputs(
normalizedPrompt,
dialog.generationReferences,
),
},
rememberImageModel: imageModel,
};
}
export function buildIconSpritesheetGenerationSubmissionPlan(
dialog: GenerateDialogState,
): IconSpritesheetGenerationSubmissionPlan {
const iconDescriptions = (dialog.prompt.trim()
? dialog.prompt.split(/[\r\n,,、;/|]+/u)
: (dialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS)
)
.map((description) => description.trim())
.filter(Boolean)
.slice(0, ICON_DESCRIPTION_LIMIT);
if (!dialog.iconSpecReference) {
return {
ok: false,
errorMessage: '请选择图标规范',
};
}
if (!iconDescriptions.length) {
return {
ok: false,
errorMessage: '请填写素材描述',
};
}
const rememberImageModel = dialog.imageModel ?? DEFAULT_IMAGE_MODEL;
return {
ok: true,
iconDescriptions,
input: {
referenceImageSrc: dialog.iconSpecReference.src,
iconDescriptions,
model: rememberImageModel,
aspectRatio: dialog.aspectRatio ?? '1:1',
imageSize: dialog.imageSize ?? '1K',
priceMudPoints: EDITOR_GENERATION_MUD_POINT_CONFIG.icon,
},
generationInputs: buildIconGenerationInputs(
iconDescriptions,
dialog.iconSpecReference,
),
rememberImageModel,
};
}
export function buildCharacterAnimationSubmissionPlan({
panel,
sourceLayer,
}: {
panel: CharacterAnimationPanelState;
sourceLayer: CanvasLayer;
}): CharacterAnimationSubmissionPlan {
const promptText = panel.promptText.trim();
return {
promptText,
input: {
sourceLayerId: sourceLayer.id,
sourceImageSrc: resolveCharacterAnimationSourceImageSrc(sourceLayer),
sourceWidth: sourceLayer.originalWidth,
sourceHeight: sourceLayer.originalHeight,
promptText,
resolution: panel.resolution,
ratio: panel.ratio,
frameCount: panel.frameCount,
durationSeconds: panel.durationSeconds,
priceMudPoints: calculateCharacterAnimationPrice(
panel.resolution,
panel.durationSeconds,
),
model: CHARACTER_ANIMATION_MODEL,
},
};
}