2395 lines
70 KiB
TypeScript
2395 lines
70 KiB
TypeScript
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;
|
||
}
|