From c84bb983ceca5f2def99575fd2de0ea92777194d Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Tue, 20 Oct 2026 01:42:16 -0600 Subject: [PATCH 01/14] test(ui): stabilize two load-sensitive browser tests 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. --- .../src/platform/ui/theme/ToastContrast.browser.test.tsx | 9 ++++++++- .../WorkbenchHotkeyRuntime.modal.browser.test.tsx | 8 +++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/invokeai/frontend/webv2/src/platform/ui/theme/ToastContrast.browser.test.tsx b/invokeai/frontend/webv2/src/platform/ui/theme/ToastContrast.browser.test.tsx index 38569b84445..2b3adc1ead4 100644 --- a/invokeai/frontend/webv2/src/platform/ui/theme/ToastContrast.browser.test.tsx +++ b/invokeai/frontend/webv2/src/platform/ui/theme/ToastContrast.browser.test.tsx @@ -36,7 +36,12 @@ describe('toast contrast', () => { ); }); await act(() => { - toaster.create({ description: 'Layer thumbnail rasterization failed.', title: 'Error', type }); + toaster.create({ + description: 'Layer thumbnail rasterization failed.', + duration: Infinity, + title: 'Error', + type, + }); }); await expect.poll(() => document.querySelector('[data-part="description"]')).not.toBeNull(); const description = document.querySelector('[data-part="description"]')!; @@ -93,6 +98,8 @@ describe('toast contrast', () => { { label: 'Retry', onClick: () => {} }, { label: 'Show in Gallery', onClick: () => {} }, ], + // Success toasts expire after 2s; a loaded machine can take longer to hover every button. + duration: Infinity, title: 'Saved', type, }); diff --git a/invokeai/frontend/webv2/src/workbench/hotkeys/WorkbenchHotkeyRuntime.modal.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/hotkeys/WorkbenchHotkeyRuntime.modal.browser.test.tsx index 18ea3a4b3f2..fcf669824ac 100644 --- a/invokeai/frontend/webv2/src/workbench/hotkeys/WorkbenchHotkeyRuntime.modal.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/hotkeys/WorkbenchHotkeyRuntime.modal.browser.test.tsx @@ -16,7 +16,7 @@ import { WorkbenchFocusProvider } from '@workbench/WorkbenchRuntime'; import { createWorkbenchStore } from '@workbench/workbenchStore'; import { act, StrictMode, useCallback } from 'react'; import { createRoot, type Root } from 'react-dom/client'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'; import { page, userEvent } from 'vitest/browser'; /** @@ -225,6 +225,12 @@ describe('WorkbenchHotkeyRuntime under a modal dialog', () => { }); it('runs nothing behind for the Escape or Enter that closes a dialog, and the next press reaches it', async () => { + // The next press lands while the closed dialog is still animating out; on a loaded machine a real-length exit + // animation can end before then, so it is held for the whole test. + const holdExit = document.createElement('style'); + holdExit.textContent = '[role="alertdialog"][data-state="closed"] { animation-duration: 3600s !important; }'; + document.head.append(holdExit); + onTestFinished(() => holdExit.remove()); await openConfirm(); await userEvent.keyboard('{Escape}'); expect(ran).toEqual([]); From d892acf4426b359823cc1b6a7d3b310a9d0d0a48 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Tue, 20 Oct 2026 01:42:16 -0600 Subject: [PATCH 02/14] fix(canvas): straight strokes, in-place painting, crop all and merged 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. --- .../webv2/src/workbench/autoRoutePolicy.ts | 1 + .../workbench/canvas-engine/ARCHITECTURE.md | 11 +- .../workbench/canvas-engine/capabilities.ts | 68 ++- .../controllers/cropLayerController.test.ts | 251 +++++++- .../controllers/cropLayerController.ts | 288 ++++++++- .../canvas-engine/controllers/editSteps.ts | 88 ++- .../mergeLayerController.browser.test.ts | 82 +++ .../controllers/mergeLayerController.test.ts | 148 ++++- .../controllers/mergeLayerController.ts | 138 ++++- .../controllers/renderController.ts | 16 +- .../document-model/screenComposition.test.ts | 15 + .../document/mergeVisible.test.ts | 35 ++ .../canvas-engine/document/mergeVisible.ts | 31 + .../canvas-engine/engine.resources.test.ts | 15 +- .../workbench/canvas-engine/engine.test.ts | 549 +++++++++++++++++- .../src/workbench/canvas-engine/engine.ts | 180 +++++- .../workbench/canvas-engine/engineStores.ts | 3 + .../input/pointerPipeline.browser.test.ts | 42 +- .../input/pointerPipeline.test.ts | 202 +++++++ .../canvas-engine/input/pointerPipeline.ts | 143 +++-- .../canvas-engine/mutationContracts.ts | 8 + .../render/compositeFrame.budget.test.ts | 2 + .../render/compositeFrame.test.ts | 58 ++ .../canvas-engine/render/compositeFrame.ts | 21 +- .../canvas-engine/render/compositor.ts | 20 +- .../render/compositorDamage.browser.test.ts | 46 ++ .../canvas-engine/render/frameDemand.test.ts | 25 + .../canvas-engine/render/frameDemand.ts | 3 + .../tools/colorPickerTool.test.ts | 56 +- .../canvas-engine/tools/colorPickerTool.ts | 46 +- .../canvas-engine/tools/paintTool.test.ts | 209 ++++++- .../canvas-engine/tools/paintTool.ts | 29 +- .../tools/strokeSession.browser.test.ts | 24 + .../src/workbench/canvas-engine/tools/tool.ts | 26 +- .../canvas-engine/tools/transformTool.ts | 30 +- .../src/workbench/canvasProjectMutations.ts | 23 + .../src/workbench/workbenchState.test.ts | 43 ++ 37 files changed, 2715 insertions(+), 260 deletions(-) create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/mergeLayerController.browser.test.ts 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 95ef03401b0..38e1cafbfa9 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/ARCHITECTURE.md +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/ARCHITECTURE.md @@ -74,7 +74,8 @@ Current extracted ownership includes: - `RasterExportController`: guarded layer rasterization, transformed copies, encoding, reservations, pin leases. - `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. Locked leaves (own or ancestor lock) are skipped, and counted when their content reaches past the frame, so the result can say how many were skipped even when nothing was cropped; 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 its opacity) into a mask configured like the topmost source. - `MaskResultController`, `FilterResultController`, `GeneratedResultController`: guarded application-result adoption through narrow host ports. Controller-local tests instantiate these boundaries with fakes. Integration tests in `engine.test.ts` cover composition, document routing, cross-controller invariants. @@ -99,7 +100,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 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. @@ -160,7 +161,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. @@ -192,7 +193,9 @@ Capability returns `exported`, `nothing`, `too-large`, `not-ready`, `over-budget ## 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. + +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 map of where the last committed stroke ended on each layer: a Shift press on a layer with a remembered end draws a straight segment from it as part of the one stroke, and the map survives tool switches but forgets on replay, document replacement and the layer's removal. `layers.beginTransform` 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 document replacement, 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/capabilities.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/capabilities.ts index 258771e6078..a660c8cba0e 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/capabilities.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/capabilities.ts @@ -13,9 +13,9 @@ import type { LayerTransform } from '@workbench/canvas-engine/transform/transfor import type { BooleanRasterResult } from './controllers/booleanMergeController'; import type { CopyLayerToRasterResult } from './controllers/copyLayerController'; -import type { CropLayerResult } from './controllers/cropLayerController'; +import type { CropAllLayersResult, CropLayerResult } from './controllers/cropLayerController'; import type { ExtractMaskedAreaResult } from './controllers/extractMaskedAreaController'; -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'; @@ -142,6 +142,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; } @@ -201,9 +207,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; } @@ -281,6 +306,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). */ export type CanvasEditRefusal = SubsetOf< CanvasTransactionOutcome, @@ -516,9 +548,11 @@ export type DuplicateLayersResult = export type { BooleanRasterResult, CopyLayerToRasterResult, + CropAllLayersResult, CropLayerResult, ExtractMaskedAreaResult, MergeDownResult, + MergeInpaintMasksResult, MergeVisibleResult, RasterizeLayerResult, }; @@ -569,16 +603,31 @@ 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; + /** Whether `mergeInpaintMasks` would accept these ids; pure, for menu enablement. */ + canMergeInpaintMasks(layerIds: readonly string[]): boolean; cancelTextEdit(): void; cancelTransform(): void; clearMask(layerId: string): MaskEditResult; @@ -595,8 +644,15 @@ export interface CanvasEngineLayerCapability extends CanvasLayerCapability { /** `null` when there was nothing to commit: an empty creation or an unchanged edit. */ commitTextEdit(content: string, styleChanges?: Partial): StructuralCommitResult | null; 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): MergeDownResult; mergeSelectedRasterLayers(layerIds: readonly string[]): Promise; mergeVisibleRasterLayers(): Promise; 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 2ad09f6f55a..d97c2074653 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,13 @@ import type { CanvasDocumentContractV3, CanvasLayerContract } from '@workbench/canvas-engine/contracts'; 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'; @@ -100,3 +105,245 @@ 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 createCropAllHarness = (options: { historyBytes?: number; onExport?: (layerId: string) => void } = {}) => { + const document: CanvasDocumentContractV3 = { + background: 'transparent', + bbox: FRAME, + height: 100, + selectedLayerId: 'raster', + stacks: stacksFrom([ + layerContract('inside'), + layerContract('raster'), + lockedPaint('locked'), + lockedPaint('lockedInside'), + groupContract('lockedGroup', [lockedPaint('lockedByGroup', false)], { isLocked: true }), + layerContract('control', 'control', { isEnabled: false }), + 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); + } + 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) => { + exported.push(layerId); + options.onExport?.(layerId); + 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(layerId); + }, + status: 'ok' as const, + surface: entry.surface, + }); + }, + isGuardCurrent: () => guard.current, + isSupportedSource: () => true, + }); + return { ...h, controller, discardPersisted, exported, guard, original: h.document(), released, reservedAtExport }; +}; + +describe('CropLayerController: crop all layers', () => { + 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, 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({ + isEnabled: false, + source: { offset: { x: 5, y: 5 } }, + }); + 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('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, 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 e5b62f7fc75..9d3063a1e4d 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/cropLayerController.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/cropLayerController.ts @@ -7,10 +7,11 @@ import type { import type { RasterBackend, RasterSurface } from '@workbench/canvas-engine/render/raster'; import type { Rect } from '@workbench/canvas-engine/types'; +import { compileDocumentLeaves } from '@workbench/canvas-engine/document-model/documentModel'; 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 type { CanvasMutationContext, EditStep } from './mutationContext'; @@ -21,6 +22,7 @@ import { withReplayReservation, type LayerEditRefusal, replaceLayerStep, + replaceLayersStep, type LayerPixels, } from './editSteps'; @@ -29,6 +31,15 @@ export type CropLayerResult = | { status: 'missing' | 'locked' | 'unsupported' | 'empty' | LayerEditRefusal } | { status: 'failed'; message: string }; +/** + * `skippedLocked` counts locked leaves (own or ancestor lock) whose content reaches past the frame. `nothing` when + * every unlocked leaf already fits; every refusal leaves document, pixels and history alone. + */ +export type CropAllLayersResult = + | { status: 'cropped'; cropped: number; skippedLocked: number } + | { status: 'nothing'; skippedLocked: number } + | { status: 'stale' | 'failed' | LayerEditRefusal }; + type ExportResult = | { status: 'ok'; surface: RasterSurface; rect: Rect; guard: LayerExportGuard; release(): void } | { status: 'missing' | 'disabled' | 'unsupported' | 'empty' | 'not-ready' | 'over-budget' }; @@ -60,7 +71,53 @@ export interface CropLayerControllerOptions { const failedCrop: CropLayerResult = { message: 'The crop could not be applied.', status: 'failed' }; -/** Owns guarded crop-to-bbox conversion and replayable pixel snapshots. */ +/** + * 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') { + const { filter: _filter, ...rest } = before; + return { ...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; +}; + +/** 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) {} @@ -131,36 +188,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') { - const { filter: _filter, ...rest } = before; - after = { ...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: () => @@ -183,7 +220,204 @@ 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) + * 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. 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' }; + } + const document = ctx.getDocument(); + if (!document) { + return { skippedLocked: 0, status: 'nothing' }; + } + const frame = roundOut(document.bbox); + let skippedLocked = 0; + const candidates: CanvasLayerContract[] = []; + for (const leaf of compileDocumentLeaves(document)) { + const source = renderableSourceOf(leaf.layer); + if (!source || !this.deps.isSupportedSource(source)) { + continue; + } + if (!leaf.effectiveLocked) { + candidates.push(leaf.layer); + continue; + } + // A locked leaf counts only when cropping would have changed it: content reaching past the frame. + const content = getSourceContentRect(leaf.layer, document); + if (!isEmpty(content) && cropRectFor(transformBounds(leaf.worldTransform, content), frame)) { + skippedLocked += 1; + } + } + const guards: LayerExportGuard[] = []; + const cropped: CroppedLeaf[] = []; + try { + for (const layer of candidates) { + const exported = await this.deps.exportBaked(layer.id); + if (exported.status === 'missing') { + return { status: 'stale' }; + } + if (exported.status === 'not-ready' || exported.status === 'over-budget') { + return { status: exported.status }; + } + if (exported.status !== 'ok') { + // Empty, disabled-only or unsupported content has nothing to crop. + continue; + } + 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, status: 'nothing' }; + } + return this.publishAll(liveDocument, cropped, skippedLocked); + } catch { + return { status: 'failed' }; + } finally { + for (const leaf of cropped) { + leaf.reservation.release(); + } + } + } + + private publishAll( + document: CanvasDocumentContractV3, + cropped: readonly CroppedLeaf[], + skippedLocked: number + ): 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, skippedLocked, 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 dc14c3c2516..663e9f62e87 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/editSteps.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/editSteps.ts @@ -116,6 +116,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 +161,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/mergeLayerController.browser.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/mergeLayerController.browser.test.ts new file mode 100644 index 00000000000..3b93e36a29e --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/mergeLayerController.browser.test.ts @@ -0,0 +1,82 @@ +import type { CanvasDocumentContractV3, CanvasLayerContract } from '@workbench/canvas-engine/contracts'; +import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; +import type { Rect } from '@workbench/canvas-engine/types'; + +import { layerContract, stacksFrom } from '@workbench/canvas-engine/document-model/documentFixtures.testStub'; +import { getDocumentLayer } from '@workbench/canvas-engine/document/documentIndex'; +import { createDomRasterBackend } from '@workbench/canvas-engine/render/raster'; +import { describe, expect, it } from 'vitest'; + +import { createLayerOperationHarness } from './layerOperationHarness.testing'; +import { MergeLayerController } from './mergeLayerController'; + +/** 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, each mask at its own opacity', async () => { + // The top mask is half-covered at full opacity; the one below is fully covered at half opacity. + const sources: Record = { + below: { alpha: 255, opacity: 0.5, 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, + isCacheReady: () => true, + layers: h.layers, + needsPixelPersistence: () => true, + 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 coverage. Below only: half of full coverage. Both: 128 over 128 = 128 + 128 * (1 - 128/255). + expect(alphaAt(2)).toBeCloseTo(128, -0.5); + expect(alphaAt(12)).toBeCloseTo(128, -0.5); + expect(alphaAt(7)).toBeCloseTo(128 + 128 * (1 - 128 / 255), -0.5); + }); +}); 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 f4d6e47db4b..2dfeb498724 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 @@ -1,8 +1,12 @@ import type { CanvasDocumentContractV3, CanvasLayerContract } from '@workbench/canvas-engine/contracts'; import type { Rect } from '@workbench/canvas-engine/types'; -import { 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 { describe, expect, it } from 'vitest'; import { createLayerOperationHarness } from './layerOperationHarness.testing'; @@ -132,3 +136,143 @@ describe('MergeLayerController: merge down', () => { expect(h.dispatched).toEqual([]); }); }); + +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: { historyBytes?: number; onExport?: () => void } = {}) => { + const top = layerContract('top', 'inpaint_mask', { + denoise: { isEnabled: true, limit: 0.4 }, + mask: { bitmap: null, fill: { color: '#123456', style: 'grid' } }, + noise: { isEnabled: true, level: 0.2 }, + 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')]), + layerContract('bottom', 'inpaint_mask'), + ]), + 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, + isCacheReady: () => true, + layers: h.layers, + needsPixelPersistence: () => true, + 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, configured like it, 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 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 ece8ae5060b..1e4ef4948cf 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 { LayerCacheStore } from '@workbench/canvas-engine/render/layerCache'; @@ -12,12 +13,21 @@ 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, +} from '@workbench/canvas-engine/document/mergeVisible'; import { collectHistoryMediaRefs, HISTORY_ENTRY_OVERHEAD_BYTES } from '@workbench/canvas-engine/history/history'; import { isEmpty, roundOut, transformBounds, union } from '@workbench/canvas-engine/math/rect'; import { applyAdjustments, isIdentityAdjustments } from '@workbench/canvas-engine/render/adjustments'; @@ -45,6 +55,52 @@ import { export type MergeVisibleResult = 'merged' | LayerEditRefusal | 'nothing' | 'failed'; +/** Every refusal leaves document, pixels and history alone. */ +export type MergeInpaintMasksResult = + | { status: 'merged'; layerId: string } + | { status: 'ineligible' | 'missing' | 'failed' | LayerEditRefusal }; + +/** + * 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 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 }; + +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, noise and denoise. + */ +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 @@ -387,22 +443,55 @@ 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), + createResult: (id, contributors, rect) => paintLayerAt(id, `${contributors[0]!.name} merged`, rect), + label: 'Merge selected layers', + stack: 'raster', + }); + return 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' }; + } + const result = await this.mergeSelectedInto(layerIds, { + canMerge: (candidate, selectedIds) => + canMergeInpaintMasks(compileDocumentLeaves(candidate), selectedIds, this.deps.hasExportableContent), + createResult: mergedMaskLayer, + label: 'Merge masks', + stack: 'inpaint_mask', + }); + if (result.status === 'merged') { + return result; + } + return { status: result.status === 'nothing' ? 'ineligible' : result.status }; + } + + /** + * Composites the selected leaves bottom-up (source-over, each at its opacity) 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 => { @@ -420,13 +509,13 @@ export class MergeLayerController { } const exports = settled.map((result) => (result as PromiseFulfilledResult).value); if (!ctx.isPermitCurrent(permit)) { - return 'busy'; + return { status: 'busy' }; } if (exports.some((result) => result.status === 'over-budget')) { - return 'over-budget'; + return { status: 'over-budget' }; } 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]; @@ -438,7 +527,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 @@ -453,19 +542,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; @@ -474,7 +558,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); @@ -482,13 +566,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]!; @@ -510,10 +594,10 @@ export class MergeLayerController { 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], @@ -566,14 +650,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/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/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..fbf8f8fbe7c 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,6 +10,7 @@ import { describe, expect, it } from 'vitest'; import { areSelectedRasterLayersContiguous, + canMergeInpaintMasks, canMergeSelectedRasters, canMergeVisibleRasters, getMergeVisibleRasterLayers, @@ -113,3 +114,37 @@ 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'), + ]; + const can = (ids: string[]) => canMergeInpaintMasks(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); + }); +}); 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..c1396442cbf 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/document/mergeVisible.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/mergeVisible.ts @@ -81,3 +81,34 @@ export const canMergeSelectedRasters = ( leaf.layer.blendMode === 'normal' && hasContent(leaf.id) ); + +/** + * 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. Regional guidance never merges this way. + */ +export const canMergeInpaintMasks = ( + leaves: readonly SemanticLeaf[], + selectedLayerIds: ReadonlySet, + hasContent: HasMergeVisibleContent +): boolean => { + if (selectedLayerIds.size < 2) { + return false; + } + 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 false; + } + found += 1; + } + // A selected group or a missing id is not a mask leaf. + return found === selectedLayerIds.size; +}; 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 1e3334a9a7a..ccb6e5aff09 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 e257044e1ee..8a996a237dd 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.test.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.test.ts @@ -31,9 +31,10 @@ import { type EngineTestAction = WorkbenchAction | CanvasProjectMutation; import { stacksFrom } from '@workbench/canvas-engine/document-model/documentFixtures.testStub'; -import { getDocumentLeaves } from '@workbench/canvas-engine/document/documentIndex'; +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 { canvasApplicationPort } from '@workbench/canvas-operations/applicationPort'; @@ -232,6 +233,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 }, @@ -813,6 +829,61 @@ describe('createCanvasEngine', () => { engine.lifecycle.dispose(); }); + it('sends a pick’s commit or cancel only to the sink that took its first preview', 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()); @@ -2110,6 +2181,63 @@ describe('createCanvasEngine', () => { engine.lifecycle.dispose(); }); + 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, 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('cropLayerToBbox returns empty without mutation when the layer and bbox do not overlap', async () => { const document = { ...makeDoc(), bbox: { height: 5, width: 5, x: 50, y: 50 } }; const { projectId, store } = createReducerBackedStore(document); @@ -3154,37 +3282,86 @@ const putImageDataCalls = (surfaces: StubRasterSurface[]): { image: unknown; x: ); describe('engine-owned pixel editing', () => { - it('materializes an image layer plus eraser stroke as one reversible edit', async () => { - const h = createRasterImageEraserHarness(); + 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(); - const before = structuredClone(getDocumentLeaves(h.engine.document.getDocument()!)[0]); + // 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'); - 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: 'eraser' }); - expect(h.bitmapStore.suspendLayer).toHaveBeenCalledWith('image'); - expect(h.bitmapStore.markLayerDirty).toHaveBeenCalledWith('image'); - expect(h.engine.stores.canUndo.get()).toBe(true); + expect(strokeSpan(click(42, true))).toBeGreaterThanOrEqual(dab + 30); + expect(h.strokes).toHaveLength(2); await h.engine.history.undo(); - expect(getDocumentLeaves(h.engine.document.getDocument()!)[0]).toEqual(before); - expect(h.engine.stores.canRedo.get()).toBe(true); + expect(strokeSpan(click(12, true))).toBeCloseTo(dab, 6); - await h.engine.history.redo(); - expect(getDocumentLeaves(h.engine.document.getDocument()!)[0]).toEqual(after); + h.engine.document.replaceDocument(structuredClone(h.engine.document.getDocument()!)); + await h.publishInitialCache(); + expect(strokeSpan(click(42, true))).toBeCloseTo(dab, 6); h.engine.lifecycle.dispose(); }); @@ -8713,6 +8890,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(); @@ -8728,7 +8910,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[] = []; @@ -15236,6 +15418,315 @@ describe('fitToView: content ∪ bbox', () => { // ---- 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('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(); + }); + + it('cancels a session on another layer, dropping its preview, before opening one on the target', () => { + const { dispatch, engine } = setup([rasterLayer('a'), rasterLayer('b')], 'a'); + engine.tools.setTool('transform'); + engine.layers.updateTransformSession({ rotation: 0, scaleX: 2, scaleY: 2, x: 5, y: 6 }); + dispatch.mockClear(); + + expect(engine.layers.beginTransform('b')).toBe('started'); + expect(engine.stores.transformSession.get()).toMatchObject({ + layerId: 'b', + transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, + }); + // Only the selection changed: the abandoned preview on `a` never landed. + expect(dispatch.mock.calls.map(([action]) => action.type)).toEqual(['setCanvasSelectedLayer']); + expect(getDocumentLayer(engine.document.getDocument(), 'a')?.transform.scaleX).toBe(1); + engine.lifecycle.dispose(); + }); + + it.each([ + ['locked', [{ ...rasterLayer('b'), isLocked: true }], 'locked'], + ['a mask', [thumbnailMaskLayer('b', 'inpaint_mask')], 'ineligible'], + ['missing', [rasterLayer('a')], 'missing'], + ] as const)('refuses %s without selecting or switching tools', (_, layers, status) => { + const { dispatch, engine } = setup([rasterLayer('a'), ...layers.filter((layer) => layer.id !== 'a')], 'a'); + + expect(engine.layers.beginTransform('b')).toBe(status); + expect(dispatch).not.toHaveBeenCalled(); + expect(engine.stores.activeTool.get()).not.toBe('transform'); + expect(engine.stores.transformSession.get()).toBeNull(); + engine.lifecycle.dispose(); + }); + + /** Paints a stroke onto a new layer, then asks for its transform before persistence acknowledges the pixels. */ + const strokeThenTransform = (options: { 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 { dispatch, projectId, store } = createReducerBackedStore({ + ...makeDoc(), + selectedLayerId: null, + stacks: stacksFrom([]), + }); + 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: string[] = []; + 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('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(); + } + }); + + 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('refuses as busy while document edits are locked, leaving the selection and tool unchanged', () => { + const { dispatch, engine } = setup([rasterLayer('a'), rasterLayer('b')], 'a'); + engine.tools.setTool('brush'); + engine.stores.documentEditingLocked.set(true); + dispatch.mockClear(); + + expect(engine.layers.beginTransform('b')).toBe('busy'); + expect(dispatch).not.toHaveBeenCalled(); + expect(engine.stores.activeTool.get()).toBe('brush'); + expect(engine.stores.transformSession.get()).toBeNull(); + engine.lifecycle.dispose(); + }); + + it('refuses as busy once disposed, without touching the selection', () => { + const { dispatch, engine } = setup([rasterLayer('a'), rasterLayer('b')], 'a'); + engine.lifecycle.dispose(); + dispatch.mockClear(); + + expect(engine.layers.beginTransform('b')).toBe('busy'); + expect(dispatch).not.toHaveBeenCalled(); + }); +}); + describe('transform session', () => { it('image layer Apply commits one structural transform with the exact inverse', async () => { const { store } = createReactiveStore(selectedImageDoc()); @@ -18962,6 +19453,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(), @@ -18969,6 +19461,7 @@ describe('layer quick-switch memory', () => { reportStructuralCommit: vi.fn(), reportPreparedCommit: vi.fn(), reportLayerOperation: vi.fn(), + reportTransformRefusal: vi.fn(), t: (key) => key, }); }; @@ -19048,7 +19541,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 7d850b10de7..53b4a3e49e5 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.ts @@ -1,4 +1,5 @@ import type { + BeginTransformResult, CanvasEditRefusal, CanvasHistoryReplayStatus, CanvasHistoryCapability, @@ -16,17 +17,21 @@ import type { CanvasDocumentSnapshot, CanvasLifecycleCapability, CanvasSelectionCapability, + CanvasSurfaceAttachOptions, CanvasSurfaceCapability, CanvasViewportCapability, + ColorSamplePhase, BooleanRasterOperation, BooleanRasterResult, CopyLayerToRasterResult, + CropAllLayersResult, CropLayerResult, ExportBakedLayerPixelsOptions, ExportLayerPixelsOptions, ExtractMaskedAreaResult, MaskEditResult, MergeDownResult, + MergeInpaintMasksResult, MergeVisibleResult, NewRasterLayerResult, PsdExportResult, @@ -124,6 +129,7 @@ import { createCanvasDiagnostics } from '@workbench/canvas-engine/diagnostics'; import { compileDocumentLeaves, createDocumentModel, + lookupDocumentLeaf, type CanvasDocumentModel, } from '@workbench/canvas-engine/document-model/documentModel'; import { @@ -178,7 +184,7 @@ import { stepBrushSize } from '@workbench/canvas-engine/tools/paintConstants'; import { createSamTool } from '@workbench/canvas-engine/tools/samTool'; import { createShapeTool } from '@workbench/canvas-engine/tools/shapeTool'; import { createTextTool } from '@workbench/canvas-engine/tools/textTool'; -import { createTransformTool } from '@workbench/canvas-engine/tools/transformTool'; +import { createTransformTool, isTransformEligible } from '@workbench/canvas-engine/tools/transformTool'; import { FULL_DAMAGE } from '@workbench/canvas-engine/types'; import { createViewport, type Viewport } from '@workbench/canvas-engine/viewport'; @@ -189,6 +195,7 @@ import type { StrokeCommittedEvent, Tool, ToolContext } from './tools/tool'; import { createBitmapStore, type BitmapStore } from './document/bitmapStore'; import { createDocumentMirror, type DocumentMirror } from './document/documentMirror'; import { decideLayerChange } from './document/layerChangeDecision'; +import { canMergeInpaintMasks as canMergeInpaintMasksIn } from './document/mergeVisible'; import { getSourceBounds, getSourceContentRect, isRenderableLayer, renderableSourceOf } from './document/sources'; import { collectCanvasFontReferences, replaceCanvasFontReferences } from './fontReferences'; import { createLayerExportGuards, isSupportedExportSource } from './layerExportGuards'; @@ -393,6 +400,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, }; @@ -622,14 +630,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) { + failPendingTransform(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); @@ -639,6 +652,7 @@ 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(); floatingSelection.cancel(); structuralController.endPreview(); }, @@ -980,16 +994,56 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC } /** - * One-shot color claims stash samples during press/drag and settle on release, after structural edits are + * One-shot color claims take a pick's previews and settle on its release commit, 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; + let colorSampleRouter: ((hex: string, phase: ColorSamplePhase) => boolean) | null = null; + // Which sink took the current pick's first preview; null between picks. + let colorPickSink: 'request' | 'router' | null = null; + // Shift-click line starts: where the last brush or eraser stroke ended on each layer. Engine-lifetime memory, + // kept across tool switches and forgotten on replay, document replacement and layer removal. + const paintLineEnds = new Map(); + /** + * A transform requested on pixels persistence has not acknowledged yet (a stroke's first upload) waits in + * `pendingTransformLayerId`: sessions size their frame from the persisted source, so the session opens once the + * bitmap lands, if the layer is still the selection under the transform tool (or a hold over it) with no session + * open. Otherwise the wait ends there; a genuine tool switch, Escape, document replacement and a failed upload of + * that layer end it too. + */ + const openDeferredTransform = (): void => { + const layerId = stores.pendingTransformLayerId.get(); + if (layerId === null) { + return; + } + const document = mirror.getDocument(); + const layer = getDocumentLayer(document, layerId); + // A Space, Alt or C hold only lends the surface to another tool; the user is still on transform. + const toolId = pipeline.heldRestoreToolId() ?? interactionController.getActiveToolId(); + if ( + !document || + !layer || + document.selectedLayerId !== layerId || + toolId !== 'transform' || + stores.transformSession.get() !== null + ) { + stores.pendingTransformLayerId.set(null); + } else if (isTransformEligible(layer, document)) { + stores.pendingTransformLayerId.set(null); + beginTransformSession(layerId); + } + }; + /** A pending transform whose pixels could not be persisted will not open; say so instead of waiting silently. */ + const failPendingTransform = (layerId: string): void => { + if (stores.pendingTransformLayerId.get() === layerId) { + stores.pendingTransformLayerId.set(null); + reportEditRefusal('not-ready'); + } + }; const toolContext: ToolContext = { applyTransform: () => applyTransform(), @@ -1046,23 +1100,25 @@ 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; + resolveColorSample: (hex, phase) => { + // A pick belongs to whichever sink 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 router's pick into the request. + if (phase === 'preview') { + colorPickSink ??= pendingColorSample ? 'request' : 'router'; } - return colorSampleRouter?.(hex) ?? false; - }, - commitColorSample: () => { - if (pendingColorSample !== null && pendingColorSample.sampledHex !== null) { - settleColorSample(pendingColorSample.sampledHex, true); + const sink = colorPickSink; + if (phase !== 'preview') { + colorPickSink = null; } - }, - discardColorSample: () => { - if (pendingColorSample !== null) { - pendingColorSample.sampledHex = null; + if (sink === 'request') { + if (phase === 'commit' && pendingColorSample) { + settleColorSample(hex, true); + } + return true; } + return sink === 'router' ? (colorSampleRouter?.(hex, phase) ?? false) : false; }, setLayerTransformOverride: (layerId, override) => { if (override) { @@ -1397,6 +1453,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, @@ -1408,6 +1467,7 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC layerCache, previews: renderController.previews, raster: rasterController, + hasStagedCandidates, rasterizeLayer: (layer, doc) => void getOrStartLayerRasterization(layer, doc), stores, transformOverrides, @@ -1465,7 +1525,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: () => { + openDeferredTransform(); + 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: () => { @@ -1484,7 +1547,9 @@ 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()); + paintLineEnds.clear(); cleanup.run(() => stores.transformSession.set(null)); + cleanup.run(() => stores.pendingTransformLayerId.set(null)); cleanup.run(() => transformOverrides.clear()); // A text-edit session likewise belongs to the outgoing document; drop it. cleanup.run(() => stores.textEditSession.set(null)); @@ -1569,6 +1634,7 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC sourceChanged: sourceChanged.has(id), }); if (decision.kind === 'removed') { + paintLineEnds.delete(id); rasterController.deleteThumbnailKey(id); cleanup.run(() => dropLayer(id)); // Removed preview layers must drop decoded pixels and advance decode tokens so late results or restored @@ -1634,7 +1700,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; @@ -1766,6 +1837,10 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC // finished the move. if (!switchOptions?.temporary) { floatingSelection.commit(); + // A genuine switch away also ends a transform still waiting on its pixels. + if (to !== 'transform') { + stores.pendingTransformLayerId.set(null); + } } for (const listener of toolChangeListeners) { listener({ from, temporary: switchOptions?.temporary === true, to }); @@ -1812,7 +1887,7 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC pipeline.endForToolSwitch(); return new Promise((resolve) => { - pendingColorSample = { previousToolId: interactionController.getActiveToolId(), resolve, sampledHex: null }; + pendingColorSample = { previousToolId: interactionController.getActiveToolId(), resolve }; interactionController.setTool('colorPicker'); }); }; @@ -1837,6 +1912,10 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC cancelTransform(); return; } + if (stores.pendingTransformLayerId.get() !== null) { + stores.pendingTransformLayerId.set(null); + return; + } if (floatingSelection.has()) { // Escape restores lifted pixels while retaining selection; Enter, deselect and tool switches commit the // float. @@ -1856,6 +1935,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), @@ -1930,8 +2010,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 => { @@ -2271,6 +2352,16 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC const mergeSelectedRasterLayers = (layerIds: readonly string[]): Promise => layerController.merge.mergeSelected(layerIds); const mergeVisibleRasterLayers = (): Promise => layerController.merge.mergeVisible(); + const canMergeInpaintMasks = (layerIds: readonly string[]): boolean => { + const document = mirror.getDocument(); + return ( + document !== null && + canMergeInpaintMasksIn(compileDocumentLeaves(document), new Set(layerIds), hasExportableLayerContent) + ); + }; + const mergeInpaintMasks = (layerIds: readonly string[]): Promise => + layerController.merge.mergeInpaintMasks(layerIds); + const cropAllLayersToBbox = (): Promise => layerController.crop.cropAll(); const { captureDocumentSnapshot, captureRasterSnapshot, isDocumentSnapshotCurrent, releaseActiveSnapshots } = createRasterSnapshotCapture({ @@ -2302,6 +2393,42 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC // paint; Cancel drops previews. Tools and public UI share the session API. const beginTransformSession = (layerId: string): void => editingController.transform.begin(layerId); + /** Opens a session on `layerId` now, whatever tool is active; the context menu and shortcut use it. */ + const beginTransform = (layerId: string): BeginTransformResult => { + const document = mirror.getDocument(); + const leaf = lookupDocumentLeaf(document, layerId); + if (disposed || !document || !leaf) { + return disposed ? 'busy' : 'missing'; + } + if (leaf.effectiveLocked) { + return 'locked'; + } + const persisted = isTransformEligible(leaf.layer, document); + if (!persisted && !isTransformEligible(leaf.layer, document, layerCache.peek(layerId)?.rect)) { + return 'ineligible'; + } + if (interactionLocked || pipeline.isGestureActive() || floatingSelection.has() || !canEditDocument()) { + return 'busy'; + } + // Selecting cancels a session on another layer (see `onSelectionChanged`); entering the tool opens one on the + // selection, and an already active tool is opened explicitly. + if (document.selectedLayerId !== layerId) { + dispatchCanvasMutation({ id: layerId, type: 'setCanvasSelectedLayer' }); + } + setTool('transform'); + if (stores.transformSession.get()?.layerId !== layerId) { + cancelTransform(); + if (persisted) { + beginTransformSession(layerId); + } + // Every refusal is decided above, before the selection or tool changes. A layer whose pixels persistence + // has not acknowledged yet (a stroke's first upload) opens its session once the bitmap lands. + if (stores.transformSession.get()?.layerId !== layerId) { + stores.pendingTransformLayerId.set(layerId); + } + } + return 'started'; + }; const updateTransformSession = (transform: LayerTransform): void => editingController.transform.update(transform); // A framed float is the session — Apply banks it, Cancel abandons it. Only // with no float in flight do these reach the layer transform session. @@ -2410,6 +2537,7 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC return; } disposed = true; + paintLineEnds.clear(); releaseActiveSnapshots(); rasterController.memory.releaseGeneration(lifecycleGeneration); lifecycleGeneration += 1; @@ -2768,7 +2896,9 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC const layersCapability: CanvasEngineLayerCapability = { ...layerController.layers, applyTransform, + beginTransform, booleanMergeRasterLayers, + canMergeInpaintMasks, cancelTextEdit, cancelTransform, clearMask, @@ -2780,8 +2910,10 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC commitStagedImage, commitTextEdit, copyLayerToRaster, + cropAllLayersToBbox, cropLayerToBbox, duplicateLayers, + mergeInpaintMasks, mergeLayerDown, mergeSelectedRasterLayers, mergeVisibleRasterLayers, diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/engineStores.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/engineStores.ts index 78890b98504..c4102ed2943 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/engineStores.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/engineStores.ts @@ -507,6 +507,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; /** @@ -727,6 +729,7 @@ export const createEngineStores = (initialTool: ToolId = 'view'): EngineStores = thumbnailVersion: createKeyedVersionStore(), thumbnailStatus: createKeyedThumbnailStatusStore(), transformSession: createScalarStore(null, transformSessionEqual), + pendingTransformLayerId: createScalarStore(null), viewportReady: createScalarStore(false), zoom: createScalarStore(1), }); 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/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/render/compositeFrame.budget.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/compositeFrame.budget.test.ts index 95b6f69fe20..8c9cbe05c2e 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 @@ -55,6 +55,7 @@ const scene = (budgetBytes = SURFACE_BYTES * 2) => { getCheckerboardTile: () => backend.createSurface(1, 1), getGroupSurface: (scope, members, matrices, content) => raster.groups.get(scope, members, matrices, content), getMaskPatternTile: () => null, + hasStagedCandidates: () => false, layerCache: raster.layers, previews: new PreviewStateController(), raster, @@ -124,6 +125,7 @@ describe('frame description over 2,000 nodes', () => { getCheckerboardTile: () => backend.createSurface(1, 1), 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 d11ec3f2254..0ab40ceaabb 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 @@ -46,6 +46,7 @@ const documentOf = (layers: CanvasLayerContract[]): CanvasDocumentContractV3 => interface Harness { deps: CreateCompositeFrameDeps; + staging: { candidates: boolean }; stores: ReturnType; previews: PreviewStateController; overrides: Map; @@ -70,9 +71,12 @@ 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 }; return { beginFrame, + staging, deps: { + hasStagedCandidates: () => staging.candidates, backend: {} as CreateCompositeFrameDeps['backend'], derivedSurfaceCache: { byteSize: () => 0 } as CreateCompositeFrameDeps['derivedSurfaceCache'], diagnostics: {} as CreateCompositeFrameDeps['diagnostics'], @@ -317,3 +321,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 1801a5fb0a9..e779cfa363c 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/compositeFrame.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/compositeFrame.ts @@ -44,8 +44,16 @@ export interface CreateCompositeFrameDeps { readonly getCheckerboardTile: () => RasterSurface; /** 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( @@ -130,12 +138,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; @@ -185,7 +195,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({ @@ -220,8 +232,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 d061d94dc2e..8a47c4b4155 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'; @@ -108,6 +109,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. @@ -124,7 +127,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; /** @@ -164,14 +167,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[]; @@ -198,13 +208,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; @@ -216,6 +226,7 @@ export const prepareComposite = ( isolationLayerId, leaves, matrices: leaves.map((leaf) => effectiveMatrix(leaf, overrides)), + overlayStacksKey: overlayStacksKey(opts.showOverlayStacks), overridesKey: transformOverridesKey(overrides), scopes, }; @@ -229,6 +240,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/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/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..6bac3fcd0e6 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,117 @@ 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('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..3dda85ba2ff 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/paintTool.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/paintTool.ts @@ -1,7 +1,7 @@ /** * 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. */ import type { CanvasLayerContract, CanvasRasterLayerContractV2 } from '@workbench/canvas-engine/contracts'; @@ -112,17 +112,27 @@ 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 unready image keeps the brush's auto-created paint layer below. + 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)) { @@ -297,7 +307,10 @@ 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: both points take the press pressure, so the segment + // renders as a line rather than collapsing to a tap. + const lineStart = input.modifiers.shift ? ctx.paintLineEnds?.get(target.layerId) : undefined; + session.addPoints(lineStart ? [{ ...input, documentPoint: lineStart }, input] : [input]); } catch { abortSession(); } @@ -326,7 +339,9 @@ export const createPaintTool = (spec: PaintToolSpec): Tool => { throw error; } endSession(); - if (!published) { + if (published) { + ctx.paintLineEnds?.set(activeTarget.layerId, { ...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..ba8992bac06 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 document 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,19 @@ 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, in document space; 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/canvasProjectMutations.ts b/invokeai/frontend/webv2/src/workbench/canvasProjectMutations.ts index e1d38e5c4d4..89a6561f089 100644 --- a/invokeai/frontend/webv2/src/workbench/canvasProjectMutations.ts +++ b/invokeai/frontend/webv2/src/workbench/canvasProjectMutations.ts @@ -70,6 +70,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))); @@ -851,6 +872,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/workbenchState.test.ts b/invokeai/frontend/webv2/src/workbench/workbenchState.test.ts index ec38b4694f7..96f7d2b5335 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', @@ -7880,6 +7881,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; From a3675e39052946ed162c361cbcb6a1ebc117176f Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Tue, 20 Oct 2026 01:42:16 -0600 Subject: [PATCH 03/14] feat(generation): reset the frame to native size and remember Auto Process 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. --- .../frontend/webv2/public/locales/en.json | 62 ++++-- .../webv2/src/app/GenerationUiAdapter.tsx | 8 +- .../GenerateDimensionFields.browser.test.tsx | 191 ++++++++++++++++-- .../generation/ui/GenerateDimensionFields.tsx | 191 ++++++++++++++---- .../ui/GenerateSettingsForm.browser.test.tsx | 40 ++++ .../generation/ui/GenerateSettingsForm.tsx | 29 ++- .../generation/ui/GenerationUiContext.tsx | 5 + .../SelectObjectCoordinator.test.ts | 78 +++++++ .../SelectObjectCoordinator.ts | 1 + .../workbench/canvas-operations/contracts.ts | 3 + .../canvas-operations/createCanvasEngine.ts | 5 +- .../selectObjectSession.test.ts | 22 +- .../canvas-operations/selectObjectSession.ts | 10 +- .../canvas-operations/useCanvasEngine.ts | 2 + .../src/workbench/canvasDimsSync.test.ts | 86 +++++++- .../webv2/src/workbench/canvasDimsSync.ts | 42 ++++ .../webv2/src/workbench/settings/contracts.ts | 2 + .../src/workbench/settings/store.test.ts | 10 + .../webv2/src/workbench/settings/store.ts | 5 + .../GenerateCanvasRenderSize.browser.test.tsx | 27 +-- .../tool-options/FilterOptions.test.tsx | 6 +- .../canvas/tool-options/FilterOptions.tsx | 2 +- .../canvas/tool-options/SamOptions.test.ts | 33 ++- .../canvas/tool-options/SamOptions.tsx | 15 +- 24 files changed, 767 insertions(+), 108 deletions(-) create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-operations/SelectObjectCoordinator.test.ts diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index 72e8a0f565c..6d771519907 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -2798,6 +2798,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", @@ -2812,8 +2817,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", @@ -3267,19 +3270,30 @@ "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", + "cropAllLayersToBbox": "Crop all layers to frame", + "cropAllNothing": "Every layer already fits the frame", + "cropAllNothingUnlocked": "Every unlocked layer already fits the frame", + "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", @@ -3289,7 +3303,7 @@ "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", @@ -3341,9 +3355,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", @@ -3364,6 +3378,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", @@ -3498,13 +3513,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}})" } }, "workflow": { @@ -3730,9 +3748,9 @@ "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", "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", @@ -3747,7 +3765,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", @@ -3813,7 +3831,12 @@ "showModifiers": "Show modifiers", "addDenoiseLimit": "Add denoise limit", "addNoise": "Add noise", - "addRegenerateRegion": "Add regenerate region" + "addRegenerateRegion": "Add regenerate region", + "addRegenerateRegionDescription": "Regenerate where this layer has content, as a live inpaint mask.", + "mergeMasks": "Merge masks", + "mergeMasksIneligible": "Merge masks needs two or more enabled, unlocked inpaint masks with content.", + "removeFromGroup": "Remove from group", + "transformIneligible": "Transform works on enabled raster and control layers with content." }, "control": { "apply": "Apply", @@ -3980,6 +4003,7 @@ "preview": "Preview", "processFailed": "The filter could not run.", "replace": "Replace", + "resetSettings": "Reset settings", "running": "Running filter…", "sourceOverBudget": "Not enough memory is free to read this layer for the filter.", "statusCommitting": "Applying filter…", @@ -3990,12 +4014,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.", "errorNotReady": "The layer source is not ready. Try again when it finishes loading.", 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/ui/GenerateDimensionFields.browser.test.tsx b/invokeai/frontend/webv2/src/features/generation/ui/GenerateDimensionFields.browser.test.tsx index d8fd6865b3f..c958aa8b15c 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 { 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 { GenerateDimensionFields } from './GenerateDimensionFields'; @@ -37,6 +43,11 @@ void i18n.use(initReactI18next).init({ height: 'Height', lockAspectRatio: 'Lock aspect ratio', megapixelsValue: '{{value}} MP', + 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', @@ -62,28 +73,30 @@ const settle = (action: () => void): Promise => }); }); -const render = async (settings: Partial) => { +const render = async (settings: Partial, model: MainModelConfig = sd1Model) => { host = document.createElement('div'); document.body.append(host); root = createRoot(host); const onCommit = vi.fn(); + const onResetSize = vi.fn(); await settle(() => { root?.render( ); }); - return onCommit; + return { onCommit, onResetSize }; }; const lockButton = () => @@ -93,16 +106,18 @@ const lockButton = () => const bracket = () => host?.querySelector('[data-part="bracket"]')?.parentElement ?? null; const presetTrigger = () => host?.querySelector('[role="combobox"]') ?? null; -afterEach(async () => { +const unmountAndReset = async () => { await settle(() => root?.unmount()); host?.remove(); host = null; root = null; -}); +}; + +afterEach(unmountAndReset); 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); @@ -121,7 +136,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, @@ -145,7 +160,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()); @@ -160,7 +175,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()); @@ -174,7 +189,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, @@ -192,3 +207,155 @@ describe('GenerateDimensionFields aspect ratio', () => { }); }); }); + +describe('GenerateDimensionFields Reset size', () => { + const resetSizeButton = () => host?.querySelector('button[aria-label="Reset size"]') ?? null; + const menuItems = () => [...document.querySelectorAll('[role="menuitem"]')]; + 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('marks the applied choices current and disabled, 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-current')).toBe('true'); + expect(square?.getAttribute('aria-disabled')).toBe('true'); + 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')?.hasAttribute('aria-current')).toBe(false); + expect(menuItem('Square at native size')?.hasAttribute('aria-disabled')).toBe(false); + }); + + 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 ec98116da29..8e9533fe1ce 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 { ASPECT_RATIO_MAP, @@ -13,9 +13,11 @@ import { MIN_DIMENSION, } 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,75 @@ 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 disabled. */ + isCurrent: boolean; + onSelect: () => void; +} + +/** The ratio row's Reset size menu; each choice shows the size it sets. */ +const ResetSizeMenu = ({ choices }: { choices: readonly ResetSizeChoice[] }) => { + const { t } = useTranslation(); + const ids = useTooltipTriggerIds(); + const label = t('widgets.generate.resetSize'); + + 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, @@ -255,10 +328,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); @@ -399,16 +473,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( @@ -416,9 +558,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. @@ -443,12 +582,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', { @@ -460,13 +596,6 @@ export const GenerateDimensionFields = ({ ? ` · ${t('widgets.generate.secondsPerRun', { value: Math.round(secondsPerRun) })}` : ''} - {offersOptimalSize ? ( - - - - ) : null} ); @@ -497,14 +626,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} > @@ -526,6 +648,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 } } }); @@ -510,6 +516,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 981f5770eb3..3a6e328efd8 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/GenerateSettingsForm.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/GenerateSettingsForm.tsx @@ -24,6 +24,7 @@ import { GenerateGuidanceSection } from './GenerateGuidanceSection'; import { GenerateModelCard } from './GenerateModelCard'; import { GenerateRefinerSection } from './GenerateRefinerSection'; import { GenerateRenderSection } from './GenerateRenderSection'; +import { useGenerationUi } from './GenerationUiContext'; import { GeneratePromptFields } from './promptFields'; const GENERATE_INPUT_DEBOUNCE_MS = 250; @@ -54,6 +55,7 @@ export const GenerateSettingsForm = ({ settings, supportedModels, }: GenerateSettingsFormProps) => { + const { resetDimensions } = useGenerationUi().settings; const [draft] = useState(() => createExternalStoreCore(settings)); const latestSettingsRef = useRef(settings); const pendingUpdateRef = useRef(null); @@ -215,8 +217,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), @@ -231,9 +234,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 ( @@ -259,7 +272,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/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 af4ae913e8d..aad565317ac 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-operations/contracts.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/contracts.ts @@ -196,6 +196,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; @@ -384,6 +386,7 @@ export interface CreateSelectObjectSessionOptions { cleanupPreview(): void; isGuardCurrent(guard: LayerExportGuard): boolean; }; + initialAutoProcess?: boolean; operation: CanvasOperationSession; } diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/createCanvasEngine.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/createCanvasEngine.ts index c92f73125a2..ed4a55b6f28 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-operations/createCanvasEngine.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/createCanvasEngine.ts @@ -27,6 +27,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: { @@ -63,7 +65,7 @@ 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 { ensureProjectOnServer, filterDeps, selectObjectDeps, ...coreOptions } = options; + const { ensureProjectOnServer, filterDeps, readSelectObjectAutoProcess, selectObjectDeps, ...coreOptions } = options; const owner = captureAccountScope(); const uploadLifetime = new AbortController(); let projectMissingAt = -Infinity; @@ -164,6 +166,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/selectObjectSession.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/selectObjectSession.test.ts index 2e4d48eb14c..e9e60351177 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, @@ -575,6 +578,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(); @@ -860,7 +874,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) }); @@ -874,7 +888,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 ef80c6b04ce..1aadbf0cb59 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, @@ -161,7 +163,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; @@ -529,7 +531,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.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/useCanvasEngine.ts index 1d82499ae70..5078e3f02f9 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-operations/useCanvasEngine.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/useCanvasEngine.ts @@ -5,6 +5,7 @@ import { galleryImageUrls } from '@features/gallery/utility'; import { getModelsSnapshot } from '@features/models'; import { createCanvasProjectMutationPort } from '@workbench/canvasProjectMutationPort'; import { publishLayerPanelSelection, readLayerPanelState } from '@workbench/layerPanelState'; +import { getWorkbenchPreferences } from '@workbench/settings/store'; import { resolveDefaultControlModelForBase } from '@workbench/widgets/layers/controlModelOptions'; import { getSelectedModelBase } from '@workbench/widgets/layers/selectedModel'; import { @@ -100,6 +101,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/settings/contracts.ts b/invokeai/frontend/webv2/src/workbench/settings/contracts.ts index b173db5d85e..1c3cf99d1a7 100644 --- a/invokeai/frontend/webv2/src/workbench/settings/contracts.ts +++ b/invokeai/frontend/webv2/src/workbench/settings/contracts.ts @@ -92,6 +92,8 @@ export interface WorkbenchPreferences { workflowEdgesBehindNodes: boolean; /** 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 b04734654e1..2579ca9b391 100644 --- a/invokeai/frontend/webv2/src/workbench/settings/store.test.ts +++ b/invokeai/frontend/webv2/src/workbench/settings/store.test.ts @@ -395,6 +395,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('normalizeWorkbenchPreferences workflow editor', () => { it('keeps connections above nodes unless the preference is explicitly on', () => { expect(store.normalizeWorkbenchPreferences({}).workflowEdgesBehindNodes).toBe(false); diff --git a/invokeai/frontend/webv2/src/workbench/settings/store.ts b/invokeai/frontend/webv2/src/workbench/settings/store.ts index 7bf67bbeda4..3841b16a909 100644 --- a/invokeai/frontend/webv2/src/workbench/settings/store.ts +++ b/invokeai/frontend/webv2/src/workbench/settings/store.ts @@ -69,6 +69,7 @@ export const DEFAULT_PREFERENCES: WorkbenchPreferences = { promptFontSize: 'default', queueJobsScope: 'all', reduceMotion: false, + selectObjectAutoProcess: false, showPromptSyntaxHighlighting: true, showFocusRegionHighlight: true, themeId: DEFAULT_THEME_ID, @@ -356,6 +357,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/widgets/canvas/GenerateCanvasRenderSize.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/GenerateCanvasRenderSize.browser.test.tsx index 8cf3479aa80..db4e3973716 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/GenerateCanvasRenderSize.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/GenerateCanvasRenderSize.browser.test.tsx @@ -136,6 +136,7 @@ const renderSizeSection = async (project: HarnessProject) => { 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/tool-options/FilterOptions.test.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/FilterOptions.test.tsx index 6ffc40f0bdb..6f0db1a6ed8 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 @@ -223,9 +223,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 4157443afd7..8a2f6492762 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 @@ -236,7 +236,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..13eb8793277 --- /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 { + CANVAS_COMPANION_HOTKEYS, + CANVAS_HISTORY_HOTKEYS, + executeCanvasToolCommand, + reportTransformRefusal, + type CanvasToolCommandId, +} from '@workbench/widgets/canvas/canvasHotkeyCommands'; +import { isCanvasInteractionLocked } from '@workbench/widgets/canvas/canvasInteractionLock'; +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/vite.config.mts b/invokeai/frontend/webv2/vite.config.mts index 2b32173a43b..eb9b08161f5 100644 --- a/invokeai/frontend/webv2/vite.config.mts +++ b/invokeai/frontend/webv2/vite.config.mts @@ -267,9 +267,12 @@ const CANVAS_LAYER_SHARED_MODULES = [ '/workbench/canvas-operations/useCanvasEngine.ts', '/workbench/canvasProjectMutationPort.ts', '/workbench/useCanvasProjectMutationDispatch.ts', + '/workbench/widgets/canvas/canvasHotkeyCommands.ts', '/workbench/widgets/canvas/canvasInteractionLock.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', From dbac9682fdb288c790df72c4114f95f83c9e5f3b Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Tue, 20 Oct 2026 01:42:16 -0600 Subject: [PATCH 05/14] docs(canvas): describe the updated canvas tools and size reset Covers straight strokes, crop all, merge masks, frame naming, Shift+T, Alt-pick targets, zoom shortcuts and the Generate size reset menu. --- .../04.Image Generation/generate-panel.mdx | 10 +++- .../04.Image Generation/parameters.mdx | 4 +- .../Users Guide/07.Canvas/01.introduction.mdx | 4 +- .../Users Guide/07.Canvas/02.canvas-guide.mdx | 55 ++++++++++++------- .../Users Guide/07.Canvas/03.layers-panel.mdx | 21 ++++--- 5 files changed, 60 insertions(+), 34 deletions(-) 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..abeefa809cf 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 and unavailable: + * **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. + * **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..7f9745a47ca 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** in the Size section, and upscale afterwards for larger images. ## 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 abe71656d1a..348c2b69cf8 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 @@ -1,7 +1,7 @@ --- title: Crafting images with the Canvas description: An introduction to the Canvas — layers and masks, sending generations to the Canvas, inpainting, outpainting, regional prompts, control layers, drawing and adding gallery images. -lastUpdated: 2026-10-05 +lastUpdated: 2026-10-19 --- import { Image } from 'astro:assets'; @@ -171,4 +171,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 ceeb399f26b..84da1ebecde 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 @@ -1,7 +1,7 @@ --- title: Canvas guide description: A detailed guide to the Canvas — its tools and their options, navigation, the header buttons, the generation frame, selections, transform, filters, the staging area, menus, settings and keyboard shortcuts. -lastUpdated: 2026-10-05 +lastUpdated: 2026-10-19 --- import { Image } from 'astro:assets'; @@ -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; +* 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, use the zoom menu instead. * **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. @@ -70,9 +77,9 @@ 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 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. * **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 Gallery's selected board. Its arrow offers **Save BBox to Gallery**, which saves only what is inside the generation frame. +* **Save Canvas to Gallery** — saves the visible canvas as an image on the Gallery's selected board. Its arrow offers **Save Frame to Gallery**, which saves only what is inside the generation frame. * **New session → New canvas** — 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. **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. A notice says how many layers were cropped, counting only the ones that changed, and how many locked layers were skipped. -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 with **Save layer to uploads**. 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 with **Save layer to uploads**. 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 bb27657b9b0..bb7580db566 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 @@ -1,7 +1,7 @@ --- title: Layers panel guide description: A detailed guide to the Canvas Layers panel — the color picker, managing layers, history, the layer menu, and every layer type's settings in the Properties pane. -lastUpdated: 2026-09-30 +lastUpdated: 2026-10-19 --- import { Image } from 'astro:assets'; @@ -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 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,22 @@ 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 uploads, Disable layer, Lock layer, 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 uploads, Disable layer, Lock layer, 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**. * **Copy to** — including **Copy layer to clipboard**, and copies to other layer types. - * **Crop layer to bbox** and **Save layer to uploads** (save it to the Gallery's Uploads). + * **Crop layer to frame** and **Save layer to uploads** (save it to the Gallery's Uploads). * **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 or Invert. Adjustments are non-destructive and appear as child rows. - * **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). * **Boolean operations** — combine the layer with the one below: intersect, cut out, cut away or exclude. * **Convert to** — make it a control layer, inpaint mask or regional guidance layer. Text, shape and gradient layers can also be rasterized into plain pixels. @@ -95,7 +100,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, which takes the topmost mask's fill color and style, noise and denoise limit. 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. One undo brings the original masks back. ## Properties @@ -118,6 +123,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**. ### Control layer From 19445e3501092494fa9816e6a72acddf798969cf Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Wed, 21 Oct 2026 00:44:11 -0600 Subject: [PATCH 06/14] chore(performance): record the canvas widget's bytes with main's control 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. --- invokeai/frontend/webv2/performance/browser-baseline.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/invokeai/frontend/webv2/performance/browser-baseline.json b/invokeai/frontend/webv2/performance/browser-baseline.json index bbba5dfb1fe..7655c65bfb0 100644 --- a/invokeai/frontend/webv2/performance/browser-baseline.json +++ b/invokeai/frontend/webv2/performance/browser-baseline.json @@ -162,9 +162,9 @@ "largestAssetRawBytes": 305523, "otherRawBytes": 0, "requestCount": 9, - "scriptRawBytes": 527661, + "scriptRawBytes": 533097, "scriptRequestCount": 9, - "totalRawBytes": 527661 + "totalRawBytes": 533097 }, "domContentLoadedMedianMs": 100.40000009536743, "id": "editor-canvas", @@ -200,9 +200,9 @@ "largestAssetRawBytes": 305523, "otherRawBytes": 0, "requestCount": 9, - "scriptRawBytes": 527661, + "scriptRawBytes": 533097, "scriptRequestCount": 9, - "totalRawBytes": 527661 + "totalRawBytes": 533097 }, "domContentLoadedMedianMs": 97.7000002861023, "id": "editor-canvas", From 2ade83464d6519fcbb4748dda24dcdbdeca14f54 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Wed, 21 Oct 2026 17:20:00 -0600 Subject: [PATCH 07/14] chore(performance): record the canvas widget's bytes on current main 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. --- invokeai/frontend/webv2/performance/browser-baseline.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/invokeai/frontend/webv2/performance/browser-baseline.json b/invokeai/frontend/webv2/performance/browser-baseline.json index 44eeb151794..16794ae837b 100644 --- a/invokeai/frontend/webv2/performance/browser-baseline.json +++ b/invokeai/frontend/webv2/performance/browser-baseline.json @@ -162,9 +162,9 @@ "largestAssetRawBytes": 305523, "otherRawBytes": 0, "requestCount": 9, - "scriptRawBytes": 524052, + "scriptRawBytes": 533485, "scriptRequestCount": 9, - "totalRawBytes": 524052 + "totalRawBytes": 533485 }, "domContentLoadedMedianMs": 100.40000009536743, "id": "editor-canvas", @@ -200,9 +200,9 @@ "largestAssetRawBytes": 305523, "otherRawBytes": 0, "requestCount": 9, - "scriptRawBytes": 524052, + "scriptRawBytes": 533485, "scriptRequestCount": 9, - "totalRawBytes": 524052 + "totalRawBytes": 533485 }, "domContentLoadedMedianMs": 97.7000002861023, "id": "editor-canvas", From 6a122fa051cddf4f2a027becbd8c97b94ee8e088 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Wed, 21 Oct 2026 17:33:06 -0600 Subject: [PATCH 08/14] chore(performance): record the locale and canvas widget bytes on current 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. --- .../webv2/performance/browser-baseline.json | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/invokeai/frontend/webv2/performance/browser-baseline.json b/invokeai/frontend/webv2/performance/browser-baseline.json index dd3b077f931..d0d1a450f52 100644 --- a/invokeai/frontend/webv2/performance/browser-baseline.json +++ b/invokeai/frontend/webv2/performance/browser-baseline.json @@ -30,11 +30,11 @@ "fontRawBytes": 23664, "imageRawBytes": 0, "largestAssetRawBytes": 752413, - "otherRawBytes": 220849, + "otherRawBytes": 225516, "requestCount": 29, "scriptRawBytes": 1868300, "scriptRequestCount": 25, - "totalRawBytes": 2114972 + "totalRawBytes": 2119639 }, "routeReadyMedianMs": 215.30000019073486, "scriptSourceOwnerSet": "launchpad-static", @@ -68,11 +68,11 @@ "fontRawBytes": 23664, "imageRawBytes": 0, "largestAssetRawBytes": 752413, - "otherRawBytes": 220849, + "otherRawBytes": 225516, "requestCount": 29, "scriptRawBytes": 1868300, "scriptRequestCount": 25, - "totalRawBytes": 2114972 + "totalRawBytes": 2119639 }, "routeReadyMedianMs": 225.39999961853027, "scriptSourceOwnerSet": "launchpad-static", @@ -106,11 +106,11 @@ "fontRawBytes": 23664, "imageRawBytes": 44398, "largestAssetRawBytes": 752413, - "otherRawBytes": 220849, + "otherRawBytes": 225516, "requestCount": 87, "scriptRawBytes": 4199867, "scriptRequestCount": 82, - "totalRawBytes": 4490937 + "totalRawBytes": 4495604 }, "routeReadyMedianMs": 360.90000009536743, "scriptSourceOwnerSet": "editor-minimal-static", @@ -144,11 +144,11 @@ "fontRawBytes": 23664, "imageRawBytes": 44398, "largestAssetRawBytes": 752413, - "otherRawBytes": 220849, + "otherRawBytes": 225516, "requestCount": 87, "scriptRawBytes": 4199867, "scriptRequestCount": 82, - "totalRawBytes": 4490937 + "totalRawBytes": 4495604 }, "routeReadyMedianMs": 367.80000019073486, "scriptSourceOwnerSet": "editor-minimal-static", @@ -162,9 +162,9 @@ "largestAssetRawBytes": 305523, "otherRawBytes": 0, "requestCount": 9, - "scriptRawBytes": 524052, + "scriptRawBytes": 531250, "scriptRequestCount": 9, - "totalRawBytes": 524052 + "totalRawBytes": 531250 }, "domContentLoadedMedianMs": 100.40000009536743, "id": "editor-canvas", @@ -182,11 +182,11 @@ "fontRawBytes": 23664, "imageRawBytes": 44398, "largestAssetRawBytes": 752413, - "otherRawBytes": 220849, + "otherRawBytes": 225516, "requestCount": 96, "scriptRawBytes": 4718489, "scriptRequestCount": 91, - "totalRawBytes": 5009559 + "totalRawBytes": 5014226 }, "routeReadyMedianMs": 1095.3000001907349, "scriptSourceOwnerSet": "editor-canvas-static", @@ -200,9 +200,9 @@ "largestAssetRawBytes": 305523, "otherRawBytes": 0, "requestCount": 9, - "scriptRawBytes": 524052, + "scriptRawBytes": 531250, "scriptRequestCount": 9, - "totalRawBytes": 524052 + "totalRawBytes": 531250 }, "domContentLoadedMedianMs": 97.7000002861023, "id": "editor-canvas", @@ -220,11 +220,11 @@ "fontRawBytes": 23664, "imageRawBytes": 44398, "largestAssetRawBytes": 752413, - "otherRawBytes": 220849, + "otherRawBytes": 225516, "requestCount": 96, "scriptRawBytes": 4718489, "scriptRequestCount": 91, - "totalRawBytes": 5009559 + "totalRawBytes": 5014226 }, "routeReadyMedianMs": 1042.5, "scriptSourceOwnerSet": "editor-canvas-static", @@ -258,11 +258,11 @@ "fontRawBytes": 23664, "imageRawBytes": 44398, "largestAssetRawBytes": 752413, - "otherRawBytes": 220849, + "otherRawBytes": 225516, "requestCount": 94, "scriptRawBytes": 4644510, "scriptRequestCount": 88, - "totalRawBytes": 4950993 + "totalRawBytes": 4955660 }, "routeReadyMedianMs": 1093.0999999046326, "scriptSourceOwnerSet": "editor-workflow-static", @@ -296,11 +296,11 @@ "fontRawBytes": 23664, "imageRawBytes": 44398, "largestAssetRawBytes": 752413, - "otherRawBytes": 220849, + "otherRawBytes": 225516, "requestCount": 94, "scriptRawBytes": 4644510, "scriptRequestCount": 88, - "totalRawBytes": 4950993 + "totalRawBytes": 4955660 }, "routeReadyMedianMs": 1105.0999999046326, "scriptSourceOwnerSet": "editor-workflow-static", @@ -334,11 +334,11 @@ "fontRawBytes": 23664, "imageRawBytes": 44398, "largestAssetRawBytes": 752413, - "otherRawBytes": 220849, + "otherRawBytes": 225516, "requestCount": 87, "scriptRawBytes": 4199867, "scriptRequestCount": 82, - "totalRawBytes": 4490937 + "totalRawBytes": 4495604 }, "routeReadyMedianMs": 1324.5, "scriptSourceOwnerSet": "editor-minimal-static", @@ -372,11 +372,11 @@ "fontRawBytes": 23664, "imageRawBytes": 44398, "largestAssetRawBytes": 752413, - "otherRawBytes": 220849, + "otherRawBytes": 225516, "requestCount": 87, "scriptRawBytes": 4199867, "scriptRequestCount": 82, - "totalRawBytes": 4490937 + "totalRawBytes": 4495604 }, "routeReadyMedianMs": 1301.5, "scriptSourceOwnerSet": "editor-minimal-static", From 38b6ea8cb4e7a7fd251274a3fb31d2898922ce1d Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Wed, 21 Oct 2026 19:12:41 -0600 Subject: [PATCH 09/14] chore(performance): record the canvas widget's bytes with main's model changes With main's generation and model changes, this branch's canvas widget activates 531,800 bytes of script; request counts are unchanged. --- invokeai/frontend/webv2/performance/browser-baseline.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/invokeai/frontend/webv2/performance/browser-baseline.json b/invokeai/frontend/webv2/performance/browser-baseline.json index e50712c212c..1a35d009288 100644 --- a/invokeai/frontend/webv2/performance/browser-baseline.json +++ b/invokeai/frontend/webv2/performance/browser-baseline.json @@ -162,9 +162,9 @@ "largestAssetRawBytes": 305523, "otherRawBytes": 0, "requestCount": 9, - "scriptRawBytes": 524052, + "scriptRawBytes": 531800, "scriptRequestCount": 9, - "totalRawBytes": 524052 + "totalRawBytes": 531800 }, "domContentLoadedMedianMs": 100.40000009536743, "id": "editor-canvas", @@ -200,9 +200,9 @@ "largestAssetRawBytes": 305523, "otherRawBytes": 0, "requestCount": 9, - "scriptRawBytes": 524052, + "scriptRawBytes": 531800, "scriptRequestCount": 9, - "totalRawBytes": 524052 + "totalRawBytes": 531800 }, "domContentLoadedMedianMs": 97.7000002861023, "id": "editor-canvas", From e6470e8a4e92a7c4b769c6861c2ba303aeb17987 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Fri, 23 Oct 2026 02:19:15 -0600 Subject: [PATCH 10/14] fix(canvas): show the whole canvas in the overview pane 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. --- .../workbench/canvas-engine/ARCHITECTURE.md | 2 +- .../workbench/canvas-engine/capabilities.ts | 6 +- .../workbench/canvas-engine/engine.test.ts | 41 +++ .../src/workbench/canvas-engine/engine.ts | 38 +- .../widgets/canvas/engineStoreHooks.ts | 10 - .../panes/LayerEditorPanes.browser.test.tsx | 114 +++++- .../widgets/layers/panes/OverviewPane.tsx | 332 ++++++++---------- 7 files changed, 337 insertions(+), 206 deletions(-) diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/ARCHITECTURE.md b/invokeai/frontend/webv2/src/workbench/canvas-engine/ARCHITECTURE.md index 38e1cafbfa9..47f9bdc102f 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/ARCHITECTURE.md +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/ARCHITECTURE.md @@ -100,7 +100,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. 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. +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. diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/capabilities.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/capabilities.ts index a660c8cba0e..b5c730cd3f9 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/capabilities.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/capabilities.ts @@ -673,8 +673,10 @@ export interface CanvasEngineExportCapability extends CanvasExportCapability { 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; 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 8a996a237dd..9121ad53f14 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.test.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.test.ts @@ -15416,6 +15416,47 @@ 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', () => { diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.ts index 53b4a3e49e5..54f672afc6f 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.ts @@ -2089,19 +2089,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; }; @@ -2930,7 +2937,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 @@ -2953,17 +2964,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, @@ -2975,7 +2987,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/widgets/canvas/engineStoreHooks.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/engineStoreHooks.ts index a8709facf8c..5c4184625e0 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/engineStoreHooks.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/engineStoreHooks.ts @@ -164,16 +164,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'); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/layers/panes/LayerEditorPanes.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/widgets/layers/panes/LayerEditorPanes.browser.test.tsx index 09e6c04c2b7..7c0d9456bc7 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/layers/panes/LayerEditorPanes.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/layers/panes/LayerEditorPanes.browser.test.tsx @@ -123,7 +123,11 @@ const createEngineDeps = (): EngineDeps => { commitEdit: () => undefined, dispatch: () => false, getCanvasState: () => harness.project?.canvas ?? createEmptyCanvasState(64, 64), - subscribe: () => () => undefined, + // Harness project changes reach the engine's document mirror, as reducer dispatches do in the app. + subscribe: (listener) => { + harness.listeners.add(listener); + return () => harness.listeners.delete(listener); + }, }; return { getMainModelBase: () => null, @@ -232,7 +236,11 @@ const GRADIENT_SOURCE = { width: 10, } satisfies Extract; -const mount = async (View: typeof PropertiesPane, selection: Selection = 'none') => { +const mount = async ( + View: typeof PropertiesPane, + selection: Selection = 'none', + extraNodes: Parameters[0] = [] +) => { const base = { ...createInitialWorkbenchState().projects[0]!, id: 'p' }; const nodes = selection === 'none' @@ -257,7 +265,7 @@ const mount = async (View: typeof PropertiesPane, selection: Selection = 'none') document: { ...createEmptyCanvasDocument(), selectedLayerId: selection === 'none' ? null : selection === 'mask' ? 'm0' : selection === 'group' ? 'g0' : 'l0', - stacks: stacksFrom(nodes), + stacks: stacksFrom([...nodes, ...extraNodes]), }, type: 'replaceCanvasDocument', }); @@ -1055,4 +1063,104 @@ describe('Overview pane', () => { const after = viewport.getState().pan; expect(after.x === before.x && after.y === before.y).toBe(false); }); + + it('shows content beyond the frame and centres the view on the clicked point there', async () => { + // Outpainted pixels left of the default 1024² frame at the origin. + const outpaint = layerContract('outpaint', 'raster', { + source: { bitmap: { height: 512, imageName: 'outpaint', width: 1024 }, type: 'paint' }, + transform: { ...IDENTITY, x: -1024 }, + }); + await mount(OverviewHarness, 'none', [outpaint]); + const pan = page.getByRole('button', { exact: true, name: 'Pan the canvas view' }); + await expect.element(pan).toBeVisible(); + const canvas = pan.element().querySelector('canvas')!; + // Frame (0,0) 1024×1024 ∪ content (-1024,0) 1024×512 = (-1024,0) 2048×1024. + expect(canvas.width).toBeGreaterThan(0); + expect(canvas.width / canvas.height).toBeCloseTo(2, 1); + const unitsPerPx = 2048 / canvas.width; + const viewport = engine!.viewport.getViewport(); + engine!.surface.resize(400, 300, 1); + + const offset = { x: 3, y: 3 }; + await act(async () => { + await userEvent.click(canvas, { position: offset }); + }); + await settle(); + + const centre = viewport.screenToDocument({ x: 200, y: 150 }); + const expected = { x: -1024 + offset.x * unitsPerPx, y: offset.y * unitsPerPx }; + expect(expected.x).toBeLessThan(0); + expect(Math.abs(centre.x - expected.x)).toBeLessThan(unitsPerPx); + expect(Math.abs(centre.y - expected.y)).toBeLessThan(unitsPerPx); + // The outline is centred where the click landed on the bitmap. + const outline = pan.element().querySelector('div')!; + await expect + .poll(() => parseFloat(outline.style.left) + parseFloat(outline.style.width) / 2) + .toBeCloseTo(offset.x, 0); + await expect + .poll(() => parseFloat(outline.style.top) + parseFloat(outline.style.height) / 2) + .toBeCloseTo(offset.y, 0); + }); + + it('zooms the view around the document point under the wheel', async () => { + const outpaint = layerContract('outpaint', 'raster', { + source: { bitmap: { height: 512, imageName: 'outpaint', width: 1024 }, type: 'paint' }, + transform: { ...IDENTITY, x: -1024 }, + }); + await mount(OverviewHarness, 'none', [outpaint]); + const pan = page.getByRole('button', { exact: true, name: 'Pan the canvas view' }); + await expect.element(pan).toBeVisible(); + const canvas = pan.element().querySelector('canvas')!; + const unitsPerPx = 2048 / canvas.width; + const viewport = engine!.viewport.getViewport(); + engine!.surface.resize(400, 300, 1); + const offset = { x: 10, y: 20 }; + // Frame (0,0) 1024×1024 ∪ content (-1024,0) 1024×512 puts document (-1024,0) at the overview's top-left. + const documentPoint = { x: -1024 + offset.x * unitsPerPx, y: offset.y * unitsPerPx }; + const anchor = viewport.documentToScreen(documentPoint); + const zoomBefore = viewport.getZoom(); + + const bounds = canvas.getBoundingClientRect(); + act(() => { + pan.element().dispatchEvent( + new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + clientX: bounds.left + offset.x, + clientY: bounds.top + offset.y, + deltaY: -100, + }) + ); + }); + + expect(viewport.getZoom()).toBeGreaterThan(zoomBefore); + const anchored = viewport.documentToScreen(documentPoint); + expect(Math.abs(anchored.x - anchor.x)).toBeLessThan(0.5); + expect(Math.abs(anchored.y - anchor.y)).toBeLessThan(0.5); + }); + + it('redraws to the new extent when a document edit moves content after mount', async () => { + await mount(OverviewHarness); + const pan = page.getByRole('button', { exact: true, name: 'Pan the canvas view' }); + await expect.element(pan).toBeVisible(); + const canvas = pan.element().querySelector('canvas')!; + // The empty document shows only its 1024² frame. + expect(canvas.width / canvas.height).toBeCloseTo(1, 1); + + // Content 1024×512 placed above the frame: (0,-512)..(1024,1024) = 1024×1536. + const outpaint = layerContract('outpaint', 'raster', { + source: { bitmap: { height: 512, imageName: 'outpaint', width: 1024 }, type: 'paint' }, + transform: { ...IDENTITY, y: -512 }, + }); + act(() => { + const project = harness.project!; + harness.project = applyCanvasProjectMutation(project, { + document: { ...project.canvas.document, stacks: stacksFrom([outpaint]) }, + type: 'replaceCanvasDocument', + }); + harness.listeners.forEach((listener) => listener()); + }); + + await expect.poll(() => canvas.width / canvas.height, { timeout: 2000 }).toBeCloseTo(1024 / 1536, 1); + }); }); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/layers/panes/OverviewPane.tsx b/invokeai/frontend/webv2/src/workbench/widgets/layers/panes/OverviewPane.tsx index b7bffd7b708..5007e5ca47e 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/layers/panes/OverviewPane.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/layers/panes/OverviewPane.tsx @@ -2,9 +2,7 @@ import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerE import { Box, chakra, Flex } from '@chakra-ui/react'; import { useCanvasEngine, type CanvasEngineHandle } from '@workbench/canvas-operations/react'; -import { useCanvasLayerPixelEpoch } from '@workbench/widgets/canvas/engineStoreHooks'; -import { useActiveProjectSelector } from '@workbench/WorkbenchContext'; -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; const KEY_PAN_STEP_PX = 40; @@ -40,175 +38,181 @@ export const OverviewPane = () => { return ; }; -interface OverviewFrame { - /** Preview pixels per document unit. */ +/** How the drawn bitmap maps to document space: target pixel = (document point − origin) × scale. */ +interface OverviewMapping { + /** Document-space point drawn at the target's top-left pixel. */ + x: number; + y: number; + /** Target pixels per document unit. */ scale: number; - widthPx: number; - heightPx: number; } +const clientToDocument = ( + mapping: OverviewMapping, + canvasBounds: DOMRect, + clientX: number, + clientY: number +): { x: number; y: number } => ({ + x: mapping.x + (clientX - canvasBounds.left) / mapping.scale, + y: mapping.y + (clientY - canvasBounds.top) / mapping.scale, +}); + const ConnectedOverview = ({ engine }: { engine: CanvasEngineHandle }) => { const { t } = useTranslation(); - const hostRef = useRef(null); const canvasRef = useRef(null); const buttonRef = useRef(null); const viewRectRef = useRef(null); const drag = useRef(null); - const [hostSize, setHostSize] = useState<{ width: number; height: number }>({ height: 0, width: 0 }); - - const pixelEpoch = useCanvasLayerPixelEpoch(engine); - const stacks = useActiveProjectSelector((project) => project.canvas.document.stacks); - const docWidth = useActiveProjectSelector((project) => project.canvas.document.width); - const docHeight = useActiveProjectSelector((project) => project.canvas.document.height); + // Written after each draw from the rect the engine reports, so pointer mapping and the outline match the bitmap. + const mapping = useRef(null); - useEffect(() => { - const host = hostRef.current; - if (!host) { - return; - } - const observer = new ResizeObserver((observations) => { - const content = observations[0]?.contentRect; - if (content) { - setHostSize({ height: content.height, width: content.width }); + // Engine-keyed host binding: the host's size, the engine's document and pixel signals, and the viewport drive the + // bitmap and outline imperatively, without re-rendering the pane. + const bindHost = useCallback( + (host: HTMLDivElement | null) => { + const canvas = canvasRef.current; + const button = buttonRef.current; + const outline = viewRectRef.current; + if (!host || !canvas || !button || !outline) { + return; } - }); - observer.observe(host); - return () => observer.disconnect(); - }, []); + const viewport = engine.viewport.getViewport(); + let maxSizePx = 0; - useEffect(() => () => drag.current?.abort(), []); + const applyOutline = () => { + const current = mapping.current; + const size = viewport.getViewportSize(); + if (!current || size.width <= 0 || size.height <= 0) { + outline.style.visibility = 'hidden'; + return; + } + const widthPx = canvas.width; + const heightPx = canvas.height; + const topLeft = viewport.screenToDocument({ x: 0, y: 0 }); + const bottomRight = viewport.screenToDocument({ x: size.width, y: size.height }); + const left = (topLeft.x - current.x) * current.scale; + const top = (topLeft.y - current.y) * current.scale; + const width = (bottomRight.x - topLeft.x) * current.scale; + const height = (bottomRight.y - topLeft.y) * current.scale; + outline.style.visibility = 'visible'; + outline.style.left = `${Math.max(-widthPx, Math.min(left, widthPx))}px`; + outline.style.top = `${Math.max(-heightPx, Math.min(top, heightPx))}px`; + outline.style.width = `${Math.max(4, Math.min(width, widthPx * 2))}px`; + outline.style.height = `${Math.max(4, Math.min(height, heightPx * 2))}px`; + }; + // Throttle DOM outline updates and let CSS interpolate between them. + const outlineThrottle = { last: 0, timer: null as ReturnType | null }; + const updateOutline = () => { + if (outlineThrottle.timer !== null) { + return; + } + const elapsed = Date.now() - outlineThrottle.last; + if (elapsed >= VIEW_RECT_INTERVAL_MS) { + outlineThrottle.last = Date.now(); + applyOutline(); + return; + } + outlineThrottle.timer = setTimeout(() => { + outlineThrottle.timer = null; + outlineThrottle.last = Date.now(); + applyOutline(); + }, VIEW_RECT_INTERVAL_MS - elapsed); + }; - // Mirrors the engine's `fitThumbnailSize` math, so the drawn bitmap and the - // outline overlay agree without a state round trip. - const frame = useMemo(() => { - const maxSize = Math.floor(Math.min(hostSize.width, hostSize.height)); - if (maxSize < 16 || docWidth <= 0 || docHeight <= 0) { - return null; - } - const scale = Math.min(1, maxSize / docWidth, maxSize / docHeight); - return { - heightPx: Math.max(1, Math.round(docHeight * scale)), - scale, - widthPx: Math.max(1, Math.round(docWidth * scale)), - }; - }, [docHeight, docWidth, hostSize]); - const frameRef = useRef(frame); - useLayoutEffect(() => { - frameRef.current = frame; - }, [frame]); + // Only a real composite consumes throttle budget, so the pre-measure clearing draw never defers the first + // paint. The outline follows every draw at once, so it never disagrees with the bitmap. + const drawComposite = (): boolean => { + const rect = maxSizePx < 16 ? null : engine.previews.drawDocumentOverview(canvas, maxSizePx); + if (!rect) { + canvas.width = 0; + canvas.height = 0; + } + mapping.current = rect && canvas.width > 0 ? { scale: canvas.width / rect.width, x: rect.x, y: rect.y } : null; + applyOutline(); + return rect !== null; + }; + const redraw = { last: 0, timer: null as ReturnType | null }; + const scheduleRedraw = () => { + if (redraw.timer !== null) { + return; + } + const elapsed = Date.now() - redraw.last; + if (elapsed >= REDRAW_INTERVAL_MS) { + if (drawComposite()) { + redraw.last = Date.now(); + } + return; + } + redraw.timer = setTimeout(() => { + redraw.timer = null; + if (drawComposite()) { + redraw.last = Date.now(); + } + }, REDRAW_INTERVAL_MS - elapsed); + }; - const redraw = useRef({ last: 0, timer: null as ReturnType | null }); - // Only a real composite consumes throttle budget, so the pre-measure clearing - // draw never defers the first paint. - const drawComposite = useCallback((): boolean => { - const canvas = canvasRef.current; - const current = frameRef.current; - if (!canvas) { - return false; - } - if (!current) { - canvas.width = 0; - canvas.height = 0; - return false; - } - engine.previews.drawDocumentOverview(canvas, Math.max(current.widthPx, current.heightPx)); - return true; - }, [engine]); - const scheduleRedraw = useCallback(() => { - const state = redraw.current; - if (state.timer !== null) { - return; - } - const elapsed = Date.now() - state.last; - if (elapsed >= REDRAW_INTERVAL_MS) { - if (drawComposite()) { - state.last = Date.now(); - } - return; - } - state.timer = setTimeout(() => { - state.timer = null; - if (drawComposite()) { - state.last = Date.now(); - } - }, REDRAW_INTERVAL_MS - elapsed); - }, [drawComposite]); - useEffect(() => { - const state = redraw.current; - return () => { - if (state.timer !== null) { - clearTimeout(state.timer); - } - }; - }, []); - useEffect(() => { - scheduleRedraw(); - }, [pixelEpoch, stacks, frame, scheduleRedraw]); + const observer = new ResizeObserver((observations) => { + const content = observations[0]?.contentRect; + if (content) { + maxSizePx = Math.floor(Math.min(content.width, content.height)); + scheduleRedraw(); + } + }); + observer.observe(host); - // Throttle DOM outline updates and let CSS interpolate between them. - useEffect(() => { - const viewport = engine.viewport.getViewport(); - let timer: ReturnType | null = null; - let last = 0; - const apply = () => { - const element = viewRectRef.current; - const current = frameRef.current; - if (!element) { - return; - } - const size = viewport.getViewportSize(); - if (!current || size.width <= 0 || size.height <= 0) { - element.style.visibility = 'hidden'; - return; - } - const topLeft = viewport.screenToDocument({ x: 0, y: 0 }); - const bottomRight = viewport.screenToDocument({ x: size.width, y: size.height }); - const width = (bottomRight.x - topLeft.x) * current.scale; - const height = (bottomRight.y - topLeft.y) * current.scale; - element.style.visibility = 'visible'; - element.style.left = `${Math.max(-current.widthPx, Math.min(topLeft.x * current.scale, current.widthPx))}px`; - element.style.top = `${Math.max(-current.heightPx, Math.min(topLeft.y * current.scale, current.heightPx))}px`; - element.style.width = `${Math.max(4, Math.min(width, current.widthPx * 2))}px`; - element.style.height = `${Math.max(4, Math.min(height, current.heightPx * 2))}px`; - }; - const update = () => { - if (timer !== null) { - return; - } - const elapsed = Date.now() - last; - if (elapsed >= VIEW_RECT_INTERVAL_MS) { - last = Date.now(); - apply(); - return; - } - timer = setTimeout(() => { - timer = null; - last = Date.now(); - apply(); - }, VIEW_RECT_INTERVAL_MS - elapsed); - }; - apply(); - const unsubscribe = viewport.subscribe(update); - return () => { - unsubscribe(); - if (timer !== null) { - clearTimeout(timer); - } - }; - }, [engine, frame]); + // Layer and frame edits change the drawn extent; selection-only document changes do not repaint. + let drawnDocument = engine.document.getDocument(); + const onDocumentChange = () => { + const doc = engine.document.getDocument(); + if (doc?.stacks !== drawnDocument?.stacks || doc?.bbox !== drawnDocument?.bbox) { + drawnDocument = doc; + scheduleRedraw(); + } + }; + const unsubscribeDocument = engine.interaction.subscribe('documentEpoch', onDocumentChange); + const unsubscribePixels = engine.interaction.subscribe('layerPixelEpoch', scheduleRedraw); + const unsubscribeViewport = viewport.subscribe(updateOutline); + + // React registers wheel listeners passively; zooming must preventDefault, so the listener is attached natively. + const handleWheel = (event: WheelEvent) => { + event.preventDefault(); + const current = mapping.current; + if (!current) { + return; + } + const documentPoint = clientToDocument(current, canvas.getBoundingClientRect(), event.clientX, event.clientY); + viewport.wheelZoom(event.deltaY, viewport.documentToScreen(documentPoint)); + }; + button.addEventListener('wheel', handleWheel, { passive: false }); + + drawComposite(); + return () => { + observer.disconnect(); + unsubscribeDocument(); + unsubscribePixels(); + unsubscribeViewport(); + button.removeEventListener('wheel', handleWheel); + for (const timer of [redraw.timer, outlineThrottle.timer]) { + if (timer !== null) { + clearTimeout(timer); + } + } + mapping.current = null; + // A drag maps through this engine's viewport, so it ends with the binding. + drag.current?.abort(); + }; + }, + [engine] + ); const centerOn = useCallback( (clientX: number, clientY: number) => { const canvas = canvasRef.current; - const current = frameRef.current; + const current = mapping.current; if (!canvas || !current) { return; } - const bounds = canvas.getBoundingClientRect(); - const documentPoint = { - x: (clientX - bounds.left) / current.scale, - y: (clientY - bounds.top) / current.scale, - }; + const documentPoint = clientToDocument(current, canvas.getBoundingClientRect(), clientX, clientY); const viewport = engine.viewport.getViewport(); const size = viewport.getViewportSize(); const screenPoint = viewport.documentToScreen(documentPoint); @@ -217,32 +221,6 @@ const ConnectedOverview = ({ engine }: { engine: CanvasEngineHandle }) => { [engine] ); - // React registers wheel listeners passively; zooming must preventDefault, so - // the listener is attached natively. - useEffect(() => { - const button = buttonRef.current; - if (!button) { - return; - } - const handleWheel = (event: WheelEvent) => { - event.preventDefault(); - const canvas = canvasRef.current; - const current = frameRef.current; - if (!canvas || !current) { - return; - } - const bounds = canvas.getBoundingClientRect(); - const documentPoint = { - x: (event.clientX - bounds.left) / current.scale, - y: (event.clientY - bounds.top) / current.scale, - }; - const viewport = engine.viewport.getViewport(); - viewport.wheelZoom(event.deltaY, viewport.documentToScreen(documentPoint)); - }; - button.addEventListener('wheel', handleWheel, { passive: false }); - return () => button.removeEventListener('wheel', handleWheel); - }, [engine]); - const onPointerDown = useCallback( (event: ReactPointerEvent) => { if (event.button !== 0) { @@ -291,7 +269,7 @@ const ConnectedOverview = ({ engine }: { engine: CanvasEngineHandle }) => { const fit = useCallback(() => engine.viewport.fitToView(), [engine]); return ( - + Date: Sat, 24 Oct 2026 02:20:17 -0600 Subject: [PATCH 11/14] fix(canvas): decide crop-all candidates before baking adjustments 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. --- .../Users Guide/07.Canvas/03.layers-panel.mdx | 2 +- .../controllers/cropLayerController.test.ts | 74 +++++++++++++++---- .../controllers/cropLayerController.ts | 59 +++++++++++---- .../src/workbench/canvas-engine/engine.ts | 3 +- .../canvas-engine/tools/paintTool.ts | 4 +- .../layers/layerContextActions.test.ts | 2 +- .../widgets/layers/layerContextActions.ts | 2 +- 7 files changed, 115 insertions(+), 31 deletions(-) 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 b7cd08c5815..486343a9a09 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 @@ -158,7 +158,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, copying or converting to 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/src/workbench/canvas-engine/controllers/cropLayerController.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/cropLayerController.test.ts index f9c6684f9b6..ffb07eaaac1 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,4 +1,8 @@ -import type { CanvasDocumentContractV3, CanvasLayerContract } from '@workbench/canvas-engine/contracts'; +import type { + CanvasAdjustmentsContract, + CanvasDocumentContractV3, + CanvasLayerContract, +} from '@workbench/canvas-engine/contracts'; import type { AdjustmentRefusal } from '@workbench/canvas-engine/render/exactAdjustments'; import type { Rect } from '@workbench/canvas-engine/types'; @@ -134,21 +138,32 @@ const lockedPaint = (id: string, locked = true) => }, } 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; } = {} ) => { + 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([ - layerContract('inside'), - layerContract('raster'), + raster('inside'), + raster('raster'), lockedPaint('locked'), lockedPaint('lockedInside'), groupContract('lockedGroup', [lockedPaint('lockedByGroup', false)], { isLocked: true }), @@ -163,6 +178,7 @@ const createCropAllHarness = ( 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. @@ -182,10 +198,11 @@ const createCropAllHarness = ( }, ctx: h.ctx, discardPersisted, - exportBaked: (layerId) => { - exported.push(layerId); + exportBaked: (layerId, exportOptions) => { + const read = exportOptions?.applyAdjustments === false ? `${layerId}:raw` : layerId; + exported.push(read); options.onExport?.(layerId); - if (options.refusal?.ownerId === layerId) { + if (options.refusal?.ownerId === layerId && read === layerId) { return Promise.resolve(options.refusal); } const entry = h.layers.get(layerId)!; @@ -196,7 +213,7 @@ const createCropAllHarness = ( rect: { ...entry.rect }, release: () => { reservation?.release(); - released.push(layerId); + released.push(read); }, status: 'ok' as const, surface: entry.surface, @@ -210,19 +227,20 @@ const createCropAllHarness = ( describe('CropLayerController: crop all layers', () => { it.each(['too-large', 'over-budget', 'error'] as const)( - 'refuses the whole crop on a %s adjustment, releasing earlier copies', + 'refuses the whole crop on a %s adjustment of a leaf it must crop, releasing earlier copies', async (reason) => { const refusal: AdjustmentRefusal = { - ownerId: 'control', - ownerName: 'Control', + ownerId: 'raster', + ownerName: 'Raster', reason, status: 'adjustment-refused', }; - const h = createCropAllHarness({ refusal }); + const h = createCropAllHarness({ adjusted: ['raster'], refusal }); await expect(h.controller.cropAll()).resolves.toEqual(refusal); - expect(h.exported).toEqual(['mask', 'region', 'control']); - expect(h.released).toEqual(['mask', 'region']); + // 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); @@ -230,6 +248,36 @@ describe('CropLayerController: crop all layers', () => { } ); + 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, 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, 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(); 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 1a1ddc5cce3..fdb0ed21508 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/cropLayerController.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/cropLayerController.ts @@ -1,4 +1,4 @@ -import type { LayerExportGuard } from '@workbench/canvas-engine/capabilities'; +import type { ExportBakedLayerPixelsOptions, LayerExportGuard } from '@workbench/canvas-engine/capabilities'; import type { CanvasDocumentContractV3, CanvasLayerContract, @@ -13,6 +13,7 @@ import { getDocumentLayer } from '@workbench/canvas-engine/document/documentInde 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, transformBounds } from '@workbench/canvas-engine/math/rect'; +import { isIdentityAdjustments } from '@workbench/canvas-engine/render/adjustments'; import type { CanvasMutationContext, EditStep } from './mutationContext'; @@ -63,7 +64,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, @@ -265,19 +269,12 @@ export class CropLayerController { const cropped: CroppedLeaf[] = []; try { for (const layer of candidates) { - const exported = await this.deps.exportBaked(layer.id); - if (exported.status === 'adjustment-refused') { - return exported; - } - if (exported.status === 'missing') { - return { status: 'stale' }; - } - if (exported.status === 'not-ready' || exported.status === 'over-budget') { - return { status: exported.status }; + const exported = await this.exportForCrop(layer, frame); + if (exported.status === 'skip') { + continue; } if (exported.status !== 'ok') { - // Empty, disabled-only or unsupported content has nothing to crop. - continue; + return exported; } try { guards.push(exported.guard); @@ -327,6 +324,42 @@ export class CropLayerController { } } + /** + * A leaf's baked pixels for crop all, or `skip` when it has nothing to crop. 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 | { status: 'skip' } | CropAllLayersResult> { + const settle = ( + exported: ExportResult + ): Extract | { status: 'skip' } | CropAllLayersResult => { + if (exported.status === 'ok' || exported.status === 'adjustment-refused') { + 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[], diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.ts index 8f3db167c47..50816959555 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.ts @@ -2861,7 +2861,8 @@ 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 }), isGuardCurrent: isLayerExportGuardCurrent, isSupportedSource: isSupportedExportSource, }, 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 3dda85ba2ff..a07e086ec07 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/paintTool.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/paintTool.ts @@ -126,7 +126,9 @@ const resolveTarget = (ctx: ToolContext, tool: PaintToolSpec['id']): PaintTarget 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 unready image keeps the brush's auto-created paint layer below. + // 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 diff --git a/invokeai/frontend/webv2/src/workbench/widgets/layers/layerContextActions.test.ts b/invokeai/frontend/webv2/src/workbench/widgets/layers/layerContextActions.test.ts index 4682da47518..9fcdb780c05 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/layers/layerContextActions.test.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/layers/layerContextActions.test.ts @@ -111,7 +111,7 @@ const makeEffects = (): LayerContextActionEffects => ({ fitToBbox: vi.fn(), group: vi.fn(), mergeDown: vi.fn(() => Promise.resolve()), - mergeMasks: vi.fn(), + mergeMasks: vi.fn(() => Promise.resolve()), mergeSelected: vi.fn(), openProperties: vi.fn(), openRename: vi.fn(), diff --git a/invokeai/frontend/webv2/src/workbench/widgets/layers/layerContextActions.ts b/invokeai/frontend/webv2/src/workbench/widgets/layers/layerContextActions.ts index 2105dafaa99..c8f0321c09b 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/layers/layerContextActions.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/layers/layerContextActions.ts @@ -158,7 +158,7 @@ export interface LayerContextActionEffects { reorder(kind: LayerStackMoveKind, actionId: LayerContextActionId): void; duplicate(): void; mergeSelected(): void; - mergeMasks(): void; + mergeMasks(): Promise; group(): void; removeFromGroup(): void; openRename(): void; From 993c4e8d5df9bbce6349fc2dcf3d28fdd32167bc Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sun, 25 Oct 2026 01:57:12 -0600 Subject: [PATCH 12/14] fix(canvas): report unreadable layers and merge masks at full coverage 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. --- .../Users Guide/07.Canvas/02.canvas-guide.mdx | 12 +- .../Users Guide/07.Canvas/03.layers-panel.mdx | 7 +- .../webv2/performance/browser-baseline.json | 7 + .../frontend/webv2/public/locales/en.json | 22 +- .../workbench/canvas-engine/ARCHITECTURE.md | 13 +- .../canvas-engine/applicationHost.ts | 2 +- .../workbench/canvas-engine/capabilities.ts | 16 +- .../controllers/booleanMergeController.ts | 18 +- .../controllers/colorSampleController.test.ts | 163 ++++++ .../controllers/colorSampleController.ts | 109 ++++ .../controllers/copyLayerController.ts | 11 +- .../controllers/cropLayerController.test.ts | 115 ++++- .../controllers/cropLayerController.ts | 147 ++++-- .../canvas-engine/controllers/editSteps.ts | 10 + .../controllers/editingController.test.ts | 8 + .../extractMaskedAreaController.ts | 29 +- .../controllers/layerController.test.ts | 7 +- .../controllers/layerMutationController.ts | 5 +- .../mergeLayerController.browser.test.ts | 17 +- .../controllers/mergeLayerController.test.ts | 35 +- .../controllers/mergeLayerController.ts | 61 ++- .../paintLineEndController.test.ts | 65 +++ .../controllers/paintLineEndController.ts | 110 +++++ .../controllers/rasterExportController.ts | 11 +- .../transformEditingController.test.ts | 223 ++++++++- .../controllers/transformEditingController.ts | 136 ++++- .../document/mergeVisible.test.ts | 33 +- .../canvas-engine/document/mergeVisible.ts | 40 +- .../workbench/canvas-engine/engine.test.ts | 464 ++++++++++++++---- .../src/workbench/canvas-engine/engine.ts | 272 ++++------ .../canvas-engine/exportRasterComposite.ts | 6 +- .../canvas-engine/layerRasterizer.test.ts | 63 ++- .../canvas-engine/layerRasterizer.ts | 109 +++- .../canvas-engine/rasterSnapshotCapture.ts | 6 +- .../canvas-engine/rasterTransactions.ts | 3 +- .../canvas-engine/render/rasterComposite.ts | 8 + .../canvas-engine/tools/paintTool.test.ts | 21 + .../canvas-engine/tools/paintTool.ts | 37 +- .../src/workbench/canvas-engine/tools/tool.ts | 7 +- .../canvas-operations/createFromBbox.ts | 2 +- .../createFromBboxNotice.test.ts | 1 + .../canvas-operations/createFromBboxNotice.ts | 5 +- .../exportCanvasComposite.ts | 2 +- .../filterOperationSession.ts | 8 +- .../canvas-operations/generationComposite.ts | 2 +- .../canvas-operations/layerImageResult.ts | 4 +- .../canvas-operations/operationTypes.ts | 4 + .../canvas-operations/saveCanvasToGallery.ts | 2 +- .../canvas-operations/selectObjectSession.ts | 3 + ...nvasCropAllLayersMenuItem.browser.test.tsx | 118 +++++ .../canvas/CanvasCropAllLayersMenuItem.tsx | 19 +- .../widgets/canvas/CanvasWidgetView.tsx | 14 +- .../widgets/canvas/canvasContextMenu.test.ts | 57 ++- .../widgets/canvas/canvasContextMenu.ts | 30 +- .../canvas/canvasHotkeyCommands.test.ts | 10 +- .../widgets/canvas/canvasHotkeyCommands.ts | 174 +------ .../widgets/canvas/canvasToolCommands.ts | 170 +++++++ .../canvas/invoke/prepareCanvasInvocation.ts | 4 +- .../tool-options/FilterOptions.test.tsx | 1 + .../canvas/tool-options/FilterOptions.tsx | 1 + .../canvas/tool-options/SamOptions.tsx | 1 + .../useCanvasGallerySave.browser.test.tsx | 19 +- .../widgets/canvas/useCanvasGallerySave.ts | 2 + .../widgets/canvas/useStructuralCommit.ts | 2 + .../layers/LayerContextMenu.browser.test.tsx | 242 +++++++++ .../widgets/layers/LayerContextMenu.tsx | 37 +- .../workbench/widgets/layers/LayersTree.tsx | 8 +- .../widgets/layers/RunLayerWorkflowDialog.tsx | 1 + .../layers/layerContextActions.test.ts | 61 ++- .../widgets/layers/layerContextActions.ts | 81 ++- .../layers/layerContextMenuLayout.test.ts | 2 +- .../widgets/layers/psdExportNotice.ts | 3 + .../widgets/layers/runLayerWorkflow.ts | 1 + .../layers/useCanvasCompanionHotkeys.ts | 4 +- .../widgets/layers/useLayerStackActions.ts | 6 + invokeai/frontend/webv2/vite.config.mts | 2 +- 76 files changed, 2809 insertions(+), 722 deletions(-) create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/colorSampleController.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/colorSampleController.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/paintLineEndController.test.ts create mode 100644 invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/paintLineEndController.ts create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasCropAllLayersMenuItem.browser.test.tsx create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasToolCommands.ts create mode 100644 invokeai/frontend/webv2/src/workbench/widgets/layers/LayerContextMenu.browser.test.tsx 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 7d1c470f146..055c6fc7a0c 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 @@ -36,7 +36,7 @@ The tool strip on the left edge of the canvas holds the drawing and editing tool 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; +* 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. @@ -67,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%). **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, use the zoom menu instead. +* **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. @@ -76,8 +76,8 @@ 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, 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. +* **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 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. @@ -115,7 +115,7 @@ Choose **Transform** from a layer's menu, or press **Shift+T**, to scale and rot 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 frame** from its menu. **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). +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 @@ -168,7 +168,7 @@ Right-click the canvas to open the menu of the selected layer, the same menu as * **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. A notice says how many layers were cropped, counting only the ones that changed, and how many locked layers were skipped. +* **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. 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 or unreadable layers were left as they were. 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 the Canvas section. 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 486343a9a09..8710b8171cd 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,7 +30,7 @@ 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 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. +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. @@ -85,7 +85,8 @@ The icons at the top **Arrange** the layer (move it to the front, forward, backw * **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 frame** 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. + * **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**. @@ -100,7 +101,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 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, which takes the topmost mask's fill color and style, noise and denoise limit. 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. One undo brings the original masks back. +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 diff --git a/invokeai/frontend/webv2/performance/browser-baseline.json b/invokeai/frontend/webv2/performance/browser-baseline.json index 80cc316b198..5a75048e866 100644 --- a/invokeai/frontend/webv2/performance/browser-baseline.json +++ b/invokeai/frontend/webv2/performance/browser-baseline.json @@ -1380,6 +1380,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/controlPixelController.ts", "source:src/workbench/canvas-engine/controllers/copyLayerController.ts", "source:src/workbench/canvas-engine/controllers/cropLayerController.ts", @@ -1398,6 +1399,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", @@ -2493,6 +2495,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/controlPixelController.ts", "source:src/workbench/canvas-engine/controllers/copyLayerController.ts", "source:src/workbench/canvas-engine/controllers/cropLayerController.ts", @@ -2511,6 +2514,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", @@ -2937,6 +2941,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", @@ -3832,6 +3837,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/controlPixelController.ts", "source:src/workbench/canvas-engine/controllers/copyLayerController.ts", "source:src/workbench/canvas-engine/controllers/cropLayerController.ts", @@ -3850,6 +3856,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 27a7f8dbe90..8de12bc2e31 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -3425,13 +3425,18 @@ "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_one": "{{count}} layer could not be read and was left as it is.", + "cropAllUnreadable_other": "{{count}} 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", + "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", @@ -3446,7 +3451,8 @@ "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": "A layer's image could not be read (it may have been deleted or damaged). Disable or delete that layer, then try again." }, "controls": { "clearCanvas": "Clear canvas", @@ -3890,6 +3896,7 @@ "cropBusy": "Finish the current canvas action before cropping this layer.", "cropFailed": "The layer could not be cropped.", "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 frame.", "cutaway": "Cutaway with layer below", @@ -3949,6 +3956,7 @@ "toggleVisibility": "Enable/disable", "transform": "Transform", "unsupported": "This action is not supported for this layer.", + "unreadable": "A layer's image could not be read (it may have been deleted or damaged), so nothing was changed.", "unlock": "Unlock layer", "unlockSelected": "Unlock selected", "group": "Group layers", @@ -3975,9 +3983,13 @@ "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." }, "control": { @@ -4159,6 +4171,7 @@ "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.", @@ -4183,6 +4196,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}}", @@ -4243,6 +4257,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.", @@ -4347,6 +4362,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": "A layer's image 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/workbench/canvas-engine/ARCHITECTURE.md b/invokeai/frontend/webv2/src/workbench/canvas-engine/ARCHITECTURE.md index ce2d24114e0..c742c9a9cbf 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/ARCHITECTURE.md +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/ARCHITECTURE.md @@ -69,13 +69,16 @@ Current extracted ownership includes: - `HistoryController`: engine-owned history, guarded async replay with failure reporting, inactive byte trimming. - `PersistenceController`: dirty-bitmap flush barriers 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 (an image that is gone or does not decode) reports `unreadable`, 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) refuses with that reason instead of asking the user to retry. The rasterizer remembers the failure per cache entry, keyed by cache version, document generation and source, and a retry of the same source that fails again raises no second error notice. Two questions read that record. `hasFailed` (a failure is 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 it holds. `isBackingOff` (that 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 layer nobody redraws (off screen, disabled) is retried by a readiness gate that finds the backoff over, in the background while it still answers `unreadable`. 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. - `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 (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. Locked leaves (own or ancestor lock) are skipped, and counted when their content reaches past the frame, so the result can say how many were skipped even when nothing was cropped; 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 its opacity) into a mask configured like the topmost source. +- `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 and counts each when its contract's content reaches past the frame (`skippedLocked`, `skippedUnreadable`), so the result can say what was left alone 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. Controller-local tests instantiate these boundaries with fakes. Integration tests in `engine.test.ts` cover composition, document routing, cross-controller invariants. @@ -84,7 +87,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`, `unreadable`, or `over-budget` 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. @@ -209,13 +212,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`, `unreadable`, `over-budget`, `stale`, `aborted`, 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 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. -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 map of where the last committed stroke ended on each layer: a Shift press on a layer with a remembered end draws a straight segment from it as part of the one stroke, and the map survives tool switches but forgets on replay, document replacement and the layer's removal. `layers.beginTransform` 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 document replacement, 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`. +`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 72fecad528e..918f7a619fc 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/applicationHost.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/applicationHost.ts @@ -48,7 +48,7 @@ export interface CanvasApplicationHost { options?: ExportLayerPixelsOptions ): Promise< | { status: 'ok'; surface: RasterSurface; rect: Rect; guard: LayerExportGuard; release(): void } - | { status: 'missing' | 'disabled' | 'unsupported' | 'empty' | 'not-ready' | 'over-budget' } + | { status: 'missing' | 'disabled' | 'unsupported' | 'empty' | 'not-ready' | 'unreadable' | 'over-budget' } | 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 af4af734cdb..c2bcebb9ea4 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/capabilities.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/capabilities.ts @@ -270,6 +270,7 @@ export type PsdExportResult = | 'nothing' | 'too-large' | 'over-budget' + | 'unreadable' | SubsetOf; export interface CanvasPsdExportCapability { @@ -377,7 +378,8 @@ export type ExportBakedLayerBlobResult = | SubsetOf | 'disabled' | 'empty' - | 'over-budget'; + | 'over-budget' + | 'unreadable'; } | AdjustmentRefusal; @@ -550,7 +552,13 @@ 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' + | 'unreadable'; + }; /** * 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. @@ -637,8 +645,6 @@ export interface CanvasEngineLayerCapability extends CanvasLayerCapability { */ beginTransform(layerId: string): BeginTransformResult; booleanMergeRasterLayers(upperLayerId: string, operation: BooleanRasterOperation): Promise; - /** Whether `mergeInpaintMasks` would accept these ids; pure, for menu enablement. */ - canMergeInpaintMasks(layerIds: readonly string[]): boolean; cancelTextEdit(): void; cancelTransform(): void; clearMask(layerId: string): MaskEditResult; @@ -797,6 +803,8 @@ export { areSelectedRasterLayersContiguous, canMergeSelectedRasters, canMergeVisibleRasters, + inpaintMaskMergeEligibility, + type InpaintMaskMergeEligibility, } from './document/mergeVisible'; export { documentToExportLocalSamPoint } from './samCoordinates'; export { bboxEquals, constrainBboxToRatio, roundBbox } from './tools/bboxHitTest'; 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..f670115f66c 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/booleanMergeController.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/booleanMergeController.ts @@ -16,7 +16,9 @@ import { layerOperationStatus, paintLayerAt, publishAddedRasterLayer, + readinessOfAll, rgbaBytes, + type LayerCacheReadiness, type LayerStepContext, type LayerEditRefusal, } from './editSteps'; @@ -27,13 +29,14 @@ export type BooleanRasterResult = | 'missing' | 'unsupported' | 'empty' + | 'unreadable' | 'failed' | LayerEditRefusal | AdjustmentRefusal; type ExportResult = | { status: 'ok'; surface: RasterSurface; rect: Rect; guard: LayerExportGuard; release(): void } - | { status: 'missing' | 'disabled' | 'unsupported' | 'empty' | 'not-ready' | 'over-budget' } + | { status: 'missing' | 'disabled' | 'unsupported' | 'empty' | 'not-ready' | 'unreadable' | 'over-budget' } | AdjustmentRefusal; export interface BooleanMergeControllerOptions { @@ -49,7 +52,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 +89,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 +123,9 @@ export class BooleanMergeController { if (upperPixels.status === 'over-budget' || belowPixels.status === 'over-budget') { return 'over-budget'; } + if (upperPixels.status === 'unreadable' || belowPixels.status === 'unreadable') { + return 'unreadable'; + } 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 db1dcd484a7..5049b01ce22 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/copyLayerController.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/copyLayerController.ts @@ -25,7 +25,7 @@ import { type ExportResult = | { status: 'ok'; surface: RasterSurface; rect: Rect; guard: LayerExportGuard; release(): void } - | { status: 'missing' | 'disabled' | 'unsupported' | 'empty' | 'not-ready' | 'over-budget' } + | { status: 'missing' | 'disabled' | 'unsupported' | 'empty' | 'not-ready' | 'unreadable' | 'over-budget' } | AdjustmentRefusal; /** The stack a baked copy lands in: a raster paint layer, or a control layer with a default adapter. */ @@ -33,7 +33,14 @@ export type CopyLayerTarget = 'raster' | 'control'; export type CopyLayerResult = | { status: 'copied'; layerId: string } - | { status: LayerEditRefusal | SubsetOf | 'empty' | 'failed' } + | { + status: + | LayerEditRefusal + | SubsetOf + | 'empty' + | 'unreadable' + | 'failed'; + } | AdjustmentRefusal; export interface CopyLayerControllerOptions { 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 ffb07eaaac1..82a8da9b2cc 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 @@ -2,6 +2,7 @@ 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'; @@ -33,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) => { +const createHarness = ( + historyBytes?: number, + options: { readonly nodes?: CanvasNodeContract[]; readonly unreadable?: boolean } = {} +) => { const document: CanvasDocumentContractV3 = { background: 'transparent', bbox: BBOX, height: 100, selectedLayerId: 'paint', - stacks: stacksFrom([layer]), + 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) => { @@ -57,6 +62,10 @@ const createHarness = (historyBytes?: number) => { ctx: h.ctx, discardPersisted, exportBaked: (layerId) => { + exported.push(layerId); + if (options.unreadable) { + return Promise.resolve({ status: 'unreadable' as const }); + } const entry = h.layers.get(layerId)!; const current = getDocumentLayer(h.document(), layerId)!; return Promise.resolve({ @@ -67,10 +76,11 @@ const createHarness = (historyBytes?: number) => { surface: entry.surface, }); }, + flushPendingPixels: () => Promise.resolve(), isGuardCurrent: () => true, isSupportedSource: () => true, }); - return { ...h, controller, discardPersisted, original: getDocumentLayer(h.document(), 'paint')! }; + return { ...h, controller, discardPersisted, exported, original: getDocumentLayer(h.document(), 'paint')! }; }; describe('CropLayerController', () => { @@ -100,6 +110,31 @@ 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({ 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'); @@ -148,6 +183,10 @@ const createCropAllHarness = ( 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[]; + /** Settles pending paint uploads; resolves at once by default. */ + flushPendingPixels?: () => Promise; } = {} ) => { const raster = (id: string) => @@ -162,6 +201,7 @@ const createCropAllHarness = ( height: 100, selectedLayerId: 'raster', stacks: stacksFrom([ + ...(options.unreadable ?? []), raster('inside'), raster('raster'), lockedPaint('locked'), @@ -202,6 +242,9 @@ const createCropAllHarness = ( const read = exportOptions?.applyAdjustments === false ? `${layerId}:raw` : layerId; exported.push(read); options.onExport?.(layerId); + if (options.unreadable?.some((leaf) => leaf.id === layerId)) { + return Promise.resolve({ status: 'unreadable' as const }); + } if (options.refusal?.ownerId === layerId && read === layerId) { return Promise.resolve(options.refusal); } @@ -219,6 +262,7 @@ const createCropAllHarness = ( surface: entry.surface, }); }, + flushPendingPixels: options.flushPendingPixels ?? (() => Promise.resolve()), isGuardCurrent: () => guard.current, isSupportedSource: () => true, }); @@ -257,7 +301,12 @@ describe('CropLayerController: crop all layers', () => { }; const h = createCropAllHarness({ adjusted: ['inside'], refusal }); - await expect(h.controller.cropAll()).resolves.toEqual({ cropped: 4, skippedLocked: 2, status: 'cropped' }); + await expect(h.controller.cropAll()).resolves.toEqual({ + cropped: 4, + skippedLocked: 2, + skippedUnreadable: 0, + 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); @@ -267,7 +316,12 @@ describe('CropLayerController: crop all layers', () => { 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, status: 'cropped' }); + await expect(h.controller.cropAll()).resolves.toEqual({ + cropped: 4, + skippedLocked: 2, + skippedUnreadable: 0, + 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 }); @@ -281,7 +335,12 @@ describe('CropLayerController: crop all layers', () => { 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, status: 'cropped' }); + await expect(h.controller.cropAll()).resolves.toEqual({ + cropped: 4, + skippedLocked: 2, + skippedUnreadable: 0, + status: 'cropped', + }); // Hidden, disabled and grouped leaves crop too; locked ones are never read, and only those reaching past the // frame count as skipped. @@ -338,6 +397,44 @@ describe('CropLayerController: crop all layers', () => { expect(h.layers.get('raster')?.rect).toEqual(FRAME); }); + it('skips leaves whose pixels cannot be read and crops the rest, counting 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, x: number) => + layerContract(id, 'raster', { + 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', 15), unreadableImage('goneInside', 5)] }); + + await expect(h.controller.cropAll()).resolves.toEqual({ + cropped: 4, + skippedLocked: 2, + skippedUnreadable: 1, + status: 'cropped', + }); + expect(h.exported).toEqual(['mask', 'region', 'control', 'gone', 'goneInside', '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('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']) { @@ -345,7 +442,11 @@ describe('CropLayerController: crop all layers', () => { h.layers.getOrCreateRect(id, CACHE_RECTS.inside!); } - await expect(h.controller.cropAll()).resolves.toEqual({ skippedLocked: 2, status: 'nothing' }); + await expect(h.controller.cropAll()).resolves.toEqual({ + skippedLocked: 2, + skippedUnreadable: 0, + status: 'nothing', + }); expect(h.dispatched).toEqual([]); expect(h.history.canUndo()).toBe(false); }); 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 fdb0ed21508..9540bd4a907 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/cropLayerController.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/cropLayerController.ts @@ -4,11 +4,12 @@ import type { CanvasLayerContract, CanvasLayerSourceContract, } from '@workbench/canvas-engine/contracts'; +import type { SemanticLeaf } from '@workbench/canvas-engine/document-model/semanticLeaf'; 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 } from '@workbench/canvas-engine/document-model/documentModel'; +import { compileDocumentLeaves, lookupDocumentLeaf } from '@workbench/canvas-engine/document-model/documentModel'; import { getDocumentLayer } from '@workbench/canvas-engine/document/documentIndex'; import { getSourceContentRect, renderableSourceOf } from '@workbench/canvas-engine/document/sources'; import { collectHistoryMediaRefs, HISTORY_ENTRY_OVERHEAD_BYTES } from '@workbench/canvas-engine/history/history'; @@ -28,25 +29,31 @@ import { 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). `unreadable`: 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' | 'unreadable' | LayerEditRefusal } | { status: 'failed'; message: string } | AdjustmentRefusal; /** - * `skippedLocked` counts locked leaves (own or ancestor lock) whose content reaches past the frame. `nothing` when - * every unlocked leaf already fits; every refusal leaves document, pixels and history alone. + * `skippedLocked` counts locked leaves (own or ancestor lock) and `skippedUnreadable` 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. */ export type CropAllLayersResult = - | { status: 'cropped'; cropped: number; skippedLocked: number } - | { status: 'nothing'; skippedLocked: number } + | { status: 'cropped'; cropped: number; skippedLocked: number; skippedUnreadable: number } + | { status: 'nothing'; skippedLocked: number; skippedUnreadable: number } | { 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' } + | { status: 'missing' | 'disabled' | 'unsupported' | 'empty' | 'not-ready' | 'unreadable' | 'over-budget' } | AdjustmentRefusal; export interface CropLayerControllerOptions { @@ -75,10 +82,30 @@ 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' }; +/** + * 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 @@ -110,6 +137,13 @@ const cropPixels = (backend: RasterBackend, exported: RasterSurface, exportedRec return cropped; }; +/** What crop all makes of one leaf's export. */ +type CropExport = + | Extract + | { status: 'skip' } + | { status: 'unreadable' } + | CropAllLayersResult; + /** One leaf's crop: its contract and pixels before and after. */ interface LayerCrop { readonly before: CanvasLayerContract; @@ -137,16 +171,13 @@ 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); @@ -161,23 +192,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 }); @@ -233,9 +266,10 @@ 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) - * 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. Each leaf's export is - * released as soon as its cropped pixels are copied, so the peak is the cropped set plus one export. + * 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; @@ -243,36 +277,51 @@ export class CropLayerController { 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, status: 'nothing' }; + return { skippedLocked: 0, skippedUnreadable: 0, 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 candidates: CanvasLayerContract[] = []; + let skippedUnreadable = 0; + const candidates: SemanticLeaf[] = []; for (const leaf of compileDocumentLeaves(document)) { - const source = renderableSourceOf(leaf.layer); - if (!source || !this.deps.isSupportedSource(source)) { - continue; - } - if (!leaf.effectiveLocked) { - candidates.push(leaf.layer); - continue; - } - // A locked leaf counts only when cropping would have changed it: content reaching past the frame. - const content = getSourceContentRect(leaf.layer, document); - if (!isEmpty(content) && cropRectFor(transformBounds(leaf.worldTransform, content), frame)) { + 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 layer of candidates) { + 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 += 1; + } + continue; + } if (exported.status !== 'ok') { return exported; } @@ -312,9 +361,9 @@ export class CropLayerController { return { status: 'stale' }; } if (cropped.length === 0) { - return { skippedLocked, status: 'nothing' }; + return { skippedLocked, skippedUnreadable, status: 'nothing' }; } - return this.publishAll(liveDocument, cropped, skippedLocked); + return this.publishAll(liveDocument, cropped, { skippedLocked, skippedUnreadable }); } catch { return { status: 'failed' }; } finally { @@ -325,21 +374,19 @@ export class CropLayerController { } /** - * A leaf's baked pixels for crop all, or `skip` when it has nothing to crop. A leaf with its own adjustment stack is + * A leaf's baked pixels for crop all, `skip` when it has nothing to crop, or `unreadable`. 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 | { status: 'skip' } | CropAllLayersResult> { - const settle = ( - exported: ExportResult - ): Extract | { status: 'skip' } | CropAllLayersResult => { + private async exportForCrop(layer: CanvasLayerContract, frame: Rect): Promise { + const settle = (exported: ExportResult): CropExport => { if (exported.status === 'ok' || exported.status === 'adjustment-refused') { return exported; } + if (exported.status === 'unreadable') { + return { status: 'unreadable' }; + } if (exported.status === 'missing') { return { status: 'stale' }; } @@ -363,7 +410,7 @@ export class CropLayerController { private publishAll( document: CanvasDocumentContractV3, cropped: readonly CroppedLeaf[], - skippedLocked: number + skipped: { readonly skippedLocked: number; readonly skippedUnreadable: number } ): CropAllLayersResult { const { ctx } = this.deps; const afterBytes = cropped.reduce((sum, leaf) => sum + rgbaBytes(leaf.pixels.rect), 0); @@ -408,7 +455,7 @@ export class CropLayerController { undo: replay('before'), }); const status = layerOperationStatus(result); - return status === 'committed' ? { cropped: crops.length, skippedLocked, status: 'cropped' } : { status }; + return status === 'committed' ? { cropped: crops.length, ...skipped, status: 'cropped' } : { status }; } finally { txn.end(); } 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 2c5b3be64b5..e9fb4399a8f 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/editSteps.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/editSteps.ts @@ -34,6 +34,16 @@ 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 (waiting helps), + * `unreadable` once rasterizing its current source failed (waiting does not; the operation refuses and says so). + */ +export type LayerCacheReadiness = 'ready' | 'not-ready' | 'unreadable'; + +/** Layers read together: one that cannot be read refuses the set even while others still load. */ +export const readinessOfAll = (readiness: readonly LayerCacheReadiness[]): LayerCacheReadiness => + readiness.includes('unreadable') ? 'unreadable' : 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; 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..07d99b3edc2 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/extractMaskedAreaController.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/extractMaskedAreaController.ts @@ -41,21 +41,30 @@ 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' + | 'unreadable' + | 'failed'; + } | AdjustmentRefusal; type OwnedExport = Extract; type ExportResult = | { status: 'ok'; surface: RasterSurface; rect: Rect; guard: LayerExportGuard; release(): void } - | { status: 'missing' | 'disabled' | 'unsupported' | 'empty' | 'not-ready' | 'over-budget' } + | { status: 'missing' | 'disabled' | 'unsupported' | 'empty' | 'not-ready' | 'unreadable' | 'over-budget' } | AdjustmentRefusal; export interface ExtractMaskedAreaControllerOptions { @@ -72,7 +81,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 +147,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 { status: readiness }; } const owned: Extract[] = []; const acquire = async (resultPromise: Promise): Promise => { @@ -168,8 +175,10 @@ 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' }; + for (const status of ['over-budget', 'unreadable', 'not-ready'] as const) { + if (statuses.includes(status)) { + return { status }; + } } 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 9205d03d0e8..f09b7f051d0 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 80cb3a35f4a..c32e280f628 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/layerMutationController.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/layerMutationController.ts @@ -42,12 +42,13 @@ export type ConvertLayerToControlResult = | 'converted' | SubsetOf | LayerEditRefusal + | 'unreadable' | 'failed' | AdjustmentRefusal; type BakedExportResult = | { status: 'ok'; surface: RasterSurface; rect: Rect; guard: LayerExportGuard; release(): void } - | { status: 'missing' | 'disabled' | 'unsupported' | 'empty' | 'not-ready' | 'over-budget' } + | { status: 'missing' | 'disabled' | 'unsupported' | 'empty' | 'not-ready' | 'unreadable' | 'over-budget' } | AdjustmentRefusal; export type DuplicateLayerRasterPlan = @@ -64,7 +65,7 @@ export type DuplicateLayerRasterPlan = type DuplicateRasterPreparationResult = | { readonly status: 'ready'; readonly layer: CanvasLayerContract } - | { readonly status: 'not-ready' | 'over-budget' }; + | { readonly status: 'not-ready' | 'unreadable' | 'over-budget' }; export interface LayerMutationControllerOptions { readonly ctx: Pick< 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 fbe7bee3627..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 @@ -123,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) => @@ -233,10 +233,10 @@ const coverage = (rect: Rect, alpha: number): RasterSurface => { }; describe('MergeLayerController in Chromium: merge masks', () => { - it('unions coverage source-over, each mask at its own opacity', async () => { - // The top mask is half-covered at full opacity; the one below is fully covered at half opacity. + 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.5, rect: { height: 10, width: 10, x: 5, y: 0 } }, + 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 = { @@ -278,7 +278,7 @@ describe('MergeLayerController in Chromium: merge masks', () => { surface: coverage(sources[layerId]!.rect, sources[layerId]!.alpha), }), hasExportableContent: () => true, - isCacheReady: () => true, + cacheReadiness: () => 'ready', layers: h.layers, needsPixelPersistence: () => true, peekAdjustedSurface: () => null, @@ -289,9 +289,10 @@ describe('MergeLayerController in Chromium: merge masks', () => { 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 coverage. Below only: half of full coverage. Both: 128 over 128 = 128 + 128 * (1 - 128/255). + // 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)).toBeCloseTo(128, -0.5); - expect(alphaAt(7)).toBeCloseTo(128 + 128 * (1 - 128 / 255), -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 b44d069b2a2..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 @@ -72,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, @@ -295,7 +295,7 @@ describe('MergeLayerController: merge visible with a spatial group stack', () => } ), hasExportableContent: () => true, - isCacheReady: () => true, + cacheReadiness: () => 'ready', layers: h.layers, needsPixelPersistence: () => false, peekAdjustedSurface: () => null, @@ -357,11 +357,17 @@ describe('MergeLayerController: merge masks', () => { top: { height: 10, width: 10, x: 0, y: 0 }, }; - const createMaskHarness = (options: { historyBytes?: number; onExport?: () => void } = {}) => { - const top = layerContract('top', 'inpaint_mask', { + 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' } }, - noise: { isEnabled: true, level: 0.2 }, opacity: 0.5, } as Partial); const document: CanvasDocumentContractV3 = { @@ -371,8 +377,11 @@ describe('MergeLayerController: merge masks', () => { selectedLayerId: 'middle', stacks: stacksFrom([ groupContract('first', [layerContract('other', 'inpaint_mask'), top]), - groupContract('second', [layerContract('middle', 'inpaint_mask')]), - layerContract('bottom', 'inpaint_mask'), + 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, @@ -398,7 +407,7 @@ describe('MergeLayerController: merge masks', () => { }); }, hasExportableContent: (layerId) => (h.layers.get(layerId)?.rect.width ?? 0) > 0, - isCacheReady: () => true, + cacheReadiness: () => 'ready', layers: h.layers, needsPixelPersistence: () => true, peekAdjustedSurface: () => null, @@ -410,7 +419,7 @@ describe('MergeLayerController: merge masks', () => { 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, configured like it, as one step', async () => { + 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']); @@ -462,6 +471,14 @@ describe('MergeLayerController: merge masks', () => { 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 }); 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 781b3ffeb1c..1d65edcbcda 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/mergeLayerController.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/mergeLayerController.ts @@ -27,6 +27,7 @@ import { canMergeInpaintMasks, canMergeSelectedRasters, getMergeVisibleRasterLeaves, + inpaintMaskMergeEligibility, } from '@workbench/canvas-engine/document/mergeVisible'; import { collectHistoryMediaRefs, HISTORY_ENTRY_OVERHEAD_BYTES } from '@workbench/canvas-engine/history/history'; import { isEmpty, roundOut, transformBounds, union } from '@workbench/canvas-engine/math/rect'; @@ -59,19 +60,25 @@ import { layerOperationStatus, paintLayerAt, publishAddedRasterLayer, + readinessOfAll, rgbaBytes, withReplayReservation, + type LayerCacheReadiness, type LayerEditRefusal, replaceLayerStep, type LayerPixels, } from './editSteps'; -export type MergeVisibleResult = 'merged' | LayerEditRefusal | 'nothing' | 'failed' | AdjustmentRefusal; +/** `unreadable`: a contributor's pixels could not be read (its image is gone or does not decode). */ +export type MergeVisibleResult = 'merged' | LayerEditRefusal | 'nothing' | 'unreadable' | 'failed' | AdjustmentRefusal; -/** Every refusal leaves document, pixels and history alone. */ +/** + * 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' | 'missing' | 'failed' | LayerEditRefusal } + | { status: 'ineligible' | 'settings-differ' | 'missing' | 'unreadable' | 'failed' | LayerEditRefusal } | AdjustmentRefusal; /** @@ -83,6 +90,8 @@ 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; } @@ -96,7 +105,7 @@ 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, noise and denoise. + * 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]!; @@ -122,19 +131,20 @@ export type MergeDownResult = | 'merged' | SubsetOf | LayerEditRefusal + | 'unreadable' | 'failed' | AdjustmentRefusal; type ExportResult = | { status: 'ok'; surface: RasterSurface; rect: Rect; guard: LayerExportGuard; release(): void } - | { status: 'missing' | 'disabled' | 'unsupported' | 'empty' | 'not-ready' | 'over-budget' } + | { status: 'missing' | 'disabled' | 'unsupported' | 'empty' | 'not-ready' | 'unreadable' | 'over-budget' } | 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; @@ -182,12 +192,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); @@ -412,6 +424,9 @@ export class MergeLayerController { if (exports.some((result) => result.status === 'over-budget')) { return 'over-budget'; } + if (exports.some((result) => result.status === 'unreadable')) { + return 'unreadable'; + } if (exports.some((result) => result.status !== 'ok')) { return 'not-ready'; } @@ -556,6 +571,7 @@ export class MergeLayerController { 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', @@ -569,9 +585,21 @@ export class MergeLayerController { 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', @@ -583,8 +611,8 @@ export class MergeLayerController { } /** - * Composites the selected leaves bottom-up (source-over, each at its opacity) into a new leaf above the topmost and - * removes them, as one undo step that restores every source in place with its raw pixels. + * 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; @@ -626,6 +654,9 @@ export class MergeLayerController { if (exports.some((result) => result.status === 'over-budget')) { return { status: 'over-budget' }; } + if (exports.some((result) => result.status === 'unreadable')) { + return { status: 'unreadable' }; + } if (exports.some((result) => result.status !== 'ok')) { return { status: 'not-ready' }; } @@ -699,7 +730,7 @@ 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); } 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/rasterExportController.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/rasterExportController.ts index f07356c0bb4..cc2e055383d 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/rasterExportController.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/controllers/rasterExportController.ts @@ -27,9 +27,16 @@ import { type SpatialRunner, } from '@workbench/canvas-engine/render/exactAdjustments'; +/** + * `not-ready`: the pixels are still loading or moved while they were read, so trying again can succeed. `unreadable`: + * 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' | 'unreadable' | 'over-budget' | 'aborted'; + } | AdjustmentRefusal; export interface RasterExportControllerOptions { @@ -206,7 +213,7 @@ export class RasterExportController { try { const rasterized = await this.options.getOrStartRasterization(layer, document, options.signal); if (rasterized !== 'published') { - return { status: rasterized === 'aborted' ? 'aborted' : 'not-ready' }; + return { status: rasterized === 'aborted' ? 'aborted' : rasterized === 'error' ? 'unreadable' : 'not-ready' }; } const currentDocument = this.options.getDocument(); const currentLayer = currentDocument ? lookupDocumentLayer(currentDocument, layerId) : null; 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 0f49ad5bab3..fca5eba3bc3 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 { 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'; @@ -16,19 +17,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; @@ -58,23 +66,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, }); @@ -82,13 +111,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); @@ -185,3 +219,176 @@ describe('TransformEditingController', () => { expect(h.calls.restoreCache).toHaveBeenCalledOnce(); }); }); + +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 3708e9ef7a2..672254d10ae 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 } 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 { getDocumentLayer } from '@workbench/canvas-engine/document/documentIndex'; @@ -14,6 +18,7 @@ import { isRenderableLayer } from '@workbench/canvas-engine/document/sources'; import { HISTORY_ENTRY_OVERHEAD_BYTES, NO_HELD_ASSET_REFS } 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'; @@ -22,13 +27,19 @@ import { 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, @@ -37,6 +48,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; } @@ -52,7 +71,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; @@ -82,6 +104,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) { @@ -199,6 +325,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(); @@ -223,6 +352,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/mergeVisible.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/document/mergeVisible.test.ts index fbf8f8fbe7c..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 @@ -14,10 +14,11 @@ import { 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: { @@ -126,8 +127,18 @@ describe('merge-masks eligibility', () => { 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); @@ -146,5 +157,25 @@ describe('merge-masks eligibility', () => { ['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 c1396442cbf..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'; @@ -82,18 +86,30 @@ export const canMergeSelectedRasters = ( 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. Regional guidance never merges this way. + * 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 const canMergeInpaintMasks = ( +export type InpaintMaskMergeEligibility = 'eligible' | 'ineligible' | 'settings-differ'; + +export const inpaintMaskMergeEligibility = ( leaves: readonly SemanticLeaf[], selectedLayerIds: ReadonlySet, hasContent: HasMergeVisibleContent -): boolean => { +): InpaintMaskMergeEligibility => { if (selectedLayerIds.size < 2) { - return false; + return 'ineligible'; } + const settings = new Set(); let found = 0; for (const leaf of leaves) { if (!selectedLayerIds.has(leaf.id)) { @@ -105,10 +121,20 @@ export const canMergeInpaintMasks = ( leaf.effectiveLocked || !hasContent(leaf.id) ) { - return false; + return 'ineligible'; } + settings.add(maskGenerationSettings(leaf.layer)); found += 1; } // A selected group or a missing id is not a mask leaf. - return found === selectedLayerIds.size; + 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.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.test.ts index 2687dfa21e7..0fb36321779 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.test.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.test.ts @@ -60,6 +60,7 @@ 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'; interface EngineStore { dispatch(action: EngineTestAction): void; @@ -753,6 +754,113 @@ 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('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')]) }; + 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('backend restarting')) + ); + 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(); + } + }; + // No surface is attached, so nothing redraws either layer. + expect((await engine.exports.exportLayerPixels('upper')).status).toBe('ok'); + expect((await engine.exports.exportLayerPixels('flaky')).status).toBe('unreadable'); + expect(await engine.layers.mergeLayerDown('upper')).toBe('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')).toBe('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')).toBe('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); @@ -834,7 +942,7 @@ describe('createCanvasEngine', () => { engine.lifecycle.dispose(); }); - it('sends a pick’s commit or cancel only to the sink that took its first preview', async () => { + 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); @@ -987,7 +1095,7 @@ 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(), @@ -999,7 +1107,7 @@ describe('createCanvasEngine', () => { const documentSnapshot = engine.document.captureSnapshot(); await expect(engine.exports.captureRasterSnapshot(documentSnapshot!, ['a'])).resolves.toEqual({ - status: 'not-ready', + status: 'unreadable', }); engine.lifecycle.dispose(); }); @@ -1237,6 +1345,22 @@ describe('createCanvasEngine', () => { engine.lifecycle.dispose(); }); + it('refuses a composite export with a layer whose image cannot be read as unreadable', async () => { + const { store } = createReactiveStore(makeDoc()); + const engine = createCanvasEngine({ + backend: createTestStubRasterBackend(), + imageResolver: () => Promise.reject(new Error('image deleted')), + projectId: 'p1', + reportError: vi.fn(), + store, + }); + + await expect(engine.exports.exportRasterComposite({ bounds: 'content' })).resolves.toEqual({ + status: 'unreadable', + }); + 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. @@ -1600,7 +1724,7 @@ describe('createCanvasEngine', () => { store, }); - expect(await engine.exports.exportLayerPixels('shape')).toEqual({ status: 'not-ready' }); + expect(await engine.exports.exportLayerPixels('shape')).toEqual({ status: 'unreadable' }); expect((await engine.exports.exportLayerPixels('shape')).status).toBe('ok'); engine.lifecycle.dispose(); }); @@ -2235,7 +2359,12 @@ describe('createCanvasEngine', () => { }); const before = structuredClone(engine.document.getDocument()!); - expect(await engine.layers.cropAllLayersToBbox()).toEqual({ cropped: 3, skippedLocked: 1, status: 'cropped' }); + expect(await engine.layers.cropAllLayersToBbox()).toEqual({ + cropped: 3, + skippedLocked: 1, + skippedUnreadable: 0, + 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 } }); @@ -2254,7 +2383,47 @@ describe('createCanvasEngine', () => { engine.lifecycle.dispose(); }); - it('cropLayerToBbox returns empty without mutation when the layer and bbox do not overlap', async () => { + 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 Error('image deleted')) : Promise.resolve(new Blob()), + projectId, + reportError, + store, + }); + const broken = getDocumentLayer(engine.document.getDocument()!, 'broken'); + + expect(await engine.layers.cropAllLayersToBbox()).toEqual({ + cropped: 1, + skippedLocked: 0, + skippedUnreadable: 1, + 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: 1, + 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(); @@ -2267,14 +2436,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', @@ -2289,7 +2458,7 @@ 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); @@ -2303,7 +2472,7 @@ 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({ status: 'unreadable' }); engine.lifecycle.dispose(); }); @@ -3381,6 +3550,109 @@ describe('engine-owned pixel editing', () => { 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(); + 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('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(); + }); + + 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', + }); + 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); + }; + + 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(); + }); + it('persists and releases a materialized image erase when a thumbnail observer throws', async () => { const h = createRasterImageEraserHarness(); await h.publishInitialCache(); @@ -7605,6 +7877,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: 0, status: 'cropped' }); + expect(engine.history.getEntries().past).toEqual(['Crop all layers to frame']); + engine.lifecycle.dispose(); + }); }); describe('mergeVisibleRasterLayers', () => { @@ -15893,6 +16228,21 @@ describe('beginTransform', () => { 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'); @@ -15905,39 +16255,11 @@ describe('beginTransform', () => { engine.lifecycle.dispose(); }); - it('cancels a session on another layer, dropping its preview, before opening one on the target', () => { - const { dispatch, engine } = setup([rasterLayer('a'), rasterLayer('b')], 'a'); - engine.tools.setTool('transform'); - engine.layers.updateTransformSession({ rotation: 0, scaleX: 2, scaleY: 2, x: 5, y: 6 }); - dispatch.mockClear(); - - expect(engine.layers.beginTransform('b')).toBe('started'); - expect(engine.stores.transformSession.get()).toMatchObject({ - layerId: 'b', - transform: { rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, - }); - // Only the selection changed: the abandoned preview on `a` never landed. - expect(dispatch.mock.calls.map(([action]) => action.type)).toEqual(['setCanvasSelectedLayer']); - expect(getDocumentLayer(engine.document.getDocument(), 'a')?.transform.scaleX).toBe(1); - engine.lifecycle.dispose(); - }); - - it.each([ - ['locked', [{ ...rasterLayer('b'), isLocked: true }], 'locked'], - ['a mask', [thumbnailMaskLayer('b', 'inpaint_mask')], 'ineligible'], - ['missing', [rasterLayer('a')], 'missing'], - ] as const)('refuses %s without selecting or switching tools', (_, layers, status) => { - const { dispatch, engine } = setup([rasterLayer('a'), ...layers.filter((layer) => layer.id !== 'a')], 'a'); - - expect(engine.layers.beginTransform('b')).toBe(status); - expect(dispatch).not.toHaveBeenCalled(); - expect(engine.stores.activeTool.get()).not.toBe('transform'); - expect(engine.stores.transformSession.get()).toBeNull(); - engine.lifecycle.dispose(); - }); - - /** Paints a stroke onto a new layer, then asks for its transform before persistence acknowledges the pixels. */ - const strokeThenTransform = (options: { uploadImage?: () => Promise } = {}) => { + /** + * 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); @@ -15956,10 +16278,14 @@ describe('beginTransform', () => { 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: null, - stacks: stacksFrom([]), + selectedLayerId: options.existingLayer ? existing.id : null, + stacks: stacksFrom(options.existingLayer ? [existing] : []), }); const engine = createCanvasEngine({ backend: createTestStubRasterBackend(), @@ -16033,6 +16359,17 @@ describe('beginTransform', () => { 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 { @@ -16079,43 +16416,6 @@ describe('beginTransform', () => { vi.useRealTimers(); } }); - - 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('refuses as busy while document edits are locked, leaving the selection and tool unchanged', () => { - const { dispatch, engine } = setup([rasterLayer('a'), rasterLayer('b')], 'a'); - engine.tools.setTool('brush'); - engine.stores.documentEditingLocked.set(true); - dispatch.mockClear(); - - expect(engine.layers.beginTransform('b')).toBe('busy'); - expect(dispatch).not.toHaveBeenCalled(); - expect(engine.stores.activeTool.get()).toBe('brush'); - expect(engine.stores.transformSession.get()).toBeNull(); - engine.lifecycle.dispose(); - }); - - it('refuses as busy once disposed, without touching the selection', () => { - const { dispatch, engine } = setup([rasterLayer('a'), rasterLayer('b')], 'a'); - engine.lifecycle.dispose(); - dispatch.mockClear(); - - expect(engine.layers.beginTransform('b')).toBe('busy'); - expect(dispatch).not.toHaveBeenCalled(); - }); }); describe('transform session', () => { diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.ts index 50816959555..5b5772edd81 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/engine.ts @@ -1,5 +1,4 @@ import type { - BeginTransformResult, CanvasEditRefusal, CanvasHistoryReplayStatus, CanvasHistoryCapability, @@ -20,7 +19,6 @@ import type { CanvasSurfaceAttachOptions, CanvasSurfaceCapability, CanvasViewportCapability, - ColorSamplePhase, BooleanRasterOperation, BooleanRasterResult, CopyLayerResult, @@ -85,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'; @@ -100,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 { PixelEditController } from '@workbench/canvas-engine/controllers/controlPixelController'; import { EditingController } from '@workbench/canvas-engine/controllers/editingController'; import { FilterResultController } from '@workbench/canvas-engine/controllers/filterResultController'; @@ -117,6 +117,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'; @@ -131,7 +132,6 @@ import { createCanvasDiagnostics } from '@workbench/canvas-engine/diagnostics'; import { compileDocumentLeaves, createDocumentModel, - lookupDocumentLeaf, type CanvasDocumentModel, } from '@workbench/canvas-engine/document-model/documentModel'; import { @@ -169,7 +169,10 @@ 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 { + 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'; @@ -188,7 +191,7 @@ import { stepBrushSize } from '@workbench/canvas-engine/tools/paintConstants'; import { createSamTool } from '@workbench/canvas-engine/tools/samTool'; import { createShapeTool } from '@workbench/canvas-engine/tools/shapeTool'; import { createTextTool } from '@workbench/canvas-engine/tools/textTool'; -import { createTransformTool, isTransformEligible } from '@workbench/canvas-engine/tools/transformTool'; +import { createTransformTool } from '@workbench/canvas-engine/tools/transformTool'; import { FULL_DAMAGE } from '@workbench/canvas-engine/types'; import { createViewport, type Viewport } from '@workbench/canvas-engine/viewport'; @@ -199,7 +202,6 @@ import type { StrokeCommittedEvent, Tool, ToolContext } from './tools/tool'; import { createBitmapStore, type BitmapStore } from './document/bitmapStore'; import { createDocumentMirror, type DocumentMirror } from './document/documentMirror'; import { decideLayerChange, nextThumbnailDisplayToken } from './document/layerChangeDecision'; -import { canMergeInpaintMasks as canMergeInpaintMasksIn } from './document/mergeVisible'; import { getSourceBounds, getSourceContentRect, isRenderableLayer, renderableSourceOf } from './document/sources'; import { collectCanvasFontReferences, replaceCanvasFontReferences } from './fontReferences'; import { createLayerExportGuards, isSupportedExportSource } from './layerExportGuards'; @@ -645,7 +647,7 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC onError: (error, layerId, info) => { // A retry may still land the pixels; only a failure no retry follows ends the wait. if (!info.willRetry) { - failPendingTransform(layerId); + editingController.transform.onUploadFailed(layerId); } reportError( info.willRetry ? 'Bitmap persistence failed' : 'Bitmap persistence suspended', @@ -665,6 +667,7 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC // undoable until it commits. beforeReplay: () => { paintLineEnds.clear(); + editingController.transform.onReplay(); floatingSelection.cancel(); structuralController.endPreview(); }, @@ -1013,12 +1016,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) { @@ -1083,57 +1094,16 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC } } - /** - * One-shot color claims take a pick's previews and settle on its release commit, after structural edits are - * allowed. Escape, tool change and teardown resolve null. - */ - let pendingColorSample: { - previousToolId: ToolId; - resolve: (hex: string | null) => void; - } | null = null; - // Where unclaimed eyedropper samples land while a workbench is attached. - let colorSampleRouter: ((hex: string, phase: ColorSamplePhase) => boolean) | null = null; - // Which sink took the current pick's first preview; null between picks. - let colorPickSink: 'request' | 'router' | null = null; - // Shift-click line starts: where the last brush or eraser stroke ended on each layer. Engine-lifetime memory, - // kept across tool switches and forgotten on replay, document replacement and layer removal. - const paintLineEnds = new Map(); - /** - * A transform requested on pixels persistence has not acknowledged yet (a stroke's first upload) waits in - * `pendingTransformLayerId`: sessions size their frame from the persisted source, so the session opens once the - * bitmap lands, if the layer is still the selection under the transform tool (or a hold over it) with no session - * open. Otherwise the wait ends there; a genuine tool switch, Escape, document replacement and a failed upload of - * that layer end it too. - */ - const openDeferredTransform = (): void => { - const layerId = stores.pendingTransformLayerId.get(); - if (layerId === null) { - return; - } - const document = mirror.getDocument(); - const layer = getDocumentLayer(document, layerId); - // A Space, Alt or C hold only lends the surface to another tool; the user is still on transform. - const toolId = pipeline.heldRestoreToolId() ?? interactionController.getActiveToolId(); - if ( - !document || - !layer || - document.selectedLayerId !== layerId || - toolId !== 'transform' || - stores.transformSession.get() !== null - ) { - stores.pendingTransformLayerId.set(null); - } else if (isTransformEligible(layer, document)) { - stores.pendingTransformLayerId.set(null); - beginTransformSession(layerId); - } - }; - /** A pending transform whose pixels could not be persisted will not open; say so instead of waiting silently. */ - const failPendingTransform = (layerId: string): void => { - if (stores.pendingTransformLayerId.get() === layerId) { - stores.pendingTransformLayerId.set(null); - reportEditRefusal('not-ready'); - } - }; + 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(), @@ -1192,24 +1162,7 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC openTextEdit: (layerId) => openTextEdit(layerId), paintLineEnds, sampleProviders: colorSampleProviders, - resolveColorSample: (hex, phase) => { - // A pick belongs to whichever sink 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 router's pick into the request. - if (phase === 'preview') { - colorPickSink ??= pendingColorSample ? 'request' : 'router'; - } - const sink = colorPickSink; - if (phase !== 'preview') { - colorPickSink = null; - } - if (sink === 'request') { - if (phase === 'commit' && pendingColorSample) { - settleColorSample(hex, true); - } - return true; - } - return sink === 'router' ? (colorSampleRouter?.(hex, phase) ?? false) : false; - }, + resolveColorSample: (hex, phase) => colorSamples.route(hex, phase), setLayerTransformOverride: (layerId, override) => { if (override) { transformOverrides.set(layerId, override); @@ -1311,7 +1264,12 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC projectId, }); - const { getOrStartLayerRasterization } = createLayerRasterizer({ + const { + dispose: disposeLayerRasterizer, + getOrStartLayerRasterization, + hasFailed: hasFailedRasterization, + isBackingOff: isRasterizationBackingOff, + } = createLayerRasterizer({ createSurface: (width, height) => backend.createSurface(width, height), fontLoader, getDocument: () => mirror.getDocument(), @@ -1412,7 +1370,10 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC const prepareLayerRasterCache = async (layerId: string) => { const result = await rasterizeLayerPixels(layerId, { includeDisabled: true }); 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 === 'unreadable' ? result.status : ('not-ready' as const), + }; } const layer = result.guard.layer; result.release(); @@ -1434,7 +1395,7 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC const exportBakedLayerBlob = rasterExportController.blob.bind(rasterExportController); type StructuralExportLayerPixelsResult = | Extract - | { status: 'missing' | 'disabled' | 'unsupported' | 'empty' | 'not-ready' | 'over-budget' } + | { status: 'missing' | 'disabled' | 'unsupported' | 'empty' | 'not-ready' | 'unreadable' | 'over-budget' } | AdjustmentRefusal; const normalizeStructuralExport = async ( result: Promise @@ -1479,6 +1440,9 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC if (result.status === 'over-budget') { throw new RasterCompositeOverBudgetError(); } + if (result.status === 'unreadable') { + throw new RasterCompositeUnreadableError(layerId); + } throw new Error(`Cannot rasterize layer ${layerId} for export: ${result.status}.`); }; @@ -1565,7 +1529,13 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC previews: renderController.previews, raster: rasterController, hasStagedCandidates, - rasterizeLayer: (layer, doc) => void getOrStartLayerRasterization(layer, doc), + // 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, @@ -1623,7 +1593,7 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC let lastSelectedLayerId: string | null = null; const mirror: DocumentMirror = createDocumentMirror(mutationPort, { onDocumentChanged: () => { - openDeferredTransform(); + 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. @@ -1645,8 +1615,7 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC cleanup.run(() => stores.bboxPreview.set(null)); cleanup.run(() => history.clear()); paintLineEnds.clear(); - cleanup.run(() => stores.transformSession.set(null)); - cleanup.run(() => stores.pendingTransformLayerId.set(null)); + 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)); @@ -1723,6 +1692,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), @@ -1733,7 +1703,6 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC sourceChanged: sourceChanged.has(id), }); if (decision.kind === 'removed') { - paintLineEnds.delete(id); rasterController.deleteThumbnailKey(id); cleanup.run(() => dropLayer(id)); // Removed preview layers must drop decoded pixels and advance decode tokens so late results or restored @@ -1936,10 +1905,7 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC // finished the move. if (!switchOptions?.temporary) { floatingSelection.commit(); - // A genuine switch away also ends a transform still waiting on its pixels. - if (to !== 'transform') { - stores.pendingTransformLayerId.set(null); - } + editingController.transform.onToolSwitch(to); } for (const listener of toolChangeListeners) { listener({ from, temporary: switchOptions?.temporary === true, to }); @@ -1954,53 +1920,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 }; - 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()) { @@ -2011,8 +1942,7 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC cancelTransform(); return; } - if (stores.pendingTransformLayerId.get() !== null) { - stores.pendingTransformLayerId.set(null); + if (editingController.transform.onEscape()) { return; } if (floatingSelection.has()) { @@ -2231,6 +2161,23 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC const entry = layerCache.get(layer.id); return !!entry && !entry.stale && !isCurrentRasterizationJob(layer); }; + /** + * `unreadable` while a failure is recorded for the layer's current source. Once its backoff has ended this also + * tries the image again in the background (a layer off screen or disabled is never redrawn to do so), so an image + * that became available recovers; a retry that fails again only restarts the backoff. + */ + const layerCacheReadinessForOp = (layer: CanvasLayerContract, doc: CanvasDocumentContractV3): LayerCacheReadiness => { + if (isLayerCacheReadyForOp(layer, doc)) { + return 'ready'; + } + if (isCurrentRasterizationJob(layer) || !hasFailedRasterization(layer)) { + return 'not-ready'; + } + if (!isRasterizationBackingOff(layer)) { + scheduleLayerRasterization([layer.id]); + } + return 'unreadable'; + }; const prepareGeneratedPaintCache = (layerId: string, rect: Rect, pixels: RasterSurface) => layerCache.prepareReplacement(layerId, rect, pixels); @@ -2471,13 +2418,6 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC const mergeSelectedRasterLayers = (layerIds: readonly string[]): Promise => layerController.merge.mergeSelected(layerIds); const mergeVisibleRasterLayers = (): Promise => layerController.merge.mergeVisible(); - const canMergeInpaintMasks = (layerIds: readonly string[]): boolean => { - const document = mirror.getDocument(); - return ( - document !== null && - canMergeInpaintMasksIn(compileDocumentLeaves(document), new Set(layerIds), hasExportableLayerContent) - ); - }; const mergeInpaintMasks = (layerIds: readonly string[]): Promise => layerController.merge.mergeInpaintMasks(layerIds); const cropAllLayersToBbox = (): Promise => layerController.crop.cropAll(); @@ -2514,42 +2454,6 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC // paint; Cancel drops previews. Tools and public UI share the session API. const beginTransformSession = (layerId: string): void => editingController.transform.begin(layerId); - /** Opens a session on `layerId` now, whatever tool is active; the context menu and shortcut use it. */ - const beginTransform = (layerId: string): BeginTransformResult => { - const document = mirror.getDocument(); - const leaf = lookupDocumentLeaf(document, layerId); - if (disposed || !document || !leaf) { - return disposed ? 'busy' : 'missing'; - } - if (leaf.effectiveLocked) { - return 'locked'; - } - const persisted = isTransformEligible(leaf.layer, document); - if (!persisted && !isTransformEligible(leaf.layer, document, layerCache.peek(layerId)?.rect)) { - return 'ineligible'; - } - if (interactionLocked || pipeline.isGestureActive() || floatingSelection.has() || !canEditDocument()) { - return 'busy'; - } - // Selecting cancels a session on another layer (see `onSelectionChanged`); entering the tool opens one on the - // selection, and an already active tool is opened explicitly. - if (document.selectedLayerId !== layerId) { - dispatchCanvasMutation({ id: layerId, type: 'setCanvasSelectedLayer' }); - } - setTool('transform'); - if (stores.transformSession.get()?.layerId !== layerId) { - cancelTransform(); - if (persisted) { - beginTransformSession(layerId); - } - // Every refusal is decided above, before the selection or tool changes. A layer whose pixels persistence - // has not acknowledged yet (a stroke's first upload) opens its session once the bitmap lands. - if (stores.transformSession.get()?.layerId !== layerId) { - stores.pendingTransformLayerId.set(layerId); - } - } - return 'started'; - }; const updateTransformSession = (transform: LayerTransform): void => editingController.transform.update(transform); // A framed float is the session — Apply banks it, Cancel abandons it. Only // with no float in flight do these reach the layer transform session. @@ -2658,7 +2562,8 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC return; } disposed = true; - paintLineEnds.clear(); + paintLineEnds.dispose(); + disposeLayerRasterizer(); releaseActiveSnapshots(); rasterController.memory.releaseGeneration(lifecycleGeneration); lifecycleGeneration += 1; @@ -2682,7 +2587,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); @@ -2853,7 +2758,7 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC backend, ctx: mutationContext, exportBaked: (layerId) => exportBakedLayerPixelsForStructural(layerId), - isCacheReady: isLayerCacheReadyForOp, + cacheReadiness: layerCacheReadinessForOp, isGuardCurrent: isLayerExportGuardCurrent, }, crop: { @@ -2863,6 +2768,8 @@ export const createCanvasEngine = (opts: CanvasEngineOptions): CanvasEngineCoreC discardPersisted: (layerId) => bitmapStore.discardLayer(layerId), 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, }, @@ -2879,7 +2786,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) => @@ -2910,7 +2817,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), @@ -3010,24 +2917,15 @@ 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, + beginTransform: (layerId) => editingController.transform.request(layerId), booleanMergeRasterLayers, - canMergeInpaintMasks, cancelTextEdit, cancelTransform, clearMask, diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/exportRasterComposite.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/exportRasterComposite.ts index da754b1d603..4fd3e1d96b9 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/exportRasterComposite.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/exportRasterComposite.ts @@ -13,6 +13,7 @@ import { getBaseRasterContentBounds, planBaseRasterComposite, RasterCompositeOverBudgetError, + RasterCompositeUnreadableError, renderRasterComposite, type RenderRasterCompositeDeps, } from '@workbench/canvas-engine/render/rasterComposite'; @@ -21,7 +22,7 @@ export type RasterCompositeExportRequest = { bounds: 'content' } | { bounds: 're export type RasterCompositeExportResult = | { status: 'ok'; blob: Blob; rect: Rect } - | { status: 'empty' | 'stale' | 'not-ready' | 'over-budget' } + | { status: 'empty' | 'stale' | 'not-ready' | 'unreadable' | 'over-budget' } | AdjustmentRefusal; export interface RasterCompositeExportSnapshot { @@ -75,6 +76,9 @@ export const exportRasterComposite = async ( if (error instanceof RasterCompositeOverBudgetError) { return { status: 'over-budget' }; } + if (error instanceof RasterCompositeUnreadableError) { + return { status: 'unreadable' }; + } if (error instanceof AdjustmentRefusedError) { return adjustmentRefusal(document, error.ownerId, error.reason); } 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..415f0c3459a 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/layerRasterizer.test.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/layerRasterizer.test.ts @@ -6,7 +6,7 @@ import type { RasterSurface } from '@workbench/canvas-engine/render/raster'; import { stacksFrom } from '@workbench/canvas-engine/document-model/documentFixtures.testStub'; 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 +123,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 +278,66 @@ 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.hasFailed(layer)).toBe(true); + + // 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.hasFailed(replaced)).toBe(false); + 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.hasFailed(layer)).toBe(true); + 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('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.hasFailed(layer)).toBe(false); + }); + 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..121ff1a252b 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'; @@ -47,8 +47,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 +65,30 @@ export interface LayerRasterizer { document: CanvasDocumentContractV3, signal?: AbortSignal ): Promise; + /** + * Whether the last rasterization of `layer`'s current source, cache version and document generation failed, so its + * stale cache cannot become ready by waiting. A success or any change to the layer clears it. + */ + hasFailed(layer: CanvasLayerContract): boolean; + /** + * Whether {@link hasFailed} and the failure's 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; +} + +/** What a failed rasterization read; a later failure of the same key is the same failure. */ +interface RasterizationFailure { + 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 +99,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 +222,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,10 +247,31 @@ 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. + // Retrying the same source fails the same way; only its first failure 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, 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) { + try { + deps.reportError('Layer thumbnail rasterization failed', layer.id, error); + } catch { + // Diagnostics must not turn a handled thumbnail failure into a rejection. + } } return 'error'; } finally { @@ -203,5 +282,23 @@ 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 hasFailed = (layer: CanvasLayerContract): boolean => !!currentFailure(layer); + const isBackingOff = (layer: CanvasLayerContract): boolean => !!currentFailure(layer)?.cancelRetry; + + const dispose = (): void => { + for (const failure of backoffs) { + endBackoff(failure); + } + }; + + return { dispose, getOrStartLayerRasterization, hasFailed, isBackingOff }; }; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/rasterSnapshotCapture.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/rasterSnapshotCapture.ts index ade1fc6137a..edd517ce4c6 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/rasterSnapshotCapture.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/rasterSnapshotCapture.ts @@ -154,7 +154,11 @@ export const createRasterSnapshotCapture = (deps: CreateRasterSnapshotCaptureDep if (liveResult.status !== 'ok') { return { status: - liveResult.status === 'aborted' || liveResult.status === 'over-budget' ? liveResult.status : 'not-ready', + liveResult.status === 'aborted' || + liveResult.status === 'over-budget' || + liveResult.status === 'unreadable' + ? liveResult.status + : 'not-ready', }; } const live = liveResult; diff --git a/invokeai/frontend/webv2/src/workbench/canvas-engine/rasterTransactions.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/rasterTransactions.ts index 9f528f4746a..fc18d1ebbca 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/rasterTransactions.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/rasterTransactions.ts @@ -29,9 +29,10 @@ export interface CanvasRasterSnapshot { release(): void; } +/** `unreadable`: 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' | 'unreadable' | 'over-budget' }; export interface CanvasCompositeExecutorDeps { backend: { 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..38e78239259 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterComposite.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/render/rasterComposite.ts @@ -108,6 +108,14 @@ export class RasterCompositeOverBudgetError extends Error { } } +/** Thrown when a contributor's pixels cannot be read (its image is gone or does not decode); export maps it to `unreadable`. */ +export class RasterCompositeUnreadableError extends Error { + constructor(readonly layerId: string) { + super(`The pixels of layer ${layerId} could not be read.`); + this.name = 'RasterCompositeUnreadableError'; + } +} + 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/tools/paintTool.test.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/paintTool.test.ts index 6bac3fcd0e6..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 @@ -930,6 +930,27 @@ describe('Shift-click straight lines', () => { 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 }); 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 a07e086ec07..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. A Shift press on a layer with a remembered stroke end draws a straight segment from it. + * 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(); @@ -268,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(); @@ -309,9 +309,10 @@ export const createPaintTool = (spec: PaintToolSpec): Tool => { thinning: spec.thinning(ctx), tool: spec.id, }); - // Shift continues from the layer's last stroke end: both points take the press pressure, so the segment - // renders as a line rather than collapsing to a tap. - const lineStart = input.modifiers.shift ? ctx.paintLineEnds?.get(target.layerId) : undefined; + // 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(); @@ -342,7 +343,13 @@ export const createPaintTool = (spec: PaintToolSpec): Tool => { } endSession(); if (published) { - ctx.paintLineEnds?.set(activeTarget.layerId, { ...input.documentPoint }); + // 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/tool.ts b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/tool.ts index ba8992bac06..89f5fd35857 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/tool.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-engine/tools/tool.ts @@ -77,7 +77,7 @@ export interface LayerTransformOverride { rotation?: number; } -/** Where the last committed brush or eraser stroke ended, per layer id, in document space. */ +/** 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; @@ -151,8 +151,9 @@ export interface ToolContext { /** Compositor providers that make sampling WYSIWYG; absent ⇒ raw cached pixels. */ sampleProviders?: Pick; /** - * Where the last committed brush or eraser stroke ended on each layer, in document space; shared by both tools for - * Shift-click lines. The engine owns it and forgets entries on replay, document replacement and layer removal. + * 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. */ paintLineEnds?: PaintLineEnds; /** diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/createFromBbox.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/createFromBbox.ts index 2b1245c0a51..1994a1784ec 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-operations/createFromBbox.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/createFromBbox.ts @@ -28,7 +28,7 @@ export type CreateFromBboxDestination = 'global-reference' | CreateFromBboxLayer export type CreateFromBboxResult = | { status: 'created'; destination: CreateFromBboxLayerDestination; layerIds: string[] } | { status: 'uploaded'; image: CanvasImageUploadResult } - | { status: 'empty' | 'stale' | 'not-ready' | 'over-budget' } + | { status: 'empty' | 'stale' | 'not-ready' | 'unreadable' | 'over-budget' } | 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..a366bde8586 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-operations/createFromBboxNotice.test.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/createFromBboxNotice.test.ts @@ -20,6 +20,7 @@ describe('getCreateFromBboxNotice', () => { ['stale', 'info', 'widgets.canvas.contextMenu.stale'], ['not-ready', 'info', 'widgets.canvas.contextMenu.notReady'], ['over-budget', 'info', 'widgets.canvas.contextMenu.notReady'], + ['unreadable', 'error', 'widgets.canvas.contextMenu.unreadable'], ['blocked', 'info', 'widgets.canvas.import.blocked'], ['stale-document', 'error', 'widgets.canvas.import.staleDocument'], ['stale-project', 'error', 'widgets.canvas.import.staleProject'], diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/createFromBboxNotice.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/createFromBboxNotice.ts index dd9349b2e10..4dd3fcc339f 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-operations/createFromBboxNotice.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/createFromBboxNotice.ts @@ -14,7 +14,8 @@ export type CreateFromBboxNotice = | 'widgets.canvas.contextMenu.createdFromBbox' | 'widgets.canvas.contextMenu.empty' | 'widgets.canvas.contextMenu.notReady' - | 'widgets.canvas.contextMenu.stale'; + | 'widgets.canvas.contextMenu.stale' + | 'widgets.canvas.contextMenu.unreadable'; /** `destination` is itself a translation key naming the created entity. */ options?: CanvasImportNotice['options'] | { destination: string }; } @@ -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', 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..ef50c45d518 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-operations/exportCanvasComposite.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/exportCanvasComposite.ts @@ -10,7 +10,7 @@ export type CanvasCompositeRegion = 'canvas' | 'bbox'; export type ExportCanvasCompositeResult = | { status: 'ok'; blob: Blob; rect: Rect } - | { status: 'empty' | 'stale' | 'not-ready' | 'over-budget' } + | { status: 'empty' | 'stale' | 'not-ready' | 'unreadable' | 'over-budget' } | 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..229fdf90219 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-operations/generationComposite.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/generationComposite.ts @@ -128,7 +128,7 @@ export interface GenerationComposites { export type ComposeForGenerationResult = | { status: 'ok'; composites: GenerationComposites; dedupeCommit: GenerationCompositeDedupeCommit } - | { status: 'no-document' | 'stale' | 'aborted' | 'not-ready' | 'over-budget' } + | { status: 'no-document' | 'stale' | 'aborted' | 'not-ready' | 'unreadable' | 'over-budget' } | 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 d31988fcbff..642d538c769 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-operations/operationTypes.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/operationTypes.ts @@ -25,6 +25,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; @@ -77,6 +79,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..ee3049534a5 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-operations/saveCanvasToGallery.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/saveCanvasToGallery.ts @@ -28,7 +28,7 @@ export type CanvasGallerySaveRegion = CanvasCompositeRegion; 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' } + | { status: 'empty' | 'stale' | 'not-ready' | 'unreadable' | 'over-budget' } | AdjustmentRefusal; const buildCanvasSaveMetadata = (project: Project, rect: Rect): Record => { diff --git a/invokeai/frontend/webv2/src/workbench/canvas-operations/selectObjectSession.ts b/invokeai/frontend/webv2/src/workbench/canvas-operations/selectObjectSession.ts index 55fbe3031b7..e70977f5479 100644 --- a/invokeai/frontend/webv2/src/workbench/canvas-operations/selectObjectSession.ts +++ b/invokeai/frontend/webv2/src/workbench/canvas-operations/selectObjectSession.ts @@ -136,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' }; }; 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 index f192dfa7fcb..915c97e0c6f 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasCropAllLayersMenuItem.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasCropAllLayersMenuItem.tsx @@ -7,26 +7,33 @@ import { CropIcon } from 'lucide-react'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { cropAllLayersToFrame, hasCroppableLayers } from './canvasContextMenu'; +import { cropAllLayersToFrame, getCropAllUnavailableReasonKey } from './canvasContextMenu'; -/** Canvas-menu action that crops every unlocked layer to the frame as one undo step and reports the count. */ +/** + * 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 = ({ - disabled, + interactionLocked, layers, }: { - disabled: boolean; + /** The canvas is generating, has staged results, or is applying an edit. */ + interactionLocked: boolean; layers: Pick; }) => { const { t } = useTranslation(); const notify = useNotify(); - const hasCroppable = useActiveProjectSelector((project) => hasCroppableLayers(project.canvas.document)); + 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/CanvasWidgetView.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasWidgetView.tsx index f82bbb9139d..3b3ce1c630a 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasWidgetView.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/CanvasWidgetView.tsx @@ -41,12 +41,7 @@ import { CanvasCreateFromBboxSubmenu } from './CanvasCreateFromBboxSubmenu'; import { CanvasCropAllLayersMenuItem } from './CanvasCropAllLayersMenuItem'; import { CanvasEditRefusalNotices } from './CanvasEditRefusalNotices'; import { CanvasGlobalContextMenu } from './CanvasGlobalContextMenu'; -import { - CANVAS_COMPANION_HOTKEYS, - CANVAS_ONLY_HOTKEYS, - executeCanvasHotkeyCommand, - reportTransformRefusal, -} from './canvasHotkeyCommands'; +import { CANVAS_ONLY_HOTKEYS, executeCanvasHotkeyCommand } from './canvasHotkeyCommands'; import { resolveCanvasImageDrop } from './canvasImageDnd'; import { CanvasImageDropOverlay } from './CanvasImageDropOverlay'; import { createCanvasInteractionLockReader, getCanvasInteractionCapabilities } from './canvasInteractionLock'; @@ -59,6 +54,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'; @@ -497,16 +493,16 @@ export const CanvasWidgetView = ({ runtime, instance }: WidgetViewProps) => { () => , [createFromBbox, isCompositeMenuDisabled] ); - const isCropAllDisabled = isInteractionLocked || interactionCapabilities.isDocumentEditingLocked; + const isCropAllLocked = isInteractionLocked || interactionCapabilities.isDocumentEditingLocked; const compositeSubmenus = useMemo( () => ( <> {saveToGallerySubmenu} {createFromBboxSubmenu} - {engine ? : null} + {engine ? : null} ), - [createFromBboxSubmenu, engine, isCropAllDisabled, saveToGallerySubmenu] + [createFromBboxSubmenu, engine, isCropAllLocked, saveToGallerySubmenu] ); const canvasSurface = useMemo( () => (engine ? : null), diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasContextMenu.test.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasContextMenu.test.ts index d8227bd1e1d..1869b2c3f36 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasContextMenu.test.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasContextMenu.test.ts @@ -11,7 +11,7 @@ import { describe, expect, it, vi } from 'vitest'; import { cropAllLayersToFrame, - hasCroppableLayers, + getCropAllUnavailableReasonKey, resolveCanvasContextMenu, resolveCanvasContextMenuBranch, } from './canvasContextMenu'; @@ -89,29 +89,31 @@ describe('resolveCanvasContextMenu', () => { }); }); -describe('hasCroppableLayers', () => { +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(hasCroppableLayers(documentWith([layerContract('mask', 'inpaint_mask', { isEnabled: false })]))).toBe(true); - expect(hasCroppableLayers(documentWith([groupContract('G', [layerContract('region', 'regional_guidance')])]))).toBe( - true - ); + expect( + getCropAllUnavailableReasonKey(documentWith([layerContract('mask', 'inpaint_mask', { isEnabled: false })])) + ).toBeNull(); + expect( + getCropAllUnavailableReasonKey(documentWith([groupContract('G', [layerContract('region', 'regional_guidance')])])) + ).toBeNull(); }); - it('has nothing to crop when every leaf is locked by itself or by a group above it', () => { - expect(hasCroppableLayers(documentWith([]))).toBe(false); + 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( - hasCroppableLayers( + getCropAllUnavailableReasonKey( documentWith([ layerContract('locked', 'raster', { isLocked: true }), groupContract('G', [layerContract('inside', 'control')], { isLocked: true }), ]) ) - ).toBe(false); + ).toBe('widgets.canvas.contextMenu.cropAllAllLocked'); }); }); @@ -129,22 +131,43 @@ describe('cropAllLayersToFrame', () => { }; it.each([ - [{ cropped: 1, skippedLocked: 0 }, ['Cropped 1 layer to the frame', undefined]], - [{ cropped: 4, skippedLocked: 1 }, ['Cropped 4 layers to the frame', '1 locked layer skipped']], - [{ cropped: 2, skippedLocked: 3 }, ['Cropped 2 layers to the frame', '3 locked layers skipped']], - ])('reports %o as cropped, with the locked layers it skipped', async (counts, expected) => { + [{ cropped: 1, skippedLocked: 0, skippedUnreadable: 0 }, ['Cropped 1 layer to the frame', undefined]], + [ + { cropped: 4, skippedLocked: 1, skippedUnreadable: 0 }, + ['Cropped 4 layers to the frame', '1 locked layer skipped.'], + ], + [ + { cropped: 2, skippedLocked: 3, skippedUnreadable: 0 }, + ['Cropped 2 layers to the frame', '3 locked layers skipped.'], + ], + [ + { cropped: 2, skippedLocked: 1, skippedUnreadable: 2 }, + [ + 'Cropped 2 layers to the frame', + '1 locked layer skipped. 2 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, status: 'nothing' })).info).toEqual([ + expect((await notices({ skippedLocked: 0, skippedUnreadable: 0, status: 'nothing' })).info).toEqual([ ['Every layer already fits the frame', undefined], ]); - expect((await notices({ skippedLocked: 2, status: 'nothing' })).info).toEqual([ - ['Every unlocked layer already fits the frame', '2 locked layers skipped'], + expect((await notices({ skippedLocked: 2, skippedUnreadable: 0, 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: 1, status: 'nothing' })).toEqual({ + error: [['No layer was cropped', '1 layer 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', diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasContextMenu.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasContextMenu.ts index c5573ba4fd4..bdc3eb7c013 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasContextMenu.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasContextMenu.ts @@ -59,15 +59,24 @@ export const resolveCanvasContextMenu = ({ }; }; -/** Whether Crop all layers to frame has a leaf to act on: one not locked by itself or a group above it. */ -export const hasCroppableLayers = (document: CanvasDocumentContractV3): boolean => { +/** + * 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); - return leaves.some((leaf) => !leaf.isLocked && !byId.get(leaf.id)?.ancestorsLocked); + 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, or that nothing needed cropping. The canvas menu and the command share it. + * many locked or unreadable ones it left as they were, or that nothing needed cropping. The canvas menu and the + * command share it. */ export const cropAllLayersToFrame = async ( layers: Pick, @@ -84,12 +93,19 @@ export const cropAllLayersToFrame = async ( switch (result.status) { case 'cropped': case 'nothing': { + const { skippedLocked, skippedUnreadable } = result; const skipped = - result.skippedLocked > 0 - ? t('widgets.canvas.contextMenu.croppedAllLayersSkipped', { count: result.skippedLocked }) - : undefined; + [ + skippedLocked > 0 ? t('widgets.canvas.contextMenu.croppedAllLayersSkipped', { count: skippedLocked }) : '', + skippedUnreadable > 0 ? t('widgets.canvas.contextMenu.cropAllUnreadable', { count: skippedUnreadable }) : '', + ] + .filter(Boolean) + .join(' ') || undefined; if (result.status === 'cropped') { notify.success(t('widgets.canvas.contextMenu.croppedAllLayers', { count: result.cropped }), skipped); + } else if (skippedUnreadable > 0) { + // 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( 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 6d57e1e9edb..31cbc7062a4 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHotkeyCommands.test.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHotkeyCommands.test.ts @@ -18,14 +18,8 @@ import { createControlLayer } from '@workbench/widgets/layers/layerOps'; import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; import { CANVAS_HEADER_HOTKEYS } from './canvasHeaderCommands'; -import { - CANVAS_COMPANION_HOTKEYS, - CANVAS_ONLY_HOTKEYS, - executeCanvasHotkeyCommand, - executeCanvasToolCommand, - reportTransformRefusal, - type CanvasHotkeyContext, -} from './canvasHotkeyCommands'; +import { CANVAS_ONLY_HOTKEYS, executeCanvasHotkeyCommand, type CanvasHotkeyContext } from './canvasHotkeyCommands'; +import { CANVAS_COMPANION_HOTKEYS, executeCanvasToolCommand, reportTransformRefusal } from './canvasToolCommands'; const rasterLayer = (id: string, overrides: Partial = {}): CanvasLayerContract => ({ diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHotkeyCommands.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHotkeyCommands.ts index db6b696b488..1664cf798a3 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHotkeyCommands.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/canvasHotkeyCommands.ts @@ -1,5 +1,4 @@ import type { - BeginTransformResult, AdjustmentRefusal, CanvasDocumentContractV3, CanvasEngine, @@ -7,7 +6,6 @@ import type { StructuralCommitResult, } from '@workbench/canvas-engine/api'; import type { CanvasProjectMutationDispatch } from '@workbench/useCanvasProjectMutationDispatch'; -import type { TFunction } from 'i18next'; import { compileDocumentLeaves, @@ -17,9 +15,15 @@ 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, - reportLayerOperation, type LayerOperationRefusal, type PreparedCommitOutcome, } from '@workbench/widgets/canvas/useStructuralCommit'; @@ -33,37 +37,6 @@ import { } from '@workbench/widgets/layers/layerGroupCommands'; import { canMergeLayerDown } from '@workbench/widgets/layers/layerOps'; -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. - */ -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[]; - /** Keys that act only while the Canvas itself holds focus; rows and fields own them elsewhere. */ export const CANVAS_ONLY_HOTKEYS = [ ...CANVAS_HISTORY_HOTKEYS, @@ -104,139 +77,6 @@ export const CANVAS_ONLY_HOTKEYS = [ ['canvas.cropAllLayersToFrame', 'widgets.canvas.contextMenu.cropAllLayersToBbox', []], ] 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); - } -}; - /** Command id → document-space nudge delta (shift variants are ×10). */ const NUDGE_DELTAS: Record = { 'canvas.nudgeDown': { dx: 0, dy: 1 }, 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/invoke/prepareCanvasInvocation.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.ts index ced828a3c9c..3dd32eae35a 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/invoke/prepareCanvasInvocation.ts @@ -360,7 +360,9 @@ 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' + ? "A layer's image could not be read (it may have been deleted or damaged). Disable or delete that layer, 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 dfe0a67d84d..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, }; 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 75977842725..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', }; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/SamOptions.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/SamOptions.tsx index 1e6f80469f3..2619160d20a 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/SamOptions.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/SamOptions.tsx @@ -87,6 +87,7 @@ const SAM_ERROR_TRANSLATION_KEYS: Record = { queue: 'widgets.layers.selectObject.errorQueue', reconcile: 'widgets.layers.selectObject.errorReconcile', unknown: 'widgets.layers.selectObject.errorUnknown', + unreadable: 'widgets.layers.selectObject.errorUnreadable', upload: 'widgets.layers.selectObject.errorUpload', }; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/useCanvasGallerySave.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/useCanvasGallerySave.browser.test.tsx index 22eb49b7c18..7aa1085a4ad 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/useCanvasGallerySave.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/useCanvasGallerySave.browser.test.tsx @@ -16,6 +16,8 @@ import { useCanvasGallerySave } from './useCanvasGallerySave'; const mocks = vi.hoisted(() => ({ activeProjectId: 'project-1', findGalleryItem: vi.fn(), + notifyError: vi.fn(), + notifyInfo: vi.fn(), reportError: vi.fn(), saveCanvasToGallery: vi.fn<(options: { region: CanvasGallerySaveRegion }) => Promise>(), })); @@ -31,7 +33,9 @@ vi.mock('@workbench/WorkbenchContext', () => ({ }), })); vi.mock('@workbench/image-actions/useFindGalleryItem', () => ({ useFindGalleryItem: () => mocks.findGalleryItem })); -vi.mock('@workbench/useNotify', () => ({ useNotify: () => ({ error: vi.fn(), info: vi.fn(), success: vi.fn() }) })); +vi.mock('@workbench/useNotify', () => ({ + useNotify: () => ({ error: mocks.notifyError, info: mocks.notifyInfo, success: vi.fn() }), +})); vi.mock('@features/gallery/queries', async (importOriginal) => ({ ...(await importOriginal>()), // The auto-add board, B, as the Gallery's board listing names it. @@ -134,6 +138,19 @@ describe('useCanvasGallerySave across surfaces', () => { await act(() => menu.current!.save('canvas')); expect(mocks.saveCanvasToGallery).toHaveBeenCalledTimes(2); }); + it('says a layer could not be read rather than that the canvas is still preparing', async () => { + mocks.saveCanvasToGallery.mockResolvedValueOnce({ status: 'unreadable' }); + + await act(() => header.current!.save('canvas')); + + expect(mocks.notifyError).toHaveBeenCalledWith( + 'widgets.canvas.contextMenu.saveError', + 'widgets.canvas.contextMenu.unreadable' + ); + expect(mocks.notifyInfo).not.toHaveBeenCalled(); + expect(mocks.reportError).not.toHaveBeenCalled(); + }); + it.each([ [ 'refuses the board (403)', diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/useCanvasGallerySave.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/useCanvasGallerySave.ts index 7fff4fbafdd..b0f5acb1ec0 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/useCanvasGallerySave.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/useCanvasGallerySave.ts @@ -90,6 +90,8 @@ export const useCanvasGallerySave = ( notify.info(t('widgets.canvas.contextMenu.stale')); } else if (result.status === 'adjustment-refused') { notify.error(t('widgets.canvas.contextMenu.saveError'), describeAdjustmentRefusal(result, t)); + } else if (result.status === 'unreadable') { + notify.error(t('widgets.canvas.contextMenu.saveError'), t('widgets.canvas.contextMenu.unreadable')); } else { notify.info(t('widgets.canvas.contextMenu.notReady')); } diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/useStructuralCommit.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/useStructuralCommit.ts index 70a6a55fbaa..09d2fe82ee0 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/useStructuralCommit.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/useStructuralCommit.ts @@ -29,6 +29,7 @@ export type LayerOperationRefusal = | 'not-ready' | 'over-budget' | 'stale' + | 'unreadable' | 'unsupported'; const LAYER_OPERATION_KEYS: Record = { @@ -40,6 +41,7 @@ const LAYER_OPERATION_KEYS: Record = { 'not-ready': 'widgets.layers.actions.notReady', 'over-budget': 'widgets.layers.actions.overBudget', stale: 'widgets.layers.actions.stale', + unreadable: 'widgets.layers.actions.unreadable', unsupported: 'widgets.layers.actions.unsupported', }; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/layers/LayerContextMenu.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/widgets/layers/LayerContextMenu.browser.test.tsx new file mode 100644 index 00000000000..98d3be391ce --- /dev/null +++ b/invokeai/frontend/webv2/src/workbench/widgets/layers/LayerContextMenu.browser.test.tsx @@ -0,0 +1,242 @@ +/* oxlint-disable react-perf/jsx-no-new-function-as-prop, react-perf/jsx-no-new-object-as-prop */ +import type { CanvasLayerContract, CanvasNodeContract } from '@workbench/canvas-engine/api'; +import type { CanvasEngine } from '@workbench/canvas-operations/createCanvasEngine'; +import type { Project } from '@workbench/projectContracts'; +import type * as WorkbenchContextModule from '@workbench/WorkbenchContext'; + +import { ChakraProvider } from '@chakra-ui/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { system } from '@theme/system'; +import { getDocumentIndex, getDocumentLeaves } from '@workbench/canvas-engine/api'; +import { + groupContract, + layerContract, + stacksFrom, +} from '@workbench/canvas-engine/document-model/documentFixtures.testStub'; +import { createCanvasEngine } from '@workbench/canvas-operations/createCanvasEngine'; +import { createCanvasProjectMutationPort } from '@workbench/canvasProjectMutationPort'; +import { clearLayerPanelStates, publishLayerPanelSelection } from '@workbench/layerPanelState'; +import { createInitialWorkbenchState } from '@workbench/workbenchState'; +import { createWorkbenchStore } from '@workbench/workbenchStore'; +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'; + +/** The menu reads the real store and drives a real engine over real decoded pixels. */ +const harness = vi.hoisted(() => ({ + engine: null as CanvasEngine | null, + store: null as ReturnType | null, +})); +const activeProject = () => harness.store!.getState().projects[0]!; + +vi.mock('@workbench/WorkbenchContext', async (importOriginal) => ({ + ...(await importOriginal()), + useActiveProjectId: () => activeProject().id, + useActiveProjectSelector: (selector: (project: Project) => unknown) => selector(activeProject()), + useOptionalWorkbenchCommands: () => harness.store!.commands, + useWorkbenchCommands: () => harness.store!.commands, +})); + +import { CanvasLayerContextMenu } from './LayerContextMenu'; + +const i18n = createInstance(); +let root: Root | null = null; +let host: HTMLDivElement | null = null; +let pixels: Blob; +(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 } } }); + const canvas = new OffscreenCanvas(16, 16); + const context = canvas.getContext('2d')!; + context.fillStyle = '#fff'; + context.fillRect(0, 0, 16, 16); + pixels = await canvas.convertToBlob({ type: 'image/png' }); +}); + +afterEach(async () => { + await act(() => root?.unmount()); + host?.remove(); + harness.engine?.lifecycle.dispose(); + Object.assign(harness, { engine: null, store: null }); + clearLayerPanelStates(); + root = null; + host = null; +}); + +const image = (name: string) => ({ height: 16, imageName: `${name}.png`, width: 16 }); +const imageLayer = (id: string): CanvasLayerContract => + layerContract(id, 'raster', { + name: id, + source: { image: image(id), type: 'image' }, + } as Partial); +const maskLayer = (id: string, overrides: Partial = {}): CanvasLayerContract => + layerContract(id, 'inpaint_mask', { + mask: { bitmap: image(id), fill: { color: '#e45050', style: 'solid' }, offset: { x: 0, y: 0 } }, + name: id, + ...overrides, + } as Partial); + +/** + * Renders the Layers panel's layer menu over a real engine for `nodes`, with `selectedIds` selected in the panel (the + * first as primary), returning a way to open it on a layer. Images named in `unreadable` fail to load. + */ +const renderMenu = async ( + nodes: CanvasNodeContract[], + selectedIds: readonly string[] = [], + unreadable: readonly string[] = [] +) => { + const initial = createInitialWorkbenchState(); + const base = initial.projects[0]!; + const store = createWorkbenchStore({ + ...initial, + projects: [ + { + ...base, + canvas: { + ...base.canvas, + document: { ...base.canvas.document, selectedLayerId: selectedIds[0] ?? null, stacks: stacksFrom(nodes) }, + }, + }, + ], + }); + const projectId = store.getState().activeProjectId; + harness.store = store; + publishLayerPanelSelection({ primaryId: selectedIds[0] ?? null, projectId, selectedIds }); + const engine = createCanvasEngine({ + ensureProjectOnServer: () => Promise.resolve(), + imageResolver: (imageName) => + unreadable.includes(imageName) ? Promise.reject(new Error('image deleted')) : Promise.resolve(pixels), + mutationPort: createCanvasProjectMutationPort(store, projectId), + projectId, + reportError: () => undefined, + }); + harness.engine = engine; + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + const queryClient = new QueryClient(); + const render = (layerId: string | null) => + act(() => + root!.render( + + + + void render(null)} + /> + + + + ) + ); + await render(null); + const openMenu = async (layerId: string) => { + await render(layerId); + await expect.element(page.getByRole('menu')).toBeVisible(); + }; + const document_ = () => store.getState().projects[0]!.canvas.document; + const notices = () => store.getState().notifications.map(({ message, title }) => [title, message]); + return { document: document_, engine, notices, openMenu }; +}; + +const item = (name: string) => page.getByRole('menuitem', { exact: true, name }); + +describe('Layers context menu over a real engine', () => { + it('opens a transform session on the chosen layer while the Transform tool is already active', async () => { + const { engine, openMenu } = await renderMenu([imageLayer('top'), imageLayer('art')]); + engine.tools.setTool('transform'); + expect(engine.stores.transformSession.get()).toBeNull(); + + await openMenu('art'); + await act(() => item('Transform').click()); + + await vi.waitFor(() => expect(engine.stores.transformSession.get()?.layerId).toBe('art')); + expect(engine.document.getDocument()?.selectedLayerId).toBe('art'); + }); + + it('merges two selected masks into one', async () => { + const { document, engine, openMenu } = await renderMenu([maskLayer('m1'), maskLayer('m2')], ['m1', 'm2']); + await vi.waitFor(() => expect(['m1', 'm2'].every((id) => engine.exports.hasExportableLayerContent(id))).toBe(true)); + + await openMenu('m1'); + await act(() => item('Merge masks').click()); + + await vi.waitFor(() => + expect(getDocumentLeaves(document()).map((layer) => [layer.type, layer.name])).toEqual([ + ['inpaint_mask', 'm1 merged'], + ]) + ); + expect(engine.history.getEntries().past).toEqual(['Merge masks']); + }); + + it('removes a layer from its group as one undo step', async () => { + const { document, engine, openMenu } = await renderMenu([ + groupContract('G', [imageLayer('child'), imageLayer('sibling')]), + ]); + const parentOf = (id: string) => getDocumentIndex(document()).byId.get(id)?.parentId; + + await openMenu('child'); + await act(() => item('Remove from group').click()); + + await vi.waitFor(() => expect(parentOf('child')).toBeNull()); + expect(engine.history.getEntries().past).toHaveLength(1); + await act(() => engine.history.undo()); + expect(parentOf('child')).toBe('G'); + expect(engine.history.getEntries().past).toHaveLength(0); + }); + + it.each([ + ['Crop layer to frame', ['gone'], []], + ['Merge selected layers', ['gone', 'kept'], ['kept']], + ] as const)( + 'says %s could not read a missing image, never that it is still loading', + async (label, ids, readable) => { + const { document, notices, openMenu } = await renderMenu( + ids.map(imageLayer), + ids, + ids.filter((id) => !(readable as readonly string[]).includes(id)).map((id) => `${id}.png`) + ); + const before = document(); + + await openMenu('gone'); + await act(() => item(label).click()); + + await vi.waitFor(() => + expect(notices()).toContainEqual([ + 'Layer action failed', + "A layer's image could not be read (it may have been deleted or damaged), so nothing was changed.", + ]) + ); + expect(notices().flat()).not.toContain('The layer content is still loading. Try again shortly.'); + expect(document().stacks).toBe(before.stacks); + } + ); + + it('keeps an unavailable Merge masks reachable by keyboard and says why', async () => { + const { engine, openMenu } = await renderMenu( + [maskLayer('m1'), maskLayer('m2', { noise: { isEnabled: true, level: 0.5 } } as Partial)], + ['m1', 'm2'] + ); + const reason = 'The masks have different noise or denoise settings. Match them to merge.'; + + await openMenu('m1'); + const merge = document.querySelector('[role="menuitem"][data-value="merge-masks"]')!; + expect(merge).toHaveAttribute('aria-disabled', 'true'); + for (let step = 0; step < 30 && !merge.hasAttribute('data-highlighted'); step += 1) { + await userEvent.keyboard('{ArrowDown}'); + } + expect(merge).toHaveAttribute('data-highlighted'); + await expect.element(page.getByRole('tooltip')).toHaveTextContent(reason); + expect(document.getElementById(merge.getAttribute('aria-describedby')!)).toHaveTextContent(reason); + + await userEvent.keyboard('{Enter}'); + expect(getDocumentLeaves(engine.document.getDocument()!).map((layer) => layer.id)).toEqual(['m1', 'm2']); + }); +}); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/layers/LayerContextMenu.tsx b/invokeai/frontend/webv2/src/workbench/widgets/layers/LayerContextMenu.tsx index 356a4e76b24..114dc91b2cb 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/layers/LayerContextMenu.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/layers/LayerContextMenu.tsx @@ -26,6 +26,7 @@ import { getDocumentIndex, getDocumentLayer, getSourceContentRect, + inpaintMaskMergeEligibility, isAdjustmentRefusal, isGroupNode, isHideableLayer, @@ -40,8 +41,8 @@ import { ShortcutKeycaps } from '@workbench/hotkeys/keyGlyphs'; import { formatHotkeyForPlatform } from '@workbench/hotkeys/keys'; import { publishLayerPanelSelection, readLayerPanelState, useLayerPanelState } from '@workbench/layerPanelState'; import { useNotify } from '@workbench/useNotify'; -import { reportTransformRefusal } from '@workbench/widgets/canvas/canvasHotkeyCommands'; import { isCanvasInteractionLocked } from '@workbench/widgets/canvas/canvasInteractionLock'; +import { reportTransformRefusal } from '@workbench/widgets/canvas/canvasToolCommands'; import { useCanvasDocumentEditingLocked, useCanvasEngineRead, @@ -165,6 +166,7 @@ type LayerActionErrorStatus = | 'missing' | 'not-ready' | 'over-budget' + | 'unreadable' | 'unsupported'; const LAYER_ACTION_ERROR_KEYS: Record = { @@ -177,6 +179,7 @@ const LAYER_ACTION_ERROR_KEYS: Record = { missing: 'widgets.layers.actions.missing', 'not-ready': 'widgets.layers.actions.notReady', 'over-budget': 'widgets.layers.actions.overBudget', + unreadable: 'widgets.layers.actions.unreadable', unsupported: 'widgets.layers.actions.unsupported', }; @@ -308,10 +311,14 @@ const LayerMenu = ({ ) ); }); - const canMergeMasks = useCanvasEngineRead( - engine, - () => !!engine && targets.length > 1 && engine.layers.canMergeInpaintMasks(targets) - ); + const mergeMasksEligibility = useCanvasEngineRead(engine, () => { + const model = engine?.document.model() ?? null; + return engine && model + ? inpaintMaskMergeEligibility(model.compileLeaves(), new Set(targets), (layerId) => + engine.exports.hasExportableLayerContent(layerId) + ) + : 'ineligible'; + }); const canRemoveTargetsFromGroup = useCanvasEngineRead(engine, () => canRemoveFromGroup(engine?.document.model() ?? null, targets) ); @@ -326,7 +333,6 @@ const LayerMenu = ({ () => ({ canDeleteSelection, canGroupSelection: canGroup, - canMergeMasks, canMergeSelection: canMerge, canRemoveFromGroup: canRemoveTargetsFromGroup, hiddenByAncestor, @@ -338,6 +344,7 @@ const LayerMenu = ({ hasWorkflowBindings: workflowAvailability.hasWorkflowBindings, interactionLocked, layer, + mergeMasksEligibility, modelBase: base, selectedIds, }), @@ -348,13 +355,13 @@ const LayerMenu = ({ canDeleteSelection, canGroup, canMerge, - canMergeMasks, canRemoveTargetsFromGroup, document, engine, hasSupportedContent, interactionLocked, layer, + mergeMasksEligibility, selectedIds, workflowAvailability.canRunWorkflow, workflowAvailability.hasWorkflowBindings, @@ -438,6 +445,8 @@ const LayerMenu = ({ reportLayerOperation(result, notify.error, t); } else if (result.status === 'ineligible') { notify.error(t('widgets.layers.actions.actionFailed'), t('widgets.layers.actions.mergeMasksIneligible')); + } else if (result.status === 'settings-differ') { + notify.error(t('widgets.layers.actions.actionFailed'), t('widgets.layers.actions.mergeMasksSettingsDiffer')); } else if (result.status !== 'merged') { reportLayerOperation(result.status, notify.error, t); } @@ -452,6 +461,8 @@ const LayerMenu = ({ notify.error(t('widgets.layers.actions.actionFailed'), describeAdjustmentRefusal(result, t)); } else if (result === 'not-ready') { notify.error(t('widgets.layers.actions.actionFailed'), t('widgets.layers.groupActions.mergeNotReady')); + } else if (result === 'unreadable') { + notify.error(t('widgets.layers.actions.actionFailed'), t('widgets.layers.actions.unreadable')); } else if (result === 'over-budget') { notify.error(t('widgets.layers.actions.actionFailed'), t('widgets.layers.groupActions.mergeOverBudget')); } else if (result === 'failed') { @@ -715,8 +726,11 @@ const LayerMenu = ({ case 'locked': case 'empty': case 'not-ready': + case 'unreadable': case 'over-budget': throw makeStatusError(result.status); + case 'outside-frame': + throw new Error(t('widgets.layers.actions.cropOutsideFrame')); case 'adjustment-refused': throw makeStatusError(result); case 'busy': @@ -1381,11 +1395,16 @@ const LayerMenuSubmenu = ({ type LayerMenuTranslate = (key: string, options: { count?: number; defaultValue: string }) => string; -/** Copying a layer needs the clipboard, which a plain-HTTP origin lacks; the item says so instead of failing. */ +/** + * Why the item cannot run: the registry's reason for a disabled action, or, since copying a layer needs the clipboard, + * which a plain-HTTP origin lacks, that. The item stays reachable by keyboard and says so instead of failing. + */ const getUnavailableReason = (action: LayerContextAction, t: LayerMenuTranslate): string | undefined => action.id === 'copy-to-clipboard' && !canCopyImage() ? t('common.copyUnavailable', { defaultValue: 'Copying needs HTTPS or localhost' }) - : undefined; + : action.unavailableReasonKey + ? t(action.unavailableReasonKey, { defaultValue: '' }) || undefined + : undefined; const LayerMenuIconActionItem = ({ action, diff --git a/invokeai/frontend/webv2/src/workbench/widgets/layers/LayersTree.tsx b/invokeai/frontend/webv2/src/workbench/widgets/layers/LayersTree.tsx index bcd23420841..e1a62598083 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/layers/LayersTree.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/layers/LayersTree.tsx @@ -514,7 +514,13 @@ export const LayersTree = ({ void copyLayerToClipboard(key, { exportLayer: currentEngine.exports.exportBakedLayerBlob }).then( (status) => { if (status !== 'ok') { - copyFailed(isAdjustmentRefusal(status) ? describeAdjustmentRefusal(status, t) : undefined); + copyFailed( + isAdjustmentRefusal(status) + ? describeAdjustmentRefusal(status, t) + : status === 'unreadable' + ? t('widgets.layers.actions.unreadable') + : undefined + ); } }, () => copyFailed() diff --git a/invokeai/frontend/webv2/src/workbench/widgets/layers/RunLayerWorkflowDialog.tsx b/invokeai/frontend/webv2/src/workbench/widgets/layers/RunLayerWorkflowDialog.tsx index 9f2fdcb2280..e486dfcf505 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/layers/RunLayerWorkflowDialog.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/layers/RunLayerWorkflowDialog.tsx @@ -178,6 +178,7 @@ const FAILURE_KEYS: Record< 'not-ready': 'widgets.layers.runWorkflow.notReady', 'over-budget': 'widgets.layers.runWorkflow.overBudget', stale: 'widgets.layers.runWorkflow.stale', + unreadable: 'widgets.layers.runWorkflow.unreadable', unsupported: 'widgets.layers.runWorkflow.unsupported', }; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/layers/layerContextActions.test.ts b/invokeai/frontend/webv2/src/workbench/widgets/layers/layerContextActions.test.ts index 9fcdb780c05..adccdeaebb3 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/layers/layerContextActions.test.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/layers/layerContextActions.test.ts @@ -85,7 +85,7 @@ const makeState = ( interactionLocked: false, canGroupSelection: true, canDeleteSelection: true, - canMergeMasks: true, + mergeMasksEligibility: 'eligible', canMergeSelection: true, canRemoveFromGroup: true, layer, @@ -205,6 +205,10 @@ describe('layer context action registry', () => { 'widgets.layers.actions.cropUnsupported', 'widgets.layers.actions.cropped', 'widgets.layers.actions.mergeMasksIneligible', + 'widgets.layers.actions.mergeMasksSettingsDiffer', + 'widgets.layers.actions.canvasNotReady', + 'widgets.layers.actions.interactionLocked', + 'widgets.layers.actions.removeFromGroupLocked', 'widgets.layers.actions.transformIneligible', 'widgets.layers.rasterFilter.busy', 'widgets.layers.rasterFilter.cancel', @@ -989,15 +993,32 @@ describe('remove-from-group', () => { it("is enabled by the model's answer and routes to its effect", () => { const document = makeDocument(grouped); + const enabled = byId(getLayerContextActions(makeState(r1, { document })), 'remove-from-group'); + expect(enabled).toMatchObject({ isDisabled: false, unavailableReasonKey: undefined }); const refused = getLayerContextActions(makeState(r1, { canRemoveFromGroup: false, document })); expect(byId(refused, 'remove-from-group').isDisabled).toBe(true); - const locked = getLayerContextActions(makeState(r1, { document, interactionLocked: true })); - expect(byId(locked, 'remove-from-group').isDisabled).toBe(true); const runtime = makeRuntimeContext(r1, { document }); void getLayerContextActionDefinition('remove-from-group').handler(runtime); expect(runtime.effects.removeFromGroup).toHaveBeenCalledOnce(); }); + + it('says why it is disabled in each state that disables it', () => { + const reason = (layer: CanvasLayerContract, nodes: CanvasNodeContract[], state: Partial) => + byId(getLayerContextActions(makeState(layer, { document: makeDocument(nodes), ...state })), 'remove-from-group') + .unavailableReasonKey; + const lockedLayer = { ...r1, isLocked: true }; + const refused = { canRemoveFromGroup: false }; + + expect(reason(r1, grouped, { ...refused, hasEngine: false })).toBe('widgets.layers.actions.canvasNotReady'); + expect(reason(lockedLayer, [groupContract('G', [lockedLayer])], refused)).toBe( + 'widgets.layers.actions.removeFromGroupLocked' + ); + // A lock on any group above the layer refuses the move just the same. + const nested = [groupContract('Outer', [groupContract('G', [r1])], { isLocked: true })]; + expect(reason(r1, nested, refused)).toBe('widgets.layers.actions.removeFromGroupLocked'); + expect(reason(r1, grouped, { interactionLocked: true })).toBe('widgets.layers.actions.interactionLocked'); + }); }); describe('merge-masks', () => { @@ -1018,14 +1039,38 @@ describe('merge-masks', () => { it("is enabled by the engine's eligibility and routes to its effect", () => { const state = { document: makeDocument(layers), selectedIds: ['m1', 'm2'] }; - expect(byId(getLayerContextActions(makeState(m1, state)), 'merge-masks').isDisabled).toBe(false); - expect(byId(getLayerContextActions(makeState(m1, state)), 'merge-masks').descriptionKey).toBeUndefined(); - const ineligible = getLayerContextActions(makeState(m1, { ...state, canMergeMasks: false })); - expect(byId(ineligible, 'merge-masks').isDisabled).toBe(true); - expect(byId(ineligible, 'merge-masks').descriptionKey).toBe('widgets.layers.actions.mergeMasksIneligible'); + expect(byId(getLayerContextActions(makeState(m1, state)), 'merge-masks')).toMatchObject({ + isDisabled: false, + unavailableReasonKey: undefined, + }); + const ineligible = getLayerContextActions(makeState(m1, { ...state, mergeMasksEligibility: 'ineligible' })); + expect(byId(ineligible, 'merge-masks')).toMatchObject({ + descriptionKey: undefined, + isDisabled: true, + unavailableReasonKey: 'widgets.layers.actions.mergeMasksIneligible', + }); const runtime = makeRuntimeContext(m1, state); void getLayerContextActionDefinition('merge-masks').handler(runtime); expect(runtime.effects.mergeMasks).toHaveBeenCalledOnce(); }); + + it('tells a settings mismatch, a canvas still starting and a busy canvas apart from other ineligibility', () => { + const reason = (nodes: CanvasNodeContract[], state: Partial) => + byId( + getLayerContextActions(makeState(m1, { document: makeDocument(nodes), selectedIds: ['m1', 'm2'], ...state })), + 'merge-masks' + ).unavailableReasonKey; + + expect(reason([m1, m2], { mergeMasksEligibility: 'settings-differ' })).toBe( + 'widgets.layers.actions.mergeMasksSettingsDiffer' + ); + expect(reason([m1, m2], { mergeMasksEligibility: 'ineligible' })).toBe( + 'widgets.layers.actions.mergeMasksIneligible' + ); + expect(reason([m1, m2], { hasEngine: false, mergeMasksEligibility: 'ineligible' })).toBe( + 'widgets.layers.actions.canvasNotReady' + ); + expect(reason([m1, m2], { interactionLocked: true })).toBe('widgets.layers.actions.interactionLocked'); + }); }); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/layers/layerContextActions.ts b/invokeai/frontend/webv2/src/workbench/widgets/layers/layerContextActions.ts index c8f0321c09b..e4fba2c5258 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/layers/layerContextActions.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/layers/layerContextActions.ts @@ -4,6 +4,7 @@ import type { CanvasColorLabel, CanvasDocumentContractV3, CanvasLayerContract, + InpaintMaskMergeEligibility, LayerStackMoveKind, } from '@workbench/canvas-engine/api'; import type { LucideIcon } from 'lucide-react'; @@ -144,8 +145,8 @@ export interface LayerContextActionState { canDeleteSelection: boolean; /** Whether the whole multi-selection can merge into one raster, asked the same way. */ canMergeSelection: boolean; - /** Whether the engine would merge `actionTargets` into one inpaint mask. */ - canMergeMasks: boolean; + /** Whether merge masks would take `actionTargets`, and if not, whether only their generation settings differ. */ + mergeMasksEligibility: InpaintMaskMergeEligibility; /** The model's answer for lifting `actionTargets` out of their group. */ canRemoveFromGroup: boolean; /** A group above the layer hides it on the canvas; the layer's own flag cannot override that. */ @@ -216,10 +217,10 @@ interface LayerContextActionDefinitionBase { tone?: 'danger'; /** A raw hotkey string (e.g. `mod+]`) shown as a trailing hint on the item. */ hint?: string; - /** A second line under the label explaining what the action does. */ + /** A second line under the label explaining what the action does, shown whether or not it is enabled. */ descriptionKey?: string; - /** A second line that depends on the context, such as why the action is unavailable; wins over `descriptionKey`. */ - getDescriptionKey?(context: LayerContextActionState): string | undefined; + /** Why the disabled action cannot run here; asked only when `isEnabled` is false. */ + getUnavailableReasonKey?(context: LayerContextActionState): string | undefined; isVisible(context: LayerContextActionState): boolean; isEnabled(context: LayerContextActionState): boolean; handler(context: LayerContextActionRuntimeContext): void | Promise; @@ -255,6 +256,8 @@ export interface LayerContextAction { hint?: string; descriptionKey?: string; isDisabled: boolean; + /** Set only on a disabled action whose reason is known; the menu keeps such an item reachable and says why. */ + unavailableReasonKey?: string; handler(context: LayerContextActionRuntimeContext): void | Promise; } @@ -328,6 +331,16 @@ const isLayerFrozen = (context: LayerContextActionState): boolean => context.layer.isLocked || (layerEntry(context)?.ancestorsLocked ?? false); const isLayerMutable = (context: LayerContextActionState): boolean => isInteractionFree(context) && !isLayerFrozen(context); +const CANVAS_NOT_READY_REASON = 'widgets.layers.actions.canvasNotReady'; +const INTERACTION_LOCKED_REASON = 'widgets.layers.actions.interactionLocked'; +/** Any target locked, by itself or by a group above it: the model refuses to move it out. */ +const isAnyTargetFrozen = (context: LayerContextActionState): boolean => { + const index = getDocumentIndex(context.document); + return actionTargets(context).some((id) => { + const entry = index.byId.get(id); + return !!entry && (entry.node.isLocked || entry.ancestorsLocked); + }); +}; const hasReadablePixels = (context: LayerContextActionState): boolean => context.hasEngine && context.hasSupportedContent && !context.interactionLocked; const hasMutablePixels = (context: LayerContextActionState): boolean => @@ -486,6 +499,14 @@ export const LAYER_CONTEXT_ACTION_DEFINITIONS: readonly LayerContextActionDefini }, { defaultLabel: 'Remove from group', + getUnavailableReasonKey: (context) => + !context.hasEngine + ? CANVAS_NOT_READY_REASON + : isAnyTargetFrozen(context) + ? 'widgets.layers.actions.removeFromGroupLocked' + : context.interactionLocked + ? INTERACTION_LOCKED_REASON + : undefined, handler: ({ effects }) => effects.removeFromGroup(), icon: UngroupIcon, id: 'remove-from-group', @@ -709,12 +730,18 @@ export const LAYER_CONTEXT_ACTION_DEFINITIONS: readonly LayerContextActionDefini }, { defaultLabel: 'Merge masks', - // The engine's eligibility has several reasons; name them rather than leave the item silently disabled. - getDescriptionKey: (context) => (context.canMergeMasks ? undefined : 'widgets.layers.actions.mergeMasksIneligible'), + getUnavailableReasonKey: (context) => + !context.hasEngine + ? CANVAS_NOT_READY_REASON + : context.mergeMasksEligibility === 'settings-differ' + ? 'widgets.layers.actions.mergeMasksSettingsDiffer' + : context.mergeMasksEligibility === 'ineligible' + ? 'widgets.layers.actions.mergeMasksIneligible' + : INTERACTION_LOCKED_REASON, handler: ({ effects }) => effects.mergeMasks(), icon: MergeIcon, id: 'merge-masks', - isEnabled: (context) => isInteractionFree(context) && context.canMergeMasks, + isEnabled: (context) => isInteractionFree(context) && context.mergeMasksEligibility === 'eligible', isVisible: (context) => isMultiTarget(context) && allTargetsInpaintMasks(context), labelKey: 'widgets.layers.actions.mergeMasks', order: 1, @@ -1086,20 +1113,24 @@ export const getLayerContextActionDefinition = (id: LayerContextActionId): Layer export const getLayerContextActions = (context: LayerContextActionState): LayerContextAction[] => LAYER_CONTEXT_ACTION_DEFINITIONS.filter( (definition) => definition.supportedLayerTypes.includes(context.layer.type) && definition.isVisible(context) - ).map((definition) => ({ - defaultLabel: definition.getDefaultLabel?.(context) ?? definition.defaultLabel, - descriptionKey: definition.getDescriptionKey?.(context) ?? definition.descriptionKey, - handler: definition.handler, - hint: definition.hint, - icon: definition.getIcon?.(context) ?? definition.icon, - iconColor: definition.iconColor, - id: definition.id, - isDisabled: !definition.isEnabled(context), - labelCount: definition.getLabelCount?.(context), - labelKey: definition.getLabelKey?.(context) ?? definition.labelKey, - order: definition.order, - picker: definition.picker, - section: definition.section, - submenu: definition.submenu, - tone: definition.tone, - })); + ).map((definition) => { + const isDisabled = !definition.isEnabled(context); + return { + defaultLabel: definition.getDefaultLabel?.(context) ?? definition.defaultLabel, + descriptionKey: definition.descriptionKey, + handler: definition.handler, + hint: definition.hint, + icon: definition.getIcon?.(context) ?? definition.icon, + iconColor: definition.iconColor, + id: definition.id, + isDisabled, + labelCount: definition.getLabelCount?.(context), + labelKey: definition.getLabelKey?.(context) ?? definition.labelKey, + order: definition.order, + picker: definition.picker, + section: definition.section, + submenu: definition.submenu, + tone: definition.tone, + unavailableReasonKey: isDisabled ? definition.getUnavailableReasonKey?.(context) : undefined, + }; + }); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/layers/layerContextMenuLayout.test.ts b/invokeai/frontend/webv2/src/workbench/widgets/layers/layerContextMenuLayout.test.ts index f4357b559b2..b5b286ca531 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/layers/layerContextMenuLayout.test.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/layers/layerContextMenuLayout.test.ts @@ -51,7 +51,7 @@ const actionsFor = (layer: CanvasLayerContract, layers: readonly CanvasLayerCont selectedIds: [layer.id], canGroupSelection: true, canDeleteSelection: true, - canMergeMasks: true, + mergeMasksEligibility: 'eligible', canMergeSelection: true, canRemoveFromGroup: true, layer, diff --git a/invokeai/frontend/webv2/src/workbench/widgets/layers/psdExportNotice.ts b/invokeai/frontend/webv2/src/workbench/widgets/layers/psdExportNotice.ts index e07dd3104fd..87505e742e5 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/layers/psdExportNotice.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/layers/psdExportNotice.ts @@ -6,6 +6,7 @@ export type PsdExportNoticeKey = | 'widgets.layers.groupActions.exportNothing' | 'widgets.layers.groupActions.exportOverBudget' | 'widgets.layers.groupActions.exportStale' + | 'widgets.layers.groupActions.exportUnreadable' | 'widgets.layers.groupActions.exportAborted'; /** The notice for a non-refusal result; an `AdjustmentRefusal` is worded by `describeAdjustmentRefusal`. */ @@ -25,6 +26,8 @@ export const getPsdExportNoticeKey = ( return 'widgets.layers.groupActions.exportOverBudget'; case 'stale': return 'widgets.layers.groupActions.exportStale'; + case 'unreadable': + return 'widgets.layers.groupActions.exportUnreadable'; case 'aborted': return 'widgets.layers.groupActions.exportAborted'; } diff --git a/invokeai/frontend/webv2/src/workbench/widgets/layers/runLayerWorkflow.ts b/invokeai/frontend/webv2/src/workbench/widgets/layers/runLayerWorkflow.ts index 2bb1ec48776..d5851b9a0ab 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/layers/runLayerWorkflow.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/layers/runLayerWorkflow.ts @@ -42,6 +42,7 @@ export type RunLayerWorkflowResult = | 'unsupported' | 'empty' | 'not-ready' + | 'unreadable' | 'over-budget' | 'locked' | 'stale' diff --git a/invokeai/frontend/webv2/src/workbench/widgets/layers/useCanvasCompanionHotkeys.ts b/invokeai/frontend/webv2/src/workbench/widgets/layers/useCanvasCompanionHotkeys.ts index 13eb8793277..e07e6c0208a 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/layers/useCanvasCompanionHotkeys.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/layers/useCanvasCompanionHotkeys.ts @@ -2,14 +2,14 @@ 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/canvasHotkeyCommands'; -import { isCanvasInteractionLocked } from '@workbench/widgets/canvas/canvasInteractionLock'; +} from '@workbench/widgets/canvas/canvasToolCommands'; import { useActiveColorCommands } from '@workbench/widgets/canvas/color-system/useActiveColors'; import { useWorkbenchQueries } from '@workbench/WorkbenchContext'; import { useEffect, useEffectEvent } from 'react'; diff --git a/invokeai/frontend/webv2/src/workbench/widgets/layers/useLayerStackActions.ts b/invokeai/frontend/webv2/src/workbench/widgets/layers/useLayerStackActions.ts index 5f2fa825fac..3ab9d3c3ac7 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/layers/useLayerStackActions.ts +++ b/invokeai/frontend/webv2/src/workbench/widgets/layers/useLayerStackActions.ts @@ -119,6 +119,12 @@ export const useLayerStackActions = ( }); } else if (result === 'not-ready') { toaster.create({ title: t('widgets.layers.groupActions.mergeNotReady'), type: 'warning' }); + } else if (result === 'unreadable') { + toaster.create({ + description: t('widgets.layers.actions.unreadable'), + title: t('widgets.layers.actions.actionFailed'), + type: 'error', + }); } else if (result === 'over-budget') { toaster.create({ title: t('widgets.layers.groupActions.mergeOverBudget'), type: 'warning' }); } else if (result === 'failed') { diff --git a/invokeai/frontend/webv2/vite.config.mts b/invokeai/frontend/webv2/vite.config.mts index 1893d377e57..1ca8b92578b 100644 --- a/invokeai/frontend/webv2/vite.config.mts +++ b/invokeai/frontend/webv2/vite.config.mts @@ -271,8 +271,8 @@ const CANVAS_LAYER_SHARED_MODULES = [ '/workbench/canvas-operations/useCanvasEngine.ts', '/workbench/canvasProjectMutationPort.ts', '/workbench/useCanvasProjectMutationDispatch.ts', - '/workbench/widgets/canvas/canvasHotkeyCommands.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', From bc5e755aa670b1c16a95afdcc07fc99e28faf113 Mon Sep 17 00:00:00 2001 From: Josh Corbett Date: Sun, 25 Oct 2026 01:58:01 -0600 Subject: [PATCH 13/14] fix(canvas): keep the mask blur inside fitted frames on coarse grids 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. --- .../04.Image Generation/generate-panel.mdx | 4 +- .../04.Image Generation/parameters.mdx | 2 +- .../GenerateDimensionFields.browser.test.tsx | 34 ++++++++-- .../generation/ui/GenerateDimensionFields.tsx | 63 ++++++++++--------- .../CanvasHeaderActions.browser.test.tsx | 45 ++++++++++--- .../CanvasColorFeed.browser.test.tsx | 47 +++++++++++++- .../canvas/color-system/CanvasColorFeed.tsx | 12 +++- .../workbench/widgets/canvas/fitBbox.test.ts | 55 ++++++++++++++-- .../src/workbench/widgets/canvas/fitBbox.ts | 24 ++++--- .../geometryForm.browser.test.tsx | 45 +++++++++---- .../canvas/tool-options/geometryForm.tsx | 11 +++- 11 files changed, 265 insertions(+), 77 deletions(-) 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 abeefa809cf..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 @@ -18,9 +18,9 @@ The **Size** section sets the width and height of the image. * **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. -* **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 and unavailable: +* **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. + * **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. 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 7f9745a47ca..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 @@ -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 **Reset size → 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/invokeai/frontend/webv2/src/features/generation/ui/GenerateDimensionFields.browser.test.tsx b/invokeai/frontend/webv2/src/features/generation/ui/GenerateDimensionFields.browser.test.tsx index 868d399ef76..3415b063cf4 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 @@ -311,7 +311,7 @@ describe('GenerateDimensionFields aspect ratio', () => { describe('GenerateDimensionFields Reset size', () => { const resetSizeButton = () => host?.querySelector('button[aria-label="Reset size"]') ?? null; - const menuItems = () => [...document.querySelectorAll('[role="menuitem"]')]; + 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. @@ -398,22 +398,44 @@ describe('GenerateDimensionFields Reset size', () => { } }); - it('marks the applied choices current and disabled, and drops an optimal size that is the native square', async () => { + 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-current')).toBe('true'); - expect(square?.getAttribute('aria-disabled')).toBe('true'); + 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')?.hasAttribute('aria-current')).toBe(false); - expect(menuItem('Square at native size')?.hasAttribute('aria-disabled')).toBe(false); + 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 () => { diff --git a/invokeai/frontend/webv2/src/features/generation/ui/GenerateDimensionFields.tsx b/invokeai/frontend/webv2/src/features/generation/ui/GenerateDimensionFields.tsx index fb172e331aa..c6bed433a75 100644 --- a/invokeai/frontend/webv2/src/features/generation/ui/GenerateDimensionFields.tsx +++ b/invokeai/frontend/webv2/src/features/generation/ui/GenerateDimensionFields.tsx @@ -101,16 +101,27 @@ interface ResetSizeChoice { value: string; label: string; size: Dimensions; - /** Already applied: shown checked and disabled. */ + /** 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 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 ( @@ -124,32 +135,28 @@ const ResetSizeMenu = ({ choices }: { choices: readonly ResetSizeChoice[] }) => - {choices.map((choice) => ( - - - - - {choice.label} - - - {`${choice.size.width}×${choice.size.height}`} - - - - ))} + + {choices.map((choice) => ( + + + + + {choice.label} + + + {`${choice.size.width}×${choice.size.height}`} + + + + ))} + 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/color-system/CanvasColorFeed.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/color-system/CanvasColorFeed.browser.test.tsx index e3b73ace183..f8eea5c2800 100644 --- 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 @@ -52,6 +52,7 @@ beforeAll(async () => { const TINT = '#e07575'; const PICKED = '#3a7bd5'; +type ShapeSource = Extract; const SHAPE_SOURCE = { fill: '#ff0000', height: 10, @@ -60,7 +61,7 @@ const SHAPE_SOURCE = { strokeWidth: 2, type: 'shape', width: 10, -} satisfies Extract; +} satisfies ShapeSource; const TEXT_SOURCE = { align: 'left', color: '#123456', @@ -103,12 +104,15 @@ afterEach(async () => { /** * 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', { withTintEditor = false } = {}) => { +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: SHAPE_SOURCE }), + 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' }), @@ -276,6 +280,43 @@ describe('CanvasColorFeed Alt-pick routing', () => { 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'); 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 ed9bc6fe6d4..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 @@ -34,7 +34,8 @@ type PickTarget = /** * Precedence: the armed mask tint while its mask is still selected; a selected shape (the foreground target writes - * its fill, the background its stroke); a selected text layer; otherwise the pair. + * 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(); @@ -48,7 +49,10 @@ const resolvePickTarget = (engine: CanvasEngineHandle, canvasValues: Record { 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') { 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 495d60a2512..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,8 +118,8 @@ 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} → blur 0 pads 8 → {92,92,56,56} → grid 8 → {96,96,48,48}. - expect(computeFitBboxToMasks(doc, 8, 0)).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', () => { @@ -129,17 +129,60 @@ describe('computeFitBboxToMasks', () => { expect(computeFitBboxToMasks(doc, 1, 0)).toEqual({ height: 116, width: 116, x: 192, y: 192 }); }); - it('grid-snaps the blur-padded rect inward', () => { + 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,76,88,88}; grid 8 moves the origin up to 80 (+4) and floors 84 to 80. - expect(computeFitBboxToMasks(doc, 8, 16)).toEqual({ height: 80, width: 80, x: 80, y: 80 }); + // 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, 0)).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, 0)).toBeNull(); }); diff --git a/invokeai/frontend/webv2/src/workbench/widgets/canvas/fitBbox.ts b/invokeai/frontend/webv2/src/workbench/widgets/canvas/fitBbox.ts index 760d05101f1..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 - * pads by the mask blur plus {@link MASK_FIT_MARGIN}; 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 { @@ -34,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, @@ -65,8 +75,9 @@ export const computeFitBboxToLayers = (doc: CanvasDocumentContractV3, gridSize: }; /** - * The grid-snapped frame that fits 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. + * 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, @@ -84,6 +95,5 @@ export const computeFitBboxToMasks = ( 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/tool-options/geometryForm.browser.test.tsx b/invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/geometryForm.browser.test.tsx index b37dd726e6e..7427e0ec088 100644 --- a/invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/geometryForm.browser.test.tsx +++ b/invokeai/frontend/webv2/src/workbench/widgets/canvas/tool-options/geometryForm.browser.test.tsx @@ -58,22 +58,28 @@ const createEngine = () => { return { engine: engine as unknown as ToolFooterProps['engine'], setPending }; }; +/** Renders the footer `width` px wide. */ +const renderFooter = async (engine: ToolFooterProps['engine'], width = 320) => { + const Footer = transformForm.footer!; + host = document.createElement('div'); + host.style.width = `${width}px`; + document.body.append(host); + root = createRoot(host); + await act(() => + root!.render( + + +