ea29887d9c
- 快速编辑支持最多8张额外参考图,并将原图自动作为最后一张参考图提交 - 快速编辑尺寸和模型沿用原图配置,按禁用态参数胶囊展示 - 快速编辑提交前同步改写原图引用为实际图序号 - 快速编辑生成改为创建独立画布生成占位,并支持键盘删除生成中对象 - 对齐生成类面板参考图图标、规范类固定参数和相关文档测试
484 lines
14 KiB
TypeScript
484 lines
14 KiB
TypeScript
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,
|
||
},
|
||
};
|
||
}
|