From 3c37108ef68d9f737dcc6838357371f56b67866d Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 17 Jun 2026 07:04:20 +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=94=9F=E6=88=90=E5=9B=BE=E5=B1=82=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增生成结果图层模型和单测 主视图改为复用生成图层模型创建普通生图、快速编辑和图标图层 更新图片画布前端拆分文档和 TRACKING 回归记录 --- TRACKING.md | 1 + ...构】图片画布编辑器前端拆分计划-2026-06-17.md | 13 +- .../image-editor/ImageCanvasEditorView.tsx | 169 +++---------- .../ImageCanvasGenerationLayerModel.test.ts | 225 +++++++++++++++++ .../ImageCanvasGenerationLayerModel.ts | 237 ++++++++++++++++++ 5 files changed, 502 insertions(+), 143 deletions(-) create mode 100644 src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts create mode 100644 src/components/image-editor/ImageCanvasGenerationLayerModel.ts diff --git a/TRACKING.md b/TRACKING.md index 625fcb12..155fc39e 100644 --- a/TRACKING.md +++ b/TRACKING.md @@ -126,3 +126,4 @@ - 2026-06-17 前端拆分第九阶段:新增 `useCanvasGenerationDialogs`,把画布生成对象的 active / inactive 注册表、归档、激活、按 id 更新 / 删除、按图层清理和生成中最新占位框查询从主视图抽出;主视图继续保留生成提交、结果落图、quick edit 和跨图层副作用。同步把 `画布背景设置` 调整为 Lovart 式紧凑色板弹层。验证命令:`npm run test -- src/components/image-editor/useCanvasGenerationDialogs.test.tsx src/components/image-editor/useCanvasHistory.test.tsx src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 清空会话后未登录弹出 `账号入口`,关闭后点击 `画布背景色` 显示色域、色相条、圆形预设和 HEX 输入,点击 `生成工具` 后画布显示 `Image Generator` 占位框和 `生成图片` 对话框,`AI画布工具栏` 保持可见。 - 2026-06-17 前端拆分第十阶段:新增 `useImageCanvasAssetLibrary`,把账号级素材库加载、文件夹新建 / 折叠 / 重命名 / 删除、素材重命名 / 删除、素材选择模式、框选、多选删除、素材拖到文件夹和素材库 401 登录弹窗从主视图抽出;主视图继续保留上传读取、上传进度、拖到画布坐标、画布图层创建和工程资源持久化。新增 hook 单测覆盖素材库归一化、401 登录、新建文件夹临时 id 替换、素材移动、删除回调和多选删除。验证命令:`npm run test -- src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录刷新弹出 `账号入口`,背景面板点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`,点击 `生成工具` 后生成占位和 `生成图片` 对话框出现且 `AI画布工具栏` 保持可见;登录临时开发账号后上传图片成功进入 `项目素材`,点击素材加入画布,切换 `图层` 可看到对应图层,控制台无前端 error。 - 2026-06-17 前端拆分第十一阶段:新增 `ImageCanvasFileModel` 和 `useImageCanvasUploadWorkflow`,把隐藏上传 input、上传目标分发、未登录续传、上传占位卡片、素材落库、拖到画布建层、生成参考图上传从主视图抽出;主视图保留画布 drop 外层判断和项目资源持久化注入。验证命令:`npm run test -- src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 未登录刷新弹出 `账号入口`,登录临时开发账号后 `画布背景设置` 面板点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`,点击 `生成工具` 后显示 `Image Generator` 占位框和 `生成图片` 对话框且 `AI画布工具栏` 保持可见;上传图片后素材数增加,点击素材加入画布,切换 `图层` 面板可看到 2 个图层,登录后控制台无前端 error。 +- 2026-06-17 前端拆分第十二阶段:新增 `ImageCanvasGenerationLayerModel`,把普通生图、修改图片、快速编辑和图标素材批量生成结果落画布的图层 id、临时 resourceId、标题、位置、原始分辨率尺寸、zIndex、source metadata、源图关联和 `generationInputs` 纯规则从主视图抽出;主视图继续负责 API 提交、生成对象状态、资源持久化、选中态、侧栏和适合视图副作用。验证命令:`npm run test -- src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10003/editor/canvas` 清空会话后未登录刷新弹出 `账号入口`,登录临时开发账号后 `画布背景设置` 面板保留色相 / 自定义颜色 / 预设 / HEX / 恢复默认,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`,点击 `生成工具` 后显示 `Image Generator` 占位框和 `生成图片` 对话框且 `AI画布工具栏` 保持可见;真实上传图片后素材数从 2 增至 3,登录后控制台无前端 error。 diff --git a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md index a96272a4..459851b0 100644 --- a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md +++ b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md @@ -110,14 +110,21 @@ - 主视图继续负责画布 drop 外层事件判断、素材库已有素材加入画布、项目资源持久化 hook 注入和画布历史捕获,避免上传 hook 反向成为画布全局状态真相。 - 该 hook 用独立单测覆盖登录续传、上传占位 / 成功回写、上传到画布建层、鉴权失败和生成参考图分发,主视图保留 DOM 级 smoke 覆盖侧栏上传、画布 drop 上传和文件夹定向上传。 +## 第十二阶段模块 + +- `ImageCanvasGenerationLayerModel.ts` + - 承载生成结果落画布的纯数据规则:普通生图、修改图片、快速编辑和图标素材批量结果如何生成图层 id、临时 resourceId、标题、位置、原始分辨率尺寸、zIndex、source metadata、`assetKind`、源图关联和 `generationInputs`。 + - 主视图继续负责生成提交 API、生成对象 active / archived 状态、资源持久化、图层选择、侧栏切换、对话框收起和适合视图等副作用,避免把多个画布生成对象的生命周期拆成浅 wrapper。 + - 该模块用独立单测锁定“图片显示尺寸跟随原始 Resolution”“生成占位框只作为定位参考”“图标素材沿用当前行宽换行规则”和“快速编辑保留源图分组 / 类型”的规则。 + ## 后续阶段 -- 生成状态机模型:等生成对象归档、占位框拖拽、生成完成回写、失败恢复和 undo / redo 规则进一步稳定后,再从主视图抽出深层状态模型。 -- 生成状态机模型之后,可继续评估快速编辑 / 角色动画结果回写是否已经稳定到足以形成深模块。 +- 生成工作流 hook:等生成对象归档、占位框拖拽、生成完成回写、失败恢复和 undo / redo 规则进一步稳定后,再抽出 `useImageCanvasGenerationWorkflow` 这类深 hook;它应整体承接打开入口、提交状态、API 调用、错误映射和结果落图协调,而不是把主视图拆成大量 setter 透传。 +- 生成工作流 hook 之前,不再单独把 quick edit、角色动画或图标提交切成浅模块,避免破坏多生成对象同时存在、完成时读取最新占位框和角色动画优先传 `objectKey` 的历史保护规则。 ## 验证计划 -- `npm run test -- src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx` +- `npm run test -- src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts src/components/image-editor/ImageCanvasEditorView.test.tsx` - `npm run typecheck` - `npm run check:encoding` - `git diff --check` diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 11b605ec..8b8752a4 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -64,6 +64,11 @@ import { } from './ImageCanvasLayerCommandModel'; import { ImageCanvasSidebarView } from './ImageCanvasSidebarView'; import { ImageCanvasStageView } from './ImageCanvasStageView'; +import { + createGeneratedResultLayer, + createIconSpritesheetResultLayers, + createQuickEditResultLayer, +} from './ImageCanvasGenerationLayerModel'; import { ASSET_DRAG_MIME_TYPE, DEFAULT_CANVAS_BACKGROUND_COLOR, @@ -78,7 +83,6 @@ import { isLayerLinkedToAsset, normalizeCanvasBackgroundHex, resolveContextMenuPosition, - resolveLayerResolutionSize, serializeLayer, } from './ImageCanvasEditorModel'; import { @@ -1725,57 +1729,17 @@ export function ImageCanvasEditorView() { ) => { layerCounterRef.current += 1; const generatedIndex = layerCounterRef.current; - const originalWidth = generated.width || 1024; - const originalHeight = generated.height || 1024; - const { width, height } = resolveLayerResolutionSize( - originalWidth, - originalHeight, - { width: 1024, height: 1024 }, - ); - const worldCenterX = (canvasSize.width / 2 - viewport.x) / viewport.scale; - const worldCenterY = (canvasSize.height / 2 - viewport.y) / viewport.scale; - const frameX = - options.frame && options.frame.width > 0 - ? options.frame.x + options.frame.width / 2 - width / 2 - : undefined; - const frameY = - options.frame && options.frame.height > 0 - ? options.frame.y + options.frame.height / 2 - height / 2 - : undefined; - const nextLayer: CanvasLayer = { - id: options.sourceLayer - ? `layer-edit-${generatedIndex}` - : `layer-generated-${generatedIndex}`, - resourceId: options.sourceLayer - ? `local-resource-edit-${generatedIndex}` - : `local-resource-generated-${generatedIndex}`, - title: options.sourceLayer - ? `${options.sourceLayer.title} 修改结果` - : (options.title ?? `生成图片 ${generatedIndex}`), - src: generated.imageSrc, - x: options.sourceLayer - ? options.sourceLayer.x + options.sourceLayer.width + 32 - : (frameX ?? worldCenterX - width / 2), - y: options.sourceLayer - ? options.sourceLayer.y - : (frameY ?? worldCenterY - height / 2), - width, - height, - originalWidth, - originalHeight, - zIndex: generatedIndex + 10, - sourceType: generated.sourceType, + const nextLayer = createGeneratedResultLayer({ + generated, + generatedIndex, + canvasSize, + viewport, + sourceLayer: options.sourceLayer, + frame: options.frame, assetKind: options.assetKind, - prompt: generated.prompt, - actualPrompt: generated.actualPrompt ?? generated.prompt, - model: generated.model, - provider: generated.provider, - taskId: generated.taskId, - objectKey: generated.objectKey, - assetObjectId: generated.assetObjectId, - sourceResourceId: options.sourceLayer?.resourceId, + title: options.title, generationInputs: options.generationInputs, - }; + }); appendCanvasLayersWithResources([nextLayer]); selectSingleLayer(nextLayer.id); @@ -1809,42 +1773,12 @@ export function ImageCanvasEditorView() { ) => { layerCounterRef.current += 1; const generatedIndex = layerCounterRef.current; - const originalWidth = generated.width || sourceLayer.originalWidth || 1024; - const originalHeight = - generated.height || sourceLayer.originalHeight || 1024; - const { width, height } = resolveLayerResolutionSize( - originalWidth, - originalHeight, - { - width: sourceLayer.width, - height: sourceLayer.height, - }, - ); - const nextLayer: CanvasLayer = { - id: `layer-quick-edit-${generatedIndex}`, - resourceId: `local-resource-quick-edit-${generatedIndex}`, - title: `${sourceLayer.title} 快速编辑`, - src: generated.imageSrc, - x: sourceLayer.x + sourceLayer.width + 32, - y: sourceLayer.y, - width, - height, - originalWidth, - originalHeight, - zIndex: generatedIndex + 10, - sourceType: generated.sourceType, - prompt: generated.prompt, - actualPrompt: generated.actualPrompt ?? generated.prompt, - model: generated.model, - provider: generated.provider, - taskId: generated.taskId, - objectKey: generated.objectKey, - assetObjectId: generated.assetObjectId, - sourceResourceId: sourceLayer.resourceId, - groupId: sourceLayer.groupId, - assetKind: sourceLayer.assetKind, + const nextLayer = createQuickEditResultLayer({ + generated, + generatedIndex, + sourceLayer, generationInputs, - }; + }); appendCanvasLayersWithResources([nextLayer]); selectSingleLayer(nextLayer.id); @@ -1861,66 +1795,21 @@ export function ImageCanvasEditorView() { frame?: GenerateDialogState['placeholder'], dialogId?: string, ) => { - const startX = - frame?.x ?? - (canvasSize.width / 2 - viewport.x) / viewport.scale - - ICON_FRAME_DISPLAY_SIZE.width / 2; - const startY = - frame?.y ?? - (canvasSize.height / 2 - viewport.y) / viewport.scale - - ICON_FRAME_DISPLAY_SIZE.height / 2; - const spacing = 24; - const maxRowWidth = 560; - let cursorX = startX; - let cursorY = startY; - let rowHeight = 0; - const nextLayers: CanvasLayer[] = []; - - iconResults.forEach((icon) => { - const originalWidth = icon.width || 128; - const originalHeight = icon.height || 128; - const { width, height } = resolveLayerResolutionSize( - originalWidth, - originalHeight, - { width: 128, height: 128 }, - ); - if (cursorX > startX && cursorX + width - startX > maxRowWidth) { - cursorX = startX; - cursorY += rowHeight + spacing; - rowHeight = 0; - } - - layerCounterRef.current += 1; - const generatedIndex = layerCounterRef.current; - nextLayers.push({ - id: `layer-icon-${generatedIndex}`, - resourceId: `local-resource-icon-${generatedIndex}`, - title: icon.name, - src: icon.imageSrc, - x: cursorX, - y: cursorY, - width, - height, - originalWidth, - originalHeight, - zIndex: generatedIndex + 10, - sourceType: 'generated', - prompt: generated.prompt, - actualPrompt: generated.actualPrompt ?? generated.prompt, - model: generated.model, - provider: generated.provider, - taskId: generated.taskId, - assetKind: 'icon', - generationInputs, - }); - - cursorX += width + spacing; - rowHeight = Math.max(rowHeight, height); + const startIndex = layerCounterRef.current + 1; + const nextLayers = createIconSpritesheetResultLayers({ + generated, + iconResults, + startIndex, + canvasSize, + viewport, + generationInputs, + frame, }); if (!nextLayers.length) { return; } + layerCounterRef.current += nextLayers.length; appendCanvasLayersWithResources(nextLayers); selectSingleLayer(nextLayers[0]?.id ?? null); setActiveSidebarPanel('layers'); diff --git a/src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts b/src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts new file mode 100644 index 00000000..88351f67 --- /dev/null +++ b/src/components/image-editor/ImageCanvasGenerationLayerModel.test.ts @@ -0,0 +1,225 @@ +import { describe, expect, it } from 'vitest'; + +import type { + CanvasGenerationInputs, + CanvasLayer, +} from './ImageCanvasEditorTypes'; +import { + createGeneratedResultLayer, + createIconSpritesheetResultLayers, + createQuickEditResultLayer, +} from './ImageCanvasGenerationLayerModel'; + +function createGenerated(overrides = {}) { + return { + imageSrc: 'data:image/png;base64,generated', + width: 1024, + height: 1024, + sourceType: 'generated' as const, + prompt: '生成提示词', + actualPrompt: '实际提示词', + model: 'gpt-image-2', + provider: 'VectorEngine', + taskId: 'task-generated', + objectKey: 'generated/object.png', + assetObjectId: 'asset-object-generated', + ...overrides, + }; +} + +function createSourceLayer(overrides: Partial = {}): CanvasLayer { + return { + id: 'layer-source', + resourceId: 'resource-source', + title: '源图', + src: 'data:image/png;base64,source', + x: 120, + y: 140, + width: 320, + height: 240, + originalWidth: 1536, + originalHeight: 1024, + zIndex: 2, + sourceType: 'generated', + groupId: 'group-a', + assetKind: 'spec', + ...overrides, + }; +} + +function createGenerationInputs(): CanvasGenerationInputs { + return { + fields: [{ title: '生成提示词', value: '生成提示词' }], + references: [], + }; +} + +describe('ImageCanvasGenerationLayerModel', () => { + it('creates generated result layers centered on the active placeholder', () => { + const layer = createGeneratedResultLayer({ + generated: createGenerated({ width: 2048, height: 1152 }), + generatedIndex: 7, + canvasSize: { width: 900, height: 640 }, + viewport: { x: 10, y: 20, scale: 2 }, + frame: { + x: 80, + y: 60, + width: 560, + height: 315, + originalWidth: 2048, + originalHeight: 1152, + }, + assetKind: 'spec', + title: 'UI素材规范 7', + generationInputs: createGenerationInputs(), + }); + + expect(layer).toMatchObject({ + id: 'layer-generated-7', + resourceId: 'local-resource-generated-7', + title: 'UI素材规范 7', + x: -664, + y: -358.5, + width: 2048, + height: 1152, + originalWidth: 2048, + originalHeight: 1152, + zIndex: 17, + sourceType: 'generated', + assetKind: 'spec', + prompt: '生成提示词', + actualPrompt: '实际提示词', + objectKey: 'generated/object.png', + assetObjectId: 'asset-object-generated', + }); + expect(layer.generationInputs?.fields[0]?.value).toBe('生成提示词'); + }); + + it('creates edit result layers beside the source image', () => { + const sourceLayer = createSourceLayer(); + const layer = createGeneratedResultLayer({ + generated: createGenerated({ prompt: '修改要求' }), + generatedIndex: 8, + canvasSize: { width: 900, height: 640 }, + viewport: { x: 0, y: 0, scale: 1 }, + sourceLayer, + generationInputs: createGenerationInputs(), + }); + + expect(layer).toMatchObject({ + id: 'layer-edit-8', + resourceId: 'local-resource-edit-8', + title: '源图 修改结果', + x: 472, + y: 140, + width: 1024, + height: 1024, + sourceResourceId: 'resource-source', + prompt: '修改要求', + }); + }); + + it('creates quick edit layers with source group and asset kind preserved', () => { + const sourceLayer = createSourceLayer(); + const layer = createQuickEditResultLayer({ + generated: createGenerated({ width: 1536, height: 1024 }), + generatedIndex: 9, + sourceLayer, + generationInputs: createGenerationInputs(), + }); + + expect(layer).toMatchObject({ + id: 'layer-quick-edit-9', + resourceId: 'local-resource-quick-edit-9', + title: '源图 快速编辑', + x: 472, + y: 140, + width: 1536, + height: 1024, + originalWidth: 1536, + originalHeight: 1024, + sourceResourceId: 'resource-source', + groupId: 'group-a', + assetKind: 'spec', + }); + }); + + it('creates wrapped icon layers with icon metadata', () => { + const layers = createIconSpritesheetResultLayers({ + generated: { + spritesheetImageSrc: 'data:image/png;base64,sheet', + spritesheetWidth: 512, + spritesheetHeight: 512, + iconImageSrcs: [], + prompt: '图标 prompt', + actualPrompt: '图标 actual prompt', + model: 'gpt-image-2', + provider: 'VectorEngine', + taskId: 'task-icons', + }, + iconResults: [ + { + name: '返回按钮', + imageSrc: 'data:image/png;base64,back', + width: 256, + height: 256, + }, + { + name: '设置按钮', + imageSrc: 'data:image/png;base64,settings', + width: 512, + height: 256, + }, + { + name: '提示按钮', + imageSrc: 'data:image/png;base64,hint', + width: 128, + height: 128, + }, + ], + startIndex: 11, + canvasSize: { width: 900, height: 640 }, + viewport: { x: 0, y: 0, scale: 1 }, + frame: { + x: 200, + y: 100, + width: 360, + height: 360, + originalWidth: 512, + originalHeight: 512, + }, + generationInputs: createGenerationInputs(), + }); + + expect(layers).toHaveLength(3); + expect(layers[0]).toMatchObject({ + id: 'layer-icon-11', + title: '返回按钮', + x: 200, + y: 100, + width: 256, + height: 256, + assetKind: 'icon', + prompt: '图标 prompt', + actualPrompt: '图标 actual prompt', + }); + expect(layers[1]).toMatchObject({ + id: 'layer-icon-12', + title: '设置按钮', + x: 200, + y: 380, + width: 512, + height: 256, + assetKind: 'icon', + }); + expect(layers[2]).toMatchObject({ + id: 'layer-icon-13', + title: '提示按钮', + x: 200, + y: 660, + width: 128, + height: 128, + assetKind: 'icon', + }); + }); +}); diff --git a/src/components/image-editor/ImageCanvasGenerationLayerModel.ts b/src/components/image-editor/ImageCanvasGenerationLayerModel.ts new file mode 100644 index 00000000..6ad7874a --- /dev/null +++ b/src/components/image-editor/ImageCanvasGenerationLayerModel.ts @@ -0,0 +1,237 @@ +import type { + EditorIconSpritesheetGenerationResult, + EditorIconSpritesheetIconResult, + EditorImageGenerationResult, +} from '../../services/image-editor/editorProjectClient'; +import { resolveLayerResolutionSize } from './ImageCanvasEditorModel'; +import { ICON_FRAME_DISPLAY_SIZE } from './ImageCanvasGenerationModel'; +import type { + CanvasGenerationInputs, + CanvasLayer, + CanvasViewport, + GenerateDialogState, +} from './ImageCanvasEditorTypes'; + +type CanvasSize = { width: number; height: number }; + +type GeneratedResultLayerOptions = { + generated: EditorImageGenerationResult; + generatedIndex: number; + canvasSize: CanvasSize; + viewport: CanvasViewport; + sourceLayer?: CanvasLayer; + frame?: GenerateDialogState['placeholder']; + assetKind?: CanvasLayer['assetKind']; + title?: string; + generationInputs?: CanvasGenerationInputs; +}; + +type QuickEditResultLayerOptions = { + generated: EditorImageGenerationResult; + generatedIndex: number; + sourceLayer: CanvasLayer; + generationInputs: CanvasGenerationInputs; +}; + +type IconSpritesheetResultLayerOptions = { + generated: EditorIconSpritesheetGenerationResult; + iconResults: EditorIconSpritesheetIconResult[]; + startIndex: number; + canvasSize: CanvasSize; + viewport: CanvasViewport; + generationInputs: CanvasGenerationInputs; + frame?: GenerateDialogState['placeholder']; +}; + +function getViewportWorldCenter({ + canvasSize, + viewport, +}: { + canvasSize: CanvasSize; + viewport: CanvasViewport; +}) { + const safeScale = viewport.scale > 0 ? viewport.scale : 1; + return { + x: (canvasSize.width / 2 - viewport.x) / safeScale, + y: (canvasSize.height / 2 - viewport.y) / safeScale, + }; +} + +function applyGeneratedMetadata( + layer: CanvasLayer, + generated: EditorImageGenerationResult, + generationInputs: CanvasGenerationInputs | undefined, +): CanvasLayer { + return { + ...layer, + prompt: generated.prompt, + actualPrompt: generated.actualPrompt ?? generated.prompt, + model: generated.model, + provider: generated.provider, + taskId: generated.taskId, + objectKey: generated.objectKey, + assetObjectId: generated.assetObjectId, + generationInputs, + }; +} + +export function createGeneratedResultLayer({ + generated, + generatedIndex, + canvasSize, + viewport, + sourceLayer, + frame, + assetKind, + title, + generationInputs, +}: GeneratedResultLayerOptions): CanvasLayer { + const originalWidth = generated.width || 1024; + const originalHeight = generated.height || 1024; + const { width, height } = resolveLayerResolutionSize( + originalWidth, + originalHeight, + { width: 1024, height: 1024 }, + ); + const worldCenter = getViewportWorldCenter({ canvasSize, viewport }); + const frameX = + frame && frame.width > 0 + ? frame.x + frame.width / 2 - width / 2 + : undefined; + const frameY = + frame && frame.height > 0 + ? frame.y + frame.height / 2 - height / 2 + : undefined; + + return applyGeneratedMetadata( + { + id: sourceLayer + ? `layer-edit-${generatedIndex}` + : `layer-generated-${generatedIndex}`, + resourceId: sourceLayer + ? `local-resource-edit-${generatedIndex}` + : `local-resource-generated-${generatedIndex}`, + title: sourceLayer + ? `${sourceLayer.title} 修改结果` + : (title ?? `生成图片 ${generatedIndex}`), + src: generated.imageSrc, + x: sourceLayer + ? sourceLayer.x + sourceLayer.width + 32 + : (frameX ?? worldCenter.x - width / 2), + y: sourceLayer ? sourceLayer.y : (frameY ?? worldCenter.y - height / 2), + width, + height, + originalWidth, + originalHeight, + zIndex: generatedIndex + 10, + sourceType: generated.sourceType, + assetKind, + sourceResourceId: sourceLayer?.resourceId, + }, + generated, + generationInputs, + ); +} + +export function createQuickEditResultLayer({ + generated, + generatedIndex, + sourceLayer, + generationInputs, +}: QuickEditResultLayerOptions): CanvasLayer { + const originalWidth = generated.width || sourceLayer.originalWidth || 1024; + const originalHeight = generated.height || sourceLayer.originalHeight || 1024; + const { width, height } = resolveLayerResolutionSize( + originalWidth, + originalHeight, + { + width: sourceLayer.width, + height: sourceLayer.height, + }, + ); + + return applyGeneratedMetadata( + { + id: `layer-quick-edit-${generatedIndex}`, + resourceId: `local-resource-quick-edit-${generatedIndex}`, + title: `${sourceLayer.title} 快速编辑`, + src: generated.imageSrc, + x: sourceLayer.x + sourceLayer.width + 32, + y: sourceLayer.y, + width, + height, + originalWidth, + originalHeight, + zIndex: generatedIndex + 10, + sourceType: generated.sourceType, + sourceResourceId: sourceLayer.resourceId, + groupId: sourceLayer.groupId, + assetKind: sourceLayer.assetKind, + }, + generated, + generationInputs, + ); +} + +export function createIconSpritesheetResultLayers({ + generated, + iconResults, + startIndex, + canvasSize, + viewport, + generationInputs, + frame, +}: IconSpritesheetResultLayerOptions): CanvasLayer[] { + const worldCenter = getViewportWorldCenter({ canvasSize, viewport }); + const startX = + frame?.x ?? worldCenter.x - ICON_FRAME_DISPLAY_SIZE.width / 2; + const startY = + frame?.y ?? worldCenter.y - ICON_FRAME_DISPLAY_SIZE.height / 2; + const spacing = 24; + const maxRowWidth = 560; + let cursorX = startX; + let cursorY = startY; + let rowHeight = 0; + + return iconResults.map((icon, index) => { + const originalWidth = icon.width || 128; + const originalHeight = icon.height || 128; + const { width, height } = resolveLayerResolutionSize( + originalWidth, + originalHeight, + { width: 128, height: 128 }, + ); + if (cursorX > startX && cursorX + width - startX > maxRowWidth) { + cursorX = startX; + cursorY += rowHeight + spacing; + rowHeight = 0; + } + + const generatedIndex = startIndex + index; + const layer: CanvasLayer = { + id: `layer-icon-${generatedIndex}`, + resourceId: `local-resource-icon-${generatedIndex}`, + title: icon.name, + src: icon.imageSrc, + x: cursorX, + y: cursorY, + width, + height, + originalWidth, + originalHeight, + zIndex: generatedIndex + 10, + sourceType: 'generated', + prompt: generated.prompt, + actualPrompt: generated.actualPrompt ?? generated.prompt, + model: generated.model, + provider: generated.provider, + taskId: generated.taskId, + assetKind: 'icon', + generationInputs, + }; + + cursorX += width + spacing; + rowHeight = Math.max(rowHeight, height); + return layer; + }); +}