Files
ausboss-ComfyUI-AusBoss/js/shared/lora_stack.mjs
T
ausboss c15c6c9317 Implement the pack audit batch: on-node pad editors, feature gaps, removals
The audit compared every node against ComfyUI core and the major packs;
this lands its outcomes in one branch.

On-node visual editing (the old Codex-pack interaction, rebuilt):
- New Load Image + Pad: image loader with whole-edge pad dragging on the
  node canvas, align-to-multiple rounding, and a megapixel target that
  rescales the source before padding so the mask seam stays binary.
- Pad Image gains the same handle canvas, fed by an execution-time
  snapshot of its input; wireframe with diagnostic empty states before
  the first run. Widgets, outputs, and saved workflows unchanged.
- Image Crop + Rotate + Pad's compact panel is now a live stage - crop,
  pad, and rotate handles on the node, one shared implementation with
  the full-screen editor so the two surfaces cannot drift.

Feature gaps closed against the ecosystem:
- Crop For Inpaint: target_megapixels, selectable rescale_algorithm
  (recorded in the stitcher for the paste-back), and extend_* inputs
  that grow the frame itself for outpainting.
- Save Video: format choice - mp4 h264, mp4 h265, webm vp9 with Opus
  (resampled to 48 kHz); CRF drives all three.
- Load Video: every_nth thinning (fps output divides to match) and a
  max_frames cap that ends the decode early.
- LoRA Loader: saved stack templates behind a bar button, and a console
  warning when a LoRA's base-model family disagrees with the checkpoint.
- LM Studio Chat: history in/out for multi-turn chains and a json_schema
  widget for structured replies.
- Drop Shadow: multiply blend and a shadow_mask output. Align Image:
  offset outputs for un-aligning after sampling. Color Match:
  first_frame reference mode as a one-node video flicker fix.

Removed (backed up in git history): Select Frame Range and the Video
Bundle family - core ImageFromBatch and the core VIDEO wire now cover
both jobs. Dead NODE_ID module variables cleaned out of every node file.

455 Python tests, 216 JS tests, validate_nodes.py green on 19 node files.
2026-08-17 10:49:55 -05:00

250 lines
8.2 KiB
JavaScript

