拆分图片画布编辑器前端模型
抽出编辑器共享类型、画布模型、生成模型和导出模型 补充模型层单测覆盖素材、吸附、生成快照和导出规则 新增前端拆分计划并更新 TRACKING 浏览器回归记录
This commit is contained in:
394
src/components/image-editor/ImageCanvasGenerationModel.ts
Normal file
394
src/components/image-editor/ImageCanvasGenerationModel.ts
Normal 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
|
||||
: '生成图片失败';
|
||||
}
|
||||
Reference in New Issue
Block a user