From 571b8be0be7a08b05f94ceb3cdb761084760b4c0 Mon Sep 17 00:00:00 2001 From: joshistoast Date: Wed, 8 Jul 2026 21:29:01 -0600 Subject: [PATCH 001/150] feat(canvas): new canvas --- invokeai/frontend/webv2/package.json | 3 + invokeai/frontend/webv2/pnpm-lock.yaml | 32 + .../frontend/webv2/public/locales/en.json | 371 +- .../webv2/src/workbench/WorkbenchRuntime.tsx | 17 +- .../webv2/src/workbench/backend/events.ts | 38 +- .../workbench/backend/progressImageStore.ts | 4 +- .../backend/queueCoordinator.test.ts | 32 +- .../backend/canvasImages.test.ts | 78 + .../canvas-engine/backend/canvasImages.ts | 111 + .../backend/utilityQueue.test.ts | 321 ++ .../canvas-engine/backend/utilityQueue.ts | 205 + .../src/workbench/canvas-engine/color.test.ts | 24 + .../src/workbench/canvas-engine/color.ts | 18 + .../document/bitmapStore.test.ts | 620 +++ .../canvas-engine/document/bitmapStore.ts | 486 ++ .../document/documentMirror.test.ts | 358 ++ .../canvas-engine/document/documentMirror.ts | 234 + .../canvas-engine/document/mergeDown.test.ts | 53 + .../canvas-engine/document/mergeDown.ts | 38 + .../document/mergeVisible.test.ts | 119 + .../canvas-engine/document/mergeVisible.ts | 100 + .../canvas-engine/document/sources.test.ts | 129 + .../canvas-engine/document/sources.ts | 208 + .../canvas-engine/engine.mask.test.ts | 352 ++ .../workbench/canvas-engine/engine.test.ts | 4725 +++++++++++++++++ .../src/workbench/canvas-engine/engine.ts | 3127 +++++++++++ .../canvas-engine/engineRegistry.test.ts | 113 + .../workbench/canvas-engine/engineRegistry.ts | 105 + .../workbench/canvas-engine/engineStores.ts | 582 ++ .../export/compositeForGeneration.test.ts | 379 ++ .../export/compositeForGeneration.ts | 536 ++ .../canvas-engine/export/psdExport.test.ts | 165 + .../canvas-engine/export/psdExport.ts | 399 ++ .../workbench/canvas-engine/freehand.test.ts | 85 + .../src/workbench/canvas-engine/freehand.ts | 122 + .../history/documentPatch.test.ts | 30 + .../canvas-engine/history/documentPatch.ts | 49 + .../canvas-engine/history/history.test.ts | 265 + .../canvas-engine/history/history.ts | 222 + .../canvas-engine/history/imagePatch.test.ts | 71 + .../canvas-engine/history/imagePatch.ts | 73 + .../input/pointerPipeline.test.ts | 427 ++ .../canvas-engine/input/pointerPipeline.ts | 353 ++ .../canvas-engine/input/wheel.test.ts | 90 + .../workbench/canvas-engine/input/wheel.ts | 71 + .../canvas-engine/math/mat2d.test.ts | 149 + .../src/workbench/canvas-engine/math/mat2d.ts | 95 + .../workbench/canvas-engine/math/rect.test.ts | 202 + .../src/workbench/canvas-engine/math/rect.ts | 167 + .../canvas-engine/math/snapping.test.ts | 141 + .../workbench/canvas-engine/math/snapping.ts | 110 + .../render/adjustedSurfaceCache.test.ts | 101 + .../render/adjustedSurfaceCache.ts | 105 + .../canvas-engine/render/adjustments.test.ts | 211 + .../canvas-engine/render/adjustments.ts | 239 + .../canvas-engine/render/colorSample.test.ts | 147 + .../canvas-engine/render/colorSample.ts | 94 + .../canvas-engine/render/compositor.test.ts | 455 ++ .../canvas-engine/render/compositor.ts | 377 ++ .../render/controlTransparency.test.ts | 193 + .../render/controlTransparency.ts | 60 + .../canvas-engine/render/fontLoader.test.ts | 86 + .../canvas-engine/render/fontLoader.ts | 85 + .../canvas-engine/render/layerCache.test.ts | 254 + .../canvas-engine/render/layerCache.ts | 338 ++ .../canvas-engine/render/maskFill.test.ts | 93 + .../canvas-engine/render/maskFill.ts | 153 + .../render/overlayRenderer.test.ts | 127 + .../canvas-engine/render/overlayRenderer.ts | 482 ++ .../render/raster.testStub.test.ts | 78 + .../canvas-engine/render/raster.testStub.ts | 178 + .../workbench/canvas-engine/render/raster.ts | 119 + .../rasterizers/gradientRasterizer.test.ts | 112 + .../render/rasterizers/gradientRasterizer.ts | 78 + .../render/rasterizers/imageRasterizer.ts | 62 + .../canvas-engine/render/rasterizers/index.ts | 54 + .../render/rasterizers/paintRasterizer.ts | 43 + .../rasterizers/rasterizeSource.test.ts | 169 + .../rasterizers/shapeRasterizer.test.ts | 111 + .../render/rasterizers/shapeRasterizer.ts | 79 + .../render/rasterizers/textRasterizer.test.ts | 133 + .../render/rasterizers/textRasterizer.ts | 136 + .../canvas-engine/render/rasterizers/types.ts | 46 + .../canvas-engine/render/scheduler.test.ts | 188 + .../canvas-engine/render/scheduler.ts | 163 + .../canvas-engine/render/thumbnail.test.ts | 30 + .../canvas-engine/render/thumbnail.ts | 33 + .../selection/marchingAnts.test.ts | 136 + .../canvas-engine/selection/marchingAnts.ts | 155 + .../selection/selectionOps.test.ts | 145 + .../canvas-engine/selection/selectionOps.ts | 114 + .../selection/selectionState.test.ts | 154 + .../canvas-engine/selection/selectionState.ts | 344 ++ .../canvas-engine/tools/bboxHitTest.test.ts | 178 + .../canvas-engine/tools/bboxHitTest.ts | 280 + .../canvas-engine/tools/bboxTool.test.ts | 232 + .../workbench/canvas-engine/tools/bboxTool.ts | 203 + .../canvas-engine/tools/brushTool.ts | 26 + .../tools/colorPickerTool.test.ts | 192 + .../canvas-engine/tools/colorPickerTool.ts | 74 + .../canvas-engine/tools/eraserTool.ts | 25 + .../canvas-engine/tools/gradientTool.test.ts | 221 + .../canvas-engine/tools/gradientTool.ts | 174 + .../canvas-engine/tools/lassoTool.test.ts | 138 + .../canvas-engine/tools/lassoTool.ts | 123 + .../canvas-engine/tools/moveHitTest.test.ts | 348 ++ .../canvas-engine/tools/moveHitTest.ts | 174 + .../canvas-engine/tools/moveTool.test.ts | 324 ++ .../workbench/canvas-engine/tools/moveTool.ts | 208 + .../tools/paintConstants.test.ts | 44 + .../canvas-engine/tools/paintConstants.ts | 24 + .../canvas-engine/tools/paintTool.test.ts | 344 ++ .../canvas-engine/tools/paintTool.ts | 209 + .../canvas-engine/tools/shapeTool.test.ts | 191 + .../canvas-engine/tools/shapeTool.ts | 159 + .../canvas-engine/tools/strokeSession.test.ts | 264 + .../canvas-engine/tools/strokeSession.ts | 277 + .../canvas-engine/tools/textTool.test.ts | 192 + .../workbench/canvas-engine/tools/textTool.ts | 132 + .../src/workbench/canvas-engine/tools/tool.ts | 201 + .../canvas-engine/tools/transformTool.test.ts | 440 ++ .../canvas-engine/tools/transformTool.ts | 297 ++ .../workbench/canvas-engine/tools/viewTool.ts | 59 + .../transform/transformMath.test.ts | 449 ++ .../canvas-engine/transform/transformMath.ts | 429 ++ .../src/workbench/canvas-engine/types.ts | 118 + .../workbench/canvas-engine/viewport.test.ts | 114 + .../src/workbench/canvas-engine/viewport.ts | 185 + .../src/workbench/canvasDimsSync.test.ts | 374 ++ .../webv2/src/workbench/canvasDimsSync.ts | 257 + .../src/workbench/canvasLayerOps.test.ts | 96 + .../webv2/src/workbench/canvasLayerOps.ts | 88 + .../src/workbench/canvasMigration.test.ts | 425 ++ .../webv2/src/workbench/canvasMigration.ts | 290 + .../workbench/components/ui/ColorPicker.tsx | 106 + .../src/workbench/components/ui/Toolbar.tsx | 1 - .../components/ui/colorPickerSync.test.ts | 31 + .../components/ui/colorPickerSync.ts | 18 + .../src/workbench/components/ui/index.ts | 1 + .../webv2/src/workbench/gallery/api.test.ts | 22 + .../webv2/src/workbench/gallery/api.ts | 56 +- .../webv2/src/workbench/generation/api.ts | 27 + .../canvas/addControlLayers.test.ts | 475 ++ .../generation/canvas/addControlLayers.ts | 203 + .../canvas/addRegionalGuidance.test.ts | 312 ++ .../generation/canvas/addRegionalGuidance.ts | 356 ++ .../generation/canvas/canvasMode.test.ts | 98 + .../workbench/generation/canvas/canvasMode.ts | 78 + .../canvas/compileCanvasGraph.test.ts | 723 +++ .../generation/canvas/compileCanvasGraph.ts | 544 ++ .../generation/canvas/compositePlan.test.ts | 488 ++ .../generation/canvas/compositePlan.ts | 396 ++ .../generation/canvas/filterGraphs.test.ts | 171 + .../generation/canvas/filterGraphs.ts | 194 + .../src/workbench/generation/canvas/types.ts | 225 + .../webv2/src/workbench/generation/graph.ts | 121 +- .../src/workbench/generation/graphBuilder.ts | 118 + .../graph-preview/GraphPreviewDialog.test.ts | 138 + .../graph-preview/GraphPreviewDialog.tsx | 76 +- .../hotkeys/WorkbenchHotkeyRuntime.test.ts | 76 +- .../hotkeys/WorkbenchHotkeyRuntime.tsx | 15 +- .../src/workbench/hotkeys/catalog.test.ts | 5 +- .../webv2/src/workbench/hotkeys/catalog.ts | 5 + .../workbench/hotkeys/firstPartyCommands.ts | 9 +- .../webv2/src/workbench/invocation.test.ts | 115 +- .../webv2/src/workbench/invocation.ts | 21 +- .../webv2/src/workbench/invocationSubmit.ts | 73 + .../workbench/projects/projectSync.test.ts | 1 - .../src/workbench/queueSubmission.test.ts | 60 + .../webv2/src/workbench/queueSubmission.ts | 45 + .../webv2/src/workbench/queueSummary.test.ts | 15 +- .../workbench/shell/topbar/InvokeControl.tsx | 9 +- .../frontend/webv2/src/workbench/types.ts | 245 +- .../workflow/editor => }/useModifierHeld.ts | 6 +- .../widgets/canvas/CanvasDocumentFrame.tsx | 109 - .../widgets/canvas/CanvasFloatingBar.tsx | 26 + .../widgets/canvas/CanvasHeaderActions.tsx | 352 ++ .../widgets/canvas/CanvasStagingControls.tsx | 228 - .../widgets/canvas/CanvasSurface.tsx | 126 + .../widgets/canvas/CanvasWidgetView.tsx | 466 +- .../workbench/widgets/canvas/StagingBar.tsx | 312 ++ .../widgets/canvas/TextEditPortal.tsx | 176 + .../workbench/widgets/canvas/ToolStrip.tsx | 74 + .../workbench/widgets/canvas/bboxGrid.test.ts | 27 + .../src/workbench/widgets/canvas/bboxGrid.ts | 30 + .../widgets/canvas/canvasSettings.test.ts | 97 + .../widgets/canvas/canvasSettings.ts | 156 + .../workbench/widgets/canvas/checkerColors.ts | 71 + .../widgets/canvas/engineStoreHooks.ts | 101 + .../workbench/widgets/canvas/fitBbox.test.ts | 137 + .../src/workbench/widgets/canvas/fitBbox.ts | 100 + .../src/workbench/widgets/canvas/index.ts | 2 + .../canvas/invoke/canvasCompositing.test.ts | 54 + .../canvas/invoke/canvasCompositing.ts | 133 + .../widgets/canvas/invoke/canvasStrength.ts | 39 + .../invoke/prepareCanvasInvocation.test.ts | 505 ++ .../canvas/invoke/prepareCanvasInvocation.ts | 526 ++ .../widgets/canvas/stagingPreview.test.ts | 101 + .../widgets/canvas/stagingPreview.ts | 90 + .../widgets/canvas/surfaceFocus.test.ts | 102 + .../workbench/widgets/canvas/surfaceFocus.ts | 52 + .../canvas/tool-options/BboxOptions.tsx | 231 + .../canvas/tool-options/BrushOptions.tsx | 124 + .../canvas/tool-options/EraserOptions.tsx | 100 + .../canvas/tool-options/GradientOptions.tsx | 248 + .../canvas/tool-options/LassoOptions.tsx | 91 + .../canvas/tool-options/MoveOptions.tsx | 105 + .../canvas/tool-options/ShapeOptions.tsx | 193 + .../canvas/tool-options/TextOptions.tsx | 261 + .../tool-options/ToolOptionsBar.test.ts | 37 + .../canvas/tool-options/ToolOptionsBar.tsx | 111 + .../canvas/tool-options/TransformOptions.tsx | 175 + .../widgets/canvas/useCanvasEngine.ts | 52 + .../widgets/canvas/zoomOptions.test.ts | 37 + .../workbench/widgets/canvas/zoomOptions.ts | 22 + .../GenerateCanvasCompositingSection.tsx | 202 + .../widgets/generate/GenerateSettingsForm.tsx | 3 + .../widgets/layers/AdjustmentsPopover.tsx | 433 ++ .../widgets/layers/ControlLayerSettings.tsx | 695 +++ .../widgets/layers/InpaintMaskSettings.tsx | 246 + .../widgets/layers/LayerContextMenu.tsx | 511 ++ .../widgets/layers/LayerGroupSection.tsx | 307 ++ .../widgets/layers/LayerListItem.tsx | 191 + .../widgets/layers/LayerPropertiesPopover.tsx | 208 + .../widgets/layers/LayerThumbnail.tsx | 78 + .../widgets/layers/LayersHeaderActions.tsx | 79 +- .../widgets/layers/LayersPanelHeader.tsx | 340 ++ .../widgets/layers/LayersWidgetView.tsx | 151 +- .../layers/RegionalGuidanceSettings.tsx | 681 +++ .../widgets/layers/addLayerMenu.test.ts | 42 + .../workbench/widgets/layers/addLayerMenu.ts | 48 + .../layers/controlFilterPreview.test.ts | 194 + .../widgets/layers/controlFilterPreview.ts | 134 + .../widgets/layers/layerGroupActions.test.ts | 121 + .../widgets/layers/layerGroupActions.ts | 92 + .../widgets/layers/layerGroups.test.ts | 170 + .../workbench/widgets/layers/layerGroups.ts | 165 + .../widgets/layers/layerMenuState.test.ts | 28 + .../widgets/layers/layerMenuState.ts | 16 + .../workbench/widgets/layers/layerOps.test.ts | 338 ++ .../src/workbench/widgets/layers/layerOps.ts | 377 ++ .../widgets/layers/layersDnd.test.ts | 23 + .../src/workbench/widgets/layers/layersDnd.ts | 18 + .../workbench/widgets/layers/useAddLayer.ts | 109 + .../widgets/layers/useSelectedModelBase.ts | 20 + .../workflow/editor/WorkflowEditorView.tsx | 2 +- .../src/workbench/workbenchState.test.ts | 813 ++- .../webv2/src/workbench/workbenchState.ts | 915 +++- invokeai/frontend/webv2/vite.config.mts | 12 + 249 files changed, 50430 insertions(+), 831 deletions(-) create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/backend/canvasImages.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/backend/canvasImages.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/backend/utilityQueue.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/backend/utilityQueue.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/color.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/color.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/document/bitmapStore.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/document/bitmapStore.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/document/documentMirror.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/document/documentMirror.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/document/mergeDown.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/document/mergeDown.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/document/mergeVisible.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/document/mergeVisible.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/document/sources.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/document/sources.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/engine.mask.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/engine.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/engine.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/engineRegistry.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/engineRegistry.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/engineStores.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/export/compositeForGeneration.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/export/compositeForGeneration.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/export/psdExport.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/export/psdExport.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/freehand.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/freehand.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/history/documentPatch.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/history/documentPatch.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/history/history.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/history/history.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/history/imagePatch.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/history/imagePatch.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/input/pointerPipeline.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/input/pointerPipeline.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/input/wheel.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/input/wheel.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/math/mat2d.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/math/mat2d.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/math/rect.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/math/rect.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/math/snapping.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/math/snapping.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/render/adjustedSurfaceCache.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/render/adjustedSurfaceCache.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/render/adjustments.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/render/adjustments.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/render/colorSample.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/render/colorSample.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/render/compositor.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/render/compositor.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/render/controlTransparency.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/render/controlTransparency.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/render/fontLoader.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/render/fontLoader.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/render/layerCache.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/render/layerCache.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/render/maskFill.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/render/maskFill.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/render/overlayRenderer.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/render/overlayRenderer.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/render/raster.testStub.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/render/raster.testStub.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/render/raster.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/gradientRasterizer.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/gradientRasterizer.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/imageRasterizer.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/index.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/paintRasterizer.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/rasterizeSource.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/shapeRasterizer.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/shapeRasterizer.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/textRasterizer.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/textRasterizer.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/types.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/render/scheduler.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/render/scheduler.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/render/thumbnail.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/render/thumbnail.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/selection/marchingAnts.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/selection/marchingAnts.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/selection/selectionOps.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/selection/selectionOps.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/selection/selectionState.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/selection/selectionState.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/tools/bboxHitTest.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/tools/bboxHitTest.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/tools/bboxTool.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/tools/bboxTool.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/tools/brushTool.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/tools/colorPickerTool.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/tools/colorPickerTool.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/tools/eraserTool.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/tools/gradientTool.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/tools/gradientTool.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/tools/lassoTool.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/tools/lassoTool.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/tools/moveHitTest.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/tools/moveHitTest.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/tools/moveTool.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/tools/moveTool.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/tools/paintConstants.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/tools/paintConstants.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/tools/paintTool.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/tools/paintTool.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/tools/shapeTool.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/tools/shapeTool.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/tools/strokeSession.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/tools/strokeSession.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/tools/textTool.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/tools/textTool.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/tools/tool.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/tools/transformTool.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/tools/transformTool.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/tools/viewTool.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/transform/transformMath.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/transform/transformMath.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/types.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/viewport.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/viewport.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvasDimsSync.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvasDimsSync.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvasLayerOps.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvasLayerOps.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvasMigration.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvasMigration.ts create mode 100644 invokeai/frontend/webv2/src/workbench/components/ui/ColorPicker.tsx create mode 100644 invokeai/frontend/webv2/src/workbench/components/ui/colorPickerSync.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/components/ui/colorPickerSync.ts create mode 100644 invokeai/frontend/webv2/src/workbench/gallery/api.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/generation/canvas/addControlLayers.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/generation/canvas/addControlLayers.ts create mode 100644 invokeai/frontend/webv2/src/workbench/generation/canvas/addRegionalGuidance.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/generation/canvas/addRegionalGuidance.ts create mode 100644 invokeai/frontend/webv2/src/workbench/generation/canvas/canvasMode.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/generation/canvas/canvasMode.ts create mode 100644 invokeai/frontend/webv2/src/workbench/generation/canvas/compileCanvasGraph.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/generation/canvas/compileCanvasGraph.ts create mode 100644 invokeai/frontend/webv2/src/workbench/generation/canvas/compositePlan.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/generation/canvas/compositePlan.ts create mode 100644 invokeai/frontend/webv2/src/workbench/generation/canvas/filterGraphs.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/generation/canvas/filterGraphs.ts create mode 100644 invokeai/frontend/webv2/src/workbench/generation/canvas/types.ts create mode 100644 invokeai/frontend/webv2/src/workbench/generation/graphBuilder.ts create mode 100644 invokeai/frontend/webv2/src/workbench/graph-preview/GraphPreviewDialog.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/invocationSubmit.ts create mode 100644 invokeai/frontend/webv2/src/workbench/queueSubmission.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/queueSubmission.ts rename invokeai/frontend/webv2/src/workbench/{widgets/workflow/editor => }/useModifierHeld.ts (79%) delete mode 100644 invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasDocumentFrame.tsx create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasFloatingBar.tsx create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasHeaderActions.tsx delete mode 100644 invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasStagingControls.tsx create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasSurface.tsx create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/canvas/StagingBar.tsx create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/canvas/TextEditPortal.tsx create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/canvas/ToolStrip.tsx create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/canvas/bboxGrid.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/canvas/bboxGrid.ts create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasSettings.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasSettings.ts create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/canvas/checkerColors.ts create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/canvas/engineStoreHooks.ts create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/canvas/fitBbox.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/canvas/fitBbox.ts create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/canvasCompositing.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/canvasCompositing.ts create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/canvasStrength.ts create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.ts create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/canvas/stagingPreview.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/canvas/stagingPreview.ts create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/canvas/surfaceFocus.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/canvas/surfaceFocus.ts create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/BboxOptions.tsx create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/BrushOptions.tsx create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/EraserOptions.tsx create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/GradientOptions.tsx create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/LassoOptions.tsx create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/MoveOptions.tsx create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/ShapeOptions.tsx create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/TextOptions.tsx create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/ToolOptionsBar.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/ToolOptionsBar.tsx create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/TransformOptions.tsx create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/canvas/useCanvasEngine.ts create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/canvas/zoomOptions.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/canvas/zoomOptions.ts create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/generate/GenerateCanvasCompositingSection.tsx create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/layers/AdjustmentsPopover.tsx create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/layers/ControlLayerSettings.tsx create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/layers/InpaintMaskSettings.tsx create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/layers/LayerContextMenu.tsx create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/layers/LayerGroupSection.tsx create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/layers/LayerListItem.tsx create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/layers/LayerPropertiesPopover.tsx create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/layers/LayerThumbnail.tsx create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/layers/LayersPanelHeader.tsx create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/layers/RegionalGuidanceSettings.tsx create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/layers/addLayerMenu.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/layers/addLayerMenu.ts create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/layers/controlFilterPreview.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/layers/controlFilterPreview.ts create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/layers/layerGroupActions.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/layers/layerGroupActions.ts create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/layers/layerGroups.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/layers/layerGroups.ts create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/layers/layerMenuState.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/layers/layerMenuState.ts create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/layers/layerOps.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/layers/layerOps.ts create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/layers/layersDnd.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/layers/layersDnd.ts create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/layers/useAddLayer.ts create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/layers/useSelectedModelBase.ts diff --git a/invokeai/frontend/webv2/package.json b/invokeai/frontend/webv2/package.json index f401b2ffd90..90db66d5b21 100644 --- a/invokeai/frontend/webv2/package.json +++ b/invokeai/frontend/webv2/package.json @@ -30,9 +30,12 @@ "@tanstack/react-router": "^1.170.16", "@tanstack/react-virtual": "^3.14.3", "@xyflow/react": "^12.11.1", + "ag-psd": "^31.0.1", + "fflate": "^0.8.3", "i18next": "^25.7.3", "i18next-http-backend": "^3.0.2", "lucide-react": "^1.21.0", + "perfect-freehand": "^1.2.3", "react": "^19.2.7", "react-dom": "^19.2.7", "react-hook-tanstack-virtual": "^0.0.4", diff --git a/invokeai/frontend/webv2/pnpm-lock.yaml b/invokeai/frontend/webv2/pnpm-lock.yaml index 8616726e6bc..ca505a02e86 100644 --- a/invokeai/frontend/webv2/pnpm-lock.yaml +++ b/invokeai/frontend/webv2/pnpm-lock.yaml @@ -38,6 +38,12 @@ importers: '@xyflow/react': specifier: ^12.11.1 version: 12.11.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + ag-psd: + specifier: ^31.0.1 + version: 31.0.1 + fflate: + specifier: ^0.8.3 + version: 0.8.3 i18next: specifier: ^25.7.3 version: 25.10.10(typescript@6.0.3) @@ -47,6 +53,9 @@ importers: lucide-react: specifier: ^1.21.0 version: 1.21.0(react@19.2.7) + perfect-freehand: + specifier: ^1.2.3 + version: 1.2.3 react: specifier: ^19.2.7 version: 19.2.7 @@ -1200,6 +1209,9 @@ packages: '@zag-js/utils@1.41.2': resolution: {integrity: sha512-Yj8FSrR7vGA6ahUhjrThfHAF+PM2Y1Yv2lkXkqZZd60mPBhixcot1+SHOfEMV63JimQcWrmQ8QbeYYMmF+ZpLQ==} + ag-psd@31.0.1: + resolution: {integrity: sha512-/byWVGIrKv0Jqq3lleaRji2QdrCsAhbG4Ansc9nWkH1ghOBbujnhdlRXMfK2A750mInXbPLsgpHhrtfq9lLWkA==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -1211,6 +1223,9 @@ packages: babel-plugin-react-compiler@1.0.0: resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.10.40: resolution: {integrity: sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==} engines: {node: '>=6.0.0'} @@ -1354,6 +1369,9 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + find-root@1.1.0: resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} @@ -1556,6 +1574,9 @@ packages: vite-plus: optional: true + pako@2.1.0: + resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -3232,6 +3253,11 @@ snapshots: '@zag-js/utils@1.41.2': {} + ag-psd@31.0.1: + dependencies: + base64-js: 1.5.1 + pako: 2.1.0 + assertion-error@2.0.1: {} babel-plugin-macros@3.1.0: @@ -3244,6 +3270,8 @@ snapshots: dependencies: '@babel/types': 7.29.7 + base64-js@1.5.1: {} + baseline-browser-mapping@2.10.40: {} browserslist@4.28.4: @@ -3394,6 +3422,8 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fflate@0.8.3: {} + find-root@1.1.0: {} fsevents@2.3.3: @@ -3569,6 +3599,8 @@ snapshots: '@oxlint/binding-win32-ia32-msvc': 1.71.0 '@oxlint/binding-win32-x64-msvc': 1.71.0 + pako@2.1.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index 1a35799f95d..f048cab5da5 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -634,12 +634,15 @@ "clipSkip": "CLIP skip", "colorCompensation": "Color compensation", "components": "Components", + "compositing": "Compositing", "concepts": "Concepts", "conceptWeight": "{{name}} weight", "compatibleEmbeddings": "Compatible embeddings", "couldNotExpandPrompt": "Could not expand the prompt.", "couldNotGeneratePromptFromImage": "Could not generate a prompt from the selected image.", "deletePromptHistoryItem": "Delete prompt history item", + "denoisingStrength": "Denoising strength", + "denoisingStrengthHelp": "How much the generation reworks the existing canvas content. Only applies to image-to-image (when content fills the generation frame).", "dimensions": "Dimensions", "disableConcept": "Disable {{name}}", "enableNegativePrompt": "Enable negative prompt", @@ -746,7 +749,26 @@ "weight": "Weight", "width": "Width", "xAxis": "X axis", - "yAxis": "Y axis" + "yAxis": "Y axis", + "compositingOptions": { + "infillMethod": "Infill method", + "infillMethods": { + "patchmatch": "PatchMatch", + "lama": "LaMa", + "cv2": "OpenCV", + "color": "Solid color", + "tile": "Tile" + }, + "coherenceMode": "Coherence pass mode", + "coherenceModes": { + "Gaussian Blur": "Gaussian Blur", + "Box Blur": "Box Blur", + "Staged": "Staged" + }, + "coherenceEdgeSize": "Coherence edge size", + "coherenceMinDenoise": "Coherence min denoise", + "maskBlur": "Mask blur" + } }, "graph": { "setSource": "Set Source", @@ -887,23 +909,162 @@ "acceptToLayer": "Accept to Layer", "candidateCount": "Candidate {{current}} of {{total}}", "commands": { + "decreaseBrushSize": "Decrease brush/eraser size", + "deleteLayer": "Delete layer", "deleteSelected": "Delete selected canvas item", + "deselect": "Deselect", + "duplicateLayer": "Duplicate layer", + "fitBbox": "Fit generation frame", + "increaseBrushSize": "Increase brush/eraser size", + "invertSelection": "Invert selection", + "layerBackward": "Send layer backward", + "layerForward": "Bring layer forward", + "layerToBack": "Send layer to back", + "layerToFront": "Bring layer to front", + "mergeDown": "Merge layer down", "nextEntity": "Next canvas entity", + "nudgeDown": "Nudge layer down", + "nudgeDownLarge": "Nudge layer down 10px", + "nudgeLeft": "Nudge layer left", + "nudgeLeftLarge": "Nudge layer left 10px", + "nudgeRight": "Nudge layer right", + "nudgeRightLarge": "Nudge layer right 10px", + "nudgeUp": "Nudge layer up", + "nudgeUpLarge": "Nudge layer up 10px", "previousEntity": "Previous canvas entity", "redo": "Redo canvas edit", + "reorderLayer": "Reorder layer", + "selectAll": "Select all", + "selectBboxTool": "Select generation frame tool", + "selectBrushTool": "Select brush tool", + "selectEraserTool": "Select eraser tool", + "selectGradientTool": "Select gradient tool", + "selectLassoTool": "Select lasso tool", + "selectMoveTool": "Select move tool", + "selectShapeTool": "Select shape tool", + "selectTextTool": "Select text tool", + "selectTransformTool": "Select transform tool", + "selectViewTool": "Select view tool", "undo": "Undo canvas edit" }, - "emptyLayerStack": "Canvas layer stack is empty.", - "emptyStaging": "Invoke Generate to Canvas to stage results here.", + "controls": { + "fitBboxToLayers": "Fit frame to layers", + "fitBboxToMasks": "Fit frame to masks", + "fitToView": "Fit to view", + "frameSize": "Generation frame size", + "newCanvas": "New canvas", + "newCanvasConfirm": "Clear all layers and start a fresh canvas? This cannot be undone.", + "newSession": "New session", + "zoomLevel": "Zoom level" + }, "hideStagedResultPreview": "Hide staged result preview", "hideStagingThumbnails": "Hide staging thumbnails", "nextStagedCandidate": "Next staged candidate", "previousStagedCandidate": "Previous staged candidate", "selectStagedCandidate": "Select staged candidate {{number}}", + "settings": { + "bboxOverlay": "Show bbox overlay", + "checkerboard": "Checkerboard background", + "clearCaches": "Clear caches", + "clearHistory": "Clear history", + "grid": "Show grid", + "invertBrushScroll": "Invert scroll for brush size", + "label": "Canvas settings", + "logDebugInfo": "Log debug info", + "ruleOfThirds": "Rule of thirds", + "sections": { + "behavior": "Behavior", + "debug": "Debug", + "display": "Display", + "grid": "Grid" + }, + "showBbox": "Show generation frame", + "showProgressOnCanvas": "Show progress on canvas", + "snapToGrid": "Snap to grid" + }, "showStagedResultPreview": "Show staged result preview", "showStagingThumbnails": "Show staging thumbnails", - "stagingPreviewAlt": "Staging preview {{name}}", - "surface": "Canvas surface" + "staging": { + "autoSwitch": "Auto-switch", + "autoSwitchLatest": "Newest", + "autoSwitchOff": "Off", + "autoSwitchOldest": "Oldest", + "generating": "Generating…", + "saveError": "Couldn't save to gallery", + "saveToGallery": "Save to gallery", + "saved": "Saved to gallery", + "savedDescription": "{{name}} added to your gallery." + }, + "surface": "Canvas surface", + "toolOptions": { + "applyTransform": "Apply", + "aspectRatio": "Aspect ratio", + "brushColor": "Brush color", + "brushSize": "Brush size", + "cancelTransform": "Cancel", + "deselect": "Deselect", + "eraseSelection": "Erase", + "eraserSize": "Eraser size", + "fillSelection": "Fill", + "frameHeight": "H", + "frameWidth": "W", + "gradientAngle": "Angle", + "gradientEdit": "Edit gradient", + "gradientEnd": "End", + "gradientEndOpacity": "End opacity", + "gradientKind": "Gradient type", + "gradientLinear": "Linear", + "gradientRadial": "Radial", + "gradientStart": "Start", + "gradientStartOpacity": "Start opacity", + "invertSelection": "Invert", + "lassoHint": "Draw a shape to select. Shift adds, Alt subtracts.", + "lockAspect": "Lock aspect ratio", + "movePosition": "Move layer", + "opacity": "Opacity", + "positionX": "X", + "positionY": "Y", + "pressureSensitivity": "Pressure sensitivity", + "rotation": "Rotation", + "scaleHeight": "H %", + "scaleWidth": "W %", + "selectionAdd": "Add", + "selectionIntersect": "Intersect", + "selectionMode": "Selection mode", + "selectionReplace": "Replace", + "selectionSubtract": "Subtract", + "setFrame": "Set generation frame", + "shapeEdit": "Edit shape", + "shapeEllipse": "Ellipse", + "shapeFill": "Fill", + "shapeHint": "Drag to draw a shape.", + "shapeKind": "Shape type", + "shapeRect": "Rectangle", + "shapeStroke": "Stroke", + "shapeStrokeWidth": "Stroke width", + "textAlignCenter": "Align center", + "textAlignLeft": "Align left", + "textAlignRight": "Align right", + "textColor": "Text color", + "textEdit": "Edit text", + "textFont": "Font", + "textLineHeight": "Line height", + "textSize": "Font size", + "textWeight": "Weight" + }, + "tools": { + "bbox": "Generation frame", + "brush": "Brush", + "eraser": "Eraser", + "gradient": "Gradient", + "label": "Tools", + "lasso": "Lasso select", + "move": "Move", + "shape": "Shape", + "text": "Text", + "transform": "Transform", + "view": "View" + } }, "workflow": { "about": "About", @@ -956,13 +1117,201 @@ "untitled": "Untitled Workflow" }, "layers": { - "empty": "Accepted canvas layers will appear here.", - "eventsCount": "Events: {{count}}", - "graphHistoryCount": "Graph history: {{count}}", + "actions": { + "addControlLayer": "Control layer", + "addInpaintMask": "Inpaint mask", + "addRasterLayer": "Raster layer", + "addRegionalGuidance": "Regional guidance", + "addRegionalReferenceImage": "Regional reference image", + "blendMode": "Blend mode", + "convertToControl": "Convert to control layer", + "convertToRaster": "Convert to raster layer", + "delete": "Delete layer", + "duplicate": "Duplicate layer", + "hide": "Hide layer", + "lock": "Lock layer", + "mergeDown": "Merge down", + "moveBackward": "Move backward", + "moveForward": "Move forward", + "moveToBack": "Move to back", + "moveToFront": "Move to front", + "opacity": "Opacity", + "rasterize": "Rasterize layer", + "rename": "Rename layer", + "reorder": "Reorder layer", + "show": "Show layer", + "toggleLock": "Toggle lock", + "toggleVisibility": "Toggle visibility", + "unlock": "Unlock layer" + }, + "control": { + "apply": "Apply", + "applyFilter": "Apply filter", + "beginStep": "Begin step", + "cancel": "Cancel", + "endStep": "End step", + "filter": "Filter", + "filterParams": { + "coarse": "Coarse", + "distance_threshold": "Distance threshold", + "draw_body": "Draw body", + "draw_face": "Draw face", + "draw_hands": "Draw hands", + "high_threshold": "High threshold", + "low_threshold": "Low threshold", + "max_faces": "Max faces", + "min_confidence": "Min confidence", + "model_size": "Model size", + "quantize_edges": "Quantize edges", + "scale_factor": "Scale factor", + "score_threshold": "Score threshold", + "scribble": "Scribble", + "tile_size": "Tile size" + }, + "filters": { + "canny_edge_detection": "Canny", + "color_map": "Color map", + "content_shuffle": "Content shuffle", + "depth_anything_depth_estimation": "Depth Anything", + "dw_openpose_detection": "DW Openpose", + "hed_edge_detection": "HED", + "lineart_edge_detection": "Lineart", + "mediapipe_face_detection": "Mediapipe Face", + "mlsd_detection": "MLSD", + "pidi_edge_detection": "PiDiNet" + }, + "kind": "Adapter type", + "kinds": { + "control_lora": "Control LoRA", + "controlnet": "ControlNet", + "t2i_adapter": "T2I Adapter" + }, + "mode": "Control mode", + "model": "Model", + "modes": { + "balanced": "Balanced", + "more_control": "More control", + "more_prompt": "More prompt", + "unbalanced": "Unbalanced" + }, + "preview": "Preview filter", + "selectModel": "Select a model", + "stepRange": "Step range", + "transparencyEffect": "Transparency effect", + "weight": "Weight" + }, + "addLayer": "Add layer", + "adjustments": { + "brightness": "Brightness", + "channel": "Channel", + "channels": { + "b": "Blue", + "g": "Green", + "r": "Red" + }, + "contrast": "Contrast", + "curves": "Curves", + "curvesHint": "Drag points to bend the curve. Double-click empty space to add a point; double-click a point to remove it.", + "reset": "Reset adjustments", + "saturation": "Saturation", + "title": "Adjustments", + "transparencyLock": "Lock transparency" + }, + "blendModes": { + "color": "Color", + "color-burn": "Color burn", + "color-dodge": "Color dodge", + "darken": "Darken", + "difference": "Difference", + "exclusion": "Exclusion", + "hard-light": "Hard light", + "hue": "Hue", + "lighten": "Lighten", + "luminosity": "Luminosity", + "multiply": "Multiply", + "normal": "Normal", + "overlay": "Overlay", + "saturation": "Saturation", + "screen": "Screen", + "soft-light": "Soft light" + }, + "denoisingStrength": "Denoising strength", + "denoisingStrengthHelp": "How much the canvas image is changed. Lower keeps more of the original; higher allows bigger changes.", + "empty": "No layers yet. Add a layer or paint on the canvas to get started.", + "groupActions": { + "collapse": "Collapse group", + "expand": "Expand group", + "exportNothing": "No raster layer content to export", + "exportNotReady": "Layers are still loading — try exporting again in a moment", + "exportPsd": "Export to PSD", + "exportTooLarge": "Canvas is too large to export to PSD", + "hideAll": "Hide all", + "mergeNotReady": "Layers are still loading — try merging again in a moment", + "mergeVisible": "Merge visible", + "new": "New layer", + "showAll": "Show all", + "toggleVisibility": "Toggle group visibility" + }, + "groups": { + "control": "Control Layers", + "inpaint_mask": "Inpaint Masks", + "raster": "Raster Layers", + "regional_guidance": "Regional Guidance" + }, + "maskFill": { + "color": "Fill color", + "denoiseLimit": "Denoise limit", + "fill": "Fill", + "invert": "Invert mask", + "noiseLevel": "Noise level", + "style": "Fill style", + "styles": { + "crosshatch": "Crosshatch", + "diagonal": "Diagonal", + "grid": "Grid", + "horizontal": "Horizontal", + "solid": "Solid", + "vertical": "Vertical" + } + }, + "menuGroups": { + "layers": "Layers", + "regional": "Regional" + }, "options": "Layer options", - "projectContracts": "Project Contracts", - "summary": "{{layers}} layers, {{stagedImages}} staged images.", - "undoRedoCount": "Undo: {{undo}} / Redo: {{redo}}" + "properties": "Layer properties", + "regionalGuidance": { + "addReferenceImage": "Add reference image", + "autoNegative": "Auto-Negative", + "clearReferenceImage": "Clear reference image", + "method": "Method", + "methods": { + "composition": "Composition", + "full": "Full", + "style": "Style", + "style_precise": "Style (precise)", + "style_strong": "Style (strong)" + }, + "model": "Model", + "negativePrompt": "Negative prompt", + "negativePromptPlaceholder": "Region negative prompt", + "positivePrompt": "Positive prompt", + "positivePromptPlaceholder": "Region positive prompt", + "referenceImage": "Reference image", + "referenceImageHelp": "Upload or drag an image from the gallery.", + "referenceImages": "Reference images", + "removeReferenceImage": "Remove reference image", + "selectModel": "Select a model", + "setReferenceImage": "Set reference image", + "weight": "Weight" + }, + "types": { + "control": "Control", + "image": "Image", + "inpaint_mask": "Inpaint", + "paint": "Paint", + "regional_guidance": "Region" + } }, "gallery": { "alwaysShowDimensions": "Always show dimensions", diff --git a/invokeai/frontend/webv2/src/workbench/WorkbenchRuntime.tsx b/invokeai/frontend/webv2/src/workbench/WorkbenchRuntime.tsx index ba12396dcd8..8510663b381 100644 --- a/invokeai/frontend/webv2/src/workbench/WorkbenchRuntime.tsx +++ b/invokeai/frontend/webv2/src/workbench/WorkbenchRuntime.tsx @@ -12,11 +12,13 @@ import { type ReconcileInput, } from './backend/queueCoordinator'; import { socketHub } from './backend/socketHub'; +import { createCanvasDimsSync } from './canvasDimsSync'; import { configureDiagnostics } from './diagnostics/logger'; import { addImagesToGalleryBoard } from './gallery/api'; import { getQueueItemResultImages, resumeQueueProcessor } from './generation/api'; import { sanitizeBatchCount } from './generation/batch'; import { normalizeGenerateSettings } from './generation/settings'; +import { shouldSubmitPendingQueueItem } from './queueSubmission'; import { useWorkbenchPreferenceSelector } from './settings/store'; import { useWorkbenchDispatch, @@ -256,6 +258,15 @@ export const WorkbenchRuntime = () => { [store] ); + // Keep the canvas generation frame and the generate-widget dimensions in sync + // while a project invokes into the canvas (the frame is the generation size). + // Lives beside the other store subscriptions; renders nothing. + useEffect(() => { + const sync = createCanvasDimsSync(store); + + return () => sync.dispose(); + }, [store]); + useEffect(() => { configureDiagnostics(diagnosticsPreferences); }, [diagnosticsPreferences]); @@ -446,11 +457,7 @@ export const WorkbenchRuntime = () => { for (const project of stateRef.current.projects) { for (const queueItem of project.queue.items) { - if ( - queueItem.status === 'pending' && - (queueItem.snapshot.sourceId === 'generate' || queueItem.snapshot.sourceId === 'workflow') && - !startedQueueItemIdsRef.current.has(queueItem.id) - ) { + if (shouldSubmitPendingQueueItem(queueItem) && !startedQueueItemIdsRef.current.has(queueItem.id)) { startedQueueItemIdsRef.current.add(queueItem.id); submitQueueItem(coordinator, project, queueItem, dispatch); } diff --git a/invokeai/frontend/webv2/src/workbench/backend/events.ts b/invokeai/frontend/webv2/src/workbench/backend/events.ts index 6b898d181c4..b0491e45cd6 100644 --- a/invokeai/frontend/webv2/src/workbench/backend/events.ts +++ b/invokeai/frontend/webv2/src/workbench/backend/events.ts @@ -96,6 +96,23 @@ export const isTerminalBackendStatus = (status: BackendQueueItemStatus): status const QUEUE_ITEM_ORIGIN_PREFIX = 'webv2:'; const PROJECT_QUEUE_ITEM_ORIGIN_PREFIX = 'webv2:p:'; +/** + * The origin prefix for utility-queue items — small graphs (filter previews, + * SAM, …) enqueued OUTSIDE any project's queue and awaited directly via + * `socketHub.on` (see `canvas-engine/backend/utilityQueue.ts`). + * + * The whole point of a distinct prefix is result isolation (plan Risk 4): a + * utility item must never be mistaken for a project queue item and routed into + * staging or the gallery. `parseQueueItemOrigin` therefore returns `null` for it + * — so `queueCoordinator.reconcile` and `isQueueServerItemInProject` never map a + * utility backend item to a local project item, and `routeQueueItemResults` + * (only ever invoked for coordinator-tracked project runs, which utility items + * are never registered as) never sees it. The `util:` segment sits under the + * shared `webv2:` namespace but is checked BEFORE the generic branch below, so + * it is not misparsed as a bare (non-project) local queue item id. + */ +const UTILITY_QUEUE_ITEM_ORIGIN_PREFIX = 'webv2:util:'; + export const buildProjectQueueItemOriginPrefix = (projectId: string): string => `${PROJECT_QUEUE_ITEM_ORIGIN_PREFIX}${projectId}:q:`; @@ -104,12 +121,29 @@ export const buildQueueItemOrigin = (localQueueItemId: string, projectId?: strin ? `${buildProjectQueueItemOriginPrefix(projectId)}${localQueueItemId}` : `${QUEUE_ITEM_ORIGIN_PREFIX}${localQueueItemId}`; -export const parseQueueItemOrigin = (origin: string | null | undefined): string | null => - origin?.startsWith(PROJECT_QUEUE_ITEM_ORIGIN_PREFIX) +/** Builds the isolated origin for a utility-queue item (`webv2:util:`). */ +export const buildUtilityQueueItemOrigin = (utilityId: string): string => + `${UTILITY_QUEUE_ITEM_ORIGIN_PREFIX}${utilityId}`; + +/** True when `origin` belongs to the utility queue (never a project/local queue item). */ +export const isUtilityQueueItemOrigin = (origin: string | null | undefined): boolean => + origin?.startsWith(UTILITY_QUEUE_ITEM_ORIGIN_PREFIX) ?? false; + +export const parseQueueItemOrigin = (origin: string | null | undefined): string | null => { + // Utility items are intentionally invisible to project routing (Risk 4): they + // resolve to no local queue item, so nothing adopts them or routes their + // results. Checked first because `webv2:util:` also matches the generic + // `webv2:` branch below. + if (isUtilityQueueItemOrigin(origin)) { + return null; + } + + return origin?.startsWith(PROJECT_QUEUE_ITEM_ORIGIN_PREFIX) ? origin.slice(origin.lastIndexOf(':q:') + 3) : origin?.startsWith(QUEUE_ITEM_ORIGIN_PREFIX) ? origin.slice(QUEUE_ITEM_ORIGIN_PREFIX.length) : null; +}; export const parseQueueItemOriginProjectId = (origin: string | null | undefined): string | null => { if (!origin?.startsWith(PROJECT_QUEUE_ITEM_ORIGIN_PREFIX)) { diff --git a/invokeai/frontend/webv2/src/workbench/backend/progressImageStore.ts b/invokeai/frontend/webv2/src/workbench/backend/progressImageStore.ts index 6646bb251b8..fd1ce3e21e3 100644 --- a/invokeai/frontend/webv2/src/workbench/backend/progressImageStore.ts +++ b/invokeai/frontend/webv2/src/workbench/backend/progressImageStore.ts @@ -15,7 +15,7 @@ export interface ProgressImageTarget { itemIndex: number; } -type LatestProgressImageSnapshot = ProgressImageSnapshot & { target?: ProgressImageTarget }; +export type LatestProgressImageSnapshot = ProgressImageSnapshot & { target?: ProgressImageTarget }; const latestSnapshotStore = createExternalStore<{ latestSnapshot: LatestProgressImageSnapshot | null }>({ latestSnapshot: null, @@ -57,7 +57,7 @@ export const progressImageStore = { export type ProgressImageSink = typeof progressImageStore; -export const useProgressImage = (): ProgressImageSnapshot | null => +export const useProgressImage = (): LatestProgressImageSnapshot | null => latestSnapshotStore.useSelector((snapshot) => snapshot.latestSnapshot); export const useQueueItemProgressImage = (queueItemId: string, itemIndex: number): ProgressImageSnapshot | null => diff --git a/invokeai/frontend/webv2/src/workbench/backend/queueCoordinator.test.ts b/invokeai/frontend/webv2/src/workbench/backend/queueCoordinator.test.ts index 149824e6510..de8ed15606b 100644 --- a/invokeai/frontend/webv2/src/workbench/backend/queueCoordinator.test.ts +++ b/invokeai/frontend/webv2/src/workbench/backend/queueCoordinator.test.ts @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { NodeExecutionSink } from './nodeExecutionStore'; import type { QueueItemProgress, QueueItemProgressSink } from './progressStore'; -import { buildQueueItemOrigin, type QueueItemStatusChangedEvent } from './events'; +import { buildQueueItemOrigin, buildUtilityQueueItemOrigin, type QueueItemStatusChangedEvent } from './events'; import { ApiError } from './http'; import { createQueueCoordinator, @@ -565,6 +565,36 @@ describe('queueCoordinator', () => { expect(outcomes.size).toBe(0); expect(harness.api.listAllQueueItems).not.toHaveBeenCalled(); }); + + // Review fix (Task 38, finding 3): the prior "Risk-4" coverage only asserted + // the parse primitive (`parseQueueItemOrigin(utilityOrigin) === null`) in + // isolation. This drives the REAL `reconcile` path with a completed, + // result-carrying `webv2:util:`-origin backend item mixed into a live + // `listAllQueueItems` response, proving the coordinator itself — not just the + // helper it calls — never adopts it into a project queue item (which is the + // only way a utility item's images could ever reach `routeQueueItemResults` + // and land in canvas staging or the gallery). + it('never adopts a completed, result-carrying utility-origin item into a project queue item', async () => { + harness.api.listAllQueueItems.mockResolvedValue([ + createQueueItemDTO({ + batch_id: 'util-batch', + item_id: 99, + origin: buildUtilityQueueItemOrigin('util-run-1'), + session: { results: { n1: { image: { image_name: 'util-result.png' }, type: 'image_output' } } }, + status: 'completed', + }), + ]); + + const outcomes = await harness.coordinator.reconcile([{ id: 'local-1', status: 'pending' }]); + + // The utility item parses to no local queue item id, so it can never be + // bucketed under 'local-1' by origin: the pending project item finds no + // backend trace of its own and is asked to enqueue fresh — never + // "adopted" with the utility item's id, and never routed anywhere. + expect(outcomes.get('local-1')).toEqual({ kind: 'enqueue' }); + expect(harness.api.getQueueItemResultImages).not.toHaveBeenCalled(); + expect(harness.api.getQueueItem).not.toHaveBeenCalledWith(99); + }); }); it('settles missed events through the safety sweep on reconnect', async () => { diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/backend/canvasImages.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/backend/canvasImages.test.ts new file mode 100644 index 00000000000..192ea0387f6 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/backend/canvasImages.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { CanvasImageUploadError, uploadCanvasImage } from './canvasImages'; + +/** A minimal `Response`-shaped fake for the injected fetch. */ +const jsonResponse = (body: unknown, init: { ok?: boolean; status?: number; statusText?: string } = {}): Response => + ({ + json: () => Promise.resolve(body), + ok: init.ok ?? true, + status: init.status ?? 200, + statusText: init.statusText ?? 'OK', + text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)), + }) as unknown as Response; + +describe('uploadCanvasImage', () => { + it('POSTs a multipart file to the upload endpoint with the persistence params', async () => { + const fetchImpl = vi.fn(() => + Promise.resolve(jsonResponse({ height: 64, image_name: 'paint-1.png', width: 128 }, { status: 201 })) + ); + const blob = new Blob(['png-bytes'], { type: 'image/png' }); + + const result = await uploadCanvasImage(blob, { fetch: fetchImpl }); + + expect(result).toEqual({ height: 64, imageName: 'paint-1.png', width: 128 }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + + const [url, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toContain('/api/v1/images/upload'); + expect(url).toContain('image_category=other'); + expect(url).toContain('is_intermediate=false'); + expect(init.method).toBe('POST'); + expect(init.body).toBeInstanceOf(FormData); + const file = (init.body as FormData).get('file'); + expect(file).toBeInstanceOf(File); + expect((file as File).type).toBe('image/png'); + }); + + it('honors overrides for category, intermediate flag, and board', async () => { + const fetchImpl = vi.fn(() => + Promise.resolve(jsonResponse({ height: 1, image_name: 'x', width: 1 }, { status: 201 })) + ); + + await uploadCanvasImage(new Blob([''], { type: 'image/png' }), { + boardId: 'board-9', + fetch: fetchImpl, + imageCategory: 'user', + isIntermediate: true, + }); + + const [url] = fetchImpl.mock.calls[0] as unknown as [string]; + expect(url).toContain('image_category=user'); + expect(url).toContain('is_intermediate=true'); + expect(url).toContain('board_id=board-9'); + }); + + it('throws a typed error with the status on a non-ok response', async () => { + const fetchImpl = vi.fn(() => + Promise.resolve(jsonResponse('Not an image', { ok: false, status: 415, statusText: 'Unsupported Media Type' })) + ); + + await expect(uploadCanvasImage(new Blob([''], { type: 'image/png' }), { fetch: fetchImpl })).rejects.toMatchObject({ + name: 'CanvasImageUploadError', + status: 415, + }); + }); + + it('wraps a network failure in a CanvasImageUploadError', async () => { + const fetchImpl = vi.fn(() => Promise.reject(new Error('network down'))); + + const error = await uploadCanvasImage(new Blob([''], { type: 'image/png' }), { fetch: fetchImpl }).catch( + (caught: unknown) => caught + ); + + expect(error).toBeInstanceOf(CanvasImageUploadError); + expect((error as CanvasImageUploadError).status).toBeNull(); + expect((error as CanvasImageUploadError).message).toContain('network down'); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/backend/canvasImages.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/backend/canvasImages.ts new file mode 100644 index 00000000000..69477e58829 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/backend/canvasImages.ts @@ -0,0 +1,111 @@ +/** + * Uploads canvas paint bitmaps to the backend as persistent, non-gallery + * images. Paint layers reference their pixels by `imageName` (never by URL or + * inline data), so the persisted workbench document — which autosaves to + * localStorage (~5 MB) — stays ref-only and the pixels live server-side. + * + * `image_category='other'` + `is_intermediate=false` keeps the bitmap durable + * (not garbage-collected as an intermediate) while hiding it from the gallery's + * general/asset views. + * + * The `fetch` seam is injectable so this runs in node tests without a DOM. + * Auth + base-URL resolution mirror the shared HTTP client so uploads carry the + * same bearer token as every other authenticated request. Zero React. + */ + +import { buildApiUrl, getAuthToken } from '@workbench/backend/http'; + +/** The subset of the backend `ImageDTO` this module reads. */ +interface UploadImageResponseDTO { + image_name: string; + width: number; + height: number; +} + +/** The result of a successful canvas-image upload. */ +export interface CanvasImageUploadResult { + imageName: string; + width: number; + height: number; +} + +/** Options for {@link uploadCanvasImage}. */ +export interface UploadCanvasImageOptions { + /** Overrides the default `'other'` category. */ + imageCategory?: 'other' | 'general' | 'control' | 'mask' | 'user'; + /** Overrides the default `false` (persistent, not garbage-collected). */ + isIntermediate?: boolean; + /** Adds the image to a board, if given. */ + boardId?: string; + /** File name sent in the multipart part (defaults to `canvas-paint.png`). */ + fileName?: string; + /** Injectable `fetch` implementation (defaults to the global). */ + fetch?: typeof globalThis.fetch; +} + +/** Thrown when a canvas-image upload fails (non-2xx or network error). */ +export class CanvasImageUploadError extends Error { + readonly status: number | null; + + constructor(message: string, status: number | null) { + super(message); + this.name = 'CanvasImageUploadError'; + this.status = status; + } +} + +/** + * POSTs `blob` as a PNG to `/api/v1/images/upload` (multipart `file` field) and + * returns the server-assigned image name and dimensions. Throws + * {@link CanvasImageUploadError} on any non-success response. + */ +export const uploadCanvasImage = async ( + blob: Blob, + options: UploadCanvasImageOptions = {} +): Promise => { + const fetchImpl = options.fetch ?? globalThis.fetch; + + const query = new URLSearchParams({ + image_category: options.imageCategory ?? 'other', + is_intermediate: String(options.isIntermediate ?? false), + }); + if (options.boardId) { + query.set('board_id', options.boardId); + } + + const fileName = options.fileName ?? 'canvas-paint.png'; + const file = new File([blob], fileName, { type: blob.type || 'image/png' }); + const body = new FormData(); + body.append('file', file); + + const headers = new Headers(); + const token = getAuthToken(); + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } + + let response: Response; + try { + response = await fetchImpl(buildApiUrl(`/api/v1/images/upload?${query.toString()}`), { + body, + headers, + method: 'POST', + }); + } catch (error) { + throw new CanvasImageUploadError( + `Canvas image upload failed: ${error instanceof Error ? error.message : String(error)}`, + null + ); + } + + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new CanvasImageUploadError( + text || `Canvas image upload failed: ${response.status} ${response.statusText}`, + response.status + ); + } + + const dto = (await response.json()) as UploadImageResponseDTO; + return { height: dto.height, imageName: dto.image_name, width: dto.width }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/backend/utilityQueue.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/backend/utilityQueue.test.ts new file mode 100644 index 00000000000..26b22bd98db --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/backend/utilityQueue.test.ts @@ -0,0 +1,321 @@ +import type { InvocationCompleteEvent, QueueItemStatusChangedEvent } from '@workbench/backend/events'; +import type { SocketHub } from '@workbench/backend/socketHub'; + +import { + buildQueueItemOrigin, + buildUtilityQueueItemOrigin, + isUtilityQueueItemOrigin, + parseQueueItemOrigin, +} from '@workbench/backend/events'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { UtilityEnqueue } from './utilityQueue'; + +import { DEFAULT_UTILITY_QUEUE_TIMEOUT_MS, runUtilityGraph, UtilityQueueError } from './utilityQueue'; + +/** A fake socket hub that records handlers per event and can emit to them, mirroring `hub.on`. */ +const createFakeHub = () => { + const handlers = new Map void>>(); + let detachCount = 0; + + const hub: Pick = { + on: (event, handler) => { + const set = handlers.get(event) ?? new Set(); + set.add(handler as (payload: unknown) => void); + handlers.set(event, set); + return () => { + detachCount += 1; + set.delete(handler as (payload: unknown) => void); + }; + }, + }; + + const emit = (event: string, payload: unknown): void => { + for (const handler of handlers.get(event) ?? []) { + handler(payload); + } + }; + + return { + emit, + get detachCount() { + return detachCount; + }, + handlerCount: (event: string): number => handlers.get(event)?.size ?? 0, + hub, + }; +}; + +const UTIL_ID = 'fixed-util-id'; +const ORIGIN = buildUtilityQueueItemOrigin(UTIL_ID); + +const completeEvent = (overrides: Partial = {}): InvocationCompleteEvent => ({ + batch_id: 'b', + destination: null, + invocation_source_id: 'control_filter', + item_id: 1, + origin: ORIGIN, + queue_id: 'default', + result: { image: { image_name: 'filtered.png' }, type: 'image_output' }, + session_id: 's', + ...overrides, +}); + +const statusEvent = ( + status: QueueItemStatusChangedEvent['status'], + overrides: Partial = {} +): QueueItemStatusChangedEvent => ({ + batch_id: 'b', + completed_at: null, + created_at: '', + destination: null, + error_message: null, + error_type: null, + item_id: 1, + origin: ORIGIN, + queue_id: 'default', + session_id: 's', + started_at: null, + status, + updated_at: '', + ...overrides, +}); + +const okEnqueue: UtilityEnqueue = () => Promise.resolve({ enqueued: 1, itemIds: [1] }); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('runUtilityGraph — await + settle', () => { + it('resolves with the captured image name and the isolated origin on completion', async () => { + const fake = createFakeHub(); + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + outputNodeId: 'control_filter', + }); + + // Listeners are attached before enqueue resolves (fast-finish race closed). + expect(fake.handlerCount('invocation_complete')).toBe(1); + expect(fake.handlerCount('queue_item_status_changed')).toBe(1); + + fake.emit('invocation_complete', completeEvent()); + fake.emit('queue_item_status_changed', statusEvent('completed')); + + await expect(promise).resolves.toEqual({ imageName: 'filtered.png', origin: ORIGIN }); + // Both listeners detached on settle. + expect(fake.handlerCount('invocation_complete')).toBe(0); + expect(fake.handlerCount('queue_item_status_changed')).toBe(0); + expect(fake.detachCount).toBe(2); + }); + + it('ignores completions from other output nodes when outputNodeId is set', async () => { + const fake = createFakeHub(); + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + outputNodeId: 'control_filter', + }); + + // A different node's completion is ignored; the target node's is captured. + fake.emit( + 'invocation_complete', + completeEvent({ + invocation_source_id: 'other', + result: { image: { image_name: 'wrong.png' }, type: 'image_output' }, + }) + ); + fake.emit('invocation_complete', completeEvent()); + fake.emit('queue_item_status_changed', statusEvent('completed')); + + await expect(promise).resolves.toMatchObject({ imageName: 'filtered.png' }); + }); + + it('rejects no-output when the item completes without an image', async () => { + const fake = createFakeHub(); + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + }); + fake.emit('queue_item_status_changed', statusEvent('completed')); + await expect(promise).rejects.toMatchObject({ name: 'UtilityQueueError', reason: 'no-output' }); + }); + + it('rejects with the failure reason + message on a failed item', async () => { + const fake = createFakeHub(); + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + }); + fake.emit('queue_item_status_changed', statusEvent('failed', { error_message: 'boom' })); + await expect(promise).rejects.toMatchObject({ message: 'boom', reason: 'failed' }); + }); + + it('rejects canceled on a canceled item', async () => { + const fake = createFakeHub(); + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + }); + fake.emit('queue_item_status_changed', statusEvent('canceled')); + await expect(promise).rejects.toMatchObject({ reason: 'canceled' }); + }); +}); + +describe('runUtilityGraph — timeout + cancellation', () => { + it('rejects with timeout after timeoutMs with no terminal event', async () => { + vi.useFakeTimers(); + const fake = createFakeHub(); + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + timeoutMs: 1000, + }); + const assertion = expect(promise).rejects.toMatchObject({ reason: 'timeout' }); + await vi.advanceTimersByTimeAsync(1000); + await assertion; + // Listeners cleaned up after timeout. + expect(fake.handlerCount('queue_item_status_changed')).toBe(0); + }); + + it('rejects immediately when the signal is already aborted', async () => { + const fake = createFakeHub(); + const controller = new AbortController(); + controller.abort(); + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + signal: controller.signal, + }); + await expect(promise).rejects.toMatchObject({ reason: 'aborted' }); + }); + + it('rejects aborted when the signal fires mid-flight and detaches listeners', async () => { + const fake = createFakeHub(); + const controller = new AbortController(); + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + signal: controller.signal, + }); + controller.abort(); + await expect(promise).rejects.toMatchObject({ reason: 'aborted' }); + expect(fake.handlerCount('invocation_complete')).toBe(0); + }); + + it('does not use a timeout when timeoutMs is 0', async () => { + vi.useFakeTimers(); + const fake = createFakeHub(); + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + timeoutMs: 0, + }); + await vi.advanceTimersByTimeAsync(DEFAULT_UTILITY_QUEUE_TIMEOUT_MS * 2); + // Still pending — settle it so the promise doesn't leak. + fake.emit('invocation_complete', completeEvent()); + fake.emit('queue_item_status_changed', statusEvent('completed')); + await expect(promise).resolves.toMatchObject({ imageName: 'filtered.png' }); + }); +}); + +describe('runUtilityGraph — enqueue failures', () => { + it('rejects enqueue when the backend accepts zero items', async () => { + const fake = createFakeHub(); + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: () => Promise.resolve({ enqueued: 0, itemIds: [] }), + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + }); + await expect(promise).rejects.toMatchObject({ reason: 'enqueue' }); + }); + + it('rejects enqueue when the enqueue call throws', async () => { + const fake = createFakeHub(); + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: () => Promise.reject(new Error('network down')), + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + }); + await expect(promise).rejects.toMatchObject({ message: 'network down', reason: 'enqueue' }); + }); +}); + +describe('runUtilityGraph — origin isolation (Risk 4)', () => { + it('mints a webv2:util: origin that project routing provably ignores', () => { + // The utility origin resolves to NO local queue item, so `queueCoordinator` + // (which keys backend items by `parseQueueItemOrigin`) and `routeQueueItemResults` + // never adopt a utility item into project staging / gallery routing. + expect(isUtilityQueueItemOrigin(ORIGIN)).toBe(true); + expect(parseQueueItemOrigin(ORIGIN)).toBeNull(); + + // A real project origin, by contrast, DOES parse to its local queue item id — + // demonstrating the coordinator adopts those but not utility items. + const projectOrigin = buildQueueItemOrigin('local-1', 'project-1'); + expect(isUtilityQueueItemOrigin(projectOrigin)).toBe(false); + expect(parseQueueItemOrigin(projectOrigin)).toBe('local-1'); + }); + + it('ignores socket events whose origin is not this utility run', async () => { + const fake = createFakeHub(); + let settled = false; + const promise = runUtilityGraph({ + createId: () => UTIL_ID, + enqueue: okEnqueue, + graph: { edges: [], id: 'g', nodes: {} }, + hub: fake.hub, + timeoutMs: 0, + }).then((result) => { + settled = true; + return result; + }); + + // Events for a DIFFERENT origin (another util run or a project item) must not settle us. + const otherOrigin = buildUtilityQueueItemOrigin('other-util'); + fake.emit( + 'invocation_complete', + completeEvent({ origin: otherOrigin, result: { image: { image_name: 'other.png' }, type: 'image_output' } }) + ); + fake.emit( + 'queue_item_status_changed', + statusEvent('completed', { origin: buildQueueItemOrigin('local-1', 'project-1') }) + ); + await Promise.resolve(); + expect(settled).toBe(false); + + // Our origin's events do settle us. + fake.emit('invocation_complete', completeEvent()); + fake.emit('queue_item_status_changed', statusEvent('completed')); + await expect(promise).resolves.toMatchObject({ imageName: 'filtered.png' }); + }); +}); + +describe('UtilityQueueError', () => { + it('carries a reason and a name', () => { + const error = new UtilityQueueError('timeout', 'nope'); + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('UtilityQueueError'); + expect(error.reason).toBe('timeout'); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/backend/utilityQueue.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/backend/utilityQueue.ts new file mode 100644 index 00000000000..920f555d97e --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/backend/utilityQueue.ts @@ -0,0 +1,205 @@ +/** + * The utility queue: fire-and-await small graphs OUTSIDE any project's queue. + * + * Filter previews and (later) Segment-Anything need to run a one-shot graph and + * read back its single output image WITHOUT the result ever touching project + * staging or the gallery. {@link runUtilityGraph} does exactly that: + * + * 1. Mints a fresh `webv2:util:` origin (see `backend/events.ts`). That + * origin is deliberately invisible to project routing — `parseQueueItemOrigin` + * returns `null` for it, so `queueCoordinator.reconcile` / + * `isQueueServerItemInProject` never adopt it and `routeQueueItemResults` + * (only invoked for coordinator-tracked project runs, which a utility item is + * never registered as) never sees it. This is the plan's Risk-4 guard. + * 2. Attaches raw `socketHub.on` listeners (they survive socket recreation) for + * `invocation_complete` (to capture the output image name) and + * `queue_item_status_changed` (to settle on the terminal status), matching + * events by our unique origin. + * 3. Enqueues the graph (listeners are attached first, closing the fast-finish + * race), then resolves with the output `imageName` on completion, or rejects + * on failure/cancellation/timeout/abort. + * + * Zero React, zero DOM: `hub` and `enqueue` are injected, so this runs in node + * tests against fakes. Every side-effecting dependency is a parameter. + */ + +import type { InvocationCompleteEvent, QueueItemStatusChangedEvent } from '@workbench/backend/events'; +import type { SocketHub } from '@workbench/backend/socketHub'; +import type { BackendGraphContract } from '@workbench/types'; + +import { buildUtilityQueueItemOrigin } from '@workbench/backend/events'; +import { enqueueUtilityGraph } from '@workbench/generation/api'; + +/** The default time a utility graph may run before it is abandoned. */ +export const DEFAULT_UTILITY_QUEUE_TIMEOUT_MS = 120_000; + +/** Thrown when a utility graph fails, is canceled, times out, or is aborted. */ +export class UtilityQueueError extends Error { + readonly reason: 'failed' | 'canceled' | 'timeout' | 'aborted' | 'no-output' | 'enqueue'; + + constructor(reason: UtilityQueueError['reason'], message: string) { + super(message); + this.name = 'UtilityQueueError'; + this.reason = reason; + } +} + +/** The enqueue seam: posts the graph under `origin`, resolving to its backend item ids. */ +export type UtilityEnqueue = (request: { + graph: BackendGraphContract; + origin: string; +}) => Promise<{ itemIds: number[]; enqueued: number }>; + +/** Dependencies for {@link runUtilityGraph} (all injectable for tests). */ +export interface RunUtilityGraphOptions { + /** The graph to enqueue and await. */ + graph: BackendGraphContract; + /** + * The source node id whose `invocation_complete` output image is the result. + * When omitted, the first image-bearing completion for our origin is used. + */ + outputNodeId?: string; + /** The socket hub (only `on` is used). Raw listeners survive socket recreation. */ + hub: Pick; + /** Enqueue seam (defaults to the real utility enqueue API). */ + enqueue?: UtilityEnqueue; + /** Abandon after this many ms (default {@link DEFAULT_UTILITY_QUEUE_TIMEOUT_MS}). `0` disables. */ + timeoutMs?: number; + /** Cancels the await (does not itself cancel the backend item). */ + signal?: AbortSignal; + /** Injectable id source (defaults to `crypto.randomUUID`). */ + createId?: () => string; +} + +/** The resolved result of a utility graph: its single output image. */ +export interface UtilityGraphResult { + imageName: string; + /** The origin used, for diagnostics/tests. */ + origin: string; +} + +/** Extracts an image name from an `invocation_complete` result payload, if present. */ +const extractImageName = (result: InvocationCompleteEvent['result'] | undefined): string | null => { + const image = (result as { image?: { image_name?: unknown } } | undefined)?.image; + return typeof image?.image_name === 'string' ? image.image_name : null; +}; + +/** + * Runs `graph` on the utility queue and resolves with its output image name. + * Never routes into project state (isolated origin). Rejects with a + * {@link UtilityQueueError} on failure/cancel/timeout/abort/enqueue error. + */ +export const runUtilityGraph = (options: RunUtilityGraphOptions): Promise => { + const { graph, hub, outputNodeId, signal } = options; + const enqueue = options.enqueue ?? enqueueUtilityGraph; + const timeoutMs = options.timeoutMs ?? DEFAULT_UTILITY_QUEUE_TIMEOUT_MS; + const utilityId = (options.createId ?? (() => crypto.randomUUID()))(); + const origin = buildUtilityQueueItemOrigin(utilityId); + + return new Promise((resolve, reject) => { + let settled = false; + let capturedImageName: string | null = null; + const detachers: Array<() => void> = []; + let timer: ReturnType | null = null; + + const cleanup = (): void => { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + for (const detach of detachers) { + detach(); + } + detachers.length = 0; + if (signal) { + signal.removeEventListener('abort', onAbort); + } + }; + + const settleResolve = (imageName: string): void => { + if (settled) { + return; + } + settled = true; + cleanup(); + resolve({ imageName, origin }); + }; + + const settleReject = (error: UtilityQueueError): void => { + if (settled) { + return; + } + settled = true; + cleanup(); + reject(error); + }; + + function onAbort(): void { + settleReject(new UtilityQueueError('aborted', 'The utility graph was aborted.')); + } + + // Attach listeners BEFORE enqueue so a very fast completion is not missed. + detachers.push( + hub.on('invocation_complete', (event: InvocationCompleteEvent) => { + if (event.origin !== origin) { + return; + } + // Only take the target node's output (or, when unspecified, any image). + if (outputNodeId && event.invocation_source_id !== outputNodeId) { + return; + } + const imageName = extractImageName(event.result); + if (imageName) { + capturedImageName = imageName; + } + }) + ); + + detachers.push( + hub.on('queue_item_status_changed', (event: QueueItemStatusChangedEvent) => { + if (event.origin !== origin) { + return; + } + if (event.status === 'completed') { + if (capturedImageName) { + settleResolve(capturedImageName); + } else { + settleReject(new UtilityQueueError('no-output', 'The utility graph produced no output image.')); + } + } else if (event.status === 'failed') { + settleReject(new UtilityQueueError('failed', event.error_message ?? 'The utility graph failed.')); + } else if (event.status === 'canceled') { + settleReject(new UtilityQueueError('canceled', 'The utility graph was canceled.')); + } + }) + ); + + if (signal) { + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener('abort', onAbort); + } + + if (timeoutMs > 0) { + timer = setTimeout(() => { + settleReject(new UtilityQueueError('timeout', `The utility graph timed out after ${timeoutMs}ms.`)); + }, timeoutMs); + } + + // Enqueue last. A synchronous listener callback could already have fired by + // the time this resolves; `settled` guards against a late enqueue result. + enqueue({ graph, origin }) + .then((result) => { + if (!settled && result.enqueued === 0) { + settleReject(new UtilityQueueError('enqueue', 'The backend queue did not accept the utility graph.')); + } + }) + .catch((error: unknown) => { + settleReject( + new UtilityQueueError('enqueue', error instanceof Error ? error.message : 'Failed to enqueue utility graph.') + ); + }); + }); +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/color.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/color.test.ts new file mode 100644 index 00000000000..999eb3c030f --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/color.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; + +import { rgbaToHex } from './color'; + +describe('rgbaToHex', () => { + it('formats channels as a lowercase #rrggbb string', () => { + expect(rgbaToHex(0, 0, 0)).toBe('#000000'); + expect(rgbaToHex(255, 255, 255)).toBe('#ffffff'); + expect(rgbaToHex(255, 0, 0)).toBe('#ff0000'); + expect(rgbaToHex(18, 52, 86)).toBe('#123456'); + }); + + it('pads single-digit hex bytes with a leading zero', () => { + expect(rgbaToHex(1, 2, 3)).toBe('#010203'); + }); + + it('rounds fractional channels to the nearest integer', () => { + expect(rgbaToHex(127.6, 127.4, 0)).toBe('#807f00'); + }); + + it('clamps out-of-range channels to [0, 255]', () => { + expect(rgbaToHex(-10, 300, 128)).toBe('#00ff80'); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/color.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/color.ts new file mode 100644 index 00000000000..8ddef1befc8 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/color.ts @@ -0,0 +1,18 @@ +/** + * Small pure color helpers shared by the engine. Zero React, zero import-time + * side effects. + */ + +/** Clamps a color channel to the `[0, 255]` byte range, rounding to the nearest integer. */ +const clampChannel = (value: number): number => Math.max(0, Math.min(255, Math.round(value))); + +const toHexByte = (value: number): string => clampChannel(value).toString(16).padStart(2, '0'); + +/** + * Formats an opaque color as a lowercase `#rrggbb` CSS hex string. Channels are + * `[0, 255]`; alpha is intentionally not part of the output — brush/eraser + * options store an opaque fill color, and the color picker's own transparency + * check (`sampleDocumentColor` returning `null`) already decides whether a + * sample is pickable at all. + */ +export const rgbaToHex = (r: number, g: number, b: number): string => `#${toHexByte(r)}${toHexByte(g)}${toHexByte(b)}`; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/document/bitmapStore.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/bitmapStore.test.ts new file mode 100644 index 00000000000..fcec69f8958 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/bitmapStore.test.ts @@ -0,0 +1,620 @@ +import type { CanvasImageUploadResult } from '@workbench/canvas-engine/backend/canvasImages'; +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { CanvasImageRef, CanvasLayerSourceContract } from '@workbench/types'; +import type { WorkbenchAction } from '@workbench/workbenchState'; + +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createBitmapStore } from './bitmapStore'; + +const LAYER = 'layer-1'; + +/** A resolvable deferred, so a test can hold an upload pending on demand. */ +const createDeferred = (): { + promise: Promise; + resolve: (value: T) => void; + reject: (reason: unknown) => void; +} => { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, reject, resolve }; +}; + +/** Drains microtasks until `predicate` is true (or `maxTicks` is exhausted). */ +const drainUntil = async (predicate: () => boolean, maxTicks = 50): Promise => { + for (let i = 0; i < maxTicks && !predicate(); i += 1) { + await Promise.resolve(); + } +}; + +interface HarnessOptions { + uploadImage?: (blob: Blob) => Promise; + maxUploadAttempts?: number; + /** The layer-local content-rect origin the surface sits at (default 0,0). */ + offset?: { x: number; y: number }; +} + +/** The default source: a plain paint layer, matching every pre-existing test's assumption. */ +const PAINT_SOURCE: CanvasLayerSourceContract = { bitmap: null, type: 'paint' }; + +const createHarness = (options: HarnessOptions = {}) => { + const surface: RasterSurface = createTestStubRasterBackend().createSurface(10, 10); + let encoded = 'pixels-A'; + let uploadSeq = 0; + let source: CanvasLayerSourceContract | null = PAINT_SOURCE; + + let offset = options.offset ?? { x: 0, y: 0 }; + + const encodeSurface = vi.fn(() => Promise.resolve(new Blob([encoded], { type: 'image/png' }))); + const uploadImage = + options.uploadImage ?? + vi.fn( + (_blob: Blob): Promise => + Promise.resolve({ height: 10, imageName: `img-${uploadSeq++}`, width: 10 }) + ); + const dispatch = vi.fn<(action: WorkbenchAction) => void>(); + + const store = createBitmapStore({ + debounceMs: 1500, + dispatch, + encodeSurface, + getLayerSource: () => source, + getLayerSurface: () => ({ offset, surface }), + // Deterministic content hash: the encoded blob's own text. + hashBlob: (blob) => blob.text(), + maxUploadAttempts: options.maxUploadAttempts ?? 3, + retryDelaysMs: [1], + // Immediate backoff so retries don't depend on timer advancement. + sleep: () => Promise.resolve(), + uploadImage, + }); + + return { + dispatch, + encodeSurface, + setEncoded: (value: string) => { + encoded = value; + }, + setOffset: (value: { x: number; y: number }) => { + offset = value; + }, + setSource: (value: CanvasLayerSourceContract | null) => { + source = value; + }, + store, + uploadImage: uploadImage as ReturnType, + }; +}; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('createBitmapStore', () => { + it('debounces a burst of strokes into a single flush', async () => { + const h = createHarness(); + + h.store.markLayerDirty(LAYER); + await vi.advanceTimersByTimeAsync(500); + h.store.markLayerDirty(LAYER); // resets the timer + await vi.advanceTimersByTimeAsync(500); + h.store.markLayerDirty(LAYER); // resets again + await vi.advanceTimersByTimeAsync(1000); // 1000 < 1500 since the last stroke + + expect(h.uploadImage).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1500); + await h.store.flushPendingUploads(); + + expect(h.uploadImage).toHaveBeenCalledTimes(1); + expect(h.dispatch).toHaveBeenCalledTimes(1); + h.store.dispose(); + }); + + it('dedupes identical pixels: the second flush reuses the image and skips the upload', async () => { + const h = createHarness(); + + h.store.markLayerDirty(LAYER); + await vi.advanceTimersByTimeAsync(1500); + await h.store.flushPendingUploads(); + expect(h.uploadImage).toHaveBeenCalledTimes(1); + expect(h.dispatch).toHaveBeenCalledTimes(1); + + // Same encoded pixels → same hash → dedupe hit, no second upload. + h.store.markLayerDirty(LAYER); + await vi.advanceTimersByTimeAsync(1500); + await h.store.flushPendingUploads(); + + expect(h.uploadImage).toHaveBeenCalledTimes(1); + h.store.dispose(); + }); + + it('dispatches a same-hash re-flush when the offset changed (pure-translation persistence)', async () => { + const h = createHarness({ offset: { x: 0, y: 0 } }); + + // First flush: paint pixels at the origin → uploads img-0, document now + // points at { imageName: 'img-0', offset: { x: 0, y: 0 } }. + h.store.markLayerDirty(LAYER); + await vi.advanceTimersByTimeAsync(1500); + await h.store.flushPendingUploads(); + expect(h.uploadImage).toHaveBeenCalledTimes(1); + expect(h.dispatch).toHaveBeenCalledTimes(1); + h.setSource({ bitmap: { height: 10, imageName: 'img-0', width: 10 }, offset: { x: 0, y: 0 }, type: 'paint' }); + + // Transform-drag by +50px then Apply: the bake produces byte-identical + // pixels (same hash → dedupe hit, no upload) but the content sits at a new + // offset. The dispatch that persists the moved offset must still fire. + h.setOffset({ x: 50, y: 0 }); + h.store.markLayerDirty(LAYER); + await vi.advanceTimersByTimeAsync(1500); + await h.store.flushPendingUploads(); + + // No new upload (pixels deduped), but a fresh dispatch carrying the offset. + expect(h.uploadImage).toHaveBeenCalledTimes(1); + expect(h.dispatch).toHaveBeenCalledTimes(2); + expect(h.dispatch.mock.calls.at(-1)?.[0]).toMatchObject({ + source: { bitmap: { imageName: 'img-0' }, offset: { x: 50, y: 0 }, type: 'paint' }, + type: 'updateCanvasLayerSource', + }); + h.store.dispose(); + }); + + it('dispatches the content-rect offset alongside the bitmap ref (paint persistence round-trip)', async () => { + const h = createHarness({ offset: { x: 40, y: 25 } }); + + h.store.markLayerDirty(LAYER); + await vi.advanceTimersByTimeAsync(1500); + await h.store.flushPendingUploads(); + + const dispatched = h.dispatch.mock.calls.at(-1)?.[0]; + expect(dispatched).toMatchObject({ + source: { bitmap: { imageName: 'img-0' }, offset: { x: 40, y: 25 }, type: 'paint' }, + type: 'updateCanvasLayerSource', + }); + // The encoded blob covers only the content-sized surface (10×10 stub), so a + // reload rasterizes those pixels at the persisted offset. + expect(h.encodeSurface).toHaveBeenCalledWith(expect.objectContaining({ height: 10, width: 10 })); + h.store.dispose(); + }); + + it('routes the swap through dispatchBitmap when provided (mask persistence seam), not the default dispatch', async () => { + const surface = createTestStubRasterBackend().createSurface(10, 10); + const dispatch = vi.fn<(action: WorkbenchAction) => void>(); + const dispatchBitmap = vi.fn<(layerId: string, bitmap: CanvasImageRef, offset: { x: number; y: number }) => void>(); + const store = createBitmapStore({ + debounceMs: 1500, + dispatch, + dispatchBitmap, + encodeSurface: () => Promise.resolve(new Blob(['pixels'], { type: 'image/png' })), + getLayerSource: () => ({ bitmap: null, type: 'paint' }), + getLayerSurface: () => ({ offset: { x: 7, y: 8 }, surface }), + hashBlob: (blob) => blob.text(), + retryDelaysMs: [1], + sleep: () => Promise.resolve(), + uploadImage: () => Promise.resolve({ height: 10, imageName: 'mask-img', width: 10 }), + }); + + store.markLayerDirty('mask1'); + await vi.advanceTimersByTimeAsync(1500); + await store.flushPendingUploads(); + + // The engine-provided seam receives the ref + offset; the default paint-source + // dispatch is NOT used (the engine picks updateCanvasLayerConfig for masks). + expect(dispatchBitmap).toHaveBeenCalledTimes(1); + expect(dispatchBitmap).toHaveBeenCalledWith('mask1', expect.objectContaining({ imageName: 'mask-img' }), { + x: 7, + y: 8, + }); + expect(dispatch).not.toHaveBeenCalled(); + store.dispose(); + }); + + it('uploads a new image when the pixels change', async () => { + const h = createHarness(); + + h.store.markLayerDirty(LAYER); + await vi.advanceTimersByTimeAsync(1500); + await h.store.flushPendingUploads(); + + h.setEncoded('pixels-B'); + h.store.markLayerDirty(LAYER); + await vi.advanceTimersByTimeAsync(1500); + await h.store.flushPendingUploads(); + + expect(h.uploadImage).toHaveBeenCalledTimes(2); + expect(h.dispatch).toHaveBeenCalledTimes(2); + h.store.dispose(); + }); + + it('undo re-flush reuses the prior image and does not re-upload (history convergence)', async () => { + const h = createHarness(); + + // Paint state A → upload img-0. + h.store.markLayerDirty(LAYER); + await vi.advanceTimersByTimeAsync(1500); + await h.store.flushPendingUploads(); + // Paint state B → upload img-1. + h.setEncoded('pixels-B'); + h.store.markLayerDirty(LAYER); + await vi.advanceTimersByTimeAsync(1500); + await h.store.flushPendingUploads(); + expect(h.uploadImage).toHaveBeenCalledTimes(2); + + // Undo restores state A's pixels in the cache; the engine re-marks the layer + // dirty. The re-flush re-hashes to img-0's content and reuses it — NO upload. + h.setEncoded('pixels-A'); + h.store.markLayerDirty(LAYER); + await vi.advanceTimersByTimeAsync(1500); + await h.store.flushPendingUploads(); + + expect(h.uploadImage).toHaveBeenCalledTimes(2); + // The contract converges back to img-0 (the previously uploaded state-A image). + const lastDispatch = h.dispatch.mock.calls.at(-1)?.[0]; + expect(lastDispatch).toMatchObject({ + source: { bitmap: { imageName: 'img-0' }, type: 'paint' }, + type: 'updateCanvasLayerSource', + }); + h.store.dispose(); + }); + + it('swaps on success: dispatch fires only after the upload resolves', async () => { + const deferred = createDeferred(); + const uploadImage = vi.fn(() => deferred.promise); + const h = createHarness({ uploadImage }); + + h.store.markLayerDirty(LAYER); + await vi.advanceTimersByTimeAsync(1500); + + // Upload is in flight; the contract keeps its old ref (no dispatch yet). + expect(uploadImage).toHaveBeenCalledTimes(1); + expect(h.dispatch).not.toHaveBeenCalled(); + + deferred.resolve({ height: 10, imageName: 'img-x', width: 10 }); + await h.store.flushPendingUploads(); + + expect(h.dispatch).toHaveBeenCalledTimes(1); + expect(h.dispatch.mock.calls[0][0]).toMatchObject({ + id: LAYER, + source: { bitmap: { imageName: 'img-x' }, type: 'paint' }, + type: 'updateCanvasLayerSource', + }); + h.store.dispose(); + }); + + it('on upload failure: no dispatch, layer stays dirty, then recovers on a later success', async () => { + let shouldFail = true; + const uploadImage = vi.fn(() => { + if (shouldFail) { + return Promise.reject(new Error('upload failed')); + } + return Promise.resolve({ height: 10, imageName: 'img-ok', width: 10 }); + }); + const onError = vi.fn(); + const surface = createTestStubRasterBackend().createSurface(10, 10); + const dispatch = vi.fn<(action: WorkbenchAction) => void>(); + const store = createBitmapStore({ + debounceMs: 1500, + dispatch, + encodeSurface: () => Promise.resolve(new Blob(['pixels'], { type: 'image/png' })), + getLayerSource: () => PAINT_SOURCE, + getLayerSurface: () => ({ offset: { x: 0, y: 0 }, surface }), + hashBlob: (blob) => blob.text(), + maxUploadAttempts: 2, + onError, + retryDelaysMs: [1], + sleep: () => Promise.resolve(), + uploadImage, + }); + + store.markLayerDirty(LAYER); + await store.flushPendingUploads(); + + // Retried up to the attempt cap, then gave up without dispatching. + expect(uploadImage).toHaveBeenCalledTimes(2); + expect(dispatch).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledTimes(1); + + // The layer is still dirty; a subsequent flush succeeds and dispatches. + shouldFail = false; + store.markLayerDirty(LAYER); + await store.flushPendingUploads(); + + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch.mock.calls[0][0]).toMatchObject({ source: { bitmap: { imageName: 'img-ok' } } }); + store.dispose(); + }); + + it('flushPendingUploads is a barrier: it cancels the debounce and resolves only after uploads settle', async () => { + const deferred = createDeferred(); + const uploadImage = vi.fn(() => deferred.promise); + const h = createHarness({ uploadImage }); + + h.store.markLayerDirty(LAYER); + // Do NOT advance to the debounce window; the barrier must flush immediately. + const barrier = h.store.flushPendingUploads(); + + let settled = false; + void barrier.then(() => { + settled = true; + }); + + // Encode/hash have run and the upload is in flight, but it hasn't resolved. + // Drain the encode→hash microtask chain (several awaits) without resolving. + for (let i = 0; i < 5; i += 1) { + await Promise.resolve(); + } + expect(uploadImage).toHaveBeenCalledTimes(1); + expect(settled).toBe(false); + + deferred.resolve({ height: 10, imageName: 'img-b', width: 10 }); + await barrier; + + expect(settled).toBe(true); + expect(h.dispatch).toHaveBeenCalledTimes(1); + h.store.dispose(); + }); + + it('a stroke landing while the barrier awaits an in-flight upload is not dropped: the barrier waits for the follow-up flush', async () => { + const deferreds = [createDeferred(), createDeferred()]; + let call = 0; + const uploadImage = vi.fn(() => deferreds[call++].promise); + const h = createHarness({ uploadImage }); + + h.store.markLayerDirty(LAYER); + const barrier = h.store.flushPendingUploads(); + let settled = false; + void barrier.then(() => { + settled = true; + }); + + // Drain the encode→hash microtask chain: the first (stale) upload is in flight. + await drainUntil(() => uploadImage.mock.calls.length >= 1); + expect(uploadImage).toHaveBeenCalledTimes(1); + expect(settled).toBe(false); + + // A fresh stroke lands mid-flight: new pixels re-dirty the layer while the + // stale upload is still pending. + h.setEncoded('pixels-B'); + h.store.markLayerDirty(LAYER); + + // The stale upload resolves. The barrier must NOT settle yet: the layer + // was re-dirtied by a newer stroke during the await, so it owes a follow-up + // flush of the newer pixels before the "latest painted pixels" guarantee holds. + deferreds[0].resolve({ height: 10, imageName: 'img-old', width: 10 }); + await drainUntil(() => uploadImage.mock.calls.length >= 2); + expect(uploadImage).toHaveBeenCalledTimes(2); + expect(settled).toBe(false); + + deferreds[1].resolve({ height: 10, imageName: 'img-new', width: 10 }); + await barrier; + + expect(settled).toBe(true); + expect(h.dispatch).toHaveBeenCalledTimes(2); + expect(h.dispatch.mock.calls[1][0]).toMatchObject({ + id: LAYER, + source: { bitmap: { imageName: 'img-new' }, type: 'paint' }, + type: 'updateCanvasLayerSource', + }); + h.store.dispose(); + }); + + it('a persistently failing layer does not spin the barrier: it resolves after one bounded attempt, and the layer stays dirty for a later retry', async () => { + const uploadImage = vi.fn(() => Promise.reject(new Error('upload failed'))); + const onError = vi.fn(); + const surface = createTestStubRasterBackend().createSurface(10, 10); + const dispatch = vi.fn<(action: WorkbenchAction) => void>(); + const store = createBitmapStore({ + debounceMs: 1500, + dispatch, + encodeSurface: () => Promise.resolve(new Blob(['pixels'], { type: 'image/png' })), + getLayerSource: () => PAINT_SOURCE, + getLayerSurface: () => ({ offset: { x: 0, y: 0 }, surface }), + hashBlob: (blob) => blob.text(), + maxUploadAttempts: 2, + onError, + retryDelaysMs: [1], + sleep: () => Promise.resolve(), + uploadImage, + }); + + store.markLayerDirty(LAYER); + await store.flushPendingUploads(); + + // The internal retry cap (maxUploadAttempts) bounds a single flush's own + // attempts. The barrier must not loop back and re-attempt a layer whose + // flush already FAILED this call, so the total stays at that cap instead + // of growing with extra barrier iterations (no spin). + expect(uploadImage).toHaveBeenCalledTimes(2); + expect(dispatch).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledTimes(1); + + // The layer is still dirty (deferred, not dropped): a later debounce retries it. + await vi.advanceTimersByTimeAsync(1500); + expect(uploadImage.mock.calls.length).toBeGreaterThan(2); + + store.dispose(); + }); + + it('isSelfEcho recognizes the exact ref it just applied and rejects a different one', async () => { + const h = createHarness(); + + h.store.markLayerDirty(LAYER); + await vi.advanceTimersByTimeAsync(1500); + await h.store.flushPendingUploads(); + + const applied = h.dispatch.mock.calls[0][0] as Extract; + const appliedSource = applied.source; + + // The dispatch's own round-trip is a self-echo → the engine skips re-raster. + expect(h.store.isSelfEcho(LAYER, appliedSource)).toBe(true); + + // A different bitmap (undo/import) is NOT an echo → must re-rasterize. + const otherPaint: CanvasLayerSourceContract = { + bitmap: { height: 10, imageName: 'other', width: 10 }, + type: 'paint', + }; + expect(h.store.isSelfEcho(LAYER, otherPaint)).toBe(false); + + // A different layer, and non-paint / null sources, are never echoes. + expect(h.store.isSelfEcho('layer-2', appliedSource)).toBe(false); + expect(h.store.isSelfEcho(LAYER, { image: { height: 1, imageName: 'i', width: 1 }, type: 'image' })).toBe(false); + expect(h.store.isSelfEcho(LAYER, null)).toBe(false); + h.store.dispose(); + }); + + it('reset() clears the self-echo guard so a reused layer id is not suppressed', async () => { + const h = createHarness(); + + h.store.markLayerDirty(LAYER); + await vi.advanceTimersByTimeAsync(1500); + await h.store.flushPendingUploads(); + expect(h.dispatch).toHaveBeenCalledTimes(1); + expect(h.uploadImage).toHaveBeenCalledTimes(1); + + const applied = h.dispatch.mock.calls[0][0] as Extract; + expect(h.store.isSelfEcho(LAYER, applied.source)).toBe(true); + + // A wholesale document replacement drops the outgoing document's self-echo + // bookkeeping (a reused layer id must not inherit it). + h.store.reset(); + expect(h.store.isSelfEcho(LAYER, applied.source)).toBe(false); + + // Re-persisting identical pixels now dispatches again: the content-hash + // dedupe reuses the already-uploaded image (no new upload), but the stale + // self-echo no longer suppresses the dispatch, so the contract converges. + h.store.markLayerDirty(LAYER); + await vi.advanceTimersByTimeAsync(1500); + await h.store.flushPendingUploads(); + expect(h.dispatch).toHaveBeenCalledTimes(2); + expect(h.uploadImage).toHaveBeenCalledTimes(1); + + h.store.dispose(); + }); + + it('reset() cancels a pending debounced flush for the outgoing document', async () => { + const h = createHarness(); + + h.store.markLayerDirty(LAYER); + h.store.reset(); + await vi.advanceTimersByTimeAsync(3000); + + expect(h.uploadImage).not.toHaveBeenCalled(); + expect(h.dispatch).not.toHaveBeenCalled(); + + h.store.dispose(); + }); + + it('does not flush after dispose', async () => { + const h = createHarness(); + h.store.markLayerDirty(LAYER); + h.store.dispose(); + await vi.advanceTimersByTimeAsync(3000); + expect(h.uploadImage).not.toHaveBeenCalled(); + }); + + describe('source-type guard (rasterize → undo convergence)', () => { + it('drops the dirty entry without dispatching when the debounce timer fires after the layer left `paint`', async () => { + const h = createHarness(); + + h.store.markLayerDirty(LAYER); + // The layer converted back to a parametric source (e.g. rasterize → undo) + // before the debounce window elapsed — the cache surface still resolves + // (a source swap doesn't clear it), so only this guard prevents a stale + // paint dispatch. + h.setSource({ + fill: '#ff0000', + height: 40, + kind: 'rect', + stroke: null, + strokeWidth: 0, + type: 'shape', + width: 60, + }); + + await vi.advanceTimersByTimeAsync(1500); + + expect(h.uploadImage).not.toHaveBeenCalled(); + expect(h.dispatch).not.toHaveBeenCalled(); + h.store.dispose(); + }); + + it('drops the dirty entry without dispatching when `flushPendingUploads` is awaited after the layer left `paint`', async () => { + const h = createHarness(); + + h.store.markLayerDirty(LAYER); + h.setSource({ angle: 0, kind: 'linear', stops: [{ color: '#000', offset: 0 }], type: 'gradient' }); + + // Do NOT advance timers: exercise the barrier path (e.g. pressing Invoke + // right after the undo), not just the debounce path. + await h.store.flushPendingUploads(); + + expect(h.uploadImage).not.toHaveBeenCalled(); + expect(h.dispatch).not.toHaveBeenCalled(); + h.store.dispose(); + }); + + it('drops the dirty entry without dispatching when the layer no longer exists', async () => { + const h = createHarness(); + + h.store.markLayerDirty(LAYER); + h.setSource(null); + + await h.store.flushPendingUploads(); + + expect(h.uploadImage).not.toHaveBeenCalled(); + expect(h.dispatch).not.toHaveBeenCalled(); + h.store.dispose(); + }); + + it('closes the race where the source leaves `paint` WHILE an upload is already in flight', async () => { + const deferred = createDeferred(); + const uploadImage = vi.fn(() => deferred.promise); + const h = createHarness({ uploadImage }); + + h.store.markLayerDirty(LAYER); + const barrier = h.store.flushPendingUploads(); + + // Encode/hash/upload have started (passing the entry-time guard while the + // source was still `paint`); the upload is now in flight. + await drainUntil(() => uploadImage.mock.calls.length >= 1); + expect(uploadImage).toHaveBeenCalledTimes(1); + + // The source changes away from `paint` DURING the in-flight upload — + // later than the entry-time check, so only the pre-dispatch recheck + // catches it. + h.setSource({ fill: '#000', height: 10, kind: 'rect', stroke: null, strokeWidth: 0, type: 'shape', width: 10 }); + + deferred.resolve({ height: 10, imageName: 'img-x', width: 10 }); + await barrier; + + expect(h.dispatch).not.toHaveBeenCalled(); + h.store.dispose(); + }); + + it('still flushes normally when the layer stays a paint layer (no regression)', async () => { + const h = createHarness(); + + h.store.markLayerDirty(LAYER); + await vi.advanceTimersByTimeAsync(1500); + await h.store.flushPendingUploads(); + + expect(h.uploadImage).toHaveBeenCalledTimes(1); + expect(h.dispatch).toHaveBeenCalledTimes(1); + expect(h.dispatch.mock.calls[0][0]).toMatchObject({ + id: LAYER, + source: { type: 'paint' }, + type: 'updateCanvasLayerSource', + }); + h.store.dispose(); + }); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/document/bitmapStore.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/bitmapStore.ts new file mode 100644 index 00000000000..305830ae59d --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/bitmapStore.ts @@ -0,0 +1,486 @@ +/** + * The bitmap store: paint persistence via content-hashed server images. + * + * Painting bakes strokes straight into a layer's raster cache surface (see + * `strokeSession`/`paintTool`), but nothing persists — a reload would lose the + * strokes. This store closes that loop: on each committed stroke it marks the + * layer dirty, and after a short idle window it encodes the layer's full cache + * surface to a PNG, content-hashes it (SHA-256), dedupes against already + * uploaded bitmaps, uploads new ones, and — only once the upload succeeds — + * dispatches `updateCanvasLayerSource` so the reducer-owned document points its + * paint layer at the persisted image name. The reducer stays pixel-free: only a + * `CanvasImageRef` (name + dims + hash) ever crosses the boundary. + * + * Key invariants: + * - **Swap-on-success**: the contract keeps its previous ref until the upload + * resolves. A failed upload never dispatches; the layer stays dirty and the + * old ref stays valid, so a reload still shows the last persisted pixels. + * - **Debounce per layer** (~1.5 s idle): a new stroke resets the timer, so a + * burst of strokes uploads once. + * - **Content-hash dedupe**: identical pixels reuse the previously uploaded + * image name and skip the upload entirely. This is what makes undo cheap + * (restoring old pixels re-hashes to the already-uploaded image). + * - **Self-echo guard**: the dispatch round-trips back through the document + * mirror as a source change for the layer. {@link BitmapStore.isSelfEcho} + * lets the engine skip re-rasterizing/invalidating the cache for the exact + * bitmap ref this store just applied (the pixels already match). + * - **Source-type guard**: a layer's cache surface survives a source-type + * change (e.g. rasterize's paint bake, then an undo back to shape/gradient) + * — only the document's source pointer changes, not the cache. So every + * flush re-checks `getLayerSource` (at entry AND again right before the + * dispatch, since encode/hash/upload all await) and drops the pending work + * without dispatching if the layer is no longer `paint` (or no longer + * exists). Otherwise a stale debounced flush would silently convert a + * parametric layer back to `paint` with wrong-extent pixels. + * - **Redundant-dispatch skip is ground-truth, not memory**: right before + * dispatching, a flush skips if the DOCUMENT's current bitmap ref (via + * `getLayerSource`) already equals the resolved image name — not if + * `lastApplied` (this store's memory of what it last dispatched) does. A + * round trip through a non-`paint` source and back (rasterize → undo → + * redo) leaves `lastApplied` pointing at a name the document no longer + * references (the redo lands on `{ bitmap: null }`); comparing against that + * stale memory would suppress the re-dispatch forever. Comparing against + * the document itself self-heals regardless of how it drifted. + * + * Every side-effecting dependency (encode, upload, hash, dispatch, timers) is + * injectable, so this runs in node tests with fakes. Zero React. + */ + +import type { CanvasImageUploadResult } from '@workbench/canvas-engine/backend/canvasImages'; +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { CanvasImageRef, CanvasLayerSourceContract } from '@workbench/types'; +import type { WorkbenchAction } from '@workbench/workbenchState'; + +/** Default idle window before a dirty layer is flushed. */ +export const DEFAULT_DEBOUNCE_MS = 1500; +/** Default upload attempts per flush (initial try + retries). */ +export const DEFAULT_MAX_UPLOAD_ATTEMPTS = 3; +/** Default backoff delays (ms) between upload retries. */ +export const DEFAULT_RETRY_DELAYS_MS = [250, 1000] as const; +/** Default cap on the hash→image dedupe map. */ +export const DEFAULT_DEDUPE_CAP = 64; + +/** Injectable timer seam (defaults to the global timers). */ +export interface BitmapStoreTimers { + setTimeout(handler: () => void, ms: number): number; + clearTimeout(handle: number): void; +} + +/** Dependencies for {@link createBitmapStore}. */ +export interface BitmapStoreDeps { + /** + * Returns a layer's live cache surface (its painted pixels) plus the layer-local + * `offset` its top-left pixel sits at (its content rect origin), or `null` when + * the cache is gone/empty. The surface is CONTENT-SIZED, so the encoded PNG + * covers only the painted region and the dispatched paint source carries the + * offset (loading rasterizes at it). Read atomically here so the encoded pixels + * and the offset always agree. + */ + getLayerSurface(layerId: string): { surface: RasterSurface; offset: { x: number; y: number } } | null; + /** + * Returns a layer's CURRENT document source, or `null` if the layer no + * longer exists. Used to guard a flush against a source-type change that + * happened AFTER the dirty mark was recorded — e.g. rasterize (paint) → + * undo (back to shape/gradient) — where the layer's cache surface still + * resolves (it isn't cleared by the source swap) but persisting it would + * dispatch stale paint pixels over a now-parametric layer. + */ + getLayerSource(layerId: string): CanvasLayerSourceContract | null; + /** Encodes a surface to an image `Blob` (PNG). Usually `backend.encodeSurface`. */ + encodeSurface(surface: RasterSurface): Promise; + /** Uploads a bitmap blob, resolving to its server image name and dimensions. */ + uploadImage(blob: Blob): Promise; + /** Dispatches to the reducer (the single swap-on-success `updateCanvasLayerSource`). */ + dispatch(action: WorkbenchAction): void; + /** + * Applies the persisted bitmap ref + offset to the layer's document contract, + * as the single swap-on-success dispatch. Lets the engine pick the right + * action per layer type — `updateCanvasLayerSource` (paint source) for raster/ + * control layers, `updateCanvasLayerConfig` (mask) for inpaint/regional masks — + * while the store stays type-agnostic. Absent ⇒ the default paint-source + * dispatch (used by the store's own tests, which only exercise paint layers). + */ + dispatchBitmap?(layerId: string, bitmap: CanvasImageRef, offset: { x: number; y: number }): void; + /** Content-hashes a blob (defaults to SHA-256 hex via `crypto.subtle`). */ + hashBlob?(blob: Blob): Promise; + /** Idle debounce window in ms (default {@link DEFAULT_DEBOUNCE_MS}). */ + debounceMs?: number; + /** Upload attempts per flush (default {@link DEFAULT_MAX_UPLOAD_ATTEMPTS}). */ + maxUploadAttempts?: number; + /** Backoff delays between retries (default {@link DEFAULT_RETRY_DELAYS_MS}). */ + retryDelaysMs?: readonly number[]; + /** Cap on the dedupe map (default {@link DEFAULT_DEDUPE_CAP}). */ + dedupeCap?: number; + /** Injectable timers (default: global). */ + timers?: BitmapStoreTimers; + /** Injectable delay used for retry backoff (default: `timers.setTimeout`). */ + sleep?(ms: number): Promise; + /** Reports a persistent flush/upload failure (default: `console.warn`). */ + onError?(error: unknown, layerId: string): void; +} + +/** The imperative bitmap-store handle. */ +export interface BitmapStore { + /** Marks a layer dirty and (re)arms its debounce timer. Called on each committed stroke. */ + markLayerDirty(layerId: string): void; + /** Flushes every dirty layer immediately and resolves once all in-flight uploads settle. */ + flushPendingUploads(): Promise; + /** + * True when `source` is exactly the paint bitmap ref this store most recently + * applied to `layerId` — i.e. the engine is seeing its own dispatch round-trip + * and must NOT re-rasterize/invalidate the cache (the pixels already match). + * A different bitmap (undo/import) returns `false` and re-rasterizes as usual. + */ + isSelfEcho(layerId: string, source: CanvasLayerSourceContract | null): boolean; + /** + * Drops the persistence bookkeeping that describes the OUTGOING document, for + * use on a wholesale document replacement. Clears the `lastApplied` self-echo + * map (a reused layer id in the new document could otherwise have a legit + * persistence dispatch suppressed forever) and any pending dirty/debounced + * work for the old document. The content-hash dedupe cache is intentionally + * kept — it is a pure content-addressed mapping (identical PNG bytes → the + * same immutable uploaded image) and so is never stale across documents. + */ + reset(): void; + /** Cancels all timers; in-flight uploads are left to settle (no dispatch after dispose). */ + dispose(): void; +} + +const defaultTimers: BitmapStoreTimers = { + clearTimeout: (handle) => globalThis.clearTimeout(handle), + setTimeout: (handler, ms) => globalThis.setTimeout(handler, ms), +}; + +/** SHA-256 hex of a blob's bytes, via the Web Crypto API (Node ≥ 20 exposes `crypto.subtle`). */ +const defaultHashBlob = async (blob: Blob): Promise => { + const buffer = await blob.arrayBuffer(); + const digest = await crypto.subtle.digest('SHA-256', buffer); + const bytes = new Uint8Array(digest); + let hex = ''; + for (const byte of bytes) { + hex += byte.toString(16).padStart(2, '0'); + } + return hex; +}; + +/** Creates a bitmap store wired to the given seams. */ +export const createBitmapStore = (deps: BitmapStoreDeps): BitmapStore => { + const debounceMs = deps.debounceMs ?? DEFAULT_DEBOUNCE_MS; + const maxAttempts = Math.max(1, deps.maxUploadAttempts ?? DEFAULT_MAX_UPLOAD_ATTEMPTS); + const retryDelays = deps.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS; + const dedupeCap = Math.max(1, deps.dedupeCap ?? DEFAULT_DEDUPE_CAP); + const timers = deps.timers ?? defaultTimers; + const hashBlob = deps.hashBlob ?? defaultHashBlob; + const sleep = + deps.sleep ?? + ((ms: number): Promise => + new Promise((resolve) => { + timers.setTimeout(resolve, ms); + })); + const reportError = (error: unknown, layerId: string): void => { + if (deps.onError) { + deps.onError(error, layerId); + } else { + // eslint-disable-next-line no-console + console.warn(`bitmapStore: failed to persist layer "${layerId}"`, error); + } + }; + + /** Layers awaiting a flush (either debounced or re-dirtied during a flush). */ + const dirty = new Set(); + /** + * Why a layer is currently in `dirty`: `'stroke'` means a new paint stroke + * (re)marked it — worth retrying inside a barrier call; `'failure'` means + * its last flush attempt exhausted upload retries — the barrier must not + * retry it again within the same {@link flushPendingUploads} call (anti-spin). + * A stroke landing after a failure flips this back to `'stroke'`. + */ + const dirtyReason = new Map(); + /** Active debounce timers, keyed by layer id. */ + const debounceTimers = new Map(); + /** The in-flight flush op per layer (at most one), used by the barrier and to serialize. */ + const inFlight = new Map>(); + /** Content-hash → uploaded image, an LRU-ish dedupe cache (bounded). */ + const hashToImage = new Map(); + /** Layer id → the image name most recently dispatched by this store (self-echo guard). */ + const lastApplied = new Map(); + let disposed = false; + + const clearTimer = (layerId: string): void => { + const handle = debounceTimers.get(layerId); + if (handle !== undefined) { + timers.clearTimeout(handle); + debounceTimers.delete(layerId); + } + }; + + const scheduleFlush = (layerId: string): void => { + clearTimer(layerId); + const handle = timers.setTimeout(() => { + debounceTimers.delete(layerId); + void runFlush(layerId); + }, debounceMs); + debounceTimers.set(layerId, handle); + }; + + const rememberDedupe = (hash: string, result: CanvasImageUploadResult): void => { + hashToImage.delete(hash); + hashToImage.set(hash, result); + while (hashToImage.size > dedupeCap) { + const oldest = hashToImage.keys().next().value; + if (oldest === undefined) { + break; + } + hashToImage.delete(oldest); + } + }; + + const touchDedupe = (hash: string, result: CanvasImageUploadResult): void => { + // Move to the most-recently-used end. + hashToImage.delete(hash); + hashToImage.set(hash, result); + }; + + const uploadWithRetry = async (blob: Blob): Promise => { + let lastError: unknown; + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + if (attempt > 0) { + const delay = retryDelays[Math.min(attempt - 1, retryDelays.length - 1)] ?? 0; + if (delay > 0) { + await sleep(delay); + } + } + try { + return await deps.uploadImage(blob); + } catch (error) { + lastError = error; + } + } + throw lastError ?? new Error('Canvas image upload failed'); + }; + + /** Encodes → hashes → dedupes/uploads → swaps the layer's ref, once. */ + const flushLayer = async (layerId: string): Promise => { + const placed = deps.getLayerSurface(layerId); + if (!placed) { + // Layer or its cache is gone (or empty); nothing to persist. + dirty.delete(layerId); + clearTimer(layerId); + return; + } + // Capture the surface AND its offset together at encode time so they agree: + // encode reads these pixels, and the dispatch below carries this offset. A + // growth during the async encode window re-marks the layer (its stroke marks + // it dirty), so a follow-up flush re-converges with the current rect + offset. + const { offset, surface } = placed; + // Source-type guard (see `getLayerSource` doc): the dirty mark may predate + // a conversion away from `paint` (rasterize → undo is the motivating case, + // but any convert-back qualifies). The cache surface still resolves above + // — a source swap doesn't clear it — so without this check we'd encode and + // dispatch a `paint` source over a layer that is no longer paint at all. + // Drop the pending flush entirely: nothing about this dirty mark is still + // valid, and a future genuine paint stroke will re-mark it if the layer + // ever becomes a paint layer again. + const sourceAtEntry = deps.getLayerSource(layerId); + if (!sourceAtEntry || sourceAtEntry.type !== 'paint') { + dirty.delete(layerId); + clearTimer(layerId); + return; + } + // Consume the dirty flag up front; a failure re-adds it below. A stroke that + // lands mid-flush re-marks the layer, so the finally handler re-schedules. + dirty.delete(layerId); + clearTimer(layerId); + + let hash: string; + let blob: Blob; + try { + blob = await deps.encodeSurface(surface); + hash = await hashBlob(blob); + } catch (error) { + reportError(error, layerId); + dirty.add(layerId); + dirtyReason.set(layerId, 'failure'); + return; + } + + let result = hashToImage.get(hash); + if (result) { + // Dedupe hit: identical pixels already uploaded — reuse the name, no upload. + touchDedupe(hash, result); + } else { + try { + result = await uploadWithRetry(blob); + } catch (error) { + // Swap-on-success: never dispatch on failure. The old ref stays valid + // and the layer stays dirty for a later retry. + reportError(error, layerId); + dirty.add(layerId); + dirtyReason.set(layerId, 'failure'); + return; + } + rememberDedupe(hash, result); + } + + if (disposed) { + return; + } + // Re-check the source right before dispatching: `encodeSurface`/`hashBlob`/ + // `uploadImage` above all awaited, so a source-type change (rasterize → + // undo) landing DURING that window would slip past the entry-time + // `sourceAtEntry` check otherwise. This is the final gate before the + // side-effecting dispatch. + const sourceNow = deps.getLayerSource(layerId); + if (!sourceNow || sourceNow.type !== 'paint') { + return; + } + // The DOCUMENT already points at this exact image (e.g. a re-flush of + // identical pixels that hash-deduped to an already-applied ref): skip a + // redundant dispatch and its self-echo round-trip. + // + // This is checked against `sourceNow` (the document's CURRENT bitmap ref), + // not `lastApplied` (this store's memory of what it last dispatched) — the + // two can diverge. `lastApplied` is never cleared when a layer's source is + // converted away from `paint` and back (e.g. `rasterizeLayer` → undo → + // redo: the document round-trips through a parametric source and back to + // `paint`, landing on `{ bitmap: null }`, while `lastApplied` still holds + // the previously-uploaded name from before the undo). Comparing against + // `lastApplied` in that case would suppress the dispatch forever, leaving + // the document permanently pointed at `bitmap: null` even though the + // dedupe correctly resolved the identical baked pixels back to the prior + // image. Comparing against the document's actual current ref is the + // ground truth for "is this dispatch a no-op" and self-heals regardless + // of how the document's source got out of sync with this store's memory. + // + // The OFFSET must match too: a pure-translation transform (drag + Apply) + // bakes byte-identical pixels → same hash → dedupe resolves to the + // already-referenced image, but the paint offset moved. Comparing only + // `imageName` would skip the dispatch that persists the new offset, so the + // translation would be silently lost on reload (and every re-flush would + // re-skip it forever). Compare the captured `offset` (which travels with + // this dispatch) against the document's current offset — absent offsets are + // the legacy origin `{ x: 0, y: 0 }`. + const currentOffset = sourceNow.bitmap ? (sourceNow.offset ?? { x: 0, y: 0 }) : null; + if ( + sourceNow.bitmap?.imageName === result.imageName && + currentOffset !== null && + currentOffset.x === offset.x && + currentOffset.y === offset.y + ) { + return; + } + + const bitmap: CanvasImageRef = { + contentHash: hash, + height: result.height, + imageName: result.imageName, + width: result.width, + }; + // Record BEFORE dispatching: `dispatch` may notify the mirror synchronously, + // so `isSelfEcho` must already see the applied name when the engine reacts. + lastApplied.set(layerId, result.imageName); + if (deps.dispatchBitmap) { + deps.dispatchBitmap(layerId, bitmap, { x: offset.x, y: offset.y }); + } else { + deps.dispatch({ + id: layerId, + source: { bitmap, offset: { x: offset.x, y: offset.y }, type: 'paint' }, + type: 'updateCanvasLayerSource', + }); + } + }; + + /** Runs (or joins) a flush for a layer, serializing to one in-flight op per layer. */ + const runFlush = (layerId: string): Promise => { + const existing = inFlight.get(layerId); + if (existing) { + return existing; + } + const op = flushLayer(layerId).finally(() => { + inFlight.delete(layerId); + // Re-dirtied during the flush (new stroke) or a failure re-queued it. + if (dirty.has(layerId) && !disposed) { + scheduleFlush(layerId); + } + }); + inFlight.set(layerId, op); + return op; + }; + + const markLayerDirty = (layerId: string): void => { + if (disposed) { + return; + } + dirty.add(layerId); + dirtyReason.set(layerId, 'stroke'); + scheduleFlush(layerId); + }; + + /** Safety net against a genuine infinite loop; real barrier calls settle in a handful of rounds. */ + const MAX_BARRIER_ITERATIONS = 10_000; + + const flushPendingUploads = async (): Promise => { + // Immediately flush every currently-dirty layer (cancelling its debounce), + // then await the in-flight ops — looping so a layer re-dirtied by a NEW + // stroke that lands while its upload is in flight gets a follow-up flush + // before the barrier resolves (the "document points at the latest painted + // pixels" guarantee). A layer whose flush FAILED within this barrier call + // is not retried again this call — only a fresh stroke re-enters the loop + // for it — so a persistently failing upload still can't spin the barrier + // forever. + const failedThisBarrier = new Set(); + for (let iteration = 0; iteration < MAX_BARRIER_ITERATIONS; iteration += 1) { + const toFlush = Array.from(dirty).filter((layerId) => !failedThisBarrier.has(layerId)); + for (const layerId of toFlush) { + clearTimer(layerId); + void runFlush(layerId); + } + const ops = [...inFlight.values()]; + if (ops.length === 0) { + return; + } + await Promise.all(ops); + for (const layerId of toFlush) { + if (dirty.has(layerId) && dirtyReason.get(layerId) === 'failure') { + failedThisBarrier.add(layerId); + } + } + } + }; + + const isSelfEcho = (layerId: string, source: CanvasLayerSourceContract | null): boolean => { + if (!source || source.type !== 'paint') { + return false; + } + const imageName = source.bitmap?.imageName; + return imageName !== undefined && lastApplied.get(layerId) === imageName; + }; + + const reset = (): void => { + // Cancel pending debounced flushes and drop dirty state for the OLD document. + for (const handle of debounceTimers.values()) { + timers.clearTimeout(handle); + } + debounceTimers.clear(); + dirty.clear(); + dirtyReason.clear(); + // The self-echo map is per-(old)document; a reused layer id in the new + // document must not inherit it. `hashToImage` is content-addressed and kept. + lastApplied.clear(); + }; + + const dispose = (): void => { + disposed = true; + for (const handle of debounceTimers.values()) { + timers.clearTimeout(handle); + } + debounceTimers.clear(); + dirty.clear(); + dirtyReason.clear(); + inFlight.clear(); + hashToImage.clear(); + lastApplied.clear(); + }; + + return { dispose, flushPendingUploads, isSelfEcho, markLayerDirty, reset }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/document/documentMirror.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/documentMirror.test.ts new file mode 100644 index 00000000000..51e1cb18c3e --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/documentMirror.test.ts @@ -0,0 +1,358 @@ +import type { + CanvasDocumentContractV2, + CanvasInpaintMaskLayerContract, + CanvasLayerContract, + CanvasRasterLayerContractV2, + CanvasStagingAreaContractV2, + CanvasStateContractV2, +} from '@workbench/types'; + +import { describe, expect, it, vi } from 'vitest'; + +import type { DocumentMirrorCallbacks } from './documentMirror'; + +import { createDocumentMirror } from './documentMirror'; + +const rasterLayer = (id: string, overrides: Partial = {}): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { image: { height: 10, imageName: id, width: 10 }, type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + ...overrides, +}); + +const makeDoc = ( + layers: CanvasLayerContract[], + overrides: Partial = {} +): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers, + selectedLayerId: null, + version: 2, + width: 100, + ...overrides, +}); + +const makeStaging = (): CanvasStagingAreaContractV2 => ({ + areThumbnailsVisible: false, + autoSwitchMode: 'off', + isVisible: false, + pendingImageIds: [], + pendingImages: [], + selectedImageIndex: 0, +}); + +const makeCanvas = (document: CanvasDocumentContractV2, documentRevision = 0): CanvasStateContractV2 => ({ + document, + documentRevision, + snapshots: [], + stagingArea: makeStaging(), + version: 2, +}); + +interface FakeProject { + id: string; + canvas: CanvasStateContractV2; +} + +const createFakeStore = (projects: FakeProject[]) => { + let state = { projects }; + const listeners = new Set<() => void>(); + return { + getState: () => state, + setState: (next: { projects: FakeProject[] }) => { + state = next; + for (const listener of listeners) { + listener(); + } + }, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +}; + +const spyCallbacks = () => ({ + onBboxChanged: vi.fn(), + onDocumentReplaced: vi.fn(), + onLayerOrderChanged: vi.fn(), + onLayersChanged: vi.fn(), + onStagingChanged: vi.fn(), +}); + +describe('createDocumentMirror', () => { + it('reports exactly the edited layer id when one layer changes by reference', () => { + const a = rasterLayer('a'); + const b = rasterLayer('b'); + const doc = makeDoc([a, b]); + const canvas = makeCanvas(doc); + const store = createFakeStore([{ canvas, id: 'p1' }]); + const callbacks = spyCallbacks(); + createDocumentMirror(store, 'p1', callbacks); + + // Replace only layer `a` (new object), keep `b` identity. A prop-only edit + // (like the reducer's `updateCanvasLayer`) keeps the `source` reference, so + // the id is reported as changed but NOT source-changed. + const nextDoc: CanvasDocumentContractV2 = { ...doc, layers: [{ ...a, opacity: 0.5 }, b] }; + store.setState({ projects: [{ canvas: { ...canvas, document: nextDoc }, id: 'p1' }] }); + + expect(callbacks.onLayersChanged).toHaveBeenCalledTimes(1); + expect(callbacks.onLayersChanged).toHaveBeenCalledWith(['a'], []); + expect(callbacks.onDocumentReplaced).not.toHaveBeenCalled(); + expect(callbacks.onBboxChanged).not.toHaveBeenCalled(); + }); + + it('reports a prop-only edit as changed but not source-changed, and a source swap as both', () => { + const a = rasterLayer('a'); + const doc = makeDoc([a]); + const canvas = makeCanvas(doc); + const store = createFakeStore([{ canvas, id: 'p1' }]); + const callbacks = spyCallbacks(); + createDocumentMirror(store, 'p1', callbacks); + + // Prop-only edit (opacity): spreading the prior layer preserves its `source` + // reference exactly as the reducer does, so the engine must NOT re-rasterize + // (which would clear an unflushed paint layer). + const opacityEdit: CanvasDocumentContractV2 = { ...doc, layers: [{ ...a, opacity: 0.5 }] }; + store.setState({ projects: [{ canvas: { ...canvas, document: opacityEdit }, id: 'p1' }] }); + expect(callbacks.onLayersChanged).toHaveBeenLastCalledWith(['a'], []); + + // Genuine source swap (new `source` object): reported as source-changed. + const swapped = store.getState().projects[0]!.canvas.document.layers[0] as CanvasRasterLayerContractV2; + const sourceSwap: CanvasDocumentContractV2 = { + ...doc, + layers: [{ ...swapped, source: { image: { height: 10, imageName: 'a-v2', width: 10 }, type: 'image' } }], + }; + store.setState({ projects: [{ canvas: { ...canvas, document: sourceSwap }, id: 'p1' }] }); + expect(callbacks.onLayersChanged).toHaveBeenLastCalledWith(['a'], ['a']); + }); + + it('for a mask: a fill-only change is NOT source-changed, a bitmap swap IS (protects unflushed strokes)', () => { + const mask: CanvasInpaintMaskLayerContract = { + blendMode: 'normal', + id: 'm', + isEnabled: true, + isLocked: false, + mask: { bitmap: null, fill: { color: '#e07575', style: 'diagonal' } }, + name: 'm', + opacity: 1, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'inpaint_mask', + }; + const doc = makeDoc([mask]); + const canvas = makeCanvas(doc); + const store = createFakeStore([{ canvas, id: 'p1' }]); + const callbacks = spyCallbacks(); + createDocumentMirror(store, 'p1', callbacks); + + // A fill-only change (new `mask` object, same `bitmap` ref): reported as + // changed but NOT source-changed — invalidating would clear unflushed strokes. + const fillEdit: CanvasDocumentContractV2 = { + ...doc, + layers: [{ ...mask, mask: { bitmap: null, fill: { color: '#00ff00', style: 'grid' } } }], + }; + store.setState({ projects: [{ canvas: { ...canvas, document: fillEdit }, id: 'p1' }] }); + expect(callbacks.onLayersChanged).toHaveBeenLastCalledWith(['m'], []); + + // A bitmap swap (persistence round-trip / undo): reported as source-changed. + const current = store.getState().projects[0]!.canvas.document.layers[0] as CanvasInpaintMaskLayerContract; + const bitmapSwap: CanvasDocumentContractV2 = { + ...doc, + layers: [{ ...current, mask: { ...current.mask, bitmap: { height: 20, imageName: 'mask-v1', width: 30 } } }], + }; + store.setState({ projects: [{ canvas: { ...canvas, document: bitmapSwap }, id: 'p1' }] }); + expect(callbacks.onLayersChanged).toHaveBeenLastCalledWith(['m'], ['m']); + }); + + it('reports added and removed layer ids', () => { + const a = rasterLayer('a'); + const doc = makeDoc([a]); + const canvas = makeCanvas(doc); + const store = createFakeStore([{ canvas, id: 'p1' }]); + const callbacks = spyCallbacks(); + createDocumentMirror(store, 'p1', callbacks); + + const b = rasterLayer('b'); + // Added layers are reported as source-changed (no prior cache to keep). + store.setState({ projects: [{ canvas: { ...canvas, document: { ...doc, layers: [b, a] } }, id: 'p1' }] }); + expect(callbacks.onLayersChanged).toHaveBeenLastCalledWith(['b'], ['b']); + + // A removal is a change but not a source change (the id has no incoming source). + store.setState({ projects: [{ canvas: { ...canvas, document: { ...doc, layers: [b] } }, id: 'p1' }] }); + expect(callbacks.onLayersChanged).toHaveBeenLastCalledWith(['a'], []); + }); + + it('fires onLayerOrderChanged exactly once on a pure reorder, with no layer ids reported', () => { + const a = rasterLayer('a'); + const b = rasterLayer('b'); + const doc = makeDoc([a, b]); + const canvas = makeCanvas(doc); + const store = createFakeStore([{ canvas, id: 'p1' }]); + const callbacks = spyCallbacks(); + createDocumentMirror(store, 'p1', callbacks); + + // New array reference, same element references, swapped order. + store.setState({ projects: [{ canvas: { ...canvas, document: { ...doc, layers: [b, a] } }, id: 'p1' }] }); + + expect(callbacks.onLayerOrderChanged).toHaveBeenCalledTimes(1); + expect(callbacks.onLayersChanged).not.toHaveBeenCalled(); + expect(callbacks.onDocumentReplaced).not.toHaveBeenCalled(); + expect(callbacks.onBboxChanged).not.toHaveBeenCalled(); + }); + + it('does not fire onLayerOrderChanged when the layers array is replaced with an identical order', () => { + const a = rasterLayer('a'); + const b = rasterLayer('b'); + const doc = makeDoc([a, b]); + const canvas = makeCanvas(doc); + const store = createFakeStore([{ canvas, id: 'p1' }]); + const callbacks = spyCallbacks(); + createDocumentMirror(store, 'p1', callbacks); + + // New array reference, same element references, same order: a true no-op churn. + store.setState({ projects: [{ canvas: { ...canvas, document: { ...doc, layers: [a, b] } }, id: 'p1' }] }); + + expect(callbacks.onLayerOrderChanged).not.toHaveBeenCalled(); + expect(callbacks.onLayersChanged).not.toHaveBeenCalled(); + }); + + it('fires onDocumentReplaced when dimensions change', () => { + const doc = makeDoc([rasterLayer('a')]); + const canvas = makeCanvas(doc); + const store = createFakeStore([{ canvas, id: 'p1' }]); + const callbacks = spyCallbacks(); + createDocumentMirror(store, 'p1', callbacks); + + store.setState({ + projects: [{ canvas: { ...canvas, document: { ...doc, width: 200 } }, id: 'p1' }], + }); + expect(callbacks.onDocumentReplaced).toHaveBeenCalledTimes(1); + expect(callbacks.onLayersChanged).not.toHaveBeenCalled(); + }); + + it('fires onDocumentReplaced when documentRevision changes, even with identical dims and layer ids', () => { + const doc = makeDoc([rasterLayer('a')]); + const canvas = makeCanvas(doc); + const store = createFakeStore([{ canvas, id: 'p1' }]); + const callbacks = spyCallbacks(); + createDocumentMirror(store, 'p1', callbacks); + + // A same-dims snapshot restore: structuredClone reuses layer ids and keeps + // width/height/background, so only the revision bump signals the swap. + const restored = structuredClone(doc); + store.setState({ projects: [{ canvas: makeCanvas(restored, 1), id: 'p1' }] }); + + expect(callbacks.onDocumentReplaced).toHaveBeenCalledTimes(1); + expect(callbacks.onLayersChanged).not.toHaveBeenCalled(); + expect(callbacks.onLayerOrderChanged).not.toHaveBeenCalled(); + }); + + it('does not fire onDocumentReplaced for an ordinary layer edit at an unchanged revision', () => { + const a = rasterLayer('a'); + const doc = makeDoc([a]); + const canvas = makeCanvas(doc); + const store = createFakeStore([{ canvas, id: 'p1' }]); + const callbacks = spyCallbacks(); + createDocumentMirror(store, 'p1', callbacks); + + const nextDoc: CanvasDocumentContractV2 = { ...doc, layers: [{ ...a, opacity: 0.5 }] }; + store.setState({ projects: [{ canvas: { ...canvas, document: nextDoc }, id: 'p1' }] }); + + expect(callbacks.onDocumentReplaced).not.toHaveBeenCalled(); + expect(callbacks.onLayersChanged).toHaveBeenCalledWith(['a'], []); + }); + + it('fires onBboxChanged when only the bbox moves', () => { + const layers = [rasterLayer('a')]; + const doc = makeDoc(layers); + const canvas = makeCanvas(doc); + const store = createFakeStore([{ canvas, id: 'p1' }]); + const callbacks = spyCallbacks(); + createDocumentMirror(store, 'p1', callbacks); + + // Same layers array identity, new bbox. + store.setState({ + projects: [ + { canvas: { ...canvas, document: { ...doc, bbox: { height: 100, width: 100, x: 10, y: 10 } } }, id: 'p1' }, + ], + }); + expect(callbacks.onBboxChanged).toHaveBeenCalledTimes(1); + expect(callbacks.onLayersChanged).not.toHaveBeenCalled(); + expect(callbacks.onDocumentReplaced).not.toHaveBeenCalled(); + }); + + it('fires onStagingChanged when the staging area reference changes', () => { + const doc = makeDoc([rasterLayer('a')]); + const canvas = makeCanvas(doc); + const store = createFakeStore([{ canvas, id: 'p1' }]); + const callbacks = spyCallbacks(); + createDocumentMirror(store, 'p1', callbacks); + + store.setState({ projects: [{ canvas: { ...canvas, stagingArea: makeStaging() }, id: 'p1' }] }); + expect(callbacks.onStagingChanged).toHaveBeenCalledTimes(1); + expect(callbacks.onDocumentReplaced).not.toHaveBeenCalled(); + }); + + it('is silent when an unrelated project changes (identity short-circuit)', () => { + const docA = makeDoc([rasterLayer('a')]); + const canvasA = makeCanvas(docA); + const docB = makeDoc([rasterLayer('z')]); + const canvasB = makeCanvas(docB); + const store = createFakeStore([ + { canvas: canvasA, id: 'p1' }, + { canvas: canvasB, id: 'p2' }, + ]); + const callbacks = spyCallbacks(); + createDocumentMirror(store, 'p1', callbacks); + + // Mutate only p2; p1's canvas keeps its identity. + store.setState({ + projects: [ + { canvas: canvasA, id: 'p1' }, + { canvas: makeCanvas(makeDoc([rasterLayer('z', { opacity: 0.2 })])), id: 'p2' }, + ], + }); + expect(callbacks.onLayersChanged).not.toHaveBeenCalled(); + expect(callbacks.onDocumentReplaced).not.toHaveBeenCalled(); + expect(callbacks.onBboxChanged).not.toHaveBeenCalled(); + expect(callbacks.onStagingChanged).not.toHaveBeenCalled(); + }); + + it('reports null and stays no-op safe when the project is deleted', () => { + const doc = makeDoc([rasterLayer('a')]); + const canvas = makeCanvas(doc); + const store = createFakeStore([{ canvas, id: 'p1' }]); + const callbacks = spyCallbacks(); + const mirror = createDocumentMirror(store, 'p1', callbacks); + + store.setState({ projects: [] }); + expect(callbacks.onDocumentReplaced).toHaveBeenCalledTimes(1); + expect(mirror.getDocument()).toBeNull(); + + // Further unrelated churn: no additional callbacks. + store.setState({ projects: [] }); + expect(callbacks.onDocumentReplaced).toHaveBeenCalledTimes(1); + }); + + it('stops observing after dispose', () => { + const doc = makeDoc([rasterLayer('a')]); + const canvas = makeCanvas(doc); + const store = createFakeStore([{ canvas, id: 'p1' }]); + const callbacks = spyCallbacks(); + const mirror = createDocumentMirror(store, 'p1', callbacks); + + mirror.dispose(); + store.setState({ projects: [{ canvas: makeCanvas(makeDoc([rasterLayer('a', { opacity: 0.1 })])), id: 'p1' }] }); + expect(callbacks.onLayersChanged).not.toHaveBeenCalled(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/document/documentMirror.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/documentMirror.ts new file mode 100644 index 00000000000..2f0a441e52f --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/documentMirror.ts @@ -0,0 +1,234 @@ +/** + * The document mirror: the one-way bridge from the reducer-owned canvas + * document into the engine. + * + * The reducer owns the document; the engine only reads it. External changes + * (layer edits, undo/redo, staging accept, project switch) arrive via a plain + * `store.subscribe`, and the mirror translates a new state into the narrowest + * possible set of callbacks by diffing against the last-seen references: + * + * - document object identical → nothing happened (fast path; this is what makes + * unrelated-project changes free, since the reducer preserves identity). + * - `documentRevision` changed → `onDocumentReplaced` (a full invalidate). This + * is bumped by the reducer on wholesale document swaps (snapshot restore, + * `replaceCanvasDocument`) that reuse the same dimensions and layer ids, which + * a reference/dimension diff alone cannot tell apart from an ordinary edit. + * - dimensions / background changed, or the document appeared/disappeared → + * `onDocumentReplaced` (a full invalidate). + * - otherwise diff the layer array element-wise by reference (id-keyed) → + * `onLayersChanged(changedIds, sourceChangedIds)`. The second argument is the + * subset of ids whose *rasterization source* reference changed (an image/paint + * swap, or a newly added layer) — as opposed to a prop/transform-only edit + * (opacity, blend, lock, visibility, rename, nudge) that replaces the layer + * object but keeps its `source`/`mask` reference. The engine invalidates a + * layer's raster cache ONLY for a source change: re-rasterizing on a prop edit + * is wasteful for image layers and destructive for an unflushed paint layer + * (a `bitmap: null` source rasterizes to a cleared surface, wiping cached + * strokes that have not been persisted yet). + * - layers array reference changed but every id kept the same element + * reference, just in a different order (a pure reorder) → + * `onLayerOrderChanged` (recomposite only; nothing to re-rasterize). + * - bbox value changed → `onBboxChanged`. + * - stagingArea reference changed → `onStagingChanged`. + * + * A missing/deleted project is a no-op safe: the mirror simply reports `null`. + * + * Zero React, zero import-time side effects. + */ + +import type { CanvasDocumentContractV2, CanvasStagingAreaContractV2, CanvasStateContractV2 } from '@workbench/types'; + +/** The minimal slice of a project the mirror reads. */ +interface MirrorProject { + id: string; + canvas: CanvasStateContractV2; +} + +/** The minimal store shape the mirror depends on (a superset of `WorkbenchStore`). */ +export interface DocumentMirrorStore { + getState(): { projects: readonly MirrorProject[] }; + subscribe(listener: () => void): () => void; +} + +/** Callbacks fired when the mirrored document changes. */ +export interface DocumentMirrorCallbacks { + /** + * One or more layers were added, removed, or replaced (the `changed` ids). + * `sourceChanged` is the subset whose rasterization source (`source` for + * raster/control layers, `mask` for guidance/mask layers) reference changed, + * plus any newly added layer — i.e. the layers whose cached pixels are now + * stale. A prop/transform-only edit reports the id in `changed` but NOT in + * `sourceChanged`, so the engine keeps (rather than clears) its raster cache. + */ + onLayersChanged(changed: string[], sourceChanged: string[]): void; + /** + * The layers array was reordered (a new array reference, same element + * references, different sequence) — recomposite the document with the new + * z-order, but nothing needs re-rasterizing. + */ + onLayerOrderChanged(): void; + /** The document was replaced wholesale (dims/background change, appear/disappear) — full invalidate. */ + onDocumentReplaced(): void; + /** The generation bounding box changed. */ + onBboxChanged(): void; + /** The staging area changed. */ + onStagingChanged(): void; +} + +/** The imperative mirror handle. */ +export interface DocumentMirror { + /** The current mirrored document, or `null` if the project is gone. */ + getDocument(): CanvasDocumentContractV2 | null; + /** Removes the store subscription. */ + dispose(): void; +} + +type Bbox = CanvasDocumentContractV2['bbox']; + +const bboxEqual = (a: Bbox, b: Bbox): boolean => + a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height; + +/** + * The reference whose change requires re-rasterizing a layer's cache: the + * `source` for raster/control layers, the mask's `bitmap` for guidance/mask + * layers. The reducer preserves this reference across a prop/transform-only edit + * (it spreads `...layer`), so comparing it distinguishes a genuine source swap + * from an opacity/blend/lock/visibility/rename/nudge tweak. + * + * For masks the reference is the mask BITMAP, not the whole `mask` object: the + * cache holds only the alpha stencil, so a fill-only change (colour/style/noise/ + * denoise-limit) must NOT invalidate it. Invalidating on a fill tweak would + * re-rasterize from `mask.bitmap` — clearing unflushed brush strokes that live + * only in the cache — while the compositor already reads the fresh fill from the + * mirror at draw time. A genuine bitmap swap (persistence round-trip, undo, + * import) still changes this reference and re-rasterizes (guarded by self-echo). + */ +const rasterSourceRef = (layer: CanvasDocumentContractV2['layers'][number]): unknown => + layer.type === 'raster' || layer.type === 'control' ? layer.source : layer.mask.bitmap; + +/** + * Diffs two layer arrays by object identity, keyed by layer id. Returns the ids + * of layers that were added, removed, or replaced (`changed`), and the subset + * whose rasterization source reference changed or that were newly added + * (`sourceChanged`) — the layers whose cached pixels are now stale. + */ +const diffLayers = ( + prev: readonly CanvasDocumentContractV2['layers'][number][], + next: readonly CanvasDocumentContractV2['layers'][number][] +): { changed: string[]; sourceChanged: string[] } => { + const prevById = new Map(prev.map((layer) => [layer.id, layer])); + const nextById = new Map(next.map((layer) => [layer.id, layer])); + const changed = new Set(); + const sourceChanged = new Set(); + + for (const layer of next) { + const before = prevById.get(layer.id); + if (!before) { + // Added: no prior cache, so its source is new by definition. + changed.add(layer.id); + sourceChanged.add(layer.id); + } else if (before !== layer) { + changed.add(layer.id); + if (rasterSourceRef(before) !== rasterSourceRef(layer)) { + sourceChanged.add(layer.id); + } + } + } + for (const layer of prev) { + if (!nextById.has(layer.id)) { + // Removed: reported so the engine drops its cache. Not a source change. + changed.add(layer.id); + } + } + return { changed: [...changed], sourceChanged: [...sourceChanged] }; +}; + +/** + * True when two layer arrays hold the same ids in a different sequence. + * Only meaningful to call once `diffLayers` has already confirmed no id was + * added, removed, or replaced by reference. + */ +const layerOrderChanged = ( + prev: readonly CanvasDocumentContractV2['layers'][number][], + next: readonly CanvasDocumentContractV2['layers'][number][] +): boolean => { + if (prev.length !== next.length) { + return true; + } + for (let i = 0; i < prev.length; i++) { + if (prev[i]?.id !== next[i]?.id) { + return true; + } + } + return false; +}; + +/** + * Creates a document mirror bound to `projectId`. Subscribes immediately and + * seeds the last-seen references from the current state (so no spurious + * callback fires on creation). + */ +export const createDocumentMirror = ( + store: DocumentMirrorStore, + projectId: string, + callbacks: DocumentMirrorCallbacks +): DocumentMirror => { + const selectCanvas = (): CanvasStateContractV2 | null => + store.getState().projects.find((project) => project.id === projectId)?.canvas ?? null; + + let lastDoc: CanvasDocumentContractV2 | null = selectCanvas()?.document ?? null; + let lastRevision: number = selectCanvas()?.documentRevision ?? 0; + let lastStaging: CanvasStagingAreaContractV2 | null = selectCanvas()?.stagingArea ?? null; + + const handleChange = (): void => { + const canvas = selectCanvas(); + const doc = canvas?.document ?? null; + const revision = canvas?.documentRevision ?? 0; + const staging = canvas?.stagingArea ?? null; + + if (doc !== lastDoc) { + const prevDoc = lastDoc; + const prevRevision = lastRevision; + lastDoc = doc; + lastRevision = revision; + + if (!prevDoc || !doc) { + // Document appeared or disappeared: treat as a full replacement. + callbacks.onDocumentReplaced(); + } else if ( + revision !== prevRevision || + prevDoc.width !== doc.width || + prevDoc.height !== doc.height || + prevDoc.background !== doc.background + ) { + // A wholesale swap (revision bump) or a dims/background change: the pixel + // history no longer describes the live document. + callbacks.onDocumentReplaced(); + } else { + if (prevDoc.layers !== doc.layers) { + const { changed, sourceChanged } = diffLayers(prevDoc.layers, doc.layers); + if (changed.length > 0) { + callbacks.onLayersChanged(changed, sourceChanged); + } else if (layerOrderChanged(prevDoc.layers, doc.layers)) { + callbacks.onLayerOrderChanged(); + } + } + if (!bboxEqual(prevDoc.bbox, doc.bbox)) { + callbacks.onBboxChanged(); + } + } + } + + if (staging !== lastStaging) { + lastStaging = staging; + callbacks.onStagingChanged(); + } + }; + + const unsubscribe = store.subscribe(handleChange); + + return { + dispose: unsubscribe, + getDocument: () => lastDoc, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/document/mergeDown.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/mergeDown.test.ts new file mode 100644 index 00000000000..176a28ade32 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/mergeDown.test.ts @@ -0,0 +1,53 @@ +import type { CanvasLayerBaseContract } from '@workbench/types'; + +import { fromTRS, multiply } from '@workbench/canvas-engine/math/mat2d'; +import { describe, expect, it } from 'vitest'; + +import { mergeDownMatrix } from './mergeDown'; + +type Transform = CanvasLayerBaseContract['transform']; + +const transform = (patch: Partial = {}): Transform => ({ + rotation: 0, + scaleX: 1, + scaleY: 1, + x: 0, + y: 0, + ...patch, +}); + +const toMat = (t: Transform) => fromTRS({ x: t.x, y: t.y }, t.rotation, t.scaleX, t.scaleY); + +const closeTo = (a: number, b: number) => Math.abs(a - b) < 1e-9; + +describe('mergeDownMatrix', () => { + it('is the identity when both transforms are identity', () => { + expect(mergeDownMatrix(transform(), transform())).toEqual({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }); + }); + + it('translates the upper cache into the below layer local space (offset below cancels)', () => { + // Below sits at (10, 20); the upper layer sits at the document origin. In + // below-local space the origin is therefore at (-10, -20). + const m = mergeDownMatrix(transform({ x: 10, y: 20 }), transform()); + expect(m).not.toBeNull(); + expect(m!.e).toBeCloseTo(-10); + expect(m!.f).toBeCloseTo(-20); + }); + + it('satisfies below · M = above, so merged pixels land at their original document position', () => { + const below = transform({ scaleX: 2, scaleY: 2, x: 5, y: 7 }); + const above = transform({ scaleX: 3, x: 40, y: 12 }); + const m = mergeDownMatrix(below, above); + expect(m).not.toBeNull(); + + const recomposed = multiply(toMat(below), m!); + const expected = toMat(above); + for (const key of ['a', 'b', 'c', 'd', 'e', 'f'] as const) { + expect(closeTo(recomposed[key], expected[key])).toBe(true); + } + }); + + it('returns null when the below transform is non-invertible (zero scale)', () => { + expect(mergeDownMatrix(transform({ scaleX: 0 }), transform())).toBeNull(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/document/mergeDown.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/mergeDown.ts new file mode 100644 index 00000000000..03d5f370a46 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/mergeDown.ts @@ -0,0 +1,38 @@ +/** + * Pure transform math for merging one layer down into the layer below it. + * + * "Merge down" bakes the upper layer's pixels into the lower layer while the + * reducer keeps the lower layer's transform verbatim (see + * `mergeCanvasLayersDown` — it never touches pixels). So the engine must draw + * the upper layer's cache into the lower layer's *local* space: upper-local → + * document (via the upper transform) → lower-local (via the inverse of the + * lower transform). Composing those gives the matrix returned here, which the + * engine feeds to `ctx.setTransform` when blitting the upper cache onto the + * merged surface. When the merged (lower) layer is later composited with its + * unchanged transform, the upper content lands back at its original document + * position: `lowerTransform · (lowerTransform⁻¹ · upperTransform) = upperTransform`. + * + * Zero React, zero DOM, zero import-time side effects. + */ + +import type { Mat2d } from '@workbench/canvas-engine/types'; +import type { CanvasLayerBaseContract } from '@workbench/types'; + +import { fromTRS, invert, multiply } from '@workbench/canvas-engine/math/mat2d'; + +type LayerTransform = CanvasLayerBaseContract['transform']; + +const transformToMat = (t: LayerTransform): Mat2d => fromTRS({ x: t.x, y: t.y }, t.rotation, t.scaleX, t.scaleY); + +/** + * The matrix that maps the upper layer's local (cache) space into the lower + * layer's local space: `inverse(lower) · upper`. Returns `null` when the lower + * transform is singular (zero scale) and cannot be inverted. + */ +export const mergeDownMatrix = (lowerTransform: LayerTransform, upperTransform: LayerTransform): Mat2d | null => { + const lowerInverse = invert(transformToMat(lowerTransform)); + if (!lowerInverse) { + return null; + } + return multiply(lowerInverse, transformToMat(upperTransform)); +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/document/mergeVisible.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/mergeVisible.test.ts new file mode 100644 index 00000000000..9585456a31d --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/mergeVisible.test.ts @@ -0,0 +1,119 @@ +import type { + CanvasInpaintMaskLayerContract, + CanvasLayerContract, + CanvasRasterLayerContractV2, +} from '@workbench/types'; + +import { describe, expect, it } from 'vitest'; + +import { canMergeVisibleRasters, planMergeVisibleRuns, planNextMergeVisibleStep } from './mergeVisible'; + +const raster = (id: string, overrides: Partial = {}): CanvasRasterLayerContractV2 => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { bitmap: null, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + ...overrides, +}); + +const mask = (id: string): CanvasInpaintMaskLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + mask: { bitmap: null, fill: { color: '#e07575', style: 'diagonal' } }, + name: id, + opacity: 1, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'inpaint_mask', +}); + +const gradientRaster = (id: string): CanvasLayerContract => + raster(id, { + source: { + angle: 0, + height: 10, + kind: 'linear', + stops: [ + { color: '#000', offset: 0 }, + { color: '#fff', offset: 1 }, + ], + type: 'gradient', + width: 10, + }, + }); + +describe('planMergeVisibleRuns', () => { + it('scenario A: a mask between two rasters does not split the run (interleaved global order)', () => { + // [R2(top), M, R1] — the case the round-1 adjacency planner silently no-oped. + const layers = [raster('r2'), mask('m1'), raster('r1')]; + expect(planMergeVisibleRuns(layers)).toEqual([['r2', 'r1']]); + expect(canMergeVisibleRasters(layers)).toBe(true); + }); + + it('scenario B: a hidden raster between two visible rasters does not split the run', () => { + const layers = [raster('r1'), raster('r2', { isEnabled: false }), raster('r3')]; + expect(planMergeVisibleRuns(layers)).toEqual([['r1', 'r3']]); + }); + + it('scenario C: two raster clusters separated only by masks fold as ONE run (legacy parity)', () => { + const layers = [raster('r1'), raster('r2'), mask('m1'), raster('r3'), raster('r4')]; + expect(planMergeVisibleRuns(layers)).toEqual([['r1', 'r2', 'r3', 'r4']]); + }); + + it('a visible locked raster RENDERS but cannot participate, so it splits the fold into runs', () => { + const layers = [raster('r1'), raster('r2'), raster('locked', { isLocked: true }), raster('r3'), raster('r4')]; + expect(planMergeVisibleRuns(layers)).toEqual([ + ['r1', 'r2'], + ['r3', 'r4'], + ]); + }); + + it('a visible parametric raster splits the fold; runs of one are dropped', () => { + const layers = [raster('r1'), gradientRaster('g1'), raster('r2')]; + expect(planMergeVisibleRuns(layers)).toEqual([]); + expect(canMergeVisibleRasters(layers)).toBe(false); + }); + + it('fewer than two participants means nothing to merge', () => { + expect(canMergeVisibleRasters([raster('r1')])).toBe(false); + expect(canMergeVisibleRasters([raster('r1'), raster('r2', { isEnabled: false })])).toBe(false); + expect(canMergeVisibleRasters([raster('r1'), raster('r2', { isLocked: true })])).toBe(false); + expect(canMergeVisibleRasters([])).toBe(false); + }); +}); + +describe('planNextMergeVisibleStep', () => { + it('returns a no-reorder step for a globally adjacent pair', () => { + const step = planNextMergeVisibleStep([raster('r1'), raster('r2'), mask('m1')]); + expect(step).toEqual({ lowerId: 'r2', orderedIds: null, upperId: 'r1' }); + }); + + it('plans a reorder that moves the upper directly above the lower across interleaved layers', () => { + // [R2, M, R1] → reorder to [M, R2, R1], then merge R2 → R1. + const step = planNextMergeVisibleStep([raster('r2'), mask('m1'), raster('r1')]); + expect(step).toEqual({ lowerId: 'r1', orderedIds: ['m1', 'r2', 'r1'], upperId: 'r2' }); + }); + + it('the reorder slides the upper past a hidden raster, leaving everything else in place', () => { + const step = planNextMergeVisibleStep([raster('r1'), raster('hidden', { isEnabled: false }), raster('r3')]); + expect(step).toEqual({ lowerId: 'r3', orderedIds: ['hidden', 'r1', 'r3'], upperId: 'r1' }); + }); + + it('always picks the topmost run first and returns null when nothing remains', () => { + const step = planNextMergeVisibleStep([ + raster('r1'), + raster('r2'), + raster('locked', { isLocked: true }), + raster('r3'), + raster('r4'), + ]); + expect(step).toEqual({ lowerId: 'r2', orderedIds: null, upperId: 'r1' }); + expect(planNextMergeVisibleStep([raster('r1'), mask('m1')])).toBeNull(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/document/mergeVisible.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/mergeVisible.ts new file mode 100644 index 00000000000..1e143905983 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/mergeVisible.ts @@ -0,0 +1,100 @@ +/** + * Pure planning for "merge visible" (the raster group-header action): folds ALL + * visible mergeable raster layers together, matching legacy's + * `compositor.mergeVisibleOfType('raster_layer')`, regardless of how non-raster + * layers interleave with them in the global z-array. + * + * Why interleaving is safe to merge across: since the Task 40 compositor fix the + * renderer draws layers by GROUP RANK (raster < control < regional guidance < + * inpaint mask), not by raw global order — a mask or control layer sitting between + * two rasters in the array never renders between them. Hidden layers of any type + * do not render at all. So the only layer that can visually separate two raster + * participants is another raster that RENDERS but cannot participate (a visible + * locked raster, or a visible parametric shape/text/gradient raster): merging + * across such a layer would reorder real raster-pass compositing, so it splits + * the fold into independent runs instead. + * + * The engine's `mergeLayerDown` only accepts globally-adjacent pairs, so each + * planned step may carry a reorder (`orderedIds`) that moves the upper participant + * to sit directly above its lower partner. That reorder only slides the upper past + * non-raster layers and/or hidden rasters (render-neutral, see above), and never + * changes the relative order of visible rasters. + * + * Kept pure (no engine, no React) so the planning is unit-testable in node. + */ + +import type { CanvasLayerContract } from '@workbench/types'; + +import { isMergeableRasterLayer } from './sources'; + +/** + * True when `layer` renders in the raster compositing pass but cannot join a + * merge: a visible locked raster, or a visible parametric (shape/text/gradient) + * raster. Such a layer splits the fold — merging across it would change which + * pixels composite above/below it. + */ +const isRasterRunBlocker = (layer: CanvasLayerContract): boolean => + layer.type === 'raster' && layer.isEnabled && !isMergeableRasterLayer(layer); + +/** + * Partitions the visible mergeable rasters (in global top-to-bottom order) into + * runs of ids that can fold together. Non-raster layers and hidden rasters never + * split a run; a rendering non-participant raster does. Only runs with at least + * two members are returned (a single raster has nothing to merge with). + */ +export const planMergeVisibleRuns = (layers: readonly CanvasLayerContract[]): string[][] => { + const runs: string[][] = []; + let current: string[] = []; + for (const layer of layers) { + if (isMergeableRasterLayer(layer)) { + current.push(layer.id); + } else if (isRasterRunBlocker(layer)) { + runs.push(current); + current = []; + } + } + runs.push(current); + return runs.filter((run) => run.length >= 2); +}; + +/** Whether the raster group's "merge visible" action has anything to do. */ +export const canMergeVisibleRasters = (layers: readonly CanvasLayerContract[]): boolean => + planMergeVisibleRuns(layers).length > 0; + +/** One step of the merge-visible fold. */ +export interface MergeVisibleStep { + /** The layer merged down (removed by the merge). */ + upperId: string; + /** The layer it merges into (survives, keeps its transform). */ + lowerId: string; + /** + * The full global id order that makes `upperId` directly adjacent above + * `lowerId`, or null when the pair is already adjacent. Dispatch as a + * `reorderCanvasLayers` before calling the engine's `mergeLayerDown`. + */ + orderedIds: string[] | null; +} + +/** + * The next fold step against the CURRENT document: the topmost run's first pair. + * The caller performs the (optional) reorder + merge, then re-plans against the + * updated document until this returns null — each merge removes one layer, so the + * fold strictly terminates. + */ +export const planNextMergeVisibleStep = (layers: readonly CanvasLayerContract[]): MergeVisibleStep | null => { + const run = planMergeVisibleRuns(layers)[0]; + const upperId = run?.[0]; + const lowerId = run?.[1]; + if (!upperId || !lowerId) { + return null; + } + const ids = layers.map((layer) => layer.id); + const upperIndex = ids.indexOf(upperId); + const lowerIndex = ids.indexOf(lowerId); + if (lowerIndex === upperIndex + 1) { + return { lowerId, orderedIds: null, upperId }; + } + const without = ids.filter((id) => id !== upperId); + without.splice(without.indexOf(lowerId), 0, upperId); + return { lowerId, orderedIds: without, upperId }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/document/sources.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/sources.test.ts new file mode 100644 index 00000000000..466fbb62711 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/sources.test.ts @@ -0,0 +1,129 @@ +import type { CanvasDocumentContractV2, CanvasLayerContract, CanvasLayerSourceContract } from '@workbench/types'; + +import { describe, expect, it } from 'vitest'; + +import { + getSourceBounds, + getSourceContentRect, + getSourcePixelSize, + isMaskLayer, + isRenderableLayer, + maskAsPaintSource, + renderableSourceOf, +} from './sources'; + +const doc = { height: 200, width: 300 } as CanvasDocumentContractV2; + +const inpaintMask = ( + bitmap: { imageName: string; width: number; height: number } | null, + offset?: { x: number; y: number } +): CanvasLayerContract => + ({ + blendMode: 'normal', + id: 'm1', + isEnabled: true, + isLocked: false, + mask: { bitmap, fill: { color: '#e07575', style: 'diagonal' }, ...(offset ? { offset } : {}) }, + name: 'M', + opacity: 1, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'inpaint_mask', + }) as CanvasLayerContract; + +describe('mask layers as paint-like sources', () => { + it('isMaskLayer / renderableSourceOf treat a mask as an alpha paint source', () => { + const layer = inpaintMask({ height: 30, imageName: 'mask', width: 40 }, { x: 5, y: 6 }); + expect(isMaskLayer(layer)).toBe(true); + expect(maskAsPaintSource(layer)).toEqual({ + bitmap: { height: 30, imageName: 'mask', width: 40 }, + offset: { x: 5, y: 6 }, + type: 'paint', + }); + expect(renderableSourceOf(layer)).toEqual(maskAsPaintSource(layer)); + }); + + it('an enabled mask is renderable; content rect is the bitmap dims at its offset', () => { + const layer = inpaintMask({ height: 30, imageName: 'mask', width: 40 }, { x: 5, y: 6 }); + expect(isRenderableLayer(layer)).toBe(true); + expect(getSourceContentRect(layer, doc)).toEqual({ height: 30, width: 40, x: 5, y: 6 }); + }); + + it('an empty (bitmap-less) mask is renderable but has an empty content rect', () => { + const layer = inpaintMask(null); + expect(isRenderableLayer(layer)).toBe(true); + expect(getSourceContentRect(layer, doc)).toEqual({ height: 0, width: 0, x: 0, y: 0 }); + }); + + it('a disabled mask is not renderable', () => { + const layer = inpaintMask({ height: 30, imageName: 'mask', width: 40 }); + (layer as { isEnabled: boolean }).isEnabled = false; + expect(isRenderableLayer(layer)).toBe(false); + }); +}); + +const rasterLayer = (source: CanvasLayerSourceContract, transformOver = {}): CanvasLayerContract => + ({ + blendMode: 'normal', + id: 'l1', + isEnabled: true, + isLocked: false, + name: 'L', + opacity: 1, + source, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0, ...transformOver }, + type: 'raster', + }) as CanvasLayerContract; + +const shape: CanvasLayerSourceContract = { + fill: '#000', + height: 40, + kind: 'rect', + stroke: null, + strokeWidth: 0, + type: 'shape', + width: 60, +}; + +const gradient: CanvasLayerSourceContract = { + angle: 0, + kind: 'linear', + stops: [{ color: '#000', offset: 0 }], + type: 'gradient', +}; + +describe('isRenderableLayer — parametric sources', () => { + it('renders rect/ellipse shapes and gradients', () => { + expect(isRenderableLayer(rasterLayer(shape))).toBe(true); + expect(isRenderableLayer(rasterLayer({ ...shape, kind: 'ellipse' }))).toBe(true); + expect(isRenderableLayer(rasterLayer(gradient))).toBe(true); + }); + + it('does not render a deferred polygon shape', () => { + expect(isRenderableLayer(rasterLayer({ ...shape, kind: 'polygon' }))).toBe(false); + }); + + it('does not render a disabled layer', () => { + expect(isRenderableLayer({ ...rasterLayer(shape), isEnabled: false })).toBe(false); + }); +}); + +describe('getSourcePixelSize — parametric sources', () => { + it('sizes a shape to its own extent', () => { + expect(getSourcePixelSize(rasterLayer(shape), doc)).toEqual({ height: 40, width: 60 }); + }); + + it('sizes a gradient to the document', () => { + expect(getSourcePixelSize(rasterLayer(gradient), doc)).toEqual({ height: 200, width: 300 }); + }); +}); + +describe('getSourceBounds — parametric sources', () => { + it('scales+offsets a shape by its transform', () => { + const bounds = getSourceBounds(rasterLayer(shape, { scaleX: 2, scaleY: 3, x: 10, y: 20 }), doc); + expect(bounds).toEqual({ height: 120, width: 120, x: 10, y: 20 }); + }); + + it('returns the whole document for a gradient', () => { + expect(getSourceBounds(rasterLayer(gradient), doc)).toEqual({ height: 200, width: 300, x: 0, y: 0 }); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/document/sources.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/sources.ts new file mode 100644 index 00000000000..b77fdd1d966 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/sources.ts @@ -0,0 +1,208 @@ +/** + * Small pure helpers over canvas layers and their sources, shared by the engine + * (cache sizing, culling, fit-to-content) and the rasterizers. + * + * `getSourceBounds` returns a layer's axis-aligned bounds in **document space** + * (used for culling / fit-to-content), while `getSourcePixelSize` returns the + * **native** (unscaled) pixel dimensions of the layer's raster cache surface — + * the compositor applies the layer transform when drawing, so caches hold + * unscaled pixels. `isRenderableLayer` reports whether a layer can be + * rasterized today (enabled, with an image/paint source). + * + * Zero React, zero import-time side effects. + */ + +import type { Rect } from '@workbench/canvas-engine/types'; +import type { CanvasDocumentContractV2, CanvasLayerContract, CanvasLayerSourceContract } from '@workbench/types'; + +import { fromTRS } from '@workbench/canvas-engine/math/mat2d'; +import { transformBounds } from '@workbench/canvas-engine/math/rect'; +import { estimateTextExtent } from '@workbench/canvas-engine/render/rasterizers/textRasterizer'; + +/** A layer type that carries a rasterizable `source` (raster or control). */ +type SourceLayer = Extract; + +/** A mask-bearing layer (inpaint mask / regional guidance); its `mask` holds an alpha bitmap. */ +type MaskLayer = Extract; + +/** True when a layer carries a `source` field (raster / control layers). */ +const hasSource = (layer: CanvasLayerContract): layer is SourceLayer => + layer.type === 'raster' || layer.type === 'control'; + +/** True when a layer carries a `mask` (inpaint mask / regional guidance). */ +export const isMaskLayer = (layer: CanvasLayerContract): layer is MaskLayer => + layer.type === 'inpaint_mask' || layer.type === 'regional_guidance'; + +/** The number of strict composite groups (see {@link layerGroupRank}). */ +export const LAYER_GROUP_COUNT = 4; + +/** + * The strict composite-group rank of a layer, ordered bottom→top: raster (0) < + * control (1) < regional guidance (2) < inpaint mask (3). Mirrors legacy + * `arrangeEntities` / `CanvasEntityRendererModule` and the layers panel's grouped + * sections, so a layer's global insertion index never lets it draw — or be + * hit-tested — out of group order (a raster created above a control layer still + * composites, and is grabbed, below it). The compositor draws groups in this + * order; the move/context-menu hit-test iterates them top→bottom, keeping the two + * in lockstep so the layer the user clicks is the layer they see on top. + */ +export const layerGroupRank = (layer: CanvasLayerContract): number => { + switch (layer.type) { + case 'control': + return 1; + case 'regional_guidance': + return 2; + case 'inpaint_mask': + return 3; + default: + // raster (and any future non-grouped renderable) composites at the bottom. + return 0; + } +}; + +/** + * A mask layer's alpha bitmap viewed as a `paint` source, so the whole paint + * pipeline (rasterize, content-sized cache, growth, stroke, persistence) can + * operate on masks unchanged: the mask stores an alpha stencil exactly like a + * paint bitmap; only the compositor differs (it colorizes the alpha with the + * layer's `fill` instead of blitting the RGBA directly). Returns `null` for a + * non-mask layer. + */ +export const maskAsPaintSource = ( + layer: CanvasLayerContract +): Extract | null => + isMaskLayer(layer) ? { bitmap: layer.mask.bitmap, offset: layer.mask.offset, type: 'paint' } : null; + +/** + * A layer's rasterizable source: its own `source` for raster/control layers, or + * a synthetic `paint` view of a mask layer's alpha bitmap. `null` for layers + * with neither (there are none today). This is the single "what pixels does this + * layer hold" accessor the engine's rasterize / cache / persistence paths share. + */ +export const renderableSourceOf = (layer: CanvasLayerContract): CanvasLayerSourceContract | null => { + if (hasSource(layer)) { + return layer.source; + } + return maskAsPaintSource(layer); +}; + +/** + * True when a layer's source is one the engine can rasterize today: image, + * paint, gradient, text, or a rect/ellipse shape. A `polygon` shape has no + * rasterizer yet (deferred), so it is not renderable. + */ +export const isRenderableLayer = (layer: CanvasLayerContract): boolean => { + if (!layer.isEnabled) { + return false; + } + // Mask layers are renderable whenever enabled: an empty (bitmap-less) mask + // rasterizes to a zero-rect surface (skipped downstream, like empty paint). + if (isMaskLayer(layer)) { + return true; + } + if (!hasSource(layer)) { + return false; + } + const { source } = layer; + switch (source.type) { + case 'image': + case 'paint': + case 'gradient': + case 'text': + return true; + case 'shape': + return source.kind !== 'polygon'; + default: + return false; + } +}; + +/** + * True when a layer carries pixels the engine can rasterize AND merge: an + * enabled, unlocked paint/image raster layer. Narrower than + * {@link isRenderableLayer} — masks, control layers, shapes, text, and gradient + * layers are all renderable but NOT mergeable. Locked matches the paint tool's + * rule (`isLocked || !isEnabled` refuses a paint target): merge writes pixels + * into the below layer and deletes the upper one, so it must refuse a locked + * layer on either side just as painting refuses a locked target. The layers + * panel's merge-down enablement (`canMergeLayerDown`) and the engine's + * `mergeLayerDown` guard both defer to this single predicate so the hotkey, + * context menu, and engine can never disagree about what is mergeable. + */ +export const isMergeableRasterLayer = (layer: CanvasLayerContract): boolean => + layer.isEnabled && + !layer.isLocked && + layer.type === 'raster' && + (layer.source.type === 'paint' || layer.source.type === 'image'); + +/** + * A layer's content rectangle in its LOCAL (untransformed) coordinate space — + * the extent its raster cache surface covers, before the layer transform is + * applied. Every layer type is content-sized: + * + * - `image`: `[0, 0, w, h]` at the image's native pixels. + * - `shape` / `text`: `[0, 0, w, h]` at the source's own / estimated extent. + * - `gradient`: `[0, 0, width, height]` from the explicit extent, defaulting to + * the document dims for legacy gradients that predate the extent field. + * - `paint`: the persisted bitmap's dims at its `offset` (legacy default `0, 0`), + * or an EMPTY rect when the layer has no bitmap yet (a brand-new paint layer). + * + * Throws for layers without a rasterizable source, so callers fail loudly. + */ +export const getSourceContentRect = (layer: CanvasLayerContract, doc: CanvasDocumentContractV2): Rect => { + const source = renderableSourceOf(layer); + if (!source) { + throw new Error(`getSourceContentRect: layer type '${layer.type}' has no rasterizable source`); + } + switch (source.type) { + case 'image': + return { height: source.image.height, width: source.image.width, x: 0, y: 0 }; + case 'shape': + return { + height: Math.max(1, Math.round(source.height)), + width: Math.max(1, Math.round(source.width)), + x: 0, + y: 0, + }; + case 'text': { + const extent = estimateTextExtent(source); + return { height: extent.height, width: extent.width, x: 0, y: 0 }; + } + case 'gradient': { + // Explicit extent when present; legacy gradients (no extent) were + // document-sized by construction, so default to the document dims. + const width = source.width ?? doc.width; + const height = source.height ?? doc.height; + return { height, width, x: 0, y: 0 }; + } + case 'paint': { + if (!source.bitmap) { + // A brand-new / cleared paint layer holds no pixels: an empty rect. + return { height: 0, width: 0, x: 0, y: 0 }; + } + const offset = source.offset ?? { x: 0, y: 0 }; + return { height: source.bitmap.height, width: source.bitmap.width, x: offset.x, y: offset.y }; + } + } +}; + +/** + * A layer's axis-aligned bounds in DOCUMENT space: its {@link getSourceContentRect} + * projected through the layer transform (rotation-aware). Used for culling and + * fit-to-content. Throws for layers without a rasterizable source. + */ +export const getSourceBounds = (layer: CanvasLayerContract, doc: CanvasDocumentContractV2): Rect => { + const contentRect = getSourceContentRect(layer, doc); + const { transform } = layer; + const matrix = fromTRS({ x: transform.x, y: transform.y }, transform.rotation, transform.scaleX, transform.scaleY); + return transformBounds(matrix, contentRect); +}; + +/** The native (unscaled) pixel size of a layer's raster cache surface. */ +export const getSourcePixelSize = ( + layer: CanvasLayerContract, + doc: CanvasDocumentContractV2 +): { width: number; height: number } => { + const rect = getSourceContentRect(layer, doc); + return { height: rect.height, width: rect.width }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.mask.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.mask.test.ts new file mode 100644 index 00000000000..1f595a7cd78 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.mask.test.ts @@ -0,0 +1,352 @@ +/** + * Inpaint mask engine behaviour: painting into a mask's alpha cache, persisting + * the mask bitmap through the bitmap store, and the in-place mask invert. + * + * Isolated in its own file so it can `vi.mock` the canvas-image upload seam + * (the engine's INTERNAL bitmap store uses it) without touching the rest of the + * engine test suite. + */ + +import type { StubRasterSurface } from '@workbench/canvas-engine/render/raster.testStub'; +import type { CanvasDocumentContractV2, CanvasStateContractV2, WorkbenchState } from '@workbench/types'; +import type { WorkbenchAction } from '@workbench/workbenchState'; + +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { EngineStore } from './engine'; +import type { StrokeCommittedEvent } from './tools/tool'; + +import { createCanvasEngine } from './engine'; + +vi.mock('./backend/canvasImages', () => ({ + CanvasImageUploadError: class extends Error {}, + uploadCanvasImage: vi.fn(() => Promise.resolve({ height: 64, imageName: 'mask-img', width: 64 })), +})); + +const makeCanvas = (document: CanvasDocumentContractV2, documentRevision = 0): CanvasStateContractV2 => + ({ + document, + documentRevision, + snapshots: [], + stagingArea: { + areThumbnailsVisible: false, + autoSwitchMode: 'off', + isVisible: false, + pendingImageIds: [], + pendingImages: [], + selectedImageIndex: 0, + }, + version: 2, + }) as CanvasStateContractV2; + +const maskDoc = (): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers: [ + { + blendMode: 'normal', + id: 'mask1', + isEnabled: true, + isLocked: false, + mask: { bitmap: null, fill: { color: '#e07575', style: 'diagonal' } }, + name: 'Inpaint Mask 1', + opacity: 1, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'inpaint_mask', + }, + ], + selectedLayerId: 'mask1', + version: 2, + width: 100, +}); + +const createReactiveStore = (document: CanvasDocumentContractV2) => { + let state = { projects: [{ canvas: makeCanvas(document), id: 'p1' }] } as unknown as WorkbenchState; + const listeners = new Set<() => void>(); + const dispatch = vi.fn<(action: WorkbenchAction) => void>(); + return { + dispatch, + setDocument: (next: CanvasDocumentContractV2, revision = 0) => { + state = { projects: [{ canvas: makeCanvas(next, revision), id: 'p1' }] } as unknown as WorkbenchState; + for (const listener of listeners) { + listener(); + } + }, + store: { + dispatch, + getState: () => state, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + } as unknown as EngineStore, + }; +}; + +const createControllableRaf = () => { + let nextHandle = 1; + const callbacks = new Map(); + return { + cancelFrame: (handle: number) => callbacks.delete(handle), + flush: () => { + const queued = [...callbacks.values()]; + callbacks.clear(); + for (const cb of queued) { + cb(0); + } + }, + requestFrame: (cb: FrameRequestCallback) => { + const handle = nextHandle++; + callbacks.set(handle, cb); + return handle; + }, + }; +}; + +const createInputCanvas = (width = 100, height = 100) => { + const surface = createTestStubRasterBackend().createSurface(width, height); + const listeners = new Map void>>(); + const element = { + addEventListener: (type: string, handler: (event: Event) => void) => { + const set = listeners.get(type) ?? new Set(); + set.add(handler); + listeners.set(type, set); + }, + getBoundingClientRect: () => ({ bottom: height, height, left: 0, right: width, top: 0, width, x: 0, y: 0 }), + getContext: () => surface.ctx, + height, + releasePointerCapture: () => {}, + removeEventListener: (type: string, handler: (event: Event) => void) => { + listeners.get(type)?.delete(handler); + }, + setPointerCapture: () => {}, + width, + } as unknown as HTMLCanvasElement; + const fire = (type: string, event: Partial) => { + for (const handler of listeners.get(type) ?? []) { + handler({ preventDefault: () => {}, ...event } as unknown as Event); + } + }; + return { element, fire }; +}; + +const pointerAt = (x: number, y: number, buttons = 1): Partial => + ({ + altKey: false, + button: 0, + buttons, + clientX: x, + clientY: y, + ctrlKey: false, + metaKey: false, + pointerId: 1, + pointerType: 'mouse', + pressure: 0.5, + shiftKey: false, + timeStamp: 0, + }) as Partial; + +const setupEngine = (doc: CanvasDocumentContractV2) => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal('Path2D', class FakePath2D {}); + + const reactive = createReactiveStore(doc); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store: reactive.store, + }); + const strokes: StrokeCommittedEvent[] = []; + engine.onStrokeCommitted((event) => strokes.push(event)); + + const screen = createInputCanvas(); + const overlay = createInputCanvas(); + engine.attach(screen.element, overlay.element); + raf.flush(); + + return { dispatch: reactive.dispatch, engine, overlay, raf, setDocument: reactive.setDocument, strokes }; +}; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('inpaint mask painting', () => { + it('routes a brush stroke into the selected mask cache (no auto-created paint layer)', () => { + const { dispatch, engine, overlay, strokes } = setupEngine(maskDoc()); + engine.setTool('brush'); + overlay.fire('pointerdown', pointerAt(20, 20)); + overlay.fire('pointermove', pointerAt(40, 40)); + overlay.fire('pointerup', pointerAt(40, 40, 0)); + + expect(strokes).toHaveLength(1); + expect(strokes[0]!.layerId).toBe('mask1'); + expect(strokes[0]!.tool).toBe('brush'); + // A mask target must NOT spawn a fresh paint layer (the auto-create path). + const added = dispatch.mock.calls.map((c) => c[0]).filter((a) => a.type === 'addCanvasLayer'); + expect(added).toHaveLength(0); + engine.dispose(); + }); + + it('erases from the mask cache with the eraser (destination-out) as a committed stroke', () => { + const { engine, overlay, strokes } = setupEngine(maskDoc()); + // Paint some coverage first. + engine.setTool('brush'); + overlay.fire('pointerdown', pointerAt(20, 20)); + overlay.fire('pointermove', pointerAt(60, 60)); + overlay.fire('pointerup', pointerAt(60, 60, 0)); + // Then erase. + engine.setTool('eraser'); + overlay.fire('pointerdown', pointerAt(30, 30)); + overlay.fire('pointermove', pointerAt(40, 40)); + overlay.fire('pointerup', pointerAt(40, 40, 0)); + + expect(strokes).toHaveLength(2); + expect(strokes[1]!.tool).toBe('eraser'); + expect(strokes[1]!.layerId).toBe('mask1'); + engine.dispose(); + }); + + it('does not paint into a locked mask', () => { + const doc = maskDoc(); + doc.layers[0]!.isLocked = true; + const { dispatch, engine, overlay, strokes } = setupEngine(doc); + engine.setTool('brush'); + overlay.fire('pointerdown', pointerAt(20, 20)); + overlay.fire('pointermove', pointerAt(40, 40)); + overlay.fire('pointerup', pointerAt(40, 40, 0)); + + expect(strokes).toHaveLength(0); + // Also never spawns a paint layer over the locked mask. + expect(dispatch.mock.calls.map((c) => c[0]).some((a) => a.type === 'addCanvasLayer')).toBe(false); + engine.dispose(); + }); + + it('persists the mask via updateCanvasLayerConfig (bitmap + offset) after a stroke flush', async () => { + const { dispatch, engine, overlay } = setupEngine(maskDoc()); + engine.setTool('brush'); + overlay.fire('pointerdown', pointerAt(20, 20)); + overlay.fire('pointermove', pointerAt(50, 50)); + overlay.fire('pointerup', pointerAt(50, 50, 0)); + + await engine.flushPendingUploads(); + + const configDispatch = dispatch.mock.calls + .map((c) => c[0]) + .find( + (a): a is Extract => + a.type === 'updateCanvasLayerConfig' && a.id === 'mask1' + ); + expect(configDispatch).toBeDefined(); + const config = configDispatch!.config; + expect(config.layerType).toBe('inpaint_mask'); + // The mask bitmap ref + its content offset are dispatched (not an image source). + expect(config).toHaveProperty('mask'); + const mask = (config as { mask: { bitmap: unknown; offset: unknown } }).mask; + expect(mask.bitmap).toMatchObject({ imageName: 'mask-img' }); + expect(mask.offset).toBeDefined(); + // The mask persistence must NEVER dispatch a paint source (that would convert + // the mask into a raster paint layer). + expect(dispatch.mock.calls.map((c) => c[0]).some((a) => a.type === 'updateCanvasLayerSource')).toBe(false); + engine.dispose(); + }); +}); + +describe('mask invert', () => { + it('inverts a mask as an undoable op and returns true', () => { + const { engine } = setupEngine(maskDoc()); + expect(engine.stores.canUndo.get()).toBe(false); + expect(engine.invertMask('mask1')).toBe(true); + // One undoable image patch was recorded. + expect(engine.stores.canUndo.get()).toBe(true); + // Round trips: undo then redo restore without throwing. + engine.undo(); + expect(engine.stores.canRedo.get()).toBe(true); + engine.redo(); + expect(engine.stores.canUndo.get()).toBe(true); + engine.dispose(); + }); + + it('returns false for a missing layer, a non-mask layer, or a locked mask', () => { + const lockedDoc = maskDoc(); + lockedDoc.layers[0]!.isLocked = true; + const { engine } = setupEngine(lockedDoc); + expect(engine.invertMask('nope')).toBe(false); + expect(engine.invertMask('mask1')).toBe(false); // locked + engine.dispose(); + }); + + // Regression: the invert domain used to come ONLY from `getSourceContentRect`, + // which reads the persisted `mask.bitmap` — stale/null until the debounced + // bitmap-store flush runs. A stroke painted moments earlier (already reflected + // in the live `layerCache`, not yet flushed to the contract) that extends past + // the document bbox was silently excluded from the invert, so its pixels never + // got flipped. The domain must union in the live cache rect too. + it('unions the live (unflushed) cache rect into the invert domain, covering an out-of-bbox stroke', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal('Path2D', class FakePath2D {}); + + const reactive = createReactiveStore(maskDoc()); + const base = createTestStubRasterBackend(); + const surfaces: StubRasterSurface[] = []; + const backend = { + ...base, + createSurface: (w: number, h: number): StubRasterSurface => { + const surface = base.createSurface(w, h); + surfaces.push(surface); + return surface; + }, + }; + const engine = createCanvasEngine({ + backend, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store: reactive.store, + }); + const overlay = createInputCanvas(); + const screen = createInputCanvas(); + engine.attach(screen.element, overlay.element); + raf.flush(); + + engine.setTool('brush'); + // Paint well outside the document bbox (0,0,100,100 per `maskDoc`). The live + // cache grows to cover the stroke immediately; deliberately never call + // `flushPendingUploads()`, so the contract's `mask.bitmap` stays null and + // `getSourceContentRect` alone would report an empty content rect. + overlay.fire('pointerdown', pointerAt(150, 150)); + overlay.fire('pointermove', pointerAt(180, 180)); + overlay.fire('pointerup', pointerAt(180, 180, 0)); + + expect(engine.invertMask('mask1')).toBe(true); + + // Growing the cache (both while painting and inside `invertMask` itself) + // reallocates a fresh backing surface each time its extent changes, so + // `surfaces` holds one entry per intermediate size, not one per layer. The + // invert's own read/write always lands on the LAST surface that gets a + // `putImageData` — the final, invert-sized surface — so pick that one + // rather than the first surface that happens to have a `getImageData` + // (which would be a stale, already-abandoned paint-time surface). + const putSurfaces = surfaces.filter((surface) => surface.callLog.some((entry) => entry.op === 'putImageData')); + const maskCache = putSurfaces[putSurfaces.length - 1]; + expect(maskCache).toBeDefined(); + const getCalls = maskCache!.callLog.filter((entry) => entry.op === 'getImageData'); + expect(getCalls.length).toBeGreaterThan(0); + const [sx, sy, sw, sh] = getCalls[getCalls.length - 1]!.args as [number, number, number, number]; + // A domain of just the document bbox (0,0,100,100) — what `getSourceContentRect` + // alone would report, since `mask.bitmap` is still null pre-flush — would read + // a region that ends at x/y 100. Unioning in the live cache rect must grow the + // domain to also cover the stroke out at document (150,150)-(180,180), well + // past that 100 bound on both axes. + expect(sx + sw).toBeGreaterThan(110); + expect(sy + sh).toBeGreaterThan(110); + + engine.dispose(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.test.ts new file mode 100644 index 00000000000..09a1daf4e8c --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.test.ts @@ -0,0 +1,4725 @@ +import type * as AdjustedSurfaceCacheModule from '@workbench/canvas-engine/render/adjustedSurfaceCache'; +import type { + RasterCallLogEntry, + StubRasterBackend, + StubRasterSurface, +} from '@workbench/canvas-engine/render/raster.testStub'; +import type { ToolId } from '@workbench/canvas-engine/types'; +import type { + CanvasDocumentContractV2, + CanvasInpaintMaskLayerContract, + CanvasLayerContract, + CanvasLayerSourceContract, + CanvasRasterLayerContractV2, + CanvasStateContractV2, + WorkbenchState, +} from '@workbench/types'; +import type { WorkbenchAction } from '@workbench/workbenchState'; + +import { DEFAULT_CHECKER_COLORS } from '@workbench/canvas-engine/render/compositor'; +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { createInitialWorkbenchState, workbenchReducer } from '@workbench/workbenchState'; +import { afterEach, describe, expect, it, type Mock, vi } from 'vitest'; + +import type { BitmapStore } from './document/bitmapStore'; +import type { EngineStore } from './engine'; +import type { StrokeCommittedEvent } from './tools/tool'; + +import { createBitmapStore } from './document/bitmapStore'; +import { mergeDownMatrix } from './document/mergeDown'; +import { createCanvasEngine } from './engine'; + +// Records every layer id the engine tells its adjusted-surface cache to drop, so +// the layer-removal cleanup wiring (Task 39, finding 2) can be asserted without +// exposing the cache. The factory wraps the real implementation, preserving all +// behaviour and only spying on `delete`. +const adjustedSurfaceCacheDeletes = vi.hoisted(() => [] as string[]); + +vi.mock('@workbench/canvas-engine/render/adjustedSurfaceCache', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createAdjustedSurfaceCache: (backend: Parameters[0]) => { + const cache = actual.createAdjustedSurfaceCache(backend); + return { + ...cache, + delete: (layerId: string) => { + adjustedSurfaceCacheDeletes.push(layerId); + cache.delete(layerId); + }, + }; + }, + }; +}); + +const rasterLayer = (id: string, opts: { imageName?: string; opacity?: number } = {}): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: opts.opacity ?? 1, + source: { image: { height: 10, imageName: opts.imageName ?? id, width: 10 }, type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', +}); + +const makeDoc = (): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers: [rasterLayer('a')], + selectedLayerId: null, + version: 2, + width: 100, +}); + +const makeCanvas = (document: CanvasDocumentContractV2, documentRevision = 0): CanvasStateContractV2 => ({ + document, + documentRevision, + snapshots: [], + stagingArea: { + areThumbnailsVisible: false, + autoSwitchMode: 'off', + isVisible: false, + pendingImageIds: [], + pendingImages: [], + selectedImageIndex: 0, + }, + version: 2, +}); + +const createFakeStore = ( + document: CanvasDocumentContractV2 +): { store: EngineStore; unsubscribe: ReturnType } => { + const state = { projects: [{ canvas: makeCanvas(document), id: 'p1' }] } as unknown as WorkbenchState; + const unsubscribe = vi.fn(); + return { + store: { + dispatch: vi.fn(), + getState: () => state, + subscribe: () => unsubscribe, + }, + unsubscribe, + }; +}; + +const createEngine = () => { + const doc = makeDoc(); + const { store, unsubscribe } = createFakeStore(doc); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + return { doc, engine, unsubscribe }; +}; + +// ---- Reactive store + render-loop harness ----------------------------- +// +// The tests above never `attach()`, so the render loop (and thus +// `ensureLayerCaches`) never runs. The tests below exercise the document +// mirror wired into a live engine, so they need: a store that actually +// notifies subscribers on change, a controllable `requestAnimationFrame` +// pair to drive the (otherwise real) scheduler deterministically, and fake +// canvases whose `getContext('2d')` returns a recording stub context. + +/** A reactive fake store: `setDocument` notifies subscribers, unlike `createFakeStore` above. */ +const createReactiveStore = ( + document: CanvasDocumentContractV2 +): { + setDocument: (next: CanvasDocumentContractV2, documentRevision?: number) => void; + store: EngineStore; +} => { + let revision = 0; + let state = { projects: [{ canvas: makeCanvas(document), id: 'p1' }] } as unknown as WorkbenchState; + const listeners = new Set<() => void>(); + return { + setDocument: (next, documentRevision = revision) => { + revision = documentRevision; + state = { + projects: [{ canvas: makeCanvas(next, documentRevision), id: 'p1' }], + } as unknown as WorkbenchState; + for (const listener of listeners) { + listener(); + } + }, + store: { + dispatch: vi.fn(), + getState: () => state, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }, + }; +}; + +/** A controllable `requestAnimationFrame`/`cancelAnimationFrame` pair for driving the scheduler. */ +const createControllableRaf = () => { + let nextHandle = 1; + const callbacks = new Map(); + return { + cancelFrame: (handle: number): void => { + callbacks.delete(handle); + }, + /** Runs every currently-queued frame callback, like the browser firing a frame. */ + flush: (): void => { + const queued = [...callbacks.entries()]; + callbacks.clear(); + for (const [, cb] of queued) { + cb(0); + } + }, + pendingCount: (): number => callbacks.size, + requestFrame: (cb: FrameRequestCallback): number => { + const handle = nextHandle++; + callbacks.set(handle, cb); + return handle; + }, + }; +}; + +/** A minimal fake `HTMLCanvasElement`: a recording 2D context, no-op listeners. */ +const createFakeCanvas = (width = 100, height = 100): { element: HTMLCanvasElement; surface: StubRasterSurface } => { + const surface = createTestStubRasterBackend().createSurface(width, height); + const listeners = new Map void>>(); + const element = { + addEventListener: (type: string, handler: (event: Event) => void) => { + let set = listeners.get(type); + if (!set) { + set = new Set(); + listeners.set(type, set); + } + set.add(handler); + }, + getBoundingClientRect: () => ({ + bottom: height, + height, + left: 0, + right: width, + toJSON: () => ({}), + top: 0, + width, + x: 0, + y: 0, + }), + getContext: () => surface.ctx, + height, + releasePointerCapture: () => {}, + removeEventListener: (type: string, handler: (event: Event) => void) => { + listeners.get(type)?.delete(handler); + }, + setPointerCapture: () => {}, + width, + } as unknown as HTMLCanvasElement; + return { element, surface }; +}; + +/** A deferred promise, so a test can resolve a rasterize step on demand. */ +const createDeferred = (): { promise: Promise; resolve: (value: T) => void } => { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +}; + +/** Flushes pending microtasks (promise chains) without depending on fake timers. */ +const flushMicrotasks = (): Promise => + new Promise((resolve) => { + setTimeout(resolve, 0); + }); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('createCanvasEngine', () => { + it('mirrors the reducer-owned document on creation', () => { + const { doc, engine } = createEngine(); + expect(engine.getDocument()).toBe(doc); + engine.dispose(); + }); + + it('exposes an initial viewport and stores', () => { + const { engine } = createEngine(); + expect(engine.getViewport().getZoom()).toBe(1); + expect(engine.stores.activeTool.get()).toBe('view'); + expect(engine.stores.viewportReady.get()).toBe(false); + engine.dispose(); + }); + + it('setTool switches the active tool and updates the store', () => { + const { engine } = createEngine(); + const listener = vi.fn(); + engine.stores.activeTool.subscribe(listener); + + engine.setTool('brush'); + expect(engine.stores.activeTool.get()).toBe('brush'); + expect(listener).toHaveBeenCalledTimes(1); + + // Setting the same tool again is a no-op. + engine.setTool('brush'); + expect(listener).toHaveBeenCalledTimes(1); + engine.dispose(); + }); + + it('registers the brush and eraser tools', () => { + const { engine } = createEngine(); + engine.setTool('brush'); + expect(engine.stores.activeTool.get()).toBe('brush'); + engine.setTool('eraser'); + expect(engine.stores.activeTool.get()).toBe('eraser'); + engine.dispose(); + }); + + it('onStrokeCommitted returns an unsubscribe and never fires without a stroke', () => { + const { engine } = createEngine(); + const listener = vi.fn(); + const unsubscribe = engine.onStrokeCommitted(listener); + expect(typeof unsubscribe).toBe('function'); + unsubscribe(); + expect(listener).not.toHaveBeenCalled(); + engine.dispose(); + }); + + it('resize records the viewport size and clamps the device-pixel ratio', () => { + const { engine } = createEngine(); + engine.resize(800, 600, 4); + expect(engine.getViewport().getViewportSize()).toEqual({ height: 600, width: 800 }); + expect(engine.getViewport().getDpr()).toBe(2); + engine.dispose(); + }); + + it('dispose removes the store subscription', () => { + const { engine, unsubscribe } = createEngine(); + expect(unsubscribe).not.toHaveBeenCalled(); + engine.dispose(); + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); + + it('dispose is idempotent', () => { + const { engine, unsubscribe } = createEngine(); + engine.dispose(); + engine.dispose(); + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); +}); + +describe('document mirror wiring: layer reorder', () => { + it('recomposites in the new z-order without re-rasterizing any layer', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const a = rasterLayer('a', { opacity: 0.25 }); + const b = rasterLayer('b', { opacity: 0.75 }); + const doc: CanvasDocumentContractV2 = { + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers: [a, b], + selectedLayerId: null, + version: 2, + width: 100, + }; + const { setDocument, store } = createReactiveStore(doc); + const resolver = vi.fn(() => Promise.resolve(new Blob())); + + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: resolver, + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.attach(screen.element, overlay.element); + + // Initial frame: dispatches (and, after a microtask flush, completes) the + // rasterize for both layers. + raf.flush(); + await flushMicrotasks(); + raf.flush(); // the follow-up frame each rasterize's resolve scheduled + expect(resolver).toHaveBeenCalledTimes(2); + + // Isolate the reorder-triggered frame's draw log. + screen.surface.callLog.length = 0; + + // Pure reorder: new array reference, same layer object references, swapped order. + setDocument({ ...doc, layers: [b, a] }); + raf.flush(); + + // No layer content changed, so nothing should be re-rasterized. + expect(resolver).toHaveBeenCalledTimes(2); + + // The compositor draws bottom-to-top; `a` (alpha 0.25) is now the bottom + // layer and `b` (alpha 0.75) the top, so alpha is applied in that order. + const alphaOrder = screen.surface.callLog + .filter((entry) => entry.op === 'set' && entry.args[0] === 'globalAlpha') + .map((entry) => entry.args[1]); + expect(alphaOrder).toEqual([0.25, 0.75]); + + engine.dispose(); + }); +}); + +describe('layer removal: adjusted-surface cache cleanup', () => { + it("drops the removed layer's adjusted surface (no cache leak)", async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + adjustedSurfaceCacheDeletes.length = 0; + + const doc: CanvasDocumentContractV2 = { + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers: [rasterLayer('a'), rasterLayer('b')], + selectedLayerId: null, + version: 2, + width: 100, + }; + const { setDocument, store } = createReactiveStore(doc); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + engine.attach(createFakeCanvas().element, createFakeCanvas().element); + raf.flush(); + await flushMicrotasks(); + raf.flush(); + + // Still present: nothing removed yet. + expect(adjustedSurfaceCacheDeletes).not.toContain('a'); + + // Remove layer 'a' via an ordinary layer-array edit (onLayersChanged → dropLayer). + setDocument({ ...doc, layers: [rasterLayer('b')] }); + raf.flush(); + + // The removed layer's adjusted-surface slot is dropped alongside its layer cache. + expect(adjustedSurfaceCacheDeletes).toContain('a'); + + engine.dispose(); + }); +}); + +describe('resize: synchronous composite (no flash/strobe on panel drag)', () => { + it('composites in the SAME task as the backing-store resize, scheduling no deferred frame', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const doc = makeDoc(); + const { store } = createReactiveStore(doc); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.attach(screen.element, overlay.element); + + // Run the initial attach frame so the surface holds a composited frame, then + // isolate what the resize alone draws. + raf.flush(); + screen.surface.callLog.length = 0; + + // A single resize event (as a ResizeObserver fires mid panel-drag). Sizing a + // `` backing store CLEARS it; the fix recomposites synchronously so the + // cleared surface is repainted before the browser paints (no blank frame). + // No `raf.flush()` follows — so any draw seen here happened IN-TASK. + engine.resize(800, 600, 1); + + // Backing store was resized in the same call... + expect(screen.element.width).toBe(800); + expect(screen.element.height).toBe(600); + // ...and the composite ran SYNCHRONOUSLY (drew into the surface without a rAF + // flush). `compositeDocument` always clears the target, so a `clearRect` here + // proves the recomposite happened in-task rather than being deferred to rAF. + const composited = screen.surface.callLog.some((entry) => entry.op === 'clearRect'); + expect(composited).toBe(true); + + engine.dispose(); + }); + + it('schedules NO deferred frame after the synchronous composite (exactly one composite per resize)', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const doc = makeDoc(); + const { store } = createReactiveStore(doc); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.attach(screen.element, overlay.element); + raf.flush(); + screen.surface.callLog.length = 0; + + engine.resize(800, 600, 1); + + // The synchronous composite ran... + expect(screen.surface.callLog.some((entry) => entry.op === 'clearRect')).toBe(true); + // ...and setViewportSize's viewport-subscription `{ view: true }` invalidate was + // suppressed, so NO rAF frame is queued to recomposite the identical content. + expect(raf.pendingCount()).toBe(0); + + // Draining any frame anyway must not produce a second composite. + screen.surface.callLog.length = 0; + raf.flush(); + expect(screen.surface.callLog.some((entry) => entry.op === 'clearRect')).toBe(false); + + engine.dispose(); + }); + + it('marks the composite dirty flag so the synchronous path is not gated out (T22)', () => { + // A resize must force a full recomposite even though no layer pixels changed: + // if it only marked `overlay`, the T22 `needsComposite` gate would skip the + // composite and the just-cleared screen surface would stay blank. + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const doc = makeDoc(); + const { store } = createReactiveStore(doc); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.attach(screen.element, overlay.element); + raf.flush(); + screen.surface.callLog.length = 0; + + engine.resize(320, 240, 2); + + // The screen (document composite) surface — not just the overlay — was redrawn. + expect(screen.surface.callLog.some((entry) => entry.op === 'clearRect')).toBe(true); + engine.dispose(); + }); +}); + +describe('ensureLayerCaches: edit-during-rasterize race', () => { + it('re-rasterizes with the newest source when an edit lands while a rasterize is in flight', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const doc = makeDoc(); // single layer 'a', imageName 'a' + const { setDocument, store } = createReactiveStore(doc); + + const deferreds = new Map>>(); + const resolver = vi.fn((imageName: string) => { + const deferred = createDeferred(); + deferreds.set(imageName, deferred); + return deferred.promise; + }); + + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: resolver, + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.attach(screen.element, overlay.element); + + // Subscribe to thumbnail-version notifications for layer 'a' *before* either + // rasterize settles. A bare `.get()` check at the end can't distinguish + // "notified once, at the wrong time, with stale pixels on the surface" from + // "notified once, at the right time, with fresh pixels on the surface" -- + // both leave the same final version number. Tracking call count/timing does. + const thumbnailListener = vi.fn(); + const unsubscribe = engine.stores.thumbnailVersion.subscribeKey('a', thumbnailListener); + + // Frame 1: dispatches the first rasterize for imageName 'a'; it stays in flight + // (the deferred is never auto-resolved). + raf.flush(); + expect(resolver).toHaveBeenCalledTimes(1); + expect(resolver).toHaveBeenNthCalledWith(1, 'a'); + + // An edit lands mid-flight: same layer id, new object reference, new source. + setDocument({ ...doc, layers: [rasterLayer('a', { imageName: 'a-v2' })] }); + + // A frame runs while the first rasterize is still in flight: the in-flight + // guard must prevent a second dispatch here. + raf.flush(); + expect(resolver).toHaveBeenCalledTimes(1); + + // The first (now-stale) rasterize resolves with the OLD pixels. + deferreds.get('a')!.resolve(new Blob()); + await flushMicrotasks(); + + // Its resolution scheduled a follow-up frame; running it must re-dispatch a + // rasterize for the layer, this time picking up the newest source. + raf.flush(); + expect(resolver).toHaveBeenCalledTimes(2); + expect(resolver).toHaveBeenNthCalledWith(2, 'a-v2'); + + // Critical assertion: the stale completion must NOT have notified thumbnail + // subscribers. If it did, subscribers would have redrawn from the surface + // while it still held the OLD pixels (the fresh rasterize hasn't finished + // yet), and -- because the store's `set` is equality-guarded -- the later, + // real notification for the fresh pixels would be silently swallowed since + // it lands on the same version number the stale completion already wrote. + expect(thumbnailListener).not.toHaveBeenCalled(); + + // Resolve the second (fresh) rasterize so nothing is left in flight. + deferreds.get('a-v2')!.resolve(new Blob()); + await flushMicrotasks(); + raf.flush(); + + // Now that fresh pixels have actually landed, subscribers must be notified + // exactly once -- not zero times (swallowed by the equality guard) and not + // twice (which would imply the stale completion also fired). + expect(thumbnailListener).toHaveBeenCalledTimes(1); + expect(engine.stores.thumbnailVersion.get('a')).toBe(1); + + unsubscribe(); + engine.dispose(); + }); +}); + +// ---- Engine-owned history (P2.3) -------------------------------------- +// +// Drives a real brush stroke end-to-end through the engine (attach → dispatch +// pointer events → commit) and asserts the history wiring: strokeCommitted pushes +// an image patch, undo/redo write the before/after pixels back into the layer's +// cache surface AND re-mark the layer dirty for persistence, and the canUndo / +// canRedo stores track the stacks. + +const paintDoc = (): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers: [ + { + blendMode: 'normal', + id: 'paint1', + isEnabled: true, + isLocked: false, + name: 'paint1', + opacity: 1, + source: { bitmap: null, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }, + ], + selectedLayerId: 'paint1', + version: 2, + width: 100, +}); + +/** A minimal in-memory bitmap store: records dirty-marks, never touches the network. */ +const createSpyBitmapStore = (): BitmapStore & { + markLayerDirty: Mock<(layerId: string) => void>; + reset: Mock<() => void>; +} => ({ + dispose: vi.fn(), + flushPendingUploads: vi.fn(() => Promise.resolve()), + isSelfEcho: () => false, + markLayerDirty: vi.fn<(layerId: string) => void>(), + reset: vi.fn<() => void>(), +}); + +/** A fake canvas that also lets a test fire pointer events at the engine's listeners. */ +const createInputCanvas = ( + width = 100, + height = 100 +): { element: HTMLCanvasElement; fire: (type: string, event: Partial) => void } => { + const surface = createTestStubRasterBackend().createSurface(width, height); + const listeners = new Map void>>(); + const element = { + addEventListener: (type: string, handler: (event: Event) => void) => { + const set = listeners.get(type) ?? new Set(); + set.add(handler); + listeners.set(type, set); + }, + getBoundingClientRect: () => ({ bottom: height, height, left: 0, right: width, top: 0, width, x: 0, y: 0 }), + getContext: () => surface.ctx, + height, + releasePointerCapture: () => {}, + removeEventListener: (type: string, handler: (event: Event) => void) => { + listeners.get(type)?.delete(handler); + }, + setPointerCapture: () => {}, + width, + } as unknown as HTMLCanvasElement; + const fire = (type: string, event: Partial): void => { + for (const handler of listeners.get(type) ?? []) { + handler({ preventDefault: () => {}, ...event } as unknown as Event); + } + }; + return { element, fire }; +}; + +const pointerAt = (x: number, y: number, opts: { button?: number; buttons?: number } = {}): Partial => ({ + altKey: false, + button: opts.button ?? 0, + buttons: opts.buttons ?? 1, + clientX: x, + clientY: y, + ctrlKey: false, + metaKey: false, + pointerId: 1, + pointerType: 'mouse', + pressure: 0.5, + shiftKey: false, + timeStamp: 0, +}); + +/** Every `putImageData` recorded across all surfaces the backend created. */ +const putImageDataCalls = (surfaces: StubRasterSurface[]): { image: unknown; x: unknown; y: unknown }[] => + surfaces.flatMap((surface) => + surface.callLog + .filter((entry) => entry.op === 'putImageData') + .map((entry) => ({ image: entry.args[0], x: entry.args[1], y: entry.args[2] })) + ); + +describe('engine-owned history: stroke → undo → redo', () => { + const drawStroke = () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + // The engine builds real `Path2D`s for stroke outlines; node lacks it. + vi.stubGlobal('Path2D', class FakePath2D {}); + + const { store } = createReactiveStore(paintDoc()); + const base = createTestStubRasterBackend(); + const surfaces: StubRasterSurface[] = []; + const backend = { + ...base, + createSurface: (w: number, h: number): StubRasterSurface => { + const surface = base.createSurface(w, h); + surfaces.push(surface); + return surface; + }, + }; + const bitmapStore = createSpyBitmapStore(); + + const engine = createCanvasEngine({ + backend, + bitmapStore, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const strokes: StrokeCommittedEvent[] = []; + engine.onStrokeCommitted((event) => strokes.push(event)); + + const overlay = createInputCanvas(); + const screen = createInputCanvas(); + engine.attach(screen.element, overlay.element); + engine.setTool('brush'); + + // A brush gesture entirely inside the 100x100 document (identity viewport). + overlay.fire('pointerdown', pointerAt(20, 20)); + overlay.fire('pointermove', pointerAt(40, 40)); + overlay.fire('pointerup', pointerAt(40, 40, { buttons: 0 })); + + return { bitmapStore, engine, strokes, surfaces }; + }; + + it('records a stroke and restores before/after pixels on undo/redo', () => { + const { bitmapStore, engine, strokes, surfaces } = drawStroke(); + + // One stroke committed and one history entry recorded. + expect(strokes).toHaveLength(1); + const event = strokes[0]!; + expect(event.layerId).toBe('paint1'); + expect(engine.stores.canUndo.get()).toBe(true); + expect(engine.stores.canRedo.get()).toBe(false); + // The commit marked the layer dirty for persistence. + const dirtyAfterStroke = bitmapStore.markLayerDirty.mock.calls.length; + expect(dirtyAfterStroke).toBeGreaterThanOrEqual(1); + + // Undo: the layer's cache surface receives putImageData(before), and the layer + // is re-marked dirty (convergence path). Content-sized: the paint layer started + // empty and grew to exactly the stroke's dirty rect, so the cache-local origin + // equals the dirty-rect origin and the patch lands at surface (0, 0). + engine.undo(); + const undoPut = putImageDataCalls(surfaces).find((call) => call.image === event.beforeImageData); + expect(undoPut).toBeDefined(); + expect(undoPut!.x).toBe(0); + expect(undoPut!.y).toBe(0); + expect(engine.stores.canUndo.get()).toBe(false); + expect(engine.stores.canRedo.get()).toBe(true); + expect(bitmapStore.markLayerDirty.mock.calls.length).toBeGreaterThan(dirtyAfterStroke); + + // Redo: putImageData(after) restores the post-stroke pixels. + engine.redo(); + const redoPut = putImageDataCalls(surfaces).find((call) => call.image === event.afterImageData); + expect(redoPut).toBeDefined(); + expect(engine.stores.canUndo.get()).toBe(true); + expect(engine.stores.canRedo.get()).toBe(false); + + engine.dispose(); + }); + + it('round-trips pixels exactly across a cache-growing stroke → undo → redo', () => { + // Integrated case: a multi-move stroke that GROWS the (initially empty) paint + // cache across several pointer batches, then undo/redo restore the exact + // before/after ImageData over the FULL grown extent. The piecewise pieces + // (growth, patch application) are covered elsewhere; this pins them together. + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal('Path2D', class FakePath2D {}); + + const { store } = createReactiveStore(paintDoc()); + const base = createTestStubRasterBackend(); + const surfaces: StubRasterSurface[] = []; + const backend = { + ...base, + createSurface: (w: number, h: number): StubRasterSurface => { + const surface = base.createSurface(w, h); + surfaces.push(surface); + return surface; + }, + }; + const bitmapStore = createSpyBitmapStore(); + const engine = createCanvasEngine({ + backend, + bitmapStore, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const strokes: StrokeCommittedEvent[] = []; + engine.onStrokeCommitted((event) => strokes.push(event)); + + const overlay = createInputCanvas(); + const screen = createInputCanvas(); + engine.attach(screen.element, overlay.element); + engine.setTool('brush'); + + // Drag rightward across the document in several batches so the content-sized + // cache grows (and reallocates) as the stroke extends. + overlay.fire('pointerdown', pointerAt(10, 50)); + overlay.fire('pointermove', pointerAt(30, 50)); + overlay.fire('pointermove', pointerAt(50, 50)); + overlay.fire('pointermove', pointerAt(70, 50)); + overlay.fire('pointermove', pointerAt(90, 50)); + overlay.fire('pointerup', pointerAt(90, 50, { buttons: 0 })); + + expect(strokes).toHaveLength(1); + const event = strokes[0]!; + // The stroke grew the cache well beyond a single dab: the dirty rect spans most + // of the drag width, and the captured before/after cover that full extent. + expect(event.dirtyRect.width).toBeGreaterThan(60); + expect(event.beforeImageData.width).toBe(event.dirtyRect.width); + expect(event.beforeImageData.height).toBe(event.dirtyRect.height); + expect(event.afterImageData.width).toBe(event.dirtyRect.width); + expect(event.afterImageData.height).toBe(event.dirtyRect.height); + + // Undo writes the EXACT pre-stroke ImageData back into the cache. The cache + // grew to exactly the (chunk-padded) dirty rect, so its local origin equals the + // dirty-rect origin and the patch lands at surface (0, 0). + engine.undo(); + const undoPut = putImageDataCalls(surfaces).find((call) => call.image === event.beforeImageData); + expect(undoPut).toBeDefined(); + expect(undoPut!.x).toBe(0); + expect(undoPut!.y).toBe(0); + expect(engine.stores.canUndo.get()).toBe(false); + expect(engine.stores.canRedo.get()).toBe(true); + + // Redo writes the EXACT post-stroke ImageData back — a lossless round-trip. + engine.redo(); + const redoPut = putImageDataCalls(surfaces).find((call) => call.image === event.afterImageData); + expect(redoPut).toBeDefined(); + expect(redoPut!.x).toBe(0); + expect(redoPut!.y).toBe(0); + expect(engine.stores.canUndo.get()).toBe(true); + expect(engine.stores.canRedo.get()).toBe(false); + + engine.dispose(); + }); + + it('clears history when the document is replaced', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal('Path2D', class FakePath2D {}); + + const { setDocument, store } = createReactiveStore(paintDoc()); + const bitmapStore = createSpyBitmapStore(); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + + const overlay = createInputCanvas(); + const screen = createInputCanvas(); + engine.attach(screen.element, overlay.element); + engine.setTool('brush'); + overlay.fire('pointerdown', pointerAt(20, 20)); + overlay.fire('pointermove', pointerAt(40, 40)); + overlay.fire('pointerup', pointerAt(40, 40, { buttons: 0 })); + expect(engine.stores.canUndo.get()).toBe(true); + + // A dims change triggers onDocumentReplaced → history.clear(). + const replaced = { ...paintDoc(), height: 200, width: 200 }; + setDocument(replaced); + expect(engine.stores.canUndo.get()).toBe(false); + expect(engine.stores.canRedo.get()).toBe(false); + + engine.dispose(); + }); + + it('clears history on a same-dimension snapshot restore (documentRevision bump)', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal('Path2D', class FakePath2D {}); + + const { setDocument, store } = createReactiveStore(paintDoc()); + const bitmapStore = createSpyBitmapStore(); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + + const overlay = createInputCanvas(); + const screen = createInputCanvas(); + engine.attach(screen.element, overlay.element); + engine.setTool('brush'); + overlay.fire('pointerdown', pointerAt(20, 20)); + overlay.fire('pointermove', pointerAt(40, 40)); + overlay.fire('pointerup', pointerAt(40, 40, { buttons: 0 })); + expect(engine.stores.canUndo.get()).toBe(true); + + // A snapshot restore reuses the same dims AND layer ids (structuredClone), so + // only the bumped documentRevision distinguishes it from an ordinary edit. It + // must still clear history: a subsequent undo would otherwise put pre-restore + // pixels over the restored content. + setDocument(paintDoc(), 1); + expect(engine.stores.canUndo.get()).toBe(false); + expect(engine.stores.canRedo.get()).toBe(false); + + engine.dispose(); + }); + + it('re-rasterizes and resets persistence bookkeeping on a revision-bump swap that reuses a layer id with a different source', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const docV1: CanvasDocumentContractV2 = { + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers: [rasterLayer('a', { imageName: 'src-v1' })], + selectedLayerId: 'a', + version: 2, + width: 100, + }; + const { setDocument, store } = createReactiveStore(docV1); + const resolver = vi.fn((_imageName: string) => Promise.resolve(new Blob())); + const bitmapStore = createSpyBitmapStore(); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore, + imageResolver: resolver, + projectId: 'p1', + store, + }); + + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.attach(screen.element, overlay.element); + + // Initial rasterize of the v1 source. + raf.flush(); + await flushMicrotasks(); + raf.flush(); + expect(resolver).toHaveBeenCalledTimes(1); + expect(resolver).toHaveBeenNthCalledWith(1, 'src-v1'); + + // A revision bump that REUSES layer id 'a' with a DIFFERENT source: the + // mirror routes this through onDocumentReplaced (a full swap), which a + // reference diff alone could not tell from an ordinary edit. The engine must + // invalidate the surviving cache entry so it re-rasterizes the new source + // (a stale cache entry would keep rendering the v1 pixels). + setDocument({ ...docV1, layers: [rasterLayer('a', { imageName: 'src-v2' })] }, 1); + + // Persistence bookkeeping for the outgoing document was dropped so a reused + // layer id can't have its next legit persistence dispatch suppressed. + expect(bitmapStore.reset).toHaveBeenCalledTimes(1); + + // The invalidated cache re-rasterizes, this time from the v2 source. + raf.flush(); + await flushMicrotasks(); + raf.flush(); + expect(resolver).toHaveBeenCalledTimes(2); + expect(resolver).toHaveBeenNthCalledWith(2, 'src-v2'); + + engine.dispose(); + }); +}); + +describe('engine-owned history: undo/redo guarded during an active gesture', () => { + it('no-ops undo/redo mid-stroke, then works after the gesture ends', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal('Path2D', class FakePath2D {}); + + const { store } = createReactiveStore(paintDoc()); + const base = createTestStubRasterBackend(); + const surfaces: StubRasterSurface[] = []; + const backend = { + ...base, + createSurface: (w: number, h: number): StubRasterSurface => { + const surface = base.createSurface(w, h); + surfaces.push(surface); + return surface; + }, + }; + const bitmapStore = createSpyBitmapStore(); + const engine = createCanvasEngine({ + backend, + bitmapStore, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const strokes: StrokeCommittedEvent[] = []; + engine.onStrokeCommitted((event) => strokes.push(event)); + + const overlay = createInputCanvas(); + const screen = createInputCanvas(); + engine.attach(screen.element, overlay.element); + engine.setTool('brush'); + + // First, record one committed stroke so there is something to undo. + overlay.fire('pointerdown', pointerAt(20, 20)); + overlay.fire('pointermove', pointerAt(40, 40)); + overlay.fire('pointerup', pointerAt(40, 40, { buttons: 0 })); + expect(engine.stores.canUndo.get()).toBe(true); + + // Start a SECOND stroke and leave it open (pointer down + move, no up). + // (The live session itself may draw, so snapshot the put count after it opens.) + overlay.fire('pointerdown', pointerAt(50, 50)); + overlay.fire('pointermove', pointerAt(60, 60)); + const putsMidGesture = putImageDataCalls(surfaces).length; + + // Mid-gesture undo/redo must be no-ops: no history pop and no putImageData. + engine.undo(); + engine.redo(); + expect(engine.stores.canUndo.get()).toBe(true); + expect(putImageDataCalls(surfaces).length).toBe(putsMidGesture); + // In particular, the first stroke's before pixels were never injected. + expect(putImageDataCalls(surfaces).some((call) => call.image === strokes[0]!.beforeImageData)).toBe(false); + + // End the gesture; now undo works and writes the newest stroke's before pixels. + overlay.fire('pointerup', pointerAt(60, 60, { buttons: 0 })); + expect(strokes).toHaveLength(2); + engine.undo(); + expect(putImageDataCalls(surfaces).some((call) => call.image === strokes[1]!.beforeImageData)).toBe(true); + expect(engine.stores.canUndo.get()).toBe(true); + expect(engine.stores.canRedo.get()).toBe(true); + + engine.dispose(); + }); +}); + +// ---- commitStructural: UI-initiated structural edits on the canvas history ---- + +describe('commitStructural', () => { + const forward: WorkbenchAction = { id: 'a', type: 'setCanvasSelectedLayer' }; + const inverse: WorkbenchAction = { id: null, type: 'setCanvasSelectedLayer' }; + + it('dispatches forward immediately and records a reversible history entry', () => { + const { store } = createFakeStore(makeDoc()); + const dispatch = store.dispatch as Mock; + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + + engine.commitStructural('Select layer', forward, inverse); + // Forward dispatched once; the edit is now undoable but not redoable. + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenNthCalledWith(1, forward); + expect(engine.stores.canUndo.get()).toBe(true); + expect(engine.stores.canRedo.get()).toBe(false); + + // Undo dispatches the inverse and flips the stacks. + engine.undo(); + expect(dispatch).toHaveBeenNthCalledWith(2, inverse); + expect(engine.stores.canUndo.get()).toBe(false); + expect(engine.stores.canRedo.get()).toBe(true); + + // Redo re-dispatches the forward. + engine.redo(); + expect(dispatch).toHaveBeenNthCalledWith(3, forward); + expect(engine.stores.canUndo.get()).toBe(true); + expect(engine.stores.canRedo.get()).toBe(false); + + engine.dispose(); + }); +}); + +// ---- drawLayerThumbnail: cache-backed layer previews -------------------- + +const createThumbnailTarget = (): { + calls: { args: unknown[]; op: string }[]; + target: HTMLCanvasElement; +} => { + const calls: { args: unknown[]; op: string }[] = []; + const ctx = { + clearRect: (...args: unknown[]) => calls.push({ args, op: 'clearRect' }), + drawImage: (...args: unknown[]) => calls.push({ args, op: 'drawImage' }), + }; + const target = { getContext: () => ctx, height: 0, width: 0 } as unknown as HTMLCanvasElement; + return { calls, target }; +}; + +describe('drawLayerThumbnail', () => { + it('returns false and draws nothing when the layer has no cache', () => { + const { engine } = createEngine(); + const { calls, target } = createThumbnailTarget(); + expect(engine.drawLayerThumbnail('missing', target, 96)).toBe(false); + expect(calls).toHaveLength(0); + engine.dispose(); + }); + + it('scales the layer cache into the target and reports success', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const { store } = createReactiveStore(makeDoc()); // one 10x10 image layer 'a' + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.attach(screen.element, overlay.element); + // One frame creates the layer cache entry (10x10). + raf.flush(); + await flushMicrotasks(); + raf.flush(); + + const { calls, target } = createThumbnailTarget(); + expect(engine.drawLayerThumbnail('a', target, 96)).toBe(true); + // 10x10 never upscales, so the target keeps the source dimensions. + expect(target.width).toBe(10); + expect(target.height).toBe(10); + expect(calls.map((call) => call.op)).toEqual(['clearRect', 'drawImage']); + + engine.dispose(); + }); +}); + +// ---- mergeLayerDown: composites the upper cache into the below local space ---- + +const twoPaintDoc = (): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers: [ + { + blendMode: 'multiply', + id: 'upper', + isEnabled: true, + isLocked: false, + name: 'upper', + opacity: 0.5, + // Content-sized: a persisted bitmap gives the cache a non-empty content rect. + // The upper (40×40) sits fully within the below (60×60) in below-local space, + // so the merge union stays 60×60 and the warped upper transform is non-trivial. + source: { bitmap: { height: 40, imageName: 'upper-bmp', width: 40 }, offset: { x: 0, y: 0 }, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 30, y: 40 }, + type: 'raster', + }, + { + blendMode: 'normal', + id: 'below', + isEnabled: true, + isLocked: false, + name: 'below', + opacity: 1, + // Offset so the merge matrix is non-trivial (below-local origin shifts). + source: { bitmap: { height: 60, imageName: 'below-bmp', width: 60 }, offset: { x: 0, y: 0 }, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 10, y: 20 }, + type: 'raster', + }, + ], + selectedLayerId: 'upper', + version: 2, + width: 100, +}); + +/** An empty inpaint mask layer, for the merge-down mask-rejection tests below. */ +const maskLayer = (id: string): CanvasInpaintMaskLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + mask: { bitmap: null, fill: { color: '#e07575', style: 'diagonal' } }, + name: id, + opacity: 1, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'inpaint_mask', +}); + +describe('mergeLayerDown', () => { + it('composites below then the transformed upper cache, and dispatches mergeCanvasLayersDown', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const { store } = createReactiveStore(twoPaintDoc()); + const dispatch = store.dispatch as Mock; + const base = createTestStubRasterBackend(); + const surfaces: StubRasterSurface[] = []; + const backend = { + ...base, + createSurface: (w: number, h: number): StubRasterSurface => { + const surface = base.createSurface(w, h); + surfaces.push(surface); + return surface; + }, + }; + const engine = createCanvasEngine({ + backend, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.attach(screen.element, overlay.element); + // One frame builds both layer caches; await the async bitmap decode so both + // caches are READY (merge refuses stale/in-flight caches — finding 20). + raf.flush(); + await flushMicrotasks(); + raf.flush(); + + const surfacesBeforeMerge = surfaces.length; + expect(engine.mergeLayerDown('upper')).toBe(true); + + // The reducer is asked to collapse the two layers into a paint layer. + const mergeCall = dispatch.mock.calls + .map((call) => call[0] as WorkbenchAction) + .find((action) => action.type === 'mergeCanvasLayersDown'); + expect(mergeCall).toEqual({ + source: { bitmap: null, offset: { x: 0, y: 0 }, type: 'paint' }, + type: 'mergeCanvasLayersDown', + upperLayerId: 'upper', + }); + + // A fresh union-sized surface was allocated for the merged pixels. Content- + // sized: below's rect {0,0,60,60} unioned with upper's rect warped into + // below-local space {20,20,40,40} is {0,0,60,60}, so the merged surface stays + // 60×60 with origin (0,0). + const merged = surfaces[surfacesBeforeMerge]; + expect(merged).toBeDefined(); + expect(merged!.width).toBe(60); + expect(merged!.height).toBe(60); + const log = merged!.callLog; + + const expectedMatrix = mergeDownMatrix( + { rotation: 0, scaleX: 1, scaleY: 1, x: 10, y: 20 }, + { rotation: 0, scaleX: 1, scaleY: 1, x: 30, y: 40 } + )!; + // upper{30,40} → below-local via inverse(below{10,20}) = translate(20,20). + expect(expectedMatrix.e).toBe(20); + expect(expectedMatrix.f).toBe(20); + const mergedOrigin = { x: 0, y: 0 }; + const matrixIndex = log.findIndex( + (entry) => + entry.op === 'setTransform' && + entry.args[0] === expectedMatrix.a && + entry.args[4] === expectedMatrix.e - mergedOrigin.x && + entry.args[5] === expectedMatrix.f - mergedOrigin.y + ); + expect(matrixIndex).toBeGreaterThan(-1); + + // Below is blitted before the upper transform; the upper after it. + const drawIndices = log.reduce((acc, entry, index) => { + if (entry.op === 'drawImage') { + acc.push(index); + } + return acc; + }, []); + expect(drawIndices).toHaveLength(2); + expect(drawIndices[0]).toBeLessThan(matrixIndex); + expect(drawIndices[1]).toBeGreaterThan(matrixIndex); + + // The upper layer's opacity and blend mode are baked into the merge. + const alphaSet = log.find( + (entry) => entry.op === 'set' && entry.args[0] === 'globalAlpha' && entry.args[1] === 0.5 + ); + const blendSet = log.find( + (entry) => entry.op === 'set' && entry.args[0] === 'globalCompositeOperation' && entry.args[1] === 'multiply' + ); + expect(alphaSet).toBeDefined(); + expect(blendSet).toBeDefined(); + + engine.dispose(); + }); + + it('is a no-op for the bottom-most layer (nothing below)', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const { store } = createReactiveStore(twoPaintDoc()); + const dispatch = store.dispatch as Mock; + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.attach(screen.element, overlay.element); + raf.flush(); + + expect(engine.mergeLayerDown('below')).toBe(false); + expect(dispatch.mock.calls.some((call) => (call[0] as WorkbenchAction).type === 'mergeCanvasLayersDown')).toBe( + false + ); + + engine.dispose(); + }); + + it.each(['upper', 'below'] as const)( + "is a no-op when the %s layer is locked (merge is not undoable; must mirror the paint tool's locked-target refusal)", + (lockedId) => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const doc = twoPaintDoc(); + const locked = { + ...doc, + layers: doc.layers.map((layer) => (layer.id === lockedId ? { ...layer, isLocked: true } : layer)), + }; + const { store } = createReactiveStore(locked); + const dispatch = store.dispatch as Mock; + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.attach(screen.element, overlay.element); + raf.flush(); + + expect(engine.mergeLayerDown('upper')).toBe(false); + expect(dispatch.mock.calls.some((call) => (call[0] as WorkbenchAction).type === 'mergeCanvasLayersDown')).toBe( + false + ); + + engine.dispose(); + } + ); + + // A two-paint doc where exactly ONE layer is content-empty (bitmap: null → a 0×0 + // cache surface). Merging must NOT `drawImage` the zero-dimension operand (which + // throws in browsers) but must still dispatch the collapse and composite the + // non-empty operand. + const oneEmptyPaintDoc = (emptyId: 'upper' | 'below'): CanvasDocumentContractV2 => { + const doc = twoPaintDoc(); + return { + ...doc, + layers: doc.layers.map((layer) => + layer.id === emptyId ? { ...layer, source: { bitmap: null, offset: { x: 0, y: 0 }, type: 'paint' } } : layer + ), + }; + }; + + it.each([ + { emptyId: 'upper', keptId: 'below' }, + { emptyId: 'below', keptId: 'upper' }, + ] as const)( + 'merges when only the $emptyId layer is empty: dispatches, and never draws a 0×0 surface', + async ({ emptyId }) => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const { store } = createReactiveStore(oneEmptyPaintDoc(emptyId)); + const dispatch = store.dispatch as Mock; + const base = createTestStubRasterBackend(); + const surfaces: StubRasterSurface[] = []; + const backend = { + ...base, + createSurface: (w: number, h: number): StubRasterSurface => { + const surface = base.createSurface(w, h); + surfaces.push(surface); + return surface; + }, + }; + const engine = createCanvasEngine({ + backend, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.attach(screen.element, overlay.element); + // Await the kept layer's bitmap decode so its cache is READY before merge + // (the empty operand needs no decode; merge refuses stale/in-flight caches). + raf.flush(); + await flushMicrotasks(); + raf.flush(); + + const surfacesBeforeMerge = surfaces.length; + expect(engine.mergeLayerDown('upper')).toBe(true); + + // Single-dispatch collapse still happens (undo semantics unchanged). + expect(dispatch.mock.calls.some((call) => (call[0] as WorkbenchAction).type === 'mergeCanvasLayersDown')).toBe( + true + ); + + // The merged surface only composites the NON-empty operand: exactly one + // drawImage, and it never draws a zero-dimension source canvas. + const merged = surfaces[surfacesBeforeMerge]; + expect(merged).toBeDefined(); + const draws = merged!.callLog.filter((entry) => entry.op === 'drawImage'); + expect(draws).toHaveLength(1); + for (const draw of draws) { + const src = draw.args[0] as { width: number; height: number }; + expect(src.width).toBeGreaterThan(0); + expect(src.height).toBeGreaterThan(0); + } + + engine.dispose(); + } + ); + + // A both-empty pair must fold trivially (delete the upper, below stays empty) + // rather than silently no-op — otherwise merge-visible stalls on such a run (F4). + it('folds a both-empty pair trivially: dispatches the collapse and allocates no merged surface (F4)', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const base = twoPaintDoc(); + const doc: CanvasDocumentContractV2 = { + ...base, + layers: base.layers.map((layer) => ({ + ...layer, + source: { bitmap: null, offset: { x: 0, y: 0 }, type: 'paint' as const }, + })), + }; + const { store } = createReactiveStore(doc); + const dispatch = store.dispatch as Mock; + const backendBase = createTestStubRasterBackend(); + const surfaces: StubRasterSurface[] = []; + const backend = { + ...backendBase, + createSurface: (w: number, h: number): StubRasterSurface => { + const surface = backendBase.createSurface(w, h); + surfaces.push(surface); + return surface; + }, + }; + const engine = createCanvasEngine({ + backend, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.attach(screen.element, overlay.element); + raf.flush(); + await flushMicrotasks(); + raf.flush(); + + const surfacesBeforeMerge = surfaces.length; + expect(engine.mergeLayerDown('upper')).toBe(true); + + // The collapse still dispatches (the upper layer is removed). + expect(dispatch.mock.calls.some((call) => (call[0] as WorkbenchAction).type === 'mergeCanvasLayersDown')).toBe( + true + ); + // No merged surface was allocated: a 0×0 union surface would throw, and there + // are no pixels to composite. + expect(surfaces.length).toBe(surfacesBeforeMerge); + + engine.dispose(); + }); + + // Regression: `mergeLayerDown` used to gate on `isRenderableLayer`, which masks + // satisfy (a mask rasterizes to a stencil whenever enabled). That let a mask + // reach the merge path, whose reducer unconditionally produces a `type: 'raster'` + // result — merging a mask blitted its stencil into the layer below and/or + // clobbered a mask below into a raster layer, destroying its config, with no + // undo. The guard must reject a mask on EITHER side, mirroring the layers + // panel's `isMergeableRasterLayer`/`canMergeLayerDown` enablement exactly. + it.each([ + { label: 'mask above a raster layer', maskId: 'upper' as const }, + { label: 'a raster layer above a mask', maskId: 'below' as const }, + ])('is a no-op for $label (document unchanged, no dispatch)', ({ maskId }) => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const base = twoPaintDoc(); + const doc: CanvasDocumentContractV2 = { + ...base, + layers: base.layers.map((layer) => (layer.id === maskId ? maskLayer(maskId) : layer)), + }; + const { store } = createReactiveStore(doc); + const dispatch = store.dispatch as Mock; + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.attach(screen.element, overlay.element); + raf.flush(); + + expect(engine.mergeLayerDown('upper')).toBe(false); + expect(dispatch.mock.calls.some((call) => (call[0] as WorkbenchAction).type === 'mergeCanvasLayersDown')).toBe( + false + ); + // Document unchanged: still two layers, each with its original type — no + // mask was blitted into, and no mask was clobbered into a raster layer. + expect(engine.getDocument()!.layers.map((l) => l.type)).toEqual(doc.layers.map((l) => l.type)); + + engine.dispose(); + }); + + it('is a no-op for mask-above-mask (document unchanged, no dispatch)', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const base = twoPaintDoc(); + const doc: CanvasDocumentContractV2 = { + ...base, + layers: [maskLayer('upper'), maskLayer('below')], + }; + const { store } = createReactiveStore(doc); + const dispatch = store.dispatch as Mock; + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.attach(screen.element, overlay.element); + raf.flush(); + + expect(engine.mergeLayerDown('upper')).toBe(false); + expect(dispatch.mock.calls.some((call) => (call[0] as WorkbenchAction).type === 'mergeCanvasLayersDown')).toBe( + false + ); + expect(engine.getDocument()!.layers.map((l) => l.type)).toEqual(['inpaint_mask', 'inpaint_mask']); + + engine.dispose(); + }); +}); + +// ---- mergeVisibleRasterLayers: whole-fold pre-flight + interleave-safe fold ---- + +/** + * An EngineStore backed by the REAL workbench reducer, so the fold's own + * dispatches (reorder + mergeCanvasLayersDown) actually advance the document the + * mirror re-reads between steps — the no-op mock store cannot exercise a + * multi-step fold. + */ +const createReducerBackedStore = (document: CanvasDocumentContractV2): { projectId: string; store: EngineStore } => { + let state = createInitialWorkbenchState(); + const projectId = state.projects[0]!.id; + state = workbenchReducer(state, { document, type: 'replaceCanvasDocument' }); + const listeners = new Set<() => void>(); + return { + projectId, + store: { + dispatch: (action) => { + state = workbenchReducer(state, action); + for (const listener of listeners) { + listener(); + } + }, + getState: () => state, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }, + }; +}; + +describe('mergeVisibleRasterLayers', () => { + /** [upper raster, mask, lower raster] — the interleaved case round 1 no-oped. */ + const interleavedDoc = (): CanvasDocumentContractV2 => { + const base = twoPaintDoc(); + const [upper, below] = base.layers as [CanvasLayerContract, CanvasLayerContract]; + return { ...base, layers: [upper, maskLayer('mid-mask'), below] }; + }; + + const setup = (doc: CanvasDocumentContractV2) => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + const { projectId, store } = createReducerBackedStore(doc); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.attach(screen.element, overlay.element); + return { engine, raf, store }; + }; + + it('refuses the WHOLE fold before any pixel work while a participant cache is not ready', async () => { + const { engine, raf } = setup(interleavedDoc()); + // No frame has run yet: both paint bitmaps are undecoded (no ready caches). + expect(engine.mergeVisibleRasterLayers()).toBe('not-ready'); + // Nothing happened — no reorder, no merge, no partial fold. + expect(engine.getDocument()!.layers.map((layer) => layer.id)).toEqual(['upper', 'mid-mask', 'below']); + + // Once the decodes land, the SAME call succeeds. + raf.flush(); + await flushMicrotasks(); + raf.flush(); + expect(engine.mergeVisibleRasterLayers()).toBe('merged'); + expect(engine.getDocument()!.layers.map((layer) => layer.id)).toEqual(['mid-mask', 'below']); + + engine.dispose(); + }); + + it('folds visible rasters across an interleaved mask (reorder + merge), then reports nothing left', async () => { + const { engine, raf } = setup(interleavedDoc()); + raf.flush(); + await flushMicrotasks(); + raf.flush(); + + expect(engine.mergeVisibleRasterLayers()).toBe('merged'); + + // The upper raster merged INTO the lower one across the mask; the mask + // survives untouched and the merged layer keeps the lower id + raster type. + const layers = engine.getDocument()!.layers; + expect(layers.map((layer) => layer.id)).toEqual(['mid-mask', 'below']); + expect(layers.map((layer) => layer.type)).toEqual(['inpaint_mask', 'raster']); + + // Re-running has nothing to do (one raster left). + expect(engine.mergeVisibleRasterLayers()).toBe('nothing'); + + engine.dispose(); + }); + + it('folds an all-empty run trivially instead of stalling on a half-merged stack (F4)', async () => { + const base = twoPaintDoc(); + const doc: CanvasDocumentContractV2 = { + ...base, + layers: base.layers.map((layer) => ({ + ...layer, + source: { bitmap: null, offset: { x: 0, y: 0 }, type: 'paint' as const }, + })), + }; + const { engine, raf } = setup(doc); + raf.flush(); + await flushMicrotasks(); + raf.flush(); + + expect(engine.getDocument()!.layers.map((layer) => layer.id)).toEqual(['upper', 'below']); + // The topmost (only) run is two empty rasters: the fold trivially collapses them + // into one empty layer rather than reporting a silent no-op. + expect(engine.mergeVisibleRasterLayers()).toBe('merged'); + expect(engine.getDocument()!.layers.map((layer) => layer.id)).toEqual(['below']); + expect(engine.mergeVisibleRasterLayers()).toBe('nothing'); + + engine.dispose(); + }); +}); + +// ---- rasterizeLayer: parametric → paint, undoable via param re-convert ---- + +const shapeLayerDoc = (over: { isLocked?: boolean } = {}): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers: [ + { + blendMode: 'normal', + id: 'shape1', + isEnabled: true, + isLocked: over.isLocked ?? false, + name: 'Shape', + opacity: 1, + source: { fill: '#ff0000', height: 40, kind: 'rect', stroke: null, strokeWidth: 0, type: 'shape', width: 60 }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 10, y: 20 }, + type: 'raster', + }, + ], + selectedLayerId: 'shape1', + version: 2, + width: 100, +}); + +describe('rasterizeLayer (parametric → paint)', () => { + const setup = (doc: CanvasDocumentContractV2) => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + const { setDocument, store } = createReactiveStore(doc); + const dispatch = store.dispatch as Mock; + const base = createTestStubRasterBackend(); + const surfaces: StubRasterSurface[] = []; + const backend = { + ...base, + createSurface: (w: number, h: number): StubRasterSurface => { + const surface = base.createSurface(w, h); + surfaces.push(surface); + return surface; + }, + }; + const engine = createCanvasEngine({ + backend, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.attach(screen.element, overlay.element); + raf.flush(); + return { backend, dispatch, engine, raf, setDocument, surfaces }; + }; + + const convertCalls = (dispatch: Mock): WorkbenchAction[] => + dispatch.mock.calls.map((call) => call[0] as WorkbenchAction).filter((a) => a.type === 'convertCanvasLayer'); + + it('bakes to a CONTENT-sized paint layer at identity and dispatches convertCanvasLayer with the offset', () => { + const { dispatch, engine, surfaces } = setup(shapeLayerDoc()); + const before = surfaces.length; + + expect(engine.rasterizeLayer('shape1')).toBe(true); + + const converts = convertCalls(dispatch); + expect(converts).toHaveLength(1); + const convert = converts[0]; + expect(convert).toMatchObject({ id: 'shape1', targetType: 'raster', type: 'convertCanvasLayer' }); + if (convert?.type === 'convertCanvasLayer') { + expect(convert.layer.type).toBe('raster'); + if (convert.layer.type === 'raster') { + // Content-sized: the shape (60×40) at transform (10,20) bakes to a paint + // layer whose bitmap sits at offset (10,20); the transform resets to identity. + expect(convert.layer.source).toEqual({ bitmap: null, offset: { x: 10, y: 20 }, type: 'paint' }); + expect(convert.layer.transform).toEqual({ rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }); + } + } + + // A fresh CONTENT-sized surface (the transformed shape bounds, 60×40) was + // allocated and the parametric cache baked into it. + const baked = surfaces[before]; + expect(baked).toBeDefined(); + expect(baked?.width).toBe(60); + expect(baked?.height).toBe(40); + expect(baked?.callLog.some((e) => e.op === 'drawImage')).toBe(true); + + engine.dispose(); + }); + + it('undo re-converts to the ORIGINAL parametric source (no pixel snapshot)', () => { + const { dispatch, engine } = setup(shapeLayerDoc()); + engine.rasterizeLayer('shape1'); + + engine.undo(); + const converts = convertCalls(dispatch); + // forward convert + undo convert. + expect(converts).toHaveLength(2); + const undoConvert = converts[1]; + if (undoConvert?.type === 'convertCanvasLayer' && undoConvert.layer.type === 'raster') { + expect(undoConvert.layer.source).toEqual({ + fill: '#ff0000', + height: 40, + kind: 'rect', + stroke: null, + strokeWidth: 0, + type: 'shape', + width: 60, + }); + expect(undoConvert.layer.transform).toEqual({ rotation: 0, scaleX: 1, scaleY: 1, x: 10, y: 20 }); + } else { + throw new Error('expected undo to re-convert to the shape source'); + } + + // Redo re-applies the paint conversion (bitmap:null at the baked offset). + engine.redo(); + const afterRedo = convertCalls(dispatch); + expect(afterRedo).toHaveLength(3); + if (afterRedo[2]?.type === 'convertCanvasLayer' && afterRedo[2].layer.type === 'raster') { + expect(afterRedo[2].layer.source).toEqual({ bitmap: null, offset: { x: 10, y: 20 }, type: 'paint' }); + } + engine.dispose(); + }); + + it('redo re-bakes from params rather than pinning the doc-sized surface (byte-budget honesty)', () => { + // Regression: the history entry declared bytes:256 but captured the doc-sized + // `baked` surface in its redo closure, so repeated rasterizes could retain + // gigabytes invisible to HISTORY_BYTE_BUDGET. Redo must re-bake instead. + const { engine, surfaces } = setup(shapeLayerDoc()); + + expect(engine.rasterizeLayer('shape1')).toBe(true); + const afterApply = surfaces.length; + + engine.undo(); + // Undo only re-converts (a dispatch) — it bakes nothing. + expect(surfaces.length).toBe(afterApply); + + engine.redo(); + // Redo allocates FRESH surfaces: it re-baked from params (content-sized to the + // transformed shape bounds, 60×40) rather than reusing a surface pinned by the + // entry. + expect(surfaces.length).toBeGreaterThan(afterApply); + const rebaked = surfaces.at(-1); + expect(rebaked?.width).toBe(60); + expect(rebaked?.height).toBe(40); + expect(rebaked?.callLog.some((e) => e.op === 'drawImage')).toBe(true); + + engine.dispose(); + }); + + it('rasterizes a gradient layer', () => { + const doc = shapeLayerDoc(); + doc.layers[0] = { + ...doc.layers[0], + source: { angle: 45, kind: 'linear', stops: [{ color: '#000', offset: 0 }], type: 'gradient' }, + } as CanvasLayerContract; + const { dispatch, engine } = setup(doc); + expect(engine.rasterizeLayer('shape1')).toBe(true); + expect(convertCalls(dispatch)).toHaveLength(1); + engine.dispose(); + }); + + it('is a no-op for a locked layer, a missing layer, and a non-parametric (paint) layer', () => { + const { dispatch, engine } = setup(shapeLayerDoc({ isLocked: true })); + expect(engine.rasterizeLayer('shape1')).toBe(false); + expect(engine.rasterizeLayer('nope')).toBe(false); + expect(convertCalls(dispatch)).toHaveLength(0); + engine.dispose(); + + const paintDoc = shapeLayerDoc(); + paintDoc.layers[0] = { ...paintDoc.layers[0], source: { bitmap: null, type: 'paint' } } as CanvasLayerContract; + const paint = setup(paintDoc); + expect(paint.engine.rasterizeLayer('shape1')).toBe(false); + paint.engine.dispose(); + }); + + // ---- rasterize → undo → bitmap-store flush: source-type guard -------- + // + // Reviewer-flagged bug: rasterize bakes the shape to a paint layer and marks + // it dirty in the bitmap store; undo re-converts it back to the parametric + // shape. Nothing previously cleared the pending dirty mark, so the eventual + // debounced (or barrier) flush would encode the paint-cache surface — still + // populated, since a source swap doesn't clear it — and dispatch + // `updateCanvasLayerSource({ type: 'paint', ... })`, silently flipping the + // parametric layer back to paint with stale, wrong-extent pixels. + // + // These tests wire a REAL `createBitmapStore` (exercising the actual guard + // in `flushLayer`) through `opts.bitmapStore`, with test-controlled + // encode/upload stubs (no real network) and a `getLayerSurface` that always + // resolves — mirroring the bug precondition that a source swap does NOT + // clear the cache. `getLayerSource` reads the engine's own mirrored + // document, so it reflects the exact reducer round trip the test drives via + // `setDocument`. + describe('rasterize → undo → bitmap-store flush (source-type guard)', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + const setupWithRealBitmapStore = (doc: CanvasDocumentContractV2) => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + const { setDocument, store } = createReactiveStore(doc); + const dispatch = store.dispatch as Mock; + + const encodeSurface = vi.fn(() => Promise.resolve(new Blob(['pixels'], { type: 'image/png' }))); + const uploadImage = vi.fn(() => Promise.resolve({ height: 100, imageName: 'img-x', width: 100 })); + // Always resolves, regardless of the layer's current source: mirrors the + // real cache, whose surface a source swap does NOT clear (only marks stale). + const fakeSurface = createTestStubRasterBackend().createSurface(10, 10); + + // Forward-declared: `getLayerSource` closes over it, but is only ever + // CALLED once `engine` is assigned below (bitmap-store flushes never + // happen synchronously during construction). + let engine: ReturnType; + const bitmapStore = createBitmapStore({ + dispatch: (action) => store.dispatch(action), + encodeSurface, + getLayerSource: (layerId) => { + const layer = engine.getDocument()?.layers.find((candidate) => candidate.id === layerId); + return layer && (layer.type === 'raster' || layer.type === 'control') ? layer.source : null; + }, + getLayerSurface: () => ({ offset: { x: 0, y: 0 }, surface: fakeSurface }), + hashBlob: (blob) => blob.text(), + retryDelaysMs: [1], + sleep: () => Promise.resolve(), + uploadImage, + }); + + engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.attach(screen.element, overlay.element); + raf.flush(); + + return { dispatch, encodeSurface, engine, raf, setDocument, uploadImage }; + }; + + /** + * Applies the most recently dispatched `convertCanvasLayer` action onto + * `doc`, simulating what the real reducer would do — `createReactiveStore`'s + * `dispatch` is a bare spy and does not mutate state on its own. + */ + const applyLastConvert = (doc: CanvasDocumentContractV2, dispatch: Mock): CanvasDocumentContractV2 => { + const converts = convertCalls(dispatch); + const last = converts.at(-1); + if (last?.type !== 'convertCanvasLayer') { + throw new Error('expected a convertCanvasLayer dispatch'); + } + return { ...doc, layers: doc.layers.map((layer) => (layer.id === last.id ? last.layer : layer)) }; + }; + + /** + * Rasterizes `shape1` (paint bake, dirty-marks the bitmap store), applies + * that conversion to the mirrored document, then undoes it and applies + * THAT conversion too — the full reducer round trip a live document would + * go through. Leaves the document back at its original parametric shape + * source, with the paint-bake dirty mark for `shape1` still pending. + */ + const rasterizeThenUndo = () => { + vi.useFakeTimers(); + let doc = shapeLayerDoc(); + const harness = setupWithRealBitmapStore(doc); + const { dispatch, engine, raf, setDocument } = harness; + + expect(engine.rasterizeLayer('shape1')).toBe(true); + doc = applyLastConvert(doc, dispatch); + setDocument(doc); + raf.flush(); + + engine.undo(); + doc = applyLastConvert(doc, dispatch); + setDocument(doc); + raf.flush(); + + return harness; + }; + + /** Every dispatched `updateCanvasLayerSource` whose source is `paint`. */ + const paintSourceDispatches = (dispatch: Mock): Extract[] => + dispatch.mock.calls + .map((call) => call[0] as WorkbenchAction) + .filter( + (action): action is Extract => + action.type === 'updateCanvasLayerSource' && action.source.type === 'paint' + ); + + it('await flushPendingUploads(): the layer stays the parametric shape and no paint dispatch fires', async () => { + const { dispatch, engine, uploadImage } = rasterizeThenUndo(); + + await engine.flushPendingUploads(); + + // The guard drops the flush before it ever encodes/uploads. + expect(uploadImage).not.toHaveBeenCalled(); + expect(paintSourceDispatches(dispatch)).toHaveLength(0); + const layer = engine.getDocument()!.layers[0] as CanvasRasterLayerContractV2; + expect(layer.source.type).toBe('shape'); + + engine.dispose(); + }); + + it('advancing the debounce timer: the layer stays the parametric shape and no paint dispatch fires', async () => { + const { dispatch, engine, uploadImage } = rasterizeThenUndo(); + + await vi.advanceTimersByTimeAsync(1500); + + expect(uploadImage).not.toHaveBeenCalled(); + expect(paintSourceDispatches(dispatch)).toHaveLength(0); + const layer = engine.getDocument()!.layers[0] as CanvasRasterLayerContractV2; + expect(layer.source.type).toBe('shape'); + + engine.dispose(); + }); + + it('redo after rasterize → flush → undo re-dispatches the paint image ref (fix round 2: no permanent bitmap:null)', async () => { + // Reviewer round 2, data-loss finding: rasterize → flush (contract lands + // on img-x, and the store's `lastApplied` remembers img-x) → undo (back + // to shape) → redo (convertCanvasLayer resets to `paint {bitmap: null}`) + // → a further flush re-bakes IDENTICAL pixels, so the content-hash dedupe + // resolves back to img-x — but the old `lastApplied`-based redundant- + // dispatch skip treated that as "already applied" and swallowed the + // dispatch, permanently stranding the document on `bitmap: null`. This + // drives the FULL sequence (including the first flush landing for real) + // and asserts the second flush actually re-dispatches the ref. + vi.useFakeTimers(); + let doc = shapeLayerDoc(); + const { dispatch, engine, raf, setDocument, uploadImage } = setupWithRealBitmapStore(doc); + + // Rasterize → bake to paint → flush lands img-x. + expect(engine.rasterizeLayer('shape1')).toBe(true); + doc = applyLastConvert(doc, dispatch); + setDocument(doc); + raf.flush(); + + await vi.advanceTimersByTimeAsync(1500); + await engine.flushPendingUploads(); + expect(uploadImage).toHaveBeenCalledTimes(1); + + const firstPersist = paintSourceDispatches(dispatch).at(-1); + expect(firstPersist).toBeDefined(); + expect(firstPersist?.source).toMatchObject({ bitmap: { imageName: 'img-x' }, type: 'paint' }); + // Apply the persisted ref onto the mirrored document, as the real reducer + // would — the document now genuinely points at img-x, not `bitmap: null`. + doc = { + ...doc, + layers: doc.layers.map((layer) => + layer.id === firstPersist?.id ? { ...layer, source: firstPersist.source } : layer + ), + }; + setDocument(doc); + raf.flush(); + + // Undo → back to the parametric shape source. + engine.undo(); + doc = applyLastConvert(doc, dispatch); + setDocument(doc); + raf.flush(); + + // Redo → paint bake again; the fresh conversion lands on `bitmap: null` + // (only the debounced flush fills in the persisted ref). + engine.redo(); + doc = applyLastConvert(doc, dispatch); + setDocument(doc); + raf.flush(); + const layerAfterRedo = engine.getDocument()!.layers[0] as CanvasRasterLayerContractV2; + expect(layerAfterRedo.source).toEqual({ bitmap: null, offset: { x: 10, y: 20 }, type: 'paint' }); + + // Flush again: the re-baked pixels are identical, so the content hash + // dedupes back to img-x with NO new upload — but the ref must still be + // re-dispatched into the contract, since the document currently reads + // `bitmap: null`, not img-x. + await vi.advanceTimersByTimeAsync(1500); + await engine.flushPendingUploads(); + + expect(uploadImage).toHaveBeenCalledTimes(1); // dedupe hit: no re-upload + const dispatchesAfterRedo = paintSourceDispatches(dispatch); + const secondPersist = dispatchesAfterRedo.at(-1); + expect(secondPersist).toBeDefined(); + expect(secondPersist).not.toBe(firstPersist); + expect(secondPersist?.source).toMatchObject({ bitmap: { imageName: 'img-x' }, type: 'paint' }); + + // Applying it restores the document's bitmap ref — no longer null. + doc = { + ...doc, + layers: doc.layers.map((layer) => + layer.id === secondPersist?.id ? { ...layer, source: secondPersist.source } : layer + ), + }; + setDocument(doc); + const layerFinal = engine.getDocument()!.layers[0] as CanvasRasterLayerContractV2; + expect(layerFinal.source.type).toBe('paint'); + if (layerFinal.source.type === 'paint') { + expect(layerFinal.source.bitmap).not.toBeNull(); + expect(layerFinal.source.bitmap?.imageName).toBe('img-x'); + } + + engine.dispose(); + }); + + it('sanity check: a normal (non-reverted) rasterize DOES flush to paint (the guard only blocks the reverted case)', async () => { + vi.useFakeTimers(); + let doc = shapeLayerDoc(); + const { dispatch, engine, raf, setDocument, uploadImage } = setupWithRealBitmapStore(doc); + + expect(engine.rasterizeLayer('shape1')).toBe(true); + doc = applyLastConvert(doc, dispatch); + setDocument(doc); + raf.flush(); + + await vi.advanceTimersByTimeAsync(1500); + await engine.flushPendingUploads(); + + expect(uploadImage).toHaveBeenCalledTimes(1); + expect(paintSourceDispatches(dispatch)).toHaveLength(1); + + engine.dispose(); + }); + }); +}); + +// ---- nudgeSelectedLayer: bounds/lock logic + coalescing ---------------- + +const selectedImageDoc = ( + overrides: { isLocked?: boolean; isEnabled?: boolean } = {}, + selectedLayerId: string | null = 'a' +): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers: [{ ...rasterLayer('a'), ...overrides }], + selectedLayerId, + version: 2, + width: 100, +}); + +describe('nudgeSelectedLayer', () => { + const setup = (doc: CanvasDocumentContractV2) => { + const { store } = createFakeStore(doc); + const dispatch = store.dispatch as Mock; + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + return { dispatch, engine }; + }; + + it('no-ops with no selection', () => { + const { dispatch, engine } = setup(selectedImageDoc({}, null)); + engine.nudgeSelectedLayer(1, 0); + expect(dispatch).not.toHaveBeenCalled(); + expect(engine.stores.canUndo.get()).toBe(false); + engine.dispose(); + }); + + it('no-ops on a locked selected layer', () => { + const { dispatch, engine } = setup(selectedImageDoc({ isLocked: true })); + engine.nudgeSelectedLayer(0, 1); + expect(dispatch).not.toHaveBeenCalled(); + expect(engine.stores.canUndo.get()).toBe(false); + engine.dispose(); + }); + + it('no-ops on a hidden selected layer', () => { + const { dispatch, engine } = setup(selectedImageDoc({ isEnabled: false })); + engine.nudgeSelectedLayer(0, 1); + expect(dispatch).not.toHaveBeenCalled(); + engine.dispose(); + }); + + it('dispatches a transform update and records an undoable entry', () => { + const { dispatch, engine } = setup(selectedImageDoc()); + engine.nudgeSelectedLayer(3, -2); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch).toHaveBeenNthCalledWith(1, { + id: 'a', + patch: { transform: { x: 3, y: -2 } }, + type: 'updateCanvasLayer', + }); + expect(engine.stores.canUndo.get()).toBe(true); + + // Undo dispatches the inverse (back to the original position). + engine.undo(); + expect(dispatch).toHaveBeenNthCalledWith(2, { + id: 'a', + patch: { transform: { x: 0, y: 0 } }, + type: 'updateCanvasLayer', + }); + engine.dispose(); + }); + + it('coalesces a rapid same-layer burst into one history entry', () => { + vi.spyOn(Date, 'now').mockReturnValue(1_000); + const { engine } = setup(selectedImageDoc()); + engine.nudgeSelectedLayer(1, 0); + engine.nudgeSelectedLayer(1, 0); + engine.nudgeSelectedLayer(1, 0); + // A single undo empties the stack: the burst collapsed to one entry. + expect(engine.stores.canUndo.get()).toBe(true); + engine.undo(); + expect(engine.stores.canUndo.get()).toBe(false); + engine.dispose(); + vi.restoreAllMocks(); + }); + + it('starts a fresh entry once the coalescing window elapses', () => { + const now = vi.spyOn(Date, 'now'); + now.mockReturnValue(1_000); + const { engine } = setup(selectedImageDoc()); + engine.nudgeSelectedLayer(1, 0); + now.mockReturnValue(2_000); // > 500ms later + engine.nudgeSelectedLayer(1, 0); + // Two distinct entries: one undo still leaves something to undo. + engine.undo(); + expect(engine.stores.canUndo.get()).toBe(true); + engine.undo(); + expect(engine.stores.canUndo.get()).toBe(false); + engine.dispose(); + vi.restoreAllMocks(); + }); +}); + +// ---- move tool: drag gesture through the pointer pipeline --------------- + +describe('move tool: drag through the pipeline', () => { + it('commits one structural transform update and records an undoable entry', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + // A document-sized paint layer is hit-testable everywhere and pre-selected. + const { store } = createReactiveStore(paintDoc()); + const dispatch = store.dispatch as Mock; + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const overlay = createInputCanvas(); + const screen = createInputCanvas(); + engine.attach(screen.element, overlay.element); + engine.setTool('move'); + + overlay.fire('pointerdown', pointerAt(20, 20)); + overlay.fire('pointermove', pointerAt(50, 30)); + overlay.fire('pointerup', pointerAt(50, 30, { buttons: 0 })); + + const transformUpdates = dispatch.mock.calls + .map((call) => call[0] as WorkbenchAction) + .filter((action) => action.type === 'updateCanvasLayer'); + expect(transformUpdates).toHaveLength(1); + expect(transformUpdates[0]).toMatchObject({ id: 'paint1', type: 'updateCanvasLayer' }); + expect(engine.stores.canUndo.get()).toBe(true); + + engine.dispose(); + }); + + it('a click (no drag) dispatches a selection change and no transform update', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const { store } = createReactiveStore(paintDoc()); + const dispatch = store.dispatch as Mock; + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const overlay = createInputCanvas(); + const screen = createInputCanvas(); + engine.attach(screen.element, overlay.element); + engine.setTool('move'); + + overlay.fire('pointerdown', pointerAt(20, 20)); + overlay.fire('pointerup', pointerAt(20, 20, { buttons: 0 })); + + const actions = dispatch.mock.calls.map((call) => call[0] as WorkbenchAction); + expect(actions.some((action) => action.type === 'updateCanvasLayer')).toBe(false); + expect(actions.some((action) => action.type === 'setCanvasSelectedLayer')).toBe(true); + expect(engine.stores.canUndo.get()).toBe(false); + + engine.dispose(); + }); +}); + +// ---- ride-along: composed auto-create + stroke history entry ------------ + +const imageSelectedDoc = (): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + // Selected layer is an image (not paintable) → a brush stroke auto-creates a + // fresh paint layer for the gesture. + layers: [rasterLayer('img')], + selectedLayerId: 'img', + version: 2, + width: 100, +}); + +describe('engine-owned history: composed auto-create + stroke entry', () => { + it('undo removes the auto-created layer (no pixel restore); redo re-adds it and re-applies the stroke', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal('Path2D', class FakePath2D {}); + + const { store } = createReactiveStore(imageSelectedDoc()); + const dispatch = store.dispatch as Mock; + const base = createTestStubRasterBackend(); + const surfaces: StubRasterSurface[] = []; + const backend = { + ...base, + createSurface: (w: number, h: number): StubRasterSurface => { + const surface = base.createSurface(w, h); + surfaces.push(surface); + return surface; + }, + }; + const engine = createCanvasEngine({ + backend, + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const strokes: StrokeCommittedEvent[] = []; + engine.onStrokeCommitted((event) => strokes.push(event)); + + const overlay = createInputCanvas(); + const screen = createInputCanvas(); + engine.attach(screen.element, overlay.element); + engine.setTool('brush'); + + overlay.fire('pointerdown', pointerAt(20, 20)); + overlay.fire('pointermove', pointerAt(40, 40)); + overlay.fire('pointerup', pointerAt(40, 40, { buttons: 0 })); + + // The gesture auto-created a paint layer and reported it on the commit event. + expect(strokes).toHaveLength(1); + const created = strokes[0]!.createdLayer; + expect(created).toBeDefined(); + const newLayerId = created!.layer.id; + expect(engine.stores.canUndo.get()).toBe(true); + + // The auto-create dispatched addCanvasLayer for the new id. + const addCalls = dispatch.mock.calls + .map((call) => call[0] as WorkbenchAction) + .filter((action) => action.type === 'addCanvasLayer'); + expect(addCalls).toHaveLength(1); + + const putBefore = () => putImageDataCalls(surfaces).some((call) => call.image === strokes[0]!.beforeImageData); + const beforeUndoPutBefore = putBefore(); + + // Undo: removes the auto-created layer, and does NOT restore pre-stroke pixels + // (the layer's cache is gone). + engine.undo(); + const removeAfterUndo = dispatch.mock.calls + .map((call) => call[0] as WorkbenchAction) + .filter((action) => action.type === 'removeCanvasLayers'); + expect(removeAfterUndo).toHaveLength(1); + expect(removeAfterUndo[0]).toEqual({ ids: [newLayerId], type: 'removeCanvasLayers' }); + // No new before-pixel putImageData was introduced by the undo. + expect(putBefore()).toBe(beforeUndoPutBefore); + expect(engine.stores.canUndo.get()).toBe(false); + expect(engine.stores.canRedo.get()).toBe(true); + + // Redo: re-adds the layer and re-applies the stroke's after pixels. + engine.redo(); + const addAfterRedo = dispatch.mock.calls + .map((call) => call[0] as WorkbenchAction) + .filter((action) => action.type === 'addCanvasLayer'); + expect(addAfterRedo).toHaveLength(2); // original auto-create + redo re-add + expect(putImageDataCalls(surfaces).some((call) => call.image === strokes[0]!.afterImageData)).toBe(true); + expect(engine.stores.canUndo.get()).toBe(true); + + engine.dispose(); + }); +}); + +describe('setStagedPreview', () => { + const emptyDoc = (bbox = { height: 100, width: 100, x: 0, y: 0 }): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox, + height: 100, + layers: [], + selectedLayerId: null, + version: 2, + width: 100, + }); + + /** A stub backend whose `createImageBitmap` is deferred, so decodes resolve on demand. */ + const createDeferredBitmapBackend = () => { + const base = createTestStubRasterBackend(); + const deferreds: ReturnType>[] = []; + const backend: StubRasterBackend = { + ...base, + createImageBitmap: () => { + const deferred = createDeferred(); + deferreds.push(deferred); + return deferred.promise; + }, + }; + return { + backend, + deferreds, + resolveBitmap: (index: number, width = 0, height = 0): void => + deferreds[index]!.resolve({ close: () => {}, height, width } as unknown as ImageBitmap), + }; + }; + + /** Screen-surface staged-preview draws are the 5-arg `drawImage(canvas, x, y, w, h)` calls. */ + const stagedDraws = (surface: StubRasterSurface): unknown[][] => + surface.callLog.filter((entry) => entry.op === 'drawImage' && entry.args.length === 5).map((entry) => entry.args); + + const dataUrl = (tag: string) => `data:image/png;base64,${tag}`; + + it('draws the newest decode and discards a stale one that resolves later (version race)', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const bitmaps = createDeferredBitmapBackend(); + const { store } = createReactiveStore(emptyDoc()); + const engine = createCanvasEngine({ + backend: bitmaps.backend, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.attach(screen.element, overlay.element); + raf.flush(); // initial (no layers, no preview) + screen.surface.callLog.length = 0; + + // Two rapid selections; the second supersedes the first. + engine.setStagedPreview({ dataUrl: dataUrl('AAAA'), height: 10, width: 10 }); // decode #0 + engine.setStagedPreview({ dataUrl: dataUrl('BBBB'), height: 20, width: 20 }); // decode #1 + + // The newer decode resolves first and is drawn; a frame must have been scheduled. + bitmaps.resolveBitmap(1); + await flushMicrotasks(); + expect(raf.pendingCount()).toBeGreaterThan(0); + raf.flush(); + + // The stale (older) decode resolves afterwards and must be dropped. + bitmaps.resolveBitmap(0); + await flushMicrotasks(); + raf.flush(); + + const draws = stagedDraws(screen.surface); + expect(draws.length).toBeGreaterThan(0); + // Every staged draw is the 20x20 candidate at the bbox origin; the 10x10 + // stale decode never reaches the screen. + for (const args of draws) { + expect(args.slice(1)).toEqual([0, 0, 20, 20]); + } + + engine.dispose(); + }); + + it('clears the preview on null', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const bitmaps = createDeferredBitmapBackend(); + const { store } = createReactiveStore(emptyDoc()); + const engine = createCanvasEngine({ + backend: bitmaps.backend, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + engine.attach(screen.element, createFakeCanvas().element); + + engine.setStagedPreview({ dataUrl: dataUrl('AAAA'), height: 10, width: 10 }); + bitmaps.resolveBitmap(0); + await flushMicrotasks(); + raf.flush(); + expect(stagedDraws(screen.surface).length).toBeGreaterThan(0); + + screen.surface.callLog.length = 0; + engine.setStagedPreview(null); + raf.flush(); + expect(stagedDraws(screen.surface)).toHaveLength(0); + + engine.dispose(); + }); + + it('follows the current bbox origin, not the bbox at set time', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const bitmaps = createDeferredBitmapBackend(); + const { setDocument, store } = createReactiveStore(emptyDoc({ height: 100, width: 100, x: 0, y: 0 })); + const engine = createCanvasEngine({ + backend: bitmaps.backend, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + engine.attach(screen.element, createFakeCanvas().element); + + engine.setStagedPreview({ dataUrl: dataUrl('AAAA'), height: 10, width: 10 }); + bitmaps.resolveBitmap(0); + await flushMicrotasks(); + raf.flush(); + expect(stagedDraws(screen.surface).at(-1)!.slice(1)).toEqual([0, 0, 10, 10]); + + // Move the bbox (an ordinary edit, same document revision — not a replacement). + screen.surface.callLog.length = 0; + setDocument(emptyDoc({ height: 10, width: 10, x: 30, y: 40 })); + raf.flush(); + expect(stagedDraws(screen.surface).at(-1)!.slice(1)).toEqual([30, 40, 10, 10]); + + engine.dispose(); + }); + + it('decodes an imageName candidate through the resolver and draws it at the bbox', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const { store } = createReactiveStore(emptyDoc({ height: 100, width: 100, x: 5, y: 7 })); + const resolver = vi.fn(() => Promise.resolve(new Blob())); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: resolver, + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + engine.attach(screen.element, createFakeCanvas().element); + + engine.setStagedPreview({ imageName: 'staged-candidate' }); + await flushMicrotasks(); + raf.flush(); + + expect(resolver).toHaveBeenCalledWith('staged-candidate'); + // The decoded (stub, 0-sized) surface is drawn at the current bbox origin. + expect(stagedDraws(screen.surface).at(-1)!.slice(1, 3)).toEqual([5, 7]); + + engine.dispose(); + }); +}); + +describe('setFilterPreview', () => { + const emptyDoc = (): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers: [], + selectedLayerId: null, + version: 2, + width: 100, + }); + + /** + * A layer that can be added/removed from the document to drive the mirror's + * `onLayersChanged`/`onDocumentReplaced` callbacks, without itself consuming a + * `createImageBitmap` call — a `bitmap: null` paint source rasterizes + * synchronously (a clear), unlike an image source, so it can't shift the call + * ordering the pruning tests rely on to target a SPECIFIC filter-preview decode. + */ + const previewableLayer = (id: string): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { bitmap: null, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }); + + /** A stub backend whose `createImageBitmap` is deferred, so decodes resolve on demand. */ + const createDeferredBitmapBackend = () => { + const base = createTestStubRasterBackend(); + const deferreds: ReturnType>[] = []; + const backend: StubRasterBackend = { + ...base, + createImageBitmap: () => { + const deferred = createDeferred(); + deferreds.push(deferred); + return deferred.promise; + }, + }; + return { + backend, + resolveBitmap: (index: number): void => + deferreds[index]!.resolve({ close: () => {}, height: 0, width: 0 } as unknown as ImageBitmap), + }; + }; + + it('decodes a per-layer filter preview candidate through the resolver and schedules a render', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const { store } = createReactiveStore(emptyDoc()); + const resolver = vi.fn(() => Promise.resolve(new Blob())); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: resolver, + projectId: 'p1', + store, + }); + engine.attach(createFakeCanvas().element, createFakeCanvas().element); + raf.flush(); + resolver.mockClear(); + + engine.setFilterPreview('layer-x', { imageName: 'filtered-preview' }); + await flushMicrotasks(); + + expect(resolver).toHaveBeenCalledWith('filtered-preview'); + // The resolved decode invalidates the layer, so a frame is scheduled. + expect(raf.pendingCount()).toBeGreaterThan(0); + + engine.dispose(); + }); + + it('clears a filter preview on null without decoding again', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const { store } = createReactiveStore(emptyDoc()); + const resolver = vi.fn(() => Promise.resolve(new Blob())); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: resolver, + projectId: 'p1', + store, + }); + engine.attach(createFakeCanvas().element, createFakeCanvas().element); + raf.flush(); + + engine.setFilterPreview('layer-x', { imageName: 'p' }); + await flushMicrotasks(); + raf.flush(); + resolver.mockClear(); + + // Clearing does not initiate a new decode. + engine.setFilterPreview('layer-x', null); + await flushMicrotasks(); + expect(resolver).not.toHaveBeenCalled(); + + engine.dispose(); + }); + + it('drops a stale decode superseded by a newer set for the same layer (version guard)', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const bitmaps = createDeferredBitmapBackend(); + const { store } = createReactiveStore(emptyDoc()); + const engine = createCanvasEngine({ + backend: bitmaps.backend, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + engine.attach(createFakeCanvas().element, createFakeCanvas().element); + raf.flush(); + + // Two rapid previews for the SAME layer; the second supersedes the first. + engine.setFilterPreview('L', { imageName: 'A' }); // decode #0 (stale) + engine.setFilterPreview('L', { imageName: 'B' }); // decode #1 (newest) + await flushMicrotasks(); + + // The newest decode resolves and schedules a frame. + bitmaps.resolveBitmap(1); + await flushMicrotasks(); + expect(raf.pendingCount()).toBeGreaterThan(0); + raf.flush(); + + // The stale (older) decode resolves afterwards: the version guard drops it, so + // it must NOT schedule another frame. + bitmaps.resolveBitmap(0); + await flushMicrotasks(); + expect(raf.pendingCount()).toBe(0); + + engine.dispose(); + }); + + // ---- Review fix (Task 38, finding 1): pruning on layer removal / doc replace --- + // + // A filter preview is per-layer session state. If the layer it belongs to leaves + // the mirrored document (deleted, or a wholesale document swap), the preview must + // be dropped AND its decode token bumped — never reset to a value a still-in-flight + // decode could later match — or a late-resolving (or undo-restored) decode can + // resurrect a preview for a layer nobody is looking at anymore. + + it("bumps the layer's preview token when its layer leaves the document, so a late-resolving decode never resurrects a preview after an undo restores the same id", async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const bitmaps = createDeferredBitmapBackend(); + const withLayer = { ...emptyDoc(), layers: [previewableLayer('L')] }; + const { setDocument, store } = createReactiveStore(withLayer); + const engine = createCanvasEngine({ + backend: bitmaps.backend, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + engine.attach(createFakeCanvas().element, createFakeCanvas().element); + raf.flush(); + + // Start a filter-preview decode for layer L (decode #0). + engine.setFilterPreview('L', { imageName: 'A' }); + await flushMicrotasks(); + + // Layer L leaves the document via an ordinary layer-array edit (same dims, + // same revision) — onLayersChanged, not onDocumentReplaced. This must bump + // L's token so decode #0's captured token can never match again. + setDocument({ ...withLayer, layers: [] }); + raf.flush(); + + // An undo restores the layer (same id) and the user starts a fresh preview + // (decode #1) before decode #0 ever resolves. + setDocument(withLayer); + raf.flush(); + // The restored layer's OWN (unrelated) rasterize dispatch/completion cycle + // must be fully settled before the assertions below, or its completion's + // unconditional `scheduler.invalidate` would land during a later + // `flushMicrotasks()` and be mistaken for filter-preview activity. + await flushMicrotasks(); + raf.flush(); + engine.setFilterPreview('L', { imageName: 'C' }); + await flushMicrotasks(); + + // Decode #0 (stale, pre-deletion) resolves late: the bumped token rejects + // it, so it must NOT resurrect a preview for the restored layer. + bitmaps.resolveBitmap(0); + await flushMicrotasks(); + expect(raf.pendingCount()).toBe(0); + + // Decode #1 (the fresh, post-restore request) resolves normally. + bitmaps.resolveBitmap(1); + await flushMicrotasks(); + expect(raf.pendingCount()).toBeGreaterThan(0); + + engine.dispose(); + }); + + it('clears every filter preview on a wholesale document replace, rejecting a decode that was in flight before the swap', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const bitmaps = createDeferredBitmapBackend(); + const withLayer = { ...emptyDoc(), layers: [previewableLayer('L')] }; + const { setDocument, store } = createReactiveStore(withLayer); + const engine = createCanvasEngine({ + backend: bitmaps.backend, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + engine.attach(createFakeCanvas().element, createFakeCanvas().element); + raf.flush(); + + // A live, already-decoded preview for L. + engine.setFilterPreview('L', { imageName: 'A' }); // decode #0 + await flushMicrotasks(); + bitmaps.resolveBitmap(0); + await flushMicrotasks(); + raf.flush(); + + // A second, slower decode for the SAME id starts (decode #1) and is still + // in flight when a wholesale document swap arrives (dims change — a project + // switch or snapshot restore that changes dims). + engine.setFilterPreview('L', { imageName: 'A2' }); // decode #1, in flight + await flushMicrotasks(); + setDocument({ ...withLayer, height: 200, width: 200 }, 1); + raf.flush(); + // Settle the swapped-in layer's own (unrelated) rasterize dispatch/completion + // cycle before asserting — see the analogous comment in the prior test. + await flushMicrotasks(); + raf.flush(); + + // Decode #1 resolves AFTER the swap: even though it targets the SAME layer + // id, it describes a document that no longer exists, so it must be rejected. + bitmaps.resolveBitmap(1); + await flushMicrotasks(); + expect(raf.pendingCount()).toBe(0); + + // A fresh request post-swap still decodes normally. + engine.setFilterPreview('L', { imageName: 'B' }); // decode #2 + await flushMicrotasks(); + bitmaps.resolveBitmap(2); + await flushMicrotasks(); + expect(raf.pendingCount()).toBeGreaterThan(0); + + engine.dispose(); + }); +}); + +// ---- C1: prop/transform edits must not wipe an unflushed paint layer ----- +// +// A `bitmap: null` paint layer's strokes live ONLY in its raster cache until a +// debounced upload persists them. The paint rasterizer clears the surface for a +// null bitmap, so re-rasterizing such a layer WIPES the strokes. The engine must +// therefore invalidate a layer's cache only when its SOURCE reference changed — +// never for a prop/transform-only edit (opacity/blend/lock/rename/nudge), which +// the compositor already applies at draw time. + +/** Full-surface clears (`clearRect(0,0,w,h)`) recorded on a stub surface — the wipe signature. */ +const fullClearCount = (surface: StubRasterSurface): number => + surface.callLog.filter((entry) => entry.op === 'clearRect' && entry.args[0] === 0 && entry.args[1] === 0).length; + +describe('document mirror wiring: prop vs source change (paint-pixel survival)', () => { + /** + * Rasterizes the pre-existing `paint1` layer to completion (as real frames + * would before the user paints — the existing-layer paint path does NOT mark + * the cache non-stale itself), then draws one stroke into that non-stale cache. + * This reproduces the C1 state: real strokes living only in a cache that a + * spurious re-rasterize (of the `bitmap: null` source) would clear to blank. + */ + const paintOneStroke = async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal('Path2D', class FakePath2D {}); + + const { setDocument, store } = createReactiveStore(paintDoc()); + const base = createTestStubRasterBackend(); + const surfaces: StubRasterSurface[] = []; + const backend = { + ...base, + createSurface: (w: number, h: number): StubRasterSurface => { + const surface = base.createSurface(w, h); + surfaces.push(surface); + return surface; + }, + }; + const bitmapStore = createSpyBitmapStore(); + const resolver = vi.fn((_imageName: string) => Promise.resolve(new Blob())); + const engine = createCanvasEngine({ + backend, + bitmapStore, + imageResolver: resolver, + projectId: 'p1', + store, + }); + const overlay = createInputCanvas(); + const screen = createInputCanvas(); + engine.attach(screen.element, overlay.element); + engine.setTool('brush'); + + // Initial rasterize of the (bitmap: null) paint layer → one full clear, then + // the cache settles non-stale (the `.then` fires on a microtask). + raf.flush(); + await flushMicrotasks(); + raf.flush(); + + overlay.fire('pointerdown', pointerAt(20, 20)); + overlay.fire('pointermove', pointerAt(40, 40)); + overlay.fire('pointerup', pointerAt(40, 40, { buttons: 0 })); + + // The painted layer cache is the only engine-backend surface that ever gets + // `getImageData` (the before/after stroke capture); the scratch stroke + // surface never does. + const paintCache = surfaces.find((surface) => surface.callLog.some((entry) => entry.op === 'getImageData')); + return { bitmapStore, engine, paintCache: paintCache!, raf, resolver, setDocument }; + }; + + it('keeps an unflushed paint layer’s pixels on a transform/opacity-only change (no re-rasterize)', async () => { + const { engine, paintCache, raf, resolver, setDocument } = await paintOneStroke(); + + // Baseline: the stroke composited into the cache (a drawImage). Exactly one + // full clear so far — the initial rasterize; the stroke never clears. + expect(paintCache.callLog.some((entry) => entry.op === 'drawImage')).toBe(true); + const clearsBefore = fullClearCount(paintCache); + + // A prop-only edit that PRESERVES the source reference (exactly as the reducer + // does — it spreads `...layer`): opacity + a transform nudge. + const doc = engine.getDocument()!; + const layer = doc.layers[0]!; + setDocument({ + ...doc, + layers: [{ ...layer, opacity: 0.5, transform: { ...layer.transform, x: 12, y: -4 } }], + }); + raf.flush(); + await flushMicrotasks(); + raf.flush(); + + // The cache was NOT re-rasterized: no new full clear, so the painted pixels + // survive. (A `bitmap: null` re-rasterize would clear the surface to blank.) + expect(fullClearCount(paintCache)).toBe(clearsBefore); + // No resolve/rasterize was even attempted for the paint layer. + expect(resolver).not.toHaveBeenCalled(); + expect(engine.getDocument()!.layers[0]!.opacity).toBe(0.5); + + engine.dispose(); + }); + + it('flushing after a prop-only change persists the painted (non-blank) surface', async () => { + const { bitmapStore, engine, paintCache, raf, setDocument } = await paintOneStroke(); + const clearsBefore = fullClearCount(paintCache); + + const doc = engine.getDocument()!; + const layer = doc.layers[0]!; + setDocument({ ...doc, layers: [{ ...layer, opacity: 0.25 }] }); + raf.flush(); + await flushMicrotasks(); + raf.flush(); + + await engine.flushPendingUploads(); + + // The flush barrier ran, and it operated on a cache that was never wiped: the + // surface still carries the stroke (drawImage) with no re-rasterize clear, so + // the bitmap store (which encodes this exact surface) persists real pixels. + expect(bitmapStore.flushPendingUploads).toHaveBeenCalled(); + expect(fullClearCount(paintCache)).toBe(clearsBefore); + expect(paintCache.callLog.some((entry) => entry.op === 'drawImage')).toBe(true); + + engine.dispose(); + }); + + it('re-rasterizes when the paint layer’s source genuinely changes (swap to a persisted bitmap)', async () => { + const { engine, raf, resolver, setDocument } = await paintOneStroke(); + expect(resolver).not.toHaveBeenCalled(); + + // A genuine source swap (undo/import → a NEW paint source object with a + // persisted bitmap). isSelfEcho is false in the spy store, so this must + // invalidate and re-rasterize — which decodes the persisted image. + const doc = engine.getDocument()!; + const layer = doc.layers[0] as CanvasRasterLayerContractV2; + setDocument({ + ...doc, + layers: [ + { + ...layer, + source: { bitmap: { contentHash: 'h', height: 100, imageName: 'persisted', width: 100 }, type: 'paint' }, + }, + ], + }); + raf.flush(); + await flushMicrotasks(); + raf.flush(); + + // The cache was invalidated and re-rasterized from the new persisted source. + expect(resolver).toHaveBeenCalledWith('persisted'); + + engine.dispose(); + }); + + it('leaves image layers alone on a prop change but re-rasterizes on a source swap', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const { setDocument, store } = createReactiveStore(makeDoc()); // one image layer 'a' + const resolver = vi.fn((_imageName: string) => Promise.resolve(new Blob())); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: resolver, + projectId: 'p1', + store, + }); + engine.attach(createFakeCanvas().element, createFakeCanvas().element); + + // Initial rasterize of image 'a'. + raf.flush(); + await flushMicrotasks(); + raf.flush(); + expect(resolver).toHaveBeenCalledTimes(1); + expect(resolver).toHaveBeenNthCalledWith(1, 'a'); + + // Prop-only edit (opacity), source reference preserved: no re-rasterize. + const doc = engine.getDocument()!; + setDocument({ ...doc, layers: [{ ...doc.layers[0]!, opacity: 0.3 }] }); + raf.flush(); + await flushMicrotasks(); + raf.flush(); + expect(resolver).toHaveBeenCalledTimes(1); + + // Source swap (new image name → new source object): re-rasterizes the new source. + const doc2 = engine.getDocument()!; + const imgLayer = doc2.layers[0] as CanvasRasterLayerContractV2; + setDocument({ + ...doc2, + layers: [{ ...imgLayer, source: { image: { height: 10, imageName: 'a-v2', width: 10 }, type: 'image' } }], + }); + raf.flush(); + await flushMicrotasks(); + raf.flush(); + expect(resolver).toHaveBeenCalledTimes(2); + expect(resolver).toHaveBeenNthCalledWith(2, 'a-v2'); + + engine.dispose(); + }); +}); + +// ---- I4: structural edits are no-ops during an active pointer gesture ----- + +describe('gesture guard: nudge / commitStructural mid-stroke', () => { + const startOpenStroke = () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal('Path2D', class FakePath2D {}); + + const { store } = createReactiveStore(paintDoc()); + const dispatch = store.dispatch as Mock; + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const overlay = createInputCanvas(); + const screen = createInputCanvas(); + engine.attach(screen.element, overlay.element); + engine.setTool('brush'); + // Open a stroke (pointer down + move, NO up) so the gesture stays active. + overlay.fire('pointerdown', pointerAt(20, 20)); + overlay.fire('pointermove', pointerAt(30, 30)); + return { dispatch, engine, overlay }; + }; + + it('no-ops nudgeSelectedLayer while a stroke gesture is open', () => { + const { dispatch, engine, overlay } = startOpenStroke(); + const before = dispatch.mock.calls.length; + + engine.nudgeSelectedLayer(1, 0); + // No structural transform dispatch, no history entry. + expect(dispatch.mock.calls.filter((call) => call[0].type === 'updateCanvasLayer')).toHaveLength(0); + expect(engine.stores.canUndo.get()).toBe(false); + + // After the gesture ends, the nudge lands. + overlay.fire('pointerup', pointerAt(30, 30, { buttons: 0 })); + engine.nudgeSelectedLayer(1, 0); + expect(dispatch.mock.calls.length).toBeGreaterThan(before); + expect(dispatch.mock.calls.some((call) => call[0].type === 'updateCanvasLayer')).toBe(true); + + engine.dispose(); + }); + + it('no-ops commitStructural while a stroke gesture is open, then commits after it ends', () => { + const { dispatch, engine, overlay } = startOpenStroke(); + const forward: WorkbenchAction = { id: 'x', type: 'setCanvasSelectedLayer' }; + const inverse: WorkbenchAction = { id: null, type: 'setCanvasSelectedLayer' }; + + engine.commitStructural('Select', forward, inverse); + // Nothing dispatched, nothing recorded on history mid-gesture. + expect(dispatch.mock.calls.some((call) => call[0] === forward)).toBe(false); + expect(engine.stores.canUndo.get()).toBe(false); + + overlay.fire('pointerup', pointerAt(30, 30, { buttons: 0 })); + engine.commitStructural('Select', forward, inverse); + expect(dispatch.mock.calls.some((call) => call[0] === forward)).toBe(true); + expect(engine.stores.canUndo.get()).toBe(true); + + engine.dispose(); + }); + + it('no-ops mergeLayerDown while a stroke gesture is open, then merges after it ends', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal('Path2D', class FakePath2D {}); + + const { store } = createReactiveStore(twoPaintDoc()); + const dispatch = store.dispatch as Mock; + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const overlay = createInputCanvas(); + const screen = createInputCanvas(); + engine.attach(screen.element, overlay.element); + // Build both layer caches so a merge could otherwise succeed; await the async + // decode so both are READY (merge refuses stale/in-flight caches — finding 20). + raf.flush(); + await flushMicrotasks(); + raf.flush(); + engine.setTool('brush'); + + // Open a stroke into the selected 'upper' paint layer (no pointer-up). + overlay.fire('pointerdown', pointerAt(20, 20)); + overlay.fire('pointermove', pointerAt(30, 30)); + + // Mid-gesture merge is refused (matches commitStructural/nudge): not undoable. + expect(engine.mergeLayerDown('upper')).toBe(false); + expect(dispatch.mock.calls.some((call) => (call[0] as WorkbenchAction).type === 'mergeCanvasLayersDown')).toBe( + false + ); + + // After the gesture ends, the merge lands. + overlay.fire('pointerup', pointerAt(30, 30, { buttons: 0 })); + expect(engine.mergeLayerDown('upper')).toBe(true); + expect(dispatch.mock.calls.some((call) => (call[0] as WorkbenchAction).type === 'mergeCanvasLayersDown')).toBe( + true + ); + + engine.dispose(); + }); +}); + +// ---- brush cursor ring: resizes on a size change with no pointer event ---- + +describe('brush cursor ring: live size updates', () => { + it('invalidates the overlay when the brush size changes without a pointer move', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal('Path2D', class FakePath2D {}); + + const { store } = createReactiveStore(paintDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const overlay = createInputCanvas(); + const screen = createInputCanvas(); + engine.attach(screen.element, overlay.element); + engine.setTool('brush'); + + // A bare hover move (no button) sets the cursor ring at a known position. + overlay.fire('pointermove', pointerAt(30, 30, { buttons: 0 })); + raf.flush(); + expect(raf.pendingCount()).toBe(0); + + // The `[`/`]` path: a size step with NO pointer event must schedule a frame + // (the ring redraws at its last position with the new radius). + engine.stepBrushSize(1); + expect(raf.pendingCount()).toBeGreaterThan(0); + raf.flush(); + + // The options-bar slider path (a direct store write) likewise invalidates. + engine.stores.brushOptions.set({ ...engine.stores.brushOptions.get(), size: 123 }); + expect(raf.pendingCount()).toBeGreaterThan(0); + + engine.dispose(); + }); + + it('does not invalidate for a size change while no ring is shown (pointer off-canvas)', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal('Path2D', class FakePath2D {}); + + const { store } = createReactiveStore(paintDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const overlay = createInputCanvas(); + const screen = createInputCanvas(); + engine.attach(screen.element, overlay.element); + engine.setTool('brush'); + raf.flush(); + expect(raf.pendingCount()).toBe(0); + + // No hover move happened, so there is no ring to resize: no frame scheduled. + engine.stepBrushSize(1); + expect(raf.pendingCount()).toBe(0); + + engine.dispose(); + }); +}); + +// ---- doc-replace mid-gesture: cancels the active tool gesture (I-follow-up) ---- + +describe('doc-replace mid-gesture: cancels the active tool gesture', () => { + it('cancels a bbox drag on a document swap, clears the preview, and commits nothing on pointer-up', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const { setDocument, store } = createReactiveStore(paintDoc()); + const dispatch = store.dispatch as Mock; + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const overlay = createInputCanvas(); + const screen = createInputCanvas(); + engine.attach(screen.element, overlay.element); + engine.setTool('bbox'); + + // Start a bbox move-drag (press inside the 100x100 frame, then drag). + overlay.fire('pointerdown', pointerAt(40, 40)); + overlay.fire('pointermove', pointerAt(60, 60)); + expect(engine.stores.bboxPreview.get()).not.toBeNull(); + + // A wholesale document swap mid-drag (dims change → onDocumentReplaced). + setDocument({ ...paintDoc(), height: 200, width: 200 }); + + // The gesture was cancelled: the transient preview is cleared. + expect(engine.stores.bboxPreview.get()).toBeNull(); + + // The eventual pointer-up must NOT commit a bbox against the replaced document. + overlay.fire('pointerup', pointerAt(60, 60, { buttons: 0 })); + expect(dispatch.mock.calls.some((call) => (call[0] as WorkbenchAction).type === 'setCanvasBbox')).toBe(false); + expect(engine.stores.canUndo.get()).toBe(false); + + engine.dispose(); + }); + + it('cancels a move-tool drag on a document swap, clearing the transform override', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const { setDocument, store } = createReactiveStore(paintDoc()); + const dispatch = store.dispatch as Mock; + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const overlay = createInputCanvas(); + const screen = createInputCanvas(); + engine.attach(screen.element, overlay.element); + engine.setTool('move'); + + // Drag the doc-sized paint layer. + overlay.fire('pointerdown', pointerAt(20, 20)); + overlay.fire('pointermove', pointerAt(50, 40)); + + const updatesBefore = dispatch.mock.calls.filter( + (call) => (call[0] as WorkbenchAction).type === 'updateCanvasLayer' + ).length; + + setDocument({ ...paintDoc(), height: 200, width: 200 }); + + // Pointer-up after the swap commits no transform update (the gesture was cancelled). + overlay.fire('pointerup', pointerAt(50, 40, { buttons: 0 })); + const updatesAfter = dispatch.mock.calls.filter( + (call) => (call[0] as WorkbenchAction).type === 'updateCanvasLayer' + ).length; + expect(updatesAfter).toBe(updatesBefore); + expect(engine.stores.canUndo.get()).toBe(false); + + engine.dispose(); + }); +}); + +// ---- Zoom-cost regressions: what must NOT scale with zoom ---------------- +// +// The reported lag ("laggier the closer you zoom in") traced to the render loop +// recompositing the whole document on EVERY invalidation — including overlay-only +// hover frames, whose cost is otherwise constant. A full composite up-scales each +// doc-sized layer surface to fill the screen, so its fill-rate grows with zoom. +// These lock the two fixes: overlay-only frames skip the composite, and composites +// disable image smoothing when zoomed in (crisp + no bilinear up-scale per frame). + +describe('zoom-cost: overlay-only frames skip the document composite', () => { + /** A fake canvas that can BOTH fire pointer events and expose its recording surface. */ + const createFireableSurfaceCanvas = ( + width = 100, + height = 100 + ): { + element: HTMLCanvasElement; + fire: (type: string, event: Partial) => void; + surface: StubRasterSurface; + } => { + const surface = createTestStubRasterBackend().createSurface(width, height); + const listeners = new Map void>>(); + const element = { + addEventListener: (type: string, handler: (event: Event) => void) => { + const set = listeners.get(type) ?? new Set(); + set.add(handler); + listeners.set(type, set); + }, + getBoundingClientRect: () => ({ bottom: height, height, left: 0, right: width, top: 0, width, x: 0, y: 0 }), + getContext: () => surface.ctx, + height, + releasePointerCapture: () => {}, + removeEventListener: (type: string, handler: (event: Event) => void) => { + listeners.get(type)?.delete(handler); + }, + setPointerCapture: () => {}, + width, + } as unknown as HTMLCanvasElement; + const fire = (type: string, event: Partial): void => { + for (const handler of listeners.get(type) ?? []) { + handler({ preventDefault: () => {}, ...event } as unknown as Event); + } + }; + return { element, fire, surface }; + }; + + /** Composite draws land on the screen surface as clear/fill/blit ops. */ + const compositeOps = (surface: StubRasterSurface): RasterCallLogEntry[] => + surface.callLog.filter((e) => e.op === 'drawImage' || e.op === 'clearRect' || e.op === 'fillRect'); + + it('a hover pointermove redraws only the overlay, never the screen composite', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const { store } = createReactiveStore(paintDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFireableSurfaceCanvas(); + const overlay = createFireableSurfaceCanvas(); + engine.attach(screen.element, overlay.element); + engine.setTool('brush'); + + // Drain the attach `{ all }` frame and the paint layer's async rasterize + // follow-up so no composite-triggering work is left pending. + raf.flush(); + await flushMicrotasks(); + raf.flush(); + + // Isolate the hover frame. + screen.surface.callLog.length = 0; + overlay.surface.callLog.length = 0; + + // A hover move (no button pressed) resizes the cursor ring → `{ overlay: true }`. + overlay.fire('pointermove', pointerAt(40, 40, { buttons: 0 })); + raf.flush(); + + // The screen composite did NOT run: zero clears/fills/blits landed on it. This + // is the win — hover cost is now independent of zoom and document size. + expect(compositeOps(screen.surface)).toHaveLength(0); + // The overlay WAS redrawn: cleared, and the cursor ring arc drawn. + expect(overlay.surface.callLog.some((e) => e.op === 'clearRect')).toBe(true); + expect(overlay.surface.callLog.some((e) => e.op === 'arc')).toBe(true); + + engine.dispose(); + }); + + it('composites with smoothing OFF when zoomed in and ON when zoomed out', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const findSet = (surface: StubRasterSurface, prop: string): unknown[] => + surface.callLog.filter((e) => e.op === 'set' && e.args[0] === prop).map((e) => e.args[1]); + + const { store } = createReactiveStore(paintDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.attach(screen.element, overlay.element); + raf.flush(); + await flushMicrotasks(); + raf.flush(); + + // Zoom in to 20× → a view invalidation → recomposite with smoothing off. + screen.surface.callLog.length = 0; + engine.getViewport().zoomAtPoint(20, { x: 0, y: 0 }); + raf.flush(); + expect(findSet(screen.surface, 'imageSmoothingEnabled')).toContain(false); + + // Zoom out below 1× → recomposite with smoothing on (clean down-scale). + screen.surface.callLog.length = 0; + engine.getViewport().zoomAtPoint(0.5, { x: 0, y: 0 }); + raf.flush(); + expect(findSet(screen.surface, 'imageSmoothingEnabled')).toContain(true); + + engine.dispose(); + }); + + it('builds the checkerboard pattern tile once across many composited frames', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + // Count surfaces allocated at the checkerboard tile size (CHECKERBOARD_SQUARE_PX * 2 = 16). + const base = createTestStubRasterBackend(); + const tileSurfaceSizes: string[] = []; + const backend: StubRasterBackend = { + ...base, + createSurface: (w: number, h: number): StubRasterSurface => { + if (w === 16 && h === 16) { + tileSurfaceSizes.push(`${w}x${h}`); + } + return base.createSurface(w, h); + }, + }; + + const { store } = createReactiveStore(paintDoc()); + const engine = createCanvasEngine({ + backend, + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + // Checkerboard is on by default; the transparent paintDoc fills with it. + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.attach(screen.element, overlay.element); + + // Force several composited frames (each recomposites and re-`createPattern`s). + for (let i = 0; i < 5; i++) { + engine.getViewport().zoomAtPoint(1 + i, { x: 0, y: 0 }); + raf.flush(); + await flushMicrotasks(); + } + + // The tile surface was built exactly once and reused (no per-frame rebuild). + expect(tileSurfaceSizes).toEqual(['16x16']); + + engine.dispose(); + }); +}); + +// ---- checker colors: theme-token feed → tile rebuild + recomposite --------- + +const fillStyleSets = (surface: StubRasterSurface): unknown[] => + surface.callLog.filter((e) => e.op === 'set' && e.args[0] === 'fillStyle').map((e) => e.args[1]); + +describe('checker colors: fed-token tile rebuild', () => { + it('builds the fallback-colored tile when never fed, then rebuilds with fed colors and recomposites', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + // Capture every 16x16 checker tile surface (CHECKERBOARD_SQUARE_PX * 2). + const base = createTestStubRasterBackend(); + const tiles: StubRasterSurface[] = []; + const backend: StubRasterBackend = { + ...base, + createSurface: (w: number, h: number): StubRasterSurface => { + const surface = base.createSurface(w, h); + if (w === 16 && h === 16) { + tiles.push(surface); + } + return surface; + }, + }; + + const { store } = createReactiveStore(paintDoc()); + const engine = createCanvasEngine({ + backend, + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.attach(screen.element, overlay.element); + raf.flush(); + await flushMicrotasks(); + + // First composite built the tile once, using the React-free fallback colors. + expect(tiles).toHaveLength(1); + expect(fillStyleSets(tiles[0]!)).toEqual([DEFAULT_CHECKER_COLORS.a, DEFAULT_CHECKER_COLORS.b]); + + // Feed new (resolved-token) checker colors: the cached tile is dropped and + // rebuilt with the new colors on the next composite, which is forced to run. + engine.stores.checkerColors.set({ a: '#010101', b: '#020202' }); + raf.flush(); + await flushMicrotasks(); + + expect(tiles).toHaveLength(2); + expect(fillStyleSets(tiles[1]!)).toEqual(['#010101', '#020202']); + + engine.dispose(); + }); + + it('does not rebuild the tile when fed colors are unchanged (equality-gated store)', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const base = createTestStubRasterBackend(); + const tiles: StubRasterSurface[] = []; + const backend: StubRasterBackend = { + ...base, + createSurface: (w: number, h: number): StubRasterSurface => { + const surface = base.createSurface(w, h); + if (w === 16 && h === 16) { + tiles.push(surface); + } + return surface; + }, + }; + + const { store } = createReactiveStore(paintDoc()); + const engine = createCanvasEngine({ + backend, + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.attach(screen.element, overlay.element); + raf.flush(); + await flushMicrotasks(); + expect(tiles).toHaveLength(1); + + // Re-feeding the SAME colors is a no-op (the store's equality check drops it), + // so no invalidation and no tile rebuild. + engine.stores.checkerColors.set({ ...DEFAULT_CHECKER_COLORS }); + raf.flush(); + await flushMicrotasks(); + expect(tiles).toHaveLength(1); + + engine.dispose(); + }); +}); + +// ---- fitToView: content ∪ bbox (document rect retired as world bounds) ------ + +const noLayerDoc = (overrides: Partial = {}): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 1000, + layers: [], + selectedLayerId: null, + version: 2, + width: 1000, + ...overrides, +}); + +describe('fitToView: content ∪ bbox', () => { + it('fits the bbox (not the larger doc rect) on an empty canvas', () => { + const { store } = createReactiveStore(noLayerDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + engine.resize(400, 400, 1); + + engine.fitToView(); + + // avail = 400 - 48*2 = 304; fitting the 100px bbox → 3.04. Fitting the 1000px + // doc rect would have been ~0.304 — the doc rect is no longer the fit target. + expect(engine.getViewport().getZoom()).toBeCloseTo(3.04, 2); + engine.dispose(); + }); + + it('unions a renderable layer that lies beyond the bbox into the fit', () => { + // A raster layer 100x100 translated to (900,900): content extends to 1000, far + // past the 100px bbox. Fitting content ∪ bbox spans 0..1000 → zoom ~0.304. + const layer: CanvasLayerContract = { + blendMode: 'normal', + id: 'a', + isEnabled: true, + isLocked: false, + name: 'a', + opacity: 1, + source: { image: { height: 100, imageName: 'a', width: 100 }, type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 900, y: 900 }, + type: 'raster', + }; + const { store } = createReactiveStore(noLayerDoc({ layers: [layer] })); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + engine.resize(400, 400, 1); + + engine.fitToView(); + + // Union spans (0,0)..(1000,1000) = 1000px; avail 304 → 0.304. + expect(engine.getViewport().getZoom()).toBeCloseTo(0.304, 2); + engine.dispose(); + }); +}); + +// ---- transform session: param commit (image) + pixel bake (paint) ---------- + +describe('transform session', () => { + it('image layer Apply commits one structural transform with the exact inverse', () => { + const { store } = createReactiveStore(selectedImageDoc()); + const dispatch = store.dispatch as Mock; + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + + engine.setTool('transform'); + engine.updateTransformSession({ rotation: 0, scaleX: 2, scaleY: 2, x: 5, y: 6 }); + dispatch.mockClear(); + engine.applyTransform(); + + const layerDispatches = dispatch.mock.calls + .map((call) => call[0] as WorkbenchAction) + .filter((action) => action.type === 'updateCanvasLayer'); + // Exactly one structural dispatch (the forward transform); no pixel work. + expect(layerDispatches).toHaveLength(1); + expect(layerDispatches[0]).toEqual({ + id: 'a', + patch: { transform: { rotation: 0, scaleX: 2, scaleY: 2, x: 5, y: 6 } }, + type: 'updateCanvasLayer', + }); + expect(engine.stores.transformSession.get()).toBeNull(); + expect(engine.stores.canUndo.get()).toBe(true); + + // Undo dispatches the exact inverse (the captured start transform). + dispatch.mockClear(); + engine.undo(); + const undoDispatch = dispatch.mock.calls + .map((call) => call[0] as WorkbenchAction) + .find((action) => action.type === 'updateCanvasLayer'); + expect(undoDispatch).toEqual({ + id: 'a', + patch: { transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 } }, + type: 'updateCanvasLayer', + }); + + engine.dispose(); + }); + + it('parametric (text) layer Apply commits ONE param transform, stays type text, no bake; undo restores', () => { + // Regression: parametric layers (shape/gradient/text) could not be transformed + // — `applyTransform` only handled image sources. Phase 5 "param for parametric": + // the transform commits as a param edit and the source stays editable-forever. + const textLayerDoc: CanvasDocumentContractV2 = { + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers: [ + { + blendMode: 'normal', + id: 't', + isEnabled: true, + isLocked: false, + name: 'Text', + opacity: 1, + source: { + align: 'left', + color: '#000000', + content: 'hi', + fontFamily: 'sans', + fontSize: 20, + fontWeight: 400, + lineHeight: 1.2, + type: 'text', + }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }, + ], + selectedLayerId: 't', + version: 2, + width: 100, + }; + const { store } = createReactiveStore(textLayerDoc); + const dispatch = store.dispatch as Mock; + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + + engine.setTool('transform'); + // A session opened on the (now hit-testable) text layer. + expect(engine.stores.transformSession.get()).not.toBeNull(); + engine.updateTransformSession({ rotation: 0, scaleX: 2, scaleY: 2, x: 5, y: 6 }); + dispatch.mockClear(); + engine.applyTransform(); + + const actions = dispatch.mock.calls.map((call) => call[0] as WorkbenchAction); + const updates = actions.filter((a) => a.type === 'updateCanvasLayer'); + // ONE param transform; source untouched (stays text) — no convert, no bake. + expect(updates).toHaveLength(1); + expect(updates[0]).toEqual({ + id: 't', + patch: { transform: { rotation: 0, scaleX: 2, scaleY: 2, x: 5, y: 6 } }, + type: 'updateCanvasLayer', + }); + expect(actions.some((a) => a.type === 'convertCanvasLayer' || a.type === 'updateCanvasLayerSource')).toBe(false); + expect(engine.stores.transformSession.get()).toBeNull(); + + dispatch.mockClear(); + engine.undo(); + const undo = dispatch.mock.calls + .map((call) => call[0] as WorkbenchAction) + .find((a) => a.type === 'updateCanvasLayer'); + expect(undo).toEqual({ + id: 't', + patch: { transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 } }, + type: 'updateCanvasLayer', + }); + + engine.dispose(); + }); + + it('handleEscapePriority cancels an open transform session (chain: transform → deselect)', () => { + const { store } = createReactiveStore(selectedImageDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + + engine.setTool('transform'); + engine.updateTransformSession({ rotation: 0, scaleX: 2, scaleY: 2, x: 5, y: 6 }); + expect(engine.stores.transformSession.get()).not.toBeNull(); + + engine.handleEscapePriority({ gestureWasActive: false }); + expect(engine.stores.transformSession.get()).toBeNull(); + + engine.dispose(); + }); + + it('an unchanged transform Apply cancels with no dispatch', () => { + const { store } = createReactiveStore(selectedImageDoc()); + const dispatch = store.dispatch as Mock; + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + + engine.setTool('transform'); + dispatch.mockClear(); + engine.applyTransform(); + + expect(dispatch.mock.calls.filter((c) => (c[0] as WorkbenchAction).type === 'updateCanvasLayer')).toHaveLength(0); + expect(engine.stores.transformSession.get()).toBeNull(); + expect(engine.stores.canUndo.get()).toBe(false); + + engine.dispose(); + }); + + it('Cancel drops the session with no dispatch', () => { + const { store } = createReactiveStore(selectedImageDoc()); + const dispatch = store.dispatch as Mock; + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + + engine.setTool('transform'); + engine.updateTransformSession({ rotation: 0, scaleX: 3, scaleY: 3, x: 0, y: 0 }); + dispatch.mockClear(); + engine.cancelTransform(); + + expect(dispatch).not.toHaveBeenCalled(); + expect(engine.stores.transformSession.get()).toBeNull(); + + engine.dispose(); + }); + + it('paint layer Apply bakes pixels through the matrix, resets the transform, and composes one undo entry', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + // A paint layer that already carries a non-identity transform, so undo restores + // a transform distinct from the post-bake identity. Content-sized: it needs a + // persisted bitmap so its cache is non-empty (an empty paint layer is not + // transformable). + const movedPaint = paintDoc(); + const movedLayer = movedPaint.layers[0] as CanvasRasterLayerContractV2; + movedLayer.source = { + bitmap: { height: 50, imageName: 'paint1-bmp', width: 50 }, + offset: { x: 0, y: 0 }, + type: 'paint', + }; + movedLayer.transform = { rotation: 0, scaleX: 1, scaleY: 1, x: 10, y: 20 }; + const { store } = createReactiveStore(movedPaint); + const dispatch = store.dispatch as Mock; + const bitmapStore = createSpyBitmapStore(); + const base = createTestStubRasterBackend(); + const surfaces: StubRasterSurface[] = []; + const backend = { + ...base, + createSurface: (w: number, h: number): StubRasterSurface => { + const surface = base.createSurface(w, h); + surfaces.push(surface); + return surface; + }, + }; + const engine = createCanvasEngine({ + backend, + bitmapStore, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.attach(screen.element, overlay.element); + raf.flush(); // build the paint layer cache + + const surfacesBeforeApply = surfaces.length; + const dirtyBefore = bitmapStore.markLayerDirty.mock.calls.length; + + engine.setTool('transform'); + engine.updateTransformSession({ rotation: 0, scaleX: 2, scaleY: 2, x: 10, y: 20 }); + dispatch.mockClear(); + engine.applyTransform(); + + // A fresh CONTENT-sized surface (the transformed content bounds, 100×100 here) + // was allocated and drawn through the bake matrix. The 50×50 cache at scale 2 + + // offset (10,20) bakes to bounds {10,20,100,100}, so the surface is 100×100 and + // the bake transform's translation is shifted by the baked origin to (0,0). + const baked = surfaces[surfacesBeforeApply]; + expect(baked).toBeDefined(); + expect(baked!.width).toBe(100); + expect(baked!.height).toBe(100); + const setTransforms = baked!.callLog.filter((entry) => entry.op === 'setTransform'); + // The non-identity setTransform carries the bake matrix scale (a=2, d=2), with + // its translation shifted into baked-local space (e=0, f=0). + expect( + setTransforms.some( + (entry) => entry.args[0] === 2 && entry.args[3] === 2 && entry.args[4] === 0 && entry.args[5] === 0 + ) + ).toBe(true); + expect(baked!.callLog.some((entry) => entry.op === 'drawImage')).toBe(true); + // Determinism: the bake always smooths (a doc-space resample, unlike the + // live composite which varies smoothing with zoom) rather than leaving it + // at whatever the fresh surface's context happens to default to. + expect( + baked!.callLog.some( + (entry) => entry.op === 'set' && entry.args[0] === 'imageSmoothingEnabled' && entry.args[1] === true + ) + ).toBe(true); + + // The reducer transform was reset to identity (pixel-free structural change). + const resetDispatch = dispatch.mock.calls + .map((call) => call[0] as WorkbenchAction) + .find((action) => action.type === 'updateCanvasLayer'); + expect(resetDispatch).toEqual({ + id: 'paint1', + patch: { transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 } }, + type: 'updateCanvasLayer', + }); + + // Baked pixels persist through the normal dirty path; session cleared; undoable. + expect(bitmapStore.markLayerDirty.mock.calls.length).toBeGreaterThan(dirtyBefore); + expect(engine.stores.transformSession.get()).toBeNull(); + expect(engine.stores.canUndo.get()).toBe(true); + + // Undo restores BOTH the old transform and the old pixels in one step. + dispatch.mockClear(); + engine.undo(); + const undoDispatch = dispatch.mock.calls + .map((call) => call[0] as WorkbenchAction) + .find((action) => action.type === 'updateCanvasLayer'); + expect(undoDispatch).toEqual({ + id: 'paint1', + patch: { transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 10, y: 20 } }, + type: 'updateCanvasLayer', + }); + expect(engine.stores.canRedo.get()).toBe(true); + + engine.dispose(); + }); + + it('tears down the transform session on a document replace', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const { setDocument, store } = createReactiveStore(selectedImageDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.attach(screen.element, overlay.element); + raf.flush(); + + engine.setTool('transform'); + expect(engine.stores.transformSession.get()).not.toBeNull(); + + // A dims change is a wholesale document replacement. + setDocument({ ...selectedImageDoc(), height: 200, width: 200 }, 1); + + expect(engine.stores.transformSession.get()).toBeNull(); + + engine.dispose(); + }); + + it('cancels the session (and its preview override) when its layer is deleted mid-session', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const doc = selectedImageDoc(); + const { setDocument, store } = createReactiveStore(doc); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.attach(screen.element, overlay.element); + raf.flush(); + + engine.setTool('transform'); + engine.updateTransformSession({ rotation: 0, scaleX: 2, scaleY: 2, x: 5, y: 5 }); + expect(engine.stores.transformSession.get()).not.toBeNull(); + + // Delete the session's layer via an ordinary layer-array edit (same dims, + // same revision) — NOT a wholesale replace, so this exercises the + // `onLayersChanged` teardown rather than `onDocumentReplaced`'s. + setDocument({ ...doc, layers: [] }); + + expect(engine.stores.transformSession.get()).toBeNull(); + + engine.dispose(); + }); + + // ---- temp-tool switch (space/alt hold) must not discard the session ---- + // + // The pointer pipeline flags a modifier-hold switch (and its matching + // restore) with `{ temporary: true }` on `setTool` (see + // `pointerPipeline.test.ts`, "temporary modifier tools"). These tests drive + // that same seam directly against the engine: the public `CanvasEngine` + // type narrows `setTool` to one argument, but the runtime function accepts + // the pipeline's second (`opts`) argument, so the cast below exercises the + // exact call the pipeline makes on a real space/alt hold and release. + describe('temp-tool switch (space/alt hold)', () => { + it('preserves the session and its numeric edits, resuming after the hold ends', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const { store } = createReactiveStore(selectedImageDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.attach(screen.element, overlay.element); + raf.flush(); + + engine.setTool('transform'); + // A numeric edit, as the options bar would drive. + engine.updateTransformSession({ rotation: 0, scaleX: 2, scaleY: 1, x: 5, y: 0 }); + const edited = { rotation: 0, scaleX: 2, scaleY: 1, x: 5, y: 0 }; + expect(engine.stores.transformSession.get()?.transform).toEqual(edited); + + const setTool = engine.setTool as (id: ToolId, opts?: { temporary?: boolean }) => void; + + setTool('view', { temporary: true }); // space down + expect(engine.stores.activeTool.get()).toBe('view'); + // The session — and the numeric edit — survive the hold. + expect(engine.stores.transformSession.get()?.transform).toEqual(edited); + + setTool('transform', { temporary: true }); // space up: resume + expect(engine.stores.activeTool.get()).toBe('transform'); + // Resuming does not reopen the session from the layer's committed + // transform, discarding the edit. + expect(engine.stores.transformSession.get()?.transform).toEqual(edited); + + engine.dispose(); + }); + + it('a REAL tool switch (not temporary) still cancels the session', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const { store } = createReactiveStore(selectedImageDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.attach(screen.element, overlay.element); + raf.flush(); + + engine.setTool('transform'); + expect(engine.stores.transformSession.get()).not.toBeNull(); + + engine.setTool('view'); // a real switch — no `{ temporary: true }` + + expect(engine.stores.transformSession.get()).toBeNull(); + + engine.dispose(); + }); + + it('cancels cleanly when the session layer is deleted mid-hold, instead of resurrecting it on resume', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + + const doc = selectedImageDoc(); + const { setDocument, store } = createReactiveStore(doc); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.attach(screen.element, overlay.element); + raf.flush(); + + engine.setTool('transform'); + expect(engine.stores.transformSession.get()).not.toBeNull(); + + const setTool = engine.setTool as (id: ToolId, opts?: { temporary?: boolean }) => void; + setTool('view', { temporary: true }); // space down + + // The session's layer is deleted while temp-switched away (e.g. via the + // layers panel) — the layer-change teardown cancels the session + // immediately, regardless of which tool is active. + setDocument({ ...doc, layers: [] }); + expect(engine.stores.transformSession.get()).toBeNull(); + + setTool('transform', { temporary: true }); // space up: resume + // Resuming must not resurrect a session against the now layer-less + // document. + expect(engine.stores.transformSession.get()).toBeNull(); + + engine.dispose(); + }); + }); +}); + +// ---- Selection subsystem ------------------------------------------------ + +const lockedPaintDoc = (): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers: [ + { + blendMode: 'normal', + id: 'paint1', + isEnabled: true, + isLocked: true, + name: 'paint1', + opacity: 1, + source: { bitmap: null, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }, + ], + selectedLayerId: 'paint1', + version: 2, + width: 100, +}); + +describe('engine selection: select all / deselect / invert + hasSelection store', () => { + it('selectAll sets hasSelection; deselect clears it', () => { + vi.stubGlobal('Path2D', class FakePath2D {}); + const { store } = createFakeStore(paintDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + expect(engine.stores.hasSelection.get()).toBe(false); + engine.selectAll(); + expect(engine.stores.hasSelection.get()).toBe(true); + engine.deselect(); + expect(engine.stores.hasSelection.get()).toBe(false); + engine.dispose(); + }); + + it('invertSelection of an empty selection selects everything', () => { + vi.stubGlobal('Path2D', class FakePath2D {}); + const { store } = createFakeStore(paintDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + engine.invertSelection(); + expect(engine.stores.hasSelection.get()).toBe(true); + engine.dispose(); + }); +}); + +describe('engine selection: fill / erase', () => { + const makeEngine = (doc: CanvasDocumentContractV2) => { + vi.stubGlobal('Path2D', class FakePath2D {}); + const { store } = createFakeStore(doc); + const bitmapStore = createSpyBitmapStore(); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore, + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + return { bitmapStore, engine }; + }; + + it('fillSelection on the selected paint layer records one undoable edit + persists', () => { + const { bitmapStore, engine } = makeEngine(paintDoc()); + engine.selectAll(); + expect(engine.stores.canUndo.get()).toBe(false); + engine.fillSelection(); + expect(engine.stores.canUndo.get()).toBe(true); + expect(bitmapStore.markLayerDirty).toHaveBeenCalledWith('paint1'); + // Undo restores (canRedo becomes available). + engine.undo(); + expect(engine.stores.canRedo.get()).toBe(true); + engine.dispose(); + }); + + it('eraseSelection records one undoable edit (on existing content)', () => { + const { engine } = makeEngine(paintDoc()); + engine.selectAll(); + // Content-sized: erase only affects EXISTING pixels, so give the layer content + // first (a fill grows the empty paint cache to the selection). Then erase records + // its own edit within that extent. + engine.fillSelection(); + engine.eraseSelection(); + expect(engine.stores.canUndo.get()).toBe(true); + engine.dispose(); + }); + + it('eraseSelection is a no-op on an EMPTY paint layer (no pixels to erase)', () => { + const { engine } = makeEngine(paintDoc()); + engine.selectAll(); + engine.eraseSelection(); + expect(engine.stores.canUndo.get()).toBe(false); + engine.dispose(); + }); + + it('fillSelection is a no-op with no selection', () => { + const { engine } = makeEngine(paintDoc()); + engine.fillSelection(); + expect(engine.stores.canUndo.get()).toBe(false); + engine.dispose(); + }); + + it('fillSelection is a no-op on an image-source layer (deferred to rasterize task)', () => { + const { engine } = makeEngine(imageSelectedDoc()); + engine.selectAll(); + engine.fillSelection(); + expect(engine.stores.canUndo.get()).toBe(false); + engine.dispose(); + }); + + it('fillSelection is a no-op on a transparency-locked EMPTY layer (source-atop clamps to existing pixels)', () => { + const doc = paintDoc(); + const target = doc.layers[0]; + if (target?.type === 'raster') { + target.isTransparencyLocked = true; + } + const { engine } = makeEngine(doc); + engine.selectAll(); + engine.fillSelection(); + // The layer has no existing pixels, so a transparency-locked (source-atop) fill + // lands nothing — no undoable edit. + expect(engine.stores.canUndo.get()).toBe(false); + engine.dispose(); + }); + + it('fillSelection is a no-op on a locked paint layer', () => { + const { engine } = makeEngine(lockedPaintDoc()); + engine.selectAll(); + engine.fillSelection(); + expect(engine.stores.canUndo.get()).toBe(false); + engine.dispose(); + }); + + it('clearCaches flushes pending bitmap uploads before invalidating (unflushed strokes survive)', async () => { + const { bitmapStore, engine } = makeEngine(paintDoc()); + // The debug "Clear caches" action must persist any in-flight (debounced) paint + // upload before it drops the layer caches — otherwise an unflushed stroke is lost. + await engine.clearCaches(); + expect(bitmapStore.flushPendingUploads).toHaveBeenCalledTimes(1); + engine.dispose(); + }); +}); + +describe('engine selection: marching ants animation + overlay-only', () => { + it('a selection redraws the overlay with ants and never composites the document', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal('Path2D', class FakePath2D {}); + + const { store } = createReactiveStore(paintDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.attach(screen.element, overlay.element); + raf.flush(); + await flushMicrotasks(); + raf.flush(); + + screen.surface.callLog.length = 0; + overlay.surface.callLog.length = 0; + + engine.selectAll(); + raf.flush(); + + // Overlay redrawn (ants stroked); the screen composite never ran. + const compositeOps = screen.surface.callLog.filter( + (e) => e.op === 'drawImage' || e.op === 'clearRect' || e.op === 'fillRect' + ); + expect(compositeOps).toHaveLength(0); + expect(overlay.surface.callLog.some((e) => e.op === 'stroke')).toBe(true); + + engine.dispose(); + }); + + it('stops the ants loop (no pending frames) on deselect and on detach', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal('Path2D', class FakePath2D {}); + + const { store } = createReactiveStore(paintDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createFakeCanvas(); + engine.attach(screen.element, overlay.element); + raf.flush(); + + // With a selection the loop keeps rescheduling itself frame after frame. + engine.selectAll(); + raf.flush(); + raf.flush(); + expect(raf.pendingCount()).toBeGreaterThan(0); + + // Deselect stops it: after draining, nothing reschedules. + engine.deselect(); + raf.flush(); + expect(raf.pendingCount()).toBe(0); + + // Re-select, then detach: the loop must not leak a pending frame. + engine.selectAll(); + raf.flush(); + expect(raf.pendingCount()).toBeGreaterThan(0); + engine.detach(); + expect(raf.pendingCount()).toBe(0); + + engine.dispose(); + }); +}); + +describe('engine selection: lasso commit through the pipeline', () => { + it('a lasso drag commits a selection (hasSelection true) without dispatching', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal('Path2D', class FakePath2D {}); + + const { store } = createReactiveStore(paintDoc()); + const dispatch = store.dispatch as Mock; + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const screen = createFakeCanvas(); + const overlay = createInputCanvas(); + engine.attach(screen.element, overlay.element); + engine.setTool('lasso'); + raf.flush(); + dispatch.mockClear(); + + overlay.fire('pointerdown', pointerAt(10, 10)); + overlay.fire('pointermove', pointerAt(40, 10)); + overlay.fire('pointermove', pointerAt(40, 40)); + overlay.fire('pointerup', pointerAt(10, 40, { buttons: 0 })); + + expect(engine.stores.hasSelection.get()).toBe(true); + // Selection is transient: no reducer traffic from the lasso gesture. + expect(dispatch).not.toHaveBeenCalled(); + + engine.dispose(); + }); +}); + +describe('engine selection: document replace clears the selection', () => { + it('a wholesale document swap deselects', () => { + vi.stubGlobal('Path2D', class FakePath2D {}); + const { setDocument, store } = createReactiveStore(paintDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + engine.selectAll(); + expect(engine.stores.hasSelection.get()).toBe(true); + // A new document revision replaces the mirror. + setDocument(paintDoc(), 1); + expect(engine.stores.hasSelection.get()).toBe(false); + engine.dispose(); + }); +}); + +// ---- text edit session: create/edit commit, cancel, teardown --------------- + +describe('text edit session', () => { + const textSource = (over: Partial> = {}) => ({ + align: 'left' as const, + color: '#112233', + content: 'hello', + fontFamily: 'Inter', + fontSize: 20, + fontWeight: 400, + lineHeight: 1.2, + type: 'text' as const, + ...over, + }); + + const textDoc = (over: Partial = {}): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers: [ + { + blendMode: 'normal', + id: 'txt1', + isEnabled: true, + isLocked: false, + name: 'Text 1', + opacity: 1, + source: textSource(), + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 5, y: 6 }, + type: 'raster', + }, + ], + selectedLayerId: 'txt1', + version: 2, + width: 100, + ...over, + }); + + const makeEngine = (doc: CanvasDocumentContractV2) => { + const { setDocument, store } = createReactiveStore(doc); + const dispatch = store.dispatch as Mock; + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const layerActions = () => dispatch.mock.calls.map((call) => call[0] as WorkbenchAction); + return { dispatch, engine, layerActions, setDocument }; + }; + + it('create-mode commit dispatches ONE addCanvasLayer with the typed content; undo removes it', () => { + const { dispatch, engine, layerActions } = makeEngine(paintDoc()); + engine.setTool('text'); + engine.openTextCreate({ x: 10, y: 20 }); + expect(engine.stores.textEditSession.get()?.mode).toBe('create'); + + dispatch.mockClear(); + engine.commitTextEdit('Typed here'); + + const adds = layerActions().filter((a) => a.type === 'addCanvasLayer'); + expect(adds).toHaveLength(1); + const forward = adds[0]; + if (forward?.type === 'addCanvasLayer' && forward.layer.type === 'raster' && forward.layer.source.type === 'text') { + expect(forward.layer.source.content).toBe('Typed here'); + expect(forward.layer.transform).toEqual({ rotation: 0, scaleX: 1, scaleY: 1, x: 10, y: 20 }); + } else { + throw new Error('expected an addCanvasLayer with a text source'); + } + expect(engine.stores.textEditSession.get()).toBeNull(); + + dispatch.mockClear(); + engine.undo(); + const removes = layerActions().filter((a) => a.type === 'removeCanvasLayers'); + expect(removes).toHaveLength(1); + engine.dispose(); + }); + + it('an empty create-mode commit dispatches nothing (cancel semantics)', () => { + const { dispatch, engine } = makeEngine(paintDoc()); + engine.setTool('text'); + engine.openTextCreate({ x: 0, y: 0 }); + dispatch.mockClear(); + engine.commitTextEdit(' '); + expect(dispatch).not.toHaveBeenCalled(); + expect(engine.stores.textEditSession.get()).toBeNull(); + engine.dispose(); + }); + + it('edit-mode commit dispatches ONE updateCanvasLayerSource with the exact inverse', () => { + const { dispatch, engine, layerActions } = makeEngine(textDoc()); + engine.setTool('text'); + engine.openTextEdit('txt1'); + expect(engine.stores.textEditSession.get()?.mode).toBe('edit'); + + dispatch.mockClear(); + engine.commitTextEdit('changed'); + + const edits = layerActions().filter((a) => a.type === 'updateCanvasLayerSource'); + expect(edits).toHaveLength(1); + const forward = edits[0]; + if (forward?.type === 'updateCanvasLayerSource' && forward.source.type === 'text') { + expect(forward.id).toBe('txt1'); + expect(forward.source.content).toBe('changed'); + } else { + throw new Error('expected an updateCanvasLayerSource text edit'); + } + + dispatch.mockClear(); + engine.undo(); + const inverse = layerActions().find((a) => a.type === 'updateCanvasLayerSource'); + if (inverse?.type === 'updateCanvasLayerSource' && inverse.source.type === 'text') { + expect(inverse.source.content).toBe('hello'); + } else { + throw new Error('expected the inverse to restore the original content'); + } + engine.dispose(); + }); + + it('folds a live style change into the single edit commit', () => { + const { dispatch, engine, layerActions } = makeEngine(textDoc()); + engine.setTool('text'); + engine.openTextEdit('txt1'); + engine.updateTextEditStyle({ color: '#ff0000', fontSize: 40 }); + + dispatch.mockClear(); + engine.commitTextEdit('hello'); + + const edits = layerActions().filter((a) => a.type === 'updateCanvasLayerSource'); + expect(edits).toHaveLength(1); + const forward = edits[0]; + if (forward?.type === 'updateCanvasLayerSource' && forward.source.type === 'text') { + expect(forward.source.color).toBe('#ff0000'); + expect(forward.source.fontSize).toBe(40); + expect(forward.source.content).toBe('hello'); + } else { + throw new Error('expected the folded style change'); + } + engine.dispose(); + }); + + it('an unchanged edit-mode commit dispatches nothing', () => { + const { dispatch, engine } = makeEngine(textDoc()); + engine.setTool('text'); + engine.openTextEdit('txt1'); + dispatch.mockClear(); + engine.commitTextEdit('hello'); + expect(dispatch).not.toHaveBeenCalled(); + expect(engine.stores.textEditSession.get()).toBeNull(); + engine.dispose(); + }); + + it('cancel drops the session with no dispatch', () => { + const { dispatch, engine } = makeEngine(textDoc()); + engine.setTool('text'); + engine.openTextEdit('txt1'); + dispatch.mockClear(); + engine.cancelTextEdit(); + expect(dispatch).not.toHaveBeenCalled(); + expect(engine.stores.textEditSession.get()).toBeNull(); + engine.dispose(); + }); + + it('does not open an edit session on a locked text layer', () => { + const { engine } = makeEngine( + textDoc({ + layers: [ + { + blendMode: 'normal', + id: 'txt1', + isEnabled: true, + isLocked: true, + name: 'Text 1', + opacity: 1, + source: textSource(), + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }, + ], + }) + ); + engine.setTool('text'); + engine.openTextEdit('txt1'); + expect(engine.stores.textEditSession.get()).toBeNull(); + engine.dispose(); + }); + + it('a temporary tool switch (space-hold) preserves the open session', () => { + const { engine } = makeEngine(textDoc()); + engine.setTool('text'); + engine.openTextEdit('txt1'); + expect(engine.stores.textEditSession.get()).not.toBeNull(); + + // Space-hold switches to the view tool temporarily; the session must survive. + (engine.setTool as (id: ToolId, opts?: { temporary?: boolean }) => void)('view', { temporary: true }); + expect(engine.stores.textEditSession.get()).not.toBeNull(); + engine.dispose(); + }); + + it('a real tool switch away from the text tool tears the session down', () => { + const { engine } = makeEngine(textDoc()); + engine.setTool('text'); + engine.openTextEdit('txt1'); + expect(engine.stores.textEditSession.get()).not.toBeNull(); + + engine.setTool('brush'); + expect(engine.stores.textEditSession.get()).toBeNull(); + engine.dispose(); + }); + + it('drops the session on a wholesale document replace', () => { + vi.stubGlobal('Path2D', class FakePath2D {}); + const { engine, setDocument } = makeEngine(textDoc()); + engine.setTool('text'); + engine.openTextEdit('txt1'); + expect(engine.stores.textEditSession.get()).not.toBeNull(); + setDocument(paintDoc(), 1); + expect(engine.stores.textEditSession.get()).toBeNull(); + engine.dispose(); + }); + + it('drops the session on dispose', () => { + const { engine } = makeEngine(paintDoc()); + engine.setTool('text'); + engine.openTextCreate({ x: 0, y: 0 }); + expect(engine.stores.textEditSession.get()).not.toBeNull(); + engine.dispose(); + expect(engine.stores.textEditSession.get()).toBeNull(); + }); + + // ---- click-elsewhere-to-commit (pointerdown / Escape) ---- + // + // Regression: `commitTextEdit` only fired on the portal's blur, but the pointer + // pipeline `preventDefault`s a canvas pointerdown (suppressing that blur), and + // the mid-gesture guard swallowed any blur that did land. The commit now runs + // engine-side on pointerdown (before a gesture starts), reading the live portal + // content via a registered reader; Escape cancels a defocused session via the + // engine's escape ladder. These drive that engine-side logic directly. + + it('commitOpenTextSession reads the registered content reader and commits the create', () => { + const { engine, layerActions } = makeEngine(paintDoc()); + engine.setTool('text'); + engine.openTextCreate({ x: 10, y: 20 }); + engine.setTextEditContentReader(() => 'live typed text'); + + expect(engine.commitOpenTextSession()).toBe(true); + + const adds = layerActions().filter((a) => a.type === 'addCanvasLayer'); + expect(adds).toHaveLength(1); + const add = adds[0]; + if (add?.type === 'addCanvasLayer' && add.layer.type === 'raster' && add.layer.source.type === 'text') { + expect(add.layer.source.content).toBe('live typed text'); + } else { + throw new Error('expected an addCanvasLayer with the live reader content'); + } + expect(engine.stores.textEditSession.get()).toBeNull(); + engine.dispose(); + }); + + it('commitOpenTextSession returns false when no session is open', () => { + const { dispatch, engine } = makeEngine(paintDoc()); + expect(engine.commitOpenTextSession()).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + engine.dispose(); + }); + + it('commitOpenTextSession falls back to the session content when no reader is registered', () => { + const { dispatch, engine } = makeEngine(textDoc()); + engine.setTool('text'); + engine.openTextEdit('txt1'); // session content = 'hello' + dispatch.mockClear(); + + expect(engine.commitOpenTextSession()).toBe(true); + // No reader → uses the session's own content ('hello') → unchanged edit → no dispatch. + expect(dispatch).not.toHaveBeenCalled(); + expect(engine.stores.textEditSession.get()).toBeNull(); + engine.dispose(); + }); + + it('a second commit after the session closed is a no-op (one commit per close)', () => { + const { dispatch, engine, layerActions } = makeEngine(paintDoc()); + engine.setTool('text'); + engine.openTextCreate({ x: 0, y: 0 }); + engine.setTextEditContentReader(() => 'once'); + expect(engine.commitOpenTextSession()).toBe(true); + dispatch.mockClear(); + // The portal's onBlur would re-fire commitTextEdit after the pointerdown commit; + // the session is already null, so it dispatches nothing. + engine.commitTextEdit('once'); + expect(engine.commitOpenTextSession()).toBe(false); + expect(layerActions().filter((a) => a.type === 'addCanvasLayer')).toHaveLength(0); + engine.dispose(); + }); + + it('handleEscapePriority cancels a defocused-but-open text session (no dispatch)', () => { + const { dispatch, engine } = makeEngine(textDoc()); + engine.setTool('text'); + engine.openTextEdit('txt1'); + dispatch.mockClear(); + + engine.handleEscapePriority({ gestureWasActive: false }); + expect(engine.stores.textEditSession.get()).toBeNull(); + expect(dispatch).not.toHaveBeenCalled(); + engine.dispose(); + }); +}); + +describe('contextMenuLayerIdAt (canvas right-click target)', () => { + const controlLayer = (id: string): CanvasLayerContract => ({ + adapter: { beginEndStepPct: [0, 1], controlMode: 'balanced', kind: 'controlnet', model: null, weight: 1 }, + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { image: { height: 10, imageName: id, width: 10 }, type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'control', + withTransparencyEffect: false, + }); + + // A raster at index 0 with a control layer above it, both covering [0,10]². At + // the default viewport (zoom 1, no pan) a screen point maps 1:1 to document space. + const stackedDoc = (): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers: [rasterLayer('raster'), controlLayer('control')], + selectedLayerId: null, + version: 2, + width: 100, + }); + + const makeEngine = (doc: CanvasDocumentContractV2) => { + const { store } = createFakeStore(doc); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + return engine; + }; + + it('returns the composite-top layer at the point (control over raster, batch finding N1)', () => { + const engine = makeEngine(stackedDoc()); + // The raster is earlier in the array but the control composites above it. + expect(engine.contextMenuLayerIdAt({ x: 5, y: 5 })).toBe('control'); + engine.dispose(); + }); + + it('returns null on empty space', () => { + const engine = makeEngine(stackedDoc()); + expect(engine.contextMenuLayerIdAt({ x: 60, y: 60 })).toBeNull(); + engine.dispose(); + }); + + it('returns null while a text-edit session is open (never opens over an in-progress edit)', () => { + const engine = makeEngine(stackedDoc()); + engine.setTool('text'); + engine.openTextCreate({ x: 5, y: 5 }); + expect(engine.stores.textEditSession.get()).not.toBeNull(); + expect(engine.contextMenuLayerIdAt({ x: 5, y: 5 })).toBeNull(); + engine.dispose(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.ts new file mode 100644 index 00000000000..45f6c365f08 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.ts @@ -0,0 +1,3127 @@ +/** + * `CanvasEngine`: the per-project imperative heart of the canvas. + * + * One engine instance exists per project (managed by `engineRegistry.ts`) and + * shared by the canvas and layers widgets. It owns interaction, pixels, and + * rendering; the reducer owns the document. The document flows in one way + * through the {@link createDocumentMirror | document mirror}; the engine never + * dispatches from pointer input except for the single gesture-start + * `addCanvasLayer` a paint tool emits when auto-creating a layer. + * + * Responsibilities: + * - Hold the {@link Viewport} (pan/zoom) and the transient {@link EngineStores} + * React subscribes to. + * - `attach`/`detach` bind two stacked canvases (composited document + overlay) + * as render targets, wire pointer/wheel/key listeners, and drive the + * scheduler; `resize` sizes the backing stores to `css × min(dpr, 2)`. + * - Route normalized pointer/wheel input to the active {@link Tool} via the + * pointer pipeline and wheel handler (middle-mouse pan, space/alt temp tools, + * plain-wheel zoom, ctrl+wheel brush-size step), and relay committed strokes + * to `onStrokeCommitted` subscribers. + * - Orchestrate rasterization: ensure a raster cache per visible layer, compose + * the document, and draw the overlay each scheduled frame. + * + * DOM APIs are confined to `attach`/`detach`/`resize` and the DOM raster + * backend, so importing this module stays node-safe. Zero React imports. + */ + +import type { ExecuteCompositePlanDeps } from '@workbench/canvas-engine/export/compositeForGeneration'; +import type { PsdExportLayerInput } from '@workbench/canvas-engine/export/psdExport'; +import type { CreatePath2D } from '@workbench/canvas-engine/freehand'; +import type { FontLoadApi } from '@workbench/canvas-engine/render/fontLoader'; +import type { OverlayCursor } from '@workbench/canvas-engine/render/overlayRenderer'; +import type { RenderScheduler } from '@workbench/canvas-engine/render/scheduler'; +import type { Rect, RenderFlags, ToolId, Vec2 } from '@workbench/canvas-engine/types'; +import type { + CanvasImageRef, + CanvasDocumentContractV2, + CanvasLayerContract, + CanvasLayerSourceContract, + WorkbenchState, +} from '@workbench/types'; +import type { WorkbenchAction } from '@workbench/workbenchState'; + +import { createEngineStores, type EngineStores, type TextToolOptions } from '@workbench/canvas-engine/engineStores'; +import { executePsdExport, planPsdExport } from '@workbench/canvas-engine/export/psdExport'; +import { createPointerPipeline, type PointerPipeline } from '@workbench/canvas-engine/input/pointerPipeline'; +import { createWheelHandler } from '@workbench/canvas-engine/input/wheel'; +import { invert as invertMatrix } from '@workbench/canvas-engine/math/mat2d'; +import { intersect, isEmpty, roundOut, transformBounds, union } from '@workbench/canvas-engine/math/rect'; +import { createAdjustedSurfaceCache } from '@workbench/canvas-engine/render/adjustedSurfaceCache'; +import { + blendToComposite, + compositeDocument, + createCheckerboardTile, + shouldSmoothAtZoom, +} from '@workbench/canvas-engine/render/compositor'; +import { createFontLoader, domFontLoadApi } from '@workbench/canvas-engine/render/fontLoader'; +import { + createLayerCacheStore, + type LayerCacheEntry, + type LayerCacheStore, +} from '@workbench/canvas-engine/render/layerCache'; +import { createMaskPatternTile } from '@workbench/canvas-engine/render/maskFill'; +import { renderOverlay } from '@workbench/canvas-engine/render/overlayRenderer'; +import { createDomRasterBackend, type RasterBackend, type RasterSurface } from '@workbench/canvas-engine/render/raster'; +import { rasterizeSource, type ImageResolver, type RasterizeDeps } from '@workbench/canvas-engine/render/rasterizers'; +import { textFontString, type TextSource } from '@workbench/canvas-engine/render/rasterizers/textRasterizer'; +import { createRenderScheduler } from '@workbench/canvas-engine/render/scheduler'; +import { fitThumbnailSize } from '@workbench/canvas-engine/render/thumbnail'; +import { ANTS_STEP_PX, createAntsAnimator, type AntsAnimator } from '@workbench/canvas-engine/selection/marchingAnts'; +import { eraseMaskedRegion, fillMaskedRegion } from '@workbench/canvas-engine/selection/selectionOps'; +import { createSelectionState, type SelectionState } from '@workbench/canvas-engine/selection/selectionState'; +import { createBboxTool } from '@workbench/canvas-engine/tools/bboxTool'; +import { createBrushTool } from '@workbench/canvas-engine/tools/brushTool'; +import { createColorPickerTool } from '@workbench/canvas-engine/tools/colorPickerTool'; +import { createEraserTool } from '@workbench/canvas-engine/tools/eraserTool'; +import { createGradientTool } from '@workbench/canvas-engine/tools/gradientTool'; +import { createLassoTool } from '@workbench/canvas-engine/tools/lassoTool'; +import { + hittableLayerRect, + hittableLayerSize, + layerOutlineCorners, + topLayerAt, +} from '@workbench/canvas-engine/tools/moveHitTest'; +import { createMoveTool } from '@workbench/canvas-engine/tools/moveTool'; +import { stepBrushSize } from '@workbench/canvas-engine/tools/paintConstants'; +import { createShapeTool } from '@workbench/canvas-engine/tools/shapeTool'; +import { createTextTool } from '@workbench/canvas-engine/tools/textTool'; +import { createTransformTool } from '@workbench/canvas-engine/tools/transformTool'; +import { + bakeMatrix, + type LayerTransform, + transformOverlayGeometry, +} from '@workbench/canvas-engine/transform/transformMath'; +import { createViewport, MAX_DPR, type Viewport } from '@workbench/canvas-engine/viewport'; + +import type { StrokeCommittedEvent, Tool, ToolContext } from './tools/tool'; + +import { uploadCanvasImage } from './backend/canvasImages'; +import { createBitmapStore, type BitmapStore } from './document/bitmapStore'; +import { createDocumentMirror, type DocumentMirror } from './document/documentMirror'; +import { mergeDownMatrix } from './document/mergeDown'; +import { planMergeVisibleRuns, planNextMergeVisibleStep } from './document/mergeVisible'; +import { + getSourceBounds, + getSourceContentRect, + isMaskLayer, + isMergeableRasterLayer, + isRenderableLayer, + renderableSourceOf, +} from './document/sources'; +import { createDocumentPatchEntry } from './history/documentPatch'; +import { createHistory, type History, type HistoryEntry } from './history/history'; +import { createImagePatchEntry, type ImagePatchApply } from './history/imagePatch'; +import { createViewTool } from './tools/viewTool'; + +/** + * The input to {@link CanvasEngine.setStagedPreview}: either a persisted image + * (decoded via the engine's `imageResolver`, sized to the decoded pixels — the + * final staged candidate) or an inline data-URL with explicit document-space + * dimensions (a live denoise-progress frame, scaled to fill those dims). + */ +export type StagedPreviewInput = { imageName: string } | { dataUrl: string; width: number; height: number }; + +/** + * Result of {@link CanvasEngine.mergeVisibleRasterLayers}: `'merged'` when the + * fold ran, `'not-ready'` when a participant's cache is still decoding/stale (the + * whole op was refused — surface feedback), `'nothing'` when there was nothing to + * merge. + */ +export type MergeVisibleResult = 'merged' | 'not-ready' | 'nothing'; + +/** + * Result of {@link CanvasEngine.exportRasterLayersToPsd}: `'exported'` on + * success, `'nothing'` when there are no raster layers with content, `'too-large'` + * when the union bounds exceed the PSD dimension cap, and `'not-ready'` when a + * participant's cache is still decoding (nothing exported — surface feedback). + */ +export type PsdExportResult = 'exported' | 'nothing' | 'too-large' | 'not-ready'; + +/** The minimal workbench store the engine depends on. */ +export interface EngineStore { + getState(): WorkbenchState; + subscribe(listener: () => void): () => void; + dispatch(action: WorkbenchAction): void; +} + +/** Options for {@link createCanvasEngine}. */ +export interface CanvasEngineOptions { + projectId: string; + store: EngineStore; + /** Raster surface/bitmap factory. Defaults to the DOM backend. */ + backend?: RasterBackend; + /** Resolves persisted image assets to blobs for decoding. */ + imageResolver: ImageResolver; + /** + * Overrides the paint-persistence store. Defaults to a real {@link createBitmapStore} + * wired to the layer cache and the upload backend. Tests inject a fake to + * observe dirty-marking / avoid network uploads. + */ + bitmapStore?: BitmapStore; + /** + * Overrides the web-font readiness api used to re-rasterize text layers once a + * pending font loads. Defaults to the browser's `document.fonts` (or a no-op + * in node). Tests inject a fake to drive the load without a real FontFaceSet. + */ + fonts?: FontLoadApi | null; +} + +/** The public engine handle. */ +export interface CanvasEngine { + readonly projectId: string; + /** The pan/zoom viewport. */ + getViewport(): Viewport; + /** The transient stores React subscribes to. */ + readonly stores: EngineStores; + /** Binds the two stacked canvases as render targets and starts the render loop. */ + attach(screenCanvas: HTMLCanvasElement, overlayCanvas: HTMLCanvasElement): void; + /** Unbinds the canvases, removes listeners, and pauses the render loop. */ + detach(): void; + /** Sizes the backing stores to `css × min(dpr, 2)` and updates the viewport. */ + resize(cssWidth: number, cssHeight: number, dpr: number): void; + /** Activates a tool (deactivating the previous one). */ + setTool(toolId: ToolId): void; + /** + * Steps the active brush/eraser diameter by one notch (`+1` grows, `-1` + * shrinks) — the same helper the ctrl+wheel path drives, so the `[`/`]` + * hotkeys and ctrl+wheel stay in lockstep. A no-op when the active tool is + * neither brush nor eraser. + */ + stepBrushSize(direction: 1 | -1): void; + /** + * Subscribes to completed brush/eraser strokes. Downstream tasks wire + * persistence (P2.2) and undo/redo history (P2.3) to this; the engine itself + * does not upload or record history. Returns an unsubscribe function. + */ + onStrokeCommitted(listener: (event: StrokeCommittedEvent) => void): () => void; + /** + * Flushes any pending paint-bitmap uploads immediately and resolves once they + * settle. A barrier for invoke/export/autosave: the persisted document is + * guaranteed to reference the latest painted pixels after this resolves. + */ + flushPendingUploads(): Promise; + /** + * The side-effecting dependencies `executeCompositePlan` needs, wired to this + * engine: its raster `backend`, a rasterize-or-throw `getLayerSurface`, and an + * intermediate-image `uploadImage`. The caller supplies the dedupe cache (it + * is per-engine, not per-invoke). `getLayerSurface` never returns a blank + * surface — a missing/unrasterizable layer throws, so generation can't + * silently omit a layer. + */ + getCompositeExecutorDeps(): Omit; + /** Centers and zooms to fit the document content. */ + fitToView(): void; + /** + * Sets the grid size (document px) the bbox tool snaps positions and sizes to. + * The engine can't know the active model, so React feeds this from generate + * settings (see `gridSizeForModelBase`); defaults to 8. + */ + setBboxGrid(size: number): void; + /** + * Reverts the most recent canvas edit via the engine-owned history (paint + * pixels or structural document patch). A no-op when there is nothing to undo. + * Canvas-scoped: it never touches project-level (reducer) undo. + */ + undo(): void; + /** Re-applies the most recently undone canvas edit. A no-op when there is nothing to redo. */ + redo(): void; + /** + * Draws a layer's cached pixels, scaled to fit a `maxSize` box (aspect ratio + * preserved, never upscaled), onto `target`, sizing `target`'s backing store + * to the fitted dimensions. Returns `false` (drawing nothing) when the layer + * has no live raster cache — the caller then falls back to a placeholder. The + * layers panel redraws when a layer's `stores.thumbnailVersion` bumps. + */ + drawLayerThumbnail(layerId: string, target: HTMLCanvasElement, maxSize: number): boolean; + /** + * Composites the layer identified by `upperLayerId` down INTO the layer + * directly below it, baking the upper layer's pixels (with its opacity/blend) into a new + * content-sized (union-extent) paint cache in the below layer's local space, then dispatches + * `mergeCanvasLayersDown` so the reducer collapses the two layers. The new + * pixels are persisted through the normal bitmap-store path. Returns `false` + * (a no-op) when there is no layer below, either layer is not a live raster + * cache, or the below transform is non-invertible. Not recorded on the + * engine's undo history (the inverse would need the discarded pixels). + */ + mergeLayerDown(upperLayerId: string): boolean; + /** + * Folds ALL visible mergeable raster layers together (the raster group-header + * "merge visible" action; legacy `mergeVisibleOfType`). Plans runs via the pure + * `planMergeVisibleRuns` — interleaved non-raster layers and hidden rasters do + * not block a merge (the compositor draws by group rank, so they never render + * between rasters); a rendering non-participant raster (visible locked / + * parametric) splits the fold into independent runs. Pre-flights the ENTIRE + * fold — every participant's cache must be ready and every consecutive pair's + * merge matrix computable — BEFORE the first pixel is touched, so the op never + * leaves a half-merged stack: returns `'not-ready'` (doing nothing) when any + * participant is still decoding/stale, `'nothing'` when there is nothing to + * merge (or mid-gesture), else performs the fold (reorder + `mergeLayerDown` + * per step) and returns `'merged'`. Not undoable, like `mergeLayerDown`. + */ + mergeVisibleRasterLayers(): MergeVisibleResult; + /** + * Exports ALL raster layers (with content) to a Photoshop `.psd` file and + * triggers a browser download. Read-only: no dispatches, no history entries, + * no document mutation. Hidden layers are exported with `hidden: true`; each + * layer's non-destructive adjustments are baked so the PSD matches the canvas. + * The `ag-psd` library is loaded lazily (its own chunk). Pre-flights readiness: + * returns `'not-ready'` (exporting nothing) when a participant's cache is still + * decoding, `'nothing'` when no raster layer has content, `'too-large'` when the + * union bounds exceed the PSD dimension cap, else `'exported'`. `fileName` is + * the download name (extension appended if absent). + */ + exportRasterLayersToPsd(fileName: string): Promise; + /** + * Rasterizes a parametric (shape/gradient/text) layer to a paint layer: bakes its + * current appearance into a content-sized paint bitmap (persisted through the + * normal dirty path) and converts the source via `convertCanvasLayer`. UNDOABLE + * via a composed entry whose inverse re-converts to the original parametric + * source (regenerated from params, so no pixel snapshot is kept). Returns + * `false` (a no-op) mid-gesture or when the layer is missing, not a raster + * layer, locked, or not a rasterizable parametric source. + */ + rasterizeLayer(layerId: string): boolean; + /** + * Performs a UI-initiated structural document edit under the engine-owned + * history: dispatches `forward` immediately and records a reversible entry + * whose undo dispatches `inverse` and whose redo re-dispatches `forward`. Use + * for panel ops (add/remove/duplicate/reorder/rename/opacity/blend/eye/lock) + * so they share the canvas undo stack with paint edits. + */ + commitStructural(label: string, forward: WorkbenchAction, inverse: WorkbenchAction): void; + /** + * Updates the active transform session's live transform (a numeric options-bar + * edit). Refreshes the preview; no dispatch until Apply. A no-op with no session. + */ + updateTransformSession(transform: LayerTransform): void; + /** + * Commits the active transform session as ONE undoable entry — a param edit for + * image layers, a pixel bake for paint layers — then clears the session. A no-op + * with no session or an unchanged transform. Bound to `enter` and the options + * bar's Apply button. + */ + applyTransform(): void; + /** + * Cancels the active transform session (drops the preview, no dispatch). Bound + * to `esc`, tool switch, and the options bar's Cancel button. + */ + cancelTransform(): void; + /** + * Opens a CREATE-mode text-editing session at `docPoint` (no layer yet), seeded + * from the current text options. The text tool calls this on an empty click. + */ + openTextCreate(docPoint: Vec2): void; + /** + * Opens an EDIT-mode text-editing session on an existing text layer (captures + * its committed source for the undo inverse). The text tool calls this on a hit. + */ + openTextEdit(layerId: string): void; + /** + * Updates the active text session's live style (font/size/weight/lineHeight/ + * align/color). No dispatch — the portal restyles and the change folds into the + * single commit. The text options bar drives this while a session is open. + */ + updateTextEditStyle(patch: Partial): void; + /** + * Commits the active text session as ONE undoable edit: create → `addCanvasLayer` + * with the final `content` (inverse removes); edit → `updateCanvasLayerSource` + * (exact inverse). A no-change edit or an empty create dispatches nothing (cancel + * semantics). `content` comes from the React contenteditable (the engine never + * sees per-keystroke input); `styleChanges` folds in any final style override. + * Bound to blur / click-outside / `mod+enter`. + */ + commitTextEdit(content: string, styleChanges?: Partial): void; + /** + * Registers (or clears with `null`) a getter the React text portal provides so + * the engine can read the live contenteditable content at commit time without + * per-keystroke traffic. Set in the portal's ref callback; cleared on unmount. + */ + setTextEditContentReader(reader: (() => string) | null): void; + /** + * Commits an open text-edit session using the live content from the registered + * reader (falling back to the session content when none is set). Returns `true` + * when a session was open and committed. The pointer pipeline calls this on a + * canvas pointerdown so clicking away commits the session ("click elsewhere to + * commit"); it runs before any gesture starts so the commit is never swallowed. + */ + commitOpenTextSession(): boolean; + /** + * Runs the Escape priority ladder (text session → transform session → deselect) + * after the pointer pipeline has cancelled any in-flight gesture. The pipeline + * wires this to the window keydown; exposed for tests. `gestureWasActive` + * suppresses deselect when a drag just consumed the Escape. + */ + handleEscapePriority(opts: { gestureWasActive: boolean }): void; + /** Cancels the active text-editing session (drops it, no dispatch). Bound to `esc`. */ + cancelTextEdit(): void; + /** Selects the whole document (transient selection; `mod+a`). */ + selectAll(): void; + /** Clears the selection (`mod+d` / Escape when idle). */ + deselect(): void; + /** Inverts the selection within the document (`mod+shift+i`). */ + invertSelection(): void; + /** + * Inverts a mask layer's alpha in place over `content ∪ bbox`, as one undoable + * edit persisted through the normal dirty path. A no-op (returns `false`) + * mid-gesture, or when the layer is missing, not a mask, or locked/hidden. + */ + invertMask(layerId: string): boolean; + /** + * Fills the selection region on the selected paint layer with the current brush + * color, as one undoable edit. A no-op mid-gesture, with no selection, or with a + * locked/hidden/non-paint selected layer. + */ + fillSelection(): void; + /** Erases the selection region on the selected paint layer, as one undoable edit. */ + eraseSelection(): void; + /** + * Nudges the selected layer by `(dx, dy)` document pixels as a single undoable + * structural edit (bounds/lock logic stays engine-side). A no-op with no + * selection or a locked/hidden selected layer. Rapid same-layer nudges coalesce + * into one history entry. Widget arrow-key hotkeys drive this. + */ + nudgeSelectedLayer(dx: number, dy: number): void; + /** + * Shows a staged generation preview drawn over the CURRENT bbox on the + * composited canvas (above committed layers), or clears it with `null`. + * + * The input is decoded to a surface off the main path — an `imageName` through + * the engine's `imageResolver` (the final candidate, drawn at the decoded + * pixel size), or a `dataUrl` with explicit dims (a live progress frame, + * scaled to those dims). Decoding is async and latest-call-wins: a stale + * decode resolving after a newer `setStagedPreview`/clear is discarded + * (version-guarded like the rasterize path), so rapid candidate cycling never + * flashes an older result. The preview's top-left tracks the bbox at render + * time, matching where `acceptStagedImage` places the accepted layer. + */ + setStagedPreview(input: StagedPreviewInput | null): void; + /** + * Shows a NON-DESTRUCTIVE control-filter preview for one layer: the decoded + * `imageName` is drawn stretched over that layer's content footprint instead of + * its committed pixels, until cleared with `null`. The document is untouched — + * "Apply" swaps the layer source (an undoable reducer edit); "Cancel" clears + * the preview. Decoding is async + per-layer version-guarded (a stale decode + * resolving after a newer set/clear for the same layer is discarded), so a + * newer preview or a layer deletion never flashes an old result. + */ + setFilterPreview(layerId: string, input: StagedPreviewInput | null): void; + /** + * The top-most VISIBLE layer id under a SCREEN-space point (relative to the + * input element's top-left), for a right-click context menu on the canvas + * surface — or `null` when nothing is hit OR an interaction is mid-flight (a + * live paint/drag gesture, or an open transform / text-edit session), in which + * case the menu must not open over an in-progress edit. Group-rank consistent + * and live-cache aware: it shares {@link topLayerAt} with the move tool, so the + * layer this returns is exactly the one the move tool would auto-select there. + */ + contextMenuLayerIdAt(screenPoint: Vec2): string | null; + /** The current mirrored document, or `null`. */ + getDocument(): CanvasDocumentContractV2 | null; + /** + * Debug action: drops every layer's raster/adjusted cache and the + * checkerboard/mask pattern tiles, then forces a full recomposite so everything + * re-rasterizes from source. Recovers from a suspected stale cache; not undoable + * (it touches no document state). Flushes pending bitmap uploads first so an + * un-flushed stroke inside the debounce window is not lost. + */ + clearCaches(): Promise; + /** Debug action: resets the engine-owned canvas undo/redo history. Not undoable. */ + clearHistory(): void; + /** Debug action: logs a summary of the engine's live state (document, tool, view, caches) to the console. */ + logDebugInfo(): void; + /** Tears everything down: listeners, subscriptions, caches, scheduler. */ + dispose(): void; +} + +/** + * Decodes a `data:` URL to a `Blob` without a DOM (`atob`/`Blob`/`Uint8Array` + * are all node-safe), so the staged-progress decode path runs in vitest through + * the injected raster backend just like the imageName path runs through the + * injected resolver. + */ +const dataUrlToBlob = (dataUrl: string): Blob => { + const commaIndex = dataUrl.indexOf(','); + const header = commaIndex >= 0 ? dataUrl.slice(0, commaIndex) : ''; + const data = commaIndex >= 0 ? dataUrl.slice(commaIndex + 1) : dataUrl; + const mime = /^data:([^;,]+)/i.exec(header)?.[1] ?? 'application/octet-stream'; + if (/;base64/i.test(header)) { + const binary = atob(data); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return new Blob([bytes], { type: mime }); + } + return new Blob([decodeURIComponent(data)], { type: mime }); +}; + +/** The image name a layer's source references, if any (raster/control source or mask bitmap). */ +const layerImageName = (layer: CanvasLayerContract): string | null => { + const source = renderableSourceOf(layer); + if (!source) { + return null; + } + if (source.type === 'image') { + return source.image.imageName; + } + if (source.type === 'paint') { + return source.bitmap?.imageName ?? null; + } + return null; +}; + +/** Mints a fresh layer id for engine-created paint layers. */ +const createLayerId = (): string => `layer-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + +const clearSurface = (surface: RasterSurface): void => { + surface.ctx.setTransform(1, 0, 0, 1, 0, 0); + surface.ctx.clearRect(0, 0, surface.width, surface.height); +}; + +/** Wraps an existing DOM canvas as a {@link RasterSurface} render target. */ +const wrapCanvasSurface = (canvas: HTMLCanvasElement): RasterSurface => { + const ctx = canvas.getContext('2d'); + if (!ctx) { + throw new Error('Failed to acquire a 2D context from the canvas element'); + } + return { + canvas, + ctx, + get height() { + return canvas.height; + }, + resize(width: number, height: number) { + canvas.width = width; + canvas.height = height; + }, + get width() { + return canvas.width; + }, + }; +}; + +/** Creates a per-project canvas engine. */ +export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngine => { + const { imageResolver, projectId, store } = opts; + const backend = opts.backend ?? createDomRasterBackend(); + + const viewport = createViewport(); + const layerCache: LayerCacheStore = createLayerCacheStore(backend); + const stores = createEngineStores(); + // Web-font readiness for text layers: re-rasterizes a text layer once its font + // resolves (a no-op in node / when `document.fonts` is absent). `undefined` + // opts.fonts falls back to the DOM api; an explicit `null` forces the no-op. + const fontLoader = createFontLoader(opts.fonts === undefined ? domFontLoadApi() : opts.fonts); + + const tools = new Map([ + ['view', createViewTool()], + ['brush', createBrushTool()], + ['eraser', createEraserTool()], + ['move', createMoveTool()], + ['transform', createTransformTool()], + ['bbox', createBboxTool()], + ['colorPicker', createColorPickerTool()], + ['lasso', createLassoTool()], + ['shape', createShapeTool()], + ['gradient', createGradientTool()], + ['text', createTextTool()], + ]); + let activeToolId: ToolId = 'view'; + + // Transient per-layer transform overrides driving the move/transform drag + // preview (compositor + overlay read at render time; the mirror stays untouched). + // The move tool sets only x/y; the transform tool sets the full transform. + const transformOverrides = new Map< + string, + { x: number; y: number; scaleX?: number; scaleY?: number; rotation?: number } + >(); + + /** Layer id → decoded image name, so removed layers can release their bitmaps. */ + const trackedImageNames = new Map(); + /** Layer ids with an in-flight rasterization, to avoid re-triggering each frame. */ + const inFlight = new Set(); + + let screenSurface: RasterSurface | null = null; + let overlaySurface: RasterSurface | null = null; + let inputEl: HTMLCanvasElement | null = null; + let disposed = false; + + // The brush/eraser cursor ring, drawn on the overlay (set by the active tool). + let overlayCursor: OverlayCursor | null = null; + + // The transparency checkerboard pattern tile, built once (lazily) through the + // raster backend and reused each frame (see `createCheckerboardTile`). It is + // rebuilt only when the fed checker colors change (theme/color-mode switch), + // signalled by nulling it in the `checkerColors` subscription below. + let checkerboardTile: RasterSurface | null = null; + const getCheckerboardTile = (): RasterSurface => { + checkerboardTile ??= createCheckerboardTile(backend, stores.checkerColors.get()); + return checkerboardTile; + }; + + // Cached mask fill pattern tiles, keyed by `style:color` (a solid style has no + // tile → cached as `null`). Built lazily through the backend seam like the + // checkerboard and reused each frame by the compositor's mask colorize path. + const maskPatternTiles = new Map(); + const getMaskPatternTile = (style: string, color: string): RasterSurface | null => { + const key = `${style}:${color}`; + if (!maskPatternTiles.has(key)) { + maskPatternTiles.set( + key, + createMaskPatternTile(backend, style as Parameters[1], color) + ); + } + return maskPatternTiles.get(key) ?? null; + }; + + // Memoized adjusted surfaces for raster layers carrying brightness/contrast/ + // saturation/curves — rebuilt only when a layer's cache version or its + // adjustments change (never per frame). Reused each frame by the compositor. + const adjustedSurfaceCache = createAdjustedSurfaceCache(backend); + const getAdjustedSurface = (layer: CanvasLayerContract, entry: LayerCacheEntry): RasterSurface | null => + layer.type === 'raster' ? adjustedSurfaceCache.get(layer.id, entry, layer.adjustments) : null; + + // The decoded staged-generation preview drawn over the current bbox, or null. + // `stagedPreviewToken` version-guards the async decode: every set/clear bumps + // it, and a decode whose token no longer matches is dropped so a slow decode + // can't clobber a newer selection (mirrors the rasterize race guard). + let stagedPreview: { surface: RasterSurface; width: number; height: number } | null = null; + let stagedPreviewToken = 0; + // Completed-stroke subscribers (persistence P2.2, history P2.3). + const strokeListeners = new Set<(event: StrokeCommittedEvent) => void>(); + + /** + * A layer's CURRENT document source (raster/control layers only), or `null` + * if the layer doesn't exist / isn't a source-bearing layer. Shared by the + * bitmap store's source-type flush guard and `onLayersChanged`'s self-echo + * check below — both need the same "what does this id currently point at" + * lookup. Reads `mirror` by closure; safe because neither caller invokes it + * before `mirror` is assigned further down. + */ + const getLayerSourceById = (layerId: string): CanvasLayerSourceContract | null => { + const doc = mirror.getDocument(); + const layer = doc?.layers.find((candidate) => candidate.id === layerId); + // Masks expose their alpha bitmap as a synthetic `paint` source so the bitmap + // store's source-type/redundant-dispatch guards and the mirror's self-echo + // check work uniformly across paint layers and masks. + return layer ? renderableSourceOf(layer) : null; + }; + + /** + * Applies a persisted bitmap ref + offset to a layer's document contract — the + * bitmap store's single swap-on-success dispatch. Raster/control layers take a + * `paint` source (`updateCanvasLayerSource`); mask layers take their `mask` + * bitmap + offset (`updateCanvasLayerConfig`, preserving the fill). The self-echo + * `lastApplied` name the store records covers both, so a mask flush round-tripping + * back through the mirror is skipped for re-rasterization exactly like a paint one. + */ + const dispatchLayerBitmap = (layerId: string, bitmap: CanvasImageRef, offset: { x: number; y: number }): void => { + const doc = mirror.getDocument(); + const layer = doc?.layers.find((candidate) => candidate.id === layerId); + if (!layer) { + return; + } + if (layer.type === 'raster' || layer.type === 'control') { + store.dispatch({ id: layerId, source: { bitmap, offset, type: 'paint' }, type: 'updateCanvasLayerSource' }); + } else if (layer.type === 'inpaint_mask' || layer.type === 'regional_guidance') { + store.dispatch({ + config: { layerType: layer.type, mask: { bitmap, offset } }, + id: layerId, + type: 'updateCanvasLayerConfig', + }); + } + }; + + // Paint persistence: debounced PNG encode → SHA-256 dedupe → upload → a single + // swap-on-success `updateCanvasLayerSource` (paint) / `updateCanvasLayerConfig` + // (mask). Wired to committed strokes below. + const bitmapStore: BitmapStore = + opts.bitmapStore ?? + createBitmapStore({ + dispatch: (action) => store.dispatch(action), + dispatchBitmap: (layerId, bitmap, offset) => dispatchLayerBitmap(layerId, bitmap, offset), + encodeSurface: (surface) => backend.encodeSurface(surface), + getLayerSource: getLayerSourceById, + getLayerSurface: (layerId) => { + const entry = layerCache.get(layerId); + // Content-sized: skip empty (zero-rect) caches — nothing to persist — and + // carry the cache's content-rect origin as the paint source offset. + if (!entry || entry.rect.width <= 0 || entry.rect.height <= 0) { + return null; + } + return { offset: { x: entry.rect.x, y: entry.rect.y }, surface: entry.surface }; + }, + uploadImage: (blob) => uploadCanvasImage(blob), + }); + + // Engine-owned canvas history (paint pixel patches + structural patches). + // Project-level undo deliberately no longer covers the canvas (Phase 0). + const history: History = createHistory(); + + // Nudge coalescing: a rapid burst of same-layer arrow nudges collapses into a + // single history entry whose inverse restores the burst's ORIGINAL position. + // Any other edit (stroke/structural/undo/redo/replace) ends the burst. + const NUDGE_COALESCE_MS = 500; + let nudgeBurst: { layerId: string; origin: { x: number; y: number }; expiresAt: number } | null = null; + const endNudgeBurst = (): void => { + nudgeBurst = null; + }; + + /** + * The pixel-write bridge shared by undo and redo: put the patch's pixels back + * into the layer's live cache surface, propagate the edit, and re-persist. + * + * ## Undo ↔ bitmap-store convergence (P2.2 reviewer note) + * + * Undo writes the OLD pixels into the cache and marks the layer dirty while an + * upload of the NEW pixels may still be in flight. The sequence converges: + * + * 1. The in-flight upload finishes and dispatches `updateCanvasLayerSource` + * with the NEW bitmap ref. Because the store recorded that name in + * `lastApplied` before dispatching, the engine's mirror sees it as a + * self-echo ({@link BitmapStore.isSelfEcho}) and does NOT re-rasterize — so + * the cache keeps the OLD pixels this undo just wrote (never clobbered). + * The contract now *transiently* points at the NEW ref. + * 2. This `markLayerDirty` schedules a follow-up flush. The bitmap store + * serializes per layer, so it runs after the in-flight upload settles. It + * encodes the cache (now the OLD pixels), hashes it, and the content-hash + * dedupe reuses the OLD pixels' already-uploaded image name — no re-upload. + * 3. That flush dispatches the OLD ref, moving `lastApplied` back and pointing + * the contract at the OLD pixels: cache and contract have re-converged. + */ + const applyImagePatch: ImagePatchApply = (layerId, rect, pixels) => { + if (!layerCache.get(layerId)) { + // The layer's cache is gone (removed/evicted); nothing to restore into. + return; + } + // The patch `rect` is in LAYER-LOCAL coordinates (stable across cache growth). + // Grow the cache to cover it before writing — an undo/redo whose region falls + // outside the current (possibly shrunk-since) extent must re-expand the cache + // rather than write out of bounds. `growToRect` preserves existing pixels. + const entry = layerCache.growToRect(layerId, rect); + // Match the paint hot path: write pixels straight into the live cache surface + // (no re-rasterize from source), translated to the surface's local origin, + // then bump version/thumbnail and recomposite. + entry.surface.ctx.putImageData(pixels, rect.x - entry.rect.x, rect.y - entry.rect.y); + notifyLayerPainted(layerId); + // Re-persist the restored pixels (converges the contract ref; see above). + bitmapStore.markLayerDirty(layerId); + }; + + /** + * REPLACES a layer's whole cache with a fresh content-sized surface holding + * `pixels` placed at `rect` (layer-local). Unlike {@link applyImagePatch} (which + * grows + overlays a dirty region into a persistent cache), this swaps the entire + * cache extent — used by the transform bake's undo/redo, where the pre- and + * post-bake states occupy DIFFERENT rects (an overlay would leave stale pixels + * outside the smaller rect). Shields the pixels from the async rasterize pass and + * re-persists through the normal dirty path. + */ + const restoreLayerCache = (layerId: string, rect: Rect, pixels: ImageData): void => { + layerCache.delete(layerId); + adjustedSurfaceCache.delete(layerId); + const entry = layerCache.getOrCreateRect(layerId, rect); + if (rect.width > 0 && rect.height > 0) { + entry.surface.ctx.putImageData(pixels, 0, 0); + } + entry.stale = false; + notifyLayerPainted(layerId); + bitmapStore.markLayerDirty(layerId); + }; + + const createPath2DImpl: CreatePath2D = (d) => new Path2D(d); + + /** Bumps a layer's cache version after a direct paint (pixels stay fresh) and recomposites. */ + const notifyLayerPainted = (layerId: string): void => { + const entry = layerCache.get(layerId); + if (entry) { + entry.version += 1; + stores.thumbnailVersion.set(layerId, entry.version); + } + scheduler.invalidate({ layers: [layerId] }); + }; + + /** + * A composed history entry for a stroke that auto-created its paint layer. + * Undo removes the created layer (its cache is dropped by the mirror, so no + * pixel restore is needed); redo re-adds the layer, recreates a blank cache, + * and re-applies the stroke's `after` pixels. + */ + const createComposedPaintEntry = ( + created: { layer: CanvasLayerContract; index: number }, + event: StrokeCommittedEvent, + label: string + ): HistoryEntry => { + const { afterImageData, dirtyRect, layerId } = event; + const rect: Rect = { height: dirtyRect.height, width: dirtyRect.width, x: dirtyRect.x, y: dirtyRect.y }; + const bytes = event.beforeImageData.data.byteLength + afterImageData.data.byteLength + 256; + return { + bytes, + label, + redo: () => { + store.dispatch({ index: created.index, layer: created.layer, type: 'addCanvasLayer' }); + // Re-create an EMPTY cache marked fresh so the async rasterize pass can't + // clobber the restored stroke; `applyImagePatch` grows it to the stroke's + // content bounds and writes the `after` pixels. + const entry = layerCache.getOrCreateRect(layerId, { height: 0, width: 0, x: 0, y: 0 }); + entry.stale = false; + applyImagePatch(layerId, rect, afterImageData); + }, + undo: () => { + store.dispatch({ ids: [layerId], type: 'removeCanvasLayers' }); + }, + }; + }; + + // ---- Selection (transient interaction state) + marching ants ------------ + // + // The selection lives on the engine, never the reducer, and is not undoable + // (legacy parity). The lasso tool commits paths through `commitSelection`; the + // mask clips paint strokes and drives fill/erase. Marching ants animate on the + // overlay only (never recomposite — Task-22 gate) while a selection exists and + // the engine is attached. + + let antsPhase = 0; + + const onSelectionChanged = (): void => { + stores.hasSelection.set(selection.hasSelection()); + updateAntsAnimation(); + scheduler.invalidate({ overlay: true }); + }; + + const selection: SelectionState = createSelectionState({ + backend, + createPath2D: createPath2DImpl, + getDocumentSize: () => { + const doc = mirror.getDocument(); + return doc ? { height: doc.height, width: doc.width } : null; + }, + onChange: () => onSelectionChanged(), + }); + + const antsAnimator: AntsAnimator = createAntsAnimator({ + cancelFrame: (handle) => globalThis.cancelAnimationFrame(handle), + now: () => + typeof performance !== 'undefined' && typeof performance.now === 'function' ? performance.now() : Date.now(), + onStep: () => { + antsPhase += ANTS_STEP_PX; + // Overlay-only: an ants tick never recomposites the document. + scheduler.invalidate({ overlay: true }); + }, + requestFrame: (callback) => globalThis.requestAnimationFrame(callback), + }); + + /** Runs the ants loop only while a selection exists AND render targets are bound. */ + function updateAntsAnimation(): void { + if (!disposed && selection.hasSelection() && inputEl) { + antsAnimator.start(); + } else { + antsAnimator.stop(); + } + } + + const toolContext: ToolContext = { + applyTransform: () => applyTransform(), + backend, + beginTransformSession: (layerId) => beginTransformSession(layerId), + cancelTextEdit: () => cancelTextEdit(), + cancelTransform: () => cancelTransform(), + commitSelection: (commit) => selection.commit(commit), + commitStructural: (label, forward, inverse) => commitStructural(label, forward, inverse), + createLayerId, + createPath2D: createPath2DImpl, + dispatch: (action) => store.dispatch(action), + emitStrokeCommitted: (event) => { + // A fresh stroke ends any open nudge-coalescing burst. + endNudgeBurst(); + // Persistence first: mark the layer dirty so a debounced upload fires even + // when no external subscriber is attached. + bitmapStore.markLayerDirty(event.layerId); + // Record the edit on the engine-owned history. Guarded against re-entrancy: + // an undo/redo replay routes pixels through `applyImagePatch`, not a fresh + // stroke, so this never fires during apply — the guard is belt-and-braces. + if (!history.isApplying()) { + const label = event.tool === 'eraser' ? 'Eraser stroke' : 'Brush stroke'; + history.push( + event.createdLayer + ? // The gesture auto-created its paint layer: compose layer creation + + // stroke into ONE entry, so an undo removes the now-empty layer AND the + // stroke (skipping the pixel restore — the layer's cache is gone), and a + // redo re-adds the layer then re-applies the stroke pixels. + createComposedPaintEntry(event.createdLayer, event, label) + : createImagePatchEntry({ + after: event.afterImageData, + apply: applyImagePatch, + before: event.beforeImageData, + label, + layerId: event.layerId, + rect: event.dirtyRect, + }) + ); + } + for (const listener of strokeListeners) { + listener(event); + } + }, + getDocument: () => mirror.getDocument(), + getSelectionMask: () => selection.mask(), + invalidate: (payload) => scheduler.invalidate(payload), + layers: layerCache, + notifyLayerPainted, + openTextCreate: (docPoint) => openTextCreate(docPoint), + openTextEdit: (layerId) => openTextEdit(layerId), + setLayerTransformOverride: (layerId, override) => { + if (override) { + transformOverrides.set(layerId, override); + } else { + transformOverrides.delete(layerId); + } + scheduler.invalidate({ layers: [layerId], overlay: true }); + }, + setOverlayCursor: (cursor) => { + overlayCursor = cursor; + }, + stores, + updateCursor: () => updateCursor(), + updateTransformSession: (transform) => updateTransformSession(transform), + viewport, + }; + + const activeTool = (): Tool | undefined => tools.get(activeToolId); + + /** Applies a CSS cursor to the input element, guarded for node stubs without `style`. */ + const applyCursorToInput = (cursor: string): void => { + const style = inputEl?.style; + if (style) { + style.cursor = cursor; + } + }; + + const updateCursor = (): void => { + const cursor = activeTool()?.cursor?.(toolContext) ?? 'default'; + stores.cursor.set(cursor); + // The store write alone never changes the pointer; apply to the DOM directly. + applyCursorToInput(cursor); + }; + + /** + * Resizes the brush/eraser cursor ring in place when the active tool's size + * changes without a pointer event (`[`/`]` hotkeys, ctrl+wheel, or the + * options-bar slider). The ring's radius otherwise stays stale until the next + * pointermove; here we keep its last-known center and just refresh the radius, + * then invalidate the overlay so it redraws immediately. + */ + const refreshBrushCursorRadius = (): void => { + if (!overlayCursor) { + return; + } + let size: number | null = null; + if (activeToolId === 'brush') { + size = stores.brushOptions.get().size; + } else if (activeToolId === 'eraser') { + size = stores.eraserOptions.get().size; + } + if (size === null) { + return; + } + overlayCursor = { point: overlayCursor.point, radiusDoc: size / 2 }; + scheduler.invalidate({ overlay: true }); + }; + + // ---- Rasterization orchestration --------------------------------------- + + const rasterizeDeps = (doc: CanvasDocumentContractV2): RasterizeDeps => ({ + backend, + documentSize: { height: doc.height, width: doc.width }, + resolver: imageResolver, + store: layerCache, + }); + + const ensureLayerCaches = (doc: CanvasDocumentContractV2): void => { + for (const layer of doc.layers) { + // The layer's rasterizable source: a raster/control `source`, or a mask + // layer's alpha bitmap viewed as a paint source (colorized at composite). + const source = renderableSourceOf(layer); + if (!layer.isEnabled || !source) { + continue; + } + if (!isRenderableLayer(layer)) { + continue; + } + + const name = layerImageName(layer); + if (name) { + trackedImageNames.set(layer.id, name); + } + + // Ensure a cache entry exists WITHOUT resizing an existing one: a paint + // layer's live cache may have grown past its persisted content rect (fresh + // unflushed strokes), so we must never resize it down to the contract size + // here — that would destroy those pixels. The rasterizer (below, guarded by + // `stale`) owns sizing the surface + placing its content rect. + const entry = layerCache.getOrCreateRect(layer.id, getSourceContentRect(layer, doc)); + if (!entry.stale || inFlight.has(layer.id)) { + continue; + } + + inFlight.add(layer.id); + // Capture the cache's version before dispatching: if another + // invalidation bumps it while this rasterize is in flight, the pixels + // we're about to produce are already stale by the time they land, and + // must not clear `stale` — otherwise the newest edit would be dropped + // until some unrelated invalidation happens to re-trigger a rasterize. + const dispatchedVersion = layerCache.version(layer.id); + // Text metrics depend on the font actually being available. This rasterize + // draws with whatever `measureText` reports now (a fallback if a web font + // is still loading); when the font resolves, re-rasterize — unless a newer + // edit already bumped the version (a stale resolve must not clobber it). + if (source.type === 'text') { + fontLoader.ensure(textFontString(source), () => { + if (disposed || layerCache.version(layer.id) !== dispatchedVersion) { + return; + } + layerCache.invalidate(layer.id); + scheduler.invalidate({ layers: [layer.id] }); + }); + } + rasterizeSource(source, rasterizeDeps(doc), entry.surface) + .then((result) => { + inFlight.delete(layer.id); + const current = layerCache.get(layer.id); + if (current && layerCache.version(layer.id) === dispatchedVersion) { + current.stale = false; + // Adopt the placed content rect the rasterizer produced (the surface + // was resized in place, so its identity is unchanged). + current.rect = result.rect; + // Only bump the thumbnail version when the pixels that just + // landed are actually the current ones. A stale (superseded) + // completion must not touch `thumbnailVersion`: since the + // equality-guarded store only notifies on change, a stale + // completion setting the same version the fresh completion will + // later compute would suppress that later notification, leaving + // thumbnail subscribers stuck on pre-edit pixels indefinitely. + stores.thumbnailVersion.set(layer.id, layerCache.version(layer.id)); + } + // If the version moved on, `current.stale` is left `true` (set by + // the intervening `invalidate`), so the next scheduled frame's + // `ensureLayerCaches` pass re-dispatches a fresh rasterize. + scheduler.invalidate({ layers: [layer.id] }); + }) + .catch(() => { + inFlight.delete(layer.id); + }); + } + }; + + /** + * Rasterizes a single layer on demand and returns its cache surface plus the + * content rect (layer-local origin/size) those pixels occupy, for the + * composite-for-generation executor. Rasterize-or-throw: a missing layer, a + * non-raster/control layer, or an unsupported source throws a descriptive error + * rather than returning a blank surface, so an invoke can never silently drop a + * contributing layer. Only invoked for layers the pure planner already selected + * (enabled image/paint rasters). + */ + const getLayerSurfaceForExport = async (layerId: string): Promise<{ surface: RasterSurface; rect: Rect }> => { + const doc = mirror.getDocument(); + if (!doc) { + throw new Error(`Cannot rasterize layer ${layerId} for generation: no active canvas document.`); + } + const layer = doc.layers.find((candidate) => candidate.id === layerId); + // Raster/control layers rasterize their own source; inpaint-mask layers + // rasterize their alpha bitmap through the shared paint view (`renderableSourceOf`), + // so the grayscale mask composite can read a mask's alpha exactly like a paint layer. + const source = layer ? renderableSourceOf(layer) : null; + if (!layer || !source) { + throw new Error( + `Cannot rasterize layer ${layerId} for generation: layer is missing or has no rasterizable source.` + ); + } + if (!isRenderableLayer(layer)) { + throw new Error(`Cannot rasterize layer ${layerId} for generation: unsupported source type "${source.type}".`); + } + const entry = layerCache.getOrCreateRect(layerId, getSourceContentRect(layer, doc)); + if (entry.stale) { + // Rasterize synchronously into the cache; a failure (e.g. asset resolve) + // propagates so the executor aborts with a meaningful message. + const result = await rasterizeSource(source, rasterizeDeps(doc), entry.surface); + entry.rect = result.rect; + entry.stale = false; + } + return { rect: entry.rect, surface: entry.surface }; + }; + + const releaseBitmapIfUnreferenced = (imageName: string): void => { + const doc = mirror.getDocument(); + const usedInDoc = doc ? doc.layers.some((layer) => layerImageName(layer) === imageName) : false; + if (usedInDoc) { + return; + } + for (const tracked of trackedImageNames.values()) { + if (tracked === imageName) { + return; + } + } + layerCache.deleteBitmap(imageName); + }; + + const dropLayer = (layerId: string): void => { + layerCache.delete(layerId); + adjustedSurfaceCache.delete(layerId); + inFlight.delete(layerId); + stores.thumbnailVersion.delete(layerId); + const imageName = trackedImageNames.get(layerId); + trackedImageNames.delete(layerId); + if (imageName) { + releaseBitmapIfUnreferenced(imageName); + } + }; + + // ---- Staged generation preview ------------------------------------------ + + /** Drops any staged preview and bumps the token so an in-flight decode is discarded. */ + const clearStagedPreview = (): void => { + stagedPreviewToken += 1; + if (stagedPreview) { + stagedPreview = null; + scheduler.invalidate({ all: true }); + } + }; + + /** Decodes a staged-preview input to a surface (imageName via resolver, dataUrl via the backend seam). */ + const decodeStagedPreview = async ( + input: StagedPreviewInput + ): Promise<{ surface: RasterSurface; width: number; height: number }> => { + if ('imageName' in input) { + const blob = await imageResolver(input.imageName); + const bitmap = await backend.createImageBitmap(blob); + const width = bitmap.width; + const height = bitmap.height; + const surface = backend.createSurface(width, height); + surface.ctx.clearRect(0, 0, width, height); + surface.ctx.drawImage(bitmap, 0, 0); + bitmap.close(); + return { height, surface, width }; + } + const { dataUrl, height, width } = input; + const bitmap = await backend.createImageBitmap(dataUrlToBlob(dataUrl)); + const surface = backend.createSurface(width, height); + surface.ctx.clearRect(0, 0, width, height); + surface.ctx.drawImage(bitmap, 0, 0, width, height); + bitmap.close(); + return { height, surface, width }; + }; + + const setStagedPreview = (input: StagedPreviewInput | null): void => { + if (input === null) { + clearStagedPreview(); + return; + } + stagedPreviewToken += 1; + const token = stagedPreviewToken; + decodeStagedPreview(input) + .then((decoded) => { + // A newer set/clear superseded this decode while it was in flight. + if (token !== stagedPreviewToken) { + return; + } + stagedPreview = decoded; + scheduler.invalidate({ all: true }); + }) + .catch(() => { + // A transient decode failure leaves any prior preview untouched rather + // than blanking the canvas; the next selection re-drives a decode. + }); + }; + + // Per-layer control-filter previews: layerId → decoded filtered surface, plus a + // per-layer decode token so a stale decode never overwrites a newer one. + const filterPreviews = new Map(); + const filterPreviewTokens = new Map(); + + /** + * Drops a layer's filter-preview state and bumps its token so an in-flight + * decode for it is discarded — even if the id is later reused (e.g. an undo + * that restores a deleted layer must not resurrect a stale decode result + * that resolves afterward). The token is bumped, never reset/deleted, so a + * later `setFilterPreview` for the same id can never collide with a + * still-in-flight decode's captured token. + */ + const clearFilterPreview = (layerId: string): void => { + filterPreviewTokens.set(layerId, (filterPreviewTokens.get(layerId) ?? 0) + 1); + if (filterPreviews.delete(layerId)) { + scheduler.invalidate({ layers: [layerId] }); + } + }; + + /** + * Drops every layer's filter-preview state. Used on a wholesale document + * replace: none of the outgoing document's previews describe the incoming + * document, even if a layer id happens to be reused. + */ + const clearAllFilterPreviews = (): void => { + const ids = new Set([...filterPreviews.keys(), ...filterPreviewTokens.keys()]); + for (const id of ids) { + clearFilterPreview(id); + } + }; + + const setFilterPreview = (layerId: string, input: StagedPreviewInput | null): void => { + if (input === null) { + clearFilterPreview(layerId); + return; + } + + const nextToken = (filterPreviewTokens.get(layerId) ?? 0) + 1; + filterPreviewTokens.set(layerId, nextToken); + + decodeStagedPreview(input) + .then((decoded) => { + // A newer set/clear for THIS layer superseded the decode in flight. + if (filterPreviewTokens.get(layerId) !== nextToken) { + return; + } + filterPreviews.set(layerId, decoded); + scheduler.invalidate({ layers: [layerId] }); + }) + .catch(() => { + // Transient decode failure leaves any prior preview untouched. + }); + }; + + // ---- Render loop -------------------------------------------------------- + + /** + * The selected-layer bounds outline for the move overlay, or `null`. Only the + * move tool draws it; a layer mid-drag (with a live override) is preferred over + * the committed selection so the marquee tracks the preview. + */ + const moveOutlineCorners = (doc: CanvasDocumentContractV2): readonly { x: number; y: number }[] | null => { + if (activeToolId !== 'move') { + return null; + } + const overridden = doc.layers.find((layer) => transformOverrides.has(layer.id)); + const target = overridden ?? doc.layers.find((layer) => layer.id === doc.selectedLayerId); + if (!target) { + return null; + } + return layerOutlineCorners(target, doc, transformOverrides.get(target.id) ?? null); + }; + + /** The transform-tool frame (rotated bounds + handles + rotation nub), or `null`. */ + const transformFrameOverlay = ( + doc: CanvasDocumentContractV2 + ): { + corners: { x: number; y: number }[]; + handles: { x: number; y: number }[]; + center: { x: number; y: number }; + rotationAnchor: { x: number; y: number }; + } | null => { + if (activeToolId !== 'transform') { + return null; + } + const session = stores.transformSession.get(); + if (!session) { + return null; + } + const layer = doc.layers.find((candidate) => candidate.id === session.layerId); + // The layer's LOCAL content rect (off-origin aware): the frame must wrap the + // pixels where the compositor draws them, not an assumed origin-anchored box. + const rect = layer ? hittableLayerRect(layer, doc) : null; + if (!rect) { + return null; + } + return transformOverlayGeometry(session.transform, rect); + }; + + const render = (flags: RenderFlags): void => { + const screen = screenSurface; + const overlay = overlaySurface; + if (!screen || !overlay) { + return; + } + const doc = mirror.getDocument(); + const view = viewport.viewMatrix(viewport.getDpr()); + + if (!doc) { + clearSurface(screen); + clearSurface(overlay); + return; + } + + // The composited document only needs redrawing when pixels, layer order, or + // the viewport transform changed. An overlay-ONLY invalidation (the common + // hover case: a cursor-ring move dispatches `{ overlay: true }`) must NOT + // recomposite: the screen canvas retains its last frame, and the overlay is + // redrawn on top. Skipping the composite here is the single biggest zoom-lag + // win — a full composite up-scales every doc-sized layer surface to fill the + // screen, and that fill-rate grows with zoom, so recompositing on every hover + // move at high zoom is exactly the reported "laggier the closer you zoom in". + const needsComposite = flags.all || flags.view || flags.layers.size > 0; + if (needsComposite) { + ensureLayerCaches(doc); + compositeDocument(screen, doc, layerCache, view, { + // Memoized adjusted surfaces for raster layers with brightness/contrast/ + // saturation/curves (not recomputed per frame — see adjustedSurfaceCache). + adjustedSurface: getAdjustedSurface, + // The raster backend + mask fill tile resolver drive the mask colorize + // path (alpha stencil → source-in fill colour/pattern, above all layers). + backend, + maskPatternTile: getMaskPatternTile, + // Non-destructive control-filter previews (drawn in place of the layer's + // committed pixels). Only allocated when a preview is actually active. + layerPreviews: + filterPreviews.size > 0 + ? new Map(Array.from(filterPreviews, ([id, decoded]) => [id, decoded.surface])) + : null, + // Feed the cached checkerboard tile only while the toggle is ON; passing + // `null` renders transparent documents without a checkerboard. + checkerboardTile: stores.checkerboard.get() ? getCheckerboardTile() : null, + // Crisp + cheap when zoomed in (nearest-neighbor up-scale), smooth when + // shrinking (bilinear down-scale). See `shouldSmoothAtZoom`. + imageSmoothing: shouldSmoothAtZoom(viewport.getZoom()), + // The preview's origin follows the CURRENT bbox so it lands exactly where + // `acceptStagedImage` would place the accepted layer (bbox x/y, unscaled). + stagedPreview: stagedPreview + ? { + rect: { height: stagedPreview.height, width: stagedPreview.width, x: doc.bbox.x, y: doc.bbox.y }, + surface: stagedPreview.surface, + } + : null, + // While a text-edit session is open on a layer, skip it in the composite — + // the contenteditable portal shows its live text instead (avoids double-draw). + skipLayerId: stores.textEditSession.get()?.layerId ?? null, + transformOverrides: transformOverrides.size > 0 ? transformOverrides : null, + }); + + const visibleIds = doc.layers.filter(isRenderableLayer).map((layer) => layer.id); + const evicted = layerCache.evictHidden(visibleIds); + // Prune version-keyed dependents for every evicted id (mirrors dropLayer): + // the evicted layer's surface is gone, so its adjusted-surface memo and + // thumbnail state must not linger keyed to a version the re-rasterized entry + // will exceed (the cache floor keeps versions monotonic across the recreate). + for (const id of evicted) { + adjustedSurfaceCache.delete(id); + stores.thumbnailVersion.delete(id); + } + } + + // The overlay is cheap (a handful of screen-space strokes, independent of + // zoom and document size) and shares the `view` transform with the composite, + // so redraw it whenever any frame runs — including overlay-only frames. + // While the bbox tool drags, the transient preview stands in for the + // committed frame so the overlay (rect + handles) tracks the gesture. + const bboxPreview = stores.bboxPreview.get(); + renderOverlay(overlay, { + bbox: bboxPreview ?? doc.bbox, + bboxHandles: activeToolId === 'bbox', + cursor: overlayCursor, + // The grid spans the whole viewport at the bbox snap size when the setting + // is on (the document rect no longer bounds it). + gridSize: stores.bboxGrid.get(), + layerOutline: moveOutlineCorners(doc), + // The in-progress lasso path (transient) and the committed selection's + // animated marching ants. Both are overlay chrome; ants advance via the + // ants animator's overlay-only ticks. + gradientPreview: stores.gradientPreview.get(), + lassoPreview: stores.lassoPreview.get(), + marchingAnts: selection.hasSelection() ? { paths: selection.antsPaths(), phase: antsPhase } : null, + bboxOverlay: stores.bboxOverlay.get(), + ruleOfThirds: stores.ruleOfThirds.get(), + shapePreview: stores.shapePreview.get(), + // The passive bbox frame follows the setting, but always renders while the + // bbox tool is active so its handles have a frame to attach to (editable). + showBbox: stores.showBbox.get() || activeToolId === 'bbox', + showGrid: stores.showGrid.get(), + transformFrame: transformFrameOverlay(doc), + view, + }); + }; + + const scheduler: RenderScheduler = createRenderScheduler({ render }); + // Stay paused until attached: invalidations accumulate but never request a + // (DOM) frame, keeping the engine node-safe before it has render targets. + scheduler.pause(); + + // ---- Document mirror ---------------------------------------------------- + + const mirror: DocumentMirror = createDocumentMirror(store, projectId, { + // The bbox rectangle/handles are overlay chrome, so a bbox move is normally + // overlay-only (no recomposite). The one exception: a staged-generation + // preview is drawn in the COMPOSITE at the current bbox origin, so while one + // is active the document must recomposite to reposition it with the bbox. + onBboxChanged: () => scheduler.invalidate(stagedPreview ? { all: true } : { overlay: true }), + onDocumentReplaced: () => { + // A wholesale document swap — project switch, dims/background change, or a + // snapshot restore that changes dims — invalidates the pixel history: its + // entries reference layers/pixels that no longer describe the live document. + // + // Cancel any in-flight tool gesture FIRST: a swap mid-drag leaves stale tool + // state (a bbox `startBbox`, a move drag anchor) whose pointer-up would + // otherwise commit against the replaced document. Routing through the + // pipeline clears `gestureActive` and runs the tool's `onPointerCancel`, so + // the tool drops its own transient state. + pipeline.cancelActiveGesture(); + // Defensive: a non-bbox active tool won't have cleared a lingering preview. + stores.bboxPreview.set(null); + history.clear(); + endNudgeBurst(); + // A transform session (which outlives individual gestures) belongs to the + // outgoing document; tear it down alongside its preview override. + stores.transformSession.set(null); + transformOverrides.clear(); + // A text-edit session likewise belongs to the outgoing document; drop it. + stores.textEditSession.set(null); + // A staged preview belongs to the outgoing document's bbox/candidates; a + // wholesale swap (project switch, snapshot restore) invalidates it. + clearStagedPreview(); + // Per-layer control-filter previews likewise belong to the outgoing + // document — a swap can reuse a layer id with different content, so + // pruning only "missing" ids isn't enough; drop them all. + clearAllFilterPreviews(); + // The selection is document-scoped interaction state: a swap drops it (and + // any in-progress lasso preview), stopping the ants loop via onChange. + selection.clear(); + stores.lassoPreview.set(null); + const doc = mirror.getDocument(); + const present = new Set(doc ? doc.layers.map((layer) => layer.id) : []); + // Snapshot ids first: dropLayer mutates trackedImageNames during iteration. + const trackedIds = Array.from(trackedImageNames.keys()); + for (const layerId of trackedIds) { + if (!present.has(layerId)) { + dropLayer(layerId); + } + } + // A wholesale replacement can reuse a layer id with a DIFFERENT source, so + // a surviving cache entry may hold pixels from the outgoing document. + // Invalidate EVERY id in the incoming document — not just ids whose + // reference happened to change — to force a re-rasterize from the new + // source; a diff can't be trusted across a full swap. + for (const layerId of present) { + layerCache.invalidate(layerId); + } + // Persistence bookkeeping (the self-echo `lastApplied` map and pending + // debounced flushes) described the OLD document. Drop it so a reused layer + // id can't have its next legit persistence dispatch suppressed as a stale + // self-echo. + bitmapStore.reset(); + scheduler.invalidate({ all: true }); + }, + onLayerOrderChanged: () => scheduler.invalidate({ all: true }), + onLayersChanged: (ids, sourceChangedIds) => { + const doc = mirror.getDocument(); + const present = new Set(doc ? doc.layers.map((layer) => layer.id) : []); + const sourceChanged = new Set(sourceChangedIds); + // A transform session outlives individual gestures (and any tool switch, + // including a temp modifier-hold), so it can easily outlive its own layer + // being deleted out from under it — e.g. deleted via the layers panel + // while the pointer is elsewhere, or while temp-switched to view/colorPicker. + // Tear it down (session + preview override) the same way a document + // replace does, rather than leaving a ghost session/override pointing at + // a layer id that no longer exists. + const session = stores.transformSession.get(); + const textSession = stores.textEditSession.get(); + for (const id of ids) { + if (!present.has(id)) { + dropLayer(id); + // A control-filter preview (session + decoded surface) belongs to a + // specific layer; a layer removed out from under an in-flight or + // already-decoded preview (delete via the layers panel, or an undo + // that removes it) must have its preview dropped and its decode + // token bumped, or a late-resolving decode — or a later undo that + // restores this same id — would repopulate a stale preview. + clearFilterPreview(id); + if (session && session.layerId === id) { + cancelTransform(); + } + // An edit-mode text session whose layer was deleted out from under it + // (layers panel, or undo of the add) is torn down the same way. + if (textSession && textSession.layerId === id) { + cancelTextEdit(); + } + continue; + } + if (!sourceChanged.has(id)) { + // Prop/transform-only change (opacity, blend, lock, visibility, + // rename, nudge): the layer object was replaced but its SOURCE + // reference is unchanged, so the rasterized pixels are still valid. + // Invalidating here would be wasteful for an image layer and + // *destructive* for an unflushed paint layer — a `bitmap: null` paint + // source rasterizes to a CLEARED surface, wiping strokes that live + // only in the cache until their debounced upload lands. The compositor + // applies transform/opacity/blend at draw time, so the scheduled + // recomposite below is all a prop change needs. + continue; + } + // The layer's source genuinely changed (image swap, or a paint-bitmap + // swap from undo/import). Self-echo guard: skip when it's the exact + // paint-bitmap ref the bitmap store just applied — the cache already + // holds those pixels, so re-rasterizing would needlessly re-fetch and + // could flicker. Any other swap invalidates → re-rasterizes. + const source = getLayerSourceById(id); + if (bitmapStore.isSelfEcho(id, source)) { + continue; + } + layerCache.invalidate(id); + } + scheduler.invalidate({ layers: ids }); + }, + onStagingChanged: () => scheduler.invalidate({ overlay: true }), + }); + + // ---- Viewport → stores/scheduler --------------------------------------- + + // Set while `resize` drives `setViewportSize` synchronously: the resize path + // composites in the same task (the anti-strobe fix), so the viewport + // subscription must NOT also schedule a `{ view: true }` frame — that pending + // flag would recomposite identical content on the next rAF (a second full + // composite per ResizeObserver event during a panel-drag resize). + let suppressViewportInvalidate = false; + + const unsubscribeViewport = viewport.subscribe(() => { + stores.zoom.set(viewport.getZoom()); + if (!suppressViewportInvalidate) { + scheduler.invalidate({ view: true }); + } + }); + + // ---- Tool-option / setting stores → overlay + recomposite --------------- + // + // A brush/eraser size change must resize the cursor ring even with no pointer + // event; toggling the checkerboard must recomposite the document. + const unsubscribeBrushOptions = stores.brushOptions.subscribe(refreshBrushCursorRadius); + const unsubscribeEraserOptions = stores.eraserOptions.subscribe(refreshBrushCursorRadius); + const unsubscribeCheckerboard = stores.checkerboard.subscribe(() => scheduler.invalidate({ all: true })); + // New checker colors (theme/color-mode switch): drop the cached tile so it + // rebuilds with the fed colors on the next composite, then force a recomposite. + const unsubscribeCheckerColors = stores.checkerColors.subscribe(() => { + checkerboardTile = null; + scheduler.invalidate({ all: true }); + }); + // The grid lives on the (cheap) overlay; toggling it or changing its snap size + // only needs an overlay redraw, never a recomposite. + const unsubscribeShowGrid = stores.showGrid.subscribe(() => scheduler.invalidate({ overlay: true })); + const unsubscribeBboxGrid = stores.bboxGrid.subscribe(() => { + if (stores.showGrid.get()) { + scheduler.invalidate({ overlay: true }); + } + }); + // The bbox frame, bbox overlay shade, and rule-of-thirds guides live on the + // (cheap) overlay; toggling any only needs an overlay redraw, never a + // recomposite. (`snapToGrid` is a pure interaction preference the bbox tool + // reads on gesture — no render effect.) + const unsubscribeShowBbox = stores.showBbox.subscribe(() => scheduler.invalidate({ overlay: true })); + const unsubscribeBboxOverlay = stores.bboxOverlay.subscribe(() => scheduler.invalidate({ overlay: true })); + const unsubscribeRuleOfThirds = stores.ruleOfThirds.subscribe(() => scheduler.invalidate({ overlay: true })); + + // ---- History → stores --------------------------------------------------- + + const syncHistoryStores = (): void => { + stores.canUndo.set(history.canUndo()); + stores.canRedo.set(history.canRedo()); + }; + const unsubscribeHistory = history.subscribe(syncHistoryStores); + + // ---- Pointer / wheel / key input --------------------------------------- + // + // Normalization, capture, coalescing, temp-tool holds, and gesture cancel live + // in the pointer pipeline; wheel routing (zoom vs brush-size step) lives in the + // wheel handler. The engine just supplies seams and wires the DOM listeners. + + /** Steps the active brush/eraser diameter by one notch (ctrl+wheel or the `[`/`]` hotkeys). */ + const stepActiveBrushSize = (direction: 1 | -1): void => { + if (activeToolId === 'brush') { + const opts = stores.brushOptions.get(); + stores.brushOptions.set({ ...opts, size: stepBrushSize(opts.size, direction) }); + } else if (activeToolId === 'eraser') { + const opts = stores.eraserOptions.get(); + stores.eraserOptions.set({ ...opts, size: stepBrushSize(opts.size, direction) }); + } + }; + + /** + * The engine's Escape priority ladder, run by the pointer pipeline AFTER it + * cancels any in-flight gesture, matching the planned chain "gesture → text + * session → transform → deselect": cancel an open text-edit session, else an + * open transform session, else deselect. A focused text portal consumes Escape + * itself (stopPropagation), so this window-level handler only reaches a + * defocused-but-open text session. Deselect is suppressed when a drag just + * consumed the Escape (`gestureWasActive`), so a mid-lasso Escape drops only the + * in-progress path, never the committed selection. Exposed for the pipeline + * wiring and node tests (the real DOM keydown listener can't run in node-env). + */ + const handleEscapePriority = ({ gestureWasActive }: { gestureWasActive: boolean }): void => { + if (stores.textEditSession.get()) { + cancelTextEdit(); + return; + } + if (stores.transformSession.get()) { + cancelTransform(); + return; + } + if (!gestureWasActive && selection.hasSelection()) { + selection.clear(); + } + }; + + const pipeline: PointerPipeline = createPointerPipeline({ + getActiveTool: activeTool, + getActiveToolId: () => activeToolId, + getInputElement: () => inputEl, + getToolContext: () => toolContext, + handleEscape: handleEscapePriority, + hasTool: (id) => tools.has(id), + // A primary-button pointerdown while a text-edit session is open commits it + // (engine reads the live portal content). The pipeline swallows that press. + maybeCommitModalSession: () => commitOpenTextSession(), + setTool: (id, opts) => setTool(id, opts), + updateCursor, + viewport, + }); + + const onWheel = createWheelHandler({ + getActiveTool: activeTool, + getInputElement: () => inputEl, + getInvertBrushSizeScroll: () => stores.invertBrushSizeScroll.get(), + getToolContext: () => toolContext, + invalidate: (payload) => scheduler.invalidate(payload), + stepActiveBrushSize, + viewport, + }); + + // ---- Lifecycle: shrink the paint-loss window --------------------------- + // + // Unload cannot be reliably blocked, so these are fire-and-forget kicks that + // narrow the gap between the last paint and its upload; the real barrier is + // `flushPendingUploads()`, which invoke/export await. `blur` additionally + // resets the pointer pipeline so a held space/alt temp tool doesn't strand + // when the window loses focus mid-hold. + const kickPendingFlush = (): void => { + void bitmapStore.flushPendingUploads(); + }; + const onPageHide = (): void => { + kickPendingFlush(); + }; + const onVisibilityChange = (): void => { + if (typeof document !== 'undefined' && document.visibilityState === 'hidden') { + kickPendingFlush(); + } + }; + const onWindowBlur = (): void => { + pipeline.reset(); + }; + + // ---- Public API --------------------------------------------------------- + + function setTool(toolId: ToolId, opts?: { temporary?: boolean }): void { + if (toolId === activeToolId) { + return; + } + tools.get(activeToolId)?.onDeactivate?.(toolContext, opts); + activeToolId = toolId; + tools.get(toolId)?.onActivate?.(toolContext, opts); + stores.activeTool.set(toolId); + updateCursor(); + // The overlay draws tool-specific chrome (bbox handles, move outline) keyed + // on the active tool, so a switch must repaint it even without a doc change. + scheduler.invalidate({ overlay: true }); + } + + const attach = (screenCanvas: HTMLCanvasElement, overlayCanvas: HTMLCanvasElement): void => { + if (disposed) { + return; + } + if (inputEl) { + detach(); + } + screenSurface = wrapCanvasSurface(screenCanvas); + overlaySurface = wrapCanvasSurface(overlayCanvas); + // The overlay canvas sits on top, so it receives pointer/wheel input. + inputEl = overlayCanvas; + + inputEl.addEventListener('pointerdown', pipeline.onPointerDown); + inputEl.addEventListener('pointermove', pipeline.onPointerMove); + inputEl.addEventListener('pointerup', pipeline.onPointerUp); + inputEl.addEventListener('pointercancel', pipeline.onPointerCancel); + inputEl.addEventListener('pointerenter', pipeline.onPointerEnter); + inputEl.addEventListener('pointerleave', pipeline.onPointerLeave); + inputEl.addEventListener('wheel', onWheel, { passive: false }); + if (typeof globalThis.addEventListener === 'function') { + globalThis.addEventListener('keydown', pipeline.onKeyDown); + globalThis.addEventListener('keyup', pipeline.onKeyUp); + globalThis.addEventListener('pagehide', onPageHide); + globalThis.addEventListener('blur', onWindowBlur); + } + if (typeof document !== 'undefined') { + document.addEventListener('visibilitychange', onVisibilityChange); + } + + scheduler.resume(); + stores.viewportReady.set(true); + // Reflect the active tool's cursor on the freshly-bound input element. + updateCursor(); + scheduler.invalidate({ all: true }); + // Resume marching ants if a selection outlived a detach/attach cycle. + updateAntsAnimation(); + }; + + const detach = (): void => { + if (inputEl) { + inputEl.removeEventListener('pointerdown', pipeline.onPointerDown); + inputEl.removeEventListener('pointermove', pipeline.onPointerMove); + inputEl.removeEventListener('pointerup', pipeline.onPointerUp); + inputEl.removeEventListener('pointercancel', pipeline.onPointerCancel); + inputEl.removeEventListener('pointerenter', pipeline.onPointerEnter); + inputEl.removeEventListener('pointerleave', pipeline.onPointerLeave); + inputEl.removeEventListener('wheel', onWheel); + // Restore the element's default cursor so a detached surface isn't stuck + // with a stale tool cursor. + applyCursorToInput(''); + } + if (typeof globalThis.removeEventListener === 'function') { + globalThis.removeEventListener('keydown', pipeline.onKeyDown); + globalThis.removeEventListener('keyup', pipeline.onKeyUp); + globalThis.removeEventListener('pagehide', onPageHide); + globalThis.removeEventListener('blur', onWindowBlur); + } + if (typeof document !== 'undefined') { + document.removeEventListener('visibilitychange', onVisibilityChange); + } + pipeline.reset(); + // Drop the staged preview surface so a detached engine holds no stale + // decoded pixels; React re-drives it after a re-attach. + clearStagedPreview(); + scheduler.pause(); + stores.viewportReady.set(false); + screenSurface = null; + overlaySurface = null; + inputEl = null; + // No render targets: pause marching ants (the selection itself is retained). + updateAntsAnimation(); + }; + + const resize = (cssWidth: number, cssHeight: number, dpr: number): void => { + // Suppress the viewport subscription's `{ view: true }` invalidate: the + // synchronous `render` below already repaints this size change, so letting the + // subscription schedule a frame would composite the identical result again on + // the next rAF (two full composites per resize event). + suppressViewportInvalidate = true; + viewport.setViewportSize(cssWidth, cssHeight, dpr); + suppressViewportInvalidate = false; + const backingDpr = Math.min(dpr, MAX_DPR); + const backingWidth = Math.round(cssWidth * backingDpr); + const backingHeight = Math.round(cssHeight * backingDpr); + screenSurface?.resize(backingWidth, backingHeight); + overlaySurface?.resize(backingWidth, backingHeight); + // Composite SYNCHRONOUSLY, in this same task, right after the backing-store + // resize. Sizing a `` backing store clears it, so deferring the + // recomposite to the next rAF (the normal dirty-path) leaves a blank frame + // on screen until then — during a continuous panel-drag resize that reads as + // a flash/strobe. A same-task repaint lands before the browser paints, so the + // canvas never shows empty. `all: true` forces the composite through the T22 + // dirty gate; `render` no-ops when detached (no surfaces). + render({ all: true, layers: new Set(), overlay: true, view: true }); + }; + + const fitToView = (): void => { + const doc = mirror.getDocument(); + if (!doc) { + return; + } + // The document rect is no longer a spatial boundary — fit content ∪ bbox. The + // bbox (generation frame) is the primary anchor, so an empty canvas fits it; + // any renderable layer beyond the bbox is unioned in so it lands in view. + let bounds: Rect = { ...doc.bbox }; + for (const layer of doc.layers) { + if (isRenderableLayer(layer)) { + bounds = union(bounds, getSourceBounds(layer, doc)); + } + } + viewport.fitToView(bounds, viewport.getViewportSize()); + }; + + const drawLayerThumbnail = (layerId: string, target: HTMLCanvasElement, maxSize: number): boolean => { + const entry = layerCache.get(layerId); + if (!entry) { + return false; + } + const { height, width } = fitThumbnailSize(entry.surface.width, entry.surface.height, maxSize); + if (width === 0 || height === 0) { + return false; + } + const ctx = target.getContext('2d'); + if (!ctx) { + return false; + } + target.width = width; + target.height = height; + ctx.clearRect(0, 0, width, height); + ctx.drawImage(entry.surface.canvas, 0, 0, width, height); + return true; + }; + + /** + * True when a layer's cache is safe to READ + mutate in place for a structural + * op (invert mask, merge down). The hazard is a layer with PERSISTED pixels + * (`mask.bitmap` / image / paint bitmap) that haven't been decoded into a ready + * cache: the surface then holds blank/old pixels, so the op would bake garbage + * AND the in-flight `rasterizeSource` completion would later redraw over the + * op's result. Such a layer is safe only once its cache exists and is neither + * stale (re-rasterize pending) nor mid-decode (`inFlight`). + * + * A layer with NO persisted content is always safe: its cache — whether a fresh + * live paint (unflushed stroke) or a genuinely empty 0×0 stale entry — already + * reflects the current pixels, with nothing to decode that could clobber it. + */ + const isLayerCacheReadyForOp = (layer: CanvasLayerContract, doc: CanvasDocumentContractV2): boolean => { + if (isEmpty(getSourceContentRect(layer, doc))) { + return true; + } + const entry = layerCache.get(layer.id); + return !!entry && !entry.stale && !inFlight.has(layer.id); + }; + + const mergeLayerDown = (upperLayerId: string): boolean => { + // No-op mid-gesture (a mod+e mashed during a paint drag): merging writes + // pixels and dispatches a structural collapse under the open stroke session. + // Matches the commitStructural/nudge guards; this op is not undoable, so the + // guard is doubly important. The pipeline reports gesture state. + if (pipeline.isGestureActive()) { + return false; + } + endNudgeBurst(); + const doc = mirror.getDocument(); + if (!doc) { + return false; + } + const upperIndex = doc.layers.findIndex((layer) => layer.id === upperLayerId); + if (upperIndex === -1) { + return false; + } + const upper = doc.layers[upperIndex]; + const below = doc.layers[upperIndex + 1]; + // Both layers must be mergeable raster layers (enabled, unlocked, image/paint + // sourced `raster` type) — the SAME predicate the layers panel's context menu + // uses to enable/disable merge-down (`isMergeableRasterLayer`), so the hotkey + // and the menu can never disagree. `isRenderableLayer` is deliberately NOT used + // here: it is broader (masks, control layers, shapes, text, gradients are all + // renderable) and gating on it let a mask on either side merge — the reducer's + // merge unconditionally produces a `type: 'raster'` result, so merging a mask + // blitted its stencil into the layer below and/or clobbered a mask below into + // a raster layer, destroying its config. Not undoable, so this guard matters. + if (!upper || !below || !isMergeableRasterLayer(upper) || !isMergeableRasterLayer(below)) { + return false; + } + const upperCache = layerCache.get(upper.id); + const belowCache = layerCache.get(below.id); + if (!upperCache || !belowCache) { + return false; + } + // Refuse while either cache is stale or mid-decode: merging would bake blank/ + // old pixels, and the in-flight rasterize completion would then redraw over + // the merged result. (This op is not undoable, so a corrupt bake is permanent.) + if (!isLayerCacheReadyForOp(upper, doc) || !isLayerCacheReadyForOp(below, doc)) { + return false; + } + // Map the upper cache into the below layer's local space (the reducer keeps + // the below transform verbatim, so we pre-warp the upper pixels to match). + const matrix = mergeDownMatrix(below.transform, upper.transform); + if (!matrix) { + return false; + } + + // Both layers are empty (content-sized 0×0 caches): the merge is trivially just + // deleting the upper layer, the below staying empty. Skip every pixel step — a + // 0×0 merged surface throws — and collapse the pair in the reducer with an empty + // paint source. Merge-visible folds an all-empty run this way instead of stalling + // on a silent no-op that leaves the stack half-merged (F4). + if (isEmpty(belowCache.rect) && isEmpty(upperCache.rect)) { + store.dispatch({ + source: { bitmap: null, offset: { x: 0, y: 0 }, type: 'paint' }, + type: 'mergeCanvasLayersDown', + upperLayerId, + }); + // The dispatch dropped the upper cache and invalidated the below one; the below + // layer stays empty, so just clear its cache (a fresh empty one rebuilds on the + // next composite) and persist the (empty) result like any other merge. + layerCache.delete(below.id); + notifyLayerPainted(below.id); + bitmapStore.markLayerDirty(below.id); + return true; + } + // The merged (below-local) content rect is the UNION of both layers' content + // bounds mapped into the below layer's local space: the below cache's own rect + // plus the upper cache's rect warped through `matrix`. Content-sized: no + // document clipping, so nothing outside the old doc rect is lost. + const upperInBelow = transformBounds(matrix, upperCache.rect); + const mergedRect = roundOut(union(belowCache.rect, upperInBelow)); + + // Composite below + upper into a fresh union-sized surface BEFORE dispatching: + // the dispatch invalidates the below cache, which would otherwise destroy the + // pixels we need as the base. Everything is drawn in merged-local space + // (origin `mergedRect.origin`). + const merged = backend.createSurface(mergedRect.width, mergedRect.height); + const mctx = merged.ctx; + mctx.setTransform(1, 0, 0, 1, 0, 0); + mctx.clearRect(0, 0, mergedRect.width, mergedRect.height); + // Below: its surface holds pixels for `belowCache.rect` in below-local; place + // it at that origin minus the merged origin. Skip when the below layer is empty + // (content-sizing means an empty paint cache is a 0×0 surface, and drawing a + // zero-dimension canvas throws in browsers) — the merge is then just the warped + // upper. + if (!isEmpty(belowCache.rect)) { + mctx.drawImage(belowCache.surface.canvas, belowCache.rect.x - mergedRect.x, belowCache.rect.y - mergedRect.y); + } + // Upper: warp upper-local → below-local via `matrix`, then shift into + // merged-local by subtracting the merged origin. Its surface holds pixels for + // `upperCache.rect` in upper-local, so draw at that local origin. Skip when the + // upper layer is empty (0×0 surface — see above): the merge then degenerates to + // deleting the upper layer, leaving the below pixels unchanged. + if (!isEmpty(upperCache.rect)) { + mctx.setTransform(matrix.a, matrix.b, matrix.c, matrix.d, matrix.e - mergedRect.x, matrix.f - mergedRect.y); + mctx.globalAlpha = upper.opacity; + mctx.globalCompositeOperation = blendToComposite(upper.blendMode); + mctx.drawImage(upperCache.surface.canvas, upperCache.rect.x, upperCache.rect.y); + mctx.setTransform(1, 0, 0, 1, 0, 0); + mctx.globalAlpha = 1; + mctx.globalCompositeOperation = 'source-over'; + } + + // Collapse the two layers in the reducer (pixels stay in the cache; the + // bitmap store persists them from there). Destructive: not undoable here. + // The merged rect's origin rides along as the paint offset (matching the + // rasterize bake) so the contract already records where the content sits; + // the bitmap-store flush re-dispatches the same offset with the real ref. + store.dispatch({ + source: { bitmap: null, offset: { x: mergedRect.x, y: mergedRect.y }, type: 'paint' }, + type: 'mergeCanvasLayersDown', + upperLayerId, + }); + + // The (synchronously mirrored) dispatch invalidated the below cache and + // dropped the upper one. Replace the below cache with a fresh union-sized + // surface holding the merged pixels, placed at the merged rect, and shield it + // from the async rasterize pass exactly like a fresh paint stroke. + layerCache.delete(below.id); + const targetEntry = layerCache.getOrCreateRect(below.id, mergedRect); + targetEntry.surface.ctx.drawImage(merged.canvas, 0, 0); + targetEntry.stale = false; + notifyLayerPainted(below.id); + bitmapStore.markLayerDirty(below.id); + return true; + }; + + /** Guard against a pathological fold loop if a merge ever fails to remove a layer. */ + const MAX_MERGE_VISIBLE_STEPS = 256; + + const mergeVisibleRasterLayers = (): MergeVisibleResult => { + // Mirrors the mergeLayerDown guard: no pixel work under an open gesture. + if (pipeline.isGestureActive()) { + return 'nothing'; + } + const doc = mirror.getDocument(); + if (!doc) { + return 'nothing'; + } + const runs = planMergeVisibleRuns(doc.layers); + if (runs.length === 0) { + return 'nothing'; + } + // Pre-flight the ENTIRE fold before touching any pixels, so a mid-fold + // refusal can never leave a half-merged (non-undoable) stack: + // 1. every participant's cache must be ready (not stale / mid-decode); + // 2. every consecutive pair's merge matrix must be computable. Transforms + // are stable across the fold (a merge keeps the below transform verbatim + // and the merged layer then becomes the next pair's upper), so checking + // the original transforms covers every step exactly. + const layerById = new Map(doc.layers.map((layer) => [layer.id, layer])); + for (const run of runs) { + for (const id of run) { + const layer = layerById.get(id); + if (!layer || !isLayerCacheReadyForOp(layer, doc)) { + return 'not-ready'; + } + } + for (let i = 0; i + 1 < run.length; i += 1) { + const upper = layerById.get(run[i] ?? ''); + const below = layerById.get(run[i + 1] ?? ''); + if (!upper || !below || !mergeDownMatrix(below.transform, upper.transform)) { + return 'not-ready'; + } + } + } + // Execute the fold: reorder the pair adjacent when interleaved (the reorder + // only slides the upper past non-raster layers and/or hidden rasters, which + // is render-neutral — the compositor draws by group rank), merge, re-plan + // against the synchronously-updated document, repeat. + for (let step = 0; step < MAX_MERGE_VISIBLE_STEPS; step += 1) { + const liveDoc = mirror.getDocument(); + if (!liveDoc) { + break; + } + const next = planNextMergeVisibleStep(liveDoc.layers); + if (!next) { + break; + } + if (next.orderedIds) { + store.dispatch({ orderedIds: next.orderedIds, type: 'reorderCanvasLayers' }); + } + if (!mergeLayerDown(next.upperId)) { + // Defensive: should be unreachable after the pre-flight above. + break; + } + } + return 'merged'; + }; + + /** + * True when `layer` is a raster layer whose source the engine can rasterize + * IGNORING its enabled state — the export gate (hidden layers are exported too, + * so `isRenderableLayer`, which requires `isEnabled`, is too strict). `polygon` + * shapes have no rasterizer, so they are excluded. + */ + const isExportableRasterLayer = (layer: CanvasLayerContract): boolean => { + if (layer.type !== 'raster') { + return false; + } + switch (layer.source.type) { + case 'image': + case 'paint': + case 'gradient': + case 'text': + return true; + case 'shape': + return layer.source.kind !== 'polygon'; + default: + return false; + } + }; + + /** + * Rasterizes a layer to its cache for PSD export and returns the surface + its + * content rect. Like {@link getLayerSurfaceForExport} but does NOT gate on + * `isEnabled` (hidden layers are exported too). Reads the live cache when it is + * not stale (capturing unflushed paint strokes), else rasterizes synchronously + * from the source. + */ + const getLayerSurfaceForPsd = async (layerId: string): Promise<{ surface: RasterSurface; rect: Rect }> => { + const doc = mirror.getDocument(); + if (!doc) { + throw new Error(`Cannot rasterize layer ${layerId} for PSD export: no active canvas document.`); + } + const layer = doc.layers.find((candidate) => candidate.id === layerId); + const source = layer ? renderableSourceOf(layer) : null; + if (!layer || !source) { + throw new Error(`Cannot rasterize layer ${layerId} for PSD export: layer is missing or has no source.`); + } + const entry = layerCache.getOrCreateRect(layerId, getSourceContentRect(layer, doc)); + if (entry.stale) { + const result = await rasterizeSource(source, rasterizeDeps(doc), entry.surface); + entry.rect = result.rect; + entry.stale = false; + } + return { rect: entry.rect, surface: entry.surface }; + }; + + const exportRasterLayersToPsd = async (fileName: string): Promise => { + const doc = mirror.getDocument(); + if (!doc) { + return 'nothing'; + } + const rasterLayers = doc.layers.filter(isExportableRasterLayer); + if (rasterLayers.length === 0) { + return 'nothing'; + } + // Readiness pre-flight: a layer whose cache is MID-DECODE (`inFlight`) would + // bake blank/old pixels, so refuse the whole export (nothing partial). A + // layer with no live cache entry is safe — the executor rasterizes it fresh + // and synchronously. Transform/stroke sessions in progress export the current + // committed cache state (acceptable). + for (const layer of rasterLayers) { + if (isEmpty(getSourceContentRect(layer, doc))) { + continue; + } + if (inFlight.has(layer.id)) { + return 'not-ready'; + } + } + const inputs: PsdExportLayerInput[] = rasterLayers.map((layer) => ({ + adjustments: layer.type === 'raster' ? layer.adjustments : undefined, + blendMode: layer.blendMode, + contentRect: getSourceContentRect(layer, doc), + id: layer.id, + isEnabled: layer.isEnabled, + name: layer.name, + opacity: layer.opacity, + transform: layer.transform, + })); + const plan = planPsdExport(inputs); + if (plan.status === 'empty') { + return 'nothing'; + } + if (plan.status === 'too-large') { + return 'too-large'; + } + const name = /\.psd$/i.test(fileName) ? fileName : `${fileName}.psd`; + await executePsdExport(plan, name, { backend, getLayerSurface: getLayerSurfaceForPsd }); + return 'exported'; + }; + + /** + * Rasterizes a parametric (shape/gradient/text) layer to pixels: bakes its current + * appearance (the cached parametric pixels drawn through the layer transform) + * into a NEW content-sized paint layer at identity — the transformed content + * bounds become the paint source's persisted offset — then converts the layer's + * source to `paint` via `convertCanvasLayer`. The baked pixels are written into + * the layer's cache and persisted through the normal bitmap-store dirty path + * (mirrors mergeLayerDown / the transform bake). + * + * UNDOABLE via a composed history entry: undo re-converts the layer back to its + * ORIGINAL parametric source (angle/stops/shape params). Because those pixels + * regenerate deterministically from the params, undo needs NO pixel snapshot — + * the source-ref change flips the mirror's sourceChanged bit, which invalidates + * the cache so `ensureLayerCaches` re-rasterizes the parametric source. That is + * the key asymmetry with a paint merge (whose inverse WOULD need the discarded + * pixels): parametric params are the cheap, lossless source of truth. + * + * Returns `false` (a no-op) mid-gesture, with no document, or when the layer is + * missing, not a raster layer, locked, or not a (rasterizable) parametric + * source. + */ + const rasterizeLayer = (layerId: string): boolean => { + if (pipeline.isGestureActive()) { + return false; + } + endNudgeBurst(); + const doc = mirror.getDocument(); + if (!doc) { + return false; + } + const layer = doc.layers.find((candidate) => candidate.id === layerId); + if (!layer || layer.type !== 'raster' || layer.isLocked) { + return false; + } + const source = layer.source; + if (source.type !== 'shape' && source.type !== 'gradient' && source.type !== 'text') { + return false; + } + if (source.type === 'shape' && source.kind === 'polygon') { + // Deferred: no polygon rasterizer, so nothing to bake. + return false; + } + + // The original parametric layer, captured for the undo re-conversion. + const parametricLayer: CanvasLayerContract = structuredClone(layer); + + /** + * Converts the CURRENT parametric layer to paint, re-baking its pixels from + * the live params each call. This is BOTH the initial apply and the redo: + * redo runs after an undo has restored the parametric source, so the layer is + * parametric again and its params regenerate the pixels deterministically. + * Re-baking (rather than capturing the doc-sized `baked` surface in a closure) + * keeps the history entry's retained bytes tiny — the entry must not pin a + * ~w×h×4 surface (a 4096² doc is ~64 MB) invisibly to the byte budget. + */ + const applyForward = (): void => { + const liveDoc = mirror.getDocument(); + const liveLayer = liveDoc?.layers.find((candidate) => candidate.id === layerId); + if (!liveDoc || !liveLayer || liveLayer.type !== 'raster') { + return; + } + const liveSource = liveLayer.source; + if (liveSource.type !== 'shape' && liveSource.type !== 'gradient' && liveSource.type !== 'text') { + return; + } + + // Ensure the parametric cache holds fresh pixels. A parametric layer's + // content rect is authoritative and origin-anchored, so it — not the cache's + // possibly-stale `entry.rect` — drives the bake. This matters on redo: a + // re-bake after undo finds the entry still carrying the PREVIOUS paint-bake + // extent (an off-origin rect), and `getOrCreateRect` deliberately never + // resizes an existing entry, so we reset it here before re-rasterizing. + const contentRect = getSourceContentRect(liveLayer, liveDoc); + const entry = layerCache.getOrCreateRect(layerId, contentRect); + entry.rect = contentRect; + entry.stale = true; + // The shape/gradient/text rasterizers draw synchronously (they resolve + // immediately), so the target surface is populated the moment the call + // returns despite the async signature; the deferred `.then` only reconciles + // `entry.rect` (a no-op for these origin-anchored parametric rects). + void rasterizeSource(liveSource, rasterizeDeps(liveDoc), entry.surface).then((r) => { + entry.rect = r.rect; + }); + entry.stale = false; + + // Bake the parametric pixels through the layer transform into a surface sized + // to the TRANSFORMED CONTENT bounds (content-sized, not document-clipped). The + // layer transform resets to identity, so this doc-space rect becomes the new + // paint layer's local content rect + persisted offset. + const matrix = bakeMatrix(liveLayer.transform); + const bakedRect = roundOut(transformBounds(matrix, contentRect)); + const baked = backend.createSurface(bakedRect.width, bakedRect.height); + const bctx = baked.ctx; + bctx.setTransform(1, 0, 0, 1, 0, 0); + bctx.clearRect(0, 0, bakedRect.width, bakedRect.height); + bctx.imageSmoothingEnabled = true; + // Draw in baked-local space (subtract the baked origin from the bake matrix), + // placing the parametric surface at its content origin. + bctx.setTransform(matrix.a, matrix.b, matrix.c, matrix.d, matrix.e - bakedRect.x, matrix.f - bakedRect.y); + bctx.drawImage(entry.surface.canvas, contentRect.x, contentRect.y); + bctx.setTransform(1, 0, 0, 1, 0, 0); + + const paintLayer: CanvasLayerContract = { + ...liveLayer, + source: { bitmap: null, offset: { x: bakedRect.x, y: bakedRect.y }, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + }; + store.dispatch({ id: layerId, layer: paintLayer, targetType: 'raster', type: 'convertCanvasLayer' }); + + // AFTER the convert dispatch (and the synchronous mirror invalidation it + // triggers): replace the cache with a fresh content-sized surface holding the + // baked pixels, placed at `bakedRect`, shielded from the async rasterize pass. + layerCache.delete(layerId); + const target = layerCache.getOrCreateRect(layerId, bakedRect); + target.surface.ctx.drawImage(baked.canvas, 0, 0); + target.stale = false; + notifyLayerPainted(layerId); + bitmapStore.markLayerDirty(layerId); + }; + + applyForward(); + history.push({ + // Tiny: the entry holds only the (pixel-free) parametric layer clone plus + // closures — redo re-bakes from params, so no doc-sized surface is pinned. + bytes: 256, + label: 'Rasterize layer', + redo: applyForward, + // Re-convert to the parametric source; the mirror re-rasterizes it from + // params (no pixel snapshot needed). + undo: () => + store.dispatch({ id: layerId, layer: parametricLayer, targetType: 'raster', type: 'convertCanvasLayer' }), + }); + return true; + }; + + const commitStructural = (label: string, forward: WorkbenchAction, inverse: WorkbenchAction): void => { + // No-op while a pointer gesture is in progress. A structural edit injected + // mid-stroke (a duplicate/delete/reorder hotkey mashed during a paint drag) + // would replace the layer under the open session and interleave a history + // entry inside the gesture. The move/bbox tools commit on pointer-UP, by + // which point the pipeline has already cleared the gesture flag (it is reset + // before the active tool's `onPointerUp` runs), so their commits still land. + if (pipeline.isGestureActive()) { + return; + } + endNudgeBurst(); + store.dispatch(forward); + history.push( + createDocumentPatchEntry({ + dispatch: (action) => store.dispatch(action), + forward, + inverse, + label, + }) + ); + }; + + /** + * Nudges the selected layer by `(dx, dy)` document pixels as a structural, + * undoable edit. A no-op with no selection or a locked/hidden selected layer. + * A rapid burst of nudges on the same layer (within {@link NUDGE_COALESCE_MS}) + * coalesces into a single history entry whose inverse restores the original + * position — so a burst undoes in one step, not one press at a time. + */ + const nudgeSelectedLayer = (dx: number, dy: number): void => { + // No-op mid-gesture: an arrow-key nudge during a paint drag would dispatch a + // structural transform under the open stroke session (feeding the cache-wipe + // path) and interleave a history entry inside the gesture. The pipeline + // reports gesture state; while a stroke is in progress this is a no-op. + if (pipeline.isGestureActive()) { + return; + } + const doc = mirror.getDocument(); + if (!doc || !doc.selectedLayerId) { + return; + } + const layer = doc.layers.find((candidate) => candidate.id === doc.selectedLayerId); + if (!layer || layer.isLocked || !layer.isEnabled) { + return; + } + const oldX = layer.transform.x; + const oldY = layer.transform.y; + const next = { x: oldX + dx, y: oldY + dy }; + + const now = Date.now(); + const coalesce = !!nudgeBurst && nudgeBurst.layerId === layer.id && now < nudgeBurst.expiresAt; + const origin = coalesce && nudgeBurst ? nudgeBurst.origin : { x: oldX, y: oldY }; + + const forward: WorkbenchAction = { + id: layer.id, + patch: { transform: { x: next.x, y: next.y } }, + type: 'updateCanvasLayer', + }; + const inverse: WorkbenchAction = { + id: layer.id, + patch: { transform: { x: origin.x, y: origin.y } }, + type: 'updateCanvasLayer', + }; + store.dispatch(forward); + const entry = createDocumentPatchEntry({ + dispatch: (action) => store.dispatch(action), + forward, + inverse, + label: 'Nudge layer', + }); + if (coalesce) { + history.amendLast(entry); + } else { + history.push(entry); + } + nudgeBurst = { expiresAt: now + NUDGE_COALESCE_MS, layerId: layer.id, origin }; + }; + + // ---- Transform session -------------------------------------------------- + // + // The transform tool opens a session on one layer (start/live transform in + // `stores.transformSession`, preview via `transformOverrides`) that outlives + // individual pointer gestures. Apply commits — a param edit for image layers, + // a pixel bake for paint layers — as ONE undoable entry; Cancel drops the + // preview. The transform tool drives begin/update/cancel through the tool + // context; React (numeric bar + Apply/Cancel buttons) drives the public API. + + const transformUnchanged = (a: LayerTransform, b: LayerTransform): boolean => + a.x === b.x && a.y === b.y && a.scaleX === b.scaleX && a.scaleY === b.scaleY && a.rotation === b.rotation; + + /** Drops the active session's preview override without clearing the session store. */ + const clearSessionOverride = (): void => { + const session = stores.transformSession.get(); + if (session) { + transformOverrides.delete(session.layerId); + } + }; + + const beginTransformSession = (layerId: string): void => { + const doc = mirror.getDocument(); + const layer = doc?.layers.find((candidate) => candidate.id === layerId); + if (!doc || !layer || !layer.isEnabled || layer.isLocked || !hittableLayerSize(layer, doc)) { + // Ineligible target: leave any existing session untouched. + return; + } + // Switching layers: drop the outgoing layer's preview first. + clearSessionOverride(); + const start: LayerTransform = { ...layer.transform }; + stores.transformSession.set({ layerId, startTransform: start, transform: start }); + transformOverrides.set(layerId, start); + scheduler.invalidate({ layers: [layerId], overlay: true }); + }; + + const updateTransformSession = (transform: LayerTransform): void => { + const session = stores.transformSession.get(); + if (!session) { + return; + } + stores.transformSession.set({ ...session, transform }); + transformOverrides.set(session.layerId, transform); + scheduler.invalidate({ layers: [session.layerId], overlay: true }); + }; + + const cancelTransform = (): void => { + const session = stores.transformSession.get(); + if (!session) { + return; + } + transformOverrides.delete(session.layerId); + stores.transformSession.set(null); + scheduler.invalidate({ layers: [session.layerId], overlay: true }); + }; + + /** + * A composed history entry for a paint-layer transform bake: undo restores the + * OLD pixels (at the OLD content rect) AND the OLD transform; redo re-applies the + * baked pixels (at the baked content rect) and the identity transform. Because + * the pre- and post-bake caches occupy DIFFERENT rects, pixel writes go through + * {@link restoreLayerCache} (whole-cache swap), not the grow-and-overlay + * `applyImagePatch`. + */ + const createTransformBakeEntry = ( + layerId: string, + beforeRect: Rect, + before: ImageData, + afterRect: Rect, + after: ImageData, + oldTransform: LayerTransform, + newTransform: LayerTransform, + label: string + ): HistoryEntry => ({ + bytes: before.data.byteLength + after.data.byteLength + 256, + label, + redo: () => { + store.dispatch({ id: layerId, patch: { transform: newTransform }, type: 'updateCanvasLayer' }); + restoreLayerCache(layerId, afterRect, after); + }, + undo: () => { + store.dispatch({ id: layerId, patch: { transform: oldTransform }, type: 'updateCanvasLayer' }); + restoreLayerCache(layerId, beforeRect, before); + }, + }); + + const applyTransform = (): void => { + // No-op mid-gesture (guarded like commitStructural/merge): apply writes a + // structural transform (and, for paint, pixels) that must not interleave with + // an open pointer session. Apply lands on Enter/button, after pointer-up. + if (pipeline.isGestureActive()) { + return; + } + const session = stores.transformSession.get(); + if (!session) { + return; + } + const doc = mirror.getDocument(); + const layer = doc?.layers.find((candidate) => candidate.id === session.layerId); + const size = doc && layer ? hittableLayerSize(layer, doc) : null; + // Ineligible now (removed / locked / unrenderable), or an unchanged transform: + // drop the session with no commit. + if ( + !doc || + !layer || + !size || + !isRenderableLayer(layer) || + layer.isLocked || + transformUnchanged(session.transform, session.startTransform) + ) { + cancelTransform(); + return; + } + const source = layer.type === 'raster' || layer.type === 'control' ? layer.source : null; + + // Parametric-or-image sources commit the transform as a param edit: the + // source stays parametric (editable-forever), the compositor applies the new + // transform at draw time. Only paint layers bake pixels (below). CANVAS_PLAN + // Phase 5: "bake for paint, param for parametric". + if ( + source?.type === 'image' || + source?.type === 'shape' || + source?.type === 'gradient' || + source?.type === 'text' + ) { + // Param commit: ONE structural entry with the new/old transform. No pixels. + endNudgeBurst(); + transformOverrides.delete(session.layerId); + stores.transformSession.set(null); + const forward: WorkbenchAction = { + id: session.layerId, + patch: { transform: session.transform }, + type: 'updateCanvasLayer', + }; + const inverse: WorkbenchAction = { + id: session.layerId, + patch: { transform: session.startTransform }, + type: 'updateCanvasLayer', + }; + store.dispatch(forward); + history.push( + createDocumentPatchEntry({ + dispatch: (action) => store.dispatch(action), + forward, + inverse, + label: 'Transform layer', + }) + ); + scheduler.invalidate({ layers: [session.layerId], overlay: true }); + return; + } + + if (source?.type !== 'paint') { + cancelTransform(); + return; + } + + // Bake for paint: render the old cache through the final matrix into a NEW + // surface sized to the TRANSFORMED CONTENT bounds (no document clipping — the + // Task-26 carryover fix), swap the cache, and reset the layer transform to + // identity — pixels + transform composed into one history entry, persisted + // through the normal bitmap-store dirty path (mirrors mergeLayerDown). + const cache = layerCache.get(layer.id); + if (!cache || isEmpty(cache.rect)) { + cancelTransform(); + return; + } + endNudgeBurst(); + + const beforeRect: Rect = { ...cache.rect }; + const before = cache.surface.ctx.getImageData(0, 0, beforeRect.width, beforeRect.height); + + // The transformed content bounds in document space become the baked paint + // layer's local content rect (the transform resets to identity below). + const matrix = bakeMatrix(session.transform); + const afterRect = roundOut(transformBounds(matrix, beforeRect)); + + const baked = backend.createSurface(afterRect.width, afterRect.height); + const bctx = baked.ctx; + bctx.setTransform(1, 0, 0, 1, 0, 0); + bctx.clearRect(0, 0, afterRect.width, afterRect.height); + // The bake is a one-shot resample onto the layer's permanent pixels — always + // smooth it explicitly (quality over per-frame cost; no zoom to key off). + bctx.imageSmoothingEnabled = true; + // Draw in baked-local space: subtract the baked origin from the bake matrix and + // place the cache surface at its content origin. + bctx.setTransform(matrix.a, matrix.b, matrix.c, matrix.d, matrix.e - afterRect.x, matrix.f - afterRect.y); + bctx.drawImage(cache.surface.canvas, beforeRect.x, beforeRect.y); + bctx.setTransform(1, 0, 0, 1, 0, 0); + const after = baked.ctx.getImageData(0, 0, afterRect.width, afterRect.height); + + const oldTransform: LayerTransform = { ...session.startTransform }; + const identityTransform: LayerTransform = { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }; + + // Clear the preview before the structural dispatch so the composite shows the + // reset transform + baked pixels (which are equal to the preview anyway). + transformOverrides.delete(layer.id); + stores.transformSession.set(null); + + // Reset the transform in the reducer (pixel-free), then swap the cache to the + // baked, content-sized surface placed at the baked rect. + store.dispatch({ id: layer.id, patch: { transform: identityTransform }, type: 'updateCanvasLayer' }); + layerCache.delete(layer.id); + const target = layerCache.getOrCreateRect(layer.id, afterRect); + target.surface.ctx.drawImage(baked.canvas, 0, 0); + target.stale = false; + notifyLayerPainted(layer.id); + bitmapStore.markLayerDirty(layer.id); + + history.push( + createTransformBakeEntry( + layer.id, + beforeRect, + before, + afterRect, + after, + oldTransform, + identityTransform, + 'Transform layer' + ) + ); + }; + + // ---- Text editing session ----------------------------------------------- + // + // The text tool opens a session (create or edit) exposed through + // `stores.textEditSession`; React renders a contenteditable portal over it and + // drives the commit (blur / mod+enter) — the engine never sees per-keystroke + // content, so commit takes the final content from React. ONE commit per close: + // create → `addCanvasLayer` (inverse removes), edit → `updateCanvasLayerSource` + // (exact inverse). A no-change / empty-create commit dispatches nothing (cancel + // semantics). The options bar restyles the live session via `updateTextEditStyle`. + + let textSessionId = 0; + + // A getter the React portal registers (in its ref callback) so the engine can + // read the live contenteditable text at commit time WITHOUT per-keystroke + // traffic. `null` when no portal is mounted (e.g. node tests inject their own). + let textContentReader: (() => string) | null = null; + + const setTextEditContentReader = (reader: (() => string) | null): void => { + textContentReader = reader; + }; + + const textSourceEqual = (a: TextSource, b: TextSource): boolean => + a.content === b.content && + a.fontFamily === b.fontFamily && + a.fontSize === b.fontSize && + a.fontWeight === b.fontWeight && + a.lineHeight === b.lineHeight && + a.align === b.align && + a.color === b.color; + + /** Builds a text source from the given content + the current text options. */ + const textSourceFromOptions = (content: string): TextSource => { + const options = stores.textOptions.get(); + return { + align: options.align, + color: options.color, + content, + fontFamily: options.fontFamily, + fontSize: options.fontSize, + fontWeight: options.fontWeight, + lineHeight: options.lineHeight, + type: 'text', + }; + }; + + const openTextCreate = (docPoint: Vec2): void => { + if (!mirror.getDocument()) { + return; + } + stores.textEditSession.set({ + id: ++textSessionId, + layerId: null, + mode: 'create', + source: textSourceFromOptions(''), + startSource: null, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: Math.round(docPoint.x), y: Math.round(docPoint.y) }, + }); + // Create mode adds no layer yet, so the composite is unchanged; the portal + // draws over it. Refresh the overlay for the tool cursor. + scheduler.invalidate({ overlay: true }); + }; + + const openTextEdit = (layerId: string): void => { + const doc = mirror.getDocument(); + const layer = doc?.layers.find((candidate) => candidate.id === layerId); + if ( + !doc || + !layer || + layer.type !== 'raster' || + layer.source.type !== 'text' || + !layer.isEnabled || + layer.isLocked + ) { + return; + } + stores.textEditSession.set({ + id: ++textSessionId, + layerId, + mode: 'edit', + source: { ...layer.source }, + startSource: { ...layer.source }, + transform: { ...layer.transform }, + }); + // Recomposite so the layer is skipped (the portal shows its live text). + scheduler.invalidate({ layers: [layerId] }); + }; + + const updateTextEditStyle = (patch: Partial): void => { + const session = stores.textEditSession.get(); + if (!session) { + return; + } + // The edit-mode layer is already skipped and create mode has no layer, so no + // composite change is needed — the portal restyles itself from the store. + stores.textEditSession.set({ ...session, source: { ...session.source, ...patch } }); + }; + + const cancelTextEdit = (): void => { + const session = stores.textEditSession.get(); + if (!session) { + return; + } + stores.textEditSession.set(null); + // Unskip: recompositing redraws an edit-mode layer's committed pixels. + if (session.layerId) { + scheduler.invalidate({ layers: [session.layerId] }); + } else { + scheduler.invalidate({ overlay: true }); + } + }; + + const commitTextEdit = (content: string, styleChanges?: Partial): void => { + // Defensive mid-gesture guard (commit is React-driven on blur/mod+enter, so + // this normally never fires mid-stroke): a structural dispatch must not + // interleave with an open pointer session. Mirrors commitStructural's guard. + if (pipeline.isGestureActive()) { + return; + } + const session = stores.textEditSession.get(); + if (!session) { + return; + } + const finalSource: TextSource = { ...session.source, ...styleChanges, content }; + + if (session.mode === 'create') { + if (content.trim() === '') { + // An empty create adds nothing (cancel semantics). + cancelTextEdit(); + return; + } + const doc = mirror.getDocument(); + const layerId = createLayerId(); + const layer: CanvasLayerContract = { + blendMode: 'normal', + id: layerId, + isEnabled: true, + isLocked: false, + name: `Text ${(doc?.layers.length ?? 0) + 1}`, + opacity: 1, + source: finalSource, + transform: session.transform, + type: 'raster', + }; + stores.textEditSession.set(null); + commitStructural( + 'Add text', + { index: 0, layer, type: 'addCanvasLayer' }, + { ids: [layerId], type: 'removeCanvasLayers' } + ); + scheduler.invalidate({ overlay: true }); + return; + } + + // Edit mode: ONE updateCanvasLayerSource with the exact inverse (the source + // captured at session open). No change → no dispatch (cancel semantics). + const layerId = session.layerId; + const before = session.startSource; + if (!layerId || !before) { + cancelTextEdit(); + return; + } + stores.textEditSession.set(null); + if (textSourceEqual(before, finalSource)) { + scheduler.invalidate({ layers: [layerId] }); + return; + } + commitStructural( + 'Edit text', + { id: layerId, source: finalSource, type: 'updateCanvasLayerSource' }, + { id: layerId, source: before, type: 'updateCanvasLayerSource' } + ); + }; + + /** + * Commits an open text-edit session, reading the live content from the portal's + * registered reader (falling back to the session's own content when none is + * registered — node tests, or a session with no mounted portal). Returns `true` + * when a session was open and committed, `false` otherwise. This is the + * engine-side, node-testable commit that a canvas pointerdown triggers (via the + * pointer pipeline's `maybeCommitModalSession`): the reviewer's "click elsewhere + * to commit" — the click's job is to close the open session, so the pipeline + * swallows it rather than painting or opening a second session. It runs BEFORE + * the pipeline sets `gestureActive`, so `commitTextEdit`'s mid-gesture guard + * cannot swallow it. One commit per session close is preserved: `commitTextEdit` + * clears the session, and the portal's `onBlur` re-commit then no-ops. + */ + const commitOpenTextSession = (): boolean => { + const session = stores.textEditSession.get(); + if (!session) { + return false; + } + const content = textContentReader ? textContentReader() : session.source.content; + commitTextEdit(content); + return true; + }; + + // ---- Selection public API ----------------------------------------------- + + /** + * The bounded domain selectAll/invert operate over now that the document rect is + * retired: `content ∪ bbox` — the same union `fitToView` fits. The bbox anchors + * an empty canvas; any renderable layer beyond it is unioned in. The closest + * coherent analogue of legacy's bounded canvas for the complement in `invert`. + */ + const selectionDomain = (): Rect | null => { + const doc = mirror.getDocument(); + if (!doc) { + return null; + } + let bounds: Rect = { ...doc.bbox }; + for (const layer of doc.layers) { + if (isRenderableLayer(layer)) { + bounds = union(bounds, getSourceBounds(layer, doc)); + } + } + return roundOut(bounds); + }; + + const selectAll = (): void => { + const rect = selectionDomain(); + if (rect) { + selection.selectAll(rect); + } + }; + + const deselect = (): void => { + selection.clear(); + }; + + const invertSelection = (): void => { + const rect = selectionDomain(); + if (rect) { + selection.invert(rect); + } + }; + + /** Resolves the selected paint layer eligible for a masked fill/erase, or `null`. */ + const selectionPaintTarget = (): { layerId: string; transparencyLocked: boolean } | null => { + const doc = mirror.getDocument(); + if (!doc || !doc.selectedLayerId) { + return null; + } + const layer = doc.layers.find((candidate) => candidate.id === doc.selectedLayerId); + // Paint layers only (image-source layers are a no-op this phase — rasterize + // comes with the next task); locked/hidden targets are refused. + if (!layer || layer.type !== 'raster' || layer.source.type !== 'paint' || layer.isLocked || !layer.isEnabled) { + return null; + } + return { layerId: layer.id, transparencyLocked: layer.isTransparencyLocked === true }; + }; + + /** + * Fills or erases the selection region on the selected paint layer, as ONE + * undoable image patch (mirrors a stroke: cache write + before/after history + + * bitmap-store persistence). A no-op mid-gesture, with no selection, or with an + * ineligible target. + * + * Content-sized: FILL may GROW the layer's cache to the selection bounds (a fill + * in empty space must appear); ERASE only touches existing pixels (its region is + * clamped to the layer's current content extent). The patch rect is layer-local + * (= document space for identity paint layers). + */ + const runMaskedSelectionEdit = (kind: 'fill' | 'erase'): void => { + if (pipeline.isGestureActive()) { + return; + } + const doc = mirror.getDocument(); + const placedMask = selection.mask(); + const bounds = selection.bounds(); + if (!doc || !placedMask || !bounds) { + return; + } + const target = selectionPaintTarget(); + if (!target) { + return; + } + // Transparency lock (mirrors the brush/eraser tool policy, paintTool.ts): an + // ERASE is refused outright (it would delete locked alpha), and a FILL is + // constrained to existing pixels via `source-atop` — colour lands only on + // already-opaque areas, never into transparent space. + if (kind === 'erase' && target.transparencyLocked) { + return; + } + const selRect = roundOut(bounds); + let rect: Rect | null; + let entry: ReturnType; + if (kind === 'fill' && !target.transparencyLocked) { + // Grow the cache to the selection bounds so a fill in empty space appears. + rect = selRect; + entry = layerCache.growToRect(target.layerId, selRect); + } else { + // Erase, OR a transparency-locked fill: both only affect EXISTING pixels, so + // clamp to the current content extent (never grow into empty space). + const existing = layerCache.get(target.layerId); + if (!existing || isEmpty(existing.rect)) { + return; + } + rect = intersect(selRect, existing.rect); + entry = existing; + } + if (!rect || isEmpty(rect)) { + return; + } + endNudgeBurst(); + const surface = entry.surface; + const origin = { x: entry.rect.x, y: entry.rect.y }; + const before = surface.ctx.getImageData(rect.x - origin.x, rect.y - origin.y, rect.width, rect.height); + if (kind === 'fill') { + fillMaskedRegion({ + backend, + color: stores.brushOptions.get().color, + // Transparency lock: colour only on existing pixels (never fill holes). + composite: target.transparencyLocked ? 'source-atop' : 'source-over', + mask: placedMask.surface, + maskOrigin: placedMask.rect, + rect, + target: surface, + targetOrigin: origin, + }); + } else { + eraseMaskedRegion({ + backend, + mask: placedMask.surface, + maskOrigin: placedMask.rect, + rect, + target: surface, + targetOrigin: origin, + }); + } + const after = surface.ctx.getImageData(rect.x - origin.x, rect.y - origin.y, rect.width, rect.height); + notifyLayerPainted(target.layerId); + bitmapStore.markLayerDirty(target.layerId); + if (!history.isApplying()) { + history.push( + createImagePatchEntry({ + after, + apply: applyImagePatch, + before, + label: kind === 'fill' ? 'Fill selection' : 'Erase selection', + layerId: target.layerId, + rect, + }) + ); + } + }; + + const fillSelection = (): void => runMaskedSelectionEdit('fill'); + const eraseSelection = (): void => runMaskedSelectionEdit('erase'); + + /** + * Inverts a mask layer's alpha in place, over the domain + * `content ∪ liveCacheRect ∪ bbox` (the same bounded-domain decision the + * selection invert uses: a bounded, meaningful region rather than the + * unbounded plane). The live cache rect is unioned in alongside the contract's + * persisted content rect because the latter lags the debounced bitmap-store + * flush — a stroke painted moments ago is already reflected in `layerCache` + * but not yet in `mask.bitmap`, so relying on content alone would silently + * exclude it (and any of its pixels outside the bbox) from the invert. + * Legacy parity (`hooks/useInvertMask.ts`): only the ALPHA channel is flipped + * (`a = 255 - a`); RGB is irrelevant since the compositor colorizes by alpha. + * ONE undoable image patch (pixel-exact round trip), persisted through the same + * dirty path as a stroke. A no-op mid-gesture, or when the layer is missing, not + * a mask, or locked/hidden. Returns whether it ran. + */ + const invertMask = (layerId: string): boolean => { + if (pipeline.isGestureActive()) { + return false; + } + const doc = mirror.getDocument(); + if (!doc) { + return false; + } + const layer = doc.layers.find((candidate) => candidate.id === layerId); + if (!layer || !isMaskLayer(layer) || layer.isLocked || !layer.isEnabled) { + return false; + } + // Refuse while the mask's cache is stale or its bitmap decode is in flight: + // inverting would read a blank/old surface (garbage `before` in history), and + // the in-flight `rasterizeSource` completion would then redraw the surface, + // erasing the invert entirely. A freshly-painted mask (live cache, not stale) + // and an empty mask (no persisted bitmap) both pass and invert correctly. + if (!isLayerCacheReadyForOp(layer, doc)) { + return false; + } + // The contract's `getSourceContentRect` reads the persisted `mask.bitmap`, + // which lags the live paint cache until the debounced flush runs — a stroke + // painted moments ago (still in-flight in `layerCache`, not yet flushed to the + // contract) would otherwise be silently excluded from the invert domain. + // Union in the live cache's rect (when present) so an un-flushed stroke that + // extends past the persisted content is still covered. + const content = getSourceContentRect(layer, doc); + const liveRect = layerCache.get(layerId)?.rect; + const contentUnion = + liveRect && !isEmpty(liveRect) ? (isEmpty(content) ? liveRect : union(content, liveRect)) : content; + // `content`/`liveRect` are LAYER-LOCAL (the cache's own space, which is what + // `getImageData` below reads); `doc.bbox` is DOCUMENT space. Unioning them + // directly inverts the wrong region on a moved/transformed mask. Convert the + // bbox into layer-local space (inverse of the layer transform, rotation-aware) + // before the union; identity transforms are unchanged. + const inverse = invertMatrix(bakeMatrix(layer.transform)); + const bbox = inverse ? roundOut(transformBounds(inverse, doc.bbox)) : doc.bbox; + const domain = roundOut(isEmpty(contentUnion) ? bbox : union(contentUnion, bbox)); + if (isEmpty(domain)) { + return false; + } + endNudgeBurst(); + // Grow the cache to the whole domain (content may be empty / smaller than the + // bbox) so the previously-transparent region flips to opaque coverage. + const entry = layerCache.growToRect(layerId, domain); + const origin = { x: entry.rect.x, y: entry.rect.y }; + const surfaceCtx = entry.surface.ctx; + const readX = domain.x - origin.x; + const readY = domain.y - origin.y; + const before = surfaceCtx.getImageData(readX, readY, domain.width, domain.height); + // A second read of the same (still-pristine) pixels to mutate + write back — + // avoids `new ImageData` (absent in the node test env). `work` becomes the + // patch's `after`: after `putImageData` its bytes are exactly the surface's. + const work = surfaceCtx.getImageData(readX, readY, domain.width, domain.height); + const data = work.data; + for (let i = 3; i < data.length; i += 4) { + data[i] = 255 - (data[i] ?? 0); + } + surfaceCtx.putImageData(work, readX, readY); + notifyLayerPainted(layerId); + bitmapStore.markLayerDirty(layerId); + if (!history.isApplying()) { + history.push( + createImagePatchEntry({ + after: work, + apply: applyImagePatch, + before, + label: 'Invert mask', + layerId, + rect: domain, + }) + ); + } + return true; + }; + + const dispose = (): void => { + if (disposed) { + return; + } + disposed = true; + detach(); + // Drop any open text-edit session (its layer belongs to a document this + // engine no longer serves). + stores.textEditSession.set(null); + // Drop any control-filter previews outright — the engine is going away, so + // there's no render loop left to invalidate for them. + filterPreviews.clear(); + filterPreviewTokens.clear(); + antsAnimator.stop(); + selection.dispose(); + activeTool()?.onDeactivate?.(toolContext); + unsubscribeViewport(); + unsubscribeBrushOptions(); + unsubscribeEraserOptions(); + unsubscribeCheckerboard(); + unsubscribeCheckerColors(); + unsubscribeShowGrid(); + unsubscribeBboxGrid(); + unsubscribeShowBbox(); + unsubscribeBboxOverlay(); + unsubscribeRuleOfThirds(); + unsubscribeHistory(); + history.clear(); + bitmapStore.dispose(); + mirror.dispose(); + scheduler.dispose(); + layerCache.dispose(); + adjustedSurfaceCache.dispose(); + trackedImageNames.clear(); + inFlight.clear(); + strokeListeners.clear(); + }; + + const onStrokeCommitted = (listener: (event: StrokeCommittedEvent) => void): (() => void) => { + strokeListeners.add(listener); + return () => { + strokeListeners.delete(listener); + }; + }; + + const clearCaches = async (): Promise => { + // Flush pending paint-bitmap uploads FIRST: an unflushed stroke lives only in + // the live `layerCache` until the debounced (1500ms) flush persists it. If we + // invalidated the cache before flushing, that in-flight stroke would be + // destroyed — the next composite re-rasterizes from the (older) source. + await bitmapStore.flushPendingUploads(); + const doc = mirror.getDocument(); + // Invalidate (mark stale → re-rasterize) every live layer cache and drop its + // memoized adjusted surface; the next composite rebuilds them from source. + for (const layer of doc?.layers ?? []) { + layerCache.invalidate(layer.id); + adjustedSurfaceCache.delete(layer.id); + } + // Drop the derived pattern tiles so they rebuild from the current fed colors. + checkerboardTile = null; + maskPatternTiles.clear(); + scheduler.invalidate({ all: true }); + }; + + const clearHistory = (): void => { + // `history.clear` notifies subscribers, so `syncHistoryStores` refreshes + // canUndo/canRedo — the header buttons disable in lockstep. + endNudgeBurst(); + history.clear(); + }; + + const logDebugInfo = (): void => { + const doc = mirror.getDocument(); + // eslint-disable-next-line no-console + console.info('[canvas-engine] debug info', { + activeTool: activeToolId, + bbox: doc?.bbox ?? null, + canRedo: history.canRedo(), + canUndo: history.canUndo(), + document: doc ? { height: doc.height, layers: doc.layers.length, width: doc.width } : null, + hasSelection: selection.hasSelection(), + projectId, + selectedLayerId: doc?.selectedLayerId ?? null, + zoom: viewport.getZoom(), + }); + }; + + const contextMenuLayerIdAt = (screenPoint: Vec2): string | null => { + // Never open the menu over an in-progress edit: a live paint/drag gesture, or + // an open transform / text-edit session. Right-click during those belongs to + // the interaction, not to picking a layer. Mirrors the mid-gesture guards on + // merge/nudge/undo above. + if (pipeline.isGestureActive() || stores.transformSession.get() || stores.textEditSession.get()) { + return null; + } + const doc = mirror.getDocument(); + if (!doc) { + return null; + } + // Same screen→document conversion and group-rank-consistent, live-cache-aware + // hit-test the move tool uses for click-selection, so right-clicking a layer + // targets exactly the layer a left-click there would select. + const documentPoint = viewport.screenToDocument(screenPoint); + const hit = topLayerAt( + doc, + documentPoint, + (layer) => layer.isEnabled, + (layerId) => layerCache.get(layerId)?.rect + ); + return hit?.id ?? null; + }; + + return { + applyTransform, + clearCaches, + clearHistory, + contextMenuLayerIdAt, + logDebugInfo, + attach, + cancelTextEdit, + cancelTransform, + commitOpenTextSession, + commitStructural, + commitTextEdit, + deselect, + detach, + dispose, + drawLayerThumbnail, + eraseSelection, + exportRasterLayersToPsd, + fillSelection, + fitToView, + flushPendingUploads: () => bitmapStore.flushPendingUploads(), + getCompositeExecutorDeps: () => ({ + backend, + getLayerSurface: getLayerSurfaceForExport, + uploadImage: (blob) => uploadCanvasImage(blob, { isIntermediate: true }), + }), + getDocument: () => mirror.getDocument(), + handleEscapePriority, + invertMask, + invertSelection, + mergeLayerDown, + mergeVisibleRasterLayers, + openTextCreate, + openTextEdit, + rasterizeLayer, + setTextEditContentReader, + updateTextEditStyle, + getViewport: () => viewport, + onStrokeCommitted, + projectId, + selectAll, + // Guarded against firing mid-stroke: an undo/redo during a live gesture would + // put pixels under the open session, and the eventual commit would record a + // before/after straddling the injected pixels. The pipeline reports gesture + // state; while a stroke is in progress these are no-ops. + nudgeSelectedLayer, + redo: () => { + if (pipeline.isGestureActive()) { + return; + } + endNudgeBurst(); + history.redo(); + }, + resize, + setBboxGrid: (size) => stores.bboxGrid.set(size > 0 ? size : 1), + setFilterPreview, + setStagedPreview, + setTool, + stepBrushSize: stepActiveBrushSize, + stores, + undo: () => { + if (pipeline.isGestureActive()) { + return; + } + endNudgeBurst(); + history.undo(); + }, + updateTransformSession, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/engineRegistry.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/engineRegistry.test.ts new file mode 100644 index 00000000000..bf96f28d863 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/engineRegistry.test.ts @@ -0,0 +1,113 @@ +import type { WorkbenchState } from '@workbench/types'; + +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { describe, expect, it, vi } from 'vitest'; + +import type { EngineDeps, RegistryTimers } from './engineRegistry'; + +import { createEngineRegistry } from './engineRegistry'; + +const createFakeDeps = (): EngineDeps => ({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + store: { + dispatch: () => {}, + getState: () => ({ projects: [] }) as unknown as WorkbenchState, + subscribe: () => () => {}, + }, +}); + +const createFakeTimers = (): { timers: RegistryTimers; flush: () => void; pending: () => number } => { + let nextHandle = 0; + const scheduled = new Map void>(); + return { + flush: () => { + const callbacks = [...scheduled.values()]; + scheduled.clear(); + for (const callback of callbacks) { + callback(); + } + }, + pending: () => scheduled.size, + timers: { + clearTimeout: (handle) => { + scheduled.delete(handle); + }, + setTimeout: (handler) => { + nextHandle += 1; + scheduled.set(nextHandle, handler); + return nextHandle; + }, + }, + }; +}; + +describe('createEngineRegistry', () => { + it('returns the same instance per project id and distinct instances across ids', () => { + const registry = createEngineRegistry(); + const deps = createFakeDeps(); + + const first = registry.getOrCreateEngine('p1', deps); + const again = registry.getOrCreateEngine('p1', deps); + const other = registry.getOrCreateEngine('p2', deps); + + expect(again).toBe(first); + expect(other).not.toBe(first); + expect(registry.getEngine('p1')).toBe(first); + + first.dispose(); + other.dispose(); + }); + + it('disposes after the grace period once the last reference is released', () => { + const fakeTimers = createFakeTimers(); + const registry = createEngineRegistry({ gracePeriodMs: 30_000, timers: fakeTimers.timers }); + const engine = registry.getOrCreateEngine('p1', createFakeDeps()); + const disposeSpy = vi.spyOn(engine, 'dispose'); + + registry.releaseEngine('p1'); + expect(fakeTimers.pending()).toBe(1); + expect(disposeSpy).not.toHaveBeenCalled(); + + fakeTimers.flush(); + expect(disposeSpy).toHaveBeenCalledTimes(1); + expect(registry.getEngine('p1')).toBeUndefined(); + }); + + it('cancels the pending disposal when the engine is re-acquired', () => { + const fakeTimers = createFakeTimers(); + const registry = createEngineRegistry({ timers: fakeTimers.timers }); + const engine = registry.getOrCreateEngine('p1', createFakeDeps()); + const disposeSpy = vi.spyOn(engine, 'dispose'); + + registry.releaseEngine('p1'); + expect(fakeTimers.pending()).toBe(1); + + const reacquired = registry.getOrCreateEngine('p1', createFakeDeps()); + expect(reacquired).toBe(engine); + expect(fakeTimers.pending()).toBe(0); + + fakeTimers.flush(); + expect(disposeSpy).not.toHaveBeenCalled(); + + engine.dispose(); + }); + + it('only schedules disposal when the last reference is released', () => { + const fakeTimers = createFakeTimers(); + const registry = createEngineRegistry({ timers: fakeTimers.timers }); + const deps = createFakeDeps(); + + registry.getOrCreateEngine('p1', deps); + registry.getOrCreateEngine('p1', deps); + + registry.releaseEngine('p1'); + expect(fakeTimers.pending()).toBe(0); + + registry.releaseEngine('p1'); + expect(fakeTimers.pending()).toBe(1); + + fakeTimers.flush(); + expect(registry.getEngine('p1')).toBeUndefined(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/engineRegistry.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/engineRegistry.ts new file mode 100644 index 00000000000..5f5ba1b3723 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/engineRegistry.ts @@ -0,0 +1,105 @@ +/** + * The engine registry: one {@link CanvasEngine} per project, shared by the + * canvas and layers widgets (and any other surface that needs the same live + * document/pixels). + * + * Acquisition is reference-counted: `getOrCreateEngine` hands out (or creates) + * the instance and bumps the count; `releaseEngine` drops it. When the count + * reaches zero the engine is not disposed immediately — a grace-period timer + * runs first, so a quick unmount/remount (route change, widget re-layout) + * re-acquires the same warm instance instead of paying to rebuild it. A + * re-acquire cancels the pending disposal. The timer is injectable so tests can + * drive it deterministically. + * + * Zero React, zero import-time side effects. + */ + +import { createCanvasEngine, type CanvasEngine, type CanvasEngineOptions } from '@workbench/canvas-engine/engine'; + +/** Engine creation dependencies, minus the project id the registry supplies. */ +export type EngineDeps = Omit; + +/** Default grace period before a released engine is disposed (30s). */ +export const DEFAULT_GRACE_PERIOD_MS = 30_000; + +/** Injectable timer seam (defaults to the global timers). */ +export interface RegistryTimers { + setTimeout(handler: () => void, ms: number): number; + clearTimeout(handle: number): void; +} + +/** The registry handle. */ +export interface EngineRegistry { + /** Returns the engine for `projectId`, creating it if needed, and adds a reference. */ + getOrCreateEngine(projectId: string, deps: EngineDeps): CanvasEngine; + /** Returns the engine for `projectId` without changing its reference count. */ + getEngine(projectId: string): CanvasEngine | undefined; + /** Drops a reference; schedules grace-period disposal when the last reference is released. */ + releaseEngine(projectId: string): void; +} + +interface RegistryEntry { + engine: CanvasEngine; + refCount: number; + disposeHandle: number | null; +} + +const defaultTimers: RegistryTimers = { + clearTimeout: (handle) => globalThis.clearTimeout(handle), + setTimeout: (handler, ms) => globalThis.setTimeout(handler, ms), +}; + +/** Creates an engine registry with an optional grace period and injectable timers. */ +export const createEngineRegistry = ( + options: { + gracePeriodMs?: number; + timers?: RegistryTimers; + } = {} +): EngineRegistry => { + const gracePeriodMs = options.gracePeriodMs ?? DEFAULT_GRACE_PERIOD_MS; + const timers = options.timers ?? defaultTimers; + const entries = new Map(); + + const cancelDisposal = (entry: RegistryEntry): void => { + if (entry.disposeHandle !== null) { + timers.clearTimeout(entry.disposeHandle); + entry.disposeHandle = null; + } + }; + + return { + getEngine: (projectId) => entries.get(projectId)?.engine, + getOrCreateEngine: (projectId, deps) => { + const existing = entries.get(projectId); + if (existing) { + cancelDisposal(existing); + existing.refCount += 1; + return existing.engine; + } + const engine = createCanvasEngine({ projectId, ...deps }); + entries.set(projectId, { disposeHandle: null, engine, refCount: 1 }); + return engine; + }, + releaseEngine: (projectId) => { + const entry = entries.get(projectId); + if (!entry) { + return; + } + entry.refCount = Math.max(0, entry.refCount - 1); + if (entry.refCount > 0 || entry.disposeHandle !== null) { + return; + } + entry.disposeHandle = timers.setTimeout(() => { + entries.delete(projectId); + entry.engine.dispose(); + }, gracePeriodMs); + }, + }; +}; + +/** The process-wide default registry shared by all widget surfaces. */ +const defaultRegistry = createEngineRegistry(); + +export const getOrCreateEngine = defaultRegistry.getOrCreateEngine; +export const getEngine = defaultRegistry.getEngine; +export const releaseEngine = defaultRegistry.releaseEngine; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/engineStores.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/engineStores.ts new file mode 100644 index 00000000000..e67ed1b36f9 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/engineStores.ts @@ -0,0 +1,582 @@ +/** + * Per-engine transient external stores. + * + * These are the narrow, imperative channels React subscribes to (in the widget + * task) to observe engine-owned interaction state — active tool, zoom, + * readiness, cursor, and per-layer thumbnail versions — without the engine ever + * importing React. They follow the `externalStore.ts` pattern (a listener + * channel plus a snapshot getter, `useSyncExternalStore`-compatible) but + * deliberately do NOT import it: `externalStore.ts` pulls in React, and this + * module must stay node-safe and React-free. The React hooks live with the + * widget shell. + * + * Zero React, zero import-time side effects. + */ + +import type { LayerTransform } from '@workbench/canvas-engine/transform/transformMath'; +import type { Rect, SelectionOp, ToolId, Vec2 } from '@workbench/canvas-engine/types'; +import type { CanvasLayerSourceContract } from '@workbench/types'; + +import { type CheckerColors, DEFAULT_CHECKER_COLORS } from '@workbench/canvas-engine/render/compositor'; + +export type { CheckerColors }; + +/** A `text` layer source (content + style params). */ +export type TextSource = Extract; + +/** Brush tool options (document-space size, style, and pressure behavior). */ +export interface BrushOptions { + /** Base stroke diameter in document units. */ + size: number; + /** Fill color (any CSS color string). */ + color: string; + /** Per-stroke opacity in [0, 1]. */ + opacity: number; + /** Whether pen pressure modulates the stroke width. */ + pressureSensitivity: boolean; +} + +/** Eraser tool options. */ +export interface EraserOptions { + /** Base eraser diameter in document units. */ + size: number; + /** Per-stroke erase strength in [0, 1]. */ + opacity: number; +} + +/** Lasso (selection) tool options: the boolean op applied when a path commits. */ +export interface LassoToolOptions { + /** The op a committed lasso path applies to the selection, when no modifier overrides it. */ + mode: SelectionOp; +} + +/** Default lasso options: a fresh path replaces the selection. */ +export const DEFAULT_LASSO_OPTIONS: LassoToolOptions = { + mode: 'replace', +}; + +/** A single gradient stop (offset in [0,1], any CSS color). */ +export interface GradientStop { + offset: number; + color: string; +} + +/** + * Shape tool options: the kind drawn on the next drag, and the fill/stroke + * style applied to it (and, when a shape layer is selected, edited live on it). + * `fill`/`stroke` are `null` for "none". + */ +export interface ShapeToolOptions { + kind: 'rect' | 'ellipse'; + fill: string | null; + stroke: string | null; + strokeWidth: number; +} + +/** Sensible starting shape options: a filled black rect, no stroke. */ +export const DEFAULT_SHAPE_OPTIONS: ShapeToolOptions = { + fill: '#000000', + kind: 'rect', + stroke: null, + strokeWidth: 8, +}; + +/** Largest shape stroke width (document px) the options bar clamps to. */ +export const MAX_SHAPE_STROKE_WIDTH = 2000; + +/** + * Gradient tool options: the kind, the linear angle (degrees), and the stops. + * The minimal two-stop editor edits `stops[0]` (start) and the last stop (end); + * a full multi-stop editor is a follow-up. + */ +export interface GradientToolOptions { + kind: 'linear' | 'radial'; + angle: number; + stops: GradientStop[]; +} + +/** Sensible starting gradient options: black→transparent, horizontal linear. */ +export const DEFAULT_GRADIENT_OPTIONS: GradientToolOptions = { + angle: 0, + kind: 'linear', + stops: [ + { color: '#000000ff', offset: 0 }, + { color: '#00000000', offset: 1 }, + ], +}; + +/** The text style the text tool applies to a newly created layer (and edits live). */ +export interface TextToolOptions { + fontFamily: string; + fontSize: number; + /** CSS numeric weight (400/500/600/700). */ + fontWeight: number; + /** Unitless line-height multiplier over `fontSize`. */ + lineHeight: number; + align: 'left' | 'center' | 'right'; + color: string; +} + +/** + * A small curated font list offered by the text options bar. Values are CSS + * `font-family` stacks so each falls back gracefully; keep it short this phase + * (a full system-font enumeration is a follow-up). + */ +export const TEXT_FONT_FAMILIES: readonly { label: string; value: string }[] = [ + { label: 'Inter', value: "'Inter Variable', Inter, sans-serif" }, + { label: 'Sans-serif', value: 'system-ui, sans-serif' }, + { label: 'Serif', value: "Georgia, 'Times New Roman', serif" }, + { label: 'Monospace', value: "'JetBrains Mono', ui-monospace, monospace" }, +]; + +/** Weights the text options bar offers. */ +export const TEXT_FONT_WEIGHTS: readonly number[] = [400, 500, 600, 700]; + +/** Smallest / largest font size (document px) the text options bar clamps to. */ +export const MIN_TEXT_FONT_SIZE = 1; +export const MAX_TEXT_FONT_SIZE = 2000; + +/** Sensible starting text options: black left-aligned Inter at 48px. */ +export const DEFAULT_TEXT_OPTIONS: TextToolOptions = { + align: 'left', + color: '#000000', + fontFamily: TEXT_FONT_FAMILIES[0]!.value, + fontSize: 48, + fontWeight: 400, + lineHeight: 1.2, +}; + +/** Bbox (generation-frame) tool options: the aspect-ratio lock. */ +export interface BboxToolOptions { + /** Whether corner/edge resize preserves {@link BboxToolOptions.aspectRatio}. */ + aspectLocked: boolean; + /** The locked width / height ratio. */ + aspectRatio: number; +} + +/** Default grid size (document px) the bbox snaps to before a model feeds a real one. */ +export const DEFAULT_BBOX_GRID = 8; + +/** Default bbox tool options: aspect unlocked, square ratio. */ +export const DEFAULT_BBOX_OPTIONS: BboxToolOptions = { + aspectLocked: false, + aspectRatio: 1, +}; + +/** Smallest and largest brush/eraser diameters (document units) the size step clamps to. */ +export const MIN_BRUSH_SIZE = 1; +export const MAX_BRUSH_SIZE = 2000; + +/** Sensible starting brush options. */ +export const DEFAULT_BRUSH_OPTIONS: BrushOptions = { + color: '#000000', + opacity: 1, + pressureSensitivity: true, + size: 50, +}; + +/** Sensible starting eraser options. */ +export const DEFAULT_ERASER_OPTIONS: EraserOptions = { + opacity: 1, + size: 50, +}; + +/** + * An active transform-tool session on one layer. Outlives individual pointer + * gestures (drag handles, adjust numerics) until Apply or Cancel. `startTransform` + * is the committed transform captured at session start (restored on Cancel / + * used as the undo inverse); `transform` is the live, edited transform the + * compositor previews and the options bar renders as numerics. + */ +export interface TransformSession { + layerId: string; + startTransform: LayerTransform; + transform: LayerTransform; +} + +/** + * An active text-editing session. Set by the text tool; while it is active the + * contenteditable portal (in `widgets/canvas`) shows the live text and the + * compositor SKIPS the session's layer (edit mode) so the two don't double-draw. + * + * Two modes: + * - **create**: no layer exists yet (`layerId === null`, `startSource === null`). + * Commit dispatches ONE `addCanvasLayer` with the final content; cancel adds + * nothing. This keeps a new text layer to a single, cleanly-undoable commit. + * - **edit**: an existing text layer is being re-edited. `startSource` is its + * committed source (the exact undo inverse / no-change baseline); `source` is + * the live, style-edited source. Commit dispatches ONE `updateCanvasLayerSource`. + * + * `source` carries the live style (font/size/weight/lineHeight/align/color) the + * portal renders WYSIWYG and the options bar edits; `content` on it is only the + * seed — the live typed content lives in the contenteditable DOM until commit. + * `transform` positions/scales the portal (document→screen via the view matrix). + * `id` increments per session so React can key (remount) the editable per open. + */ +export interface TextEditSession { + id: number; + mode: 'create' | 'edit'; + layerId: string | null; + startSource: TextSource | null; + source: TextSource; + transform: LayerTransform; +} + +/** A single-value store, `useSyncExternalStore`-compatible. */ +export interface ScalarStore { + get(): T; + set(next: T): void; + subscribe(listener: () => void): () => void; +} + +const createScalarStore = (initial: T, isEqual: (a: T, b: T) => boolean = Object.is): ScalarStore => { + let value = initial; + const listeners = new Set<() => void>(); + + return { + get: () => value, + set: (next) => { + if (isEqual(value, next)) { + return; + } + value = next; + for (const listener of listeners) { + listener(); + } + }, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +}; + +/** + * A keyed numeric store with per-key subscription granularity, so a React + * component watching one layer's thumbnail version only re-renders when that + * layer changes. A global `subscribe` is also exposed for coarse observers. + */ +export interface KeyedVersionStore { + get(key: string): number | undefined; + set(key: string, value: number): void; + delete(key: string): void; + /** Subscribes to changes for a single key. */ + subscribeKey(key: string, listener: () => void): () => void; + /** Subscribes to any change across all keys. */ + subscribe(listener: () => void): () => void; +} + +const createKeyedVersionStore = (): KeyedVersionStore => { + const values = new Map(); + const keyedListeners = new Map void>>(); + const globalListeners = new Set<() => void>(); + + const notify = (key: string): void => { + for (const listener of keyedListeners.get(key) ?? []) { + listener(); + } + for (const listener of globalListeners) { + listener(); + } + }; + + return { + delete: (key) => { + if (values.delete(key)) { + notify(key); + } + }, + get: (key) => values.get(key), + set: (key, value) => { + if (values.get(key) === value) { + return; + } + values.set(key, value); + notify(key); + }, + subscribe: (listener) => { + globalListeners.add(listener); + return () => { + globalListeners.delete(listener); + }; + }, + subscribeKey: (key, listener) => { + const listeners = keyedListeners.get(key) ?? new Set<() => void>(); + listeners.add(listener); + keyedListeners.set(key, listeners); + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + keyedListeners.delete(key); + } + }; + }, + }; +}; + +/** The bundle of transient stores owned by one engine instance. */ +export interface EngineStores { + activeTool: ScalarStore; + zoom: ScalarStore; + viewportReady: ScalarStore; + cursor: ScalarStore; + thumbnailVersion: KeyedVersionStore; + /** Brush tool options (size / color / opacity / pressure). */ + brushOptions: ScalarStore; + /** Eraser tool options (size / opacity). */ + eraserOptions: ScalarStore; + /** Whether the engine-owned canvas history has an entry to undo. */ + canUndo: ScalarStore; + /** Whether the engine-owned canvas history has an entry to redo. */ + canRedo: ScalarStore; + /** Bbox tool options (aspect lock / ratio). */ + bboxOptions: ScalarStore; + /** Lasso tool options (the committed boolean op mode). */ + lassoOptions: ScalarStore; + /** Shape tool options (kind / fill / stroke / stroke width). */ + shapeOptions: ScalarStore; + /** Gradient tool options (kind / angle / stops). */ + gradientOptions: ScalarStore; + /** Text tool options (font family / size / weight / line-height / align / color). */ + textOptions: ScalarStore; + /** + * The active text-editing session, or `null`. React reads it to render the + * contenteditable portal and enable the text options bar's live-restyle path; + * the engine reads it to skip the session layer in the composite. Cleared on + * commit, cancel, real tool switch, layer delete, or document replace. + */ + textEditSession: ScalarStore; + /** + * The live shape-tool drag preview (document-space rect + kind), or `null` + * when idle. The overlay renders the shape outline in place of a committed + * layer so the drag tracks without dispatching; cleared on commit/cancel. + */ + shapePreview: ScalarStore<{ rect: Rect; kind: 'rect' | 'ellipse' } | null>; + /** + * The live gradient-tool drag preview: the drag vector's start/end points in + * document space, drawn on the overlay as a direction indicator (a gradient + * necessarily fills the document, so only its ANGLE is previewed, not a + * bounding box). `null` when idle; cleared on commit/cancel. + */ + gradientPreview: ScalarStore<{ start: Vec2; end: Vec2 } | null>; + /** + * Whether a pixel selection currently exists. React reads it to enable the + * fill/erase/invert/deselect controls and the engine gates selection hotkeys + * and marching-ants animation off it. The engine writes it as the selection + * mask gains/loses content. + */ + hasSelection: ScalarStore; + /** + * The in-progress lasso polygon (document-space points) during a lasso drag, + * or `null` when idle. The overlay renders it as a live dashed preview in + * place of a committed selection; cleared on commit/cancel. Like `bboxPreview`, + * it is a transient channel — no dispatch, no React subscriber. + */ + lassoPreview: ScalarStore; + /** Model-dependent grid size (document px) the bbox snaps to. React feeds this from generate settings. */ + bboxGrid: ScalarStore; + /** + * The live bbox preview rect during a bbox-tool gesture (document space), or + * `null` when idle. The overlay renders this in place of the committed bbox so + * the frame tracks the drag without dispatching; cleared on commit/cancel. + */ + bboxPreview: ScalarStore; + /** + * The active transform-tool session (layer id + start/live transform), or + * `null` when no session is open. React reads it to render the numeric options + * and enable Apply/Cancel; the engine drives the live preview from it. Cleared + * on Apply, Cancel, tool switch, or document replace. + */ + transformSession: ScalarStore; + /** + * Whether the transparency checkerboard is drawn behind transparent documents + * (default ON). Off shows the widget surface through the document instead. The + * compositor reads this each frame; the canvas settings menu toggles it. + */ + checkerboard: ScalarStore; + /** + * The two square colors of the transparency checkerboard, resolved from Chakra + * semantic tokens in React and fed down (see `widgets/canvas/checkerColors.ts`). + * The engine rebuilds its cached checker tile and recomposites when these + * change (e.g. a theme/color-mode switch); {@link DEFAULT_CHECKER_COLORS} is the + * React-free fallback until the first feed. + */ + checkerColors: ScalarStore; + /** + * Whether the document-space grid (at the bbox snap size) is drawn on the + * overlay (default OFF). The overlay reads this each frame; the canvas settings + * menu toggles it. + */ + showGrid: ScalarStore; + /** + * Whether ctrl+wheel brush/eraser sizing is inverted (default OFF): normally + * wheel-up grows the size. The wheel handler reads this; the canvas settings + * menu toggles it. Purely an input preference — no render effect. + */ + invertBrushSizeScroll: ScalarStore; + /** + * Whether the generation bbox (dashed frame) is drawn as passive overlay chrome + * (default ON). The overlay reads this each frame; the canvas settings popover + * toggles it. The bbox is still drawn (with its handles) while the bbox TOOL is + * active regardless, so it stays editable — this only hides the passive frame. + */ + showBbox: ScalarStore; + /** + * Whether the bbox overlay shade is drawn (default OFF): a translucent dark + * fill over everything OUTSIDE the bbox, focusing attention on the generation + * region (legacy `CanvasBboxToolModule` overlayRect parity). Overlay-only — + * toggling never recomposites the document. + */ + bboxOverlay: ScalarStore; + /** + * Whether the rule-of-thirds composition guide (two vertical + two horizontal + * lines dividing the bbox into thirds) is drawn inside the bbox (default OFF). + * The overlay reads this each frame; the canvas settings popover toggles it. + */ + ruleOfThirds: ScalarStore; + /** + * Whether bbox-tool moves/resizes snap to the model grid (default ON). The bbox + * tool reads this; the canvas settings popover toggles it, and the fit-bbox + * header actions honor it too. Holding Alt bypasses snapping independently. + * Purely an interaction preference — no render effect. + */ + snapToGrid: ScalarStore; +} + +const brushOptionsEqual = (a: BrushOptions, b: BrushOptions): boolean => + a.size === b.size && + a.color === b.color && + a.opacity === b.opacity && + a.pressureSensitivity === b.pressureSensitivity; + +const eraserOptionsEqual = (a: EraserOptions, b: EraserOptions): boolean => + a.size === b.size && a.opacity === b.opacity; + +const checkerColorsEqual = (a: CheckerColors, b: CheckerColors): boolean => a.a === b.a && a.b === b.b; + +const bboxOptionsEqual = (a: BboxToolOptions, b: BboxToolOptions): boolean => + a.aspectLocked === b.aspectLocked && a.aspectRatio === b.aspectRatio; + +const lassoOptionsEqual = (a: LassoToolOptions, b: LassoToolOptions): boolean => a.mode === b.mode; + +const shapeOptionsEqual = (a: ShapeToolOptions, b: ShapeToolOptions): boolean => + a.kind === b.kind && a.fill === b.fill && a.stroke === b.stroke && a.strokeWidth === b.strokeWidth; + +const stopsEqual = (a: readonly GradientStop[], b: readonly GradientStop[]): boolean => + a.length === b.length && a.every((stop, i) => stop.offset === b[i]?.offset && stop.color === b[i]?.color); + +const gradientOptionsEqual = (a: GradientToolOptions, b: GradientToolOptions): boolean => + a.kind === b.kind && a.angle === b.angle && stopsEqual(a.stops, b.stops); + +const shapePreviewEqual = ( + a: { rect: Rect; kind: 'rect' | 'ellipse' } | null, + b: { rect: Rect; kind: 'rect' | 'ellipse' } | null +): boolean => { + if (a === null || b === null) { + return a === b; + } + return ( + a.kind === b.kind && + a.rect.x === b.rect.x && + a.rect.y === b.rect.y && + a.rect.width === b.rect.width && + a.rect.height === b.rect.height + ); +}; + +const gradientPreviewEqual = (a: { start: Vec2; end: Vec2 } | null, b: { start: Vec2; end: Vec2 } | null): boolean => { + if (a === null || b === null) { + return a === b; + } + return a.start.x === b.start.x && a.start.y === b.start.y && a.end.x === b.end.x && a.end.y === b.end.y; +}; + +const bboxPreviewEqual = (a: Rect | null, b: Rect | null): boolean => { + if (a === null || b === null) { + return a === b; + } + return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height; +}; + +const transformEqual = (a: LayerTransform, b: LayerTransform): boolean => + a.x === b.x && a.y === b.y && a.scaleX === b.scaleX && a.scaleY === b.scaleY && a.rotation === b.rotation; + +const transformSessionEqual = (a: TransformSession | null, b: TransformSession | null): boolean => { + if (a === null || b === null) { + return a === b; + } + return ( + a.layerId === b.layerId && + transformEqual(a.startTransform, b.startTransform) && + transformEqual(a.transform, b.transform) + ); +}; + +const textOptionsEqual = (a: TextToolOptions, b: TextToolOptions): boolean => + a.fontFamily === b.fontFamily && + a.fontSize === b.fontSize && + a.fontWeight === b.fontWeight && + a.lineHeight === b.lineHeight && + a.align === b.align && + a.color === b.color; + +const textSourceEqual = (a: TextSource, b: TextSource): boolean => + a.content === b.content && + a.fontFamily === b.fontFamily && + a.fontSize === b.fontSize && + a.fontWeight === b.fontWeight && + a.lineHeight === b.lineHeight && + a.align === b.align && + a.color === b.color; + +const textEditSessionEqual = (a: TextEditSession | null, b: TextEditSession | null): boolean => { + if (a === null || b === null) { + return a === b; + } + return ( + a.id === b.id && + a.mode === b.mode && + a.layerId === b.layerId && + textSourceEqual(a.source, b.source) && + transformEqual(a.transform, b.transform) + ); +}; + +/** Creates a fresh bundle of engine stores with their initial values. */ +export const createEngineStores = (initialTool: ToolId = 'view'): EngineStores => ({ + activeTool: createScalarStore(initialTool), + bboxGrid: createScalarStore(DEFAULT_BBOX_GRID), + bboxOptions: createScalarStore({ ...DEFAULT_BBOX_OPTIONS }, bboxOptionsEqual), + bboxPreview: createScalarStore(null, bboxPreviewEqual), + bboxOverlay: createScalarStore(false), + brushOptions: createScalarStore({ ...DEFAULT_BRUSH_OPTIONS }, brushOptionsEqual), + canRedo: createScalarStore(false), + canUndo: createScalarStore(false), + checkerboard: createScalarStore(true), + checkerColors: createScalarStore({ ...DEFAULT_CHECKER_COLORS }, checkerColorsEqual), + cursor: createScalarStore('default'), + eraserOptions: createScalarStore({ ...DEFAULT_ERASER_OPTIONS }, eraserOptionsEqual), + hasSelection: createScalarStore(false), + invertBrushSizeScroll: createScalarStore(false), + gradientOptions: createScalarStore( + { ...DEFAULT_GRADIENT_OPTIONS, stops: DEFAULT_GRADIENT_OPTIONS.stops.map((s) => ({ ...s })) }, + gradientOptionsEqual + ), + gradientPreview: createScalarStore<{ start: Vec2; end: Vec2 } | null>(null, gradientPreviewEqual), + lassoOptions: createScalarStore({ ...DEFAULT_LASSO_OPTIONS }, lassoOptionsEqual), + lassoPreview: createScalarStore(null), + ruleOfThirds: createScalarStore(false), + shapeOptions: createScalarStore({ ...DEFAULT_SHAPE_OPTIONS }, shapeOptionsEqual), + shapePreview: createScalarStore<{ rect: Rect; kind: 'rect' | 'ellipse' } | null>(null, shapePreviewEqual), + showBbox: createScalarStore(true), + showGrid: createScalarStore(false), + snapToGrid: createScalarStore(true), + textEditSession: createScalarStore(null, textEditSessionEqual), + textOptions: createScalarStore({ ...DEFAULT_TEXT_OPTIONS }, textOptionsEqual), + thumbnailVersion: createKeyedVersionStore(), + transformSession: createScalarStore(null, transformSessionEqual), + viewportReady: createScalarStore(false), + zoom: createScalarStore(1), +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/export/compositeForGeneration.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/export/compositeForGeneration.test.ts new file mode 100644 index 00000000000..4a24e9dc85d --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/export/compositeForGeneration.test.ts @@ -0,0 +1,379 @@ +import type { CanvasImageUploadResult } from '@workbench/canvas-engine/backend/canvasImages'; +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { StubRasterSurface } from '@workbench/canvas-engine/render/raster.testStub'; +import type { Rect } from '@workbench/generation/canvas/types'; +import type { + CanvasDocumentContractV2, + CanvasImageRef, + CanvasLayerContract, + CanvasRasterLayerContractV2, +} from '@workbench/types'; + +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { planComposites } from '@workbench/generation/canvas/compositePlan'; +import { describe, expect, it, vi } from 'vitest'; + +import type { ExecuteCompositePlanDeps } from './compositeForGeneration'; + +import { + createCompositeDedupeCache, + executeCompositePlan, + executeMaskComposite, + toGrayscaleMaskPixels, +} from './compositeForGeneration'; + +const imageRef = (imageName: string, width = 64, height = 48): CanvasImageRef => ({ height, imageName, width }); + +const rasterLayer = ( + id: string, + overrides: Partial = {} +): CanvasRasterLayerContractV2 => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { image: imageRef(id), type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + ...overrides, +}); + +const makeDoc = (layers: CanvasLayerContract[]): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers, + selectedLayerId: null, + version: 2, + width: 100, +}); + +const BBOX: Rect = { height: 100, width: 100, x: 0, y: 0 }; + +/** Builds an ImageData-like object with a uniform alpha (255 = opaque). */ +const uniformImageData = (width: number, height: number, alpha: number): ImageData => { + const data = new Uint8ClampedArray(width * height * 4); + for (let i = 3; i < data.length; i += 4) { + data[i] = alpha; + } + return { colorSpace: 'srgb', data, height, width } as unknown as ImageData; +}; + +/** An opaque scan except a single transparent pixel (a hole). */ +const holeImageData = (width: number, height: number): ImageData => { + const image = uniformImageData(width, height, 255); + image.data[3] = 0; + return image; +}; + +interface Harness { + deps: ExecuteCompositePlanDeps; + createdTargets: StubRasterSurface[]; + layerSurfaces: Map; + uploadImage: ReturnType; + encodeSurface: ReturnType; +} + +const makeHarness = (readImageData?: (surface: RasterSurface, rect: Rect) => ImageData): Harness => { + const stub = createTestStubRasterBackend(); + const createdTargets: StubRasterSurface[] = []; + const layerSurfaces = new Map(); + + const encodeSurface = vi.fn((surface: RasterSurface) => stub.encodeSurface(surface)); + const backend = { + createSurface: (w: number, h: number): StubRasterSurface => { + const surface = stub.createSurface(w, h); + createdTargets.push(surface); + return surface; + }, + encodeSurface, + }; + + const getLayerSurface = (layerId: string): Promise<{ surface: RasterSurface; rect: Rect }> => { + let surface = layerSurfaces.get(layerId); + if (!surface) { + // Layer caches are content-sized; created off the raw stub so they don't + // pollute the composite-target capture. Origin-anchored (0,0) here. + surface = stub.createSurface(64, 48); + layerSurfaces.set(layerId, surface); + } + return Promise.resolve({ rect: { height: 48, width: 64, x: 0, y: 0 }, surface }); + }; + + let counter = 0; + const uploadImage = vi.fn((blob: Blob): Promise => { + void blob; + counter += 1; + return Promise.resolve({ height: 100, imageName: `uploaded-${counter}`, width: 100 }); + }); + + const deps: ExecuteCompositePlanDeps = { + backend, + dedupe: createCompositeDedupeCache(), + getLayerSurface, + // Content-addressed hash: identical blob bytes → identical hash. + hashBlob: (blob: Blob) => blob.text(), + readImageData, + uploadImage, + }; + + return { createdTargets, deps, encodeSurface, layerSurfaces, uploadImage }; +}; + +describe('executeCompositePlan — compositing', () => { + it('composites enabled raster layers bottom→top into a bbox-sized surface', async () => { + const harness = makeHarness(() => uniformImageData(100, 100, 255)); + const doc = makeDoc([rasterLayer('top', { opacity: 0.4 }), rasterLayer('bottom', { opacity: 0.9 })]); + const plan = planComposites(doc, BBOX); + + await executeCompositePlan(plan, harness.deps); + + expect(harness.createdTargets).toHaveLength(1); + const target = harness.createdTargets[0]!; + expect(target.width).toBe(100); + expect(target.height).toBe(100); + + const drawImages = target.callLog.filter((e) => e.op === 'drawImage'); + expect(drawImages).toHaveLength(2); + // Bottom layer (array index 1) is drawn first. + expect(drawImages[0]!.args[0]).toBe(harness.layerSurfaces.get('bottom')!.canvas); + expect(drawImages[1]!.args[0]).toBe(harness.layerSurfaces.get('top')!.canvas); + + // Per-layer opacity is applied. + const alphas = target.callLog.filter((e) => e.op === 'set' && e.args[0] === 'globalAlpha').map((e) => e.args[1]); + expect(alphas).toContain(0.4); + expect(alphas).toContain(0.9); + }); + + it('translates the composite by the bbox origin (crops to the bbox)', async () => { + const harness = makeHarness(() => uniformImageData(50, 50, 255)); + const doc = makeDoc([rasterLayer('a')]); + const bbox: Rect = { height: 50, width: 50, x: 20, y: 10 }; + const plan = planComposites(doc, bbox); + + await executeCompositePlan(plan, harness.deps); + + const target = harness.createdTargets[0]!; + // The layer sits at document origin; under the bbox translate it draws at (-20, -10). + const setTransforms = target.callLog.filter((e) => e.op === 'setTransform'); + expect(setTransforms.some((e) => e.args[4] === -20 && e.args[5] === -10)).toBe(true); + }); +}); + +describe('executeCompositePlan — mode geometry', () => { + it('reports bboxFullyCovered=true for an opaque composite', async () => { + const harness = makeHarness(() => uniformImageData(100, 100, 255)); + const plan = planComposites(makeDoc([rasterLayer('a')]), BBOX); + const result = await executeCompositePlan(plan, harness.deps); + expect(result.bboxFullyCovered).toBe(true); + }); + + it('reports bboxFullyCovered=false when the composite has a transparent hole', async () => { + const harness = makeHarness(() => holeImageData(100, 100)); + const plan = planComposites(makeDoc([rasterLayer('a')]), BBOX); + const result = await executeCompositePlan(plan, harness.deps); + expect(result.bboxFullyCovered).toBe(false); + }); + + it('computes contentBounds as the union of layer bounds in document space', async () => { + const harness = makeHarness(() => uniformImageData(100, 100, 255)); + const doc = makeDoc([ + rasterLayer('a'), // 64x48 at (0,0) + rasterLayer('b', { transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 100, y: 100 } }), // 64x48 at (100,100) + ]); + const plan = planComposites(doc, BBOX); + const result = await executeCompositePlan(plan, harness.deps); + expect(result.contentBounds).toEqual({ height: 148, width: 164, x: 0, y: 0 }); + }); + + it('returns contentBounds=null when there is no enabled raster content', async () => { + const harness = makeHarness(() => uniformImageData(100, 100, 0)); + const plan = planComposites(makeDoc([rasterLayer('a', { isEnabled: false })]), BBOX); + const result = await executeCompositePlan(plan, harness.deps); + expect(result.contentBounds).toBeNull(); + }); +}); + +describe('executeCompositePlan — upload + dedupe', () => { + it('encodes and uploads once, returning the uploaded image name', async () => { + const harness = makeHarness(() => uniformImageData(100, 100, 255)); + const plan = planComposites(makeDoc([rasterLayer('a')]), BBOX); + + const result = await executeCompositePlan(plan, harness.deps); + + expect(harness.encodeSurface).toHaveBeenCalledTimes(1); + expect(harness.uploadImage).toHaveBeenCalledTimes(1); + expect(result.base.imageName).toBe('uploaded-1'); + expect(result.base.reusedUpload).toBe(false); + }); + + it('re-running the same plan skips compositing, encoding and upload entirely', async () => { + const harness = makeHarness(() => uniformImageData(100, 100, 255)); + const plan = planComposites(makeDoc([rasterLayer('a')]), BBOX); + + await executeCompositePlan(plan, harness.deps); + harness.createdTargets.length = 0; + const second = await executeCompositePlan(plan, harness.deps); + + // No new composite target, no new encode, no new upload. + expect(harness.createdTargets).toHaveLength(0); + expect(harness.encodeSurface).toHaveBeenCalledTimes(1); + expect(harness.uploadImage).toHaveBeenCalledTimes(1); + expect(second.base.reusedUpload).toBe(true); + expect(second.base.imageName).toBe('uploaded-1'); + }); + + it('a changed plan with identical pixels re-composites but reuses the upload by content hash', async () => { + const harness = makeHarness(() => uniformImageData(100, 100, 255)); + const planA = planComposites(makeDoc([rasterLayer('a', { opacity: 1 })]), BBOX); + const planB = planComposites(makeDoc([rasterLayer('a', { opacity: 0.5 })]), BBOX); + expect(planA.entries[0]!.key).not.toBe(planB.entries[0]!.key); + + await executeCompositePlan(planA, harness.deps); + const resultB = await executeCompositePlan(planB, harness.deps); + + // The changed plan re-composites + re-encodes (same-size stub blob → same hash)... + expect(harness.encodeSurface).toHaveBeenCalledTimes(2); + // ...but the identical pixel hash means no second upload. + expect(harness.uploadImage).toHaveBeenCalledTimes(1); + expect(resultB.base.reusedUpload).toBe(true); + expect(resultB.base.imageName).toBe('uploaded-1'); + }); +}); + +// ---- Grayscale mask composite --------------------------------------------- + +const inpaintMask = ( + id: string, + overrides: Partial<{ denoiseLimit: number; noiseLevel: number }> = {} +): CanvasLayerContract => ({ + blendMode: 'normal', + denoiseLimit: overrides.denoiseLimit, + id, + isEnabled: true, + isLocked: false, + mask: { bitmap: imageRef(`${id}-bmp`, 64, 48), fill: { color: '#ff0000', style: 'solid' } }, + name: id, + noiseLevel: overrides.noiseLevel, + opacity: 1, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'inpaint_mask', +}); + +const maskEntryOf = (doc: CanvasDocumentContractV2) => + planComposites(doc, BBOX).entries.find((e) => e.kind === 'inpaint-mask')!; + +/** A grayscale ImageData that is uniformly white or has a single dark pixel. */ +const grayImageData = (width: number, height: number, hasDark: boolean): ImageData => { + const data = new Uint8ClampedArray(width * height * 4); + for (let i = 0; i < data.length; i += 4) { + data[i] = 255; + data[i + 1] = 255; + data[i + 2] = 255; + data[i + 3] = 255; + } + if (hasDark) { + data[0] = 0; + } + return { colorSpace: 'srgb', data, height, width } as unknown as ImageData; +}; + +describe('toGrayscaleMaskPixels', () => { + const pixel = (alpha: number): ImageData => { + const data = new Uint8ClampedArray([120, 130, 140, alpha]); + return { colorSpace: 'srgb', data, height: 1, width: 1 } as unknown as ImageData; + }; + + it('turns a masked pixel dark by the attribute value (1.0 → black)', () => { + const img = pixel(255); + toGrayscaleMaskPixels(img, 1); + expect(Array.from(img.data)).toEqual([0, 0, 0, 255]); + }); + + it('turns a masked pixel mid-gray at partial strength', () => { + const img = pixel(255); + toGrayscaleMaskPixels(img, 0.5); + // 255 - round(255 * 0.5) = 255 - 128 = 127 + expect(Array.from(img.data)).toEqual([127, 127, 127, 255]); + }); + + it('leaves an unmasked (transparent) pixel white', () => { + const img = pixel(0); + toGrayscaleMaskPixels(img, 1); + expect(Array.from(img.data)).toEqual([255, 255, 255, 255]); + }); +}); + +describe('executeMaskComposite', () => { + it('composites mask layers onto a white bbox surface and uploads the result', async () => { + const harness = makeHarness(() => grayImageData(100, 100, true)); + const entry = maskEntryOf(makeDoc([inpaintMask('m1')])); + + const result = await executeMaskComposite(entry, harness.deps); + + // White background fill + one mask draw. + const target = harness.createdTargets[0]!; + const fills = target.callLog.filter((e) => e.op === 'set' && e.args[0] === 'fillStyle'); + expect(fills.map((e) => e.args[1])).toContain('white'); + expect(harness.uploadImage).toHaveBeenCalledTimes(1); + expect(result.imageName).toBe('uploaded-1'); + expect(result.hasContent).toBe(true); + }); + + it('reports no content when the composite is fully white', async () => { + const harness = makeHarness(() => grayImageData(100, 100, false)); + const entry = maskEntryOf(makeDoc([inpaintMask('m1')])); + const result = await executeMaskComposite(entry, harness.deps); + expect(result.hasContent).toBe(false); + }); + + it('dedupes a repeated mask plan key with no second upload', async () => { + const harness = makeHarness(() => grayImageData(100, 100, true)); + const doc = makeDoc([inpaintMask('m1', { denoiseLimit: 0.5 })]); + + const first = await executeMaskComposite(maskEntryOf(doc), harness.deps); + const second = await executeMaskComposite(maskEntryOf(doc), harness.deps); + + expect(harness.uploadImage).toHaveBeenCalledTimes(1); + expect(second.reusedUpload).toBe(true); + expect(second.imageName).toBe(first.imageName); + expect(second.hasContent).toBe(true); + }); + + it('darken-composites multiple mask layers', async () => { + const harness = makeHarness(() => grayImageData(100, 100, true)); + const entry = maskEntryOf(makeDoc([inpaintMask('a'), inpaintMask('b')])); + + await executeMaskComposite(entry, harness.deps); + + const target = harness.createdTargets[0]!; + const composites = target.callLog + .filter((e) => e.op === 'set' && e.args[0] === 'globalCompositeOperation') + .map((e) => e.args[1]); + expect(composites).toContain('darken'); + }); +}); + +describe('executeCompositePlan — raster adjustments baked into generation pixels', () => { + it('reads + writes the adjusted layer pixels (bake) and NOT for a plain layer', async () => { + const writeImageData = vi.fn(); + const readImageData = vi.fn((_surface: RasterSurface, rect: Rect) => + uniformImageData(rect.width, rect.height, 255) + ); + const harness = makeHarness(readImageData); + harness.deps.writeImageData = writeImageData; + + // Plain layer: no adjustments → no bake write beyond the coverage scan writes (none here). + const plainDoc = makeDoc([rasterLayer('a')]); + await executeCompositePlan(planComposites(plainDoc, BBOX), harness.deps); + expect(writeImageData).not.toHaveBeenCalled(); + + // Adjusted layer: brightness bake → the executor reads the temp, applies the + // LUT, and writes the adjusted pixels back before compositing. + const adjustedDoc = makeDoc([rasterLayer('b', { adjustments: { brightness: 0.5, contrast: 0, saturation: 0 } })]); + await executeCompositePlan(planComposites(adjustedDoc, BBOX), harness.deps); + expect(writeImageData).toHaveBeenCalledTimes(1); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/export/compositeForGeneration.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/export/compositeForGeneration.ts new file mode 100644 index 00000000000..90c66460788 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/export/compositeForGeneration.ts @@ -0,0 +1,536 @@ +/** + * The composite-plan executor: turns a {@link CompositePlan} into an uploaded, + * bbox-sized base image plus the geometry the mode detector needs. + * + * This is the impure half of the canvas → generation pipeline (the planner in + * `generation/canvas/compositePlan.ts` is pure). It lives under `canvas-engine` + * and has zero React: every side-effecting dependency (surface allocation, + * layer rasterization, encode, hash, upload) is injected, so it runs in node + * tests against `render/raster.testStub.ts` + a mock uploader — no fetch, no DOM. + * + * For each `base-raster` entry it: + * 1. Composites the entry's enabled raster layers, in z-order, through each + * layer's transform / opacity / blend mode, cropped to the bbox onto a + * bbox-sized surface (following `render/compositor.ts`'s draw model, where + * the "view" is a bbox translate). Layers are rasterized on demand via the + * injected {@link ExecuteCompositePlanDeps.getLayerSurface}. + * 2. Computes `contentBounds` (union of the entry's layer bounds in document + * space) and `bboxFullyCovered` (an alpha scan of the composited surface). + * 3. Encodes → PNG blob → SHA-256, then dedupes: an unchanged plan key reuses + * the previous upload with zero new work, and a changed plan whose pixels + * hash identically reuses the previous upload via the content hash. + * + * Dedupe state lives in a caller-owned {@link CompositeDedupeCache} passed + * through `deps`, so it persists across invokes while the function stays a plain + * `(plan, deps) → result`. + */ + +import type { CanvasImageUploadResult } from '@workbench/canvas-engine/backend/canvasImages'; +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { Mat2d, Rect } from '@workbench/canvas-engine/types'; +import type { + CompositeEntry, + CompositeLayerRef, + CompositeMaskLayerRef, + CompositePlan, +} from '@workbench/generation/canvas/types'; + +import { fromTRS, multiply } from '@workbench/canvas-engine/math/mat2d'; +import { transformBounds, union } from '@workbench/canvas-engine/math/rect'; +import { applyAdjustments } from '@workbench/canvas-engine/render/adjustments'; +import { blendToComposite } from '@workbench/canvas-engine/render/compositor'; + +type Ctx = RasterSurface['ctx']; + +/** SHA-256 hex of a blob's bytes, via the Web Crypto API (matches `bitmapStore`). */ +const defaultHashBlob = async (blob: Blob): Promise => { + const buffer = await blob.arrayBuffer(); + const digest = await crypto.subtle.digest('SHA-256', buffer); + const bytes = new Uint8Array(digest); + let hex = ''; + for (const byte of bytes) { + hex += byte.toString(16).padStart(2, '0'); + } + return hex; +}; + +/** Reads a surface's pixels via its 2D context (real DOM path; injectable for tests). */ +const defaultReadImageData = (surface: RasterSurface, rect: Rect): ImageData => + surface.ctx.getImageData(rect.x, rect.y, rect.width, rect.height); + +/** Writes pixels back to a surface's 2D context (real DOM path; injectable for tests). */ +const defaultWriteImageData = (surface: RasterSurface, imageData: ImageData, x: number, y: number): void => + surface.ctx.putImageData(imageData, x, y); + +/** + * A caller-owned dedupe cache, persisted across executor calls: + * - `byKey`: plan key → its last result, so an unchanged plan skips all work. + * - `byHash`: pixel hash → uploaded image, so a changed plan with identical + * pixels reuses the upload. + */ +export interface CompositeDedupeCache { + byKey: Map; + byHash: Map; +} + +/** A cached executor result for one plan key. */ +export interface CompositeCacheEntry { + imageName: string; + width: number; + height: number; + pixelHash: string; + bboxFullyCovered: boolean; +} + +/** Creates an empty {@link CompositeDedupeCache}. */ +export const createCompositeDedupeCache = (): CompositeDedupeCache => ({ + byHash: new Map(), + byKey: new Map(), +}); + +/** Injected dependencies for {@link executeCompositePlan}. */ +export interface ExecuteCompositePlanDeps { + /** Surface factory + encoder seam (usually the engine's `RasterBackend`). */ + backend: { + createSurface(width: number, height: number): RasterSurface; + encodeSurface(surface: RasterSurface, type?: string): Promise; + }; + /** + * Ensures a layer's cache is rasterized and returns its surface plus the + * content `rect` (layer-local origin/size) those pixels occupy. The executor + * draws the surface at `rect.origin` (then through the layer transform). The + * engine wires this to its rasterize path; tests return a stub. + */ + getLayerSurface(layerId: string): Promise<{ surface: RasterSurface; rect: Rect }>; + /** + * Uploads a composited blob and resolves to its server image name + dims. The + * engine wires this to `uploadCanvasImage(blob, { isIntermediate: true })`. + */ + uploadImage(blob: Blob): Promise; + /** Persistent dedupe state (see {@link CompositeDedupeCache}). */ + dedupe: CompositeDedupeCache; + /** Content-hashes a blob (default SHA-256 hex via `crypto.subtle`). */ + hashBlob?(blob: Blob): Promise; + /** Reads a surface region's pixels for the coverage scan (default `getImageData`). */ + readImageData?(surface: RasterSurface, rect: Rect): ImageData; + /** Writes pixels back to a surface (default `putImageData`; injectable for tests). */ + writeImageData?(surface: RasterSurface, imageData: ImageData, x: number, y: number): void; +} + +/** The base composite's upload identity + hash. */ +export interface CompositeEntryResult { + /** The entry's stable plan key. */ + key: string; + /** The uploaded (or reused) image name. */ + imageName: string; + width: number; + height: number; + /** SHA-256 of the composited PNG bytes. */ + pixelHash: string; + /** True when this result came from cache/dedupe (no upload happened this call). */ + reusedUpload: boolean; +} + +/** The full result of executing a plan: the base image + mode-detection geometry. */ +export interface CompositeResult { + base: CompositeEntryResult; + /** Union of enabled raster content bounds in document space, or `null`. */ + contentBounds: Rect | null; + /** Whether the composited bbox surface is fully opaque (no transparent holes). */ + bboxFullyCovered: boolean; +} + +/** Document→bbox translate matrix (the "view" the entry is composited under). */ +const bboxView = (bbox: Rect): Mat2d => ({ a: 1, b: 0, c: 0, d: 1, e: -bbox.x, f: -bbox.y }); + +/** Applies a matrix to a 2D context's transform. */ +const setTransform = (ctx: Ctx, m: Mat2d): void => { + ctx.setTransform(m.a, m.b, m.c, m.d, m.e, m.f); +}; + +/** The layer's local→document transform matrix. */ +const layerMatrix = (ref: CompositeLayerRef): Mat2d => + fromTRS( + { x: ref.transform.x, y: ref.transform.y }, + ref.transform.rotation, + ref.transform.scaleX, + ref.transform.scaleY + ); + +/** + * Union of a plan's base-raster content bounds in document space, or `null` + * when the plan has no enabled raster content. Pure geometry (no pixels, no + * upload), so the invoke orchestrator can run it as a bounds-only pre-pass to + * decide txt2img (no bbox overlap) *before* paying for a composite/encode/upload. + */ +export const computeCompositeContentBounds = (plan: CompositePlan): Rect | null => { + const entry = plan.entries.find((e) => e.kind === 'base-raster'); + return entry ? computeContentBounds(entry.layers) : null; +}; + +/** Union of the entry's layer bounds in document space, or `null` when empty. */ +const computeContentBounds = (layers: CompositeLayerRef[]): Rect | null => { + let bounds: Rect | null = null; + for (const ref of layers) { + const nativeRect: Rect = { + height: ref.contentSize.height, + width: ref.contentSize.width, + x: ref.contentOffset.x, + y: ref.contentOffset.y, + }; + const layerBounds = transformBounds(layerMatrix(ref), nativeRect); + bounds = bounds === null ? layerBounds : union(bounds, layerBounds); + } + return bounds; +}; + +/** True when every pixel of `imageData` is fully opaque (alpha === 255). Empty → false. */ +const isFullyOpaque = (imageData: ImageData): boolean => { + const { data, height, width } = imageData; + if (width <= 0 || height <= 0) { + return false; + } + for (let i = 3; i < data.length; i += 4) { + if (data[i] < 255) { + return false; + } + } + return true; +}; + +/** Composites an entry's layers, in z-order, onto a fresh bbox-sized surface. */ +const compositeEntry = async (entry: CompositeEntry, deps: ExecuteCompositePlanDeps): Promise => { + const { bbox } = entry; + const width = Math.max(0, bbox.width); + const height = Math.max(0, bbox.height); + const surface = deps.backend.createSurface(width, height); + const ctx = surface.ctx; + const readImageData = deps.readImageData ?? defaultReadImageData; + const writeImageData = deps.writeImageData ?? defaultWriteImageData; + + setTransform(ctx, { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }); + ctx.clearRect(0, 0, surface.width, surface.height); + + const view = bboxView(bbox); + // Layers are stored top-first (index 0 = top-most); draw bottom→top. + for (let i = entry.layers.length - 1; i >= 0; i--) { + const ref = entry.layers[i]; + if (!ref) { + continue; + } + const layerSurface = await deps.getLayerSurface(ref.id); + if (ref.adjustments) { + // Bake non-destructive adjustments so the generated image matches what the + // user sees: render this layer alone into a bbox temp, apply the LUTs, then + // composite the adjusted temp with the layer's opacity/blend. + const temp = deps.backend.createSurface(width, height); + const tempCtx = temp.ctx; + setTransform(tempCtx, { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }); + tempCtx.clearRect(0, 0, width, height); + setTransform(tempCtx, multiply(view, layerMatrix(ref))); + tempCtx.drawImage(layerSurface.surface.canvas, layerSurface.rect.x, layerSurface.rect.y); + const fullRect: Rect = { height, width, x: 0, y: 0 }; + const pixels = readImageData(temp, fullRect); + applyAdjustments(pixels, ref.adjustments); + writeImageData(temp, pixels, 0, 0); + ctx.save(); + setTransform(ctx, { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }); + ctx.globalAlpha = ref.opacity; + ctx.globalCompositeOperation = blendToComposite(ref.blendMode); + ctx.drawImage(temp.canvas, 0, 0); + ctx.restore(); + continue; + } + ctx.save(); + ctx.globalAlpha = ref.opacity; + ctx.globalCompositeOperation = blendToComposite(ref.blendMode); + setTransform(ctx, multiply(view, layerMatrix(ref))); + // Draw the cache at its layer-local content origin (content-sized paint + // layers place their pixels off-zero). + ctx.drawImage(layerSurface.surface.canvas, layerSurface.rect.x, layerSurface.rect.y); + ctx.restore(); + } + + return surface; +}; + +/** + * Composites, scans coverage, encodes, hashes, dedupes, and (when needed) + * uploads a single raster-style entry (`base-raster` or `control-layer`). + * Shared by {@link executeCompositePlan} and {@link executeControlComposite} so + * both go through the identical plan-key + content-hash dedupe path. + */ +const executeRasterEntry = async ( + entry: CompositeEntry, + deps: ExecuteCompositePlanDeps +): Promise => { + const hashBlob = deps.hashBlob ?? defaultHashBlob; + const readImageData = deps.readImageData ?? defaultReadImageData; + + // Plan-key hit: nothing that affects these pixels changed — reuse everything, + // no composite, no encode, no upload. + const cached = deps.dedupe.byKey.get(entry.key); + if (cached) { + return { + bboxFullyCovered: cached.bboxFullyCovered, + height: cached.height, + imageName: cached.imageName, + key: entry.key, + pixelHash: cached.pixelHash, + reusedUpload: true, + width: cached.width, + }; + } + + const surface = await compositeEntry(entry, deps); + const bboxFullyCovered = isFullyOpaque( + readImageData(surface, { height: surface.height, width: surface.width, x: 0, y: 0 }) + ); + + const blob = await deps.backend.encodeSurface(surface); + const pixelHash = await hashBlob(blob); + + // Content-hash dedupe: identical pixels (even under a different key) reuse the + // already-uploaded image — no second upload. + let upload = deps.dedupe.byHash.get(pixelHash); + let reusedUpload = true; + if (!upload) { + upload = await deps.uploadImage(blob); + deps.dedupe.byHash.set(pixelHash, upload); + reusedUpload = false; + } + + deps.dedupe.byKey.set(entry.key, { + bboxFullyCovered, + height: upload.height, + imageName: upload.imageName, + pixelHash, + width: upload.width, + }); + + return { + bboxFullyCovered, + height: upload.height, + imageName: upload.imageName, + key: entry.key, + pixelHash, + reusedUpload, + width: upload.width, + }; +}; + +/** + * Executes `plan`'s base-raster composite: composites, scans coverage, encodes, + * dedupes, and (when needed) uploads. Returns the base image identity plus the + * `contentBounds` / `bboxFullyCovered` facts the mode detector consumes. + */ +export const executeCompositePlan = async ( + plan: CompositePlan, + deps: ExecuteCompositePlanDeps +): Promise => { + const entry = plan.entries.find((e) => e.kind === 'base-raster'); + if (!entry) { + throw new Error('executeCompositePlan: plan has no base-raster entry'); + } + + const contentBounds = computeContentBounds(entry.layers); + const { bboxFullyCovered, ...base } = await executeRasterEntry(entry, deps); + + return { base, bboxFullyCovered, contentBounds }; +}; + +/** + * Executes a single `control-layer` composite entry (one enabled control layer, + * composited alone over the bbox). Reuses the same dedupe cache as the base + * composite, so an unchanged control layer skips re-upload across invokes. + */ +export const executeControlComposite = async ( + entry: CompositeEntry, + deps: ExecuteCompositePlanDeps +): Promise => { + const { bboxFullyCovered: _bboxFullyCovered, ...result } = await executeRasterEntry(entry, deps); + return result; +}; + +/** + * Executes a single `regional-mask` composite entry (one enabled regional-guidance + * layer's mask, composited alone over the bbox with its alpha preserved). The + * uploaded image's alpha channel is the region coverage, consumed by + * `alpha_mask_to_tensor`. Reuses the same dedupe cache + raster path as the base + * / control composites, so an unchanged region mask skips re-upload. + */ +export const executeRegionalMaskComposite = async ( + entry: CompositeEntry, + deps: ExecuteCompositePlanDeps +): Promise => { + const { bboxFullyCovered: _bboxFullyCovered, ...result } = await executeRasterEntry(entry, deps); + return result; +}; + +// ---- Grayscale mask composite (inpaint/outpaint) --------------------------- + +/** + * Converts a mask layer's alpha into legacy grayscale, in place: a masked pixel + * (alpha > 127) becomes `255 - round(255 * attributeValue)` (black at full + * strength), an unmasked pixel becomes white (255); alpha is forced opaque. This + * mirrors `getGrayscaleMaskCompositeImageDTO`'s per-pixel step so multiple masks + * can be darken-composited over a white background (dark = inpaint, white = keep). + */ +export const toGrayscaleMaskPixels = (imageData: ImageData, attributeValue: number): void => { + const { data } = imageData; + const masked = Math.max(0, Math.min(255, 255 - Math.round(255 * attributeValue))); + for (let i = 0; i + 3 < data.length; i += 4) { + const gray = (data[i + 3] ?? 0) > 127 ? masked : 255; + data[i] = gray; + data[i + 1] = gray; + data[i + 2] = gray; + data[i + 3] = 255; + } +}; + +/** The local→document transform matrix for a mask layer ref. */ +const maskLayerMatrix = (ref: CompositeMaskLayerRef): Mat2d => + fromTRS( + { x: ref.transform.x, y: ref.transform.y }, + ref.transform.rotation, + ref.transform.scaleX, + ref.transform.scaleY + ); + +/** True when any pixel is non-white (a masked region exists). Empty → false. */ +const hasNonWhitePixel = (imageData: ImageData): boolean => { + const { data, height, width } = imageData; + if (width <= 0 || height <= 0) { + return false; + } + for (let i = 0; i + 3 < data.length; i += 4) { + if ((data[i] ?? 255) < 255) { + return true; + } + } + return false; +}; + +/** The result of a grayscale mask composite: its upload identity + whether it has any masked pixels. */ +export interface MaskCompositeResult { + key: string; + imageName: string; + width: number; + height: number; + pixelHash: string; + reusedUpload: boolean; + /** True when the composite contains a masked (non-white) region within the bbox. */ + hasContent: boolean; +} + +/** Composites one mask entry's layers into a grayscale bbox surface (white bg, darken combine). */ +const compositeMaskEntry = async ( + entry: CompositeEntry, + deps: ExecuteCompositePlanDeps, + writeImageData: (surface: RasterSurface, imageData: ImageData, x: number, y: number) => void, + readImageData: (surface: RasterSurface, rect: Rect) => ImageData +): Promise => { + const { bbox } = entry; + const maskLayers = entry.maskLayers ?? []; + const width = Math.max(0, bbox.width); + const height = Math.max(0, bbox.height); + const accumulator = deps.backend.createSurface(width, height); + const accCtx = accumulator.ctx; + + // White background: unmasked area stays white ("keep"). + setTransform(accCtx, { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }); + accCtx.fillStyle = 'white'; + accCtx.fillRect(0, 0, width, height); + + const view = bboxView(bbox); + const fullRect: Rect = { height, width, x: 0, y: 0 }; + + for (const ref of maskLayers) { + // Render the mask alpha into a temp bbox surface through its transform. + const temp = deps.backend.createSurface(width, height); + const tempCtx = temp.ctx; + setTransform(tempCtx, { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }); + tempCtx.clearRect(0, 0, width, height); + const layerSurface = await deps.getLayerSurface(ref.id); + setTransform(tempCtx, multiply(view, maskLayerMatrix(ref))); + tempCtx.drawImage(layerSurface.surface.canvas, layerSurface.rect.x, layerSurface.rect.y); + + // Convert its alpha to grayscale by the layer's attribute value. + const pixels = readImageData(temp, fullRect); + toGrayscaleMaskPixels(pixels, ref.attributeValue); + writeImageData(temp, pixels, 0, 0); + + // Darken-combine onto the accumulator (min per channel), matching legacy. + setTransform(accCtx, { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }); + accCtx.globalAlpha = 1; + accCtx.globalCompositeOperation = 'darken'; + accCtx.drawImage(temp.canvas, 0, 0); + } + + accCtx.globalCompositeOperation = 'source-over'; + return accumulator; +}; + +/** + * Executes a grayscale mask composite entry (`inpaint-mask` / `noise-mask`): + * composites the mask layers into a white-backed grayscale image, scans whether + * any masked region exists, then encodes / dedupes / uploads exactly like the + * base composite. Content-hash + plan-key dedupe reuse the same caller-owned + * cache so an unchanged mask skips re-upload. + */ +export const executeMaskComposite = async ( + entry: CompositeEntry, + deps: ExecuteCompositePlanDeps +): Promise => { + const hashBlob = deps.hashBlob ?? defaultHashBlob; + const readImageData = deps.readImageData ?? defaultReadImageData; + const writeImageData = deps.writeImageData ?? defaultWriteImageData; + + const cached = deps.dedupe.byKey.get(entry.key); + if (cached) { + return { + hasContent: cached.bboxFullyCovered, + height: cached.height, + imageName: cached.imageName, + key: entry.key, + pixelHash: cached.pixelHash, + reusedUpload: true, + width: cached.width, + }; + } + + const surface = await compositeMaskEntry(entry, deps, writeImageData, readImageData); + const hasContent = hasNonWhitePixel( + readImageData(surface, { height: surface.height, width: surface.width, x: 0, y: 0 }) + ); + + const blob = await deps.backend.encodeSurface(surface); + const pixelHash = await hashBlob(blob); + + let upload = deps.dedupe.byHash.get(pixelHash); + let reusedUpload = true; + if (!upload) { + upload = await deps.uploadImage(blob); + deps.dedupe.byHash.set(pixelHash, upload); + reusedUpload = false; + } + + // Reuse the `bboxFullyCovered` slot to persist `hasContent` for this key. + deps.dedupe.byKey.set(entry.key, { + bboxFullyCovered: hasContent, + height: upload.height, + imageName: upload.imageName, + pixelHash, + width: upload.width, + }); + + return { + hasContent, + height: upload.height, + imageName: upload.imageName, + key: entry.key, + pixelHash, + reusedUpload, + width: upload.width, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/export/psdExport.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/export/psdExport.test.ts new file mode 100644 index 00000000000..5881ed568c9 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/export/psdExport.test.ts @@ -0,0 +1,165 @@ +import type { Rect } from '@workbench/canvas-engine/types'; +import type { CanvasBlendMode } from '@workbench/types'; + +import { describe, expect, it } from 'vitest'; + +import type { PsdExportLayerInput } from './psdExport'; + +import { blendModeToPsd, planPsdExport, PSD_MAX_DIMENSION } from './psdExport'; + +const IDENTITY = { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }; + +const layer = (over: Partial = {}): PsdExportLayerInput => ({ + blendMode: 'normal', + contentRect: { height: 50, width: 100, x: 0, y: 0 }, + id: 'a', + isEnabled: true, + name: 'Layer', + opacity: 1, + transform: { ...IDENTITY }, + ...over, +}); + +const findLayer = (plan: ReturnType, id: string) => { + if (plan.status !== 'ok') { + throw new Error(`expected ok plan, got ${plan.status}`); + } + const found = plan.layers.find((l) => l.id === id); + if (!found) { + throw new Error(`layer ${id} not in plan`); + } + return found; +}; + +describe('blendModeToPsd', () => { + it('maps every canvas blend mode to a PSD blend key', () => { + const cases: [CanvasBlendMode, string][] = [ + ['normal', 'normal'], + ['multiply', 'multiply'], + ['screen', 'screen'], + ['overlay', 'overlay'], + ['darken', 'darken'], + ['lighten', 'lighten'], + ['color-dodge', 'color dodge'], + ['color-burn', 'color burn'], + ['hard-light', 'hard light'], + ['soft-light', 'soft light'], + ['difference', 'difference'], + ['exclusion', 'exclusion'], + ['hue', 'hue'], + ['saturation', 'saturation'], + ['color', 'color'], + ['luminosity', 'luminosity'], + ]; + for (const [mode, key] of cases) { + expect(blendModeToPsd(mode)).toBe(key); + } + }); + + it('falls back to normal for an unknown blend mode', () => { + expect(blendModeToPsd('made-up' as CanvasBlendMode)).toBe('normal'); + }); +}); + +describe('planPsdExport', () => { + it('returns empty when there are no layers', () => { + expect(planPsdExport([])).toEqual({ status: 'empty' }); + }); + + it('returns empty when every layer has empty content', () => { + expect(planPsdExport([layer({ contentRect: { height: 0, width: 0, x: 0, y: 0 } })])).toEqual({ + status: 'empty', + }); + }); + + it('sizes the PSD canvas to a single layer content bounds', () => { + const plan = planPsdExport([layer()]); + expect(plan.status).toBe('ok'); + if (plan.status !== 'ok') { + return; + } + expect(plan.width).toBe(100); + expect(plan.height).toBe(50); + expect(plan.canvasRect).toEqual({ height: 50, width: 100, x: 0, y: 0 }); + const only = plan.layers[0]!; + expect(only).toMatchObject({ bottom: 50, hidden: false, left: 0, opacity: 1, right: 100, top: 0 }); + }); + + it('unions world-space bounds across layers and positions each relative to the origin', () => { + const plan = planPsdExport([ + layer({ id: 'top', transform: { ...IDENTITY, x: 50, y: 20 } }), + layer({ id: 'bottom', transform: { ...IDENTITY, x: -30, y: -10 } }), + ]); + if (plan.status !== 'ok') { + throw new Error('expected ok'); + } + // union of [-30,-10,100,50] and [50,20,100,50] = [-30,-10, 180, 80] + expect(plan.canvasRect).toEqual({ height: 80, width: 180, x: -30, y: -10 }); + expect(plan.width).toBe(180); + expect(plan.height).toBe(80); + // positions are relative to the union origin (-30, -10) + expect(findLayer(plan, 'bottom')).toMatchObject({ left: 0, top: 0, right: 100, bottom: 50 }); + expect(findLayer(plan, 'top')).toMatchObject({ left: 80, top: 30, right: 180, bottom: 80 }); + }); + + it('emits layers bottom-to-top (input is top-first, PSD children are bottom-first)', () => { + const plan = planPsdExport([layer({ id: 'top' }), layer({ id: 'mid' }), layer({ id: 'bottom' })]); + if (plan.status !== 'ok') { + throw new Error('expected ok'); + } + expect(plan.layers.map((l) => l.id)).toEqual(['bottom', 'mid', 'top']); + }); + + it('marks disabled layers hidden but still exports them, and clamps opacity to 0..1', () => { + const plan = planPsdExport([ + layer({ id: 'shown', isEnabled: true, opacity: 0.5 }), + layer({ id: 'over', isEnabled: true, opacity: 2 }), + layer({ id: 'hidden', isEnabled: false, opacity: -1 }), + ]); + expect(findLayer(plan, 'shown')).toMatchObject({ hidden: false, opacity: 0.5 }); + expect(findLayer(plan, 'over')).toMatchObject({ opacity: 1 }); + expect(findLayer(plan, 'hidden')).toMatchObject({ hidden: true, opacity: 0 }); + }); + + it('maps blend modes and reports unmapped ones (falling back to normal)', () => { + const plan = planPsdExport([ + layer({ blendMode: 'multiply', id: 'a' }), + layer({ blendMode: 'nonsense' as CanvasBlendMode, id: 'b' }), + ]); + if (plan.status !== 'ok') { + throw new Error('expected ok'); + } + expect(findLayer(plan, 'a').blendMode).toBe('multiply'); + expect(findLayer(plan, 'b').blendMode).toBe('normal'); + expect(plan.unmappedBlends).toEqual(['nonsense']); + }); + + it('passes non-destructive adjustments through for the executor to bake', () => { + const adjustments = { brightness: 0.2, contrast: 0, saturation: 0 }; + const plan = planPsdExport([layer({ adjustments })]); + expect(findLayer(plan, 'a').adjustments).toBe(adjustments); + }); + + it('drops empty-content layers from the plan but keeps the rest', () => { + const plan = planPsdExport([ + layer({ contentRect: { height: 0, width: 0, x: 0, y: 0 }, id: 'empty' }), + layer({ id: 'real' }), + ]); + if (plan.status !== 'ok') { + throw new Error('expected ok'); + } + expect(plan.layers.map((l) => l.id)).toEqual(['real']); + }); + + it('refuses an export whose union bounds exceed the dimension cap', () => { + const plan = planPsdExport([layer({ contentRect: { height: 10, width: 100, x: 0, y: 0 } })], { + maxDimension: 50, + }); + expect(plan).toEqual({ height: 10, status: 'too-large', width: 100 }); + }); + + it('accepts bounds exactly at the cap', () => { + const plan = planPsdExport([layer({ contentRect: { height: 10, width: PSD_MAX_DIMENSION, x: 0, y: 0 } })]); + expect(plan.status).toBe('ok'); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/export/psdExport.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/export/psdExport.ts new file mode 100644 index 00000000000..af8e4771fd5 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/export/psdExport.ts @@ -0,0 +1,399 @@ +/** + * Export raster layers to a Photoshop (.psd) document. + * + * Split into a PURE planner and an IMPURE executor, mirroring the + * planner/executor split of `compositeForGeneration.ts`: + * + * - {@link planPsdExport} is pure geometry (no DOM, no `ag-psd`, no engine): it + * turns each raster layer's transform + content rect into a PSD layer entry + * (position, opacity, blend, hidden, order) and the document bounds. Unit + * tested in node. + * - {@link executePsdExport} is the side-effecting half: it bakes each layer's + * pixels through the {@link RasterBackend} seam, lazily imports `ag-psd` + * (`writePsd`) at call time so the library never enters the main bundle, and + * triggers a browser download. Verified by types + manual QA (opening the PSD). + * + * ### Conventions + * - **Order.** The canvas document stores layers top-first (index 0 = top-most). + * ag-psd's `children` array is BOTTOM-to-top (`children[0]` is the bottom-most + * layer, written first to the PSD layer records, which the format stores + * bottom-up). So the plan reverses the top-first input into bottom-to-top. + * - **Bounds.** The PSD canvas is the union of every EXPORTED layer's + * world-space (document-space) content AABB — document/bbox-independent. An + * empty union means nothing to export. + * - **Opacity.** ag-psd's `Layer.opacity` is 0..1 (the writer multiplies by 255 + * internally), NOT 0..255. Our `layer.opacity` is already 0..1, so it passes + * through unchanged (clamped). + * - **Hidden.** Every raster layer with content is exported; hidden (disabled) + * layers are written with `hidden: true` rather than dropped. + * - **Adjustments.** Non-destructive raster adjustments are BAKED into the + * layer's pixels (PSD has no matching non-destructive representation we emit), + * exactly as `compositeForGeneration` bakes them, so the PSD matches what the + * user sees. Opacity/blend stay as PSD layer properties (not baked). + */ + +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { Mat2d, Rect } from '@workbench/canvas-engine/types'; +import type { CanvasAdjustmentsContract, CanvasBlendMode } from '@workbench/types'; +import type { BlendMode, Layer as AgPsdLayer, Psd } from 'ag-psd'; + +import { fromTRS } from '@workbench/canvas-engine/math/mat2d'; +import { isEmpty, roundOut, transformBounds, union } from '@workbench/canvas-engine/math/rect'; +import { applyAdjustments } from '@workbench/canvas-engine/render/adjustments'; +import { blendToComposite } from '@workbench/canvas-engine/render/compositor'; + +/** + * Maximum PSD side length. ag-psd/Photoshop tolerate up to 300000px, but a + * multi-gigabyte export from an unbounded-canvas union helps nobody — refuse + * past a sane cap and tell the user. Legacy Photoshop's own PSD limit is 30000. + */ +export const PSD_MAX_DIMENSION = 30000; + +/** A canvas layer transform (TRS), duplicated to keep this module contract-light. */ +export interface PsdLayerTransform { + x: number; + y: number; + scaleX: number; + scaleY: number; + rotation: number; +} + +/** + * Maps a document blend mode to ag-psd's blend key. Every blend mode the canvas + * supports has a direct PSD equivalent (Photoshop is the origin of these modes), + * so this is total; an unknown value falls back to 'normal' and is reported via + * {@link PsdExportOk.unmappedBlends}. + */ +const BLEND_MODE_TO_PSD: Record = { + color: 'color', + 'color-burn': 'color burn', + 'color-dodge': 'color dodge', + darken: 'darken', + difference: 'difference', + exclusion: 'exclusion', + 'hard-light': 'hard light', + hue: 'hue', + lighten: 'lighten', + luminosity: 'luminosity', + multiply: 'multiply', + normal: 'normal', + overlay: 'overlay', + saturation: 'saturation', + screen: 'screen', + 'soft-light': 'soft light', +}; + +/** The ag-psd blend key for a document blend mode ('normal' for anything unmapped). */ +export const blendModeToPsd = (mode: CanvasBlendMode): BlendMode => BLEND_MODE_TO_PSD[mode] ?? 'normal'; + +/** One raster layer's export-relevant facts, in the document's top-first order. */ +export interface PsdExportLayerInput { + id: string; + name: string; + transform: PsdLayerTransform; + /** The layer's content rect in LOCAL space (origin may be negative). */ + contentRect: Rect; + /** 0..1. */ + opacity: number; + blendMode: CanvasBlendMode; + /** Hidden (disabled) layers are exported with `hidden: true`, not dropped. */ + isEnabled: boolean; + /** Non-destructive adjustments to bake into the layer's pixels, if any. */ + adjustments?: CanvasAdjustmentsContract; +} + +/** A single planned PSD layer (already in ag-psd bottom-to-top order). */ +export interface PsdPlanLayer { + id: string; + name: string; + /** Position within the PSD canvas (relative to the union origin). */ + left: number; + top: number; + right: number; + bottom: number; + /** The layer's world-space AABB (document space) the executor bakes into. */ + worldRect: Rect; + transform: PsdLayerTransform; + /** Layer-local content rect (executor draws the cache at its origin). */ + contentRect: Rect; + /** 0..1 (ag-psd's range). */ + opacity: number; + blendMode: BlendMode; + /** Canvas `globalCompositeOperation` for the flattened composite preview. */ + compositeBlend: GlobalCompositeOperation; + hidden: boolean; + adjustments?: CanvasAdjustmentsContract; +} + +/** A successful export plan. */ +export interface PsdExportOk { + status: 'ok'; + /** PSD canvas dimensions (= union bounds). */ + width: number; + height: number; + /** The union bounds in document space (origin is the PSD's (0,0)). */ + canvasRect: Rect; + /** Layers in ag-psd order (bottom-to-top). */ + layers: PsdPlanLayer[]; + /** Distinct blend modes that had no PSD equivalent (fell back to 'normal'). */ + unmappedBlends: string[]; +} + +/** The plan, or a refusal (`empty` / `too-large`). */ +export type PsdExportPlan = PsdExportOk | { status: 'empty' } | { status: 'too-large'; width: number; height: number }; + +/** Options for {@link planPsdExport}. */ +export interface PlanPsdExportOptions { + /** Override the per-side dimension cap (default {@link PSD_MAX_DIMENSION}). */ + maxDimension?: number; +} + +const layerMatrix = (t: PsdLayerTransform): Mat2d => fromTRS({ x: t.x, y: t.y }, t.rotation, t.scaleX, t.scaleY); + +/** Clamps to [0, 1] (defensive against out-of-range opacities). */ +const clamp01 = (value: number): number => (value < 0 ? 0 : value > 1 ? 1 : value); + +/** + * Plans a PSD export from raster layers (top-first). Computes each layer's + * world-space AABB, unions them for the PSD canvas, and produces per-layer PSD + * entries in bottom-to-top order. Layers with no content (empty rect, or a + * degenerate zero-area transform) contribute nothing and are omitted. Returns + * `empty` when nothing has content and `too-large` when the union exceeds the + * dimension cap. + */ +export const planPsdExport = ( + inputs: readonly PsdExportLayerInput[], + options: PlanPsdExportOptions = {} +): PsdExportPlan => { + const maxDimension = options.maxDimension ?? PSD_MAX_DIMENSION; + + // World-space AABB per layer (null = no content: empty local rect or a + // zero-area transform that collapses the bounds). + const withBounds = inputs.map((input) => { + if (isEmpty(input.contentRect)) { + return { input, worldRect: null as Rect | null }; + } + const worldRect = roundOut(transformBounds(layerMatrix(input.transform), input.contentRect)); + return { input, worldRect: isEmpty(worldRect) ? null : worldRect }; + }); + + let bounds: Rect | null = null; + for (const { worldRect } of withBounds) { + if (worldRect) { + bounds = bounds === null ? worldRect : union(bounds, worldRect); + } + } + if (bounds === null || isEmpty(bounds)) { + return { status: 'empty' }; + } + const canvasRect = roundOut(bounds); + if (canvasRect.width > maxDimension || canvasRect.height > maxDimension) { + return { height: canvasRect.height, status: 'too-large', width: canvasRect.width }; + } + + const unmappedBlends = new Set(); + // ag-psd order is bottom-to-top; inputs are top-first, so reverse. Layers + // without content are dropped. + const layers: PsdPlanLayer[] = []; + for (let i = withBounds.length - 1; i >= 0; i -= 1) { + const { input, worldRect } = withBounds[i]!; + if (!worldRect) { + continue; + } + const mapped = BLEND_MODE_TO_PSD[input.blendMode]; + if (!mapped) { + unmappedBlends.add(input.blendMode); + } + const left = worldRect.x - canvasRect.x; + const top = worldRect.y - canvasRect.y; + layers.push({ + adjustments: input.adjustments, + blendMode: mapped ?? 'normal', + bottom: top + worldRect.height, + compositeBlend: blendToComposite(input.blendMode), + contentRect: input.contentRect, + hidden: !input.isEnabled, + id: input.id, + left, + name: input.name, + opacity: clamp01(input.opacity), + right: left + worldRect.width, + top, + transform: input.transform, + worldRect, + }); + } + + return { + canvasRect, + height: canvasRect.height, + layers, + status: 'ok', + unmappedBlends: [...unmappedBlends], + width: canvasRect.width, + }; +}; + +// ---- Executor (impure) ----------------------------------------------------- + +type Ctx = RasterSurface['ctx']; + +/** Reads a surface region's pixels (real DOM path; injectable for tests). */ +const defaultReadImageData = (surface: RasterSurface, rect: Rect): ImageData => + surface.ctx.getImageData(rect.x, rect.y, rect.width, rect.height); + +/** Writes pixels back to a surface (real DOM path; injectable for tests). */ +const defaultWriteImageData = (surface: RasterSurface, imageData: ImageData, x: number, y: number): void => + surface.ctx.putImageData(imageData, x, y); + +/** + * Serializes a {@link Psd} to bytes via a LAZILY-imported `ag-psd`, so the + * library is never pulled into the main bundle (Vite code-splits the dynamic + * import into its own chunk, loaded only when an export runs). + */ +const defaultWritePsd = async (psd: Psd): Promise => { + const { writePsd } = await import('ag-psd'); + return writePsd(psd); +}; + +/** Triggers a browser download of the PSD bytes (Blob + anchor click). */ +const defaultDownload = (data: ArrayBuffer, fileName: string): void => { + const blob = new Blob([data], { type: 'image/vnd.adobe.photoshop' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = fileName; + anchor.click(); + URL.revokeObjectURL(url); +}; + +/** Injected dependencies for {@link executePsdExport}. */ +export interface ExecutePsdExportDeps { + /** Surface factory (usually the engine's `RasterBackend`). */ + backend: { createSurface(width: number, height: number): RasterSurface }; + /** + * Ensures a layer's cache is rasterized and returns its surface plus the + * content `rect` (layer-local origin/size) those pixels occupy. The engine + * wires this to its rasterize path (reading live paint caches when present). + */ + getLayerSurface(layerId: string): Promise<{ surface: RasterSurface; rect: Rect }>; + /** Reads a surface region's pixels (default `getImageData`). */ + readImageData?(surface: RasterSurface, rect: Rect): ImageData; + /** Writes pixels back to a surface (default `putImageData`). */ + writeImageData?(surface: RasterSurface, imageData: ImageData, x: number, y: number): void; + /** Serializes a PSD to bytes (default: lazy `ag-psd` `writePsd`). */ + writePsd?(psd: Psd): Promise; + /** Triggers the download (default: Blob + anchor click). */ + download?(data: ArrayBuffer, fileName: string): void; +} + +/** Sets a 2D context transform from a matrix. */ +const setTransform = (ctx: Ctx, m: Mat2d): void => { + ctx.setTransform(m.a, m.b, m.c, m.d, m.e, m.f); +}; + +/** + * Bakes one planned layer's pixels into a world-AABB-sized surface: draws the + * layer's cache through its transform (offset into the AABB), then bakes any + * non-destructive adjustments in place. Opacity/blend are NOT baked — they ride + * on the PSD layer. Returns the surface + its straight-alpha `ImageData`. + */ +const bakeLayer = async ( + planLayer: PsdPlanLayer, + deps: ExecutePsdExportDeps, + read: (surface: RasterSurface, rect: Rect) => ImageData, + write: (surface: RasterSurface, imageData: ImageData, x: number, y: number) => void +): Promise<{ surface: RasterSurface; imageData: ImageData }> => { + const { worldRect } = planLayer; + const width = worldRect.width; + const height = worldRect.height; + const surface = deps.backend.createSurface(width, height); + const ctx = surface.ctx; + setTransform(ctx, { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }); + ctx.clearRect(0, 0, width, height); + + const { rect, surface: cache } = await deps.getLayerSurface(planLayer.id); + // local→world then shift into AABB-local (translation only affects e/f). + const local = layerMatrix(planLayer.transform); + setTransform(ctx, { ...local, e: local.e - worldRect.x, f: local.f - worldRect.y }); + // The cache holds pixels for `rect` in layer-local space; draw at that origin. + if (rect.width > 0 && rect.height > 0) { + ctx.drawImage(cache.canvas, rect.x, rect.y); + } + + const fullRect: Rect = { height, width, x: 0, y: 0 }; + const imageData = read(surface, fullRect); + if (planLayer.adjustments) { + applyAdjustments(imageData, planLayer.adjustments); + // Write the adjusted pixels back so the flattened composite (below) reuses + // this surface directly. + write(surface, imageData, 0, 0); + } + return { imageData, surface }; +}; + +/** + * Executes a PSD export plan: bakes each layer, flattens the enabled layers into + * a merged composite (so Photoshop/Bridge show a correct preview — ag-psd does + * NOT regenerate the composite), assembles the {@link Psd}, serializes via the + * lazily-imported `ag-psd`, and triggers a download. No-op for a non-`ok` plan. + */ +export const executePsdExport = async ( + plan: PsdExportPlan, + fileName: string, + deps: ExecutePsdExportDeps +): Promise => { + if (plan.status !== 'ok') { + return; + } + const read = deps.readImageData ?? defaultReadImageData; + const write = deps.writeImageData ?? defaultWriteImageData; + const writePsdFn = deps.writePsd ?? defaultWritePsd; + const download = deps.download ?? defaultDownload; + + const children: AgPsdLayer[] = []; + const baked: { planLayer: PsdPlanLayer; surface: RasterSurface }[] = []; + + for (const planLayer of plan.layers) { + const { imageData, surface } = await bakeLayer(planLayer, deps, read, write); + baked.push({ planLayer, surface }); + children.push({ + blendMode: planLayer.blendMode, + bottom: planLayer.bottom, + hidden: planLayer.hidden, + imageData, + left: planLayer.left, + name: planLayer.name, + opacity: planLayer.opacity, + right: planLayer.right, + top: planLayer.top, + }); + } + + // Flatten the enabled layers (bottom-to-top = plan order) into the merged + // composite the PSD carries as its full-document preview. + const composite = deps.backend.createSurface(plan.width, plan.height); + const cctx = composite.ctx; + setTransform(cctx, { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }); + cctx.clearRect(0, 0, plan.width, plan.height); + for (const { planLayer, surface } of baked) { + if (planLayer.hidden) { + continue; + } + cctx.globalAlpha = planLayer.opacity; + cctx.globalCompositeOperation = planLayer.compositeBlend; + cctx.drawImage(surface.canvas, planLayer.left, planLayer.top); + } + cctx.globalAlpha = 1; + cctx.globalCompositeOperation = 'source-over'; + + const psd: Psd = { + children, + height: plan.height, + imageData: read(composite, { height: plan.height, width: plan.width, x: 0, y: 0 }), + width: plan.width, + }; + + const bytes = await writePsdFn(psd); + download(bytes, fileName); +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/freehand.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/freehand.test.ts new file mode 100644 index 00000000000..c9eff1ac481 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/freehand.test.ts @@ -0,0 +1,85 @@ +import type { StrokeSamplePoint } from '@workbench/canvas-engine/freehand'; +import type { Vec2 } from '@workbench/canvas-engine/types'; + +import { polygonBounds, polygonToSvgPath, strokeOutlinePolygon, strokeToPath } from '@workbench/canvas-engine/freehand'; +import { describe, expect, it } from 'vitest'; + +/** Signed polygon area (shoelace); sign indicates winding, magnitude the area. */ +const signedArea = (polygon: readonly Vec2[]): number => { + let sum = 0; + for (let i = 0; i < polygon.length; i++) { + const a = polygon[i]!; + const b = polygon[(i + 1) % polygon.length]!; + sum += a.x * b.y - b.x * a.y; + } + return sum / 2; +}; + +const horizontalLine = (pressure: number): StrokeSamplePoint[] => + Array.from({ length: 9 }, (_, i) => ({ pressure, x: 10 + i * 10, y: 50 })); + +describe('strokeOutlinePolygon', () => { + it('returns an empty polygon for no points', () => { + expect(strokeOutlinePolygon([], { size: 20 })).toEqual([]); + }); + + it('produces a closed, non-degenerate outline around a straight line', () => { + const polygon = strokeOutlinePolygon(horizontalLine(0.5), { size: 20, thinning: 0 }); + expect(polygon.length).toBeGreaterThan(2); + // A real 2D band, not a collinear degenerate: non-zero winding area. + expect(Math.abs(signedArea(polygon))).toBeGreaterThan(0); + }); + + it('brackets the input points with a width near the base size (no thinning)', () => { + const size = 20; + const bounds = polygonBounds(strokeOutlinePolygon(horizontalLine(0.5), { size, thinning: 0 })); + // The band spans the drawn length (~80px) plus the round caps. + expect(bounds.width).toBeGreaterThan(80); + // With thinning disabled the perpendicular extent tracks the base diameter. + expect(bounds.height).toBeGreaterThan(size * 0.5); + expect(bounds.height).toBeLessThan(size * 2); + // Centered on the y=50 line the points sit on. + expect(bounds.y).toBeLessThan(50); + expect(bounds.y + bounds.height).toBeGreaterThan(50); + }); + + it('widens the stroke as pressure increases when thinning is enabled', () => { + const opts = { size: 40, thinning: 0.8 } as const; + const light = polygonBounds(strokeOutlinePolygon(horizontalLine(0.1), opts)); + const heavy = polygonBounds(strokeOutlinePolygon(horizontalLine(0.95), opts)); + expect(heavy.height).toBeGreaterThan(light.height); + }); +}); + +describe('polygonToSvgPath', () => { + it('serializes a polygon to a closed move/line path', () => { + const path = polygonToSvgPath([ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 10, y: 5 }, + ]); + expect(path).toBe('M 0 0 L 10 0 L 10 5 Z'); + }); + + it('returns an empty string for an empty polygon', () => { + expect(polygonToSvgPath([])).toBe(''); + }); +}); + +describe('strokeToPath', () => { + it('builds the path via the injected factory and returns the polygon bounds', () => { + const seen: string[] = []; + const marker = { __brand: 'path' } as unknown as Path2D; + const createPath2D = (d?: string): Path2D => { + seen.push(d ?? ''); + return marker; + }; + const result = strokeToPath(horizontalLine(0.5), { size: 20, thinning: 0 }, createPath2D); + + expect(result.path).toBe(marker); + expect(seen).toHaveLength(1); + expect(seen[0]).toMatch(/^M /); + expect(result.polygon.length).toBeGreaterThan(2); + expect(result.bounds).toEqual(polygonBounds(result.polygon)); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/freehand.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/freehand.ts new file mode 100644 index 00000000000..83e57cd3d4f --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/freehand.ts @@ -0,0 +1,122 @@ +/** + * A thin wrapper over `perfect-freehand`, turning a pressure-sampled point path + * into the filled outline polygon of a variable-width stroke. + * + * The polygon math ({@link strokeOutlinePolygon}) is pure and DOM-free, so it is + * fully unit-testable in node. `Path2D` does not exist in node, so building the + * fillable path is split out into {@link strokeToPath}, which takes an injected + * `createPath2D` factory (the engine passes `(d) => new Path2D(d)`; tests pass a + * stub). This keeps the interesting geometry testable without a DOM. + * + * Zero React, zero import-time side effects. + */ + +import type { Rect, Vec2 } from '@workbench/canvas-engine/types'; + +import { getStroke } from 'perfect-freehand'; + +/** A single pressure-sampled input point for a stroke. */ +export interface StrokeSamplePoint { + x: number; + y: number; + /** Pressure in [0, 1]. */ + pressure: number; +} + +/** Tuning for the freehand outline. `size` is the base stroke diameter in document units. */ +export interface FreehandOptions { + /** Base stroke diameter (document units). */ + size: number; + /** Effect of pressure on width, [0, 1]. 0 disables pressure sensitivity. */ + thinning?: number; + /** Edge softening, [0, 1]. */ + smoothing?: number; + /** Input smoothing / lag, [0, 1]. */ + streamline?: number; + /** Whether the stroke is complete (adds the end cap). */ + last?: boolean; +} + +/** Factory for a `Path2D`; injected so the polygon math stays node-testable. */ +export type CreatePath2D = (d?: string) => Path2D; + +const DEFAULT_SMOOTHING = 0.5; +const DEFAULT_STREAMLINE = 0.5; +const DEFAULT_THINNING = 0.5; + +/** + * Computes the filled outline polygon (a closed ring of document-space points) + * of a variable-width stroke through `points`. Pure and DOM-free. + */ +export const strokeOutlinePolygon = (points: readonly StrokeSamplePoint[], opts: FreehandOptions): Vec2[] => { + if (points.length === 0) { + return []; + } + const outline = getStroke( + points.map((p) => [p.x, p.y, p.pressure]), + { + last: opts.last ?? false, + simulatePressure: false, + size: opts.size, + smoothing: opts.smoothing ?? DEFAULT_SMOOTHING, + streamline: opts.streamline ?? DEFAULT_STREAMLINE, + thinning: opts.thinning ?? DEFAULT_THINNING, + } + ); + return outline.map(([x, y]) => ({ x, y })); +}; + +/** Serializes an outline polygon to an SVG path string (`M … L … Z`). */ +export const polygonToSvgPath = (polygon: readonly Vec2[]): string => { + if (polygon.length === 0) { + return ''; + } + const [first, ...rest] = polygon; + let d = `M ${first.x} ${first.y}`; + for (const p of rest) { + d += ` L ${p.x} ${p.y}`; + } + return `${d} Z`; +}; + +/** Axis-aligned bounds of an outline polygon; an empty polygon yields a zero-size rect. */ +export const polygonBounds = (polygon: readonly Vec2[]): Rect => { + if (polygon.length === 0) { + return { height: 0, width: 0, x: 0, y: 0 }; + } + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const p of polygon) { + if (p.x < minX) { + minX = p.x; + } + if (p.y < minY) { + minY = p.y; + } + if (p.x > maxX) { + maxX = p.x; + } + if (p.y > maxY) { + maxY = p.y; + } + } + return { height: maxY - minY, width: maxX - minX, x: minX, y: minY }; +}; + +/** + * Builds a fillable `Path2D` for the stroke through `points`, using the injected + * `createPath2D` factory (so callers in node can stub it). Returns the built + * path and the polygon's document-space {@link Rect} bounds so the caller can + * derive the dirty region without recomputing the outline. + */ +export const strokeToPath = ( + points: readonly StrokeSamplePoint[], + opts: FreehandOptions, + createPath2D: CreatePath2D +): { path: Path2D; polygon: Vec2[]; bounds: Rect } => { + const polygon = strokeOutlinePolygon(points, opts); + const path = createPath2D(polygonToSvgPath(polygon)); + return { bounds: polygonBounds(polygon), path, polygon }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/history/documentPatch.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/history/documentPatch.test.ts new file mode 100644 index 00000000000..f98133c8069 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/history/documentPatch.test.ts @@ -0,0 +1,30 @@ +import type { WorkbenchAction } from '@workbench/workbenchState'; + +import { describe, expect, it, vi } from 'vitest'; + +import { createDocumentPatchEntry, DOCUMENT_PATCH_DEFAULT_BYTES } from './documentPatch'; + +const forward: WorkbenchAction = { direction: 1, type: 'cycleStagedImage' }; +const inverse: WorkbenchAction = { direction: -1, type: 'cycleStagedImage' }; + +describe('createDocumentPatchEntry', () => { + it('dispatches inverse on undo and forward on redo', () => { + const dispatch = vi.fn(); + const entry = createDocumentPatchEntry({ dispatch, forward, inverse, label: 'Cycle' }); + + entry.undo(); + expect(dispatch).toHaveBeenNthCalledWith(1, inverse); + entry.redo(); + expect(dispatch).toHaveBeenNthCalledWith(2, forward); + }); + + it('defaults bytes to a small nominal cost, overridable', () => { + const dispatch = vi.fn(); + const entry = createDocumentPatchEntry({ dispatch, forward, inverse, label: 'Cycle' }); + expect(entry.bytes).toBe(DOCUMENT_PATCH_DEFAULT_BYTES); + + const heavier = createDocumentPatchEntry({ bytes: 4096, dispatch, forward, inverse, label: 'Cycle' }); + expect(heavier.bytes).toBe(4096); + expect(heavier.label).toBe('Cycle'); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/history/documentPatch.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/history/documentPatch.ts new file mode 100644 index 00000000000..000da17fcbf --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/history/documentPatch.ts @@ -0,0 +1,49 @@ +/** + * A structural history entry: a pair of reducer actions (forward + inverse) + * dispatched to undo/redo a document-shape change. + * + * Unlike a pixel {@link createImagePatchEntry | image patch}, a document patch + * carries no bitmaps — just two {@link WorkbenchAction}s. Undo dispatches the + * `inverse`, redo dispatches the `forward`. It exists so structural canvas edits + * (add/remove/reorder layer, rename, …) can live on the same engine-owned undo + * stack as paint edits. Phase 3's layers-panel operations lean on this; P2.1 uses + * it lightly (if at all) for composing an auto-created layer into a stroke's undo. + * + * Byte cost is tiny (actions are small plain objects); the default keeps it off + * the byte budget's radar while still counting as one of the entry-count slots. + * + * Zero React, zero DOM, zero import-time side effects. + */ + +import type { WorkbenchAction } from '@workbench/workbenchState'; + +import type { HistoryEntry } from './history'; + +/** Nominal byte cost for a structural entry (small; actions are plain objects). */ +export const DOCUMENT_PATCH_DEFAULT_BYTES = 256; + +/** Options for {@link createDocumentPatchEntry}. */ +export interface CreateDocumentPatchEntryOptions { + label: string; + /** The action that performs the change (dispatched on redo). */ + forward: WorkbenchAction; + /** The action that reverses the change (dispatched on undo). */ + inverse: WorkbenchAction; + /** The reducer bridge. */ + dispatch(action: WorkbenchAction): void; + /** Approximate retained size (default {@link DOCUMENT_PATCH_DEFAULT_BYTES}). */ + bytes?: number; +} + +/** Creates a reversible structural entry that dispatches inverse on undo, forward on redo. */ +export const createDocumentPatchEntry = (opts: CreateDocumentPatchEntryOptions): HistoryEntry => { + const { dispatch, forward, inverse, label } = opts; + const bytes = opts.bytes ?? DOCUMENT_PATCH_DEFAULT_BYTES; + + return { + bytes, + label, + redo: () => dispatch(forward), + undo: () => dispatch(inverse), + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/history/history.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/history/history.test.ts new file mode 100644 index 00000000000..a184f5e2441 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/history/history.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { HistoryEntry } from './history'; + +import { createHistory, HISTORY_BYTE_BUDGET, HISTORY_MAX_ENTRIES } from './history'; + +/** A tiny entry that records undo/redo calls into a shared log. */ +const makeEntry = (label: string, log: string[], bytes = 0): HistoryEntry => ({ + bytes, + label, + redo: () => log.push(`redo:${label}`), + undo: () => log.push(`undo:${label}`), +}); + +describe('createHistory: push / undo / redo ordering', () => { + it('undoes and redoes entries in LIFO order', () => { + const log: string[] = []; + const history = createHistory(); + history.push(makeEntry('a', log)); + history.push(makeEntry('b', log)); + history.push(makeEntry('c', log)); + + history.undo(); + history.undo(); + expect(log).toEqual(['undo:c', 'undo:b']); + + history.redo(); + expect(log).toEqual(['undo:c', 'undo:b', 'redo:b']); + + history.undo(); + history.undo(); + expect(log).toEqual(['undo:c', 'undo:b', 'redo:b', 'undo:b', 'undo:a']); + }); + + it('undo / redo are no-ops on empty stacks', () => { + const log: string[] = []; + const history = createHistory(); + history.undo(); + history.redo(); + expect(log).toEqual([]); + expect(history.canUndo()).toBe(false); + expect(history.canRedo()).toBe(false); + }); +}); + +describe('createHistory: redo cleared on push', () => { + it('drops the redo stack when a new entry is pushed', () => { + const log: string[] = []; + const history = createHistory(); + history.push(makeEntry('a', log)); + history.push(makeEntry('b', log)); + history.undo(); // b -> redo + expect(history.canRedo()).toBe(true); + + history.push(makeEntry('c', log)); + expect(history.canRedo()).toBe(false); + + // Redoing does nothing: the redo stack was cleared by the push. + history.redo(); + expect(log).toEqual(['undo:b']); + }); +}); + +describe('createHistory: entry-count eviction', () => { + it('evicts the oldest entry beyond the 64-entry budget', () => { + const log: string[] = []; + const history = createHistory(); + // Push one past the default cap. + for (let i = 0; i < HISTORY_MAX_ENTRIES + 1; i += 1) { + history.push(makeEntry(`e${i}`, log)); + } + // Undo every retained entry: there should be exactly the cap, and the very + // first entry (e0) should have been evicted (never undone). + let undos = 0; + while (history.canUndo()) { + history.undo(); + undos += 1; + } + expect(undos).toBe(HISTORY_MAX_ENTRIES); + expect(log).not.toContain('undo:e0'); + expect(log).toContain('undo:e1'); + expect(log[log.length - 1]).toBe('undo:e1'); + }); + + it('honors a custom maxEntries', () => { + const log: string[] = []; + const history = createHistory({ maxEntries: 2 }); + history.push(makeEntry('a', log)); + history.push(makeEntry('b', log)); + history.push(makeEntry('c', log)); // evicts 'a' + let undos = 0; + while (history.canUndo()) { + history.undo(); + undos += 1; + } + expect(undos).toBe(2); + expect(log).toEqual(['undo:c', 'undo:b']); + }); +}); + +describe('createHistory: byte-budget eviction', () => { + it('evicts oldest entries until under the byte budget', () => { + const log: string[] = []; + // Budget fits two 10-byte entries but not three. + const history = createHistory({ byteBudget: 25 }); + history.push(makeEntry('a', log, 10)); + history.push(makeEntry('b', log, 10)); + expect(history.canUndo()).toBe(true); + history.push(makeEntry('c', log, 10)); // total 30 > 25 -> evict 'a' + + let undos = 0; + while (history.canUndo()) { + history.undo(); + undos += 1; + } + expect(undos).toBe(2); + expect(log).toEqual(['undo:c', 'undo:b']); + }); + + it('exports the 256 MB default byte budget', () => { + expect(HISTORY_BYTE_BUDGET).toBe(256 * 1024 * 1024); + }); +}); + +describe('createHistory: change listener', () => { + it('fires on push / undo / redo / clear', () => { + const log: string[] = []; + const history = createHistory(); + const listener = vi.fn(); + const unsubscribe = history.subscribe(listener); + + history.push(makeEntry('a', log)); + expect(listener).toHaveBeenCalledTimes(1); + history.undo(); + expect(listener).toHaveBeenCalledTimes(2); + history.redo(); + expect(listener).toHaveBeenCalledTimes(3); + history.clear(); + expect(listener).toHaveBeenCalledTimes(4); + + // Clear again with empty stacks: no change, no notification. + history.clear(); + expect(listener).toHaveBeenCalledTimes(4); + + unsubscribe(); + history.push(makeEntry('b', log)); + expect(listener).toHaveBeenCalledTimes(4); + }); + + it('reflects canUndo / canRedo transitions', () => { + const log: string[] = []; + const history = createHistory(); + expect(history.canUndo()).toBe(false); + history.push(makeEntry('a', log)); + expect(history.canUndo()).toBe(true); + expect(history.canRedo()).toBe(false); + history.undo(); + expect(history.canUndo()).toBe(false); + expect(history.canRedo()).toBe(true); + }); +}); + +describe('createHistory: amendLast', () => { + it('replaces the most recent entry in place and adjusts the byte total', () => { + const log: string[] = []; + const history = createHistory({ byteBudget: 25 }); + history.push(makeEntry('a', log, 10)); + history.push(makeEntry('b', log, 10)); // burst start + + // Coalesce: replace 'b' with 'b2' (still one burst entry). + history.amendLast(makeEntry('b2', log, 10)); + + let undos = 0; + while (history.canUndo()) { + history.undo(); + undos += 1; + } + // Two entries retained (a + b2), not three: amend did not grow the stack. + expect(undos).toBe(2); + expect(log).toEqual(['undo:b2', 'undo:a']); + }); + + it('pushes when the undo stack is empty', () => { + const log: string[] = []; + const history = createHistory(); + history.amendLast(makeEntry('a', log)); + expect(history.canUndo()).toBe(true); + history.undo(); + expect(log).toEqual(['undo:a']); + }); + + it('clears the redo stack like push', () => { + const log: string[] = []; + const history = createHistory(); + history.push(makeEntry('a', log)); + history.push(makeEntry('b', log)); + history.undo(); // b -> redo + expect(history.canRedo()).toBe(true); + history.amendLast(makeEntry('a2', log)); + expect(history.canRedo()).toBe(false); + }); + + it('is a no-op while applying', () => { + const log: string[] = []; + const history = createHistory(); + const reentrant: HistoryEntry = { + bytes: 0, + label: 'reentrant', + redo: () => {}, + undo: () => history.amendLast(makeEntry('sneaky', log)), + }; + history.push(reentrant); + history.undo(); + // The amend during undo was dropped; only the reentrant entry is reachable. + history.redo(); + let undos = 0; + while (history.canUndo()) { + history.undo(); + undos += 1; + } + expect(undos).toBe(1); + }); +}); + +describe('createHistory: re-entrancy guard', () => { + it('reports isApplying during undo/redo and drops entries pushed while applying', () => { + const log: string[] = []; + const history = createHistory(); + const observed: boolean[] = []; + + const reentrant: HistoryEntry = { + bytes: 0, + label: 'reentrant', + redo: () => { + observed.push(history.isApplying()); + // A replay must not be able to record a new entry. + history.push(makeEntry('sneaky', log)); + }, + undo: () => { + observed.push(history.isApplying()); + history.push(makeEntry('sneaky', log)); + }, + }; + + expect(history.isApplying()).toBe(false); + history.push(reentrant); + history.undo(); + expect(observed).toEqual([true]); + expect(history.isApplying()).toBe(false); + // The sneaky push during undo was dropped: redo stack still holds only the + // reentrant entry, and no 'sneaky' entry is reachable. + expect(history.canRedo()).toBe(true); + + history.redo(); + expect(observed).toEqual([true, true]); + // Still exactly one undoable entry (the reentrant one); the sneaky pushes + // never landed. + let undos = 0; + while (history.canUndo()) { + history.undo(); + undos += 1; + } + expect(undos).toBe(1); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/history/history.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/history/history.ts new file mode 100644 index 00000000000..e98e546a6d6 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/history/history.ts @@ -0,0 +1,222 @@ +/** + * Engine-owned canvas history: a bounded undo/redo stack of opaque entries. + * + * Project-level undo (`pushUndo` in the reducer) deliberately no longer covers + * the canvas (Phase 0) — pixels never cross the reducer boundary, so the reducer + * can't cheaply snapshot them. Instead the canvas keeps its OWN history here, + * living entirely inside the engine. Each {@link HistoryEntry} is a self-contained + * undo/redo pair (a paint {@link createImagePatchEntry | pixel patch} or a + * structural {@link createDocumentPatchEntry | document patch}) plus a byte cost + * used to bound memory. + * + * Budgets (evict oldest beyond EITHER dimension): + * - at most {@link HISTORY_MAX_ENTRIES} undo entries, and + * - at most {@link HISTORY_BYTE_BUDGET} bytes across both stacks. + * + * Re-entrancy: while an entry's `undo`/`redo` runs, {@link History.isApplying} + * is `true`. The engine's paint/apply paths check it and skip recording, and + * `push` is a hard no-op during apply — so replaying an entry can never spawn a + * new entry (which would corrupt the stacks). + * + * Zero React, zero DOM, zero import-time side effects. + */ + +/** Max number of undo entries retained before the oldest is evicted. */ +export const HISTORY_MAX_ENTRIES = 64; + +/** Max total bytes retained across the undo + redo stacks before the oldest is evicted (256 MB). */ +export const HISTORY_BYTE_BUDGET = 256 * 1024 * 1024; + +/** One reversible step. `bytes` is the (approximate) memory the entry pins, for the budget. */ +export interface HistoryEntry { + /** Human-readable label (e.g. "Brush stroke"). */ + readonly label: string; + /** Approximate retained size in bytes (e.g. before+after ImageData byteLength). */ + readonly bytes: number; + /** Reverts the change. Must not push new history entries. */ + undo(): void; + /** Re-applies the change. Must not push new history entries. */ + redo(): void; +} + +/** Options for {@link createHistory}. */ +export interface CreateHistoryOptions { + /** Undo-entry cap (default {@link HISTORY_MAX_ENTRIES}). */ + maxEntries?: number; + /** Total-byte cap across both stacks (default {@link HISTORY_BYTE_BUDGET}). */ + byteBudget?: number; +} + +/** The imperative history handle. */ +export interface History { + /** Records a new entry (clearing the redo stack) and enforces the budgets. No-op while applying. */ + push(entry: HistoryEntry): void; + /** + * Replaces the most recent undo entry in place (adjusting the byte total), + * used to coalesce a rapid burst of same-target edits — e.g. arrow-key nudges — + * into a single reversible step. Falls back to {@link push} when the undo stack + * is empty. Clears the redo stack like `push`. No-op while applying. + */ + amendLast(entry: HistoryEntry): void; + /** Reverts the most recent entry (moving it onto the redo stack). No-op when empty or already applying. */ + undo(): void; + /** Re-applies the most recently undone entry (moving it back onto the undo stack). No-op when empty or applying. */ + redo(): void; + /** True when there is at least one entry that can be undone. */ + canUndo(): boolean; + /** True when there is at least one entry that can be redone. */ + canRedo(): boolean; + /** True while an entry's `undo`/`redo` is executing (re-entrancy guard). */ + isApplying(): boolean; + /** Drops both stacks (document replace / project switch / snapshot restore). */ + clear(): void; + /** Subscribes to any change in `canUndo`/`canRedo`. Returns an unsubscribe function. */ + subscribe(listener: () => void): () => void; +} + +/** Creates a bounded history stack. */ +export const createHistory = (opts: CreateHistoryOptions = {}): History => { + const maxEntries = Math.max(1, opts.maxEntries ?? HISTORY_MAX_ENTRIES); + const byteBudget = Math.max(0, opts.byteBudget ?? HISTORY_BYTE_BUDGET); + + const undoStack: HistoryEntry[] = []; + const redoStack: HistoryEntry[] = []; + const listeners = new Set<() => void>(); + + // Running byte totals, kept in sync with the stacks so eviction is O(1) per drop. + let undoBytes = 0; + let redoBytes = 0; + // Re-entrancy flag: true while replaying an entry. + let applying = false; + + const notify = (): void => { + for (const listener of listeners) { + listener(); + } + }; + + const clearRedo = (): void => { + if (redoStack.length === 0) { + return; + } + redoStack.length = 0; + redoBytes = 0; + }; + + /** Evicts the oldest undo entries until BOTH budgets are satisfied. */ + const enforceBudgets = (): void => { + while (undoStack.length > maxEntries && undoStack.length > 0) { + const evicted = undoStack.shift(); + if (evicted) { + undoBytes -= evicted.bytes; + } + } + while (undoBytes + redoBytes > byteBudget && undoStack.length > 0) { + const evicted = undoStack.shift(); + if (evicted) { + undoBytes -= evicted.bytes; + } + } + }; + + const push = (entry: HistoryEntry): void => { + // Replaying an entry must never record a new one; drop it defensively. + if (applying) { + return; + } + clearRedo(); + undoStack.push(entry); + undoBytes += entry.bytes; + enforceBudgets(); + // A push always clears redo and grows undo, so both booleans may have moved + // (canUndo→true on the first push, canRedo→false when redo was non-empty). + notify(); + }; + + const amendLast = (entry: HistoryEntry): void => { + if (applying) { + return; + } + if (undoStack.length === 0) { + push(entry); + return; + } + clearRedo(); + const replaced = undoStack.pop(); + if (replaced) { + undoBytes -= replaced.bytes; + } + undoStack.push(entry); + undoBytes += entry.bytes; + enforceBudgets(); + notify(); + }; + + const undo = (): void => { + if (applying || undoStack.length === 0) { + return; + } + const entry = undoStack.pop(); + if (!entry) { + return; + } + undoBytes -= entry.bytes; + redoStack.push(entry); + redoBytes += entry.bytes; + applying = true; + try { + entry.undo(); + } finally { + applying = false; + } + notify(); + }; + + const redo = (): void => { + if (applying || redoStack.length === 0) { + return; + } + const entry = redoStack.pop(); + if (!entry) { + return; + } + redoBytes -= entry.bytes; + undoStack.push(entry); + undoBytes += entry.bytes; + applying = true; + try { + entry.redo(); + } finally { + applying = false; + } + notify(); + }; + + const clear = (): void => { + if (undoStack.length === 0 && redoStack.length === 0) { + return; + } + undoStack.length = 0; + redoStack.length = 0; + undoBytes = 0; + redoBytes = 0; + notify(); + }; + + return { + amendLast, + canRedo: () => redoStack.length > 0, + canUndo: () => undoStack.length > 0, + clear, + isApplying: () => applying, + push, + redo, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + undo, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/history/imagePatch.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/history/imagePatch.test.ts new file mode 100644 index 00000000000..c112243e10c --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/history/imagePatch.test.ts @@ -0,0 +1,71 @@ +import type { Rect } from '@workbench/canvas-engine/types'; + +import { describe, expect, it, vi } from 'vitest'; + +import { createImagePatchEntry } from './imagePatch'; + +/** A fake ImageData carrier sized to `w`x`h` (byteLength = w*h*4). */ +const fakeImageData = (w: number, h: number): ImageData => + ({ colorSpace: 'srgb', data: new Uint8ClampedArray(w * h * 4), height: h, width: w }) as unknown as ImageData; + +const rect: Rect = { height: 3, width: 4, x: 5, y: 6 }; + +describe('createImagePatchEntry', () => { + it('applies before on undo and after on redo with the patch rect', () => { + const before = fakeImageData(4, 3); + const after = fakeImageData(4, 3); + const apply = vi.fn(); + + const entry = createImagePatchEntry({ after, apply, before, label: 'Brush stroke', layerId: 'L1', rect }); + + entry.undo(); + expect(apply).toHaveBeenNthCalledWith(1, 'L1', rect, before); + entry.redo(); + expect(apply).toHaveBeenNthCalledWith(2, 'L1', rect, after); + }); + + it('accounts bytes as both buffers combined', () => { + const before = fakeImageData(4, 3); // 48 bytes + const after = fakeImageData(4, 3); // 48 bytes + const entry = createImagePatchEntry({ + after, + apply: vi.fn(), + before, + label: 'Brush stroke', + layerId: 'L1', + rect, + }); + expect(entry.bytes).toBe(before.data.byteLength + after.data.byteLength); + expect(entry.bytes).toBe(96); + expect(entry.label).toBe('Brush stroke'); + }); + + it('snapshots the rect so later caller mutation does not shift the write', () => { + const before = fakeImageData(4, 3); + const after = fakeImageData(4, 3); + const apply = vi.fn(); + const mutableRect: Rect = { height: 3, width: 4, x: 5, y: 6 }; + const entry = createImagePatchEntry({ + after, + apply, + before, + label: 'Brush stroke', + layerId: 'L1', + rect: mutableRect, + }); + mutableRect.x = 999; + entry.undo(); + expect(apply).toHaveBeenCalledWith('L1', { height: 3, width: 4, x: 5, y: 6 }, before); + }); + + it('throws when an ImageData dimension disagrees with the rect', () => { + const good = fakeImageData(4, 3); + const wrong = fakeImageData(2, 3); + expect(() => + createImagePatchEntry({ after: wrong, apply: vi.fn(), before: good, label: 'x', layerId: 'L1', rect }) + ).toThrow(/does not match rect/); + expect(() => + createImagePatchEntry({ after: good, apply: vi.fn(), before: wrong, label: 'x', layerId: 'L1', rect }) + ).toThrow(/before ImageData/); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/history/imagePatch.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/history/imagePatch.ts new file mode 100644 index 00000000000..a7825d94118 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/history/imagePatch.ts @@ -0,0 +1,73 @@ +/** + * A paint-edit history entry: a before/after pair of `ImageData` over a + * LAYER-LOCAL rect, reversed by putting the pixels back into the layer's cache + * surface. Layer-local coordinates are stable across cache growth/reallocation + * (the cache's content rect origin can shift as strokes grow it), so an old + * entry replays correctly no matter how the cache has been resized since. + * + * Painting (P2.1) commits a stroke as a {@link StrokeCommittedEvent} carrying the + * `dirtyRect` plus `beforeImageData`/`afterImageData` (both sized to that rect). + * This wraps that pair into a {@link HistoryEntry}: undo puts `before`, redo puts + * `after`. The actual pixel write is delegated to an engine-provided + * {@link ImagePatchApply} so this module stays free of the cache/backend/bitmap + * store — it just owns the before/after bookkeeping and the byte accounting. + * + * Byte cost = both buffers' `byteLength`, which is what the history budget bounds. + * + * Zero React, zero DOM (ImageData is a plain data carrier here), zero import-time + * side effects. + */ + +import type { Rect } from '@workbench/canvas-engine/types'; + +import type { HistoryEntry } from './history'; + +/** + * Writes `pixels` into `layerId`'s cache at `rect`'s origin and propagates the + * edit (invalidate/version bump + mark the layer dirty for persistence). Provided + * by the engine; the entry never touches the cache or backend directly. + */ +export type ImagePatchApply = (layerId: string, rect: Rect, pixels: ImageData) => void; + +/** Options for {@link createImagePatchEntry}. */ +export interface CreateImagePatchEntryOptions { + layerId: string; + /** The painted region in LAYER-LOCAL space; its w/h must match the ImageData. */ + rect: Rect; + /** Cache pixels within `rect` before the stroke. */ + before: ImageData; + /** Cache pixels within `rect` after the stroke. */ + after: ImageData; + /** Entry label (e.g. "Brush stroke" / "Eraser stroke"). */ + label: string; + /** The engine's pixel-write bridge. */ + apply: ImagePatchApply; +} + +/** Throws if an ImageData's dimensions disagree with the patch rect. */ +const assertDims = (rect: Rect, image: ImageData, which: 'before' | 'after'): void => { + if (image.width !== rect.width || image.height !== rect.height) { + throw new Error( + `imagePatch: ${which} ImageData (${image.width}x${image.height}) does not match rect (${rect.width}x${rect.height})` + ); + } +}; + +/** Creates a reversible paint-edit entry from a committed stroke's before/after pixels. */ +export const createImagePatchEntry = (opts: CreateImagePatchEntryOptions): HistoryEntry => { + const { after, apply, before, label, layerId, rect } = opts; + assertDims(rect, before, 'before'); + assertDims(rect, after, 'after'); + + // Snapshot the rect so a later external mutation of the caller's object can't + // shift where undo/redo write. + const patchRect: Rect = { height: rect.height, width: rect.width, x: rect.x, y: rect.y }; + const bytes = before.data.byteLength + after.data.byteLength; + + return { + bytes, + label, + redo: () => apply(layerId, patchRect, after), + undo: () => apply(layerId, patchRect, before), + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/input/pointerPipeline.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/input/pointerPipeline.test.ts new file mode 100644 index 00000000000..9c7ce9dbbad --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/input/pointerPipeline.test.ts @@ -0,0 +1,427 @@ +import type { PointerPipelineDeps } from '@workbench/canvas-engine/input/pointerPipeline'; +import type { Tool, ToolContext } from '@workbench/canvas-engine/tools/tool'; +import type { PointerInput, ToolId } from '@workbench/canvas-engine/types'; + +import { createPointerPipeline } from '@workbench/canvas-engine/input/pointerPipeline'; +import { createViewport } from '@workbench/canvas-engine/viewport'; +import { describe, expect, it, vi } from 'vitest'; + +// ---- Fakes ----------------------------------------------------------------- + +interface FakePointerInit { + button?: number; + buttons?: number; + clientX?: number; + clientY?: number; + pointerId?: number; + pointerType?: string; + pressure?: number; + ctrlKey?: boolean; + altKey?: boolean; + coalesced?: FakePointerInit[]; +} + +const makePointerEvent = (init: FakePointerInit = {}): PointerEvent => { + const self: Record = { + altKey: init.altKey ?? false, + button: init.button ?? 0, + buttons: init.buttons ?? 1, + clientX: init.clientX ?? 0, + clientY: init.clientY ?? 0, + ctrlKey: init.ctrlKey ?? false, + metaKey: false, + pointerId: init.pointerId ?? 1, + pointerType: init.pointerType ?? 'mouse', + preventDefault: vi.fn(), + pressure: init.pressure ?? 0, + shiftKey: false, + timeStamp: 0, + }; + self.getCoalescedEvents = () => (init.coalesced ?? []).map(makePointerEvent); + return self as unknown as PointerEvent; +}; + +const makeKeyEvent = (init: { code?: string; key?: string; repeat?: boolean; target?: unknown }): KeyboardEvent => + ({ + code: init.code ?? '', + key: init.key ?? '', + preventDefault: vi.fn(), + repeat: init.repeat ?? false, + target: init.target ?? null, + }) as unknown as KeyboardEvent; + +const createHarness = ( + opts: { + tools?: ToolId[]; + handleEscape?: (o: { gestureWasActive: boolean }) => void; + maybeCommitModalSession?: () => boolean; + } = {} +) => { + const registered = new Set(opts.tools ?? ['view', 'brush']); + const tool: Tool & { + downs: PointerInput[]; + moves: { input: PointerInput; batch: readonly PointerInput[] }[]; + ups: PointerInput[]; + cancels: number; + } = { + cancels: 0, + downs: [], + id: 'brush', + moves: [], + onPointerCancel: () => { + tool.cancels += 1; + }, + onPointerDown: (_ctx, input) => { + tool.downs.push(input); + }, + onPointerMove: (_ctx, input, batch) => { + tool.moves.push({ batch, input }); + }, + onPointerUp: (_ctx, input) => { + tool.ups.push(input); + }, + ups: [], + }; + + const captures: number[] = []; + const releases: number[] = []; + const element = { + getBoundingClientRect: () => ({ left: 0, top: 0 }) as DOMRect, + releasePointerCapture: (id: number) => releases.push(id), + setPointerCapture: (id: number) => captures.push(id), + } as unknown as HTMLElement; + + let activeToolId: ToolId = 'brush'; + const setTool = vi.fn((id: ToolId) => { + activeToolId = id; + }); + const ctx = {} as ToolContext; + + const deps: PointerPipelineDeps = { + getActiveTool: () => tool, + getActiveToolId: () => activeToolId, + getInputElement: () => element, + getToolContext: () => ctx, + handleEscape: opts.handleEscape, + hasTool: (id) => registered.has(id), + maybeCommitModalSession: opts.maybeCommitModalSession, + setTool, + updateCursor: vi.fn(), + viewport: createViewport(), + }; + + return { captures, deps, pipeline: createPointerPipeline(deps), releases, setTool, tool }; +}; + +// ---- Tests ----------------------------------------------------------------- + +describe('pointer pipeline: pressure + capture', () => { + it('captures the pointer on primary down and defaults mouse pressure to 0.5', () => { + const h = createHarness(); + h.pipeline.onPointerDown(makePointerEvent({ pointerId: 7, pointerType: 'mouse', pressure: 0 })); + + expect(h.captures).toEqual([7]); + expect(h.tool.downs).toHaveLength(1); + expect(h.tool.downs[0]!.pressure).toBe(0.5); + }); + + it('preserves reported pen pressure', () => { + const h = createHarness(); + h.pipeline.onPointerDown(makePointerEvent({ pointerType: 'pen', pressure: 0.73 })); + expect(h.tool.downs[0]!.pressure).toBeCloseTo(0.73); + expect(h.tool.downs[0]!.pointerType).toBe('pen'); + }); + + it('releases capture on pointer up and forwards the up sample', () => { + const h = createHarness(); + h.pipeline.onPointerDown(makePointerEvent({ pointerId: 3 })); + h.pipeline.onPointerUp(makePointerEvent({ buttons: 0, pointerId: 3 })); + expect(h.releases).toEqual([3]); + expect(h.tool.ups).toHaveLength(1); + }); +}); + +describe('pointer pipeline: coalesced batching', () => { + it('expands coalesced events into a batch whose last element is the primary sample', () => { + const h = createHarness(); + h.pipeline.onPointerDown(makePointerEvent({})); + h.pipeline.onPointerMove( + makePointerEvent({ + clientX: 30, + coalesced: [ + { clientX: 10, clientY: 10 }, + { clientX: 20, clientY: 15 }, + { clientX: 30, clientY: 20 }, + ], + }) + ); + + expect(h.tool.moves).toHaveLength(1); + const { batch, input } = h.tool.moves[0]!; + expect(batch).toHaveLength(3); + expect(input).toBe(batch[2]); + expect(batch[0]!.screenPoint).toEqual({ x: 10, y: 10 }); + }); + + it('falls back to a single-sample batch when no coalesced events exist', () => { + const h = createHarness(); + h.pipeline.onPointerDown(makePointerEvent({})); + h.pipeline.onPointerMove(makePointerEvent({ clientX: 42, coalesced: [] })); + expect(h.tool.moves[0]!.batch).toHaveLength(1); + expect(h.tool.moves[0]!.input.screenPoint.x).toBe(42); + }); +}); + +describe('pointer pipeline: modal (text-edit) session commit on pointerdown', () => { + it('commits and swallows a primary press when a modal session is open (no capture/gesture/tool routing)', () => { + const maybeCommitModalSession = vi.fn(() => true); + const h = createHarness({ maybeCommitModalSession }); + const event = makePointerEvent({ pointerId: 5 }); + + h.pipeline.onPointerDown(event); + + expect(maybeCommitModalSession).toHaveBeenCalledTimes(1); + expect(h.captures).toEqual([]); // no pointer capture + expect(h.tool.downs).toHaveLength(0); // not routed to the active tool + expect(h.pipeline.isGestureActive()).toBe(false); // ran before the gesture flag is set + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it('starts a normal gesture when no modal session is open (returns false)', () => { + const maybeCommitModalSession = vi.fn(() => false); + const h = createHarness({ maybeCommitModalSession }); + + h.pipeline.onPointerDown(makePointerEvent({ pointerId: 6 })); + + expect(maybeCommitModalSession).toHaveBeenCalledTimes(1); + expect(h.tool.downs).toHaveLength(1); + expect(h.pipeline.isGestureActive()).toBe(true); + expect(h.captures).toEqual([6]); + }); +}); + +describe('pointer pipeline: Escape priority (handleEscape)', () => { + it('runs handleEscape with gestureWasActive=false when Escape is pressed idle', () => { + const handleEscape = vi.fn(); + const h = createHarness({ handleEscape }); + h.pipeline.onKeyDown(makeKeyEvent({ key: 'Escape' })); + expect(handleEscape).toHaveBeenCalledTimes(1); + expect(handleEscape).toHaveBeenCalledWith({ gestureWasActive: false }); + }); + + it('cancels the active gesture AND flags it to handleEscape (gestureWasActive=true)', () => { + const handleEscape = vi.fn(); + const h = createHarness({ handleEscape }); + h.pipeline.onPointerDown(makePointerEvent({})); + h.pipeline.onKeyDown(makeKeyEvent({ key: 'Escape' })); + expect(h.tool.cancels).toBe(1); + expect(handleEscape).toHaveBeenCalledWith({ gestureWasActive: true }); + }); + + it('does not run handleEscape when Escape targets an editable field', () => { + const handleEscape = vi.fn(); + const h = createHarness({ handleEscape }); + h.pipeline.onKeyDown(makeKeyEvent({ key: 'Escape', target: { tagName: 'INPUT' } })); + expect(handleEscape).not.toHaveBeenCalled(); + }); +}); + +describe('pointer pipeline: gesture cancel + secondary buttons', () => { + it('routes pointercancel to the tool and releases capture', () => { + const h = createHarness(); + h.pipeline.onPointerDown(makePointerEvent({ pointerId: 5 })); + h.pipeline.onPointerCancel(makePointerEvent({ pointerId: 5 })); + expect(h.tool.cancels).toBe(1); + expect(h.releases).toEqual([5]); + }); + + it('cancels the active gesture on Escape', () => { + const h = createHarness(); + h.pipeline.onPointerDown(makePointerEvent({ pointerId: 9 })); + h.pipeline.onKeyDown(makeKeyEvent({ key: 'Escape' })); + expect(h.tool.cancels).toBe(1); + // A subsequent up is ignored (the gesture already ended). + h.pipeline.onPointerUp(makePointerEvent({ buttons: 0, pointerId: 9 })); + expect(h.tool.ups).toHaveLength(0); + }); + + it('ignores secondary-button presses during an active gesture', () => { + const h = createHarness(); + h.pipeline.onPointerDown(makePointerEvent({ button: 0 })); + h.pipeline.onPointerDown(makePointerEvent({ button: 2, buttons: 3 })); + expect(h.tool.downs).toHaveLength(1); + }); + + it('ignores a standalone secondary-button press', () => { + const h = createHarness(); + h.pipeline.onPointerDown(makePointerEvent({ button: 2, buttons: 2 })); + expect(h.tool.downs).toHaveLength(0); + expect(h.captures).toHaveLength(0); + }); +}); + +describe('pointer pipeline: middle-mouse pan', () => { + it('pans the viewport on middle drag without routing to the tool', () => { + const h = createHarness(); + const panBy = vi.spyOn(h.deps.viewport, 'panBy'); + h.pipeline.onPointerDown(makePointerEvent({ button: 1, buttons: 4, clientX: 0 })); + h.pipeline.onPointerMove(makePointerEvent({ buttons: 4, clientX: 25 })); + expect(panBy).toHaveBeenCalledWith({ x: 25, y: 0 }); + expect(h.tool.moves).toHaveLength(0); + }); +}); + +describe('pointer pipeline: multi-pointer isolation during an active gesture', () => { + it('ignores move events from a second pointer while the first pointer is gesturing', () => { + const h = createHarness(); + h.pipeline.onPointerDown(makePointerEvent({ pointerId: 1 })); + h.pipeline.onPointerMove(makePointerEvent({ pointerId: 2, clientX: 99 })); + expect(h.tool.moves).toHaveLength(0); + h.pipeline.onPointerMove(makePointerEvent({ pointerId: 1, clientX: 10 })); + expect(h.tool.moves).toHaveLength(1); + }); + + it('ignores a second pointer up but ends the gesture on the first pointer up', () => { + const h = createHarness(); + h.pipeline.onPointerDown(makePointerEvent({ pointerId: 1 })); + h.pipeline.onPointerUp(makePointerEvent({ buttons: 0, pointerId: 2 })); + expect(h.tool.ups).toHaveLength(0); + expect(h.releases).toHaveLength(0); + h.pipeline.onPointerUp(makePointerEvent({ buttons: 0, pointerId: 1 })); + expect(h.tool.ups).toHaveLength(1); + expect(h.releases).toEqual([1]); + }); + + it('ignores cancel events from a second pointer during an active gesture', () => { + const h = createHarness(); + h.pipeline.onPointerDown(makePointerEvent({ pointerId: 1 })); + h.pipeline.onPointerCancel(makePointerEvent({ pointerId: 2 })); + expect(h.tool.cancels).toBe(0); + h.pipeline.onPointerCancel(makePointerEvent({ pointerId: 1 })); + expect(h.tool.cancels).toBe(1); + }); + + it('ignores a second pointerdown while a gesture is active', () => { + const h = createHarness(); + h.pipeline.onPointerDown(makePointerEvent({ pointerId: 1 })); + h.pipeline.onPointerDown(makePointerEvent({ pointerId: 2 })); + expect(h.tool.downs).toHaveLength(1); + expect(h.captures).toEqual([1]); + }); +}); + +describe('pointer pipeline: gesture-active state', () => { + it('reports the primary-button gesture from down to up', () => { + const h = createHarness(); + expect(h.pipeline.isGestureActive()).toBe(false); + h.pipeline.onPointerDown(makePointerEvent({ pointerId: 1 })); + expect(h.pipeline.isGestureActive()).toBe(true); + h.pipeline.onPointerMove(makePointerEvent({ pointerId: 1, clientX: 10 })); + expect(h.pipeline.isGestureActive()).toBe(true); + h.pipeline.onPointerUp(makePointerEvent({ buttons: 0, pointerId: 1 })); + expect(h.pipeline.isGestureActive()).toBe(false); + }); + + it('clears on pointercancel, Escape, and reset', () => { + const h = createHarness(); + h.pipeline.onPointerDown(makePointerEvent({ pointerId: 1 })); + h.pipeline.onPointerCancel(makePointerEvent({ pointerId: 1 })); + expect(h.pipeline.isGestureActive()).toBe(false); + + h.pipeline.onPointerDown(makePointerEvent({ pointerId: 2 })); + h.pipeline.onKeyDown(makeKeyEvent({ key: 'Escape' })); + expect(h.pipeline.isGestureActive()).toBe(false); + + h.pipeline.onPointerDown(makePointerEvent({ pointerId: 3 })); + h.pipeline.reset(); + expect(h.pipeline.isGestureActive()).toBe(false); + }); + + it('does not report a middle-mouse pan as a gesture', () => { + const h = createHarness(); + h.pipeline.onPointerDown(makePointerEvent({ button: 1, buttons: 4 })); + expect(h.pipeline.isGestureActive()).toBe(false); + }); +}); + +describe('pointer pipeline: cancelActiveGesture + reset run onPointerCancel', () => { + it('cancelActiveGesture releases capture and runs the tool cancel once; a no-op with no gesture', () => { + const h = createHarness(); + // No gesture: a no-op (the tool cancel must not fire). + h.pipeline.cancelActiveGesture(); + expect(h.tool.cancels).toBe(0); + + h.pipeline.onPointerDown(makePointerEvent({ pointerId: 5 })); + h.pipeline.cancelActiveGesture(); + expect(h.tool.cancels).toBe(1); + expect(h.releases).toContain(5); + expect(h.pipeline.isGestureActive()).toBe(false); + + // A second call is a no-op — the gesture is already cancelled. + h.pipeline.cancelActiveGesture(); + expect(h.tool.cancels).toBe(1); + }); + + it('reset cancels an in-flight gesture (runs onPointerCancel + releases capture), unlike a bare flag clear', () => { + const h = createHarness(); + h.pipeline.onPointerDown(makePointerEvent({ pointerId: 9 })); + expect(h.pipeline.isGestureActive()).toBe(true); + + h.pipeline.reset(); + expect(h.tool.cancels).toBe(1); + expect(h.releases).toContain(9); + expect(h.pipeline.isGestureActive()).toBe(false); + }); +}); + +describe('pointer pipeline: temporary modifier tools', () => { + it('holds view while space is down and restores the prior tool on release', () => { + const h = createHarness(); + h.pipeline.onPointerEnter(); + h.pipeline.onKeyDown(makeKeyEvent({ code: 'Space' })); + // Flagged `temporary` so a session-bearing tool (transform) can tell this + // apart from a real switch and keep its session alive across the hold. + expect(h.setTool).toHaveBeenLastCalledWith('view', { temporary: true }); + h.pipeline.onKeyUp(makeKeyEvent({ code: 'Space' })); + expect(h.setTool).toHaveBeenLastCalledWith('brush', { temporary: true }); + }); + + it('does not switch on space when the pointer is not over the canvas', () => { + const h = createHarness(); + h.pipeline.onKeyDown(makeKeyEvent({ code: 'Space' })); + expect(h.setTool).not.toHaveBeenCalled(); + }); + + it('alt-hold is a no-op switch when colorPicker is not registered', () => { + const h = createHarness({ tools: ['view', 'brush'] }); + h.pipeline.onPointerEnter(); + h.pipeline.onKeyDown(makeKeyEvent({ code: 'AltLeft' })); + h.pipeline.onKeyUp(makeKeyEvent({ code: 'AltLeft' })); + expect(h.setTool).not.toHaveBeenCalled(); + }); + + it('alt-hold switches to colorPicker when registered and restores on release', () => { + const h = createHarness({ tools: ['view', 'brush', 'colorPicker'] }); + h.pipeline.onPointerEnter(); + h.pipeline.onKeyDown(makeKeyEvent({ code: 'AltLeft' })); + expect(h.setTool).toHaveBeenLastCalledWith('colorPicker', { temporary: true }); + h.pipeline.onKeyUp(makeKeyEvent({ code: 'AltLeft' })); + expect(h.setTool).toHaveBeenLastCalledWith('brush', { temporary: true }); + }); + + it('ignores space/alt holds when the key event targets an editable element', () => { + const h = createHarness({ tools: ['view', 'brush', 'colorPicker'] }); + h.pipeline.onPointerEnter(); + + // Typing space/alt in an input, textarea, or contenteditable belongs to that + // field, not the canvas — the temp-tool hold must not fire. + h.pipeline.onKeyDown(makeKeyEvent({ code: 'Space', target: { tagName: 'INPUT' } })); + h.pipeline.onKeyDown(makeKeyEvent({ code: 'Space', target: { tagName: 'textarea' } })); + h.pipeline.onKeyDown(makeKeyEvent({ code: 'AltLeft', target: { isContentEditable: true } })); + expect(h.setTool).not.toHaveBeenCalled(); + + // A non-editable target (e.g. the canvas) still triggers the hold. + h.pipeline.onKeyDown(makeKeyEvent({ code: 'Space', target: { tagName: 'CANVAS' } })); + expect(h.setTool).toHaveBeenLastCalledWith('view', { temporary: true }); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/input/pointerPipeline.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/input/pointerPipeline.ts new file mode 100644 index 00000000000..c79ee0908f5 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/input/pointerPipeline.ts @@ -0,0 +1,353 @@ +/** + * The pointer pipeline: normalizes raw DOM pointer/key events into the engine's + * {@link PointerInput} vocabulary and routes them to the active tool. + * + * Responsibilities lifted out of the engine so `engine.ts` stays lean: + * - Pointer capture on down; `getCoalescedEvents()` batching on move (so fast + * strokes keep every intermediate sample); mouse pressure defaulted to 0.5. + * - Middle-mouse pan (engine-level, tool-independent). + * - Modifier-hold temporary tools: space → view; alt → colorPicker (a no-op if + * that tool isn't registered yet — the registry decides). The prior tool is + * restored on release. Temp switches are suppressed mid-gesture, and are + * flagged `{ temporary: true }` on `setTool` so a session-bearing tool + * (transform) can tell them apart from a real switch and keep its session + * alive across the hold. + * - Gesture cancellation: pointercancel and Esc route to the tool's + * `onPointerCancel`; secondary/extra buttons are ignored during a gesture. + * + * The pipeline reaches the DOM only through the injected `getInputElement` + * (for pointer capture / element rect) and the events passed to its handlers, so + * it is fully driveable by a fake harness in node tests. Zero React, zero + * import-time side effects. + */ + +import type { Tool, ToolContext } from '@workbench/canvas-engine/tools/tool'; +import type { PointerInput, ToolId, Vec2 } from '@workbench/canvas-engine/types'; +import type { Viewport } from '@workbench/canvas-engine/viewport'; + +/** The tool id temporarily activated while alt is held (ships in Task P2.4). */ +const ALT_TEMP_TOOL: ToolId = 'colorPicker'; +/** The tool id temporarily activated while space is held. */ +const SPACE_TEMP_TOOL: ToolId = 'view'; + +/** Dependencies injected by the engine. */ +export interface PointerPipelineDeps { + viewport: Viewport; + /** The element that owns pointer capture and defines the coordinate rect. */ + getInputElement(): (HTMLElement & Partial>) | null; + getActiveTool(): Tool | undefined; + getActiveToolId(): ToolId; + getToolContext(): ToolContext; + /** + * Switches the active tool. `opts.temporary` marks a modifier-hold switch + * (and its matching restore) so a session-bearing tool's `onActivate`/ + * `onDeactivate` can preserve its session instead of tearing it down — see + * {@link beginTempTool} / {@link endTempTool}. + */ + setTool(id: ToolId, opts?: { temporary?: boolean }): void; + hasTool(id: ToolId): boolean; + updateCursor(): void; + /** + * The engine's Escape priority, run AFTER the in-flight gesture is cancelled + * (and skipped in editable fields): cancel a transform session, else deselect. + * `gestureWasActive` tells it a drag just consumed this Escape, so it should + * cancel a session (session teardown is wanted mid-drag, matching the prior + * behavior) but NOT also deselect. Optional so minimal harnesses can omit it. + * See `engine.ts` `handleEscape`. + */ + handleEscape?(opts: { gestureWasActive: boolean }): void; + /** + * Called on a primary-button pointerdown BEFORE any gesture starts. Returns + * `true` if the engine consumed the press to commit an open modal session (a + * text-edit session), in which case the pipeline swallows the press entirely — + * no capture, no gesture, no tool routing — because the click's sole job was to + * close the session (the next press then starts a fresh interaction). Running + * before `gestureActive` is set is what lets the commit through the engine's + * mid-gesture guard. Optional so minimal harnesses can omit it. + */ + maybeCommitModalSession?(): boolean; +} + +/** The pipeline handle: DOM handlers plus lifecycle reset. */ +export interface PointerPipeline { + onPointerDown(event: PointerEvent): void; + onPointerMove(event: PointerEvent): void; + onPointerUp(event: PointerEvent): void; + onPointerCancel(event: PointerEvent): void; + onPointerEnter(): void; + onPointerLeave(): void; + onKeyDown(event: KeyboardEvent): void; + onKeyUp(event: KeyboardEvent): void; + /** + * True while a primary-button paint/drag gesture is mid-stroke (pointer down, + * not yet up/cancel). The engine consults this to no-op undo/redo during a + * live stroke, so a mid-gesture mod+z can't inject pixels under the session. + */ + isGestureActive(): boolean; + /** + * Cancels an in-flight primary-button gesture the same way Esc/pointercancel + * do: releases pointer capture, runs the active tool's `onPointerCancel` (so it + * drops its own transient state), and refreshes the cursor. A no-op when no + * gesture is active. The engine calls this on a wholesale document replacement + * so a mid-drag swap can't commit against the outgoing document on pointer-up. + */ + cancelActiveGesture(): void; + /** Clears hover/gesture/temp-tool state, cancelling any in-flight gesture (called on detach/blur). */ + reset(): void; +} + +const toPointerType = (type: string): PointerInput['pointerType'] => + type === 'pen' ? 'pen' : type === 'touch' ? 'touch' : 'mouse'; + +const isAltKey = (event: KeyboardEvent): boolean => event.code === 'AltLeft' || event.code === 'AltRight'; + +/** + * True when the key event targets an editable element (text input, textarea, or + * a contenteditable node). The space/alt temp-tool holds are window-level, so + * without this guard typing a space in a rename field would hijack the canvas + * view tool. Duck-typed on `tagName`/`isContentEditable` so it stays node-safe + * (no `instanceof HTMLElement`, which throws where those globals are absent). + */ +const isEditableTarget = (target: EventTarget | null): boolean => { + const el = target as { tagName?: unknown; isContentEditable?: unknown } | null; + if (!el) { + return false; + } + const tagName = typeof el.tagName === 'string' ? el.tagName.toUpperCase() : ''; + return tagName === 'INPUT' || tagName === 'TEXTAREA' || el.isContentEditable === true; +}; + +/** Creates a pointer pipeline bound to the engine's injected deps. */ +export const createPointerPipeline = (deps: PointerPipelineDeps): PointerPipeline => { + let hovered = false; + // Primary-button paint/drag gesture in progress. + let gestureActive = false; + let activePointerId: number | null = null; + // Middle-mouse pan. + let middlePanning = false; + let middleLast: Vec2 | null = null; + // Temporary modifier-hold tool. + let tempHold: 'space' | 'alt' | null = null; + let priorToolId: ToolId = deps.getActiveToolId(); + let tempSwitched = false; + + const buildPointerInput = (event: PointerEvent): PointerInput => { + const el = deps.getInputElement(); + const rect = el?.getBoundingClientRect() ?? ({ left: 0, top: 0 } as DOMRect); + const screenPoint: Vec2 = { x: event.clientX - rect.left, y: event.clientY - rect.top }; + return { + buttons: event.buttons, + documentPoint: deps.viewport.screenToDocument(screenPoint), + modifiers: { alt: event.altKey, ctrl: event.ctrlKey, meta: event.metaKey, shift: event.shiftKey }, + pointerType: toPointerType(event.pointerType), + pressure: event.pressure > 0 ? event.pressure : 0.5, + screenPoint, + timeStamp: event.timeStamp, + }; + }; + + const buildBatch = (event: PointerEvent): PointerInput[] => { + const coalesced = event.getCoalescedEvents?.(); + if (coalesced && coalesced.length > 0) { + return coalesced.map(buildPointerInput); + } + return [buildPointerInput(event)]; + }; + + const releaseCapture = (pointerId: number): void => { + deps.getInputElement()?.releasePointerCapture?.(pointerId); + }; + + const cancelGesture = (): void => { + if (!gestureActive) { + return; + } + gestureActive = false; + if (activePointerId !== null) { + releaseCapture(activePointerId); + activePointerId = null; + } + deps.getActiveTool()?.onPointerCancel?.(deps.getToolContext()); + deps.updateCursor(); + }; + + const beginTempTool = (hold: 'space' | 'alt', toolId: ToolId): void => { + if (tempHold || gestureActive || !hovered) { + return; + } + tempHold = hold; + priorToolId = deps.getActiveToolId(); + if (deps.hasTool(toolId)) { + deps.setTool(toolId, { temporary: true }); + tempSwitched = true; + } else { + tempSwitched = false; + } + }; + + const endTempTool = (): void => { + if (tempSwitched) { + deps.setTool(priorToolId, { temporary: true }); + } + tempHold = null; + tempSwitched = false; + }; + + return { + cancelActiveGesture: () => { + cancelGesture(); + }, + onKeyDown: (event) => { + if (event.key === 'Escape') { + // Cancel any active drag first, then run the engine's Escape priority + // (cancel a transform session, else deselect). The editable guard keeps + // Escape in a text field from tearing down a canvas session/selection the + // field isn't part of. A mid-drag Escape cancels the gesture here; the + // subsequent `handleEscape` still sees (and cancels) a transform session + // the reverted drag kept open, matching the prior gesture+session teardown, + // but skips deselect so a mid-lasso Escape drops only the in-progress path. + const gestureWasActive = gestureActive; + cancelGesture(); + if (!isEditableTarget(event.target)) { + deps.handleEscape?.({ gestureWasActive }); + } + return; + } + // Never let space/alt temp-tool holds (or Enter apply) fire while the user is + // typing in an editable field (e.g. a layer rename input) — that key belongs + // to the field, not the canvas. + if (isEditableTarget(event.target)) { + return; + } + if (event.key === 'Enter') { + // Enter applies a session-bearing tool's edit (transform). No-op otherwise. + deps.getActiveTool()?.onKeyCommand?.(deps.getToolContext(), 'apply'); + return; + } + if (event.code === 'Space' && !event.repeat) { + beginTempTool('space', SPACE_TEMP_TOOL); + if (tempHold === 'space') { + event.preventDefault(); + } + return; + } + if (isAltKey(event) && !event.repeat) { + beginTempTool('alt', ALT_TEMP_TOOL); + } + }, + isGestureActive: () => gestureActive, + onKeyUp: (event) => { + if (event.code === 'Space' && tempHold === 'space') { + endTempTool(); + } else if (isAltKey(event) && tempHold === 'alt') { + endTempTool(); + } + }, + onPointerCancel: (event) => { + // Ignore cancel events from a pointer other than the one driving the active gesture/pan + // (pointer capture on the active pointer does not suppress other pointers' events). + if (activePointerId !== null && event.pointerId !== activePointerId) { + return; + } + if (middlePanning) { + releaseCapture(event.pointerId); + middlePanning = false; + middleLast = null; + activePointerId = null; + return; + } + // `cancelGesture` releases the captured pointer itself. + cancelGesture(); + }, + onPointerDown: (event) => { + // Ignore extra/secondary buttons pressed during an active gesture or pan. + if (gestureActive || middlePanning) { + return; + } + const el = deps.getInputElement(); + if (event.button === 1) { + el?.setPointerCapture?.(event.pointerId); + activePointerId = event.pointerId; + middlePanning = true; + middleLast = buildPointerInput(event).screenPoint; + return; + } + if (event.button !== 0) { + return; + } + // A primary press while a text-edit session is open commits it (engine-side, + // reading the live portal content) and is swallowed — no gesture, no tool + // routing. This must precede `gestureActive = true` so the engine's + // mid-gesture commit guard cannot drop it. `preventDefault` avoids a stray + // focus/selection default now that the session has already been closed. + if (deps.maybeCommitModalSession?.()) { + event.preventDefault(); + return; + } + el?.setPointerCapture?.(event.pointerId); + activePointerId = event.pointerId; + gestureActive = true; + event.preventDefault(); + deps.getActiveTool()?.onPointerDown?.(deps.getToolContext(), buildPointerInput(event)); + deps.updateCursor(); + }, + onPointerEnter: () => { + hovered = true; + }, + onPointerLeave: () => { + hovered = false; + }, + onPointerMove: (event) => { + // Ignore move events from a pointer other than the one driving the active gesture/pan. + if (activePointerId !== null && event.pointerId !== activePointerId) { + return; + } + if (middlePanning && middleLast) { + const screenPoint = buildPointerInput(event).screenPoint; + deps.viewport.panBy({ x: screenPoint.x - middleLast.x, y: screenPoint.y - middleLast.y }); + middleLast = screenPoint; + return; + } + const batch = buildBatch(event); + const last = batch[batch.length - 1]; + if (!last) { + return; + } + deps.getActiveTool()?.onPointerMove?.(deps.getToolContext(), last, batch); + }, + onPointerUp: (event) => { + // Ignore up events from a pointer other than the one driving the active gesture/pan. + if (activePointerId !== null && event.pointerId !== activePointerId) { + return; + } + releaseCapture(event.pointerId); + if (middlePanning) { + middlePanning = false; + middleLast = null; + activePointerId = null; + return; + } + if (!gestureActive) { + return; + } + gestureActive = false; + activePointerId = null; + deps.getActiveTool()?.onPointerUp?.(deps.getToolContext(), buildPointerInput(event)); + deps.updateCursor(); + }, + reset: () => { + if (tempHold) { + endTempTool(); + } + // Cancel an in-flight gesture through the same path Esc uses, so the active + // tool's `onPointerCancel` runs and clears its transient state (rather than + // silently dropping `gestureActive` and stranding stale tool state). + cancelGesture(); + hovered = false; + gestureActive = false; + activePointerId = null; + middlePanning = false; + middleLast = null; + }, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/input/wheel.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/input/wheel.test.ts new file mode 100644 index 00000000000..b83e7df8ec0 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/input/wheel.test.ts @@ -0,0 +1,90 @@ +import type { WheelHandlerDeps } from '@workbench/canvas-engine/input/wheel'; +import type { Tool, ToolContext } from '@workbench/canvas-engine/tools/tool'; +import type { ToolId } from '@workbench/canvas-engine/types'; + +import { createWheelHandler } from '@workbench/canvas-engine/input/wheel'; +import { createViewport } from '@workbench/canvas-engine/viewport'; +import { describe, expect, it, vi } from 'vitest'; + +const makeWheelEvent = (deltaY: number, opts: { ctrlKey?: boolean } = {}): WheelEvent => + ({ + altKey: false, + clientX: 10, + clientY: 10, + ctrlKey: opts.ctrlKey ?? false, + deltaY, + metaKey: false, + preventDefault: vi.fn(), + shiftKey: false, + }) as unknown as WheelEvent; + +const createHarness = (tool: Tool | undefined, invertBrushSizeScroll = false) => { + const viewport = createViewport(); + const stepActiveBrushSize = vi.fn(); + const invalidate = vi.fn(); + const deps: WheelHandlerDeps = { + getActiveTool: () => tool, + getInputElement: () => ({ getBoundingClientRect: () => ({ left: 0, top: 0 }) }) as unknown as HTMLElement, + getInvertBrushSizeScroll: () => invertBrushSizeScroll, + getToolContext: () => ({}) as ToolContext, + invalidate, + stepActiveBrushSize, + viewport, + }; + return { handler: createWheelHandler(deps), invalidate, stepActiveBrushSize, viewport }; +}; + +const toolWithId = (id: ToolId, onWheel?: Tool['onWheel']): Tool => ({ id, onWheel }); + +describe('wheel handler: ctrl+wheel brush size step', () => { + it('grows the size on ctrl+wheel-up when brush is active', () => { + const h = createHarness(toolWithId('brush')); + h.handler(makeWheelEvent(-100, { ctrlKey: true })); + expect(h.stepActiveBrushSize).toHaveBeenCalledWith(1); + }); + + it('shrinks the size on ctrl+wheel-down when eraser is active', () => { + const h = createHarness(toolWithId('eraser')); + h.handler(makeWheelEvent(100, { ctrlKey: true })); + expect(h.stepActiveBrushSize).toHaveBeenCalledWith(-1); + }); + + it('inverts the step direction when the invert-scroll preference is on', () => { + const grow = createHarness(toolWithId('brush'), true); + grow.handler(makeWheelEvent(-100, { ctrlKey: true })); + // Wheel-up normally grows (+1); inverted, it shrinks (-1). + expect(grow.stepActiveBrushSize).toHaveBeenCalledWith(-1); + + const shrink = createHarness(toolWithId('eraser'), true); + shrink.handler(makeWheelEvent(100, { ctrlKey: true })); + // Wheel-down normally shrinks (-1); inverted, it grows (+1). + expect(shrink.stepActiveBrushSize).toHaveBeenCalledWith(1); + }); + + it('swallows ctrl+wheel (no size step, no zoom) for non-paint tools', () => { + const h = createHarness(toolWithId('view')); + const wheelZoom = vi.spyOn(h.viewport, 'wheelZoom'); + h.handler(makeWheelEvent(-100, { ctrlKey: true })); + expect(h.stepActiveBrushSize).not.toHaveBeenCalled(); + expect(wheelZoom).not.toHaveBeenCalled(); + }); +}); + +describe('wheel handler: plain wheel', () => { + it('zooms the viewport when the active tool defines no onWheel', () => { + const h = createHarness(toolWithId('brush')); + const wheelZoom = vi.spyOn(h.viewport, 'wheelZoom'); + h.handler(makeWheelEvent(-120)); + expect(wheelZoom).toHaveBeenCalledWith(-120, { x: 10, y: 10 }); + expect(h.invalidate).toHaveBeenCalledWith({ view: true }); + }); + + it('routes to the active tool onWheel when present', () => { + const onWheel = vi.fn(); + const h = createHarness(toolWithId('view', onWheel)); + const wheelZoom = vi.spyOn(h.viewport, 'wheelZoom'); + h.handler(makeWheelEvent(-120)); + expect(onWheel).toHaveBeenCalledTimes(1); + expect(wheelZoom).not.toHaveBeenCalled(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/input/wheel.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/input/wheel.ts new file mode 100644 index 00000000000..96ec4770f2a --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/input/wheel.ts @@ -0,0 +1,71 @@ +/** + * Wheel routing for the canvas. + * + * - Plain wheel → the active tool's `onWheel` if it defines one, else viewport + * zoom about the cursor (the default navigation behavior). + * - Ctrl+wheel → when the active tool is brush/eraser, step its size; otherwise + * it is swallowed (reserved for the browser pinch-zoom gesture). + * + * The handler is a pure function of injected deps (viewport, the active tool, and + * a `stepActiveBrushSize` callback the engine wires to the tool-options stores), + * so it is driven directly in node tests. DOM is touched only through the passed + * `WheelEvent`. Zero React, zero import-time side effects. + */ + +import type { InvalidatePayload } from '@workbench/canvas-engine/render/scheduler'; +import type { Tool, ToolContext } from '@workbench/canvas-engine/tools/tool'; +import type { PointerModifiers, Vec2 } from '@workbench/canvas-engine/types'; +import type { Viewport } from '@workbench/canvas-engine/viewport'; + +/** Dependencies for {@link createWheelHandler}. */ +export interface WheelHandlerDeps { + viewport: Viewport; + invalidate(payload: InvalidatePayload): void; + getInputElement(): HTMLElement | null; + getActiveTool(): Tool | undefined; + getToolContext(): ToolContext; + /** Steps the active brush/eraser diameter by one notch (`+1` grow, `-1` shrink). */ + stepActiveBrushSize(direction: 1 | -1): void; + /** + * Whether ctrl+wheel brush sizing is inverted. `false` (default): wheel-up + * grows. `true`: wheel-up shrinks. A user preference read fresh per event. + */ + getInvertBrushSizeScroll(): boolean; +} + +const modifiersOf = (event: WheelEvent): PointerModifiers => ({ + alt: event.altKey, + ctrl: event.ctrlKey, + meta: event.metaKey, + shift: event.shiftKey, +}); + +/** Creates the canvas wheel handler. See module docs for routing. */ +export const createWheelHandler = (deps: WheelHandlerDeps): ((event: WheelEvent) => void) => { + return (event: WheelEvent): void => { + event.preventDefault(); + + const rect = deps.getInputElement()?.getBoundingClientRect(); + const screenAnchor: Vec2 = { x: event.clientX - (rect?.left ?? 0), y: event.clientY - (rect?.top ?? 0) }; + + const tool = deps.getActiveTool(); + + // Ctrl+wheel: brush/eraser size step, else reserved (pinch-zoom) — swallowed. + if (event.ctrlKey) { + if (tool && (tool.id === 'brush' || tool.id === 'eraser')) { + // Default: wheel-up (deltaY < 0) grows. The inversion preference flips it. + const grow = event.deltaY < 0; + const stepUp = grow !== deps.getInvertBrushSizeScroll(); + deps.stepActiveBrushSize(stepUp ? 1 : -1); + } + return; + } + + if (tool?.onWheel) { + tool.onWheel(deps.getToolContext(), event.deltaY, screenAnchor, modifiersOf(event)); + return; + } + deps.viewport.wheelZoom(event.deltaY, screenAnchor); + deps.invalidate({ view: true }); + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/math/mat2d.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/math/mat2d.test.ts new file mode 100644 index 00000000000..4ff01baa28e --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/math/mat2d.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from 'vitest'; + +import { applyToPoint, fromTRS, getScale, identity, invert, multiply, rotate, scale, translate } from './mat2d'; + +describe('identity', () => { + it('returns the identity matrix', () => { + expect(identity()).toEqual({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }); + }); + + it('applied to a point returns the same point', () => { + expect(applyToPoint(identity(), { x: 5, y: -3 })).toEqual({ x: 5, y: -3 }); + }); +}); + +describe('translate', () => { + it('shifts points by the translation vector', () => { + const m = translate(identity(), { x: 10, y: 20 }); + expect(applyToPoint(m, { x: 0, y: 0 })).toEqual({ x: 10, y: 20 }); + expect(applyToPoint(m, { x: 5, y: 5 })).toEqual({ x: 15, y: 25 }); + }); +}); + +describe('scale', () => { + it('scales points uniformly when sy is omitted', () => { + const m = scale(identity(), 2); + expect(applyToPoint(m, { x: 3, y: 4 })).toEqual({ x: 6, y: 8 }); + }); + + it('scales points non-uniformly when sy is given', () => { + const m = scale(identity(), 2, 3); + expect(applyToPoint(m, { x: 1, y: 1 })).toEqual({ x: 2, y: 3 }); + }); +}); + +describe('rotate', () => { + it('rotates a point 90 degrees clockwise', () => { + const m = rotate(identity(), Math.PI / 2); + const p = applyToPoint(m, { x: 1, y: 0 }); + expect(p.x).toBeCloseTo(0, 10); + expect(p.y).toBeCloseTo(1, 10); + }); + + it('rotating by 0 radians is a no-op', () => { + const m = rotate(identity(), 0); + expect(applyToPoint(m, { x: 7, y: -2 })).toEqual({ x: 7, y: -2 }); + }); +}); + +describe('multiply', () => { + it('composes transforms so the right operand applies first', () => { + // translate(10,0) * scale(2) applied to (1,1): scale first -> (2,2), then translate -> (12, 2) + const t = translate(identity(), { x: 10, y: 0 }); + const s = scale(identity(), 2); + const m = multiply(t, s); + expect(applyToPoint(m, { x: 1, y: 1 })).toEqual({ x: 12, y: 2 }); + }); + + it('multiplying by identity is a no-op on either side', () => { + const m = translate(scale(identity(), 3, 4), { x: 1, y: 2 }); + expect(multiply(m, identity())).toEqual(m); + expect(multiply(identity(), m)).toEqual(m); + }); +}); + +describe('invert', () => { + it('round-trips applyToPoint through a matrix and its inverse', () => { + const m = fromTRS({ x: 5, y: -3 }, Math.PI / 6, 2, 0.5); + const inv = invert(m); + expect(inv).not.toBeNull(); + const p = { x: 13, y: -4 }; + const roundTripped = applyToPoint(inv as NonNullable, applyToPoint(m, p)); + expect(roundTripped.x).toBeCloseTo(p.x, 8); + expect(roundTripped.y).toBeCloseTo(p.y, 8); + }); + + it('returns null for a singular matrix', () => { + // Zero determinant: a*d - b*c = 0 + expect(invert({ a: 1, b: 2, c: 2, d: 4, e: 0, f: 0 })).toBeNull(); + expect(invert({ a: 0, b: 0, c: 0, d: 0, e: 1, f: 1 })).toBeNull(); + }); + + it('inverting identity returns identity', () => { + const inv = invert(identity()); + expect(inv).not.toBeNull(); + const nonNullInv = inv as NonNullable; + expect(nonNullInv.a).toBeCloseTo(1, 10); + expect(nonNullInv.b).toBeCloseTo(0, 10); + expect(nonNullInv.c).toBeCloseTo(0, 10); + expect(nonNullInv.d).toBeCloseTo(1, 10); + expect(nonNullInv.e).toBeCloseTo(0, 10); + expect(nonNullInv.f).toBeCloseTo(0, 10); + }); +}); + +describe('fromTRS', () => { + it('matches manual translate . rotate . scale composition', () => { + const translation = { x: 4, y: -2 }; + const rad = 0.7; + const sx = 1.5; + const sy = 2.5; + + const t = translate(identity(), translation); + const r = rotate(identity(), rad); + const s = scale(identity(), sx, sy); + const manual = multiply(multiply(t, r), s); + const composed = fromTRS(translation, rad, sx, sy); + + expect(composed.a).toBeCloseTo(manual.a, 10); + expect(composed.b).toBeCloseTo(manual.b, 10); + expect(composed.c).toBeCloseTo(manual.c, 10); + expect(composed.d).toBeCloseTo(manual.d, 10); + expect(composed.e).toBeCloseTo(manual.e, 10); + expect(composed.f).toBeCloseTo(manual.f, 10); + }); + + it('defaults scaleY to scaleX for uniform scale', () => { + const composed = fromTRS({ x: 0, y: 0 }, 0, 3, 3); + const defaulted = fromTRS({ x: 0, y: 0 }, 0, 3); + expect(defaulted).toEqual(composed); + }); + + it('with no rotation/scale reduces to pure translation', () => { + expect(fromTRS({ x: 8, y: 9 }, 0, 1, 1)).toEqual(translate(identity(), { x: 8, y: 9 })); + }); +}); + +describe('getScale', () => { + it('returns 1 for the identity matrix', () => { + expect(getScale(identity())).toBeCloseTo(1, 10); + }); + + it('returns the scale factor for a uniformly scaled matrix', () => { + expect(getScale(scale(identity(), 3))).toBeCloseTo(3, 10); + }); + + it('returns the geometric mean for a non-uniformly scaled matrix', () => { + expect(getScale(scale(identity(), 2, 8))).toBeCloseTo(4, 10); + }); + + it('is unaffected by translation', () => { + const m = translate(scale(identity(), 2), { x: 100, y: -50 }); + expect(getScale(m)).toBeCloseTo(2, 10); + }); + + it('is unaffected by rotation for a uniformly scaled matrix', () => { + const m = rotate(scale(identity(), 5), 1.234); + expect(getScale(m)).toBeCloseTo(5, 8); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/math/mat2d.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/math/mat2d.ts new file mode 100644 index 00000000000..cd6b641cc29 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/math/mat2d.ts @@ -0,0 +1,95 @@ +/** + * Pure functions on `Mat2d`, the engine's 2D affine matrix type. + * + * Convention (matches `CanvasRenderingContext2D.setTransform`): + * ``` + * x' = a*x + c*y + e + * y' = b*x + d*y + f + * ``` + * + * No classes, no mutation — every function returns a new `Mat2d`. + */ + +import type { Mat2d, Vec2 } from '@workbench/canvas-engine/types'; + +/** Returns the identity matrix. */ +export const identity = (): Mat2d => ({ a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }); + +/** + * Composes two matrices such that applying the result to a point is + * equivalent to applying `b` first, then `a` (i.e. `result = a * b`, using + * the same composition order as `DOMMatrix.multiply` / `ctx.transform` + * chaining: `a.multiply(b)` applies `b`'s transform in `a`'s coordinate + * space). + */ +export const multiply = (a: Mat2d, b: Mat2d): Mat2d => ({ + a: a.a * b.a + a.c * b.b, + b: a.b * b.a + a.d * b.b, + c: a.a * b.c + a.c * b.d, + d: a.b * b.c + a.d * b.d, + e: a.a * b.e + a.c * b.f + a.e, + f: a.b * b.e + a.d * b.f + a.f, +}); + +/** Inverts a matrix. Returns `null` if the matrix is singular (determinant ~0). */ +export const invert = (m: Mat2d): Mat2d | null => { + const det = m.a * m.d - m.b * m.c; + if (Math.abs(det) < 1e-12) { + return null; + } + const invDet = 1 / det; + const a = m.d * invDet; + const b = -m.b * invDet; + const c = -m.c * invDet; + const d = m.a * invDet; + const e = -(a * m.e + c * m.f); + const f = -(b * m.e + d * m.f); + return { a, b, c, d, e, f }; +}; + +/** Returns a matrix translated by `v` (post-multiplies a translation). */ +export const translate = (m: Mat2d, v: Vec2): Mat2d => multiply(m, { a: 1, b: 0, c: 0, d: 1, e: v.x, f: v.y }); + +/** Returns a matrix scaled by `sx`/`sy` (defaults `sy` to `sx` for uniform scale). */ +export const scale = (m: Mat2d, sx: number, sy: number = sx): Mat2d => + multiply(m, { a: sx, b: 0, c: 0, d: sy, e: 0, f: 0 }); + +/** Returns a matrix rotated by `rad` radians (positive = clockwise in canvas space). */ +export const rotate = (m: Mat2d, rad: number): Mat2d => { + const cos = Math.cos(rad); + const sin = Math.sin(rad); + return multiply(m, { a: cos, b: sin, c: -sin, d: cos, e: 0, f: 0 }); +}; + +/** Applies a matrix to a point, returning the transformed point. */ +export const applyToPoint = (m: Mat2d, p: Vec2): Vec2 => ({ + x: m.a * p.x + m.c * p.y + m.e, + y: m.b * p.x + m.d * p.y + m.f, +}); + +/** + * Composes a matrix from translation, rotation, and scale, in the order + * translate · rotate · scale — i.e. scale is applied first (in local + * space), then rotation, then translation. This is the standard TRS + * composition used for layer transforms. + */ +export const fromTRS = (translation: Vec2, rotationRad: number, scaleX: number, scaleY: number = scaleX): Mat2d => { + let m = identity(); + m = translate(m, translation); + m = rotate(m, rotationRad); + m = scale(m, scaleX, scaleY); + return m; +}; + +/** + * Extracts an approximate uniform scale magnitude from a matrix — the + * geometric mean of the transformed lengths of the unit x/y axis vectors. + * Useful for stroke-width and zoom math where an exact per-axis scale isn't + * needed. For a matrix with no shear/non-uniform scale this equals the + * true scale factor. + */ +export const getScale = (m: Mat2d): number => { + const scaleX = Math.hypot(m.a, m.b); + const scaleY = Math.hypot(m.c, m.d); + return Math.sqrt(scaleX * scaleY); +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/math/rect.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/math/rect.test.ts new file mode 100644 index 00000000000..8888aa85487 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/math/rect.test.ts @@ -0,0 +1,202 @@ +import type { Rect } from '@workbench/canvas-engine/types'; + +import { describe, expect, it } from 'vitest'; + +import { identity, rotate, scale, translate } from './mat2d'; +import { containsPoint, expand, intersect, isEmpty, mergeDirtyRects, roundOut, transformBounds, union } from './rect'; + +describe('isEmpty', () => { + it('is true for zero width or height', () => { + expect(isEmpty({ x: 0, y: 0, width: 0, height: 10 })).toBe(true); + expect(isEmpty({ x: 0, y: 0, width: 10, height: 0 })).toBe(true); + }); + + it('is true for negative width or height', () => { + expect(isEmpty({ x: 0, y: 0, width: -1, height: 10 })).toBe(true); + }); + + it('is false for a positive-area rect', () => { + expect(isEmpty({ x: 0, y: 0, width: 1, height: 1 })).toBe(false); + }); +}); + +describe('intersect', () => { + it('returns the overlapping region for overlapping rects', () => { + const a: Rect = { x: 0, y: 0, width: 10, height: 10 }; + const b: Rect = { x: 5, y: 5, width: 10, height: 10 }; + expect(intersect(a, b)).toEqual({ x: 5, y: 5, width: 5, height: 5 }); + }); + + it('returns null for disjoint rects', () => { + const a: Rect = { x: 0, y: 0, width: 10, height: 10 }; + const b: Rect = { x: 20, y: 20, width: 10, height: 10 }; + expect(intersect(a, b)).toBeNull(); + }); + + it('returns null for rects that only touch at an edge', () => { + const a: Rect = { x: 0, y: 0, width: 10, height: 10 }; + const b: Rect = { x: 10, y: 0, width: 10, height: 10 }; + expect(intersect(a, b)).toBeNull(); + }); + + it('returns null when either rect is empty', () => { + const a: Rect = { x: 0, y: 0, width: 0, height: 10 }; + const b: Rect = { x: 0, y: 0, width: 10, height: 10 }; + expect(intersect(a, b)).toBeNull(); + }); +}); + +describe('union', () => { + it('returns the bounding box of two rects', () => { + const a: Rect = { x: 0, y: 0, width: 10, height: 10 }; + const b: Rect = { x: 5, y: -5, width: 10, height: 10 }; + expect(union(a, b)).toEqual({ x: 0, y: -5, width: 15, height: 15 }); + }); + + it('returns the non-empty rect when the other is empty', () => { + const a: Rect = { x: 0, y: 0, width: 0, height: 0 }; + const b: Rect = { x: 5, y: 5, width: 10, height: 10 }; + expect(union(a, b)).toEqual(b); + expect(union(b, a)).toEqual(b); + }); + + it('returns the first rect when both are empty', () => { + const a: Rect = { x: 1, y: 1, width: 0, height: 0 }; + const b: Rect = { x: 2, y: 2, width: 0, height: 0 }; + expect(union(a, b)).toEqual(a); + }); +}); + +describe('containsPoint', () => { + const r: Rect = { x: 0, y: 0, width: 10, height: 10 }; + + it('is true for points inside the rect', () => { + expect(containsPoint(r, { x: 5, y: 5 })).toBe(true); + expect(containsPoint(r, { x: 0, y: 0 })).toBe(true); + }); + + it('is false for points on the max edge (exclusive)', () => { + expect(containsPoint(r, { x: 10, y: 5 })).toBe(false); + expect(containsPoint(r, { x: 5, y: 10 })).toBe(false); + }); + + it('is false for points outside the rect', () => { + expect(containsPoint(r, { x: -1, y: 5 })).toBe(false); + expect(containsPoint(r, { x: 5, y: 20 })).toBe(false); + }); +}); + +describe('expand', () => { + it('grows the rect on all sides by margin', () => { + const r: Rect = { x: 10, y: 10, width: 10, height: 10 }; + expect(expand(r, 5)).toEqual({ x: 5, y: 5, width: 20, height: 20 }); + }); + + it('shrinks the rect for negative margin', () => { + const r: Rect = { x: 10, y: 10, width: 10, height: 10 }; + expect(expand(r, -2)).toEqual({ x: 12, y: 12, width: 6, height: 6 }); + }); +}); + +describe('roundOut', () => { + it('floors the min edges and ceils the max edges', () => { + const r: Rect = { x: 1.2, y: 1.8, width: 3.5, height: 2.1 }; + // right edge = 1.2 + 3.5 = 4.7 -> ceil 5; bottom = 1.8 + 2.1 = 3.9 -> ceil 4 + expect(roundOut(r)).toEqual({ x: 1, y: 1, width: 4, height: 3 }); + }); + + it('is a no-op for already-integer rects', () => { + const r: Rect = { x: 2, y: 3, width: 4, height: 5 }; + expect(roundOut(r)).toEqual(r); + }); + + it('handles negative coordinates', () => { + const r: Rect = { x: -1.5, y: -2.1, width: 3, height: 3 }; + // right = 1.5 -> ceil 2; bottom = 0.9 -> ceil 1 + expect(roundOut(r)).toEqual({ x: -2, y: -3, width: 4, height: 4 }); + }); +}); + +describe('transformBounds', () => { + it('returns the same rect for the identity matrix', () => { + const r: Rect = { x: 1, y: 2, width: 10, height: 20 }; + expect(transformBounds(identity(), r)).toEqual(r); + }); + + it('translates the rect for a translation matrix', () => { + const r: Rect = { x: 0, y: 0, width: 10, height: 10 }; + const m = translate(identity(), { x: 5, y: -5 }); + expect(transformBounds(m, r)).toEqual({ x: 5, y: -5, width: 10, height: 10 }); + }); + + it('scales the rect for a scale matrix', () => { + const r: Rect = { x: 0, y: 0, width: 10, height: 10 }; + const m = scale(identity(), 2); + expect(transformBounds(m, r)).toEqual({ x: 0, y: 0, width: 20, height: 20 }); + }); + + it('returns the axis-aligned bounding box of a rotated rect', () => { + const r: Rect = { x: -5, y: -5, width: 10, height: 10 }; + const m = rotate(identity(), Math.PI / 4); + const bounds = transformBounds(m, r); + const diagonal = Math.sqrt(2) * 10; + expect(bounds.width).toBeCloseTo(diagonal, 8); + expect(bounds.height).toBeCloseTo(diagonal, 8); + expect(bounds.x).toBeCloseTo(-diagonal / 2, 8); + expect(bounds.y).toBeCloseTo(-diagonal / 2, 8); + }); +}); + +describe('mergeDirtyRects', () => { + it('returns an empty array for no rects', () => { + expect(mergeDirtyRects([])).toEqual([]); + }); + + it('returns the single rect unchanged (filtering empties)', () => { + const r: Rect = { x: 0, y: 0, width: 5, height: 5 }; + expect(mergeDirtyRects([r])).toEqual([r]); + }); + + it('filters out empty rects', () => { + const r: Rect = { x: 0, y: 0, width: 5, height: 5 }; + const empty: Rect = { x: 0, y: 0, width: 0, height: 0 }; + expect(mergeDirtyRects([r, empty])).toEqual([r]); + }); + + it('merges overlapping rects into one', () => { + const a: Rect = { x: 0, y: 0, width: 10, height: 10 }; + const b: Rect = { x: 5, y: 5, width: 10, height: 10 }; + const result = mergeDirtyRects([a, b]); + expect(result).toEqual([{ x: 0, y: 0, width: 15, height: 15 }]); + }); + + it('keeps far-apart rects separate when under the region cap', () => { + const a: Rect = { x: 0, y: 0, width: 5, height: 5 }; + const b: Rect = { x: 1000, y: 1000, width: 5, height: 5 }; + const result = mergeDirtyRects([a, b]); + expect(result).toHaveLength(2); + expect(result).toEqual(expect.arrayContaining([a, b])); + }); + + it('falls back to a single merged bounding rect beyond maxRegions', () => { + const rects: Rect[] = [ + { x: 0, y: 0, width: 5, height: 5 }, + { x: 100, y: 0, width: 5, height: 5 }, + { x: 0, y: 100, width: 5, height: 5 }, + { x: 100, y: 100, width: 5, height: 5 }, + { x: 200, y: 200, width: 5, height: 5 }, + ]; + const result = mergeDirtyRects(rects, 4); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ x: 0, y: 0, width: 205, height: 205 }); + }); + + it('respects a custom maxRegions', () => { + const rects: Rect[] = [ + { x: 0, y: 0, width: 5, height: 5 }, + { x: 100, y: 0, width: 5, height: 5 }, + ]; + const result = mergeDirtyRects(rects, 1); + expect(result).toHaveLength(1); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/math/rect.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/math/rect.ts new file mode 100644 index 00000000000..141aa548313 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/math/rect.ts @@ -0,0 +1,167 @@ +/** + * Pure functions on `Rect`, the engine's axis-aligned rectangle type. + * + * No classes, no mutation — every function returns a new `Rect` (or a + * primitive / array of `Rect`). + */ + +import type { Mat2d, Rect, Vec2 } from '@workbench/canvas-engine/types'; + +import { applyToPoint } from './mat2d'; + +/** True if the rect has non-positive width or height. */ +export const isEmpty = (r: Rect): boolean => r.width <= 0 || r.height <= 0; + +/** Intersection of two rects, or `null` if they don't overlap (or either is empty). */ +export const intersect = (a: Rect, b: Rect): Rect | null => { + if (isEmpty(a) || isEmpty(b)) { + return null; + } + const x = Math.max(a.x, b.x); + const y = Math.max(a.y, b.y); + const right = Math.min(a.x + a.width, b.x + b.width); + const bottom = Math.min(a.y + a.height, b.y + b.height); + if (right <= x || bottom <= y) { + return null; + } + return { x, y, width: right - x, height: bottom - y }; +}; + +/** + * Union (bounding box) of two rects. An empty rect is treated as + * contributing no area — the union of an empty rect and a non-empty rect is + * the non-empty rect; the union of two empty rects is the first rect. + */ +export const union = (a: Rect, b: Rect): Rect => { + if (isEmpty(a) && isEmpty(b)) { + return a; + } + if (isEmpty(a)) { + return b; + } + if (isEmpty(b)) { + return a; + } + const x = Math.min(a.x, b.x); + const y = Math.min(a.y, b.y); + const right = Math.max(a.x + a.width, b.x + b.width); + const bottom = Math.max(a.y + a.height, b.y + b.height); + return { x, y, width: right - x, height: bottom - y }; +}; + +/** True if `p` lies within `r`, inclusive of the min edges and exclusive of the max edges. */ +export const containsPoint = (r: Rect, p: Vec2): boolean => + p.x >= r.x && p.x < r.x + r.width && p.y >= r.y && p.y < r.y + r.height; + +/** Grows (or shrinks, for negative `margin`) a rect uniformly on all sides. */ +export const expand = (r: Rect, margin: number): Rect => ({ + x: r.x - margin, + y: r.y - margin, + width: r.width + margin * 2, + height: r.height + margin * 2, +}); + +/** + * Rounds a rect outward to integer bounds (floor the min edges, ceil the + * max edges). Used to align dirty rects to pixel boundaries so patches + * fully cover the region that changed. + */ +export const roundOut = (r: Rect): Rect => { + const x = Math.floor(r.x); + const y = Math.floor(r.y); + const right = Math.ceil(r.x + r.width); + const bottom = Math.ceil(r.y + r.height); + return { x, y, width: right - x, height: bottom - y }; +}; + +/** Axis-aligned bounds of `r` after being transformed by `m`. */ +export const transformBounds = (m: Mat2d, r: Rect): Rect => { + const corners: Vec2[] = [ + { x: r.x, y: r.y }, + { x: r.x + r.width, y: r.y }, + { x: r.x, y: r.y + r.height }, + { x: r.x + r.width, y: r.y + r.height }, + ].map((p) => applyToPoint(m, p)); + + const xs = corners.map((p) => p.x); + const ys = corners.map((p) => p.y); + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + const minY = Math.min(...ys); + const maxY = Math.max(...ys); + + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; +}; + +/** Rect area, treating empty rects as zero area. */ +const area = (r: Rect): number => (isEmpty(r) ? 0 : r.width * r.height); + +/** + * True if two rects overlap or touch within `slack` pixels of each other + * (i.e. their `slack`-expanded bounds intersect). + */ +const isNearby = (a: Rect, b: Rect, slack: number): boolean => { + const expandedA = slack === 0 ? a : expand(a, slack); + return intersect(expandedA, b) !== null; +}; + +/** + * Coalesces a list of dirty rects into at most `maxRegions` rects. + * + * Algorithm: greedily merges any two rects that overlap or are within a + * small proximity slack (derived from the average rect size) whenever the + * merge doesn't waste too much area — specifically, whenever the merged + * rect's area is no more than 2x the sum of the two input areas, or the + * rects already overlap/touch. This repeats to a fixed point. If more than + * `maxRegions` rects remain, it falls back to merging everything into a + * single bounding rect (merge-all fallback) — simpler than picking which + * regions to keep, and cheap dirty-rect accounting favors correctness + * (never under-repaint) over minimizing painted area in the pathological + * case. + */ +export const mergeDirtyRects = (rects: Rect[], maxRegions = 4): Rect[] => { + const nonEmpty = rects.filter((r) => !isEmpty(r)); + if (nonEmpty.length <= 1) { + return nonEmpty; + } + + let current = nonEmpty.slice(); + const avgDimension = current.reduce((sum, r) => sum + (r.width + r.height) / 2, 0) / current.length; + const slack = avgDimension * 0.1; + + const findMergeablePair = (rectList: Rect[]): [number, number] | null => { + for (let i = 0; i < rectList.length; i++) { + for (let j = i + 1; j < rectList.length; j++) { + const a = rectList[i]; + const b = rectList[j]; + if (!a || !b) { + continue; + } + const overlaps = intersect(a, b) !== null; + const merged = union(a, b); + const wasteOk = area(merged) <= (area(a) + area(b)) * 2; + if (overlaps || (isNearby(a, b, slack) && wasteOk)) { + return [i, j]; + } + } + } + return null; + }; + + let pair = findMergeablePair(current); + while (pair !== null && current.length > 1) { + const [i, j] = pair; + const a = current[i] as Rect; + const b = current[j] as Rect; + const next = current.filter((_, idx) => idx !== i && idx !== j); + next.push(union(a, b)); + current = next; + pair = findMergeablePair(current); + } + + if (current.length > maxRegions) { + return [current.reduce((acc, r) => union(acc, r))]; + } + + return current; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/math/snapping.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/math/snapping.test.ts new file mode 100644 index 00000000000..174b70d7e50 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/math/snapping.test.ts @@ -0,0 +1,141 @@ +import type { Rect } from '@workbench/canvas-engine/types'; + +import { describe, expect, it } from 'vitest'; + +import { + clampZoom, + constrainAspect, + snapRectToGrid, + snapToGrid, + snapZoom, + ZOOM_MAX, + ZOOM_MIN, + ZOOM_SNAP_CANDIDATES, +} from './snapping'; + +describe('snapZoom', () => { + it('snaps to an exact candidate', () => { + expect(snapZoom(1)).toBe(1); + expect(snapZoom(0.25)).toBe(0.25); + }); + + it('snaps a value within tolerance of a candidate', () => { + expect(snapZoom(1.02)).toBe(1); + expect(snapZoom(0.98)).toBe(1); + }); + + it('passes through a value outside tolerance of any candidate', () => { + expect(snapZoom(1.2)).toBe(1.2); + expect(snapZoom(0.6)).toBe(0.6); + }); + + it('snaps at the tolerance boundary and passes through just beyond it', () => { + const candidate = 2; + const tolerance = candidate * 0.03; + expect(snapZoom(candidate + tolerance * 0.99)).toBe(candidate); + expect(snapZoom(candidate + tolerance * 1.5)).toBeCloseTo(candidate + tolerance * 1.5, 10); + }); + + it('supports custom candidates', () => { + expect(snapZoom(1.01, [1, 10])).toBe(1); + expect(snapZoom(5, [1, 10])).toBe(5); + }); + + it('exports the default candidates as a stable list', () => { + expect(ZOOM_SNAP_CANDIDATES).toEqual([0.25, 0.33, 0.5, 0.67, 0.75, 1, 1.25, 1.5, 2, 3, 4, 5]); + }); +}); + +describe('clampZoom', () => { + it('clamps values below the minimum', () => { + expect(clampZoom(0)).toBe(ZOOM_MIN); + expect(clampZoom(-5)).toBe(ZOOM_MIN); + }); + + it('clamps values above the maximum', () => { + expect(clampZoom(100)).toBe(ZOOM_MAX); + }); + + it('passes through in-range values', () => { + expect(clampZoom(1)).toBe(1); + expect(clampZoom(ZOOM_MIN)).toBe(ZOOM_MIN); + expect(clampZoom(ZOOM_MAX)).toBe(ZOOM_MAX); + }); +}); + +describe('snapToGrid', () => { + it('rounds to the nearest multiple of grid', () => { + expect(snapToGrid(10, 8)).toBe(8); + expect(snapToGrid(13, 8)).toBe(16); + expect(snapToGrid(0, 16)).toBe(0); + }); + + it('handles negative values', () => { + expect(snapToGrid(-10, 8)).toBe(-8); + }); + + it('returns the value unchanged for a non-positive grid', () => { + expect(snapToGrid(13.3, 0)).toBe(13.3); + expect(snapToGrid(13.3, -4)).toBe(13.3); + }); +}); + +describe('snapRectToGrid', () => { + it('snaps position and the far edge to the grid, deriving size', () => { + const r: Rect = { x: 3, y: 5, width: 30, height: 30 }; + // x: 3 -> 0, right: 33 -> 32 -> width 32 + // y: 5 -> 8, bottom: 35 -> 32 -> height 24 + expect(snapRectToGrid(r, 8)).toEqual({ x: 0, y: 8, width: 32, height: 24 }); + }); + + it('is a no-op for a rect already aligned to the grid', () => { + const r: Rect = { x: 16, y: 32, width: 64, height: 16 }; + expect(snapRectToGrid(r, 16)).toEqual(r); + }); +}); + +describe('constrainAspect', () => { + const square: Rect = { x: 10, y: 10, width: 20, height: 20 }; + + it('keeps the nw corner fixed', () => { + const result = constrainAspect(square, 2, 'nw'); + expect(result).toEqual({ x: 10, y: 10, width: 40, height: 20 }); + }); + + it('keeps the ne corner fixed', () => { + const result = constrainAspect(square, 2, 'ne'); + // right edge (30) stays fixed, width grows to 40, so x = 30 - 40 = -10 + expect(result).toEqual({ x: -10, y: 10, width: 40, height: 20 }); + }); + + it('keeps the sw corner fixed', () => { + const result = constrainAspect(square, 0.5, 'sw'); + // bottom edge (30) stays fixed; height = width / aspect... width kept? currentAspect(1) < aspect? no aspect=0.5 <1 + // currentAspect(1) > aspect(0.5) -> keep width(20), height = width/aspect = 40 + expect(result).toEqual({ x: 10, y: -10, width: 20, height: 40 }); + }); + + it('keeps the se corner fixed', () => { + const result = constrainAspect(square, 2, 'se'); + expect(result).toEqual({ x: -10, y: 10, width: 40, height: 20 }); + }); + + it('keeps the center fixed', () => { + const result = constrainAspect(square, 2, 'center'); + expect(result).toEqual({ x: 0, y: 10, width: 40, height: 20 }); + }); + + it('is a no-op-ish passthrough for a non-positive aspect or empty rect', () => { + expect(constrainAspect(square, 0, 'nw')).toEqual(square); + expect(constrainAspect(square, -1, 'nw')).toEqual(square); + const empty: Rect = { x: 0, y: 0, width: 0, height: 10 }; + expect(constrainAspect(empty, 2, 'nw')).toEqual(empty); + }); + + it('leaves an already-matching aspect rect unchanged in size', () => { + const wide: Rect = { x: 0, y: 0, width: 40, height: 20 }; + const result = constrainAspect(wide, 2, 'center'); + expect(result.width).toBeCloseTo(40, 8); + expect(result.height).toBeCloseTo(20, 8); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/math/snapping.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/math/snapping.ts new file mode 100644 index 00000000000..70c849277f7 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/math/snapping.ts @@ -0,0 +1,110 @@ +/** + * Pure snapping/clamping helpers for zoom, grid, and aspect-ratio math. + * + * No classes, no mutation — every function returns a new value. + */ + +import type { Rect } from '@workbench/canvas-engine/types'; + +/** Zoom levels the viewport snaps to when close enough. Single source of truth for the HUD. */ +export const ZOOM_SNAP_CANDIDATES: readonly number[] = [0.25, 0.33, 0.5, 0.67, 0.75, 1, 1.25, 1.5, 2, 3, 4, 5]; + +/** Relative tolerance (fraction of the candidate value) used by `snapZoom`. */ +export const ZOOM_SNAP_TOLERANCE = 0.03; + +/** Minimum allowed zoom, used by `clampZoom`. */ +export const ZOOM_MIN = 0.1; + +/** Maximum allowed zoom, used by `clampZoom`. */ +export const ZOOM_MAX = 20; + +/** + * Snaps `zoom` to the nearest of `candidates` if it's within a ~3% relative + * tolerance of that candidate; otherwise returns `zoom` unchanged. + */ +export const snapZoom = (zoom: number, candidates: readonly number[] = ZOOM_SNAP_CANDIDATES): number => { + let closest: number | null = null; + let closestDelta = Infinity; + for (const candidate of candidates) { + const delta = Math.abs(zoom - candidate); + if (delta < closestDelta) { + closestDelta = delta; + closest = candidate; + } + } + if (closest === null) { + return zoom; + } + const tolerance = closest * ZOOM_SNAP_TOLERANCE; + return closestDelta <= tolerance ? closest : zoom; +}; + +/** Clamps `zoom` to `[ZOOM_MIN, ZOOM_MAX]`. */ +export const clampZoom = (zoom: number): number => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom)); + +/** Snaps a single value to the nearest multiple of `grid`. Returns `value` unchanged if `grid <= 0`. */ +export const snapToGrid = (value: number, grid: number): number => { + if (grid <= 0) { + return value; + } + return Math.round(value / grid) * grid; +}; + +/** Snaps a rect's position and size to the nearest multiple of `grid`. */ +export const snapRectToGrid = (rect: Rect, grid: number): Rect => { + const x = snapToGrid(rect.x, grid); + const y = snapToGrid(rect.y, grid); + const right = snapToGrid(rect.x + rect.width, grid); + const bottom = snapToGrid(rect.y + rect.height, grid); + return { x, y, width: right - x, height: bottom - y }; +}; + +/** The corner or center kept fixed when `constrainAspect` resizes a rect. */ +export type AspectAnchor = 'nw' | 'ne' | 'sw' | 'se' | 'center'; + +/** + * Resizes `rect` to match `aspect` (width / height), keeping the named + * anchor point fixed. The rect's area is preserved as closely as possible + * by scaling both dimensions from the current size to fit the target + * aspect ratio (the dimension that would grow beyond the current bounding + * box is chosen based on which axis needs less change). + */ +export const constrainAspect = (rect: Rect, aspect: number, anchor: AspectAnchor): Rect => { + if (aspect <= 0 || rect.width <= 0 || rect.height <= 0) { + return rect; + } + + const currentAspect = rect.width / rect.height; + let width: number; + let height: number; + + if (currentAspect > aspect) { + // Too wide for the target aspect: keep width, shrink/grow height to match. + width = rect.width; + height = width / aspect; + } else { + // Too tall (or exact): keep height, shrink/grow width to match. + height = rect.height; + width = height * aspect; + } + + const left = rect.x; + const top = rect.y; + const right = rect.x + rect.width; + const bottom = rect.y + rect.height; + const centerX = rect.x + rect.width / 2; + const centerY = rect.y + rect.height / 2; + + switch (anchor) { + case 'nw': + return { x: left, y: top, width, height }; + case 'ne': + return { x: right - width, y: top, width, height }; + case 'sw': + return { x: left, y: bottom - height, width, height }; + case 'se': + return { x: right - width, y: bottom - height, width, height }; + case 'center': + return { x: centerX - width / 2, y: centerY - height / 2, width, height }; + } +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/adjustedSurfaceCache.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/adjustedSurfaceCache.test.ts new file mode 100644 index 00000000000..69f28117183 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/adjustedSurfaceCache.test.ts @@ -0,0 +1,101 @@ +import type { CanvasAdjustmentsContract } from '@workbench/types'; + +import { describe, expect, it } from 'vitest'; + +import type { LayerCacheEntry } from './layerCache'; +import type { StubRasterSurface } from './raster.testStub'; + +import { createAdjustedSurfaceCache } from './adjustedSurfaceCache'; +import { createTestStubRasterBackend } from './raster.testStub'; + +const BRIGHTEN: CanvasAdjustmentsContract = { brightness: 0.5, contrast: 0, saturation: 0 }; +const CONTRAST: CanvasAdjustmentsContract = { brightness: 0, contrast: 0.5, saturation: 0 }; + +const makeEntry = (layerId: string, surface: StubRasterSurface, version = 0): LayerCacheEntry => ({ + lastUsed: 0, + layerId, + rect: { height: surface.height, width: surface.width, x: 0, y: 0 }, + stale: false, + surface, + version, +}); + +const drawImageCount = (surface: StubRasterSurface): number => + surface.callLog.filter((entry) => entry.op === 'drawImage').length; + +describe('createAdjustedSurfaceCache', () => { + it('returns null (and caches nothing) for identity adjustments', () => { + const backend = createTestStubRasterBackend(); + const cache = createAdjustedSurfaceCache(backend); + const entry = makeEntry('a', backend.createSurface(10, 10)); + expect(cache.get('a', entry, undefined)).toBeNull(); + expect(cache.get('a', entry, { brightness: 0, contrast: 0, saturation: 0 })).toBeNull(); + expect(cache.size()).toBe(0); + }); + + it('builds and reuses the adjusted surface while version + adjustments are unchanged', () => { + const backend = createTestStubRasterBackend(); + const cache = createAdjustedSurfaceCache(backend); + const entry = makeEntry('a', backend.createSurface(10, 10)); + + const first = cache.get('a', entry, BRIGHTEN); + expect(first).not.toBeNull(); + const stub = first as StubRasterSurface; + expect(drawImageCount(stub)).toBe(1); + + // Same version + key: reuse the same surface, no rebuild. + const second = cache.get('a', entry, BRIGHTEN); + expect(second).toBe(first); + expect(drawImageCount(stub)).toBe(1); + expect(cache.size()).toBe(1); + }); + + it('rebuilds when the adjustments value changes', () => { + const backend = createTestStubRasterBackend(); + const cache = createAdjustedSurfaceCache(backend); + const entry = makeEntry('a', backend.createSurface(10, 10)); + + const first = cache.get('a', entry, BRIGHTEN) as StubRasterSurface; + expect(drawImageCount(first)).toBe(1); + // Different adjustments → rebuild (draws again into the reused surface). + cache.get('a', entry, CONTRAST); + expect(drawImageCount(first)).toBe(2); + }); + + it('invalidates only the edited layer when its cache version bumps', () => { + const backend = createTestStubRasterBackend(); + const cache = createAdjustedSurfaceCache(backend); + const entryA = makeEntry('a', backend.createSurface(10, 10), 0); + const entryB = makeEntry('b', backend.createSurface(10, 10), 0); + + const surfaceA = cache.get('a', entryA, BRIGHTEN) as StubRasterSurface; + const surfaceB = cache.get('b', entryB, BRIGHTEN) as StubRasterSurface; + expect(drawImageCount(surfaceA)).toBe(1); + expect(drawImageCount(surfaceB)).toBe(1); + + // Layer 'a' is edited: its cache version bumps → its adjusted surface rebuilds. + const editedA = makeEntry('a', surfaceA, 1); + cache.get('a', editedA, BRIGHTEN); + expect(drawImageCount(surfaceA)).toBe(2); + + // Layer 'b' is untouched (same version) → reused, no rebuild. + const reusedB = cache.get('b', entryB, BRIGHTEN); + expect(reusedB).toBe(surfaceB); + expect(drawImageCount(surfaceB)).toBe(1); + }); + + it('drops a layer slot on delete and reverting to identity', () => { + const backend = createTestStubRasterBackend(); + const cache = createAdjustedSurfaceCache(backend); + const entry = makeEntry('a', backend.createSurface(10, 10)); + cache.get('a', entry, BRIGHTEN); + expect(cache.size()).toBe(1); + // Reverting to identity clears the slot. + expect(cache.get('a', entry, undefined)).toBeNull(); + expect(cache.size()).toBe(0); + // Re-cache then delete. + cache.get('a', entry, BRIGHTEN); + cache.delete('a'); + expect(cache.size()).toBe(0); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/adjustedSurfaceCache.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/adjustedSurfaceCache.ts new file mode 100644 index 00000000000..7def0179e4f --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/adjustedSurfaceCache.ts @@ -0,0 +1,105 @@ +/** + * A cached, non-destructive "adjusted surface" per raster layer. + * + * Raster adjustments (brightness/contrast/saturation + curves) must be applied at + * composite time WITHOUT recomputing every frame (the plan explicitly forbids a + * third per-frame recompute alongside the mask-colorize and control-transparency + * smells). This store memoizes each layer's adjusted pixels, keyed by the source + * cache's `version` AND the adjustments' identity ({@link adjustmentsKey}), and + * rebuilds only when either changes — so a steady frame reuses the cached surface, + * an edit to the layer (cache version bump) invalidates it, and an unrelated + * layer's edit does not. + * + * Everything flows through the injected {@link RasterBackend} seam, so it runs + * unchanged in node tests. Zero React, zero import-time side effects. + */ + +import type { CanvasAdjustmentsContract } from '@workbench/types'; + +import type { LayerCacheEntry } from './layerCache'; +import type { RasterBackend, RasterSurface } from './raster'; + +import { adjustmentsKey, applyAdjustments, isIdentityAdjustments } from './adjustments'; + +/** The imperative adjusted-surface store returned by {@link createAdjustedSurfaceCache}. */ +export interface AdjustedSurfaceCache { + /** + * Returns a surface holding `entry`'s pixels with `adjustments` applied, or + * `null` when the adjustments are identity (the caller should draw the original + * cache surface). The returned surface is memoized: an unchanged + * `(entry.version, adjustmentsKey)` reuses it with zero pixel work. + */ + get( + layerId: string, + entry: LayerCacheEntry, + adjustments: CanvasAdjustmentsContract | undefined + ): RasterSurface | null; + /** Drops a layer's memoized adjusted surface (e.g. on layer delete). */ + delete(layerId: string): void; + /** Number of memoized adjusted surfaces (for tests / accounting). */ + size(): number; + /** Releases all memoized surfaces. */ + dispose(): void; +} + +interface CacheSlot { + version: number; + key: string; + surface: RasterSurface; +} + +/** Creates an {@link AdjustedSurfaceCache} backed by the given {@link RasterBackend}. */ +export const createAdjustedSurfaceCache = (backend: RasterBackend): AdjustedSurfaceCache => { + const slots = new Map(); + + const get = ( + layerId: string, + entry: LayerCacheEntry, + adjustments: CanvasAdjustmentsContract | undefined + ): RasterSurface | null => { + if (isIdentityAdjustments(adjustments)) { + // Identity: nothing to cache — drop any stale slot and let the caller draw + // the original surface. + slots.delete(layerId); + return null; + } + const { height, width } = entry.surface; + if (width <= 0 || height <= 0) { + return null; + } + const key = adjustmentsKey(adjustments); + const existing = slots.get(layerId); + if (existing && existing.version === entry.version && existing.key === key) { + return existing.surface; + } + + // (Re)build the adjusted surface: copy the source pixels, apply the LUTs. + const surface = existing?.surface ?? backend.createSurface(width, height); + if (surface.width !== width || surface.height !== height) { + surface.resize(width, height); + } + const ctx = surface.ctx; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, width, height); + ctx.globalAlpha = 1; + ctx.globalCompositeOperation = 'source-over'; + ctx.drawImage(entry.surface.canvas, 0, 0); + const imageData = ctx.getImageData(0, 0, width, height); + applyAdjustments(imageData, adjustments); + ctx.putImageData(imageData, 0, 0); + + slots.set(layerId, { key, surface, version: entry.version }); + return surface; + }; + + return { + delete: (layerId) => { + slots.delete(layerId); + }, + dispose: () => { + slots.clear(); + }, + get, + size: () => slots.size, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/adjustments.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/adjustments.test.ts new file mode 100644 index 00000000000..8fbe92e6b73 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/adjustments.test.ts @@ -0,0 +1,211 @@ +import type { CanvasAdjustmentsContract } from '@workbench/types'; + +import { describe, expect, it } from 'vitest'; + +import { + adjustmentsKey, + applyAdjustments, + buildAdjustmentLuts, + buildCurveLut, + DEFAULT_ADJUSTMENTS, + isIdentityAdjustments, +} from './adjustments'; + +/** Builds an ImageData-like object (node has no DOM ImageData; the shape is enough). */ +const imageData = (pixels: number[]): ImageData => + ({ data: new Uint8ClampedArray(pixels), height: 1, width: pixels.length / 4 }) as ImageData; + +describe('buildCurveLut', () => { + it('is the identity for absent / empty / diagonal curves', () => { + for (const pts of [ + undefined, + [], + [ + [0, 0], + [255, 255], + ], + ] as const) { + const lut = buildCurveLut(pts as never); + expect(lut[0]).toBe(0); + expect(lut[128]).toBe(128); + expect(lut[255]).toBe(255); + } + }); + + it('clamps values outside the first/last control point to the endpoints', () => { + // A curve that starts at x=64 (y=0) and ends at x=192 (y=255): below 64 → 0, above 192 → 255. + const lut = buildCurveLut([ + [64, 0], + [192, 255], + ]); + expect(lut[0]).toBe(0); + expect(lut[64]).toBe(0); + expect(lut[192]).toBe(255); + expect(lut[255]).toBe(255); + // Midpoint between the two knots is roughly mid grey. + expect(lut[128]).toBeGreaterThan(100); + expect(lut[128]).toBeLessThan(160); + }); + + it('interpolates monotonically through interior points without overshoot', () => { + const lut = buildCurveLut([ + [0, 0], + [128, 200], + [255, 255], + ]); + // The 128 knot is honoured. + expect(lut[128]).toBe(200); + // Monotonic non-decreasing, always within [0, 255]. + for (let i = 1; i < 256; i++) { + expect(lut[i]).toBeGreaterThanOrEqual(lut[i - 1]); + expect(lut[i]).toBeLessThanOrEqual(255); + expect(lut[i]).toBeGreaterThanOrEqual(0); + } + }); +}); + +describe('buildAdjustmentLuts', () => { + it('is the identity for default adjustments', () => { + const { b, g, r } = buildAdjustmentLuts(DEFAULT_ADJUSTMENTS); + for (let i = 0; i < 256; i++) { + expect(r[i]).toBe(i); + expect(g[i]).toBe(i); + expect(b[i]).toBe(i); + } + }); + + it('applies additive brightness and clamps', () => { + const { r } = buildAdjustmentLuts({ brightness: 0.5, contrast: 0, saturation: 0 }); + // +0.5*255 ≈ +128 (rounded). + expect(r[0]).toBe(128); + expect(r[200]).toBe(255); // clamped + }); + + it('applies contrast about mid-grey', () => { + const { r } = buildAdjustmentLuts({ brightness: 0, contrast: 1, saturation: 0 }); + // factor 2 about 128: 128 stays, 0 → -128 clamp 0, 255 → 382 clamp 255. + expect(r[128]).toBe(128); + expect(r[0]).toBe(0); + expect(r[255]).toBe(255); + expect(r[64]).toBe(0); // (64-128)*2+128 = 0 + expect(r[192]).toBe(255); // (192-128)*2+128 = 256 → clamp + }); + + it('composes curve → brightness → contrast', () => { + // A curve mapping everything to 100, then +0 brightness, contrast 0 → all 100. + const { r } = buildAdjustmentLuts({ + brightness: 0, + contrast: 0, + curves: { + b: [ + [0, 0], + [255, 255], + ], + g: [ + [0, 0], + [255, 255], + ], + r: [ + [0, 100], + [255, 100], + ], + }, + saturation: 0, + }); + expect(r[0]).toBe(100); + expect(r[255]).toBe(100); + }); +}); + +describe('isIdentityAdjustments / adjustmentsKey', () => { + it('treats zeros + diagonal / absent curves as identity', () => { + expect(isIdentityAdjustments(undefined)).toBe(true); + expect(isIdentityAdjustments(DEFAULT_ADJUSTMENTS)).toBe(true); + expect( + isIdentityAdjustments({ + brightness: 0, + contrast: 0, + saturation: 0, + curves: { + b: [ + [0, 0], + [255, 255], + ], + g: [ + [0, 0], + [255, 255], + ], + r: [ + [0, 0], + [255, 255], + ], + }, + }) + ).toBe(true); + expect(adjustmentsKey(DEFAULT_ADJUSTMENTS)).toBe('identity'); + }); + + it('is non-identity for any non-zero param or bent curve', () => { + expect(isIdentityAdjustments({ brightness: 0.1, contrast: 0, saturation: 0 })).toBe(false); + expect( + isIdentityAdjustments({ + brightness: 0, + contrast: 0, + saturation: 0, + curves: { + b: [ + [0, 0], + [255, 255], + ], + g: [ + [0, 0], + [255, 255], + ], + r: [ + [0, 0], + [128, 200], + [255, 255], + ], + }, + }) + ).toBe(false); + }); + + it('produces distinct, stable keys per distinct adjustment', () => { + const a: CanvasAdjustmentsContract = { brightness: 0.2, contrast: 0, saturation: 0 }; + const b: CanvasAdjustmentsContract = { brightness: 0.3, contrast: 0, saturation: 0 }; + expect(adjustmentsKey(a)).toBe(adjustmentsKey({ ...a })); + expect(adjustmentsKey(a)).not.toBe(adjustmentsKey(b)); + }); +}); + +describe('applyAdjustments', () => { + it('is a no-op for identity adjustments', () => { + const img = imageData([10, 20, 30, 255]); + applyAdjustments(img, DEFAULT_ADJUSTMENTS); + expect(Array.from(img.data)).toEqual([10, 20, 30, 255]); + }); + + it('never modifies alpha', () => { + const img = imageData([10, 20, 30, 128]); + applyAdjustments(img, { brightness: 0.5, contrast: 0, saturation: 0 }); + expect(img.data[3]).toBe(128); + }); + + it('brightens rgb', () => { + const img = imageData([10, 20, 30, 255]); + applyAdjustments(img, { brightness: 0.5, contrast: 0, saturation: 0 }); + expect(img.data[0]).toBe(138); // 10 + 128 + expect(img.data[1]).toBe(148); + expect(img.data[2]).toBe(158); + }); + + it('fully desaturates to luma at saturation -1', () => { + const img = imageData([200, 100, 50, 255]); + applyAdjustments(img, { brightness: 0, contrast: 0, saturation: -1 }); + const luma = Math.round(0.299 * 200 + 0.587 * 100 + 0.114 * 50); + expect(img.data[0]).toBe(luma); + expect(img.data[1]).toBe(luma); + expect(img.data[2]).toBe(luma); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/adjustments.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/adjustments.ts new file mode 100644 index 00000000000..9548f80b41c --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/adjustments.ts @@ -0,0 +1,239 @@ +/** + * Pure raster-adjustment math (no DOM, no engine, no React). + * + * Turns a {@link CanvasAdjustmentsContract} into per-channel 256-entry lookup + * tables and applies them (plus saturation) to raw `ImageData`. Kept pure and + * allocation-conscious so it can drive BOTH the non-destructive display cache + * (`adjustedSurfaceCache.ts`) and the generation composite + * (`export/compositeForGeneration.ts`), and so its correctness is exhaustively + * node-testable without any canvas. + * + * ## Composition order (documented, tested) + * + * Per channel, a single LUT is built as `contrast(brightness(curve(i)))`: + * 1. **Curve** — the per-channel monotone-cubic curve remaps the raw value. + * 2. **Brightness** — additive offset (`+ brightness * 255`). + * 3. **Contrast** — linear scale about mid-grey (`(v - 128) * (1 + contrast) + 128`). + * The LUT is applied to R/G/B; alpha is never touched. **Saturation** is applied + * last, per pixel, on the post-LUT values (a luminance lerp), since it mixes + * channels and cannot be expressed as an independent per-channel LUT. + * + * All scalar params are in `[-1, 1]` with `0` = identity; curve control points + * are `[input, output]` pairs in `[0, 255]`. + */ + +import type { CanvasAdjustmentsContract } from '@workbench/types'; + +/** The identity adjustment (no brightness/contrast/saturation, no curves). */ +export const DEFAULT_ADJUSTMENTS: CanvasAdjustmentsContract = { brightness: 0, contrast: 0, saturation: 0 }; + +const LUT_SIZE = 256; + +/** ITU-R BT.601 luma weights, matching legacy grayscale/lightness math. */ +const LUMA_R = 0.299; +const LUMA_G = 0.587; +const LUMA_B = 0.114; + +const clamp255 = (v: number): number => (v < 0 ? 0 : v > 255 ? 255 : v); + +type CurvePoints = readonly (readonly [number, number])[]; + +/** True when a channel's curve points describe the identity mapping (absent, or exactly 0→0 / 255→255). */ +const isIdentityCurve = (points: CurvePoints | undefined): boolean => { + if (!points || points.length === 0) { + return true; + } + // Any 2-point curve that is exactly the diagonal is identity. + if (points.length === 2) { + const sorted = [...points].sort((a, b) => a[0] - b[0]); + const [p0, p1] = sorted; + return p0[0] === 0 && p0[1] === 0 && p1[0] === 255 && p1[1] === 255; + } + return false; +}; + +/** + * Builds a 256-entry LUT that maps input → output through the channel's curve + * control points using monotone-cubic (Fritsch–Carlson) interpolation, so the + * result never overshoots between points. Fewer than two points → identity. + * Values before the first / after the last point are clamped to that point's + * output (flat extension). + */ +export const buildCurveLut = (points: CurvePoints | undefined): Uint8ClampedArray => { + const lut = new Uint8ClampedArray(LUT_SIZE); + if (isIdentityCurve(points)) { + for (let i = 0; i < LUT_SIZE; i++) { + lut[i] = i; + } + return lut; + } + + // Clean + sort + dedupe by x (keep the last y for a duplicated x). + const byX = new Map(); + for (const [x, y] of points as CurvePoints) { + byX.set(clamp255(Math.round(x)), clamp255(y)); + } + const xs = [...byX.keys()].sort((a, b) => a - b); + if (xs.length < 2) { + for (let i = 0; i < LUT_SIZE; i++) { + lut[i] = i; + } + return lut; + } + const ys = xs.map((x) => byX.get(x) as number); + + const n = xs.length; + // Secant slopes between consecutive points. + const delta: number[] = []; + for (let i = 0; i < n - 1; i++) { + const dx = xs[i + 1] - xs[i]; + delta.push(dx === 0 ? 0 : (ys[i + 1] - ys[i]) / dx); + } + // Fritsch–Carlson tangents (m) enforcing monotonicity. + const m: number[] = Array.from({ length: n }, () => 0); + m[0] = delta[0]; + m[n - 1] = delta[n - 2]; + for (let i = 1; i < n - 1; i++) { + if (delta[i - 1] * delta[i] <= 0) { + m[i] = 0; + } else { + m[i] = (delta[i - 1] + delta[i]) / 2; + } + } + for (let i = 0; i < n - 1; i++) { + if (delta[i] === 0) { + m[i] = 0; + m[i + 1] = 0; + continue; + } + const a = m[i] / delta[i]; + const b = m[i + 1] / delta[i]; + const h = Math.hypot(a, b); + if (h > 3) { + const t = 3 / h; + m[i] = t * a * delta[i]; + m[i + 1] = t * b * delta[i]; + } + } + + let seg = 0; + for (let i = 0; i < LUT_SIZE; i++) { + if (i <= xs[0]) { + lut[i] = ys[0]; + continue; + } + if (i >= xs[n - 1]) { + lut[i] = ys[n - 1]; + continue; + } + while (seg < n - 2 && i > xs[seg + 1]) { + seg += 1; + } + const x0 = xs[seg]; + const x1 = xs[seg + 1]; + const hSeg = x1 - x0; + const t = (i - x0) / hSeg; + const t2 = t * t; + const t3 = t2 * t; + const h00 = 2 * t3 - 3 * t2 + 1; + const h10 = t3 - 2 * t2 + t; + const h01 = -2 * t3 + 3 * t2; + const h11 = t3 - t2; + const value = h00 * ys[seg] + h10 * hSeg * m[seg] + h01 * ys[seg + 1] + h11 * hSeg * m[seg + 1]; + lut[i] = clamp255(Math.round(value)); + } + return lut; +}; + +/** + * Builds the composed per-channel LUTs for `adjustments`: + * `contrast(brightness(curve(i)))`, clamped to `[0, 255]`. Brightness/contrast are + * shared across channels; the curve is per-channel. + */ +export const buildAdjustmentLuts = ( + adjustments: CanvasAdjustmentsContract +): { r: Uint8ClampedArray; g: Uint8ClampedArray; b: Uint8ClampedArray } => { + const brightnessOffset = (adjustments.brightness ?? 0) * 255; + const contrastFactor = 1 + (adjustments.contrast ?? 0); + const curves = adjustments.curves; + + const compose = (curveLut: Uint8ClampedArray): Uint8ClampedArray => { + const out = new Uint8ClampedArray(LUT_SIZE); + for (let i = 0; i < LUT_SIZE; i++) { + const curved = curveLut[i]; + const brightened = curved + brightnessOffset; + const contrasted = (brightened - 128) * contrastFactor + 128; + out[i] = clamp255(Math.round(contrasted)); + } + return out; + }; + + return { + b: compose(buildCurveLut(curves?.b)), + g: compose(buildCurveLut(curves?.g)), + r: compose(buildCurveLut(curves?.r)), + }; +}; + +/** True when `adjustments` is a no-op (identity brightness/contrast/saturation + identity curves). */ +export const isIdentityAdjustments = (adjustments: CanvasAdjustmentsContract | undefined): boolean => { + if (!adjustments) { + return true; + } + if ((adjustments.brightness ?? 0) !== 0 || (adjustments.contrast ?? 0) !== 0 || (adjustments.saturation ?? 0) !== 0) { + return false; + } + const { curves } = adjustments; + if (!curves) { + return true; + } + return isIdentityCurve(curves.r) && isIdentityCurve(curves.g) && isIdentityCurve(curves.b); +}; + +/** A deterministic cache key fully identifying an adjustment's pixel effect. */ +export const adjustmentsKey = (adjustments: CanvasAdjustmentsContract | undefined): string => { + if (isIdentityAdjustments(adjustments)) { + return 'identity'; + } + const a = adjustments as CanvasAdjustmentsContract; + const curveKey = (pts: CurvePoints | undefined): string => + pts && pts.length > 0 ? pts.map(([x, y]) => `${x},${y}`).join(';') : '-'; + return [ + `b${a.brightness ?? 0}`, + `c${a.contrast ?? 0}`, + `s${a.saturation ?? 0}`, + `r:${curveKey(a.curves?.r)}`, + `g:${curveKey(a.curves?.g)}`, + `bl:${curveKey(a.curves?.b)}`, + ].join('|'); +}; + +/** + * Applies `adjustments` to `imageData` IN PLACE: the composed per-channel LUTs + * remap R/G/B, then saturation lerps each channel toward its luma. Alpha is never + * modified. A no-op for identity adjustments. + */ +export const applyAdjustments = (imageData: ImageData, adjustments: CanvasAdjustmentsContract | undefined): void => { + if (isIdentityAdjustments(adjustments)) { + return; + } + const a = adjustments as CanvasAdjustmentsContract; + const { b: lutB, g: lutG, r: lutR } = buildAdjustmentLuts(a); + const sat = 1 + (a.saturation ?? 0); + const applySaturation = sat !== 1; + const { data } = imageData; + for (let i = 0; i + 3 < data.length; i += 4) { + let r = lutR[data[i]]; + let g = lutG[data[i + 1]]; + let b = lutB[data[i + 2]]; + if (applySaturation) { + const lum = LUMA_R * r + LUMA_G * g + LUMA_B * b; + r = clamp255(Math.round(lum + (r - lum) * sat)); + g = clamp255(Math.round(lum + (g - lum) * sat)); + b = clamp255(Math.round(lum + (b - lum) * sat)); + } + data[i] = r; + data[i + 1] = g; + data[i + 2] = b; + } +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/colorSample.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/colorSample.test.ts new file mode 100644 index 00000000000..c2cd0871825 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/colorSample.test.ts @@ -0,0 +1,147 @@ +import type { CanvasDocumentContractV2, CanvasLayerContract, CanvasRasterLayerContractV2 } from '@workbench/types'; + +import { describe, expect, it } from 'vitest'; + +import type { RasterBackend, RasterSurface } from './raster'; + +import { sampleDocumentColor } from './colorSample'; +import { createLayerCacheStore } from './layerCache'; + +/** A fake surface that records `drawImage`/`setTransform` calls, for asserting traversal order and translation math. */ +interface FakeSurface extends RasterSurface { + drawnCanvases: unknown[]; + transforms: number[][]; +} + +/** A {@link RasterBackend} test double that also exposes every surface it created, for assertions. */ +interface FixedPixelBackend extends RasterBackend { + __surfaces: FakeSurface[]; +} + +/** + * A minimal `RasterBackend` whose scratch surfaces report a single fixed pixel + * for every `getImageData` call — enough to test `sampleDocumentColor`'s + * bounds/alpha/traversal logic without modeling real canvas compositing. + */ +const createFixedPixelBackend = (pixel: readonly [number, number, number, number]): FixedPixelBackend => { + const createdSurfaces: FakeSurface[] = []; + + return { + createImageBitmap: () => Promise.resolve({} as ImageBitmap), + createSurface: (width: number, height: number): FakeSurface => { + const drawnCanvases: unknown[] = []; + const transforms: number[][] = []; + const canvas = { height, width } as unknown as OffscreenCanvas; + const ctx = { + clearRect: () => {}, + drawImage: (image: unknown) => drawnCanvases.push(image), + getImageData: () => ({ data: Uint8ClampedArray.from(pixel), height: 1, width: 1 }) as unknown as ImageData, + restore: () => {}, + save: () => {}, + setTransform: (...args: number[]) => transforms.push(args), + } as unknown as OffscreenCanvasRenderingContext2D; + const surface: FakeSurface = { + canvas, + ctx, + drawnCanvases, + height, + resize: () => {}, + transforms, + width, + }; + createdSurfaces.push(surface); + return surface; + }, + encodeSurface: () => Promise.resolve(new Blob()), + __surfaces: createdSurfaces, + }; +}; + +const rasterLayer = (id: string, overrides: Partial = {}): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { bitmap: null, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + ...overrides, +}); + +const makeDoc = (layers: CanvasLayerContract[]): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers, + selectedLayerId: null, + version: 2, + width: 100, +}); + +describe('sampleDocumentColor', () => { + it('returns null for a point outside the document bounds (never allocates a scratch surface)', () => { + const backend = createFixedPixelBackend([10, 20, 30, 255]); + const layers = createLayerCacheStore(backend); + const doc = makeDoc([]); + + expect(sampleDocumentColor(doc, layers, backend, { x: -1, y: 5 })).toBeNull(); + expect(sampleDocumentColor(doc, layers, backend, { x: 100, y: 5 })).toBeNull(); + expect(sampleDocumentColor(doc, layers, backend, { x: 5, y: -1 })).toBeNull(); + expect(sampleDocumentColor(doc, layers, backend, { x: 5, y: 100 })).toBeNull(); + expect(backend.__surfaces).toHaveLength(0); + }); + + it('returns null when the composited pixel is fully transparent', () => { + const backend = createFixedPixelBackend([10, 20, 30, 0]); + const layers = createLayerCacheStore(backend); + const doc = makeDoc([rasterLayer('a')]); + layers.getOrCreate('a', 100, 100); + + expect(sampleDocumentColor(doc, layers, backend, { x: 5, y: 5 })).toBeNull(); + }); + + it('returns the composited rgba when the sampled pixel has non-zero alpha', () => { + const backend = createFixedPixelBackend([10, 20, 30, 128]); + const layers = createLayerCacheStore(backend); + const doc = makeDoc([rasterLayer('a')]); + layers.getOrCreate('a', 100, 100); + + expect(sampleDocumentColor(doc, layers, backend, { x: 5, y: 5 })).toEqual({ a: 128, b: 30, g: 20, r: 10 }); + }); + + it('draws renderable layers bottom-to-top, skipping disabled and uncached layers', () => { + const backend = createFixedPixelBackend([1, 2, 3, 255]); + const layers = createLayerCacheStore(backend); + const topEntry = layers.getOrCreate('top', 100, 100); + const bottomEntry = layers.getOrCreate('bottom', 100, 100); + // 'disabled' and 'nocache' are deliberately excluded from compositing. + const doc = makeDoc([ + rasterLayer('top'), + rasterLayer('disabled', { isEnabled: false }), + rasterLayer('nocache'), + rasterLayer('bottom'), + ]); + + sampleDocumentColor(doc, layers, backend, { x: 5, y: 5 }); + + const scratch = backend.__surfaces.at(-1)!; + expect(scratch.drawnCanvases).toEqual([bottomEntry.surface.canvas, topEntry.surface.canvas]); + }); + + it('translates the view so the floored sample point lands at the scratch origin', () => { + const backend = createFixedPixelBackend([1, 2, 3, 255]); + const layers = createLayerCacheStore(backend); + layers.getOrCreate('a', 100, 100); + const doc = makeDoc([rasterLayer('a')]); + + sampleDocumentColor(doc, layers, backend, { x: 12.7, y: 34.2 }); + + const scratch = backend.__surfaces.at(-1)!; + // Identity layer transform composed with the sample-point translation: e/f + // carry the floored, negated point (no per-layer offset/scale/rotation). + // The last `setTransform` is the per-layer draw (the first is the initial reset). + expect(scratch.transforms.at(-1)).toEqual([1, 0, 0, 1, -12, -34]); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/colorSample.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/colorSample.ts new file mode 100644 index 00000000000..e4a59d82d83 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/colorSample.ts @@ -0,0 +1,94 @@ +/** + * Samples the composited document color at a document-space point, for the + * color-picker tool. Composites just the renderable layers (bottom → top, + * respecting opacity/blend/transform, matching {@link compositeDocument}'s + * paint order) into a 1×1 scratch surface translated so the sampled pixel + * lands at its origin — cheap regardless of document size, since only one + * destination pixel is ever rasterized. + * + * The document background (solid fill or checkerboard) and the staged + * preview are intentionally excluded: a point with no layer coverage samples + * as fully transparent (`null`), not the page chrome. + * + * Zero React, zero import-time side effects. + */ + +import type { Mat2d, Vec2 } from '@workbench/canvas-engine/types'; +import type { CanvasDocumentContractV2 } from '@workbench/types'; + +import { isRenderableLayer } from '@workbench/canvas-engine/document/sources'; +import { fromTRS, multiply } from '@workbench/canvas-engine/math/mat2d'; + +import type { LayerCacheStore } from './layerCache'; +import type { RasterBackend } from './raster'; + +import { blendToComposite } from './compositor'; + +/** An RGBA sample, channels in `[0, 255]`. */ +export interface RgbaSample { + r: number; + g: number; + b: number; + a: number; +} + +const matToTuple = (m: Mat2d): [number, number, number, number, number, number] => [m.a, m.b, m.c, m.d, m.e, m.f]; + +/** + * Samples the composited document color at `docPoint` (document space; + * fractional coordinates are floored to the covering pixel). Returns `null` + * when the point falls outside the document, or when no renderable layer + * covers it with non-zero alpha there. + */ +export const sampleDocumentColor = ( + doc: CanvasDocumentContractV2, + layers: LayerCacheStore, + backend: RasterBackend, + docPoint: Vec2 +): RgbaSample | null => { + const px = Math.floor(docPoint.x); + const py = Math.floor(docPoint.y); + if (px < 0 || py < 0 || px >= doc.width || py >= doc.height) { + return null; + } + + const scratch = backend.createSurface(1, 1); + const ctx = scratch.ctx; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, 1, 1); + // Translate so the sampled document pixel lands at the scratch surface's + // one pixel (0, 0); each layer's own transform composes on top of this. + const view: Mat2d = { a: 1, b: 0, c: 0, d: 1, e: -px, f: -py }; + + // Bottom → top, matching the real compositor's paint order. + for (let i = doc.layers.length - 1; i >= 0; i--) { + const layer = doc.layers[i]; + if (!layer || !isRenderableLayer(layer)) { + continue; + } + const entry = layers.get(layer.id); + if (!entry) { + continue; + } + + ctx.save(); + ctx.globalAlpha = layer.opacity; + ctx.globalCompositeOperation = blendToComposite(layer.blendMode); + const layerMat = fromTRS( + { x: layer.transform.x, y: layer.transform.y }, + layer.transform.rotation, + layer.transform.scaleX, + layer.transform.scaleY + ); + ctx.setTransform(...matToTuple(multiply(view, layerMat))); + ctx.drawImage(entry.surface.canvas, 0, 0); + ctx.restore(); + } + + const { data } = ctx.getImageData(0, 0, 1, 1); + const alpha = data[3] ?? 0; + if (alpha === 0) { + return null; + } + return { a: alpha, b: data[2] ?? 0, g: data[1] ?? 0, r: data[0] ?? 0 }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/compositor.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/compositor.test.ts new file mode 100644 index 00000000000..ee4565cf4f0 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/compositor.test.ts @@ -0,0 +1,455 @@ +import type { Mat2d } from '@workbench/canvas-engine/types'; +import type { + CanvasBlendMode, + CanvasDocumentContractV2, + CanvasImageRef, + CanvasLayerContract, + CanvasRasterLayerContractV2, +} from '@workbench/types'; + +import { identity } from '@workbench/canvas-engine/math/mat2d'; +import { describe, expect, it } from 'vitest'; + +import type { RasterCallLogEntry, StubRasterSurface } from './raster.testStub'; + +import { compositeDocument, createCheckerboardTile, shouldSmoothAtZoom } from './compositor'; +import { createLayerCacheStore } from './layerCache'; +import { createTestStubRasterBackend } from './raster.testStub'; + +const VIEW: Mat2d = identity(); + +const imageRef = (): CanvasImageRef => ({ height: 10, imageName: 'x', width: 10 }); + +const rasterLayer = (id: string, overrides: Partial = {}): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { image: imageRef(), type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + ...overrides, +}); + +const maskLayer = (id: string): CanvasLayerContract => ({ + autoNegative: false, + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + mask: { bitmap: null, fill: { color: '#ff0000', style: 'solid' } }, + name: id, + negativePrompt: null, + opacity: 1, + positivePrompt: null, + referenceImages: [], + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'regional_guidance', +}); + +const controlLayer = (id: string): CanvasLayerContract => ({ + adapter: { beginEndStepPct: [0, 0.75], controlMode: 'balanced', kind: 'controlnet', model: null, weight: 1 }, + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { image: imageRef(), type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'control', + withTransparencyEffect: false, +}); + +const inpaintMaskLayer = (id: string): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + mask: { bitmap: null, fill: { color: '#00ff00', style: 'solid' } }, + name: id, + opacity: 1, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'inpaint_mask', +}); + +const makeDoc = ( + layers: CanvasLayerContract[], + overrides: Partial = {} +): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers, + selectedLayerId: null, + version: 2, + width: 100, + ...overrides, +}); + +/** Sets are recorded as { op: 'set', args: [prop, value] }; find the value for a prop. */ +const findSet = (log: RasterCallLogEntry[], prop: string): unknown[] => + log.filter((e) => e.op === 'set' && e.args[0] === prop).map((e) => e.args[1]); + +describe('compositeDocument', () => { + it('clears then draws layer caches bottom-to-top (array index 0 is top-most)', () => { + const backend = createTestStubRasterBackend(); + const caches = createLayerCacheStore(backend); + // top -> bottom in array order. + const top = rasterLayer('top'); + const bottom = rasterLayer('bottom'); + const topCache = caches.getOrCreate('top', 10, 10); + const bottomCache = caches.getOrCreate('bottom', 10, 10); + + const target = backend.createSurface(200, 200); + compositeDocument(target, makeDoc([top, bottom]), caches, VIEW); + + const log = target.callLog; + // First op is a clearRect (after the outer save + identity setTransform). + expect(log.some((e) => e.op === 'clearRect')).toBe(true); + + const drawImages = log.filter((e) => e.op === 'drawImage'); + expect(drawImages).toHaveLength(2); + // Bottom layer's canvas is drawn before the top layer's. + expect(drawImages[0]!.args[0]).toBe(bottomCache.surface.canvas); + expect(drawImages[1]!.args[0]).toBe(topCache.surface.canvas); + }); + + it('skips layers that are disabled', () => { + const backend = createTestStubRasterBackend(); + const caches = createLayerCacheStore(backend); + caches.getOrCreate('a', 10, 10); + caches.getOrCreate('b', 10, 10); + const target = backend.createSurface(200, 200); + + const doc = makeDoc([rasterLayer('a', { isEnabled: false }), rasterLayer('b')]); + compositeDocument(target, doc, caches, VIEW); + + const drawImages = target.callLog.filter((e) => e.op === 'drawImage'); + expect(drawImages).toHaveLength(1); + expect(drawImages[0]!.args[0]).toBe(caches.get('b')!.surface.canvas); + }); + + it('skips the layer named by skipLayerId (open text-edit session)', () => { + const backend = createTestStubRasterBackend(); + const caches = createLayerCacheStore(backend); + caches.getOrCreate('a', 10, 10); + caches.getOrCreate('b', 10, 10); + const target = backend.createSurface(200, 200); + + const doc = makeDoc([rasterLayer('a'), rasterLayer('b')]); + compositeDocument(target, doc, caches, VIEW, { skipLayerId: 'a' }); + + const drawImages = target.callLog.filter((e) => e.op === 'drawImage'); + // 'a' is skipped (its live text is shown by the contenteditable portal); only 'b' draws. + expect(drawImages).toHaveLength(1); + expect(drawImages[0]!.args[0]).toBe(caches.get('b')!.surface.canvas); + }); + + it('skips layers that have no cache entry yet', () => { + const backend = createTestStubRasterBackend(); + const caches = createLayerCacheStore(backend); + caches.getOrCreate('a', 10, 10); + const target = backend.createSurface(200, 200); + + // 'b' has no cache; only 'a' should draw. + compositeDocument(target, makeDoc([rasterLayer('a'), rasterLayer('b')]), caches, VIEW); + expect(target.callLog.filter((e) => e.op === 'drawImage')).toHaveLength(1); + }); + + it('applies per-layer opacity and maps blend mode to a composite operation', () => { + const backend = createTestStubRasterBackend(); + const caches = createLayerCacheStore(backend); + caches.getOrCreate('a', 10, 10); + const target = backend.createSurface(200, 200); + + const blend: CanvasBlendMode = 'multiply'; + compositeDocument(target, makeDoc([rasterLayer('a', { blendMode: blend, opacity: 0.4 })]), caches, VIEW); + + expect(findSet(target.callLog, 'globalAlpha')).toContain(0.4); + expect(findSet(target.callLog, 'globalCompositeOperation')).toContain('multiply'); + }); + + it("maps the 'normal' blend mode to 'source-over'", () => { + const backend = createTestStubRasterBackend(); + const caches = createLayerCacheStore(backend); + caches.getOrCreate('a', 10, 10); + const target = backend.createSurface(200, 200); + + compositeDocument(target, makeDoc([rasterLayer('a', { blendMode: 'normal' })]), caches, VIEW); + expect(findSet(target.callLog, 'globalCompositeOperation')).toContain('source-over'); + }); + + it('fills the ENTIRE viewport with the checkerboard pattern (unbounded plane)', () => { + const backend = createTestStubRasterBackend(); + const caches = createLayerCacheStore(backend); + const tile = createCheckerboardTile(backend); + + const target = backend.createSurface(200, 200); + compositeDocument(target, makeDoc([], { background: 'transparent' }), caches, VIEW, { checkerboardTile: tile }); + + // The pattern is created once from the tile (cheap: no per-cell fill loop). + const patternCalls = target.callLog.filter((e) => e.op === 'createPattern'); + expect(patternCalls).toHaveLength(1); + expect(patternCalls[0]!.args[0]).toBe(tile.canvas); + expect(patternCalls[0]!.args[1]).toBe('repeat'); + + // The whole 200x200 target is filled with the pattern — NOT clipped to the + // 100x100 doc rect (the document is no longer a visual boundary). + const fills = target.callLog.filter((e) => e.op === 'fillRect'); + expect(fills).toHaveLength(1); + expect(fills[0]!.args).toEqual([0, 0, 200, 200]); + // fillStyle was set to the pattern object (the stub's non-null marker), not a color string. + const styles = findSet(target.callLog, 'fillStyle'); + expect(styles).toHaveLength(1); + expect(typeof styles[0]).toBe('object'); + }); + + it('fills the full viewport regardless of the doc rect position/size (offset + scaled view)', () => { + const backend = createTestStubRasterBackend(); + const caches = createLayerCacheStore(backend); + const tile = createCheckerboardTile(backend); + + // A tiny doc, panned far off-origin under a scaled view: the checker still + // covers the whole screen because it is screen-anchored, not doc-anchored. + const view: Mat2d = { a: 3, b: 0, c: 0, d: 3, e: -500, f: 220 }; + const target = backend.createSurface(320, 240); + compositeDocument(target, makeDoc([], { height: 8, width: 8 }), caches, view, { checkerboardTile: tile }); + + const fills = target.callLog.filter((e) => e.op === 'fillRect'); + expect(fills).toHaveLength(1); + expect(fills[0]!.args).toEqual([0, 0, 320, 240]); + }); + + it('draws NO checkerboard when the tile is absent (toggle off), leaving bg.inset through', () => { + const backend = createTestStubRasterBackend(); + const caches = createLayerCacheStore(backend); + + const target = backend.createSurface(200, 200); + compositeDocument(target, makeDoc([], { background: 'transparent' }), caches, VIEW); + + // No tile → no pattern and no fill; the cleared surface shows the widget's bg.inset. + expect(target.callLog.some((e) => e.op === 'createPattern')).toBe(false); + expect(target.callLog.filter((e) => e.op === 'fillRect')).toHaveLength(0); + // The whole target is still cleared each frame. + expect(target.callLog.some((e) => e.op === 'clearRect')).toBe(true); + }); + + it('ignores the contract background field (checker fills the viewport even for a color background)', () => { + const backend = createTestStubRasterBackend(); + const caches = createLayerCacheStore(backend); + + const solidTarget = backend.createSurface(200, 200); + // The `background` field no longer renders: a color-background doc with the + // checkerboard on still fills the whole viewport with the pattern, not a flat color. + compositeDocument(solidTarget, makeDoc([], { background: { color: '#123456' } }), caches, VIEW, { + checkerboardTile: createCheckerboardTile(backend), + }); + const patternCalls = solidTarget.callLog.filter((e) => e.op === 'createPattern'); + expect(patternCalls).toHaveLength(1); + const fills = solidTarget.callLog.filter((e) => e.op === 'fillRect'); + expect(fills).toHaveLength(1); + expect(fills[0]!.args).toEqual([0, 0, 200, 200]); + // The flat color is never applied. + expect(findSet(solidTarget.callLog, 'fillStyle')).not.toContain('#123456'); + }); + + it('builds the checker tile with the given colors on the diagonal', () => { + const backend = createTestStubRasterBackend(); + const tile = createCheckerboardTile(backend, { a: '#111111', b: '#222222' }) as StubRasterSurface; + // The tile's two fillStyle sets are exactly the fed colors (base then diagonal). + const styles = findSet(tile.callLog, 'fillStyle'); + expect(styles).toEqual(['#111111', '#222222']); + }); + + it('draws mask-bearing layers as a tinted fill of the mask color (backend-less fallback)', () => { + const backend = createTestStubRasterBackend(); + const caches = createLayerCacheStore(backend); + caches.getOrCreate('rg', 10, 10); + const target = backend.createSurface(200, 200); + + compositeDocument(target, makeDoc([maskLayer('rg')]), caches, VIEW); + + // Without a backend the mask draws its coverage then tints with a flat fill. + expect(target.callLog.some((e) => e.op === 'drawImage')).toBe(true); + expect(findSet(target.callLog, 'fillStyle')).toContain('#ff0000'); + }); + + it('colorizes the mask alpha via source-in on an intermediate surface when a backend is supplied', () => { + const base = createTestStubRasterBackend(); + const created: StubRasterSurface[] = []; + const backend = { + ...base, + createSurface: (w: number, h: number): StubRasterSurface => { + const surface = base.createSurface(w, h); + created.push(surface); + return surface; + }, + }; + const caches = createLayerCacheStore(backend); + const maskEntry = caches.getOrCreate('rg', 10, 10) as unknown as { surface: StubRasterSurface }; + const target = backend.createSurface(200, 200); + + compositeDocument(target, makeDoc([maskLayer('rg')]), caches, VIEW, { backend }); + + // The colorize happens on a NEW intermediate surface (not the target, not the + // mask cache): it blits the stencil then fills source-in with the fill colour. + const colorized = created.find( + (s) => + s !== target && + s !== maskEntry.surface && + s.callLog.some((e) => e.op === 'set' && e.args[0] === 'globalCompositeOperation' && e.args[1] === 'source-in') + ); + expect(colorized).toBeDefined(); + expect(findSet(colorized!.callLog, 'fillStyle')).toContain('#ff0000'); + // The colorized overlay is then blitted onto the target. + expect(target.callLog.some((e) => e.op === 'drawImage')).toBe(true); + }); + + it('draws mask layers ABOVE all non-mask layers regardless of their global z position', () => { + const backend = createTestStubRasterBackend(); + const caches = createLayerCacheStore(backend); + // A mask placed at the BOTTOM of the z-order (last in the array) must still be + // composited after (above) the raster layer above it. + caches.getOrCreate('raster', 10, 10); + caches.getOrCreate('mask', 10, 10); + const target = backend.createSurface(200, 200) as StubRasterSurface; + + const doc = makeDoc([rasterLayer('raster'), maskLayer('mask')]); + compositeDocument(target, doc, caches, VIEW, { backend }); + + // The mask's source-in colorize marks its draw pass; it must come AFTER the + // last raster blit. Find the first source-in op (mask pass) and assert every + // non-mask blit already ran — i.e. the mask pass draws last. + const firstDrawImage = target.callLog.findIndex((e) => e.op === 'drawImage'); + expect(firstDrawImage).toBeGreaterThanOrEqual(0); + // Two draws land on the target: the raster blit, then the colorized mask blit; + // the mask blit is the LAST drawImage. + const drawIdxs = target.callLog.map((e, i) => (e.op === 'drawImage' ? i : -1)).filter((i) => i >= 0); + expect(drawIdxs.length).toBeGreaterThanOrEqual(2); + }); + + it('composites in strict group order: raster < control < regional < inpaint mask, ignoring global index', () => { + const backend = createTestStubRasterBackend(); + const caches = createLayerCacheStore(backend); + // Distinct cache sizes so each layer's blit is identifiable by source width + // (masks blit a colorized intermediate sized to their cache, not the cache + // surface itself, so identity matching won't work — width does). + caches.getOrCreate('raster', 10, 10); + caches.getOrCreate('control', 11, 11); + caches.getOrCreate('regional', 12, 12); + caches.getOrCreate('inpaint', 13, 13); + const widthToId: Record = { 10: 'raster', 11: 'control', 12: 'regional', 13: 'inpaint' }; + const target = backend.createSurface(200, 200) as StubRasterSurface; + + // Deliberately SCRAMBLED array order (index 0 = top-most): a raster created + // above a control layer, masks interleaved below. Group order must win. + const doc = makeDoc([ + rasterLayer('raster'), + inpaintMaskLayer('inpaint'), + controlLayer('control'), + maskLayer('regional'), + ]); + compositeDocument(target, doc, caches, VIEW, { backend }); + + const order = target.callLog + .filter((e) => e.op === 'drawImage') + .map((e) => widthToId[(e.args[0] as { width: number }).width]) + .filter((id): id is string => id !== undefined); + + // Raster (bottom) first, then control, then the masks — regardless of array index. + expect(order.indexOf('raster')).toBeLessThan(order.indexOf('control')); + expect(order.indexOf('control')).toBeLessThan(order.indexOf('regional')); + expect(order.indexOf('regional')).toBeLessThan(order.indexOf('inpaint')); + }); + + it('disables image smoothing for the composite when imageSmoothing is false (zoomed-in policy)', () => { + const backend = createTestStubRasterBackend(); + const caches = createLayerCacheStore(backend); + caches.getOrCreate('a', 10, 10); + const target = backend.createSurface(200, 200); + + compositeDocument(target, makeDoc([rasterLayer('a')]), caches, VIEW, { imageSmoothing: false }); + // The smoothing flag is set exactly once, to false, so every layer/staged + // blit up-scales nearest-neighbor (crisp + cheap) rather than bilinear. + expect(findSet(target.callLog, 'imageSmoothingEnabled')).toEqual([false]); + }); + + it('enables image smoothing by default and when imageSmoothing is true (down-scale/quality)', () => { + const backend = createTestStubRasterBackend(); + const caches = createLayerCacheStore(backend); + caches.getOrCreate('a', 10, 10); + + const defaulted = backend.createSurface(200, 200); + compositeDocument(defaulted, makeDoc([rasterLayer('a')]), caches, VIEW); + expect(findSet(defaulted.callLog, 'imageSmoothingEnabled')).toEqual([true]); + + const explicit = backend.createSurface(200, 200); + compositeDocument(explicit, makeDoc([rasterLayer('a')]), caches, VIEW, { imageSmoothing: true }); + expect(findSet(explicit.callLog, 'imageSmoothingEnabled')).toEqual([true]); + }); + + it('draws a staged preview over its bbox when provided', () => { + const backend = createTestStubRasterBackend(); + const caches = createLayerCacheStore(backend); + const target = backend.createSurface(200, 200); + const staged = backend.createSurface(50, 50); + + compositeDocument(target, makeDoc([]), caches, VIEW, { + stagedPreview: { rect: { height: 40, width: 40, x: 5, y: 5 }, surface: staged }, + }); + + const drawImages = target.callLog.filter((e) => e.op === 'drawImage'); + expect(drawImages).toHaveLength(1); + expect(drawImages[0]!.args).toEqual([staged.canvas, 5, 5, 40, 40]); + }); +}); + +describe('compositeDocument — raster adjustments', () => { + it('draws the provided adjusted surface instead of the raw cache for a raster layer', () => { + const backend = createTestStubRasterBackend(); + const caches = createLayerCacheStore(backend); + const layer = rasterLayer('a', { adjustments: { brightness: 0.5, contrast: 0, saturation: 0 } }); + const cache = caches.getOrCreate('a', 10, 10); + const adjusted = backend.createSurface(10, 10); + const target = backend.createSurface(200, 200); + + compositeDocument(target, makeDoc([layer]), caches, VIEW, { + adjustedSurface: (l) => (l.id === 'a' ? adjusted : null), + }); + + const drawImages = target.callLog.filter((e) => e.op === 'drawImage'); + expect(drawImages).toHaveLength(1); + // The adjusted surface's canvas is drawn, NOT the raw cache surface. + expect(drawImages[0]!.args[0]).toBe(adjusted.canvas); + expect(drawImages[0]!.args[0]).not.toBe(cache.surface.canvas); + }); + + it('draws the raw cache when the provider returns null (identity / no adjustments)', () => { + const backend = createTestStubRasterBackend(); + const caches = createLayerCacheStore(backend); + const layer = rasterLayer('a'); + const cache = caches.getOrCreate('a', 10, 10); + const target = backend.createSurface(200, 200); + + compositeDocument(target, makeDoc([layer]), caches, VIEW, { adjustedSurface: () => null }); + + const drawImages = target.callLog.filter((e) => e.op === 'drawImage'); + expect(drawImages[0]!.args[0]).toBe(cache.surface.canvas); + }); +}); + +describe('shouldSmoothAtZoom', () => { + it('smooths only when the document is down-scaled (zoom < 1)', () => { + expect(shouldSmoothAtZoom(0.1)).toBe(true); + expect(shouldSmoothAtZoom(0.5)).toBe(true); + expect(shouldSmoothAtZoom(0.99)).toBe(true); + // At or above 1× the document is magnified: keep pixels crisp and skip the + // per-frame bilinear up-scale whose cost grows with zoom. + expect(shouldSmoothAtZoom(1)).toBe(false); + expect(shouldSmoothAtZoom(4)).toBe(false); + expect(shouldSmoothAtZoom(20)).toBe(false); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/compositor.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/compositor.ts new file mode 100644 index 00000000000..c64e4218f48 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/compositor.ts @@ -0,0 +1,377 @@ +/** + * Composites a canvas document onto a target surface. + * + * Draws (in order): a clear + a viewport-filling checkerboard (the unbounded + * plane's surround; omitted when the checkerboard is off), then each layer's + * cached surface from bottom to top applying opacity / blend mode / transform, + * and finally an optional staged-generation preview. Layers without a cache entry are + * skipped — rasterization is the caller's job (see `rasterizers/` + + * `layerCache.ts`); this module only draws what's already cached. + * + * Every pixel operation flows through the {@link RasterSurface} `ctx`, which + * in tests is the recording stub, so composite order is assertable in node. + * Zero React, zero import-time side effects. + */ + +import type { Mat2d, Rect, Vec2 } from '@workbench/canvas-engine/types'; +import type { CanvasBlendMode, CanvasDocumentContractV2, CanvasLayerContract } from '@workbench/types'; + +import { LAYER_GROUP_COUNT, layerGroupRank } from '@workbench/canvas-engine/document/sources'; +import { fromTRS, multiply } from '@workbench/canvas-engine/math/mat2d'; + +import type { LayerCacheEntry, LayerCacheStore } from './layerCache'; +import type { RasterBackend, RasterSurface } from './raster'; + +import { renderControlTransparency } from './controlTransparency'; +import { colorizeMask } from './maskFill'; + +/** Screen-space size (px) of each checkerboard square for transparent backgrounds. */ +export const CHECKERBOARD_SQUARE_PX = 8; + +/** + * CSS-zoom threshold at/above which image smoothing is disabled while + * compositing. See {@link shouldSmoothAtZoom}. + */ +export const SMOOTHING_MAX_ZOOM = 1; + +/** + * Image-smoothing policy for compositing at a given CSS zoom. + * + * Smoothing is enabled only when the document is DOWN-scaled (`zoom < 1`) — + * bilinear interpolation keeps a shrunk image clean. When zoomed IN + * (`zoom >= 1`) the document is up-scaled to fill the screen; smoothing is + * disabled so (a) pixels stay crisp when magnified — the behavior legacy pixel + * editors adopt above ~1× — and (b) the browser skips the per-frame bilinear + * interpolation of an ever-larger upscale, whose fill-rate cost is precisely + * what grows with zoom. Nearest-neighbor upscaling is dramatically cheaper. + */ +export const shouldSmoothAtZoom = (zoom: number): boolean => zoom < SMOOTHING_MAX_ZOOM; + +/** The two square colors of the transparency checkerboard. */ +export interface CheckerColors { + /** The base color, filled across the whole tile. */ + a: string; + /** The alternating color, drawn on the tile's diagonal cells. */ + b: string; +} + +/** + * Fallback checkerboard colors when no theme tokens have been fed to the engine + * (node tests, first frame before React resolves the tokens). Deliberately DARK, + * theme-appropriate greys (in the spirit of the legacy dark transparency pattern) + * so the indicator reads as "empty" against the dark workbench surface. In the + * app these are replaced by resolved Chakra semantic tokens (see + * `widgets/canvas/checkerColors.ts`). + */ +export const DEFAULT_CHECKER_COLORS: CheckerColors = { a: '#2a2a2a', b: '#363636' }; + +/** + * Fallback tint alpha for a mask-bearing layer drawn WITHOUT a backend (a bare + * {@link compositeDocument} call in a minimal test): the mask coverage is blitted + * and a translucent flat fill laid over it. The real path (with a backend) + * colorizes the mask alpha via `source-in` at the layer opacity, matching legacy. + */ +export const MASK_TINT_ALPHA = 0.5; + +/** Dashed outline drawn around a staged-generation preview so it reads as pending, not committed. */ +const STAGED_PREVIEW_OUTLINE_COLOR = '#3b82f6'; +const STAGED_PREVIEW_OUTLINE_WIDTH = 2; +const STAGED_PREVIEW_OUTLINE_DASH = 6; + +/** Optional inputs to {@link compositeDocument}. */ +export interface CompositeOptions { + /** A staged generation candidate to draw over its bbox (document space). */ + stagedPreview?: { surface: RasterSurface; rect: Rect } | null; + /** + * The cached checkerboard pattern tile (see {@link createCheckerboardTile}) to + * fill the ENTIRE viewport with (the canvas is an unbounded plane — the checker + * is the world, not a document backdrop). Omit or pass `null` to draw NO + * checkerboard (the "checkerboard off" state) — the cleared surface then shows + * the widget's themed `bg.inset` through it. + */ + checkerboardTile?: RasterSurface | null; + /** + * Whether `imageSmoothingEnabled` is on for this composite's `drawImage` + * blits (layer caches + staged preview). Defaults to `true` (the browser + * default) so non-viewport callers are unaffected; the engine feeds + * {@link shouldSmoothAtZoom} so zoomed-in frames composite crisp and cheap. + */ + imageSmoothing?: boolean; + /** + * Transient per-layer transform overrides (a live move/transform preview): a + * layer with an entry here is drawn through the overridden transform instead of + * its committed one. `scaleX`/`scaleY`/`rotation` fall back to the committed + * transform when absent (the move tool overrides only `x`/`y`). The mirror stays + * untouched. + */ + transformOverrides?: ReadonlyMap< + string, + { x: number; y: number; scaleX?: number; scaleY?: number; rotation?: number } + > | null; + /** + * A layer id to SKIP entirely (draw nothing for it). Used while a text-edit + * session is open: the contenteditable portal shows the layer's live text + * instead, so drawing its committed pixels underneath would double up. `null` + * (or absent) skips nothing. + */ + skipLayerId?: string | null; + /** + * The raster backend, needed to colorize mask layers (an intermediate surface + * holds the alpha stencil while the fill is composited `source-in`). When + * absent, mask layers fall back to the flat-tint approximation + * ({@link MASK_TINT_ALPHA}); the engine always supplies it. + */ + backend?: RasterBackend | null; + /** + * Returns a cached repeat tile for a mask fill (style, colour), or `null` for a + * solid fill (drawn directly). The engine caches tiles by `style:color` (like + * the checkerboard). Absent ⇒ solid fills only. + */ + maskPatternTile?: ((style: string, color: string) => RasterSurface | null) | null; + /** + * Transient per-layer content previews (a non-destructive control-filter + * preview): a layer with an entry here draws the preview surface — stretched + * over the layer's cached content footprint, through its transform — INSTEAD of + * its committed cache, so the document is untouched until the filter is applied. + * `null`/absent ⇒ no previews. + */ + layerPreviews?: ReadonlyMap | null; + /** + * Returns a raster layer's ADJUSTED cache surface (brightness/contrast/ + * saturation/curves applied), or `null` when the layer has identity (or no) + * adjustments — in which case the committed cache surface is drawn directly. + * The engine wires this to a memoizing {@link + * import('./adjustedSurfaceCache').AdjustedSurfaceCache} so the adjusted pixels + * are NOT recomputed each frame (see that module). Only consulted for raster + * layers; absent ⇒ adjustments are ignored (a bare test call draws raw pixels). + */ + adjustedSurface?: ((layer: CanvasLayerContract, entry: LayerCacheEntry) => RasterSurface | null) | null; +} + +type Ctx = RasterSurface['ctx']; + +/** Maps a document blend mode to the canvas `globalCompositeOperation`. Also used by `colorSample.ts`. */ +export const blendToComposite = (mode: CanvasBlendMode): GlobalCompositeOperation => + mode === 'normal' ? 'source-over' : (mode as GlobalCompositeOperation); + +const setTransformFromMat = (ctx: Ctx, m: Mat2d): void => { + ctx.setTransform(m.a, m.b, m.c, m.d, m.e, m.f); +}; + +const identityTransform = (ctx: Ctx): void => { + ctx.setTransform(1, 0, 0, 1, 0, 0); +}; + +/** + * Builds a small, reusable two-tone checkerboard tile (a 2×2 grid of `squarePx` + * cells) through the {@link RasterBackend} seam. The engine creates this ONCE and + * feeds it back via {@link CompositeOptions.checkerboardTile}; each frame then + * only `createPattern`s over it, so no per-cell fill loop runs per frame. + */ +export const createCheckerboardTile = ( + backend: RasterBackend, + colors: CheckerColors = DEFAULT_CHECKER_COLORS, + squarePx: number = CHECKERBOARD_SQUARE_PX +): RasterSurface => { + const size = squarePx * 2; + const tile = backend.createSurface(size, size); + const ctx = tile.ctx; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, size, size); + // Base color across the whole tile, then the alternating cells on the diagonal. + ctx.fillStyle = colors.a; + ctx.fillRect(0, 0, size, size); + ctx.fillStyle = colors.b; + ctx.fillRect(0, 0, squarePx, squarePx); + ctx.fillRect(squarePx, squarePx, squarePx, squarePx); + return tile; +}; + +/** + * Fills the ENTIRE viewport with the checkerboard pattern. The canvas is a + * virtually infinite plane (like legacy): the checker IS the world surround, not + * a document backdrop — the document rect is no longer a visual boundary, so the + * contract's `background` field no longer renders. When no tile is provided the + * checkerboard is off and the cleared surface shows the widget's `bg.inset`. + * + * The pattern is laid with the identity transform in place, anchoring its cells + * to the screen (canvas) origin at a fixed pixel size — like legacy, which pins + * the pattern to the stage — so it stays visually stable and never swims or + * scales while panning/zooming. + */ +const drawBackground = (ctx: Ctx, target: RasterSurface, tile: RasterSurface | null): void => { + if (!tile) { + // Checkerboard disabled: leave the cleared surface showing `bg.inset`. + return; + } + const pattern = ctx.createPattern(tile.canvas, 'repeat'); + if (!pattern) { + return; + } + ctx.fillStyle = pattern; + ctx.fillRect(0, 0, target.width, target.height); +}; + +const isMaskLayer = ( + layer: CanvasLayerContract +): layer is Extract => + layer.type === 'regional_guidance' || layer.type === 'inpaint_mask'; + +/** + * Draws a mask-bearing layer as a TINTED, TRANSLUCENT overlay. With a backend + * available (the engine path) it colorizes the mask's alpha stencil with the + * layer's fill colour/pattern (`source-in`) on an intermediate surface, then + * blits that through the current (already transformed, `globalAlpha = + * layer.opacity`) context — legacy's `source-in` compositing-rect technique. + * Without a backend it falls back to blitting the coverage plus a flat + * translucent fill. + */ +const drawMaskLayer = ( + ctx: Ctx, + layer: Extract, + surface: RasterSurface, + origin: Vec2, + opts: CompositeOptions +): void => { + const fill = layer.mask.fill; + if (opts.backend) { + const tile = opts.maskPatternTile ? opts.maskPatternTile(fill.style, fill.color) : null; + const colorized = colorizeMask(opts.backend, surface, surface.width, surface.height, fill, tile); + // The outer loop already set globalAlpha = layer.opacity; blit the colorized + // overlay at the mask's local content origin. + ctx.drawImage(colorized.canvas, origin.x, origin.y); + return; + } + // Backend-less fallback (bare test call): coverage + flat translucent fill. + ctx.drawImage(surface.canvas, origin.x, origin.y); + ctx.globalAlpha = layer.opacity * MASK_TINT_ALPHA; + ctx.fillStyle = fill.color; + ctx.fillRect(origin.x, origin.y, surface.width, surface.height); +}; + +/** + * Draws one cached layer through its transform, applying opacity/blend and any + * transient transform override. Mask-bearing layers are colorized; everything + * else is a straight blit of its content-sized cache surface. + */ +const drawCachedLayer = ( + ctx: Ctx, + layer: CanvasLayerContract, + entry: LayerCacheEntry, + view: Mat2d, + opts: CompositeOptions +): void => { + ctx.save(); + ctx.globalAlpha = layer.opacity; + ctx.globalCompositeOperation = blendToComposite(layer.blendMode); + + const override = opts.transformOverrides?.get(layer.id); + const layerMat = fromTRS( + { x: override?.x ?? layer.transform.x, y: override?.y ?? layer.transform.y }, + override?.rotation ?? layer.transform.rotation, + override?.scaleX ?? layer.transform.scaleX, + override?.scaleY ?? layer.transform.scaleY + ); + setTransformFromMat(ctx, multiply(view, layerMat)); + + // The cache surface holds pixels for `entry.rect` in layer-local space; draw + // it at that local origin (offset paint/mask layers place their content off-zero). + const origin = { x: entry.rect.x, y: entry.rect.y }; + const preview = opts.layerPreviews?.get(layer.id) ?? null; + if (preview) { + // Non-destructive filter preview: stretch the filtered image over the layer's + // content footprint (through the already-applied layer transform). + ctx.drawImage(preview.canvas, origin.x, origin.y, entry.rect.width, entry.rect.height); + } else if (isMaskLayer(layer)) { + drawMaskLayer(ctx, layer, entry.surface, origin, opts); + } else if (layer.type === 'control' && layer.withTransparencyEffect && opts.backend) { + // Display-only lightness→alpha effect (legacy `LightnessToAlphaFilter`): dark + // areas of the control map drop out so underlying content shows through. + const effect = renderControlTransparency(opts.backend, entry.surface, entry.surface.width, entry.surface.height); + ctx.drawImage(effect.canvas, origin.x, origin.y); + } else { + // Raster layers may carry non-destructive adjustments; the engine supplies a + // memoized adjusted surface (never recomputed per frame). Fall back to the raw + // cache when there are no adjustments or no provider. + const adjusted = layer.type === 'raster' && opts.adjustedSurface ? opts.adjustedSurface(layer, entry) : null; + ctx.drawImage((adjusted ?? entry.surface).canvas, origin.x, origin.y); + } + + ctx.restore(); +}; + +/** + * Composites `doc` onto `target`, using `caches` for each layer's pixels and + * `view` as the document→screen transform. + */ +export const compositeDocument = ( + target: RasterSurface, + doc: CanvasDocumentContractV2, + caches: LayerCacheStore, + view: Mat2d, + opts: CompositeOptions = {} +): void => { + const ctx = target.ctx; + + ctx.save(); + + // Smoothing policy for all layer/staged `drawImage` blits below. Set once + // under the outer save (inner per-layer save/restore preserves it). Off when + // zoomed in keeps magnified pixels crisp and skips the costly bilinear upscale. + ctx.imageSmoothingEnabled = opts.imageSmoothing ?? true; + + // Clear the whole target in screen space, then lay the checkerboard across the + // entire viewport — the canvas is an unbounded plane, so the checker is the + // world, not a document backdrop. With the checkerboard off the cleared surface + // shows the widget's themed `bg.inset` through it. + identityTransform(ctx); + ctx.clearRect(0, 0, target.width, target.height); + drawBackground(ctx, target, opts.checkerboardTile ?? null); + + // Composite in STRICT GROUP ORDER (legacy `arrangeEntities` / `CanvasEntityRendererModule`): + // raster (bottom) < control < regional guidance < inpaint mask (top). This + // matches the layers panel, which renders these as fixed grouped sections, and + // makes a layer's global insertion index irrelevant ACROSS groups (a raster + // created after a control layer must still draw below it). WITHIN a group the + // panel/array relative order is preserved. The `stagedPreview` lands on top of + // all groups below. Index 0 is top-most, so iterate the array in reverse. + const drawGroup = (rank: number): void => { + for (let i = doc.layers.length - 1; i >= 0; i--) { + const layer = doc.layers[i]; + if (!layer || !layer.isEnabled || layer.id === opts.skipLayerId || layerGroupRank(layer) !== rank) { + continue; + } + const entry = caches.get(layer.id); + // Skip layers with no cache or an empty content rect (a brand-new / cleared + // paint / mask layer holds no pixels — nothing to draw). + if (!entry || entry.rect.width <= 0 || entry.rect.height <= 0) { + continue; + } + drawCachedLayer(ctx, layer, entry, view, opts); + } + }; + for (let rank = 0; rank < LAYER_GROUP_COUNT; rank++) { + drawGroup(rank); + } + + // Staged generation preview over its bbox (document space), with a subtle + // dashed outline so the pending result reads distinctly from committed pixels. + const staged = opts.stagedPreview; + if (staged) { + ctx.save(); + setTransformFromMat(ctx, view); + ctx.drawImage(staged.surface.canvas, staged.rect.x, staged.rect.y, staged.rect.width, staged.rect.height); + // Keep the outline visually constant regardless of zoom by dividing the + // document-space stroke by the view scale (√det of the linear part). + const viewScale = Math.sqrt(Math.abs(view.a * view.d - view.b * view.c)) || 1; + ctx.globalAlpha = 1; + ctx.strokeStyle = STAGED_PREVIEW_OUTLINE_COLOR; + ctx.lineWidth = STAGED_PREVIEW_OUTLINE_WIDTH / viewScale; + ctx.setLineDash([STAGED_PREVIEW_OUTLINE_DASH / viewScale, STAGED_PREVIEW_OUTLINE_DASH / viewScale]); + ctx.strokeRect(staged.rect.x, staged.rect.y, staged.rect.width, staged.rect.height); + ctx.setLineDash([]); + ctx.restore(); + } + + ctx.restore(); +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/controlTransparency.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/controlTransparency.test.ts new file mode 100644 index 00000000000..2e2feb91e2c --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/controlTransparency.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from 'vitest'; + +import type { RasterBackend, RasterSurface } from './raster'; + +import { applyLightnessToAlpha, renderControlTransparency } from './controlTransparency'; + +/** Builds a flat RGBA `Uint8ClampedArray` from a list of [r, g, b, a] pixels. */ +const pixels = (...rgba: [number, number, number, number][]): Uint8ClampedArray => new Uint8ClampedArray(rgba.flat()); + +describe('applyLightnessToAlpha', () => { + it('sets alpha to the HSL lightness of each pixel, clamped to the existing alpha', () => { + const data = pixels( + [255, 255, 255, 255], // white: lightness 255 -> alpha stays 255 + [0, 0, 0, 255], // black: lightness 0 -> alpha becomes 0 + [128, 128, 128, 255], // mid-gray: lightness 128 -> alpha 128 + [100, 50, 200, 255], // colored: min 50, max 200 -> lightness 125 -> alpha 125 + [255, 255, 255, 100] // opaque-white but a=100: lightness 255, min(100, 255) keeps 100 + ); + + applyLightnessToAlpha(data); + + // Assert the resulting alpha channel for each pixel. + expect(data[3]).toBe(255); + expect(data[7]).toBe(0); + expect(data[11]).toBe(128); + expect(data[15]).toBe(125); + expect(data[19]).toBe(100); + }); + + it('leaves the RGB channels untouched', () => { + const data = pixels( + [255, 255, 255, 255], + [0, 0, 0, 255], + [128, 128, 128, 255], + [100, 50, 200, 255], + [255, 255, 255, 100] + ); + + applyLightnessToAlpha(data); + + // Only the alpha byte of every quad may change; the RGB bytes are preserved. + expect([data[0], data[1], data[2]]).toEqual([255, 255, 255]); + expect([data[4], data[5], data[6]]).toEqual([0, 0, 0]); + expect([data[8], data[9], data[10]]).toEqual([128, 128, 128]); + expect([data[12], data[13], data[14]]).toEqual([100, 50, 200]); + expect([data[16], data[17], data[18]]).toEqual([255, 255, 255]); + }); + + it('mutates the buffer in place and returns void', () => { + const data = pixels([0, 0, 0, 255]); + const same = data; + + const result = applyLightnessToAlpha(data); + + // Same reference, mutated in place; the function returns nothing. + expect(result).toBeUndefined(); + expect(data).toBe(same); + expect(same[3]).toBe(0); + }); + + it('handles an empty buffer without throwing', () => { + const data = new Uint8ClampedArray(0); + expect(() => applyLightnessToAlpha(data)).not.toThrow(); + expect(data).toHaveLength(0); + }); +}); + +/** A minimal `RasterSurface` recording drawImage/getImageData/putImageData against a fed bitmap. */ +interface FakeSurface extends RasterSurface { + readonly drawImageArgs: unknown[][]; + readonly getImageDataArgs: unknown[][]; + readonly putImageDataCalls: ImageData[]; +} + +/** + * A minimal but faithful `RasterBackend` for `renderControlTransparency`: every + * surface's `getImageData` returns a fresh copy of `sourcePixels`, and its + * `putImageData` is recorded so the transform's output can be inspected. + */ +const createFakeBackend = (sourcePixels: Uint8ClampedArray): RasterBackend & { readonly created: FakeSurface[] } => { + const created: FakeSurface[] = []; + + const createSurface = (width: number, height: number): FakeSurface => { + const drawImageArgs: unknown[][] = []; + const getImageDataArgs: unknown[][] = []; + const putImageDataCalls: ImageData[] = []; + + const ctx = { + clearRect: () => {}, + drawImage: (...args: unknown[]) => { + drawImageArgs.push(args); + }, + getImageData: (...args: unknown[]): ImageData => { + getImageDataArgs.push(args); + // Return a fresh copy so callers mutate their own buffer, not the source. + const data = new Uint8ClampedArray(sourcePixels); + return { colorSpace: 'srgb', data, height, width } as unknown as ImageData; + }, + globalAlpha: 1, + globalCompositeOperation: 'source-over' as GlobalCompositeOperation, + putImageData: (imageData: ImageData) => { + putImageDataCalls.push(imageData); + }, + setTransform: () => {}, + } as unknown as OffscreenCanvasRenderingContext2D; + + const surface: FakeSurface = { + canvas: { height, width } as unknown as OffscreenCanvas, + ctx, + drawImageArgs, + getImageDataArgs, + height, + putImageDataCalls, + resize: () => {}, + width, + }; + created.push(surface); + return surface; + }; + + return { + createImageBitmap: () => Promise.resolve({ close: () => {}, height: 0, width: 0 } as unknown as ImageBitmap), + createSurface, + created, + encodeSurface: (surface: RasterSurface, type = 'image/png') => + Promise.resolve(new Blob([`fake-${surface.width}x${surface.height}`], { type })), + }; +}; + +describe('renderControlTransparency', () => { + it('allocates the output surface at the requested dimensions', () => { + const backend = createFakeBackend(pixels([0, 0, 0, 255])); + const cache = backend.createSurface(64, 48); + + const out = renderControlTransparency(backend, cache, 64, 48); + + expect(out.width).toBe(64); + expect(out.height).toBe(48); + // The returned surface is a NEW allocation, not the cache itself. + expect(out).not.toBe(cache); + }); + + it('draws the cache canvas into the output surface', () => { + const backend = createFakeBackend(pixels([0, 0, 0, 255])); + const cache = backend.createSurface(10, 10); + + const out = renderControlTransparency(backend, cache, 10, 10) as FakeSurface; + + // The cache's canvas is blitted into the output at the origin. + expect(out.drawImageArgs).toHaveLength(1); + expect(out.drawImageArgs[0]![0]).toBe(cache.canvas); + expect(out.drawImageArgs[0]!.slice(1)).toEqual([0, 0]); + }); + + it('applies the lightness->alpha transform: opaque black pixels become fully transparent', () => { + // Feed a bitmap of opaque black pixels; after the transform each alpha is 0. + const backend = createFakeBackend(pixels([0, 0, 0, 255], [0, 0, 0, 255])); + const cache = backend.createSurface(2, 1); + + const out = renderControlTransparency(backend, cache, 2, 1) as FakeSurface; + + // getImageData was read over the full surface rect, and the result put back. + expect(out.getImageDataArgs).toHaveLength(1); + expect(out.getImageDataArgs[0]).toEqual([0, 0, 2, 1]); + expect(out.putImageDataCalls).toHaveLength(1); + + const result = out.putImageDataCalls[0]!.data; + // Black -> lightness 0 -> alpha 0; RGB channels are preserved. + expect(result[3]).toBe(0); + expect(result[7]).toBe(0); + expect([result[0], result[1], result[2]]).toEqual([0, 0, 0]); + }); + + it('applies the lightness->alpha transform to a mixed bitmap', () => { + const backend = createFakeBackend( + pixels( + [255, 255, 255, 255], // white -> alpha 255 + [0, 0, 0, 255], // black -> alpha 0 + [100, 50, 200, 255] // colored -> lightness 125 -> alpha 125 + ) + ); + const cache = backend.createSurface(3, 1); + + const out = renderControlTransparency(backend, cache, 3, 1) as FakeSurface; + + const result = out.putImageDataCalls[0]!.data; + expect(result[3]).toBe(255); + expect(result[7]).toBe(0); + expect(result[11]).toBe(125); + // RGB untouched by the transform. + expect([result[8], result[9], result[10]]).toEqual([100, 50, 200]); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/controlTransparency.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/controlTransparency.ts new file mode 100644 index 00000000000..2b9ded6e641 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/controlTransparency.ts @@ -0,0 +1,60 @@ +/** + * The control-layer transparency effect (legacy `LightnessToAlphaFilter`). + * + * A control layer with `withTransparencyEffect` renders its source so DARK areas + * become transparent — for an edge/depth/pose map, the black background drops out + * and the underlying content shows through. This mirrors legacy + * `features/controlLayers/konva/filters.ts`: per pixel, HSL lightness + * `(min(r,g,b) + max(r,g,b)) / 2` becomes the new alpha, clamped to never EXCEED + * the pixel's existing alpha. + * + * It is DISPLAY-ONLY: at generation time the control image is composited at full + * opacity with NO transparency effect (see `generation/canvas/compositePlan.ts` + * `toControlLayerRef`), exactly like legacy (which rasterizes control with + * `filters: []`, `bg: 'black'`). + * + * Zero React, zero import-time side effects. + */ + +import type { RasterBackend, RasterSurface } from './raster'; + +/** + * Applies the lightness→alpha transform to an RGBA pixel buffer in place: each + * pixel's alpha becomes `min(alpha, lightness)` where `lightness` is the HSL + * lightness of its RGB. Pure — the unit-testable core of the effect. + */ +export const applyLightnessToAlpha = (data: Uint8ClampedArray): void => { + for (let i = 0; i + 3 < data.length; i += 4) { + const r = data[i] ?? 0; + const g = data[i + 1] ?? 0; + const b = data[i + 2] ?? 0; + const a = data[i + 3] ?? 0; + const lightness = (Math.min(r, g, b) + Math.max(r, g, b)) / 2; + data[i + 3] = Math.min(a, lightness); + } +}; + +/** + * Produces a transparency-effect copy of a control layer's cache surface (same + * dimensions): the cache is drawn, its pixels transformed by + * {@link applyLightnessToAlpha}, and the result returned. The caller blits it + * through the layer transform in place of the raw cache. + */ +export const renderControlTransparency = ( + backend: RasterBackend, + cache: RasterSurface, + width: number, + height: number +): RasterSurface => { + const out = backend.createSurface(width, height); + const ctx = out.ctx; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, width, height); + ctx.globalAlpha = 1; + ctx.globalCompositeOperation = 'source-over'; + ctx.drawImage(cache.canvas, 0, 0); + const imageData = ctx.getImageData(0, 0, width, height); + applyLightnessToAlpha(imageData.data); + ctx.putImageData(imageData, 0, 0); + return out; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/fontLoader.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/fontLoader.test.ts new file mode 100644 index 00000000000..d5cae5e72bf --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/fontLoader.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { FontLoadApi } from './fontLoader'; + +import { createFontLoader } from './fontLoader'; + +/** A fake fonts api with a controllable `check` and a deferred `load`. */ +const createFakeFonts = (initiallyLoaded = false) => { + let loaded = initiallyLoaded; + let resolveLoad: (() => void) | null = null; + const load = vi.fn( + (_font: string): Promise => + new Promise((resolve) => { + resolveLoad = () => { + loaded = true; + resolve(); + }; + }) + ); + const api: FontLoadApi = { + check: () => loaded, + load, + }; + return { api, load, settle: () => resolveLoad?.() }; +}; + +const flush = (): Promise => + new Promise((resolve) => { + setTimeout(resolve, 0); + }); + +describe('createFontLoader', () => { + it('is a silent no-op with no api (node-safe)', () => { + const loader = createFontLoader(null); + const onReady = vi.fn(); + loader.ensure('400 20px Inter', onReady); + expect(onReady).not.toHaveBeenCalled(); + }); + + it('does not load or call onReady when the font is already available', () => { + const { api, load } = createFakeFonts(true); + const loader = createFontLoader(api); + const onReady = vi.fn(); + loader.ensure('400 20px Inter', onReady); + expect(load).not.toHaveBeenCalled(); + expect(onReady).not.toHaveBeenCalled(); + }); + + it('kicks a load for an unavailable font and calls onReady when it resolves', async () => { + const { api, load, settle } = createFakeFonts(false); + const loader = createFontLoader(api); + const onReady = vi.fn(); + loader.ensure('400 20px Inter', onReady); + expect(load).toHaveBeenCalledTimes(1); + expect(onReady).not.toHaveBeenCalled(); + settle(); + await flush(); + expect(onReady).toHaveBeenCalledTimes(1); + }); + + it('dedupes concurrent loads of the same font (one load, both onReady fire)', async () => { + const { api, load, settle } = createFakeFonts(false); + const loader = createFontLoader(api); + const onReadyA = vi.fn(); + const onReadyB = vi.fn(); + loader.ensure('400 20px Inter', onReadyA); + loader.ensure('400 20px Inter', onReadyB); + expect(load).toHaveBeenCalledTimes(1); + settle(); + await flush(); + expect(onReadyA).toHaveBeenCalledTimes(1); + expect(onReadyB).toHaveBeenCalledTimes(1); + }); + + it('swallows a check() that throws (treats the font as unavailable)', () => { + const api: FontLoadApi = { + check: () => { + throw new Error('bad font string'); + }, + load: vi.fn(() => Promise.resolve()), + }; + const loader = createFontLoader(api); + expect(() => loader.ensure('garbage', vi.fn())).not.toThrow(); + expect(api.load).toHaveBeenCalledTimes(1); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/fontLoader.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/fontLoader.ts new file mode 100644 index 00000000000..5d5759aaf97 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/fontLoader.ts @@ -0,0 +1,85 @@ +/** + * Web-font readiness for text layers. + * + * A text layer's rasterized metrics depend on the font actually being available: + * if a web font isn't loaded yet, the platform substitutes a fallback and + * `measureText` reports the WRONG advances, so the cached text pixels (and the + * layer's extent) would be wrong until something else happened to re-rasterize. + * + * This loader is the seam the engine uses to fix that without importing the DOM + * into the rasterizer: given a `FontLoadApi` (the browser's `document.fonts`, or + * a fake in tests), it kicks a load for a not-yet-available font exactly once + * (concurrent requests for the same font string share one in-flight promise) and + * invokes `onReady` when it resolves — the engine's callback re-rasterizes the + * affected layer. A `null` api (node, or a browser without `document.fonts`) is + * a silent no-op, so importing this module and the engine stays node-safe. + * + * The loader holds NO permanent "loaded" set — it trusts `api.check()` (the + * browser flips it to `true` post-load) as the source of truth and only tracks + * in-flight promises (cleared on settle). Combined with the per-engine instance, + * that keeps it free of cross-test global state. + * + * Zero React, zero import-time side effects. + */ + +/** The minimal slice of `FontFaceSet` the loader needs (so tests can inject a fake). */ +export interface FontLoadApi { + /** Whether every face for the CSS `font` shorthand is loaded and usable. */ + check(font: string): boolean; + /** Loads the faces for the CSS `font` shorthand; resolves when they're ready. */ + load(font: string): Promise; +} + +/** A per-engine font loader bound to one `FontLoadApi` (or `null` in node). */ +export interface FontLoader { + /** + * Ensures `font` is available, invoking `onReady` once when a pending load + * resolves. A no-op (never calls `onReady`) when there is no api or the font + * is already available — the caller's synchronous rasterize already used the + * correct metrics in that case. + */ + ensure(font: string, onReady: () => void): void; +} + +/** `check` can throw on an unparseable font string; treat a throw as "not available". */ +const safeCheck = (api: FontLoadApi, font: string): boolean => { + try { + return api.check(font); + } catch { + return false; + } +}; + +/** Creates a font loader bound to `api` (pass `null` for a node-safe no-op loader). */ +export const createFontLoader = (api: FontLoadApi | null): FontLoader => { + const pending = new Map>(); + + return { + ensure: (font, onReady) => { + if (!api || safeCheck(api, font)) { + return; + } + const existing = pending.get(font); + if (existing) { + existing.then(onReady, () => {}); + return; + } + const promise = Promise.resolve(api.load(font)).then( + () => {}, + () => {} + ); + const tracked = promise.finally(() => pending.delete(font)); + pending.set(font, tracked); + tracked.then(onReady, () => {}); + }, + }; +}; + +/** Resolves the browser's `document.fonts` api, or `null` where it is unavailable (node, older browsers). */ +export const domFontLoadApi = (): FontLoadApi | null => { + if (typeof document === 'undefined') { + return null; + } + const fonts = (document as Document & { fonts?: FontLoadApi }).fonts; + return fonts && typeof fonts.check === 'function' && typeof fonts.load === 'function' ? fonts : null; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/layerCache.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/layerCache.test.ts new file mode 100644 index 00000000000..04a2d07c7b7 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/layerCache.test.ts @@ -0,0 +1,254 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { StubRasterSurface } from './raster.testStub'; + +import { createLayerCacheStore, DEFAULT_CACHE_BUDGET_BYTES } from './layerCache'; +import { createTestStubRasterBackend } from './raster.testStub'; + +const makeBitmap = () => { + const close = vi.fn(); + return { bitmap: { close, height: 1, width: 1 } as unknown as ImageBitmap, close }; +}; + +describe('createLayerCacheStore', () => { + it('returns a stable entry identity for the same layer + size', () => { + const store = createLayerCacheStore(createTestStubRasterBackend()); + const a = store.getOrCreate('layer-1', 100, 50); + const b = store.getOrCreate('layer-1', 100, 50); + expect(b).toBe(a); + expect(store.get('layer-1')).toBe(a); + }); + + it('resizes the surface (and marks stale) when the requested size changes', () => { + const store = createLayerCacheStore(createTestStubRasterBackend()); + const entry = store.getOrCreate('layer-1', 100, 50); + entry.stale = false; + const resized = store.getOrCreate('layer-1', 200, 80); + expect(resized).toBe(entry); + expect(resized.surface.width).toBe(200); + expect(resized.surface.height).toBe(80); + expect(resized.stale).toBe(true); + }); + + it('invalidate bumps the version and marks the cache stale', () => { + const store = createLayerCacheStore(createTestStubRasterBackend()); + const entry = store.getOrCreate('layer-1', 10, 10); + entry.stale = false; + expect(store.version('layer-1')).toBe(0); + + store.invalidate('layer-1'); + expect(store.version('layer-1')).toBe(1); + expect(entry.version).toBe(1); + expect(entry.stale).toBe(true); + + store.invalidate('layer-1'); + expect(store.version('layer-1')).toBe(2); + }); + + it('version is 0 for an unknown layer', () => { + const store = createLayerCacheStore(createTestStubRasterBackend()); + expect(store.version('nope')).toBe(0); + }); + + it('a recreated entry (after delete) resumes ABOVE the old version, never resetting to 0', () => { + const store = createLayerCacheStore(createTestStubRasterBackend()); + const first = store.getOrCreate('a', 10, 10); + store.invalidate('a'); + store.invalidate('a'); + expect(first.version).toBe(2); + + // Delete + recreate (undo→redo / transform bake / merge / evict→re-show): the + // fresh entry must start above 2 so version-keyed caches (adjusted surface, + // thumbnails) can't mistake its new pixels for a version they already hold. + store.delete('a'); + const recreated = store.getOrCreate('a', 10, 10); + expect(recreated.version).toBeGreaterThan(2); + }); + + it('an evicted-then-re-shown entry also resumes above its pre-eviction version', () => { + const store = createLayerCacheStore(createTestStubRasterBackend()); + store.getOrCreate('hidden', 100, 100); + store.invalidate('hidden'); // version 1 + const budget = 100 * 100 * 4; // room for one surface + store.getOrCreate('visible', 100, 100); + const evicted = store.evictHidden(['visible'], budget); + expect(evicted).toContain('hidden'); + + const reshown = store.getOrCreate('hidden', 100, 100); + expect(reshown.version).toBeGreaterThan(1); + }); + + it('byteSize sums w*h*4 across all cache surfaces', () => { + const store = createLayerCacheStore(createTestStubRasterBackend()); + store.getOrCreate('a', 100, 100); // 40_000 + store.getOrCreate('b', 10, 10); // 400 + expect(store.byteSize()).toBe(100 * 100 * 4 + 10 * 10 * 4); + }); + + it('delete drops a cache entry', () => { + const store = createLayerCacheStore(createTestStubRasterBackend()); + store.getOrCreate('a', 10, 10); + store.delete('a'); + expect(store.get('a')).toBeUndefined(); + expect(store.byteSize()).toBe(0); + }); + + it('evictHidden does nothing when already within budget', () => { + const store = createLayerCacheStore(createTestStubRasterBackend()); + store.getOrCreate('a', 10, 10); + const evicted = store.evictHidden([], DEFAULT_CACHE_BUDGET_BYTES); + expect(evicted).toEqual([]); + expect(store.get('a')).toBeDefined(); + }); + + it('evictHidden evicts least-recently-used hidden caches until within budget, never visible ones', () => { + const store = createLayerCacheStore(createTestStubRasterBackend()); + // Each surface is 100*100*4 = 40_000 bytes. + store.getOrCreate('old-hidden', 100, 100); + store.getOrCreate('visible', 100, 100); + store.getOrCreate('new-hidden', 100, 100); + // Touch 'new-hidden' so 'old-hidden' is the LRU among hidden entries. + store.get('new-hidden'); + + // Budget allows exactly two surfaces; three exist -> one hidden must go. + const budget = 100 * 100 * 4 * 2; + const evicted = store.evictHidden(['visible'], budget); + + expect(evicted).toEqual(['old-hidden']); + expect(store.get('old-hidden')).toBeUndefined(); + expect(store.get('visible')).toBeDefined(); + expect(store.get('new-hidden')).toBeDefined(); + expect(store.byteSize()).toBeLessThanOrEqual(budget); + }); + + it('evictHidden will not evict visible caches even if still over budget', () => { + const store = createLayerCacheStore(createTestStubRasterBackend()); + store.getOrCreate('visible', 100, 100); + const evicted = store.evictHidden(['visible'], 1); + expect(evicted).toEqual([]); + expect(store.get('visible')).toBeDefined(); + }); + + it('caches bitmaps by name and closes the previous bitmap when replaced', () => { + const store = createLayerCacheStore(createTestStubRasterBackend()); + const first = makeBitmap(); + const second = makeBitmap(); + + store.setBitmap('img', first.bitmap); + expect(store.getBitmap('img')).toBe(first.bitmap); + + store.setBitmap('img', second.bitmap); + expect(first.close).toHaveBeenCalledTimes(1); + expect(store.getBitmap('img')).toBe(second.bitmap); + + store.deleteBitmap('img'); + expect(second.close).toHaveBeenCalledTimes(1); + expect(store.getBitmap('img')).toBeUndefined(); + }); + + it('dispose closes every cached bitmap and clears caches', () => { + const store = createLayerCacheStore(createTestStubRasterBackend()); + const bm = makeBitmap(); + store.setBitmap('img', bm.bitmap); + store.getOrCreate('a', 10, 10); + + store.dispose(); + expect(bm.close).toHaveBeenCalledTimes(1); + expect(store.getBitmap('img')).toBeUndefined(); + expect(store.byteSize()).toBe(0); + }); +}); + +describe('getOrCreateRect (content-sized, off-origin placement)', () => { + it('creates a new surface placed at an off-origin (negative) layer-local rect', () => { + const store = createLayerCacheStore(createTestStubRasterBackend()); + const entry = store.getOrCreateRect('L', { height: 40, width: 60, x: -15, y: -25 }); + expect(entry.rect).toEqual({ height: 40, width: 60, x: -15, y: -25 }); + expect(entry.surface.width).toBe(60); + expect(entry.surface.height).toBe(40); + expect(entry.stale).toBe(true); + }); + + it('NEVER resizes or re-places an existing entry (a grown paint cache must survive)', () => { + const store = createLayerCacheStore(createTestStubRasterBackend()); + const entry = store.getOrCreateRect('L', { height: 40, width: 60, x: 5, y: 5 }); + entry.stale = false; + // A later request with the (smaller/different) contract rect returns the + // live entry untouched: sizing belongs to the rasterizer/grow paths. + const again = store.getOrCreateRect('L', { height: 10, width: 10, x: 0, y: 0 }); + expect(again).toBe(entry); + expect(again.rect).toEqual({ height: 40, width: 60, x: 5, y: 5 }); + expect(again.surface.width).toBe(60); + expect(again.stale).toBe(false); + }); + + it('supports a zero-rect entry (brand-new empty paint layer)', () => { + const store = createLayerCacheStore(createTestStubRasterBackend()); + const entry = store.getOrCreateRect('L', { height: 0, width: 0, x: 0, y: 0 }); + expect(entry.rect).toEqual({ height: 0, width: 0, x: 0, y: 0 }); + expect(entry.surface.width).toBe(0); + expect(entry.surface.height).toBe(0); + }); +}); + +describe('growToRect (paint caches grow with strokes)', () => { + it('creates a fresh non-stale entry at the rounded target rect when none exists', () => { + const store = createLayerCacheStore(createTestStubRasterBackend()); + const entry = store.growToRect('L', { height: 19.2, width: 10.6, x: 3.4, y: -2.7 }); + expect(entry.rect).toEqual({ height: 19, width: 11, x: 3, y: -3 }); + expect(entry.stale).toBe(false); + }); + + it('grows to the UNION of the current extent and the request, preserving old pixels at their shifted origin', () => { + const store = createLayerCacheStore(createTestStubRasterBackend()); + const entry = store.growToRect('L', { height: 20, width: 20, x: 10, y: 10 }); + const surfaceBefore = entry.surface; + + const grown = store.growToRect('L', { height: 20, width: 20, x: -10, y: -10 }); + expect(grown).toBe(entry); + // Union of [10,30)² and [-10,10)² = [-10,30)² → 40×40 at (-10,-10). + expect(grown.rect).toEqual({ height: 40, width: 40, x: -10, y: -10 }); + // Resized in place: the surface identity is preserved (open sessions hold it). + expect(grown.surface).toBe(surfaceBefore); + expect(grown.surface.width).toBe(40); + expect(grown.surface.height).toBe(40); + + // The old pixels were snapshotted before the (clearing) resize and blitted + // back at their layer-local position within the grown surface: old origin + // (10,10) minus new origin (-10,-10) = surface (20,20). + const log = (grown.surface as StubRasterSurface).callLog; + const snapshot = log.find((e) => e.op === 'getImageData'); + expect(snapshot?.args).toEqual([0, 0, 20, 20]); + const resizeIndex = log.findIndex((e) => e.op === 'resize'); + const putIndex = log.findIndex((e) => e.op === 'putImageData'); + expect(resizeIndex).toBeGreaterThan(-1); + expect(putIndex).toBeGreaterThan(resizeIndex); + expect(log[putIndex]?.args.slice(1)).toEqual([20, 20]); + }); + + it('is a no-op (no resize, no blit) when the current extent already covers the request', () => { + const store = createLayerCacheStore(createTestStubRasterBackend()); + const entry = store.growToRect('L', { height: 100, width: 100, x: 0, y: 0 }); + const logLength = (entry.surface as StubRasterSurface).callLog.length; + + const again = store.growToRect('L', { height: 10, width: 10, x: 20, y: 20 }); + expect(again).toBe(entry); + expect(again.rect).toEqual({ height: 100, width: 100, x: 0, y: 0 }); + expect((again.surface as StubRasterSurface).callLog.length).toBe(logLength); + }); + + it('adopts the target rect from an EMPTY extent without snapshotting stale pixels', () => { + const store = createLayerCacheStore(createTestStubRasterBackend()); + const empty = store.getOrCreateRect('L', { height: 0, width: 0, x: 0, y: 0 }); + empty.stale = false; + + const grown = store.growToRect('L', { height: 30, width: 30, x: 50, y: 60 }); + expect(grown).toBe(empty); + // No union with the empty rect's (0,0) origin: the first stroke's bounds + // become the extent verbatim (no needless 0,0-anchored surface). + expect(grown.rect).toEqual({ height: 30, width: 30, x: 50, y: 60 }); + const log = (grown.surface as StubRasterSurface).callLog; + expect(log.some((e) => e.op === 'getImageData')).toBe(false); + expect(log.some((e) => e.op === 'putImageData')).toBe(false); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/layerCache.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/layerCache.ts new file mode 100644 index 00000000000..278f959ab69 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/layerCache.ts @@ -0,0 +1,338 @@ +/** + * Per-layer raster cache store. + * + * Each layer's rendered pixels are cached on their own {@link RasterSurface} + * so the compositor can redraw the document without re-rasterizing every + * layer each frame. Caches are always re-rasterizable, so eviction is safe: + * a hidden layer's cache can be dropped to stay under a memory budget and + * rebuilt on demand when it becomes visible again. + * + * The store also owns a decoded-`ImageBitmap` cache keyed by image name (used + * by the image/paint rasterizers) and closes those bitmaps when they are + * replaced or the store is disposed. + * + * All surface allocation flows through the injected {@link RasterBackend} + * seam, so the store runs unchanged in node tests. Zero React, zero + * import-time side effects. + */ + +import type { Rect } from '@workbench/canvas-engine/types'; + +import type { RasterBackend, RasterSurface } from './raster'; + +/** Default cache budget: ~512 MB of surface pixels before hidden caches are evicted. */ +export const DEFAULT_CACHE_BUDGET_BYTES = 512 * 1024 * 1024; + +const BYTES_PER_PIXEL = 4; + +/** A single layer's cache entry. */ +export interface LayerCacheEntry { + readonly layerId: string; + /** The backing surface holding the layer's rasterized pixels. */ + surface: RasterSurface; + /** + * The surface's content bounds in the layer's LOCAL coordinate space. The + * surface holds `rect.width`×`rect.height` pixels; surface pixel `(sx, sy)` + * maps to layer-local `(rect.x + sx, rect.y + sy)`. The origin can be negative + * (a paint layer grown up/left of its start). The compositor draws the surface + * at `transform × rect.origin`. An empty rect (0 width/height) marks an empty + * layer (a brand-new or cleared paint layer) — safe to skip everywhere. + */ + rect: Rect; + /** Bumped every time the cache is invalidated; thumbnails/subscribers watch this. */ + version: number; + /** True when the cached pixels are known to be out of date and need re-rasterizing. */ + stale: boolean; + /** Monotonic access tick, used to order LRU eviction. */ + lastUsed: number; +} + +/** The imperative store returned by {@link createLayerCacheStore}. */ +export interface LayerCacheStore { + /** Returns the existing cache entry for a layer, or `undefined`. Touches LRU order. */ + get(layerId: string): LayerCacheEntry | undefined; + /** + * Returns the cache entry for a layer, creating (or resizing) its surface to + * `width`x`height` at the LOCAL origin `(0, 0)`. The returned entry identity is + * stable across calls that don't change the size, so callers can hold onto it + * between frames. Use this for origin-anchored layers (image / shape / text / + * gradient); paint caches, which can grow off-origin, use {@link getOrCreateRect} + * / {@link growToRect}. + */ + getOrCreate(layerId: string, width: number, height: number): LayerCacheEntry; + /** + * Like {@link getOrCreate} but places the surface at an arbitrary layer-local + * `rect` origin (which may be negative). Creates the entry when absent; when it + * already exists the surface is left untouched (the caller — a rasterizer or a + * growth op — owns resizing), only ensuring existence. Used for paint layers + * whose content rect is not origin-anchored. + */ + getOrCreateRect(layerId: string, rect: Rect): LayerCacheEntry; + /** + * Grows (never shrinks) a layer's cache to cover `rect` in layer-local space, + * preserving the existing pixels at their new offset. A no-op when `rect` is + * already within the current extent. Creates the entry (surface sized to `rect`) + * when absent. Returns the entry. + */ + growToRect(layerId: string, rect: Rect): LayerCacheEntry; + /** Marks a layer's cache stale and bumps its `version`. */ + invalidate(layerId: string): void; + /** Drops a layer's cache entry entirely. */ + delete(layerId: string): void; + /** The current `version` for a layer (0 if it has no cache yet). */ + version(layerId: string): number; + /** Total bytes held across all cache surfaces (w*h*4 each). */ + byteSize(): number; + /** + * Evicts hidden-layer caches (those whose id is not in `visibleIds`) in + * least-recently-used order until the total {@link byteSize} is within + * `budgetBytes`. Visible layers are never evicted. Returns the evicted ids. + */ + evictHidden(visibleIds: Iterable, budgetBytes?: number): string[]; + /** Looks up a decoded bitmap by image name. */ + getBitmap(imageName: string): ImageBitmap | undefined; + /** Stores a decoded bitmap, closing any previous bitmap held under the same name. */ + setBitmap(imageName: string, bitmap: ImageBitmap): void; + /** Drops (and closes) a decoded bitmap by image name. */ + deleteBitmap(imageName: string): void; + /** Releases every cached bitmap. Cache surfaces are GC'd with the store. */ + dispose(): void; +} + +const surfaceBytes = (surface: RasterSurface): number => surface.width * surface.height * BYTES_PER_PIXEL; + +/** Creates a per-layer raster cache store backed by the given {@link RasterBackend}. */ +export const createLayerCacheStore = (backend: RasterBackend): LayerCacheStore => { + const entries = new Map(); + const bitmaps = new Map(); + // Per-id version FLOOR: the highest version each layer id has ever reached, + // retained across delete/recreate (delete, LRU eviction). A recreated entry + // (undo→redo, transform bake, merge, evict→re-show) starts ABOVE this floor + // rather than resetting to 0 — so version-keyed dependents (the adjusted-surface + // cache, thumbnail state) never mistake fresh pixels for a stale version they + // already have cached. Only ever grows; a plain number per id (negligible). + const versionFloors = new Map(); + let tick = 0; + + const touch = (entry: LayerCacheEntry): void => { + tick += 1; + entry.lastUsed = tick; + }; + + /** The version a freshly-created entry for `layerId` must start at (monotonic). */ + const initialVersion = (layerId: string): number => { + const floor = versionFloors.get(layerId); + return floor === undefined ? 0 : floor + 1; + }; + + /** Records a to-be-dropped entry's version as the id's floor, so a recreate exceeds it. */ + const rememberFloor = (entry: LayerCacheEntry): void => { + const prev = versionFloors.get(entry.layerId) ?? -1; + if (entry.version > prev) { + versionFloors.set(entry.layerId, entry.version); + } + }; + + const get = (layerId: string): LayerCacheEntry | undefined => { + const entry = entries.get(layerId); + if (entry) { + touch(entry); + } + return entry; + }; + + const getOrCreate = (layerId: string, width: number, height: number): LayerCacheEntry => { + const existing = entries.get(layerId); + if (existing) { + if (existing.surface.width !== width || existing.surface.height !== height) { + existing.surface.resize(width, height); + existing.stale = true; + } + // Origin-anchored: this variant always places the surface at (0, 0). + existing.rect = { height, width, x: 0, y: 0 }; + touch(existing); + return existing; + } + const entry: LayerCacheEntry = { + lastUsed: 0, + layerId, + rect: { height, width, x: 0, y: 0 }, + stale: true, + surface: backend.createSurface(width, height), + version: initialVersion(layerId), + }; + touch(entry); + entries.set(layerId, entry); + return entry; + }; + + const getOrCreateRect = (layerId: string, rect: Rect): LayerCacheEntry => { + const existing = entries.get(layerId); + if (existing) { + touch(existing); + return existing; + } + const width = Math.max(0, Math.round(rect.width)); + const height = Math.max(0, Math.round(rect.height)); + const entry: LayerCacheEntry = { + lastUsed: 0, + layerId, + rect: { height, width, x: rect.x, y: rect.y }, + stale: true, + surface: backend.createSurface(width, height), + version: initialVersion(layerId), + }; + touch(entry); + entries.set(layerId, entry); + return entry; + }; + + const growToRect = (layerId: string, rect: Rect): LayerCacheEntry => { + const existing = entries.get(layerId); + const targetRect: Rect = { + height: Math.max(0, Math.round(rect.height)), + width: Math.max(0, Math.round(rect.width)), + x: Math.round(rect.x), + y: Math.round(rect.y), + }; + if (!existing) { + const entry: LayerCacheEntry = { + lastUsed: 0, + layerId, + rect: targetRect, + stale: false, + surface: backend.createSurface(targetRect.width, targetRect.height), + version: initialVersion(layerId), + }; + touch(entry); + entries.set(layerId, entry); + return entry; + } + const cur = existing.rect; + const curEmpty = cur.width <= 0 || cur.height <= 0; + // Union of the current extent and the requested rect (in layer-local space). + const minX = curEmpty ? targetRect.x : Math.min(cur.x, targetRect.x); + const minY = curEmpty ? targetRect.y : Math.min(cur.y, targetRect.y); + const maxX = curEmpty + ? targetRect.x + targetRect.width + : Math.max(cur.x + cur.width, targetRect.x + targetRect.width); + const maxY = curEmpty + ? targetRect.y + targetRect.height + : Math.max(cur.y + cur.height, targetRect.y + targetRect.height); + const newRect: Rect = { height: maxY - minY, width: maxX - minX, x: minX, y: minY }; + if (newRect.x === cur.x && newRect.y === cur.y && newRect.width === cur.width && newRect.height === cur.height) { + // Already covers the request — no realloc. + touch(existing); + return existing; + } + // Snapshot the old pixels BEFORE resizing (resize clears the backing canvas), + // then blit them back at their new offset within the grown surface. Skip the + // copy when the old extent was empty (nothing to preserve). + const surface = existing.surface; + let snapshot: ImageData | null = null; + if (!curEmpty && cur.width > 0 && cur.height > 0) { + snapshot = surface.ctx.getImageData(0, 0, cur.width, cur.height); + } + surface.resize(newRect.width, newRect.height); + if (snapshot) { + surface.ctx.putImageData(snapshot, cur.x - newRect.x, cur.y - newRect.y); + } + existing.rect = newRect; + touch(existing); + return existing; + }; + + const invalidate = (layerId: string): void => { + const entry = entries.get(layerId); + if (entry) { + entry.version += 1; + entry.stale = true; + } + }; + + const del = (layerId: string): void => { + const entry = entries.get(layerId); + if (entry) { + rememberFloor(entry); + entries.delete(layerId); + } + }; + + const version = (layerId: string): number => entries.get(layerId)?.version ?? 0; + + const byteSize = (): number => { + let total = 0; + for (const entry of entries.values()) { + total += surfaceBytes(entry.surface); + } + return total; + }; + + const evictHidden = (visibleIds: Iterable, budgetBytes: number = DEFAULT_CACHE_BUDGET_BYTES): string[] => { + const visible = new Set(visibleIds); + const evicted: string[] = []; + let total = byteSize(); + if (total <= budgetBytes) { + return evicted; + } + // Hidden entries, least-recently-used first. + const candidates = [...entries.values()] + .filter((entry) => !visible.has(entry.layerId)) + .sort((a, b) => a.lastUsed - b.lastUsed); + for (const entry of candidates) { + if (total <= budgetBytes) { + break; + } + // Retain the version floor so a re-shown (re-rasterized) layer resumes ABOVE + // its pre-eviction version, not from 0 — otherwise the adjusted-surface / + // thumbnail caches would serve stale pixels keyed to the recycled version. + rememberFloor(entry); + entries.delete(entry.layerId); + evicted.push(entry.layerId); + total -= surfaceBytes(entry.surface); + } + return evicted; + }; + + const getBitmap = (imageName: string): ImageBitmap | undefined => bitmaps.get(imageName); + + const setBitmap = (imageName: string, bitmap: ImageBitmap): void => { + const previous = bitmaps.get(imageName); + if (previous && previous !== bitmap) { + previous.close(); + } + bitmaps.set(imageName, bitmap); + }; + + const deleteBitmap = (imageName: string): void => { + const previous = bitmaps.get(imageName); + if (previous) { + previous.close(); + bitmaps.delete(imageName); + } + }; + + const dispose = (): void => { + for (const bitmap of bitmaps.values()) { + bitmap.close(); + } + bitmaps.clear(); + entries.clear(); + }; + + return { + byteSize, + delete: del, + deleteBitmap, + dispose, + evictHidden, + get, + getBitmap, + getOrCreate, + getOrCreateRect, + growToRect, + invalidate, + setBitmap, + version, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/maskFill.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/maskFill.test.ts new file mode 100644 index 00000000000..889ed647e2e --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/maskFill.test.ts @@ -0,0 +1,93 @@ +import type { CanvasMaskFillContract } from '@workbench/types'; + +import { describe, expect, it } from 'vitest'; + +import type { RasterCallLogEntry, StubRasterSurface } from './raster.testStub'; + +import { colorizeMask, createMaskPatternTile } from './maskFill'; +import { createTestStubRasterBackend } from './raster.testStub'; + +/** Values recorded for a set-property (e.g. `strokeStyle`), in order. */ +const setValues = (log: RasterCallLogEntry[], prop: string): unknown[] => + log.filter((e) => e.op === 'set' && e.args[0] === prop).map((e) => e.args[1]); + +const countOp = (log: RasterCallLogEntry[], op: string): number => log.filter((e) => e.op === op).length; + +describe('createMaskPatternTile', () => { + it('returns null for the solid style (no pattern — filled directly)', () => { + const backend = createTestStubRasterBackend(); + expect(createMaskPatternTile(backend, 'solid', '#ff0000')).toBeNull(); + }); + + it('builds a colour-specific tile per non-solid style, stroking in the fill colour', () => { + const backend = createTestStubRasterBackend(); + for (const style of ['grid', 'crosshatch', 'diagonal', 'horizontal', 'vertical'] as const) { + const tile = createMaskPatternTile(backend, style, '#00ff88') as StubRasterSurface; + expect(tile).not.toBeNull(); + // Lines are drawn (stroke) in the fill colour. + expect(setValues(tile.callLog, 'strokeStyle')).toContain('#00ff88'); + expect(countOp(tile.callLog, 'stroke')).toBeGreaterThan(0); + } + }); + + it('sizes tiles to the legacy specs (diagonal/crosshatch 6px, grid 12px, h/v 9px)', () => { + const backend = createTestStubRasterBackend(); + const size = (style: CanvasMaskFillContract['style']): number => + (createMaskPatternTile(backend, style, '#fff') as StubRasterSurface).width; + expect(size('diagonal')).toBe(6); + expect(size('crosshatch')).toBe(6); + expect(size('grid')).toBe(12); + expect(size('horizontal')).toBe(9); + expect(size('vertical')).toBe(9); + }); + + it('draws more lines for crosshatch (both directions) than diagonal (one direction)', () => { + const backend = createTestStubRasterBackend(); + const diagonal = createMaskPatternTile(backend, 'diagonal', '#fff') as StubRasterSurface; + const crosshatch = createMaskPatternTile(backend, 'crosshatch', '#fff') as StubRasterSurface; + expect(countOp(crosshatch.callLog, 'stroke')).toBeGreaterThan(countOp(diagonal.callLog, 'stroke')); + }); + + it('horizontal draws only horizontal lines, vertical only vertical (equal counts, disjoint geometry)', () => { + const backend = createTestStubRasterBackend(); + const horizontal = createMaskPatternTile(backend, 'horizontal', '#fff') as StubRasterSurface; + const vertical = createMaskPatternTile(backend, 'vertical', '#fff') as StubRasterSurface; + // Same 9px tile, same spacing → same number of strokes. + expect(countOp(horizontal.callLog, 'stroke')).toBe(countOp(vertical.callLog, 'stroke')); + }); +}); + +describe('colorizeMask', () => { + const fill = (style: CanvasMaskFillContract['style'], color = '#ff0000'): CanvasMaskFillContract => ({ + color, + style, + }); + + it('draws the mask stencil then fills the colour with source-in (solid)', () => { + const backend = createTestStubRasterBackend(); + const mask = backend.createSurface(10, 10); + const out = colorizeMask(backend, mask, 10, 10, fill('solid'), null) as StubRasterSurface; + + const ops = out.callLog; + const drawIdx = ops.findIndex((e) => e.op === 'drawImage'); + const sourceInIdx = ops.findIndex( + (e) => e.op === 'set' && e.args[0] === 'globalCompositeOperation' && e.args[1] === 'source-in' + ); + const fillRectIdx = ops.findIndex((e) => e.op === 'fillRect'); + // Order: stencil blit → switch to source-in → fill the colour. + expect(drawIdx).toBeGreaterThanOrEqual(0); + expect(sourceInIdx).toBeGreaterThan(drawIdx); + expect(fillRectIdx).toBeGreaterThan(sourceInIdx); + expect(setValues(ops, 'fillStyle')).toContain('#ff0000'); + }); + + it('uses the pattern tile as the fill when one is provided (pattern style)', () => { + const backend = createTestStubRasterBackend(); + const mask = backend.createSurface(8, 8); + const tile = createMaskPatternTile(backend, 'diagonal', '#ff0000'); + const out = colorizeMask(backend, mask, 8, 8, fill('diagonal'), tile) as StubRasterSurface; + // A repeat pattern was created and used as the source-in fill. + expect(countOp(out.callLog, 'createPattern')).toBe(1); + expect(out.callLog.some((e) => e.op === 'createPattern' && e.args[1] === 'repeat')).toBe(true); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/maskFill.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/maskFill.ts new file mode 100644 index 00000000000..c21df3e1e78 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/maskFill.ts @@ -0,0 +1,153 @@ +/** + * Mask fill rendering: turns a mask layer's alpha stencil into a tinted, + * translucent overlay coloured by its {@link CanvasMaskFillContract}. + * + * Mirrors legacy (`features/controlLayers/konva/CanvasEntity/CanvasEntityObjectRenderer.ts`): + * the mask objects are drawn as an opaque alpha stencil, then a fill (a solid + * colour, or one of five line PATTERNS) is composited over them with + * `globalCompositeOperation: 'source-in'` so the colour/pattern shows ONLY where + * the mask is painted. The whole overlay is drawn above all raster/control + * content at the layer's opacity (legacy default `1`). + * + * Pattern tiles are small, colour-specific, cached surfaces built through the + * {@link RasterBackend} seam (like the transparency checkerboard). Legacy anchors + * its pattern to the screen; here the colorized surface is drawn through the + * layer transform, so the pattern is OBJECT-anchored (it scales/pans with the + * mask content) — a deliberate, documented simplification of the screen-anchored + * legacy behaviour that keeps the whole pipeline node-testable through the seam. + * + * Zero React, zero import-time side effects. + */ + +import type { CanvasMaskFillContract } from '@workbench/types'; + +import type { RasterBackend, RasterSurface } from './raster'; + +/** Line specs (tile size, line spacing, stroke width) for each non-solid fill style. */ +const PATTERN_SPECS: Record< + Exclude, + { size: number; spacing: number; width: number } +> = { + // 6px tile, two parallel 45° lines 3px apart (legacy `pattern-diagonal.svg`). + diagonal: { size: 6, spacing: 3, width: 1.3 }, + // 6px tile, 45° lines in BOTH directions (legacy `pattern-crosshatch.svg`). + crosshatch: { size: 6, spacing: 3, width: 1.2 }, + // 12px tile, 3 vertical + 3 horizontal lines 4px apart (legacy `pattern-grid.svg`). + grid: { size: 12, spacing: 4, width: 1 }, + // 9px tile, 3 horizontal lines 3px apart (legacy `pattern-horizontal.svg`). + horizontal: { size: 9, spacing: 3, width: 1 }, + // 9px tile, 3 vertical lines 3px apart (legacy `pattern-vertical.svg`). + vertical: { size: 9, spacing: 3, width: 1 }, +}; + +/** Draws the anti-diagonal (`x + y = c`) or main-diagonal (`x - y = c`) line family across the tile. */ +const drawDiagonalLines = ( + ctx: RasterSurface['ctx'], + size: number, + spacing: number, + direction: 'anti' | 'main' +): void => { + // Sweep `c` well beyond the tile so every crossing line is drawn; the surface + // clips to its bounds. `spacing` divides `size`, so the family tiles seamlessly. + for (let c = -size; c <= 2 * size; c += spacing) { + ctx.beginPath(); + if (direction === 'anti') { + // x + y = c: a long segment crossing the tile. + ctx.moveTo(c + size, -size); + ctx.lineTo(c - 2 * size, 2 * size); + } else { + // x - y = c. + ctx.moveTo(c - size, -size); + ctx.lineTo(c + 2 * size, 2 * size); + } + ctx.stroke(); + } +}; + +/** + * Builds a colour-specific repeat tile for a non-solid fill `style`, or returns + * `null` for `'solid'` (no pattern — the colour is filled directly). The tile is + * drawn in `color` through the backend seam. + */ +export const createMaskPatternTile = ( + backend: RasterBackend, + style: CanvasMaskFillContract['style'], + color: string +): RasterSurface | null => { + if (style === 'solid') { + return null; + } + const spec = PATTERN_SPECS[style]; + const tile = backend.createSurface(spec.size, spec.size); + const ctx = tile.ctx; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, spec.size, spec.size); + ctx.strokeStyle = color; + ctx.lineWidth = spec.width; + + switch (style) { + case 'diagonal': + drawDiagonalLines(ctx, spec.size, spec.spacing, 'anti'); + break; + case 'crosshatch': + drawDiagonalLines(ctx, spec.size, spec.spacing, 'anti'); + drawDiagonalLines(ctx, spec.size, spec.spacing, 'main'); + break; + case 'grid': + case 'horizontal': + case 'vertical': { + // Pixel-centred lines (0.5 offset) so 1px strokes stay crisp, matching + // the legacy SVG tiles (lines at 0.5 / 3.5 / 6.5, etc.). + for (let p = 0.5; p < spec.size; p += spec.spacing) { + if (style !== 'horizontal') { + ctx.beginPath(); + ctx.moveTo(p, 0); + ctx.lineTo(p, spec.size); + ctx.stroke(); + } + if (style !== 'vertical') { + ctx.beginPath(); + ctx.moveTo(0, p); + ctx.lineTo(spec.size, p); + ctx.stroke(); + } + } + break; + } + } + return tile; +}; + +/** + * Produces a colorized RGBA surface the size of the mask cache: the mask's alpha + * is the stencil, the `fill` supplies the colour (solid) or a repeat `tile` + * (pattern), composited `source-in`. The caller blits the result through the + * layer transform. + */ +export const colorizeMask = ( + backend: RasterBackend, + mask: RasterSurface, + width: number, + height: number, + fill: CanvasMaskFillContract, + tile: RasterSurface | null +): RasterSurface => { + const out = backend.createSurface(width, height); + const ctx = out.ctx; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, width, height); + // 1. The mask alpha stencil. + ctx.globalAlpha = 1; + ctx.globalCompositeOperation = 'source-over'; + ctx.drawImage(mask.canvas, 0, 0); + // 2. Colour/pattern kept only where the stencil is opaque (`source-in`). + ctx.globalCompositeOperation = 'source-in'; + if (tile) { + const pattern = ctx.createPattern(tile.canvas, 'repeat'); + ctx.fillStyle = pattern ?? fill.color; + } else { + ctx.fillStyle = fill.color; + } + ctx.fillRect(0, 0, width, height); + return out; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/overlayRenderer.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/overlayRenderer.test.ts new file mode 100644 index 00000000000..4379261434f --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/overlayRenderer.test.ts @@ -0,0 +1,127 @@ +import type { Mat2d, Rect } from '@workbench/canvas-engine/types'; + +import { identity, scale } from '@workbench/canvas-engine/math/mat2d'; +import { describe, expect, it } from 'vitest'; + +import type { OverlayState } from './overlayRenderer'; +import type { RasterCallLogEntry } from './raster.testStub'; + +import { MIN_GRID_SPACING_PX, renderOverlay } from './overlayRenderer'; +import { createTestStubRasterBackend } from './raster.testStub'; + +const BBOX: Rect = { height: 40, width: 40, x: 10, y: 10 }; + +const findSet = (log: RasterCallLogEntry[], prop: string): unknown[] => + log.filter((e) => e.op === 'set' && e.args[0] === prop).map((e) => e.args[1]); + +const baseState = (overrides: Partial = {}): OverlayState => ({ + bbox: BBOX, + view: identity(), + ...overrides, +}); + +describe('renderOverlay', () => { + it('clears then strokes ONLY the bbox (dashed) — no document outline (unbounded plane)', () => { + const backend = createTestStubRasterBackend(); + const target = backend.createSurface(200, 200); + + renderOverlay(target, baseState()); + + const ops = target.callLog.map((e) => e.op); + expect(ops).toContain('clearRect'); + // The document rect is retired as a visual boundary: the bbox is the only rect stroked. + expect(target.callLog.filter((e) => e.op === 'strokeRect')).toHaveLength(1); + // At least one dashed setLineDash for the bbox. + const dashArgs = target.callLog.filter((e) => e.op === 'setLineDash').map((e) => e.args[0]); + expect(dashArgs).toContainEqual([4, 4]); + }); + + it('draws no grid when showGrid is false', () => { + const backend = createTestStubRasterBackend(); + const target = backend.createSurface(200, 200); + renderOverlay(target, baseState({ gridSize: 10, showGrid: false })); + // No path building beyond stroking rects (no moveTo/lineTo for grid lines). + expect(target.callLog.some((e) => e.op === 'moveTo')).toBe(false); + }); + + it('draws grid lines when enabled and spacing is large enough', () => { + const backend = createTestStubRasterBackend(); + const target = backend.createSurface(200, 200); + renderOverlay(target, baseState({ gridSize: 20, showGrid: true, view: scale(identity(), 2) })); + expect(target.callLog.some((e) => e.op === 'moveTo')).toBe(true); + expect(target.callLog.some((e) => e.op === 'lineTo')).toBe(true); + expect(target.callLog.some((e) => e.op === 'stroke')).toBe(true); + }); + + it('skips the grid when zoomed out below the density threshold', () => { + const backend = createTestStubRasterBackend(); + const target = backend.createSurface(200, 200); + // gridSize * scale below MIN_GRID_SPACING_PX -> too dense, skip. + const smallScale = (MIN_GRID_SPACING_PX - 1) / 10; + const view: Mat2d = scale(identity(), smallScale); + renderOverlay(target, baseState({ gridSize: 10, showGrid: true, view })); + expect(target.callLog.some((e) => e.op === 'moveTo')).toBe(false); + }); + + it('draws a brush cursor ring scaled from document units', () => { + const backend = createTestStubRasterBackend(); + const target = backend.createSurface(200, 200); + renderOverlay( + target, + baseState({ cursor: { point: { x: 50, y: 50 }, radiusDoc: 10 }, view: scale(identity(), 2) }) + ); + + const arc = target.callLog.find((e) => e.op === 'arc'); + expect(arc).toBeDefined(); + // center = point * 2, radius = radiusDoc * scale(2) = 20. + expect(arc!.args.slice(0, 3)).toEqual([100, 100, 20]); + }); + + it('omits the cursor ring when cursor is null', () => { + const backend = createTestStubRasterBackend(); + const target = backend.createSurface(200, 200); + renderOverlay(target, baseState({ cursor: null })); + expect(target.callLog.some((e) => e.op === 'arc')).toBe(false); + }); + + it('strokes the bbox in its own accent color (no document-outline color)', () => { + const backend = createTestStubRasterBackend(); + const target = backend.createSurface(200, 200); + renderOverlay(target, baseState()); + const strokeStyles = findSet(target.callLog, 'strokeStyle'); + // With the document outline gone, the bbox is the only stroked chrome here: + // exactly one stroke color is applied. + expect(strokeStyles).toEqual(['#3b82f6']); + }); + + it('hides the passive bbox frame when showBbox is false', () => { + const backend = createTestStubRasterBackend(); + const target = backend.createSurface(200, 200); + renderOverlay(target, baseState({ showBbox: false })); + expect(target.callLog.filter((e) => e.op === 'strokeRect')).toHaveLength(0); + }); + + it('draws the bbox-overlay shade (evenodd punch-out) only when bboxOverlay is on', () => { + const backend = createTestStubRasterBackend(); + const off = backend.createSurface(200, 200); + renderOverlay(off, baseState()); + expect(off.callLog.some((e) => e.op === 'fill' && e.args[0] === 'evenodd')).toBe(false); + + const on = backend.createSurface(200, 200); + renderOverlay(on, baseState({ bboxOverlay: true })); + // One even-odd fill: the viewport rect with the bbox rect punched out. + expect(on.callLog.filter((e) => e.op === 'fill' && e.args[0] === 'evenodd')).toHaveLength(1); + const rects = on.callLog.filter((e) => e.op === 'rect').map((e) => e.args); + expect(rects).toContainEqual([0, 0, 200, 200]); + expect(rects).toContainEqual([BBOX.x, BBOX.y, BBOX.width, BBOX.height]); + }); + + it('draws rule-of-thirds guides inside the bbox when enabled', () => { + const backend = createTestStubRasterBackend(); + const target = backend.createSurface(200, 200); + renderOverlay(target, baseState({ ruleOfThirds: true })); + // Four guide lines: two vertical + two horizontal moveTo/lineTo pairs. + const moveTos = target.callLog.filter((e) => e.op === 'moveTo'); + expect(moveTos).toHaveLength(4); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/overlayRenderer.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/overlayRenderer.ts new file mode 100644 index 00000000000..b30cfa6af45 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/overlayRenderer.ts @@ -0,0 +1,482 @@ +/** + * Renders the interaction overlay — the second stacked canvas above the + * composited document: bbox rectangle, an optional viewport-wide grid, and + * the brush cursor ring. Everything is drawn in screen space (document points + * projected through `view`) so strokes stay a constant pixel width regardless + * of zoom. The canvas is an unbounded plane, so no document-bounds outline is + * drawn. + * + * Marching ants and transform handles come later; the functions here are kept + * small and composable so those can slot in. Every pixel operation flows + * through the {@link RasterSurface} `ctx` seam. Zero React, zero import-time + * side effects. + */ + +import type { Mat2d, Rect, Vec2 } from '@workbench/canvas-engine/types'; + +import { applyToPoint, getScale, invert } from '@workbench/canvas-engine/math/mat2d'; +import { transformBounds } from '@workbench/canvas-engine/math/rect'; +import { drawMarchingAnts, type MarchingAntsRender } from '@workbench/canvas-engine/selection/marchingAnts'; +import { BBOX_HANDLES, bboxHandlePoint } from '@workbench/canvas-engine/tools/bboxHitTest'; +import { TRANSFORM_ROTATE_NUB_PX } from '@workbench/canvas-engine/transform/transformMath'; + +import type { RasterSurface } from './raster'; + +/** Minimum screen-space grid spacing (px) below which the grid is too dense to draw. */ +export const MIN_GRID_SPACING_PX = 8; + +const BBOX_COLOR = '#3b82f6'; +const BBOX_DASH: readonly number[] = [4, 4]; +/** The bbox-overlay dim fill (legacy `CanvasBboxToolModule` overlayRect parity). */ +const BBOX_OVERLAY_FILL = 'hsl(220 12% 10% / 0.8)'; +const GRID_COLOR = 'rgba(128, 128, 128, 0.25)'; +const CURSOR_COLOR = '#ffffff'; +/** Side length (screen px) of a drawn bbox resize handle. */ +const BBOX_HANDLE_DRAW_PX = 8; +const BBOX_HANDLE_FILL = '#ffffff'; +/** Solid accent used for the selected-layer bounds outline while the move tool is active. */ +const LAYER_OUTLINE_COLOR = '#38bdf8'; +/** Accent for the transform frame (outline, handles, rotation nub). */ +const TRANSFORM_COLOR = '#38bdf8'; +const TRANSFORM_HANDLE_FILL = '#ffffff'; +/** Side length (screen px) of a drawn transform scale handle. */ +const TRANSFORM_HANDLE_DRAW_PX = 8; +/** Screen-px radius of the rotation-indicator knob at the nub's tip. */ +const TRANSFORM_ROTATE_KNOB_PX = 3.5; + +/** The brush cursor ring: center in document space, radius in document units. */ +export interface OverlayCursor { + point: Vec2; + radiusDoc: number; +} + +/** Everything the overlay needs to draw a frame. */ +export interface OverlayState { + /** Document→screen transform. */ + view: Mat2d; + /** Generation bounding box in document space. */ + bbox: Rect; + /** Whether to draw the eight bbox resize handles (bbox tool active). */ + bboxHandles?: boolean; + /** Whether to draw the passive bbox frame (default when absent: drawn). */ + showBbox?: boolean; + /** Whether to dim everything outside the bbox (the legacy "bbox overlay" shade). */ + bboxOverlay?: boolean; + /** Whether to draw the rule-of-thirds guides inside the bbox. */ + ruleOfThirds?: boolean; + /** Whether to draw the grid. */ + showGrid?: boolean; + /** Grid spacing in document units. */ + gridSize?: number; + /** Brush cursor ring, or `null`/absent to hide it. */ + cursor?: OverlayCursor | null; + /** + * A layer's rendered-bounds outline (the move tool's selection marquee): the + * four document-space corners, projected through `view` and stroked as a closed + * polygon. Absent/`null` to hide it. + */ + layerOutline?: readonly Vec2[] | null; + /** + * The active transform-tool frame: the layer's rotated bounds, its eight scale + * handles, and the center/top-edge points for the rotation nub — all in + * document space, projected through `view`. Absent/`null` when no transform + * session is active. + */ + transformFrame?: TransformFrameOverlay | null; + /** + * The in-progress lasso polygon (document-space points), drawn as a live dashed + * outline while a lasso drag is underway. Absent/`null` when idle. + */ + lassoPreview?: readonly Vec2[] | null; + /** + * The committed selection's marching ants: the outline paths (document space) + * plus the animated dash phase. Absent/`null` when there is no selection. + */ + marchingAnts?: MarchingAntsRender | null; + /** + * The in-progress shape-tool drag (document-space rect + kind), drawn as a + * live outline while a shape is being created. Absent/`null` when idle. + */ + shapePreview?: { rect: Rect; kind: 'rect' | 'ellipse' } | null; + /** + * The in-progress gradient-tool drag vector (document-space start/end), + * drawn as a direction indicator. Absent/`null` when idle. + */ + gradientPreview?: { start: Vec2; end: Vec2 } | null; +} + +/** Document-space geometry the overlay draws for an active transform session. */ +export interface TransformFrameOverlay { + /** The four rotated-rect corners (closed polygon). */ + corners: readonly Vec2[]; + /** The eight scale-handle positions. */ + handles: readonly Vec2[]; + /** The layer center (rotation nub direction reference). */ + center: Vec2; + /** The top edge midpoint (root of the rotation nub). */ + rotationAnchor: Vec2; +} + +type Ctx = RasterSurface['ctx']; + +const strokeRectScreen = (ctx: Ctx, screenRect: Rect): void => { + ctx.strokeRect(screenRect.x, screenRect.y, screenRect.width, screenRect.height); +}; + +/** + * Draws grid lines across the entire viewport, in screen space, skipping when + * too dense. The canvas is an unbounded plane, so the grid is not clipped to any + * document rect: the visible screen rect is projected back into document space + * (via the inverse view) and snapped out to whole grid cells so the lines tile + * seamlessly while panning/zooming. + */ +const drawGrid = (ctx: Ctx, state: OverlayState, target: RasterSurface): void => { + const gridSize = state.gridSize ?? 0; + if (gridSize <= 0) { + return; + } + const scale = getScale(state.view); + if (gridSize * scale < MIN_GRID_SPACING_PX) { + return; + } + + const { view } = state; + const inv = invert(view); + if (!inv) { + return; + } + // Document-space bounds of the viewport's four screen corners. + const corners = [ + applyToPoint(inv, { x: 0, y: 0 }), + applyToPoint(inv, { x: target.width, y: 0 }), + applyToPoint(inv, { x: 0, y: target.height }), + applyToPoint(inv, { x: target.width, y: target.height }), + ]; + const xs = corners.map((p) => p.x); + const ys = corners.map((p) => p.y); + // Snap outward to whole grid cells so lines are stable across pan/zoom. + const left = Math.floor(Math.min(...xs) / gridSize) * gridSize; + const top = Math.floor(Math.min(...ys) / gridSize) * gridSize; + const right = Math.ceil(Math.max(...xs) / gridSize) * gridSize; + const bottom = Math.ceil(Math.max(...ys) / gridSize) * gridSize; + + ctx.save(); + ctx.strokeStyle = GRID_COLOR; + ctx.lineWidth = 1; + ctx.setLineDash([]); + ctx.beginPath(); + for (let x = left; x <= right; x += gridSize) { + const a = applyToPoint(view, { x, y: top }); + const b = applyToPoint(view, { x, y: bottom }); + ctx.moveTo(a.x, a.y); + ctx.lineTo(b.x, b.y); + } + for (let y = top; y <= bottom; y += gridSize) { + const a = applyToPoint(view, { x: left, y }); + const b = applyToPoint(view, { x: right, y }); + ctx.moveTo(a.x, a.y); + ctx.lineTo(b.x, b.y); + } + ctx.stroke(); + ctx.restore(); +}; + +/** Draws the brush cursor ring at the pointer, radius scaled from document units. */ +const drawCursor = (ctx: Ctx, state: OverlayState): void => { + const cursor = state.cursor; + if (!cursor) { + return; + } + const center = applyToPoint(state.view, cursor.point); + const radius = cursor.radiusDoc * getScale(state.view); + ctx.save(); + ctx.strokeStyle = CURSOR_COLOR; + ctx.lineWidth = 1; + ctx.setLineDash([]); + ctx.beginPath(); + ctx.arc(center.x, center.y, Math.max(0, radius), 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); +}; + +/** Draws a layer's rendered-bounds outline as a closed polygon in screen space. */ +const drawLayerOutline = (ctx: Ctx, state: OverlayState): void => { + const corners = state.layerOutline; + if (!corners || corners.length < 2) { + return; + } + ctx.save(); + ctx.strokeStyle = LAYER_OUTLINE_COLOR; + ctx.lineWidth = 1; + ctx.setLineDash([]); + ctx.beginPath(); + corners.forEach((corner, index) => { + const p = applyToPoint(state.view, corner); + if (index === 0) { + ctx.moveTo(p.x, p.y); + } else { + ctx.lineTo(p.x, p.y); + } + }); + ctx.closePath(); + ctx.stroke(); + ctx.restore(); +}; + +/** + * Draws the transform frame (rotated bounds polygon, eight scale handles, and a + * rotation nub past the top edge) in screen space. Handles/nub stay a constant + * pixel size; the nub direction follows the rotated frame (top-edge → away from + * center), so it reads correctly at any layer rotation. + */ +const drawTransformFrame = (ctx: Ctx, state: OverlayState): void => { + const frame = state.transformFrame; + if (!frame || frame.corners.length < 3) { + return; + } + const { view } = state; + ctx.save(); + ctx.setLineDash([]); + ctx.strokeStyle = TRANSFORM_COLOR; + ctx.lineWidth = 1; + + // Bounds polygon. + ctx.beginPath(); + frame.corners.forEach((corner, index) => { + const p = applyToPoint(view, corner); + if (index === 0) { + ctx.moveTo(p.x, p.y); + } else { + ctx.lineTo(p.x, p.y); + } + }); + ctx.closePath(); + ctx.stroke(); + + // Rotation nub: from the top-edge midpoint, outward (away from center), a + // constant screen length; a small knob at the tip. + const anchor = applyToPoint(view, frame.rotationAnchor); + const center = applyToPoint(view, frame.center); + const dx = anchor.x - center.x; + const dy = anchor.y - center.y; + const len = Math.hypot(dx, dy) || 1; + const nx = anchor.x + (dx / len) * TRANSFORM_ROTATE_NUB_PX; + const ny = anchor.y + (dy / len) * TRANSFORM_ROTATE_NUB_PX; + ctx.beginPath(); + ctx.moveTo(anchor.x, anchor.y); + ctx.lineTo(nx, ny); + ctx.stroke(); + ctx.beginPath(); + ctx.fillStyle = TRANSFORM_HANDLE_FILL; + ctx.arc(nx, ny, TRANSFORM_ROTATE_KNOB_PX, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + + // Scale handles. + const half = TRANSFORM_HANDLE_DRAW_PX / 2; + ctx.fillStyle = TRANSFORM_HANDLE_FILL; + for (const handle of frame.handles) { + const p = applyToPoint(view, handle); + ctx.fillRect(p.x - half, p.y - half, TRANSFORM_HANDLE_DRAW_PX, TRANSFORM_HANDLE_DRAW_PX); + ctx.strokeRect(p.x - half, p.y - half, TRANSFORM_HANDLE_DRAW_PX, TRANSFORM_HANDLE_DRAW_PX); + } + ctx.restore(); +}; + +const LASSO_PREVIEW_COLOR = '#38bdf8'; +const LASSO_PREVIEW_DASH: readonly number[] = [4, 4]; + +/** Draws the in-progress lasso polygon as a dashed screen-space outline. */ +const drawLassoPreview = (ctx: Ctx, state: OverlayState): void => { + const points = state.lassoPreview; + if (!points || points.length < 2) { + return; + } + ctx.save(); + ctx.strokeStyle = LASSO_PREVIEW_COLOR; + ctx.lineWidth = 1; + ctx.setLineDash([...LASSO_PREVIEW_DASH]); + ctx.beginPath(); + points.forEach((point, index) => { + const p = applyToPoint(state.view, point); + if (index === 0) { + ctx.moveTo(p.x, p.y); + } else { + ctx.lineTo(p.x, p.y); + } + }); + // Close the loop back to the start so the enclosed region reads clearly. + ctx.closePath(); + ctx.stroke(); + ctx.setLineDash([]); + ctx.restore(); +}; + +/** Draws the shape-tool drag preview (rect or ellipse outline) in screen space. */ +const drawShapePreview = (ctx: Ctx, state: OverlayState): void => { + const preview = state.shapePreview; + if (!preview || preview.rect.width <= 0 || preview.rect.height <= 0) { + return; + } + const screen = transformBounds(state.view, preview.rect); + ctx.save(); + ctx.strokeStyle = LAYER_OUTLINE_COLOR; + ctx.lineWidth = 1; + ctx.setLineDash([...BBOX_DASH]); + ctx.beginPath(); + if (preview.kind === 'ellipse') { + ctx.ellipse( + screen.x + screen.width / 2, + screen.y + screen.height / 2, + Math.abs(screen.width) / 2, + Math.abs(screen.height) / 2, + 0, + 0, + Math.PI * 2 + ); + } else { + ctx.rect(screen.x, screen.y, screen.width, screen.height); + } + ctx.stroke(); + ctx.setLineDash([]); + ctx.restore(); +}; + +/** Draws the gradient-tool drag vector (a line with endpoint dots) in screen space. */ +const drawGradientPreview = (ctx: Ctx, state: OverlayState): void => { + const preview = state.gradientPreview; + if (!preview) { + return; + } + const start = applyToPoint(state.view, preview.start); + const end = applyToPoint(state.view, preview.end); + ctx.save(); + ctx.strokeStyle = LAYER_OUTLINE_COLOR; + ctx.fillStyle = LAYER_OUTLINE_COLOR; + ctx.lineWidth = 1; + ctx.setLineDash([]); + ctx.beginPath(); + ctx.moveTo(start.x, start.y); + ctx.lineTo(end.x, end.y); + ctx.stroke(); + for (const point of [start, end]) { + ctx.beginPath(); + ctx.arc(point.x, point.y, 3, 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); +}; + +/** + * Draws the bbox overlay shade: a translucent dark fill over the ENTIRE viewport + * with the bbox region punched out (even-odd fill rule — the two nested rect paths + * cancel inside the bbox), dimming everything outside the generation frame. Fill + * color matches legacy `CanvasBboxToolModule`'s overlayRect. + */ +const drawBboxOverlayShade = (ctx: Ctx, state: OverlayState, target: RasterSurface): void => { + if (!state.bboxOverlay) { + return; + } + const bboxScreen = transformBounds(state.view, state.bbox); + ctx.save(); + ctx.fillStyle = BBOX_OVERLAY_FILL; + ctx.beginPath(); + ctx.rect(0, 0, target.width, target.height); + ctx.rect(bboxScreen.x, bboxScreen.y, bboxScreen.width, bboxScreen.height); + ctx.fill('evenodd'); + ctx.restore(); +}; + +/** + * Draws the rule-of-thirds guides: two vertical and two horizontal lines dividing + * the bbox into thirds (screen space, solid, in the grid color). A composition aid + * over the generation frame. + */ +const drawRuleOfThirds = (ctx: Ctx, state: OverlayState): void => { + if (!state.ruleOfThirds) { + return; + } + const r = transformBounds(state.view, state.bbox); + ctx.save(); + ctx.setLineDash([]); + ctx.strokeStyle = GRID_COLOR; + ctx.lineWidth = 1; + ctx.beginPath(); + for (let i = 1; i <= 2; i++) { + const x = r.x + (r.width * i) / 3; + const y = r.y + (r.height * i) / 3; + ctx.moveTo(x, r.y); + ctx.lineTo(x, r.y + r.height); + ctx.moveTo(r.x, y); + ctx.lineTo(r.x + r.width, y); + } + ctx.stroke(); + ctx.restore(); +}; + +/** Draws the eight bbox resize handles as small squares at the frame's corners/edges (screen space). */ +const drawBboxHandles = (ctx: Ctx, state: OverlayState): void => { + if (!state.bboxHandles) { + return; + } + const screenRect = transformBounds(state.view, state.bbox); + const half = BBOX_HANDLE_DRAW_PX / 2; + ctx.save(); + ctx.setLineDash([]); + ctx.fillStyle = BBOX_HANDLE_FILL; + ctx.strokeStyle = BBOX_COLOR; + ctx.lineWidth = 1; + for (const handle of BBOX_HANDLES) { + const center = bboxHandlePoint(screenRect, handle); + ctx.fillRect(center.x - half, center.y - half, BBOX_HANDLE_DRAW_PX, BBOX_HANDLE_DRAW_PX); + ctx.strokeRect(center.x - half, center.y - half, BBOX_HANDLE_DRAW_PX, BBOX_HANDLE_DRAW_PX); + } + ctx.restore(); +}; + +/** Clears and redraws the entire overlay for the given `state`. */ +export const renderOverlay = (target: RasterSurface, state: OverlayState): void => { + const ctx = target.ctx; + + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, target.width, target.height); + + // The document rect is retired as a visual boundary (unbounded plane), so no + // document outline is drawn. The bbox (generation frame) is the primary anchor. + + // The bbox overlay shade first: it dims the composited document outside the + // bbox, and every piece of overlay chrome (grid, guides, frame) draws over it. + drawBboxOverlayShade(ctx, state, target); + + // Grid next (behind the bbox), spanning the whole viewport. + if (state.showGrid) { + drawGrid(ctx, state, target); + } + + // Rule-of-thirds guides sit inside the bbox, behind its frame. + drawRuleOfThirds(ctx, state); + + // Bbox (dashed, distinct color). Drawn as passive chrome unless the setting hides + // it (`showBbox === false`); the handles below still render for the bbox tool so + // the frame stays editable even when hidden. + if (state.showBbox ?? true) { + ctx.strokeStyle = BBOX_COLOR; + ctx.setLineDash([...BBOX_DASH]); + strokeRectScreen(ctx, transformBounds(state.view, state.bbox)); + ctx.setLineDash([]); + } + + drawLayerOutline(ctx, state); + drawBboxHandles(ctx, state); + drawTransformFrame(ctx, state); + if (state.marchingAnts) { + drawMarchingAnts(ctx, state.view, state.marchingAnts); + } + drawLassoPreview(ctx, state); + drawShapePreview(ctx, state); + drawGradientPreview(ctx, state); + drawCursor(ctx, state); + + ctx.restore(); +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/raster.testStub.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/raster.testStub.test.ts new file mode 100644 index 00000000000..cbc1d62a1a1 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/raster.testStub.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; + +import { createTestStubRasterBackend } from './raster.testStub'; + +describe('createTestStubRasterBackend', () => { + it('creates a surface with the requested dimensions', () => { + const backend = createTestStubRasterBackend(); + const surface = backend.createSurface(64, 32); + expect(surface.width).toBe(64); + expect(surface.height).toBe(32); + }); + + it('resize updates the surface dimensions and logs the call', () => { + const backend = createTestStubRasterBackend(); + const surface = backend.createSurface(10, 10); + surface.resize(20, 40); + expect(surface.width).toBe(20); + expect(surface.height).toBe(40); + expect(surface.callLog).toContainEqual({ op: 'resize', args: [20, 40] }); + }); + + it('records draw calls made against the surface context', () => { + const backend = createTestStubRasterBackend(); + const surface = backend.createSurface(10, 10); + const ctx = surface.ctx; + + ctx.save(); + ctx.clearRect(0, 0, 10, 10); + ctx.setTransform(1, 0, 0, 1, 5, 5); + ctx.fill(); + ctx.restore(); + + const ops = surface.callLog.map((entry) => entry.op); + expect(ops).toEqual(['save', 'clearRect', 'setTransform', 'fill', 'restore']); + expect(surface.callLog[1]).toEqual({ op: 'clearRect', args: [0, 0, 10, 10] }); + expect(surface.callLog[2]).toEqual({ op: 'setTransform', args: [1, 0, 0, 1, 5, 5] }); + }); + + it('getImageData returns a correctly-sized, zeroed buffer', () => { + const backend = createTestStubRasterBackend(); + const surface = backend.createSurface(4, 3); + const imageData = surface.ctx.getImageData(0, 0, 4, 3); + expect(imageData.width).toBe(4); + expect(imageData.height).toBe(3); + expect(imageData.data.length).toBe(4 * 3 * 4); + expect(Array.from(imageData.data).every((v) => v === 0)).toBe(true); + }); + + it('putImageData is recorded in the call log', () => { + const backend = createTestStubRasterBackend(); + const surface = backend.createSurface(2, 2); + const imageData = surface.ctx.getImageData(0, 0, 2, 2); + surface.ctx.putImageData(imageData, 0, 0); + expect(surface.callLog.at(-1)).toEqual({ op: 'putImageData', args: [imageData, 0, 0] }); + }); + + it('createImageBitmap resolves to a bitmap-shaped object', async () => { + const backend = createTestStubRasterBackend(); + const fakeSource = {} as ImageBitmapSource; + const bitmap = await backend.createImageBitmap(fakeSource); + expect(bitmap).toHaveProperty('width'); + expect(bitmap).toHaveProperty('height'); + }); + + it('encodeSurface resolves to a deterministic, size-keyed image blob', async () => { + const backend = createTestStubRasterBackend(); + const surface = backend.createSurface(8, 4); + const blob = await backend.encodeSurface(surface); + expect(blob).toBeInstanceOf(Blob); + expect(blob.type).toBe('image/png'); + // Deterministic: same-size surfaces encode to identical bytes. + const again = await backend.encodeSurface(backend.createSurface(8, 4)); + expect(await blob.text()).toBe(await again.text()); + // Size participates in the encoding, so different sizes differ. + const other = await backend.encodeSurface(backend.createSurface(8, 5)); + expect(await other.text()).not.toBe(await blob.text()); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/raster.testStub.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/raster.testStub.ts new file mode 100644 index 00000000000..ed281a8671b --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/raster.testStub.ts @@ -0,0 +1,178 @@ +/** + * A node-safe `RasterBackend` stub for vitest. Surfaces are backed by a + * fake 2D context that records every call it receives instead of touching + * a real canvas, so engine tests can assert on what was drawn without a + * DOM or `OffscreenCanvas`. + * + * The subset of `CanvasRenderingContext2D` the engine currently uses is + * implemented as recorded methods (save/restore/clearRect/fillRect/ + * strokeRect/drawImage/path building/fill/stroke/clip/createPattern/ + * createLinearGradient/createRadialGradient/setTransform/ + * getImageData/putImageData/setLineDash). Gradients returned by the + * `create*Gradient` factories record their `addColorStop` calls on the same + * surface call log. Property assignments (fillStyle, + * globalAlpha, globalCompositeOperation, ...) are recorded too, via a + * `{ op: 'set', args: [prop, value] }` entry, so tests can assert that + * opacity/blend/style state was applied in order. Extend the method table in + * `createStubCtx` below as later tasks need more of the API surface. + */ + +import type { RasterBackend, RasterSurface } from './raster'; + +/** A single recorded call made against a stub context. */ +export interface RasterCallLogEntry { + op: string; + args: unknown[]; +} + +/** A `RasterSurface` created by the test stub backend, with its call log exposed. */ +export interface StubRasterSurface extends RasterSurface { + readonly callLog: RasterCallLogEntry[]; +} + +/** A `RasterBackend` whose `createSurface` returns the call-log-bearing `StubRasterSurface`. */ +export interface StubRasterBackend extends RasterBackend { + createSurface(width: number, height: number): StubRasterSurface; +} + +const createStubImageData = (width: number, height: number): ImageData => { + const data = new Uint8ClampedArray(Math.max(0, width) * Math.max(0, height) * 4); + return { colorSpace: 'srgb', data, height, width } as unknown as ImageData; +}; + +/** + * Deterministic per-character advance the stub's {@link measureText} multiplies + * by the current font's pixel size — the same `0.6` factor the text rasterizer's + * pure `estimateTextExtent` uses (`TEXT_CHAR_WIDTH_FACTOR`), so a text layer's + * measured surface size in node tests exactly matches its estimated extent. + */ +const STUB_CHAR_WIDTH_FACTOR = 0.6; + +/** Extracts the `px` size from a CSS `font` shorthand (e.g. `"700 48px Inter"` → 48), defaulting to 10. */ +const fontSizeFromShorthand = (font: unknown): number => { + const match = typeof font === 'string' ? /(\d+(?:\.\d+)?)px/.exec(font) : null; + return match ? parseFloat(match[1] ?? '10') : 10; +}; + +const createStubCtx = (callLog: RasterCallLogEntry[]): OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D => { + const log = (op: string, args: unknown[]): void => { + callLog.push({ args, op }); + }; + + // Stored non-method property values (fillStyle, globalAlpha, font, etc.). + // Declared before the method table so `measureText` can read the current + // `font` to produce font-size-dependent metrics. + const props: Record = {}; + + const methods: Record unknown> = { + arc: (...args: unknown[]) => log('arc', args), + beginPath: (...args: unknown[]) => log('beginPath', args), + clearRect: (...args: unknown[]) => log('clearRect', args), + clip: (...args: unknown[]) => log('clip', args), + closePath: (...args: unknown[]) => log('closePath', args), + fillText: (...args: unknown[]) => log('fillText', args), + // Deterministic, font-size-aware metrics: width = chars × fontSizePx × 0.6. + // No DOM/real canvas needed, so text measurement is reproducible in node. + measureText: (...args: unknown[]) => { + log('measureText', args); + const text = String(args[0] ?? ''); + const width = text.length * fontSizeFromShorthand(props.font) * STUB_CHAR_WIDTH_FACTOR; + return { width } as unknown as TextMetrics; + }, + createLinearGradient: (...args: unknown[]) => { + log('createLinearGradient', args); + // A recording CanvasGradient stand-in: every addColorStop is logged on + // the surface's own call log (as `addColorStop`), so tests can assert the + // stop offsets/colors that were applied to the gradient. + return { + addColorStop: (...stopArgs: unknown[]) => log('addColorStop', stopArgs), + } as unknown as CanvasGradient; + }, + createPattern: (...args: unknown[]) => { + log('createPattern', args); + // A non-null marker standing in for a CanvasPattern, so callers that + // guard on a null return (e.g. the checkerboard fill) proceed. + return { __stubPattern: true } as unknown as CanvasPattern; + }, + createRadialGradient: (...args: unknown[]) => { + log('createRadialGradient', args); + return { + addColorStop: (...stopArgs: unknown[]) => log('addColorStop', stopArgs), + } as unknown as CanvasGradient; + }, + drawImage: (...args: unknown[]) => log('drawImage', args), + ellipse: (...args: unknown[]) => log('ellipse', args), + fill: (...args: unknown[]) => log('fill', args), + fillRect: (...args: unknown[]) => log('fillRect', args), + getImageData: (...args: unknown[]) => { + const [sx, sy, sw, sh] = args as [number, number, number, number]; + log('getImageData', [sx, sy, sw, sh]); + return createStubImageData(sw, sh); + }, + lineTo: (...args: unknown[]) => log('lineTo', args), + moveTo: (...args: unknown[]) => log('moveTo', args), + putImageData: (...args: unknown[]) => log('putImageData', args), + rect: (...args: unknown[]) => log('rect', args), + restore: (...args: unknown[]) => log('restore', args), + save: (...args: unknown[]) => log('save', args), + setLineDash: (...args: unknown[]) => log('setLineDash', args), + setTransform: (...args: unknown[]) => log('setTransform', args), + stroke: (...args: unknown[]) => log('stroke', args), + strokeRect: (...args: unknown[]) => log('strokeRect', args), + }; + + // The proxy records every property assignment (into `props`, declared above) + // so tests can assert on applied state. + const proxy = new Proxy(methods, { + get(target, prop: string) { + if (prop in target) { + return target[prop]; + } + return props[prop]; + }, + set(_target, prop: string, value: unknown) { + props[prop] = value; + log('set', [prop, value]); + return true; + }, + }); + + return proxy as unknown as OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D; +}; + +class StubRasterSurfaceImpl implements StubRasterSurface { + readonly callLog: RasterCallLogEntry[] = []; + readonly canvas: OffscreenCanvas | HTMLCanvasElement; + readonly ctx: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D; + width: number; + height: number; + + constructor(width: number, height: number) { + this.width = width; + this.height = height; + this.canvas = { height, width } as unknown as OffscreenCanvas | HTMLCanvasElement; + this.ctx = createStubCtx(this.callLog); + } + + resize(w: number, h: number): void { + this.callLog.push({ args: [w, h], op: 'resize' }); + this.width = w; + this.height = h; + } +} + +/** + * Creates a `RasterBackend` whose surfaces are backed by a fake, node-safe + * 2D context that records draw calls instead of executing them. + */ +export const createTestStubRasterBackend = (): StubRasterBackend => ({ + createImageBitmap: (source: ImageBitmapSource): Promise => { + void source; + return Promise.resolve({ close: () => {}, height: 0, width: 0 } as unknown as ImageBitmap); + }, + createSurface: (width: number, height: number): StubRasterSurface => new StubRasterSurfaceImpl(width, height), + // Deterministic fake blob keyed on the surface size, so encode calls are + // reproducible in node without touching a real canvas. + encodeSurface: (surface: RasterSurface, type = 'image/png'): Promise => + Promise.resolve(new Blob([`stub-surface-${surface.width}x${surface.height}`], { type })), +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/raster.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/raster.ts new file mode 100644 index 00000000000..d9768165a2c --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/raster.ts @@ -0,0 +1,119 @@ +/** + * The RasterBackend seam: every place the engine needs a 2D canvas surface + * or an ImageBitmap, it goes through an injected `RasterBackend` instead of + * calling `document.createElement('canvas')` / `new OffscreenCanvas(...)` + * directly. This keeps the engine testable in node — tests inject + * `render/raster.testStub.ts`'s stub backend instead of `createDomRasterBackend()`. + */ + +/** A single drawable/resizable 2D surface, backed by either an OffscreenCanvas or an HTMLCanvasElement. */ +export interface RasterSurface { + readonly canvas: OffscreenCanvas | HTMLCanvasElement; + readonly ctx: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D; + readonly width: number; + readonly height: number; + resize(w: number, h: number): void; +} + +/** Injectable factory for raster surfaces and image bitmaps. */ +export interface RasterBackend { + createSurface(width: number, height: number): RasterSurface; + createImageBitmap(source: ImageBitmapSource): Promise; + /** + * Encodes a surface's pixels to an image `Blob` (PNG by default). Used by the + * bitmap store to persist painted layers as content-hashed server images. + */ + encodeSurface(surface: RasterSurface, type?: string): Promise; +} + +const isOffscreenCanvasSupported = (): boolean => typeof OffscreenCanvas !== 'undefined'; + +/** Encodes a DOM/Offscreen surface's canvas to an image `Blob`. */ +const encodeDomSurface = (surface: RasterSurface, type: string): Promise => { + const { canvas } = surface; + if (typeof (canvas as OffscreenCanvas).convertToBlob === 'function') { + return (canvas as OffscreenCanvas).convertToBlob({ type }); + } + const htmlCanvas = canvas as HTMLCanvasElement; + return new Promise((resolve, reject) => { + htmlCanvas.toBlob((blob) => { + if (blob) { + resolve(blob); + } else { + reject(new Error('Failed to encode canvas surface to a Blob')); + } + }, type); + }); +}; + +class OffscreenRasterSurface implements RasterSurface { + canvas: OffscreenCanvas; + ctx: OffscreenCanvasRenderingContext2D; + width: number; + height: number; + + constructor(width: number, height: number) { + this.canvas = new OffscreenCanvas(width, height); + const ctx = this.canvas.getContext('2d'); + if (!ctx) { + throw new Error('Failed to acquire a 2D context from OffscreenCanvas'); + } + this.ctx = ctx; + this.width = width; + this.height = height; + } + + resize(w: number, h: number): void { + this.canvas.width = w; + this.canvas.height = h; + this.width = w; + this.height = h; + } +} + +class DomCanvasRasterSurface implements RasterSurface { + canvas: HTMLCanvasElement; + ctx: CanvasRenderingContext2D; + width: number; + height: number; + + constructor(width: number, height: number) { + this.canvas = document.createElement('canvas'); + this.canvas.width = width; + this.canvas.height = height; + const ctx = this.canvas.getContext('2d'); + if (!ctx) { + throw new Error('Failed to acquire a 2D context from HTMLCanvasElement'); + } + this.ctx = ctx; + this.width = width; + this.height = height; + } + + resize(w: number, h: number): void { + this.canvas.width = w; + this.canvas.height = h; + this.width = w; + this.height = h; + } +} + +/** + * Creates a `RasterBackend` backed by the DOM/browser: `OffscreenCanvas` + * when available, falling back to `HTMLCanvasElement` otherwise (notably + * Safari < 16.4, which lacks `OffscreenCanvas` support). + */ +export const createDomRasterBackend = (): RasterBackend => { + const useOffscreen = isOffscreenCanvasSupported(); + return { + createSurface(width: number, height: number): RasterSurface { + return useOffscreen ? new OffscreenRasterSurface(width, height) : new DomCanvasRasterSurface(width, height); + }, + createImageBitmap(source: ImageBitmapSource): Promise { + return createImageBitmap(source); + }, + encodeSurface(surface: RasterSurface, type = 'image/png'): Promise { + return encodeDomSurface(surface, type); + }, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/gradientRasterizer.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/gradientRasterizer.test.ts new file mode 100644 index 00000000000..ba15212d22a --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/gradientRasterizer.test.ts @@ -0,0 +1,112 @@ +import type { StubRasterSurface } from '@workbench/canvas-engine/render/raster.testStub'; +import type { CanvasLayerSourceContract } from '@workbench/types'; + +import { createLayerCacheStore } from '@workbench/canvas-engine/render/layerCache'; +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { describe, expect, it } from 'vitest'; + +import type { RasterizeDeps } from './types'; + +import { rasterizeGradientSource } from './gradientRasterizer'; + +type GradientSource = Extract; + +const makeDeps = (): RasterizeDeps => { + const backend = createTestStubRasterBackend(); + return { + backend, + documentSize: { height: 200, width: 300 }, + resolver: () => Promise.resolve(new Blob()), + store: createLayerCacheStore(backend), + }; +}; + +const ops = (surface: StubRasterSurface): string[] => surface.callLog.map((e) => e.op); + +const grad = (over: Partial = {}): GradientSource => ({ + angle: 0, + kind: 'linear', + stops: [ + { color: '#000000', offset: 0 }, + { color: '#ffffff', offset: 1 }, + ], + type: 'gradient', + ...over, +}); + +describe('rasterizeGradientSource — extent', () => { + it('defaults to the DOCUMENT extent for a legacy gradient (no explicit width/height)', async () => { + const deps = makeDeps(); + const { rect, surface } = await rasterizeGradientSource(grad(), deps); + expect(surface.width).toBe(300); + expect(surface.height).toBe(200); + expect(rect).toEqual({ height: 200, width: 300, x: 0, y: 0 }); + expect(ops(surface as StubRasterSurface)).toContain('fillRect'); + }); + + it('uses the explicit width/height extent when present (content-sized)', async () => { + const deps = makeDeps(); + const { rect, surface } = await rasterizeGradientSource(grad({ height: 90, width: 120 }), deps); + expect(surface.width).toBe(120); + expect(surface.height).toBe(90); + expect(rect).toEqual({ height: 90, width: 120, x: 0, y: 0 }); + }); +}); + +describe('rasterizeGradientSource — linear', () => { + it('creates a linear gradient and applies every stop', async () => { + const deps = makeDeps(); + const surface = (await rasterizeGradientSource(grad({ angle: 90 }), deps)).surface as StubRasterSurface; + expect(ops(surface)).toContain('createLinearGradient'); + const stops = surface.callLog.filter((e) => e.op === 'addColorStop'); + expect(stops).toHaveLength(2); + expect(stops[0]?.args).toEqual([0, '#000000']); + expect(stops[1]?.args).toEqual([1, '#ffffff']); + }); + + it('does not create a radial gradient for the linear kind', async () => { + const deps = makeDeps(); + const surface = (await rasterizeGradientSource(grad(), deps)).surface as StubRasterSurface; + expect(ops(surface)).not.toContain('createRadialGradient'); + }); +}); + +describe('rasterizeGradientSource — radial', () => { + it('creates a radial gradient covering the document and applies stops', async () => { + const deps = makeDeps(); + const surface = (await rasterizeGradientSource(grad({ kind: 'radial' }), deps)).surface as StubRasterSurface; + expect(ops(surface)).toContain('createRadialGradient'); + expect(ops(surface)).not.toContain('createLinearGradient'); + const stops = surface.callLog.filter((e) => e.op === 'addColorStop'); + expect(stops).toHaveLength(2); + }); + + it('supports more than two stops', async () => { + const deps = makeDeps(); + const surface = ( + await rasterizeGradientSource( + grad({ + stops: [ + { color: '#000000', offset: 0 }, + { color: '#ff0000', offset: 0.5 }, + { color: '#ffffff', offset: 1 }, + ], + }), + deps + ) + ).surface as StubRasterSurface; + const stops = surface.callLog.filter((e) => e.op === 'addColorStop'); + expect(stops).toHaveLength(3); + }); +}); + +describe('rasterizeGradientSource — target reuse', () => { + it('resizes and reuses a provided target surface to the document size', async () => { + const deps = makeDeps(); + const target = deps.backend.createSurface(10, 10); + const { surface } = await rasterizeGradientSource(grad(), deps, target); + expect(surface).toBe(target); + expect(surface.width).toBe(300); + expect(surface.height).toBe(200); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/gradientRasterizer.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/gradientRasterizer.ts new file mode 100644 index 00000000000..fc314890140 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/gradientRasterizer.ts @@ -0,0 +1,78 @@ +/** + * Rasterizes a `gradient` layer source (linear / radial). Gradient layers are + * PARAMETRIC and CONTENT-SIZED: the source carries an explicit `width`/`height` + * extent (set bbox-sized at creation, preserved across angle edits) and the + * gradient spans that extent. Legacy gradients that predate the extent field + * default to the document dims (see {@link RasterizeDeps.documentSize}), so they + * render identically. The compositor positions the extent via the layer transform. + * + * Linear: the gradient line runs through the extent center along `angle` + * (degrees; 0° = left→right), long enough to cover the box corners. Radial: + * centered on the extent, radius reaching the farthest corner. + * + * Zero React, zero import-time side effects. + */ + +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { CanvasLayerSourceContract } from '@workbench/types'; + +import type { RasterizeDeps, RasterizeResult } from './types'; + +type GradientSource = Extract; +type Ctx = RasterSurface['ctx']; + +/** Builds the CSS-like gradient for the source across a `width`×`height` box. */ +const buildGradient = (ctx: Ctx, source: GradientSource, width: number, height: number): CanvasGradient => { + const cx = width / 2; + const cy = height / 2; + + let gradient: CanvasGradient; + if (source.kind === 'radial') { + const radius = Math.hypot(width / 2, height / 2); + gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius); + } else { + const rad = (source.angle * Math.PI) / 180; + const dx = Math.cos(rad); + const dy = Math.sin(rad); + // Half-length along the direction that just covers the box (projection of + // the box's half-extents onto the gradient direction). + const half = (Math.abs(dx) * width + Math.abs(dy) * height) / 2; + gradient = ctx.createLinearGradient(cx - dx * half, cy - dy * half, cx + dx * half, cy + dy * half); + } + + for (const stop of source.stops) { + // Clamp offsets defensively: `addColorStop` throws for out-of-range values. + gradient.addColorStop(Math.min(1, Math.max(0, stop.offset)), stop.color); + } + return gradient; +}; + +/** + * Draws a gradient source into a surface sized to its explicit extent (or the + * document dims for legacy gradients), reusing `target` if provided. Synchronous + * work wrapped in a resolved promise so it shares the `rasterizeSource` dispatch + * signature. + */ +export const rasterizeGradientSource = ( + source: GradientSource, + deps: RasterizeDeps, + target?: RasterSurface +): Promise => { + const width = Math.max(1, Math.round(source.width ?? deps.documentSize.width)); + const height = Math.max(1, Math.round(source.height ?? deps.documentSize.height)); + + const surface = target ?? deps.backend.createSurface(width, height); + if (surface.width !== width || surface.height !== height) { + surface.resize(width, height); + } + const { ctx } = surface; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, width, height); + + if (source.stops.length > 0) { + ctx.fillStyle = buildGradient(ctx, source, width, height); + ctx.fillRect(0, 0, width, height); + } + + return Promise.resolve({ rect: { height, width, x: 0, y: 0 }, surface }); +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/imageRasterizer.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/imageRasterizer.ts new file mode 100644 index 00000000000..3057e1f72a4 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/imageRasterizer.ts @@ -0,0 +1,62 @@ +/** + * Rasterizes an `image` layer source: decodes the referenced asset to an + * `ImageBitmap` (cached per image name in the store) and blits it onto a + * surface sized to the bitmap. + * + * Decoding is async and goes entirely through injected seams (the resolver + * for bytes, the backend for `createImageBitmap`), so it runs in node tests + * with fakes. Zero React, zero import-time side effects. + */ + +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { CanvasImageRef } from '@workbench/types'; + +import type { RasterizeDeps, RasterizeResult } from './types'; + +/** Decodes and caches a bitmap for the given image reference, reusing the store cache. */ +export const resolveBitmap = async (image: CanvasImageRef, deps: RasterizeDeps): Promise => { + const cached = deps.store.getBitmap(image.imageName); + if (cached) { + return cached; + } + const blob = await deps.resolver(image.imageName); + const bitmap = await deps.backend.createImageBitmap(blob); + // Another concurrent decode may have populated the cache while we awaited; + // prefer the already-cached bitmap and close ours to avoid a leak. + const raced = deps.store.getBitmap(image.imageName); + if (raced) { + bitmap.close(); + return raced; + } + deps.store.setBitmap(image.imageName, bitmap); + return bitmap; +}; + +/** Draws a bitmap onto a surface sized to `width`x`height`, reusing `target` if given. */ +export const blitBitmap = ( + bitmap: ImageBitmap, + width: number, + height: number, + deps: RasterizeDeps, + target?: RasterSurface +): RasterSurface => { + const surface = target ?? deps.backend.createSurface(width, height); + if (surface.width !== width || surface.height !== height) { + surface.resize(width, height); + } + surface.ctx.clearRect(0, 0, width, height); + surface.ctx.drawImage(bitmap, 0, 0); + return surface; +}; + +/** Rasterizes an `image` source into a surface sized to the image ref. */ +export const rasterizeImageSource = async ( + source: { type: 'image'; image: CanvasImageRef }, + deps: RasterizeDeps, + target?: RasterSurface +): Promise => { + const bitmap = await resolveBitmap(source.image, deps); + const { height, width } = source.image; + const surface = blitBitmap(bitmap, width, height, deps, target); + return { rect: { height, width, x: 0, y: 0 }, surface }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/index.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/index.ts new file mode 100644 index 00000000000..76c0c33676e --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/index.ts @@ -0,0 +1,54 @@ +/** + * Source rasterizer dispatch. + * + * Given a layer `source`, produces a {@link RasterSurface} holding its + * rasterized pixels. `image`, `paint`, `shape`, `gradient`, and `text` are + * implemented. A `polygon` shape (no points-editing UX yet) throws — the + * dispatch only routes rect/ellipse. + * + * Zero React, zero import-time side effects. + */ + +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { CanvasLayerSourceContract } from '@workbench/types'; + +import type { RasterizeDeps, RasterizeResult } from './types'; + +import { rasterizeGradientSource } from './gradientRasterizer'; +import { rasterizeImageSource } from './imageRasterizer'; +import { rasterizePaintSource } from './paintRasterizer'; +import { rasterizeShapeSource } from './shapeRasterizer'; +import { rasterizeTextSource } from './textRasterizer'; + +export type { ImageResolver, RasterizeDeps, RasterizeResult } from './types'; +export { rasterizeGradientSource } from './gradientRasterizer'; +export { rasterizeImageSource } from './imageRasterizer'; +export { rasterizePaintSource } from './paintRasterizer'; +export { rasterizeShapeSource } from './shapeRasterizer'; +export { rasterizeTextSource } from './textRasterizer'; + +/** + * Rasterizes any supported layer source into a surface. Throws only for the + * deferred `polygon` shape kind (no rasterizer yet). + */ +export const rasterizeSource = ( + source: CanvasLayerSourceContract, + deps: RasterizeDeps, + target?: RasterSurface +): Promise => { + switch (source.type) { + case 'image': + return rasterizeImageSource(source, deps, target); + case 'paint': + return rasterizePaintSource(source, deps, target); + case 'shape': + if (source.kind === 'polygon') { + throw new Error("rasterizeSource: 'polygon' shapes are not implemented yet (deferred)"); + } + return rasterizeShapeSource(source, deps, target); + case 'gradient': + return rasterizeGradientSource(source, deps, target); + case 'text': + return rasterizeTextSource(source, deps, target); + } +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/paintRasterizer.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/paintRasterizer.ts new file mode 100644 index 00000000000..ee98b0a0246 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/paintRasterizer.ts @@ -0,0 +1,43 @@ +/** + * Rasterizes a `paint` layer source. Paint layers are CONTENT-SIZED: the + * persisted bitmap covers only the painted region, positioned in the layer's + * local space by the source `offset` (default `{ x: 0, y: 0 }` for legacy + * document-sized bitmaps). When the source has a bitmap it is decoded and + * blitted onto a surface sized to the bitmap; when it is `null` the layer is + * empty and the result is a zero-rect surface (a brand-new / cleared layer). + * + * Zero React, zero import-time side effects. + */ + +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { CanvasImageRef } from '@workbench/types'; + +import type { RasterizeDeps, RasterizeResult } from './types'; + +import { blitBitmap, resolveBitmap } from './imageRasterizer'; + +/** A `paint` source (its optional layer-local bitmap offset). */ +type PaintSource = { type: 'paint'; bitmap: CanvasImageRef | null; offset?: { x: number; y: number } }; + +/** Rasterizes a `paint` source into a content-sized surface placed at its offset. */ +export const rasterizePaintSource = async ( + source: PaintSource, + deps: RasterizeDeps, + target?: RasterSurface +): Promise => { + if (source.bitmap) { + const { height, width } = source.bitmap; + const offset = source.offset ?? { x: 0, y: 0 }; + const bitmap = await resolveBitmap(source.bitmap, deps); + const surface = blitBitmap(bitmap, width, height, deps, target); + return { rect: { height, width, x: offset.x, y: offset.y }, surface }; + } + + // No bitmap yet: an empty (zero-rect) layer. Collapse any reused target to 0×0 + // so the empty-layer invariants (skip in composite/hit-test/flush) hold. + const surface = target ?? deps.backend.createSurface(0, 0); + if (surface.width !== 0 || surface.height !== 0) { + surface.resize(0, 0); + } + return { rect: { height: 0, width: 0, x: 0, y: 0 }, surface }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/rasterizeSource.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/rasterizeSource.test.ts new file mode 100644 index 00000000000..cc9573df8b3 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/rasterizeSource.test.ts @@ -0,0 +1,169 @@ +import type { RasterBackend } from '@workbench/canvas-engine/render/raster'; +import type { CanvasImageRef, CanvasLayerSourceContract } from '@workbench/types'; + +import { createLayerCacheStore } from '@workbench/canvas-engine/render/layerCache'; +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { describe, expect, it, vi } from 'vitest'; + +import type { ImageResolver, RasterizeDeps } from './types'; + +import { rasterizeSource } from './index'; + +const imageRef = (imageName: string, width = 32, height = 16): CanvasImageRef => ({ height, imageName, width }); + +/** A backend whose `createImageBitmap` is a spy returning a closable fake bitmap. */ +const createSpyBackend = () => { + const stub = createTestStubRasterBackend(); + const createImageBitmap = vi.fn((source: ImageBitmapSource): Promise => { + void source; + return Promise.resolve({ close: vi.fn(), height: 16, width: 32 } as unknown as ImageBitmap); + }); + const backend: RasterBackend = { + createImageBitmap, + createSurface: stub.createSurface, + encodeSurface: stub.encodeSurface, + }; + return { backend, createImageBitmap, createSurface: stub.createSurface }; +}; + +const makeDeps = (resolver: ImageResolver, backend: RasterBackend): RasterizeDeps => ({ + backend, + documentSize: { height: 200, width: 300 }, + resolver, + store: createLayerCacheStore(backend), +}); + +describe('rasterizeSource — image', () => { + it('decodes via the resolver + backend and caches the bitmap per image name', async () => { + const { backend, createImageBitmap } = createSpyBackend(); + const resolver = vi.fn(() => Promise.resolve(new Blob())); + const deps = makeDeps(resolver, backend); + const source: CanvasLayerSourceContract = { image: imageRef('cat'), type: 'image' }; + + const resultA = await rasterizeSource(source, deps); + const resultB = await rasterizeSource(source, deps); + + // Second call reuses the cached bitmap: no extra resolve/decode. + expect(resolver).toHaveBeenCalledTimes(1); + expect(createImageBitmap).toHaveBeenCalledTimes(1); + expect(deps.store.getBitmap('cat')).toBeDefined(); + + // Surface sized to the image ref; content rect at the origin. + expect(resultA.surface.width).toBe(32); + expect(resultA.surface.height).toBe(16); + expect(resultA.rect).toEqual({ height: 16, width: 32, x: 0, y: 0 }); + expect(resultB.surface.width).toBe(32); + }); + + it('draws the decoded bitmap onto the surface', async () => { + const { backend } = createSpyBackend(); + const resolver = vi.fn(() => Promise.resolve(new Blob())); + const deps = makeDeps(resolver, backend); + + const { surface } = await rasterizeSource({ image: imageRef('dog'), type: 'image' }, deps); + const ops = (surface as ReturnType['createSurface']>).callLog.map( + (e) => e.op + ); + expect(ops).toContain('clearRect'); + expect(ops).toContain('drawImage'); + }); +}); + +describe('rasterizeSource — paint', () => { + it('null bitmap produces an EMPTY (zero-rect) surface (no drawImage)', async () => { + const { backend, createImageBitmap } = createSpyBackend(); + const resolver = vi.fn(() => Promise.resolve(new Blob())); + const deps = makeDeps(resolver, backend); + + const { rect, surface } = await rasterizeSource({ bitmap: null, type: 'paint' }, deps); + + // Content-sized: an empty paint layer holds no pixels — a zero rect. + expect(rect).toEqual({ height: 0, width: 0, x: 0, y: 0 }); + expect(surface.width).toBe(0); + expect(surface.height).toBe(0); + expect(resolver).not.toHaveBeenCalled(); + expect(createImageBitmap).not.toHaveBeenCalled(); + const ops = (surface as ReturnType['createSurface']>).callLog.map( + (e) => e.op + ); + expect(ops).not.toContain('drawImage'); + }); + + it('a paint bitmap is decoded and blitted onto a content-sized surface at its offset', async () => { + const { backend, createImageBitmap } = createSpyBackend(); + const resolver = vi.fn(() => Promise.resolve(new Blob())); + const deps = makeDeps(resolver, backend); + + const { rect, surface } = await rasterizeSource( + { bitmap: imageRef('brush'), offset: { x: 40, y: 25 }, type: 'paint' }, + deps + ); + + // Sized to the bitmap dims, placed at the persisted offset. + expect(surface.width).toBe(32); + expect(surface.height).toBe(16); + expect(rect).toEqual({ height: 16, width: 32, x: 40, y: 25 }); + expect(createImageBitmap).toHaveBeenCalledTimes(1); + }); + + it('a paint bitmap without an offset defaults to origin (0,0) — legacy back-compat', async () => { + const { backend } = createSpyBackend(); + const resolver = vi.fn(() => Promise.resolve(new Blob())); + const deps = makeDeps(resolver, backend); + + const { rect } = await rasterizeSource({ bitmap: imageRef('brush'), type: 'paint' }, deps); + expect(rect).toEqual({ height: 16, width: 32, x: 0, y: 0 }); + }); +}); + +describe('rasterizeSource — unimplemented sources', () => { + it('throws for the deferred polygon shape kind', () => { + const { backend } = createSpyBackend(); + const resolver = vi.fn(() => Promise.resolve(new Blob())); + const deps = makeDeps(resolver, backend); + const source = { + fill: '#000000', + height: 10, + kind: 'polygon', + stroke: null, + strokeWidth: 0, + type: 'shape', + width: 10, + } as unknown as CanvasLayerSourceContract; + expect(() => rasterizeSource(source, deps)).toThrow(/not implemented/i); + }); + + it('rasterizes shape, gradient, and text sources (no longer throwing)', async () => { + const { backend } = createSpyBackend(); + const resolver = vi.fn(() => Promise.resolve(new Blob())); + const deps = makeDeps(resolver, backend); + const shape = { + fill: '#000000', + height: 10, + kind: 'rect', + stroke: null, + strokeWidth: 0, + type: 'shape', + width: 10, + } as CanvasLayerSourceContract; + const gradient = { + angle: 0, + kind: 'linear', + stops: [{ color: '#000000', offset: 0 }], + type: 'gradient', + } as CanvasLayerSourceContract; + const text = { + align: 'left', + color: '#000000', + content: 'hi', + fontFamily: 'Inter', + fontSize: 20, + fontWeight: 400, + lineHeight: 1.2, + type: 'text', + } as CanvasLayerSourceContract; + await expect(rasterizeSource(shape, deps)).resolves.toBeDefined(); + await expect(rasterizeSource(gradient, deps)).resolves.toBeDefined(); + await expect(rasterizeSource(text, deps)).resolves.toBeDefined(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/shapeRasterizer.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/shapeRasterizer.test.ts new file mode 100644 index 00000000000..479963d8771 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/shapeRasterizer.test.ts @@ -0,0 +1,111 @@ +import type { StubRasterSurface } from '@workbench/canvas-engine/render/raster.testStub'; +import type { CanvasLayerSourceContract } from '@workbench/types'; + +import { createLayerCacheStore } from '@workbench/canvas-engine/render/layerCache'; +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { describe, expect, it } from 'vitest'; + +import type { RasterizeDeps } from './types'; + +import { rasterizeShapeSource } from './shapeRasterizer'; + +type ShapeSource = Extract; + +const makeDeps = (): RasterizeDeps => { + const backend = createTestStubRasterBackend(); + return { + backend, + documentSize: { height: 200, width: 300 }, + resolver: () => Promise.resolve(new Blob()), + store: createLayerCacheStore(backend), + }; +}; + +const ops = (surface: StubRasterSurface): string[] => surface.callLog.map((e) => e.op); + +const rect = (over: Partial = {}): ShapeSource => ({ + fill: '#ff0000', + height: 40, + kind: 'rect', + stroke: null, + strokeWidth: 0, + type: 'shape', + width: 60, + ...over, +}); + +describe('rasterizeShapeSource — extent + sizing', () => { + it('sizes the surface to the source width/height (not the document)', async () => { + const deps = makeDeps(); + const surface = (await rasterizeShapeSource(rect(), deps)).surface as StubRasterSurface; + expect(surface.width).toBe(60); + expect(surface.height).toBe(40); + }); + + it('clears before drawing so re-rasterizing into a reused target is clean', async () => { + const deps = makeDeps(); + const surface = (await rasterizeShapeSource(rect(), deps)).surface as StubRasterSurface; + expect(ops(surface)).toContain('clearRect'); + }); +}); + +describe('rasterizeShapeSource — rect', () => { + it('fills a rect covering the full extent when fill is set and no stroke', async () => { + const deps = makeDeps(); + const surface = (await rasterizeShapeSource(rect({ fill: '#00ff00', stroke: null }), deps)) + .surface as StubRasterSurface; + const log = surface.callLog; + // Fill style applied and a rect path filled. + expect(log.some((e) => e.op === 'set' && e.args[0] === 'fillStyle' && e.args[1] === '#00ff00')).toBe(true); + expect(ops(surface)).toContain('fill'); + // No stroke was drawn. + expect(ops(surface)).not.toContain('stroke'); + }); + + it('strokes with the stroke color + width, inset so it stays within the extent', async () => { + const deps = makeDeps(); + const surface = (await rasterizeShapeSource(rect({ fill: null, stroke: '#0000ff', strokeWidth: 8 }), deps)) + .surface as StubRasterSurface; + const log = surface.callLog; + expect(log.some((e) => e.op === 'set' && e.args[0] === 'strokeStyle' && e.args[1] === '#0000ff')).toBe(true); + expect(log.some((e) => e.op === 'set' && e.args[0] === 'lineWidth' && e.args[1] === 8)).toBe(true); + expect(ops(surface)).toContain('stroke'); + expect(ops(surface)).not.toContain('fill'); + }); + + it('draws both fill and stroke when both are set', async () => { + const deps = makeDeps(); + const surface = (await rasterizeShapeSource(rect({ fill: '#111111', stroke: '#222222', strokeWidth: 4 }), deps)) + .surface as StubRasterSurface; + expect(ops(surface)).toContain('fill'); + expect(ops(surface)).toContain('stroke'); + }); + + it('draws nothing when both fill and stroke are null', async () => { + const deps = makeDeps(); + const surface = (await rasterizeShapeSource(rect({ fill: null, stroke: null }), deps)).surface as StubRasterSurface; + expect(ops(surface)).not.toContain('fill'); + expect(ops(surface)).not.toContain('stroke'); + }); +}); + +describe('rasterizeShapeSource — ellipse', () => { + it('draws an ellipse path (not a rect) for the ellipse kind', async () => { + const deps = makeDeps(); + const surface = (await rasterizeShapeSource(rect({ fill: '#abcdef', kind: 'ellipse' }), deps)) + .surface as StubRasterSurface; + expect(ops(surface)).toContain('ellipse'); + expect(ops(surface)).toContain('fill'); + }); +}); + +describe('rasterizeShapeSource — target reuse', () => { + it('resizes and reuses a provided target surface', async () => { + const deps = makeDeps(); + const target = deps.backend.createSurface(10, 10); + const { surface } = await rasterizeShapeSource(rect({ height: 40, width: 60 }), deps, target); + expect(surface).toBe(target); + expect(surface.width).toBe(60); + expect(surface.height).toBe(40); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/shapeRasterizer.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/shapeRasterizer.ts new file mode 100644 index 00000000000..c4150d75fc0 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/shapeRasterizer.ts @@ -0,0 +1,79 @@ +/** + * Rasterizes a `shape` layer source (rect / ellipse). Shape layers are + * PARAMETRIC: their pixels are derived from the source params (`width`, + * `height`, `fill`, `stroke`, `strokeWidth`, `kind`) rather than a persisted + * bitmap, so they re-render for free whenever a param changes. + * + * Extent semantics: a shape's surface is sized to the source's own + * `width`×`height` (its layer-local extent), NOT the document — the compositor + * applies the layer transform (position/scale/rotation) when drawing, exactly + * like an `image` layer. The stroke is drawn INSET by `strokeWidth / 2` so a + * thick outline stays entirely within the extent rather than clipping at the + * surface edge. + * + * `polygon` is intentionally NOT handled here (deferred until a points-editing + * UX exists); the dispatch never routes a polygon source to this rasterizer in + * this phase. + * + * Zero React, zero import-time side effects. + */ + +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { CanvasLayerSourceContract } from '@workbench/types'; + +import type { RasterizeDeps, RasterizeResult } from './types'; + +type ShapeSource = Extract; +type Ctx = RasterSurface['ctx']; + +/** Builds the rect/ellipse path for `kind` inset by `inset` on every side into the current path. */ +const buildShapePath = (ctx: Ctx, kind: ShapeSource['kind'], width: number, height: number, inset: number): void => { + const w = Math.max(0, width - inset * 2); + const h = Math.max(0, height - inset * 2); + ctx.beginPath(); + if (kind === 'ellipse') { + ctx.ellipse(width / 2, height / 2, w / 2, h / 2, 0, 0, Math.PI * 2); + return; + } + // `rect` (and, defensively, `polygon` which the dispatch never sends here). + ctx.rect(inset, inset, w, h); +}; + +/** + * Draws a shape source onto a surface sized to the source extent. Reuses + * `target` if provided (resizing it to the extent), matching the paint/image + * rasterizer contract. Synchronous work wrapped in a resolved promise so it + * shares the `rasterizeSource` dispatch signature. + */ +export const rasterizeShapeSource = ( + source: ShapeSource, + deps: RasterizeDeps, + target?: RasterSurface +): Promise => { + const width = Math.max(1, Math.round(source.width)); + const height = Math.max(1, Math.round(source.height)); + + const surface = target ?? deps.backend.createSurface(width, height); + if (surface.width !== width || surface.height !== height) { + surface.resize(width, height); + } + const { ctx } = surface; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, width, height); + + if (source.fill) { + ctx.fillStyle = source.fill; + buildShapePath(ctx, source.kind, width, height, 0); + ctx.fill(); + } + + if (source.stroke && source.strokeWidth > 0) { + ctx.strokeStyle = source.stroke; + ctx.lineWidth = source.strokeWidth; + // Inset by half the stroke width so the (centered) stroke stays inside the extent. + buildShapePath(ctx, source.kind, width, height, source.strokeWidth / 2); + ctx.stroke(); + } + + return Promise.resolve({ rect: { height, width, x: 0, y: 0 }, surface }); +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/textRasterizer.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/textRasterizer.test.ts new file mode 100644 index 00000000000..d0556ace40c --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/textRasterizer.test.ts @@ -0,0 +1,133 @@ +import type { RasterCallLogEntry, StubRasterSurface } from '@workbench/canvas-engine/render/raster.testStub'; + +import { createLayerCacheStore } from '@workbench/canvas-engine/render/layerCache'; +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { describe, expect, it } from 'vitest'; + +import type { TextSource } from './textRasterizer'; +import type { RasterizeDeps } from './types'; + +import { estimateTextExtent, rasterizeTextSource, TEXT_CHAR_WIDTH_FACTOR, textFontString } from './textRasterizer'; + +const makeDeps = (): RasterizeDeps => { + const backend = createTestStubRasterBackend(); + return { + backend, + documentSize: { height: 200, width: 300 }, + resolver: () => Promise.resolve(new Blob()), + store: createLayerCacheStore(backend), + }; +}; + +const ops = (surface: StubRasterSurface): string[] => surface.callLog.map((e) => e.op); +const entries = (surface: StubRasterSurface, op: string): RasterCallLogEntry[] => + surface.callLog.filter((e) => e.op === op); + +const text = (over: Partial = {}): TextSource => ({ + align: 'left', + color: '#112233', + content: 'hello', + fontFamily: 'Inter', + fontSize: 20, + fontWeight: 400, + lineHeight: 1.5, + type: 'text', + ...over, +}); + +// The stub's deterministic metric: width = chars × fontSizePx × TEXT_CHAR_WIDTH_FACTOR. +const stubWidth = (chars: number, fontSize: number): number => Math.ceil(chars * fontSize * TEXT_CHAR_WIDTH_FACTOR); + +describe('rasterizeTextSource — measurement + extent', () => { + it('sizes the surface to the measured block (widest line × fontSize×0.6, lines × fontSize×lineHeight)', async () => { + const deps = makeDeps(); + const source = text({ content: 'hello', fontSize: 20, lineHeight: 1.5 }); + const surface = (await rasterizeTextSource(source, deps)).surface as StubRasterSurface; + expect(surface.width).toBe(stubWidth(5, 20)); // 'hello' = 5 chars → 60 + expect(surface.height).toBe(Math.ceil(1 * 20 * 1.5)); // one line → 30 + }); + + it('agrees with the pure estimateTextExtent (cache-size stability)', async () => { + const deps = makeDeps(); + const source = text({ content: 'abc\ndefgh', fontSize: 16, lineHeight: 1.2 }); + const surface = (await rasterizeTextSource(source, deps)).surface as StubRasterSurface; + const estimate = estimateTextExtent(source); + expect(surface.width).toBe(estimate.width); + expect(surface.height).toBe(estimate.height); + }); + + it('clears before drawing and applies the font + fill color', async () => { + const deps = makeDeps(); + const surface = (await rasterizeTextSource(text({ color: '#abcdef' }), deps)).surface as StubRasterSurface; + expect(ops(surface)).toContain('clearRect'); + expect( + surface.callLog.some((e) => e.op === 'set' && e.args[0] === 'font' && e.args[1] === textFontString(text())) + ).toBe(true); + expect(surface.callLog.some((e) => e.op === 'set' && e.args[0] === 'fillStyle' && e.args[1] === '#abcdef')).toBe( + true + ); + }); +}); + +describe('rasterizeTextSource — line breaks', () => { + it('draws one fillText per manual line, stepped by fontSize×lineHeight', async () => { + const deps = makeDeps(); + const surface = (await rasterizeTextSource(text({ content: 'a\nbb\nccc', fontSize: 10, lineHeight: 2 }), deps)) + .surface as StubRasterSurface; + const fills = entries(surface, 'fillText'); + expect(fills).toHaveLength(3); + // step = 10 × 2 = 20; y anchors at 0, 20, 40. + expect(fills.map((e) => e.args[2])).toEqual([0, 20, 40]); + expect(fills.map((e) => e.args[0])).toEqual(['a', 'bb', 'ccc']); + // Height covers all three lines. + expect(surface.height).toBe(60); + }); +}); + +describe('rasterizeTextSource — alignment', () => { + it('left-aligns at x=0', async () => { + const deps = makeDeps(); + const surface = (await rasterizeTextSource(text({ align: 'left' }), deps)).surface as StubRasterSurface; + expect(surface.callLog.some((e) => e.op === 'set' && e.args[0] === 'textAlign' && e.args[1] === 'left')).toBe(true); + expect(entries(surface, 'fillText')[0]?.args[1]).toBe(0); + }); + + it('center-aligns at x=width/2', async () => { + const deps = makeDeps(); + const source = text({ align: 'center', content: 'hello', fontSize: 20 }); + const surface = (await rasterizeTextSource(source, deps)).surface as StubRasterSurface; + expect(surface.callLog.some((e) => e.op === 'set' && e.args[0] === 'textAlign' && e.args[1] === 'center')).toBe( + true + ); + expect(entries(surface, 'fillText')[0]?.args[1]).toBe(stubWidth(5, 20) / 2); + }); + + it('right-aligns at x=width', async () => { + const deps = makeDeps(); + const source = text({ align: 'right', content: 'hello', fontSize: 20 }); + const surface = (await rasterizeTextSource(source, deps)).surface as StubRasterSurface; + expect(surface.callLog.some((e) => e.op === 'set' && e.args[0] === 'textAlign' && e.args[1] === 'right')).toBe( + true + ); + expect(entries(surface, 'fillText')[0]?.args[1]).toBe(stubWidth(5, 20)); + }); +}); + +describe('rasterizeTextSource — empty + reuse', () => { + it('produces a minimal 1×lineHeight surface for empty content', async () => { + const deps = makeDeps(); + const surface = (await rasterizeTextSource(text({ content: '', fontSize: 20, lineHeight: 1.5 }), deps)) + .surface as StubRasterSurface; + expect(surface.width).toBe(1); + expect(surface.height).toBe(30); + }); + + it('resizes and reuses a provided target surface', async () => { + const deps = makeDeps(); + const target = deps.backend.createSurface(5, 5); + const source = text({ content: 'hello', fontSize: 20 }); + const { surface } = await rasterizeTextSource(source, deps, target); + expect(surface).toBe(target); + expect(surface.width).toBe(stubWidth(5, 20)); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/textRasterizer.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/textRasterizer.ts new file mode 100644 index 00000000000..63a57d3a335 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/textRasterizer.ts @@ -0,0 +1,136 @@ +/** + * Rasterizes a `text` layer source. Text layers are PARAMETRIC and + * EDITABLE-FOREVER: their pixels are derived from the source params (`content`, + * `fontFamily`, `fontSize`, `fontWeight`, `lineHeight`, `align`, `color`) rather + * than a persisted bitmap, so a style/content edit re-renders for free — the + * headline upgrade over legacy's rasterized (frozen) text. + * + * Layout is deliberately SIMPLE (Risk 8): manual line breaks on `\n`, a + * `lineHeight` multiplier over `fontSize`, and per-line horizontal alignment + * within the measured block width. There is NO shaping engine — no BiDi, no + * complex-script clustering, no automatic word wrap, no kerning beyond what the + * platform `fillText` applies. Metrics come through the {@link RasterSurface} + * `ctx` seam (`measureText`), so node tests run on the stub's deterministic + * `width = chars × fontSizePx × 0.6` (see `raster.testStub.ts`). + * + * Extent semantics: like a shape, the surface is sized to the text BLOCK's own + * measured `width`×`height` (its layer-local extent, top-left origin); the + * compositor applies the layer transform (position/scale) when drawing. A layer + * with an empty `content` still produces a minimal 1×lineHeight surface so its + * transform/anchor stays meaningful. + * + * Font loading (late web-font availability) is handled by the ENGINE, not here: + * this rasterizer draws with whatever metrics `ctx` currently reports, and the + * engine re-rasterizes when a pending font resolves (see `render/fontLoader.ts`). + * + * Zero React, zero import-time side effects. + */ + +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { CanvasLayerSourceContract } from '@workbench/types'; + +import type { RasterizeDeps, RasterizeResult } from './types'; + +/** A `text` layer source. */ +export type TextSource = Extract; +type Ctx = RasterSurface['ctx']; + +/** + * Per-character horizontal advance as a fraction of the font's pixel size, used + * by the pure {@link estimateTextExtent}. It matches the test stub's + * `measureText` factor so a text layer's estimated extent and its + * stub-measured surface size agree exactly in node tests. In the browser the + * true `measureText` governs the rendered surface; the estimate is only a + * pre-measure cache-size / bounds approximation. + */ +export const TEXT_CHAR_WIDTH_FACTOR = 0.6; + +/** The CSS `font` shorthand for a text source (`" px "`). */ +export const textFontString = (source: TextSource): string => + `${source.fontWeight} ${source.fontSize}px ${source.fontFamily}`; + +/** Splits a text source's content into lines on `\n`; always at least one (possibly empty) line. */ +export const textLines = (content: string): string[] => content.split('\n'); + +/** The line-box height in document px for a source (`fontSize × lineHeight`). */ +const lineHeightPx = (source: TextSource): number => source.fontSize * source.lineHeight; + +/** + * A pure, DOM-free estimate of a text block's unscaled pixel extent, matching + * the stub's deterministic metrics (`TEXT_CHAR_WIDTH_FACTOR`). Used by the pure + * geometry helpers (`sources.ts`) to size caches / bounds before a real + * `measureText` runs; the rasterizer resizes the surface to the precise + * measured size, so a browser mismatch only affects the initial cache size. + */ +export const estimateTextExtent = (source: TextSource): { width: number; height: number } => { + const lines = textLines(source.content); + const widest = lines.reduce((max, line) => Math.max(max, line.length), 0); + return { + height: Math.max(1, Math.ceil(lines.length * lineHeightPx(source))), + width: Math.max(1, Math.ceil(widest * source.fontSize * TEXT_CHAR_WIDTH_FACTOR)), + }; +}; + +/** Measures the block extent through the seam's `ctx` (font must be set first). */ +const measureBlock = (ctx: Ctx, source: TextSource, lines: string[]): { width: number; height: number } => { + let widest = 0; + for (const line of lines) { + widest = Math.max(widest, ctx.measureText(line).width); + } + return { + height: Math.max(1, Math.ceil(lines.length * lineHeightPx(source))), + width: Math.max(1, Math.ceil(widest)), + }; +}; + +/** + * Draws a text source into a surface sized to the measured text block, reusing + * `target` if provided (resizing it to the measured extent), matching the + * paint/image/shape rasterizer contract. Synchronous work wrapped in a resolved + * promise so it shares the `rasterizeSource` dispatch signature. + */ +export const rasterizeTextSource = ( + source: TextSource, + deps: RasterizeDeps, + target?: RasterSurface +): Promise => { + const lines = textLines(source.content); + const font = textFontString(source); + + // Measure on the target's own ctx (or a fresh surface) with the font applied. + const surface = target ?? deps.backend.createSurface(1, 1); + surface.ctx.font = font; + const { height, width } = measureBlock(surface.ctx, source, lines); + + if (surface.width !== width || surface.height !== height) { + surface.resize(width, height); + } + const { ctx } = surface; + // A resize resets the browser context state, so (re)apply the transform, font, + // and paint state AFTER resizing. + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, width, height); + ctx.font = font; + ctx.textBaseline = 'top'; + ctx.fillStyle = source.color; + + // Horizontal alignment within the block width, via the canvas text anchor. + let anchorX = 0; + if (source.align === 'center') { + ctx.textAlign = 'center'; + anchorX = width / 2; + } else if (source.align === 'right') { + ctx.textAlign = 'right'; + anchorX = width; + } else { + ctx.textAlign = 'left'; + anchorX = 0; + } + + const step = lineHeightPx(source); + for (let i = 0; i < lines.length; i++) { + ctx.fillText(lines[i] ?? '', anchorX, i * step); + } + + return Promise.resolve({ rect: { height, width, x: 0, y: 0 }, surface }); +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/types.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/types.ts new file mode 100644 index 00000000000..98b47caaa72 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/types.ts @@ -0,0 +1,46 @@ +/** + * Shared vocabulary for the source rasterizers. + * + * Type-only module (no runtime), so it can be imported by both the dispatch + * (`rasterizeSource`) and the per-source rasterizers without any import + * cycle. Zero React, zero side effects. + */ + +import type { LayerCacheStore } from '@workbench/canvas-engine/render/layerCache'; +import type { RasterBackend, RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { Rect } from '@workbench/canvas-engine/types'; + +/** + * Resolves a persisted image asset (referenced by name in the document) to a + * `Blob` for decoding. The DOM implementation (deriving a URL and fetching) + * ships with the React shell task; the engine only depends on this seam so it + * stays node-testable. + */ +export type ImageResolver = (imageName: string) => Promise; + +/** + * The result of rasterizing a source: the surface holding its pixels plus the + * content `rect` those pixels occupy in the layer's LOCAL coordinate space. For + * origin-anchored sources (image / shape / text / gradient) the rect origin is + * `(0, 0)`; a `paint` source places its bitmap at the persisted `offset`. + */ +export interface RasterizeResult { + surface: RasterSurface; + rect: Rect; +} + +/** Dependencies shared by the source rasterizers. */ +export interface RasterizeDeps { + /** Surface + bitmap factory seam. */ + backend: RasterBackend; + /** Fetches image blobs by name for decoding. */ + resolver: ImageResolver; + /** Holds the decoded-bitmap cache (keyed by image name). */ + store: LayerCacheStore; + /** + * Document pixel size. Layers are content-sized, so this only backs the + * legacy default for gradients that predate the explicit extent field (they + * were document-sized by construction and must render identically). + */ + documentSize: { width: number; height: number }; +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/scheduler.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/scheduler.test.ts new file mode 100644 index 00000000000..cb5e81d35b0 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/scheduler.test.ts @@ -0,0 +1,188 @@ +import type { RenderFlags } from '@workbench/canvas-engine/types'; + +import { describe, expect, it, vi } from 'vitest'; + +import { createRenderScheduler } from './scheduler'; + +/** A controllable fake rAF: `flush()` runs the pending callback, if any. */ +const createFakeRaf = () => { + let nextHandle = 1; + const callbacks = new Map(); + return { + cancelFrame: (handle: number): void => { + callbacks.delete(handle); + }, + /** Runs all currently-queued frame callbacks (like the browser firing a frame). */ + flush: (): void => { + const queued = [...callbacks.entries()]; + callbacks.clear(); + for (const [, cb] of queued) { + cb(performance.now()); + } + }, + pendingCount: (): number => callbacks.size, + requestFrame: (cb: FrameRequestCallback): number => { + const handle = nextHandle++; + callbacks.set(handle, cb); + return handle; + }, + }; +}; + +describe('createRenderScheduler', () => { + it('coalesces multiple invalidations into a single render with merged flags', () => { + const raf = createFakeRaf(); + const render = vi.fn<(flags: RenderFlags) => void>(); + const scheduler = createRenderScheduler({ cancelFrame: raf.cancelFrame, render, requestFrame: raf.requestFrame }); + + scheduler.invalidate({ view: true }); + scheduler.invalidate({ layers: ['a'] }); + scheduler.invalidate({ layers: ['b'] }); + scheduler.invalidate({ overlay: true }); + + // Only one frame is scheduled despite four invalidations. + expect(raf.pendingCount()).toBe(1); + expect(render).not.toHaveBeenCalled(); + + raf.flush(); + + expect(render).toHaveBeenCalledTimes(1); + const flags = render.mock.calls[0]![0]; + expect(flags.view).toBe(true); + expect(flags.overlay).toBe(true); + expect(flags.all).toBe(false); + expect([...flags.layers].sort()).toEqual(['a', 'b']); + }); + + it('resets flags after the render callback runs', () => { + const raf = createFakeRaf(); + const render = vi.fn<(flags: RenderFlags) => void>(); + const scheduler = createRenderScheduler({ cancelFrame: raf.cancelFrame, render, requestFrame: raf.requestFrame }); + + scheduler.invalidate({ layers: ['a'], view: true }); + raf.flush(); + + scheduler.invalidate({ layers: ['b'] }); + raf.flush(); + + expect(render).toHaveBeenCalledTimes(2); + const second = render.mock.calls[1]![0]; + // No leftover 'a' or view flag from the first frame. + expect(second.view).toBe(false); + expect([...second.layers]).toEqual(['b']); + }); + + it('does not schedule a new frame when nothing was invalidated', () => { + const raf = createFakeRaf(); + const render = vi.fn<(flags: RenderFlags) => void>(); + createRenderScheduler({ cancelFrame: raf.cancelFrame, render, requestFrame: raf.requestFrame }); + + expect(raf.pendingCount()).toBe(0); + raf.flush(); + expect(render).not.toHaveBeenCalled(); + }); + + it('accumulates invalidations while paused and flushes them on resume', () => { + const raf = createFakeRaf(); + const render = vi.fn<(flags: RenderFlags) => void>(); + const scheduler = createRenderScheduler({ cancelFrame: raf.cancelFrame, render, requestFrame: raf.requestFrame }); + + scheduler.pause(); + scheduler.invalidate({ layers: ['a'] }); + scheduler.invalidate({ view: true }); + + // Nothing scheduled while paused. + expect(raf.pendingCount()).toBe(0); + + scheduler.resume(); + expect(raf.pendingCount()).toBe(1); + raf.flush(); + + expect(render).toHaveBeenCalledTimes(1); + const flags = render.mock.calls[0]![0]; + expect(flags.view).toBe(true); + expect([...flags.layers]).toEqual(['a']); + }); + + it('pause cancels an already-scheduled frame; resume reschedules the same pending flags', () => { + const raf = createFakeRaf(); + const render = vi.fn<(flags: RenderFlags) => void>(); + const scheduler = createRenderScheduler({ cancelFrame: raf.cancelFrame, render, requestFrame: raf.requestFrame }); + + scheduler.invalidate({ view: true }); + expect(raf.pendingCount()).toBe(1); + + scheduler.pause(); + expect(raf.pendingCount()).toBe(0); + raf.flush(); + expect(render).not.toHaveBeenCalled(); + + scheduler.resume(); + raf.flush(); + expect(render).toHaveBeenCalledTimes(1); + expect(render.mock.calls[0]![0].view).toBe(true); + }); + + it('resume with no pending work does not schedule a frame', () => { + const raf = createFakeRaf(); + const render = vi.fn<(flags: RenderFlags) => void>(); + const scheduler = createRenderScheduler({ cancelFrame: raf.cancelFrame, render, requestFrame: raf.requestFrame }); + + scheduler.pause(); + scheduler.resume(); + expect(raf.pendingCount()).toBe(0); + }); + + it('dispose cancels a pending frame and ignores further invalidations', () => { + const raf = createFakeRaf(); + const cancelFrame = vi.fn(raf.cancelFrame); + const render = vi.fn<(flags: RenderFlags) => void>(); + const scheduler = createRenderScheduler({ cancelFrame, render, requestFrame: raf.requestFrame }); + + scheduler.invalidate({ view: true }); + expect(raf.pendingCount()).toBe(1); + + scheduler.dispose(); + expect(cancelFrame).toHaveBeenCalledTimes(1); + expect(raf.pendingCount()).toBe(0); + + scheduler.invalidate({ all: true }); + raf.flush(); + expect(render).not.toHaveBeenCalled(); + expect(raf.pendingCount()).toBe(0); + }); + + it('invalidations from within the render callback schedule a fresh frame', () => { + const raf = createFakeRaf(); + let reentered = false; + const scheduler = createRenderScheduler({ + cancelFrame: raf.cancelFrame, + render: () => { + if (!reentered) { + reentered = true; + scheduler.invalidate({ overlay: true }); + } + }, + requestFrame: raf.requestFrame, + }); + + scheduler.invalidate({ view: true }); + raf.flush(); + // The re-entrant invalidate scheduled another frame rather than being lost. + expect(raf.pendingCount()).toBe(1); + }); + + it('reflects paused state via isPaused', () => { + const raf = createFakeRaf(); + const scheduler = createRenderScheduler({ + cancelFrame: raf.cancelFrame, + render: () => {}, + requestFrame: raf.requestFrame, + }); + expect(scheduler.isPaused).toBe(false); + scheduler.pause(); + expect(scheduler.isPaused).toBe(true); + scheduler.resume(); + expect(scheduler.isPaused).toBe(false); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/scheduler.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/scheduler.ts new file mode 100644 index 00000000000..7a7d3370d5b --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/scheduler.ts @@ -0,0 +1,163 @@ +/** + * A single-rAF render scheduler. + * + * Consumers call `invalidate(...)` many times per frame; the scheduler + * coalesces those into one pending `RenderFlags` set and runs the injected + * `render` callback at most once per animation frame. The rAF driver is + * injectable so the scheduler can be driven deterministically in node tests. + * + * Zero React, zero import-time side effects; the only DOM touch point is the + * default `requestFrame`/`cancelFrame`, which are resolved lazily at call + * time (never at import) and are always overridden in tests. + */ + +import type { RenderFlags } from '@workbench/canvas-engine/types'; + +/** The partial invalidation payload accepted by {@link RenderScheduler.invalidate}. */ +export interface InvalidatePayload { + /** The viewport transform (pan/zoom) changed. */ + view?: true; + /** Ids of layers whose pixel content or transform changed. */ + layers?: string[]; + /** Interaction overlays (selection, cursors, guides) changed. */ + overlay?: true; + /** Force a full repaint next frame. */ + all?: true; +} + +/** Dependencies for {@link createRenderScheduler}; the rAF pair is injectable for tests. */ +export interface RenderSchedulerDeps { + /** Invoked once per scheduled frame with the coalesced flags. */ + render: (flags: RenderFlags) => void; + /** Defaults to `globalThis.requestAnimationFrame`. */ + requestFrame?: (callback: FrameRequestCallback) => number; + /** Defaults to `globalThis.cancelAnimationFrame`. */ + cancelFrame?: (handle: number) => void; +} + +/** The imperative scheduler handle returned by {@link createRenderScheduler}. */ +export interface RenderScheduler { + /** Merge a partial invalidation into the pending flags and schedule a frame. */ + invalidate(payload: InvalidatePayload): void; + /** Suspend frame scheduling (e.g. widget detach); invalidations still accumulate. */ + pause(): void; + /** Resume scheduling; flushes any invalidations accumulated while paused. */ + resume(): void; + /** True while paused. */ + readonly isPaused: boolean; + /** Cancel any pending frame and stop accepting further work. */ + dispose(): void; +} + +const createEmptyFlags = (): RenderFlags => ({ + all: false, + layers: new Set(), + overlay: false, + view: false, +}); + +const hasPending = (flags: RenderFlags): boolean => flags.all || flags.view || flags.overlay || flags.layers.size > 0; + +const defaultRequestFrame = (callback: FrameRequestCallback): number => globalThis.requestAnimationFrame(callback); + +const defaultCancelFrame = (handle: number): void => { + globalThis.cancelAnimationFrame(handle); +}; + +/** + * Creates a coalescing, single-frame render scheduler. See module docs for + * the coalescing and pause/resume semantics. + */ +export const createRenderScheduler = (deps: RenderSchedulerDeps): RenderScheduler => { + const requestFrame = deps.requestFrame ?? defaultRequestFrame; + const cancelFrame = deps.cancelFrame ?? defaultCancelFrame; + + let pending = createEmptyFlags(); + let frameHandle: number | null = null; + let paused = false; + let disposed = false; + + const runFrame = (): void => { + frameHandle = null; + if (disposed) { + return; + } + // Snapshot then reset before invoking render, so invalidations made from + // within the render callback accumulate into a fresh frame rather than + // being wiped by the post-render reset. + const flags = pending; + pending = createEmptyFlags(); + deps.render(flags); + }; + + const schedule = (): void => { + if (disposed || paused || frameHandle !== null) { + return; + } + if (!hasPending(pending)) { + return; + } + frameHandle = requestFrame(runFrame); + }; + + const invalidate = (payload: InvalidatePayload): void => { + if (disposed) { + return; + } + if (payload.all) { + pending.all = true; + } + if (payload.view) { + pending.view = true; + } + if (payload.overlay) { + pending.overlay = true; + } + if (payload.layers) { + for (const id of payload.layers) { + pending.layers.add(id); + } + } + schedule(); + }; + + const pause = (): void => { + if (disposed || paused) { + return; + } + paused = true; + if (frameHandle !== null) { + cancelFrame(frameHandle); + frameHandle = null; + } + }; + + const resume = (): void => { + if (disposed || !paused) { + return; + } + paused = false; + schedule(); + }; + + const dispose = (): void => { + if (disposed) { + return; + } + disposed = true; + if (frameHandle !== null) { + cancelFrame(frameHandle); + frameHandle = null; + } + }; + + return { + dispose, + invalidate, + get isPaused() { + return paused; + }, + pause, + resume, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/thumbnail.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/thumbnail.test.ts new file mode 100644 index 00000000000..4b3d02ee4d0 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/thumbnail.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { fitThumbnailSize } from './thumbnail'; + +describe('fitThumbnailSize', () => { + it('scales a large landscape surface to fit the box width', () => { + expect(fitThumbnailSize(200, 100, 96)).toEqual({ height: 48, width: 96 }); + }); + + it('scales a large portrait surface to fit the box height', () => { + expect(fitThumbnailSize(100, 200, 96)).toEqual({ height: 96, width: 48 }); + }); + + it('keeps a square surface square', () => { + expect(fitThumbnailSize(300, 300, 96)).toEqual({ height: 96, width: 96 }); + }); + + it('never upscales a surface smaller than the box', () => { + expect(fitThumbnailSize(40, 20, 96)).toEqual({ height: 20, width: 40 }); + }); + + it('clamps a fitted dimension to at least 1px', () => { + expect(fitThumbnailSize(1000, 1, 96)).toEqual({ height: 1, width: 96 }); + }); + + it('returns a zero size for a degenerate source or box', () => { + expect(fitThumbnailSize(0, 100, 96)).toEqual({ height: 0, width: 0 }); + expect(fitThumbnailSize(100, 100, 0)).toEqual({ height: 0, width: 0 }); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/thumbnail.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/thumbnail.ts new file mode 100644 index 00000000000..6d43a0a4345 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/thumbnail.ts @@ -0,0 +1,33 @@ +/** + * Pure sizing math for layer thumbnails. + * + * A layer's cache surface can be any size (up to the document dimensions); a + * thumbnail must fit within a small square box while preserving aspect ratio + * and never upscaling past the source. Extracted from the engine's + * `drawLayerThumbnail` so the arithmetic is unit-testable without a canvas. + * + * Zero React, zero DOM, zero import-time side effects. + */ + +/** A thumbnail's integer pixel dimensions after fitting into a `maxSize` box. */ +export interface ThumbnailSize { + width: number; + height: number; +} + +/** + * Scales a `srcW`x`srcH` surface to fit inside a `maxSize`x`maxSize` box, + * preserving aspect ratio and never upscaling (scale is clamped to ≤ 1). Both + * returned dimensions are at least 1px. Returns a zero size for a degenerate + * (non-positive) source. + */ +export const fitThumbnailSize = (srcW: number, srcH: number, maxSize: number): ThumbnailSize => { + if (srcW <= 0 || srcH <= 0 || maxSize <= 0) { + return { height: 0, width: 0 }; + } + const scale = Math.min(1, maxSize / srcW, maxSize / srcH); + return { + height: Math.max(1, Math.round(srcH * scale)), + width: Math.max(1, Math.round(srcW * scale)), + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/marchingAnts.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/marchingAnts.test.ts new file mode 100644 index 00000000000..7f318967858 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/marchingAnts.test.ts @@ -0,0 +1,136 @@ +import type { StubRasterSurface } from '@workbench/canvas-engine/render/raster.testStub'; +import type { Mat2d } from '@workbench/canvas-engine/types'; + +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { createAntsAnimator, drawMarchingAnts } from '@workbench/canvas-engine/selection/marchingAnts'; +import { describe, expect, it, vi } from 'vitest'; + +const IDENTITY: Mat2d = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }; +const fakePath = (id: string): Path2D => ({ id }) as unknown as Path2D; + +describe('drawMarchingAnts', () => { + it('strokes each path twice (two-tone), applies a dash and the animated offset', () => { + const surface = createTestStubRasterBackend().createSurface(50, 50) as StubRasterSurface; + drawMarchingAnts(surface.ctx, IDENTITY, { paths: [fakePath('p')], phase: 3 }); + + const log = surface.callLog; + const ops = log.map((e) => e.op); + expect(ops.filter((op) => op === 'stroke')).toHaveLength(2); + expect(ops).toContain('setLineDash'); + expect(ops).toContain('setTransform'); + + const strokeStyles = log.filter((e) => e.op === 'set' && e.args[0] === 'strokeStyle').map((e) => e.args[1]); + expect(strokeStyles).toContain('#000000'); + expect(strokeStyles).toContain('#ffffff'); + // The light run is offset from the dark run so the ants appear to crawl. + const offsets = log.filter((e) => e.op === 'set' && e.args[0] === 'lineDashOffset').map((e) => e.args[1]); + expect(new Set(offsets).size).toBeGreaterThan(1); + }); + + it('is a no-op with no paths', () => { + const surface = createTestStubRasterBackend().createSurface(10, 10) as StubRasterSurface; + drawMarchingAnts(surface.ctx, IDENTITY, { paths: [], phase: 0 }); + expect(surface.callLog).toHaveLength(0); + }); +}); + +/** A controllable rAF pair + clock for driving the animator deterministically. */ +const createDriver = () => { + let nextHandle = 1; + const callbacks = new Map void>(); + let clock = 0; + return { + advance: (ms: number): void => { + clock += ms; + }, + cancelFrame: (handle: number): void => { + callbacks.delete(handle); + }, + /** Runs every queued frame callback once (like the browser firing a frame). */ + flush: (): void => { + const queued = [...callbacks.entries()]; + callbacks.clear(); + for (const [, cb] of queued) { + cb(); + } + }, + now: (): number => clock, + pending: (): number => callbacks.size, + requestFrame: (cb: () => void): number => { + const handle = nextHandle++; + callbacks.set(handle, cb); + return handle; + }, + }; +}; + +describe('createAntsAnimator', () => { + it('steps on the throttle boundary and reschedules each frame while running', () => { + const driver = createDriver(); + const onStep = vi.fn(); + const animator = createAntsAnimator({ + cancelFrame: driver.cancelFrame, + intervalMs: 100, + now: driver.now, + onStep, + requestFrame: driver.requestFrame, + }); + + animator.start(); + expect(animator.isRunning).toBe(true); + expect(driver.pending()).toBe(1); + + // First frame steps immediately (start primes lastStep one interval back). + driver.flush(); + expect(onStep).toHaveBeenCalledTimes(1); + // It rescheduled itself. + expect(driver.pending()).toBe(1); + + // A frame before the interval elapses does not step, but keeps polling. + driver.advance(40); + driver.flush(); + expect(onStep).toHaveBeenCalledTimes(1); + expect(driver.pending()).toBe(1); + + // Crossing the interval steps again. + driver.advance(70); + driver.flush(); + expect(onStep).toHaveBeenCalledTimes(2); + }); + + it('stop cancels the pending frame and halts rescheduling (no timer leak)', () => { + const driver = createDriver(); + const onStep = vi.fn(); + const animator = createAntsAnimator({ + cancelFrame: driver.cancelFrame, + intervalMs: 100, + now: driver.now, + onStep, + requestFrame: driver.requestFrame, + }); + + animator.start(); + animator.stop(); + expect(animator.isRunning).toBe(false); + expect(driver.pending()).toBe(0); + + // Any straggler frame that still fires must not step or reschedule. + driver.advance(1000); + driver.flush(); + expect(onStep).not.toHaveBeenCalled(); + expect(driver.pending()).toBe(0); + }); + + it('start is idempotent (no double scheduling)', () => { + const driver = createDriver(); + const animator = createAntsAnimator({ + cancelFrame: driver.cancelFrame, + now: driver.now, + onStep: vi.fn(), + requestFrame: driver.requestFrame, + }); + animator.start(); + animator.start(); + expect(driver.pending()).toBe(1); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/marchingAnts.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/marchingAnts.ts new file mode 100644 index 00000000000..7870d286f40 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/marchingAnts.ts @@ -0,0 +1,155 @@ +/** + * Marching ants: the animated dashed outline of a pixel selection. + * + * Two concerns live here: + * + * 1. {@link drawMarchingAnts} strokes the selection's committed path list onto + * the overlay surface as a two-tone (black/white) dashed outline. It draws in + * document space (paths are document-space `Path2D`s) via the shared `view` + * transform, scaling line width and dash by `1/scale` so the ants stay a + * constant pixel size at any zoom — the same screen-constant convention the + * rest of the overlay uses. The animated `lineDashOffset` (`phase`) makes the + * white dashes crawl. + * + * 2. {@link createAntsAnimator} drives the `phase` advance. It self-reschedules + * through an injected `requestFrame`/`cancelFrame` pair (the engine passes the + * global rAF, so it rides the same frame clock as the scheduler) and, throttled + * by an injected `now()` to ~a few fps (the legacy look, not 60fps), calls + * `onStep` — the engine advances the phase and requests an OVERLAY-ONLY + * invalidation there, so an ants tick never recomposites layers (Task-22 gate). + * It runs only while started (a selection exists AND the engine is attached) + * and cancels its pending frame on `stop`, so there are no timer leaks on + * detach/deselect/dispose. + * + * Zero React, zero import-time side effects. + */ + +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { Mat2d } from '@workbench/canvas-engine/types'; + +import { getScale } from '@workbench/canvas-engine/math/mat2d'; + +/** Dash length in screen pixels; the offset advances by {@link ANTS_STEP_PX} per tick. */ +export const ANTS_DASH_PX = 4; +/** Screen-pixel advance of the dash offset per animation step. */ +export const ANTS_STEP_PX = 1; +/** Default step interval (ms) — ~5 fps, the legacy marching-ants cadence. */ +export const ANTS_INTERVAL_MS = 200; + +const ANTS_LINE_WIDTH_PX = 1; +const ANTS_DARK = '#000000'; +const ANTS_LIGHT = '#ffffff'; + +/** What {@link drawMarchingAnts} needs: the committed outlines and the animation phase. */ +export interface MarchingAntsRender { + paths: readonly Path2D[]; + /** Animated dash offset in screen pixels. */ + phase: number; +} + +/** + * Strokes the selection outline(s) as animated two-tone marching ants, in + * document space projected through `view`. A no-op with no paths. + */ +export const drawMarchingAnts = (ctx: RasterSurface['ctx'], view: Mat2d, render: MarchingAntsRender): void => { + if (render.paths.length === 0) { + return; + } + const scale = getScale(view) || 1; + const dash = ANTS_DASH_PX / scale; + const lineWidth = ANTS_LINE_WIDTH_PX / scale; + const offset = render.phase / scale; + + ctx.save(); + ctx.setTransform(view.a, view.b, view.c, view.d, view.e, view.f); + ctx.lineWidth = lineWidth; + + // Dark base run: a continuous dashed outline. + ctx.setLineDash([dash, dash]); + ctx.lineDashOffset = -offset; + ctx.strokeStyle = ANTS_DARK; + for (const path of render.paths) { + ctx.stroke(path); + } + + // Light run, offset by one dash so it fills the gaps and appears to crawl. + ctx.lineDashOffset = -offset + dash; + ctx.strokeStyle = ANTS_LIGHT; + for (const path of render.paths) { + ctx.stroke(path); + } + + ctx.restore(); +}; + +/** The animator handle the engine starts/stops as selection presence + attachment change. */ +export interface AntsAnimator { + /** Begins the animation loop (idempotent). */ + start(): void; + /** Stops the loop and cancels any pending frame (idempotent). */ + stop(): void; + /** True while the loop is running. */ + readonly isRunning: boolean; +} + +/** Dependencies for {@link createAntsAnimator}; all injectable for deterministic tests. */ +export interface AntsAnimatorDeps { + requestFrame(callback: () => void): number; + cancelFrame(handle: number): void; + /** Monotonic clock (ms) used to throttle steps to {@link AntsAnimatorDeps.intervalMs}. */ + now(): number; + /** Called on each throttled step (the engine advances the phase + invalidates the overlay). */ + onStep(): void; + /** Step interval in ms; defaults to {@link ANTS_INTERVAL_MS}. */ + intervalMs?: number; +} + +/** + * Creates a self-rescheduling animation loop that fires `onStep` at most every + * `intervalMs`. It reschedules on every frame while running (to keep polling the + * clock) but only steps on the throttle boundary. + */ +export const createAntsAnimator = (deps: AntsAnimatorDeps): AntsAnimator => { + const intervalMs = deps.intervalMs ?? ANTS_INTERVAL_MS; + let running = false; + let handle: number | null = null; + let lastStep = 0; + + const frame = (): void => { + handle = null; + if (!running) { + return; + } + const t = deps.now(); + if (t - lastStep >= intervalMs) { + lastStep = t; + deps.onStep(); + } + // Reschedule while running (even between steps) so the clock keeps advancing. + if (running) { + handle = deps.requestFrame(frame); + } + }; + + return { + get isRunning() { + return running; + }, + start: () => { + if (running) { + return; + } + running = true; + // Force the first frame to step immediately. + lastStep = deps.now() - intervalMs; + handle = deps.requestFrame(frame); + }, + stop: () => { + running = false; + if (handle !== null) { + deps.cancelFrame(handle); + handle = null; + } + }, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/selectionOps.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/selectionOps.test.ts new file mode 100644 index 00000000000..59314ea8be1 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/selectionOps.test.ts @@ -0,0 +1,145 @@ +import type { StubRasterBackend, StubRasterSurface } from '@workbench/canvas-engine/render/raster.testStub'; + +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { eraseMaskedRegion, fillMaskedRegion } from '@workbench/canvas-engine/selection/selectionOps'; +import { describe, expect, it } from 'vitest'; + +/** Wraps the stub backend so every scratch surface it mints is captured for assertions. */ +const createCapturingBackend = (): { backend: StubRasterBackend; created: StubRasterSurface[] } => { + const inner = createTestStubRasterBackend(); + const created: StubRasterSurface[] = []; + return { + backend: { + ...inner, + createSurface: (w, h) => { + const s = inner.createSurface(w, h); + created.push(s); + return s; + }, + }, + created, + }; +}; + +const compositeOps = (surface: StubRasterSurface): unknown[] => + surface.callLog.filter((e) => e.op === 'set' && e.args[0] === 'globalCompositeOperation').map((e) => e.args[1]); + +describe('fillMaskedRegion', () => { + it('masks the color to the selection (destination-in scratch) and draws it over the target', () => { + const { backend, created } = createCapturingBackend(); + const target = backend.createSurface(100, 100); + const mask = backend.createSurface(100, 100); + created.length = 0; // ignore target/mask; capture only the op's scratch + + fillMaskedRegion({ + backend, + color: '#ff0000', + mask, + maskOrigin: { x: 0, y: 0 }, + rect: { height: 20, width: 20, x: 10, y: 10 }, + target, + targetOrigin: { x: 0, y: 0 }, + }); + + // The scratch: fill the color, then intersect with the mask. + const scratch = created[0]!; + const scratchOps = scratch.callLog.map((e) => e.op); + expect(scratchOps).toContain('fillRect'); + expect(compositeOps(scratch)).toContain('destination-in'); + + // The target: the masked color is drawn source-over at the rect origin. + const targetDraw = target.callLog.find((e) => e.op === 'drawImage'); + expect(targetDraw).toBeDefined(); + expect(compositeOps(target)).toContain('source-over'); + }); + + it('uses source-atop on the target when composite is source-atop (transparency lock)', () => { + const { backend, created } = createCapturingBackend(); + const target = backend.createSurface(100, 100); + const mask = backend.createSurface(100, 100); + created.length = 0; + + fillMaskedRegion({ + backend, + color: '#ff0000', + composite: 'source-atop', + mask, + maskOrigin: { x: 0, y: 0 }, + rect: { height: 20, width: 20, x: 10, y: 10 }, + target, + targetOrigin: { x: 0, y: 0 }, + }); + + // Transparency lock: the final blit is source-atop, so colour lands ONLY where + // the target is already opaque (never fills transparent space). + expect(compositeOps(target)).toContain('source-atop'); + expect(compositeOps(target)).not.toContain('source-over'); + }); + + it('translates the draws by the target/mask origins (content-sized placement)', () => { + const { backend, created } = createCapturingBackend(); + const target = backend.createSurface(100, 100); + const mask = backend.createSurface(100, 100); + created.length = 0; + + // Edit region at doc (50,50); mask surface origin (40,40); target cache + // origin (30,30). The mask is drawn into the region-local scratch at + // (maskOrigin - rect) = (-10,-10); the scratch is drawn onto the target at + // (rect - targetOrigin) = (20,20). + fillMaskedRegion({ + backend, + color: '#ff0000', + mask, + maskOrigin: { x: 40, y: 40 }, + rect: { height: 20, width: 20, x: 50, y: 50 }, + target, + targetOrigin: { x: 30, y: 30 }, + }); + + const scratch = created[0]!; + const scratchMaskDraw = scratch.callLog.find((e) => e.op === 'drawImage'); + expect(scratchMaskDraw?.args.slice(1, 3)).toEqual([-10, -10]); + const targetDraw = target.callLog.find((e) => e.op === 'drawImage'); + expect(targetDraw?.args.slice(1, 3)).toEqual([20, 20]); + }); + + it('is a no-op for an empty rect', () => { + const { backend } = createCapturingBackend(); + const target = backend.createSurface(10, 10); + const mask = backend.createSurface(10, 10); + const before = target.callLog.length; + fillMaskedRegion({ + backend, + color: '#000', + mask, + maskOrigin: { x: 0, y: 0 }, + rect: { height: 0, width: 0, x: 0, y: 0 }, + target, + targetOrigin: { x: 0, y: 0 }, + }); + expect(target.callLog.length).toBe(before); + }); +}); + +describe('eraseMaskedRegion', () => { + it('composites destination-out through the mask onto the target', () => { + const { backend, created } = createCapturingBackend(); + const target = backend.createSurface(100, 100); + const mask = backend.createSurface(100, 100); + created.length = 0; + + eraseMaskedRegion({ + backend, + mask, + maskOrigin: { x: 0, y: 0 }, + rect: { height: 20, width: 20, x: 5, y: 5 }, + target, + targetOrigin: { x: 0, y: 0 }, + }); + + const scratch = created[0]!; + expect(scratch.callLog.map((e) => e.op)).toContain('drawImage'); + expect(compositeOps(target)).toContain('destination-out'); + expect(target.callLog.some((e) => e.op === 'drawImage')).toBe(true); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/selectionOps.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/selectionOps.ts new file mode 100644 index 00000000000..73387d07680 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/selectionOps.ts @@ -0,0 +1,114 @@ +/** + * Selection-constrained raster primitives: fill and erase a layer's pixels + * within the selection mask. + * + * These are the pure pixel ops behind the engine's `fillSelection` / + * `eraseSelection` (the engine owns the layer-eligibility guards, dirty-rect + * before/after capture, undo history, and persistence around them). Each op + * composites through a scratch surface so the write lands ONLY where the mask is + * opaque — pixels outside the selection are never touched — and flows entirely + * through the {@link RasterBackend} `ctx` seam, so it runs on the node stub. + * + * Coordinate spaces: both the layer cache and the selection mask are content- + * sized (placed) surfaces with their own origins. `rect` is the edit region in + * document (= layer-local) space; `targetOrigin` / `maskOrigin` are the target + * cache surface's and the mask surface's document-space origins, so a document + * coordinate `d` maps to target-surface `d - targetOrigin` and mask-surface + * `d - maskOrigin`. + * + * Zero React, zero import-time side effects. + */ + +import type { RasterBackend, RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { Rect, Vec2 } from '@workbench/canvas-engine/types'; + +/** Shared inputs for a masked layer edit over a document-space `rect`. */ +export interface MaskedEditParams { + backend: RasterBackend; + /** The layer cache surface (content-sized) and its document-space origin. */ + target: RasterSurface; + /** The target surface's document-space origin (its cache rect's `x`/`y`). */ + targetOrigin: Vec2; + /** The selection mask surface (alpha 255 inside). */ + mask: RasterSurface; + /** The mask surface's document-space origin (its selection rect's `x`/`y`). */ + maskOrigin: Vec2; + /** The dirty region to edit (document space), integer bounds. */ + rect: Rect; +} + +/** + * Fills `color` into `target` wherever `mask` is opaque, within `rect`. Builds a + * `color`-filled scratch (region-local), intersects it with the mask + * (`destination-in`), then draws the masked color over the target. + * + * `composite` is the final blit's op: `source-over` (default) fills every masked + * pixel; `source-atop` honours a transparency lock — colour lands ONLY where the + * target is already opaque, never into transparent space (mirrors the + * transparency-locked brush in `paintTool.ts`). + */ +export const fillMaskedRegion = ({ + backend, + color, + composite = 'source-over', + mask, + maskOrigin, + rect, + target, + targetOrigin, +}: MaskedEditParams & { color: string; composite?: 'source-over' | 'source-atop' }): void => { + if (rect.width <= 0 || rect.height <= 0) { + return; + } + const scratch = backend.createSurface(rect.width, rect.height); + const sctx = scratch.ctx; + sctx.setTransform(1, 0, 0, 1, 0, 0); + sctx.globalCompositeOperation = 'source-over'; + sctx.globalAlpha = 1; + sctx.fillStyle = color; + sctx.fillRect(0, 0, rect.width, rect.height); + // Keep the color only where the mask is opaque (mask drawn at its offset within + // the region-local scratch). + sctx.globalCompositeOperation = 'destination-in'; + sctx.drawImage(mask.canvas, maskOrigin.x - rect.x, maskOrigin.y - rect.y); + + const ctx = target.ctx; + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.globalCompositeOperation = composite; + ctx.globalAlpha = 1; + ctx.drawImage(scratch.canvas, rect.x - targetOrigin.x, rect.y - targetOrigin.y); + ctx.restore(); +}; + +/** + * Erases (`destination-out`) `target`'s pixels wherever `mask` is opaque, within + * `rect`. The mask shape is copied into a region-local scratch and composited out + * of the target. + */ +export const eraseMaskedRegion = ({ + backend, + mask, + maskOrigin, + rect, + target, + targetOrigin, +}: MaskedEditParams): void => { + if (rect.width <= 0 || rect.height <= 0) { + return; + } + const scratch = backend.createSurface(rect.width, rect.height); + const sctx = scratch.ctx; + sctx.setTransform(1, 0, 0, 1, 0, 0); + sctx.globalCompositeOperation = 'source-over'; + sctx.globalAlpha = 1; + sctx.drawImage(mask.canvas, maskOrigin.x - rect.x, maskOrigin.y - rect.y); + + const ctx = target.ctx; + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.globalCompositeOperation = 'destination-out'; + ctx.globalAlpha = 1; + ctx.drawImage(scratch.canvas, rect.x - targetOrigin.x, rect.y - targetOrigin.y); + ctx.restore(); +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/selectionState.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/selectionState.test.ts new file mode 100644 index 00000000000..dbcfdc27c59 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/selectionState.test.ts @@ -0,0 +1,154 @@ +import type { StubRasterSurface } from '@workbench/canvas-engine/render/raster.testStub'; +import type { Rect } from '@workbench/canvas-engine/types'; + +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { createSelectionState, type SelectionState } from '@workbench/canvas-engine/selection/selectionState'; +import { describe, expect, it, vi } from 'vitest'; + +const DOC = { height: 100, width: 100 }; + +const rectBounds = (x: number, y: number, w: number, h: number): Rect => ({ height: h, width: w, x, y }); + +/** A fake Path2D carrier (Path2D is absent in node); the stub ctx just records it. */ +const fakePath = (id: string): Path2D => ({ id }) as unknown as Path2D; + +const createHarness = (docSize: { width: number; height: number } | null = DOC) => { + const backend = createTestStubRasterBackend(); + const onChange = vi.fn(); + const selection: SelectionState = createSelectionState({ + backend, + createPath2D: (d) => ({ d }) as unknown as Path2D, + getDocumentSize: () => docSize, + onChange, + }); + return { backend, onChange, selection }; +}; + +/** The recorded op names on the mask surface. */ +const maskLog = (selection: SelectionState): { op: string; args: unknown[] }[] => + (selection.mask()?.surface as StubRasterSurface | undefined)?.callLog ?? []; + +/** The last `globalCompositeOperation` value set before a given op, scanning the whole log. */ +const compositeOpsFor = (log: { op: string; args: unknown[] }[]): unknown[] => + log.filter((e) => e.op === 'set' && e.args[0] === 'globalCompositeOperation').map((e) => e.args[1]); + +describe('selectionState: mask building from a path', () => { + it('replace clears then source-over fills the path, sets bounds + hasSelection', () => { + const { selection, onChange } = createHarness(); + selection.commit({ bounds: rectBounds(10, 10, 20, 20), op: 'replace', path: fakePath('p1') }); + + expect(selection.hasSelection()).toBe(true); + expect(selection.bounds()).toEqual(rectBounds(10, 10, 20, 20)); + expect(selection.antsPaths()).toHaveLength(1); + expect(onChange).toHaveBeenCalledTimes(1); + + const log = maskLog(selection); + const ops = log.map((e) => e.op); + expect(ops).toContain('clearRect'); + expect(ops).toContain('fill'); + expect(compositeOpsFor(log)).toContain('source-over'); + }); + + it('no longer clamps bounds to the document rect (infinite plane); the mask is bounded to the path', () => { + const { selection } = createHarness(); + selection.commit({ bounds: rectBounds(-10, -10, 200, 200), op: 'replace', path: fakePath('p') }); + // Content-sized: the selection bounds are the path's own bounds, not clamped + // to the document. The mask surface is placed at that (negative) origin. + expect(selection.bounds()).toEqual(rectBounds(-10, -10, 200, 200)); + expect(selection.mask()?.rect).toEqual(rectBounds(-10, -10, 200, 200)); + }); +}); + +describe('selectionState: boolean ops', () => { + it('add unions bounds with source-over and keeps both ant paths', () => { + const { selection } = createHarness(); + selection.commit({ bounds: rectBounds(10, 10, 10, 10), op: 'replace', path: fakePath('a') }); + selection.commit({ bounds: rectBounds(40, 40, 10, 10), op: 'add', path: fakePath('b') }); + + expect(selection.hasSelection()).toBe(true); + expect(selection.bounds()).toEqual(rectBounds(10, 10, 40, 40)); + expect(selection.antsPaths()).toHaveLength(2); + expect(compositeOpsFor(maskLog(selection))).toContain('source-over'); + }); + + it('subtract composites destination-out', () => { + const { selection } = createHarness(); + selection.commit({ bounds: rectBounds(0, 0, 50, 50), op: 'replace', path: fakePath('a') }); + selection.commit({ bounds: rectBounds(10, 10, 10, 10), op: 'subtract', path: fakePath('b') }); + expect(selection.hasSelection()).toBe(true); + expect(compositeOpsFor(maskLog(selection))).toContain('destination-out'); + }); + + it('intersect composites destination-in and intersects the bounds', () => { + const { selection } = createHarness(); + selection.commit({ bounds: rectBounds(0, 0, 50, 50), op: 'replace', path: fakePath('a') }); + selection.commit({ bounds: rectBounds(30, 30, 50, 50), op: 'intersect', path: fakePath('b') }); + expect(selection.hasSelection()).toBe(true); + expect(selection.bounds()).toEqual(rectBounds(30, 30, 20, 20)); + expect(compositeOpsFor(maskLog(selection))).toContain('destination-in'); + }); + + it('intersect against no existing selection clears to nothing', () => { + const { selection } = createHarness(); + selection.commit({ bounds: rectBounds(0, 0, 10, 10), op: 'intersect', path: fakePath('a') }); + expect(selection.hasSelection()).toBe(false); + expect(selection.bounds()).toBeNull(); + expect(selection.mask()).toBeNull(); + }); +}); + +describe('selectionState: selectAll / invert / clear', () => { + it('selectAll fills the whole document rect', () => { + const { selection } = createHarness(); + selection.selectAll(rectBounds(0, 0, 100, 100)); + expect(selection.hasSelection()).toBe(true); + expect(selection.bounds()).toEqual(rectBounds(0, 0, 100, 100)); + expect(maskLog(selection).map((e) => e.op)).toContain('fillRect'); + }); + + it('invert of an empty selection selects everything', () => { + const { selection } = createHarness(); + selection.invert(rectBounds(0, 0, 100, 100)); + expect(selection.hasSelection()).toBe(true); + expect(selection.bounds()).toEqual(rectBounds(0, 0, 100, 100)); + }); + + it('invert of a selection keeps a document-border ants path plus the former outline', () => { + const { selection } = createHarness(); + selection.commit({ bounds: rectBounds(10, 10, 20, 20), op: 'replace', path: fakePath('a') }); + expect(selection.antsPaths()).toHaveLength(1); + selection.invert(rectBounds(0, 0, 100, 100)); + expect(selection.hasSelection()).toBe(true); + // The document border plus the former outline. + expect(selection.antsPaths()).toHaveLength(2); + }); + + it('clear deselects and drops the mask + bounds', () => { + const { selection, onChange } = createHarness(); + selection.commit({ bounds: rectBounds(10, 10, 20, 20), op: 'replace', path: fakePath('a') }); + onChange.mockClear(); + selection.clear(); + expect(selection.hasSelection()).toBe(false); + expect(selection.bounds()).toBeNull(); + expect(selection.mask()).toBeNull(); + expect(selection.antsPaths()).toHaveLength(0); + expect(onChange).toHaveBeenCalledTimes(1); + }); +}); + +describe('selectionState: teardown + guards', () => { + it('commit is a no-op with no document size', () => { + const { selection, onChange } = createHarness(null); + selection.commit({ bounds: rectBounds(0, 0, 10, 10), op: 'replace', path: fakePath('a') }); + expect(selection.hasSelection()).toBe(false); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('dispose clears state (document-replace teardown path)', () => { + const { selection } = createHarness(); + selection.commit({ bounds: rectBounds(0, 0, 10, 10), op: 'replace', path: fakePath('a') }); + selection.dispose(); + expect(selection.hasSelection()).toBe(false); + expect(selection.mask()).toBeNull(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/selectionState.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/selectionState.ts new file mode 100644 index 00000000000..35975b33fda --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/selection/selectionState.ts @@ -0,0 +1,344 @@ +/** + * Engine-transient pixel selection. + * + * Per the plan's state-tier table, a selection is *interaction* state: it lives + * on the engine, never in the reducer contract, and is not undoable. The source + * of truth is the set of closed `Path2D` polygons the lasso tool commits; the + * derived artifact is a bounded **mask surface** (alpha 255 inside the + * selection), sized to the selection extent and placed in document space (a + * {@link PlacedSurface}), built through the {@link RasterBackend} seam. Boolean ops + * (`replace`/`add`/`subtract`/`intersect`) are applied to the MASK — that is the + * single source of truth for painting/fill clipping. The committed path list is + * kept only for {@link marchingAnts} rendering (stroking the outlines), not for + * re-deriving the mask. + * + * ## Marching-ants approximation + * + * Ants are stroked from the committed path LIST (each path drawn dashed), not by + * tracing the mask's true edge. This matches the mask exactly for `replace` and + * `add`, and reads correctly for `subtract` (the cut-out path's outline shows as + * a hole) and `selectAll`/`invert` (a document-border path is included). It is an + * approximation for `intersect` and for heavily overlapping compositions, where + * the true selection outline is the boolean result rather than the union of the + * source outlines — the ants may show interior source edges that the mask does + * not. Mask-edge tracing is deferred; the mask itself is always exact. + * + * ## Emptiness + * + * `hasSelection` is tracked structurally, not by scanning pixels (a scan is + * unavailable on the node raster stub and costly on the DOM). Consequently, + * `subtract`ing away the entire selection leaves `hasSelection` true until an + * explicit deselect — a known, documented limitation of this phase. + * + * Zero React, zero import-time side effects. + */ + +import type { CreatePath2D } from '@workbench/canvas-engine/freehand'; +import type { RasterBackend, RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { PlacedSurface, Rect, SelectionOp, Vec2 } from '@workbench/canvas-engine/types'; + +import { intersect, isEmpty, roundOut, union } from '@workbench/canvas-engine/math/rect'; + +/** A committed selection contribution: the closed path and the op it applied. */ +export interface SelectionCommit { + path: Path2D; + /** The path's document-space bounds (used to maintain the selection bounds cheaply). */ + bounds: Rect; + op: SelectionOp; +} + +/** The engine-facing selection handle. */ +export interface SelectionState { + /** Whether a selection currently exists (drives clip/fill/ants gating). */ + hasSelection(): boolean; + /** The selection's document-space bounds, or `null` when empty. */ + bounds(): Rect | null; + /** + * The selection mask as a placed surface (alpha 255 inside) in document space, + * bounded to the selection extent (the `rect` records its origin/size), or + * `null` when empty. + */ + mask(): PlacedSurface | null; + /** The committed path outlines, for marching-ants rendering. */ + antsPaths(): readonly Path2D[]; + /** True if `p` (document space) is inside the selection. Cheap 1×1 read; `false` when empty. */ + containsPoint(p: Vec2): boolean; + /** Applies a committed lasso path against the mask with the given boolean op. */ + commit(commit: SelectionCommit): void; + /** Selects the whole `domain` rectangle (the engine passes `content ∪ bbox`). */ + selectAll(domain: Rect): void; + /** + * Inverts the selection within `domain` (empty → select `domain`). The domain + * is the bounded region the complement is taken over — the engine passes + * `content ∪ bbox`, the coherent analogue of legacy's bounded canvas now that + * the document rect is retired. + */ + invert(domain: Rect): void; + /** Clears the selection (deselect). */ + clear(): void; + /** Releases the mask surface reference. */ + dispose(): void; +} + +/** Dependencies injected by the engine. */ +export interface SelectionStateDeps { + backend: RasterBackend; + createPath2D: CreatePath2D; + /** Current document pixel size (the mask surface size), or `null` when no document. */ + getDocumentSize(): { width: number; height: number } | null; + /** Called after every mutation (engine syncs the `hasSelection` store + overlay/ants). */ + onChange(): void; +} + +const MASK_FILL = '#ffffff'; + +/** Builds a closed rectangle `Path2D` (document space) via the injected factory. */ +const rectPath = (createPath2D: CreatePath2D, r: Rect): Path2D => + createPath2D( + `M ${r.x} ${r.y} L ${r.x + r.width} ${r.y} L ${r.x + r.width} ${r.y + r.height} L ${r.x} ${r.y + r.height} Z` + ); + +/** The canvas composite op that realizes each boolean selection op on the mask. */ +const compositeForOp = (op: SelectionOp): GlobalCompositeOperation => { + switch (op) { + case 'add': + case 'replace': + return 'source-over'; + case 'subtract': + return 'destination-out'; + case 'intersect': + return 'destination-in'; + } +}; + +/** Creates an engine-transient selection bound to the raster backend seam. */ +export const createSelectionState = (deps: SelectionStateDeps): SelectionState => { + const { backend, createPath2D, getDocumentSize, onChange } = deps; + + let mask: RasterSurface | null = null; + // The mask surface's document-space bounds (its origin/extent). The mask is + // bounded to the selection, not the document — surface pixel (sx,sy) maps to + // document (maskRect.x+sx, maskRect.y+sy). + let maskRect: Rect | null = null; + let commits: SelectionCommit[] = []; + let selectionBounds: Rect | null = null; + let selected = false; + + /** + * Ensures the mask surface exists and covers `rect` (integer bounds), + * preserving any existing mask pixels at their new offset when it must grow. + */ + const ensureMask = (rect: Rect): RasterSurface => { + const want: Rect = roundOut(rect); + if (!mask || !maskRect) { + mask = backend.createSurface(Math.max(0, want.width), Math.max(0, want.height)); + maskRect = want; + return mask; + } + const grown = union(maskRect, want); + if ( + grown.x === maskRect.x && + grown.y === maskRect.y && + grown.width === maskRect.width && + grown.height === maskRect.height + ) { + return mask; + } + // Grow, preserving old pixels at their new offset (snapshot before resize). + let snapshot: ImageData | null = null; + if (maskRect.width > 0 && maskRect.height > 0) { + snapshot = mask.ctx.getImageData(0, 0, maskRect.width, maskRect.height); + } + mask.resize(grown.width, grown.height); + if (snapshot) { + mask.ctx.putImageData(snapshot, maskRect.x - grown.x, maskRect.y - grown.y); + } + maskRect = grown; + return mask; + }; + + /** Resets the mask surface to exactly `rect` (cleared), for `replace`-style ops. */ + const resetMask = (rect: Rect): RasterSurface => { + const want: Rect = roundOut(rect); + if (!mask) { + mask = backend.createSurface(Math.max(0, want.width), Math.max(0, want.height)); + } else if (mask.width !== want.width || mask.height !== want.height) { + mask.resize(Math.max(0, want.width), Math.max(0, want.height)); + } + maskRect = want; + clearSurface(mask); + return mask; + }; + + /** + * Fills a path into the mask under `op`'s composite mode. The mask surface is + * offset by `maskRect.origin`, so the (document-space) path is drawn through a + * translate that maps document → surface coordinates. + */ + const fillPath = (surface: RasterSurface, origin: Vec2, path: Path2D, op: GlobalCompositeOperation): void => { + const ctx = surface.ctx; + ctx.save(); + ctx.setTransform(1, 0, 0, 1, -origin.x, -origin.y); + ctx.globalCompositeOperation = op; + ctx.globalAlpha = 1; + ctx.fillStyle = MASK_FILL; + ctx.fill(path); + ctx.restore(); + }; + + const clearSurface = (surface: RasterSurface): void => { + const ctx = surface.ctx; + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.globalCompositeOperation = 'source-over'; + ctx.clearRect(0, 0, surface.width, surface.height); + ctx.restore(); + }; + + const clear = (): void => { + commits = []; + selectionBounds = null; + selected = false; + if (mask) { + clearSurface(mask); + } + onChange(); + }; + + const commit = (next: SelectionCommit): void => { + if (!getDocumentSize()) { + // No active document ⇒ no selection surface to build. + return; + } + const bounds = isEmpty(next.bounds) ? null : roundOut(next.bounds); + + if (next.op === 'replace') { + if (!bounds) { + clear(); + return; + } + const surface = resetMask(bounds); + fillPath(surface, { x: maskRect!.x, y: maskRect!.y }, next.path, 'source-over'); + commits = [next]; + selectionBounds = bounds; + selected = true; + onChange(); + return; + } + + if (next.op === 'intersect' && (!selected || !selectionBounds)) { + // Intersecting with nothing yields nothing. + clear(); + return; + } + + // For `add` the mask may need to grow to include the new path; `subtract` / + // `intersect` never grow the covered region (they only remove). + const surface = + next.op === 'add' && bounds + ? ensureMask(bounds) + : ensureMask(selectionBounds ?? bounds ?? { height: 0, width: 0, x: 0, y: 0 }); + fillPath(surface, { x: maskRect!.x, y: maskRect!.y }, next.path, compositeForOp(next.op)); + commits.push(next); + + switch (next.op) { + case 'add': + selectionBounds = selectionBounds && bounds ? union(selectionBounds, bounds) : (bounds ?? selectionBounds); + selected = selected || bounds !== null; + break; + case 'subtract': + // Bounds can only shrink; without a pixel scan we keep the (over-approx) + // prior bounds. `selected` stays true — see module docs. + break; + case 'intersect': + selectionBounds = selectionBounds && bounds ? intersect(selectionBounds, bounds) : null; + selected = selectionBounds !== null; + break; + } + onChange(); + }; + + const selectAll = (domain: Rect): void => { + const rect = roundOut(domain); + const surface = resetMask(rect); + const ctx = surface.ctx; + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.globalCompositeOperation = 'source-over'; + ctx.globalAlpha = 1; + ctx.fillStyle = MASK_FILL; + ctx.fillRect(0, 0, surface.width, surface.height); + ctx.restore(); + commits = [{ bounds: rect, op: 'replace', path: rectPath(createPath2D, rect) }]; + selectionBounds = rect; + selected = !isEmpty(rect); + onChange(); + }; + + const invert = (domain: Rect): void => { + if (!selected || !mask || !maskRect) { + // Inverting an empty selection selects the whole domain. + selectAll(domain); + return; + } + const rect = roundOut(domain); + // The current mask, captured over the domain: draw the (offset) mask onto a + // domain-sized temp so the complement is taken in domain-local coordinates. + const temp = backend.createSurface(Math.max(0, rect.width), Math.max(0, rect.height)); + const tctx = temp.ctx; + tctx.setTransform(1, 0, 0, 1, 0, 0); + tctx.globalCompositeOperation = 'source-over'; + tctx.globalAlpha = 1; + tctx.fillStyle = MASK_FILL; + tctx.fillRect(0, 0, temp.width, temp.height); + tctx.globalCompositeOperation = 'destination-out'; + // Punch out the current mask at its offset within the domain. + tctx.drawImage(mask.canvas, maskRect.x - rect.x, maskRect.y - rect.y); + + const surface = resetMask(rect); + const ctx = surface.ctx; + ctx.save(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.globalCompositeOperation = 'source-over'; + ctx.drawImage(temp.canvas, 0, 0); + ctx.restore(); + + // Ants: the outer domain border plus the former outlines (now holes). + commits = [{ bounds: rect, op: 'replace', path: rectPath(createPath2D, rect) }, ...commits]; + selectionBounds = rect; + selected = true; + onChange(); + }; + + const containsPoint = (p: Vec2): boolean => { + if (!selected || !mask || !maskRect) { + return false; + } + const x = Math.floor(p.x) - maskRect.x; + const y = Math.floor(p.y) - maskRect.y; + if (x < 0 || y < 0 || x >= mask.width || y >= mask.height) { + return false; + } + const pixel = mask.ctx.getImageData(x, y, 1, 1); + return pixel.data[3] !== undefined && pixel.data[3] > 0; + }; + + return { + antsPaths: () => commits.map((entry) => entry.path), + bounds: () => selectionBounds, + clear, + commit, + containsPoint, + dispose: () => { + mask = null; + maskRect = null; + commits = []; + selectionBounds = null; + selected = false; + }, + hasSelection: () => selected, + invert, + mask: () => (selected && mask && maskRect ? { rect: maskRect, surface: mask } : null), + selectAll, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/bboxHitTest.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/bboxHitTest.test.ts new file mode 100644 index 00000000000..449bcc7ac63 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/bboxHitTest.test.ts @@ -0,0 +1,178 @@ +import type { Rect } from '@workbench/canvas-engine/types'; + +import { describe, expect, it } from 'vitest'; + +import { + BBOX_HANDLE_HIT_PX, + bboxEquals, + bboxHandleAt, + bboxHandlePoint, + bboxTargetAt, + constrainBboxToRatio, + isInsideRect, + moveBbox, + resizeBbox, + roundBbox, +} from './bboxHitTest'; + +const rect = (x: number, y: number, width: number, height: number): Rect => ({ height, width, x, y }); + +describe('bboxHandlePoint', () => { + it('places the eight handles on corners and edge midpoints', () => { + const r = rect(0, 0, 100, 80); + expect(bboxHandlePoint(r, 'nw')).toEqual({ x: 0, y: 0 }); + expect(bboxHandlePoint(r, 'ne')).toEqual({ x: 100, y: 0 }); + expect(bboxHandlePoint(r, 'se')).toEqual({ x: 100, y: 80 }); + expect(bboxHandlePoint(r, 'sw')).toEqual({ x: 0, y: 80 }); + expect(bboxHandlePoint(r, 'n')).toEqual({ x: 50, y: 0 }); + expect(bboxHandlePoint(r, 'e')).toEqual({ x: 100, y: 40 }); + expect(bboxHandlePoint(r, 's')).toEqual({ x: 50, y: 80 }); + expect(bboxHandlePoint(r, 'w')).toEqual({ x: 0, y: 40 }); + }); +}); + +describe('bboxHandleAt (screen-space hit-testing)', () => { + it('returns the handle whose grab area contains the point', () => { + const screenRect = rect(0, 0, 100, 100); + expect(bboxHandleAt(screenRect, { x: 0, y: 0 })).toBe('nw'); + expect(bboxHandleAt(screenRect, { x: 100, y: 100 })).toBe('se'); + expect(bboxHandleAt(screenRect, { x: 50, y: 0 })).toBe('n'); + }); + + it('tolerates a few pixels of slack around the handle', () => { + const screenRect = rect(0, 0, 100, 100); + const slack = BBOX_HANDLE_HIT_PX / 2 - 1; + expect(bboxHandleAt(screenRect, { x: slack, y: slack })).toBe('nw'); + // Just outside the grab area → no handle. + expect(bboxHandleAt(screenRect, { x: BBOX_HANDLE_HIT_PX, y: BBOX_HANDLE_HIT_PX })).toBeNull(); + }); + + it('keeps the grab area a constant screen size across zooms', () => { + // Same doc bbox {0,0,100,100} projected at two zooms. A point 5px from the + // top-left corner in SCREEN space hits 'nw' regardless of the frame's on-screen size. + const atZoom1 = rect(0, 0, 100, 100); + const atZoom4 = rect(0, 0, 400, 400); + expect(bboxHandleAt(atZoom1, { x: 5, y: 5 })).toBe('nw'); + expect(bboxHandleAt(atZoom4, { x: 5, y: 5 })).toBe('nw'); + // The midpoint of the top edge moves with zoom: 50 at zoom1, 200 at zoom4. + expect(bboxHandleAt(atZoom1, { x: 50, y: 0 })).toBe('n'); + // At zoom4 the top-edge midpoint lives at 200, so 50 grabs nothing. + expect(bboxHandleAt(atZoom4, { x: 50, y: 0 })).toBeNull(); + expect(bboxHandleAt(atZoom4, { x: 200, y: 0 })).toBe('n'); + }); + + it('prefers a corner over an overlapping edge midpoint on a tiny frame', () => { + // At a 10px frame the edge midpoints sit within the corner grab areas; corners win. + const tiny = rect(0, 0, 10, 10); + expect(bboxHandleAt(tiny, { x: 0, y: 5 })).toBe('nw'); + }); +}); + +describe('bboxTargetAt', () => { + const screenRect = rect(10, 10, 100, 100); + it('returns a handle when over one', () => { + expect(bboxTargetAt(screenRect, { x: 10, y: 10 })).toBe('nw'); + }); + it("returns 'move' inside the frame away from handles", () => { + expect(bboxTargetAt(screenRect, { x: 60, y: 60 })).toBe('move'); + }); + it('returns null outside the frame (bbox is never deselected)', () => { + expect(bboxTargetAt(screenRect, { x: 200, y: 200 })).toBeNull(); + }); +}); + +describe('isInsideRect', () => { + it('includes the border', () => { + expect(isInsideRect(rect(0, 0, 10, 10), { x: 0, y: 10 })).toBe(true); + expect(isInsideRect(rect(0, 0, 10, 10), { x: 11, y: 5 })).toBe(false); + }); +}); + +describe('resizeBbox — free (unconstrained)', () => { + const start = rect(0, 0, 100, 100); + + it('grows the SE corner and snaps the moved edges to the grid', () => { + const out = resizeBbox({ constrain: false, dx: 10, dy: 10, grid: 8, handle: 'se', ratio: 1, snap: true, start }); + // right = snap(110) = 112, bottom = snap(110) = 112, anchored at (0,0). + expect(out).toEqual(rect(0, 0, 112, 112)); + }); + + it('drags the NW corner (moves origin) and snaps', () => { + const out = resizeBbox({ constrain: false, dx: -10, dy: -10, grid: 8, handle: 'nw', ratio: 1, snap: true, start }); + // left = snap(-10) = -8, top = -8; right/bottom fixed at 100. + expect(out).toEqual(rect(-8, -8, 108, 108)); + }); + + it('resizes a single edge, leaving the perpendicular axis untouched', () => { + const out = resizeBbox({ constrain: false, dx: 13, dy: 0, grid: 8, handle: 'e', ratio: 1, snap: true, start }); + expect(out).toEqual(rect(0, 0, 112, 100)); + }); + + it('bypasses snapping when snap is false (alt held)', () => { + const out = resizeBbox({ constrain: false, dx: 13, dy: 0, grid: 8, handle: 'e', ratio: 1, snap: false, start }); + expect(out).toEqual(rect(0, 0, 113, 100)); + }); + + it('clamps to a minimum of one grid cell when collapsing an edge', () => { + const out = resizeBbox({ constrain: false, dx: -500, dy: 0, grid: 8, handle: 'e', ratio: 1, snap: true, start }); + expect(out).toEqual(rect(0, 0, 8, 100)); + }); + + it('never goes below 1px when the grid is sub-pixel', () => { + const out = resizeBbox({ constrain: false, dx: -500, dy: 0, grid: 0, handle: 'e', ratio: 1, snap: false, start }); + expect(out.width).toBe(1); + }); +}); + +describe('resizeBbox — aspect constrained', () => { + const start = rect(0, 0, 100, 100); + + it('preserves the ratio on a corner drag (anchored at the opposite corner)', () => { + const out = resizeBbox({ constrain: true, dx: 100, dy: 0, grid: 8, handle: 'se', ratio: 2, snap: false, start }); + // Pointer widens to 200; ratio 2:1 → height 100. Anchor NW stays at (0,0). + expect(out).toEqual(rect(0, 0, 200, 100)); + }); + + it('re-derives the free axis on an edge drag, keeping the center fixed', () => { + const out = resizeBbox({ constrain: true, dx: 100, dy: 0, grid: 8, handle: 'e', ratio: 2, snap: false, start }); + // width 200 → height 100 (ratio 2), centered on the original vertical center (50). + expect(out).toEqual(rect(0, 0, 200, 100)); + }); + + it('grid-aligns the driven dimension while keeping the ratio exact', () => { + const out = resizeBbox({ constrain: true, dx: 13, dy: 13, grid: 8, handle: 'se', ratio: 1, snap: true, start }); + // se raw ~113 → snap width 112, height = width/ratio = 112. + expect(out.width).toBe(112); + expect(out.height).toBe(112); + }); +}); + +describe('moveBbox', () => { + it('translates and snaps the origin to the grid', () => { + expect(moveBbox(rect(10, 10, 100, 100), 5, 5, 8, true)).toEqual(rect(16, 16, 100, 100)); + }); + it('bypasses snap when snap is false', () => { + expect(moveBbox(rect(10, 10, 100, 100), 5, 5, 8, false)).toEqual(rect(15, 15, 100, 100)); + }); +}); + +describe('constrainBboxToRatio', () => { + it('re-fits to the ratio around the center and snaps the width', () => { + const out = constrainBboxToRatio(rect(0, 0, 100, 100), 2, 8); + expect(out.width / out.height).toBeCloseTo(2, 5); + expect(out.width % 8).toBe(0); + // Center preserved. + expect(out.x + out.width / 2).toBeCloseTo(50, 5); + expect(out.y + out.height / 2).toBeCloseTo(50, 5); + }); +}); + +describe('roundBbox / bboxEquals', () => { + it('rounds to integer document px with a ≥1 size', () => { + expect(roundBbox(rect(1.4, 2.6, 0.2, 10.5))).toEqual(rect(1, 3, 1, 11)); + }); + it('compares equality after rounding', () => { + expect(bboxEquals(rect(0, 0, 100, 100), rect(0.2, -0.1, 100.4, 99.6))).toBe(true); + expect(bboxEquals(rect(0, 0, 100, 100), rect(0, 0, 108, 100))).toBe(false); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/bboxHitTest.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/bboxHitTest.ts new file mode 100644 index 00000000000..1d5066328b4 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/bboxHitTest.ts @@ -0,0 +1,280 @@ +/** + * Pure geometry for the bbox (generation-frame) tool: handle layout, screen-space + * hit-testing, and resize/move math. + * + * The bbox is an axis-aligned document-space rectangle. Its eight resize handles + * (four corners + four edge midpoints) are hit-tested in SCREEN space so their + * grab areas stay a constant pixel size regardless of zoom — callers project the + * bbox to screen coordinates first. Resize math runs in DOCUMENT space: the edge + * opposite the grabbed handle stays fixed, the moved edge(s) follow a pointer + * delta, then snapping, min-size clamping, and (optionally) an aspect constraint + * are applied. + * + * Zero React, zero import-time side effects. + */ + +import type { Rect, Vec2 } from '@workbench/canvas-engine/types'; + +import { type AspectAnchor, constrainAspect, snapToGrid } from '@workbench/canvas-engine/math/snapping'; + +/** The eight resize handles: four corners + four edge midpoints. */ +export type BboxHandle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w'; + +/** What a pointer-down landed on: a resize handle, the interior (move), or nothing. */ +export type BboxTarget = BboxHandle | 'move'; + +/** + * Handles in hit-test priority order: corners first, so at a small bbox where a + * corner and an adjacent edge midpoint overlap, the corner wins. + */ +export const BBOX_HANDLES: readonly BboxHandle[] = ['nw', 'ne', 'se', 'sw', 'n', 'e', 's', 'w']; + +/** Side length (screen px) of a handle's square grab area. */ +export const BBOX_HANDLE_HIT_PX = 14; + +const hasWest = (h: BboxHandle): boolean => h === 'nw' || h === 'w' || h === 'sw'; +const hasEast = (h: BboxHandle): boolean => h === 'ne' || h === 'e' || h === 'se'; +const hasNorth = (h: BboxHandle): boolean => h === 'nw' || h === 'n' || h === 'ne'; +const hasSouth = (h: BboxHandle): boolean => h === 'sw' || h === 's' || h === 'se'; + +/** True for the four corner handles (two-letter ids), false for edge midpoints. */ +export const isCornerHandle = (h: BboxHandle): boolean => h.length === 2; + +/** The point of a handle on `rect` (works in any space — screen or document). */ +export const bboxHandlePoint = (rect: Rect, handle: BboxHandle): Vec2 => { + const x = hasWest(handle) ? rect.x : hasEast(handle) ? rect.x + rect.width : rect.x + rect.width / 2; + const y = hasNorth(handle) ? rect.y : hasSouth(handle) ? rect.y + rect.height : rect.y + rect.height / 2; + return { x, y }; +}; + +/** The corner of `rect` opposite `handle` (stays fixed while `handle` is dragged). */ +const OPPOSITE_CORNER: Record<'nw' | 'ne' | 'se' | 'sw', AspectAnchor> = { + ne: 'sw', + nw: 'se', + se: 'nw', + sw: 'ne', +}; + +/** True when `point` is inside (or on the border of) `rect`. */ +export const isInsideRect = (rect: Rect, point: Vec2): boolean => + point.x >= rect.x && point.x <= rect.x + rect.width && point.y >= rect.y && point.y <= rect.y + rect.height; + +/** + * The handle whose square grab area (side `hitSize`, screen px) contains + * `point`, or `null`. `screenRect` is the bbox already projected to screen space. + */ +export const bboxHandleAt = ( + screenRect: Rect, + point: Vec2, + hitSize: number = BBOX_HANDLE_HIT_PX +): BboxHandle | null => { + const half = hitSize / 2; + for (const handle of BBOX_HANDLES) { + const center = bboxHandlePoint(screenRect, handle); + if (Math.abs(point.x - center.x) <= half && Math.abs(point.y - center.y) <= half) { + return handle; + } + } + return null; +}; + +/** + * What a screen-space `point` targets on the bbox: a resize handle (grab area + * first), the interior (`'move'`), or `null` (outside — the bbox is never + * deselected). + */ +export const bboxTargetAt = ( + screenRect: Rect, + point: Vec2, + hitSize: number = BBOX_HANDLE_HIT_PX +): BboxTarget | null => { + const handle = bboxHandleAt(screenRect, point, hitSize); + if (handle) { + return handle; + } + return isInsideRect(screenRect, point) ? 'move' : null; +}; + +/** Minimum bbox side (document px): one grid cell, but never below 1px. */ +export const bboxMinSize = (grid: number): number => Math.max(1, grid); + +/** Translates `start` by a document-space delta, snapping the origin to `grid` unless bypassed. */ +export const moveBbox = (start: Rect, dx: number, dy: number, grid: number, snap: boolean): Rect => { + let x = start.x + dx; + let y = start.y + dy; + if (snap) { + x = snapToGrid(x, grid); + y = snapToGrid(y, grid); + } + return { height: start.height, width: start.width, x, y }; +}; + +export interface ResizeBboxParams { + /** The bbox at gesture start. */ + start: Rect; + handle: BboxHandle; + /** Document-space pointer delta since gesture start. */ + dx: number; + dy: number; + /** Model grid size (document px). */ + grid: number; + /** Whether to snap moved edges to the grid (false = alt-bypass). */ + snap: boolean; + /** Whether to preserve `ratio` (aspect lock, or shift). */ + constrain: boolean; + /** Target width / height ratio, used when `constrain`. */ + ratio: number; +} + +/** Builds a rect from a fixed anchor corner extending by `width`/`height` in the handle's directions. */ +const rectFromCorner = (fixed: Vec2, width: number, height: number, signX: number, signY: number): Rect => { + const x = signX >= 0 ? fixed.x : fixed.x - width; + const y = signY >= 0 ? fixed.y : fixed.y - height; + return { height, width, x, y }; +}; + +/** Unconstrained resize: moved edges follow the delta; the opposite edge stays fixed. */ +const resizeFree = (p: ResizeBboxParams): Rect => { + const { dx, dy, grid, handle, snap, start } = p; + let left = start.x; + let top = start.y; + let right = start.x + start.width; + let bottom = start.y + start.height; + + if (hasWest(handle)) { + left = snap ? snapToGrid(left + dx, grid) : left + dx; + } + if (hasEast(handle)) { + right = snap ? snapToGrid(right + dx, grid) : right + dx; + } + if (hasNorth(handle)) { + top = snap ? snapToGrid(top + dy, grid) : top + dy; + } + if (hasSouth(handle)) { + bottom = snap ? snapToGrid(bottom + dy, grid) : bottom + dy; + } + + const min = bboxMinSize(grid); + // Clamp so the moved edge never crosses within `min` of the fixed edge. + if (hasWest(handle)) { + left = Math.min(left, right - min); + } + if (hasEast(handle)) { + right = Math.max(right, left + min); + } + if (hasNorth(handle)) { + top = Math.min(top, bottom - min); + } + if (hasSouth(handle)) { + bottom = Math.max(bottom, top + min); + } + + return { height: bottom - top, width: right - left, x: left, y: top }; +}; + +/** Aspect-constrained corner resize: anchored at the opposite corner, grid-aligning width. */ +const resizeCornerConstrained = (p: ResizeBboxParams): Rect => { + const { dx, dy, grid, handle, ratio, snap, start } = p; + const anchorCorner = OPPOSITE_CORNER[handle as 'nw' | 'ne' | 'se' | 'sw']; + const fixed = bboxHandlePoint(start, anchorCorner as BboxHandle); + const moveStart = bboxHandlePoint(start, handle); + const mx = moveStart.x + dx; + const my = moveStart.y + dy; + const signX = moveStart.x >= fixed.x ? 1 : -1; + const signY = moveStart.y >= fixed.y ? 1 : -1; + + const rawW = Math.abs(mx - fixed.x); + const rawH = Math.abs(my - fixed.y); + + // Pick the driven axis: whichever keeps the frame closest to the pointer. + let width = rawW / rawH >= ratio ? rawW : rawH * ratio; + const min = bboxMinSize(grid); + width = snap ? Math.max(min, snapToGrid(width, grid)) : Math.max(min, width); + let height = width / ratio; + if (height < 1) { + height = 1; + width = height * ratio; + } + return rectFromCorner(fixed, width, height, signX, signY); +}; + +/** Aspect-constrained edge resize: the free axis follows the delta, the other axis re-derives from `ratio`, centered. */ +const resizeEdgeConstrained = (p: ResizeBboxParams): Rect => { + const { dx, dy, grid, handle, ratio, snap, start } = p; + const min = bboxMinSize(grid); + const left = start.x; + const right = start.x + start.width; + const top = start.y; + const bottom = start.y + start.height; + const centerX = start.x + start.width / 2; + const centerY = start.y + start.height / 2; + + if (handle === 'e' || handle === 'w') { + let width: number; + if (handle === 'e') { + const edge = snap ? snapToGrid(right + dx, grid) : right + dx; + width = Math.max(min, edge - left); + } else { + const edge = snap ? snapToGrid(left + dx, grid) : left + dx; + width = Math.max(min, right - edge); + } + const height = Math.max(1, width / ratio); + const x = handle === 'e' ? left : right - width; + return { height, width, x, y: centerY - height / 2 }; + } + + // 'n' | 's': height-driven. + let height: number; + if (handle === 's') { + const edge = snap ? snapToGrid(bottom + dy, grid) : bottom + dy; + height = Math.max(min, edge - top); + } else { + const edge = snap ? snapToGrid(top + dy, grid) : top + dy; + height = Math.max(min, bottom - edge); + } + const width = Math.max(1, height * ratio); + const y = handle === 's' ? top : bottom - height; + return { height, width, x: centerX - width / 2, y }; +}; + +/** + * Resizes `start` for a handle drag. Applies (in order) the delta to the moved + * edge(s), grid snapping (unless bypassed), min-size clamping, and — when + * `constrain` — an aspect-ratio constraint anchored at the opposite corner + * (corners) or the center (edges). + */ +export const resizeBbox = (p: ResizeBboxParams): Rect => { + if (p.constrain && p.ratio > 0) { + return isCornerHandle(p.handle) ? resizeCornerConstrained(p) : resizeEdgeConstrained(p); + } + return resizeFree(p); +}; + +/** Re-fits `rect` to `ratio`, keeping its center fixed, then snaps to `grid` and clamps min size. */ +export const constrainBboxToRatio = (rect: Rect, ratio: number, grid: number): Rect => { + if (ratio <= 0) { + return rect; + } + const fitted = constrainAspect(rect, ratio, 'center'); + const min = bboxMinSize(grid); + const width = Math.max(min, snapToGrid(fitted.width, grid)); + const height = Math.max(1, width / ratio); + const centerX = rect.x + rect.width / 2; + const centerY = rect.y + rect.height / 2; + return { height, width, x: centerX - width / 2, y: centerY - height / 2 }; +}; + +/** Rounds a rect's fields to integers (document px), enforcing a ≥1px size. */ +export const roundBbox = (rect: Rect): Rect => ({ + height: Math.max(1, Math.round(rect.height)), + width: Math.max(1, Math.round(rect.width)), + x: Math.round(rect.x), + y: Math.round(rect.y), +}); + +/** True when two rects are equal after rounding to integer document px. */ +export const bboxEquals = (a: Rect, b: Rect): boolean => { + const ra = roundBbox(a); + const rb = roundBbox(b); + return ra.x === rb.x && ra.y === rb.y && ra.width === rb.width && ra.height === rb.height; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/bboxTool.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/bboxTool.test.ts new file mode 100644 index 00000000000..91340e8760a --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/bboxTool.test.ts @@ -0,0 +1,232 @@ +import type { Tool, ToolContext } from '@workbench/canvas-engine/tools/tool'; +import type { PointerInput, Vec2 } from '@workbench/canvas-engine/types'; +import type { Viewport } from '@workbench/canvas-engine/viewport'; +import type { CanvasDocumentContractV2 } from '@workbench/types'; +import type { WorkbenchAction } from '@workbench/workbenchState'; + +import { createEngineStores } from '@workbench/canvas-engine/engineStores'; +import { describe, expect, it, vi } from 'vitest'; + +import { createBboxTool } from './bboxTool'; + +// Grid-aligned start frame (grid 8) so a zero-net-move gesture is a true no-op. +const bbox = { height: 96, width: 96, x: 16, y: 16 }; + +const makeDoc = (): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { ...bbox }, + height: 512, + layers: [], + selectedLayerId: null, + version: 2, + width: 512, +}); + +// Identity viewport: screen coordinates equal document coordinates (zoom 1, no pan), +// so pointer inputs can use the same value for both spaces. +const identityViewport = { + documentToScreen: (p: Vec2): Vec2 => ({ x: p.x, y: p.y }), +} as unknown as Viewport; + +const pointer = ( + x: number, + y: number, + opts: { shift?: boolean; alt?: boolean; buttons?: number } = {} +): PointerInput => ({ + buttons: opts.buttons ?? 1, + documentPoint: { x, y }, + modifiers: { alt: opts.alt ?? false, ctrl: false, meta: false, shift: opts.shift ?? false }, + pointerType: 'mouse', + pressure: 0.5, + screenPoint: { x, y }, + timeStamp: 0, +}); + +interface StructuralCommit { + label: string; + forward: WorkbenchAction; + inverse: WorkbenchAction; +} + +interface Harness { + ctx: ToolContext; + dispatched: WorkbenchAction[]; + commits: StructuralCommit[]; + previewOf: () => CanvasDocumentContractV2['bbox'] | null; +} + +const createHarness = (doc: CanvasDocumentContractV2): Harness => { + const dispatched: WorkbenchAction[] = []; + const commits: StructuralCommit[] = []; + const stores = createEngineStores(); + const ctx: ToolContext = { + backend: null as never, + commitStructural: (label, forward, inverse) => commits.push({ forward, inverse, label }), + createLayerId: () => 'x', + createPath2D: (d) => ({ d }) as unknown as Path2D, + dispatch: (action) => dispatched.push(action), + emitStrokeCommitted: vi.fn(), + getDocument: () => doc, + invalidate: vi.fn(), + layers: null as never, + notifyLayerPainted: vi.fn(), + setLayerTransformOverride: vi.fn(), + setOverlayCursor: vi.fn(), + stores, + updateCursor: vi.fn(), + viewport: identityViewport, + }; + return { commits, ctx, dispatched, previewOf: () => stores.bboxPreview.get() }; +}; + +const down = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerDown?.(ctx, i); +const move = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerMove?.(ctx, i, [i]); +const up = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerUp?.(ctx, i); +const cancel = (t: Tool, ctx: ToolContext): void => t.onPointerCancel?.(ctx); + +describe('bbox tool: move gesture', () => { + it('previews on move then commits one setCanvasBbox on up (grid-snapped)', () => { + const h = createHarness(makeDoc()); + const tool = createBboxTool(); + + down(tool, h.ctx, pointer(60, 60)); // inside the frame → move + move(tool, h.ctx, pointer(75, 75)); // delta (15,15) → moved + expect(h.previewOf()).not.toBeNull(); + + up(tool, h.ctx, pointer(75, 75)); + + expect(h.dispatched).toHaveLength(0); + expect(h.commits).toHaveLength(1); + // origin snapped: 16+15 = 31 → grid 8 → 32. + expect(h.commits[0]?.forward).toEqual({ bbox: { height: 96, width: 96, x: 32, y: 32 }, type: 'setCanvasBbox' }); + expect(h.commits[0]?.inverse).toEqual({ bbox: { ...bbox }, type: 'setCanvasBbox' }); + // Preview cleared after commit. + expect(h.previewOf()).toBeNull(); + }); + + it('bypasses snapping while alt is held', () => { + const h = createHarness(makeDoc()); + const tool = createBboxTool(); + down(tool, h.ctx, pointer(60, 60, { alt: true })); + move(tool, h.ctx, pointer(75, 75, { alt: true })); + up(tool, h.ctx, pointer(75, 75, { alt: true })); + expect(h.commits[0]?.forward).toEqual({ bbox: { height: 96, width: 96, x: 31, y: 31 }, type: 'setCanvasBbox' }); + }); +}); + +describe('bbox tool: resize gesture', () => { + it('resizes from the SE handle and commits the grid-snapped frame', () => { + const h = createHarness(makeDoc()); + const tool = createBboxTool(); + + down(tool, h.ctx, pointer(112, 112)); // SE corner handle + move(tool, h.ctx, pointer(130, 130)); // delta (18,18) + up(tool, h.ctx, pointer(130, 130)); + + expect(h.commits).toHaveLength(1); + // right/bottom = 112+18 = 130 → grid 8 → 128; anchored at (16,16). + expect(h.commits[0]?.forward).toEqual({ bbox: { height: 112, width: 112, x: 16, y: 16 }, type: 'setCanvasBbox' }); + }); + + it('constrains the ratio while shift is held on an unlocked frame', () => { + const h = createHarness(makeDoc()); + const tool = createBboxTool(); + // Start frame is square (96x96) → shift preserves 1:1. + down(tool, h.ctx, pointer(112, 112)); + move(tool, h.ctx, pointer(150, 112, { shift: true })); // widen only, ratio 1:1 keeps square + up(tool, h.ctx, pointer(150, 112, { shift: true })); + const out = h.commits[0]?.forward as { bbox: { width: number; height: number } }; + expect(out.bbox.width).toBe(out.bbox.height); + }); +}); + +describe('bbox tool: no-op cases', () => { + it('commits nothing on a zero-delta gesture that returns to the origin', () => { + const h = createHarness(makeDoc()); + const tool = createBboxTool(); + down(tool, h.ctx, pointer(60, 60)); + move(tool, h.ctx, pointer(70, 70)); // crosses threshold → moved + move(tool, h.ctx, pointer(60, 60)); // back to origin + up(tool, h.ctx, pointer(60, 60)); + expect(h.commits).toHaveLength(0); + expect(h.dispatched).toHaveLength(0); + expect(h.previewOf()).toBeNull(); + }); + + it('does nothing when pressing outside the frame (never deselects)', () => { + const h = createHarness(makeDoc()); + const tool = createBboxTool(); + down(tool, h.ctx, pointer(400, 400)); + move(tool, h.ctx, pointer(420, 420)); + up(tool, h.ctx, pointer(420, 420)); + expect(h.commits).toHaveLength(0); + expect(h.dispatched).toHaveLength(0); + expect(h.previewOf()).toBeNull(); + }); + + it('ignores a sub-threshold press (no drag)', () => { + const h = createHarness(makeDoc()); + const tool = createBboxTool(); + down(tool, h.ctx, pointer(60, 60)); + up(tool, h.ctx, pointer(61, 61)); + expect(h.commits).toHaveLength(0); + }); +}); + +describe('bbox tool: cancel', () => { + it('drops the preview and commits nothing on escape/cancel', () => { + const h = createHarness(makeDoc()); + const tool = createBboxTool(); + down(tool, h.ctx, pointer(112, 112)); + move(tool, h.ctx, pointer(130, 130)); + expect(h.previewOf()).not.toBeNull(); + cancel(tool, h.ctx); + expect(h.commits).toHaveLength(0); + expect(h.dispatched).toHaveLength(0); + expect(h.previewOf()).toBeNull(); + }); +}); + +describe('bbox tool: hover cursors', () => { + it('reflects the hovered handle/interior in the cursor and refreshes it on change', () => { + const h = createHarness(makeDoc()); + const tool = createBboxTool(); + // Hover (no gesture) then read the tool's CSS cursor. Frame is {16,16,96,96}. + const cursorAt = (x: number, y: number): string | undefined => { + move(tool, h.ctx, pointer(x, y)); + return tool.cursor?.(h.ctx); + }; + + expect(cursorAt(112, 112)).toBe('nwse-resize'); // SE corner + expect(cursorAt(16, 112)).toBe('nesw-resize'); // SW corner + expect(cursorAt(16, 16)).toBe('nwse-resize'); // NW corner + expect(cursorAt(112, 16)).toBe('nesw-resize'); // NE corner + expect(cursorAt(64, 16)).toBe('ns-resize'); // N edge midpoint + expect(cursorAt(112, 64)).toBe('ew-resize'); // E edge midpoint + expect(cursorAt(64, 64)).toBe('move'); // interior + expect(cursorAt(400, 400)).toBe('default'); // off the frame + + // The tool asked the engine to re-apply the cursor as the hover target changed. + expect(h.ctx.updateCursor).toHaveBeenCalled(); + }); + + it('holds the grabbed handle cursor during a resize drag', () => { + const h = createHarness(makeDoc()); + const tool = createBboxTool(); + down(tool, h.ctx, pointer(112, 112)); // grab SE corner + move(tool, h.ctx, pointer(130, 130)); // dragging + expect(tool.cursor?.(h.ctx)).toBe('nwse-resize'); + }); +}); + +describe('bbox tool: undo/redo contract', () => { + it('carries an inverse that restores the prior bbox', () => { + const h = createHarness(makeDoc()); + const tool = createBboxTool(); + down(tool, h.ctx, pointer(112, 112)); + move(tool, h.ctx, pointer(130, 130)); + up(tool, h.ctx, pointer(130, 130)); + // The engine's commitStructural dispatches `inverse` on undo — restoring the original frame. + expect(h.commits[0]?.inverse).toEqual({ bbox: { ...bbox }, type: 'setCanvasBbox' }); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/bboxTool.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/bboxTool.ts new file mode 100644 index 00000000000..44015ceabf5 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/bboxTool.ts @@ -0,0 +1,203 @@ +/** + * The bbox tool: interactive editing of the generation bounding box + * (`document.bbox`). + * + * Interaction contract (CANVAS_PLAN Phase 4.1): + * - **Pointer-down** hit-tests the bbox: on a resize handle → a resize gesture; + * inside the frame → a move gesture; outside → nothing (the bbox is never + * "deselected"). + * - **Pointer-move** updates an engine-transient preview rect (via + * `stores.bboxPreview`) that the overlay renders — it never dispatches. Sizes + * and positions snap to the model grid (`stores.bboxGrid`); hold **alt** to + * bypass snapping. Corner/edge resize preserves the aspect ratio when the lock + * is active (`stores.bboxOptions`); **shift** toggles the constraint on for an + * unlocked frame (a locked ratio stays locked). + * - **Commit** (pointer-up after a real change): exactly one `commitStructural` + * with the new/old bbox (`setCanvasBbox`, undoable). A zero-delta gesture + * commits nothing. + * - **Cancel** (Esc / pointercancel): drops the preview, no dispatch. + * + * Zero React, zero import-time side effects. + */ + +import type { Rect, Vec2 } from '@workbench/canvas-engine/types'; + +import type { Tool, ToolContext } from './tool'; + +import { type BboxTarget, bboxEquals, bboxTargetAt, moveBbox, resizeBbox, roundBbox } from './bboxHitTest'; + +/** Bit for the primary (usually left) mouse button in `PointerEvent.buttons`. */ +const PRIMARY_BUTTON = 1; + +/** + * The CSS cursor for each bbox target: axis-aware resize arrows on the handles + * (opposite corners/edges share a cursor) and `move` inside the frame. + */ +const CURSOR_FOR_TARGET: Record = { + e: 'ew-resize', + move: 'move', + n: 'ns-resize', + ne: 'nesw-resize', + nw: 'nwse-resize', + s: 'ns-resize', + se: 'nwse-resize', + sw: 'nesw-resize', + w: 'ew-resize', +}; + +/** Screen-space distance (CSS px) the pointer must travel before a press becomes a drag. */ +export const BBOX_DRAG_THRESHOLD_PX = 3; + +interface GestureState { + target: BboxTarget; + /** The bbox at gesture start (document space). */ + startBbox: Rect; + /** The pointer's document/screen position at gesture start. */ + startDoc: Vec2; + startScreen: Vec2; + moved: boolean; +} + +/** The bbox projected to screen space (axis-aligned; the view has no rotation). */ +const bboxToScreen = (ctx: ToolContext, bbox: Rect): Rect => { + const topLeft = ctx.viewport.documentToScreen({ x: bbox.x, y: bbox.y }); + const bottomRight = ctx.viewport.documentToScreen({ x: bbox.x + bbox.width, y: bbox.y + bbox.height }); + return { + height: bottomRight.y - topLeft.y, + width: bottomRight.x - topLeft.x, + x: topLeft.x, + y: topLeft.y, + }; +}; + +/** Computes the next bbox for a gesture from the current pointer, applying snap/aspect rules. */ +const nextBboxFor = ( + ctx: ToolContext, + state: GestureState, + point: Vec2, + modifiers: { alt: boolean; shift: boolean } +): Rect => { + const grid = ctx.stores.bboxGrid.get(); + // Snap to the model grid unless Alt bypasses it or the snap-to-grid setting is off. + const snap = !modifiers.alt && ctx.stores.snapToGrid.get(); + const dx = point.x - state.startDoc.x; + const dy = point.y - state.startDoc.y; + + if (state.target === 'move') { + return moveBbox(state.startBbox, dx, dy, grid, snap); + } + + const options = ctx.stores.bboxOptions.get(); + const constrain = options.aspectLocked || modifiers.shift; + // Locked → the option ratio; unlocked+shift → preserve the frame's own ratio. + const ratio = options.aspectLocked + ? options.aspectRatio + : state.startBbox.height > 0 + ? state.startBbox.width / state.startBbox.height + : 1; + + return resizeBbox({ constrain, dx, dy, grid, handle: state.target, ratio, snap, start: state.startBbox }); +}; + +/** Creates a fresh bbox tool with its own gesture state. */ +export const createBboxTool = (): Tool => { + let state: GestureState | null = null; + // The bbox target under the pointer while idle (no gesture), driving the hover + // resize/move cursor. `null` = the pointer is off the frame. + let hoverTarget: BboxTarget | null = null; + + const clearPreview = (ctx: ToolContext): void => { + ctx.stores.bboxPreview.set(null); + ctx.invalidate({ overlay: true }); + }; + + return { + // During a drag the grabbed target's cursor holds; while idle the hovered + // handle/interior wins; off the frame it falls back to the default arrow. + cursor: () => { + const target = state?.target ?? hoverTarget; + return target ? CURSOR_FOR_TARGET[target] : 'default'; + }, + id: 'bbox', + onDeactivate: (ctx) => { + state = null; + hoverTarget = null; + clearPreview(ctx); + }, + onPointerCancel: (ctx) => { + state = null; + clearPreview(ctx); + }, + onPointerDown: (ctx, input) => { + if (state || (input.buttons & PRIMARY_BUTTON) === 0) { + return; + } + const doc = ctx.getDocument(); + if (!doc) { + return; + } + const target = bboxTargetAt(bboxToScreen(ctx, doc.bbox), input.screenPoint); + if (!target) { + // Outside the frame: the bbox is never deselected, so do nothing. + return; + } + state = { + moved: false, + startBbox: doc.bbox, + startDoc: input.documentPoint, + startScreen: input.screenPoint, + target, + }; + }, + onPointerMove: (ctx, input) => { + if (!state) { + // Idle hover: reflect the handle/interior under the pointer in the cursor. + const doc = ctx.getDocument(); + const target = doc ? bboxTargetAt(bboxToScreen(ctx, doc.bbox), input.screenPoint) : null; + if (target !== hoverTarget) { + hoverTarget = target; + ctx.updateCursor(); + } + return; + } + if (!state.moved) { + const dxs = input.screenPoint.x - state.startScreen.x; + const dys = input.screenPoint.y - state.startScreen.y; + if (Math.hypot(dxs, dys) < BBOX_DRAG_THRESHOLD_PX) { + return; + } + state.moved = true; + } + const next = nextBboxFor(ctx, state, input.documentPoint, input.modifiers); + ctx.stores.bboxPreview.set(next); + ctx.invalidate({ overlay: true }); + }, + onPointerUp: (ctx, input) => { + if (!state) { + return; + } + const current = state; + state = null; + + if (!current.moved) { + clearPreview(ctx); + return; + } + + const next = roundBbox(nextBboxFor(ctx, current, input.documentPoint, input.modifiers)); + if (bboxEquals(next, current.startBbox)) { + // Zero-delta gesture: nothing to commit. + clearPreview(ctx); + return; + } + + ctx.commitStructural( + 'Set generation frame', + { bbox: next, type: 'setCanvasBbox' }, + { bbox: roundBbox(current.startBbox), type: 'setCanvasBbox' } + ); + // The committed bbox now flows through the mirror; drop the preview. + clearPreview(ctx); + }, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/brushTool.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/brushTool.ts new file mode 100644 index 00000000000..2d99e2ee339 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/brushTool.ts @@ -0,0 +1,26 @@ +/** + * The brush tool: paints variable-width, pressure-sensitive strokes in the brush + * color into the target paint layer's cache surface. It is a thin binding over + * {@link createPaintTool} that sources its size/color/opacity/pressure from the + * engine's `brushOptions` store; all gesture and pixel logic lives in the shared + * paint tool and {@link createStrokeSession}. + * + * Each engine builds its own instance so gesture state is per-engine. Zero React, + * zero import-time side effects. + */ + +import { PRESSURE_THINNING } from '@workbench/canvas-engine/tools/paintConstants'; +import { createPaintTool } from '@workbench/canvas-engine/tools/paintTool'; + +import type { Tool } from './tool'; + +/** Creates a fresh brush tool with its own gesture state. */ +export const createBrushTool = (): Tool => + createPaintTool({ + color: (ctx) => ctx.stores.brushOptions.get().color, + composite: 'source-over', + id: 'brush', + opacity: (ctx) => ctx.stores.brushOptions.get().opacity, + size: (ctx) => ctx.stores.brushOptions.get().size, + thinning: (ctx) => (ctx.stores.brushOptions.get().pressureSensitivity ? PRESSURE_THINNING : 0), + }); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/colorPickerTool.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/colorPickerTool.test.ts new file mode 100644 index 00000000000..a632a805bb1 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/colorPickerTool.test.ts @@ -0,0 +1,192 @@ +import type { RasterBackend, RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { Tool, ToolContext } from '@workbench/canvas-engine/tools/tool'; +import type { PointerInput } from '@workbench/canvas-engine/types'; +import type { CanvasDocumentContractV2, CanvasLayerContract } from '@workbench/types'; +import type { WorkbenchAction } from '@workbench/workbenchState'; + +import { createEngineStores } from '@workbench/canvas-engine/engineStores'; +import { createLayerCacheStore, type LayerCacheStore } from '@workbench/canvas-engine/render/layerCache'; +import { describe, expect, it, vi } from 'vitest'; + +import { createColorPickerTool } from './colorPickerTool'; + +/** A mutable RGBA pixel a test can change between gesture steps, injected into every `getImageData` call. */ +type MutablePixel = { current: readonly [number, number, number, number] }; + +/** A minimal `RasterBackend` whose scratch surfaces report `pixel.current` for every `getImageData` call. */ +const createFixedPixelBackend = (pixel: MutablePixel): RasterBackend => ({ + createImageBitmap: () => Promise.resolve({} as ImageBitmap), + createSurface: (width: number, height: number): RasterSurface => { + const canvas = { height, width } as unknown as OffscreenCanvas; + const ctx = { + clearRect: () => {}, + drawImage: () => {}, + getImageData: () => + ({ data: Uint8ClampedArray.from(pixel.current), height: 1, width: 1 }) as unknown as ImageData, + restore: () => {}, + save: () => {}, + setTransform: () => {}, + } as unknown as OffscreenCanvasRenderingContext2D; + return { canvas, ctx, height, resize: () => {}, width }; + }, + encodeSurface: () => Promise.resolve(new Blob()), +}); + +const paintLayer = (id: string): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { bitmap: null, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', +}); + +const makeDoc = (): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers: [paintLayer('paint1')], + selectedLayerId: 'paint1', + version: 2, + width: 100, +}); + +const pointer = (x: number, y: number, opts: { buttons?: number } = {}): PointerInput => ({ + buttons: opts.buttons ?? 1, + documentPoint: { x, y }, + modifiers: { alt: true, ctrl: false, meta: false, shift: false }, + pointerType: 'mouse', + pressure: 0.5, + screenPoint: { x, y }, + timeStamp: 0, +}); + +interface Harness { + ctx: ToolContext; + pixel: MutablePixel; + layers: LayerCacheStore; + dispatched: WorkbenchAction[]; + strokes: unknown[]; + overlayCursors: unknown[]; +} + +const createHarness = (doc: CanvasDocumentContractV2 | null): Harness => { + const pixel: MutablePixel = { current: [10, 20, 30, 255] }; + const backend = createFixedPixelBackend(pixel); + const layers = createLayerCacheStore(backend); + if (doc) { + layers.getOrCreate('paint1', doc.width, doc.height); + } + const stores = createEngineStores(); + const dispatched: WorkbenchAction[] = []; + const strokes: unknown[] = []; + const overlayCursors: unknown[] = []; + + const ctx: ToolContext = { + backend, + commitStructural: vi.fn(), + createLayerId: () => 'unused', + createPath2D: (d) => ({ d }) as unknown as Path2D, + dispatch: (action) => dispatched.push(action), + emitStrokeCommitted: (event) => strokes.push(event), + getDocument: () => doc, + invalidate: vi.fn(), + layers, + notifyLayerPainted: vi.fn(), + setLayerTransformOverride: vi.fn(), + setOverlayCursor: (cursor) => overlayCursors.push(cursor), + stores, + updateCursor: vi.fn(), + viewport: { getZoom: () => 1 } as unknown as ToolContext['viewport'], + }; + + return { ctx, dispatched, layers, overlayCursors, pixel, strokes }; +}; + +const down = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerDown?.(ctx, i); +const move = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerMove?.(ctx, i, [i]); +const up = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerUp?.(ctx, i); + +describe('color picker tool', () => { + it('samples the composited color on pointer down and writes it into brushOptions.color', () => { + const h = createHarness(makeDoc()); + const tool = createColorPickerTool(); + + down(tool, h.ctx, pointer(10, 10)); + + expect(h.ctx.stores.brushOptions.get().color).toBe('#0a141e'); + }); + + it('re-samples on drag (primary button held) as the sample changes', () => { + const h = createHarness(makeDoc()); + const tool = createColorPickerTool(); + + down(tool, h.ctx, pointer(10, 10)); + expect(h.ctx.stores.brushOptions.get().color).toBe('#0a141e'); + + h.pixel.current = [40, 50, 60, 255]; + move(tool, h.ctx, pointer(20, 20)); + expect(h.ctx.stores.brushOptions.get().color).toBe('#28323c'); + }); + + it('does not sample on move when no button is held', () => { + const h = createHarness(makeDoc()); + const tool = createColorPickerTool(); + const defaultColor = h.ctx.stores.brushOptions.get().color; + + move(tool, h.ctx, pointer(10, 10, { buttons: 0 })); + + expect(h.ctx.stores.brushOptions.get().color).toBe(defaultColor); + }); + + it('leaves brushOptions untouched when the point falls outside the document', () => { + const h = createHarness(makeDoc()); + const tool = createColorPickerTool(); + const defaultColor = h.ctx.stores.brushOptions.get().color; + + down(tool, h.ctx, pointer(-5, 10)); + + expect(h.ctx.stores.brushOptions.get().color).toBe(defaultColor); + }); + + it('leaves brushOptions untouched when there is no document', () => { + const h = createHarness(null); + const tool = createColorPickerTool(); + const defaultColor = h.ctx.stores.brushOptions.get().color; + + down(tool, h.ctx, pointer(10, 10)); + + expect(h.ctx.stores.brushOptions.get().color).toBe(defaultColor); + }); + + it('never dispatches and never emits a committed stroke', () => { + const h = createHarness(makeDoc()); + const tool = createColorPickerTool(); + + down(tool, h.ctx, pointer(10, 10)); + move(tool, h.ctx, pointer(20, 20)); + up(tool, h.ctx, pointer(20, 20)); + + expect(h.dispatched).toHaveLength(0); + expect(h.strokes).toHaveLength(0); + }); + + it('shows a ring cursor while active and clears it on deactivate', () => { + const h = createHarness(makeDoc()); + const tool = createColorPickerTool(); + + move(tool, h.ctx, pointer(10, 10, { buttons: 0 })); + expect(h.overlayCursors.at(-1)).toMatchObject({ point: { x: 10, y: 10 } }); + + tool.onDeactivate?.(h.ctx); + expect(h.overlayCursors.at(-1)).toBeNull(); + }); + + it('reports a crosshair cursor', () => { + const tool = createColorPickerTool(); + expect(tool.cursor?.({} as ToolContext)).toBe('crosshair'); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/colorPickerTool.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/colorPickerTool.ts new file mode 100644 index 00000000000..6d0b0cf5b28 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/colorPickerTool.ts @@ -0,0 +1,74 @@ +/** + * The color-picker tool: while held down (usually via the alt-hold temp-tool + * switch the pointer pipeline already drives — see `input/pointerPipeline.ts`), + * it samples the composited document color under the cursor and writes it into + * the brush color option, so releasing alt drops the user right back into + * painting with the picked color. Sampling reads the layer cache directly + * through {@link sampleDocumentColor} — this tool never dispatches and never + * touches pixels. + * + * Each engine builds its own instance so cursor-ring state is per-engine. + * Zero React, zero import-time side effects. + */ + +import type { PointerInput } from '@workbench/canvas-engine/types'; + +import { rgbaToHex } from '@workbench/canvas-engine/color'; +import { sampleDocumentColor } from '@workbench/canvas-engine/render/colorSample'; + +import type { Tool, ToolContext } from './tool'; + +/** Bit for the primary (usually left) mouse button in `PointerEvent.buttons`. */ +const PRIMARY_BUTTON = 1; + +/** Fixed on-screen radius (px) for the picker's ring cursor, independent of zoom. */ +const CURSOR_SCREEN_RADIUS_PX = 8; + +const updateCursorRing = (ctx: ToolContext, input: PointerInput): void => { + const zoom = ctx.viewport.getZoom(); + ctx.setOverlayCursor({ point: input.documentPoint, radiusDoc: CURSOR_SCREEN_RADIUS_PX / Math.max(zoom, 1e-6) }); + ctx.invalidate({ overlay: true }); +}; + +/** Samples the composited color under `input` and writes it into the brush color option, if pickable. */ +const pickColorAt = (ctx: ToolContext, input: PointerInput): void => { + const doc = ctx.getDocument(); + if (!doc) { + return; + } + const sample = sampleDocumentColor(doc, ctx.layers, ctx.backend, input.documentPoint); + if (!sample) { + return; + } + const hex = rgbaToHex(sample.r, sample.g, sample.b); + const opts = ctx.stores.brushOptions.get(); + if (hex !== opts.color) { + ctx.stores.brushOptions.set({ ...opts, color: hex }); + } +}; + +/** Creates a fresh color-picker tool. */ +export const createColorPickerTool = (): Tool => ({ + cursor: () => 'crosshair', + id: 'colorPicker', + onDeactivate: (ctx) => { + ctx.setOverlayCursor(null); + ctx.invalidate({ overlay: true }); + }, + onPointerDown: (ctx, input) => { + if ((input.buttons & PRIMARY_BUTTON) === 0) { + return; + } + updateCursorRing(ctx, input); + pickColorAt(ctx, input); + }, + onPointerMove: (ctx, input) => { + updateCursorRing(ctx, input); + if (input.buttons & PRIMARY_BUTTON) { + pickColorAt(ctx, input); + } + }, + onPointerUp: (ctx, input) => { + updateCursorRing(ctx, input); + }, +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/eraserTool.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/eraserTool.ts new file mode 100644 index 00000000000..8914ae61166 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/eraserTool.ts @@ -0,0 +1,25 @@ +/** + * The eraser tool: clears pixels along a stroke by compositing the same freehand + * shape as the brush with `destination-out` into the target paint layer's cache. + * A thin binding over {@link createPaintTool} sourcing size/opacity from the + * engine's `eraserOptions` store; the eraser is pressure-insensitive (thinning 0) + * and has no color (the shape alone drives the erase). + * + * Each engine builds its own instance so gesture state is per-engine. Zero React, + * zero import-time side effects. + */ + +import { createPaintTool } from '@workbench/canvas-engine/tools/paintTool'; + +import type { Tool } from './tool'; + +/** Creates a fresh eraser tool with its own gesture state. */ +export const createEraserTool = (): Tool => + createPaintTool({ + color: () => '#000000', + composite: 'destination-out', + id: 'eraser', + opacity: (ctx) => ctx.stores.eraserOptions.get().opacity, + size: (ctx) => ctx.stores.eraserOptions.get().size, + thinning: () => 0, + }); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/gradientTool.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/gradientTool.test.ts new file mode 100644 index 00000000000..5c2a921e52f --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/gradientTool.test.ts @@ -0,0 +1,221 @@ +import type { Tool, ToolContext } from '@workbench/canvas-engine/tools/tool'; +import type { PointerInput, Vec2 } from '@workbench/canvas-engine/types'; +import type { Viewport } from '@workbench/canvas-engine/viewport'; +import type { CanvasDocumentContractV2, CanvasLayerContract } from '@workbench/types'; +import type { WorkbenchAction } from '@workbench/workbenchState'; + +import { createEngineStores } from '@workbench/canvas-engine/engineStores'; +import { describe, expect, it, vi } from 'vitest'; + +import { angleFromDrag, createGradientTool } from './gradientTool'; + +const gradientLayer = (over: Partial = {}): CanvasLayerContract => + ({ + blendMode: 'normal', + id: 'grad-existing', + isEnabled: true, + isLocked: false, + name: 'Gradient', + opacity: 1, + source: { + angle: 0, + kind: 'linear', + stops: [ + { color: '#000000', offset: 0 }, + { color: '#ffffff', offset: 1 }, + ], + type: 'gradient', + }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + ...over, + }) as CanvasLayerContract; + +const makeDoc = (over: Partial = {}): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 96, width: 96, x: 0, y: 0 }, + height: 512, + layers: [], + selectedLayerId: null, + version: 2, + width: 512, + ...over, +}); + +const identityViewport = { + documentToScreen: (p: Vec2): Vec2 => ({ x: p.x, y: p.y }), +} as unknown as Viewport; + +const pointer = (x: number, y: number, buttons = 1): PointerInput => ({ + buttons, + documentPoint: { x, y }, + modifiers: { alt: false, ctrl: false, meta: false, shift: false }, + pointerType: 'mouse', + pressure: 0.5, + screenPoint: { x, y }, + timeStamp: 0, +}); + +interface StructuralCommit { + label: string; + forward: WorkbenchAction; + inverse: WorkbenchAction; +} + +const createHarness = (doc: CanvasDocumentContractV2) => { + const dispatched: WorkbenchAction[] = []; + const commits: StructuralCommit[] = []; + const stores = createEngineStores(); + let idCounter = 0; + const ctx: ToolContext = { + backend: null as never, + commitStructural: (label, forward, inverse) => commits.push({ forward, inverse, label }), + createLayerId: () => `grad-${++idCounter}`, + createPath2D: (d) => ({ d }) as unknown as Path2D, + dispatch: (action) => dispatched.push(action), + emitStrokeCommitted: vi.fn(), + getDocument: () => doc, + invalidate: vi.fn(), + layers: null as never, + notifyLayerPainted: vi.fn(), + setLayerTransformOverride: vi.fn(), + setOverlayCursor: vi.fn(), + stores, + updateCursor: vi.fn(), + viewport: identityViewport, + }; + return { commits, ctx, dispatched, previewOf: () => stores.gradientPreview.get(), stores }; +}; + +const down = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerDown?.(ctx, i); +const move = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerMove?.(ctx, i, [i]); +const up = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerUp?.(ctx, i); + +describe('angleFromDrag', () => { + it('is 0° for a left→right drag and 90° for a top→bottom drag', () => { + expect(angleFromDrag({ x: 0, y: 0 }, { x: 10, y: 0 })).toBeCloseTo(0); + expect(angleFromDrag({ x: 0, y: 0 }, { x: 0, y: 10 })).toBeCloseTo(90); + }); +}); + +describe('gradient tool: create when no gradient selected', () => { + it('creates a document-covering gradient layer with the drag angle', () => { + const h = createHarness(makeDoc()); + const tool = createGradientTool(); + + down(tool, h.ctx, pointer(10, 10)); + move(tool, h.ctx, pointer(10, 110)); + expect(h.previewOf()).toEqual({ end: { x: 10, y: 110 }, start: { x: 10, y: 10 } }); + + up(tool, h.ctx, pointer(10, 110)); + + expect(h.dispatched).toHaveLength(0); + expect(h.commits).toHaveLength(1); + const forward = h.commits[0]?.forward; + expect(forward?.type).toBe('addCanvasLayer'); + if ( + forward?.type === 'addCanvasLayer' && + forward.layer.type === 'raster' && + forward.layer.source.type === 'gradient' + ) { + expect(forward.layer.source.angle).toBeCloseTo(90); + expect(forward.layer.source.kind).toBe('linear'); + expect(forward.layer.transform).toEqual({ rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }); + } else { + throw new Error('expected a gradient layer'); + } + expect(h.commits[0]?.inverse).toEqual({ ids: ['grad-1'], type: 'removeCanvasLayers' }); + expect(h.previewOf()).toBeNull(); + }); +}); + +describe('gradient tool: edit selected gradient layer', () => { + it('commits ONE updateCanvasLayerSource with the new angle (kind/stops preserved)', () => { + const layer = gradientLayer(); + const doc = makeDoc({ layers: [layer], selectedLayerId: 'grad-existing' }); + const h = createHarness(doc); + const tool = createGradientTool(); + + down(tool, h.ctx, pointer(0, 0)); + move(tool, h.ctx, pointer(100, 0)); + up(tool, h.ctx, pointer(100, 0)); + + expect(h.commits).toHaveLength(1); + const forward = h.commits[0]?.forward; + const inverse = h.commits[0]?.inverse; + expect(forward?.type).toBe('updateCanvasLayerSource'); + if (forward?.type === 'updateCanvasLayerSource' && forward.source.type === 'gradient') { + expect(forward.id).toBe('grad-existing'); + expect(forward.source.angle).toBeCloseTo(0); + expect(forward.source.kind).toBe('linear'); + expect(forward.source.stops).toHaveLength(2); + } else { + throw new Error('expected an updateCanvasLayerSource gradient edit'); + } + // Inverse restores the exact original source object. + if (inverse?.type === 'updateCanvasLayerSource' && layer.type === 'raster') { + expect(inverse.source).toBe(layer.source); + } else { + throw new Error('expected an inverse source restore'); + } + }); + + it('is a no-op when the selected gradient layer is locked', () => { + const layer = gradientLayer({ isLocked: true }); + const doc = makeDoc({ layers: [layer], selectedLayerId: 'grad-existing' }); + const h = createHarness(doc); + const tool = createGradientTool(); + + down(tool, h.ctx, pointer(0, 0)); + move(tool, h.ctx, pointer(100, 0)); + up(tool, h.ctx, pointer(100, 0)); + + expect(h.commits).toHaveLength(0); + expect(h.dispatched).toHaveLength(0); + expect(h.previewOf()).toBeNull(); + }); + + it('is a no-op when the selected gradient layer is radial (angle has no visual effect)', () => { + const layer = gradientLayer({ + source: { + angle: 0, + kind: 'radial', + stops: [ + { color: '#000000', offset: 0 }, + { color: '#ffffff', offset: 1 }, + ], + type: 'gradient', + }, + } as Partial); + const doc = makeDoc({ layers: [layer], selectedLayerId: 'grad-existing' }); + const h = createHarness(doc); + const tool = createGradientTool(); + + down(tool, h.ctx, pointer(0, 0)); + move(tool, h.ctx, pointer(100, 0)); + up(tool, h.ctx, pointer(100, 0)); + + // A radial gradient ignores `angle`, so dragging on one would only ever + // produce an angle-only, visually-inert commit. Skipped entirely: no + // commit, no dispatch, no dangling preview. + expect(h.commits).toHaveLength(0); + expect(h.dispatched).toHaveLength(0); + expect(h.previewOf()).toBeNull(); + }); + + it('creates a new gradient when the selected layer is not a gradient', () => { + const paint = gradientLayer({ + id: 'paint-1', + source: { bitmap: null, type: 'paint' }, + } as Partial); + const doc = makeDoc({ layers: [paint], selectedLayerId: 'paint-1' }); + const h = createHarness(doc); + const tool = createGradientTool(); + + down(tool, h.ctx, pointer(0, 0)); + move(tool, h.ctx, pointer(50, 50)); + up(tool, h.ctx, pointer(50, 50)); + + expect(h.commits[0]?.forward.type).toBe('addCanvasLayer'); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/gradientTool.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/gradientTool.ts new file mode 100644 index 00000000000..91190cfef78 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/gradientTool.ts @@ -0,0 +1,174 @@ +/** + * The gradient tool: drag to set a gradient's ANGLE from the drag vector. + * + * Interaction contract (CANVAS_PLAN Phase 6.1): + * - **Pointer-down** (primary button) starts a gesture at the press point. + * - **Pointer-move** (past a small threshold) updates a transient overlay + * preview (`stores.gradientPreview`, the drag vector) — it never dispatches. + * - **Commit** (pointer-up after a real drag): exactly ONE commit. + * - A gradient layer is selected (unlocked + visible) → one `commitStructural` + * with `updateCanvasLayerSource` (new/old angle; kind + stops preserved). + * - Otherwise → create a new bbox-sized gradient layer (angle from the drag, + * kind + stops from the tool options, extent = the generation frame) via + * `addCanvasLayer`. + * - A selected gradient layer that is locked/hidden is a no-op (don't silently + * spawn a new layer over it), mirroring the paint tool's locked-target rule. + * - **Cancel** (Esc / pointercancel): drops the preview, no dispatch. + * + * Gradient layers are content-sized via the contract's explicit `width`/`height` + * extent — set at creation (bbox-sized) and preserved across angle edits — so + * only the ANGLE is derived from the drag, not a bounding box. The selection + * mask does NOT constrain gradient layers (parametric, not pixels). + * + * Zero React, zero import-time side effects. + */ + +import type { Vec2 } from '@workbench/canvas-engine/types'; +import type { CanvasLayerSourceContract, CanvasRasterLayerContractV2 } from '@workbench/types'; +import type { WorkbenchAction } from '@workbench/workbenchState'; + +import type { Tool, ToolContext } from './tool'; + +/** Bit for the primary (usually left) mouse button in `PointerEvent.buttons`. */ +const PRIMARY_BUTTON = 1; + +/** Screen-space distance (CSS px) the pointer must travel before a press becomes a drag. */ +export const GRADIENT_DRAG_THRESHOLD_PX = 3; + +interface GestureState { + startDoc: Vec2; + startScreen: Vec2; + moved: boolean; +} + +/** Degrees of the vector from `start` to `end` (0° = left→right). */ +export const angleFromDrag = (start: Vec2, end: Vec2): number => + (Math.atan2(end.y - start.y, end.x - start.x) * 180) / Math.PI; + +/** Creates a fresh gradient tool with its own gesture state. */ +export const createGradientTool = (): Tool => { + let state: GestureState | null = null; + + const clearPreview = (ctx: ToolContext): void => { + ctx.stores.gradientPreview.set(null); + ctx.invalidate({ overlay: true }); + }; + + return { + cursor: () => 'crosshair', + id: 'gradient', + onDeactivate: (ctx) => { + state = null; + clearPreview(ctx); + }, + onKeyCommand: (ctx, command) => { + if (command === 'cancel' && state) { + state = null; + clearPreview(ctx); + } + }, + onPointerCancel: (ctx) => { + state = null; + clearPreview(ctx); + }, + onPointerDown: (ctx, input) => { + if (state || (input.buttons & PRIMARY_BUTTON) === 0) { + return; + } + if (!ctx.getDocument()) { + return; + } + state = { moved: false, startDoc: input.documentPoint, startScreen: input.screenPoint }; + }, + onPointerMove: (ctx, input) => { + if (!state) { + return; + } + if (!state.moved) { + const dxs = input.screenPoint.x - state.startScreen.x; + const dys = input.screenPoint.y - state.startScreen.y; + if (Math.hypot(dxs, dys) < GRADIENT_DRAG_THRESHOLD_PX) { + return; + } + state.moved = true; + } + ctx.stores.gradientPreview.set({ end: input.documentPoint, start: state.startDoc }); + ctx.invalidate({ overlay: true }); + }, + onPointerUp: (ctx, input) => { + if (!state) { + return; + } + const current = state; + state = null; + + if (!current.moved) { + clearPreview(ctx); + return; + } + + const doc = ctx.getDocument(); + if (!doc) { + clearPreview(ctx); + return; + } + const angle = angleFromDrag(current.startDoc, input.documentPoint); + const selected = doc.selectedLayerId ? doc.layers.find((layer) => layer.id === doc.selectedLayerId) : undefined; + + if (selected && selected.type === 'raster' && selected.source.type === 'gradient') { + // Edit the selected gradient layer — unless it's locked/hidden (no-op). + if (selected.isLocked || !selected.isEnabled) { + clearPreview(ctx); + return; + } + const old = selected.source; + // A radial gradient's rendering ignores `angle` entirely — dragging on + // one would only ever change that inert field, producing a commit with + // zero visual effect and a useless history entry. Skip it. + if (old.kind === 'radial') { + clearPreview(ctx); + return; + } + const forward: WorkbenchAction = { + id: selected.id, + source: { ...old, angle }, + type: 'updateCanvasLayerSource', + }; + const inverse: WorkbenchAction = { id: selected.id, source: old, type: 'updateCanvasLayerSource' }; + ctx.commitStructural('Edit gradient', forward, inverse); + clearPreview(ctx); + return; + } + + // Create a new bbox-sized gradient layer. The document rect is retired, so + // creation covers the generation frame (bbox): the extent is the bbox size, + // positioned at the bbox origin via the layer transform. Angle-drag edits on + // an existing gradient preserve its extent (the `...old` spread above). + const options = ctx.stores.gradientOptions.get(); + const layerId = ctx.createLayerId(); + const source: CanvasLayerSourceContract = { + angle, + height: doc.bbox.height, + kind: options.kind, + stops: options.stops.map((stop) => ({ ...stop })), + type: 'gradient', + width: doc.bbox.width, + }; + const layer: CanvasRasterLayerContractV2 = { + blendMode: 'normal', + id: layerId, + isEnabled: true, + isLocked: false, + name: `Gradient ${doc.layers.length + 1}`, + opacity: 1, + source, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: doc.bbox.x, y: doc.bbox.y }, + type: 'raster', + }; + const forward: WorkbenchAction = { index: 0, layer, type: 'addCanvasLayer' }; + const inverse: WorkbenchAction = { ids: [layerId], type: 'removeCanvasLayers' }; + ctx.commitStructural('Add gradient', forward, inverse); + clearPreview(ctx); + }, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/lassoTool.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/lassoTool.test.ts new file mode 100644 index 00000000000..4940cfa7ccd --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/lassoTool.test.ts @@ -0,0 +1,138 @@ +import type { SelectionCommit } from '@workbench/canvas-engine/selection/selectionState'; +import type { Tool, ToolContext } from '@workbench/canvas-engine/tools/tool'; +import type { PointerInput } from '@workbench/canvas-engine/types'; +import type { WorkbenchAction } from '@workbench/workbenchState'; + +import { createEngineStores } from '@workbench/canvas-engine/engineStores'; +import { createLassoTool, lassoOpFor } from '@workbench/canvas-engine/tools/lassoTool'; +import { describe, expect, it, vi } from 'vitest'; + +const pointer = ( + x: number, + y: number, + opts: { shift?: boolean; alt?: boolean; buttons?: number } = {} +): PointerInput => ({ + buttons: opts.buttons ?? 1, + documentPoint: { x, y }, + modifiers: { alt: opts.alt ?? false, ctrl: false, meta: false, shift: opts.shift ?? false }, + pointerType: 'mouse', + pressure: 0.5, + screenPoint: { x, y }, + timeStamp: 0, +}); + +const createHarness = () => { + const stores = createEngineStores(); + const commits: SelectionCommit[] = []; + const dispatched: WorkbenchAction[] = []; + const ctx = { + commitSelection: (commit: SelectionCommit) => commits.push(commit), + createPath2D: (d?: string) => ({ d }) as unknown as Path2D, + dispatch: (action: WorkbenchAction) => dispatched.push(action), + invalidate: vi.fn(), + stores, + } as unknown as ToolContext; + return { commits, ctx, dispatched, stores }; +}; + +const down = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerDown?.(ctx, i); +const move = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerMove?.(ctx, i, [i]); +const up = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerUp?.(ctx, i); + +describe('lassoOpFor', () => { + it('maps modifiers to ops, falling back to the persistent mode', () => { + const none = { alt: false, ctrl: false, meta: false, shift: false }; + expect(lassoOpFor(none, 'replace')).toBe('replace'); + expect(lassoOpFor(none, 'add')).toBe('add'); + expect(lassoOpFor({ ...none, shift: true }, 'replace')).toBe('add'); + expect(lassoOpFor({ ...none, alt: true }, 'replace')).toBe('subtract'); + expect(lassoOpFor({ ...none, alt: true, shift: true }, 'replace')).toBe('intersect'); + }); +}); + +describe('lassoTool: drag → commit', () => { + const drag = (tool: Tool, ctx: ToolContext, upOpts: { shift?: boolean; alt?: boolean } = {}): void => { + down(tool, ctx, pointer(0, 0)); + move(tool, ctx, pointer(20, 0)); + move(tool, ctx, pointer(20, 20)); + up(tool, ctx, pointer(0, 20, upOpts)); + }; + + it('commits a replace by default and publishes/clears the live preview', () => { + const tool = createLassoTool(); + const { commits, ctx, stores } = createHarness(); + + down(tool, ctx, pointer(0, 0)); + move(tool, ctx, pointer(20, 0)); + expect(stores.lassoPreview.get()).not.toBeNull(); + + move(tool, ctx, pointer(20, 20)); + up(tool, ctx, pointer(0, 20)); + + expect(commits).toHaveLength(1); + expect(commits[0]!.op).toBe('replace'); + expect(commits[0]!.bounds).toEqual({ height: 20, width: 20, x: 0, y: 0 }); + // Preview cleared on commit. + expect(stores.lassoPreview.get()).toBeNull(); + }); + + it('resolves each modifier op', () => { + for (const [mods, op] of [ + [{ shift: true }, 'add'], + [{ alt: true }, 'subtract'], + [{ shift: true, alt: true }, 'intersect'], + ] as const) { + const tool = createLassoTool(); + const { commits, ctx } = createHarness(); + drag(tool, ctx, mods); + expect(commits[0]!.op).toBe(op); + } + }); + + it('uses the persistent lasso op mode when no modifier is held', () => { + const tool = createLassoTool(); + const { commits, ctx, stores } = createHarness(); + stores.lassoOptions.set({ mode: 'subtract' }); + drag(tool, ctx); + expect(commits[0]!.op).toBe('subtract'); + }); + + it('never dispatches to the reducer (selection is transient)', () => { + const tool = createLassoTool(); + const { ctx, dispatched } = createHarness(); + drag(tool, ctx); + expect(dispatched).toHaveLength(0); + }); +}); + +describe('lassoTool: cancel + guards', () => { + it('Escape/pointercancel drops the in-progress path without committing', () => { + const tool = createLassoTool(); + const { commits, ctx, stores } = createHarness(); + down(tool, ctx, pointer(0, 0)); + move(tool, ctx, pointer(20, 0)); + move(tool, ctx, pointer(20, 20)); + tool.onPointerCancel?.(ctx); + expect(commits).toHaveLength(0); + expect(stores.lassoPreview.get()).toBeNull(); + }); + + it('a click (too few points) commits nothing', () => { + const tool = createLassoTool(); + const { commits, ctx } = createHarness(); + down(tool, ctx, pointer(5, 5)); + up(tool, ctx, pointer(5, 5)); + expect(commits).toHaveLength(0); + }); + + it('decimates points closer than the minimum distance', () => { + const tool = createLassoTool(); + const { ctx, stores } = createHarness(); + down(tool, ctx, pointer(0, 0)); + // These are all within 1px of each other → decimated away. + move(tool, ctx, pointer(0.5, 0)); + move(tool, ctx, pointer(1, 0)); + const preview = stores.lassoPreview.get(); + expect(preview).toHaveLength(1); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/lassoTool.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/lassoTool.ts new file mode 100644 index 00000000000..48322c750fa --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/lassoTool.ts @@ -0,0 +1,123 @@ +/** + * The lasso tool: freehand pixel-selection. + * + * A primary-button drag accumulates document-space polygon points (coalesced by + * the pipeline, then distance-decimated here like the brush thins its input); a + * live dashed preview of the in-progress path is published to the engine's + * `lassoPreview` store (a transient channel — no dispatch, no reducer traffic). + * Pointer-up closes the path and commits it to the engine's selection through + * {@link ToolContext.commitSelection}, with the boolean op resolved from the + * modifiers (shift = add, alt = subtract, shift+alt = intersect) or, with none + * held, the persistent op mode from `lassoOptions`. Escape / pointercancel drops + * the in-progress path. + * + * Selection edits are transient interaction state: they are NOT dispatches and + * NOT recorded on the engine's undo history this phase (legacy parity — selection + * changes aren't undoable). Zero React, zero import-time side effects. + */ + +import type { PointerInput, SelectionOp, Vec2 } from '@workbench/canvas-engine/types'; + +import { polygonBounds, polygonToSvgPath } from '@workbench/canvas-engine/freehand'; + +import type { Tool, ToolContext } from './tool'; + +/** Bit for the primary (usually left) mouse button in `PointerEvent.buttons`. */ +const PRIMARY_BUTTON = 1; + +/** Minimum document-space gap between stored polygon points (input decimation). */ +export const LASSO_MIN_POINT_DISTANCE = 2; + +/** Fewest distinct points that make a fillable selection polygon. */ +const MIN_POLYGON_POINTS = 3; + +/** Resolves the boolean op for a commit: modifiers win, else the persistent mode. */ +export const lassoOpFor = (modifiers: PointerInput['modifiers'], mode: SelectionOp): SelectionOp => { + if (modifiers.shift && modifiers.alt) { + return 'intersect'; + } + if (modifiers.shift) { + return 'add'; + } + if (modifiers.alt) { + return 'subtract'; + } + return mode; +}; + +const distance = (a: Vec2, b: Vec2): number => Math.hypot(a.x - b.x, a.y - b.y); + +/** Creates a fresh lasso tool with its own per-gesture point buffer. */ +export const createLassoTool = (): Tool => { + let points: Vec2[] = []; + let active = false; + + const reset = (): void => { + points = []; + active = false; + }; + + /** Appends a point if it is far enough from the last stored one. */ + const pushPoint = (p: Vec2): void => { + const last = points[points.length - 1]; + if (!last || distance(last, p) >= LASSO_MIN_POINT_DISTANCE) { + points.push({ x: p.x, y: p.y }); + } + }; + + const publishPreview = (ctx: ToolContext): void => { + ctx.stores.lassoPreview.set(points.length > 0 ? points.slice() : null); + ctx.invalidate({ overlay: true }); + }; + + const clearPreview = (ctx: ToolContext): void => { + ctx.stores.lassoPreview.set(null); + ctx.invalidate({ overlay: true }); + }; + + return { + cursor: () => 'crosshair', + id: 'lasso', + onDeactivate: (ctx) => { + reset(); + clearPreview(ctx); + }, + onPointerCancel: (ctx) => { + reset(); + clearPreview(ctx); + }, + onPointerDown: (ctx, input) => { + if (active || (input.buttons & PRIMARY_BUTTON) === 0) { + return; + } + active = true; + points = [{ x: input.documentPoint.x, y: input.documentPoint.y }]; + publishPreview(ctx); + }, + onPointerMove: (ctx, input, batch) => { + if (!active) { + return; + } + for (const sample of batch) { + pushPoint(sample.documentPoint); + } + publishPreview(ctx); + }, + onPointerUp: (ctx, input) => { + if (!active) { + return; + } + pushPoint(input.documentPoint); + const polygon = points.slice(); + reset(); + clearPreview(ctx); + if (polygon.length < MIN_POLYGON_POINTS || !ctx.commitSelection) { + return; + } + const path = ctx.createPath2D(polygonToSvgPath(polygon)); + const bounds = polygonBounds(polygon); + const op = lassoOpFor(input.modifiers, ctx.stores.lassoOptions.get().mode); + ctx.commitSelection({ bounds, op, path }); + }, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/moveHitTest.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/moveHitTest.test.ts new file mode 100644 index 00000000000..75403560c83 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/moveHitTest.test.ts @@ -0,0 +1,348 @@ +import type { CanvasDocumentContractV2, CanvasLayerContract } from '@workbench/types'; + +import { estimateTextExtent } from '@workbench/canvas-engine/render/rasterizers/textRasterizer'; +import { describe, expect, it } from 'vitest'; + +import { hitTestLayer, hittableLayerSize, layerOutlineCorners, topLayerAt } from './moveHitTest'; + +const shapeLayer = (id: string, width: number, height: number): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { fill: '#ffffff', height, kind: 'rect', stroke: null, strokeWidth: 0, type: 'shape', width }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', +}); + +const gradientLayer = (id: string): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { angle: 0, kind: 'linear', stops: [], type: 'gradient' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', +}); + +const textLayer = (id: string, content: string): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { + align: 'left', + color: '#000000', + content, + fontFamily: 'sans', + fontSize: 20, + fontWeight: 400, + lineHeight: 1.2, + type: 'text', + }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', +}); + +const imageLayer = ( + id: string, + opts: { + x?: number; + y?: number; + scaleX?: number; + scaleY?: number; + rotation?: number; + width?: number; + height?: number; + isEnabled?: boolean; + isLocked?: boolean; + } = {} +): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: opts.isEnabled ?? true, + isLocked: opts.isLocked ?? false, + name: id, + opacity: 1, + source: { image: { height: opts.height ?? 20, imageName: id, width: opts.width ?? 20 }, type: 'image' }, + transform: { + rotation: opts.rotation ?? 0, + scaleX: opts.scaleX ?? 1, + scaleY: opts.scaleY ?? 1, + x: opts.x ?? 0, + y: opts.y ?? 0, + }, + type: 'raster', +}); + +/** A control layer (composite group rank 1) backed by an image source. */ +const controlLayer = (id: string, opts: { width?: number; height?: number } = {}): CanvasLayerContract => ({ + adapter: { + beginEndStepPct: [0, 1], + controlMode: 'balanced', + kind: 'controlnet', + model: null, + weight: 1, + }, + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { image: { height: opts.height ?? 20, imageName: id, width: opts.width ?? 20 }, type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'control', + withTransparencyEffect: false, +}); + +const paintLayer = ( + id: string, + bitmap?: { width: number; height: number; offset?: { x: number; y: number } } +): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: bitmap + ? { + bitmap: { height: bitmap.height, imageName: `${id}-bmp`, width: bitmap.width }, + offset: bitmap.offset, + type: 'paint', + } + : { bitmap: null, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', +}); + +const maskLayer = ( + id: string, + bitmap?: { width: number; height: number; offset?: { x: number; y: number } } +): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + mask: { + bitmap: bitmap ? { height: bitmap.height, imageName: `${id}-mask`, width: bitmap.width } : null, + fill: { color: '#ff0000', style: 'solid' }, + ...(bitmap?.offset ? { offset: bitmap.offset } : {}), + }, + name: id, + opacity: 1, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'inpaint_mask', +}); + +const doc = (layers: CanvasLayerContract[]): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers, + selectedLayerId: null, + version: 2, + width: 100, +}); + +describe('hittableLayerSize', () => { + it('returns native size for image layers', () => { + expect(hittableLayerSize(imageLayer('a', { width: 30, height: 40 }), doc([]))).toEqual({ height: 40, width: 30 }); + }); + + it('returns null for an EMPTY paint layer (content-sized: no bitmap ⇒ not hit-testable)', () => { + expect(hittableLayerSize(paintLayer('a'), doc([]))).toBeNull(); + }); + + it('returns the persisted bitmap size for a paint layer with content', () => { + expect(hittableLayerSize(paintLayer('a', { height: 40, width: 60 }), doc([]))).toEqual({ height: 40, width: 60 }); + }); + + it('returns null for an EMPTY (bitmap-less) mask, but the bitmap size for a painted mask (masks are movable)', () => { + expect(hittableLayerSize(maskLayer('m'), doc([]))).toBeNull(); + expect(hittableLayerSize(maskLayer('m', { height: 24, width: 32 }), doc([]))).toEqual({ height: 24, width: 32 }); + }); + + it('returns the shape extent for shape layers (param for parametric)', () => { + expect(hittableLayerSize(shapeLayer('s', 30, 40), doc([]))).toEqual({ height: 40, width: 30 }); + }); + + it('returns document size for gradient layers', () => { + expect(hittableLayerSize(gradientLayer('g'), doc([]))).toEqual({ height: 100, width: 100 }); + }); + + it('returns the estimated text extent for text layers', () => { + const layer = textLayer('t', 'hi'); + expect(hittableLayerSize(layer, doc([]))).toEqual( + estimateTextExtent(layer.type === 'raster' && layer.source.type === 'text' ? layer.source : (null as never)) + ); + }); +}); + +describe('hitTestLayer: parametric layers are now hit-testable', () => { + it('hits inside a shape layer and misses outside', () => { + const layer = shapeLayer('s', 30, 40); + const d = doc([layer]); + expect(hitTestLayer(layer, d, { x: 15, y: 20 })).toBe(true); + expect(hitTestLayer(layer, d, { x: 35, y: 20 })).toBe(false); + }); +}); + +describe('hitTestLayer', () => { + it('hits inside the transformed bounds and misses outside', () => { + const layer = imageLayer('a', { x: 10, y: 10, width: 20, height: 20 }); + const d = doc([layer]); + expect(hitTestLayer(layer, d, { x: 15, y: 15 })).toBe(true); + expect(hitTestLayer(layer, d, { x: 9, y: 15 })).toBe(false); + expect(hitTestLayer(layer, d, { x: 31, y: 15 })).toBe(false); + }); + + it('accounts for scale', () => { + // 20x20 image scaled x2 → covers [0,40]x[0,40] in document space. + const layer = imageLayer('a', { scaleX: 2, scaleY: 2, width: 20, height: 20 }); + const d = doc([layer]); + expect(hitTestLayer(layer, d, { x: 39, y: 39 })).toBe(true); + expect(hitTestLayer(layer, d, { x: 41, y: 41 })).toBe(false); + }); + + it('accounts for rotation (90° about origin)', () => { + // Rotate a 20x40 image 90° clockwise about its origin: local (0,0)->(0,0), + // local (0,40) -> document (-40, 0). Document point (-10, 5) maps to a local + // point inside [0,20]x[0,40]. + const layer = imageLayer('a', { rotation: Math.PI / 2, width: 20, height: 40 }); + const d = doc([layer]); + expect(hitTestLayer(layer, d, { x: -10, y: 5 })).toBe(true); + expect(hitTestLayer(layer, d, { x: 10, y: 5 })).toBe(false); + }); + + it('never hits a mask layer (no source bounds)', () => { + const layer = maskLayer('m'); + expect(hitTestLayer(layer, doc([layer]), { x: 5, y: 5 })).toBe(false); + }); +}); + +describe('topLayerAt', () => { + it('returns the top-most (index 0) layer that passes the predicate and contains the point', () => { + const top = imageLayer('top', { width: 50, height: 50 }); + const bottom = imageLayer('bottom', { width: 50, height: 50 }); + const d = doc([top, bottom]); + expect(topLayerAt(d, { x: 10, y: 10 }, () => true)?.id).toBe('top'); + }); + + it('skips layers the predicate rejects (e.g. locked/hidden)', () => { + const top = imageLayer('top', { width: 50, height: 50, isLocked: true }); + const bottom = imageLayer('bottom', { width: 50, height: 50 }); + const d = doc([top, bottom]); + // Drag predicate rejects locked → falls through to the bottom layer. + expect(topLayerAt(d, { x: 10, y: 10 }, (l) => !l.isLocked)?.id).toBe('bottom'); + }); + + it('returns null on empty space', () => { + const layer = imageLayer('a', { x: 0, y: 0, width: 10, height: 10 }); + expect(topLayerAt(doc([layer]), { x: 80, y: 80 }, () => true)).toBeNull(); + }); +}); + +describe('topLayerAt: composite-group ordering (batch finding N1)', () => { + // The compositor draws by group rank (raster < control < regional < inpaint + // mask), NOT by raw array index, so the hit-test must agree: a layer in a higher + // group wins the hit over a lower-group layer that sits EARLIER in the array. + it('a control layer wins the hit over an overlapping raster placed earlier in the array', () => { + const raster = imageLayer('raster'); // global index 0 — would win under naive array order + const control = controlLayer('control'); // index 1, but composites above the raster + const d = doc([raster, control]); + expect(topLayerAt(d, { x: 10, y: 10 }, () => true)?.id).toBe('control'); + }); + + it('an inpaint mask (top group) wins over control and raster below it', () => { + const raster = imageLayer('raster'); + const control = controlLayer('control'); + const mask = maskLayer('mask', { width: 20, height: 20 }); + // Array order deliberately does NOT match composite order. + const d = doc([raster, mask, control]); + expect(topLayerAt(d, { x: 10, y: 10 }, () => true)?.id).toBe('mask'); + }); + + it('within a group, array order still decides (index 0 is top-most within the group)', () => { + const top = controlLayer('top'); + const bottom = controlLayer('bottom'); + const raster = imageLayer('raster'); + const d = doc([top, bottom, raster]); + expect(topLayerAt(d, { x: 10, y: 10 }, () => true)?.id).toBe('top'); + }); + + it('falls through to a lower group when the predicate rejects the higher one', () => { + const raster = imageLayer('raster'); + const control = controlLayer('control'); + const d = doc([raster, control]); + // e.g. move-tool auto-select excluding controls → the raster below is grabbed. + expect(topLayerAt(d, { x: 10, y: 10 }, (l) => l.type === 'raster')?.id).toBe('raster'); + }); +}); + +describe('layerOutlineCorners', () => { + it('returns the four document-space corners of the rendered rect', () => { + const layer = imageLayer('a', { x: 5, y: 5, width: 10, height: 10 }); + const corners = layerOutlineCorners(layer, doc([layer])); + expect(corners).toEqual([ + { x: 5, y: 5 }, + { x: 15, y: 5 }, + { x: 15, y: 15 }, + { x: 5, y: 15 }, + ]); + }); + + it('applies an x/y override without mutating the layer transform', () => { + const layer = imageLayer('a', { x: 5, y: 5, width: 10, height: 10 }); + const corners = layerOutlineCorners(layer, doc([layer]), { x: 25, y: 30 }); + expect(corners?.[0]).toEqual({ x: 25, y: 30 }); + expect(corners?.[2]).toEqual({ x: 35, y: 40 }); + expect(layer.transform.x).toBe(5); + }); + + it('returns null for a mask layer', () => { + const layer = maskLayer('m'); + expect(layerOutlineCorners(layer, doc([layer]))).toBeNull(); + }); +}); + +describe('live cache rect (freshly-painted, not-yet-flushed content)', () => { + it('hitTestLayer misses an empty paint layer with no live rect', () => { + const layer = paintLayer('p'); // bitmap: null → empty persisted content + expect(hitTestLayer(layer, doc([layer]), { x: 30, y: 30 })).toBe(false); + }); + + it('hitTestLayer hits content present only in the live cache rect', () => { + const layer = paintLayer('p'); // persisted content still empty (unflushed stroke) + const liveRect = { height: 40, width: 40, x: 20, y: 20 }; + // Inside the live rect: grabbable even though the contract bitmap is null. + expect(hitTestLayer(layer, doc([layer]), { x: 30, y: 30 }, liveRect)).toBe(true); + // Outside the live rect: still a miss. + expect(hitTestLayer(layer, doc([layer]), { x: 5, y: 5 }, liveRect)).toBe(false); + }); + + it('topLayerAt uses the liveRectOf resolver to grab unflushed content', () => { + const layer = paintLayer('p'); + const d = doc([layer]); + const liveRectOf = (id: string) => (id === 'p' ? { height: 40, width: 40, x: 20, y: 20 } : undefined); + expect(topLayerAt(d, { x: 30, y: 30 }, () => true, liveRectOf)?.id).toBe('p'); + // Without the resolver the empty layer is ungrabbable. + expect(topLayerAt(d, { x: 30, y: 30 }, () => true)).toBeNull(); + }); + + it('unions the live rect with persisted content (both contribute to the hit area)', () => { + const layer = paintLayer('p', { width: 10, height: 10 }); // persisted content [0,0,10,10] + const liveRect = { height: 10, width: 10, x: 40, y: 40 }; // grown region off to the side + // A point only inside the grown live region is now hit-testable. + expect(hitTestLayer(layer, doc([layer]), { x: 45, y: 45 }, liveRect)).toBe(true); + // The original persisted region is still hit-testable. + expect(hitTestLayer(layer, doc([layer]), { x: 5, y: 5 }, liveRect)).toBe(true); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/moveHitTest.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/moveHitTest.ts new file mode 100644 index 00000000000..2b11e9c9ffb --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/moveHitTest.ts @@ -0,0 +1,174 @@ +/** + * Pure geometry for the move tool: layer hit-testing and outline corners. + * + * A layer's raster cache holds unscaled pixels in the layer's LOCAL space; the + * compositor draws it through the layer transform (translate·rotate·scale). To + * hit-test a document-space point we invert that transform and check the point + * against the layer's native `[0,w]×[0,h]` rect — so rotation and scale are + * handled exactly, not via an axis-aligned approximation. + * + * Every renderable layer is hit-testable — image by its native size, paint by + * its content rect (bitmap dims at the persisted offset), the parametric sources + * (shape/gradient/text) by the same content rect the compositor/cache use + * (CANVAS_PLAN Phase 5: "param for parametric"), and MASK layers (inpaint / + * regional) by their alpha bitmap's content rect (legacy allows moving masks). + * An EMPTY layer (a brand-new / cleared paint or mask layer with no pixels) + * returns `null`/`false` rather than throwing, so the move/transform tools can + * scan a mixed stack safely. + * + * Zero React, zero import-time side effects. + */ + +import type { Rect, Vec2 } from '@workbench/canvas-engine/types'; +import type { CanvasDocumentContractV2, CanvasLayerBaseContract, CanvasLayerContract } from '@workbench/types'; + +import { + getSourceContentRect, + LAYER_GROUP_COUNT, + layerGroupRank, + renderableSourceOf, +} from '@workbench/canvas-engine/document/sources'; +import { applyToPoint, fromTRS, invert } from '@workbench/canvas-engine/math/mat2d'; +import { isEmpty, union } from '@workbench/canvas-engine/math/rect'; + +type LayerTransform = CanvasLayerBaseContract['transform']; + +/** + * Resolves a layer's LIVE cache rect (layer-local), or `undefined`. Threaded from + * the engine so hit-testing covers freshly-painted content the debounced bitmap + * flush hasn't yet written back to the persisted contract — mirrors how + * `invertMask` unions the live cache rect. A pure caller (no engine) omits it. + */ +export type LiveCacheRectOf = (layerId: string) => Rect | undefined; + +/** + * A hit-testable layer's content rectangle in its LOCAL (untransformed) space — + * including its (possibly off-zero) origin for content-sized paint/mask layers — + * or `null` for a layer with no rasterizable content or an EMPTY layer (no + * pixels). Mask layers hit-test by their alpha bitmap's content rect, so the move + * tool can grab a painted mask (matching legacy). Every source (image/paint/ + * shape/gradient/text) and mask reuses {@link getSourceContentRect} — the exact + * rect the cache/compositor render. + */ +export const hittableLayerRect = ( + layer: CanvasLayerContract, + doc: CanvasDocumentContractV2, + liveRect?: Rect +): Rect | null => { + if (!renderableSourceOf(layer)) { + return null; + } + const content = getSourceContentRect(layer, doc); + // Union the live cache rect (when present): a stroke painted moments ago lives + // in the cache but not yet in the persisted contract (the bitmap flush is + // debounced), so content alone would leave freshly-painted pixels ungrabbable — + // and content is EMPTY for a brand-new paint layer whose first stroke hasn't + // flushed. Mirrors `invertMask`'s live-cache union. + const rect = liveRect && !isEmpty(liveRect) ? (isEmpty(content) ? liveRect : union(content, liveRect)) : content; + if (rect.width <= 0 || rect.height <= 0) { + return null; + } + return rect; +}; + +/** + * The native (unscaled) local pixel size of a hit-testable layer, or `null` when + * not hit-testable. A thin wrapper over {@link hittableLayerRect} for callers + * that only need eligibility / dimensions (not the origin). + */ +export const hittableLayerSize = ( + layer: CanvasLayerContract, + doc: CanvasDocumentContractV2 +): { width: number; height: number } | null => { + const rect = hittableLayerRect(layer, doc); + return rect ? { height: rect.height, width: rect.width } : null; +}; + +/** The layer's local→document affine matrix from its transform. */ +export const layerMatrix = (transform: LayerTransform) => + fromTRS({ x: transform.x, y: transform.y }, transform.rotation, transform.scaleX, transform.scaleY); + +/** True when `point` (document space) falls inside `layer`'s rendered bounds. */ +export const hitTestLayer = ( + layer: CanvasLayerContract, + doc: CanvasDocumentContractV2, + point: Vec2, + liveRect?: Rect +): boolean => { + const rect = hittableLayerRect(layer, doc, liveRect); + if (!rect) { + return false; + } + const inverse = invert(layerMatrix(layer.transform)); + if (!inverse) { + return false; + } + const local = applyToPoint(inverse, point); + return local.x >= rect.x && local.x <= rect.x + rect.width && local.y >= rect.y && local.y <= rect.y + rect.height; +}; + +/** + * The top-most layer that both satisfies `predicate` and contains `point`, or + * `null`. "Top-most" follows the COMPOSITE order, not the raw array order: layers + * are visited by group rank high→low (inpaint mask > regional guidance > control > + * raster — see {@link layerGroupRank}), and WITHIN a group by array order (index 0 + * is top-most within its group). This matches what the compositor draws, so a + * control layer painted visually over a raster wins the hit even when the raster + * sits at a lower global index — keeping the move-tool auto-select and the canvas + * context-menu target consistent with the pixels on screen (batch finding N1). The + * predicate lets callers require visible/unlocked; `liveRectOf` (optional) supplies + * each layer's live cache rect so freshly-painted, not-yet-flushed content is + * hit-testable. + */ +export const topLayerAt = ( + doc: CanvasDocumentContractV2, + point: Vec2, + predicate: (layer: CanvasLayerContract) => boolean, + liveRectOf?: LiveCacheRectOf +): CanvasLayerContract | null => { + for (let rank = LAYER_GROUP_COUNT - 1; rank >= 0; rank--) { + for (const layer of doc.layers) { + if (layerGroupRank(layer) !== rank) { + continue; + } + if (predicate(layer) && hitTestLayer(layer, doc, point, liveRectOf?.(layer.id))) { + return layer; + } + } + } + return null; +}; + +/** + * The four document-space corners of a layer's rendered rectangle (for the move + * overlay outline), or `null` when the layer has no hit-testable bounds. + * `override` shifts/scales/rotates the rect for a live drag preview without + * mutating the layer (fields absent from the override fall back to the committed + * transform; the move tool passes only `x`/`y`). + */ +export const layerOutlineCorners = ( + layer: CanvasLayerContract, + doc: CanvasDocumentContractV2, + override?: { x: number; y: number; scaleX?: number; scaleY?: number; rotation?: number } | null +): Vec2[] | null => { + const rect = hittableLayerRect(layer, doc); + if (!rect) { + return null; + } + const transform: LayerTransform = override + ? { + rotation: override.rotation ?? layer.transform.rotation, + scaleX: override.scaleX ?? layer.transform.scaleX, + scaleY: override.scaleY ?? layer.transform.scaleY, + x: override.x, + y: override.y, + } + : layer.transform; + const matrix = layerMatrix(transform); + return [ + { x: rect.x, y: rect.y }, + { x: rect.x + rect.width, y: rect.y }, + { x: rect.x + rect.width, y: rect.y + rect.height }, + { x: rect.x, y: rect.y + rect.height }, + ].map((corner) => applyToPoint(matrix, corner)); +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/moveTool.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/moveTool.test.ts new file mode 100644 index 00000000000..de745ef0111 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/moveTool.test.ts @@ -0,0 +1,324 @@ +import type { Tool, ToolContext } from '@workbench/canvas-engine/tools/tool'; +import type { PointerInput } from '@workbench/canvas-engine/types'; +import type { CanvasDocumentContractV2, CanvasLayerContract } from '@workbench/types'; +import type { WorkbenchAction } from '@workbench/workbenchState'; + +import { createEngineStores } from '@workbench/canvas-engine/engineStores'; +import { createLayerCacheStore } from '@workbench/canvas-engine/render/layerCache'; +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { describe, expect, it, vi } from 'vitest'; + +import { constrainDelta, createMoveTool } from './moveTool'; + +const imageLayer = ( + id: string, + opts: { x?: number; y?: number; width?: number; height?: number; isLocked?: boolean; isEnabled?: boolean } = {} +): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: opts.isEnabled ?? true, + isLocked: opts.isLocked ?? false, + name: id, + opacity: 1, + source: { image: { height: opts.height ?? 40, imageName: id, width: opts.width ?? 40 }, type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: opts.x ?? 0, y: opts.y ?? 0 }, + type: 'raster', +}); + +const shapeLayer = ( + id: string, + opts: { x?: number; y?: number; width?: number; height?: number } = {} +): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { + fill: '#ffffff', + height: opts.height ?? 40, + kind: 'rect', + stroke: null, + strokeWidth: 0, + type: 'shape', + width: opts.width ?? 40, + }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: opts.x ?? 0, y: opts.y ?? 0 }, + type: 'raster', +}); + +const makeDoc = (layers: CanvasLayerContract[], selectedLayerId: string | null): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers, + selectedLayerId, + version: 2, + width: 100, +}); + +const pointer = (x: number, y: number, opts: { shift?: boolean; buttons?: number } = {}): PointerInput => ({ + buttons: opts.buttons ?? 1, + documentPoint: { x, y }, + modifiers: { alt: false, ctrl: false, meta: false, shift: opts.shift ?? false }, + pointerType: 'mouse', + pressure: 0.5, + screenPoint: { x, y }, + timeStamp: 0, +}); + +interface StructuralCommit { + label: string; + forward: WorkbenchAction; + inverse: WorkbenchAction; +} + +interface Harness { + ctx: ToolContext; + dispatched: WorkbenchAction[]; + commits: StructuralCommit[]; + overrides: { layerId: string; override: { x: number; y: number } | null }[]; +} + +const createHarness = (doc: CanvasDocumentContractV2): Harness => { + const dispatched: WorkbenchAction[] = []; + const commits: StructuralCommit[] = []; + const overrides: { layerId: string; override: { x: number; y: number } | null }[] = []; + const ctx: ToolContext = { + backend: null as never, + commitStructural: (label, forward, inverse) => commits.push({ forward, inverse, label }), + createLayerId: () => 'x', + createPath2D: (d) => ({ d }) as unknown as Path2D, + dispatch: (action) => dispatched.push(action), + emitStrokeCommitted: vi.fn(), + getDocument: () => doc, + invalidate: vi.fn(), + // A real (empty) layer cache so the move tool's live-cache-rect hit-test seam + // resolves; no test here relies on cached content, so entries stay absent. + layers: createLayerCacheStore(createTestStubRasterBackend()), + notifyLayerPainted: vi.fn(), + setLayerTransformOverride: (layerId, override) => overrides.push({ layerId, override }), + setOverlayCursor: vi.fn(), + stores: createEngineStores(), + updateCursor: vi.fn(), + viewport: null as never, + }; + return { commits, ctx, dispatched, overrides }; +}; + +const down = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerDown?.(ctx, i); +const move = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerMove?.(ctx, i, [i]); +const up = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerUp?.(ctx, i); +const cancel = (t: Tool, ctx: ToolContext): void => t.onPointerCancel?.(ctx); + +describe('constrainDelta', () => { + it('passes the raw delta through without shift', () => { + expect(constrainDelta(3, 7, false)).toEqual({ x: 3, y: 7 }); + }); + it('zeroes the smaller axis under shift', () => { + expect(constrainDelta(10, 3, true)).toEqual({ x: 10, y: 0 }); + expect(constrainDelta(2, -8, true)).toEqual({ x: 0, y: -8 }); + }); +}); + +describe('move tool: click selection', () => { + it('selects the top-most visible layer under the point (one dispatch, no commit)', () => { + const doc = makeDoc( + [imageLayer('top', { width: 50, height: 50 }), imageLayer('bottom', { width: 50, height: 50 })], + null + ); + const h = createHarness(doc); + const tool = createMoveTool(); + + down(tool, h.ctx, pointer(10, 10)); + up(tool, h.ctx, pointer(10, 10)); + + expect(h.commits).toHaveLength(0); + expect(h.dispatched).toEqual([{ id: 'top', type: 'setCanvasSelectedLayer' }]); + }); + + it('clears the selection when clicking empty space', () => { + const doc = makeDoc([imageLayer('a', { width: 10, height: 10 })], 'a'); + const h = createHarness(doc); + const tool = createMoveTool(); + + down(tool, h.ctx, pointer(80, 80)); + up(tool, h.ctx, pointer(80, 80)); + + expect(h.dispatched).toEqual([{ id: null, type: 'setCanvasSelectedLayer' }]); + }); + + it('can click-select a locked layer', () => { + const doc = makeDoc([imageLayer('locked', { width: 50, height: 50, isLocked: true })], null); + const h = createHarness(doc); + const tool = createMoveTool(); + + down(tool, h.ctx, pointer(10, 10)); + up(tool, h.ctx, pointer(10, 10)); + + expect(h.dispatched).toEqual([{ id: 'locked', type: 'setCanvasSelectedLayer' }]); + }); + + it('does not click-select a hidden layer', () => { + const doc = makeDoc([imageLayer('hidden', { width: 50, height: 50, isEnabled: false })], null); + const h = createHarness(doc); + const tool = createMoveTool(); + + down(tool, h.ctx, pointer(10, 10)); + up(tool, h.ctx, pointer(10, 10)); + + // Hidden layer is not hit → empty-space clear. + expect(h.dispatched).toEqual([{ id: null, type: 'setCanvasSelectedLayer' }]); + }); +}); + +describe('move tool: drag', () => { + it('previews via override on move then commits one structural transform on up', () => { + const doc = makeDoc([imageLayer('a', { x: 0, y: 0, width: 50, height: 50 })], 'a'); + const h = createHarness(doc); + const tool = createMoveTool(); + + down(tool, h.ctx, pointer(10, 10)); + move(tool, h.ctx, pointer(30, 25)); + up(tool, h.ctx, pointer(30, 25)); + + // Preview override applied during the move, cleared on commit. + expect(h.overrides).toEqual([ + { layerId: 'a', override: { x: 20, y: 15 } }, + { layerId: 'a', override: null }, + ]); + // Exactly one structural commit; the target was already selected → no extra dispatch. + expect(h.dispatched).toHaveLength(0); + expect(h.commits).toHaveLength(1); + expect(h.commits[0]!.forward).toEqual({ + id: 'a', + patch: { transform: { x: 20, y: 15 } }, + type: 'updateCanvasLayer', + }); + expect(h.commits[0]!.inverse).toEqual({ + id: 'a', + patch: { transform: { x: 0, y: 0 } }, + type: 'updateCanvasLayer', + }); + }); + + it('drags a parametric shape layer, committing one structural transform', () => { + // Regression: shape/gradient/text layers were not hit-testable, so the move + // tool skipped them (Phase 5 "param for parametric" hit-testing). + const doc = makeDoc([shapeLayer('s', { x: 0, y: 0, width: 50, height: 50 })], 's'); + const h = createHarness(doc); + const tool = createMoveTool(); + + down(tool, h.ctx, pointer(10, 10)); + move(tool, h.ctx, pointer(30, 25)); + up(tool, h.ctx, pointer(30, 25)); + + expect(h.commits).toHaveLength(1); + expect(h.commits[0]!.forward).toEqual({ + id: 's', + patch: { transform: { x: 20, y: 15 } }, + type: 'updateCanvasLayer', + }); + }); + + it('auto-selects the pressed unlocked layer before committing the move', () => { + const doc = makeDoc( + [imageLayer('top', { width: 50, height: 50 }), imageLayer('bottom', { width: 50, height: 50 })], + 'bottom' + ); + const h = createHarness(doc); + const tool = createMoveTool(); + + down(tool, h.ctx, pointer(10, 10)); + move(tool, h.ctx, pointer(30, 10)); + up(tool, h.ctx, pointer(30, 10)); + + // Selection switched to the pressed layer, then the move committed on it. + expect(h.dispatched).toEqual([{ id: 'top', type: 'setCanvasSelectedLayer' }]); + expect(h.commits).toHaveLength(1); + expect(h.commits[0]!.forward).toMatchObject({ id: 'top' }); + }); + + it('moves the selected layer when the press lands on empty space', () => { + const doc = makeDoc([imageLayer('a', { x: 0, y: 0, width: 10, height: 10 })], 'a'); + const h = createHarness(doc); + const tool = createMoveTool(); + + // Press at empty (80,80): no layer hit there, but 'a' is the selected movable layer. + down(tool, h.ctx, pointer(80, 80)); + move(tool, h.ctx, pointer(90, 85)); + up(tool, h.ctx, pointer(90, 85)); + + expect(h.commits).toHaveLength(1); + expect(h.commits[0]!.forward).toEqual({ + id: 'a', + patch: { transform: { x: 10, y: 5 } }, + type: 'updateCanvasLayer', + }); + }); + + it('does not drag a locked layer (auto-select finds nothing, no selected fallback)', () => { + const doc = makeDoc([imageLayer('locked', { width: 50, height: 50, isLocked: true })], null); + const h = createHarness(doc); + const tool = createMoveTool(); + + down(tool, h.ctx, pointer(10, 10)); + move(tool, h.ctx, pointer(40, 40)); + up(tool, h.ctx, pointer(40, 40)); + + expect(h.commits).toHaveLength(0); + expect(h.overrides).toHaveLength(0); + expect(h.dispatched).toHaveLength(0); + }); + + it('constrains to the dominant axis under shift', () => { + const doc = makeDoc([imageLayer('a', { x: 0, y: 0, width: 50, height: 50 })], 'a'); + const h = createHarness(doc); + const tool = createMoveTool(); + + down(tool, h.ctx, pointer(10, 10)); + move(tool, h.ctx, pointer(40, 15, { shift: true })); + up(tool, h.ctx, pointer(40, 15, { shift: true })); + + expect(h.commits[0]!.forward).toEqual({ + id: 'a', + patch: { transform: { x: 30, y: 0 } }, + type: 'updateCanvasLayer', + }); + }); + + it('commits nothing for a sub-threshold press+release (treated as a click)', () => { + const doc = makeDoc([imageLayer('a', { width: 50, height: 50 })], 'a'); + const h = createHarness(doc); + const tool = createMoveTool(); + + down(tool, h.ctx, pointer(10, 10)); + move(tool, h.ctx, pointer(11, 11)); // < 3px screen + up(tool, h.ctx, pointer(11, 11)); + + expect(h.commits).toHaveLength(0); + expect(h.dispatched).toEqual([{ id: 'a', type: 'setCanvasSelectedLayer' }]); + }); +}); + +describe('move tool: cancel', () => { + it('drops the override and commits nothing on Esc mid-drag', () => { + const doc = makeDoc([imageLayer('a', { width: 50, height: 50 })], 'a'); + const h = createHarness(doc); + const tool = createMoveTool(); + + down(tool, h.ctx, pointer(10, 10)); + move(tool, h.ctx, pointer(40, 40)); + cancel(tool, h.ctx); + + expect(h.commits).toHaveLength(0); + expect(h.dispatched).toHaveLength(0); + // Last override op clears the preview. + expect(h.overrides.at(-1)).toEqual({ layerId: 'a', override: null }); + + // A subsequent up (from the released pointer) does nothing. + up(tool, h.ctx, pointer(40, 40)); + expect(h.commits).toHaveLength(0); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/moveTool.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/moveTool.ts new file mode 100644 index 00000000000..4fdd5514667 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/moveTool.ts @@ -0,0 +1,208 @@ +/** + * The move tool: click to select the top-most layer under the cursor, drag to + * move a layer (auto-selecting the pressed unlocked layer, photo-editor style). + * + * Interaction contract (see CANVAS_PLAN Phase 3): + * - **Click** (press+release under the drag threshold): selects the top-most + * VISIBLE layer whose rendered bounds contain the point (locked layers may be + * click-selected); empty space clears the selection. One `setCanvasSelectedLayer` + * dispatch, no history entry. + * - **Drag**: moves a layer. The pressed point's top-most enabled+unlocked layer + * becomes the target (auto-select); otherwise the currently-selected + * enabled+unlocked layer is moved. Hidden/locked layers are never dragged. + * `shift` constrains motion to the dominant axis. Pointer-move only sets a + * transient transform override (live preview) — it never dispatches. + * - **Commit** (pointer-up after a real move): one `commitStructural` with the + * new/old transform x/y (plus a selection dispatch when the target wasn't + * already selected). A zero-delta drag commits nothing. + * - **Cancel** (Esc / pointercancel): drops the override, no dispatch. + * + * Zero React, zero import-time side effects. + */ + +import type { PointerInput, Vec2 } from '@workbench/canvas-engine/types'; +import type { CanvasLayerContract } from '@workbench/types'; + +import type { Tool, ToolContext } from './tool'; + +import { type LiveCacheRectOf, topLayerAt } from './moveHitTest'; + +/** Bit for the primary (usually left) mouse button in `PointerEvent.buttons`. */ +const PRIMARY_BUTTON = 1; + +/** + * A live-cache-rect resolver over the tool's layer cache, so hit-testing grabs + * freshly-painted content the debounced bitmap flush hasn't yet written back to + * the persisted contract (a new layer + stroke is otherwise ungrabbable until the + * ~1.5s flush lands). + */ +const liveRectOf = + (ctx: ToolContext): LiveCacheRectOf => + (layerId) => + ctx.layers.get(layerId)?.rect; + +/** Screen-space distance (CSS px) the pointer must travel before a press becomes a drag. */ +export const MOVE_DRAG_THRESHOLD_PX = 3; + +const isVisible = (layer: CanvasLayerContract): boolean => layer.isEnabled; +const isDraggable = (layer: CanvasLayerContract): boolean => layer.isEnabled && !layer.isLocked; + +/** Applies the shift-to-dominant-axis constraint to a document-space delta. */ +export const constrainDelta = (dx: number, dy: number, shift: boolean): Vec2 => { + if (!shift) { + return { x: dx, y: dy }; + } + return Math.abs(dx) >= Math.abs(dy) ? { x: dx, y: 0 } : { x: 0, y: dy }; +}; + +interface GestureState { + startDoc: Vec2; + startScreen: Vec2; + /** The layer being dragged (null when the press had nothing to move). */ + targetId: string | null; + /** The drag target's original transform x/y. */ + origin: { x: number; y: number } | null; + /** The top-most visible layer under the press, for click selection (lock allowed). */ + clickTargetId: string | null; + /** The document's selected layer at press time. */ + selectedAtStart: string | null; + moved: boolean; +} + +/** Creates a fresh move tool with its own gesture state. */ +export const createMoveTool = (): Tool => { + let state: GestureState | null = null; + + const clearOverride = (ctx: ToolContext): void => { + if (state?.targetId) { + ctx.setLayerTransformOverride(state.targetId, null); + } + }; + + const endGesture = (): void => { + state = null; + }; + + const resolveDragTarget = (ctx: ToolContext, point: Vec2, selectedId: string | null): CanvasLayerContract | null => { + const doc = ctx.getDocument(); + if (!doc) { + return null; + } + // Auto-select: the pressed point's top-most enabled+unlocked layer wins. + const hit = topLayerAt(doc, point, isDraggable, liveRectOf(ctx)); + if (hit) { + return hit; + } + // Otherwise fall back to moving the currently-selected layer (if movable). + const selected = selectedId ? doc.layers.find((layer) => layer.id === selectedId) : undefined; + return selected && isDraggable(selected) ? selected : null; + }; + + const previewAt = (ctx: ToolContext, input: PointerInput): void => { + if (!state || !state.targetId || !state.origin) { + return; + } + const delta = constrainDelta( + input.documentPoint.x - state.startDoc.x, + input.documentPoint.y - state.startDoc.y, + input.modifiers.shift + ); + ctx.setLayerTransformOverride(state.targetId, { x: state.origin.x + delta.x, y: state.origin.y + delta.y }); + }; + + return { + cursor: () => 'move', + id: 'move', + onDeactivate: (ctx) => { + clearOverride(ctx); + endGesture(); + }, + onPointerCancel: (ctx) => { + clearOverride(ctx); + ctx.invalidate({ overlay: true }); + endGesture(); + }, + onPointerDown: (ctx, input) => { + if (state || (input.buttons & PRIMARY_BUTTON) === 0) { + return; + } + const doc = ctx.getDocument(); + if (!doc) { + return; + } + const clickTarget = topLayerAt(doc, input.documentPoint, isVisible, liveRectOf(ctx)); + const dragTarget = resolveDragTarget(ctx, input.documentPoint, doc.selectedLayerId); + state = { + clickTargetId: clickTarget?.id ?? null, + moved: false, + origin: dragTarget ? { x: dragTarget.transform.x, y: dragTarget.transform.y } : null, + selectedAtStart: doc.selectedLayerId, + startDoc: input.documentPoint, + startScreen: input.screenPoint, + targetId: dragTarget?.id ?? null, + }; + }, + onPointerMove: (ctx, input) => { + if (!state) { + return; + } + if (!state.moved) { + const dxs = input.screenPoint.x - state.startScreen.x; + const dys = input.screenPoint.y - state.startScreen.y; + if (Math.hypot(dxs, dys) < MOVE_DRAG_THRESHOLD_PX) { + return; + } + state.moved = true; + } + previewAt(ctx, input); + }, + onPointerUp: (ctx, input) => { + if (!state) { + return; + } + const current = state; + endGesture(); + + if (!current.moved) { + // A click: select the top-most visible layer, or clear on empty space. + ctx.dispatch({ id: current.clickTargetId, type: 'setCanvasSelectedLayer' }); + return; + } + + if (!current.targetId || !current.origin) { + // Dragged over empty space with nothing to move — no-op. + return; + } + + const delta = constrainDelta( + input.documentPoint.x - current.startDoc.x, + input.documentPoint.y - current.startDoc.y, + input.modifiers.shift + ); + const next = { x: current.origin.x + delta.x, y: current.origin.y + delta.y }; + + if (next.x === current.origin.x && next.y === current.origin.y) { + // Zero-delta drag: behave like a click-select of the target, no commit. + ctx.setLayerTransformOverride(current.targetId, null); + ctx.dispatch({ id: current.targetId, type: 'setCanvasSelectedLayer' }); + return; + } + + // Auto-select the dragged layer if it wasn't already selected (no history). + if (current.targetId !== current.selectedAtStart) { + ctx.dispatch({ id: current.targetId, type: 'setCanvasSelectedLayer' }); + } + ctx.commitStructural( + 'Move layer', + { id: current.targetId, patch: { transform: { x: next.x, y: next.y } }, type: 'updateCanvasLayer' }, + { + id: current.targetId, + patch: { transform: { x: current.origin.x, y: current.origin.y } }, + type: 'updateCanvasLayer', + } + ); + // The committed transform now flows through the mirror; drop the preview. + ctx.setLayerTransformOverride(current.targetId, null); + }, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/paintConstants.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/paintConstants.test.ts new file mode 100644 index 00000000000..42177978f5b --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/paintConstants.test.ts @@ -0,0 +1,44 @@ +import { MAX_BRUSH_SIZE, MIN_BRUSH_SIZE } from '@workbench/canvas-engine/engineStores'; +import { describe, expect, it } from 'vitest'; + +import { clampBrushSize, SIZE_STEP_FACTOR, stepBrushSize } from './paintConstants'; + +describe('clampBrushSize', () => { + it('clamps to [MIN_BRUSH_SIZE, MAX_BRUSH_SIZE]', () => { + expect(clampBrushSize(0)).toBe(MIN_BRUSH_SIZE); + expect(clampBrushSize(-100)).toBe(MIN_BRUSH_SIZE); + expect(clampBrushSize(1_000_000)).toBe(MAX_BRUSH_SIZE); + }); + + it('rounds to the nearest integer', () => { + expect(clampBrushSize(50.6)).toBe(51); + expect(clampBrushSize(50.4)).toBe(50); + }); +}); + +describe('stepBrushSize', () => { + it('grows by SIZE_STEP_FACTOR for direction +1', () => { + expect(stepBrushSize(50, 1)).toBe(Math.round(50 * (1 + SIZE_STEP_FACTOR))); + }); + + it('shrinks by SIZE_STEP_FACTOR for direction -1', () => { + expect(stepBrushSize(50, -1)).toBe(Math.round(50 * (1 - SIZE_STEP_FACTOR))); + }); + + it('clamps growth at MAX_BRUSH_SIZE', () => { + expect(stepBrushSize(MAX_BRUSH_SIZE, 1)).toBe(MAX_BRUSH_SIZE); + }); + + it('clamps shrink at MIN_BRUSH_SIZE', () => { + expect(stepBrushSize(MIN_BRUSH_SIZE, -1)).toBe(MIN_BRUSH_SIZE); + }); + + it('is monotonic: repeated growth never decreases size', () => { + let size = MIN_BRUSH_SIZE; + for (let i = 0; i < 20; i++) { + const next = stepBrushSize(size, 1); + expect(next).toBeGreaterThanOrEqual(size); + size = next; + } + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/paintConstants.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/paintConstants.ts new file mode 100644 index 00000000000..1989ef363a6 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/paintConstants.ts @@ -0,0 +1,24 @@ +/** + * Shared brush/eraser painting constants and the size-step math both the + * ctrl+wheel path (`input/wheel.ts`) and the `[`/`]` hotkeys drive through the + * same helper. Zero React, zero import-time side effects. + */ + +import { MAX_BRUSH_SIZE, MIN_BRUSH_SIZE } from '@workbench/canvas-engine/engineStores'; + +/** Freehand `thinning` applied when brush pressure sensitivity is on. */ +export const PRESSURE_THINNING = 0.5; + +/** Fraction to grow/shrink the brush/eraser diameter per size-step notch (ctrl+wheel or `[`/`]`). */ +export const SIZE_STEP_FACTOR = 0.1; + +/** Clamps a brush/eraser diameter to `[MIN_BRUSH_SIZE, MAX_BRUSH_SIZE]`, rounded to the nearest integer. */ +export const clampBrushSize = (value: number): number => + Math.max(MIN_BRUSH_SIZE, Math.min(MAX_BRUSH_SIZE, Math.round(value))); + +/** + * Steps a brush/eraser diameter by one notch: `+1` grows by `SIZE_STEP_FACTOR`, + * `-1` shrinks by it, clamped to the valid size range. + */ +export const stepBrushSize = (size: number, direction: 1 | -1): number => + clampBrushSize(size * (1 + direction * SIZE_STEP_FACTOR)); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/paintTool.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/paintTool.test.ts new file mode 100644 index 00000000000..bff38b0333c --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/paintTool.test.ts @@ -0,0 +1,344 @@ +import type { StubRasterBackend, StubRasterSurface } from '@workbench/canvas-engine/render/raster.testStub'; +import type { StrokeCommittedEvent, Tool, ToolContext } from '@workbench/canvas-engine/tools/tool'; +import type { PointerInput } from '@workbench/canvas-engine/types'; +import type { CanvasDocumentContractV2, CanvasLayerContract } from '@workbench/types'; +import type { WorkbenchAction } from '@workbench/workbenchState'; + +import { createEngineStores } from '@workbench/canvas-engine/engineStores'; +import { isEmpty } from '@workbench/canvas-engine/math/rect'; +import { createLayerCacheStore, type LayerCacheStore } from '@workbench/canvas-engine/render/layerCache'; +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { createBrushTool } from '@workbench/canvas-engine/tools/brushTool'; +import { createEraserTool } from '@workbench/canvas-engine/tools/eraserTool'; +import { describe, expect, it, vi } from 'vitest'; + +const paintLayer = ( + id: string, + opts: { isLocked?: boolean; isEnabled?: boolean; isTransparencyLocked?: boolean } = {} +): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: opts.isEnabled ?? true, + isLocked: opts.isLocked ?? false, + ...(opts.isTransparencyLocked ? { isTransparencyLocked: true } : {}), + name: id, + opacity: 1, + source: { bitmap: null, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', +}); + +const imageLayer = (id: string): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { image: { height: 10, imageName: id, width: 10 }, type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', +}); + +const inpaintMaskLayer = (id: string): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + mask: { bitmap: null, fill: { color: '#e07575', style: 'diagonal' } }, + name: id, + opacity: 1, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'inpaint_mask', +}); + +const makeDoc = (layers: CanvasLayerContract[], selectedLayerId: string | null): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + layers, + selectedLayerId, + version: 2, + width: 100, +}); + +const pointer = (x: number, y: number, opts: { pressure?: number; buttons?: number } = {}): PointerInput => ({ + buttons: opts.buttons ?? 1, + documentPoint: { x, y }, + modifiers: { alt: false, ctrl: false, meta: false, shift: false }, + pointerType: 'mouse', + pressure: opts.pressure ?? 0.5, + screenPoint: { x, y }, + timeStamp: 0, +}); + +// Optional-method drivers, so tests read as gestures rather than `?.` noise. +const down = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerDown?.(ctx, i); +const move = (t: Tool, ctx: ToolContext, i: PointerInput, batch: PointerInput[]): void => + t.onPointerMove?.(ctx, i, batch); +const up = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerUp?.(ctx, i); +const cancel = (t: Tool, ctx: ToolContext): void => t.onPointerCancel?.(ctx); + +interface Harness { + ctx: ToolContext; + backend: StubRasterBackend; + layers: LayerCacheStore; + dispatched: WorkbenchAction[]; + strokes: StrokeCommittedEvent[]; + painted: string[]; + createdIds: string[]; +} + +const createHarness = (doc: CanvasDocumentContractV2): Harness => { + const backend = createTestStubRasterBackend(); + const layers = createLayerCacheStore(backend); + const stores = createEngineStores(); + const dispatched: WorkbenchAction[] = []; + const strokes: StrokeCommittedEvent[] = []; + const painted: string[] = []; + const createdIds: string[] = []; + let idCounter = 0; + + const ctx: ToolContext = { + backend, + commitStructural: vi.fn(), + createLayerId: () => { + const id = `new-layer-${(idCounter += 1)}`; + createdIds.push(id); + return id; + }, + createPath2D: (d) => ({ d }) as unknown as Path2D, + dispatch: (action) => dispatched.push(action), + emitStrokeCommitted: (event) => strokes.push(event), + getDocument: () => doc, + invalidate: vi.fn(), + layers, + notifyLayerPainted: (layerId) => painted.push(layerId), + setLayerTransformOverride: vi.fn(), + setOverlayCursor: vi.fn(), + stores, + updateCursor: vi.fn(), + viewport: null as never, + }; + + return { backend, createdIds, ctx, dispatched, layers, painted, strokes }; +}; + +const cacheOps = (surface: StubRasterSurface): string[] => surface.callLog.map((entry) => entry.op); + +/** The value of the last `globalCompositeOperation` assignment recorded on the cache surface. */ +const lastCompositeOp = (surface: StubRasterSurface): unknown => + surface.callLog + .filter((entry) => entry.op === 'set' && entry.args[0] === 'globalCompositeOperation') + .map((entry) => entry.args[1]) + .pop(); + +/** The value of the last `globalAlpha` assignment recorded on the cache surface. */ +const lastGlobalAlpha = (surface: StubRasterSurface): unknown => + surface.callLog + .filter((entry) => entry.op === 'set' && entry.args[0] === 'globalAlpha') + .map((entry) => entry.args[1]) + .pop(); + +const cacheSurface = (h: Harness, layerId: string): StubRasterSurface => + h.layers.get(layerId)!.surface as StubRasterSurface; + +describe('brush tool: stroke into an existing paint layer', () => { + it('paints, emits one strokeCommitted with a content-sized dirty rect, and does not dispatch', () => { + const doc = makeDoc([paintLayer('paint1')], 'paint1'); + const h = createHarness(doc); + const brush = createBrushTool(); + + down(brush, h.ctx, pointer(20, 20)); + move(brush, h.ctx, pointer(40, 40), [pointer(30, 30), pointer(40, 40)]); + up(brush, h.ctx, pointer(40, 40)); + + expect(h.dispatched).toHaveLength(0); + expect(h.strokes).toHaveLength(1); + const event = h.strokes[0]!; + expect(event.tool).toBe('brush'); + expect(event.layerId).toBe('paint1'); + + // Dirty rect is integer and non-empty. Content-sized: it is the stroke's TRUE + // bounds (no document clamp), so it can extend past the document edges — a + // paint layer grows with its strokes. + const { dirtyRect } = event; + expect(Number.isInteger(dirtyRect.x)).toBe(true); + expect(Number.isInteger(dirtyRect.width)).toBe(true); + expect(isEmpty(dirtyRect)).toBe(false); + + // Before/after snapshots are sized exactly to the dirty rect. + expect(event.beforeImageData.width).toBe(dirtyRect.width); + expect(event.beforeImageData.height).toBe(dirtyRect.height); + expect(event.afterImageData.width).toBe(dirtyRect.width); + expect(event.afterImageData.height).toBe(dirtyRect.height); + + // The layer version was bumped exactly once (on commit). + expect(h.painted).toEqual(['paint1']); + + // The cache surface received the buffered-stroke composite (source-over). + const ops = cacheOps(cacheSurface(h, 'paint1')); + expect(ops).toContain('getImageData'); + expect(ops).toContain('drawImage'); + expect(lastCompositeOp(cacheSurface(h, 'paint1'))).toBe('source-over'); + }); +}); + +describe('eraser tool', () => { + it('composites the stroke with destination-out and reports tool "eraser"', () => { + const doc = makeDoc([paintLayer('paint1')], 'paint1'); + const h = createHarness(doc); + const eraser = createEraserTool(); + + down(eraser, h.ctx, pointer(20, 20)); + move(eraser, h.ctx, pointer(40, 40), [pointer(40, 40)]); + up(eraser, h.ctx, pointer(40, 40)); + + expect(h.strokes).toHaveLength(1); + expect(h.strokes[0]!.tool).toBe('eraser'); + expect(lastCompositeOp(cacheSurface(h, 'paint1'))).toBe('destination-out'); + }); +}); + +describe('brush tool: cancel', () => { + it('restores pixels via putImageData and emits no event', () => { + const doc = makeDoc([paintLayer('paint1')], 'paint1'); + const h = createHarness(doc); + const brush = createBrushTool(); + + down(brush, h.ctx, pointer(20, 20)); + move(brush, h.ctx, pointer(40, 40), [pointer(40, 40)]); + cancel(brush, h.ctx); + + expect(h.strokes).toHaveLength(0); + expect(h.painted).toHaveLength(0); + expect(cacheOps(cacheSurface(h, 'paint1'))).toContain('putImageData'); + }); +}); + +describe('paint tool: target resolution', () => { + it('auto-creates a paint layer (one dispatch) when the selection is not paintable', () => { + const doc = makeDoc([imageLayer('img1')], 'img1'); + const h = createHarness(doc); + const brush = createBrushTool(); + + down(brush, h.ctx, pointer(20, 20)); + up(brush, h.ctx, pointer(20, 20)); + + expect(h.dispatched).toHaveLength(1); + const action = h.dispatched[0]!; + expect(action.type).toBe('addCanvasLayer'); + if (action.type === 'addCanvasLayer' && action.layer.type === 'raster') { + expect(action.layer.source.type).toBe('paint'); + expect(action.layer.id).toBe(h.createdIds[0]); + } else { + throw new Error('expected an addCanvasLayer with a raster layer'); + } + // The stroke commits against the freshly-created layer. + expect(h.strokes).toHaveLength(1); + expect(h.strokes[0]!.layerId).toBe(h.createdIds[0]); + }); + + it('auto-creates when nothing is selected', () => { + const doc = makeDoc([paintLayer('paint1')], null); + const h = createHarness(doc); + const brush = createBrushTool(); + down(brush, h.ctx, pointer(20, 20)); + expect(h.dispatched).toHaveLength(1); + expect(h.dispatched[0]!.type).toBe('addCanvasLayer'); + }); + + it('no-ops on a locked selected paint layer (no dispatch, no paint)', () => { + const doc = makeDoc([paintLayer('paint1', { isLocked: true })], 'paint1'); + const h = createHarness(doc); + const brush = createBrushTool(); + + down(brush, h.ctx, pointer(20, 20)); + move(brush, h.ctx, pointer(40, 40), [pointer(40, 40)]); + up(brush, h.ctx, pointer(40, 40)); + + expect(h.dispatched).toHaveLength(0); + expect(h.strokes).toHaveLength(0); + }); + + it('no-ops on a disabled selected paint layer', () => { + const doc = makeDoc([paintLayer('paint1', { isEnabled: false })], 'paint1'); + const h = createHarness(doc); + const brush = createBrushTool(); + down(brush, h.ctx, pointer(20, 20)); + up(brush, h.ctx, pointer(20, 20)); + expect(h.strokes).toHaveLength(0); + }); +}); + +describe('transparency lock', () => { + it('brush composites source-atop on a transparency-locked layer (colour only on existing pixels)', () => { + const doc = makeDoc([paintLayer('paint1', { isTransparencyLocked: true })], 'paint1'); + const h = createHarness(doc); + const brush = createBrushTool(); + + down(brush, h.ctx, pointer(20, 20)); + move(brush, h.ctx, pointer(40, 40), [pointer(40, 40)]); + up(brush, h.ctx, pointer(40, 40)); + + expect(h.strokes).toHaveLength(1); + expect(lastCompositeOp(cacheSurface(h, 'paint1'))).toBe('source-atop'); + }); + + it('refuses the eraser entirely on a transparency-locked layer (no stroke, no alpha change)', () => { + const doc = makeDoc([paintLayer('paint1', { isTransparencyLocked: true })], 'paint1'); + const h = createHarness(doc); + const eraser = createEraserTool(); + + down(eraser, h.ctx, pointer(20, 20)); + move(eraser, h.ctx, pointer(40, 40), [pointer(40, 40)]); + up(eraser, h.ctx, pointer(40, 40)); + + expect(h.strokes).toHaveLength(0); + expect(h.painted).toEqual([]); + }); + + it('brush is unaffected (source-over) when transparency is NOT locked', () => { + const doc = makeDoc([paintLayer('paint1')], 'paint1'); + const h = createHarness(doc); + const brush = createBrushTool(); + + down(brush, h.ctx, pointer(20, 20)); + move(brush, h.ctx, pointer(40, 40), [pointer(40, 40)]); + up(brush, h.ctx, pointer(40, 40)); + + expect(lastCompositeOp(cacheSurface(h, 'paint1'))).toBe('source-over'); + }); +}); + +describe('mask strokes are forced opaque', () => { + it('composites a mask stroke at globalAlpha 1 even when the brush opacity is 0.5', () => { + const doc = makeDoc([inpaintMaskLayer('mask1')], 'mask1'); + const h = createHarness(doc); + // A half-opacity brush: on a raster layer this would land alpha ~128, but a + // mask is an all-or-nothing alpha stencil and must be forced fully opaque. + h.ctx.stores.brushOptions.set({ ...h.ctx.stores.brushOptions.get(), opacity: 0.5 }); + const brush = createBrushTool(); + + down(brush, h.ctx, pointer(20, 20)); + move(brush, h.ctx, pointer(40, 40), [pointer(40, 40)]); + up(brush, h.ctx, pointer(40, 40)); + + expect(h.strokes).toHaveLength(1); + expect(h.strokes[0]!.layerId).toBe('mask1'); + expect(lastGlobalAlpha(cacheSurface(h, 'mask1'))).toBe(1); + }); + + it('still respects brush opacity on a normal raster paint layer', () => { + const doc = makeDoc([paintLayer('paint1')], 'paint1'); + const h = createHarness(doc); + h.ctx.stores.brushOptions.set({ ...h.ctx.stores.brushOptions.get(), opacity: 0.5 }); + const brush = createBrushTool(); + + down(brush, h.ctx, pointer(20, 20)); + move(brush, h.ctx, pointer(40, 40), [pointer(40, 40)]); + up(brush, h.ctx, pointer(40, 40)); + + expect(lastGlobalAlpha(cacheSurface(h, 'paint1'))).toBe(0.5); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/paintTool.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/paintTool.ts new file mode 100644 index 00000000000..8dd5af8ce05 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/paintTool.ts @@ -0,0 +1,209 @@ +/** + * The shared machinery behind the brush and eraser tools. Both are the same + * gesture — resolve a paintable target layer on pointer-down (auto-creating a + * fresh paint layer when the selection can't be painted into), drive a + * {@link StrokeSession} across the move batches, and commit or cancel on release — + * differing only in their blend (`source-over` fill vs `destination-out`) and in + * which options store they read. `createBrushTool` / `createEraserTool` + * (in `brushTool.ts` / `eraserTool.ts`) are thin wrappers over + * {@link createPaintTool}. + * + * This is the ONE place a painting tool is allowed to dispatch, and only ever + * the single gesture-start `addCanvasLayer`. Pointer-move never dispatches. + * + * Zero React, zero import-time side effects. + */ + +import type { PointerInput } from '@workbench/canvas-engine/types'; +import type { CanvasLayerContract, CanvasRasterLayerContractV2 } from '@workbench/types'; + +import type { Tool, ToolContext } from './tool'; + +import { createStrokeSession, type StrokeSession } from './strokeSession'; + +/** Bit for the primary (usually left) mouse button in `PointerEvent.buttons`. */ +const PRIMARY_BUTTON = 1; + +/** The resolved config for one gesture, derived from the tool's options store. */ +export interface PaintToolSpec { + id: 'brush' | 'eraser'; + composite: 'source-over' | 'destination-out'; + /** Reads the current size (document units) from the options store. */ + size(ctx: ToolContext): number; + /** Reads the current per-stroke opacity from the options store. */ + opacity(ctx: ToolContext): number; + /** The fill color; `null` for the eraser (shape only). */ + color(ctx: ToolContext): string; + /** Freehand thinning for this gesture; 0 disables pressure sensitivity. */ + thinning(ctx: ToolContext): number; +} + +/** Colour brush strokes paint into a MASK cache: an opaque stencil (only alpha matters). */ +const MASK_STROKE_COLOR = '#ffffff'; + +/** The resolved paint target for a gesture. `createdLayer` is set only when auto-created. */ +interface PaintTarget { + layerId: string; + /** When the gesture auto-created its layer, the created contract + insert index (for history). */ + createdLayer?: { layer: CanvasLayerContract; index: number }; + /** + * Overrides the brush colour for this gesture (mask targets paint an opaque + * stencil — the stored RGB is irrelevant, the compositor colorizes by alpha). + * Absent ⇒ the tool's own colour. + */ + color?: string; + /** + * True when the target is a transparency-LOCKED raster paint layer. The brush + * then composites `source-atop` (colour only on existing pixels); the eraser is + * refused (erasing would change the locked alpha). Never set for mask targets. + */ + transparencyLocked?: boolean; + /** + * True for mask targets (inpaint / regional guidance). A mask is an opaque + * alpha stencil, so the stroke is forced to opacity 1 regardless of the brush's + * opacity slider — a 50%-opacity brush would otherwise land alpha ~128 and + * silently attenuate the mask (a ~50% denoise, invisible in the tinted overlay). + */ + forceOpaque?: boolean; +} + +/** True for a mask-bearing layer (inpaint mask / regional guidance) — a paintable alpha stencil. */ +const isMaskLayer = (layer: CanvasLayerContract): boolean => + layer.type === 'inpaint_mask' || layer.type === 'regional_guidance'; + +/** Resolves (or auto-creates) the paint target for a gesture, or `null` to no-op. */ +const resolveTarget = (ctx: ToolContext): PaintTarget | null => { + const doc = ctx.getDocument(); + if (!doc) { + return null; + } + const selected = doc.selectedLayerId ? doc.layers.find((layer) => layer.id === doc.selectedLayerId) : undefined; + + if (selected && selected.type === 'raster' && selected.source.type === 'paint') { + // The selection is a paint layer: paint into it, unless it's locked/disabled + // (a no-op — don't silently spawn a new layer over the user's locked target). + if (selected.isLocked || !selected.isEnabled) { + return null; + } + // The cache (if any) keeps its current content extent; the stroke grows it. + return { layerId: selected.id, transparencyLocked: selected.isTransparencyLocked === true }; + } + + if (selected && isMaskLayer(selected)) { + // The selection is a mask: paint the stroke into its alpha stencil cache + // (brush adds coverage, eraser removes — the shared stroke session handles + // both via its composite op). Never auto-create a paint layer here. A + // locked/hidden mask refuses the stroke (a no-op, not a spawn). + if (selected.isLocked || !selected.isEnabled) { + return null; + } + return { color: MASK_STROKE_COLOR, forceOpaque: true, layerId: selected.id }; + } + + // Selection is an image/other raster, another layer type, or nothing: create a + // fresh paint layer (inserted on top and selected by the reducer) and paint + // into it. This is the single allowed gesture-start dispatch. + const layerId = ctx.createLayerId(); + const layer: CanvasRasterLayerContractV2 = { + blendMode: 'normal', + id: layerId, + isEnabled: true, + isLocked: false, + name: `Layer ${doc.layers.length + 1}`, + opacity: 1, + source: { bitmap: null, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }; + // Auto-create inserts at the top (index 0); the reducer selects it. + ctx.dispatch({ layer, type: 'addCanvasLayer' }); + + // A brand-new empty paint layer: create a zero-rect cache marked fresh so the + // async rasterize pass doesn't clobber the stroke mid-gesture. The first stroke + // grows it from empty to the stroke's content bounds. + const entry = ctx.layers.getOrCreateRect(layerId, { height: 0, width: 0, x: 0, y: 0 }); + entry.stale = false; + return { createdLayer: { index: 0, layer }, layerId }; +}; + +/** Creates a brush-family tool from its per-gesture {@link PaintToolSpec}. */ +export const createPaintTool = (spec: PaintToolSpec): Tool => { + let session: StrokeSession | null = null; + + const cursorRadiusDoc = (ctx: ToolContext): number => spec.size(ctx) / 2; + + const updateCursorRing = (ctx: ToolContext, input: PointerInput): void => { + ctx.setOverlayCursor({ point: input.documentPoint, radiusDoc: cursorRadiusDoc(ctx) }); + ctx.invalidate({ overlay: true }); + }; + + const endSession = (): void => { + session = null; + }; + + return { + cursor: () => 'crosshair', + id: spec.id, + onDeactivate: (ctx) => { + if (session) { + session.cancel(); + endSession(); + } + ctx.setOverlayCursor(null); + ctx.invalidate({ overlay: true }); + }, + onPointerCancel: () => { + if (session) { + session.cancel(); + endSession(); + } + }, + onPointerDown: (ctx, input) => { + if (session || (input.buttons & PRIMARY_BUTTON) === 0) { + return; + } + const target = resolveTarget(ctx); + updateCursorRing(ctx, input); + if (!target) { + return; + } + // Transparency lock: the eraser is refused (it would alter the locked alpha); + // the brush switches to `source-atop` so colour lands only on existing pixels. + if (target.transparencyLocked && spec.id === 'eraser') { + return; + } + const composite = target.transparencyLocked && spec.id === 'brush' ? 'source-atop' : spec.composite; + session = createStrokeSession({ + // Resolve the selection clip ONCE per gesture: when a selection exists the + // stroke is masked to it; with none the field is null and the hot path is + // untouched (no per-point mask lookup). + clipMask: ctx.getSelectionMask?.() ?? null, + color: target.color ?? spec.color(ctx), + composite, + createdLayer: target.createdLayer ?? null, + ctx, + layerId: target.layerId, + // Mask strokes are forced opaque (an alpha stencil is all-or-nothing); a + // brush-opacity mask stroke would silently attenuate the denoise strength. + opacity: target.forceOpaque ? 1 : spec.opacity(ctx), + size: spec.size(ctx), + thinning: spec.thinning(ctx), + tool: spec.id, + }); + session.addPoints([input]); + }, + onPointerMove: (ctx, input, batch) => { + updateCursorRing(ctx, input); + if (session) { + session.addPoints(batch); + } + }, + onPointerUp: (ctx, input) => { + updateCursorRing(ctx, input); + if (session) { + session.commit(); + endSession(); + } + }, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/shapeTool.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/shapeTool.test.ts new file mode 100644 index 00000000000..110ef793ad7 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/shapeTool.test.ts @@ -0,0 +1,191 @@ +import type { Tool, ToolContext } from '@workbench/canvas-engine/tools/tool'; +import type { PointerInput, Vec2 } from '@workbench/canvas-engine/types'; +import type { Viewport } from '@workbench/canvas-engine/viewport'; +import type { CanvasDocumentContractV2 } from '@workbench/types'; +import type { WorkbenchAction } from '@workbench/workbenchState'; + +import { createEngineStores } from '@workbench/canvas-engine/engineStores'; +import { describe, expect, it, vi } from 'vitest'; + +import { createShapeTool, rectFromDrag } from './shapeTool'; + +const makeDoc = (): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 96, width: 96, x: 0, y: 0 }, + height: 512, + layers: [], + selectedLayerId: null, + version: 2, + width: 512, +}); + +const identityViewport = { + documentToScreen: (p: Vec2): Vec2 => ({ x: p.x, y: p.y }), +} as unknown as Viewport; + +const pointer = (x: number, y: number, opts: { shift?: boolean; buttons?: number } = {}): PointerInput => ({ + buttons: opts.buttons ?? 1, + documentPoint: { x, y }, + modifiers: { alt: false, ctrl: false, meta: false, shift: opts.shift ?? false }, + pointerType: 'mouse', + pressure: 0.5, + screenPoint: { x, y }, + timeStamp: 0, +}); + +interface StructuralCommit { + label: string; + forward: WorkbenchAction; + inverse: WorkbenchAction; +} + +const createHarness = (doc: CanvasDocumentContractV2) => { + const dispatched: WorkbenchAction[] = []; + const commits: StructuralCommit[] = []; + const stores = createEngineStores(); + let idCounter = 0; + const ctx: ToolContext = { + backend: null as never, + commitStructural: (label, forward, inverse) => commits.push({ forward, inverse, label }), + createLayerId: () => `shape-${++idCounter}`, + createPath2D: (d) => ({ d }) as unknown as Path2D, + dispatch: (action) => dispatched.push(action), + emitStrokeCommitted: vi.fn(), + getDocument: () => doc, + invalidate: vi.fn(), + layers: null as never, + notifyLayerPainted: vi.fn(), + setLayerTransformOverride: vi.fn(), + setOverlayCursor: vi.fn(), + stores, + updateCursor: vi.fn(), + viewport: identityViewport, + }; + return { commits, ctx, dispatched, previewOf: () => stores.shapePreview.get(), stores }; +}; + +const down = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerDown?.(ctx, i); +const move = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerMove?.(ctx, i, [i]); +const up = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerUp?.(ctx, i); + +describe('rectFromDrag', () => { + it('normalizes a drag to a positive integer rect', () => { + expect(rectFromDrag({ x: 50, y: 60 }, { x: 20, y: 100 }, false)).toEqual({ + height: 40, + width: 30, + x: 20, + y: 60, + }); + }); + + it('constrains to a square using the larger dimension, preserving direction', () => { + expect(rectFromDrag({ x: 0, y: 0 }, { x: 30, y: 80 }, true)).toEqual({ height: 80, width: 80, x: 0, y: 0 }); + // Dragging up-left: the square extends toward negative both axes. + expect(rectFromDrag({ x: 100, y: 100 }, { x: 70, y: 20 }, true)).toEqual({ + height: 80, + width: 80, + x: 20, + y: 20, + }); + }); +}); + +describe('shape tool: creation', () => { + it('previews on move then commits ONE addCanvasLayer on up', () => { + const h = createHarness(makeDoc()); + const tool = createShapeTool(); + + down(tool, h.ctx, pointer(10, 10)); + move(tool, h.ctx, pointer(70, 50)); + expect(h.previewOf()).toEqual({ kind: 'rect', rect: { height: 40, width: 60, x: 10, y: 10 } }); + + up(tool, h.ctx, pointer(70, 50)); + + // Creation goes through commitStructural (undoable), never a bare dispatch. + expect(h.dispatched).toHaveLength(0); + expect(h.commits).toHaveLength(1); + const forward = h.commits[0]?.forward; + expect(forward?.type).toBe('addCanvasLayer'); + if (forward?.type === 'addCanvasLayer' && forward.layer.type === 'raster') { + expect(forward.layer.source).toEqual({ + fill: '#000000', + height: 40, + kind: 'rect', + stroke: null, + strokeWidth: 8, + type: 'shape', + width: 60, + }); + expect(forward.layer.transform).toEqual({ rotation: 0, scaleX: 1, scaleY: 1, x: 10, y: 10 }); + } + // Inverse removes the created layer. + expect(h.commits[0]?.inverse).toEqual({ ids: ['shape-1'], type: 'removeCanvasLayers' }); + expect(h.previewOf()).toBeNull(); + }); + + it('uses the shape options kind/fill/stroke for the created layer', () => { + const h = createHarness(makeDoc()); + h.stores.shapeOptions.set({ fill: '#ff0000', kind: 'ellipse', stroke: '#0000ff', strokeWidth: 4 }); + const tool = createShapeTool(); + + down(tool, h.ctx, pointer(0, 0)); + move(tool, h.ctx, pointer(100, 100)); + up(tool, h.ctx, pointer(100, 100)); + + const forward = h.commits[0]?.forward; + if ( + forward?.type === 'addCanvasLayer' && + forward.layer.type === 'raster' && + forward.layer.source.type === 'shape' + ) { + expect(forward.layer.source.kind).toBe('ellipse'); + expect(forward.layer.source.fill).toBe('#ff0000'); + expect(forward.layer.source.stroke).toBe('#0000ff'); + expect(forward.layer.source.strokeWidth).toBe(4); + } else { + throw new Error('expected an ellipse shape layer'); + } + }); + + it('constrains to a square while shift is held', () => { + const h = createHarness(makeDoc()); + const tool = createShapeTool(); + down(tool, h.ctx, pointer(0, 0, { shift: true })); + move(tool, h.ctx, pointer(30, 80, { shift: true })); + up(tool, h.ctx, pointer(30, 80, { shift: true })); + const forward = h.commits[0]?.forward; + if ( + forward?.type === 'addCanvasLayer' && + forward.layer.type === 'raster' && + forward.layer.source.type === 'shape' + ) { + expect(forward.layer.source.width).toBe(80); + expect(forward.layer.source.height).toBe(80); + } else { + throw new Error('expected a shape layer'); + } + }); + + it('commits nothing for a zero-area drag', () => { + const h = createHarness(makeDoc()); + const tool = createShapeTool(); + down(tool, h.ctx, pointer(10, 10)); + move(tool, h.ctx, pointer(10, 10)); + up(tool, h.ctx, pointer(10, 10)); + expect(h.commits).toHaveLength(0); + expect(h.previewOf()).toBeNull(); + }); + + it('escape (cancel key command) drops the gesture without committing', () => { + const h = createHarness(makeDoc()); + const tool = createShapeTool(); + down(tool, h.ctx, pointer(10, 10)); + move(tool, h.ctx, pointer(70, 50)); + expect(h.previewOf()).not.toBeNull(); + tool.onKeyCommand?.(h.ctx, 'cancel'); + expect(h.previewOf()).toBeNull(); + // A subsequent up does nothing (gesture already dropped). + up(tool, h.ctx, pointer(70, 50)); + expect(h.commits).toHaveLength(0); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/shapeTool.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/shapeTool.ts new file mode 100644 index 00000000000..3ad2635920f --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/shapeTool.ts @@ -0,0 +1,159 @@ +/** + * The shape tool: drag on the canvas to CREATE a new shape layer sized by the + * drag rect. Scope is creation-only — dragging never edits an existing shape + * (param edits happen through the options bar and the transform tool). + * + * Interaction contract (CANVAS_PLAN Phase 6.1): + * - **Pointer-down** (primary button) starts a gesture at the press point. + * - **Pointer-move** (past a small threshold) updates a transient overlay + * preview rect (`stores.shapePreview`) — it never dispatches. Hold **shift** + * to constrain to a square/circle. + * - **Commit** (pointer-up after a real drag): exactly one `commitStructural` + * whose forward is `addCanvasLayer` (a shape source sized to the rect, placed + * at the rect origin) and whose inverse is `removeCanvasLayers` — so undo + * removes the created layer. A zero-area drag commits nothing. + * - **Cancel** (Esc / pointercancel): drops the preview, no dispatch. + * + * The selection mask does NOT constrain shape creation — a shape layer is a + * parametric object, not a pixel edit into an existing layer. + * + * Zero React, zero import-time side effects. + */ + +import type { Rect, Vec2 } from '@workbench/canvas-engine/types'; +import type { CanvasRasterLayerContractV2 } from '@workbench/types'; +import type { WorkbenchAction } from '@workbench/workbenchState'; + +import type { Tool, ToolContext } from './tool'; + +/** Bit for the primary (usually left) mouse button in `PointerEvent.buttons`. */ +const PRIMARY_BUTTON = 1; + +/** Screen-space distance (CSS px) the pointer must travel before a press becomes a drag. */ +export const SHAPE_DRAG_THRESHOLD_PX = 3; + +interface GestureState { + startDoc: Vec2; + startScreen: Vec2; + moved: boolean; +} + +/** The integer, normalized document rect for a drag from `start` to `end`, optionally square-constrained. */ +export const rectFromDrag = (start: Vec2, end: Vec2, square: boolean): Rect => { + let dx = end.x - start.x; + let dy = end.y - start.y; + if (square) { + const side = Math.max(Math.abs(dx), Math.abs(dy)); + dx = (dx < 0 ? -1 : 1) * side; + dy = (dy < 0 ? -1 : 1) * side; + } + const x = Math.round(Math.min(start.x, start.x + dx)); + const y = Math.round(Math.min(start.y, start.y + dy)); + return { height: Math.round(Math.abs(dy)), width: Math.round(Math.abs(dx)), x, y }; +}; + +/** Creates a fresh shape tool with its own gesture state. */ +export const createShapeTool = (): Tool => { + let state: GestureState | null = null; + + const clearPreview = (ctx: ToolContext): void => { + ctx.stores.shapePreview.set(null); + ctx.invalidate({ overlay: true }); + }; + + return { + cursor: () => 'crosshair', + id: 'shape', + onDeactivate: (ctx) => { + state = null; + clearPreview(ctx); + }, + onKeyCommand: (ctx, command) => { + if (command === 'cancel' && state) { + state = null; + clearPreview(ctx); + } + }, + onPointerCancel: (ctx) => { + state = null; + clearPreview(ctx); + }, + onPointerDown: (ctx, input) => { + if (state || (input.buttons & PRIMARY_BUTTON) === 0) { + return; + } + if (!ctx.getDocument()) { + return; + } + state = { moved: false, startDoc: input.documentPoint, startScreen: input.screenPoint }; + }, + onPointerMove: (ctx, input) => { + if (!state) { + return; + } + if (!state.moved) { + const dxs = input.screenPoint.x - state.startScreen.x; + const dys = input.screenPoint.y - state.startScreen.y; + if (Math.hypot(dxs, dys) < SHAPE_DRAG_THRESHOLD_PX) { + return; + } + state.moved = true; + } + const rect = rectFromDrag(state.startDoc, input.documentPoint, input.modifiers.shift); + ctx.stores.shapePreview.set({ kind: ctx.stores.shapeOptions.get().kind, rect }); + ctx.invalidate({ overlay: true }); + }, + onPointerUp: (ctx, input) => { + if (!state) { + return; + } + const current = state; + state = null; + + if (!current.moved) { + clearPreview(ctx); + return; + } + + const rect = rectFromDrag(current.startDoc, input.documentPoint, input.modifiers.shift); + if (rect.width < 1 || rect.height < 1) { + // Degenerate drag: nothing to create. + clearPreview(ctx); + return; + } + + const doc = ctx.getDocument(); + if (!doc) { + clearPreview(ctx); + return; + } + + const options = ctx.stores.shapeOptions.get(); + const layerId = ctx.createLayerId(); + const layer: CanvasRasterLayerContractV2 = { + blendMode: 'normal', + id: layerId, + isEnabled: true, + isLocked: false, + name: `Shape ${doc.layers.length + 1}`, + opacity: 1, + source: { + fill: options.fill, + height: rect.height, + kind: options.kind, + stroke: options.stroke, + strokeWidth: options.strokeWidth, + type: 'shape', + width: rect.width, + }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: rect.x, y: rect.y }, + type: 'raster', + }; + + const forward: WorkbenchAction = { index: 0, layer, type: 'addCanvasLayer' }; + const inverse: WorkbenchAction = { ids: [layerId], type: 'removeCanvasLayers' }; + ctx.commitStructural('Add shape', forward, inverse); + clearPreview(ctx); + }, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/strokeSession.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/strokeSession.test.ts new file mode 100644 index 00000000000..62e409f24f7 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/strokeSession.test.ts @@ -0,0 +1,264 @@ +import type { LayerCacheEntry } from '@workbench/canvas-engine/render/layerCache'; +import type { StubRasterBackend, StubRasterSurface } from '@workbench/canvas-engine/render/raster.testStub'; +import type { StrokeCommittedEvent, ToolContext } from '@workbench/canvas-engine/tools/tool'; +import type { PointerInput } from '@workbench/canvas-engine/types'; + +import { createLayerCacheStore } from '@workbench/canvas-engine/render/layerCache'; +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { createStrokeSession } from '@workbench/canvas-engine/tools/strokeSession'; +import { describe, expect, it, vi } from 'vitest'; + +const pointer = (x: number, y: number): PointerInput => ({ + buttons: 1, + documentPoint: { x, y }, + modifiers: { alt: false, ctrl: false, meta: false, shift: false }, + pointerType: 'mouse', + pressure: 0.5, + screenPoint: { x, y }, + timeStamp: 0, +}); + +/** A capturing backend so the stroke scratch surface can be identified. */ +const createCapturingBackend = (): { backend: StubRasterBackend; created: StubRasterSurface[] } => { + const inner = createTestStubRasterBackend(); + const created: StubRasterSurface[] = []; + return { + backend: { + ...inner, + createSurface: (w, h) => { + const s = inner.createSurface(w, h); + created.push(s); + return s; + }, + }, + created, + }; +}; + +const runStroke = (opts: { withMask: boolean }) => { + const { backend, created } = createCapturingBackend(); + const layers = createLayerCacheStore(backend); + const entry: LayerCacheEntry = layers.getOrCreate('L', 100, 100); + const mask = opts.withMask ? backend.createSurface(100, 100) : null; + const clipMask = mask ? { rect: { height: 100, width: 100, x: 0, y: 0 }, surface: mask } : null; + const strokes: StrokeCommittedEvent[] = []; + + const ctx = { + backend, + createPath2D: (d?: string) => ({ d }) as unknown as Path2D, + emitStrokeCommitted: (event: StrokeCommittedEvent) => strokes.push(event), + invalidate: vi.fn(), + layers, + notifyLayerPainted: vi.fn(), + } as unknown as ToolContext; + + // Only the scratch is created after this point. + created.length = 0; + const session = createStrokeSession({ + clipMask, + color: '#ff0000', + composite: 'source-over', + ctx, + layerId: 'L', + opacity: 1, + size: 20, + thinning: 0, + tool: 'brush', + }); + session.addPoints([pointer(10, 10)]); + session.addPoints([pointer(40, 10), pointer(40, 40)]); + session.commit(); + + const scratch = created[0]!; + return { cache: entry.surface as StubRasterSurface, scratch, strokes }; +}; + +const compositeOps = (surface: StubRasterSurface): unknown[] => + surface.callLog.filter((e) => e.op === 'set' && e.args[0] === 'globalCompositeOperation').map((e) => e.args[1]); + +describe('strokeSession: selection-constrained painting', () => { + it('with a clip mask, intersects the scratch stroke with the mask (destination-in) before compositing', () => { + const { scratch } = runStroke({ withMask: true }); + expect(compositeOps(scratch)).toContain('destination-in'); + // The mask is drawn into the scratch to clip it. + expect(scratch.callLog.some((e) => e.op === 'drawImage')).toBe(true); + }); + + it('without a clip mask, the scratch stays a plain filled stroke (no extra clip ops)', () => { + const { scratch } = runStroke({ withMask: false }); + expect(compositeOps(scratch)).not.toContain('destination-in'); + expect(scratch.callLog.some((e) => e.op === 'drawImage')).toBe(false); + expect(scratch.callLog.filter((e) => e.op === 'fill')).not.toHaveLength(0); + }); + + it('applies the selection clip on the scratch, not by changing the cache composite ops', () => { + const withMask = runStroke({ withMask: true }); + const noMask = runStroke({ withMask: false }); + // The clip is a `destination-in` on the SCRATCH; the layer-cache composite op + // sequence (source-over blit of the clipped stroke) is unchanged — the clip + // never adds a mask op to the cache itself. (The cache's content EXTENT can + // differ: without a selection the cache grows to the stroke's true bounds, + // while a selection bounds the growth to the mask — content-sized behavior.) + expect(compositeOps(withMask.cache)).toEqual(compositeOps(noMask.cache)); + expect(compositeOps(withMask.cache)).not.toContain('destination-in'); + }); + + it('still emits a commit with a dirty rect the mask does not shift', () => { + const { strokes } = runStroke({ withMask: true }); + expect(strokes).toHaveLength(1); + expect(strokes[0]!.dirtyRect.width).toBeGreaterThan(0); + expect(strokes[0]!.dirtyRect.height).toBeGreaterThan(0); + }); +}); + +describe('strokeSession: content-sized cache growth', () => { + const makeSession = (initialRect: { x: number; y: number; width: number; height: number }) => { + const { backend } = createCapturingBackend(); + const layers = createLayerCacheStore(backend); + const entry = layers.getOrCreateRect('L', initialRect); + entry.stale = false; + const strokes: StrokeCommittedEvent[] = []; + const ctx = { + backend, + createPath2D: (d?: string) => ({ d }) as unknown as Path2D, + emitStrokeCommitted: (event: StrokeCommittedEvent) => strokes.push(event), + invalidate: vi.fn(), + layers, + notifyLayerPainted: vi.fn(), + } as unknown as ToolContext; + const session = createStrokeSession({ + clipMask: null, + color: '#ff0000', + composite: 'source-over', + ctx, + layerId: 'L', + opacity: 1, + size: 20, + thinning: 0, + tool: 'brush', + }); + return { entry, session, strokes }; + }; + + it('grows an EMPTY (brand-new) paint cache to the stroke bounds on the first stroke', () => { + const { entry, session, strokes } = makeSession({ height: 0, width: 0, x: 0, y: 0 }); + session.addPoints([pointer(50, 60)]); + session.commit(); + + // The cache adopted the stroke's content bounds, snapped OUTWARD to the 64px + // growth-chunk grid — still content-sized (a couple of chunks), NOT an + // origin-anchored document-sized surface. A size-20 dab at (50,60) sits roughly + // at [40,60]×[50,70], which chunk-pads to a small chunk-aligned rect. + expect(entry.rect.width).toBeGreaterThan(0); + expect(entry.rect.height).toBeGreaterThan(0); + // Chunk-aligned extent (origin and size are multiples of the 64px chunk). + expect(entry.rect.x % 64).toBe(0); + expect(entry.rect.y % 64).toBe(0); + expect(entry.rect.width % 64).toBe(0); + expect(entry.rect.height % 64).toBe(0); + // Content-sized: a few chunks around the dab, not a huge (document) surface. + expect(entry.rect.width).toBeLessThanOrEqual(128); + expect(entry.rect.height).toBeLessThanOrEqual(128); + // The padded extent still fully contains the painted dab center (50,60). + expect(entry.rect.x).toBeLessThanOrEqual(50); + expect(entry.rect.x + entry.rect.width).toBeGreaterThan(50); + expect(entry.rect.y).toBeLessThanOrEqual(60); + expect(entry.rect.y + entry.rect.height).toBeGreaterThan(60); + expect(entry.surface.width).toBe(entry.rect.width); + expect(entry.surface.height).toBe(entry.rect.height); + // The committed dirty rect is the same (chunk-padded) layer-local region. + expect(strokes[0]!.dirtyRect).toEqual(entry.rect); + }); + + it('grows an existing cache to the UNION of its extent and an out-of-extent stroke (negative coords included)', () => { + const { entry, session } = makeSession({ height: 20, width: 20, x: 0, y: 0 }); + session.addPoints([pointer(-40, -40)]); + session.commit(); + + // Union of the pre-stroke [0,20)² extent and the stroke bounds around + // (-40,-40): the origin moved into negative layer-local space and the old + // extent's far edge is still covered. + expect(entry.rect.x).toBeLessThan(-30); + expect(entry.rect.y).toBeLessThan(-30); + expect(entry.rect.x + entry.rect.width).toBeGreaterThanOrEqual(20); + expect(entry.rect.y + entry.rect.height).toBeGreaterThanOrEqual(20); + expect(entry.surface.width).toBe(entry.rect.width); + expect(entry.surface.height).toBe(entry.rect.height); + }); + + it('reallocates the cache surface O(stroke / chunk) times — NOT once per batch — across an extending drag', () => { + // Pre-size the cache to a chunk-aligned rect already covering the first dab, so + // only genuine growth (not the empty-cache adoption) counts as a reallocation. + const { entry, session } = makeSession({ height: 64, width: 64, x: 64, y: 64 }); + const surface = entry.surface as StubRasterSurface; + const resizeCount = (): number => surface.callLog.filter((e) => e.op === 'resize').length; + + // Ten 10px batches extending the stroke 100px rightward within a single chunk + // row. Without chunk-padding, growToRect grows to the EXACT union every batch, + // so each batch reallocates + full-copies the cache (≈10 resizes). With the + // 64px chunk grid, successive small extensions land inside the padded extent, + // so the surface reallocates only when the stroke crosses a chunk boundary + // (~100 / 64 ≈ 2 times). + const batches = 10; + for (let i = 0; i < batches; i++) { + session.addPoints([pointer(100 + i * 10, 100)]); + } + session.commit(); + + const resizes = resizeCount(); + expect(resizes).toBeLessThanOrEqual(2); + // Sanity: far fewer reallocations than batches — the unpadded behavior this + // regression guards would resize on nearly every batch. + expect(resizes).toBeLessThan(batches); + }); +}); + +describe('strokeSession: cache version bump (live adjusted-surface invalidation)', () => { + const makeVersionSession = () => { + const { backend } = createCapturingBackend(); + const layers = createLayerCacheStore(backend); + const entry = layers.getOrCreate('L', 100, 100); + entry.stale = false; + const ctx = { + backend, + createPath2D: (d?: string) => ({ d }) as unknown as Path2D, + emitStrokeCommitted: vi.fn(), + invalidate: vi.fn(), + layers, + notifyLayerPainted: vi.fn(), + } as unknown as ToolContext; + const session = createStrokeSession({ + clipMask: null, + color: '#ff0000', + composite: 'source-over', + ctx, + layerId: 'L', + opacity: 1, + size: 20, + thinning: 0, + tool: 'brush', + }); + return { entry, session }; + }; + + it('bumps the cache version on every mid-stroke frame (so the adjusted-surface memo recomputes live)', () => { + const { entry, session } = makeVersionSession(); + const v0 = entry.version; + session.addPoints([pointer(10, 10)]); + const v1 = entry.version; + session.addPoints([pointer(40, 40)]); + const v2 = entry.version; + // Each painted frame advances the version — a version-keyed adjusted surface + // would otherwise serve stale (pre-stroke) adjusted pixels mid-stroke. + expect(v1).toBeGreaterThan(v0); + expect(v2).toBeGreaterThan(v1); + }); + + it('bumps the version on cancel so the restored pixels re-derive the adjusted surface', () => { + const { entry, session } = makeVersionSession(); + session.addPoints([pointer(10, 10)]); + const vBeforeCancel = entry.version; + session.cancel(); + expect(entry.version).toBeGreaterThan(vBeforeCancel); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/strokeSession.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/strokeSession.ts new file mode 100644 index 00000000000..1a4b22f0d86 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/strokeSession.ts @@ -0,0 +1,277 @@ +/** + * The per-gesture paint session shared by the brush and eraser tools. + * + * A session is created on pointer-down against a resolved target layer's cache + * surface and lives until commit (pointer-up) or cancel (Esc / pointercancel). + * It owns the hot path the plan pins as invariant: coalesced points accumulate, + * and on each batch the full freehand outline is filled into a scratch surface + * at **full alpha** and composited into the layer cache at the stroke's opacity — + * so overlapping segments within one stroke never darken (the "stroke buffer" + * approach). Brush composites `source-over` in the fill color; eraser composites + * `destination-out`. When the target raster layer's transparency is LOCKED, the + * brush composites `source-atop` instead, so colour only lands on already-opaque + * pixels and the layer's alpha channel is never grown (legacy "lock transparent + * pixels"). The eraser is refused entirely on a transparency-locked layer (it + * would alter alpha), handled by the tool before a session is created. + * + * ## Per-frame restore/recapture + * + * The cache is the live preview, so it must show `before ∪ stroke@opacity` each + * frame. To recomposite without compounding opacity, every frame first restores + * the previously-painted region from the captured "before" pixels, then + * recaptures the pristine "before" for the (monotonically growing) dirty region, + * then composites the whole accumulated stroke once. This keeps `beforeImageData` + * exactly equal to the pre-stroke pixels over the final dirty rect — which is + * what commit hands to history — and makes cancel a single `putImageData`. + * + * Everything flows through the {@link RasterSurface} `ctx` seam, so this runs + * unchanged on the node test stub. Zero React, zero dispatch on the move path. + */ + +import type { LayerCacheStore } from '@workbench/canvas-engine/render/layerCache'; +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { PlacedSurface, PointerInput, Rect } from '@workbench/canvas-engine/types'; +import type { CanvasLayerContract } from '@workbench/types'; + +import { strokeToPath, type StrokeSamplePoint } from '@workbench/canvas-engine/freehand'; +import { intersect, isEmpty, roundOut, union } from '@workbench/canvas-engine/math/rect'; + +import type { StrokeCommittedEvent, ToolContext } from './tool'; + +/** Everything a stroke session needs, resolved by the owning tool on pointer-down. */ +export interface StrokeSessionConfig { + ctx: ToolContext; + /** The layer being painted into (its cache grows with the stroke). */ + layerId: string; + /** Base stroke diameter (document units). */ + size: number; + /** Per-stroke opacity in [0, 1]. */ + opacity: number; + /** Freehand thinning; 0 disables pressure sensitivity. */ + thinning: number; + /** Fill color (brush only; ignored for the eraser). */ + color: string; + /** + * Cache composite operation: `source-over` (brush), `destination-out` (eraser), + * or `source-atop` (transparency-locked brush — colour only where the layer is + * already opaque, alpha never grows). + */ + composite: 'source-over' | 'destination-out' | 'source-atop'; + tool: 'brush' | 'eraser'; + /** Set only when this gesture auto-created its paint layer (for the composed history entry). */ + createdLayer?: { layer: CanvasLayerContract; index: number } | null; + /** + * The bounded selection mask to clip the stroke to (resolved once by the tool + * on pointer-down when a selection exists), as a placed surface in document + * (= layer-local) space. When set, the paint region is intersected with the + * mask bounds and the scratch stroke is masked (`destination-in`) before + * compositing, so pixels outside the selection are never written and the cache + * only grows within the selection. Absent ⇒ the no-selection hot path. + */ + clipMask?: PlacedSurface | null; +} + +/** The imperative handle a tool drives across a gesture. */ +export interface StrokeSession { + /** Appends coalesced samples and repaints the accumulated stroke. */ + addPoints(inputs: readonly PointerInput[]): void; + /** Finalizes the stroke, bumps the cache version, and emits the commit event. */ + commit(): void; + /** Restores the pre-stroke pixels and drops the session without an event. */ + cancel(): void; +} + +const toSample = (input: PointerInput): StrokeSamplePoint => ({ + pressure: input.pressure, + x: input.documentPoint.x, + y: input.documentPoint.y, +}); + +/** + * Cache/scratch growth is snapped OUTWARD to this pixel grid. Without it, an + * outward brush drag extends the paint region by a few pixels on every batch, so + * the cache (and the scratch) would reallocate + full-copy on every pointer-move. + * Snapping to a coarse chunk grid means successive small extensions land inside + * the current padded extent, so growth happens at most once per chunk crossed — + * O(stroke / CHUNK) reallocations instead of O(batches). + */ +const GROWTH_CHUNK = 64; + +/** Rounds a rect OUTWARD to the {@link GROWTH_CHUNK} grid (integer, chunk-aligned). */ +const padToChunk = (r: Rect): Rect => { + const x = Math.floor(r.x / GROWTH_CHUNK) * GROWTH_CHUNK; + const y = Math.floor(r.y / GROWTH_CHUNK) * GROWTH_CHUNK; + const right = Math.ceil((r.x + r.width) / GROWTH_CHUNK) * GROWTH_CHUNK; + const bottom = Math.ceil((r.y + r.height) / GROWTH_CHUNK) * GROWTH_CHUNK; + return { height: bottom - y, width: right - x, x, y }; +}; + +/** Creates a paint session that grows the target layer's content-sized cache. */ +export const createStrokeSession = (config: StrokeSessionConfig): StrokeSession => { + const { clipMask, color, composite, createdLayer, ctx, layerId, opacity, size, thinning, tool } = config; + + const layers: LayerCacheStore = ctx.layers; + // A per-frame scratch surface for the filled stroke, sized to the paint region. + let stroke: RasterSurface | null = null; + + const points: StrokeSamplePoint[] = []; + // `beforeImageData` holds the pristine (pre-stroke) pixels of `accumRect`, in + // LAYER-LOCAL coordinates — so it stays valid across a cache growth-realloc + // (which only shifts the surface origin, not the layer-local geometry). + let beforeImageData: ImageData | null = null; + let accumRect: Rect | null = null; + + /** Ensures the scratch surface is at least `w`×`h`. */ + const ensureStroke = (w: number, h: number): RasterSurface => { + if (!stroke) { + stroke = ctx.backend.createSurface(w, h); + } else if (stroke.width < w || stroke.height < h) { + stroke.resize(Math.max(stroke.width, w), Math.max(stroke.height, h)); + } + return stroke; + }; + + const paint = (last: boolean): void => { + if (points.length === 0) { + return; + } + const { bounds, path } = strokeToPath(points, { last, size, thinning }, ctx.createPath2D); + let dirty: Rect | null = roundOut(bounds); + // Selection clip: only the region inside the selection can ever change, so + // bound the dirty/growth region to the mask extent (and skip empty results). + if (clipMask) { + dirty = intersect(dirty, clipMask.rect); + } + if (!dirty || isEmpty(dirty)) { + return; + } + // Accumulate the dirty union, then round it OUTWARD to a coarse chunk grid. + // Chunk-padding is the allocation-light seam the plan pins as invariant: an + // extending drag now grows the cache/scratch at most once per chunk it crosses + // instead of on every pointer batch. The padded extent may exceed the true + // content bounds — fine: it is an internal cache extent, so the flush just + // encodes a slightly larger (mostly-transparent) PNG at a consistent offset, + // and the reported dirty/before/after all use this same padded region so every + // consumer stays coherent. When a selection clips the stroke, clamp the padded + // region back to the mask so growth still never escapes the selection. + let region = padToChunk(accumRect ? roundOut(union(accumRect, dirty)) : dirty); + if (clipMask) { + const clamped = intersect(region, clipMask.rect); + if (clamped) { + region = clamped; + } + } + if (isEmpty(region)) { + return; + } + + // Grow the cache to cover the (chunk-padded, layer-local) region, preserving + // existing pixels. Because `region` is chunk-aligned and monotonically growing, + // `entry.surface` is reallocated at most once per chunk boundary the stroke + // crosses — not once per batch — keeping the hot path allocation-light. The + // surface is resized in place (identity preserved). + const entry = layers.growToRect(layerId, region); + const target = entry.surface; + const targetCtx = target.ctx; + // Surface origin in layer-local space: surface(sx,sy) ↔ local(ox+sx, oy+sy). + const ox = entry.rect.x; + const oy = entry.rect.y; + + // 1. Restore the region painted last frame back to pristine "before" pixels, + // so recompositing the (larger) stroke doesn't compound its opacity. + if (accumRect && beforeImageData) { + targetCtx.putImageData(beforeImageData, accumRect.x - ox, accumRect.y - oy); + } + // 2. Recapture the pristine "before" for the grown region (in surface coords). + beforeImageData = targetCtx.getImageData(region.x - ox, region.y - oy, region.width, region.height); + accumRect = region; + + // 3. Render the whole accumulated stroke into the scratch surface at full + // alpha — one filled polygon, so no self-overlap darkening. The scratch is + // region-local: translate the (layer-local) path by -region.origin. + const scratch = ensureStroke(region.width, region.height); + const strokeCtx = scratch.ctx; + strokeCtx.setTransform(1, 0, 0, 1, -region.x, -region.y); + strokeCtx.clearRect(region.x, region.y, region.width, region.height); + strokeCtx.globalCompositeOperation = 'source-over'; + strokeCtx.globalAlpha = 1; + strokeCtx.fillStyle = color; + strokeCtx.fill(path); + + // 3b. Selection clip: keep only the stroke pixels inside the selection mask. + // The mask is a placed surface; draw it at (maskOrigin - regionOrigin) in + // the scratch's region-local space. + if (clipMask) { + strokeCtx.setTransform(1, 0, 0, 1, 0, 0); + strokeCtx.globalCompositeOperation = 'destination-in'; + strokeCtx.globalAlpha = 1; + strokeCtx.drawImage(clipMask.surface.canvas, clipMask.rect.x - region.x, clipMask.rect.y - region.y); + } + + // 4. Composite the scratch stroke into the cache over the region at the stroke + // opacity, using the tool's blend (brush over / eraser out). Both are in + // surface coords (region translated by the surface origin). + targetCtx.save(); + targetCtx.setTransform(1, 0, 0, 1, 0, 0); + targetCtx.beginPath(); + targetCtx.rect(region.x - ox, region.y - oy, region.width, region.height); + targetCtx.clip(); + targetCtx.globalAlpha = opacity; + targetCtx.globalCompositeOperation = composite; + targetCtx.drawImage(scratch.canvas, region.x - ox, region.y - oy); + targetCtx.restore(); + + // The cache pixels changed THIS frame — bump the version so a version-keyed + // dependent (the memoized adjusted-surface cache) recomputes over the live + // stroke. Without this the compositor would keep serving the pre-stroke + // adjusted surface, and the live stroke would be invisible on an adjusted + // raster layer until pointer-up (and jump on rect growth). This does NOT + // touch `thumbnailVersion` (only `notifyLayerPainted`/rasterize do), so + // thumbnails don't churn mid-stroke. + entry.version += 1; + ctx.invalidate({ layers: [layerId] }); + }; + + return { + addPoints: (inputs) => { + for (const input of inputs) { + points.push(toSample(input)); + } + paint(false); + }, + cancel: () => { + const entry = layers.get(layerId); + if (entry && accumRect && beforeImageData) { + entry.surface.ctx.putImageData(beforeImageData, accumRect.x - entry.rect.x, accumRect.y - entry.rect.y); + // Bump the version so the adjusted-surface memo (which recomputed over the + // live stroke) re-derives from the RESTORED pixels — otherwise an adjusted + // raster layer would keep showing the cancelled stroke's adjusted preview. + entry.version += 1; + ctx.invalidate({ layers: [layerId] }); + } + }, + commit: () => { + paint(true); + const entry = layers.get(layerId); + if (!entry || !accumRect || !beforeImageData) { + return; + } + const afterImageData = entry.surface.ctx.getImageData( + accumRect.x - entry.rect.x, + accumRect.y - entry.rect.y, + accumRect.width, + accumRect.height + ); + ctx.notifyLayerPainted(layerId); + const event: StrokeCommittedEvent = { + afterImageData, + beforeImageData, + dirtyRect: accumRect, + layerId, + tool, + ...(createdLayer ? { createdLayer } : {}), + }; + ctx.emitStrokeCommitted(event); + }, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/textTool.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/textTool.test.ts new file mode 100644 index 00000000000..31cd513e97f --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/textTool.test.ts @@ -0,0 +1,192 @@ +import type { Tool, ToolContext } from '@workbench/canvas-engine/tools/tool'; +import type { PointerInput, Vec2 } from '@workbench/canvas-engine/types'; +import type { CanvasDocumentContractV2, CanvasLayerContract } from '@workbench/types'; + +import { createEngineStores } from '@workbench/canvas-engine/engineStores'; +import { describe, expect, it, vi } from 'vitest'; + +import { createTextTool } from './textTool'; + +const textLayer = (over: Partial = {}): CanvasLayerContract => + ({ + blendMode: 'normal', + id: 'text-existing', + isEnabled: true, + isLocked: false, + name: 'Text', + opacity: 1, + source: { + align: 'left', + color: '#000000', + content: 'hello', + fontFamily: 'Inter', + fontSize: 20, + fontWeight: 400, + lineHeight: 1.2, + type: 'text', + }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + ...over, + }) as CanvasLayerContract; + +const makeDoc = (over: Partial = {}): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 96, width: 96, x: 0, y: 0 }, + height: 512, + layers: [], + selectedLayerId: null, + version: 2, + width: 512, + ...over, +}); + +const pointer = (x: number, y: number, buttons = 1): PointerInput => ({ + buttons, + documentPoint: { x, y }, + modifiers: { alt: false, ctrl: false, meta: false, shift: false }, + pointerType: 'mouse', + pressure: 0.5, + screenPoint: { x, y }, + timeStamp: 0, +}); + +const createHarness = (doc: CanvasDocumentContractV2) => { + const stores = createEngineStores(); + const openTextCreate = vi.fn<(point: Vec2) => void>(); + const openTextEdit = vi.fn<(layerId: string) => void>(); + const cancelTextEdit = vi.fn<() => void>(); + const ctx = { + cancelTextEdit, + getDocument: () => doc, + // No layer cache in these tests, so hit-testing falls back to the pure + // estimateTextExtent (a `get` that always misses). + layers: { get: () => undefined }, + openTextCreate, + openTextEdit, + stores, + } as unknown as ToolContext; + return { cancelTextEdit, ctx, openTextCreate, openTextEdit, stores }; +}; + +const down = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerDown?.(ctx, i); + +describe('text tool: click behaviour', () => { + it('opens a create-mode session at the click point on empty area', () => { + const h = createHarness(makeDoc()); + const tool = createTextTool(); + + down(tool, h.ctx, pointer(40, 60)); + + expect(h.openTextCreate).toHaveBeenCalledTimes(1); + expect(h.openTextCreate).toHaveBeenCalledWith({ x: 40, y: 60 }); + expect(h.openTextEdit).not.toHaveBeenCalled(); + }); + + it('opens an edit-mode session when the click hits an existing text layer', () => { + const doc = makeDoc({ layers: [textLayer()] }); + const h = createHarness(doc); + const tool = createTextTool(); + + // 'hello' at 20px → estimated block ~60×24, so (5,5) is inside. + down(tool, h.ctx, pointer(5, 5)); + + expect(h.openTextEdit).toHaveBeenCalledTimes(1); + expect(h.openTextEdit).toHaveBeenCalledWith('text-existing'); + expect(h.openTextCreate).not.toHaveBeenCalled(); + }); + + it('creates rather than edits when the click misses the text block', () => { + const doc = makeDoc({ layers: [textLayer()] }); + const h = createHarness(doc); + const tool = createTextTool(); + + down(tool, h.ctx, pointer(500, 500)); + + expect(h.openTextEdit).not.toHaveBeenCalled(); + expect(h.openTextCreate).toHaveBeenCalledTimes(1); + }); + + it('does not edit a locked text layer (falls through to create)', () => { + const doc = makeDoc({ layers: [textLayer({ isLocked: true })] }); + const h = createHarness(doc); + const tool = createTextTool(); + + down(tool, h.ctx, pointer(5, 5)); + + expect(h.openTextEdit).not.toHaveBeenCalled(); + expect(h.openTextCreate).toHaveBeenCalledTimes(1); + }); + + it('does not edit a hidden (disabled) text layer', () => { + const doc = makeDoc({ layers: [textLayer({ isEnabled: false })] }); + const h = createHarness(doc); + const tool = createTextTool(); + + down(tool, h.ctx, pointer(5, 5)); + + expect(h.openTextEdit).not.toHaveBeenCalled(); + expect(h.openTextCreate).toHaveBeenCalledTimes(1); + }); + + it('is a no-op while a session is already open (the click blurs/commits React-side)', () => { + const h = createHarness(makeDoc()); + h.stores.textEditSession.set({ + id: 1, + layerId: null, + mode: 'create', + source: { + align: 'left', + color: '#000000', + content: '', + fontFamily: 'Inter', + fontSize: 20, + fontWeight: 400, + lineHeight: 1.2, + type: 'text', + }, + startSource: null, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + }); + const tool = createTextTool(); + + down(tool, h.ctx, pointer(40, 60)); + + expect(h.openTextCreate).not.toHaveBeenCalled(); + expect(h.openTextEdit).not.toHaveBeenCalled(); + }); + + it('ignores a non-primary button press', () => { + const h = createHarness(makeDoc()); + const tool = createTextTool(); + + down(tool, h.ctx, pointer(40, 60, 2)); + + expect(h.openTextCreate).not.toHaveBeenCalled(); + }); +}); + +describe('text tool: teardown', () => { + it('cancels the session on a real deactivate but not on a temporary one', () => { + const h = createHarness(makeDoc()); + const tool = createTextTool(); + + tool.onDeactivate?.(h.ctx, { temporary: true }); + expect(h.cancelTextEdit).not.toHaveBeenCalled(); + + tool.onDeactivate?.(h.ctx); + expect(h.cancelTextEdit).toHaveBeenCalledTimes(1); + }); + + it('cancels the session on an Escape key command', () => { + const h = createHarness(makeDoc()); + const tool = createTextTool(); + + tool.onKeyCommand?.(h.ctx, 'cancel'); + expect(h.cancelTextEdit).toHaveBeenCalledTimes(1); + + // Apply is a no-op here (React owns the live content/commit). + tool.onKeyCommand?.(h.ctx, 'apply'); + expect(h.cancelTextEdit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/textTool.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/textTool.ts new file mode 100644 index 00000000000..ab0d7cad091 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/textTool.ts @@ -0,0 +1,132 @@ +/** + * The text tool: click to CREATE editable-forever text, or click an existing + * text layer to re-edit it. + * + * Interaction contract (CANVAS_PLAN Phase 6.2): + * - **Click on empty area** → open a CREATE-mode text-editing session at that + * document point (style seeded from the text options bar). Nothing is added to + * the document until the session commits (a single `addCanvasLayer`). + * - **Click on an existing text layer** (top-most, enabled, unlocked) → open an + * EDIT-mode session on it. Hit-testing inverts the layer transform and checks + * the point against the layer's rendered text-block rect (cache size, or the + * pure estimate before a cache exists). Locked/hidden text layers are skipped. + * - **Click while a session is open** → the pointer pipeline commits the open + * session engine-side (`maybeCommitModalSession` → `commitOpenTextSession`, + * reading the live portal content) and swallows the press BEFORE it reaches + * this tool, so `onPointerDown` below is never invoked for that press. A + * subsequent click then places/edits. The `textEditSession` guard here is a + * defensive backstop for a harness that routes the press through anyway. + * - **Commit** is engine-side on a canvas pointerdown (above); the portal's blur + * (focus lost to non-canvas UI) and `mod+enter` also commit. The live typed + * text is read from the portal only at commit time (no per-keystroke traffic). + * - **Escape** is handled by the focused contenteditable (cancels the session); + * a defocused-but-open session is cancelled by the engine's Escape chain + * (`handleEscape`: text → transform → deselect), not routed to this tool. + * - A **real** tool switch cancels the session (`onDeactivate`); a **temporary** + * modifier-hold switch (space→view / alt→colorPicker) preserves it, mirroring + * the transform tool. + * + * Text layers are not hit-testable by the move/transform tools (like shapes and + * gradients), so this tool owns its own text hit-test. + * + * Zero React, zero import-time side effects. + */ + +import type { Vec2 } from '@workbench/canvas-engine/types'; +import type { CanvasDocumentContractV2, CanvasLayerContract, CanvasLayerSourceContract } from '@workbench/types'; + +import { applyToPoint, invert } from '@workbench/canvas-engine/math/mat2d'; +import { estimateTextExtent } from '@workbench/canvas-engine/render/rasterizers/textRasterizer'; + +import type { Tool, ToolContext } from './tool'; + +import { layerMatrix } from './moveHitTest'; + +/** Bit for the primary (usually left) mouse button in `PointerEvent.buttons`. */ +const PRIMARY_BUTTON = 1; + +type TextSource = Extract; +/** A text-sourced raster layer. */ +type TextLayer = Extract & { source: TextSource }; + +/** True when `layer` is an enabled, unlocked text layer (an edit-session candidate). */ +const isEditableTextLayer = (layer: CanvasLayerContract): layer is TextLayer => + layer.type === 'raster' && layer.source.type === 'text' && layer.isEnabled && !layer.isLocked; + +/** + * The rendered text-block size for hit-testing: the live cache surface size when + * one exists (the precise, measured extent), else the pure estimate (before the + * layer has been rasterized once). + */ +const textLayerSize = (layer: TextLayer, ctx: ToolContext): { width: number; height: number } => { + const cache = ctx.layers.get(layer.id); + if (cache) { + return { height: cache.surface.height, width: cache.surface.width }; + } + return estimateTextExtent(layer.source); +}; + +/** The top-most editable text layer whose rendered block contains `point` (document space), or `null`. */ +const topTextLayerAt = (doc: CanvasDocumentContractV2, point: Vec2, ctx: ToolContext): TextLayer | null => { + for (const layer of doc.layers) { + if (!isEditableTextLayer(layer)) { + continue; + } + const inverse = invert(layerMatrix(layer.transform)); + if (!inverse) { + continue; + } + const local = applyToPoint(inverse, point); + const size = textLayerSize(layer, ctx); + if (local.x >= 0 && local.x <= size.width && local.y >= 0 && local.y <= size.height) { + return layer; + } + } + return null; +}; + +/** Creates a fresh text tool. It holds no gesture state (a click opens a session and returns). */ +export const createTextTool = (): Tool => ({ + cursor: () => 'text', + id: 'text', + onDeactivate: (ctx, opts) => { + if (opts?.temporary) { + // A modifier-hold switch (space/alt) preserves the open session for + // `onActivate` to resume when the hold ends — like the transform tool. + return; + } + // A real tool switch cancels the session (in practice the contenteditable's + // blur has already committed by now; this is the safety teardown). + ctx.cancelTextEdit?.(); + }, + onKeyCommand: (ctx, command) => { + // Defensive backstop: the pipeline does not route 'cancel' to tools (the + // engine's `handleEscape` chain owns text/transform/deselect), so this only + // fires if a harness routes it directly. Cancel drops a defocused session; + // apply is a no-op here (only the portal holds the live content to commit). + if (command === 'cancel') { + ctx.cancelTextEdit?.(); + } + }, + onPointerDown: (ctx, input) => { + if ((input.buttons & PRIMARY_BUTTON) === 0) { + return; + } + // Backstop: when a session is open the pipeline commits+swallows the press + // before it reaches this tool, so this branch is normally unreached. Guard + // anyway so a harness that routes the press through never opens a 2nd session. + if (ctx.stores.textEditSession.get()) { + return; + } + const doc = ctx.getDocument(); + if (!doc) { + return; + } + const hit = topTextLayerAt(doc, input.documentPoint, ctx); + if (hit) { + ctx.openTextEdit?.(hit.id); + } else { + ctx.openTextCreate?.(input.documentPoint); + } + }, +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/tool.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/tool.ts new file mode 100644 index 00000000000..5de9cb78443 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/tool.ts @@ -0,0 +1,201 @@ +/** + * The `Tool` seam: the engine routes normalized pointer/wheel input to whichever + * tool is active. Tools are pure interaction handlers — they read the viewport + * and mirrored document through the engine-provided {@link ToolContext} and + * request re-renders via `invalidate`. + * + * Navigation tools (view) never dispatch and never touch pixels. Painting tools + * (brush/eraser) reach the layer-cache surfaces and raster backend through the + * same context, dispatch at most once per gesture (auto-creating a paint layer + * on pointer-down when needed), and emit exactly one {@link StrokeCommittedEvent} + * on commit — persistence/history are wired to that event downstream, not here. + * + * Zero React, zero import-time side effects. + */ + +import type { EngineStores } from '@workbench/canvas-engine/engineStores'; +import type { CreatePath2D } from '@workbench/canvas-engine/freehand'; +import type { LayerCacheStore } from '@workbench/canvas-engine/render/layerCache'; +import type { OverlayCursor } from '@workbench/canvas-engine/render/overlayRenderer'; +import type { RasterBackend } from '@workbench/canvas-engine/render/raster'; +import type { InvalidatePayload } from '@workbench/canvas-engine/render/scheduler'; +import type { SelectionCommit } from '@workbench/canvas-engine/selection/selectionState'; +import type { LayerTransform } from '@workbench/canvas-engine/transform/transformMath'; +import type { PlacedSurface, PointerInput, PointerModifiers, Rect, ToolId, Vec2 } from '@workbench/canvas-engine/types'; +import type { Viewport } from '@workbench/canvas-engine/viewport'; +import type { CanvasDocumentContractV2, CanvasLayerContract } from '@workbench/types'; +import type { WorkbenchAction } from '@workbench/workbenchState'; + +/** + * Emitted once per completed brush/eraser gesture. Persistence (Task P2.2) and + * history (Task P2.3) subscribe via `engine.onStrokeCommitted`. `beforeImageData` + * and `afterImageData` are both sized to `dirtyRect`, so an undo can restore the + * pre-stroke pixels and a redo can re-apply the post-stroke pixels cheaply. + */ +export interface StrokeCommittedEvent { + /** The layer that received the stroke. */ + layerId: string; + /** The painted region in document space (integer bounds, clamped to the document). */ + dirtyRect: Rect; + /** Cache pixels within `dirtyRect` before the stroke. */ + beforeImageData: ImageData; + /** Cache pixels within `dirtyRect` after the stroke. */ + afterImageData: ImageData; + /** Which tool produced the stroke. */ + tool: 'brush' | 'eraser'; + /** + * When the gesture auto-created its paint layer on pointer-down, the created + * layer contract (and where it was inserted). The engine composes this into + * the stroke's history entry so an undo removes BOTH the stroke and the + * now-empty auto-created layer (and a redo re-adds the layer + stroke). + * Absent for strokes painted into a pre-existing layer. + */ + createdLayer?: { layer: CanvasLayerContract; index: number }; +} + +/** + * A transient per-layer transform override the compositor/overlay read at render + * time (a live drag preview that never touches the mirror). The move tool sets + * only `x`/`y` (rotation/scale fall back to the committed transform); the + * transform tool sets the full transform so a scale/rotate preview renders. + */ +export interface LayerTransformOverride { + x: number; + y: number; + scaleX?: number; + scaleY?: number; + rotation?: number; +} + +/** Everything a tool is allowed to reach, injected by the engine. */ +export interface ToolContext { + /** The pan/zoom viewport. */ + viewport: Viewport; + /** The current mirrored document, or `null` when none is available. */ + getDocument(): CanvasDocumentContractV2 | null; + /** Requests a re-render for the given flags. */ + invalidate(payload: InvalidatePayload): void; + /** Reducer bridge. Painting tools use it for the single gesture-start `addCanvasLayer`. */ + dispatch(action: WorkbenchAction): void; + /** + * Records a structural document edit on the engine-owned canvas history: + * dispatches `forward` now, and an undo dispatches `inverse` / a redo + * re-dispatches `forward`. The move tool commits a layer nudge through this. + */ + commitStructural(label: string, forward: WorkbenchAction, inverse: WorkbenchAction): void; + /** + * Sets (or clears with `null`) a transient per-layer transform override the + * compositor and overlay read at render time — a live drag preview that never + * touches the mirror/document. Cleared on commit or cancel. + */ + setLayerTransformOverride(layerId: string, override: LayerTransformOverride | null): void; + /** + * Begins a transform session on `layerId` (captures its committed transform, + * shows the live preview). Provided by the engine; the transform tool calls it + * on activate / when a layer is clicked. Absent in minimal test harnesses. + */ + beginTransformSession?(layerId: string): void; + /** Updates the active transform session's live transform (drag or numeric edit). */ + updateTransformSession?(transform: LayerTransform): void; + /** + * Commits the active transform session: a param commit (image layers) or a + * pixel bake (paint layers), as ONE undoable entry. Then clears the session. + */ + applyTransform?(): void; + /** Cancels the active transform session (drops the preview, no dispatch). */ + cancelTransform?(): void; + /** + * Opens a CREATE-mode text-editing session at `docPoint` (no layer yet; the + * commit later dispatches one `addCanvasLayer`). Seeds style from the text + * options store. The text tool calls it on an empty-area click. Absent in + * minimal test harnesses. + */ + openTextCreate?(docPoint: Vec2): void; + /** + * Opens an EDIT-mode text-editing session on an existing text layer (captures + * its committed source for the undo inverse). The text tool calls it when a + * click hits a text layer. Absent in minimal test harnesses. + */ + openTextEdit?(layerId: string): void; + /** Cancels the active text-editing session (drops it, no dispatch). */ + cancelTextEdit?(): void; + /** The raster backend, for allocating scratch stroke surfaces. */ + backend: RasterBackend; + /** The per-layer raster cache; painting tools fill directly into a layer's surface. */ + layers: LayerCacheStore; + /** Builds a `Path2D` (node-safe seam; the engine passes `(d) => new Path2D(d)`). */ + createPath2D: CreatePath2D; + /** Mints a fresh layer id for an auto-created paint layer. */ + createLayerId(): string; + /** The transient engine stores (tool options live here). */ + stores: EngineStores; + /** Sets (or clears) the brush cursor ring drawn on the overlay. */ + setOverlayCursor(cursor: OverlayCursor | null): void; + /** + * Re-evaluates the active tool's CSS cursor and applies it to the input + * element. A tool calls this when its `cursor(ctx)` result changes off a plain + * pointer-move (e.g. the bbox tool switching to a resize cursor while hovering a + * handle) — pointer-move does not otherwise refresh the cursor. + */ + updateCursor(): void; + /** Emits a completed-stroke event to `engine.onStrokeCommitted` subscribers. */ + emitStrokeCommitted(event: StrokeCommittedEvent): void; + /** Bumps a layer's cache version (without marking it stale) after a direct paint, and recomposites. */ + notifyLayerPainted(layerId: string): void; + /** + * Commits a lasso path to the engine's transient selection (boolean op applied + * to the mask). Provided by the engine; the lasso tool calls it on pointer-up. + * Absent in minimal test harnesses. + */ + commitSelection?(commit: SelectionCommit): void; + /** + * The current selection mask as a placed surface (alpha 255 inside) in document + * space — the mask is bounded to the selection extent, so its `rect` records + * where it sits. `null` when there is no selection. Painting tools read it ONCE + * on pointer-down to clip the stroke; a `null` result keeps the zero-overhead + * hot path. Absent in minimal test harnesses. + */ + getSelectionMask?(): PlacedSurface | null; +} + +/** + * Why a tool is being (de)activated, passed by the engine's `setTool` so a + * session-bearing tool (transform) can tell a temporary modifier-hold switch + * (space→view, alt→colorPicker; the pointer pipeline restores the prior tool + * on release) apart from a REAL tool switch. A temp switch must not tear down + * an in-progress session — only a real switch (or dispose) does. + */ +export interface ToolActivationOptions { + /** True for a pipeline modifier-hold switch (and its matching restore); absent/false for a real switch. */ + temporary?: boolean; +} + +/** A stateless-to-the-engine interaction handler. Implementations may hold private drag state. */ +export interface Tool { + readonly id: ToolId; + /** Called when the tool becomes active. */ + onActivate?(ctx: ToolContext, opts?: ToolActivationOptions): void; + /** Called when the tool is deactivated (also on engine dispose). */ + onDeactivate?(ctx: ToolContext, opts?: ToolActivationOptions): void; + onPointerDown?(ctx: ToolContext, input: PointerInput): void; + /** + * A pointer move. `batch` carries the coalesced samples for this move event + * (always at least one; its last element equals `input`); tools that paint + * consume the whole batch, navigation tools use only `input`. + */ + onPointerMove?(ctx: ToolContext, input: PointerInput, batch: readonly PointerInput[]): void; + onPointerUp?(ctx: ToolContext, input: PointerInput): void; + /** The active gesture was cancelled (Esc, pointercancel, focus loss). */ + onPointerCancel?(ctx: ToolContext): void; + /** + * A session-level key command routed from the pointer pipeline: Enter → + * `'apply'`, Escape → `'cancel'`. Tools with a multi-gesture session (transform) + * use it to commit/abort; other tools ignore it. Escape also runs the normal + * gesture cancel independently. + */ + onKeyCommand?(ctx: ToolContext, command: 'apply' | 'cancel'): void; + /** Wheel over the canvas; `screenAnchor` is the CSS-pixel cursor position. */ + onWheel?(ctx: ToolContext, deltaY: number, screenAnchor: { x: number; y: number }, modifiers: PointerModifiers): void; + /** The CSS cursor to show while this tool is active. */ + cursor?(ctx: ToolContext): string; +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/transformTool.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/transformTool.test.ts new file mode 100644 index 00000000000..69dff9b25df --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/transformTool.test.ts @@ -0,0 +1,440 @@ +import type { Tool, ToolContext } from '@workbench/canvas-engine/tools/tool'; +import type { LayerTransform } from '@workbench/canvas-engine/transform/transformMath'; +import type { PointerInput, Vec2 } from '@workbench/canvas-engine/types'; +import type { CanvasDocumentContractV2, CanvasLayerContract } from '@workbench/types'; + +import { createEngineStores } from '@workbench/canvas-engine/engineStores'; +import { TRANSFORM_ROTATE_NUB_PX, transformOverlayGeometry } from '@workbench/canvas-engine/transform/transformMath'; +import { describe, expect, it, vi } from 'vitest'; + +import { createTransformTool } from './transformTool'; + +const imageLayer = ( + id: string, + opts: { x?: number; y?: number; width?: number; height?: number; isLocked?: boolean; isEnabled?: boolean } = {} +): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: opts.isEnabled ?? true, + isLocked: opts.isLocked ?? false, + name: id, + opacity: 1, + source: { image: { height: opts.height ?? 100, imageName: id, width: opts.width ?? 100 }, type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: opts.x ?? 0, y: opts.y ?? 0 }, + type: 'raster', +}); + +const makeDoc = (layers: CanvasLayerContract[], selectedLayerId: string | null): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 200, width: 200, x: 0, y: 0 }, + height: 200, + layers, + selectedLayerId, + version: 2, + width: 200, +}); + +const pointer = ( + x: number, + y: number, + opts: { shift?: boolean; alt?: boolean; buttons?: number } = {} +): PointerInput => ({ + buttons: opts.buttons ?? 1, + documentPoint: { x, y }, + modifiers: { alt: opts.alt ?? false, ctrl: false, meta: false, shift: opts.shift ?? false }, + pointerType: 'mouse', + pressure: 0.5, + screenPoint: { x, y }, + timeStamp: 0, +}); + +/** A pointer input at an explicit SCREEN point, with the doc point derived at `zoom`. */ +const pointerAtScreen = (screen: Vec2, zoom: number): PointerInput => ({ + buttons: 1, + documentPoint: { x: screen.x / zoom, y: screen.y / zoom }, + modifiers: { alt: false, ctrl: false, meta: false, shift: false }, + pointerType: 'mouse', + pressure: 0.5, + screenPoint: screen, + timeStamp: 0, +}); + +/** The screen-space tip of the drawn rotation nub for `transform` at `zoom`. */ +const nubTipScreen = ( + transform: LayerTransform, + sz: { x: number; y: number; width: number; height: number }, + zoom: number +): Vec2 => { + const geo = transformOverlayGeometry(transform, sz); + const toScreen = (p: Vec2) => ({ x: zoom * p.x, y: zoom * p.y }); + const a = toScreen(geo.rotationAnchor); + const c = toScreen(geo.center); + const dx = a.x - c.x; + const dy = a.y - c.y; + const len = Math.hypot(dx, dy) || 1; + return { x: a.x + (dx / len) * TRANSFORM_ROTATE_NUB_PX, y: a.y + (dy / len) * TRANSFORM_ROTATE_NUB_PX }; +}; + +/** Rotates `p` about `pivot` by `rad` (screen space). */ +const rotateAbout = (p: Vec2, pivot: Vec2, rad: number): Vec2 => { + const cos = Math.cos(rad); + const sin = Math.sin(rad); + const dx = p.x - pivot.x; + const dy = p.y - pivot.y; + return { x: pivot.x + cos * dx - sin * dy, y: pivot.y + sin * dx + cos * dy }; +}; + +interface Harness { + ctx: ToolContext; + applyCount: () => number; + session: () => ReturnType['transformSession']['get']>; + overrides: { layerId: string; override: unknown }[]; +} + +/** + * A ToolContext whose transform-session seams mutate a real `transformSession` + * store (mirroring the engine), so the tool's reads reflect its own writes across + * a down→move→up drag. The viewport projects document→screen 1:1. + */ +const createHarness = (doc: CanvasDocumentContractV2, zoom = 1): Harness => { + const stores = createEngineStores(); + const overrides: { layerId: string; override: unknown }[] = []; + const state = { applyCount: 0 }; + + const beginTransformSession = (layerId: string): void => { + const layer = doc.layers.find((entry) => entry.id === layerId); + if (!layer) { + return; + } + const start: LayerTransform = { ...layer.transform }; + stores.transformSession.set({ layerId, startTransform: start, transform: start }); + overrides.push({ layerId, override: start }); + }; + const updateTransformSession = (transform: LayerTransform): void => { + const session = stores.transformSession.get(); + if (!session) { + return; + } + stores.transformSession.set({ ...session, transform }); + overrides.push({ layerId: session.layerId, override: transform }); + }; + const cancelTransform = (): void => { + const session = stores.transformSession.get(); + if (session) { + overrides.push({ layerId: session.layerId, override: null }); + } + stores.transformSession.set(null); + }; + + const ctx: ToolContext = { + applyTransform: () => { + state.applyCount += 1; + }, + backend: null as never, + beginTransformSession, + cancelTransform, + commitStructural: vi.fn(), + createLayerId: () => 'x', + createPath2D: (d) => ({ d }) as unknown as Path2D, + dispatch: vi.fn(), + emitStrokeCommitted: vi.fn(), + getDocument: () => doc, + invalidate: vi.fn(), + layers: null as never, + notifyLayerPainted: vi.fn(), + setLayerTransformOverride: vi.fn(), + setOverlayCursor: vi.fn(), + stores, + updateCursor: vi.fn(), + updateTransformSession, + viewport: { + documentToScreen: (p: Vec2) => ({ x: zoom * p.x, y: zoom * p.y }), + screenToDocument: (p: Vec2) => ({ x: p.x / zoom, y: p.y / zoom }), + } as never, + }; + + return { + applyCount: () => state.applyCount, + ctx, + overrides, + session: () => stores.transformSession.get(), + }; +}; + +const activate = (t: Tool, ctx: ToolContext): void => t.onActivate?.(ctx); +const down = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerDown?.(ctx, i); +const move = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerMove?.(ctx, i, [i]); +const up = (t: Tool, ctx: ToolContext, i: PointerInput): void => t.onPointerUp?.(ctx, i); + +describe('transform tool: session lifecycle', () => { + it('opens a session on the selected eligible layer when activated', () => { + const doc = makeDoc([imageLayer('a')], 'a'); + const h = createHarness(doc); + const tool = createTransformTool(); + + activate(tool, h.ctx); + + const s = h.session(); + expect(s?.layerId).toBe('a'); + expect(s?.transform).toEqual({ rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }); + }); + + it('opens no session on a locked selected layer', () => { + const doc = makeDoc([imageLayer('a', { isLocked: true })], 'a'); + const h = createHarness(doc); + const tool = createTransformTool(); + + activate(tool, h.ctx); + + expect(h.session()).toBeNull(); + }); + + it('opens no session on a hidden selected layer', () => { + const doc = makeDoc([imageLayer('a', { isEnabled: false })], 'a'); + const h = createHarness(doc); + const tool = createTransformTool(); + + activate(tool, h.ctx); + + expect(h.session()).toBeNull(); + }); + + it('clicking a layer with no session starts a session (move gesture)', () => { + const doc = makeDoc([imageLayer('a', { width: 100, height: 100 })], null); + const h = createHarness(doc); + const tool = createTransformTool(); + + down(tool, h.ctx, pointer(50, 50)); + expect(h.session()?.layerId).toBe('a'); + }); +}); + +describe('transform tool: gestures', () => { + it('scales via a corner handle drag (updates the session, no dispatch)', () => { + const doc = makeDoc([imageLayer('a', { width: 100, height: 100 })], 'a'); + const h = createHarness(doc); + const tool = createTransformTool(); + activate(tool, h.ctx); + + // se corner is at doc (100,100); drag it to (150,150). + down(tool, h.ctx, pointer(100, 100)); + move(tool, h.ctx, pointer(150, 150)); + up(tool, h.ctx, pointer(150, 150)); + + const s = h.session(); + expect(s?.transform.scaleX).toBeCloseTo(1.5, 5); + expect(s?.transform.scaleY).toBeCloseTo(1.5, 5); + // No structural dispatch — the session holds the preview until Apply. + expect(h.ctx.commitStructural).not.toHaveBeenCalled(); + }); + + it('moves via an interior drag', () => { + const doc = makeDoc([imageLayer('a', { width: 100, height: 100 })], 'a'); + const h = createHarness(doc); + const tool = createTransformTool(); + activate(tool, h.ctx); + + down(tool, h.ctx, pointer(50, 50)); + move(tool, h.ctx, pointer(70, 65)); + up(tool, h.ctx, pointer(70, 65)); + + const s = h.session(); + expect(s?.transform.x).toBeCloseTo(20, 5); + expect(s?.transform.y).toBeCloseTo(15, 5); + }); + + it('rotates via a corner rotate zone drag', () => { + const doc = makeDoc([imageLayer('a', { width: 100, height: 100 })], 'a'); + const h = createHarness(doc); + const tool = createTransformTool(); + activate(tool, h.ctx); + + // Just outside the se corner (100,100) is a rotate zone. + down(tool, h.ctx, pointer(112, 112)); + move(tool, h.ctx, pointer(100, 130)); + up(tool, h.ctx, pointer(100, 130)); + + const s = h.session(); + expect(s?.transform.rotation).not.toBe(0); + }); + + it('ignores a sub-threshold drag (no session change)', () => { + const doc = makeDoc([imageLayer('a', { width: 100, height: 100 })], 'a'); + const h = createHarness(doc); + const tool = createTransformTool(); + activate(tool, h.ctx); + const startOverrides = h.overrides.length; + + down(tool, h.ctx, pointer(100, 100)); + move(tool, h.ctx, pointer(101, 101)); + up(tool, h.ctx, pointer(101, 101)); + + // No update beyond the initial begin override. + expect(h.overrides.length).toBe(startOverrides); + expect(h.session()?.transform.scaleX).toBe(1); + }); +}); + +describe('transform tool: rotation nub (regression — a nub press must rotate, not reset)', () => { + const transformed: LayerTransform = { rotation: 0.4, scaleX: 1.6, scaleY: 1.3, x: 30, y: 20 }; + const layerSize = { height: 100, width: 100, x: 0, y: 0 }; + + const run = (zoom: number): void => { + const layer: CanvasLayerContract = { ...imageLayer('a'), transform: transformed }; + const doc = makeDoc([layer], 'a'); + const h = createHarness(doc, zoom); + const tool = createTransformTool(); + activate(tool, h.ctx); + + const startTransform = h.session()?.transform; + expect(startTransform).toEqual(transformed); + const overridesBefore = h.overrides.length; + + // Pointer-down EXACTLY on the drawn rotation nub (above the top edge). + const tip = nubTipScreen(transformed, layerSize, zoom); + down(tool, h.ctx, pointerAtScreen(tip, zoom)); + + // (a) Gesture start must NOT touch the session/override values. The bug read + // the nub press as off-frame and re-opened the session, resetting its live + // transform back to the committed one. + expect(h.session()?.transform).toEqual(startTransform); + expect(h.overrides.length).toBe(overridesBefore); + + // (b) A subsequent move begins a ROTATION: rotation changes by the swept + // angle, scale is untouched (not a move/scale/reset). + const geo = transformOverlayGeometry(transformed, layerSize); + const centerScreen: Vec2 = { x: zoom * geo.center.x, y: zoom * geo.center.y }; + const moved = rotateAbout(tip, centerScreen, 0.5); + move(tool, h.ctx, pointerAtScreen(moved, zoom)); + + const after = h.session()?.transform; + expect(after).toBeDefined(); + expect(after?.scaleX).toBeCloseTo(transformed.scaleX, 6); + expect(after?.scaleY).toBeCloseTo(transformed.scaleY, 6); + expect(after?.rotation).toBeCloseTo(transformed.rotation + 0.5, 6); + }; + + it('rotates (does not reset) at zoom 1', () => run(1)); + it('rotates (does not reset) at a non-1 zoom', () => run(2.5)); +}); + +describe('transform tool: apply / cancel', () => { + it('Enter applies the session', () => { + const doc = makeDoc([imageLayer('a')], 'a'); + const h = createHarness(doc); + const tool = createTransformTool(); + activate(tool, h.ctx); + + tool.onKeyCommand?.(h.ctx, 'apply'); + + // The engine apply seam was invoked exactly once. + expect(h.applyCount()).toBe(1); + }); + + it('Escape cancels the session', () => { + const doc = makeDoc([imageLayer('a')], 'a'); + const h = createHarness(doc); + const tool = createTransformTool(); + activate(tool, h.ctx); + expect(h.session()).not.toBeNull(); + + tool.onKeyCommand?.(h.ctx, 'cancel'); + + expect(h.session()).toBeNull(); + }); + + it('tool switch (deactivate) cancels the session', () => { + const doc = makeDoc([imageLayer('a')], 'a'); + const h = createHarness(doc); + const tool = createTransformTool(); + activate(tool, h.ctx); + + tool.onDeactivate?.(h.ctx); + + expect(h.session()).toBeNull(); + }); + + it('pointercancel reverts the drag but keeps the session', () => { + const doc = makeDoc([imageLayer('a', { width: 100, height: 100 })], 'a'); + const h = createHarness(doc); + const tool = createTransformTool(); + activate(tool, h.ctx); + + down(tool, h.ctx, pointer(100, 100)); + move(tool, h.ctx, pointer(160, 160)); + expect(h.session()?.transform.scaleX).toBeGreaterThan(1); + + tool.onPointerCancel?.(h.ctx); + + // Session persists, reverted to the start transform. + const s = h.session(); + expect(s).not.toBeNull(); + expect(s?.transform.scaleX).toBe(1); + }); + + it('Enter mid-drag no-ops: the gesture continues (a further move still updates the session) and pointer-up still works', () => { + const doc = makeDoc([imageLayer('a', { width: 100, height: 100 })], 'a'); + const h = createHarness(doc); + const tool = createTransformTool(); + activate(tool, h.ctx); + + // se corner drag, past the threshold — a gesture is now in progress and + // holds (conceptually) pointer capture. + down(tool, h.ctx, pointer(100, 100)); + move(tool, h.ctx, pointer(120, 120)); + const midDrag = h.session()?.transform.scaleX; + expect(midDrag).toBeGreaterThan(1); + + tool.onKeyCommand?.(h.ctx, 'apply'); + + // No-op: the engine apply seam was NOT invoked (unlike the "Enter applies + // the session" test above, which has no live gesture). + expect(h.applyCount()).toBe(0); + + // The gesture is still alive — it did not silently freeze mid-drag. + move(tool, h.ctx, pointer(150, 150)); + expect(h.session()?.transform.scaleX).toBeGreaterThan(midDrag!); + + // Pointer-up still ends the gesture normally, keeping the session. + up(tool, h.ctx, pointer(150, 150)); + expect(h.session()).not.toBeNull(); + }); +}); + +describe('transform tool: temp-tool switch (space/alt hold)', () => { + it('a temporary deactivate preserves the session; the matching temporary activate leaves it untouched', () => { + const doc = makeDoc([imageLayer('a', { x: 5, y: 5 })], 'a'); + const h = createHarness(doc); + const tool = createTransformTool(); + activate(tool, h.ctx); + h.ctx.updateTransformSession?.({ rotation: 0, scaleX: 2, scaleY: 1, x: 40, y: 5 }); + const edited = h.session()?.transform; + + // Space down: a temporary deactivate must not cancel the session. + tool.onDeactivate?.(h.ctx, { temporary: true }); + expect(h.session()?.transform).toEqual(edited); + + // Space up: a temporary activate must not re-open the session from the + // current selection (which would stomp the preserved edit with the + // layer's committed transform). + tool.onActivate?.(h.ctx, { temporary: true }); + expect(h.session()?.transform).toEqual(edited); + }); + + it('a temporary activate does not resurrect a session the engine already cancelled while held (e.g. its layer was deleted)', () => { + const doc = makeDoc([imageLayer('a')], 'a'); + const h = createHarness(doc); + const tool = createTransformTool(); + activate(tool, h.ctx); + expect(h.session()).not.toBeNull(); + + tool.onDeactivate?.(h.ctx, { temporary: true }); + // Simulates the engine's layer-change teardown (Task 26 finding #3) + // cancelling the session out-of-band while temp-switched away. + h.ctx.cancelTransform?.(); + expect(h.session()).toBeNull(); + + tool.onActivate?.(h.ctx, { temporary: true }); + expect(h.session()).toBeNull(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/transformTool.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/transformTool.ts new file mode 100644 index 00000000000..3833123d0cd --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/transformTool.ts @@ -0,0 +1,297 @@ +/** + * The transform tool: an interactive scale/rotate/move SESSION on a single layer. + * + * Interaction contract (CANVAS_PLAN Phase 5): + * - **Session**: selecting the tool with an eligible layer selected (or clicking a + * layer) captures its committed transform and opens a session. The live preview + * flows through the engine's transform-override channel; a `transformSession` + * store exposes the layer id + live transform so the numeric options bar can + * render and edit it. The session survives multiple gestures — drag handles, + * drag to rotate/move, adjust numerics — until **Apply** or **Cancel**. + * - **Gestures** (each a fresh pointer drag on the session's frame): a scale + * handle scales about the opposite handle (alt = center, shift = uniform on + * corners); a corner rotate zone rotates about the center (shift = 15° snap); + * the interior moves (shift = axis constrain). Pointer-move only updates the + * session preview — it never dispatches. + * - **Apply** (`enter` / options button): the engine commits — a param edit for + * image layers, a pixel bake for paint layers — as ONE undoable entry. + * - **Cancel** (`esc` / options button / a REAL tool switch): drops the + * preview, no dispatch. A mid-gesture pointercancel reverts just that drag, + * keeping the session; Escape aborts the whole session. A TEMPORARY tool + * switch (space/alt modifier-hold) is not a cancel — the session and its + * preview survive the hold and resume when it ends (see `onActivate`/ + * `onDeactivate`'s `opts.temporary`). If the session's layer is deleted + * while held, the engine's layer-change teardown cancels it regardless of + * which tool is active. + * + * Locked/hidden layers get no session (same guard as the move tool). Zero React, + * zero import-time side effects. + */ + +import type { LayerTransform, TransformRect, TransformTarget } from '@workbench/canvas-engine/transform/transformMath'; +import type { PointerInput, Vec2 } from '@workbench/canvas-engine/types'; +import type { CanvasLayerContract } from '@workbench/types'; + +import { + applyMove, + applyRotate, + applyScale, + resizeCursorForHandle, + transformTargetAt, +} from '@workbench/canvas-engine/transform/transformMath'; + +import type { Tool, ToolContext } from './tool'; + +import { hittableLayerRect, topLayerAt } from './moveHitTest'; + +/** Bit for the primary (usually left) mouse button in `PointerEvent.buttons`. */ +const PRIMARY_BUTTON = 1; + +/** Screen-space distance (CSS px) the pointer must travel before a press becomes a drag. */ +export const TRANSFORM_DRAG_THRESHOLD_PX = 3; + +interface GestureState { + target: TransformTarget; + /** The session transform captured at gesture start (revert target for cancel). */ + startTransform: LayerTransform; + startPointerDoc: Vec2; + startScreen: Vec2; + /** The session layer's local content rect (off-origin aware). */ + rect: TransformRect; + /** The cursor held for the duration of this gesture. */ + cursor: string; + moved: boolean; +} + +/** The cursor for a hovered/grabbed target, given the layer's current transform. */ +const cursorForTarget = (transform: LayerTransform, target: TransformTarget): string => { + if (target.kind === 'scale') { + return resizeCursorForHandle(transform, target.handle); + } + return target.kind === 'rotate' ? 'grab' : 'move'; +}; + +/** Creates a fresh transform tool with its own session/gesture state. */ +export const createTransformTool = (): Tool => { + let gesture: GestureState | null = null; + // The cursor for the target under the pointer while idle (session but no drag). + let hoverCursor: string | null = null; + + const isEligible = (layer: CanvasLayerContract, doc: NonNullable>): boolean => + // Masks are MOVE-able (legacy parity) but not transform-able in this phase: + // `applyTransform` has no mask bake path, so a transform session on a mask + // would preview then no-op on Apply. Exclude them until that lands (Phase 7+). + layer.isEnabled && + !layer.isLocked && + layer.type !== 'inpaint_mask' && + layer.type !== 'regional_guidance' && + hittableLayerRect(layer, doc) !== null; + + /** Hit-tests the active session's frame at a screen point (or `null`). */ + const targetAt = (ctx: ToolContext, screenPoint: Vec2): TransformTarget | null => { + const session = ctx.stores.transformSession.get(); + const doc = ctx.getDocument(); + if (!session || !doc) { + return null; + } + const layer = doc.layers.find((candidate) => candidate.id === session.layerId); + const rect = layer ? hittableLayerRect(layer, doc) : null; + if (!rect) { + return null; + } + return transformTargetAt({ + point: screenPoint, + rect, + toScreen: (p) => ctx.viewport.documentToScreen(p), + transform: session.transform, + }); + }; + + const nextTransform = (state: GestureState, input: PointerInput): LayerTransform => { + const delta: Vec2 = { + x: input.documentPoint.x - state.startPointerDoc.x, + y: input.documentPoint.y - state.startPointerDoc.y, + }; + switch (state.target.kind) { + case 'move': + return applyMove(state.startTransform, delta, input.modifiers.shift); + case 'scale': + return applyScale({ + alt: input.modifiers.alt, + handle: state.target.handle, + pointerDoc: input.documentPoint, + shift: input.modifiers.shift, + rect: state.rect, + start: state.startTransform, + startPointerDoc: state.startPointerDoc, + }); + case 'rotate': + return applyRotate({ + pointerDoc: input.documentPoint, + shift: input.modifiers.shift, + rect: state.rect, + start: state.startTransform, + startPointerDoc: state.startPointerDoc, + }); + } + }; + + const endGesture = (): void => { + gesture = null; + }; + + return { + cursor: () => { + if (gesture) { + return gesture.cursor; + } + return hoverCursor ?? 'default'; + }, + id: 'transform', + onActivate: (ctx, opts) => { + if (opts?.temporary) { + // Resuming from a modifier-hold switch (space→view, alt→colorPicker): + // `onDeactivate` preserved the session (and its preview override) + // across the hold, so there is nothing to (re)open here. Re-opening + // from the current selection would stomp the live preview with the + // layer's committed transform, discarding accumulated drags/numeric + // edits. If the session's layer vanished mid-hold, the engine's + // layer-change teardown already cancelled it — leave that alone too. + return; + } + // Entering the tool on an eligible selected layer opens a session on it. + const doc = ctx.getDocument(); + const selectedId = doc?.selectedLayerId; + const selected = selectedId ? doc?.layers.find((layer) => layer.id === selectedId) : undefined; + if (doc && selected && isEligible(selected, doc)) { + ctx.beginTransformSession?.(selected.id); + } + }, + onDeactivate: (ctx, opts) => { + hoverCursor = null; + if (opts?.temporary) { + // A modifier-hold switch (space/alt) must not discard an in-progress + // session: the pipeline already suppresses temp switches mid-gesture, + // so `gesture` is guaranteed null here — this only clears the idle + // hover cursor. The session + preview override are left for + // `onActivate` to resume when the hold ends. A REAL tool switch (below) + // still cancels. + endGesture(); + return; + } + // A real tool switch mid-session cancels it (drops the preview, no dispatch). + endGesture(); + ctx.cancelTransform?.(); + }, + onKeyCommand: (ctx, command) => { + if (gesture) { + // A live drag holds pointer capture (its own pointerup/pointercancel + // will end it); applying or cancelling now would tear the gesture down + // out from under the still-open pointer session, freezing the preview + // mid-drag. No-op instead — mirrors `applyTransform`'s own mid-gesture + // guard (`pipeline.isGestureActive()`) for the same reason. + return; + } + if (command === 'apply') { + ctx.applyTransform?.(); + } else { + ctx.cancelTransform?.(); + } + }, + onPointerCancel: (ctx) => { + // Revert just this drag (keep the session); Escape's session cancel is separate. + if (gesture) { + const session = ctx.stores.transformSession.get(); + if (session) { + ctx.updateTransformSession?.(gesture.startTransform); + } + endGesture(); + ctx.invalidate({ overlay: true }); + } + }, + onPointerDown: (ctx, input) => { + if (gesture || (input.buttons & PRIMARY_BUTTON) === 0) { + return; + } + const doc = ctx.getDocument(); + if (!doc) { + return; + } + const session = ctx.stores.transformSession.get(); + + // 1) An open session: a press on its frame starts a scale/rotate/move gesture. + if (session) { + const layer = doc.layers.find((candidate) => candidate.id === session.layerId); + const rect = layer ? hittableLayerRect(layer, doc) : null; + const target = rect ? targetAt(ctx, input.screenPoint) : null; + if (rect && target) { + gesture = { + cursor: target.kind === 'rotate' ? 'grabbing' : cursorForTarget(session.transform, target), + moved: false, + rect, + startPointerDoc: input.documentPoint, + startScreen: input.screenPoint, + startTransform: session.transform, + target, + }; + return; + } + } + + // 2) No session, or a press off the session frame: adopt the top-most eligible + // layer under the pointer and start a move gesture on it. Empty space is a + // no-op (the current session, if any, persists). + const hit = topLayerAt(doc, input.documentPoint, (layer) => isEligible(layer, doc)); + if (!hit) { + return; + } + const rect = hittableLayerRect(hit, doc); + if (!rect) { + return; + } + ctx.beginTransformSession?.(hit.id); + gesture = { + cursor: 'move', + moved: false, + rect, + startPointerDoc: input.documentPoint, + startScreen: input.screenPoint, + startTransform: hit.transform, + target: { kind: 'move' }, + }; + }, + onPointerMove: (ctx, input) => { + if (gesture) { + if (!gesture.moved) { + const dxs = input.screenPoint.x - gesture.startScreen.x; + const dys = input.screenPoint.y - gesture.startScreen.y; + if (Math.hypot(dxs, dys) < TRANSFORM_DRAG_THRESHOLD_PX) { + return; + } + gesture.moved = true; + } + ctx.updateTransformSession?.(nextTransform(gesture, input)); + return; + } + // Idle hover over a session frame: reflect the target under the pointer. + const session = ctx.stores.transformSession.get(); + if (!session) { + if (hoverCursor !== null) { + hoverCursor = null; + ctx.updateCursor(); + } + return; + } + const target = targetAt(ctx, input.screenPoint); + const cursor = target ? cursorForTarget(session.transform, target) : 'default'; + if (cursor !== hoverCursor) { + hoverCursor = cursor; + ctx.updateCursor(); + } + }, + onPointerUp: () => { + // The session's live transform already reflects the drag; keep the session. + endGesture(); + }, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/viewTool.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/viewTool.ts new file mode 100644 index 00000000000..515e39a2720 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/viewTool.ts @@ -0,0 +1,59 @@ +/** + * The view tool: pan the canvas by dragging, zoom with the wheel. It is the + * default tool and the one the engine temporarily swaps in while space is held. + * All navigation flows through the viewport, so it never mutates the document + * and never dispatches. + * + * Each engine builds its own instance via {@link createViewTool} so the private + * drag state is per-engine (never module-global). Zero React, zero import-time + * side effects. + */ + +import type { PointerInput, PointerModifiers, Vec2 } from '@workbench/canvas-engine/types'; + +import type { Tool, ToolContext } from './tool'; + +/** Bit for the primary (usually left) mouse button in `PointerEvent.buttons`. */ +const PRIMARY_BUTTON = 1; + +/** Creates a fresh view tool with its own drag state. */ +export const createViewTool = (): Tool => { + let panning = false; + let lastScreen: Vec2 | null = null; + + const begin = (input: PointerInput): void => { + // Only the primary button pans; secondary/middle are handled by the engine. + if ((input.buttons & PRIMARY_BUTTON) === 0) { + return; + } + panning = true; + lastScreen = input.screenPoint; + }; + + const move = (ctx: ToolContext, input: PointerInput): void => { + if (!panning || !lastScreen) { + return; + } + ctx.viewport.panBy({ x: input.screenPoint.x - lastScreen.x, y: input.screenPoint.y - lastScreen.y }); + lastScreen = input.screenPoint; + ctx.invalidate({ view: true }); + }; + + const end = (): void => { + panning = false; + lastScreen = null; + }; + + return { + cursor: () => (panning ? 'grabbing' : 'grab'), + id: 'view', + onDeactivate: end, + onPointerDown: (_ctx, input) => begin(input), + onPointerMove: move, + onPointerUp: end, + onWheel: (ctx: ToolContext, deltaY: number, screenAnchor: Vec2, _modifiers: PointerModifiers) => { + ctx.viewport.wheelZoom(deltaY, screenAnchor); + ctx.invalidate({ view: true }); + }, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/transform/transformMath.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/transform/transformMath.test.ts new file mode 100644 index 00000000000..4a4951d355b --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/transform/transformMath.test.ts @@ -0,0 +1,449 @@ +import { applyToPoint } from '@workbench/canvas-engine/math/mat2d'; +import { describe, expect, it } from 'vitest'; + +import type { LayerTransform, TransformRect } from './transformMath'; + +import { + applyMove, + applyRotate, + applyScale, + bakeMatrix, + IDENTITY_TRANSFORM, + layerTransformMatrix, + localHandlePoint, + MIN_ABS_SCALE, + resizeCursorForHandle, + ROTATE_SNAP_RAD, + snapRotation, + TRANSFORM_ROTATE_NUB_PX, + transformOverlayGeometry, + transformTargetAt, +} from './transformMath'; + +/** The screen-space tip of the drawn rotation nub for `transform` at `zoom`. */ +const nubTip = (transform: LayerTransform, sz: TransformRect, zoom: number): { x: number; y: number } => { + const geo = transformOverlayGeometry(transform, sz); + const toScreen = (p: { x: number; y: number }) => ({ x: zoom * p.x, y: zoom * p.y }); + const a = toScreen(geo.rotationAnchor); + const c = toScreen(geo.center); + const dx = a.x - c.x; + const dy = a.y - c.y; + const len = Math.hypot(dx, dy) || 1; + return { x: a.x + (dx / len) * TRANSFORM_ROTATE_NUB_PX, y: a.y + (dy / len) * TRANSFORM_ROTATE_NUB_PX }; +}; + +const identity: LayerTransform = { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }; +const rect: TransformRect = { height: 100, width: 100, x: 0, y: 0 }; + +/** A zoom-only screen projection (no rotation/flip), matching the real viewport. */ +const scaleToScreen = + (zoom: number, panX = 0, panY = 0) => + (p: { x: number; y: number }) => ({ x: zoom * p.x + panX, y: zoom * p.y + panY }); + +const identityScreen = scaleToScreen(1); + +const close = (a: number, b: number, eps = 1e-6): boolean => Math.abs(a - b) <= eps; + +describe('localHandlePoint', () => { + it('places corners and edge midpoints on the native rect', () => { + expect(localHandlePoint(rect, 'nw')).toEqual({ x: 0, y: 0 }); + expect(localHandlePoint(rect, 'se')).toEqual({ x: 100, y: 100 }); + expect(localHandlePoint(rect, 'e')).toEqual({ x: 100, y: 50 }); + expect(localHandlePoint(rect, 'n')).toEqual({ x: 50, y: 0 }); + }); + + it('honors an off-origin content rect (content-sized paint layers)', () => { + // A paint layer whose bitmap sits at offset (10, 20): every handle shifts + // by the origin — the frame must wrap the pixels, not [0,w]×[0,h]. + const offOrigin: TransformRect = { height: 40, width: 60, x: 10, y: 20 }; + expect(localHandlePoint(offOrigin, 'nw')).toEqual({ x: 10, y: 20 }); + expect(localHandlePoint(offOrigin, 'se')).toEqual({ x: 70, y: 60 }); + expect(localHandlePoint(offOrigin, 'e')).toEqual({ x: 70, y: 40 }); + }); +}); + +describe('off-origin content rect (content-sized layers)', () => { + const offOrigin: TransformRect = { height: 40, width: 60, x: 10, y: 20 }; + + it('transformOverlayGeometry wraps the off-origin rect at identity', () => { + const geo = transformOverlayGeometry(identity, offOrigin); + expect(geo.corners).toEqual([ + { x: 10, y: 20 }, + { x: 70, y: 20 }, + { x: 70, y: 60 }, + { x: 10, y: 60 }, + ]); + expect(geo.center).toEqual({ x: 40, y: 40 }); + }); + + it('transformTargetAt hit-tests the shifted frame (interior + corner)', () => { + const inside = transformTargetAt({ + point: { x: 40, y: 40 }, + rect: offOrigin, + toScreen: identityScreen, + transform: identity, + }); + expect(inside).toEqual({ kind: 'move' }); + const corner = transformTargetAt({ + point: { x: 70, y: 60 }, + rect: offOrigin, + toScreen: identityScreen, + transform: identity, + }); + expect(corner).toEqual({ handle: 'se', kind: 'scale' }); + // A point far from the shifted frame (outside its interior, handles, and + // rotate zones) is NOT a target, even though an (incorrect) origin-anchored + // frame would sit in that direction. + const stale = transformTargetAt({ + point: { x: -40, y: -40 }, + rect: offOrigin, + toScreen: identityScreen, + transform: identity, + }); + expect(stale).toBeNull(); + }); + + it('applyScale keeps the opposite (off-origin) anchor fixed in document space', () => { + // Drag the se handle from (70,60) to (130,100): 2× in both axes, anchored at nw. + const next = applyScale({ + alt: false, + handle: 'se', + pointerDoc: { x: 130, y: 100 }, + rect: offOrigin, + shift: false, + start: identity, + startPointerDoc: { x: 70, y: 60 }, + }); + expect(next.scaleX).toBeCloseTo(2, 6); + expect(next.scaleY).toBeCloseTo(2, 6); + // The nw corner (local 10,20) must not move: T = anchorDoc − S·anchorLocal. + const anchorAfter = applyToPoint(layerTransformMatrix(next), { x: 10, y: 20 }); + expect(anchorAfter.x).toBeCloseTo(10, 6); + expect(anchorAfter.y).toBeCloseTo(20, 6); + }); + + it('applyRotate pivots about the off-origin rect center', () => { + const next = applyRotate({ + pointerDoc: { x: 40, y: 100 }, + rect: offOrigin, + shift: false, + start: identity, + // Start due east of the center (40,40); end due south → +90°. + startPointerDoc: { x: 100, y: 40 }, + }); + expect(next.rotation).toBeCloseTo(Math.PI / 2, 6); + const centerAfter = applyToPoint(layerTransformMatrix(next), { x: 40, y: 40 }); + expect(centerAfter.x).toBeCloseTo(40, 6); + expect(centerAfter.y).toBeCloseTo(40, 6); + }); +}); + +describe('transformTargetAt', () => { + it('hits a corner scale handle at zoom 1', () => { + const target = transformTargetAt({ + point: { x: 100, y: 100 }, + rect, + toScreen: identityScreen, + transform: identity, + }); + expect(target).toEqual({ handle: 'se', kind: 'scale' }); + }); + + it('reports the interior as move', () => { + const target = transformTargetAt({ point: { x: 50, y: 50 }, rect, toScreen: identityScreen, transform: identity }); + expect(target).toEqual({ kind: 'move' }); + }); + + it('reports a rotate zone just outside a corner', () => { + const target = transformTargetAt({ + point: { x: 112, y: 112 }, + rect, + toScreen: identityScreen, + transform: identity, + }); + expect(target).toEqual({ handle: 'se', kind: 'rotate' }); + }); + + it('hits the rotation nub above the top edge (drawn but previously not hit-testable)', () => { + // The nub tip sits `TRANSFORM_ROTATE_NUB_PX` above the top-edge midpoint — + // outside the frame polygon and away from every corner, so before the fix it + // fell through to `null` (misread by the tool as an off-frame press → reset). + const target = transformTargetAt({ + point: nubTip(identity, rect, 1), + rect, + toScreen: identityScreen, + transform: identity, + }); + expect(target).toEqual({ handle: 'n', kind: 'rotate' }); + }); + + it('hits the rotation nub of a rotated + scaled layer at a non-1 zoom', () => { + const transform: LayerTransform = { rotation: 0.4, scaleX: 1.6, scaleY: 1.3, x: 30, y: 20 }; + const target = transformTargetAt({ + point: nubTip(transform, rect, 2), + rect, + toScreen: scaleToScreen(2), + transform, + }); + expect(target).toEqual({ handle: 'n', kind: 'rotate' }); + }); + + it('keeps the interior a move below the nub anchor (nub never steals an interior click)', () => { + // Inside the top edge, clear of the `'n'` scale handle: the polygon wins so a + // near-top interior click stays a move, never a rotate. + const target = transformTargetAt({ point: { x: 50, y: 20 }, rect, toScreen: identityScreen, transform: identity }); + expect(target).toEqual({ kind: 'move' }); + }); + + it('returns null far outside the frame', () => { + const target = transformTargetAt({ + point: { x: 400, y: 400 }, + rect, + toScreen: identityScreen, + transform: identity, + }); + expect(target).toBeNull(); + }); + + it('keeps a constant screen-space grab area under zoom (handle at scaled corner)', () => { + // At zoom 2 the se corner projects to (200,200); a point 3px away still hits. + const target = transformTargetAt({ + point: { x: 202, y: 199 }, + rect, + toScreen: scaleToScreen(2), + transform: identity, + }); + expect(target).toEqual({ handle: 'se', kind: 'scale' }); + }); + + it('hit-tests handles through the layer rotation', () => { + // Rotate 90° about origin: local se (100,100) → doc. Handle tracks the rotated corner. + const rotated: LayerTransform = { rotation: Math.PI / 2, scaleX: 1, scaleY: 1, x: 0, y: 0 }; + const seDoc = applyToPoint(layerTransformMatrix(rotated), localHandlePoint(rect, 'se')); + const target = transformTargetAt({ point: seDoc, rect, toScreen: identityScreen, transform: rotated }); + expect(target).toEqual({ handle: 'se', kind: 'scale' }); + }); +}); + +describe('resizeCursorForHandle', () => { + it('gives axis cursors for an unrotated layer', () => { + expect(resizeCursorForHandle(identity, 'e')).toBe('ew-resize'); + expect(resizeCursorForHandle(identity, 'w')).toBe('ew-resize'); + expect(resizeCursorForHandle(identity, 'n')).toBe('ns-resize'); + expect(resizeCursorForHandle(identity, 's')).toBe('ns-resize'); + }); + + it('gives diagonal cursors for corners', () => { + // se outward normal (1,1) → screen y-down diagonal → nwse. + expect(resizeCursorForHandle(identity, 'se')).toBe('nwse-resize'); + expect(resizeCursorForHandle(identity, 'ne')).toBe('nesw-resize'); + }); + + it('rotates the cursor with the layer (45° turns an edge into a diagonal)', () => { + const rotated: LayerTransform = { rotation: Math.PI / 4, scaleX: 1, scaleY: 1, x: 0, y: 0 }; + // The n edge normal (0,-1) rotated 45° becomes a diagonal → nesw. + expect(resizeCursorForHandle(rotated, 'n')).toBe('nesw-resize'); + // A 90° rotation swaps the axes: the e handle now reads vertical. + const quarter: LayerTransform = { rotation: Math.PI / 2, scaleX: 1, scaleY: 1, x: 0, y: 0 }; + expect(resizeCursorForHandle(quarter, 'e')).toBe('ns-resize'); + }); +}); + +describe('applyScale: about the opposite handle', () => { + it('scales a corner about the opposite corner, tracking the pointer', () => { + const next = applyScale({ + alt: false, + handle: 'se', + pointerDoc: { x: 150, y: 100 }, + shift: false, + rect, + start: identity, + startPointerDoc: { x: 100, y: 100 }, + }); + // nw anchor fixed at (0,0); width doubled in x → scaleX 1.5, scaleY unchanged. + expect(close(next.scaleX, 1.5)).toBe(true); + expect(close(next.scaleY, 1)).toBe(true); + expect(close(next.x, 0)).toBe(true); + expect(close(next.y, 0)).toBe(true); + }); + + it('scales an edge handle on one axis only', () => { + const next = applyScale({ + alt: false, + handle: 'e', + pointerDoc: { x: 150, y: 999 }, + shift: false, + rect, + start: identity, + startPointerDoc: { x: 100, y: 50 }, + }); + expect(close(next.scaleX, 1.5)).toBe(true); + expect(close(next.scaleY, 1)).toBe(true); + }); + + it('keeps the opposite corner fixed in document space', () => { + const start: LayerTransform = { rotation: 0, scaleX: 1, scaleY: 1, x: 10, y: 20 }; + const anchorBefore = applyToPoint(layerTransformMatrix(start), localHandlePoint(rect, 'nw')); + const next = applyScale({ + alt: false, + handle: 'se', + pointerDoc: { x: 200, y: 200 }, + shift: false, + rect, + start, + startPointerDoc: { x: 110, y: 120 }, + }); + const anchorAfter = applyToPoint(layerTransformMatrix(next), localHandlePoint(rect, 'nw')); + expect(close(anchorAfter.x, anchorBefore.x)).toBe(true); + expect(close(anchorAfter.y, anchorBefore.y)).toBe(true); + }); +}); + +describe('applyScale: modifiers', () => { + it('alt scales about the center (center stays fixed)', () => { + const centerBefore = applyToPoint(layerTransformMatrix(identity), { x: 50, y: 50 }); + const next = applyScale({ + alt: true, + handle: 'se', + pointerDoc: { x: 150, y: 150 }, + shift: false, + rect, + start: identity, + startPointerDoc: { x: 100, y: 100 }, + }); + const centerAfter = applyToPoint(layerTransformMatrix(next), { x: 50, y: 50 }); + expect(close(centerAfter.x, centerBefore.x)).toBe(true); + expect(close(centerAfter.y, centerBefore.y)).toBe(true); + }); + + it('shift keeps a uniform aspect on a corner drag', () => { + const next = applyScale({ + alt: false, + handle: 'se', + // Pointer moves mostly in x; uniform factor follows the larger axis. + pointerDoc: { x: 200, y: 110 }, + shift: true, + rect, + start: identity, + startPointerDoc: { x: 100, y: 100 }, + }); + expect(close(next.scaleX, next.scaleY)).toBe(true); + expect(next.scaleX).toBeGreaterThan(1); + }); +}); + +describe('applyScale: flip and clamp', () => { + it('allows a flip through the anchor (negative scale, no NaN)', () => { + const next = applyScale({ + alt: false, + handle: 'e', + // Drag the east edge past the west anchor → negative scaleX. + pointerDoc: { x: -50, y: 50 }, + shift: false, + rect, + start: identity, + startPointerDoc: { x: 100, y: 50 }, + }); + expect(next.scaleX).toBeLessThan(0); + expect(Number.isNaN(next.scaleX)).toBe(false); + }); + + it('clamps a collapsed axis to the minimum magnitude', () => { + const next = applyScale({ + alt: false, + handle: 'e', + // Drag the east edge exactly onto the west anchor → zero width. + pointerDoc: { x: 0, y: 50 }, + shift: false, + rect, + start: identity, + startPointerDoc: { x: 100, y: 50 }, + }); + expect(Math.abs(next.scaleX)).toBeGreaterThanOrEqual(MIN_ABS_SCALE - 1e-9); + expect(Number.isFinite(next.x)).toBe(true); + }); +}); + +describe('applyRotate', () => { + it('rotates about the center by the swept angle', () => { + const next = applyRotate({ + pointerDoc: { x: 50, y: 100 }, // 90° CCW around center from the start ray + shift: false, + rect, + start: identity, + startPointerDoc: { x: 100, y: 50 }, + }); + // start ray points +x; end ray points +y (down) → +90° in canvas space. + expect(close(next.rotation, Math.PI / 2)).toBe(true); + // Center stays fixed. + const center = applyToPoint(layerTransformMatrix(next), { x: 50, y: 50 }); + expect(close(center.x, 50)).toBe(true); + expect(close(center.y, 50)).toBe(true); + }); + + it('snaps to 15° increments under shift', () => { + const next = applyRotate({ + pointerDoc: { x: 100, y: 62 }, // a small angle just above 0 + shift: true, + rect, + start: identity, + startPointerDoc: { x: 100, y: 50 }, + }); + const remainder = Math.abs(next.rotation % ROTATE_SNAP_RAD); + expect(remainder < 1e-9 || Math.abs(remainder - ROTATE_SNAP_RAD) < 1e-9).toBe(true); + }); +}); + +describe('snapRotation', () => { + it('rounds to the nearest 15°', () => { + expect(close(snapRotation(ROTATE_SNAP_RAD * 0.4), 0)).toBe(true); + expect(close(snapRotation(ROTATE_SNAP_RAD * 0.6), ROTATE_SNAP_RAD)).toBe(true); + }); +}); + +describe('applyMove', () => { + it('translates by the document delta', () => { + expect(applyMove(identity, { x: 5, y: -3 }, false)).toMatchObject({ x: 5, y: -3, rotation: 0, scaleX: 1 }); + }); + it('constrains to the dominant axis under shift', () => { + expect(applyMove(identity, { x: 8, y: 2 }, true)).toMatchObject({ x: 8, y: 0 }); + expect(applyMove(identity, { x: 1, y: -9 }, true)).toMatchObject({ x: 0, y: -9 }); + }); +}); + +describe('bakeMatrix', () => { + it('equals the layer matrix so bake-then-draw ≡ transform-then-draw', () => { + const transform: LayerTransform = { rotation: Math.PI / 6, scaleX: 1.5, scaleY: 0.8, x: 12, y: 34 }; + const bake = bakeMatrix(transform); + const direct = layerTransformMatrix(transform); + // For any local point, drawing the baked surface at identity reproduces the + // transformed point exactly. + for (const q of [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + { x: 40, y: 70 }, + ]) { + const viaBake = applyToPoint(bake, q); // baked surface then identity draw + const viaTransform = applyToPoint(direct, q); + expect(close(viaBake.x, viaTransform.x)).toBe(true); + expect(close(viaBake.y, viaTransform.y)).toBe(true); + } + }); +}); + +describe('transformOverlayGeometry', () => { + it('returns rotated corners, eight handles, center, and the top anchor', () => { + const geo = transformOverlayGeometry(identity, rect); + expect(geo.corners).toHaveLength(4); + expect(geo.handles).toHaveLength(8); + expect(geo.center).toEqual({ x: 50, y: 50 }); + expect(geo.rotationAnchor).toEqual({ x: 50, y: 0 }); + }); +}); + +describe('IDENTITY_TRANSFORM', () => { + it('is a true identity', () => { + expect(IDENTITY_TRANSFORM).toEqual({ rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/transform/transformMath.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/transform/transformMath.ts new file mode 100644 index 00000000000..86a18d21aab --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/transform/transformMath.ts @@ -0,0 +1,429 @@ +/** + * Pure transform math for the transform tool: handle geometry, screen-space + * hit-testing over a rotated/scaled layer, per-handle rotation-aware cursors, and + * the scale-about-anchor / rotate / move transform derivations. + * + * A layer's raster cache holds unscaled pixels in the layer's LOCAL space; the + * compositor draws it through `fromTRS(transform)` (translate·rotate·scale). The + * transform tool edits that {@link LayerTransform}. All handle positions are + * computed in LOCAL space, mapped to document space via the layer matrix, then + * projected to SCREEN space by the caller-supplied `toScreen` so grab areas stay + * a constant pixel size regardless of zoom (mirrors `bboxHitTest.ts`, generalized + * to a rotated frame). Every derivation returns a fresh transform from a captured + * `start` transform plus a pointer delta — negative scale (a flip past the anchor) + * is allowed and kept correct; `|scale|` is clamped to {@link MIN_ABS_SCALE} so a + * collapsed axis never produces a singular/NaN matrix. + * + * Zero React, zero DOM, zero import-time side effects. + */ + +import type { Mat2d, Vec2 } from '@workbench/canvas-engine/types'; +import type { CanvasLayerBaseContract } from '@workbench/types'; + +import { applyToPoint, fromTRS } from '@workbench/canvas-engine/math/mat2d'; + +/** The editable layer transform (translate + per-axis scale + rotation in radians). */ +export type LayerTransform = CanvasLayerBaseContract['transform']; + +/** + * The layer's content rectangle in its LOCAL (untransformed) space — the extent + * being transformed. Content-sized layers can sit off-origin (a paint layer's + * persisted offset, negative for strokes up/left of the origin), so the frame, + * handles, and scale/rotate anchors are all derived from this rect, never from + * an assumed `[0,w]×[0,h]`. + */ +export interface TransformRect { + x: number; + y: number; + width: number; + height: number; +} + +/** The eight scale handles: four corners + four edge midpoints. */ +export type TransformHandle = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w'; + +/** + * What a pointer landed on over the transform frame: a scale handle, a corner + * rotate zone (just outside a corner), or the interior (`'move'`). + */ +export type TransformTarget = + | { kind: 'scale'; handle: TransformHandle } + | { kind: 'rotate'; handle: TransformHandle } + | { kind: 'move' }; + +/** Handles in hit-test priority order: corners first (they win over adjacent edges). */ +export const TRANSFORM_HANDLES: readonly TransformHandle[] = ['nw', 'ne', 'se', 'sw', 'n', 'e', 's', 'w']; + +/** The four corner handles (also the rotate-zone anchors). */ +export const CORNER_HANDLES: readonly TransformHandle[] = ['nw', 'ne', 'se', 'sw']; + +/** Side length (screen px) of a scale handle's square grab area. */ +export const TRANSFORM_HANDLE_HIT_PX = 14; + +/** Radius (screen px) of a corner's rotate zone, measured from the corner outward. */ +export const TRANSFORM_ROTATE_ZONE_PX = 22; + +/** + * Screen-px length of the rotation-indicator nub the overlay draws past the top + * edge midpoint (the `'n'` handle), outward away from the center. Shared with the + * overlay renderer so the drawn nub and its hit region stay in lock-step. + */ +export const TRANSFORM_ROTATE_NUB_PX = 18; + +/** Screen-px grab radius around the rotation nub/knob (the capsule half-width). */ +export const TRANSFORM_ROTATE_NUB_HIT_PX = 11; + +/** Rotation snap increment under shift: 15°. */ +export const ROTATE_SNAP_RAD = Math.PI / 12; + +/** Smallest permitted |scaleX|/|scaleY| — keeps the layer matrix invertible. */ +export const MIN_ABS_SCALE = 0.01; + +/** The corner/edge opposite a handle (the fixed anchor for a non-alt scale). */ +const OPPOSITE: Record = { + e: 'w', + n: 's', + ne: 'sw', + nw: 'se', + s: 'n', + se: 'nw', + sw: 'ne', + w: 'e', +}; + +const hasWest = (h: TransformHandle): boolean => h === 'nw' || h === 'w' || h === 'sw'; +const hasEast = (h: TransformHandle): boolean => h === 'ne' || h === 'e' || h === 'se'; +const hasNorth = (h: TransformHandle): boolean => h === 'nw' || h === 'n' || h === 'ne'; +const hasSouth = (h: TransformHandle): boolean => h === 'sw' || h === 's' || h === 'se'; + +/** True for the four corner handles (two-letter ids). */ +export const isCornerHandle = (h: TransformHandle): boolean => h.length === 2; + +/** A handle's position on the layer's local content rect (off-origin aware). */ +export const localHandlePoint = (rect: TransformRect, handle: TransformHandle): Vec2 => ({ + x: rect.x + (hasWest(handle) ? 0 : hasEast(handle) ? rect.width : rect.width / 2), + y: rect.y + (hasNorth(handle) ? 0 : hasSouth(handle) ? rect.height : rect.height / 2), +}); + +/** The local content rect's center (the rotation pivot / alt-scale anchor). */ +const localCenter = (rect: TransformRect): Vec2 => ({ + x: rect.x + rect.width / 2, + y: rect.y + rect.height / 2, +}); + +/** The layer's local→document affine matrix from its transform. */ +export const layerTransformMatrix = (t: LayerTransform): Mat2d => + fromTRS({ x: t.x, y: t.y }, t.rotation, t.scaleX, t.scaleY); + +/** The rotation matrix for `rad` (linear part only). */ +const rotationMat = (rad: number): { a: number; b: number; c: number; d: number } => { + const cos = Math.cos(rad); + const sin = Math.sin(rad); + return { a: cos, b: sin, c: -sin, d: cos }; +}; + +/** Applies a rotation matrix's linear part to a vector. */ +const applyLinear = (m: { a: number; b: number; c: number; d: number }, v: Vec2): Vec2 => ({ + x: m.a * v.x + m.c * v.y, + y: m.b * v.x + m.d * v.y, +}); + +/** A handle's outward normal in LOCAL space (y-down, matching document/screen). */ +const localOutward = (handle: TransformHandle): Vec2 => ({ + x: hasWest(handle) ? -1 : hasEast(handle) ? 1 : 0, + y: hasNorth(handle) ? -1 : hasSouth(handle) ? 1 : 0, +}); + +/** + * Geometry the overlay needs to draw the transform frame, in DOCUMENT space + * (the overlay projects each point through the view). `handles` is ordered to + * match {@link TRANSFORM_HANDLES}. + */ +export interface TransformOverlayGeometry { + /** The four rotated-rect corners (nw, ne, se, sw) for the bounds polygon. */ + corners: Vec2[]; + /** The eight scale-handle positions (order = {@link TRANSFORM_HANDLES}). */ + handles: Vec2[]; + /** The layer center (rotation pivot). */ + center: Vec2; + /** The top edge midpoint (root of the rotation indicator nub). */ + rotationAnchor: Vec2; +} + +/** Document-space geometry for the transform overlay of a layer at `transform`. */ +export const transformOverlayGeometry = (transform: LayerTransform, rect: TransformRect): TransformOverlayGeometry => { + const m = layerTransformMatrix(transform); + return { + center: applyToPoint(m, localCenter(rect)), + corners: (['nw', 'ne', 'se', 'sw'] as const).map((h) => applyToPoint(m, localHandlePoint(rect, h))), + handles: TRANSFORM_HANDLES.map((h) => applyToPoint(m, localHandlePoint(rect, h))), + rotationAnchor: applyToPoint(m, localHandlePoint(rect, 'n')), + }; +}; + +/** True when `pt` is inside the convex polygon `poly` (consistent-winding cross test). */ +const pointInConvexPolygon = (poly: readonly Vec2[], pt: Vec2): boolean => { + let pos = false; + let neg = false; + for (let i = 0; i < poly.length; i++) { + const a = poly[i]!; + const b = poly[(i + 1) % poly.length]!; + const cross = (b.x - a.x) * (pt.y - a.y) - (b.y - a.y) * (pt.x - a.x); + if (cross > 1e-9) { + pos = true; + } else if (cross < -1e-9) { + neg = true; + } + if (pos && neg) { + return false; + } + } + return true; +}; + +/** Distance (screen px) from `p` to the segment `a`→`b`. */ +const distanceToSegment = (p: Vec2, a: Vec2, b: Vec2): number => { + const abx = b.x - a.x; + const aby = b.y - a.y; + const len2 = abx * abx + aby * aby; + const t = len2 > 0 ? Math.max(0, Math.min(1, ((p.x - a.x) * abx + (p.y - a.y) * aby) / len2)) : 0; + return Math.hypot(p.x - (a.x + t * abx), p.y - (a.y + t * aby)); +}; + +/** Parameters for {@link transformTargetAt}. */ +export interface TransformHitTestParams { + transform: LayerTransform; + rect: TransformRect; + /** Projects a document-space point to screen space (CSS px). */ + toScreen: (docPoint: Vec2) => Vec2; + /** The screen-space pointer position (CSS px). */ + point: Vec2; + /** Scale-handle grab-area side length (screen px). */ + handleHitPx?: number; + /** Corner rotate-zone radius (screen px). */ + rotateZonePx?: number; + /** Rotation-nub grab radius (screen px). */ + rotateNubHitPx?: number; +} + +/** + * What a screen-space `point` targets on the transform frame: a scale handle + * (grab area first, corners before edges), a corner rotate zone (outside the + * frame, near a corner), the interior (`'move'`), or `null`. + */ +export const transformTargetAt = (params: TransformHitTestParams): TransformTarget | null => { + const { point, rect, toScreen, transform } = params; + const half = (params.handleHitPx ?? TRANSFORM_HANDLE_HIT_PX) / 2; + const m = layerTransformMatrix(transform); + const screenOf = (handle: TransformHandle): Vec2 => toScreen(applyToPoint(m, localHandlePoint(rect, handle))); + + for (const handle of TRANSFORM_HANDLES) { + const s = screenOf(handle); + if (Math.abs(point.x - s.x) <= half && Math.abs(point.y - s.y) <= half) { + return { handle, kind: 'scale' }; + } + } + + const screenCorners = CORNER_HANDLES.map(screenOf); + if (pointInConvexPolygon(screenCorners, point)) { + return { kind: 'move' }; + } + + // Rotation nub: a capsule from the top-edge (`'n'`) midpoint outward (away + // from center) by `TRANSFORM_ROTATE_NUB_PX`, matching the drawn nub. Tested + // after the interior polygon (so an interior click stays a move) and before + // the corner rotate zones (disjoint regions — a top-middle nub vs corners). + // Without this, a click on the visible nub falls through to `null`, which the + // tool misreads as an off-frame press — re-opening the session (resetting its + // live transform) instead of starting a rotation. + const anchorScreen = screenOf('n'); + const centerScreen = toScreen(applyToPoint(m, { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 })); + const outX = anchorScreen.x - centerScreen.x; + const outY = anchorScreen.y - centerScreen.y; + const outLen = Math.hypot(outX, outY) || 1; + const nubTip: Vec2 = { + x: anchorScreen.x + (outX / outLen) * TRANSFORM_ROTATE_NUB_PX, + y: anchorScreen.y + (outY / outLen) * TRANSFORM_ROTATE_NUB_PX, + }; + if (distanceToSegment(point, anchorScreen, nubTip) <= (params.rotateNubHitPx ?? TRANSFORM_ROTATE_NUB_HIT_PX)) { + return { handle: 'n', kind: 'rotate' }; + } + + const zone = params.rotateZonePx ?? TRANSFORM_ROTATE_ZONE_PX; + for (let i = 0; i < CORNER_HANDLES.length; i++) { + const s = screenCorners[i]!; + if (Math.hypot(point.x - s.x, point.y - s.y) <= zone) { + return { handle: CORNER_HANDLES[i]!, kind: 'rotate' }; + } + } + return null; +}; + +/** The four resize cursors, indexed by the quantized 45° bucket of the outward normal. */ +const RESIZE_CURSORS = ['ew-resize', 'nesw-resize', 'ns-resize', 'nwse-resize'] as const; + +/** + * The resize cursor for a scale handle, accounting for the layer's rotation: + * the handle's outward normal is rotated/scaled by the layer matrix, then the + * resulting screen-space angle is quantized to one of the four resize + * orientations. (The view adds only a uniform scale — no rotation/flip — so the + * document-space angle equals the screen-space angle.) + */ +export const resizeCursorForHandle = (transform: LayerTransform, handle: TransformHandle): string => { + const m = layerTransformMatrix(transform); + const dir = applyLinear(m, localOutward(handle)); + // Screen space is y-down; negate y to get a conventional (y-up) math angle. + let deg = (Math.atan2(-dir.y, dir.x) * 180) / Math.PI; + deg = ((deg % 360) + 360) % 360; + deg %= 180; // opposite directions share a resize cursor + const bucket = Math.round(deg / 45) % 4; + return RESIZE_CURSORS[bucket]!; +}; + +/** Clamps |s| to at least {@link MIN_ABS_SCALE}, preserving sign (0 → +MIN). */ +const clampScale = (s: number): number => { + if (!Number.isFinite(s) || s === 0) { + return MIN_ABS_SCALE; + } + const sign = s < 0 ? -1 : 1; + return Math.abs(s) < MIN_ABS_SCALE ? sign * MIN_ABS_SCALE : s; +}; + +/** Applies shift axis-constraint to a document-space delta (dominant axis wins). */ +export const constrainMoveDelta = (delta: Vec2, shift: boolean): Vec2 => { + if (!shift) { + return delta; + } + return Math.abs(delta.x) >= Math.abs(delta.y) ? { x: delta.x, y: 0 } : { x: 0, y: delta.y }; +}; + +/** Translates `start` by a document-space pointer delta (shift = axis constrain). */ +export const applyMove = (start: LayerTransform, deltaDoc: Vec2, shift: boolean): LayerTransform => { + const d = constrainMoveDelta(deltaDoc, shift); + return { ...start, x: start.x + d.x, y: start.y + d.y }; +}; + +/** Parameters for {@link applyScale}. */ +export interface ScaleParams { + start: LayerTransform; + rect: TransformRect; + handle: TransformHandle; + /** Pointer position (document space) at gesture start. */ + startPointerDoc: Vec2; + /** Current pointer position (document space). */ + pointerDoc: Vec2; + /** Preserve aspect ratio (corner handles only). */ + shift: boolean; + /** Scale about the layer center instead of the opposite handle. */ + alt: boolean; +} + +/** + * Scales `start` by dragging `handle`, anchored at the opposite handle (or the + * center under `alt`). The grabbed handle tracks the pointer; edge handles scale + * one axis, corners scale both (`shift` forces a uniform, aspect-preserving + * factor). Dragging past the anchor flips the axis (negative scale), which is + * kept correct; each axis is clamped to {@link MIN_ABS_SCALE}. + */ +export const applyScale = (p: ScaleParams): LayerTransform => { + const { alt, handle, rect, shift, start } = p; + const m = layerTransformMatrix(start); + const handleLocal = localHandlePoint(rect, handle); + const anchorLocal = alt ? localCenter(rect) : localHandlePoint(rect, OPPOSITE[handle]); + const anchorDoc = applyToPoint(m, anchorLocal); + const handleDoc = applyToPoint(m, handleLocal); + const targetDoc: Vec2 = { + x: handleDoc.x + (p.pointerDoc.x - p.startPointerDoc.x), + y: handleDoc.y + (p.pointerDoc.y - p.startPointerDoc.y), + }; + + // Un-rotate the anchor→target vector into the layer's scaled-local frame. + const cos = Math.cos(start.rotation); + const sin = Math.sin(start.rotation); + const dx = targetDoc.x - anchorDoc.x; + const dy = targetDoc.y - anchorDoc.y; + const vx = cos * dx + sin * dy; + const vy = -sin * dx + cos * dy; + + const dlx = handleLocal.x - anchorLocal.x; + const dly = handleLocal.y - anchorLocal.y; + let sx = dlx !== 0 ? vx / dlx : start.scaleX; + let sy = dly !== 0 ? vy / dly : start.scaleY; + + if (shift && isCornerHandle(handle)) { + const fx = start.scaleX !== 0 ? sx / start.scaleX : 1; + const fy = start.scaleY !== 0 ? sy / start.scaleY : 1; + const f = Math.abs(fx) >= Math.abs(fy) ? fx : fy; + sx = start.scaleX * f; + sy = start.scaleY * f; + } + + sx = clampScale(sx); + sy = clampScale(sy); + + // Keep the anchor fixed: T = anchorDoc − R·(sx·anchorLocal.x, sy·anchorLocal.y). + const rs = applyLinear(rotationMat(start.rotation), { x: sx * anchorLocal.x, y: sy * anchorLocal.y }); + return { + rotation: start.rotation, + scaleX: sx, + scaleY: sy, + x: anchorDoc.x - rs.x, + y: anchorDoc.y - rs.y, + }; +}; + +/** Snaps a radian angle to the nearest {@link ROTATE_SNAP_RAD} increment. */ +export const snapRotation = (rad: number): number => Math.round(rad / ROTATE_SNAP_RAD) * ROTATE_SNAP_RAD; + +/** Parameters for {@link applyRotate}. */ +export interface RotateParams { + start: LayerTransform; + rect: TransformRect; + startPointerDoc: Vec2; + pointerDoc: Vec2; + /** Snap the resulting absolute rotation to 15° increments. */ + shift: boolean; +} + +/** + * Rotates `start` about the layer center by the angle the pointer sweeps around + * that center since gesture start. `shift` snaps the resulting absolute rotation + * to 15° increments. Scale is unchanged. + */ +export const applyRotate = (p: RotateParams): LayerTransform => { + const { rect, start } = p; + const m = layerTransformMatrix(start); + const centerLocal = localCenter(rect); + const center = applyToPoint(m, centerLocal); + const a0 = Math.atan2(p.startPointerDoc.y - center.y, p.startPointerDoc.x - center.x); + const a1 = Math.atan2(p.pointerDoc.y - center.y, p.pointerDoc.x - center.x); + let rotation = start.rotation + (a1 - a0); + if (p.shift) { + rotation = snapRotation(rotation); + } + // Keep the center fixed: T = center − R(rotation)·(sx·cx, sy·cy). + const rs = applyLinear(rotationMat(rotation), { + x: start.scaleX * centerLocal.x, + y: start.scaleY * centerLocal.y, + }); + return { + rotation, + scaleX: start.scaleX, + scaleY: start.scaleY, + x: center.x - rs.x, + y: center.y - rs.y, + }; +}; + +/** + * The matrix that renders a layer's local (cache) pixels into a NEW document-space + * surface so that the result, drawn at identity, reproduces the layer as if drawn + * through `transform` — i.e. `fromTRS(transform)`. Used by the paint-layer bake: + * old pixels are drawn through this matrix, the layer transform is then reset to + * identity, and the composite is unchanged. Kept as a named helper so the bake + * site reads intentionally and composes with `math/mat2d`. + */ +export const bakeMatrix = (transform: LayerTransform): Mat2d => layerTransformMatrix(transform); + +/** The identity transform (a reset layer transform after a bake). */ +export const IDENTITY_TRANSFORM: LayerTransform = { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/types.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/types.ts new file mode 100644 index 00000000000..a7678c5342c --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/types.ts @@ -0,0 +1,118 @@ +/** + * Core types for the canvas engine. + * + * This module is the shared vocabulary for the imperative canvas engine + * (`src/workbench/canvas-engine/`). It has zero React imports and zero + * side effects at import time — it is pure type/interface declarations (the + * single type-only import below is erased at compile time). + */ + +import type { RasterSurface } from './render/raster'; + +/** A 2D point or vector. */ +export interface Vec2 { + x: number; + y: number; +} + +/** An axis-aligned rectangle. */ +export interface Rect { + x: number; + y: number; + width: number; + height: number; +} + +/** + * A raster surface placed in some coordinate space: the surface holds pixels for + * `rect` (its bounds in that space), so surface pixel `(sx, sy)` maps to + * `(rect.x + sx, rect.y + sy)`. Used for content-sized layer caches (layer-local + * space) and the bounded selection mask (document space). + */ +export interface PlacedSurface { + surface: RasterSurface; + rect: Rect; +} + +/** + * A 2D affine transform matrix, using the canvas transform convention: + * + * ``` + * x' = a*x + c*y + e + * y' = b*x + d*y + f + * ``` + * + * This matches `CanvasRenderingContext2D.setTransform(a, b, c, d, e, f)` and + * the DOMMatrix 2D constructor argument order. + */ +export interface Mat2d { + a: number; + b: number; + c: number; + d: number; + e: number; + f: number; +} + +/** Identifiers for the tools the engine's interaction layer can dispatch to. */ +export type ToolId = + | 'brush' + | 'eraser' + | 'shape' + | 'gradient' + | 'lasso' + | 'move' + | 'transform' + | 'bbox' + | 'view' + | 'colorPicker' + | 'text' + | 'sam'; + +/** + * A pixel-selection boolean operation applied when a new lasso path commits + * against the existing selection mask (see `selection/selectionState.ts`): + * `replace` swaps it, `add` unions, `subtract` cuts out, `intersect` keeps the + * overlap. + */ +export type SelectionOp = 'replace' | 'add' | 'subtract' | 'intersect'; + +/** Modifier key state accompanying a pointer sample. */ +export interface PointerModifiers { + shift: boolean; + alt: boolean; + ctrl: boolean; + meta: boolean; +} + +/** A normalized pointer sample fed into the engine's interaction layer. */ +export interface PointerInput { + /** Pointer position in document space (unaffected by pan/zoom). */ + documentPoint: Vec2; + /** Pointer position in screen/viewport space (CSS pixels). */ + screenPoint: Vec2; + /** Pressure in [0, 1]. 0.5 is used for devices that don't report pressure. */ + pressure: number; + /** Bitmask of currently-pressed buttons, matching `PointerEvent.buttons`. */ + buttons: number; + modifiers: PointerModifiers; + pointerType: 'mouse' | 'pen' | 'touch'; + /** High-resolution timestamp (ms), matching `PointerEvent.timeStamp`. */ + timeStamp: number; +} + +/** + * Describes what needs to be re-rendered on the next frame. Consumers + * (scheduler/compositor, added in later tasks) union flags together rather + * than always doing a full repaint. + */ +export interface RenderFlags { + /** The viewport transform (pan/zoom) changed. */ + view: boolean; + /** Ids of layers whose pixel content or transform changed. */ + layers: Set; + /** Interaction overlays (selection, cursors, guides) changed. */ + overlay: boolean; + /** Force a full repaint, ignoring the other flags. */ + all: boolean; +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/viewport.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/viewport.test.ts new file mode 100644 index 00000000000..6558f61a7ff --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/viewport.test.ts @@ -0,0 +1,114 @@ +import type { Vec2 } from '@workbench/canvas-engine/types'; + +import { applyToPoint } from '@workbench/canvas-engine/math/mat2d'; +import { ZOOM_SNAP_CANDIDATES } from '@workbench/canvas-engine/math/snapping'; +import { describe, expect, it, vi } from 'vitest'; + +import { createViewport } from './viewport'; + +const closeTo = (a: Vec2, b: Vec2, eps = 1e-6): void => { + expect(a.x).toBeCloseTo(b.x, 6); + expect(a.y).toBeCloseTo(b.y, 6); + void eps; +}; + +describe('createViewport', () => { + it('round-trips screen↔document with pan and zoom', () => { + const vp = createViewport({ pan: { x: 30, y: -12 }, zoom: 1.5 }); + const doc: Vec2 = { x: 42, y: 17 }; + const screen = vp.documentToScreen(doc); + // screen = zoom*doc + pan + closeTo(screen, { x: 1.5 * 42 + 30, y: 1.5 * 17 - 12 }); + closeTo(vp.screenToDocument(screen), doc); + }); + + it('viewMatrix folds dpr into scale and translation (device = dpr * screen)', () => { + const vp = createViewport({ pan: { x: 10, y: 5 }, zoom: 2 }); + const dpr = 2; + const m = vp.viewMatrix(dpr); + const doc: Vec2 = { x: 7, y: 9 }; + const device = applyToPoint(m, doc); + const screen = vp.documentToScreen(doc); + closeTo(device, { x: screen.x * dpr, y: screen.y * dpr }); + }); + + it('zoomAtPoint keeps the anchored document point fixed on screen', () => { + const vp = createViewport({ pan: { x: 0, y: 0 }, zoom: 1 }); + const anchor: Vec2 = { x: 200, y: 120 }; + const docUnderAnchorBefore = vp.screenToDocument(anchor); + vp.zoomAtPoint(3, anchor); + expect(vp.getZoom()).toBe(3); + // The document point under the anchor must project back to the same screen anchor. + closeTo(vp.documentToScreen(docUnderAnchorBefore), anchor); + }); + + it('wheelZoom snaps to a candidate when the exponential step lands near one', () => { + const vp = createViewport({ pan: { x: 0, y: 0 }, zoom: 1 }); + const anchor: Vec2 = { x: 0, y: 0 }; + // A tiny negative deltaY nudges zoom just above 1; it should snap back to 1. + vp.wheelZoom(-1, anchor); + expect(vp.getZoom()).toBe(1); + expect(ZOOM_SNAP_CANDIDATES).toContain(vp.getZoom()); + }); + + it('wheelZoom zooms out on positive deltaY and in on negative deltaY', () => { + const vpOut = createViewport({ zoom: 1 }); + vpOut.wheelZoom(400, { x: 0, y: 0 }); + expect(vpOut.getZoom()).toBeLessThan(1); + + const vpIn = createViewport({ zoom: 1 }); + vpIn.wheelZoom(-400, { x: 0, y: 0 }); + expect(vpIn.getZoom()).toBeGreaterThan(1); + }); + + it('fitToView centers the document and applies padding-limited zoom', () => { + const vp = createViewport(); + const documentRect = { height: 100, width: 200, x: 0, y: 0 }; + const viewportSize = { height: 400, width: 400 }; + const padding = 50; + vp.fitToView(documentRect, viewportSize, padding); + + // avail = 400 - 2*50 = 300; zoom = min(300/200, 300/100) = 1.5 + expect(vp.getZoom()).toBeCloseTo(1.5, 6); + // Document center maps to viewport center. + const docCenter: Vec2 = { x: 100, y: 50 }; + closeTo(vp.documentToScreen(docCenter), { x: 200, y: 200 }); + }); + + it('fitToView is a no-op for a degenerate viewport', () => { + const vp = createViewport({ zoom: 2 }); + vp.fitToView({ height: 100, width: 100, x: 0, y: 0 }, { height: 0, width: 0 }); + expect(vp.getZoom()).toBe(2); + }); + + it('panBy translates in screen space', () => { + const vp = createViewport({ pan: { x: 5, y: 5 }, zoom: 2 }); + vp.panBy({ x: 10, y: -4 }); + expect(vp.getState().pan).toEqual({ x: 15, y: 1 }); + }); + + it('setViewportSize clamps dpr to the max and notifies on change', () => { + const vp = createViewport(); + const listener = vi.fn(); + vp.subscribe(listener); + vp.setViewportSize(800, 600, 3); + expect(vp.getDpr()).toBe(2); + expect(vp.getViewportSize()).toEqual({ height: 600, width: 800 }); + expect(listener).toHaveBeenCalledTimes(1); + // Identical call does not re-notify. + vp.setViewportSize(800, 600, 3); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('notifies subscribers on zoom/pan and stops after unsubscribe', () => { + const vp = createViewport(); + const listener = vi.fn(); + const unsubscribe = vp.subscribe(listener); + vp.panBy({ x: 1, y: 0 }); + vp.zoomAtPoint(2, { x: 0, y: 0 }); + expect(listener).toHaveBeenCalledTimes(2); + unsubscribe(); + vp.panBy({ x: 1, y: 0 }); + expect(listener).toHaveBeenCalledTimes(2); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/viewport.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/viewport.ts new file mode 100644 index 00000000000..1715e8b9aa6 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/viewport.ts @@ -0,0 +1,185 @@ +/** + * The pan/zoom viewport: the single source of truth for how document space + * maps to the on-screen canvas. It owns `{ pan, zoom }`, the CSS viewport + * size, and the device-pixel ratio, and derives the document→screen + * transform used by both the renderer (device pixels) and pointer routing + * (CSS pixels). + * + * ## Coordinate convention + * + * - **Document space**: the canvas document's own pixel grid, unaffected by + * pan/zoom (a layer at document `(0,0)` is the top-left of the document). + * - **Screen/CSS space**: CSS pixels relative to the canvas element's + * top-left. This is what `PointerEvent.clientX/Y` (minus the element rect) + * gives you. `pan` is stored in CSS pixels. + * - **Device space**: the canvas backing store, `CSS × dpr`. The renderer + * draws here. + * + * The mappings are: + * ``` + * screen = zoom * doc + pan (CSS pixels) + * device = dpr * screen = (zoom*dpr) * doc + dpr*pan + * ``` + * so `viewMatrix(dpr)` is `scale(zoom*dpr)` followed by a `dpr*pan` + * translation — i.e. `a = d = zoom*dpr`, `e = dpr*pan.x`, `f = dpr*pan.y`. + * + * Zero React, zero import-time side effects; DOM-free (operates on plain + * numbers), so it runs unchanged in node tests. + */ + +import type { Mat2d, Rect, Vec2 } from '@workbench/canvas-engine/types'; + +import { applyToPoint, invert } from '@workbench/canvas-engine/math/mat2d'; +import { clampZoom, snapZoom } from '@workbench/canvas-engine/math/snapping'; + +/** Maximum device-pixel ratio the engine honors; higher DPRs are clamped to bound backing-store size. */ +export const MAX_DPR = 2; + +/** Wheel exponential zoom sensitivity: `zoom *= exp(-deltaY * step)`. */ +export const WHEEL_ZOOM_STEP = 0.0015; + +/** The mutable view state. */ +export interface ViewState { + pan: Vec2; + zoom: number; +} + +/** A `{ width, height }` size in CSS pixels. */ +export interface Size { + width: number; + height: number; +} + +/** The imperative viewport handle. */ +export interface Viewport { + /** Current `{ pan, zoom }` (a fresh copy — never mutate the return value). */ + getState(): ViewState; + /** Current zoom factor. */ + getZoom(): number; + /** Current CSS viewport size. */ + getViewportSize(): Size; + /** Current (clamped) device-pixel ratio. */ + getDpr(): number; + /** The document→device-pixel transform for the given `dpr`. */ + viewMatrix(dpr: number): Mat2d; + /** Maps a CSS-pixel screen point to document space. */ + screenToDocument(p: Vec2): Vec2; + /** Maps a document point to a CSS-pixel screen point. */ + documentToScreen(p: Vec2): Vec2; + /** Sets zoom while keeping the document point under `screenAnchor` fixed. */ + zoomAtPoint(newZoom: number, screenAnchor: Vec2): void; + /** Exponential wheel zoom about `screenAnchor`, snapping near common zoom levels. */ + wheelZoom(deltaY: number, screenAnchor: Vec2): void; + /** Pans by a CSS-pixel screen delta. */ + panBy(screenDelta: Vec2): void; + /** Centers and zooms to fit `documentRect` within `viewportSize` (minus `padding`). */ + fitToView(documentRect: Rect, viewportSize: Size, padding?: number): void; + /** Records the CSS viewport size and device-pixel ratio (dpr clamped to {@link MAX_DPR}). */ + setViewportSize(width: number, height: number, dpr: number): void; + /** Subscribes to any view change. Returns an unsubscribe function. */ + subscribe(listener: () => void): () => void; +} + +/** Default fit-to-view padding in CSS pixels. */ +const DEFAULT_FIT_PADDING = 48; + +/** Creates a viewport, optionally seeded with an initial view state / size. */ +export const createViewport = (initial?: Partial): Viewport => { + let pan: Vec2 = { x: initial?.pan?.x ?? 0, y: initial?.pan?.y ?? 0 }; + let zoom = clampZoom(initial?.zoom ?? 1); + let size: Size = { height: 0, width: 0 }; + let dpr = 1; + + const listeners = new Set<() => void>(); + + const emit = (): void => { + for (const listener of listeners) { + listener(); + } + }; + + const cssMatrix = (): Mat2d => ({ a: zoom, b: 0, c: 0, d: zoom, e: pan.x, f: pan.y }); + + const documentToScreen = (p: Vec2): Vec2 => ({ x: zoom * p.x + pan.x, y: zoom * p.y + pan.y }); + + const screenToDocument = (p: Vec2): Vec2 => { + const inverse = invert(cssMatrix()); + if (!inverse) { + return { x: 0, y: 0 }; + } + return applyToPoint(inverse, p); + }; + + const zoomAtPoint = (newZoom: number, screenAnchor: Vec2): void => { + const clamped = clampZoom(newZoom); + // Keep the document point currently under the anchor fixed on screen. + const docAnchor = screenToDocument(screenAnchor); + zoom = clamped; + pan = { x: screenAnchor.x - clamped * docAnchor.x, y: screenAnchor.y - clamped * docAnchor.y }; + emit(); + }; + + const wheelZoom = (deltaY: number, screenAnchor: Vec2): void => { + const target = clampZoom(zoom * Math.exp(-deltaY * WHEEL_ZOOM_STEP)); + zoomAtPoint(snapZoom(target), screenAnchor); + }; + + const panBy = (screenDelta: Vec2): void => { + pan = { x: pan.x + screenDelta.x, y: pan.y + screenDelta.y }; + emit(); + }; + + const fitToView = (documentRect: Rect, viewportSize: Size, padding: number = DEFAULT_FIT_PADDING): void => { + const availWidth = viewportSize.width - padding * 2; + const availHeight = viewportSize.height - padding * 2; + if (documentRect.width <= 0 || documentRect.height <= 0 || availWidth <= 0 || availHeight <= 0) { + return; + } + const fitZoom = clampZoom(Math.min(availWidth / documentRect.width, availHeight / documentRect.height)); + const docCenter: Vec2 = { + x: documentRect.x + documentRect.width / 2, + y: documentRect.y + documentRect.height / 2, + }; + zoom = fitZoom; + pan = { x: viewportSize.width / 2 - fitZoom * docCenter.x, y: viewportSize.height / 2 - fitZoom * docCenter.y }; + emit(); + }; + + const setViewportSize = (width: number, height: number, nextDpr: number): void => { + const clampedDpr = Math.max(1, Math.min(MAX_DPR, nextDpr)); + if (size.width === width && size.height === height && dpr === clampedDpr) { + return; + } + size = { height, width }; + dpr = clampedDpr; + emit(); + }; + + return { + documentToScreen, + fitToView, + getDpr: () => dpr, + getState: () => ({ pan: { x: pan.x, y: pan.y }, zoom }), + getViewportSize: () => ({ height: size.height, width: size.width }), + getZoom: () => zoom, + panBy, + screenToDocument, + setViewportSize, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + viewMatrix: (matrixDpr) => ({ + a: zoom * matrixDpr, + b: 0, + c: 0, + d: zoom * matrixDpr, + e: pan.x * matrixDpr, + f: pan.y * matrixDpr, + }), + wheelZoom, + zoomAtPoint, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvasDimsSync.test.ts b/invokeai/frontend/webv2/src/workbench/canvasDimsSync.test.ts new file mode 100644 index 00000000000..d57fc5d8087 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvasDimsSync.test.ts @@ -0,0 +1,374 @@ +import { describe, expect, it } from 'vitest'; + +import type { MainModelConfig } from './generation/types'; +import type { WorkbenchState } from './types'; +import type { WorkbenchAction } from './workbenchState'; + +import { type CanvasDimsSnapshot, createCanvasDimsSync, reconcileCanvasDims } from './canvasDimsSync'; +import { getProjectWidgetValues } from './widgetState'; +import { createWorkbenchStore } from './workbenchStore'; + +const bbox = (width: number, height: number, x = 0, y = 0) => ({ height, width, x, y }); + +const snapshot = (bboxW: number, bboxH: number, dimsW: number, dimsH: number): CanvasDimsSnapshot => ({ + bboxHeight: bboxH, + bboxWidth: bboxW, + dimsHeight: dimsH, + dimsWidth: dimsW, +}); + +describe('reconcileCanvasDims (pure)', () => { + it('no-ops when there is no canvas frame (not in canvas mode)', () => { + expect(reconcileCanvasDims({ bbox: null, dims: { height: 512, width: 512 }, grid: 8, prev: null })).toEqual({ + kind: 'none', + }); + }); + + it('no-ops when the bbox and dims already agree', () => { + expect( + reconcileCanvasDims({ + bbox: bbox(1024, 1024), + dims: { height: 1024, width: 1024 }, + grid: 8, + prev: snapshot(1024, 1024, 1024, 1024), + }) + ).toEqual({ kind: 'none' }); + }); + + it('maps a bbox size change onto the generate dims and re-derives the aspect id', () => { + const result = reconcileCanvasDims({ + bbox: bbox(512, 768), + dims: { height: 1024, width: 1024 }, + grid: 8, + prev: snapshot(1024, 1024, 1024, 1024), + }); + + expect(result).toEqual({ + aspectRatioId: '2:3', + aspectRatioValue: 512 / 768, + height: 768, + kind: 'patch-dims', + width: 512, + }); + }); + + it('emits the bbox ratio as aspectRatioValue for a non-square bbox (not just the preset id)', () => { + const result = reconcileCanvasDims({ + bbox: bbox(1024, 768), + dims: { height: 1024, width: 1024 }, + grid: 8, + prev: snapshot(1024, 1024, 1024, 1024), + }); + + // 4:3 as an id, but the numeric ratio must agree so downstream constraint + // math (GenerateDimensionFields' getActiveRatio) uses the fresh ratio + // instead of a stale 1.0 left over from the previous (square) dims. + expect(result).toMatchObject({ aspectRatioId: '4:3', aspectRatioValue: 1024 / 768, kind: 'patch-dims' }); + }); + + it('re-derives a preset aspect id for a matching bbox ratio', () => { + const result = reconcileCanvasDims({ + bbox: bbox(1024, 768), + dims: { height: 1024, width: 1024 }, + grid: 8, + prev: snapshot(1024, 1024, 1024, 1024), + }); + + expect(result).toMatchObject({ aspectRatioId: '4:3', kind: 'patch-dims' }); + }); + + it('re-derives Free for a non-preset bbox ratio', () => { + const result = reconcileCanvasDims({ + bbox: bbox(1000, 512), + dims: { height: 1024, width: 1024 }, + grid: 8, + prev: snapshot(1024, 1024, 1024, 1024), + }); + + expect(result).toMatchObject({ aspectRatioId: 'Free', kind: 'patch-dims' }); + }); + + it('ignores a position-only bbox move (same width/height)', () => { + const result = reconcileCanvasDims({ + bbox: bbox(1024, 1024, 128, 64), + dims: { height: 1024, width: 1024 }, + grid: 8, + prev: snapshot(1024, 1024, 1024, 1024), + }); + + expect(result).toEqual({ kind: 'none' }); + }); + + it('resizes the bbox to changed dims, keeping the top-left position', () => { + const result = reconcileCanvasDims({ + bbox: bbox(512, 768, 40, 24), + dims: { height: 768, width: 768 }, + grid: 8, + prev: snapshot(512, 768, 512, 768), + }); + + expect(result).toEqual({ bbox: { height: 768, width: 768, x: 40, y: 24 }, kind: 'set-bbox' }); + }); + + it('snaps dims to the grid when resizing the bbox', () => { + const result = reconcileCanvasDims({ + bbox: bbox(512, 512), + dims: { height: 100, width: 100 }, + grid: 16, + prev: snapshot(512, 512, 512, 512), + }); + + expect(result).toEqual({ bbox: { height: 96, width: 96, x: 0, y: 0 }, kind: 'set-bbox' }); + }); + + it('clamps a sub-grid dimension up to the grid minimum', () => { + const result = reconcileCanvasDims({ + bbox: bbox(512, 512), + dims: { height: 1, width: 1 }, + grid: 16, + prev: snapshot(512, 512, 512, 512), + }); + + expect(result).toEqual({ bbox: { height: 16, width: 16, x: 0, y: 0 }, kind: 'set-bbox' }); + }); + + it('no-ops the dims->bbox direction when the snapped size already matches the bbox', () => { + const result = reconcileCanvasDims({ + bbox: bbox(96, 96), + dims: { height: 100, width: 100 }, + grid: 16, + prev: snapshot(96, 96, 96, 96), + }); + + expect(result).toEqual({ kind: 'none' }); + }); + + it('lets the bbox win on first run (no prior snapshot)', () => { + const result = reconcileCanvasDims({ + bbox: bbox(512, 512), + dims: { height: 256, width: 256 }, + grid: 8, + prev: null, + }); + + expect(result).toEqual({ aspectRatioId: '1:1', aspectRatioValue: 1, height: 512, kind: 'patch-dims', width: 512 }); + }); + + it('converges: applying each direction twice is a fixed point', () => { + // bbox -> dims, then re-reconcile the resulting agreed state. + const first = reconcileCanvasDims({ + bbox: bbox(640, 512), + dims: { height: 1024, width: 1024 }, + grid: 8, + prev: snapshot(1024, 1024, 1024, 1024), + }); + expect(first.kind).toBe('patch-dims'); + const afterPatch = reconcileCanvasDims({ + bbox: bbox(640, 512), + dims: { height: 512, width: 640 }, + grid: 8, + prev: snapshot(640, 512, 640, 512), + }); + expect(afterPatch).toEqual({ kind: 'none' }); + + // dims -> bbox, then re-reconcile the resulting agreed state. + const second = reconcileCanvasDims({ + bbox: bbox(512, 512, 10, 20), + dims: { height: 512, width: 768 }, + grid: 8, + prev: snapshot(512, 512, 512, 512), + }); + expect(second.kind).toBe('set-bbox'); + const afterResize = reconcileCanvasDims({ + bbox: bbox(768, 512, 10, 20), + dims: { height: 512, width: 768 }, + grid: 8, + prev: snapshot(768, 512, 768, 512), + }); + expect(afterResize).toEqual({ kind: 'none' }); + }); +}); + +const model: MainModelConfig = { base: 'sdxl', key: 'test-model', name: 'Test Model', type: 'main' }; + +const getActiveGenerate = (state: WorkbenchState) => { + const project = state.projects.find((candidate) => candidate.id === state.activeProjectId); + if (!project) { + throw new Error('no active project'); + } + return { bbox: project.canvas.document.bbox, values: getProjectWidgetValues(project, 'generate') }; +}; + +/** A store that counts only the dispatches the sync itself issues. */ +const setupCanvasStore = () => { + const store = createWorkbenchStore(); + + // Put the active project into canvas mode with concrete generate dims/model. + store.dispatch({ type: 'setInvocationSource', sourceId: 'canvas' }); + store.dispatch({ + type: 'patchGenerateSettings', + values: { height: 1024, model, modelKey: model.key, width: 1024 }, + }); + + let syncDispatches = 0; + const countingStore = { + dispatch: (action: WorkbenchAction) => { + syncDispatches += 1; + store.dispatch(action); + }, + getState: store.getState, + subscribe: store.subscribe, + }; + + const sync = createCanvasDimsSync(countingStore); + + return { getSyncDispatches: () => syncDispatches, store, sync }; +}; + +describe('createCanvasDimsSync (wiring)', () => { + it('does not dispatch on mount when bbox and dims already agree', () => { + const { getSyncDispatches, sync } = setupCanvasStore(); + + expect(getSyncDispatches()).toBe(0); + sync.dispose(); + }); + + it('patches the generate dims when the bbox changes, without looping', () => { + const { getSyncDispatches, store, sync } = setupCanvasStore(); + + store.dispatch({ bbox: { height: 768, width: 512, x: 0, y: 0 }, type: 'setCanvasBbox' }); + + const { values } = getActiveGenerate(store.getState()); + expect(values.width).toBe(512); + expect(values.height).toBe(768); + expect(values.aspectRatioId).toBe('2:3'); + // The numeric ratio must be re-derived alongside the id, or downstream + // constraint math (GenerateDimensionFields' getActiveRatio, which prefers + // aspectRatioValue whenever it is > 0) keeps constraining edits to the + // stale ratio from before the bbox drag. + expect(values.aspectRatioValue).toBeCloseTo(512 / 768); + // Exactly one sync dispatch (the echo is a no-op, so no unbounded loop). + expect(getSyncDispatches()).toBe(1); + sync.dispose(); + }); + + it('a bbox-driven ratio is what a subsequent constrained width edit uses (not the pre-drag ratio)', () => { + const { store, sync } = setupCanvasStore(); + + // Drag the bbox to a non-square 4:3 frame; dims should follow. + store.dispatch({ bbox: { height: 768, width: 1024, x: 0, y: 0 }, type: 'setCanvasBbox' }); + const { values } = getActiveGenerate(store.getState()); + expect(values.aspectRatioId).toBe('4:3'); + expect(values.aspectRatioValue).toBeCloseTo(1024 / 768); + + const aspectRatioValue = values.aspectRatioValue as number; + const width = values.width as number; + const height = values.height as number; + + // Mirror GenerateDimensionFields' getActiveRatio: prefer aspectRatioValue + // when positive, else fall back to the live width/height. + const activeRatio = aspectRatioValue > 0 ? aspectRatioValue : width / height; + const nextWidth = 800; + const constrainedHeight = nextWidth / activeRatio; + + // Before the fix this would divide by the stale ratio (1.0, from the + // original 1024x1024 dims) and yield the wrong height (800 instead of 600). + expect(constrainedHeight).toBeCloseTo(600); + sync.dispose(); + }); + + it('resizes the bbox when the generate dims change, without looping', () => { + const { getSyncDispatches, store, sync } = setupCanvasStore(); + + // Move the frame first so the resize must preserve the top-left position. + store.dispatch({ bbox: { height: 1024, width: 1024, x: 32, y: 48 }, type: 'setCanvasBbox' }); + const before = getSyncDispatches(); + + store.dispatch({ type: 'patchGenerateSettings', values: { width: 512 } }); + + const { bbox: nextBbox } = getActiveGenerate(store.getState()); + expect(nextBbox).toEqual({ height: 1024, width: 512, x: 32, y: 48 }); + expect(getSyncDispatches() - before).toBe(1); + sync.dispose(); + }); + + it('driving the dims off-grid still keeps the dispatch count bounded (no snap/reconcile loop)', () => { + const { getSyncDispatches, store, sync } = setupCanvasStore(); + + // Move the frame first so the resize must preserve the top-left position. + store.dispatch({ bbox: { height: 1024, width: 1024, x: 32, y: 48 }, type: 'setCanvasBbox' }); + const before = getSyncDispatches(); + + // 501 is not a multiple of the sdxl grid (8): dims -> bbox must snap it, + // and the bbox -> dims echo from that snapped bbox must not re-trigger + // another round of reconciliation (the snapped bbox and the still-501-wide + // dims disagree by construction, but the sync's re-entrancy guard must + // still hold the total dispatch count for this one external change to 1). + store.dispatch({ type: 'patchGenerateSettings', values: { width: 501 } }); + + const { bbox: nextBbox } = getActiveGenerate(store.getState()); + expect(nextBbox.width).toBe(504); // snapped up to the nearest multiple of 8 + expect(getSyncDispatches() - before).toBe(1); + sync.dispose(); + }); + + it('ignores a position-only bbox move', () => { + const { getSyncDispatches, store, sync } = setupCanvasStore(); + + store.dispatch({ bbox: { height: 1024, width: 1024, x: 100, y: 200 }, type: 'setCanvasBbox' }); + + const { values } = getActiveGenerate(store.getState()); + expect(values.width).toBe(1024); + expect(values.height).toBe(1024); + expect(getSyncDispatches()).toBe(0); + sync.dispose(); + }); + + it('stays inert when the project is not invoking into the canvas', () => { + const store = createWorkbenchStore(); + store.dispatch({ type: 'setInvocationSource', sourceId: 'generate' }); + store.dispatch({ + type: 'patchGenerateSettings', + values: { height: 1024, model, modelKey: model.key, width: 1024 }, + }); + + let syncDispatches = 0; + const countingStore = { + dispatch: (action: WorkbenchAction) => { + syncDispatches += 1; + store.dispatch(action); + }, + getState: store.getState, + subscribe: store.subscribe, + }; + const sync = createCanvasDimsSync(countingStore); + + store.dispatch({ bbox: { height: 512, width: 512, x: 0, y: 0 }, type: 'setCanvasBbox' }); + + const { values } = getActiveGenerate(store.getState()); + // Generate dims untouched: non-canvas behavior is exactly as today. + expect(values.width).toBe(1024); + expect(values.height).toBe(1024); + expect(syncDispatches).toBe(0); + sync.dispose(); + }); + + it('aligns dims to the bbox when a project enters canvas mode', () => { + const store = createWorkbenchStore(); + // Diverge the frame from the dims while still in generate mode. + store.dispatch({ + type: 'patchGenerateSettings', + values: { height: 1024, model, modelKey: model.key, width: 1024 }, + }); + store.dispatch({ bbox: { height: 640, width: 896, x: 0, y: 0 }, type: 'setCanvasBbox' }); + + const sync = createCanvasDimsSync(store); + // Switching into canvas mode should let the bbox win and drive the dims. + store.dispatch({ type: 'setInvocationSource', sourceId: 'canvas' }); + + const { values } = getActiveGenerate(store.getState()); + expect(values.width).toBe(896); + expect(values.height).toBe(640); + sync.dispose(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvasDimsSync.ts b/invokeai/frontend/webv2/src/workbench/canvasDimsSync.ts new file mode 100644 index 00000000000..e19725d67ea --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvasDimsSync.ts @@ -0,0 +1,257 @@ +/** + * Two-way sync between a project's canvas generation frame (`canvas.document.bbox`) + * and its generate-widget dimensions (`width` / `height` / `aspectRatioId`). + * + * Legacy parity: while a project is invoking into the canvas, the generation + * frame IS the generation size. Resizing the bbox (tool gesture, BboxOptions, + * undo/redo) drives the generate width/height; editing the generate dimensions + * (or picking an aspect preset) resizes the bbox in place (top-left anchored). + * Position-only bbox moves never touch the dimensions. + * + * The module is two parts: + * - {@link reconcileCanvasDims}: a pure, unit-tested reconcile that decides which + * side (if any) to write, given the current bbox, the current committed + * generate dims, the snapping grid, and the last-synced snapshot. + * - {@link createCanvasDimsSync}: a thin `store.subscribe` wiring that feeds the + * reconcile from workbench state and dispatches the resulting action. + * + * Loop safety: the reconcile short-circuits to `none` whenever the bbox and dims + * already agree, so applying either direction is a fixed point. The wiring also + * updates its last-synced snapshot *before* dispatching and hard-guards against + * re-entrant notifications, keeping the dispatch count per external change + * bounded (at most one echo, which is itself a no-op). + * + * Only active while `project.invocation.sourceId === 'canvas'`; for every other + * source the sync is inert and generate-dimension editing behaves exactly as it + * does today. Zero React. + */ + +import type { AspectRatioId } from './generation/types'; +import type { CanvasDocumentContractV2, WorkbenchState } from './types'; +import type { WorkbenchAction } from './workbenchState'; + +import { deriveAspectRatioId } from './generation/settings'; +import { gridSizeForModelBase } from './widgets/canvas/bboxGrid'; +import { getProjectWidgetValues } from './widgetState'; + +type Bbox = CanvasDocumentContractV2['bbox']; + +/** The last-synced width/height on both sides, used to detect which side changed. */ +export interface CanvasDimsSnapshot { + bboxWidth: number; + bboxHeight: number; + dimsWidth: number; + dimsHeight: number; +} + +export interface CanvasDimsReconcileInput { + /** The current generation frame, or `null` when the sync should stay inert (no canvas mode). */ + bbox: Bbox | null; + /** The current committed generate width/height. */ + dims: { width: number; height: number }; + /** The bbox/generate snapping grid (model-derived; identical on both sides). */ + grid: number; + /** The last snapshot this sync wrote/observed, or `null` on first run / after a reset. */ + prev: CanvasDimsSnapshot | null; +} + +export type CanvasDimsReconcileResult = + | { kind: 'none' } + /** + * Write the bbox size onto the generate dims (bbox wins), re-deriving the + * aspect id *and* the numeric aspect ratio. Both are re-derived from the bbox + * unconditionally (even when the form's ratio is locked to a preset) so the + * id, the numeric ratio, and the dims stay mutually consistent after the + * patch — a locked preset does not veto the bbox, which remains authoritative. + */ + | { kind: 'patch-dims'; width: number; height: number; aspectRatioId: AspectRatioId; aspectRatioValue: number } + /** Resize the bbox to the (grid-snapped) generate dims, keeping its top-left position. */ + | { kind: 'set-bbox'; bbox: Bbox }; + +const snapToGrid = (value: number, grid: number): number => { + const step = grid > 0 ? grid : 1; + return Math.max(step, Math.round(value / step) * step); +}; + +/** + * Decide which direction of the bbox <-> dims sync to apply. + * + * - No bbox (not in canvas mode) -> `none`. + * - Bbox and dims already agree -> `none` (the primary loop guard). + * - Otherwise the side that changed since `prev` wins; the bbox is authoritative + * when both (or neither, on first run) changed, matching "the frame is the + * generation size". Bbox -> dims writes the exact bbox size and re-derives the + * aspect id. Dims -> bbox snaps to the grid and only emits when the snapped + * size actually differs from the live bbox. + */ +export const reconcileCanvasDims = ({ + bbox, + dims, + grid, + prev, +}: CanvasDimsReconcileInput): CanvasDimsReconcileResult => { + if (!bbox) { + return { kind: 'none' }; + } + + if (bbox.width === dims.width && bbox.height === dims.height) { + return { kind: 'none' }; + } + + const bboxChanged = !prev || prev.bboxWidth !== bbox.width || prev.bboxHeight !== bbox.height; + + if (bboxChanged) { + return { + aspectRatioId: deriveAspectRatioId(bbox.width, bbox.height), + aspectRatioValue: bbox.height > 0 ? bbox.width / bbox.height : 1, + height: bbox.height, + kind: 'patch-dims', + width: bbox.width, + }; + } + + const dimsChanged = !prev || prev.dimsWidth !== dims.width || prev.dimsHeight !== dims.height; + + if (dimsChanged) { + const width = snapToGrid(dims.width, grid); + const height = snapToGrid(dims.height, grid); + + if (width === bbox.width && height === bbox.height) { + return { kind: 'none' }; + } + + return { bbox: { height, width, x: bbox.x, y: bbox.y }, kind: 'set-bbox' }; + } + + return { kind: 'none' }; +}; + +/** The minimal workbench store surface the sync depends on. */ +export interface CanvasDimsSyncStore { + getState(): WorkbenchState; + subscribe(listener: () => void): () => void; + dispatch(action: WorkbenchAction): void; +} + +export interface CanvasDimsSync { + dispose(): void; +} + +const readFiniteDimension = (values: Record, key: 'width' | 'height'): number | null => { + const raw = values[key]; + return typeof raw === 'number' && Number.isFinite(raw) && raw > 0 ? raw : null; +}; + +const readModelBase = (values: Record): string | null => { + const model = values.model; + return model && typeof model === 'object' && typeof (model as { base?: unknown }).base === 'string' + ? (model as { base: string }).base + : null; +}; + +/** + * Wire the bbox <-> generate-dims reconcile onto a workbench store. Subscribes + * immediately; dispatches `patchGenerateSettings` / `setCanvasBbox` as the + * reconcile directs. Returns a handle whose `dispose` removes the subscription. + */ +export const createCanvasDimsSync = (store: CanvasDimsSyncStore): CanvasDimsSync => { + let prev: CanvasDimsSnapshot | null = null; + let lastProjectId: string | null = null; + let isSyncing = false; + + const handleChange = (): void => { + // A dispatch below re-enters this listener synchronously; the snapshot is + // already updated to the post-dispatch expectation, so the nested pass would + // be a no-op — skip it to keep the dispatch count strictly bounded. + if (isSyncing) { + return; + } + + const state = store.getState(); + const project = state.projects.find((candidate) => candidate.id === state.activeProjectId); + + if (!project) { + prev = null; + lastProjectId = null; + return; + } + + if (project.id !== lastProjectId) { + lastProjectId = project.id; + prev = null; + } + + // Inert unless the project is invoking into the canvas: for every other + // source the generate dimensions behave exactly as they do today. + if (project.invocation.sourceId !== 'canvas') { + prev = null; + return; + } + + const generateValues = getProjectWidgetValues(project, 'generate'); + const width = readFiniteDimension(generateValues, 'width'); + const height = readFiniteDimension(generateValues, 'height'); + + if (width === null || height === null) { + prev = null; + return; + } + + const bbox = project.canvas.document.bbox; + const grid = gridSizeForModelBase(readModelBase(generateValues)); + const result = reconcileCanvasDims({ bbox, dims: { height, width }, grid, prev }); + + switch (result.kind) { + case 'none': { + prev = { bboxHeight: bbox.height, bboxWidth: bbox.width, dimsHeight: height, dimsWidth: width }; + return; + } + case 'patch-dims': { + prev = { + bboxHeight: bbox.height, + bboxWidth: bbox.width, + dimsHeight: result.height, + dimsWidth: result.width, + }; + isSyncing = true; + try { + store.dispatch({ + projectId: project.id, + type: 'patchGenerateSettings', + values: { + aspectRatioId: result.aspectRatioId, + aspectRatioValue: result.aspectRatioValue, + height: result.height, + width: result.width, + }, + }); + } finally { + isSyncing = false; + } + return; + } + case 'set-bbox': { + prev = { + bboxHeight: result.bbox.height, + bboxWidth: result.bbox.width, + dimsHeight: height, + dimsWidth: width, + }; + isSyncing = true; + try { + store.dispatch({ bbox: result.bbox, type: 'setCanvasBbox' }); + } finally { + isSyncing = false; + } + return; + } + } + }; + + const unsubscribe = store.subscribe(handleChange); + + // Seed from the current state so an already-canvas project reconciles on mount. + handleChange(); + + return { dispose: unsubscribe }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvasLayerOps.test.ts b/invokeai/frontend/webv2/src/workbench/canvasLayerOps.test.ts new file mode 100644 index 00000000000..3f3f69da9ec --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvasLayerOps.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; + +import type { CanvasLayerContract } from './types'; + +import { + deleteLayerActions, + duplicateLayerActions, + reorderIdsForHotkey, + reorderLayerActions, + reorderTargetIndex, +} from './canvasLayerOps'; + +const layer = (id: string): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { bitmap: null, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', +}); + +describe('duplicateLayerActions', () => { + it('duplicates forward and removes the duplicate on undo', () => { + expect(duplicateLayerActions('a', 'a-copy')).toEqual({ + forward: { newId: 'a-copy', sourceId: 'a', type: 'duplicateCanvasLayer' }, + inverse: { ids: ['a-copy'], type: 'removeCanvasLayers' }, + }); + }); +}); + +describe('deleteLayerActions', () => { + it('removes forward and re-adds at the original index on undo', () => { + const l = layer('a'); + expect(deleteLayerActions(l, 2)).toEqual({ + forward: { ids: ['a'], type: 'removeCanvasLayers' }, + inverse: { index: 2, layer: l, type: 'addCanvasLayer' }, + }); + }); +}); + +describe('reorderLayerActions', () => { + it('reorders forward and restores the prior order on undo (copies arrays)', () => { + const current = ['a', 'b', 'c']; + const next = ['b', 'a', 'c']; + const actions = reorderLayerActions(current, next); + expect(actions).toEqual({ + forward: { orderedIds: ['b', 'a', 'c'], type: 'reorderCanvasLayers' }, + inverse: { orderedIds: ['a', 'b', 'c'], type: 'reorderCanvasLayers' }, + }); + // Snapshotted, not aliased. + if (actions.forward.type === 'reorderCanvasLayers') { + expect(actions.forward.orderedIds).not.toBe(next); + } + }); +}); + +describe('reorderTargetIndex (index 0 = top-most)', () => { + it('moves toward/away and clamps at boundaries', () => { + expect(reorderTargetIndex(2, 5, 'forward')).toBe(1); + expect(reorderTargetIndex(0, 5, 'forward')).toBe(0); + expect(reorderTargetIndex(2, 5, 'backward')).toBe(3); + expect(reorderTargetIndex(4, 5, 'backward')).toBe(4); + expect(reorderTargetIndex(3, 5, 'front')).toBe(0); + expect(reorderTargetIndex(1, 5, 'back')).toBe(4); + }); +}); + +describe('reorderIdsForHotkey', () => { + it('moves one step toward the front', () => { + expect(reorderIdsForHotkey(['a', 'b', 'c'], 2, 'forward')).toEqual(['a', 'c', 'b']); + }); + + it('moves one step toward the back', () => { + expect(reorderIdsForHotkey(['a', 'b', 'c'], 0, 'backward')).toEqual(['b', 'a', 'c']); + }); + + it('moves to front and to back', () => { + expect(reorderIdsForHotkey(['a', 'b', 'c'], 2, 'front')).toEqual(['c', 'a', 'b']); + expect(reorderIdsForHotkey(['a', 'b', 'c'], 0, 'back')).toEqual(['b', 'c', 'a']); + }); + + it('returns null when nothing would move (already at the boundary)', () => { + expect(reorderIdsForHotkey(['a', 'b', 'c'], 0, 'forward')).toBeNull(); + expect(reorderIdsForHotkey(['a', 'b', 'c'], 2, 'backward')).toBeNull(); + expect(reorderIdsForHotkey(['a', 'b', 'c'], 0, 'front')).toBeNull(); + expect(reorderIdsForHotkey(['a', 'b', 'c'], 2, 'back')).toBeNull(); + }); + + it('returns null for an out-of-range index', () => { + expect(reorderIdsForHotkey(['a', 'b'], 5, 'forward')).toBeNull(); + expect(reorderIdsForHotkey(['a', 'b'], -1, 'forward')).toBeNull(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvasLayerOps.ts b/invokeai/frontend/webv2/src/workbench/canvasLayerOps.ts new file mode 100644 index 00000000000..225615ba644 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvasLayerOps.ts @@ -0,0 +1,88 @@ +/** + * Pure builders for the structural document edits shared by the layers panel and + * the canvas widget's layer hotkeys: each returns the forward + inverse reducer + * action pair for `engine.commitStructural` / `applyStructural`, so the + * inverse-construction logic lives in exactly one place. + * + * Index convention matches the contract and the layers panel: index 0 is the + * top-most layer, so "up"/"forward" moves toward index 0. + * + * Zero React, zero import-time side effects. + */ + +import type { CanvasLayerContract } from './types'; +import type { WorkbenchAction } from './workbenchState'; + +/** A forward/inverse reducer-action pair for one reversible structural edit. */ +export interface StructuralActions { + forward: WorkbenchAction; + inverse: WorkbenchAction; +} + +/** Mints a fresh layer id (matches the engine's / layers panel's id shape). */ +export const createLayerId = (): string => `layer-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + +/** Duplicate a layer (forward), removing the duplicate on undo (inverse). */ +export const duplicateLayerActions = (sourceId: string, newId: string): StructuralActions => ({ + forward: { newId, sourceId, type: 'duplicateCanvasLayer' }, + inverse: { ids: [newId], type: 'removeCanvasLayers' }, +}); + +/** Delete a layer (forward), re-adding it at its original index on undo (inverse). */ +export const deleteLayerActions = (layer: CanvasLayerContract, index: number): StructuralActions => ({ + forward: { ids: [layer.id], type: 'removeCanvasLayers' }, + inverse: { index, layer, type: 'addCanvasLayer' }, +}); + +/** Reorder to `nextIds` (forward), restoring `currentIds` on undo (inverse). */ +export const reorderLayerActions = (currentIds: readonly string[], nextIds: readonly string[]): StructuralActions => ({ + forward: { orderedIds: [...nextIds], type: 'reorderCanvasLayers' }, + inverse: { orderedIds: [...currentIds], type: 'reorderCanvasLayers' }, +}); + +/** A z-reorder direction for the layer hotkeys. */ +export type LayerReorderKind = 'forward' | 'backward' | 'front' | 'back'; + +/** The destination index for a z-reorder of the layer at `index` in a stack of `count`. */ +export const reorderTargetIndex = (index: number, count: number, kind: LayerReorderKind): number => { + switch (kind) { + case 'forward': + return Math.max(0, index - 1); + case 'backward': + return Math.min(count - 1, index + 1); + case 'front': + return 0; + case 'back': + return count - 1; + } +}; + +/** Moves `items[from]` to `to`, shifting the rest. Returns a new array. */ +const moveItem = (items: readonly T[], from: number, to: number): T[] => { + const next = [...items]; + const [moved] = next.splice(from, 1); + if (moved === undefined) { + return next; + } + next.splice(to, 0, moved); + return next; +}; + +/** + * The reordered id list for a z-reorder hotkey, or `null` when nothing would move + * (already at the boundary, or the index is out of range) so callers can no-op. + */ +export const reorderIdsForHotkey = ( + currentIds: readonly string[], + index: number, + kind: LayerReorderKind +): string[] | null => { + if (index < 0 || index >= currentIds.length) { + return null; + } + const target = reorderTargetIndex(index, currentIds.length, kind); + if (target === index) { + return null; + } + return moveItem(currentIds, index, target); +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvasMigration.test.ts b/invokeai/frontend/webv2/src/workbench/canvasMigration.test.ts new file mode 100644 index 00000000000..2197134c3cf --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvasMigration.test.ts @@ -0,0 +1,425 @@ +import { describe, expect, it } from 'vitest'; + +import { + createEmptyCanvasStateV2, + createNewCanvasStateV2, + DEFAULT_CANVAS_DOCUMENT_HEIGHT, + DEFAULT_CANVAS_DOCUMENT_WIDTH, + migrateCanvasStateToV2, + placementToTransform, +} from './canvasMigration'; +import { createInpaintMaskLayer, nextInpaintMaskName } from './widgets/layers/layerOps'; + +describe('placementToTransform', () => { + it('maps a placement rect to a scale-based transform relative to the source image size', () => { + expect(placementToTransform({ height: 200, width: 400, x: 10, y: 20 }, 200, 100)).toEqual({ + rotation: 0, + scaleX: 2, + scaleY: 2, + x: 10, + y: 20, + }); + }); + + it('falls back to a 1:1 scale when the source image has no dimensions', () => { + expect(placementToTransform({ height: 200, width: 400, x: 0, y: 0 }, 0, 0)).toEqual({ + rotation: 0, + scaleX: 1, + scaleY: 1, + x: 0, + y: 0, + }); + }); +}); + +describe('migrateCanvasStateToV2', () => { + it('maps a v1 raster layer to a v2 raster layer positioned by transform', () => { + const v1Canvas = { + document: { + height: 768, + layers: [ + { + acceptedAt: '2026-06-09T00:00:00.000Z', + height: 512, + id: 'layer-1', + imageName: 'candidate.png', + imageUrl: '/api/v1/images/i/candidate.png/full', + label: 'Layer 1', + placement: { height: 300, opacity: 0.8, width: 600, x: 12, y: 24 }, + queuedAt: '2026-06-09T00:00:00.000Z', + sourceQueueItemId: 'queue-1', + thumbnailUrl: '/api/v1/images/i/candidate.png/thumbnail', + width: 1024, + }, + ], + version: 1, + width: 1024, + }, + stagingArea: { + areThumbnailsVisible: true, + isVisible: false, + pendingImageIds: [], + pendingImages: [], + selectedImageIndex: 0, + }, + version: 1, + }; + + const migrated = migrateCanvasStateToV2(v1Canvas); + + expect(migrated.version).toBe(2); + expect(migrated.document.layers).toHaveLength(1); + + const layer = migrated.document.layers[0]; + + expect(layer).toEqual({ + blendMode: 'normal', + id: 'layer-1', + isEnabled: true, + isLocked: false, + name: 'Layer 1', + opacity: 0.8, + source: { image: { height: 512, imageName: 'candidate.png', width: 1024 }, type: 'image' }, + transform: { rotation: 0, scaleX: 600 / 1024, scaleY: 300 / 512, x: 12, y: 24 }, + type: 'raster', + }); + }); + + it('generates an id and positional name for a layer missing them, and defaults a missing placement', () => { + const migrated = migrateCanvasStateToV2({ + document: { + height: 512, + layers: [{ height: 256, imageName: 'no-id.png', width: 256 }], + width: 512, + }, + version: 1, + }); + + const layer = migrated.document.layers[0]; + + expect(layer?.id).toBeTruthy(); + expect(layer?.name).toBe('Layer 1'); + expect(layer?.type).toBe('raster'); + + if (layer?.type === 'raster' && layer.source.type === 'image') { + expect(layer.source.image).toEqual({ height: 256, imageName: 'no-id.png', width: 256 }); + expect(layer.transform).toEqual({ rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }); + expect(layer.opacity).toBe(1); + } + }); + + it('migrates a bare imageName-string legacy layer', () => { + const migrated = migrateCanvasStateToV2({ + document: { height: 512, layers: ['legacy-image.png'], width: 512 }, + version: 1, + }); + + const layer = migrated.document.layers[0]; + + expect(layer?.name).toBe('legacy-image.png'); + + if (layer?.type === 'raster' && layer.source.type === 'image') { + expect(layer.source.image.imageName).toBe('legacy-image.png'); + } + }); + + it('supports the pre-`document` legacy shape with layers directly on the canvas', () => { + const migrated = migrateCanvasStateToV2({ + layers: ['ancient.png'], + version: 1, + }); + + expect(migrated.document.width).toBe(DEFAULT_CANVAS_DOCUMENT_WIDTH); + expect(migrated.document.height).toBe(DEFAULT_CANVAS_DOCUMENT_HEIGHT); + expect(migrated.document.layers).toHaveLength(1); + }); + + it('defaults bbox to the full document rect', () => { + const migrated = migrateCanvasStateToV2({ document: { height: 600, layers: [], width: 800 }, version: 1 }); + + expect(migrated.document.bbox).toEqual({ height: 600, width: 800, x: 0, y: 0 }); + }); + + it('defaults selectedLayerId to null (v1 had no selection concept)', () => { + const migrated = migrateCanvasStateToV2({ document: { height: 512, layers: [], width: 512 }, version: 1 }); + + expect(migrated.document.selectedLayerId).toBeNull(); + }); + + it('carries the staging area through unchanged and defaults autoSwitchMode to off', () => { + const pendingImages = [ + { + height: 512, + imageName: 'staged.png', + imageUrl: '/api/v1/images/i/staged.png/full', + placement: { height: 512, opacity: 1, width: 512, x: 0, y: 0 }, + queuedAt: '2026-06-09T00:00:00.000Z', + sourceQueueItemId: 'queue-1', + thumbnailUrl: '/api/v1/images/i/staged.png/thumbnail', + width: 512, + }, + ]; + + const migrated = migrateCanvasStateToV2({ + document: { height: 512, layers: [], width: 512 }, + stagingArea: { + areThumbnailsVisible: false, + isVisible: true, + pendingImageIds: ['staged.png'], + pendingImages, + selectedImageIndex: 0, + selectedLayerId: 'layer-x', + sourceQueueItemId: 'queue-1', + }, + version: 1, + }); + + expect(migrated.stagingArea).toEqual({ + areThumbnailsVisible: false, + autoSwitchMode: 'off', + isVisible: true, + pendingImageIds: ['staged.png'], + pendingImages, + selectedImageIndex: 0, + selectedLayerId: 'layer-x', + sourceQueueItemId: 'queue-1', + }); + // The candidate objects themselves are carried through by reference, not reshaped. + expect(migrated.stagingArea.pendingImages[0]).toBe(pendingImages[0]); + }); + + it('starts with no snapshots', () => { + const migrated = migrateCanvasStateToV2({ document: { height: 512, layers: [], width: 512 }, version: 1 }); + + expect(migrated.snapshots).toEqual([]); + }); + + it('passes already-v2 input through normalized rather than re-migrating it', () => { + const v2Canvas = { + document: { + background: 'transparent', + bbox: { height: 512, width: 512, x: 0, y: 0 }, + height: 512, + layers: [ + { + blendMode: 'multiply', + id: 'layer-1', + isEnabled: true, + isLocked: false, + name: 'Layer 1', + opacity: 0.5, + source: { image: { height: 100, imageName: 'v2.png', width: 100 }, type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }, + ], + selectedLayerId: 'layer-1', + version: 2, + width: 512, + }, + documentRevision: 0, + snapshots: [], + stagingArea: { + areThumbnailsVisible: true, + autoSwitchMode: 'latest', + isVisible: false, + pendingImageIds: [], + pendingImages: [], + selectedImageIndex: 0, + }, + version: 2, + }; + + const migrated = migrateCanvasStateToV2(v2Canvas); + + expect(migrated).toEqual(v2Canvas); + // Not double-migrated: blendMode/opacity/selectedLayerId survive untouched, which a v1->v2 + // re-derivation from a (nonexistent) placement would not preserve. + expect(migrated.document.layers[0]).toEqual(v2Canvas.document.layers[0]); + expect(migrated.stagingArea.autoSwitchMode).toBe('latest'); + }); + + it('round-trips content-sized fields (paint offset, gradient width/height) on an already-v2 doc unchanged', () => { + const paintOffset = { x: -30, y: 45 }; + const gradientExtent = { height: 220, width: 180 }; + const v2Canvas = { + document: { + background: 'transparent', + bbox: { height: 512, width: 512, x: 0, y: 0 }, + height: 512, + layers: [ + { + blendMode: 'normal', + id: 'paint-1', + isEnabled: true, + isLocked: false, + name: 'Paint 1', + opacity: 1, + // Content-sized paint bitmap placed off-origin. + source: { bitmap: { height: 40, imageName: 'paint.png', width: 40 }, offset: paintOffset, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }, + { + blendMode: 'normal', + id: 'gradient-1', + isEnabled: true, + isLocked: false, + name: 'Gradient 1', + opacity: 1, + // Gradient carrying an explicit content extent (not document-sized). + source: { + angle: 45, + height: gradientExtent.height, + kind: 'linear', + stops: [ + { color: '#000000', offset: 0 }, + { color: '#ffffff', offset: 1 }, + ], + type: 'gradient', + width: gradientExtent.width, + }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }, + ], + selectedLayerId: 'paint-1', + version: 2, + width: 512, + }, + documentRevision: 3, + snapshots: [], + stagingArea: { + areThumbnailsVisible: true, + autoSwitchMode: 'off', + isVisible: false, + pendingImageIds: [], + pendingImages: [], + selectedImageIndex: 0, + }, + version: 2, + }; + + const migrated = migrateCanvasStateToV2(v2Canvas); + + // Whole-state round-trip: normalization must not drop or rewrite the + // content-sizing fields the current schema carries. + expect(migrated).toEqual(v2Canvas); + const paintLayer = migrated.document.layers[0]; + const gradientLayer = migrated.document.layers[1]; + if (paintLayer?.type !== 'raster' || gradientLayer?.type !== 'raster') { + throw new Error('expected two raster layers to survive normalization'); + } + expect(paintLayer.source).toEqual({ + bitmap: { height: 40, imageName: 'paint.png', width: 40 }, + offset: paintOffset, + type: 'paint', + }); + expect(gradientLayer.source).toMatchObject({ + height: gradientExtent.height, + type: 'gradient', + width: gradientExtent.width, + }); + }); + + it.each([undefined, null, 'garbage', 42, [], {}])( + 'falls back to a fresh empty v2 state for garbage input (%j)', + (garbage) => { + const migrated = migrateCanvasStateToV2(garbage); + + expect(migrated).toEqual(createEmptyCanvasStateV2()); + expect(migrated.document.width).toBe(DEFAULT_CANVAS_DOCUMENT_WIDTH); + expect(migrated.document.height).toBe(DEFAULT_CANVAS_DOCUMENT_HEIGHT); + expect(migrated.document.layers).toEqual([]); + expect(migrated.stagingArea.autoSwitchMode).toBe('off'); + } + ); +}); + +describe('createEmptyCanvasStateV2', () => { + it('creates a well-formed empty v2 canvas at the default document size', () => { + const state = createEmptyCanvasStateV2(); + + expect(state).toEqual({ + document: { + background: 'transparent', + bbox: { height: DEFAULT_CANVAS_DOCUMENT_HEIGHT, width: DEFAULT_CANVAS_DOCUMENT_WIDTH, x: 0, y: 0 }, + height: DEFAULT_CANVAS_DOCUMENT_HEIGHT, + layers: [], + selectedLayerId: null, + version: 2, + width: DEFAULT_CANVAS_DOCUMENT_WIDTH, + }, + documentRevision: 0, + snapshots: [], + stagingArea: { + areThumbnailsVisible: true, + autoSwitchMode: 'off', + isVisible: false, + pendingImageIds: [], + pendingImages: [], + selectedImageIndex: 0, + }, + version: 2, + }); + }); + + it('honors a custom document size', () => { + const state = createEmptyCanvasStateV2(800, 600); + + expect(state.document.width).toBe(800); + expect(state.document.height).toBe(600); + expect(state.document.bbox).toEqual({ height: 600, width: 800, x: 0, y: 0 }); + }); +}); + +describe('createNewCanvasStateV2', () => { + it('seeds exactly one empty inpaint mask, selected, with legacy-default fill', () => { + const state = createNewCanvasStateV2(); + const { layers, selectedLayerId } = state.document; + + expect(layers).toHaveLength(1); + const mask = layers[0]; + expect(mask?.type).toBe('inpaint_mask'); + expect(mask?.name).toBe('Inpaint Mask 1'); + expect(mask?.isEnabled).toBe(true); + expect(mask?.isLocked).toBe(false); + expect(mask?.opacity).toBe(1); + // Empty = no bitmap (no strokes); the diagonal-hatch fill is the legacy default. + expect(mask && 'mask' in mask ? mask.mask : null).toEqual({ + bitmap: null, + fill: { color: '#e07575', style: 'diagonal' }, + }); + // The seeded mask is the initially selected layer. + expect(selectedLayerId).toBe(mask?.id); + }); + + it('matches the layers-panel inpaint-mask factory shape (kept in lockstep)', () => { + const state = createNewCanvasStateV2(); + const mask = state.document.layers[0]; + // Rebuild the expected layer via the canonical factory, pinning the minted id + // and name so only the contract shape is compared. + const expected = createInpaintMaskLayer(nextInpaintMaskName([]), mask?.id ?? ''); + + expect(mask).toEqual(expected); + }); + + it('honors a custom document size while staying otherwise identical to an empty canvas', () => { + const state = createNewCanvasStateV2(800, 600); + + expect(state.document.width).toBe(800); + expect(state.document.height).toBe(600); + expect(state.document.bbox).toEqual({ height: 600, width: 800, x: 0, y: 0 }); + // Only the document's layers/selection differ from the empty canvas. + expect({ ...state, document: { ...state.document, layers: [], selectedLayerId: null } }).toEqual( + createEmptyCanvasStateV2(800, 600) + ); + }); + + it('does not disturb the migration/empty path (garbage still migrates to an empty, mask-free canvas)', () => { + // The new-canvas seed is scoped to fresh projects only; migrating unknown + // input keeps producing an empty, mask-free document. + expect(migrateCanvasStateToV2('garbage').document.layers).toEqual([]); + expect(createEmptyCanvasStateV2().document.layers).toEqual([]); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvasMigration.ts b/invokeai/frontend/webv2/src/workbench/canvasMigration.ts new file mode 100644 index 00000000000..766a96a474b --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvasMigration.ts @@ -0,0 +1,290 @@ +/** + * Canvas v1 -> v2 document migration. + * + * `CanvasStateContract` (v1) only ever produced a flat stack of single-image + * raster layers positioned by an absolute `placement` rect. `CanvasStateContractV2` + * (see `types.ts`) generalizes the document into a typed layer union (raster, + * control, regional guidance, inpaint mask) positioned by a `transform`, with + * bitmaps referenced by `imageName` rather than by resolved URL. + * + * `migrateCanvasStateToV2` accepts genuinely unknown input (as read from + * localStorage) so it doubles as both the v1->v2 converter and the + * garbage/undefined-input fallback used when normalizing a loaded project. + * Already-v2 input passes through normalized, not double-migrated. + */ +import type { + CanvasDocumentContractV2, + CanvasImageRef, + CanvasInpaintMaskLayerContract, + CanvasLayerBaseContract, + CanvasRasterLayerContractV2, + CanvasStagingAreaContractV2, + CanvasStateContractV2, +} from './types'; + +export const DEFAULT_CANVAS_DOCUMENT_WIDTH = 1024; +export const DEFAULT_CANVAS_DOCUMENT_HEIGHT = 1024; + +const createMigrationId = (prefix: string): string => + `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; + +const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; + +const asNumber = (value: unknown, fallback: number): number => (typeof value === 'number' ? value : fallback); + +const asString = (value: unknown, fallback: string): string => (typeof value === 'string' ? value : fallback); + +const asPositiveNumber = (value: unknown, fallback: number): number => { + const numeric = asNumber(value, fallback); + + return numeric > 0 ? numeric : fallback; +}; + +export const createEmptyCanvasStateV2 = ( + width = DEFAULT_CANVAS_DOCUMENT_WIDTH, + height = DEFAULT_CANVAS_DOCUMENT_HEIGHT +): CanvasStateContractV2 => ({ + document: createEmptyCanvasDocumentV2(width, height), + documentRevision: 0, + snapshots: [], + stagingArea: createDefaultStagingAreaV2(), + version: 2, +}); + +export const createEmptyCanvasDocumentV2 = ( + width = DEFAULT_CANVAS_DOCUMENT_WIDTH, + height = DEFAULT_CANVAS_DOCUMENT_HEIGHT +): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height, width, x: 0, y: 0 }, + height, + layers: [], + selectedLayerId: null, + version: 2, + width, +}); + +/** + * A brand-new project's default inpaint mask: one empty mask (no bitmap/strokes) + * with the legacy-default diagonal-hatch fill in the first cycled mask colour + * (legacy `rgb(224,117,117)`). Mirrors `createInpaintMaskLayer` / + * `DEFAULT_INPAINT_MASK_FILL` in `widgets/layers/layerOps` — duplicated here so + * the pure reducer/migration module doesn't pull in the layers-panel/engine + * module graph; `canvasMigration.test.ts` locks the shape against that factory. + */ +const createInitialInpaintMaskLayer = (): CanvasInpaintMaskLayerContract => ({ + blendMode: 'normal', + id: createMigrationId('layer'), + isEnabled: true, + isLocked: false, + mask: { bitmap: null, fill: { color: '#e07575', style: 'diagonal' } }, + name: 'Inpaint Mask 1', + opacity: 1, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'inpaint_mask', +}); + +/** + * A fresh canvas state for a newly created project: an empty document that + * already carries one empty inpaint mask (selected), matching legacy, which seeds + * a canvas session with an inpaint mask present. The mask has no content, so it + * does NOT flip generation-mode detection to inpaint (see `detectCanvasMode`). + * + * This is the NEW-canvas path only. The migration / garbage-fallback path + * (`migrateCanvasStateToV2`) and `createEmptyCanvasStateV2` stay empty, so + * existing and migrated documents are left untouched. + */ +export const createNewCanvasStateV2 = ( + width = DEFAULT_CANVAS_DOCUMENT_WIDTH, + height = DEFAULT_CANVAS_DOCUMENT_HEIGHT +): CanvasStateContractV2 => { + const base = createEmptyCanvasStateV2(width, height); + const mask = createInitialInpaintMaskLayer(); + + return { + ...base, + document: { ...base.document, layers: [mask], selectedLayerId: mask.id }, + }; +}; + +const createDefaultStagingAreaV2 = (): CanvasStagingAreaContractV2 => ({ + areThumbnailsVisible: true, + autoSwitchMode: 'off', + isVisible: false, + pendingImageIds: [], + pendingImages: [], + selectedImageIndex: 0, +}); + +/** + * Converts a v1 `{x,y,width,height}` placement rect, plus the native size of the image it + * places, into a v2 `transform`. Shared by the migration path and the live "accept staged + * image into a raster layer" reducer, which still works from a v1-shaped placement. + */ +export const placementToTransform = ( + placement: { x: number; y: number; width: number; height: number }, + imageWidth: number, + imageHeight: number +): CanvasLayerBaseContract['transform'] => ({ + rotation: 0, + scaleX: imageWidth > 0 ? placement.width / imageWidth : 1, + scaleY: imageHeight > 0 ? placement.height / imageHeight : 1, + x: placement.x, + y: placement.y, +}); + +/** Migrates a single v1 `CanvasRasterLayerContract` (or bare imageName string) into a v2 raster layer. */ +const migrateLayerToV2 = (rawLayer: unknown, index: number): CanvasRasterLayerContractV2 => { + if (typeof rawLayer === 'string') { + return { + blendMode: 'normal', + id: rawLayer || createMigrationId('layer'), + isEnabled: true, + isLocked: false, + name: rawLayer || `Layer ${index + 1}`, + opacity: 1, + source: { image: { height: 0, imageName: rawLayer, width: 0 }, type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }; + } + + const layer = isRecord(rawLayer) ? rawLayer : {}; + const image: CanvasImageRef = { + height: asNumber(layer.height, 0), + imageName: asString(layer.imageName, ''), + width: asNumber(layer.width, 0), + }; + const placement = isRecord(layer.placement) ? layer.placement : {}; + const placementX = asNumber(placement.x, 0); + const placementY = asNumber(placement.y, 0); + const placementWidth = asNumber(placement.width, image.width); + const placementHeight = asNumber(placement.height, image.height); + const placementOpacity = typeof placement.opacity === 'number' ? placement.opacity : 1; + + return { + blendMode: 'normal', + id: asString(layer.id, createMigrationId('layer')), + isEnabled: true, + isLocked: false, + name: asString(layer.label, `Layer ${index + 1}`), + opacity: placementOpacity, + source: { image, type: 'image' }, + transform: placementToTransform( + { height: placementHeight, width: placementWidth, x: placementX, y: placementY }, + image.width, + image.height + ), + type: 'raster', + }; +}; + +const migrateDocumentToV2 = (rawCanvas: Record): CanvasDocumentContractV2 => { + const rawDocument = isRecord(rawCanvas.document) ? rawCanvas.document : rawCanvas; + const width = asPositiveNumber(rawDocument.width, DEFAULT_CANVAS_DOCUMENT_WIDTH); + const height = asPositiveNumber(rawDocument.height, DEFAULT_CANVAS_DOCUMENT_HEIGHT); + const rawLayers = Array.isArray(rawDocument.layers) ? rawDocument.layers : []; + + return { + background: 'transparent', + bbox: { height, width, x: 0, y: 0 }, + height, + layers: rawLayers.map((rawLayer, index) => migrateLayerToV2(rawLayer, index)), + selectedLayerId: null, + version: 2, + width, + }; +}; + +const AUTO_SWITCH_MODES: CanvasStagingAreaContractV2['autoSwitchMode'][] = ['off', 'latest', 'oldest']; + +const asAutoSwitchMode = (value: unknown): CanvasStagingAreaContractV2['autoSwitchMode'] => + AUTO_SWITCH_MODES.includes(value as CanvasStagingAreaContractV2['autoSwitchMode']) + ? (value as CanvasStagingAreaContractV2['autoSwitchMode']) + : 'off'; + +/** + * Normalizes a v1 or v2 staging area. v1 never had `autoSwitchMode`, so it's absent from raw + * input and defaults to `'off'`; already-v2 input keeps its existing value. + */ +const migrateStagingAreaToV2 = (rawCanvas: Record): CanvasStagingAreaContractV2 => { + const rawStagingArea = isRecord(rawCanvas.stagingArea) ? rawCanvas.stagingArea : {}; + const defaults = createDefaultStagingAreaV2(); + + return { + areThumbnailsVisible: + typeof rawStagingArea.areThumbnailsVisible === 'boolean' + ? rawStagingArea.areThumbnailsVisible + : defaults.areThumbnailsVisible, + autoSwitchMode: asAutoSwitchMode(rawStagingArea.autoSwitchMode), + isVisible: typeof rawStagingArea.isVisible === 'boolean' ? rawStagingArea.isVisible : defaults.isVisible, + pendingImageIds: Array.isArray(rawStagingArea.pendingImageIds) + ? (rawStagingArea.pendingImageIds as CanvasStagingAreaContractV2['pendingImageIds']) + : defaults.pendingImageIds, + pendingImages: Array.isArray(rawStagingArea.pendingImages) + ? (rawStagingArea.pendingImages as CanvasStagingAreaContractV2['pendingImages']) + : defaults.pendingImages, + selectedImageIndex: asNumber(rawStagingArea.selectedImageIndex, defaults.selectedImageIndex), + ...(typeof rawStagingArea.selectedLayerId === 'string' ? { selectedLayerId: rawStagingArea.selectedLayerId } : {}), + ...(typeof rawStagingArea.sourceQueueItemId === 'string' + ? { sourceQueueItemId: rawStagingArea.sourceQueueItemId } + : {}), + }; +}; + +const isCanvasStateV2 = (canvas: unknown): canvas is Record & { version: 2 } => + isRecord(canvas) && canvas.version === 2; + +/** Defensively re-normalizes an already-v2 canvas state (fills in anything missing without re-deriving layers). */ +const normalizeCanvasStateV2 = (canvas: Record): CanvasStateContractV2 => { + const rawDocument = isRecord(canvas.document) ? canvas.document : {}; + const width = asPositiveNumber(rawDocument.width, DEFAULT_CANVAS_DOCUMENT_WIDTH); + const height = asPositiveNumber(rawDocument.height, DEFAULT_CANVAS_DOCUMENT_HEIGHT); + const bbox = isRecord(rawDocument.bbox) ? rawDocument.bbox : {}; + + const document: CanvasDocumentContractV2 = { + background: + rawDocument.background === 'transparent' || isRecord(rawDocument.background) + ? (rawDocument.background as CanvasDocumentContractV2['background']) + : 'transparent', + bbox: { + height: asPositiveNumber(bbox.height, height), + width: asPositiveNumber(bbox.width, width), + x: asNumber(bbox.x, 0), + y: asNumber(bbox.y, 0), + }, + height, + layers: Array.isArray(rawDocument.layers) ? (rawDocument.layers as CanvasDocumentContractV2['layers']) : [], + selectedLayerId: typeof rawDocument.selectedLayerId === 'string' ? rawDocument.selectedLayerId : null, + version: 2, + width, + }; + + return { + document, + documentRevision: asNumber(canvas.documentRevision, 0), + snapshots: Array.isArray(canvas.snapshots) ? (canvas.snapshots as CanvasStateContractV2['snapshots']) : [], + stagingArea: migrateStagingAreaToV2(canvas), + version: 2, + }; +}; + +/** + * Converts unknown persisted canvas state (v1, v2, or garbage) into `CanvasStateContractV2`. + * Already-v2 input is normalized (defaults filled in) but not re-derived from placements. + */ +export const migrateCanvasStateToV2 = (canvas: unknown): CanvasStateContractV2 => { + if (isCanvasStateV2(canvas)) { + return normalizeCanvasStateV2(canvas); + } + + const rawCanvas = isRecord(canvas) ? canvas : {}; + + return { + document: migrateDocumentToV2(rawCanvas), + documentRevision: 0, + snapshots: [], + stagingArea: migrateStagingAreaToV2(rawCanvas), + version: 2, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/components/ui/ColorPicker.tsx b/invokeai/frontend/webv2/src/workbench/components/ui/ColorPicker.tsx new file mode 100644 index 00000000000..6019c0f33f4 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/components/ui/ColorPicker.tsx @@ -0,0 +1,106 @@ +import type { Color, ColorPickerValueChangeDetails } from '@chakra-ui/react'; +import type { ComponentProps } from 'react'; + +import { ColorPicker as ChakraColorPicker, parseColor, Portal } from '@chakra-ui/react'; +import { useCallback, useState } from 'react'; + +import { shouldSyncExternalColor } from './colorPickerSync'; + +export type ColorPickerSize = NonNullable['size']>; + +export interface ColorPickerProps { + /** The current color, as a `#rrggbb` (or any CSS-parseable) string. */ + value: string; + /** Called with the new `#rrggbb` hex string as the user drags the area/slider or edits the hex input. */ + onValueChange: (hex: string) => void; + /** + * Called with the final `#rrggbb` hex when an interaction ends (pointer up on + * the area/slider, or the hex input committing). Consumers that record undo + * history use this to collapse a whole drag into one entry, mirroring the + * `Slider` `onValueChangeEnd` pattern. + */ + onValueChangeEnd?: (hex: string) => void; + /** Accessible label for the swatch trigger button. */ + 'aria-label': string; + size?: ColorPickerSize; +} + +/** + * Workbench color picker: a compact swatch trigger that opens a popover with a + * saturation/hue area and a hex input. Wraps Chakra v3's composed + * `ColorPicker.*` parts (chrome comes from the built-in `colorPicker` slot + * recipe, which already reads workbench semantic tokens) — this is the single + * import point, kept to exactly what `BrushOptions` needs. + * + * Internally, the picker's controlled value is kept as a full Chakra/Zag + * `Color` (which preserves hue/saturation independent of RGB), not a hex + * string — see `shouldSyncExternalColor` for why. The external API stays + * hex-string based; hex is only produced at the `onValueChange` emit boundary. + */ +export const ColorPicker = ({ + 'aria-label': ariaLabel, + onValueChange, + onValueChangeEnd, + size = 'xs', + value, +}: ColorPickerProps) => { + const [previousExternalValue, setPreviousExternalValue] = useState(value); + const [color, setColor] = useState(() => parseColor(value)); + const [lastEmittedHex, setLastEmittedHex] = useState(() => color.toString('hex')); + + // Sync external -> internal only when the prop genuinely changed to + // something other than what we last emitted (see `shouldSyncExternalColor`). + if (value !== previousExternalValue) { + setPreviousExternalValue(value); + if (shouldSyncExternalColor(value, previousExternalValue, lastEmittedHex)) { + setColor(parseColor(value)); + } + } + + const handleValueChange = useCallback( + (details: ColorPickerValueChangeDetails) => { + setColor(details.value); + const hex = details.value.toString('hex'); + setLastEmittedHex(hex); + onValueChange(hex); + }, + [onValueChange] + ); + + const handleValueChangeEnd = useCallback( + (details: ColorPickerValueChangeDetails) => { + onValueChangeEnd?.(details.value.toString('hex')); + }, + [onValueChangeEnd] + ); + + return ( + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/invokeai/frontend/webv2/src/workbench/components/ui/Toolbar.tsx b/invokeai/frontend/webv2/src/workbench/components/ui/Toolbar.tsx index f27c0fd7fca..e81d4959c99 100644 --- a/invokeai/frontend/webv2/src/workbench/components/ui/Toolbar.tsx +++ b/invokeai/frontend/webv2/src/workbench/components/ui/Toolbar.tsx @@ -47,7 +47,6 @@ export const ToolbarButton = ({ aria-label={label} aria-pressed={isActive} size="sm" - title={label} variant={isActive ? 'solid' : 'ghost'} {...buttonProps} > diff --git a/invokeai/frontend/webv2/src/workbench/components/ui/colorPickerSync.test.ts b/invokeai/frontend/webv2/src/workbench/components/ui/colorPickerSync.test.ts new file mode 100644 index 00000000000..013b8bfb20d --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/components/ui/colorPickerSync.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { shouldSyncExternalColor } from './colorPickerSync'; + +describe('shouldSyncExternalColor', () => { + it('does not sync when the external value is unchanged', () => { + expect(shouldSyncExternalColor('#808080', '#808080', '#808080')).toBe(false); + }); + + it('does not sync when the external value changed to exactly what we last emitted (our own round trip)', () => { + // This is the grey-hue-loss scenario: the user drags the hue slider at + // S=0, we emit "#808080", the consumer stores it and passes it back as + // the new `value` prop -- that must not force a re-parse that would + // collapse the hue we're holding onto internally. + expect(shouldSyncExternalColor('#808080', '#7f7f7f', '#808080')).toBe(false); + }); + + it('syncs when the external value changed to something other than what we last emitted', () => { + // A genuine external change (e.g. a programmatic reset, or a different + // control writing to the same underlying value) should still win. + expect(shouldSyncExternalColor('#ff0000', '#808080', '#808080')).toBe(true); + }); + + it('syncs on the very first divergence even if nothing has been emitted yet', () => { + expect(shouldSyncExternalColor('#ff0000', '#000000', '#000000')).toBe(true); + }); + + it('does not sync when the previous and external values are identical, regardless of last emitted', () => { + expect(shouldSyncExternalColor('#123456', '#123456', '#abcdef')).toBe(false); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/components/ui/colorPickerSync.ts b/invokeai/frontend/webv2/src/workbench/components/ui/colorPickerSync.ts new file mode 100644 index 00000000000..784eed98ffe --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/components/ui/colorPickerSync.ts @@ -0,0 +1,18 @@ +/** + * True when `externalValue` (the latest `value` prop given to `ColorPicker`) + * should be re-parsed into the picker's internal `Color`, versus kept as-is. + * + * We only want to re-sync from the external hex string when it reflects a + * change the consumer made independently of the picker (e.g. a programmatic + * reset) — not the round trip of the hex the picker *just emitted itself* via + * `onValueChange`. That round trip loses hue information for any grey/black/ + * white color (saturation or value = 0, where hex/RGB is hue-agnostic), so + * re-parsing it on every render would snap the hue thumb back to whatever + * `parseColor` yields for an indeterminate hue — the picker would only ever + * be able to *emit* a new hue at S=0, never actually keep it. + */ +export const shouldSyncExternalColor = ( + externalValue: string, + previousExternalValue: string, + lastEmittedHex: string +): boolean => externalValue !== previousExternalValue && externalValue !== lastEmittedHex; diff --git a/invokeai/frontend/webv2/src/workbench/components/ui/index.ts b/invokeai/frontend/webv2/src/workbench/components/ui/index.ts index 0596ba3d29a..1e10a8129f2 100644 --- a/invokeai/frontend/webv2/src/workbench/components/ui/index.ts +++ b/invokeai/frontend/webv2/src/workbench/components/ui/index.ts @@ -1,4 +1,5 @@ export * from './Button'; +export * from './ColorPicker'; export * from './ConfirmDialog'; export * from './Field'; export * from './JsonPreview'; diff --git a/invokeai/frontend/webv2/src/workbench/gallery/api.test.ts b/invokeai/frontend/webv2/src/workbench/gallery/api.test.ts new file mode 100644 index 00000000000..51e553e1404 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/gallery/api.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { imageMakeDurableChanges, imageSaveToGalleryChanges } from './api'; + +describe('imageSaveToGalleryChanges', () => { + it('promotes an intermediate candidate to a durable, gallery-visible image', () => { + // The backend `ImageRecordChanges` body: clear `is_intermediate` (stop GC) + // and set `image_category: 'general'` (surface in the gallery images view). + expect(imageSaveToGalleryChanges()).toEqual({ image_category: 'general', is_intermediate: false }); + }); +}); + +describe('imageMakeDurableChanges', () => { + it('clears is_intermediate WITHOUT touching image_category (durable but not in gallery)', () => { + // Applying a control-filter preview makes the chosen image the layer's new + // source: it must survive GC (is_intermediate: false) but stay out of the + // gallery images view (no image_category change). + const body = imageMakeDurableChanges(); + expect(body).toEqual({ is_intermediate: false }); + expect('image_category' in body).toBe(false); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/gallery/api.ts b/invokeai/frontend/webv2/src/workbench/gallery/api.ts index 7575fedaec6..208f109aaf6 100644 --- a/invokeai/frontend/webv2/src/workbench/gallery/api.ts +++ b/invokeai/frontend/webv2/src/workbench/gallery/api.ts @@ -76,9 +76,13 @@ interface ListImagesResponse { const imageCategories = ['general']; const assetCategories = ['control', 'mask', 'user', 'other']; -const getImageThumbnailUrl = (imageName: string): string => +export const getImageThumbnailUrl = (imageName: string): string => absolutizeApiUrl(`/api/v1/images/i/${encodeURIComponent(imageName)}/thumbnail`); +/** The full-resolution image URL for an image referenced only by name (e.g. a v2 canvas layer's `CanvasImageRef`). */ +export const getImageFullUrl = (imageName: string): string => + absolutizeApiUrl(`/api/v1/images/i/${encodeURIComponent(imageName)}/full`); + const toSearchParams = (entries: Record): string => { const params = new URLSearchParams(); @@ -300,6 +304,56 @@ export const listGalleryImages = async ({ }; }; +/** + * The `ImageRecordChanges` body that promotes a staged canvas candidate (an + * intermediate image) into a durable, gallery-visible image: clearing + * `is_intermediate` stops it being garbage-collected, and `image_category: + * 'general'` surfaces it in the gallery's images view. Pure so the request + * shape can be unit-tested without a fetch. + */ +export const imageSaveToGalleryChanges = (): { is_intermediate: false; image_category: 'general' } => ({ + image_category: 'general', + is_intermediate: false, +}); + +/** + * The `ImageRecordChanges` body that makes an intermediate image durable WITHOUT + * surfacing it in the gallery: clearing `is_intermediate` stops it being + * garbage-collected, but (unlike {@link imageSaveToGalleryChanges}) it leaves + * `image_category` untouched so it stays out of the gallery's images view. Used + * when applying a control-filter preview: the chosen image becomes the layer's + * new source (durable), while the discarded previews GC away as intermediates. + */ +export const imageMakeDurableChanges = (): { is_intermediate: false } => ({ + is_intermediate: false, +}); + +/** + * Makes a single intermediate image durable (survives GC) without adding it to + * the gallery, via `PATCH /api/v1/images/i/{image_name}`. Resolves once the PATCH + * succeeds; the caller commits the layer-source swap only after this settles so a + * failed PATCH never strands the layer pointing at a soon-to-be-collected image. + */ +export const makeImageDurable = async (imageName: string): Promise => { + await apiFetchJson(`/api/v1/images/i/${encodeURIComponent(imageName)}`, { + body: JSON.stringify(imageMakeDurableChanges()), + method: 'PATCH', + }); +}; + +/** + * Promotes a single image (e.g. a staged canvas candidate) into the gallery via + * `PATCH /api/v1/images/i/{image_name}` and returns the updated {@link GalleryImage}. + */ +export const saveImageToGallery = async (imageName: string): Promise => { + const body = await apiFetchJson(`/api/v1/images/i/${encodeURIComponent(imageName)}`, { + body: JSON.stringify(imageSaveToGalleryChanges()), + method: 'PATCH', + }); + + return mapImage(body); +}; + export const getGalleryImageMetadata = async (imageName: string): Promise => { const body = await apiFetchJson(`/api/v1/images/i/${encodeURIComponent(imageName)}/metadata`); diff --git a/invokeai/frontend/webv2/src/workbench/generation/api.ts b/invokeai/frontend/webv2/src/workbench/generation/api.ts index a9bfd7f73e3..5db26614ffc 100644 --- a/invokeai/frontend/webv2/src/workbench/generation/api.ts +++ b/invokeai/frontend/webv2/src/workbench/generation/api.ts @@ -86,6 +86,33 @@ export const enqueueWorkflowGraph = async (request: EnqueueWorkflowRequest): Pro }; }; +/** + * Enqueues a small graph OUTSIDE any project's queue, tagged with a caller-built + * utility origin (`webv2:util:`). Used by {@link import('@workbench/canvas-engine/backend/utilityQueue').runUtilityGraph} + * for filter previews / SAM: the origin is intentionally opaque to project + * routing (see `events.ts`), so results are never staged or added to the gallery. + * A single deterministic run (no seed/prompt batch data). + */ +export const enqueueUtilityGraph = async (request: { + graph: BackendGraphContract; + origin: string; +}): Promise<{ itemIds: number[]; enqueued: number }> => { + const body = { + batch: { + graph: request.graph satisfies BackendGraphContract, + origin: request.origin, + runs: 1, + }, + prepend: false, + }; + const result = await apiFetchJson<{ enqueued?: number; item_ids?: number[] }>('/api/v1/queue/default/enqueue_batch', { + body: JSON.stringify(body), + method: 'POST', + }); + + return { enqueued: result.enqueued ?? 0, itemIds: result.item_ids ?? [] }; +}; + export const getQueueItem = (itemId: number): Promise => apiFetchJson(`/api/v1/queue/default/i/${itemId}`); diff --git a/invokeai/frontend/webv2/src/workbench/generation/canvas/addControlLayers.test.ts b/invokeai/frontend/webv2/src/workbench/generation/canvas/addControlLayers.test.ts new file mode 100644 index 00000000000..069b47b7c54 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/generation/canvas/addControlLayers.test.ts @@ -0,0 +1,475 @@ +import { describe, expect, it } from 'vitest'; + +import type { + AddControlLayersOptions, + ControlAdapterKind, + ControlLayerGraphInput, + ControlModelIdentifier, +} from './addControlLayers'; + +import { + addControlLayers, + CONTROL_DENOISE_NODE_ID, + getControlLayerRejectionReason, + isControlKindSupportedForBase, +} from './addControlLayers'; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +interface TestGraph { + id: string; + nodes: Record; + edges: { + source: { node_id: string; field: string }; + destination: { node_id: string; field: string }; + }[]; +} + +/** A minimal built base graph containing only the required denoise node. */ +const baseGraph = (): TestGraph => ({ + id: 'g', + nodes: { + denoise_latents: { id: 'denoise_latents', type: 'denoise_latents' }, + }, + edges: [], +}); + +const model = (base: string): ControlModelIdentifier => ({ + key: `model-${base}`, + name: `Model ${base}`, + base, + type: 'controlnet', +}); + +const layer = (overrides: Partial = {}): ControlLayerGraphInput => ({ + id: 'layer1', + imageName: 'image1.png', + kind: 'controlnet', + model: model('sd-1'), + weight: 0.75, + beginEndStepPct: [0.1, 0.8], + controlMode: 'balanced', + ...overrides, +}); + +// Convenience to run addControlLayers against a fresh graph. +const run = (options: AddControlLayersOptions) => { + const graph = baseGraph(); + // Cast: TestGraph is structurally the BackendGraphContract shape addControlLayers needs. + addControlLayers(graph as never, options); + return graph; +}; + +const edgesTo = (graph: TestGraph, nodeId: string, field: string) => + graph.edges.filter((e) => e.destination.node_id === nodeId && e.destination.field === field); + +// --------------------------------------------------------------------------- +// 1. isControlKindSupportedForBase full matrix +// --------------------------------------------------------------------------- + +describe('isControlKindSupportedForBase', () => { + it('controlnet is supported on sd-1, sdxl, flux only', () => { + expect(isControlKindSupportedForBase('sd-1', 'controlnet')).toBe(true); + expect(isControlKindSupportedForBase('sdxl', 'controlnet')).toBe(true); + expect(isControlKindSupportedForBase('flux', 'controlnet')).toBe(true); + expect(isControlKindSupportedForBase('sd-2', 'controlnet')).toBe(false); + expect(isControlKindSupportedForBase('sd-3', 'controlnet')).toBe(false); + }); + + it('t2i_adapter is supported on sd-1 and sdxl only (not flux)', () => { + expect(isControlKindSupportedForBase('sd-1', 't2i_adapter')).toBe(true); + expect(isControlKindSupportedForBase('sdxl', 't2i_adapter')).toBe(true); + expect(isControlKindSupportedForBase('flux', 't2i_adapter')).toBe(false); + expect(isControlKindSupportedForBase('sd-2', 't2i_adapter')).toBe(false); + }); + + it('control_lora is supported on flux only', () => { + expect(isControlKindSupportedForBase('flux', 'control_lora')).toBe(true); + expect(isControlKindSupportedForBase('sd-1', 'control_lora')).toBe(false); + expect(isControlKindSupportedForBase('sdxl', 'control_lora')).toBe(false); + expect(isControlKindSupportedForBase('sd-2', 'control_lora')).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// CONTROL_DENOISE_NODE_ID sanity +// --------------------------------------------------------------------------- + +describe('CONTROL_DENOISE_NODE_ID', () => { + it('is the deterministic denoise node id', () => { + expect(CONTROL_DENOISE_NODE_ID).toBe('denoise_latents'); + }); +}); + +// --------------------------------------------------------------------------- +// 2. controlnet on sd-1 +// --------------------------------------------------------------------------- + +describe('addControlLayers — controlnet on sd-1', () => { + it('creates a controlnet node with exact fields and wires the collector to denoise.control', () => { + const m = model('sd-1'); + const graph = run({ + base: 'sd-1', + layers: [ + layer({ + id: 'L1', + imageName: 'ctrl.png', + model: m, + weight: 0.6, + beginEndStepPct: [0.2, 0.9], + controlMode: null, + }), + ], + }); + + const node = graph.nodes['control_net_L1']; + expect(node).toBeDefined(); + expect(node.type).toBe('controlnet'); + expect(node.begin_step_percent).toBe(0.2); + expect(node.end_step_percent).toBe(0.9); + expect(node.control_model).toBe(m); + expect(node.control_weight).toBe(0.6); + expect(node.resize_mode).toBe('just_resize'); + // controlMode null → defaults to 'balanced' + expect(node.control_mode).toBe('balanced'); + expect(node.image).toEqual({ image_name: 'ctrl.png' }); + + // Collector created and wired. + expect(graph.nodes['control_net_collector']).toBeDefined(); + expect(graph.nodes['control_net_collector'].type).toBe('collect'); + + // node.control → collector.item + expect( + graph.edges.some( + (e) => + e.source.node_id === 'control_net_L1' && + e.source.field === 'control' && + e.destination.node_id === 'control_net_collector' && + e.destination.field === 'item' + ) + ).toBe(true); + + // collector.collection → denoise.control + expect( + graph.edges.some( + (e) => + e.source.node_id === 'control_net_collector' && + e.source.field === 'collection' && + e.destination.node_id === 'denoise_latents' && + e.destination.field === 'control' + ) + ).toBe(true); + }); + + it('carries an explicit controlMode when provided', () => { + const graph = run({ + base: 'sd-1', + layers: [layer({ id: 'L1', controlMode: 'more_control' })], + }); + expect(graph.nodes['control_net_L1'].control_mode).toBe('more_control'); + }); +}); + +// --------------------------------------------------------------------------- +// 3. controlnet on flux +// --------------------------------------------------------------------------- + +describe('addControlLayers — controlnet on flux', () => { + it('uses flux_controlnet type and omits control_mode entirely', () => { + const m = model('flux'); + const graph = run({ + base: 'flux', + layers: [layer({ id: 'F1', kind: 'controlnet', model: m, controlMode: 'more_prompt' })], + }); + + const node = graph.nodes['control_net_F1']; + expect(node).toBeDefined(); + expect(node.type).toBe('flux_controlnet'); + expect(node.resize_mode).toBe('just_resize'); + expect(node.control_model).toBe(m); + // No control_mode key at all, even though controlMode was set. + expect(node).not.toHaveProperty('control_mode'); + + // Collector wired same as SD family. + expect( + graph.edges.some( + (e) => + e.source.node_id === 'control_net_collector' && + e.source.field === 'collection' && + e.destination.node_id === 'denoise_latents' && + e.destination.field === 'control' + ) + ).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// 4. t2i_adapter on sdxl +// --------------------------------------------------------------------------- + +describe('addControlLayers — t2i_adapter on sdxl', () => { + it('creates a t2i_adapter node with exact fields and wires collector to denoise.t2i_adapter', () => { + const m = model('sdxl'); + const graph = run({ + base: 'sdxl', + layers: [ + layer({ + id: 'T1', + kind: 't2i_adapter', + imageName: 't2i.png', + model: m, + weight: 0.5, + beginEndStepPct: [0.05, 0.7], + }), + ], + }); + + const node = graph.nodes['t2i_adapter_T1']; + expect(node).toBeDefined(); + expect(node.type).toBe('t2i_adapter'); + expect(node.begin_step_percent).toBe(0.05); + expect(node.end_step_percent).toBe(0.7); + expect(node.resize_mode).toBe('just_resize'); + expect(node.t2i_adapter_model).toBe(m); + expect(node.weight).toBe(0.5); + expect(node.image).toEqual({ image_name: 't2i.png' }); + // t2i_adapter has no control_mode / control_model / control_weight + expect(node).not.toHaveProperty('control_mode'); + expect(node).not.toHaveProperty('control_model'); + + expect(graph.nodes['t2i_adapter_collector']).toBeDefined(); + expect(graph.nodes['t2i_adapter_collector'].type).toBe('collect'); + + // node.t2i_adapter → collector.item + expect( + graph.edges.some( + (e) => + e.source.node_id === 't2i_adapter_T1' && + e.source.field === 't2i_adapter' && + e.destination.node_id === 't2i_adapter_collector' && + e.destination.field === 'item' + ) + ).toBe(true); + + // collector.collection → denoise.t2i_adapter + expect( + graph.edges.some( + (e) => + e.source.node_id === 't2i_adapter_collector' && + e.source.field === 'collection' && + e.destination.node_id === 'denoise_latents' && + e.destination.field === 't2i_adapter' + ) + ).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// 5. control_lora on flux +// --------------------------------------------------------------------------- + +describe('addControlLayers — control_lora on flux', () => { + it('creates a flux_control_lora_loader wired directly to denoise.control_lora with no collector', () => { + const m = model('flux'); + const graph = run({ + base: 'flux', + layers: [layer({ id: 'CL1', kind: 'control_lora', imageName: 'lora.png', model: m, weight: 0.9 })], + }); + + const node = graph.nodes['control_lora_CL1']; + expect(node).toBeDefined(); + expect(node.type).toBe('flux_control_lora_loader'); + expect(node.lora).toBe(m); + expect(node.image).toEqual({ image_name: 'lora.png' }); + expect(node.weight).toBe(0.9); + + // Wired directly node.control_lora → denoise.control_lora + expect( + graph.edges.some( + (e) => + e.source.node_id === 'control_lora_CL1' && + e.source.field === 'control_lora' && + e.destination.node_id === 'denoise_latents' && + e.destination.field === 'control_lora' + ) + ).toBe(true); + + // No collector node of any kind created. + expect(graph.nodes['control_net_collector']).toBeUndefined(); + expect(graph.nodes['t2i_adapter_collector']).toBeUndefined(); + expect(Object.values(graph.nodes).some((n) => n.type === 'collect')).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// 6. control_lora limits: second skipped; dev_fill skips +// --------------------------------------------------------------------------- + +describe('addControlLayers — control_lora limits', () => { + it('adds at most one control_lora; the second layer is skipped', () => { + const graph = run({ + base: 'flux', + layers: [ + layer({ id: 'CL1', kind: 'control_lora', model: model('flux') }), + layer({ id: 'CL2', kind: 'control_lora', model: model('flux') }), + ], + }); + + const loraNodes = Object.values(graph.nodes).filter((n) => n.type === 'flux_control_lora_loader'); + expect(loraNodes).toHaveLength(1); + expect(graph.nodes['control_lora_CL1']).toBeDefined(); + expect(graph.nodes['control_lora_CL2']).toBeUndefined(); + }); + + it('skips control_lora entirely for a dev_fill main model variant', () => { + const graph = run({ + base: 'flux', + modelVariant: 'dev_fill', + layers: [layer({ id: 'CL1', kind: 'control_lora', model: model('flux') })], + }); + + expect(Object.values(graph.nodes).some((n) => n.type === 'flux_control_lora_loader')).toBe(false); + // Only the denoise node remains. + expect(Object.keys(graph.nodes)).toEqual(['denoise_latents']); + expect(graph.edges).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// 7. Two controlnet layers → distinct nodes, one shared collector +// --------------------------------------------------------------------------- + +describe('addControlLayers — per-layer separation', () => { + it('creates two distinct adapter nodes feeding one shared collector', () => { + const graph = run({ + base: 'sd-1', + layers: [layer({ id: 'A', imageName: 'a.png' }), layer({ id: 'B', imageName: 'b.png' })], + }); + + expect(graph.nodes['control_net_A']).toBeDefined(); + expect(graph.nodes['control_net_B']).toBeDefined(); + expect(graph.nodes['control_net_A'].image).toEqual({ image_name: 'a.png' }); + expect(graph.nodes['control_net_B'].image).toEqual({ image_name: 'b.png' }); + + // Exactly one collector. + const collectors = Object.values(graph.nodes).filter((n) => n.type === 'collect'); + expect(collectors).toHaveLength(1); + + // Two item edges into the shared collector. + const itemEdges = graph.edges.filter( + (e) => e.destination.node_id === 'control_net_collector' && e.destination.field === 'item' + ); + expect(itemEdges).toHaveLength(2); + expect(itemEdges.map((e) => e.source.node_id).sort()).toEqual(['control_net_A', 'control_net_B']); + + // Only one collection→denoise edge (collector wired once). + expect(edgesTo(graph, 'denoise_latents', 'control')).toHaveLength(1); + }); +}); + +// --------------------------------------------------------------------------- +// 8. Unsupported kind for base is silently skipped +// --------------------------------------------------------------------------- + +describe('addControlLayers — unsupported kind skipped', () => { + it('adds nothing for a t2i_adapter layer on flux', () => { + const graph = run({ + base: 'flux', + layers: [layer({ id: 'X', kind: 't2i_adapter', model: model('flux') })], + }); + + expect(Object.keys(graph.nodes)).toEqual(['denoise_latents']); + expect(graph.edges).toHaveLength(0); + }); + + it('skips only unsupported layers while keeping supported ones', () => { + const graph = run({ + base: 'flux', + layers: [ + layer({ id: 'skip', kind: 't2i_adapter', model: model('flux') }), + layer({ id: 'keep', kind: 'controlnet', model: model('flux') }), + ], + }); + + expect(graph.nodes['t2i_adapter_skip']).toBeUndefined(); + expect(graph.nodes['control_net_keep']).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 9. throws when denoise node missing +// --------------------------------------------------------------------------- + +describe('addControlLayers — missing denoise node', () => { + it('throws when the base graph has no denoise node', () => { + const graph: TestGraph = { id: 'g', nodes: {}, edges: [] }; + expect(() => addControlLayers(graph as never, { base: 'sd-1', layers: [layer()] })).toThrow( + /missing the denoise node/ + ); + }); +}); + +// --------------------------------------------------------------------------- +// 10. getControlLayerRejectionReason branches +// --------------------------------------------------------------------------- + +describe('getControlLayerRejectionReason', () => { + const validParams = { + layerName: 'My Layer', + hasContent: true, + kind: 'controlnet' as ControlAdapterKind, + adapterModel: { base: 'sd-1' }, + mainBase: 'sd-1', + mainVariant: undefined as string | undefined, + }; + + it('returns null for a valid controlnet on sd-1 with a matching model base', () => { + expect(getControlLayerRejectionReason(validParams)).toBeNull(); + }); + + it('rejects when the layer has no content', () => { + const reason = getControlLayerRejectionReason({ ...validParams, hasContent: false }); + expect(reason).toEqual(expect.any(String)); + expect(reason).toContain('no control content'); + }); + + it('rejects when no adapter model is selected', () => { + const reason = getControlLayerRejectionReason({ ...validParams, adapterModel: null }); + expect(reason).toEqual(expect.any(String)); + expect(reason).toContain('no control model'); + }); + + it('rejects when the base+kind is unsupported', () => { + const reason = getControlLayerRejectionReason({ + ...validParams, + mainBase: 'sd-2', + adapterModel: { base: 'sd-2' }, + }); + expect(reason).toEqual(expect.any(String)); + expect(reason).toContain('not supported'); + }); + + it('rejects when the adapter model base does not match the main base', () => { + const reason = getControlLayerRejectionReason({ + ...validParams, + adapterModel: { base: 'sdxl' }, + }); + expect(reason).toEqual(expect.any(String)); + expect(reason).toContain('incompatible base'); + }); + + it('rejects FLUX Fill (dev_fill) + control_lora', () => { + const reason = getControlLayerRejectionReason({ + layerName: 'My Layer', + hasContent: true, + kind: 'control_lora', + adapterModel: { base: 'flux' }, + mainBase: 'flux', + mainVariant: 'dev_fill', + }); + expect(reason).toEqual(expect.any(String)); + expect(reason).toContain('FLUX Fill'); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/generation/canvas/addControlLayers.ts b/invokeai/frontend/webv2/src/workbench/generation/canvas/addControlLayers.ts new file mode 100644 index 00000000000..1859d03ca9b --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/generation/canvas/addControlLayers.ts @@ -0,0 +1,203 @@ +/** + * Pure control-layer graph grafting (legacy parity). + * + * {@link addControlLayers} wires each enabled, valid control layer's uploaded + * composite into a base-appropriate control adapter node, collects same-kind + * nodes, and connects the collector (or, for Control LoRA, the node directly) to + * the canvas graph's denoise node. It mutates the graph in place using the same + * `addNode`/`addEdge` helpers the base builders use — no fetch, no engine, no + * React. Node types + args mirror legacy + * `features/nodes/util/graph/generation/addControlAdapters.ts`. + * + * Per-base + per-kind support (legacy `getControlLayerWarnings` + the graph + * builders): controlnet on sd-1 / sdxl (`controlnet`) and flux (`flux_controlnet`); + * t2i_adapter on sd-1 / sdxl; control_lora on flux only (single layer, and never + * with a FLUX Fill main model). Everything else is rejected upstream. + * + * The executor composites each control layer SEPARATELY (never blended) and + * passes its own uploaded image name in — so each adapter node references a + * distinct control image, exactly like legacy. + */ + +import type { SupportedGenerateBase } from '@workbench/generation/baseGenerationPolicies'; +import type { BackendGraphContract, BackendInvocationContract } from '@workbench/types'; + +import { addEdge, addNode } from '@workbench/generation/graphBuilder'; + +/** The deterministic denoise node id every canvas base graph uses. */ +export const CONTROL_DENOISE_NODE_ID = 'denoise_latents'; + +/** The control-adapter kinds a control layer can carry. */ +export type ControlAdapterKind = 'controlnet' | 't2i_adapter' | 'control_lora'; + +/** A resolved control-adapter model identifier (the backend model field shape). */ +export interface ControlModelIdentifier { + key: string; + hash?: string; + name: string; + base: string; + type: string; +} + +/** One control layer's fully-resolved graph contribution (invalid layers filtered out first). */ +export interface ControlLayerGraphInput { + /** The document layer id (used to mint deterministic node ids). */ + id: string; + /** The uploaded per-layer composite image name. */ + imageName: string; + kind: ControlAdapterKind; + /** The resolved control-adapter model identifier (never null here). */ + model: ControlModelIdentifier; + weight: number; + beginEndStepPct: [number, number]; + controlMode: 'balanced' | 'more_prompt' | 'more_control' | 'unbalanced' | null; +} + +/** + * True when `base` supports control adapters of `kind` (legacy support matrix). + * `controlMode` and adapter-model base compatibility are validated separately. + */ +export const isControlKindSupportedForBase = (base: string, kind: ControlAdapterKind): boolean => { + switch (kind) { + case 'controlnet': + return base === 'sd-1' || base === 'sdxl' || base === 'flux'; + case 't2i_adapter': + return base === 'sd-1' || base === 'sdxl'; + case 'control_lora': + return base === 'flux'; + } +}; + +/** Options for {@link addControlLayers}. */ +export interface AddControlLayersOptions { + /** The main model base — selects controlnet vs flux_controlnet and support. */ + base: SupportedGenerateBase; + /** The main model variant (a FLUX `dev_fill` blocks Control LoRA). */ + modelVariant?: string; + /** The valid, resolved control layers to graft (in document order). */ + layers: readonly ControlLayerGraphInput[]; +} + +/** Resolves the backend node type for a controlnet layer on `base`. */ +const controlNetNodeType = (base: string): string => (base === 'flux' ? 'flux_controlnet' : 'controlnet'); + +/** + * Grafts control layers onto a built canvas base graph. Assumes every input is + * already validated (supported base+kind, compatible non-null model, non-empty + * content) — use {@link getControlLayerRejectionReason} to filter first. Wires: + * - controlnet → `control_net_collector` (collect) → `denoise.control`; + * - t2i_adapter → `t2i_adapter_collector` (collect) → `denoise.t2i_adapter`; + * - control_lora → `denoise.control_lora` directly (FLUX, first layer only). + */ +export const addControlLayers = (graph: BackendGraphContract, options: AddControlLayersOptions): void => { + const { base, layers, modelVariant } = options; + const denoise = graph.nodes[CONTROL_DENOISE_NODE_ID]; + if (!denoise) { + throw new Error('addControlLayers: base graph is missing the denoise node.'); + } + + let controlNetCollector: BackendInvocationContract | null = null; + let t2iAdapterCollector: BackendInvocationContract | null = null; + let controlLoraAdded = false; + + const ensureControlNetCollector = (): BackendInvocationContract => { + if (!controlNetCollector) { + controlNetCollector = addNode(graph, { id: 'control_net_collector', type: 'collect' }); + addEdge(graph, controlNetCollector, 'collection', denoise, 'control'); + } + return controlNetCollector; + }; + + const ensureT2iAdapterCollector = (): BackendInvocationContract => { + if (!t2iAdapterCollector) { + t2iAdapterCollector = addNode(graph, { id: 't2i_adapter_collector', type: 'collect' }); + addEdge(graph, t2iAdapterCollector, 'collection', denoise, 't2i_adapter'); + } + return t2iAdapterCollector; + }; + + for (const layer of layers) { + if (!isControlKindSupportedForBase(base, layer.kind)) { + continue; + } + + if (layer.kind === 'controlnet') { + const node = addNode(graph, { + begin_step_percent: layer.beginEndStepPct[0], + control_model: layer.model, + control_weight: layer.weight, + end_step_percent: layer.beginEndStepPct[1], + id: `control_net_${layer.id}`, + image: { image_name: layer.imageName }, + resize_mode: 'just_resize', + type: controlNetNodeType(base), + // FLUX ControlNet has no control_mode; SD-family carries it. + ...(base === 'flux' ? {} : { control_mode: layer.controlMode ?? 'balanced' }), + }); + addEdge(graph, node, 'control', ensureControlNetCollector(), 'item'); + } else if (layer.kind === 't2i_adapter') { + const node = addNode(graph, { + begin_step_percent: layer.beginEndStepPct[0], + end_step_percent: layer.beginEndStepPct[1], + id: `t2i_adapter_${layer.id}`, + image: { image_name: layer.imageName }, + resize_mode: 'just_resize', + t2i_adapter_model: layer.model, + type: 't2i_adapter', + weight: layer.weight, + }); + addEdge(graph, node, 't2i_adapter', ensureT2iAdapterCollector(), 'item'); + } else { + // control_lora — FLUX only, at most one, never with a FLUX Fill main model. + if (controlLoraAdded || modelVariant === 'dev_fill') { + continue; + } + const node = addNode(graph, { + id: `control_lora_${layer.id}`, + image: { image_name: layer.imageName }, + lora: layer.model, + type: 'flux_control_lora_loader', + weight: layer.weight, + }); + addEdge(graph, node, 'control_lora', denoise, 'control_lora'); + controlLoraAdded = true; + } + } +}; + +/** + * Returns the legacy-parity rejection reason for a control layer, or `null` when + * it is valid for generation. Mirrors `getControlLayerWarnings`: + * - no drawn/composited content → "no control"; + * - no adapter model selected → "no model"; + * - main base unsupported (sd-2 / sd-3 / anima / …) → "unsupported model"; + * - adapter model base ≠ main base → "incompatible base"; + * - FLUX Fill + Control LoRA → "incompatible". + */ +export const getControlLayerRejectionReason = (params: { + layerName: string; + hasContent: boolean; + kind: ControlAdapterKind; + adapterModel: { base: string } | null; + mainBase: string; + mainVariant?: string; +}): string | null => { + const { adapterModel, hasContent, kind, layerName, mainBase, mainVariant } = params; + + if (!hasContent) { + return `Control layer "${layerName}" has no control content.`; + } + if (!adapterModel) { + return `Control layer "${layerName}" has no control model selected.`; + } + if (!isControlKindSupportedForBase(mainBase, kind)) { + return `Control layer "${layerName}" is not supported for the selected base model.`; + } + if (adapterModel.base !== mainBase) { + return `Control layer "${layerName}" uses an incompatible base model.`; + } + if (mainBase === 'flux' && mainVariant === 'dev_fill' && kind === 'control_lora') { + return `Control LoRA is not compatible with FLUX Fill.`; + } + return null; +}; diff --git a/invokeai/frontend/webv2/src/workbench/generation/canvas/addRegionalGuidance.test.ts b/invokeai/frontend/webv2/src/workbench/generation/canvas/addRegionalGuidance.test.ts new file mode 100644 index 00000000000..9819afbfc73 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/generation/canvas/addRegionalGuidance.test.ts @@ -0,0 +1,312 @@ +import { describe, expect, it } from 'vitest'; + +import type { AddRegionalGuidanceOptions, RegionalGuidanceInput, RegionalReferenceModel } from './addRegionalGuidance'; + +import { + addRegionalGuidance, + getRegionalGuidanceRejectionReason, + isRegionalGuidanceSupportedForBase, +} from './addRegionalGuidance'; + +interface TestGraph { + id: string; + nodes: Record; + edges: { source: { node_id: string; field: string }; destination: { node_id: string; field: string } }[]; +} + +/** A minimal SD-family base graph: pos/neg cond + collectors wired to denoise, with clip edges. */ +const sdBaseGraph = (opts: { sdxl?: boolean } = {}): TestGraph => { + const clip2Edges = opts.sdxl + ? [ + { destination: { field: 'clip2', node_id: 'pos_cond' }, source: { field: 'clip2', node_id: 'model_loader' } }, + { destination: { field: 'clip2', node_id: 'neg_cond' }, source: { field: 'clip2', node_id: 'model_loader' } }, + ] + : []; + return { + edges: [ + { destination: { field: 'clip', node_id: 'pos_cond' }, source: { field: 'clip', node_id: 'clip_skip' } }, + { destination: { field: 'clip', node_id: 'neg_cond' }, source: { field: 'clip', node_id: 'clip_skip' } }, + ...clip2Edges, + { + destination: { field: 'positive_conditioning', node_id: 'denoise_latents' }, + source: { field: 'collection', node_id: 'pos_cond_collect' }, + }, + { + destination: { field: 'negative_conditioning', node_id: 'denoise_latents' }, + source: { field: 'collection', node_id: 'neg_cond_collect' }, + }, + ], + id: 'g', + nodes: { + denoise_latents: { id: 'denoise_latents', type: 'denoise_latents' }, + neg_cond: { id: 'neg_cond', type: opts.sdxl ? 'sdxl_compel_prompt' : 'compel' }, + neg_cond_collect: { id: 'neg_cond_collect', type: 'collect' }, + pos_cond: { id: 'pos_cond', type: opts.sdxl ? 'sdxl_compel_prompt' : 'compel' }, + pos_cond_collect: { id: 'pos_cond_collect', type: 'collect' }, + }, + }; +}; + +/** A minimal FLUX base graph: pos cond + collector, no negative. */ +const fluxBaseGraph = (): TestGraph => ({ + edges: [ + { destination: { field: 'clip', node_id: 'pos_cond' }, source: { field: 'clip', node_id: 'model_loader' } }, + { + destination: { field: 't5_encoder', node_id: 'pos_cond' }, + source: { field: 't5_encoder', node_id: 'model_loader' }, + }, + { + destination: { field: 'positive_text_conditioning', node_id: 'denoise_latents' }, + source: { field: 'collection', node_id: 'pos_cond_collect' }, + }, + ], + id: 'g', + nodes: { + denoise_latents: { id: 'denoise_latents', type: 'flux_denoise' }, + pos_cond: { id: 'pos_cond', type: 'flux_text_encoder' }, + pos_cond_collect: { id: 'pos_cond_collect', type: 'collect' }, + }, +}); + +const ipModel = (base: string): RegionalReferenceModel => ({ + base, + key: `ip-${base}`, + name: `IP ${base}`, + type: 'ip_adapter', +}); + +const region = (overrides: Partial = {}): RegionalGuidanceInput => ({ + autoNegative: false, + id: 'r1', + maskImageName: 'mask1.png', + negativePrompt: null, + positivePrompt: 'a cat', + referenceImages: [], + ...overrides, +}); + +const run = (graph: TestGraph, options: AddRegionalGuidanceOptions): TestGraph => { + addRegionalGuidance(graph as never, options); + return graph; +}; + +const nodesOfType = (graph: TestGraph, type: string) => Object.values(graph.nodes).filter((n) => n.type === type); +const hasEdge = (graph: TestGraph, s: string, sf: string, d: string, df: string): boolean => + graph.edges.some( + (e) => e.source.node_id === s && e.source.field === sf && e.destination.node_id === d && e.destination.field === df + ); + +describe('isRegionalGuidanceSupportedForBase', () => { + it('supports sd-1 / sdxl / flux and nothing else', () => { + expect(isRegionalGuidanceSupportedForBase('sd-1')).toBe(true); + expect(isRegionalGuidanceSupportedForBase('sdxl')).toBe(true); + expect(isRegionalGuidanceSupportedForBase('flux')).toBe(true); + expect(isRegionalGuidanceSupportedForBase('sd-3')).toBe(false); + expect(isRegionalGuidanceSupportedForBase('cogview4')).toBe(false); + }); +}); + +describe('addRegionalGuidance — SD1', () => { + it('wires a mask tensor + positive conditioning into the positive collector, copying clip', () => { + const g = run(sdBaseGraph(), { base: 'sd-1', regions: [region()] }); + const mask = nodesOfType(g, 'alpha_mask_to_tensor'); + expect(mask).toHaveLength(1); + expect(mask[0].image).toEqual({ image_name: 'mask1.png' }); + expect(mask[0].id).toBe('rg_mask_to_tensor_r1'); + + const posCond = g.nodes.rg_pos_cond_r1; + expect(posCond).toBeDefined(); + expect(posCond.type).toBe('compel'); + expect(posCond.prompt).toBe('a cat'); + // mask edge + conditioning into the collector. + expect(hasEdge(g, 'rg_mask_to_tensor_r1', 'mask', 'rg_pos_cond_r1', 'mask')).toBe(true); + expect(hasEdge(g, 'rg_pos_cond_r1', 'conditioning', 'pos_cond_collect', 'item')).toBe(true); + // Copied the global posCond clip edge. + expect(hasEdge(g, 'clip_skip', 'clip', 'rg_pos_cond_r1', 'clip')).toBe(true); + }); + + it('wires a negative prompt into the negative collector', () => { + const g = run(sdBaseGraph(), { base: 'sd-1', regions: [region({ negativePrompt: 'blurry' })] }); + expect(g.nodes.rg_neg_cond_r1.prompt).toBe('blurry'); + expect(hasEdge(g, 'rg_mask_to_tensor_r1', 'mask', 'rg_neg_cond_r1', 'mask')).toBe(true); + expect(hasEdge(g, 'rg_neg_cond_r1', 'conditioning', 'neg_cond_collect', 'item')).toBe(true); + expect(hasEdge(g, 'clip_skip', 'clip', 'rg_neg_cond_r1', 'clip')).toBe(true); + }); + + it('autoNegative inverts the mask and re-encodes the positive prompt into the negative collector', () => { + const g = run(sdBaseGraph(), { base: 'sd-1', regions: [region({ autoNegative: true })] }); + const invert = nodesOfType(g, 'invert_tensor_mask'); + expect(invert).toHaveLength(1); + expect(invert[0].id).toBe('rg_invert_mask_r1'); + expect(hasEdge(g, 'rg_mask_to_tensor_r1', 'mask', 'rg_invert_mask_r1', 'mask')).toBe(true); + // The inverted-cond node uses the POSITIVE prompt but feeds the NEGATIVE collector. + expect(g.nodes.rg_pos_cond_inverted_r1.prompt).toBe('a cat'); + expect(hasEdge(g, 'rg_invert_mask_r1', 'mask', 'rg_pos_cond_inverted_r1', 'mask')).toBe(true); + expect(hasEdge(g, 'rg_pos_cond_inverted_r1', 'conditioning', 'neg_cond_collect', 'item')).toBe(true); + }); + + it('does not autoNegative when there is no positive prompt', () => { + const g = run(sdBaseGraph(), { + base: 'sd-1', + regions: [region({ autoNegative: true, negativePrompt: 'x', positivePrompt: null })], + }); + expect(nodesOfType(g, 'invert_tensor_mask')).toHaveLength(0); + }); + + it('wires a regional ip_adapter reference image mask-scoped into a collector → denoise.ip_adapter', () => { + const g = run(sdBaseGraph(), { + base: 'sd-1', + regions: [ + region({ + referenceImages: [ + { + beginEndStepPct: [0, 1], + clipVisionModel: 'ViT-H', + id: 'ref1', + imageName: 'ref.png', + method: 'full', + model: ipModel('sd-1'), + type: 'ip_adapter', + weight: 0.8, + }, + ], + }), + ], + }); + const ip = g.nodes.ip_adapter_ref1; + expect(ip.type).toBe('ip_adapter'); + expect(ip.weight).toBe(0.8); + expect(ip.image).toEqual({ image_name: 'ref.png' }); + expect(hasEdge(g, 'rg_mask_to_tensor_r1', 'mask', 'ip_adapter_ref1', 'mask')).toBe(true); + // A collector feeds denoise.ip_adapter. + const collectorEdge = g.edges.find( + (e) => e.destination.node_id === 'denoise_latents' && e.destination.field === 'ip_adapter' + ); + expect(collectorEdge).toBeDefined(); + expect(hasEdge(g, 'ip_adapter_ref1', 'ip_adapter', collectorEdge!.source.node_id, 'item')).toBe(true); + }); +}); + +describe('addRegionalGuidance — SDXL', () => { + it('sets prompt + style on the sdxl encoder and copies clip + clip2', () => { + const g = run(sdBaseGraph({ sdxl: true }), { base: 'sdxl', regions: [region()] }); + const posCond = g.nodes.rg_pos_cond_r1; + expect(posCond.type).toBe('sdxl_compel_prompt'); + expect(posCond.prompt).toBe('a cat'); + expect(posCond.style).toBe('a cat'); + expect(hasEdge(g, 'clip_skip', 'clip', 'rg_pos_cond_r1', 'clip')).toBe(true); + expect(hasEdge(g, 'model_loader', 'clip2', 'rg_pos_cond_r1', 'clip2')).toBe(true); + }); +}); + +describe('addRegionalGuidance — FLUX', () => { + it('wires a positive prompt into pos_cond_collect and copies clip/t5, no negative path', () => { + const g = run(fluxBaseGraph(), { base: 'flux', regions: [region({ negativePrompt: 'blurry' })] }); + expect(g.nodes.rg_pos_cond_r1.type).toBe('flux_text_encoder'); + expect(hasEdge(g, 'rg_pos_cond_r1', 'conditioning', 'pos_cond_collect', 'item')).toBe(true); + expect(hasEdge(g, 'model_loader', 't5_encoder', 'rg_pos_cond_r1', 't5_encoder')).toBe(true); + // No regional negative conditioning on FLUX. + expect(g.nodes.rg_neg_cond_r1).toBeUndefined(); + }); + + it('ignores regional ip_adapter on FLUX and does not wire denoise.ip_adapter', () => { + const g = run(fluxBaseGraph(), { + base: 'flux', + regions: [ + region({ + referenceImages: [ + { + beginEndStepPct: [0, 1], + clipVisionModel: 'ViT-H', + id: 'ref1', + imageName: 'ref.png', + method: 'full', + model: ipModel('flux'), + type: 'ip_adapter', + weight: 1, + }, + ], + }), + ], + }); + expect(g.nodes.ip_adapter_ref1).toBeUndefined(); + }); + + it('wires a flux_redux reference image mask-scoped into denoise.redux_conditioning', () => { + const g = run(fluxBaseGraph(), { + base: 'flux', + regions: [ + region({ + referenceImages: [ + { + id: 'ref2', + imageName: 'redux.png', + model: { base: 'flux', key: 'redux', name: 'Redux', type: 'flux_redux' }, + settings: { downsampling_factor: 1, weight: 1 }, + type: 'flux_redux', + }, + ], + }), + ], + }); + const redux = g.nodes.flux_redux_ref2; + expect(redux.type).toBe('flux_redux'); + expect(hasEdge(g, 'rg_mask_to_tensor_r1', 'mask', 'flux_redux_ref2', 'mask')).toBe(true); + const reduxEdge = g.edges.find( + (e) => e.destination.node_id === 'denoise_latents' && e.destination.field === 'redux_conditioning' + ); + expect(reduxEdge).toBeDefined(); + expect(hasEdge(g, 'flux_redux_ref2', 'redux_cond', reduxEdge!.source.node_id, 'item')).toBe(true); + }); +}); + +describe('addRegionalGuidance — multiple regions coexist', () => { + it('mints distinct deterministic node ids per region', () => { + const g = run(sdBaseGraph(), { + base: 'sd-1', + regions: [region({ id: 'a', positivePrompt: 'cat' }), region({ id: 'b', positivePrompt: 'dog' })], + }); + expect(g.nodes.rg_pos_cond_a.prompt).toBe('cat'); + expect(g.nodes.rg_pos_cond_b.prompt).toBe('dog'); + expect(nodesOfType(g, 'alpha_mask_to_tensor')).toHaveLength(2); + }); +}); + +describe('getRegionalGuidanceRejectionReason', () => { + const params = { + autoNegative: false, + hasContent: true, + layerName: 'Region 1', + mainBase: 'sd-1', + negativePrompt: null as string | null, + positivePrompt: 'a cat' as string | null, + referenceImageCount: 0, + }; + + it('accepts a valid SD region', () => { + expect(getRegionalGuidanceRejectionReason(params)).toBeNull(); + }); + + it('rejects an unsupported base', () => { + expect(getRegionalGuidanceRejectionReason({ ...params, mainBase: 'sd-3' })).toMatch(/not supported/); + }); + + it('rejects an empty mask', () => { + expect(getRegionalGuidanceRejectionReason({ ...params, hasContent: false })).toMatch(/no masked region/); + }); + + it('rejects a region with no prompt and no reference images', () => { + expect(getRegionalGuidanceRejectionReason({ ...params, positivePrompt: null, referenceImageCount: 0 })).toMatch( + /no prompt or reference/ + ); + }); + + it('rejects a FLUX negative prompt / autoNegative', () => { + expect(getRegionalGuidanceRejectionReason({ ...params, mainBase: 'flux', negativePrompt: 'blurry' })).toMatch( + /negative prompts are not supported for FLUX/ + ); + expect(getRegionalGuidanceRejectionReason({ ...params, autoNegative: true, mainBase: 'flux' })).toMatch( + /auto-negative is not supported for FLUX/ + ); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/generation/canvas/addRegionalGuidance.ts b/invokeai/frontend/webv2/src/workbench/generation/canvas/addRegionalGuidance.ts new file mode 100644 index 00000000000..e282b4a15b2 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/generation/canvas/addRegionalGuidance.ts @@ -0,0 +1,356 @@ +/** + * Pure regional-guidance graph grafting (legacy parity). + * + * {@link addRegionalGuidance} wires each enabled, valid regional-guidance region + * into the canvas base graph's conditioning collectors: a per-region + * `alpha_mask_to_tensor` node (fed by the region's uploaded mask image) supplies + * a mask tensor that scopes per-region positive/negative prompt conditioning + * (into `pos_cond_collect` / `neg_cond_collect`), an `autoNegative` inverted-mask + * negative conditioning, and mask-scoped reference images (SD `ip_adapter`, + * FLUX `flux_redux`). It mutates the graph in place using the same + * `addNode`/`addEdge` helpers the base builders use — no fetch, no engine, no + * React. Node types + args mirror legacy + * `features/nodes/util/graph/generation/addRegions.ts`. + * + * ## Support matrix (legacy `getRegionalGuidanceWarnings`) + * + * - **sd-1 / sdxl**: full support — positive, negative, autoNegative, regional + * `ip_adapter` reference images. + * - **flux**: positive prompt + `flux_redux` reference images only. Negative + * prompt, autoNegative, and regional `ip_adapter` are NOT supported (silently + * skipped here; surfaced as a rejection reason upstream). + * - Every other base is unsupported (region skipped; rejection reason emitted). + * + * The caller (`prepareCanvasInvocation`) composites + uploads each region's mask + * separately and resolves reference-image models, then passes only valid regions + * in — this module only shapes nodes/edges. + */ + +import type { BackendGraphContract, BackendInvocationContract } from '@workbench/types'; + +import { addEdge, addNode } from '@workbench/generation/graphBuilder'; + +/** The deterministic denoise node id every canvas base graph uses. */ +const DENOISE_NODE_ID = 'denoise_latents'; +/** The deterministic global positive/negative conditioning + collector node ids. */ +const POS_COND_ID = 'pos_cond'; +const NEG_COND_ID = 'neg_cond'; +const POS_COND_COLLECT_ID = 'pos_cond_collect'; +const NEG_COND_COLLECT_ID = 'neg_cond_collect'; + +/** The base models regional guidance supports (legacy: sd-1 / sdxl / flux). */ +export type RegionalGuidanceBase = 'sd-1' | 'sdxl' | 'flux'; + +/** True when `base` supports regional guidance at all (SD1 / SDXL / FLUX). */ +export const isRegionalGuidanceSupportedForBase = (base: string): base is RegionalGuidanceBase => + base === 'sd-1' || base === 'sdxl' || base === 'flux'; + +/** Whether a base supports regional NEGATIVE prompts / autoNegative (SD family only, not FLUX). */ +const supportsRegionalNegative = (base: RegionalGuidanceBase): boolean => base === 'sd-1' || base === 'sdxl'; + +/** A resolved reference-image (component) model identifier — the backend model field shape. */ +export interface RegionalReferenceModel { + key: string; + hash?: string; + name: string; + base: string; + type: string; +} + +/** A regional `ip_adapter` reference image (SD1 / SDXL). */ +export interface RegionalIPAdapterInput { + type: 'ip_adapter'; + /** The reference-image id (mints the deterministic `ip_adapter_${id}` node id). */ + id: string; + imageName: string; + model: RegionalReferenceModel; + weight: number; + method: string; + clipVisionModel: string; + beginEndStepPct: [number, number]; +} + +/** A regional `flux_redux` reference image (FLUX). */ +export interface RegionalFluxReduxInput { + type: 'flux_redux'; + /** The reference-image id (mints the deterministic `flux_redux_${id}` node id). */ + id: string; + imageName: string; + model: RegionalReferenceModel; + /** Backend redux knobs (downsampling_factor + weight), resolved from imageInfluence upstream. */ + settings: { downsampling_factor: number; weight: number }; +} + +/** One resolved regional reference image. */ +export type RegionalReferenceImageInput = RegionalIPAdapterInput | RegionalFluxReduxInput; + +/** One fully-resolved region's graph contribution (invalid regions filtered out first). */ +export interface RegionalGuidanceInput { + /** The document layer id (mints deterministic node ids). */ + id: string; + /** The uploaded per-region mask image name (alpha = region coverage). */ + maskImageName: string; + positivePrompt: string | null; + negativePrompt: string | null; + autoNegative: boolean; + referenceImages: readonly RegionalReferenceImageInput[]; +} + +/** Options for {@link addRegionalGuidance}. */ +export interface AddRegionalGuidanceOptions { + base: RegionalGuidanceBase; + regions: readonly RegionalGuidanceInput[]; +} + +/** Per-base conditioning encoder node type + the fields carrying the prompt. */ +const conditioningNodeType = (base: RegionalGuidanceBase): string => { + switch (base) { + case 'sdxl': + return 'sdxl_compel_prompt'; + case 'flux': + return 'flux_text_encoder'; + case 'sd-1': + return 'compel'; + } +}; + +/** The prompt input fields to set on a regional conditioning node (SDXL mirrors prompt→style). */ +const promptFields = (base: RegionalGuidanceBase): readonly string[] => + base === 'sdxl' ? ['prompt', 'style'] : ['prompt']; + +/** + * The encoder-input fields to COPY from the global conditioning node onto a + * regional one, so the region shares the same CLIP / T5 encoders (legacy copies + * the CLIP/T5 edges verbatim). `mask` is wired separately, so it's excluded here. + */ +const copyEncoderFields = (base: RegionalGuidanceBase): readonly string[] => { + switch (base) { + case 'sdxl': + return ['clip', 'clip2']; + case 'flux': + return ['clip', 't5_encoder', 't5_max_seq_len']; + case 'sd-1': + return ['clip']; + } +}; + +/** + * Copies every edge feeding `sourceNodeId`'s `fields` onto `target` (same source, + * same field). Used to share the global conditioning node's CLIP/T5 encoder + * inputs with a per-region conditioning node. + */ +const copyEncoderEdges = ( + graph: BackendGraphContract, + sourceNodeId: string, + target: BackendInvocationContract, + fields: readonly string[] +): void => { + const fieldSet = new Set(fields); + for (const edge of graph.edges) { + if (edge.destination.node_id !== sourceNodeId || !fieldSet.has(edge.destination.field)) { + continue; + } + graph.edges.push({ + destination: { field: edge.destination.field, node_id: target.id }, + source: { field: edge.source.field, node_id: edge.source.node_id }, + }); + } +}; + +/** Resolves (or lazily creates) the collector feeding `denoise.`, with a stable fallback id. */ +const resolveDenoiseCollector = ( + graph: BackendGraphContract, + denoise: BackendInvocationContract, + denoiseField: string, + fallbackId: string +): BackendInvocationContract => { + const existing = graph.edges.find( + (edge) => edge.destination.node_id === denoise.id && edge.destination.field === denoiseField + ); + if (existing) { + const node = graph.nodes[existing.source.node_id]; + if (node) { + return node; + } + } + const collector = addNode(graph, { id: fallbackId, type: 'collect' }); + addEdge(graph, collector, 'collection', denoise, denoiseField); + return collector; +}; + +/** Builds a per-region conditioning node with its prompt set and encoder edges copied. */ +const addRegionalConditioning = ( + graph: BackendGraphContract, + base: RegionalGuidanceBase, + nodeId: string, + prompt: string, + copyFrom: string +): BackendInvocationContract => { + const node = addNode(graph, { id: nodeId, type: conditioningNodeType(base) }); + for (const field of promptFields(base)) { + (node as Record)[field] = prompt; + } + copyEncoderEdges(graph, copyFrom, node, copyEncoderFields(base)); + return node; +}; + +/** + * Grafts regional guidance onto a built canvas base graph. Assumes every input is + * already validated for `base` (supported base, non-empty region, resolved + * reference-image models) — use {@link getRegionalGuidanceRejectionReason} to + * filter first. Wires, per enabled region: + * - `alpha_mask_to_tensor` from the uploaded region mask; + * - positive prompt → regional conditioning → `pos_cond_collect`; + * - negative prompt (SD only) → regional conditioning → `neg_cond_collect`; + * - autoNegative (SD only) → `invert_tensor_mask` + positive prompt re-encoded → + * `neg_cond_collect` (push the positive prompt away outside the region); + * - reference images → mask-scoped `ip_adapter` (SD) / `flux_redux` (FLUX). + */ +export const addRegionalGuidance = (graph: BackendGraphContract, options: AddRegionalGuidanceOptions): void => { + const { base, regions } = options; + const denoise = graph.nodes[DENOISE_NODE_ID]; + if (!denoise) { + throw new Error('addRegionalGuidance: base graph is missing the denoise node.'); + } + const posCondCollect = graph.nodes[POS_COND_COLLECT_ID]; + if (!posCondCollect) { + throw new Error('addRegionalGuidance: base graph is missing the positive conditioning collector.'); + } + const negCondCollect = graph.nodes[NEG_COND_COLLECT_ID] ?? null; + const withNegative = supportsRegionalNegative(base); + + let ipAdapterCollector: BackendInvocationContract | null = null; + let fluxReduxCollector: BackendInvocationContract | null = null; + + for (const region of regions) { + const maskToTensor = addNode(graph, { + id: `rg_mask_to_tensor_${region.id}`, + image: { image_name: region.maskImageName }, + type: 'alpha_mask_to_tensor', + }); + + // Positive prompt → positive collector (mask-scoped). + if (region.positivePrompt) { + const posCond = addRegionalConditioning( + graph, + base, + `rg_pos_cond_${region.id}`, + region.positivePrompt, + POS_COND_ID + ); + addEdge(graph, maskToTensor, 'mask', posCond, 'mask'); + addEdge(graph, posCond, 'conditioning', posCondCollect, 'item'); + } + + // Negative prompt → negative collector (SD only; FLUX has no negative path). + if (region.negativePrompt && withNegative && negCondCollect) { + const negCond = addRegionalConditioning( + graph, + base, + `rg_neg_cond_${region.id}`, + region.negativePrompt, + NEG_COND_ID + ); + addEdge(graph, maskToTensor, 'mask', negCond, 'mask'); + addEdge(graph, negCond, 'conditioning', negCondCollect, 'item'); + } + + // autoNegative: re-encode the POSITIVE prompt over the INVERTED mask into the + // negative collector — pushes the region's prompt away everywhere outside it. + if (region.autoNegative && region.positivePrompt && withNegative && negCondCollect) { + const invert = addNode(graph, { id: `rg_invert_mask_${region.id}`, type: 'invert_tensor_mask' }); + addEdge(graph, maskToTensor, 'mask', invert, 'mask'); + const inverted = addRegionalConditioning( + graph, + base, + `rg_pos_cond_inverted_${region.id}`, + region.positivePrompt, + POS_COND_ID + ); + addEdge(graph, invert, 'mask', inverted, 'mask'); + addEdge(graph, inverted, 'conditioning', negCondCollect, 'item'); + } + + // Reference images (mask-scoped): ip_adapter on SD, flux_redux on FLUX. + for (const ref of region.referenceImages) { + if (ref.type === 'ip_adapter' && base !== 'flux') { + if (!ipAdapterCollector) { + ipAdapterCollector = resolveDenoiseCollector(graph, denoise, 'ip_adapter', 'regional_ip_adapter_collector'); + } + const node = addNode(graph, { + begin_step_percent: ref.beginEndStepPct[0], + clip_vision_model: ref.clipVisionModel, + end_step_percent: ref.beginEndStepPct[1], + id: `ip_adapter_${ref.id}`, + image: { image_name: ref.imageName }, + ip_adapter_model: ref.model, + method: ref.method, + type: 'ip_adapter', + weight: ref.weight, + }); + addEdge(graph, maskToTensor, 'mask', node, 'mask'); + addEdge(graph, node, 'ip_adapter', ipAdapterCollector, 'item'); + } else if (ref.type === 'flux_redux' && base === 'flux') { + if (!fluxReduxCollector) { + fluxReduxCollector = resolveDenoiseCollector( + graph, + denoise, + 'redux_conditioning', + 'regional_flux_redux_collector' + ); + } + const node = addNode(graph, { + downsampling_factor: ref.settings.downsampling_factor, + id: `flux_redux_${ref.id}`, + image: { image_name: ref.imageName }, + redux_model: ref.model, + type: 'flux_redux', + weight: ref.settings.weight, + }); + addEdge(graph, maskToTensor, 'mask', node, 'mask'); + addEdge(graph, node, 'redux_cond', fluxReduxCollector, 'item'); + } + } + } +}; + +/** + * Returns the legacy-parity rejection reason for a regional-guidance region, or + * `null` when it can contribute to generation. Mirrors + * `getRegionalGuidanceWarnings`: + * - unsupported main base (sd-2 / sd-3 / cogview / …) → "unsupported model"; + * - no drawn mask content → "no region"; + * - no positive prompt, no negative prompt, and no reference images → "empty"; + * - FLUX with a negative prompt / autoNegative → those are unsupported on FLUX. + * + * Per-reference-image model/image validity is resolved by the caller (which drops + * incomplete reference images before building), matching how control layers work. + */ +export const getRegionalGuidanceRejectionReason = (params: { + layerName: string; + mainBase: string; + hasContent: boolean; + positivePrompt: string | null; + negativePrompt: string | null; + autoNegative: boolean; + referenceImageCount: number; +}): string | null => { + const { autoNegative, hasContent, layerName, mainBase, negativePrompt, positivePrompt, referenceImageCount } = params; + + if (!isRegionalGuidanceSupportedForBase(mainBase)) { + return `Regional guidance "${layerName}" is not supported for the selected base model.`; + } + if (!hasContent) { + return `Regional guidance "${layerName}" has no masked region.`; + } + if (!positivePrompt && !negativePrompt && referenceImageCount === 0) { + return `Regional guidance "${layerName}" has no prompt or reference image.`; + } + if (mainBase === 'flux' && negativePrompt) { + return `Regional guidance "${layerName}" negative prompts are not supported for FLUX.`; + } + if (mainBase === 'flux' && autoNegative) { + return `Regional guidance "${layerName}" auto-negative is not supported for FLUX.`; + } + return null; +}; diff --git a/invokeai/frontend/webv2/src/workbench/generation/canvas/canvasMode.test.ts b/invokeai/frontend/webv2/src/workbench/generation/canvas/canvasMode.test.ts new file mode 100644 index 00000000000..9b831134a96 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/generation/canvas/canvasMode.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; + +import type { CanvasModeInput } from './canvasMode'; +import type { Rect } from './types'; + +import { detectCanvasMode } from './canvasMode'; + +const BBOX: Rect = { height: 100, width: 100, x: 0, y: 0 }; + +const input = (overrides: Partial = {}): CanvasModeInput => ({ + bbox: BBOX, + bboxFullyCovered: false, + contentBounds: null, + hasActiveInpaintMask: false, + ...overrides, +}); + +describe('detectCanvasMode', () => { + describe('txt2img', () => { + it('returns txt2img when there is no content at all', () => { + expect(detectCanvasMode(input({ contentBounds: null }))).toBe('txt2img'); + }); + + it('returns txt2img when content lies entirely outside the bbox', () => { + const contentBounds: Rect = { height: 50, width: 50, x: 200, y: 200 }; + expect(detectCanvasMode(input({ contentBounds }))).toBe('txt2img'); + }); + + it('treats a flush edge (0px interior overlap) as no intersection → txt2img', () => { + // Content's left edge sits exactly on the bbox's right edge (x = 100). + const contentBounds: Rect = { height: 100, width: 50, x: 100, y: 0 }; + expect(detectCanvasMode(input({ contentBounds }))).toBe('txt2img'); + }); + + it('returns txt2img for a zero-width bbox even when content overlaps its line', () => { + const bbox: Rect = { height: 100, width: 0, x: 0, y: 0 }; + const contentBounds: Rect = { height: 100, width: 100, x: 0, y: 0 }; + expect(detectCanvasMode(input({ bbox, bboxFullyCovered: true, contentBounds }))).toBe('txt2img'); + }); + + it('returns txt2img for a zero-height bbox', () => { + const bbox: Rect = { height: 0, width: 100, x: 0, y: 0 }; + const contentBounds: Rect = { height: 100, width: 100, x: 0, y: 0 }; + expect(detectCanvasMode(input({ bbox, bboxFullyCovered: true, contentBounds }))).toBe('txt2img'); + }); + + it('stays txt2img for a new canvas seeded with only an empty inpaint mask', () => { + // A brand-new canvas carries one empty inpaint mask (see + // `createNewCanvasStateV2`). An empty mask contributes no raster content + // (`contentBounds` stays null) and has no content (`hasActiveInpaintMask` + // is false), so mode detection must NOT flip to inpaint. + expect(detectCanvasMode(input({ contentBounds: null, hasActiveInpaintMask: false }))).toBe('txt2img'); + }); + }); + + describe('outpaint', () => { + it('returns outpaint when content intersects but does not fully cover the bbox', () => { + const contentBounds: Rect = { height: 100, width: 50, x: 0, y: 0 }; + expect(detectCanvasMode(input({ bboxFullyCovered: false, contentBounds }))).toBe('outpaint'); + }); + + it('counts a 1px interior overlap as intersecting → outpaint', () => { + // Content's left edge at x = 99: a 1px column overlaps the bbox interior. + const contentBounds: Rect = { height: 100, width: 50, x: 99, y: 0 }; + expect(detectCanvasMode(input({ bboxFullyCovered: false, contentBounds }))).toBe('outpaint'); + }); + + it('returns outpaint even with an active mask when coverage is incomplete', () => { + const contentBounds: Rect = { height: 100, width: 50, x: 0, y: 0 }; + expect(detectCanvasMode(input({ bboxFullyCovered: false, contentBounds, hasActiveInpaintMask: true }))).toBe( + 'outpaint' + ); + }); + }); + + describe('img2img', () => { + it('returns img2img when content fully covers the bbox and there is no mask', () => { + const contentBounds: Rect = { height: 100, width: 100, x: 0, y: 0 }; + expect(detectCanvasMode(input({ bboxFullyCovered: true, contentBounds, hasActiveInpaintMask: false }))).toBe( + 'img2img' + ); + }); + + it('returns img2img when content extends past the bbox but covers it fully', () => { + const contentBounds: Rect = { height: 200, width: 200, x: -50, y: -50 }; + expect(detectCanvasMode(input({ bboxFullyCovered: true, contentBounds }))).toBe('img2img'); + }); + }); + + describe('inpaint', () => { + it('returns inpaint when content fully covers the bbox and a mask is active', () => { + const contentBounds: Rect = { height: 100, width: 100, x: 0, y: 0 }; + expect(detectCanvasMode(input({ bboxFullyCovered: true, contentBounds, hasActiveInpaintMask: true }))).toBe( + 'inpaint' + ); + }); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/generation/canvas/canvasMode.ts b/invokeai/frontend/webv2/src/workbench/generation/canvas/canvasMode.ts new file mode 100644 index 00000000000..d9ca60fb12a --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/generation/canvas/canvasMode.ts @@ -0,0 +1,78 @@ +/** + * Pure canvas generation-mode detection. + * + * Given only plain geometry/coverage facts the engine + composite executor + * already computed, {@link detectCanvasMode} reproduces the legacy canvas + * behavior of picking txt2img / img2img / inpaint / outpaint. It touches no + * pixels and imports nothing from the engine — every input is precomputed and + * passed in, so the whole decision table is exhaustively node-testable. + * + * Rules (legacy parity): + * - No enabled raster content intersects the bbox → `txt2img`. + * - Content opaquely covers the whole bbox, no active inpaint mask → `img2img`. + * - Content opaquely covers the whole bbox, active inpaint mask → `inpaint`. + * - The bbox extends past content, or has transparent holes inside it (i.e. + * content intersects but does not fully cover) → `outpaint`. + */ + +import type { CanvasGenerationMode, Rect } from './types'; + +/** The precomputed facts {@link detectCanvasMode} decides from. */ +export interface CanvasModeInput { + /** The generation bounding box, in document space. */ + bbox: Rect; + /** + * Union of enabled raster-layer content bounds in document space, or `null` + * when no enabled raster content exists. + */ + contentBounds: Rect | null; + /** + * Whether enabled raster content opaquely covers the entire bbox (no + * transparent holes) — the alpha scan of the composited bbox surface. + */ + bboxFullyCovered: boolean; + /** + * Whether an enabled inpaint-mask layer with content exists. Always `false` + * in Phase 4.2 (the contract type exists but no mask is produced yet). + */ + hasActiveInpaintMask: boolean; +} + +/** + * True when two rects share interior area. Edge-touching or a shared corner is + * NOT an intersection (strict `<`), matching the engine's `rect.intersect` + * semantics, so a 1px edge overlap counts but a flush edge does not. + * + * Exported so the invoke orchestrator can run a bounds-only pre-pass: content + * that doesn't overlap the bbox means txt2img, which needs no composite upload. + */ +export const rectsIntersect = (a: Rect, b: Rect): boolean => { + if (a.width <= 0 || a.height <= 0 || b.width <= 0 || b.height <= 0) { + return false; + } + return a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height; +}; + +/** Resolves the generation mode for a canvas invoke from precomputed facts. */ +export const detectCanvasMode = (input: CanvasModeInput): CanvasGenerationMode => { + const { bbox, bboxFullyCovered, contentBounds, hasActiveInpaintMask } = input; + + // A degenerate bbox can never contain content: nothing to reference. + if (bbox.width <= 0 || bbox.height <= 0) { + return 'txt2img'; + } + + // No enabled raster content touches the bbox: pure text-to-image. + if (!contentBounds || !rectsIntersect(contentBounds, bbox)) { + return 'txt2img'; + } + + // Content opaquely fills the whole bbox: img2img, or inpaint when masked. + if (bboxFullyCovered) { + return hasActiveInpaintMask ? 'inpaint' : 'img2img'; + } + + // Content intersects but leaves the bbox partly transparent (extends past + // content, or has holes): outpaint. + return 'outpaint'; +}; diff --git a/invokeai/frontend/webv2/src/workbench/generation/canvas/compileCanvasGraph.test.ts b/invokeai/frontend/webv2/src/workbench/generation/canvas/compileCanvasGraph.test.ts new file mode 100644 index 00000000000..62be027f07b --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/generation/canvas/compileCanvasGraph.test.ts @@ -0,0 +1,723 @@ +import type { + ComponentModelConfig, + GenerateModelConfig, + GenerateSettings, + MainModelConfig, + VaeModelConfig, +} from '@workbench/generation/types'; +import type { BackendGraphContract, ProjectSettings } from '@workbench/types'; +import type { CanvasCompositingSettings } from '@workbench/widgets/canvas/invoke/canvasCompositing'; + +import { getDefaultGenerateSettings } from '@workbench/generation/baseGenerationPolicies'; +import { DEFAULT_CANVAS_COMPOSITING } from '@workbench/widgets/canvas/invoke/canvasCompositing'; +import { describe, expect, it } from 'vitest'; + +import type { ControlLayerGraphInput } from './addControlLayers'; +import type { RegionalGuidanceInput } from './addRegionalGuidance'; +import type { CanvasCompileMode, Rect } from './types'; + +import { compileCanvasGraph } from './compileCanvasGraph'; + +const sd1Model: MainModelConfig = { base: 'sd-1', key: 'sd1-model', name: 'SD 1.5', type: 'main' }; +const sd2Model: MainModelConfig = { base: 'sd-2', key: 'sd2-model', name: 'SD 2', type: 'main' }; +const sdxlModel: MainModelConfig = { base: 'sdxl', key: 'sdxl-model', name: 'SDXL', type: 'main' }; +const sd3Model: MainModelConfig = { base: 'sd-3', key: 'sd3-model', name: 'SD3', type: 'main' }; +const fluxModel: MainModelConfig = { base: 'flux', key: 'flux-model', name: 'FLUX dev', type: 'main' }; +const flux2Model: MainModelConfig = { + base: 'flux2', + format: 'diffusers', + key: 'flux2-model', + name: 'FLUX.2', + type: 'main', +}; +const cogView4Model: MainModelConfig = { base: 'cogview4', key: 'cog-model', name: 'CogView4', type: 'main' }; +const qwenImageModel: MainModelConfig = { + base: 'qwen-image', + format: 'diffusers', + key: 'qwen-model', + name: 'Qwen Image', + type: 'main', +}; +const zImageModel: MainModelConfig = { + base: 'z-image', + format: 'diffusers', + key: 'z-model', + name: 'Z-Image', + type: 'main', +}; +const animaModel: MainModelConfig = { base: 'anima', key: 'anima-model', name: 'Anima', type: 'main' }; +const externalModel: GenerateModelConfig = { + base: 'external', + capabilities: { modes: ['txt2img'], supports_seed: true }, + format: 'external_api', + key: 'external-model', + name: 'OpenAI Image', + provider_id: 'openai', + type: 'external_image_generator', +}; + +const fluxVae: VaeModelConfig = { base: 'flux', key: 'flux-vae', name: 'FLUX VAE', type: 'vae' }; +const qwenImageVae: VaeModelConfig = { base: 'qwen-image', key: 'qwen-vae', name: 'Qwen VAE', type: 'vae' }; +const t5Encoder: ComponentModelConfig = { base: 'any', key: 't5', name: 'T5 Encoder', type: 't5_encoder' }; +const clipEmbed: ComponentModelConfig = { base: 'any', key: 'clip', name: 'CLIP Embed', type: 'clip_embed' }; +const qwen3Encoder: ComponentModelConfig = { + base: 'any', + key: 'qwen3', + name: 'Qwen3 Encoder', + type: 'qwen3_encoder', + variant: 'qwen3_06b', +}; + +const PROJECT_SETTINGS: Pick = { useCpuNoise: true }; + +const settingsFor = (model: GenerateModelConfig, overrides: Partial = {}): GenerateSettings => ({ + ...getDefaultGenerateSettings(model), + seed: 1, + shouldRandomizeSeed: false, + ...overrides, +}); + +/** Per-base overrides needed to pass component validation. */ +const COMPONENT_OVERRIDES: Partial>> = { + flux: { clipEmbedModel: clipEmbed, t5EncoderModel: t5Encoder, vae: fluxVae }, + anima: { qwen3EncoderModel: qwen3Encoder, vae: qwenImageVae }, +}; + +const bbox: Rect = { height: 1024, width: 768, x: 128, y: 64 }; + +const compile = ( + model: GenerateModelConfig, + mode: CanvasCompileMode, + overrides: { + settings?: Partial; + bbox?: Rect; + compositeImageName?: string | null; + maskImageName?: string | null; + noiseMaskImageName?: string | null; + compositing?: Partial; + strength?: number; + destination?: 'canvas' | 'gallery'; + controlLayers?: readonly ControlLayerGraphInput[]; + regionalGuidance?: readonly RegionalGuidanceInput[]; + } = {} +) => + compileCanvasGraph({ + bbox: overrides.bbox ?? bbox, + compositeImageName: + 'compositeImageName' in overrides ? (overrides.compositeImageName ?? null) : 'canvas-composite.png', + compositing: overrides.compositing + ? { ...DEFAULT_CANVAS_COMPOSITING, ...overrides.compositing } + : DEFAULT_CANVAS_COMPOSITING, + controlLayers: overrides.controlLayers, + regionalGuidance: overrides.regionalGuidance, + destination: overrides.destination ?? 'canvas', + maskImageName: + 'maskImageName' in overrides ? overrides.maskImageName : mode === 'inpaint' ? 'canvas-mask.png' : null, + mode, + model, + noiseMaskImageName: overrides.noiseMaskImageName ?? null, + projectSettings: PROJECT_SETTINGS, + settings: settingsFor(model, { + ...COMPONENT_OVERRIDES[model.base], + ...overrides.settings, + }), + strength: overrides.strength ?? 0.6, + }); + +const getEdge = (graph: BackendGraphContract, targetNodeId: string, targetField: string) => + graph.edges.find((edge) => edge.destination.node_id === targetNodeId && edge.destination.field === targetField); + +const getNodeByType = (graph: BackendGraphContract, type: string) => + Object.values(graph.nodes).find((node) => node.type === type); + +interface BaseCase { + model: MainModelConfig; + encodeType: string; + outputType: string; + denoiseType: string; + txt2imgMode: string; + img2imgMode: string; + /** sd-3 / flux / flux2 rescale strength with an exponent of 0.2 (legacy parity). */ + optimizedDenoising: boolean; +} + +/** Legacy-equivalent denoising_start for a strength on a given base. */ +const expectedDenoisingStart = (strength: number, optimizedDenoising: boolean): number => + 1 - strength ** (optimizedDenoising ? 0.2 : 1); + +const BASE_CASES: BaseCase[] = [ + { + model: sd1Model, + encodeType: 'i2l', + outputType: 'l2i', + denoiseType: 'denoise_latents', + txt2imgMode: 'txt2img', + img2imgMode: 'img2img', + optimizedDenoising: false, + }, + { + model: sd2Model, + encodeType: 'i2l', + outputType: 'l2i', + denoiseType: 'denoise_latents', + txt2imgMode: 'txt2img', + img2imgMode: 'img2img', + optimizedDenoising: false, + }, + { + model: sdxlModel, + encodeType: 'i2l', + outputType: 'l2i', + denoiseType: 'denoise_latents', + txt2imgMode: 'sdxl_txt2img', + img2imgMode: 'sdxl_img2img', + optimizedDenoising: false, + }, + { + model: sd3Model, + encodeType: 'sd3_i2l', + outputType: 'sd3_l2i', + denoiseType: 'sd3_denoise', + txt2imgMode: 'sd3_txt2img', + img2imgMode: 'sd3_img2img', + optimizedDenoising: true, + }, + { + model: fluxModel, + encodeType: 'flux_vae_encode', + outputType: 'flux_vae_decode', + denoiseType: 'flux_denoise', + txt2imgMode: 'flux_txt2img', + img2imgMode: 'flux_img2img', + optimizedDenoising: true, + }, + { + model: flux2Model, + encodeType: 'flux2_vae_encode', + outputType: 'flux2_vae_decode', + denoiseType: 'flux2_denoise', + txt2imgMode: 'flux2_txt2img', + img2imgMode: 'flux2_img2img', + optimizedDenoising: true, + }, + { + model: cogView4Model, + encodeType: 'cogview4_i2l', + outputType: 'cogview4_l2i', + denoiseType: 'cogview4_denoise', + txt2imgMode: 'cogview4_txt2img', + img2imgMode: 'cogview4_img2img', + optimizedDenoising: false, + }, + { + model: qwenImageModel, + encodeType: 'qwen_image_i2l', + outputType: 'qwen_image_l2i', + denoiseType: 'qwen_image_denoise', + txt2imgMode: 'qwen_image_txt2img', + img2imgMode: 'qwen_image_img2img', + optimizedDenoising: false, + }, + { + model: zImageModel, + encodeType: 'z_image_i2l', + outputType: 'z_image_l2i', + denoiseType: 'z_image_denoise', + txt2imgMode: 'z_image_txt2img', + img2imgMode: 'z_image_img2img', + optimizedDenoising: false, + }, + { + model: animaModel, + encodeType: 'anima_i2l', + outputType: 'anima_l2i', + denoiseType: 'anima_denoise', + txt2imgMode: 'anima_txt2img', + img2imgMode: 'anima_img2img', + optimizedDenoising: false, + }, +]; + +describe('compileCanvasGraph', () => { + describe('txt2img per base', () => { + it.each(BASE_CASES)( + 'builds a $model.base txt2img graph sized to the bbox', + ({ model, outputType, txt2imgMode }) => { + const { backendGraph, graph, mode } = compile(model, 'txt2img'); + + // No img2img encode node in a pure txt2img graph. + expect(getNodeByType(backendGraph, 'i2l')).toBeUndefined(); + expect(getNodeByType(backendGraph, `${model.base}_i2l`)).toBeUndefined(); + expect(getNodeByType(backendGraph, `${model.base}_vae_encode`)).toBeUndefined(); + + // Canvas destination: intermediate output of the base builder's decode type. + expect(backendGraph.nodes.canvas_output?.type).toBe(outputType); + expect(backendGraph.nodes.canvas_output?.is_intermediate).toBe(true); + + // Deterministic prompt/seed node ids are present. + expect(backendGraph.nodes.positive_prompt).toBeDefined(); + expect(backendGraph.nodes.seed).toBeDefined(); + + // Dimensions come from the bbox, not the settings. + expect(getNodeByType(backendGraph, 'core_metadata')?.width).toBe(bbox.width); + expect(getNodeByType(backendGraph, 'core_metadata')?.height).toBe(bbox.height); + expect(getNodeByType(backendGraph, 'core_metadata')?.generation_mode).toBe(txt2imgMode); + + expect(mode).toBe('txt2img'); + expect(graph.label).toBe(`${model.name} txt2img`); + } + ); + + it('applies bbox dimensions to the SD noise node', () => { + const { backendGraph } = compile(sd1Model, 'txt2img'); + + expect(backendGraph.nodes.noise?.width).toBe(bbox.width); + expect(backendGraph.nodes.noise?.height).toBe(bbox.height); + }); + + it('produces a durable (non-intermediate) output for a Gallery destination', () => { + // Canvas destination stages an intermediate; Gallery must be a durable image. + expect(compile(sd1Model, 'txt2img').backendGraph.nodes.canvas_output?.is_intermediate).toBe(true); + expect( + compile(sd1Model, 'txt2img', { destination: 'gallery' }).backendGraph.nodes.canvas_output?.is_intermediate + ).toBe(false); + }); + + it('applies bbox dimensions to a flow-model denoise node', () => { + const { backendGraph } = compile(fluxModel, 'txt2img'); + + expect(backendGraph.nodes.denoise_latents?.width).toBe(bbox.width); + expect(backendGraph.nodes.denoise_latents?.height).toBe(bbox.height); + }); + }); + + describe('img2img per base', () => { + it.each(BASE_CASES)( + 'grafts a $model.base image-to-latents encode node', + ({ model, encodeType, denoiseType, img2imgMode, optimizedDenoising }) => { + const { backendGraph, mode } = compile(model, 'img2img', { strength: 0.6 }); + + const encode = getNodeByType(backendGraph, encodeType); + expect(encode).toBeDefined(); + expect(encode?.image).toEqual({ image_name: 'canvas-composite.png' }); + + // image → encode → denoise.latents + expect(getEdge(backendGraph, encode!.id, 'vae')).toBeDefined(); + expect(getEdge(backendGraph, 'denoise_latents', 'latents')?.source.node_id).toBe(encode?.id); + + // denoising_start follows the base's strength curve (linear, or the + // exponent-0.2 optimized curve for sd-3 / flux / flux2). + expect(backendGraph.nodes.denoise_latents?.type).toBe(denoiseType); + expect(backendGraph.nodes.denoise_latents?.denoising_start).toBeCloseTo( + expectedDenoisingStart(0.6, optimizedDenoising), + 10 + ); + expect(backendGraph.nodes.denoise_latents?.denoising_end).toBe(1); + + // metadata reflects the img2img mode + strength. + const metadata = getNodeByType(backendGraph, 'core_metadata'); + expect(metadata?.generation_mode).toBe(img2imgMode); + expect(metadata?.strength).toBe(0.6); + + expect(mode).toBe('img2img'); + } + ); + + it('feeds the encode node from the same VAE source as the decode node', () => { + const { backendGraph } = compile(sd1Model, 'img2img'); + const encode = getNodeByType(backendGraph, 'i2l'); + const decodeVaeSource = getEdge(backendGraph, 'canvas_output', 'vae')?.source.node_id; + const encodeVaeSource = getEdge(backendGraph, encode!.id, 'vae')?.source.node_id; + + expect(encodeVaeSource).toBe(decodeVaeSource); + expect(encodeVaeSource).toBe('model_loader'); + }); + + it('routes the encode VAE through the seamless node when tiling is enabled', () => { + const { backendGraph } = compile(sd1Model, 'img2img', { settings: { seamlessXAxis: true } }); + const encode = getNodeByType(backendGraph, 'i2l'); + + expect(getEdge(backendGraph, encode!.id, 'vae')?.source.node_id).toBe('seamless'); + }); + + it('mirrors the decode precision onto the SD encode node', () => { + const fp16 = compile(sd1Model, 'img2img', { settings: { vaePrecision: 'fp16' } }); + expect(getNodeByType(fp16.backendGraph, 'i2l')?.fp32).toBe(false); + + const fp32 = compile(sd1Model, 'img2img', { settings: { vaePrecision: 'fp32' } }); + expect(getNodeByType(fp32.backendGraph, 'i2l')?.fp32).toBe(true); + }); + + it('does not attach fp32 to non-SD encode nodes', () => { + const { backendGraph } = compile(fluxModel, 'img2img'); + + expect(getNodeByType(backendGraph, 'flux_vae_encode')?.fp32).toBeUndefined(); + }); + + it('threads the composite image name into the encode node', () => { + const { backendGraph } = compile(sdxlModel, 'img2img', { compositeImageName: 'my-upload.png' }); + + expect(getNodeByType(backendGraph, 'i2l')?.image).toEqual({ image_name: 'my-upload.png' }); + }); + + it('reflects strength in denoising_start', () => { + const { backendGraph } = compile(sd1Model, 'img2img', { strength: 0.25 }); + + expect(backendGraph.nodes.denoise_latents?.denoising_start).toBeCloseTo(0.75, 10); + }); + + it.each([sd3Model, fluxModel, flux2Model])( + 'rescales strength with the optimized (exponent-0.2) curve for $base', + (model) => { + const { backendGraph } = compile(model, 'img2img', { strength: 0.75 }); + + // Legacy parity: 1 - 0.75 ** 0.2 ≈ 0.056 (not the linear 0.25). + expect(backendGraph.nodes.denoise_latents?.denoising_start).toBeCloseTo(1 - 0.75 ** 0.2, 10); + } + ); + }); + + describe('unsupported models', () => { + it('rejects external image generators for txt2img', () => { + expect(() => compile(externalModel, 'txt2img')).toThrow('does not support canvas generation'); + }); + + it('rejects external image generators for img2img', () => { + expect(() => compile(externalModel, 'img2img')).toThrow('does not support canvas generation'); + }); + }); + + describe('validation', () => { + it('rejects img2img without a composite image', () => { + expect(() => compile(sd1Model, 'img2img', { compositeImageName: null })).toThrow( + 'Canvas generation requires a composited source image.' + ); + }); + + it('rejects a strength of zero', () => { + expect(() => compile(sd1Model, 'img2img', { strength: 0 })).toThrow( + 'Canvas denoising strength must be greater than 0 and at most 1.' + ); + }); + + it('rejects a strength above one', () => { + expect(() => compile(sd1Model, 'img2img', { strength: 1.5 })).toThrow( + 'Canvas denoising strength must be greater than 0 and at most 1.' + ); + }); + + it('accepts a strength of exactly one', () => { + const { backendGraph } = compile(sd1Model, 'img2img', { strength: 1 }); + + expect(backendGraph.nodes.denoise_latents?.denoising_start).toBe(0); + }); + + it('rejects a zero-area bbox', () => { + expect(() => compile(sd1Model, 'txt2img', { bbox: { height: 0, width: 512, x: 0, y: 0 } })).toThrow( + 'Canvas bounding box must have a positive area.' + ); + }); + + it('rejects off-grid bbox dimensions', () => { + expect(() => compile(flux2Model, 'txt2img', { bbox: { height: 888, width: 1024, x: 0, y: 0 } })).toThrow( + 'Generate height must be a multiple of 16.' + ); + }); + + it('surfaces missing-component validation for the bbox-sized settings', () => { + // FLUX with no component overrides fails the shared generate validation. + expect(() => + compileCanvasGraph({ + bbox, + compositeImageName: null, + destination: 'canvas', + mode: 'txt2img', + model: fluxModel, + projectSettings: PROJECT_SETTINGS, + settings: settingsFor(fluxModel), + strength: 0.6, + }) + ).toThrow('Generate needs a T5 Encoder for FLUX models.'); + }); + }); +}); + +describe('compileCanvasGraph — inpaint per base', () => { + it.each(BASE_CASES)( + 'grafts a $model.base inpaint pipeline (encode, gradient mask, blend composite-back)', + ({ model, encodeType, outputType, denoiseType, txt2imgMode, optimizedDenoising }) => { + const { backendGraph, mode } = compile(model, 'inpaint', { strength: 0.6 }); + expect(mode).toBe('inpaint'); + + // Encode fed by the initial composite image → denoise.latents. + const encode = getNodeByType(backendGraph, encodeType); + expect(encode).toBeDefined(); + expect(encode?.image).toEqual({ image_name: 'canvas-composite.png' }); + expect(getEdge(backendGraph, 'denoise_latents', 'latents')?.source.node_id).toBe(encode?.id); + + // create_gradient_mask carries coherence params + the mask image, and feeds denoise_mask. + const gradient = backendGraph.nodes.create_gradient_mask; + expect(gradient?.type).toBe('create_gradient_mask'); + expect(gradient?.coherence_mode).toBe('Gaussian Blur'); + expect(gradient?.edge_radius).toBe(16); + expect(gradient?.minimum_denoise).toBe(0); + expect(gradient?.image).toEqual({ image_name: 'canvas-composite.png' }); + expect(gradient?.mask).toEqual({ image_name: 'canvas-mask.png' }); + expect(getEdge(backendGraph, 'denoise_latents', 'denoise_mask')?.source.node_id).toBe('create_gradient_mask'); + + // fp32 only on the SD gradient mask (mirrors the SD i2l). + expect(gradient?.fp32).toBe(encodeType === 'i2l'); + + // The base decode is demoted to an intermediate canvas_l2i; the blend is canvas_output. + expect(backendGraph.nodes.canvas_l2i?.type).toBe(outputType); + expect(backendGraph.nodes.canvas_l2i?.is_intermediate).toBe(true); + const blend = backendGraph.nodes.canvas_output; + expect(blend?.type).toBe('invokeai_img_blend'); + expect(blend?.is_intermediate).toBe(true); + expect(blend?.layer_base).toEqual({ image_name: 'canvas-composite.png' }); + expect(getEdge(backendGraph, 'canvas_output', 'layer_upper')?.source.node_id).toBe('canvas_l2i'); + + // expand_mask_with_fade uses the mask blur and feeds the blend mask. + expect(backendGraph.nodes.expand_mask?.type).toBe('expand_mask_with_fade'); + expect(backendGraph.nodes.expand_mask?.fade_size_px).toBe(16); + expect(getEdge(backendGraph, 'expand_mask', 'mask')?.source.node_id).toBe('create_gradient_mask'); + expect(getEdge(backendGraph, 'canvas_output', 'mask')?.source.node_id).toBe('expand_mask'); + + // Strength curve + metadata mode. + expect(backendGraph.nodes.denoise_latents?.type).toBe(denoiseType); + expect(backendGraph.nodes.denoise_latents?.denoising_start).toBeCloseTo( + expectedDenoisingStart(0.6, optimizedDenoising) + ); + const metadata = getNodeByType(backendGraph, 'core_metadata'); + expect(metadata?.generation_mode).toBe(txt2imgMode.replace('txt2img', 'inpaint')); + expect(metadata?.strength).toBe(0.6); + } + ); + + it('re-points core_metadata onto the final blend output (not the intermediate decode)', () => { + const { backendGraph } = compile(sd1Model, 'inpaint'); + const metadata = getNodeByType(backendGraph, 'core_metadata')!; + const metaEdge = backendGraph.edges.find( + (edge) => edge.source.node_id === metadata.id && edge.destination.field === 'metadata' + ); + expect(metaEdge?.destination.node_id).toBe('canvas_output'); + }); + + it('wires a UNet edge into create_gradient_mask only for SD-family models', () => { + const sd = compile(sd1Model, 'inpaint').backendGraph; + expect(getEdge(sd, 'create_gradient_mask', 'unet')).toBeDefined(); + const flux = compile(fluxModel, 'inpaint').backendGraph; + expect(getEdge(flux, 'create_gradient_mask', 'unet')).toBeUndefined(); + }); + + it('inserts an img_noise node before encode when a noise mask is present', () => { + const { backendGraph } = compile(sd1Model, 'inpaint', { noiseMaskImageName: 'noise.png' }); + const noise = backendGraph.nodes.add_inpaint_noise; + expect(noise?.type).toBe('img_noise'); + expect(noise?.image).toEqual({ image_name: 'canvas-composite.png' }); + expect(noise?.mask).toEqual({ image_name: 'noise.png' }); + expect(getEdge(backendGraph, 'add_inpaint_noise', 'seed')?.source.node_id).toBe('seed'); + // noise → i2l.image + expect(getEdge(backendGraph, 'canvas_i2l', 'image')?.source.node_id).toBe('add_inpaint_noise'); + }); + + it('feeds the composite directly into i2l when there is no noise mask', () => { + const { backendGraph } = compile(sd1Model, 'inpaint'); + expect(backendGraph.nodes.add_inpaint_noise).toBeUndefined(); + expect(backendGraph.nodes.canvas_i2l?.image).toEqual({ image_name: 'canvas-composite.png' }); + }); + + it('rejects inpaint without a mask image', () => { + expect(() => compile(sd1Model, 'inpaint', { maskImageName: null })).toThrow( + 'Canvas inpainting requires an inpaint mask.' + ); + }); + + it('produces a durable blend output for a Gallery destination', () => { + expect( + compile(sd1Model, 'inpaint', { destination: 'gallery' }).backendGraph.nodes.canvas_output?.is_intermediate + ).toBe(false); + }); +}); + +describe('compileCanvasGraph — outpaint per base', () => { + it.each(BASE_CASES)('grafts a $model.base outpaint pipeline (infill + alpha mask)', ({ model, txt2imgMode }) => { + const { backendGraph, mode } = compile(model, 'outpaint', { maskImageName: null }); + expect(mode).toBe('outpaint'); + + // Infill applied to the initial image (default method: lama). + const infill = backendGraph.nodes.infill; + expect(infill?.type).toBe('infill_lama'); + expect(infill?.image).toEqual({ image_name: 'canvas-composite.png' }); + + // Mask derived from the raster alpha (tomask) → gradient mask (no inpaint mask present). + expect(backendGraph.nodes.image_alpha_to_mask?.type).toBe('tomask'); + expect(getEdge(backendGraph, 'create_gradient_mask', 'mask')?.source.node_id).toBe('image_alpha_to_mask'); + + // infill → i2l.image + expect(getEdge(backendGraph, 'canvas_i2l', 'image')?.source.node_id).toBe('infill'); + + // Composite-back blend claims canvas_output. + expect(backendGraph.nodes.canvas_output?.type).toBe('invokeai_img_blend'); + expect(getNodeByType(backendGraph, 'core_metadata')?.generation_mode).toBe( + txt2imgMode.replace('txt2img', 'outpaint') + ); + }); + + it('combines the inpaint mask with the raster alpha when a mask image is present', () => { + const { backendGraph } = compile(sd1Model, 'outpaint', { maskImageName: 'canvas-mask.png' }); + const combine = backendGraph.nodes.mask_combine; + expect(combine?.type).toBe('mask_combine'); + expect(combine?.mask1).toEqual({ image_name: 'canvas-mask.png' }); + expect(getEdge(backendGraph, 'mask_combine', 'mask2')?.source.node_id).toBe('image_alpha_to_mask'); + expect(getEdge(backendGraph, 'create_gradient_mask', 'mask')?.source.node_id).toBe('mask_combine'); + }); + + it.each([ + ['patchmatch', 'infill_patchmatch'], + ['lama', 'infill_lama'], + ['cv2', 'infill_cv2'], + ['tile', 'infill_tile'], + ['color', 'infill_rgba'], + ] as const)('uses the %s infill node', (method, nodeType) => { + const { backendGraph } = compile(sd1Model, 'outpaint', { + maskImageName: null, + compositing: { infillMethod: method }, + }); + expect(backendGraph.nodes.infill?.type).toBe(nodeType); + }); + + it('threads infill sub-params (tile size, patchmatch downscale, color)', () => { + const tile = compile(sd1Model, 'outpaint', { + maskImageName: null, + compositing: { infillMethod: 'tile', infillTileSize: 64 }, + }).backendGraph; + expect(tile.nodes.infill?.tile_size).toBe(64); + + const patch = compile(sd1Model, 'outpaint', { + maskImageName: null, + compositing: { infillMethod: 'patchmatch', infillPatchmatchDownscaleSize: 3 }, + }).backendGraph; + expect(patch.nodes.infill?.downscale).toBe(3); + + const color = compile(sd1Model, 'outpaint', { + maskImageName: null, + compositing: { infillMethod: 'color', infillColorValue: { r: 10, g: 20, b: 30, a: 1 } }, + }).backendGraph; + expect(color.nodes.infill?.color).toEqual({ r: 10, g: 20, b: 30, a: 255 }); + }); + + it('threads coherence + mask-blur settings into the outpaint gradient/expand nodes', () => { + const { backendGraph } = compile(sd1Model, 'outpaint', { + maskImageName: null, + compositing: { coherenceMode: 'Staged', coherenceEdgeSize: 8, coherenceMinDenoise: 0.3, maskBlur: 24 }, + }); + expect(backendGraph.nodes.create_gradient_mask?.coherence_mode).toBe('Staged'); + expect(backendGraph.nodes.create_gradient_mask?.edge_radius).toBe(8); + expect(backendGraph.nodes.create_gradient_mask?.minimum_denoise).toBe(0.3); + expect(backendGraph.nodes.expand_mask?.fade_size_px).toBe(24); + }); + + it('rejects an external model for every image mode', () => { + expect(() => compile(externalModel, 'outpaint', { maskImageName: null })).toThrow( + 'does not support canvas generation' + ); + }); +}); + +// Review fix (Task 38, finding 2): `addControlLayers` has its own isolated unit +// coverage (addControlLayers.test.ts), but nothing previously drove control +// layers through the REAL `compileCanvasGraph` entry point — so a wiring +// regression at the seam between the two (e.g. the wrong denoise node id, or a +// base graph that no longer exposes `denoise_latents`) could pass every +// existing test here while breaking a real canvas invoke. +describe('compileCanvasGraph — control layers (integration)', () => { + const controlNetLayer: ControlLayerGraphInput = { + beginEndStepPct: [0.1, 0.85], + controlMode: 'more_control', + id: 'control-layer-1', + imageName: 'control-composite.png', + kind: 'controlnet', + model: { base: 'sd-1', key: 'canny-controlnet', name: 'Canny ControlNet', type: 'controlnet' }, + weight: 0.65, + }; + + it('grafts an enabled control layer through a real compiled canvas graph: control node args, collector → denoise.control wiring, base graph intact', () => { + const { backendGraph, graph, mode } = compile(sd1Model, 'img2img', { controlLayers: [controlNetLayer] }); + + expect(mode).toBe('img2img'); + + // The control node carries the resolved model/weight/begin-end/mode args. + const controlNode = backendGraph.nodes['control_net_control-layer-1']; + expect(controlNode?.type).toBe('controlnet'); + expect(controlNode?.control_model).toEqual(controlNetLayer.model); + expect(controlNode?.control_weight).toBe(0.65); + expect(controlNode?.begin_step_percent).toBe(0.1); + expect(controlNode?.end_step_percent).toBe(0.85); + expect(controlNode?.control_mode).toBe('more_control'); + expect(controlNode?.image).toEqual({ image_name: 'control-composite.png' }); + + // The control node feeds the collector, whose own output is what + // `denoise_latents.control` actually reads. + expect(backendGraph.nodes.control_net_collector?.type).toBe('collect'); + expect(getEdge(backendGraph, 'control_net_collector', 'item')?.source.node_id).toBe('control_net_control-layer-1'); + expect(getEdge(backendGraph, 'denoise_latents', 'control')?.source.node_id).toBe('control_net_collector'); + + // The base img2img graph is untouched by the control graft: prompts, seed, + // the encode → denoise plumbing, and the composite-back output are all + // still present and wired exactly as they would be with no control layers. + expect(backendGraph.nodes.positive_prompt).toBeDefined(); + expect(backendGraph.nodes.negative_prompt).toBeDefined(); + expect(backendGraph.nodes.seed).toBeDefined(); + expect(backendGraph.nodes.canvas_i2l?.type).toBe('i2l'); + expect(getEdge(backendGraph, 'denoise_latents', 'latents')?.source.node_id).toBe('canvas_i2l'); + expect(backendGraph.nodes.canvas_output).toBeDefined(); + expect(graph.label).toBe(`${sd1Model.name} img2img`); + }); +}); + +describe('compileCanvasGraph — regional guidance', () => { + const region = (id: string, overrides: Partial = {}) => ({ + autoNegative: false, + id, + maskImageName: `${id}-mask.png`, + negativePrompt: null, + positivePrompt: 'a cat', + referenceImages: [], + ...overrides, + }); + + it('grafts SD1 regional conditioning into the pos/neg collectors', () => { + const { backendGraph } = compile(sd1Model, 'txt2img', { + regionalGuidance: [region('r1', { autoNegative: true })], + }); + expect(backendGraph.nodes.rg_mask_to_tensor_r1).toBeDefined(); + expect(backendGraph.nodes.rg_pos_cond_r1?.prompt).toBe('a cat'); + // autoNegative inverted-mask node feeding the negative collector. + expect(backendGraph.nodes.rg_invert_mask_r1).toBeDefined(); + expect(backendGraph.nodes.rg_pos_cond_inverted_r1).toBeDefined(); + }); + + it('is a no-op for an unsupported base (sd-2)', () => { + const { backendGraph } = compile(sd2Model, 'txt2img', { regionalGuidance: [region('r1')] }); + expect(backendGraph.nodes.rg_mask_to_tensor_r1).toBeUndefined(); + }); + + it('coexists with control layers in one graph', () => { + const control: ControlLayerGraphInput = { + beginEndStepPct: [0, 0.75], + controlMode: 'balanced', + id: 'c1', + imageName: 'control.png', + kind: 'controlnet', + model: { base: 'sd-1', key: 'cn', name: 'CN', type: 'controlnet' }, + weight: 1, + }; + const { backendGraph } = compile(sd1Model, 'txt2img', { + controlLayers: [control], + regionalGuidance: [region('r1')], + }); + expect(backendGraph.nodes.control_net_c1).toBeDefined(); + expect(backendGraph.nodes.rg_pos_cond_r1).toBeDefined(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/generation/canvas/compileCanvasGraph.ts b/invokeai/frontend/webv2/src/workbench/generation/canvas/compileCanvasGraph.ts new file mode 100644 index 00000000000..064fd39ed49 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/generation/canvas/compileCanvasGraph.ts @@ -0,0 +1,544 @@ +/** + * Pure canvas generation-graph compiler. + * + * {@link compileCanvasGraph} builds the backend graph a canvas invoke submits by + * grafting image-to-image plumbing onto the existing per-base txt2img builders + * (`../graph.ts`). It is deliberately pure: no fetch, no engine imports, no + * React. The executor (Task 16) has already composited + uploaded the bbox + * source image and passes its name in; this module only shapes nodes/edges. + * + * ## Grafting strategy + * + * 1. Build the base txt2img graph via `GRAPH_BUILDERS[model.base]` with the + * canvas destination (`outputIsIntermediate = true`) and a settings copy whose + * width/height are the bbox size (builders read dims from settings). + * 2. For `img2img`, add a base-appropriate image-to-latents encode node fed by + * the composite image + the graph's VAE source, wire its latents into + * `denoise_latents.latents`, and set `denoising_start = 1 - strength`. + * 3. Update `core_metadata` (`generation_mode` → the `img2img` variant, plus + * `strength`). + * + * Every base in `GRAPH_BUILDERS` has a backend image-to-latents node, so all ten + * families support img2img (see `CANVAS_I2L_NODE_TYPES`). External image + * generators have no latent img2img path and are rejected for every canvas mode. + */ + +import type { SupportedGenerateBase } from '@workbench/generation/baseGenerationPolicies'; +import type { GenerateModelConfig, GenerateSettings } from '@workbench/generation/types'; +import type { BackendGraphContract, BackendInvocationContract } from '@workbench/types'; +import type { CanvasCompositingSettings, CanvasInfillMethod } from '@workbench/widgets/canvas/invoke/canvasCompositing'; + +import { getGenerationValidationReasons } from '@workbench/generation/baseGenerationPolicies'; +import { GRAPH_BUILDERS } from '@workbench/generation/graph'; +import { addEdge, addNode, toGraphContract } from '@workbench/generation/graphBuilder'; +import { DEFAULT_CANVAS_COMPOSITING } from '@workbench/widgets/canvas/invoke/canvasCompositing'; + +import type { CompileCanvasGraphInput, CompiledCanvasGraph } from './types'; + +import { addControlLayers } from './addControlLayers'; +import { addRegionalGuidance, isRegionalGuidanceSupportedForBase } from './addRegionalGuidance'; + +/** + * The backend image-to-latents (encode) node type per supported base. sd-1 / + * sd-2 / sdxl share the SD `i2l` node. Evidence: `invokeai/app/invocations/` + * and the legacy per-base builders under + * `features/nodes/util/graph/generation/`. + */ +const CANVAS_I2L_NODE_TYPES: Record = { + 'sd-1': 'i2l', + 'sd-2': 'i2l', + sdxl: 'i2l', + 'sd-3': 'sd3_i2l', + flux: 'flux_vae_encode', + flux2: 'flux2_vae_encode', + cogview4: 'cogview4_i2l', + 'qwen-image': 'qwen_image_i2l', + 'z-image': 'z_image_i2l', + anima: 'anima_i2l', +}; + +/** + * The `denoising_start` for a canvas img2img graft, mirroring legacy + * `getDenoisingStartAndEnd` (web `graphBuilderUtils.ts`): + * - sd-3 / flux / flux2 rescale strength with an exponent of 0.2 so the slider's + * full (0, 1] range is usable — without it nearly all perceptible change is + * crammed into strength > 0.9 (e.g. strength 0.75 → start 0.056, not 0.25). + * - A FLUX Fill model (`flux` / `dev_fill`) always denoises fully (start = 0). + * - Every other base stays linear (`start = 1 - strength`). + * + * Legacy gates the exponent on an `optimizedDenoisingEnabled` user setting that + * defaults to `true`; webv2 exposes no such toggle, so the optimized curve is + * always applied for the eligible bases. + */ +const canvasDenoisingStart = (model: GenerateModelConfig, strength: number): number => { + if (model.base === 'flux' && model.variant === 'dev_fill') { + return 0; + } + const usesOptimizedCurve = model.base === 'sd-3' || model.base === 'flux' || model.base === 'flux2'; + return 1 - strength ** (usesOptimizedCurve ? 0.2 : 1); +}; + +/** True for the image-referencing modes (everything but pure txt2img). */ +const isImageMode = (mode: CompileCanvasGraphInput['mode']): boolean => mode !== 'txt2img'; + +/** Canvas-specific validation reasons layered on top of the shared generate ones. */ +const getCanvasValidationReasons = (input: CompileCanvasGraphInput): string[] => { + const { bbox, compositeImageName, maskImageName, mode, model, strength } = input; + const reasons: string[] = []; + + if (model.type === 'external_image_generator') { + reasons.push(`${model.name} does not support canvas generation.`); + return reasons; + } + + if (!Number.isFinite(bbox.width) || !Number.isFinite(bbox.height) || bbox.width <= 0 || bbox.height <= 0) { + reasons.push('Canvas bounding box must have a positive area.'); + } + + if (isImageMode(mode)) { + if (!compositeImageName) { + reasons.push('Canvas generation requires a composited source image.'); + } + + if (!Number.isFinite(strength) || strength <= 0 || strength > 1) { + reasons.push('Canvas denoising strength must be greater than 0 and at most 1.'); + } + } + + // Inpaint always needs a mask (an active inpaint mask defines the region); + // outpaint derives its mask from the raster alpha, so its mask is optional. + if (mode === 'inpaint' && !maskImageName) { + reasons.push('Canvas inpainting requires an inpaint mask.'); + } + + return reasons; +}; + +/** The settings a base builder sees: identical to the widget's, but sized to the bbox. */ +const withBboxDimensions = (settings: GenerateSettings, bbox: CompileCanvasGraphInput['bbox']): GenerateSettings => ({ + ...settings, + height: bbox.height, + width: bbox.width, +}); + +/** Locates the node + field feeding a decode/denoise input edge (e.g. `canvas_output.vae`). */ +const findEdgeSource = ( + graph: BackendGraphContract, + destNodeId: string, + destField: string +): { node: BackendInvocationContract; field: string } | null => { + const edge = graph.edges.find( + (candidate) => candidate.destination.node_id === destNodeId && candidate.destination.field === destField + ); + const node = edge ? graph.nodes[edge.source.node_id] : undefined; + return edge && node ? { field: edge.source.field, node } : null; +}; + +/** Resolves the VAE source feeding the graph's decode node (throws if absent). */ +const requireVaeSource = (graph: BackendGraphContract): { node: BackendInvocationContract; field: string } => { + const source = findEdgeSource(graph, 'canvas_output', 'vae'); + if (!source) { + throw new Error('Canvas generation could not resolve a VAE source in the base graph.'); + } + return source; +}; + +/** + * Renames a node in place: moves its map key, updates its `id`, and rewires every + * edge referencing the old id. Used to demote the base `canvas_output` decode to + * an intermediate `canvas_l2i` so the composite-back node can claim `canvas_output`. + */ +const renameNode = (graph: BackendGraphContract, oldId: string, newId: string): BackendInvocationContract => { + const node = graph.nodes[oldId]; + if (!node) { + throw new Error(`Canvas generation could not find the "${oldId}" node to rename.`); + } + delete graph.nodes[oldId]; + node.id = newId; + graph.nodes[newId] = node; + for (const edge of graph.edges) { + if (edge.source.node_id === oldId) { + edge.source.node_id = newId; + } + if (edge.destination.node_id === oldId) { + edge.destination.node_id = newId; + } + } + return node; +}; + +/** Sets the metadata `generation_mode` variant + strength (legacy parity). */ +const setMetadataMode = (graph: BackendGraphContract, mode: string, strength: number): void => { + const metadata = Object.values(graph.nodes).find((node) => node.type === 'core_metadata'); + if (metadata) { + if (typeof metadata.generation_mode === 'string') { + metadata.generation_mode = metadata.generation_mode.replace('txt2img', mode); + } + metadata.strength = strength; + } +}; + +/** The SD `i2l` node carries fp32; every other family's encode node does not. */ +const isSdI2l = (i2lType: string): boolean => i2lType === 'i2l'; + +/** Adds the base-appropriate image-to-latents encode node fed by `imageName`. */ +const addEncodeNode = ( + graph: BackendGraphContract, + i2lType: string, + settings: GenerateSettings, + imageName: string | null +): BackendInvocationContract => + addNode(graph, { + id: 'canvas_i2l', + ...(imageName ? { image: { image_name: imageName } } : {}), + type: i2lType, + ...(isSdI2l(i2lType) ? { fp32: settings.vaePrecision === 'fp32' } : {}), + }); + +/** Resolves the encode node type for a supported base (throws otherwise). */ +const requireI2lType = (model: GenerateModelConfig): string => { + if (model.type === 'external_image_generator') { + throw new Error(`${model.name} does not support canvas generation.`); + } + const i2lType = CANVAS_I2L_NODE_TYPES[model.base as SupportedGenerateBase]; + if (!i2lType) { + throw new Error(`Canvas generation is not supported for ${model.name}.`); + } + return i2lType; +}; + +/** Resolves the base graph's denoise node (throws if absent). */ +const requireDenoise = (graph: BackendGraphContract): BackendInvocationContract => { + const denoise = graph.nodes.denoise_latents; + if (!denoise) { + throw new Error('Canvas generation could not find the denoise node in the base graph.'); + } + return denoise; +}; + +/** Grafts the image-to-latents encode path onto a freshly built base graph (img2img). */ +const graftImageToImage = ( + graph: BackendGraphContract, + model: GenerateModelConfig, + settings: GenerateSettings, + compositeImageName: string, + strength: number +): void => { + const i2lType = requireI2lType(model); + const denoise = requireDenoise(graph); + const vaeSource = requireVaeSource(graph); + const encode = addEncodeNode(graph, i2lType, settings, compositeImageName); + + addEdge(graph, vaeSource.node, vaeSource.field, encode, 'vae'); + addEdge(graph, encode, 'latents', denoise, 'latents'); + denoise.denoising_start = canvasDenoisingStart(model, strength); + denoise.denoising_end = 1; + + setMetadataMode(graph, 'img2img', strength); +}; + +/** The backend infill node for the selected method (legacy `getInfill`). */ +const addInfillNode = ( + graph: BackendGraphContract, + method: CanvasInfillMethod, + compositing: CanvasCompositingSettings +): BackendInvocationContract => { + switch (method) { + case 'patchmatch': + return addNode(graph, { + downscale: compositing.infillPatchmatchDownscaleSize, + id: 'infill', + type: 'infill_patchmatch', + }); + case 'lama': + return addNode(graph, { id: 'infill', type: 'infill_lama' }); + case 'cv2': + return addNode(graph, { id: 'infill', type: 'infill_cv2' }); + case 'tile': + return addNode(graph, { id: 'infill', tile_size: compositing.infillTileSize, type: 'infill_tile' }); + case 'color': { + const { a, b, g, r } = compositing.infillColorValue; + return addNode(graph, { + color: { a: Math.round(a * 255), b, g, r }, + id: 'infill', + type: 'infill_rgba', + }); + } + } +}; + +/** + * Shared inpaint/outpaint tail: gradient denoise mask → denoise, expand-with-fade, + * and the composite-back blend (`canvas_output`). `maskSource` supplies the + * grayscale mask feeding `create_gradient_mask`; `initialImageName` is the base + * to paste the decoded result back onto. + */ +const graftMaskTail = ( + graph: BackendGraphContract, + args: { + model: GenerateModelConfig; + settings: GenerateSettings; + i2lType: string; + denoise: BackendInvocationContract; + vaeSource: { node: BackendInvocationContract; field: string }; + initialImageName: string; + compositing: CanvasCompositingSettings; + destination: CompileCanvasGraphInput['destination']; + /** Either a fixed mask image field or an edge-fed source node. */ + gradientMaskImage?: { image_name: string }; + gradientMaskEdge?: { node: BackendInvocationContract; field: string }; + } +): void => { + const { compositing, denoise, destination, i2lType, initialImageName, settings, vaeSource } = args; + + // Demote the base decode to an intermediate `canvas_l2i`; the blend becomes `canvas_output`. + const l2i = renameNode(graph, 'canvas_output', 'canvas_l2i'); + l2i.is_intermediate = true; + + const gradientMask = addNode(graph, { + coherence_mode: compositing.coherenceMode, + edge_radius: compositing.coherenceEdgeSize, + fp32: isSdI2l(i2lType) ? settings.vaePrecision === 'fp32' : false, + id: 'create_gradient_mask', + image: { image_name: initialImageName }, + minimum_denoise: compositing.coherenceMinDenoise, + type: 'create_gradient_mask', + ...(args.gradientMaskImage ? { mask: args.gradientMaskImage } : {}), + }); + + if (args.gradientMaskEdge) { + addEdge(graph, args.gradientMaskEdge.node, args.gradientMaskEdge.field, gradientMask, 'mask'); + } + addEdge(graph, vaeSource.node, vaeSource.field, gradientMask, 'vae'); + // The optional UNet edge only applies to SD-family models (legacy `isMainModelWithoutUnet`). + const unetSource = findEdgeSource(graph, 'denoise_latents', 'unet'); + if (unetSource) { + addEdge(graph, unetSource.node, unetSource.field, gradientMask, 'unet'); + } + addEdge(graph, gradientMask, 'denoise_mask', denoise, 'denoise_mask'); + + const expandMask = addNode(graph, { + fade_size_px: compositing.maskBlur, + id: 'expand_mask', + type: 'expand_mask_with_fade', + }); + addEdge(graph, gradientMask, 'expanded_mask_area', expandMask, 'mask'); + + const blend = addNode(graph, { + id: 'canvas_output', + is_intermediate: destination === 'canvas', + layer_base: { image_name: initialImageName }, + type: 'invokeai_img_blend', + use_cache: false, + }); + addEdge(graph, l2i, 'image', blend, 'layer_upper'); + addEdge(graph, expandMask, 'image', blend, 'mask'); + + // The base builder wired core_metadata → the decode's `metadata`; `renameNode` + // followed it onto the (now intermediate) canvas_l2i. Re-point it to the final + // blend so the saved image carries generation metadata (invokeai_img_blend is + // WithMetadata). Board fields, when present, ride the same node. + const metadataEdge = graph.edges.find( + (edge) => edge.destination.node_id === 'canvas_l2i' && edge.destination.field === 'metadata' + ); + if (metadataEdge) { + metadataEdge.destination.node_id = 'canvas_output'; + } +}; + +/** Optionally inserts an `img_noise` node before encode; returns the node feeding `i2l.image`. */ +const addNoiseBeforeEncode = ( + graph: BackendGraphContract, + imageName: string, + noiseMaskImageName: string | null | undefined, + imageSourceNode: BackendInvocationContract | null +): { imageField?: { image_name: string }; edgeFrom?: BackendInvocationContract } => { + if (!noiseMaskImageName) { + // No noise mask: encode reads the (infilled) initial image directly. + return imageSourceNode ? { edgeFrom: imageSourceNode } : { imageField: { image_name: imageName } }; + } + const noise = addNode(graph, { + amount: 1.0, + id: 'add_inpaint_noise', + ...(imageSourceNode ? {} : { image: { image_name: imageName } }), + mask: { image_name: noiseMaskImageName }, + noise_color: true, + noise_type: 'gaussian', + type: 'img_noise', + }); + addEdge(graph, graph.nodes.seed, 'value', noise, 'seed'); + if (imageSourceNode) { + addEdge(graph, imageSourceNode, 'image', noise, 'image'); + } + return { edgeFrom: noise }; +}; + +/** Grafts the inpaint pipeline (content covers the bbox, an inpaint mask restricts it). */ +const graftInpaint = ( + graph: BackendGraphContract, + input: CompileCanvasGraphInput, + compositing: CanvasCompositingSettings +): void => { + const { model } = input; + const i2lType = requireI2lType(model); + const denoise = requireDenoise(graph); + const vaeSource = requireVaeSource(graph); + const initialImageName = input.compositeImageName as string; + const maskImageName = input.maskImageName as string; + const strength = input.strength; + + denoise.denoising_start = canvasDenoisingStart(model, strength); + denoise.denoising_end = 1; + + const encode = addEncodeNode(graph, i2lType, input.settings, null); + const noiseResult = addNoiseBeforeEncode(graph, initialImageName, input.noiseMaskImageName, null); + if (noiseResult.edgeFrom) { + addEdge(graph, noiseResult.edgeFrom, 'image', encode, 'image'); + } else if (noiseResult.imageField) { + encode.image = noiseResult.imageField; + } + addEdge(graph, vaeSource.node, vaeSource.field, encode, 'vae'); + addEdge(graph, encode, 'latents', denoise, 'latents'); + + graftMaskTail(graph, { + compositing, + denoise, + destination: input.destination, + gradientMaskImage: { image_name: maskImageName }, + i2lType, + initialImageName, + model, + settings: input.settings, + vaeSource, + }); + + setMetadataMode(graph, 'inpaint', strength); +}; + +/** Grafts the outpaint pipeline (bbox extends past content / has transparent holes). */ +const graftOutpaint = ( + graph: BackendGraphContract, + input: CompileCanvasGraphInput, + compositing: CanvasCompositingSettings +): void => { + const { model } = input; + const i2lType = requireI2lType(model); + const denoise = requireDenoise(graph); + const vaeSource = requireVaeSource(graph); + const initialImageName = input.compositeImageName as string; + const strength = input.strength; + + denoise.denoising_start = canvasDenoisingStart(model, strength); + denoise.denoising_end = 1; + + // Infill the transparent region before encode (legacy `getInfill`). + const infill = addInfillNode(graph, compositing.infillMethod, compositing); + infill.image = { image_name: initialImageName }; + + // Derive a mask from the initial image alpha (transparent → generate), combined + // with the inpaint mask when one exists. + const alphaToMask = addNode(graph, { + id: 'image_alpha_to_mask', + image: { image_name: initialImageName }, + type: 'tomask', + }); + + let gradientMaskEdge: { node: BackendInvocationContract; field: string }; + if (input.maskImageName) { + const maskCombine = addNode(graph, { + id: 'mask_combine', + mask1: { image_name: input.maskImageName }, + type: 'mask_combine', + }); + addEdge(graph, alphaToMask, 'image', maskCombine, 'mask2'); + gradientMaskEdge = { field: 'image', node: maskCombine }; + } else { + gradientMaskEdge = { field: 'image', node: alphaToMask }; + } + + const encode = addEncodeNode(graph, i2lType, input.settings, null); + const noiseResult = addNoiseBeforeEncode(graph, initialImageName, input.noiseMaskImageName, infill); + if (noiseResult.edgeFrom) { + addEdge(graph, noiseResult.edgeFrom, 'image', encode, 'image'); + } + addEdge(graph, vaeSource.node, vaeSource.field, encode, 'vae'); + addEdge(graph, encode, 'latents', denoise, 'latents'); + + graftMaskTail(graph, { + compositing, + denoise, + destination: input.destination, + gradientMaskEdge, + i2lType, + initialImageName, + model, + settings: input.settings, + vaeSource, + }); + + setMetadataMode(graph, 'outpaint', strength); +}; + +/** + * Compiles a canvas invoke into a backend graph. Throws a validation `Error` + * (message = first offending reason) for unsupported models/modes or bad + * geometry, mirroring `compileGenerateGraph`. + */ +export const compileCanvasGraph = (input: CompileCanvasGraphInput): CompiledCanvasGraph => { + const { bbox, compositeImageName, destination, mode, model, projectSettings, strength } = input; + const settings = withBboxDimensions(input.settings, bbox); + + const validationReasons = [...getCanvasValidationReasons(input), ...getGenerationValidationReasons(model, settings)]; + + if (validationReasons.length > 0) { + throw new Error(validationReasons[0]); + } + + const builder = GRAPH_BUILDERS[model.base as SupportedGenerateBase]; + + if (!builder || model.type === 'external_image_generator') { + throw new Error(`${model.name} does not support canvas generation.`); + } + + // Mirror compileGenerateGraph: only a `canvas` destination stages an + // intermediate output; `gallery` produces a durable image. + const outputIsIntermediate = destination === 'canvas'; + const backendGraph = builder(settings, model, outputIsIntermediate, projectSettings); + const compositing = input.compositing ?? DEFAULT_CANVAS_COMPOSITING; + + // Validation above guarantees the composite/mask images required per mode. + if (mode === 'img2img') { + graftImageToImage(backendGraph, model, settings, compositeImageName as string, strength); + } else if (mode === 'inpaint') { + graftInpaint(backendGraph, input, compositing); + } else if (mode === 'outpaint') { + graftOutpaint(backendGraph, input, compositing); + } + + // Control layers apply in every mode (legacy allows control with all). The + // executor already composited + uploaded each layer separately and resolved + // its model; the caller passes only valid layers. + if (input.controlLayers && input.controlLayers.length > 0) { + addControlLayers(backendGraph, { + base: model.base as SupportedGenerateBase, + layers: input.controlLayers, + modelVariant: model.variant ?? undefined, + }); + } + + // Regional guidance applies in every mode too. The executor already composited + // + uploaded each region's mask and resolved its reference-image models; it + // passes only valid regions for a supported base (sd-1 / sdxl / flux). + if (input.regionalGuidance && input.regionalGuidance.length > 0 && isRegionalGuidanceSupportedForBase(model.base)) { + addRegionalGuidance(backendGraph, { base: model.base, regions: input.regionalGuidance }); + } + + return { + backendGraph, + graph: toGraphContract(backendGraph, `${model.name} ${mode}`), + mode, + negativePromptNodeId: 'negative_prompt', + positivePromptNodeId: 'positive_prompt', + seedNodeId: 'seed', + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/generation/canvas/compositePlan.test.ts b/invokeai/frontend/webv2/src/workbench/generation/canvas/compositePlan.test.ts new file mode 100644 index 00000000000..08a4cd46034 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/generation/canvas/compositePlan.test.ts @@ -0,0 +1,488 @@ +import type { + CanvasControlLayerContract, + CanvasDocumentContractV2, + CanvasImageRef, + CanvasLayerContract, + CanvasRasterLayerContractV2, + CanvasRegionalGuidanceLayerContract, +} from '@workbench/types'; + +import { describe, expect, it } from 'vitest'; + +import type { Rect } from './types'; + +import { planComposites, planControlComposites, planRegionalMaskComposites } from './compositePlan'; + +const imageRef = (imageName: string, width = 64, height = 48): CanvasImageRef => ({ height, imageName, width }); + +const rasterLayer = ( + id: string, + overrides: Partial = {} +): CanvasRasterLayerContractV2 => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { image: imageRef(id), type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + ...overrides, +}); + +const maskLayer = (id: string): CanvasLayerContract => ({ + autoNegative: false, + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + mask: { bitmap: null, fill: { color: '#ff0000', style: 'solid' } }, + name: id, + negativePrompt: null, + opacity: 1, + positivePrompt: null, + referenceImages: [], + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'regional_guidance', +}); + +const BBOX: Rect = { height: 200, width: 200, x: 0, y: 0 }; + +const makeDoc = ( + layers: CanvasLayerContract[], + overrides: Partial = {} +): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: 200, width: 200, x: 0, y: 0 }, + height: 300, + layers, + selectedLayerId: null, + version: 2, + width: 400, + ...overrides, +}); + +const keyOf = (doc: CanvasDocumentContractV2, bbox: Rect = BBOX): string => planComposites(doc, bbox).entries[0]!.key; + +describe('planComposites — plan shape', () => { + it('emits a single base-raster entry scoped to the bbox', () => { + const plan = planComposites(makeDoc([rasterLayer('a')]), BBOX); + expect(plan.bbox).toEqual(BBOX); + expect(plan.entries).toHaveLength(1); + const entry = plan.entries[0]!; + expect(entry.kind).toBe('base-raster'); + expect(entry.bbox).toEqual(BBOX); + }); + + it('includes only enabled raster (image/paint) layers, preserving z-order', () => { + const doc = makeDoc([ + rasterLayer('top'), + maskLayer('mask'), + rasterLayer('disabled', { isEnabled: false }), + rasterLayer('bottom', { source: { bitmap: imageRef('paint-bmp'), type: 'paint' } }), + ]); + const entry = planComposites(doc, BBOX).entries[0]!; + expect(entry.layers.map((l) => l.id)).toEqual(['top', 'bottom']); + }); + + it('excludes an inpaint mask (even one with a persisted bitmap) from the base-raster composite', () => { + const inpaintMask: CanvasLayerContract = { + blendMode: 'normal', + id: 'inpaint', + isEnabled: true, + isLocked: false, + mask: { bitmap: imageRef('mask-bmp'), fill: { color: '#e07575', style: 'diagonal' } }, + name: 'inpaint', + opacity: 1, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'inpaint_mask', + }; + const doc = makeDoc([rasterLayer('top'), inpaintMask]); + const entry = planComposites(doc, BBOX).entries[0]!; + // Masks feed the (next task's) mask composite, never the image composite. + expect(entry.layers.map((l) => l.id)).toEqual(['top']); + }); + + it('derives sourceRef + content-sized contentSize/contentOffset for image and paint layers', () => { + const doc = makeDoc([ + rasterLayer('img', { source: { image: imageRef('pic', 128, 96), type: 'image' } }), + rasterLayer('paint', { source: { bitmap: imageRef('bmp', 200, 150), offset: { x: 40, y: 25 }, type: 'paint' } }), + ]); + const [img, paint] = planComposites(doc, BBOX).entries[0]!.layers; + expect(img!.sourceRef).toBe('image:pic'); + expect(img!.contentSize).toEqual({ height: 96, width: 128 }); + expect(img!.contentOffset).toEqual({ x: 0, y: 0 }); + expect(paint!.sourceRef).toBe('paint:bmp'); + // Paint layers are content-sized: the persisted bitmap dims placed at its offset. + expect(paint!.contentSize).toEqual({ height: 150, width: 200 }); + expect(paint!.contentOffset).toEqual({ x: 40, y: 25 }); + }); + + it('excludes an empty (bitmap: null) paint layer so it cannot force outpaint', () => { + // An auto-created paint layer left blank (e.g. its stroke was undone) carries + // no pixels; including it would inject a doc-sized transparent rect that reads + // as outpaint. It must not appear among the base-raster contributors. + const doc = makeDoc([ + rasterLayer('img', { source: { image: imageRef('pic'), type: 'image' } }), + rasterLayer('blank', { source: { bitmap: null, type: 'paint' } }), + ]); + const entry = planComposites(doc, BBOX).entries[0]!; + expect(entry.layers.map((l) => l.id)).toEqual(['img']); + }); + + it('emits an empty layer list when the only paint layer is unpainted', () => { + const doc = makeDoc([rasterLayer('blank', { source: { bitmap: null, type: 'paint' } })]); + expect(planComposites(doc, BBOX).entries[0]!.layers).toEqual([]); + }); +}); + +describe('planComposites — key stability', () => { + it('produces byte-identical keys for structurally identical documents', () => { + const build = () => makeDoc([rasterLayer('a'), rasterLayer('b', { opacity: 0.5 })]); + expect(keyOf(build())).toBe(keyOf(build())); + }); +}); + +describe('planComposites — key sensitivity', () => { + it('changes the key when layers are reordered', () => { + const a = makeDoc([rasterLayer('a'), rasterLayer('b')]); + const b = makeDoc([rasterLayer('b'), rasterLayer('a')]); + expect(keyOf(a)).not.toBe(keyOf(b)); + }); + + it('changes the key when a layer opacity changes', () => { + const a = makeDoc([rasterLayer('a', { opacity: 1 })]); + const b = makeDoc([rasterLayer('a', { opacity: 0.5 })]); + expect(keyOf(a)).not.toBe(keyOf(b)); + }); + + it('changes the key when a layer transform changes', () => { + const a = makeDoc([rasterLayer('a')]); + const b = makeDoc([rasterLayer('a', { transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 10, y: 0 } })]); + expect(keyOf(a)).not.toBe(keyOf(b)); + }); + + it('changes the key when a layer blend mode changes', () => { + const a = makeDoc([rasterLayer('a', { blendMode: 'normal' })]); + const b = makeDoc([rasterLayer('a', { blendMode: 'multiply' })]); + expect(keyOf(a)).not.toBe(keyOf(b)); + }); + + it('changes the key when a layer source is swapped', () => { + const a = makeDoc([rasterLayer('a', { source: { image: imageRef('cat'), type: 'image' } })]); + const b = makeDoc([rasterLayer('a', { source: { image: imageRef('dog'), type: 'image' } })]); + expect(keyOf(a)).not.toBe(keyOf(b)); + }); + + it('changes the key when the bbox moves', () => { + const doc = makeDoc([rasterLayer('a')]); + expect(keyOf(doc, { height: 200, width: 200, x: 0, y: 0 })).not.toBe( + keyOf(doc, { height: 200, width: 200, x: 10, y: 0 }) + ); + }); + + it('changes the key when a layer is toggled off', () => { + const a = makeDoc([rasterLayer('a')]); + const b = makeDoc([rasterLayer('a', { isEnabled: false })]); + expect(keyOf(a)).not.toBe(keyOf(b)); + }); +}); + +describe('planComposites — key insensitivity to irrelevant changes', () => { + it('ignores selectedLayerId', () => { + const a = makeDoc([rasterLayer('a')], { selectedLayerId: null }); + const b = makeDoc([rasterLayer('a')], { selectedLayerId: 'a' }); + expect(keyOf(a)).toBe(keyOf(b)); + }); + + it('ignores the document background', () => { + const a = makeDoc([rasterLayer('a')], { background: 'transparent' }); + const b = makeDoc([rasterLayer('a')], { background: { color: '#123456' } }); + expect(keyOf(a)).toBe(keyOf(b)); + }); + + it('ignores edits to a disabled layer', () => { + const a = makeDoc([rasterLayer('a'), rasterLayer('off', { isEnabled: false, opacity: 1 })]); + const b = makeDoc([rasterLayer('a'), rasterLayer('off', { isEnabled: false, opacity: 0.2, blendMode: 'screen' })]); + expect(keyOf(a)).toBe(keyOf(b)); + }); +}); + +const inpaintMask = ( + id: string, + overrides: Partial<{ + bitmap: CanvasImageRef | null; + noiseLevel: number; + denoiseLimit: number; + isEnabled: boolean; + offset: { x: number; y: number }; + }> = {} +): CanvasLayerContract => ({ + blendMode: 'normal', + denoiseLimit: overrides.denoiseLimit, + id, + isEnabled: overrides.isEnabled ?? true, + isLocked: false, + mask: { + bitmap: 'bitmap' in overrides ? overrides.bitmap! : imageRef(`${id}-bmp`, 100, 80), + fill: { color: '#ff0000', style: 'solid' }, + ...(overrides.offset ? { offset: overrides.offset } : {}), + }, + name: id, + noiseLevel: overrides.noiseLevel, + opacity: 1, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'inpaint_mask', +}); + +const entryOfKind = (doc: CanvasDocumentContractV2, kind: string) => + planComposites(doc, BBOX).entries.find((e) => e.kind === kind); + +describe('planComposites — inpaint-mask entries', () => { + it('emits no mask entries when there are no active inpaint masks', () => { + const kinds = planComposites(makeDoc([rasterLayer('a')]), BBOX).entries.map((e) => e.kind); + expect(kinds).toEqual(['base-raster']); + }); + + it('emits an inpaint-mask entry for enabled masks with a persisted bitmap', () => { + const entry = entryOfKind(makeDoc([rasterLayer('a'), inpaintMask('m1')]), 'inpaint-mask'); + expect(entry).toBeDefined(); + expect(entry!.maskLayers!.map((l) => l.id)).toEqual(['m1']); + expect(entry!.maskLayers![0]!.sourceRef).toBe('mask:m1-bmp'); + expect(entry!.maskLayers![0]!.contentSize).toEqual({ height: 80, width: 100 }); + }); + + it('excludes disabled masks and empty (bitmap: null) masks', () => { + const doc = makeDoc([ + inpaintMask('on'), + inpaintMask('off', { isEnabled: false }), + inpaintMask('empty', { bitmap: null }), + ]); + const entry = entryOfKind(doc, 'inpaint-mask'); + expect(entry!.maskLayers!.map((l) => l.id)).toEqual(['on']); + }); + + it('resolves an undefined denoiseLimit to the legacy default (1.0)', () => { + const entry = entryOfKind(makeDoc([inpaintMask('m1')]), 'inpaint-mask'); + expect(entry!.maskLayers![0]!.attributeValue).toBe(1); + }); + + it('uses the layer denoiseLimit when defined', () => { + const entry = entryOfKind(makeDoc([inpaintMask('m1', { denoiseLimit: 0.4 })]), 'inpaint-mask'); + expect(entry!.maskLayers![0]!.attributeValue).toBe(0.4); + }); + + it('unions multiple masks into the denoise-limit entry', () => { + const doc = makeDoc([inpaintMask('a', { denoiseLimit: 0.3 }), inpaintMask('b')]); + const entry = entryOfKind(doc, 'inpaint-mask'); + expect(entry!.maskLayers!.map((l) => l.id)).toEqual(['a', 'b']); + }); +}); + +describe('planComposites — noise-mask entries', () => { + it('omits the noise-mask entry when no mask defines a noiseLevel (undefined is NOT 0)', () => { + const kinds = planComposites(makeDoc([inpaintMask('m1')]), BBOX).entries.map((e) => e.kind); + expect(kinds).toEqual(['base-raster', 'inpaint-mask']); + }); + + it('emits a noise-mask entry only for masks with a defined noiseLevel', () => { + const doc = makeDoc([inpaintMask('withNoise', { noiseLevel: 0.15 }), inpaintMask('noNoise')]); + const entry = entryOfKind(doc, 'noise-mask'); + expect(entry).toBeDefined(); + expect(entry!.maskLayers!.map((l) => l.id)).toEqual(['withNoise']); + expect(entry!.maskLayers![0]!.attributeValue).toBe(0.15); + }); + + it('treats noiseLevel 0 as defined (included), distinct from undefined (excluded)', () => { + const entry = entryOfKind(makeDoc([inpaintMask('m1', { noiseLevel: 0 })]), 'noise-mask'); + expect(entry).toBeDefined(); + expect(entry!.maskLayers![0]!.attributeValue).toBe(0); + }); +}); + +describe('planComposites — mask entry keys', () => { + const maskKeyOf = (doc: CanvasDocumentContractV2, kind: string): string | undefined => entryOfKind(doc, kind)?.key; + + it('is stable for identical documents', () => { + const a = makeDoc([inpaintMask('m1', { denoiseLimit: 0.5 })]); + const b = makeDoc([inpaintMask('m1', { denoiseLimit: 0.5 })]); + expect(maskKeyOf(a, 'inpaint-mask')).toBe(maskKeyOf(b, 'inpaint-mask')); + }); + + it('changes when the attribute value changes', () => { + const a = makeDoc([inpaintMask('m1', { denoiseLimit: 0.5 })]); + const b = makeDoc([inpaintMask('m1', { denoiseLimit: 0.6 })]); + expect(maskKeyOf(a, 'inpaint-mask')).not.toBe(maskKeyOf(b, 'inpaint-mask')); + }); + + it('changes when the mask bitmap changes', () => { + const a = makeDoc([inpaintMask('m1')]); + const b = makeDoc([inpaintMask('m1', { bitmap: imageRef('other', 100, 80) })]); + expect(maskKeyOf(a, 'inpaint-mask')).not.toBe(maskKeyOf(b, 'inpaint-mask')); + }); +}); + +const controlLayer = ( + id: string, + overrides: Partial<{ + source: CanvasControlLayerContract['source']; + isEnabled: boolean; + withTransparencyEffect: boolean; + opacity: number; + blendMode: CanvasLayerContract['blendMode']; + }> = {} +): CanvasLayerContract => ({ + adapter: { beginEndStepPct: [0, 1], controlMode: 'balanced', kind: 'controlnet', model: null, weight: 1 }, + blendMode: overrides.blendMode ?? 'normal', + id, + isEnabled: overrides.isEnabled ?? true, + isLocked: false, + name: id, + opacity: overrides.opacity ?? 1, + source: overrides.source ?? { image: imageRef(id, 64, 48), type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'control', + withTransparencyEffect: overrides.withTransparencyEffect ?? false, +}); + +describe('planComposites — control-layer exclusion', () => { + it('never includes control layers in the base-raster entry', () => { + const plan = planComposites(makeDoc([rasterLayer('r'), controlLayer('c')]), BBOX); + const base = plan.entries.find((entry) => entry.kind === 'base-raster'); + expect(base?.layers.map((layer) => layer.id)).toEqual(['r']); + }); + + it('emits no control-layer entries from planComposites (they live in planControlComposites)', () => { + const plan = planComposites(makeDoc([controlLayer('c')]), BBOX); + expect(plan.entries.some((entry) => entry.kind === 'control-layer')).toBe(false); + }); +}); + +describe('planControlComposites', () => { + it('emits one separate entry per enabled control layer with content', () => { + const composites = planControlComposites(makeDoc([controlLayer('c1'), controlLayer('c2')]), BBOX); + expect(composites.map((composite) => composite.layerId)).toEqual(['c1', 'c2']); + for (const { entry } of composites) { + expect(entry.kind).toBe('control-layer'); + expect(entry.bbox).toEqual(BBOX); + expect(entry.layers).toHaveLength(1); + // Each control layer is composited SEPARATELY, never blended with others. + expect(entry.layerId).toBe(entry.layers[0]!.id); + } + // Distinct keys per layer. + expect(composites[0]!.entry.key).not.toBe(composites[1]!.entry.key); + }); + + it('excludes disabled control layers and empty paint-source control layers', () => { + const composites = planControlComposites( + makeDoc([ + controlLayer('enabled'), + controlLayer('disabled', { isEnabled: false }), + controlLayer('empty', { source: { bitmap: null, type: 'paint' } }), + ]), + BBOX + ); + expect(composites.map((composite) => composite.layerId)).toEqual(['enabled']); + }); + + it('ignores raster/mask layers entirely', () => { + const composites = planControlComposites(makeDoc([rasterLayer('r'), inpaintMask('m'), controlLayer('c')]), BBOX); + expect(composites.map((composite) => composite.layerId)).toEqual(['c']); + }); + + it('forces opacity 1 / normal blend so display tweaks and the transparency effect never churn the key', () => { + const a = planControlComposites(makeDoc([controlLayer('c')]), BBOX); + const b = planControlComposites( + makeDoc([controlLayer('c', { opacity: 0.3, blendMode: 'multiply', withTransparencyEffect: true })]), + BBOX + ); + expect(a[0]!.entry.key).toBe(b[0]!.entry.key); + expect(a[0]!.entry.layers[0]!.opacity).toBe(1); + expect(a[0]!.entry.layers[0]!.blendMode).toBe('normal'); + }); + + it('changes the key when the control source pixels change', () => { + const a = planControlComposites(makeDoc([controlLayer('c')]), BBOX); + const b = planControlComposites( + makeDoc([controlLayer('c', { source: { image: imageRef('other', 64, 48), type: 'image' } })]), + BBOX + ); + expect(a[0]!.entry.key).not.toBe(b[0]!.entry.key); + }); +}); + +const regionalLayer = ( + id: string, + overrides: Partial = {} +): CanvasLayerContract => ({ + autoNegative: false, + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + mask: { bitmap: imageRef(`${id}-mask`), fill: { color: '#ff0000', style: 'solid' } }, + name: id, + negativePrompt: null, + opacity: 0.5, + positivePrompt: 'a cat', + referenceImages: [], + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'regional_guidance', + ...overrides, +}); + +describe('planRegionalMaskComposites', () => { + it('emits one alpha-mask entry per enabled region WITH mask content', () => { + const entries = planRegionalMaskComposites(makeDoc([regionalLayer('a'), regionalLayer('b')]), BBOX); + expect(entries).toHaveLength(2); + expect(entries[0]!.entry.kind).toBe('regional-mask'); + expect(entries[0]!.layerId).toBe('a'); + // Composited at opacity 1 / normal (display opacity 0.5 must NOT alter the mask alpha sent to the backend). + expect(entries[0]!.entry.layers[0]!.opacity).toBe(1); + expect(entries[0]!.entry.layers[0]!.blendMode).toBe('normal'); + }); + + it('skips regions with no mask bitmap and disabled regions', () => { + const entries = planRegionalMaskComposites( + makeDoc([ + regionalLayer('empty', { mask: { bitmap: null, fill: { color: '#f00', style: 'solid' } } }), + regionalLayer('off', { isEnabled: false }), + regionalLayer('ok'), + ]), + BBOX + ); + expect(entries.map((e) => e.layerId)).toEqual(['ok']); + }); + + it('keys are stable per region+bbox and change with the mask source', () => { + const a = planRegionalMaskComposites(makeDoc([regionalLayer('a')]), BBOX); + const b = planRegionalMaskComposites( + makeDoc([ + regionalLayer('a', { mask: { bitmap: imageRef('other-mask'), fill: { color: '#f00', style: 'solid' } } }), + ]), + BBOX + ); + expect(a[0]!.entry.key).not.toBe(b[0]!.entry.key); + }); +}); + +describe('planComposites — raster adjustments in the base key', () => { + it('folds a non-identity adjustment into the base-raster entry (and its key)', () => { + const plain = rasterLayer('a'); + const adjusted = rasterLayer('a', { adjustments: { brightness: 0.5, contrast: 0, saturation: 0 } }); + const plan = planComposites(makeDoc([adjusted]), BBOX); + const ref = plan.entries[0]!.layers[0]!; + expect(ref.adjustments).toEqual({ brightness: 0.5, contrast: 0, saturation: 0 }); + // A changed adjustment changes the entry key so the executor re-composites/uploads. + expect(keyOf(makeDoc([plain]))).not.toBe(keyOf(makeDoc([adjusted]))); + }); + + it('ignores an identity adjustment (no key churn)', () => { + const plain = rasterLayer('a'); + const identityAdj = rasterLayer('a', { adjustments: { brightness: 0, contrast: 0, saturation: 0 } }); + expect(plan0(plain)).toBe(plan0(identityAdj)); + }); +}); + +const plan0 = (layer: CanvasLayerContract): string => planComposites(makeDoc([layer]), BBOX).entries[0]!.key; diff --git a/invokeai/frontend/webv2/src/workbench/generation/canvas/compositePlan.ts b/invokeai/frontend/webv2/src/workbench/generation/canvas/compositePlan.ts new file mode 100644 index 00000000000..c612f105bf2 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/generation/canvas/compositePlan.ts @@ -0,0 +1,396 @@ +/** + * Pure composite planner. + * + * {@link planComposites} turns a canvas document + generation bbox into a + * {@link CompositePlan} — a pixel-free description of what the executor must + * composite and upload — without touching any pixels or the engine. Phase 4.2 + * emits a single `base-raster` entry: the enabled raster layers (image/paint + * sources) in z-order, each with its transform / opacity / blend mode. + * + * ## Stable identity key + * + * Each entry carries a `key` derived from exactly the inputs that determine its + * pixels: the bbox rect plus, for each contributing layer in z-order, its + * `(id, sourceRef, transform, opacity, blendMode)`. Only *enabled* raster + * layers contribute, so a layer's enablement is encoded by its presence in the + * list (toggling it on/off changes the set, and thus the key). This makes the + * key: + * - **stable**: same document + bbox → byte-identical key; + * - **sensitive** to reorder, opacity, transform, source swap, and bbox moves; + * - **insensitive** to irrelevant edits: `selectedLayerId`, background, and any + * change to a disabled layer (it never appears in the list). + * + * The key is a plain deterministic string (no hashing needed) so it stays cheap + * and debuggable; the executor uses it as a map key to skip redundant work. + */ + +import type { + CanvasControlLayerContract, + CanvasDocumentContractV2, + CanvasInpaintMaskLayerContract, + CanvasLayerContract, + CanvasLayerSourceContract, + CanvasRasterLayerContractV2, + CanvasRegionalGuidanceLayerContract, +} from '@workbench/types'; + +import { adjustmentsKey, isIdentityAdjustments } from '@workbench/canvas-engine/render/adjustments'; + +import type { CompositeEntry, CompositeLayerRef, CompositeMaskLayerRef, CompositePlan, Rect } from './types'; + +import { DEFAULT_MASK_DENOISE_LIMIT } from './types'; + +/** True when a layer is an enabled raster layer with rasterizable, non-empty pixels. */ +const isBaseRasterLayer = (layer: CanvasLayerContract): layer is CanvasRasterLayerContractV2 => { + if (!layer.isEnabled || layer.type !== 'raster') { + return false; + } + if (layer.source.type === 'image') { + return true; + } + // A paint layer only contributes once it holds pixels. An empty (`bitmap: + // null`) paint layer — e.g. an auto-created layer left blank after the stroke + // was undone — carries no pixels; including it would force a doc-sized + // transparent rect into the composite, so its full-bbox coverage plus scanned + // transparency reads as `outpaint` (unsupported) instead of `txt2img`. The + // orchestrator flushes pending paint uploads before planning, so a bitmap + // still `null` here really is empty rather than merely unpersisted. + return layer.source.type === 'paint' && layer.source.bitmap !== null; +}; + +/** A stable string identifying a source's pixels (its asset name, or an empty sentinel). */ +const sourceRefOf = (source: CanvasLayerSourceContract): string => { + switch (source.type) { + case 'image': + return `image:${source.image.imageName}`; + case 'paint': + return source.bitmap ? `paint:${source.bitmap.imageName}` : 'paint:empty'; + default: + // Only image/paint layers reach the planner (filtered by isBaseRasterLayer). + return `${source.type}:unsupported`; + } +}; + +/** The native (unscaled) content rect of a base-raster layer's source (layer-local). */ +const contentRectOf = ( + layer: CanvasRasterLayerContractV2, + doc: CanvasDocumentContractV2 +): { width: number; height: number; x: number; y: number } => { + const { source } = layer; + if (source.type === 'image') { + return { height: source.image.height, width: source.image.width, x: 0, y: 0 }; + } + // Paint layers are content-sized: the persisted bitmap dims at its offset. Only + // paint layers WITH a bitmap reach here (empty paint is filtered upstream), but + // default defensively to the document rect when absent. + if (source.type === 'paint' && source.bitmap) { + const offset = source.offset ?? { x: 0, y: 0 }; + return { height: source.bitmap.height, width: source.bitmap.width, x: offset.x, y: offset.y }; + } + return { height: doc.height, width: doc.width, x: 0, y: 0 }; +}; + +/** Projects a document layer into its frozen composite contribution. */ +const toLayerRef = (layer: CanvasRasterLayerContractV2, doc: CanvasDocumentContractV2): CompositeLayerRef => { + const rect = contentRectOf(layer, doc); + const hasAdjustments = !isIdentityAdjustments(layer.adjustments); + return { + blendMode: layer.blendMode, + contentOffset: { x: rect.x, y: rect.y }, + contentSize: { height: rect.height, width: rect.width }, + id: layer.id, + opacity: layer.opacity, + sourceRef: sourceRefOf(layer.source), + // Bake non-destructive adjustments into the generated pixels (and the key). + ...(hasAdjustments && layer.adjustments ? { adjustments: layer.adjustments } : {}), + transform: { + rotation: layer.transform.rotation, + scaleX: layer.transform.scaleX, + scaleY: layer.transform.scaleY, + x: layer.transform.x, + y: layer.transform.y, + }, + }; +}; + +/** Serializes a rect into a compact, deterministic key fragment. */ +const rectKey = (rect: Rect): string => `${rect.x},${rect.y},${rect.width},${rect.height}`; + +/** Serializes a layer ref's pixel-determining fields into a key fragment. */ +const layerKey = (ref: CompositeLayerRef): string => { + const t = ref.transform; + const o = ref.contentOffset; + return [ + ref.id, + ref.sourceRef, + o.x, + o.y, + t.x, + t.y, + t.scaleX, + t.scaleY, + t.rotation, + ref.opacity, + ref.blendMode, + ref.adjustments ? adjustmentsKey(ref.adjustments) : '-', + ].join(':'); +}; + +/** Derives the stable identity key for a base-raster entry. */ +const deriveKey = (kind: string, bbox: Rect, layers: CompositeLayerRef[]): string => + `${kind}|${rectKey(bbox)}|${layers.map(layerKey).join('|')}`; + +/** True when a layer is an enabled inpaint mask with persisted (non-empty) alpha. */ +const isActiveInpaintMaskLayer = (layer: CanvasLayerContract): layer is CanvasInpaintMaskLayerContract => + layer.isEnabled && layer.type === 'inpaint_mask' && layer.mask.bitmap !== null; + +/** The native content rect of a mask layer's persisted bitmap (layer-local, at its offset). */ +const maskContentRect = ( + layer: CanvasInpaintMaskLayerContract +): { width: number; height: number; x: number; y: number } => { + const { bitmap, offset } = layer.mask; + if (bitmap) { + const o = offset ?? { x: 0, y: 0 }; + return { height: bitmap.height, width: bitmap.width, x: o.x, y: o.y }; + } + return { height: 0, width: 0, x: 0, y: 0 }; +}; + +/** Projects a mask layer into a grayscale-composite contribution with its resolved attribute value. */ +const toMaskLayerRef = (layer: CanvasInpaintMaskLayerContract, attributeValue: number): CompositeMaskLayerRef => { + const rect = maskContentRect(layer); + return { + attributeValue, + contentOffset: { x: rect.x, y: rect.y }, + contentSize: { height: rect.height, width: rect.width }, + id: layer.id, + sourceRef: layer.mask.bitmap ? `mask:${layer.mask.bitmap.imageName}` : 'mask:empty', + transform: { + rotation: layer.transform.rotation, + scaleX: layer.transform.scaleX, + scaleY: layer.transform.scaleY, + x: layer.transform.x, + y: layer.transform.y, + }, + }; +}; + +/** Serializes a mask layer ref (adds `attributeValue` to the pixel-determining key). */ +const maskLayerKey = (ref: CompositeMaskLayerRef): string => { + const t = ref.transform; + const o = ref.contentOffset; + return [ref.id, ref.sourceRef, o.x, o.y, t.x, t.y, t.scaleX, t.scaleY, t.rotation, ref.attributeValue].join(':'); +}; + +/** Derives the stable identity key for a grayscale mask entry. */ +const deriveMaskKey = (kind: string, bbox: Rect, layers: CompositeMaskLayerRef[]): string => + `${kind}|${rectKey(bbox)}|${layers.map(maskLayerKey).join('|')}`; + +/** + * Plans the composites required to invoke `document` over `bbox`: + * - one `base-raster` entry (the initial image); + * - one `inpaint-mask` entry (grayscale denoise-limit mask) when enabled inpaint + * masks with content exist — an undefined `denoiseLimit` resolves to the legacy + * default (1.0, full denoise); + * - one `noise-mask` entry when at least one such mask defines a `noiseLevel` + * (masks with an undefined `noiseLevel` are excluded, mirroring legacy — they + * must NOT be treated as noise 0). + */ +export const planComposites = (document: CanvasDocumentContractV2, bbox: Rect): CompositePlan => { + const layers = document.layers.filter(isBaseRasterLayer).map((layer) => toLayerRef(layer, document)); + + const baseEntry: CompositeEntry = { + bbox, + key: deriveKey('base-raster', bbox, layers), + kind: 'base-raster', + layers, + }; + + const entries: CompositeEntry[] = [baseEntry]; + + const maskLayers = document.layers.filter(isActiveInpaintMaskLayer); + + if (maskLayers.length > 0) { + const denoiseRefs = maskLayers.map((layer) => + toMaskLayerRef(layer, layer.denoiseLimit ?? DEFAULT_MASK_DENOISE_LIMIT) + ); + entries.push({ + bbox, + key: deriveMaskKey('inpaint-mask', bbox, denoiseRefs), + kind: 'inpaint-mask', + layers: [], + maskLayers: denoiseRefs, + }); + + const noiseRefs = maskLayers + .filter((layer) => layer.noiseLevel !== undefined) + .map((layer) => toMaskLayerRef(layer, layer.noiseLevel as number)); + + if (noiseRefs.length > 0) { + entries.push({ + bbox, + key: deriveMaskKey('noise-mask', bbox, noiseRefs), + kind: 'noise-mask', + layers: [], + maskLayers: noiseRefs, + }); + } + } + + return { bbox, entries }; +}; + +/** True when a control layer is enabled and holds rasterizable (non-empty) content. */ +const hasControlContent = (layer: CanvasLayerContract): layer is CanvasControlLayerContract => { + if (!layer.isEnabled || layer.type !== 'control') { + return false; + } + if (layer.source.type === 'image') { + return true; + } + return layer.source.type === 'paint' && layer.source.bitmap !== null; +}; + +/** The native (unscaled) content rect of a control layer's source (layer-local). */ +const controlContentRect = ( + layer: CanvasControlLayerContract, + doc: CanvasDocumentContractV2 +): { width: number; height: number; x: number; y: number } => { + const { source } = layer; + if (source.type === 'image') { + return { height: source.image.height, width: source.image.width, x: 0, y: 0 }; + } + if (source.type === 'paint' && source.bitmap) { + const offset = source.offset ?? { x: 0, y: 0 }; + return { height: source.bitmap.height, width: source.bitmap.width, x: offset.x, y: offset.y }; + } + return { height: doc.height, width: doc.width, x: 0, y: 0 }; +}; + +/** + * Projects a control layer into a standalone composite contribution. Opacity is + * forced to 1 and blend mode to `normal` (the layer's DISPLAY opacity/blend and + * transparency effect never alter the control image sent to the backend — legacy + * rasterizes control at opacity 1, no filters), so display tweaks don't churn the + * entry key. + */ +const toControlLayerRef = (layer: CanvasControlLayerContract, doc: CanvasDocumentContractV2): CompositeLayerRef => { + const rect = controlContentRect(layer, doc); + return { + blendMode: 'normal', + contentOffset: { x: rect.x, y: rect.y }, + contentSize: { height: rect.height, width: rect.width }, + id: layer.id, + opacity: 1, + sourceRef: sourceRefOf(layer.source), + transform: { + rotation: layer.transform.rotation, + scaleX: layer.transform.scaleX, + scaleY: layer.transform.scaleY, + x: layer.transform.x, + y: layer.transform.y, + }, + }; +}; + +/** One control layer's separate composite (never blended with other control layers). */ +export interface ControlCompositeEntry { + layerId: string; + entry: CompositeEntry; +} + +/** + * Plans one composite per enabled control layer WITH content — each composited + * separately over `bbox` (legacy parity: control images are never blended + * together). Empty control layers (no source / blank paint) are excluded (they + * carry no control content and are rejected upstream with a "no control" reason). + * Control layers never contribute to the `base-raster` composite, so they never + * paint into the img2img/inpaint source. + */ +export const planControlComposites = (document: CanvasDocumentContractV2, bbox: Rect): ControlCompositeEntry[] => + document.layers.filter(hasControlContent).map((layer) => { + const ref = toControlLayerRef(layer, document); + return { + entry: { + bbox, + key: `control-layer|${layer.id}|${rectKey(bbox)}|${layerKey(ref)}`, + kind: 'control-layer', + layerId: layer.id, + layers: [ref], + }, + layerId: layer.id, + }; + }); + +/** True when a regional-guidance layer is enabled and holds a persisted (non-empty) mask. */ +const hasRegionalMaskContent = (layer: CanvasLayerContract): layer is CanvasRegionalGuidanceLayerContract => + layer.isEnabled && layer.type === 'regional_guidance' && layer.mask.bitmap !== null; + +/** The native content rect of a regional mask's persisted bitmap (layer-local, at its offset). */ +const regionalMaskContentRect = ( + layer: CanvasRegionalGuidanceLayerContract +): { width: number; height: number; x: number; y: number } => { + const { bitmap, offset } = layer.mask; + if (bitmap) { + const o = offset ?? { x: 0, y: 0 }; + return { height: bitmap.height, width: bitmap.width, x: o.x, y: o.y }; + } + return { height: 0, width: 0, x: 0, y: 0 }; +}; + +/** + * Projects a regional-guidance layer into a standalone ALPHA composite + * contribution (opacity 1, normal blend) — the executor composites the mask's + * alpha coverage over the bbox and uploads it, so `alpha_mask_to_tensor` can read + * the region's alpha. Display opacity/blend never alter the mask sent to the + * backend (mirrors control layers). + */ +const toRegionalMaskRef = (layer: CanvasRegionalGuidanceLayerContract): CompositeLayerRef => { + const rect = regionalMaskContentRect(layer); + return { + blendMode: 'normal', + contentOffset: { x: rect.x, y: rect.y }, + contentSize: { height: rect.height, width: rect.width }, + id: layer.id, + opacity: 1, + sourceRef: layer.mask.bitmap ? `mask:${layer.mask.bitmap.imageName}` : 'mask:empty', + transform: { + rotation: layer.transform.rotation, + scaleX: layer.transform.scaleX, + scaleY: layer.transform.scaleY, + x: layer.transform.x, + y: layer.transform.y, + }, + }; +}; + +/** One regional-guidance region's separate alpha-mask composite. */ +export interface RegionalMaskCompositeEntry { + layerId: string; + entry: CompositeEntry; +} + +/** + * Plans one alpha-mask composite per enabled regional-guidance layer WITH mask + * content — each composited separately over `bbox` (a region's mask feeds its own + * `alpha_mask_to_tensor`; regions are never combined). Regions without mask + * content carry no region and are skipped (they'd be rejected upstream with a + * "no region" reason). Regional layers never contribute to `base-raster`. + */ +export const planRegionalMaskComposites = ( + document: CanvasDocumentContractV2, + bbox: Rect +): RegionalMaskCompositeEntry[] => + document.layers.filter(hasRegionalMaskContent).map((layer) => { + const ref = toRegionalMaskRef(layer); + return { + entry: { + bbox, + key: `regional-mask|${layer.id}|${rectKey(bbox)}|${layerKey(ref)}`, + kind: 'regional-mask', + layerId: layer.id, + layers: [ref], + }, + layerId: layer.id, + }; + }); diff --git a/invokeai/frontend/webv2/src/workbench/generation/canvas/filterGraphs.test.ts b/invokeai/frontend/webv2/src/workbench/generation/canvas/filterGraphs.test.ts new file mode 100644 index 00000000000..b2e1eb86f4c --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/generation/canvas/filterGraphs.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildFilterDefaults, + buildFilterGraph, + CONTROL_FILTERS, + DEFAULT_CONTROL_FILTER_TYPE, + FILTER_NODE_ID, + getFilterDefinition, + isSupportedFilterType, +} from './filterGraphs'; + +/** The exact legacy defaults for each supported filter — the spec under test. */ +const EXPECTED_DEFAULTS: Record> = { + canny_edge_detection: { low_threshold: 100, high_threshold: 200 }, + depth_anything_depth_estimation: { model_size: 'small_v2' }, + dw_openpose_detection: { draw_body: true, draw_face: true, draw_hands: true }, + lineart_edge_detection: { coarse: false }, + hed_edge_detection: { scribble: false }, + mlsd_detection: { score_threshold: 0.1, distance_threshold: 20 }, + pidi_edge_detection: { quantize_edges: false, scribble: false }, + content_shuffle: { scale_factor: 256 }, + mediapipe_face_detection: { max_faces: 1, min_confidence: 0.5 }, + color_map: { tile_size: 64 }, +}; + +const EXPECTED_TYPES = Object.keys(EXPECTED_DEFAULTS); + +describe('CONTROL_FILTERS registry', () => { + it('contains exactly the ten legacy filter types', () => { + const types = CONTROL_FILTERS.map((definition) => definition.type); + expect(types).toHaveLength(10); + expect(new Set(types).size).toBe(10); + expect(types).toEqual(EXPECTED_TYPES); + }); + + it('projects each definition to its exact legacy defaults', () => { + for (const definition of CONTROL_FILTERS) { + expect(buildFilterDefaults(definition)).toEqual(EXPECTED_DEFAULTS[definition.type]); + } + }); + + it('names canny edge detection as the default control filter', () => { + expect(DEFAULT_CONTROL_FILTER_TYPE).toBe('canny_edge_detection'); + }); +}); + +describe('isSupportedFilterType / getFilterDefinition', () => { + it('recognises every supported filter', () => { + for (const type of EXPECTED_TYPES) { + expect(isSupportedFilterType(type)).toBe(true); + expect(getFilterDefinition(type)?.type).toBe(type); + } + }); + + it('rejects an unknown filter type', () => { + expect(isSupportedFilterType('not_a_filter')).toBe(false); + expect(getFilterDefinition('not_a_filter')).toBeUndefined(); + }); +}); + +describe('buildFilterGraph', () => { + const imageName = 'source-image.png'; + + it('builds a single-node graph with default params for every filter', () => { + for (const type of EXPECTED_TYPES) { + const { graph, outputNodeId } = buildFilterGraph(type, imageName); + + expect(outputNodeId).toBe(FILTER_NODE_ID); + expect(graph.edges).toEqual([]); + expect(typeof graph.id).toBe('string'); + expect(graph.id).toContain(type); + expect(Object.keys(graph.nodes)).toEqual([FILTER_NODE_ID]); + + const node = graph.nodes[FILTER_NODE_ID] as Record; + expect(node.id).toBe(FILTER_NODE_ID); + expect(node.type).toBe(type); + expect(node.image).toEqual({ image_name: imageName }); + // Previews run intermediate (out of the gallery, GC-eligible); only Apply + // promotes the chosen image to durable via makeImageDurable. + expect(node.is_intermediate).toBe(true); + expect(node.use_cache).toBe(true); + + // Every default param field is present on the node. + for (const [key, value] of Object.entries(EXPECTED_DEFAULTS[type])) { + expect(node[key]).toBe(value); + } + } + }); + + it('applies valid custom settings, overriding defaults', () => { + const { graph } = buildFilterGraph('canny_edge_detection', imageName, { + low_threshold: 50, + high_threshold: 220, + }); + const node = graph.nodes[FILTER_NODE_ID] as Record; + expect(node.low_threshold).toBe(50); + expect(node.high_threshold).toBe(220); + }); + + it('rounds non-integer values for integer params', () => { + const { graph } = buildFilterGraph('canny_edge_detection', imageName, { + low_threshold: 50.4, + high_threshold: 199.6, + }); + const node = graph.nodes[FILTER_NODE_ID] as Record; + expect(node.low_threshold).toBe(50); + expect(node.high_threshold).toBe(200); + }); + + it('accepts a finite non-integer for a non-integer number param', () => { + const { graph } = buildFilterGraph('mediapipe_face_detection', imageName, { + min_confidence: 0.75, + }); + const node = graph.nodes[FILTER_NODE_ID] as Record; + expect(node.min_confidence).toBe(0.75); + }); + + it('falls back to the default for wrong-typed number settings', () => { + const badString = buildFilterGraph('canny_edge_detection', imageName, { + low_threshold: 'bad', + }); + expect((badString.graph.nodes[FILTER_NODE_ID] as Record).low_threshold).toBe(100); + + const nan = buildFilterGraph('canny_edge_detection', imageName, { + low_threshold: Number.NaN, + }); + expect((nan.graph.nodes[FILTER_NODE_ID] as Record).low_threshold).toBe(100); + }); + + it('falls back to the default for an unknown enum value', () => { + const { graph } = buildFilterGraph('depth_anything_depth_estimation', imageName, { + model_size: 'nonsense', + }); + expect((graph.nodes[FILTER_NODE_ID] as Record).model_size).toBe('small_v2'); + }); + + it('accepts a valid enum value', () => { + const { graph } = buildFilterGraph('depth_anything_depth_estimation', imageName, { + model_size: 'large', + }); + expect((graph.nodes[FILTER_NODE_ID] as Record).model_size).toBe('large'); + }); + + it('falls back to the default for a non-boolean boolean setting', () => { + const { graph } = buildFilterGraph('dw_openpose_detection', imageName, { + draw_body: 'yes', + draw_face: 1, + }); + const node = graph.nodes[FILTER_NODE_ID] as Record; + expect(node.draw_body).toBe(true); + expect(node.draw_face).toBe(true); + expect(node.draw_hands).toBe(true); + }); + + it('honours a valid boolean override', () => { + const { graph } = buildFilterGraph('lineart_edge_detection', imageName, { coarse: true }); + expect((graph.nodes[FILTER_NODE_ID] as Record).coarse).toBe(true); + }); + + it('ignores unknown setting keys', () => { + const { graph } = buildFilterGraph('color_map', imageName, { unknown_key: 999 }); + const node = graph.nodes[FILTER_NODE_ID] as Record; + expect(node.tile_size).toBe(64); + expect(node.unknown_key).toBeUndefined(); + }); + + it('throws for an unknown filter type', () => { + expect(() => buildFilterGraph('not_a_filter', imageName)).toThrow(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/generation/canvas/filterGraphs.ts b/invokeai/frontend/webv2/src/workbench/generation/canvas/filterGraphs.ts new file mode 100644 index 00000000000..a9f0f8bad26 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/generation/canvas/filterGraphs.ts @@ -0,0 +1,194 @@ +/** + * Pure single-node control-filter graph builders (legacy parity). + * + * A control layer can non-destructively preview a filter (canny, depth, pose, …) + * over its composited source. This module names each supported filter, its + * legacy-default settings, and the one-node backend graph it compiles to — with + * NO fetch, NO engine, NO React. The executor composites + uploads the layer's + * source image, then {@link buildFilterGraph} shapes the graph the utility queue + * runs; its `outputNodeId` names the node whose output image is the preview. + * + * Node type strings + defaults mirror legacy + * `features/controlLayers/store/filters.ts` (`IMAGE_FILTERS`): every filter here + * builds a single node whose backend `type` equals the filter id, fed + * `image: { image_name }` plus the filter's parameters. The registry is the + * spec — settings editors read `buildDefaults()` and the param metadata. + */ + +import type { BackendGraphContract } from '@workbench/types'; + +/** The deterministic node id every single-node filter graph uses. */ +export const FILTER_NODE_ID = 'control_filter'; + +/** A filter parameter's UI + validation metadata (drives the settings editor). */ +export type FilterParamSpec = + | { kind: 'number'; key: string; default: number; min?: number; max?: number; step?: number; integer?: boolean } + | { kind: 'boolean'; key: string; default: boolean } + | { kind: 'enum'; key: string; default: string; options: readonly string[] }; + +/** One filter's identity, defaults, and node-arg projection. */ +export interface FilterDefinition { + /** The filter id (equals the backend node `type`). */ + type: string; + /** Ordered parameter specs (also the default-settings source). */ + params: readonly FilterParamSpec[]; +} + +/** Builds the default settings object for a filter definition. */ +export const buildFilterDefaults = (definition: FilterDefinition): Record => { + const settings: Record = {}; + for (const param of definition.params) { + settings[param.key] = param.default; + } + return settings; +}; + +/** + * The supported filters, in the legacy display order of the required set + * (`features/controlLayers/store/filters.ts`). Each id equals its backend node + * `type`; params carry legacy defaults + ranges. + */ +export const CONTROL_FILTERS: readonly FilterDefinition[] = [ + { + params: [ + { default: 100, integer: true, key: 'low_threshold', kind: 'number', max: 255, min: 0 }, + { default: 200, integer: true, key: 'high_threshold', kind: 'number', max: 255, min: 0 }, + ], + type: 'canny_edge_detection', + }, + { + params: [ + { + default: 'small_v2', + key: 'model_size', + kind: 'enum', + options: ['large', 'base', 'small', 'small_v2'], + }, + ], + type: 'depth_anything_depth_estimation', + }, + { + params: [ + { default: true, key: 'draw_body', kind: 'boolean' }, + { default: true, key: 'draw_face', kind: 'boolean' }, + { default: true, key: 'draw_hands', kind: 'boolean' }, + ], + type: 'dw_openpose_detection', + }, + { + params: [{ default: false, key: 'coarse', kind: 'boolean' }], + type: 'lineart_edge_detection', + }, + { + params: [{ default: false, key: 'scribble', kind: 'boolean' }], + type: 'hed_edge_detection', + }, + { + params: [ + { default: 0.1, key: 'score_threshold', kind: 'number', min: 0, step: 0.01 }, + { default: 20, key: 'distance_threshold', kind: 'number', min: 0, step: 0.1 }, + ], + type: 'mlsd_detection', + }, + { + params: [ + { default: false, key: 'quantize_edges', kind: 'boolean' }, + { default: false, key: 'scribble', kind: 'boolean' }, + ], + type: 'pidi_edge_detection', + }, + { + params: [{ default: 256, integer: true, key: 'scale_factor', kind: 'number', min: 1 }], + type: 'content_shuffle', + }, + { + params: [ + { default: 1, integer: true, key: 'max_faces', kind: 'number', min: 1 }, + { default: 0.5, key: 'min_confidence', kind: 'number', max: 1, min: 0, step: 0.01 }, + ], + type: 'mediapipe_face_detection', + }, + { + params: [{ default: 64, integer: true, key: 'tile_size', kind: 'number', min: 1 }], + type: 'color_map', + }, +]; + +const FILTERS_BY_TYPE: ReadonlyMap = new Map( + CONTROL_FILTERS.map((definition) => [definition.type, definition]) +); + +/** The default filter type applied when a control layer's filter section is enabled. */ +export const DEFAULT_CONTROL_FILTER_TYPE = 'canny_edge_detection'; + +/** Looks up a filter definition by its type id (`undefined` for unknown filters). */ +export const getFilterDefinition = (type: string): FilterDefinition | undefined => FILTERS_BY_TYPE.get(type); + +/** True when `type` names a supported control filter. */ +export const isSupportedFilterType = (type: string): boolean => FILTERS_BY_TYPE.has(type); + +/** Coerces arbitrary stored settings to the filter's params, falling back to each default. */ +const resolveSettings = ( + definition: FilterDefinition, + settings: Record | undefined +): Record => { + const resolved: Record = {}; + for (const param of definition.params) { + const value = settings?.[param.key]; + if (param.kind === 'number' && typeof value === 'number' && Number.isFinite(value)) { + resolved[param.key] = param.integer ? Math.round(value) : value; + } else if (param.kind === 'boolean' && typeof value === 'boolean') { + resolved[param.key] = value; + } else if (param.kind === 'enum' && typeof value === 'string' && param.options.includes(value)) { + resolved[param.key] = value; + } else { + resolved[param.key] = param.default; + } + } + return resolved; +}; + +/** The result of {@link buildFilterGraph}: a one-node graph + the output node id. */ +export interface FilterGraphResult { + graph: BackendGraphContract; + outputNodeId: string; +} + +/** + * Builds the single-node filter graph for `filterType` over `imageName`. Throws + * for an unknown filter type. Unknown / out-of-range settings fall back to the + * filter's legacy defaults, so a malformed persisted `filter.settings` still + * produces a valid graph. The output image is INTERMEDIATE (`is_intermediate: + * true`): every preview click runs this graph, and an intermediate output stays + * out of the gallery and is garbage-collected. Only on "Apply" does the caller + * promote the chosen image to durable (`makeImageDurable`), so it survives as + * the layer's new source without every preview littering the gallery. + */ +export const buildFilterGraph = ( + filterType: string, + imageName: string, + settings?: Record +): FilterGraphResult => { + const definition = FILTERS_BY_TYPE.get(filterType); + if (!definition) { + throw new Error(`Unknown control filter "${filterType}".`); + } + + const node = { + id: FILTER_NODE_ID, + image: { image_name: imageName }, + is_intermediate: true, + type: definition.type, + use_cache: true, + ...resolveSettings(definition, settings), + }; + + return { + graph: { + edges: [], + id: `control-filter-${definition.type}`, + nodes: { [FILTER_NODE_ID]: node }, + }, + outputNodeId: FILTER_NODE_ID, + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/generation/canvas/types.ts b/invokeai/frontend/webv2/src/workbench/generation/canvas/types.ts new file mode 100644 index 00000000000..e45637b9cf8 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/generation/canvas/types.ts @@ -0,0 +1,225 @@ +/** + * Shared vocabulary for the canvas → generation-graph pipeline. + * + * This module is pure data: it names the generation mode a canvas invoke maps + * to and the shape of a *composite plan* — a pixel-free description of what the + * executor must composite and upload before a graph can be built. It has no + * runtime dependency on the canvas engine (types are structural), so the + * planner (`compositePlan.ts`) and mode detector (`canvasMode.ts`) stay pure, + * node-testable, data-in/data-out functions. + * + * The plan is deliberately open-ended: Phase 4.2 only emits the single + * `base-raster` composite, but later phases add `control-layer`, + * `regional-mask`, and `inpaint-mask` composites to the same {@link CompositePlan} + * structure — each a {@link CompositeEntry} with its own stable identity key. + */ + +import type { GenerateModelConfig, GenerateSettings } from '@workbench/generation/types'; +import type { + BackendGraphContract, + CanvasAdjustmentsContract, + CanvasBlendMode, + GraphContract, + ProjectSettings, + ResultDestination, +} from '@workbench/types'; +import type { CanvasCompositingSettings } from '@workbench/widgets/canvas/invoke/canvasCompositing'; + +import type { ControlLayerGraphInput } from './addControlLayers'; +import type { RegionalGuidanceInput } from './addRegionalGuidance'; + +/** The legacy-parity generation modes a canvas invoke resolves to. */ +export type CanvasGenerationMode = 'txt2img' | 'img2img' | 'inpaint' | 'outpaint'; + +/** + * Legacy default resolution for undefined mask attributes (`addInpaint.ts`): + * a fresh mask with `denoiseLimit === undefined` denoises fully (1.0), and a + * fresh mask with `noiseLevel === undefined` is EXCLUDED from the noise + * composite entirely (no `img_noise`) — it must not be treated as noise 0. + */ +export const DEFAULT_MASK_DENOISE_LIMIT = 1; + +/** An axis-aligned rectangle in document space (structurally the engine's `Rect`). */ +export interface Rect { + x: number; + y: number; + width: number; + height: number; +} + +/** A layer's 2D transform (translate / scale / rotate), mirrored from the contract. */ +export interface CompositeLayerTransform { + x: number; + y: number; + scaleX: number; + scaleY: number; + rotation: number; +} + +/** + * A single layer's contribution to a composite entry — the frozen, pixel- + * determining projection of a document layer. The executor draws these in + * z-order; the planner derives them from the live document. + */ +export interface CompositeLayerRef { + /** The document layer id (used to fetch its rasterized cache surface). */ + id: string; + /** + * A stable string identifying the layer's pixels: the resolved image name for + * image/paint bitmaps (or a sentinel for an empty paint layer). Changing the + * underlying asset changes this, and so changes the entry key. + */ + sourceRef: string; + /** Native (unscaled) pixel size of the layer's source, used for content-bounds math. */ + contentSize: { width: number; height: number }; + /** + * Layer-local origin of the content (its cache surface's top-left) — non-zero + * for content-sized paint layers whose persisted bitmap sits at an `offset`. + * `{ x: 0, y: 0 }` for origin-anchored sources (image, legacy paint). The + * executor draws the layer's cache at this local origin (then through the + * transform), and content-bounds math projects the rect from here. + */ + contentOffset: { x: number; y: number }; + transform: CompositeLayerTransform; + opacity: number; + blendMode: CanvasBlendMode; + /** + * Non-destructive raster adjustments (brightness/contrast/saturation/curves) to + * bake into this layer's pixels before compositing, so the generated image uses + * the ADJUSTED pixels the user sees. Present only for raster layers with a + * non-identity adjustment; folded into the entry key so a changed adjustment + * re-composites/uploads. Absent ⇒ raw pixels. + */ + adjustments?: CanvasAdjustmentsContract; +} + +/** The kind of composite an entry describes. */ +export type CompositeEntryKind = 'base-raster' | 'inpaint-mask' | 'noise-mask' | 'control-layer' | 'regional-mask'; + +/** + * A mask layer's contribution to a grayscale mask composite. The executor draws + * each mask's alpha and colors it by `attributeValue` (white = keep, darker = + * inpaint), darken-compositing multiple masks over a white background, exactly + * like the legacy `getGrayscaleMaskCompositeImageDTO`. + */ +export interface CompositeMaskLayerRef { + /** The document layer id (used to fetch its rasterized mask alpha surface). */ + id: string; + /** Stable pixel identity (the mask bitmap name, or an empty sentinel). */ + sourceRef: string; + contentSize: { width: number; height: number }; + contentOffset: { x: number; y: number }; + transform: CompositeLayerTransform; + /** + * The per-layer attribute value in [0, 1] driving the grayscale gray value + * (`255 - round(255 * attributeValue)` for masked pixels). For the + * `inpaint-mask` (denoise-limit) entry, an undefined `denoiseLimit` resolves + * to {@link DEFAULT_MASK_DENOISE_LIMIT}; for the `noise-mask` entry it is the + * layer's `noiseLevel` (only layers with a defined `noiseLevel` appear). + */ + attributeValue: number; +} + +/** + * One composite the executor must produce: a set of layers composited over a + * bbox into a single uploadable image. `key` is a content-identity hash of + * everything that determines the entry's pixels (see {@link CompositePlan}). + */ +export interface CompositeEntry { + kind: CompositeEntryKind; + /** + * Stable identity key: same document + bbox → same key, so the executor can + * skip recompositing / re-uploading when nothing that affects these pixels + * changed between invokes. + */ + key: string; + /** The bbox (document space) the entry is composited over and cropped to. */ + bbox: Rect; + /** Contributing raster layers in document z-order (`base-raster` entries). */ + layers: CompositeLayerRef[]; + /** Contributing mask layers, for grayscale mask entries (`inpaint-mask` / `noise-mask`). */ + maskLayers?: CompositeMaskLayerRef[]; + /** + * The single document layer id a `control-layer` entry composites (each + * enabled control layer is composited SEPARATELY, never blended together). + */ + layerId?: string; +} + +/** + * A pixel-free description of everything an invoke must composite/upload. Phase + * 4.2 emits exactly one `base-raster` entry; later phases append more. + */ +export interface CompositePlan { + /** The bbox (document space) the plan is scoped to. */ + bbox: Rect; + entries: CompositeEntry[]; +} + +/** The generation modes the pure canvas graph compiler supports (full matrix). */ +export type CanvasCompileMode = CanvasGenerationMode; + +/** + * Everything {@link import('./compileCanvasGraph').compileCanvasGraph} needs to + * build a canvas generation graph. Pure data: the executor (Task 16) supplies + * the already-uploaded `compositeImageName`; no pixels or engine state leak in. + */ +export interface CompileCanvasGraphInput { + /** Prompts / steps / model-adjacent settings, reused verbatim from Generate. */ + settings: GenerateSettings; + model: GenerateModelConfig; + projectSettings: Pick; + /** The resolved canvas mode (from `canvasMode.ts`). */ + mode: CanvasCompileMode; + /** + * The resolved result destination. `canvas` marks the output node intermediate + * (staging); `gallery` makes it a durable image, matching `compileGenerateGraph`. + */ + destination: ResultDestination; + /** The generation bounding box, in document space. Its size overrides settings dims. */ + bbox: Rect; + /** The uploaded bbox composite (executor result). Required for image-referencing modes. */ + compositeImageName: string | null; + /** + * The uploaded grayscale denoise-limit mask (white = keep, dark = inpaint). + * Required for `inpaint` / `outpaint`. + */ + maskImageName?: string | null; + /** + * The uploaded grayscale noise mask, present only when at least one enabled + * mask layer defines a `noiseLevel`. Adds an `img_noise` node before encode. + */ + noiseMaskImageName?: string | null; + /** Denoising strength in (0, 1]. Consulted for `img2img` / `inpaint` / `outpaint`. */ + strength: number; + /** Infill / coherence / mask-blur knobs. Defaults applied by the reader. */ + compositing?: CanvasCompositingSettings; + /** + * Valid, already-resolved control layers (each with its own uploaded composite + * image name). The executor filters + composites these; the compiler only + * grafts adapter nodes. Present in every mode (control works with all). + */ + controlLayers?: readonly ControlLayerGraphInput[]; + /** + * Valid, already-resolved regional-guidance regions (each with its own uploaded + * mask image name + resolved reference-image models). The executor filters + + * composites these; the compiler only grafts the mask-tensor + conditioning + * nodes into the base graph's `pos_cond_collect` / `neg_cond_collect`. Applied + * only for supported bases (sd-1 / sdxl / flux). + */ + regionalGuidance?: readonly RegionalGuidanceInput[]; +} + +/** + * Mirrors {@link import('../types').CompiledGenerateGraph} with the resolved + * canvas mode attached so downstream (Task 18) can label / branch without + * re-deriving it. + */ +export interface CompiledCanvasGraph { + backendGraph: BackendGraphContract; + graph: GraphContract; + negativePromptNodeId: string; + positivePromptNodeId: string; + seedNodeId: string; + mode: CanvasCompileMode; +} diff --git a/invokeai/frontend/webv2/src/workbench/generation/graph.ts b/invokeai/frontend/webv2/src/workbench/generation/graph.ts index b336c5d733e..95054d3254b 100644 --- a/invokeai/frontend/webv2/src/workbench/generation/graph.ts +++ b/invokeai/frontend/webv2/src/workbench/generation/graph.ts @@ -1,6 +1,5 @@ import type { BackendGraphContract, - BackendGraphEdgeContract, BackendInvocationContract, ProjectSettings, ResultDestination, @@ -34,52 +33,16 @@ import { isNonAnimaQwen3Encoder, isVaeForBases, } from './componentCompatibility'; -import { isLoraCompatibleWithModel, SEED_MAX } from './settings'; - -const now = (): string => new Date().toISOString(); - -const createId = (prefix: string): string => - `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; - -const addNode = (graph: BackendGraphContract, node: T): T => { - graph.nodes[node.id] = { - is_intermediate: true, - use_cache: true, - ...node, - }; - - return graph.nodes[node.id] as T; -}; - -const addEdge = ( - graph: BackendGraphContract, - source: BackendInvocationContract, - sourceField: string, - destination: BackendInvocationContract, - destinationField: string -): BackendGraphEdgeContract => { - const edge = { - destination: { field: destinationField, node_id: destination.id }, - source: { field: sourceField, node_id: source.id }, - }; - graph.edges.push(edge); - - return edge; -}; - -const getActiveCompatibleLoras = (settings: GenerateSettings, model: GenerateModelConfig): GenerateLora[] => - settings.loras.filter((lora) => lora.isEnabled && isLoraCompatibleWithModel(lora.model, model)); - -const toModelIdentifier = ( - model: { base: string; key: string; name: string; type: string } & Record -) => ({ - base: model.base, - key: model.key, - name: model.name, - type: model.type, - ...(typeof model.hash === 'string' ? { hash: model.hash } : {}), - ...(typeof model.submodel_type === 'string' ? { submodel_type: model.submodel_type } : {}), -}); +import { + addEdge, + addMetadata, + addNode, + createId, + getActiveCompatibleLoras, + toModelIdentifier, + toGraphContract, +} from './graphBuilder'; +import { SEED_MAX } from './settings'; const getCompatibleComponentSource = ( settings: GenerateSettings, @@ -324,52 +287,6 @@ const addTransformerLoraCollectionLoader = ( return loader; }; -const addMetadata = ( - graph: BackendGraphContract, - outputNode: BackendInvocationContract, - settings: GenerateSettings, - model: GenerateModelConfig, - generationMode: string | null, - projectSettings: Pick, - extras: Record = {} -) => { - const activeLoras = getActiveCompatibleLoras(settings, model); - const scheduler = coerceSchedulerForGraph(model, settings.scheduler); - const metadata = addNode(graph, { - cfg_scale: settings.cfgScale, - cfg_rescale_multiplier: settings.cfgRescaleMultiplier, - ...(generationMode ? { generation_mode: generationMode } : {}), - height: settings.height, - id: createId('core_metadata'), - model, - rand_device: projectSettings.useCpuNoise ? 'cpu' : 'cuda', - scheduler, - seamless_x: settings.seamlessXAxis, - seamless_y: settings.seamlessYAxis, - steps: settings.steps, - type: 'core_metadata', - vae: settings.vae ?? undefined, - width: settings.width, - ...extras, - ...(activeLoras.length - ? { - loras: activeLoras.map((lora) => ({ - model: toModelIdentifier(lora.model), - weight: lora.weight, - })), - } - : {}), - ...(model.base === 'sdxl' ? {} : { clip_skip: settings.clipSkip }), - }); - - addEdge(graph, graph.nodes.seed, 'value', metadata, 'seed'); - addEdge(graph, graph.nodes.positive_prompt, 'value', metadata, 'positive_prompt'); - if (graph.nodes.negative_prompt) { - addEdge(graph, graph.nodes.negative_prompt, 'value', metadata, 'negative_prompt'); - } - addEdge(graph, metadata, 'metadata', outputNode, 'metadata'); -}; - const buildSDGraph = ( settings: GenerateSettings, model: MainModelConfig, @@ -1073,7 +990,7 @@ type GenerateGraphBuilder = ( projectSettings: Pick ) => BackendGraphContract; -const GRAPH_BUILDERS = { +export const GRAPH_BUILDERS = { 'sd-1': buildSDGraph, 'sd-2': buildSDGraph, sdxl: buildSDGraph, @@ -1086,22 +1003,6 @@ const GRAPH_BUILDERS = { anima: buildAnimaGraph, } satisfies Record; -const toGraphContract = (backendGraph: BackendGraphContract, label: string) => ({ - backendGraph, - edges: backendGraph.edges.map((edge, index) => ({ - id: `edge-${index}`, - sourceField: edge.source.field, - sourceNodeId: edge.source.node_id, - targetField: edge.destination.field, - targetNodeId: edge.destination.node_id, - })), - id: backendGraph.id, - label, - nodes: Object.values(backendGraph.nodes).map(({ id, type, ...inputs }) => ({ id, inputs, type })), - updatedAt: now(), - version: 1 as const, -}); - export const compileGenerateGraph = ( settings: GenerateSettings, model: GenerateModelConfig, diff --git a/invokeai/frontend/webv2/src/workbench/generation/graphBuilder.ts b/invokeai/frontend/webv2/src/workbench/generation/graphBuilder.ts new file mode 100644 index 00000000000..bfae73f1609 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/generation/graphBuilder.ts @@ -0,0 +1,118 @@ +import type { + BackendGraphContract, + BackendGraphEdgeContract, + BackendInvocationContract, + ProjectSettings, +} from '@workbench/types'; + +import type { GenerateLora, GenerateModelConfig, GenerateSettings } from './types'; + +import { coerceSchedulerForGraph } from './baseGenerationPolicies'; +import { isLoraCompatibleWithModel } from './settings'; + +const now = (): string => new Date().toISOString(); + +export const createId = (prefix: string): string => + `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; + +export const addNode = (graph: BackendGraphContract, node: T): T => { + graph.nodes[node.id] = { + is_intermediate: true, + use_cache: true, + ...node, + }; + + return graph.nodes[node.id] as T; +}; + +export const addEdge = ( + graph: BackendGraphContract, + source: BackendInvocationContract, + sourceField: string, + destination: BackendInvocationContract, + destinationField: string +): BackendGraphEdgeContract => { + const edge = { + destination: { field: destinationField, node_id: destination.id }, + source: { field: sourceField, node_id: source.id }, + }; + graph.edges.push(edge); + + return edge; +}; + +export const getActiveCompatibleLoras = (settings: GenerateSettings, model: GenerateModelConfig): GenerateLora[] => + settings.loras.filter((lora) => lora.isEnabled && isLoraCompatibleWithModel(lora.model, model)); + +export const toModelIdentifier = ( + model: { base: string; key: string; name: string; type: string } & Record +) => ({ + base: model.base, + key: model.key, + name: model.name, + type: model.type, + ...(typeof model.hash === 'string' ? { hash: model.hash } : {}), + ...(typeof model.submodel_type === 'string' ? { submodel_type: model.submodel_type } : {}), +}); + +export const addMetadata = ( + graph: BackendGraphContract, + outputNode: BackendInvocationContract, + settings: GenerateSettings, + model: GenerateModelConfig, + generationMode: string | null, + projectSettings: Pick, + extras: Record = {} +) => { + const activeLoras = getActiveCompatibleLoras(settings, model); + const scheduler = coerceSchedulerForGraph(model, settings.scheduler); + const metadata = addNode(graph, { + cfg_scale: settings.cfgScale, + cfg_rescale_multiplier: settings.cfgRescaleMultiplier, + ...(generationMode ? { generation_mode: generationMode } : {}), + height: settings.height, + id: createId('core_metadata'), + model, + rand_device: projectSettings.useCpuNoise ? 'cpu' : 'cuda', + scheduler, + seamless_x: settings.seamlessXAxis, + seamless_y: settings.seamlessYAxis, + steps: settings.steps, + type: 'core_metadata', + vae: settings.vae ?? undefined, + width: settings.width, + ...extras, + ...(activeLoras.length + ? { + loras: activeLoras.map((lora) => ({ + model: toModelIdentifier(lora.model), + weight: lora.weight, + })), + } + : {}), + ...(model.base === 'sdxl' ? {} : { clip_skip: settings.clipSkip }), + }); + + addEdge(graph, graph.nodes.seed, 'value', metadata, 'seed'); + addEdge(graph, graph.nodes.positive_prompt, 'value', metadata, 'positive_prompt'); + if (graph.nodes.negative_prompt) { + addEdge(graph, graph.nodes.negative_prompt, 'value', metadata, 'negative_prompt'); + } + addEdge(graph, metadata, 'metadata', outputNode, 'metadata'); +}; + +export const toGraphContract = (backendGraph: BackendGraphContract, label: string) => ({ + backendGraph, + edges: backendGraph.edges.map((edge, index) => ({ + id: `edge-${index}`, + sourceField: edge.source.field, + sourceNodeId: edge.source.node_id, + targetField: edge.destination.field, + targetNodeId: edge.destination.node_id, + })), + id: backendGraph.id, + label, + nodes: Object.values(backendGraph.nodes).map(({ id, type, ...inputs }) => ({ id, inputs, type })), + updatedAt: now(), + version: 1 as const, +}); diff --git a/invokeai/frontend/webv2/src/workbench/graph-preview/GraphPreviewDialog.test.ts b/invokeai/frontend/webv2/src/workbench/graph-preview/GraphPreviewDialog.test.ts new file mode 100644 index 00000000000..a61cbe6eeae --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/graph-preview/GraphPreviewDialog.test.ts @@ -0,0 +1,138 @@ +import type { + GenerateWidgetValues, + MainModelConfig, + VaeModelConfig, + ComponentModelConfig, +} from '@workbench/generation/types'; + +import { getDefaultGenerateSettings } from '@workbench/generation/baseGenerationPolicies'; +import { createInitialWorkbenchState, workbenchReducer } from '@workbench/workbenchState'; +import { describe, expect, it, vi } from 'vitest'; + +import { resolveAndSubmitGraphPreviewInvocation } from './GraphPreviewDialog'; + +const animaModel: MainModelConfig = { base: 'anima', key: 'anima-model', name: 'Anima', type: 'main' }; +const animaVae: VaeModelConfig = { base: 'qwen-image', key: 'anima-vae', name: 'Anima VAE', type: 'vae' }; +const qwen3Encoder: ComponentModelConfig = { + base: 'any', + key: 'qwen3-encoder', + name: 'Qwen3 Encoder', + type: 'qwen3_encoder', + variant: 'qwen3_06b', +}; + +const createGenerateValues = (overrides: Partial = {}): GenerateWidgetValues => ({ + ...getDefaultGenerateSettings(animaModel), + model: animaModel, + modelKey: animaModel.key, + positivePrompt: 'an anima prompt', + qwen3EncoderModel: qwen3Encoder, + vae: animaVae, + ...overrides, +}); + +const getActiveProject = (values: GenerateWidgetValues) => { + const state = workbenchReducer(createInitialWorkbenchState(), { type: 'setGenerateSettings', values }); + const project = state.projects.find((candidate) => candidate.id === state.activeProjectId); + + expect(project).toBeDefined(); + + return project!; +}; + +// Mounts the canvas widget into the center region so `resolveInvocationRoute` +// treats a `canvas` source as available (mirrors invocation.test.ts's +// `canvasInputFor` mountedWidgetIds override, but here as an actual project +// mutation since `resolveAndSubmitGraphPreviewInvocation` takes a real Project). +const withCanvasWidgetMounted = ( + project: ReturnType +): ReturnType => ({ + ...project, + widgetRegions: { + ...project.widgetRegions, + center: { + ...project.widgetRegions.center, + activeInstanceId: 'canvas', + instanceIds: ['canvas'], + }, + }, +}); + +describe('resolveAndSubmitGraphPreviewInvocation', () => { + it('routes a canvas source through prepareCanvasInvocation and does not dispatch a resolved snapshot', () => { + const project = withCanvasWidgetMounted(getActiveProject(createGenerateValues())); + const dispatch = vi.fn(); + const prepareCanvasInvocation = vi.fn(); + + const submitted = resolveAndSubmitGraphPreviewInvocation({ + dispatch, + models: undefined, + prepareCanvasInvocation, + project, + sourceId: 'canvas', + }); + + expect(submitted).toBe(true); + expect(dispatch).not.toHaveBeenCalled(); + expect(prepareCanvasInvocation).toHaveBeenCalledTimes(1); + expect(prepareCanvasInvocation.mock.calls[0]?.[0]).toMatchObject({ projectId: project.id }); + }); + + it('dispatches submitResolvedInvocationSnapshot for a non-canvas source and never prepares the canvas', () => { + const project = getActiveProject(createGenerateValues()); + const dispatch = vi.fn(); + const prepareCanvasInvocation = vi.fn(); + + const submitted = resolveAndSubmitGraphPreviewInvocation({ + dispatch, + models: undefined, + prepareCanvasInvocation, + project, + sourceId: 'generate', + }); + + expect(submitted).toBe(true); + expect(prepareCanvasInvocation).not.toHaveBeenCalled(); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch.mock.calls[0]?.[0]).toMatchObject({ + route: expect.objectContaining({ sourceId: 'generate' }), + type: 'submitResolvedInvocationSnapshot', + }); + }); + + it('returns false and does not submit when there is no sourceId', () => { + const project = getActiveProject(createGenerateValues()); + const dispatch = vi.fn(); + const prepareCanvasInvocation = vi.fn(); + + const submitted = resolveAndSubmitGraphPreviewInvocation({ + dispatch, + models: undefined, + prepareCanvasInvocation, + project, + sourceId: undefined, + }); + + expect(submitted).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + expect(prepareCanvasInvocation).not.toHaveBeenCalled(); + }); + + it('returns false and does not submit when the resolved route is invalid', () => { + const project = getActiveProject(createGenerateValues({ qwen3EncoderModel: undefined })); + const dispatch = vi.fn(); + const prepareCanvasInvocation = vi.fn(); + + const submitted = resolveAndSubmitGraphPreviewInvocation({ + dispatch, + models: undefined, + prepareCanvasInvocation, + project, + sourceId: 'generate', + }); + + expect(submitted).toBe(false); + expect(dispatch).not.toHaveBeenCalled(); + expect(prepareCanvasInvocation).not.toHaveBeenCalled(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/graph-preview/GraphPreviewDialog.tsx b/invokeai/frontend/webv2/src/workbench/graph-preview/GraphPreviewDialog.tsx index 5b9bc1fbc7d..20f65aa22c2 100644 --- a/invokeai/frontend/webv2/src/workbench/graph-preview/GraphPreviewDialog.tsx +++ b/invokeai/frontend/webv2/src/workbench/graph-preview/GraphPreviewDialog.tsx @@ -1,4 +1,6 @@ -import type { GraphContract, GraphId, InvocationSourceId } from '@workbench/types'; +import type { ModelConfig } from '@workbench/models/types'; +import type { GraphContract, GraphId, InvocationSourceId, Project } from '@workbench/types'; +import type { WorkbenchAction } from '@workbench/workbenchState'; import type { XYPosition } from '@workbench/workflows/types'; import { Box, Dialog, Portal, SegmentGroup, Text } from '@chakra-ui/react'; @@ -10,7 +12,9 @@ import { resolveInvocationRoute, resolveInvocationRouteInput, } from '@workbench/invocation'; +import { submitResolvedInvocation } from '@workbench/invocationSubmit'; import { ensureModelsLoaded, useModelsSelector } from '@workbench/models/modelsStore'; +import { prepareCanvasInvocation } from '@workbench/widgets/canvas/invoke/prepareCanvasInvocation'; import { flushGenerateDrafts } from '@workbench/widgets/generate/generateDraftRegistry'; import { useActiveProjectSelector, useWorkbenchDispatch, useWorkbenchStore } from '@workbench/WorkbenchContext'; import { useInvocationTemplatesSelector } from '@workbench/workflows/templates'; @@ -19,6 +23,49 @@ import { useTranslation } from 'react-i18next'; import { GraphPreviewFlow } from './GraphPreviewFlow'; +export interface GraphPreviewInvokeDeps { + dispatch: (action: WorkbenchAction) => void; + models: readonly ModelConfig[] | undefined; + prepareCanvasInvocation: typeof prepareCanvasInvocation; + project: Project; + sourceId: InvocationSourceId | undefined; +} + +/** + * Resolves the dialog-locked invocation route for `sourceId` against the + * post-flush project and, when valid, submits it through the shared + * canvas-vs-generate submit decision (`submitResolvedInvocation`) — the same + * routing the topbar Invoke control and the Invoke hotkey use. Without this, + * a valid Canvas route would dispatch `submitResolvedInvocationSnapshot` + * directly, which silently no-ops for a canvas source. Returns whether the + * route was valid and submitted, so the dialog only closes on success. + */ +export const resolveAndSubmitGraphPreviewInvocation = ({ + dispatch, + models, + prepareCanvasInvocation: prepareCanvas, + project, + sourceId, +}: GraphPreviewInvokeDeps): boolean => { + if (!sourceId) { + return false; + } + + const route = resolveInvocationRoute( + project, + 'dialog', + { ...project.invocation, sourceId, sourceLocked: true }, + models + ); + + if (!isInvocationRouteValid(route)) { + return false; + } + + submitResolvedInvocation({ dispatch, models, prepareCanvasInvocation: prepareCanvas, project, route }); + return true; +}; + interface GraphPreviewDialogProps { graph: GraphContract | null; graphId: GraphId; @@ -87,26 +134,17 @@ export const GraphPreviewDialog = ({ const invokeRoute = useCallback(() => { flushGenerateDrafts(); const postFlushProject = store.getSnapshot().activeProject; - const postFlushRoute = sourceId - ? resolveInvocationRoute( - postFlushProject, - 'dialog', - { ...postFlushProject.invocation, sourceId, sourceLocked: true }, - availabilityModels - ) - : null; - - if (!postFlushRoute || !isInvocationRouteValid(postFlushRoute)) { - return; - } - - dispatch({ - backendSupportsCancellation: true, + const submitted = resolveAndSubmitGraphPreviewInvocation({ + dispatch, models: availabilityModels, - route: postFlushRoute, - type: 'submitResolvedInvocationSnapshot', + prepareCanvasInvocation, + project: postFlushProject, + sourceId, }); - onOpenChange(false); + + if (submitted) { + onOpenChange(false); + } }, [availabilityModels, dispatch, onOpenChange, sourceId, store]); const jsonLabel = useMemo(() => t('graphPreview.graphJsonLabel', { title }), [t, title]); diff --git a/invokeai/frontend/webv2/src/workbench/hotkeys/WorkbenchHotkeyRuntime.test.ts b/invokeai/frontend/webv2/src/workbench/hotkeys/WorkbenchHotkeyRuntime.test.ts index fb484079568..27887cfe17f 100644 --- a/invokeai/frontend/webv2/src/workbench/hotkeys/WorkbenchHotkeyRuntime.test.ts +++ b/invokeai/frontend/webv2/src/workbench/hotkeys/WorkbenchHotkeyRuntime.test.ts @@ -1,8 +1,9 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { RegisteredHotkey } from './types'; +import type { HotkeyContext, RegisteredHotkey } from './types'; -import { getHotkeyExecutionSource } from './WorkbenchHotkeyRuntime'; +import { resolveHotkey } from './resolve'; +import { getHotkeyExecutionSource, shouldPreventHotkeyDefault } from './WorkbenchHotkeyRuntime'; const source = { instanceId: 'alpha', projectId: 'project-1', region: 'right', typeId: 'test-widget' } as const; const activeSource = { instanceId: 'beta', projectId: 'project-1', region: 'left', typeId: 'other-widget' } as const; @@ -33,3 +34,72 @@ describe('getHotkeyExecutionSource', () => { ).toBe(activeSource); }); }); + +describe('shouldPreventHotkeyDefault', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('prevents the browser default for a matched binding with no explicit preventDefault', () => { + // Regression: widget hotkeys (e.g. canvas `mod+d`) are registered without a + // `preventDefault` flag; a claimed binding must still swallow the browser + // default (mod+d used to open the bookmark dialog). + expect(shouldPreventHotkeyDefault(createHotkey({}))).toBe(true); + }); + + it('honours an explicit opt-out with preventDefault: false', () => { + expect(shouldPreventHotkeyDefault(createHotkey({ preventDefault: false }))).toBe(false); + }); + + it('does not prevent the default when no binding claimed the event', () => { + expect(shouldPreventHotkeyDefault(null)).toBe(false); + }); + + it('prevents the default for a matched-and-run widget binding, but not when skipped by an editable target', () => { + class FakeHTMLElement { + closest(): FakeHTMLElement { + return this; + } + } + vi.stubGlobal('HTMLElement', FakeHTMLElement); + + // A canvas-scoped binding claimed by an active canvas widget, registered + // without an explicit preventDefault (like the real widget registrations). + const canvasDeselect: RegisteredHotkey = createHotkey({ + allowInEditable: false, + commandId: 'canvas.deselect', + id: 'canvas.deselect', + keys: ['mod+d'], + preventDefault: undefined, + scope: { kind: 'widget', typeId: 'canvas' }, + }); + const context: HotkeyContext = { + activeInstanceId: 'canvas', + activeWidgetTypeId: 'canvas', + focusedRegion: null, + isModalLayerActive: false, + projectId: 'project-1', + }; + + // Non-editable target: the binding resolves and must prevent the default. + const matched = resolveHotkey({ + context, + event: { target: null } as KeyboardEvent, + hotkeys: [canvasDeselect], + matchedKey: 'mod+d', + }); + expect(matched?.commandId).toBe('canvas.deselect'); + expect(shouldPreventHotkeyDefault(matched)).toBe(true); + + // Editable target: the binding is skipped (allowInEditable: false) so the + // event is left alone and the browser default is untouched. + const skipped = resolveHotkey({ + context, + event: { target: new FakeHTMLElement() } as unknown as KeyboardEvent, + hotkeys: [canvasDeselect], + matchedKey: 'mod+d', + }); + expect(skipped).toBeNull(); + expect(shouldPreventHotkeyDefault(skipped)).toBe(false); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/hotkeys/WorkbenchHotkeyRuntime.tsx b/invokeai/frontend/webv2/src/workbench/hotkeys/WorkbenchHotkeyRuntime.tsx index 5e5c57d8a74..fc927e5aa43 100644 --- a/invokeai/frontend/webv2/src/workbench/hotkeys/WorkbenchHotkeyRuntime.tsx +++ b/invokeai/frontend/webv2/src/workbench/hotkeys/WorkbenchHotkeyRuntime.tsx @@ -24,6 +24,19 @@ export const getHotkeyExecutionSource = ( activeSource: WidgetContributionSource | null ): WidgetContributionSource | null => (hotkey.scope.kind === 'global' ? (hotkey.source ?? null) : activeSource); +/** + * Whether the browser default should be suppressed for a resolved hotkey. + * + * `resolveHotkey` only returns a hotkey once it has *claimed* the event — the + * scope is active and it is not skipped by the editable-target/modal-layer + * rules — so a matched-and-run binding must swallow the browser default (e.g. + * `mod+d` opening the bookmark dialog). Prevention is therefore on by default; + * a binding opts out only by explicitly setting `preventDefault: false`. A + * `null` hotkey (no binding claimed the event) never prevents the default. + */ +export const shouldPreventHotkeyDefault = (hotkey: RegisteredHotkey | null): boolean => + hotkey !== null && hotkey.preventDefault !== false; + export const WorkbenchHotkeyRuntime = () => { useRegisterFirstPartyCommands(); @@ -81,7 +94,7 @@ export const WorkbenchHotkeyRuntime = () => { return; } - if (hotkey.preventDefault) { + if (shouldPreventHotkeyDefault(hotkey)) { event.preventDefault(); } diff --git a/invokeai/frontend/webv2/src/workbench/hotkeys/catalog.test.ts b/invokeai/frontend/webv2/src/workbench/hotkeys/catalog.test.ts index 5c5ebaf3e1c..c84eccc581a 100644 --- a/invokeai/frontend/webv2/src/workbench/hotkeys/catalog.test.ts +++ b/invokeai/frontend/webv2/src/workbench/hotkeys/catalog.test.ts @@ -4,9 +4,12 @@ import { firstPartyHotkeyCatalog } from './catalog'; describe('firstPartyHotkeyCatalog', () => { it('keeps legacy default hotkey parity', () => { - expect(firstPartyHotkeyCatalog).toHaveLength(91); + // 91 legacy-parity entries + `canvas.newSession` (webv2 new-canvas command, + // no default keys — Task 46). + expect(firstPartyHotkeyCatalog).toHaveLength(92); expect(firstPartyHotkeyCatalog.map((hotkey) => hotkey.id)).toContain('app.invoke'); expect(firstPartyHotkeyCatalog.map((hotkey) => hotkey.id)).toContain('canvas.mergeDown'); + expect(firstPartyHotkeyCatalog.map((hotkey) => hotkey.id)).toContain('canvas.newSession'); expect(firstPartyHotkeyCatalog.map((hotkey) => hotkey.id)).toContain('workflows.copySelection'); expect(firstPartyHotkeyCatalog.map((hotkey) => hotkey.id)).toContain('gallery.galleryNavLeft'); expect(firstPartyHotkeyCatalog.map((hotkey) => hotkey.id)).toContain('gallery.remix'); diff --git a/invokeai/frontend/webv2/src/workbench/hotkeys/catalog.ts b/invokeai/frontend/webv2/src/workbench/hotkeys/catalog.ts index 08af2c39d0e..8cce9b18785 100644 --- a/invokeai/frontend/webv2/src/workbench/hotkeys/catalog.ts +++ b/invokeai/frontend/webv2/src/workbench/hotkeys/catalog.ts @@ -28,6 +28,9 @@ const implemented = new Set([ 'app.togglePanels', 'app.toggleRightPanel', 'canvas.deleteSelected', + 'canvas.fitBboxToLayers', + 'canvas.fitBboxToMasks', + 'canvas.newSession', 'canvas.nextEntity', 'canvas.prevEntity', 'canvas.redo', @@ -159,6 +162,8 @@ export const firstPartyHotkeyCatalog: HotkeyDefinition[] = [ hotkey('canvas', 'toggleNonRasterLayers', ['shift+h']), hotkey('canvas', 'fitBboxToMasks', ['shift+b']), hotkey('canvas', 'toggleBbox', ['shift+o']), + // New canvas session: no default keys — assignable via the hotkeys settings. + hotkey('canvas', 'newSession', []), hotkey('workflows', 'addNode', ['shift+a', 'space']), hotkey('workflows', 'copySelection', ['mod+c']), hotkey('workflows', 'pasteSelection', ['mod+v']), diff --git a/invokeai/frontend/webv2/src/workbench/hotkeys/firstPartyCommands.ts b/invokeai/frontend/webv2/src/workbench/hotkeys/firstPartyCommands.ts index 482529ccfba..ec5f3494d9e 100644 --- a/invokeai/frontend/webv2/src/workbench/hotkeys/firstPartyCommands.ts +++ b/invokeai/frontend/webv2/src/workbench/hotkeys/firstPartyCommands.ts @@ -3,9 +3,11 @@ import { commandApi } from '@workbench/extensions/extensionApi'; import { cancelCurrentQueueItem } from '@workbench/generation/api'; import { executeImageRecall, getSelectedGalleryImage, type ImageRecallKind } from '@workbench/image-actions'; import { isInvocationRouteValid, resolveInvocationRoute } from '@workbench/invocation'; +import { submitResolvedInvocation } from '@workbench/invocationSubmit'; import { ensureModelsLoaded, useModelsSelector } from '@workbench/models/modelsStore'; import { openWidgetPlacement } from '@workbench/widgetPlacementCommands'; import { getWidgetsForRegion } from '@workbench/widgetRegistry'; +import { prepareCanvasInvocation } from '@workbench/widgets/canvas/invoke/prepareCanvasInvocation'; import { flushGenerateDrafts } from '@workbench/widgets/generate/generateDraftRegistry'; import { focusPositivePrompt } from '@workbench/widgets/generate/promptFields'; import { adjustFocusedPromptAttention } from '@workbench/widgets/generate/promptFields/promptAttentionHotkeys'; @@ -87,11 +89,12 @@ export const useRegisterFirstPartyCommands = () => { return; } - dispatch({ - backendSupportsCancellation: true, + submitResolvedInvocation({ + dispatch, models: modelsRef.current, + prepareCanvasInvocation, + project: activeProject, route: resolvedRoute, - type: 'submitResolvedInvocationSnapshot', }); }); diff --git a/invokeai/frontend/webv2/src/workbench/invocation.test.ts b/invokeai/frontend/webv2/src/workbench/invocation.test.ts index 9934873094e..09422dac88a 100644 --- a/invokeai/frontend/webv2/src/workbench/invocation.test.ts +++ b/invokeai/frontend/webv2/src/workbench/invocation.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { ComponentModelConfig, @@ -7,9 +7,11 @@ import type { MainModelConfig, VaeModelConfig, } from './generation/types'; +import type { InvocationRoute, WidgetId } from './types'; import { getDefaultGenerateSettings } from './generation/baseGenerationPolicies'; -import { resolveInvocationRoute } from './invocation'; +import { getInvocationRouteInput, resolveInvocationRoute, resolveInvocationRouteInput } from './invocation'; +import { submitResolvedInvocation } from './invocationSubmit'; import { createInitialWorkbenchState, workbenchReducer } from './workbenchState'; const animaModel: MainModelConfig = { base: 'anima', key: 'anima-model', name: 'Anima', type: 'main' }; @@ -121,3 +123,112 @@ describe('resolveInvocationRoute', () => { expect(route.validationReasons).toContain("No invocation node registered for external provider 'future-provider'."); }); }); + +describe('resolveInvocationRoute — canvas source', () => { + const canvasRoute: InvocationRoute = { + destination: 'canvas', + destinationLocked: false, + sourceId: 'canvas', + sourceLocked: false, + }; + + const validGenerateValues = createGenerateValues(animaModel, { qwen3EncoderModel: qwen3Encoder, vae: animaVae }); + + const canvasInputFor = ( + overrides: { + generateValues?: typeof validGenerateValues; + mountedWidgetIds?: WidgetId[]; + bbox?: { width: number; height: number }; + } = {} + ) => { + const project = getActiveProject(overrides.generateValues ?? validGenerateValues); + + return { + ...getInvocationRouteInput(project), + canvasBbox: overrides.bbox ?? { height: 512, width: 512 }, + mountedWidgetIds: overrides.mountedWidgetIds ?? (['canvas', 'generate'] as WidgetId[]), + }; + }; + + it('is a valid source when the widget is mounted, the model is valid, and the frame has area', () => { + const route = resolveInvocationRouteInput(canvasInputFor(), 'global', canvasRoute); + + expect(route.sourceValid).toBe(true); + expect(route.validationReasons).toEqual([]); + }); + + it('invalidates a zero-area generation frame', () => { + const route = resolveInvocationRouteInput( + canvasInputFor({ bbox: { height: 0, width: 512 } }), + 'global', + canvasRoute + ); + + expect(route.sourceValid).toBe(false); + expect(route.validationReasons).toContain('Canvas generation frame must have a positive area.'); + }); + + it('reuses the generate model validation reasons', () => { + const route = resolveInvocationRouteInput( + canvasInputFor({ generateValues: createGenerateValues(animaModel, { vae: animaVae }) }), + 'global', + canvasRoute + ); + + expect(route.sourceValid).toBe(false); + expect(route.validationReasons).toContain('Generate needs a Qwen3 Encoder for Anima models.'); + }); + + it('invalidates when the canvas widget is not mounted', () => { + const route = resolveInvocationRouteInput( + canvasInputFor({ mountedWidgetIds: ['generate'] as WidgetId[] }), + 'global', + canvasRoute + ); + + expect(route.sourceValid).toBe(false); + expect(route.validationReasons).toContain('The Canvas widget is not mounted in this project.'); + }); +}); + +describe('submitResolvedInvocation', () => { + const routeFor = (project: Parameters[0], route: InvocationRoute) => + resolveInvocationRoute(project, 'global', route); + + it('routes a canvas source through prepareCanvasInvocation and does not dispatch a resolved snapshot', () => { + const project = getActiveProject( + createGenerateValues(animaModel, { qwen3EncoderModel: qwen3Encoder, vae: animaVae }) + ); + const dispatch = vi.fn(); + const prepareCanvasInvocation = vi.fn(); + const route = routeFor(project, { ...project.invocation, destination: 'gallery', sourceId: 'canvas' }); + + submitResolvedInvocation({ dispatch, models: undefined, prepareCanvasInvocation, project, route }); + + expect(dispatch).not.toHaveBeenCalled(); + expect(prepareCanvasInvocation).toHaveBeenCalledTimes(1); + // The resolved destination rides through so a Canvas source can target the Gallery. + expect(prepareCanvasInvocation.mock.calls[0]?.[0]).toMatchObject({ + destination: 'gallery', + projectId: project.id, + }); + }); + + it('dispatches submitResolvedInvocationSnapshot for a non-canvas source and never prepares the canvas', () => { + const project = getActiveProject( + createGenerateValues(animaModel, { qwen3EncoderModel: qwen3Encoder, vae: animaVae }) + ); + const dispatch = vi.fn(); + const prepareCanvasInvocation = vi.fn(); + const route = routeFor(project, { ...project.invocation, destination: 'gallery', sourceId: 'generate' }); + + submitResolvedInvocation({ dispatch, models: undefined, prepareCanvasInvocation, project, route }); + + expect(prepareCanvasInvocation).not.toHaveBeenCalled(); + expect(dispatch).toHaveBeenCalledTimes(1); + expect(dispatch.mock.calls[0]?.[0]).toMatchObject({ + route: expect.objectContaining({ destination: 'gallery', sourceId: 'generate' }), + type: 'submitResolvedInvocationSnapshot', + }); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/invocation.ts b/invokeai/frontend/webv2/src/workbench/invocation.ts index 49bc5d6ba91..afb7af86198 100644 --- a/invokeai/frontend/webv2/src/workbench/invocation.ts +++ b/invokeai/frontend/webv2/src/workbench/invocation.ts @@ -45,7 +45,7 @@ export const invocationSources: InvocationSourceMeta[] = [ { id: 'generate', label: 'Generate', available: true }, { id: 'workflow', label: 'Workflow', available: true }, { id: 'upscale', label: 'Upscale', available: false }, - { id: 'canvas', label: 'Canvas', available: false }, + { id: 'canvas', label: 'Canvas', available: true }, ]; export const resultDestinations: ResultDestinationMeta[] = [ @@ -87,6 +87,8 @@ export interface InvocationRouteInput { mountedWidgetIds: readonly WidgetId[]; projectGraph: ProjectGraphState; projectId: string; + /** The canvas generation frame (document space) — its area gates a canvas invoke. */ + canvasBbox: { width: number; height: number }; } const getMountedWidgetIds = (project: Project): WidgetId[] => { @@ -106,6 +108,10 @@ const getMountedWidgetIds = (project: Project): WidgetId[] => { }; export const getInvocationRouteInput = (project: Project): InvocationRouteInput => ({ + canvasBbox: { + height: project.canvas.document.bbox.height, + width: project.canvas.document.bbox.width, + }, generateValues: getProjectWidgetValues(project, 'generate'), invocation: project.invocation, mountedWidgetIds: getMountedWidgetIds(project), @@ -118,6 +124,8 @@ export const areInvocationRouteInputsEqual = (left: InvocationRouteInput, right: left.invocation === right.invocation && left.projectGraph === right.projectGraph && left.generateValues === right.generateValues && + left.canvasBbox.width === right.canvasBbox.width && + left.canvasBbox.height === right.canvasBbox.height && areArraysEqual(left.mountedWidgetIds, right.mountedWidgetIds); export const createInvocationRouteInputSelector = () => @@ -178,6 +186,17 @@ export const resolveInvocationRouteInput = ( validationReasons.push(...getGenerateSnapshotValidationReasons(input.generateValues, models)); } + if (sourceId === 'canvas') { + // Canvas shares the generate model/prompt/steps, so reuse those reasons, then + // require a non-degenerate generation frame (a zero-area bbox has nothing to + // generate into and the graph compiler would reject it anyway). + validationReasons.push(...getGenerateSnapshotValidationReasons(input.generateValues, models)); + + if (input.canvasBbox.width <= 0 || input.canvasBbox.height <= 0) { + validationReasons.push('Canvas generation frame must have a positive area.'); + } + } + if (projectGraphReadiness && !projectGraphReadiness.canInvoke) { validationReasons.push(...projectGraphReadiness.reasons); } diff --git a/invokeai/frontend/webv2/src/workbench/invocationSubmit.ts b/invokeai/frontend/webv2/src/workbench/invocationSubmit.ts new file mode 100644 index 00000000000..e613ceb75f0 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/invocationSubmit.ts @@ -0,0 +1,73 @@ +/** + * The single canvas-vs-generate submit decision, shared by the three Invoke + * surfaces: the topbar Invoke button (`shell/topbar/InvokeControl`), the Invoke + * hotkey command (`hotkeys/firstPartyCommands`), and the graph-preview dialog's + * submit (`graph-preview/GraphPreviewDialog`). + * + * Both surfaces flush drafts, resolve the route, and gate on route validity + + * backend connection themselves; this helper owns only what happens *after* that + * gate — routing a `canvas` source through the async canvas pipeline + * (`prepareCanvasInvocation`, honoring the resolved destination) versus + * dispatching the reducer's `submitResolvedInvocationSnapshot` for every other + * source. `prepareCanvasInvocation` is injected so the decision stays pure and + * node-testable with a fake dispatch + a stubbed canvas pipeline. + */ + +import type { ModelConfig } from './models/types'; +import type { Project, ResolvedInvocationRoute } from './types'; +import type { PrepareCanvasInvocationArgs } from './widgets/canvas/invoke/prepareCanvasInvocation'; +import type { WorkbenchAction } from './workbenchState'; + +import { readCanvasCompositingSettings } from './widgets/canvas/invoke/canvasCompositing'; +import { readCanvasDenoisingStrength } from './widgets/canvas/invoke/canvasStrength'; +import { getProjectWidgetValues } from './widgetState'; + +export interface SubmitResolvedInvocationDeps { + /** The resolved route to submit — the caller has already checked it is valid. */ + route: ResolvedInvocationRoute; + /** The project the canvas pipeline reads its generate/canvas widget values from. */ + project: Project; + /** Loaded models (or `undefined` while loading), forwarded verbatim to both paths. */ + models: readonly ModelConfig[] | undefined; + dispatch: (action: WorkbenchAction) => void; + /** + * The async canvas-invoke entry point, injected for testability. The real + * implementation is fire-and-track (its returned promise is intentionally not + * awaited); a canvas source never dispatches `submitResolvedInvocationSnapshot`. + */ + prepareCanvasInvocation: (args: PrepareCanvasInvocationArgs) => unknown; +} + +export const submitResolvedInvocation = ({ + dispatch, + models, + prepareCanvasInvocation, + project, + route, +}: SubmitResolvedInvocationDeps): void => { + if (route.sourceId === 'canvas') { + // The canvas graph is composited + compiled asynchronously outside the + // reducer; fire-and-track (the orchestrator records any failure notice and + // guards against concurrent invokes internally). The resolved destination is + // threaded through so a Canvas source can still land its output in the + // Gallery (durable, non-intermediate) instead of canvas staging. + prepareCanvasInvocation({ + compositing: readCanvasCompositingSettings(getProjectWidgetValues(project, 'canvas')), + destination: route.destination, + dispatch, + generateValues: getProjectWidgetValues(project, 'generate'), + models, + projectId: project.id, + projectSettings: project.settings, + strength: readCanvasDenoisingStrength(getProjectWidgetValues(project, 'canvas')), + }); + return; + } + + dispatch({ + backendSupportsCancellation: true, + models, + route, + type: 'submitResolvedInvocationSnapshot', + }); +}; diff --git a/invokeai/frontend/webv2/src/workbench/projects/projectSync.test.ts b/invokeai/frontend/webv2/src/workbench/projects/projectSync.test.ts index 56b777f0dee..6f16c27f6b7 100644 --- a/invokeai/frontend/webv2/src/workbench/projects/projectSync.test.ts +++ b/invokeai/frontend/webv2/src/workbench/projects/projectSync.test.ts @@ -20,7 +20,6 @@ describe('project document serialization', () => { id: 'undo-1', label: 'test', project: { - canvas: project.canvas, invocation: project.invocation, layout: project.layout, projectGraph: project.projectGraph, diff --git a/invokeai/frontend/webv2/src/workbench/queueSubmission.test.ts b/invokeai/frontend/webv2/src/workbench/queueSubmission.test.ts new file mode 100644 index 00000000000..454a5b79b72 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/queueSubmission.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; + +import type { InvocationSourceId, QueueItem, QueueItemStatus } from './types'; + +import { + BACKEND_SUBMITTABLE_SOURCE_IDS, + isBackendSubmittableSourceId, + shouldSubmitPendingQueueItem, +} from './queueSubmission'; + +/** Minimal queue item whose submission gate only reads `status` + `snapshot.sourceId`. */ +const makeQueueItem = (sourceId: InvocationSourceId, status: QueueItemStatus): QueueItem => + ({ + id: 'queue-item-1', + status, + cancellable: true, + snapshot: { sourceId }, + }) as unknown as QueueItem; + +describe('isBackendSubmittableSourceId', () => { + it('includes canvas so canvas invocations are actually enqueued (regression: canvas→canvas stall)', () => { + // The bug: canvas snapshots carry sourceId 'canvas'; the runtime allow-list + // only had 'generate'/'workflow', so canvas items stacked as local pending + // rows forever and nothing generated. + expect(isBackendSubmittableSourceId('canvas')).toBe(true); + }); + + it('includes generate and workflow', () => { + expect(isBackendSubmittableSourceId('generate')).toBe(true); + expect(isBackendSubmittableSourceId('workflow')).toBe(true); + }); + + it('excludes upscale (not yet an available source)', () => { + expect(isBackendSubmittableSourceId('upscale')).toBe(false); + }); + + it('exposes canvas in the exported allow-list', () => { + expect(BACKEND_SUBMITTABLE_SOURCE_IDS).toContain('canvas'); + }); +}); + +describe('shouldSubmitPendingQueueItem', () => { + it('submits a pending canvas item', () => { + expect(shouldSubmitPendingQueueItem(makeQueueItem('canvas', 'pending'))).toBe(true); + }); + + it('submits a pending generate item', () => { + expect(shouldSubmitPendingQueueItem(makeQueueItem('generate', 'pending'))).toBe(true); + }); + + it('does not submit a non-pending canvas item', () => { + expect(shouldSubmitPendingQueueItem(makeQueueItem('canvas', 'running'))).toBe(false); + expect(shouldSubmitPendingQueueItem(makeQueueItem('canvas', 'completed'))).toBe(false); + expect(shouldSubmitPendingQueueItem(makeQueueItem('canvas', 'cancelled'))).toBe(false); + }); + + it('does not submit a pending item whose source is not submittable', () => { + expect(shouldSubmitPendingQueueItem(makeQueueItem('upscale', 'pending'))).toBe(false); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/queueSubmission.ts b/invokeai/frontend/webv2/src/workbench/queueSubmission.ts new file mode 100644 index 00000000000..b695141b8d0 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/queueSubmission.ts @@ -0,0 +1,45 @@ +/** + * Which pending queue items the {@link import('./WorkbenchRuntime').WorkbenchRuntime} + * submission bridge is allowed to POST to the backend queue. + * + * This is the gate that decides whether a locally-enqueued `pending` queue item + * gets handed to the coordinator (`submitGenerate` / `submitWorkflow`). It lives + * in its own pure module — separate from the React effect that consumes it — so + * it can be unit-tested under node-env vitest (the runtime component cannot). + * + * ## Why `canvas` MUST be here + * Every Invoke — generate, workflow, AND canvas — first lands an optimistic local + * `pending` queue item (see `enqueueCompiledSnapshot`). The runtime loop then + * enqueues each pending item to the backend exactly once. If a source id is + * missing from this set, its items are created locally but **never POSTed**, so + * they stack in the queue as `pending` forever and nothing ever generates. That + * was the canvas→canvas stall: canvas snapshots carry `sourceId: 'canvas'`, which + * was absent from the loop's allow-list, so each Invoke only produced a dead + * local row. Canvas items route through the coordinator's workflow path (their + * graph is fully compiled ahead of time, no generate seed/prompt batch data). + * + * `upscale` is intentionally excluded: it is not yet an available source + * (`invocation.ts` marks it `available: false`), so it never produces a snapshot. + */ + +import type { InvocationSourceId, QueueItem } from './types'; + +/** Source kinds whose pending queue items the runtime enqueues to the backend. */ +export const BACKEND_SUBMITTABLE_SOURCE_IDS = [ + 'generate', + 'workflow', + 'canvas', +] as const satisfies readonly InvocationSourceId[]; + +const BACKEND_SUBMITTABLE_SOURCE_ID_SET: ReadonlySet = new Set(BACKEND_SUBMITTABLE_SOURCE_IDS); + +/** Whether a queue item's source is one the runtime knows how to enqueue. */ +export const isBackendSubmittableSourceId = (sourceId: InvocationSourceId): boolean => + BACKEND_SUBMITTABLE_SOURCE_ID_SET.has(sourceId); + +/** + * Whether a queue item is a fresh, backend-submittable `pending` item — the + * runtime still applies its own "already started this id" dedupe on top of this. + */ +export const shouldSubmitPendingQueueItem = (queueItem: QueueItem): boolean => + queueItem.status === 'pending' && isBackendSubmittableSourceId(queueItem.snapshot.sourceId); diff --git a/invokeai/frontend/webv2/src/workbench/queueSummary.test.ts b/invokeai/frontend/webv2/src/workbench/queueSummary.test.ts index 33f94e0029c..a57843d032a 100644 --- a/invokeai/frontend/webv2/src/workbench/queueSummary.test.ts +++ b/invokeai/frontend/webv2/src/workbench/queueSummary.test.ts @@ -30,15 +30,26 @@ const createQueueItem = ({ id, snapshot: { canvas: { - document: { height: 1024, layers: [], version: 1, width: 1024 }, + document: { + background: 'transparent', + bbox: { height: 1024, width: 1024, x: 0, y: 0 }, + height: 1024, + layers: [], + selectedLayerId: null, + version: 2, + width: 1024, + }, + documentRevision: 0, + snapshots: [], stagingArea: { areThumbnailsVisible: false, + autoSwitchMode: 'off', isVisible: false, pendingImageIds: [], pendingImages: [], selectedImageIndex: 0, }, - version: 1, + version: 2, }, destination: 'gallery', graph: { edges: [], id: 'graph-1', label: 'Graph', nodes: [], updatedAt: submittedAt, version: 1 }, diff --git a/invokeai/frontend/webv2/src/workbench/shell/topbar/InvokeControl.tsx b/invokeai/frontend/webv2/src/workbench/shell/topbar/InvokeControl.tsx index 4c5f915f427..2d5ec9d74a3 100644 --- a/invokeai/frontend/webv2/src/workbench/shell/topbar/InvokeControl.tsx +++ b/invokeai/frontend/webv2/src/workbench/shell/topbar/InvokeControl.tsx @@ -14,7 +14,9 @@ import { resolveInvocationRouteInput, resultDestinations, } from '@workbench/invocation'; +import { submitResolvedInvocation } from '@workbench/invocationSubmit'; import { ensureModelsLoaded, useModelsSelector } from '@workbench/models/modelsStore'; +import { prepareCanvasInvocation } from '@workbench/widgets/canvas/invoke/prepareCanvasInvocation'; import { flushGenerateDrafts } from '@workbench/widgets/generate/generateDraftRegistry'; import { useActiveProjectSelector, @@ -146,11 +148,12 @@ export const InvokeControl = () => { return; } - dispatch({ - backendSupportsCancellation: true, + submitResolvedInvocation({ + dispatch, models: modelsRef.current, + prepareCanvasInvocation, + project: snapshot.activeProject, route: postFlushRoute, - type: 'submitResolvedInvocationSnapshot', }); }, [dispatch, store]); const tooltipContent = useMemo( diff --git a/invokeai/frontend/webv2/src/workbench/types.ts b/invokeai/frontend/webv2/src/workbench/types.ts index 7a76b0f6e1d..e80f7a76a08 100644 --- a/invokeai/frontend/webv2/src/workbench/types.ts +++ b/invokeai/frontend/webv2/src/workbench/types.ts @@ -1,6 +1,9 @@ import type { TFunction } from 'i18next'; import type { ComponentType, ExoticComponent, JSXElementConstructor, SVGProps } from 'react'; +// `generation/types.ts` imports value-less (`import type`) symbols back from this +// module; both sides are type-only so this doesn't create a runtime cycle. +import type { GenerateReferenceImage } from './generation/types'; import type { WorkbenchLanguage } from './i18n/languages'; import type { ProjectGraphState } from './workflows/types'; @@ -420,18 +423,232 @@ export interface CanvasDocumentContract { layers: CanvasRasterLayerContract[]; } +/** @deprecated legacy v1 shape, superseded by {@link CanvasStagingAreaContractV2}. Extracted so v1 and v2 canvas state can share its structure. */ +export interface CanvasStagingAreaContract { + sourceQueueItemId?: string; + selectedLayerId?: string; + pendingImageIds: string[]; + pendingImages: CanvasStagingCandidateContract[]; + selectedImageIndex: number; + isVisible: boolean; + areThumbnailsVisible: boolean; +} + +/** @deprecated legacy v1 shape, superseded by {@link CanvasStateContractV2}. Kept for migration input typing and the still-v1-shaped staging area. */ export interface CanvasStateContract { version: 1; document: CanvasDocumentContract; - stagingArea: { - sourceQueueItemId?: string; - selectedLayerId?: string; - pendingImageIds: string[]; - pendingImages: CanvasStagingCandidateContract[]; - selectedImageIndex: number; - isVisible: boolean; - areThumbnailsVisible: boolean; - }; + stagingArea: CanvasStagingAreaContract; +} + +// --------------------------------------------------------------------------- +// Canvas v2 document contracts +// +// The canvas document is being rebuilt as a full photo editor: a layer stack +// supporting raster, control, regional-guidance, and inpaint-mask layers, each +// with its own transform/opacity/blend-mode. `CanvasStateContract` (v1) only +// ever produced single-image raster layers positioned by `placement`; v2 +// layers are positioned by `transform` and reference bitmaps by `imageName` +// (via `CanvasImageRef`) rather than by resolved URLs, since URLs are +// ephemeral/backend-derived while the document is persisted. The staging +// area (pending queue results awaiting placement onto the canvas) keeps its +// v1 shape unchanged — only the accepted document evolves. +// --------------------------------------------------------------------------- + +export type CanvasBlendMode = + | 'normal' + | 'multiply' + | 'screen' + | 'overlay' + | 'darken' + | 'lighten' + | 'color-dodge' + | 'color-burn' + | 'hard-light' + | 'soft-light' + | 'difference' + | 'exclusion' + | 'hue' + | 'saturation' + | 'color' + | 'luminosity'; + +/** A reference to a persisted image asset by name, not by resolved URL. */ +export interface CanvasImageRef { + imageName: string; + width: number; + height: number; + contentHash?: string; +} + +export type CanvasLayerSourceContract = + | { + type: 'paint'; + bitmap: CanvasImageRef | null; + /** + * The layer-local origin of `bitmap`'s top-left pixel. Paint layers are + * content-sized: the persisted bitmap covers only the painted region, and + * this offset records where that region sits in the layer's local space + * (it can be negative). Absent (or `{ x: 0, y: 0 }`) for legacy documents + * whose paint bitmaps were document-sized at the origin — they load + * identically. + */ + offset?: { x: number; y: number }; + } + | { type: 'image'; image: CanvasImageRef } + | { + type: 'text'; + content: string; + fontFamily: string; + fontSize: number; + fontWeight: number; + lineHeight: number; + align: 'left' | 'center' | 'right'; + color: string; + } + | { + type: 'shape'; + kind: 'rect' | 'ellipse' | 'polygon'; + points?: { x: number; y: number }[]; + width: number; + height: number; + fill: string | null; + stroke: string | null; + strokeWidth: number; + } + | { + type: 'gradient'; + kind: 'linear' | 'radial'; + angle: number; + stops: { offset: number; color: string }[]; + /** + * The gradient's explicit content extent (layer-local pixels). Gradient + * layers are content-sized like every other layer: the extent is set at + * creation (bbox-sized) and preserved across angle edits. Absent for legacy + * documents whose gradients were document-sized by construction — they + * default to the document dimensions on load (see `getSourceContentRect`). + */ + width?: number; + height?: number; + }; + +export interface CanvasLayerBaseContract { + id: string; + name: string; + isEnabled: boolean; + isLocked: boolean; + opacity: number; + blendMode: CanvasBlendMode; + transform: { x: number; y: number; scaleX: number; scaleY: number; rotation: number }; +} + +export interface CanvasAdjustmentsContract { + brightness: number; + contrast: number; + saturation: number; + curves?: { r: [number, number][]; g: [number, number][]; b: [number, number][] }; +} + +export interface CanvasControlAdapterContract { + kind: 'controlnet' | 't2i_adapter' | 'control_lora'; + model: string | null; + weight: number; + beginEndStepPct: [number, number]; + controlMode: 'balanced' | 'more_prompt' | 'more_control' | 'unbalanced' | null; +} + +export interface CanvasMaskFillContract { + style: 'solid' | 'grid' | 'crosshatch' | 'diagonal' | 'horizontal' | 'vertical'; + color: string; +} + +export interface CanvasMaskContract { + bitmap: CanvasImageRef | null; + fill: CanvasMaskFillContract; + /** + * The layer-local origin of `bitmap`'s top-left pixel. Mask layers are + * content-sized exactly like paint layers: the persisted mask bitmap covers + * only the painted region, and this offset records where that region sits in + * the layer's local space (it can be negative). Absent (or `{ x: 0, y: 0 }`) + * for legacy documents whose mask bitmaps were document-sized at the origin — + * they load identically. Mirrors {@link CanvasLayerSourceContract} `paint`. + */ + offset?: { x: number; y: number }; +} + +export interface CanvasRasterLayerContractV2 extends CanvasLayerBaseContract { + type: 'raster'; + source: CanvasLayerSourceContract; + adjustments?: CanvasAdjustmentsContract; + isTransparencyLocked?: boolean; +} + +export interface CanvasControlLayerContract extends CanvasLayerBaseContract { + type: 'control'; + source: CanvasLayerSourceContract; + adapter: CanvasControlAdapterContract; + withTransparencyEffect: boolean; + filter?: { type: string; settings: Record }; +} + +export interface CanvasRegionalGuidanceLayerContract extends CanvasLayerBaseContract { + type: 'regional_guidance'; + mask: CanvasMaskContract; + positivePrompt: string | null; + negativePrompt: string | null; + autoNegative: boolean; + referenceImages: GenerateReferenceImage[]; +} + +export interface CanvasInpaintMaskLayerContract extends CanvasLayerBaseContract { + type: 'inpaint_mask'; + mask: CanvasMaskContract; + noiseLevel?: number; + denoiseLimit?: number; +} + +export type CanvasLayerContract = + | CanvasRasterLayerContractV2 + | CanvasControlLayerContract + | CanvasRegionalGuidanceLayerContract + | CanvasInpaintMaskLayerContract; + +export interface CanvasDocumentContractV2 { + version: 2; + width: number; + height: number; + background: 'transparent' | { color: string }; + /** Index 0 is the top-most layer. */ + layers: CanvasLayerContract[]; + bbox: { x: number; y: number; width: number; height: number }; + selectedLayerId: string | null; +} + +export interface CanvasSnapshotContract { + id: string; + name: string; + createdAt: string; + document: CanvasDocumentContractV2; +} + +export interface CanvasStagingAreaContractV2 extends CanvasStagingAreaContract { + autoSwitchMode: 'off' | 'latest' | 'oldest'; +} + +export interface CanvasStateContractV2 { + version: 2; + document: CanvasDocumentContractV2; + /** + * Monotonic counter bumped whenever the document is swapped wholesale + * (snapshot restore, `replaceCanvasDocument`) rather than incrementally + * edited. The document mirror treats any change to this value as a full + * document replacement (clearing engine pixel history), even when the new + * document keeps the same dimensions and reuses layer ids — the case a + * reference/dimension diff alone cannot distinguish from an ordinary edit. + */ + documentRevision: number; + snapshots: CanvasSnapshotContract[]; + stagingArea: CanvasStagingAreaContractV2; } export type InvocationSourceId = 'generate' | 'workflow' | 'upscale' | 'canvas'; @@ -512,7 +729,7 @@ export interface Project { widgetInstances: Record; widgetRegions: Record; widgetGraphs: Partial>; - canvas: CanvasStateContract; + canvas: CanvasStateContractV2; graphHistory: GraphHistorySnapshot[]; promptHistory: PromptHistoryItem[]; undoRedo: UndoRedoHistory; @@ -583,6 +800,11 @@ export interface UndoRedoEntry { project: ProjectUndoSnapshot; } +/** + * Project-level undo snapshot. Deliberately excludes `canvas`: the canvas + * rendering engine owns its own pixel-patch history, so project undo/redo + * passes the live `project.canvas` through untouched (see `restoreUndoSnapshot`). + */ export interface ProjectUndoSnapshot { layout: ProjectLayoutState; invocation: InvocationControllerState; @@ -590,7 +812,6 @@ export interface ProjectUndoSnapshot { widgetInstances: Record; widgetRegions: Record; widgetGraphs: Partial>; - canvas: CanvasStateContract; } export interface UndoRedoHistory { @@ -604,7 +825,7 @@ export interface QueueSubmissionSnapshot { graph: GraphContract; widgetInstances: Record; widgetStates: WidgetStateMap; - canvas: CanvasStateContract; + canvas: CanvasStateContractV2; submittedAt: string; } diff --git a/invokeai/frontend/webv2/src/workbench/widgets/workflow/editor/useModifierHeld.ts b/invokeai/frontend/webv2/src/workbench/useModifierHeld.ts similarity index 79% rename from invokeai/frontend/webv2/src/workbench/widgets/workflow/editor/useModifierHeld.ts rename to invokeai/frontend/webv2/src/workbench/useModifierHeld.ts index ea914027f76..4ce595deab8 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/workflow/editor/useModifierHeld.ts +++ b/invokeai/frontend/webv2/src/workbench/useModifierHeld.ts @@ -1,6 +1,10 @@ import { useEffect, useState } from 'react'; -/** Tracks whether a keyboard modifier is currently held (e.g. Control for grid snapping). */ +/** + * Tracks whether a keyboard modifier is currently held, event-driven (no + * polling). Shared across widgets: the workflow editor's Control-snap and the + * canvas settings popover's Shift-revealed Debug section both key off it. + */ export const useModifierHeld = (key: 'Alt' | 'Control' | 'Meta' | 'Shift'): boolean => { const [isHeld, setIsHeld] = useState(false); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasDocumentFrame.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasDocumentFrame.tsx deleted file mode 100644 index 9341c13a4cd..00000000000 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasDocumentFrame.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import type { CanvasPlacementContract, GeneratedImageContract } from '@workbench/types'; -import type { ReactNode } from 'react'; - -import { Box, Flex, Text } from '@chakra-ui/react'; -import { useTranslation } from 'react-i18next'; - -const toPercent = (value: number, max: number) => `${(value / max) * 100}%`; - -export const CanvasDocumentFrame = ({ - children, - documentHeight, - documentWidth, - hasContent, -}: { - children: ReactNode; - documentHeight: number; - documentWidth: number; - hasContent: boolean; -}) => ( - - {children} - -); - -export const CanvasPlaneImage = ({ - image, - isStaged, - opacity, - placement, - planeHeight, - planeWidth, -}: { - image: GeneratedImageContract; - isStaged?: boolean; - opacity: number; - placement: CanvasPlacementContract; - planeHeight: number; - planeWidth: number; -}) => { - const { t } = useTranslation(); - - return ( - - {isStaged - - ); -}; - -export const EmptyCanvasFrame = () => { - const { t } = useTranslation(); - - return ( - - {t('widgets.canvas.emptyLayerStack')} - - ); -}; - -export const ToolScrubber = () => ( - -); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasFloatingBar.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasFloatingBar.tsx new file mode 100644 index 00000000000..bb5dae6468b --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasFloatingBar.tsx @@ -0,0 +1,26 @@ +import type { PanelProps } from '@workbench/components/ui'; + +import { Box } from '@chakra-ui/react'; +import { Panel } from '@workbench/components/ui'; + +/** + * Shared floating-panel chrome for the canvas: the raised, rounded, shadowed + * surface that the tool options bar, the staging bar, and (later) the text-tool + * / layer-selection quick bars all sit on. It supplies only the *look* and + * re-enables pointer events (its parent group is click-through); positioning + * over the surface is the parent's job. + * + * Composition-friendly: consumers lay out their own sections inside — an + * `HStack` of controls, `{@link CanvasFloatingBarDivider}` between groups — so + * no per-consumer boolean props accrete here. + */ +export const CanvasFloatingBar = ({ children, ...rest }: PanelProps) => ( + + {children} + +); + +/** A thin vertical rule separating groups of controls inside a {@link CanvasFloatingBar}. */ +export const CanvasFloatingBarDivider = () => ( + +); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasHeaderActions.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasHeaderActions.tsx new file mode 100644 index 00000000000..22f23ec29ff --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasHeaderActions.tsx @@ -0,0 +1,352 @@ +/* oxlint-disable react-perf/jsx-no-new-function-as-prop */ +import type { CanvasEngine } from '@workbench/canvas-engine/engine'; +import type { Rect } from '@workbench/canvas-engine/types'; +import type { Project, WidgetViewProps } from '@workbench/types'; + +import { Box, HStack, Icon, Menu, Portal, Text } from '@chakra-ui/react'; +import { createNewCanvasStateV2 } from '@workbench/canvasMigration'; +import { ConfirmDialog, IconButton, MenuContent } from '@workbench/components/ui'; +import { useModifierHeld } from '@workbench/useModifierHeld'; +import { getProjectWidgetValues } from '@workbench/widgetState'; +import { useActiveProjectSelector, useWorkbenchDispatch } from '@workbench/WorkbenchContext'; +import { + BugIcon, + CheckIcon, + ChevronDownIcon, + DatabaseIcon, + FilePlusIcon, + FrameIcon, + MaximizeIcon, + Redo2Icon, + SettingsIcon, + SquareDashedBottomIcon, + Trash2Icon, + Undo2Icon, +} from 'lucide-react'; +import { Fragment, useCallback, useEffect, useEffectEvent, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { CanvasBooleanSetting, ResolvedCanvasSettings } from './canvasSettings'; + +import { gridSizeForModelBase } from './bboxGrid'; +import { + CANVAS_SETTING_SECTIONS, + CANVAS_SETTINGS, + CANVAS_SNAP_TO_GRID_KEY, + canvasSettingsEqual, + resolveCanvasSettings, +} from './canvasSettings'; +import { useCanvasCanRedo, useCanvasCanUndo, useCanvasZoom } from './engineStoreHooks'; +import { computeFitBboxToLayers, computeFitBboxToMasks } from './fitBbox'; +import { useCanvasEngine } from './useCanvasEngine'; +import { formatZoomPercent, zoomMenuOptions } from './zoomOptions'; + +const ZOOM_OPTIONS = zoomMenuOptions(); +const MENU_POSITIONING = { placement: 'bottom-end' } as const; + +/** Reads the persisted, default-applied canvas settings for the active project. */ +const selectCanvasSettings = (project: Project): ResolvedCanvasSettings => + resolveCanvasSettings(getProjectWidgetValues(project, 'canvas')); + +/** Reads the active generate model's base, so fit-bbox snaps to the same grid the bbox tool uses. */ +const selectModelBase = (project: Project): string | null => { + const values = getProjectWidgetValues(project, 'generate') as { model?: { base?: unknown } } | undefined; + return typeof values?.model?.base === 'string' ? values.model.base : null; +}; + +/** + * Canvas widget header actions, in legacy toolbar order: the zoom-percent menu, a + * reset-view (fit content to view) button, fit-bbox-to-layers / fit-bbox-to-masks, + * undo / redo, a new-session menu, and the sectioned settings popover. Rendered in + * the widget frame's header slot; resolves the shared engine like the layers header + * does, and renders nothing until it is available. + * + * Excluded per product decision: the project (save/load) menu, a save-to-gallery + * button (its command/hotkey stays), and the snapshot menu. + */ +export const CanvasHeaderActions = ({ runtime }: WidgetViewProps) => { + const engine = useCanvasEngine(); + return engine ? : null; +}; + +const CanvasHeaderActionsInner = ({ + engine, + runtime, +}: { + engine: CanvasEngine; + runtime: WidgetViewProps['runtime']; +}) => { + const { t } = useTranslation(); + const dispatch = useWorkbenchDispatch(); + const zoom = useCanvasZoom(engine); + const canUndo = useCanvasCanUndo(engine); + const canRedo = useCanvasCanRedo(engine); + const document = useActiveProjectSelector((project) => project.canvas.document); + const modelBase = useActiveProjectSelector(selectModelBase); + const settings = useActiveProjectSelector(selectCanvasSettings, canvasSettingsEqual); + + const [isNewCanvasOpen, setIsNewCanvasOpen] = useState(false); + const closeNewCanvas = useCallback(() => setIsNewCanvasOpen(false), []); + const openNewCanvas = useCallback(() => setIsNewCanvasOpen(true), []); + + const setZoom = (value: number) => { + const viewport = engine.getViewport(); + const size = viewport.getViewportSize(); + viewport.zoomAtPoint(value, { x: size.width / 2, y: size.height / 2 }); + }; + + // Fit-bbox honors the snap-to-grid setting: snapping off ⇒ grid 1 (a plain round). + const gridSize = settings[CANVAS_SNAP_TO_GRID_KEY] ? gridSizeForModelBase(modelBase) : 1; + const fitLayersRect = useMemo(() => computeFitBboxToLayers(document, gridSize), [document, gridSize]); + const fitMasksRect = useMemo(() => computeFitBboxToMasks(document, gridSize), [document, gridSize]); + + // One undoable `setCanvasBbox` (inverse restores the current bbox) — exactly how a + // manual bbox-tool edit commits. `refit` re-centers the view afterward (legacy + // re-fits the stage after fit-to-layers, but not after fit-to-masks). + const applyFit = (rect: Rect | null, refit: boolean) => { + if (!rect) { + return; + } + engine.commitStructural( + t('widgets.canvas.commands.fitBbox'), + { bbox: rect, type: 'setCanvasBbox' }, + { bbox: document.bbox, type: 'setCanvasBbox' } + ); + if (refit) { + engine.fitToView(); + } + }; + + const confirmNewCanvas = useCallback(() => { + // A wholesale document replace (seeded with one empty inpaint mask, matching + // Task 42's new-canvas init) at the current dimensions. The engine's mirror + // treats this as a document swap and clears the canvas history by design, so + // this is intentionally NOT undoable — the confirm dialog is the safety net. + dispatch({ + document: createNewCanvasStateV2(document.width, document.height).document, + type: 'replaceCanvasDocument', + }); + }, [dispatch, document.height, document.width]); + + // Commands (hotkey-assignable; catalog ids `canvas.fitBboxToLayers` / + // `canvas.fitBboxToMasks` / `canvas.newSession`). `useEffectEvent` reads the + // latest fit rects / dialog opener without re-registering per document change. + // The new-session command routes through the SAME confirm dialog as the button. + const executeHeaderCommand = useEffectEvent((commandId: string) => { + if (commandId === 'canvas.fitBboxToLayers') { + applyFit(fitLayersRect, true); + } else if (commandId === 'canvas.fitBboxToMasks') { + applyFit(fitMasksRect, false); + } else if (commandId === 'canvas.newSession') { + openNewCanvas(); + } + }); + useEffect(() => { + const entries = [ + ['canvas.fitBboxToLayers', t('widgets.canvas.controls.fitBboxToLayers'), ['shift+n']], + ['canvas.fitBboxToMasks', t('widgets.canvas.controls.fitBboxToMasks'), ['shift+b']], + // No default keys — assignable through the hotkeys settings. + ['canvas.newSession', t('widgets.canvas.controls.newSession'), []], + ] as const; + const disposers = entries.flatMap(([id, title, defaultKeys]) => [ + runtime.commands.register({ handler: () => executeHeaderCommand(id), id, title }), + runtime.hotkeys.register({ allowInEditable: false, commandId: id, defaultKeys: [...defaultKeys], id, title }), + ]); + return () => { + disposers.forEach((dispose) => dispose()); + }; + }, [runtime.commands, runtime.hotkeys, t]); + + return ( + + + + + + + {formatZoomPercent(zoom)} + + + + + + + + + {ZOOM_OPTIONS.map((option) => ( + setZoom(option.value)}> + + {option.label} + + ))} + + + + + + engine.fitToView()} + > + + + + applyFit(fitLayersRect, true)} + > + + + + applyFit(fitMasksRect, false)} + > + + + + + + engine.undo()} + > + + + + engine.redo()} + > + + + + + + + + + + + + + + + {t('widgets.canvas.controls.newCanvas')} + + + + + + + + + + + ); +}; + +/** A thin vertical rule separating header-action groups (matching legacy's dividers). */ +const HeaderDivider = () => ; + +/** + * The gear-icon settings popover: the data-driven boolean preferences grouped into + * Behavior / Display / Grid sections, plus a Shift-revealed Debug group of engine + * actions (matching legacy `CanvasSettingsPopover`). `closeOnSelect={false}` keeps + * it open while toggling. Settings persist per-project and never enter undo history. + */ +const CanvasSettingsMenu = ({ engine }: { engine: CanvasEngine }) => { + const { t } = useTranslation(); + const dispatch = useWorkbenchDispatch(); + const settings = useActiveProjectSelector(selectCanvasSettings, canvasSettingsEqual); + // Shift reveals the Debug section, matching legacy `useShiftModifier` (event-driven). + const shiftHeld = useModifierHeld('Shift'); + + const toggle = (setting: CanvasBooleanSetting) => { + dispatch({ + type: 'patchWidgetValues', + values: { [setting.key]: !settings[setting.key] }, + widgetId: 'canvas', + }); + }; + + return ( + + + + + + + + + + {CANVAS_SETTING_SECTIONS.map((section, sectionIndex) => ( + + {sectionIndex > 0 ? : null} + + + {t(`widgets.canvas.settings.sections.${section}`)} + + {CANVAS_SETTINGS.filter((setting) => setting.section === section).map((setting) => ( + toggle(setting)}> + + {t(setting.labelKey)} + + ))} + + + ))} + {shiftHeld ? ( + <> + + + + {t('widgets.canvas.settings.sections.debug')} + + void engine.clearCaches()}> + + {t('widgets.canvas.settings.clearCaches')} + + engine.logDebugInfo()}> + + {t('widgets.canvas.settings.logDebugInfo')} + + engine.clearHistory()}> + + {t('widgets.canvas.settings.clearHistory')} + + + + ) : null} + + + + + ); +}; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasStagingControls.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasStagingControls.tsx deleted file mode 100644 index 4a74285d37a..00000000000 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasStagingControls.tsx +++ /dev/null @@ -1,228 +0,0 @@ -/* oxlint-disable react-perf/jsx-no-new-object-as-prop, react-perf/jsx-no-new-function-as-prop */ -import type { CanvasStagingCandidateContract, GeneratedImageContract } from '@workbench/types'; - -import { Box, HStack, ScrollArea, Stack, Text } from '@chakra-ui/react'; -import { Button, IconButton } from '@workbench/components/ui'; -import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, ChevronUpIcon, EyeIcon, EyeOffIcon } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; - -const THUMBNAIL_STRIP_HEIGHT = '5.5rem'; - -interface CanvasStagingControlsProps { - areThumbnailsVisible: boolean; - hasMultipleCandidates: boolean; - isVisible: boolean; - pendingImages: CanvasStagingCandidateContract[]; - selectedCandidate: CanvasStagingCandidateContract; - selectedImageIndex: number; - onAccept: () => void; - onCycle: (direction: -1 | 1) => void; - onDiscardAll: () => void; - onDiscardSelected: () => void; - onSelectImage: (imageIndex: number) => void; - onToggleThumbnails: () => void; - onToggleVisibility: () => void; -} - -export const CanvasStagingControls = ({ - areThumbnailsVisible, - hasMultipleCandidates, - isVisible, - pendingImages, - selectedCandidate, - selectedImageIndex, - onAccept, - onCycle, - onDiscardAll, - onDiscardSelected, - onSelectImage, - onToggleThumbnails, - onToggleVisibility, -}: CanvasStagingControlsProps) => { - const { t } = useTranslation(); - - return ( - - - - - - {pendingImages.map((candidate, index) => ( - onSelectImage(index)} - /> - ))} - - - - - - - - - - - - {t('widgets.canvas.candidateCount', { current: selectedImageIndex + 1, total: pendingImages.length })} - - - {selectedCandidate.width} x {selectedCandidate.height} - - - - - - {areThumbnailsVisible ? : } - - onCycle(-1)} - > - - - onCycle(1)} - > - - - - - - {isVisible ? : } - - - - - - - - - ); -}; - -export const EmptyStagingControls = () => { - const { t } = useTranslation(); - - return ( - - - {t('widgets.canvas.emptyStaging')} - - - ); -}; - -const StagingThumbnail = ({ - candidate, - index, - isSelected, - onSelect, -}: { - candidate: GeneratedImageContract; - index: number; - isSelected: boolean; - onSelect: () => void; -}) => { - const { t } = useTranslation(); - - return ( - - {candidate.imageName} - - {index + 1} - - - ); -}; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasSurface.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasSurface.tsx new file mode 100644 index 00000000000..ec135d6dab8 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasSurface.tsx @@ -0,0 +1,126 @@ +/* oxlint-disable react-perf/jsx-no-new-function-as-prop -- the container ref callback is intentionally re-created when `engine` changes, so a project switch detaches the old engine and attaches the new one. */ +import type { CanvasEngine } from '@workbench/canvas-engine/engine'; +import type { CSSProperties, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from 'react'; + +import { Box } from '@chakra-ui/react'; +import { shouldFocusCanvasSurface } from '@workbench/widgets/canvas/surfaceFocus'; +import { TextEditPortal } from '@workbench/widgets/canvas/TextEditPortal'; +import { useRef } from 'react'; + +/** + * Give the canvas widget hotkey focus on a pointerdown on the surface, so tool + * hotkeys (`b`/`e`/`r`/…) work immediately after clicking the canvas. + * + * The two `` targets aren't focusable, so a plain click never moves DOM + * focus — it stays on whatever was last focused (e.g. a layers-panel button in a + * *different* widget), and the hotkey runtime's `getHotkeyTargetWidget` keeps + * resolving to that widget, so canvas hotkeys don't fire. Focusing this + * `tabIndex={-1}` container (a descendant of the canvas widget's + * `data-hotkey-widget-instance-id` element) moves focus into the canvas subtree + * so hotkeys resolve to the canvas. Focus-from-pointer doesn't match + * `:focus-visible`, so there's no focus ring. + * + * When focus is ALREADY inside the surface — critically, the text tool's + * contenteditable — this must NOT refocus: doing so in the capture phase would + * blur-commit the open text session before the engine's own (bubble-phase) + * pointerdown could run its commit-and-swallow, regressing "click away to + * commit" into "commit + open a stray new session at the click point". The full + * decision lives in the node-tested {@link shouldFocusCanvasSurface}. + * + * Runs in the capture phase so it fires before the engine's overlay pointerdown + * listener (which may `stopPropagation`). + */ +const focusCanvasSurface = (event: ReactPointerEvent) => { + if (shouldFocusCanvasSurface(event.currentTarget, event.target, document.activeElement)) { + event.currentTarget.focus({ preventScroll: true }); + } +}; + +/** + * The engine-rendered canvas surface: two stacked `` targets (the + * composited document below, the interaction overlay on top) bound to the + * shared {@link CanvasEngine}. + * + * Binding is done through the container's **ref callback** (React 19: the + * returned function is the cleanup), never a `useEffect` — the callback wires + * `attach` + a `ResizeObserver` and tears both down on unmount or engine swap. + * Because the callback closes over `engine`, a project switch (new engine + * instance) re-runs it: detach the old, attach the new. Pointer/wheel/key input + * is owned entirely by the engine via the overlay, so this component never + * re-renders on interaction. + */ +export const CanvasSurface = ({ + engine, + onContextMenu, +}: { + engine: CanvasEngine; + /** Right-click on the surface: the widget hit-tests the layer under the cursor + * and opens the shared layer context menu (or suppresses the browser menu). */ + onContextMenu?: (event: ReactMouseEvent) => void; +}) => { + const screenRef = useRef(null); + const overlayRef = useRef(null); + + const bindContainer = (container: HTMLDivElement) => { + const screen = screenRef.current; + const overlay = overlayRef.current; + if (!screen || !overlay) { + return; + } + + engine.attach(screen, overlay); + + const syncSize = () => { + const dpr = globalThis.devicePixelRatio || 1; + engine.resize(container.clientWidth, container.clientHeight, dpr); + }; + + syncSize(); + // Fit the document into view on first attach, once the viewport is sized. + if (engine.getDocument()) { + engine.fitToView(); + } + + const observer = new ResizeObserver(syncSize); + observer.observe(container); + + return () => { + observer.disconnect(); + engine.detach(); + }; + }; + + return ( + + + + {/* + * The text-editing portal: a positioned contenteditable that overlays the + * canvas whenever a text-edit session is active. It lives inside this + * relatively-positioned container so its `documentToScreen`-derived + * absolute offsets share the same origin as the canvas targets. + */} + + + ); +}; + +const CANVAS_STYLE: CSSProperties = { + height: '100%', + inset: 0, + position: 'absolute', + touchAction: 'none', + width: '100%', +}; + +const OVERLAY_STYLE: CSSProperties = { ...CANVAS_STYLE, zIndex: 1 }; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasWidgetView.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasWidgetView.tsx index 99e03402e8e..a20fa4de233 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasWidgetView.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasWidgetView.tsx @@ -1,65 +1,359 @@ +/* oxlint-disable react-perf/jsx-no-new-function-as-prop */ import type { WidgetViewProps } from '@workbench/types'; +import type { MouseEvent as ReactMouseEvent } from 'react'; -import { Box, Flex } from '@chakra-ui/react'; +import { Box, Stack } from '@chakra-ui/react'; +import { useProgressImage } from '@workbench/backend/progressImageStore'; +import { + createLayerId, + deleteLayerActions, + duplicateLayerActions, + type LayerReorderKind, + reorderIdsForHotkey, + reorderLayerActions, +} from '@workbench/canvasLayerOps'; +import { useWorkbenchSettingsSelector } from '@workbench/settings/store'; +import { CanvasLayerContextMenu, type CanvasLayerContextMenuTarget } from '@workbench/widgets/layers/LayerContextMenu'; +import { canMergeLayerDown } from '@workbench/widgets/layers/layerOps'; +import { getProjectWidgetValues } from '@workbench/widgetState'; import { useActiveProjectSelector, useWorkbenchDispatch } from '@workbench/WorkbenchContext'; -import { useCallback, useEffect, useEffectEvent } from 'react'; +import { useCallback, useEffect, useEffectEvent, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { CanvasDocumentFrame, CanvasPlaneImage, EmptyCanvasFrame, ToolScrubber } from './CanvasDocumentFrame'; -import { CanvasStagingControls, EmptyStagingControls } from './CanvasStagingControls'; +import { gridSizeForModelBase } from './bboxGrid'; +import { + CANVAS_SETTINGS, + CANVAS_SHOW_PROGRESS_KEY, + canvasSettingsEqual, + resolveCanvasSettings, +} from './canvasSettings'; +import { CanvasSurface } from './CanvasSurface'; +import { resolveCheckerColors } from './checkerColors'; +import { StagingBar } from './StagingBar'; +import { selectCanvasProgressImage, selectStagedPreviewSource, stagedPreviewKey } from './stagingPreview'; +import { INLINE_EDIT_SELECTOR } from './surfaceFocus'; +import { ToolOptionsBar } from './tool-options/ToolOptionsBar'; +import { ToolStrip } from './ToolStrip'; +import { useCanvasEngine } from './useCanvasEngine'; +/** Command id → document-space nudge delta (shift variants are ×10). */ +const NUDGE_DELTAS: Record = { + 'canvas.nudgeDown': { dx: 0, dy: 1 }, + 'canvas.nudgeDownLarge': { dx: 0, dy: 10 }, + 'canvas.nudgeLeft': { dx: -1, dy: 0 }, + 'canvas.nudgeLeftLarge': { dx: -10, dy: 0 }, + 'canvas.nudgeRight': { dx: 1, dy: 0 }, + 'canvas.nudgeRightLarge': { dx: 10, dy: 0 }, + 'canvas.nudgeUp': { dx: 0, dy: -1 }, + 'canvas.nudgeUpLarge': { dx: 0, dy: -10 }, +}; + +/** Command id → z-reorder direction (index 0 = top-most; "forward" moves toward 0). */ +const REORDER_KINDS: Record = { + 'canvas.layerBackward': 'backward', + 'canvas.layerForward': 'forward', + 'canvas.layerToBack': 'back', + 'canvas.layerToFront': 'front', +}; + +/** + * The canvas widget shell. The engine owns pixels and interaction and renders + * into {@link CanvasSurface}; this component only wires the reducer-backed + * chrome around it — command/hotkey registration, the settings-store feed, and + * the floating bottom chrome (tool options + staging). Zoom / fit / settings + * live in the widget header ({@link CanvasHeaderActions}). + */ export const CanvasWidgetView = ({ runtime }: WidgetViewProps) => { const { t } = useTranslation(); - const canvas = useActiveProjectSelector((project) => project.canvas); const dispatch = useWorkbenchDispatch(); + const engine = useCanvasEngine(); + const canvas = useActiveProjectSelector((project) => project.canvas); const { document, stagingArea } = canvas; - const { layers } = document; + + // Right-click on the canvas surface: hit-test the layer under the cursor, select + // it, and open the SAME per-layer context menu the layers panel uses — anchored + // at the pointer. `null` (empty space, or a mid-flight gesture/session the engine + // refuses) means "browser menu suppressed, no layer menu". + const [layerMenuTarget, setLayerMenuTarget] = useState(null); + const closeLayerMenu = useCallback(() => setLayerMenuTarget(null), []); + const handleSurfaceContextMenu = (event: ReactMouseEvent) => { + // Keep the native menu inside inline editors (the text tool's contenteditable + // overlay), consistent with the surface-focus INLINE_EDIT_SELECTOR. + if (event.target instanceof Element && event.target.closest(INLINE_EDIT_SELECTOR)) { + return; + } + // Suppress the browser context menu everywhere else on the surface. + event.preventDefault(); + if (!engine) { + setLayerMenuTarget(null); + return; + } + // The container and the canvas targets share a top-left origin, so its rect + // gives the same screen coords the pointer pipeline feeds the viewport. + const rect = event.currentTarget.getBoundingClientRect(); + const layerId = engine.contextMenuLayerIdAt({ x: event.clientX - rect.left, y: event.clientY - rect.top }); + if (!layerId) { + setLayerMenuTarget(null); + return; + } + // Select first (one dispatch, matching a left-click select), then open the menu. + dispatch({ id: layerId, type: 'setCanvasSelectedLayer' }); + setLayerMenuTarget({ layerId, x: event.clientX, y: event.clientY }); + }; + + // The bbox tool snaps to a model-dependent grid; the engine is model-agnostic, + // so read the active generate model's base and feed the grid size in. + const modelBase = useActiveProjectSelector((project) => { + const values = getProjectWidgetValues(project, 'generate') as { model?: { base?: unknown } } | undefined; + return typeof values?.model?.base === 'string' ? values.model.base : null; + }); + useEffect(() => { + engine?.setBboxGrid(gridSizeForModelBase(modelBase)); + }, [engine, modelBase]); + + // Canvas view settings (checkerboard / grid / invert-scroll) persist in the + // canvas widget's per-project values; the engine only reads its stores, so + // push the resolved values down whenever they change — same one-directional + // feed as the bbox grid above. The header settings menu writes the values. + const settings = useActiveProjectSelector( + (project) => resolveCanvasSettings(getProjectWidgetValues(project, 'canvas')), + canvasSettingsEqual + ); + useEffect(() => { + if (!engine) { + return; + } + for (const setting of CANVAS_SETTINGS) { + // Only engine-backed settings feed a store; React-consumed ones (e.g. + // showProgressOnCanvas, read below) have no store and are skipped here. + if (setting.store) { + engine.stores[setting.store].set(settings[setting.key]); + } + } + }, [engine, settings]); + + // The checkerboard fills the whole (unbounded) canvas, so its two square colors + // come from theme tokens rather than hardcoded greys. Resolve them from the live + // Chakra theme and feed them into the engine's checker-colors store; re-resolve + // whenever the theme (and thus color mode) changes. `themeId` flips + // `` in ThemeController's layout effect, which runs before this + // passive effect in the same commit, so getComputedStyle reads the new theme. + const themeId = useWorkbenchSettingsSelector((snapshot) => snapshot.preferences.themeId); + useEffect(() => { + engine?.stores.checkerColors.set(resolveCheckerColors()); + }, [engine, themeId]); + const selectedCandidate = stagingArea.pendingImages[stagingArea.selectedImageIndex]; - const selectedImage = stagingArea.isVisible ? selectedCandidate : undefined; const hasStagedCandidates = stagingArea.pendingImages.length > 0; const hasMultipleCandidates = stagingArea.pendingImages.length > 1; - const renderedLayers = [...layers].reverse(); - const hasCanvasContent = renderedLayers.length > 0 || Boolean(selectedImage); + + // The pending/running canvas-destination queue items for THIS project, as a + // stable comma-joined key (so the selector's identity check stays cheap). The + // staging bar keys off whether any exist; the live progress preview is gated + // to only these items so a generate-widget run (same project) or another + // project's run can't draw its denoise frames over this canvas's bbox. + const canvasQueueItemIdsKey = useActiveProjectSelector((project) => + project.queue.items + .filter( + (item) => + item.snapshot.destination === 'canvas' && + (item.status === 'pending' || item.status === 'running') && + // Only this canvas SESSION's in-flight items: an item submitted before a + // wholesale swap (new canvas / snapshot restore) belongs to a document + // that no longer exists, so its denoise frames must not leak onto the + // fresh canvas (F2). `documentRevision` bumps only on those swaps. + item.snapshot.canvas.documentRevision === project.canvas.documentRevision + ) + .map((item) => item.id) + .join(',') + ); + const canvasQueueItemIds = useMemo( + () => new Set(canvasQueueItemIdsKey ? canvasQueueItemIdsKey.split(',') : []), + [canvasQueueItemIdsKey] + ); + const isCanvasGenerationInFlight = canvasQueueItemIds.size > 0; + // "Show progress on canvas" gates ONLY the live denoise-frame preview; when off, + // the accepted staged candidate still previews (that's staging, not progress). + const liveProgressImage = selectCanvasProgressImage(useProgressImage(), canvasQueueItemIds); + const progressImage = settings[CANVAS_SHOW_PROGRESS_KEY] ? liveProgressImage : null; + + // What the engine should draw as the staged preview: the live denoise-progress + // frame while generating, else the selected candidate, else nothing. The pure + // helper is unit-tested; the effect below drives the engine imperatively. + const previewSource = selectStagedPreviewSource({ + bboxHeight: document.bbox.height, + bboxWidth: document.bbox.width, + isGenerationInFlight: isCanvasGenerationInFlight, + isVisible: stagingArea.isVisible, + progressImage, + selectedImageName: selectedCandidate?.imageName ?? null, + }); + const previewKey = stagedPreviewKey(previewSource); + + // Syncing an external imperative system (the engine's staged preview) with + // derived reducer/progress state is a genuine effect. `useEffectEvent` reads + // the latest source without making it a dependency, so the decoding + // `setStagedPreview` re-runs only when `previewKey` actually changes (which + // includes every new progress frame) — never on unrelated re-renders. + const applyStagedPreview = useEffectEvent(() => { + engine?.setStagedPreview(previewSource); + }); + useEffect(() => { + applyStagedPreview(); + }, [engine, previewKey]); + // Clear the preview when the widget (or engine) goes away, so an accepted / + // discarded candidate never lingers over the canvas. + useEffect(() => { + return () => engine?.setStagedPreview(null); + }, [engine]); const executeCanvasHotkey = useEffectEvent((commandId: string) => { + const { layers, selectedLayerId } = document; + const selectedIndex = selectedLayerId ? layers.findIndex((layer) => layer.id === selectedLayerId) : -1; + const selectedLayer = selectedIndex >= 0 ? layers[selectedIndex] : undefined; + + // Arrow-key nudge: engine owns the bounds/lock logic (no-op with no/locked selection). + const nudge = NUDGE_DELTAS[commandId]; + if (nudge) { + engine?.nudgeSelectedLayer(nudge.dx, nudge.dy); + return; + } + + // Layer z-reorder: same forward/inverse construction as the layers panel. + const reorderKind = REORDER_KINDS[commandId]; + if (reorderKind) { + if (!engine || selectedIndex < 0) { + return; + } + const currentIds = layers.map((layer) => layer.id); + const nextIds = reorderIdsForHotkey(currentIds, selectedIndex, reorderKind); + if (!nextIds) { + return; + } + const { forward, inverse } = reorderLayerActions(currentIds, nextIds); + engine.commitStructural(t('widgets.canvas.commands.reorderLayer'), forward, inverse); + return; + } + if (commandId === 'canvas.prevEntity' && hasStagedCandidates) { dispatch({ direction: -1, type: 'cycleStagedImage' }); } else if (commandId === 'canvas.nextEntity' && hasStagedCandidates) { dispatch({ direction: 1, type: 'cycleStagedImage' }); - } else if (commandId === 'canvas.deleteSelected' && hasStagedCandidates) { - dispatch({ type: 'discardSelectedStagedImage' }); + } else if (commandId === 'canvas.deleteSelected') { + // Staged images take priority; otherwise delete the selected layer (undoable). + if (hasStagedCandidates) { + dispatch({ type: 'discardSelectedStagedImage' }); + } else if (engine && selectedLayer && selectedIndex >= 0) { + const { forward, inverse } = deleteLayerActions(selectedLayer, selectedIndex); + engine.commitStructural(t('widgets.canvas.commands.deleteLayer'), forward, inverse); + } } else if (commandId === 'canvas.undo') { - dispatch({ type: 'undoProjectChange' }); + // Canvas undo/redo is engine-scoped: it drives the engine-owned pixel/ + // structural history, NOT project-level (reducer) undo. When the canvas + // history is empty this is a no-op — it deliberately does not fall back to + // `undoProjectChange` (project undo keeps its own commands/hotkeys, e.g. + // the workflow editor's `workflows.undo`). + engine?.undo(); } else if (commandId === 'canvas.redo') { - dispatch({ type: 'redoProjectChange' }); + engine?.redo(); + } else if (commandId === 'canvas.tool.view') { + engine?.setTool('view'); + } else if (commandId === 'canvas.tool.move') { + engine?.setTool('move'); + } else if (commandId === 'canvas.transformSelected') { + // Selecting the transform tool opens a session on the selected layer (if any + // eligible one); Apply/Cancel (enter/esc) are handled engine-side. + engine?.setTool('transform'); + } else if (commandId === 'canvas.tool.bbox') { + engine?.setTool('bbox'); + } else if (commandId === 'canvas.tool.brush') { + engine?.setTool('brush'); + } else if (commandId === 'canvas.tool.eraser') { + engine?.setTool('eraser'); + } else if (commandId === 'canvas.tool.lasso') { + engine?.setTool('lasso'); + } else if (commandId === 'canvas.tool.shape') { + engine?.setTool('shape'); + } else if (commandId === 'canvas.tool.text') { + engine?.setTool('text'); + } else if (commandId === 'canvas.tool.gradient') { + engine?.setTool('gradient'); + } else if (commandId === 'canvas.selectAll') { + engine?.selectAll(); + } else if (commandId === 'canvas.deselect') { + engine?.deselect(); + } else if (commandId === 'canvas.invertSelection') { + engine?.invertSelection(); + } else if (commandId === 'canvas.brushSizeDown') { + engine?.stepBrushSize(-1); + } else if (commandId === 'canvas.brushSizeUp') { + engine?.stepBrushSize(1); + } else if (commandId === 'canvas.duplicateLayer') { + if (engine && selectedLayer) { + const { forward, inverse } = duplicateLayerActions(selectedLayer.id, createLayerId()); + engine.commitStructural(t('widgets.canvas.commands.duplicateLayer'), forward, inverse); + } + } else if (commandId === 'canvas.mergeDown') { + // Gate on the SAME predicate the layers panel's context menu uses to + // enable/disable its "Merge Down" item (`canMergeLayerDown`), so the hotkey + // can never fire where the menu would refuse — e.g. a mask layer selected, + // or a mask directly below the selection. `engine.mergeLayerDown` also + // guards this itself (defense in depth for callers other than this hotkey), + // but checking here keeps the two surfaces visibly in lockstep. + if (engine && selectedLayer && canMergeLayerDown(layers, selectedIndex, true)) { + engine.mergeLayerDown(selectedLayer.id); + } } }); - const handleAccept = useCallback(() => dispatch({ type: 'acceptStagedImage' }), [dispatch]); - const handleCycle = useCallback((direction: -1 | 1) => dispatch({ direction, type: 'cycleStagedImage' }), [dispatch]); - const handleDiscardAll = useCallback(() => dispatch({ type: 'discardAllStagedImages' }), [dispatch]); - const handleDiscardSelected = useCallback(() => dispatch({ type: 'discardSelectedStagedImage' }), [dispatch]); - const handleSelectImage = useCallback( - (imageIndex: number) => dispatch({ imageIndex, type: 'setStagedImageIndex' }), - [dispatch] - ); - const handleToggleThumbnails = useCallback( - () => dispatch({ type: 'toggleCanvasStagingThumbnailsVisibility' }), - [dispatch] - ); - const handleToggleVisibility = useCallback(() => dispatch({ type: 'toggleCanvasStagingVisibility' }), [dispatch]); - useEffect(() => { const hotkeys = [ - ['canvas.prevEntity', t('widgets.canvas.commands.previousEntity'), ['alt+[', 'arrowleft']], - ['canvas.nextEntity', t('widgets.canvas.commands.nextEntity'), ['alt+]', 'arrowright']], + // Staged-candidate cycling keeps `alt+[` / `alt+]`; the bare arrows now nudge + // the selected layer (see below), so they are no longer bound here. + ['canvas.prevEntity', t('widgets.canvas.commands.previousEntity'), ['alt+[']], + ['canvas.nextEntity', t('widgets.canvas.commands.nextEntity'), ['alt+]']], ['canvas.deleteSelected', t('widgets.canvas.commands.deleteSelected'), ['delete', 'backspace']], ['canvas.undo', t('widgets.canvas.commands.undo'), ['mod+z']], ['canvas.redo', t('widgets.canvas.commands.redo'), ['mod+shift+z', 'mod+y']], + // Tool selection and brush/eraser size step. `allowInEditable: false` below + // keeps these single-letter/bracket keys from firing while the user is + // typing in a prompt/text field elsewhere in the workbench. + ['canvas.tool.view', t('widgets.canvas.commands.selectViewTool'), ['h']], + ['canvas.tool.move', t('widgets.canvas.commands.selectMoveTool'), ['v']], + ['canvas.transformSelected', t('widgets.canvas.commands.selectTransformTool'), ['mod+t']], + ['canvas.tool.bbox', t('widgets.canvas.commands.selectBboxTool'), ['c']], + ['canvas.tool.brush', t('widgets.canvas.commands.selectBrushTool'), ['b']], + ['canvas.tool.eraser', t('widgets.canvas.commands.selectEraserTool'), ['e']], + ['canvas.tool.lasso', t('widgets.canvas.commands.selectLassoTool'), ['l']], + ['canvas.tool.shape', t('widgets.canvas.commands.selectShapeTool'), ['r']], + ['canvas.tool.text', t('widgets.canvas.commands.selectTextTool'), ['t']], + ['canvas.tool.gradient', t('widgets.canvas.commands.selectGradientTool'), ['g']], + // Selection: select all / deselect / invert (engine-owned transient selection). + ['canvas.selectAll', t('widgets.canvas.commands.selectAll'), ['mod+a']], + ['canvas.deselect', t('widgets.canvas.commands.deselect'), ['mod+d']], + ['canvas.invertSelection', t('widgets.canvas.commands.invertSelection'), ['mod+shift+i']], + ['canvas.brushSizeDown', t('widgets.canvas.commands.decreaseBrushSize'), ['[']], + ['canvas.brushSizeUp', t('widgets.canvas.commands.increaseBrushSize'), [']']], + // Move the selected layer: arrows nudge 1px, shift+arrows 10px. + ['canvas.nudgeLeft', t('widgets.canvas.commands.nudgeLeft'), ['arrowleft']], + ['canvas.nudgeRight', t('widgets.canvas.commands.nudgeRight'), ['arrowright']], + ['canvas.nudgeUp', t('widgets.canvas.commands.nudgeUp'), ['arrowup']], + ['canvas.nudgeDown', t('widgets.canvas.commands.nudgeDown'), ['arrowdown']], + ['canvas.nudgeLeftLarge', t('widgets.canvas.commands.nudgeLeftLarge'), ['shift+arrowleft']], + ['canvas.nudgeRightLarge', t('widgets.canvas.commands.nudgeRightLarge'), ['shift+arrowright']], + ['canvas.nudgeUpLarge', t('widgets.canvas.commands.nudgeUpLarge'), ['shift+arrowup']], + ['canvas.nudgeDownLarge', t('widgets.canvas.commands.nudgeDownLarge'), ['shift+arrowdown']], + // Layer management. + ['canvas.duplicateLayer', t('widgets.canvas.commands.duplicateLayer'), ['mod+j']], + ['canvas.mergeDown', t('widgets.canvas.commands.mergeDown'), ['mod+e']], + ['canvas.layerForward', t('widgets.canvas.commands.layerForward'), ['mod+]']], + ['canvas.layerBackward', t('widgets.canvas.commands.layerBackward'), ['mod+[']], + ['canvas.layerToFront', t('widgets.canvas.commands.layerToFront'), ['mod+shift+]']], + ['canvas.layerToBack', t('widgets.canvas.commands.layerToBack'), ['mod+shift+[']], ] as const; const disposers = hotkeys.flatMap(([id, title, defaultKeys]) => [ runtime.commands.register({ handler: () => executeCanvasHotkey(id), id, title }), - runtime.hotkeys.register({ commandId: id, defaultKeys: [...defaultKeys], id, title }), + runtime.hotkeys.register({ allowInEditable: false, commandId: id, defaultKeys: [...defaultKeys], id, title }), ]); return () => { @@ -68,66 +362,58 @@ export const CanvasWidgetView = ({ runtime }: WidgetViewProps) => { }, [runtime.commands, runtime.hotkeys, t]); return ( - - - - - {hasCanvasContent ? null : } - {renderedLayers.map((layer) => ( - - ))} - {selectedImage ? ( - - ) : null} - - - {hasStagedCandidates && selectedCandidate ? ( - - ) : ( - - )} + + {engine ? ( + <> + + + + + ) : null} + + {/* + * Floating bottom-center chrome: the staging bar (when active) stacks + * directly above the always-present tool options bar — "just like the + * staging UI". The wrapper is click-through so the canvas stays + * interactive around the bars; each bar re-enables pointer events. + */} + + {hasStagedCandidates || isCanvasGenerationInFlight ? ( + dispatch({ type: 'acceptStagedImage' })} + onCycle={(direction) => dispatch({ direction, type: 'cycleStagedImage' })} + onDiscardAll={() => dispatch({ type: 'discardAllStagedImages' })} + onDiscardSelected={() => dispatch({ type: 'discardSelectedStagedImage' })} + onSelectImage={(imageIndex) => dispatch({ imageIndex, type: 'setStagedImageIndex' })} + onSetAutoSwitch={(mode) => dispatch({ mode, type: 'setCanvasStagingAutoSwitch' })} + onToggleThumbnails={() => dispatch({ type: 'toggleCanvasStagingThumbnailsVisibility' })} + onToggleVisibility={() => dispatch({ type: 'toggleCanvasStagingVisibility' })} + /> + ) : null} + {engine ? ( + + ) : null} + ); }; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/StagingBar.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/StagingBar.tsx new file mode 100644 index 00000000000..2c2e0e5f938 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/StagingBar.tsx @@ -0,0 +1,312 @@ +/* oxlint-disable react-perf/jsx-no-new-object-as-prop, react-perf/jsx-no-new-function-as-prop */ +import type { CanvasStagingAreaContractV2, CanvasStagingCandidateContract } from '@workbench/types'; + +import { HStack, Menu, Portal, ScrollArea, Spinner, Stack, Text } from '@chakra-ui/react'; +import { Button, IconButton, MenuContent, toaster } from '@workbench/components/ui'; +import { getImageThumbnailUrl, saveImageToGallery } from '@workbench/gallery/api'; +import { CanvasFloatingBar } from '@workbench/widgets/canvas/CanvasFloatingBar'; +import { + CheckIcon, + ChevronDownIcon, + ChevronLeftIcon, + ChevronRightIcon, + ChevronUpIcon, + EyeIcon, + EyeOffIcon, + ImagePlusIcon, + SparklesIcon, + Trash2Icon, +} from 'lucide-react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +type AutoSwitchMode = CanvasStagingAreaContractV2['autoSwitchMode']; + +const THUMBNAIL_STRIP_HEIGHT = '5rem'; +const AUTO_SWITCH_MODES: AutoSwitchMode[] = ['off', 'latest', 'oldest']; +const MENU_POSITIONING = { placement: 'top-end' } as const; + +interface StagingBarProps { + areThumbnailsVisible: boolean; + autoSwitchMode: AutoSwitchMode; + hasMultipleCandidates: boolean; + isGenerating: boolean; + isVisible: boolean; + pendingImages: CanvasStagingCandidateContract[]; + selectedCandidate: CanvasStagingCandidateContract | undefined; + selectedImageIndex: number; + onAccept: () => void; + onCycle: (direction: -1 | 1) => void; + onDiscardAll: () => void; + onDiscardSelected: () => void; + onSelectImage: (imageIndex: number) => void; + onSetAutoSwitch: (mode: AutoSwitchMode) => void; + onToggleThumbnails: () => void; + onToggleVisibility: () => void; +} + +/** + * The floating staging bar over the canvas: appears while a canvas generation + * is in flight or staged candidates await a decision. It drives the reducer's + * staging actions (cycle / accept / discard / auto-switch); the candidate and + * live progress pixels themselves are drawn on the engine canvas via + * `engine.setStagedPreview` (wired in {@link CanvasWidgetView}). Rendered inside + * the canvas's bottom-center floating group, stacked directly above the tool + * options bar; positioning is the parent's job. + */ +export const StagingBar = ({ + areThumbnailsVisible, + autoSwitchMode, + hasMultipleCandidates, + isGenerating, + isVisible, + pendingImages, + selectedCandidate, + selectedImageIndex, + onAccept, + onCycle, + onDiscardAll, + onDiscardSelected, + onSelectImage, + onSetAutoSwitch, + onToggleThumbnails, + onToggleVisibility, +}: StagingBarProps) => { + const { t } = useTranslation(); + const [isSaving, setIsSaving] = useState(false); + const hasCandidates = pendingImages.length > 0; + + const handleSaveToGallery = async () => { + if (!selectedCandidate || isSaving) { + return; + } + setIsSaving(true); + try { + await saveImageToGallery(selectedCandidate.imageName); + toaster.create({ + description: t('widgets.canvas.staging.savedDescription', { name: selectedCandidate.imageName }), + title: t('widgets.canvas.staging.saved'), + type: 'success', + }); + } catch { + toaster.create({ title: t('widgets.canvas.staging.saveError'), type: 'error' }); + } finally { + setIsSaving(false); + } + }; + + return ( + + {hasCandidates ? ( + + + + + {pendingImages.map((candidate, index) => ( + onSelectImage(index)} + /> + ))} + + + + + + + + ) : null} + + + + {isGenerating ? ( + + + + {t('widgets.canvas.staging.generating')} + + + ) : null} + + {hasCandidates && selectedCandidate ? ( + <> + + {areThumbnailsVisible ? : } + + + + onCycle(-1)} + > + + + + {t('widgets.canvas.candidateCount', { + current: selectedImageIndex + 1, + total: pendingImages.length, + })} + + onCycle(1)} + > + + + + + + {isVisible ? : } + + + + {isSaving ? : } + + + + + + + + + + + + + ) : null} + + + + ); +}; + +const AutoSwitchMenu = ({ mode, onSelect }: { mode: AutoSwitchMode; onSelect: (mode: AutoSwitchMode) => void }) => { + const { t } = useTranslation(); + const label = (value: AutoSwitchMode): string => + t( + value === 'off' + ? 'widgets.canvas.staging.autoSwitchOff' + : value === 'latest' + ? 'widgets.canvas.staging.autoSwitchLatest' + : 'widgets.canvas.staging.autoSwitchOldest' + ); + + return ( + + + + + + {label(mode)} + + + + + + + {AUTO_SWITCH_MODES.map((value) => ( + onSelect(value)}> + + {label(value)} + + ))} + + + + + ); +}; + +const StagingThumbnail = ({ + candidate, + index, + isSelected, + onSelect, +}: { + candidate: CanvasStagingCandidateContract; + index: number; + isSelected: boolean; + onSelect: () => void; +}) => { + const { t } = useTranslation(); + + return ( + + {candidate.imageName} + + {index + 1} + + + ); +}; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/TextEditPortal.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/TextEditPortal.tsx new file mode 100644 index 00000000000..f41443d0ca0 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/TextEditPortal.tsx @@ -0,0 +1,176 @@ +/* oxlint-disable react-perf/jsx-no-new-object-as-prop -- the editable's style object is derived from the live session/viewport and intentionally recomputed each render. */ +import type { CanvasEngine } from '@workbench/canvas-engine/engine'; +import type { TextEditSession } from '@workbench/canvas-engine/engineStores'; +import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent } from 'react'; + +import { useTextEditSession } from '@workbench/widgets/canvas/engineStoreHooks'; +import { useCallback, useSyncExternalStore } from 'react'; + +/** + * The text-editing portal: a positioned `contenteditable` div, rendered over the + * canvas whenever a text-edit session is active, that IS the text while editing + * (the compositor skips the session's layer so the two never double-draw). + * + * ## WYSIWYG + * + * The editable's intrinsic styles are set in DOCUMENT units (font size in px, + * unitless line-height, family/weight/align/color from the live session source) + * — exactly what the rasterizer will bake. A single CSS transform then maps the + * layer's document anchor to the screen and magnifies by the view zoom, so the + * on-screen editable matches the rasterized output at any pan/zoom. `white-space: + * pre` mirrors the rasterizer's manual-line-break, no-auto-wrap layout. + * + * ## Keystrokes stay local + * + * Typing only mutates the editable's DOM — never the engine/store (no per-key + * traffic). Every keydown is `stopPropagation`'d so canvas hotkeys can't fire + * from the field (belt-and-braces with the pipeline's editable guard and the + * widget hotkeys' `allowInEditable: false`). Commit is on blur / `mod+enter`; + * `esc` cancels — the editable owns Escape (stopPropagation) so the engine's + * Escape priority never also runs. + */ + +/** Re-renders on any viewport (pan/zoom) change via a value-stable snapshot string. */ +const useViewportTick = (engine: CanvasEngine): string => { + const viewport = engine.getViewport(); + const subscribe = useCallback((onChange: () => void) => viewport.subscribe(onChange), [viewport]); + const getSnapshot = useCallback(() => { + const { pan, zoom } = viewport.getState(); + return `${zoom}|${pan.x}|${pan.y}`; + }, [viewport]); + useSyncExternalStore(subscribe, getSnapshot); + return ''; +}; + +/** Reads the editable's text with manual line breaks preserved (`\n` per visual line). */ +const readEditableText = (el: HTMLElement): string => el.innerText; + +/** Moves the caret to the end of `el`'s content. */ +const placeCaretAtEnd = (el: HTMLElement): void => { + const selection = window.getSelection(); + if (!selection) { + return; + } + const range = document.createRange(); + range.selectNodeContents(el); + range.collapse(false); + selection.removeAllRanges(); + selection.addRange(range); +}; + +interface TextEditableProps { + engine: CanvasEngine; + session: TextEditSession; +} + +/** + * The single editable element for one session. Keyed by `session.id` in the + * parent so a fresh session remounts it — the ref callback then seeds content + + * focus exactly once, while position/style recompute on each render. + */ +const TextEditable = ({ engine, session }: TextEditableProps) => { + // Re-render on pan/zoom so the transform below tracks the viewport. + useViewportTick(engine); + const viewport = engine.getViewport(); + const { source, transform } = session; + + // Seeds content + focus once when the element mounts, and registers a live- + // content reader with the engine so it can commit on a canvas pointerdown + // (click-elsewhere-to-commit) without per-keystroke traffic. Cleared on unmount. + // A stable callback, so React never re-runs it on a position/style re-render. + const setRef = useCallback( + (el: HTMLDivElement | null) => { + if (!el) { + // Unmount: stop the engine from reading a detached element. + engine.setTextEditContentReader(null); + return; + } + engine.setTextEditContentReader(() => readEditableText(el)); + if (el.dataset.seeded === 'true') { + return; + } + el.dataset.seeded = 'true'; + el.textContent = source.content; + el.focus(); + placeCaretAtEnd(el); + }, + // `session` is stable for this element's life (parent keys by session.id); + // seed from the source captured at mount. + [engine, source.content] + ); + + const onBlur = useCallback( + (event: { currentTarget: HTMLElement }) => { + engine.commitTextEdit(readEditableText(event.currentTarget)); + }, + [engine] + ); + + const onKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + // Keep every keystroke inside the field — no canvas hotkey/window key fires. + event.stopPropagation(); + if (event.key === 'Escape') { + event.preventDefault(); + engine.cancelTextEdit(); + return; + } + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + engine.commitTextEdit(readEditableText(event.currentTarget)); + } + }, + [engine] + ); + + const origin = viewport.documentToScreen({ x: transform.x, y: transform.y }); + const scale = viewport.getZoom() * transform.scaleX; + + const style: CSSProperties = { + background: 'transparent', + border: 'none', + color: source.color, + cursor: 'text', + fontFamily: source.fontFamily, + fontSize: `${source.fontSize}px`, + fontWeight: source.fontWeight, + left: 0, + lineHeight: source.lineHeight, + margin: 0, + minWidth: '1ch', + outline: 'none', + padding: 0, + pointerEvents: 'auto', + position: 'absolute', + textAlign: source.align, + top: 0, + transform: `translate(${origin.x}px, ${origin.y}px) rotate(${transform.rotation}rad) scale(${scale})`, + transformOrigin: '0 0', + whiteSpace: 'pre', + zIndex: 4, + }; + + return ( +
+ ); +}; + +/** Renders the editable for the active session, or nothing. */ +export const TextEditPortal = ({ engine }: { engine: CanvasEngine }) => { + const session = useTextEditSession(engine); + if (!session) { + return null; + } + return ; +}; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/ToolStrip.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/ToolStrip.tsx new file mode 100644 index 00000000000..aff1d66fc74 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/ToolStrip.tsx @@ -0,0 +1,74 @@ +import type { CanvasEngine } from '@workbench/canvas-engine/engine'; +import type { ToolId } from '@workbench/canvas-engine/types'; + +import { Box } from '@chakra-ui/react'; +import { Toolbar, ToolbarButton } from '@workbench/components/ui'; +import { + BrushIcon, + EraserIcon, + FrameIcon, + HandIcon, + LassoIcon, + MoveIcon, + PaintBucketIcon, + Rotate3dIcon, + SquareIcon, + TypeIcon, +} from 'lucide-react'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useCanvasActiveTool } from './engineStoreHooks'; + +interface ToolStripButtonProps { + engine: CanvasEngine; + icon: typeof HandIcon; + label: string; + toolId: ToolId; +} + +/** One sticky tool button: active state comes straight from the engine's transient store, click drives `engine.setTool`. */ +const ToolStripButton = ({ engine, icon, label, toolId }: ToolStripButtonProps) => { + const activeTool = useCanvasActiveTool(engine); + const onClick = useCallback(() => engine.setTool(toolId), [engine, toolId]); + + return ; +}; + +/** + * The canvas's left-docked, vertical tool strip. Color-picker is intentionally + * absent — it's alt-hold-only for now (see `canvas-engine/input/pointerPipeline.ts`), + * not a sticky tool a user selects directly. + */ +const ToolStripRoot = ({ engine }: { engine: CanvasEngine }) => { + const { t } = useTranslation(); + + return ( + + + + + + + + + + + + + + + ); +}; + +export const ToolStrip = Object.assign(ToolStripRoot, { Button: ToolStripButton }); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/bboxGrid.test.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/bboxGrid.test.ts new file mode 100644 index 00000000000..85212707e72 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/bboxGrid.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { DEFAULT_MODEL_GRID, gridSizeForModelBase } from './bboxGrid'; + +describe('gridSizeForModelBase', () => { + it('maps flux-family and sd-3 bases to a 16px grid', () => { + for (const base of ['flux', 'flux2', 'sd-3', 'qwen-image', 'z-image']) { + expect(gridSizeForModelBase(base)).toBe(16); + } + }); + + it('maps cogview4 to a 32px grid', () => { + expect(gridSizeForModelBase('cogview4')).toBe(32); + }); + + it('maps sd/sdxl and unknown bases to the default 8px grid', () => { + for (const base of ['sd-1', 'sd-2', 'sdxl', 'anima', 'mystery-model']) { + expect(gridSizeForModelBase(base)).toBe(8); + } + }); + + it('falls back to the default grid for null/undefined', () => { + expect(gridSizeForModelBase(null)).toBe(DEFAULT_MODEL_GRID); + expect(gridSizeForModelBase(undefined)).toBe(DEFAULT_MODEL_GRID); + expect(DEFAULT_MODEL_GRID).toBe(8); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/bboxGrid.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/bboxGrid.ts new file mode 100644 index 00000000000..d3252f25b0b --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/bboxGrid.ts @@ -0,0 +1,30 @@ +/** + * Maps a model base to the bbox snapping grid size (document px), mirroring the + * legacy `getGridSize` rule: generation dimensions must land on a base-specific + * multiple. React reads the active generate model's base and feeds the result + * into `engine.setBboxGrid`; the engine itself stays model-agnostic. + */ + +/** Default grid when no model is selected (or an unknown base). */ +export const DEFAULT_MODEL_GRID = 8; + +/** + * The bbox grid size for a model base: + * - `cogview4` → 32 + * - `flux` / `flux2` / `sd-3` / `qwen-image` / `z-image` → 16 + * - everything else (sd-1/sd-2/sdxl/anima/unknown) → 8 + */ +export const gridSizeForModelBase = (base: string | null | undefined): number => { + switch (base) { + case 'cogview4': + return 32; + case 'flux': + case 'flux2': + case 'sd-3': + case 'qwen-image': + case 'z-image': + return 16; + default: + return DEFAULT_MODEL_GRID; + } +}; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasSettings.test.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasSettings.test.ts new file mode 100644 index 00000000000..3a2626f1358 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasSettings.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; + +import { + CANVAS_BBOX_OVERLAY_KEY, + CANVAS_CHECKERBOARD_KEY, + CANVAS_INVERT_BRUSH_SCROLL_KEY, + CANVAS_RULE_OF_THIRDS_KEY, + CANVAS_SETTING_SECTIONS, + CANVAS_SETTINGS, + CANVAS_SHOW_BBOX_KEY, + CANVAS_SHOW_GRID_KEY, + CANVAS_SHOW_PROGRESS_KEY, + CANVAS_SNAP_TO_GRID_KEY, + canvasSettingsEqual, + readCanvasBooleanSetting, + resolveCanvasSettings, +} from './canvasSettings'; + +const settingByKey = (key: string) => { + const found = CANVAS_SETTINGS.find((s) => s.key === key); + if (!found) { + throw new Error(`no setting for key ${key}`); + } + return found; +}; + +const DEFAULTS = { + [CANVAS_BBOX_OVERLAY_KEY]: false, + [CANVAS_CHECKERBOARD_KEY]: true, + [CANVAS_INVERT_BRUSH_SCROLL_KEY]: false, + [CANVAS_RULE_OF_THIRDS_KEY]: false, + [CANVAS_SHOW_BBOX_KEY]: true, + [CANVAS_SHOW_GRID_KEY]: false, + [CANVAS_SHOW_PROGRESS_KEY]: true, + [CANVAS_SNAP_TO_GRID_KEY]: true, +}; + +describe('canvasSettings persistence mapping', () => { + it('applies each setting default when values are missing', () => { + expect(resolveCanvasSettings(undefined)).toEqual(DEFAULTS); + expect(resolveCanvasSettings({})).toEqual(DEFAULTS); + }); + + it('reads persisted booleans over the defaults', () => { + const resolved = resolveCanvasSettings({ + [CANVAS_BBOX_OVERLAY_KEY]: true, + [CANVAS_CHECKERBOARD_KEY]: false, + [CANVAS_INVERT_BRUSH_SCROLL_KEY]: true, + [CANVAS_RULE_OF_THIRDS_KEY]: true, + [CANVAS_SHOW_BBOX_KEY]: false, + [CANVAS_SHOW_GRID_KEY]: true, + [CANVAS_SHOW_PROGRESS_KEY]: false, + [CANVAS_SNAP_TO_GRID_KEY]: false, + }); + expect(resolved).toEqual({ + [CANVAS_BBOX_OVERLAY_KEY]: true, + [CANVAS_CHECKERBOARD_KEY]: false, + [CANVAS_INVERT_BRUSH_SCROLL_KEY]: true, + [CANVAS_RULE_OF_THIRDS_KEY]: true, + [CANVAS_SHOW_BBOX_KEY]: false, + [CANVAS_SHOW_GRID_KEY]: true, + [CANVAS_SHOW_PROGRESS_KEY]: false, + [CANVAS_SNAP_TO_GRID_KEY]: false, + }); + }); + + it('falls back to the default for a non-boolean persisted value', () => { + expect(readCanvasBooleanSetting({ [CANVAS_CHECKERBOARD_KEY]: 'nope' }, settingByKey(CANVAS_CHECKERBOARD_KEY))).toBe( + true + ); + expect(readCanvasBooleanSetting({ [CANVAS_SHOW_GRID_KEY]: 1 }, settingByKey(CANVAS_SHOW_GRID_KEY))).toBe(false); + }); + + it('maps every setting to a unique key and a known section', () => { + const keys = CANVAS_SETTINGS.map((s) => s.key); + expect(new Set(keys).size).toBe(keys.length); + for (const setting of CANVAS_SETTINGS) { + expect(CANVAS_SETTING_SECTIONS).toContain(setting.section); + } + }); + + it('gives every engine-backed store a distinct store id (React-consumed settings have none)', () => { + const stores = CANVAS_SETTINGS.map((s) => s.store).filter((s): s is NonNullable => s !== undefined); + expect(new Set(stores).size).toBe(stores.length); + // showProgressOnCanvas is consumed React-side and intentionally has no store. + expect(settingByKey(CANVAS_SHOW_PROGRESS_KEY).store).toBeUndefined(); + }); +}); + +describe('canvasSettingsEqual', () => { + it('is true for equal maps and false when any setting differs', () => { + const base = resolveCanvasSettings({}); + expect(canvasSettingsEqual(base, resolveCanvasSettings({}))).toBe(true); + expect(canvasSettingsEqual(base, resolveCanvasSettings({ [CANVAS_SHOW_GRID_KEY]: true }))).toBe(false); + expect(canvasSettingsEqual(base, resolveCanvasSettings({ [CANVAS_SHOW_BBOX_KEY]: false }))).toBe(false); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasSettings.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasSettings.ts new file mode 100644 index 00000000000..4ab14fc9b77 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasSettings.ts @@ -0,0 +1,156 @@ +/** + * Canvas view settings — the sectioned, legacy-style popover of per-project + * canvas preferences (Behavior / Display / Grid), plus the Shift-revealed Debug + * actions rendered separately in the header. + * + * Each setting is a boolean persisted in the canvas widget's own state values + * (`widgetInstances['canvas'].state.values[key]`) — the same plumbing as the + * denoising strength (`invoke/canvasStrength.ts`), so it survives reloads and + * rides along in queue snapshots. Persistence is per-user (per-project), NEVER in + * the canvas undo history. + * + * A setting either drives an engine boolean store (`store` set — React resolves + * the persisted value and feeds it down; see the settings-feed effect in + * `CanvasWidgetView`, the engine reads only its stores and never React) OR is + * consumed directly React-side (`store` absent — e.g. `showProgressOnCanvas`, + * which gates the progress-preview feed in the widget shell). The feed is strictly + * one-directional: settings → engine, never the reverse. + * + * The list is data-driven: adding a boolean setting is one entry here plus its + * i18n label and (when engine-backed) the store it drives. Pure data + readers; + * no React, no engine imports — unit-testable in node. + */ + +/** Which engine boolean store a setting feeds (settings whose `store` is absent are React-consumed). */ +export type CanvasSettingStore = + | 'checkerboard' + | 'showGrid' + | 'invertBrushSizeScroll' + | 'showBbox' + | 'bboxOverlay' + | 'ruleOfThirds' + | 'snapToGrid'; + +/** The popover section a setting is grouped under. */ +export type CanvasSettingSection = 'behavior' | 'display' | 'grid'; + +/** Persisted keys inside the canvas widget's `state.values`. */ +export const CANVAS_CHECKERBOARD_KEY = 'showCheckerboard'; +export const CANVAS_SHOW_GRID_KEY = 'showGrid'; +export const CANVAS_INVERT_BRUSH_SCROLL_KEY = 'invertBrushSizeScroll'; +export const CANVAS_SHOW_BBOX_KEY = 'showBbox'; +export const CANVAS_BBOX_OVERLAY_KEY = 'bboxOverlay'; +export const CANVAS_RULE_OF_THIRDS_KEY = 'ruleOfThirds'; +export const CANVAS_SNAP_TO_GRID_KEY = 'snapToGrid'; +export const CANVAS_SHOW_PROGRESS_KEY = 'showProgressOnCanvas'; + +/** A single boolean canvas setting: its persisted key, default, label, section, and (optional) engine store. */ +export interface CanvasBooleanSetting { + /** The persisted key inside the canvas widget's `state.values`. */ + key: string; + /** The engine store this value drives; absent when the setting is consumed React-side. */ + store?: CanvasSettingStore; + /** Default value applied when the key is unset. */ + defaultValue: boolean; + /** i18n key for the popover label. */ + labelKey: string; + /** Which popover section the setting belongs to. */ + section: CanvasSettingSection; +} + +/** + * The canvas settings, in popover order (grouped by section). Extend by adding an + * entry (plus its label and, when engine-backed, a store); the popover, + * persistence, and engine feed all derive from this list. + */ +export const CANVAS_SETTINGS: readonly CanvasBooleanSetting[] = [ + // ── Behavior ────────────────────────────────────────────────────────────── + { + defaultValue: false, + key: CANVAS_INVERT_BRUSH_SCROLL_KEY, + labelKey: 'widgets.canvas.settings.invertBrushScroll', + section: 'behavior', + store: 'invertBrushSizeScroll', + }, + // ── Display ─────────────────────────────────────────────────────────────── + { + defaultValue: true, + key: CANVAS_SHOW_PROGRESS_KEY, + labelKey: 'widgets.canvas.settings.showProgressOnCanvas', + section: 'display', + // No engine store: gated React-side (the progress-preview feed in CanvasWidgetView). + }, + { + // Legacy-parity "bbox overlay": dims everything OUTSIDE the generation frame. + defaultValue: false, + key: CANVAS_BBOX_OVERLAY_KEY, + labelKey: 'widgets.canvas.settings.bboxOverlay', + section: 'display', + store: 'bboxOverlay', + }, + { + // webv2 extra (no legacy counterpart): hides the passive dashed bbox frame. + defaultValue: true, + key: CANVAS_SHOW_BBOX_KEY, + labelKey: 'widgets.canvas.settings.showBbox', + section: 'display', + store: 'showBbox', + }, + { + defaultValue: true, + key: CANVAS_CHECKERBOARD_KEY, + labelKey: 'widgets.canvas.settings.checkerboard', + section: 'display', + store: 'checkerboard', + }, + // ── Grid ────────────────────────────────────────────────────────────────── + { + defaultValue: false, + key: CANVAS_SHOW_GRID_KEY, + labelKey: 'widgets.canvas.settings.grid', + section: 'grid', + store: 'showGrid', + }, + { + defaultValue: true, + key: CANVAS_SNAP_TO_GRID_KEY, + labelKey: 'widgets.canvas.settings.snapToGrid', + section: 'grid', + store: 'snapToGrid', + }, + { + defaultValue: false, + key: CANVAS_RULE_OF_THIRDS_KEY, + labelKey: 'widgets.canvas.settings.ruleOfThirds', + section: 'grid', + store: 'ruleOfThirds', + }, +]; + +/** The sections, in popover render order. */ +export const CANVAS_SETTING_SECTIONS: readonly CanvasSettingSection[] = ['behavior', 'display', 'grid']; + +/** Resolved settings, keyed by persisted setting key (default-applied). */ +export type ResolvedCanvasSettings = Record; + +/** Reads one boolean setting from a widget's `state.values`, applying its default when unset/non-boolean. */ +export const readCanvasBooleanSetting = ( + values: Record | undefined, + setting: CanvasBooleanSetting +): boolean => { + const raw = values?.[setting.key]; + return typeof raw === 'boolean' ? raw : setting.defaultValue; +}; + +/** Resolves every canvas setting from persisted values into a key→boolean map. */ +export const resolveCanvasSettings = (values: Record | undefined): ResolvedCanvasSettings => { + const resolved: ResolvedCanvasSettings = {}; + for (const setting of CANVAS_SETTINGS) { + resolved[setting.key] = readCanvasBooleanSetting(values, setting); + } + return resolved; +}; + +/** Structural equality for two resolved-settings maps (stable selector identity). */ +export const canvasSettingsEqual = (a: ResolvedCanvasSettings, b: ResolvedCanvasSettings): boolean => + CANVAS_SETTINGS.every((setting) => a[setting.key] === b[setting.key]); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/checkerColors.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/checkerColors.ts new file mode 100644 index 00000000000..0ea15e17c8c --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/checkerColors.ts @@ -0,0 +1,71 @@ +/** + * Resolves the transparency checkerboard's two square colors from the live Chakra + * theme so the infinite-canvas surround uses theme tokens (not hardcoded greys), + * and re-resolves on theme / color-mode switches. The resolved colors are fed + * one-directionally into the engine's `checkerColors` store (the same + * settings→engine-store pattern as the boolean canvas settings); the engine stays + * React-free and rebuilds its cached checker tile when the colors change. + * + * Resolution reads the *computed* backgroundColor of a hidden probe element set to + * each semantic token's CSS custom property — the browser's ground truth for the + * active ``. Falls back to {@link DEFAULT_CHECKER_COLORS} when the + * DOM is unavailable (node tests) or a token yields nothing usable. + */ + +import { system } from '@theme/system'; +import { type CheckerColors, DEFAULT_CHECKER_COLORS } from '@workbench/canvas-engine/render/compositor'; + +/** + * The two Chakra semantic tokens used for the checker squares. `bg.subtle` and + * `bg.emphasized` are two adjacent neutral surfaces, giving a low-contrast checker + * that reads as "empty" in every theme (light and dark) without a bespoke pair. + */ +export const CHECKER_TOKEN_A = 'bg.subtle'; +export const CHECKER_TOKEN_B = 'bg.emphasized'; + +/** The `var(--chakra-colors-…)` reference for a semantic color token, or `null` if unknown. */ +const cssVarRef = (token: string): string | null => { + const varName = system.tokens.getByName(`colors.${token}`)?.extensions.cssVar?.var; + return varName ? `var(${varName})` : null; +}; + +/** + * Whether a computed color string is usable — a non-empty color that isn't fully + * transparent (the browser's answer when a var failed to resolve). + */ +export const isUsableColor = (value: string | null | undefined): value is string => + typeof value === 'string' && value.trim() !== '' && value !== 'transparent' && value !== 'rgba(0, 0, 0, 0)'; + +/** Returns `resolved` when usable, else `fallback`. */ +export const pickCheckerColor = (resolved: string | null | undefined, fallback: string): string => + isUsableColor(resolved) ? resolved : fallback; + +/** + * Resolves both checker colors from the current theme. Safe to call anytime; in a + * non-DOM environment (or before the theme is applied) it returns the fallback + * pair so callers always get concrete colors. + */ +export const resolveCheckerColors = (): CheckerColors => { + if (typeof document === 'undefined' || typeof getComputedStyle !== 'function' || !document.body) { + return { ...DEFAULT_CHECKER_COLORS }; + } + const probe = document.createElement('div'); + probe.style.display = 'none'; + document.body.appendChild(probe); + try { + const read = (token: string, fallback: string): string => { + const ref = cssVarRef(token); + if (!ref) { + return fallback; + } + probe.style.backgroundColor = ref; + return pickCheckerColor(getComputedStyle(probe).backgroundColor, fallback); + }; + return { + a: read(CHECKER_TOKEN_A, DEFAULT_CHECKER_COLORS.a), + b: read(CHECKER_TOKEN_B, DEFAULT_CHECKER_COLORS.b), + }; + } finally { + probe.remove(); + } +}; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/engineStoreHooks.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/engineStoreHooks.ts new file mode 100644 index 00000000000..25c3d51da0a --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/engineStoreHooks.ts @@ -0,0 +1,101 @@ +/** + * React bindings for the engine's transient stores. + * + * `canvas-engine/engineStores.ts` deliberately ships React-free + * (`useSyncExternalStore`-compatible) channels so the engine stays node-safe. + * These hooks are the widget-side adapter — the one place React subscribes to + * that engine-owned interaction state. Keeping the React import here (under + * `widgets/`) preserves the engine's zero-React boundary. + */ + +import type { CanvasEngine } from '@workbench/canvas-engine/engine'; +import type { + BboxToolOptions, + BrushOptions, + EraserOptions, + GradientToolOptions, + LassoToolOptions, + ScalarStore, + ShapeToolOptions, + TextEditSession, + TextToolOptions, + TransformSession, +} from '@workbench/canvas-engine/engineStores'; +import type { ToolId } from '@workbench/canvas-engine/types'; + +import { useCallback, useSyncExternalStore } from 'react'; + +/** Subscribes the calling component to a single engine scalar store. */ +const useScalarStore = (store: ScalarStore): T => useSyncExternalStore(store.subscribe, store.get); + +/** + * Subscribes to a single layer's thumbnail version on `engine`, re-rendering + * only when that layer's cached pixels change (the engine bumps the version on + * every repaint / rasterize). Tolerates a `null` engine — before the engine + * mounts the hook simply reports `undefined` and never subscribes — so the + * layers panel can render fallback thumbnails without an attached engine. + */ +export const useLayerThumbnailVersion = (engine: CanvasEngine | null, layerId: string): number | undefined => { + const subscribe = useCallback( + (onStoreChange: () => void) => engine?.stores.thumbnailVersion.subscribeKey(layerId, onStoreChange) ?? (() => {}), + [engine, layerId] + ); + const getSnapshot = useCallback(() => engine?.stores.thumbnailVersion.get(layerId), [engine, layerId]); + return useSyncExternalStore(subscribe, getSnapshot); +}; + +/** Current viewport zoom factor for `engine` (re-renders on zoom change). */ +export const useCanvasZoom = (engine: CanvasEngine): number => useScalarStore(engine.stores.zoom); + +/** Whether `engine` has render targets bound and its viewport is live. */ +export const useCanvasViewportReady = (engine: CanvasEngine): boolean => useScalarStore(engine.stores.viewportReady); + +/** The active tool id for `engine`. */ +export const useCanvasActiveTool = (engine: CanvasEngine): ToolId => useScalarStore(engine.stores.activeTool); + +/** Whether the engine-owned canvas history has an entry to undo (enables the header undo button). */ +export const useCanvasCanUndo = (engine: CanvasEngine): boolean => useScalarStore(engine.stores.canUndo); + +/** Whether the engine-owned canvas history has an entry to redo (enables the header redo button). */ +export const useCanvasCanRedo = (engine: CanvasEngine): boolean => useScalarStore(engine.stores.canRedo); + +/** + * The brush tool's current options (size / color / opacity / pressure). Write + * through `engine.stores.brushOptions.set(...)` directly — there is no reducer + * mirror to dispatch through, so the options bar reads and writes this one + * store. + */ +export const useBrushOptions = (engine: CanvasEngine): BrushOptions => useScalarStore(engine.stores.brushOptions); + +/** The eraser tool's current options (size / opacity). Write through `engine.stores.eraserOptions.set(...)`. */ +export const useEraserOptions = (engine: CanvasEngine): EraserOptions => useScalarStore(engine.stores.eraserOptions); + +/** The bbox tool's current options (aspect lock / ratio). Write through `engine.stores.bboxOptions.set(...)`. */ +export const useBboxOptions = (engine: CanvasEngine): BboxToolOptions => useScalarStore(engine.stores.bboxOptions); + +/** The bbox tool's current snapping grid size (document px). */ +export const useBboxGrid = (engine: CanvasEngine): number => useScalarStore(engine.stores.bboxGrid); + +/** The active transform-tool session (layer id + live transform), or `null`. */ +export const useTransformSession = (engine: CanvasEngine): TransformSession | null => + useScalarStore(engine.stores.transformSession); + +/** Whether a pixel selection currently exists (enables fill/erase/invert/deselect controls). */ +export const useCanvasHasSelection = (engine: CanvasEngine): boolean => useScalarStore(engine.stores.hasSelection); + +/** The lasso tool's current options (the committed boolean op mode). Write through `engine.stores.lassoOptions.set(...)`. */ +export const useLassoOptions = (engine: CanvasEngine): LassoToolOptions => useScalarStore(engine.stores.lassoOptions); + +/** The shape tool's current options (kind / fill / stroke / stroke width). Write through `engine.stores.shapeOptions.set(...)`. */ +export const useShapeOptions = (engine: CanvasEngine): ShapeToolOptions => useScalarStore(engine.stores.shapeOptions); + +/** The gradient tool's current options (kind / angle / stops). Write through `engine.stores.gradientOptions.set(...)`. */ +export const useGradientOptions = (engine: CanvasEngine): GradientToolOptions => + useScalarStore(engine.stores.gradientOptions); + +/** The text tool's current options (font / size / weight / line-height / align / color). */ +export const useTextOptions = (engine: CanvasEngine): TextToolOptions => useScalarStore(engine.stores.textOptions); + +/** The active text-editing session (create/edit mode + live source + transform), or `null`. */ +export const useTextEditSession = (engine: CanvasEngine): TextEditSession | null => + useScalarStore(engine.stores.textEditSession); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/fitBbox.test.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/fitBbox.test.ts new file mode 100644 index 00000000000..540b0eb4135 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/fitBbox.test.ts @@ -0,0 +1,137 @@ +import type { CanvasDocumentContractV2, CanvasLayerContract } from '@workbench/types'; + +import { describe, expect, it } from 'vitest'; + +import { computeFitBboxToLayers, computeFitBboxToMasks, fitRectToGrid, unionRenderableBounds } from './fitBbox'; + +/** An axis-aligned image layer (no rotation): document bounds are `[x, y, w, h]`. */ +const imageLayer = ( + id: string, + x: number, + y: number, + width: number, + height: number, + isEnabled = true +): CanvasLayerContract => + ({ + blendMode: 'normal', + id, + isEnabled, + isLocked: false, + name: id, + opacity: 1, + source: { image: { height, imageName: `${id}-img`, width }, type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x, y }, + type: 'raster', + }) as CanvasLayerContract; + +const maskLayer = ( + type: 'inpaint_mask' | 'regional_guidance', + id: string, + x: number, + y: number, + bitmap: { imageName: string; width: number; height: number } | null +): CanvasLayerContract => + ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + mask: { bitmap, fill: { color: '#e07575', style: 'diagonal' } }, + name: id, + opacity: 1, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x, y }, + type, + }) as CanvasLayerContract; + +const inpaintMask = ( + id: string, + x: number, + y: number, + bitmap: { imageName: string; width: number; height: number } | null +): CanvasLayerContract => maskLayer('inpaint_mask', id, x, y, bitmap); + +const docWith = (layers: CanvasLayerContract[]): CanvasDocumentContractV2 => + ({ + background: 'transparent', + bbox: { height: 512, width: 512, x: 0, y: 0 }, + height: 1024, + layers, + selectedLayerId: null, + version: 2, + width: 1024, + }) as CanvasDocumentContractV2; + +describe('fitRectToGrid', () => { + it('snaps the rect inward to grid multiples (top-left up, size down)', () => { + // x=10 → ceil(10/8)*8=16 (+6); w=240 → floor((240-6)/8)*8=232. + expect(fitRectToGrid({ height: 230, width: 240, x: 10, y: 20 }, 8)).toEqual({ + height: 224, + width: 232, + x: 16, + y: 24, + }); + }); + + it('degrades to a plain integer round when the grid is <= 1 (snap-to-grid off)', () => { + // g=1: x=ceil(3.2)=4 (+0.8), w=floor(10.9-0.8)=10; y=ceil(4.8)=5 (+0.2), h=floor(12.4-0.2)=12. + expect(fitRectToGrid({ height: 12.4, width: 10.9, x: 3.2, y: 4.8 }, 1)).toEqual({ + height: 12, + width: 10, + x: 4, + y: 5, + }); + }); +}); + +describe('unionRenderableBounds', () => { + it('unions enabled, non-empty layers and skips disabled / empty ones', () => { + const doc = docWith([ + imageLayer('a', 10, 20, 100, 100), + imageLayer('b', 200, 200, 50, 50), + imageLayer('c', 500, 500, 40, 40, false), // disabled → excluded + inpaintMask('empty', 0, 0, null), // empty mask → excluded + ]); + expect(unionRenderableBounds(doc, () => true)).toEqual({ height: 230, width: 240, x: 10, y: 20 }); + }); + + it('returns null when nothing qualifies', () => { + expect(unionRenderableBounds(docWith([inpaintMask('empty', 0, 0, null)]), () => true)).toBeNull(); + }); +}); + +describe('computeFitBboxToLayers', () => { + it('fits the union of all visible content, grid-snapped', () => { + const doc = docWith([imageLayer('a', 10, 20, 100, 100), imageLayer('b', 200, 200, 50, 50)]); + expect(computeFitBboxToLayers(doc, 8)).toEqual({ height: 224, width: 232, x: 16, y: 24 }); + }); + + it('returns null with no content (empty canvas)', () => { + expect(computeFitBboxToLayers(docWith([]), 8)).toBeNull(); + }); +}); + +describe('computeFitBboxToMasks', () => { + it('fits only inpaint masks, padded then grid-snapped', () => { + const doc = docWith([ + imageLayer('a', 0, 0, 500, 500), // ignored (not a mask) + inpaintMask('m', 100, 100, { height: 40, imageName: 'm-img', width: 40 }), + ]); + // mask bounds {100,100,40,40} → pad 8 → {92,92,56,56} → grid 8 → {96,96,48,48}. + expect(computeFitBboxToMasks(doc, 8)).toEqual({ height: 48, width: 48, x: 96, y: 96 }); + }); + + it('excludes regional-guidance masks (legacy fits inpaint_mask only)', () => { + const regional = maskLayer('regional_guidance', 'rg', 400, 400, { height: 40, imageName: 'rg-img', width: 40 }); + const doc = docWith([regional, inpaintMask('m', 100, 100, { height: 40, imageName: 'm-img', width: 40 })]); + // Identical to the inpaint-only fit above: the regional mask contributes nothing. + expect(computeFitBboxToMasks(doc, 8)).toEqual({ height: 48, width: 48, x: 96, y: 96 }); + // Regional masks alone ⇒ nothing to fit. + expect(computeFitBboxToMasks(docWith([regional]), 8)).toBeNull(); + }); + + it('returns null when there are no visible inpaint masks', () => { + expect(computeFitBboxToMasks(docWith([imageLayer('a', 0, 0, 100, 100)]), 8)).toBeNull(); + expect(computeFitBboxToMasks(docWith([inpaintMask('empty', 0, 0, null)]), 8)).toBeNull(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/fitBbox.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/fitBbox.ts new file mode 100644 index 00000000000..4650ef1626e --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/fitBbox.ts @@ -0,0 +1,100 @@ +/** + * Pure geometry for the "fit bbox to layers / masks" header actions, mirroring + * legacy `CanvasBboxToolModule.fitToLayers` / `useAutoFitBBoxToMasks`: + * + * 1. Union the DOCUMENT-space bounds of the qualifying ENABLED, renderable, + * non-empty layers (all layers for fit-to-layers; INPAINT masks only for + * fit-to-masks — legacy fits `getVisibleRectOfType('inpaint_mask')`, so + * regional-guidance masks are deliberately excluded). + * 2. For masks, expand the union outward by a fixed padding (legacy adds + * `maskBlur + 8`; webv2 does not plumb the mask-blur param through the widget + * yet, so a constant {@link MASK_FIT_PADDING} is used — see the report). + * 3. Snap the rect INWARD to the model bbox grid via {@link fitRectToGrid} so the + * result lands on the same multiples a manual bbox edit snaps to. + * + * Returns `null` when there is nothing to fit (no qualifying content, or the + * snapped rect collapses to empty) so the caller can disable the button and never + * dispatch a degenerate bbox. React feeds the grid size from generate settings + * (`gridSizeForModelBase`), exactly like the bbox tool. + * + * Zero React, zero engine/DOM state — unit-tested in node. + */ + +import type { Rect } from '@workbench/canvas-engine/types'; +import type { CanvasDocumentContractV2, CanvasLayerContract } from '@workbench/types'; + +import { getSourceBounds, isRenderableLayer } from '@workbench/canvas-engine/document/sources'; +import { isEmpty, union } from '@workbench/canvas-engine/math/rect'; + +/** Fixed outward padding (document px) applied to the mask union before grid-fitting. */ +const MASK_FIT_PADDING = 8; + +/** + * Snaps a rect INWARD to `gridSize`: the top-left edges round up (toward the + * interior) and the width/height round down, so the result aligns to the grid and + * never exceeds the input rect. `gridSize <= 1` degrades to a plain integer round + * (no snapping — used when snap-to-grid is off). Mirrors legacy `fitRectToGrid`. + */ +export const fitRectToGrid = (rect: Rect, gridSize: number): Rect => { + const g = gridSize > 1 ? gridSize : 1; + const x = Math.ceil(rect.x / g) * g; + const y = Math.ceil(rect.y / g) * g; + const width = Math.floor((rect.width - (x - rect.x)) / g) * g; + const height = Math.floor((rect.height - (y - rect.y)) / g) * g; + return { height, width, x, y }; +}; + +/** + * The union of the document-space bounds of every ENABLED, renderable layer whose + * content is non-empty and that satisfies `predicate`, or `null` when none + * qualify. Empty layers (a brand-new bitmap-less mask, an empty paint layer) + * contribute a zero-size rect and are skipped, matching legacy's `hasObjects()` + * gate. + */ +export const unionRenderableBounds = ( + doc: CanvasDocumentContractV2, + predicate: (layer: CanvasLayerContract) => boolean +): Rect | null => { + let bounds: Rect | null = null; + for (const layer of doc.layers) { + if (!isRenderableLayer(layer) || !predicate(layer)) { + continue; + } + const layerBounds = getSourceBounds(layer, doc); + if (isEmpty(layerBounds)) { + continue; + } + bounds = bounds ? union(bounds, layerBounds) : layerBounds; + } + return bounds; +}; + +/** The grid-snapped bbox that tightly fits all visible content, or `null` when there is none. */ +export const computeFitBboxToLayers = (doc: CanvasDocumentContractV2, gridSize: number): Rect | null => { + const bounds = unionRenderableBounds(doc, () => true); + if (!bounds) { + return null; + } + const fitted = fitRectToGrid(bounds, gridSize); + return isEmpty(fitted) ? null : fitted; +}; + +/** The padded, grid-snapped bbox that fits the visible INPAINT masks (legacy parity), or `null` when there are none. */ +export const computeFitBboxToMasks = ( + doc: CanvasDocumentContractV2, + gridSize: number, + padding: number = MASK_FIT_PADDING +): Rect | null => { + const bounds = unionRenderableBounds(doc, (layer) => layer.type === 'inpaint_mask'); + if (!bounds) { + return null; + } + const expanded: Rect = { + height: bounds.height + padding * 2, + width: bounds.width + padding * 2, + x: bounds.x - padding, + y: bounds.y - padding, + }; + const fitted = fitRectToGrid(expanded, gridSize); + return isEmpty(fitted) ? null : fitted; +}; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/index.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/index.ts index d794b960539..9dcadede163 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/index.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/index.ts @@ -2,6 +2,7 @@ import type { WidgetManifest } from '@workbench/types'; import { WandSparklesIcon } from 'lucide-react'; +import { CanvasHeaderActions } from './CanvasHeaderActions'; import { CanvasWidgetView } from './CanvasWidgetView'; export const canvasWidgetManifest: WidgetManifest = { @@ -9,6 +10,7 @@ export const canvasWidgetManifest: WidgetManifest = { allowedRegions: ['center'], failurePolicy: { isolateRenderFailure: true, onRegistrationFailure: 'disable' }, graphBearing: { defaultGraphId: 'canvas-graph', sourceId: 'canvas', surfaces: ['center'] }, + headerActions: CanvasHeaderActions, icon: WandSparklesIcon, id: 'canvas', label: (t) => t('widgets.labels.canvas'), diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/canvasCompositing.test.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/canvasCompositing.test.ts new file mode 100644 index 00000000000..0c77df6f97e --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/canvasCompositing.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { DEFAULT_CANVAS_COMPOSITING, readCanvasCompositingSettings } from './canvasCompositing'; + +describe('readCanvasCompositingSettings', () => { + it('returns legacy defaults for undefined values', () => { + expect(readCanvasCompositingSettings(undefined)).toEqual(DEFAULT_CANVAS_COMPOSITING); + }); + + it('mirrors the legacy params-slice defaults exactly', () => { + expect(DEFAULT_CANVAS_COMPOSITING).toEqual({ + coherenceEdgeSize: 16, + coherenceMinDenoise: 0, + coherenceMode: 'Gaussian Blur', + infillColorValue: { a: 1, b: 0, g: 0, r: 0 }, + infillMethod: 'lama', + infillPatchmatchDownscaleSize: 1, + infillTileSize: 32, + maskBlur: 16, + }); + }); + + it('reads valid persisted values', () => { + const settings = readCanvasCompositingSettings({ + coherenceEdgeSize: 32, + coherenceMinDenoise: 0.2, + coherenceMode: 'Box Blur', + infillMethod: 'patchmatch', + maskBlur: 8, + }); + expect(settings.coherenceEdgeSize).toBe(32); + expect(settings.coherenceMinDenoise).toBe(0.2); + expect(settings.coherenceMode).toBe('Box Blur'); + expect(settings.infillMethod).toBe('patchmatch'); + expect(settings.maskBlur).toBe(8); + }); + + it('falls back to defaults for invalid enum values', () => { + const settings = readCanvasCompositingSettings({ coherenceMode: 'Wobble', infillMethod: 'magic' }); + expect(settings.coherenceMode).toBe('Gaussian Blur'); + expect(settings.infillMethod).toBe('lama'); + }); + + it('clamps numeric ranges and rounds integers', () => { + const settings = readCanvasCompositingSettings({ + coherenceEdgeSize: -5, + coherenceMinDenoise: 5, + maskBlur: 12.7, + }); + expect(settings.coherenceEdgeSize).toBe(0); + expect(settings.coherenceMinDenoise).toBe(1); + expect(settings.maskBlur).toBe(13); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/canvasCompositing.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/canvasCompositing.ts new file mode 100644 index 00000000000..63aba28df4b --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/canvasCompositing.ts @@ -0,0 +1,133 @@ +/** + * Canvas compositing settings — the infill / coherence / mask-blur knobs a + * canvas inpaint or outpaint invoke exposes. + * + * Like {@link import('./canvasStrength').readCanvasDenoisingStrength}, these + * values are persisted per-project inside the canvas widget's own state values + * (`widgetInstances['canvas'].state.values`), so they survive reloads and ride + * along in queue snapshots. The generate widget's compositing section + * (`widgets/generate/GenerateCanvasCompositingSection`) reads/writes them, and + * `prepareCanvasInvocation` reads them back — defaulted + clamped — to thread + * into the pure graph compiler. + * + * Defaults mirror the legacy params slice (`features/controlLayers/store/types.ts` + * `getInitialParamsState`): infill `lama`, mask blur 16, coherence Gaussian Blur + * / edge 16 / min-denoise 0. Pure data + a reader; no React, no engine. + */ + +/** The infill methods the outpaint graph can request (legacy `zInfillMethod`). */ +export type CanvasInfillMethod = 'patchmatch' | 'lama' | 'cv2' | 'color' | 'tile'; + +/** The coherence-pass blur modes `create_gradient_mask` accepts (legacy `zParameterCanvasCoherenceMode`). */ +export type CanvasCoherenceMode = 'Gaussian Blur' | 'Box Blur' | 'Staged'; + +/** An 8-bit RGBA color, mirroring legacy `infillColorValue` (a in 0..1). */ +export interface CanvasInfillColor { + r: number; + g: number; + b: number; + a: number; +} + +/** The resolved compositing settings threaded into `compileCanvasGraph`. */ +export interface CanvasCompositingSettings { + infillMethod: CanvasInfillMethod; + infillTileSize: number; + infillPatchmatchDownscaleSize: number; + infillColorValue: CanvasInfillColor; + maskBlur: number; + coherenceMode: CanvasCoherenceMode; + coherenceMinDenoise: number; + coherenceEdgeSize: number; +} + +/** Persisted keys inside the canvas widget's `state.values`. */ +export const CANVAS_COMPOSITING_KEYS = { + coherenceEdgeSize: 'coherenceEdgeSize', + coherenceMinDenoise: 'coherenceMinDenoise', + coherenceMode: 'coherenceMode', + infillColorValue: 'infillColorValue', + infillMethod: 'infillMethod', + infillPatchmatchDownscaleSize: 'infillPatchmatchDownscaleSize', + infillTileSize: 'infillTileSize', + maskBlur: 'maskBlur', +} as const; + +/** Legacy-parity defaults (`getInitialParamsState`). */ +export const DEFAULT_CANVAS_COMPOSITING: CanvasCompositingSettings = { + coherenceEdgeSize: 16, + coherenceMinDenoise: 0, + coherenceMode: 'Gaussian Blur', + infillColorValue: { a: 1, b: 0, g: 0, r: 0 }, + infillMethod: 'lama', + infillPatchmatchDownscaleSize: 1, + infillTileSize: 32, + maskBlur: 16, +}; + +/** Inclusive UI/value bounds for the numeric compositing knobs. */ +export const CANVAS_MASK_BLUR_MAX = 512; +export const CANVAS_COHERENCE_EDGE_SIZE_MAX = 512; + +const INFILL_METHODS: ReadonlySet = new Set(['patchmatch', 'lama', 'cv2', 'color', 'tile']); +const COHERENCE_MODES: ReadonlySet = new Set(['Gaussian Blur', 'Box Blur', 'Staged']); + +export const isCanvasInfillMethod = (value: unknown): value is CanvasInfillMethod => + typeof value === 'string' && INFILL_METHODS.has(value as CanvasInfillMethod); + +export const isCanvasCoherenceMode = (value: unknown): value is CanvasCoherenceMode => + typeof value === 'string' && COHERENCE_MODES.has(value as CanvasCoherenceMode); + +const clampInt = (value: unknown, min: number, max: number, fallback: number): number => + typeof value === 'number' && Number.isFinite(value) ? Math.min(max, Math.max(min, Math.round(value))) : fallback; + +const clampUnit = (value: unknown, fallback: number): number => + typeof value === 'number' && Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : fallback; + +const readInfillColor = (value: unknown): CanvasInfillColor => { + if (value && typeof value === 'object') { + const record = value as Record; + const channel = (key: string, fallback: number) => clampInt(record[key], 0, 255, fallback); + return { + a: clampUnit(record.a, DEFAULT_CANVAS_COMPOSITING.infillColorValue.a), + b: channel('b', DEFAULT_CANVAS_COMPOSITING.infillColorValue.b), + g: channel('g', DEFAULT_CANVAS_COMPOSITING.infillColorValue.g), + r: channel('r', DEFAULT_CANVAS_COMPOSITING.infillColorValue.r), + }; + } + return { ...DEFAULT_CANVAS_COMPOSITING.infillColorValue }; +}; + +/** + * Reads the persisted canvas compositing settings from a widget's `state.values`, + * applying legacy defaults for any missing/invalid field and clamping to valid + * ranges. Always returns a fully-populated settings object. + */ +export const readCanvasCompositingSettings = ( + values: Record | undefined +): CanvasCompositingSettings => { + const v = values ?? {}; + const d = DEFAULT_CANVAS_COMPOSITING; + const coherenceMode = v[CANVAS_COMPOSITING_KEYS.coherenceMode]; + const infillMethod = v[CANVAS_COMPOSITING_KEYS.infillMethod]; + return { + coherenceEdgeSize: clampInt( + v[CANVAS_COMPOSITING_KEYS.coherenceEdgeSize], + 0, + CANVAS_COHERENCE_EDGE_SIZE_MAX, + d.coherenceEdgeSize + ), + coherenceMinDenoise: clampUnit(v[CANVAS_COMPOSITING_KEYS.coherenceMinDenoise], d.coherenceMinDenoise), + coherenceMode: isCanvasCoherenceMode(coherenceMode) ? coherenceMode : d.coherenceMode, + infillColorValue: readInfillColor(v[CANVAS_COMPOSITING_KEYS.infillColorValue]), + infillMethod: isCanvasInfillMethod(infillMethod) ? infillMethod : d.infillMethod, + infillPatchmatchDownscaleSize: clampInt( + v[CANVAS_COMPOSITING_KEYS.infillPatchmatchDownscaleSize], + 1, + 10, + d.infillPatchmatchDownscaleSize + ), + infillTileSize: clampInt(v[CANVAS_COMPOSITING_KEYS.infillTileSize], 16, 256, d.infillTileSize), + maskBlur: clampInt(v[CANVAS_COMPOSITING_KEYS.maskBlur], 0, CANVAS_MASK_BLUR_MAX, d.maskBlur), + }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/canvasStrength.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/canvasStrength.ts new file mode 100644 index 00000000000..b31698c9678 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/canvasStrength.ts @@ -0,0 +1,39 @@ +/** + * Canvas denoising-strength: the single knob a canvas img2img invoke exposes. + * + * The value is persisted in the canvas widget's own state values + * (`widgetInstances['canvas'].state.values.denoisingStrength`) so it survives + * reloads and rides along in queue snapshots, and is read back — with the + * default applied — by the invoke orchestrator. Only consulted for img2img + * (txt2img ignores it), matching the graph compiler. + * + * Pure data + a reader; no React, no engine. Shared by the tool-options UI and + * `prepareCanvasInvocation` so the storage key and clamp stay in one place. + */ + +/** The persisted key inside the canvas widget's `state.values`. */ +export const CANVAS_DENOISING_STRENGTH_KEY = 'denoisingStrength'; + +/** Default strength when unset — a moderate img2img denoise (legacy parity). */ +export const DEFAULT_CANVAS_DENOISING_STRENGTH = 0.75; + +/** Inclusive slider/value bounds. */ +export const MIN_CANVAS_DENOISING_STRENGTH = 0.01; +export const MAX_CANVAS_DENOISING_STRENGTH = 1; + +/** Clamps to `[MIN, MAX]`, snapping a non-finite value to the default. */ +export const clampCanvasDenoisingStrength = (value: number): number => + Number.isFinite(value) + ? Math.min(MAX_CANVAS_DENOISING_STRENGTH, Math.max(MIN_CANVAS_DENOISING_STRENGTH, value)) + : DEFAULT_CANVAS_DENOISING_STRENGTH; + +/** + * Reads the persisted canvas denoising strength from a widget's `state.values`, + * applying the default when unset and clamping to the valid range. + */ +export const readCanvasDenoisingStrength = (values: Record | undefined): number => { + const raw = values?.[CANVAS_DENOISING_STRENGTH_KEY]; + return typeof raw === 'number' && Number.isFinite(raw) + ? clampCanvasDenoisingStrength(raw) + : DEFAULT_CANVAS_DENOISING_STRENGTH; +}; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.test.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.test.ts new file mode 100644 index 00000000000..d95a1f8af6f --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.test.ts @@ -0,0 +1,505 @@ +import type { CanvasImageUploadResult } from '@workbench/canvas-engine/backend/canvasImages'; +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { Rect } from '@workbench/canvas-engine/types'; +import type { + GenerateModelConfig, + GenerateReferenceImage, + GenerateReferenceImageAsset, + MainModelConfig, +} from '@workbench/generation/types'; +import type { + CanvasDocumentContractV2, + CanvasLayerContract, + CanvasRasterLayerContractV2, + WorkbenchState, +} from '@workbench/types'; +import type { WorkbenchAction } from '@workbench/workbenchState'; + +import { createCompositeDedupeCache } from '@workbench/canvas-engine/export/compositeForGeneration'; +import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; +import { getDefaultGenerateSettings } from '@workbench/generation/baseGenerationPolicies'; +import { createInitialWorkbenchState, workbenchReducer } from '@workbench/workbenchState'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { RunCanvasInvocationDeps } from './prepareCanvasInvocation'; + +import { DEFAULT_CANVAS_COMPOSITING } from './canvasCompositing'; +import { resolveRegionalReferenceImages, runCanvasInvocation } from './prepareCanvasInvocation'; + +const sd1Model: MainModelConfig = { base: 'sd-1', key: 'sd1-model', name: 'SD 1.5', type: 'main' }; +const externalModel: GenerateModelConfig = { + base: 'external', + capabilities: { modes: ['txt2img'], supports_seed: true }, + format: 'external_api', + key: 'external-model', + name: 'OpenAI Image', + provider_id: 'openai', + type: 'external_image_generator', +}; + +const generateValuesFor = (model: GenerateModelConfig): Record => ({ + ...getDefaultGenerateSettings(model), + model, + modelKey: model.key, + positivePrompt: 'a canvas prompt', + seed: 7, + shouldRandomizeSeed: false, +}); + +const rasterLayer = (id: string, size = 64): CanvasRasterLayerContractV2 => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + name: id, + opacity: 1, + source: { image: { height: size, imageName: `${id}.png`, width: size }, type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', +}); + +const makeDoc = (layers: CanvasRasterLayerContractV2[], size = 64): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: size, width: size, x: 0, y: 0 }, + height: size, + layers, + selectedLayerId: null, + version: 2, + width: size, +}); + +/** Uniform-alpha ImageData (255 = fully opaque → bboxFullyCovered true). */ +const uniformImageData = (width: number, height: number, alpha: number): ImageData => { + const data = new Uint8ClampedArray(Math.max(1, width) * Math.max(1, height) * 4); + for (let i = 3; i < data.length; i += 4) { + data[i] = alpha; + } + return { colorSpace: 'srgb', data, height, width } as unknown as ImageData; +}; + +interface Harness { + deps: RunCanvasInvocationDeps; + dispatch: ReturnType; + uploadImage: ReturnType; + flushPendingUploads: ReturnType; + events: string[]; + submittedGraphs: () => { destination: string; graph: any }[]; + notices: () => { message?: string }[]; +} + +interface HarnessOptions { + document?: CanvasDocumentContractV2; + destination?: 'canvas' | 'gallery'; + model?: GenerateModelConfig; + strength?: number; + alpha?: number; + inFlight?: Set; + flushPendingUploads?: () => Promise; + uploadImage?: (blob: Blob) => Promise; + projectId?: string; + dispatch?: ReturnType void>>; +} + +const makeHarness = (options: HarnessOptions = {}): Harness => { + const stub = createTestStubRasterBackend(); + const events: string[] = []; + const model = options.model ?? sd1Model; + const alpha = options.alpha ?? 255; + + const backend = { + createSurface: (w: number, h: number): RasterSurface => stub.createSurface(w, h), + encodeSurface: (surface: RasterSurface): Promise => stub.encodeSurface(surface), + }; + + const layerSurfaces = new Map(); + const getLayerSurface = (layerId: string): Promise<{ surface: RasterSurface; rect: Rect }> => { + events.push('getLayerSurface'); + let surface = layerSurfaces.get(layerId); + if (!surface) { + surface = stub.createSurface(64, 64); + layerSurfaces.set(layerId, surface); + } + // Content-sized: the layer's cache occupies its content rect (origin-anchored + // for these 64×64 image layers). + return Promise.resolve({ rect: { height: 64, width: 64, x: 0, y: 0 }, surface }); + }; + + let counter = 0; + const uploadImage = vi.fn( + options.uploadImage ?? + ((blob: Blob): Promise => { + void blob; + events.push('upload'); + counter += 1; + return Promise.resolve({ height: 64, imageName: `composite-${counter}.png`, width: 64 }); + }) + ); + + const flushPendingUploads = vi.fn( + options.flushPendingUploads ?? + ((): Promise => + new Promise((resolve) => { + // Resolve on a microtask so a missing `await` would let compositing + // start first — the ordering assertion depends on this. + queueMicrotask(() => { + events.push('flush'); + resolve(); + }); + })) + ); + + const dispatch = options.dispatch ?? vi.fn((_action: WorkbenchAction) => {}); + + const deps: RunCanvasInvocationDeps = { + compositing: DEFAULT_CANVAS_COMPOSITING, + dedupe: createCompositeDedupeCache(), + destination: options.destination ?? 'canvas', + dispatch, + executorDeps: { + backend, + getLayerSurface, + hashBlob: (blob: Blob) => blob.text(), + readImageData: (_surface, rect) => uniformImageData(rect.width, rect.height, alpha), + uploadImage: uploadImage as (blob: Blob) => Promise, + }, + flushPendingUploads: flushPendingUploads as () => Promise, + generateValues: generateValuesFor(model), + getDocument: () => options.document ?? makeDoc([rasterLayer('layer-a')]), + inFlight: options.inFlight ?? new Set(), + projectId: options.projectId ?? 'project-1', + projectSettings: { useCpuNoise: true }, + strength: options.strength ?? 0.75, + }; + + const submittedGraphs = () => + dispatch.mock.calls.map(([action]) => action).filter((action) => action.type === 'submitCanvasInvocationSnapshot'); + const notices = () => + dispatch.mock.calls.map(([action]) => action).filter((action) => action.type === 'recordNotice'); + + return { deps, dispatch, events, flushPendingUploads, notices, submittedGraphs, uploadImage }; +}; + +describe('runCanvasInvocation', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('generates txt2img without any composite upload when content does not overlap the bbox', async () => { + // Empty document → no raster content → txt2img, no executor work. + const harness = makeHarness({ document: makeDoc([]) }); + + await runCanvasInvocation(harness.deps); + + expect(harness.uploadImage).not.toHaveBeenCalled(); + expect(harness.events).not.toContain('getLayerSurface'); + + const submitted = harness.submittedGraphs(); + expect(submitted).toHaveLength(1); + expect(submitted[0]?.graph.label).toBe('SD 1.5 txt2img'); + // Pure txt2img graph has no image-to-latents encode node. + expect(submitted[0]?.graph.backendGraph.nodes.canvas_i2l).toBeUndefined(); + expect(harness.notices()).toHaveLength(0); + }); + + it('generates img2img with the composite reference and the strength-derived denoising_start', async () => { + const harness = makeHarness({ strength: 0.75 }); + + await runCanvasInvocation(harness.deps); + + expect(harness.uploadImage).toHaveBeenCalledTimes(1); + + const submitted = harness.submittedGraphs(); + expect(submitted).toHaveLength(1); + expect(submitted[0]?.graph.label).toBe('SD 1.5 img2img'); + + const nodes = submitted[0]?.graph.backendGraph.nodes; + const encode = Object.values(nodes).find((node: any) => node.type === 'i2l') as any; + expect(encode?.image).toEqual({ image_name: 'composite-1.png' }); + // sd-1 is linear: denoising_start = 1 - 0.75. + expect(nodes.denoise_latents.denoising_start).toBeCloseTo(0.25, 10); + expect(harness.notices()).toHaveLength(0); + }); + + it('threads a Gallery destination through to the snapshot and a durable (non-intermediate) output', async () => { + // Empty document → txt2img, so no composite upload noise; we only care about + // the destination + is_intermediate wiring. + const harness = makeHarness({ destination: 'gallery', document: makeDoc([]) }); + + await runCanvasInvocation(harness.deps); + + const submitted = harness.submittedGraphs(); + expect(submitted).toHaveLength(1); + expect(submitted[0]?.destination).toBe('gallery'); + // A Gallery destination must produce a durable image, not a staging intermediate. + expect(submitted[0]?.graph.backendGraph.nodes.canvas_output.is_intermediate).toBe(false); + }); + + it('marks the output intermediate for a Canvas destination (staging)', async () => { + const harness = makeHarness({ destination: 'canvas', document: makeDoc([]) }); + + await runCanvasInvocation(harness.deps); + + const submitted = harness.submittedGraphs(); + expect(submitted).toHaveLength(1); + expect(submitted[0]?.destination).toBe('canvas'); + expect(submitted[0]?.graph.backendGraph.nodes.canvas_output.is_intermediate).toBe(true); + }); + + it('awaits the upload flush before compositing', async () => { + const harness = makeHarness(); + + await runCanvasInvocation(harness.deps); + + // Flush must settle before any layer is rasterized/uploaded. + expect(harness.events[0]).toBe('flush'); + expect(harness.events.indexOf('flush')).toBeLessThan(harness.events.indexOf('getLayerSurface')); + expect(harness.events.indexOf('flush')).toBeLessThan(harness.events.indexOf('upload')); + }); + + it('plans from the POST-flush document (re-reads getDocument after the flush barrier)', async () => { + // The flush dispatches `updateCanvasLayerSource` for just-persisted paint + // layers, so the pre-flush snapshot references stale/empty sources. The + // orchestrator must re-read the document after the flush and composite from + // it — otherwise the stale refs build wrong dedupe keys and the empty-paint + // filter reads a `null` bitmap that the flush already replaced. + const preDoc = makeDoc([rasterLayer('pre')]); + const postDoc = makeDoc([rasterLayer('post')]); + let flushed = false; + const harness = makeHarness({ + flushPendingUploads: () => + new Promise((resolve) => { + queueMicrotask(() => { + flushed = true; + resolve(); + }); + }), + }); + // Return the stale doc until the flush resolves, the fresh doc afterwards. + harness.deps.getDocument = () => (flushed ? postDoc : preDoc); + const surfaceIds: string[] = []; + const inner = harness.deps.executorDeps.getLayerSurface; + harness.deps.executorDeps.getLayerSurface = (layerId: string) => { + surfaceIds.push(layerId); + return inner(layerId); + }; + + await runCanvasInvocation(harness.deps); + + // The composite rasterized the post-flush layer, never the stale pre-flush one. + expect(surfaceIds).toContain('post'); + expect(surfaceIds).not.toContain('pre'); + }); + + it('records a notice and dispatches no snapshot when the graph fails validation', async () => { + // External image generators are rejected by the graph compiler. + const harness = makeHarness({ document: makeDoc([]), model: externalModel }); + + await runCanvasInvocation(harness.deps); + + expect(harness.submittedGraphs()).toHaveLength(0); + const notices = harness.notices(); + expect(notices).toHaveLength(1); + expect(notices[0]?.message).toContain('does not support canvas generation'); + }); + + it('records a notice and dispatches no snapshot when the composite upload fails', async () => { + const uploadImage = vi.fn(() => Promise.reject(new Error('upload exploded'))); + const harness = makeHarness({ uploadImage }); + + await runCanvasInvocation(harness.deps); + + expect(harness.submittedGraphs()).toHaveLength(0); + const notices = harness.notices(); + expect(notices).toHaveLength(1); + expect(notices[0]?.message).toBe('upload exploded'); + }); + + it('ignores a concurrent invoke while a prior prepare for the same project is in flight', async () => { + const inFlight = new Set(); + let releaseFlush = (): void => {}; + const gatedFlush = vi.fn( + () => + new Promise((resolve) => { + releaseFlush = resolve; + }) + ); + + const first = makeHarness({ flushPendingUploads: gatedFlush, inFlight }); + // Kick off the first invoke; it parks on the gated flush. + const firstRun = runCanvasInvocation(first.deps); + await Promise.resolve(); + + // A second invoke for the same project (shares the in-flight set) is dropped. + const second = makeHarness({ inFlight }); + await runCanvasInvocation(second.deps); + + expect(second.submittedGraphs()).toHaveLength(0); + expect(second.notices()).toHaveLength(0); + expect(second.uploadImage).not.toHaveBeenCalled(); + + // Let the first invoke finish; it still submits exactly once. + releaseFlush(); + await firstRun; + expect(first.submittedGraphs()).toHaveLength(1); + }); + + it('enqueues into the originating project, not the active one, when the active project changes mid-flight', async () => { + // Two real projects, driven through the real reducer, so we can tell + // "landed in project A" apart from "landed in project B". + let state: WorkbenchState = createInitialWorkbenchState(); + const originatingProjectId = state.activeProjectId; + state = workbenchReducer(state, { type: 'createProject' }); + const otherProjectId = state.activeProjectId; + expect(otherProjectId).not.toBe(originatingProjectId); + + let releaseFlush = (): void => {}; + const gatedFlush = vi.fn( + () => + new Promise((resolve) => { + releaseFlush = resolve; + }) + ); + const dispatch = vi.fn((action: WorkbenchAction) => { + state = workbenchReducer(state, action); + }); + + // The invoke is prepared while `originatingProjectId` is active... + const harness = makeHarness({ dispatch, flushPendingUploads: gatedFlush, projectId: originatingProjectId }); + const invokePromise = runCanvasInvocation(harness.deps); + await Promise.resolve(); + + // ...but by the time the flush/composite/compile settles, the user has + // switched to another project. + expect(state.activeProjectId).toBe(otherProjectId); + + releaseFlush(); + await invokePromise; + + const originatingProject = state.projects.find((project) => project.id === originatingProjectId); + const otherProject = state.projects.find((project) => project.id === otherProjectId); + + expect(originatingProject?.queue.items).toHaveLength(1); + expect(originatingProject?.queue.items[0]?.snapshot.sourceId).toBe('canvas'); + expect(otherProject?.queue.items).toHaveLength(0); + }); +}); + +// ---- Inpaint / outpaint mode dispatch ------------------------------------- + +const inpaintMaskLayer = (id: string): CanvasLayerContract => ({ + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + mask: { bitmap: { height: 64, imageName: `${id}-bmp`, width: 64 }, fill: { color: '#ff0000', style: 'solid' } }, + name: id, + opacity: 1, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'inpaint_mask', +}); + +const docWithLayers = (layers: CanvasLayerContract[], size = 64): CanvasDocumentContractV2 => ({ + background: 'transparent', + bbox: { height: size, width: size, x: 0, y: 0 }, + height: size, + layers, + selectedLayerId: null, + version: 2, + width: size, +}); + +describe('runCanvasInvocation — inpaint / outpaint dispatch', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('dispatches an inpaint graph when content covers the bbox and a mask has content', async () => { + const doc = docWithLayers([rasterLayer('base'), inpaintMaskLayer('mask')]); + const harness = makeHarness({ document: doc, alpha: 255 }); + + await runCanvasInvocation(harness.deps); + + const submitted = harness.submittedGraphs(); + expect(submitted).toHaveLength(1); + expect(submitted[0]?.graph.label).toBe('SD 1.5 inpaint'); + const nodes = submitted[0]?.graph.backendGraph.nodes; + expect(nodes.create_gradient_mask?.type).toBe('create_gradient_mask'); + expect(nodes.canvas_output?.type).toBe('invokeai_img_blend'); + // The gradient mask consumes the uploaded grayscale mask (base upload is composite-1). + expect(nodes.create_gradient_mask?.mask?.image_name).toBeDefined(); + expect(harness.notices()).toHaveLength(0); + // The mask layer was rasterized for the grayscale composite. + expect(harness.events.filter((e) => e === 'getLayerSurface')).not.toHaveLength(0); + }); + + it('dispatches an outpaint graph when content only partially covers the bbox', async () => { + // Partial alpha → bboxFullyCovered false → outpaint. + const harness = makeHarness({ document: makeDoc([rasterLayer('base')]), alpha: 200 }); + + await runCanvasInvocation(harness.deps); + + const submitted = harness.submittedGraphs(); + expect(submitted).toHaveLength(1); + expect(submitted[0]?.graph.label).toBe('SD 1.5 outpaint'); + const nodes = submitted[0]?.graph.backendGraph.nodes; + expect(nodes.infill?.type).toBe('infill_lama'); + expect(nodes.image_alpha_to_mask?.type).toBe('tomask'); + expect(nodes.canvas_output?.type).toBe('invokeai_img_blend'); + expect(harness.notices()).toHaveLength(0); + }); +}); + +// Task 39, finding 1: a regional reference image is only usable once an image is +// assigned (the settings UI now wires drop/upload → config.image). This guards +// the resolver seam: refs without an image are dropped; refs with an image + a +// compatible model survive into the graph inputs. +describe('resolveRegionalReferenceImages', () => { + const asset = { imageName: 'ref.png' } as GenerateReferenceImageAsset; + const ipAdapterModel = { base: 'sd-1', key: 'ipa', name: 'IP Adapter', type: 'ip_adapter' }; + const fluxReduxModel = { base: 'flux', key: 'redux', name: 'FLUX Redux', type: 'flux_redux' }; + + const ipAdapterRef = (image: GenerateReferenceImageAsset | null): GenerateReferenceImage => ({ + config: { + beginEndStepPct: [0, 1], + clipVisionModel: 'ViT-H', + image, + method: 'full', + model: ipAdapterModel, + type: 'ip_adapter', + weight: 1, + }, + id: 'ref-ipa', + isEnabled: true, + }); + + const fluxReduxRef = (image: GenerateReferenceImageAsset | null): GenerateReferenceImage => ({ + config: { image, imageInfluence: 'highest', model: fluxReduxModel, type: 'flux_redux' }, + id: 'ref-redux', + isEnabled: true, + }); + + it('drops an IP-Adapter ref with no image assigned', () => { + expect(resolveRegionalReferenceImages({ referenceImages: [ipAdapterRef(null)] }, 'sd-1')).toEqual([]); + }); + + it('keeps an IP-Adapter ref once an image is assigned (non-FLUX base)', () => { + const inputs = resolveRegionalReferenceImages({ referenceImages: [ipAdapterRef(asset)] }, 'sd-1'); + expect(inputs).toHaveLength(1); + expect(inputs[0]).toMatchObject({ id: 'ref-ipa', imageName: 'ref.png', type: 'ip_adapter' }); + }); + + it('drops a FLUX Redux ref with no image assigned', () => { + expect(resolveRegionalReferenceImages({ referenceImages: [fluxReduxRef(null)] }, 'flux')).toEqual([]); + }); + + it('keeps a FLUX Redux ref once an image is assigned (FLUX base)', () => { + const inputs = resolveRegionalReferenceImages({ referenceImages: [fluxReduxRef(asset)] }, 'flux'); + expect(inputs).toHaveLength(1); + expect(inputs[0]).toMatchObject({ id: 'ref-redux', imageName: 'ref.png', type: 'flux_redux' }); + }); + + it('drops a disabled ref even when an image is assigned', () => { + const disabled = { ...ipAdapterRef(asset), isEnabled: false }; + expect(resolveRegionalReferenceImages({ referenceImages: [disabled] }, 'sd-1')).toEqual([]); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.ts new file mode 100644 index 00000000000..fc263e510db --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.ts @@ -0,0 +1,526 @@ +/** + * Wires the canvas-generation pipeline into the Invoke button. + * + * `prepareCanvasInvocation` is the thin command-layer entry: it resolves the + * active project's engine, scopes a per-engine dedupe cache, and delegates to + * the React-free {@link runCanvasInvocation} orchestrator. The orchestrator runs + * the whole flush → composite → mode-detect → compile → enqueue flow with every + * side-effecting dependency injected, so it is fully node-testable with fakes. + * + * ## Flow (per the plan) + * 1. Resolve the engine + its live document (no engine/document → notice, abort). + * 2. `flushPendingUploads()` — the paint-bitmap persistence barrier — so the + * composite reads the latest painted pixels. + * 3. Plan the composites, then run a **bounds-only pre-pass**: if enabled raster + * content does not overlap the bbox, the mode is txt2img regardless of + * coverage, so we skip the composite/encode/upload entirely (no useless base + * image). Only when content overlaps do we run the executor (which the + * image-referencing modes need) and detect img2img/outpaint from coverage. + * 4. Compile the base-appropriate graph and dispatch it to canvas staging. + * 5. Any failure (validation throw, upload error, unsupported mode) records a + * notice — the app never gets stuck. + * + * A module-scoped in-flight guard drops an invoke while a prior prepare for the + * same project is still running (the Invoke hotkey can be mashed). + */ + +import type { CanvasEngine } from '@workbench/canvas-engine/engine'; +import type { + CompositeDedupeCache, + ExecuteCompositePlanDeps, +} from '@workbench/canvas-engine/export/compositeForGeneration'; +import type { ControlLayerGraphInput } from '@workbench/generation/canvas/addControlLayers'; +import type { + RegionalGuidanceInput, + RegionalReferenceImageInput, +} from '@workbench/generation/canvas/addRegionalGuidance'; +import type { CanvasCompileMode } from '@workbench/generation/canvas/types'; +import type { GenerateModelConfig, GenerateReferenceImage } from '@workbench/generation/types'; +import type { ModelConfig } from '@workbench/models/types'; +import type { + CanvasDocumentContractV2, + ProjectSettings, + ResultDestination, + WorkbenchNotificationKind, +} from '@workbench/types'; +import type { CanvasCompositingSettings } from '@workbench/widgets/canvas/invoke/canvasCompositing'; +import type { WorkbenchAction } from '@workbench/workbenchState'; + +import { getEngine } from '@workbench/canvas-engine/engineRegistry'; +import { + computeCompositeContentBounds, + executeCompositePlan, + executeControlComposite, + executeMaskComposite, + executeRegionalMaskComposite, +} from '@workbench/canvas-engine/export/compositeForGeneration'; +import { getControlLayerRejectionReason } from '@workbench/generation/canvas/addControlLayers'; +import { + getRegionalGuidanceRejectionReason, + isRegionalGuidanceSupportedForBase, +} from '@workbench/generation/canvas/addRegionalGuidance'; +import { detectCanvasMode, rectsIntersect } from '@workbench/generation/canvas/canvasMode'; +import { compileCanvasGraph } from '@workbench/generation/canvas/compileCanvasGraph'; +import { + planComposites, + planControlComposites, + planRegionalMaskComposites, +} from '@workbench/generation/canvas/compositePlan'; +import { resolveGenerateSeed } from '@workbench/generation/graph'; +import { normalizeGenerateWidgetValues, syncGenerateWidgetValuesWithModels } from '@workbench/generation/settings'; +import { DEFAULT_CANVAS_COMPOSITING } from '@workbench/widgets/canvas/invoke/canvasCompositing'; + +/** Title on every canvas-invoke failure notice. */ +export const CANVAS_INVOKE_ERROR_TITLE = 'Canvas generation failed'; + +/** Engine-backed executor deps, minus the caller-owned dedupe cache. */ +type CompositeExecutorDeps = Omit; + +/** Injected dependencies for the React-free orchestrator. */ +export interface RunCanvasInvocationDeps { + /** The active project id (also the in-flight guard key). */ + projectId: string; + /** + * The resolved result destination. Drives the compiled output node's + * `is_intermediate` flag (`destination === 'canvas'`) and the enqueued + * snapshot's destination, so a Canvas source can target the Gallery. + */ + destination: ResultDestination; + /** The live mirrored canvas document (`document.bbox` is the generation frame). */ + getDocument: () => CanvasDocumentContractV2 | null; + /** Paint-bitmap persistence barrier, awaited before compositing. */ + flushPendingUploads: () => Promise; + /** Engine-backed composite executor deps (backend + rasterize-or-throw + uploader). */ + executorDeps: CompositeExecutorDeps; + /** Per-engine dedupe cache (caller-owned; persists across invokes, size-capped). */ + dedupe: CompositeDedupeCache; + /** Project ids with a prepare currently in flight (module/registry-scoped). */ + inFlight: Set; + /** The generate widget's raw persisted values (model/prompt/steps, shared with Generate). */ + generateValues: Record; + /** Loaded models, for the same value/model sync the generate path performs. */ + models?: readonly ModelConfig[]; + /** Project settings (only `useCpuNoise` is consulted by the compiler). */ + projectSettings: Pick; + /** Persisted denoising strength (already defaulted + clamped). Used for every image mode. */ + strength: number; + /** Persisted compositing settings (infill / coherence / mask blur), defaulted + clamped. */ + compositing: CanvasCompositingSettings; + /** Reducer dispatch. */ + dispatch: (action: WorkbenchAction) => void; +} + +const recordNotice = ( + dispatch: (action: WorkbenchAction) => void, + kind: WorkbenchNotificationKind, + message: string +): void => { + dispatch({ kind, message, title: CANVAS_INVOKE_ERROR_TITLE, type: 'recordNotice' }); +}; + +/** + * Composites + resolves the enabled control layers into graph inputs. Each + * content-bearing control layer is composited SEPARATELY (never blended) and its + * adapter model resolved from the loaded models; layers that are invalid for the + * selected base (unsupported base/kind, no/incompatible model) are skipped + * silently, mirroring legacy (their warning surfaces in the layer's settings, + * they never block generation). Content-less control layers are already excluded + * by `planControlComposites`. + */ +const collectControlLayerInputs = async ( + document: CanvasDocumentContractV2, + bbox: { x: number; y: number; width: number; height: number }, + model: GenerateModelConfig, + models: readonly ModelConfig[] | undefined, + executorDeps: ExecuteCompositePlanDeps +): Promise => { + const composites = planControlComposites(document, bbox); + if (composites.length === 0) { + return []; + } + + const layerById = new Map(document.layers.map((layer) => [layer.id, layer])); + const inputs: ControlLayerGraphInput[] = []; + + for (const { entry, layerId } of composites) { + const layer = layerById.get(layerId); + if (!layer || layer.type !== 'control') { + continue; + } + + const { adapter } = layer; + const resolved = adapter.model ? models?.find((candidate) => candidate.key === adapter.model) : undefined; + const rejection = getControlLayerRejectionReason({ + adapterModel: resolved ? { base: resolved.base } : null, + hasContent: true, + kind: adapter.kind, + layerName: layer.name, + mainBase: model.base, + mainVariant: model.variant ?? undefined, + }); + if (rejection || !resolved) { + continue; + } + + const result = await executeControlComposite(entry, executorDeps); + inputs.push({ + beginEndStepPct: adapter.beginEndStepPct, + controlMode: adapter.controlMode, + id: layerId, + imageName: result.imageName, + kind: adapter.kind, + model: { + base: resolved.base, + key: resolved.key, + name: resolved.name, + type: resolved.type, + ...(typeof resolved.hash === 'string' ? { hash: resolved.hash } : {}), + }, + weight: adapter.weight, + }); + } + + return inputs; +}; + +/** FLUX Redux image-influence → backend redux settings (mirrors `graph.ts` FLUX_REDUX_INFLUENCE). */ +const FLUX_REDUX_INFLUENCE_SETTINGS = { + lowest: { downsampling_factor: 5, weight: 1 }, + low: { downsampling_factor: 4, weight: 1 }, + medium: { downsampling_factor: 3, weight: 1 }, + high: { downsampling_factor: 2, weight: 1 }, + highest: { downsampling_factor: 1, weight: 1 }, +} as const; + +/** Resolves a regional-guidance layer's reference images into graph inputs (drops incomplete/incompatible ones). */ +export const resolveRegionalReferenceImages = ( + region: { referenceImages: GenerateReferenceImage[] }, + base: string +): RegionalReferenceImageInput[] => { + const inputs: RegionalReferenceImageInput[] = []; + for (const ref of region.referenceImages) { + if (!ref.isEnabled) { + continue; + } + const { config } = ref; + if (config.type === 'ip_adapter' && base !== 'flux') { + if (!config.image || !config.model || config.model.base !== base) { + continue; + } + inputs.push({ + beginEndStepPct: config.beginEndStepPct, + clipVisionModel: config.clipVisionModel, + id: ref.id, + imageName: config.image.imageName, + method: config.method, + model: { + base: config.model.base, + key: config.model.key, + name: config.model.name, + type: config.model.type, + ...(typeof config.model.hash === 'string' ? { hash: config.model.hash } : {}), + }, + type: 'ip_adapter', + weight: config.weight, + }); + } else if (config.type === 'flux_redux' && base === 'flux') { + if (!config.image || !config.model || config.model.base !== base) { + continue; + } + inputs.push({ + id: ref.id, + imageName: config.image.imageName, + model: { + base: config.model.base, + key: config.model.key, + name: config.model.name, + type: config.model.type, + ...(typeof config.model.hash === 'string' ? { hash: config.model.hash } : {}), + }, + settings: FLUX_REDUX_INFLUENCE_SETTINGS[config.imageInfluence], + type: 'flux_redux', + }); + } + } + return inputs; +}; + +/** + * Composites + resolves the enabled regional-guidance regions into graph inputs. + * Each region with mask content is composited SEPARATELY (its alpha feeds its own + * `alpha_mask_to_tensor`); regions invalid for the base (unsupported base, empty, + * FLUX + negative/autoNegative) are skipped silently, mirroring legacy. Regions + * whose prompts + reference images are all empty are also skipped. + */ +const collectRegionalGuidanceInputs = async ( + document: CanvasDocumentContractV2, + bbox: { x: number; y: number; width: number; height: number }, + model: GenerateModelConfig, + executorDeps: ExecuteCompositePlanDeps +): Promise => { + if (!isRegionalGuidanceSupportedForBase(model.base)) { + return []; + } + const composites = planRegionalMaskComposites(document, bbox); + if (composites.length === 0) { + return []; + } + + const layerById = new Map(document.layers.map((layer) => [layer.id, layer])); + const inputs: RegionalGuidanceInput[] = []; + + for (const { entry, layerId } of composites) { + const layer = layerById.get(layerId); + if (!layer || layer.type !== 'regional_guidance') { + continue; + } + + const referenceImages = resolveRegionalReferenceImages(layer, model.base); + const rejection = getRegionalGuidanceRejectionReason({ + autoNegative: layer.autoNegative, + hasContent: true, + layerName: layer.name, + mainBase: model.base, + negativePrompt: layer.negativePrompt, + positivePrompt: layer.positivePrompt, + referenceImageCount: referenceImages.length, + }); + if (rejection) { + continue; + } + + const result = await executeRegionalMaskComposite(entry, executorDeps); + inputs.push({ + autoNegative: layer.autoNegative, + id: layerId, + maskImageName: result.imageName, + negativePrompt: layer.negativePrompt, + positivePrompt: layer.positivePrompt, + referenceImages, + }); + } + + return inputs; +}; + +/** + * Runs the canvas-invoke pipeline with injected dependencies. Never throws: + * every failure is turned into a notice, and the in-flight guard is always + * cleared. Returns when the graph has been dispatched (or the invoke aborted). + */ +export const runCanvasInvocation = async (deps: RunCanvasInvocationDeps): Promise => { + const { dispatch, inFlight, projectId } = deps; + + // Concurrency guard: ignore a re-invoke while a prior prepare for this project + // is still settling (the hotkey can be mashed). Cleared in `finally`. + if (inFlight.has(projectId)) { + return; + } + inFlight.add(projectId); + + try { + const document = deps.getDocument(); + if (!document) { + recordNotice(dispatch, 'error', 'The canvas has no active document to generate from.'); + return; + } + + const values = normalizeGenerateWidgetValues(deps.generateValues); + if (!values) { + recordNotice(dispatch, 'error', 'Select a supported model before invoking the canvas.'); + return; + } + + // Resolve model + settings exactly as the generate path does (sync to loaded + // models, then resolve the seed) so canvas and generate stay in lockstep. + const synced = deps.models ? syncGenerateWidgetValuesWithModels(values, deps.models) : values; + const settings = { ...synced, seed: resolveGenerateSeed(synced) }; + const model: GenerateModelConfig = settings.model; + + // Persist any pending paint bitmaps BEFORE compositing from their pixels. + await deps.flushPendingUploads(); + + // Re-read the document AFTER the flush barrier: the flush dispatches + // `updateCanvasLayerSource` for each just-persisted paint layer, so the + // pre-flush snapshot still references `paint:empty`/stale bitmap names. Plan, + // composite, and compile from the post-flush document so the dedupe keys are + // built from the persisted refs (silent cache misses otherwise) and so the + // empty-paint-layer filter (see `planComposites`) sees the real `bitmap`. + // The bbox can also move during a slow flush; take it from the fresh snapshot. + const postFlushDocument = deps.getDocument(); + if (!postFlushDocument) { + recordNotice(dispatch, 'error', 'The canvas has no active document to generate from.'); + return; + } + const bbox = postFlushDocument.bbox; + + const plan = planComposites(postFlushDocument, bbox); + // Bounds-only pre-pass (pure geometry, no upload): content that does not + // overlap the bbox is txt2img no matter the coverage. Also naturally skips a + // zero-area bbox (rectsIntersect is false), which validation rejects anyway. + const contentBounds = computeCompositeContentBounds(plan); + + const executorDeps = { ...deps.executorDeps, dedupe: deps.dedupe }; + + let mode: CanvasCompileMode = 'txt2img'; + let compositeImageName: string | null = null; + let maskImageName: string | null = null; + let noiseMaskImageName: string | null = null; + + if (contentBounds && rectsIntersect(contentBounds, bbox)) { + const result = await executeCompositePlan(plan, executorDeps); + compositeImageName = result.base.imageName; + + // The grayscale denoise-limit mask (when enabled inpaint masks exist) both + // decides inpaint-vs-img2img (its coverage) and feeds the graph. Legacy + // parity: raster opaque + mask has content → inpaint; else img2img. + const maskEntry = plan.entries.find((entry) => entry.kind === 'inpaint-mask'); + const maskResult = maskEntry ? await executeMaskComposite(maskEntry, executorDeps) : null; + + const detected = detectCanvasMode({ + bbox, + bboxFullyCovered: result.bboxFullyCovered, + contentBounds: result.contentBounds, + hasActiveInpaintMask: maskResult?.hasContent ?? false, + }); + mode = detected; + + if (detected === 'inpaint' || detected === 'outpaint') { + // A real (non-white) mask feeds create_gradient_mask; outpaint without one + // derives its mask from the raster alpha (maskImageName stays null). + maskImageName = maskResult?.hasContent ? maskResult.imageName : null; + const noiseEntry = plan.entries.find((entry) => entry.kind === 'noise-mask'); + if (noiseEntry) { + noiseMaskImageName = (await executeMaskComposite(noiseEntry, executorDeps)).imageName; + } + } + } + + // Control layers apply in every mode, independent of the base composite — + // composite + resolve them regardless of whether raster content overlaps. + const controlLayers = await collectControlLayerInputs(postFlushDocument, bbox, model, deps.models, executorDeps); + + // Regional guidance also applies in every mode, independent of the base + // composite — composite + resolve each region regardless of raster overlap. + const regionalGuidance = await collectRegionalGuidanceInputs(postFlushDocument, bbox, model, executorDeps); + + const compiled = compileCanvasGraph({ + bbox, + compositeImageName, + compositing: deps.compositing, + controlLayers, + destination: deps.destination, + maskImageName, + mode, + model, + noiseMaskImageName, + projectSettings: deps.projectSettings, + regionalGuidance, + settings, + strength: deps.strength, + }); + + dispatch({ + backendSupportsCancellation: true, + destination: deps.destination, + graph: compiled.graph, + projectId, + type: 'submitCanvasInvocationSnapshot', + }); + } catch (error) { + recordNotice(dispatch, 'error', error instanceof Error ? error.message : String(error)); + } finally { + inFlight.delete(projectId); + } +}; + +// ---- Command-layer wiring (per-engine caches + in-flight guard) ------------- + +/** The dedupe cache's per-map entry cap (bounds unbounded growth across invokes). */ +const DEDUPE_CACHE_CAPACITY = 16; + +/** + * A {@link Map} that evicts its oldest (insertion-order) entry once it exceeds + * `capacity`, so a long-lived dedupe cache can't grow without bound across many + * invokes with distinct plans/pixels. + */ +class BoundedMap extends Map { + constructor(private readonly capacity: number) { + super(); + } + + override set(key: K, value: V): this { + if (!this.has(key) && this.size >= this.capacity) { + const oldest = this.keys().next().value; + if (oldest !== undefined) { + this.delete(oldest); + } + } + return super.set(key, value); + } +} + +/** A size-capped {@link CompositeDedupeCache}. */ +export const createBoundedCompositeDedupeCache = (capacity = DEDUPE_CACHE_CAPACITY): CompositeDedupeCache => ({ + byHash: new BoundedMap(capacity), + byKey: new BoundedMap(capacity), +}); + +// Project ids with a prepare in flight (survives across the async orchestrator). +const inFlightProjects = new Set(); +// Per-engine dedupe caches, dropped when the engine is disposed and GC'd. +const dedupeCachesByEngine = new WeakMap(); + +const getEngineDedupeCache = (engine: CanvasEngine): CompositeDedupeCache => { + let cache = dedupeCachesByEngine.get(engine); + if (!cache) { + cache = createBoundedCompositeDedupeCache(); + dedupeCachesByEngine.set(engine, cache); + } + return cache; +}; + +/** Arguments for the thin command-layer {@link prepareCanvasInvocation}. */ +export interface PrepareCanvasInvocationArgs { + projectId: string; + /** The resolved result destination (Canvas staging vs. a durable Gallery image). */ + destination: ResultDestination; + generateValues: Record; + models?: readonly ModelConfig[]; + projectSettings: Pick; + strength: number; + /** + * Persisted compositing settings, already defaulted + clamped by the caller + * (`readCanvasCompositingSettings`). Falls back to legacy defaults when omitted. + */ + compositing?: CanvasCompositingSettings; + dispatch: (action: WorkbenchAction) => void; +} + +/** + * Resolves the active project's engine and runs the canvas-invoke orchestrator. + * Fire-and-track: the caller does not await it (the Invoke command stays sync). + */ +export const prepareCanvasInvocation = async (args: PrepareCanvasInvocationArgs): Promise => { + const engine = getEngine(args.projectId); + if (!engine) { + recordNotice(args.dispatch, 'error', 'Open the canvas before invoking it.'); + return; + } + + await runCanvasInvocation({ + compositing: args.compositing ?? DEFAULT_CANVAS_COMPOSITING, + dedupe: getEngineDedupeCache(engine), + destination: args.destination, + dispatch: args.dispatch, + executorDeps: engine.getCompositeExecutorDeps(), + flushPendingUploads: () => engine.flushPendingUploads(), + generateValues: args.generateValues, + getDocument: () => engine.getDocument(), + inFlight: inFlightProjects, + models: args.models, + projectId: args.projectId, + projectSettings: args.projectSettings, + strength: args.strength, + }); +}; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/stagingPreview.test.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/stagingPreview.test.ts new file mode 100644 index 00000000000..342d527718a --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/stagingPreview.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; + +import { selectCanvasProgressImage, selectStagedPreviewSource, stagedPreviewKey } from './stagingPreview'; + +const base = { + bboxHeight: 512, + bboxWidth: 512, + isGenerationInFlight: false, + isVisible: true, + progressImage: null, + selectedImageName: null, +} as const; + +describe('selectStagedPreviewSource', () => { + it('returns null when nothing is staged and not generating', () => { + expect(selectStagedPreviewSource(base)).toBeNull(); + }); + + it('returns the selected candidate when visible and present', () => { + expect(selectStagedPreviewSource({ ...base, selectedImageName: 'img-1' })).toEqual({ imageName: 'img-1' }); + }); + + it('returns null for a selected candidate when the preview is hidden', () => { + expect(selectStagedPreviewSource({ ...base, isVisible: false, selectedImageName: 'img-1' })).toBeNull(); + }); + + it('prefers the live progress frame while generating, scaled to fill the bbox', () => { + const source = selectStagedPreviewSource({ + ...base, + isGenerationInFlight: true, + progressImage: { dataUrl: 'data:image/png;base64,AAAA', height: 64, width: 64 }, + selectedImageName: 'img-1', + }); + // Fills the bbox dims (512x512), not the progress frame's native 64x64. + expect(source).toEqual({ dataUrl: 'data:image/png;base64,AAAA', height: 512, width: 512 }); + }); + + it('falls back to the candidate once generation settles (no progress frame)', () => { + expect( + selectStagedPreviewSource({ + ...base, + isGenerationInFlight: true, + progressImage: null, + selectedImageName: 'img-1', + }) + ).toEqual({ imageName: 'img-1' }); + }); + + it('ignores a progress frame when the bbox has no area', () => { + expect( + selectStagedPreviewSource({ + ...base, + bboxHeight: 0, + bboxWidth: 0, + isGenerationInFlight: true, + progressImage: { dataUrl: 'data:image/png;base64,AAAA', height: 64, width: 64 }, + }) + ).toBeNull(); + }); +}); + +describe('selectCanvasProgressImage', () => { + const frame = (queueItemId: string) => ({ + dataUrl: 'data:image/png;base64,AAAA', + height: 64, + target: { itemIndex: 1, queueItemId }, + width: 64, + }); + + it('returns the frame when its queue item belongs to this canvas', () => { + const result = selectCanvasProgressImage(frame('q-canvas'), new Set(['q-canvas'])); + expect(result).toEqual({ dataUrl: 'data:image/png;base64,AAAA', height: 64, width: 64 }); + }); + + it('ignores a frame from a foreign (non-canvas / other-project) queue item', () => { + // A generate-widget run in the same project, or another project's run, shares + // the app-global latest-progress store — its frames must not leak onto the canvas. + expect(selectCanvasProgressImage(frame('q-generate'), new Set(['q-canvas']))).toBeNull(); + }); + + it('returns null when there is no latest frame or the frame has no target', () => { + expect(selectCanvasProgressImage(null, new Set(['q-canvas']))).toBeNull(); + expect(selectCanvasProgressImage({ dataUrl: 'x', height: 8, width: 8 }, new Set(['q-canvas']))).toBeNull(); + }); + + it('returns null when no canvas queue items are in flight', () => { + expect(selectCanvasProgressImage(frame('q-canvas'), new Set())).toBeNull(); + }); +}); + +describe('stagedPreviewKey', () => { + it('is stable per source and distinguishes the source kinds', () => { + expect(stagedPreviewKey(null)).toBe('none'); + expect(stagedPreviewKey({ imageName: 'a' })).toBe('image:a'); + expect(stagedPreviewKey({ imageName: 'a' })).not.toBe(stagedPreviewKey({ imageName: 'b' })); + // A new progress frame (different data URL) yields a new key so the effect re-fires. + expect(stagedPreviewKey({ dataUrl: 'AA', height: 8, width: 8 })).not.toBe( + stagedPreviewKey({ dataUrl: 'BB', height: 8, width: 8 }) + ); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/stagingPreview.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/stagingPreview.ts new file mode 100644 index 00000000000..9d9680ce92d --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/stagingPreview.ts @@ -0,0 +1,90 @@ +/** + * Pure selection of what the engine should draw as the canvas staged preview: + * a live denoise-progress frame while a canvas generation is in flight, + * otherwise the selected staged candidate, otherwise nothing. Kept React-free + * so the progress-vs-candidate decision is unit-testable in node. + */ + +import type { StagedPreviewInput } from '@workbench/canvas-engine/engine'; + +/** Inputs to {@link selectStagedPreviewSource}. */ +export interface StagedPreviewSelection { + /** Whether a canvas-destination generation is currently pending/running. */ + isGenerationInFlight: boolean; + /** The latest denoise-progress frame, if any (b64 data URL + native dims). */ + progressImage: { dataUrl: string; width: number; height: number } | null; + /** Whether the staging area's preview toggle is on. */ + isVisible: boolean; + /** The currently selected staged candidate's image name, if any. */ + selectedImageName: string | null; + /** The current bbox size (document px) — progress frames scale to fill it. */ + bboxWidth: number; + bboxHeight: number; +} + +/** + * Resolves the staged-preview source. Progress wins while generating (so the + * canvas shows live denoising over the bbox), handing off to the final + * candidate once the result lands and the run settles. Returns `null` when the + * preview is hidden or there is nothing to show. + */ +export const selectStagedPreviewSource = ({ + bboxHeight, + bboxWidth, + isGenerationInFlight, + isVisible, + progressImage, + selectedImageName, +}: StagedPreviewSelection): StagedPreviewInput | null => { + if (isGenerationInFlight && progressImage && bboxWidth > 0 && bboxHeight > 0) { + // Progress frames are low-res latents of the bbox region; scale to fill it. + return { dataUrl: progressImage.dataUrl, height: bboxHeight, width: bboxWidth }; + } + if (isVisible && selectedImageName) { + return { imageName: selectedImageName }; + } + return null; +}; + +/** The app-global latest progress frame, tagged with the queue item it belongs to. */ +export interface TargetedProgressImage { + dataUrl: string; + width: number; + height: number; + target?: { queueItemId: string }; +} + +/** + * Gates the app-global latest denoise-progress frame to THIS canvas: returns the + * frame only when its originating queue item is one of the project's pending/ + * running canvas-destination items. The progress store keeps a single global + * "latest" frame, so without this filter a generate-widget run in the same + * project — or a run in another project entirely — would draw its denoise frames + * over this canvas's bbox. Returning `null` for a foreign frame also lets a + * landed candidate win over another item's live progress (the candidate is the + * next fallback in {@link selectStagedPreviewSource}). + */ +export const selectCanvasProgressImage = ( + latest: TargetedProgressImage | null, + canvasQueueItemIds: ReadonlySet +): { dataUrl: string; width: number; height: number } | null => { + if (!latest?.target || !canvasQueueItemIds.has(latest.target.queueItemId)) { + return null; + } + return { dataUrl: latest.dataUrl, height: latest.height, width: latest.width }; +}; + +/** + * A stable string key for a {@link StagedPreviewInput}, so a React effect only + * re-drives the (async, decoding) `setStagedPreview` when the source actually + * changes — including every new progress frame, but not on unrelated renders. + */ +export const stagedPreviewKey = (source: StagedPreviewInput | null): string => { + if (source === null) { + return 'none'; + } + if ('imageName' in source) { + return `image:${source.imageName}`; + } + return `data:${source.width}x${source.height}:${source.dataUrl}`; +}; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/surfaceFocus.test.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/surfaceFocus.test.ts new file mode 100644 index 00000000000..7dc262baa7e --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/surfaceFocus.test.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { INLINE_EDIT_SELECTOR, shouldFocusCanvasSurface } from './surfaceFocus'; + +/** + * Minimal DOM stand-in for node-env: `contains` walks the parent chain, + * `closest` reports whether this element (or an ancestor) matches the + * inline-edit selector. Mirrors the FakeElement approach in + * `hotkeys/targetWidget.test.ts`. + */ +class FakeElement { + parent: FakeElement | null = null; + + constructor(private readonly matchesInlineEdit = false) {} + + appendTo(parent: FakeElement): this { + this.parent = parent; + return this; + } + + closest(selector: string): FakeElement | null { + expect(selector).toBe(INLINE_EDIT_SELECTOR); + if (this.matchesInlineEdit) { + return this; + } + return this.parent ? this.parent.closest(selector) : null; + } + + contains(other: unknown): boolean { + let current = other as FakeElement | null; + while (current) { + if (current === this) { + return true; + } + current = current.parent; + } + return false; + } +} + +const asContainer = (el: FakeElement): HTMLElement => el as unknown as HTMLElement; +const asTarget = (el: FakeElement): EventTarget => el as unknown as EventTarget; +const asActive = (el: FakeElement): Element => el as unknown as Element; + +describe('shouldFocusCanvasSurface', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('focuses when DOM focus is outside the surface (e.g. a layers-panel button)', () => { + vi.stubGlobal('Element', FakeElement); + const container = new FakeElement(); + const overlayCanvas = new FakeElement().appendTo(container); + const layersButtonElsewhere = new FakeElement(); + + expect( + shouldFocusCanvasSurface(asContainer(container), asTarget(overlayCanvas), asActive(layersButtonElsewhere)) + ).toBe(true); + }); + + it('focuses when nothing is focused at all', () => { + vi.stubGlobal('Element', FakeElement); + const container = new FakeElement(); + const overlayCanvas = new FakeElement().appendTo(container); + + expect(shouldFocusCanvasSurface(asContainer(container), asTarget(overlayCanvas), null)).toBe(true); + }); + + it('does not refocus when focus is already inside the surface (open text session: click-away commits via the engine)', () => { + vi.stubGlobal('Element', FakeElement); + const container = new FakeElement(); + const overlayCanvas = new FakeElement().appendTo(container); + // The text tool's contenteditable lives inside the surface container. + const contenteditable = new FakeElement(true).appendTo(container); + + // Click on the canvas OUTSIDE the editable — the commit gesture. Focus must + // stay on the editable so the engine's pointerdown commit-and-swallow runs. + expect(shouldFocusCanvasSurface(asContainer(container), asTarget(overlayCanvas), asActive(contenteditable))).toBe( + false + ); + }); + + it('does not steal focus when the click lands inside an inline editor', () => { + vi.stubGlobal('Element', FakeElement); + const container = new FakeElement(); + // A portal-rendered editor: its DOM sits OUTSIDE the container, so only the + // target-side selector guard can spare it. + const portalEditor = new FakeElement(true); + const focusElsewhere = new FakeElement(); + + expect(shouldFocusCanvasSurface(asContainer(container), asTarget(portalEditor), asActive(focusElsewhere))).toBe( + false + ); + }); + + it('focuses when the target is not an Element (defensive: non-element event targets)', () => { + vi.stubGlobal('Element', FakeElement); + const container = new FakeElement(); + + expect(shouldFocusCanvasSurface(asContainer(container), {} as EventTarget, null)).toBe(true); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/surfaceFocus.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/surfaceFocus.ts new file mode 100644 index 00000000000..e7fa7f90d9d --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/surfaceFocus.ts @@ -0,0 +1,52 @@ +/** + * Pure predicate for the canvas surface's focus-on-pointerdown behavior, kept + * free of React/Chakra so it is node-testable (see `surfaceFocus.test.ts`). + * + * The two `` render targets aren't focusable, so a plain click never + * moves DOM focus — it stays wherever it was (e.g. a layers-panel button in a + * DIFFERENT widget), and the hotkey runtime's DOM-focus-based widget resolution + * (`getHotkeyTargetWidget`) keeps routing tool hotkeys (`b`/`e`/…) to that other + * widget. Focusing the surface container on pointerdown moves focus into the + * canvas widget's subtree so its hotkeys fire immediately. + */ + +/** + * Elements that own their own focus/caret and must NOT be focus-stolen by the + * surface: the text tool's contenteditable overlay and any inline input the + * chrome overlays on the surface. + */ +export const INLINE_EDIT_SELECTOR = 'input, textarea, select, [contenteditable="true"], [role="textbox"]'; + +/** + * Whether a pointerdown on the canvas surface should focus the surface + * container. + * + * - **Focus already inside the surface → no.** Most importantly the text + * tool's contenteditable (rendered inside the container by `TextEditPortal`): + * clicking the canvas *outside* it is the "click away to commit" gesture, + * which the ENGINE owns (`commitOpenTextSession` in its pointerdown, which + * then swallows the click). Focusing the container here would fire in the + * capture phase — before the engine's bubble-phase handler — synchronously + * blurring the editable, committing + nulling the session via its `onBlur`, + * so the engine would then see no session, not swallow the click, and open a + * stray new text session at the click point. Skipping is also harmless for + * hotkey scope: focus inside the surface already resolves to the canvas + * widget. + * - **Click landing in an inline editor → no.** Belt and suspenders for + * portal-rendered editors whose DOM may sit outside the container (so the + * `contains` check above wouldn't spare them). + * - **Otherwise → yes** (e.g. focus is on a button in another widget's panel). + */ +export const shouldFocusCanvasSurface = ( + container: HTMLElement, + target: EventTarget | null, + activeElement: Element | null +): boolean => { + if (activeElement && container.contains(activeElement)) { + return false; + } + if (target instanceof Element && target.closest(INLINE_EDIT_SELECTOR)) { + return false; + } + return true; +}; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/BboxOptions.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/BboxOptions.tsx new file mode 100644 index 00000000000..ad6eae04978 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/BboxOptions.tsx @@ -0,0 +1,231 @@ +import type { NumberInput as ChakraNumberInput, SelectValueChangeDetails } from '@chakra-ui/react'; +import type { Rect } from '@workbench/canvas-engine/types'; + +import { createListCollection, HStack, NumberInput, Text } from '@chakra-ui/react'; +import { bboxEquals, constrainBboxToRatio, roundBbox } from '@workbench/canvas-engine/tools/bboxHitTest'; +import { Select, ToggleDot } from '@workbench/components/ui'; +import { useBboxGrid, useBboxOptions } from '@workbench/widgets/canvas/engineStoreHooks'; +import { useActiveProjectSelector } from '@workbench/WorkbenchContext'; +import { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { ToolOptionsComponentProps } from './ToolOptionsBar'; + +/** Aspect-ratio presets offered in the options bar. `null` ratio = Free (unlocked). */ +interface AspectPreset { + id: string; + ratio: number | null; +} + +const ASPECT_PRESETS: readonly AspectPreset[] = [ + { id: 'Free', ratio: null }, + { id: '1:1', ratio: 1 }, + { id: '4:3', ratio: 4 / 3 }, + { id: '3:4', ratio: 3 / 4 }, + { id: '16:9', ratio: 16 / 9 }, + { id: '9:16', ratio: 9 / 16 }, +]; + +const ASPECT_TRIGGER_PROPS = { minW: '5.5rem' } as const; + +const bboxEqualsSelected = (a: Rect | null, b: Rect | null): boolean => + a === b || (!!a && !!b && a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height); + +/** The preset id whose ratio matches `aspectRatio` (within tolerance), or `null`. */ +const matchingPresetId = (aspectRatio: number): string | null => + ASPECT_PRESETS.find((preset) => preset.ratio !== null && Math.abs(preset.ratio - aspectRatio) < 1e-3)?.id ?? null; + +/** + * Bbox tool options: numeric W/H and X/Y of the generation frame (document px, + * snap-rounded), an aspect-ratio preset select, and an aspect lock toggle. Frame + * edits commit through the engine's structural history (shared canvas undo stack + * with drags); the aspect lock/ratio lives in the engine's transient store. + */ +export const BboxOptions = ({ engine }: ToolOptionsComponentProps) => { + const { t } = useTranslation(); + const bbox = useActiveProjectSelector((project) => project.canvas.document.bbox, bboxEqualsSelected); + const options = useBboxOptions(engine); + const grid = useBboxGrid(engine); + + const collection = useMemo( + () => + createListCollection({ + itemToString: (item: AspectPreset) => item.id, + itemToValue: (item: AspectPreset) => item.id, + items: [...ASPECT_PRESETS], + }), + [] + ); + + const selectValue = useMemo( + () => [options.aspectLocked ? (matchingPresetId(options.aspectRatio) ?? 'Free') : 'Free'], + [options.aspectLocked, options.aspectRatio] + ); + + const commitBbox = useCallback( + (next: Rect) => { + const rounded = roundBbox(next); + if (bboxEquals(rounded, bbox)) { + return; + } + engine.commitStructural( + t('widgets.canvas.toolOptions.setFrame'), + { bbox: rounded, type: 'setCanvasBbox' }, + { bbox: roundBbox(bbox), type: 'setCanvasBbox' } + ); + }, + [bbox, engine, t] + ); + + const setWidth = useCallback( + (value: number) => { + const width = Math.max(1, Math.round(value / grid) * grid); + const height = + options.aspectLocked && options.aspectRatio > 0 + ? Math.max(1, Math.round(width / options.aspectRatio)) + : bbox.height; + commitBbox({ height, width, x: bbox.x, y: bbox.y }); + }, + [bbox, commitBbox, grid, options.aspectLocked, options.aspectRatio] + ); + + const setHeight = useCallback( + (value: number) => { + const height = Math.max(1, Math.round(value / grid) * grid); + const width = + options.aspectLocked && options.aspectRatio > 0 + ? Math.max(1, Math.round(height * options.aspectRatio)) + : bbox.width; + commitBbox({ height, width, x: bbox.x, y: bbox.y }); + }, + [bbox, commitBbox, grid, options.aspectLocked, options.aspectRatio] + ); + + const setX = useCallback( + (value: number) => commitBbox({ ...bbox, x: Math.round(value / grid) * grid }), + [bbox, commitBbox, grid] + ); + + const setY = useCallback( + (value: number) => commitBbox({ ...bbox, y: Math.round(value / grid) * grid }), + [bbox, commitBbox, grid] + ); + + const onAspectPresetChange = useCallback( + ({ value }: SelectValueChangeDetails) => { + const id = value[0]; + const preset = ASPECT_PRESETS.find((entry) => entry.id === id); + if (!preset) { + return; + } + if (preset.ratio === null) { + engine.stores.bboxOptions.set({ ...options, aspectLocked: false }); + return; + } + engine.stores.bboxOptions.set({ aspectLocked: true, aspectRatio: preset.ratio }); + commitBbox(constrainBboxToRatio(bbox, preset.ratio, grid)); + }, + [bbox, commitBbox, engine, grid, options] + ); + + const onLockToggle = useCallback( + (checked: boolean) => { + const aspectRatio = + checked && bbox.height > 0 && matchingPresetId(options.aspectRatio) === null + ? bbox.width / bbox.height + : options.aspectRatio > 0 + ? options.aspectRatio + : 1; + engine.stores.bboxOptions.set({ aspectLocked: checked, aspectRatio }); + }, + [bbox, engine, options.aspectRatio] + ); + + const onWidthChange = useCallback( + ({ valueAsNumber }: ChakraNumberInput.ValueChangeDetails) => { + if (Number.isFinite(valueAsNumber)) { + setWidth(valueAsNumber); + } + }, + [setWidth] + ); + const onHeightChange = useCallback( + ({ valueAsNumber }: ChakraNumberInput.ValueChangeDetails) => { + if (Number.isFinite(valueAsNumber)) { + setHeight(valueAsNumber); + } + }, + [setHeight] + ); + const onXChange = useCallback( + ({ valueAsNumber }: ChakraNumberInput.ValueChangeDetails) => { + if (Number.isFinite(valueAsNumber)) { + setX(valueAsNumber); + } + }, + [setX] + ); + const onYChange = useCallback( + ({ valueAsNumber }: ChakraNumberInput.ValueChangeDetails) => { + if (Number.isFinite(valueAsNumber)) { + setY(valueAsNumber); + } + }, + [setY] + ); + + return ( + + + + {t('widgets.canvas.toolOptions.frameWidth')} + + + + + + + + + {t('widgets.canvas.toolOptions.frameHeight')} + + + + + + + + + {t('widgets.canvas.toolOptions.positionX')} + + + + + + + + + {t('widgets.canvas.toolOptions.positionY')} + + + + + + + + + + + {t('widgets.canvas.toolOptions.gradientAngle')} + + + + + + + + + + {t('widgets.canvas.toolOptions.gradientStart')} + + + + + + + + + + + {t('widgets.canvas.toolOptions.gradientEnd')} + + + + + + + + + ); +}; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/LassoOptions.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/LassoOptions.tsx new file mode 100644 index 00000000000..4a26e5cbd7a --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/LassoOptions.tsx @@ -0,0 +1,91 @@ +import type { CanvasEngine } from '@workbench/canvas-engine/engine'; +import type { SelectionOp } from '@workbench/canvas-engine/types'; + +import { HStack, Text } from '@chakra-ui/react'; +import { Button } from '@workbench/components/ui'; +import { useCanvasHasSelection, useLassoOptions } from '@workbench/widgets/canvas/engineStoreHooks'; +import { useActiveProjectSelector } from '@workbench/WorkbenchContext'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { ToolOptionsComponentProps } from './ToolOptionsBar'; + +const OP_MODES: readonly SelectionOp[] = ['replace', 'add', 'subtract', 'intersect']; + +const OP_MODE_LABEL_KEYS: Record = { + add: 'widgets.canvas.toolOptions.selectionAdd', + intersect: 'widgets.canvas.toolOptions.selectionIntersect', + replace: 'widgets.canvas.toolOptions.selectionReplace', + subtract: 'widgets.canvas.toolOptions.selectionSubtract', +}; + +/** One op-mode button with a stable click handler (avoids a per-render closure in the map). */ +const OpModeButton = ({ engine, mode, active }: { engine: CanvasEngine; mode: SelectionOp; active: boolean }) => { + const { t } = useTranslation(); + const onClick = useCallback(() => engine.stores.lassoOptions.set({ mode }), [engine, mode]); + return ( + + ); +}; + +/** + * Lasso tool options: the boolean op-mode selector (replace / add / subtract / + * intersect — also settable transiently by holding shift / alt / shift+alt while + * committing a path) plus fill / erase / invert / deselect actions. Fill and + * erase require an eligible (unlocked, visible) paint layer selected; invert and + * deselect only require a live selection. Reads/writes the engine's transient + * selection state directly — no reducer mirror. + */ +export const LassoOptions = ({ engine }: ToolOptionsComponentProps) => { + const { t } = useTranslation(); + const options = useLassoOptions(engine); + const hasSelection = useCanvasHasSelection(engine); + + // Whether the selected layer can receive a masked fill/erase (paint, unlocked, + // visible). Same eligibility the engine enforces; used to disable the buttons. + const canPaintTarget = useActiveProjectSelector((project) => { + const { document } = project.canvas; + const layer = document.selectedLayerId + ? document.layers.find((entry) => entry.id === document.selectedLayerId) + : undefined; + return !!layer && layer.type === 'raster' && layer.source.type === 'paint' && !layer.isLocked && layer.isEnabled; + }); + + const onFill = useCallback(() => engine.fillSelection(), [engine]); + const onErase = useCallback(() => engine.eraseSelection(), [engine]); + const onInvert = useCallback(() => engine.invertSelection(), [engine]); + const onDeselect = useCallback(() => engine.deselect(), [engine]); + + const canEdit = hasSelection && canPaintTarget; + + return ( + + + {OP_MODES.map((mode) => ( + + ))} + + + + + + + + {!hasSelection ? ( + + {t('widgets.canvas.toolOptions.lassoHint')} + + ) : null} + + ); +}; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/MoveOptions.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/MoveOptions.tsx new file mode 100644 index 00000000000..1df004b180f --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/MoveOptions.tsx @@ -0,0 +1,105 @@ +import type { NumberInput as ChakraNumberInput } from '@chakra-ui/react'; + +import { HStack, NumberInput, Text } from '@chakra-ui/react'; +import { useActiveProjectSelector } from '@workbench/WorkbenchContext'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { ToolOptionsComponentProps } from './ToolOptionsBar'; + +interface SelectedTransform { + id: string; + x: number; + y: number; +} + +/** + * Move tool options: numeric X / Y position of the selected layer (document + * pixels). Reads the committed transform from the reducer and writes each edit + * back through the engine's structural history (so it shares the canvas undo + * stack with drags and nudges). Disabled with no selection. + */ +export const MoveOptions = ({ engine }: ToolOptionsComponentProps) => { + const { t } = useTranslation(); + const selected = useActiveProjectSelector( + (project): SelectedTransform | null => { + const { document } = project.canvas; + const layer = document.selectedLayerId + ? document.layers.find((entry) => entry.id === document.selectedLayerId) + : undefined; + return layer ? { id: layer.id, x: layer.transform.x, y: layer.transform.y } : null; + }, + (a, b) => a?.id === b?.id && a?.x === b?.x && a?.y === b?.y + ); + + const commitAxis = useCallback( + (axis: 'x' | 'y', next: number) => { + if (!selected || next === selected[axis]) { + return; + } + const forwardTransform = axis === 'x' ? { x: next } : { y: next }; + const inverseTransform = axis === 'x' ? { x: selected.x } : { y: selected.y }; + engine.commitStructural( + t('widgets.canvas.toolOptions.movePosition'), + { id: selected.id, patch: { transform: forwardTransform }, type: 'updateCanvasLayer' }, + { id: selected.id, patch: { transform: inverseTransform }, type: 'updateCanvasLayer' } + ); + }, + [engine, selected, t] + ); + + const onXChange = useCallback( + ({ valueAsNumber }: ChakraNumberInput.ValueChangeDetails) => { + if (Number.isFinite(valueAsNumber)) { + commitAxis('x', Math.round(valueAsNumber)); + } + }, + [commitAxis] + ); + + const onYChange = useCallback( + ({ valueAsNumber }: ChakraNumberInput.ValueChangeDetails) => { + if (Number.isFinite(valueAsNumber)) { + commitAxis('y', Math.round(valueAsNumber)); + } + }, + [commitAxis] + ); + + const disabled = !selected; + + return ( + + + + {t('widgets.canvas.toolOptions.positionX')} + + + + + + + + + {t('widgets.canvas.toolOptions.positionY')} + + + + + + + + ); +}; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/ShapeOptions.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/ShapeOptions.tsx new file mode 100644 index 00000000000..34ffec40b3e --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/ShapeOptions.tsx @@ -0,0 +1,193 @@ +import type { NumberInput as ChakraNumberInput, SelectValueChangeDetails } from '@chakra-ui/react'; +import type { ShapeToolOptions } from '@workbench/canvas-engine/engineStores'; +import type { CanvasLayerSourceContract } from '@workbench/types'; + +import { createListCollection, HStack, NumberInput, Text } from '@chakra-ui/react'; +import { MAX_SHAPE_STROKE_WIDTH } from '@workbench/canvas-engine/engineStores'; +import { ColorPicker, Select, ToggleDot } from '@workbench/components/ui'; +import { useShapeOptions } from '@workbench/widgets/canvas/engineStoreHooks'; +import { useActiveProjectSelector } from '@workbench/WorkbenchContext'; +import { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { ToolOptionsComponentProps } from './ToolOptionsBar'; + +type ShapeSource = Extract; +type ShapeKind = ShapeToolOptions['kind']; + +interface SelectedShape { + id: string; + source: ShapeSource; +} + +/** Fallback color used when re-enabling a `none` fill/stroke. */ +const FALLBACK_COLOR = '#000000'; + +const SELECT_POSITIONING = { placement: 'top-start', sameWidth: false } as const; +const SELECT_TRIGGER_PROPS = { minW: '6rem' } as const; + +/** + * Shape tool options: kind (rect/ellipse), fill color (+ none), stroke color + * (+ none) and stroke width. Edits set the defaults for the next created shape + * AND, when a shape layer is selected, apply to it — colors commit ONE history + * entry on interaction end (the ColorPicker popover shows the live color while + * dragging; the canvas updates on release), discrete edits commit immediately. + */ +export const ShapeOptions = ({ engine }: ToolOptionsComponentProps) => { + const { t } = useTranslation(); + const options = useShapeOptions(engine); + + const selected = useActiveProjectSelector( + (project): SelectedShape | null => { + const { document } = project.canvas; + const layer = document.selectedLayerId + ? document.layers.find((entry) => entry.id === document.selectedLayerId) + : undefined; + if (layer && layer.type === 'raster' && layer.source.type === 'shape') { + return { id: layer.id, source: layer.source }; + } + return null; + }, + // Re-render on a selection change or a source-reference change (the reducer + // replaces the source object on every edit). + (a, b) => a?.id === b?.id && a?.source === b?.source + ); + + // Displayed values track the selected shape layer, or the tool defaults. + const kind: ShapeKind = selected ? (selected.source.kind === 'ellipse' ? 'ellipse' : 'rect') : options.kind; + const fill = selected ? selected.source.fill : options.fill; + const stroke = selected ? selected.source.stroke : options.stroke; + const strokeWidth = selected ? selected.source.strokeWidth : options.strokeWidth; + + const kindCollection = useMemo( + () => + createListCollection<{ label: string; value: ShapeKind }>({ + items: [ + { label: t('widgets.canvas.toolOptions.shapeRect'), value: 'rect' }, + { label: t('widgets.canvas.toolOptions.shapeEllipse'), value: 'ellipse' }, + ], + }), + [t] + ); + const kindValue = useMemo(() => [kind], [kind]); + + /** + * Applies an options patch: always updates the defaults store; when a shape + * layer is selected and `commit` is set, records one history entry on it. + */ + const applyEdit = useCallback( + (patch: Partial, commit: boolean) => { + engine.stores.shapeOptions.set({ fill, kind, stroke, strokeWidth, ...patch }); + if (selected && commit) { + const before = selected.source; + const after: ShapeSource = { ...before, ...patch }; + engine.commitStructural( + t('widgets.canvas.toolOptions.shapeEdit'), + { id: selected.id, source: after, type: 'updateCanvasLayerSource' }, + { id: selected.id, source: before, type: 'updateCanvasLayerSource' } + ); + } + }, + [engine, fill, kind, stroke, strokeWidth, selected, t] + ); + + const onKindChange = useCallback( + ({ value }: SelectValueChangeDetails<{ label: string; value: ShapeKind }>) => { + const next = value[0] as ShapeKind | undefined; + if (next && next !== kind) { + applyEdit({ kind: next }, true); + } + }, + [applyEdit, kind] + ); + + // Fill: a toggle for none, plus a color picker (live store on drag, one commit on end). + const onFillToggle = useCallback( + (checked: boolean) => applyEdit({ fill: checked ? (fill ?? FALLBACK_COLOR) : null }, true), + [applyEdit, fill] + ); + const onFillChange = useCallback((hex: string) => applyEdit({ fill: hex }, false), [applyEdit]); + const onFillChangeEnd = useCallback((hex: string) => applyEdit({ fill: hex }, true), [applyEdit]); + + const onStrokeToggle = useCallback( + (checked: boolean) => applyEdit({ stroke: checked ? (stroke ?? FALLBACK_COLOR) : null }, true), + [applyEdit, stroke] + ); + const onStrokeChange = useCallback((hex: string) => applyEdit({ stroke: hex }, false), [applyEdit]); + const onStrokeChangeEnd = useCallback((hex: string) => applyEdit({ stroke: hex }, true), [applyEdit]); + + const onStrokeWidthChange = useCallback( + ({ valueAsNumber }: ChakraNumberInput.ValueChangeDetails) => { + if (Number.isFinite(valueAsNumber)) { + applyEdit({ strokeWidth: Math.max(0, Math.round(valueAsNumber)) }, true); + } + }, + [applyEdit] + ); + + return ( + + + + + + + + + + + + + + + + + {points.map((p, i) => { + const { cx, cy } = toSvg(p[0], p[1]); + return ( + e.preventDefault()} + onDoubleClick={handleRemove(i)} + onPointerDown={handlePointDown(i)} + r={4} + style={{ cursor: 'pointer' }} + /> + ); + })} + + + {t('widgets.layers.adjustments.curvesHint')} + + + ); +}; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/layers/ControlLayerSettings.tsx b/invokeai/frontend/webv2/src/workbench/widgets/layers/ControlLayerSettings.tsx new file mode 100644 index 00000000000..4d2be782de5 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/layers/ControlLayerSettings.tsx @@ -0,0 +1,695 @@ +import type { SelectValueChangeDetails, SliderValueChangeDetails } from '@chakra-ui/react'; +import type { CanvasEngine } from '@workbench/canvas-engine/engine'; +import type { ControlAdapterKind } from '@workbench/generation/canvas/addControlLayers'; +import type { FilterParamSpec } from '@workbench/generation/canvas/filterGraphs'; +import type { CanvasControlAdapterContract, CanvasControlLayerContract } from '@workbench/types'; + +import { createListCollection, HStack, Stack, Switch, Text } from '@chakra-ui/react'; +import { socketHub } from '@workbench/backend/socketHub'; +import { runUtilityGraph } from '@workbench/canvas-engine/backend/utilityQueue'; +import { applyToPoint, fromTRS } from '@workbench/canvas-engine/math/mat2d'; +import { Button, Field, Select, Slider } from '@workbench/components/ui'; +import { makeImageDurable } from '@workbench/gallery/api'; +import { isControlKindSupportedForBase } from '@workbench/generation/canvas/addControlLayers'; +import { + buildFilterDefaults, + CONTROL_FILTERS, + DEFAULT_CONTROL_FILTER_TYPE, + getFilterDefinition, +} from '@workbench/generation/canvas/filterGraphs'; +import { useModelsSelector } from '@workbench/models/modelsStore'; +import { getProjectWidgetValues } from '@workbench/widgetState'; +import { useActiveProjectSelector, useWorkbenchDispatch } from '@workbench/WorkbenchContext'; +import { useCallback, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { runControlFilterPreview } from './controlFilterPreview'; +import { applyStructural } from './layerOps'; + +const SELECT_POSITIONING = { placement: 'bottom-end', sameWidth: false } as const; + +const CONTROL_ADAPTER_KINDS: readonly ControlAdapterKind[] = ['controlnet', 't2i_adapter', 'control_lora']; +const CONTROL_MODES: readonly NonNullable[] = [ + 'balanced', + 'more_prompt', + 'more_control', + 'unbalanced', +]; + +const formatUnitPercent = (value: number): string => `${Math.round(value * 100)}%`; +const formatWeight = (value: number): string => value.toFixed(2); + +/** The main model's base, read from the generate widget values (drives adapter support). */ +const useSelectedModelBase = (): string | null => { + const modelKey = useActiveProjectSelector((project) => { + const values = getProjectWidgetValues(project, 'generate'); + const model = values?.model; + return model && typeof model === 'object' && 'key' in model ? String((model as { key: unknown }).key) : null; + }); + const models = useModelsSelector((snapshot) => snapshot.models); + return useMemo(() => models.find((model) => model.key === modelKey)?.base ?? null, [models, modelKey]); +}; + +interface ControlLayerSettingsProps { + engine: CanvasEngine | null; + layer: CanvasControlLayerContract; +} + +/** + * Per-layer settings for a selected control layer (plan §1.3): adapter kind + + * model, weight, begin/end step range, control mode (ControlNet only), the + * transparency effect toggle, and a non-destructive filter section (type + + * per-type settings + preview/apply/cancel). Adapter edits go through the canvas + * undo stack (`updateCanvasLayerConfig`); the filter preview runs on the utility + * queue and never mutates the document until "Apply". + */ +export const ControlLayerSettings = ({ engine, layer }: ControlLayerSettingsProps) => { + const { t } = useTranslation(); + const dispatch = useWorkbenchDispatch(); + const models = useModelsSelector((snapshot) => snapshot.models); + const base = useSelectedModelBase(); + const { adapter } = layer; + + const commitAdapter = useCallback( + (next: Partial, before: Partial, label: string) => { + applyStructural( + engine, + dispatch, + label, + { config: { adapter: next, layerType: 'control' }, id: layer.id, type: 'updateCanvasLayerConfig' }, + { config: { adapter: before, layerType: 'control' }, id: layer.id, type: 'updateCanvasLayerConfig' } + ); + }, + [dispatch, engine, layer.id] + ); + + // Adapter kinds supported by the selected base (legacy support matrix). With no + // model selected, offer all kinds so the layer is still configurable. + const kindOptions = useMemo( + () => CONTROL_ADAPTER_KINDS.filter((kind) => !base || isControlKindSupportedForBase(base, kind)), + [base] + ); + const kindCollection = useMemo( + () => + createListCollection({ + items: kindOptions.map((kind) => ({ label: t(`widgets.layers.control.kinds.${kind}`), value: kind })), + }), + [kindOptions, t] + ); + + // Adapter models matching the current kind + base (mirrors the generate model list). + const modelOptions = useMemo( + () => models.filter((model) => model.type === adapter.kind && (!base || model.base === base)), + [adapter.kind, base, models] + ); + const modelCollection = useMemo( + () => createListCollection({ items: modelOptions.map((model) => ({ label: model.name, value: model.key })) }), + [modelOptions] + ); + + const handleKindChange = useCallback( + ({ value }: SelectValueChangeDetails) => { + const kind = value[0] as ControlAdapterKind | undefined; + if (!kind || kind === adapter.kind) { + return; + } + // Switching kind clears the model (its base/type no longer matches) and, for + // non-ControlNet kinds, drops the control mode. + commitAdapter( + { controlMode: kind === 'controlnet' ? (adapter.controlMode ?? 'balanced') : null, kind, model: null }, + { controlMode: adapter.controlMode, kind: adapter.kind, model: adapter.model }, + t('widgets.layers.control.kind') + ); + }, + [adapter.controlMode, adapter.kind, adapter.model, commitAdapter, t] + ); + + const handleModelChange = useCallback( + ({ value }: SelectValueChangeDetails) => { + const model = value[0] ?? null; + if (model !== adapter.model) { + commitAdapter({ model }, { model: adapter.model }, t('widgets.layers.control.model')); + } + }, + [adapter.model, commitAdapter, t] + ); + + const handleControlModeChange = useCallback( + ({ value }: SelectValueChangeDetails) => { + const mode = value[0] as CanvasControlAdapterContract['controlMode'] | undefined; + if (mode && mode !== adapter.controlMode) { + commitAdapter({ controlMode: mode }, { controlMode: adapter.controlMode }, t('widgets.layers.control.mode')); + } + }, + [adapter.controlMode, commitAdapter, t] + ); + + const weightBeforeRef = useRef(null); + const handleWeightChange = useCallback( + ({ value }: SliderValueChangeDetails) => { + const next = value[0]; + if (next === undefined || !Number.isFinite(next)) { + return; + } + if (weightBeforeRef.current === null) { + weightBeforeRef.current = adapter.weight; + } + dispatch({ + config: { adapter: { weight: next }, layerType: 'control' }, + id: layer.id, + type: 'updateCanvasLayerConfig', + }); + }, + [adapter.weight, dispatch, layer.id] + ); + const handleWeightChangeEnd = useCallback( + ({ value }: SliderValueChangeDetails) => { + const next = value[0]; + const before = weightBeforeRef.current ?? adapter.weight; + weightBeforeRef.current = null; + if (next === undefined || !Number.isFinite(next)) { + return; + } + commitAdapter({ weight: next }, { weight: before }, t('widgets.layers.control.weight')); + }, + [adapter.weight, commitAdapter, t] + ); + + const rangeBeforeRef = useRef<[number, number] | null>(null); + const handleRangeChange = useCallback( + ({ value }: SliderValueChangeDetails) => { + if (value.length !== 2) { + return; + } + if (rangeBeforeRef.current === null) { + rangeBeforeRef.current = adapter.beginEndStepPct; + } + dispatch({ + config: { adapter: { beginEndStepPct: [value[0]!, value[1]!] }, layerType: 'control' }, + id: layer.id, + type: 'updateCanvasLayerConfig', + }); + }, + [adapter.beginEndStepPct, dispatch, layer.id] + ); + const handleRangeChangeEnd = useCallback( + ({ value }: SliderValueChangeDetails) => { + const before = rangeBeforeRef.current ?? adapter.beginEndStepPct; + rangeBeforeRef.current = null; + if (value.length !== 2) { + return; + } + commitAdapter( + { beginEndStepPct: [value[0]!, value[1]!] }, + { beginEndStepPct: before }, + t('widgets.layers.control.stepRange') + ); + }, + [adapter.beginEndStepPct, commitAdapter, t] + ); + + const handleTransparencyToggle = useCallback( + ({ checked }: { checked: boolean }) => { + applyStructural( + engine, + dispatch, + t('widgets.layers.control.transparencyEffect'), + { + config: { layerType: 'control', withTransparencyEffect: checked }, + id: layer.id, + type: 'updateCanvasLayerConfig', + }, + { + config: { layerType: 'control', withTransparencyEffect: !checked }, + id: layer.id, + type: 'updateCanvasLayerConfig', + } + ); + }, + [dispatch, engine, layer.id, t] + ); + + const controlModeCollection = useMemo( + () => + createListCollection({ + items: CONTROL_MODES.map((mode) => ({ label: t(`widgets.layers.control.modes.${mode}`), value: mode })), + }), + [t] + ); + + const kindValue = useMemo(() => [adapter.kind], [adapter.kind]); + const modelValue = useMemo(() => (adapter.model ? [adapter.model] : []), [adapter.model]); + const controlModeValue = useMemo(() => [adapter.controlMode ?? 'balanced'], [adapter.controlMode]); + const weightValue = useMemo(() => [adapter.weight], [adapter.weight]); + const rangeValue = useMemo(() => [...adapter.beginEndStepPct], [adapter.beginEndStepPct]); + const weightAria = useMemo(() => [t('widgets.layers.control.weight')], [t]); + const rangeAria = useMemo(() => [t('widgets.layers.control.beginStep'), t('widgets.layers.control.endStep')], [t]); + + const selectedModelName = modelOptions.find((model) => model.key === adapter.model)?.name; + + return ( + + + + + + + + + + + + {adapter.kind === 'controlnet' ? ( + + + + {definition?.params.map((param) => ( + + ))} + + + {preview.status === 'previewing' ? ( + <> + + + + ) : null} + + {preview.status === 'error' ? ( + + {preview.message} + + ) : null} + + ); +}; + +interface FilterParamFieldProps { + param: FilterParamSpec; + value: unknown; + onChange: (key: string, value: unknown) => void; +} + +/** One filter parameter editor (number slider / boolean switch / enum select). */ +const FilterParamField = ({ param, value, onChange }: FilterParamFieldProps) => { + const { t } = useTranslation(); + const label = t(`widgets.layers.control.filterParams.${param.key}`, param.key); + const labelAria = useMemo(() => [label], [label]); + + const handleBoolean = useCallback( + ({ checked }: { checked: boolean }) => onChange(param.key, checked), + [onChange, param.key] + ); + const handleEnum = useCallback( + ({ value: next }: SelectValueChangeDetails) => { + if (next[0]) { + onChange(param.key, next[0]); + } + }, + [onChange, param.key] + ); + const handleNumberEnd = useCallback( + ({ value: next }: SliderValueChangeDetails) => { + const n = next[0]; + if (n !== undefined && Number.isFinite(n)) { + onChange(param.key, param.kind === 'number' && param.integer ? Math.round(n) : n); + } + }, + [onChange, param] + ); + + const enumCollection = useMemo( + () => + param.kind === 'enum' + ? createListCollection({ items: param.options.map((option) => ({ label: option, value: option })) }) + : null, + [param] + ); + const enumCurrent = + param.kind === 'enum' && typeof value === 'string' && param.options.includes(value) ? value : param.default; + const enumValue = useMemo(() => [String(enumCurrent)], [enumCurrent]); + const numberCurrent = typeof value === 'number' && Number.isFinite(value) ? value : param.default; + const numberValue = useMemo(() => [Number(numberCurrent)], [numberCurrent]); + + if (param.kind === 'boolean') { + return ( + + + + + + + {label} + + + ); + } + + if (param.kind === 'enum' && enumCollection) { + return ( + + + + + + + + + + + + + ); +}; + +const SELECT_POSITIONING = { placement: 'bottom-end', sameWidth: false } as const; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/layers/LayerContextMenu.tsx b/invokeai/frontend/webv2/src/workbench/widgets/layers/LayerContextMenu.tsx new file mode 100644 index 00000000000..d5bd09c8103 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/layers/LayerContextMenu.tsx @@ -0,0 +1,511 @@ +import type { CanvasEngine } from '@workbench/canvas-engine/engine'; +import type { CanvasLayerContract } from '@workbench/types'; +import type { WorkbenchAction } from '@workbench/workbenchState'; +import type { LucideIcon } from 'lucide-react'; +import type { ComponentProps, Dispatch } from 'react'; + +import { HStack, Icon, Menu, Portal, Text } from '@chakra-ui/react'; +import { deleteLayerActions, duplicateLayerActions } from '@workbench/canvasLayerOps'; +import { IconButton, MenuContent, RenameDialog } from '@workbench/components/ui'; +import { + ArrowDownIcon, + ArrowDownToLineIcon, + ArrowUpIcon, + ArrowUpToLineIcon, + CopyIcon, + EyeIcon, + EyeOffIcon, + ImageIcon, + LockIcon, + LockOpenIcon, + MergeIcon, + MoreVerticalIcon, + PencilIcon, + SlidersHorizontalIcon, + Trash2Icon, +} from 'lucide-react'; +import { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { LayerMoveKind } from './layerGroups'; + +import { getGroupPosition, reorderWithinGroupByKind } from './layerGroups'; +import { resolveMenuTargetForRender } from './layerMenuState'; +import { + applyStructural, + canConvertRasterControl, + canMergeLayerDown, + convertRasterControlLayer, + createLayerId, +} from './layerOps'; + +type MenuPositioning = ComponentProps['positioning']; +type MenuOpenChange = ComponentProps['onOpenChange']; + +const PANEL_POSITIONING: MenuPositioning = { placement: 'bottom-end' }; + +interface LayerMenuProps { + dispatch: Dispatch; + engine: CanvasEngine | null; + index: number; + layer: CanvasLayerContract; + layers: readonly CanvasLayerContract[]; + /** Where the menu opens: the panel anchors to its trigger; the canvas uses a + * virtual rect at the cursor. */ + positioning: MenuPositioning; + /** Render the panel's ⋯ trigger button. Off in the canvas' controlled, anchored + * mode, where there is no trigger DOM. */ + withTrigger?: boolean; + /** Controlled open state (canvas right-click). Undefined ⇒ uncontrolled (panel). */ + open?: boolean; + onOpenChange?: MenuOpenChange; + lazyMount?: boolean; + unmountOnExit?: boolean; + /** + * Controlled rename-in-flight state. When provided (canvas right-click), the + * parent owns it so the rename dialog survives the menu closing (F1). Undefined + * ⇒ the menu keeps this state internally (panel). + */ + isRenaming?: boolean; + onRenamingChange?: (isRenaming: boolean) => void; +} + +/** + * The shared layer context menu — ONE source of truth for the per-layer items and + * their handlers, used by both the layers panel (a ⋯ trigger button) and the + * canvas surface (right-click, anchored at the cursor). All actions operate on + * `layer.id`, so they behave identically from either surface. + * + * Legacy-parity items (scoped to what webv2 supports today): rename, duplicate, + * rasterize, raster↔control convert, group-aware z-arrange (move to front / + * forward / backward / to back — within the layer's type group), merge-down, + * visibility + lock toggles, and delete. Merge-down uses global z-adjacency + * (compositing order); the arrange actions map to a splice inside the global array. + * + * The rename dialog is a SIBLING of `Menu.Root` (not inside its portal) so it + * survives the menu closing when "Rename" is chosen. + */ +const LayerMenu = ({ + dispatch, + engine, + index, + layer, + layers, + positioning, + withTrigger, + open, + onOpenChange, + lazyMount, + unmountOnExit, + isRenaming: controlledRenaming, + onRenamingChange, +}: LayerMenuProps) => { + const { t } = useTranslation(); + const [internalRenaming, setInternalRenaming] = useState(false); + // Controlled (canvas) vs. uncontrolled (panel): the canvas parent owns the flag + // so the rename dialog outlives the menu closing. The panel keeps it internally. + const isRenaming = controlledRenaming ?? internalRenaming; + const setRenaming = useCallback( + (next: boolean) => { + setInternalRenaming(next); + onRenamingChange?.(next); + }, + [onRenamingChange] + ); + + const groupPosition = getGroupPosition(layers, layer.id); + const canMoveForward = !!groupPosition && groupPosition.index > 0; + const canMoveBackward = !!groupPosition && groupPosition.index < groupPosition.count - 1; + const canMerge = canMergeLayerDown(layers, index, !!engine); + // "Rasterize layer" is offered only for parametric sources (shape/gradient/ + // text), where the engine can bake the params to pixels. A polygon shape has + // no rasterizer yet, so it stays disabled. + const canRasterize = + !!engine && + layer.type === 'raster' && + (layer.source.type === 'gradient' || + layer.source.type === 'text' || + (layer.source.type === 'shape' && layer.source.kind !== 'polygon')); + + const patchBase = useCallback( + (label: string, forward: Partial, inverse: Partial) => { + applyStructural( + engine, + dispatch, + label, + { id: layer.id, patch: forward, type: 'updateCanvasLayer' }, + { id: layer.id, patch: inverse, type: 'updateCanvasLayer' } + ); + }, + [dispatch, engine, layer.id] + ); + + const reorder = useCallback( + (kind: LayerMoveKind, label: string) => { + const next = reorderWithinGroupByKind(layers, layer.id, kind); + if (!next) { + return; + } + applyStructural( + engine, + dispatch, + label, + { orderedIds: next, type: 'reorderCanvasLayers' }, + { orderedIds: layers.map((entry) => entry.id), type: 'reorderCanvasLayers' } + ); + }, + [dispatch, engine, layer.id, layers] + ); + + const handleMoveToFront = useCallback(() => reorder('front', t('widgets.layers.actions.moveToFront')), [reorder, t]); + const handleMoveForward = useCallback( + () => reorder('forward', t('widgets.layers.actions.moveForward')), + [reorder, t] + ); + const handleMoveBackward = useCallback( + () => reorder('backward', t('widgets.layers.actions.moveBackward')), + [reorder, t] + ); + const handleMoveToBack = useCallback(() => reorder('back', t('widgets.layers.actions.moveToBack')), [reorder, t]); + + const handleDuplicate = useCallback(() => { + const { forward, inverse } = duplicateLayerActions(layer.id, createLayerId()); + applyStructural(engine, dispatch, t('widgets.layers.actions.duplicate'), forward, inverse); + }, [dispatch, engine, layer.id, t]); + + const handleDelete = useCallback(() => { + const { forward, inverse } = deleteLayerActions(layer, index); + applyStructural(engine, dispatch, t('widgets.layers.actions.delete'), forward, inverse); + }, [dispatch, engine, index, layer, t]); + + const handleMerge = useCallback(() => { + // Pixel work: engine-only, and not recorded on the undo history. + engine?.mergeLayerDown(layer.id); + }, [engine, layer.id]); + + const handleRasterize = useCallback(() => { + // Bakes the parametric source to pixels; the engine records ONE undoable + // entry (inverse re-converts to the parametric source). + engine?.rasterizeLayer(layer.id); + }, [engine, layer.id]); + + const convert = useCallback( + (targetType: 'raster' | 'control', label: string) => { + const converted = convertRasterControlLayer(layer, targetType); + if (!converted) { + return; + } + // Convert in place, preserving the pixel source + id. The inverse restores + // the layer verbatim (adapter/filter config and all). + applyStructural( + engine, + dispatch, + label, + { id: layer.id, layer: converted, targetType, type: 'convertCanvasLayer' }, + { id: layer.id, layer: structuredClone(layer), targetType: layer.type, type: 'convertCanvasLayer' } + ); + }, + [dispatch, engine, layer] + ); + + const handleConvertToControl = useCallback( + () => convert('control', t('widgets.layers.actions.convertToControl')), + [convert, t] + ); + const handleConvertToRaster = useCallback( + () => convert('raster', t('widgets.layers.actions.convertToRaster')), + [convert, t] + ); + // raster↔control conversion is offered only for pixel-backed layers (image/paint + // raster sources, or a control layer). Parametric raster + mask layers cannot. + const canConvertToControl = layer.type === 'raster' && canConvertRasterControl(layer); + const canConvertToRaster = layer.type === 'control'; + + const handleToggleVisibility = useCallback(() => { + patchBase( + t('widgets.layers.actions.toggleVisibility'), + { isEnabled: !layer.isEnabled }, + { isEnabled: layer.isEnabled } + ); + }, [layer.isEnabled, patchBase, t]); + + const handleToggleLock = useCallback(() => { + patchBase(t('widgets.layers.actions.toggleLock'), { isLocked: !layer.isLocked }, { isLocked: layer.isLocked }); + }, [layer.isLocked, patchBase, t]); + + const openRename = useCallback(() => setRenaming(true), [setRenaming]); + const closeRename = useCallback(() => setRenaming(false), [setRenaming]); + const submitRename = useCallback( + (name: string) => { + patchBase(t('widgets.layers.actions.rename'), { name }, { name: layer.name }); + }, + [layer.name, patchBase, t] + ); + + return ( + <> + + {withTrigger ? ( + + + + + + ) : null} + + + + + + {canRasterize ? ( + + ) : null} + {canConvertToControl ? ( + + ) : null} + {canConvertToRaster ? ( + + ) : null} + + + + + + + + + + + + + + + + + + ); +}; + +interface LayerRowMenuProps { + dispatch: Dispatch; + engine: CanvasEngine | null; + index: number; + layer: CanvasLayerContract; + layers: readonly CanvasLayerContract[]; +} + +/** The layers-panel per-row context menu: a ⋯ trigger button, opened below it. */ +export const LayerContextMenu = (props: LayerRowMenuProps) => ( + +); + +/** The layer + pointer position a canvas right-click resolved to. */ +export interface CanvasLayerContextMenuTarget { + layerId: string; + x: number; + y: number; +} + +/** + * The canvas-surface right-click menu: the SAME {@link LayerMenu}, anchored at the + * cursor via a 1×1 virtual rect (no trigger DOM), controlled by `target`. The + * canvas widget sets `target` to the hit layer + pointer position after selecting + * it; `null` closes the menu. The layer and its global index are resolved from + * `target.layerId` against the live layer list, so the shared items get the exact + * same inputs the panel passes. Keyed by layer id so switching target resets the + * menu's rename state. + * + * Choosing "Rename" closes the menu, which nulls `target`. The rename dialog lives + * inside {@link LayerMenu} (a sibling of the menu), so it must survive that: the + * wrapper owns the rename-in-flight flag and keeps rendering against the last-known + * (sticky) target until the dialog closes (F1). + */ +export const CanvasLayerContextMenu = ({ + dispatch, + engine, + layers, + target, + onClose, +}: { + dispatch: Dispatch; + engine: CanvasEngine | null; + layers: readonly CanvasLayerContract[]; + target: CanvasLayerContextMenuTarget | null; + onClose: () => void; +}) => { + // The layer a pending rename is anchored to. Captured when the dialog opens (the + // live `target` is still set then) and cleared when it closes — set only inside + // the rename callback, never during render, so the rename dialog survives the menu + // closing (which nulls `target`). + const [renameTarget, setRenameTarget] = useState(null); + const renderTarget = resolveMenuTargetForRender(target, renameTarget); + + const index = renderTarget ? layers.findIndex((entry) => entry.id === renderTarget.layerId) : -1; + const layer = index >= 0 ? layers[index] : undefined; + + const anchorX = renderTarget?.x ?? 0; + const anchorY = renderTarget?.y ?? 0; + const positioning = useMemo( + () => ({ + getAnchorRect: () => ({ height: 1, width: 1, x: anchorX, y: anchorY }), + placement: 'bottom-start', + }), + [anchorX, anchorY] + ); + const handleOpenChange = useCallback( + (details: { open: boolean }) => { + if (!details.open) { + onClose(); + } + }, + [onClose] + ); + const handleRenamingChange = useCallback( + (renaming: boolean) => { + setRenameTarget(renaming ? target : null); + }, + [target] + ); + + if (!renderTarget || !layer) { + return null; + } + + return ( + + ); +}; + +const stopPropagation = (event: { stopPropagation: () => void }): void => event.stopPropagation(); + +const LayerMenuItem = ({ + color, + disabled, + icon, + label, + onSelect, + value, +}: { + color?: string; + disabled?: boolean; + icon: LucideIcon; + label: string; + onSelect: () => void; + value: string; +}) => ( + + + + + {label} + + + +); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/layers/LayerGroupSection.tsx b/invokeai/frontend/webv2/src/workbench/widgets/layers/LayerGroupSection.tsx new file mode 100644 index 00000000000..4d11c90a0ea --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/layers/LayerGroupSection.tsx @@ -0,0 +1,307 @@ +import type { DragEndEvent } from '@dnd-kit/core'; +import type { CanvasEngine } from '@workbench/canvas-engine/engine'; +import type { CanvasLayerContract } from '@workbench/types'; +import type { WorkbenchAction } from '@workbench/workbenchState'; +import type { LucideIcon } from 'lucide-react'; +import type { Dispatch } from 'react'; + +import { Collapsible, HStack, Icon, Stack, Text } from '@chakra-ui/react'; +import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; +import { restrictToParentElement, restrictToVerticalAxis } from '@dnd-kit/modifiers'; +import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { canMergeVisibleRasters } from '@workbench/canvas-engine/document/mergeVisible'; +import { IconButton, toaster, Tooltip } from '@workbench/components/ui'; +import { useActiveProjectName } from '@workbench/WorkbenchContext'; +import { ChevronDownIcon, EyeIcon, EyeOffIcon, FileDownIcon, LayersIcon, PlusIcon } from 'lucide-react'; +import { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { LayerGroupKey } from './layerGroups'; + +import { groupAddItemId } from './addLayerMenu'; +import { canExportRasterPsd, getGroupActions, isGroupAllVisible, planGroupVisibilityToggle } from './layerGroupActions'; +import { reorderWithinGroup } from './layerGroups'; +import { LayerListItem } from './LayerListItem'; +import { applyStructural } from './layerOps'; +import { useAddLayer } from './useAddLayer'; + +const DND_MODIFIERS = [restrictToVerticalAxis, restrictToParentElement]; +const POINTER_SENSOR_OPTIONS = { activationConstraint: { distance: 6 } } as const; +const KEYBOARD_SENSOR_OPTIONS = { coordinateGetter: sortableKeyboardCoordinates } as const; + +interface LayerGroupSectionProps { + dispatch: Dispatch; + engine: CanvasEngine | null; + groupKey: LayerGroupKey; + groupLayers: readonly CanvasLayerContract[]; + isCollapsed: boolean; + layers: readonly CanvasLayerContract[]; + onToggleCollapse: (groupKey: LayerGroupKey) => void; + selectedLayerId: string | null; +} + +/** + * One collapsible type-group section: a header (chevron + name + count + a + * right-aligned action cluster) and, when expanded, the group's rows in a + * self-contained `DndContext`. Scoping DnD to the group means a drag can never see + * (or drop onto) another group's rows — cross-group moves are structurally + * impossible. Collapsing a group unmounts only its own rows, so drag-and-drop in + * the other (still-expanded) groups is unaffected. + */ +export const LayerGroupSection = ({ + dispatch, + engine, + groupKey, + groupLayers, + isCollapsed, + layers, + onToggleCollapse, + selectedLayerId, +}: LayerGroupSectionProps) => { + const { t } = useTranslation(); + + const sensors = useSensors( + useSensor(PointerSensor, POINTER_SENSOR_OPTIONS), + useSensor(KeyboardSensor, KEYBOARD_SENSOR_OPTIONS) + ); + + const globalIndexById = useMemo(() => { + const map = new Map(); + layers.forEach((layer, index) => map.set(layer.id, index)); + return map; + }, [layers]); + + const groupIds = useMemo(() => groupLayers.map((layer) => layer.id), [groupLayers]); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + if (!over) { + return; + } + const next = reorderWithinGroup(layers, String(active.id), String(over.id)); + if (!next) { + return; + } + applyStructural( + engine, + dispatch, + t('widgets.layers.actions.reorder'), + { orderedIds: next, type: 'reorderCanvasLayers' }, + { orderedIds: layers.map((layer) => layer.id), type: 'reorderCanvasLayers' } + ); + }, + [dispatch, engine, layers, t] + ); + + const handleToggleCollapse = useCallback(() => onToggleCollapse(groupKey), [groupKey, onToggleCollapse]); + + return ( + + + + + + + {t(`widgets.layers.groups.${groupKey}`)} ({groupLayers.length}) + + + + {/* unmountOnExit: collapsing must UNMOUNT the rows (not just hide them) so an + open per-row properties popover — and any control-layer filter preview it + hosts — is torn down with them (Task 38/39 cleanup contract). */} + + + + + + {groupLayers.map((layer) => ( + + ))} + + + + + + + ); +}; + +/** + * The right-aligned group-header action cluster. The set of actions is data + * (`getGroupActions`), so Task 44's PSD export button drops in by extending that + * array + this switch — no new prop wiring. + */ +const GroupActions = ({ + dispatch, + engine, + groupKey, + groupLayers, + layers, +}: { + dispatch: Dispatch; + engine: CanvasEngine | null; + groupKey: LayerGroupKey; + groupLayers: readonly CanvasLayerContract[]; + layers: readonly CanvasLayerContract[]; +}) => { + const { t } = useTranslation(); + const addLayer = useAddLayer(); + const projectName = useActiveProjectName(); + const allVisible = isGroupAllVisible(groupLayers); + // Enablement uses the SAME planner the engine op executes (`planMergeVisibleRuns` + // over the GLOBAL array — run-splitting depends on interleaved non-participants), + // so the button is enabled exactly when clicking it will merge something. + const canMerge = !!engine && groupKey === 'raster' && canMergeVisibleRasters(layers); + const canExport = !!engine && groupKey === 'raster' && canExportRasterPsd(layers); + + const handleNew = useCallback(() => addLayer(groupAddItemId(groupKey)), [addLayer, groupKey]); + + const handleToggleVisibility = useCallback(() => { + const { forward, inverse } = planGroupVisibilityToggle(groupLayers); + applyStructural( + engine, + dispatch, + t('widgets.layers.groupActions.toggleVisibility'), + { type: 'setCanvasLayersEnabled', updates: forward }, + { type: 'setCanvasLayersEnabled', updates: inverse } + ); + }, [dispatch, engine, groupLayers, t]); + + const handleMergeVisible = useCallback(() => { + // The engine folds ALL visible mergeable rasters (reorder + merge per step) + // and pre-flights every participant before touching pixels; 'not-ready' + // means a cache is still decoding — nothing was merged, so tell the user + // instead of silently half-working. Engine pixel work: not undoable, + // mirroring the per-row "merge down". + if (engine?.mergeVisibleRasterLayers() === 'not-ready') { + toaster.create({ title: t('widgets.layers.groupActions.mergeNotReady'), type: 'warning' }); + } + }, [engine, t]); + + const handleExportPsd = useCallback(() => { + if (!engine) { + return; + } + // Read-only: no dispatch, no history. The engine lazily loads `ag-psd`, + // bakes the raster layers, and triggers the download. Surface the refusal + // cases (still-loading caches / oversized bounds / nothing to export). + void engine.exportRasterLayersToPsd(projectName).then((result) => { + if (result === 'not-ready') { + toaster.create({ title: t('widgets.layers.groupActions.exportNotReady'), type: 'warning' }); + } else if (result === 'too-large') { + toaster.create({ title: t('widgets.layers.groupActions.exportTooLarge'), type: 'warning' }); + } else if (result === 'nothing') { + toaster.create({ title: t('widgets.layers.groupActions.exportNothing'), type: 'warning' }); + } + }); + }, [engine, projectName, t]); + + const actions = getGroupActions(groupKey); + + return ( + + {actions.map((action) => { + switch (action) { + case 'mergeVisible': + return ( + + ); + case 'exportPsd': + return ( + + ); + case 'toggleVisibility': + return ( + + ); + case 'new': + return ( + + ); + } + })} + + ); +}; + +const GroupActionButton = ({ + disabled, + icon, + label, + onClick, +}: { + disabled?: boolean; + icon: LucideIcon; + label: string; + onClick: () => void; +}) => ( + + + + + +); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/layers/LayerListItem.tsx b/invokeai/frontend/webv2/src/workbench/widgets/layers/LayerListItem.tsx new file mode 100644 index 00000000000..203cb8dff57 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/layers/LayerListItem.tsx @@ -0,0 +1,191 @@ +import type { CanvasEngine } from '@workbench/canvas-engine/engine'; +import type { CanvasLayerContract } from '@workbench/types'; +import type { WorkbenchAction } from '@workbench/workbenchState'; +import type { Dispatch, KeyboardEvent } from 'react'; + +import { Badge, Box, HStack, Input, Stack, Text } from '@chakra-ui/react'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { IconButton, Row, ToggleDot } from '@workbench/components/ui'; +import { LockIcon, LockOpenIcon } from 'lucide-react'; +import { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { LayerContextMenu } from './LayerContextMenu'; +import { applyStructural } from './layerOps'; +import { LayerPropertiesPopover } from './LayerPropertiesPopover'; +import { LayerThumbnail } from './LayerThumbnail'; + +/** i18n key for a layer's short type/source badge. */ +const layerBadgeKey = (layer: CanvasLayerContract): string => { + if (layer.type === 'raster') { + return layer.source.type === 'image' ? 'widgets.layers.types.image' : 'widgets.layers.types.paint'; + } + return `widgets.layers.types.${layer.type}`; +}; + +interface LayerListItemProps { + dispatch: Dispatch; + engine: CanvasEngine | null; + index: number; + isSelected: boolean; + layer: CanvasLayerContract; + layers: readonly CanvasLayerContract[]; +} + +/** + * One layer row: thumbnail, name (double-click to rename), type badge, + * visibility + lock toggles, a properties popover (blend mode + the layer's + * type-specific settings), and an overflow/context menu. The whole row is the + * drag target — no visible handle — with a pointer distance activation + * constraint so clicks, double-click rename, and the row buttons still work; + * the selected layer's opacity (and mask fill swatch) lives in the panel header. + */ +export const LayerListItem = ({ dispatch, engine, index, isSelected, layer, layers }: LayerListItemProps) => { + const { t } = useTranslation(); + const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id: layer.id }); + const [isEditing, setIsEditing] = useState(false); + const [draftName, setDraftName] = useState(layer.name); + + const dndStyle = useMemo( + () => ({ + opacity: isDragging ? 0.4 : undefined, + position: 'relative' as const, + transform: CSS.Translate.toString(transform), + transition, + zIndex: isDragging ? 1 : undefined, + }), + [isDragging, transform, transition] + ); + + const handleSelect = useCallback(() => { + if (!isSelected) { + dispatch({ id: layer.id, type: 'setCanvasSelectedLayer' }); + } + }, [dispatch, isSelected, layer.id]); + + const patchBase = useCallback( + (label: string, forward: Partial, inverse: Partial) => { + applyStructural( + engine, + dispatch, + label, + { id: layer.id, patch: forward, type: 'updateCanvasLayer' }, + { id: layer.id, patch: inverse, type: 'updateCanvasLayer' } + ); + }, + [dispatch, engine, layer.id] + ); + + const handleToggleVisible = useCallback( + (checked: boolean) => { + patchBase(t('widgets.layers.actions.toggleVisibility'), { isEnabled: checked }, { isEnabled: layer.isEnabled }); + }, + [layer.isEnabled, patchBase, t] + ); + + const handleToggleLock = useCallback( + (event: { stopPropagation: () => void }) => { + event.stopPropagation(); + patchBase(t('widgets.layers.actions.toggleLock'), { isLocked: !layer.isLocked }, { isLocked: layer.isLocked }); + }, + [layer.isLocked, patchBase, t] + ); + + const startEditing = useCallback(() => { + setDraftName(layer.name); + setIsEditing(true); + }, [layer.name]); + + const commitName = useCallback(() => { + setIsEditing(false); + const name = draftName.trim(); + if (name && name !== layer.name) { + patchBase(t('widgets.layers.actions.rename'), { name }, { name: layer.name }); + } + }, [draftName, layer.name, patchBase, t]); + + const handleNameKeyDown = useCallback( + (event: KeyboardEvent) => { + // Keep every keystroke inside the rename input. The whole row carries + // dnd-kit keyboard `listeners` whose activator claims Space/Enter (and + // preventDefaults them) to arm a keyboard drag — without this guard, typing + // a space in a layer name is eaten and can start a drag. Stop propagation + // for ALL keys (not just the two we handle) so nothing bubbles to the row. + event.stopPropagation(); + if (event.key === 'Enter') { + commitName(); + } else if (event.key === 'Escape') { + setIsEditing(false); + } + }, + [commitName] + ); + + const handleNameChange = useCallback((event: { target: { value: string } }) => setDraftName(event.target.value), []); + + return ( + + + + + + {isEditing ? ( + + ) : ( + + {layer.name} + + )} + + {t(layerBadgeKey(layer))} + + + + + + + {layer.isLocked ? : } + + + + + + + + + + + ); +}; + +const stopPropagation = (event: { stopPropagation: () => void }): void => event.stopPropagation(); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/layers/LayerPropertiesPopover.tsx b/invokeai/frontend/webv2/src/workbench/widgets/layers/LayerPropertiesPopover.tsx new file mode 100644 index 00000000000..28e5a59fe20 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/layers/LayerPropertiesPopover.tsx @@ -0,0 +1,208 @@ +import type { SelectValueChangeDetails } from '@chakra-ui/react'; +import type { CanvasEngine } from '@workbench/canvas-engine/engine'; +import type { CanvasBlendMode, CanvasLayerContract } from '@workbench/types'; +import type { WorkbenchAction } from '@workbench/workbenchState'; +import type { Dispatch } from 'react'; + +import { createListCollection, Popover, Portal, Stack, Switch, Text } from '@chakra-ui/react'; +import { Field, IconButton, Select } from '@workbench/components/ui'; +import { SlidersHorizontalIcon } from 'lucide-react'; +import { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { AdjustmentsPopover } from './AdjustmentsPopover'; +import { ControlLayerSettings } from './ControlLayerSettings'; +import { InpaintMaskSettings } from './InpaintMaskSettings'; +import { applyStructural, CANVAS_BLEND_MODES } from './layerOps'; +import { RegionalGuidanceSettings } from './RegionalGuidanceSettings'; + +const POPOVER_POSITIONING = { placement: 'left-start' } as const; +const SELECT_POSITIONING = { placement: 'bottom-end', sameWidth: false } as const; + +const stopPropagation = (event: { stopPropagation: () => void }): void => event.stopPropagation(); + +interface BlendModeOption { + label: string; + value: CanvasBlendMode; +} + +interface LayerPropertiesPopoverProps { + dispatch: Dispatch; + engine: CanvasEngine | null; + layer: CanvasLayerContract; +} + +/** + * The per-layer "properties/configure" affordance (round-3 restructure): a sliders + * IconButton on each row that opens a popover holding that layer's type-specific + * settings — control adapter/filter, regional prompts/ref-images, inpaint mask + * fill/noise, or raster adjustments — plus its blend mode. Previously these were + * stacked in the panel header for the *selected* layer; moving them into a per-row + * popover keeps the header slim and each layer's config next to the layer. + * + * Content is mounted ONLY while open (`lazyMount` + `unmountOnExit`, the codebase + * convention) — unmount-on-close is what fires `ControlLayerSettings`' filter-preview + * ref-callback teardown, so a previewed filter can never outlive a closed popover + * (Task 38 lesson). The type-specific settings are additionally `key`ed on + * `layer.id` (Task 39 lesson: the cleanup relies on a fresh instance per layer). + */ +export const LayerPropertiesPopover = ({ dispatch, engine, layer }: LayerPropertiesPopoverProps) => { + const { t } = useTranslation(); + + return ( + + + + + + + + + + + + + + + + + + + + ); +}; + +/** The layer's blend-mode select (moved out of the panel header, per legacy). */ +const LayerBlendModeControl = ({ + dispatch, + engine, + layer, +}: { + dispatch: Dispatch; + engine: CanvasEngine | null; + layer: CanvasLayerContract; +}) => { + const { t } = useTranslation(); + + const blendCollection = useMemo( + () => + createListCollection({ + items: CANVAS_BLEND_MODES.map((mode) => ({ label: t(`widgets.layers.blendModes.${mode}`), value: mode })), + }), + [t] + ); + + const blendValue = useMemo(() => [layer.blendMode], [layer.blendMode]); + + const handleBlendChange = useCallback( + ({ value }: SelectValueChangeDetails) => { + const mode = value[0] as CanvasBlendMode | undefined; + if (mode && mode !== layer.blendMode) { + applyStructural( + engine, + dispatch, + t('widgets.layers.actions.blendMode'), + { id: layer.id, patch: { blendMode: mode }, type: 'updateCanvasLayer' }, + { id: layer.id, patch: { blendMode: layer.blendMode }, type: 'updateCanvasLayer' } + ); + } + }, + [dispatch, engine, layer.blendMode, layer.id, t] + ); + + return ( + +