// Pure state logic for the LoRA Loader rows: stack normalization, strength
// scrubbing math, and the picker's filter/keyboard model. DOM wiring lives in
// js/lora_loader/index.js; everything here runs under node:test.
export const STRENGTH_MIN = -10;
export const STRENGTH_MAX = 10;
export const DEFAULT_STEP = 0.05;
export const FINE_STEP = 0.01;
// Horizontal pixels of drag per strength step; small enough to feel light,
// large enough that a shaky click cannot change the value.
export const SCRUB_PIXELS_PER_STEP = 4;
export const SCRUB_DEAD_ZONE = 3;
export const MAX_ROWS = 64;
let rowSerial = 0;
export function clampStrength(value) {
const number = Number(value);
if (!Number.isFinite(number)) return 0;
return Math.max(STRENGTH_MIN, Math.min(STRENGTH_MAX, number));
}
export function roundStrength(value) {
return Math.round(clampStrength(value) * 100) / 100;
}
export function newRow() {
rowSerial += 1;
return {
id: `r${Date.now().toString(36)}${rowSerial}`,
name: "",
strength: 1,
strength_clip: 1,
enabled: true,
triggers: "",
};
}
export function normalizeRows(parsed) {
if (!Array.isArray(parsed)) return [];
return parsed.slice(0, MAX_ROWS).map((entry) => {
const row = newRow();
if (entry && typeof entry === "object") {
row.id = typeof entry.id === "string" && entry.id ? entry.id : row.id;
row.name = typeof entry.name === "string" ? entry.name : "";
row.strength = roundStrength(entry.strength ?? 1);
row.strength_clip = roundStrength(entry.strength_clip ?? row.strength);
row.enabled = entry.enabled !== false;
row.triggers = typeof entry.triggers === "string" ? entry.triggers : "";
}
return row;
});
}
export function parseRows(raw) {
try {
return normalizeRows(JSON.parse(raw));
} catch {
return [];
}
}
export function serializeRows(rows) {
return JSON.stringify(rows);
}
export function moveRow(rows, index, delta) {
const target = index + delta;
if (index < 0 || index >= rows.length || target < 0 || target >= rows.length) return rows;
const next = rows.slice();
const [row] = next.splice(index, 1);
next.splice(target, 0, row);
return next;
}
export function setStrength(rows, index, value, linked) {
const next = rows.slice();
const row = { ...next[index], strength: roundStrength(value) };
if (linked) row.strength_clip = row.strength;
next[index] = row;
return next;
}
// Scrub model: value follows horizontal pointer distance from the grab point.
// Inside the dead zone nothing changes, so a plain click stays a click. The
// coarse step is configurable (the gear menu's "Strength step"); Shift always
// scrubs by the fine step.
export function scrubValue(startValue, deltaX, fine = false, coarseStep = DEFAULT_STEP) {
if (Math.abs(deltaX) <= SCRUB_DEAD_ZONE) return roundStrength(startValue);
const coarse = Number.isFinite(coarseStep) && coarseStep > 0 ? coarseStep : DEFAULT_STEP;
const step = fine ? FINE_STEP : coarse;
const travel = deltaX - Math.sign(deltaX) * SCRUB_DEAD_ZONE;
const steps = Math.round(travel / SCRUB_PIXELS_PER_STEP);
return roundStrength(startValue + steps * step);
}
export function isScrubbing(deltaX, deltaY) {
return Math.abs(deltaX) > SCRUB_DEAD_ZONE && Math.abs(deltaX) >= Math.abs(deltaY);
}
export function filterLoras(names, query) {
const needle = String(query || "").trim().toLowerCase();
if (!needle) return names.slice();
return names.filter((name) => name.toLowerCase().includes(needle));
}
// Picker keyboard model: a highlight index over the filtered list.
// Arrow keys wrap; Home/End jump; a shrinking filter keeps the highlight
// valid. Enter resolves to the highlighted name (null when the list is empty).
export function moveHighlight(index, delta, length) {
if (length <= 0) return -1;
if (index < 0) return delta >= 0 ? 0 : length - 1;
return (index + delta + length) % length;
}
export function clampHighlight(index, length) {
if (length <= 0) return -1;
if (index < 0) return 0;
return Math.min(index, length - 1);
}
export function highlightedName(names, index) {
return index >= 0 && index < names.length ? names[index] : null;
}
export function toggleTrigger(triggers, word) {
const parts = String(triggers || "")
.split(",")
.map((part) => part.trim())
.filter(Boolean);
const key = word.trim().toLowerCase();
const existing = parts.findIndex((part) => part.toLowerCase() === key);
if (existing >= 0) parts.splice(existing, 1);
else if (word.trim()) parts.push(word.trim());
return parts.join(", ");
}
export function summarizeRows(rows) {
const on = rows.filter((row) => row.enabled && row.name).length;
const total = rows.filter((row) => row.name).length;
return total === 0 ? "no LoRAs" : `${on} / ${total} on`;
}
// Master toggle model: "on" when every named row is enabled, "off" when none
// are, "mixed" otherwise. Blank rows don't vote.
export function toggleAllState(rows) {
const named = rows.filter((row) => row.name);
if (!named.length) return "off";
const on = named.filter((row) => row.enabled).length;
return on === 0 ? "off" : on === named.length ? "on" : "mixed";
}
// Clicking the master pill: anything not fully-on turns everything on;
// fully-on turns everything off.
export function toggleAllRows(rows) {
const enabled = toggleAllState(rows) !== "on";
return rows.map((row) => (row.name ? { ...row, enabled } : row));
}
// Longest common folder prefix (whole segments only) across displayed names,
// so a picker full of "styles/wan22/x.safetensors" reads as just "x".
export function commonFolderPrefix(names) {
if (names.length < 2) return "";
const split = names.map((name) => name.split("/"));
const first = split[0];
let depth = 0;
while (
depth < first.length - 1 &&
split.every((parts) => parts.length > depth + 1 && parts[depth] === first[depth])
) {
depth += 1;
}
return depth ? first.slice(0, depth).join("/") + "/" : "";
}
// Folder grouping for the picker's browse view: names bucketed by their
// top-level folder in first-appearance order; root files group under "".
export function groupByFolder(names) {
const groups = [];
const index = new Map();
for (const name of names) {
const slash = name.indexOf("/");
const folder = slash >= 0 ? name.slice(0, slash) : "";
if (!index.has(folder)) {
index.set(folder, { folder, names: [] });
groups.push(index.get(folder));
}
index.get(folder).names.push(name);
}
return groups;
}
// A suggested range is advisory: null/absent bounds never flag.
// ---------- templates ----------
// A template is a named snapshot of the row stack: { name, rows } with rows
// stored id-less (ids are per-node identity, not part of the recipe).
export function templateFromRows(name, rows) {
const label = String(name ?? "").trim();
if (!label) return null;
return {
name: label,
rows: normalizeRows(rows).map(({ id, ...rest }) => rest),
};
}
export function applyTemplate(template) {
return normalizeRows(template?.rows);
}
// Replace a same-named template (case-insensitive) or append; kept sorted so
// the menu order is stable however the list was built.
export function upsertTemplate(list, template) {
if (!template) return Array.isArray(list) ? list : [];
const base = Array.isArray(list) ? list : [];
const key = template.name.toLowerCase();
const next = base.filter((entry) => entry?.name?.toLowerCase() !== key);
next.push(template);
next.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
return next;
}
export function removeTemplate(list, name) {
const key = String(name ?? "").toLowerCase();
return (Array.isArray(list) ? list : []).filter(
(entry) => entry?.name?.toLowerCase() !== key
);
}
export function parseTemplates(raw) {
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed
.map((entry) => templateFromRows(entry?.name, entry?.rows))
.filter(Boolean);
} catch {
return [];
}
}
export function strengthOutOfRange(value, range) {
if (!range) return false;
const low = Number.isFinite(range.min) ? range.min : null;
const high = Number.isFinite(range.max) ? range.max : null;
if (low !== null && value < low) return true;
if (high !== null && value > high) return true;
return false;
}