Files
Genarrative/src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts
T
suzmii 05f0e2db06 合并master并保留Spine序列帧修复
合并master当前工程、后端、前端和文档更新

按已确认方案解决Spine序列帧多模态、帧数和快速编辑冲突

保留当前分支底部工具栏宽度与隐藏滚动条样式
2026-08-14 21:33:26 +08:00

1252 lines
37 KiB
TypeScript

import { EDITOR_SOUND_EFFECT_MODEL } from '../../../packages/shared/src/contracts/editorAudio';
import type { EditorSceneGenerationInputs } from '../../../packages/shared/src/contracts/editorScene';
import {
CUSTOM_EDITOR_SCENE_STYLE_PRESET,
DEFAULT_EDITOR_SCENE_STYLE_PRESET,
} from '../../../packages/shared/src/contracts/editorScene';
import type {
EditorBackgroundMusicGenerationInput,
EditorCharacterAnimationGenerationInput,
EditorIconSpecGenerationInput,
EditorIconSpritesheetGenerationInput,
EditorImageEditInput,
EditorImageGenerationInput,
EditorSceneGenerationInput,
EditorSoundEffectGenerationInput,
EditorVideoGenerationInput,
} from '../../services/image-editor/editorProjectClient';
import { EDITOR_ICON_DESCRIPTION_MAX_CHARS } from '../../services/image-editor/editorProjectClient';
import type {
CanvasGenerationInputs,
CanvasLayer,
CharacterAnimationPanelState,
CharacterReferenceImage,
GenerateDialogState,
} from './ImageCanvasEditorTypes';
import {
buildBackgroundMusicGenerationInputs,
buildCharacterGenerationInputs,
buildCharacterSheetGenerationInputs,
buildEditGenerationInputs,
buildIconGenerationInputs,
buildImageGenerationInputs,
buildPublicationMaterialsGenerationInputs,
buildPublicationMaterialsGenerationPrompt,
buildPublicationMaterialsPrompt,
buildQuickEditGenerationInputs,
buildSceneGenerationInputs,
buildSoundEffectGenerationInputs,
buildSpecGenerationInputs,
buildSpecPrompt,
buildUiDesignGenerationInputs,
buildVideoGenerationInputs,
CHARACTER_ANIMATION_MODEL,
DEFAULT_EDITOR_BGFILTER_SEG_MODEL,
DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR,
DEFAULT_IMAGE_MODEL,
DEFAULT_SOUND_EFFECT_DURATION_SECONDS,
DEFAULT_SPEC_FORM_VALUES,
DEFAULT_VIDEO_ASPECT_RATIO,
DEFAULT_VIDEO_DURATION_SECONDS,
DEFAULT_VIDEO_MODEL,
DEFAULT_VIDEO_SOUND,
DEFAULT_VIDEO_WEB_SEARCH_ENABLED,
EDITOR_IMAGE_DIMENSION_OPTIONS,
EDITOR_IMAGE_MODEL_OPTIONS,
IMAGE_MODEL_GPT_IMAGE_2,
inferEditorImageAspectRatio,
inferEditorImageSizeLabel,
normalizeEditorImageModel,
resolveCharacterAnimationSourceImageSrc,
resolveEditorImageGenerationPixelSize,
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';
import { validateSoundEffectPrompt } from './ImageCanvasSoundEffectPromptModel';
type ImageGenerationSubmissionOptions = {
dialog: GenerateDialogState;
layers: CanvasLayer[];
nextGeneratedIndex: number;
canonicalBackgroundMusicPrompt?: string;
};
export const EDITOR_SCENE_CONTENT_REQUIRED_ERROR = '请填写画面内容';
export const EDITOR_SCENE_CUSTOM_STYLE_REQUIRED_ERROR = '请填写自定义画风';
export const EDITOR_GENERATED_ASSET_LABEL_MAX_CHARS = 80;
/**
* 场景生成接口的历史契约只接受字符串参数;画布配方则保留原生
* string | number | boolean 值,以便在素材元数据中无损回放。
*/
export function toEditorSceneGenerationInputs(
generationInputs: CanvasGenerationInputs,
): EditorSceneGenerationInputs {
return {
fields: generationInputs.fields.map(({ title, value }) => ({
title,
value: String(value),
})),
references: generationInputs.references.map(
({ title, label, refType, refId }) => ({
title,
label: label ?? '',
refType,
refId,
}),
),
};
}
export function resolveGenerationAssetLabel(
assetLabel: string | null | undefined,
fallback: string,
) {
const normalized = assetLabel?.trim();
return normalized
? Array.from(normalized)
.slice(0, EDITOR_GENERATED_ASSET_LABEL_MAX_CHARS)
.join('')
: fallback;
}
function isSeedanceVideoModel(model: string) {
return model === 'seedance2.0' || model === 'seedance2.0-fast';
}
export function isSeedanceEditorVideoModel(model: string) {
return isSeedanceVideoModel(model);
}
function normalizeGptImageSize(imageSize: string | null | undefined) {
return imageSize?.trim().toUpperCase() === '2K' ? '2K' : '1K';
}
export function resolveImageReferenceSubmissionSource(reference: {
src: string;
objectKey?: string | null;
}) {
return reference.objectKey?.trim() || reference.src;
}
export function resolveRegisteredEditorReferenceId(
reference: {
resourceId?: string | null;
sourceAssetId?: string | null;
},
referenceLabel: '原图' | '参考图' = '原图',
) {
const resourceId = reference.resourceId?.trim();
if (
resourceId &&
!resourceId.startsWith('local-resource-') &&
!resourceId.startsWith('generation-dialog:')
) {
return resourceId;
}
const assetId = reference.sourceAssetId?.trim();
if (assetId && !assetId.startsWith('upload-')) {
return assetId;
}
throw new Error(
`${referenceLabel}尚未登记为项目资源或素材,请重新选择或上传后再试`,
);
}
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('参考音频必须搭配参考图片或参考视频');
}
return {
referenceImageSrcs: imageReferences.map(resolveReferenceSource),
referenceVideoSrcs: videoReferences.map(resolveReferenceSource),
referenceAudioSrcs: audioReferences.map(resolveReferenceSource),
};
}
// 使用 map 避免嵌套 if else
const DEFAULT_GENERATION_PROMPTS = new Map<GenerateDialogState['mode'], string>(
[
['edit', '修改当前图片'],
['audio-sound-effect', '游戏音效'],
['audio-background-music', '游戏背景音乐'],
],
);
const REQUIRED_GENERATION_PROMPT_MODES = new Set<GenerateDialogState['mode']>([
'scene',
]);
function getDialogDefaultPrompt(mode: GenerateDialogState['mode']) {
return DEFAULT_GENERATION_PROMPTS.get(mode) ?? 'AI 生成图片';
}
export function resolveImageGenerationDialogPrompt(
dialog: GenerateDialogState,
) {
const prompt = dialog.prompt.trim();
if (prompt || REQUIRED_GENERATION_PROMPT_MODES.has(dialog.mode)) {
return prompt;
}
return getDialogDefaultPrompt(dialog.mode);
}
function resolveOptionalFieldWithFallback<T extends string>(
preferredValue: string | undefined | null,
supportedValues: readonly string[],
fallbackValues: readonly T[],
defaultValue: T,
) {
const normalizedPreferred = preferredValue?.trim() ?? '';
if (supportedValues.includes(normalizedPreferred)) {
return normalizedPreferred as T;
}
return (
fallbackValues.find((fallbackValue) =>
supportedValues.includes(fallbackValue),
) ?? defaultValue
);
}
function resolveSupportedFieldValue({
preferredValues,
supportedValues,
fallbackValues,
defaultValue,
}: {
preferredValues: Array<string | null | undefined>;
supportedValues: readonly string[];
fallbackValues: readonly string[];
defaultValue: string;
}) {
for (const value of preferredValues) {
const normalized = value?.trim();
if (normalized && supportedValues.includes(normalized)) {
return normalized;
}
}
return (
fallbackValues.find((fallbackValue) =>
supportedValues.includes(fallbackValue),
) ?? defaultValue
);
}
function resolveImageEditFieldValueFromSourceLayer(
sourceLayer: CanvasLayer,
fieldId: string,
) {
const rawValue = sourceLayer.generationInputs?.fields.find(
(field) => field.id === fieldId,
)?.value;
return typeof rawValue === 'string' ? rawValue : null;
}
function resolveImageEditModel({
sourceLayer,
dialogModel,
}: {
sourceLayer: CanvasLayer;
dialogModel?: string | null;
}) {
const normalizedDialogModel = dialogModel
? normalizeEditorImageModel(dialogModel)
: null;
const normalizedSourceLayerModel = sourceLayer.model
? normalizeEditorImageModel(sourceLayer.model)
: null;
const resolvedModelFromLayer = resolveImageEditFieldValueFromSourceLayer(
sourceLayer,
'model',
);
const normalizedLayerPersistedModel = resolvedModelFromLayer
? normalizeEditorImageModel(resolvedModelFromLayer)
: null;
return [
normalizedDialogModel,
normalizedLayerPersistedModel,
normalizedSourceLayerModel,
].find((candidateModel) =>
EDITOR_IMAGE_MODEL_OPTIONS.some(
(option) => option.value === candidateModel,
),
);
}
function resolveImageEditGeometry({
sourceLayer,
dialogImageModel,
dialogAspectRatio,
dialogImageSize,
}: {
sourceLayer: CanvasLayer;
dialogImageModel?: string | null;
dialogAspectRatio?: string | null;
dialogImageSize?: string | null;
}) {
const model = resolveImageEditModel({
sourceLayer,
dialogModel: dialogImageModel,
});
const selectedModel = model ?? DEFAULT_IMAGE_MODEL;
const dimensionOptions =
EDITOR_IMAGE_DIMENSION_OPTIONS[
selectedModel as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS
] ??
EDITOR_IMAGE_DIMENSION_OPTIONS[
DEFAULT_IMAGE_MODEL as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS
];
const supportedAspectRatios =
dimensionOptions.aspectRatios as readonly string[];
const supportedImageSizes = dimensionOptions.imageSizes as readonly string[];
const persistedAspectRatio = resolveImageEditFieldValueFromSourceLayer(
sourceLayer,
'aspectRatio',
);
const persistedImageSize = resolveImageEditFieldValueFromSourceLayer(
sourceLayer,
'imageSize',
);
const inferredAspectRatio = inferEditorImageAspectRatio(
sourceLayer.originalWidth,
sourceLayer.originalHeight,
);
const inferredImageSize = inferEditorImageSizeLabel(
sourceLayer.originalWidth,
sourceLayer.originalHeight,
);
const defaultImageSize = supportedImageSizes.includes('1K')
? '1K'
: (supportedImageSizes[0] ?? '1K');
const aspectRatio = resolveSupportedFieldValue({
preferredValues: [dialogAspectRatio, persistedAspectRatio],
supportedValues: supportedAspectRatios,
fallbackValues: [inferredAspectRatio, ...dimensionOptions.aspectRatios],
defaultValue: '1:1',
});
const imageSize = resolveSupportedFieldValue({
preferredValues: [dialogImageSize, persistedImageSize],
supportedValues: supportedImageSizes,
fallbackValues: [inferredImageSize, ...dimensionOptions.imageSizes],
defaultValue: defaultImageSize,
});
const outputSize = resolveEditorImageGenerationPixelSize({
model: selectedModel,
aspectRatio,
imageSize,
});
return {
model: selectedModel,
aspectRatio,
imageSize,
outputSize,
};
}
export type ImageGenerationSubmissionPlan =
| {
kind: 'edit';
normalizedPrompt: string;
sourceLayer: CanvasLayer;
resultTitle: string;
editInput: Pick<
EditorImageEditInput,
'size' | 'model' | 'aspectRatio' | 'imageSize'
>;
generationInputs: CanvasGenerationInputs;
}
| {
kind: 'image';
normalizedPrompt: string;
input: EditorImageGenerationInput;
result: {
assetKind?: CanvasLayer['assetKind'];
title?: string;
generationInputs: CanvasGenerationInputs;
};
rememberImageModel?: string;
}
| {
kind: 'scene';
normalizedPrompt: string;
input: EditorSceneGenerationInput;
result: {
assetKind: 'scene';
title: string;
generationInputs: CanvasGenerationInputs;
};
rememberImageModel?: string;
}
| {
kind: 'icon-spec';
normalizedPrompt: string;
input: EditorIconSpecGenerationInput;
result: {
assetKind: 'icon-spec';
title: string;
generationInputs: CanvasGenerationInputs;
};
}
| {
kind: 'quick-edit';
normalizedPrompt: string;
sourceLayer: CanvasLayer;
editInput: Pick<
EditorImageEditInput,
'size' | 'model' | 'aspectRatio' | 'imageSize' | 'referenceImageSrcs'
>;
result: {
title: string;
assetKind?: CanvasLayer['assetKind'];
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;
resultTitle: string;
};
export type CharacterAnimationSubmissionPlan = {
promptText: string;
resultTitle: string;
input: EditorCharacterAnimationGenerationInput;
};
export function buildImageGenerationSubmissionPlan({
dialog,
layers,
nextGeneratedIndex,
canonicalBackgroundMusicPrompt,
}: ImageGenerationSubmissionOptions): ImageGenerationSubmissionPlan {
if (dialog.mode === 'audio-background-music') {
if (canonicalBackgroundMusicPrompt === undefined) {
throw new Error('背景音乐提交缺少已确认的提示词');
}
return {
kind: 'audio',
audioKind: 'background-music',
normalizedPrompt: canonicalBackgroundMusicPrompt,
input: {
gptDescriptionPrompt: canonicalBackgroundMusicPrompt,
makeInstrumental: true,
},
result: {
title: resolveGenerationAssetLabel(
dialog.assetLabel,
`游戏背景音乐 ${nextGeneratedIndex}`,
),
generationInputs: buildBackgroundMusicGenerationInputs(
canonicalBackgroundMusicPrompt,
),
},
};
}
const soundEffectPrompt =
dialog.mode === 'audio-sound-effect'
? validateSoundEffectPrompt(dialog.prompt)
: null;
if (soundEffectPrompt && !soundEffectPrompt.ok) {
throw new Error(
soundEffectPrompt.reason === 'empty'
? '音效描述不能为空'
: '音效描述不能超过 2048 个字符',
);
}
// SFX 走自己的 canonicalization 与 2048 边界;其余模式复用统一解析(含 scene 必填语义)。
const normalizedPrompt = soundEffectPrompt
? soundEffectPrompt.prompt
: resolveImageGenerationDialogPrompt(dialog);
if (dialog.mode === 'edit') {
const sourceLayer = layers.find(
(layer) => layer.id === dialog.sourceLayerId,
);
if (!sourceLayer) {
throw new Error('未找到要修改的图片');
}
const geometry = resolveImageEditGeometry({
sourceLayer,
dialogImageModel: dialog.imageModel,
dialogAspectRatio: dialog.aspectRatio,
dialogImageSize: dialog.imageSize,
});
return {
kind: 'edit',
normalizedPrompt,
sourceLayer,
resultTitle: resolveGenerationAssetLabel(
dialog.assetLabel,
`${sourceLayer.title} 修改结果`,
),
editInput: {
size: `${geometry.outputSize.width}x${geometry.outputSize.height}`,
model: geometry.model,
aspectRatio: geometry.aspectRatio,
imageSize: geometry.imageSize,
},
generationInputs: buildEditGenerationInputs(
'修改要求',
normalizedPrompt,
sourceLayer,
{
model: geometry.model,
aspectRatio: geometry.aspectRatio,
imageSize: geometry.imageSize,
},
),
};
}
if (dialog.mode === 'quick-edit' && dialog.sourceLayerId) {
const sourceLayer = layers.find(
(layer) => layer.id === dialog.sourceLayerId,
);
if (!sourceLayer) {
throw new Error('未找到要改造的原图');
}
const basePrompt = dialog.prompt.trim() || '快速编辑图片';
const normalizedQuickEditPrompt = basePrompt;
const requestedImageModel = normalizeEditorImageModel(
dialog.imageModel ?? IMAGE_MODEL_GPT_IMAGE_2,
);
const imageModel = EDITOR_IMAGE_MODEL_OPTIONS.some(
(option) => option.value === requestedImageModel,
)
? requestedImageModel
: IMAGE_MODEL_GPT_IMAGE_2;
const dimensionOptions =
EDITOR_IMAGE_DIMENSION_OPTIONS[
imageModel as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS
] ?? EDITOR_IMAGE_DIMENSION_OPTIONS[IMAGE_MODEL_GPT_IMAGE_2];
const supportedAspectRatios =
dimensionOptions.aspectRatios as readonly string[];
const supportedImageSizes =
dimensionOptions.imageSizes as readonly string[];
const inferredAspectRatio = inferEditorImageAspectRatio(
sourceLayer.originalWidth,
sourceLayer.originalHeight,
);
const inferredImageSize = inferEditorImageSizeLabel(
sourceLayer.originalWidth,
sourceLayer.originalHeight,
);
const aspectRatio = supportedAspectRatios.includes(dialog.aspectRatio ?? '')
? dialog.aspectRatio!.trim()
: resolveOptionalFieldWithFallback(
inferredAspectRatio,
supportedAspectRatios,
dimensionOptions.aspectRatios,
'1:1',
);
const imageSize = supportedImageSizes.includes(dialog.imageSize ?? '')
? dialog.imageSize!
: resolveOptionalFieldWithFallback(
inferredImageSize,
supportedImageSizes,
dimensionOptions.imageSizes,
'1K',
);
const outputSize = resolveEditorImageGenerationPixelSize({
model: imageModel,
aspectRatio,
imageSize,
});
const references = dialog.generationReferences ?? [];
return {
kind: 'quick-edit',
normalizedPrompt: normalizedQuickEditPrompt,
sourceLayer,
editInput: {
size: `${outputSize.width}x${outputSize.height}`,
model: imageModel,
aspectRatio,
imageSize,
...(references.length
? {
referenceImageSrcs: references.map(
resolveImageReferenceSubmissionSource,
),
}
: {}),
},
result: {
title: resolveGenerationAssetLabel(
dialog.assetLabel,
`${sourceLayer.title} 快速编辑`,
),
assetKind: sourceLayer.assetKind,
generationInputs: buildQuickEditGenerationInputs(
'快速编辑提示词',
normalizedQuickEditPrompt,
sourceLayer,
references,
{
model: imageModel,
aspectRatio,
imageSize,
},
),
},
rememberImageModel: imageModel,
};
}
if (dialog.mode === 'scene') {
const sceneContent = dialog.prompt.trim();
if (!sceneContent) {
throw new Error(EDITOR_SCENE_CONTENT_REQUIRED_ERROR);
}
const stylePreset =
dialog.sceneStylePreset ?? DEFAULT_EDITOR_SCENE_STYLE_PRESET;
const customStyle = dialog.sceneCustomStyle?.trim() ?? '';
if (stylePreset === CUSTOM_EDITOR_SCENE_STYLE_PRESET && !customStyle) {
throw new Error(EDITOR_SCENE_CUSTOM_STYLE_REQUIRED_ERROR);
}
const references = dialog.generationReferences ?? [];
const imageModel = normalizeEditorImageModel(dialog.imageModel);
const generationInputs = buildSceneGenerationInputs(
sceneContent,
stylePreset,
customStyle,
references,
{
model: imageModel,
aspectRatio: dialog.aspectRatio ?? '16:9',
imageSize: dialog.imageSize ?? '1K',
},
);
return {
kind: 'scene',
normalizedPrompt: sceneContent,
input: {
sceneContent,
stylePreset,
...(stylePreset === CUSTOM_EDITOR_SCENE_STYLE_PRESET
? { customStyle }
: {}),
model: imageModel,
aspectRatio: dialog.aspectRatio ?? '16:9',
imageSize: dialog.imageSize ?? '1K',
...(references.length
? {
referenceImageSrcs: references.map((reference) =>
resolveImageReferenceSubmissionSource(reference),
),
}
: {}),
generationInputs: toEditorSceneGenerationInputs(generationInputs),
},
result: {
assetKind: 'scene',
title: resolveGenerationAssetLabel(
dialog.assetLabel,
`游戏场景 ${nextGeneratedIndex}`,
),
generationInputs,
},
rememberImageModel: imageModel,
};
}
if (dialog.mode === 'spec') {
const specType = dialog.specType ?? 'custom';
const specValues = dialog.specValues ?? DEFAULT_SPEC_FORM_VALUES[specType];
if (specType === 'icon') {
const generationInputs = buildSpecGenerationInputs(
specType,
specValues,
dialog.specReference,
);
return {
kind: 'icon-spec',
normalizedPrompt,
input: {
playSetting: specValues.playSetting.trim(),
artStyle: specValues.artStyle.trim(),
...(dialog.specReference?.src
? {
referenceId: resolveRegisteredEditorReferenceId(
dialog.specReference,
'参考图',
),
}
: {}),
generationInputs,
},
result: {
assetKind: 'icon-spec',
title: resolveGenerationAssetLabel(
dialog.assetLabel,
`${SPEC_TYPE_LABEL[specType]} ${nextGeneratedIndex}`,
),
generationInputs,
},
};
}
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: [
resolveImageReferenceSubmissionSource(dialog.specReference),
],
}
: {}),
},
result: {
assetKind: 'spec',
title: resolveGenerationAssetLabel(
dialog.assetLabel,
`${SPEC_TYPE_LABEL[specType]} ${nextGeneratedIndex}`,
),
generationInputs: buildSpecGenerationInputs(
specType,
specValues,
dialog.specReference,
),
},
};
}
if (dialog.mode === 'character') {
const referenceImageSrcs = [
dialog.characterSpecReference
? resolveImageReferenceSubmissionSource(dialog.characterSpecReference)
: null,
...(dialog.characterReferences ?? []).map((reference) =>
resolveImageReferenceSubmissionSource(reference),
),
].filter((src): src is string => Boolean(src));
const imageModel = normalizeEditorImageModel(dialog.imageModel);
const screenColor = DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR;
const segModel = DEFAULT_EDITOR_BGFILTER_SEG_MODEL;
const sheetEnabled = dialog.characterSheetEnabled === true;
// 中文注释:多方向角色图走后端 kind=character-sheet(绿色间隔底,便于后续拆分),
// 产物按 icon-spritesheet 落库,复用既有「拆分图集」能力;不做像素化、不去背景。
return {
kind: 'image',
normalizedPrompt,
input: {
prompt: normalizedPrompt,
kind: sheetEnabled ? 'character-sheet' : 'character',
model: imageModel,
...(sheetEnabled ? {} : { screenColor, segModel }),
style: sheetEnabled
? 'none'
: dialog.style === 'pixelArt'
? 'pixelArt'
: 'none',
aspectRatio: dialog.aspectRatio ?? '1:1',
imageSize: dialog.imageSize ?? '1K',
...(sheetEnabled
? {
assetKind: 'icon-spritesheet',
}
: {}),
...(referenceImageSrcs.length ? { referenceImageSrcs } : {}),
},
result: {
assetKind: sheetEnabled ? 'icon-spritesheet' : 'character',
title: resolveGenerationAssetLabel(
dialog.assetLabel,
sheetEnabled
? `角色多方向图 ${nextGeneratedIndex}`
: `角色形象 ${nextGeneratedIndex}`,
),
generationInputs: sheetEnabled
? buildCharacterSheetGenerationInputs(
normalizedPrompt,
dialog.characterSpecReference,
dialog.characterReferences,
{
model: imageModel,
aspectRatio: dialog.aspectRatio ?? '1:1',
imageSize: dialog.imageSize ?? '1K',
},
)
: buildCharacterGenerationInputs(
normalizedPrompt,
dialog.characterSpecReference,
dialog.characterReferences,
{
model: imageModel,
style: dialog.style === 'pixelArt' ? 'pixelArt' : 'none',
aspectRatio: dialog.aspectRatio ?? '1:1',
imageSize: dialog.imageSize ?? '1K',
},
),
},
rememberImageModel: imageModel,
};
}
if (dialog.mode === 'ui-design') {
const imageModel = IMAGE_MODEL_GPT_IMAGE_2;
const referenceImageSrcs = [
dialog.uiDesignSpecReference
? resolveImageReferenceSubmissionSource(dialog.uiDesignSpecReference)
: null,
...(dialog.generationReferences ?? []).map((reference) =>
resolveImageReferenceSubmissionSource(reference),
),
].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: normalizeGptImageSize(dialog.imageSize),
...(referenceImageSrcs.length ? { referenceImageSrcs } : {}),
},
result: {
assetKind: 'ui-design',
title: resolveGenerationAssetLabel(
dialog.assetLabel,
`UI设计图 ${nextGeneratedIndex}`,
),
generationInputs: buildUiDesignGenerationInputs(
normalizedPrompt,
dialog.uiDesignSpecReference,
dialog.generationReferences,
{
model: imageModel,
aspectRatio: dialog.aspectRatio ?? '16:9',
imageSize: normalizeGptImageSize(dialog.imageSize),
},
),
},
rememberImageModel: imageModel,
};
}
if (dialog.mode === 'publication') {
const workflow = getPublicationMaterialsWorkflow(
dialog.publicationWorkflowId ?? 'publication-cover-image',
);
const imageModel = IMAGE_MODEL_GPT_IMAGE_2;
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) => resolveImageReferenceSubmissionSource(reference),
),
}
: {}),
},
result: {
assetKind: 'publication-material',
title: resolveGenerationAssetLabel(
dialog.assetLabel,
`${nextGeneratedIndex} 宣发素材`,
),
generationInputs: buildPublicationMaterialsGenerationInputs(
dialog.publicationGameInfo,
dialog.publicationReferences,
workflow.id,
),
},
};
}
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,
...(seedanceReferences.referenceImageSrcs.length
? { referenceImageSrcs: seedanceReferences.referenceImageSrcs }
: {}),
...(seedanceReferences.referenceVideoSrcs.length
? { referenceVideoSrcs: seedanceReferences.referenceVideoSrcs }
: {}),
...(seedanceReferences.referenceAudioSrcs.length
? { referenceAudioSrcs: seedanceReferences.referenceAudioSrcs }
: {}),
},
result: {
title: resolveGenerationAssetLabel(
dialog.assetLabel,
`生成视频 ${nextGeneratedIndex}`,
),
generationInputs: buildVideoGenerationInputs(
normalizedPrompt,
dialog.generationReferences,
{
model,
aspectRatio,
resolution,
durationSeconds,
sound,
webSearchEnabled,
},
),
},
};
}
if (dialog.mode === 'audio-sound-effect') {
const soundModel = EDITOR_SOUND_EFFECT_MODEL;
const durationSeconds =
dialog.soundDurationMode === 'auto'
? null
: typeof dialog.soundDurationSeconds === 'number'
? dialog.soundDurationSeconds
: DEFAULT_SOUND_EFFECT_DURATION_SECONDS;
const loop = dialog.soundLoop === true;
return {
kind: 'audio',
audioKind: 'sound-effect',
normalizedPrompt,
input: {
prompt: normalizedPrompt,
model: soundModel,
duration: durationSeconds,
loop,
},
result: {
title: resolveGenerationAssetLabel(
dialog.assetLabel,
`游戏音效 ${nextGeneratedIndex}`,
),
generationInputs: buildSoundEffectGenerationInputs(
normalizedPrompt,
soundModel,
durationSeconds,
loop,
),
},
};
}
const imageModel = normalizeEditorImageModel(dialog.imageModel);
return {
kind: 'image',
normalizedPrompt,
input: {
prompt: normalizedPrompt,
model: imageModel,
...(dialog.mode === 'generate'
? { style: dialog.style === 'pixelArt' ? 'pixelArt' : 'none' }
: {}),
aspectRatio: dialog.aspectRatio ?? '1:1',
imageSize: dialog.imageSize ?? '1K',
...(dialog.generationReferences?.length
? {
referenceImageSrcs: dialog.generationReferences.map((reference) =>
resolveImageReferenceSubmissionSource(reference),
),
}
: {}),
},
result: {
title: resolveGenerationAssetLabel(
dialog.assetLabel,
`生成图片 ${nextGeneratedIndex}`,
),
generationInputs: buildImageGenerationInputs(
normalizedPrompt,
dialog.generationReferences,
{
model: imageModel,
style: dialog.style === 'pixelArt' ? 'pixelArt' : 'none',
aspectRatio: dialog.aspectRatio ?? '1:1',
imageSize: dialog.imageSize ?? '1K',
},
),
},
rememberImageModel: imageModel,
};
}
export function buildIconSpritesheetGenerationSubmissionPlan(
dialog: GenerateDialogState,
nextGeneratedIndex = 1,
): IconSpritesheetGenerationSubmissionPlan {
const normalizedPrompt =
dialog.prompt.trim() ||
(dialog.iconDescriptions ?? [])
.map((description) => description.trim())
.filter(Boolean)
.join('\n');
const iconDescriptions = normalizedPrompt ? [normalizedPrompt] : [];
if (!dialog.iconSpecReference) {
return {
ok: false,
errorMessage: '请选择图标规范',
};
}
if (!iconDescriptions.length) {
return {
ok: false,
errorMessage: '请填写素材描述',
};
}
if (Array.from(normalizedPrompt).length > EDITOR_ICON_DESCRIPTION_MAX_CHARS) {
return {
ok: false,
errorMessage: `素材描述不能超过 ${EDITOR_ICON_DESCRIPTION_MAX_CHARS} 个字符`,
};
}
let referenceId: string;
try {
referenceId = resolveRegisteredEditorReferenceId(
dialog.iconSpecReference,
'参考图',
);
} catch (error) {
return {
ok: false,
errorMessage:
error instanceof Error && error.message.trim()
? error.message
: '图标规范尚未完成资源登记,请重新选择或上传后再试',
};
}
const rememberImageModel = normalizeEditorImageModel(dialog.imageModel);
const screenColor = DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR;
const segModel = DEFAULT_EDITOR_BGFILTER_SEG_MODEL;
return {
ok: true,
iconDescriptions,
input: {
referenceId,
...(dialog.generationReferences?.length
? {
referenceImageSrcs: dialog.generationReferences.map((reference) =>
resolveImageReferenceSubmissionSource(reference),
),
}
: {}),
iconDescriptions,
model: rememberImageModel,
screenColor,
segModel,
style: dialog.style === 'pixelArt' ? 'pixelArt' : 'none',
aspectRatio: dialog.aspectRatio ?? '1:1',
imageSize: dialog.imageSize ?? '1K',
},
generationInputs: buildIconGenerationInputs(
iconDescriptions,
dialog.iconSpecReference,
dialog.generationReferences,
{
model: rememberImageModel,
style: dialog.style === 'pixelArt' ? 'pixelArt' : 'none',
aspectRatio: dialog.aspectRatio ?? '1:1',
imageSize: dialog.imageSize ?? '1K',
},
),
rememberImageModel,
resultTitle: resolveGenerationAssetLabel(
dialog.assetLabel,
`图标素材图集 ${nextGeneratedIndex}`,
),
};
}
export function buildCharacterAnimationSubmissionPlan({
panel,
sourceLayer,
reference = null,
}: {
panel: CharacterAnimationPanelState;
sourceLayer: CanvasLayer | null;
reference?: CharacterReferenceImage | null;
}): CharacterAnimationSubmissionPlan {
const promptText = panel.promptText.trim();
const resultTitle = resolveGenerationAssetLabel(panel.assetLabel, '角色动作');
if (!promptText && !reference && !sourceLayer) {
throw new Error('动画描述和参考素材不能同时为空。');
}
const inputMode =
reference?.mediaType === 'video'
? ('video' as const)
: reference || sourceLayer
? ('image' as const)
: ('text' as const);
const referenceSrc = reference
? reference.objectKey?.trim() || reference.src
: sourceLayer
? resolveCharacterAnimationSourceImageSrc(sourceLayer)
: undefined;
const referenceWidth =
typeof reference?.width === 'number' &&
Number.isFinite(reference.width) &&
reference.width > 0
? Math.round(reference.width)
: sourceLayer?.originalWidth;
const referenceHeight =
typeof reference?.height === 'number' &&
Number.isFinite(reference.height) &&
reference.height > 0
? Math.round(reference.height)
: sourceLayer?.originalHeight;
return {
promptText,
resultTitle,
input: {
inputMode,
...(sourceLayer ? { sourceLayerId: sourceLayer.id } : {}),
...(inputMode === 'image' && referenceSrc
? { sourceImageSrc: referenceSrc }
: {}),
...(referenceWidth && referenceHeight
? {
sourceWidth: referenceWidth,
sourceHeight: referenceHeight,
}
: {}),
...(referenceSrc
? {
referenceMediaType: inputMode === 'video' ? 'video' : 'image',
referenceSrc,
}
: {}),
promptText,
...(panel.backgroundColor
? { backgroundColor: panel.backgroundColor }
: {}),
resolution: panel.resolution,
ratio: panel.ratio,
frameCount: panel.frameCount,
durationSeconds: panel.durationSeconds,
model: CHARACTER_ANIMATION_MODEL,
assetLabel: resultTitle,
},
};
}