Skip to content

fix(canvas): v7 alpha canvas feedback — frames, layer actions, and engine fixes - #9961

Merged
joshistoast merged 27 commits into
mainfrom
fix/canvas-v7-feedback
Oct 26, 2026
Merged

joshistoast merged 27 commits into
mainfrom
fix/canvas-v7-feedback

Conversation

@joshistoast

@joshistoast joshistoast commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Fixes a batch of v7 alpha canvas reports:

  • Drawing: Shift-click draws a straight brush or eraser line from the end of the last stroke on that layer; moving, cropping or transforming the layer carries that point. The brush paints in place on image layers, with one undo restoring the image layer; locked or unready images still get a new paint layer.
  • Fit Frame to Masks: the fitted frame now includes the mask blur (blur + 8 px) and snaps outward to the model's grid, so the soft edge stays inside the frame on 16 px and 32 px grids too.
  • Alt: Alt is claimed for every canvas tool, so a lone Alt no longer opens the browser menu bar (Firefox: tapping Alt, then B, now selects the brush).
  • Focus: canvas keys follow focus. Tool, colour and brush-size keys work from the Layers and Color panels; Escape and C act from Layers; Enter only from the canvas.
  • Transform:
    • Opens immediately from the layer menu.
    • Shift+T replaces Mod+T.
    • Right after a stroke it waits for the stroke to save, with a status line.
    • Refusals explain themselves.
  • New layer operations:
    • Crop all layers to frame: one undo step. It waits for pending stroke uploads, then skips and counts locked layers, and names the layers whose image could not be read. Available from the canvas menu and as a command.
    • Merge inpaint masks: one undo step; the sources are removed. Masks merge at full coverage whatever their display opacity, and masks with different noise or denoise settings aren't merged.
    • Remove from group for leaf layers.
    • Merge masks, Remove from group and Crop all layers say why they are disabled, reachable by keyboard and screen readers.
  • Staging: mask and region overlays stay hidden while a progress image or staged result is shown.
  • Size reset menu beside Swap: a native-size square, the optimal size for the current ratio, and model defaults. Resets keep the canvas frame centred and stay out of undo history.
  • Frame naming: "Frame" replaces "bbox" in the UI, with distinct icons for the frame tool, fit-to-layers, fit-to-masks and the marquee.
  • Select Object remembers Auto Process per account. The filter button now reads Reset settings.
  • Zoom: Mod+1…4 zoom shortcuts.
  • Unreadable layers: a layer whose image is missing (404, 410, 403) or does not decode is reported as unreadable, not "still loading". Layer operations, exports and generation name the layer and say how to fix it. Other fetch failures (network, 5xx, 401) count as temporary: operations ask you to try again and fetch at once when you do, and Crop all refuses rather than skipping the layer. The canvas retries a failed layer on a backoff instead of on every frame.
  • Overview: the Layers panel's Overview shows the whole canvas, frame plus every layer, including outpainted content. Its outline, clicks and wheel zoom map through that area.

Related Issues / Discussions

v7 alpha feedback thread on Discord: https://discord.com/channels/1020123559063990373/1149506274971631688

QA Instructions

  • pnpm check:release (from invokeai/frontend/webv2) passes: lint, unit, browser, fixtures, performance, project-file and accessibility journeys.
  • Browser-checked in Chromium and Firefox against pnpm dev:mock:
    • the Alt and B sequences;
    • companion keys from Layers;
    • Transform from the menu and right after painting;
    • Remove from group with undo;
    • crop all and merge masks;
    • the size reset menu, including the centred frame.
    • the Overview with a layer outpainted beyond the frame: whole extent drawn, outline tracks zoom and pan, click centres, wheel zooms around the pointer.
  • Manual check still needed: that real browsers deliver Ctrl/Cmd+1…4. Some browsers reserve them for tab switching, and headless runs can't show this; the docs note it.

Review

