Files
Genarrative/src/components/image-editor/ImageCanvasGenerationModel.ts
kdletters 1f5605331f 拆分图片画布编辑器前端模型
抽出编辑器共享类型、画布模型、生成模型和导出模型

补充模型层单测覆盖素材、吸附、生成快照和导出规则

新增前端拆分计划并更新 TRACKING 浏览器回归记录
2026-06-17 01:53:59 +08:00

395 lines
13 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 { ApiClientError } from '../../services/apiClient';
import type {
EditorCharacterAnimationRatio,
EditorCharacterAnimationResolution,
} from '../../services/image-editor/editorProjectClient';
import type {
CanvasGenerationDialogState,
CanvasGenerationInputField,
CanvasGenerationInputs,
CanvasLayer,
CharacterReferenceImage,
GenerateDialogState,
SpecFormValues,
SpecGenerationType,
} from './ImageCanvasEditorTypes';
import { isGeneratedLayer } from './ImageCanvasEditorModel';
export const SPEC_GENERATION_COST = 5;
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 ICON_FRAME_ORIGINAL_SIZE = { width: 512, height: 512 };
export const ICON_FRAME_DISPLAY_SIZE = { width: 360, height: 360 };
export const DEFAULT_IMAGE_MODEL = 'gpt-image-2';
export const ICON_DESCRIPTION_LIMIT = 100;
// 图标素材面板按描述项扩宽,避免在画布子面板里做滑动列表。
export const ICON_DESCRIPTION_CARD_WIDTH_REM = 8.4;
export const ICON_COMPOSER_MIN_WIDTH_REM = 28;
export const ICON_COMPOSER_HORIZONTAL_CHROME_REM = 2.4;
export const DEFAULT_ICON_DESCRIPTIONS = [
'返回按钮',
'设置按钮',
'下一关按钮',
'提示按钮',
'原图按钮',
'冻结按钮',
];
export const QUICK_EDIT_SIZE_PRESETS = [
'1024x1024',
'1536x1024',
'2048x1152',
'1024x1536',
] as const;
export const QUICK_EDIT_MODEL_OPTIONS = [
{ label: 'GPT Image', value: DEFAULT_IMAGE_MODEL },
] as const;
export const CHARACTER_ANIMATION_MODEL = 'seedance2.0';
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 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: '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,
) {
if (type === 'character') {
return buildCharacterSpecPrompt(values);
}
if (type === 'ui') {
return buildUiSpecPrompt(values);
}
if (type === 'icon') {
return buildIconSpecPrompt(values);
}
return values.customPrompt.trim();
}
export function getLayerKindLabel(layer: CanvasLayer) {
if (layer.assetKind === 'spec') {
return '规范';
}
if (layer.assetKind === 'character') {
return '角色';
}
if (layer.assetKind === 'icon') {
return '图标';
}
if (layer.assetKind === 'icon-spec') {
return '图标规范';
}
return null;
}
export function formatLayerImageType(layer: CanvasLayer) {
if (layer.assetKind === 'spec') {
return '规范图片';
}
if (layer.assetKind === 'character') {
return '角色图片';
}
if (layer.assetKind === 'icon') {
return '图标素材图片';
}
if (layer.assetKind === 'icon-spec') {
return '图标素材规范图片';
}
return isGeneratedLayer(layer) ? '生成图片' : '上传图片';
}
export function calculateCharacterAnimationPrice(
resolution: EditorCharacterAnimationResolution,
durationSeconds: number,
) {
return (resolution === '720p' ? 20 : 10) * 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 {
return {
id: `canvas-${layer.id}`,
label: layer.title,
src: layer.src,
};
}
export function createGenerationInputField(
title: string,
value: string | null | undefined,
): CanvasGenerationInputField[] {
const normalizedValue = value?.trim();
return normalizedValue ? [{ title, value: normalizedValue }] : [];
}
export function buildImageGenerationInputs(prompt: string): CanvasGenerationInputs {
return {
fields: createGenerationInputField('生成提示词', prompt),
references: [],
};
}
export function buildSpecGenerationInputs(
specType: SpecGenerationType,
values: SpecFormValues,
): CanvasGenerationInputs {
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 buildIconGenerationInputs(
iconDescriptions: string[],
specReference: CharacterReferenceImage,
): CanvasGenerationInputs {
return {
fields: iconDescriptions.map((description, index) => ({
title: `素材描述 ${index + 1}`,
value: description,
})),
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 isCanvasGenerationDialog(
dialog: GenerateDialogState | null,
): dialog is CanvasGenerationDialogState {
return Boolean(
dialog?.id &&
(dialog.mode === 'generate' ||
dialog.mode === 'spec' ||
dialog.mode === 'character' ||
dialog.mode === 'icon'),
);
}
export function getGenerationFrameAriaLabel(
dialog: CanvasGenerationDialogState,
) {
if (dialog.mode === 'character') {
return '角色生成占位图';
}
if (dialog.mode === 'spec') {
return '规范生成占位图';
}
if (dialog.mode === 'icon') {
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';
}
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
: '生成图片失败';
}