拆分图片画布生成图层模型
新增生成结果图层模型和单测 主视图改为复用生成图层模型创建普通生图、快速编辑和图标图层 更新图片画布前端拆分文档和 TRACKING 回归记录
This commit is contained in:
@@ -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。
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
237
src/components/image-editor/ImageCanvasGenerationLayerModel.ts
Normal file
237
src/components/image-editor/ImageCanvasGenerationLayerModel.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user