Review findings resolved:

  • Crop all: peak memory reduced; a reservation leak on a failing copy fixed; the multi-layer replace step moved into the shared edit steps.
  • Colour picking: picks report back only to the sink that previewed them; a mask-tint gesture race fixed.
  • Alt: handling for Marquee and Lasso fixed.

Known limit: menu item descriptions are still read as part of the item's accessible name. That fix belongs in the shared Menu component and will follow separately.

Compatibility / Rollout

  • Settings: adds the selectObjectAutoProcess account preference; a missing value means off.
  • Shortcut: the transform shortcut moves from Mod+T (the browser's new-tab shortcut) to Shift+T.
  • Performance baselines: updated for the added code: canvas activation +~9 KB, editor initial +~9 KB raw. Request counts are unchanged. Three new modules are added to the browser baseline's source-owner sets (colorSampleController.ts, paintLineEndController.ts, canvasToolCommands.ts); timings and bytes were not re-recorded.

Checklist

  • The PR has a short but descriptive title, suitable for a changelog
  • Meaningful regression coverage added / updated where needed; obsolete tests/code removed
  • Persisted-state and API changes include required migrations / compatibility validation
  • Relevant performance/efficiency opportunities considered; material claims have evidence
  • Material review findings resolved and relevant checks rerun
  • Documentation added / updated (if applicable)
  • Updated What's New copy (if doing a release after this PR)

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

Pin the toast-contrast toasts open and hold the closed dialog's exit animation, so a busy machine can no longer unmount them mid-assertion.
… masks in the engine

Shift-click draws a straight brush or eraser line, the brush paints in place on image layers, and masks stay hidden while a result is staged.
Crop all layers and merge inpaint masks each record one undo step within the memory budget; transform waits for a fresh stroke to save.
Alt is claimed for every tool so Firefox's menu bar stays closed, and canvas keys respect which panel owns focus.
…ocess

A reset menu beside swap offers a native square, the optimal size for the current ratio and model defaults, keeping the canvas frame centred.
Select Object's Auto Process choice is saved to the account, and the filter's Reset button now says it resets settings.
…able

Frame replaces bbox in labels with distinct icons, crop all and merge masks join the menus and command palette, and Shift+T opens Transform.
Shortcut tables are shared so collisions are caught, the Alt-pick colour goes to the armed target, and pending transforms say so.
Covers straight strokes, crop all, merge masks, frame naming, Shift+T, Alt-pick targets, zoom shortcuts and the Generate size reset menu.
# Conflicts:
#	docs/src/content/docs/Users Guide/07.Canvas/02.canvas-guide.mdx
#	docs/src/content/docs/Users Guide/07.Canvas/03.layers-panel.mdx
#	invokeai/frontend/webv2/src/workbench/widgets/layers/LayerContextMenu.tsx
@github-actions github-actions Bot added frontend PRs that change frontend files docs PRs that change docs labels Oct 20, 2026
@joshistoast joshistoast assigned JPPhoto and lstein and unassigned JPPhoto Oct 20, 2026
# Conflicts:
#	docs/src/content/docs/Users Guide/07.Canvas/02.canvas-guide.mdx
…rol layers

The widget's activated script now carries both this branch's canvas changes and main's FLUX.2 control layers (533,097 bytes); request counts are unchanged.
# Conflicts:
#	invokeai/frontend/webv2/performance/architecture-baseline.json
#	invokeai/frontend/webv2/performance/browser-baseline.json
This branch's canvas changes on top of main's control layers and clipboard messages bring the widget's activated script to 533,485 bytes; request counts are unchanged.
# Conflicts:
#	docs/src/content/docs/Users Guide/07.Canvas/02.canvas-guide.mdx
#	invokeai/frontend/webv2/performance/browser-baseline.json
#	invokeai/frontend/webv2/src/workbench/hotkeys/catalog.ts
#	invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasHeaderActions.tsx
…ent main

The English locale grows to 225,516 bytes on every route with the gallery and canvas strings together, and the canvas widget's activated script is 531,250 bytes; request counts are unchanged.
# Conflicts:
#	invokeai/frontend/webv2/performance/browser-baseline.json
#	invokeai/frontend/webv2/src/workbench/settings/store.test.ts
…l changes

With main's generation and model changes, this branch's canvas widget activates 531,800 bytes of script; request counts are unchanged.
@joshistoast
joshistoast requested a review from lstein October 22, 2026 01:36
The overview composites the frame joined with every enabled layer, the
same extent fit-to-view fits, and the pane maps its outline and pointer
through the rect the engine drew instead of the document origin.
# Conflicts:
#	invokeai/frontend/webv2/src/features/generation/ui/GenerateDimensionFields.browser.test.tsx
…as-v7-feedback

# Conflicts:
#	docs/src/content/docs/Users Guide/07.Canvas/03.layers-panel.mdx
#	invokeai/frontend/webv2/performance/browser-baseline.json
#	invokeai/frontend/webv2/src/workbench/canvas-engine/capabilities.ts
#	invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/mergeLayerController.browser.test.ts
#	invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/mergeLayerController.test.ts
#	invokeai/frontend/webv2/src/workbench/canvas-engine/engine.test.ts
#	invokeai/frontend/webv2/src/workbench/canvas-engine/engine.ts
#	invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHotkeyCommands.test.ts
#	invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHotkeyCommands.ts
#	invokeai/frontend/webv2/src/workbench/widgets/layers/layerContextActions.test.ts
Crop all reads an adjusted layer raw first and runs its stack only when the layer reaches past the frame.
Mask merges are typed async, and the brush comment and layers guide match the refusal behavior.

@Pfannkuchensack Pfannkuchensack left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 074628f. Paths are relative to invokeai/frontend/webv2/ unless they start with docs/.

Should fix before merge

M1. Merge masks bakes each mask's display opacity into the mask pixels (src/workbench/canvas-engine/controllers/mergeLayerController.ts:702)

  • mergeSelectedInto composites each contributor with globalAlpha = contributor.opacity, which raster merges need. For inpaint masks, opacity is display-only: generation ignores it (toMaskLayerRef carries none) and reads coverage from pixel alpha with a hard cut at 127 (src/workbench/canvas-operations/compositeForGeneration.ts:365). The merged layer gets opacity: 1 (mergeLayerController.ts:113).
  • Scenario: mask A at 40 % opacity so the image shows through, mask B at 100 %. After Merge masks, A's pixels land at alpha ≈ 102. They still show as a faint tint, but A's area is no longer inpainted. At 50–99 % coverage survives but feathered edges shift. Nothing warns.
  • The existing test sets the top mask to 0.5 (mergeLayerController.test.ts:365) but never checks the merged alpha.
  • Fix: composite masks at alpha 1 (an opacity(contributor) hook on SelectedMergeSpec, or force 1 in the mask spec), and add a test that asserts the merged alpha for a source mask below 50 %.

M2. One unreadable layer blocks Crop all for the whole document, with "try again shortly" (src/workbench/canvas-engine/controllers/cropLayerController.ts:346-347, rasterExportController.ts:208)

  • A layer whose image failed to load or decode has no live cache. rasterize starts rasterization, the rasterizer reports 'error' (layerRasterizer.ts:181-197), and every non-published outcome becomes not-ready. Nothing remembers the failure, so it is permanent for a deleted or corrupt image. cropAll aborts on the first not-ready and shows "The layer content is still loading. Try again shortly."; each attempt also raises a rasterization error notice.
  • Crop all reads every leaf, including disabled ones (includeDisabled: true, engine.ts:2864-2865), so disabling the broken layer doesn't help. Only locking it does, and nothing says so. Single crop, merge visible and generation share the not-ready collapse, but there it affects one layer or can be avoided by disabling the layer.
  • Reproduced with the mock backend, whose fixture layer images don't decode: Crop all refuses with that message.
  • Fix: keep the rasterizer's failure distinct from not-ready. In crop all, skip such leaves and count them like skippedLocked ("N layers could not be read and were left as they are"), or at least report "could not be read". Add a test with one leaf whose rasterization failed.

M3. Fit Frame to Masks still cuts the blurred edge on 16 px and 32 px grids (src/workbench/widgets/canvas/fitBbox.ts:71-87, fitRectToGrid at :28)

  • Padding is blur + 8, then fitRectToGrid snaps inward (origin rounded up, size down), losing up to grid−1 px per side. Snap-to-grid is on by default; FLUX, FLUX.2, Krea-2 and Qwen use grid 16, and CogView4, Qwen 2.1 and LTX-2 use grid 32.
  • With the default blur 16:
    • Grid 16, mask at x=105..205: frame 96..224. The feather starts at 89, so 7 px are cut.
    • Grid 32, mask at x=121..221: frame 128..224. The mask itself starts 7 px outside the frame, and 13 px of the right feather are cut.
  • The summary and docs/src/content/docs/Users Guide/07.Canvas/02.canvas-guide.mdx:80 ("the blurred edge of the mask stays inside the frame … the margin is 24 pixels") don't hold for these models, and fitBbox.test.ts only uses grids 1 and 8. Inward snapping matches legacy, but this is the reported bug's stated fix.
  • Fix: snap the masks fit outward (floor the origin, ceil the far edge) and add grid-16/32 tests asserting the frame contains the mask bounds ± blur. The header passing the project's maskBlur is also untested (mutating it to 0 passes).

M4. Merge masks' "why disabled" reason is unreachable by keyboard and screen readers (src/workbench/widgets/layers/layerContextActions.ts:713, LayerContextMenu.tsx:1427)

  • The reason is passed as getDescriptionKey on an item that stays Zag-disabled, so keyboard navigation skips it and assistive tech never gets the reason. src/platform/ui/Menu.tsx already has unavailableReason for this (aria-disabled, aria-describedby, highlightable, tooltip), and LayerMenuActionItem already passes it through getUnavailableReason.
  • Fix: route the ineligibility through unavailableReason and keep descriptionKey for always-on help text. The test "merge-masks … descriptionKey" currently locks in the description channel.

M5. The menu wiring behind three headline fixes has no test (LayerContextMenu.tsx:649 handleTransform, :432 handleMergeMasks, :425 handleRemoveFromGroup)

  • The engine, registry and hotkey paths are tested. But reverting handleTransform to the old dispatch(setCanvasSelectedLayer); setTool('transform') passes every test, as does dropping description= from LayerMenuActionItem or !hasCroppable from CanvasCropAllLayersMenuItem.
  • Fix: one browser test through the real menu: Transform with the Transform tool already active opens a session on that layer, Merge masks turns two masks into one, and Remove from group is one undo step.

M6. New engine behavior is added to engine.ts instead of a controller (src/workbench/canvas-engine/ARCHITECTURE.md:62: "New behavior goes in a focused controller with explicit dependencies and idempotent dispose().")

  • engine.ts grows from 3058 to 3203 lines with the pending-transform wait (:1108-1137, beginTransform at :2518-2552), colour-pick sink routing (:1097, :1195-1212), the Shift-line memory (:1100) and the staging flag (:1552).
  • The pending-transform state has no owner: it is cleared from seven places (:648, :1124, :1126, :1133, :1649, :1941, :2015), and replay doesn't clear it. Its tests all live in engine.test.ts (+588 lines). It also widens the engine.ts conflict with #10002.
  • Fix: move the eligibility check, the pending wait and its clear hooks into TransformEditingController, which already has session, getDocument, canEdit and isGestureActive, and move the pick routing into a small colour-sample controller. engine.ts then only routes events.

Minor

  1. Merge masks gives the whole union the top mask's denoise limit and noise (mergeLayerController.ts:101-117), which changes generation in the other masks' areas (generationCompositePlan.ts:197-235 applies them per area). It's documented, but nothing warns; refusing with a stated reason when the enabled settings differ would fit the existing mergeMasksIneligible slot.
  2. An Alt-pick on a selected shape can switch on a fill or stroke the shape doesn't have (color-system/CanvasColorFeed.tsx:51, :124 write source[field] = hex when the field is null), with strokeWidth taken from the tool options.
  3. The Shift-click line start is stored in document space and not reset when the layer moves (tools/paintTool.ts:345, engine.ts:667, :1736). Nudge the layer 100 px and the line starts 100 px off, contrary to the docs ("from the end of the last stroke on that layer").
  4. Painting directly on image layers now re-uploads the whole layer on every debounced flush (tools/paintTool.ts:126-138, document/bitmapStore.ts), versus stroke-only paint layers before. The eraser already worked this way; worth measuring for a 2048² layer.
  5. Crop all can refuse as stale right after painting (cropLayerController.ts:306-313): a persistence acknowledgement during the export loop replaces a layer object. Crossing the paint-flush barrier first, as canvas invocation does, avoids it.
  6. Single crop and Crop all are two pipelines with different rules: crop() (:133-235) checks only layer.isLocked and refuses leaves outside the frame as empty, while cropAll() uses effectiveLocked and empties them.
  7. The Layers widget now loads the full canvas hotkey dispatcher (vite.config.mts:274); it only needs the companion/history hotkey lists, executeCanvasToolCommand and reportTransformRefusal.
  8. Reset size menu: the current size is a Zag-disabled item with aria-current, so keyboard users skip it, and with the native square current the menu opens with nothing to highlight (src/features/generation/ui/GenerateDimensionFields.tsx:130-131). menuitemradio with aria-checked would fit. Crop all layers and Remove from group are disabled without a reason in some states.
  9. The transform status line truncates without a tooltip (tool-options/geometryForm.tsx:272). The history labels "Crop all layers to frame" and "Merge masks" and the " merged" layer name are hard-coded English.
  10. Docs: parameters.mdx:57 says "Reset size → Optimal size", but the item is "Optimal size for " and is absent for 1:1. The Mod+1…4 fallback "use the zoom menu" can't reach 800 %.
  11. "Focus: loss of focus on retry and recovery paths fixed": I couldn't find a production change or test for this beyond the pending-transform wait surviving retried uploads.

Merge with #10002

The two PRs conflict in 9 files (engine.ts, createCanvasEngine.ts, cropLayerController.ts, useCanvasEngine.ts, en.json, browser-baseline.json, three tests). These are ordinary text conflicts, but the baselines need re-measuring after whichever lands second.

Checked in the browser against the mock backend: the reset size menu (the frame stays centred, and focus returns to the trigger by keyboard), Shift-click straight lines, Shift+T right after a stroke, and Crop all from the canvas menu.

A failed layer image is reported as unreadable across crop, merges, exports and generation; Crop all skips it and waits for pending uploads.
Merge masks ignores display opacity, refuses mismatched noise/denoise, and disabled layer actions name their reason.
Pending transforms, colour picks and Shift-line ends move into controllers; the Layers widget loads only tool commands.
Fit Frame to Masks snaps outward to the model grid; Fit to layers keeps its inward snap.
Reset size marks the current size as a checked radio item, Alt-pick never enables a missing fill or stroke, and the transform status line gets a tooltip.

@Pfannkuchensack Pfannkuchensack left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up at 3976335. All findings from my previous review are resolved:

  • Masks merge at full coverage, and differing noise/denoise settings are refused with a reason.
  • Crop all skips and counts unreadable layers instead of waiting.
  • Fit to masks snaps outward on every grid.
  • The merge-masks and crop-all reasons go through unavailableReason.
  • The menu wiring is tested through the real menu.
  • The new behavior moved into TransformEditingController, ColorSampleController and PaintLineEndController.
  • Shift-line ends follow the layer.
  • Crop all flushes pixels first.
  • Layers loads only canvasToolCommands.
  • The Reset size menu uses radio items.

One new issue from the fix:

The "could not be read" messages don't say which layer (src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.ts:363, useCanvasGallerySave.ts:94, createFromBboxNotice.ts:52, the PSD export's groupActions.exportUnreadable, canvasContextMenu.ts:100)

  • The unreadable results carry no layer id: { status: 'unreadable' } in cropLayerController.ts:144 and mergeLayerController.ts:658, and a bare status in exportRasterComposite.ts:80 and generationComposite.ts:131. Nothing in the Layers panel marks the layer either.
  • Scenario: a gallery image used by one layer of a 20-layer canvas was deleted. Invoke, Save canvas, Create from frame and PSD export all refuse with "Disable or delete that layer". The user can only find it by disabling layers one at a time. Seen with the mock backend, whose fixture images don't decode: Save Canvas to Gallery shows exactly that text, and no row is marked.
  • This is better than the old "still loading", but the recovery step can't be acted on.
  • Fix: carry layerId/layerName in the unreadable result, as adjustment-refused already does with ownerName, and name the layer in the message. Or mark the row.

