import { ApiClientError } from '../../services/apiClient'; import type { EditorCharacterAnimationRatio, EditorCharacterAnimationResolution, } from '../../services/image-editor/editorProjectClient'; import { isGeneratedLayer } from './ImageCanvasEditorModel'; import type { CanvasGenerationDialogState, CanvasGenerationInputField, CanvasGenerationInputs, CanvasLayer, CharacterReferenceImage, GenerateDialogState, SpecFormValues, SpecGenerationType, } from './ImageCanvasEditorTypes'; // 中文注释:与 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_COST = EDITOR_GENERATION_MUD_POINT_CONFIG.spec; 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 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; 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 ICON_DESCRIPTION_LIMIT = 100; export const DEFAULT_ICON_DESCRIPTIONS = [ '返回按钮', '设置按钮', '下一关按钮', '提示按钮', '原图按钮', '冻结按钮', ]; 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; export const VIDEO_MODEL_SEEDANCE_2 = 'seedance2.0'; export const VIDEO_MODEL_SEEDANCE_2_FAST = 'seedance2.0-fast'; export const DEFAULT_VIDEO_MODEL = VIDEO_MODEL_SEEDANCE_2_FAST; export const CHARACTER_ANIMATION_MODEL = VIDEO_MODEL_SEEDANCE_2_FAST; 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: 1280, height: 720 }; export const VIDEO_FRAME_DISPLAY_SIZE = { width: 560, height: 315 }; 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: 'kling3.0' }, { label: 'Kling 3.0 Omni', value: 'kling3.0-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 buildQuickEditModelOptions(currentModel: string) { const options = [...QUICK_EDIT_MODEL_OPTIONS]; return options.some((option) => option.value === currentModel) ? options : [{ label: currentModel, value: currentModel }, ...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-spec') { 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: [ { title: '角色图片', label: sourceLayer.title, src: sourceLayer.objectKey?.trim() || sourceLayer.src, }, ], }; } 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-spec') { 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( resolution: EditorCharacterAnimationResolution, durationSeconds: number, ) { return ( EDITOR_GENERATION_MUD_POINT_CONFIG.characterAnimationPerSecond[resolution] * durationSeconds ); } export function calculateEditorVideoPrice( resolution: '480p' | '720p', durationSeconds: number, ) { return ( EDITOR_GENERATION_MUD_POINT_CONFIG.videoPerSecond[resolution] * durationSeconds ); } 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, }; } 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 ?? []).map((reference, index) => ({ title: `参考图 ${index + 1}`, label: reference.label, src: reference.src, })), }; } export function buildVideoGenerationInputs( prompt: string, references?: CharacterReferenceImage[], ): CanvasGenerationInputs { const mediaIndexes = { image: 0, video: 0, audio: 0, }; return { fields: createGenerationInputField('视频描述', prompt), references: (references ?? []).map((reference) => { const mediaType = reference.mediaType ?? 'image'; mediaIndexes[mediaType] += 1; const title = mediaType === 'video' ? `参考视频 ${mediaIndexes.video}` : mediaType === 'audio' ? `参考音频 ${mediaIndexes.audio}` : `参考图 ${mediaIndexes.image}`; return { title, label: reference.label, src: reference.src, }; }), }; } export function buildSoundEffectGenerationInputs( sound: string, soundType: string, tempo: number | null | undefined, ): CanvasGenerationInputs { return { fields: [ ...createGenerationInputField('sound', sound), ...createGenerationInputField('type', soundType), ...(tempo ? createGenerationInputField('tempo', `${tempo}BPM`) : []), ], 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 ? [{ title: '参考图', label: reference.label, src: reference.src }] : []; 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 ? [ { title: '角色规范', label: specReference.label, src: specReference.src, }, ] : []), ...(references ?? []).map((reference, index) => ({ title: `常规参考图 ${index + 1}`, label: reference.label, src: reference.src, })), ], }; } export function buildUiDesignGenerationInputs( prompt: string, specReference: CharacterReferenceImage | null | undefined, ): CanvasGenerationInputs { return { fields: createGenerationInputField('用户输入', prompt), references: specReference ? [ { title: '图标规范', label: specReference.label, src: specReference.src, }, ] : [], }; } export function buildIconGenerationInputs( iconDescriptions: string[], specReference: CharacterReferenceImage, ): CanvasGenerationInputs { return { fields: [ { title: '素材描述', value: iconDescriptions.join('\n'), }, ], references: [ { title: '图标规范', label: specReference.label, src: specReference.src, }, ], }; } export function buildEditGenerationInputs( title: '修改要求' | '快速编辑提示词' | '重绘提示词', prompt: string, sourceLayer: CanvasLayer, ): CanvasGenerationInputs { return { fields: createGenerationInputField(title, prompt), references: [ { title: '参考图', label: sourceLayer.title, src: sourceLayer.src, }, ], }; } 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.map((reference, index) => ({ title: `参考图 ${index + 1}`, label: reference.label, src: reference.src, })), { title: `参考图 ${limitedReferences.length + 1}`, label: sourceLayer.title, src: sourceLayer.objectKey?.trim() || sourceLayer.src, }, ], }; } 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 === '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 === '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 === '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 : '生成图片失败'; }