From e07002c1dc6fd2680a9135dd94283623d11e362f Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 17 Jun 2026 08:11:51 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8B=86=E5=88=86=E5=9B=BE=E7=89=87=E7=94=BB?= =?UTF-8?q?=E5=B8=83=E7=94=9F=E6=88=90=E5=B7=A5=E4=BD=9C=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 useImageCanvasGenerationWorkflow 承接生成入口、提交和结果落图 主视图改为通过生成工作流 hook 处理生成态清理和工具入口 补充生成工作流单测、拆分文档和 TRACKING 浏览器回归记录 --- TRACKING.md | 1 + ...构】图片画布编辑器前端拆分计划-2026-06-17.md | 11 +- .../image-editor/ImageCanvasEditorView.tsx | 994 ++------------- .../useImageCanvasGenerationWorkflow.test.tsx | 389 ++++++ .../useImageCanvasGenerationWorkflow.ts | 1087 +++++++++++++++++ 5 files changed, 1616 insertions(+), 866 deletions(-) create mode 100644 src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx create mode 100644 src/components/image-editor/useImageCanvasGenerationWorkflow.ts diff --git a/TRACKING.md b/TRACKING.md index 9cfad3ae..39177667 100644 --- a/TRACKING.md +++ b/TRACKING.md @@ -129,3 +129,4 @@ - 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。 - 2026-06-17 前端拆分第十三阶段:新增 `useImageCanvasAssetExportWorkflow`,把画布素材导出状态、单图右键导出、整包 ZIP 组包、图片去重、读取失败记录、metadata / manifest 和下载链接副作用从主视图抽出;主视图保留右键目标解析和状态提示渲染。验证命令:`npm run test -- src/components/image-editor/useImageCanvasAssetExportWorkflow.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` 清空会话后未登录刷新弹出 `账号入口`,登录临时开发账号后下载按钮启用,点击后触发真实下载 `未命名画布-画布素材-20260617.zip` 并显示导出状态;背景设置点击 `暖灰` 后 viewport 背景为 `rgb(243, 240, 234)` 且 `background-image: none`,点击 `生成工具` 后 `生成图片` 对话框出现且 `AI画布工具栏` 保持可见,登录后控制台无前端 error。 - 2026-06-17 前端拆分第十四阶段:新增 `useImageCanvasLayerCommands`,把画布剪贴板、右键目标解析、复制 / 剪切 / 粘贴、创建副本、层级移动、分组 / 解组、显隐、锁定、翻转、删除选中图层、按 id 删除和单图导出委托从主视图抽出;主视图保留菜单定位、画布事件、生成、上传、项目持久化和实际导出下载。验证命令:`npm run test -- src/components/image-editor/useImageCanvasLayerCommands.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画布工具栏` 均可见;登录临时开发账号后新标签素材、画布图层、返回项目入口、小地图和底部工具栏可见,控制台无前端 error。 +- 2026-06-17 前端拆分第十五阶段:新增 `useImageCanvasGenerationWorkflow`,把生成入口、规范 / 角色 / 图标 / 修改 / 快速编辑 / 角色动画状态机、真实生成提交、结果落图、失败恢复和删除图层后的生成态清理从主视图抽出;主视图保留画布事件、浮层定位、上传、项目资源持久化和历史捕获。验证命令:`npm run test -- src/components/image-editor/useImageCanvasGenerationWorkflow.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画布工具栏` 均可见;登录临时开发账号后上传素材成功,素材数增加,点击素材可加入画布,切换 `图层` 面板可看到对应图层,登录后控制台无前端 error。 diff --git a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md index e2884f12..7bace166 100644 --- a/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md +++ b/docs/technical/【前端架构】图片画布编辑器前端拆分计划-2026-06-17.md @@ -131,10 +131,17 @@ - 主视图继续负责右键菜单定位、画布事件、生成提交、素材上传、项目持久化和实际下载实现;hook 只接收必要 setter 与副作用回调,不反向读取 DOM 或路由。 - 该 hook 用独立单测覆盖菜单关闭、历史捕获、选中态更新、删除副作用、剪贴板和导出委托,避免主视图后续继续堆积右键菜单 orchestration。 +## 第十五阶段模块 + +- `useImageCanvasGenerationWorkflow.ts` + - 承载图片画布生成工作流:打开普通生图 / 规范 / 角色 / 图标生成对象、打开修改图片 / 快速编辑 / 角色动画面板、选择画布规范参考、提交真实生成 API、失败恢复、生成结果落图、侧栏切换、适合视图和删除图层后的生成态清理。 + - 主视图继续负责画布事件、生成对话框定位样式、占位框拖拽、DOM 渲染、上传入口、工程资源持久化和历史捕获;生成 hook 只接收状态 setter 与落图 / 选中 / 适合视图回调。 + - 该 hook 用独立单测覆盖打开生成占位、普通生图落图、快速编辑落图并适配源图、删除源图时清理 quick edit / edit dialog、角色动画入口过滤和隐藏生成面板不删除占位框,避免后续拆分再次导致生成工具或底部工具栏状态回退。 + ## 后续阶段 -- 生成工作流 hook:等生成对象归档、占位框拖拽、生成完成回写、失败恢复和 undo / redo 规则进一步稳定后,再抽出 `useImageCanvasGenerationWorkflow` 这类深 hook;它应整体承接打开入口、提交状态、API 调用、错误映射和结果落图协调,而不是把主视图拆成大量 setter 透传。 -- 生成工作流 hook 之前,不再单独把 quick edit、角色动画或图标提交切成浅模块,避免破坏多生成对象同时存在、完成时读取最新占位框和角色动画优先传 `objectKey` 的历史保护规则。 +- 后续可继续选择更高内聚的交互 workflow 或持久化边界,不再把生成链路继续拆成浅层 wrapper。 +- 生成对象定位、画布 pointer 事件、工程资源持久化和历史捕获仍在主视图编排,拆分前需要先确认不会破坏多生成对象同时存在、完成时读取最新占位框和角色动画优先传 `objectKey` 的历史保护规则。 ## 验证计划 diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index e2655fb5..968b509f 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -12,17 +12,7 @@ import { } from 'react'; import { ApiClientError } from '../../services/apiClient'; -import { resolveEditorImageReferenceDataUrl } from '../../services/image-editor/editorImageReference'; -import { - editEditorImage, - type EditorIconSpritesheetGenerationResult, - type EditorIconSpritesheetIconResult, - type EditorImageGenerationResult, - generateEditorCharacterAnimation, - generateEditorIconSpritesheet, - generateEditorImage, - renameEditorProject, -} from '../../services/image-editor/editorProjectClient'; +import { renameEditorProject } from '../../services/image-editor/editorProjectClient'; import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; import { PlatformTextField } from '../common/PlatformTextField'; import { UnifiedModal } from '../common/UnifiedModal'; @@ -51,11 +41,6 @@ import { } from './ImageCanvasLayerCommandModel'; import { ImageCanvasSidebarView } from './ImageCanvasSidebarView'; import { ImageCanvasStageView } from './ImageCanvasStageView'; -import { - createGeneratedResultLayer, - createIconSpritesheetResultLayers, - createQuickEditResultLayer, -} from './ImageCanvasGenerationLayerModel'; import { ASSET_DRAG_MIME_TYPE, DEFAULT_CANVAS_BACKGROUND_COLOR, @@ -63,7 +48,6 @@ import { TOOLBAR_HALF_WIDTH, clamp, createLayerFromAsset, - formatImageSizeValue, getDraggedAssetId, hasDataTransferType, isGeneratedLayer, @@ -73,65 +57,33 @@ import { serializeLayer, } from './ImageCanvasEditorModel'; import { - CHARACTER_ANIMATION_DURATION_OPTIONS, - CHARACTER_ANIMATION_MODEL, - CHARACTER_FRAME_DISPLAY_SIZE, - CHARACTER_FRAME_ORIGINAL_SIZE, - DEFAULT_ICON_DESCRIPTIONS, - DEFAULT_IMAGE_MODEL, - DEFAULT_SPEC_FORM_VALUES, ICON_COMPOSER_HORIZONTAL_CHROME_REM, ICON_COMPOSER_MIN_WIDTH_REM, ICON_DESCRIPTION_CARD_WIDTH_REM, - ICON_DESCRIPTION_LIMIT, - ICON_FRAME_DISPLAY_SIZE, - ICON_FRAME_ORIGINAL_SIZE, - SPEC_FRAME_DISPLAY_SIZE, - SPEC_FRAME_ORIGINAL_SIZE, - SPEC_GENERATION_SIZE, - SPEC_TYPE_LABEL, - buildCharacterGenerationInputs, - buildEditGenerationInputs, - buildIconGenerationInputs, - buildImageGenerationInputs, - buildQuickEditModelOptions, - buildQuickEditSizeOptions, - buildSpecGenerationInputs, - buildSpecPrompt, - calculateCharacterAnimationPrice, - createCanvasLayerReference, formatLayerImageType, getGenerationFrameAriaLabel, getGenerationFrameLabel, getLayerKindLabel, - isCanvasGenerationDialog, - resolveCharacterAnimationSourceImageSrc, - resolveImageGenerationErrorMessage, } from './ImageCanvasGenerationModel'; import type { AssetPointerDragState, CanvasContextMenuState, CanvasGenerationDialogState, - CanvasGenerationInputs, CanvasLayer, CanvasMarqueeState, CanvasTool, CanvasViewport, - CharacterAnimationPanelState, DragState, EditorAsset, - GenerateDialogState, ImageContextMenuState, - QuickEditPanelState, SidebarPanel, SnapGuide, - SpecFormValues, - SpecGenerationType, } from './ImageCanvasEditorTypes'; import { useCanvasHistory } from './useCanvasHistory'; import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs'; import { useImageCanvasAssetLibrary } from './useImageCanvasAssetLibrary'; import { useImageCanvasAssetExportWorkflow } from './useImageCanvasAssetExportWorkflow'; +import { useImageCanvasGenerationWorkflow } from './useImageCanvasGenerationWorkflow'; import { useImageCanvasLayerCommands } from './useImageCanvasLayerCommands'; import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence'; import { useImageCanvasUploadWorkflow } from './useImageCanvasUploadWorkflow'; @@ -254,7 +206,6 @@ export function ImageCanvasEditorView() { const [isZoomMenuOpen, setIsZoomMenuOpen] = useState(false); const [isBackgroundSettingsOpen, setIsBackgroundSettingsOpen] = useState(false); - const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(false); const [isMinimapOpen, setIsMinimapOpen] = useState(true); const [canvasBackgroundColor, setCanvasBackgroundColor] = useState( DEFAULT_CANVAS_BACKGROUND_COLOR, @@ -263,23 +214,11 @@ export function ImageCanvasEditorView() { DEFAULT_CANVAS_BACKGROUND_COLOR, ); const [metadataLayer, setMetadataLayer] = useState(null); - const [isCharacterSpecMenuOpen, setIsCharacterSpecMenuOpen] = useState(false); - const [ - isPickingCharacterSpecFromCanvas, - setIsPickingCharacterSpecFromCanvas, - ] = useState(false); - const [isIconSpecMenuOpen, setIsIconSpecMenuOpen] = useState(false); - const [isPickingIconSpecFromCanvas, setIsPickingIconSpecFromCanvas] = - useState(false); const [imageContextMenu, setImageContextMenu] = useState(null); const [contextMenu, setContextMenu] = useState( null, ); - const [quickEditPanel, setQuickEditPanel] = - useState(null); - const [characterAnimationPanel, setCharacterAnimationPanel] = - useState(null); const [uploadDropTarget, setUploadDropTarget] = useState< 'canvas' | 'assets' | null >(null); @@ -459,22 +398,6 @@ export function ImageCanvasEditorView() { 10, } : null; - const iconDescriptionValues = - activeCanvasGenerationDialog?.mode === 'icon' - ? (activeCanvasGenerationDialog.iconDescriptions ?? - DEFAULT_ICON_DESCRIPTIONS) - : DEFAULT_ICON_DESCRIPTIONS; - const iconComposerStyle: CSSProperties | null = - activeCanvasGenerationDialog?.mode === 'icon' && generationComposerStyle - ? { - ...generationComposerStyle, - width: `${Math.max( - ICON_COMPOSER_MIN_WIDTH_REM, - ICON_COMPOSER_HORIZONTAL_CHROME_REM + - iconDescriptionValues.length * ICON_DESCRIPTION_CARD_WIDTH_REM, - ).toFixed(1)}rem`, - } - : null; const selectedToolbarStyle = selectedLayer ? { left: clamp( @@ -487,66 +410,6 @@ export function ImageCanvasEditorView() { top: Math.max(10, viewport.y + selectedLayer.y * viewport.scale - 12), } : null; - const characterAnimationSourceLayer = characterAnimationPanel - ? (layers.find( - (layer) => layer.id === characterAnimationPanel.sourceLayerId, - ) ?? null) - : null; - const quickEditSourceLayer = quickEditPanel - ? (layers.find((layer) => layer.id === quickEditPanel.sourceLayerId) ?? - null) - : null; - const quickEditPanelStyle = - quickEditPanel && quickEditSourceLayer - ? { - left: clamp( - viewport.x + - (quickEditSourceLayer.x + quickEditSourceLayer.width / 2) * - viewport.scale, - 12, - Math.max(12, canvasSize.width - 12), - ), - top: clamp( - viewport.y + - (quickEditSourceLayer.y + quickEditSourceLayer.height) * - viewport.scale + - 12, - 12, - Math.max(12, canvasSize.height - 360), - ), - } - : null; - const quickEditSizeOptions = quickEditPanel - ? buildQuickEditSizeOptions(quickEditPanel.size) - : []; - const quickEditModelOptions = quickEditPanel - ? buildQuickEditModelOptions(quickEditPanel.model) - : []; - const characterAnimationPrice = characterAnimationPanel - ? calculateCharacterAnimationPrice( - characterAnimationPanel.resolution, - characterAnimationPanel.durationSeconds, - ) - : 0; - const characterAnimationPanelStyle = - characterAnimationPanel && characterAnimationSourceLayer - ? { - left: clamp( - viewport.x + - (characterAnimationSourceLayer.x + - characterAnimationSourceLayer.width) * - viewport.scale + - 12, - 12, - Math.max(12, canvasSize.width - 364), - ), - top: clamp( - viewport.y + characterAnimationSourceLayer.y * viewport.scale, - 12, - Math.max(12, canvasSize.height - 520), - ), - } - : null; const imageContextMenuLayer = imageContextMenu ? (layers.find((layer) => layer.id === imageContextMenu.layerId) ?? null) : null; @@ -629,6 +492,21 @@ export function ImageCanvasEditorView() { ); } }, []); + const fitLayers = useCallback( + (targetLayers: CanvasLayer[] = layers) => { + const nextViewport = fitViewportToLayers({ + layers: targetLayers, + canvasSize, + }); + if (!nextViewport) { + return; + } + + captureCanvasHistory(); + setViewport(nextViewport); + }, + [captureCanvasHistory, canvasSize, layers], + ); const projectPersistenceRefs = useMemo( () => ({ layersRef, @@ -667,24 +545,118 @@ export function ImageCanvasEditorView() { projectId, projectTitle, }); - const handleDeletedLayerSideEffects = useCallback( - (targetLayerId: string) => { - setQuickEditPanel((currentPanel) => - currentPanel?.sourceLayerId === targetLayerId ? null : currentPanel, - ); - setCharacterAnimationPanel((currentPanel) => - currentPanel?.sourceLayerId === targetLayerId ? null : currentPanel, - ); - setGenerateDialog((currentDialog) => - currentDialog?.mode === 'edit' && - currentDialog.sourceLayerId === targetLayerId - ? null - : currentDialog, - ); - removeCanvasGenerationDialogsByLayerId(targetLayerId); - }, - [removeCanvasGenerationDialogsByLayerId, setGenerateDialog], - ); + const generationWorkflow = useImageCanvasGenerationWorkflow({ + layers, + canvasSize, + viewport, + layerCounterRef, + generateDialog, + setGenerateDialog, + openCanvasGenerationDialog, + updateCanvasGenerationDialogById, + removeCanvasGenerationDialogById, + removeCanvasGenerationDialogsByLayerId, + getGeneratingDialogPlaceholder, + appendCanvasLayersWithResources, + selectSingleLayer, + fitLayers, + setActiveTool, + setActiveSidebarPanel, + setMetadataLayer, + setImageContextMenu, + }); + const { + quickEditPanel, + setQuickEditPanel, + quickEditSourceLayer, + quickEditSizeOptions, + quickEditModelOptions, + characterAnimationPanel, + setCharacterAnimationPanel, + characterAnimationSourceLayer, + characterAnimationPrice, + iconDescriptionValues, + isSpecMenuOpen, + setIsSpecMenuOpen, + isCharacterSpecMenuOpen, + setIsCharacterSpecMenuOpen, + isPickingCharacterSpecFromCanvas, + setIsPickingCharacterSpecFromCanvas, + isIconSpecMenuOpen, + setIsIconSpecMenuOpen, + isPickingIconSpecFromCanvas, + setIsPickingIconSpecFromCanvas, + openGenerateDialog, + openSpecDialog, + openCharacterAnimationPanel, + openCharacterGenerationDialog, + openIconGenerationDialog, + openEditDialog, + openQuickEditPanel, + pickCharacterSpecFromLayer, + pickIconSpecFromLayer, + submitIconSpritesheetGeneration, + submitQuickEdit, + submitImageGeneration, + updateSpecFormValue, + updateIconDescription, + addIconDescription, + updateCharacterAnimationDuration, + submitCharacterAnimation, + hideGeneratedLayerPanelAfterBlur, + closeGenerateComposer, + clearDeletedLayerGenerationState, + } = generationWorkflow; + const iconComposerStyle: CSSProperties | null = + activeCanvasGenerationDialog?.mode === 'icon' && generationComposerStyle + ? { + ...generationComposerStyle, + width: `${Math.max( + ICON_COMPOSER_MIN_WIDTH_REM, + ICON_COMPOSER_HORIZONTAL_CHROME_REM + + iconDescriptionValues.length * ICON_DESCRIPTION_CARD_WIDTH_REM, + ).toFixed(1)}rem`, + } + : null; + const quickEditPanelStyle = + quickEditPanel && quickEditSourceLayer + ? { + left: clamp( + viewport.x + + (quickEditSourceLayer.x + quickEditSourceLayer.width / 2) * + viewport.scale, + 12, + Math.max(12, canvasSize.width - 12), + ), + top: clamp( + viewport.y + + (quickEditSourceLayer.y + quickEditSourceLayer.height) * + viewport.scale + + 12, + 12, + Math.max(12, canvasSize.height - 360), + ), + } + : null; + const characterAnimationPanelStyle = + characterAnimationPanel && characterAnimationSourceLayer + ? { + left: clamp( + viewport.x + + (characterAnimationSourceLayer.x + + characterAnimationSourceLayer.width) * + viewport.scale + + 12, + 12, + Math.max(12, canvasSize.width - 364), + ), + top: clamp( + viewport.y + characterAnimationSourceLayer.y * viewport.scale, + 12, + Math.max(12, canvasSize.height - 520), + ), + } + : null; const { canvasClipboard, pasteCanvasClipboard, @@ -716,7 +688,7 @@ export function ImageCanvasEditorView() { setActiveTool, captureCanvasHistory, selectSingleLayer, - onDeleteLayerSideEffects: handleDeletedLayerSideEffects, + onDeleteLayerSideEffects: clearDeletedLayerGenerationState, exportLayerImage, }); const { @@ -746,21 +718,6 @@ export function ImageCanvasEditorView() { selectSingleLayer, }); - const hideGeneratedLayerPanelAfterBlur = useCallback(() => { - setGenerateDialog((currentDialog) => - (currentDialog?.mode === 'generate' || - currentDialog?.mode === 'spec' || - currentDialog?.mode === 'character' || - currentDialog?.mode === 'icon') && - currentDialog.status !== 'generating' - ? { - ...currentDialog, - composerOpen: false, - } - : currentDialog, - ); - }, []); - const clearCanvasFocus = useCallback(() => { selectSingleLayer(null); hideGeneratedLayerPanelAfterBlur(); @@ -997,22 +954,6 @@ export function ImageCanvasEditorView() { }; }, []); - const fitLayers = useCallback( - (targetLayers: CanvasLayer[] = layers) => { - const nextViewport = fitViewportToLayers({ - layers: targetLayers, - canvasSize, - }); - if (!nextViewport) { - return; - } - - captureCanvasHistory(); - setViewport(nextViewport); - }, - [captureCanvasHistory, canvasSize, layers], - ); - const updateScaleFromCenter = (nextScale: number) => { const viewportElement = canvasViewportRef.current; if (!viewportElement) { @@ -1130,578 +1071,8 @@ export function ImageCanvasEditorView() { moveAssetToFolderRef.current = moveAssetToFolder; - const setCharacterGenerationIdle = (dialog: GenerateDialogState) => ({ - ...dialog, - status: dialog.status === 'failed' ? 'idle' : dialog.status, - errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage, - }); - - const pickCharacterSpecFromLayer = (layer: CanvasLayer) => { - setGenerateDialog((currentDialog) => - currentDialog?.mode === 'character' - ? { - ...setCharacterGenerationIdle(currentDialog), - characterSpecReference: createCanvasLayerReference(layer), - composerOpen: true, - } - : currentDialog, - ); - setIsPickingCharacterSpecFromCanvas(false); - setIsCharacterSpecMenuOpen(false); - setImageContextMenu(null); - }; - - const setIconGenerationIdle = (dialog: GenerateDialogState) => ({ - ...dialog, - status: dialog.status === 'failed' ? 'idle' : dialog.status, - errorMessage: dialog.status === 'failed' ? undefined : dialog.errorMessage, - }); - - const pickIconSpecFromLayer = (layer: CanvasLayer) => { - if (layer.assetKind !== 'icon-spec') { - return; - } - setGenerateDialog((currentDialog) => - currentDialog?.mode === 'icon' - ? { - ...setIconGenerationIdle(currentDialog), - iconSpecReference: createCanvasLayerReference(layer), - composerOpen: true, - } - : currentDialog, - ); - setIsPickingIconSpecFromCanvas(false); - setIsIconSpecMenuOpen(false); - setImageContextMenu(null); - }; - deleteLayerByIdRef.current = deleteLayerById; - const openGenerateDialog = () => { - const placeholderWidth = 420; - const placeholderHeight = 420; - const worldCenterX = (canvasSize.width / 2 - viewport.x) / viewport.scale; - const worldCenterY = (canvasSize.height / 2 - viewport.y) / viewport.scale; - openCanvasGenerationDialog({ - mode: 'generate', - prompt: '', - status: 'idle', - composerOpen: true, - placeholder: { - x: worldCenterX - placeholderWidth / 2, - y: worldCenterY - placeholderHeight / 2, - width: placeholderWidth, - height: placeholderHeight, - originalWidth: 2048, - originalHeight: 2048, - }, - }); - setActiveTool('generate'); - selectSingleLayer(null); - setQuickEditPanel(null); - }; - - const openSpecDialog = (specType: SpecGenerationType) => { - const worldCenterX = (canvasSize.width / 2 - viewport.x) / viewport.scale; - const worldCenterY = (canvasSize.height / 2 - viewport.y) / viewport.scale; - openCanvasGenerationDialog({ - mode: 'spec', - prompt: '', - status: 'idle', - composerOpen: true, - specType, - specValues: { ...DEFAULT_SPEC_FORM_VALUES[specType] }, - placeholder: { - x: worldCenterX - SPEC_FRAME_DISPLAY_SIZE.width / 2, - y: worldCenterY - 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, - }, - }); - setIsSpecMenuOpen(false); - setActiveTool('generate'); - selectSingleLayer(null); - setQuickEditPanel(null); - }; - - const openCharacterAnimationPanel = (layer: CanvasLayer) => { - if (layer.assetKind !== 'character') { - return; - } - setImageContextMenu(null); - setQuickEditPanel(null); - setCharacterAnimationPanel({ - sourceLayerId: layer.id, - promptText: '', - resolution: '480p', - ratio: 'same', - frameCount: 32, - durationSeconds: 4, - status: 'idle', - }); - selectSingleLayer(layer.id); - }; - - const openCharacterGenerationDialog = () => { - const worldCenterX = (canvasSize.width / 2 - viewport.x) / viewport.scale; - const worldCenterY = (canvasSize.height / 2 - viewport.y) / viewport.scale; - setIsSpecMenuOpen(false); - setIsPickingCharacterSpecFromCanvas(false); - openCanvasGenerationDialog({ - mode: 'character', - prompt: '', - status: 'idle', - composerOpen: true, - characterSpecReference: null, - characterReferences: [], - placeholder: { - x: worldCenterX - CHARACTER_FRAME_DISPLAY_SIZE.width / 2, - y: worldCenterY - 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, - }, - }); - setActiveTool('character'); - selectSingleLayer(null); - setQuickEditPanel(null); - }; - - const openIconGenerationDialog = () => { - const worldCenterX = (canvasSize.width / 2 - viewport.x) / viewport.scale; - const worldCenterY = (canvasSize.height / 2 - viewport.y) / viewport.scale; - setIsSpecMenuOpen(false); - setIsPickingCharacterSpecFromCanvas(false); - setIsPickingIconSpecFromCanvas(false); - openCanvasGenerationDialog({ - mode: 'icon', - prompt: '', - status: 'idle', - composerOpen: true, - iconSpecReference: null, - iconDescriptions: [...DEFAULT_ICON_DESCRIPTIONS], - placeholder: { - x: worldCenterX - ICON_FRAME_DISPLAY_SIZE.width / 2, - y: worldCenterY - 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, - }, - }); - setActiveTool('icon'); - selectSingleLayer(null); - setQuickEditPanel(null); - setCharacterAnimationPanel(null); - }; - - const openEditDialog = (sourceLayer: CanvasLayer) => { - setMetadataLayer(null); - setImageContextMenu(null); - setQuickEditPanel(null); - setGenerateDialog({ - mode: 'edit', - prompt: sourceLayer.prompt - ? `${sourceLayer.prompt},在保持主体结构的基础上优化画面细节` - : '', - status: 'idle', - composerOpen: true, - sourceLayerId: sourceLayer.id, - }); - setActiveTool('generate'); - }; - - const openQuickEditPanel = (sourceLayer: CanvasLayer) => { - setImageContextMenu(null); - 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', - }); - selectSingleLayer(sourceLayer.id); - setActiveTool('generate'); - }; - - const addGeneratedResultLayer = ( - generated: EditorImageGenerationResult, - options: { - sourceLayer?: CanvasLayer; - frame?: GenerateDialogState['placeholder']; - assetKind?: CanvasLayer['assetKind']; - title?: string; - dialogId?: string; - generationInputs?: CanvasGenerationInputs; - } = {}, - ) => { - layerCounterRef.current += 1; - const generatedIndex = layerCounterRef.current; - const nextLayer = createGeneratedResultLayer({ - generated, - generatedIndex, - canvasSize, - viewport, - sourceLayer: options.sourceLayer, - frame: options.frame, - assetKind: options.assetKind, - title: options.title, - generationInputs: options.generationInputs, - }); - - appendCanvasLayersWithResources([nextLayer]); - selectSingleLayer(nextLayer.id); - setActiveSidebarPanel('layers'); - if (options.sourceLayer) { - setGenerateDialog(null); - setActiveTool('select'); - } else if (options.dialogId) { - updateCanvasGenerationDialogById(options.dialogId, (currentDialog) => - currentDialog.mode === 'character' || currentDialog.mode === 'icon' - ? null - : { - ...currentDialog, - status: 'idle', - composerOpen: true, - generatedLayerId: nextLayer.id, - placeholder: undefined, - errorMessage: undefined, - }, - ); - } - if (options.sourceLayer) { - fitLayers([options.sourceLayer, nextLayer]); - } - }; - - const addQuickEditResultLayer = ( - generated: EditorImageGenerationResult, - sourceLayer: CanvasLayer, - generationInputs: CanvasGenerationInputs, - ) => { - layerCounterRef.current += 1; - const generatedIndex = layerCounterRef.current; - const nextLayer = createQuickEditResultLayer({ - generated, - generatedIndex, - sourceLayer, - generationInputs, - }); - - appendCanvasLayersWithResources([nextLayer]); - selectSingleLayer(nextLayer.id); - setActiveSidebarPanel('layers'); - setQuickEditPanel(null); - setActiveTool('select'); - fitLayers([sourceLayer, nextLayer]); - }; - - const addIconSpritesheetResultLayers = ( - generated: EditorIconSpritesheetGenerationResult, - iconResults: EditorIconSpritesheetIconResult[], - generationInputs: CanvasGenerationInputs, - frame?: GenerateDialogState['placeholder'], - dialogId?: string, - ) => { - const startIndex = layerCounterRef.current + 1; - const nextLayers = createIconSpritesheetResultLayers({ - generated, - iconResults, - startIndex, - canvasSize, - viewport, - generationInputs, - frame, - }); - - if (!nextLayers.length) { - return; - } - layerCounterRef.current += nextLayers.length; - appendCanvasLayersWithResources(nextLayers); - selectSingleLayer(nextLayers[0]?.id ?? null); - setActiveSidebarPanel('layers'); - if (dialogId) { - removeCanvasGenerationDialogById(dialogId); - } - setActiveTool('select'); - }; - - const updateIconDescription = (index: number, value: string) => { - setGenerateDialog((currentDialog) => - currentDialog?.mode === 'icon' - ? { - ...setIconGenerationIdle(currentDialog), - iconDescriptions: ( - currentDialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS - ).map((description, descriptionIndex) => - descriptionIndex === index ? value : description, - ), - } - : currentDialog, - ); - }; - - const addIconDescription = () => { - setGenerateDialog((currentDialog) => { - if (currentDialog?.mode !== 'icon') { - return currentDialog; - } - const descriptions = - currentDialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS; - if (descriptions.length >= ICON_DESCRIPTION_LIMIT) { - return currentDialog; - } - return { - ...setIconGenerationIdle(currentDialog), - iconDescriptions: [...descriptions, ''], - }; - }); - }; - - const submitIconSpritesheetGeneration = async ( - dialog: GenerateDialogState, - ) => { - if (dialog.mode !== 'icon') { - return; - } - const canvasDialog = isCanvasGenerationDialog(dialog) ? dialog : null; - const setSubmittingIconDialog = ( - nextDialog: CanvasGenerationDialogState, - ) => { - updateCanvasGenerationDialogById(nextDialog.id, () => nextDialog); - }; - const iconDescriptions = ( - dialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS - ) - .map((description) => description.trim()) - .filter(Boolean); - if (!dialog.iconSpecReference) { - if (canvasDialog) { - setSubmittingIconDialog({ - ...canvasDialog, - status: 'failed', - composerOpen: true, - errorMessage: '请选择图标素材规范', - }); - } - return; - } - if (!iconDescriptions.length) { - if (canvasDialog) { - setSubmittingIconDialog({ - ...canvasDialog, - status: 'failed', - composerOpen: true, - errorMessage: '请填写素材描述', - }); - } - return; - } - - if (!canvasDialog) { - return; - } - - setSubmittingIconDialog({ - ...canvasDialog, - iconDescriptions, - status: 'generating', - composerOpen: false, - errorMessage: undefined, - }); - - try { - const generated = await generateEditorIconSpritesheet({ - referenceImageSrc: dialog.iconSpecReference.src, - iconDescriptions, - }); - addIconSpritesheetResultLayers( - generated, - generated.iconImageSrcs, - buildIconGenerationInputs(iconDescriptions, dialog.iconSpecReference), - getGeneratingDialogPlaceholder(dialog), - canvasDialog.id, - ); - } catch (error) { - setSubmittingIconDialog({ - ...canvasDialog, - iconDescriptions, - status: 'failed', - composerOpen: true, - errorMessage: resolveImageGenerationErrorMessage(error), - }); - } - }; - - const submitQuickEdit = async () => { - if (!quickEditPanel || !quickEditSourceLayer) { - return; - } - - const normalizedPrompt = quickEditPanel.prompt.trim() || '快速编辑图片'; - setQuickEditPanel({ - ...quickEditPanel, - prompt: normalizedPrompt, - status: 'generating', - errorMessage: undefined, - }); - - try { - const referenceImageSrc = await resolveEditorImageReferenceDataUrl( - quickEditSourceLayer.src, - ); - const generated = await generateEditorImage({ - prompt: normalizedPrompt, - size: quickEditPanel.size, - kind: 'quick-edit', - model: quickEditPanel.model, - referenceImageSrcs: [referenceImageSrc], - }); - addQuickEditResultLayer( - generated, - quickEditSourceLayer, - buildEditGenerationInputs( - '快速编辑提示词', - normalizedPrompt, - quickEditSourceLayer, - ), - ); - } catch (error) { - setQuickEditPanel({ - ...quickEditPanel, - prompt: normalizedPrompt, - status: 'failed', - errorMessage: resolveImageGenerationErrorMessage(error), - }); - } - }; - - const submitImageGeneration = async (dialog: GenerateDialogState) => { - const normalizedPrompt = - dialog.prompt.trim() || - (dialog.mode === 'edit' ? '修改当前图片' : 'AI 生成图片'); - const canvasDialog = isCanvasGenerationDialog(dialog) ? dialog : null; - if (canvasDialog) { - updateCanvasGenerationDialogById(canvasDialog.id, (currentDialog) => ({ - ...currentDialog, - prompt: normalizedPrompt, - status: 'generating', - composerOpen: false, - })); - } else { - setGenerateDialog({ - ...dialog, - prompt: normalizedPrompt, - status: 'generating', - composerOpen: dialog.mode === 'edit', - }); - } - - try { - if (dialog.mode === 'edit') { - const sourceLayer = layers.find( - (layer) => layer.id === dialog.sourceLayerId, - ); - if (!sourceLayer) { - throw new Error('未找到要修改的图片'); - } - const referenceImageSrc = await resolveEditorImageReferenceDataUrl( - sourceLayer.src, - ); - const generated = await editEditorImage({ - prompt: normalizedPrompt, - sourceImageSrc: referenceImageSrc, - }); - addGeneratedResultLayer(generated, { - sourceLayer, - generationInputs: buildEditGenerationInputs( - '修改要求', - normalizedPrompt, - sourceLayer, - ), - }); - } else if (dialog.mode === 'spec') { - const specType = dialog.specType ?? 'custom'; - const specValues = - dialog.specValues ?? DEFAULT_SPEC_FORM_VALUES[specType]; - const specPrompt = buildSpecPrompt(specType, specValues); - const generated = await generateEditorImage({ - prompt: specPrompt, - size: SPEC_GENERATION_SIZE, - model: DEFAULT_IMAGE_MODEL, - kind: 'spec', - }); - addGeneratedResultLayer(generated, { - frame: getGeneratingDialogPlaceholder(dialog), - assetKind: specType === 'icon' ? 'icon-spec' : 'spec', - title: `${SPEC_TYPE_LABEL[specType]} ${layerCounterRef.current + 1}`, - dialogId: canvasDialog?.id, - generationInputs: buildSpecGenerationInputs(specType, specValues), - }); - } else if (dialog.mode === 'character') { - const referenceImageSrcs = [ - dialog.characterSpecReference?.src, - ...(dialog.characterReferences ?? []).map( - (reference) => reference.src, - ), - ].filter((src): src is string => Boolean(src)); - const generated = await generateEditorImage({ - prompt: normalizedPrompt, - kind: 'character', - ...(referenceImageSrcs.length ? { referenceImageSrcs } : {}), - }); - addGeneratedResultLayer(generated, { - frame: getGeneratingDialogPlaceholder(dialog), - assetKind: 'character', - title: `角色形象 ${layerCounterRef.current + 1}`, - dialogId: canvasDialog?.id, - generationInputs: buildCharacterGenerationInputs( - normalizedPrompt, - dialog.characterSpecReference, - dialog.characterReferences, - ), - }); - } else { - const generated = await generateEditorImage({ - prompt: normalizedPrompt, - }); - addGeneratedResultLayer(generated, { - frame: getGeneratingDialogPlaceholder(dialog), - dialogId: canvasDialog?.id, - generationInputs: buildImageGenerationInputs(normalizedPrompt), - }); - } - } catch (error) { - if (canvasDialog) { - updateCanvasGenerationDialogById(canvasDialog.id, () => ({ - ...canvasDialog, - prompt: normalizedPrompt, - status: 'failed', - composerOpen: true, - errorMessage: resolveImageGenerationErrorMessage(error), - })); - } else { - setGenerateDialog({ - ...dialog, - prompt: normalizedPrompt, - status: 'failed', - composerOpen: true, - errorMessage: resolveImageGenerationErrorMessage(error), - }); - } - } - }; - const handleNativeWheel = useCallback((event: WheelEvent) => { event.preventDefault(); const viewportElement = canvasViewportRef.current; @@ -2241,111 +1612,6 @@ export function ImageCanvasEditorView() { ); }; - const updateSpecFormValue = (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, - }; - }); - }; - - const updateCharacterAnimationDuration = (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, - ); - }; - - const submitCharacterAnimation = async () => { - if (!characterAnimationPanel || !characterAnimationSourceLayer) { - return; - } - const promptText = characterAnimationPanel.promptText.trim(); - const nextPanel = { - ...characterAnimationPanel, - promptText, - status: 'generating' as const, - errorMessage: undefined, - result: undefined, - }; - setCharacterAnimationPanel(nextPanel); - - try { - const result = await generateEditorCharacterAnimation({ - sourceLayerId: characterAnimationSourceLayer.id, - sourceImageSrc: resolveCharacterAnimationSourceImageSrc( - characterAnimationSourceLayer, - ), - sourceWidth: characterAnimationSourceLayer.originalWidth, - sourceHeight: characterAnimationSourceLayer.originalHeight, - promptText, - resolution: nextPanel.resolution, - ratio: nextPanel.ratio, - frameCount: nextPanel.frameCount, - durationSeconds: nextPanel.durationSeconds, - priceMudPoints: calculateCharacterAnimationPrice( - nextPanel.resolution, - nextPanel.durationSeconds, - ), - model: CHARACTER_ANIMATION_MODEL, - }); - setCharacterAnimationPanel((currentPanel) => - currentPanel - ? { - ...currentPanel, - status: 'completed', - result, - } - : currentPanel, - ); - } catch (error) { - setCharacterAnimationPanel((currentPanel) => - currentPanel - ? { - ...currentPanel, - status: 'failed', - errorMessage: - error instanceof Error && error.message.trim() - ? error.message - : '生成角色动画失败', - } - : currentPanel, - ); - } - }; - return (
vi.fn()); +const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn()); +const generateEditorIconSpritesheetMock = vi.hoisted(() => vi.fn()); +const editEditorImageMock = vi.hoisted(() => vi.fn()); + +vi.mock('../../services/image-editor/editorImageReference', () => ({ + resolveEditorImageReferenceDataUrl: vi.fn(async (src: string) => src), +})); + +vi.mock('../../services/image-editor/editorProjectClient', async () => { + const actual = await vi.importActual< + typeof import('../../services/image-editor/editorProjectClient') + >('../../services/image-editor/editorProjectClient'); + return { + ...actual, + editEditorImage: editEditorImageMock, + generateEditorCharacterAnimation: generateEditorCharacterAnimationMock, + generateEditorIconSpritesheet: generateEditorIconSpritesheetMock, + generateEditorImage: generateEditorImageMock, + }; +}); + +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, + }; +} + +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', + ...overrides, + }; +} + +function GenerationWorkflowHarness({ + initialLayers = [createLayer()], +}: { + initialLayers?: CanvasLayer[]; +}) { + const [layers, setLayers] = useState(initialLayers); + const [activeTool, setActiveTool] = useState('select'); + const [activeSidebarPanel, setActiveSidebarPanel] = + useState('assets'); + const [selectedLayerId, setSelectedLayerId] = useState(null); + const [metadataLayer, setMetadataLayer] = useState(null); + const [imageContextMenu, setImageContextMenu] = + useState({ + layerId: 'layer-source', + x: 1, + y: 2, + }); + const fitLayersMockRef = useRef(vi.fn()); + const layerCounterRef = useRef(0); + const dialogs = useCanvasGenerationDialogs(); + const workflow = useImageCanvasGenerationWorkflow({ + layers, + canvasSize: { width: 900, height: 640 }, + viewport: { x: 10, y: 20, scale: 2 }, + layerCounterRef, + generateDialog: dialogs.generateDialog, + setGenerateDialog: dialogs.setGenerateDialog, + openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog, + updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById, + removeCanvasGenerationDialogById: dialogs.removeCanvasGenerationDialogById, + removeCanvasGenerationDialogsByLayerId: + dialogs.removeCanvasGenerationDialogsByLayerId, + getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder, + appendCanvasLayersWithResources: (nextLayers) => + setLayers((currentLayers) => [...currentLayers, ...nextLayers]), + selectSingleLayer: setSelectedLayerId, + fitLayers: fitLayersMockRef.current, + setActiveTool, + setActiveSidebarPanel, + setMetadataLayer, + setImageContextMenu, + }); + + const activeDialog = dialogs.generateDialog; + const activeCanvasDialog = + activeDialog && 'id' in activeDialog + ? (activeDialog as CanvasGenerationDialogState) + : null; + + return ( +
+ {activeTool} + {activeSidebarPanel ?? '-'} + {selectedLayerId ?? '-'} + {metadataLayer?.id ?? '-'} + {imageContextMenu ? 'open' : '-'} + + {layers + .map( + (layer) => + `${layer.id}:${layer.title}:${layer.sourceResourceId ?? '-'}:${layer.assetKind ?? '-'}`, + ) + .join('|')} + + + {activeDialog + ? `${activeDialog.mode}:${activeDialog.status}:${activeDialog.composerOpen !== false ? 'open' : 'closed'}:${activeDialog.generatedLayerId ?? '-'}:${activeDialog.placeholder ? 'placeholder' : '-'}` + : '-'} + + + {workflow.quickEditPanel + ? `${workflow.quickEditPanel.sourceLayerId}:${workflow.quickEditPanel.status}:${workflow.quickEditPanel.prompt || '-'}` + : '-'} + + + {workflow.characterAnimationPanel + ? `${workflow.characterAnimationPanel.sourceLayerId}:${workflow.characterAnimationPanel.status}` + : '-'} + + + {fitLayersMockRef.current.mock.calls.length} + + + + + + + + + + + + + +
+ ); +} + +describe('useImageCanvasGenerationWorkflow', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('opens a movable canvas generation placeholder and keeps toolbar state active', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: '打开生成' })); + + expect(screen.getByTestId('tool').textContent).toBe('generate'); + expect(screen.getByTestId('selected').textContent).toBe('-'); + expect(screen.getByTestId('dialog').textContent).toBe( + 'generate:idle:open:-:placeholder', + ); + }); + + it('submits a normal generation, appends the generated layer, and keeps the composer anchored', async () => { + generateEditorImageMock.mockResolvedValueOnce( + createGenerated({ prompt: '一张生成图' }), + ); + render(); + + fireEvent.click(screen.getByRole('button', { name: '打开生成' })); + fireEvent.click(screen.getByRole('button', { name: '填写生成提示词' })); + fireEvent.click(screen.getByRole('button', { name: '提交生成' })); + + expect(generateEditorImageMock).toHaveBeenCalledWith({ + prompt: '一张生成图', + }); + expect(screen.getByTestId('dialog').textContent).toBe( + 'generate:generating:closed:-:placeholder', + ); + + await waitFor(() => { + expect(screen.getByTestId('layers').textContent).toContain( + 'layer-generated-1:生成图片 1', + ); + }); + expect(screen.getByTestId('sidebar').textContent).toBe('layers'); + expect(screen.getByTestId('selected').textContent).toBe( + 'layer-generated-1', + ); + expect(screen.getByTestId('dialog').textContent).toBe( + 'generate:idle:open:layer-generated-1:-', + ); + }); + + it('submits quick edits beside the source and fits source plus result', async () => { + generateEditorImageMock.mockResolvedValueOnce( + createGenerated({ prompt: '快速修图' }), + ); + render(); + + fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' })); + await waitFor(() => { + expect(screen.getByTestId('quick-edit').textContent).toBe( + 'layer-source:idle:-', + ); + }); + fireEvent.click(screen.getByRole('button', { name: '填写快速编辑' })); + await waitFor(() => { + expect(screen.getByTestId('quick-edit').textContent).toBe( + 'layer-source:idle:快速修图', + ); + }); + fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' })); + + await waitFor(() => { + expect(generateEditorImageMock).toHaveBeenCalledWith({ + prompt: '快速修图', + size: '1024x768', + kind: 'quick-edit', + model: 'gpt-image-2', + referenceImageSrcs: ['data:image/png;base64,source'], + }); + }); + + await waitFor(() => { + expect(screen.getByTestId('layers').textContent).toContain( + 'layer-quick-edit-1:源图 快速编辑:resource-source', + ); + }); + expect(screen.getByTestId('quick-edit').textContent).toBe('-'); + expect(screen.getByTestId('tool').textContent).toBe('select'); + expect(screen.getByTestId('fit-count').textContent).toBe('1'); + }); + + it('clears generation side panels and linked edit dialogs when a source layer is deleted', async () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' })); + await waitFor(() => { + expect(screen.getByTestId('quick-edit').textContent).toBe( + 'layer-source:idle:-', + ); + }); + fireEvent.click(screen.getByRole('button', { name: '打开修改状态' })); + fireEvent.click(screen.getByRole('button', { name: '清理源图状态' })); + + expect(screen.getByTestId('quick-edit').textContent).toBe('-'); + expect(screen.getByTestId('dialog').textContent).toBe('-'); + }); + + it('only opens the character animation panel for character layers', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: '打开普通动画' })); + expect(screen.getByTestId('character-animation').textContent).toBe('-'); + + fireEvent.click(screen.getByRole('button', { name: '打开角色动画' })); + expect(screen.getByTestId('character-animation').textContent).toBe( + 'layer-character:idle', + ); + expect(screen.getByTestId('image-context').textContent).toBe('-'); + }); + + it('hides non-generating canvas generation composers without deleting placeholders', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: '打开生成' })); + fireEvent.click(screen.getByRole('button', { name: '隐藏生成面板' })); + + expect(screen.getByTestId('dialog').textContent).toBe( + 'generate:idle:closed:-:placeholder', + ); + }); +}); diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.ts b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts new file mode 100644 index 00000000..f321d1d2 --- /dev/null +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.ts @@ -0,0 +1,1087 @@ +import { + useCallback, + useMemo, + useState, + type Dispatch, + type MutableRefObject, + type SetStateAction, +} from 'react'; + +import { resolveEditorImageReferenceDataUrl } from '../../services/image-editor/editorImageReference'; +import { + editEditorImage, + generateEditorCharacterAnimation, + generateEditorIconSpritesheet, + generateEditorImage, + type EditorIconSpritesheetGenerationResult, + type EditorIconSpritesheetIconResult, + type EditorImageGenerationResult, +} from '../../services/image-editor/editorProjectClient'; +import { + createGeneratedResultLayer, + createIconSpritesheetResultLayers, + createQuickEditResultLayer, +} from './ImageCanvasGenerationLayerModel'; +import { + CHARACTER_ANIMATION_DURATION_OPTIONS, + CHARACTER_ANIMATION_MODEL, + CHARACTER_FRAME_DISPLAY_SIZE, + CHARACTER_FRAME_ORIGINAL_SIZE, + DEFAULT_ICON_DESCRIPTIONS, + DEFAULT_IMAGE_MODEL, + DEFAULT_SPEC_FORM_VALUES, + ICON_DESCRIPTION_LIMIT, + ICON_FRAME_DISPLAY_SIZE, + ICON_FRAME_ORIGINAL_SIZE, + SPEC_FRAME_DISPLAY_SIZE, + SPEC_FRAME_ORIGINAL_SIZE, + SPEC_GENERATION_SIZE, + SPEC_TYPE_LABEL, + buildCharacterGenerationInputs, + buildEditGenerationInputs, + buildIconGenerationInputs, + buildImageGenerationInputs, + buildQuickEditModelOptions, + buildQuickEditSizeOptions, + buildSpecGenerationInputs, + buildSpecPrompt, + calculateCharacterAnimationPrice, + createCanvasLayerReference, + isCanvasGenerationDialog, + resolveCharacterAnimationSourceImageSrc, + resolveImageGenerationErrorMessage, +} from './ImageCanvasGenerationModel'; +import { formatImageSizeValue } from './ImageCanvasEditorModel'; +import type { + CanvasGenerationDialogState, + CanvasGenerationInputs, + CanvasLayer, + CanvasTool, + CanvasViewport, + CharacterAnimationPanelState, + GenerateDialogState, + ImageContextMenuState, + QuickEditPanelState, + SidebarPanel, + SpecFormValues, + SpecGenerationType, +} from './ImageCanvasEditorTypes'; + +type CanvasSize = { width: number; height: number }; + +type CanvasGenerationDialogUpdater = ( + dialog: CanvasGenerationDialogState, +) => CanvasGenerationDialogState | null; + +type GenerationWorkflowOptions = { + layers: CanvasLayer[]; + canvasSize: CanvasSize; + viewport: CanvasViewport; + layerCounterRef: MutableRefObject; + generateDialog: GenerateDialogState | null; + setGenerateDialog: Dispatch>; + openCanvasGenerationDialog: ( + dialog: Omit, + ) => void; + updateCanvasGenerationDialogById: ( + dialogId: string, + updater: CanvasGenerationDialogUpdater, + ) => void; + removeCanvasGenerationDialogById: (dialogId: string) => void; + removeCanvasGenerationDialogsByLayerId: (targetLayerId: string) => void; + getGeneratingDialogPlaceholder: ( + dialog: GenerateDialogState, + ) => GenerateDialogState['placeholder']; + appendCanvasLayersWithResources: (nextLayers: CanvasLayer[]) => void; + selectSingleLayer: (layerId: string | null) => void; + fitLayers: (targetLayers?: CanvasLayer[]) => void; + setActiveTool: Dispatch>; + setActiveSidebarPanel: Dispatch>; + setMetadataLayer: Dispatch>; + 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, + viewport, + layerCounterRef, + generateDialog, + setGenerateDialog, + openCanvasGenerationDialog, + updateCanvasGenerationDialogById, + removeCanvasGenerationDialogById, + removeCanvasGenerationDialogsByLayerId, + getGeneratingDialogPlaceholder, + appendCanvasLayersWithResources, + selectSingleLayer, + fitLayers, + setActiveTool, + setActiveSidebarPanel, + setMetadataLayer, + setImageContextMenu, +}: GenerationWorkflowOptions) { + const [isSpecMenuOpen, setIsSpecMenuOpen] = useState(false); + const [isCharacterSpecMenuOpen, setIsCharacterSpecMenuOpen] = + useState(false); + const [ + isPickingCharacterSpecFromCanvas, + setIsPickingCharacterSpecFromCanvas, + ] = useState(false); + const [isIconSpecMenuOpen, setIsIconSpecMenuOpen] = useState(false); + const [isPickingIconSpecFromCanvas, setIsPickingIconSpecFromCanvas] = + useState(false); + const [quickEditPanel, setQuickEditPanel] = + useState(null); + const [characterAnimationPanel, setCharacterAnimationPanel] = + useState(null); + + const quickEditSourceLayer = quickEditPanel + ? (layers.find((layer) => layer.id === quickEditPanel.sourceLayerId) ?? + null) + : null; + const characterAnimationSourceLayer = characterAnimationPanel + ? (layers.find( + (layer) => layer.id === characterAnimationPanel.sourceLayerId, + ) ?? null) + : null; + const quickEditSizeOptions = quickEditPanel + ? buildQuickEditSizeOptions(quickEditPanel.size) + : []; + const quickEditModelOptions = quickEditPanel + ? buildQuickEditModelOptions(quickEditPanel.model) + : []; + const characterAnimationPrice = characterAnimationPanel + ? calculateCharacterAnimationPrice( + characterAnimationPanel.resolution, + characterAnimationPanel.durationSeconds, + ) + : 0; + const iconDescriptionValues = + generateDialog?.mode === 'icon' + ? (generateDialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS) + : 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, + }, + }); + setActiveTool('generate'); + selectSingleLayer(null); + setQuickEditPanel(null); + }, [ + canvasSize, + openCanvasGenerationDialog, + selectSingleLayer, + setActiveTool, + viewport, + ]); + + 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, + }, + }); + setIsSpecMenuOpen(false); + setActiveTool('generate'); + selectSingleLayer(null); + setQuickEditPanel(null); + }, + [ + canvasSize, + openCanvasGenerationDialog, + selectSingleLayer, + setActiveTool, + viewport, + ], + ); + + const openCharacterAnimationPanel = useCallback( + (layer: CanvasLayer) => { + if (layer.assetKind !== 'character') { + return; + } + setImageContextMenu(null); + setQuickEditPanel(null); + setCharacterAnimationPanel({ + sourceLayerId: layer.id, + promptText: '', + resolution: '480p', + ratio: 'same', + frameCount: 32, + durationSeconds: 4, + status: 'idle', + }); + selectSingleLayer(layer.id); + }, + [selectSingleLayer, setImageContextMenu], + ); + + const openCharacterGenerationDialog = useCallback(() => { + const worldCenter = getViewportWorldCenter({ canvasSize, viewport }); + setIsSpecMenuOpen(false); + setIsPickingCharacterSpecFromCanvas(false); + openCanvasGenerationDialog({ + mode: 'character', + prompt: '', + status: 'idle', + composerOpen: true, + characterSpecReference: null, + characterReferences: [], + 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, + }, + }); + setActiveTool('character'); + selectSingleLayer(null); + setQuickEditPanel(null); + }, [ + canvasSize, + openCanvasGenerationDialog, + selectSingleLayer, + setActiveTool, + viewport, + ]); + + const openIconGenerationDialog = useCallback(() => { + const worldCenter = getViewportWorldCenter({ canvasSize, viewport }); + setIsSpecMenuOpen(false); + setIsPickingCharacterSpecFromCanvas(false); + setIsPickingIconSpecFromCanvas(false); + openCanvasGenerationDialog({ + mode: 'icon', + prompt: '', + status: 'idle', + composerOpen: true, + iconSpecReference: null, + iconDescriptions: [...DEFAULT_ICON_DESCRIPTIONS], + 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, + }, + }); + setActiveTool('icon'); + selectSingleLayer(null); + setQuickEditPanel(null); + setCharacterAnimationPanel(null); + }, [ + canvasSize, + openCanvasGenerationDialog, + selectSingleLayer, + setActiveTool, + viewport, + ]); + + const openEditDialog = useCallback( + (sourceLayer: CanvasLayer) => { + setMetadataLayer(null); + setImageContextMenu(null); + setQuickEditPanel(null); + setGenerateDialog({ + mode: 'edit', + prompt: sourceLayer.prompt + ? `${sourceLayer.prompt},在保持主体结构的基础上优化画面细节` + : '', + status: 'idle', + composerOpen: true, + sourceLayerId: sourceLayer.id, + }); + setActiveTool('generate'); + }, + [setActiveTool, setGenerateDialog, setImageContextMenu, setMetadataLayer], + ); + + const openQuickEditPanel = useCallback( + (sourceLayer: CanvasLayer) => { + setImageContextMenu(null); + 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', + }); + selectSingleLayer(sourceLayer.id); + setActiveTool('generate'); + }, + [ + selectSingleLayer, + setActiveTool, + setGenerateDialog, + setImageContextMenu, + setMetadataLayer, + ], + ); + + const addGeneratedResultLayer = useCallback( + ( + generated: EditorImageGenerationResult, + options: { + sourceLayer?: CanvasLayer; + frame?: GenerateDialogState['placeholder']; + assetKind?: CanvasLayer['assetKind']; + title?: string; + dialogId?: string; + generationInputs?: CanvasGenerationInputs; + } = {}, + ) => { + layerCounterRef.current += 1; + const generatedIndex = layerCounterRef.current; + const nextLayer = createGeneratedResultLayer({ + generated, + generatedIndex, + canvasSize, + viewport, + sourceLayer: options.sourceLayer, + frame: options.frame, + assetKind: options.assetKind, + title: options.title, + generationInputs: options.generationInputs, + }); + + appendCanvasLayersWithResources([nextLayer]); + selectSingleLayer(nextLayer.id); + setActiveSidebarPanel('layers'); + if (options.sourceLayer) { + setGenerateDialog(null); + setActiveTool('select'); + } else if (options.dialogId) { + updateCanvasGenerationDialogById(options.dialogId, (currentDialog) => + currentDialog.mode === 'character' || currentDialog.mode === 'icon' + ? null + : { + ...currentDialog, + status: 'idle', + composerOpen: true, + generatedLayerId: nextLayer.id, + placeholder: undefined, + errorMessage: undefined, + }, + ); + } + if (options.sourceLayer) { + fitLayers([options.sourceLayer, nextLayer]); + } + }, + [ + appendCanvasLayersWithResources, + canvasSize, + fitLayers, + layerCounterRef, + selectSingleLayer, + setActiveSidebarPanel, + setActiveTool, + setGenerateDialog, + updateCanvasGenerationDialogById, + viewport, + ], + ); + + const addQuickEditResultLayer = useCallback( + ( + generated: EditorImageGenerationResult, + sourceLayer: CanvasLayer, + generationInputs: CanvasGenerationInputs, + ) => { + layerCounterRef.current += 1; + const generatedIndex = layerCounterRef.current; + const nextLayer = createQuickEditResultLayer({ + generated, + generatedIndex, + sourceLayer, + generationInputs, + }); + + appendCanvasLayersWithResources([nextLayer]); + selectSingleLayer(nextLayer.id); + setActiveSidebarPanel('layers'); + setQuickEditPanel(null); + setActiveTool('select'); + fitLayers([sourceLayer, nextLayer]); + }, + [ + appendCanvasLayersWithResources, + fitLayers, + layerCounterRef, + selectSingleLayer, + setActiveSidebarPanel, + setActiveTool, + ], + ); + + const addIconSpritesheetResultLayers = useCallback( + ( + generated: EditorIconSpritesheetGenerationResult, + iconResults: EditorIconSpritesheetIconResult[], + generationInputs: CanvasGenerationInputs, + frame?: GenerateDialogState['placeholder'], + dialogId?: string, + ) => { + const startIndex = layerCounterRef.current + 1; + const nextLayers = createIconSpritesheetResultLayers({ + generated, + iconResults, + startIndex, + canvasSize, + viewport, + generationInputs, + frame, + }); + + if (!nextLayers.length) { + return; + } + layerCounterRef.current += nextLayers.length; + appendCanvasLayersWithResources(nextLayers); + selectSingleLayer(nextLayers[0]?.id ?? null); + setActiveSidebarPanel('layers'); + if (dialogId) { + removeCanvasGenerationDialogById(dialogId); + } + setActiveTool('select'); + }, + [ + appendCanvasLayersWithResources, + canvasSize, + layerCounterRef, + removeCanvasGenerationDialogById, + selectSingleLayer, + setActiveSidebarPanel, + setActiveTool, + viewport, + ], + ); + + const pickCharacterSpecFromLayer = useCallback( + (layer: CanvasLayer) => { + setGenerateDialog((currentDialog) => + currentDialog?.mode === 'character' + ? { + ...setFailedCharacterGenerationIdle(currentDialog), + characterSpecReference: createCanvasLayerReference(layer), + composerOpen: true, + } + : currentDialog, + ); + setIsPickingCharacterSpecFromCanvas(false); + setIsCharacterSpecMenuOpen(false); + setImageContextMenu(null); + }, + [setGenerateDialog, setImageContextMenu], + ); + + const pickIconSpecFromLayer = useCallback( + (layer: CanvasLayer) => { + 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); + }, + [setGenerateDialog, setImageContextMenu], + ); + + 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, + ); + }, + [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]); + + const submitIconSpritesheetGeneration = useCallback( + async (dialog: GenerateDialogState) => { + if (dialog.mode !== 'icon') { + return; + } + const canvasDialog = isCanvasGenerationDialog(dialog) ? dialog : null; + const setSubmittingIconDialog = ( + nextDialog: CanvasGenerationDialogState, + ) => { + updateCanvasGenerationDialogById(nextDialog.id, () => nextDialog); + }; + const iconDescriptions = ( + dialog.iconDescriptions ?? DEFAULT_ICON_DESCRIPTIONS + ) + .map((description) => description.trim()) + .filter(Boolean); + if (!dialog.iconSpecReference) { + if (canvasDialog) { + setSubmittingIconDialog({ + ...canvasDialog, + status: 'failed', + composerOpen: true, + errorMessage: '请选择图标素材规范', + }); + } + return; + } + if (!iconDescriptions.length) { + if (canvasDialog) { + setSubmittingIconDialog({ + ...canvasDialog, + status: 'failed', + composerOpen: true, + errorMessage: '请填写素材描述', + }); + } + return; + } + + if (!canvasDialog) { + return; + } + + setSubmittingIconDialog({ + ...canvasDialog, + iconDescriptions, + status: 'generating', + composerOpen: false, + errorMessage: undefined, + }); + + try { + const generated = await generateEditorIconSpritesheet({ + referenceImageSrc: dialog.iconSpecReference.src, + iconDescriptions, + }); + addIconSpritesheetResultLayers( + generated, + generated.iconImageSrcs, + buildIconGenerationInputs(iconDescriptions, dialog.iconSpecReference), + getGeneratingDialogPlaceholder(dialog), + canvasDialog.id, + ); + } catch (error) { + setSubmittingIconDialog({ + ...canvasDialog, + iconDescriptions, + status: 'failed', + composerOpen: true, + errorMessage: resolveImageGenerationErrorMessage(error), + }); + } + }, + [ + addIconSpritesheetResultLayers, + getGeneratingDialogPlaceholder, + updateCanvasGenerationDialogById, + ], + ); + + const submitQuickEdit = useCallback(async () => { + if (!quickEditPanel || !quickEditSourceLayer) { + return; + } + + const normalizedPrompt = quickEditPanel.prompt.trim() || '快速编辑图片'; + setQuickEditPanel({ + ...quickEditPanel, + prompt: normalizedPrompt, + status: 'generating', + errorMessage: undefined, + }); + + try { + const referenceImageSrc = await resolveEditorImageReferenceDataUrl( + quickEditSourceLayer.src, + ); + const generated = await generateEditorImage({ + prompt: normalizedPrompt, + size: quickEditPanel.size, + kind: 'quick-edit', + model: quickEditPanel.model, + referenceImageSrcs: [referenceImageSrc], + }); + addQuickEditResultLayer( + generated, + quickEditSourceLayer, + buildEditGenerationInputs( + '快速编辑提示词', + normalizedPrompt, + quickEditSourceLayer, + ), + ); + } catch (error) { + setQuickEditPanel({ + ...quickEditPanel, + prompt: normalizedPrompt, + status: 'failed', + errorMessage: resolveImageGenerationErrorMessage(error), + }); + } + }, [addQuickEditResultLayer, quickEditPanel, quickEditSourceLayer]); + + const submitImageGeneration = useCallback( + async (dialog: GenerateDialogState) => { + const normalizedPrompt = + dialog.prompt.trim() || + (dialog.mode === 'edit' ? '修改当前图片' : 'AI 生成图片'); + const canvasDialog = isCanvasGenerationDialog(dialog) ? dialog : null; + if (canvasDialog) { + updateCanvasGenerationDialogById(canvasDialog.id, (currentDialog) => ({ + ...currentDialog, + prompt: normalizedPrompt, + status: 'generating', + composerOpen: false, + })); + } else { + setGenerateDialog({ + ...dialog, + prompt: normalizedPrompt, + status: 'generating', + composerOpen: dialog.mode === 'edit', + }); + } + + try { + if (dialog.mode === 'edit') { + const sourceLayer = layers.find( + (layer) => layer.id === dialog.sourceLayerId, + ); + if (!sourceLayer) { + throw new Error('未找到要修改的图片'); + } + const referenceImageSrc = await resolveEditorImageReferenceDataUrl( + sourceLayer.src, + ); + const generated = await editEditorImage({ + prompt: normalizedPrompt, + sourceImageSrc: referenceImageSrc, + }); + addGeneratedResultLayer(generated, { + sourceLayer, + generationInputs: buildEditGenerationInputs( + '修改要求', + normalizedPrompt, + sourceLayer, + ), + }); + } else if (dialog.mode === 'spec') { + const specType = dialog.specType ?? 'custom'; + const specValues = + dialog.specValues ?? DEFAULT_SPEC_FORM_VALUES[specType]; + const specPrompt = buildSpecPrompt(specType, specValues); + const generated = await generateEditorImage({ + prompt: specPrompt, + size: SPEC_GENERATION_SIZE, + model: DEFAULT_IMAGE_MODEL, + kind: 'spec', + }); + addGeneratedResultLayer(generated, { + frame: getGeneratingDialogPlaceholder(dialog), + assetKind: specType === 'icon' ? 'icon-spec' : 'spec', + title: `${SPEC_TYPE_LABEL[specType]} ${layerCounterRef.current + 1}`, + dialogId: canvasDialog?.id, + generationInputs: buildSpecGenerationInputs(specType, specValues), + }); + } else if (dialog.mode === 'character') { + const referenceImageSrcs = [ + dialog.characterSpecReference?.src, + ...(dialog.characterReferences ?? []).map( + (reference) => reference.src, + ), + ].filter((src): src is string => Boolean(src)); + const generated = await generateEditorImage({ + prompt: normalizedPrompt, + kind: 'character', + ...(referenceImageSrcs.length ? { referenceImageSrcs } : {}), + }); + addGeneratedResultLayer(generated, { + frame: getGeneratingDialogPlaceholder(dialog), + assetKind: 'character', + title: `角色形象 ${layerCounterRef.current + 1}`, + dialogId: canvasDialog?.id, + generationInputs: buildCharacterGenerationInputs( + normalizedPrompt, + dialog.characterSpecReference, + dialog.characterReferences, + ), + }); + } else { + const generated = await generateEditorImage({ + prompt: normalizedPrompt, + }); + addGeneratedResultLayer(generated, { + frame: getGeneratingDialogPlaceholder(dialog), + dialogId: canvasDialog?.id, + generationInputs: buildImageGenerationInputs(normalizedPrompt), + }); + } + } catch (error) { + if (canvasDialog) { + updateCanvasGenerationDialogById(canvasDialog.id, () => ({ + ...canvasDialog, + prompt: normalizedPrompt, + status: 'failed', + composerOpen: true, + errorMessage: resolveImageGenerationErrorMessage(error), + })); + } else { + setGenerateDialog({ + ...dialog, + prompt: normalizedPrompt, + status: 'failed', + composerOpen: true, + errorMessage: resolveImageGenerationErrorMessage(error), + }); + } + } + }, + [ + addGeneratedResultLayer, + getGeneratingDialogPlaceholder, + layerCounterRef, + layers, + setGenerateDialog, + updateCanvasGenerationDialogById, + ], + ); + + 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], + ); + + 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, + ); + }, + [], + ); + + const submitCharacterAnimation = useCallback(async () => { + if (!characterAnimationPanel || !characterAnimationSourceLayer) { + return; + } + const promptText = characterAnimationPanel.promptText.trim(); + const nextPanel = { + ...characterAnimationPanel, + promptText, + status: 'generating' as const, + errorMessage: undefined, + result: undefined, + }; + setCharacterAnimationPanel(nextPanel); + + try { + const result = await generateEditorCharacterAnimation({ + sourceLayerId: characterAnimationSourceLayer.id, + sourceImageSrc: resolveCharacterAnimationSourceImageSrc( + characterAnimationSourceLayer, + ), + sourceWidth: characterAnimationSourceLayer.originalWidth, + sourceHeight: characterAnimationSourceLayer.originalHeight, + promptText, + resolution: nextPanel.resolution, + ratio: nextPanel.ratio, + frameCount: nextPanel.frameCount, + durationSeconds: nextPanel.durationSeconds, + priceMudPoints: calculateCharacterAnimationPrice( + nextPanel.resolution, + nextPanel.durationSeconds, + ), + model: CHARACTER_ANIMATION_MODEL, + }); + setCharacterAnimationPanel((currentPanel) => + currentPanel + ? { + ...currentPanel, + status: 'completed', + result, + } + : currentPanel, + ); + } catch (error) { + setCharacterAnimationPanel((currentPanel) => + currentPanel + ? { + ...currentPanel, + status: 'failed', + errorMessage: + error instanceof Error && error.message.trim() + ? error.message + : '生成角色动画失败', + } + : currentPanel, + ); + } + }, [characterAnimationPanel, characterAnimationSourceLayer]); + + const hideGeneratedLayerPanelAfterBlur = useCallback(() => { + setGenerateDialog((currentDialog) => + (currentDialog?.mode === 'generate' || + currentDialog?.mode === 'spec' || + currentDialog?.mode === 'character' || + currentDialog?.mode === 'icon') && + currentDialog.status !== 'generating' + ? { + ...currentDialog, + composerOpen: false, + } + : currentDialog, + ); + }, [setGenerateDialog]); + + const closeGenerateComposer = useCallback(() => { + setGenerateDialog((currentDialog) => + currentDialog?.mode === 'generate' + ? { + ...currentDialog, + composerOpen: false, + } + : currentDialog, + ); + setActiveTool('select'); + }, [setActiveTool, setGenerateDialog]); + + const clearDeletedLayerGenerationState = useCallback( + (targetLayerId: string) => { + setQuickEditPanel((currentPanel) => + currentPanel?.sourceLayerId === targetLayerId ? null : currentPanel, + ); + setCharacterAnimationPanel((currentPanel) => + currentPanel?.sourceLayerId === targetLayerId ? null : currentPanel, + ); + setGenerateDialog((currentDialog) => + currentDialog?.mode === 'edit' && + currentDialog.sourceLayerId === targetLayerId + ? null + : currentDialog, + ); + removeCanvasGenerationDialogsByLayerId(targetLayerId); + }, + [removeCanvasGenerationDialogsByLayerId, setGenerateDialog], + ); + + return useMemo( + () => ({ + quickEditPanel, + setQuickEditPanel, + quickEditSourceLayer, + quickEditSizeOptions, + quickEditModelOptions, + characterAnimationPanel, + setCharacterAnimationPanel, + characterAnimationSourceLayer, + characterAnimationPrice, + iconDescriptionValues, + isSpecMenuOpen, + setIsSpecMenuOpen, + isCharacterSpecMenuOpen, + setIsCharacterSpecMenuOpen, + isPickingCharacterSpecFromCanvas, + setIsPickingCharacterSpecFromCanvas, + isIconSpecMenuOpen, + setIsIconSpecMenuOpen, + isPickingIconSpecFromCanvas, + setIsPickingIconSpecFromCanvas, + openGenerateDialog, + openSpecDialog, + openCharacterAnimationPanel, + openCharacterGenerationDialog, + openIconGenerationDialog, + openEditDialog, + openQuickEditPanel, + pickCharacterSpecFromLayer, + pickIconSpecFromLayer, + submitIconSpritesheetGeneration, + submitQuickEdit, + submitImageGeneration, + updateSpecFormValue, + updateIconDescription, + addIconDescription, + updateCharacterAnimationDuration, + submitCharacterAnimation, + hideGeneratedLayerPanelAfterBlur, + closeGenerateComposer, + clearDeletedLayerGenerationState, + }), + [ + addIconDescription, + characterAnimationPanel, + characterAnimationPrice, + characterAnimationSourceLayer, + clearDeletedLayerGenerationState, + closeGenerateComposer, + hideGeneratedLayerPanelAfterBlur, + iconDescriptionValues, + isCharacterSpecMenuOpen, + isIconSpecMenuOpen, + isPickingCharacterSpecFromCanvas, + isPickingIconSpecFromCanvas, + isSpecMenuOpen, + openCharacterAnimationPanel, + openCharacterGenerationDialog, + openEditDialog, + openGenerateDialog, + openIconGenerationDialog, + openQuickEditPanel, + openSpecDialog, + pickCharacterSpecFromLayer, + pickIconSpecFromLayer, + quickEditModelOptions, + quickEditPanel, + quickEditSizeOptions, + quickEditSourceLayer, + submitCharacterAnimation, + submitIconSpritesheetGeneration, + submitImageGeneration, + submitQuickEdit, + updateCharacterAnimationDuration, + updateIconDescription, + updateSpecFormValue, + ], + ); +}