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, ) { 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, }, }; }