From 4e4edc285b4a4b29b0ae4e52726164083f630a78 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 17 Jun 2026 19:12:11 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=BD=E5=87=BA=E7=BC=96=E8=BE=91=E5=99=A8?= =?UTF-8?q?=E7=94=9F=E6=88=90=E5=AF=B9=E8=AF=9D=E7=8A=B6=E6=80=81=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 ImageCanvasGenerationDialogModel 承载生成面板草稿和引用选择规则 补充生成对话状态模型单测 精简 useImageCanvasGenerationWorkflow 中的面板状态构造 更新 TRACKING.md 记录第四十一阶段验证 --- TRACKING.md | 1 + .../ImageCanvasGenerationDialogModel.test.ts | 317 ++++++++++++++ .../ImageCanvasGenerationDialogModel.ts | 369 +++++++++++++++++ .../useImageCanvasGenerationWorkflow.ts | 391 +++++------------- 4 files changed, 780 insertions(+), 298 deletions(-) create mode 100644 src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts create mode 100644 src/components/image-editor/ImageCanvasGenerationDialogModel.ts diff --git a/TRACKING.md b/TRACKING.md index 74c5a0b0..194fa540 100644 --- a/TRACKING.md +++ b/TRACKING.md @@ -157,3 +157,4 @@ - 2026-06-17 前端拆分第三十八阶段:继续收口 `useImageCanvasGenerationWorkflow`,扩展 `ImageCanvasGenerationSubmissionModel`,把图标素材批量生成的规范校验 / 描述清洗 / 请求 payload / generationInputs,以及角色动画生成的 prompt 清洗 / objectKey 优先源图 / 尺寸 / 价格 / 模型参数从 workflow hook 中抽成纯模型;workflow hook 继续保留对话状态、真实 API 调用、生成结果落图、失败恢复和角色动画面板生命周期。新增模型单测覆盖图标缺少规范、图标空描述、图标描述 trim / 参考快照,以及角色动画 trim、objectKey 源图和价格计算;`useImageCanvasGenerationWorkflow` 从 1104 行降至 1075 行。统一验证命令:`npm run test -- src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx`、`npm run test -- src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts src/components/image-editor/ImageCanvasUploadModel.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/image-editor/ImageCanvasSidebarView.test.tsx src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx src/components/image-editor/ImageCanvasEditorShellView.test.tsx src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx src/components/image-editor/ImageCanvasPanelDockView.test.tsx src/components/image-editor/ImageCanvasContextMenusView.test.tsx src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 临时 `XDG_CONFIG_HOME` 下 Chrome headless 打开成功,未登录显示 `账号入口`;关闭后 `画布背景设置` 可打开,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;`打开图层` 切换后侧栏显示 `图层`,点击 `生成工具` 后 `Image Generator`、`生成图片` 对话框和 `AI画布工具栏` 均可见;点击 `生成图标素材` 后 `Icon Generator` 占位和 `生成图标素材` 面板可见,控制台仅有预期的未登录 `/api/auth/refresh` 401。 - 2026-06-17 前端拆分第三十九阶段:开始拆分 `ImageCanvasEditorView.test.tsx` 巨型集成测试,新增 `ImageCanvasEditorView.test-utils` 共享默认工程 / 素材库 mock、认证上下文、pointer / dataTransfer / deferred helper 和生命周期 setup;新增 `ImageCanvasEditorAssetsIntegration.test.tsx`,把素材库默认文件夹去重、文件夹折叠 / 新建 / 重命名 / 删除、素材重命名 / 拖拽移动、多文件上传、未登录续传、上传占位、401 登录、素材选择模式、删除素材同步清理画布图层、素材框选、画布 drop 上传和素材库拖入画布等集成用例从主测试文件迁出;`ImageCanvasEditorView.test.tsx` 从 4817 行降至 3741 行。统一验证命令:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx`、`npm run test -- src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts src/components/image-editor/ImageCanvasUploadModel.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/image-editor/ImageCanvasSidebarView.test.tsx src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx src/components/image-editor/ImageCanvasEditorShellView.test.tsx src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx src/components/image-editor/ImageCanvasPanelDockView.test.tsx src/components/image-editor/ImageCanvasContextMenusView.test.tsx src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 临时 `XDG_CONFIG_HOME` 下 Chrome headless 打开成功,未登录显示 `账号入口`;关闭后 `画布背景设置` 打开,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;`打开图层` 切换后侧栏显示 `图层`,点击 `生成工具` 后 `Image Generator`、`生成图片` 对话框和 `AI画布工具栏` 均可见,控制台仅有预期的未登录 `/api/auth/refresh` 401。 - 2026-06-17 前端拆分第四十阶段:继续拆分 `ImageCanvasEditorView.test.tsx` 巨型集成测试,新增 `ImageCanvasEditorGenerationIntegration.test.tsx`,把画布生图占位 / 生成提交、生成错误与未登录、规范生成、角色形象生成、图标素材生成、角色动画、快速编辑、生成图元数据和修改结果等生成相关集成用例从主测试文件迁出;`ImageCanvasEditorView.test.tsx` 从 3741 行降至 1419 行,主测试保留工程加载、导出、画布基础交互、右键菜单、侧栏、缩放、小地图、工具切换、吸附和历史。统一验证命令:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx`、`npm run test -- src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts src/components/image-editor/ImageCanvasUploadModel.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/image-editor/ImageCanvasSidebarView.test.tsx src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx src/components/image-editor/ImageCanvasEditorShellView.test.tsx src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx src/components/image-editor/ImageCanvasPanelDockView.test.tsx src/components/image-editor/ImageCanvasContextMenusView.test.tsx src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 临时 `XDG_CONFIG_HOME` 下 Chrome headless 打开成功,未登录显示 `账号入口`;关闭后 `画布背景设置` 可打开,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;`打开图层` 切换后侧栏标题为 `图层`;点击 `生成工具` 后 `Image Generator`、`生成图片` 对话框和 `AI画布工具栏` 均可见,控制台仅有预期的未登录 `/api/auth/refresh` 401。 +- 2026-06-17 前端拆分第四十一阶段:继续收口 `useImageCanvasGenerationWorkflow`,新增 `ImageCanvasGenerationDialogModel`,把普通生图 / 规范 / 角色形象 / 图标素材生成对话框草稿、修改图片 / 快速编辑 / 角色动画面板草稿、角色 / 图标参考选择、规范表单更新、图标描述更新、角色动画时长更新以及生成器失焦 / 关闭规则从 hook 中抽成纯模型;workflow hook 保留真实 API 调用、生成结果落画布、侧栏 / 工具 / 选中态副作用和错误回写。新增模型单测覆盖各类草稿、失败态清理、引用选择、描述限制、动画时长和 composer 可见性;`useImageCanvasGenerationWorkflow.ts` 从 1075 行降至 870 行。统一验证命令:`npm run test -- src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts src/components/image-editor/ImageCanvasUploadModel.test.ts src/components/image-editor/ImageCanvasGenerationSubmissionModel.test.ts src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/image-editor/ImageCanvasSidebarView.test.tsx src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasCharacterGenerationComposerView.test.tsx src/components/image-editor/ImageCanvasEditGenerationModalView.test.tsx src/components/image-editor/ImageCanvasEditorShellView.test.tsx src/components/image-editor/ImageCanvasBottomToolbarView.test.tsx src/components/image-editor/ImageCanvasPanelDockView.test.tsx src/components/image-editor/ImageCanvasContextMenusView.test.tsx src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx src/components/image-editor/ImageCanvasIconSpritesheetComposerView.test.tsx src/components/image-editor/ImageCanvasSpecGenerationPanelView.test.tsx src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/ImageCanvasCharacterAnimationPanelView.test.tsx src/components/image-editor/ImageCanvasWorldView.test.tsx src/components/image-editor/useImageCanvasAssetLibrary.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.test.tsx src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasUploadWorkflow.test.tsx src/components/image-editor/ImageCanvasEditorAssetsIntegration.test.tsx src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx src/components/image-editor/ImageCanvasEditorView.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`;浏览器回归:`http://127.0.0.1:10007/editor/canvas` 临时 `XDG_CONFIG_HOME` 下 Chrome headless 打开成功,未登录显示 `账号入口`;关闭后 `画布背景设置` 可打开,点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`;`打开图层` 切换后侧栏标题为 `图层`;点击 `生成工具` 后 `Image Generator`、`生成图片` 对话框和 `AI画布工具栏` 均可见,控制台仅有预期的未登录 `/api/auth/refresh` 401。 diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts new file mode 100644 index 00000000..218048ee --- /dev/null +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts @@ -0,0 +1,317 @@ +import { describe, expect, it } from 'vitest'; + +import type { + CanvasLayer, + CharacterAnimationPanelState, + GenerateDialogState, +} from './ImageCanvasEditorTypes'; +import { + appendCharacterReference, + appendIconDescriptionToDialog, + assignCharacterSpecReference, + assignIconSpecReference, + closeGenerateComposerDialog, + createCharacterAnimationPanelDraft, + createCharacterGenerationDialogDraft, + createEditDialogDraft, + createGenerateDialogDraft, + createIconGenerationDialogDraft, + createQuickEditPanelDraft, + createSpecDialogDraft, + hideGeneratedLayerComposerAfterBlur, + updateCharacterAnimationDurationPanel, + updateIconDescriptionInDialog, + updateSpecFormDialogValue, +} from './ImageCanvasGenerationDialogModel'; +import { ICON_DESCRIPTION_LIMIT } from './ImageCanvasGenerationModel'; + +function createLayer(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: 1024, + originalHeight: 768, + zIndex: 2, + sourceType: 'uploaded', + ...overrides, + }; +} + +describe('ImageCanvasGenerationDialogModel', () => { + it('creates centered drafts for image and spec generation dialogs', () => { + const canvasSize = { width: 1000, height: 800 }; + const viewport = { x: 100, y: 40, scale: 2 }; + + expect(createGenerateDialogDraft({ canvasSize, viewport })).toMatchObject({ + mode: 'generate', + status: 'idle', + composerOpen: true, + placeholder: { + x: -10, + y: -30, + width: 420, + height: 420, + originalWidth: 2048, + originalHeight: 2048, + }, + }); + expect( + createSpecDialogDraft({ canvasSize, viewport, specType: 'icon' }), + ).toMatchObject({ + mode: 'spec', + specType: 'icon', + specValues: { + playSetting: '休闲小游戏', + artStyle: '清爽卡通', + }, + placeholder: { + x: -80, + y: 22.5, + width: 560, + height: 315, + }, + }); + }); + + it('creates character and icon generation drafts with model dimensions', () => { + const canvasSize = { width: 960, height: 720 }; + const viewport = { x: 0, y: 0, scale: 1 }; + + expect( + createCharacterGenerationDialogDraft({ + canvasSize, + viewport, + imageModel: 'gpt-image-2', + }), + ).toMatchObject({ + mode: 'character', + imageModel: 'gpt-image-2', + aspectRatio: '1:1', + imageSize: '1K', + characterSpecReference: null, + characterReferences: [], + placeholder: { + x: 270, + y: 150, + width: 420, + height: 420, + }, + }); + expect( + createIconGenerationDialogDraft({ + canvasSize, + viewport, + imageModel: 'unknown-model', + }), + ).toMatchObject({ + mode: 'icon', + imageModel: 'unknown-model', + aspectRatio: '1:1', + imageSize: '1K', + iconSpecReference: null, + iconDescriptions: [ + '返回按钮', + '设置按钮', + '下一关按钮', + '提示按钮', + '原图按钮', + '冻结按钮', + ], + placeholder: { + x: 300, + y: 180, + width: 360, + height: 360, + }, + }); + }); + + it('creates edit, quick-edit, and character animation panel drafts', () => { + const sourceLayer = createLayer({ + prompt: '原图提示', + model: 'gpt-image-2', + assetKind: 'character', + }); + + expect(createEditDialogDraft(sourceLayer)).toEqual({ + mode: 'edit', + prompt: '原图提示,在保持主体结构的基础上优化画面细节', + status: 'idle', + composerOpen: true, + sourceLayerId: 'layer-source', + }); + expect(createQuickEditPanelDraft(sourceLayer)).toEqual({ + sourceLayerId: 'layer-source', + prompt: '', + size: '1024x768', + model: 'gpt-image-2', + status: 'idle', + }); + expect(createCharacterAnimationPanelDraft(sourceLayer)).toEqual({ + sourceLayerId: 'layer-source', + promptText: '', + resolution: '480p', + ratio: 'same', + frameCount: 32, + durationSeconds: 4, + status: 'idle', + }); + expect(createCharacterAnimationPanelDraft(createLayer())).toBeNull(); + }); + + it('resets failed character and icon dialogs when picking references', () => { + const characterDialog: GenerateDialogState = { + mode: 'character', + prompt: '', + status: 'failed', + errorMessage: '请选择角色规范', + characterReferences: [], + }; + const sourceLayer = createLayer({ title: '参考图' }); + + expect(assignCharacterSpecReference(characterDialog, sourceLayer)).toEqual( + expect.objectContaining({ + status: 'idle', + errorMessage: undefined, + composerOpen: true, + characterSpecReference: { + id: 'canvas-layer-source', + label: '参考图', + src: 'data:image/png;base64,source', + }, + }), + ); + expect( + appendCharacterReference(characterDialog, sourceLayer), + ).toMatchObject({ + status: 'idle', + errorMessage: undefined, + characterReferences: [{ label: '参考图' }], + }); + + const iconDialog: GenerateDialogState = { + mode: 'icon', + prompt: '', + status: 'failed', + errorMessage: '请选择图标素材规范', + }; + expect( + assignIconSpecReference( + iconDialog, + createLayer({ assetKind: 'icon-spec' }), + ), + ).toMatchObject({ + status: 'idle', + errorMessage: undefined, + iconSpecReference: { id: 'canvas-layer-source' }, + }); + expect(assignIconSpecReference(iconDialog, sourceLayer)).toBe(iconDialog); + }); + + it('updates failed spec and icon dialog fields back to idle state', () => { + const specDialog: GenerateDialogState = { + mode: 'spec', + prompt: '', + status: 'failed', + errorMessage: '生成失败', + specType: 'custom', + specValues: { + playSetting: '', + artStyle: '', + bodyRatio: '3', + characterView: '', + customPrompt: '', + }, + }; + + expect( + updateSpecFormDialogValue(specDialog, 'customPrompt', '新规范'), + ).toEqual( + expect.objectContaining({ + status: 'idle', + errorMessage: undefined, + specValues: expect.objectContaining({ + customPrompt: '新规范', + bodyRatio: '3', + }), + }), + ); + + const iconDialog: GenerateDialogState = { + mode: 'icon', + prompt: '', + status: 'failed', + errorMessage: '描述错误', + iconDescriptions: ['旧描述'], + }; + expect(updateIconDescriptionInDialog(iconDialog, 0, '新描述')).toEqual( + expect.objectContaining({ + status: 'idle', + errorMessage: undefined, + iconDescriptions: ['新描述'], + }), + ); + expect(appendIconDescriptionToDialog(iconDialog)).toEqual( + expect.objectContaining({ + iconDescriptions: ['旧描述', ''], + }), + ); + expect( + appendIconDescriptionToDialog({ + ...iconDialog, + iconDescriptions: Array.from( + { length: ICON_DESCRIPTION_LIMIT }, + (_, index) => `图标${index}`, + ), + }), + ).toEqual( + expect.objectContaining({ + iconDescriptions: expect.arrayContaining(['图标0']), + }), + ); + }); + + it('updates character animation duration and composer visibility', () => { + const failedPanel: CharacterAnimationPanelState = { + sourceLayerId: 'layer-character', + promptText: '', + resolution: '480p', + ratio: 'same', + frameCount: 32, + durationSeconds: 4, + status: 'failed', + errorMessage: '失败', + }; + expect(updateCharacterAnimationDurationPanel(failedPanel, '48')).toEqual({ + ...failedPanel, + frameCount: 48, + durationSeconds: 6, + status: 'idle', + errorMessage: undefined, + }); + expect(updateCharacterAnimationDurationPanel(failedPanel, '999')).toBe( + failedPanel, + ); + + const generateDialog: GenerateDialogState = { + mode: 'generate', + prompt: '', + status: 'idle', + composerOpen: true, + }; + expect(hideGeneratedLayerComposerAfterBlur(generateDialog)).toEqual({ + ...generateDialog, + composerOpen: false, + }); + expect(closeGenerateComposerDialog(generateDialog)).toEqual({ + ...generateDialog, + composerOpen: false, + }); + }); +}); diff --git a/src/components/image-editor/ImageCanvasGenerationDialogModel.ts b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts new file mode 100644 index 00000000..c0342128 --- /dev/null +++ b/src/components/image-editor/ImageCanvasGenerationDialogModel.ts @@ -0,0 +1,369 @@ +import { formatImageSizeValue } from './ImageCanvasEditorModel'; +import type { + CanvasGenerationDialogState, + CanvasLayer, + CanvasViewport, + CharacterAnimationPanelState, + GenerateDialogState, + QuickEditPanelState, + SpecFormValues, + SpecGenerationType, +} from './ImageCanvasEditorTypes'; +import { + CHARACTER_ANIMATION_DURATION_OPTIONS, + CHARACTER_FRAME_DISPLAY_SIZE, + CHARACTER_FRAME_ORIGINAL_SIZE, + createCanvasLayerReference, + DEFAULT_ICON_DESCRIPTIONS, + DEFAULT_IMAGE_MODEL, + DEFAULT_SPEC_FORM_VALUES, + EDITOR_IMAGE_DIMENSION_OPTIONS, + ICON_DESCRIPTION_LIMIT, + ICON_FRAME_DISPLAY_SIZE, + ICON_FRAME_ORIGINAL_SIZE, + SPEC_FRAME_DISPLAY_SIZE, + SPEC_FRAME_ORIGINAL_SIZE, +} from './ImageCanvasGenerationModel'; + +type CanvasSize = { width: number; height: number }; + +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 resetFailedGenerationDialog(dialog: GenerateDialogState) { + return { + ...dialog, + status: dialog.status === 'failed' ? 'idle' : dialog.status, + errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage, + }; +} + +function resolveImageDimensionDefaults(imageModel: string) { + const dimensionOptions = + EDITOR_IMAGE_DIMENSION_OPTIONS[ + imageModel as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS + ] ?? EDITOR_IMAGE_DIMENSION_OPTIONS[DEFAULT_IMAGE_MODEL]; + return { + aspectRatio: dimensionOptions.aspectRatios[0], + imageSize: + dimensionOptions.imageSizes.find((size) => size === '1K') ?? + dimensionOptions.imageSizes[0], + }; +} + +export function createGenerateDialogDraft({ + canvasSize, + viewport, +}: { + canvasSize: CanvasSize; + viewport: CanvasViewport; +}): Omit { + const placeholderWidth = 420; + const placeholderHeight = 420; + const worldCenter = getViewportWorldCenter({ canvasSize, viewport }); + return { + mode: 'generate', + prompt: '', + status: 'idle', + composerOpen: true, + placeholder: { + x: worldCenter.x - placeholderWidth / 2, + y: worldCenter.y - placeholderHeight / 2, + width: placeholderWidth, + height: placeholderHeight, + originalWidth: 2048, + originalHeight: 2048, + }, + }; +} + +export function createSpecDialogDraft({ + canvasSize, + viewport, + specType, +}: { + canvasSize: CanvasSize; + viewport: CanvasViewport; + specType: SpecGenerationType; +}): Omit { + const worldCenter = getViewportWorldCenter({ canvasSize, viewport }); + return { + mode: 'spec', + prompt: '', + status: 'idle', + composerOpen: true, + specType, + specValues: { ...DEFAULT_SPEC_FORM_VALUES[specType] }, + placeholder: { + x: worldCenter.x - SPEC_FRAME_DISPLAY_SIZE.width / 2, + y: worldCenter.y - SPEC_FRAME_DISPLAY_SIZE.height / 2, + width: SPEC_FRAME_DISPLAY_SIZE.width, + height: SPEC_FRAME_DISPLAY_SIZE.height, + originalWidth: SPEC_FRAME_ORIGINAL_SIZE.width, + originalHeight: SPEC_FRAME_ORIGINAL_SIZE.height, + }, + }; +} + +export function createCharacterGenerationDialogDraft({ + canvasSize, + viewport, + imageModel, +}: { + canvasSize: CanvasSize; + viewport: CanvasViewport; + imageModel: string; +}): Omit { + const worldCenter = getViewportWorldCenter({ canvasSize, viewport }); + const dimensionDefaults = resolveImageDimensionDefaults(imageModel); + return { + mode: 'character', + prompt: '', + status: 'idle', + composerOpen: true, + characterSpecReference: null, + characterReferences: [], + imageModel, + aspectRatio: dimensionDefaults.aspectRatio, + imageSize: dimensionDefaults.imageSize, + placeholder: { + x: worldCenter.x - CHARACTER_FRAME_DISPLAY_SIZE.width / 2, + y: worldCenter.y - CHARACTER_FRAME_DISPLAY_SIZE.height / 2, + width: CHARACTER_FRAME_DISPLAY_SIZE.width, + height: CHARACTER_FRAME_DISPLAY_SIZE.height, + originalWidth: CHARACTER_FRAME_ORIGINAL_SIZE.width, + originalHeight: CHARACTER_FRAME_ORIGINAL_SIZE.height, + }, + }; +} + +export function createIconGenerationDialogDraft({ + canvasSize, + viewport, + imageModel, +}: { + canvasSize: CanvasSize; + viewport: CanvasViewport; + imageModel: string; +}): Omit { + const worldCenter = getViewportWorldCenter({ canvasSize, viewport }); + const dimensionDefaults = resolveImageDimensionDefaults(imageModel); + return { + mode: 'icon', + prompt: '', + status: 'idle', + composerOpen: true, + iconSpecReference: null, + iconDescriptions: [...DEFAULT_ICON_DESCRIPTIONS], + imageModel, + aspectRatio: dimensionDefaults.aspectRatio, + imageSize: dimensionDefaults.imageSize, + placeholder: { + x: worldCenter.x - ICON_FRAME_DISPLAY_SIZE.width / 2, + y: worldCenter.y - ICON_FRAME_DISPLAY_SIZE.height / 2, + width: ICON_FRAME_DISPLAY_SIZE.width, + height: ICON_FRAME_DISPLAY_SIZE.height, + originalWidth: ICON_FRAME_ORIGINAL_SIZE.width, + originalHeight: ICON_FRAME_ORIGINAL_SIZE.height, + }, + }; +} + +export function createEditDialogDraft( + sourceLayer: CanvasLayer, +): GenerateDialogState { + return { + mode: 'edit', + prompt: sourceLayer.prompt + ? `${sourceLayer.prompt},在保持主体结构的基础上优化画面细节` + : '', + status: 'idle', + composerOpen: true, + sourceLayerId: sourceLayer.id, + }; +} + +export function createQuickEditPanelDraft( + sourceLayer: CanvasLayer, +): QuickEditPanelState { + return { + sourceLayerId: sourceLayer.id, + prompt: '', + size: formatImageSizeValue( + sourceLayer.originalWidth, + sourceLayer.originalHeight, + ), + model: sourceLayer.model?.trim() || DEFAULT_IMAGE_MODEL, + status: 'idle', + }; +} + +export function createCharacterAnimationPanelDraft( + layer: CanvasLayer, +): CharacterAnimationPanelState | null { + if (layer.assetKind !== 'character') { + return null; + } + return { + sourceLayerId: layer.id, + promptText: '', + resolution: '480p', + ratio: 'same', + frameCount: 32, + durationSeconds: 4, + status: 'idle', + }; +} + +export function assignCharacterSpecReference( + dialog: GenerateDialogState | null, + layer: CanvasLayer, +): GenerateDialogState | null { + return dialog?.mode === 'character' + ? { + ...resetFailedGenerationDialog(dialog), + characterSpecReference: createCanvasLayerReference(layer), + composerOpen: true, + } + : dialog; +} + +export function appendCharacterReference( + dialog: GenerateDialogState | null, + layer: CanvasLayer, +): GenerateDialogState | null { + return dialog?.mode === 'character' + ? { + ...resetFailedGenerationDialog(dialog), + characterReferences: [ + ...(dialog.characterReferences ?? []), + createCanvasLayerReference(layer), + ], + composerOpen: true, + } + : dialog; +} + +export function assignIconSpecReference( + dialog: GenerateDialogState | null, + layer: CanvasLayer, +): GenerateDialogState | null { + if (layer.assetKind !== 'icon-spec') { + return dialog; + } + return dialog?.mode === 'icon' + ? { + ...resetFailedGenerationDialog(dialog), + iconSpecReference: createCanvasLayerReference(layer), + composerOpen: true, + } + : dialog; +} + +export function updateSpecFormDialogValue( + dialog: GenerateDialogState | null, + key: keyof SpecFormValues, + value: string, +): GenerateDialogState | null { + if (dialog?.mode !== 'spec') { + return dialog; + } + const specType = dialog.specType ?? 'custom'; + return { + ...resetFailedGenerationDialog(dialog), + specValues: { + ...DEFAULT_SPEC_FORM_VALUES[specType], + ...dialog.specValues, + [key]: value, + }, + }; +} + +export function updateIconDescriptionInDialog( + dialog: GenerateDialogState | null, + index: number, + value: string, +): GenerateDialogState | null { + return dialog?.mode === 'icon' + ? { + ...resetFailedGenerationDialog(dialog), + iconDescriptions: ( + dialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS + ).map((description, descriptionIndex) => + descriptionIndex === index ? value : description, + ), + } + : dialog; +} + +export function appendIconDescriptionToDialog( + dialog: GenerateDialogState | null, +): GenerateDialogState | null { + if (dialog?.mode !== 'icon') { + return dialog; + } + const descriptions = dialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS; + if (descriptions.length >= ICON_DESCRIPTION_LIMIT) { + return dialog; + } + return { + ...resetFailedGenerationDialog(dialog), + iconDescriptions: [...descriptions, ''], + }; +} + +export function updateCharacterAnimationDurationPanel( + panel: CharacterAnimationPanelState | null, + frameCountValue: string, +): CharacterAnimationPanelState | null { + const option = CHARACTER_ANIMATION_DURATION_OPTIONS.find( + (item) => String(item.frameCount) === frameCountValue, + ); + if (!option || !panel) { + return panel; + } + return { + ...panel, + frameCount: option.frameCount, + durationSeconds: option.durationSeconds, + status: panel.status === 'failed' ? 'idle' : panel.status, + errorMessage: panel.status === 'failed' ? undefined : panel.errorMessage, + }; +} + +export function hideGeneratedLayerComposerAfterBlur( + dialog: GenerateDialogState | null, +): GenerateDialogState | null { + return (dialog?.mode === 'generate' || + dialog?.mode === 'spec' || + dialog?.mode === 'character' || + dialog?.mode === 'icon') && + dialog.status !== 'generating' + ? { + ...dialog, + composerOpen: false, + } + : dialog; +} + +export function closeGenerateComposerDialog( + dialog: GenerateDialogState | null, +): GenerateDialogState | null { + return dialog?.mode === 'generate' + ? { + ...dialog, + composerOpen: false, + } + : dialog; +} diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts index 724ea314..c77f5e50 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -1,54 +1,22 @@ import { - useCallback, - useMemo, - useState, type Dispatch, type MutableRefObject, type SetStateAction, + useCallback, + useMemo, + useState, } from 'react'; import { resolveEditorImageReferenceDataUrl } from '../../services/image-editor/editorImageReference'; import { editEditorImage, - generateEditorCharacterAnimation, - generateEditorIconSpritesheet, - generateEditorImage, type EditorIconSpritesheetGenerationResult, type EditorIconSpritesheetIconResult, type EditorImageGenerationResult, + generateEditorCharacterAnimation, + generateEditorIconSpritesheet, + generateEditorImage, } from '../../services/image-editor/editorProjectClient'; -import { - createGeneratedResultLayer, - createIconSpritesheetResultLayers, - createQuickEditResultLayer, -} from './ImageCanvasGenerationLayerModel'; -import { - CHARACTER_ANIMATION_DURATION_OPTIONS, - CHARACTER_FRAME_DISPLAY_SIZE, - CHARACTER_FRAME_ORIGINAL_SIZE, - DEFAULT_ICON_DESCRIPTIONS, - DEFAULT_IMAGE_MODEL, - DEFAULT_SPEC_FORM_VALUES, - EDITOR_IMAGE_DIMENSION_OPTIONS, - ICON_DESCRIPTION_LIMIT, - ICON_FRAME_DISPLAY_SIZE, - ICON_FRAME_ORIGINAL_SIZE, - SPEC_FRAME_DISPLAY_SIZE, - SPEC_FRAME_ORIGINAL_SIZE, - buildEditGenerationInputs, - buildQuickEditModelOptions, - buildQuickEditSizeOptions, - calculateCharacterAnimationPrice, - createCanvasLayerReference, - isCanvasGenerationDialog, - resolveImageGenerationErrorMessage, -} from './ImageCanvasGenerationModel'; -import { - buildCharacterAnimationSubmissionPlan, - buildIconSpritesheetGenerationSubmissionPlan, - buildImageGenerationSubmissionPlan, -} from './ImageCanvasGenerationSubmissionModel'; -import { formatImageSizeValue } from './ImageCanvasEditorModel'; import type { CanvasGenerationDialogState, CanvasGenerationInputs, @@ -63,6 +31,44 @@ import type { SpecFormValues, SpecGenerationType, } from './ImageCanvasEditorTypes'; +import { + appendCharacterReference, + appendIconDescriptionToDialog, + assignCharacterSpecReference, + assignIconSpecReference, + closeGenerateComposerDialog, + createCharacterAnimationPanelDraft, + createCharacterGenerationDialogDraft, + createEditDialogDraft, + createGenerateDialogDraft, + createIconGenerationDialogDraft, + createQuickEditPanelDraft, + createSpecDialogDraft, + hideGeneratedLayerComposerAfterBlur, + updateCharacterAnimationDurationPanel, + updateIconDescriptionInDialog, + updateSpecFormDialogValue, +} from './ImageCanvasGenerationDialogModel'; +import { + createGeneratedResultLayer, + createIconSpritesheetResultLayers, + createQuickEditResultLayer, +} from './ImageCanvasGenerationLayerModel'; +import { + buildEditGenerationInputs, + buildQuickEditModelOptions, + buildQuickEditSizeOptions, + calculateCharacterAnimationPrice, + DEFAULT_ICON_DESCRIPTIONS, + DEFAULT_IMAGE_MODEL, + isCanvasGenerationDialog, + resolveImageGenerationErrorMessage, +} from './ImageCanvasGenerationModel'; +import { + buildCharacterAnimationSubmissionPlan, + buildIconSpritesheetGenerationSubmissionPlan, + buildImageGenerationSubmissionPlan, +} from './ImageCanvasGenerationSubmissionModel'; type CanvasSize = { width: number; height: number }; @@ -98,36 +104,6 @@ type GenerationWorkflowOptions = { setImageContextMenu: Dispatch>; }; -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 setFailedCharacterGenerationIdle(dialog: GenerateDialogState) { - return { - ...dialog, - status: dialog.status === 'failed' ? 'idle' : dialog.status, - errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage, - }; -} - -function setFailedIconGenerationIdle(dialog: GenerateDialogState) { - return { - ...dialog, - status: dialog.status === 'failed' ? 'idle' : dialog.status, - errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage, - }; -} - export function useImageCanvasGenerationWorkflow({ layers, canvasSize, @@ -149,8 +125,7 @@ export function useImageCanvasGenerationWorkflow({ setImageContextMenu, }: GenerationWorkflowOptions) { const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(false); - const [isCharacterSpecMenuOpen, setIsCharacterSpecMenuOpen] = - useState(false); + const [isCharacterSpecMenuOpen, setIsCharacterSpecMenuOpen] = useState(false); const [isCharacterReferenceMenuOpen, setIsCharacterReferenceMenuOpen] = useState(false); const [ @@ -179,12 +154,16 @@ export function useImageCanvasGenerationWorkflow({ (layer) => layer.id === characterAnimationPanel.sourceLayerId, ) ?? null) : null; - const quickEditSizeOptions = quickEditPanel - ? buildQuickEditSizeOptions(quickEditPanel.size) - : []; - const quickEditModelOptions = quickEditPanel - ? buildQuickEditModelOptions(quickEditPanel.model) - : []; + const quickEditSizeOptions = useMemo( + () => + quickEditPanel ? buildQuickEditSizeOptions(quickEditPanel.size) : [], + [quickEditPanel], + ); + const quickEditModelOptions = useMemo( + () => + quickEditPanel ? buildQuickEditModelOptions(quickEditPanel.model) : [], + [quickEditPanel], + ); const characterAnimationPrice = characterAnimationPanel ? calculateCharacterAnimationPrice( characterAnimationPanel.resolution, @@ -197,23 +176,9 @@ export function useImageCanvasGenerationWorkflow({ : DEFAULT_ICON_DESCRIPTIONS; const openGenerateDialog = useCallback(() => { - const placeholderWidth = 420; - const placeholderHeight = 420; - const worldCenter = getViewportWorldCenter({ canvasSize, viewport }); - openCanvasGenerationDialog({ - mode: 'generate', - prompt: '', - status: 'idle', - composerOpen: true, - placeholder: { - x: worldCenter.x - placeholderWidth / 2, - y: worldCenter.y - placeholderHeight / 2, - width: placeholderWidth, - height: placeholderHeight, - originalWidth: 2048, - originalHeight: 2048, - }, - }); + openCanvasGenerationDialog( + createGenerateDialogDraft({ canvasSize, viewport }), + ); setActiveTool('generate'); selectSingleLayer(null); setQuickEditPanel(null); @@ -227,23 +192,9 @@ export function useImageCanvasGenerationWorkflow({ const openSpecDialog = useCallback( (specType: SpecGenerationType) => { - const worldCenter = getViewportWorldCenter({ canvasSize, viewport }); - openCanvasGenerationDialog({ - mode: 'spec', - prompt: '', - status: 'idle', - composerOpen: true, - specType, - specValues: { ...DEFAULT_SPEC_FORM_VALUES[specType] }, - placeholder: { - x: worldCenter.x - SPEC_FRAME_DISPLAY_SIZE.width / 2, - y: worldCenter.y - SPEC_FRAME_DISPLAY_SIZE.height / 2, - width: SPEC_FRAME_DISPLAY_SIZE.width, - height: SPEC_FRAME_DISPLAY_SIZE.height, - originalWidth: SPEC_FRAME_ORIGINAL_SIZE.width, - originalHeight: SPEC_FRAME_ORIGINAL_SIZE.height, - }, - }); + openCanvasGenerationDialog( + createSpecDialogDraft({ canvasSize, viewport, specType }), + ); setIsSpecMenuOpen(false); setActiveTool('generate'); selectSingleLayer(null); @@ -260,56 +211,30 @@ export function useImageCanvasGenerationWorkflow({ const openCharacterAnimationPanel = useCallback( (layer: CanvasLayer) => { - if (layer.assetKind !== 'character') { + const nextPanel = createCharacterAnimationPanelDraft(layer); + if (!nextPanel) { return; } setImageContextMenu(null); setQuickEditPanel(null); - setCharacterAnimationPanel({ - sourceLayerId: layer.id, - promptText: '', - resolution: '480p', - ratio: 'same', - frameCount: 32, - durationSeconds: 4, - status: 'idle', - }); + setCharacterAnimationPanel(nextPanel); selectSingleLayer(layer.id); }, [selectSingleLayer, setImageContextMenu], ); const openCharacterGenerationDialog = useCallback(() => { - const worldCenter = getViewportWorldCenter({ canvasSize, viewport }); setIsSpecMenuOpen(false); setIsCharacterReferenceMenuOpen(false); setIsPickingCharacterSpecFromCanvas(false); setIsPickingCharacterReferenceFromCanvas(false); - const dimensionOptions = - EDITOR_IMAGE_DIMENSION_OPTIONS[ - lastImageModel as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS - ] ?? EDITOR_IMAGE_DIMENSION_OPTIONS[DEFAULT_IMAGE_MODEL]; - openCanvasGenerationDialog({ - mode: 'character', - prompt: '', - status: 'idle', - composerOpen: true, - characterSpecReference: null, - characterReferences: [], - imageModel: lastImageModel, - aspectRatio: dimensionOptions.aspectRatios[0], - imageSize: - dimensionOptions.imageSizes.find((size) => size === '1K') ?? - dimensionOptions.imageSizes[0], - placeholder: { - x: worldCenter.x - CHARACTER_FRAME_DISPLAY_SIZE.width / 2, - y: worldCenter.y - CHARACTER_FRAME_DISPLAY_SIZE.height / 2, - width: CHARACTER_FRAME_DISPLAY_SIZE.width, - height: CHARACTER_FRAME_DISPLAY_SIZE.height, - originalWidth: CHARACTER_FRAME_ORIGINAL_SIZE.width, - originalHeight: CHARACTER_FRAME_ORIGINAL_SIZE.height, - }, - }); + openCanvasGenerationDialog( + createCharacterGenerationDialogDraft({ + canvasSize, + viewport, + imageModel: lastImageModel, + }), + ); setActiveTool('character'); selectSingleLayer(null); setQuickEditPanel(null); @@ -323,37 +248,18 @@ export function useImageCanvasGenerationWorkflow({ ]); const openIconGenerationDialog = useCallback(() => { - const worldCenter = getViewportWorldCenter({ canvasSize, viewport }); setIsSpecMenuOpen(false); setIsCharacterReferenceMenuOpen(false); setIsPickingCharacterSpecFromCanvas(false); setIsPickingCharacterReferenceFromCanvas(false); setIsPickingIconSpecFromCanvas(false); - const dimensionOptions = - EDITOR_IMAGE_DIMENSION_OPTIONS[ - lastImageModel as keyof typeof EDITOR_IMAGE_DIMENSION_OPTIONS - ] ?? EDITOR_IMAGE_DIMENSION_OPTIONS[DEFAULT_IMAGE_MODEL]; - openCanvasGenerationDialog({ - mode: 'icon', - prompt: '', - status: 'idle', - composerOpen: true, - iconSpecReference: null, - iconDescriptions: [...DEFAULT_ICON_DESCRIPTIONS], - imageModel: lastImageModel, - aspectRatio: dimensionOptions.aspectRatios[0], - imageSize: - dimensionOptions.imageSizes.find((size) => size === '1K') ?? - dimensionOptions.imageSizes[0], - placeholder: { - x: worldCenter.x - ICON_FRAME_DISPLAY_SIZE.width / 2, - y: worldCenter.y - ICON_FRAME_DISPLAY_SIZE.height / 2, - width: ICON_FRAME_DISPLAY_SIZE.width, - height: ICON_FRAME_DISPLAY_SIZE.height, - originalWidth: ICON_FRAME_ORIGINAL_SIZE.width, - originalHeight: ICON_FRAME_ORIGINAL_SIZE.height, - }, - }); + openCanvasGenerationDialog( + createIconGenerationDialogDraft({ + canvasSize, + viewport, + imageModel: lastImageModel, + }), + ); setActiveTool('icon'); selectSingleLayer(null); setQuickEditPanel(null); @@ -372,15 +278,7 @@ export function useImageCanvasGenerationWorkflow({ setMetadataLayer(null); setImageContextMenu(null); setQuickEditPanel(null); - setGenerateDialog({ - mode: 'edit', - prompt: sourceLayer.prompt - ? `${sourceLayer.prompt},在保持主体结构的基础上优化画面细节` - : '', - status: 'idle', - composerOpen: true, - sourceLayerId: sourceLayer.id, - }); + setGenerateDialog(createEditDialogDraft(sourceLayer)); setActiveTool('generate'); }, [setActiveTool, setGenerateDialog, setImageContextMenu, setMetadataLayer], @@ -392,16 +290,7 @@ export function useImageCanvasGenerationWorkflow({ setMetadataLayer(null); setGenerateDialog(null); setCharacterAnimationPanel(null); - setQuickEditPanel({ - sourceLayerId: sourceLayer.id, - prompt: '', - size: formatImageSizeValue( - sourceLayer.originalWidth, - sourceLayer.originalHeight, - ), - model: sourceLayer.model?.trim() || DEFAULT_IMAGE_MODEL, - status: 'idle', - }); + setQuickEditPanel(createQuickEditPanelDraft(sourceLayer)); selectSingleLayer(sourceLayer.id); setActiveTool('generate'); }, @@ -556,13 +445,7 @@ export function useImageCanvasGenerationWorkflow({ const pickCharacterSpecFromLayer = useCallback( (layer: CanvasLayer) => { setGenerateDialog((currentDialog) => - currentDialog?.mode === 'character' - ? { - ...setFailedCharacterGenerationIdle(currentDialog), - characterSpecReference: createCanvasLayerReference(layer), - composerOpen: true, - } - : currentDialog, + assignCharacterSpecReference(currentDialog, layer), ); setIsPickingCharacterSpecFromCanvas(false); setIsCharacterSpecMenuOpen(false); @@ -574,16 +457,7 @@ export function useImageCanvasGenerationWorkflow({ const pickCharacterReferenceFromLayer = useCallback( (layer: CanvasLayer) => { setGenerateDialog((currentDialog) => - currentDialog?.mode === 'character' - ? { - ...setFailedCharacterGenerationIdle(currentDialog), - characterReferences: [ - ...(currentDialog.characterReferences ?? []), - createCanvasLayerReference(layer), - ], - composerOpen: true, - } - : currentDialog, + appendCharacterReference(currentDialog, layer), ); setIsPickingCharacterReferenceFromCanvas(false); setImageContextMenu(null); @@ -593,18 +467,12 @@ export function useImageCanvasGenerationWorkflow({ const pickIconSpecFromLayer = useCallback( (layer: CanvasLayer) => { + setGenerateDialog((currentDialog) => + assignIconSpecReference(currentDialog, layer), + ); if (layer.assetKind !== 'icon-spec') { return; } - setGenerateDialog((currentDialog) => - currentDialog?.mode === 'icon' - ? { - ...setFailedIconGenerationIdle(currentDialog), - iconSpecReference: createCanvasLayerReference(layer), - composerOpen: true, - } - : currentDialog, - ); setIsPickingIconSpecFromCanvas(false); setIsIconSpecMenuOpen(false); setImageContextMenu(null); @@ -615,36 +483,14 @@ export function useImageCanvasGenerationWorkflow({ const updateIconDescription = useCallback( (index: number, value: string) => { setGenerateDialog((currentDialog) => - currentDialog?.mode === 'icon' - ? { - ...setFailedIconGenerationIdle(currentDialog), - iconDescriptions: ( - currentDialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS - ).map((description, descriptionIndex) => - descriptionIndex === index ? value : description, - ), - } - : currentDialog, + updateIconDescriptionInDialog(currentDialog, index, value), ); }, [setGenerateDialog], ); const addIconDescription = useCallback(() => { - setGenerateDialog((currentDialog) => { - if (currentDialog?.mode !== 'icon') { - return currentDialog; - } - const descriptions = - currentDialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS; - if (descriptions.length >= ICON_DESCRIPTION_LIMIT) { - return currentDialog; - } - return { - ...setFailedIconGenerationIdle(currentDialog), - iconDescriptions: [...descriptions, ''], - }; - }); + setGenerateDialog(appendIconDescriptionToDialog); }, [setGenerateDialog]); const submitIconSpritesheetGeneration = useCallback( @@ -841,52 +687,17 @@ export function useImageCanvasGenerationWorkflow({ const updateSpecFormValue = useCallback( (key: keyof SpecFormValues, value: string) => { - setGenerateDialog((currentDialog) => { - if (currentDialog?.mode !== 'spec') { - return currentDialog; - } - const specType = currentDialog.specType ?? 'custom'; - return { - ...currentDialog, - specValues: { - ...DEFAULT_SPEC_FORM_VALUES[specType], - ...currentDialog.specValues, - [key]: value, - }, - status: - currentDialog.status === 'failed' ? 'idle' : currentDialog.status, - errorMessage: - currentDialog.status === 'failed' - ? undefined - : currentDialog.errorMessage, - }; - }); + setGenerateDialog((currentDialog) => + updateSpecFormDialogValue(currentDialog, key, value), + ); }, [setGenerateDialog], ); const updateCharacterAnimationDuration = useCallback( (frameCountValue: string) => { - const option = CHARACTER_ANIMATION_DURATION_OPTIONS.find( - (item) => String(item.frameCount) === frameCountValue, - ); - if (!option) { - return; - } setCharacterAnimationPanel((currentPanel) => - currentPanel - ? { - ...currentPanel, - frameCount: option.frameCount, - durationSeconds: option.durationSeconds, - status: - currentPanel.status === 'failed' ? 'idle' : currentPanel.status, - errorMessage: - currentPanel.status === 'failed' - ? undefined - : currentPanel.errorMessage, - } - : currentPanel, + updateCharacterAnimationDurationPanel(currentPanel, frameCountValue), ); }, [], @@ -940,28 +751,12 @@ export function useImageCanvasGenerationWorkflow({ const hideGeneratedLayerPanelAfterBlur = useCallback(() => { setGenerateDialog((currentDialog) => - (currentDialog?.mode === 'generate' || - currentDialog?.mode === 'spec' || - currentDialog?.mode === 'character' || - currentDialog?.mode === 'icon') && - currentDialog.status !== 'generating' - ? { - ...currentDialog, - composerOpen: false, - } - : currentDialog, + hideGeneratedLayerComposerAfterBlur(currentDialog), ); }, [setGenerateDialog]); const closeGenerateComposer = useCallback(() => { - setGenerateDialog((currentDialog) => - currentDialog?.mode === 'generate' - ? { - ...currentDialog, - composerOpen: false, - } - : currentDialog, - ); + setGenerateDialog(closeGenerateComposerDialog); setActiveTool('select'); }, [setActiveTool, setGenerateDialog]);