拆分图片画布编辑器前端模型
抽出编辑器共享类型、画布模型、生成模型和导出模型 补充模型层单测覆盖素材、吸附、生成快照和导出规则 新增前端拆分计划并更新 TRACKING 浏览器回归记录
This commit is contained in:
@@ -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画布工具栏` 保持可见。
|
||||
|
||||
49
docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md
Normal file
49
docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md
Normal 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`:确认登录弹窗、素材上传、背景设置面板、底部工具栏和画布基础渲染仍正常。
|
||||
164
src/components/image-editor/ImageCanvasEditorModel.test.ts
Normal file
164
src/components/image-editor/ImageCanvasEditorModel.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
529
src/components/image-editor/ImageCanvasEditorModel.ts
Normal file
529
src/components/image-editor/ImageCanvasEditorModel.ts
Normal 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;
|
||||
}
|
||||
348
src/components/image-editor/ImageCanvasEditorTypes.ts
Normal file
348
src/components/image-editor/ImageCanvasEditorTypes.ts
Normal 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
118
src/components/image-editor/ImageCanvasExportModel.test.ts
Normal file
118
src/components/image-editor/ImageCanvasExportModel.test.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
127
src/components/image-editor/ImageCanvasExportModel.ts
Normal file
127
src/components/image-editor/ImageCanvasExportModel.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
168
src/components/image-editor/ImageCanvasGenerationModel.test.ts
Normal file
168
src/components/image-editor/ImageCanvasGenerationModel.test.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
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