Resolution Master is renamed Latent Size: the old name belongs to another
node pack. The node id AUSBOSS_NODES_Resolution is unchanged, so saved
workflows and API graphs load and run as before. Workflows only store a
title that differs from the default, so they pick up the new name; a saved
title that starts with "Resolution Master 🆎" gets the new name in its
place on load. The example, its thumbnail and the README image follow.
- Latent Size can be resized: wider stretches the chips, fields and size
picks, and taller gives the stage the room. It opens at 340 x 531 as
before and goes down to 320, where the size row tightens so W, H and MP
stay whole.
- LoRA Loader: gear -> Long names, end (the default, as before) or middle,
which keeps the checkpoint number at the end of a name in view. The
picker opens wide enough for long names and shortens the folder first.
- The frontend adds 104 px to a DOM panel's minWidth before using it as
the node's floor (LGraphNode.computeSize), so a node that opened
narrower jumped wider the moment its corner was touched: the LoRA Loader
could not go under 424. fillNodeHeight gains exactMinWidth, which
measures that padding and takes it back off, and holdVueNodeMinWidth
lends the floor to Nodes 2.0, whose corner drag reads the node's inline
min-width instead. Both nodes use them; the other panels keep their old
floors for now.
- README: the workflow-tools image shows Latent Size alone; the Seed card
is gone.
409 lines
16 KiB
JavaScript
409 lines
16 KiB
JavaScript
// Pure geometry for the Latent Size 🆎 node: snapping, ratio math, drag
|
||
// mapping, and readouts. DOM-free so node:test covers it
|
||
// (tests/resolution_math.test.mjs); js/resolution/index.js wires it to the
|
||
// panel.
|
||
|
||
export const DIM_MIN = 64;
|
||
export const DIM_MAX = 8192;
|
||
export const SNAP_STEPS = [8, 16, 32, 64];
|
||
export const DEFAULT_SNAP = 32;
|
||
|
||
export function clampDim(value) {
|
||
const number = Math.round(Number(value));
|
||
if (!Number.isFinite(number)) return DIM_MIN;
|
||
return Math.max(DIM_MIN, Math.min(DIM_MAX, number));
|
||
}
|
||
|
||
// Round to the step without ever snapping below one step, so a small
|
||
// dimension snaps UP instead of collapsing to zero.
|
||
export function snapDim(value, step = DEFAULT_SNAP) {
|
||
const s = Number(step) > 0 ? Number(step) : 1;
|
||
return clampDim(Math.max(s, Math.round(Number(value) / s) * s));
|
||
}
|
||
|
||
function gcd(a, b) {
|
||
let x = Math.abs(Math.round(a));
|
||
let y = Math.abs(Math.round(b));
|
||
while (y) [x, y] = [y, x % y];
|
||
return x || 1;
|
||
}
|
||
|
||
function lcm(a, b) {
|
||
return (a * b) / gcd(a, b);
|
||
}
|
||
|
||
// One snap system for ratio-locked sizes: with width = rx*k and
|
||
// height = ry*k, both dimensions land on the snap grid exactly when k is a
|
||
// multiple of lcm(step/gcd(step,rx), step/gcd(step,ry)). 7:4 at /32 gives
|
||
// stride 32 (k=192 -> 1344x768); 16:9 at /16 gives stride 16 (k=80 ->
|
||
// 1280x720). The ratio stays mathematically exact AND on-grid - no second
|
||
// snapping pass, no drift.
|
||
export function ratioStride(ratio, step) {
|
||
const s = Number(step) > 0 ? Math.round(step) : 1;
|
||
if (s <= 1) return 1;
|
||
return lcm(s / gcd(s, ratio.x), s / gcd(s, ratio.y));
|
||
}
|
||
|
||
// Some ratio/step pairs only align at coarse strides (16:9 at /32 moves
|
||
// width in 512px jumps). The panel warns past this per-axis step so the
|
||
// user can pick a finer snap instead of thinking the drag is stuck.
|
||
export function strideWarning(ratio, step, threshold = 256) {
|
||
const stride = ratioStride(ratio, step);
|
||
return stride * Math.max(ratio.x, ratio.y) > threshold;
|
||
}
|
||
|
||
// Reduced integer ratio of a size, e.g. 1344x768 -> {x: 7, y: 4}.
|
||
export function reduceRatio(width, height) {
|
||
const d = gcd(width, height);
|
||
return { x: Math.round(width / d), y: Math.round(height / d) };
|
||
}
|
||
|
||
// Human ratio label: exact when the reduced terms stay small, otherwise a
|
||
// one-decimal approximation ("~1.78:1" / "~1:1.42") that reads better than
|
||
// a monster fraction like 341:192.
|
||
export function ratioLabel(width, height) {
|
||
const { x, y } = reduceRatio(width, height);
|
||
if (x <= 32 && y <= 32) return `${x}:${y}`;
|
||
const value = width / height;
|
||
return value >= 1
|
||
? `~${(Math.round(value * 100) / 100)}:1`
|
||
: `~1:${(Math.round((1 / value) * 100) / 100)}`;
|
||
}
|
||
|
||
export function megapixels(width, height) {
|
||
return Math.round((width * height) / 10000) / 100;
|
||
}
|
||
|
||
export function readout(width, height) {
|
||
return `${width} × ${height} · ${megapixels(width, height).toFixed(2)} MP · ${ratioLabel(width, height)}`;
|
||
}
|
||
|
||
// Largest w x h with the given aspect that fits a box - the chip and stage
|
||
// thumbnails.
|
||
export function fitRectInBox(aspectW, aspectH, boxW, boxH) {
|
||
const scale = Math.min(boxW / aspectW, boxH / aspectH);
|
||
return { w: Math.max(1, aspectW * scale), h: Math.max(1, aspectH * scale) };
|
||
}
|
||
|
||
// A size on an exact integer ratio nearest a pixel target for the driving
|
||
// axis: width = rx*k, height = ry*k for whole k, so the ratio never drifts
|
||
// however far the drag travels or however coarse the snap. When snapping,
|
||
// k moves in ratioStride multiples so BOTH dimensions stay on the grid.
|
||
export function ratioSizeForAxis(ratio, axis, target, step = 1) {
|
||
const r = axis === "height" ? ratio.y : ratio.x;
|
||
const kStep = ratioStride(ratio, step);
|
||
const minK = Math.ceil(Math.max(DIM_MIN / ratio.x, DIM_MIN / ratio.y) / kStep) * kStep;
|
||
const maxK = Math.floor(Math.min(DIM_MAX / ratio.x, DIM_MAX / ratio.y) / kStep) * kStep;
|
||
if (minK > maxK) {
|
||
// Extreme ratios and coarse grids may have no exact solution. Keep
|
||
// dimensions legal and use the nearest bounded approximation.
|
||
const factor = Math.min(DIM_MAX / Math.max(ratio.x, ratio.y),
|
||
Math.max(DIM_MIN / Math.min(ratio.x, ratio.y), target / r));
|
||
return { width: snapDim(ratio.x * factor, step), height: snapDim(ratio.y * factor, step) };
|
||
}
|
||
const k = Math.max(minK, Math.min(maxK, Math.round(target / r / kStep) * kStep));
|
||
const width = ratio.x * k;
|
||
const height = ratio.y * k;
|
||
return { width, height };
|
||
}
|
||
|
||
// Nearest size on the ratio to a free wxh point, judged by area, driven by
|
||
// the longer axis for stability.
|
||
export function ratioSizeNear(ratio, width, height, step = 1) {
|
||
const byW = ratioSizeForAxis(ratio, "width", width, step);
|
||
const byH = ratioSizeForAxis(ratio, "height", height, step);
|
||
const errW = Math.abs(byW.width - width) + Math.abs(byW.height - height);
|
||
const errH = Math.abs(byH.width - width) + Math.abs(byH.height - height);
|
||
return errW <= errH ? byW : byH;
|
||
}
|
||
|
||
// ---------- the stage drag model ----------
|
||
//
|
||
// The stage shows the current rectangle centered in a fixed box at a live
|
||
// scale (px per CSS px). A drag grabs an edge or corner and moves that
|
||
// edge; the rectangle stays centered, so moving the right edge out grows
|
||
// width symmetrically. deltas are CSS px; scale converts to image px.
|
||
//
|
||
// handle: "e" | "w" grow width; "n" | "s" grow height;
|
||
// "ne" "nw" "se" "sw" grow both.
|
||
// modifiers: snap (default true; false = 1px freedom),
|
||
// lockRatio (true = keep the grab-time ratio exactly).
|
||
|
||
export function stageScale(width, height, boxW, boxH) {
|
||
// Fit with fixed headroom for the handles, corner hints, and the label
|
||
// (the design's 90px horizontal / 60px vertical margins), never below a
|
||
// sliver on a tiny stage.
|
||
const usableW = Math.max(40, boxW - 90);
|
||
const usableH = Math.max(40, boxH - 60);
|
||
return Math.max(0.002, Math.min(usableW / width, usableH / height));
|
||
}
|
||
|
||
const HANDLE_AXES = {
|
||
e: { x: 1, y: 0 }, w: { x: 1, y: 0 },
|
||
n: { x: 0, y: 1 }, s: { x: 0, y: 1 },
|
||
ne: { x: 1, y: 1 }, nw: { x: 1, y: 1 }, se: { x: 1, y: 1 }, sw: { x: 1, y: 1 },
|
||
};
|
||
|
||
// Outward-positive delta for a handle: dragging the east edge right, the
|
||
// west edge left, the north edge up, or the south edge down all GROW.
|
||
export function outwardDelta(handle, dx, dy) {
|
||
const sx = handle.includes("w") ? -1 : handle.includes("e") ? 1 : 0;
|
||
const sy = handle.includes("n") ? -1 : handle.includes("s") ? 1 : 0;
|
||
return { ox: dx * sx, oy: dy * sy };
|
||
}
|
||
|
||
export function dragResize({
|
||
handle, startWidth, startHeight, dx, dy, scale,
|
||
snap = DEFAULT_SNAP, snapOn = true, lockRatio = false,
|
||
}) {
|
||
const axes = HANDLE_AXES[handle] ?? { x: 1, y: 1 };
|
||
const { ox, oy } = outwardDelta(handle, dx, dy);
|
||
// Centered rectangle: an edge moving out d grows the dimension 2d.
|
||
const growX = axes.x ? (ox * 2) / scale : 0;
|
||
const growY = axes.y ? (oy * 2) / scale : 0;
|
||
const step = snapOn ? snap : 1;
|
||
|
||
if (lockRatio) {
|
||
const ratio = reduceRatio(startWidth, startHeight);
|
||
// Exact-k locking is only pleasant when the ratio aligns to the grid
|
||
// at a workable stride. Custom ratios (a snapped 88:115, say) would
|
||
// jump hundreds of pixels per step, so they scale proportionally at
|
||
// snap granularity instead - fluid, ratio approximately kept.
|
||
if (snapOn && strideWarning(ratio, snap)) {
|
||
const factor = Math.max(
|
||
0.05,
|
||
1 + (growX / startWidth + growY / startHeight) / 2,
|
||
);
|
||
return {
|
||
width: snapDim(startWidth * factor, step),
|
||
height: snapDim(startHeight * factor, step),
|
||
};
|
||
}
|
||
// The dominant travel axis drives; the other follows the exact ratio.
|
||
const useWidth = axes.x && (!axes.y || Math.abs(growX) >= Math.abs(growY));
|
||
return useWidth
|
||
? ratioSizeForAxis(ratio, "width", startWidth + growX, step)
|
||
: ratioSizeForAxis(ratio, "height", startHeight + growY, step);
|
||
}
|
||
return {
|
||
width: axes.x ? snapDim(startWidth + growX, step) : snapDim(startWidth, 1),
|
||
height: axes.y ? snapDim(startHeight + growY, step) : snapDim(startHeight, 1),
|
||
};
|
||
}
|
||
|
||
// ---------- chip policy: SHAPE x BUDGET ----------
|
||
|
||
// Clicking a ratio chip changes the SHAPE while keeping the pixel budget
|
||
// and the current orientation: 3:2 clicked while portrait yields 2:3, and
|
||
// the area stays as close to the current area as the grid allows. From the
|
||
// 1024x1024 default this lands exactly on the real-world sizes: 7:4 at /32
|
||
// gives 1344x768, 16:9 at /16 gives 1280x720.
|
||
// portrait defaults to the size's own orientation; a square caller passes
|
||
// the orientation it wants, since a square has none to read.
|
||
export function applyRatioAtBudget(width, height, ratio, step = DEFAULT_SNAP, portrait = height > width) {
|
||
const flipped = portrait !== ratio.y > ratio.x && ratio.x !== ratio.y;
|
||
const shaped = flipped ? { x: ratio.y, y: ratio.x } : { x: ratio.x, y: ratio.y };
|
||
const area = width * height;
|
||
const idealWidth = Math.sqrt(area * (shaped.x / shaped.y));
|
||
const kStep = ratioStride(shaped, step);
|
||
const candidates = [
|
||
ratioSizeForAxis(shaped, "width", idealWidth, step),
|
||
ratioSizeForAxis(shaped, "width", idealWidth + kStep * shaped.x, step),
|
||
ratioSizeForAxis(shaped, "width", idealWidth - kStep * shaped.x, step),
|
||
];
|
||
let best = candidates[0];
|
||
let bestError = Infinity;
|
||
for (const size of candidates) {
|
||
const error = Math.abs(size.width * size.height - area);
|
||
if (error < bestError) {
|
||
bestError = error;
|
||
best = size;
|
||
}
|
||
}
|
||
return best;
|
||
}
|
||
|
||
// How the current size relates to a chip's ratio: "exact", "near" (within
|
||
// tolerance, rendered outlined), or null. Orientation-blind - a portrait
|
||
// 2:3 lights the 3:2 chip with flipped:true.
|
||
export function chipMatch(width, height, ratio, tolerance = 0.015) {
|
||
const current = reduceRatio(width, height);
|
||
const flippedMatch = current.x === ratio.y && current.y === ratio.x;
|
||
if ((current.x === ratio.x && current.y === ratio.y) || (flippedMatch && ratio.x !== ratio.y)) {
|
||
return { state: "exact", flipped: flippedMatch && ratio.x !== ratio.y };
|
||
}
|
||
const value = width / height;
|
||
const chipValue = ratio.x / ratio.y;
|
||
const drift = Math.abs(Math.log(value / chipValue));
|
||
const driftFlipped = Math.abs(Math.log(value / (ratio.y / ratio.x)));
|
||
if (drift <= tolerance) return { state: "near", flipped: false };
|
||
if (ratio.x !== ratio.y && driftFlipped <= tolerance) return { state: "near", flipped: true };
|
||
return null;
|
||
}
|
||
|
||
// Parse the gear menu's editable rail text ("1:1, 4:3, 7:4") into ratio
|
||
// objects; junk entries drop, duplicates collapse, and an empty result
|
||
// falls back to the shipped defaults so the rail can never vanish.
|
||
export const DEFAULT_RAIL = "1:1, 4:3, 3:2, 16:9, 7:4, 2:1";
|
||
|
||
export function parseRail(text) {
|
||
const seen = new Set();
|
||
const rail = [];
|
||
for (const token of String(text ?? "").split(",")) {
|
||
const match = token.trim().match(/^(\d{1,3})\s*[:x]\s*(\d{1,3})$/i);
|
||
if (!match) continue;
|
||
const x = Number(match[1]);
|
||
const y = Number(match[2]);
|
||
if (!x || !y) continue;
|
||
const d = gcd(x, y);
|
||
const key = `${x / d}:${y / d}`;
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
rail.push({ x: x / d, y: y / d, label: `${x}:${y}` });
|
||
}
|
||
return rail.length ? rail : parseRail(DEFAULT_RAIL);
|
||
}
|
||
|
||
// The rail shows every ratio in the CANVAS's current orientation - a
|
||
// portrait canvas renders the 3:2 chip as "2:3" with a portrait glyph -
|
||
// so what you see is exactly what a click gives you.
|
||
export function orientRatio(ratio, portrait) {
|
||
const long = Math.max(ratio.x, ratio.y), short = Math.min(ratio.x, ratio.y);
|
||
const x = portrait ? short : long, y = portrait ? long : short;
|
||
return { x, y, label: `${x}:${y}` };
|
||
}
|
||
|
||
// ---------- quick sizes ----------
|
||
|
||
// The per-ratio quick list, anchored to the canonical square areas
|
||
// (512^2, 768^2, 1024^2, 1280^2, 1536^2, 1792^2, 2048^2): each rung is
|
||
// the nearest on-ratio, on-grid size to that pixel budget. Generated,
|
||
// never curated - so 1:1 at /64 IS the familiar 512/768/1024/1280/1536/
|
||
// 1792/2048 ladder, and every other ratio gets the equivalent rungs
|
||
// (16:9 at /16 includes 1280x720, 7:4 at /32 includes 1344x768).
|
||
export const LADDER_EDGES = [512, 768, 1024, 1280, 1536, 1792, 2048];
|
||
|
||
export function quickSizes(width, height, step, count = 8) {
|
||
const ratio = reduceRatio(width, height);
|
||
const kStep = ratioStride(ratio, step);
|
||
const area = ratio.x * ratio.y;
|
||
const seen = new Set();
|
||
const list = [];
|
||
for (const edge of LADDER_EDGES) {
|
||
const idealK = Math.sqrt((edge * edge) / area);
|
||
const k = Math.max(kStep, Math.round(idealK / kStep) * kStep);
|
||
const w = ratio.x * k;
|
||
const h = ratio.y * k;
|
||
if (w < DIM_MIN || h < DIM_MIN || w > DIM_MAX || h > DIM_MAX) continue;
|
||
const key = `${w}x${h}`;
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
list.push({ width: w, height: h });
|
||
}
|
||
return list
|
||
.sort((a, b) => a.width * a.height - b.width * b.height)
|
||
.slice(0, count);
|
||
}
|
||
|
||
// Every ratio's canonical default - the ~1MP rung, exactly what a chip
|
||
// click lands on (1:1 -> 1024x1024, 16:9 at /16 -> 1280x720, 7:4 at /32
|
||
// -> 1344x768). Same idea as Pixaroma's hand-written per-ratio default
|
||
// table, derived instead of curated.
|
||
export function defaultSizeForRatio(ratio, step) {
|
||
return applyRatioAtBudget(1024, 1024, ratio, step, ratio.y > ratio.x);
|
||
}
|
||
|
||
// ---------- megapixel ladder ----------
|
||
|
||
export const MP_LADDER = [0.25, 0.5, 1, 1.5, 2, 3, 4];
|
||
|
||
// Walk the rung ladder from wherever the current area sits: nearest rung
|
||
// first (by log distance), then one step in the asked direction.
|
||
export function stepMegapixels(currentMp, direction) {
|
||
let nearest = 0;
|
||
let bestDistance = Infinity;
|
||
MP_LADDER.forEach((rung, index) => {
|
||
const distance = Math.abs(Math.log(Math.max(0.01, currentMp) / rung));
|
||
if (distance < bestDistance) {
|
||
bestDistance = distance;
|
||
nearest = index;
|
||
}
|
||
});
|
||
const next = Math.max(0, Math.min(MP_LADDER.length - 1, nearest + Math.sign(direction)));
|
||
return MP_LADDER[next];
|
||
}
|
||
|
||
// ---------- pins (user presets) ----------
|
||
|
||
export const MAX_PINS = 12;
|
||
|
||
export function parsePins(raw) {
|
||
try {
|
||
const data = JSON.parse(raw);
|
||
if (!Array.isArray(data)) return [];
|
||
return data
|
||
.filter((pin) => pin && Number.isFinite(pin.w) && Number.isFinite(pin.h))
|
||
.map((pin) => ({ w: clampDim(pin.w), h: clampDim(pin.h) }))
|
||
.slice(0, MAX_PINS);
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
export function serializePins(pins) {
|
||
return JSON.stringify(pins);
|
||
}
|
||
|
||
// Star toggles: pinning an already-pinned size removes it.
|
||
export function togglePin(pins, width, height) {
|
||
const list = pins.filter((pin) => !(pin.w === width && pin.h === height));
|
||
if (list.length === pins.length) list.unshift({ w: clampDim(width), h: clampDim(height) });
|
||
return list.slice(0, MAX_PINS);
|
||
}
|
||
|
||
export function pinLabel(pin) {
|
||
return `${pin.w}×${pin.h}`;
|
||
}
|
||
|
||
// ---------- misc panel helpers ----------
|
||
|
||
export function offGrid(width, height, step) {
|
||
const s = Number(step) > 0 ? step : 1;
|
||
return width % s !== 0 || height % s !== 0;
|
||
}
|
||
|
||
// The W/H boxes reuse the pack's scrub grammar: pointer travel past a dead
|
||
// zone moves the value in snap-step increments.
|
||
export function scrubDim(startValue, deltaX, step, pixelsPerStep = 4, deadZone = 3) {
|
||
if (Math.abs(deltaX) <= deadZone) return clampDim(startValue);
|
||
const travel = deltaX - Math.sign(deltaX) * deadZone;
|
||
const steps = Math.round(travel / pixelsPerStep);
|
||
return snapDim(startValue + steps * (Number(step) > 0 ? step : 1), Number(step) > 0 ? step : 1);
|
||
}
|
||
|
||
// ---------- megapixel retarget ----------
|
||
|
||
// Scale a size to a megapixel target. Exact-ratio version of sqrt scaling:
|
||
// walk the reduced ratio's integer k to the area nearest the target, so
|
||
// 2/3-ish sizes do not drift ratio when retargeted.
|
||
export function sizeForMegapixels(width, height, targetMp, step = DEFAULT_SNAP) {
|
||
const ratio = reduceRatio(width, height);
|
||
const targetPixels = Math.max(0.05, Number(targetMp) || 0.05) * 1_000_000;
|
||
const idealWidth = Math.sqrt(targetPixels * (ratio.x / ratio.y));
|
||
const candidates = [
|
||
ratioSizeForAxis(ratio, "width", idealWidth, step),
|
||
ratioSizeForAxis(ratio, "width", idealWidth + step, step),
|
||
ratioSizeForAxis(ratio, "width", idealWidth - step, step),
|
||
];
|
||
let best = candidates[0];
|
||
let bestError = Infinity;
|
||
for (const size of candidates) {
|
||
const error = Math.abs(size.width * size.height - targetPixels);
|
||
if (error < bestError) {
|
||
bestError = error;
|
||
best = size;
|
||
}
|
||
}
|
||
return best;
|
||
}
|