Files
Genarrative/src/components/image-editor/ImageCanvasGenerationModel.ts
T
k88936 8f2b889160
Project CI / Repository checks (pull_request) Failing after 43s
Project CI / Backend tests (pull_request) Successful in 3m48s
Project CI / Frontend tests (pull_request) Successful in 4m0s
Project CI / Native shell tests (pull_request) Successful in 16m16s
移除视频素材的快速编辑支持
清理相关逻辑
清理测试用例。
2026-08-12 16:12:30 +08:00

2395 lines
70 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
) {
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 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<SpecGenerationType, string> = {
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<CanvasLayer['mediaType']>([
undefined,
'image',
]);
const QUICK_EDIT_SUPPORTED_ASSET_KINDS = new Set<CanvasLayer['assetKind']>([
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 [
'参考图生成规范:严格参考图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 === '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<T extends EditorGenerationPricingConfig['models']>(
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<typeof createGenerationInputReference>[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<string>();
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 = <T extends CanvasGenerationInputValue>(
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 = <T extends string>(
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 = <T extends number>(
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<GenerateDialogState['sceneStylePreset']>,
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,
): CanvasGenerationInputs {
const references = reference
? createGenerationInputReference('参考图', reference, {
id: 'reference',
})
: [];
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;
}