Minor:

  • Transient fetch failures count as unreadable. The image resolver throws on any non-OK response or network error (src/workbench/canvas-operations/useCanvasEngine.ts:32-37), so a 5xx, 401 or brief outage shows "it may have been deleted or damaged", and Crop all skips that layer for that run. Classifying only 404/410 and decode errors as unreadable would avoid it.
  • The history labels "Crop all layers to frame" and "Merge masks", and the " merged" layer name, are still hard-coded English (cropLayerController.ts:451, mergeLayerController.ts:121, :604).
  • Dropping description= from LayerMenuActionItem still passes every test; only "Add regenerate region" uses it now.
  • Painting directly on image layers still re-uploads the whole layer on each flush (not measured).

… retryable

Unreadable results carry the layer's id and name, so every refusal names the layer; Crop all names and counts skipped layers.
Only missing (404/410/403) or undecodable images count as unreadable; other fetch failures report not-ready and retry on the next request.
@joshistoast
joshistoast enabled auto-merge October 26, 2026 00:56
Pfannkuchensack and others added 2 commits October 26, 2026 04:40
Record the locale file's growth from main and this branch in the browser byte baseline.

@Pfannkuchensack Pfannkuchensack left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up at d5c41f0. Approving: e1cedf9 resolves the open point from my last comment.

  • Unreadable results now carry the layer's id and name, and every refusal names the layer. Checked with the mock backend: Save to Gallery says "“Fixture Layer 064” could not be read …", Crop layer names its layer, and Crop all names the first unreadable layer and counts the rest. Invoke, Create from Frame, PSD export and the merges are covered by tests.
  • Temporary fetch failures (5xx, network errors, 401) now report not-ready and retry. Only 404/410/403 and undecodable images count as unreadable.
  • Dropping description= from LayerMenuActionItem now fails a test.
  • browser-baseline.json: I measured the merged head, and otherRawBytes comes out at exactly the committed 246,409 on every route.

Remaining minor notes, not blocking:

  • The history labels "Crop all layers to frame" and "Merge masks" and the " merged" suffix are still hard-coded English (cropLayerController.ts:456, mergeLayerController.ts:130, :563, :586, :615).
  • The Invoke notices in prepareCanvasInvocation.ts, including the two new ones, are not localized; the rest of that file isn't either.

@joshistoast
joshistoast merged commit c56ed5e into main Oct 26, 2026
22 checks passed
@joshistoast
joshistoast deleted the fix/canvas-v7-feedback branch October 26, 2026 21:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs PRs that change docs frontend PRs that change frontend files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants