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

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

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

新增前端拆分计划并更新 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

@@ -1,6 +1,6 @@
# 图片画布编辑器 Lovart 化执行跟踪
更新时间:`2026-06-14`
更新时间:`2026-06-17`
## 目标
@@ -17,6 +17,7 @@
| 前端交互 | 已完成 | 已实现缩放菜单、工具模式、Space 抓手、中键平移、吸附线、元数据弹窗和右侧真实修改结果。 |
| 素材库增强 | 已完成 | 已实现账号级素材库持久化、文件夹新建 / 折叠 / 重命名 / 删除、多文件上传、拖拽定向上传、素材框选与批量删除。 |
| 画布增强 | 已完成 | 已实现拖拽上传到画布并创建图层、图层打组、Ctrl/Cmd 滚轮缩放、普通滚轮纵向滚动和小地图拖拽移动视图。 |
| 前端拆分 | 进行中 | 已新增前端拆分计划,抽出类型、画布模型、生成模型和导出模型,主视图保留状态编排与 JSX 工作面。 |
| 验证 | 已完成 | 聚焦测试、类型检查、Rust 检查、schema guard、编码检查、diff 空白检查和浏览器 smoke 已通过。 |
## 待办清单
@@ -109,3 +110,5 @@
- 2026-06-14 组件复用修正:画布图片 hover 尺寸标签改为复用 `PlatformPillBadge tone="lightOverlay"`,局部 CSS 只保留定位和深色覆盖,不再重复维护 badge 的圆角、字号和基础排版;验证命令:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/common/PlatformPillBadge.test.tsx``npm run typecheck`
- 2026-06-14 组件复用修正:生成跟随框的关闭按钮改为复用 `PlatformIconButton variant="surfaceFloating"`,编辑器薄包装 `EditorIconButton` 增加 variant 透传,删除局部关闭按钮基础 chrome验证命令`npm run test -- src/components/image-editor/ImageCanvasEditorPrimitives.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/common/PlatformIconButton.test.tsx``npm run typecheck`
- 2026-06-16 编辑器回归修正:工程 / 素材 / 上传等编辑器请求恢复全局 401 / 403 登录弹窗;未登录上传会先弹登录并在登录后续传;画布背景入口恢复为 `画布背景设置` 面板支持预设色、自定义颜色、HEX 输入、非法值不应用、恢复默认和 Escape 关闭。验证命令:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/services/image-editor/editorProjectClient.test.ts``npm run typecheck``npm run check:encoding``git diff --check`;浏览器 smoke`http://127.0.0.1:10006/editor/canvas` 未登录打开 `账号入口`,登录后上传素材成功,背景面板打开后点击“暖灰”使画布背景变为 `rgb(243, 240, 234)`
- 2026-06-17 前端拆分第一阶段:新增 `ImageCanvasEditorTypes``ImageCanvasEditorModel``ImageCanvasGenerationModel``ImageCanvasExportModel`,把类型、画布快照 / 吸附 / 背景、生成输入快照和导出元数据规则从 `ImageCanvasEditorView` 抽出;新增模型层单测,主视图从 8286 行降至 7054 行。
- 2026-06-17 浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录打开工程和未登录上传均弹出 `账号入口`;关闭登录后点击 `画布背景色` 打开 `画布背景设置` 面板,点击 `暖灰` 后画布背景为 `rgb(243, 240, 234)`;登录开发账号后上传图片成功进入 `项目素材``AI画布工具栏` 保持可见。

View File

@@ -0,0 +1,49 @@
# 图片画布编辑器前端拆分计划
日期2026-06-17
## 背景
`src/components/image-editor/ImageCanvasEditorView.tsx` 已承载图片画布的素材库、图层、生成对象、导出、右键菜单、拖拽、缩放、吸附、小地图和登录恢复等能力。文件体量超过八千行,阅读一个局部交互时需要先穿过大量无关类型、常量和纯函数,后续继续补齐 Lovart 式画布能力会持续放大维护成本。
本轮拆分目标不是把界面拆成很多浅模块,而是先把稳定模型和纯逻辑放到有深度的模块中。主视图继续保留 React 状态编排和 JSX 工作面,避免第一轮把复杂交互拆成大量 props 透传。
## 拆分原则
- 类型、常量和纯函数优先拆出React 状态闭环暂不拆散。
- 模块接口要承载真实规则,不建立只转发一两个属性的浅模块。
- 画布坐标、素材快照、生成输入快照和导出元数据分别放在相近规则的模块内,提升局部修改能力。
- `ImageCanvasEditorView.tsx` 不再反向作为模型模块的依赖;依赖方向固定为 `View -> model/types/services`
- 任何拆分必须保持现有交互、持久化和测试断言不变。
## 第一阶段模块
- `ImageCanvasEditorTypes.ts`
- 承载编辑器前端共享类型:素材、图层、视口、工具、生成对象、历史快照、剪贴板、右键菜单、拖拽状态等。
- 只暴露类型,不承载运行时逻辑。
- `ImageCanvasEditorModel.ts`
- 承载画布基础模型:尺寸、缩放、背景色、素材默认文件夹、快照序列化 / 水合、素材库快照映射、吸附、右键菜单定位、DataTransfer 工具和通用数值格式化。
- 保留“图片显示尺寸跟随 Resolution”“只保留一个默认素材文件夹”“右键菜单不滚动而是限制到视口内”等规则。
- `ImageCanvasGenerationModel.ts`
- 承载生成相关模型:生成占位尺寸、默认模型、规范表单默认值、角色动画选项、生成输入快照、规范 prompt 构建、生成对象识别和错误文案。
- 保留角色动画优先用 `objectKey` 的体积保护规则。
- `ImageCanvasExportModel.ts`
- 承载画布素材导出的底层规则:文件名清理、日期格式、图片去重 key、Data URL 转 Blob、Blob 读取和图层导出元数据。
- ZIP 组包和下载触发仍留在主视图,作为 UI 状态编排的一部分。
## 后续阶段
- `ImageCanvasSidebarView`:素材 / 图层共用侧栏,等模型层稳定后再拆。
- `ImageCanvasStageView`:画布 viewport、图层渲染、右键菜单和生成占位框等交互回归覆盖更强后再拆。
- `ImageCanvasGenerationDock`:底部 AI 工具栏和生成面板族,等生成对象状态机进一步收口后再拆。
## 验证计划
- `npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/ImageCanvasEditorPrimitives.test.tsx`
- `npm run typecheck`
- `npm run check:encoding`
- `git diff --check`
- 浏览器回归 `/editor/canvas`:确认登录弹窗、素材上传、背景设置面板、底部工具栏和画布基础渲染仍正常。

