diff --git a/docs/src/content/docs/Users Guide/04.Image Generation/generate-panel.mdx b/docs/src/content/docs/Users Guide/04.Image Generation/generate-panel.mdx index 1ec9cbe5742..13bd95ffba6 100644 --- a/docs/src/content/docs/Users Guide/04.Image Generation/generate-panel.mdx +++ b/docs/src/content/docs/Users Guide/04.Image Generation/generate-panel.mdx @@ -1,7 +1,7 @@ --- title: Generate Panel Reference description: Every section of the Generate panel in detail — size, render settings, negative prompts, advanced options, resetting to defaults, concepts, reference images and the prompt tools. -lastUpdated: 2026-09-30 +lastUpdated: 2026-10-19 sidebar: order: 1 --- @@ -17,7 +17,13 @@ The **Size** section sets the width and height of the image. * **Width** and **Height** can be set by dragging, by typing a value, or by dragging the corner of the small size preview. Sizes run from 64 to 4096 pixels and snap to a multiple the model requires, usually 8, 16 or 32. * **Aspect ratio** offers common shapes: 21:9, 16:9, 3:2, 4:3, 1:1, 3:4, 2:3, 9:16 and 9:21, or **Free**. Choosing a ratio locks it, so changing the width changes the height to match. The lock button between the two fields locks or unlocks the current ratio. * **Swap width and height** turns a landscape image into a portrait one and back. -* Each model has an optimal image area, typically about one megapixel for newer models and less for SD 1.5. The line under the fields shows the image's size in megapixels, marks the recommended size, and estimates the run time from your recent runs. **Set optimal size** resizes the image to the model's optimal area at the current aspect ratio. +* Each model has an optimal image area, typically about one megapixel for newer models and less for SD 1.5. The line under the fields shows the image's size in megapixels, marks the recommended size, and estimates the run time from your recent runs. +* **Reset size**, the button beside **Swap width and height**, opens a menu. Each item shows the size it sets, and the item matching the current size is checked; choosing it again changes nothing: + * **Square at native size** — a 1:1 square at the model's native size, such as 1024×1024 for SDXL and FLUX or 512×512 for SD 1.5, with the ratio locked. + * **Optimal size for** the current ratio — the model's optimal area at the current aspect ratio. It is absent at 1:1, where it would be the native square. + * **Model defaults** — the size set in the model's default settings. It appears only when the model sets one. + + Right-clicking the width and height and choosing **Reset to model default** also resets the size this way. On the Canvas, these resets keep the generation frame centered on the same point; typing or dragging a new width or height resizes the frame from its top-left corner. Size resets are not canvas edits, so they do not appear in the canvas's undo history. Models produce their best results near their optimal size. Much larger images can show repeated subjects or distorted anatomy. To make a large image, generate at the optimal size and then upscale. diff --git a/docs/src/content/docs/Users Guide/04.Image Generation/parameters.mdx b/docs/src/content/docs/Users Guide/04.Image Generation/parameters.mdx index 377e5aeb789..8562ba2e3cf 100644 --- a/docs/src/content/docs/Users Guide/04.Image Generation/parameters.mdx +++ b/docs/src/content/docs/Users Guide/04.Image Generation/parameters.mdx @@ -1,7 +1,7 @@ --- title: Parameter Selection Guide description: How to choose steps, CFG or guidance, schedulers, seeds, size and denoising strength for the model you are using, with a historical study of sampler convergence. -lastUpdated: 2026-09-30 +lastUpdated: 2026-10-19 sidebar: order: 5 --- @@ -54,7 +54,7 @@ The seed sets the starting noise, so the same model, prompt, seed and settings r ## Size -Each model has an optimal image area, around one megapixel for most current models and less for SD 1.5. Results are best near that size; much larger images can repeat subjects or distort anatomy. Use **Set optimal size** in the Size section, and upscale afterwards for larger images. +Each model has an optimal image area, around one megapixel for most current models and less for SD 1.5. Results are best near that size; much larger images can repeat subjects or distort anatomy. Use **Reset size → Optimal size for** *ratio* (for example **Optimal size for 3:2**) in the Size section, and upscale afterwards for larger images. At 1:1 that item is absent; use **Square at native size**, the model's optimal square. ## Denoising strength diff --git a/docs/src/content/docs/Users Guide/07.Canvas/01.introduction.mdx b/docs/src/content/docs/Users Guide/07.Canvas/01.introduction.mdx index 4c8733dd1ba..aa990fc6986 100644 --- a/docs/src/content/docs/Users Guide/07.Canvas/01.introduction.mdx +++ b/docs/src/content/docs/Users Guide/07.Canvas/01.introduction.mdx @@ -179,4 +179,4 @@ There are three ways to bring an existing image onto the canvas: * **Use the image's menu.** Right-click an image in the Gallery or Preview and choose **Add to Current Canvas**, then the kind of layer. **New Project from Image** instead starts a new project whose canvas holds the image, sized to fit it; the current project is left as it is. * **Paste it.** Copy an image, click the canvas and press **Ctrl+V** (**Cmd+V** on macOS). It is added as a new raster layer at the generation frame. -A new raster layer from an image is placed at its full size at the top-left of the generation frame. Use the **Move** (**V**) or **Transform** (**Ctrl+T**) tool to position and scale it. +A new raster layer from an image is placed at its full size at the top-left of the generation frame. Use the **Move** (**V**) or **Transform** (**Shift+T**) tool to position and scale it. diff --git a/docs/src/content/docs/Users Guide/07.Canvas/02.canvas-guide.mdx b/docs/src/content/docs/Users Guide/07.Canvas/02.canvas-guide.mdx index 785bafa34f1..ec7d2f7bd3c 100644 --- a/docs/src/content/docs/Users Guide/07.Canvas/02.canvas-guide.mdx +++ b/docs/src/content/docs/Users Guide/07.Canvas/02.canvas-guide.mdx @@ -24,20 +24,27 @@ The tool strip on the left edge of the canvas holds the drawing and editing tool |---|---|---| | **View** | **H** | Pan by dragging. Hold **Space** to pan from any tool. | | **Move** | **V** | Move the selected layers. **Shift** locks the direction. Dragging inside a selection lifts the selected pixels so you can move them; **Enter** places them. | -| **Transform** | **Ctrl+T** | Scale and rotate the selected layer with handles. See [Transforming layers](#transforming-layers). | +| **Transform** | **Shift+T** | Scale and rotate the selected layer with handles. See [Transforming layers](#transforming-layers). | | **Generation frame** | **C** | Move and resize the generation frame. Tap **C** to switch to it, or hold **C** to use it briefly. | -| **Brush** | **B** | Paint on the selected raster layer, or paint a mask or region on the selected inpaint mask or regional guidance layer. | -| **Eraser** | **E** | Erase paint or mask. | +| **Brush** | **B** | Paint on the selected raster layer, or paint a mask or region on the selected inpaint mask or regional guidance layer. **Shift**-click draws a straight line from the end of the last stroke on that layer. | +| **Eraser** | **E** | Erase paint or mask. **Shift**-click erases a straight line, as with the Brush. | | **Shape** | **R** | Draw a rectangle, ellipse, triangle, star, polygon or freehand shape on a raster layer, or add a shaped area to the selected inpaint mask or regional guidance layer. Press **R** again to cycle through the shapes. | | **Gradient** | **G** | Create a linear or radial gradient. | | **Text** | **T** | Add editable text. | | **Marquee select** / **Lasso select** | **U** / **L** | Select an area of pixels. Press **U** again to switch between rectangle and ellipse, or **L** again for freehand and polygon. | -The Shape and Select buttons hold several tools. Click to use the current one, or press and hold (or right-click, or press **→** on) the button to choose another from a menu that lists each tool with its shortcut. To pick a color from the canvas, hold **Alt** with any painting tool and click. While you pick, a round **loupe** follows the pointer: it magnifies the pixels around it, boxes the pixel that will be picked in the center, and shows that pixel's RGB values. The pointer itself is hidden, so aim with the boxed pixel. You can drag to sample as you go; the color is set when you release. +The Shape and Select buttons hold several tools. Click to use the current one, or press and hold (or right-click, or press **→** on) the button to choose another from a menu that lists each tool with its shortcut. To pick a color from the canvas, hold **Alt** with any painting tool and click. While you pick, a round **loupe** follows the pointer: it magnifies the pixels around it, boxes the pixel that will be picked in the center, and shows that pixel's RGB values. The pointer itself is hidden, so aim with the boxed pixel. You can drag to sample as you go; the color is set when you release, and **Escape** cancels the pick and restores the previous color. The picked color goes to what you are editing: + +* the selected mask's tint, while **Mask tint** is chosen in the [Color](/users-guide/canvas/layers-panel/#color) tab; +* the selected shape layer's fill when the foreground chip is active, or its stroke when the background chip is active. A pick never turns on a fill or stroke the shape doesn't have; it sets the active color instead; +* the selected text layer's color; +* otherwise the active foreground or background color. + +A pick that changes a layer is one undoable step. Gradient stops are not picked into; with the Gradient tool, the pick sets the foreground or background color. ### Tool options -* **Brush** — **Size**, **Opacity** and **Hardness**; the **Foreground** color; and, under **Dynamics**, whether pen pressure affects the width or the opacity. **[** and **]** make the brush smaller or larger, and so does **Ctrl**+scroll. +* **Brush** — **Size**, **Opacity** and **Hardness**; the **Foreground** color; and, under **Dynamics**, whether pen pressure affects the width or the opacity. **[** and **]** make the brush smaller or larger, and so does **Ctrl**+scroll. Painting on an image layer, such as an accepted result or an added picture, paints on that layer itself instead of adding a new one; one undo restores the original image. **Shift**-click joins the click to the end of the last Brush or Eraser stroke on the same layer with a straight line. The line follows the selection and the frame clipping like any stroke, and one undo removes it. Undo, redo or deleting the layer clears the remembered point, so the next **Shift**-click paints a dot. * **Eraser** — **Size**, **Opacity** and **Hardness**; and, under **Dynamics**, whether pen pressure affects the width. Pressure never changes how strongly the eraser erases. Eraser settings are temporary and are not saved in the project; they can reset when the canvas is released or the page reloads. * **Shape** — the **Kind** of shape; **Place on**, which draws on the **Selected layer** or on a **New layer** of its own; **Fill** and **Stroke**, each with a color; and the stroke **Width**. **Shift** constrains the proportions. For polygons, click to place points and double-click, press **Enter** or click the first point to close the shape. With an inpaint mask or regional guidance layer selected, the shape is added to the mask or region, which makes shapes a quick way to mark rectangular or geometric areas; the colors are ignored. * **Gradient** — **Kind** (Linear or Radial), **Angle**, and the color **Stops**, which can be added and removed or follow the foreground and background colors. Dragging creates a gradient layer the size of the generation frame, or repositions the selected gradient layer. @@ -60,7 +67,7 @@ In InvokeAI 6, the Lasso drew directly into an inpaint mask, and created one if ## Navigating the canvas -* **Zoom** with the mouse wheel, which zooms around the pointer, or choose a level from the zoom menu in the header (25% to 500%). +* **Zoom** with the mouse wheel, which zooms around the pointer, or choose a level from the zoom menu in the header (25% to 500%). **Ctrl+1**, **Ctrl+2**, **Ctrl+3** and **Ctrl+4** zoom to 100%, 200%, 400% and 800% around the center of the view. Many browsers use these keys to switch tabs and do not pass them to the page; there, choose **Zoom to 100%**, **Zoom to 200%**, **Zoom to 400%** or **Zoom to 800%** from the command palette (**Ctrl+K**), or assign other keys in **Settings → Hotkeys**. * **Pan** with the View tool, by holding **Space**, or by dragging with the middle mouse button. * **Fit to view** (header) fits the generation frame and all layers into the window. @@ -69,10 +76,10 @@ In InvokeAI 6, the Lasso drew directly into an inpaint mask, and created one if The buttons along the top of the canvas, from left to right: * **Zoom level** and **Fit to view**. -* **Fit frame to layers** (**Shift+N**) — resizes the generation frame to enclose all layers. -* **Fit frame to masks** (**Shift+B**) — resizes the generation frame to enclose the inpaint masks. +* **Fit frame to layers** (**Shift+N**) — resizes the generation frame to the area all layers cover. With **Snap to grid** on, the frame then shrinks inward to the model's grid, so it can trim up to one grid step less one pixel from each edge of the layers. +* **Fit frame to masks** (**Shift+B**) — resizes the generation frame to enclose the inpaint masks, with a margin of the **Mask blur** (in the Generate panel's **Compositing** section) plus 8 pixels, so the blurred edge of the mask stays inside the frame. With the default blur of 16, the margin is 24 pixels. With **Snap to grid** on, the frame then grows outward to the model's grid, so the margin can be wider but never narrower. * **Undo canvas edit** (**Ctrl+Z**) and **Redo canvas edit** (**Ctrl+Shift+Z** or **Ctrl+Y**). -* **Save Canvas to Gallery** — saves the visible canvas as an image on the board new results go to: the auto-add board, or the selected board when none is set (see [Choosing where new media goes](/users-guide/gallery/introduction/#choosing-where-new-media-goes)). Its arrow offers **Save BBox to Gallery**, which saves only what is inside the generation frame. A message names the board the image went to; its **Show in Gallery** button selects the image in the Gallery and keeps the Canvas in the center. +* **Save Canvas to Gallery** — saves the visible canvas as an image on the board new results go to: the auto-add board, or the selected board when none is set (see [Choosing where new media goes](/users-guide/gallery/introduction/#choosing-where-new-media-goes)). Its arrow offers **Save Frame to Gallery**, which saves only what is inside the generation frame. A message names the board the image went to; its **Show in Gallery** button selects the image in the Gallery and keeps the Canvas in the center. * **Clear canvas** (**Ctrl+Alt+N**, **Cmd+Option+N** on macOS) — after you confirm, clears all layers and starts a fresh canvas with an empty inpaint mask. This cannot be undone. * **Canvas settings** — see [below](#canvas-settings). @@ -100,7 +107,7 @@ The frame is the final footprint of the generation on the canvas. With **Render ## Transforming layers -Choose **Transform** from a layer's menu, or press **Ctrl+T**, to scale and rotate the selected layer: +Choose **Transform** from a layer's menu, or press **Shift+T**, to scale and rotate the selected layer. The handles appear at once, even when the Transform tool is already active; if a brush stroke is still being saved, the tool switches straight away and the handles appear when the stroke is saved. Transform works on enabled raster and control layers with content. * Drag a corner or edge handle to scale. **Shift** scales proportionally and **Alt** scales from the center. * Drag just outside a corner to rotate. **Shift** rotates in 15° steps. @@ -108,7 +115,7 @@ Choose **Transform** from a layer's menu, or press **Ctrl+T**, to scale and rota Press **Enter** or click **Apply** to finish, or **Escape** or **Cancel** to undo the transform. The **Transform** tab of the Layers panel sets the position, scale and rotation numerically. A negative scale flips the layer. -To crop a layer to the generation frame, choose **Crop layer to bbox** from its menu. **Fit to bbox** scales a layer to fit inside the frame. +To crop a layer to the generation frame, choose **Crop layer to frame** from its menu; a layer entirely outside the frame is left alone. **Fit to frame** scales a layer to fit inside the frame. To crop every layer at once, use **Crop all layers to frame** in the [canvas menu](#the-canvas-menu). ## Filters and object selection @@ -151,16 +158,19 @@ When the Canvas is the destination, results appear in the staging area at the bo Right-click a result's thumbnail to recall its settings into the Generate panel. While results are staged or a canvas generation is running, only the View tool works. +While a progress image or a staged result is shown, and while results stay staged with their preview hidden for comparison, inpaint masks, regional guidance and regenerate regions are hidden from view so they do not cover the result. They still take part in generation, and they reappear when you accept or discard the results. + ## The canvas menu -Right-click the canvas to open the menu of the selected layer, the same menu as in the Layers panel (see the [Layers panel guide](/users-guide/canvas/layers-panel/#the-layer-menu)), with two extra submenus: +Right-click the canvas to open the menu of the selected layer, the same menu as in the Layers panel (see the [Layers panel guide](/users-guide/canvas/layers-panel/#the-layer-menu)), with an extra **Canvas** section: -The canvas right-click menu for a raster layer, with the layer actions followed by a Canvas section with Save to Gallery and Create From BBox. +The canvas right-click menu for a raster layer, with the layer actions followed by a Canvas section with Save to Gallery and Create From Frame. -* **Save to Gallery** — **Save Canvas to Gallery** or **Save BBox to Gallery**. -* **Create From BBox** — copy what is inside the generation frame into a **New Global Reference Image**, **New Regional Reference Image**, **New Control Layer** or **New Raster Layer**. +* **Save to Gallery** — **Save Canvas to Gallery** or **Save Frame to Gallery**. +* **Create From Frame** — copy what is inside the generation frame into a **New Global Reference Image**, **New Regional Reference Image**, **New Control Layer** or **New Raster Layer**. +* **Crop all layers to frame** — crops every raster layer, control layer, inpaint mask and regional guidance layer to the generation frame, including hidden and disabled layers and layers inside groups, as one undoable step. Groups, layer order and visibility are unchanged. A layer entirely outside the frame is emptied rather than deleted. Locked layers, and layers in a locked group, are skipped, and so are layers whose image could not be read, for example because it was deleted from the Gallery; the other layers are still cropped. If an image cannot be fetched for now, for example while the server restarts, nothing is cropped and the notice asks you to try again. A brush stroke still being saved is saved first. A notice says how many layers were cropped, counting only the ones that changed, and how many locked layers were skipped; it names a layer that could not be read and counts any others. When every layer is locked, or there are no layers, the item is unavailable; highlighting it shows why. -With no layer selected, the menu has only these two submenus. +With no layer selected, the menu has only the Canvas section. ## Canvas settings @@ -170,11 +180,11 @@ The gear button in the header opens the canvas settings. They are saved with the * **Behavior** * **Invert scroll for brush size** — reverses **Ctrl**+scroll brush sizing. - * **Clip strokes to bbox** — keeps painting inside the generation frame. + * **Clip strokes to frame** — keeps painting inside the generation frame. * **Output only generated regions** — when on, an inpainting or outpainting result contains only the regenerated area, with the rest transparent; when off, it includes the whole frame. * **Display** * **Show progress on canvas** — shows the image forming while it generates. - * **Show bbox overlay** — dims everything outside the generation frame. + * **Dim outside frame** — dims everything outside the generation frame. * **Show generation frame** — shows the frame's dashed outline. * **Checkerboard background** — shows transparent areas as a checkerboard. * **Grid** @@ -184,7 +194,7 @@ The gear button in the header opens the canvas settings. They are saved with the ## Saving and sharing a canvas -The canvas belongs to its project and is saved with it automatically. To keep a finished image, use **Save Canvas to Gallery** or **Save BBox to Gallery**, or accept results and save individual layers to a board with **Save layer to board**. To share a whole canvas, with its layers, masks and settings, export the project as an `.invk` file; see [Projects](/users-guide/projects/introduction/#exporting-and-importing). +The canvas belongs to its project and is saved with it automatically. To keep a finished image, use **Save Canvas to Gallery** or **Save Frame to Gallery**, or accept results and save individual layers to a board with **Save layer to board**. To share a whole canvas, with its layers, masks and settings, export the project as an `.invk` file; see [Projects](/users-guide/projects/introduction/#exporting-and-importing). ## Copying and pasting @@ -196,9 +206,11 @@ The canvas belongs to its project and is saved with it automatically. To keep a These shortcuts work while the canvas has focus. On macOS, use **Cmd** in place of **Ctrl**. +The tool keys, **Shift+T**, **[** and **]**, and **X** and **D** also work after you click in the Layers panel or its Color pane, so you can pick a layer or a color and switch tools without clicking back on the canvas. **Escape** and holding or tapping **C** work from the Layers panel too, but **Enter** applies a transform, text or filter only while the canvas has focus. None of these keys act while you type in a text field. + | Shortcut | Action | |---|---| -| **H**, **V**, **Ctrl+T**, **C**, **B**, **E**, **R**, **G**, **T**, **U**, **L** | Select a tool (see [Tools](#tools)) | +| **H**, **V**, **Shift+T**, **C**, **B**, **E**, **R**, **G**, **T**, **U**, **L** | Select a tool (see [Tools](#tools)) | | Hold **Space** | Pan temporarily | | Hold **Alt** | Pick a color | | **[** / **]** | Smaller / larger brush or eraser | @@ -210,12 +222,13 @@ These shortcuts work while the canvas has focus. On macOS, use **Cmd** in place | **Shift+C** | Clear the selected mask | | Arrow keys (**Shift**: 10 px) | Nudge the selected layer | | **Ctrl+J** / **Ctrl+E** | Duplicate layer / merge layer down | -| **Ctrl+G** / **Ctrl+Shift+G** | Group / ungroup layers | +| **Ctrl+G** / **Ctrl+Shift+G** | Group / ungroup layers. With layers of one group selected, **Ctrl+Shift+G** moves them out of the group | | **Ctrl+]** / **Ctrl+[** (add **Shift** for front / back) | Move layer forward / backward | | **Alt+[** / **Alt+]** | Previous / next layer (wrapping, in the order of the Layers panel), or staged result while results are staged | | **Q** | Switch to the layer bookmarked with **Bookmark for quick switch** in its menu, or back to the previously selected layer | | **Shift+H** | Hide control, guidance and mask layers | | **Shift+N** / **Shift+B** | Fit the generation frame to the layers / to the masks | +| **Ctrl+1** / **Ctrl+2** / **Ctrl+3** / **Ctrl+4** | Zoom to 100% / 200% / 400% / 800%, where the browser passes these keys to the page | | **Enter** / **Escape** | Apply / cancel a transform, text or filter | Some canvas commands listed in **Settings → Hotkeys** are marked *Pending*; they are not available yet. diff --git a/docs/src/content/docs/Users Guide/07.Canvas/03.layers-panel.mdx b/docs/src/content/docs/Users Guide/07.Canvas/03.layers-panel.mdx index 767ac24af25..b9c0b9392c3 100644 --- a/docs/src/content/docs/Users Guide/07.Canvas/03.layers-panel.mdx +++ b/docs/src/content/docs/Users Guide/07.Canvas/03.layers-panel.mdx @@ -30,6 +30,10 @@ The **Color** tab edits the two painting colors: the **Foreground color**, which * **Pick a color from the canvas** — the eyedropper. You can also hold **Alt** on the canvas. A magnifying loupe shows the pixel under the pointer and its RGB values; see the [Canvas guide](/users-guide/canvas/canvas-guide/#tools). * When an inpaint mask or regional guidance layer is selected, a third chip, **Mask tint**, edits the color of its overlay. +Picking with **Alt** on the canvas writes to what you are editing: the mask tint while **Mask tint** is chosen, the selected shape layer's fill (foreground chip) or stroke (background chip), the selected text layer's color, and otherwise the active foreground or background color. A pick never turns on a fill or stroke the shape does not have: on such a shape it sets the active foreground or background color instead. A pick that changes a layer is one undoable step. The eyedropper button sets the mask tint while **Mask tint** is chosen, and otherwise the active foreground or background color. + +The canvas's tool keys, such as **B** for the Brush, and **[**, **]**, **X** and **D**, also work while the Layers panel or the Color tab has focus, except while you type in a field. + The **Swatches** tab has built-in **Swatches**, your **Recent colors**, and the **Project palette**: click **+** (**Add current color to palette**) to save a color with the project, and right-click a swatch to remove it. ## Layers @@ -73,21 +77,23 @@ The history keeps the last 64 steps. It lasts only while the project is open in Right-click a layer, or select it and press **Shift+F10**, for its menu. Right-clicking the canvas opens the same menu for the selected layer. -The menu of a raster layer, with Transform, Rename layer, Fit to bbox, Add adjustment, Add regenerate region, Filter, Select object, Merge down, Copy to, Convert to, Crop layer to bbox, Save layer to board, Disable layer, Lock layer, Bookmark for quick switch, Color label and Delete layer. +The menu of a raster layer, with Transform, Rename layer, Fit to frame, Add adjustment, Add regenerate region, Filter, Select object, Merge down, Copy to, Convert to, Crop layer to frame, Save layer to board, Disable layer, Lock layer, Bookmark for quick switch, Color label and Delete layer. The icons at the top **Arrange** the layer (move it to the front, forward, backward or to the back), **Duplicate layer** and **Group layers**. Below them: * **For every layer:** - * **Rename layer** and **Fit to bbox** (scale the layer to fit inside the generation frame). + * **Rename layer** and **Fit to frame** (scale the layer to fit inside the generation frame). * **Merge down** — combines the layer with the one below. Each layer's adjustments are baked into the merged pixels; undo restores both layers with their adjustments. * **Copy to** — including **Copy layer to clipboard**, and copies to other layer types. - * **Crop layer to bbox** and **Save layer to board** (save it to the Gallery as an upload, in Uncategorized or a board you pick from its submenu). + * **Crop layer to frame** — keeps only the part of the layer inside the generation frame. It refuses, saying why, a layer that is locked or inside a locked group, a layer that lies entirely outside the frame (cropping would leave it empty), and a layer whose image could not be read, for example because it was deleted from the Gallery. A cropped control layer keeps its **Type**, but its cropped map becomes its image: the original it was detected from is no longer kept, and **Detection** changes to **None**. + * **Save layer to board** — saves the layer to the Gallery as an upload, in Uncategorized or a board you pick from its submenu. * **Disable layer**, **Lock layer**, **Bookmark for quick switch** and **Color label**. A bookmarked layer is where **Q** switches on the canvas; press **Q** again to return. The bookmark is temporary and is not saved in the project. It can reset when the canvas is released, for example after switching away from its project, and resets on reload. + * **Remove from group** — for a layer inside a group: moves it out, directly above the group. With several layers selected, it appears when they are all in the same group, and moves them together in their current order. The emptied group stays. * **Delete layer**. * **Raster layers:** - * **Transform**. + * **Transform** (**Shift+T**) — shows the transform handles at once. It works on enabled raster and control layers with content. * **Add adjustment** — Brightness/Contrast, Exposure, Levels, Curves, Saturation, Hue, Invert, Texture, Clarity, Dehaze or Noise. Adjustments are non-destructive and appear as child rows. Texture, Clarity and Dehaze change detail and contrast; Noise adds film grain. - * **Add regenerate region** — treats the layer's own shape as an inpaint mask. + * **Add regenerate region** — regenerates where the layer has content, as a live inpaint mask: everything on the layer marks the area to regenerate, and it follows the layer as you keep editing it. The menu shows this description under the item. * **Filter**, **Select object** and **Run workflow** — process the layer; see the [Canvas guide](/users-guide/canvas/canvas-guide/#filters-and-object-selection). * **Use as control layer** — copies the layer's pixels to a new control layer, whose Properties then ask for its **Type**. * **Boolean operations** — combine the layer with the one below: intersect, cut out, cut away or exclude. @@ -96,7 +102,7 @@ The icons at the top **Arrange** the layer (move it to the front, forward, backw * **Inpaint masks:** **Add noise**, **Add denoise limit**, **Extract masked area** (copy the masked part of the image to a new layer), and **Hide layer**. * **Regional guidance:** **Enable auto-negative**, **Add reference image**, and **Hide layer**. -When several layers are selected, the menu also offers **Merge selected layers**. +When several layers are selected, the menu also offers **Merge selected layers**. When only inpaint masks are selected, it offers **Merge masks** instead: the masks are combined into one new inpaint mask, named after the topmost one with "merged" added. It covers everywhere any of the masks did, at full strength whatever each mask's opacity, and takes the topmost mask's fill color and style. The new mask is placed where the topmost one was, even when the masks are in different groups, and replaces them. Merge masks needs two or more enabled, unlocked inpaint masks with content, whose noise and denoise limit settings match, since the new mask applies one setting to all of its area. When the item is unavailable, highlighting it shows why. One undo brings the original masks back. ## Properties @@ -119,6 +125,8 @@ The Layer section is dimmed while results are being staged or a canvas generatio * **Lock transparency** — paint only where the layer already has pixels, leaving transparent areas transparent, like Photoshop's "Lock transparent pixels". Use it to recolor a shape without spilling paint around it, or to refine a subject painted on a transparent layer without growing its outline. Brushes, shapes and pen input all respect the lock. * **Filter** — start the Filter operation. +The Brush and Eraser paint directly on a selected image layer, such as an accepted result or an added picture, rather than adding a new layer. The first stroke turns it into a paint layer, with its position, scale and adjustments applied to its pixels; one undo restores the original image layer. + Adjustment child rows each have their own settings, such as **Brightness** and **Contrast**, **Exposure**, **Levels** and **Curves** with a channel selector, **Saturation**, and **Hue**. A regenerate region has a **Fill color** and **Fill style**. **Noise** adds film grain with these settings: @@ -152,7 +160,7 @@ Their settings: Things to know about Texture, Clarity and Dehaze: * **They are computed in the background.** Each one looks at the whole layer, so the canvas computes it in the background and stays responsive. Meanwhile, the canvas shows the previous result, or the layer without them after you paint on it. A small spinner appears on the layer's row when the work takes more than a moment. -* **Size limit.** They cannot run on content larger than 4096 × 4096 px (16.8 megapixels, measured over the area the layer's visible content spans, in the layer's own pixels before any scaling; for a group, at document resolution). A warning icon then appears on the layer's or group's row, the canvas leaves them out, and anything that uses the layer's pixels with them — generating, exporting, merging, rasterizing, filtering, copying or converting to a control layer, saving to PSD, painting on an image layer — is refused with a message naming the layer or group. Disable or remove them, or crop the content. The same warning appears when there is not enough canvas memory or the computation fails; hover over it for what to do, or select the adjustment to read it in Properties. +* **Size limit.** They cannot run on content larger than 4096 × 4096 px (16.8 megapixels, measured over the area the layer's visible content spans, in the layer's own pixels before any scaling; for a group, at document resolution). A warning icon then appears on the layer's or group's row, the canvas leaves them out, and anything that uses the layer's pixels with them — generating, exporting, merging, rasterizing, filtering, converting to or using as a control layer, cropping to the frame (one layer, or all layers when this one reaches past the frame), saving to PSD, painting on an image layer — is refused with a message naming the layer or group. Disable or remove them, or crop the content. The same warning appears when there is not enough canvas memory or the computation fails; hover over it for what to do, or select the adjustment to read it in Properties. * **What you see is what you get.** Generating, exporting, merging, rasterizing and saving to PSD use the same result as the canvas, computing it first if the canvas is still working on it. One exception: PSD has no group adjustments, so a group's Texture, Clarity and Dehaze are applied to each of its layers separately, and the PSD can look different from the canvas. * **While you drag a selection**, the dragged pixels show without them; once placed, the layer is computed again with them. * **Thumbnails** of layers the canvas has not shown yet can leave them out. diff --git a/invokeai/frontend/webv2/performance/browser-baseline.json b/invokeai/frontend/webv2/performance/browser-baseline.json index 31c1e0c7a69..09cb838a052 100644 --- a/invokeai/frontend/webv2/performance/browser-baseline.json +++ b/invokeai/frontend/webv2/performance/browser-baseline.json @@ -162,9 +162,9 @@ "largestAssetRawBytes": 341949, "otherRawBytes": 0, "requestCount": 9, - "scriptRawBytes": 567866, + "scriptRawBytes": 575614, "scriptRequestCount": 9, - "totalRawBytes": 567866 + "totalRawBytes": 575614 }, "domContentLoadedMedianMs": 100.40000009536743, "id": "editor-canvas", @@ -200,9 +200,9 @@ "largestAssetRawBytes": 341949, "otherRawBytes": 0, "requestCount": 9, - "scriptRawBytes": 567866, + "scriptRawBytes": 575614, "scriptRequestCount": 9, - "totalRawBytes": 567866 + "totalRawBytes": 575614 }, "domContentLoadedMedianMs": 97.7000002861023, "id": "editor-canvas", @@ -1385,6 +1385,7 @@ "source:src/workbench/canvas-engine/color.ts", "source:src/workbench/canvas-engine/contracts.ts", "source:src/workbench/canvas-engine/controllers/booleanMergeController.ts", + "source:src/workbench/canvas-engine/controllers/colorSampleController.ts", "source:src/workbench/canvas-engine/controllers/controlDerivationController.ts", "source:src/workbench/canvas-engine/controllers/controlPixelController.ts", "source:src/workbench/canvas-engine/controllers/controlViewController.ts", @@ -1405,6 +1406,7 @@ "source:src/workbench/canvas-engine/controllers/mergeLayerController.ts", "source:src/workbench/canvas-engine/controllers/mutationContext.ts", "source:src/workbench/canvas-engine/controllers/newRasterLayerController.ts", + "source:src/workbench/canvas-engine/controllers/paintLineEndController.ts", "source:src/workbench/canvas-engine/controllers/persistenceController.ts", "source:src/workbench/canvas-engine/controllers/previewStateController.ts", "source:src/workbench/canvas-engine/controllers/psdExportController.ts", @@ -2512,6 +2514,7 @@ "source:src/workbench/canvas-engine/color.ts", "source:src/workbench/canvas-engine/contracts.ts", "source:src/workbench/canvas-engine/controllers/booleanMergeController.ts", + "source:src/workbench/canvas-engine/controllers/colorSampleController.ts", "source:src/workbench/canvas-engine/controllers/controlDerivationController.ts", "source:src/workbench/canvas-engine/controllers/controlPixelController.ts", "source:src/workbench/canvas-engine/controllers/controlViewController.ts", @@ -2532,6 +2535,7 @@ "source:src/workbench/canvas-engine/controllers/mergeLayerController.ts", "source:src/workbench/canvas-engine/controllers/mutationContext.ts", "source:src/workbench/canvas-engine/controllers/newRasterLayerController.ts", + "source:src/workbench/canvas-engine/controllers/paintLineEndController.ts", "source:src/workbench/canvas-engine/controllers/persistenceController.ts", "source:src/workbench/canvas-engine/controllers/previewStateController.ts", "source:src/workbench/canvas-engine/controllers/psdExportController.ts", @@ -2936,6 +2940,7 @@ "source:src/workbench/widgets/autosave-status/manifest.ts", "source:src/workbench/widgets/canvas/CanvasBottomOverlay.tsx", "source:src/workbench/widgets/canvas/CanvasCreateFromBboxSubmenu.tsx", + "source:src/workbench/widgets/canvas/CanvasCropAllLayersMenuItem.tsx", "source:src/workbench/widgets/canvas/CanvasEditRefusalNotices.tsx", "source:src/workbench/widgets/canvas/CanvasFloatingBar.tsx", "source:src/workbench/widgets/canvas/CanvasGlobalContextMenu.tsx", @@ -2966,6 +2971,7 @@ "source:src/workbench/widgets/canvas/canvasImageDnd.ts", "source:src/workbench/widgets/canvas/canvasInteractionLock.ts", "source:src/workbench/widgets/canvas/canvasSettings.ts", + "source:src/workbench/widgets/canvas/canvasToolCommands.ts", "source:src/workbench/widgets/canvas/checkerColors.ts", "source:src/workbench/widgets/canvas/color-system/CanvasColorFeed.tsx", "source:src/workbench/widgets/canvas/color-system/colorPair.ts", @@ -2975,6 +2981,7 @@ "source:src/workbench/widgets/canvas/engineStoreHooks.ts", "source:src/workbench/widgets/canvas/executeCanvasImageDropImport.ts", "source:src/workbench/widgets/canvas/fitBbox.ts", + "source:src/workbench/widgets/canvas/frameIcons.ts", "source:src/workbench/widgets/canvas/gallerySaveToast.ts", "source:src/workbench/widgets/canvas/implementation.ts", "source:src/workbench/widgets/canvas/invoke/canvasCompositing.ts", @@ -3098,7 +3105,7 @@ "source:src/workbench/widgets/layers/runLayerWorkflow.ts", "source:src/workbench/widgets/layers/selectedModel.ts", "source:src/workbench/widgets/layers/useAddLayer.ts", - "source:src/workbench/widgets/layers/useCanvasHistoryHotkeys.ts", + "source:src/workbench/widgets/layers/useCanvasCompanionHotkeys.ts", "source:src/workbench/widgets/layers/useControlSourceActions.ts", "source:src/workbench/widgets/layers/useLayerSelectionCommands.ts", "source:src/workbench/widgets/layers/useLayerStackActions.ts", @@ -3872,6 +3879,7 @@ "source:src/workbench/canvas-engine/color.ts", "source:src/workbench/canvas-engine/contracts.ts", "source:src/workbench/canvas-engine/controllers/booleanMergeController.ts", + "source:src/workbench/canvas-engine/controllers/colorSampleController.ts", "source:src/workbench/canvas-engine/controllers/controlDerivationController.ts", "source:src/workbench/canvas-engine/controllers/controlPixelController.ts", "source:src/workbench/canvas-engine/controllers/controlViewController.ts", @@ -3892,6 +3900,7 @@ "source:src/workbench/canvas-engine/controllers/mergeLayerController.ts", "source:src/workbench/canvas-engine/controllers/mutationContext.ts", "source:src/workbench/canvas-engine/controllers/newRasterLayerController.ts", + "source:src/workbench/canvas-engine/controllers/paintLineEndController.ts", "source:src/workbench/canvas-engine/controllers/persistenceController.ts", "source:src/workbench/canvas-engine/controllers/previewStateController.ts", "source:src/workbench/canvas-engine/controllers/psdExportController.ts", diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index f30dae556f9..636220dfa3d 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -2923,6 +2923,11 @@ "removeConceptNamed": "Remove {{name}}", "removeReferenceImage": "Remove reference image", "render": "Render", + "resetSize": "Reset size", + "resetSizeModelDefaults": "Model defaults", + "resetSizeOptimalForCurrentRatio": "Optimal size for current ratio", + "resetSizeOptimalForRatio": "Optimal size for {{ratio}}", + "resetSizeSquareNative": "Square at native size", "resetAllToModelDefaults": "Reset all to model defaults", "resizeNegativePrompt": "Resize negative prompt", "resizePositivePrompt": "Resize positive prompt", @@ -2937,8 +2942,6 @@ "selectModelFirst": "Select a model first", "selectTextLlm": "Select Text LLM", "selectVisionModel": "Select vision model", - "setOptimalSize": "Set optimal size", - "setOptimalSizeDescription": "Set size to the model's optimal pixel count", "size": "Size", "sizeRecommended": "Recommended ✓", "steps": "Steps", @@ -3449,19 +3452,36 @@ "selectMoveTool": "Select move tool", "selectShapeTool": "Select shape tool", "selectTextTool": "Select text tool", - "selectTransformTool": "Select transform tool", + "selectTransformTool": "Transform selected layer", "selectViewTool": "Select view tool", "toggleNonRasterLayers": "Hide control, guidance and mask layers", "undo": "Undo canvas edit", "groupLayers": "Group selected layers", "ungroupLayers": "Ungroup selected layers", "swapColors": "Swap foreground and background colors", - "resetColors": "Reset colors to black and white" + "resetColors": "Reset colors to black and white", + "zoomTo100": "Zoom to 100%", + "zoomTo200": "Zoom to 200%", + "zoomTo400": "Zoom to 400%", + "zoomTo800": "Zoom to 800%" }, "contextMenu": { - "createError": "Couldn't create from BBox", - "createFromBbox": "Create From BBox", - "createdFromBbox": "Added {{destination}} from BBox", + "createError": "Couldn't create from the frame", + "createFromBbox": "Create From Frame", + "createdFromBbox": "Added {{destination}} from the frame", + "cropAllAllLocked": "Every layer is locked. Unlock a layer to crop it.", + "cropAllLayersToBbox": "Crop all layers to frame", + "cropAllNoLayers": "There are no layers to crop.", + "cropAllNoneCropped": "No layer was cropped", + "cropAllNothing": "Every layer already fits the frame", + "cropAllNothingUnlocked": "Every unlocked layer already fits the frame", + "cropAllUnreadable": "“{{name}}” could not be read and was left as it is.", + "cropAllUnreadableWithOthers_one": "“{{name}}” and {{count}} other layer could not be read and were left as they are.", + "cropAllUnreadableWithOthers_other": "“{{name}}” and {{count}} other layers could not be read and were left as they are.", + "croppedAllLayers_one": "Cropped {{count}} layer to the frame", + "croppedAllLayers_other": "Cropped {{count}} layers to the frame", + "croppedAllLayersSkipped_one": "{{count}} locked layer skipped.", + "croppedAllLayersSkipped_other": "{{count}} locked layers skipped.", "empty": "This region has no visible raster content.", "newControlLayer": "New Control Layer", "newGlobalReferenceImage": "New Global Reference Image", @@ -3471,12 +3491,13 @@ "referenceImageAdded": "Added reference image", "referenceImageFull": "The selected model can't accept more reference images.", "referenceImageUnsupported": "The selected model doesn't support reference images.", - "saveBboxToGallery": "Save BBox to Gallery", + "saveBboxToGallery": "Save Frame to Gallery", "saveCanvasToGallery": "Save Canvas to Gallery", "saveError": "Couldn't save to gallery", "saveToGallery": "Save to Gallery", "saved": "Saved to gallery", - "stale": "The canvas changed while saving. Try again." + "stale": "The canvas changed while saving. Try again.", + "unreadable": "“{{name}}” could not be read (its image may have been deleted or damaged). Disable or delete that layer, then try again." }, "controls": { "clearCanvas": "Clear canvas", @@ -3522,9 +3543,9 @@ "previousStagedCandidate": "Previous staged candidate", "selectStagedCandidate": "Select staged candidate {{number}}", "settings": { - "bboxOverlay": "Show bbox overlay", + "bboxOverlay": "Dim outside frame", "checkerboard": "Checkerboard background", - "clipToBbox": "Clip strokes to bbox", + "clipToBbox": "Clip strokes to frame", "clearCaches": "Clear caches", "clearHistory": "Clear history", "grid": "Show grid", @@ -3545,6 +3566,7 @@ }, "showStagedResultPreview": "Show staged result preview", "showStagingThumbnails": "Show staging thumbnails", + "transformPending": "Waiting for the stroke to save…", "staging": { "acceptStopMessage": "Stopping the rest of its batch, which shows as cancelled in the queue.", "acceptStopTitle": "Result accepted", @@ -3680,13 +3702,16 @@ "gradient": "Gradient", "label": "Tools", "lasso": "Lasso select", + "lassoHelp": "Draw a freehand or polygon outline to select pixels on the active layer. Fill, erase, or move them to a new layer. Doesn't change the generation frame.", "marquee": "Marquee select", + "marqueeHelp": "Drag a rectangle or ellipse to select pixels on the active layer. Fill, erase, or move them to a new layer. Doesn't change the generation frame.", "move": "Move", "sam": "Select object", "shape": "Shape", "text": "Text", "transform": "Transform", - "view": "View" + "view": "View", + "withHotkey": "{{label}} ({{hotkey}})" }, "controlHint": { "title": "Drop an image here", @@ -3933,9 +3958,10 @@ "convertToRegionalGuidance": "Convert to regional guidance", "cropBusy": "Finish the current canvas action before cropping this layer.", "cropFailed": "The layer could not be cropped.", - "cropLayerToBbox": "Crop layer to bbox", + "cropLayerToBbox": "Crop layer to frame", + "cropOutsideFrame": "This layer lies entirely outside the frame, so cropping it would leave it empty. Nothing was changed.", "cropUnsupported": "This layer cannot be cropped.", - "cropped": "Layer cropped to bbox.", + "cropped": "Layer cropped to frame.", "cutaway": "Cutaway with layer below", "cutout": "Cutout with layer below", "delete": "Delete layer", @@ -3950,7 +3976,7 @@ "enableTransparencyEffect": "Enable transparent preview", "exclude": "Exclude layer below", "extractMaskedArea": "Extract masked area", - "fitToBbox": "Fit to bbox", + "fitToBbox": "Fit to frame", "disableLayer": "Disable layer", "enableLayer": "Enable layer", "disableSelected": "Disable selected", @@ -3993,6 +4019,7 @@ "toggleVisibility": "Enable/disable", "transform": "Transform", "unsupported": "This action is not supported for this layer.", + "unreadable": "“{{name}}” could not be read (its image may have been deleted or damaged), so nothing was changed. Delete or replace that layer, then try again.", "unlock": "Unlock layer", "unlockSelected": "Unlock selected", "group": "Group layers", @@ -4018,6 +4045,15 @@ "addDenoiseLimit": "Add denoise limit", "addNoise": "Add noise", "addRegenerateRegion": "Add regenerate region", + "addRegenerateRegionDescription": "Regenerate where this layer has content, as a live inpaint mask.", + "canvasNotReady": "The canvas is still starting. Try again in a moment.", + "interactionLocked": "Unavailable while the canvas is generating, has staged results, or is applying an edit.", + "mergeMasks": "Merge masks", + "mergeMasksIneligible": "Merge masks needs two or more enabled, unlocked inpaint masks with content.", + "mergeMasksSettingsDiffer": "The masks have different noise or denoise settings. Match them to merge.", + "removeFromGroup": "Remove from group", + "removeFromGroupLocked": "Unlock the layer and the groups that contain it first.", + "transformIneligible": "Transform works on enabled raster and control layers with content.", "useAsControlLayer": "Use as control layer" }, "control": { @@ -4338,11 +4374,13 @@ "preview": "Preview", "processFailed": "The filter could not run.", "replace": "Replace", + "resetSettings": "Reset settings", "running": "Running filter…", "sourceAdjustmentError": "Clarity, Texture or Dehaze failed on this layer, so it was not filtered. Change a setting or disable them, then try again.", "sourceAdjustmentOverBudget": "Not enough canvas memory to apply this layer’s Clarity, Texture or Dehaze before filtering it. Delete layers you no longer need, then try again.", "sourceAdjustmentTooLarge": "Clarity, Texture and Dehaze can’t run on this layer: its content is larger than 16.8 megapixels (4096 × 4096 px). Disable or remove them, or crop the layer, then try again.", "sourceOverBudget": "Not enough memory is free to read this layer for the filter.", + "sourceUnreadable": "The layer's image could not be read, so it cannot be filtered.", "statusCommitting": "Applying filter…", "statusError": "Filter needs attention.", "stale": "The layer changed while filtering. Preview it again.", @@ -4351,12 +4389,12 @@ }, "selectObject": { "autoProcess": "Auto Process", - "bbox": "BBox", - "bboxActive": "Bounding box active", - "bboxInactive": "No bounding box", + "bbox": "Box", + "bboxActive": "Box prompt set", + "bboxInactive": "No box prompt", "errorDecode": "The object preview could not be decoded.", "errorEmpty": "The layer has no visible content to select.", - "errorInvalid": "Add an object prompt, point, or bounding box before processing.", + "errorInvalid": "Add an object prompt, point, or box before processing.", "errorLocked": "Select Object cannot finish while canvas editing is locked.", "errorNoOutput": "Select Object finished without producing an image.", "errorAdjustment": "Clarity, Texture or Dehaze failed on this layer, so it was not used as the source. Change a setting or disable them, then try again.", @@ -4367,6 +4405,7 @@ "errorQueue": "Select Object processing failed on the backend.", "errorReconcile": "The completed Select Object result could not be retrieved.", "errorUnknown": "Select Object could not finish.", + "errorUnreadable": "The layer's image could not be read.", "errorUpload": "The layer source could not be uploaded for Select Object.", "exclude": "Exclude", "excludeCount": "Exclude {{count}}", @@ -4427,6 +4466,7 @@ "noBindings": "The active workflow has no compatible image input and output.", "notReady": "The active workflow is not ready with this layer input.", "overBudget": "The workflow result is too large to apply undoably, so the layer was not changed.", + "unreadable": "The layer's image could not be read, so the workflow did not run.", "output": "Workflow image output", "replace": "Replace layer", "replaceSuccess": "Replaced the layer with the workflow result.", @@ -4531,6 +4571,7 @@ "exportNotReady": "Layers are still loading — try exporting again in a moment", "exportOverBudget": "Not enough raster memory is available to export this PSD", "exportStale": "The canvas changed during export — try again", + "exportUnreadable": "“{{name}}” could not be read, so nothing was exported. Delete or replace that layer, then export again", "exportAborted": "PSD export was canceled", "exportFailed": "PSD export failed", "exportPsd": "Export to PSD", diff --git a/invokeai/frontend/webv2/src/app/GenerationUiAdapter.tsx b/invokeai/frontend/webv2/src/app/GenerationUiAdapter.tsx index 072abb78e74..f1572171c6e 100644 --- a/invokeai/frontend/webv2/src/app/GenerationUiAdapter.tsx +++ b/invokeai/frontend/webv2/src/app/GenerationUiAdapter.tsx @@ -32,6 +32,7 @@ import { useMountEffect } from '@platform/react/useMountEffect'; import { createProjectedExternalStore } from '@platform/state/projectedExternalStore'; import { shallowEqual } from '@platform/state/selectors'; import { type QueryClient, QueryObserver, useQueryClient } from '@tanstack/react-query'; +import { resetGenerateDims } from '@workbench/canvasDimsSync'; import { useFindGalleryItem } from '@workbench/image-actions/useFindGalleryItem'; import { getWorkbenchPreferences, @@ -308,8 +309,11 @@ export const GenerationUiAdapterProvider = ({ children }: { children: ReactNode [generation, promptHistoryItems] ); const settingsGroup = useMemo( - () => ({ patchGenerateSettings: generation.patchSettings }), - [generation] + () => ({ + patchGenerateSettings: generation.patchSettings, + resetDimensions: (values, projectId) => resetGenerateDims(store, values, projectId), + }), + [generation, store] ); const { canManagePromptTemplates, canManageSharedSystemPrompts } = useCapabilities(); const capabilitiesGroup = useMemo( diff --git a/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.test.ts b/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.test.ts index 5f76c47e13d..2675cbf4ebc 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.test.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.test.ts @@ -773,13 +773,13 @@ describe('compileCanvasGraph', () => { 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.' + 'Canvas frame must have a positive area.' ); }); it('rejects fractional bbox dimensions before emitting backend resize fields', () => { expect(() => compile(flux2Model, 'txt2img', { bbox: { height: 888, width: 1024.5, x: 0, y: 0 } })).toThrow( - 'Canvas bounding box dimensions must be whole pixels.' + 'Canvas frame dimensions must be whole pixels.' ); }); diff --git a/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.ts b/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.ts index 273b4f84483..84a2542d8e7 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/canvas/compileCanvasGraph.ts @@ -157,9 +157,9 @@ const getCanvasValidationReasons = (input: CompileCanvasGraphInput, processingSi } 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.'); + reasons.push('Canvas frame must have a positive area.'); } else if (!Number.isInteger(bbox.width) || !Number.isInteger(bbox.height)) { - reasons.push('Canvas bounding box dimensions must be whole pixels.'); + reasons.push('Canvas frame dimensions must be whole pixels.'); } if (isImageMode(mode)) { diff --git a/invokeai/frontend/webv2/src/features/generation/ui/GenerateDimensionFields.browser.test.tsx b/invokeai/frontend/webv2/src/features/generation/ui/GenerateDimensionFields.browser.test.tsx index b9f2c393ed4..4414a0ef00b 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/GenerateDimensionFields.browser.test.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/GenerateDimensionFields.browser.test.tsx @@ -2,6 +2,11 @@ import type { GenerateModelConfig, GenerateSettings, MainModelConfig } from '@features/generation/core/types'; import { ChakraProvider } from '@chakra-ui/react'; +import { + resetArchitectureCapabilities, + setArchitectureCapabilities, +} from '@features/generation/core/architectureCapabilities'; +import { architectureCapabilitiesFixture } from '@features/generation/core/architectureCapabilities.testing'; import { getDefaultGenerateSettings } from '@features/generation/core/baseGenerationPolicies'; import { createExternalStoreCore } from '@platform/state/externalStoreCore'; import { system } from '@theme/system'; @@ -9,7 +14,8 @@ import { createInstance } from 'i18next'; import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { I18nextProvider, initReactI18next } from 'react-i18next'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { userEvent } from 'vitest/browser'; import type { CanvasRenderSizeProps } from './GenerationUiContext'; @@ -48,6 +54,11 @@ void i18n.use(initReactI18next).init({ lockAspectRatio: 'Lock aspect ratio', megapixelsValue: '{{value}} MP', providerSize: 'Provider size', + resetSize: 'Reset size', + resetSizeModelDefaults: 'Model defaults', + resetSizeOptimalForCurrentRatio: 'Optimal size for current ratio', + resetSizeOptimalForRatio: 'Optimal size for {{ratio}}', + resetSizeSquareNative: 'Square at native size', size: 'Size', swapWidthAndHeight: 'Swap width and height', unlockAspectRatio: 'Unlock aspect ratio', @@ -87,6 +98,7 @@ const render = async (settings: Partial, model: GenerateModelC document.body.append(host); root = createRoot(host); const onCommit = vi.fn(); + const onResetSize = vi.fn(); await settle(() => { root?.render( @@ -97,13 +109,14 @@ const render = async (settings: Partial, model: GenerateModelC projectId="project" selectedModel={model} onCommit={onCommit} + onResetSize={onResetSize} /> ); }); - return onCommit; + return { onCommit, onResetSize }; }; const lockButton = () => @@ -113,12 +126,16 @@ const lockButton = () => const bracket = () => host?.querySelector('[data-part="bracket"]')?.parentElement ?? null; const presetTrigger = () => host?.querySelector('[role="combobox"]') ?? null; -afterEach(async () => { - harness.invocationSourceId = 'generate'; +const unmountAndReset = async () => { await settle(() => root?.unmount()); host?.remove(); host = null; root = null; +}; + +afterEach(async () => { + harness.invocationSourceId = 'generate'; + await unmountAndReset(); }); describe('GenerateDimensionFields canvas render size', () => { @@ -182,7 +199,7 @@ describe('GenerateDimensionFields provider size', () => { }); it('commits the hosted preset through the dimension panel with its dimensions and name', async () => { - const onCommit = await render({ externalImageSize: null, height: 1024, width: 1024 }, geminiImage); + const { onCommit } = await render({ externalImageSize: null, height: 1024, width: 1024 }, geminiImage); expect(providerTrigger()?.textContent).toContain('1:1 (1K)'); await settle(() => providerTrigger()?.click()); @@ -201,7 +218,7 @@ describe('GenerateDimensionFields provider size', () => { describe('GenerateDimensionFields aspect ratio', () => { it('reshapes the size to a chosen preset and locks it', async () => { - const onCommit = await render({ aspectRatioId: 'Free', aspectRatioIsLocked: false, height: 1024, width: 1024 }); + const { onCommit } = await render({ aspectRatioId: 'Free', aspectRatioIsLocked: false, height: 1024, width: 1024 }); expect(lockButton()?.getAttribute('aria-label')).toBe('Lock aspect ratio'); expect(bracket()?.hasAttribute('data-locked')).toBe(false); @@ -220,7 +237,7 @@ describe('GenerateDimensionFields aspect ratio', () => { it('shows the lock engaged while a preset is selected and unlocking returns to Free', async () => { // A preset implies ratio lock even when the stored flag is false. - const onCommit = await render({ + const { onCommit } = await render({ aspectRatioId: '16:9', aspectRatioIsLocked: false, aspectRatioValue: 16 / 9, @@ -244,7 +261,7 @@ describe('GenerateDimensionFields aspect ratio', () => { }); it('locking in Free captures the current ratio', async () => { - const onCommit = await render({ aspectRatioId: 'Free', aspectRatioIsLocked: false, height: 800, width: 1024 }); + const { onCommit } = await render({ aspectRatioId: 'Free', aspectRatioIsLocked: false, height: 800, width: 1024 }); await settle(() => lockButton()?.click()); @@ -259,7 +276,7 @@ describe('GenerateDimensionFields aspect ratio', () => { it('locking names the preset the current size already matches', async () => { // The same gesture in the canvas geometry form shows "4:3" for these dimensions. - const onCommit = await render({ aspectRatioId: 'Free', aspectRatioIsLocked: false, height: 768, width: 1024 }); + const { onCommit } = await render({ aspectRatioId: 'Free', aspectRatioIsLocked: false, height: 768, width: 1024 }); await settle(() => lockButton()?.click()); @@ -273,7 +290,7 @@ describe('GenerateDimensionFields aspect ratio', () => { }); it('swaps width and height and mirrors the preset', async () => { - const onCommit = await render({ + const { onCommit } = await render({ aspectRatioId: '16:9', aspectRatioIsLocked: true, aspectRatioValue: 16 / 9, @@ -291,3 +308,177 @@ describe('GenerateDimensionFields aspect ratio', () => { }); }); }); + +describe('GenerateDimensionFields Reset size', () => { + const resetSizeButton = () => host?.querySelector('button[aria-label="Reset size"]') ?? null; + const menuItems = () => [...document.querySelectorAll('[role="menuitemradio"]')]; + const menuItem = (label: string) => menuItems().find((item) => item.textContent?.startsWith(label)) ?? null; + const openMenu = () => settle(() => resetSizeButton()?.click()); + // Zag selects on a real pointer release, which a synthetic click() does not send. + const choose = async (label: string) => { + const item = menuItem(label); + if (!item) { + throw new Error(`no "${label}" choice`); + } + await userEvent.click(item); + await settle(() => undefined); + }; + + beforeEach(() => { + setArchitectureCapabilities(architectureCapabilitiesFixture); + }); + + afterEach(() => { + resetArchitectureCapabilities(); + }); + + it('lists each choice with the size it sets and resets to a locked square at the native size', async () => { + const { onCommit, onResetSize } = await render({ + aspectRatioId: '2:3', + aspectRatioIsLocked: true, + aspectRatioValue: 2 / 3, + height: 768, + width: 512, + }); + + await openMenu(); + + // SD 1.5 is native at 512 on an 8px grid: 512² reshaped to 2:3 is 418×627, snapped to 416×624. + expect(menuItems().map((item) => item.textContent)).toEqual([ + 'Square at native size512×512', + 'Optimal size for 2:3416×624', + ]); + + await choose('Square at native size'); + + expect(onResetSize).toHaveBeenCalledExactlyOnceWith({ + aspectRatioId: '1:1', + aspectRatioIsLocked: true, + aspectRatioValue: 1, + height: 512, + width: 512, + }); + expect(onCommit).not.toHaveBeenCalled(); + }); + + it('keeps an unlocked free ratio when resetting to its optimal size', async () => { + const { onResetSize } = await render({ + aspectRatioId: 'Free', + aspectRatioIsLocked: false, + height: 400, + width: 800, + }); + + await openMenu(); + // 512² at 2:1 is 724×362, snapped to 728×360. + expect(menuItem('Optimal size for current ratio')?.textContent).toBe('Optimal size for current ratio728×360'); + + await choose('Optimal size for current ratio'); + + expect(onResetSize).toHaveBeenCalledExactlyOnceWith({ height: 360, width: 728 }); + }); + + it('names a locked free ratio in the app locale', async () => { + i18n.addResourceBundle('de', 'translation', i18n.getResourceBundle('en', 'translation')); + await i18n.changeLanguage('de'); + try { + await render({ + aspectRatioId: 'Free', + aspectRatioIsLocked: true, + aspectRatioValue: 2.5, + height: 400, + width: 1000, + }); + await openMenu(); + + // 512² at 5:2 is 809.5×323.8, snapped to 808×320. + expect(menuItem('Optimal size for 2,5:1')?.textContent).toBe('Optimal size for 2,5:1808×320'); + } finally { + await i18n.changeLanguage('en'); + } + }); + + it('checks the applied choice, keeps it reachable, and drops an optimal size that is the native square', async () => { + await render({ aspectRatioId: '1:1', aspectRatioIsLocked: true, aspectRatioValue: 1, height: 512, width: 512 }); + + await openMenu(); + + const square = menuItem('Square at native size'); + expect(square?.getAttribute('aria-checked')).toBe('true'); + expect(square?.hasAttribute('aria-disabled')).toBe(false); + expect(menuItems()).toHaveLength(1); + await unmountAndReset(); + + // The same size under a free ratio is not yet the locked square. + await render({ aspectRatioId: 'Free', aspectRatioIsLocked: false, aspectRatioValue: 1, height: 512, width: 512 }); + await openMenu(); + expect(menuItem('Square at native size')?.getAttribute('aria-checked')).toBe('false'); + }); + + it('lands the keyboard on the applied choice, and choosing it again changes nothing', async () => { + const { onResetSize } = await render({ + aspectRatioId: '1:1', + aspectRatioIsLocked: true, + aspectRatioValue: 1, + height: 512, + width: 512, + }); + + resetSizeButton()?.focus(); + await userEvent.keyboard('{Enter}'); + await vi.waitFor(() => expect(document.activeElement?.getAttribute('role')).toBe('menu')); + await userEvent.keyboard('{ArrowDown}'); + const square = menuItem('Square at native size'); + await vi.waitFor(() => expect(square?.hasAttribute('data-highlighted')).toBe(true)); + expect(document.activeElement?.getAttribute('aria-activedescendant')).toBe(square?.id); + + await userEvent.keyboard('{Enter}'); + + await vi.waitFor(() => expect(menuItems()).toHaveLength(0)); + expect(onResetSize).not.toHaveBeenCalled(); + }); + + it('offers model defaults only for a model that sets its own size', async () => { + await render({ aspectRatioId: 'Free', aspectRatioIsLocked: false, height: 512, width: 512 }); + await openMenu(); + expect(menuItem('Model defaults')).toBeNull(); + await unmountAndReset(); + + const sizedModel: MainModelConfig = { ...sd1Model, default_settings: { height: 768, width: 640 } }; + const { onResetSize } = await render( + { aspectRatioId: 'Free', aspectRatioIsLocked: false, height: 512, width: 512 }, + sizedModel + ); + await openMenu(); + expect(menuItem('Model defaults')?.textContent).toBe('Model defaults640×768'); + + await choose('Model defaults'); + + expect(onResetSize).toHaveBeenCalledExactlyOnceWith({ height: 768, width: 640 }); + }); + + it('opens from the keyboard and applies the highlighted choice', async () => { + const { onResetSize } = await render({ + aspectRatioId: '2:3', + aspectRatioIsLocked: true, + aspectRatioValue: 2 / 3, + height: 768, + width: 512, + }); + + resetSizeButton()?.focus(); + await userEvent.keyboard('{Enter}'); + await vi.waitFor(() => expect(document.activeElement?.getAttribute('role')).toBe('menu')); + await userEvent.keyboard('{ArrowDown}'); + await userEvent.keyboard('{ArrowDown}'); + await userEvent.keyboard('{Enter}'); + + await vi.waitFor(() => expect(onResetSize).toHaveBeenCalledExactlyOnceWith({ height: 624, width: 416 })); + }); + + it('has no footer optimal-size button', async () => { + await render({ aspectRatioId: 'Free', aspectRatioIsLocked: false, height: 384, width: 384 }); + + expect(host?.textContent).not.toContain('Set optimal size'); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/generation/ui/GenerateDimensionFields.tsx b/invokeai/frontend/webv2/src/features/generation/ui/GenerateDimensionFields.tsx index be5cfd452c0..c6bed433a75 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/GenerateDimensionFields.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/GenerateDimensionFields.tsx @@ -1,7 +1,7 @@ /* eslint-disable react-perf/jsx-no-new-object-as-prop, react-perf/jsx-no-new-function-as-prop, react-perf/jsx-no-new-array-as-prop, react-perf/jsx-no-jsx-as-prop */ import type { AspectRatioId, GenerateModelConfig, GenerateSettings } from '@features/generation/core/types'; -import { Badge, Box, HStack, Icon, Stack, Text } from '@chakra-ui/react'; +import { Badge, Box, HStack, Icon, Menu, Portal, Stack, Text } from '@chakra-ui/react'; import { getDefaultGenerateSettings, getGenerationDimensions } from '@features/generation/core/baseGenerationPolicies'; import { MAX_DIMENSION, MIN_DIMENSION } from '@features/generation/core/dimensionLimits'; import { @@ -12,9 +12,11 @@ import { isAspectRatioLocked, } from '@features/generation/core/settings'; import { useExternalStoreSelector } from '@platform/state/selectors'; -import { Button, IconButton, Tooltip } from '@platform/ui'; +import { IconButton } from '@platform/ui/Button'; +import { MenuContent } from '@platform/ui/Menu'; import { ScrubberField } from '@platform/ui/ScrubberField'; -import { ArrowLeftRightIcon, LockIcon } from 'lucide-react'; +import { Tooltip, useTooltipTriggerIds } from '@platform/ui/Tooltip'; +import { ArrowLeftRightIcon, CheckIcon, LockIcon, RotateCcwIcon } from 'lucide-react'; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -35,6 +37,8 @@ interface GenerateDimensionFieldsProps { projectId: string; selectedModel: GenerateModelConfig | undefined; onCommit: (patch: Partial) => void; + /** Applies a Reset size choice at once; on the canvas it resizes the frame around its centre. */ + onResetSize: (patch: Partial) => void; } type Dimensions = Pick; @@ -84,6 +88,82 @@ const PREVIEW_PAD_PX = 10; const clampToRange = (value: number): number => Math.min(MAX_DIMENSION, Math.max(MIN_DIMENSION, value)); +/** Whether the model sets a size of its own; otherwise its defaults are just the architecture's optimal size. */ +const hasModelDefaultSize = (model: GenerateModelConfig | undefined): boolean => { + const defaults = model?.default_settings; + + return Number.isFinite(defaults?.width) || Number.isFinite(defaults?.height); +}; + +const RESET_SIZE_MENU_POSITIONING = { placement: 'bottom-end' } as const; + +interface ResetSizeChoice { + value: string; + label: string; + size: Dimensions; + /** Already applied: shown checked, and choosing it again changes nothing. */ + isCurrent: boolean; + onSelect: () => void; +} + +/** + * The ratio row's Reset size menu; each choice shows the size it sets. The choices are radio items, so the applied one + * stays reachable and reads as checked, and an open menu always has an item to land on. Two choices can set the + * same size; the first of them is the checked one. + */ +const ResetSizeMenu = ({ choices }: { choices: readonly ResetSizeChoice[] }) => { + const { t } = useTranslation(); + const ids = useTooltipTriggerIds(); + const label = t('widgets.generate.resetSize'); + const current = choices.find((choice) => choice.isCurrent)?.value ?? ''; + const onValueChange = ({ value }: { value: string }) => { + const choice = choices.find((candidate) => candidate.value === value); + if (choice && !choice.isCurrent) { + choice.onSelect(); + } + }; + + return ( + + + + + + + + + + + + + {choices.map((choice) => ( + + + + + {choice.label} + + + {`${choice.size.width}×${choice.size.height}`} + + + + ))} + + + + + + ); +}; + /** Respect ratio lock during drag and snap to the grid on release; arrow keys move by one grid step. */ const SizePreview = ({ current, @@ -256,10 +336,11 @@ const selectDimensionSettings = pickGenerateSettings([ export const GenerateDimensionFields = ({ draft, onCommit, + onResetSize, projectId, selectedModel, }: GenerateDimensionFieldsProps) => { - const { t } = useTranslation(); + const { i18n, t } = useTranslation(); const { CanvasRenderSize: CanvasRenderSizeSlot, project } = useGenerationUi(); const settings = useExternalStoreSelector(draft.subscribe, draft.getSnapshot, selectDimensionSettings); const secondsPerRun = useGenerationQueueInsights(selectSecondsPerRun); @@ -400,16 +481,84 @@ export const GenerateDimensionFields = ({ }); }; - const optimizeSize = () => { - const optimal = dimensions.optimal; - const ratio = isRatioConstrained - ? getActiveRatio({ aspectRatioValue: settings.aspectRatioValue, ...displayDimensions }) - : displayDimensions.height > 0 - ? displayDimensions.width / displayDimensions.height - : 1; + const resetSize = (patch: Partial) => { + pendingDimensionsRef.current = null; + setDraftDimensions(null); + onResetSize(patch); + }; - commitSettings(calculateNewSize(ratio, optimal * optimal, dimensionGrid)); + const nativeSide = clampDimension(dimensions.optimal, dimensionGrid); + const optimalRatio = isRatioConstrained + ? getActiveRatio({ aspectRatioValue: settings.aspectRatioValue, ...displayDimensions }) + : dimensionRatio; + // The model's pixel budget reshaped to the ratio the section holds. + const optimalSize = calculateNewSize(optimalRatio, dimensions.optimal * dimensions.optimal, dimensionGrid); + const isAtSize = (size: Dimensions) => + displayDimensions.width === size.width && displayDimensions.height === size.height; + // A preset by its name and a locked free ratio as `W:1`; an unlocked free ratio is just whatever the size is now. + const optimalLabel = + settings.aspectRatioId !== 'Free' + ? t('widgets.generate.resetSizeOptimalForRatio', { ratio: settings.aspectRatioId }) + : settings.aspectRatioIsLocked + ? t('widgets.generate.resetSizeOptimalForRatio', { + ratio: `${new Intl.NumberFormat(i18n.resolvedLanguage, { maximumFractionDigits: 2 }).format(optimalRatio)}:1`, + }) + : t('widgets.generate.resetSizeOptimalForCurrentRatio'); + + const resetToModelDefaults = () => { + if (modelDefaults) { + resetSize({ height: modelDefaults.height, width: modelDefaults.width }); + } }; + // Only a size the model sets itself is worth offering; otherwise its defaults are the native square. + const modelDefaultSize = + modelDefaults && hasModelDefaultSize(selectedModel) + ? { height: modelDefaults.height, width: modelDefaults.width } + : null; + + const resetSizeChoices: readonly ResetSizeChoice[] = [ + { + isCurrent: + isAtSize({ height: nativeSide, width: nativeSide }) && + settings.aspectRatioId === '1:1' && + settings.aspectRatioIsLocked, + label: t('widgets.generate.resetSizeSquareNative'), + // The field set choosing the 1:1 preset writes. + onSelect: () => + resetSize({ + aspectRatioId: '1:1', + aspectRatioIsLocked: true, + aspectRatioValue: 1, + height: nativeSide, + width: nativeSide, + }), + size: { height: nativeSide, width: nativeSide }, + value: 'square-native', + }, + // A square ratio's optimal size is the native square, which the first choice already sets. + ...(optimalSize.width === nativeSide && optimalSize.height === nativeSide + ? [] + : [ + { + isCurrent: isAtSize(optimalSize), + label: optimalLabel, + onSelect: () => resetSize(optimalSize), + size: optimalSize, + value: 'optimal-for-ratio', + }, + ]), + ...(modelDefaultSize + ? [ + { + isCurrent: isAtSize(modelDefaultSize), + label: t('widgets.generate.resetSizeModelDefaults'), + onSelect: resetToModelDefaults, + size: modelDefaultSize, + value: 'model-defaults', + }, + ] + : []), + ]; // The recommendation reshapes the model's pixel budget to the live aspect ratio. const recommendedDimensions = calculateNewSize( @@ -417,9 +566,6 @@ export const GenerateDimensionFields = ({ dimensions.optimal * dimensions.optimal, dimensionGrid ); - const isAtRecommendedSize = - displayDimensions.width === recommendedDimensions.width && - displayDimensions.height === recommendedDimensions.height; const frameLabel = `${displayDimensions.width}x${displayDimensions.height}`; // The optimal-side stop is scalar; recommended dimensions preserve the live ratio. @@ -444,12 +590,9 @@ export const GenerateDimensionFields = ({ const isResized = renderSize.width !== displayDimensions.width || renderSize.height !== displayDimensions.height; const isAtRecommendedRenderSize = renderSize.width === recommendedDimensions.width && renderSize.height === recommendedDimensions.height; - // In canvas mode, resizing the frame only adds something while it renders at its own size: Auto has not already - // grown it toward the optimum and Custom has not set a size of its own. - const offersOptimalSize = !isAtRecommendedSize && !isResized; const footer = ( - + {isResized ? `${renderSize.width}×${renderSize.height} · ` : ''} {t('widgets.generate.megapixelsValue', { @@ -461,13 +604,6 @@ export const GenerateDimensionFields = ({ ? ` · ${t('widgets.generate.secondsPerRun', { value: Math.round(secondsPerRun) })}` : ''} - {offersOptimalSize ? ( - - - - ) : null} ); @@ -498,14 +634,7 @@ export const GenerateDimensionFields = ({ displayDimensions.width === modelDefaults.width && displayDimensions.height === modelDefaults.height } - onReset={ - modelDefaults - ? () => { - setDraftDimensions(null); - commitDimensions({ height: modelDefaults.height, width: modelDefaults.width }); - } - : undefined - } + onReset={modelDefaults ? resetToModelDefaults : undefined} > @@ -539,6 +668,7 @@ export const GenerateDimensionFields = ({ value={settings.aspectRatioId} onChange={setAspectRatioId} /> + >>(); let patches: Array<{ patch: Record; projectId: string | undefined }> = []; +let sizeResets: Array<{ patch: Record; projectId: string | undefined }> = []; (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; const storedValues = (projectId = 'project-1') => { @@ -184,6 +185,10 @@ const createStableGroups = () => ({ patches.push({ patch, projectId }); storedValues(projectId).patchSnapshot(patch); }, + resetDimensions: (patch: Record, projectId?: string) => { + sizeResets.push({ patch, projectId }); + storedValues(projectId).patchSnapshot(patch); + }, }, }); let stableGroups = createStableGroups(); @@ -279,6 +284,7 @@ beforeEach(async () => { ['project-2', createExternalStoreCore>({ ...INITIAL_VALUES, steps: 50 })], ]); patches = []; + sizeResets = []; stableGroups = createStableGroups(); // Seeded, never-stale reads keep late responses from landing inside a counted window. queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); @@ -556,6 +562,40 @@ describe('GenerateSettingsForm render isolation', () => { expect(patches).toEqual([{ patch: { steps: 31 }, projectId: 'project-1' }]); }); + it('applies a size reset at once, superseding a pending size edit that can then never land after it', async () => { + await settle(() => + storedValues().patchSnapshot({ + aspectRatioId: '2:3', + aspectRatioIsLocked: true, + aspectRatioValue: 2 / 3, + height: 1216, + width: 832, + }) + ); + await settle(() => + host!.querySelector('button[aria-label="widgets.generate.resetSize"]')?.click() + ); + const menu = document.querySelector('[role="menu"]'); + // Highlights the first choice, Square at native size. + await settle(() => menu?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'ArrowDown' }))); + + // A width step still inside its debounce window, then the reset. + await settle(() => { + sliderNamed('widgets.generate.width').dispatchEvent( + new KeyboardEvent('keydown', { bubbles: true, key: 'ArrowRight' }) + ); + menu?.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' })); + }); + await settle(noop, 400); + + // SDXL is native at 1024. + expect(sizeResets).toEqual([ + { patch: { aspectRatioId: '1:1', aspectRatioValue: 1, height: 1024, width: 1024 }, projectId: 'project-1' }, + ]); + expect(patches).toEqual([]); + expect(storedValues().getSnapshot()).toMatchObject({ aspectRatioIsLocked: true, height: 1024, width: 1024 }); + }); + it('batches fast edits to several fields into one patch on the project they were made in', async () => { await stepSteps(); await stepSteps(); diff --git a/invokeai/frontend/webv2/src/features/generation/ui/GenerateSettingsForm.tsx b/invokeai/frontend/webv2/src/features/generation/ui/GenerateSettingsForm.tsx index 31d9533907a..335e3367751 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/GenerateSettingsForm.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/GenerateSettingsForm.tsx @@ -25,6 +25,7 @@ import { GenerateModelCard } from './GenerateModelCard'; import { GenerateProviderOptionsSection } from './GenerateProviderOptionsSection'; import { GenerateRefinerSection } from './GenerateRefinerSection'; import { GenerateRenderSection } from './GenerateRenderSection'; +import { useGenerationUi } from './GenerationUiContext'; import { GeneratePromptFields } from './promptFields'; const GENERATE_INPUT_DEBOUNCE_MS = 250; @@ -55,6 +56,7 @@ export const GenerateSettingsForm = ({ settings, supportedModels, }: GenerateSettingsFormProps) => { + const { resetDimensions } = useGenerationUi().settings; const [draft] = useState(() => createExternalStoreCore(settings)); const latestSettingsRef = useRef(settings); const pendingUpdateRef = useRef(null); @@ -216,8 +218,9 @@ export const GenerateSettingsForm = ({ [draft, onCommitSettings] ); - const commitPatchImmediately = useCallback( - (patch: Partial) => { + /** Folds pending edits under `patch` and hands the change to `send` now, so no late debounce can follow it. */ + const sendPatchNow = useCallback( + (patch: Partial, send: (patch: Partial, projectId: string) => void) => { const previousSettings = latestSettingsRef.current; const nextSettings = applyGenerateSettingsPatch( applyGenerateSettingsUpdate(latestSettingsRef.current, pendingUpdateRef.current), @@ -232,9 +235,19 @@ export const GenerateSettingsForm = ({ pendingUpdateRef.current = null; latestSettingsRef.current = nextSettings; draft.setSnapshot(nextSettings); - onPatchSettings(getChangedGenerateSettingsPatch(previousSettings, nextSettings), projectIdRef.current); + send(getChangedGenerateSettingsPatch(previousSettings, nextSettings), projectIdRef.current); }, - [draft, onPatchSettings] + [draft] + ); + + const commitPatchImmediately = useCallback( + (patch: Partial) => sendPatchNow(patch, onPatchSettings), + [onPatchSettings, sendPatchNow] + ); + + const commitSizeReset = useCallback( + (patch: Partial) => sendPatchNow(patch, resetDimensions), + [resetDimensions, sendPatchNow] ); return ( @@ -260,7 +273,13 @@ export const GenerateSettingsForm = ({ onCommitImmediate={commitPatchImmediately} /> - + , projectId?: string, origin?: 'user' | 'system'): void; + /** + * Applies a Reset size choice: the same patch, marked as a reset so a surface that frames the size (the canvas) + * can keep its centre rather than its top-left. Generation does not know which surface that is. + */ + resetDimensions(values: Partial, projectId?: string): void; }; } diff --git a/invokeai/frontend/webv2/src/workbench/autoRoutePolicy.ts b/invokeai/frontend/webv2/src/workbench/autoRoutePolicy.ts index bc2b60165bf..5d30e1ee48b 100644 --- a/invokeai/frontend/webv2/src/workbench/autoRoutePolicy.ts +++ b/invokeai/frontend/webv2/src/workbench/autoRoutePolicy.ts @@ -127,6 +127,7 @@ const CANVAS_EDIT_CONFIDENCE = { replaceCanvasFontReferences: 'high', replaceCanvasDocument: 'none', replaceCanvasLayer: 'high', + replaceCanvasLayers: 'high', resizeCanvasDocument: 'high', restoreCanvasSnapshot: 'none', rollbackStagedImageCommit: 'none', diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/ARCHITECTURE.md b/invokeai/frontend/webv2/src/workbench/canvas-engine/ARCHITECTURE.md index a8bb912056e..43dafeb5186 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/ARCHITECTURE.md +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/ARCHITECTURE.md @@ -69,12 +69,16 @@ Current extracted ownership includes: - `HistoryController`: engine-owned history, guarded async replay with failure reporting, inactive byte trimming. - `PersistenceController`: dirty-bitmap flush barriers (whole document, or one layer through `applicationHost.flushLayerUploads`) and disposal. - `EditingController`: selection state and exclusive edit-lease lifecycle. +- `TransformEditingController` (under `EditingController`): transform sessions, previews, parameter commits and paint bakes, plus `layers.beginTransform` and the wait of a session requested before its layer's pixels persist. It alone decides when that wait opens a session or ends; `engine.ts` routes it document changes, tool switches, Escape, replays, document replacement and final upload failures. +- `ColorSampleController`: one-shot color requests and the workbench color router, and which of them receives each pick. +- `PaintLineEndController`: where the last brush or eraser stroke ended on each layer, followed through the layer's changes (see "Input ownership and frame scheduling"). - `InteractionController`: public tool commands and their disposal boundary. - `LayerController`: guarded layer mutations and thumbnail capability ports. -- `RasterExportController`: guarded layer rasterization, transformed copies, encoding, reservations, pin leases. +- `RasterExportController`: guarded layer rasterization, transformed copies, encoding, reservations, pin leases. A rasterization that failed because the source cannot be read reports a `LayerUnreadable` refusal (`layerExportGuards.ts`) carrying the layer's id and name, never `not-ready`: waiting cannot fix it, so every consumer (crop, merges, boolean merges, copies, conversion, extraction, duplicate, filter, Select Object, Run Workflow, snapshots for generation and PSD, composite export) refuses with it instead of asking the user to retry, and the message names the layer to disable, delete or replace. An operation reading several layers names the first that failed in its read order; crop all lists each one it skipped. Unreadable means the image resolver threw `CanvasImageMissingError` (the `ImageResolver` contract: the image is missing, or not readable by this user; the production resolver maps 403, 404 and 410 to it), the fetched image did not decode, or rendering threw. Any other resolver failure (network, server, an expired session) is wrapped as `ImageUnavailableError` and is transient: operations and readiness gates report `not-ready`, and crop all refuses the whole crop as `not-ready` instead of skipping the layer. The rasterizer remembers either failure per cache entry, keyed by cache version, document generation and source, with its kind, and a retry of the same source that fails again with the same kind raises no second error notice; a change of kind (a fetch outage that turns out to be a deleted image) is reported, with the `ImageUnavailableError` itself (naming the image, the resolver's error as its cause) for a transient one. Two questions read that record. `getFailure` (the kind recorded for the layer's current key) drives the readiness gates that never rasterize themselves (Merge Down, boolean merges, extraction): they answer unreadable for as long as an `unreadable` failure holds, and `not-ready` for an `unavailable` one. `isBackingOff` (either failure's retry backoff, 3 s doubling per repeated failure up to 60 s, is still running) drives the compositor, which skips the layer meanwhile instead of reading its image every frame; the backoff's end invalidates the layer, so a displayed layer retries on its own. A readiness gate asked about a layer also retries it in the background while it answers from the record (a layer off screen or disabled is never redrawn to do so): at once for an `unavailable` failure, so trying again after an outage ends fetches without waiting out the backoff, and only once the backoff is over for an `unreadable` one. A running job is joined, never duplicated. Explicit operations (crop, export, merges) always try again. A retry that fails again restarts the backoff without a second notice; a success or any change to the layer clears the failure. Thumbnails show `error` for both kinds. - `PsdExportController`: immutable PSD planning, snapshot capture, reservation, execution, cancellation. - `StagedResultController`: guarded staged-candidate acceptance and its history entry. -- `PixelEditController`, `SelectionImageController`, `LayerMutationController`: atomic pixel/document publication at their respective boundaries. Pixel editor materializes control layers and raster-image layers in place before destructive edits, keeping conversion plus pixels in one undoable transaction. +- `PixelEditController`, `SelectionImageController`, `LayerMutationController`: atomic pixel/document publication at their respective boundaries. Pixel editor materializes control layers and raster-image layers in place before destructive edits (brush and eraser alike), keeping conversion plus pixels in one undoable transaction. +- `CropLayerController`: crops one layer, or every leaf of every stack, to the frame; crop-all is one entry. Both paths share one eligibility rule: a leaf locked by itself or an ancestor, or with a source the export cannot bake, is never cropped. A single crop refuses such a leaf, an `unreadable` one, and one wholly outside the frame (`outside-frame`: the explicit crop would empty it). Crop all first crosses the paint-upload barrier (`flushPendingPixels`, not waiting for held pixels), so an acknowledgement replacing a just-painted leaf cannot land mid-read and refuse it as `stale`. It skips locked and unreadable leaves when their contract's content reaches past the frame, counting the locked ones (`skippedLocked`) and listing the unreadable ones in document order (`skippedUnreadable`), so the result can say what was left alone, by name, even when nothing was cropped, and one leaf that cannot be read never blocks the rest; a leaf already inside the frame, empty or unsupported is left alone; a leaf wholly outside is emptied in place, so ids, order, groups and flags never change. It reads one leaf at a time through a read lease, copies the cropped pixels into a surface under their own raster reservation and releases the lease before the next leaf, so the peak is the cropped set plus one export; it refuses `stale` when the stacks, frame or any pixel guard moved meanwhile, admits one history entry for every before and after snapshot, and publishes one `replaceCanvasLayers` (same-id, same-type leaves, refused whole when any entry is invalid). `MergeLayerController` merges selected leaves into a new one through one pipeline parameterized by eligibility, result stack, result leaf and label: merge-selected flattens a contiguous raster run into a paint layer, merge-masks unions any inpaint masks' coverage (each at full alpha, since a mask's opacity only tints it on screen) into a mask configured like the topmost source. - `MaskResultController`, `FilterResultController`, `GeneratedResultController`: guarded application-result adoption through narrow host ports. - `ControlDerivationController`: control-layer intent patches and detected-map commits (see "Control derivation"). - `ControlViewController`: the display-only Original / Overlay view of one control layer (see "Control layer view"). @@ -85,7 +89,7 @@ Controller-local tests instantiate these boundaries with fakes. Integration test `CanvasDocumentCapability.captureSnapshot()` returns `CanvasDocumentSnapshot` or `null`. Snapshot holds structured clone of exact `CanvasStateContractV3` and engine's `documentGeneration`. Engine privately ties it to the reducer canvas identity it was captured from, so caller cannot manufacture a current snapshot by copying fields. -`CanvasExportCapability.captureRasterSnapshot(snapshot, layerIds, options)` requests exactly the required layer caches, temporarily pins them, reserves detached-surface capacity, copies their pixels into independent surfaces. Returns typed `ok`, `stale`, `aborted`, `not-ready`, or `over-budget` result. Freshness checked before and after every async rasterization boundary and once after all copies are detached. +`CanvasExportCapability.captureRasterSnapshot(snapshot, layerIds, options)` requests exactly the required layer caches, temporarily pins them, reserves detached-surface capacity, copies their pixels into independent surfaces. Returns typed `ok`, `stale`, `aborted`, `not-ready`, `over-budget`, or `LayerUnreadable` (naming the first layer that could not be read) result. Freshness checked before and after every async rasterization boundary and once after all copies are detached. `ok` result holds a `CanvasRasterSnapshot`: cloned canvas contract, its document generation, map of detached layer surfaces, idempotent `release()`. Snapshot is caller-owned. Its surfaces do not read live caches and stay valid while engine cools down; cooldown releases generation-scoped reservations but does not invalidate detached snapshots. Caller must release snapshot in `finally`. Engine disposal releases any snapshots still outstanding. @@ -101,7 +105,7 @@ Canvas invocation first crosses paint-flush barrier, then captures post-flush do ## Frame demand before allocation -Each frame is described once by a `CompositePreparation` (`render/compositor.ts`): leaves drawn bottom first, each leaf's effective matrix with transient transform overrides applied, isolated group scopes, each leaf's document-space bounds (persisted source bounds joined with any larger live cache rect, through that matrix). `compositeFrame` reuses the description until document identity, isolation or override contents change, and each leaf's bounds until its contract, matrix, live cache extent or document size does. Frame demand and screen composite read that one description; frame demand also keeps every filter-previewed layer resident, since its preview draws through layer's cache entry. Color sampler, overview and extraction prepare their own descriptions of committed document without transient overrides. +Each frame is described once by a `CompositePreparation` (`render/compositor.ts`): leaves drawn bottom first, each leaf's effective matrix with transient transform overrides applied, isolated group scopes, each leaf's document-space bounds (persisted source bounds joined with any larger live cache rect, through that matrix). `compositeFrame` reuses the description until document identity, isolation or override contents change, and each leaf's bounds until its contract, matrix, live cache extent or document size does. Frame demand and screen composite read that one description; frame demand also keeps every filter-previewed layer resident, since its preview draws through layer's cache entry. Color sampler, overview and extraction prepare their own descriptions of committed document without transient overrides. The overview composites the same extent fit-to-view fits (the frame joined with every enabled renderable layer's bounds), translated so that extent fills its target, and returns that document-space rect so the Overview pane maps its outline and pointer through it. The screen description also carries which overlay stacks it shows: while a generation is staged (the staging area holds a candidate, shown or not, or a staged or progress preview is published) it hides the inpaint-mask and regional-guidance stacks and the regenerate-region tint, so the result reads untinted; control layers, isolation targets and every non-screen composite are unaffected. The flag is derived per frame, joins the description's reuse key, and its flip repaints in full once. `calculateActiveFrameLayerIds` is the pure pre-allocation pass over that description: intersects each drawable leaf's bounds with document-space viewport. Explicitly isolated operation target is the one exception to normal document visibility: stays demanded and drawable when disabled or display-hidden, because user acts on that named layer directly. @@ -180,7 +184,7 @@ Invalidate only affected effect when parameters change. Delete every derived slo Every recorded document and pixel edit goes through one protocol, `CanvasMutationContext.begin`, and is admitted before it mutates anything. Admission reserves undo entry's bytes in `History` without clearing redo or evicting older steps, so an edit whose entry could never be kept is refused as `over-budget` with document, pixels and history untouched. Two kinds of edit are admitted before they change anything but publish afterwards: live strokes paint as they are admitted (see below), and floating selection holds its admission from lift to landing. Selection-mask changes (`selection/selectionHistory.ts`) admit their step right after applying, because new mask's size is only known then, and roll back to exact prior selection when it cannot be kept. Resulting `EditTransaction` also reserves raster memory for its preparation (`reserveRaster`), holds leases and pins until `end()`, publishes once: `publish(label, step, entry)` applies an `EditStep` — optional document mutation with its postcondition and rollback, prepared pixels to install, ancillary notifications — verifies reducer and mirror, records the entry, then routes a user edit and notifies. Step that is rejected or fails its postconditions records nothing; failed postcondition dispatches step's rollback and reports when reducer or mirror could not return. Transactions refuse while permit is stale, a gesture runs (unless edit belongs to that gesture), a replay runs, or engine is disposed. Coalescing (nudges) names the entry it replaces by its `HistoryEntryToken` and is refused once anything else became newest step. -Layer controllers build their steps from `controllers/editSteps.ts`: byte accounting, replay reservations (replay whose preparation memory does not fit throws `REPLAY_RASTER_REFUSAL` and stays in place), steps that add, replace and remove layers with their prepared pixels, and new paint layers. It also names how a publication that did not land is reported: ordinary layer operations (merge, crop, copy, rasterize, insert) report reducer refusal or failed postcondition as `failed` (`layerOperationStatus`), while guarded result operations (generated, filter, mask and staged results, mask edits, duplicate) report it as `stale` (`guardedResultRefusal`), because their target moved on after the guard was taken. +Layer controllers build their steps from `controllers/editSteps.ts`: byte accounting, replay reservations (replay whose preparation memory does not fit throws `REPLAY_RASTER_REFUSAL` and stays in place), steps that add, replace (one layer, or several as one `replaceCanvasLayers`, sharing the identity postcondition, rollback and pre-install hook) and remove layers with their prepared pixels, and new paint layers. It also names how a publication that did not land is reported: ordinary layer operations (merge, crop, copy, rasterize, insert) report reducer refusal or failed postcondition as `failed` (`layerOperationStatus`), while guarded result operations (generated, filter, mask and staged results, mask edits, duplicate) report it as `stale` (`guardedResultRefusal`), because their target moved on after the guard was taken. Floating selection (`FloatingSelectionController`) computes its commit footprint from geometry before it copies or cuts anything: before and after pixels over union of hole and landing, selection alpha before and after, entry overhead. Admitted for that footprint as part of the drag that lifts it; keeps the transaction until it lands or is dropped. Every move, scale or rotation grows admission to the footprint at the new transform, from geometry alone; transform that does not fit keeps last accepted one and reports `over-budget` once per float, so an accepted transform always fits its undo entry while permit stays current; admission never shrinks, so a live float holds its largest accepted footprint until it lands or is dropped. Refused lift is reported and moves nothing (`lift` returns `refused`), including content whose cache is not rebuilt yet; only a lift with nothing to take (`unavailable`) lets Move tool fall back to moving the layer. Cut layer's persistence is suspended while float lives. Commit lands pixels and moved selection as one entry under retained admission, renewed once when its permit went stale (an operation locked and released the document meanwhile); commit that cannot be admitted or published puts pre-lift pixels and selection back. Cancellation refills hole before persistence resumes; hole that cannot be refilled keeps layer suspended rather than uploading it. Every terminal path ends the transaction: commit, cancel, Escape, undo/redo, layer's deletion or replacement, document replacement, cooldown (which banks the float before the flush), disposal, exceptions while cutting or landing. @@ -251,11 +255,13 @@ Each leaf bakes its own stack exactly (spatial entries included), layer-local, b Controller keeps 30,000-pixel side cap and also derives a pixel-area limit from currently available reserved bytes. Accounts for flattened composite plus each transformed layer surface, budgets eight bytes per required pixel, reserves full execution allocation before creating PSD surfaces. Final freshness check happens before that reservation; once execution starts it uses only frozen pixels. Capture and execution reservations release in `finally`; cancellation aborts before next allocation or side effect. -Capability returns `exported`, `nothing`, `too-large`, `not-ready`, `over-budget`, `stale`, `aborted`, or an `AdjustmentRefusal`. Layers widget awaits the promise and maps every non-success result to a user-visible notice. +Capability returns `exported`, `nothing`, `too-large`, `not-ready`, `over-budget`, `stale`, `aborted`, a `LayerUnreadable`, or an `AdjustmentRefusal`. Layers widget awaits the promise and maps every non-success result to a user-visible notice. ## Input ownership and frame scheduling -Pointer pipeline (`input/pointerPipeline.ts`) owns gesture capture and temporary holds (Space → view, Alt → picker, C → bbox). Keyboard ownership is explicit: widget passes its focusable keyboard root to `surface.attach`, and Enter applies a session only while focus is on that root or nowhere, never on a control or editable field. Hold keys and Escape ladder also act after a pointer click left focus on a control such as a tool button or layer row, since pipeline records on `focusin` whether focus arrived by pointer (Chromium marks focused element `:focus-visible` at any keydown, so arrival is the only reliable signal); they never act over an editable field, a control reached by keyboard, or an open dialog, menu or listbox. Owned Space keydown is prevented, so clicked control does not activate; key releases are always observed, never consumed. Genuine tool switch first cancels the gesture through outgoing tool and ends any hold, restoring held tool, so the switch leaves the tool the user was really on and a later key release restores nothing. +Pointer pipeline (`input/pointerPipeline.ts`) owns gesture capture and temporary holds (Space → view, Alt → picker, C → bbox). Keyboard ownership is explicit. Widget passes its focusable keyboard root and a `resolveKeyOwner` judge to `surface.attach`; the judge names the workbench region that owns a keydown (`surface`, a `companion` widget acting on this canvas's layers, or `elsewhere`) and is asked only on keydown, after the pipeline's own refusals, so the engine imports nothing from Workbench focus and pointer paths never pay for it. No key acts over an editable field, a control reached by keyboard (pipeline records on `focusin` whether focus arrived by pointer: Chromium marks focused element `:focus-visible` at any keydown, so arrival is the only reliable signal), an open dialog, menu or listbox, or beneath a modal. Past those refusals: Enter applies a session only from the `surface` with focus on its root or nowhere, never on a control; the Escape ladder and C act from the `surface` or a `companion`; Space and Alt stay pointer-led, acting over the hovered canvas whatever the owner and otherwise only from the `surface`. Without a judge (tests, detached engines) focus alone decides: the root or no focus is the surface, and a control the pointer last clicked counts as a companion. Owned Space keydown is prevented, so a clicked control does not activate. An owned Alt keydown is prevented, and so is the release of an Alt the pipeline claimed, because Firefox and Windows otherwise activate the menu bar; that holds for a tool that reads Alt as a gesture modifier too (Marquee, Lasso), which keeps the key and only gets no picker hold, since pointer events still carry `altKey`. Every other release is observed, never consumed. Alt from the surface while the pointer is elsewhere arms a pending hold that the next `pointerenter` with Alt still held, or a `pointerdown` with Alt, begins before the press is routed, so a pen landing without hover samples on contact; release, tool switch, blur, reset and detach clear it. Genuine tool switch first cancels the gesture through outgoing tool and ends any hold, restoring held tool, so the switch leaves the tool the user was really on and a later key release restores nothing. + +`ColorSampleController` routes picks. A pick previews each sampled color to the one-shot request or the workbench router (`setColorSampleRouter`), then commits the last preview once on release or cancels it once when the press ends any other way (Escape, a tool change, a cancelled pointer); a declined preview falls back to the brush color. The sink that took a pick's first preview gets its commit or cancel, so a request armed or settled mid-pick never sends the router a stray phase, nor the reverse. Brush and eraser share an engine-lifetime memory (`PaintLineEndController`) of where the last committed stroke ended on each layer, kept in that layer's local space: a Shift press on a layer with a remembered end draws a straight segment from it, placed through the layer's current transform, as part of the one stroke. The engine routes every layer change to it. A change of transform alone (move, nudge, Fit to frame, a transform parameter) carries the end with the layer; a change that rewrites content and transform together (crop, conversion, a baked float or result) keeps its document position and re-expresses it in the new local space; an applied Transform on a paint layer, which redraws only the pixels, rebases it from the session's transform. It survives tool switches but forgets on replay, document replacement, the layer's removal and a transform it cannot invert. `layers.beginTransform` (`TransformEditingController.request`) selects a leaf, switches to transform and opens its session even when transform is already active, cancelling a session on another layer; it refuses a locked or ineligible leaf, a running gesture, a live float or locked edits without changing selection or tool. A leaf whose only content is a stroke persistence has not acknowledged yet (a new paint layer's first upload) has no persisted extent to frame, so the request waits in the `pendingTransformLayerId` interaction store and the session opens when the bitmap lands, while the leaf is still selected, transform is the tool the user is on (a Space, Alt or C hold over it counts) and no other session opened. The wait ends without a session on a genuine tool switch, a selection change, the leaf's removal, Escape, a history replay, a document replacement, disposal, or an upload of that leaf failing with no retry to follow (retried failures keep the wait), which also reports a `not-ready` refusal through `tools.onEditRefused`. `RenderScheduler.beforeNextFrame(task)` runs tasks once before next frame takes its flags, so their invalidations join that frame. Tools reach it through required `ToolContext.scheduleFrame`; detached engine runs the task immediately because it produces no frames. Brush strokes append samples and decimate them incrementally per event, render accumulated stroke at most once per presented frame, render synchronously on commit. diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/applicationHost.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/applicationHost.ts index 981e6973c7e..1736e293170 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/applicationHost.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/applicationHost.ts @@ -17,6 +17,7 @@ import type { CommitMaskImageResultOptions, } from '@workbench/canvas-engine/controllers/maskResultController'; import type { LayerFlushOptions, LayerFlushResult } from '@workbench/canvas-engine/controllers/persistenceController'; +import type { LayerUnreadable } from '@workbench/canvas-engine/layerExportGuards'; import type { CanvasCompositeExecutorDeps } from '@workbench/canvas-engine/rasterTransactions'; import type { AdjustmentRefusal } from '@workbench/canvas-engine/render/exactAdjustments'; import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; @@ -56,6 +57,7 @@ export interface CanvasApplicationHost extends CanvasControlDerivationCapability ): Promise< | { status: 'ok'; surface: RasterSurface; rect: Rect; guard: LayerExportGuard; release(): void } | { status: 'missing' | 'disabled' | 'unsupported' | 'empty' | 'not-ready' | 'over-budget' } + | LayerUnreadable | AdjustmentRefusal >; getCompositeExecutorDeps(): CanvasCompositeExecutorDeps; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/capabilities.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/capabilities.ts index b9e9de7e020..1e1c8459d98 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/capabilities.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/capabilities.ts @@ -16,10 +16,10 @@ import type { LayerTransform } from '@workbench/canvas-engine/transform/transfor import type { BooleanRasterResult } from './controllers/booleanMergeController'; import type { CopyLayerResult } from './controllers/copyLayerController'; -import type { CropLayerResult } from './controllers/cropLayerController'; +import type { CropAllLayersResult, CropLayerResult } from './controllers/cropLayerController'; import type { ExtractMaskedAreaResult } from './controllers/extractMaskedAreaController'; import type { ConvertLayerToControlResult } from './controllers/layerMutationController'; -import type { MergeDownResult, MergeVisibleResult } from './controllers/mergeLayerController'; +import type { MergeDownResult, MergeInpaintMasksResult, MergeVisibleResult } from './controllers/mergeLayerController'; import type { NewRasterLayerResult } from './controllers/newRasterLayerController'; import type { RasterizeLayerResult } from './controllers/rasterizeLayerController'; import type { PreparedDocumentEdit } from './document-model/documentCommands'; @@ -54,6 +54,7 @@ import type { CanvasFontReplacementSummary, CanvasFontReplacementTarget, } from './fontReferences'; +import type { LayerUnreadable } from './layerExportGuards'; import type { CanvasLayerPreviewMutation, CanvasProjectMutation } from './mutationContracts'; import type { AdjustmentRefusal } from './render/exactAdjustments'; import type { Rect, ToolId, Vec2 } from './types'; @@ -224,6 +225,12 @@ export interface CanvasInteractionState { textEditSession: TextEditSession | null; textOptions: TextToolOptions; transformSession: TransformSession | null; + /** + * The layer `layers.beginTransform` is waiting on: its session opens once the layer's fresh pixels are persisted. + * Null when nothing waits; it also clears on a tool switch, a selection change, Escape, the layer's removal, a + * document replacement or a persistence failure with no retry to follow. + */ + pendingTransformLayerId: string | null; viewportReady: boolean; zoom: number; } @@ -286,9 +293,28 @@ export interface CanvasCoreStoreCapability { readonly interaction: CanvasInteractionStateCapability; } +/** + * Which workbench region a key belongs to, as the host's focus judge decides: this canvas (`surface`), a widget + * that acts on this canvas's layers (`companion`), or anything else (`elsewhere`). + */ +export type CanvasKeyOwner = 'surface' | 'companion' | 'elsewhere'; + +export interface CanvasSurfaceAttachOptions { + /** + * Names the owner of a keydown the canvas would otherwise act on. Asked only on keydown, after the pipeline's own + * refusals (editable target, open menu or dialog). Without it the canvas judges ownership from focus alone. + */ + readonly resolveKeyOwner?: (event: KeyboardEvent) => CanvasKeyOwner; +} + export interface CanvasSurfaceCapability { /** `keyboardRoot` is the focusable element whose focus gives the canvas its session keys; defaults to the overlay. */ - attach(screenCanvas: HTMLCanvasElement, overlayCanvas: HTMLCanvasElement, keyboardRoot?: HTMLElement): void; + attach( + screenCanvas: HTMLCanvasElement, + overlayCanvas: HTMLCanvasElement, + keyboardRoot?: HTMLElement, + options?: CanvasSurfaceAttachOptions + ): void; detach(): void; resize(cssWidth: number, cssHeight: number, dpr: number): void; } @@ -320,6 +346,7 @@ export interface CanvasDocumentSnapshot { export type PsdExportResult = | AdjustmentRefusal + | LayerUnreadable | 'exported' | 'nothing' | 'too-large' @@ -367,6 +394,13 @@ export interface CanvasHistoryCapability { stepBy(offset: number): Promise; } +/** + * Where an eyedropper sample is in its pick: `preview` on every sampled move while the pick is held, `commit` once on + * release, `cancel` once when the pick ends without a release over the canvas (Escape, a tool change, an aborted + * hold), carrying the last previewed color. + */ +export type ColorSamplePhase = 'preview' | 'commit' | 'cancel'; + /** * Why the engine refused an edit the user started without a caller to report to (a stroke, a shape). An * `AdjustmentRefusal` names a layer whose spatial adjustments cannot run, so its pixels cannot be baked for painting. @@ -426,6 +460,7 @@ export type ExportBakedLayerBlobResult = | 'empty' | 'over-budget'; } + | LayerUnreadable | AdjustmentRefusal; export interface CanvasSelectionCapability { @@ -597,7 +632,10 @@ export interface FilterPreviewInput { export type DuplicateLayersResult = | { readonly status: 'duplicated'; readonly duplicateIds: readonly string[]; readonly selectedLayerId: string } - | { readonly status: SubsetOf | 'nothing' | 'over-budget' }; + | { + readonly status: SubsetOf | 'nothing' | 'over-budget'; + } + | LayerUnreadable; /** * Layer pixel operations report a distinct outcome: refused before anything changed (`busy`, `not-ready`, * `over-budget` when the edit could never be undone), nothing to do, or `failed` when the document refused it. @@ -606,9 +644,11 @@ export type { BooleanRasterResult, ConvertLayerToControlResult, CopyLayerResult, + CropAllLayersResult, CropLayerResult, ExtractMaskedAreaResult, MergeDownResult, + MergeInpaintMasksResult, MergeVisibleResult, RasterizeLayerResult, }; @@ -659,15 +699,28 @@ export interface CanvasEngineToolCapability extends CanvasToolCapability { */ requestColorSample(): Promise; /** - * Routes unclaimed eyedropper samples to the active color target, falling back to brush color if absent or - * declined. Disposal removes only this router, preserving newer registrations. + * Routes unclaimed eyedropper samples to the active color target by phase. A pick previews each sample, then + * commits or cancels exactly once; a declined `preview` falls back to the brush color. Disposal removes only this + * router, preserving newer registrations. */ - setColorSampleRouter(router: (hex: string) => boolean): () => void; + setColorSampleRouter(router: (hex: string, phase: ColorSamplePhase) => boolean): () => void; setInteractionLocked(locked: boolean): void; } +/** Why `beginTransform` opened no session; refusals leave the selection and the active tool unchanged. */ +export type BeginTransformResult = + | 'started' + | SubsetOf + | 'ineligible' + | SubsetOf; + export interface CanvasEngineLayerCapability extends CanvasLayerCapability { applyTransform(): void; + /** + * Selects `layerId`, switches to the transform tool and opens its session, even when transform is already active. + * An open session on another layer is cancelled first. + */ + beginTransform(layerId: string): BeginTransformResult; booleanMergeRasterLayers(upperLayerId: string, operation: BooleanRasterOperation): Promise; cancelTextEdit(): void; cancelTransform(): void; @@ -697,8 +750,15 @@ export interface CanvasEngineLayerCapability extends CanvasLayerCapability { */ copyLayerToControl(layerId: string): Promise; copyLayerToRaster(layerId: string): Promise; + /** + * Crops every raster, control, regional and inpaint leaf to the frame as one undo step, hidden and disabled leaves + * included; locked leaves are skipped and counted, and leaves outside the frame are emptied in place. + */ + cropAllLayersToBbox(): Promise; cropLayerToBbox(layerId: string): Promise; duplicateLayers(layerIds: readonly string[]): Promise; + /** Unions the masks' coverage into one new inpaint mask above the topmost source, removing the sources. */ + mergeInpaintMasks(layerIds: readonly string[]): Promise; mergeLayerDown(upperLayerId: string): Promise; mergeSelectedRasterLayers(layerIds: readonly string[]): Promise; mergeVisibleRasterLayers(): Promise; @@ -729,8 +789,10 @@ export type ControlLayerViewOutcome = 'shown' | 'missing' | 'locked' | 'stale' | export interface CanvasEnginePreviewCapability extends CanvasPreviewCapability { /** - * Sizes `target` and draws the whole document fitted to `maxSizePx`. Returns its drawn rect, or null without a - * document. + * Sizes `target` to fit `maxSizePx` (never above 1:1) and draws the committed document's extent into it: the frame + * joined with every enabled renderable layer's bounds, the same extent `fitToView` fits. Returns that + * document-space rect, which the caller maps between target pixels and document space with (target pixel = + * (document point - rect origin) * target.width / rect.width); null without a document or 2D context. */ drawDocumentOverview(target: HTMLCanvasElement, maxSizePx: number): Rect | null; preloadStagedPreview(imageName: string): void; @@ -812,7 +874,7 @@ export { TEXT_FONT_WEIGHTS, } from './engineStores'; export type { LayerTransform } from './transform/transformMath'; -export type { ImageResolver } from './render/rasterizers'; +export { CanvasImageMissingError, type ImageResolver } from './render/rasterizers'; export type { Rect, SelectionOp, ToolId, Vec2 } from './types'; export { adjustmentsKey, buildCurveLut, isIdentityAdjustments } from './render/adjustments'; export { isAdjustmentRefusal, type AdjustmentRefusal, type AdjustmentRefusalReason } from './render/exactAdjustments'; @@ -836,6 +898,8 @@ export { areSelectedRasterLayersContiguous, canMergeSelectedRasters, canMergeVisibleRasters, + inpaintMaskMergeEligibility, + type InpaintMaskMergeEligibility, } from './document/mergeVisible'; export { documentToExportLocalSamPoint } from './samCoordinates'; export { bboxEquals, constrainBboxToRatio, roundBbox } from './tools/bboxHitTest'; @@ -925,7 +989,7 @@ export { type CanvasNodeMove, type InsertionAnchorCapture, } from './document/insertionAnchors'; -export { isExportableRasterLayer } from './layerExportGuards'; +export { isExportableRasterLayer, isLayerUnreadable, type LayerUnreadable } from './layerExportGuards'; export { getLayerThumbnailFallbackRenderState, nextLayerThumbnailFallbackStage, diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/bakedControlLayer.browser.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/bakedControlLayer.browser.test.ts index eace59dede8..ba9a18caebc 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/bakedControlLayer.browser.test.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/bakedControlLayer.browser.test.ts @@ -107,7 +107,7 @@ const scene = (runSpatial?: SpatialRunner) => { }); const exportBaked: CopyLayerControllerOptions['exportBaked'] = async (layerId) => { const result = await exporter.baked(layerId, { includeDisabled: true }); - if (result.status === 'ok' || result.status === 'adjustment-refused') { + if (result.status === 'ok' || result.status === 'adjustment-refused' || result.status === 'unreadable') { return result; } return { status: result.status === 'aborted' ? 'not-ready' : result.status }; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/booleanMergeController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/booleanMergeController.ts index 85bddcbf1c8..06992552425 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/booleanMergeController.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/booleanMergeController.ts @@ -1,5 +1,6 @@ import type { LayerExportGuard } from '@workbench/canvas-engine/capabilities'; import type { CanvasDocumentContractV3, CanvasLayerContract } from '@workbench/canvas-engine/contracts'; +import type { LayerUnreadable } from '@workbench/canvas-engine/layerExportGuards'; import type { AdjustmentRefusal } from '@workbench/canvas-engine/render/exactAdjustments'; import type { RasterBackend, RasterSurface } from '@workbench/canvas-engine/render/raster'; import type { Rect } from '@workbench/canvas-engine/types'; @@ -16,7 +17,9 @@ import { layerOperationStatus, paintLayerAt, publishAddedRasterLayer, + readinessOfAll, rgbaBytes, + type LayerCacheReadiness, type LayerStepContext, type LayerEditRefusal, } from './editSteps'; @@ -29,11 +32,13 @@ export type BooleanRasterResult = | 'empty' | 'failed' | LayerEditRefusal + | LayerUnreadable | AdjustmentRefusal; type ExportResult = | { status: 'ok'; surface: RasterSurface; rect: Rect; guard: LayerExportGuard; release(): void } | { status: 'missing' | 'disabled' | 'unsupported' | 'empty' | 'not-ready' | 'over-budget' } + | LayerUnreadable | AdjustmentRefusal; export interface BooleanMergeControllerOptions { @@ -49,7 +54,7 @@ export interface BooleanMergeControllerOptions { | 'isPermitCurrent' >; readonly backend: RasterBackend; - readonly isCacheReady: (layer: CanvasLayerContract, document: CanvasDocumentContractV3) => boolean; + readonly cacheReadiness: (layer: CanvasLayerContract, document: CanvasDocumentContractV3) => LayerCacheReadiness; readonly exportBaked: (layerId: string) => Promise; readonly isGuardCurrent: (guard: LayerExportGuard) => boolean; } @@ -86,8 +91,12 @@ export class BooleanMergeController { } const upper = getDocumentLayer(document, eligibility.upperId)!; const below = getDocumentLayer(document, eligibility.lowerId)!; - if (!this.deps.isCacheReady(upper, document) || !this.deps.isCacheReady(below, document)) { - return 'not-ready'; + const readiness = readinessOfAll([ + this.deps.cacheReadiness(upper, document), + this.deps.cacheReadiness(below, document), + ]); + if (readiness !== 'ready') { + return readiness; } const owned: Extract[] = []; const acquire = async (layerId: string): Promise => { @@ -116,6 +125,9 @@ export class BooleanMergeController { if (upperPixels.status === 'over-budget' || belowPixels.status === 'over-budget') { return 'over-budget'; } + if (upperPixels.status === 'unreadable' || belowPixels.status === 'unreadable') { + return upperPixels.status === 'unreadable' ? upperPixels : (belowPixels as LayerUnreadable); + } if (upperPixels.status === 'not-ready' || belowPixels.status === 'not-ready') { return 'not-ready'; } diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/colorSampleController.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/colorSampleController.test.ts new file mode 100644 index 00000000000..4ebf5a6ab70 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/colorSampleController.test.ts @@ -0,0 +1,163 @@ +import type { ColorSamplePhase } from '@workbench/canvas-engine/capabilities'; +import type { ToolId } from '@workbench/canvas-engine/types'; + +import { describe, expect, it } from 'vitest'; + +import { ColorSampleController } from './colorSampleController'; + +/** The controller over a recorded tool; `heldPick` is a press the held tool ends when a request arrives. */ +const createHarness = (initialTool: ToolId = 'brush') => { + const tool = { current: initialTool }; + const routed: [string, ColorSamplePhase][] = []; + const heldPick = { active: false }; + const controller: ColorSampleController = new ColorSampleController({ + // Ending a held picker cancels its pick, as the pointer pipeline does. + endHeldTool: () => { + if (heldPick.active) { + heldPick.active = false; + controller.route('#000000', 'cancel'); + } + }, + getActiveToolId: () => tool.current, + setTool: (toolId) => { + tool.current = toolId; + }, + }); + controller.setRouter((hex, phase) => { + routed.push([hex, phase]); + return true; + }); + const request = () => { + const result: { hex?: string | null } = {}; + void controller.requestSample().then((hex) => { + result.hex = hex; + }); + return result; + }; + return { controller, heldPick, request, routed, tool }; +}; + +const settled = () => Promise.resolve(); + +describe('ColorSampleController', () => { + it('arms the picker and settles a request on the release commit, returning to the previous tool', async () => { + const h = createHarness('brush'); + const result = h.request(); + expect(h.tool.current).toBe('colorPicker'); + + expect(h.controller.route('#111111', 'preview')).toBe(true); + expect(h.controller.route('#222222', 'preview')).toBe(true); + await settled(); + expect(result.hex).toBeUndefined(); + + expect(h.controller.route('#222222', 'commit')).toBe(true); + await settled(); + expect(result.hex).toBe('#222222'); + expect(h.tool.current).toBe('brush'); + expect(h.routed).toEqual([]); + }); + + it('keeps a request armed through a cancelled pick and settles it from the next one', async () => { + const h = createHarness(); + const result = h.request(); + h.controller.route('#111111', 'preview'); + h.controller.route('#111111', 'cancel'); + await settled(); + expect(result.hex).toBeUndefined(); + + h.controller.route('#333333', 'preview'); + h.controller.route('#333333', 'commit'); + await settled(); + expect(result.hex).toBe('#333333'); + }); + + it('sends a pick’s commit or cancel only to the sink that took its first preview', async () => { + const h = createHarness('colorPicker'); + // A standing pick is under way when a request arrives: ending the held picker cancels the router's pick, once. + h.controller.route('#111111', 'preview'); + h.heldPick.active = true; + const first = h.request(); + expect(h.routed).toEqual([ + ['#111111', 'preview'], + ['#000000', 'cancel'], + ]); + h.controller.route('#444444', 'preview'); + h.controller.route('#444444', 'commit'); + await settled(); + expect(first.hex).toBe('#444444'); + + // A request settled by Escape mid-pick keeps that pick's cancel away from the router. + const second = h.request(); + h.controller.route('#555555', 'preview'); + expect(h.controller.onEscape()).toBe(true); + expect(h.controller.route('#555555', 'cancel')).toBe(true); + await settled(); + expect(second.hex).toBeNull(); + expect(h.routed).toHaveLength(2); + + // The other way round: a pick the router took keeps its commit when a request arms mid-pick. + h.controller.route('#666666', 'preview'); + const third = h.request(); + h.controller.route('#666666', 'commit'); + await settled(); + expect(third.hex).toBeUndefined(); + expect(h.routed.at(-1)).toEqual(['#666666', 'commit']); + }); + + it('declines a pick with no request and no router, so the picker falls back to the brush color', () => { + const h = createHarness(); + const remove = h.controller.setRouter(() => true); + remove(); + // The harness router was replaced, so removing the newer one leaves none. + expect(h.controller.route('#111111', 'preview')).toBe(false); + }); + + it('removes a router only while it is still the installed one', () => { + const h = createHarness(); + const routedByLater: ColorSamplePhase[] = []; + const removeEarlier = h.controller.setRouter(() => true); + h.controller.setRouter((_hex, phase) => { + routedByLater.push(phase); + return true; + }); + removeEarlier(); + + expect(h.controller.route('#111111', 'preview')).toBe(true); + expect(routedByLater).toEqual(['preview']); + }); + + it('settles with null, keeping the requested tool, on a switch off the picker; a newer request supersedes', async () => { + const h = createHarness('brush'); + const first = h.request(); + const second = h.request(); + await settled(); + expect(first.hex).toBeNull(); + + h.controller.onToolChange('colorPicker'); + await settled(); + expect(second.hex).toBeUndefined(); + h.controller.onToolChange('move'); + await settled(); + expect(second.hex).toBeNull(); + // The switch the caller requested wins; the controller restores nothing. + expect(h.tool.current).toBe('colorPicker'); + }); + + it('leaves Escape to the next handler when nothing is armed', () => { + expect(createHarness().controller.onEscape()).toBe(false); + }); + + it('settles an armed request on dispose, idempotently, and answers later requests with null', async () => { + const h = createHarness(); + const armed = h.request(); + h.controller.dispose(); + h.controller.dispose(); + await settled(); + expect(armed.hex).toBeNull(); + + const late = h.request(); + await settled(); + expect(late.hex).toBeNull(); + expect(h.controller.route('#111111', 'preview')).toBe(false); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/colorSampleController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/colorSampleController.ts new file mode 100644 index 00000000000..b54290a8b58 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/colorSampleController.ts @@ -0,0 +1,109 @@ +import type { ColorSamplePhase } from '@workbench/canvas-engine/capabilities'; +import type { ToolId } from '@workbench/canvas-engine/types'; + +export interface ColorSampleControllerOptions { + /** The tool the user is on; a settled request returns to it. */ + readonly getActiveToolId: () => ToolId; + /** Switches tools directly; the controller settles its own request around the switch. */ + readonly setTool: (toolId: ToolId) => void; + /** Ends a held temporary tool, so a request records the tool the user was really on. */ + readonly endHeldTool: () => void; +} + +type ColorSampleRouter = (hex: string, phase: ColorSamplePhase) => boolean; + +/** + * Routes eyedropper picks. A one-shot request takes a pick's previews and settles on its release commit, after + * structural edits are allowed; Escape, a tool change and disposal settle it with null. Unclaimed picks go to the + * workbench router while one is installed. A pick belongs to the sink that took its first preview: its commit or + * cancel goes there alone, so a request armed or settled mid-pick never strays into the router, nor the reverse. + */ +export class ColorSampleController { + private request: { previousToolId: ToolId; resolve: (hex: string | null) => void } | null = null; + private router: ColorSampleRouter | null = null; + // Which sink took the current pick's first preview; null between picks. + private pickSink: 'request' | 'router' | null = null; + private disposed = false; + + constructor(private readonly deps: ColorSampleControllerOptions) {} + + /** Arms the color picker and resolves the picked hex on release, or null when cancelled. */ + requestSample(): Promise { + // A second request supersedes the first; the earlier caller gets a cancel. + this.settle(null, false); + if (this.disposed) { + return Promise.resolve(null); + } + this.deps.endHeldTool(); + return new Promise((resolve) => { + this.request = { previousToolId: this.deps.getActiveToolId(), resolve }; + this.deps.setTool('colorPicker'); + }); + } + + /** Takes one sample of a pick; returns whether a sink consumed it, else the picker falls back to the brush. */ + route(hex: string, phase: ColorSamplePhase): boolean { + if (phase === 'preview') { + this.pickSink ??= this.request ? 'request' : 'router'; + } + const sink = this.pickSink; + if (phase !== 'preview') { + this.pickSink = null; + } + if (sink === 'request') { + if (phase === 'commit') { + this.settle(hex, true); + } + return true; + } + return sink === 'router' ? (this.router?.(hex, phase) ?? false) : false; + } + + /** Installs the workbench router; the returned cleanup removes it only while it is still the installed one. */ + setRouter(router: ColorSampleRouter): () => void { + this.router = router; + return () => { + if (this.router === router) { + this.router = null; + } + }; + } + + /** Any move off the eyedropper abandons the sample the caller is awaiting; the requested switch wins. */ + onToolChange(toolId: ToolId): void { + if (toolId !== 'colorPicker') { + this.settle(null, false); + } + } + + /** An armed request is the most recent thing the user opted into, so Escape takes it back first. */ + onEscape(): boolean { + if (!this.request) { + return false; + } + this.settle(null, true); + return true; + } + + private settle(hex: string | null, restoreTool: boolean): void { + const request = this.request; + if (!request) { + return; + } + this.request = null; + request.resolve(hex); + if (restoreTool) { + this.deps.setTool(request.previousToolId); + } + } + + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + this.settle(null, false); + this.router = null; + this.pickSink = null; + } +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/copyLayerController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/copyLayerController.ts index eba639a4ed2..7b7163dfd96 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/copyLayerController.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/copyLayerController.ts @@ -3,6 +3,7 @@ import type { CanvasLayerContract } from '@workbench/canvas-engine/contracts'; import type { CanvasCommandRefusal } from '@workbench/canvas-engine/document/commandRefusal'; import type { CanvasNodeInsertionAnchor } from '@workbench/canvas-engine/document/insertionAnchors'; import type { SubsetOf } from '@workbench/canvas-engine/editConcurrency'; +import type { LayerUnreadable } from '@workbench/canvas-engine/layerExportGuards'; import type { AdjustmentRefusal } from '@workbench/canvas-engine/render/exactAdjustments'; import type { RasterBackend, RasterSurface } from '@workbench/canvas-engine/render/raster'; import type { Rect } from '@workbench/canvas-engine/types'; @@ -26,6 +27,7 @@ import { type ExportResult = | { status: 'ok'; surface: RasterSurface; rect: Rect; guard: LayerExportGuard; release(): void } | { status: 'missing' | 'disabled' | 'unsupported' | 'empty' | 'not-ready' | 'over-budget' } + | LayerUnreadable | AdjustmentRefusal; /** The stack a baked copy lands in: a raster paint layer, or a control layer with a default adapter. */ @@ -33,7 +35,10 @@ export type CopyLayerTarget = 'raster' | 'control'; export type CopyLayerResult = | { status: 'copied'; layerId: string } - | { status: LayerEditRefusal | SubsetOf | 'empty' | 'failed' } + | { + status: LayerEditRefusal | SubsetOf | 'empty' | 'failed'; + } + | LayerUnreadable | AdjustmentRefusal; export interface CopyLayerControllerOptions { @@ -115,6 +120,9 @@ export class CopyLayerController { ? { layerId: layer.id, status: 'copied' } : { status: 'not-ready' }; } + if (baked.status === 'unreadable') { + return baked; + } if (baked.status !== 'ok') { return { status: baked.status === 'disabled' ? 'not-ready' : baked.status }; } diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/cropLayerController.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/cropLayerController.test.ts index 32bf5ebb81b..0d9c68f4d57 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/cropLayerController.test.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/cropLayerController.test.ts @@ -1,8 +1,19 @@ -import type { CanvasDocumentContractV3, CanvasLayerContract } from '@workbench/canvas-engine/contracts'; +import type { + CanvasAdjustmentsContract, + CanvasDocumentContractV3, + CanvasLayerContract, + CanvasNodeContract, +} from '@workbench/canvas-engine/contracts'; +import type { AdjustmentRefusal } from '@workbench/canvas-engine/render/exactAdjustments'; import type { Rect } from '@workbench/canvas-engine/types'; -import { stacksFrom } from '@workbench/canvas-engine/document-model/documentFixtures.testStub'; -import { getDocumentLayer } from '@workbench/canvas-engine/document/documentIndex'; +import { + groupContract, + layerContract, + stacksFrom, +} from '@workbench/canvas-engine/document-model/documentFixtures.testStub'; +import { getDocumentIndex, getDocumentLayer, getDocumentLeaves } from '@workbench/canvas-engine/document/documentIndex'; +import { HISTORY_ENTRY_OVERHEAD_BYTES } from '@workbench/canvas-engine/history/history'; import { describe, expect, it, vi } from 'vitest'; import { CropLayerController } from './cropLayerController'; @@ -23,19 +34,23 @@ const layer: CanvasLayerContract = { const CACHE_RECT: Rect = { height: 50, width: 50, x: -10, y: -10 }; const BBOX: Rect = { height: 20, width: 20, x: 0, y: 0 }; -const createHarness = (historyBytes?: number, target: CanvasLayerContract = layer) => { +const createHarness = ( + historyBytes?: number, + options: { readonly nodes?: CanvasNodeContract[]; readonly unreadable?: boolean } = {} +) => { const document: CanvasDocumentContractV3 = { background: 'transparent', bbox: BBOX, height: 100, - selectedLayerId: target.id, - stacks: stacksFrom([target]), + selectedLayerId: 'paint', + stacks: stacksFrom(options.nodes ?? [layer]), version: 3, width: 100, }; const h = createLayerOperationHarness(document, { historyBytes }); h.layers.getOrCreateRect('paint', CACHE_RECT); const discardPersisted = vi.fn(); + const exported: string[] = []; const controller = new CropLayerController({ backend: h.backend, captureCache: (target) => { @@ -47,6 +62,10 @@ const createHarness = (historyBytes?: number, target: CanvasLayerContract = laye ctx: h.ctx, discardPersisted, exportBaked: (layerId) => { + exported.push(layerId); + if (options.unreadable) { + return Promise.resolve({ layerId, layerName: 'Paint', status: 'unreadable' as const }); + } const entry = h.layers.get(layerId)!; const current = getDocumentLayer(h.document(), layerId)!; return Promise.resolve({ @@ -57,10 +76,11 @@ const createHarness = (historyBytes?: number, target: CanvasLayerContract = laye surface: entry.surface, }); }, + flushPendingPixels: () => Promise.resolve(), isGuardCurrent: () => true, isSupportedSource: () => true, }); - return { ...h, controller, discardPersisted, original: getDocumentLayer(h.document(), target.id)! }; + return { ...h, controller, discardPersisted, exported, original: getDocumentLayer(h.document(), 'paint')! }; }; describe('CropLayerController', () => { @@ -82,7 +102,7 @@ describe('CropLayerController', () => { it("keeps a control layer's type but drops the original its cropped pixels no longer derive from", async () => { const depth = { settings: {}, type: 'depth_anything_depth_estimation' }; - const h = createHarness(undefined, { + const control: CanvasLayerContract = { adapter: { beginEndStepPct: [0, 1], controlMode: 'balanced', kind: 'controlnet', model: null, weight: 1 }, blendMode: 'normal', controlType: 'depth', @@ -99,7 +119,8 @@ describe('CropLayerController', () => { transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, type: 'control', withTransparencyEffect: true, - }); + }; + const h = createHarness(undefined, { nodes: [control] }); await expect(h.controller.crop('paint')).resolves.toEqual({ status: 'cropped' }); const cropped = getDocumentLayer(h.document(), 'paint'); @@ -122,6 +143,35 @@ describe('CropLayerController', () => { expect(h.layers.get('paint')?.rect).toEqual(CACHE_RECT); }); + it('refuses a layer under a locked group, as crop all skips it, without reading it', async () => { + const h = createHarness(undefined, { nodes: [groupContract('G', [layer], { isLocked: true })] }); + + await expect(h.controller.crop('paint')).resolves.toEqual({ status: 'locked' }); + expect(h.exported).toEqual([]); + expect(h.dispatched).toEqual([]); + }); + + it('says a layer that could not be read is unreadable, not still loading', async () => { + const h = createHarness(undefined, { unreadable: true }); + + await expect(h.controller.crop('paint')).resolves.toEqual({ + layerId: 'paint', + layerName: 'Paint', + status: 'unreadable', + }); + expect(h.dispatched).toEqual([]); + expect(h.history.canUndo()).toBe(false); + }); + + it('refuses to empty a layer that lies wholly outside the frame', async () => { + const h = createHarness(); + h.layers.delete('paint'); + h.layers.getOrCreateRect('paint', { height: 10, width: 10, x: 60, y: 60 }); + + await expect(h.controller.crop('paint')).resolves.toEqual({ status: 'outside-frame' }); + expect(h.dispatched).toEqual([]); + }); + it('leaves the step on the undo stack when its replay is refused', async () => { const h = createHarness(); await h.controller.crop('paint'); @@ -132,3 +182,421 @@ describe('CropLayerController', () => { expect(h.history.canUndo()).toBe(true); }); }); + +const FRAME: Rect = { height: 20, width: 20, x: 0, y: 0 }; + +/** Each leaf's cache rect, which the fake export also reports as its baked pixels. */ +const CACHE_RECTS: Record = { + control: { height: 10, width: 30, x: 5, y: 5 }, + inside: { height: 5, width: 5, x: 2, y: 2 }, + locked: { height: 50, width: 50, x: -10, y: -10 }, + lockedByGroup: { height: 50, width: 50, x: -10, y: -10 }, + lockedInside: { height: 5, width: 5, x: 2, y: 2 }, + mask: { height: 10, width: 10, x: 60, y: 60 }, + raster: { height: 50, width: 50, x: -10, y: -10 }, + region: { height: 10, width: 10, x: -5, y: 0 }, +}; + +const bytes = (rect: Rect): number => rect.width * rect.height * 4; + +/** A locked paint leaf whose persisted pixels cover its cache rect. */ +const lockedPaint = (id: string, locked = true) => + layerContract(id, 'raster', { + isLocked: locked, + source: { + bitmap: { height: CACHE_RECTS[id]!.height, imageName: `${id}.png`, width: CACHE_RECTS[id]!.width }, + offset: { x: CACHE_RECTS[id]!.x, y: CACHE_RECTS[id]!.y }, + type: 'paint', + }, + } as Partial); + +const CLARITY: CanvasAdjustmentsContract = [{ amount: 90, id: 'clarity', isEnabled: true, size: 8, type: 'clarity' }]; + +const createCropAllHarness = ( + options: { + /** Raster leaves carrying a Clarity stack. */ + adjusted?: readonly string[]; + historyBytes?: number; + onExport?: (layerId: string) => void; + /** Refuses the owner's bake; its raw export still succeeds. */ + refusal?: AdjustmentRefusal; + /** Leaves added on top whose pixels cannot be read. */ + unreadable?: readonly CanvasLayerContract[]; + /** Leaves whose pixels are not ready (still loading, or their image could not be fetched for now). */ + notReady?: readonly string[]; + /** Settles pending paint uploads; resolves at once by default. */ + flushPendingPixels?: () => Promise; + } = {} +) => { + const raster = (id: string) => + layerContract( + id, + 'raster', + options.adjusted?.includes(id) ? ({ adjustments: CLARITY } as Partial) : {} + ); + const document: CanvasDocumentContractV3 = { + background: 'transparent', + bbox: FRAME, + height: 100, + selectedLayerId: 'raster', + stacks: stacksFrom([ + ...(options.unreadable ?? []), + raster('inside'), + raster('raster'), + lockedPaint('locked'), + lockedPaint('lockedInside'), + groupContract('lockedGroup', [lockedPaint('lockedByGroup', false)], { isLocked: true }), + // A depth layer keeping the original its map was detected from. + layerContract('control', 'control', { + controlType: 'depth', + detection: { settings: {}, type: 'depth_anything_depth_estimation' }, + isEnabled: false, + original: { image: { height: 20, imageName: 'photo.png', width: 20 }, type: 'image' }, + } as Partial), + groupContract('regions', [layerContract('region', 'regional_guidance')]), + layerContract('mask', 'inpaint_mask', { isHidden: true } as Partial), + ]), + version: 3, + width: 100, + }; + const h = createLayerOperationHarness(document, { historyBytes: options.historyBytes }); + for (const [id, rect] of Object.entries(CACHE_RECTS)) { + h.layers.getOrCreateRect(id, rect); + } + // Raw reads of adjusted leaves are recorded as `:raw`. + const exported: string[] = []; + const released: string[] = []; + // Reserved raster bytes when each export is read; every export holds its own reservation until released. + const reservedAtExport: number[] = []; + const guard = { current: true }; + const discardPersisted = vi.fn(); + const controller = new CropLayerController({ + backend: h.backend, + captureCache: (target, _document, admit = () => true) => { + const entry = h.layers.get(target.id)!; + if (!admit(entry.rect)) { + return 'over-budget'; + } + const pixels = h.backend.createSurface(entry.rect.width, entry.rect.height); + pixels.ctx.drawImage(entry.surface.canvas, 0, 0); + return { pixels, rect: { ...entry.rect } }; + }, + ctx: h.ctx, + discardPersisted, + exportBaked: (layerId, exportOptions) => { + const read = exportOptions?.applyAdjustments === false ? `${layerId}:raw` : layerId; + exported.push(read); + options.onExport?.(layerId); + const unreadable = options.unreadable?.find((leaf) => leaf.id === layerId); + if (unreadable) { + return Promise.resolve({ layerId, layerName: unreadable.name, status: 'unreadable' as const }); + } + if (options.notReady?.includes(layerId)) { + return Promise.resolve({ status: 'not-ready' as const }); + } + if (options.refusal?.ownerId === layerId && read === layerId) { + return Promise.resolve(options.refusal); + } + const entry = h.layers.get(layerId)!; + const reservation = h.ctx.reserveRaster(bytes(entry.rect)); + reservedAtExport.push(h.reservedBytes()); + return Promise.resolve({ + guard: { layer: getDocumentLayer(h.document(), layerId) } as never, + rect: { ...entry.rect }, + release: () => { + reservation?.release(); + released.push(read); + }, + status: 'ok' as const, + surface: entry.surface, + }); + }, + flushPendingPixels: options.flushPendingPixels ?? (() => Promise.resolve()), + isGuardCurrent: () => guard.current, + isSupportedSource: () => true, + }); + return { ...h, controller, discardPersisted, exported, guard, original: h.document(), released, reservedAtExport }; +}; + +describe('CropLayerController: crop all layers', () => { + it.each(['too-large', 'over-budget', 'error'] as const)( + 'refuses the whole crop on a %s adjustment of a leaf it must crop, releasing earlier copies', + async (reason) => { + const refusal: AdjustmentRefusal = { + ownerId: 'raster', + ownerName: 'Raster', + reason, + status: 'adjustment-refused', + }; + const h = createCropAllHarness({ adjusted: ['raster'], refusal }); + + await expect(h.controller.cropAll()).resolves.toEqual(refusal); + // The raw read shows the leaf reaches past the frame, so its stack must run. + expect(h.exported).toEqual(['mask', 'region', 'control', 'inside', 'raster:raw', 'raster']); + expect(h.released).toEqual(['mask', 'region', 'control', 'inside', 'raster:raw']); + expect(h.document()).toBe(h.original); + expect(h.dispatched).toEqual([]); + expect(h.history.canUndo()).toBe(false); + expect(h.reservedBytes()).toBe(0); + } + ); + + it('never runs the stack of an adjusted leaf already inside the frame', async () => { + const refusal: AdjustmentRefusal = { + ownerId: 'inside', + ownerName: 'Inside', + reason: 'too-large', + status: 'adjustment-refused', + }; + const h = createCropAllHarness({ adjusted: ['inside'], refusal }); + + await expect(h.controller.cropAll()).resolves.toEqual({ + cropped: 4, + skippedLocked: 2, + skippedUnreadable: [], + status: 'cropped', + }); + expect(h.exported).toEqual(['mask', 'region', 'control', 'inside:raw', 'raster']); + expect(getDocumentLayer(h.document(), 'inside')).toBe(getDocumentLayer(h.original, 'inside')); + expect(h.layers.get('raster')?.rect).toEqual(FRAME); + expect(h.reservedBytes()).toBe(0); + }); + + it('bakes an adjusted leaf it must crop, after releasing its raw read', async () => { + const h = createCropAllHarness({ adjusted: ['raster'] }); + + await expect(h.controller.cropAll()).resolves.toEqual({ + cropped: 4, + skippedLocked: 2, + skippedUnreadable: [], + status: 'cropped', + }); + expect(h.exported).toEqual(['mask', 'region', 'control', 'inside', 'raster:raw', 'raster']); + // The raw read is released before the bake reserves, so the peak stays one export. + const croppedRegion = bytes({ height: 10, width: 5, x: 0, y: 0 }); + const croppedControl = bytes({ height: 10, width: 15, x: 0, y: 0 }); + expect(h.reservedAtExport.at(-1)).toBe(croppedRegion + croppedControl + bytes(CACHE_RECTS.raster!)); + expect(getDocumentLayer(h.document(), 'raster')).not.toHaveProperty('adjustments'); + expect(h.layers.get('raster')?.rect).toEqual(FRAME); + expect(h.reservedBytes()).toBe(0); + }); + + it('crops every unlocked leaf that reaches past the frame in one entry, and replays it as one', async () => { + const h = createCropAllHarness(); + + await expect(h.controller.cropAll()).resolves.toEqual({ + cropped: 4, + skippedLocked: 2, + skippedUnreadable: [], + status: 'cropped', + }); + + // Hidden, disabled and grouped leaves crop too; locked ones are never read, and only those reaching past the + // frame count as skipped. + expect(h.exported).toEqual(['mask', 'region', 'control', 'inside', 'raster']); + // Each export is released before the next is read, keeping only the cropped pixels: the peak is one export. + const croppedRegion = bytes({ height: 10, width: 5, x: 0, y: 0 }); + const croppedControl = bytes({ height: 10, width: 15, x: 0, y: 0 }); + expect(h.reservedAtExport).toEqual([ + bytes(CACHE_RECTS.mask!), + bytes(CACHE_RECTS.region!), + croppedRegion + bytes(CACHE_RECTS.control!), + croppedRegion + croppedControl + bytes(CACHE_RECTS.inside!), + croppedRegion + croppedControl + bytes(CACHE_RECTS.raster!), + ]); + expect(h.released.sort()).toEqual(h.exported.sort()); + expect(h.dispatched.map((mutation) => mutation.type)).toEqual(['replaceCanvasLayers']); + expect(h.history.entries().past).toEqual(['Crop all layers to frame']); + // Before and after pixels of every cropped leaf, admitted once for the entry. + const after = + bytes(FRAME) + bytes({ height: 10, width: 15, x: 0, y: 0 }) + bytes({ height: 10, width: 5, x: 0, y: 0 }); + const before = ['raster', 'control', 'mask', 'region'].reduce((sum, id) => sum + bytes(CACHE_RECTS[id]!), 0); + expect(h.history.byteSize()).toBe(before + after + HISTORY_ENTRY_OVERHEAD_BYTES); + expect(h.reservedBytes()).toBe(0); + + const cropped = h.document(); + expect(h.layers.get('raster')?.rect).toEqual(FRAME); + expect(h.layers.get('control')?.rect).toEqual({ height: 10, width: 15, x: 5, y: 5 }); + expect(h.layers.get('region')?.rect).toEqual({ height: 10, width: 5, x: 0, y: 0 }); + // The mask outside the frame stays, emptied. + expect(h.layers.get('mask')?.rect).toMatchObject({ height: 0, width: 0 }); + expect(getDocumentLayer(cropped, 'mask')).toMatchObject({ isHidden: true, mask: { bitmap: null } }); + expect(getDocumentLayer(cropped, 'control')).toMatchObject({ + controlType: 'depth', + detection: null, + isEnabled: false, + source: { offset: { x: 5, y: 5 } }, + }); + // The cropped map no longer derives from the original, so the original goes; undo brings it back. + expect(getDocumentLayer(cropped, 'control')).not.toHaveProperty('original'); + for (const id of ['inside', 'locked', 'lockedInside', 'lockedByGroup']) { + expect(getDocumentLayer(cropped, id)).toBe(getDocumentLayer(h.original, id)); + expect(h.layers.get(id)?.rect).toEqual(CACHE_RECTS[id]); + } + // Structure is untouched: same ids, parents and order. + const structure = (document: CanvasDocumentContractV3) => + [...getDocumentIndex(document).byId.values()].map((entry) => [entry.node.id, entry.parentId, entry.siblingIndex]); + expect(structure(cropped)).toEqual(structure(h.original)); + expect(h.discardPersisted.mock.calls.map(([id]) => id).sort()).toEqual(['control', 'mask', 'raster', 'region']); + + await expect(h.history.undo()).resolves.toEqual({ status: 'applied' }); + expect(getDocumentLeaves(h.document())).toEqual(getDocumentLeaves(h.original)); + for (const id of ['raster', 'control', 'mask', 'region']) { + expect(h.layers.get(id)?.rect).toEqual(CACHE_RECTS[id]); + } + + await expect(h.history.redo()).resolves.toEqual({ status: 'applied' }); + expect(getDocumentLeaves(h.document())).toEqual(getDocumentLeaves(cropped)); + expect(h.layers.get('raster')?.rect).toEqual(FRAME); + }); + + it('skips leaves whose pixels cannot be read and crops the rest, naming those that reach past the frame', async () => { + /** An image layer whose decode failed; its contract still says where its content lies. */ + const unreadableImage = (id: string, name: string, x: number) => + layerContract(id, 'raster', { + name, + source: { image: { height: 10, imageName: `${id}.png`, width: 10 }, type: 'image' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x, y: 0 }, + } as Partial); + const h = createCropAllHarness({ + unreadable: [ + unreadableImage('gone', 'Sky', 15), + unreadableImage('goneInside', 'Inside sky', 5), + unreadableImage('goneLeft', 'Hills', -5), + ], + }); + + await expect(h.controller.cropAll()).resolves.toEqual({ + cropped: 4, + skippedLocked: 2, + skippedUnreadable: [ + { layerId: 'gone', layerName: 'Sky', status: 'unreadable' }, + { layerId: 'goneLeft', layerName: 'Hills', status: 'unreadable' }, + ], + status: 'cropped', + }); + expect(h.exported).toEqual(['mask', 'region', 'control', 'gone', 'goneInside', 'goneLeft', 'inside', 'raster']); + expect(getDocumentLayer(h.document(), 'gone')).toBe(getDocumentLayer(h.original, 'gone')); + expect(getDocumentLayer(h.document(), 'raster')).not.toBe(getDocumentLayer(h.original, 'raster')); + expect(h.history.entries().past).toEqual(['Crop all layers to frame']); + }); + + it('refuses as not ready, without skipping, when a leaf it must read is not ready', async () => { + const h = createCropAllHarness({ notReady: ['control'] }); + + await expect(h.controller.cropAll()).resolves.toEqual({ status: 'not-ready' }); + expect(h.document()).toBe(h.original); + expect(h.dispatched).toEqual([]); + expect(h.history.canUndo()).toBe(false); + expect(h.reservedBytes()).toBe(0); + }); + + it('settles pending paint uploads before it reads any leaf', async () => { + let settle!: () => void; + const h = createCropAllHarness({ + flushPendingPixels: () => + new Promise((resolve) => { + settle = resolve; + }), + }); + + const crop = h.controller.cropAll(); + await Promise.resolve(); + expect(h.exported).toEqual([]); + settle(); + + await expect(crop).resolves.toMatchObject({ cropped: 4, status: 'cropped' }); + }); + + it('reports nothing when every unlocked leaf already fits the frame', async () => { + const h = createCropAllHarness(); + for (const id of ['raster', 'control', 'mask', 'region']) { + h.layers.delete(id); + h.layers.getOrCreateRect(id, CACHE_RECTS.inside!); + } + + await expect(h.controller.cropAll()).resolves.toEqual({ + skippedLocked: 2, + skippedUnreadable: [], + status: 'nothing', + }); + expect(h.dispatched).toEqual([]); + expect(h.history.canUndo()).toBe(false); + }); + + it('refuses a set it could never undo with nothing changed and every lease released', async () => { + const h = createCropAllHarness({ historyBytes: 8 * 1024 }); + + await expect(h.controller.cropAll()).resolves.toEqual({ status: 'over-budget' }); + expect(h.document()).toBe(h.original); + expect(h.dispatched).toEqual([]); + expect(h.layers.get('raster')?.rect).toEqual(CACHE_RECTS.raster); + expect(h.history.byteSize()).toBe(0); + expect(h.reservedBytes()).toBe(0); + expect(h.released.sort()).toEqual(h.exported.sort()); + }); + + it('releases every reservation when copying the cropped pixels throws', async () => { + const h = createCropAllHarness(); + const createSurface = h.backend.createSurface.bind(h.backend); + // The emptied mask's zero-sized copy succeeds; the first real crop, after its reservation, throws. + vi.spyOn(h.backend, 'createSurface').mockImplementation((width: number, height: number) => { + if (width > 0) { + throw new Error('surface allocation failed'); + } + return createSurface(width, height); + }); + + await expect(h.controller.cropAll()).resolves.toEqual({ status: 'failed' }); + expect(h.reservedBytes()).toBe(0); + expect(h.document()).toBe(h.original); + expect(h.released).toEqual(h.exported); + }); + + it('refuses as over-budget when the cropped pixels cannot be reserved', async () => { + const h = createCropAllHarness(); + h.state.rasterBytes = 1024; + + await expect(h.controller.cropAll()).resolves.toEqual({ status: 'over-budget' }); + expect(h.document()).toBe(h.original); + expect(h.history.canUndo()).toBe(false); + }); + + it('is stale when the document changes while the pixels are read, and changes nothing', async () => { + let edited = false; + const h = createCropAllHarness({ + onExport: (layerId) => { + if (layerId === 'control' && !edited) { + edited = true; + h.ctx.applyStep({ + accepted: (document) => document?.bbox.width === 30, + mutation: { bbox: { ...FRAME, width: 30 }, type: 'setCanvasBbox' }, + }); + } + }, + }); + await expect(h.controller.cropAll()).resolves.toEqual({ status: 'stale' }); + expect(h.dispatched.map((mutation) => mutation.type)).toEqual(['setCanvasBbox']); + expect(getDocumentLeaves(h.document())).toEqual(getDocumentLeaves(h.original)); + expect(h.history.canUndo()).toBe(false); + expect(h.released.sort()).toEqual(h.exported.sort()); + }); + + it('is stale when a leaf pixel guard went out of date', async () => { + const h = createCropAllHarness(); + h.guard.current = false; + + await expect(h.controller.cropAll()).resolves.toEqual({ status: 'stale' }); + expect(h.dispatched).toEqual([]); + }); + + it('leaves the entry on the undo stack when its replay is refused', async () => { + const h = createCropAllHarness(); + await h.controller.cropAll(); + h.state.refuse = () => true; + + expect((await h.history.undo()).status).toBe('failed'); + expect(h.layers.get('raster')?.rect).toEqual(FRAME); + expect(h.history.canUndo()).toBe(true); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/cropLayerController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/cropLayerController.ts index 08f7281ed16..69ee3836d40 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/cropLayerController.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/cropLayerController.ts @@ -1,18 +1,22 @@ -import type { LayerExportGuard } from '@workbench/canvas-engine/capabilities'; +import type { ExportBakedLayerPixelsOptions, LayerExportGuard } from '@workbench/canvas-engine/capabilities'; import type { CanvasDocumentContractV3, CanvasLayerContract, CanvasLayerSourceContract, } from '@workbench/canvas-engine/contracts'; +import type { SemanticLeaf } from '@workbench/canvas-engine/document-model/semanticLeaf'; +import type { LayerUnreadable } from '@workbench/canvas-engine/layerExportGuards'; import type { AdjustmentRefusal } from '@workbench/canvas-engine/render/exactAdjustments'; import type { RasterBackend, RasterSurface } from '@workbench/canvas-engine/render/raster'; import type { Rect } from '@workbench/canvas-engine/types'; +import { compileDocumentLeaves, lookupDocumentLeaf } from '@workbench/canvas-engine/document-model/documentModel'; import { detachControlDerivation } from '@workbench/canvas-engine/document/controlDerivation'; import { getDocumentLayer } from '@workbench/canvas-engine/document/documentIndex'; -import { renderableSourceOf } from '@workbench/canvas-engine/document/sources'; +import { getSourceContentRect, renderableSourceOf } from '@workbench/canvas-engine/document/sources'; import { collectHistoryMediaRefs, HISTORY_ENTRY_OVERHEAD_BYTES } from '@workbench/canvas-engine/history/history'; -import { intersect, isEmpty, roundOut } from '@workbench/canvas-engine/math/rect'; +import { intersect, isEmpty, roundOut, transformBounds } from '@workbench/canvas-engine/math/rect'; +import { isIdentityAdjustments } from '@workbench/canvas-engine/render/adjustments'; import type { CanvasMutationContext, EditStep } from './mutationContext'; @@ -23,18 +27,44 @@ import { withReplayReservation, type LayerEditRefusal, replaceLayerStep, + replaceLayersStep, type LayerPixels, } from './editSteps'; +/** + * `locked`: the layer or a group above it is locked. `outside-frame`: the layer lies wholly outside the frame, so the + * crop would empty it (crop all does that; an explicit crop of one layer refuses). {@link LayerUnreadable}: its + * pixels could not be read (an image that is gone or does not decode). + */ export type CropLayerResult = | { status: 'cropped' } - | { status: 'missing' | 'locked' | 'unsupported' | 'empty' | LayerEditRefusal } + | { status: 'missing' | 'locked' | 'unsupported' | 'empty' | 'outside-frame' | LayerEditRefusal } | { status: 'failed'; message: string } + | LayerUnreadable + | AdjustmentRefusal; + +/** What crop all left alone: how many locked leaves, and each unreadable one in document order. */ +interface CropAllSkipped { + readonly skippedLocked: number; + readonly skippedUnreadable: readonly LayerUnreadable[]; +} + +/** + * `skippedLocked` counts locked leaves (own or ancestor lock) and `skippedUnreadable` lists leaves whose pixels could + * not be read, each only when its content reaches past the frame; both are left as they are. `nothing` when every + * other leaf already fits; every refusal leaves document, pixels and history alone. A leaf whose image could not be + * fetched for now refuses the whole crop as `not-ready`, since trying again can crop it too. + */ +export type CropAllLayersResult = + | ({ status: 'cropped'; cropped: number } & CropAllSkipped) + | ({ status: 'nothing' } & CropAllSkipped) + | { status: 'stale' | 'failed' | LayerEditRefusal } | AdjustmentRefusal; type ExportResult = | { status: 'ok'; surface: RasterSurface; rect: Rect; guard: LayerExportGuard; release(): void } | { status: 'missing' | 'disabled' | 'unsupported' | 'empty' | 'not-ready' | 'over-budget' } + | LayerUnreadable | AdjustmentRefusal; export interface CropLayerControllerOptions { @@ -52,7 +82,10 @@ export interface CropLayerControllerOptions { >; readonly backend: RasterBackend; readonly isSupportedSource: (source: CanvasLayerSourceContract) => boolean; - readonly exportBaked: (layerId: string) => Promise; + readonly exportBaked: ( + layerId: string, + options?: Pick + ) => Promise; readonly isGuardCurrent: (guard: LayerExportGuard) => boolean; readonly captureCache: ( layer: CanvasLayerContract, @@ -60,11 +93,81 @@ export interface CropLayerControllerOptions { admit?: (rect: Rect) => boolean ) => LayerPixels | null | 'not-ready' | 'over-budget'; readonly discardPersisted: (layerId: string) => void; + /** + * Settles paint uploads already owed (the persistence barrier canvas invocation crosses), so an acknowledgement + * replacing a layer's contract cannot land while crop all reads every leaf and refuse it as `stale`. + */ + readonly flushPendingPixels: () => Promise; } const failedCrop: CropLayerResult = { message: 'The crop could not be applied.', status: 'failed' }; -/** Owns guarded crop-to-bbox conversion and replayable pixel snapshots. */ +/** + * Whether a crop may change `leaf`, asked by single crop and crop all alike: never when it or a group above it is + * locked, and only for a source the export can bake. + */ +const cropEligibility = ( + leaf: SemanticLeaf, + isSupportedSource: (source: CanvasLayerSourceContract) => boolean +): 'eligible' | 'locked' | 'unsupported' => { + if (leaf.effectiveLocked) { + return 'locked'; + } + const source = renderableSourceOf(leaf.layer); + return source && isSupportedSource(source) ? 'eligible' : 'unsupported'; +}; + +/** + * The contract of `layer` cropped to `rect`: its baked pixels now sit at `rect` in a paint source (or mask bitmap) with + * an identity transform, so transform, raster adjustments and control filter no longer apply. The bitmap is + * written by persistence once the cropped pixels land. + */ +const croppedLayerContract = (layer: CanvasLayerContract, rect: Rect): CanvasLayerContract => { + const before = structuredClone(layer); + const identity = { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }; + const offset = { x: rect.x, y: rect.y }; + if (before.type === 'raster') { + const { adjustments: _adjustments, ...rest } = before; + return { ...rest, source: { bitmap: null, offset, type: 'paint' }, transform: identity }; + } + if (before.type === 'control') { + // The cropped pixels become the image itself: no original's frame survives the crop. + const { filter: _filter, ...rest } = before; + return detachControlDerivation({ ...rest, source: { bitmap: null, offset, type: 'paint' }, transform: identity }); + } + return { ...before, mask: { ...before.mask, bitmap: null, offset }, transform: identity }; +}; + +/** `exported` pixels (placed at `exportedRect`) copied into a new surface covering exactly `rect`. */ +const cropPixels = (backend: RasterBackend, exported: RasterSurface, exportedRect: Rect, rect: Rect): RasterSurface => { + const cropped = backend.createSurface(rect.width, rect.height); + if (rect.width > 0 && rect.height > 0) { + cropped.ctx.setTransform(1, 0, 0, 1, 0, 0); + cropped.ctx.clearRect(0, 0, rect.width, rect.height); + cropped.ctx.drawImage(exported.canvas, exportedRect.x - rect.x, exportedRect.y - rect.y); + } + return cropped; +}; + +/** What crop all makes of one leaf's export. */ +type CropExport = Extract | { status: 'skip' } | LayerUnreadable | CropAllLayersResult; + +/** One leaf's crop: its contract and pixels before and after. */ +interface LayerCrop { + readonly before: CanvasLayerContract; + readonly beforePixels: LayerPixels; + readonly after: CanvasLayerContract; + readonly afterPixels: LayerPixels; +} + +/** A leaf's cropped pixels, copied out of its export under their own reservation, so the export is already released. */ +interface CroppedLeaf { + readonly layer: CanvasLayerContract; + readonly pixels: LayerPixels; + readonly reservation: { release(): void }; +} + +/** Owns guarded crop-to-frame conversion, of one layer or all of them, and replayable pixel snapshots. */ export class CropLayerController { private disposed = false; constructor(private readonly deps: CropLayerControllerOptions) {} @@ -76,20 +179,17 @@ export class CropLayerController { return { status: 'busy' }; } const document = ctx.getDocument(); - const layer = getDocumentLayer(document, layerId); - if (!document || !layer) { + const leaf = document ? lookupDocumentLeaf(document, layerId) : null; + if (!document || !leaf) { return { status: 'missing' }; } - if (layer.isLocked) { - return { status: 'locked' }; - } - const source = renderableSourceOf(layer); - if (!source || !this.deps.isSupportedSource(source)) { - return { status: 'unsupported' }; + const eligibility = cropEligibility(leaf, this.deps.isSupportedSource); + if (eligibility !== 'eligible') { + return { status: eligibility }; } try { const exported = await this.deps.exportBaked(layerId); - if (exported.status === 'adjustment-refused') { + if (exported.status === 'adjustment-refused' || exported.status === 'unreadable') { return exported; } if (exported.status !== 'ok') { @@ -100,23 +200,25 @@ export class CropLayerController { return { status: 'busy' }; } const liveDocument = ctx.getDocument(); - const liveLayer = getDocumentLayer(liveDocument, layerId); - if (!liveDocument || !liveLayer) { + const liveLeaf = liveDocument ? lookupDocumentLeaf(liveDocument, layerId) : null; + if (!liveDocument || !liveLeaf) { return { status: 'missing' }; } - if (liveLayer.isLocked) { + const liveEligibility = cropEligibility(liveLeaf, this.deps.isSupportedSource); + if (liveEligibility === 'locked') { return { status: 'locked' }; } if (!this.deps.isGuardCurrent(exported.guard)) { return { status: 'not-ready' }; } - const liveSource = renderableSourceOf(liveLayer); - if (!liveSource || !this.deps.isSupportedSource(liveSource)) { + if (liveEligibility === 'unsupported') { return { status: 'unsupported' }; } + const liveLayer = liveLeaf.layer; const overlap = intersect(exported.rect, roundOut(liveDocument.bbox)); + // Crop all empties such a layer in place; asked for this one layer, emptying it is never what was meant. if (!overlap || isEmpty(overlap)) { - return { status: 'empty' }; + return { status: 'outside-frame' }; } const cropRect = roundOut(overlap); const txn = ctx.begin({ historyBytes: rgbaBytes(cropRect) + HISTORY_ENTRY_OVERHEAD_BYTES }); @@ -138,37 +240,16 @@ export class CropLayerController { return { status: 'not-ready' }; } const before = structuredClone(liveLayer); - const cropped = this.deps.backend.createSurface(cropRect.width, cropRect.height); - cropped.ctx.setTransform(1, 0, 0, 1, 0, 0); - cropped.ctx.clearRect(0, 0, cropRect.width, cropRect.height); - cropped.ctx.drawImage(exported.surface.canvas, exported.rect.x - cropRect.x, exported.rect.y - cropRect.y); - const identity = { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }; - const paint = { bitmap: null, offset: { x: cropRect.x, y: cropRect.y }, type: 'paint' } as const; - let after: CanvasLayerContract; - if (before.type === 'raster') { - const { adjustments: _adjustments, ...rest } = before; - after = { ...rest, source: paint, transform: identity }; - } else if (before.type === 'control') { - // The cropped pixels become the image itself: no original's frame survives the crop. - const { filter: _filter, ...rest } = before; - after = detachControlDerivation({ ...rest, source: paint, transform: identity }); - } else { - after = { ...before, mask: { ...before.mask, bitmap: null, offset: paint.offset }, transform: identity }; - } + const cropped = cropPixels(this.deps.backend, exported.surface, exported.rect, cropRect); + const after = croppedLayerContract(before, cropRect); const afterPixels: LayerPixels = { pixels: cropped, rect: cropRect }; /** Replaces the layer's contract and cache together; a failed replacement restores the current contract. */ const replaceStep = (contract: CanvasLayerContract, snapshot: LayerPixels): EditStep => replaceLayerStep(ctx, contract, ctx.preparePixels(layerId, snapshot.rect, snapshot.pixels), { - beforeInstall: () => { - try { - this.deps.discardPersisted(layerId); - } catch { - // The installed pixels are marked dirty and persist on their own. - } - }, + beforeInstall: () => this.discardPersisted(layerId), restore: getDocumentLayer(ctx.getReducerDocument(), layerId) ?? undefined, }); - const result = txn.publish('Crop layer to bbox', replaceStep(after, afterPixels), { + const result = txn.publish('Crop layer to frame', replaceStep(after, afterPixels), { bytes: rgbaBytes(beforePixels.rect) + rgbaBytes(cropRect) + HISTORY_ENTRY_OVERHEAD_BYTES, heldAssetRefs: collectHistoryMediaRefs(before, after), redo: () => @@ -191,7 +272,247 @@ export class CropLayerController { } } + /** + * Crops every leaf of every stack to the frame as one history entry. Locked leaves (their own lock or an ancestor's) + * and leaves whose pixels cannot be read are skipped; leaves already inside the frame, empty ones and unsupported + * sources are left alone; a leaf entirely outside the frame is emptied in place, so ids, order, groups and flags + * never change. Pending paint uploads settle first. Each leaf's export is released as soon as its cropped pixels + * are copied, so the peak is the cropped set plus one export. + */ + async cropAll(): Promise { + const { ctx } = this.deps; + const permit = ctx.capturePermit(); + if (this.disposed || !permit) { + return { status: 'busy' }; + } + try { + await this.deps.flushPendingPixels(); + } catch { + // A layer whose upload failed or is held still crops from its live pixels; the guards below still fence it. + } + if (this.disposed || !ctx.isPermitCurrent(permit)) { + return { status: 'busy' }; + } + const document = ctx.getDocument(); + if (!document) { + return { skippedLocked: 0, skippedUnreadable: [], status: 'nothing' }; + } + const frame = roundOut(document.bbox); + /** Whether cropping would change `leaf`, judged from its contract: content reaching past the frame. */ + const reachesPastFrame = (leaf: SemanticLeaf): boolean => { + const content = getSourceContentRect(leaf.layer, document); + return !isEmpty(content) && cropRectFor(transformBounds(leaf.worldTransform, content), frame) !== null; + }; + let skippedLocked = 0; + const skippedUnreadable: LayerUnreadable[] = []; + const candidates: SemanticLeaf[] = []; + for (const leaf of compileDocumentLeaves(document)) { + const eligibility = cropEligibility(leaf, this.deps.isSupportedSource); + if (eligibility === 'eligible') { + candidates.push(leaf); + } else if (eligibility === 'locked' && reachesPastFrame(leaf)) { + skippedLocked += 1; + } + } + const guards: LayerExportGuard[] = []; + const cropped: CroppedLeaf[] = []; + try { + for (const leaf of candidates) { + const { layer } = leaf; + const exported = await this.exportForCrop(layer, frame); + if (exported.status === 'skip') { + continue; + } + if (exported.status === 'unreadable') { + // Waiting cannot make it readable; one such layer must not hold every other layer back. + if (reachesPastFrame(leaf)) { + skippedUnreadable.push(exported); + } + continue; + } + if (exported.status !== 'ok') { + return exported; + } + try { + guards.push(exported.guard); + const rect = cropRectFor(exported.rect, frame); + if (!rect) { + continue; + } + const reservation = ctx.reserveRaster(rgbaBytes(rect)); + if (!reservation) { + return { status: 'over-budget' }; + } + let pixels: RasterSurface; + try { + pixels = cropPixels(this.deps.backend, exported.surface, exported.rect, rect); + } catch (error) { + reservation.release(); + throw error; + } + cropped.push({ layer, pixels: { pixels, rect }, reservation }); + } finally { + exported.release(); + } + } + if (!ctx.isPermitCurrent(permit)) { + return { status: 'busy' }; + } + const liveDocument = ctx.getDocument(); + // Any edit while the pixels were read (a layer, the frame, the stacks) makes the plan stale. + if ( + !liveDocument || + liveDocument.stacks !== document.stacks || + !isSameRect(liveDocument.bbox, document.bbox) || + guards.some((guard) => !this.deps.isGuardCurrent(guard)) + ) { + return { status: 'stale' }; + } + if (cropped.length === 0) { + return { skippedLocked, skippedUnreadable, status: 'nothing' }; + } + return this.publishAll(liveDocument, cropped, { skippedLocked, skippedUnreadable }); + } catch { + return { status: 'failed' }; + } finally { + for (const leaf of cropped) { + leaf.reservation.release(); + } + } + } + + /** + * A leaf's baked pixels for crop all, `skip` when it has nothing to crop, or its unreadable refusal. A leaf with its + * own adjustment stack is first exported raw: adjustments never move pixels, so the raw rect is the baked one, and a + * leaf already inside the frame never runs its stack (a spatial job that could refuse the whole crop). Its guard + * still joins the staleness check, as every read leaf's does. + */ + private async exportForCrop(layer: CanvasLayerContract, frame: Rect): Promise { + const settle = (exported: ExportResult): CropExport => { + if (exported.status === 'ok' || exported.status === 'adjustment-refused' || exported.status === 'unreadable') { + return exported; + } + if (exported.status === 'missing') { + return { status: 'stale' }; + } + if (exported.status === 'not-ready' || exported.status === 'over-budget') { + return { status: exported.status }; + } + // Empty, disabled-only or unsupported content has nothing to crop. + return { status: 'skip' }; + }; + if (layer.type !== 'raster' || isIdentityAdjustments(layer.adjustments)) { + return settle(await this.deps.exportBaked(layer.id)); + } + const raw = settle(await this.deps.exportBaked(layer.id, { applyAdjustments: false })); + if (raw.status !== 'ok' || !cropRectFor(raw.rect, frame)) { + return raw; + } + raw.release(); + return settle(await this.deps.exportBaked(layer.id)); + } + + private publishAll( + document: CanvasDocumentContractV3, + cropped: readonly CroppedLeaf[], + skipped: CropAllSkipped + ): CropAllLayersResult { + const { ctx } = this.deps; + const afterBytes = cropped.reduce((sum, leaf) => sum + rgbaBytes(leaf.pixels.rect), 0); + const txn = ctx.begin({ historyBytes: afterBytes + HISTORY_ENTRY_OVERHEAD_BYTES }); + if (!('publish' in txn)) { + return { status: layerEditRefusal(txn.status) }; + } + try { + // The prepared cache replacements; the cropped pixels hold their own reservations. + if (!txn.reserveRaster(afterBytes)) { + return { status: 'over-budget' }; + } + const crops: LayerCrop[] = []; + for (const leaf of cropped) { + // Each before snapshot grows the one admission; a set that cannot be kept refuses with nothing changed. + const beforePixels = this.deps.captureCache(leaf.layer, document, (cacheRect) => + txn.growHistory(rgbaBytes(cacheRect)) + ); + if (beforePixels === 'over-budget') { + return { status: 'over-budget' }; + } + if (!beforePixels || beforePixels === 'not-ready') { + return { status: 'not-ready' }; + } + const before = structuredClone(leaf.layer); + crops.push({ + after: croppedLayerContract(before, leaf.pixels.rect), + afterPixels: leaf.pixels, + before, + beforePixels, + }); + } + const beforeBytes = crops.reduce((sum, crop) => sum + rgbaBytes(crop.beforePixels.rect), 0); + const replay = (side: 'before' | 'after') => () => + withReplayReservation(ctx, side === 'before' ? beforeBytes : afterBytes, () => + ctx.applyStep(this.replaceAllStep(crops, side)) + ); + const result = txn.publish('Crop all layers to frame', this.replaceAllStep(crops, 'after'), { + bytes: beforeBytes + afterBytes + HISTORY_ENTRY_OVERHEAD_BYTES, + heldAssetRefs: collectHistoryMediaRefs(...crops.flatMap((crop) => [crop.before, crop.after])), + redo: replay('after'), + undo: replay('before'), + }); + const status = layerOperationStatus(result); + return status === 'committed' ? { cropped: crops.length, ...skipped, status: 'cropped' } : { status }; + } finally { + txn.end(); + } + } + + /** Every cropped leaf's contract and cache, replaced together as one step; it rolls back to the reducer's contracts. */ + private replaceAllStep(crops: readonly LayerCrop[], side: 'before' | 'after'): EditStep { + const { ctx } = this.deps; + const reducerDocument = ctx.getReducerDocument(); + return replaceLayersStep( + ctx, + crops.map((crop) => { + const pixels = side === 'before' ? crop.beforePixels : crop.afterPixels; + return { contract: crop[side], prepared: ctx.preparePixels(crop.before.id, pixels.rect, pixels.pixels) }; + }), + { + beforeInstall: (layerId) => this.discardPersisted(layerId), + restore: crops.flatMap((crop) => getDocumentLayer(reducerDocument, crop.before.id) ?? []), + } + ); + } + + /** Drops a replaced layer's pending persistence; the installed pixels are marked dirty and persist on their own. */ + private discardPersisted(layerId: string): void { + try { + this.deps.discardPersisted(layerId); + } catch { + // The installed pixels persist on their own. + } + } + dispose(): void { this.disposed = true; } } + +const isSameRect = (a: Rect, b: Rect): boolean => + a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height; + +/** + * Where a leaf's exported pixels land once cropped to `frame`: their overlap, an empty rect at the frame origin when + * they lie entirely outside it, or null when they already fit inside and there is nothing to crop. + */ +const cropRectFor = (exportedRect: Rect, frame: Rect): Rect | null => { + const inside = + exportedRect.x >= frame.x && + exportedRect.y >= frame.y && + exportedRect.x + exportedRect.width <= frame.x + frame.width && + exportedRect.y + exportedRect.height <= frame.y + frame.height; + if (inside) { + return null; + } + const overlap = intersect(exportedRect, frame); + return overlap && !isEmpty(overlap) ? roundOut(overlap) : { height: 0, width: 0, x: frame.x, y: frame.y }; +}; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/editSteps.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/editSteps.ts index b1125c94002..3d65effaba6 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/editSteps.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/editSteps.ts @@ -8,6 +8,7 @@ import type { CanvasDocumentContractV3, CanvasLayerContract } from '@workbench/c import type { CanvasNodeInsertionAnchor } from '@workbench/canvas-engine/document/insertionAnchors'; import type { CanvasTransactionOutcome, SubsetOf } from '@workbench/canvas-engine/editConcurrency'; import type { HeldAssetRefs } from '@workbench/canvas-engine/history/history'; +import type { LayerUnreadable } from '@workbench/canvas-engine/layerExportGuards'; import type { CanvasProjectMutation } from '@workbench/canvas-engine/mutationContracts'; import type { PreparedLayerCacheReplacement } from '@workbench/canvas-engine/render/layerCache'; import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; @@ -15,6 +16,7 @@ import type { Rect } from '@workbench/canvas-engine/types'; import { getDocumentLayer, isNodeAbsent } from '@workbench/canvas-engine/document/documentIndex'; import { collectHistoryMediaRefs, HISTORY_ENTRY_OVERHEAD_BYTES } from '@workbench/canvas-engine/history/history'; +import { isLayerUnreadable } from '@workbench/canvas-engine/layerExportGuards'; import type { CanvasMutationContext, @@ -34,6 +36,17 @@ export const rgbaBytes = (rect: Rect): number => Math.max(0, rect.width) * Math. // ---- Publication outcomes ---------------------------------------------------------------------------------- +/** + * Whether a layer operation can read a layer's cache now: `not-ready` while its pixels load or cannot be fetched for + * now (waiting helps), {@link LayerUnreadable} once rasterizing its current source failed (waiting does not; the + * operation refuses and names the layer). + */ +export type LayerCacheReadiness = 'ready' | 'not-ready' | LayerUnreadable; + +/** Layers read together: the first that cannot be read refuses the set even while others still load. */ +export const readinessOfAll = (readiness: readonly LayerCacheReadiness[]): LayerCacheReadiness => + readiness.find(isLayerUnreadable) ?? (readiness.includes('not-ready') ? 'not-ready' : 'ready'); + /** A refused admission as callers report it; a gesture in progress reads as contention. */ export type LayerEditRefusal = SubsetOf; @@ -116,6 +129,43 @@ export const replayWithPixels = ( // ---- Steps --------------------------------------------------------------------------------------------------- +/** Options shared by the in-place replace steps. */ +export interface ReplaceLayerStepOptions { + readonly persist?: boolean; + /** Runs just before each layer's pixels install, once the document accepted the contracts. */ + readonly beforeInstall?: (layerId: string) => void; + readonly notify?: () => void; +} + +/** Whether `document` holds exactly these contracts, by identity. */ +const holdsContracts = + (contracts: readonly CanvasLayerContract[]) => + (document: CanvasDocumentContractV3 | null): boolean => + contracts.every((contract) => getDocumentLayer(document, contract.id) === contract); + +/** Installs each replacement's prepared pixels in order, after its `beforeInstall`; nothing to do yields undefined. */ +const installReplacements = ( + ctx: Pick, + replacements: readonly LayerReplacement[], + options: ReplaceLayerStepOptions +): (() => void) | undefined => + options.beforeInstall || replacements.some(({ prepared }) => prepared) + ? () => { + for (const { contract, prepared } of replacements) { + options.beforeInstall?.(contract.id); + if (prepared) { + ctx.installPrepared(prepared, options.persist); + } + } + } + : undefined; + +/** A layer's replacement contract and the pixels that land with it. */ +export interface LayerReplacement { + readonly contract: CanvasLayerContract; + readonly prepared: PreparedLayerCacheReplacement | null; +} + /** * Swaps `contract` in for its layer and, once the document accepted it, installs the prepared pixels. A step with a * `restore` contract rolls back to it when its postconditions fail. @@ -124,34 +174,49 @@ export const replaceLayerStep = ( ctx: Pick, contract: CanvasLayerContract, prepared: PreparedLayerCacheReplacement | null, - options: { - readonly restore: CanvasLayerContract | undefined; - readonly persist?: boolean; - /** Runs just before the pixels install, once the document accepted the contract. */ - readonly beforeInstall?: () => void; - readonly notify?: () => void; - } + options: ReplaceLayerStepOptions & { readonly restore: CanvasLayerContract | undefined } ): EditStep => ({ - accepted: (document) => getDocumentLayer(document, contract.id) === contract, - install: - prepared || options.beforeInstall - ? () => { - options.beforeInstall?.(); - if (prepared) { - ctx.installPrepared(prepared, options.persist); - } - } - : undefined, + accepted: holdsContracts([contract]), + install: installReplacements(ctx, [{ contract, prepared }], options), mutation: { layer: contract, layerId: contract.id, type: 'replaceCanvasLayer' }, notify: options.notify, rollback: options.restore ? { mutation: { layer: options.restore, layerId: contract.id, type: 'replaceCanvasLayer' }, - restored: (document) => getDocumentLayer(document, contract.id) === options.restore, + restored: holdsContracts([options.restore]), } : undefined, }); +/** + * {@link replaceLayerStep} for several same-type leaves at once, as one `replaceCanvasLayers`. It rolls back to + * `restore` (one contract per replaced layer) when its postconditions fail; without a complete restore set it has no + * rollback. + */ +export const replaceLayersStep = ( + ctx: Pick, + replacements: readonly LayerReplacement[], + options: ReplaceLayerStepOptions & { readonly restore: readonly CanvasLayerContract[] | undefined } +): EditStep => { + const contracts = replacements.map(({ contract }) => contract); + const restore = options.restore?.length === contracts.length ? options.restore : undefined; + return { + accepted: holdsContracts(contracts), + install: installReplacements(ctx, replacements, options), + mutation: { replacements: contracts.map((layer) => ({ layer, layerId: layer.id })), type: 'replaceCanvasLayers' }, + notify: options.notify, + rollback: restore + ? { + mutation: { + replacements: restore.map((layer) => ({ layer, layerId: layer.id })), + type: 'replaceCanvasLayers', + }, + restored: holdsContracts(restore), + } + : undefined, + }; +}; + const removeLayerMutation = (layerId: string, selectedLayerId: string | null) => ({ enabledUpdates: [], removeIds: [layerId], selectedLayerId, type: 'applyCanvasLayerStackMutation' }) as const; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/editingController.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/editingController.test.ts index c5d333baf3a..510e38c3980 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/editingController.test.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/editingController.test.ts @@ -41,12 +41,20 @@ const createTransformOptions = () => ({ canEdit: () => true, commitStructural: vi.fn(), ctx: {} as never, + enterTool: vi.fn(), getCache: () => null, getDocument: () => null, + getUserToolId: () => 'brush' as const, + hasFloatingSelection: () => false, invalidate: vi.fn(), isGestureActive: () => false, + isInteractionLocked: () => false, + peekCacheRect: () => undefined, + pending: { get: () => null, set: vi.fn() }, reportRefusal: vi.fn(), + rebaseLineEnd: vi.fn(), restoreCache: vi.fn(), + select: vi.fn(), session: { get: () => null, set: vi.fn() }, setOverride: vi.fn(), }); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/extractMaskedAreaController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/extractMaskedAreaController.ts index 940f034280f..227a368c372 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/extractMaskedAreaController.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/extractMaskedAreaController.ts @@ -15,6 +15,7 @@ import type { Rect } from '@workbench/canvas-engine/types'; import { compileDocumentLeaves } from '@workbench/canvas-engine/document-model/documentModel'; import { getDocumentLeaves } from '@workbench/canvas-engine/document/documentIndex'; import { getSourceContentRect } from '@workbench/canvas-engine/document/sources'; +import { isLayerUnreadable, type LayerUnreadable } from '@workbench/canvas-engine/layerExportGuards'; import { isEmpty } from '@workbench/canvas-engine/math/rect'; import { type AdjustmentRefusal, @@ -41,14 +42,19 @@ import { layerOperationStatus, paintLayerAt, publishAddedRasterLayer, + readinessOfAll, rgbaBytes, + type LayerCacheReadiness, type LayerStepContext, type LayerEditRefusal, } from './editSteps'; export type ExtractMaskedAreaResult = | { status: 'extracted'; layerId: string } - | { status: LayerEditRefusal | SubsetOf | 'empty' | 'failed' } + | { + status: LayerEditRefusal | SubsetOf | 'empty' | 'failed'; + } + | LayerUnreadable | AdjustmentRefusal; type OwnedExport = Extract; @@ -56,6 +62,7 @@ type OwnedExport = Extract; type ExportResult = | { status: 'ok'; surface: RasterSurface; rect: Rect; guard: LayerExportGuard; release(): void } | { status: 'missing' | 'disabled' | 'unsupported' | 'empty' | 'not-ready' | 'over-budget' } + | LayerUnreadable | AdjustmentRefusal; export interface ExtractMaskedAreaControllerOptions { @@ -72,7 +79,7 @@ export interface ExtractMaskedAreaControllerOptions { >; readonly backend: RasterBackend; readonly layers: LayerCacheStore; - readonly isCacheReady: (layer: CanvasLayerContract, document: CanvasDocumentContractV3) => boolean; + readonly cacheReadiness: (layer: CanvasLayerContract, document: CanvasDocumentContractV3) => LayerCacheReadiness; readonly hasExportableContent: (layerId: string) => boolean; readonly exportBaked: (layerId: string, includeDisabled?: boolean) => Promise; readonly rasterize: (layerId: string) => Promise; @@ -138,11 +145,9 @@ export class ExtractMaskedAreaController { if (contributors.length === 0) { return { status: 'empty' }; } - if ( - !this.deps.isCacheReady(mask, document) || - contributors.some((layer) => !this.deps.isCacheReady(layer, document)) - ) { - return { status: 'not-ready' }; + const readiness = readinessOfAll([mask, ...contributors].map((layer) => this.deps.cacheReadiness(layer, document))); + if (readiness !== 'ready') { + return readiness === 'not-ready' ? { status: readiness } : readiness; } const owned: Extract[] = []; const acquire = async (resultPromise: Promise): Promise => { @@ -168,8 +173,15 @@ export class ExtractMaskedAreaController { return { status: 'busy' }; } const statuses = [maskPixels, ...contributorPixels].map((pixels) => pixels.status); - if (statuses.includes('over-budget') || statuses.includes('not-ready')) { - return { status: statuses.includes('over-budget') ? 'over-budget' : 'not-ready' }; + if (statuses.includes('over-budget')) { + return { status: 'over-budget' }; + } + const unreadable = [maskPixels, ...contributorPixels].find(isLayerUnreadable); + if (unreadable) { + return unreadable; + } + if (statuses.includes('not-ready')) { + return { status: 'not-ready' }; } if (maskPixels.status !== 'ok' || statuses.some((status) => status !== 'ok')) { return { status: 'empty' }; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/layerController.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/layerController.test.ts index 534607619cb..e0d5c53c541 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/layerController.test.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/layerController.test.ts @@ -54,7 +54,7 @@ describe('LayerController', () => { ctx: {} as never, exportBaked: vi.fn(), hasExportableContent: () => false, - isCacheReady: () => true, + cacheReadiness: () => 'ready' as const, layers: {} as never, needsPixelPersistence: () => false, peekAdjustedSurface: () => null, @@ -64,7 +64,7 @@ describe('LayerController', () => { backend: {} as never, ctx: {} as never, exportBaked: vi.fn(), - isCacheReady: () => true, + cacheReadiness: () => 'ready' as const, isGuardCurrent: () => true, }; const extractMaskedArea = { @@ -76,7 +76,7 @@ describe('LayerController', () => { getAdjustedSurface: vi.fn(), getMaskPattern: () => null, hasExportableContent: () => false, - isCacheReady: () => true, + cacheReadiness: () => 'ready' as const, isGuardCurrent: () => true, layers: {} as never, rasterize: vi.fn(), @@ -87,6 +87,7 @@ describe('LayerController', () => { ctx: {} as never, discardPersisted: vi.fn(), exportBaked: vi.fn(), + flushPendingPixels: () => Promise.resolve(), isGuardCurrent: () => true, isSupportedSource: () => true, }; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/layerMutationController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/layerMutationController.ts index 2b789c288dd..8e65a19eb0c 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/layerMutationController.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/layerMutationController.ts @@ -3,6 +3,7 @@ import type { CanvasDocumentContractV3, CanvasLayerContract } from '@workbench/c import type { CanvasCommandRefusal } from '@workbench/canvas-engine/document/commandRefusal'; import type { CanvasNodeInsertionAnchor } from '@workbench/canvas-engine/document/insertionAnchors'; import type { SubsetOf } from '@workbench/canvas-engine/editConcurrency'; +import type { LayerUnreadable } from '@workbench/canvas-engine/layerExportGuards'; import type { CanvasProjectMutation } from '@workbench/canvas-engine/mutationContracts'; import type { AdjustmentRefusal } from '@workbench/canvas-engine/render/exactAdjustments'; import type { PreparedLayerCacheReplacement } from '@workbench/canvas-engine/render/layerCache'; @@ -43,11 +44,13 @@ export type ConvertLayerToControlResult = | SubsetOf | LayerEditRefusal | 'failed' + | LayerUnreadable | AdjustmentRefusal; type BakedExportResult = | { status: 'ok'; surface: RasterSurface; rect: Rect; guard: LayerExportGuard; release(): void } | { status: 'missing' | 'disabled' | 'unsupported' | 'empty' | 'not-ready' | 'over-budget' } + | LayerUnreadable | AdjustmentRefusal; export type DuplicateLayerRasterPlan = @@ -64,7 +67,8 @@ export type DuplicateLayerRasterPlan = type DuplicateRasterPreparationResult = | { readonly status: 'ready'; readonly layer: CanvasLayerContract } - | { readonly status: 'not-ready' | 'over-budget' }; + | { readonly status: 'not-ready' | 'over-budget' } + | LayerUnreadable; export interface LayerMutationControllerOptions { readonly ctx: Pick< @@ -169,6 +173,9 @@ export class LayerMutationController { return { status: 'not-ready' }; } const prepared = await o.prepareDuplicateRasterSource(source.id); + if (prepared.status === 'unreadable') { + return prepared; + } if (prepared.status !== 'ready') { return { status: prepared.status }; } @@ -495,7 +502,7 @@ export class LayerMutationController { return 'locked'; } const baked = await o.exportBaked(current.id); - if (baked.status === 'adjustment-refused') { + if (baked.status === 'adjustment-refused' || baked.status === 'unreadable') { return baked; } if (baked.status === 'empty') { diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/mergeLayerController.browser.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/mergeLayerController.browser.test.ts index ee9d6286ae6..4906bb1eeb9 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/mergeLayerController.browser.test.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/mergeLayerController.browser.test.ts @@ -3,12 +3,14 @@ import type { CanvasAdjustmentsContract, CanvasDocumentContractV3, + CanvasLayerContract, CanvasRasterLayerContractV2, } from '@workbench/canvas-engine/contracts'; +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; import type { Rect } from '@workbench/canvas-engine/types'; import { RasterMemoryBudgetController } from '@workbench/canvas-engine/controllers/rasterMemoryBudgetController'; -import { stacksFrom } from '@workbench/canvas-engine/document-model/documentFixtures.testStub'; +import { layerContract, stacksFrom } from '@workbench/canvas-engine/document-model/documentFixtures.testStub'; import { getDocumentLayer, getDocumentLeaves } from '@workbench/canvas-engine/document/documentIndex'; import { fromTRS } from '@workbench/canvas-engine/math/mat2d'; import { createAdjustedSurfaceCache } from '@workbench/canvas-engine/render/adjustedSurfaceCache'; @@ -121,7 +123,7 @@ const mergeScene = (scene: Scene, displayed: boolean, service?: SpatialAdjustmen ctx: h.ctx, exportBaked: () => Promise.reject(new Error('merge down reads live caches')), hasExportableContent: (layerId) => (h.layers.get(layerId)?.rect.width ?? 0) > 0, - isCacheReady: () => true, + cacheReadiness: () => 'ready', layers: h.layers, needsPixelPersistence: () => false, peekAdjustedSurface: (contract, entry) => @@ -221,3 +223,76 @@ describe('Merge Down with spatial stacks', () => { expect(getDocumentLayer(h.document(), 'lower')).toBe(h.contracts.lower); }); }); + +/** Coverage alpha of a mask's baked pixels, filled uniformly over `rect`. */ +const coverage = (rect: Rect, alpha: number): RasterSurface => { + const surface = createDomRasterBackend().createSurface(rect.width, rect.height); + surface.ctx.fillStyle = `rgba(255, 255, 255, ${alpha / 255})`; + surface.ctx.fillRect(0, 0, rect.width, rect.height); + return surface; +}; + +describe('MergeLayerController in Chromium: merge masks', () => { + it('unions coverage source-over, ignoring each mask’s display opacity', async () => { + // The top mask is half-covered at full opacity; the one below is fully covered but shown at 40 % opacity. + const sources: Record = { + below: { alpha: 255, opacity: 0.4, rect: { height: 10, width: 10, x: 5, y: 0 } }, + top: { alpha: 128, opacity: 1, rect: { height: 10, width: 10, x: 0, y: 0 } }, + }; + const document: CanvasDocumentContractV3 = { + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + selectedLayerId: 'top', + stacks: stacksFrom( + ['top', 'below'].map((id) => + layerContract(id, 'inpaint_mask', { opacity: sources[id]!.opacity } as Partial) + ) + ), + version: 3, + width: 100, + }; + const h = createLayerOperationHarness(document); + const dom = createDomRasterBackend(); + // Real raw caches, which undo snapshots copy. + for (const [layerId, { alpha, rect }] of Object.entries(sources)) { + h.layers.installReplacement({ layerId, rect, surface: coverage(rect, alpha) }); + } + const created: RasterSurface[] = []; + const controller = new MergeLayerController({ + backend: { + ...dom, + createSurface: (width, height, options) => { + const surface = dom.createSurface(width, height, options); + created.push(surface); + return surface; + }, + }, + ctx: h.ctx, + exportBaked: (layerId) => + Promise.resolve({ + guard: { layer: getDocumentLayer(h.document(), layerId) } as never, + rect: { ...sources[layerId]!.rect }, + release: () => undefined, + status: 'ok' as const, + surface: coverage(sources[layerId]!.rect, sources[layerId]!.alpha), + }), + hasExportableContent: () => true, + cacheReadiness: () => 'ready', + layers: h.layers, + needsPixelPersistence: () => true, + peekAdjustedSurface: () => null, + publishSelectedLayerIds: () => undefined, + }); + + await expect(controller.mergeInpaintMasks(['top', 'below'])).resolves.toMatchObject({ status: 'merged' }); + + const merged = created.find((surface) => surface.width === 15 && surface.height === 10)!; + const alphaAt = (x: number): number => merged.ctx.getImageData(x, 5, 1, 1).data[3]!; + // Top only: its own partial coverage. Below only: full coverage, which generation reads as masked (alpha > 127) + // however faintly the mask was shown. Both: full coverage under the top's. + expect(alphaAt(2)).toBeCloseTo(128, -0.5); + expect(alphaAt(12)).toBe(255); + expect(alphaAt(7)).toBe(255); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/mergeLayerController.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/mergeLayerController.test.ts index c864e10343c..1a66fc0b188 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/mergeLayerController.test.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/mergeLayerController.test.ts @@ -3,12 +3,16 @@ import type { CanvasDocumentContractV3, CanvasLayerContract, } from '@workbench/canvas-engine/contracts'; -import type { SpatialRunner } from '@workbench/canvas-engine/render/exactAdjustments'; +import type { AdjustmentRefusal, SpatialRunner } from '@workbench/canvas-engine/render/exactAdjustments'; import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; import type { Rect } from '@workbench/canvas-engine/types'; -import { groupContract, stacksFrom } from '@workbench/canvas-engine/document-model/documentFixtures.testStub'; -import { getDocumentLayer, getDocumentLeaves } from '@workbench/canvas-engine/document/documentIndex'; +import { + groupContract, + layerContract, + stacksFrom, +} from '@workbench/canvas-engine/document-model/documentFixtures.testStub'; +import { getDocumentIndex, getDocumentLayer, getDocumentLeaves } from '@workbench/canvas-engine/document/documentIndex'; import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; import { describe, expect, it, vi } from 'vitest'; @@ -68,7 +72,7 @@ const createHarness = ( ctx: h.ctx, exportBaked: () => Promise.reject(new Error('merge down reads live caches')), hasExportableContent: (layerId) => (h.layers.get(layerId)?.rect.width ?? 0) > 0, - isCacheReady: () => true, + cacheReadiness: () => 'ready', layers: h.layers, needsPixelPersistence: () => true, peekAdjustedSurface: () => displayed, @@ -250,7 +254,7 @@ describe('MergeLayerController: merge down', () => { }); describe('MergeLayerController: merge visible with a spatial group stack', () => { - const scene = (runSpatial?: SpatialRunner) => { + const scene = (runSpatial?: SpatialRunner, exportRefusal?: AdjustmentRefusal) => { const first = { ...paintLayer('first'), name: 'First' }; const second = { ...paintLayer('second'), name: 'Second' }; const document: CanvasDocumentContractV3 = { @@ -273,21 +277,25 @@ describe('MergeLayerController: merge visible with a spatial group stack', () => backend: h.backend, ctx: h.ctx, exportBaked: (layerId) => - Promise.resolve({ - guard: { - cacheVersion: 1, - documentGeneration: 1, - layer: getDocumentLayer(h.document(), layerId)!, - layerId, - projectId: 'p', - }, - rect: rects[layerId]!, - release: () => undefined, - status: 'ok', - surface: h.backend.createSurface(rects[layerId]!.width, rects[layerId]!.height), - }), + Promise.resolve( + exportRefusal?.ownerId === layerId + ? exportRefusal + : { + guard: { + cacheVersion: 1, + documentGeneration: 1, + layer: getDocumentLayer(h.document(), layerId)!, + layerId, + projectId: 'p', + }, + rect: rects[layerId]!, + release: () => undefined, + status: 'ok', + surface: h.backend.createSurface(rects[layerId]!.width, rects[layerId]!.height), + } + ), hasExportableContent: () => true, - isCacheReady: () => true, + cacheReadiness: () => 'ready', layers: h.layers, needsPixelPersistence: () => false, peekAdjustedSurface: () => null, @@ -297,6 +305,23 @@ describe('MergeLayerController: merge visible with a spatial group stack', () => return { ...h, controller }; }; + it('preserves a selected layer export refusal with its owner and reason, merging nothing', async () => { + const refusal: AdjustmentRefusal = { + ownerId: 'second', + ownerName: 'Second', + reason: 'too-large', + status: 'adjustment-refused', + }; + const h = scene(undefined, refusal); + const original = h.document(); + + expect(await h.controller.mergeSelected(['first', 'second'])).toEqual(refusal); + expect(h.document()).toBe(original); + expect(h.dispatched).toEqual([]); + expect(h.history.canUndo()).toBe(false); + expect(h.reservedBytes()).toBe(0); + }); + it("evaluates the group's spatial stack on the merged members", async () => { const runSpatial = vi.fn((request) => Promise.resolve({ pixels: request.pixels, status: 'ok' })); const h = scene(runSpatial); @@ -324,3 +349,161 @@ describe('MergeLayerController: merge visible with a spatial group stack', () => expect(h.history.canUndo()).toBe(false); }); }); + +describe('MergeLayerController: merge masks', () => { + const RECTS: Record = { + bottom: { height: 10, width: 10, x: 30, y: 30 }, + middle: { height: 10, width: 10, x: 10, y: 10 }, + top: { height: 10, width: 10, x: 0, y: 0 }, + }; + + const createMaskHarness = ( + options: { bottomNoiseLevel?: number; historyBytes?: number; onExport?: () => void } = {} + ) => { + // Generation settings every source shares, so the merge may carry them over the whole union. + const settings = (noiseLevel = 0.2) => ({ + denoise: { isEnabled: true, limit: 0.4 }, + noise: { isEnabled: true, level: noiseLevel }, + }); + const top = layerContract('top', 'inpaint_mask', { + ...settings(), + mask: { bitmap: null, fill: { color: '#123456', style: 'grid' } }, + opacity: 0.5, + } as Partial); + const document: CanvasDocumentContractV3 = { + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + selectedLayerId: 'middle', + stacks: stacksFrom([ + groupContract('first', [layerContract('other', 'inpaint_mask'), top]), + groupContract('second', [layerContract('middle', 'inpaint_mask', settings() as Partial)]), + layerContract('bottom', 'inpaint_mask', { + ...settings(options.bottomNoiseLevel), + mask: { bitmap: null, fill: { color: '#abcdef', style: 'solid' } }, + } as Partial), + ]), + version: 3, + width: 100, + }; + const h = createLayerOperationHarness(document, { historyBytes: options.historyBytes }); + for (const [id, rect] of Object.entries({ ...RECTS, other: { height: 4, width: 4, x: 0, y: 0 } })) { + h.layers.getOrCreateRect(id, rect).stale = false; + } + const released: string[] = []; + const selections: [string | null, readonly string[]][] = []; + const controller = new MergeLayerController({ + backend: h.backend, + ctx: h.ctx, + exportBaked: (layerId) => { + options.onExport?.(); + const entry = h.layers.get(layerId)!; + return Promise.resolve({ + guard: { layer: getDocumentLayer(h.document(), layerId) } as never, + rect: { ...entry.rect }, + release: () => released.push(layerId), + status: 'ok' as const, + surface: entry.surface, + }); + }, + hasExportableContent: (layerId) => (h.layers.get(layerId)?.rect.width ?? 0) > 0, + cacheReadiness: () => 'ready', + layers: h.layers, + needsPixelPersistence: () => true, + peekAdjustedSurface: () => null, + publishSelectedLayerIds: (primary, ids) => selections.push([primary, ids]), + }); + return { ...h, controller, original: h.document(), released, selections }; + }; + + const structure = (document: CanvasDocumentContractV3) => + [...getDocumentIndex(document).byId.values()].map((entry) => [entry.node.id, entry.parentId, entry.siblingIndex]); + + it('unions masks from different groups into one mask above the topmost, with its fill, as one step', async () => { + const h = createMaskHarness(); + + const result = await h.controller.mergeInpaintMasks(['bottom', 'top', 'middle']); + expect(result).toEqual({ layerId: 'new-1', status: 'merged' }); + + const merged = getDocumentLayer(h.document(), 'new-1'); + expect(merged).toEqual({ + blendMode: 'normal', + denoise: { isEnabled: true, limit: 0.4 }, + id: 'new-1', + isEnabled: true, + isLocked: false, + mask: { bitmap: null, fill: { color: '#123456', style: 'grid' }, offset: { x: 0, y: 0 } }, + name: 'top merged', + noise: { isEnabled: true, level: 0.2 }, + opacity: 1, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'inpaint_mask', + }); + // The union of the three sources' extents, in place of the topmost source; the others are gone. + expect(h.layers.get('new-1')?.rect).toEqual({ height: 40, width: 40, x: 0, y: 0 }); + expect(getDocumentIndex(h.document()).byId.get('new-1')).toMatchObject({ parentId: 'first', siblingIndex: 1 }); + expect(leafIds(h.document())).toEqual(['other', 'new-1']); + expect(h.document().selectedLayerId).toBe('new-1'); + expect(h.history.entries().past).toEqual(['Merge masks']); + expect(h.released.sort()).toEqual(['bottom', 'middle', 'top']); + expect(h.reservedBytes()).toBe(0); + + await expect(h.history.undo()).resolves.toEqual({ status: 'applied' }); + expect(structure(h.document())).toEqual(structure(h.original)); + for (const id of ['top', 'middle', 'bottom']) { + expect(getDocumentLayer(h.document(), id)).toBe(getDocumentLayer(h.original, id)); + expect(h.layers.get(id)?.rect).toEqual(RECTS[id]); + } + expect(h.document().selectedLayerId).toBe('middle'); + + await expect(h.history.redo()).resolves.toEqual({ status: 'applied' }); + expect(leafIds(h.document())).toEqual(['other', 'new-1']); + }); + + it.each([ + ['one mask', ['top'], 'ineligible'], + ['a regional or raster leaf', ['top', 'first'], 'ineligible'], + ['a missing id', ['top', 'gone'], 'missing'], + ] as const)('refuses %s without reading pixels', async (_, ids, status) => { + const h = createMaskHarness({ onExport: () => expect.fail('read pixels') }); + + await expect(h.controller.mergeInpaintMasks(ids)).resolves.toEqual({ status }); + expect(h.dispatched).toEqual([]); + }); + + it('refuses masks whose noise or denoise settings differ without reading pixels', async () => { + const h = createMaskHarness({ bottomNoiseLevel: 0.7, onExport: () => expect.fail('read pixels') }); + + await expect(h.controller.mergeInpaintMasks(['top', 'bottom'])).resolves.toEqual({ status: 'settings-differ' }); + expect(h.dispatched).toEqual([]); + expect(h.history.canUndo()).toBe(false); + }); + + it('refuses a merge it could never undo with nothing changed', async () => { + const h = createMaskHarness({ historyBytes: 1024 }); + + await expect(h.controller.mergeInpaintMasks(['top', 'middle'])).resolves.toEqual({ status: 'over-budget' }); + expect(h.document()).toBe(h.original); + expect(h.dispatched).toEqual([]); + expect(h.reservedBytes()).toBe(0); + }); + + it('refuses when a source changes while its pixels are read', async () => { + let edited = false; + const h = createMaskHarness({ + onExport: () => { + if (!edited) { + edited = true; + h.ctx.applyStep({ + accepted: (document) => getDocumentLayer(document, 'middle')?.name === 'Renamed', + mutation: { id: 'middle', patch: { name: 'Renamed' }, type: 'updateCanvasLayer' }, + }); + } + }, + }); + + await expect(h.controller.mergeInpaintMasks(['top', 'middle'])).resolves.toEqual({ status: 'not-ready' }); + expect(h.dispatched.map((mutation) => mutation.type)).toEqual(['updateCanvasLayer']); + expect(h.history.canUndo()).toBe(false); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/mergeLayerController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/mergeLayerController.ts index 4cfd5d5f640..90d5ea99088 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/mergeLayerController.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/mergeLayerController.ts @@ -1,6 +1,7 @@ import type { LayerExportGuard } from '@workbench/canvas-engine/capabilities'; import type { CanvasDocumentContractV3, CanvasLayerContract } from '@workbench/canvas-engine/contracts'; import type { CanvasCommandRefusal } from '@workbench/canvas-engine/document/commandRefusal'; +import type { LayerStackKind } from '@workbench/canvas-engine/document/layerStacks'; import type { SubsetOf } from '@workbench/canvas-engine/editConcurrency'; import type { CanvasProjectMutation } from '@workbench/canvas-engine/mutationContracts'; import type { LayerCacheEntry, LayerCacheStore } from '@workbench/canvas-engine/render/layerCache'; @@ -12,13 +13,24 @@ import { mergeDownEligibility, compileDocumentLeaves, } from '@workbench/canvas-engine/document-model/documentModel'; -import { getDocumentLayer, getDocumentLeaves, isNodeAbsent } from '@workbench/canvas-engine/document/documentIndex'; +import { + getDocumentLayer, + getDocumentLeaves, + getDocumentNode, + isNodeAbsent, +} from '@workbench/canvas-engine/document/documentIndex'; import { removeNodes } from '@workbench/canvas-engine/document/documentTree'; import { insertNodesAtAnchor } from '@workbench/canvas-engine/document/insertionAnchors'; import { haveSameStructure } from '@workbench/canvas-engine/document/layerStacks'; import { mergeDownMatrix } from '@workbench/canvas-engine/document/mergeDown'; -import { canMergeSelectedRasters, getMergeVisibleRasterLeaves } from '@workbench/canvas-engine/document/mergeVisible'; +import { + canMergeInpaintMasks, + canMergeSelectedRasters, + getMergeVisibleRasterLeaves, + inpaintMaskMergeEligibility, +} from '@workbench/canvas-engine/document/mergeVisible'; import { collectHistoryMediaRefs, HISTORY_ENTRY_OVERHEAD_BYTES } from '@workbench/canvas-engine/history/history'; +import { isLayerUnreadable, type LayerUnreadable } from '@workbench/canvas-engine/layerExportGuards'; import { isEmpty, roundOut, transformBounds, union } from '@workbench/canvas-engine/math/rect'; import { applyAdjustments, @@ -49,32 +61,100 @@ import { layerOperationStatus, paintLayerAt, publishAddedRasterLayer, + readinessOfAll, rgbaBytes, withReplayReservation, + type LayerCacheReadiness, type LayerEditRefusal, replaceLayerStep, type LayerPixels, } from './editSteps'; -export type MergeVisibleResult = 'merged' | LayerEditRefusal | 'nothing' | 'failed' | AdjustmentRefusal; +/** {@link LayerUnreadable}: the first contributor whose pixels could not be read (its image is gone or does not decode). */ +export type MergeVisibleResult = + | 'merged' + | LayerEditRefusal + | 'nothing' + | 'failed' + | LayerUnreadable + | AdjustmentRefusal; + +/** + * Every refusal leaves document, pixels and history alone. `settings-differ`: the masks are otherwise eligible, but + * their enabled noise or denoise settings differ. + */ +export type MergeInpaintMasksResult = + | { status: 'merged'; layerId: string } + | { status: 'ineligible' | 'settings-differ' | 'missing' | 'failed' | LayerEditRefusal } + | LayerUnreadable + | AdjustmentRefusal; + +/** + * What tells one merge of selected leaves into a new leaf from another: who may merge (checked before the pixels are + * read and again before publishing), which stack the result joins above the topmost contributor, what it is, and + * the history label. + */ +interface SelectedMergeSpec { + readonly label: string; + readonly stack: LayerStackKind; + canMerge(document: CanvasDocumentContractV3, selectedIds: ReadonlySet): boolean; + /** The alpha a contributor composites at. */ + contributorAlpha(contributor: CanvasLayerContract): number; + /** The merged leaf placing pixels at `rect`, from the contributors top first. */ + createResult(id: string, contributors: readonly CanvasLayerContract[], rect: Rect): CanvasLayerContract; +} + +type SelectedMergeResult = + | { status: 'merged'; layerId: string } + | { status: Exclude } + | LayerUnreadable + | AdjustmentRefusal; + +const IDENTITY_TRANSFORM = { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }; + +/** + * One inpaint mask carrying the union of the sources' coverage at `rect`, named after and configured like the topmost + * source: its fill, and the noise and denoise every source shares. + */ +const mergedMaskLayer = (id: string, contributors: readonly CanvasLayerContract[], rect: Rect): CanvasLayerContract => { + const top = contributors[0]!; + if (top.type !== 'inpaint_mask') { + throw new Error('A mask merge needs inpaint mask contributors.'); + } + return { + blendMode: 'normal', + id, + isEnabled: true, + isLocked: false, + mask: { bitmap: null, fill: structuredClone(top.mask.fill), offset: { x: rect.x, y: rect.y } }, + name: `${top.name} merged`, + opacity: 1, + transform: { ...IDENTITY_TRANSFORM }, + type: 'inpaint_mask', + ...(top.noise ? { noise: structuredClone(top.noise) } : {}), + ...(top.denoise ? { denoise: structuredClone(top.denoise) } : {}), + }; +}; export type MergeDownResult = | 'merged' | SubsetOf | LayerEditRefusal | 'failed' + | LayerUnreadable | AdjustmentRefusal; type ExportResult = | { status: 'ok'; surface: RasterSurface; rect: Rect; guard: LayerExportGuard; release(): void } | { status: 'missing' | 'disabled' | 'unsupported' | 'empty' | 'not-ready' | 'over-budget' } + | LayerUnreadable | AdjustmentRefusal; export interface MergeLayerControllerOptions { readonly backend: RasterBackend; readonly ctx: CanvasMutationContext; readonly layers: LayerCacheStore; - readonly isCacheReady: (layer: CanvasLayerContract, document: CanvasDocumentContractV3) => boolean; + readonly cacheReadiness: (layer: CanvasLayerContract, document: CanvasDocumentContractV3) => LayerCacheReadiness; readonly hasExportableContent: (layerId: string) => boolean; readonly exportBaked: (layerId: string) => Promise; readonly needsPixelPersistence: (layer: CanvasLayerContract) => boolean; @@ -122,12 +202,14 @@ export class MergeLayerController { const belowCache = this.deps.layers.get(below.id); const upperHasContent = this.deps.hasExportableContent(upper.id); const belowHasContent = this.deps.hasExportableContent(below.id); - if ( - (upperHasContent && !upperCache) || - (belowHasContent && !belowCache) || - !this.deps.isCacheReady(upper, document) || - !this.deps.isCacheReady(below, document) - ) { + const readiness = readinessOfAll([ + this.deps.cacheReadiness(upper, document), + this.deps.cacheReadiness(below, document), + ]); + if (readiness !== 'ready') { + return readiness; + } + if ((upperHasContent && !upperCache) || (belowHasContent && !belowCache)) { return 'not-ready'; } const matrix = mergeDownMatrix(below.transform, upper.transform); @@ -352,6 +434,10 @@ export class MergeLayerController { if (exports.some((result) => result.status === 'over-budget')) { return 'over-budget'; } + const unreadable = exports.find(isLayerUnreadable); + if (unreadable) { + return unreadable; + } if (exports.some((result) => result.status !== 'ok')) { return 'not-ready'; } @@ -491,22 +577,68 @@ export class MergeLayerController { } } + /** Flattens one contiguous run of selected raster siblings into a new paint layer above them. */ async mergeSelected(layerIds: readonly string[]): Promise { + const result = await this.mergeSelectedInto(layerIds, { + canMerge: (document, selectedIds) => + canMergeSelectedRasters(document, compileDocumentLeaves(document), selectedIds, this.deps.hasExportableContent), + contributorAlpha: (contributor) => contributor.opacity, + createResult: (id, contributors, rect) => paintLayerAt(id, `${contributors[0]!.name} merged`, rect), + label: 'Merge selected layers', + stack: 'raster', + }); + return isAdjustmentRefusal(result) || isLayerUnreadable(result) ? result : result.status; + } + + /** Unions the coverage of selected inpaint masks, anywhere in the stack, into one new mask above the topmost. */ + async mergeInpaintMasks(layerIds: readonly string[]): Promise { + const document = this.deps.ctx.getDocument(); + if (document && layerIds.some((id) => !getDocumentNode(document, id))) { + return { status: 'missing' }; + } + if ( + document && + inpaintMaskMergeEligibility( + compileDocumentLeaves(document), + new Set(layerIds), + this.deps.hasExportableContent + ) === 'settings-differ' + ) { + return { status: 'settings-differ' }; + } + const result = await this.mergeSelectedInto(layerIds, { + canMerge: (candidate, selectedIds) => + canMergeInpaintMasks(compileDocumentLeaves(candidate), selectedIds, this.deps.hasExportableContent), + // Opacity only tints a mask on screen; generation reads its coverage alone, so it never thins merged coverage. + contributorAlpha: () => 1, + createResult: mergedMaskLayer, + label: 'Merge masks', + stack: 'inpaint_mask', + }); + if (result.status === 'merged' || isAdjustmentRefusal(result) || isLayerUnreadable(result)) { + return result; + } + return { status: result.status === 'nothing' ? 'ineligible' : result.status }; + } + + /** + * Composites the selected leaves bottom-up (source-over, each at the spec's alpha) into a new leaf above the topmost + * and removes them, as one undo step that restores every source in place with its raw pixels. + */ + private async mergeSelectedInto(layerIds: readonly string[], spec: SelectedMergeSpec): Promise { const { ctx } = this.deps; const permit = ctx.capturePermit(); if (this.disposed || !permit) { - return 'busy'; + return { status: 'busy' }; } const document = ctx.getDocument(); if (!document) { - return 'nothing'; + return { status: 'nothing' }; } const selectedIds = new Set(layerIds); const contributors = getDocumentLeaves(document).filter((layer) => selectedIds.has(layer.id)); - if ( - !canMergeSelectedRasters(document, compileDocumentLeaves(document), selectedIds, this.deps.hasExportableContent) - ) { - return 'nothing'; + if (!spec.canMerge(document, selectedIds)) { + return { status: 'nothing' }; } const owned: Extract[] = []; const acquire = async (layerId: string): Promise => { @@ -524,17 +656,21 @@ export class MergeLayerController { } const exports = settled.map((result) => (result as PromiseFulfilledResult).value); if (!ctx.isPermitCurrent(permit)) { - return 'busy'; + return { status: 'busy' }; } const refused = exports.find(isAdjustmentRefusal); if (refused) { return refused; } if (exports.some((result) => result.status === 'over-budget')) { - return 'over-budget'; + return { status: 'over-budget' }; + } + const unreadable = exports.find(isLayerUnreadable); + if (unreadable) { + return unreadable; } if (exports.some((result) => result.status !== 'ok')) { - return 'not-ready'; + return { status: 'not-ready' }; } for (let index = 0; index < exports.length; index += 1) { const exported = exports[index]; @@ -546,7 +682,7 @@ export class MergeLayerController { exported.guard.layer !== contributor || !ctx.isGuardCurrent(exported.guard) ) { - return 'not-ready'; + return { status: 'not-ready' }; } } // The merge is built from `document`: its structure and selection, the contributors' identities and their @@ -561,19 +697,14 @@ export class MergeLayerController { !haveSameStructure(liveDocument.stacks, document.stacks) || liveContributors.length !== contributors.length || liveContributors.some((layer, index) => layer !== contributors[index]) || - !canMergeSelectedRasters( - liveDocument, - compileDocumentLeaves(liveDocument), - selectedIds, - this.deps.hasExportableContent - ) + !spec.canMerge(liveDocument, selectedIds) ) { - return 'not-ready'; + return { status: 'not-ready' }; } const rawEntries = contributors.map((contributor) => this.deps.layers.get(contributor.id)); if (rawEntries.some((entry) => !entry || entry.stale)) { - return 'not-ready'; + return { status: 'not-ready' }; } const successful = exports as Extract[]; let rect = successful[0]!.rect; @@ -582,7 +713,7 @@ export class MergeLayerController { } rect = roundOut(rect); if (isEmpty(rect)) { - return 'nothing'; + return { status: 'nothing' }; } const rawBytes = rawEntries.reduce((bytes, entry) => bytes + rgbaBytes(entry!.rect), 0); const mergedBytes = rgbaBytes(rect); @@ -590,13 +721,13 @@ export class MergeLayerController { historyBytes: rawBytes + mergedBytes + contributors.length * HISTORY_ENTRY_OVERHEAD_BYTES, }); if (!('publish' in txn)) { - return layerEditRefusal(txn.status); + return { status: layerEditRefusal(txn.status) }; } try { // Besides already-reserved baked exports, the transaction simultaneously // owns raw undo snapshots, the flattened result, and its prepared cache. if (!txn.reserveRaster(rawBytes + mergedBytes * 2)) { - return 'over-budget'; + return { status: 'over-budget' }; } const rawSnapshots = contributors.map((contributor, index) => { const entry = rawEntries[index]!; @@ -611,17 +742,17 @@ export class MergeLayerController { for (let index = successful.length - 1; index >= 0; index -= 1) { const exported = successful[index]!; const contributor = contributors[index]!; - context.globalAlpha = contributor.opacity; + context.globalAlpha = spec.contributorAlpha(contributor); context.globalCompositeOperation = 'source-over'; context.drawImage(exported.surface.canvas, exported.rect.x - rect.x, exported.rect.y - rect.y); } context.globalAlpha = 1; context.globalCompositeOperation = 'source-over'; - const resultLayer = paintLayerAt(ctx.createLayerId(), `${contributors[0]!.name} merged`, rect); + const resultLayer = spec.createResult(ctx.createLayerId(), contributors, rect); const resultId = resultLayer.id; const contributorIds = contributors.map((layer) => layer.id); - const anchor = ctx.captureInsertionAnchor('raster', contributors[0]!.id); + const anchor = ctx.captureInsertionAnchor(spec.stack, contributors[0]!.id); const restoreInsertions = contributors.map((layer) => ({ anchor: ctx.captureRestoreAnchor(layer.id)!, nodes: [layer], @@ -674,14 +805,14 @@ export class MergeLayerController { }; }; const status = layerOperationStatus( - txn.publish('Merge selected layers', mergeStep(), { + txn.publish(spec.label, mergeStep(), { bytes: rawBytes + mergedBytes + contributors.length * HISTORY_ENTRY_OVERHEAD_BYTES, heldAssetRefs: collectHistoryMediaRefs(rawSnapshots.map(({ layer }) => layer)), redo: () => withReplayReservation(ctx, mergedBytes, () => ctx.applyStep(mergeStep())), undo: () => withReplayReservation(ctx, rawBytes, () => ctx.applyStep(restoreStep())), }) ); - return status === 'committed' ? 'merged' : status; + return status === 'committed' ? { layerId: resultId, status: 'merged' } : { status }; } finally { txn.end(); } diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/paintLineEndController.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/paintLineEndController.test.ts new file mode 100644 index 00000000000..f11df605088 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/paintLineEndController.test.ts @@ -0,0 +1,65 @@ +import type { CanvasLayerContract } from '@workbench/canvas-engine/contracts'; + +import { layerContract } from '@workbench/canvas-engine/document-model/documentFixtures.testStub'; +import { describe, expect, it } from 'vitest'; + +import { PaintLineEndController } from './paintLineEndController'; + +const paint = (x: number, offsetX: number, overrides: Partial = {}): CanvasLayerContract => + layerContract('paint', 'raster', { + source: { bitmap: null, offset: { x: offsetX, y: 0 }, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x, y: 0 }, + ...overrides, + } as Partial); + +const createHarness = (initial: CanvasLayerContract) => { + let layer: CanvasLayerContract | null = initial; + const ends = new PaintLineEndController({ getLayer: () => layer }); + const change = (next: CanvasLayerContract | null) => { + layer = next; + ends.onLayerChanged('paint'); + }; + return { change, ends }; +}; + +describe('PaintLineEndController', () => { + it('carries the stroke end with a layer that only moves', () => { + const h = createHarness(paint(0, 0)); + h.ends.set('paint', { x: 60, y: 10 }); + + h.change(paint(100, 0)); + + expect(h.ends.get('paint')).toEqual({ x: 60, y: 10 }); + }); + + it('keeps the document point when content and transform are rewritten together, as a crop does', () => { + const h = createHarness(paint(100, 0)); + h.ends.set('paint', { x: 60, y: 10 }); + + // Cropped: the pixels stay where they were, now at an identity transform. + h.change(paint(0, 100)); + + expect(h.ends.get('paint')).toEqual({ x: 160, y: 10 }); + }); + + it('follows pixels redrawn from a Transform session into a new local space', () => { + const h = createHarness(paint(0, 0)); + h.ends.set('paint', { x: 60, y: 10 }); + + h.ends.rebase('paint', { rotation: 0, scaleX: 2, scaleY: 2, x: 100, y: 0 }); + + expect(h.ends.get('paint')).toEqual({ x: 220, y: 20 }); + }); + + it('forgets the end of a removed layer and one a degenerate transform cannot hold', () => { + const removed = createHarness(paint(0, 0)); + removed.ends.set('paint', { x: 1, y: 1 }); + removed.change(null); + expect(removed.ends.get('paint')).toBeUndefined(); + + const flattened = createHarness(paint(0, 0)); + flattened.ends.set('paint', { x: 1, y: 1 }); + flattened.change(paint(0, 5, { transform: { rotation: 0, scaleX: 0, scaleY: 1, x: 0, y: 0 } })); + expect(flattened.ends.get('paint')).toBeUndefined(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/paintLineEndController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/paintLineEndController.ts new file mode 100644 index 00000000000..4d4814e61dd --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/paintLineEndController.ts @@ -0,0 +1,110 @@ +import type { CanvasLayerContract } from '@workbench/canvas-engine/contracts'; +import type { PaintLineEnds } from '@workbench/canvas-engine/tools/tool'; +import type { LayerTransform } from '@workbench/canvas-engine/transform/transformMath'; +import type { Vec2 } from '@workbench/canvas-engine/types'; + +import { areJsonValuesStructurallyEqual } from '@platform/core/json'; +import { renderableSourceOf } from '@workbench/canvas-engine/document/sources'; +import { applyToPoint, invert, multiply } from '@workbench/canvas-engine/math/mat2d'; +import { layerTransformMatrix } from '@workbench/canvas-engine/transform/transformMath'; + +export interface PaintLineEndControllerOptions { + /** The layer as the document holds it now, or null when it is gone. */ + readonly getLayer: (layerId: string) => CanvasLayerContract | null; +} + +interface LineEnd { + /** Layer-local, in the space of `layer.transform`. */ + readonly point: Vec2; + /** The contract last seen for the layer, which `point` is relative to. */ + readonly layer: CanvasLayerContract; +} + +const sameTransform = (a: LayerTransform, b: LayerTransform): boolean => + a.x === b.x && a.y === b.y && a.rotation === b.rotation && a.scaleX === b.scaleX && a.scaleY === b.scaleY; + +/** + * Where the last committed brush or eraser stroke ended on each layer, for Shift-click straight lines. Points are kept + * layer-local, so a change of transform alone (a move, nudge, Fit to frame or transform parameter) carries them with + * the layer. A change that rewrites the layer's content and transform together (crop, conversion, a baked float or + * result) keeps the point where the content stays in the document, mapping it from the previous transform into the + * new one. An applied Transform on a paint layer rewrites only its pixels, so it says where they went with + * {@link rebase}. A point the new transform cannot hold is forgotten. + */ +export class PaintLineEndController implements PaintLineEnds { + private readonly ends = new Map(); + + constructor(private readonly deps: PaintLineEndControllerOptions) {} + + get(layerId: string): Vec2 | undefined { + // Catches up with a change no layer event reported (a layer moved between stacks reports a reorder). + this.onLayerChanged(layerId); + return this.ends.get(layerId)?.point; + } + + set(layerId: string, point: Vec2): void { + const layer = this.deps.getLayer(layerId); + if (layer) { + this.ends.set(layerId, { layer, point: { ...point } }); + } + } + + /** Follows one document change of `layerId`. */ + onLayerChanged(layerId: string): void { + const end = this.ends.get(layerId); + if (!end) { + return; + } + const layer = this.deps.getLayer(layerId); + if (!layer) { + this.ends.delete(layerId); + return; + } + if (layer === end.layer) { + return; + } + const rebaked = + !sameTransform(layer.transform, end.layer.transform) && + (layer.type !== end.layer.type || + !areJsonValuesStructurallyEqual(renderableSourceOf(layer), renderableSourceOf(end.layer))); + if (!rebaked) { + this.ends.set(layerId, { layer, point: end.point }); + return; + } + this.map(layerId, end.point, end.layer.transform, layer); + } + + /** + * The layer's pixels were redrawn through `from`, the transform they were shown at, into its current local space + * (an applied Transform on a paint layer); the point follows them. + */ + rebase(layerId: string, from: LayerTransform): void { + this.onLayerChanged(layerId); + const end = this.ends.get(layerId); + if (end) { + this.map(layerId, end.point, from, end.layer); + } + } + + /** Stores `point`, local to `from`, as the same document point local to `layer`'s transform. */ + private map(layerId: string, point: Vec2, from: LayerTransform, layer: CanvasLayerContract): void { + const toLocal = invert(layerTransformMatrix(layer.transform)); + if (!toLocal) { + this.ends.delete(layerId); + return; + } + this.ends.set(layerId, { layer, point: applyToPoint(multiply(toLocal, layerTransformMatrix(from)), point) }); + } + + delete(layerId: string): void { + this.ends.delete(layerId); + } + + clear(): void { + this.ends.clear(); + } + + dispose(): void { + this.ends.clear(); + } +} diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/psdExportController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/psdExportController.ts index 0afd797aab8..9e8e98e260f 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/psdExportController.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/psdExportController.ts @@ -105,6 +105,9 @@ export class PsdExportController { layers.map((layer) => layer.id), { includeDisabled: true, signal: abortController.signal } ); + if (capture.status === 'unreadable') { + return capture; + } if (capture.status !== 'ok') { return capture.status; } diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/rasterController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/rasterController.ts index 512fa452b0b..db14be5beed 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/rasterController.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/rasterController.ts @@ -20,7 +20,7 @@ export interface RasterizationJob { version: number; documentGeneration: number; source: CanvasLayerSourceContract; - promise: Promise<'published' | 'stale' | 'error' | 'aborted'>; + promise: Promise<'published' | 'stale' | 'error' | 'unavailable' | 'aborted'>; } import { diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/rasterExportController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/rasterExportController.ts index f07356c0bb4..877832bd1d1 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/rasterExportController.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/rasterExportController.ts @@ -10,6 +10,7 @@ import type { CanvasLayerContract, CanvasLayerSourceContract, } from '@workbench/canvas-engine/contracts'; +import type { LayerRasterizationOutcome } from '@workbench/canvas-engine/layerRasterizer'; import type { RasterReadLease } from '@workbench/canvas-engine/rasterTransactions'; import type { CanvasTextSource } from '@workbench/canvas-engine/render/fontLoader'; import type { LayerCacheEntry, LayerCacheStore } from '@workbench/canvas-engine/render/layerCache'; @@ -17,6 +18,7 @@ import type { RasterBackend, RasterSurface } from '@workbench/canvas-engine/rend import { lookupDocumentLayer, lookupDocumentLeaf } from '@workbench/canvas-engine/document-model/documentModel'; import { getSourceContentRect, renderableSourceOf } from '@workbench/canvas-engine/document/sources'; +import { layerUnreadable, type LayerUnreadable } from '@workbench/canvas-engine/layerExportGuards'; import { fromTRS } from '@workbench/canvas-engine/math/mat2d'; import { isEmpty, roundOut, transformBounds } from '@workbench/canvas-engine/math/rect'; import { hasSpatialAdjustments, isIdentityAdjustments } from '@workbench/canvas-engine/render/adjustments'; @@ -27,9 +29,17 @@ import { type SpatialRunner, } from '@workbench/canvas-engine/render/exactAdjustments'; +/** + * `not-ready`: the pixels are still loading, moved while they were read, or their image could not be fetched for now, + * so trying again can succeed. {@link LayerUnreadable}: rasterizing the layer's current source failed (an image that is + * gone or does not decode), so trying again cannot succeed until the source changes. + */ export type ExportLayerPixelsResult = | ({ status: 'ok' } & RasterReadLease) - | { status: 'missing' | 'disabled' | 'unsupported' | 'empty' | 'not-ready' | 'over-budget' | 'aborted' } + | { + status: 'missing' | 'disabled' | 'unsupported' | 'empty' | 'not-ready' | 'over-budget' | 'aborted'; + } + | LayerUnreadable | AdjustmentRefusal; export interface RasterExportControllerOptions { @@ -40,7 +50,7 @@ export interface RasterExportControllerOptions { layer: CanvasLayerContract, document: CanvasDocumentContractV3, signal?: AbortSignal - ) => Promise<'published' | 'stale' | 'error' | 'aborted'>; + ) => Promise; readonly isGuardCurrent: (guard: LayerExportGuard) => boolean; readonly isRasterizing: (layer: CanvasLayerContract) => boolean; readonly isSupportedSource: (source: CanvasLayerSourceContract) => boolean; @@ -205,6 +215,9 @@ export class RasterExportController { let leased = false; try { const rasterized = await this.options.getOrStartRasterization(layer, document, options.signal); + if (rasterized === 'error') { + return layerUnreadable(this.options.getDocument() ?? document, layerId); + } if (rasterized !== 'published') { return { status: rasterized === 'aborted' ? 'aborted' : 'not-ready' }; } diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/renderController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/renderController.ts index ac3038f96b9..f90dcd82f40 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/renderController.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/renderController.ts @@ -1,3 +1,4 @@ +import type { CanvasKeyOwner, CanvasSurfaceAttachOptions } from '@workbench/canvas-engine/capabilities'; import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; import type { RenderScheduler, RenderSchedulerDeps } from '@workbench/canvas-engine/render/scheduler'; @@ -70,6 +71,7 @@ export class RenderController { private overlay: RasterSurface | null = null; private input: HTMLCanvasElement | null = null; private keyboardRoot: HTMLElement | null = null; + private resolveKeyOwner: ((event: KeyboardEvent) => CanvasKeyOwner) | null = null; private disposed = false; constructor(private readonly options: RenderControllerOptions) { @@ -92,7 +94,17 @@ export class RenderController { return this.keyboardRoot; } - attach(screenCanvas: HTMLCanvasElement, overlayCanvas: HTMLCanvasElement, keyboardRoot?: HTMLElement): void { + /** The host's key-owner judge from the current attach; null when detached or none was given. */ + getKeyOwnerResolver(): ((event: KeyboardEvent) => CanvasKeyOwner) | null { + return this.resolveKeyOwner; + } + + attach( + screenCanvas: HTMLCanvasElement, + overlayCanvas: HTMLCanvasElement, + keyboardRoot?: HTMLElement, + options?: CanvasSurfaceAttachOptions + ): void { if (this.disposed || this.options.isEngineDisposed()) { return; } @@ -103,6 +115,7 @@ export class RenderController { this.overlay = wrapCanvasSurface(overlayCanvas); this.input = overlayCanvas; this.keyboardRoot = keyboardRoot ?? overlayCanvas; + this.resolveKeyOwner = options?.resolveKeyOwner ?? null; const handlers = this.options.getInputHandlers(); this.input.addEventListener('pointerdown', handlers.onPointerDown); this.input.addEventListener('pointermove', handlers.onPointerMove); @@ -158,6 +171,7 @@ export class RenderController { this.overlay = null; this.input = null; this.keyboardRoot = null; + this.resolveKeyOwner = null; this.options.updateAnimation(); } diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/transformEditingController.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/transformEditingController.test.ts index 90b79e58183..68eb02fdfe7 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/transformEditingController.test.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/transformEditingController.test.ts @@ -1,6 +1,7 @@ import type { CanvasControlLayerContract, CanvasLayerContract } from '@workbench/canvas-engine/contracts'; import type { TransformSession } from '@workbench/canvas-engine/engineStores'; import type { CanvasProjectMutation } from '@workbench/canvas-engine/mutationContracts'; +import type { ToolId } from '@workbench/canvas-engine/types'; import { createCanvasMutationContext } from '@workbench/canvas-engine/controllers/mutationContext'; import { documentFrom, layerContract } from '@workbench/canvas-engine/document-model/documentFixtures.testStub'; @@ -18,19 +19,26 @@ import { TransformEditingController } from './transformEditingController'; const MOVED = { rotation: 0, scaleX: 1, scaleY: 1, x: 12, y: 4 }; -/** The controller over a real reducer, mutation context and history. */ +/** + * The controller over a real reducer, mutation context and history. The first layer is selected and has a 4×4 cache; + * `state` holds what the engine would otherwise decide (the tool, locks, a float, a gesture). + */ const createHarness = ( - layer: CanvasLayerContract, + layers: CanvasLayerContract | CanvasLayerContract[], options: { byteBudget?: number; commitStatus?: 'committed' | 'busy' } = {} ) => { + const [layer] = Array.isArray(layers) ? layers : [layers]; + if (!layer) { + throw new Error('expected a layer'); + } let project = applyCanvasProjectMutation(createInitialWorkbenchState().projects[0]!, { - document: documentFrom([layer], layer.id), + document: documentFrom(Array.isArray(layers) ? layers : [layers], layer.id), type: 'replaceCanvasDocument', }); const history = createHistory({ byteBudget: options.byteBudget }); const backend = createTestStubRasterBackend(); - const layers = createLayerCacheStore(backend); - layers.getOrCreateRect(layer.id, { height: 4, width: 4, x: 0, y: 0 }); + const caches = createLayerCacheStore(backend); + caches.getOrCreateRect(layer.id, { height: 4, width: 4, x: 0, y: 0 }); const dispatch = (action: CanvasProjectMutation): boolean => { project = applyCanvasProjectMutation(project, action); return true; @@ -60,23 +68,44 @@ const createHarness = ( subscribeReducer: () => () => undefined, }); let session: TransformSession | null = null; + let pending: string | null = null; + const state = { + canEdit: true, + floating: false, + gesture: false, + interactionLocked: false, + tool: 'brush' as ToolId, + }; const calls = { commitStructural: vi.fn(() => ({ status: options.commitStatus ?? ('committed' as const) })), + enterTool: vi.fn(() => { + state.tool = 'transform'; + }), + rebaseLineEnd: vi.fn(), reportRefusal: vi.fn(), restoreCache: vi.fn(), + select: vi.fn((layerId: string) => dispatch({ id: layerId, type: 'setCanvasSelectedLayer' })), setOverride: vi.fn(), }; const controller = new TransformEditingController({ backend, - canEdit: () => true, + canEdit: () => state.canEdit, commitStructural: calls.commitStructural, ctx, - getCache: (layerId) => layers.get(layerId) ?? null, + enterTool: calls.enterTool, + getCache: (layerId) => caches.get(layerId) ?? null, getDocument: () => project.canvas.document, + getUserToolId: () => state.tool, + hasFloatingSelection: () => state.floating, invalidate: vi.fn(), - isGestureActive: () => false, + isGestureActive: () => state.gesture, + isInteractionLocked: () => state.interactionLocked, + peekCacheRect: (layerId) => caches.peek(layerId)?.rect, + pending: { get: () => pending, set: (value) => (pending = value) }, reportRefusal: calls.reportRefusal, + rebaseLineEnd: calls.rebaseLineEnd, restoreCache: calls.restoreCache, + select: calls.select, session: { get: () => session, set: (value) => (session = value) }, setOverride: calls.setOverride, }); @@ -84,13 +113,18 @@ const createHarness = ( calls, controller, dispatch, + document: () => project.canvas.document, history, layer: () => getDocumentLayer(project.canvas.document, layer.id), + pending: () => pending, rasterFits, session: () => session, + state, }; }; +type HarnessState = ReturnType['state']; + const shape = layerContract('shape', 'raster', { source: { fill: '#fff', height: 10, kind: 'rect', stroke: null, strokeWidth: 0, type: 'shape', width: 10 }, } as Partial); @@ -261,3 +295,176 @@ describe('TransformEditingController', () => { expect(h.layer()).toEqual(detected); }); }); + +describe('TransformEditingController.request', () => { + const persistedPaint = (id: string) => + layerContract(id, 'raster', { + source: { bitmap: { height: 4, imageName: `${id}.png`, width: 4 }, type: 'paint' }, + } as Partial); + const a = persistedPaint('a'); + const b = persistedPaint('b'); + + it('selects the layer, enters the tool and opens its session', () => { + const h = createHarness([a, b]); + + expect(h.controller.request('b')).toBe('started'); + expect(h.document()?.selectedLayerId).toBe('b'); + expect(h.calls.enterTool).toHaveBeenCalledOnce(); + expect(h.session()?.layerId).toBe('b'); + expect(h.pending()).toBeNull(); + }); + + it('cancels a session on another layer, dropping its preview, before opening one on the target', () => { + const h = createHarness([a, b]); + h.controller.begin('a'); + h.controller.update(MOVED); + + expect(h.controller.request('b')).toBe('started'); + expect(h.calls.setOverride).toHaveBeenCalledWith('a', null); + expect(h.session()).toMatchObject({ layerId: 'b', transform: { scaleX: 1, x: 0, y: 0 } }); + // The abandoned preview on `a` never landed. + expect(getDocumentLayer(h.document(), 'a')?.transform.x).toBe(0); + }); + + it.each([ + ['a locked leaf', { ...b, isLocked: true }, 'locked'], + ['a mask', layerContract('b', 'inpaint_mask'), 'ineligible'], + ['a missing leaf', null, 'missing'], + ] as const)('refuses %s without selecting or switching tools', (_, target, status) => { + const h = createHarness(target ? [a, target] : [a]); + + expect(h.controller.request('b')).toBe(status); + expect(h.calls.select).not.toHaveBeenCalled(); + expect(h.calls.enterTool).not.toHaveBeenCalled(); + expect(h.session()).toBeNull(); + expect(h.pending()).toBeNull(); + }); + + it.each([ + ['interaction is locked', (s: HarnessState) => (s.interactionLocked = true)], + ['a gesture runs', (s: HarnessState) => (s.gesture = true)], + ['a float is live', (s: HarnessState) => (s.floating = true)], + ['document edits are locked', (s: HarnessState) => (s.canEdit = false)], + ])('refuses as busy while %s, leaving the selection and tool unchanged', (_, lock) => { + const h = createHarness([a, b]); + lock(h.state); + + expect(h.controller.request('b')).toBe('busy'); + expect(h.calls.select).not.toHaveBeenCalled(); + expect(h.calls.enterTool).not.toHaveBeenCalled(); + expect(h.session()).toBeNull(); + }); + + it('refuses as busy once disposed', () => { + const h = createHarness([a, b]); + h.controller.dispose(); + h.controller.dispose(); + + expect(h.controller.request('b')).toBe('busy'); + expect(h.calls.select).not.toHaveBeenCalled(); + }); +}); + +describe('TransformEditingController pending transform', () => { + /** A paint layer whose only pixels are a stroke persistence has not acknowledged yet. */ + const unpersisted = layerContract('stroke', 'raster'); + const other = layerContract('other', 'raster', { + source: { bitmap: { height: 4, imageName: 'other.png', width: 4 }, type: 'paint' }, + } as Partial); + + const requestUnpersisted = () => { + const h = createHarness([unpersisted, other]); + expect(h.controller.request('stroke')).toBe('started'); + expect(h.session()).toBeNull(); + expect(h.pending()).toBe('stroke'); + const persist = () => { + h.dispatch({ + id: 'stroke', + source: { bitmap: { height: 4, imageName: 'stroke.png', width: 4 }, type: 'paint' }, + type: 'updateCanvasLayerSource', + }); + h.controller.onDocumentChanged(); + }; + return { ...h, persist }; + }; + + it('opens the session once the pixels are persisted, and not before', () => { + const h = requestUnpersisted(); + h.controller.onDocumentChanged(); + expect(h.pending()).toBe('stroke'); + + h.persist(); + expect(h.session()?.layerId).toBe('stroke'); + expect(h.pending()).toBeNull(); + }); + + it('keeps waiting through a switch to the transform tool itself', () => { + const h = requestUnpersisted(); + h.controller.onToolSwitch('transform'); + h.persist(); + expect(h.session()?.layerId).toBe('stroke'); + }); + + it.each([ + ['a tool switch', (h: ReturnType) => h.controller.onToolSwitch('brush')], + ['Escape', (h: ReturnType) => expect(h.controller.onEscape()).toBe(true)], + ['a history replay', (h: ReturnType) => h.controller.onReplay()], + ['a document replacement', (h: ReturnType) => h.controller.onDocumentReplaced()], + [ + 'a selection change', + (h: ReturnType) => { + h.dispatch({ id: 'other', type: 'setCanvasSelectedLayer' }); + h.controller.onDocumentChanged(); + h.dispatch({ id: 'stroke', type: 'setCanvasSelectedLayer' }); + }, + ], + [ + 'leaving transform for a tool the user is on', + (h: ReturnType) => { + h.state.tool = 'brush'; + h.controller.onDocumentChanged(); + h.state.tool = 'transform'; + }, + ], + [ + 'another session opening', + (h: ReturnType) => { + h.controller.begin('other'); + h.controller.onDocumentChanged(); + h.controller.cancel(); + }, + ], + ['disposal', (h: ReturnType) => h.controller.dispose()], + ])('stops waiting on %s, silently', (_, end) => { + const h = requestUnpersisted(); + end(h); + expect(h.pending()).toBeNull(); + + h.persist(); + expect(h.session()).toBeNull(); + expect(h.calls.reportRefusal).not.toHaveBeenCalled(); + }); + + it('stops waiting when the layer is removed', () => { + const h = requestUnpersisted(); + h.dispatch({ ids: ['stroke'], type: 'removeCanvasLayers' }); + h.controller.onDocumentChanged(); + expect(h.pending()).toBeNull(); + }); + + it('leaves Escape to the next handler when nothing waits', () => { + const h = createHarness([other]); + expect(h.controller.onEscape()).toBe(false); + }); + + it('reports not-ready when the waiting layer’s upload fails with no retry to follow', () => { + const h = requestUnpersisted(); + h.controller.onUploadFailed('other'); + expect(h.pending()).toBe('stroke'); + expect(h.calls.reportRefusal).not.toHaveBeenCalled(); + + h.controller.onUploadFailed('stroke'); + expect(h.pending()).toBeNull(); + expect(h.calls.reportRefusal).toHaveBeenCalledExactlyOnceWith('not-ready'); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/transformEditingController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/transformEditingController.ts index 45e9e2abb7c..ada811455e7 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/transformEditingController.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/transformEditingController.ts @@ -1,11 +1,15 @@ -import type { CanvasEditRefusal, StructuralCommitResult } from '@workbench/canvas-engine/capabilities'; +import type { + BeginTransformResult, + CanvasEditRefusal, + StructuralCommitResult, +} from '@workbench/canvas-engine/capabilities'; import type { CanvasDocumentContractV3, CanvasLayerContract } from '@workbench/canvas-engine/contracts'; import type { TransformSession } from '@workbench/canvas-engine/engineStores'; import type { CanvasProjectMutation } from '@workbench/canvas-engine/mutationContracts'; import type { LayerCacheEntry } from '@workbench/canvas-engine/render/layerCache'; import type { RasterBackend } from '@workbench/canvas-engine/render/raster'; import type { LayerTransform } from '@workbench/canvas-engine/transform/transformMath'; -import type { Rect } from '@workbench/canvas-engine/types'; +import type { Rect, ToolId } from '@workbench/canvas-engine/types'; import { lookupDocumentLeaf } from '@workbench/canvas-engine/document-model/documentModel'; import { rebaseControlOriginal } from '@workbench/canvas-engine/document/controlDerivation'; @@ -19,6 +23,7 @@ import { } from '@workbench/canvas-engine/history/history'; import { isEmpty, roundOut, transformBounds } from '@workbench/canvas-engine/math/rect'; import { hittableLayerSize } from '@workbench/canvas-engine/tools/moveHitTest'; +import { isTransformEligible } from '@workbench/canvas-engine/tools/transformTool'; import { bakeMatrix } from '@workbench/canvas-engine/transform/transformMath'; import type { CanvasMutationContext, EditStep } from './mutationContext'; @@ -27,13 +32,19 @@ import { replaceLayerStep, rgbaBytes, withReplayReservation } from './editSteps' export interface TransformEditingControllerOptions { readonly session: { get(): TransformSession | null; set(value: TransformSession | null): void }; + /** A requested session waiting for its layer's pixels to persist; see {@link TransformEditingController.request}. */ + readonly pending: { get(): string | null; set(value: string | null): void }; readonly backend: RasterBackend; readonly ctx: Pick; readonly getDocument: () => CanvasDocumentContractV3 | null; readonly getCache: (layerId: string) => LayerCacheEntry | null; + /** The live cache extent, read without touching residency order. */ + readonly peekCacheRect: (layerId: string) => Rect | undefined; readonly setOverride: (layerId: string, transform: LayerTransform | null) => void; /** Installs whole-layer pixels at a layer-local rect, marking them painted and dirty. */ readonly restoreCache: (layerId: string, rect: Rect, pixels: ImageData) => void; + /** A paint layer's pixels were redrawn from `from`, the session's transform, into its new local space. */ + readonly rebaseLineEnd: (layerId: string, from: LayerTransform) => void; readonly commitStructural: ( label: string, forward: CanvasProjectMutation, @@ -42,6 +53,14 @@ export interface TransformEditingControllerOptions { readonly reportRefusal: (refusal: CanvasEditRefusal) => void; readonly canEdit: () => boolean; readonly isGestureActive: () => boolean; + readonly isInteractionLocked: () => boolean; + readonly hasFloatingSelection: () => boolean; + /** The tool the user is on: a Space, Alt or C hold only lends the surface to another tool. */ + readonly getUserToolId: () => ToolId; + /** Selects `layerId`; selecting cancels a session on another layer. */ + readonly select: (layerId: string) => void; + /** Switches to the transform tool, which opens a session on an eligible selection. */ + readonly enterTool: () => void; readonly invalidate: (payload: { layers: string[]; overlay: true }) => void; } @@ -70,7 +89,10 @@ const unchanged = (left: LayerTransform, right: LayerTransform): boolean => left.scaleY === right.scaleY && left.rotation === right.rotation; -/** Owns transform session state, previews, param commits, and paint bakes. */ +/** + * Owns transform session state, previews, param commits, paint bakes, and the wait of a session requested on pixels + * persistence has not acknowledged yet. + */ export class TransformEditingController { private disposed = false; @@ -100,6 +122,110 @@ export class TransformEditingController { this.deps.invalidate({ layers: [layerId], overlay: true }); } + /** + * Opens a session on `layerId` now, whatever tool is active: selects it, enters the tool and opens the session + * even when transform is already active, cancelling a session on another layer. Every refusal is decided before + * the selection or tool changes. A leaf whose only content is a stroke persistence has not acknowledged yet (a new + * paint layer's first upload) has no persisted extent to frame, so the request waits in `pending` and the session + * opens once the bitmap lands. + */ + request(layerId: string): BeginTransformResult { + const document = this.deps.getDocument(); + const leaf = lookupDocumentLeaf(document, layerId); + if (this.disposed || !document || !leaf) { + return this.disposed ? 'busy' : 'missing'; + } + if (leaf.effectiveLocked) { + return 'locked'; + } + const persisted = isTransformEligible(leaf.layer, document); + if (!persisted && !isTransformEligible(leaf.layer, document, this.deps.peekCacheRect(layerId))) { + return 'ineligible'; + } + if ( + this.deps.isInteractionLocked() || + this.deps.isGestureActive() || + this.deps.hasFloatingSelection() || + !this.deps.canEdit() + ) { + return 'busy'; + } + if (document.selectedLayerId !== layerId) { + this.deps.select(layerId); + } + this.deps.enterTool(); + if (this.deps.session.get()?.layerId !== layerId) { + this.cancel(); + if (persisted) { + this.begin(layerId); + } + if (this.deps.session.get()?.layerId !== layerId) { + this.deps.pending.set(layerId); + } + } + return 'started'; + } + + /** + * Every document change settles a wait: the session opens once the layer's pixels are persisted, if it is still + * the selection, transform is the tool the user is on and no session opened meanwhile; otherwise the wait ends. + */ + onDocumentChanged(): void { + const layerId = this.deps.pending.get(); + if (layerId === null) { + return; + } + const document = this.deps.getDocument(); + const layer = getDocumentLayer(document, layerId); + if ( + !document || + !layer || + document.selectedLayerId !== layerId || + this.deps.getUserToolId() !== 'transform' || + this.deps.session.get() !== null + ) { + this.deps.pending.set(null); + } else if (isTransformEligible(layer, document)) { + this.deps.pending.set(null); + this.begin(layerId); + } + } + + /** A genuine switch to another tool ends the wait; temporary holds never reach here. */ + onToolSwitch(to: ToolId): void { + if (to !== 'transform') { + this.deps.pending.set(null); + } + } + + /** Escape ends a wait once no session is open; returns whether it did. */ + onEscape(): boolean { + if (this.deps.pending.get() === null) { + return false; + } + this.deps.pending.set(null); + return true; + } + + /** A replay lands on a document the request never saw, so the wait ends. */ + onReplay(): void { + this.deps.pending.set(null); + } + + /** A replaced document drops the session and the wait without restoring anything. */ + onDocumentReplaced(): void { + this.deps.session.set(null); + this.deps.pending.set(null); + } + + /** Pixels that could not be persisted, with no retry to follow, will not open a session; say so. */ + onUploadFailed(layerId: string): void { + if (this.deps.pending.get() === layerId) { + this.deps.pending.set(null); + this.deps.reportRefusal('not-ready'); + } + } + update(transform: LayerTransform): void { const session = this.deps.session.get(); if (this.disposed || !session) { @@ -230,6 +356,9 @@ export class TransformEditingController { redo: () => this.replay(forward, afterRect), undo: () => this.replay(backward, beforeRect), }); + if (result.status === 'committed') { + this.deps.rebaseLineEnd(layer.id, session.transform); + } this.settle(result.status); } finally { txn.end(); @@ -254,6 +383,7 @@ export class TransformEditingController { return; } this.cancel(); + this.deps.pending.set(null); this.disposed = true; } } diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/document-model/screenComposition.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/document-model/screenComposition.test.ts index 678f8c249b7..73acf53b756 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/document-model/screenComposition.test.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/document-model/screenComposition.test.ts @@ -73,6 +73,21 @@ describe('planScreenComposition', () => { expect(plan.isolationLayerId).toBe('c1'); }); + it('hides mask and region stacks as staging does, while an isolated mask still draws', () => { + const nodes = [ + layer('m1', 'inpaint_mask'), + groupContract('regions', [layer('g1', 'regional_guidance')]), + layer('c1', 'control'), + layer('r1', 'raster'), + ]; + const staging = { control: true, inpaint_mask: false, regional_guidance: false }; + + expect(drawn(planScreenComposition(leaves(nodes), view({ showOverlayStacks: staging })))).toEqual(['r1', 'c1']); + expect( + drawn(planScreenComposition(leaves(nodes), view({ isolationLayerId: 'm1', showOverlayStacks: staging }))) + ).toEqual(['m1']); + }); + it('isolates a group as every leaf beneath it', () => { const plan = planScreenComposition( leaves([ 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 index 07c7fe4d76d..a7f8c540eb3 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/document/mergeVisible.test.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/mergeVisible.test.ts @@ -10,13 +10,15 @@ import { describe, expect, it } from 'vitest'; import { areSelectedRasterLayersContiguous, + canMergeInpaintMasks, canMergeSelectedRasters, canMergeVisibleRasters, getMergeVisibleRasterLayers, + inpaintMaskMergeEligibility, } from './mergeVisible'; const raster = (id: string, overrides: Partial = {}) => layerContract(id, 'raster', overrides); -const mask = (id: string) => layerContract(id, 'inpaint_mask'); +const mask = (id: string, overrides: Partial = {}) => layerContract(id, 'inpaint_mask', overrides); const gradientRaster = (id: string): CanvasLayerContract => raster(id, { source: { @@ -113,3 +115,67 @@ describe('merge-selected eligibility', () => { ); }); }); + +describe('merge-masks eligibility', () => { + const nodes = [ + mask('top'), + groupContract('a', [mask('inA')]), + groupContract('b', [mask('inB')]), + groupContract('lockedGroup', [mask('underLock')], { isLocked: true }), + layerContract('disabled', 'inpaint_mask', { isEnabled: false }), + layerContract('locked', 'inpaint_mask', { isLocked: true }), + layerContract('region', 'regional_guidance'), + mask('empty'), + raster('raster'), + mask('noisy', { noise: { isEnabled: true, level: 0.3 } } as Partial), + mask('noisyToo', { + mask: { bitmap: null, fill: { color: '#00ff00', style: 'grid' } }, + noise: { isEnabled: true, level: 0.3 }, + } as Partial), + mask('noisier', { noise: { isEnabled: true, level: 0.6 } } as Partial), + mask('quietNoise', { noise: { isEnabled: false, level: 0.6 } } as Partial), + mask('limited', { denoise: { isEnabled: true, limit: 0.4 } } as Partial), + mask('unlimited', { denoise: { isEnabled: false, limit: 0.4 } } as Partial), + ]; + const can = (ids: string[]) => canMergeInpaintMasks(leavesOf(nodes), new Set(ids), hasContent); + const eligibility = (ids: string[]) => inpaintMaskMergeEligibility(leavesOf(nodes), new Set(ids), hasContent); + + it('accepts two or more contributing, unlocked masks with pixels in any groups', () => { + expect(can(['top', 'inA', 'inB'])).toBe(true); + expect(can(['inA', 'inB'])).toBe(true); + }); + + it.each([ + ['a single mask', ['top']], + ['a regional guidance layer', ['top', 'region']], + ['a raster', ['top', 'raster']], + ['a group', ['top', 'a']], + ['a missing id', ['top', 'gone']], + ['a disabled mask', ['top', 'disabled']], + ['a locked mask', ['top', 'locked']], + ['a mask under a locked group', ['top', 'underLock']], + ['a mask without pixels', ['top', 'empty']], + ])('refuses a selection holding %s', (_, ids) => { + expect(can(ids)).toBe(false); + expect(eligibility(ids)).toBe('ineligible'); + }); + + it('accepts masks whose enabled noise and denoise match, whatever their fill or disabled modifiers', () => { + expect(can(['noisy', 'noisyToo'])).toBe(true); + expect(can(['top', 'quietNoise', 'unlimited'])).toBe(true); + }); + + it.each([ + ['noise levels', ['noisy', 'noisier']], + ['enabled and absent noise', ['top', 'noisy']], + ['enabled and disabled noise', ['noisier', 'quietNoise']], + ['enabled and disabled denoise limits', ['limited', 'unlimited']], + ])('refuses different %s as a settings mismatch', (_, ids) => { + expect(can(ids)).toBe(false); + expect(eligibility(ids)).toBe('settings-differ'); + }); + + it('names an ineligible mask before a settings mismatch', () => { + expect(eligibility(['noisy', 'noisier', 'locked'])).toBe('ineligible'); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/document/mergeVisible.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/mergeVisible.ts index 1cc4c152d41..a648336189d 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/document/mergeVisible.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/mergeVisible.ts @@ -1,4 +1,8 @@ -import type { CanvasDocumentContractV3, CanvasLayerContract } from '@workbench/canvas-engine/contracts'; +import type { + CanvasDocumentContractV3, + CanvasInpaintMaskLayerContract, + CanvasLayerContract, +} from '@workbench/canvas-engine/contracts'; import type { SemanticLeaf } from '@workbench/canvas-engine/document-model/semanticLeaf'; import { getDocumentIndex } from './documentIndex'; @@ -81,3 +85,56 @@ export const canMergeSelectedRasters = ( leaf.layer.blendMode === 'normal' && hasContent(leaf.id) ); + +/** + * What generation applies across an inpaint mask's area besides its coverage: the enabled noise level and denoise + * limit (a disabled modifier keeps its tuned value out of generation). Fill and opacity are display-only. + */ +const maskGenerationSettings = (layer: CanvasInpaintMaskLayerContract): string => + `${layer.noise?.isEnabled ? layer.noise.level : '-'}|${layer.denoise?.isEnabled ? layer.denoise.limit : '-'}`; + +/** + * Whether merge-masks can take the exact selection: two or more inpaint masks, each contributing, unlocked and holding + * pixels, wherever they sit in the stack (`ineligible` otherwise), whose generation settings agree, since the merged + * mask carries one set over the whole union (`settings-differ`, reported only for an otherwise eligible selection). + * Regional guidance never merges this way. + */ +export type InpaintMaskMergeEligibility = 'eligible' | 'ineligible' | 'settings-differ'; + +export const inpaintMaskMergeEligibility = ( + leaves: readonly SemanticLeaf[], + selectedLayerIds: ReadonlySet, + hasContent: HasMergeVisibleContent +): InpaintMaskMergeEligibility => { + if (selectedLayerIds.size < 2) { + return 'ineligible'; + } + const settings = new Set(); + let found = 0; + for (const leaf of leaves) { + if (!selectedLayerIds.has(leaf.id)) { + continue; + } + if ( + leaf.layer.type !== 'inpaint_mask' || + !leaf.contributionEnabled || + leaf.effectiveLocked || + !hasContent(leaf.id) + ) { + return 'ineligible'; + } + settings.add(maskGenerationSettings(leaf.layer)); + found += 1; + } + // A selected group or a missing id is not a mask leaf. + if (found !== selectedLayerIds.size) { + return 'ineligible'; + } + return settings.size === 1 ? 'eligible' : 'settings-differ'; +}; + +export const canMergeInpaintMasks = ( + leaves: readonly SemanticLeaf[], + selectedLayerIds: ReadonlySet, + hasContent: HasMergeVisibleContent +): boolean => inpaintMaskMergeEligibility(leaves, selectedLayerIds, hasContent) === 'eligible'; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.resources.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.resources.test.ts index 70bca061feb..f4df89e93d9 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.resources.test.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.resources.test.ts @@ -110,7 +110,20 @@ const offscreenDocument = () => const createFramedEngine = (pendingLayerIds: ReadonlySet) => { const document = offscreenDocument(); - const canvas = { document, documentRevision: 0, snapshots: [], version: 3 }; + const canvas = { + document, + documentRevision: 0, + snapshots: [], + stagingArea: { + areThumbnailsVisible: false, + autoSwitchMode: 'off' as const, + isVisible: false, + pendingImageIds: [], + pendingImages: [], + selectedImageIndex: 0, + }, + version: 3, + }; const store = { discardLayer: vi.fn(), dispose: vi.fn(), diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.test.ts index 0b4fbbaeccb..aa0329f7daa 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.test.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.test.ts @@ -38,6 +38,7 @@ import { groupContract, stacksFrom } from '@workbench/canvas-engine/document-mod import { getDocumentLayer, getDocumentLeaves } from '@workbench/canvas-engine/document/documentIndex'; import { stackTopAnchor } from '@workbench/canvas-engine/document/insertionAnchors.testStub'; import { haveSameStructure } from '@workbench/canvas-engine/document/layerStacks'; +import * as compositor from '@workbench/canvas-engine/render/compositor'; import { DEFAULT_CHECKER_COLORS } from '@workbench/canvas-engine/render/compositor'; import { createTestStubRasterBackend } from '@workbench/canvas-engine/render/raster.testStub'; import { createInlineSpatialExecutor } from '@workbench/canvas-engine/render/spatial/spatialAdjustmentService.testing'; @@ -59,6 +60,8 @@ import type { StrokeCommittedEvent } from './tools/tool'; import { createBitmapStore } from './document/bitmapStore'; import { mergeDownMatrix } from './document/mergeDown'; import { createCanvasEngine as createCoreCanvasEngine } from './engine'; +import { RASTERIZATION_RETRY_BASE_MS } from './layerRasterizer'; +import { CanvasImageMissingError } from './render/rasterizers/imageRasterizer'; interface EngineStore { dispatch(action: EngineTestAction): void; @@ -237,6 +240,21 @@ const rasterLayer = (id: string, opts: { imageName?: string; opacity?: number } type: 'raster', }); +/** A raster the brush cannot paint in place, so a stroke on it auto-creates a paint layer. */ +const textRasterLayer = (id: string): CanvasLayerContract => ({ + ...(rasterLayer(id) as CanvasRasterLayerContractV2), + source: { + align: 'left', + color: '#000000', + content: id, + fontFamily: 'sans', + fontSize: 20, + fontWeight: 400, + lineHeight: 1.2, + type: 'text', + }, +}); + const makeDoc = (): CanvasDocumentContractV3 => ({ background: 'transparent', bbox: { height: 100, width: 100, x: 0, y: 0 }, @@ -737,6 +755,235 @@ describe('createCanvasEngine', () => { engine.lifecycle.dispose(); }); + it('retries a layer whose image failed only once its backoff ends, and shows it once the image loads', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + try { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + const document = { ...makeDoc(), stacks: stacksFrom([rasterLayer('flaky')]), selectedLayerId: 'flaky' }; + const { projectId, store } = createReducerBackedStore(document); + let available = false; + const imageResolver = vi.fn(() => + available ? Promise.resolve(new Blob()) : Promise.reject(new Error('backend restarting')) + ); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver, + projectId, + reportError: vi.fn(), + store, + }); + engine.surface.attach(createInputCanvas().element, createInputCanvas().element); + // The shared helper waits on a timer, which these fake timers hold. + const settle = async () => { + for (let tick = 0; tick < 50; tick += 1) { + await Promise.resolve(); + } + }; + const renderFrames = async () => { + for (let frame = 0; frame < 3; frame += 1) { + engine.viewport.getViewport().zoomAtPoint(1 + frame / 10, { x: 0, y: 0 }); + raf.flush(); + await settle(); + } + }; + + await renderFrames(); + expect(imageResolver).toHaveBeenCalledOnce(); + expect(engine.stores.thumbnailStatus.get('flaky')).toBe('error'); + + // Frames inside the backoff never read the image again. + vi.advanceTimersByTime(RASTERIZATION_RETRY_BASE_MS - 1); + await renderFrames(); + expect(imageResolver).toHaveBeenCalledOnce(); + + // Its end invalidates the layer by itself, and the next frame reads the image, which now loads. + available = true; + vi.advanceTimersByTime(1); + raf.flush(); + await settle(); + expect(imageResolver).toHaveBeenCalledTimes(2); + expect(engine.stores.thumbnailStatus.get('flaky')).toBe('ready'); + await renderFrames(); + expect(imageResolver).toHaveBeenCalledTimes(2); + engine.lifecycle.dispose(); + } finally { + vi.useRealTimers(); + } + }); + + it('answers Merge down not ready while an image cannot be fetched, retrying it on every ask, and merges once it loads', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + try { + const document = { ...makeDoc(), stacks: stacksFrom([rasterLayer('upper'), rasterLayer('flaky')]) }; + const { projectId, store } = createReducerBackedStore(document); + let available = false; + const imageResolver = vi.fn((imageName: string) => + imageName !== 'flaky' || available + ? Promise.resolve(new Blob()) + : Promise.reject(new Error('Failed to load canvas image "flaky" (503)')) + ); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver, + projectId, + reportError: vi.fn(), + store, + }); + const settle = async () => { + for (let tick = 0; tick < 50; tick += 1) { + await Promise.resolve(); + } + }; + const flakyFetches = () => imageResolver.mock.calls.filter(([name]) => name === 'flaky').length; + expect((await engine.exports.exportLayerPixels('upper')).status).toBe('ok'); + expect(await engine.exports.exportLayerPixels('flaky')).toEqual({ status: 'not-ready' }); + expect(flakyFetches()).toBe(1); + // Asking again fetches at once, inside the backoff; a second ask joins that fetch rather than starting another. + const asks = [engine.layers.mergeLayerDown('upper'), engine.layers.mergeLayerDown('upper')]; + expect(await Promise.all(asks)).toEqual(['not-ready', 'not-ready']); + await settle(); + expect(flakyFetches()).toBe(2); + + // The server recovers; the next ask, still inside the backoff, fetches and the one after merges. + available = true; + expect(await engine.layers.mergeLayerDown('upper')).toBe('not-ready'); + await settle(); + expect(flakyFetches()).toBe(3); + expect(await engine.layers.mergeLayerDown('upper')).toBe('merged'); + engine.lifecycle.dispose(); + } finally { + vi.useRealTimers(); + } + }); + + it('crops all at once when asked again after a fetch outage ends, without waiting out the backoff', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + try { + const document = { + ...makeDoc(), + bbox: { height: 8, width: 8, x: 0, y: 0 }, + stacks: stacksFrom([rasterLayer('flaky'), rasterLayer('a')]), + }; + const { projectId, store } = createReducerBackedStore(document); + let available = false; + const imageResolver = vi.fn((imageName: string) => + imageName !== 'flaky' || available + ? Promise.resolve(new Blob()) + : Promise.reject(new Error('Failed to load canvas image "flaky" (503)')) + ); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver, + projectId, + reportError: vi.fn(), + store, + }); + const flakyFetches = () => imageResolver.mock.calls.filter(([name]) => name === 'flaky').length; + + expect(await engine.layers.cropAllLayersToBbox()).toEqual({ status: 'not-ready' }); + expect(getDocumentLayer(engine.document.getDocument()!, 'a')).toMatchObject({ source: { type: 'image' } }); + expect(flakyFetches()).toBe(1); + + available = true; + expect(await engine.layers.cropAllLayersToBbox()).toEqual({ + cropped: 2, + skippedLocked: 0, + skippedUnreadable: [], + status: 'cropped', + }); + expect(flakyFetches()).toBe(2); + engine.lifecycle.dispose(); + } finally { + vi.useRealTimers(); + } + }); + + it('names the topmost of two unreadable layers when Merge selected refuses', async () => { + const document = { + ...makeDoc(), + stacks: stacksFrom([ + { ...rasterLayer('upper'), name: 'Sky' }, + { ...rasterLayer('below'), name: 'Hills' }, + ]), + }; + const { projectId, store } = createReducerBackedStore(document); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: (imageName) => Promise.reject(new CanvasImageMissingError(imageName)), + projectId, + reportError: vi.fn(), + store, + }); + + // Contributors are read in document order, topmost first, and the first that cannot be read is named. + expect(await engine.layers.mergeSelectedRasterLayers(['below', 'upper'])).toEqual({ + layerId: 'upper', + layerName: 'Sky', + status: 'unreadable', + }); + expect(getDocumentLeaves(engine.document.getDocument()!).map((layer) => layer.id)).toEqual(['upper', 'below']); + engine.lifecycle.dispose(); + }); + + it('keeps Merge down unreadable past the backoff for a layer nobody redraws, and merges once its image loads', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + try { + const document = { + ...makeDoc(), + stacks: stacksFrom([rasterLayer('upper'), { ...rasterLayer('flaky'), name: 'Sky' }]), + }; + const { projectId, store } = createReducerBackedStore(document); + let available = false; + const imageResolver = vi.fn((imageName: string) => + imageName !== 'flaky' || available + ? Promise.resolve(new Blob()) + : Promise.reject(new CanvasImageMissingError(imageName)) + ); + const reportError = vi.fn(); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver, + projectId, + reportError, + store, + }); + const settle = async () => { + for (let tick = 0; tick < 50; tick += 1) { + await Promise.resolve(); + } + }; + // The refusal names the layer below, which is the one that cannot be read. + const unreadable = { layerId: 'flaky', layerName: 'Sky', status: 'unreadable' }; + // No surface is attached, so nothing redraws either layer. + expect((await engine.exports.exportLayerPixels('upper')).status).toBe('ok'); + expect(await engine.exports.exportLayerPixels('flaky')).toEqual(unreadable); + expect(await engine.layers.mergeLayerDown('upper')).toEqual(unreadable); + + vi.advanceTimersByTime(RASTERIZATION_RETRY_BASE_MS); + // Past the backoff it still cannot be read, and asking tries the image again in the background. + expect(await engine.layers.mergeLayerDown('upper')).toEqual(unreadable); + await settle(); + expect(imageResolver.mock.calls.filter(([name]) => name === 'flaky')).toHaveLength(2); + expect(reportError.mock.calls).toHaveLength(1); + + available = true; + vi.advanceTimersByTime(RASTERIZATION_RETRY_BASE_MS * 2); + expect(await engine.layers.mergeLayerDown('upper')).toEqual(unreadable); + await settle(); + expect(await engine.layers.mergeLayerDown('upper')).toBe('merged'); + engine.lifecycle.dispose(); + } finally { + vi.useRealTimers(); + } + }); + it('settles a claimed color sample on release, after the sampling gesture ends', async () => { const raf = createControllableRaf(); vi.stubGlobal('requestAnimationFrame', raf.requestFrame); @@ -818,6 +1065,61 @@ describe('createCanvasEngine', () => { engine.lifecycle.dispose(); }); + it('sends a pick’s commit or cancel only to the sink that took its first preview, through real pointer events', async () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + const layer = { + ...rasterLayer('a'), + source: { bitmap: { height: 20, imageName: 'paint-pixels', width: 20 }, type: 'paint' as const }, + }; + const document = { ...makeDoc(), stacks: stacksFrom([layer]), selectedLayerId: layer.id }; + const { projectId, store } = createReducerBackedStore(document); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const overlay = createInputCanvas(); + engine.surface.attach(createInputCanvas().element, overlay.element); + raf.flush(); + await flushMicrotasks(); + raf.flush(); + const routed: string[] = []; + engine.tools.setColorSampleRouter((_hex, phase) => { + routed.push(phase); + return true; + }); + + // A standing pick is under way when a one-shot request arrives: the router's pick is cancelled, once. + engine.tools.setTool('colorPicker'); + overlay.fire('pointerdown', pointerAt(5, 5)); + let settled: string | null | undefined; + void engine.tools.requestColorSample().then((hex) => { + settled = hex; + }); + expect(routed).toEqual(['preview', 'cancel']); + overlay.fire('pointerup', pointerAt(5, 5, { buttons: 0 })); + overlay.fire('pointerdown', pointerAt(5, 5)); + overlay.fire('pointerup', pointerAt(5, 5, { buttons: 0 })); + await flushMicrotasks(); + expect(settled).toBe('#000000'); + + // A request settled by Escape mid-pick sends its pick's cancel nowhere near the router. + void engine.tools.requestColorSample().then((hex) => { + settled = hex; + }); + overlay.fire('pointerdown', pointerAt(5, 5)); + engine.tools.handleEscapePriority({ gestureWasActive: false }); + overlay.fire('pointercancel', pointerAt(5, 5, { buttons: 0 })); + await flushMicrotasks(); + expect(settled).toBeNull(); + expect(routed).toEqual(['preview', 'cancel']); + engine.lifecycle.dispose(); + }); + it('returns stale when cooldown changes lifecycle generation during raster detachment', async () => { const pending = createDeferred(); const { store } = createReactiveStore(makeDoc()); @@ -916,11 +1218,11 @@ describe('createCanvasEngine', () => { engine.lifecycle.dispose(); }); - it('returns typed not-ready when asynchronous raster cache preparation fails', async () => { + it('returns typed unreadable when asynchronous raster cache preparation fails', async () => { const { store } = createReactiveStore(makeDoc()); const engine = createCanvasEngine({ backend: createTestStubRasterBackend(), - imageResolver: () => Promise.reject(new Error('decode unavailable')), + imageResolver: (imageName) => Promise.reject(new CanvasImageMissingError(imageName)), projectId: 'p1', reportError: vi.fn(), store, @@ -928,7 +1230,9 @@ describe('createCanvasEngine', () => { const documentSnapshot = engine.document.captureSnapshot(); await expect(engine.exports.captureRasterSnapshot(documentSnapshot!, ['a'])).resolves.toEqual({ - status: 'not-ready', + layerId: 'a', + layerName: 'a', + status: 'unreadable', }); engine.lifecycle.dispose(); }); @@ -1166,6 +1470,44 @@ describe('createCanvasEngine', () => { engine.lifecycle.dispose(); }); + it('refuses a composite export with a layer whose image is gone as unreadable, naming the layer', async () => { + const { store } = createReactiveStore({ + ...makeDoc(), + stacks: stacksFrom([{ ...rasterLayer('a'), name: 'Sky' }, rasterLayer('b')]), + }); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: (imageName) => + imageName === 'a' ? Promise.reject(new CanvasImageMissingError(imageName)) : Promise.resolve(new Blob()), + projectId: 'p1', + reportError: vi.fn(), + store, + }); + + await expect(engine.exports.exportRasterComposite({ bounds: 'content' })).resolves.toEqual({ + layerId: 'a', + layerName: 'Sky', + status: 'unreadable', + }); + engine.lifecycle.dispose(); + }); + + it('refuses a composite export as not ready, never unreadable, while a layer image cannot be fetched', async () => { + const { store } = createReactiveStore(makeDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.reject(new Error('Failed to load canvas image "a" (503)')), + projectId: 'p1', + reportError: vi.fn(), + store, + }); + + await expect(engine.exports.exportRasterComposite({ bounds: 'content' })).resolves.toEqual({ + status: 'not-ready', + }); + engine.lifecycle.dispose(); + }); + it('uploads per-generation composites through the intermediate seam, not the durable one', async () => { // Generation composites have no document references and must be reclaimable; only layer-owned pixels require // durable uploads. @@ -1529,7 +1871,11 @@ describe('createCanvasEngine', () => { store, }); - expect(await engine.exports.exportLayerPixels('shape')).toEqual({ status: 'not-ready' }); + expect(await engine.exports.exportLayerPixels('shape')).toEqual({ + layerId: 'shape', + layerName: 'shape', + status: 'unreadable', + }); expect((await engine.exports.exportLayerPixels('shape')).status).toBe('ok'); engine.lifecycle.dispose(); }); @@ -2126,7 +2472,110 @@ describe('createCanvasEngine', () => { engine.lifecycle.dispose(); }); - it('cropLayerToBbox returns empty without mutation when the layer and bbox do not overlap', async () => { + it('cropAllLayersToBbox crops every unlocked leaf as one undo step and replays every contract', async () => { + const document = { + ...makeDoc(), + bbox: { height: 8, width: 10, x: 8, y: 2 }, + stacks: stacksFrom([ + { ...rasterLayer('a'), transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 5, y: 5 } }, + { ...rasterLayer('locked'), isLocked: true }, + { ...rasterLayer('outside'), isEnabled: false, transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 60, y: 60 } }, + { + children: [ + { + ...maskLayer('mask'), + isHidden: true, + mask: { + bitmap: { height: 10, imageName: 'mask-img', width: 10 }, + fill: { color: '#123456', style: 'crosshatch' as const }, + offset: { x: 0, y: 0 }, + }, + }, + ], + id: 'masks', + isEnabled: true, + isLocked: false, + name: 'Masks', + type: 'group', + }, + ]), + }; + const { projectId, store } = createReducerBackedStore(document); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const before = structuredClone(engine.document.getDocument()!); + + expect(await engine.layers.cropAllLayersToBbox()).toEqual({ + cropped: 3, + skippedLocked: 1, + skippedUnreadable: [], + status: 'cropped', + }); + const cropped = structuredClone(engine.document.getDocument()!); + expect(getDocumentLayer(cropped, 'a')).toMatchObject({ source: { bitmap: null, offset: { x: 8, y: 5 } } }); + expect(getDocumentLayer(cropped, 'outside')).toMatchObject({ isEnabled: false, source: { bitmap: null } }); + expect(getDocumentLayer(cropped, 'mask')).toMatchObject({ + isHidden: true, + mask: { bitmap: null, fill: { color: '#123456', style: 'crosshatch' }, offset: { x: 8, y: 2 } }, + }); + expect(getDocumentLayer(cropped, 'locked')).toEqual(getDocumentLayer(before, 'locked')); + expect(cropped.stacks.inpaint_mask.map((node) => node.id)).toEqual(['masks']); + expect(engine.history.getEntries()).toEqual({ future: [], past: ['Crop all layers to frame'] }); + + await engine.history.undo(); + expect(engine.document.getDocument()).toEqual(before); + await engine.history.redo(); + expect(engine.document.getDocument()).toEqual(cropped); + engine.lifecycle.dispose(); + }); + + it('cropAllLayersToBbox crops around a layer whose image cannot be read, and reports the read failure once', async () => { + const document = { + ...makeDoc(), + bbox: { height: 8, width: 8, x: 0, y: 0 }, + stacks: stacksFrom([rasterLayer('broken'), rasterLayer('a')]), + }; + const { projectId, store } = createReducerBackedStore(document); + const reportError = vi.fn(); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: (imageName) => + imageName === 'broken' ? Promise.reject(new CanvasImageMissingError(imageName)) : Promise.resolve(new Blob()), + projectId, + reportError, + store, + }); + const broken = getDocumentLayer(engine.document.getDocument()!, 'broken'); + const skippedUnreadable = [{ layerId: 'broken', layerName: 'broken', status: 'unreadable' }]; + + expect(await engine.layers.cropAllLayersToBbox()).toEqual({ + cropped: 1, + skippedLocked: 0, + skippedUnreadable, + status: 'cropped', + }); + expect(getDocumentLayer(engine.document.getDocument()!, 'broken')).toBe(broken); + expect(getDocumentLayer(engine.document.getDocument()!, 'a')).toMatchObject({ source: { type: 'paint' } }); + // Asking again reads the image again, but the same failure raises no second notice. + expect(await engine.layers.cropAllLayersToBbox()).toEqual({ + skippedLocked: 0, + skippedUnreadable, + status: 'nothing', + }); + const readFailures = reportError.mock.calls.filter( + ([report]) => (report as { message: string }).message === 'Layer thumbnail rasterization failed' + ); + expect(readFailures).toHaveLength(1); + engine.lifecycle.dispose(); + }); + + it('cropLayerToBbox refuses without mutation a layer that lies wholly outside the frame', async () => { const document = { ...makeDoc(), bbox: { height: 5, width: 5, x: 50, y: 50 } }; const { projectId, store } = createReducerBackedStore(document); const bitmapStore = createSpyBitmapStore(); @@ -2139,14 +2588,14 @@ describe('createCanvasEngine', () => { }); const before = engine.document.getDocument(); - expect(await engine.layers.cropLayerToBbox('a')).toEqual({ status: 'empty' }); + expect(await engine.layers.cropLayerToBbox('a')).toEqual({ status: 'outside-frame' }); expect(engine.document.getDocument()).toBe(before); expect(engine.stores.canUndo.get()).toBe(false); expect(bitmapStore.markLayerDirty).not.toHaveBeenCalled(); engine.lifecycle.dispose(); }); - it('cropLayerToBbox reports missing, locked, unsupported, and not-ready layers explicitly', async () => { + it('cropLayerToBbox reports missing, locked, unsupported, and unreadable layers explicitly', async () => { // A polygon without enough points to fill has no raster to crop. const polygon: CanvasLayerSourceContract = { fill: '#000', @@ -2161,13 +2610,13 @@ describe('createCanvasEngine', () => { const layers = [ { ...rasterLayer('locked'), isLocked: true }, { ...rasterLayer('polygon'), source: polygon }, - rasterLayer('pending'), + rasterLayer('unreadable'), ]; const document = { ...makeDoc(), stacks: stacksFrom(layers) }; const { projectId, store } = createReducerBackedStore(document); const engine = createCanvasEngine({ backend: createTestStubRasterBackend(), - imageResolver: () => Promise.reject(new Error('decode failed')), + imageResolver: (imageName) => Promise.reject(new CanvasImageMissingError(imageName)), projectId, store, }); @@ -2175,7 +2624,11 @@ describe('createCanvasEngine', () => { expect(await engine.layers.cropLayerToBbox('missing')).toEqual({ status: 'missing' }); expect(await engine.layers.cropLayerToBbox('locked')).toEqual({ status: 'locked' }); expect(await engine.layers.cropLayerToBbox('polygon')).toEqual({ status: 'unsupported' }); - expect(await engine.layers.cropLayerToBbox('pending')).toEqual({ status: 'not-ready' }); + expect(await engine.layers.cropLayerToBbox('unreadable')).toEqual({ + layerId: 'unreadable', + layerName: 'unreadable', + status: 'unreadable', + }); engine.lifecycle.dispose(); }); @@ -3169,38 +3622,190 @@ const putImageDataCalls = (surfaces: StubRasterSurface[]): { image: unknown; x: .map((entry) => ({ image: entry.args[0], x: entry.args[1], y: entry.args[2] })) ); -describe('engine-owned pixel editing', () => { - it('materializes an image layer plus eraser stroke as one reversible edit', async () => { - const h = createRasterImageEraserHarness(); +describe('engine-owned pixel editing', () => { + it.each(['eraser', 'brush'] as const)( + 'materializes an image layer plus %s stroke as one reversible edit', + async (tool) => { + const h = createRasterImageEraserHarness(); + await h.publishInitialCache(); + const before = structuredClone(getDocumentLeaves(h.engine.document.getDocument()!)[0]); + + h.engine.tools.setTool(tool); + h.overlay.fire('pointerdown', pointerAt(20, 20)); + h.overlay.fire('pointermove', pointerAt(25, 25)); + h.overlay.fire('pointerup', pointerAt(25, 25, { buttons: 0 })); + + const after = structuredClone(getDocumentLeaves(h.engine.document.getDocument()!)[0]); + expect(getDocumentLeaves(h.engine.document.getDocument()!)).toHaveLength(1); + expect(after).toMatchObject({ + id: 'image', + source: { type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', + }); + expect(after).not.toHaveProperty('adjustments'); + expect(h.strokes).toHaveLength(1); + expect(h.strokes[0]).toMatchObject({ layerId: 'image', tool }); + expect(h.bitmapStore.suspendLayer).toHaveBeenCalledWith('image'); + expect(h.bitmapStore.markLayerDirty).toHaveBeenCalledWith('image'); + expect(h.engine.stores.canUndo.get()).toBe(true); + + await h.engine.history.undo(); + expect(getDocumentLeaves(h.engine.document.getDocument()!)[0]).toEqual(before); + expect(h.engine.stores.canRedo.get()).toBe(true); + + await h.engine.history.redo(); + expect(getDocumentLeaves(h.engine.document.getDocument()!)[0]).toEqual(after); + h.engine.lifecycle.dispose(); + } + ); + + it('draws a Shift-click line from the last stroke end until a replay or document swap forgets it', async () => { + const h = createRasterImageEraserHarness({ adjustments: [], blendMode: 'normal', opacity: 1 }); + await h.publishInitialCache(); + // Stroke outlines are traced through Path2D; their horizontal extent tells a dab from a line. + let xs: number[] = []; + vi.stubGlobal( + 'Path2D', + class RecordingPath2D { + closePath() {} + lineTo(x: number) { + xs.push(x); + } + moveTo(x: number) { + xs.push(x); + } + quadraticCurveTo(_cx: number, _cy: number, x: number) { + xs.push(x); + } + } + ); + const strokeSpan = (fire: () => void): number => { + xs = []; + fire(); + return Math.max(...xs) - Math.min(...xs); + }; + const click = (x: number, shiftKey: boolean) => () => { + h.overlay.fire('pointerdown', { ...pointerAt(x, 20), shiftKey }); + h.overlay.fire('pointerup', { ...pointerAt(x, 20, { buttons: 0 }), shiftKey }); + }; + + h.engine.tools.setTool('brush'); + const dab = strokeSpan(click(12, false)); + // From the eraser, the line still starts where the brush stroke ended; the materialized layer keeps its id. + h.engine.tools.setTool('eraser'); + expect(strokeSpan(click(42, true))).toBeGreaterThanOrEqual(dab + 30); + expect(h.strokes).toHaveLength(2); + + await h.engine.history.undo(); + expect(strokeSpan(click(12, true))).toBeCloseTo(dab, 6); + + h.engine.document.replaceDocument(structuredClone(h.engine.document.getDocument()!)); + await h.publishInitialCache(); + expect(strokeSpan(click(42, true))).toBeCloseTo(dab, 6); + h.engine.lifecycle.dispose(); + }); + + it('starts a Shift-click line from the last stroke end on a layer nudged 100 px since', async () => { + const h = createRasterImageEraserHarness({ adjustments: [], blendMode: 'normal', opacity: 1 }); await h.publishInitialCache(); - const before = structuredClone(getDocumentLeaves(h.engine.document.getDocument()!)[0]); + let xs: number[] = []; + vi.stubGlobal( + 'Path2D', + class RecordingPath2D { + closePath() {} + lineTo(x: number) { + xs.push(x); + } + moveTo(x: number) { + xs.push(x); + } + quadraticCurveTo(_cx: number, _cy: number, x: number) { + xs.push(x); + } + } + ); + const strokeSpan = (x: number, shiftKey: boolean): number => { + xs = []; + h.overlay.fire('pointerdown', { ...pointerAt(x, 20), shiftKey }); + h.overlay.fire('pointerup', { ...pointerAt(x, 20, { buttons: 0 }), shiftKey }); + return Math.max(...xs) - Math.min(...xs); + }; - h.engine.tools.setTool('eraser'); - h.overlay.fire('pointerdown', pointerAt(20, 20)); - h.overlay.fire('pointermove', pointerAt(25, 25)); - h.overlay.fire('pointerup', pointerAt(25, 25, { buttons: 0 })); + h.engine.tools.setTool('brush'); + const dab = strokeSpan(12, false); + expect(h.engine.layers.nudgeSelectedLayer(100, 0).status).toBe('committed'); + // The stroke ended at x=12 on the layer, which now sits at x=112: the line runs 30 px, not 130. + const line = strokeSpan(142, true); + expect(line).toBeGreaterThan(dab + 25); + expect(line).toBeLessThan(dab + 35); + h.engine.lifecycle.dispose(); + }); - const after = structuredClone(getDocumentLeaves(h.engine.document.getDocument()!)[0]); - expect(getDocumentLeaves(h.engine.document.getDocument()!)).toHaveLength(1); - expect(after).toMatchObject({ - id: 'image', - source: { type: 'paint' }, + it.each([ + [ + 'cropped', + async (h: ReturnType) => { + expect(h.engine.layers.nudgeSelectedLayer(30, 0).status).toBe('committed'); + expect(await h.engine.layers.cropLayerToBbox('paint')).toEqual({ status: 'cropped' }); + }, + ], + [ + 'transformed and applied', + (h: ReturnType) => { + expect(h.engine.layers.beginTransform('paint')).toBe('started'); + expect(h.engine.stores.transformSession.get()?.layerId).toBe('paint'); + h.engine.layers.updateTransformSession({ rotation: 0, scaleX: 1, scaleY: 1, x: 30, y: 0 }); + h.engine.layers.applyTransform(); + expect(h.engine.stores.transformSession.get()).toBeNull(); + expect(h.engine.history.getEntries().past.at(-1)).toBe('Transform layer'); + }, + ], + ])('starts a Shift-click line where the last stroke ended once its paint layer is %s', async (_label, edit) => { + const h = createPixelEditHarness({ + blendMode: 'normal', + id: 'paint', + isEnabled: true, + isLocked: false, + name: 'Paint', + opacity: 1, + source: { bitmap: { height: 10, imageName: 'paint-bitmap', width: 20 }, offset: { x: 5, y: 15 }, type: 'paint' }, transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, type: 'raster', }); - expect(after).not.toHaveProperty('adjustments'); - expect(h.strokes).toHaveLength(1); - expect(h.strokes[0]).toMatchObject({ layerId: 'image', tool: 'eraser' }); - expect(h.bitmapStore.suspendLayer).toHaveBeenCalledWith('image'); - expect(h.bitmapStore.markLayerDirty).toHaveBeenCalledWith('image'); - expect(h.engine.stores.canUndo.get()).toBe(true); - - await h.engine.history.undo(); - expect(getDocumentLeaves(h.engine.document.getDocument()!)[0]).toEqual(before); - expect(h.engine.stores.canRedo.get()).toBe(true); + await h.publishInitialCache(); + let xs: number[] = []; + vi.stubGlobal( + 'Path2D', + class RecordingPath2D { + closePath() {} + lineTo(x: number) { + xs.push(x); + } + moveTo(x: number) { + xs.push(x); + } + quadraticCurveTo(_cx: number, _cy: number, x: number) { + xs.push(x); + } + } + ); + const strokeSpan = (x: number, shiftKey: boolean): number => { + xs = []; + h.overlay.fire('pointerdown', { ...pointerAt(x, 20), shiftKey }); + h.overlay.fire('pointerup', { ...pointerAt(x, 20, { buttons: 0 }), shiftKey }); + return Math.max(...xs) - Math.min(...xs); + }; - await h.engine.history.redo(); - expect(getDocumentLeaves(h.engine.document.getDocument()!)[0]).toEqual(after); + h.engine.tools.setTool('brush'); + const dab = strokeSpan(12, false); + await edit(h); + // Both edits rebuild the layer at an identity transform with the stroke's pixels 30 px to the right, at x=42. + expect(getDocumentLeaves(h.engine.document.getDocument()!)[0]?.transform).toMatchObject({ x: 0, y: 0 }); + h.engine.tools.setTool('brush'); + const line = strokeSpan(72, true); + expect(line).toBeGreaterThan(dab + 25); + expect(line).toBeLessThan(dab + 35); h.engine.lifecycle.dispose(); }); @@ -5289,7 +5894,7 @@ describe('requestLayerThumbnail', () => { expect.objectContaining({ area: 'canvas-engine', context: expect.objectContaining({ - error: expect.objectContaining({ message: 'decode failed' }), + error: expect.objectContaining({ message: 'Canvas image "a" could not be fetched.' }), layerId: 'a', }), message: 'Layer thumbnail rasterization failed', @@ -7428,6 +8033,69 @@ describe('structural raster publication failure atomicity', () => { await expectLayerCacheExact(engine, source.id, croppedCache); engine.lifecycle.dispose(); }); + + it('crop all right after a stroke waits for its upload, so the acknowledgement cannot make it stale', async () => { + const painted: CanvasRasterLayerContractV2 = { + ...(rasterLayer('painted') as CanvasRasterLayerContractV2), + source: { bitmap: { height: 10, imageName: 'painted-before', width: 10 }, offset: { x: 0, y: 0 }, type: 'paint' }, + }; + const document: CanvasDocumentContractV3 = { + ...makeDoc(), + bbox: { height: 7, width: 6, x: 2, y: 3 }, + stacks: stacksFrom([painted, rasterLayer('slow')]), + }; + const reducer = createReducerBackedStore(document); + const upload = createDeferred<{ height: number; imageName: string; width: number }>(); + const uploadImage = vi.fn(() => upload.promise); + let placed: { offset: { x: number; y: number }; surface: RasterSurface } | null = null; + const bitmapStore = createBitmapStore({ + debounceMs: 1500, + dispatch: createTestMutationPort(reducer.store, reducer.projectId).dispatch, + encodeSurface: (surface) => + Promise.resolve(new Blob([`${surface.width}x${surface.height}`], { type: 'image/png' })), + getLayerSource: (layerId) => { + const layer = getDocumentLeaves( + reducer.store.getState().projects.find((project) => project.id === reducer.projectId)?.canvas.document + ).find((candidate) => candidate.id === layerId); + return layer?.type === 'raster' ? layer.source : null; + }, + getLayerSurface: () => placed, + hashBlob: (blob) => blob.text(), + maxUploadAttempts: 1, + retryDelaysMs: [], + sleep: () => Promise.resolve(), + uploadImage, + }); + // The second layer's image is still decoding, so crop all is mid-read when the acknowledgement could land. + const slowImage = createDeferred(); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore, + imageResolver: (imageName) => (imageName === 'slow' ? slowImage.promise : Promise.resolve(new Blob())), + projectId: reducer.projectId, + store: reducer.store, + }); + const before = await snapshotLayerCache(engine, painted.id); + placed = { offset: { x: before.rect.x, y: before.rect.y }, surface: before.surface }; + // A stroke marks the layer dirty and its debounced upload starts. + bitmapStore.markLayerDirty(painted.id); + void bitmapStore.flushPendingUploads(); + for (let tick = 0; tick < 50 && uploadImage.mock.calls.length === 0; tick += 1) { + await Promise.resolve(); + } + expect(uploadImage).toHaveBeenCalledOnce(); + + const crop = engine.layers.cropAllLayersToBbox(); + upload.resolve({ height: 10, imageName: 'painted-after', width: 10 }); + for (let tick = 0; tick < 50; tick += 1) { + await Promise.resolve(); + } + slowImage.resolve(new Blob()); + + await expect(crop).resolves.toEqual({ cropped: 2, skippedLocked: 0, skippedUnreadable: [], status: 'cropped' }); + expect(engine.history.getEntries().past).toEqual(['Crop all layers to frame']); + engine.lifecycle.dispose(); + }); }); describe('mergeVisibleRasterLayers', () => { @@ -8901,6 +9569,11 @@ const imageSelectedDoc = (): CanvasDocumentContractV3 => ({ width: 100, }); +const textSelectedDoc = (): CanvasDocumentContractV3 => ({ + ...imageSelectedDoc(), + stacks: stacksFrom([textRasterLayer('img')]), +}); + 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', async () => { const raf = createControllableRaf(); @@ -8916,7 +9589,7 @@ describe('engine-owned history: composed auto-create + stroke entry', () => { } ); - const { store } = createReactiveStore(imageSelectedDoc()); + const { store } = createReactiveStore(textSelectedDoc()); const dispatch = store.dispatch as Mock; const base = createTestStubRasterBackend(); const surfaces: StubRasterSurface[] = []; @@ -15350,8 +16023,323 @@ describe('fitToView: content ∪ bbox', () => { }); }); +describe('overview: composites the fit-to-view extent', () => { + const paintAt = (id: string, x: number, y: number, isEnabled = true): CanvasLayerContract => ({ + ...(rasterLayer(id) as CanvasRasterLayerContractV2), + isEnabled, + source: { bitmap: { height: 100, imageName: `${id}-pixels`, width: 100 }, type: 'paint' }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x, y }, + }); + const overviewTarget = (): HTMLCanvasElement => + ({ + getContext: () => createTestStubRasterBackend().createSurface(1, 1).ctx, + height: 0, + width: 0, + }) as unknown as HTMLCanvasElement; + + it('sizes, translates and reports the frame ∪ enabled content, leaving disabled layers out', () => { + const composite = vi.spyOn(compositor, 'compositeDocument'); + // Frame (0,0)..(100,100) ∪ enabled paint (-200,-50)..(-100,50) = (-200,-50) 300×150; the disabled layer at + // (500,500) would stretch it to 800×650. + const { store } = createReactiveStore( + noLayerDoc({ stacks: stacksFrom([paintAt('outside', -200, -50), paintAt('hidden', 500, 500, false)]) }) + ); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + const target = overviewTarget(); + + const shown = engine.previews.drawDocumentOverview(target, 150); + + expect(shown).toEqual({ height: 150, width: 300, x: -200, y: -50 }); + expect({ height: target.height, width: target.width }).toEqual({ height: 75, width: 150 }); + // Half scale, shifted so document (-200,-50) lands on target pixel (0,0). + expect(composite.mock.calls.at(-1)?.[3]).toEqual({ a: 0.5, b: 0, c: 0, d: 0.5, e: 100, f: 25 }); + composite.mockRestore(); + engine.lifecycle.dispose(); + }); +}); + // ---- transform session: param commit (image) + pixel bake (paint) ---------- +describe('masks while a result is staged', () => { + it('hides masks and region tints for the whole staging session, redrawing in full when it starts and ends', () => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + const composite = vi.spyOn(compositor, 'compositeDocument'); + let canvas = makeCanvas({ ...makeDoc(), stacks: stacksFrom([maskLayer('m'), rasterLayer('a')]) }); + const listeners = new Set<() => void>(); + const store: EngineStore = { + dispatch: vi.fn(), + getState: () => ({ activeProjectId: 'p1', projects: [{ canvas, id: 'p1' }] }) as unknown as WorkbenchState, + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; + const setCandidates = (pendingImages: CanvasStagingCandidateContract[]): void => { + canvas = { ...canvas, stagingArea: { ...canvas.stagingArea, isVisible: false, pendingImages } }; + for (const listener of listeners) { + listener(); + } + }; + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.resolve(new Blob()), + projectId: 'p1', + store, + }); + engine.surface.attach(createInputCanvas().element, createInputCanvas().element); + const frame = () => { + composite.mockClear(); + raf.flush(); + const options = composite.mock.calls.at(-1)?.[4]; + return { + damage: options?.damage?.kind, + drawn: options?.preparation?.leaves.map((leaf) => leaf.id), + regionOverlays: options?.regionOverlays, + }; + }; + expect(frame()).toEqual({ damage: 'full', drawn: ['a', 'm'], regionOverlays: true }); + + // A candidate whose preview is toggled off still hides the masks. + setCandidates([ + { + height: 20, + imageName: 'staged.png', + imageUrl: '/staged.png', + placement: { height: 20, opacity: 1, width: 20, x: 0, y: 0 }, + queuedAt: '2026-07-16T00:00:00.000Z', + sourceQueueItemId: 'queue', + thumbnailUrl: '/staged-thumb.png', + width: 20, + }, + ]); + expect(frame()).toEqual({ damage: 'full', drawn: ['a'], regionOverlays: false }); + + setCandidates([]); + expect(frame()).toEqual({ damage: 'full', drawn: ['a', 'm'], regionOverlays: true }); + composite.mockRestore(); + engine.lifecycle.dispose(); + }); +}); + +describe('beginTransform', () => { + const setup = (layers: CanvasLayerContract[], selectedLayerId: string | null) => { + const { dispatch, projectId, store } = createReducerBackedStore({ + background: 'transparent', + bbox: { height: 100, width: 100, x: 0, y: 0 }, + height: 100, + selectedLayerId, + stacks: stacksFrom(layers), + version: 3, + width: 100, + }); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + bitmapStore: createSpyBitmapStore(), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + return { dispatch, engine }; + }; + + it('refuses as busy while interaction is locked, leaving the selection and tool unchanged', () => { + const { dispatch, engine } = setup([rasterLayer('a'), rasterLayer('b')], 'a'); + engine.tools.setInteractionLocked(true); + // The lock itself parks the surface on the view tool. + const lockedTool = engine.stores.activeTool.get(); + dispatch.mockClear(); + + expect(engine.layers.beginTransform('b')).toBe('busy'); + expect(dispatch).not.toHaveBeenCalled(); + expect(engine.document.getDocument()?.selectedLayerId).toBe('a'); + expect(engine.stores.activeTool.get()).toBe(lockedTool); + expect(engine.stores.transformSession.get()).toBeNull(); + engine.lifecycle.dispose(); + }); + + it('selects the layer and opens its session now, even with transform already active and no session', () => { + const { engine } = setup([rasterLayer('a'), rasterLayer('b')], null); + engine.tools.setTool('transform'); + expect(engine.stores.transformSession.get()).toBeNull(); + + expect(engine.layers.beginTransform('b')).toBe('started'); + expect(engine.document.getDocument()?.selectedLayerId).toBe('b'); + expect(engine.stores.activeTool.get()).toBe('transform'); + expect(engine.stores.transformSession.get()?.layerId).toBe('b'); + engine.lifecycle.dispose(); + }); + + /** + * Paints a stroke onto a new layer (or an empty paint layer that already exists), then asks for its transform + * before persistence acknowledges the pixels. + */ + const strokeThenTransform = (options: { existingLayer?: boolean; uploadImage?: () => Promise } = {}) => { + const raf = createControllableRaf(); + vi.stubGlobal('requestAnimationFrame', raf.requestFrame); + vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); + vi.stubGlobal( + 'Path2D', + class FakePath2D { + closePath() {} + lineTo() {} + moveTo() {} + quadraticCurveTo() {} + } + ); + // The window key listeners attach adds, so a test can hold Space as the browser would deliver it. + const windowListeners = new Map void>(); + vi.stubGlobal('addEventListener', (type: string, listener: (event: unknown) => void) => + windowListeners.set(type, listener) + ); + vi.stubGlobal('removeEventListener', (type: string) => windowListeners.delete(type)); + const existing: CanvasLayerContract = { + ...(rasterLayer('paint') as CanvasRasterLayerContractV2), + source: { bitmap: null, type: 'paint' }, + }; + const { dispatch, projectId, store } = createReducerBackedStore({ + ...makeDoc(), + selectedLayerId: options.existingLayer ? existing.id : null, + stacks: stacksFrom(options.existingLayer ? [existing] : []), + }); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + ...(options.uploadImage ? { uploadImage: options.uploadImage } : { bitmapStore: createSpyBitmapStore() }), + imageResolver: () => Promise.resolve(new Blob()), + projectId, + store, + }); + const overlay = createInputCanvas(); + engine.surface.attach(createInputCanvas().element, overlay.element); + const strokes: StrokeCommittedEvent[] = []; + engine.tools.onStrokeCommitted((event) => strokes.push(event)); + const refusals: CanvasEditRefusal[] = []; + engine.tools.onEditRefused((refusal) => refusals.push(refusal)); + engine.tools.setTool('brush'); + overlay.fire('pointerenter', pointerAt(20, 20)); + overlay.fire('pointerdown', pointerAt(20, 20)); + overlay.fire('pointermove', pointerAt(40, 40)); + overlay.fire('pointerup', pointerAt(40, 40, { buttons: 0 })); + const stroke = strokes[0]!; + // Its pixels live only in the cache until persistence acknowledges the bitmap. + expect(engine.layers.beginTransform(stroke.layerId)).toBe('started'); + expect(engine.stores.activeTool.get()).toBe('transform'); + expect(engine.stores.transformSession.get()).toBeNull(); + expect(engine.interaction.get('pendingTransformLayerId')).toBe(stroke.layerId); + const key = (type: 'keydown' | 'keyup', code: string) => + windowListeners.get(type)?.({ code, key: code === 'Space' ? ' ' : code, preventDefault: () => {}, target: null }); + const persist = () => + dispatch({ + id: stroke.layerId, + source: { + bitmap: { height: stroke.dirtyRect.height, imageName: 'stroke.png', width: stroke.dirtyRect.width }, + offset: { x: stroke.dirtyRect.x, y: stroke.dirtyRect.y }, + type: 'paint', + }, + type: 'updateCanvasLayerSource', + }); + const sessionLayerId = () => engine.stores.transformSession.get()?.layerId ?? null; + return { engine, key, layerId: stroke.layerId, persist, refusals, sessionLayerId }; + }; + + it('opens a transform requested right after a stroke once its pixels are persisted', () => { + const h = strokeThenTransform(); + h.persist(); + expect(h.sessionLayerId()).toBe(h.layerId); + expect(h.engine.interaction.get('pendingTransformLayerId')).toBeNull(); + h.engine.lifecycle.dispose(); + }); + + it('keeps waiting through a Space pan held meanwhile, opening when the pixels land under the hold', () => { + const h = strokeThenTransform(); + h.key('keydown', 'Space'); + expect(h.engine.stores.activeTool.get()).toBe('view'); + h.persist(); + expect(h.sessionLayerId()).toBe(h.layerId); + h.key('keyup', 'Space'); + expect(h.engine.stores.activeTool.get()).toBe('transform'); + expect(h.sessionLayerId()).toBe(h.layerId); + h.engine.lifecycle.dispose(); + }); + + it.each([ + ['a tool switch', (engine: CanvasEngine) => engine.tools.setTool('brush')], + ['Escape', (engine: CanvasEngine) => engine.tools.handleEscapePriority({ gestureWasActive: false })], + ])('stops waiting on %s', (_, end) => { + const h = strokeThenTransform(); + end(h.engine); + expect(h.engine.interaction.get('pendingTransformLayerId')).toBeNull(); + h.persist(); + expect(h.sessionLayerId()).toBeNull(); + h.engine.lifecycle.dispose(); + }); + + it('stops waiting on an undo, whose replay lands on a document the request never saw', async () => { + // The layer survives the undo, still selected under transform, so only the replay itself ends the wait. + const h = strokeThenTransform({ existingLayer: true }); + expect(h.layerId).toBe('paint'); + await h.engine.history.undo(); + expect(h.engine.interaction.get('pendingTransformLayerId')).toBeNull(); + h.persist(); + expect(h.sessionLayerId()).toBeNull(); + h.engine.lifecycle.dispose(); + }); + + it('keeps waiting through retried upload failures, and stops with a refusal once no retry follows', async () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + try { + const h = strokeThenTransform({ uploadImage: () => Promise.reject(new Error('offline')) }); + // Advances the fake clock, then yields to real I/O: hashing the encoded pixels settles outside the clock. + const advance = async (ms: number) => { + await vi.advanceTimersByTimeAsync(ms); + await new Promise((resolve) => { + const channel = new MessageChannel(); + channel.port1.onmessage = () => { + channel.port1.close(); + resolve(); + }; + channel.port2.postMessage(null); + }); + }; + // One barrier: every upload attempt fails, its retry delays elapse on the fake clock. + const failFlush = async () => { + const flush = { settled: false }; + void h.engine.lifecycle + .flushPendingUploads() + .catch(() => undefined) + .then(() => { + flush.settled = true; + }); + for (let step = 0; step < 200 && !flush.settled; step += 1) { + await advance(250); + } + expect(flush.settled).toBe(true); + }; + + await failFlush(); + expect(h.engine.interaction.get('pendingTransformLayerId')).toBe(h.layerId); + expect(h.refusals).toEqual([]); + + // The store retries on its own backoff until its consecutive-failure limit stops it. + for (let step = 0; step < 300 && h.engine.interaction.get('pendingTransformLayerId'); step += 1) { + await advance(1000); + } + expect(h.engine.interaction.get('pendingTransformLayerId')).toBeNull(); + expect(h.refusals).toEqual(['not-ready']); + h.engine.lifecycle.dispose(); + } finally { + vi.useRealTimers(); + } + }); +}); + describe('transform session', () => { it('image layer Apply commits one structural transform with the exact inverse', async () => { const { store } = createReactiveStore(selectedImageDoc()); @@ -19078,6 +20066,7 @@ describe('layer quick-switch memory', () => { hasSelectedStagedCandidate: false, isInteractionLocked: false, copySelection: vi.fn(), + cropAllLayers: vi.fn(), swapActiveColors: vi.fn(), resetActiveColors: vi.fn(), pasteFromClipboard: vi.fn(), @@ -19085,6 +20074,7 @@ describe('layer quick-switch memory', () => { reportStructuralCommit: vi.fn(), reportPreparedCommit: vi.fn(), reportLayerOperation: vi.fn(), + reportTransformRefusal: vi.fn(), t: (key) => key, }); }; @@ -19164,7 +20154,7 @@ describe('layer quick-switch memory', () => { vi.stubGlobal('cancelAnimationFrame', raf.cancelFrame); const document = { ...makeDoc(), - stacks: stacksFrom([rasterLayer('a'), rasterLayer('b'), rasterLayer('c')]), + stacks: stacksFrom([rasterLayer('a'), textRasterLayer('b'), rasterLayer('c')]), selectedLayerId: 'a', }; const { setDocument, store } = createReactiveStore(document); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.ts index 45f0f25150f..d661663b5fe 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.ts @@ -16,17 +16,20 @@ import type { CanvasDocumentSnapshot, CanvasLifecycleCapability, CanvasSelectionCapability, + CanvasSurfaceAttachOptions, CanvasSurfaceCapability, CanvasViewportCapability, BooleanRasterOperation, BooleanRasterResult, CopyLayerResult, + CropAllLayersResult, CropLayerResult, ExportBakedLayerPixelsOptions, ExportLayerPixelsOptions, ExtractMaskedAreaResult, MaskEditResult, MergeDownResult, + MergeInpaintMasksResult, MergeVisibleResult, NewRasterLayerResult, PsdExportResult, @@ -80,6 +83,7 @@ import type { CanvasLayerContract, CanvasLayerSourceContract, } from '@workbench/canvas-engine/contracts'; +import type { LayerCacheReadiness } from '@workbench/canvas-engine/controllers/editSteps'; import type { CreatePath2D } from '@workbench/canvas-engine/freehand'; import type { ColorSampleProviders, ColorSampler } from '@workbench/canvas-engine/render/colorSample'; import type { AdjustmentRefusal } from '@workbench/canvas-engine/render/exactAdjustments'; @@ -95,6 +99,7 @@ import type { CanvasProjectMutationPort } from '@workbench/canvasProjectMutation import { createItemId } from '@platform/browser/randomUuid'; import { areJsonValuesStructurallyEqual } from '@platform/core/json'; +import { ColorSampleController } from '@workbench/canvas-engine/controllers/colorSampleController'; import { ControlDerivationController } from '@workbench/canvas-engine/controllers/controlDerivationController'; import { PixelEditController } from '@workbench/canvas-engine/controllers/controlPixelController'; import { ControlViewController } from '@workbench/canvas-engine/controllers/controlViewController'; @@ -114,6 +119,7 @@ import { createCanvasMutationContext, type CanvasMutationContext, } from '@workbench/canvas-engine/controllers/mutationContext'; +import { PaintLineEndController } from '@workbench/canvas-engine/controllers/paintLineEndController'; import { PersistenceController } from '@workbench/canvas-engine/controllers/persistenceController'; import { PsdExportController } from '@workbench/canvas-engine/controllers/psdExportController'; import { RasterController } from '@workbench/canvas-engine/controllers/rasterController'; @@ -165,7 +171,11 @@ import { createMaskPatternTile } from '@workbench/canvas-engine/render/maskFill' import { colorLoupePixels, renderOverlay } from '@workbench/canvas-engine/render/overlayRenderer'; import { trimPaintCacheToAlpha } from '@workbench/canvas-engine/render/paintCacheTrim'; import { createDomRasterBackend, type RasterBackend, type RasterSurface } from '@workbench/canvas-engine/render/raster'; -import { RasterCompositeOverBudgetError } from '@workbench/canvas-engine/render/rasterComposite'; +import { + RasterCompositeNotReadyError, + RasterCompositeOverBudgetError, + RasterCompositeUnreadableError, +} from '@workbench/canvas-engine/render/rasterComposite'; import { rasterizeSource, type ImageResolver, type RasterizeDeps } from '@workbench/canvas-engine/render/rasterizers'; import { fitThumbnailSize, getLayerThumbnailDisplayKey } from '@workbench/canvas-engine/render/thumbnail'; import { documentDeltaToLocal, liftSelectedPixels } from '@workbench/canvas-engine/selection/floatingSelection'; @@ -189,6 +199,7 @@ import { FULL_DAMAGE } from '@workbench/canvas-engine/types'; import { createViewport, type Viewport } from '@workbench/canvas-engine/viewport'; import type { ImagePatchApply } from './history/imagePatch'; +import type { LayerUnreadable } from './layerExportGuards'; import type { CanvasProjectMutation } from './mutationContracts'; import type { StrokeCommittedEvent, Tool, ToolContext } from './tools/tool'; @@ -197,7 +208,7 @@ import { createDocumentMirror, type DocumentMirror } from './document/documentMi import { decideLayerChange, nextThumbnailDisplayToken } from './document/layerChangeDecision'; import { getSourceBounds, getSourceContentRect, isRenderableLayer, renderableSourceOf } from './document/sources'; import { collectCanvasFontReferences, replaceCanvasFontReferences } from './fontReferences'; -import { createLayerExportGuards, isSupportedExportSource } from './layerExportGuards'; +import { createLayerExportGuards, isSupportedExportSource, layerUnreadable } from './layerExportGuards'; import { createLayerRasterizer } from './layerRasterizer'; import { createPreviewPublisher } from './previewPublisher'; import { createRasterSnapshotCapture } from './rasterSnapshotCapture'; @@ -404,6 +415,7 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC textEditSession: stores.textEditSession, textOptions: stores.textOptions, transformSession: stores.transformSession, + pendingTransformLayerId: stores.pendingTransformLayerId, viewportReady: stores.viewportReady, zoom: stores.zoom, }; @@ -635,14 +647,19 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC } return { offset: { x: entry.rect.x, y: entry.rect.y }, surface: entry.surface }; }, - onError: (error, layerId, info) => + onError: (error, layerId, info) => { + // A retry may still land the pixels; only a failure no retry follows ends the wait. + if (!info.willRetry) { + editingController.transform.onUploadFailed(layerId); + } reportError( info.willRetry ? 'Bitmap persistence failed' : 'Bitmap persistence suspended', layerId, // Lead circuit-opening feedback with persistence stopping until a fresh stroke, rather than repeating // the underlying error. info.willRetry ? error : new Error('Canvas changes are no longer uploading. A new stroke will retry.') - ), + ); + }, uploadImage: (blob) => opts.uploadImage(blob), }); const persistenceController = new PersistenceController(bitmapStore); @@ -652,6 +669,8 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC // replaying over either would land on state the entry never saw. Put both back first; neither is itself // undoable until it commits. beforeReplay: () => { + paintLineEnds.clear(); + editingController.transform.onReplay(); floatingSelection.cancel(); structuralController.endPreview(); }, @@ -1002,12 +1021,20 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC canEdit: () => canEditDocument(), commitStructural: (label, forward, inverse) => commitStructural(label, forward, inverse), ctx: mutationContext, + enterTool: () => setTool('transform'), getCache: (layerId) => layerCache.get(layerId) ?? null, getDocument: () => mirror.getDocument(), + getUserToolId: () => pipeline.heldRestoreToolId() ?? interactionController.getActiveToolId(), + hasFloatingSelection: () => floatingSelection.has(), invalidate: (payload) => scheduler.invalidate(payload), isGestureActive: () => pipeline.isGestureActive(), + isInteractionLocked: () => interactionLocked, + peekCacheRect: (layerId) => layerCache.peek(layerId)?.rect, + pending: stores.pendingTransformLayerId, reportRefusal: reportEditRefusal, + rebaseLineEnd: (layerId, from) => paintLineEnds.rebase(layerId, from), restoreCache: restoreLayerCache, + select: (layerId) => dispatchCanvasMutation({ id: layerId, type: 'setCanvasSelectedLayer' }), session: stores.transformSession, setOverride: (layerId, transform) => { if (transform) { @@ -1072,17 +1099,16 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC } } - /** - * One-shot color claims stash samples during press/drag and settle on release, after structural edits are - * allowed. Escape, tool change and teardown resolve null. - */ - let pendingColorSample: { - previousToolId: ToolId; - resolve: (hex: string | null) => void; - sampledHex: string | null; - } | null = null; - // Where unclaimed eyedropper samples land while a workbench is attached. - let colorSampleRouter: ((hex: string) => boolean) | null = null; + const colorSamples = new ColorSampleController({ + endHeldTool: () => pipeline.endForToolSwitch(), + getActiveToolId: () => interactionController.getActiveToolId(), + setTool: (toolId) => interactionController.setTool(toolId), + }); + // Shift-click line starts, shared by brush and eraser for the engine's lifetime; forgotten on replay and document + // replacement, and followed through every change of their layer. + const paintLineEnds = new PaintLineEndController({ + getLayer: (layerId) => getDocumentLayer(mirror.getDocument(), layerId), + }); const toolContext: ToolContext = { applyTransform: () => applyTransform(), @@ -1139,24 +1165,9 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC getSamInteraction: () => stores.samInteraction.get(), openTextCreate: (docPoint) => openTextCreate(docPoint), openTextEdit: (layerId) => openTextEdit(layerId), + paintLineEnds, sampleProviders: colorSampleProviders, - resolveColorSample: (hex) => { - if (pendingColorSample) { - pendingColorSample.sampledHex = hex; - return true; - } - return colorSampleRouter?.(hex) ?? false; - }, - commitColorSample: () => { - if (pendingColorSample !== null && pendingColorSample.sampledHex !== null) { - settleColorSample(pendingColorSample.sampledHex, true); - } - }, - discardColorSample: () => { - if (pendingColorSample !== null) { - pendingColorSample.sampledHex = null; - } - }, + resolveColorSample: (hex, phase) => colorSamples.route(hex, phase), setLayerTransformOverride: (layerId, override) => { if (override) { transformOverrides.set(layerId, override); @@ -1258,7 +1269,12 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC projectId, }); - const { getOrStartLayerRasterization } = createLayerRasterizer({ + const { + dispose: disposeLayerRasterizer, + getOrStartLayerRasterization, + getFailure: getRasterizationFailure, + isBackingOff: isRasterizationBackingOff, + } = createLayerRasterizer({ createSurface: (width, height) => backend.createSurface(width, height), fontLoader, getDocument: () => mirror.getDocument(), @@ -1358,8 +1374,11 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC const rasterizeLayerPixels = rasterExportController.rasterize.bind(rasterExportController); const prepareLayerRasterCache = async (layerId: string) => { const result = await rasterizeLayerPixels(layerId, { includeDisabled: true }); + if (result.status === 'unreadable') { + return result; + } if (result.status !== 'ok') { - return { status: result.status === 'over-budget' ? ('over-budget' as const) : ('not-ready' as const) }; + return { status: result.status === 'over-budget' ? result.status : ('not-ready' as const) }; } const layer = result.guard.layer; result.release(); @@ -1382,12 +1401,13 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC type StructuralExportLayerPixelsResult = | Extract | { status: 'missing' | 'disabled' | 'unsupported' | 'empty' | 'not-ready' | 'over-budget' } + | LayerUnreadable | AdjustmentRefusal; const normalizeStructuralExport = async ( result: Promise ): Promise => { const resolved = await result; - if (resolved.status === 'ok' || resolved.status === 'adjustment-refused') { + if (resolved.status === 'ok' || resolved.status === 'adjustment-refused' || resolved.status === 'unreadable') { return resolved; } return { status: resolved.status === 'aborted' ? 'not-ready' : resolved.status }; @@ -1405,7 +1425,7 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC document: CanvasDocumentContractV3 ): Promise<'published' | 'stale' | 'error'> => { const result = await getOrStartLayerRasterization(layer, document); - return result === 'aborted' ? 'stale' : result; + return result === 'aborted' ? 'stale' : result === 'unavailable' ? 'error' : result; }; const cropLayerToBbox = (layerId: string): Promise => layerController.crop.crop(layerId); @@ -1426,6 +1446,12 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC if (result.status === 'over-budget') { throw new RasterCompositeOverBudgetError(); } + if (result.status === 'unreadable') { + throw new RasterCompositeUnreadableError(result); + } + if (result.status === 'not-ready') { + throw new RasterCompositeNotReadyError(layerId); + } throw new Error(`Cannot rasterize layer ${layerId} for export: ${result.status}.`); }; @@ -1495,6 +1521,9 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC }); const scheduler: RenderScheduler = renderController.scheduler; + /** Whether staging holds a candidate, even while its preview is toggled off; the screen then hides masks. */ + const hasStagedCandidates = (): boolean => (mutationPort.getCanvasState()?.stagingArea.pendingImages.length ?? 0) > 0; + let hadStagedCandidates = hasStagedCandidates(); const compositeFrame = createCompositeFrame({ backend, derivedSurfaceCache, @@ -1509,7 +1538,14 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC layerCache, previews: renderController.previews, raster: rasterController, - rasterizeLayer: (layer, doc) => void getOrStartLayerRasterization(layer, doc), + hasStagedCandidates, + // A source that just failed waits out its retry backoff (or a change to it) rather than failing again every frame; + // the backoff's end invalidates the layer, so the display retries on its own. + rasterizeLayer: (layer, doc) => { + if (!isRasterizationBackingOff(layer)) { + void getOrStartLayerRasterization(layer, doc); + } + }, stores, transformOverrides, viewport, @@ -1576,7 +1612,10 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC /** Actual last non-null selection, retained across deselect/reselect and updated by engine selections too. */ let lastSelectedLayerId: string | null = null; const mirror: DocumentMirror = createDocumentMirror(mutationPort, { - onDocumentChanged: () => stores.documentEpoch.set(stores.documentEpoch.get() + 1), + onDocumentChanged: () => { + editingController.transform.onDocumentChanged(); + stores.documentEpoch.set(stores.documentEpoch.get() + 1); + }, // Bbox changes need only overlay redraw unless a bbox-relative staged preview must move in the composite. // Explicitly placed candidates stay fixed. onBboxChanged: () => { @@ -1595,7 +1634,8 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC // anchors and clear active-gesture state; also clear any lingering bbox preview. cleanup.run(() => stores.bboxPreview.set(null)); cleanup.run(() => history.clear()); - cleanup.run(() => stores.transformSession.set(null)); + paintLineEnds.clear(); + cleanup.run(() => editingController.transform.onDocumentReplaced()); cleanup.run(() => transformOverrides.clear()); // A text-edit session likewise belongs to the outgoing document; drop it. cleanup.run(() => stores.textEditSession.set(null)); @@ -1673,6 +1713,7 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC const session = stores.transformSession.get(); const textSession = stores.textEditSession.get(); for (const id of ids) { + paintLineEnds.onLayerChanged(id); const decision = decideLayerChange({ currentThumbnailKey: rasterController.getThumbnailKey(id), currentThumbnailVersion: stores.thumbnailVersion.get(id), @@ -1751,7 +1792,12 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC cleanup.run(() => scheduler.invalidate({ overlay: true })); cleanup.throwIfFailed(); }, - onStagingChanged: () => scheduler.invalidate({ overlay: true }), + // Gaining the first candidate or losing the last shows or hides masks across the whole screen. + onStagingChanged: () => { + const staged = hasStagedCandidates(); + scheduler.invalidate(staged === hadStagedCandidates ? { overlay: true } : { all: true }); + hadStagedCandidates = staged; + }, }); // The mirror seeds silently from the current state, so the memory starts from that selection too. lastSelectedLayerId = mirror.getDocument()?.selectedLayerId ?? null; @@ -1890,6 +1936,7 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC // finished the move. if (!switchOptions?.temporary) { floatingSelection.commit(); + editingController.transform.onToolSwitch(to); } for (const listener of toolChangeListeners) { listener({ from, temporary: switchOptions?.temporary === true, to }); @@ -1904,53 +1951,18 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC stepBrushSize: stepActiveBrushSize, updateCursor, }); - /** - * Settle a pending sample, optionally restoring the previous tool. Tool-switch callers skip restoration so their - * requested switch wins. - */ - const settleColorSample = (hex: string | null, restoreTool: boolean): void => { - const pending = pendingColorSample; - if (!pending) { - return; - } - pendingColorSample = null; - pending.resolve(hex); - if (restoreTool) { - interactionController.setTool(pending.previousToolId); - } - }; - const setTool = (toolId: ToolId, options?: { temporary?: boolean }): void => { - // Any move off the eyedropper abandons the sample the caller is awaiting. - if (toolId !== 'colorPicker') { - settleColorSample(null, false); - } + colorSamples.onToolChange(toolId); interactionController.setTool(toolId, options); }; - /** Samples the document composite and resolves hex on release, after the gesture, or null on cancellation. */ - const requestColorSample = (): Promise => { - // A second request supersedes the first; the earlier caller gets a cancel. - settleColorSample(null, false); - // End any held temporary tool first, so the request records the tool the user was really on. - pipeline.endForToolSwitch(); - - return new Promise((resolve) => { - pendingColorSample = { previousToolId: interactionController.getActiveToolId(), resolve, sampledHex: null }; - interactionController.setTool('colorPicker'); - }); - }; - /** * After gesture cancellation, Escape cancels defocused text editing, then transform, then selection. Focused * portals handle Escape themselves. If a gesture consumed Escape, preserve committed selection. Shared with * pipeline wiring and node tests. */ const handleEscapePriority = ({ gestureWasActive }: { gestureWasActive: boolean }): void => { - // An armed eyedropper is the most recent thing the user opted into, so it - // is the first thing Escape takes back. - if (pendingColorSample) { - settleColorSample(null, true); + if (colorSamples.onEscape()) { return; } if (stores.textEditSession.get()) { @@ -1961,6 +1973,9 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC cancelTransform(); return; } + if (editingController.transform.onEscape()) { + return; + } if (floatingSelection.has()) { // Escape restores lifted pixels while retaining selection; Enter, deselect and tool switches commit the // float. @@ -1980,6 +1995,7 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC getActiveToolId: () => interactionController.getActiveToolId(), getInputElement: () => renderController.getInputElement(), getKeyboardRoot: () => renderController.getKeyboardRoot(), + getKeyOwnerResolver: () => renderController.getKeyOwnerResolver(), getToolContext: () => toolContext, handleEscape: handleEscapePriority, hasTool: (id) => tools.has(id), @@ -2056,8 +2072,9 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC const attach = ( screenCanvas: HTMLCanvasElement, overlayCanvas: HTMLCanvasElement, - keyboardRoot?: HTMLElement - ): void => renderController.attach(screenCanvas, overlayCanvas, keyboardRoot); + keyboardRoot?: HTMLElement, + options?: CanvasSurfaceAttachOptions + ): void => renderController.attach(screenCanvas, overlayCanvas, keyboardRoot, options); const detach = (): void => renderController.detach(); const activate = (): void => { @@ -2136,19 +2153,26 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC let hasEverFitToView = false; + /** + * The document's navigable extent: the bbox (the empty-canvas anchor) joined with every enabled renderable + * layer's source bounds. Fit-to-view and the overview share it so both show the same canvas. + */ + const getDocumentExtent = (doc: CanvasDocumentContractV3): Rect => { + let extent: Rect = { ...doc.bbox }; + for (const leaf of compileDocumentLeaves(doc)) { + if (leaf.contributionEnabled && isRenderableLayer(leaf.layer)) { + extent = union(extent, getSourceBounds(leaf.layer, doc)); + } + } + return extent; + }; + const fitToView = (): void => { const doc = mirror.getDocument(); if (!doc) { return; } - // Fit content union bbox, using the bbox as the empty-canvas anchor and including content beyond it. - let bounds: Rect = { ...doc.bbox }; - for (const leaf of compileDocumentLeaves(doc)) { - if (leaf.contributionEnabled && isRenderableLayer(leaf.layer)) { - bounds = union(bounds, getSourceBounds(leaf.layer, doc)); - } - } - viewport.fitToView(bounds, viewport.getViewportSize()); + viewport.fitToView(getDocumentExtent(doc), viewport.getViewportSize()); hasEverFitToView = true; }; @@ -2171,6 +2195,27 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC const entry = layerCache.get(layer.id); return !!entry && !entry.stale && !isCurrentRasterizationJob(layer); }; + /** + * Unreadable while an `unreadable` failure is recorded for the layer's current source, `not-ready` while it loads or + * its image could not be fetched for now. The image is tried again in the background (a layer off screen or disabled + * is never redrawn to do so), so one that became available recovers: at once after an `unavailable` failure, since + * the user asking again is the moment a recovered server should be noticed, and only once the backoff has ended + * after an `unreadable` one. A retry that fails again only restarts the backoff; a running job is joined, not + * duplicated. + */ + const layerCacheReadinessForOp = (layer: CanvasLayerContract, doc: CanvasDocumentContractV3): LayerCacheReadiness => { + if (isLayerCacheReadyForOp(layer, doc)) { + return 'ready'; + } + const failure = isCurrentRasterizationJob(layer) ? null : getRasterizationFailure(layer); + if (!failure) { + return 'not-ready'; + } + if (failure === 'unavailable' || !isRasterizationBackingOff(layer)) { + scheduleLayerRasterization([layer.id]); + } + return failure === 'unreadable' ? layerUnreadable(doc, layer.id) : 'not-ready'; + }; const prepareGeneratedPaintCache = (layerId: string, rect: Rect, pixels: RasterSurface) => layerCache.prepareReplacement(layerId, rect, pixels); @@ -2420,6 +2465,9 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC const mergeSelectedRasterLayers = (layerIds: readonly string[]): Promise => layerController.merge.mergeSelected(layerIds); const mergeVisibleRasterLayers = (): Promise => layerController.merge.mergeVisible(); + const mergeInpaintMasks = (layerIds: readonly string[]): Promise => + layerController.merge.mergeInpaintMasks(layerIds); + const cropAllLayersToBbox = (): Promise => layerController.crop.cropAll(); const { captureDocumentSnapshot, captureRasterSnapshot, isDocumentSnapshotCurrent, releaseActiveSnapshots } = createRasterSnapshotCapture({ @@ -2561,6 +2609,8 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC return; } disposed = true; + paintLineEnds.dispose(); + disposeLayerRasterizer(); releaseActiveSnapshots(); rasterController.memory.releaseGeneration(lifecycleGeneration); lifecycleGeneration += 1; @@ -2585,7 +2635,7 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC cleanup.run(() => stores.textEditSession.set(null)); cleanup.run(() => antsAnimator.stop()); // No render loop left to sample with; release any awaited eyedropper. - cleanup.run(() => settleColorSample(null, false)); + cleanup.run(() => colorSamples.dispose()); cleanup.run(() => activeTool()?.onDeactivate?.(toolContext)); cleanup.run(unsubscribeViewport); cleanup.run(unsubscribeBrushOptions); @@ -2758,7 +2808,7 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC backend, ctx: mutationContext, exportBaked: (layerId) => exportBakedLayerPixelsForStructural(layerId), - isCacheReady: isLayerCacheReadyForOp, + cacheReadiness: layerCacheReadinessForOp, isGuardCurrent: isLayerExportGuardCurrent, }, crop: { @@ -2766,7 +2816,10 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC captureCache: captureLayerCache, ctx: mutationContext, discardPersisted: (layerId) => bitmapStore.discardLayer(layerId), - exportBaked: (layerId) => exportBakedLayerPixelsForStructural(layerId, { includeDisabled: true }), + exportBaked: (layerId, options) => + exportBakedLayerPixelsForStructural(layerId, { ...options, includeDisabled: true }), + // Held pixels belong to an open edit; crop all must not wait for that edit to end. + flushPendingPixels: () => persistenceController.flush({ waitForHeldPixels: false }), isGuardCurrent: isLayerExportGuardCurrent, isSupportedSource: isSupportedExportSource, }, @@ -2783,7 +2836,7 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC ctx: mutationContext, exportBaked: (layerId, includeDisabled) => exportBakedLayerPixelsForStructural(layerId, { includeDisabled }), hasExportableContent: hasExportableLayerContent, - isCacheReady: isLayerCacheReadyForOp, + cacheReadiness: layerCacheReadinessForOp, isGuardCurrent: isLayerExportGuardCurrent, layers: layerCache, peekAdjustedCacheSurface: (layerId, surface, adjustments) => @@ -2814,7 +2867,7 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC ctx: mutationContext, exportBaked: (layerId) => exportBakedLayerPixelsForStructural(layerId), hasExportableContent: hasExportableLayerContent, - isCacheReady: isLayerCacheReadyForOp, + cacheReadiness: layerCacheReadinessForOp, layers: layerCache, needsPixelPersistence: layerNeedsPixelPersistence, peekAdjustedSurface: (layer, entry) => rasterController.peekAdjustedSurface(layer, entry), @@ -2914,21 +2967,14 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC handleEscapePriority, onEditRefused, onStrokeCommitted, - requestColorSample, - setColorSampleRouter: (router) => { - colorSampleRouter = router; - return () => { - // Compare-and-clear: a later installer must not be evicted by an earlier one's cleanup. - if (colorSampleRouter === router) { - colorSampleRouter = null; - } - }; - }, + requestColorSample: () => colorSamples.requestSample(), + setColorSampleRouter: (router) => colorSamples.setRouter(router), setInteractionLocked, }; const layersCapability: CanvasEngineLayerCapability = { ...layerController.layers, applyTransform, + beginTransform: (layerId) => editingController.transform.request(layerId), booleanMergeRasterLayers, cancelTextEdit, cancelTransform, @@ -2943,8 +2989,10 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC convertLayerToControl, copyLayerToControl, copyLayerToRaster, + cropAllLayersToBbox, cropLayerToBbox, duplicateLayers, + mergeInpaintMasks, mergeLayerDown, mergeSelectedRasterLayers, mergeVisibleRasterLayers, @@ -2961,7 +3009,11 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC const drawDocumentOverview = (target: HTMLCanvasElement, maxSizePx: number): Rect | null => { const doc = mirror.getDocument(); const ctx = target.getContext('2d'); - if (!doc || !ctx || doc.width <= 0 || doc.height <= 0) { + if (!doc || !ctx) { + return null; + } + const extent = getDocumentExtent(doc); + if (isEmpty(extent)) { return null; } // Overview requests missing offscreen caches and repaints after publication. Attempt each layer once per @@ -2984,17 +3036,18 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC if (missing.length > 0) { scheduleLayerRasterization(missing); } - const size = fitThumbnailSize(doc.width, doc.height, maxSizePx); + const size = fitThumbnailSize(extent.width, extent.height, maxSizePx); target.width = size.width; target.height = size.height; - const scale = size.width / doc.width; + const scale = size.width / extent.width; // Only `ctx`/`width`/`height` are read by the compositor; the element is not resized through the surface seam. const surfaceLike = { canvas: target, ctx, height: size.height, width: size.width } as unknown as RasterSurface; compositeDocument( surfaceLike, doc, layerCache, - { a: scale, b: 0, c: 0, d: scale, e: 0, f: 0 }, + // Translate so the extent's origin lands on the target's top-left pixel. + { a: scale, b: 0, c: 0, d: scale, e: -extent.x * scale, f: -extent.y * scale }, { adjustedSurface: getAdjustedSurface, backend, @@ -3006,7 +3059,7 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC regionOverlays: true, } ); - return { height: doc.height, width: doc.width, x: 0, y: 0 }; + return extent; }; const previewCapability: CanvasEnginePreviewCapability = { ...layerController.previews, diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/engineStores.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/engineStores.ts index 7943b0056ea..fa2b7d00781 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/engineStores.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/engineStores.ts @@ -518,6 +518,8 @@ export interface EngineStores { bboxPreview: ScalarStore; /** Transform session supplies numeric controls, Apply/Cancel and live preview; null when closed. */ transformSession: ScalarStore; + /** The layer a requested transform session waits on until its fresh pixels are persisted; null when none waits. */ + pendingTransformLayerId: ScalarStore; /** Checkerboard defaults on; disabling reveals the widget surface through transparent document pixels. */ checkerboard: ScalarStore; /** @@ -742,6 +744,7 @@ export const createEngineStores = (initialTool: ToolId = 'view'): EngineStores = thumbnailStatus: createKeyedStatusStore(), adjustmentStatus: createKeyedStatusStore(), transformSession: createScalarStore(null, transformSessionEqual), + pendingTransformLayerId: createScalarStore(null), viewportReady: createScalarStore(false), zoom: createScalarStore(1), }); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/exportRasterComposite.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/exportRasterComposite.ts index da754b1d603..9d83f1deda9 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/exportRasterComposite.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/exportRasterComposite.ts @@ -1,4 +1,5 @@ import type { CanvasDocumentContractV3 } from '@workbench/canvas-engine/contracts'; +import type { LayerUnreadable } from '@workbench/canvas-engine/layerExportGuards'; import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; import type { Rect } from '@workbench/canvas-engine/types'; @@ -12,7 +13,9 @@ import { compositeScratchPixels, getBaseRasterContentBounds, planBaseRasterComposite, + RasterCompositeNotReadyError, RasterCompositeOverBudgetError, + RasterCompositeUnreadableError, renderRasterComposite, type RenderRasterCompositeDeps, } from '@workbench/canvas-engine/render/rasterComposite'; @@ -22,6 +25,7 @@ export type RasterCompositeExportRequest = { bounds: 'content' } | { bounds: 're export type RasterCompositeExportResult = | { status: 'ok'; blob: Blob; rect: Rect } | { status: 'empty' | 'stale' | 'not-ready' | 'over-budget' } + | LayerUnreadable | AdjustmentRefusal; export interface RasterCompositeExportSnapshot { @@ -75,6 +79,9 @@ export const exportRasterComposite = async ( if (error instanceof RasterCompositeOverBudgetError) { return { status: 'over-budget' }; } + if (error instanceof RasterCompositeUnreadableError) { + return error.refusal; + } if (error instanceof AdjustmentRefusedError) { return adjustmentRefusal(document, error.ownerId, error.reason); } @@ -82,6 +89,9 @@ export const exportRasterComposite = async ( if (!deps.isSnapshotCurrent(snapshot) || (error instanceof DOMException && error.name === 'AbortError')) { return { status: 'stale' }; } + if (error instanceof RasterCompositeNotReadyError) { + return { status: 'not-ready' }; + } throw error; } if (!deps.isSnapshotCurrent(snapshot)) { diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/input/pointerPipeline.browser.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/input/pointerPipeline.browser.test.ts index bbaa031e609..62dea4a9b22 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/input/pointerPipeline.browser.test.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/input/pointerPipeline.browser.test.ts @@ -35,7 +35,7 @@ const createInputHarness = () => { const events: string[] = []; const deactivations: string[] = []; const tools = Object.fromEntries( - (['brush', 'view', 'transform', 'bbox', 'eraser'] as const).map((id): [ToolId, Tool] => [ + (['brush', 'view', 'transform', 'bbox', 'eraser', 'colorPicker'] as const).map((id): [ToolId, Tool] => [ id, { id, @@ -233,3 +233,43 @@ describe('pointer pipeline in Chromium: tool switches during a hold', () => { expect(h.interaction.getActiveToolId()).toBe('brush'); }); }); + +describe('pointer pipeline in Chromium: Alt eyedropper', () => { + it('prevents a real Alt tap on the focused canvas, keydown and keyup alike', async () => { + const h = createInputHarness(); + h.pipeline.onPointerEnter(); + h.surface.focus(); + + await userEvent.keyboard('{Alt}'); + expect(h.observed).toEqual([ + { defaultPrevented: true, type: 'keydown' }, + { defaultPrevented: true, type: 'keyup' }, + ]); + expect(h.interaction.getActiveToolId()).toBe('brush'); + }); + + it('leaves a real Alt tap in a text field to the browser', async () => { + const h = createInputHarness(); + const input = document.createElement('input'); + document.body.append(input); + cleanups.push(() => input.remove()); + h.pipeline.onPointerEnter(); + input.focus(); + + await userEvent.keyboard('{Alt}'); + expect(h.observed.map((event) => event.defaultPrevented)).toEqual([false, false]); + }); + + it('begins the picker when the pointer enters with Alt already held on the focused canvas', async () => { + const h = createInputHarness(); + h.surface.focus(); + + await userEvent.keyboard('{Alt>}'); + expect(h.interaction.getActiveToolId()).toBe('brush'); + h.pipeline.onPointerEnter(new PointerEvent('pointerenter', { altKey: true })); + expect(h.interaction.getActiveToolId()).toBe('colorPicker'); + await userEvent.keyboard('{/Alt}'); + expect(h.interaction.getActiveToolId()).toBe('brush'); + expect(h.observed.every((event) => event.defaultPrevented)).toBe(true); + }); +}); 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 index 485a22c0f1d..974e49fa082 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/input/pointerPipeline.test.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/input/pointerPipeline.test.ts @@ -1,3 +1,4 @@ +import type { CanvasKeyOwner } from '@workbench/canvas-engine/capabilities'; 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'; @@ -77,6 +78,7 @@ const createHarness = ( handleEscape?: (o: { gestureWasActive: boolean }) => void; maybeCommitModalSession?: () => boolean; replaying?: () => boolean; + resolveKeyOwner?: (event: KeyboardEvent) => CanvasKeyOwner; } = {} ) => { const registered = new Set(opts.tools ?? ['view', 'brush']); @@ -124,6 +126,7 @@ const createHarness = ( getActiveToolId: () => activeToolId, getInputElement: () => element, getKeyboardRoot: () => ({ contains: (node) => node === (SURFACE_TARGET as unknown) }), + getKeyOwnerResolver: () => opts.resolveKeyOwner ?? null, getToolContext: () => ctx, handleEscape: opts.handleEscape, hasTool: (id) => registered.has(id), @@ -532,6 +535,205 @@ describe('pointer pipeline: temporary modifier tools', () => { }); }); +describe('pointer pipeline: Alt eyedropper hold', () => { + const pickerHarness = (opts: { resolveKeyOwner?: (event: KeyboardEvent) => CanvasKeyOwner } = {}) => + createHarness({ ...opts, tools: ['view', 'brush', 'colorPicker'] }); + const altDown = (target: unknown = SURFACE_TARGET) => makeKeyEvent({ altKey: true, code: 'AltLeft', target }); + const altUp = () => makeKeyEvent({ code: 'AltLeft', target: SURFACE_TARGET }); + + it('prevents both halves of an owned Alt tap so the browser menu bar stays closed', () => { + const h = pickerHarness(); + h.pipeline.onPointerEnter(); + const down = altDown(); + const up = altUp(); + h.pipeline.onKeyDown(down); + h.pipeline.onKeyUp(up); + expect(down.preventDefault).toHaveBeenCalledOnce(); + expect(up.preventDefault).toHaveBeenCalledOnce(); + + // An unrelated release is still only observed. + const space = makeKeyEvent({ code: 'Space', target: SURFACE_TARGET }); + h.pipeline.onKeyUp(space); + expect(space.preventDefault).not.toHaveBeenCalled(); + }); + + it('leaves Alt alone in an editable field', () => { + const h = pickerHarness(); + h.pipeline.onPointerEnter(); + const editable = altDown({ tagName: 'INPUT' }); + const editableUp = makeKeyEvent({ code: 'AltLeft', target: { tagName: 'INPUT' } }); + h.pipeline.onKeyDown(editable); + h.pipeline.onKeyUp(editableUp); + expect(editable.preventDefault).not.toHaveBeenCalled(); + expect(editableUp.preventDefault).not.toHaveBeenCalled(); + }); + + it.each([ + ['hovered', true], + ['off the canvas', false], + ])( + 'still prevents an owned Alt %s for a tool that uses it as a modifier, but starts and arms no picker', + (_, hovered) => { + const h = pickerHarness(); + (h.tool as { usesAltKey?: boolean }).usesAltKey = true; + if (hovered) { + h.pipeline.onPointerEnter(); + } + const down = altDown(); + const up = altUp(); + h.pipeline.onKeyDown(down); + h.pipeline.onPointerEnter({ altKey: true }); + h.pipeline.onKeyUp(up); + expect(down.preventDefault).toHaveBeenCalledOnce(); + expect(up.preventDefault).toHaveBeenCalledOnce(); + expect(h.setTool).not.toHaveBeenCalled(); + } + ); + + it('arms a pending hold off the canvas that a pointerenter with Alt still held begins', () => { + const h = pickerHarness(); + const down = altDown(); + h.pipeline.onKeyDown(down); + expect(down.preventDefault).toHaveBeenCalledOnce(); + expect(h.setTool).not.toHaveBeenCalled(); + + h.pipeline.onPointerEnter({ altKey: true }); + expect(h.setTool).toHaveBeenLastCalledWith('colorPicker', { temporary: true }); + const up = altUp(); + h.pipeline.onKeyUp(up); + expect(up.preventDefault).toHaveBeenCalledOnce(); + expect(h.setTool).toHaveBeenLastCalledWith('brush', { temporary: true }); + }); + + it('drops the pending hold when the pointer enters without Alt', () => { + const h = pickerHarness(); + h.pipeline.onKeyDown(altDown()); + h.pipeline.onPointerEnter({ altKey: false }); + h.pipeline.onPointerLeave(); + h.pipeline.onPointerEnter({ altKey: true }); + expect(h.setTool).not.toHaveBeenCalled(); + }); + + it.each([ + ['the Alt release', (h: ReturnType) => h.pipeline.onKeyUp(altUp())], + ['a tool switch', (h: ReturnType) => h.pipeline.endForToolSwitch()], + ['a reset', (h: ReturnType) => h.pipeline.reset()], + ])('clears the pending hold on %s', (_, clear) => { + const h = pickerHarness(); + h.pipeline.onKeyDown(altDown()); + clear(h); + h.pipeline.onPointerEnter({ altKey: true }); + expect(h.setTool).not.toHaveBeenCalled(); + }); + + it('begins the pending hold before routing a press that lands without hover, so a pen samples on contact', () => { + const h = pickerHarness(); + const order: string[] = []; + h.setTool.mockImplementation((id: ToolId) => order.push(`set:${id}`)); + h.tool.onPointerDown = () => order.push('down'); + h.pipeline.onKeyDown(altDown()); + + h.pipeline.onPointerDown(makePointerEvent({ altKey: true, pointerId: 5, pointerType: 'pen' })); + expect(order).toEqual(['set:colorPicker', 'down']); + }); + + it('arms nothing off the canvas unless the surface owns the key', () => { + const h = pickerHarness({ resolveKeyOwner: () => 'companion' }); + const down = altDown({ tagName: 'BODY' }); + h.pipeline.onKeyDown(down); + h.pipeline.onPointerEnter({ altKey: true }); + expect(down.preventDefault).not.toHaveBeenCalled(); + expect(h.setTool).not.toHaveBeenCalled(); + }); +}); + +describe('pointer pipeline: injected key owner', () => { + const ownerHarness = (owner: CanvasKeyOwner) => { + const handleEscape = vi.fn(); + const resolveKeyOwner = vi.fn((_event: KeyboardEvent) => owner); + const h = createHarness({ handleEscape, resolveKeyOwner, tools: ['view', 'brush', 'bbox', 'colorPicker'] }); + const commands: string[] = []; + h.tool.onKeyCommand = (_ctx, command) => commands.push(command); + return { ...h, commands, handleEscape, resolveKeyOwner }; + }; + const BODY = { tagName: 'BODY' }; + + it.each([ + ['surface', ['apply'], 1], + ['companion', [], 1], + ['elsewhere', [], 0], + ] as const)('with owner %s, Enter applies %j and Escape reaches the ladder %i time(s)', (owner, applied, escapes) => { + const h = ownerHarness(owner); + // Focus left on the body by a click outside the canvas no longer settles ownership by itself. + h.pipeline.onKeyDown(makeKeyEvent({ key: 'Enter', target: BODY })); + h.pipeline.onKeyDown(makeKeyEvent({ key: 'Escape', target: BODY })); + expect(h.commands).toEqual(applied); + expect(h.handleEscape).toHaveBeenCalledTimes(escapes); + }); + + it('applies Enter from the surface only while focus is on its root or nowhere, not on a clicked control', () => { + const h = ownerHarness('surface'); + const button = { ...BUTTON_TARGET, matches: () => false }; + h.pipeline.onFocusIn({ target: button } as unknown as FocusEvent); + h.pipeline.onKeyDown(makeKeyEvent({ key: 'Enter', target: button })); + h.pipeline.onKeyDown(makeKeyEvent({ key: 'Enter', target: SURFACE_TARGET })); + expect(h.commands).toEqual(['apply']); + }); + + it.each([ + ['surface', true], + ['companion', true], + ['elsewhere', false], + ] as const)('with owner %s, a quick C tap selects the frame tool: %s', (owner, selects) => { + const h = ownerHarness(owner); + h.pipeline.onKeyDown(makeKeyEvent({ code: 'KeyC', key: 'c', target: BODY })); + h.pipeline.onKeyUp(makeKeyEvent({ code: 'KeyC', key: 'c', target: BODY })); + expect(h.setTool.mock.calls.some(([id]) => id === 'bbox')).toBe(selects); + }); + + it.each(['surface', 'companion', 'elsewhere'] as const)( + 'with owner %s, Space and Alt hold over the hovered canvas', + (owner) => { + const h = ownerHarness(owner); + h.pipeline.onPointerEnter(); + const space = makeKeyEvent({ code: 'Space', target: BODY }); + h.pipeline.onKeyDown(space); + expect(h.setTool).toHaveBeenLastCalledWith('view', { temporary: true }); + expect(space.preventDefault).toHaveBeenCalledOnce(); + h.pipeline.onKeyUp(makeKeyEvent({ code: 'Space', target: BODY })); + + const alt = makeKeyEvent({ altKey: true, code: 'AltLeft', target: BODY }); + h.pipeline.onKeyDown(alt); + expect(h.setTool).toHaveBeenLastCalledWith('colorPicker', { temporary: true }); + expect(alt.preventDefault).toHaveBeenCalledOnce(); + } + ); + + it.each([ + ['companion', false], + ['elsewhere', false], + ['surface', true], + ] as const)('with owner %s and no hover, Alt is claimed: %s', (owner, claimed) => { + const h = ownerHarness(owner); + const alt = makeKeyEvent({ altKey: true, code: 'AltLeft', target: BODY }); + h.pipeline.onKeyDown(alt); + expect(alt.preventDefault).toHaveBeenCalledTimes(claimed ? 1 : 0); + h.pipeline.onKeyDown(makeKeyEvent({ code: 'Space', target: BODY })); + expect(h.setTool).not.toHaveBeenCalled(); + }); + + it('asks the judge only after its own refusals', () => { + const h = ownerHarness('surface'); + h.pipeline.onPointerEnter(); + h.pipeline.onKeyDown(makeKeyEvent({ key: 'Escape', target: { tagName: 'INPUT' } })); + h.pipeline.onKeyDown(makeKeyEvent({ code: 'Space', target: BUTTON_TARGET })); + h.pipeline.onKeyDown(makeKeyEvent({ defaultPrevented: true, key: 'Enter', target: BODY })); + h.pipeline.onPointerDown(makePointerEvent({ pointerId: 1 })); + expect(h.resolveKeyOwner).not.toHaveBeenCalled(); + expect(h.handleEscape).not.toHaveBeenCalled(); + }); +}); + describe('pointer pipeline: canvas keyboard ownership', () => { const applyHarness = () => { const h = createHarness(); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/input/pointerPipeline.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/input/pointerPipeline.ts index 7d6715f1acc..d6042453e63 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/input/pointerPipeline.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/input/pointerPipeline.ts @@ -2,12 +2,14 @@ * Normalizes DOM events into tool input with capture, coalesced samples and default mouse pressure 0.5. Middle * mouse pans independently. Space, Alt and held C temporarily select view, picker and bbox; release restores the * tool, while quick C selects bbox persistently. Temporary switches preserve sessions and are blocked mid-gesture. - * Escape/pointercancel cancel; extra buttons are ignored mid-gesture. Enter acts only while the keyboard root owns - * focus. Hold keys and the Escape ladder also act after a control was merely clicked, but never over an editable - * field, a keyboard-navigated control, an open overlay or beneath a modal dialog. Key releases are always observed and - * never consumed. DOM access is injected. + * Escape/pointercancel cancel; extra buttons are ignored mid-gesture. No key acts over an editable field, a + * keyboard-navigated control, an open overlay or beneath a modal dialog; past that, the host's key-owner judge decides: + * Enter needs the surface itself, the Escape ladder and C also accept a companion widget, and Space and Alt act over + * the hovered canvas whatever the owner. Owned Space and Alt are prevented, and so is the release of an Alt the + * pipeline acted on; other releases are observed, never consumed. DOM access is injected. */ +import type { CanvasKeyOwner } from '@workbench/canvas-engine/capabilities'; 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'; @@ -30,6 +32,12 @@ export interface PointerPipelineDeps { getInputElement(): (HTMLElement & Partial>) | null; /** The focusable element whose focus gives the canvas its session keys. */ getKeyboardRoot(): Pick | null; + /** + * The host's judge of which region owns a keydown; asked only on keydown, after the pipeline's own refusals. Absent + * (tests, detached engines), focus alone decides: the keyboard root or no focus is the surface, and a control the + * pointer last clicked lends its keys like a companion. + */ + getKeyOwnerResolver?(): ((event: KeyboardEvent) => CanvasKeyOwner) | null; getActiveTool(): Tool | undefined; getActiveToolId(): ToolId; getToolContext(): ToolContext; @@ -60,7 +68,8 @@ export interface PointerPipeline { onPointerMove(event: PointerEvent): void; onPointerUp(event: PointerEvent): void; onPointerCancel(event: PointerEvent): void; - onPointerEnter(): void; + /** The canvas gained hover; an Alt still held consumes a pending Alt hold. */ + onPointerEnter(event?: Pick): void; onPointerLeave(): void; onKeyDown(event: KeyboardEvent): void; onKeyUp(event: KeyboardEvent): void; @@ -85,6 +94,8 @@ export interface PointerPipeline { endForToolSwitch(): void; /** Replaces a matching tool id that a currently-held temporary tool would restore on release. */ replaceTemporaryRestoreTool(current: ToolId, replacement: ToolId): void; + /** The tool a held temporary switch restores on release, i.e. the tool the user is really on; null without one. */ + heldRestoreToolId(): ToolId | null; /** Clears hover/gesture/temp-tool state, cancelling any in-flight gesture (called on detach/blur). */ reset(): void; } @@ -186,6 +197,11 @@ export const createPointerPipeline = (deps: PointerPipelineDeps): PointerPipelin // The control focus last landed on by pointer; keyboard navigation (`:focus-visible` on arrival) clears it. // Held weakly: a focused row may be removed (virtualized) without another focusin. let pointerFocused: WeakRef | null = null; + // Alt went down while the surface owned it but the pointer was elsewhere: the next hover or press with Alt still + // held begins the picker hold, so a pen that lands without hovering samples on contact. + let altPending = false; + // This pipeline began or armed a hold for the Alt now down; its release is prevented too. + let altClaimed = false; /** * Reads the input element's viewport offset. Hoisted out of @@ -217,33 +233,53 @@ export const createPointerPipeline = (deps: PointerPipelineDeps): PointerPipelin return [buildPointerInput(event, origin)]; }; - /** - * Enter belongs to the canvas only when focus is on its keyboard root (or nowhere), no control claims it and no modal - * dialog is open: focus can rest on the document body beneath one. - */ - const ownsKeyboard = (event: KeyboardEvent): boolean => { - if (event.defaultPrevented || isModalPresent(event) || isInteractiveTarget(event.target)) { - return false; - } - return isDocumentRootTarget(event.target) || deps.getKeyboardRoot()?.contains(event.target as Node) === true; - }; + /** Enter belongs to the canvas only when focus is on its keyboard root (or nowhere) and no control claims it. */ + const focusOnSurface = (event: KeyboardEvent): boolean => + !isInteractiveTarget(event.target) && + (isDocumentRootTarget(event.target) || deps.getKeyboardRoot()?.contains(event.target as Node) === true); /** - * Hold keys and Escape also reach the canvas after a pointer click left focus on a control (a tool button, a layer - * row), but not over an editable field, a control reached by keyboard or an open overlay. + * Who owns a keydown the canvas might act on, or null when it is refused outright: already handled, under a modal + * dialog (focus can rest on the body beneath one), over an editable field or an open overlay, or on a control + * reached by keyboard, whose own activation must win. */ - const ownsCanvasKey = (event: KeyboardEvent): boolean => { - if (event.defaultPrevented || isModalPresent(event) || isEditableTarget(event.target)) { - return false; + const keyOwner = (event: KeyboardEvent): CanvasKeyOwner | null => { + const target = event.target as KeyTarget; + if ( + event.defaultPrevented || + isModalPresent(event) || + isEditableTarget(event.target) || + target?.closest?.(OVERLAY_SELECTOR) + ) { + return null; + } + const clicked = event.target !== null && event.target === pointerFocused?.deref(); + if (isInteractiveTarget(event.target) && !clicked) { + return null; } - if (ownsKeyboard(event)) { - return true; + const resolve = deps.getKeyOwnerResolver?.(); + if (resolve) { + return resolve(event); } - return ( - event.target !== null && - event.target === pointerFocused?.deref() && - !(event.target as KeyTarget)?.closest?.(OVERLAY_SELECTOR) - ); + if (focusOnSurface(event)) { + return 'surface'; + } + return clicked ? 'companion' : null; + }; + + /** Enter applies only from the surface itself, with focus on its keyboard root or nowhere. */ + const ownsEnter = (event: KeyboardEvent): boolean => focusOnSurface(event) && keyOwner(event) === 'surface'; + + /** The Escape ladder and C act from the surface or a companion widget. */ + const ownsSessionKey = (event: KeyboardEvent): boolean => { + const owner = keyOwner(event); + return owner === 'surface' || owner === 'companion'; + }; + + /** Space and Alt are pointer-led: any owner while the canvas is hovered, otherwise only the surface. */ + const holdOwner = (event: KeyboardEvent): CanvasKeyOwner | null => { + const owner = keyOwner(event); + return owner !== null && (hovered || owner === 'surface') ? owner : null; }; const releaseCapture = (pointerId: number): void => { @@ -368,6 +404,7 @@ export const createPointerPipeline = (deps: PointerPipelineDeps): PointerPipelin cancelGesture(); }, endForToolSwitch: () => { + altPending = false; cancelGesture(); if (tempHold) { endTempTool(); @@ -379,7 +416,7 @@ export const createPointerPipeline = (deps: PointerPipelineDeps): PointerPipelin // Escape may cancel a session but must preserve committed selection. const gestureWasActive = gestureActive; cancelGesture(); - if (ownsCanvasKey(event)) { + if (ownsSessionKey(event)) { deps.handleEscape?.({ gestureWasActive }); } return; @@ -390,13 +427,13 @@ export const createPointerPipeline = (deps: PointerPipelineDeps): PointerPipelin } if (event.key === 'Enter') { // Enter applies a session-bearing tool's edit (transform). No-op otherwise. - if (!event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey && ownsKeyboard(event)) { + if (!event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey && ownsEnter(event)) { deps.getActiveTool()?.onKeyCommand?.(deps.getToolContext(), 'apply'); } return; } if (event.code === 'Space' && !event.repeat) { - if (event.ctrlKey || event.metaKey || event.altKey || !ownsCanvasKey(event)) { + if (event.ctrlKey || event.metaKey || event.altKey || holdOwner(event) === null) { return; } beginTempTool('space', SPACE_TEMP_TOOL); @@ -406,15 +443,28 @@ export const createPointerPipeline = (deps: PointerPipelineDeps): PointerPipelin return; } if (isAltKey(event) && !event.repeat) { - if (!deps.getActiveTool()?.usesAltKey && ownsCanvasKey(event)) { + if (holdOwner(event) === null) { + return; + } + // Firefox and Windows activate the menu bar on a lone Alt unless both its press and release are prevented. + event.preventDefault(); + altClaimed = true; + // A tool that reads Alt as a gesture modifier keeps it; pointer events still carry `altKey`. + if (deps.getActiveTool()?.usesAltKey) { + return; + } + if (hovered) { beginTempTool('alt', ALT_TEMP_TOOL); + } else if (!tempHold && !gestureActive) { + altPending = true; } return; } - if (isBboxKey(event) && !event.repeat && ownsCanvasKey(event)) { + if (isBboxKey(event) && !event.repeat && ownsSessionKey(event)) { beginBboxTempTool(); } }, + heldRestoreToolId: () => (tempHold && tempSwitched ? priorToolId : null), hoverPoint: () => hoverPoint, isGestureActive: () => gestureActive, onFocusIn: (event) => { @@ -423,8 +473,15 @@ export const createPointerPipeline = (deps: PointerPipelineDeps): PointerPipelin onKeyUp: (event) => { if (event.code === 'Space' && tempHold === 'space') { releaseTempTool(); - } else if (isAltKey(event) && tempHold === 'alt') { - releaseTempTool(); + } else if (isAltKey(event)) { + altPending = false; + if (altClaimed) { + altClaimed = false; + event.preventDefault(); + } + if (tempHold === 'alt') { + releaseTempTool(); + } } else if (isBboxKey(event) && tempHold === 'bbox') { releaseBboxTempTool(); } @@ -466,6 +523,14 @@ export const createPointerPipeline = (deps: PointerPipelineDeps): PointerPipelin return; } cancelledPointerId = null; + if (altPending) { + // The press is over the canvas whether or not it hovered first; begin the hold so the pick routes it. + altPending = false; + hovered = true; + if (event.altKey) { + beginTempTool('alt', ALT_TEMP_TOOL); + } + } // Commit and consume modal text presses before gesture activation; prevent default focus/selection after // closing the session. if (deps.maybeCommitModalSession?.()) { @@ -480,8 +545,14 @@ export const createPointerPipeline = (deps: PointerPipelineDeps): PointerPipelin deps.getActiveTool()?.onPointerDown?.(deps.getToolContext(), buildPointerInput(event)); deps.updateCursor(); }, - onPointerEnter: () => { + onPointerEnter: (event) => { hovered = true; + if (altPending) { + altPending = false; + if (event?.altKey) { + beginTempTool('alt', ALT_TEMP_TOOL); + } + } }, onPointerLeave: () => { hovered = false; @@ -555,6 +626,8 @@ export const createPointerPipeline = (deps: PointerPipelineDeps): PointerPipelin endTempTool(); } clearBboxTapTimer(); + altPending = false; + altClaimed = false; hovered = false; hoverPoint = null; gestureActive = false; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/layerExportGuards.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/layerExportGuards.ts index 15cc87ccdfc..3c2f59d542b 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/layerExportGuards.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/layerExportGuards.ts @@ -8,7 +8,7 @@ import type { RasterizationJob } from '@workbench/canvas-engine/controllers/rast import type { LayerCacheEntry, LayerCacheStore } from '@workbench/canvas-engine/render/layerCache'; import { areJsonValuesStructurallyEqual } from '@platform/core/json'; -import { getDocumentLayer } from '@workbench/canvas-engine/document/documentIndex'; +import { getDocumentLayer, getDocumentNode } from '@workbench/canvas-engine/document/documentIndex'; import { getSourceContentRect, isEmptyPolygonShape, @@ -16,6 +16,28 @@ import { } from '@workbench/canvas-engine/document/sources'; import { isEmpty } from '@workbench/canvas-engine/math/rect'; +/** + * An operation refused because one layer's pixels could not be read: its image is gone or does not decode. Waiting + * cannot fix it, so the message names the layer for the user to disable, delete or replace. + */ +export interface LayerUnreadable { + readonly status: 'unreadable'; + readonly layerId: string; + /** The layer's name, or its id when it has none or is gone. */ + readonly layerName: string; +} + +/** The refusal for `layerId`, named from `document`. */ +export const layerUnreadable = (document: CanvasDocumentContractV3 | null, layerId: string): LayerUnreadable => ({ + layerId, + layerName: getDocumentNode(document, layerId)?.name || layerId, + status: 'unreadable', +}); + +/** Type guard for results that mix string statuses with a {@link LayerUnreadable}. */ +export const isLayerUnreadable = (value: unknown): value is LayerUnreadable => + typeof value === 'object' && value !== null && (value as { status?: unknown }).status === 'unreadable'; + /** A pointless polygon shape has no raster, so it can never back an export. */ export const isSupportedExportSource = (source: CanvasLayerSourceContract): boolean => source.type !== 'shape' || !isEmptyPolygonShape(source); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/layerRasterizer.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/layerRasterizer.test.ts index 76d2ca1dbe8..25859d12579 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/layerRasterizer.test.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/layerRasterizer.test.ts @@ -4,9 +4,10 @@ import type { LayerCacheEntry } from '@workbench/canvas-engine/render/layerCache import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; import { stacksFrom } from '@workbench/canvas-engine/document-model/documentFixtures.testStub'; +import { ImageUnavailableError } from '@workbench/canvas-engine/render/rasterizers/imageRasterizer'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { createLayerRasterizer, type CreateLayerRasterizerDeps } from './layerRasterizer'; +import { createLayerRasterizer, type CreateLayerRasterizerDeps, RASTERIZATION_RETRY_BASE_MS } from './layerRasterizer'; const surface = (width = 4, height = 4): RasterSurface => ({ @@ -123,6 +124,7 @@ const makeHarness = (overrides: Partial = {}): Harnes layerCache: { get: vi.fn(() => entry), getOrCreateRect: vi.fn(() => entry), + peek: vi.fn(() => entry), publishRasterized: vi.fn((_layerId: string, _rect: unknown, _pixels: unknown, family?: string) => { entry.renderedFontFamily = family; return { ...entry, version: entry.version + 1 }; @@ -277,6 +279,108 @@ describe('failure', () => { ); }); + it('reports a source that keeps failing once, and remembers the failure until the layer changes', async () => { + const rasterizer = createLayerRasterizer(harness.deps); + (harness.entry as { stale: boolean }).stale = true; + harness.rasterize.mockRejectedValue(new Error('decode failed')); + const layer = rasterLayer(); + + await expect(rasterizer.getOrStartLayerRasterization(layer, harness.document.value)).resolves.toBe('error'); + await expect(rasterizer.getOrStartLayerRasterization(layer, harness.document.value)).resolves.toBe('error'); + expect(harness.rasterize).toHaveBeenCalledTimes(2); + expect(harness.spies.reportError).toHaveBeenCalledOnce(); + expect(rasterizer.getFailure(layer)).toBe('unreadable'); + + // Another image is another failure, worth its own notice. + const replaced = rasterLayer({ source: { image: { height: 4, imageName: 'b.png', width: 4 }, type: 'image' } }); + harness.document.value = documentOf([replaced]); + expect(rasterizer.getFailure(replaced)).toBeNull(); + await expect(rasterizer.getOrStartLayerRasterization(replaced, harness.document.value)).resolves.toBe('error'); + expect(harness.spies.reportError).toHaveBeenCalledTimes(2); + }); + + it('lets a failed source retry after a backoff that doubles per failure, reporting it once', async () => { + const timers: { callback: () => void; delayMs: number }[] = []; + const rasterizer = createLayerRasterizer({ + ...harness.deps, + setTimer: (callback, delayMs) => { + timers.push({ callback, delayMs }); + return () => undefined; + }, + }); + (harness.entry as { stale: boolean }).stale = true; + harness.rasterize.mockRejectedValue(new Error('backend restarting')); + const layer = rasterLayer(); + + await rasterizer.getOrStartLayerRasterization(layer, harness.document.value); + expect(rasterizer.isBackingOff(layer)).toBe(true); + timers.at(-1)!.callback(); + // The backoff's end lets the display try again, and says so; the failure itself is still on record. + expect(rasterizer.isBackingOff(layer)).toBe(false); + expect(rasterizer.getFailure(layer)).toBe('unreadable'); + expect(harness.spies.invalidateLayerRender).toHaveBeenCalledWith('layer-1'); + + await rasterizer.getOrStartLayerRasterization(layer, harness.document.value); + expect(timers.map(({ delayMs }) => delayMs)).toEqual([ + RASTERIZATION_RETRY_BASE_MS, + RASTERIZATION_RETRY_BASE_MS * 2, + ]); + expect(harness.spies.reportError).toHaveBeenCalledOnce(); + }); + + it('records an image that could not be fetched as unavailable: backed off, reported once per kind', async () => { + const timers: { callback: () => void; delayMs: number }[] = []; + const rasterizer = createLayerRasterizer({ + ...harness.deps, + setTimer: (callback, delayMs) => { + timers.push({ callback, delayMs }); + return () => undefined; + }, + }); + (harness.entry as { stale: boolean }).stale = true; + const outage = new ImageUnavailableError('a.png', { cause: new Error('503') }); + harness.rasterize.mockRejectedValue(outage); + const layer = rasterLayer(); + + await expect(rasterizer.getOrStartLayerRasterization(layer, harness.document.value)).resolves.toBe('unavailable'); + expect(harness.spies.setStatus).toHaveBeenCalledWith('layer-1', 'error'); + expect(rasterizer.getFailure(layer)).toBe('unavailable'); + expect(rasterizer.isBackingOff(layer)).toBe(true); + + timers.at(-1)!.callback(); + await expect(rasterizer.getOrStartLayerRasterization(layer, harness.document.value)).resolves.toBe('unavailable'); + expect(timers.map(({ delayMs }) => delayMs)).toEqual([ + RASTERIZATION_RETRY_BASE_MS, + RASTERIZATION_RETRY_BASE_MS * 2, + ]); + // The notice carries the wrapper, which names the image, with the resolver's error as its cause. + expect(harness.spies.reportError.mock.calls).toEqual([['Layer thumbnail rasterization failed', 'layer-1', outage]]); + + // Once the image is gone for good, the same key reads as unreadable, and that new kind is reported. + const gone = new Error('decode failed'); + harness.rasterize.mockRejectedValue(gone); + await expect(rasterizer.getOrStartLayerRasterization(layer, harness.document.value)).resolves.toBe('error'); + expect(rasterizer.getFailure(layer)).toBe('unreadable'); + expect(harness.spies.reportError.mock.calls.at(-1)).toEqual([ + 'Layer thumbnail rasterization failed', + 'layer-1', + gone, + ]); + await expect(rasterizer.getOrStartLayerRasterization(layer, harness.document.value)).resolves.toBe('error'); + expect(harness.spies.reportError).toHaveBeenCalledTimes(2); + }); + + it('forgets a failure once a retry publishes', async () => { + const rasterizer = createLayerRasterizer(harness.deps); + (harness.entry as { stale: boolean }).stale = true; + harness.rasterize.mockRejectedValueOnce(new Error('network blip')); + const layer = rasterLayer(); + + await expect(rasterizer.getOrStartLayerRasterization(layer, harness.document.value)).resolves.toBe('error'); + await expect(rasterizer.getOrStartLayerRasterization(layer, harness.document.value)).resolves.toBe('published'); + expect(rasterizer.getFailure(layer)).toBeNull(); + }); + it('still reports error when diagnostics itself throws', async () => { harness.rasterize.mockRejectedValue(new Error('decode failed')); harness.spies.reportError.mockImplementation(() => { diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/layerRasterizer.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/layerRasterizer.ts index 394271e5146..bf9bb1a9161 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/layerRasterizer.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/layerRasterizer.ts @@ -5,7 +5,7 @@ import type { } from '@workbench/canvas-engine/contracts'; import type { RasterizationJob } from '@workbench/canvas-engine/controllers/rasterController'; import type { FontLoader } from '@workbench/canvas-engine/render/fontLoader'; -import type { LayerCacheStore } from '@workbench/canvas-engine/render/layerCache'; +import type { LayerCacheEntry, LayerCacheStore } from '@workbench/canvas-engine/render/layerCache'; import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; import type { RasterizeResult } from '@workbench/canvas-engine/render/rasterizers'; @@ -13,9 +13,14 @@ import { areJsonValuesStructurallyEqual } from '@platform/core/json'; import { getDocumentLayer } from '@workbench/canvas-engine/document/documentIndex'; import { getSourceContentRect, renderableSourceOf } from '@workbench/canvas-engine/document/sources'; import { isSupportedExportSource } from '@workbench/canvas-engine/layerExportGuards'; +import { ImageUnavailableError } from '@workbench/canvas-engine/render/rasterizers/imageRasterizer'; -/** How a rasterization ended: pixels landed, the world moved, it threw, or it was cancelled. */ -export type LayerRasterizationOutcome = 'published' | 'stale' | 'error' | 'aborted'; +/** + * How a rasterization ended: pixels landed, the world moved, it was cancelled, or it failed. `error`: the source cannot + * be read (its image is missing or does not decode, or rendering threw), so it fails again until it changes. + * `unavailable`: its image could not be fetched for now, so a later try may succeed. + */ +export type LayerRasterizationOutcome = 'published' | 'stale' | 'error' | 'unavailable' | 'aborted'; /** The registry of in-flight rasterizations, keyed by layer. */ export interface RasterizationJobRegistry { @@ -47,8 +52,14 @@ export interface CreateLayerRasterizerDeps { readonly invalidateLayerCache: (layerId: string) => void; readonly invalidateLayerRender: (layerId: string) => void; readonly reportError: (message: 'Layer thumbnail rasterization failed', layerId: string, error: unknown) => void; + /** Runs `callback` after `delayMs`, returning a cancel; defaults to the global timer. */ + readonly setTimer?: (callback: () => void, delayMs: number) => () => void; } +/** The first wait before a failed source may rasterize again for display, doubling per repeated failure up to the cap. */ +export const RASTERIZATION_RETRY_BASE_MS = 3_000; +export const RASTERIZATION_RETRY_MAX_MS = 60_000; + export interface LayerRasterizer { /** * Starts or joins rasterization for the same source, cache version and document generation; repeated frame @@ -59,6 +70,34 @@ export interface LayerRasterizer { document: CanvasDocumentContractV3, signal?: AbortSignal ): Promise; + /** + * How the last rasterization of `layer`'s current source, cache version and document generation failed, or null: + * `unreadable` when its stale cache cannot become ready by waiting, `unavailable` when its image could not be + * fetched for now. A success or any change to the layer clears it. + */ + getFailure(layer: CanvasLayerContract): RasterizationFailureKind | null; + /** + * Whether a failure of either kind is recorded and its retry backoff is still running. The backoff starts at + * {@link RASTERIZATION_RETRY_BASE_MS} and doubles per repeated failure up to {@link RASTERIZATION_RETRY_MAX_MS}; its + * end invalidates the layer's render, so a displayed layer retries on its own. + */ + isBackingOff(layer: CanvasLayerContract): boolean; + /** Cancels pending retry timers. */ + dispose(): void; +} + +export type RasterizationFailureKind = 'unreadable' | 'unavailable'; + +/** What a failed rasterization read; a later failure of the same key continues its backoff, whatever its kind. */ +interface RasterizationFailure { + readonly kind: RasterizationFailureKind; + readonly version: number; + readonly documentGeneration: number; + readonly source: CanvasLayerSourceContract; + /** Consecutive failures of this key. */ + readonly attempts: number; + /** Ends the backoff early; null once it ended. */ + cancelRetry: (() => void) | null; } /** @@ -69,6 +108,32 @@ export interface LayerRasterizer { */ export const createLayerRasterizer = (deps: CreateLayerRasterizerDeps): LayerRasterizer => { const { jobs, layerCache } = deps; + const setTimer = + deps.setTimer ?? + ((callback: () => void, delayMs: number) => { + const handle = setTimeout(callback, delayMs); + return () => clearTimeout(handle); + }); + // Keyed by cache entry so an evicted or deleted layer's record goes with it. + const failures = new WeakMap(); + const backoffs = new Set(); + const endBackoff = (failure: RasterizationFailure | undefined): void => { + failure?.cancelRetry?.(); + if (failure) { + failure.cancelRetry = null; + backoffs.delete(failure); + } + }; + const isSameFailure = ( + failure: RasterizationFailure | undefined, + version: number, + documentGeneration: number, + source: CanvasLayerSourceContract | null + ): boolean => + !!failure && + failure.version === version && + failure.documentGeneration === documentGeneration && + areJsonValuesStructurallyEqual(failure.source, source); const getOrStartLayerRasterization = ( layer: CanvasLayerContract, @@ -166,6 +231,8 @@ export const createLayerRasterizer = (deps: CreateLayerRasterizerDeps): LayerRas return 'stale'; } + endBackoff(failures.get(currentEntry)); + failures.delete(currentEntry); const publishedEntry = layerCache.publishRasterized(layer.id, result.rect, result.surface, renderedFontFamily); deps.thumbnails.setVersion(layer.id, publishedEntry.version); deps.thumbnails.setStatus(layer.id, 'ready'); @@ -189,12 +256,41 @@ export const createLayerRasterizer = (deps: CreateLayerRasterizerDeps): LayerRas return 'stale'; } deps.thumbnails.setStatus(layer.id, 'error'); - try { - deps.reportError('Layer thumbnail rasterization failed', layer.id, error); - } catch { - // Diagnostics must not turn a handled thumbnail failure into a rejection. + const kind: RasterizationFailureKind = error instanceof ImageUnavailableError ? 'unavailable' : 'unreadable'; + // Retrying the same source fails the same way; only its first failure of each kind is worth a notice. + const failedEntry = layerCache.peek(layer.id); + const previous = failedEntry ? failures.get(failedEntry) : undefined; + const repeated = !!failedEntry && isSameFailure(previous, version, documentGeneration, source); + endBackoff(previous); + if (failedEntry && !deps.isDisposed()) { + const attempts = repeated ? previous!.attempts + 1 : 1; + const failure: RasterizationFailure = { + attempts, + cancelRetry: null, + documentGeneration, + kind, + source, + version, + }; + failure.cancelRetry = setTimer( + () => { + failure.cancelRetry = null; + backoffs.delete(failure); + deps.invalidateLayerRender(layer.id); + }, + Math.min(RASTERIZATION_RETRY_BASE_MS * 2 ** (attempts - 1), RASTERIZATION_RETRY_MAX_MS) + ); + backoffs.add(failure); + failures.set(failedEntry, failure); + } + if (!repeated || previous!.kind !== kind) { + try { + deps.reportError('Layer thumbnail rasterization failed', layer.id, error); + } catch { + // Diagnostics must not turn a handled thumbnail failure into a rejection. + } } - return 'error'; + return kind === 'unavailable' ? 'unavailable' : 'error'; } finally { signal?.removeEventListener('abort', abort); jobs.finish(layer.id, job); @@ -203,5 +299,24 @@ export const createLayerRasterizer = (deps: CreateLayerRasterizerDeps): LayerRas return promise; }; - return { getOrStartLayerRasterization }; + /** The failure recorded for `layer`'s current key, while its cache still waits for pixels. */ + const currentFailure = (layer: CanvasLayerContract): RasterizationFailure | undefined => { + const entry = layerCache.peek(layer.id); + const failure = entry ? failures.get(entry) : undefined; + return entry?.stale && + isSameFailure(failure, entry.version, jobs.getDocumentGeneration(), renderableSourceOf(layer)) + ? failure + : undefined; + }; + const getFailure = (layer: CanvasLayerContract): RasterizationFailureKind | null => + currentFailure(layer)?.kind ?? null; + const isBackingOff = (layer: CanvasLayerContract): boolean => !!currentFailure(layer)?.cancelRetry; + + const dispose = (): void => { + for (const failure of backoffs) { + endBackoff(failure); + } + }; + + return { dispose, getFailure, getOrStartLayerRasterization, isBackingOff }; }; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/mutationContracts.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/mutationContracts.ts index 1eaeeac18c5..c30ef99cd2c 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/mutationContracts.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/mutationContracts.ts @@ -124,6 +124,14 @@ export type CanvasProjectMutation = | { type: 'reorderCanvasSiblings'; orders: readonly ReorderSiblingsCommand[] } | { type: 'updateCanvasLayer'; id: string; patch: CanvasLayerBasePatch } | { type: 'replaceCanvasLayer'; layerId: string; layer: CanvasLayerContract } + | { + /** + * Replaces several leaves in place, each by a contract of the same id and type, as one value edit. The whole + * mutation is refused when any id is missing, repeated, names a group, or would change its leaf's type. + */ + type: 'replaceCanvasLayers'; + replacements: readonly { layerId: string; layer: CanvasLayerContract }[]; + } | { type: 'setCanvasLayersEnabled'; updates: readonly { id: string; isEnabled: boolean }[] } | { type: 'setCanvasLayerPositions'; updates: readonly { id: string; x: number; y: number }[] } | { type: 'setCanvasLayersHidden'; updates: readonly { id: string; isHidden: boolean }[] } diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/rasterSnapshotCapture.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/rasterSnapshotCapture.ts index ade1fc6137a..a1c156cc82f 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/rasterSnapshotCapture.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/rasterSnapshotCapture.ts @@ -151,6 +151,9 @@ export const createRasterSnapshotCapture = (deps: CreateRasterSnapshotCaptureDep emptyLayerIds.add(layerId); continue; } + if (liveResult.status === 'unreadable') { + return liveResult; + } if (liveResult.status !== 'ok') { return { status: diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/rasterTransactions.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/rasterTransactions.ts index 9f528f4746a..ca65d614f7c 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/rasterTransactions.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/rasterTransactions.ts @@ -1,5 +1,6 @@ import type { LayerExportGuard } from './capabilities'; import type { CanvasStateContractV3 } from './contracts'; +import type { LayerUnreadable } from './layerExportGuards'; import type { SpatialRunner } from './render/exactAdjustments'; import type { RasterSurface } from './render/raster'; import type { Rect } from './types'; @@ -29,9 +30,11 @@ export interface CanvasRasterSnapshot { release(): void; } +/** {@link LayerUnreadable}: a required layer's pixels could not be read (its image is gone or does not decode). */ export type CaptureRasterSnapshotResult = | { status: 'ok'; snapshot: CanvasRasterSnapshot } - | { status: 'stale' | 'aborted' | 'not-ready' | 'over-budget' }; + | { status: 'stale' | 'aborted' | 'not-ready' | 'over-budget' } + | LayerUnreadable; export interface CanvasCompositeExecutorDeps { backend: { diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/compositeFrame.budget.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/compositeFrame.budget.test.ts index e21b5f9bbe4..84e24e15c16 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/compositeFrame.budget.test.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/compositeFrame.budget.test.ts @@ -56,6 +56,7 @@ const scene = (budgetBytes = SURFACE_BYTES * 2) => { getControlView: () => null, getGroupSurface: (scope, members, matrices, content) => raster.groups.get(scope, members, matrices, content), getMaskPatternTile: () => null, + hasStagedCandidates: () => false, layerCache: raster.layers, previews: new PreviewStateController(), raster, @@ -126,6 +127,7 @@ describe('frame description over 2,000 nodes', () => { getControlView: () => null, getGroupSurface: () => null, getMaskPatternTile: () => null, + hasStagedCandidates: () => false, layerCache: raster.layers, previews: new PreviewStateController(), raster, diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/compositeFrame.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/compositeFrame.test.ts index 0696d76bcbc..bc30afdd7b5 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/compositeFrame.test.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/compositeFrame.test.ts @@ -47,6 +47,7 @@ const documentOf = (layers: CanvasLayerContract[]): CanvasDocumentContractV3 => interface Harness { deps: CreateCompositeFrameDeps; controlView: ReturnType; + staging: { candidates: boolean }; stores: ReturnType; previews: PreviewStateController; overrides: Map; @@ -71,10 +72,13 @@ const makeHarness = (): Harness => { const rasterizeLayer = vi.fn(); const beginFrame = vi.fn(() => FRAME_USAGE); const enforce = vi.fn(() => ({ evictedBaseLayerIds: [] as string[], overageBytes: 0 })); + const staging = { candidates: false }; const harness: Harness = { beginFrame, controlView: null, + staging, deps: { + hasStagedCandidates: () => staging.candidates, backend: {} as CreateCompositeFrameDeps['backend'], derivedSurfaceCache: { byteSize: () => 0 } as CreateCompositeFrameDeps['derivedSurfaceCache'], diagnostics: {} as CreateCompositeFrameDeps['diagnostics'], @@ -342,3 +346,57 @@ describe('the surface budget', () => { expect(composite.mock.invocationCallOrder[0]).toBeLessThan(harness.enforce.mock.invocationCallOrder[0]!); }); }); + +describe('masks while a result is staged', () => { + const mask = (id: string) => + layer(id, { + mask: { bitmap: { height: 8, imageName: `${id}.png`, width: 8 }, fill: { color: '#e07575', style: 'solid' } }, + source: undefined, + type: 'inpaint_mask', + }); + const region = (id: string) => ({ ...mask(id), type: 'regional_guidance' }) as unknown as CanvasLayerContract; + const control = (id: string) => layer(id, { type: 'control' }); + const doc = documentOf([layer('a'), control('c'), mask('m'), region('r')]); + const drawnIds = () => + (lastCompositeOptions().preparation as compositor.CompositePreparation).leaves.map((leaf) => leaf.id).sort(); + const demandedIds = () => [...(harness.enforce.mock.calls.at(-1)![0] as Set)].sort(); + for (const id of ['c', 'm', 'r']) { + beforeEach(() => harness.entries.set(id, entryFor())); + } + + it('hides mask and region stacks and the region tint, rebuilding the frame description once per flip', () => { + const frame = createCompositeFrame(harness.deps); + const drawFrame = () => frame.draw(surface(), doc, VIEW, null, null); + + const description = () => (lastCompositeOptions().preparation as compositor.CompositePreparation).leaves; + drawFrame(); + const shown = description(); + expect(drawnIds()).toEqual(['a', 'c', 'm', 'r']); + expect(lastCompositeOptions().regionOverlays).toBe(true); + + harness.staging.candidates = true; + drawFrame(); + const hidden = description(); + expect(hidden).not.toBe(shown); + expect(drawnIds()).toEqual(['a', 'c']); + expect(demandedIds()).toEqual(['a', 'c']); + expect(lastCompositeOptions().regionOverlays).toBe(false); + drawFrame(); + expect(description()).toBe(hidden); + + harness.staging.candidates = false; + drawFrame(); + expect(drawnIds()).toEqual(['a', 'c', 'm', 'r']); + expect(demandedIds()).toEqual(['a', 'c', 'm', 'r']); + }); + + it('also hides them while only a staged or progress preview is published', () => { + harness.previews.publishStaged(harness.previews.nextStagedToken(), { + height: 8, + surface: surface(8, 8), + width: 8, + } as never); + draw(doc); + expect(drawnIds()).toEqual(['a', 'c']); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/compositeFrame.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/compositeFrame.ts index 4b142a946b0..e624898eeed 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/compositeFrame.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/compositeFrame.ts @@ -46,8 +46,16 @@ export interface CreateCompositeFrameDeps { readonly getControlView: () => CompositeOptions['controlView']; /** Starts (or joins) the rasterization of a layer whose cache is stale. */ readonly rasterizeLayer: (layer: CanvasLayerContract, doc: CanvasDocumentContractV3) => void; + /** Whether the staging area holds a candidate, shown or not. */ + readonly hasStagedCandidates: () => boolean; } +/** + * While a generation is staged (a candidate waits, or a staged or progress preview is published) masks and regions + * stop tinting the screen, so the result reads as it would be accepted; control layers stay. + */ +const STAGING_OVERLAY_STACKS = { control: true, inpaint_mask: false, regional_guidance: false } as const; + export interface CompositeFrame { /** Composites the document onto the screen surface and enforces the surface budget. */ draw( @@ -132,12 +140,14 @@ export const createCompositeFrame = (deps: CreateCompositeFrameDeps): CompositeF const describeFrame = ( doc: CanvasDocumentContractV3, isolationLayerId: string | null, - overrides: CompositeOptions['transformOverrides'] + overrides: CompositeOptions['transformOverrides'], + showOverlayStacks: CompositeOptions['showOverlayStacks'] ): CompositePreparation => { const plan = reusePreparation(preparation, doc, { backend: deps.backend, groupSurface: deps.getGroupSurface, isolationLayerId, + showOverlayStacks, transformOverrides: overrides, }); preparation = plan; @@ -187,7 +197,9 @@ export const createCompositeFrame = (deps: CreateCompositeFrameDeps): CompositeF const stagedPlacement = stagedPreview?.placement; const isolatedGuard = samPreview?.isolated ? samPreview.guard : null; const overrides = !isolatedGuard && transformOverrides.size > 0 ? transformOverrides : null; - const frame = describeFrame(doc, isolatedGuard?.layerId ?? null, overrides); + const staging = stagedPreview !== null || deps.hasStagedCandidates(); + const showOverlayStacks = staging ? STAGING_OVERLAY_STACKS : undefined; + const frame = describeFrame(doc, isolatedGuard?.layerId ?? null, overrides, showOverlayStacks); // Snapshot filter previews only when active, avoiding an empty map allocation every frame. const layerPreviews = !isolatedGuard && previews.filterCount() > 0 ? previews.filterSnapshot() : null; const activeFrameLayerIds = calculateActiveFrameLayerIds({ @@ -223,8 +235,9 @@ export const createCompositeFrame = (deps: CreateCompositeFrameDeps): CompositeF layerPreviews, maskPatternTile: deps.getMaskPatternTile, preparation: frame, - // Screen frames are display-time: the region tint may draw. - regionOverlays: true, + // Screen frames are display-time: the region tint may draw, except over a staged result. + regionOverlays: !staging, + showOverlayStacks, // Candidate-specific placement wins for final images. Progress frames // and legacy image inputs continue to follow the CURRENT bbox origin. stagedPreview: diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/compositor.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/compositor.ts index 5ec152f2d58..c2030542742 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/compositor.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/compositor.ts @@ -12,6 +12,7 @@ import type { } from '@workbench/canvas-engine/contracts'; import type { CanvasDiagnostics } from '@workbench/canvas-engine/diagnostics'; import type { SemanticLeaf } from '@workbench/canvas-engine/document-model/semanticLeaf'; +import type { OverlayStackKind } from '@workbench/canvas-engine/document/layerStacks'; import type { FrameDamage, Mat2d, Rect, Vec2 } from '@workbench/canvas-engine/types'; import { compileDocumentLeaves, lookupDocumentLeaf } from '@workbench/canvas-engine/document-model/documentModel'; @@ -111,6 +112,8 @@ export interface CompositeOptions { layerPreviews?: ReadonlyMap | null; /** Isolate one leaf and suppress staged/filter previews; disabled or hidden leaves remain undrawn. */ isolationLayerId?: string | null; + /** Overlay stacks the screen draws; absence draws all. An isolated target draws whatever its stack. */ + showOverlayStacks?: Readonly>; /** * Memoized adjusted raster surface, or null for raw pixels. Optional provider; without it adjustments are * ignored. Cache versions prevent per-frame recomputation. @@ -138,7 +141,7 @@ export interface CompositeOptions { diagnostics?: CanvasDiagnostics | null; /** * Reused frame description from {@link prepareComposite}; ignored unless it was prepared for this document, - * isolation, grouping and transform overrides. + * isolation, overlay stacks, grouping and transform overrides. */ preparation?: CompositePreparation | null; /** @@ -178,14 +181,21 @@ export const transformOverridesKey = (overrides: TransformOverrides | null | und return key; }; +/** A stable key for the overlay stack switches; absence shows every stack. */ +const overlayStacksKey = (shown: CompositeOptions['showOverlayStacks']): string => { + const stacks = shown ?? ALL_OVERLAY_STACKS_SHOWN; + return `${+stacks.control}${+stacks.inpaint_mask}${+stacks.regional_guidance}`; +}; + /** * One frame description: the leaves drawn (bottom first), their effective matrices with overrides applied, the * isolated group scopes, and optionally each leaf's document-space bounds this frame. Stable while the document, - * isolation, grouping and override contents are, so frame demand, compositing and sampling share it. + * isolation, overlay stacks, grouping and override contents are, so frame demand, compositing and sampling share it. */ export interface CompositePreparation { readonly document: CanvasDocumentContractV3; readonly isolationLayerId: string | null; + readonly overlayStacksKey: string; readonly grouped: boolean; readonly overridesKey: string; readonly leaves: readonly SemanticLeaf[]; @@ -212,13 +222,13 @@ const effectiveMatrix = (leaf: SemanticLeaf, overrides: TransformOverrides | nul /** Plans the draw order, placement and isolated group scopes for `doc`, for callers compositing it repeatedly. */ export const prepareComposite = ( doc: CanvasDocumentContractV3, - opts: Pick = {} + opts: Pick = {} ): CompositePreparation => { const isolationLayerId = opts.isolationLayerId ?? null; // The plan lists the leaves to draw bottom first; the `stagedPreview` lands on top of every stack. const { leaves } = planScreenComposition(compileDocumentLeaves(doc), { isolationLayerId, - showOverlayStacks: ALL_OVERLAY_STACKS_SHOWN, + showOverlayStacks: opts.showOverlayStacks ?? ALL_OVERLAY_STACKS_SHOWN, }); // Isolation mode inspects raw members, so scopes are bypassed while active. const grouped = !!opts.groupSurface && isolationLayerId === null; @@ -230,6 +240,7 @@ export const prepareComposite = ( isolationLayerId, leaves, matrices: leaves.map((leaf) => effectiveMatrix(leaf, overrides)), + overlayStacksKey: overlayStacksKey(opts.showOverlayStacks), overridesKey: transformOverridesKey(overrides), scopes, }; @@ -243,6 +254,7 @@ const isPreparedFor = ( !!preparation && preparation.document === doc && preparation.isolationLayerId === (opts.isolationLayerId ?? null) && + preparation.overlayStacksKey === overlayStacksKey(opts.showOverlayStacks) && preparation.grouped === (!!opts.groupSurface && !isIsolated(opts)) && preparation.overridesKey === transformOverridesKey(opts.transformOverrides); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/compositorDamage.browser.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/compositorDamage.browser.test.ts index d50c8750cbd..01799551405 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/compositorDamage.browser.test.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/compositorDamage.browser.test.ts @@ -223,3 +223,49 @@ describe('damage-clipped compositing matches a full repaint', () => { expect(countDifferences(full, clipped)).toEqual({ differing: 0, worst: 0 }); }); }); + +describe('overlay stacks switched off while a result is staged', () => { + it('draws the raster under a mask, a region and a regenerate region untinted, and tinted again after', () => { + const mask: CanvasLayerContract = { + blendMode: 'normal', + id: 'mask', + isEnabled: true, + isLocked: false, + mask: { bitmap: null, fill: { color: '#0000ff', style: 'solid' } }, + name: 'mask', + opacity: 1, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'inpaint_mask', + }; + const region = rasterLayer('region', { inpaint: { fill: { color: '#00ff00', style: 'solid' }, isEnabled: true } }); + const layers = [mask, region, rasterLayer('base')]; + const rects = { + base: { height: 100, width: 300, x: 0, y: 0 }, + mask: { height: 100, width: 100, x: 0, y: 0 }, + region: { height: 100, width: 100, x: 200, y: 0 }, + }; + const { backend, caches } = scene(layers, rects, { base: '#ff0000', mask: '#ffffff', region: '#ff0000' }); + const doc = { stacks: stacksFrom(layers) } as unknown as CanvasDocumentContractV3; + const screen = backend.createSurface(SCREEN_W, SCREEN_H); + const rgbAt = (x: number): number[] => Array.from(screen.ctx.getImageData(x, 50, 1, 1).data.slice(0, 3)); + const draw = (staging: boolean) => + compositeDocument(screen, doc, caches, identity(), { + backend, + imageSmoothing: false, + regionOverlays: !staging, + showOverlayStacks: staging ? { control: true, inpaint_mask: false, regional_guidance: false } : undefined, + }); + + draw(true); + expect([rgbAt(50), rgbAt(150), rgbAt(250)]).toEqual([ + [255, 0, 0], + [255, 0, 0], + [255, 0, 0], + ]); + + draw(false); + expect(rgbAt(50)).not.toEqual([255, 0, 0]); + expect(rgbAt(150)).toEqual([255, 0, 0]); + expect(rgbAt(250)).not.toEqual([255, 0, 0]); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/exactAdjustments.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/exactAdjustments.ts index b9159546c40..98ad72e93d3 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/exactAdjustments.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/exactAdjustments.ts @@ -14,7 +14,7 @@ import type { } from '@workbench/canvas-engine/render/spatial/spatialAdjustmentService'; import type { Rect } from '@workbench/canvas-engine/types'; -import { getDocumentIndex } from '@workbench/canvas-engine/document/documentIndex'; +import { getDocumentNode } from '@workbench/canvas-engine/document/documentIndex'; import { type AdjustablePixels, type AdjustmentOrigin, @@ -68,7 +68,7 @@ export const adjustmentRefusal = ( reason: AdjustmentRefusalReason ): AdjustmentRefusal => ({ ownerId, - ownerName: (document && getDocumentIndex(document).byId.get(ownerId)?.node.name) || ownerId, + ownerName: getDocumentNode(document, ownerId)?.name || ownerId, reason, status: 'adjustment-refused', }); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/frameDemand.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/frameDemand.test.ts index ee2e6349af5..cb31cb59ce1 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/frameDemand.test.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/frameDemand.test.ts @@ -88,6 +88,31 @@ describe('calculateActiveFrameLayerIds', () => { ).toEqual(new Set(['off'])); }); + it('demands nothing from a hidden overlay stack unless an isolated operation reads it', () => { + const mask: CanvasLayerContract = { + blendMode: 'normal', + id: 'mask', + isEnabled: true, + isLocked: false, + mask: { bitmap: { height: 100, imageName: 'mask.png', width: 100 }, fill: { color: '#e07575', style: 'solid' } }, + name: 'mask', + opacity: 1, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'inpaint_mask', + }; + const doc = document([mask, { ...hiddenControl('control'), isHidden: false }, layer('base', 0, 0)]); + const viewport = { height: 200, width: 200, x: 0, y: 0 }; + const showOverlayStacks = { control: true, inpaint_mask: false, regional_guidance: false }; + + expect(calculateActiveFrameLayerIds({ document: doc, viewport })).toEqual(new Set(['base', 'control', 'mask'])); + expect(calculateActiveFrameLayerIds({ document: doc, showOverlayStacks, viewport })).toEqual( + new Set(['base', 'control']) + ); + expect( + calculateActiveFrameLayerIds({ document: doc, isolationLayerId: 'mask', showOverlayStacks, viewport }) + ).toEqual(new Set(['mask'])); + }); + it('limits demand to isolated layers', () => { const doc = document([layer('isolated', 0, 0), layer('covered', 0, 0)]); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/frameDemand.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/frameDemand.ts index b0f73b1ebea..dc097e6d950 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/frameDemand.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/frameDemand.ts @@ -11,6 +11,8 @@ export interface FrameDemandInput { readonly document: CanvasDocumentContractV3; readonly isolationLayerId?: string | null; readonly liveCacheRects?: ReadonlyMap; + /** Overlay stacks the frame draws; a hidden stack demands nothing. */ + readonly showOverlayStacks?: CompositeOptions['showOverlayStacks']; readonly transformOverrides?: CompositeOptions['transformOverrides']; readonly viewport: Rect; /** The frame description to plan against; prepared from the other inputs when absent. */ @@ -42,6 +44,7 @@ export const calculateActiveFrameLayerIds = (input: FrameDemandInput): Set(); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterComposite.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterComposite.ts index 975f07c3cfa..04ec02db4bd 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterComposite.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterComposite.ts @@ -7,6 +7,7 @@ import type { CanvasRasterLayerContractV2, } from '@workbench/canvas-engine/contracts'; import type { SemanticLeaf } from '@workbench/canvas-engine/document-model/semanticLeaf'; +import type { LayerUnreadable } from '@workbench/canvas-engine/layerExportGuards'; import type { GroupCompositeScope } from '@workbench/canvas-engine/render/groupCompositeScopes'; import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; import type { Mat2d, Rect } from '@workbench/canvas-engine/types'; @@ -108,6 +109,25 @@ export class RasterCompositeOverBudgetError extends Error { } } +/** + * Thrown when a contributor's pixels cannot be read (its image is gone or does not decode); export reports its + * `unreadable` refusal, which names the layer. + */ +export class RasterCompositeUnreadableError extends Error { + constructor(readonly refusal: LayerUnreadable) { + super(`The pixels of layer ${refusal.layerId} could not be read.`); + this.name = 'RasterCompositeUnreadableError'; + } +} + +/** Thrown when a contributor's pixels are still loading or could not be fetched for now; export maps it to `not-ready`. */ +export class RasterCompositeNotReadyError extends Error { + constructor(readonly layerId: string) { + super(`The pixels of layer ${layerId} are not ready.`); + this.name = 'RasterCompositeNotReadyError'; + } +} + const defaultReadImageData = (surface: RasterSurface, rect: Rect): ImageData => surface.ctx.getImageData(rect.x, rect.y, rect.width, rect.height); 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 index 8b0dbe0d460..5e80ed9b7d6 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/imageRasterizer.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/imageRasterizer.ts @@ -6,14 +6,50 @@ import { createDecodedBitmapPool } from '@workbench/canvas-engine/render/decoded import type { RasterizeDeps, RasterizeResult } from './types'; -/** Acquires a short-lived decoded bitmap lease, coalescing concurrent decodes by image name. */ +/** + * Thrown by an `ImageResolver` for an image that is missing (deleted, or never stored) or not readable by this user; + * either lasts, so the layer reads as unreadable. + */ +export class CanvasImageMissingError extends Error { + constructor(readonly imageName: string) { + super(`Canvas image "${imageName}" is missing or not readable.`); + this.name = 'CanvasImageMissingError'; + } +} + +/** + * The resolver could not fetch an image that may still be readable (network, server, an expired session): a + * rasterization that fails with it can succeed later, unlike a missing image or one that does not decode. + */ +export class ImageUnavailableError extends Error { + constructor( + readonly imageName: string, + options: { cause: unknown } + ) { + super(`Canvas image "${imageName}" could not be fetched.`, options); + this.name = 'ImageUnavailableError'; + } +} + +/** + * Acquires a short-lived decoded bitmap lease, coalescing concurrent decodes by image name. A resolver failure other + * than {@link CanvasImageMissingError} or cancellation rejects as {@link ImageUnavailableError}. + */ export const resolveBitmap = async (image: CanvasImageRef, deps: RasterizeDeps): Promise => { deps.signal?.throwIfAborted(); const pool = deps.bitmapPool ?? createDecodedBitmapPool(); const lease = await pool.acquire( image.imageName, async (decodeSignal) => { - const blob = await deps.resolver(image.imageName, decodeSignal); + let blob: Blob; + try { + blob = await deps.resolver(image.imageName, decodeSignal); + } catch (error) { + if (error instanceof CanvasImageMissingError || decodeSignal?.aborted) { + throw error; + } + throw new ImageUnavailableError(image.imageName, { cause: error }); + } decodeSignal?.throwIfAborted(); const bitmap = await deps.backend.createImageBitmap(blob); if (decodeSignal?.aborted) { 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 index fe0c7725206..d106eca0590 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/index.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/index.ts @@ -13,7 +13,7 @@ import { rasterizeTextSource } from './textRasterizer'; export type { ImageResolver, RasterizeDeps, RasterizeResult } from './types'; export { rasterizeGradientSource } from './gradientRasterizer'; -export { rasterizeImageSource } from './imageRasterizer'; +export { CanvasImageMissingError, rasterizeImageSource } from './imageRasterizer'; export { rasterizePaintSource } from './paintRasterizer'; export { rasterizeShapeSource } from './shapeRasterizer'; export { rasterizeTextSource, textFontString, textFontVariationSettings } from './textRasterizer'; 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 index 5f401f14b63..c176c36de6a 100644 --- 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 @@ -8,6 +8,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { ImageResolver, RasterizeDeps } from './types'; +import { CanvasImageMissingError, ImageUnavailableError } from './imageRasterizer'; import { rasterizeSource } from './index'; const imageRef = (imageName: string, width = 32, height = 16): CanvasImageRef => ({ height, imageName, width }); @@ -35,6 +36,30 @@ const makeDeps = (resolver: ImageResolver, backend: RasterBackend): RasterizeDep store: createLayerCacheStore(backend), }); +describe('rasterizeSource — image resolution failures', () => { + const rasterizeWith = (resolver: ImageResolver, backend = createSpyBackend().backend) => + rasterizeSource({ image: imageRef('cat'), type: 'image' }, makeDeps(resolver, backend)); + + it('passes a missing image on as missing, so the layer reads as unreadable', async () => { + const missing = new CanvasImageMissingError('cat'); + await expect(rasterizeWith(() => Promise.reject(missing))).rejects.toBe(missing); + }); + + it('wraps any other resolver failure as unavailable, keeping the cause', async () => { + const outage = new Error('Failed to load canvas image "cat" (503)'); + const rejection = rasterizeWith(() => Promise.reject(outage)); + await expect(rejection).rejects.toBeInstanceOf(ImageUnavailableError); + await expect(rejection).rejects.toMatchObject({ cause: outage, imageName: 'cat' }); + }); + + it('leaves an image that was fetched but does not decode as a plain failure', async () => { + const { backend } = createSpyBackend(); + const corrupt = new Error('decode failed'); + backend.createImageBitmap = vi.fn(() => Promise.reject(corrupt)); + await expect(rasterizeWith(() => Promise.resolve(new Blob()), backend)).rejects.toBe(corrupt); + }); +}); + describe('rasterizeSource — image', () => { it('gives the shared image resolver a cancellable pool-owned signal', async () => { const { backend } = createSpyBackend(); 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 index 8bddb1647e3..344d3e40706 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/types.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterizers/types.ts @@ -4,7 +4,12 @@ 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 persisted image names to decode blobs; application networking stays behind this injectable seam. */ +/** + * Resolves persisted image names to decode blobs; application networking stays behind this injectable seam. A resolver + * throws {@link CanvasImageMissingError} when the image is missing or not readable by this user, so the layer reads + * as unreadable until its source changes. Any other failure (network, server, an expired session) is taken as + * transient: operations report `not-ready`, and asking again or the display after its backoff tries again. + */ export type ImageResolver = (imageName: string, signal?: AbortSignal) => Promise; /** A text source whose family may be replaced by an injected custom-font alias. */ 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 index c8233afdb0d..a39fca79ca1 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/colorPickerTool.test.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/colorPickerTool.test.ts @@ -188,38 +188,55 @@ describe('color picker tool', () => { expect(h.ctx.stores.brushOptions.get().color).toBe(defaultColor); }); - it('stashes claimed samples without writing brushOptions and commits on release', () => { + it('previews every sample of a pick and commits the last one once on release', () => { const h = createHarness(makeDoc()); const tool = createColorPickerTool(); const defaultColor = h.ctx.stores.brushOptions.get().color; - const claimed: string[] = []; - h.ctx.resolveColorSample = (hex) => { - claimed.push(hex); + const routed: [string, string][] = []; + h.ctx.resolveColorSample = (hex, phase) => { + routed.push([hex, phase]); return true; }; - const commit = vi.fn(); - h.ctx.commitColorSample = commit; down(tool, h.ctx, pointer(10, 10)); - expect(claimed).toEqual(['#0a141e']); - expect(commit).not.toHaveBeenCalled(); + h.pixel.current = [255, 0, 0, 255]; + move(tool, h.ctx, pointer(20, 20)); + up(tool, h.ctx, pointer(20, 20, { buttons: 0 })); + // The next hover move after release samples nothing and settles nothing. + move(tool, h.ctx, pointer(30, 30, { buttons: 0 })); + + expect(routed).toEqual([ + ['#0a141e', 'preview'], + ['#ff0000', 'preview'], + ['#ff0000', 'commit'], + ]); expect(h.ctx.stores.brushOptions.get().color).toBe(defaultColor); - - up(tool, h.ctx, pointer(10, 10, { buttons: 0 })); - expect(commit).toHaveBeenCalledTimes(1); }); - it('discards the stashed sample on gesture cancel and at each fresh press', () => { + it('cancels a pick with its last preview when the gesture is cancelled or the tool deactivates', () => { const h = createHarness(makeDoc()); const tool = createColorPickerTool(); - const discard = vi.fn(); - h.ctx.discardColorSample = discard; + const routed: [string, string][] = []; + h.ctx.resolveColorSample = (hex, phase) => { + routed.push([hex, phase]); + return true; + }; down(tool, h.ctx, pointer(10, 10)); - expect(discard).toHaveBeenCalledTimes(1); - tool.onPointerCancel?.(h.ctx); - expect(discard).toHaveBeenCalledTimes(2); + up(tool, h.ctx, pointer(10, 10, { buttons: 0 })); + expect(routed).toEqual([ + ['#0a141e', 'preview'], + ['#0a141e', 'cancel'], + ]); + + routed.length = 0; + down(tool, h.ctx, pointer(10, 10)); + tool.onDeactivate?.(h.ctx); + expect(routed).toEqual([ + ['#0a141e', 'preview'], + ['#0a141e', 'cancel'], + ]); }); it('never dispatches and never emits a committed stroke', () => { @@ -263,7 +280,7 @@ describe('color picker tool', () => { down(tool, h.ctx, pointer(10, 10)); - expect(resolveColorSample).toHaveBeenCalledWith('#0a141e'); + expect(resolveColorSample).toHaveBeenCalledWith('#0a141e', 'preview'); expect(h.ctx.stores.brushOptions.get().color).toBe(defaultColor); }); @@ -276,7 +293,7 @@ describe('color picker tool', () => { down(tool, h.ctx, pointer(10, 10)); - expect(resolveColorSample).toHaveBeenCalledWith('#0a141e'); + expect(resolveColorSample).toHaveBeenCalledWith('#0a141e', 'preview'); expect(h.ctx.stores.brushOptions.get().color).toBe('#0a141e'); }); @@ -287,6 +304,7 @@ describe('color picker tool', () => { h.ctx.resolveColorSample = resolveColorSample; down(tool, h.ctx, pointer(10, 10)); + up(tool, h.ctx, pointer(10, 10, { buttons: 0 })); expect(resolveColorSample).not.toHaveBeenCalled(); }); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/colorPickerTool.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/colorPickerTool.ts index 87e601f2815..45fc41dbdaa 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/colorPickerTool.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/colorPickerTool.ts @@ -1,7 +1,7 @@ /** - * Shows a loupe of the composited pixels under the pointer, samples while pressed and routes the color to the active - * color target. Alt-hold restoration returns to the prior tool; the picker neither dispatches nor edits pixels. - * State is per engine. + * Shows a loupe of the composited pixels under the pointer, previews samples while pressed and commits the last one + * on release (or cancels it when the press ends any other way) through the active color target. Alt-hold restoration + * returns to the prior tool; the picker neither dispatches nor edits pixels. State is per engine. */ import type { PointerInput } from '@workbench/canvas-engine/types'; @@ -19,18 +19,19 @@ const updateLoupe = (ctx: ToolContext): void => { ctx.showColorLoupe?.(true); }; -/** Offers sampled color to the one-shot claim then workbench router; standalone fallback updates brush color. */ -const pickColorAt = (ctx: ToolContext, sampler: ColorSampler, input: PointerInput): void => { +/** Samples the composite under the pointer as `#rrggbb`, or null off the document. */ +const sampleAt = (ctx: ToolContext, sampler: ColorSampler, input: PointerInput): string | null => { const doc = ctx.getDocument(); if (!doc) { - return; + return null; } const sample = sampler.sample(doc, ctx.layers, input.documentPoint, ctx.sampleProviders); - if (!sample) { - return; - } - const hex = rgbaToHex(sample.r, sample.g, sample.b); - if (ctx.resolveColorSample?.(hex)) { + return sample ? rgbaToHex(sample.r, sample.g, sample.b) : null; +}; + +/** Previews a sample through the one-shot claim then workbench router; standalone fallback updates brush color. */ +const previewColor = (ctx: ToolContext, hex: string): void => { + if (ctx.resolveColorSample?.(hex, 'preview')) { return; } const opts = ctx.stores.brushOptions.get(); @@ -43,9 +44,23 @@ const pickColorAt = (ctx: ToolContext, sampler: ColorSampler, input: PointerInpu export const createColorPickerTool = (): Tool => { // One engine owns the tool and its backend, so the sampler can outlive a gesture. let sampler: ColorSampler | null = null; + // The last color this press previewed; a pick commits or cancels only after previewing something. + let pickedHex: string | null = null; const pick = (ctx: ToolContext, input: PointerInput): void => { sampler ??= createColorSampler(ctx.backend); - pickColorAt(ctx, sampler, input); + const hex = sampleAt(ctx, sampler, input); + if (hex !== null) { + pickedHex = hex; + previewColor(ctx, hex); + } + }; + /** Ends the pick once: release commits the last preview, anything else cancels it. */ + const settle = (ctx: ToolContext, phase: 'commit' | 'cancel'): void => { + const hex = pickedHex; + pickedHex = null; + if (hex !== null) { + ctx.resolveColorSample?.(hex, phase); + } }; return { // The loupe's boxed center pixel is the target; a system cursor would cover it. @@ -54,16 +69,17 @@ export const createColorPickerTool = (): Tool => { // A pointer already over the canvas (an Alt hold, a pick request) gets the loupe without moving. onActivate: (ctx) => updateLoupe(ctx), onDeactivate: (ctx) => { + settle(ctx, 'cancel'); ctx.showColorLoupe?.(false); }, onPointerCancel: (ctx) => { - ctx.discardColorSample?.(); + settle(ctx, 'cancel'); }, onPointerDown: (ctx, input) => { if ((input.buttons & PRIMARY_BUTTON) === 0) { return; } - ctx.discardColorSample?.(); + settle(ctx, 'cancel'); updateLoupe(ctx); pick(ctx, input); }, @@ -75,7 +91,7 @@ export const createColorPickerTool = (): Tool => { }, onPointerUp: (ctx) => { updateLoupe(ctx); - ctx.commitColorSample?.(); + settle(ctx, 'commit'); }, }; }; 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 index dc71e212ab5..23061a1bb72 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/paintTool.test.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/paintTool.test.ts @@ -2,6 +2,7 @@ import type { CanvasControlLayerContract, CanvasDocumentContractV3, CanvasLayerContract, + CanvasRasterLayerContractV2, } from '@workbench/canvas-engine/contracts'; import type { StubRasterBackend, StubRasterSurface } from '@workbench/canvas-engine/render/raster.testStub'; import type { @@ -51,6 +52,21 @@ const imageLayer = (id: string): CanvasLayerContract => ({ type: 'raster', }); +/** A raster the brush cannot edit in place, so a stroke auto-creates a paint layer above it. */ +const textLayer = (id: string): CanvasLayerContract => ({ + ...(imageLayer(id) as CanvasRasterLayerContractV2), + source: { + align: 'left', + color: '#000000', + content: id, + fontFamily: 'sans-serif', + fontSize: 12, + fontWeight: 400, + lineHeight: 1.2, + type: 'text', + }, +}); + const inpaintMaskLayer = (id: string): CanvasLayerContract => ({ blendMode: 'normal', id, @@ -100,10 +116,14 @@ const makeDoc = (layers: CanvasLayerContract[], selectedLayerId: string | null): width: 100, }); -const pointer = (x: number, y: number, opts: { pressure?: number; buttons?: number } = {}): PointerInput => ({ +const pointer = ( + x: number, + y: number, + opts: { pressure?: number; buttons?: number; shift?: boolean } = {} +): PointerInput => ({ buttons: opts.buttons ?? 1, documentPoint: { x, y }, - modifiers: { alt: false, ctrl: false, meta: false, shift: false }, + modifiers: { alt: false, ctrl: false, meta: false, shift: opts.shift ?? false }, pointerType: 'mouse', pressure: opts.pressure ?? 0.5, screenPoint: { x, y }, @@ -328,20 +348,67 @@ describe('eraser tool', () => { expect(erasedHeight(0.1, false)).toBe(erasedHeight(1, false)); }); - it('edits a selected image layer through a materializing pixel transaction', () => { + it.each([ + ['eraser', createEraserTool], + ['brush', createBrushTool], + ] as const)('%s edits a selected image layer through a materializing pixel transaction', (_, createTool) => { const transaction = controlTransaction('img1'); const h = createHarness(makeDoc([imageLayer('img1')], 'img1'), transaction); - const eraser = createEraserTool(); + h.layers.getOrCreateRect('img1', { height: 10, width: 10, x: 0, y: 0 }).stale = false; + const tool = createTool(); - down(eraser, h.ctx, pointer(2, 2)); - move(eraser, h.ctx, pointer(6, 6), [pointer(6, 6)]); - up(eraser, h.ctx, pointer(6, 6)); + down(tool, h.ctx, pointer(2, 2)); + move(tool, h.ctx, pointer(6, 6), [pointer(6, 6)]); + up(tool, h.ctx, pointer(6, 6)); expect(h.beginPixelEdit).toHaveBeenCalledWith('img1'); expect(transaction.commit).toHaveBeenCalledOnce(); expect(h.dispatched).toHaveLength(0); expect(h.createdIds).toHaveLength(0); }); + + it.each([ + ['locked', { isLocked: true }, true], + ['disabled', { isEnabled: false }, true], + ['not rasterized yet', {}, false], + ] as const)('brushes a %s image layer onto an auto-created paint layer instead', (_, overrides, cached) => { + const transaction = controlTransaction('img1'); + const h = createHarness( + makeDoc([{ ...imageLayer('img1'), ...overrides } as CanvasLayerContract], 'img1'), + transaction + ); + if (cached) { + h.layers.getOrCreateRect('img1', { height: 10, width: 10, x: 0, y: 0 }).stale = false; + } + const brush = createBrushTool(); + + down(brush, h.ctx, pointer(2, 2)); + up(brush, h.ctx, pointer(2, 2)); + + expect(h.beginPixelEdit).not.toHaveBeenCalled(); + expect(h.dispatched.map((action) => action.type)).toEqual(['addCanvasLayer']); + expect(h.strokes.map((stroke) => stroke.layerId)).toEqual([h.createdIds[0]]); + }); + + it('brushes a transparency-locked image layer in place with source-atop, but refuses to erase it', () => { + const locked = { ...imageLayer('img1'), isTransparencyLocked: true } as CanvasLayerContract; + const transaction = controlTransaction('img1'); + const h = createHarness(makeDoc([locked], 'img1'), transaction); + h.layers.getOrCreateRect('img1', { height: 100, width: 100, x: 0, y: 0 }).stale = false; + + const eraser = createEraserTool(); + down(eraser, h.ctx, pointer(2, 2)); + up(eraser, h.ctx, pointer(2, 2)); + expect(h.beginPixelEdit).not.toHaveBeenCalled(); + + const brush = createBrushTool(); + down(brush, h.ctx, pointer(2, 2)); + move(brush, h.ctx, pointer(6, 6), [pointer(6, 6)]); + expect(lastCompositeOp(cacheSurface(h, 'img1'))).toBe('source-atop'); + up(brush, h.ctx, pointer(6, 6)); + expect(transaction.commit).toHaveBeenCalledOnce(); + expect(h.dispatched).toHaveLength(0); + }); }); describe('brush tool: cancel', () => { @@ -529,7 +596,7 @@ 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 doc = makeDoc([textLayer('img1')], 'img1'); const h = createHarness(doc); const brush = createBrushTool(); @@ -714,7 +781,7 @@ describe('auto-created layer rollback (a gesture that commits nothing leaves no const kinds = (h: { dispatched: CanvasProjectMutation[] }): string[] => h.dispatched.map((action) => action.type); it('removes the layer and restores the prior selection when the stroke is fully clipped away', () => { - const h = clipped(makeDoc([imageLayer('img1'), paintLayer('p1')], 'img1')); + const h = clipped(makeDoc([textLayer('img1'), paintLayer('p1')], 'img1')); const brush = createBrushTool(); down(brush, h.ctx, pointer(80, 80)); @@ -731,7 +798,7 @@ describe('auto-created layer rollback (a gesture that commits nothing leaves no }); it('drops the provisional cache entry too', () => { - const h = clipped(makeDoc([imageLayer('img1')], 'img1')); + const h = clipped(makeDoc([textLayer('img1')], 'img1')); const brush = createBrushTool(); down(brush, h.ctx, pointer(80, 80)); @@ -742,7 +809,7 @@ describe('auto-created layer rollback (a gesture that commits nothing leaves no }); it('rolls back on pointercancel mid-drag', () => { - const h = createHarness(makeDoc([imageLayer('img1')], 'img1')); + const h = createHarness(makeDoc([textLayer('img1')], 'img1')); const brush = createBrushTool(); down(brush, h.ctx, pointer(20, 20)); @@ -755,7 +822,7 @@ describe('auto-created layer rollback (a gesture that commits nothing leaves no }); it('rolls back when the tool is switched away mid-drag', () => { - const h = createHarness(makeDoc([imageLayer('img1')], 'img1')); + const h = createHarness(makeDoc([textLayer('img1')], 'img1')); const brush = createBrushTool(); down(brush, h.ctx, pointer(20, 20)); @@ -766,7 +833,7 @@ describe('auto-created layer rollback (a gesture that commits nothing leaves no }); it('does NOT roll back for a temporary tool switch (space/alt hold keeps the gesture)', () => { - const h = createHarness(makeDoc([imageLayer('img1')], 'img1')); + const h = createHarness(makeDoc([textLayer('img1')], 'img1')); const brush = createBrushTool(); down(brush, h.ctx, pointer(20, 20)); @@ -789,7 +856,7 @@ describe('auto-created layer rollback (a gesture that commits nothing leaves no }); it('keeps the layer when the stroke DOES commit pixels', () => { - const h = createHarness(makeDoc([imageLayer('img1')], 'img1')); + const h = createHarness(makeDoc([textLayer('img1')], 'img1')); const brush = createBrushTool(); down(brush, h.ctx, pointer(20, 20)); @@ -810,3 +877,138 @@ describe('auto-created layer rollback (a gesture that commits nothing leaves no expect(h.dispatched).toHaveLength(0); }); }); + +describe('Shift-click straight lines', () => { + /** + * A harness with an engine-owned line memory and a path recorder: `span()` is the horizontal extent of the stroke + * outlines traced since the last call, so a dab and a line read apart regardless of cache growth chunks. + */ + const lineHarness = (layers: CanvasLayerContract[], selectedLayerId: string | null) => { + const h = createHarness(makeDoc(layers, selectedLayerId)); + const paintLineEnds = new Map(); + let xs: number[] = []; + const record = (x: number): void => { + xs.push(x); + }; + const ctx: ToolContext = { + ...h.ctx, + createPath2D: () => + ({ + closePath: () => {}, + lineTo: record, + moveTo: record, + quadraticCurveTo: (_cx: number, _cy: number, x: number) => record(x), + }) as unknown as Path2D, + paintLineEnds, + }; + ctx.stores.brushOptions.set({ ...ctx.stores.brushOptions.get(), size: 4 }); + ctx.stores.eraserOptions.set({ ...ctx.stores.eraserOptions.get(), size: 4 }); + const span = (): number => { + const extent = Math.max(...xs) - Math.min(...xs); + xs = []; + return extent; + }; + return { ...h, ctx, paintLineEnds, span }; + }; + + it('draws from the last stroke end to a Shift press, as one committed stroke', () => { + const h = lineHarness([paintLayer('paint1')], 'paint1'); + const brush = createBrushTool(); + + down(brush, h.ctx, pointer(10, 10)); + up(brush, h.ctx, pointer(10, 10)); + expect(h.paintLineEnds.get('paint1')).toEqual({ x: 10, y: 10 }); + // A 4 px dab is about one brush wide. + expect(h.span()).toBeLessThanOrEqual(5); + + down(brush, h.ctx, pointer(60, 10, { shift: true })); + up(brush, h.ctx, pointer(60, 10, { shift: true })); + + expect(h.strokes).toHaveLength(2); + // The line runs 50 px from x=10 to x=60, plus the brush radius at each end. + expect(h.span()).toBeGreaterThanOrEqual(50); + expect(h.paintLineEnds.get('paint1')).toEqual({ x: 60, y: 10 }); + }); + + it('keeps the line start on the layer when the layer moves', () => { + const layer = paintLayer('paint1'); + const h = lineHarness([layer], 'paint1'); + const brush = createBrushTool(); + down(brush, h.ctx, pointer(10, 10)); + up(brush, h.ctx, pointer(10, 10)); + h.span(); + + // Nudge the layer 100 px right: its last stroke end now sits at document x=110. + const nudged = makeDoc([{ ...layer, transform: { ...layer.transform, x: 100 } }], 'paint1'); + const ctx: ToolContext = { ...h.ctx, getDocument: () => nudged }; + down(brush, ctx, pointer(160, 10, { shift: true })); + up(brush, ctx, pointer(160, 10, { shift: true })); + + // 50 px from x=110 to x=160 plus the brush radius at each end, not 150 px from where the end used to be. + const span = h.span(); + expect(span).toBeGreaterThanOrEqual(50); + expect(span).toBeLessThan(60); + expect(h.paintLineEnds.get('paint1')).toEqual({ x: 60, y: 10 }); + }); + + it('paints a dab on a Shift press without a remembered point, or on a layer with none', () => { + const h = lineHarness([paintLayer('paint1'), paintLayer('paint2')], 'paint2'); + h.paintLineEnds.set('paint1', { x: 10, y: 10 }); + const brush = createBrushTool(); + + down(brush, h.ctx, pointer(60, 10, { shift: true })); + up(brush, h.ctx, pointer(60, 10, { shift: true })); + + expect(h.strokes).toHaveLength(1); + expect(h.span()).toBeLessThanOrEqual(5); + }); + + it('ignores the memory on a press without Shift', () => { + const h = lineHarness([paintLayer('paint1')], 'paint1'); + h.paintLineEnds.set('paint1', { x: 10, y: 10 }); + const brush = createBrushTool(); + + down(brush, h.ctx, pointer(60, 10)); + up(brush, h.ctx, pointer(60, 10)); + + expect(h.span()).toBeLessThanOrEqual(5); + }); + + it('shares one point per layer between brush and eraser', () => { + const h = lineHarness([paintLayer('paint1')], 'paint1'); + h.layers.getOrCreateRect('paint1', { height: 100, width: 100, x: 0, y: 0 }).stale = false; + const brush = createBrushTool(); + const eraser = createEraserTool(); + + down(brush, h.ctx, pointer(10, 10)); + up(brush, h.ctx, pointer(10, 10)); + // Switching tools keeps the memory; only the engine's lifecycle events forget it. + brush.onDeactivate?.(h.ctx, undefined); + h.span(); + down(eraser, h.ctx, pointer(60, 10, { shift: true })); + up(eraser, h.ctx, pointer(60, 10, { shift: true })); + + expect(h.strokes.map((stroke) => stroke.tool)).toEqual(['brush', 'eraser']); + expect(h.span()).toBeGreaterThanOrEqual(50); + }); + + it('remembers the end of a stroke that auto-created its layer under the new id', () => { + const h = lineHarness([textLayer('text1')], 'text1'); + const brush = createBrushTool(); + + down(brush, h.ctx, pointer(20, 20)); + up(brush, h.ctx, pointer(25, 20)); + + expect([...h.paintLineEnds]).toEqual([[h.createdIds[0], { x: 25, y: 20 }]]); + }); + + it('remembers nothing for a cancelled stroke', () => { + const h = lineHarness([paintLayer('paint1')], 'paint1'); + const brush = createBrushTool(); + + down(brush, h.ctx, pointer(20, 20)); + cancel(brush, h.ctx); + + expect(h.paintLineEnds.size).toBe(0); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/paintTool.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/paintTool.ts index c5d6dd52b4a..080f359b526 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/paintTool.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/paintTool.ts @@ -1,18 +1,19 @@ /** * Shared brush/eraser gesture resolves or creates a target, drives a StrokeSession and commits/cancels. Only * gesture-start layer creation dispatches here; moves never dispatch. Fill/erase differ by blend and option - * source. + * source. A Shift press on a layer with a remembered stroke end draws a straight segment from it; the end is kept + * layer-local, so moving or transforming the layer carries it. */ import type { CanvasLayerContract, CanvasRasterLayerContractV2 } from '@workbench/canvas-engine/contracts'; import type { CanvasNodeInsertionAnchor } from '@workbench/canvas-engine/document/insertionAnchors'; -import type { PointerInput } from '@workbench/canvas-engine/types'; +import type { Mat2d, PointerInput } from '@workbench/canvas-engine/types'; import { lookupDocumentLeaf } from '@workbench/canvas-engine/document-model/documentModel'; import { getDocumentLeaves } from '@workbench/canvas-engine/document/documentIndex'; import { isLeafPaintable, isLayerTransparencyLocked } from '@workbench/canvas-engine/document/layerEligibility'; import { isMaskLayer } from '@workbench/canvas-engine/document/sources'; -import { fromTRS, invert } from '@workbench/canvas-engine/math/mat2d'; +import { applyToPoint, fromTRS, invert } from '@workbench/canvas-engine/math/mat2d'; import type { StrokeEdit, Tool, ToolContext } from './tool'; @@ -77,6 +78,12 @@ const editTarget = ( ...extra, }); +/** The target layer's document matrix; null is identity (a new or materialized layer). */ +const targetMatrix = ({ transform }: PaintTarget): Mat2d | null => + transform + ? fromTRS({ x: transform.x, y: transform.y }, transform.rotation, transform.scaleX, transform.scaleY) + : null; + /** Resolves (or auto-creates) the paint target for a gesture, or `null` to no-op. */ const resolveTarget = (ctx: ToolContext, tool: PaintToolSpec['id']): PaintTarget | null => { const doc = ctx.getDocument(); @@ -112,17 +119,29 @@ const resolveTarget = (ctx: ToolContext, tool: PaintToolSpec['id']): PaintTarget : null; } - if (leaf && selected?.type === 'raster' && selected.source.type === 'image' && tool === 'eraser') { - // Erase materializes image pixels as an undoable paint layer in place; locked, disabled or unready images - // refuse without spawning. + const imageSelected = leaf && selected?.type === 'raster' && selected.source.type === 'image'; + if (imageSelected && tool === 'eraser') { + // Erasing materializes image pixels as an undoable paint layer in place; locked, disabled, unready or + // transparency-locked images refuse without spawning. if (!isLeafPaintable(leaf) || isLayerTransparencyLocked(selected)) { return null; } const transaction = ctx.beginPixelEdit?.(selected.id) ?? null; + // No transform on purpose: see the brush branch below. + return transaction ? editTarget(transaction, transaction.layerId) : null; + } + const imageCache = imageSelected ? ctx.layers.peek(selected.id) : undefined; + if (imageSelected && isLeafPaintable(leaf) && imageCache && !imageCache.stale) { + // The brush paints a paintable, rasterized image in place the same way, under its transparency lock; a locked, + // disabled or not yet rasterized image keeps the brush's auto-created paint layer below. A rasterized image whose + // spatial adjustments have not landed refuses `not-ready` (or names the stack that cannot run) and drops the + // stroke with a notice, as the eraser does. + const transparencyLocked = isLayerTransparencyLocked(selected); + const transaction = ctx.beginPixelEdit?.(selected.id) ?? null; // No transform on purpose: materialization BAKES the layer's placement // into document-space pixels and resets the layer to identity, so the // session's document coordinates already are the cache's coordinates. - return transaction ? editTarget(transaction, transaction.layerId) : null; + return transaction ? editTarget(transaction, transaction.layerId, { transparencyLocked }) : null; } if (leaf && selected && isMaskLayer(selected)) { @@ -256,14 +275,7 @@ export const createPaintTool = (spec: PaintToolSpec): Tool => { return; } const composite = resolvedTarget.transparencyLocked && spec.id === 'brush' ? 'source-atop' : spec.composite; - const layerTransform = resolvedTarget.transform - ? fromTRS( - { x: resolvedTarget.transform.x, y: resolvedTarget.transform.y }, - resolvedTarget.transform.rotation, - resolvedTarget.transform.scaleX, - resolvedTarget.transform.scaleY - ) - : null; + const layerTransform = targetMatrix(resolvedTarget); if (layerTransform && !invert(layerTransform)) { // A zero-scale layer has no paintable geometry to invert into. resolvedTarget.cancel(); @@ -297,7 +309,11 @@ export const createPaintTool = (spec: PaintToolSpec): Tool => { thinning: spec.thinning(ctx), tool: spec.id, }); - session.addPoints([input]); + // Shift continues from the layer's last stroke end, placed through the layer's current transform: both points + // take the press pressure, so the segment renders as a line rather than collapsing to a tap. + const localStart = input.modifiers.shift ? ctx.paintLineEnds?.get(target.layerId) : undefined; + const lineStart = localStart && layerTransform ? applyToPoint(layerTransform, localStart) : localStart; + session.addPoints(lineStart ? [{ ...input, documentPoint: lineStart }, input] : [input]); } catch { abortSession(); } @@ -326,7 +342,15 @@ export const createPaintTool = (spec: PaintToolSpec): Tool => { throw error; } endSession(); - if (!published) { + if (published) { + // Kept layer-local, in the space the stroke painted; the press already refused a non-invertible matrix. + const matrix = targetMatrix(activeTarget); + const toLocal = matrix ? invert(matrix) : null; + ctx.paintLineEnds?.set( + activeTarget.layerId, + toLocal ? applyToPoint(toLocal, input.documentPoint) : { ...input.documentPoint } + ); + } else { activeTarget.cancel(); } } diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/strokeSession.browser.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/strokeSession.browser.test.ts index f7fd69bbe78..8a9c390b080 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/strokeSession.browser.test.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/strokeSession.browser.test.ts @@ -332,3 +332,27 @@ describe('dense stylus trace', () => { await annotate(`dense stroke ms: every batch ${everyBatch.ms}, per frame ${perFrame.ms}`); }); }); + +describe('two-point seed (Shift-click line)', () => { + const alphaAt = ({ pixels, rect }: { pixels: Uint8ClampedArray; rect: Rect }, x: number, y: number): number => { + if (x < rect.x || y < rect.y || x >= rect.x + rect.width || y >= rect.y + rect.height) { + return 0; + } + return pixels[((y - rect.y) * rect.width + (x - rect.x)) * 4 + 3]!; + }; + + it('rasterizes a capsule between two seeded points past the tap threshold, not a dab', () => { + const options = { opacity: 1, size: 6, thinning: 0 }; + const line = paint([pointer(10, 20), pointer(60, 20)], 2, options); + const dab = paint([pointer(10, 20)], 1, options); + + // Covered along the whole segment and at both ends, but not beyond the brush radius. + for (const x of [10, 25, 35, 45, 60]) { + expect(alphaAt(line, x, 20)).toBe(255); + } + expect(alphaAt(line, 35, 26)).toBe(0); + expect(alphaAt(line, 66, 20)).toBe(0); + expect(alphaAt(dab, 10, 20)).toBe(255); + expect(alphaAt(dab, 25, 20)).toBe(0); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/tool.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/tool.ts index 7e545b3c660..89f5fd35857 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/tool.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/tool.ts @@ -4,7 +4,7 @@ * StrokeCommittedEvent} for downstream history/persistence. */ -import type { StructuralCommitResult } from '@workbench/canvas-engine/capabilities'; +import type { ColorSamplePhase, StructuralCommitResult } from '@workbench/canvas-engine/capabilities'; import type { CanvasDocumentContractV3, CanvasLayerContract } from '@workbench/canvas-engine/contracts'; import type { CanvasNodeInsertionAnchor } from '@workbench/canvas-engine/document/insertionAnchors'; import type { LayerStackKind } from '@workbench/canvas-engine/document/layerStacks'; @@ -77,6 +77,13 @@ export interface LayerTransformOverride { rotation?: number; } +/** Where the last committed brush or eraser stroke ended, per layer id, in that layer's local space. */ +export interface PaintLineEnds { + get(layerId: string): Vec2 | undefined; + set(layerId: string, point: Vec2): void; + clear(): void; +} + /** Everything a tool is allowed to reach, injected by the engine. */ export interface ToolContext { /** The pan/zoom viewport. */ @@ -135,20 +142,20 @@ export interface ToolContext { /** Shows or hides the color picker's loupe, which follows the pointer while shown; optional in test harnesses. */ showColorLoupe?(shown: boolean): void; /** - * Route samples to one-shot claim, then persistent workbench target. Return whether consumed; otherwise picker - * falls back to brush color. Optional in test harnesses. + * Routes a pick's samples to the one-shot claim, else the persistent workbench target: `preview` per sample, then + * one `commit` (pointer up only, so a structural commit in the requester's continuation is not refused as + * `gesture-active`) or `cancel`. Returns whether consumed; a declined preview falls back to brush color. Optional + * in test harnesses. */ - resolveColorSample?(hex: string): boolean; + resolveColorSample?(hex: string, phase: ColorSamplePhase): boolean; /** Compositor providers that make sampling WYSIWYG; absent ⇒ raw cached pixels. */ sampleProviders?: Pick; /** - * Settles a pending one-shot sample with the gesture's stashed color. Called - * on pointer up only, so a structural commit in the requester's continuation - * is not refused as `gesture-active`. + * Where the last committed brush or eraser stroke ended on each layer, layer-local so moving or transforming the + * layer carries it; shared by both tools for Shift-click lines. The engine owns it and forgets entries on replay, + * document replacement and layer removal. */ - commitColorSample?(): void; - /** Drops the stashed sample (gesture cancel, fresh press) without settling the request. */ - discardColorSample?(): void; + paintLineEnds?: PaintLineEnds; /** * Refresh the input element cursor when tool state changes; ordinary pointermove does not automatically * reevaluate it. diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/transformTool.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/transformTool.ts index 5b2c2bdf3c8..aa906733ac3 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/transformTool.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/transformTool.ts @@ -9,9 +9,9 @@ * down. */ -import type { CanvasLayerContract } from '@workbench/canvas-engine/contracts'; +import type { CanvasDocumentContractV3, CanvasLayerContract } from '@workbench/canvas-engine/contracts'; import type { LayerTransform, TransformRect, TransformTarget } from '@workbench/canvas-engine/transform/transformMath'; -import type { PointerInput, Vec2 } from '@workbench/canvas-engine/types'; +import type { PointerInput, Rect, Vec2 } from '@workbench/canvas-engine/types'; import { lookupDocumentLeaf } from '@workbench/canvas-engine/document-model/documentModel'; import { getDocumentLayer } from '@workbench/canvas-engine/document/documentIndex'; @@ -81,19 +81,27 @@ interface TransformSubject { const identityPoint = (point: Vec2): Vec2 => point; +/** + * Whether a transform session may open on `layer`: an editable, non-empty leaf that is not a mask. `liveRect` also + * counts pixels the live cache holds but persistence has not acknowledged yet. + */ +export const isTransformEligible = ( + layer: CanvasLayerContract, + doc: CanvasDocumentContractV3, + liveRect?: Rect +): boolean => + // Masks lack an applyTransform bake path, so reject sessions that would preview but fail to apply. + isLeafEditable(lookupDocumentLeaf(doc, layer.id)) && + layer.type !== 'inpaint_mask' && + layer.type !== 'regional_guidance' && + hittableLayerRect(layer, doc, liveRect) !== null; + /** 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 lack an applyTransform bake path, so reject sessions that would preview but fail to apply. - isLeafEditable(lookupDocumentLeaf(doc, layer.id)) && - layer.type !== 'inpaint_mask' && - layer.type !== 'regional_guidance' && - hittableLayerRect(layer, doc) !== null; - /** Live floats take precedence over layer sessions so the frame follows detached pixels. */ const subjectOf = (ctx: ToolContext): TransformSubject | null => { const doc = ctx.getDocument(); @@ -219,7 +227,7 @@ export const createTransformTool = (): Tool => { const doc = ctx.getDocument(); const selectedId = doc?.selectedLayerId; const selected = selectedId ? getDocumentLayer(doc, selectedId) : undefined; - if (doc && selected && isEligible(selected, doc)) { + if (doc && selected && isTransformEligible(selected, doc)) { ctx.beginTransformSession?.(selected.id); } }, @@ -303,7 +311,7 @@ export const createTransformTool = (): Tool => { // Open an unframed session on panel selection and begin movement without retargeting. const selectedId = doc.selectedLayerId; const selected = selectedId ? getDocumentLayer(doc, selectedId) : undefined; - if (!selected || !isEligible(selected, doc)) { + if (!selected || !isTransformEligible(selected, doc)) { return; } const rect = hittableLayerRect(selected, doc); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/SelectObjectCoordinator.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/SelectObjectCoordinator.test.ts new file mode 100644 index 00000000000..1170641317d --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/SelectObjectCoordinator.test.ts @@ -0,0 +1,78 @@ +import type { CanvasRasterLayerContractV2 } from '@workbench/canvas-engine/contracts'; +import type { LayerExportGuard } from '@workbench/canvas-engine/engine'; + +import { createCanvasOperationController } from '@workbench/canvas-operations/operationController'; +import { createCanvasOperationStores } from '@workbench/canvas-operations/operationStores'; +import { describe, expect, it, vi } from 'vitest'; + +import type { CanvasSelectObjectCoordinatorDeps } from './contracts'; + +import { createSelectObjectOperationCoordinator } from './SelectObjectCoordinator'; + +const layer: CanvasRasterLayerContractV2 = { + blendMode: 'normal', + id: 'source', + isEnabled: true, + isLocked: false, + name: 'Source', + opacity: 1, + source: { fill: '#fff', height: 12, kind: 'rect', stroke: null, strokeWidth: 0, type: 'shape', width: 16 }, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + type: 'raster', +}; +const guard: LayerExportGuard = { cacheVersion: 1, documentGeneration: 1, layer, layerId: layer.id, projectId: 'p1' }; + +const createCoordinator = (readAutoProcessPreference: () => boolean) => { + const stores = createCanvasOperationStores(); + const deps: CanvasSelectObjectCoordinatorDeps = { + captureGuard: () => guard, + clearOtherOperation: vi.fn(), + clearPreview: vi.fn(), + commitGenerated: vi.fn(), + commitMask: vi.fn(), + controller: createCanvasOperationController({ isGuardCurrent: (candidate) => candidate === guard }), + decodePreview: vi.fn(), + exportSource: vi.fn(), + invalidateOverlay: vi.fn(), + isGuardCurrent: (candidate) => candidate === guard, + isInteractionLocked: () => false, + isSamToolActive: () => true, + prepareStart: () => ({ + guard, + layerId: layer.id, + layerName: layer.name, + layerType: 'raster', + sourceRect: { height: 12, width: 16, x: 0, y: 0 }, + status: 'ready', + }), + projectId: 'p1', + publishPreview: vi.fn(), + readAutoProcessPreference, + replaceSelection: vi.fn(), + replaceTemporaryRestoreTool: vi.fn(), + runGraph: vi.fn(), + selectLayer: vi.fn(), + setSamTool: vi.fn(), + setViewTool: vi.fn(), + stores, + uploadIntermediate: vi.fn(), + }; + return { coordinator: createSelectObjectOperationCoordinator(deps), stores }; +}; + +describe('createSelectObjectOperationCoordinator', () => { + it('starts each session from the Auto Process preference as it stands at that start', () => { + let preference = true; + const { coordinator, stores } = createCoordinator(() => preference); + + expect(coordinator.start(layer.id)).toBe('started'); + expect(stores.samSession.get()?.autoProcess).toBe(true); + + coordinator.cancel(); + preference = false; + + expect(coordinator.start(layer.id)).toBe('started'); + expect(stores.samSession.get()?.autoProcess).toBe(false); + coordinator.cancel(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/SelectObjectCoordinator.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/SelectObjectCoordinator.ts index fa8c23587d3..e496c27ab77 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-operations/SelectObjectCoordinator.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/SelectObjectCoordinator.ts @@ -224,6 +224,7 @@ export const createSelectObjectOperationCoordinator = ( runGraph: deps.runGraph, uploadIntermediate: deps.uploadIntermediate, }, + initialAutoProcess: deps.readAutoProcessPreference?.() ?? false, operation, }); const installed = session; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/contracts.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/contracts.ts index a58590347e0..52f80d62095 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-operations/contracts.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/contracts.ts @@ -206,6 +206,8 @@ export interface CanvasSelectObjectCoordinatorDeps { readonly controller: CanvasOperationController; readonly projectId: string; isInteractionLocked(): boolean; + /** Reads the account's Auto Process preference when a session starts; absent means off. */ + readAutoProcessPreference?(): boolean; prepareStart(layerId: string): SelectObjectStartContext; selectLayer(layerId: string): void; clearOtherOperation(): void; @@ -394,6 +396,7 @@ export interface CreateSelectObjectSessionOptions { cleanupPreview(): void; isGuardCurrent(guard: LayerExportGuard): boolean; }; + initialAutoProcess?: boolean; operation: CanvasOperationSession; } diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/controlSourceActions.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/controlSourceActions.ts index 3ff6e42270c..395424438f3 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-operations/controlSourceActions.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/controlSourceActions.ts @@ -4,6 +4,7 @@ import type { CanvasDocumentCapability, CanvasDocumentContractV3, CanvasExportCapability, + LayerUnreadable, } from '@workbench/canvas-engine/api'; import type { CanvasProjectMutation } from '@workbench/canvasProjectMutations'; import type { Project } from '@workbench/projectContracts'; @@ -255,7 +256,8 @@ export const resolveControlFileDrop = (input: { export type FillControlFromBboxResult = | { status: 'applied'; result: ControlLayerIntentResult; imageName: string } | { status: 'empty' | 'stale' | 'not-ready' | 'over-budget' } - | AdjustmentRefusal; + | AdjustmentRefusal + | LayerUnreadable; /** * Uses the visible raster content inside the bbox as a control layer's image: the composite is uploaded as a control diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/createCanvasEngine.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/createCanvasEngine.ts index 3e0d6a43104..80a3f26de45 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-operations/createCanvasEngine.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/createCanvasEngine.ts @@ -30,6 +30,8 @@ export interface CanvasEngineOptions extends Omit< */ ensureProjectOnServer(): Promise; getMainModelBase?: () => string | null; + /** The account's Select Object Auto Process preference; the widget layer injects the settings read. */ + readSelectObjectAutoProcess?: () => boolean; selectObjectDeps?: { uploadIntermediate(blob: Blob, signal?: AbortSignal): Promise<{ height: number; imageName: string; width: number }>; runGraph(options: { @@ -70,7 +72,14 @@ export type CanvasEngine = CoreCanvasEngineImplementation; /** Application composition root: owns SAM/filter sessions, queues, uploads, and their core capability adapters. */ export const createCanvasEngine = (options: CanvasEngineOptions): CanvasEngine => { - const { controlDetectionDeps, ensureProjectOnServer, filterDeps, selectObjectDeps, ...coreOptions } = options; + const { + controlDetectionDeps, + ensureProjectOnServer, + filterDeps, + readSelectObjectAutoProcess, + selectObjectDeps, + ...coreOptions + } = options; const owner = captureAccountScope(); const uploadLifetime = new AbortController(); let projectMissingAt = -Infinity; @@ -179,6 +188,7 @@ export const createCanvasEngine = (options: CanvasEngineOptions): CanvasEngine = isSamToolActive: host.isSamToolActive, prepareStart: host.prepareSelectObjectStart, projectId: options.projectId, + readAutoProcessPreference: readSelectObjectAutoProcess, publishPreview: (preview) => host.publishSamPreview(preview), replaceSelection: (preview, signal) => host.replaceSelection(preview.guard, preview.image, preview.rect, signal), replaceTemporaryRestoreTool: host.replaceTemporaryRestoreTool, diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/createFromBbox.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/createFromBbox.ts index 2b1245c0a51..28def4a45b0 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-operations/createFromBbox.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/createFromBbox.ts @@ -1,5 +1,5 @@ import type { GalleryImage } from '@features/gallery'; -import type { AdjustmentRefusal, CanvasLayerCapability } from '@workbench/canvas-engine/api'; +import type { AdjustmentRefusal, CanvasLayerCapability, LayerUnreadable } from '@workbench/canvas-engine/api'; import type { CanvasImageUploadResult } from '@workbench/canvas-engine/document/imageUpload'; import type { CanvasProjectMutation } from '@workbench/canvasProjectMutations'; import type { Project } from '@workbench/projectContracts'; @@ -29,6 +29,7 @@ export type CreateFromBboxResult = | { status: 'created'; destination: CreateFromBboxLayerDestination; layerIds: string[] } | { status: 'uploaded'; image: CanvasImageUploadResult } | { status: 'empty' | 'stale' | 'not-ready' | 'over-budget' } + | LayerUnreadable | AdjustmentRefusal | { status: 'blocked' | 'stale-document' | 'stale-project' }; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/createFromBboxNotice.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/createFromBboxNotice.test.ts index 4bd383497e5..3aa7cc130a6 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-operations/createFromBboxNotice.test.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/createFromBboxNotice.test.ts @@ -27,6 +27,14 @@ describe('getCreateFromBboxNotice', () => { expect(getCreateFromBboxNotice({ status })).toEqual({ kind, titleKey }); }); + it('names the layer that could not be read', () => { + expect(getCreateFromBboxNotice({ layerId: 'layer-7', layerName: 'Sky', status: 'unreadable' })).toEqual({ + kind: 'error', + options: { name: 'Sky' }, + titleKey: 'widgets.canvas.contextMenu.unreadable', + }); + }); + it('passes an adjustment refusal on as an error for the view to word', () => { const refusal = { ownerId: 'g', ownerName: 'Sky', reason: 'too-large', status: 'adjustment-refused' } as const; expect(getCreateFromBboxNotice(refusal)).toEqual({ kind: 'error', refusal }); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/createFromBboxNotice.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/createFromBboxNotice.ts index dd9349b2e10..a465f14dd5f 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-operations/createFromBboxNotice.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/createFromBboxNotice.ts @@ -14,9 +14,10 @@ export type CreateFromBboxNotice = | 'widgets.canvas.contextMenu.createdFromBbox' | 'widgets.canvas.contextMenu.empty' | 'widgets.canvas.contextMenu.notReady' - | 'widgets.canvas.contextMenu.stale'; - /** `destination` is itself a translation key naming the created entity. */ - options?: CanvasImportNotice['options'] | { destination: string }; + | 'widgets.canvas.contextMenu.stale' + | 'widgets.canvas.contextMenu.unreadable'; + /** `destination` is itself a translation key naming the created entity; `name` names an unreadable layer. */ + options?: CanvasImportNotice['options'] | { destination: string } | { name: string }; } | { kind: 'error'; refusal: AdjustmentRefusal }; @@ -47,6 +48,8 @@ export const getCreateFromBboxNotice = ( case 'not-ready': case 'over-budget': return { kind: 'info', titleKey: 'widgets.canvas.contextMenu.notReady' }; + case 'unreadable': + return { kind: 'error', options: { name: result.layerName }, titleKey: 'widgets.canvas.contextMenu.unreadable' }; case 'adjustment-refused': return { kind: 'error', refusal: result }; case 'blocked': diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/exportCanvasComposite.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/exportCanvasComposite.ts index f2f5bbf91b6..1b3869b1c0a 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-operations/exportCanvasComposite.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/exportCanvasComposite.ts @@ -1,5 +1,6 @@ import type { AdjustmentRefusal, + LayerUnreadable, CanvasDocumentCapability, CanvasExportCapability, CanvasLifecycleCapability, @@ -11,6 +12,7 @@ export type CanvasCompositeRegion = 'canvas' | 'bbox'; export type ExportCanvasCompositeResult = | { status: 'ok'; blob: Blob; rect: Rect } | { status: 'empty' | 'stale' | 'not-ready' | 'over-budget' } + | LayerUnreadable | AdjustmentRefusal; export type CanvasCompositeExportEngine = { diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/filterOperationSession.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/filterOperationSession.ts index e10ac2f322e..35265e4e972 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-operations/filterOperationSession.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/filterOperationSession.ts @@ -184,9 +184,11 @@ export const createFilterOperationSession = ( ? `source-adjustment-${exported.reason}` : exported.status === 'over-budget' ? 'source-over-budget' - : exported.status === 'aborted' - ? 'not-ready' - : exported.status; + : exported.status === 'unreadable' + ? 'source-unreadable' + : exported.status === 'aborted' + ? 'not-ready' + : exported.status; throw new Error(`The filter source is ${exported.status}.`); } try { diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/generationComposite.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/generationComposite.ts index d1778beddd9..124db03dab9 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-operations/generationComposite.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/generationComposite.ts @@ -10,6 +10,7 @@ import type { CanvasDocumentSnapshot, CanvasRegionalGuidanceLayerContract, CanvasStateContractV3, + LayerUnreadable, } from '@workbench/canvas-engine/api'; import type { CaptureRasterSnapshotResult } from '@workbench/canvas-engine/rasterTransactions'; import type { Rect } from '@workbench/canvas-engine/types'; @@ -129,6 +130,7 @@ export interface GenerationComposites { export type ComposeForGenerationResult = | { status: 'ok'; composites: GenerationComposites; dedupeCommit: GenerationCompositeDedupeCommit } | { status: 'no-document' | 'stale' | 'aborted' | 'not-ready' | 'over-budget' } + | LayerUnreadable | AdjustmentRefusal; /** diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/layerImageResult.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/layerImageResult.ts index 957f1a8ba44..8b848a3a07e 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-operations/layerImageResult.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/layerImageResult.ts @@ -18,7 +18,9 @@ export interface SelectObjectReadyResult { export type SelectObjectRunResult = | SelectObjectReadyResult - | { status: 'aborted' | 'missing' | 'disabled' | 'unsupported' | 'empty' | 'not-ready' | 'over-budget' } + | { + status: 'aborted' | 'missing' | 'disabled' | 'unsupported' | 'empty' | 'not-ready' | 'unreadable' | 'over-budget'; + } | AdjustmentRefusal | { status: 'invalid-input' } | { status: 'dimension-mismatch'; message: string } diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/operationTypes.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/operationTypes.ts index 0ae42e57455..fe922a83a62 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-operations/operationTypes.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/operationTypes.ts @@ -27,6 +27,8 @@ export type SamSessionErrorCode = | 'output-dimension' | 'decode' | 'locked' + /** The layer's image could not be read (it is gone or does not decode). */ + | 'unreadable' | 'unknown'; export interface SamSessionError { code: SamSessionErrorCode; @@ -79,6 +81,8 @@ export type FilterSessionErrorCode = | 'disabled' | 'empty' | 'source-over-budget' + /** The layer's image could not be read (it is gone or does not decode). */ + | 'source-unreadable' /** The layer's Clarity, Texture or Dehaze could not run on the source, so it was not filtered without them. */ | 'source-adjustment-too-large' | 'source-adjustment-over-budget' diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/saveCanvasToGallery.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/saveCanvasToGallery.ts index c7cd7e5cba6..11b12cc1f17 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-operations/saveCanvasToGallery.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/saveCanvasToGallery.ts @@ -1,4 +1,4 @@ -import type { AdjustmentRefusal } from '@workbench/canvas-engine/api'; +import type { AdjustmentRefusal, LayerUnreadable } from '@workbench/canvas-engine/api'; import type { Rect } from '@workbench/canvas-engine/types'; import type { Project } from '@workbench/projectContracts'; @@ -29,6 +29,7 @@ export type SaveCanvasToGalleryResult = /** `boardId` is where the image went: a board id, or `none` for Uncategorized. */ | { status: 'saved'; boardId: string; imageName: string } | { status: 'empty' | 'stale' | 'not-ready' | 'over-budget' } + | LayerUnreadable | AdjustmentRefusal; const buildCanvasSaveMetadata = (project: Project, rect: Rect): Record => { diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/selectObjectSession.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/selectObjectSession.test.ts index 08ff77b7cf0..7fe1e2879a6 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-operations/selectObjectSession.test.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/selectObjectSession.test.ts @@ -44,7 +44,10 @@ const deferred = () => { return { promise, resolve }; }; -const createHarness = (overrides: Partial> = {}) => { +const createHarness = ( + overrides: Partial> = {}, + { initialAutoProcess }: { initialAutoProcess?: boolean } = {} +) => { let currentGuard: LayerExportGuard | null = guard; let controllerSubscriber: (() => void) | null = null; const unsubscribe = vi.fn(); @@ -84,7 +87,7 @@ const createHarness = (overrides: Partial> = {}) if (!operation) { throw new Error('expected Select Object operation'); } - const session = createSelectObjectSession({ deps, operation }); + const session = createSelectObjectSession({ deps, initialAutoProcess, operation }); session.update({ input: { prompt: 'cat', type: 'prompt' } }); return { controller, @@ -588,6 +591,17 @@ describe('createSelectObjectSession', () => { expect(harness.session.getSnapshot().sourceGuard).toBe(replacementGuard); }); + it('starts from the seeded auto-process preference and runs the first valid input after the debounce', async () => { + vi.useFakeTimers(); + const harness = createHarness({}, { initialAutoProcess: true }); + + expect(harness.session.getSnapshot()).toMatchObject({ autoProcess: true, status: 'scheduled' }); + await vi.advanceTimersByTimeAsync(999); + expect(harness.deps.exportSource).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + await vi.waitFor(() => expect(harness.deps.runGraph).toHaveBeenCalledOnce()); + }); + it('debounces auto-processing for one second and manual processing runs immediately', async () => { vi.useFakeTimers(); const harness = createHarness(); @@ -873,7 +887,7 @@ describe('createSelectObjectSession', () => { expect(harness.session.getSnapshot()).toMatchObject({ preview: null, sourceGuard: null, status: 'ready' }); }); - it('reset cancels work and scheduling, clears preview/cache, and restores defaults', async () => { + it('reset cancels work and scheduling, clears preview/cache, and restores defaults except auto-process', async () => { vi.useFakeTimers(); const graph = deferred<{ height: number; imageName: string; origin: string; width: number }>(); const harness = createHarness({ runGraph: vi.fn(() => graph.promise) }); @@ -887,7 +901,7 @@ describe('createSelectObjectSession', () => { await expect(pending).resolves.toBe('stale'); expect(harness.session.getSnapshot()).toEqual({ applyPolygonRefinement: false, - autoProcess: false, + autoProcess: true, error: null, input: { prompt: '', type: 'prompt' }, invert: false, diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/selectObjectSession.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/selectObjectSession.ts index 8af42d818d7..e70977f5479 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-operations/selectObjectSession.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/selectObjectSession.ts @@ -62,6 +62,8 @@ export interface SelectObjectSessionDeps { export interface CreateSelectObjectSessionOptions { deps: SelectObjectSessionDeps; + /** Auto Process is an account preference; the session starts from it and Reset keeps it. */ + initialAutoProcess?: boolean; operation: CanvasOperationSession; } @@ -86,9 +88,9 @@ export interface SelectObjectSession { dispose(): void; } -const defaultState = (): SelectObjectSessionState => ({ +const defaultState = (autoProcess: boolean): SelectObjectSessionState => ({ applyPolygonRefinement: false, - autoProcess: false, + autoProcess, error: null, input: { prompt: '', type: 'prompt' }, invert: false, @@ -134,6 +136,9 @@ const getPreparedSourceError = ( if (result.status === 'adjustment-refused') { return { code: `adjustment-${result.reason}` }; } + if (result.status === 'unreadable') { + return { code: 'unreadable' }; + } return { code: 'not-ready' }; }; @@ -164,7 +169,7 @@ class SamSessionFailure extends Error { export const createSelectObjectSession = (options: CreateSelectObjectSessionOptions): SelectObjectSession => { const { deps, operation } = options; - let state = defaultState(); + let state = defaultState(options.initialAutoProcess ?? false); let source: SelectObjectPreparedSource | null = null; let requestToken = 0; let lastPublishedHash: string | null = null; @@ -532,7 +537,7 @@ export const createSelectObjectSession = (options: CreateSelectObjectSessionO operation.reset(); source = null; lastPublishedHash = null; - publishState(defaultState()); + publishState(defaultState(state.autoProcess)); }, subscribe: (listener) => { if (disposed) { diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/useCanvasEngine.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/useCanvasEngine.test.ts index 6f9778f95f2..7aceda1c6c2 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-operations/useCanvasEngine.test.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/useCanvasEngine.test.ts @@ -1,6 +1,7 @@ import type { CanvasEngine } from '@workbench/canvas-engine/api'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { CanvasImageMissingError } from '@workbench/canvas-engine/api'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const registry = vi.hoisted(() => ({ acquire: vi.fn(), @@ -12,7 +13,7 @@ vi.mock('./engineRegistry', () => ({ releaseEngine: registry.release, })); -import { createCanvasEngineResource } from './useCanvasEngine'; +import { createCanvasEngineResource, createImageResolver } from './useCanvasEngine'; describe('Canvas engine React resource', () => { beforeEach(() => { @@ -57,3 +58,36 @@ describe('Canvas engine React resource', () => { expect(registry.release).toHaveBeenCalledTimes(2); }); }); + +describe('Canvas image resolver', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const resolveWith = (respond: () => Promise) => { + vi.stubGlobal('fetch', vi.fn(respond)); + return createImageResolver()('cat.png'); + }; + + it('returns the image body', async () => { + const blob = await resolveWith(() => Promise.resolve(new Response('pixels', { status: 200 }))); + await expect(blob.text()).resolves.toBe('pixels'); + }); + + it.each([403, 404, 410])('says a %i image is missing or not readable', async (status) => { + const rejection = resolveWith(() => Promise.resolve(new Response(null, { status }))); + await expect(rejection).rejects.toBeInstanceOf(CanvasImageMissingError); + await expect(rejection).rejects.toMatchObject({ imageName: 'cat.png' }); + }); + + it.each([401, 429, 500, 503])('fails a %i as a plain error, which may pass', async (status) => { + const rejection = resolveWith(() => Promise.resolve(new Response(null, { status }))); + await expect(rejection).rejects.toThrow(`(${status})`); + await expect(rejection).rejects.not.toBeInstanceOf(CanvasImageMissingError); + }); + + it('passes a network error on as it is', async () => { + const offline = new TypeError('Failed to fetch'); + await expect(resolveWith(() => Promise.reject(offline))).rejects.toBe(offline); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/useCanvasEngine.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/useCanvasEngine.ts index 198e5cf85ed..3386606b7f8 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-operations/useCanvasEngine.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/useCanvasEngine.ts @@ -2,8 +2,10 @@ import type { CanvasEngine, ImageResolver } from '@workbench/canvas-engine/api'; import { useFontRuntime } from '@features/fonts/react'; import { galleryImageUrls } from '@features/gallery/utility'; +import { CanvasImageMissingError } from '@workbench/canvas-engine/api'; import { createCanvasProjectMutationPort } from '@workbench/canvasProjectMutationPort'; import { publishLayerPanelSelection, readLayerPanelState } from '@workbench/layerPanelState'; +import { getWorkbenchPreferences } from '@workbench/settings/store'; import { getSelectedModelBase } from '@workbench/widgets/layers/selectedModel'; import { useActiveProjectId, @@ -25,9 +27,16 @@ export interface CanvasEngineResource { subscribe(listener: () => void): () => void; } -/** Fetches a persisted image asset to a Blob for the engine rasterizers. */ -const createImageResolver = (): ImageResolver => async (imageName, signal) => { +/** + * Fetches a persisted image asset to a Blob for the engine rasterizers. 404 and 410 say the image does not exist and + * 403 that this user may not read it (access to its board was lost), which lasts too. A network error, an expired + * session (401) or any other status may pass, so the engine treats it as transient. + */ +export const createImageResolver = (): ImageResolver => async (imageName, signal) => { const response = await fetch(galleryImageUrls.full(imageName), { signal }); + if (response.status === 403 || response.status === 404 || response.status === 410) { + throw new CanvasImageMissingError(imageName); + } if (!response.ok) { throw new Error(`Failed to load canvas image "${imageName}" (${response.status})`); } @@ -97,6 +106,7 @@ export const useCanvasEngine = (): CanvasEngineHandle | null => { imageResolver: createImageResolver(), fonts, mutationPort: createCanvasProjectMutationPort(store, projectId), + readSelectObjectAutoProcess: () => getWorkbenchPreferences().selectObjectAutoProcess, reportError: notifications.reportError, }), [fonts, liveEngines, notifications.reportError, persistence, projectId, store] diff --git a/invokeai/frontend/webv2/src/workbench/canvasDimsSync.test.ts b/invokeai/frontend/webv2/src/workbench/canvasDimsSync.test.ts index 4e8cd173d10..d3310b821e2 100644 --- a/invokeai/frontend/webv2/src/workbench/canvasDimsSync.test.ts +++ b/invokeai/frontend/webv2/src/workbench/canvasDimsSync.test.ts @@ -9,11 +9,17 @@ import { architectureCapabilitiesFixture, seedArchitectureCapabilities, } from '@features/generation/core/architectureCapabilities.testing'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { CanvasProjectMutation } from './canvasProjectMutations'; -import { type CanvasDimsSnapshot, createCanvasDimsSync, reconcileCanvasDims } from './canvasDimsSync'; +import { + type CanvasDimsSnapshot, + createCanvasDimsSync, + reconcileCanvasDims, + resetGenerateDims, + resizeBboxAroundCentre, +} from './canvasDimsSync'; import { getProjectWidgetValues } from './widgetState'; import { createWorkbenchStore } from './workbenchStore'; @@ -265,6 +271,82 @@ const setupCanvasStore = () => { return { getSyncDispatches: () => syncDispatches, store, sync }; }; +describe('resizeBboxAroundCentre (pure)', () => { + it('keeps the centre of the frame', () => { + // Centre (516, 708); a 1024 square around it starts at (4, 196). + expect(resizeBboxAroundCentre(bbox(832, 1216, 100, 100), { height: 1024, width: 1024 })).toEqual( + bbox(1024, 1024, 4, 196) + ); + }); + + it('rounds a half-pixel offset from an odd size difference to whole pixels', () => { + // 10 + (513 - 512) / 2 = 10.5 and 20 + (512 - 515) / 2 = 18.5. + expect(resizeBboxAroundCentre(bbox(513, 512, 10, 20), { height: 515, width: 512 })).toEqual(bbox(512, 515, 11, 19)); + // -0.5 rounds to zero, not negative zero. + expect(resizeBboxAroundCentre(bbox(513, 512, 0, 0), { height: 512, width: 514 }).x).toBe(0); + }); +}); + +describe('resetGenerateDims (wiring)', () => { + seedArchitectureCapabilities(); + + const SQUARE_RESET = { + aspectRatioId: '1:1', + aspectRatioIsLocked: true, + aspectRatioValue: 1, + height: 1024, + width: 1024, + } as const; + + it('resizes the canvas frame around its centre with system writes only, then settles', () => { + const { store, sync } = setupCanvasStore(); + dispatchCanvas(store, { bbox: { height: 1216, width: 832, x: 100, y: 100 }, type: 'setCanvasBbox' }); + const apply = vi.spyOn(store.commands.canvas, 'apply'); + + resetGenerateDims(store, SQUARE_RESET); + + const { bbox: nextBbox, values } = getActiveGenerate(store.getState()); + expect(nextBbox).toEqual({ height: 1024, width: 1024, x: 4, y: 196 }); + expect(values).toMatchObject(SQUARE_RESET); + expect(store.getState().projects[0]?.invocation.sourceId).toBe('canvas'); + // Frame writes stay out of canvas history. + expect(apply.mock.calls.length).toBeGreaterThan(0); + for (const call of apply.mock.calls) { + expect(call[2]).toBe('system'); + } + + // Frame and size now agree, so the next reconciliation writes nothing. + apply.mockClear(); + store.commands.generation.patchSettings({ steps: 31 }); + expect(apply).not.toHaveBeenCalled(); + expect(getActiveGenerate(store.getState()).bbox).toEqual({ height: 1024, width: 1024, x: 4, y: 196 }); + + // A later size edit keeps the top-left as before. + store.commands.generation.patchSettings({ width: 512 }); + expect(getActiveGenerate(store.getState()).bbox).toEqual({ height: 1024, width: 512, x: 4, y: 196 }); + sync.dispose(); + }); + + it('changes only the fields when the project is not generating into the canvas', () => { + const store = createWorkbenchStore(); + store.commands.generation.setSource('generate'); + store.commands.generation.toggleSourceLock(); + store.commands.generation.patchSettings({ height: 1216, model, modelKey: model.key, width: 832 }); + dispatchCanvas(store, { bbox: { height: 1216, width: 832, x: 100, y: 100 }, type: 'setCanvasBbox' }); + const sync = createCanvasDimsSync(store); + const apply = vi.spyOn(store.commands.canvas, 'apply'); + + resetGenerateDims(store, SQUARE_RESET); + + expect(getActiveGenerate(store.getState())).toMatchObject({ + bbox: { height: 1216, width: 832, x: 100, y: 100 }, + values: SQUARE_RESET, + }); + expect(apply).not.toHaveBeenCalled(); + sync.dispose(); + }); +}); + describe('createCanvasDimsSync (wiring)', () => { seedArchitectureCapabilities(); diff --git a/invokeai/frontend/webv2/src/workbench/canvasDimsSync.ts b/invokeai/frontend/webv2/src/workbench/canvasDimsSync.ts index 97f05db48b5..13c33a0a6c5 100644 --- a/invokeai/frontend/webv2/src/workbench/canvasDimsSync.ts +++ b/invokeai/frontend/webv2/src/workbench/canvasDimsSync.ts @@ -122,6 +122,48 @@ export const reconcileCanvasDims = ({ return { kind: 'none' }; }; +/** The frame resized to `size` around its centre; the position rounds to whole pixels. */ +export const resizeBboxAroundCentre = (bbox: Bbox, size: Pick): Bbox => ({ + height: size.height, + width: size.width, + // `+ 0` folds the -0 that rounding a small negative offset yields. + x: Math.round(bbox.x + (bbox.width - size.width) / 2) + 0, + y: Math.round(bbox.y + (bbox.height - size.height) / 2) + 0, +}); + +/** + * Applies a Generate size reset. On a project generating into the canvas, the frame then keeps its centre rather than + * its top-left: the patch lands first, the sync resizes the frame at its top-left as for any size edit, and the centred + * frame replaces that in the same turn. The patch is a person's edit like any other; the frame writes are `system`, so + * canvas history records none of them. + */ +export const resetGenerateDims = ( + store: Pick, + values: Parameters[0], + projectId?: string +): void => { + const findProject = () => { + const state = store.getState(); + const targetId = projectId ?? state.activeProjectId; + return state.projects.find((candidate) => candidate.id === targetId); + }; + const before = findProject(); + const anchor = before?.invocation.sourceId === 'canvas' ? before.canvas.document.bbox : null; + + store.commands.generation.patchSettings(values, projectId); + + const after = findProject(); + if (!anchor || !after || after.invocation.sourceId !== 'canvas') { + return; + } + // The sync has snapped the frame to the model grid; centre that size rather than the raw values. + const current = after.canvas.document.bbox; + const centred = resizeBboxAroundCentre(anchor, current); + if (centred.x !== current.x || centred.y !== current.y) { + store.commands.canvas.apply(after.id, { bbox: centred, type: 'setCanvasBbox' }, 'system'); + } +}; + /** The minimal workbench store surface the sync depends on. */ export interface CanvasDimsSyncStore { commands: { diff --git a/invokeai/frontend/webv2/src/workbench/canvasProjectMutations.ts b/invokeai/frontend/webv2/src/workbench/canvasProjectMutations.ts index 5a02d456caf..bcde9fb530c 100644 --- a/invokeai/frontend/webv2/src/workbench/canvasProjectMutations.ts +++ b/invokeai/frontend/webv2/src/workbench/canvasProjectMutations.ts @@ -74,6 +74,7 @@ const CANVAS_PROJECT_MUTATION_TYPES: ReadonlySet = new Set { + const ids = new Set(replacements.map(({ layerId }) => layerId)); + const valid = + replacements.length > 0 && + ids.size === replacements.length && + replacements.every( + ({ layer, layerId }) => layer.id === layerId && getDocumentLayer(document, layerId)?.type === layer.type + ); + return valid + ? mapNodes( + document, + replacements.map(({ layer, layerId }) => [layerId, () => layer]) + ) + : document; +}; + /** The stack every leaf of `nodes` belongs to, or `null` when they disagree; an empty group fits any stack. */ const nodesStack = (nodes: readonly CanvasNodeContract[], fallback: CanvasNodeInsertionAnchor['stack']) => { const stacks = new Set(nodes.flatMap((node) => collectSubtreeLeaves(node).map(layerStackOf))); @@ -857,6 +878,8 @@ export const applyCanvasProjectMutation = (project: Project, mutation: CanvasPro return mutation.layer.id === mutation.layerId ? updateCanvasDocument(project, (document) => replaceLeaf(project.id, document, mutation.layer)) : project; + case 'replaceCanvasLayers': + return updateCanvasDocument(project, (document) => replaceLeavesInPlace(document, mutation.replacements)); case 'setCanvasLayersEnabled': return updateCanvasDocument(project, (document) => setCanvasLayersEnabled(document, mutation.updates)); case 'setCanvasLayerPositions': diff --git a/invokeai/frontend/webv2/src/workbench/hotkeys/catalog.test.ts b/invokeai/frontend/webv2/src/workbench/hotkeys/catalog.test.ts index 4201a01202d..1eb7af5db33 100644 --- a/invokeai/frontend/webv2/src/workbench/hotkeys/catalog.test.ts +++ b/invokeai/frontend/webv2/src/workbench/hotkeys/catalog.test.ts @@ -11,7 +11,7 @@ import { toPlatformHotkey } from './resolve'; describe('firstPartyHotkeyCatalog', () => { it('keeps legacy default hotkey parity', () => { - expect(firstPartyHotkeyCatalog).toHaveLength(120); + expect(firstPartyHotkeyCatalog).toHaveLength(121); expect(firstPartyHotkeyCatalog.map((hotkey) => hotkey.id)).toContain('app.togglePreview'); expect(firstPartyHotkeyCatalog.map((hotkey) => hotkey.id)).toContain('app.invoke'); expect(firstPartyHotkeyCatalog.map((hotkey) => hotkey.id)).toContain('app.openCommandPalette'); @@ -61,6 +61,39 @@ describe('firstPartyHotkeyCatalog', () => { expect(reset).toMatchObject({ defaultKeys: ['d'], implemented: true }); }); + it('offers crop all layers to frame unbound, for a key the user chooses', () => { + const cropAll = firstPartyHotkeyCatalog.find((hotkey) => hotkey.id === 'canvas.cropAllLayersToFrame'); + + expect(cropAll).toMatchObject({ defaultKeys: [], implemented: true, title: 'Crop All Layers To Frame' }); + }); + + it('binds transform to Shift+T only, leaving Mod+T to the browser', () => { + const transform = firstPartyHotkeyCatalog.find((hotkey) => hotkey.id === 'canvas.transformSelected'); + + expect(transform).toMatchObject({ defaultKeys: ['shift+t'], implemented: true, title: 'Transform Selected Layer' }); + }); + + it.each([ + ['canvas.setZoomTo100Percent', 'mod+1', 'Zoom to 100%'], + ['canvas.setZoomTo200Percent', 'mod+2', 'Zoom to 200%'], + ['canvas.setZoomTo400Percent', 'mod+3', 'Zoom to 400%'], + ['canvas.setZoomTo800Percent', 'mod+4', 'Zoom to 800%'], + ])('implements %s on %s, titled %s', (id, key, title) => { + const zoom = firstPartyHotkeyCatalog.find((hotkey) => hotkey.id === id); + + expect(zoom).toMatchObject({ defaultKeys: [key], implemented: true, title }); + }); + + // Ids keep "bbox" so saved custom bindings survive; only the visible titles say "Frame". + it('titles every frame command without the word bbox', () => { + const titles = firstPartyHotkeyCatalog.map((hotkey) => hotkey.title); + + expect(titles.filter((title) => /bbox|bounding box/i.test(title))).toEqual([]); + expect(firstPartyHotkeyCatalog.find((hotkey) => hotkey.id === 'canvas.fitBboxToMasks')?.title).toBe( + 'Fit Frame To Masks' + ); + }); + it('keeps layout saving explicit and out of editable controls', () => { const saveLayout = firstPartyHotkeyCatalog.find((hotkey) => hotkey.id === 'app.saveLayoutPreset'); diff --git a/invokeai/frontend/webv2/src/workbench/hotkeys/catalog.ts b/invokeai/frontend/webv2/src/workbench/hotkeys/catalog.ts index 8f02378bcfe..319759e7e89 100644 --- a/invokeai/frontend/webv2/src/workbench/hotkeys/catalog.ts +++ b/invokeai/frontend/webv2/src/workbench/hotkeys/catalog.ts @@ -5,7 +5,7 @@ import { IS_MAC_OS } from './keys'; /** * Only registered handlers intercept chords; handlerless catalog entries do not. App chords are global, other * chords require widget focus, and editable fields are excluded unless explicitly allowed. Overrides include - * print/search/settings, undo/redo, selection/clipboard, and Canvas merge/deselect/invert/transform; + * print/search/settings, undo/redo, selection/clipboard, and Canvas merge/deselect/invert; * browser-reserved chords work only when delivered to the page. */ @@ -52,6 +52,7 @@ const implemented = new Set([ 'app.togglePanels', 'app.togglePreview', 'app.toggleRightPanel', + 'canvas.cropAllLayersToFrame', 'canvas.deleteSelected', 'canvas.fitBboxToLayers', 'canvas.fitBboxToMasks', @@ -59,11 +60,16 @@ const implemented = new Set([ 'canvas.nextEntity', 'canvas.saveBboxToGallery', 'canvas.saveToGallery', + 'canvas.setZoomTo100Percent', + 'canvas.setZoomTo200Percent', + 'canvas.setZoomTo400Percent', + 'canvas.setZoomTo800Percent', 'canvas.prevEntity', 'canvas.quickSwitch', 'canvas.redo', 'canvas.setFillColorsToDefault', 'canvas.toggleFillColor', + 'canvas.transformSelected', 'canvas.undo', 'gallery.clearSelection', 'gallery.deleteSelection', @@ -249,7 +255,7 @@ export const firstPartyHotkeyCatalog: HotkeyDefinition[] = [ hotkey('app', 'togglePanels', ['f']), hotkey('app', 'togglePreview', ['z']), hotkey('canvas', 'selectBrushTool', ['b']), - hotkey('canvas', 'selectBboxTool', ['c']), + hotkey('canvas', 'selectBboxTool', ['c'], { title: 'Select Frame Tool' }), hotkey('canvas', 'decrementToolWidth', ['[']), hotkey('canvas', 'incrementToolWidth', [']']), hotkey('canvas', 'selectEraserTool', ['e']), @@ -261,18 +267,18 @@ export const firstPartyHotkeyCatalog: HotkeyDefinition[] = [ hotkey('canvas', 'setFillColorsToDefault', ['d']), hotkey('canvas', 'toggleFillColor', ['x']), hotkey('canvas', 'fitLayersToCanvas', ['mod+0']), - hotkey('canvas', 'fitBboxToCanvas', ['mod+shift+0']), - hotkey('canvas', 'fitBboxToLayers', ['shift+n']), - hotkey('canvas', 'setZoomTo100Percent', ['mod+1']), - hotkey('canvas', 'setZoomTo200Percent', ['mod+2']), - hotkey('canvas', 'setZoomTo400Percent', ['mod+3']), - hotkey('canvas', 'setZoomTo800Percent', ['mod+4']), + hotkey('canvas', 'fitBboxToCanvas', ['mod+shift+0'], { title: 'Fit Frame To Canvas' }), + hotkey('canvas', 'fitBboxToLayers', ['shift+n'], { title: 'Fit Frame To Layers' }), + hotkey('canvas', 'setZoomTo100Percent', ['mod+1'], { title: 'Zoom to 100%' }), + hotkey('canvas', 'setZoomTo200Percent', ['mod+2'], { title: 'Zoom to 200%' }), + hotkey('canvas', 'setZoomTo400Percent', ['mod+3'], { title: 'Zoom to 400%' }), + hotkey('canvas', 'setZoomTo800Percent', ['mod+4'], { title: 'Zoom to 800%' }), hotkey('canvas', 'quickSwitch', ['q']), hotkey('canvas', 'deleteSelected', ['delete', 'backspace']), hotkey('canvas', 'resetSelected', ['shift+c']), hotkey('canvas', 'mergeDown', ['mod+e']), hotkey('canvas', 'mergeVisible', ['mod+shift+e']), - hotkey('canvas', 'transformSelected', ['shift+t']), + hotkey('canvas', 'transformSelected', ['shift+t'], { title: 'Transform Selected Layer' }), hotkey('canvas', 'filterSelected', ['shift+f']), hotkey('canvas', 'invertMask', ['shift+v']), hotkey('canvas', 'undo', ['mod+z']), @@ -286,13 +292,14 @@ export const firstPartyHotkeyCatalog: HotkeyDefinition[] = [ hotkey('canvas', 'applySegmentAnything', ['enter']), hotkey('canvas', 'cancelSegmentAnything', ['esc']), hotkey('canvas', 'toggleNonRasterLayers', ['shift+h']), - hotkey('canvas', 'fitBboxToMasks', ['shift+b']), - hotkey('canvas', 'toggleBbox', ['shift+o']), + hotkey('canvas', 'fitBboxToMasks', ['shift+b'], { title: 'Fit Frame To Masks' }), + hotkey('canvas', 'toggleBbox', ['shift+o'], { title: 'Toggle Frame' }), // Clear canvas keeps its saved-binding id, `newSession`. hotkey('canvas', 'newSession', ['mod+alt+n'], { title: 'Clear Canvas' }), - // The gallery saves have no default keys; they are assignable through the hotkeys settings. + // The gallery saves and crop all have no default keys; they are assignable through the hotkeys settings. hotkey('canvas', 'saveToGallery', []), - hotkey('canvas', 'saveBboxToGallery', []), + hotkey('canvas', 'saveBboxToGallery', [], { title: 'Save Frame To Gallery' }), + hotkey('canvas', 'cropAllLayersToFrame', []), hotkey('workflows', 'addNode', ['shift+a', 'space']), hotkey('workflows', 'copySelection', ['mod+c']), hotkey('workflows', 'pasteSelection', ['mod+v']), diff --git a/invokeai/frontend/webv2/src/workbench/settings/contracts.ts b/invokeai/frontend/webv2/src/workbench/settings/contracts.ts index 4fef471f54d..ac6bc5647b6 100644 --- a/invokeai/frontend/webv2/src/workbench/settings/contracts.ts +++ b/invokeai/frontend/webv2/src/workbench/settings/contracts.ts @@ -113,6 +113,8 @@ export interface WorkbenchPreferences { workflowLibraryThumbnailFit: WorkflowLibraryThumbnailFit; /** Raise text and border contrast on every theme (a11y). */ highContrast: boolean; + /** Select Object processes each prompt edit automatically; kept across sessions, Reset, and reloads. */ + selectObjectAutoProcess: boolean; /** Account-bound overrides keyed by hotkey id (`app.invoke`, `gallery.galleryNavLeft`, etc.). */ customHotkeys: Record; /** Generate panel section open/closed overrides keyed by section id; absent = section default. */ diff --git a/invokeai/frontend/webv2/src/workbench/settings/store.test.ts b/invokeai/frontend/webv2/src/workbench/settings/store.test.ts index b0b4cbf0975..bef448152d8 100644 --- a/invokeai/frontend/webv2/src/workbench/settings/store.test.ts +++ b/invokeai/frontend/webv2/src/workbench/settings/store.test.ts @@ -427,6 +427,16 @@ describe('normalizeWorkbenchPreferences appearance', () => { }); }); +describe('normalizeWorkbenchPreferences canvas operations', () => { + it('keeps Select Object auto-process off unless it was stored as on', () => { + expect(store.normalizeWorkbenchPreferences({}).selectObjectAutoProcess).toBe(false); + expect( + store.normalizeWorkbenchPreferences({ selectObjectAutoProcess: 'true' as never }).selectObjectAutoProcess + ).toBe(false); + expect(store.normalizeWorkbenchPreferences({ selectObjectAutoProcess: true }).selectObjectAutoProcess).toBe(true); + }); +}); + describe('installInPlace preference', () => { it('installs in place unless the account stored the box unticked', () => { expect(store.normalizeWorkbenchPreferences({}).installInPlace).toBe(true); diff --git a/invokeai/frontend/webv2/src/workbench/settings/store.ts b/invokeai/frontend/webv2/src/workbench/settings/store.ts index e25526f3f53..0519f24857b 100644 --- a/invokeai/frontend/webv2/src/workbench/settings/store.ts +++ b/invokeai/frontend/webv2/src/workbench/settings/store.ts @@ -74,6 +74,7 @@ export const DEFAULT_PREFERENCES: WorkbenchPreferences = { promptFontSize: 'default', queueJobsScope: 'all', reduceMotion: false, + selectObjectAutoProcess: false, showPromptSyntaxHighlighting: true, showFocusRegionHighlight: true, themeId: DEFAULT_THEME_ID, @@ -409,6 +410,10 @@ export const normalizeWorkbenchPreferences = (preferences?: WorkbenchPreferences : DEFAULT_PREFERENCES.alphaNoticeAcknowledged, reduceMotion: typeof preferences?.reduceMotion === 'boolean' ? preferences.reduceMotion : DEFAULT_PREFERENCES.reduceMotion, + selectObjectAutoProcess: + typeof preferences?.selectObjectAutoProcess === 'boolean' + ? preferences.selectObjectAutoProcess + : DEFAULT_PREFERENCES.selectObjectAutoProcess, showFocusRegionHighlight: typeof preferences?.showFocusRegionHighlight === 'boolean' ? preferences.showFocusRegionHighlight diff --git a/invokeai/frontend/webv2/src/workbench/widget-frame/README.md b/invokeai/frontend/webv2/src/workbench/widget-frame/README.md index 85a2416cb48..aac7372088d 100644 --- a/invokeai/frontend/webv2/src/workbench/widget-frame/README.md +++ b/invokeai/frontend/webv2/src/workbench/widget-frame/README.md @@ -38,7 +38,7 @@ Workbench focus is transient, never persisted. `WorkbenchFocusProvider` (`workbe - **Recall is the one reveal that moves no focus.** Recall raises and expands its window but leaves keyboard focus, outline, and hotkey target where user had them; next press or focus change brings them back into agreement. Since focus inside active window does not re-raise, it cannot re-bury the window a recall just revealed. - **Keys follow the outline.** A press inside a window moves keyboard focus into it when focus was elsewhere; a press on a docked region pulls focus out of a floating window. Floating layer renders windows in fixed DOM order and stacks them by z-index alone: raising a window never moves its node, which would drop the focus that raised it. - **Focus follows placement:** into window after Float and after a marker or "Show window" reveal; into the region a docked rail tab shows in when it is selected, so keys stop going to a window that held them; into return region after Dock; after Remove from a marker's menu, into center when it gets its view back, else into the panel the window's own rail is showing, or center when that rail shows none (emptied by float or collapsed). Removing a window from a rail's enable menu moves no focus: that menu stays open and keeps it. A focus move targets region frame on screen (side regions keep earlier panels mounted but hidden), waits a bounded time for its widget to mount, and is abandoned if user presses elsewhere first. Every panel frame carries its instance's identity, including stand-in for a widget whose view cannot render, so a move into it lands. -- **Hotkeys read the same target.** `HotkeyContext.focusedRegion` is `WidgetRegion | 'floating' | null`; persisted `WidgetRegion` unchanged. A key press is aimed at widget under event target first and, outside any widget's DOM, at active floating instance or focused region's active member (`resolveHotkeyTarget`). A region's active pointer counts only while it names a member, so a view that floated out of an emptied center answers as a window, not as center. Floating command source carries `region: 'floating'` and is never used to index `widgetRegions`. Generic `focused-region` shortcuts match under a window, shortcuts tied to a named docked region do not, and a floating widget's own `focused-region` contribution follows its window at the same priority it has when docked. A binding with a `targetSelector` (prompt weighting: an editable prompt whose model reads attention syntax) is a candidate only while the key press's target matches it; elsewhere the next candidate wins, or nothing claims the key and it keeps its browser behavior. +- **Hotkeys read the same target.** `HotkeyContext.focusedRegion` is `WidgetRegion | 'floating' | null`; persisted `WidgetRegion` unchanged. A key press is aimed at widget under event target first and, outside any widget's DOM, at active floating instance or focused region's active member (`resolveHotkeyTarget`). A region's active pointer counts only while it names a member, so a view that floated out of an emptied center answers as a window, not as center. Floating command source carries `region: 'floating'` and is never used to index `widgetRegions`. Generic `focused-region` shortcuts match under a window, shortcuts tied to a named docked region do not, and a floating widget's own `focused-region` contribution follows its window at the same priority it has when docked. A binding with a `targetSelector` (prompt weighting: an editable prompt whose model reads attention syntax) is a candidate only while the key press's target matches it; elsewhere the next candidate wins, or nothing claims the key and it keeps its browser behavior. Canvas engine's own keys (Enter, Escape, C, and the Space and Alt holds) ask the same judge with the same inputs: the canvas surface maps the target to this canvas, a companion Layers widget, or elsewhere. - **Modal dialogs suspend application shortcuts.** Every dialog is built on Platform `Dialog` root (`platform/ui/Dialog.tsx`; lint rejects Chakra's dialog and its drawer, which has no such root yet), which registers with `platform/ui/modalPresence` while its own open state holds, unless declared `modal={false}`. A dialog whose module is still loading uses `Dialog.Pending` as suspense fallback, so it is modal from keypress that opened it. While any registration stands, hotkey runtime runs only hotkeys marked `allowInModal` (command palette toggle), so invoke, global and widget shortcuts stay inactive wherever focus is, and keys the dialog handles itself (Enter, Escape, typing) keep browser behavior; Canvas engine's own key listener takes no key either. Both ask about the key press they are handling: a press that began under a modal still counts as aimed at it, because the Escape that dismisses a dialog releases its presence before the same press reaches bubble-phase listeners. Presence follows interactivity, not DOM: a closing dialog that stays mounted through its exit animation is inert and releases at close, so next press reaches application; reopening re-registers at once, an outer dialog stays registered while an inner one closes, and an unmount releases without a close. Media paste reads the same presence. Popovers and menus do not register. ## Contextual shortcut guidance diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasCropAllLayersMenuItem.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasCropAllLayersMenuItem.browser.test.tsx new file mode 100644 index 00000000000..34aeab38b49 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasCropAllLayersMenuItem.browser.test.tsx @@ -0,0 +1,118 @@ +/* oxlint-disable react-perf/jsx-no-new-function-as-prop, react-perf/jsx-no-new-object-as-prop */ +import type { CanvasNodeContract } from '@workbench/canvas-engine/api'; +import type { Project } from '@workbench/projectContracts'; +import type * as WorkbenchContextModule from '@workbench/WorkbenchContext'; + +import { ChakraProvider, Menu } from '@chakra-ui/react'; +import { system } from '@theme/system'; +import { + groupContract, + layerContract, + stacksFrom, +} from '@workbench/canvas-engine/document-model/documentFixtures.testStub'; +import { createInitialWorkbenchState } from '@workbench/workbenchState'; +import { createInstance } from 'i18next'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { I18nextProvider, initReactI18next } from 'react-i18next'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { page, userEvent } from 'vitest/browser'; + +const harness = vi.hoisted(() => ({ project: null as Project | null })); + +vi.mock('@workbench/WorkbenchContext', async (importOriginal) => ({ + ...(await importOriginal()), + useActiveProjectSelector: (selector: (project: Project) => unknown) => selector(harness.project!), +})); +vi.mock('@workbench/useNotify', () => ({ useNotify: () => ({ error: vi.fn(), info: vi.fn(), success: vi.fn() }) })); + +import { CanvasCropAllLayersMenuItem } from './CanvasCropAllLayersMenuItem'; + +const i18n = createInstance(); +let host: HTMLDivElement | null = null; +let root: Root | null = null; +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +beforeAll(async () => { + const translation = await fetch('/locales/en.json').then((response) => response.json()); + await i18n.use(initReactI18next).init({ initAsync: false, lng: 'en', resources: { en: { translation } } }); +}); + +afterEach(async () => { + await act(() => root?.unmount()); + host?.remove(); + host = null; + root = null; +}); + +/** Opens a menu holding only the item, over a project whose canvas holds `nodes`. */ +const renderItem = async (nodes: CanvasNodeContract[], interactionLocked: boolean) => { + const base = createInitialWorkbenchState().projects[0]!; + harness.project = { + ...base, + canvas: { ...base.canvas, document: { ...base.canvas.document, stacks: stacksFrom(nodes) } }, + }; + const layers = { cropAllLayersToBbox: vi.fn(() => Promise.resolve({ status: 'busy' as const })) }; + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + await act(() => { + root?.render( + + + + Canvas + + + + + + + + + ); + }); + await act(() => userEvent.click(page.getByRole('button', { name: 'Canvas' }))); + const item = document.querySelector('[data-value="crop-all-layers-to-bbox"]')!; + await act(() => userEvent.keyboard('{ArrowDown}')); + await vi.waitFor(() => expect(item.hasAttribute('data-highlighted')).toBe(true)); + return { item, layers }; +}; + +const reasonOf = (item: HTMLElement): string | null | undefined => + document.getElementById(item.getAttribute('aria-describedby') ?? '')?.textContent; + +describe('Crop all layers menu item', () => { + it.each([ + [ + 'every layer is locked, by itself or by its group', + [ + layerContract('locked', 'raster', { isLocked: true }), + groupContract('G', [layerContract('inside', 'control')], { isLocked: true }), + ], + false, + 'Every layer is locked. Unlock a layer to crop it.', + ], + [ + 'the canvas is busy', + [layerContract('free', 'raster')], + true, + 'Unavailable while the canvas is generating, has staged results, or is applying an edit.', + ], + ])('stays reachable and says why it cannot run when %s', async (_case, nodes, interactionLocked, reason) => { + const { item, layers } = await renderItem(nodes, interactionLocked); + + expect(item).toHaveAttribute('aria-disabled', 'true'); + expect(reasonOf(item)).toBe(reason); + await act(() => userEvent.keyboard('{Enter}')); + expect(layers.cropAllLayersToBbox).not.toHaveBeenCalled(); + }); + + it('crops when an unlocked layer exists and the canvas is free', async () => { + const { item, layers } = await renderItem([layerContract('free', 'raster')], false); + + expect(item).not.toHaveAttribute('aria-disabled'); + await act(() => userEvent.keyboard('{Enter}')); + expect(layers.cropAllLayersToBbox).toHaveBeenCalledOnce(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasCropAllLayersMenuItem.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasCropAllLayersMenuItem.tsx new file mode 100644 index 00000000000..915c97e0c6f --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasCropAllLayersMenuItem.tsx @@ -0,0 +1,41 @@ +import type { CanvasEngineLayerCapability } from '@workbench/canvas-engine/api'; + +import { MenuActionItem } from '@platform/ui/Menu'; +import { useNotify } from '@workbench/useNotify'; +import { useActiveProjectSelector } from '@workbench/WorkbenchContext'; +import { CropIcon } from 'lucide-react'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { cropAllLayersToFrame, getCropAllUnavailableReasonKey } from './canvasContextMenu'; + +/** + * Canvas-menu action that crops every unlocked layer to the frame as one undo step and reports the count. While it + * cannot run it stays reachable by keyboard and says why. + */ +export const CanvasCropAllLayersMenuItem = ({ + interactionLocked, + layers, +}: { + /** The canvas is generating, has staged results, or is applying an edit. */ + interactionLocked: boolean; + layers: Pick; +}) => { + const { t } = useTranslation(); + const notify = useNotify(); + const documentReasonKey = useActiveProjectSelector((project) => + getCropAllUnavailableReasonKey(project.canvas.document) + ); + const reasonKey = interactionLocked ? 'widgets.layers.actions.interactionLocked' : documentReasonKey; + const onSelect = useCallback(() => void cropAllLayersToFrame(layers, notify, t), [layers, notify, t]); + + return ( + + ); +}; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasHeaderActions.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasHeaderActions.browser.test.tsx index efa9907177b..402195c595f 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasHeaderActions.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasHeaderActions.browser.test.tsx @@ -1,3 +1,4 @@ +import type { CanvasLayerContract } from '@workbench/canvas-engine/contracts'; import type { CanvasEngine } from '@workbench/canvas-operations/createCanvasEngine'; import type { Project } from '@workbench/projectContracts'; import type { WidgetViewProps } from '@workbench/widgetContracts'; @@ -73,7 +74,7 @@ interface Registration { } /** A painted layer, so a cleared document differs from the one it replaced. */ -const paintedLayer = { +const paintedLayer: CanvasLayerContract = { blendMode: 'normal' as const, id: 'painted', isEnabled: true, @@ -86,14 +87,17 @@ const paintedLayer = { }; /** - * Renders the actions on a fresh project, whose canvas is empty unless `withLayer`, recording the commands and - * hotkeys they register. + * Renders the actions on a fresh project holding `layers` (none by default) and the canvas widget values + * `canvasValues`, recording the commands and hotkeys they register. */ -const renderActions = async ({ withLayer = false }: { withLayer?: boolean } = {}) => { +const renderActions = async ({ + canvasValues, + layers = [], +}: { canvasValues?: Record; layers?: CanvasLayerContract[] } = {}) => { const initial = createInitialWorkbenchState(); const base = initial.projects[0]!; const store = createWorkbenchStore( - withLayer + layers.length > 0 ? { ...initial, projects: [ @@ -101,13 +105,16 @@ const renderActions = async ({ withLayer = false }: { withLayer?: boolean } = {} ...base, canvas: { ...base.canvas, - document: { ...base.canvas.document, stacks: stacksFrom([paintedLayer]) }, + document: { ...base.canvas.document, stacks: stacksFrom(layers) }, }, }, ], } : initial ); + if (canvasValues) { + store.commands.widgets.patchValues('canvas', canvasValues); + } const projectId = store.getState().activeProjectId; harness.store = store; harness.engine = createCanvasEngine({ @@ -168,7 +175,7 @@ describe('CanvasHeaderActions clear canvas', () => { }); it('replaces the document once confirmed, layers and all', async () => { - const { store } = await renderActions({ withLayer: true }); + const { store } = await renderActions({ layers: [paintedLayer] }); const before = store.getState().projects[0]!.canvas.documentRevision; expect(store.getState().projects[0]!.canvas.document.stacks.raster).toHaveLength(1); @@ -201,3 +208,27 @@ describe('CanvasHeaderActions clear canvas', () => { await expect.element(confirmDialog()).toBeVisible(); }); }); + +describe('CanvasHeaderActions fit frame to masks', () => { + it("pads the masks by the project's mask blur plus 8 before snapping outward to the grid", async () => { + const mask: CanvasLayerContract = { + blendMode: 'normal', + id: 'mask', + isEnabled: true, + isLocked: false, + mask: { bitmap: { height: 40, imageName: 'mask.png', width: 40 }, fill: { color: '#e07575', style: 'diagonal' } }, + name: 'Mask', + opacity: 1, + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 100, y: 100 }, + type: 'inpaint_mask', + }; + const { store } = await renderActions({ canvasValues: { maskBlur: 40, snapToGrid: true }, layers: [mask] }); + + await page.getByRole('button', { exact: true, name: 'Fit frame to masks' }).click(); + + // Blur 40 pads 48: 52..188, on the default 8 px grid 48..192. The default blur 16 would give 72..168. + await expect + .poll(() => store.getState().projects[0]!.canvas.document.bbox) + .toEqual({ height: 144, width: 144, x: 48, y: 48 }); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasHeaderActions.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasHeaderActions.tsx index e59e185d979..71652f85cd7 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasHeaderActions.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasHeaderActions.tsx @@ -22,11 +22,10 @@ import { ChevronDownIcon, DatabaseIcon, FilePlusIcon, - FrameIcon, + FullscreenIcon, MaximizeIcon, Redo2Icon, SaveIcon, - SquareDashedBottomIcon, Trash2Icon, Undo2Icon, } from 'lucide-react'; @@ -39,6 +38,7 @@ import type { ResolvedCanvasSettings } from './canvasSettings'; import { useModelGridSize } from './bboxGrid'; import { applyFitBbox, + CANVAS_HEADER_HOTKEYS, confirmNewCanvas as confirmNewCanvasDocument, executeCanvasHeaderCommand, zoomAtViewportCentre, @@ -46,6 +46,8 @@ import { import { CANVAS_SNAP_TO_GRID_KEY, canvasSettingsEqual, resolveCanvasSettings } from './canvasSettings'; import { useCanvasCanRedo, useCanvasCanUndo, useCanvasDocumentEditingLocked, useCanvasZoom } from './engineStoreHooks'; import { computeFitBboxToLayers, computeFitBboxToMasks } from './fitBbox'; +import { FitFrameToMasksIcon } from './frameIcons'; +import { readCanvasCompositingSettings } from './invoke/canvasCompositing'; import { useCanvasGallerySave } from './useCanvasGallerySave'; import { reportStructuralCommit } from './useStructuralCommit'; import { formatZoomPercent, zoomMenuOptions } from './zoomOptions'; @@ -68,6 +70,10 @@ const selectModelBase = (project: Project): string | null => { return typeof values?.model?.base === 'string' ? values.model.base : null; }; +/** The project's clamped compositing mask blur, which fit-to-masks clears so the feathered edge stays in frame. */ +const selectMaskBlur = (project: Project): number => + readCanvasCompositingSettings(getProjectWidgetValues(project, 'canvas')).maskBlur; + const selectModelVariant = (project: Project): string | null => { const values = getProjectWidgetValues(project, 'generate') as { model?: { variant?: unknown } } | undefined; return typeof values?.model?.variant === 'string' ? values.model.variant : null; @@ -97,6 +103,7 @@ const CanvasHeaderActionsInner = ({ const modelBase = useActiveProjectSelector(selectModelBase); const modelVariant = useActiveProjectSelector(selectModelVariant); const settings = useActiveProjectSelector(selectCanvasSettings, canvasSettingsEqual); + const maskBlur = useActiveProjectSelector(selectMaskBlur); const [isNewCanvasOpen, setIsNewCanvasOpen] = useState(false); const closeNewCanvas = useCallback(() => setIsNewCanvasOpen(false), []); @@ -112,7 +119,10 @@ const CanvasHeaderActionsInner = ({ const modelGrid = useModelGridSize(modelBase, modelVariant); const gridSize = settings[CANVAS_SNAP_TO_GRID_KEY] ? modelGrid : 1; const fitLayersRect = useMemo(() => computeFitBboxToLayers(document, gridSize), [document, gridSize]); - const fitMasksRect = useMemo(() => computeFitBboxToMasks(document, gridSize), [document, gridSize]); + const fitMasksRect = useMemo( + () => computeFitBboxToMasks(document, gridSize, maskBlur), + [document, gridSize, maskBlur] + ); const commandContext = (): CanvasHeaderCommandContext => ({ document, @@ -140,19 +150,13 @@ const CanvasHeaderActionsInner = ({ ); useEffect(() => { - const entries = [ - ['canvas.fitBboxToLayers', t('widgets.canvas.controls.fitBboxToLayers'), ['shift+n']], - ['canvas.fitBboxToMasks', t('widgets.canvas.controls.fitBboxToMasks'), ['shift+b']], - // The gallery saves have no default keys; they are assignable through the hotkeys settings. - ['canvas.saveToGallery', t('widgets.canvas.contextMenu.saveCanvasToGallery'), []], - ['canvas.saveBboxToGallery', t('widgets.canvas.contextMenu.saveBboxToGallery'), []], - // Clear canvas keeps its saved-binding id, `newSession`. - ['canvas.newSession', t('widgets.canvas.controls.clearCanvas'), ['mod+alt+n']], - ] 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 }), - ]); + const disposers = CANVAS_HEADER_HOTKEYS.flatMap(([id, titleKey, defaultKeys]) => { + const title = t(titleKey); + return [ + runtime.commands.register({ handler: () => executeHeaderCommand(id), id, title }), + runtime.hotkeys.register({ allowInEditable: false, commandId: id, defaultKeys: [...defaultKeys], id, title }), + ]; + }); return () => { disposers.forEach((dispose) => dispose()); }; @@ -210,7 +214,7 @@ const CanvasHeaderActionsInner = ({ variant="ghost" onClick={() => applyFit(fitLayersRect, true)} > - + @@ -223,7 +227,7 @@ const CanvasHeaderActionsInner = ({ variant="ghost" onClick={() => applyFit(fitMasksRect, false)} > - + diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasSurface.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasSurface.browser.test.tsx index 9a95cf35cbf..df5f8ae1c47 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasSurface.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasSurface.browser.test.tsx @@ -2,19 +2,43 @@ import type { CanvasStateContractV3 } from '@workbench/canvas-engine/contracts'; import type { CanvasEngine } from '@workbench/canvas-operations/createCanvasEngine'; import type { EngineDeps, EngineRegistry } from '@workbench/canvas-operations/engineRegistry'; import type { CanvasProjectMutationPort } from '@workbench/canvasProjectMutationPort'; +import type * as WorkbenchContextModule from '@workbench/WorkbenchContext'; +import type { WorkbenchInternalStore } from '@workbench/workbenchStore'; +import type { ReactNode } from 'react'; import { ChakraProvider } from '@chakra-ui/react'; import { ResizeHandle } from '@platform/ui/ResizeHandle'; import { system } from '@theme/system'; import { createEngineRegistry } from '@workbench/canvas-operations/engineRegistry'; import { createEmptyCanvasState } from '@workbench/canvasMigration'; -import { act, StrictMode, useRef } from 'react'; +import { FocusRegionProvider, useFocusRegionProps } from '@workbench/focusRegions'; +import { createTestFocusController } from '@workbench/focusRegions.testing'; +import { WorkbenchFocusProvider } from '@workbench/WorkbenchRuntime'; +import { createWorkbenchStore } from '@workbench/workbenchStore'; +import { act, StrictMode, useRef, useState } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { userEvent } from 'vitest/browser'; +/** The surface reads the workbench only for key ownership: the active project and its widget placement. */ +const workbench = vi.hoisted(() => ({ store: null as unknown as WorkbenchInternalStore })); +vi.mock('@workbench/WorkbenchContext', async (importOriginal) => ({ + ...(await importOriginal()), + useWorkbenchInternalStore: () => workbench.store, + useWorkbenchQueries: () => workbench.store.queries, + useWorkbenchSubscription: () => workbench.store.subscribe, +})); + import { CanvasSurface } from './CanvasSurface'; +workbench.store = createWorkbenchStore(); + +/** Focus for the surface outside the workbench shell; ownership itself is covered under a real provider below. */ +const TestFocus = ({ children }: { children: ReactNode }) => { + const [controller] = useState(() => createTestFocusController()); + return {children}; +}; + type Cleanup = () => void | Promise; const cleanupStack: Cleanup[] = []; @@ -103,9 +127,11 @@ const nextFrame = (): Promise => const StrictCanvasHarness = ({ engine }: { engine: CanvasEngine }) => ( -
- -
+ +
+ +
+
); @@ -220,22 +246,24 @@ const ResizeDragHarness = ({ engine, showCanvas = true }: { engine: CanvasEngine return ( -
-
- -
- {showCanvas ? : null} + +
+
+ +
+ {showCanvas ? : null} +
-
+
); }; @@ -344,3 +372,149 @@ describe('CanvasSurface keyboard ownership', () => { await userEvent.keyboard('{/Space}'); }); }); + +/** The shell's layout in miniature: the canvas, the Generate panel and the Layers panel, each a focus region. */ +const WorkbenchHarness = ({ engine }: { engine: CanvasEngine }) => ( + + + +
+ +
+
+ + Model + + + +
+
+ Layer +
+
+
+
+
+); + +const Region = ({ + children, + instanceId, + region, + typeId, +}: { + children: ReactNode; + instanceId: string; + region: 'center' | 'left' | 'right'; + typeId: string; +}) => ( +
+ {children} +
+); + +describe('CanvasSurface key owner', () => { + const mount = async () => { + const registry = createTrackedRegistry(20); + const projectId = workbench.store.getSnapshot().activeProject.id; + const { engine } = acquireTrackedEngine(registry, projectId, createEngineDeps(createEmptyCanvasState(64, 64))); + const host = document.createElement('div'); + document.body.append(host); + const { root } = createTrackedRoot(host); + await act(async () => { + root.render(); + await nextFrame(); + }); + const overlay = host.querySelectorAll('canvas')[1]!; + return { + clickCanvas: () => userEvent.click(overlay, { position: { x: 4, y: 4 } }), + clickGenerate: () => userEvent.click(host.querySelector('span')!), + clickLayersRow: () => userEvent.click(host.querySelector('[role="treeitem"]')!), + engine, + hasSelection: () => engine.interaction.get('hasSelection'), + overlay, + prompt: host.querySelector('input')!, + tool: () => engine.interaction.get('activeTool'), + }; + }; + + it('keeps Escape and C from the canvas after a click in another widget', async () => { + const view = await mount(); + view.engine.tools.setTool('view'); + view.engine.selection.selectAll(); + await view.clickGenerate(); + + await userEvent.keyboard('{Escape}'); + await userEvent.keyboard('c'); + expect(view.hasSelection()).toBe(true); + expect(view.tool()).toBe('view'); + }); + + it('lets Escape and C act from the canvas and from a Layers row', async () => { + const view = await mount(); + view.engine.tools.setTool('view'); + view.engine.selection.selectAll(); + await view.clickLayersRow(); + + await userEvent.keyboard('{Escape}'); + expect(view.hasSelection()).toBe(false); + // Away from the canvas, a C tap selects the frame tool. + await userEvent.keyboard('c'); + expect(view.tool()).toBe('bbox'); + + view.engine.tools.setTool('view'); + view.engine.selection.selectAll(); + await view.clickCanvas(); + await userEvent.keyboard('{Escape}'); + expect(view.hasSelection()).toBe(false); + await userEvent.keyboard('{c>}'); + expect(view.tool()).toBe('bbox'); + await userEvent.keyboard('{/c}'); + }); + + it('applies Enter only from the canvas itself', async () => { + const view = await mount(); + view.engine.tools.setTool('lasso'); + view.engine.interaction.set('lassoOptions', { ...view.engine.interaction.get('lassoOptions'), shape: 'polygon' }); + for (const position of [ + { x: 20, y: 20 }, + { x: 120, y: 20 }, + { x: 120, y: 100 }, + ]) { + await userEvent.click(view.overlay, { position }); + } + + await view.clickLayersRow(); + await userEvent.keyboard('{Enter}'); + await view.clickGenerate(); + await userEvent.keyboard('{Enter}'); + expect(view.hasSelection()).toBe(false); + + await userEvent.click(view.overlay, { position: { x: 20, y: 100 } }); + await userEvent.keyboard('{Enter}'); + expect(view.hasSelection()).toBe(true); + }); + + it('keeps the Space hold over the hovered canvas after a click elsewhere, and never takes typing', async () => { + const view = await mount(); + view.engine.tools.setTool('brush'); + await view.clickGenerate(); + + await userEvent.hover(view.overlay); + await userEvent.keyboard('{Space>}'); + expect(view.tool()).toBe('view'); + await userEvent.keyboard('{/Space}'); + expect(view.tool()).toBe('brush'); + + await userEvent.click(view.prompt); + await userEvent.hover(view.overlay); + await userEvent.keyboard('c'); + expect(view.prompt.value).toBe('c'); + expect(view.tool()).toBe('brush'); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasSurface.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasSurface.tsx index be106f52faa..fe7e2dd7e47 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasSurface.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasSurface.tsx @@ -1,16 +1,21 @@ /* 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 { CanvasKeyOwner } from '@workbench/canvas-engine/api'; import type { CanvasEngineHandle } from '@workbench/canvas-operations/react'; import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react'; import { Box } from '@chakra-ui/react'; import { isResizeDragActive, subscribeResizeDrag } from '@platform/ui/ResizeHandle'; +import { useWorkbenchFocusTarget } from '@workbench/focusRegions'; +import { getHotkeyTargetWidget, resolveHotkeyTarget } from '@workbench/hotkeys/targetWidget'; +import { getWidgetPlacementProject } from '@workbench/widgetPlacementMeta'; import { shouldFocusCanvasSurface } from '@workbench/widgets/canvas/surfaceFocus'; import { TextEditPortal } from '@workbench/widgets/canvas/TextEditPortal'; +import { useWorkbenchQueries } from '@workbench/WorkbenchContext'; import { useRef } from 'react'; export type CanvasSurfaceEngine = Pick< CanvasEngineHandle, - 'document' | 'interaction' | 'layers' | 'surface' | 'viewport' + 'document' | 'interaction' | 'layers' | 'projectId' | 'surface' | 'viewport' >; /** @@ -27,9 +32,30 @@ const focusCanvasSurface = (event: ReactPointerEvent) => { * Bind document/overlay canvases and ResizeObserver through an engine-keyed ref callback with cleanup. The engine * owns input without React interaction renders. */ -export const CanvasSurface = ({ engine }: { engine: CanvasSurfaceEngine }) => { +export const CanvasSurface = ({ engine, instanceId }: { engine: CanvasSurfaceEngine; instanceId: string }) => { const screenRef = useRef(null); const overlayRef = useRef(null); + const getFocusTarget = useWorkbenchFocusTarget(); + const queries = useWorkbenchQueries(); + + // The engine's own keys ask the judge the hotkey runtime asks, with the same inputs, only on keydown: this canvas + // owns the key, the Layers widget acting on its layers is a companion, and any other widget keeps it. + const resolveKeyOwner = (event: KeyboardEvent): CanvasKeyOwner => { + const project = queries.getSnapshot().activeProject; + if (project.id !== engine.projectId) { + return 'elsewhere'; + } + const target = resolveHotkeyTarget({ + focusTarget: getFocusTarget(), + project: getWidgetPlacementProject(project), + targetWidget: getHotkeyTargetWidget(event.target), + }); + return target.activeInstanceId === instanceId + ? 'surface' + : target.activeWidgetTypeId === 'layers' + ? 'companion' + : 'elsewhere'; + }; const bindContainer = (container: HTMLDivElement) => { const screen = screenRef.current; @@ -38,7 +64,7 @@ export const CanvasSurface = ({ engine }: { engine: CanvasSurfaceEngine }) => { return; } - engine.surface.attach(screen, overlay, container); + engine.surface.attach(screen, overlay, container, { resolveKeyOwner }); // A handle drag defers the resize and full recomposition to its release; pixel CSS sizes make the interim // canvas crop or reveal rather than stretch. diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasWidgetView.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasWidgetView.tsx index dee57607ab4..1f9a321dcf2 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasWidgetView.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasWidgetView.tsx @@ -39,14 +39,16 @@ import { useModelGridSize } from './bboxGrid'; import { CanvasBottomOverlay } from './CanvasBottomOverlay'; import { decodeImageBlob, readClipboardImage } from './canvasClipboard'; import { + cropAllLayersToFrame, resolveCanvasContextMenu, resolveCanvasContextMenuBranch, type CanvasContextMenuTarget, } from './canvasContextMenu'; import { CanvasCreateFromBboxSubmenu } from './CanvasCreateFromBboxSubmenu'; +import { CanvasCropAllLayersMenuItem } from './CanvasCropAllLayersMenuItem'; import { CanvasEditRefusalNotices } from './CanvasEditRefusalNotices'; import { CanvasGlobalContextMenu } from './CanvasGlobalContextMenu'; -import { executeCanvasHotkeyCommand } from './canvasHotkeyCommands'; +import { CANVAS_ONLY_HOTKEYS, executeCanvasHotkeyCommand } from './canvasHotkeyCommands'; import { resolveCanvasImageDrop } from './canvasImageDnd'; import { CanvasImageDropOverlay } from './CanvasImageDropOverlay'; import { createCanvasInteractionLockReader, getCanvasInteractionCapabilities } from './canvasInteractionLock'; @@ -59,6 +61,7 @@ import { } from './canvasSettings'; import { CanvasSurface } from './CanvasSurface'; import { CanvasSurfaceContextLayout } from './CanvasSurfaceContextLayout'; +import { CANVAS_COMPANION_HOTKEYS, reportTransformRefusal } from './canvasToolCommands'; import { resolveCheckerColors } from './checkerColors'; import { CanvasColorFeed } from './color-system/CanvasColorFeed'; import { useActiveColorCommands } from './color-system/useActiveColors'; @@ -509,6 +512,11 @@ export const CanvasWidgetView = ({ runtime, instance }: WidgetViewProps) => { const selectedLayerIds = readLayerPanelState(projectId, document.selectedLayerId).selectedIds; executeCanvasHotkeyCommand(commandId, { copySelection, + cropAllLayers: () => { + if (engine) { + void cropAllLayersToFrame(engine.layers, notify, t); + } + }, dispatch: canvasDispatch, document, engine, @@ -519,6 +527,7 @@ export const CanvasWidgetView = ({ runtime, instance }: WidgetViewProps) => { notifications.add({ kind: 'error', title: t('widgets.layers.actions.copyFailed') }), pasteFromClipboard, reportLayerOperation: (refusal) => reportLayerOperation(refusal, notify.error, t), + reportTransformRefusal: (refusal) => reportTransformRefusal(refusal, notify.error, t), reportPreparedCommit: (outcome) => reportPreparedCommit(outcome, notify.error, t), reportStructuralCommit: (result) => reportStructuralCommit(result, notify.error, t), resetActiveColors: colorCommands.resetPair, @@ -529,64 +538,14 @@ export const CanvasWidgetView = ({ runtime, instance }: WidgetViewProps) => { }); useEffect(() => { - const hotkeys = [ - // Staging keeps `alt+[` / `alt+]`; bare left/right are registered as layer nudges, - // then intercepted above to cycle staging slots while any slot exists. - ['canvas.prevEntity', t('widgets.canvas.commands.previousEntity'), ['alt+[']], - ['canvas.nextEntity', t('widgets.canvas.commands.nextEntity'), ['alt+]']], - ['canvas.quickSwitch', t('widgets.canvas.commands.quickSwitch'), ['q']], - ['canvas.deleteSelected', t('widgets.canvas.commands.deleteSelected'), ['delete', 'backspace']], - ['canvas.resetSelected', t('widgets.canvas.commands.resetSelected'), ['shift+c']], - ['canvas.undo', t('widgets.canvas.commands.undo'), ['mod+z']], - ['canvas.redo', t('widgets.canvas.commands.redo'), ['mod+shift+z', 'mod+y']], - // Exclude editable targets so tool letters and size brackets do not intercept typing. - ['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'), []], - ['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.marquee', t('widgets.canvas.commands.selectMarqueeTool'), ['u']], - ['canvas.toggleNonRasterLayers', t('widgets.canvas.commands.toggleNonRasterLayers'), ['shift+h']], - ['canvas.copySelection', t('widgets.canvas.commands.copySelection'), ['mod+c']], - ['canvas.cutSelection', t('widgets.canvas.commands.cutSelection'), ['mod+x']], - ['canvas.pasteImage', t('widgets.canvas.commands.pasteImage'), ['mod+v']], - ['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'), [']']], - // The active color pair: X swaps, D resets to black/white. - ['canvas.toggleFillColor', t('widgets.canvas.commands.swapColors'), ['x']], - ['canvas.setFillColorsToDefault', t('widgets.canvas.commands.resetColors'), ['d']], - // 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.groupLayers', t('widgets.canvas.commands.groupLayers'), ['mod+g']], - ['canvas.ungroupLayers', t('widgets.canvas.commands.ungroupLayers'), ['mod+shift+g']], - ['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({ allowInEditable: false, commandId: id, defaultKeys: [...defaultKeys], id, title }), - ]); + // Exclude editable targets so tool letters and size brackets do not intercept typing. + const disposers = [...CANVAS_COMPANION_HOTKEYS, ...CANVAS_ONLY_HOTKEYS].flatMap(([id, titleKey, defaultKeys]) => { + const title = t(titleKey); + return [ + runtime.commands.register({ handler: () => executeCanvasHotkey(id), id, title }), + runtime.hotkeys.register({ allowInEditable: false, commandId: id, defaultKeys: [...defaultKeys], id, title }), + ]; + }); return () => { disposers.forEach((dispose) => dispose()); @@ -611,16 +570,21 @@ export const CanvasWidgetView = ({ runtime, instance }: WidgetViewProps) => { () => , [createFromBbox, isCompositeMenuDisabled] ); + const isCropAllLocked = isInteractionLocked || interactionCapabilities.isDocumentEditingLocked; const compositeSubmenus = useMemo( () => ( <> {saveToGallerySubmenu} {createFromBboxSubmenu} + {engine ? : null} ), - [createFromBboxSubmenu, saveToGallerySubmenu] + [createFromBboxSubmenu, engine, isCropAllLocked, saveToGallerySubmenu] + ); + const canvasSurface = useMemo( + () => (engine ? : null), + [engine, instance.id] ); - const canvasSurface = useMemo(() => (engine ? : null), [engine]); return ( { { projectId="project" selectedModel={SD1_MODEL} onCommit={vi.fn()} + onResetSize={vi.fn()} /> ); }; @@ -148,7 +149,7 @@ const badgeText = () => )?.textContent; const statusText = () => [...(host?.querySelectorAll('p') ?? [])].find((element) => element.textContent?.includes('MP'))?.textContent; -const optimalSizeButton = () => page.getByRole('button', { name: 'Set optimal size' }); +const resetSizeButton = () => page.getByRole('button', { name: 'Reset size' }); const renderAt = () => page.getByRole('combobox', { name: 'Render at' }); /** The dashed ghost in the size preview, in preview pixels. */ @@ -336,26 +337,14 @@ describe('Size section in canvas mode', () => { await expect.element(width).toHaveValue('1608'); }); - it('offers the optimal frame size only while the frame renders at its own size', async () => { - // An undersized frame: Auto grows it and Custom sets its own, so resizing the frame adds nothing. + it('offers the Reset size menu in every render mode, without a footer optimal-size button', async () => { await renderSizeSection(createProject({ frame: { height: 384, width: 384 }, model: SD1_MODEL })); - await expect.element(optimalSizeButton()).not.toBeInTheDocument(); + await expect.element(resetSizeButton()).toBeInTheDocument(); await chooseRenderAt('Custom'); - await expect.element(optimalSizeButton()).not.toBeInTheDocument(); + await expect.element(resetSizeButton()).toBeInTheDocument(); await chooseRenderAt('Frame size'); - await expect.element(optimalSizeButton()).toBeInTheDocument(); - await unmount(); - - // An oversized frame: Auto never shrinks it. - await renderSizeSection(createProject({ frame: { height: 768, width: 768 }, model: SD1_MODEL })); - await expect.element(optimalSizeButton()).toBeInTheDocument(); - await unmount(); - - // A frame already at the recommended size has nothing to offer in any mode. - await renderSizeSection( - createProject({ canvas: { scaleMethod: 'none' }, frame: { height: 512, width: 512 }, model: SD1_MODEL }) - ); - await expect.element(optimalSizeButton()).not.toBeInTheDocument(); + await expect.element(resetSizeButton()).toBeInTheDocument(); + await expect.element(page.getByRole('button', { name: 'Set optimal size' })).not.toBeInTheDocument(); }); it('leaves the Size section as it is when the canvas is not the invocation source', async () => { @@ -371,7 +360,7 @@ describe('Size section in canvas mode', () => { await expect.element(renderAt()).not.toBeInTheDocument(); expect(badgeText()).toBe('384x384'); expect(statusText()).toMatch(/^0\.15 MP/); - await expect.element(optimalSizeButton()).toBeInTheDocument(); + await expect.element(resetSizeButton()).toBeInTheDocument(); // The ghost is the recommended 512x512, not the canvas's custom render size. expect(ghostSize()).toEqual([88, 88]); }); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/ToolStrip.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/ToolStrip.browser.test.tsx index 75a7ebc7c01..617ff12ee74 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/ToolStrip.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/ToolStrip.browser.test.tsx @@ -357,6 +357,52 @@ describe('tool strip family slots', () => { } }); + /** Hover the slot fresh, so its tooltip opens with the current title. */ + const hoverSlot = async (slot: HTMLElement) => { + await act(() => userEvent.unhover(slot)); + await act(() => userEvent.hover(slot)); + }; + const tooltipText = () => document.querySelector('[role="tooltip"]')?.textContent ?? null; + const MARQUEE_HELP = + "Drag a rectangle or ellipse to select pixels on the active layer. Fill, erase, or move them to a new layer. Doesn't change the generation frame."; + const LASSO_HELP = + "Draw a freehand or polygon outline to select pixels on the active layer. Fill, erase, or move them to a new layer. Doesn't change the generation frame."; + + it('titles the selection slot with the current tool’s effective key and its help line, remaps included', async () => { + accountLifecycle.activate('tool-strip-test'); + await patchWorkbenchPreferences({ customHotkeys: { 'canvas.tool.lasso': ['k'] } }); + const extensions = createExtensionRegistry(); + const source = { instanceId: 'canvas', projectId: 'project-1', region: 'center', typeId: 'canvas' } as const; + for (const [id, key] of [ + ['canvas.tool.marquee', 'u'], + ['canvas.tool.lasso', 'l'], + ] as const) { + extensions.hotkeys.register({ commandId: id, defaultKeys: [key], id, source, title: id }); + } + workbench.extensions = extensions; + + try { + await renderStrip(); + const marquee = (await button('Marquee select').element()) as HTMLElement; + await hoverSlot(marquee); + await expect.poll(tooltipText).toBe(`Marquee select (U)${MARQUEE_HELP}`); + + await openByContextMenu(marquee); + await act(() => userEvent.click(page.getByRole('menuitemradio', { name: /^Lasso select/ }))); + // The lasso key was remapped from L to K; the title follows the effective binding. + await hoverSlot((await button('Lasso select').element()) as HTMLElement); + await expect.poll(tooltipText).toBe(`Lasso select (K)${LASSO_HELP}`); + } finally { + await patchWorkbenchPreferences({ customHotkeys: {} }); + } + }); + + it('titles a slot without a binding by its label alone', async () => { + await renderStrip(); + await hoverSlot((await button('Marquee select').element()) as HTMLElement); + await expect.poll(tooltipText).toBe(`Marquee select${MARQUEE_HELP}`); + }); + it('remembers the last-used selection tool across tool switches', async () => { const fake = await renderStrip(); await act(() => userEvent.click(button('Marquee select'))); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/ToolStrip.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/ToolStrip.tsx index 64a1729921a..69f4b1b44dc 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/ToolStrip.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/ToolStrip.tsx @@ -16,14 +16,14 @@ import { BrushIcon, CircleIcon, EraserIcon, - FrameIcon, HandIcon, LassoIcon, MousePointer2Icon, PenLineIcon, PentagonIcon, Rotate3dIcon, - SquareDashedIcon, + ScanIcon, + SquareDashedMousePointerIcon, SquareIcon, StarIcon, TriangleIcon, @@ -154,12 +154,14 @@ const SelectFamilyButton = ({ const items: ToolFlyoutItem[] = useMemo( () => [ { - icon: SquareDashedIcon, + description: t('widgets.canvas.tools.marqueeHelp'), + icon: SquareDashedMousePointerIcon, id: 'marquee', label: t('widgets.canvas.tools.marquee'), hotkeys: toolHotkeys(hotkeyDefinitions, customHotkeys, 'canvas.tool.marquee'), }, { + description: t('widgets.canvas.tools.lassoHelp'), icon: LassoIcon, id: 'lasso', label: t('widgets.canvas.tools.lasso'), @@ -180,7 +182,7 @@ const SelectFamilyButton = ({ { @@ -66,6 +69,7 @@ export const ToolFamilyButton = ({ }) => { // The menu anchors to, and returns focus to, the element carrying the trigger id: the slot, through its tooltip. // The slot is not a `Menu.Trigger`, whose click would open the menu instead of selecting the current subtool. + const { t } = useTranslation(); const ids = useTooltipTriggerIds(); const menuMachine = useMenu({ ids, positioning: MENU_POSITIONING }); const menu = menuMachine.api; @@ -229,12 +233,34 @@ export const ToolFamilyButton = ({ [disabled, items, menu, onSelectSubtool] ); + // The title names the slot with the current subtool's first effective binding; its help line follows. + const current = items.find((item) => item.id === currentId); + const currentHotkey = current?.hotkeys?.[0]; + const description = current?.description; + const title = currentHotkey + ? t('widgets.canvas.tools.withHotkey', { hotkey: formatHotkeyLabel(currentHotkey), label }) + : label; + const tooltip = useMemo( + () => + description ? ( + + {title} + + {description} + + + ) : ( + title + ), + [description, title] + ); + return ( {/* Forcing `open={false}` (not `disabled`) suppresses the tooltip while the menu is open: the disabled path unwraps the trigger and remounts the button, dropping pointer capture mid-hold. */} - + } }, +}); const baseOptions = { clientX: 240, @@ -63,3 +88,115 @@ describe('resolveCanvasContextMenu', () => { expect(resolveCanvasContextMenuBranch(resolution.target, false)).toBe('global'); }); }); + +describe('getCropAllUnavailableReasonKey', () => { + const documentWith = (nodes: CanvasNodeContract[]): CanvasDocumentContractV3 => ({ + ...createEmptyCanvasDocument(), + stacks: stacksFrom(nodes), + }); + + it('finds a croppable leaf in any stack, hidden or disabled ones included', () => { + expect( + getCropAllUnavailableReasonKey(documentWith([layerContract('mask', 'inpaint_mask', { isEnabled: false })])) + ).toBeNull(); + expect( + getCropAllUnavailableReasonKey(documentWith([groupContract('G', [layerContract('region', 'regional_guidance')])])) + ).toBeNull(); + }); + + it('says why there is nothing to crop: no layers, or every leaf locked by itself or a group above it', () => { + expect(getCropAllUnavailableReasonKey(documentWith([]))).toBe('widgets.canvas.contextMenu.cropAllNoLayers'); + expect( + getCropAllUnavailableReasonKey( + documentWith([ + layerContract('locked', 'raster', { isLocked: true }), + groupContract('G', [layerContract('inside', 'control')], { isLocked: true }), + ]) + ) + ).toBe('widgets.canvas.contextMenu.cropAllAllLocked'); + }); +}); + +describe('cropAllLayersToFrame', () => { + type CropResult = Awaited[0]['cropAllLayersToBbox']>>; + const notices = async (result: CropResult | Error) => { + const notify = { error: vi.fn(), info: vi.fn(), success: vi.fn() }; + const cropAllLayersToBbox = () => (result instanceof Error ? Promise.reject(result) : Promise.resolve(result)); + await cropAllLayersToFrame({ cropAllLayersToBbox }, notify, i18n.t); + return { + error: notify.error.mock.calls, + info: notify.info.mock.calls, + success: notify.success.mock.calls, + }; + }; + + const unreadable = (layerName: string) => ({ + layerId: layerName.toLowerCase(), + layerName, + status: 'unreadable' as const, + }); + + it.each([ + [{ cropped: 1, skippedLocked: 0, skippedUnreadable: [] }, ['Cropped 1 layer to the frame', undefined]], + [ + { cropped: 4, skippedLocked: 1, skippedUnreadable: [] }, + ['Cropped 4 layers to the frame', '1 locked layer skipped.'], + ], + [ + { cropped: 2, skippedLocked: 3, skippedUnreadable: [] }, + ['Cropped 2 layers to the frame', '3 locked layers skipped.'], + ], + [ + { cropped: 2, skippedLocked: 1, skippedUnreadable: [unreadable('Sky'), unreadable('Hills')] }, + [ + 'Cropped 2 layers to the frame', + '1 locked layer skipped. “Sky” and 1 other layer could not be read and were left as they are.', + ], + ], + [ + { cropped: 1, skippedLocked: 0, skippedUnreadable: [unreadable('Sky'), unreadable('Hills'), unreadable('Sea')] }, + ['Cropped 1 layer to the frame', '“Sky” and 2 other layers could not be read and were left as they are.'], + ], + ])('reports %o as cropped, with the layers it left as they were', async (counts, expected) => { + expect(await notices({ status: 'cropped', ...counts })).toEqual({ error: [], info: [], success: [expected] }); + }); + + it('says nothing needed cropping, and that only unlocked layers were checked when some were skipped', async () => { + expect((await notices({ skippedLocked: 0, skippedUnreadable: [], status: 'nothing' })).info).toEqual([ + ['Every layer already fits the frame', undefined], + ]); + expect((await notices({ skippedLocked: 2, skippedUnreadable: [], status: 'nothing' })).info).toEqual([ + ['Every unlocked layer already fits the frame', '2 locked layers skipped.'], + ]); + }); + + it('reports a crop that left only unreadable layers uncropped as a failure, never as still loading', async () => { + expect(await notices({ skippedLocked: 0, skippedUnreadable: [unreadable('Sky')], status: 'nothing' })).toEqual({ + error: [['No layer was cropped', '“Sky” could not be read and was left as it is.']], + info: [], + success: [], + }); + }); + + it('names the layer whose adjustments prevented the crop', async () => { + const result = await notices({ + ownerId: 'haze', + ownerName: 'Haze', + reason: 'too-large', + status: 'adjustment-refused', + }); + expect(result.error).toHaveLength(1); + expect(result.error[0]?.[1]).toContain('Haze'); + expect(result.info).toEqual([]); + expect(result.success).toEqual([]); + }); + + it('explains refusals and a rejected crop with the shared layer notice', async () => { + expect((await notices({ status: 'over-budget' })).error).toEqual([ + [i18n.t('widgets.layers.actions.actionFailed'), i18n.t('widgets.layers.actions.overBudget')], + ]); + expect((await notices(new Error('rejected'))).error).toEqual([ + [i18n.t('widgets.layers.actions.actionFailed'), i18n.t('widgets.layers.actions.operationFailed')], + ]); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasContextMenu.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasContextMenu.ts index 316f9c0661c..97ce90ff6d5 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasContextMenu.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasContextMenu.ts @@ -1,3 +1,11 @@ +import type { CanvasDocumentContractV3, CanvasEngineLayerCapability } from '@workbench/canvas-engine/api'; +import type { Notify } from '@workbench/useNotify'; +import type { TFunction } from 'i18next'; + +import { getDocumentIndex } from '@workbench/canvas-engine/api'; + +import { reportLayerOperation } from './useStructuralCommit'; + export interface CanvasContextMenuTarget { x: number; y: number; @@ -50,3 +58,76 @@ export const resolveCanvasContextMenu = ({ target: { layerId, x: clientX, y: clientY }, }; }; + +/** + * Why Crop all layers to frame cannot run on `document`, or null when it has a leaf to act on: one not locked by + * itself or a group above it. + */ +export const getCropAllUnavailableReasonKey = (document: CanvasDocumentContractV3): string | null => { + const { byId, leaves } = getDocumentIndex(document); + if (leaves.length === 0) { + return 'widgets.canvas.contextMenu.cropAllNoLayers'; + } + return leaves.some((leaf) => !leaf.isLocked && !byId.get(leaf.id)?.ancestorsLocked) + ? null + : 'widgets.canvas.contextMenu.cropAllAllLocked'; +}; + +/** + * Crops every unlocked layer to the frame as one undo step and says what happened: how many layers it cropped, how + * many locked ones it skipped, which layer could not be read (named, with a count of any others), or that nothing + * needed cropping. The canvas menu and the command share it. + */ +export const cropAllLayersToFrame = async ( + layers: Pick, + notify: Pick, + t: TFunction +): Promise => { + let result: Awaited>; + try { + result = await layers.cropAllLayersToBbox(); + } catch { + // The engine leaves every layer as it was when the transaction is rejected. + result = { status: 'failed' }; + } + switch (result.status) { + case 'cropped': + case 'nothing': { + const { skippedLocked, skippedUnreadable } = result; + const [firstUnreadable] = skippedUnreadable; + const skipped = + [ + skippedLocked > 0 ? t('widgets.canvas.contextMenu.croppedAllLayersSkipped', { count: skippedLocked }) : '', + !firstUnreadable + ? '' + : skippedUnreadable.length === 1 + ? t('widgets.canvas.contextMenu.cropAllUnreadable', { name: firstUnreadable.layerName }) + : t('widgets.canvas.contextMenu.cropAllUnreadableWithOthers', { + count: skippedUnreadable.length - 1, + name: firstUnreadable.layerName, + }), + ] + .filter(Boolean) + .join(' ') || undefined; + if (result.status === 'cropped') { + notify.success(t('widgets.canvas.contextMenu.croppedAllLayers', { count: result.cropped }), skipped); + } else if (firstUnreadable) { + // Those layers needed cropping and could not be read: nothing the user asked for happened. + notify.error(t('widgets.canvas.contextMenu.cropAllNoneCropped'), skipped); + } else { + notify.info( + t( + skipped ? 'widgets.canvas.contextMenu.cropAllNothingUnlocked' : 'widgets.canvas.contextMenu.cropAllNothing' + ), + skipped + ); + } + return; + } + case 'adjustment-refused': + reportLayerOperation(result, notify.error, t); + return; + default: + reportLayerOperation(result.status, notify.error, t); + } +}; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHeaderCommands.test.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHeaderCommands.test.ts index f71fcf43f06..9163ebdfb18 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHeaderCommands.test.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHeaderCommands.test.ts @@ -179,6 +179,18 @@ describe('executeCanvasHeaderCommand', () => { expect(saveToGallery).toHaveBeenCalledWith(region); }); + it.each([ + ['canvas.setZoomTo100Percent', 1], + ['canvas.setZoomTo200Percent', 2], + ['canvas.setZoomTo400Percent', 4], + ['canvas.setZoomTo800Percent', 8], + ] as const)('%s zooms to %d× about the viewport centre', (commandId, scale) => { + const { engine, zoomAtPoint } = createEngine({ height: 600, width: 800 }); + executeCanvasHeaderCommand(commandId, contextOf({ engine })); + expect(zoomAtPoint).toHaveBeenCalledExactlyOnceWith(scale, { x: 400, y: 300 }); + expect(engine.layers.commitStructural).not.toHaveBeenCalled(); + }); + it('ignores an unknown command id', () => { const openNewCanvas = vi.fn(); const { engine } = createEngine(); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHeaderCommands.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHeaderCommands.ts index db828429777..83e2186eb07 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHeaderCommands.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHeaderCommands.ts @@ -80,9 +80,36 @@ export const confirmNewCanvas = (ctx: NewCanvasContext): void => { ctx.engine.document.replaceDocument(createNewCanvasState(ctx.document.width, ctx.document.height).document); }; +/** The header's commands as `[id, titleKey, defaultKeys]`; the header registers them while the Canvas is mounted. */ +export const CANVAS_HEADER_HOTKEYS = [ + ['canvas.fitBboxToLayers', 'widgets.canvas.controls.fitBboxToLayers', ['shift+n']], + ['canvas.fitBboxToMasks', 'widgets.canvas.controls.fitBboxToMasks', ['shift+b']], + // The gallery saves have no default keys; they are assignable through the hotkeys settings. + ['canvas.saveToGallery', 'widgets.canvas.contextMenu.saveCanvasToGallery', []], + ['canvas.saveBboxToGallery', 'widgets.canvas.contextMenu.saveBboxToGallery', []], + // Clear canvas keeps its saved-binding id, `newSession`. + ['canvas.newSession', 'widgets.canvas.controls.clearCanvas', ['mod+alt+n']], + // Browsers reserve Mod+1…8 for tab switching; these act only where the browser delivers the chord. + ['canvas.setZoomTo100Percent', 'widgets.canvas.commands.zoomTo100', ['mod+1']], + ['canvas.setZoomTo200Percent', 'widgets.canvas.commands.zoomTo200', ['mod+2']], + ['canvas.setZoomTo400Percent', 'widgets.canvas.commands.zoomTo400', ['mod+3']], + ['canvas.setZoomTo800Percent', 'widgets.canvas.commands.zoomTo800', ['mod+4']], +] as const satisfies readonly (readonly [id: string, titleKey: string, defaultKeys: readonly string[]])[]; + +/** The zoom preset commands and the scale each sets about the viewport centre. */ +const ZOOM_PRESET_COMMANDS: Readonly> = { + 'canvas.setZoomTo100Percent': 1, + 'canvas.setZoomTo200Percent': 2, + 'canvas.setZoomTo400Percent': 4, + 'canvas.setZoomTo800Percent': 8, +}; + /** Route new-session commands through the button's confirmation dialog. */ export const executeCanvasHeaderCommand = (commandId: string, ctx: CanvasHeaderCommandContext): void => { - if (commandId === 'canvas.fitBboxToLayers') { + const zoomPreset = ZOOM_PRESET_COMMANDS[commandId]; + if (zoomPreset !== undefined) { + zoomAtViewportCentre(ctx.engine, zoomPreset); + } else if (commandId === 'canvas.fitBboxToLayers') { applyFitBbox(ctx, ctx.fitLayersRect, true); } else if (commandId === 'canvas.fitBboxToMasks') { applyFitBbox(ctx, ctx.fitMasksRect, false); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHotkeyCommands.test.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHotkeyCommands.test.ts index 4a35ce97902..df44d62856d 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHotkeyCommands.test.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHotkeyCommands.test.ts @@ -1,4 +1,5 @@ import type { + BeginTransformResult, CanvasDocumentContractV3, CanvasEngine, CanvasInteractionState, @@ -8,13 +9,16 @@ import type { NewRasterLayerResult, } from '@workbench/canvas-engine/api'; import type { CanvasProjectMutation } from '@workbench/canvasProjectMutations'; +import type { TFunction } from 'i18next'; import { createControlLayer, createDocumentModel } from '@workbench/canvas-engine/api'; import { groupContract, stacksFrom } from '@workbench/canvas-engine/document-model/documentFixtures.testStub'; import { stackTopAnchor } from '@workbench/canvas-engine/document/insertionAnchors.testStub'; import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; -import { executeCanvasHotkeyCommand, type CanvasHotkeyContext } from './canvasHotkeyCommands'; +import { CANVAS_HEADER_HOTKEYS } from './canvasHeaderCommands'; +import { CANVAS_ONLY_HOTKEYS, executeCanvasHotkeyCommand, type CanvasHotkeyContext } from './canvasHotkeyCommands'; +import { CANVAS_COMPANION_HOTKEYS, executeCanvasToolCommand, reportTransformRefusal } from './canvasToolCommands'; const rasterLayer = (id: string, overrides: Partial = {}): CanvasLayerContract => ({ @@ -77,6 +81,7 @@ const createEngine = (interaction: Partial = {}) => { status: 'duplicated', }) ), + beginTransform: vi.fn((): BeginTransformResult => 'started'), mergeLayerDown: vi.fn((): Promise => Promise.resolve('merged')), nudgeSelectedLayer: vi.fn(() => ({ status: 'committed' as const })), }, @@ -98,8 +103,10 @@ let pasteFromClipboard: Mock<() => void>; const contextOf = (overrides: Partial = {}): CanvasHotkeyContext => ({ copySelection, + cropAllLayers: vi.fn(), dispatch, reportLayerOperation: vi.fn(), + reportTransformRefusal: vi.fn(), reportPreparedCommit: () => undefined, reportStructuralCommit: () => undefined, document: documentOf([rasterLayer('a'), rasterLayer('b')], 'a'), @@ -451,6 +458,18 @@ describe('clipboard', () => { }); }); +describe('crop all layers to frame', () => { + it('runs the canvas menu action, but not while interaction is locked', () => { + const free = contextOf(); + executeCanvasHotkeyCommand('canvas.cropAllLayersToFrame', free); + expect(free.cropAllLayers).toHaveBeenCalledOnce(); + + const locked = contextOf({ isInteractionLocked: true }); + executeCanvasHotkeyCommand('canvas.cropAllLayersToFrame', locked); + expect(locked.cropAllLayers).not.toHaveBeenCalled(); + }); +}); + describe('tool selection', () => { it.each([ ['canvas.tool.view', 'view'], @@ -461,12 +480,54 @@ describe('tool selection', () => { ['canvas.tool.shape', 'shape'], ['canvas.tool.text', 'text'], ['canvas.tool.gradient', 'gradient'], - ['canvas.transformSelected', 'transform'], ])('%s selects the %s tool', (commandId, tool) => { const engine = run(commandId); expect(engine.tools.setTool).toHaveBeenCalledWith(tool); }); + it('transform opens a session on the selected leaf at once, without a separate tool switch', () => { + const engine = run('canvas.transformSelected'); + expect(engine.layers.beginTransform).toHaveBeenCalledExactlyOnceWith('a'); + expect(engine.tools.setTool).not.toHaveBeenCalled(); + }); + + it('transform only picks the tool with no selection or a group selected', () => { + const none = run('canvas.transformSelected', { document: documentOf([rasterLayer('a')], null) }); + expect(none.layers.beginTransform).not.toHaveBeenCalled(); + expect(none.tools.setTool).toHaveBeenCalledWith('transform'); + const group = run('canvas.transformSelected', { + document: { ...documentOf([], 'g'), stacks: stacksFrom([groupContract('g', [rasterLayer('a')])]) }, + }); + expect(group.layers.beginTransform).not.toHaveBeenCalled(); + expect(group.tools.setTool).toHaveBeenCalledWith('transform'); + }); + + it('transform explains a refusal and stays quiet mid-gesture', () => { + const report = (result: BeginTransformResult) => { + const engine = createEngine(); + engine.layers.beginTransform.mockReturnValue(result); + const reportTransformRefusal = vi.fn(); + executeCanvasHotkeyCommand('canvas.transformSelected', contextOf({ engine, reportTransformRefusal })); + expect(engine.tools.setTool).not.toHaveBeenCalled(); + return reportTransformRefusal.mock.calls; + }; + expect(report('locked')).toEqual([['locked']]); + expect(report('ineligible')).toEqual([['ineligible']]); + expect(report('busy')).toEqual([]); + }); + + it("gives an ineligible layer Transform's own reason and other refusals the shared layer notice", () => { + const explain = (refusal: Exclude) => { + const reportError = vi.fn(); + reportTransformRefusal(refusal, reportError, ((key: string) => key) as unknown as TFunction); + return reportError.mock.calls; + }; + expect(explain('ineligible')).toEqual([ + ['widgets.layers.actions.actionFailed', 'widgets.layers.actions.transformIneligible'], + ]); + expect(explain('locked')).toEqual([['widgets.layers.actions.actionFailed', 'widgets.layers.actions.locked']]); + }); + it('lasso selects the tool when not already active', () => { const engine = run('canvas.tool.lasso'); expect(engine.tools.setTool).toHaveBeenCalledWith('lasso'); @@ -727,6 +788,24 @@ describe('group selection', () => { expect.objectContaining({ forward: expect.objectContaining({ removeIds: ['g'] }) }) ); }); + + it('ungroup on leaves of one group moves them out above it, and does nothing for a top-level leaf', () => { + const document = nodeDocument([groupContract('g', [rasterLayer('a'), rasterLayer('b')]), rasterLayer('c')], 'a'); + const lifted = run('canvas.ungroupLayers', { document, selectedLayerIds: ['b', 'a'] }); + expect(lifted.layers.commitPrepared).toHaveBeenCalledExactlyOnceWith( + 'widgets.layers.actions.removeFromGroup', + expect.objectContaining({ + forward: expect.objectContaining({ + move: [{ anchor: expect.objectContaining({ beforeId: 'g', parentPath: [] }), ids: ['a', 'b'] }], + }), + }) + ); + const topLevel = run('canvas.ungroupLayers', { + document: { ...document, selectedLayerId: 'c' }, + selectedLayerIds: ['c'], + }); + expect(topLevel.layers.commitPrepared).not.toHaveBeenCalled(); + }); }); describe('active color pair commands', () => { @@ -744,3 +823,43 @@ describe('active color pair commands', () => { } }); }); + +describe('registration tables', () => { + // Everything the mounted Canvas registers: its own keys, the companion keys and the header's commands. The + // companion widget registers a subset (history plus companion keys), so it is covered too. + const allEntries = [...CANVAS_COMPANION_HOTKEYS, ...CANVAS_ONLY_HOTKEYS, ...CANVAS_HEADER_HOTKEYS]; + + it('gives Transform Shift+T as its only default', () => { + expect(CANVAS_COMPANION_HOTKEYS.find(([id]) => id === 'canvas.transformSelected')?.[2]).toEqual(['shift+t']); + expect(allEntries.flatMap(([, , keys]) => keys)).not.toContain('mod+t'); + }); + + it('never gives two entries the same id or default chord', () => { + const ids = allEntries.map(([id]) => id); + const chords = allEntries.flatMap(([, , keys]) => keys); + expect(new Set(ids).size).toBe(ids.length); + expect(chords.filter((chord, index) => chords.indexOf(chord) !== index)).toEqual([]); + expect(chords).toEqual(expect.arrayContaining(['mod+1', 'mod+2', 'mod+3', 'mod+4', 'mod+z', 'shift+t'])); + }); + + it('shares every companion key with the companion runner and nothing else', () => { + const engine = createEngine(); + const ctx = { + engine, + isInteractionLocked: false, + reportTransformRefusal: vi.fn(), + resetActiveColors: vi.fn(), + selectedLayerId: null, + swapActiveColors: vi.fn(), + }; + for (const [id] of CANVAS_COMPANION_HOTKEYS) { + executeCanvasToolCommand(id, ctx); + } + // Every companion key reaches the engine or the color pair; bbox has no default key but still runs. + expect(engine.tools.setTool.mock.calls.length + engine.tools.stepBrushSize.mock.calls.length).toBe( + CANVAS_COMPANION_HOTKEYS.length - 2 + ); + expect(ctx.swapActiveColors).toHaveBeenCalledOnce(); + expect(ctx.resetActiveColors).toHaveBeenCalledOnce(); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHotkeyCommands.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHotkeyCommands.ts index 25d676abf22..2ad7c516f53 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHotkeyCommands.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHotkeyCommands.ts @@ -1,5 +1,6 @@ import type { AdjustmentRefusal, + LayerUnreadable, CanvasDocumentContractV3, CanvasEngine, LayerStackMoveKind, @@ -15,6 +16,13 @@ import { isOverlayStack, } from '@workbench/canvas-engine/api'; import { publishLayerPanelSelection } from '@workbench/layerPanelState'; +import { + CANVAS_HISTORY_HOTKEYS, + executeCanvasToolCommand, + isCanvasToolCommand, + type CanvasToolCommandContext, + type HotkeyTableEntry, +} from '@workbench/widgets/canvas/canvasToolCommands'; import { commitPreparedEdit, type LayerOperationRefusal, @@ -22,12 +30,54 @@ import { } from '@workbench/widgets/canvas/useStructuralCommit'; import { canGroupSelection, + canRemoveFromGroup, canUngroupSelection, groupLayers, + removeFromGroup, ungroupLayers, } from '@workbench/widgets/layers/layerGroupCommands'; import { canMergeLayerDown } from '@workbench/widgets/layers/layerOps'; +/** Keys that act only while the Canvas itself holds focus; rows and fields own them elsewhere. */ +export const CANVAS_ONLY_HOTKEYS = [ + ...CANVAS_HISTORY_HOTKEYS, + // Staging keeps `alt+[` / `alt+]`; bare left/right are registered as layer nudges, + // then intercepted to cycle staging slots while any slot exists. + ['canvas.prevEntity', 'widgets.canvas.commands.previousEntity', ['alt+[']], + ['canvas.nextEntity', 'widgets.canvas.commands.nextEntity', ['alt+]']], + ['canvas.quickSwitch', 'widgets.canvas.commands.quickSwitch', ['q']], + ['canvas.deleteSelected', 'widgets.canvas.commands.deleteSelected', ['delete', 'backspace']], + ['canvas.resetSelected', 'widgets.canvas.commands.resetSelected', ['shift+c']], + ['canvas.toggleNonRasterLayers', 'widgets.canvas.commands.toggleNonRasterLayers', ['shift+h']], + ['canvas.copySelection', 'widgets.canvas.commands.copySelection', ['mod+c']], + ['canvas.cutSelection', 'widgets.canvas.commands.cutSelection', ['mod+x']], + ['canvas.pasteImage', 'widgets.canvas.commands.pasteImage', ['mod+v']], + // Selection: select all / deselect / invert (engine-owned transient selection). + ['canvas.selectAll', 'widgets.canvas.commands.selectAll', ['mod+a']], + ['canvas.deselect', 'widgets.canvas.commands.deselect', ['mod+d']], + ['canvas.invertSelection', 'widgets.canvas.commands.invertSelection', ['mod+shift+i']], + // Move the selected layer: arrows nudge 1px, shift+arrows 10px. + ['canvas.nudgeLeft', 'widgets.canvas.commands.nudgeLeft', ['arrowleft']], + ['canvas.nudgeRight', 'widgets.canvas.commands.nudgeRight', ['arrowright']], + ['canvas.nudgeUp', 'widgets.canvas.commands.nudgeUp', ['arrowup']], + ['canvas.nudgeDown', 'widgets.canvas.commands.nudgeDown', ['arrowdown']], + ['canvas.nudgeLeftLarge', 'widgets.canvas.commands.nudgeLeftLarge', ['shift+arrowleft']], + ['canvas.nudgeRightLarge', 'widgets.canvas.commands.nudgeRightLarge', ['shift+arrowright']], + ['canvas.nudgeUpLarge', 'widgets.canvas.commands.nudgeUpLarge', ['shift+arrowup']], + ['canvas.nudgeDownLarge', 'widgets.canvas.commands.nudgeDownLarge', ['shift+arrowdown']], + // Layer management. + ['canvas.duplicateLayer', 'widgets.canvas.commands.duplicateLayer', ['mod+j']], + ['canvas.groupLayers', 'widgets.canvas.commands.groupLayers', ['mod+g']], + ['canvas.ungroupLayers', 'widgets.canvas.commands.ungroupLayers', ['mod+shift+g']], + ['canvas.mergeDown', 'widgets.canvas.commands.mergeDown', ['mod+e']], + ['canvas.layerForward', 'widgets.canvas.commands.layerForward', ['mod+]']], + ['canvas.layerBackward', 'widgets.canvas.commands.layerBackward', ['mod+[']], + ['canvas.layerToFront', 'widgets.canvas.commands.layerToFront', ['mod+shift+]']], + ['canvas.layerToBack', 'widgets.canvas.commands.layerToBack', ['mod+shift+[']], + // No default key — assignable through the hotkeys settings; the canvas menu offers the same action. + ['canvas.cropAllLayersToFrame', 'widgets.canvas.contextMenu.cropAllLayersToBbox', []], +] as const satisfies readonly HotkeyTableEntry[]; + /** Command id → document-space nudge delta (shift variants are ×10). */ const NUDGE_DELTAS: Record = { 'canvas.nudgeDown': { dx: 0, dy: 1 }, @@ -87,26 +137,23 @@ const quickSwitchLayerId = (document: CanvasDocumentContractV3, engine: CanvasEn }; /** Pass hotkey dependencies explicitly so dispatch can be tested without mounting React. */ -export interface CanvasHotkeyContext { +export interface CanvasHotkeyContext extends Omit { readonly document: CanvasDocumentContractV3; readonly engine: CanvasEngine | null; /** Any staging slot exists, so left/right cycle candidates instead of nudging. */ readonly hasStagingSlots: boolean; /** A staged candidate is selected, so Delete discards it instead of touching layers. */ readonly hasSelectedStagedCandidate: boolean; - readonly isInteractionLocked: boolean; readonly selectedLayerIds: readonly string[]; readonly dispatch: CanvasProjectMutationDispatch; readonly copySelection: (cut: boolean) => void; - /** Swaps the project foreground/background pair — a preference, never a document edit. */ - readonly swapActiveColors: () => void; - /** Resets the pair to the black-on-white default. */ - readonly resetActiveColors: () => void; + /** Crops every unlocked layer to the frame and reports the outcome, as the canvas menu does. */ + readonly cropAllLayers: () => void; readonly pasteFromClipboard: () => void; readonly notifyLayerDuplicateFailed: () => void; readonly reportStructuralCommit: (result: StructuralCommitResult) => void; readonly reportPreparedCommit: (outcome: PreparedCommitOutcome) => void; - readonly reportLayerOperation: (refusal: LayerOperationRefusal | AdjustmentRefusal) => void; + readonly reportLayerOperation: (refusal: LayerOperationRefusal | AdjustmentRefusal | LayerUnreadable) => void; readonly t: (key: string) => string; } @@ -114,9 +161,9 @@ export interface CanvasHotkeyContext { * Routes one canvas command id to its effect. * * Ordering is load-bearing and reads top-down as a precedence list: staging - * cycling and staged-candidate discard win over layer commands, the interaction - * lock then blocks everything except the view tool, and only after those do - * nudge, reorder, and the per-command table apply. + * cycling and staged-candidate discard win over layer commands, companion keys + * apply their own lock rule, the interaction lock then blocks everything else, + * and only after those do nudge, reorder, and the per-command table apply. */ export const executeCanvasHotkeyCommand = (commandId: string, ctx: CanvasHotkeyContext): void => { const { dispatch, document, engine, t } = ctx; @@ -141,22 +188,12 @@ export const executeCanvasHotkeyCommand = (commandId: string, ctx: CanvasHotkeyC return; } - // The color pair is a preference, not a document edit, so X/D stay live even - // while the surface is interaction-locked. - if (commandId === 'canvas.toggleFillColor') { - ctx.swapActiveColors(); - return; - } - - if (commandId === 'canvas.setFillColorsToDefault') { - ctx.resetActiveColors(); + if (isCanvasToolCommand(commandId)) { + executeCanvasToolCommand(commandId, { ...ctx, selectedLayerId: selectedLayer?.id ?? null }); return; } if (ctx.isInteractionLocked) { - if (commandId === 'canvas.tool.view') { - engine?.tools.setTool('view'); - } return; } @@ -212,6 +249,8 @@ export const executeCanvasHotkeyCommand = (commandId: string, ctx: CanvasHotkeyC ctx.copySelection(commandId === 'canvas.cutSelection'); } else if (commandId === 'canvas.pasteImage') { ctx.pasteFromClipboard(); + } else if (commandId === 'canvas.cropAllLayersToFrame') { + ctx.cropAllLayers(); } else if (commandId === 'canvas.toggleNonRasterLayers') { // Hide overlays without disabling generation content; showing restores individually hidden nodes too. const overlays = index.nodes.filter((entry) => isOverlayStack(entry.stack)); @@ -242,71 +281,12 @@ export const executeCanvasHotkeyCommand = (commandId: string, ctx: CanvasHotkeyC engine?.history.undo(); } else if (commandId === 'canvas.redo') { engine?.history.redo(); - } else if (commandId === 'canvas.tool.view') { - engine?.tools.setTool('view'); - } else if (commandId === 'canvas.tool.move') { - engine?.tools.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?.tools.setTool('transform'); - } else if (commandId === 'canvas.tool.bbox') { - engine?.tools.setTool('bbox'); - } else if (commandId === 'canvas.tool.brush') { - engine?.tools.setTool('brush'); - } else if (commandId === 'canvas.tool.eraser') { - engine?.tools.setTool('eraser'); - } else if (commandId === 'canvas.tool.lasso') { - if (engine) { - // Repeating the active tool shortcut cycles its shape. - if (engine.interaction.get('activeTool') === 'lasso') { - const lasso = engine.interaction.get('lassoOptions'); - engine.interaction.set('lassoOptions', { - ...lasso, - shape: lasso.shape === 'freehand' ? 'polygon' : 'freehand', - }); - } else { - engine.tools.setTool('lasso'); - } - } - } else if (commandId === 'canvas.tool.marquee') { - if (engine) { - if (engine.interaction.get('activeTool') === 'marquee') { - const marquee = engine.interaction.get('marqueeOptions'); - engine.interaction.set('marqueeOptions', { - ...marquee, - kind: marquee.kind === 'rect' ? 'ellipse' : 'rect', - }); - } else { - engine.tools.setTool('marquee'); - } - } - } else if (commandId === 'canvas.tool.shape') { - if (engine) { - if (engine.interaction.get('activeTool') === 'shape') { - // Repeat presses cycle the kind, like the marquee hotkey. - const shape = engine.interaction.get('shapeOptions'); - const order = ['rect', 'ellipse', 'triangle', 'star', 'polygon', 'freehand'] as const; - const next = order[(order.indexOf(shape.kind) + 1) % order.length]!; - engine.interaction.set('shapeOptions', { ...shape, kind: next }); - } else { - engine.tools.setTool('shape'); - } - } - } else if (commandId === 'canvas.tool.text') { - engine?.tools.setTool('text'); - } else if (commandId === 'canvas.tool.gradient') { - engine?.tools.setTool('gradient'); } else if (commandId === 'canvas.selectAll') { engine?.selection.selectAll(); } else if (commandId === 'canvas.deselect') { engine?.selection.deselect(); } else if (commandId === 'canvas.invertSelection') { engine?.selection.invertSelection(); - } else if (commandId === 'canvas.brushSizeDown') { - engine?.tools.stepBrushSize(-1); - } else if (commandId === 'canvas.brushSizeUp') { - engine?.tools.stepBrushSize(1); } else if (commandId === 'canvas.duplicateLayer') { // With a live pixel selection, mod+J is "layer via copy" — it lifts just // the selected pixels. With none, it duplicates the whole layer. @@ -334,7 +314,7 @@ export const executeCanvasHotkeyCommand = (commandId: string, ctx: CanvasHotkeyC ctx.notifyLayerDuplicateFailed(); return; } - ctx.reportLayerOperation(result.status); + ctx.reportLayerOperation(result.status === 'unreadable' ? result : result.status); }) .catch(() => { // The engine keeps rejected transactions atomic; route both rejection @@ -349,8 +329,14 @@ export const executeCanvasHotkeyCommand = (commandId: string, ctx: CanvasHotkeyC ); } } else if (commandId === 'canvas.ungroupLayers') { - if (engine && selected && canUngroupSelection(engine.document.model(), ctx.selectedLayerIds)) { + // Groups dissolve; a selection of leaves of one group moves out above it instead. + const model = engine?.document.model() ?? null; + if (engine && selected && canUngroupSelection(model, ctx.selectedLayerIds)) { ctx.reportPreparedCommit(ungroupLayers(engine, ctx.selectedLayerIds, t('widgets.canvas.commands.ungroupLayers'))); + } else if (engine && selected && canRemoveFromGroup(model, ctx.selectedLayerIds)) { + ctx.reportPreparedCommit( + removeFromGroup(engine, ctx.selectedLayerIds, t('widgets.layers.actions.removeFromGroup')) + ); } } else if (commandId === 'canvas.mergeDown') { // Share canMergeLayerDown with the layer menu so shortcuts respect identical eligibility. diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasToolCommands.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasToolCommands.ts new file mode 100644 index 00000000000..56af262fa7e --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasToolCommands.ts @@ -0,0 +1,170 @@ +import type { BeginTransformResult, CanvasEngine } from '@workbench/canvas-engine/api'; +import type { TFunction } from 'i18next'; + +import { reportLayerOperation } from '@workbench/widgets/canvas/useStructuralCommit'; + +/** One hotkey row: its command id, title key and default keys. */ +export type HotkeyTableEntry = readonly [id: string, titleKey: string, defaultKeys: readonly string[]]; + +/** + * Canvas keys that also act while a companion widget (Layers, with its Color pane) holds focus: tools, the + * transform session, brush size and the color pair. The Canvas and the companion both register from this table; + * companions import this module alone, without the Canvas's full dispatcher (`canvasHotkeyCommands.ts`). + */ +export const CANVAS_COMPANION_HOTKEYS = [ + ['canvas.tool.view', 'widgets.canvas.commands.selectViewTool', ['h']], + ['canvas.tool.move', 'widgets.canvas.commands.selectMoveTool', ['v']], + ['canvas.transformSelected', 'widgets.canvas.commands.selectTransformTool', ['shift+t']], + ['canvas.tool.bbox', 'widgets.canvas.commands.selectBboxTool', []], + ['canvas.tool.brush', 'widgets.canvas.commands.selectBrushTool', ['b']], + ['canvas.tool.eraser', 'widgets.canvas.commands.selectEraserTool', ['e']], + ['canvas.tool.lasso', 'widgets.canvas.commands.selectLassoTool', ['l']], + ['canvas.tool.marquee', 'widgets.canvas.commands.selectMarqueeTool', ['u']], + ['canvas.tool.shape', 'widgets.canvas.commands.selectShapeTool', ['r']], + ['canvas.tool.text', 'widgets.canvas.commands.selectTextTool', ['t']], + ['canvas.tool.gradient', 'widgets.canvas.commands.selectGradientTool', ['g']], + ['canvas.brushSizeDown', 'widgets.canvas.commands.decreaseBrushSize', ['[']], + ['canvas.brushSizeUp', 'widgets.canvas.commands.increaseBrushSize', [']']], + // The active color pair: X swaps, D resets to black/white. + ['canvas.toggleFillColor', 'widgets.canvas.commands.swapColors', ['x']], + ['canvas.setFillColorsToDefault', 'widgets.canvas.commands.resetColors', ['d']], +] as const satisfies readonly HotkeyTableEntry[]; + +/** Canvas history, which the Canvas and its companion widgets both bind. */ +export const CANVAS_HISTORY_HOTKEYS = [ + ['canvas.undo', 'widgets.canvas.commands.undo', ['mod+z']], + ['canvas.redo', 'widgets.canvas.commands.redo', ['mod+shift+z', 'mod+y']], +] as const satisfies readonly HotkeyTableEntry[]; + +export type CanvasToolCommandId = (typeof CANVAS_COMPANION_HOTKEYS)[number][0]; + +const CANVAS_TOOL_COMMAND_IDS: ReadonlySet = new Set(CANVAS_COMPANION_HOTKEYS.map(([id]) => id)); + +export const isCanvasToolCommand = (commandId: string): commandId is CanvasToolCommandId => + CANVAS_TOOL_COMMAND_IDS.has(commandId); + +export type TransformRefusal = Exclude; + +/** Explains a refused `beginTransform`; a layer Transform cannot act on gets Transform's own reason. */ +export const reportTransformRefusal = ( + refusal: TransformRefusal, + reportError: (title: string, message: string) => void, + t: TFunction +): void => { + if (refusal === 'ineligible') { + reportError(t('widgets.layers.actions.actionFailed'), t('widgets.layers.actions.transformIneligible')); + } else { + reportLayerOperation(refusal, reportError, t); + } +}; + +/** What the companion keys need; the Canvas and the Layers widget each build it from their own state. */ +export interface CanvasToolCommandContext { + readonly engine: Pick | null; + readonly isInteractionLocked: boolean; + /** The selected leaf that Transform opens on; null when nothing or a group is selected. */ + readonly selectedLayerId: string | null; + /** Swaps the project foreground/background pair — a preference, never a document edit. */ + readonly swapActiveColors: () => void; + /** Resets the pair to the black-on-white default. */ + readonly resetActiveColors: () => void; + readonly reportTransformRefusal: (refusal: TransformRefusal) => void; +} + +/** + * Runs one companion key. The color pair is a preference, not a document edit, so X/D stay live while the surface + * is interaction-locked; of the rest only the view tool works then. + */ +export const executeCanvasToolCommand = (commandId: CanvasToolCommandId, ctx: CanvasToolCommandContext): void => { + const { engine } = ctx; + if (commandId === 'canvas.toggleFillColor') { + ctx.swapActiveColors(); + return; + } + if (commandId === 'canvas.setFillColorsToDefault') { + ctx.resetActiveColors(); + return; + } + if (ctx.isInteractionLocked && commandId !== 'canvas.tool.view') { + return; + } + if (!engine) { + return; + } + switch (commandId) { + case 'canvas.tool.view': + engine.tools.setTool('view'); + return; + case 'canvas.tool.move': + engine.tools.setTool('move'); + return; + case 'canvas.transformSelected': { + // A selected leaf opens its session at once, even with Transform already active; otherwise only the tool + // changes. Apply/Cancel (Enter/Escape) are handled engine-side. + if (ctx.selectedLayerId === null) { + engine.tools.setTool('transform'); + return; + } + const result = engine.layers.beginTransform(ctx.selectedLayerId); + // A key pressed mid-gesture is dropped quietly; the gesture already says why. + if (result !== 'started' && result !== 'busy') { + ctx.reportTransformRefusal(result); + } + return; + } + case 'canvas.tool.bbox': + engine.tools.setTool('bbox'); + return; + case 'canvas.tool.brush': + engine.tools.setTool('brush'); + return; + case 'canvas.tool.eraser': + engine.tools.setTool('eraser'); + return; + case 'canvas.tool.lasso': + // Repeating the active tool shortcut cycles its shape. + if (engine.interaction.get('activeTool') === 'lasso') { + const lasso = engine.interaction.get('lassoOptions'); + engine.interaction.set('lassoOptions', { + ...lasso, + shape: lasso.shape === 'freehand' ? 'polygon' : 'freehand', + }); + } else { + engine.tools.setTool('lasso'); + } + return; + case 'canvas.tool.marquee': + if (engine.interaction.get('activeTool') === 'marquee') { + const marquee = engine.interaction.get('marqueeOptions'); + engine.interaction.set('marqueeOptions', { + ...marquee, + kind: marquee.kind === 'rect' ? 'ellipse' : 'rect', + }); + } else { + engine.tools.setTool('marquee'); + } + return; + case 'canvas.tool.shape': + if (engine.interaction.get('activeTool') === 'shape') { + // Repeat presses cycle the kind, like the marquee hotkey. + const shape = engine.interaction.get('shapeOptions'); + const order = ['rect', 'ellipse', 'triangle', 'star', 'polygon', 'freehand'] as const; + const next = order[(order.indexOf(shape.kind) + 1) % order.length]!; + engine.interaction.set('shapeOptions', { ...shape, kind: next }); + } else { + engine.tools.setTool('shape'); + } + return; + case 'canvas.tool.text': + engine.tools.setTool('text'); + return; + case 'canvas.tool.gradient': + engine.tools.setTool('gradient'); + return; + case 'canvas.brushSizeDown': + engine.tools.stepBrushSize(-1); + return; + case 'canvas.brushSizeUp': + engine.tools.stepBrushSize(1); + } +}; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/color-system/CanvasColorFeed.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/color-system/CanvasColorFeed.browser.test.tsx new file mode 100644 index 00000000000..f8eea5c2800 --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/color-system/CanvasColorFeed.browser.test.tsx @@ -0,0 +1,363 @@ +/* oxlint-disable react-perf/jsx-no-new-function-as-prop */ +import type { CanvasLayerContract, CanvasLayerSourceContract, ColorSamplePhase } from '@workbench/canvas-engine/api'; +import type { CanvasEngine } from '@workbench/canvas-operations/createCanvasEngine'; +import type { Project } from '@workbench/projectContracts'; +import type * as workbenchContext from '@workbench/WorkbenchContext'; +import type { WorkbenchInternalStore } from '@workbench/workbenchStore'; + +import { layerContract, stacksFrom } from '@workbench/canvas-engine/document-model/documentFixtures.testStub'; +import { createCanvasEngine } from '@workbench/canvas-operations/createCanvasEngine'; +import { createEmptyCanvasDocument } from '@workbench/canvasMigration'; +import { createCanvasProjectMutationPort } from '@workbench/canvasProjectMutationPort'; +import { getProjectWidgetValues } from '@workbench/widgetState'; +import { createWorkbenchStore } from '@workbench/workbenchStore'; +import { createInstance } from 'i18next'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { I18nextProvider } from 'react-i18next'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +/** The real workbench store the hooks under test read and write, standing in for the provider. */ +const harness = vi.hoisted(() => ({ store: null as WorkbenchInternalStore | null })); + +vi.mock('@workbench/WorkbenchContext', async (importOriginal) => { + const actual = await importOriginal(); + const { useExternalStoreSelector, shallowEqual } = await import('@platform/state/selectors'); + const store = () => harness.store!; + return { + ...actual, + useActiveProjectSelector: (selector: (project: Project) => T, isEqual?: (a: T, b: T) => boolean) => + useExternalStoreSelector( + store().subscribe, + store().getSnapshot, + (snapshot) => selector(snapshot.activeProject), + isEqual ?? shallowEqual + ), + useWorkbenchCommands: () => store().commands, + useWorkbenchQueries: () => store().queries, + }; +}); + +import { CanvasColorFeed } from './CanvasColorFeed'; +import { armMaskTintTarget, clearMaskTintTarget } from './maskTintTarget'; +import { useMaskTintEditor, type MaskTintEditor } from './useMaskTintEditor'; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const i18n = createInstance(); +beforeAll(async () => { + const translation = (await (await fetch('/locales/en.json')).json()) as Record; + await i18n.init({ initAsync: false, lng: 'en', resources: { en: { translation } } }); +}); + +const TINT = '#e07575'; +const PICKED = '#3a7bd5'; +type ShapeSource = Extract; +const SHAPE_SOURCE = { + fill: '#ff0000', + height: 10, + kind: 'rect', + stroke: '#00ff00', + strokeWidth: 2, + type: 'shape', + width: 10, +} satisfies ShapeSource; +const TEXT_SOURCE = { + align: 'left', + color: '#123456', + content: 'Hello', + fontFamily: 'system-ui, sans-serif', + fontSize: 48, + fontWeight: 400, + lineHeight: 1.2, + type: 'text', +} satisfies Extract; + +type Router = (hex: string, phase: ColorSamplePhase) => boolean; + +/** The Color pane's tint editor, mounted beside the feed as the pane mounts it beside the canvas. */ +const TintEditorProbe = ({ + engine, + onEditor, +}: { + engine: CanvasEngine; + onEditor: (editor: MaskTintEditor | null) => void; +}) => { + onEditor(useMaskTintEditor(engine)); + return null; +}; + +const wait = (ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +const cleanups: (() => void | Promise)[] = []; +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) { + await cleanup(); + } + clearMaskTintTarget(); + harness.store = null; +}); + +/** + * A real engine over a project holding a mask, a shape, a text layer and a paint layer, with `selected` selected. The feed's registered router is captured so tests drive picks the way the color-picker tool does. + */ +const setup = async ( + selected: 'm0' | 'shape' | 'text' | 'paint', + { shapeSource = SHAPE_SOURCE, withTintEditor = false }: { shapeSource?: ShapeSource; withTintEditor?: boolean } = {} +) => { + const store = createWorkbenchStore(); + harness.store = store; + const projectId = store.getState().activeProjectId; + const layers: CanvasLayerContract[] = [ + layerContract('shape', 'raster', { name: 'Shape', source: shapeSource }), + layerContract('text', 'raster', { name: 'Text', source: TEXT_SOURCE }), + layerContract('paint', 'raster', { name: 'Paint' }), + layerContract('m0', 'inpaint_mask', { name: 'Mask' }), + ]; + store.commands.canvas.apply(projectId, { + document: { ...createEmptyCanvasDocument(), selectedLayerId: selected, stacks: stacksFrom(layers) }, + type: 'replaceCanvasDocument', + }); + const createEngine = (): CanvasEngine => { + const created = createCanvasEngine({ + ensureProjectOnServer: () => Promise.resolve(), + imageResolver: () => Promise.resolve(new Blob()), + mutationPort: createCanvasProjectMutationPort(store, projectId), + projectId, + reportError: () => undefined, + }); + cleanups.push(() => created.lifecycle.dispose()); + return created; + }; + const engine = createEngine(); + const [screen, overlay] = [document.createElement('canvas'), document.createElement('canvas')]; + for (const canvas of [screen, overlay]) { + canvas.width = 64; + canvas.height = 64; + document.body.append(canvas); + cleanups.push(() => canvas.remove()); + } + engine.surface.attach(screen, overlay); + + let router: Router | null = null; + const setRouter = engine.tools.setColorSampleRouter.bind(engine.tools); + vi.spyOn(engine.tools, 'setColorSampleRouter').mockImplementation((next) => { + router = next; + return setRouter(next); + }); + + const host = document.createElement('div'); + document.body.append(host); + const root: Root = createRoot(host); + cleanups.push(async () => { + await act(() => root.unmount()); + host.remove(); + }); + let tintEditor: MaskTintEditor | null = null; + const renderFeed = (feedEngine: CanvasEngine) => + act(() => { + root.render( + + + {withTintEditor ? (tintEditor = editor)} /> : null} + + ); + }); + await renderFeed(engine); + expect(router).not.toBeNull(); + + const document_ = () => store.getState().projects.find((project) => project.id === projectId)!.canvas.document; + const layer = (id: string) => engine.document.model()!.getLayer(id)!; + const canvasValues = () => + getProjectWidgetValues( + store.getState().projects.find((project) => project.id === projectId)!, + 'canvas' + ); + /** One pick: previews each color, then ends with `end` carrying the last one, as the tool does. */ + const pick = (colors: readonly string[], end: 'commit' | 'cancel') => + act(() => { + for (const hex of colors) { + router!(hex, 'preview'); + } + router!(colors.at(-1)!, end); + }); + return { + canvasValues, + document: document_, + engine, + layer, + createEngine, + pick, + projectId, + renderFeed, + router: () => router!, + store, + tintEditor: () => tintEditor, + }; +}; + +const tintOf = (layer: CanvasLayerContract) => (layer.type === 'inpaint_mask' ? layer.mask.fill.color : null); +const sourceOf = (layer: CanvasLayerContract) => (layer.type === 'raster' ? layer.source : null); + +describe('CanvasColorFeed Alt-pick routing', () => { + it('writes the armed mask tint as one undoable edit and leaves the pair alone', async () => { + const view = await setup('m0'); + armMaskTintTarget('m0'); + const pairBefore = view.canvasValues().activeColors; + + await act(() => view.router()('#101010', 'preview')); + // A preview shows live, on the next frame, without recording history. + await expect.poll(() => tintOf(view.layer('m0'))).toBe('#101010'); + expect(view.engine.history.getEntries().past).toEqual([]); + + await act(() => { + view.router()(PICKED, 'preview'); + view.router()(PICKED, 'commit'); + }); + expect(tintOf(view.layer('m0'))).toBe(PICKED); + expect(view.engine.history.getEntries().past).toEqual(['Fill']); + expect(view.canvasValues().activeColors).toEqual(pairBefore); + + await act(() => view.engine.history.undo()); + expect(tintOf(view.layer('m0'))).toBe(TINT); + }); + + it('restores the tint from before the pick on cancel', async () => { + const view = await setup('m0'); + armMaskTintTarget('m0'); + await view.pick(['#101010', PICKED], 'cancel'); + expect(tintOf(view.layer('m0'))).toBe(TINT); + expect(view.engine.history.getEntries().past).toEqual([]); + }); + + it('records a pending Color pane tint edit before an Alt-pick, so the pick lands last as its own step', async () => { + const view = await setup('m0', { withTintEditor: true }); + await act(() => armMaskTintTarget('m0')); + // A pane edit waits out its settle delay before recording; the pick arrives inside that window. + await act(() => view.tintEditor()!.commit('#111111')); + await view.pick([PICKED], 'commit'); + expect(tintOf(view.layer('m0'))).toBe(PICKED); + expect(view.engine.history.getEntries().past).toEqual(['Fill', 'Fill']); + + // The pane's settle delay passing changes nothing more. + await act(() => wait(500)); + expect(tintOf(view.layer('m0'))).toBe(PICKED); + expect(view.engine.history.getEntries().past).toEqual(['Fill', 'Fill']); + + await act(() => view.engine.history.undo()); + expect(tintOf(view.layer('m0'))).toBe('#111111'); + }); + + it('keeps the pick on its target when the selection moves mid-pick', async () => { + const view = await setup('m0'); + armMaskTintTarget('m0'); + await act(() => view.router()('#101010', 'preview')); + await act(() => { + view.store.commands.canvas.apply(view.projectId, { id: 'paint', type: 'setCanvasSelectedLayer' }); + }); + await act(() => view.router()(PICKED, 'commit')); + expect(tintOf(view.layer('m0'))).toBe(PICKED); + expect(view.canvasValues().activeColors).toBeUndefined(); + }); + + it.each([ + ['foreground', 'fill'], + ['background', 'stroke'], + ] as const)('writes a selected shape’s %s-target color to its %s in one undo step', async (target, field) => { + const view = await setup('shape'); + await act(() => view.store.commands.widgets.patchValues('canvas', { activeColorTarget: target })); + const pairBefore = view.canvasValues().activeColors; + + await view.pick(['#101010', PICKED], 'commit'); + expect(sourceOf(view.layer('shape'))).toMatchObject({ ...SHAPE_SOURCE, [field]: PICKED }); + expect(view.engine.history.getEntries().past).toEqual(['Edit shape']); + expect(view.canvasValues().activeColors).toEqual(pairBefore); + + await act(() => view.engine.history.undo()); + expect(sourceOf(view.layer('shape'))).toEqual(SHAPE_SOURCE); + }); + + it.each([ + ['foreground', 'fill', { ...SHAPE_SOURCE, fill: null }], + ['background', 'stroke', { ...SHAPE_SOURCE, stroke: null }], + ] as const)( + 'leaves a selected shape’s %s-target %s off when the shape has none, and picks into the pair', + async (target, _field, shapeSource) => { + const view = await setup('shape', { shapeSource }); + await act(() => view.store.commands.widgets.patchValues('canvas', { activeColorTarget: target })); + + await view.pick(['#101010', PICKED], 'commit'); + + expect(sourceOf(view.layer('shape'))).toEqual(shapeSource); + expect(view.engine.history.getEntries().past).toEqual([]); + expect(view.canvasValues().activeColors).toMatchObject({ [target]: PICKED }); + } + ); + + it('does not turn a shape’s stroke back on when it goes off mid-pick', async () => { + const view = await setup('shape'); + await act(() => view.store.commands.widgets.patchValues('canvas', { activeColorTarget: 'background' })); + await act(() => view.router()('#101010', 'preview')); + + const strokeless = { ...SHAPE_SOURCE, stroke: null }; + await act(() => + view.store.commands.canvas.apply(view.projectId, { + document: { + ...view.document(), + stacks: stacksFrom([layerContract('shape', 'raster', { name: 'Shape', source: strokeless })]), + }, + type: 'replaceCanvasDocument', + }) + ); + await act(() => view.router()(PICKED, 'commit')); + + expect(sourceOf(view.layer('shape'))).toEqual(strokeless); + }); + + it('writes a selected text layer’s color in one undo step', async () => { + const view = await setup('text'); + await view.pick([PICKED], 'commit'); + expect(sourceOf(view.layer('text'))).toMatchObject({ color: PICKED }); + expect(view.engine.history.getEntries().past).toEqual(['Edit text']); + }); + + it('writes the pair, with no history, when nothing is armed — the Gradient tool included', async () => { + const view = await setup('paint'); + await act(() => view.engine.tools.setTool('gradient')); + await act(() => view.store.commands.widgets.patchValues('canvas', { activeColorTarget: 'background' })); + + await act(() => view.router()('#101010', 'preview')); + // The pair follows every preview, as before. + expect(view.canvasValues().activeColors).toEqual({ background: '#101010', foreground: '#000000' }); + await act(() => view.router()(PICKED, 'commit')); + expect(view.canvasValues().activeColors).toEqual({ background: PICKED, foreground: '#000000' }); + expect(view.engine.history.getEntries().past).toEqual([]); + }); + + it('restores the pair color from before the pick on cancel', async () => { + const view = await setup('paint'); + await view.pick(['#101010', PICKED], 'cancel'); + expect(view.canvasValues().activeColors).toEqual({ background: '#ffffff', foreground: '#000000' }); + }); + + it('registers its router again with a new engine instance for the same project', async () => { + const view = await setup('paint'); + const replacement = view.createEngine(); + const registered: Router[] = []; + const setRouter = replacement.tools.setColorSampleRouter.bind(replacement.tools); + vi.spyOn(replacement.tools, 'setColorSampleRouter').mockImplementation((next) => { + registered.push(next); + return setRouter(next); + }); + expect(replacement.projectId).toBe(view.engine.projectId); + + await view.renderFeed(replacement); + + expect(registered).toHaveLength(1); + await act(() => registered[0]!('#101010', 'preview')); + expect(view.canvasValues().activeColors).toEqual({ background: '#ffffff', foreground: '#101010' }); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/color-system/CanvasColorFeed.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/color-system/CanvasColorFeed.tsx index ac0f21c7096..583a40af96e 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/color-system/CanvasColorFeed.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/color-system/CanvasColorFeed.tsx @@ -1,16 +1,71 @@ +import type { ColorSamplePhase } from '@workbench/canvas-engine/api'; import type { CanvasEngineHandle } from '@workbench/canvas-operations/react'; -import { useEffect } from 'react'; +import { useMountEffect } from '@platform/react/useMountEffect'; +import { usePreparedCommit, useStructuralPreview } from '@workbench/widgets/canvas/useStructuralCommit'; +import { getProjectWidgetValues } from '@workbench/widgetState'; +import { useWorkbenchQueries } from '@workbench/WorkbenchContext'; +import { useEffect, useEffectEvent, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { ActiveColorTarget } from './colorPair'; + +import { readActiveColorPair, readActiveColorTarget } from './colorPair'; +import { maskTintTargetStore, settlePendingMaskTint } from './maskTintTarget'; import { useActiveColorCommands, useActiveColorPair } from './useActiveColors'; +import { + armedMaskLayer, + isMaskLayer, + MASK_TINT_EDIT_LABEL_KEY, + maskTintPreview, + prepareMaskTintEdit, +} from './useMaskTintEditor'; + +/** + * What one eyedropper pick writes, fixed at its first sample so a selection change mid-pick cannot retarget it. + * Gradient stops have no armed state, so a pick on the Gradient tool writes the pair. A live text session is no + * target: its editor holds focus and keeps Alt, and leaving it commits the session. + */ +type PickTarget = + | { kind: 'mask-tint'; layerId: string } + | { kind: 'shape'; layerId: string; field: 'fill' | 'stroke' } + | { kind: 'text-layer'; layerId: string } + | { kind: 'pair'; target: ActiveColorTarget; before: string }; + +/** + * Precedence: the armed mask tint while its mask is still selected; a selected shape (the foreground target writes + * its fill, the background its stroke) when it has that slot; a selected text layer; otherwise the pair. A shape's + * off slot is no target, as in its own options, where the toggle alone turns a slot on. + */ +const resolvePickTarget = (engine: CanvasEngineHandle, canvasValues: Record): PickTarget => { + const model = engine.document.model(); + const document = model?.document ?? null; + const pairTarget = readActiveColorTarget(canvasValues); + if (document) { + const tint = armedMaskLayer(document, maskTintTargetStore.getSnapshot().layerId); + if (tint) { + return { kind: 'mask-tint', layerId: tint.id }; + } + } + const selected = document?.selectedLayerId ? (model?.getLayer(document.selectedLayerId) ?? null) : null; + if (selected?.type === 'raster' && selected.source.type === 'shape') { + const field = pairTarget === 'foreground' ? 'fill' : 'stroke'; + if (selected.source[field] !== null) { + return { field, kind: 'shape', layerId: selected.id }; + } + } + if (selected?.type === 'raster' && selected.source.type === 'text') { + return { kind: 'text-layer', layerId: selected.id }; + } + return { before: readActiveColorPair(canvasValues)[pairTarget], kind: 'pair', target: pairTarget }; +}; /** * Isolate frequent color-pair updates in this null adapter. Mirror the pair into brush color, captured at stroke - * start, and route unclaimed samples to the active pair target. + * start, and route eyedropper picks to the armed color target. */ export const CanvasColorFeed = ({ engine }: { engine: CanvasEngineHandle | null }) => { const colorPair = useActiveColorPair(); - const colorCommands = useActiveColorCommands(); useEffect(() => { if (!engine) { @@ -31,15 +86,121 @@ export const CanvasColorFeed = ({ engine }: { engine: CanvasEngineHandle | null engine.interaction.set('colorPair', { background: colorPair.background, foreground: colorPair.foreground }); }, [engine, colorPair.background, colorPair.foreground]); - useEffect(() => { - if (!engine) { - return; - } - return engine.tools.setColorSampleRouter((hex) => { - colorCommands.applySampledColor(hex); - return true; + // Keyed by engine identity, so each engine instance (a project's engine recreated after disposal included) mounts + // its own routing and registers its router exactly once. + return engine ? : null; +}; + +// Created on first use: a module-scope global constructor call would make this module side-effectful. +let engineKeys: WeakMap | null = null; +let nextEngineKey = 0; +/** A stable React key per engine object; held weakly, so a disposed engine's entry goes with it. */ +const engineKey = (engine: CanvasEngineHandle): string => { + engineKeys ??= new WeakMap(); + let key = engineKeys.get(engine); + if (key === undefined) { + nextEngineKey += 1; + key = `engine-${nextEngineKey}`; + engineKeys.set(engine, key); + } + return key; +}; + +/** Owns the engine's eyedropper router and the pick in progress. */ +const ColorSampleRouting = ({ engine }: { engine: CanvasEngineHandle }) => { + const { t } = useTranslation(); + const colorCommands = useActiveColorCommands(); + const queries = useWorkbenchQueries(); + const tintPreview = useStructuralPreview(engine); + const commitPrepared = usePreparedCommit(engine); + const pickRef = useRef(null); + + /** Commits the shape's or text layer's color through the same edit and history label as its own picker. */ + const commitSourceColor = (target: Extract, hex: string): void => { + const label = + target.kind === 'shape' ? t('widgets.canvas.toolOptions.shapeEdit') : t('widgets.canvas.toolOptions.textEdit'); + commitPrepared(label, (model) => { + const layer = model.getLayer(target.layerId); + if (layer?.type !== 'raster') { + return { ids: [target.layerId], status: 'missing' }; + } + if (target.kind === 'shape' && layer.source.type === 'shape') { + // The slot can go off mid-pick (an undo, say); the pick never turns it back on. + if (layer.source[target.field] === null) { + return { status: 'unchanged' }; + } + return model.prepare({ id: layer.id, source: { ...layer.source, [target.field]: hex }, type: 'patch-source' }); + } + if (target.kind === 'text-layer' && layer.source.type === 'text') { + return model.prepare({ id: layer.id, source: { ...layer.source, color: hex }, type: 'patch-source' }); + } + return { ids: [target.layerId], status: 'missing' }; }); - }, [colorCommands, engine]); + }; + + // Previews are live and unrecorded; the release records one edit, and a cancel restores the color from before the + // pick. Shape and text-layer colors live in the layer source, which has no live preview, so they change on release. + const routeSample = useEffectEvent((hex: string, phase: ColorSamplePhase): boolean => { + const startsPick = pickRef.current === null; + const target = + pickRef.current ?? + resolvePickTarget(engine, getProjectWidgetValues(queries.getSnapshot().activeProject, 'canvas')); + pickRef.current = phase === 'preview' ? target : null; + switch (target.kind) { + case 'mask-tint': { + if (phase === 'cancel') { + tintPreview.cancel(); + break; + } + if (phase === 'preview') { + // The pane's own tint gesture records first: the canvas holds one preview at a time. + if (startsPick) { + settlePendingMaskTint(); + } + // The held layer, even if the selection moved mid-pick. + const layer = engine.document.model()?.getLayer(target.layerId) ?? null; + if (isMaskLayer(layer)) { + tintPreview.preview(maskTintPreview(layer, hex)); + } + break; + } + tintPreview.commit(t(MASK_TINT_EDIT_LABEL_KEY), (model, baseline) => + prepareMaskTintEdit(model, baseline, target.layerId, hex) + ); + break; + } + case 'shape': + case 'text-layer': + if (phase === 'commit') { + commitSourceColor(target, hex); + } + break; + case 'pair': + colorCommands.setPairColor(target.target, phase === 'cancel' ? target.before : hex); + break; + } + return true; + }); + + /** A pick cut short by unmount records nothing. */ + const abandonPick = useEffectEvent((): void => { + if (pickRef.current?.kind === 'mask-tint') { + tintPreview.cancel(); + } + pickRef.current = null; + }); + + // The parent keys this component by engine, so the router registers once per engine instance. + // The linter cannot see useMountEffect as an effect, so it rejects calling the effect events from it. + /* eslint-disable react-hooks/rules-of-hooks -- useMountEffect is the repository's explicit useEffect wrapper */ + useMountEffect(() => { + const dispose = engine.tools.setColorSampleRouter((hex, phase) => routeSample(hex, phase)); + return () => { + dispose(); + abandonPick(); + }; + }); + /* eslint-enable react-hooks/rules-of-hooks */ return null; }; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/color-system/maskTintTarget.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/color-system/maskTintTarget.ts index 38d1b739f0b..e4ec68aa627 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/color-system/maskTintTarget.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/color-system/maskTintTarget.ts @@ -7,13 +7,38 @@ import { createExternalStore } from '@platform/state/externalStore'; */ export const maskTintTargetStore = createExternalStore<{ layerId: string | null }>({ layerId: null }); +/** + * Records the Color pane's tint gesture that previews but has not yet recorded. The canvas allows one structural + * preview at a time, so an Alt-pick into the tint records this gesture before it starts its own; otherwise the + * pane's late settle would land after the pick and overwrite it. + */ +let pendingTintSettle: (() => void) | null = null; + registerAccountOwnedResource({ clear: () => { maskTintTargetStore.setSnapshot({ layerId: null }); + pendingTintSettle = null; }, name: 'mask-tint-target', }); +/** Registers `settle` as the gesture an Alt-pick must record first. */ +export const holdPendingMaskTint = (settle: () => void): void => { + pendingTintSettle = settle; +}; + +/** Forgets `settle` once its gesture recorded, leaving a newer gesture's registration in place. */ +export const releasePendingMaskTint = (settle: () => void): void => { + if (pendingTintSettle === settle) { + pendingTintSettle = null; + } +}; + +/** Records any pending pane tint gesture now, before another tint preview begins. */ +export const settlePendingMaskTint = (): void => { + pendingTintSettle?.(); +}; + export const armMaskTintTarget = (layerId: string): void => { maskTintTargetStore.setSnapshot({ layerId }); }; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/color-system/useMaskTintEditor.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/color-system/useMaskTintEditor.ts index d6a65e53955..f92d4e46a6f 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/color-system/useMaskTintEditor.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/color-system/useMaskTintEditor.ts @@ -1,13 +1,25 @@ -import type { CanvasLayerContract, CanvasMaskFillContract } from '@workbench/canvas-engine/api'; +import type { + CanvasDocumentContractV3, + CanvasDocumentModel, + CanvasLayerContract, + CanvasLayerPreviewMutation, + CanvasMaskFillContract, + PrepareEditResult, +} from '@workbench/canvas-engine/api'; import type { CanvasEngineHandle } from '@workbench/canvas-operations/react'; import { getDocumentLayer } from '@workbench/canvas-engine/api'; import { baselineConfig, useStructuralPreview } from '@workbench/widgets/canvas/useStructuralCommit'; import { useActiveProjectSelector } from '@workbench/WorkbenchContext'; -import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { clearMaskTintTarget, useMaskTintTargetLayerId } from './maskTintTarget'; +import { + clearMaskTintTarget, + holdPendingMaskTint, + releasePendingMaskTint, + useMaskTintTargetLayerId, +} from './maskTintTarget'; type MaskTintEngine = Pick; type MaskLayer = Extract; @@ -35,7 +47,7 @@ interface TintGesture { timer: ReturnType | null; } -const isMaskLayer = (layer: CanvasLayerContract | null): layer is MaskLayer => +export const isMaskLayer = (layer: CanvasLayerContract | null): layer is MaskLayer => layer?.type === 'inpaint_mask' || layer?.type === 'regional_guidance'; const configFor = (layer: MaskLayer, fill: CanvasMaskFillContract) => @@ -43,6 +55,40 @@ const configFor = (layer: MaskLayer, fill: CanvasMaskFillContract) => ? ({ layerType: 'inpaint_mask', mask: { fill } } as const) : ({ layerType: 'regional_guidance', mask: { fill } } as const); +/** The history label every tint edit records under, whichever control or pick made it. */ +export const MASK_TINT_EDIT_LABEL_KEY = 'widgets.layers.maskFill.fill'; + +/** The armed tint target's layer while it is still the selected mask; anything else disarms it. */ +export const armedMaskLayer = (document: CanvasDocumentContractV3, armedLayerId: string | null): MaskLayer | null => { + if (!armedLayerId || document.selectedLayerId !== armedLayerId) { + return null; + } + const node = getDocumentLayer(document, armedLayerId); + return node && isMaskLayer(node) ? node : null; +}; + +/** The un-recorded live preview of `layer` tinted `hex`. */ +export const maskTintPreview = (layer: MaskLayer, hex: string): CanvasLayerPreviewMutation => ({ + config: configFor(layer, { ...layer.mask.fill, color: hex }), + id: layer.id, + type: 'updateCanvasLayerConfig', +}); + +/** The recorded tint edit for the live layer, its `before` taken from what a preview gesture replaced. */ +export const prepareMaskTintEdit = ( + model: CanvasDocumentModel, + baseline: CanvasLayerPreviewMutation | null, + layerId: string, + hex: string +): PrepareEditResult => { + const live = model.getLayer(layerId); + if (!isMaskLayer(live)) { + return { ids: [layerId], status: 'missing' }; + } + const config = configFor(live, { ...live.mask.fill, color: hex }); + return model.prepare({ before: baselineConfig(baseline, config), config, id: live.id, type: 'patch-config' }); +}; + /** * Clear invalid/unselected mask targets and return to pair editing. Settle each pending preview into exactly one * history entry on idle, disarm, or unmount. @@ -51,35 +97,27 @@ export const useMaskTintEditor = (engine: MaskTintEngine | null): MaskTintEditor const { t } = useTranslation(); const { commit: commitPrepared, preview: previewStructural } = useStructuralPreview(engine); const armedLayerId = useMaskTintTargetLayerId(); - const layer = useActiveProjectSelector((project): MaskLayer | null => { - if (!armedLayerId || project.canvas.document.selectedLayerId !== armedLayerId) { - return null; - } - const node = getDocumentLayer(project.canvas.document, armedLayerId); - return node && (node.type === 'inpaint_mask' || node.type === 'regional_guidance') ? node : null; - }); + const layer = useActiveProjectSelector((project) => armedMaskLayer(project.canvas.document, armedLayerId)); const gestureRef = useRef(null); + const settleRef = useRef<() => void>(() => undefined); + // One stable identity for the shared pending-gesture slot, always running the latest settle. + const [settleLatest] = useState(() => () => settleRef.current()); const settle = useCallback(() => { const gesture = gestureRef.current; gestureRef.current = null; if (!gesture) { return; } + releasePendingMaskTint(settleLatest); if (gesture.timer !== null) { clearTimeout(gesture.timer); } // A gesture ending on the color it started from prepares nothing, which drops its previews. - commitPrepared(t('widgets.layers.maskFill.fill'), (model, baseline) => { - const live = model.getLayer(gesture.layerId); - if (!isMaskLayer(live)) { - return { ids: [gesture.layerId], status: 'missing' }; - } - const config = configFor(live, { ...live.mask.fill, color: gesture.latestHex }); - return model.prepare({ before: baselineConfig(baseline, config), config, id: live.id, type: 'patch-config' }); - }); - }, [commitPrepared, t]); - const settleRef = useRef(settle); + commitPrepared(t(MASK_TINT_EDIT_LABEL_KEY), (model, baseline) => + prepareMaskTintEdit(model, baseline, gesture.layerId, gesture.latestHex) + ); + }, [commitPrepared, settleLatest, t]); useEffect(() => { settleRef.current = settle; }, [settle]); @@ -103,22 +141,17 @@ export const useMaskTintEditor = (engine: MaskTintEngine | null): MaskTintEditor if (!layer) { return; } - if ( - !previewStructural({ - config: configFor(layer, { ...layer.mask.fill, color: hex }), - id: layer.id, - type: 'updateCanvasLayerConfig', - }) - ) { + if (!previewStructural(maskTintPreview(layer, hex))) { return; } if (gestureRef.current === null) { gestureRef.current = { latestHex: hex, layerId: layer.id, timer: null }; + holdPendingMaskTint(settleLatest); } else { gestureRef.current.latestHex = hex; } }, - [previewStructural, layer] + [previewStructural, layer, settleLatest] ); const commit = useCallback( (hex: string) => { diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/engineStoreHooks.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/engineStoreHooks.ts index 0bd2c2fae84..5f6975b48eb 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/engineStoreHooks.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/engineStoreHooks.ts @@ -184,16 +184,6 @@ export const useCanvasDocumentEditingLocked = (engine: CanvasCoreStoreCapability return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); }; -/** Re-renders when any live layer cache gains, loses, or changes pixels. */ -export const useCanvasLayerPixelEpoch = (engine: CanvasCoreStoreCapability | null): number => { - const subscribe = useCallback( - (listener: () => void) => engine?.interaction.subscribe('layerPixelEpoch', listener) ?? (() => undefined), - [engine] - ); - const getSnapshot = useCallback(() => engine?.interaction.get('layerPixelEpoch') ?? 0, [engine]); - return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); -}; - /** Read/write brush options directly through engine.interaction; no reducer mirror exists. */ export const useBrushOptions = (engine: CanvasCoreStoreCapability): BrushOptions => useCanvasInteractionState(engine, 'brushOptions'); @@ -226,6 +216,10 @@ export const useBboxGrid = (engine: CanvasCoreStoreCapability): number => useCan export const useTransformSession = (engine: CanvasCoreStoreCapability): TransformSession | null => useCanvasInteractionState(engine, 'transformSession'); +/** The layer a requested transform waits on until its stroke saves; null when nothing is waiting. */ +export const usePendingTransformLayerId = (engine: CanvasCoreStoreCapability): string | null => + useCanvasInteractionState(engine, 'pendingTransformLayerId'); + /** Whether a pixel selection currently exists (enables fill/erase/invert/deselect controls). */ export const useCanvasHasSelection = (engine: CanvasCoreStoreCapability): boolean => useCanvasInteractionState(engine, 'hasSelection'); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/fitBbox.test.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/fitBbox.test.ts index b894c221019..b98f1318a6c 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/fitBbox.test.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/fitBbox.test.ts @@ -118,21 +118,77 @@ describe('computeFitBboxToMasks', () => { 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 }); + // mask bounds {100,100,40,40} → blur 0 pads 8 → 92..148 → grid 8 outward → 88..152. + expect(computeFitBboxToMasks(doc, 8, 0)).toEqual({ height: 64, width: 64, x: 88, y: 88 }); + }); + + it('pads by the mask blur plus 8 so the feathered edge stays inside the frame', () => { + const doc = docWith([inpaintMask('m', 200, 200, { height: 100, imageName: 'm-img', width: 100 })]); + // Default blur 16 pads 24: {176,176,148,148}. Blur 0 pads 8: {192,192,116,116}. + expect(computeFitBboxToMasks(doc, 1, 16)).toEqual({ height: 148, width: 148, x: 176, y: 176 }); + expect(computeFitBboxToMasks(doc, 1, 0)).toEqual({ height: 116, width: 116, x: 192, y: 192 }); + }); + + it('grid-snaps the blur-padded rect outward', () => { + const doc = docWith([inpaintMask('m', 100, 100, { height: 40, imageName: 'm-img', width: 40 })]); + // Blur 16 pads 24 → 76..164; grid 8 floors the origin to 72 and ceils the far edge to 168. + expect(computeFitBboxToMasks(doc, 8, 16)).toEqual({ height: 96, width: 96, x: 72, y: 72 }); + }); + + /** The frame holds the whole feathered mask: its bounds grown by the blur on every side. */ + const expectContainsFeather = ( + frame: { x: number; y: number; width: number; height: number } | null, + mask: { x: number; y: number; width: number; height: number }, + blur: number + ) => { + expect(frame).not.toBeNull(); + expect(frame!.x).toBeLessThanOrEqual(mask.x - blur); + expect(frame!.y).toBeLessThanOrEqual(mask.y - blur); + expect(frame!.x + frame!.width).toBeGreaterThanOrEqual(mask.x + mask.width + blur); + expect(frame!.y + frame!.height).toBeGreaterThanOrEqual(mask.y + mask.height + blur); + }; + + it.each([ + // Grid 16, mask 105..205: padded 81..229 snaps to 80..240. Inward snapping gave 96..224, cutting 89..96. + [16, 105, { height: 160, width: 160, x: 80, y: 80 }], + // Grid 32, mask 121..221: padded 97..245 snaps to 96..256. Inward snapping gave 128..224, cutting the mask itself. + [32, 121, { height: 160, width: 160, x: 96, y: 96 }], + ] as const)('keeps the blurred edge inside a grid-%i frame', (grid, origin, expected) => { + const mask = { height: 100, width: 100, x: origin, y: origin }; + const doc = docWith([inpaintMask('m', origin, origin, { height: 100, imageName: 'm-img', width: 100 })]); + + const frame = computeFitBboxToMasks(doc, grid, 16); + + expect(frame).toEqual(expected); + expectContainsFeather(frame, mask, 16); + }); + + it('contains the feathered mask at every offset on 16 and 32 px grids', () => { + for (const grid of [16, 32]) { + for (let offset = 0; offset < grid; offset += 1) { + const mask = { height: 37, width: 53, x: 200 + offset, y: 300 - offset }; + const doc = docWith([inpaintMask('m', mask.x, mask.y, { height: 37, imageName: 'm-img', width: 53 })]); + const frame = computeFitBboxToMasks(doc, grid, 16); + + expectContainsFeather(frame, mask, 16); + for (const value of [frame!.x, frame!.y, frame!.width, frame!.height]) { + expect(value % grid).toBe(0); + } + } + } }); 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 }); + expect(computeFitBboxToMasks(doc, 8, 0)).toEqual({ height: 64, width: 64, x: 88, y: 88 }); // Regional masks alone ⇒ nothing to fit. - expect(computeFitBboxToMasks(docWith([regional]), 8)).toBeNull(); + expect(computeFitBboxToMasks(docWith([regional]), 8, 0)).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(); + expect(computeFitBboxToMasks(docWith([imageLayer('a', 0, 0, 100, 100)]), 8, 16)).toBeNull(); + expect(computeFitBboxToMasks(docWith([inpaintMask('empty', 0, 0, null)]), 8, 16)).toBeNull(); }); }); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/fitBbox.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/fitBbox.ts index e1bbaa95781..0352f53e2ab 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/fitBbox.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/fitBbox.ts @@ -1,7 +1,7 @@ /** - * Union enabled, renderable, nonempty layer bounds in document space. Mask fitting includes only inpaint masks and - * adds {@link MASK_FIT_PADDING}; snap inward with {@link fitRectToGrid}. Return null for no content or a collapsed - * result. + * Union enabled, renderable, nonempty layer bounds in document space. Fitting to layers snaps inward with + * {@link fitRectToGrid}. Mask fitting includes only inpaint masks, pads by the mask blur plus {@link MASK_FIT_MARGIN} + * and snaps outward, so no grid cuts the feathered edge. Return null for no content or a collapsed result. */ import { @@ -15,8 +15,11 @@ import { union, } from '@workbench/canvas-engine/api'; -/** Fixed outward padding (document px) applied to the mask union before grid-fitting. */ -const MASK_FIT_PADDING = 8; +/** + * Outward margin (document px) beyond the mask blur. The blur feathers the mask past its painted edge, so the frame + * has to clear the blur radius for the soft edge to land inside the generated region. + */ +const MASK_FIT_MARGIN = 8; /** * Snap inward: round origins up and sizes down. gridSize <= 1 uses integer rounding, matching legacy @@ -31,6 +34,16 @@ export const fitRectToGrid = (rect: Rect, gridSize: number): Rect => { return { height, width, x, y }; }; +/** Snap outward: round origins down and far edges up, so the result contains `rect`. */ +const expandRectToGrid = (rect: Rect, gridSize: number): Rect => { + const g = gridSize > 1 ? gridSize : 1; + const x = Math.floor(rect.x / g) * g; + const y = Math.floor(rect.y / g) * g; + const width = Math.ceil((rect.x + rect.width) / g) * g - x; + const height = Math.ceil((rect.y + rect.height) / g) * g - y; + return { height, width, x, y }; +}; + /** Union document bounds of enabled, renderable, nonempty layers matching predicate; return null when none qualify. */ export const unionRenderableBounds = ( doc: CanvasDocumentContractV3, @@ -61,22 +74,26 @@ export const computeFitBboxToLayers = (doc: CanvasDocumentContractV3, 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. */ +/** + * The grid-aligned frame that contains the visible INPAINT masks padded by `maskBlur` plus {@link MASK_FIT_MARGIN}, + * or `null` when there are none. `maskBlur` is the project's clamped compositing mask blur. Snapping inward would cut + * up to a grid step less one pixel from each side, more than the margin on 16 and 32 px grids. + */ export const computeFitBboxToMasks = ( doc: CanvasDocumentContractV3, gridSize: number, - padding: number = MASK_FIT_PADDING + maskBlur: number ): Rect | null => { const bounds = unionRenderableBounds(doc, (layer) => layer.type === 'inpaint_mask'); if (!bounds) { return null; } + const padding = maskBlur + MASK_FIT_MARGIN; 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; + return expandRectToGrid(expanded, gridSize); }; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/frameIcons.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/frameIcons.ts new file mode 100644 index 00000000000..e882b05e40d --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/frameIcons.ts @@ -0,0 +1,17 @@ +import { createLucideIcon } from 'lucide-react'; + +/** + * Fit frame to masks: the frame tool's viewfinder corners around a diamond, so it reads as the frame's own family and + * stays distinct from the inpaint-mask type icon. The corners are Lucide `Scan` 1.46.0's four paths, inlined because + * lucide-react exports no typed per-icon node; the icon inherits Lucide's sizing, stroke width and round joins. + */ +export const FitFrameToMasksIcon = createLucideIcon({ + name: 'fit-frame-to-masks', + node: [ + ['path', { d: 'M3 7V5a2 2 0 0 1 2-2h2', key: 'scan-top-left' }], + ['path', { d: 'M17 3h2a2 2 0 0 1 2 2v2', key: 'scan-top-right' }], + ['path', { d: 'M21 17v2a2 2 0 0 1-2 2h-2', key: 'scan-bottom-right' }], + ['path', { d: 'M7 21H5a2 2 0 0 1-2-2v-2', key: 'scan-bottom-left' }], + ['path', { d: 'M12 7.5 16.5 12 12 16.5 7.5 12Z', key: 'diamond' }], + ], +}); 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 index 95fffc232a4..49215e4bd46 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.test.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.test.ts @@ -704,6 +704,22 @@ describe('runCanvasInvocation', () => { ]); }); + it.each([ + [ + { layerId: 'layer-7', layerName: 'Sky', status: 'unreadable' } as const, + '“Sky” could not be read (its image may have been deleted or damaged). Disable or delete that layer, then invoke again.', + ], + [{ status: 'not-ready' } as const, 'Some layers are still loading. Wait a moment, then invoke again.'], + ])('explains a %o capture and dispatches nothing', async (result, message) => { + const harness = makeHarness(); + harness.deps.composeForGeneration = () => Promise.resolve(result); + + await runCanvasInvocation(harness.deps); + + expect(harness.submittedGraphs()).toHaveLength(0); + expect(harness.notices().map((notice) => notice.message)).toEqual([message]); + }); + it('uses the frozen canvas and detached pixels after edits at composite, upload, and queue-dispatch boundaries', async () => { let liveDocument = makeDoc([rasterLayer('frozen-layer')], 96); liveDocument.bbox = { height: 64, width: 64, x: 7, y: 11 }; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.ts index eacb2e38ff3..fc54e6031a6 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.ts @@ -363,7 +363,11 @@ export const runCanvasInvocation = async (deps: RunCanvasInvocationDeps): Promis ? 'The canvas changed while generation was being prepared. Please invoke again.' : composed.status === 'adjustment-refused' ? ADJUSTMENT_REFUSAL_NOTICES[composed.reason](composed.ownerName) - : `The canvas could not be captured for generation (${composed.status}).` + : composed.status === 'unreadable' + ? `“${composed.layerName}” could not be read (its image may have been deleted or damaged). Disable or delete that layer, then invoke again.` + : composed.status === 'not-ready' + ? 'Some layers are still loading. Wait a moment, then invoke again.' + : `The canvas could not be captured for generation (${composed.status}).` ); return; } diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/FilterOptions.test.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/FilterOptions.test.tsx index 1622dbab0bd..27f7ea04b5b 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/FilterOptions.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/FilterOptions.test.tsx @@ -160,6 +160,7 @@ describe('getFilterErrorTranslationKey', () => { 'source-adjustment-over-budget': true, 'source-adjustment-too-large': true, 'source-over-budget': true, + 'source-unreadable': true, stale: true, unsupported: true, }; @@ -226,9 +227,11 @@ describe('filter operation form', () => { expect(footer).toContain('Portrait · Raster layer'); expect(footer).toContain('role="status"'); - // Auto-process on: no Process verb; Reset then Apply then Cancel. + // Auto-process on: no Process verb; Reset settings then Apply then Cancel. expect(footer).not.toContain('>Process<'); - expect(footer.indexOf('>Reset<')).toBeLessThan(footer.indexOf('>Apply<')); + expect(footer).not.toContain('>Reset<'); + expect(footer.indexOf('>Reset settings<')).toBeGreaterThan(-1); + expect(footer.indexOf('>Reset settings<')).toBeLessThan(footer.indexOf('>Apply<')); expect(footer.indexOf('>Apply<')).toBeLessThan(footer.indexOf('>Cancel<')); }); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/FilterOptions.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/FilterOptions.tsx index 5d66bfc5f95..279dc73fa2a 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/FilterOptions.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/FilterOptions.tsx @@ -88,6 +88,7 @@ const FILTER_ERROR_TRANSLATION_KEYS: Record = { 'source-adjustment-over-budget': 'widgets.layers.rasterFilter.sourceAdjustmentOverBudget', 'source-adjustment-too-large': 'widgets.layers.rasterFilter.sourceAdjustmentTooLarge', 'source-over-budget': 'widgets.layers.rasterFilter.sourceOverBudget', + 'source-unreadable': 'widgets.layers.rasterFilter.sourceUnreadable', stale: 'widgets.layers.rasterFilter.stale', unsupported: 'widgets.layers.rasterFilter.unsupported', }; @@ -239,7 +240,7 @@ const FilterFooter = ({ engine, isExternalInteractionLocked }: ToolFooterProps) /> {session.autoProcess ? null : ( + +
+ ); +}; + +let host: HTMLDivElement | null = null; +let root: Root | null = null; +let engine = createEngine(); + +const colorPair = () => readActiveColorPair(getProjectWidgetValues(mocks.store.getSnapshot().activeProject, 'canvas')); + +beforeEach(async () => { + mocks.store = createWorkbenchStore(); + mocks.extensions = createExtensionRegistry(); + engine = createEngine(); + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + await act(() => + root!.render( + + + + + ) + ); +}); + +afterEach(async () => { + await act(() => root?.unmount()); + host?.remove(); + host = null; + root = null; +}); + +describe('useCanvasCompanionHotkeys', () => { + it('selects canvas tools from a clicked layer row without taking focus from it', async () => { + const row = host!.querySelector('[role="treeitem"]')!; + await userEvent.click(row); + + await userEvent.keyboard('b'); + + expect(engine.tools.setTool).toHaveBeenCalledExactlyOnceWith('brush'); + expect(document.activeElement).toBe(row); + }); + + it('swaps the color pair and steps the brush from a clicked control, and switches tools from it', async () => { + const before = colorPair(); + await userEvent.click(host!.querySelector('button')!); + + await userEvent.keyboard('x'); + await userEvent.keyboard(']'); + await userEvent.keyboard('e'); + + expect(colorPair()).toEqual({ background: before.foreground, foreground: before.background }); + expect(engine.tools.stepBrushSize).toHaveBeenCalledExactlyOnceWith(1); + expect(engine.tools.setTool).toHaveBeenCalledExactlyOnceWith('eraser'); + }); + + it('keeps undo and redo on the canvas history', async () => { + await userEvent.click(host!.querySelector('[role="treeitem"]')!); + + await userEvent.keyboard(IS_MAC_OS ? '{Meta>}z{/Meta}' : '{Control>}z{/Control}'); + + expect(engine.history.undo).toHaveBeenCalledOnce(); + }); + + it('leaves typing in a field alone', async () => { + const field = host!.querySelector('input')!; + await userEvent.click(field); + + await userEvent.keyboard('b'); + + expect(field.value).toBe('b'); + expect(engine.tools.setTool).not.toHaveBeenCalled(); + }); + + it('withdraws every registration on unmount', async () => { + await act(() => root!.unmount()); + root = null; + + expect(mocks.extensions.stores.hotkeys.list()).toEqual([]); + expect(mocks.extensions.stores.commands.list()).toEqual([]); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/layers/useCanvasCompanionHotkeys.ts b/invokeai/frontend/webv2/src/workbench/widgets/layers/useCanvasCompanionHotkeys.ts new file mode 100644 index 00000000000..e07e6c0208a --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/layers/useCanvasCompanionHotkeys.ts @@ -0,0 +1,74 @@ +import type { CanvasEngineHandle } from '@workbench/canvas-operations/react'; +import type { WidgetCommandApi, WidgetHotkeyApi } from '@workbench/widgetContracts'; + +import { useNotify } from '@workbench/useNotify'; +import { isCanvasInteractionLocked } from '@workbench/widgets/canvas/canvasInteractionLock'; +import { + CANVAS_COMPANION_HOTKEYS, + CANVAS_HISTORY_HOTKEYS, + executeCanvasToolCommand, + reportTransformRefusal, + type CanvasToolCommandId, +} from '@workbench/widgets/canvas/canvasToolCommands'; +import { useActiveColorCommands } from '@workbench/widgets/canvas/color-system/useActiveColors'; +import { useWorkbenchQueries } from '@workbench/WorkbenchContext'; +import { useEffect, useEffectEvent } from 'react'; +import { useTranslation } from 'react-i18next'; + +type CompanionCommandId = (typeof CANVAS_HISTORY_HOTKEYS)[number][0] | CanvasToolCommandId; + +export type CanvasCompanionEngine = Pick< + CanvasEngineHandle, + 'document' | 'history' | 'interaction' | 'layers' | 'tools' +>; + +/** + * Registers the canvas keys that stay live while a companion widget (Layers and its Color pane) holds focus: + * undo/redo and the shared tool, brush-size and color table. Rows keep arrows, Delete and selection. + */ +export const useCanvasCompanionHotkeys = ( + runtime: { commands: Pick; hotkeys: WidgetHotkeyApi }, + engine: CanvasCompanionEngine | null +): void => { + const { t } = useTranslation(); + const notify = useNotify(); + const queries = useWorkbenchQueries(); + const colorCommands = useActiveColorCommands(); + const run = useEffectEvent((commandId: CompanionCommandId) => { + if (commandId === 'canvas.undo') { + engine?.history.undo(); + return; + } + if (commandId === 'canvas.redo') { + engine?.history.redo(); + return; + } + const { canvas, queue } = queries.getSnapshot().activeProject; + const model = engine?.document.model() ?? null; + const selectedId = model?.document.selectedLayerId ?? null; + executeCanvasToolCommand(commandId, { + engine, + isInteractionLocked: isCanvasInteractionLocked(canvas, queue.items), + reportTransformRefusal: (refusal) => reportTransformRefusal(refusal, notify.error, t), + resetActiveColors: colorCommands.resetPair, + selectedLayerId: selectedId === null ? null : (model?.getLayer(selectedId)?.id ?? null), + swapActiveColors: colorCommands.swapPair, + }); + }); + useEffect(() => { + // Undo/redo stay reachable while a companion owns focus: selecting a layer or editing it must not strand mod+Z. + const disposers = [...CANVAS_HISTORY_HOTKEYS, ...CANVAS_COMPANION_HOTKEYS].flatMap( + ([id, titleKey, defaultKeys]) => { + const title = t(titleKey); + return [ + runtime.commands.register({ handler: () => run(id), id, title }), + runtime.hotkeys.register({ allowInEditable: false, commandId: id, defaultKeys: [...defaultKeys], id, title }), + ]; + } + ); + + return () => { + disposers.forEach((dispose) => dispose()); + }; + }, [runtime.commands, runtime.hotkeys, t]); +}; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/layers/useCanvasHistoryHotkeys.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/widgets/layers/useCanvasHistoryHotkeys.browser.test.tsx deleted file mode 100644 index 6cde36f9a2e..00000000000 --- a/invokeai/frontend/webv2/src/workbench/widgets/layers/useCanvasHistoryHotkeys.browser.test.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import type { CanvasEngineHandle } from '@workbench/canvas-operations/react'; -import type { WidgetCommandContribution, WidgetHotkeyContribution } from '@workbench/widgetContracts'; - -import { act, createElement, type ReactElement } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import { useCanvasHistoryHotkeys } from './useCanvasHistoryHotkeys'; - -const translation = { t: (key: string) => key }; -vi.mock('react-i18next', () => ({ useTranslation: () => translation })); - -(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; - -const createRuntime = () => { - const commands = new Map(); - const hotkeys = new Map(); - return { - commands: { - register: vi.fn((command: WidgetCommandContribution) => { - commands.set(command.id, command); - return () => commands.delete(command.id); - }), - }, - hotkeys: { - register: vi.fn((hotkey: WidgetHotkeyContribution) => { - hotkeys.set(hotkey.id, hotkey); - return () => hotkeys.delete(hotkey.id); - }), - }, - registered: { commands, hotkeys }, - }; -}; - -const Host = ({ - engine, - runtime, -}: { - engine: Pick | null; - runtime: ReturnType; -}) => { - useCanvasHistoryHotkeys(runtime, engine); - return null; -}; - -let root: Root | null = null; -let host: HTMLDivElement | null = null; - -afterEach(() => { - act(() => root?.unmount()); - host?.remove(); - root = null; - host = null; -}); - -const mount = (element: ReactElement): void => { - host = document.createElement('div'); - document.body.append(host); - root = createRoot(host); - act(() => root!.render(element)); -}; - -describe('useCanvasHistoryHotkeys', () => { - it('registers undo/redo outside editable fields and drives the current engine', () => { - const runtime = createRuntime(); - const first = { history: { redo: vi.fn(), undo: vi.fn() } } as unknown as CanvasEngineHandle; - const second = { history: { redo: vi.fn(), undo: vi.fn() } } as unknown as CanvasEngineHandle; - mount(createElement(Host, { engine: first, runtime })); - - expect([...runtime.registered.hotkeys.values()]).toEqual([ - expect.objectContaining({ allowInEditable: false, commandId: 'canvas.undo', defaultKeys: ['mod+z'] }), - expect.objectContaining({ - allowInEditable: false, - commandId: 'canvas.redo', - defaultKeys: ['mod+shift+z', 'mod+y'], - }), - ]); - void runtime.registered.commands.get('canvas.undo')!.handler(); - expect(first.history.undo).toHaveBeenCalledOnce(); - - // A new engine identity re-renders the host without re-registering. - act(() => root!.render(createElement(Host, { engine: second, runtime }))); - void runtime.registered.commands.get('canvas.redo')!.handler(); - expect(second.history.redo).toHaveBeenCalledOnce(); - expect(first.history.redo).not.toHaveBeenCalled(); - expect(runtime.commands.register).toHaveBeenCalledTimes(2); - }); - - it('withdraws both registrations on unmount and tolerates a missing engine', () => { - const runtime = createRuntime(); - mount(createElement(Host, { engine: null, runtime })); - expect(() => runtime.registered.commands.get('canvas.undo')!.handler()).not.toThrow(); - - act(() => root!.unmount()); - expect(runtime.registered.commands.size).toBe(0); - expect(runtime.registered.hotkeys.size).toBe(0); - }); -}); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/layers/useCanvasHistoryHotkeys.ts b/invokeai/frontend/webv2/src/workbench/widgets/layers/useCanvasHistoryHotkeys.ts deleted file mode 100644 index 7a94d57ff30..00000000000 --- a/invokeai/frontend/webv2/src/workbench/widgets/layers/useCanvasHistoryHotkeys.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { CanvasEngineHandle } from '@workbench/canvas-operations/react'; -import type { WidgetCommandApi, WidgetHotkeyApi } from '@workbench/widgetContracts'; - -import { useEffect, useEffectEvent } from 'react'; -import { useTranslation } from 'react-i18next'; - -// The canvas history's undo/redo, reachable while a companion widget owns -// focus: selecting a layer or editing its properties must not strand mod+Z. -const HISTORY_HOTKEYS = [ - ['canvas.undo', 'widgets.canvas.commands.undo', ['mod+z']], - ['canvas.redo', 'widgets.canvas.commands.redo', ['mod+shift+z', 'mod+y']], -] as const; - -type HistoryCommandId = (typeof HISTORY_HOTKEYS)[number][0]; - -/** Registers canvas undo/redo on `runtime` for as long as the caller is mounted. */ -export const useCanvasHistoryHotkeys = ( - runtime: { commands: Pick; hotkeys: WidgetHotkeyApi }, - engine: Pick | null -): void => { - const { t } = useTranslation(); - const run = useEffectEvent((commandId: HistoryCommandId) => { - if (commandId === 'canvas.undo') { - engine?.history.undo(); - } else { - engine?.history.redo(); - } - }); - useEffect(() => { - const disposers = HISTORY_HOTKEYS.flatMap(([id, titleKey, defaultKeys]) => [ - runtime.commands.register({ handler: () => run(id), id, title: t(titleKey) }), - runtime.hotkeys.register({ - allowInEditable: false, - commandId: id, - defaultKeys: [...defaultKeys], - id, - title: t(titleKey), - }), - ]); - - return () => { - disposers.forEach((dispose) => dispose()); - }; - }, [runtime.commands, runtime.hotkeys, t]); -}; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/layers/useControlSourceActions.ts b/invokeai/frontend/webv2/src/workbench/widgets/layers/useControlSourceActions.ts index a2271deacc0..d6152174589 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/layers/useControlSourceActions.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/layers/useControlSourceActions.ts @@ -128,6 +128,12 @@ export const useControlSourceActions = ({ case 'adjustment-refused': notify.error(t('widgets.layers.control.source.frameFailed'), describeAdjustmentRefusal(filled, t)); return; + case 'unreadable': + notify.error( + t('widgets.layers.control.source.frameFailed'), + t('widgets.canvas.contextMenu.unreadable', { name: filled.layerName }) + ); + return; case 'stale': // The account changed on the way; the result belongs to nobody here. } diff --git a/invokeai/frontend/webv2/src/workbench/widgets/layers/useLayerSelectionCommands.ts b/invokeai/frontend/webv2/src/workbench/widgets/layers/useLayerSelectionCommands.ts index 336782ed0e6..dbf5ce5a591 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/layers/useLayerSelectionCommands.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/layers/useLayerSelectionCommands.ts @@ -55,7 +55,7 @@ export const useLayerSelectionCommands = ( } if (result && result.status !== 'nothing') { reportLayerOperation( - result.status, + result.status === 'unreadable' ? result : result.status, (title, description) => toaster.create({ description, title, type: 'warning' }), t ); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/layers/useLayerStackActions.ts b/invokeai/frontend/webv2/src/workbench/widgets/layers/useLayerStackActions.ts index 5f2fa825fac..4b171d5c8df 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/layers/useLayerStackActions.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/layers/useLayerStackActions.ts @@ -10,7 +10,11 @@ import { isAdjustmentRefusal, } from '@workbench/canvas-engine/api'; import { useCanvasEngineRead } from '@workbench/widgets/canvas/engineStoreHooks'; -import { describeAdjustmentRefusal, usePreparedCommit } from '@workbench/widgets/canvas/useStructuralCommit'; +import { + describeAdjustmentRefusal, + describeNamedRefusal, + usePreparedCommit, +} from '@workbench/widgets/canvas/useStructuralCommit'; import { useActiveProjectName } from '@workbench/WorkbenchContext'; import { EyeIcon, EyeOffIcon, FileDownIcon, LayersIcon, PlusIcon } from 'lucide-react'; import { useMemo } from 'react'; @@ -25,7 +29,7 @@ import { stackVisibilityAxis, type StackActionId, } from './layerStackActions'; -import { getPsdExportNoticeKey } from './psdExportNotice'; +import { getPsdExportNotice } from './psdExportNotice'; import { useAddLayer } from './useAddLayer'; export type LayerStackActionsEngine = Pick; @@ -93,9 +97,9 @@ export const useLayerStackActions = ( }); return; } - const noticeKey = getPsdExportNoticeKey(result); - if (noticeKey) { - toaster.create({ title: t(noticeKey), type: 'warning' }); + const notice = getPsdExportNotice(result); + if (notice) { + toaster.create({ title: t(notice.key, notice.options), type: 'warning' }); } }) .catch(() => toaster.create({ title: t('widgets.layers.groupActions.exportFailed'), type: 'error' })); @@ -111,11 +115,11 @@ export const useLayerStackActions = ( return; } void engine.layers.mergeVisibleRasterLayers().then((result) => { - if (isAdjustmentRefusal(result)) { + if (typeof result === 'object') { toaster.create({ - description: describeAdjustmentRefusal(result, t), + description: describeNamedRefusal(result, t), title: t('widgets.layers.actions.actionFailed'), - type: 'warning', + type: isAdjustmentRefusal(result) ? 'warning' : 'error', }); } else if (result === 'not-ready') { toaster.create({ title: t('widgets.layers.groupActions.mergeNotReady'), type: 'warning' }); diff --git a/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts b/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts index c9909152477..5d32bca0536 100644 --- a/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts +++ b/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts @@ -111,6 +111,7 @@ const CANVAS_MUTATION_TYPES = new Set([ 'reorderCanvasSiblings', 'replaceCanvasDocument', 'replaceCanvasLayer', + 'replaceCanvasLayers', 'rollbackStagedImageCommit', 'resizeCanvasDocument', 'restoreCanvasSnapshot', @@ -8139,6 +8140,48 @@ describe('workbenchReducer canvas v2 layer reducers', () => { expect(getCanvas(missing).document).toBe(getCanvas(replaced).document); }); + it('replaces several leaves in place in one edit, and refuses them all when any entry is invalid', () => { + const state = withCanvasLayers(createInitialWorkbenchState(), [ + createRasterLayer('a'), + createRasterLayer('b'), + createInpaintMaskLayer('m'), + ]); + const before = getCanvas(state).document; + const paintA = { ...createRasterLayer('a'), source: { bitmap: null, type: 'paint' as const } }; + const emptiedMask = { ...getDocumentLeaves(before).find((layer) => layer.id === 'm')!, name: 'Emptied' }; + + const replaced = workbenchReducer(state, { + replacements: [ + { layer: paintA, layerId: 'a' }, + { layer: emptiedMask, layerId: 'm' }, + ], + type: 'replaceCanvasLayers', + }); + const leaves = getDocumentLeaves(getCanvas(replaced).document); + expect(leaves.map((layer) => layer.id)).toEqual(getDocumentLeaves(before).map((layer) => layer.id)); + expect(leaves.find((layer) => layer.id === 'a')).toBe(paintA); + expect(leaves.find((layer) => layer.id === 'm')).toBe(emptiedMask); + expect(leaves.find((layer) => layer.id === 'b')).toBe(getDocumentLeaves(before).find((layer) => layer.id === 'b')); + + const refusals = [ + [ + { layer: paintA, layerId: 'a' }, + { layer: { ...paintA, id: 'missing' }, layerId: 'missing' }, + ], + [ + { layer: paintA, layerId: 'a' }, + { layer: paintA, layerId: 'a' }, + ], + [{ layer: paintA, layerId: 'b' }], + [{ layer: { ...createControlLayer('a') }, layerId: 'a' }], + [], + ]; + for (const replacements of refusals) { + const refused = workbenchReducer(state, { replacements, type: 'replaceCanvasLayers' }); + expect(refused).toBe(state); + } + }); + it('swaps a raster/control layer source but ignores mask-only layer types', () => { let state = withCanvasLayers(createInitialWorkbenchState(), [createRasterLayer('a')]); const newSource = { image: { height: 10, imageName: 'swapped.png', width: 10 }, type: 'image' } as const; diff --git a/invokeai/frontend/webv2/vite.config.mts b/invokeai/frontend/webv2/vite.config.mts index 456db82c41e..b7941826f1d 100644 --- a/invokeai/frontend/webv2/vite.config.mts +++ b/invokeai/frontend/webv2/vite.config.mts @@ -281,8 +281,11 @@ const CANVAS_LAYER_SHARED_MODULES = [ '/workbench/canvasProjectMutationPort.ts', '/workbench/useCanvasProjectMutationDispatch.ts', '/workbench/widgets/canvas/canvasInteractionLock.ts', + '/workbench/widgets/canvas/canvasToolCommands.ts', '/workbench/widgets/canvas/color-system/colorPair.ts', + '/workbench/widgets/canvas/color-system/maskTintTarget.ts', '/workbench/widgets/canvas/color-system/useActiveColors.ts', + '/workbench/widgets/canvas/color-system/useMaskTintEditor.ts', '/workbench/widgets/canvas/engineStoreHooks.ts', '/workbench/widgets/canvas/textFontStyle.tsx', '/workbench/widgets/canvas/tool-presentation/FormControls.tsx',