拆分图片画布编辑器前端模型

抽出编辑器共享类型、画布模型、生成模型和导出模型

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

新增前端拆分计划并更新 TRACKING 浏览器回归记录
This commit is contained in:
2026-06-17 01:53:59 +08:00
parent 9177a313c2
commit 1f5605331f
10 changed files with 2010 additions and 1342 deletions

View File

@@ -0,0 +1,394 @@
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
: '生成图片失败';
}