import { EDITOR_SOUND_EFFECT_MODEL, SOUND_EFFECT_DURATION_MAX_SECONDS, SOUND_EFFECT_DURATION_MIN_SECONDS, } from '../../../packages/shared/src/contracts/editorAudio'; import { CUSTOM_EDITOR_SCENE_STYLE_PRESET, DEFAULT_EDITOR_SCENE_STYLE_PRESET, EDITOR_SCENE_STYLE_PRESET_OPTIONS, } from '../../../packages/shared/src/contracts/editorScene'; import { ApiClientError } from '../../services/apiClient'; import type { EditorGenerationPricingConfig } from '../../services/image-editor/editorProjectClient'; import type { EditorCharacterAnimationRatio, EditorCharacterAnimationResolution, } from '../../services/image-editor/editorProjectClient'; import { isEditorInternalProcessingModel, isGeneratedLayer, } from './ImageCanvasEditorModel'; import type { CanvasGenerationAction, CanvasGenerationDialogState, CanvasGenerationInputField, CanvasGenerationInputReference, CanvasGenerationInputs, CanvasGenerationInputValue, CanvasLayer, CharacterReferenceImage, GenerateDialogState, PublicationMaterialsGameInfo, PublicationMaterialsWorkflowId, SpecFormValues, SpecGenerationType, } from './ImageCanvasEditorTypes'; import { isNormalizedCanvasGenerationInputsStructure, isRemixableCanvasGenerationAction, } from './ImageCanvasGenerationInputsModel'; import { getPublicationMaterialsWorkflow, type PublicationMaterialsWorkflow, } from './ImageCanvasPublicationMaterialsModel'; // 中文注释:与 api-server/src/editor_generation_config.rs 保持同名模型定价语义,前端仅作为展示兜底。 export const IMAGE_MODEL_GPT_IMAGE_2 = 'gpt-image-2'; export const IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview'; export const DEFAULT_IMAGE_MODEL = IMAGE_MODEL_NANOBANANA2; const IMAGE_MODEL_NANOBANANA_ALIASES = new Set([ IMAGE_MODEL_NANOBANANA2, 'nanobanana2', 'nano-banana', ]); export const SPEC_GENERATION_MODEL = IMAGE_MODEL_GPT_IMAGE_2; export const SPEC_GENERATION_ASPECT_RATIO = '16:9'; export const SPEC_GENERATION_IMAGE_SIZE = '2K'; export const SPEC_GENERATION_SIZE = '2048x1152'; export const SPEC_FRAME_ORIGINAL_SIZE = { width: 2048, height: 1152 }; export const SPEC_FRAME_DISPLAY_SIZE = { width: 560, height: 315 }; export const CHARACTER_FRAME_ORIGINAL_SIZE = { width: 2048, height: 2048 }; export const CHARACTER_FRAME_DISPLAY_SIZE = { width: 420, height: 420 }; export const CHARACTER_ANIMATION_FRAME_ORIGINAL_SIZE = { width: 1024, height: 1024, }; export const CHARACTER_ANIMATION_FRAME_DISPLAY_SIZE = { width: 420, height: 420, }; export const ICON_FRAME_ORIGINAL_SIZE = { width: 512, height: 512 }; export const ICON_FRAME_DISPLAY_SIZE = { width: 360, height: 360 }; export const UI_DESIGN_FRAME_ORIGINAL_SIZE = { width: 2048, height: 1152 }; export const UI_DESIGN_FRAME_DISPLAY_SIZE = { width: 560, height: 315 }; export const PUBLICATION_FRAME_ORIGINAL_SIZE: Record< PublicationMaterialsWorkflowId, { width: number; height: number } > = { 'publication-cover-image': { width: 720, height: 540 }, 'publication-detail-gallery': { width: 720, height: 1280 }, 'publication-promo-poster': { width: 1280, height: 720 }, }; export const PUBLICATION_FRAME_DISPLAY_SIZE: Record< PublicationMaterialsWorkflowId, { width: number; height: number } > = { 'publication-cover-image': { width: 480, height: 360 }, 'publication-detail-gallery': { width: 360, height: 640 }, 'publication-promo-poster': { width: 560, height: 315 }, }; export const EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG = { [IMAGE_MODEL_NANOBANANA2]: { '0.5K': 8, '1K': 12, '2K': 24, }, [IMAGE_MODEL_GPT_IMAGE_2]: { '1K': 3, '2K': 5, }, } as const; export const SPEC_GENERATION_COST = EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[SPEC_GENERATION_MODEL][ SPEC_GENERATION_IMAGE_SIZE ]; export const DEFAULT_PUBLICATION_GAME_INFO: PublicationMaterialsGameInfo = { gameName: '', gameCategories: '', gameDescription: '', }; export const DEFAULT_ICON_DESCRIPTIONS = [ '返回按钮', '设置按钮', '下一关按钮', '提示按钮', '原图按钮', '冻结按钮', ]; export const DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR = 'auto'; export const DEFAULT_EDITOR_BGFILTER_SEG_MODEL = 'birefnet'; export const UI_DESIGN_ASSET_EXTRACTION_PROMPT = '仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。'; export const QUICK_EDIT_SIZE_PRESETS = [ '1024x1024', '1536x1024', '2048x1152', '1024x1536', ] as const; export const EDITOR_IMAGE_MODEL_OPTIONS = [ { label: 'nanobanana2', value: IMAGE_MODEL_NANOBANANA2 }, { label: 'gpt-image-2', value: IMAGE_MODEL_GPT_IMAGE_2 }, ] as const; export const QUICK_EDIT_MODEL_OPTIONS = EDITOR_IMAGE_MODEL_OPTIONS; export const EDITOR_IMAGE_DIMENSION_OPTIONS = { [IMAGE_MODEL_NANOBANANA2]: { aspectRatios: ['1:1', '4:3', '3:2', '2:3', '9:16', '16:9'], imageSizes: ['0.5K', '1K', '2K'], }, [IMAGE_MODEL_GPT_IMAGE_2]: { aspectRatios: ['1:1', '4:3', '3:2', '2:3', '9:16', '16:9'], imageSizes: ['1K', '2K'], }, } as const; type EditorImageFrameSize = { width: number; height: number; }; function getSafeImageSizeValue(imageSize: string | null | undefined) { return (imageSize ?? '1K').trim().toUpperCase(); } function getImageSizeLongEdge(imageSize: string | null | undefined) { const normalizedImageSize = getSafeImageSizeValue(imageSize); if (normalizedImageSize === '0.5K') { return 512; } if (normalizedImageSize === '2K') { return 2048; } return 1024; } function resolveAspectRatioNumbers(aspectRatio: string | null | undefined) { const [rawWidth = 1, rawHeight = 1] = (aspectRatio ?? '1:1') .split(':') .map((value) => Number(value)); const width = Number.isFinite(rawWidth) && rawWidth > 0 ? rawWidth : 1; const height = Number.isFinite(rawHeight) && rawHeight > 0 ? rawHeight : 1; return { width, height }; } function resolveImageFrameSizeFromRatio({ aspectRatio, imageSize, }: { aspectRatio: string | null | undefined; imageSize: string | null | undefined; }): EditorImageFrameSize { const longEdge = getImageSizeLongEdge(imageSize); const ratio = resolveAspectRatioNumbers(aspectRatio); if (ratio.width >= ratio.height) { return { width: longEdge, height: Math.max(1, Math.round((longEdge * ratio.height) / ratio.width)), }; } return { width: Math.max(1, Math.round((longEdge * ratio.width) / ratio.height)), height: longEdge, }; } export function resolveEditorImageGenerationPixelSize({ model: _model, aspectRatio, imageSize, }: { model: string | null | undefined; aspectRatio: string | null | undefined; imageSize: string | null | undefined; }): EditorImageFrameSize { return resolveImageFrameSizeFromRatio({ aspectRatio, imageSize }); } export function resolveEditorImageSizeLabel({ aspectRatio, imageSize, }: { aspectRatio: string | null | undefined; imageSize: string | null | undefined; }) { return `${aspectRatio?.trim() || '1:1'}·${getSafeImageSizeValue(imageSize)}`; } export function inferEditorImageAspectRatio(width: number, height: number) { const safeWidth = Number.isFinite(width) && width > 0 ? Math.round(width) : 1; const safeHeight = Number.isFinite(height) && height > 0 ? Math.round(height) : 1; const commonRatios = EDITOR_IMAGE_DIMENSION_OPTIONS[IMAGE_MODEL_NANOBANANA2] .aspectRatios as readonly string[]; const sourceRatio = safeWidth / safeHeight; return commonRatios.reduce((bestRatio, ratio) => { const bestNumbers = resolveAspectRatioNumbers(bestRatio); const ratioNumbers = resolveAspectRatioNumbers(ratio); const bestDiff = Math.abs( bestNumbers.width / bestNumbers.height - sourceRatio, ); const ratioDiff = Math.abs( ratioNumbers.width / ratioNumbers.height - sourceRatio, ); return ratioDiff < bestDiff ? ratio : bestRatio; }, commonRatios[0] ?? '1:1'); } export function inferEditorImageSizeLabel(width: number, height: number) { const longEdge = Math.max(width, height); if (longEdge <= 768) { return '0.5K'; } if (longEdge > 1536) { return '2K'; } return '1K'; } export function resizeGenerationPlaceholderToImageSelection< T extends GenerateDialogState, >(dialog: T): T { if (!dialog.placeholder) { return dialog; } const size = resolveEditorImageGenerationPixelSize({ model: dialog.imageModel, aspectRatio: dialog.aspectRatio, imageSize: dialog.imageSize, }); const centerX = dialog.placeholder.x + dialog.placeholder.width / 2; const centerY = dialog.placeholder.y + dialog.placeholder.height / 2; return { ...dialog, placeholder: { ...dialog.placeholder, x: centerX - size.width / 2, y: centerY - size.height / 2, width: size.width, height: size.height, originalWidth: size.width, originalHeight: size.height, }, } as T; } function resolveEvenVideoWidth( height: number, aspectRatio: string | null | undefined, ) { const ratio = resolveAspectRatioNumbers(aspectRatio ?? '16:9'); const rawWidth = Math.max( 1, Math.round((height * ratio.width) / ratio.height), ); return rawWidth % 2 === 0 ? rawWidth : rawWidth + 1; } export function resolveEditorVideoGenerationPixelSize({ aspectRatio, resolution, }: { aspectRatio: string | null | undefined; resolution: '480p' | '720p' | '1080p' | string | null | undefined; }): EditorImageFrameSize { const height = resolution === '1080p' ? 1080 : resolution === '720p' ? 720 : 480; return { width: resolveEvenVideoWidth(height, aspectRatio), height, }; } export function resizeGenerationPlaceholderToVideoSelection( dialog: GenerateDialogState, ): GenerateDialogState { if (!dialog.placeholder) { return dialog; } const size = resolveEditorVideoGenerationPixelSize({ aspectRatio: dialog.videoAspectRatio, resolution: dialog.videoResolution, }); const centerX = dialog.placeholder.x + dialog.placeholder.width / 2; const centerY = dialog.placeholder.y + dialog.placeholder.height / 2; return { ...dialog, placeholder: { ...dialog.placeholder, x: centerX - size.width / 2, y: centerY - size.height / 2, width: size.width, height: size.height, originalWidth: size.width, originalHeight: size.height, }, }; } export const VIDEO_MODEL_SEEDANCE_2 = 'seedance2.0'; export const VIDEO_MODEL_SEEDANCE_2_FAST = 'seedance2.0-fast'; export const VIDEO_MODEL_KLING_3 = 'kling3.0'; export const VIDEO_MODEL_KLING_3_OMNI = 'kling3.0-omni'; export const VIDEO_MODEL_VEO_3_1 = 'veo3.1'; export const VIDEO_MODEL_VEO_3_1_FAST = 'veo3.1-fast'; export const DEFAULT_VIDEO_MODEL = VIDEO_MODEL_SEEDANCE_2_FAST; export const CHARACTER_ANIMATION_MODEL = VIDEO_MODEL_SEEDANCE_2_FAST; export const SOUND_EFFECT_MODEL_VIDU = 'audio1.0'; export const DEFAULT_SOUND_EFFECT_MODEL = EDITOR_SOUND_EFFECT_MODEL; export const DEFAULT_SOUND_EFFECT_DURATION_SECONDS = 5; export const SOUND_EFFECT_DURATION_OPTIONS = Array.from( { length: 9 }, (_, index) => index + 2, ); export const BACKGROUND_MUSIC_MODEL_SUNO = 'chirp-v5'; export const DEFAULT_BACKGROUND_MUSIC_MODEL = BACKGROUND_MUSIC_MODEL_SUNO; const EDITOR_VIDEO_RESOLUTION_RATE_CONFIG = { '480p': 10, '720p': 20, '1080p': 40, } as const; const EDITOR_VIDEO_STANDARD_RESOLUTION_RATE_CONFIG = { '480p': 12, '720p': 24, '1080p': 48, } as const; const EDITOR_VIDEO_KLING_RESOLUTION_RATE_CONFIG = { '480p': 15, '720p': 30, '1080p': 60, } as const; const EDITOR_VIDEO_KLING_OMNI_RESOLUTION_RATE_CONFIG = { '480p': 20, '720p': 40, '1080p': 80, } as const; export const EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG = { [VIDEO_MODEL_SEEDANCE_2_FAST]: EDITOR_VIDEO_RESOLUTION_RATE_CONFIG, [VIDEO_MODEL_SEEDANCE_2]: EDITOR_VIDEO_STANDARD_RESOLUTION_RATE_CONFIG, [VIDEO_MODEL_KLING_3]: EDITOR_VIDEO_KLING_RESOLUTION_RATE_CONFIG, [VIDEO_MODEL_KLING_3_OMNI]: EDITOR_VIDEO_KLING_OMNI_RESOLUTION_RATE_CONFIG, // 中文注释:前端不展示 Veo 入口,但客户端类型和后端兼容层仍可接收旧布局回放,定价表必须同步覆盖。 [VIDEO_MODEL_VEO_3_1]: EDITOR_VIDEO_RESOLUTION_RATE_CONFIG, [VIDEO_MODEL_VEO_3_1_FAST]: EDITOR_VIDEO_RESOLUTION_RATE_CONFIG, } as const; export const EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG = { [EDITOR_SOUND_EFFECT_MODEL]: 5, [SOUND_EFFECT_MODEL_VIDU]: 5, } as const; export const EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG = { [BACKGROUND_MUSIC_MODEL_SUNO]: 12, } as const; export const EDITOR_MODEL_MUD_POINT_CONFIG = { [IMAGE_MODEL_NANOBANANA2]: { unit: 'perGeneration', prices: EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_NANOBANANA2], }, [IMAGE_MODEL_GPT_IMAGE_2]: { unit: 'perGeneration', prices: EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[IMAGE_MODEL_GPT_IMAGE_2], }, [VIDEO_MODEL_SEEDANCE_2_FAST]: { unit: 'perSecond', prices: EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_SEEDANCE_2_FAST], }, [VIDEO_MODEL_SEEDANCE_2]: { unit: 'perSecond', prices: EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_SEEDANCE_2], }, [VIDEO_MODEL_KLING_3]: { unit: 'perSecond', prices: EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_KLING_3], }, [VIDEO_MODEL_KLING_3_OMNI]: { unit: 'perSecond', prices: EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_KLING_3_OMNI], }, [VIDEO_MODEL_VEO_3_1]: { unit: 'perSecond', prices: EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_VEO_3_1], }, [VIDEO_MODEL_VEO_3_1_FAST]: { unit: 'perSecond', prices: EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_VEO_3_1_FAST], }, [SOUND_EFFECT_MODEL_VIDU]: { unit: 'perGeneration', price: EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG[SOUND_EFFECT_MODEL_VIDU], }, [EDITOR_SOUND_EFFECT_MODEL]: { unit: 'perGeneration', price: EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG[EDITOR_SOUND_EFFECT_MODEL], }, [BACKGROUND_MUSIC_MODEL_SUNO]: { unit: 'perGeneration', price: EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[ BACKGROUND_MUSIC_MODEL_SUNO ], }, } satisfies EditorGenerationPricingConfig['models']; let runtimeEditorGenerationPricingConfig: EditorGenerationPricingConfig = { models: cloneModelPricing(EDITOR_MODEL_MUD_POINT_CONFIG), }; export const EDITOR_SOUND_EFFECT_MODEL_OPTIONS = [ { label: 'ElevenLabs', value: EDITOR_SOUND_EFFECT_MODEL }, ] as const; export const SEEDANCE_VIDEO_REFERENCE_LIMITS = { image: 9, video: 3, audio: 3, } as const; export const QUICK_EDIT_REFERENCE_LIMIT = 8; export const IMAGE_GENERATION_REFERENCE_LIMIT = 5; export const ICON_EXTRA_REFERENCE_LIMIT = 8; export const UI_EXTRA_REFERENCE_LIMIT = 5; export function resolveImageProviderReferenceLimit( model: string | null | undefined, ) { return normalizeEditorImageModel(model) === IMAGE_MODEL_NANOBANANA2 ? 14 : 5; } export function resolveExtraImageReferenceLimit( model: string | null | undefined, productLimit: number, primaryReferenceCount = 0, ) { return Math.max( 0, Math.min( productLimit, resolveImageProviderReferenceLimit(model) - primaryReferenceCount, ), ); } export function resolveDialogExtraImageReferenceLimit( dialog: GenerateDialogState, model: string | null | undefined = dialog.imageModel ?? (dialog.mode === 'spec' ? SPEC_GENERATION_MODEL : undefined), ) { if (dialog.mode === 'icon') { return resolveExtraImageReferenceLimit( model, ICON_EXTRA_REFERENCE_LIMIT, 1, ); } if (dialog.mode === 'ui-design') { return resolveExtraImageReferenceLimit( model, IMAGE_GENERATION_REFERENCE_LIMIT - 1, 1, ); } if (dialog.mode === 'quick-edit') { return resolveExtraImageReferenceLimit( model, QUICK_EDIT_REFERENCE_LIMIT, 1, ); } if (dialog.mode === 'character') { return resolveExtraImageReferenceLimit( model, IMAGE_GENERATION_REFERENCE_LIMIT - 1, 1, ); } return resolveExtraImageReferenceLimit( model, IMAGE_GENERATION_REFERENCE_LIMIT, ); } export function countDialogExtraImageReferences(dialog: GenerateDialogState) { const references = dialog.mode === 'character' ? dialog.characterReferences : dialog.mode === 'publication' ? dialog.publicationReferences : dialog.mode === 'video' ? [] : dialog.generationReferences; return (references ?? []).filter( (reference) => (reference.mediaType ?? 'image') === 'image', ).length; } export const CHARACTER_ANIMATION_ACTION_PROMPTS = [ { label: '待机', text: '待机动作,轻微呼吸起伏。' }, { label: '行走', text: '循环行走动作,步伐稳定。' }, { label: '奔跑', text: '循环奔跑动作,动作清晰有力。' }, { label: '跳跃', text: '起跳、滞空、落地动作。' }, { label: '攻击', text: '攻击动作,前摇、出手、收招清晰。' }, { label: '受击', text: '受击后短暂后仰并恢复站姿。' }, { label: '倒下', text: '倒下动作,重心下落自然。' }, ] as const; export const CHARACTER_ANIMATION_RATIO_OPTIONS: Array<{ label: string; value: EditorCharacterAnimationRatio; }> = [ { label: '与角色图片保持同尺寸', value: 'same' }, { label: '1:1', value: '1:1' }, { label: '4:3', value: '4:3' }, { label: '16:9', value: '16:9' }, { label: '9:16', value: '9:16' }, { label: '3:4', value: '3:4' }, ]; export const CHARACTER_ANIMATION_DURATION_OPTIONS = [ { label: '32帧·4秒', frameCount: 32, durationSeconds: 4 }, { label: '40帧·5秒', frameCount: 40, durationSeconds: 5 }, { label: '48帧·6秒', frameCount: 48, durationSeconds: 6 }, ] as const; export const VIDEO_FRAME_ORIGINAL_SIZE = { width: 854, height: 480 }; export const VIDEO_FRAME_DISPLAY_SIZE = { width: 854, height: 480 }; export const AUDIO_FRAME_ORIGINAL_SIZE = { width: 420, height: 120 }; export const AUDIO_FRAME_DISPLAY_SIZE = { width: 420, height: 120 }; export const EDITOR_VIDEO_MODEL_OPTIONS = [ { label: 'Seedance 2.0 Fast', value: VIDEO_MODEL_SEEDANCE_2_FAST }, { label: 'Seedance 2.0', value: VIDEO_MODEL_SEEDANCE_2 }, { label: 'Kling 3.0', value: VIDEO_MODEL_KLING_3 }, { label: 'Kling 3.0 Omni', value: VIDEO_MODEL_KLING_3_OMNI }, ] as const; export const EDITOR_VIDEO_DURATION_OPTIONS = [ 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, ] as const; export const EDITOR_VIDEO_ASPECT_RATIO_OPTIONS = [ '16:9', '9:16', '1:1', '4:3', '3:4', '21:9', ] as const; export const EDITOR_VIDEO_RESOLUTION_OPTIONS = [ '480p', '720p', '1080p', ] as const; export const DEFAULT_VIDEO_ASPECT_RATIO = '16:9'; export const DEFAULT_VIDEO_DURATION_SECONDS = 4; export const DEFAULT_VIDEO_SOUND = 'on'; export const DEFAULT_VIDEO_WEB_SEARCH_ENABLED = true; export const DEFAULT_SPEC_FORM_VALUES: Record< SpecGenerationType, SpecFormValues > = { character: { playSetting: '战棋类RPG玩法', artStyle: '像素风', bodyRatio: '3', characterView: '右向斜侧身站姿,保留少量正面信息,能读到面部轮廓与胸肩结构,禁止生成完全 90 度纯右视图,也禁止生成正面立绘。', customPrompt: '', }, icon: { playSetting: '', artStyle: '', bodyRatio: '3', characterView: '右向斜侧身站姿,保留少量正面信息,能读到面部轮廓与胸肩结构,禁止生成完全 90 度纯右视图,也禁止生成正面立绘。', customPrompt: '', }, custom: { playSetting: '', artStyle: '', bodyRatio: '3', characterView: '右向斜侧身站姿,保留少量正面信息,能读到面部轮廓与胸肩结构,禁止生成完全 90 度纯右视图,也禁止生成正面立绘。', customPrompt: '', }, }; export const SPEC_TYPE_LABEL: Record = { character: '角色规范', icon: '图标规范', custom: '自定义规范', }; export const CHARACTER_SPEC_VIEW_OPTIONS = [ DEFAULT_SPEC_FORM_VALUES.character.characterView, '左向三分之二侧身站姿', '左向三分之二侧身站姿,保留少量正面信息,能读到面部轮廓与胸肩结构,禁止生成完全 90 度纯左视图,也禁止生成正面立绘。', '右向三分之二侧身站姿,保留少量正面信息,强调面部轮廓、胸肩结构与主要装备层次。', '背向斜侧身站姿,保留少量侧脸信息,突出背部服饰层次、武器挂载与轮廓识别。', ]; export function buildQuickEditSizeOptions(currentSize: string) { return Array.from(new Set([currentSize, ...QUICK_EDIT_SIZE_PRESETS])); } export function normalizeEditorImageModel(model: string | null | undefined) { const normalizedModel = model?.trim(); if (!normalizedModel) { return DEFAULT_IMAGE_MODEL; } if (IMAGE_MODEL_NANOBANANA_ALIASES.has(normalizedModel)) { return IMAGE_MODEL_NANOBANANA2; } return normalizedModel; } export function getEditorImageModelDisplayName( model: string | null | undefined, ) { const normalizedModel = normalizeEditorImageModel(model); return ( EDITOR_IMAGE_MODEL_OPTIONS.find( (option) => option.value === normalizedModel, )?.label ?? normalizedModel ); } export function isEditorUserVisibleGenerationInputField( field: CanvasGenerationInputField, ) { return field.title.trim() !== '处理模型'; } const GENERATION_INPUT_DURATION_TITLES = new Set([ '时长', '请求时长', 'duration', ]); const GENERATION_INPUT_DURATION_DECIMALS = 2; /** * 时长字段落库的是模型返回的全精度秒数(例如 8.306938775510204 秒),展示时收敛到两位 * 小数。只作用于渲染:底层字段保持原值,重绘默认时长与作品时长推断仍按原精度解析。 */ export function formatEditorGenerationInputFieldValue( field: CanvasGenerationInputField, ) { const displayValue = formatGenerationInputValue(field.value); if (!GENERATION_INPUT_DURATION_TITLES.has(field.title.trim().toLowerCase())) { return displayValue; } // 只改写首个数字,"自动"这类无数字取值和"秒"后缀都原样保留。 return displayValue.replace(/\d+(?:\.\d+)?/u, (matched) => { const parsed = Number.parseFloat(matched); if (!Number.isFinite(parsed)) { return matched; } const factor = 10 ** GENERATION_INPUT_DURATION_DECIMALS; return String(Math.round(parsed * factor) / factor); }); } export function getEditorLayerModelDisplayName( model: string | null | undefined, ) { if (!model?.trim() || isEditorInternalProcessingModel(model)) { return '-'; } return getEditorImageModelDisplayName(model); } export function getEditorGenerationModelDisplayName( model: string | null | undefined, ) { const normalizedModel = model?.trim(); if (normalizedModel === BACKGROUND_MUSIC_MODEL_SUNO) { return 'Suno'; } const soundEffectLabel = EDITOR_SOUND_EFFECT_MODEL_OPTIONS.find( (option) => option.value === normalizedModel, )?.label; if (soundEffectLabel) { return soundEffectLabel; } const videoLabel = EDITOR_VIDEO_MODEL_OPTIONS.find( (option) => option.value === normalizedModel, )?.label; if (videoLabel) { return videoLabel; } return getEditorImageModelDisplayName(normalizedModel); } export function buildQuickEditModelOptions(currentModel: string) { void currentModel; const options = [...QUICK_EDIT_MODEL_OPTIONS]; return options; } const QUICK_EDIT_SUPPORTED_MEDIA_TYPES = new Set([ undefined, 'image', ]); const QUICK_EDIT_SUPPORTED_ASSET_KINDS = new Set([ undefined, null, 'spec', 'character', 'icon-spritesheet', 'icon-spec', 'publication-material', 'ui-design', 'scene', ]); export function isQuickEditSupportedLayer(layer: CanvasLayer) { return ( QUICK_EDIT_SUPPORTED_MEDIA_TYPES.has(layer.mediaType) && QUICK_EDIT_SUPPORTED_ASSET_KINDS.has(layer.assetKind) ); } export function buildCharacterSpecPrompt(values: SpecFormValues) { return [ '生成2D 角色美术视觉规范设定图,纯白底板,整齐排布全身标准立绘;固定统一头身比例、勾线粗细恒定;展示待机行走攻击基础动作帧样例,重心对齐不变位,服饰配饰分层结构示意,搭配专属角色色卡标注色号,无多余杂物,精准尺寸标注,高清矢量规范稿', '禁止模糊、笔触杂乱、光影方向混乱、比例畸形、3D 渲染、实景照片、水印、花纹堆砌、画面抖动错位效果、噪点,', `玩法设计:${values.playSetting.trim() || DEFAULT_SPEC_FORM_VALUES.character.playSetting}`, `美术风格:${values.artStyle.trim() || DEFAULT_SPEC_FORM_VALUES.character.artStyle}`, `头身比:${values.bodyRatio.trim() || DEFAULT_SPEC_FORM_VALUES.character.bodyRatio}`, `视角要求:${values.characterView.trim() || DEFAULT_SPEC_FORM_VALUES.character.characterView}`, ].join('\n'); } export function buildSpecPrompt( type: SpecGenerationType, values: SpecFormValues, hasReferenceImage = false, ) { const prompt = type === 'character' ? buildCharacterSpecPrompt(values) : values.customPrompt.trim(); if (!hasReferenceImage) { return prompt; } return [ '参考图生成规范:严格参考全部参考图的构图、风格、材质、色彩、形状语言和视觉层级生成本次规范图;参考图只作为美术方向和规范语义依据,不要直接复制参考图中的文字、水印或无关背景。', prompt, ].join('\n'); } export function getLayerKindLabel(layer: CanvasLayer) { if (layer.assetKind === 'spec') { return '规范'; } if (layer.assetKind === 'character') { return '角色'; } if (layer.assetKind === 'character-animation') { return '动作'; } if (layer.assetKind === 'icon') { return '图标'; } if (layer.assetKind === 'icon-spritesheet') { return '图集'; } if (layer.assetKind === 'icon-spec') { return '图标规范'; } if (layer.assetKind === 'publication-material') { return '宣发素材'; } if (layer.assetKind === 'ui-design') { return 'UI设计'; } if (layer.assetKind === 'scene') { return '游戏场景'; } if (layer.assetKind === 'video' || layer.mediaType === 'video') { return '视频'; } if (layer.assetKind === 'sound-effect') { return '音效'; } if (layer.assetKind === 'background-music') { return '背景音乐'; } if (layer.mediaType === 'audio') { return '音频'; } if (layer.mediaType === 'image-sequence') { return '序列帧'; } return null; } export function buildCharacterAnimationGenerationInputs( promptText: string, sourceLayer: CanvasLayer, options: { resolution?: string; ratio?: string; frameCount?: number; durationSeconds?: number; } = {}, ): CanvasGenerationInputs { return { version: 2, action: 'character-animation.generate', fields: [ ...createGenerationInputField('动作描述', promptText, 'prompt'), ...createGenerationInputField( '清晰度', options.resolution ?? '480p', 'resolution', ), ...createGenerationInputField('比例', options.ratio ?? 'same', 'ratio'), ...createGenerationInputField( '帧数', options.frameCount ?? 32, 'frameCount', ), ...createGenerationInputField( '时长', options.durationSeconds ?? 4, 'durationSeconds', ), ], references: createLayerGenerationInputReference('角色图片', sourceLayer, { id: 'source', }), }; } export function formatLayerImageType(layer: CanvasLayer) { if (layer.assetKind === 'spec') { return '规范图片'; } if (layer.assetKind === 'character') { return '角色图片'; } if (layer.assetKind === 'character-animation') { return '角色动作序列帧'; } if (layer.assetKind === 'icon') { return '图标素材图片'; } if (layer.assetKind === 'icon-spritesheet') { return '图标素材图集'; } if (layer.assetKind === 'icon-spec') { return '图标规范图片'; } if (layer.assetKind === 'publication-material') { return '宣发素材图片'; } if (layer.assetKind === 'ui-design') { return 'UI设计图'; } if (layer.assetKind === 'scene') { return '游戏场景'; } if (layer.assetKind === 'video' || layer.mediaType === 'video') { return '生成视频'; } if (layer.assetKind === 'sound-effect') { return '游戏音效'; } if (layer.assetKind === 'background-music') { return '游戏背景音乐'; } if (layer.mediaType === 'audio') { return '生成音频'; } if (layer.mediaType === 'image-sequence') { return '序列帧'; } return isGeneratedLayer(layer) ? '生成图片' : '上传图片'; } function normalizeImagePriceSize( model: string, imageSize: string | null | undefined, ) { const normalizedImageSize = getSafeImageSizeValue(imageSize); if (model === IMAGE_MODEL_NANOBANANA2 && normalizedImageSize === '0.5K') { return '0.5K'; } if (normalizedImageSize === '2K') { return '2K'; } return '1K'; } function getModelPrices(model: string | null | undefined) { const normalizedModel = model?.trim() || ''; return normalizedModel ? runtimeEditorGenerationPricingConfig.models[normalizedModel] : undefined; } function readModelTierPrice({ model, fallbackModel, tier, fallbackTier, }: { model: string; fallbackModel: string; tier: string; fallbackTier: string; }) { const modelPricing = getModelPrices(model) ?? getModelPrices(fallbackModel); const fallbackPricing = getModelPrices(fallbackModel); return ( modelPricing?.prices?.[tier] ?? modelPricing?.prices?.[fallbackTier] ?? modelPricing?.price ?? fallbackPricing?.prices?.[tier] ?? fallbackPricing?.prices?.[fallbackTier] ?? fallbackPricing?.price ?? 0 ); } function readModelFlatPrice(model: string, fallbackModel: string) { return ( getModelPrices(model)?.price ?? getModelPrices(fallbackModel)?.price ?? 0 ); } export function calculateCharacterAnimationPrice( model: string | null | undefined, resolution: EditorCharacterAnimationResolution, durationSeconds: number, ) { const normalizedModel = (model ?? CHARACTER_ANIMATION_MODEL).trim(); return ( readModelTierPrice({ model: normalizedModel, fallbackModel: CHARACTER_ANIMATION_MODEL, tier: resolution, fallbackTier: '480p', }) * durationSeconds ); } export function calculateEditorVideoPrice( model: string | null | undefined, resolution: '480p' | '720p' | '1080p', durationSeconds: number, ) { const normalizedModel = (model ?? DEFAULT_VIDEO_MODEL).trim(); return ( readModelTierPrice({ model: normalizedModel, fallbackModel: DEFAULT_VIDEO_MODEL, tier: resolution, fallbackTier: '480p', }) * durationSeconds ); } export function calculateEditorImageModelPrice( model: string | null | undefined, imageSize: string | null | undefined = '1K', ) { const normalizedModel = normalizeEditorImageModel(model); const normalizedSize = normalizeImagePriceSize(normalizedModel, imageSize); return readModelTierPrice({ model: normalizedModel, fallbackModel: DEFAULT_IMAGE_MODEL, tier: normalizedSize, fallbackTier: '1K', }); } export function calculateEditorSpecGenerationPrice( model: string | null | undefined = SPEC_GENERATION_MODEL, ) { return calculateEditorImageModelPrice( model ?? SPEC_GENERATION_MODEL, SPEC_GENERATION_IMAGE_SIZE, ); } export function calculateEditorImageGenerationPrice({ kind, model, imageSize, }: { kind?: string | null; model?: string | null; imageSize?: string | null; } = {}) { const normalizedKind = kind?.trim(); if (normalizedKind === 'spec') { return calculateEditorSpecGenerationPrice(model); } return calculateEditorImageModelPrice(model, imageSize); } export function calculateEditorIconSpritesheetPrice( model: string | null | undefined, imageSize: string | null | undefined = '1K', ) { return calculateEditorImageModelPrice(model, imageSize); } export function calculateEditorUiDesignPrice( model: string | null | undefined, imageSize: string | null | undefined = '1K', ) { return calculateEditorImageModelPrice(model, imageSize); } export function calculateEditorSoundEffectPrice( model: string | null | undefined, ) { const normalizedModel = (model ?? DEFAULT_SOUND_EFFECT_MODEL).trim(); return readModelFlatPrice(normalizedModel, DEFAULT_SOUND_EFFECT_MODEL); } export function calculateEditorBackgroundMusicPrice( model: string | null | undefined = DEFAULT_BACKGROUND_MUSIC_MODEL, ) { const normalizedModel = (model ?? DEFAULT_BACKGROUND_MUSIC_MODEL).trim(); return readModelFlatPrice(normalizedModel, DEFAULT_BACKGROUND_MUSIC_MODEL); } export function applyEditorGenerationPricingConfig( pricing: EditorGenerationPricingConfig, ) { runtimeEditorGenerationPricingConfig = { models: cloneModelPricing(pricing.models), }; } function cloneModelPricing( value: T, ): EditorGenerationPricingConfig['models'] { return Object.fromEntries( Object.entries(value).map(([model, pricing]) => [ model, { unit: pricing.unit, ...(typeof pricing.price === 'number' ? { price: pricing.price } : {}), ...(pricing.prices ? { prices: { ...pricing.prices } } : {}), }, ]), ); } export function resolveCharacterAnimationSourceImageSrc(layer: CanvasLayer) { if (layer.assetKind === 'character-animation') { const firstFrame = layer.imageSequenceFrames?.[0]; const source = firstFrame?.objectKey?.trim() || firstFrame?.imageSrc?.trim(); if (!source) { throw new Error('角色动作缺少正式序列首帧,无法继续生成。'); } return source; } // 中文注释:角色图已持久化到 OSS 时优先传 objectKey,避免把大 Data URL 塞进 JSON 请求体触发 body limit。 return layer.objectKey?.trim() || layer.src; } export function createCanvasLayerReference( layer: CanvasLayer, ): CharacterReferenceImage { const mediaType = layer.mediaType === 'video' || layer.mediaType === 'audio' ? layer.mediaType : undefined; return { id: `canvas-${layer.id}`, label: layer.title, src: layer.objectKey?.trim() || layer.src, ...(mediaType ? { mediaType } : {}), objectKey: layer.objectKey ?? undefined, assetObjectId: layer.assetObjectId ?? undefined, resourceId: layer.resourceId, sourceAssetId: layer.sourceAssetId ?? undefined, }; } export function createGenerationInputReference( title: string, reference: Pick< CharacterReferenceImage, 'label' | 'resourceId' | 'sourceAssetId' >, options: { id?: string; } = {}, ): CanvasGenerationInputReference[] { const resourceId = reference.resourceId?.trim(); if ( resourceId && !resourceId.startsWith('local-') && !resourceId.startsWith('generation-dialog:') ) { return [ { ...(options.id ? { id: options.id } : {}), title, label: reference.label, refType: 'project-resource', refId: resourceId, }, ]; } const sourceAssetId = reference.sourceAssetId?.trim(); return sourceAssetId && !sourceAssetId.startsWith('upload-') ? [ { ...(options.id ? { id: options.id } : {}), title, label: reference.label, refType: 'asset', refId: sourceAssetId, }, ] : []; } export function createLayerGenerationInputReference( title: string, layer: CanvasLayer, options: Parameters[2] = {}, ): CanvasGenerationInputReference[] { return createGenerationInputReference( title, { label: layer.title, resourceId: layer.resourceId, sourceAssetId: layer.sourceAssetId, }, options, ); } export function buildDeterministicGenerationInputs( action: | 'image.perfect-pixel' | 'spritesheet.split' | 'image.remove-background' | 'image.crop-expand', sourceLayer: CanvasLayer, sourceTitle = '原图', ): CanvasGenerationInputs { return { version: 2, action, fields: [], references: createLayerGenerationInputReference(sourceTitle, sourceLayer, { id: 'source', }), }; } export function appendLimitedQuickEditReferences( references: CharacterReferenceImage[] | undefined, nextReferences: CharacterReferenceImage[], ) { const imageReferences = nextReferences.filter( (reference) => (reference.mediaType ?? 'image') === 'image', ); return [...(references ?? []), ...imageReferences].slice( 0, QUICK_EDIT_REFERENCE_LIMIT, ); } export function appendLimitedImageReferences( references: CharacterReferenceImage[] | undefined, nextReferences: CharacterReferenceImage[], limit: number, ) { const imageReferences = nextReferences.filter( (reference) => (reference.mediaType ?? 'image') === 'image', ); return [...(references ?? []), ...imageReferences].slice( 0, Math.max(0, limit), ); } export function createGenerationInputField( title: string, value: CanvasGenerationInputValue | null | undefined, id?: string, ): CanvasGenerationInputField[] { const normalizedValue = typeof value === 'string' ? value.trim() : value; return normalizedValue === undefined || normalizedValue === null || normalizedValue === '' ? [] : [{ ...(id ? { id } : {}), title, value: normalizedValue }]; } export function formatGenerationInputValue(value: CanvasGenerationInputValue) { return typeof value === 'string' ? value : String(value); } export function isNormalizedCanvasGenerationInputs( value: CanvasGenerationInputs | null | undefined, ): value is CanvasGenerationInputs & { version: 2; action: CanvasGenerationAction; } { return isNormalizedCanvasGenerationInputsStructure(value); } type NormalizedCanvasGenerationInputs = CanvasGenerationInputs & { version: 2; action: CanvasGenerationAction; }; export type CanvasGenerationInputDecodeWarning = { code: 'parameter-fallback'; fieldIds: string[]; message: string; }; export type CanvasGenerationInputDecodeResult = | { ok: true; inputs: NormalizedCanvasGenerationInputs; warnings: CanvasGenerationInputDecodeWarning[]; } | { ok: false; inputs: null; warnings: []; error: 'invalid-structure'; }; export const CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING = '部分原生成参数已失效或不受支持,已使用当前默认值。'; export function decodeCanvasGenerationInputs( value: CanvasGenerationInputs | null | undefined, ): CanvasGenerationInputDecodeResult { if (!isNormalizedCanvasGenerationInputs(value)) { return { ok: false, inputs: null, warnings: [], error: 'invalid-structure', }; } const fields = value.fields.map((field) => ({ ...field })); const warnings: CanvasGenerationInputDecodeWarning[] = []; const warnedFieldGroups = new Set(); const addFallbackWarning = (fieldIds: string[]) => { const key = [...fieldIds].sort().join('|'); if (warnedFieldGroups.has(key)) { return; } warnedFieldGroups.add(key); warnings.push({ code: 'parameter-fallback', fieldIds, message: CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING, }); }; const findFieldIndex = (id: string) => fields.findIndex((field) => field.id === id); const setFieldValue = ( id: string, title: string, fieldValue: CanvasGenerationInputValue, ) => { const index = findFieldIndex(id); if (index >= 0) { fields[index] = { ...fields[index]!, value: fieldValue }; return; } fields.push({ id, title, value: fieldValue }); }; const normalizeExistingField = ( id: string, title: string, fallback: T, resolve: (fieldValue: CanvasGenerationInputValue) => T | undefined, materializeMissing = true, ): T => { const index = findFieldIndex(id); if (index < 0) { if (materializeMissing) { setFieldValue(id, title, fallback); addFallbackWarning([id]); } return fallback; } const resolved = resolve(fields[index]!.value); if (resolved === undefined) { setFieldValue(id, title, fallback); addFallbackWarning([id]); return fallback; } setFieldValue(id, title, resolved); return resolved; }; const normalizeStringField = (id: string, title: string, fallback = '') => normalizeExistingField( id, title, fallback, (fieldValue) => (typeof fieldValue === 'string' ? fieldValue : undefined), false, ); const normalizeStringOption = ( id: string, title: string, options: readonly T[], fallback: T, normalize: (fieldValue: string) => string = (fieldValue) => fieldValue, ) => normalizeExistingField(id, title, fallback, (fieldValue) => { if (typeof fieldValue !== 'string') { return undefined; } const normalized = normalize(fieldValue); return options.includes(normalized as T) ? (normalized as T) : undefined; }); const normalizeNumberOption = ( id: string, title: string, options: readonly T[], fallback: T, ) => normalizeExistingField(id, title, fallback, (fieldValue) => typeof fieldValue === 'number' && options.includes(fieldValue as T) ? (fieldValue as T) : undefined, ); const normalizeNumberRange = ( id: string, title: string, minimum: number, maximum: number, fallback: number, ) => normalizeExistingField(id, title, fallback, (fieldValue) => typeof fieldValue === 'number' && Number.isFinite(fieldValue) && fieldValue >= minimum && fieldValue <= maximum ? fieldValue : undefined, ); const normalizeBooleanField = ( id: string, title: string, fallback: boolean, ) => normalizeExistingField(id, title, fallback, (fieldValue) => typeof fieldValue === 'boolean' ? fieldValue : undefined, ); const normalizeImageParameters = ({ defaultModel, defaultAspectRatio, fixedAspectRatios, }: { defaultModel: string; defaultAspectRatio: string; fixedAspectRatios?: readonly string[]; }) => { const imageModels = EDITOR_IMAGE_MODEL_OPTIONS.map( (option) => option.value, ); const model = normalizeStringOption( 'model', '模型', imageModels, defaultModel, normalizeEditorImageModel, ); const dimensions = EDITOR_IMAGE_DIMENSION_OPTIONS[ model as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS ] ?? EDITOR_IMAGE_DIMENSION_OPTIONS[ defaultModel as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS ]; const aspectRatios = fixedAspectRatios ?? (dimensions.aspectRatios as readonly string[]); const resolvedDefaultAspectRatio = aspectRatios.includes(defaultAspectRatio) ? defaultAspectRatio : (aspectRatios[0] ?? '1:1'); const imageSizes = dimensions.imageSizes as readonly string[]; const defaultImageSize = imageSizes.includes('1K') ? '1K' : (imageSizes[0] ?? '1K'); normalizeStringOption( 'aspectRatio', '比例', aspectRatios, resolvedDefaultAspectRatio, ); normalizeStringOption('imageSize', '尺寸', imageSizes, defaultImageSize); }; const action = value.action; if ( action === 'image.generate' || action === 'character.generate' || action === 'icon.generate' ) { normalizeStringField('prompt', '提示词'); normalizeStringOption('style', '风格', ['none', 'pixelArt'], 'none'); normalizeImageParameters({ defaultModel: DEFAULT_IMAGE_MODEL, defaultAspectRatio: '1:1', }); } else if (action === 'scene.generate') { normalizeStringField('prompt', '画面内容'); const stylePreset = normalizeStringOption( 'stylePreset', '视觉风格', EDITOR_SCENE_STYLE_PRESET_OPTIONS.map((option) => option.value), DEFAULT_EDITOR_SCENE_STYLE_PRESET, ); if (stylePreset === CUSTOM_EDITOR_SCENE_STYLE_PRESET) { normalizeStringField('customStyle', '自定义画风'); } normalizeImageParameters({ defaultModel: DEFAULT_IMAGE_MODEL, defaultAspectRatio: '16:9', }); } else if (action === 'ui-design.generate') { normalizeStringField('prompt', '用户输入'); normalizeImageParameters({ defaultModel: IMAGE_MODEL_GPT_IMAGE_2, defaultAspectRatio: '16:9', }); } else if (action === 'image.edit') { normalizeStringField('prompt', '修改要求'); normalizeImageParameters({ defaultModel: DEFAULT_IMAGE_MODEL, defaultAspectRatio: '1:1', }); } else if (action === 'ui-design.extract-assets') { normalizeStringField('prompt', '提取提示词'); normalizeImageParameters({ defaultModel: DEFAULT_IMAGE_MODEL, defaultAspectRatio: '1:1', fixedAspectRatios: ['1:1'], }); } else if (action === 'spec.generate') { normalizeStringOption( 'specType', '规范类型', ['character', 'ui', 'icon', 'custom'], 'custom', ); normalizeStringField('playSetting', '玩法设定'); normalizeStringField('artStyle', '美术风格'); normalizeStringField('bodyRatio', '头身比'); normalizeStringField('characterView', '角色视角'); normalizeStringField('customPrompt', '自定义规范提示词'); } else if (action === 'publication.generate') { normalizeStringOption( 'workflowId', '宣发类型', [ 'publication-cover-image', 'publication-detail-gallery', 'publication-promo-poster', ], 'publication-cover-image', ); normalizeStringField('gameName', '游戏名'); normalizeStringField('gameCategories', '游戏分类'); normalizeStringField('gameDescription', '一句话描述游戏'); } else if (action === 'video.generate') { normalizeStringField('prompt', '视频描述'); const videoModel = normalizeStringOption( 'model', '模型', EDITOR_VIDEO_MODEL_OPTIONS.map((option) => option.value), DEFAULT_VIDEO_MODEL, ); normalizeStringOption( 'aspectRatio', '比例', EDITOR_VIDEO_ASPECT_RATIO_OPTIONS, DEFAULT_VIDEO_ASPECT_RATIO, ); normalizeStringOption( 'resolution', '清晰度', videoModel === VIDEO_MODEL_SEEDANCE_2_FAST ? (['480p', '720p'] as const) : EDITOR_VIDEO_RESOLUTION_OPTIONS, '480p', ); normalizeNumberOption( 'durationSeconds', '时长', EDITOR_VIDEO_DURATION_OPTIONS, DEFAULT_VIDEO_DURATION_SECONDS, ); normalizeStringOption('sound', '声音', ['on', 'off'], DEFAULT_VIDEO_SOUND); normalizeBooleanField( 'webSearchEnabled', '联网搜索', DEFAULT_VIDEO_WEB_SEARCH_ENABLED, ); } else if (action === 'audio.sound-effect.generate') { normalizeStringField('prompt', '用户描述'); normalizeStringOption( 'model', 'model', EDITOR_SOUND_EFFECT_MODEL_OPTIONS.map((option) => option.value), DEFAULT_SOUND_EFFECT_MODEL, ); const durationMode = normalizeStringOption( 'durationMode', '时长模式', ['auto', 'manual'], 'manual', ); if (durationMode === 'manual') { normalizeNumberRange( 'durationSeconds', '请求时长', SOUND_EFFECT_DURATION_MIN_SECONDS, SOUND_EFFECT_DURATION_MAX_SECONDS, DEFAULT_SOUND_EFFECT_DURATION_SECONDS, ); } else { setFieldValue('durationSeconds', '请求时长', '自动'); } normalizeBooleanField('loop', 'Loop', false); } else if (action === 'audio.background-music.generate') { normalizeStringField('prompt', 'gpt_description_prompt'); } else if (action === 'character-animation.generate') { normalizeStringField('prompt', '动作描述'); normalizeStringOption('resolution', '清晰度', ['480p', '720p'], '480p'); normalizeStringOption( 'ratio', '比例', CHARACTER_ANIMATION_RATIO_OPTIONS.map((option) => option.value), 'same', ); const frameCountIndex = findFieldIndex('frameCount'); const durationIndex = findFieldIndex('durationSeconds'); if (frameCountIndex >= 0 || durationIndex >= 0) { const frameCount = frameCountIndex >= 0 ? fields[frameCountIndex]!.value : undefined; const durationSeconds = durationIndex >= 0 ? fields[durationIndex]!.value : undefined; const durationOption = CHARACTER_ANIMATION_DURATION_OPTIONS.find( (option) => option.frameCount === frameCount && option.durationSeconds === durationSeconds, ); if (!durationOption) { const defaultDuration = CHARACTER_ANIMATION_DURATION_OPTIONS[0]; setFieldValue('frameCount', '帧数', defaultDuration.frameCount); setFieldValue( 'durationSeconds', '时长', defaultDuration.durationSeconds, ); addFallbackWarning(['frameCount', 'durationSeconds']); } } else { const defaultDuration = CHARACTER_ANIMATION_DURATION_OPTIONS[0]; setFieldValue('frameCount', '帧数', defaultDuration.frameCount); setFieldValue('durationSeconds', '时长', defaultDuration.durationSeconds); addFallbackWarning(['frameCount', 'durationSeconds']); } } return { ok: true, inputs: { ...value, version: 2, action, fields, references: value.references.map((reference) => ({ ...reference })), }, warnings, }; } const LEGACY_REUSABLE_GENERATION_INPUT_TITLES = new Set( [ '生成提示词', '视频描述', 'prompt', 'gpt_description_prompt', '音效提示词', '背景音乐提示词', '角色设定', '用户输入', '素材描述', '自定义规范提示词', '玩法设定', '美术风格', '游戏名', '修改要求', '快速编辑提示词', '重绘提示词', '动作描述', ].map((title) => title.toLowerCase()), ); export function canOpenRedrawPanel(layer: CanvasLayer) { // 中文注释:按钮显隐只表达 action capability。参数损坏或必需来源丢失属于 // 点击后的恢复错误,不能在这里静默吞掉入口。 if (layer.taskId?.startsWith('pixel-art-snap-')) { return false; } const action = layer.generationInputs?.action; if (action !== undefined) { return isRemixableCanvasGenerationAction(action); } if ( layer.sourceType === 'uploaded' || layer.generationInputs?.version === 2 ) { return false; } // 72f268e0 至 scene.generate V2 落地前的场景产物只保存 legacy 中文字段。 // 兼容范围限定为 scene assetKind,避免把同名展示字段重新提升为通用执行能力。 if ( layer.assetKind === 'scene' && layer.generationInputs?.fields.some( (field) => typeof field?.title === 'string' && field.title.trim() === '画面内容', ) ) { return true; } return Boolean( layer.generationInputs?.fields.some( (field) => typeof field?.title === 'string' && LEGACY_REUSABLE_GENERATION_INPUT_TITLES.has( field.title.trim().toLowerCase(), ), ), ); } function buildNormalizedGenerationInputs( action: CanvasGenerationAction, fields: CanvasGenerationInputField[], references: CanvasGenerationInputReference[], ): CanvasGenerationInputs { return { version: 2, action, fields, references }; } export function buildImageGenerationInputs( prompt: string, references?: CharacterReferenceImage[], options: { model?: string; style?: string; aspectRatio?: string; imageSize?: string; } = {}, ): CanvasGenerationInputs { return buildNormalizedGenerationInputs( 'image.generate', [ ...createGenerationInputField('生成提示词', prompt, 'prompt'), ...createGenerationInputField( '模型', options.model ?? DEFAULT_IMAGE_MODEL, 'model', ), ...createGenerationInputField('风格', options.style ?? 'none', 'style'), ...createGenerationInputField( '比例', options.aspectRatio ?? '1:1', 'aspectRatio', ), ...createGenerationInputField( '尺寸', options.imageSize ?? '1K', 'imageSize', ), ], (references ?? []).flatMap((reference, index) => createGenerationInputReference(`参考图 ${index + 1}`, reference, { id: 'reference', }), ), ); } export function buildSceneGenerationInputs( sceneContent: string, stylePreset: NonNullable, customStyle: string | null | undefined, references?: CharacterReferenceImage[], options: { model?: string; aspectRatio?: string; imageSize?: string } = {}, ): CanvasGenerationInputs { return buildNormalizedGenerationInputs( 'scene.generate', [ ...createGenerationInputField('画面内容', sceneContent, 'prompt'), ...createGenerationInputField('视觉风格', stylePreset, 'stylePreset'), ...(stylePreset === CUSTOM_EDITOR_SCENE_STYLE_PRESET ? createGenerationInputField('自定义画风', customStyle, 'customStyle') : []), ...createGenerationInputField( '模型', options.model ?? DEFAULT_IMAGE_MODEL, 'model', ), ...createGenerationInputField( '比例', options.aspectRatio ?? '16:9', 'aspectRatio', ), ...createGenerationInputField( '尺寸', options.imageSize ?? '1K', 'imageSize', ), ], (references ?? []).flatMap((reference, index) => createGenerationInputReference(`场景参考图 ${index + 1}`, reference, { id: 'reference', }), ), ); } export function buildVideoGenerationInputs( prompt: string, references?: CharacterReferenceImage[], options: { model?: string; aspectRatio?: string; resolution?: string; durationSeconds?: number; sound?: string; webSearchEnabled?: boolean; } = {}, ): CanvasGenerationInputs { const mediaIndexes = { image: 0, video: 0, audio: 0, }; return buildNormalizedGenerationInputs( 'video.generate', [ ...createGenerationInputField('视频描述', prompt, 'prompt'), ...createGenerationInputField( '模型', options.model ?? DEFAULT_VIDEO_MODEL, 'model', ), ...createGenerationInputField( '比例', options.aspectRatio ?? DEFAULT_VIDEO_ASPECT_RATIO, 'aspectRatio', ), ...createGenerationInputField( '清晰度', options.resolution ?? '480p', 'resolution', ), ...createGenerationInputField( '时长', options.durationSeconds ?? DEFAULT_VIDEO_DURATION_SECONDS, 'durationSeconds', ), ...createGenerationInputField( '声音', options.sound ?? DEFAULT_VIDEO_SOUND, 'sound', ), ...createGenerationInputField( '联网搜索', options.webSearchEnabled ?? DEFAULT_VIDEO_WEB_SEARCH_ENABLED, 'webSearchEnabled', ), ], (references ?? []).flatMap((reference) => { const mediaType = reference.mediaType === 'video' || reference.mediaType === 'audio' ? reference.mediaType : 'image'; mediaIndexes[mediaType] += 1; const title = mediaType === 'video' ? `参考视频 ${mediaIndexes.video}` : mediaType === 'audio' ? `参考音频 ${mediaIndexes.audio}` : `参考图 ${mediaIndexes.image}`; return createGenerationInputReference(title, reference, { id: `${mediaType}Reference`, }); }), ); } export function buildSoundEffectGenerationInputs( prompt: string, model: string, duration: number | null, loop: boolean, ): CanvasGenerationInputs { return { version: 2, action: 'audio.sound-effect.generate', fields: [ ...createGenerationInputField('用户描述', prompt, 'prompt'), ...createGenerationInputField('model', model, 'model'), ...createGenerationInputField( '时长模式', duration === null ? 'auto' : 'manual', 'durationMode', ), ...createGenerationInputField( '请求时长', duration === null ? '自动' : duration, 'durationSeconds', ), ...createGenerationInputField('Loop', loop, 'loop'), ], references: [], }; } export function buildBackgroundMusicGenerationInputs( gptDescriptionPrompt: string, ): CanvasGenerationInputs { return { version: 2, action: 'audio.background-music.generate', fields: gptDescriptionPrompt === '' ? [] : [ { id: 'prompt', title: 'gpt_description_prompt', value: gptDescriptionPrompt, }, ], references: [], }; } export function buildSpecGenerationInputs( specType: SpecGenerationType, values: SpecFormValues, reference?: CharacterReferenceImage | null, extraReferences: CharacterReferenceImage[] = [], ): CanvasGenerationInputs { const references = [ ...(reference ? [reference] : []), ...extraReferences, ].flatMap((item, index) => createGenerationInputReference( index === 0 ? '参考图' : `参考图${index + 1}`, item, { id: index === 0 ? 'reference' : `reference-${index + 1}` }, ), ); if (specType === 'custom') { return { version: 2, action: 'spec.generate', fields: [ ...createGenerationInputField('规范类型', specType, 'specType'), ...createGenerationInputField( '自定义规范提示词', values.customPrompt, 'customPrompt', ), ], references, }; } const baseFields = [ ...createGenerationInputField('规范类型', specType, 'specType'), ...createGenerationInputField( '玩法设定', values.playSetting, 'playSetting', ), ...createGenerationInputField('美术风格', values.artStyle, 'artStyle'), ]; if (specType === 'character') { baseFields.push( ...createGenerationInputField('头身比', values.bodyRatio, 'bodyRatio'), ...createGenerationInputField( '角色视角', values.characterView, 'characterView', ), ); } return { version: 2, action: 'spec.generate', fields: baseFields, references, }; } export function buildCharacterGenerationInputs( prompt: string, specReference: CharacterReferenceImage | null | undefined, references: CharacterReferenceImage[] | undefined, options: { model?: string; style?: string; aspectRatio?: string; imageSize?: string; } = {}, ): CanvasGenerationInputs { return { version: 2, action: 'character.generate', fields: [ ...createGenerationInputField('角色设定', prompt, 'prompt'), ...createGenerationInputField( '模型', options.model ?? DEFAULT_IMAGE_MODEL, 'model', ), ...createGenerationInputField('风格', options.style ?? 'none', 'style'), ...createGenerationInputField( '比例', options.aspectRatio ?? '1:1', 'aspectRatio', ), ...createGenerationInputField( '尺寸', options.imageSize ?? '1K', 'imageSize', ), ], references: [ ...(specReference ? createGenerationInputReference('角色规范', specReference, { id: 'specReference', }) : []), ...(references ?? []).flatMap((reference, index) => createGenerationInputReference(`常规参考图 ${index + 1}`, reference, { id: 'reference', }), ), ], }; } export function buildUiDesignGenerationInputs( prompt: string, specReference: CharacterReferenceImage | null | undefined, references?: CharacterReferenceImage[], options: { model?: string; aspectRatio?: string; imageSize?: string } = {}, ): CanvasGenerationInputs { return { version: 2, action: 'ui-design.generate', fields: [ ...createGenerationInputField('用户输入', prompt, 'prompt'), ...createGenerationInputField( '模型', options.model ?? IMAGE_MODEL_GPT_IMAGE_2, 'model', ), ...createGenerationInputField( '比例', options.aspectRatio ?? '16:9', 'aspectRatio', ), ...createGenerationInputField( '尺寸', options.imageSize ?? '1K', 'imageSize', ), ], references: [ ...(specReference ? createGenerationInputReference('图标规范', specReference, { id: 'specReference', }) : []), ...(references ?? []).flatMap((reference, index) => createGenerationInputReference(`参考图 ${index + 1}`, reference, { id: 'reference', }), ), ], }; } function normalizePublicationGameInfo( gameInfo: PublicationMaterialsGameInfo | null | undefined, ): PublicationMaterialsGameInfo { return { gameName: gameInfo?.gameName?.trim() ?? '', gameCategories: gameInfo?.gameCategories?.trim() ?? '', gameDescription: gameInfo?.gameDescription?.trim() ?? '', }; } export function buildPublicationMaterialsPrompt( gameInfo: PublicationMaterialsGameInfo | null | undefined, ) { const normalizedGameInfo = normalizePublicationGameInfo(gameInfo); return [ ...createGenerationInputField('游戏名', normalizedGameInfo.gameName), ...createGenerationInputField( '游戏分类', normalizedGameInfo.gameCategories, ), ...createGenerationInputField( '一句话描述游戏', normalizedGameInfo.gameDescription, ), ] .map((field) => `${field.title}:${field.value}`) .join('\n'); } export function buildPublicationMaterialsGenerationPrompt({ gameInfo, workflow, }: { gameInfo: PublicationMaterialsGameInfo | null | undefined; workflow: PublicationMaterialsWorkflow; }) { const inputPrompt = buildPublicationMaterialsPrompt(gameInfo); return [ `【宣发素材类型】${workflow.promptLabel ?? workflow.label}`, inputPrompt ? `【游戏输入】\n${inputPrompt}` : '【游戏输入】\n请根据参考图生成宣发素材。', `【生图约束】\n${workflow.promptConstraints.join('\n')}`, `【参考图约束】\n${workflow.referenceConstraints.join('\n')}`, `【输出检查】\n${workflow.postProcessConstraints.join('\n')}`, ].join('\n\n'); } export function buildPublicationMaterialsGenerationInputs( gameInfo: PublicationMaterialsGameInfo | null | undefined, references: CharacterReferenceImage[] | undefined, workflowId: PublicationMaterialsWorkflowId = 'publication-cover-image', ): CanvasGenerationInputs { const normalizedGameInfo = normalizePublicationGameInfo(gameInfo); return { version: 2, action: 'publication.generate', fields: [ ...createGenerationInputField('宣发类型', workflowId, 'workflowId'), ...createGenerationInputField( '游戏名', normalizedGameInfo.gameName, 'gameName', ), ...createGenerationInputField( '游戏分类', normalizedGameInfo.gameCategories, 'gameCategories', ), ...createGenerationInputField( '一句话描述游戏', normalizedGameInfo.gameDescription, 'gameDescription', ), ], references: (references ?? []).flatMap((reference, index) => createGenerationInputReference(`宣发参考图 ${index + 1}`, reference, { id: 'reference', }), ), }; } export function buildIconGenerationInputs( iconDescriptions: string[], specReference: CharacterReferenceImage, references?: CharacterReferenceImage[], options: { model?: string; style?: string; aspectRatio?: string; imageSize?: string; } = {}, ): CanvasGenerationInputs { return { version: 2, action: 'icon.generate', fields: [ { id: 'prompt', title: '素材描述', value: iconDescriptions.join('\n'), }, ...createGenerationInputField( '模型', options.model ?? DEFAULT_IMAGE_MODEL, 'model', ), ...createGenerationInputField('风格', options.style ?? 'none', 'style'), ...createGenerationInputField( '比例', options.aspectRatio ?? '1:1', 'aspectRatio', ), ...createGenerationInputField( '尺寸', options.imageSize ?? '1K', 'imageSize', ), ], references: [ ...createGenerationInputReference('图标规范', specReference, { id: 'specReference', }), ...(references ?? []).flatMap((reference, index) => createGenerationInputReference(`参考图 ${index + 1}`, reference, { id: 'reference', }), ), ], }; } export function buildUiDesignAssetExtractionGenerationInputs( sourceLayer: CanvasLayer, references?: CharacterReferenceImage[], options: { model?: string; imageSize?: string } = {}, ): CanvasGenerationInputs { return { version: 2, action: 'ui-design.extract-assets', fields: [ ...createGenerationInputField( '提取提示词', UI_DESIGN_ASSET_EXTRACTION_PROMPT, 'prompt', ), ...createGenerationInputField( '模型', options.model ?? DEFAULT_IMAGE_MODEL, 'model', ), ...createGenerationInputField('比例', '1:1', 'aspectRatio'), ...createGenerationInputField( '尺寸', options.imageSize ?? '1K', 'imageSize', ), ], references: [ ...createLayerGenerationInputReference('UI设计图', sourceLayer, { id: 'source', }), ...(references ?? []).flatMap((reference, index) => createGenerationInputReference(`参考图 ${index + 1}`, reference, { id: 'reference', }), ), ], }; } export function buildEditGenerationInputs( title: '修改要求' | '快速编辑提示词' | '重绘提示词', prompt: string, sourceLayer: CanvasLayer, options: { model?: string; aspectRatio?: string; imageSize?: string } = {}, ): CanvasGenerationInputs { return { version: 2, action: 'image.edit', fields: [ ...createGenerationInputField(title, prompt, 'prompt'), ...createGenerationInputField( '模型', options.model ?? DEFAULT_IMAGE_MODEL, 'model', ), ...createGenerationInputField( '比例', options.aspectRatio ?? '1:1', 'aspectRatio', ), ...createGenerationInputField( '尺寸', options.imageSize ?? '1K', 'imageSize', ), ], references: createLayerGenerationInputReference('参考图', sourceLayer, { id: 'source', }), }; } export function buildQuickEditGenerationInputs( title: '快速编辑提示词' | '重绘提示词', prompt: string, sourceLayer: CanvasLayer, references: CharacterReferenceImage[] | undefined, options: { model?: string; aspectRatio?: string; imageSize?: string } = {}, ): CanvasGenerationInputs { const limitedReferences = (references ?? []).slice( 0, QUICK_EDIT_REFERENCE_LIMIT, ); return { version: 2, action: 'image.edit', fields: [ ...createGenerationInputField(title, prompt, 'prompt'), ...createGenerationInputField( '模型', options.model ?? DEFAULT_IMAGE_MODEL, 'model', ), ...createGenerationInputField( '比例', options.aspectRatio ?? '1:1', 'aspectRatio', ), ...createGenerationInputField( '尺寸', options.imageSize ?? '1K', 'imageSize', ), ], references: [ ...createLayerGenerationInputReference('原图', sourceLayer, { id: 'source', }), ...limitedReferences.flatMap((reference, index) => createGenerationInputReference(`参考图 ${index + 1}`, reference, { id: 'reference', }), ), ], }; } export function isCanvasGenerationDialog( dialog: GenerateDialogState | null, ): dialog is CanvasGenerationDialogState { return Boolean( dialog?.id && (dialog.mode === 'generate' || dialog.mode === 'scene' || dialog.mode === 'spec' || dialog.mode === 'character' || dialog.mode === 'icon' || dialog.mode === 'publication' || dialog.mode === 'ui-design' || dialog.mode === 'quick-edit' || dialog.mode === 'character-animation' || dialog.mode === 'video' || dialog.mode === 'audio-sound-effect' || dialog.mode === 'audio-background-music'), ); } export function getGenerationFrameAriaLabel( dialog: CanvasGenerationDialogState, ) { if (dialog.mode === 'character') { return '角色生成占位图'; } if (dialog.mode === 'scene') { return '游戏场景生成占位图'; } if (dialog.mode === 'spec') { return '规范生成占位图'; } if (dialog.mode === 'icon') { return '图标素材生成占位图'; } if (dialog.mode === 'publication') { return '宣发素材生成占位图'; } if (dialog.mode === 'ui-design') { return 'UI设计图生成占位图'; } if (dialog.mode === 'quick-edit') { return '快速编辑生成占位图'; } if (dialog.mode === 'video') { return '视频生成占位图'; } if (dialog.mode === 'character-animation') { return '角色动作生成占位图'; } if (dialog.mode === 'audio-sound-effect') { return '音效生成占位图'; } if (dialog.mode === 'audio-background-music') { return '背景音乐生成占位图'; } return '图像生成占位图'; } export function getGenerationFrameLabel(dialog: CanvasGenerationDialogState) { if (dialog.mode === 'character') { return 'Character Generator'; } if (dialog.mode === 'scene') { return 'Scene Generator'; } if (dialog.mode === 'spec') { return 'Spec Generator'; } if (dialog.mode === 'icon') { return 'Icon Generator'; } if (dialog.mode === 'publication') { return `${ getPublicationMaterialsWorkflow( dialog.publicationWorkflowId ?? 'publication-cover-image', ).englishName } Generator`; } if (dialog.mode === 'ui-design') { return 'UI Design Generator'; } if (dialog.mode === 'quick-edit') { return 'Quick Edit Generator'; } if (dialog.mode === 'video') { return 'Video Generator'; } if (dialog.mode === 'character-animation') { return 'Action Generator'; } if (dialog.mode === 'audio-sound-effect') { return 'Sound Generator'; } if (dialog.mode === 'audio-background-music') { return 'Music Generator'; } return 'Image Generator'; } export function resolveImageGenerationErrorMessage( error: unknown, fallbackMessage = '生成图片失败', ) { if ( error instanceof ApiClientError && (error.status === 401 || error.status === 403) ) { return '请先登录后再生成图片'; } if (error instanceof ApiClientError && error.status === 409) { const message = error.message.trim(); if (message && !message.startsWith('请求冲突')) { return message; } const provider = typeof error.details?.provider === 'string' ? error.details.provider.trim() : ''; if (provider === 'profile-wallet') { return '泥点余额不足,请充值后再生成'; } return '生成任务未完成,请稍后重试'; } return error instanceof Error && error.message.trim() ? error.message : fallbackMessage; }