Files
Genarrative/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts
T
JenkenB 160c96a014 合并画板素材生成与宣发入口改动
新增 UI 设计图提取素材入口并复用 gpt-image-2 spritesheet 拆分流程。

保留图标生成与 UI 提取的 spritesheet 原图并同步放置拆分素材到画布。

补齐宣发素材演示入口、占位图样式、引用上传与生成提交链路。

调整编辑器生成接口 body 限制和 VectorEngine 请求兼容逻辑。

更新画板音乐生成、项目基线、宣发素材与图片画布相关文档。
2026-06-19 10:37:22 +08:00

524 lines
15 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,
buildPublicationMaterialsGenerationInputs,
buildPublicationMaterialsGenerationPrompt,
buildPublicationMaterialsPrompt,
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';
import { getPublicationMaterialsWorkflow } from './ImageCanvasPublicationMaterialsModel';
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 === 'publication') {
const workflow = getPublicationMaterialsWorkflow(
dialog.publicationWorkflowId ?? 'publication-cover-image',
);
const publicationPrompt =
buildPublicationMaterialsPrompt(dialog.publicationGameInfo) ||
normalizedPrompt;
const generationPrompt = buildPublicationMaterialsGenerationPrompt({
gameInfo: dialog.publicationGameInfo,
workflow,
});
return {
kind: 'image',
normalizedPrompt: publicationPrompt,
input: {
prompt: generationPrompt,
size: workflow.outputSize,
kind: 'publication-material',
...(dialog.publicationReferences?.length
? {
referenceImageSrcs: dialog.publicationReferences.map(
(reference) => reference.src,
),
}
: {}),
},
result: {
title: `${nextGeneratedIndex} 宣发素材`,
generationInputs: buildPublicationMaterialsGenerationInputs(
dialog.publicationGameInfo,
dialog.publicationReferences,
),
},
};
}
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,
},
};
}