From 1f5605331ffeac510155d575d500c2c3adc64526 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 17 Jun 2026 01:53:59 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=86=E5=88=86=E5=9B=BE=E7=89=87=E7=94=BB?= =?UTF-8?q?=E5=B8=83=E7=BC=96=E8=BE=91=E5=99=A8=E5=89=8D=E7=AB=AF=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 抽出编辑器共享类型、画布模型、生成模型和导出模型 补充模型层单测覆盖素材、吸附、生成快照和导出规则 新增前端拆分计划并更新 TRACKING 浏览器回归记录 --- TRACKING.md | 5 +- ...构】图片画布编辑器前端拆分计划-2026-06-17.md | 49 + .../ImageCanvasEditorModel.test.ts | 164 ++ .../image-editor/ImageCanvasEditorModel.ts | 529 ++++++ .../image-editor/ImageCanvasEditorTypes.ts | 348 ++++ .../image-editor/ImageCanvasEditorView.tsx | 1450 ++--------------- .../ImageCanvasExportModel.test.ts | 118 ++ .../image-editor/ImageCanvasExportModel.ts | 127 ++ .../ImageCanvasGenerationModel.test.ts | 168 ++ .../ImageCanvasGenerationModel.ts | 394 +++++ 10 files changed, 2010 insertions(+), 1342 deletions(-) create mode 100644 docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md create mode 100644 src/components/image-editor/ImageCanvasEditorModel.test.ts create mode 100644 src/components/image-editor/ImageCanvasEditorModel.ts create mode 100644 src/components/image-editor/ImageCanvasEditorTypes.ts create mode 100644 src/components/image-editor/ImageCanvasExportModel.test.ts create mode 100644 src/components/image-editor/ImageCanvasExportModel.ts create mode 100644 src/components/image-editor/ImageCanvasGenerationModel.test.ts create mode 100644 src/components/image-editor/ImageCanvasGenerationModel.ts diff --git a/TRACKING.md b/TRACKING.md index 1f5c9e0e..6b0e9257 100644 --- a/TRACKING.md +++ b/TRACKING.md @@ -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画布工具栏` 保持可见。 diff --git a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md new file mode 100644 index 00000000..7e4c270a --- /dev/null +++ b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md @@ -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`:确认登录弹窗、素材上传、背景设置面板、底部工具栏和画布基础渲染仍正常。 diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts new file mode 100644 index 00000000..47ce7f6a --- /dev/null +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -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, + }); + }); +}); diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts new file mode 100644 index 00000000..9e85e6fc --- /dev/null +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -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, +): 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; +} diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts new file mode 100644 index 00000000..c043bf49 --- /dev/null +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -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; + }>; +}; diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 84ebe779..a4a5bd96 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -56,15 +56,9 @@ import { deleteEditorAsset, deleteEditorAssetFolder, editEditorImage, - type EditorAssetLibrarySnapshot, - type EditorCharacterAnimationFrameCount, - type EditorCharacterAnimationGenerationResult, - type EditorCharacterAnimationRatio, - type EditorCharacterAnimationResolution, type EditorIconSpritesheetGenerationResult, type EditorIconSpritesheetIconResult, type EditorImageGenerationResult, - type EditorProjectLayerSnapshot, generateEditorCharacterAnimation, generateEditorIconSpritesheet, generateEditorImage, @@ -97,1192 +91,120 @@ import { EditorIconButton, SidebarMediaItem, } from './ImageCanvasEditorPrimitives'; - -type EditorAsset = { - id: string; - label: string; - src: string; - width: number; - height: number; - folderId: string; - sourceKind: 'built-in' | 'uploaded'; - sourceType: CanvasLayer['sourceType']; - persisted: boolean; - prompt?: string; - actualPrompt?: string; - model?: string; - provider?: string; - taskId?: string; - objectKey?: string; - assetObjectId?: string; - uploadStatus?: 'uploading' | 'failed'; - uploadProgress?: number; - uploadMessage?: string; -}; - -type CanvasGenerationInputField = { - title: string; - value: string; -}; - -type CanvasGenerationInputReference = { - title: string; - label: string; - src: string; -}; - -type CanvasGenerationInputs = { - fields: CanvasGenerationInputField[]; - references: CanvasGenerationInputReference[]; -}; - -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: 'uploaded' | 'generated' | 'mock_generated'; - 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?: 'spec' | 'character' | 'icon' | 'icon-spec' | null; - generationInputs?: CanvasGenerationInputs | null; - hidden?: boolean; - locked?: boolean; - flipX?: boolean; - flipY?: boolean; -}; - -type CanvasViewport = { - x: number; - y: number; - scale: number; -}; - -type CanvasTool = - | 'select' - | 'hand' - | 'upload' - | 'generate' - | 'spec' - | 'character' - | 'icon' - | 'text' - | 'shape' - | 'export'; - -type SidebarPanel = 'assets' | 'layers'; - -type EditorAssetFolder = { - id: string; - label: string; - collapsed: boolean; - systemDefault: boolean; - persisted: boolean; -}; - -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; - }; -}; - -type CanvasGenerationDialogMode = Exclude; - -type CanvasGenerationDialogState = GenerateDialogState & { - id: string; - mode: CanvasGenerationDialogMode; -}; - -type SpecGenerationType = 'character' | 'ui' | 'icon' | 'custom'; - -type SpecFormValues = { - playSetting: string; - artStyle: string; - bodyRatio: string; - characterView: string; - customPrompt: string; -}; - -type CharacterReferenceImage = { - id: string; - label: string; - src: string; -}; - -type ImageContextMenuState = { - layerId: string; - x: number; - y: number; -}; - -type CanvasHistorySnapshot = { - layers: CanvasLayer[]; - viewport: CanvasViewport; - generateDialog: GenerateDialogState | null; - inactiveGenerateDialogs: CanvasGenerationDialogState[]; - selectedLayerId: string | null; - selectedLayerIds: string[]; -}; - -type CanvasClipboard = { - layers: CanvasLayer[]; - mode: 'copy' | 'cut'; -}; - -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 }; - }; - -type CanvasAssetExportImage = { - key: string; - file: string; - layer: CanvasLayer; - blob?: Blob; - error?: string; -}; - -type CanvasAssetExportMetadata = { - projectId: string | null; - projectTitle: string; - exportedAt: string; - layers: Array<{ - layerId: string; - title: string; - file: string | null; - sourceType: CanvasLayer['sourceType']; - 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; - }>; -}; - -type QuickEditPanelState = { - sourceLayerId: string; - prompt: string; - size: string; - model: string; - status: 'idle' | 'generating' | 'failed'; - errorMessage?: string; -}; - -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; -}; - -type UploadTarget = - | 'asset' - | 'character-spec' - | 'character-reference' - | 'icon-spec'; - -type SnapGuide = { - vertical?: number; - horizontal?: number; -}; - -type SnapCandidate = { - position: number; - guide: number; - distance: number; -}; - -type AssetMarqueeState = { - pointerId: number; - startX: number; - startY: number; - currentX: number; - currentY: number; -}; - -type AssetPointerDragState = { - pointerId: number; - assetId: string; - startClientX: number; - startClientY: number; - currentClientX: number; - currentClientY: number; - active: boolean; - dropFolderId: string | null; -}; - -type CanvasMarqueeState = { - pointerId: number; - startX: number; - startY: number; - currentX: number; - currentY: number; -}; - -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; - }; - -const EDITOR_ASSETS: EditorAsset[] = []; - -const EDITOR_ASSET_FOLDERS: EditorAssetFolder[] = [ - { - id: 'project', - label: '项目素材', - collapsed: false, - systemDefault: true, - persisted: false, - }, -]; - -const INITIAL_LAYERS: CanvasLayer[] = []; - -const CANVAS_WORLD_SIZE = 12000; -const CANVAS_WORLD_ORIGIN = CANVAS_WORLD_SIZE / 2; -const MIN_SCALE = 0.24; -const MAX_SCALE = 3.2; -const TOOLBAR_HALF_WIDTH = 132; -const DEFAULT_CANVAS_SIZE = { width: 900, height: 640 }; -const SNAP_THRESHOLD_SCREEN_PX = 18; -const FIT_VIEW_PADDING = 10; -const MINIMAP_SIZE = { width: 132, height: 84 }; -const MINIMAP_PADDING = 8; -const MINIMAP_DRAG_SENSITIVITY = 0.3; -const ASSET_DRAG_MIME_TYPE = 'application/x-genarrative-editor-asset'; -const MAX_HISTORY_STEPS = 60; -const CONTEXT_MENU_VIEWPORT_MARGIN = 8; -const CONTEXT_MENU_SIZE = { - blank: { width: 188, height: 176 }, - layer: { width: 188, height: 492 }, -} as const; -const SPEC_GENERATION_COST = 5; -const SPEC_GENERATION_SIZE = '2048x1152'; -const SPEC_FRAME_ORIGINAL_SIZE = { width: 2048, height: 1152 }; -const SPEC_FRAME_DISPLAY_SIZE = { width: 560, height: 315 }; -const CHARACTER_FRAME_ORIGINAL_SIZE = { width: 2048, height: 2048 }; -const CHARACTER_FRAME_DISPLAY_SIZE = { width: 420, height: 420 }; -const ICON_FRAME_ORIGINAL_SIZE = { width: 512, height: 512 }; -const ICON_FRAME_DISPLAY_SIZE = { width: 360, height: 360 }; -const DEFAULT_IMAGE_MODEL = 'gpt-image-2'; -const ICON_DESCRIPTION_LIMIT = 100; -// 图标素材面板按描述项扩宽,避免在画布子面板里做滑动列表。 -const ICON_DESCRIPTION_CARD_WIDTH_REM = 8.4; -const ICON_COMPOSER_MIN_WIDTH_REM = 28; -const ICON_COMPOSER_HORIZONTAL_CHROME_REM = 2.4; -const DEFAULT_ICON_DESCRIPTIONS = [ - '返回按钮', - '设置按钮', - '下一关按钮', - '提示按钮', - '原图按钮', - '冻结按钮', -]; -const QUICK_EDIT_SIZE_PRESETS = [ - '1024x1024', - '1536x1024', - '2048x1152', - '1024x1536', -] as const; -const QUICK_EDIT_MODEL_OPTIONS = [ - { label: 'GPT Image', value: DEFAULT_IMAGE_MODEL }, -] as const; -const CHARACTER_ANIMATION_MODEL = 'seedance2.0'; -const CHARACTER_ANIMATION_ACTION_PROMPTS = [ - { label: '待机', text: '待机动作,轻微呼吸起伏。' }, - { label: '行走', text: '循环行走动作,步伐稳定。' }, - { label: '奔跑', text: '循环奔跑动作,动作清晰有力。' }, - { label: '跳跃', text: '起跳、滞空、落地动作。' }, - { label: '攻击', text: '攻击动作,前摇、出手、收招清晰。' }, - { label: '受击', text: '受击后短暂后仰并恢复站姿。' }, - { label: '倒下', text: '倒下动作,重心下落自然。' }, -] as const; -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' }, -]; -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; -const CANVAS_BACKGROUND_OPTIONS = [ - { label: '白色', value: '#ffffff' }, - { label: '浅灰', value: '#f8fafc' }, - { label: '暖灰', value: '#f3f0ea' }, - { label: '冷蓝', value: '#eef6ff' }, -]; -const DEFAULT_CANVAS_BACKGROUND_COLOR = '#f8fafc'; - -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}`; -} - -const DEFAULT_SPEC_FORM_VALUES: Record = { - 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: '', - }, -}; - -const SPEC_TYPE_LABEL: Record = { - character: '角色形象规范', - ui: 'UI素材规范', - icon: '图标素材规范', - custom: '自定义规范', -}; - -const CHARACTER_SPEC_VIEW_OPTIONS = [ - DEFAULT_SPEC_FORM_VALUES.character.characterView, - '左向三分之二侧身站姿', - '左向三分之二侧身站姿,保留少量正面信息,能读到面部轮廓与胸肩结构,禁止生成完全 90 度纯左视图,也禁止生成正面立绘。', - '右向三分之二侧身站姿,保留少量正面信息,强调面部轮廓、胸肩结构与主要装备层次。', - '背向斜侧身站姿,保留少量侧脸信息,突出背部服饰层次、武器挂载与轮廓识别。', -]; - -function clamp(value: number, min: number, max: number) { - return Math.min(max, Math.max(min, value)); -} - -function formatPercent(value: number) { - return `${Math.round(value * 100)}%`; -} - -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}`; -} - -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)), - }; -} - -function buildQuickEditSizeOptions(currentSize: string) { - return Array.from(new Set([currentSize, ...QUICK_EDIT_SIZE_PRESETS])); -} - -function buildQuickEditModelOptions(currentModel: string) { - const options = [...QUICK_EDIT_MODEL_OPTIONS]; - return options.some((option) => option.value === currentModel) - ? options - : [{ label: currentModel, value: currentModel }, ...options]; -} +import { + ASSET_DRAG_MIME_TYPE, + CANVAS_BACKGROUND_OPTIONS, + CANVAS_WORLD_SIZE, + DEFAULT_CANVAS_BACKGROUND_COLOR, + DEFAULT_CANVAS_SIZE, + EDITOR_ASSET_FOLDERS, + FIT_VIEW_PADDING, + MAX_HISTORY_STEPS, + MAX_SCALE, + MIN_SCALE, + MINIMAP_DRAG_SENSITIVITY, + MINIMAP_PADDING, + MINIMAP_SIZE, + TOOLBAR_HALF_WIDTH, + clamp, + createLayerFromAsset, + escapeCssIdentifier, + formatImageSizeValue, + formatPercent, + getDraggedAssetId, + getLayerBounds, + hasDataTransferType, + hydrateLayer, + isGeneratedLayer, + isLayerLinkedToAsset, + normalizeAssetLibrary, + normalizeCanvasBackgroundHex, + resolveContextMenuPosition, + resolveLayerResolutionSize, + resolveSnappedLayerPosition, + serializeLayer, +} from './ImageCanvasEditorModel'; +import { + blobToUint8Array, + buildLayerExportMetadata, + formatExportDate, + getImageExtensionFromTypeOrSrc, + getLayerExportKey, + readLayerImageBlob, + sanitizeExportFilePart, +} from './ImageCanvasExportModel'; +import { + CHARACTER_ANIMATION_ACTION_PROMPTS, + CHARACTER_ANIMATION_DURATION_OPTIONS, + CHARACTER_ANIMATION_MODEL, + CHARACTER_ANIMATION_RATIO_OPTIONS, + CHARACTER_FRAME_DISPLAY_SIZE, + CHARACTER_FRAME_ORIGINAL_SIZE, + CHARACTER_SPEC_VIEW_OPTIONS, + DEFAULT_ICON_DESCRIPTIONS, + DEFAULT_IMAGE_MODEL, + DEFAULT_SPEC_FORM_VALUES, + ICON_COMPOSER_HORIZONTAL_CHROME_REM, + ICON_COMPOSER_MIN_WIDTH_REM, + ICON_DESCRIPTION_CARD_WIDTH_REM, + ICON_DESCRIPTION_LIMIT, + ICON_FRAME_DISPLAY_SIZE, + ICON_FRAME_ORIGINAL_SIZE, + SPEC_FRAME_DISPLAY_SIZE, + SPEC_FRAME_ORIGINAL_SIZE, + SPEC_GENERATION_COST, + SPEC_GENERATION_SIZE, + SPEC_TYPE_LABEL, + buildCharacterGenerationInputs, + buildEditGenerationInputs, + buildIconGenerationInputs, + buildImageGenerationInputs, + buildQuickEditModelOptions, + buildQuickEditSizeOptions, + buildSpecGenerationInputs, + buildSpecPrompt, + calculateCharacterAnimationPrice, + createCanvasLayerReference, + formatLayerImageType, + getGenerationFrameAriaLabel, + getGenerationFrameLabel, + getLayerKindLabel, + isCanvasGenerationDialog, + resolveCharacterAnimationSourceImageSrc, + resolveImageGenerationErrorMessage, +} from './ImageCanvasGenerationModel'; +import type { + AssetMarqueeState, + AssetPointerDragState, + CanvasAssetExportImage, + CanvasAssetExportMetadata, + CanvasClipboard, + CanvasContextMenuState, + CanvasGenerationDialogState, + CanvasGenerationInputs, + CanvasHistorySnapshot, + CanvasLayer, + CanvasMarqueeState, + CanvasTool, + CanvasViewport, + CharacterAnimationPanelState, + DragState, + EditorAsset, + EditorAssetFolder, + GenerateDialogState, + ImageContextMenuState, + QuickEditPanelState, + SidebarPanel, + SnapGuide, + SpecFormValues, + SpecGenerationType, + UploadTarget, +} from './ImageCanvasEditorTypes'; function triggerPlaceholderAction(label: string) { window.alert(`${label}功能建设中`); } -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; -} - -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, - }; -} - -function hydrateLayer( - snapshot: EditorProjectLayerSnapshot, - resourcesById: Map, -): 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), - }; -} - -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, - })), - }; -} - -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, - }; -} - -function numberFromSnapshot(value: unknown, fallback: number) { - return typeof value === 'number' && Number.isFinite(value) ? value : fallback; -} - -function stringOrNull(value: unknown) { - return typeof value === 'string' && value.trim() ? value : null; -} - -function booleanFromSnapshot(value: unknown) { - return value === true; -} - -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, - ), - ), - }; -} - -function sanitizeExportFilePart(value: string, fallback: string) { - const safeValue = value - .trim() - .replace(/[\\/:*?"<>|]+/gu, ' ') - .replace(/\s+/gu, ' ') - .trim(); - return safeValue || fallback; -} - -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}`; -} - -function getLayerExportKey(layer: CanvasLayer) { - return ( - layer.assetObjectId || - layer.objectKey || - layer.sourceAssetId || - layer.sourceResourceId || - layer.src - ); -} - -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'; -} - -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 }); -} - -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(); -} - -async function blobToUint8Array(blob: Blob) { - if (typeof blob.arrayBuffer === 'function') { - return new Uint8Array(await blob.arrayBuffer()); - } - return new Promise((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); - }); -} - -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, - }, - }; -} - -function hasDataTransferType(dataTransfer: DataTransfer, type: string) { - return Array.from(dataTransfer.types).includes(type); -} - -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); -} - -function escapeCssIdentifier(value: string) { - return typeof CSS !== 'undefined' && typeof CSS.escape === 'function' - ? CSS.escape(value) - : value.replace(/["\\]/gu, '\\$&'); -} - -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 - ); -} - -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; -} - -function canvasAssetKindOrNull(value: unknown): CanvasLayer['assetKind'] { - return value === 'spec' || - value === 'character' || - value === 'icon' || - value === 'icon-spec' - ? value - : null; -} - -function isCanvasSourceType( - value: unknown, -): value is CanvasLayer['sourceType'] { - return ( - value === 'uploaded' || value === 'generated' || value === 'mock_generated' - ); -} - -function isGeneratedLayer(layer: CanvasLayer) { - return ( - layer.sourceType === 'generated' || layer.sourceType === 'mock_generated' - ); -} - -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'); -} - -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'); -} - -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'); -} - -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(); -} - -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; -} - -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) ? '生成图片' : '上传图片'; -} - -function calculateCharacterAnimationPrice( - resolution: EditorCharacterAnimationResolution, - durationSeconds: number, -) { - return (resolution === '720p' ? 20 : 10) * durationSeconds; -} - -function resolveCharacterAnimationSourceImageSrc(layer: CanvasLayer) { - // 中文注释:角色图已持久化到 OSS 时优先传 objectKey,避免把大 Data URL 塞进 JSON 请求体触发 body limit。 - return layer.objectKey?.trim() || layer.src; -} - -function createCanvasLayerReference( - layer: CanvasLayer, -): CharacterReferenceImage { - return { - id: `canvas-${layer.id}`, - label: layer.title, - src: layer.src, - }; -} - -function createGenerationInputField( - title: string, - value: string | null | undefined, -): CanvasGenerationInputField[] { - const normalizedValue = value?.trim(); - return normalizedValue ? [{ title, value: normalizedValue }] : []; -} - -function buildImageGenerationInputs(prompt: string): CanvasGenerationInputs { - return { - fields: createGenerationInputField('生成提示词', prompt), - references: [], - }; -} - -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: [], - }; -} - -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, - })), - ], - }; -} - -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, - }, - ], - }; -} - -function buildEditGenerationInputs( - title: '修改要求' | '快速编辑提示词', - prompt: string, - sourceLayer: CanvasLayer, -): CanvasGenerationInputs { - return { - fields: createGenerationInputField(title, prompt), - references: [ - { - title: '参考图', - label: sourceLayer.title, - src: sourceLayer.src, - }, - ], - }; -} - function buildPortalMenuStyle( anchor: HTMLElement | null, placement: 'above' | 'below', @@ -1325,27 +247,6 @@ function isImageFile(file: File) { return file.type.startsWith('image/'); } -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, - }, - ); -} - function isEditableTarget(event: KeyboardEvent) { const target = event.target as HTMLElement | null; if (!target) { @@ -1403,57 +304,6 @@ function getPointerId(event: ReactPointerEvent) { return Number.isFinite(nativeId) ? nativeId : -1; } -function isCanvasGenerationDialog( - dialog: GenerateDialogState | null, -): dialog is CanvasGenerationDialogState { - return Boolean( - dialog?.id && - (dialog.mode === 'generate' || - dialog.mode === 'spec' || - dialog.mode === 'character' || - dialog.mode === 'icon'), - ); -} - -function getGenerationFrameAriaLabel(dialog: CanvasGenerationDialogState) { - if (dialog.mode === 'character') { - return '角色生成占位图'; - } - if (dialog.mode === 'spec') { - return '规范生成占位图'; - } - if (dialog.mode === 'icon') { - return '图标素材生成占位图'; - } - return '图像生成占位图'; -} - -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'; -} - -function resolveImageGenerationErrorMessage(error: unknown) { - if ( - error instanceof ApiClientError && - (error.status === 401 || error.status === 403) - ) { - return '请先登录后再生成图片'; - } - - return error instanceof Error && error.message.trim() - ? error.message - : '生成图片失败'; -} - function isEditorAuthError(error: unknown) { return ( error instanceof ApiClientError && @@ -8201,86 +7051,4 @@ export function ImageCanvasEditorView() { ); } -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, - }; -} - -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; -} - export default ImageCanvasEditorView; diff --git a/src/components/image-editor/ImageCanvasExportModel.test.ts b/src/components/image-editor/ImageCanvasExportModel.test.ts new file mode 100644 index 00000000..86706a6b --- /dev/null +++ b/src/components/image-editor/ImageCanvasExportModel.test.ts @@ -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, + }; +} diff --git a/src/components/image-editor/ImageCanvasExportModel.ts b/src/components/image-editor/ImageCanvasExportModel.ts new file mode 100644 index 00000000..cd14ec8d --- /dev/null +++ b/src/components/image-editor/ImageCanvasExportModel.ts @@ -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((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, + }, + }; +} diff --git a/src/components/image-editor/ImageCanvasGenerationModel.test.ts b/src/components/image-editor/ImageCanvasGenerationModel.test.ts new file mode 100644 index 00000000..59286b2d --- /dev/null +++ b/src/components/image-editor/ImageCanvasGenerationModel.test.ts @@ -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', + }; +} diff --git a/src/components/image-editor/ImageCanvasGenerationModel.ts b/src/components/image-editor/ImageCanvasGenerationModel.ts new file mode 100644 index 00000000..88ee13b4 --- /dev/null +++ b/src/components/image-editor/ImageCanvasGenerationModel.ts @@ -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 = { + 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 + : '生成图片失败'; +}