import { ApiClientError } from '../../services/apiClient'; import type { EditorGenerationPricingConfig } from '../../services/image-editor/editorProjectClient'; import type { EditorCharacterAnimationRatio, EditorCharacterAnimationResolution, } from '../../services/image-editor/editorProjectClient'; import { isGeneratedLayer } from './ImageCanvasEditorModel'; import type { CanvasGenerationDialogState, CanvasGenerationInputField, CanvasGenerationInputReference, CanvasGenerationInputs, CanvasLayer, CharacterReferenceImage, GenerateDialogState, PublicationMaterialsGameInfo, PublicationMaterialsWorkflowId, SpecFormValues, SpecGenerationType, } from './ImageCanvasEditorTypes'; 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 ICON_DESCRIPTION_LIMIT = 100; 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 = [ { label: 'gpt-image-2', value: IMAGE_MODEL_GPT_IMAGE_2 }, ] as const; 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({ 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( dialog: GenerateDialogState, ): GenerateDialogState { 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, }, }; } 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 = SOUND_EFFECT_MODEL_VIDU; 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 = { [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], }, [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: 'Vidu', value: SOUND_EFFECT_MODEL_VIDU }, ] as const; export const SEEDANCE_VIDEO_REFERENCE_LIMITS = { image: 9, video: 3, audio: 3, requestBytes: 64 * 1024 * 1024, } as const; export const QUICK_EDIT_REFERENCE_LIMIT = 8; 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: '', }, ui: { playSetting: '抓娃娃题材的抓大鹅玩法', 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: '角色规范', ui: '图标规范', 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 buildQuickEditModelOptions(currentModel: string) { void currentModel; const options = [...QUICK_EDIT_MODEL_OPTIONS]; return options; } 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 buildUiSpecPrompt(values: SpecFormValues) { return [ '生成一张完整游戏UI规范汇总设定展板,纯白色干净背景,Figma专业设计稿质感,矢量锐利线条,页面划分九大区域:色彩规范、字体规范、图标规范、按钮规范、组件规范、布局规范、特效规范、IP规范、主视觉。主视觉居中较大显示,其他八个区域环绕主视觉', '', `玩法设定:${values.playSetting.trim() || DEFAULT_SPEC_FORM_VALUES.ui.playSetting}`, `美术风格:${values.artStyle.trim() || DEFAULT_SPEC_FORM_VALUES.ui.artStyle}`, ].join('\n'); } export function buildIconSpecPrompt(values: SpecFormValues) { return [ '生成一张游戏图标素材视觉规范展板,纯白色干净背景,展示按钮图标的统一视角、线条粗细、填充风格、描边、阴影、圆角、材质、状态层级和色彩规范,图标样例需要成组排列且风格高度统一。', '', `玩法设定:${values.playSetting.trim() || DEFAULT_SPEC_FORM_VALUES.icon.playSetting}`, `美术风格:${values.artStyle.trim() || DEFAULT_SPEC_FORM_VALUES.icon.artStyle}`, ].join('\n'); } export function buildSpecPrompt( type: SpecGenerationType, values: SpecFormValues, hasReferenceImage = false, ) { const prompt = type === 'character' ? buildCharacterSpecPrompt(values) : type === 'ui' ? buildUiSpecPrompt(values) : type === 'icon' ? buildIconSpecPrompt(values) : values.customPrompt.trim(); if (!hasReferenceImage) { return prompt; } return [ '参考图生成规范:严格参考图1的构图、风格、材质、色彩、形状语言和视觉层级生成本次规范图;参考图只作为美术方向和规范语义依据,不要直接复制参考图中的文字、水印或无关背景。', 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 === '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, ): CanvasGenerationInputs { return { fields: createGenerationInputField('动作描述', promptText), references: createLayerGenerationInputReference('角色图片', sourceLayer), }; } 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 === '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' && layer.thumbnailSrc?.trim()) { return layer.thumbnailSrc.trim(); } // 中文注释:角色图已持久化到 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' >, ): CanvasGenerationInputReference[] { const resourceId = reference.resourceId?.trim(); if ( resourceId && !resourceId.startsWith('local-') && !resourceId.startsWith('generation-dialog:') ) { return [ { title, label: reference.label, refType: 'project-resource', refId: resourceId, }, ]; } const sourceAssetId = reference.sourceAssetId?.trim(); return sourceAssetId && !sourceAssetId.startsWith('upload-') ? [ { title, label: reference.label, refType: 'asset', refId: sourceAssetId, }, ] : []; } export function createLayerGenerationInputReference( title: string, layer: CanvasLayer, ): CanvasGenerationInputReference[] { return createGenerationInputReference(title, { label: layer.title, resourceId: layer.resourceId, sourceAssetId: layer.sourceAssetId, }); } 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 createGenerationInputField( title: string, value: string | null | undefined, ): CanvasGenerationInputField[] { const normalizedValue = value?.trim(); return normalizedValue ? [{ title, value: normalizedValue }] : []; } export function buildImageGenerationInputs( prompt: string, references?: CharacterReferenceImage[], ): CanvasGenerationInputs { return { fields: createGenerationInputField('生成提示词', prompt), references: (references ?? []).flatMap((reference, index) => createGenerationInputReference(`参考图 ${index + 1}`, reference), ), }; } export function buildVideoGenerationInputs( prompt: string, references?: CharacterReferenceImage[], ): CanvasGenerationInputs { const mediaIndexes = { image: 0, video: 0, audio: 0, }; return { fields: createGenerationInputField('视频描述', prompt), references: (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); }), }; } export function buildSoundEffectGenerationInputs( prompt: string, model: string, duration: number, ): CanvasGenerationInputs { return { fields: [ ...createGenerationInputField('prompt', prompt), ...createGenerationInputField('model', model), ...createGenerationInputField('duration', `${duration}秒`), ], references: [], }; } export function buildBackgroundMusicGenerationInputs( gptDescriptionPrompt: string, ): CanvasGenerationInputs { return { fields: createGenerationInputField( 'gpt_description_prompt', gptDescriptionPrompt, ), references: [], }; } export function buildSpecGenerationInputs( specType: SpecGenerationType, values: SpecFormValues, reference?: CharacterReferenceImage | null, ): CanvasGenerationInputs { const references = reference ? createGenerationInputReference('参考图', reference) : []; if (specType === 'custom') { return { fields: createGenerationInputField( '自定义规范提示词', values.customPrompt, ), references, }; } const baseFields = [ ...createGenerationInputField('玩法设定', values.playSetting), ...createGenerationInputField('美术风格', values.artStyle), ]; if (specType === 'character') { baseFields.push( ...createGenerationInputField('头身比', values.bodyRatio), ...createGenerationInputField('角色视角', values.characterView), ); } return { fields: baseFields, references, }; } export function buildCharacterGenerationInputs( prompt: string, specReference: CharacterReferenceImage | null | undefined, references: CharacterReferenceImage[] | undefined, ): CanvasGenerationInputs { return { fields: createGenerationInputField('角色设定', prompt), references: [ ...(specReference ? createGenerationInputReference('角色规范', specReference) : []), ...(references ?? []).flatMap((reference, index) => createGenerationInputReference(`常规参考图 ${index + 1}`, reference), ), ], }; } export function buildUiDesignGenerationInputs( prompt: string, specReference: CharacterReferenceImage | null | undefined, references?: CharacterReferenceImage[], ): CanvasGenerationInputs { return { fields: createGenerationInputField('用户输入', prompt), references: [ ...(specReference ? createGenerationInputReference('图标规范', specReference) : []), ...(references ?? []).flatMap((reference, index) => createGenerationInputReference(`参考图 ${index + 1}`, 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, ): CanvasGenerationInputs { const normalizedGameInfo = normalizePublicationGameInfo(gameInfo); return { fields: [ ...createGenerationInputField('游戏名', normalizedGameInfo.gameName), ...createGenerationInputField( '游戏分类', normalizedGameInfo.gameCategories, ), ...createGenerationInputField( '一句话描述游戏', normalizedGameInfo.gameDescription, ), ], references: (references ?? []).flatMap((reference, index) => createGenerationInputReference(`宣发参考图 ${index + 1}`, reference), ), }; } export function buildIconGenerationInputs( iconDescriptions: string[], specReference: CharacterReferenceImage, references?: CharacterReferenceImage[], ): CanvasGenerationInputs { return { fields: [ { title: '素材描述', value: iconDescriptions.join('\n'), }, ], references: [ ...createGenerationInputReference('图标规范', specReference), ...(references ?? []).flatMap((reference, index) => createGenerationInputReference(`参考图 ${index + 1}`, reference), ), ], }; } export function buildUiDesignAssetExtractionGenerationInputs( sourceLayer: CanvasLayer, references?: CharacterReferenceImage[], ): CanvasGenerationInputs { return { fields: [ ...createGenerationInputField( '提取提示词', UI_DESIGN_ASSET_EXTRACTION_PROMPT, ), ], references: [ ...createLayerGenerationInputReference('UI设计图', sourceLayer), ...(references ?? []).flatMap((reference, index) => createGenerationInputReference(`参考图 ${index + 1}`, reference), ), ], }; } export function buildEditGenerationInputs( title: '修改要求' | '快速编辑提示词' | '重绘提示词', prompt: string, sourceLayer: CanvasLayer, ): CanvasGenerationInputs { return { fields: createGenerationInputField(title, prompt), references: createLayerGenerationInputReference('参考图', sourceLayer), }; } export function buildQuickEditGenerationInputs( title: '快速编辑提示词' | '重绘提示词', prompt: string, sourceLayer: CanvasLayer, references: CharacterReferenceImage[] | undefined, ): CanvasGenerationInputs { const limitedReferences = (references ?? []).slice( 0, QUICK_EDIT_REFERENCE_LIMIT, ); return { fields: createGenerationInputField(title, prompt), references: [ ...createLayerGenerationInputReference('原图', sourceLayer), ...limitedReferences.flatMap((reference, index) => createGenerationInputReference(`参考图 ${index + 1}`, reference), ), ], }; } export function isCanvasGenerationDialog( dialog: GenerateDialogState | null, ): dialog is CanvasGenerationDialogState { return Boolean( dialog?.id && (dialog.mode === 'generate' || 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 === '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 === '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; }