import { ApiClientError } from '../../services/apiClient'; 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 EDITOR_GENERATION_MUD_POINT_CONFIG = { image: 12, spec: 5, character: 12, icon: 12, uiDesign: 12, videoPerSecond: { '480p': 10, '720p': 20, }, characterAnimationPerSecond: { '480p': 10, '720p': 20, }, soundEffect: 10, backgroundMusic: 5, } as const; 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 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 EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG = { [IMAGE_MODEL_NANOBANANA2]: EDITOR_GENERATION_MUD_POINT_CONFIG.image, // 中文注释:gpt-image-2 作为更高成本图片模型独立计费,避免切换模型后按钮价格不变。 [IMAGE_MODEL_GPT_IMAGE_2]: 20, } as const; export const EDITOR_SPEC_MODEL_MUD_POINT_CONFIG = { [IMAGE_MODEL_GPT_IMAGE_2]: EDITOR_GENERATION_MUD_POINT_CONFIG.spec, } as const; export const SPEC_GENERATION_COST = EDITOR_SPEC_MODEL_MUD_POINT_CONFIG[SPEC_GENERATION_MODEL]; export const SPEC_GENERATION_ASPECT_RATIO = '16:9'; export const SPEC_GENERATION_IMAGE_SIZE = '2K'; export const ICON_DESCRIPTION_LIMIT = 100; export const DEFAULT_PUBLICATION_GAME_INFO: PublicationMaterialsGameInfo = { gameName: '', gameCategories: '', gameDescription: '', }; export const DEFAULT_ICON_DESCRIPTIONS = [ '返回按钮', '设置按钮', '下一关按钮', '提示按钮', '原图按钮', '冻结按钮', ]; export const UI_DESIGN_ASSET_EXTRACTION_PROMPT = '提取画面中的所有独立并整理成spritesheet'; export const QUICK_EDIT_SIZE_PRESETS = [ '1024x1024', '1536x1024', '2048x1152', '1024x1536', ] as const; export const QUICK_EDIT_MODEL_OPTIONS = [ { label: 'nanobanana2', value: IMAGE_MODEL_NANOBANANA2 }, { label: 'gpt-image-2', value: IMAGE_MODEL_GPT_IMAGE_2 }, ] as const; export const EDITOR_IMAGE_MODEL_OPTIONS = QUICK_EDIT_MODEL_OPTIONS; export const EDITOR_IMAGE_DIMENSION_OPTIONS = { [IMAGE_MODEL_NANOBANANA2]: { aspectRatios: ['1:1', '2:3', '3:2', '9:16', '16:9'], imageSizes: ['0.5K', '1K', '2K'], }, [IMAGE_MODEL_GPT_IMAGE_2]: { aspectRatios: ['1:1', '2:3', '3:2', '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 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' | string | null | undefined; }): EditorImageFrameSize { const height = 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': EDITOR_GENERATION_MUD_POINT_CONFIG.videoPerSecond['480p'], '720p': EDITOR_GENERATION_MUD_POINT_CONFIG.videoPerSecond['720p'], } as const; const EDITOR_VIDEO_STANDARD_RESOLUTION_RATE_CONFIG = { '480p': 12, '720p': 24, } as const; const EDITOR_VIDEO_KLING_RESOLUTION_RATE_CONFIG = { '480p': 15, '720p': 30, } as const; const EDITOR_VIDEO_KLING_OMNI_RESOLUTION_RATE_CONFIG = { '480p': 20, '720p': 40, } 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_CHARACTER_ANIMATION_MODEL_MUD_POINT_CONFIG = { [CHARACTER_ANIMATION_MODEL]: { '480p': EDITOR_GENERATION_MUD_POINT_CONFIG.characterAnimationPerSecond['480p'], '720p': EDITOR_GENERATION_MUD_POINT_CONFIG.characterAnimationPerSecond['720p'], }, } as const; export const EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG = { [SOUND_EFFECT_MODEL_VIDU]: EDITOR_GENERATION_MUD_POINT_CONFIG.soundEffect, } as const; export const EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG = { [BACKGROUND_MUSIC_MODEL_SUNO]: EDITOR_GENERATION_MUD_POINT_CONFIG.backgroundMusic, } as const; 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 = [ { label: '4秒', value: '4' }, { label: '5秒', value: '5' }, ] as const; 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 buildQuickEditModelOptions(currentModel: string) { const normalizedCurrentModel = normalizeEditorImageModel(currentModel); const options = [...QUICK_EDIT_MODEL_OPTIONS]; return options.some((option) => option.value === normalizedCurrentModel) ? options : [{ label: normalizedCurrentModel, value: normalizedCurrentModel }, ...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 '音频'; } 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 '生成音频'; } return isGeneratedLayer(layer) ? '生成图片' : '上传图片'; } export function calculateCharacterAnimationPrice( model: string | null | undefined, resolution: EditorCharacterAnimationResolution, durationSeconds: number, ) { const normalizedModel = (model ?? CHARACTER_ANIMATION_MODEL).trim(); const modelRates = EDITOR_CHARACTER_ANIMATION_MODEL_MUD_POINT_CONFIG[ normalizedModel as keyof typeof EDITOR_CHARACTER_ANIMATION_MODEL_MUD_POINT_CONFIG ] ?? EDITOR_CHARACTER_ANIMATION_MODEL_MUD_POINT_CONFIG[CHARACTER_ANIMATION_MODEL]; return modelRates[resolution] * durationSeconds; } export function calculateEditorVideoPrice( model: string | null | undefined, resolution: '480p' | '720p', durationSeconds: number, ) { const normalizedModel = (model ?? DEFAULT_VIDEO_MODEL).trim(); const modelRates = EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[ normalizedModel as keyof typeof EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG ] ?? EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[DEFAULT_VIDEO_MODEL]; return modelRates[resolution] * durationSeconds; } export function calculateEditorImageModelPrice( model: string | null | undefined, ) { const normalizedModel = normalizeEditorImageModel(model); return ( EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[ normalizedModel as keyof typeof EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG ] ?? EDITOR_GENERATION_MUD_POINT_CONFIG.image ); } export function calculateEditorSpecGenerationPrice( model: string | null | undefined = SPEC_GENERATION_MODEL, ) { const normalizedModel = (model ?? SPEC_GENERATION_MODEL).trim(); return ( EDITOR_SPEC_MODEL_MUD_POINT_CONFIG[ normalizedModel as keyof typeof EDITOR_SPEC_MODEL_MUD_POINT_CONFIG ] ?? EDITOR_SPEC_MODEL_MUD_POINT_CONFIG[SPEC_GENERATION_MODEL] ); } export function calculateEditorImageGenerationPrice({ kind, model, }: { kind?: string | null; model?: string | null; } = {}) { const normalizedKind = kind?.trim(); if (normalizedKind === 'spec') { return calculateEditorSpecGenerationPrice(model); } return calculateEditorImageModelPrice(model); } export function calculateEditorIconSpritesheetPrice( model: string | null | undefined, ) { return calculateEditorImageModelPrice(model); } export function calculateEditorUiDesignPrice( model: string | null | undefined, ) { return calculateEditorImageModelPrice(model); } export function calculateEditorSoundEffectPrice( model: string | null | undefined, ) { const normalizedModel = (model ?? DEFAULT_SOUND_EFFECT_MODEL).trim(); return ( EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG[ normalizedModel as keyof typeof EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG ] ?? EDITOR_SOUND_EFFECT_MODEL_MUD_POINT_CONFIG[DEFAULT_SOUND_EFFECT_MODEL] ); } export function calculateEditorBackgroundMusicPrice( model: string | null | undefined = DEFAULT_BACKGROUND_MUSIC_MODEL, ) { const normalizedModel = (model ?? DEFAULT_BACKGROUND_MUSIC_MODEL).trim(); return ( EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[ normalizedModel as keyof typeof EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG ] ?? EDITOR_BACKGROUND_MUSIC_MODEL_MUD_POINT_CONFIG[ DEFAULT_BACKGROUND_MUSIC_MODEL ] ); } export function resolveCharacterAnimationSourceImageSrc(layer: CanvasLayer) { // 中文注释:角色图已持久化到 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 normalizeQuickEditPromptSourceReference( prompt: string, sourceReferenceIndex: number, ) { const normalizedIndex = Math.max(1, sourceReferenceIndex); const sourceReferenceLabel = `图${normalizedIndex}`; return prompt .replace(/原图|当前图片|当前图/gu, sourceReferenceLabel) .replace(/图\s*1(?!\d)/gu, sourceReferenceLabel); } 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 ?? '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, ): CanvasGenerationInputs { return { fields: createGenerationInputField('用户输入', prompt), references: specReference ? createGenerationInputReference('图标规范', specReference) : [], }; } 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, ): CanvasGenerationInputs { return { fields: [ { title: '素材描述', value: iconDescriptions.join('\n'), }, ], references: createGenerationInputReference('图标规范', specReference), }; } export function buildUiDesignAssetExtractionGenerationInputs( sourceLayer: CanvasLayer, ): CanvasGenerationInputs { return { fields: createGenerationInputField( '提取提示词', UI_DESIGN_ASSET_EXTRACTION_PROMPT, ), references: createLayerGenerationInputReference('UI设计图', sourceLayer), }; } 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: [ ...limitedReferences.flatMap((reference, index) => createGenerationInputReference(`参考图 ${index + 1}`, reference), ), ...createLayerGenerationInputReference( `参考图 ${limitedReferences.length + 1}`, sourceLayer, ), ], }; } 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) { if ( error instanceof ApiClientError && (error.status === 401 || error.status === 403) ) { return '请先登录后再生成图片'; } return error instanceof Error && error.message.trim() ? error.message : '生成图片失败'; }