View File

@@ -0,0 +1,164 @@
import { describe, expect, it } from 'vitest';
import {
CANVAS_WORLD_ORIGIN,
createLayerFromAsset,
hydrateLayer,
normalizeAssetLibrary,
normalizeCanvasBackgroundHex,
resolveSnappedLayerPosition,
serializeLayer,
} from './ImageCanvasEditorModel';
import type { CanvasLayer, EditorAsset } from './ImageCanvasEditorTypes';
describe('ImageCanvasEditorModel', () => {
it('normalizes valid canvas background hex values and rejects invalid input', () => {
expect(normalizeCanvasBackgroundHex(' #ABC ')).toBe('#aabbcc');
expect(normalizeCanvasBackgroundHex('#f8fafc')).toBe('#f8fafc');
expect(normalizeCanvasBackgroundHex('white')).toBeNull();
expect(normalizeCanvasBackgroundHex('#not-a-color')).toBeNull();
});
it('keeps only one default asset folder when normalizing the persisted library', () => {
const library = normalizeAssetLibrary({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
{
folderId: 'project-duplicate',
label: '旧项目素材',
sortOrder: 1,
collapsed: false,
systemDefault: true,
},
],
assets: [],
});
expect(library.folders).toHaveLength(1);
expect(library.folders[0]?.id).toBe('project');
});
it('creates a layer from an account asset at the requested screen point', () => {
const asset: EditorAsset = {
id: 'asset-1',
label: '角色草图',
src: 'data:image/png;base64,one',
width: 640,
height: 480,
folderId: 'project',
sourceKind: 'uploaded',
sourceType: 'uploaded',
persisted: true,
objectKey: 'oss/asset-1.png',
assetObjectId: 'object-1',
};
const layer = createLayerFromAsset(
asset,
3,
{ x: 20, y: 40, scale: 2 },
{ x: 420, y: 340 },
);
expect(layer).toMatchObject({
id: 'layer-asset-1-3',
title: '角色草图',
width: 640,
height: 480,
originalWidth: 640,
originalHeight: 480,
objectKey: 'oss/asset-1.png',
assetObjectId: 'object-1',
sourceAssetId: 'asset-1',
});
expect(layer.x).toBe(-18);
expect(layer.y).toBe(12);
});
it('serializes and hydrates canvas layer metadata without embedding image payloads', () => {
const layer: CanvasLayer = {
id: 'layer-generated',
resourceId: 'resource-generated',
title: '生成图',
src: 'data:image/png;base64,heavy',
x: 10,
y: 20,
width: 1024,
height: 768,
originalWidth: 1024,
originalHeight: 768,
zIndex: 9,
sourceType: 'generated',
objectKey: 'generated/object.png',
assetKind: 'character',
generationInputs: {
fields: [{ title: '角色设定', value: '骑士' }],
references: [],
},
locked: true,
};
const snapshot = serializeLayer(layer);
expect(snapshot).not.toHaveProperty('src');
const hydrated = hydrateLayer(
snapshot,
new Map([['resource-generated', { imageSrc: '/read/generated.png' }]]),
);
expect(hydrated).toMatchObject({
id: 'layer-generated',
src: '/read/generated.png',
sourceType: 'generated',
assetKind: 'character',
objectKey: 'generated/object.png',
locked: true,
});
});
it('snaps moving layers to nearby canvas and layer guides', () => {
const movingLayer: CanvasLayer = {
id: 'moving',
resourceId: 'resource-moving',
title: '移动图',
src: 'data:image/png;base64,moving',
x: 0,
y: 0,
width: 100,
height: 100,
originalWidth: 100,
originalHeight: 100,
zIndex: 1,
sourceType: 'uploaded',
};
const anchorLayer: CanvasLayer = {
...movingLayer,
id: 'anchor',
resourceId: 'resource-anchor',
x: 300,
y: 240,
zIndex: 2,
};
const snapped = resolveSnappedLayerPosition(
movingLayer,
CANVAS_WORLD_ORIGIN - 47,
238,
[movingLayer, anchorLayer],
1,
);
expect(snapped.x).toBe(CANVAS_WORLD_ORIGIN - movingLayer.width / 2);
expect(snapped.y).toBe(anchorLayer.y);
expect(snapped.guide).toEqual({
vertical: CANVAS_WORLD_ORIGIN,
horizontal: anchorLayer.y,
});
});
});

View File

