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); +});