Compare commits

...
Author SHA1 Message Date
ausboss f38ed1dc22 Fix four issues from a review of recent changes
- Crop For Inpaint: refuse a crop size that would need tens of GB of
  memory instead of trying to allocate it.
- H3 Prompt Writer timeline: retiming a line keeps its list number
  and recognises every timestamp form the backend reads.
- H3 Prompt Writer picture chips: the row grows when chips wrap, so
  they no longer spill over Length.
- Line up: clicking a resize handle without dragging no longer resizes
  the other selected nodes.
2026-11-04 00:28:37 -06:00
11 changed files with 268 additions and 18 deletions
+4 -1
View File
@@ -253,7 +253,7 @@ function run(tool) {
// took the press. Every other selected node takes its size from where it
// stood when the button went down, so nothing builds up across steps.
const together = { enabled: true, before: null, grabbed: null, handleOf: null, sides: null, frame: 0 };
const together = { enabled: true, before: null, grabbed: null, handleOf: null, sides: null, frame: 0, resized: false };
function resizableSelection(canvas) {
const { nodes } = graphSets(canvas?.graph);
@@ -288,6 +288,8 @@ function syncSizes() {
const grabbed = together.grabbed;
if (!grabbed) return;
const was = together.before.get(grabbed);
together.resized ||= grabbed.size[0] !== was.size[0] || grabbed.size[1] !== was.size[1];
if (!together.resized) return;
together.sides ??= sharedSides(
[...together.before.values()].map(({ pos, size }) => ({ x: pos[0], y: pos[1], w: size[0], h: size[1] })),
);
@@ -348,6 +350,7 @@ function endTogether() {
together.grabbed = null;
together.handleOf = null;
together.sides = null;
together.resized = false;
}
function installTogether() {
+1 -1
View File
@@ -15,7 +15,7 @@ function ensureCss() {
const style = document.createElement("style");
style.id = CSS_ID;
style.textContent = `
.ausboss-chips{display:flex;flex-wrap:wrap;align-content:flex-start;gap:5px;height:100%;min-width:0;font:600 10.5px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
.ausboss-chips{display:flex;flex-wrap:wrap;align-content:flex-start;gap:5px;min-width:0;font:600 10.5px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
.ausboss-chip{position:relative;display:flex;align-items:center;gap:4px;height:24px;max-width:100%;padding:0 8px;box-sizing:border-box;border:1px solid #2a3437;border-radius:12px;background:#0b0f10;color:#d8ecea;white-space:nowrap}
.ausboss-chip:hover{border-color:${BRAND}}
.ausboss-chip img{flex:none;width:18px;height:18px;margin-left:-5px;border-radius:9px;object-fit:cover}
+16 -11
View File
@@ -31,16 +31,27 @@ export function stripRows(report) {
}));
}
// A time typed at the start of a line: 3s:, 3 s, at 3 seconds, [3s].
const TYPED_TIME = /^\[?(?:at\s+)?\d+(?:\.\d+)?\s*(?:seconds?|secs?|s)\b\]?\s*[:,\-]?\s*/i;
// Keep these in step with NUMBER and TIMED in nodes/_h3_script_helpers.py.
const LIST_PREFIX = /^(?:\d+[.)]|[-*•]|(?:first|second|third|fourth|fifth|sixth)(?:ly)?\b[:,]?|step\s+\d+[:.]?)\s*/i;
const UNIT = String.raw`(?:seconds?|secs?|s)\b`;
const DASH = String.raw`[-–—]`;
const TYPED_TIME = new RegExp(String.raw`^\[?(?:at\s+)?(?:\d+:\d\d(?:\.\d+)?|\d+(?:\.\d+)?\s*(?:${UNIT}|(?=${DASH}\s*\d)))(?:\s*${DASH}\s*\d+(?:\.\d+)?\s*(?:${UNIT})?)?\]?\s*[:,–—\-]?\s*`, "i");
function splitMoment(line) {
const indent = line.match(/^\s*/)[0];
const body = line.slice(indent.length);
const prefix = TYPED_TIME.test(body) ? "" : body.match(LIST_PREFIX)?.[0] ?? "";
const rest = body.slice(prefix.length);
const time = rest.match(TYPED_TIME)?.[0] ?? "";
return { lead: indent + prefix, time, words: rest.slice(time.length) };
}
// The script with one line set to start at a second: its old time, if any,
// gives way to the new one. line counts typed lines from 0.
export function retime(text, line, second) {
const lines = String(text ?? "").split("\n");
if (!Number.isInteger(line) || line < 0 || line >= lines.length) return String(text ?? "");
const lead = lines[line].match(/^\s*/)[0];
const words = lines[line].slice(lead.length).replace(TYPED_TIME, "");
const { lead, words } = splitMoment(lines[line]);
lines[line] = `${lead}${trimmed(second)}s: ${words}`;
return lines.join("\n");
}
@@ -88,13 +99,7 @@ export function rulerSeconds(seconds) {
export function moveMoment(text, slots, from, to) {
const lines = String(text ?? "").split("\n");
if (from === to || !slots.every((line) => Number.isInteger(line) && line >= 0 && line < lines.length)) return String(text ?? "");
const split = (line) => {
const lead = lines[line].match(/^\s*/)[0];
const body = lines[line].slice(lead.length);
const time = body.match(TYPED_TIME)?.[0] ?? "";
return { lead, time, words: body.slice(time.length) };
};
const parts = slots.map(split);
const parts = slots.map((line) => splitMoment(lines[line]));
const words = parts.map((part) => part.words);
const [moved] = words.splice(from, 1);
words.splice(Math.max(0, Math.min(to, words.length)), 0, moved);
+18
View File
@@ -22,6 +22,24 @@
// stack's rounded bottom border once went missing.
export const WIDGET_FRAME = 20;
// A wrapped row grows from its content, which must have a natural height.
// The row's own height feeds the card total and the sockets below it.
export function observePanelHeight(row, element, content, refresh) {
const minimum = Number(row.height) || 26;
const observer = new ResizeObserver(() => {
// offsetHeight stays in layout pixels when the canvas is zoomed.
const measured = content.offsetHeight;
if (!measured) return;
const height = Math.max(minimum, measured);
if (height === row.height) return;
row.height = height;
element.style.height = `${height}px`;
refresh();
});
observer.observe(content);
return observer;
}
// The frontend pads a DOM widget's minWidth before it becomes the node's
// floor: LGraphNode.computeSize adds the room a number widget's value box
// takes (BaseWidget.minValueWidth plus both arrows and margins - 104px on
+8 -3
View File
@@ -33,7 +33,7 @@
import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "./index.mjs";
import { keepKeyInField } from "./canvas_passthrough.mjs";
import { createMediaPicker } from "./media_picker.mjs";
import { ensureNodeMinHeight, fillNodeHeight, holdNodeMinWidth, nodeHeightAfterCardChange, pinVuePanelHeight } from "./panel_layout.mjs";
import { ensureNodeMinHeight, fillNodeHeight, holdNodeMinWidth, nodeHeightAfterCardChange, observePanelHeight, pinVuePanelHeight } from "./panel_layout.mjs";
import { makeScrubInput, quantizeScrubValue, stepDecimals } from "./scrub_input.mjs";
import { limitsLabel, narrowedRange } from "./wired_limits.mjs";
import { hideWidget } from "./widget_visibility.mjs";
@@ -221,7 +221,7 @@ function isFloatWidget(widget) {
// - `top` lifts the widgets' sockets
// into the node's slot column
// { section: "Caption" }
// { panel: (node) => ({ element, sync(values) }), height }
// { panel: (node) => ({ element, sync(values) }), height, measure? }
// - the node's own display, on a row
// of the card
// { group: "advanced", label: "More" } - a disclosure; later rows with
@@ -526,11 +526,16 @@ export function mountWidgetCard(node, { rows, minWidth = 300, first = false, hid
root.append(caption); continue;
}
if (row.panel) {
// Measurements belong to this node, not the shared card definition.
const panelRow = { ...row };
const line = el("div", "ausboss-card-row panel");
line.style.height = `${rowHeight(row)}px`;
const panel = row.panel(node);
line.append(panel.element);
state.rows.push({ row, element: line, syncs: panel.sync ? [panel.sync] : [] });
state.rows.push({ row: panelRow, element: line, syncs: panel.sync ? [panel.sync] : [] });
if (row.measure && typeof ResizeObserver === "function") {
state.observers.push(observePanelHeight(panelRow, line, panel.element, refresh));
}
root.append(line); continue;
}
if (row.group !== undefined && row.widget === undefined && row.pair === undefined) {
+1 -1
View File
@@ -344,7 +344,7 @@ const CARDS = {
{ widget: "sound", label: "Sound", placeholder: "rain on a window" },
{ widget: "music", label: "Music", placeholder: "slow piano: music makes it louder" },
{ section: "Pictures and settings" },
{ panel: castChips, height: 30 },
{ panel: castChips, height: 30, measure: true },
{ widget: "seconds", label: "Length", suffix: "s" },
{ widget: "camera", label: "Camera", kind: "select" },
{ widget: "look", label: "Look", kind: "select" },
+18
View File
@@ -225,6 +225,20 @@ def _as_mask(
RESIZE_ALGORITHMS = ("bilinear", "bicubic", "area", "nearest")
# A crop keeps several float buffers alive together. Limit new picture
# buffers to 256 MiB or the source's size, before padding or resizing can
# multiply its footprint. A large source alone does not force a rejection.
_MAX_CROP_ELEMENTS = 64 * 1024 * 1024
def _check_crop_size(image: torch.Tensor, width: int, height: int) -> None:
if image.shape[0] * width * height * image.shape[3] > max(_MAX_CROP_ELEMENTS, image.numel()):
raise ValueError(
f"Crop For Inpaint: {width}x{height} across {image.shape[0]} picture(s) "
"is too large to process safely. Reduce the context, extension or "
"target size, or choose target sides with closer proportions."
)
def _resize_image(
image: torch.Tensor, width: int, height: int, algorithm: str = "bilinear"
@@ -420,6 +434,7 @@ def build_crop(
ext_u, ext_d = max(0, int(extend_up)), max(0, int(extend_down))
if ext_l or ext_r or ext_u or ext_d:
src_h, src_w = image.shape[1], image.shape[2]
_check_crop_size(image, src_w + ext_l + ext_r, src_h + ext_u + ext_d)
image = _replicate_pad_image(image, ext_l, ext_u, ext_r, ext_d)
extended = torch.ones(
(mask.shape[0], src_h + ext_u + ext_d, src_w + ext_l + ext_r),
@@ -487,7 +502,10 @@ def build_crop(
rect = expand_rect_to_multiple(rect, multiple)
rect = fit_rect(rect, width, height)
if use_target:
_check_crop_size(image, target_w, target_h)
left, top, right, bottom = rect_margins(rect, width, height)
_check_crop_size(image, width + left + right, height + top + bottom)
if left or top or right or bottom:
canvas = _replicate_pad_image(image, left, top, right, bottom)
else:
+39
View File
@@ -1,11 +1,50 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { runInNewContext } from "node:vm";
import { MIN_GAP, enoughFor, lineUp, matchWidth, sameSize, sharedSides, spreadEvenly } from "../js/shared/line_up.mjs";
const box = (x, y, w, h) => ({ x, y, w, h });
const rects = [box(10, 0, 100, 50), box(40, 100, 200, 80), box(0, 300, 60, 40)];
function resizeHandlers(layout = "column", classic = false) {
const make = (id, pos, size) => ({ id, pos, size, flags: {}, setSize(size) { this.size = size; }, computeSize: () => [100, 60] });
const first = make(1, [0, 0], [300, 100]);
const second = make(2, layout === "row" ? [500, 0] : [0, 200], [400, 150]);
const canvas = { graph: { _nodes: [first, second], _groups: [] }, selectedItems: new Set([first, second]), setDirty() {} };
const source = readFileSync(new URL("../js/line_up/index.js", import.meta.url), "utf8").replace(/^import .*;\n/gm, "");
const handlers = runInNewContext(`${source}\n({ onTogetherDown, syncSizes, onTogetherUp })`, {
app: { canvas, registerExtension() {} }, sharedSides, matchWidth,
cancelAnimationFrame() {},
});
const handle = { closest: () => ({ dataset: { nodeId: "1" } }) };
handlers.onTogetherDown({ button: 0, target: { closest: () => classic ? null : handle } });
if (classic) canvas.resizing_node = first;
return { ...handlers, first, second };
}
test("pressing and releasing a resize handle leaves other selected nodes unchanged", () => {
for (const classic of [false, true]) for (const layout of ["row", "column"]) {
const { syncSizes, onTogetherUp, second } = resizeHandlers(layout, classic);
syncSizes(); // A pointer move can arrive without a size change.
onTogetherUp();
assert.deepEqual(second.size, [400, 150]);
}
});
test("an actual resize still shares dimensions and can return to its starting size", () => {
for (const classic of [false, true]) {
const { syncSizes, onTogetherUp, first, second } = resizeHandlers("column", classic);
first.size = [350, 100];
syncSizes();
assert.deepEqual(Array.from(second.size), [350, 150]);
first.size = [300, 100];
onTogetherUp();
assert.deepEqual(Array.from(second.size), [300, 150]);
}
});
test("edges line up on the outermost edge of the selection", () => {
assert.deepEqual(lineUp(rects, "left").map((p) => p.x), [0, 0, 0]);
assert.deepEqual(lineUp(rects, "right").map((p) => p.x), [140, 40, 180]); // right edge 240 for all
+19 -1
View File
@@ -1,10 +1,28 @@
import assert from "node:assert/strict";
import test from "node:test";
import { stripCaption, stripRows, wiredJobs } from "../js/shared/moment_strip_math.mjs";
import { moveMoment, retime, stripCaption, stripRows, wiredJobs } from "../js/shared/moment_strip_math.mjs";
const row = (start, end, extra = {}) => ({ start, end, text: "She runs", cut: false, speech: false, ...extra });
test("retiming keeps the list prefix ahead of the new timestamp", () => {
for (const prefix of ["2. ", "2) ", "- ", "* ", "• ", "First, ", "secondly: ", "Step 2: "]) {
assert.equal(retime(` ${prefix}@1 sits.`, 0, 5), ` ${prefix}5s: @1 sits.`);
}
});
test("retiming replaces all supported timestamps and ranges", () => {
for (const prefix of ["", "2. "]) for (const time of ["3s:", "3 s,", "at 3 seconds,", "[3s]", "3.5s:", "at 3.5 secs,", "01:03.5:", "[at 1:03]", "3-6s:", "3s - 6 seconds,", "[3–6s]", "at 3—6 secs:"]) {
assert.equal(retime(`${prefix}${time} @1 sits.`, 0, 5), `${prefix}5s: @1 sits.`, time);
}
});
test("reordering timed list items leaves times and list prefixes in their slots", () => {
const text = "1. [0-3s] @1 walks.\n2) 00:03-6s: @1 sits.\n\nStep 3: at 6 seconds, @1 runs.";
assert.equal(moveMoment(text, [0, 1, 3], 2, 0),
"1. [0-3s] @1 runs.\n2) 00:03-6s: @1 walks.\n\nStep 3: at 6 seconds, @1 sits.");
});
test("a row says its second and its words, and its bar sits where it plays", () => {
const rows = stripRows({ seconds: 10, moments: [row(0, 2.5), row(2.5, 10)] });
assert.deepEqual(rows.map((moment) => moment.second), ["0", "2.5"]);
+110
View File
@@ -1,5 +1,9 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
import { runInNewContext } from "node:vm";
import * as cardMath from "../js/shared/widget_card_math.mjs";
import * as panelLayout from "../js/shared/panel_layout.mjs";
import {
ensureNodeMinHeight,
@@ -11,7 +15,113 @@ import {
measureLayoutWidthPadding,
nodeHeightAfterCardChange,
pinVuePanelHeight,
observePanelHeight,
} from "../js/shared/panel_layout.mjs";
import { cardHeight, rowTops } from "../js/shared/widget_card_math.mjs";
test("a measured card row updates its node and sockets without changing another node", () => {
const observed = [];
class PanelObserver {
constructor(callback) { this.callback = callback; this.disconnected = false; }
observe(content) { this.content = content; observed.push(this); }
disconnect() { this.disconnected = true; }
}
const element = () => ({
style: {}, classList: { add() {}, remove() {}, toggle() {} }, children: [],
append(...children) { this.children.push(...children); }, addEventListener() {},
});
const source = readFileSync(new URL("../js/shared/widget_card.mjs", import.meta.url), "utf8")
.replace(/^import [\s\S]*?;\n/gm, "").replace(/^export \* from .*;\n/gm, "").replace(/^export /gm, "");
const mount = runInNewContext(`${source}\nmountWidgetCard`, {
...cardMath, ...panelLayout, BRAND: "#00b4aa",
document: { getElementById: () => true, createElement: element },
ResizeObserver: PanelObserver,
observePanelHeight(row, line, content, refresh) {
// Run the real observer helper in the same test realm as the mock.
const saved = globalThis.ResizeObserver;
globalThis.ResizeObserver = PanelObserver;
try { return panelLayout.observePanelHeight(row, line, content, refresh); }
finally { globalThis.ResizeObserver = saved; }
},
chainCallback(target, name, callback) {
const previous = target[name];
target[name] = function (...args) { previous?.apply(this, args); callback.apply(this, args); };
},
keepDomWidgetWidthAuto() {}, pinVuePanelHeight() {}, holdNodeMinWidth() {},
hideWidget(widget) { widget.hidden = true; },
});
const definition = { panel: () => ({ element: { offsetHeight: 24 } }), height: 30, measure: true };
const make = () => {
const node = {
size: [420, 100], pos: [0, 0], widgets: [{ name: "seconds", type: "text", value: "8" }],
inputs: [{ name: "seconds", widget: { name: "seconds" }, link: null }],
addDOMWidget(name, type, root, options) {
const widget = { name, type, element: root, options, y: 0 };
this.widgets.push(widget);
return widget;
},
computeSize() { return this.__ausbossCard.widget.computeSize(420); },
setSize(size) { this.size = Array.from(size); },
};
return { node, state: mount(node, { rows: [definition, { widget: "seconds", kind: "text" }], minWidth: 420 }) };
};
const first = make();
const second = make();
const height = first.node.size[1];
const socketY = first.node.widgets[0].y;
if (observed[0]) {
observed[0].content.offsetHeight = 53;
observed[0].callback();
}
assert.equal(first.node.size[1], height + 23);
assert.equal(first.node.widgets[0].y, socketY + 23);
assert.equal(second.node.size[1], height);
assert.equal(definition.height, 30);
first.node.onRemoved();
assert.equal(observed[0].disconnected, true);
});
test("wrapped panel content moves the next row and grows the card, then shrinks again", () => {
const saved = globalThis.ResizeObserver;
let changed;
let disconnected = false;
globalThis.ResizeObserver = class {
constructor(callback) { changed = callback; }
observe(element) { assert.equal(element, content); }
disconnect() { disconnected = true; }
};
const row = { panel: () => {}, height: 30 };
const next = { widget: "seconds" };
const element = { style: {} };
let height = 24;
const content = { get offsetHeight() { return height; }, getBoundingClientRect: () => ({ height: height / 2 }) };
const initialHeight = cardHeight([row, next]);
const initialTop = rowTops([row, next])[1];
let refreshed = 0;
try {
const observer = observePanelHeight(row, element, content, () => { refreshed += 1; });
changed();
assert.equal(row.height, 30);
height = 53; // Two 24px chip rows and the 5px gap.
changed();
assert.equal(element.style.height, "53px");
assert.equal(cardHeight([row, next]), initialHeight + 23);
assert.equal(rowTops([row, next])[1], initialTop + 23);
changed();
assert.equal(refreshed, 1, "unchanged measurements do not resize again");
height = 0; // A hidden panel does not erase its minimum.
changed();
assert.equal(row.height, 53);
height = 24;
changed();
assert.equal(row.height, 30);
assert.equal(cardHeight([row, next]), initialHeight);
observer.disconnect();
assert.equal(disconnected, true);
} finally {
globalThis.ResizeObserver = saved;
}
});
test("fillNodeHeight drops computeSize and declares a floor with no ceiling", () => {
const widget = { computeSize: () => [0, 200], options: {} };
+34
View File
@@ -8,6 +8,7 @@ from pathlib import Path
import sys
import types
import unittest
from unittest.mock import patch
import torch
@@ -145,6 +146,39 @@ class GeometryTests(unittest.TestCase):
class CropContractTests(unittest.TestCase):
def test_size_guard_allows_a_large_source_without_amplifying_it(self):
image = torch.zeros((2, 32, 32, 4))
mask = torch.ones((1, 32, 32))
with patch.object(inpaint_helpers, "_MAX_CROP_ELEMENTS", 1024):
cropped, _, _ = build_crop(image, mask, 1.0, 0, 32)
self.assertEqual(cropped.shape, image.shape)
with self.assertRaisesRegex(ValueError, "too large"):
build_crop(image, mask, 1.0, 0, 32, target_width=64, target_height=64)
def test_extreme_target_aspects_fail_before_allocating_padding(self):
image = torch.zeros((1, 2048, 2048, 3))
mask = torch.ones((1, 2048, 2048))
for keep_inside in (False, True):
for width, height in ((16384, 32), (32, 16384)):
with self.subTest(keep_inside=keep_inside, target=(width, height)):
with patch.object(inpaint_helpers, "_replicate_pad_image", side_effect=AssertionError("unsafe allocation")) as pad:
with self.assertRaisesRegex(ValueError, "Crop For Inpaint.*too large"):
build_crop(image, mask, 1.0, 0, 32, target_width=width, target_height=height, keep_inside=keep_inside)
pad.assert_not_called()
def test_size_guard_covers_extension_native_context_and_sampler_output(self):
image = torch.zeros((1, 32, 32, 3))
mask = torch.ones((1, 32, 32))
for options in ({"extend_right": 100_000_000}, {"context_factor": 10000},
{"target_width": 16384, "target_height": 16384}):
with self.subTest(options=options):
with (
patch.object(inpaint_helpers, "_replicate_pad_image", side_effect=AssertionError("unsafe allocation")),
patch.object(inpaint_helpers, "_resize_image", side_effect=AssertionError("unsafe resize")),
):
with self.assertRaisesRegex(ValueError, "Crop For Inpaint.*too large"):
build_crop(image, mask, blend_pixels=0, output_multiple=32, **{"context_factor": 1.0, **options})
def test_stitcher_schema(self):
image = rand_image(1, 64, 96)
mask = box_mask(64, 96, 24, 40, 40, 56)