@@ -0,0 +1,529 @@
import type {
EditorAssetLibrarySnapshot,
EditorProjectLayerSnapshot,
} from '../../services/image-editor/editorProjectClient';
import type {
CanvasAssetKind,
CanvasGenerationInputs,
CanvasLayer,
CanvasContextMenuState,
CanvasViewport,
EditorAsset,
EditorAssetFolder,
SnapCandidate,
} from './ImageCanvasEditorTypes';
export const EDITOR_ASSET_FOLDERS: EditorAssetFolder[] = [
{
id: 'project',
label: '项目素材',
collapsed: false,
systemDefault: true,
persisted: false,
},
];
export const CANVAS_WORLD_SIZE = 12000;
export const CANVAS_WORLD_ORIGIN = CANVAS_WORLD_SIZE / 2;
export const MIN_SCALE = 0.24;
export const MAX_SCALE = 3.2;
export const TOOLBAR_HALF_WIDTH = 132;
export const DEFAULT_CANVAS_SIZE = { width: 900, height: 640 };
export const SNAP_THRESHOLD_SCREEN_PX = 18;
export const FIT_VIEW_PADDING = 10;
export const MINIMAP_SIZE = { width: 132, height: 84 };
export const MINIMAP_PADDING = 8;
export const MINIMAP_DRAG_SENSITIVITY = 0.3;
export const ASSET_DRAG_MIME_TYPE = 'application/x-genarrative-editor-asset';
export const MAX_HISTORY_STEPS = 60;
export const CONTEXT_MENU_VIEWPORT_MARGIN = 8;
export const CONTEXT_MENU_SIZE = {
blank: { width: 188, height: 176 },
layer: { width: 188, height: 492 },
} as const;
export const CANVAS_BACKGROUND_OPTIONS = [
{ label: '白色', value: '#ffffff' },
{ label: '浅灰', value: '#f8fafc' },
{ label: '暖灰', value: '#f3f0ea' },
{ label: '冷蓝', value: '#eef6ff' },
];
export const DEFAULT_CANVAS_BACKGROUND_COLOR = '#f8fafc';
export function normalizeCanvasBackgroundHex(value: string) {
const trimmedValue = value.trim().toLowerCase();
const match = /^#([0-9a-f]{3}|[0-9a-f]{6})$/u.exec(trimmedValue);
if (!match) {
return null;
}
const hexValue = match[1] ?? '';
if (hexValue.length === 3) {
return `#${hexValue
.split('')
.map((part) => `${part}${part}`)
.join('')}`;
}
return `#${hexValue}`;
}
export function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
export function formatPercent(value: number) {
return `${Math.round(value * 100)}%`;
}
export function formatImageSizeValue(width: number, height: number) {
const safeWidth = Math.max(1, Math.round(width || 1024));
const safeHeight = Math.max(1, Math.round(height || 1024));
return `${safeWidth}x${safeHeight}`;
}
export function resolveLayerResolutionSize(
originalWidth: number,
originalHeight: number,
fallback: { width: number; height: number },
) {
// 中文注释:画布不再维护独立展示 Size图片显示尺寸统一跟随图片原始 Resolution。
return {
width: Math.max(1, Math.round(originalWidth || fallback.width || 1)),
height: Math.max(1, Math.round(originalHeight || fallback.height || 1)),
};
}
export function createLayerFromAsset(
asset: EditorAsset,
index: number,
viewport: CanvasViewport,
screenCenter: { x: number; y: number },
): CanvasLayer {
const { width, height } = resolveLayerResolutionSize(
asset.width,
asset.height,
{ width: 360, height: 360 },
);
const safeScale = viewport.scale > 0 ? viewport.scale : 1;
const safeScreenCenter = {
x: Number.isFinite(screenCenter.x) ? screenCenter.x : 0,
y: Number.isFinite(screenCenter.y) ? screenCenter.y : 0,
};
const worldCenterX = (safeScreenCenter.x - viewport.x) / safeScale;
const worldCenterY = (safeScreenCenter.y - viewport.y) / safeScale;
const offset = index * 34;
return {
id: `layer-${asset.id}-${index}`,
resourceId: `local-resource-${asset.id}-${index}`,
title: asset.label,
src: asset.src,
x: worldCenterX - width / 2 + offset,
y: worldCenterY - height / 2 + offset,
width,
height,
originalWidth: asset.width,
originalHeight: asset.height,
zIndex: index + 10,
sourceType: asset.sourceType,
prompt: asset.prompt,
actualPrompt: asset.actualPrompt,
model: asset.model,
provider: asset.provider,
taskId: asset.taskId,
objectKey: asset.objectKey,
assetObjectId: asset.assetObjectId,
sourceAssetId: asset.id,
} satisfies CanvasLayer;
}
export function serializeLayer(
layer: CanvasLayer,
): EditorProjectLayerSnapshot {
return {
layerId: layer.id,
resourceId: layer.resourceId,
title: layer.title,
x: layer.x,
y: layer.y,
width: layer.width,
height: layer.height,
originalWidth: layer.originalWidth,
originalHeight: layer.originalHeight,
zIndex: layer.zIndex,
sourceType: layer.sourceType,
prompt: layer.prompt,
actualPrompt: layer.actualPrompt,
model: layer.model,
provider: layer.provider,
taskId: layer.taskId,
objectKey: layer.objectKey,
assetObjectId: layer.assetObjectId,
sourceResourceId: layer.sourceResourceId,
sourceAssetId: layer.sourceAssetId,
groupId: layer.groupId,
assetKind: layer.assetKind,
generationInputs: layer.generationInputs,
hidden: layer.hidden,
locked: layer.locked,
flipX: layer.flipX,
flipY: layer.flipY,
};
}
export function hydrateLayer(
snapshot: EditorProjectLayerSnapshot,
resourcesById: Map<string, { imageSrc: string }>,
): CanvasLayer | null {
const resourceId =
typeof snapshot.resourceId === 'string' ? snapshot.resourceId : '';
const layerId = typeof snapshot.layerId === 'string' ? snapshot.layerId : '';
const snapshotSrc = typeof snapshot.src === 'string' ? snapshot.src : '';
const src = snapshotSrc || resourcesById.get(resourceId)?.imageSrc || '';
const title =
typeof snapshot.title === 'string' ? snapshot.title : '画布图片';
if (!resourceId || !layerId || !src) {
return null;
}
return {
id: layerId,
resourceId,
title,
src,
x: numberFromSnapshot(snapshot.x, 0),
y: numberFromSnapshot(snapshot.y, 0),
...(() => {
const originalWidth = numberFromSnapshot(snapshot.originalWidth, 320);
const originalHeight = numberFromSnapshot(snapshot.originalHeight, 320);
return {
...resolveLayerResolutionSize(originalWidth, originalHeight, {
width: numberFromSnapshot(snapshot.width, 320),
height: numberFromSnapshot(snapshot.height, 320),
}),
originalWidth,
originalHeight,
};
})(),
zIndex: numberFromSnapshot(snapshot.zIndex, 1),
sourceType: isCanvasSourceType(snapshot.sourceType)
? snapshot.sourceType
: 'uploaded',
prompt: stringOrNull(snapshot.prompt),
actualPrompt: stringOrNull(snapshot.actualPrompt),
model: stringOrNull(snapshot.model),
provider: stringOrNull(snapshot.provider),
taskId: stringOrNull(snapshot.taskId),
objectKey: stringOrNull(snapshot.objectKey),
assetObjectId: stringOrNull(snapshot.assetObjectId),
sourceResourceId: stringOrNull(snapshot.sourceResourceId),
sourceAssetId: stringOrNull(snapshot.sourceAssetId),
groupId: stringOrNull(snapshot.groupId),
assetKind: canvasAssetKindOrNull(snapshot.assetKind),
generationInputs: generationInputsOrNull(snapshot.generationInputs),
hidden: booleanFromSnapshot(snapshot.hidden),
locked: booleanFromSnapshot(snapshot.locked),
flipX: booleanFromSnapshot(snapshot.flipX),
flipY: booleanFromSnapshot(snapshot.flipY),
};
}
export function mapAssetLibrarySnapshot(
library: EditorAssetLibrarySnapshot,
): {
folders: EditorAssetFolder[];
assets: EditorAsset[];
} {
return {
folders: library.folders.map((folder) => ({
id: folder.folderId,
label: folder.label,
collapsed: folder.collapsed,
systemDefault: folder.systemDefault,
persisted: true,
})),
assets: library.assets.map((asset) => ({
id: asset.assetId,
label: asset.label,
src: asset.imageSrc,
width: asset.width,
height: asset.height,
folderId: asset.folderId,
sourceKind: 'uploaded',
sourceType: asset.sourceType,
persisted: true,
prompt: asset.prompt ?? undefined,
actualPrompt: asset.actualPrompt ?? undefined,
model: asset.model ?? undefined,
provider: asset.provider ?? undefined,
taskId: asset.taskId ?? undefined,
objectKey: asset.objectKey ?? undefined,
assetObjectId: asset.assetObjectId ?? undefined,
})),
};
}
export function normalizeAssetLibrary(library: EditorAssetLibrarySnapshot) {
const mapped = mapAssetLibrarySnapshot(library);
let hasDefaultFolder = false;
const normalizedFolders = mapped.folders.filter((folder) => {
if (!folder.systemDefault) {
return true;
}
if (hasDefaultFolder) {
return false;
}
hasDefaultFolder = true;
return true;
});
const persistedFolderIds = new Set(
normalizedFolders.map((folder) => folder.id),
);
const fallbackFolders = hasDefaultFolder
? []
: EDITOR_ASSET_FOLDERS.filter(
(folder) => !persistedFolderIds.has(folder.id),
);
return {
folders: [...normalizedFolders, ...fallbackFolders],
assets: mapped.assets,
};
}
export function numberFromSnapshot(value: unknown, fallback: number) {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
export function stringOrNull(value: unknown) {
return typeof value === 'string' && value.trim() ? value : null;
}
export function booleanFromSnapshot(value: unknown) {
return value === true;
}
export function resolveContextMenuPosition(
clientX: number,
clientY: number,
kind: CanvasContextMenuState['kind'],
) {
if (typeof window === 'undefined') {
return { x: clientX, y: clientY };
}
const menuSize = CONTEXT_MENU_SIZE[kind];
return {
x: clamp(
clientX,
CONTEXT_MENU_VIEWPORT_MARGIN,
Math.max(
CONTEXT_MENU_VIEWPORT_MARGIN,
window.innerWidth - menuSize.width - CONTEXT_MENU_VIEWPORT_MARGIN,
),
),
y: clamp(
clientY,
CONTEXT_MENU_VIEWPORT_MARGIN,
Math.max(
CONTEXT_MENU_VIEWPORT_MARGIN,
window.innerHeight - menuSize.height - CONTEXT_MENU_VIEWPORT_MARGIN,
),
),
};
}
export function hasDataTransferType(
dataTransfer: DataTransfer,
type: string,
) {
return Array.from(dataTransfer.types).includes(type);
}
export function getDraggedAssetId(dataTransfer: DataTransfer) {
if (typeof dataTransfer.getData !== 'function') {
return '';
}
if (!hasDataTransferType(dataTransfer, ASSET_DRAG_MIME_TYPE)) {
return '';
}
return dataTransfer.getData(ASSET_DRAG_MIME_TYPE);
}
export function escapeCssIdentifier(value: string) {
return typeof CSS !== 'undefined' && typeof CSS.escape === 'function'
? CSS.escape(value)
: value.replace(/["\\]/gu, '\\$&');
}
export function isLayerLinkedToAsset(layer: CanvasLayer, asset: EditorAsset) {
return (
layer.sourceAssetId === asset.id ||
Boolean(asset.assetObjectId && layer.assetObjectId === asset.assetObjectId) ||
Boolean(asset.objectKey && layer.objectKey === asset.objectKey) ||
layer.src === asset.src
);
}
export function generationInputsOrNull(
value: unknown,
): CanvasGenerationInputs | null {
if (!value || typeof value !== 'object') {
return null;
}
const snapshot = value as {
fields?: unknown;
references?: unknown;
};
const fields = Array.isArray(snapshot.fields)
? snapshot.fields.flatMap((field) => {
if (!field || typeof field !== 'object') {
return [];
}
const item = field as { title?: unknown; value?: unknown };
const title = stringOrNull(item.title);
const fieldValue = stringOrNull(item.value);
return title && fieldValue ? [{ title, value: fieldValue }] : [];
})
: [];
const references = Array.isArray(snapshot.references)
? snapshot.references.flatMap((reference) => {
if (!reference || typeof reference !== 'object') {
return [];
}
const item = reference as {
title?: unknown;
label?: unknown;
src?: unknown;
};
const title = stringOrNull(item.title);
const label = stringOrNull(item.label);
const src = stringOrNull(item.src);
return title && label && src ? [{ title, label, src }] : [];
})
: [];
return fields.length || references.length ? { fields, references } : null;
}
export function canvasAssetKindOrNull(value: unknown): CanvasAssetKind | null {
return value === 'spec' ||
value === 'character' ||
value === 'icon' ||
value === 'icon-spec'
? value
: null;
}
export function isCanvasSourceType(
value: unknown,
): value is CanvasLayer['sourceType'] {
return (
value === 'uploaded' || value === 'generated' || value === 'mock_generated'
);
}
export function isGeneratedLayer(layer: CanvasLayer) {
return (
layer.sourceType === 'generated' || layer.sourceType === 'mock_generated'
);
}
export function getLayerBounds(targetLayers: CanvasLayer[]) {
if (targetLayers.length === 0) {
return null;
}
return targetLayers.reduce(
(current, layer) => ({
minX: Math.min(current.minX, layer.x),
minY: Math.min(current.minY, layer.y),
maxX: Math.max(current.maxX, layer.x + layer.width),
maxY: Math.max(current.maxY, layer.y + layer.height),
}),
{
minX: Number.POSITIVE_INFINITY,
minY: Number.POSITIVE_INFINITY,
maxX: Number.NEGATIVE_INFINITY,
maxY: Number.NEGATIVE_INFINITY,
},
);
}
export function resolveSnappedLayerPosition(
movingLayer: CanvasLayer,
proposedX: number,
proposedY: number,
layers: CanvasLayer[],
scale: number,
) {
const threshold = SNAP_THRESHOLD_SCREEN_PX / Math.max(scale, MIN_SCALE);
const verticalTargets = [
0,
CANVAS_WORLD_ORIGIN,
...layers
.filter((layer) => layer.id !== movingLayer.id)
.flatMap((layer) => [
layer.x,
layer.x + layer.width / 2,
layer.x + layer.width,
]),
];
const horizontalTargets = [
0,
CANVAS_WORLD_ORIGIN,
...layers
.filter((layer) => layer.id !== movingLayer.id)
.flatMap((layer) => [
layer.y,
layer.y + layer.height / 2,
layer.y + layer.height,
]),
];
const xSnap = findNearestSnap(
proposedX,
[0, movingLayer.width / 2, movingLayer.width],
verticalTargets,
threshold,
);
const ySnap = findNearestSnap(
proposedY,
[0, movingLayer.height / 2, movingLayer.height],
horizontalTargets,
threshold,
);
return {
x: xSnap ? xSnap.position : proposedX,
y: ySnap ? ySnap.position : proposedY,
guide:
xSnap || ySnap
? {
vertical: xSnap?.guide,
horizontal: ySnap?.guide,
}
: null,
};
}
export function findNearestSnap(
origin: number,
offsets: number[],
targets: number[],
threshold: number,
): SnapCandidate | null {
let nearest: SnapCandidate | null = null;
for (const offset of offsets) {
for (const target of targets) {
const distance = Math.abs(target - (origin + offset));
if (distance > threshold) {
continue;
}
if (!nearest || distance < nearest.distance) {
nearest = {
position: target - offset,
guide: target,
distance,
};
}
}
}
return nearest;
}

View File

@@ -0,0 +1,348 @@
import type {
EditorCharacterAnimationFrameCount,
EditorCharacterAnimationGenerationResult,
EditorCharacterAnimationRatio,
EditorCharacterAnimationResolution,
} from '../../services/image-editor/editorProjectClient';
export type CanvasSourceType = 'uploaded' | 'generated' | 'mock_generated';
export type CanvasAssetKind = 'spec' | 'character' | 'icon' | 'icon-spec';
export type EditorAsset = {
id: string;
label: string;
src: string;
width: number;
height: number;
folderId: string;
sourceKind: 'built-in' | 'uploaded';
sourceType: CanvasSourceType;
persisted: boolean;
prompt?: string;
actualPrompt?: string;
model?: string;
provider?: string;
taskId?: string;
objectKey?: string;
assetObjectId?: string;
uploadStatus?: 'uploading' | 'failed';
uploadProgress?: number;
uploadMessage?: string;
};
export type CanvasGenerationInputField = {
title: string;
value: string;
};
export type CanvasGenerationInputReference = {
title: string;
label: string;
src: string;
};
export type CanvasGenerationInputs = {
fields: CanvasGenerationInputField[];
references: CanvasGenerationInputReference[];
};
export type CanvasLayer = {
id: string;
resourceId: string;
title: string;
src: string;
x: number;
y: number;
width: number;
height: number;
originalWidth: number;
originalHeight: number;
zIndex: number;
sourceType: CanvasSourceType;
prompt?: string | null;
actualPrompt?: string | null;
model?: string | null;
provider?: string | null;
taskId?: string | null;
objectKey?: string | null;
assetObjectId?: string | null;
sourceResourceId?: string | null;
sourceAssetId?: string | null;
groupId?: string | null;
assetKind?: CanvasAssetKind | null;
generationInputs?: CanvasGenerationInputs | null;
hidden?: boolean;
locked?: boolean;
flipX?: boolean;
flipY?: boolean;
};
export type CanvasViewport = {
x: number;
y: number;
scale: number;
};
export type CanvasTool =
| 'select'
| 'hand'
| 'upload'
| 'generate'
| 'spec'
| 'character'
| 'icon'
| 'text'
| 'shape'
| 'export';
export type SidebarPanel = 'assets' | 'layers';
export type EditorAssetFolder = {
id: string;
label: string;
collapsed: boolean;
systemDefault: boolean;
persisted: boolean;
};
export type SpecGenerationType = 'character' | 'ui' | 'icon' | 'custom';
export type SpecFormValues = {
playSetting: string;
artStyle: string;
bodyRatio: string;
characterView: string;
customPrompt: string;
};
export type CharacterReferenceImage = {
id: string;
label: string;
src: string;
};
export type GenerateDialogState = {
id?: string;
mode: 'generate' | 'edit' | 'spec' | 'character' | 'icon';
prompt: string;
status: 'idle' | 'generating' | 'failed';
composerOpen?: boolean;
sourceLayerId?: string;
generatedLayerId?: string;
specType?: SpecGenerationType;
specValues?: SpecFormValues;
characterSpecReference?: CharacterReferenceImage | null;
characterReferences?: CharacterReferenceImage[];
iconSpecReference?: CharacterReferenceImage | null;
iconDescriptions?: string[];
errorMessage?: string;
placeholder?: {
x: number;
y: number;
width: number;
height: number;
originalWidth: number;
originalHeight: number;
};
};
export type CanvasGenerationDialogMode = Exclude<
GenerateDialogState['mode'],
'edit'
>;
export type CanvasGenerationDialogState = GenerateDialogState & {
id: string;
mode: CanvasGenerationDialogMode;
};
export type ImageContextMenuState = {
layerId: string;
x: number;
y: number;
};
export type CanvasHistorySnapshot = {
layers: CanvasLayer[];
viewport: CanvasViewport;
generateDialog: GenerateDialogState | null;
inactiveGenerateDialogs: CanvasGenerationDialogState[];
selectedLayerId: string | null;
selectedLayerIds: string[];
};
export type CanvasClipboard = {
layers: CanvasLayer[];
mode: 'copy' | 'cut';
};
export type CanvasContextMenuState =
| {
kind: 'blank';
x: number;
y: number;
canvasPoint: { x: number; y: number };
}
| {
kind: 'layer';
x: number;
y: number;
layerId: string;
canvasPoint: { x: number; y: number };
};
export type QuickEditPanelState = {
sourceLayerId: string;
prompt: string;
size: string;
model: string;
status: 'idle' | 'generating' | 'failed';
errorMessage?: string;
};
export type CharacterAnimationPanelState = {
sourceLayerId: string;
promptText: string;
resolution: EditorCharacterAnimationResolution;
ratio: EditorCharacterAnimationRatio;
frameCount: EditorCharacterAnimationFrameCount;
durationSeconds: 4 | 5 | 6;
status: 'idle' | 'generating' | 'completed' | 'failed';
errorMessage?: string;
result?: EditorCharacterAnimationGenerationResult;
};
export type UploadTarget =
| 'asset'
| 'character-spec'
| 'character-reference'
| 'icon-spec';
export type SnapGuide = {
vertical?: number;
horizontal?: number;
};
export type SnapCandidate = {
position: number;
guide: number;
distance: number;
};
export type AssetMarqueeState = {
pointerId: number;
startX: number;
startY: number;
currentX: number;
currentY: number;
};
export type AssetPointerDragState = {
pointerId: number;
assetId: string;
startClientX: number;
startClientY: number;
currentClientX: number;
currentClientY: number;
active: boolean;
dropFolderId: string | null;
};
export type CanvasMarqueeState = {
pointerId: number;
startX: number;
startY: number;
currentX: number;
currentY: number;
};
export type DragState =
| {
kind: 'pan';
pointerId: number;
startClientX: number;
startClientY: number;
startViewport: CanvasViewport;
}
| {
kind: 'layer';
pointerId: number;
layerId: string;
layerIds: string[];
startClientX: number;
startClientY: number;
startLayerX: number;
startLayerY: number;
startLayers: Array<{ id: string; x: number; y: number }>;
startScale: number;
}
| {
kind: 'generation-frame';
dialogId: string;
pointerId: number;
startClientX: number;
startClientY: number;
startFrameX: number;
startFrameY: number;
startScale: number;
}
| {
kind: 'minimap';
pointerId: number;
startClientX: number;
startClientY: number;
startViewport: CanvasViewport;
minimapScale: number;
moved: boolean;
};
export type CanvasAssetExportImage = {
key: string;
file: string;
layer: CanvasLayer;
blob?: Blob;
error?: string;
};
export type CanvasAssetExportMetadata = {
projectId: string | null;
projectTitle: string;
exportedAt: string;
layers: Array<{
layerId: string;
title: string;
file: string | null;
sourceType: CanvasSourceType;
prompt?: string | null;
actualPrompt?: string | null;
model?: string | null;
provider?: string | null;
taskId?: string | null;
objectKey?: string | null;
assetObjectId?: string | null;
sourceResourceId?: string | null;
sourceAssetId?: string | null;
exportError?: string;
canvas: {
x: number;
y: number;
width: number;
height: number;
originalWidth: number;
originalHeight: number;
zIndex: number;
groupId?: string | null;
hidden?: boolean;
locked?: boolean;
flipX?: boolean;
flipY?: boolean;
};
}>;
failedImages: Array<{
key: string;
title: string;
src: string;
error: string;
}>;
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,118 @@
import { describe, expect, it } from 'vitest';
import {
buildLayerExportMetadata,
dataUrlToBlob,
formatExportDate,
getImageExtensionFromTypeOrSrc,
getLayerExportKey,
sanitizeExportFilePart,
} from './ImageCanvasExportModel';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
describe('ImageCanvasExportModel', () => {
it('normalizes export file names and dates', () => {
expect(sanitizeExportFilePart(' 角色/草图:*? ', 'fallback')).toBe(
'角色 草图',
);
expect(sanitizeExportFilePart(' ', 'fallback')).toBe('fallback');
expect(formatExportDate(new Date('2026-06-17T01:02:03.000Z'))).toBe(
'20260617',
);
});
it('chooses stable image export keys by persistence identity', () => {
expect(
getLayerExportKey({
...buildLayer(),
assetObjectId: 'object-1',
objectKey: 'object-key',
sourceAssetId: 'asset-1',
src: 'data:image/png;base64,one',
}),
).toBe('object-1');
expect(
getLayerExportKey({
...buildLayer(),
objectKey: 'object-key',
sourceAssetId: 'asset-1',
src: 'data:image/png;base64,one',
}),
).toBe('object-key');
});
it('detects image extensions from content type before falling back to src', () => {
expect(getImageExtensionFromTypeOrSrc('image/jpeg', '/image.png')).toBe(
'jpg',
);
expect(getImageExtensionFromTypeOrSrc('', '/image.webp?x=1')).toBe('webp');
expect(getImageExtensionFromTypeOrSrc('', '/image.unknown')).toBe('png');
});
it('converts data URLs and builds layer metadata for manifest files', async () => {
const blob = dataUrlToBlob('data:text/plain;base64,SGVsbG8=');
expect(blob.type).toBe('text/plain');
expect(await blob.text()).toBe('Hello');
expect(buildLayerExportMetadata(buildLayer(), 'images/001-layer.png')).toEqual({
layerId: 'layer-1',
title: '导出图层',
file: 'images/001-layer.png',
sourceType: 'generated',
prompt: '生成提示',
actualPrompt: '实际提示',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-1',
objectKey: 'generated/layer.png',
assetObjectId: undefined,
sourceResourceId: 'source-resource',
sourceAssetId: 'asset-1',
exportError: undefined,
canvas: {
x: 10,
y: 20,
width: 512,
height: 512,
originalWidth: 1024,
originalHeight: 1024,
zIndex: 3,
groupId: 'group-1',
hidden: true,
locked: false,
flipX: true,
flipY: false,
},
});
});
});
function buildLayer(): CanvasLayer {
return {
id: 'layer-1',
resourceId: 'resource-1',
title: '导出图层',
src: 'data:image/png;base64,one',
x: 10,
y: 20,
width: 512,
height: 512,
originalWidth: 1024,
originalHeight: 1024,
zIndex: 3,
sourceType: 'generated',
prompt: '生成提示',
actualPrompt: '实际提示',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-1',
objectKey: 'generated/layer.png',
sourceResourceId: 'source-resource',
sourceAssetId: 'asset-1',
groupId: 'group-1',
hidden: true,
locked: false,
flipX: true,
flipY: false,
};
}

View File

@@ -0,0 +1,127 @@
import type {
CanvasAssetExportMetadata,
CanvasLayer,
} from './ImageCanvasEditorTypes';
export function sanitizeExportFilePart(value: string, fallback: string) {
const safeValue = value
.trim()
.replace(/[\\/:*?"<>|]+/gu, ' ')
.replace(/\s+/gu, ' ')
.trim();
return safeValue || fallback;
}
export function formatExportDate(date: Date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}${month}${day}`;
}
export function getLayerExportKey(layer: CanvasLayer) {
return (
layer.assetObjectId ||
layer.objectKey ||
layer.sourceAssetId ||
layer.sourceResourceId ||
layer.src
);
}
export function getImageExtensionFromTypeOrSrc(type: string, src: string) {
if (type.includes('jpeg') || /\.(jpe?g)(?:[?#].*)?$/iu.test(src)) {
return 'jpg';
}
if (type.includes('webp') || /\.webp(?:[?#].*)?$/iu.test(src)) {
return 'webp';
}
if (type.includes('gif') || /\.gif(?:[?#].*)?$/iu.test(src)) {
return 'gif';
}
return 'png';
}
export function dataUrlToBlob(dataUrl: string) {
const [header = '', payload = ''] = dataUrl.split(',');
const mimeMatch = /^data:([^;]+)(;base64)?$/iu.exec(header);
const type = mimeMatch?.[1] ?? 'application/octet-stream';
const isBase64 = Boolean(mimeMatch?.[2]);
const binary = isBase64
? typeof atob === 'function'
? atob(payload)
: Buffer.from(payload, 'base64').toString('binary')
: decodeURIComponent(payload);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new Blob([bytes], { type });
}
export async function readLayerImageBlob(layer: CanvasLayer) {
if (layer.src.startsWith('data:')) {
return dataUrlToBlob(layer.src);
}
const response = await fetch(layer.src);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.blob();
}
export async function blobToUint8Array(blob: Blob) {
if (typeof blob.arrayBuffer === 'function') {
return new Uint8Array(await blob.arrayBuffer());
}
return new Promise<Uint8Array>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result;
if (result instanceof ArrayBuffer) {
resolve(new Uint8Array(result));
return;
}
reject(new Error('Blob 读取失败'));
};
reader.onerror = () => reject(new Error('Blob 读取失败'));
reader.readAsArrayBuffer(blob);
});
}
export function buildLayerExportMetadata(
layer: CanvasLayer,
file: string | null,
exportError?: string,
): CanvasAssetExportMetadata['layers'][number] {
return {
layerId: layer.id,
title: layer.title,
file,
sourceType: layer.sourceType,
prompt: layer.prompt,
actualPrompt: layer.actualPrompt,
model: layer.model,
provider: layer.provider,
taskId: layer.taskId,
objectKey: layer.objectKey,
assetObjectId: layer.assetObjectId,
sourceResourceId: layer.sourceResourceId,
sourceAssetId: layer.sourceAssetId,
exportError,
canvas: {
x: layer.x,
y: layer.y,
width: layer.width,
height: layer.height,
originalWidth: layer.originalWidth,
originalHeight: layer.originalHeight,
zIndex: layer.zIndex,
groupId: layer.groupId,
hidden: layer.hidden,
locked: layer.locked,
flipX: layer.flipX,
flipY: layer.flipY,
},
};
}

View File

@@ -0,0 +1,168 @@
import { describe, expect, it } from 'vitest';
import { ApiClientError } from '../../services/apiClient';
import {
DEFAULT_IMAGE_MODEL,
buildCharacterGenerationInputs,
buildEditGenerationInputs,
buildIconGenerationInputs,
buildImageGenerationInputs,
buildQuickEditModelOptions,
buildSpecGenerationInputs,
buildSpecPrompt,
getGenerationFrameAriaLabel,
getGenerationFrameLabel,
resolveCharacterAnimationSourceImageSrc,
resolveImageGenerationErrorMessage,
} from './ImageCanvasGenerationModel';
import type {
CanvasGenerationDialogState,
CanvasLayer,
} from './ImageCanvasEditorTypes';
describe('ImageCanvasGenerationModel', () => {
it('builds user-facing generation input snapshots instead of backend prompts', () => {
expect(buildImageGenerationInputs(' 一张明亮主视觉 ')).toEqual({
fields: [{ title: '生成提示词', value: '一张明亮主视觉' }],
references: [],
});
expect(
buildSpecGenerationInputs('character', {
playSetting: '平台跳跃',
artStyle: '像素风',
bodyRatio: '3',
characterView: '右向三分之二侧身',
customPrompt: '',
}),
).toEqual({
fields: [
{ title: '玩法设定', value: '平台跳跃' },
{ title: '美术风格', value: '像素风' },
{ title: '头身比', value: '3' },
{ title: '角色视角', value: '右向三分之二侧身' },
],
references: [],
});
});
it('builds character, icon and edit reference snapshots', () => {
const sourceLayer = buildSourceLayer();
expect(
buildCharacterGenerationInputs(
'主角骑士',
{ id: 'spec', label: '角色规范', src: '/spec.png' },
[{ id: 'ref-1', label: '盔甲参考', src: '/armor.png' }],
),
).toEqual({
fields: [{ title: '角色设定', value: '主角骑士' }],
references: [
{ title: '角色形象规范', label: '角色规范', src: '/spec.png' },
{ title: '常规参考图 1', label: '盔甲参考', src: '/armor.png' },
],
});
expect(
buildIconGenerationInputs(['返回按钮', '设置按钮'], {
id: 'icon-spec',
label: '图标规范',
src: '/icon-spec.png',
}),
).toEqual({
fields: [
{ title: '素材描述 1', value: '返回按钮' },
{ title: '素材描述 2', value: '设置按钮' },
],
references: [
{ title: '图标素材规范', label: '图标规范', src: '/icon-spec.png' },
],
});
expect(
buildEditGenerationInputs('修改要求', '换成夜晚', sourceLayer),
).toEqual({
fields: [{ title: '修改要求', value: '换成夜晚' }],
references: [
{ title: '参考图', label: '原图', src: '/source.png' },
],
});
});
it('keeps generated prompts and quick edit options stable', () => {
const prompt = buildSpecPrompt('ui', {
playSetting: '消除玩法',
artStyle: '清爽卡通',
bodyRatio: '3',
characterView: '',
customPrompt: '',
});
expect(prompt).toContain('生成一张完整游戏UI规范汇总设定展板');
expect(prompt).toContain('玩法设定:消除玩法');
expect(buildSpecPrompt('custom', { ...blankSpecValues, customPrompt: '自定义' }))
.toBe('自定义');
expect(buildQuickEditModelOptions('nano-banana')).toEqual([
{ label: 'nano-banana', value: 'nano-banana' },
{ label: 'GPT Image', value: DEFAULT_IMAGE_MODEL },
]);
});
it('uses objectKey for character animation references before falling back to src', () => {
expect(
resolveCharacterAnimationSourceImageSrc({
...buildSourceLayer(),
objectKey: 'generated/character.png',
}),
).toBe('generated/character.png');
expect(resolveCharacterAnimationSourceImageSrc(buildSourceLayer())).toBe(
'/source.png',
);
});
it('maps generation dialog mode and authorization errors to user-facing copy', () => {
const iconDialog: CanvasGenerationDialogState = {
id: 'dialog-icon',
mode: 'icon',
prompt: '',
status: 'idle',
};
expect(getGenerationFrameAriaLabel(iconDialog)).toBe('图标素材生成占位图');
expect(getGenerationFrameLabel(iconDialog)).toBe('Icon Generator');
expect(
resolveImageGenerationErrorMessage(
new ApiClientError({
message: '未授权访问requestId: one',
status: 401,
code: 'UNAUTHORIZED',
}),
),
).toBe('请先登录后再生成图片');
});
});
const blankSpecValues = {
playSetting: '',
artStyle: '',
bodyRatio: '3',
characterView: '',
customPrompt: '',
};
function buildSourceLayer(): CanvasLayer {
return {
id: 'layer-source',
resourceId: 'resource-source',
title: '原图',
src: '/source.png',
x: 0,
y: 0,
width: 512,
height: 512,
originalWidth: 512,
originalHeight: 512,
zIndex: 1,
sourceType: 'uploaded',
};
}

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
: '生成图片失败';
}