Repository navigation
fix(canvas): v7 alpha canvas feedback — frames, layer actions, and engine fixes - #9961
Conversation
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
# 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.
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
left a comment
There was a problem hiding this comment.
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)
mergeSelectedIntocomposites each contributor withglobalAlpha = contributor.opacity, which raster merges need. For inpaint masks, opacity is display-only: generation ignores it (toMaskLayerRefcarries none) and reads coverage from pixel alpha with a hard cut at 127 (src/workbench/canvas-operations/compositeForGeneration.ts:365). The merged layer getsopacity: 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 onSelectedMergeSpec, 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.
rasterizestarts rasterization, the rasterizer reports'error'(layerRasterizer.ts:181-197), and every non-published outcome becomesnot-ready. Nothing remembers the failure, so it is permanent for a deleted or corrupt image.cropAllaborts on the firstnot-readyand 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 thenot-readycollapse, 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 likeskippedLocked("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
fitRectToGridsnaps 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, andfitBbox.test.tsonly 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
maskBluris also untested (mutating it to0passes).
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
getDescriptionKeyon an item that stays Zag-disabled, so keyboard navigation skips it and assistive tech never gets the reason.src/platform/ui/Menu.tsxalready hasunavailableReasonfor this (aria-disabled,aria-describedby, highlightable, tooltip), andLayerMenuActionItemalready passes it throughgetUnavailableReason. - Fix: route the ineligibility through
unavailableReasonand keepdescriptionKeyfor 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
handleTransformto the olddispatch(setCanvasSelectedLayer); setTool('transform')passes every test, as does droppingdescription=fromLayerMenuActionItemor!hasCroppablefromCanvasCropAllLayersMenuItem. - 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.tsgrows from 3058 to 3203 lines with the pending-transform wait (:1108-1137,beginTransformat: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 inengine.test.ts(+588 lines). It also widens theengine.tsconflict with #10002. - Fix: move the eligibility check, the pending wait and its clear hooks into
TransformEditingController, which already hassession,getDocument,canEditandisGestureActive, and move the pick routing into a small colour-sample controller.engine.tsthen only routes events.
Minor
- 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-235applies them per area). It's documented, but nothing warns; refusing with a stated reason when the enabled settings differ would fit the existingmergeMasksIneligibleslot. - An Alt-pick on a selected shape can switch on a fill or stroke the shape doesn't have (
color-system/CanvasColorFeed.tsx:51,:124writesource[field] = hexwhen the field isnull), withstrokeWidthtaken from the tool options. - 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"). - 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. - Crop all can refuse as
staleright 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. - Single crop and Crop all are two pipelines with different rules:
crop()(:133-235) checks onlylayer.isLockedand refuses leaves outside the frame asempty, whilecropAll()useseffectiveLockedand empties them. - The Layers widget now loads the full canvas hotkey dispatcher (
vite.config.mts:274); it only needs the companion/history hotkey lists,executeCanvasToolCommandandreportTransformRefusal. - Reset size menu: the current size is a Zag-
disableditem witharia-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).menuitemradiowitharia-checkedwould fit. Crop all layers and Remove from group are disabled without a reason in some states. - 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. - Docs:
parameters.mdx:57says "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 %. - "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
left a comment
There was a problem hiding this comment.
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,ColorSampleControllerandPaintLineEndController. - 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' }incropLayerController.ts:144andmergeLayerController.ts:658, and a bare status inexportRasterComposite.ts:80andgenerationComposite.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/layerNamein the unreadable result, asadjustment-refusedalready does withownerName, 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=fromLayerMenuActionItemstill 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.
Record the locale file's growth from main and this branch in the browser byte baseline.
Pfannkuchensack
left a comment
There was a problem hiding this comment.
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=fromLayerMenuActionItemnow fails a test. browser-baseline.json: I measured the merged head, andotherRawBytescomes 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.
Summary
Fixes a batch of v7 alpha canvas reports:
Related Issues / Discussions
v7 alpha feedback thread on Discord: https://discord.com/channels/1020123559063990373/1149506274971631688
QA Instructions
pnpm check:release(frominvokeai/frontend/webv2) passes: lint, unit, browser, fixtures, performance, project-file and accessibility journeys.pnpm dev:mock:Review
Review findings resolved:
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
selectObjectAutoProcessaccount preference; a missing value means off.colorSampleController.ts,paintLineEndController.ts,canvasToolCommands.ts); timings and bytes were not re-recorded.Checklist
What's Newcopy (if doing a release after this PR)Need help on this PR? Tag
@codesmith-botwith what you need.