Files
Genarrative/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts
T

558 lines
17 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,
calculateEditorBackgroundMusicPrice,
calculateEditorIconSpritesheetPrice,
calculateEditorSoundEffectPrice,
calculateEditorVideoPrice,
CHARACTER_ANIMATION_MODEL,
DEFAULT_BACKGROUND_MUSIC_MODEL,
DEFAULT_ICON_DESCRIPTIONS,
DEFAULT_SOUND_EFFECT_DURATION_SECONDS,
DEFAULT_SOUND_EFFECT_MODEL,
DEFAULT_SPEC_FORM_VALUES,
DEFAULT_VIDEO_ASPECT_RATIO,
DEFAULT_VIDEO_DURATION_SECONDS,
DEFAULT_VIDEO_MODEL,
DEFAULT_VIDEO_SOUND,
DEFAULT_VIDEO_WEB_SEARCH_ENABLED,
ICON_DESCRIPTION_LIMIT,
normalizeEditorImageModel,
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';
}
export function isSeedanceEditorVideoModel(model: string) {
return isSeedanceVideoModel(model);
}
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 = normalizeEditorImageModel(dialog.imageModel);
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 = normalizeEditorImageModel(dialog.imageModel);
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 imageModel = normalizeEditorImageModel(dialog.imageModel);
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',
model: imageModel,
aspectRatio: workflow.aspectRatio,
imageSize: workflow.imageSize,
...(dialog.publicationReferences?.length
? {
referenceImageSrcs: dialog.publicationReferences.map(
(reference) => reference.src,
),
}
: {}),
},
result: {
title: `${nextGeneratedIndex} 宣发素材`,
generationInputs: buildPublicationMaterialsGenerationInputs(
dialog.publicationGameInfo,
dialog.publicationReferences,
),
},
rememberImageModel: imageModel,
};
}
if (dialog.mode === 'video') {
const resolution = dialog.videoResolution ?? '480p';
const durationSeconds =
typeof dialog.videoDurationSeconds === 'number'
? Math.min(15, Math.max(4, Math.round(dialog.videoDurationSeconds)))
: DEFAULT_VIDEO_DURATION_SECONDS;
const model = dialog.videoModel ?? DEFAULT_VIDEO_MODEL;
const aspectRatio = dialog.videoAspectRatio ?? DEFAULT_VIDEO_ASPECT_RATIO;
const sound = dialog.videoSound ?? DEFAULT_VIDEO_SOUND;
const webSearchEnabled =
dialog.videoWebSearchEnabled ?? DEFAULT_VIDEO_WEB_SEARCH_ENABLED;
const seedanceReferences = isSeedanceVideoModel(model)
? buildSeedanceVideoReferenceInput(dialog.generationReferences ?? [])
: {
referenceImageSrcs: [],
referenceVideoSrcs: [],
referenceAudioSrcs: [],
};
return {
kind: 'video',
normalizedPrompt,
input: {
prompt: normalizedPrompt,
model,
aspectRatio,
durationSeconds,
resolution,
mode: 'std',
sound,
webSearchEnabled,
priceMudPoints: calculateEditorVideoPrice(
model,
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 soundModel = dialog.soundModel ?? DEFAULT_SOUND_EFFECT_MODEL;
const durationSeconds =
typeof dialog.soundDurationSeconds === 'number'
? Math.min(10, Math.max(2, Math.round(dialog.soundDurationSeconds)))
: DEFAULT_SOUND_EFFECT_DURATION_SECONDS;
return {
kind: 'audio',
audioKind: 'sound-effect',
normalizedPrompt,
input: {
prompt: normalizedPrompt,
model: soundModel,
duration: durationSeconds,
priceMudPoints: calculateEditorSoundEffectPrice(soundModel),
},
result: {
title: `游戏音效 ${nextGeneratedIndex}`,
generationInputs: buildSoundEffectGenerationInputs(
normalizedPrompt,
soundModel,
durationSeconds,
),
},
};
}
if (dialog.mode === 'audio-background-music') {
return {
kind: 'audio',
audioKind: 'background-music',
normalizedPrompt,
input: {
gptDescriptionPrompt: normalizedPrompt,
makeInstrumental: true,
priceMudPoints: calculateEditorBackgroundMusicPrice(
DEFAULT_BACKGROUND_MUSIC_MODEL,
),
},
result: {
title: `游戏背景音乐 ${nextGeneratedIndex}`,
generationInputs: buildBackgroundMusicGenerationInputs(normalizedPrompt),
},
};
}
const imageModel = normalizeEditorImageModel(dialog.imageModel);
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: {
title: `生成图片 ${nextGeneratedIndex}`,
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 = normalizeEditorImageModel(dialog.imageModel);
return {
ok: true,
iconDescriptions,
input: {
referenceImageSrc: dialog.iconSpecReference.src,
iconDescriptions,
model: rememberImageModel,
aspectRatio: dialog.aspectRatio ?? '1:1',
imageSize: dialog.imageSize ?? '1K',
priceMudPoints: calculateEditorIconSpritesheetPrice(rememberImageModel),
},
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(
CHARACTER_ANIMATION_MODEL,
panel.resolution,
panel.durationSeconds,
),
model: CHARACTER_ANIMATION_MODEL,
},
};
}