From 41bde6a3ffd39f85570ac6d8482dbbe5bbabaa45 Mon Sep 17 00:00:00 2001 From: ausboss <102309304+ausboss@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:23:00 -0500 Subject: [PATCH] Show the MaskEditor mask on Image Crop + Rotate + Pad's picture The MaskEditor saves its picture into the image widget's stored value without calling the widget back, so the node kept showing the old picture and nobody could tell a mask was there. The stage now follows a source changed that way (on the node's draw, and when the MaskEditor sets the node's preview, which covers Nodes 2.0 too), keeping the framing. The mask is the picture's alpha. The stage reads it once per picture by the run's own 90% rule and draws the picture as the run uses it: kept pixels solid, painted parts empty over the fill, with Show blend's teal on top. The teal turns, crops and pads with the picture, in the full editor too, and Show blend's paste mask now includes the painted parts. A gear on the image source box turns the teal off for this browser. --- CHANGELOG.md | 8 ++ js/docs/AUSBOSS_NODES_ImageCropRotatePad.md | 21 ++- js/shared/stitch_preview.mjs | 22 ++- js/shared/transform_editor.mjs | 147 ++++++++++++++++++-- tests/stitch_preview.test.mjs | 28 +++- 5 files changed, 214 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f71a6af..c614e59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,14 @@ All notable changes to ComfyUI-AusBoss are documented here. and both video nodes, on the node and in the editor. The rotate knob moves aside where a corner handle now sits. Saved workflows and the node inputs are unchanged. +- **Image Crop + Rotate + Pad shows the mask you draw.** After you paint a + mask with Open in MaskEditor and press Save, the picture on the node now + updates and shows the painted parts in teal. Before, it kept showing the + old picture, so you could not tell a mask was there. The teal moves with + the crop, the turn and the padding, and the full editor shows it too. + See-through parts of a PNG show the same way. A new gear on the image + source box turns the teal off. In the editor, Show blend now includes the + painted parts. ## 2.5.2 - 2026-10-02 diff --git a/js/docs/AUSBOSS_NODES_ImageCropRotatePad.md b/js/docs/AUSBOSS_NODES_ImageCropRotatePad.md index 005c105..212dce6 100644 --- a/js/docs/AUSBOSS_NODES_ImageCropRotatePad.md +++ b/js/docs/AUSBOSS_NODES_ImageCropRotatePad.md @@ -98,6 +98,20 @@ Fill, Feather and Resize stay synchronized with the editor. Changing the resize updates the size readout immediately. Restoring a workflow or undoing a change refreshes the source card and preview without resetting the saved framing. +## Drawing a mask + +Right-click the node and choose **Open in MaskEditor** to paint over the parts +you want the model to repaint. After **Save**, the picture on the node shows +them in teal, and the teal moves with the picture when you crop, turn or pad +it. The full editor shows it too. The run treats painted parts like the +padding: it fills them with the fill colour and marks them in the **mask**, +so under the teal you see the fill colour, not your picture. + +See-through parts of a PNG show in teal the same way, because the node paints +them too. To hide the teal, click the gear at the top right of the image +source box and turn off **Show the mask on the picture**. The choice applies +to every Image Crop + Rotate + Pad in this browser. + ## Outputs - **image**: BHWC float image batch. Animated image frames receive the identical transform. @@ -142,6 +156,9 @@ A picture with no see-through parts comes out exactly as before. - A turned picture's see-through parts turn with it, like its corners. In **prompt_image** they show white while the padding and the corners keep the fill colour. +- The MaskEditor saves its mask as see-through parts of the picture, so the + same rule applies: a stroke more than 10% strong is painted in full, + including one at the MaskEditor's default 70% opacity. ## Inpaint & Stitch @@ -149,8 +166,8 @@ The editor's right sidebar holds the stitcher's settings, the same as on the cli - **Blend** (`stitch_blend`, default 32): the ramp, in output pixels, where generated pixels fade over the source. It is separate from **Feather**, which shapes the mask. -- **Show blend** tints the stage with the paste mask: the generated area, the feather, - then grow and blend applied through any resize. +- **Show blend** tints the stage with the paste mask: the generated area (painted and + see-through parts included), the feather, then grow and blend applied through any resize. - **Advanced → Grow paste** (`stitch_grow`, default 0) moves the paste boundary first: a few positive pixels let the generation repaint the source edge when a seam still shows. diff --git a/js/shared/stitch_preview.mjs b/js/shared/stitch_preview.mjs index 0537c82..704dc6e 100644 --- a/js/shared/stitch_preview.mjs +++ b/js/shared/stitch_preview.mjs @@ -2,7 +2,8 @@ // blend overlay is the paste mask the stitcher will actually use - not a // sketch of it. Pure Float32Array functions on a row-major mask (1 = // generated), testable under node:test against a fixture the Python -// helpers produced (tests/fixtures/stitch_blend_parity.json). +// helpers produced (tests/fixtures/stitch_blend_parity.json). The +// see-through rule (seeThroughMap) is mirrored too. // // Mirrors nodes/_mask_helpers.py grow_shrink_mask (square max filter, // separable, -inf padding) and blur_mask (gaussian, radius ceil(3 sigma), @@ -116,6 +117,25 @@ export function featherGeneratedMask(values, width, height, featherPixels) { return out; } +// The parts of a picture the run paints like its padding: a pixel less than +// SEE_THROUGH_KEEP_PERCENT as solid as the picture's most solid one (see +// see_through_kept in nodes/_transform_engine.py). A mask drawn in the +// MaskEditor is saved exactly that way, as the picture with its alpha +// cleared where you painted. Takes RGBA bytes and returns a map, 1 where +// see-through, or null for a picture at least that solid everywhere, which +// the run leaves exactly as it is. +export const SEE_THROUGH_KEEP_PERCENT = 90; + +export function seeThroughMap(rgba) { + let low = 255, top = 0; + for (let i = 3; i < rgba.length; i += 4) { if (rgba[i] < low) low = rgba[i]; if (rgba[i] > top) top = rgba[i]; } + if (low * 100 >= 255 * SEE_THROUGH_KEEP_PERCENT) return null; + const map = new Uint8Array(rgba.length >> 2); + // A picture with no solid pixel at all is see-through everywhere. + for (let i = 0; i < map.length; i++) map[i] = !top || rgba[i * 4 + 3] * 100 < top * SEE_THROUGH_KEEP_PERCENT ? 1 : 0; + return map; +} + // Work-resolution planning for the overlay. Blend and grow are output // pixels AFTER any resize; the stage draws the pre-resize canvas. One // output pixel is `unit` work pixels (the two axes can differ by a step diff --git a/js/shared/transform_editor.mjs b/js/shared/transform_editor.mjs index f969d71..89ebcc9 100644 --- a/js/shared/transform_editor.mjs +++ b/js/shared/transform_editor.mjs @@ -10,8 +10,9 @@ import { createMediaPicker } from "./media_picker.mjs"; import { normalizeFillColor } from "./fill_color.mjs"; import { makeScrubInput } from "./scrub_input.mjs"; import { confirmDiscard } from "./discard_prompt.mjs"; -import { featherGeneratedMask, overlayPlan, stitchBlendFromMask } from "./stitch_preview.mjs"; +import { featherGeneratedMask, overlayPlan, seeThroughMap, stitchBlendFromMask } from "./stitch_preview.mjs"; import { suppressCoreVideoPreview } from "./core_preview.mjs"; +import { gearIconSvg, loadSettings, openSettingsMenu } from "./settings_menu.mjs"; import { INPUT_FOLDER_MODE, LOCAL_PATH_MODE, @@ -98,6 +99,23 @@ const CONTROL_MAX_WIDTH = 460; const TRANSFORM_DEFAULTS = resetTransformValues(false); const CORE_IMAGE_PREVIEW_WIDGET = "$$canvas-image-preview"; +// The gear on the image node's source card. Display choices only, kept for +// every image node on this browser (settings_menu.mjs stores them). +const SETTINGS_SCOPE = "image_crop_rotate_pad"; +const SETTINGS_SCHEMA = [ + { + key: "show_mask", label: "Show the mask on the picture", type: "toggle", default: true, + hint: "Teal over the parts of your picture the model paints: a mask drawn in the MaskEditor, or see-through parts of a PNG.", + }, +]; +let transformSettings = null; +function settings() { + transformSettings ??= loadSettings(SETTINGS_SCOPE, SETTINGS_SCHEMA); + return transformSettings; +} +// Every installed node, so a changed setting redraws them all at once. +const liveStates = new Set(); + function installStyles() { if (document.getElementById("ausboss-transform-styles")) return; const style = document.createElement("style"); @@ -108,6 +126,9 @@ function installStyles() { .ausboss-transform-preview{width:100%;flex:1 1 180px;min-height:0;border:1px solid #50555b;border-radius:8px;background:#111;display:block;touch-action:none} .ausboss-transform-source{display:flex;flex-direction:column;gap:7px;flex:0 0 auto;padding:8px;border:1px solid rgba(0,184,174,.28);border-radius:8px;background:rgba(0,0,0,.24)} .ausboss-transform-source-heading{color:${BRAND};font:600 10px system-ui;letter-spacing:.08em;text-transform:uppercase} + .ausboss-transform-source-head{display:flex;align-items:center;justify-content:space-between;gap:6px} + .ausboss-transform-gear{flex:none;width:20px;height:20px;margin:-4px 0;display:grid;place-items:center;padding:0;border:1px solid #2a3437;border-radius:5px;background:#0f1516;color:#8ba3a1;cursor:pointer} + .ausboss-transform-gear:hover{border-color:${BRAND};color:${BRAND}} .ausboss-transform-source-mode{display:grid;grid-template-columns:1fr 1fr;height:30px;padding:3px;border:1px solid #2a3437;border-radius:7px;background:#0f1516} .ausboss-transform-source-mode button{border:0;border-radius:5px;background:transparent;color:#8ba3a1;font:600 12px system-ui;cursor:pointer} .ausboss-transform-source-mode button:hover{color:#fff}.ausboss-transform-source-mode button.on{background:${BRAND};color:#04201d} @@ -249,10 +270,27 @@ function suppressCoreImagePreview(node) { configurable: true, enumerable: true, get() { return undefined; }, - set() {}, + // The MaskEditor sets this right after it writes the picture it saved. + set() { + const state = node.__ausbossTransformState; + if (state) queueMicrotask(() => followSource(state)); + }, }); } +// The MaskEditor saves its picture (the mask is the picture's alpha) into +// the image widget's stored value without calling the widget back, so the +// stage never heard of it and kept the old picture. A source changed that +// way is followed here, keeping the framing, as a workflow load does: the +// mask was drawn on the same picture. +function followSource(state) { + if (state.disposed || !state.ready || state.kind !== "image") return; + const key = sourceKey(state.node, "image"); + if (!key || key === state.source) return; + state.syncSourceCard?.(); + void onSourceChanged(state, false); +} + function sourceKey(node, kind) { if (kind === "image") return String(value(node, "image", "")); return videoSourceState( @@ -377,7 +415,7 @@ function buildMediaSourceCard(state) { upload.append(fileInput); const hint = createElement("div", "ausboss-transform-source-hint"); field.append(picker.element, upload); - root.append(kind === "image" ? createElement("div", "ausboss-transform-source-heading", "Image source") : modes, field, hint); + root.append(kind === "image" ? sourceHeading() : modes, field, hint); const currentOptions = () => { const target = widget(node, kind); @@ -455,6 +493,27 @@ function buildMediaSourceCard(state) { return root; } +// "Image source" with the node's gear at the other end of the line. +function sourceHeading() { + const head = createElement("div", "ausboss-transform-source-head"); + const gear = createElement("button", "ausboss-transform-gear"); + gear.type = "button"; + gear.title = "Image Crop + Rotate + Pad settings"; + gear.innerHTML = gearIconSvg(11); + gear.addEventListener("click", () => openSettingsMenu({ + scope: SETTINGS_SCOPE, + schema: SETTINGS_SCHEMA, + anchor: gear.getBoundingClientRect(), + title: "Image Crop + Rotate + Pad settings", + onChange: (values) => { + transformSettings = values; + for (const live of liveStates) draw(live); + }, + })); + head.append(createElement("div", "ausboss-transform-source-heading", "Image source"), gear); + return head; +} + export function installTransformNode(node, kind, mountPanel = null) { installStyles(); const state = { @@ -466,6 +525,7 @@ export function installTransformNode(node, kind, mountPanel = null) { imageIndex: null, imageTime: null, }; node.__ausbossTransformState = state; + liveStates.add(state); state.isClip = Boolean(widget(node, "start_seconds") && widget(node, "end_seconds")); state.trimViews = new Set(); if (state.isClip) { @@ -559,6 +619,8 @@ export function installTransformNode(node, kind, mountPanel = null) { // The graph scales the DOM widget with its zoom, so the backing store // sized at one zoom turns to mush at another: redraw when it changes. chainCallback(node, "onDrawForeground", function () { + // Also where a picture saved by the MaskEditor is noticed. + followSource(state); if (state.isClip && !state.disposed) { const rate = clipOutputRate(node, state.metadata?.fps, value(node, "every_nth", 1)); const linked = ["fixed_frames", "frame_load_cap", "max_frames", "start_frame", "end_frame", "start_seconds", "end_seconds", "every_nth"]; @@ -1678,7 +1740,8 @@ function blendOverlayCanvas(state, render) { const resize = value(node, "resize_to_megapixels", false) ? scaleToMegapixels(padding.outputWidth, padding.outputHeight, value(node, "megapixels", 1), value(node, "resolution_steps", 1)) : null; - const key = JSON.stringify([state.sourceWidth, state.sourceHeight, rotation, crop, padding, feather, blend, grow, resize]); + const layer = maskLayer(state); + const key = JSON.stringify([state.sourceWidth, state.sourceHeight, rotation, crop, padding, feather, blend, grow, resize, layer ? state.image.src : null]); if (state.blendOverlay?.key === key) return state.blendOverlay.canvas; const plan = overlayPlan(padding.outputWidth, padding.outputHeight, resize); const { width, height, k } = plan; @@ -1690,6 +1753,9 @@ function blendOverlayCanvas(state, render) { rc.translate((padding.left - crop.x) * k + source.width * k / 2, (padding.top - crop.y) * k + source.height * k / 2); rc.rotate(rotation * Math.PI / 180); rc.fillStyle = "#000"; rc.fillRect(-state.sourceWidth * k / 2, -state.sourceHeight * k / 2, state.sourceWidth * k, state.sourceHeight * k); + // The see-through parts of the picture, a painted mask among them, are + // generated area as well. + if (layer) rc.drawImage(maskStencil(layer), -state.sourceWidth * k / 2, -state.sourceHeight * k / 2, state.sourceWidth * k, state.sourceHeight * k); rc.restore(); const pixels = rc.getImageData(0, 0, width, height).data; let mask = new Float32Array(width * height); @@ -2301,9 +2367,19 @@ function keepStageRoom(state) { function drawScene(context, state, render, compact, interactive) { const { sourceRect, cropRect, outputRect } = render; context.save(); const fill = normalizeColor(value(state.node, "fill_color", "#808080")); + // Show blend's tint already holds the painted parts: one teal at a time. + const blend = !compact && state.showBlend && widget(state.node, "stitch_blend"); + const tint = !blend && settings().show_mask ? maskLayer(state)?.tint : null; const picture = () => { context.save(); context.translate(sourceRect.x + sourceRect.width / 2, sourceRect.y + sourceRect.height / 2); context.rotate((Number(value(state.node, "rotation_degrees", 0)) || 0) * Math.PI / 180); - drawSourceImage(context, state, render.scale); context.restore(); + drawSourceImage(context, state, render.scale); + // The mask rides on the picture it was drawn on: turned, cropped and + // padded with it, and dimmed with it where the crop cuts it away. + if (tint) { + const width = state.sourceWidth * render.scale; const height = state.sourceHeight * render.scale; + context.globalAlpha = MASK_TINT_ALPHA; context.drawImage(tint, -width / 2, -height / 2, width, height); + } + context.restore(); }; picture(); context.save(); context.fillStyle = "rgba(8,10,12,.66)"; @@ -2311,7 +2387,7 @@ function drawScene(context, state, render, compact, interactive) { context.fillStyle = fill; context.fillRect(outputRect.x, outputRect.y, outputRect.width, outputRect.height); drawHatch(context, outputRect, fill); context.save(); context.beginPath(); context.rect(cropRect.x, cropRect.y, cropRect.width, cropRect.height); context.clip(); picture(); context.restore(); - if (!compact && state.showBlend && widget(state.node, "stitch_blend")) drawBlendOverlay(context, state, render); + if (blend) drawBlendOverlay(context, state, render); context.strokeStyle = "#4bd8ef"; context.lineWidth = compact ? 1 : 2; context.setLineDash([7, 5]); context.strokeRect(cropRect.x, cropRect.y, cropRect.width, cropRect.height); context.strokeStyle = "#ff9d42"; context.setLineDash([5, 5]); context.strokeRect(outputRect.x, outputRect.y, outputRect.width, outputRect.height); context.setLineDash([]); if (interactive) { @@ -2427,6 +2503,61 @@ function sizeBoxPlace(state, render, width, height, viewWidth, viewHeight) { return { x: spot.x, top: clamp(spot.top, 4, Math.max(4, viewHeight - height - 4)) }; } +// The picture's see-through parts, read once per picture from its alpha by +// the run's rule (seeThroughMap). A mask drawn in the MaskEditor is saved +// as exactly that. `picture` is the picture as the run uses it: kept pixels +// fully solid, the rest empty so the fill shows through, where the browser +// would let a half-strength stroke show half the picture. `tint` is solid +// teal over the rest, drawn at Show blend's strength. Null for video frames +// and for a picture the run leaves as it is. Pictures over 2048 px are read +// at that size. +const MASK_LAYER_SIDE = 2048; +const MASK_TEAL = [0, 184, 174, 255]; +const MASK_TINT_ALPHA = 150 / 255; +function maskLayer(state) { + if (state.kind !== "image" || !state.image || !state.sourceWidth || !state.sourceHeight) return null; + if (state.seeThrough?.image === state.image) return state.seeThrough.layer; + let layer = null; + try { + const k = Math.min(1, MASK_LAYER_SIDE / Math.max(state.sourceWidth, state.sourceHeight)); + const width = Math.max(1, Math.round(state.sourceWidth * k)); + const height = Math.max(1, Math.round(state.sourceHeight * k)); + const picture = document.createElement("canvas"); picture.width = width; picture.height = height; + const context = picture.getContext("2d"); + context.drawImage(state.image, 0, 0, width, height); + const pixels = context.getImageData(0, 0, width, height); + const map = seeThroughMap(pixels.data); + if (map) { + const tint = document.createElement("canvas"); tint.width = width; tint.height = height; + const tintContext = tint.getContext("2d"); + const teal = tintContext.createImageData(width, height); + for (let i = 0; i < map.length; i++) { + pixels.data[i * 4 + 3] = map[i] ? 0 : 255; + if (map[i]) teal.data.set(MASK_TEAL, i * 4); + } + context.putImageData(pixels, 0, 0); + tintContext.putImageData(teal, 0, 0); + layer = { picture, tint, stencil: null }; + } + } catch { + // A picture the browser will not let us read is drawn as it is. + } + state.seeThrough = { image: state.image, layer }; + return layer; +} + +// White where the tint is, for Show blend's raster. +function maskStencil(layer) { + if (!layer.stencil) { + const stencil = document.createElement("canvas"); stencil.width = layer.tint.width; stencil.height = layer.tint.height; + const context = stencil.getContext("2d"); + context.drawImage(layer.tint, 0, 0); + context.globalCompositeOperation = "source-in"; context.fillStyle = "#fff"; context.fillRect(0, 0, stencil.width, stencil.height); + layer.stencil = stencil; + } + return layer.stencil; +} + // Draws the current source frame centered on the (already translated and // rotated) origin. During a scrub, the nearest storyboard tile stands in for // the real frame until its decode lands. @@ -2442,7 +2573,7 @@ function drawSourceImage(context, state, scale) { ); return; } - context.drawImage(state.image, -width / 2, -height / 2, width, height); + context.drawImage(maskLayer(state)?.picture ?? state.image, -width / 2, -height / 2, width, height); } // Faint diagonal lines over added space: light on a dark fill, dark on a @@ -2875,7 +3006,7 @@ export function openTransformEditorForNode(node) { } export function disposeTransformNode(node) { - const state = node.__ausbossTransformState; if (!state) return; state.disposed = true; closeEditor(state); state.panelAbort?.abort(); state.panelResizeObserver?.disconnect(); state.frameController?.abort(); if (state.frameObjectUrl) URL.revokeObjectURL(state.frameObjectUrl); + const state = node.__ausbossTransformState; if (!state) return; state.disposed = true; liveStates.delete(state); closeEditor(state); state.panelAbort?.abort(); state.panelResizeObserver?.disconnect(); state.frameController?.abort(); if (state.frameObjectUrl) URL.revokeObjectURL(state.frameObjectUrl); if (node.__ausbossImgsSuppressed) { const descriptor = node.__ausbossImgsDescriptor; if (descriptor) Object.defineProperty(node, "imgs", descriptor); else delete node.imgs; diff --git a/tests/stitch_preview.test.mjs b/tests/stitch_preview.test.mjs index 09f9770..33a3811 100644 --- a/tests/stitch_preview.test.mjs +++ b/tests/stitch_preview.test.mjs @@ -1,7 +1,9 @@ import test from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; -import { blurMask, featherGeneratedMask, growShrinkMask, overlayPlan, stitchBlendFromMask } from "../js/shared/stitch_preview.mjs"; +import { + SEE_THROUGH_KEEP_PERCENT, blurMask, featherGeneratedMask, growShrinkMask, overlayPlan, seeThroughMap, stitchBlendFromMask, +} from "../js/shared/stitch_preview.mjs"; const fixture = JSON.parse(readFileSync(new URL("./fixtures/stitch_blend_parity.json", import.meta.url), "utf8")); const { width, height } = fixture; @@ -50,3 +52,27 @@ test("overlayPlan converts output pixels into work pixels through the resize", ( assert.equal(plain.k, 1); assert.equal(plain.unit, 1); }); + +// The node's picture shows what the run paints from the picture itself (a +// MaskEditor mask, a cutout's see-through parts), so the rule must be the +// backend's: the cases of tests/test_transform_see_through.py. +const pixels = (...alphas) => Uint8ClampedArray.from(alphas.flatMap((alpha) => [10, 20, 30, alpha])); + +test("seeThroughMap keeps 90% of the most solid pixel, as the run does", () => { + assert.deepEqual(Array.from(seeThroughMap(pixels(0, 25, 128, 229, 230, 254, 255))), [1, 1, 1, 1, 0, 0, 0]); + // Measured against the most solid pixel: here 200, so 180 stays. + assert.deepEqual(Array.from(seeThroughMap(pixels(0, 100, 179, 180, 200))), [1, 1, 1, 0, 0]); +}); + +test("seeThroughMap leaves a solid picture alone and paints an empty one", () => { + assert.equal(seeThroughMap(pixels(255, 255, 255, 255)), null); + assert.equal(seeThroughMap(pixels(230, 230, 249, 254)), null); + // 229 everywhere is kept whole, but no longer left exactly as loaded. + assert.deepEqual(Array.from(seeThroughMap(pixels(229, 229))), [0, 0]); + assert.deepEqual(Array.from(seeThroughMap(pixels(0, 0, 0))), [1, 1, 1]); +}); + +test("the see-through threshold is the backend's", () => { + const engine = readFileSync(new URL("../nodes/_transform_engine.py", import.meta.url), "utf8"); + assert.equal(Number(/^SEE_THROUGH_KEEP_PERCENT = (\d+)$/m.exec(engine)?.[1]), SEE_THROUGH_KEEP_PERCENT); +});