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, ) { 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( [ ['edit', '修改当前图片'], ['audio-sound-effect', '游戏音效'], ['audio-background-music', '游戏背景音乐'], ], ); const REQUIRED_GENERATION_PROMPT_MODES = new Set([ '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( 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; 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, }, }; }