Files
Genarrative/src/components/image-editor/ImageCanvasGenerationModel.ts
T
JenkenB ea29887d9c 完善快速编辑参考图与生成占位
- 快速编辑支持最多8张额外参考图,并将原图自动作为最后一张参考图提交

- 快速编辑尺寸和模型沿用原图配置,按禁用态参数胶囊展示

- 快速编辑提交前同步改写原图引用为实际图序号

- 快速编辑生成改为创建独立画布生成占位,并支持键盘删除生成中对象

- 对齐生成类面板参考图图标、规范类固定参数和相关文档测试
2026-06-18 22:50:42 +08:00

719 lines
22 KiB
TypeScript

import { ApiClientError } from '../../services/apiClient';
import type {
EditorCharacterAnimationRatio,
EditorCharacterAnimationResolution,
} from '../../services/image-editor/editorProjectClient';
import { isGeneratedLayer } from './ImageCanvasEditorModel';
import type {
CanvasGenerationDialogState,
CanvasGenerationInputField,
CanvasGenerationInputs,
CanvasLayer,
CharacterReferenceImage,
GenerateDialogState,
SpecFormValues,
SpecGenerationType,
} from './ImageCanvasEditorTypes';
// 中文注释:与 api-server/src/editor_generation_config.rs 保持同名语义,当前作为前端展示兜底。
export const EDITOR_GENERATION_MUD_POINT_CONFIG = {
image: 12,
spec: 5,
character: 12,
icon: 12,
uiDesign: 12,
videoPerSecond: {
'480p': 10,
'720p': 20,
},
characterAnimationPerSecond: {
'480p': 10,
'720p': 20,
},
soundEffect: 10,
backgroundMusic: 5,
} as const;
export const SPEC_GENERATION_COST = EDITOR_GENERATION_MUD_POINT_CONFIG.spec;
export const SPEC_GENERATION_SIZE = '2048x1152';
export const SPEC_FRAME_ORIGINAL_SIZE = { width: 2048, height: 1152 };
export const SPEC_FRAME_DISPLAY_SIZE = { width: 560, height: 315 };
export const CHARACTER_FRAME_ORIGINAL_SIZE = { width: 2048, height: 2048 };
export const CHARACTER_FRAME_DISPLAY_SIZE = { width: 420, height: 420 };
export const CHARACTER_ANIMATION_FRAME_ORIGINAL_SIZE = {
width: 1024,
height: 1024,
};
export const CHARACTER_ANIMATION_FRAME_DISPLAY_SIZE = {
width: 420,
height: 420,
};
export const ICON_FRAME_ORIGINAL_SIZE = { width: 512, height: 512 };
export const ICON_FRAME_DISPLAY_SIZE = { width: 360, height: 360 };
export const UI_DESIGN_FRAME_ORIGINAL_SIZE = { width: 2048, height: 1152 };
export const UI_DESIGN_FRAME_DISPLAY_SIZE = { width: 560, height: 315 };
export const IMAGE_MODEL_GPT_IMAGE_2 = 'gpt-image-2';
export const IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview';
export const DEFAULT_IMAGE_MODEL = IMAGE_MODEL_NANOBANANA2;
export const SPEC_GENERATION_MODEL = IMAGE_MODEL_GPT_IMAGE_2;
export const SPEC_GENERATION_ASPECT_RATIO = '16:9';
export const SPEC_GENERATION_IMAGE_SIZE = '2K';
export const ICON_DESCRIPTION_LIMIT = 100;
export const DEFAULT_ICON_DESCRIPTIONS = [
'返回按钮',
'设置按钮',
'下一关按钮',
'提示按钮',
'原图按钮',
'冻结按钮',
];
export const QUICK_EDIT_SIZE_PRESETS = [
'1024x1024',
'1536x1024',
'2048x1152',
'1024x1536',
] as const;
export const QUICK_EDIT_MODEL_OPTIONS = [
{ label: 'nanobanana2', value: IMAGE_MODEL_NANOBANANA2 },
{ label: 'gpt-image-2', value: IMAGE_MODEL_GPT_IMAGE_2 },
] as const;
export const EDITOR_IMAGE_MODEL_OPTIONS = QUICK_EDIT_MODEL_OPTIONS;
export const EDITOR_IMAGE_DIMENSION_OPTIONS = {
[IMAGE_MODEL_NANOBANANA2]: {
aspectRatios: ['1:1', '2:3', '3:2', '9:16', '16:9'],
imageSizes: ['0.5K', '1K', '2K'],
},
[IMAGE_MODEL_GPT_IMAGE_2]: {
aspectRatios: ['1:1', '2:3', '3:2', '9:16', '16:9'],
imageSizes: ['1K', '2K'],
},
} as const;
export const VIDEO_MODEL_SEEDANCE_2 = 'seedance2.0';
export const VIDEO_MODEL_SEEDANCE_2_FAST = 'seedance2.0-fast';
export const DEFAULT_VIDEO_MODEL = VIDEO_MODEL_SEEDANCE_2_FAST;
export const CHARACTER_ANIMATION_MODEL = VIDEO_MODEL_SEEDANCE_2_FAST;
export const SEEDANCE_VIDEO_REFERENCE_LIMITS = {
image: 9,
video: 3,
audio: 3,
requestBytes: 64 * 1024 * 1024,
} as const;
export const QUICK_EDIT_REFERENCE_LIMIT = 8;
export const CHARACTER_ANIMATION_ACTION_PROMPTS = [
{ label: '待机', text: '待机动作,轻微呼吸起伏。' },
{ label: '行走', text: '循环行走动作,步伐稳定。' },
{ label: '奔跑', text: '循环奔跑动作,动作清晰有力。' },
{ label: '跳跃', text: '起跳、滞空、落地动作。' },
{ label: '攻击', text: '攻击动作,前摇、出手、收招清晰。' },
{ label: '受击', text: '受击后短暂后仰并恢复站姿。' },
{ label: '倒下', text: '倒下动作,重心下落自然。' },
] as const;
export const CHARACTER_ANIMATION_RATIO_OPTIONS: Array<{
label: string;
value: EditorCharacterAnimationRatio;
}> = [
{ label: '与角色图片保持同尺寸', value: 'same' },
{ label: '1:1', value: '1:1' },
{ label: '4:3', value: '4:3' },
{ label: '16:9', value: '16:9' },
{ label: '9:16', value: '9:16' },
{ label: '3:4', value: '3:4' },
];
export const CHARACTER_ANIMATION_DURATION_OPTIONS = [
{ label: '32帧·4秒', frameCount: 32, durationSeconds: 4 },
{ label: '40帧·5秒', frameCount: 40, durationSeconds: 5 },
{ label: '48帧·6秒', frameCount: 48, durationSeconds: 6 },
] as const;
export const VIDEO_FRAME_ORIGINAL_SIZE = { width: 1280, height: 720 };
export const VIDEO_FRAME_DISPLAY_SIZE = { width: 560, height: 315 };
export const AUDIO_FRAME_ORIGINAL_SIZE = { width: 420, height: 120 };
export const AUDIO_FRAME_DISPLAY_SIZE = { width: 420, height: 120 };
export const EDITOR_VIDEO_MODEL_OPTIONS = [
{ label: 'Seedance 2.0 Fast', value: VIDEO_MODEL_SEEDANCE_2_FAST },
{ label: 'Seedance 2.0', value: VIDEO_MODEL_SEEDANCE_2 },
{ label: 'Kling 3.0', value: 'kling3.0' },
{ label: 'Kling 3.0 Omni', value: 'kling3.0-omni' },
] as const;
export const EDITOR_VIDEO_DURATION_OPTIONS = [
{ label: '4秒', value: '4' },
{ label: '5秒', value: '5' },
] as const;
export const DEFAULT_SPEC_FORM_VALUES: Record<
SpecGenerationType,
SpecFormValues
> = {
character: {
playSetting: '战棋类RPG玩法',
artStyle: '像素风',
bodyRatio: '3',
characterView:
'右向斜侧身站姿,保留少量正面信息,能读到面部轮廓与胸肩结构,禁止生成完全 90 度纯右视图,也禁止生成正面立绘。',
customPrompt: '',
},
ui: {
playSetting: '抓娃娃题材的抓大鹅玩法',
artStyle: '毛茸茸',
bodyRatio: '3',
characterView:
'右向斜侧身站姿,保留少量正面信息,能读到面部轮廓与胸肩结构,禁止生成完全 90 度纯右视图,也禁止生成正面立绘。',
customPrompt: '',
},
icon: {
playSetting: '休闲小游戏',
artStyle: '清爽卡通',
bodyRatio: '3',
characterView:
'右向斜侧身站姿,保留少量正面信息,能读到面部轮廓与胸肩结构,禁止生成完全 90 度纯右视图,也禁止生成正面立绘。',
customPrompt: '',
},
custom: {
playSetting: '',
artStyle: '',
bodyRatio: '3',
characterView:
'右向斜侧身站姿,保留少量正面信息,能读到面部轮廓与胸肩结构,禁止生成完全 90 度纯右视图,也禁止生成正面立绘。',
customPrompt: '',
},
};
export const SPEC_TYPE_LABEL: Record<SpecGenerationType, string> = {
character: '角色规范',
ui: '图标规范',
icon: '图标规范',
custom: '自定义规范',
};
export const CHARACTER_SPEC_VIEW_OPTIONS = [
DEFAULT_SPEC_FORM_VALUES.character.characterView,
'左向三分之二侧身站姿',
'左向三分之二侧身站姿,保留少量正面信息,能读到面部轮廓与胸肩结构,禁止生成完全 90 度纯左视图,也禁止生成正面立绘。',
'右向三分之二侧身站姿,保留少量正面信息,强调面部轮廓、胸肩结构与主要装备层次。',
'背向斜侧身站姿,保留少量侧脸信息,突出背部服饰层次、武器挂载与轮廓识别。',
];
export function buildQuickEditSizeOptions(currentSize: string) {
return Array.from(new Set([currentSize, ...QUICK_EDIT_SIZE_PRESETS]));
}
export function buildQuickEditModelOptions(currentModel: string) {
const options = [...QUICK_EDIT_MODEL_OPTIONS];
return options.some((option) => option.value === currentModel)
? options
: [{ label: currentModel, value: currentModel }, ...options];
}
export function buildCharacterSpecPrompt(values: SpecFormValues) {
return [
'生成2D 角色美术视觉规范设定图,纯白底板,整齐排布全身标准立绘;固定统一头身比例、勾线粗细恒定;展示待机行走攻击基础动作帧样例,重心对齐不变位,服饰配饰分层结构示意,搭配专属角色色卡标注色号,无多余杂物,精准尺寸标注,高清矢量规范稿',
'禁止模糊、笔触杂乱、光影方向混乱、比例畸形、3D 渲染、实景照片、水印、花纹堆砌、画面抖动错位效果、噪点,',
`玩法设计:${values.playSetting.trim() || DEFAULT_SPEC_FORM_VALUES.character.playSetting}`,
`美术风格:${values.artStyle.trim() || DEFAULT_SPEC_FORM_VALUES.character.artStyle}`,
`头身比:${values.bodyRatio.trim() || DEFAULT_SPEC_FORM_VALUES.character.bodyRatio}`,
`视角要求:${values.characterView.trim() || DEFAULT_SPEC_FORM_VALUES.character.characterView}`,
].join('\n');
}
export function buildUiSpecPrompt(values: SpecFormValues) {
return [
'生成一张完整游戏UI规范汇总设定展板,纯白色干净背景,Figma专业设计稿质感,矢量锐利线条,页面划分九大区域:色彩规范、字体规范、图标规范、按钮规范、组件规范、布局规范、特效规范、IP规范、主视觉。主视觉居中较大显示,其他八个区域环绕主视觉',
'',
`玩法设定:${values.playSetting.trim() || DEFAULT_SPEC_FORM_VALUES.ui.playSetting}`,
`美术风格:${values.artStyle.trim() || DEFAULT_SPEC_FORM_VALUES.ui.artStyle}`,
].join('\n');
}
export function buildIconSpecPrompt(values: SpecFormValues) {
return [
'生成一张游戏图标素材视觉规范展板,纯白色干净背景,展示按钮图标的统一视角、线条粗细、填充风格、描边、阴影、圆角、材质、状态层级和色彩规范,图标样例需要成组排列且风格高度统一。',
'',
`玩法设定:${values.playSetting.trim() || DEFAULT_SPEC_FORM_VALUES.icon.playSetting}`,
`美术风格:${values.artStyle.trim() || DEFAULT_SPEC_FORM_VALUES.icon.artStyle}`,
].join('\n');
}
export function buildSpecPrompt(
type: SpecGenerationType,
values: SpecFormValues,
hasReferenceImage = false,
) {
const prompt =
type === 'character'
? buildCharacterSpecPrompt(values)
: type === 'ui'
? buildUiSpecPrompt(values)
: type === 'icon'
? buildIconSpecPrompt(values)
: values.customPrompt.trim();
if (!hasReferenceImage) {
return prompt;
}
return [
'参考图生成规范:严格参考图1的构图、风格、材质、色彩、形状语言和视觉层级生成本次规范图;参考图只作为美术方向和规范语义依据,不要直接复制参考图中的文字、水印或无关背景。',
prompt,
].join('\n');
}
export function getLayerKindLabel(layer: CanvasLayer) {
if (layer.assetKind === 'spec') {
return '规范';
}
if (layer.assetKind === 'character') {
return '角色';
}
if (layer.assetKind === 'character-animation') {
return '动作';
}
if (layer.assetKind === 'icon') {
return '图标';
}
if (layer.assetKind === 'icon-spec') {
return '图标规范';
}
if (layer.assetKind === 'ui-design') {
return 'UI设计';
}
if (layer.assetKind === 'video' || layer.mediaType === 'video') {
return '视频';
}
if (layer.assetKind === 'sound-effect') {
return '音效';
}
if (layer.assetKind === 'background-music') {
return '背景音乐';
}
if (layer.mediaType === 'audio') {
return '音频';
}
return null;
}
export function buildCharacterAnimationGenerationInputs(
promptText: string,
sourceLayer: CanvasLayer,
): CanvasGenerationInputs {
return {
fields: createGenerationInputField('动作描述', promptText),
references: [
{
title: '角色图片',
label: sourceLayer.title,
src: sourceLayer.objectKey?.trim() || sourceLayer.src,
},
],
};
}
export function formatLayerImageType(layer: CanvasLayer) {
if (layer.assetKind === 'spec') {
return '规范图片';
}
if (layer.assetKind === 'character') {
return '角色图片';
}
if (layer.assetKind === 'character-animation') {
return '角色动作图片';
}
if (layer.assetKind === 'icon') {
return '图标素材图片';
}
if (layer.assetKind === 'icon-spec') {
return '图标规范图片';
}
if (layer.assetKind === 'ui-design') {
return 'UI设计图';
}
if (layer.assetKind === 'video' || layer.mediaType === 'video') {
return '生成视频';
}
if (layer.assetKind === 'sound-effect') {
return '游戏音效';
}
if (layer.assetKind === 'background-music') {
return '游戏背景音乐';
}
if (layer.mediaType === 'audio') {
return '生成音频';
}
return isGeneratedLayer(layer) ? '生成图片' : '上传图片';
}
export function calculateCharacterAnimationPrice(
resolution: EditorCharacterAnimationResolution,
durationSeconds: number,
) {
return (
EDITOR_GENERATION_MUD_POINT_CONFIG.characterAnimationPerSecond[resolution] *
durationSeconds
);
}
export function calculateEditorVideoPrice(
resolution: '480p' | '720p',
durationSeconds: number,
) {
return (
EDITOR_GENERATION_MUD_POINT_CONFIG.videoPerSecond[resolution] *
durationSeconds
);
}
export function resolveCharacterAnimationSourceImageSrc(layer: CanvasLayer) {
// 中文注释:角色图已持久化到 OSS 时优先传 objectKey,避免把大 Data URL 塞进 JSON 请求体触发 body limit。
return layer.objectKey?.trim() || layer.src;
}
export function createCanvasLayerReference(
layer: CanvasLayer,
): CharacterReferenceImage {
const mediaType = layer.mediaType === 'video' || layer.mediaType === 'audio'
? layer.mediaType
: undefined;
return {
id: `canvas-${layer.id}`,
label: layer.title,
src: layer.objectKey?.trim() || layer.src,
...(mediaType ? { mediaType } : {}),
objectKey: layer.objectKey ?? undefined,
assetObjectId: layer.assetObjectId ?? undefined,
};
}
export function appendLimitedQuickEditReferences(
references: CharacterReferenceImage[] | undefined,
nextReferences: CharacterReferenceImage[],
) {
const imageReferences = nextReferences.filter(
(reference) => (reference.mediaType ?? 'image') === 'image',
);
return [
...(references ?? []),
...imageReferences,
].slice(0, QUICK_EDIT_REFERENCE_LIMIT);
}
export function normalizeQuickEditPromptSourceReference(
prompt: string,
sourceReferenceIndex: number,
) {
const normalizedIndex = Math.max(1, sourceReferenceIndex);
const sourceReferenceLabel = `图${normalizedIndex}`;
return prompt
.replace(/原图|当前图片|当前图/gu, sourceReferenceLabel)
.replace(/图\s*1(?!\d)/gu, sourceReferenceLabel);
}
export function createGenerationInputField(
title: string,
value: string | null | undefined,
): CanvasGenerationInputField[] {
const normalizedValue = value?.trim();
return normalizedValue ? [{ title, value: normalizedValue }] : [];
}
export function buildImageGenerationInputs(
prompt: string,
references?: CharacterReferenceImage[],
): CanvasGenerationInputs {
return {
fields: createGenerationInputField('生成提示词', prompt),
references: (references ?? []).map((reference, index) => ({
title: `参考图 ${index + 1}`,
label: reference.label,
src: reference.src,
})),
};
}
export function buildVideoGenerationInputs(
prompt: string,
references?: CharacterReferenceImage[],
): CanvasGenerationInputs {
const mediaIndexes = {
image: 0,
video: 0,
audio: 0,
};
return {
fields: createGenerationInputField('视频描述', prompt),
references: (references ?? []).map((reference) => {
const mediaType = reference.mediaType ?? 'image';
mediaIndexes[mediaType] += 1;
const title =
mediaType === 'video'
? `参考视频 ${mediaIndexes.video}`
: mediaType === 'audio'
? `参考音频 ${mediaIndexes.audio}`
: `参考图 ${mediaIndexes.image}`;
return {
title,
label: reference.label,
src: reference.src,
};
}),
};
}
export function buildSoundEffectGenerationInputs(
sound: string,
soundType: string,
tempo: number | null | undefined,
): CanvasGenerationInputs {
return {
fields: [
...createGenerationInputField('sound', sound),
...createGenerationInputField('type', soundType),
...(tempo
? createGenerationInputField('tempo', `${tempo}BPM`)
: []),
],
references: [],
};
}
export function buildBackgroundMusicGenerationInputs(
gptDescriptionPrompt: string,
): CanvasGenerationInputs {
return {
fields: createGenerationInputField(
'gpt_description_prompt',
gptDescriptionPrompt,
),
references: [],
};
}
export function buildSpecGenerationInputs(
specType: SpecGenerationType,
values: SpecFormValues,
reference?: CharacterReferenceImage | null,
): CanvasGenerationInputs {
const references = reference
? [{ title: '参考图', label: reference.label, src: reference.src }]
: [];
if (specType === 'custom') {
return {
fields: createGenerationInputField('自定义规范提示词', values.customPrompt),
references,
};
}
const baseFields = [
...createGenerationInputField('玩法设定', values.playSetting),
...createGenerationInputField('美术风格', values.artStyle),
];
if (specType === 'character') {
baseFields.push(
...createGenerationInputField('头身比', values.bodyRatio),
...createGenerationInputField('角色视角', values.characterView),
);
}
return {
fields: baseFields,
references,
};
}
export function buildCharacterGenerationInputs(
prompt: string,
specReference: CharacterReferenceImage | null | undefined,
references: CharacterReferenceImage[] | undefined,
): CanvasGenerationInputs {
return {
fields: createGenerationInputField('角色设定', prompt),
references: [
...(specReference
? [
{
title: '角色规范',
label: specReference.label,
src: specReference.src,
},
]
: []),
...(references ?? []).map((reference, index) => ({
title: `常规参考图 ${index + 1}`,
label: reference.label,
src: reference.src,
})),
],
};
}
export function buildUiDesignGenerationInputs(
prompt: string,
specReference: CharacterReferenceImage | null | undefined,
): CanvasGenerationInputs {
return {
fields: createGenerationInputField('用户输入', prompt),
references: specReference
? [
{
title: '图标规范',
label: specReference.label,
src: specReference.src,
},
]
: [],
};
}
export function buildIconGenerationInputs(
iconDescriptions: string[],
specReference: CharacterReferenceImage,
): CanvasGenerationInputs {
return {
fields: [
{
title: '素材描述',
value: iconDescriptions.join('\n'),
},
],
references: [
{
title: '图标规范',
label: specReference.label,
src: specReference.src,
},
],
};
}
export function buildEditGenerationInputs(
title: '修改要求' | '快速编辑提示词' | '重绘提示词',
prompt: string,
sourceLayer: CanvasLayer,
): CanvasGenerationInputs {
return {
fields: createGenerationInputField(title, prompt),
references: [
{
title: '参考图',
label: sourceLayer.title,
src: sourceLayer.src,
},
],
};
}
export function buildQuickEditGenerationInputs(
title: '快速编辑提示词' | '重绘提示词',
prompt: string,
sourceLayer: CanvasLayer,
references: CharacterReferenceImage[] | undefined,
): CanvasGenerationInputs {
const limitedReferences = (references ?? []).slice(
0,
QUICK_EDIT_REFERENCE_LIMIT,
);
return {
fields: createGenerationInputField(title, prompt),
references: [
...limitedReferences.map((reference, index) => ({
title: `参考图 ${index + 1}`,
label: reference.label,
src: reference.src,
})),
{
title: `参考图 ${limitedReferences.length + 1}`,
label: sourceLayer.title,
src: sourceLayer.objectKey?.trim() || sourceLayer.src,
},
],
};
}
export function isCanvasGenerationDialog(
dialog: GenerateDialogState | null,
): dialog is CanvasGenerationDialogState {
return Boolean(
dialog?.id &&
(dialog.mode === 'generate' ||
dialog.mode === 'spec' ||
dialog.mode === 'character' ||
dialog.mode === 'icon' ||
dialog.mode === 'ui-design' ||
dialog.mode === 'quick-edit' ||
dialog.mode === 'character-animation' ||
dialog.mode === 'video' ||
dialog.mode === 'audio-sound-effect' ||
dialog.mode === 'audio-background-music'),
);
}
export function getGenerationFrameAriaLabel(
dialog: CanvasGenerationDialogState,
) {
if (dialog.mode === 'character') {
return '角色生成占位图';
}
if (dialog.mode === 'spec') {
return '规范生成占位图';
}
if (dialog.mode === 'icon') {
return '图标素材生成占位图';
}
if (dialog.mode === 'ui-design') {
return 'UI设计图生成占位图';
}
if (dialog.mode === 'quick-edit') {
return '快速编辑生成占位图';
}
if (dialog.mode === 'video') {
return '视频生成占位图';
}
if (dialog.mode === 'character-animation') {
return '角色动作生成占位图';
}
if (dialog.mode === 'audio-sound-effect') {
return '音效生成占位图';
}
if (dialog.mode === 'audio-background-music') {
return '背景音乐生成占位图';
}
return '图像生成占位图';
}
export function getGenerationFrameLabel(dialog: CanvasGenerationDialogState) {
if (dialog.mode === 'character') {
return 'Character Generator';
}
if (dialog.mode === 'spec') {
return 'Spec Generator';
}
if (dialog.mode === 'icon') {
return 'Icon Generator';
}
if (dialog.mode === 'ui-design') {
return 'UI Design Generator';
}
if (dialog.mode === 'quick-edit') {
return 'Quick Edit Generator';
}
if (dialog.mode === 'video') {
return 'Video Generator';
}
if (dialog.mode === 'character-animation') {
return 'Action Generator';
}
if (dialog.mode === 'audio-sound-effect') {
return 'Sound Generator';
}
if (dialog.mode === 'audio-background-music') {
return 'Music Generator';
}
return 'Image Generator';
}
export function resolveImageGenerationErrorMessage(error: unknown) {
if (
error instanceof ApiClientError &&
(error.status === 401 || error.status === 403)
) {
return '请先登录后再生成图片';
}
return error instanceof Error && error.message.trim()
? error.message
: '生成图片失败';
}