import type { EditorBackgroundMusicGenerationInput, EditorCharacterAnimationGenerationInput, EditorIconSpritesheetGenerationInput, EditorImageEditInput, 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, buildQuickEditGenerationInputs, buildSoundEffectGenerationInputs, buildSpecGenerationInputs, buildSpecPrompt, buildUiDesignGenerationInputs, buildVideoGenerationInputs, CHARACTER_ANIMATION_MODEL, DEFAULT_EDITOR_BGFILTER_SEG_MODEL, DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR, 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, IMAGE_MODEL_GPT_IMAGE_2, 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; }; export const EDITOR_GENERATED_ASSET_LABEL_MAX_CHARS = 80; 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; } function buildSeedanceVideoReferenceInput( references: NonNullable, ) { 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), }; } 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; resultTitle: string; generationInputs: CanvasGenerationInputs; } | { kind: 'image'; normalizedPrompt: string; input: EditorImageGenerationInput; result: { assetKind?: CanvasLayer['assetKind']; title?: string; generationInputs: CanvasGenerationInputs; }; rememberImageModel?: string; } | { kind: 'quick-edit'; normalizedPrompt: string; sourceLayer: CanvasLayer; editInput: Pick; 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, }: 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, resultTitle: resolveGenerationAssetLabel( dialog.assetLabel, `${sourceLayer.title} 修改结果`, ), generationInputs: buildEditGenerationInputs( '修改要求', normalizedPrompt, sourceLayer, ), }; } 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 imageModel = IMAGE_MODEL_GPT_IMAGE_2; return { kind: 'quick-edit', normalizedPrompt: normalizedQuickEditPrompt, sourceLayer, editInput: { size: `${sourceLayer.originalWidth}x${sourceLayer.originalHeight}`, model: imageModel, }, result: { title: resolveGenerationAssetLabel( dialog.assetLabel, `${sourceLayer.title} 快速编辑`, ), assetKind: sourceLayer.assetKind, generationInputs: buildQuickEditGenerationInputs( '快速编辑提示词', normalizedQuickEditPrompt, sourceLayer, [], ), }, rememberImageModel: imageModel, }; } 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: [ resolveImageReferenceSubmissionSource(dialog.specReference), ], } : {}), }, result: { // 中文注释:生成规范菜单里的“图标规范”沿用历史 ui specType,但产物语义应作为图标规范供图标素材引用。 assetKind: specType === 'ui' || specType === 'icon' ? 'icon-spec' : '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; return { kind: 'image', normalizedPrompt, input: { prompt: normalizedPrompt, kind: 'character', model: imageModel, screenColor, segModel, style: dialog.style === 'pixelArt' ? 'pixelArt' : 'none', aspectRatio: dialog.aspectRatio ?? '1:1', imageSize: dialog.imageSize ?? '1K', ...(referenceImageSrcs.length ? { referenceImageSrcs } : {}), }, result: { assetKind: 'character', title: resolveGenerationAssetLabel( dialog.assetLabel, `角色形象 ${nextGeneratedIndex}`, ), generationInputs: buildCharacterGenerationInputs( normalizedPrompt, dialog.characterSpecReference, dialog.characterReferences, ), }, 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, ), }, 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, ), }, }; } 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, ), }, }; } 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, }, result: { title: resolveGenerationAssetLabel( dialog.assetLabel, `游戏音效 ${nextGeneratedIndex}`, ), generationInputs: buildSoundEffectGenerationInputs( normalizedPrompt, soundModel, durationSeconds, ), }, }; } if (dialog.mode === 'audio-background-music') { return { kind: 'audio', audioKind: 'background-music', normalizedPrompt, input: { gptDescriptionPrompt: normalizedPrompt, makeInstrumental: true, }, result: { title: resolveGenerationAssetLabel( dialog.assetLabel, `游戏背景音乐 ${nextGeneratedIndex}`, ), generationInputs: buildBackgroundMusicGenerationInputs(normalizedPrompt), }, }; } 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, ), }, 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: '请填写素材描述', }; } const rememberImageModel = normalizeEditorImageModel(dialog.imageModel); const screenColor = DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR; const segModel = DEFAULT_EDITOR_BGFILTER_SEG_MODEL; return { ok: true, iconDescriptions, input: { referenceImageSrc: resolveImageReferenceSubmissionSource( dialog.iconSpecReference, ), ...(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, ), rememberImageModel, resultTitle: resolveGenerationAssetLabel( dialog.assetLabel, `图标素材图集 ${nextGeneratedIndex}`, ), }; } export function buildCharacterAnimationSubmissionPlan({ panel, sourceLayer, }: { panel: CharacterAnimationPanelState; sourceLayer: CanvasLayer; }): CharacterAnimationSubmissionPlan { const promptText = panel.promptText.trim(); const resultTitle = resolveGenerationAssetLabel(panel.assetLabel, '角色动作'); return { promptText, resultTitle, input: { sourceLayerId: sourceLayer.id, sourceImageSrc: resolveCharacterAnimationSourceImageSrc(sourceLayer), sourceWidth: sourceLayer.originalWidth, sourceHeight: sourceLayer.originalHeight, promptText, // 与生图入口一致:默认 auto,由后端做背景色自动决策。 screenColor: DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR, resolution: panel.resolution, ratio: panel.ratio, frameCount: panel.frameCount, durationSeconds: panel.durationSeconds, model: CHARACTER_ANIMATION_MODEL, assetLabel: resultTitle, }, }; }