拆分图片画布生成图层模型

新增生成结果图层模型和单测

主视图改为复用生成图层模型创建普通生图、快速编辑和图标图层

更新图片画布前端拆分文档和 TRACKING 回归记录
This commit is contained in:
2026-06-17 07:04:20 +08:00
parent b1421159e6
commit 3c37108ef6
5 changed files with 502 additions and 143 deletions

View File

@@ -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 前端拆分第九阶段:新增 `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 前端拆分第十阶段:新增 `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 前端拆分第十一阶段:新增 `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。

View File

@@ -110,14 +110,21 @@
- 主视图继续负责画布 drop 外层事件判断、素材库已有素材加入画布、项目资源持久化 hook 注入和画布历史捕获,避免上传 hook 反向成为画布全局状态真相。 - 主视图继续负责画布 drop 外层事件判断、素材库已有素材加入画布、项目资源持久化 hook 注入和画布历史捕获,避免上传 hook 反向成为画布全局状态真相。
- 该 hook 用独立单测覆盖登录续传、上传占位 / 成功回写、上传到画布建层、鉴权失败和生成参考图分发,主视图保留 DOM 级 smoke 覆盖侧栏上传、画布 drop 上传和文件夹定向上传。 - 该 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 typecheck`
- `npm run check:encoding` - `npm run check:encoding`
- `git diff --check` - `git diff --check`

View File

@@ -64,6 +64,11 @@ import {
} from './ImageCanvasLayerCommandModel'; } from './ImageCanvasLayerCommandModel';
import { ImageCanvasSidebarView } from './ImageCanvasSidebarView'; import { ImageCanvasSidebarView } from './ImageCanvasSidebarView';
import { ImageCanvasStageView } from './ImageCanvasStageView'; import { ImageCanvasStageView } from './ImageCanvasStageView';
import {
createGeneratedResultLayer,
createIconSpritesheetResultLayers,
createQuickEditResultLayer,
} from './ImageCanvasGenerationLayerModel';
import { import {
ASSET_DRAG_MIME_TYPE, ASSET_DRAG_MIME_TYPE,
DEFAULT_CANVAS_BACKGROUND_COLOR, DEFAULT_CANVAS_BACKGROUND_COLOR,
@@ -78,7 +83,6 @@ import {
isLayerLinkedToAsset, isLayerLinkedToAsset,
normalizeCanvasBackgroundHex, normalizeCanvasBackgroundHex,
resolveContextMenuPosition, resolveContextMenuPosition,
resolveLayerResolutionSize,
serializeLayer, serializeLayer,
} from './ImageCanvasEditorModel'; } from './ImageCanvasEditorModel';
import { import {
@@ -1725,57 +1729,17 @@ export function ImageCanvasEditorView() {
) => { ) => {
layerCounterRef.current += 1; layerCounterRef.current += 1;
const generatedIndex = layerCounterRef.current; const generatedIndex = layerCounterRef.current;
const originalWidth = generated.width || 1024; const nextLayer = createGeneratedResultLayer({
const originalHeight = generated.height || 1024; generated,
const { width, height } = resolveLayerResolutionSize( generatedIndex,
originalWidth, canvasSize,
originalHeight, viewport,
{ width: 1024, height: 1024 }, sourceLayer: options.sourceLayer,
); frame: options.frame,
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,
assetKind: options.assetKind, assetKind: options.assetKind,
prompt: generated.prompt, title: options.title,
actualPrompt: generated.actualPrompt ?? generated.prompt,
model: generated.model,
provider: generated.provider,
taskId: generated.taskId,
objectKey: generated.objectKey,
assetObjectId: generated.assetObjectId,
sourceResourceId: options.sourceLayer?.resourceId,
generationInputs: options.generationInputs, generationInputs: options.generationInputs,
}; });
appendCanvasLayersWithResources([nextLayer]); appendCanvasLayersWithResources([nextLayer]);
selectSingleLayer(nextLayer.id); selectSingleLayer(nextLayer.id);
@@ -1809,42 +1773,12 @@ export function ImageCanvasEditorView() {
) => { ) => {
layerCounterRef.current += 1; layerCounterRef.current += 1;
const generatedIndex = layerCounterRef.current; const generatedIndex = layerCounterRef.current;
const originalWidth = generated.width || sourceLayer.originalWidth || 1024; const nextLayer = createQuickEditResultLayer({
const originalHeight = generated,
generated.height || sourceLayer.originalHeight || 1024; generatedIndex,
const { width, height } = resolveLayerResolutionSize( sourceLayer,
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,
generationInputs, generationInputs,
}; });
appendCanvasLayersWithResources([nextLayer]); appendCanvasLayersWithResources([nextLayer]);
selectSingleLayer(nextLayer.id); selectSingleLayer(nextLayer.id);
@@ -1861,66 +1795,21 @@ export function ImageCanvasEditorView() {
frame?: GenerateDialogState['placeholder'], frame?: GenerateDialogState['placeholder'],
dialogId?: string, dialogId?: string,
) => { ) => {
const startX = const startIndex = layerCounterRef.current + 1;
frame?.x ?? const nextLayers = createIconSpritesheetResultLayers({
(canvasSize.width / 2 - viewport.x) / viewport.scale - generated,
ICON_FRAME_DISPLAY_SIZE.width / 2; iconResults,
const startY = startIndex,
frame?.y ?? canvasSize,
(canvasSize.height / 2 - viewport.y) / viewport.scale - viewport,
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, generationInputs,
}); frame,
cursorX += width + spacing;
rowHeight = Math.max(rowHeight, height);
}); });
if (!nextLayers.length) { if (!nextLayers.length) {
return; return;
} }
layerCounterRef.current += nextLayers.length;
appendCanvasLayersWithResources(nextLayers); appendCanvasLayersWithResources(nextLayers);
selectSingleLayer(nextLayers[0]?.id ?? null); selectSingleLayer(nextLayers[0]?.id ?? null);
setActiveSidebarPanel('layers'); setActiveSidebarPanel('layers');

View File

@@ -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> = {}): 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',
});
});
});

View File

@@ -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;
});
}