Compare commits

..
Author SHA1 Message Date
ausboss 0c0e93a760 Mask by Touch: fixes from a full review
Twelve fixes with tests across the touch stage, preview, mask and
fine-edge helpers, including interim pictures sent for the right run.
2026-11-04 01:54:50 -06:00
ausboss c37386c1d4 Seed: add a compact look, picked from the gear (#274) 2026-11-03 23:59:16 -06:00
ausboss 7cb0abaacf Add Load Audio node: wave, trim, preview and max length (#272) 2026-11-03 23:58:16 -06:00
26 changed files with 725 additions and 8808 deletions
-10
View File
@@ -4,16 +4,6 @@ All notable changes to ComfyUI-AusBoss are documented here.
## Unreleased
- **New example: Pika H3 Flex.** One MiniMax H3 workflow for pictures,
video and sound. Give each picture a job on H3 Prompt Writer, or switch on
Video to swap someone into a clip or copy its moves. Keep the clip's sound
carries the clip's own words and music through a swap, and lets them steer
the mouths. Video swaps stop at 10 seconds and come out at about 480p,
where they hold best. Voice and Song use the new Load Audio node.
- **H3 Prompt Writer swaps two people.** With Swap the person and two person
pictures, Picture 1 takes the place of the person on the left and Picture 2
the person on the right. Type who goes to change that, one line each, such
as `@1 the man in the hat` and `@2 the woman in red`.
- **New node: Load Audio.** Load a sound file and keep only the part you
want. The node shows the sound as a wave: drag the IN and OUT handles to
trim it, and press play to hear just that part. Set Max length to the
Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

File diff suppressed because it is too large Load Diff
+9
View File
@@ -27,6 +27,15 @@ needs.
- The line underneath shows the last run's seed. The history saves with the
workflow.
## Compact look
The **gear** beside the number picks the look. **Compact** is one short
row: the number, a mode chip and a dice. Tap the chip to go Random → Fixed →
+1 → −1. The dice rolls a new seed and pins it. A long seed shrinks to fit.
Hover the number to see the last run's seed. The look is saved on the node,
and new Seed nodes start in the look you picked last. **Full** brings back
the whole card, with the seeds it ran with.
## Output
- **seed**: The seed, unchanged.
+5 -2
View File
@@ -862,8 +862,11 @@ app.registerExtension({
chainCallback(nodeType.prototype, "onExecuted", function (output) {
if (isForeignRun()) return;
const state = buildPanel(this, config);
// A result arrived (Save Image sends one even with its preview off).
if (state) { state.stale = false; state.resultGone = false; scheduleRefresh(state); }
// Count-only results carry no replacement for a stale picture.
if (state) {
if (outputRecordQuery(output)) { state.stale = false; state.resultGone = false; }
scheduleRefresh(state);
}
// Saved: the file's picture replaces the steps.
if (state?.stepOverlay) {
state.stepsSaved = stepRun().promptId;
+31 -3
View File
@@ -170,7 +170,7 @@ api.addEventListener("execution_interrupted", () => settleAll());
// second or two, so things light up while it is still looking.
api.addEventListener(LOOK_EVENT, ({ detail }) => {
try {
if (!faces.size || !detail?.note) return;
if (!faces.size || !detail?.note || isForeignRun(detail.prompt_id)) return;
const id = String(detail.node ?? "");
const root = app.rootGraph ?? app.graph;
for (const face of faces) {
@@ -183,11 +183,39 @@ api.addEventListener(LOOK_EVENT, ({ detail }) => {
// A run that left the queue without ever reaching the node (it was deleted
// there) would leave a face waiting for good. Once the queue is empty and a
// face has waited a while, it stops waiting; the next touch asks again.
let emptyQueueTimer = 0;
let emptyQueueVersion = 0;
function settleEmptyQueue() {
const now = performance.now();
let delay = Infinity;
for (const face of faces) {
if (!face.alive || !face.inFlight) continue;
const left = 1501 - (now - face.askedAt);
if (left <= 0) face.settle();
else delay = Math.min(delay, left);
}
if (Number.isFinite(delay)) {
const version = emptyQueueVersion;
emptyQueueTimer = setTimeout(async () => {
emptyQueueTimer = 0;
try {
const queue = await (await api.fetchApi("/queue")).json();
if (version !== emptyQueueVersion) return;
if (Array.isArray(queue?.queue_pending) && Array.isArray(queue?.queue_running)
&& !queue.queue_pending.length && !queue.queue_running.length) settleEmptyQueue();
} catch {
// A failed queue read never cancels a request that may still run.
}
}, delay);
}
}
api.addEventListener("status", ({ detail }) => {
try {
emptyQueueVersion += 1;
clearTimeout(emptyQueueTimer);
emptyQueueTimer = 0;
if (!faces.size || detail?.exec_info?.queue_remaining !== 0) return;
const now = performance.now();
for (const face of faces) if (face.inFlight && now - face.askedAt > 1500) face.settle();
settleEmptyQueue();
} catch {
// Never in the way of a run.
}
+123 -12
View File
@@ -19,6 +19,8 @@ import { copyToClipboard } from "../shared/clipboard.mjs";
import { hideInputsInDef, hideWidget } from "../shared/widget_visibility.mjs";
import { holdNodeMinWidth, pinVuePanelHeight } from "../shared/panel_layout.mjs";
import { formatSeed, pushSeed, seedFromExecuted } from "../shared/seed_history.mjs";
import { SEED_LOOK_MENU, SEED_LOOK_SCOPE, controlChip, nextControl, seedLook, setSeedLook } from "../shared/seed_look.mjs";
import { gearIconSvg, loadSettings, openSettingsMenu } from "../shared/settings_menu.mjs";
const NODE_CLASS = "AUSBOSS_NODES_Seed";
const CSS_ID = "ausboss-seed-css";
@@ -28,8 +30,19 @@ const PROPERTY = "ausboss_seed_history"; // [seed, newest first]
// in a double; a fresh seed rolled here obeys the same ceiling.
const ROLL_MAX = 0x4000000000000;
const CARD_HEIGHT = 158;
const PANEL_DECLARED_HEIGHT = CARD_HEIGHT + 20; // + the frontend's wrapper insets
// The compact look is one row: 8px card padding, a 32px row, 8px padding.
const COMPACT_CARD_HEIGHT = 50;
const PANEL_INSETS = 20; // the frontend's wrapper insets
const NODE_WIDTH = 300;
const COMPACT_NODE_WIDTH = 290;
function panelHeight(node) {
return (seedLook(node) === "compact" ? COMPACT_CARD_HEIGHT : CARD_HEIGHT) + PANEL_INSETS;
}
function nodeWidthFloor(node) {
return seedLook(node) === "compact" ? COMPACT_NODE_WIDTH : NODE_WIDTH;
}
let openMenu = null;
@@ -59,6 +72,21 @@ function ensureCss() {
.ausboss-seed-btn:disabled{opacity:.4;cursor:default;pointer-events:none;}
.ausboss-seed-hint{overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:#6f8886;font-size:10.5px;text-align:center;}
.ausboss-seed-hint b{color:#9fe3dc;font-weight:600;font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace;}
.ausboss-seed-top{display:flex;align-items:center;gap:6px;min-width:0;}
.ausboss-seed-top .ausboss-seed-field{flex:1 1 auto;min-width:0;}
.ausboss-seed-gear{display:grid;place-items:center;}
.ausboss-seed-chip,.ausboss-seed-dice{display:none;flex:none;height:32px;border:1px solid #2a3437;border-radius:7px;background:#0f1516;color:#d8ecea;font:600 11.5px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;cursor:pointer;pointer-events:auto;}
.ausboss-seed-chip{min-width:56px;padding:0 7px;}
.ausboss-seed-chip.random{border-color:rgba(0,180,170,.55);color:${BRAND};}
.ausboss-seed-dice{width:32px;font-size:15px;}
.ausboss-seed-chip:hover,.ausboss-seed-dice:hover{border-color:${BRAND};color:#fff;}
.ausboss-seed-card.compact{gap:0;}
.ausboss-seed-card.compact .ausboss-seed-seg,.ausboss-seed-card.compact .ausboss-seed-row,.ausboss-seed-card.compact .ausboss-seed-hint{display:none;}
.ausboss-seed-card.compact .ausboss-seed-chip,.ausboss-seed-card.compact .ausboss-seed-dice{display:block;}
.ausboss-seed-card.compact .ausboss-seed-field{height:32px;padding:0 3px 0 9px;}
.ausboss-seed-card.compact .ausboss-seed-field input{font-size:14px;}
.ausboss-seed-card.compact .ausboss-seed-tag,.ausboss-seed-card.compact .ausboss-seed-icon:not(.ausboss-seed-gear){display:none;}
.ausboss-seed-card.compact .ausboss-seed-field input{font-size:13.5px;letter-spacing:0;}
.ausboss-seed-menu{position:fixed;z-index:10000;min-width:230px;padding:4px;border:1px solid #3a4047;border-radius:7px;background:#1c1f23;box-shadow:0 8px 28px rgba(0,0,0,.5);font:12px/1.3 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:#d7dde2;}
.ausboss-seed-menu-head{padding:5px 8px 6px;border-bottom:1px solid #2c3238;color:#78908e;font:10px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;letter-spacing:.08em;text-transform:uppercase;}
.ausboss-seed-menu-item{display:block;width:100%;padding:6px 8px;border:none;border-radius:5px;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer;}
@@ -124,6 +152,7 @@ function render(state) {
const linked = seedLinked(node);
const value = seedWidget(node)?.value;
if (document.activeElement !== state.input) state.input.value = formatSeed(Number(value));
fitCompactNumber(state);
state.input.disabled = linked;
state.field.classList.toggle("linked", linked);
state.field.title = linked ? "The seed comes from its input link" : "Click to type a seed";
@@ -136,6 +165,11 @@ function render(state) {
state.segStep.title = stepping
? "Click again to step the other way"
: "Increment the seed after each queue (click again for decrement)";
const chip = controlChip(control);
state.chip.textContent = chip.text;
state.chip.title = chip.title;
state.chip.classList.toggle("random", control === "randomize");
state.card.classList.toggle("compact", seedLook(node) === "compact");
state.useLast.disabled = !seeds.length;
state.more.disabled = seeds.length < 2;
state.hint.textContent = "";
@@ -145,6 +179,8 @@ function render(state) {
b.textContent = formatSeed(last);
state.hint.append(b);
if (control === "randomize") state.hint.append(" · rolls a new seed each queue");
// The compact look has no hint line: the last run shows on the number.
if (seedLook(node) === "compact") state.field.title = `${state.field.title}. Last run: ${formatSeed(last)}`;
} else {
state.hint.textContent = control === "randomize"
? "rolls a new seed each queue"
@@ -152,6 +188,16 @@ function render(state) {
}
}
// The compact row is narrow, so a long seed gets smaller digits instead of
// being cut off: monospace digits are about 0.6 of the font size wide.
function fitCompactNumber(state) {
const { input } = state;
if (seedLook(state.node) !== "compact") { input.style.fontSize = ""; return; }
const digits = Math.max(1, String(input.value || "").length);
const room = input.clientWidth || 100;
input.style.fontSize = `${Math.max(8, Math.min(13.5, room / (0.64 * digits))).toFixed(1)}px`;
}
function showMenu(state) {
closeMenu();
const seeds = history(state.node);
@@ -241,7 +287,9 @@ function buildPanel(node) {
input.inputMode = "numeric";
input.spellcheck = false;
const copy = button("ausboss-seed-icon", "⧉", "Copy the seed");
field.append(tag, input, copy);
const gear = button("ausboss-seed-icon ausboss-seed-gear", "", "Seed settings: full or compact look");
gear.innerHTML = gearIconSvg(12);
field.append(tag, input, copy, gear);
field.addEventListener("pointerdown", (event) => event.stopPropagation());
const seg = el("div", "ausboss-seed-seg");
@@ -257,11 +305,16 @@ function buildPanel(node) {
row.append(newSeed, useLast, more);
const hint = el("div", "ausboss-seed-hint");
card.append(field, seg, row, hint);
// Compact look: the mode chip and a dice sit on the number's row.
const chip = button("ausboss-seed-chip", "Fixed");
const dice = button("ausboss-seed-dice", "🎲", "Roll a fresh seed and pin it (Fixed)");
const top = el("div", "ausboss-seed-top");
top.append(field, chip, dice);
card.append(top, seg, row, hint);
root.append(card);
const state = (node.__ausbossSeed = {
node, root, field, tag, input, copy, segRandom, segFixed, segStep, newSeed, useLast, more, hint,
node, root, card, field, tag, input, copy, gear, chip, dice, segRandom, segFixed, segStep, newSeed, useLast, more, hint,
});
const commitTyped = () => {
@@ -304,6 +357,31 @@ function buildPanel(node) {
notifyAusbossChange();
});
newSeed.addEventListener("click", () => { setSeed(node, rollSeed()); render(state); });
dice.addEventListener("click", () => { setSeed(node, rollSeed()); render(state); });
// The chip walks the same modes as the full card's buttons, and starts a
// new run the same way: into Random rolls now, Fixed into +1 steps now.
chip.addEventListener("click", () => {
const current = controlWidget(node)?.value ?? "fixed";
const next = nextControl(current);
setControl(node, next);
if (!seedLinked(node)) {
const value = Number(seed.value) || 0;
if (next === "randomize") setSeed(node, rollSeed(), { pin: false });
else if (current === "fixed" && next === "increment") setSeed(node, value + 1, { pin: false });
}
render(state);
notifyAusbossChange();
});
gear.addEventListener("click", () => {
openSettingsMenu({
scope: SEED_LOOK_SCOPE,
schema: SEED_LOOK_MENU,
anchor: gear.getBoundingClientRect(),
title: "Seed settings",
initial: { look: seedLook(node) },
onChange: (values) => applyLook(state, values.look),
});
});
useLast.addEventListener("click", () => {
const seeds = history(node);
if (seeds.length) { setSeed(node, seeds[0]); render(state); }
@@ -318,7 +396,7 @@ function buildPanel(node) {
const widget = node.addDOMWidget(WIDGET_NAME, "ausboss_seed", root, {
serialize: false,
hideOnZoom: false,
getMinHeight: () => PANEL_DECLARED_HEIGHT,
getMinHeight: () => panelHeight(node),
});
keepDomWidgetWidthAuto(widget);
// Not saved with the workflow either: options.serialize only keeps it out
@@ -327,18 +405,20 @@ function buildPanel(node) {
// A constant-height card: pinned on purpose (see
// tests/panel_guards.test.mjs, fixedByDesign).
widget.computeSize = (width) => [
Math.max(NODE_WIDTH, Number(width || node.size?.[0] || NODE_WIDTH)),
PANEL_DECLARED_HEIGHT,
Math.max(nodeWidthFloor(node), Number(width || node.size?.[0] || nodeWidthFloor(node))),
panelHeight(node),
];
// Pinned in Nodes 2.0 as well, where a node can be made taller than its card.
pinVuePanelHeight(widget);
widget.options.minNodeSize = [NODE_WIDTH, 120];
widget.options.minNodeSize = [nodeWidthFloor(node), 60];
// The frontend skips a pinned card's width, so its floor is held here.
holdNodeMinWidth(widget, NODE_WIDTH);
holdNodeMinWidth(widget, nodeWidthFloor(node));
state.widget = widget;
new ResizeObserver(() => fitCompactNumber(state)).observe(input);
render(state);
node.setSize?.([
Math.max(node.size?.[0] ?? 0, NODE_WIDTH),
Math.max(node.size?.[0] ?? 0, nodeWidthFloor(node)),
node.computeSize?.()[1] ?? node.size?.[1],
]);
// The card is a fixed-height row: width may grow, but a corner drag
@@ -356,18 +436,49 @@ function buildPanel(node) {
return state;
}
// Switch looks: the card, the node's floor and its height follow. Going
// compact also makes the node as narrow as the compact row allows, and
// going full widens it back to the full card's floor.
function applyLook(state, look) {
const { node } = state;
const before = seedLook(node);
const after = setSeedLook(node, look);
if (before === after) return;
render(state);
if (state.widget) {
state.widget.options.minNodeSize = [nodeWidthFloor(node), 60];
holdNodeMinWidth(state.widget, nodeWidthFloor(node));
}
const width = after === "compact" ? COMPACT_NODE_WIDTH : Math.max(node.size?.[0] ?? 0, NODE_WIDTH);
node.setSize?.([width, node.computeSize?.()[1] ?? node.size?.[1]]);
node.setDirtyCanvas?.(true, true);
notifyAusbossChange();
}
app.registerExtension({
name: "AusBoss.Seed",
beforeRegisterNodeDef(nodeType, nodeData) {
if (nodeData?.name !== NODE_CLASS) return;
hideInputsInDef(nodeData, ["seed"]);
chainCallback(nodeType.prototype, "onNodeCreated", function () {
buildPanel(this);
// A new node starts in the look last picked from any Seed's gear. A
// loaded one keeps its own: onConfigure restores its properties after.
if (loadSettings(SEED_LOOK_SCOPE, SEED_LOOK_MENU).look === "compact") setSeedLook(this, "compact");
const state = buildPanel(this);
if (state && seedLook(this) === "compact") {
this.setSize?.([COMPACT_NODE_WIDTH, this.computeSize?.()[1] ?? this.size?.[1]]);
}
});
chainCallback(nodeType.prototype, "onConfigure", function () {
queueMicrotask(() => {
const state = buildPanel(this);
if (state) render(state);
if (!state) return;
render(state);
// A saved compact Seed: lower the floor to the compact row's.
if (state.widget) {
state.widget.options.minNodeSize = [nodeWidthFloor(this), 60];
holdNodeMinWidth(state.widget, nodeWidthFloor(this));
}
});
});
chainCallback(nodeType.prototype, "onConnectionsChange", function () {
+61
View File
@@ -0,0 +1,61 @@
// Seed 🆎's two looks, picked from the gear on its card: the full card
// (the default), or "compact", one short row with the number, a mode chip
// and a new-seed button. The look is kept in the node's properties, so the
// node's inputs, saved values and the prompt it sends are the same in both,
// and the last look picked is what new Seed nodes start with.
//
// Data and decisions only (tests/seed_look.test.mjs); js/seed/index.js
// draws the card.
export const SEED_LOOK_PROPERTY = "ausboss_seed_look";
export const SEED_LOOKS = ["full", "compact"];
export const SEED_LOOK_SCOPE = "seed_look";
export function seedLook(node) {
return node?.properties?.[SEED_LOOK_PROPERTY] === "compact" ? "compact" : "full";
}
// The full card leaves no property behind, so a node set back to it is
// saved exactly as one that was never changed.
export function setSeedLook(node, look) {
if (!node) return "full";
node.properties ??= {};
if (look === "compact") node.properties[SEED_LOOK_PROPERTY] = "compact";
else delete node.properties[SEED_LOOK_PROPERTY];
return seedLook(node);
}
// The gear menu (js/shared/settings_menu.mjs). The pick is also kept as the
// look new Seed nodes start with.
export const SEED_LOOK_MENU = [
{
key: "look",
label: "Look",
type: "choice",
options: SEED_LOOKS,
default: "full",
hint: "How this Seed node looks. New Seed nodes start the same way.",
optionHints: {
full: "the whole card: mode buttons, New seed, Use last run, and the seeds it ran with.",
compact: "one short row: the number, a mode chip you tap to change, and a dice for a new seed.",
},
},
];
// The compact row's chip: tap to go Random -> Fixed -> Step +1 -> Step -1
// -> Random. Values are the frontend's control_after_generate choices.
const CYCLE = ["randomize", "fixed", "increment", "decrement"];
export function nextControl(control) {
const index = CYCLE.indexOf(control);
return CYCLE[(index + 1) % CYCLE.length] ?? "fixed";
}
export function controlChip(control) {
switch (control) {
case "randomize": return { text: "Random", title: "Rolls a new seed after each run. Tap for Fixed." };
case "increment": return { text: "+1", title: "Adds 1 after each run. Tap for -1." };
case "decrement": return { text: "−1", title: "Takes 1 off after each run. Tap for Random." };
default: return { text: "Fixed", title: "Keeps this seed for every run. Tap for +1." };
}
}
+1 -1
View File
@@ -1046,7 +1046,7 @@ export function pieceAt(planes, index, { sizes, inside, adding = true, ranks = n
const size = sizes?.[piece] ?? 0;
if (!size) continue;
const picked = inside?.[piece] ?? 0;
const slack = Math.max(4, size * NEARLY);
const slack = Math.min(size - 1, Math.max(4, size * NEARLY));
if (adding ? size - picked > slack : picked > slack) return piece;
}
return -1;
+48 -15
View File
@@ -141,11 +141,12 @@ const TOUCH_REACH = 2;
// The areas of picked things a picture keeps, as the node sent them.
const AREAS_KEPT = 96;
// Pictures the faces of a page have shown, newest last. A picture keeps its
// pieces and the picked areas it has shown, so a tab switch, an undo or a
// second node on the same picture costs nothing.
// pieces and the picked areas it has shown, so a tab switch, an undo or
// another visit to the same picture costs nothing. Results belong to one face.
const PICTURES_KEPT = 3;
const STATES_KEPT = 14;
const pictures = new Map();
let pictureSerial = 0;
// A face asks for a run again on its own at most this often in a row.
const RETRIES = 2;
// The long side of the small copy the masks are worked on, when the face
@@ -284,13 +285,14 @@ const widgetByName = (node, name) => node.widgets?.find((widget) => widget?.name
// ---- the pictures a page has shown ---------------------------------------
function pictureFor(note) {
function pictureFor(note, face) {
const width = Math.round(Number(note.width));
const height = Math.round(Number(note.height));
const scale = Number(note.scale) > 0 ? Number(note.scale) : 1;
let entry = pictures.get(note.key);
const cacheKey = face ? `${face.pictureId ??= ++pictureSerial}|${note.key}` : note.key;
let entry = pictures.get(cacheKey);
if (entry && (entry.width !== width || entry.height !== height)) entry = null;
pictures.delete(note.key);
pictures.delete(cacheKey);
entry ??= {
key: note.key,
width,
@@ -308,7 +310,7 @@ function pictureFor(note) {
nameSeeds: new Map(),
states: new Map(),
};
pictures.set(note.key, entry);
pictures.set(cacheKey, entry);
while (pictures.size > PICTURES_KEPT) pictures.delete(pictures.keys().next().value);
return entry;
}
@@ -386,7 +388,7 @@ function validNote(note) {
// whether the picture has points left to ask about.
const piecesTag = (pieces) => `${pieces?.list?.length ?? 0}:${(Array.isArray(pieces?.planes) ? pieces.planes : []).map((file) => file?.filename).join(",")}`;
async function loadPieces(entry, pieces) {
async function loadPieces(entry, pieces, generation) {
const tag = piecesTag(pieces);
const list = (Array.isArray(pieces.list) ? pieces.list : [])
.filter((piece) => piece && Number.isFinite(Number(piece.x)) && Number.isFinite(Number(piece.y)))
@@ -410,6 +412,7 @@ async function loadPieces(entry, pieces) {
});
const files = (Array.isArray(pieces.planes) ? pieces.planes : []).filter(isFile);
const images = await Promise.all(files.map((file) => loadImage(viewUrl(file, entry.key))));
if (entry.generation !== generation) return;
const planes = packPlanes(images.map((image) => readPixels(image, entry.pw, entry.ph)), entry.pw, entry.ph);
const sizes = pieceSizes(planes, list.length);
const overlaps = pieceOverlaps(planes, list.length);
@@ -1401,7 +1404,7 @@ function syncTools(face) {
// A click that waits for the look can be taken back like a pick.
const waiting = live && face.pending.length > 0;
face.undo.disabled = !live || (!some && !face.past.length && !waiting);
face.clear.disabled = !some && !waiting;
face.clear.disabled = !some && !waiting && !String(face.touches?.value ?? "").trim();
if (face.lookButton) {
const offer = live && face.snap && !face.held && !face.inFlight && !face.pic.scoped && Boolean(face.host.lookOnButton?.());
face.lookButton.style.display = offer ? "" : "none";
@@ -1480,6 +1483,28 @@ function ownLoader(node) {
// for: a crop that is being dragged changes them many times a second.
const OWN_SETTLE_MS = 700;
// One callback per source widget; removing a face removes its subscription.
const sourceWatchers = new WeakMap();
function sourceListeners(widget) {
let listeners = sourceWatchers.get(widget);
if (!listeners) {
listeners = new Set();
sourceWatchers.set(widget, listeners);
chainCallback(widget, "callback", () => {
for (const listener of listeners) listener();
});
}
return listeners;
}
function watchSource(face, widget, changed) {
if (face.watchedSources.has(widget)) return;
face.watchedSources.add(widget);
const listeners = sourceListeners(widget);
listeners.add(changed);
face.forgetSources.push(() => listeners.delete(changed));
}
// Show the loader's picture without a press on Show picture: the same run
// that button asks for. Only ever in answer to something the person did (a
// file chosen, a setting changed, a wire made), never while a workflow is
@@ -1520,9 +1545,8 @@ function followOwnLoader(face, now = false) {
};
for (const widget of upstream.widgets ?? []) {
if (!widget || face.watchedSources.has(widget)) continue;
face.watchedSources.add(widget);
// A workflow being opened sets its values too: that is not the person.
chainCallback(widget, "callback", () => { if (face.alive && !face.host.loading?.()) later(); });
watchSource(face, widget, () => { if (face.alive && !face.host.loading?.()) later(); });
}
if (now) later();
}
@@ -1552,8 +1576,7 @@ function syncSource(face) {
const url = source?.url ?? "";
const watched = loaderWidget(face.node);
if (watched && !face.watchedSources.has(watched)) {
face.watchedSources.add(watched);
chainCallback(watched, "callback", () => queueMicrotask(() => { if (face.alive) syncState(face); }));
watchSource(face, watched, () => queueMicrotask(() => { if (face.alive) syncState(face); }));
}
if (url === face.sourceUrl) return;
face.sourceUrl = url;
@@ -1791,8 +1814,8 @@ function show(face, entry, note, answered, { restored = false, own = false, inte
refreshHover(face);
draw(face);
drawInk(face);
wantFine(face);
if (restored) return;
wantFine(face);
const waited = settlePending(face);
// More is on its way (the look goes on, or a run sent behind it): what to
// ask next is decided when the last of it is in.
@@ -1842,7 +1865,8 @@ async function takeNote(face, note, { restored = false, interim = false } = {})
}
const turn = ++face.turn;
const sentStamp = sent?.stamp ?? "";
const entry = pictureFor(note);
const entry = pictureFor(note, face);
const generation = entry.generation = (entry.generation ?? 0) + 1;
// How many looks at this picture have come back, and how far through its
// list of words the node is: an early click waits for the words.
if (own && sent.ask === "scope") entry.looks = (entry.looks ?? 0) + 1;
@@ -1854,12 +1878,13 @@ async function takeNote(face, note, { restored = false, interim = false } = {})
const jobs = [];
if (!entry.image || entry.file !== note.picture.filename) {
jobs.push(loadImage(viewUrl(note.picture, note.key)).then((image) => {
if (entry.generation !== generation) return;
entry.image = image;
entry.file = note.picture.filename;
}));
}
if (note.pieces && typeof note.pieces === "object" && piecesTag(note.pieces) !== entry.piecesTag) {
jobs.push(loadPieces(entry, note.pieces));
jobs.push(loadPieces(entry, note.pieces, generation));
} else if (note.pieces && typeof note.pieces === "object") {
entry.scoped = note.pieces.more !== true;
}
@@ -1909,6 +1934,7 @@ async function takeNote(face, note, { restored = false, interim = false } = {})
}
return;
}
if (!face.alive || turn !== face.turn || entry.generation !== generation) return;
if (answered) remember(entry, stamp, answered);
if (fine) entry.fine = fine;
if (typeof note.fine?.trouble === "string" && note.fine.trouble) {
@@ -2870,6 +2896,7 @@ export function mountTouchStage(node, host) {
sourceEpoch: 0,
sentEpoch: 0,
watchedSources: new WeakSet(),
forgetSources: [],
// One of the pack's loaders wired straight in: what it was last asked for.
ownSeen: "",
ownTimer: 0,
@@ -2944,8 +2971,11 @@ export function mountTouchStage(node, host) {
face.active = 0;
face.fresh = 0;
if (!face.items.length) {
writeTouches(face, "");
readItems(face);
face.chipsShown = null;
syncReadout(face);
syncTools(face);
return;
}
change(face, []);
@@ -3110,6 +3140,9 @@ export function mountTouchStage(node, host) {
clearTimeout(face.ownTimer);
clearTimeout(face.dwell);
clearTimeout(face.fineTimer);
clearTimeout(face.pendingTimer);
for (const forget of face.forgetSources) forget();
face.forgetSources.length = 0;
if (face.frameRequest) cancelAnimationFrame(face.frameRequest);
abort.abort();
};
+10 -5
View File
@@ -170,16 +170,21 @@ def _core_node(key: str):
return node if hasattr(node, "execute") else None
def _model_stamp(name: str):
path = folder_paths.get_full_path(MODEL_FOLDER, name) if folder_paths is not None else None
if not path:
raise FileNotFoundError(NOT_FOUND.format(name=name) if list_subject_models() else NEEDS_MODEL)
info = os.stat(path)
return (path, info.st_mtime_ns, info.st_size)
def _load(name: str):
"""The model, through ComfyUI's own loader node, as a workflow would
load it. Kept while its file does not change."""
loader = _core_node("LoadBackgroundRemovalModel")
if loader is None or _core_node("RemoveBackground") is None:
raise RuntimeError(NEEDS_NEWER)
path = folder_paths.get_full_path(MODEL_FOLDER, name) if folder_paths is not None else None
if not path:
raise FileNotFoundError(NOT_FOUND.format(name=name) if list_subject_models() else NEEDS_MODEL)
stamp = (path, os.path.getmtime(path), os.path.getsize(path))
stamp = _model_stamp(name)
kept = _MODELS.get(name)
if kept is not None and kept[0] == stamp:
_MODELS.move_to_end(name)
@@ -206,7 +211,7 @@ def subject_matte(frame: torch.Tensor, choice, key: str | None = None) -> torch.
set to. With `key` (the picture's own key) the answer is remembered: a
picture is asked about once, however many picks follow."""
name = subject_model_name(choice)
slot = (name, key)
slot = (name, _model_stamp(name), key)
if key is not None and slot in _SUBJECTS:
_SUBJECTS.move_to_end(slot)
return _SUBJECTS[slot].float() / 255.0
+12 -75
View File
@@ -37,12 +37,6 @@ NEEDS_A_PERSON = {
MOVES: "To copy the moves, connect a picture of your person and pick person for it.",
SWAP: "To swap the person, connect a picture of the new person and pick person for it.",
}
# A hat or a shirt with no person picture: the user wants the video's own person to wear it.
DRESS_THE_VIDEO_PERSON = (
"To put this on the person in your video, load a still of them from the video as Picture 1 "
"and pick person for it. Give the hat or outfit its own picture, pick outfit or thing, and type "
"\"@1 wears @2\" with video job swap the person."
)
# (what the label is, how it is kept). {picture} is H3's number for the picture.
JOB_WORDS = {
@@ -94,20 +88,14 @@ MOVES_KEPT = (
"keep the source performer's face, body, clothes, props, background and colours out of the target.\n"
"<Video 1>: attribute_transfer - apply only its camera movement to the target scene."
)
SWAP_VIDEO = "<Video 1> supplies the place, the action, the timing, the camera movement and the look."
SWAP_IS = "{replaced} is {who} in <Video 1>, selected for replacement."
SWAP_VIDEO_KEPT = (
"<Video 1>: partially_preserved - keep the place, the action, the timing, the camera movement and the look; "
"change only the {people}."
SWAP_IS = (
"<Video 1> supplies the place, the action, the timing, the camera movement and the look.\n"
"{replaced} is {who} in <Video 1>, selected for replacement."
)
SWAP_KEPT = "{replaced}: removed - replace {who} with {main} throughout."
ONE_GOES = "the original person"
# Where each person stands when two or more go and the script doesn't say, from left to right like the pictures.
SPOTS = {2: ("left", "right"), 3: ("left", "centre", "right"), 4: ("far left", "left", "right", "far right")}
SAYS_WHO = re.compile(
r"^(?:is|are|replaces|takes\s+the\s+place\s+of|goes\s+in\s+for|swaps?\s+(?:in\s+)?for|in\s+place\s+of|for|as)\b\s*"
r"|^[:=\-\u2013\u2014>]+\s*",
re.I,
SWAP_KEPT = (
"<Video 1>: partially_preserved - keep the place, the action, the timing, the camera movement and the look; "
"change only the person.\n"
"{replaced}: removed - replace {who} with {main} throughout."
)
SWAPPED = "attribute_transfer - apply the defined appearance to the original performance; the background of {picture} is not transferred."
KEEP_IS = (
@@ -127,11 +115,6 @@ REVOICE_KEPT = (
)
def lower_first(text):
first = text.split(" ", 1)[0]
return text if text[:1] == "<" or (len(first) > 1 and first.isupper()) else text[:1].lower() + text[1:]
def subject_label(number):
return f"<Subject {number}>"
@@ -152,16 +135,11 @@ class Picture:
subject: int = 0 # A picture that only marks the last frame has none.
def swapped_in(jobs):
"""How many people a swap puts in: one for each person or sheet picture."""
return max(1, sum(job in (PERSON, SHEET) for job in jobs.values()))
def number_pictures(jobs, video=""):
"""Connected pictures in order, from {number on the node: job}. In a swap
the people being replaced come first, as <Subject 1>, <Subject 2> and on."""
the person being replaced comes first, as <Subject 1>."""
pictures = []
subjects = swapped_in(jobs) if video == SWAP else 0
subjects = 1 if video == SWAP else 0
for picture, slot in enumerate(sorted(jobs), 1):
subjects += jobs[slot] != END
pictures.append(Picture(slot, jobs[slot], picture, 0 if jobs[slot] == END else subjects))
@@ -176,12 +154,8 @@ class Cast:
self.video = video
last = max((picture.subject for picture in pictures), default=0)
self.moves = subject_label(last + 1) if video == MOVES else ""
# In a swap each person or sheet picture takes one person's place in the video, in picture order. The script
# can name who goes: "the child in the seat", or "@1 the man on the left. @2 the woman in red".
self.swappers = [picture for picture in pictures if picture.job in (PERSON, SHEET)] if video == SWAP else []
goes = len(self.swappers)
self.whos = [f"the person on the {spot}" for spot in SPOTS[goes]] if goes > 1 else [ONE_GOES]
self.replaced = subject_label(1) if video == SWAP else ""
self.who = "the original person" # In a swap the script can name who goes: "the child in the seat".
self.in_video = subject_label(last + 1) if video in (KEEP, REVOICE) else ""
self.people = [picture for picture in pictures if picture.job in (PERSON, SHEET, START)]
self.subjects = {picture.slot: subject_label(picture.subject) for picture in pictures if picture.subject}
@@ -189,39 +163,6 @@ class Cast:
def of(self, *jobs):
return [picture for picture in self.pictures if picture.job in jobs]
@property
def who(self):
return self.whos[0]
@who.setter
def who(self, words):
self.whos[0] = words
@property
def swaps(self):
"""(who goes, the words for them, who comes in) for each person a swap replaces."""
coming = [subject_label(picture.subject) for picture in self.swappers] or [self.main]
return [(subject_label(number), who, new) for number, (who, new) in enumerate(zip(self.whos, coming), 1)]
def name_who_goes(self, lines):
"""Who each new person replaces, from the lines typed in a swap. With one, the words are who goes. With more,
each @ names one, or else the lines go in picture order; anything else keeps left to right."""
lines = [line.strip() for line in lines if line.strip(" .")]
text = " ".join(lines)
if len(self.whos) == 1:
self.who = lower_first(SAYS_WHO.sub("", TOKEN.sub("", text).strip()).rstrip(". ") or ONE_GOES)
return
slots = [picture.slot for picture in self.swappers]
pieces = re.split(r"@(\d)\b", text)
named = {int(slot): words for slot, words in zip(pieces[1::2], pieces[2::2]) if int(slot) in slots}
if not named:
named = dict(zip(slots, lines)) if len(lines) == len(slots) else {}
for index, slot in enumerate(slots):
words = SAYS_WHO.sub("", named.get(slot, "").strip(" .,;")).strip(" .,;")
words = re.sub(r"\s+and$", "", words)
if words:
self.whos[index] = lower_first(words)
@property
def main(self):
"""Who a line is about when it names nobody."""
@@ -247,8 +188,6 @@ class Cast:
if not self.pictures and not self.video:
return NOTHING_IN
if self.video in NEEDS_A_PERSON and not self.people:
if self.of(OUTFIT, THING):
return DRESS_THE_VIDEO_PERSON
return NEEDS_A_PERSON[self.video]
typed = sorted({int(number) for number in TOKEN.findall(text)} - {0})
slots = {picture.slot: picture for picture in self.pictures}
@@ -283,8 +222,7 @@ class Cast:
ends = self.ends
lines = []
if self.video == SWAP:
lines.append(SWAP_VIDEO)
lines += [SWAP_IS.format(replaced=gone, who=who) for gone, who, _ in self.swaps]
lines.append(SWAP_IS.format(replaced=self.replaced, who=self.who))
worn = set(wearers.values())
for picture in self.pictures:
if picture.job == END:
@@ -338,8 +276,7 @@ class Cast:
lines = []
if self.video == SWAP:
lines.append(SWAP_VIDEO_KEPT.format(people="people" if len(self.swaps) > 1 else "person"))
lines += [SWAP_KEPT.format(replaced=gone, who=who, main=new) for gone, who, new in self.swaps]
lines.append(SWAP_KEPT.format(replaced=self.replaced, who=self.who, main=self.main))
for picture in self.pictures:
if picture.job == END:
continue
+2 -2
View File
@@ -13,7 +13,7 @@ import re
import numpy as np
from ._h3_cast_helpers import KEEP, NEEDS_NO_WORDS, REVOICE, SWAP, Cast, number_pictures
from ._h3_script_helpers import ACTION_GOES_ON, Moment, add_moments, add_sound, read_script, script_parts, speakers
from ._h3_script_helpers import ACTION_GOES_ON, Moment, add_moments, add_sound, lower_first, read_script, script_parts, speakers
SECTIONS = (
"subject_definitions",
@@ -184,7 +184,7 @@ def write_prompt(script, seconds=15.0, camera="", look="", sound="", language="E
if not scene.moments and video not in NEEDS_NO_WORDS:
raise ValueError(NO_SCRIPT)
if video == SWAP and scene.moments: # Typed words in a swap say which person goes, for a clip with two.
cast.name_who_goes([moment.text for moment in scene.moments])
cast.who = lower_first(" ".join(moment.text for moment in scene.moments).rstrip(". "))
scene.moments = []
if not scene.moments:
scene.moments.append(Moment(ACTION_GOES_ON if video == KEEP else ""))
+7 -8
View File
@@ -25,10 +25,7 @@ from ._h3_cast_helpers import (
SOUND_GOES_ON_KEPT,
THING,
TOKEN,
ONE_GOES,
SPOTS,
Cast,
lower_first,
number_pictures,
picture_label,
subject_label,
@@ -127,6 +124,7 @@ VIDEO_LOOKS = {
KEEP: "The target video keeps the look, colours and camera of <Video 1>.",
REVOICE: "The target video keeps the look, light and camera of <Video 1>.",
}
SPOTS = {2: ("left", "right"), 3: ("left", "centre", "right"), 4: ("far left", "left", "right", "far right")}
NAMES_A_SPOT = re.compile(r"\b(?:left|right|behind|in\s+front|foreground|background)\b", re.I)
OPENS_ON_THEM = re.compile(r"^(?:She|He)\b")
FOLLOWS_A_COMMA = re.compile(r"^(?:A|An|The|She|He|They|It|Her|His|Their|Its|This|That|These|Those|There|Someone|Everyone|Nobody)\b")
@@ -541,6 +539,9 @@ def stamp(seconds):
return f"{int(minutes):02d}:{rest:06.3f}"
def lower_first(text):
first = text.split(" ", 1)[0]
return text if text[:1] == "<" or (len(first) > 1 and first.isupper()) else text[:1].lower() + text[1:]
def after_comma(text):
@@ -668,8 +669,7 @@ def before_the_action(number, text, cast, wearers):
words = OPENS_ON if cast.ends else OPENING
notes.append(words.format(subject=subject_label(opens.subject), picture=picture_label(opens.picture)))
if cast.video == SWAP:
notes += [TAKES_OVER.format(main=new, who=who) for _, who, new in cast.swaps]
notes.append(ONE_PERSON if cast.whos == [ONE_GOES] else OTHERS_STAY)
notes += [TAKES_OVER.format(main=cast.main, who=cast.who), ONE_PERSON if cast.who == "the original person" else OTHERS_STAY]
if number == 1 and cast.video == KEEP:
notes += [CARRIES_ON]
if number == 1 and cast.video == REVOICE:
@@ -678,7 +678,7 @@ def before_the_action(number, text, cast, wearers):
if places and not any(place in text for place in places):
notes.append(IN_THE_PLACE.format(place=places[0]))
people = people_in(text, cast)
if len(people) > 1 and not NAMES_A_SPOT.search(text) and cast.video != SWAP: # A swap's people stand where the video has them.
if len(people) > 1 and not NAMES_A_SPOT.search(text):
spots = [f"{person} is on the {spot}" for person, spot in zip(people, SPOTS[len(people)])]
notes.append(sentence(", ".join(spots[:-1]) + f" and {spots[-1]} of the frame"))
for slot, wearer in wearers.items():
@@ -786,8 +786,7 @@ def task_of(cast):
def summary_of(script, cast, new_places, soundtrack):
task = task_of(cast)
if cast.video == SWAP:
swaps = " and ".join(f"{gone} with {new}" for gone, _, new in cast.swaps)
return f"[{task}] Replace {swaps} while preserving the original action, place, camera and look of <Video 1>."
return f"[{task}] Replace {cast.replaced} with {cast.main} while preserving the original action, place, camera and look of <Video 1>."
if cast.video == REVOICE:
return (
f"[{task}] The target video is an edited version of <Video 1>. The picture, the timing and the lip movement "
+10 -8
View File
@@ -100,8 +100,9 @@ def _torch_fill_holes(solid: torch.Tensor) -> torch.Tensor:
reachable[:, :, -1] = background[:, :, -1]
while True:
grown = (
functional.max_pool2d(
reachable.float().unsqueeze(1), kernel_size=3, stride=1, padding=1
torch.maximum(
functional.max_pool2d(reachable.float().unsqueeze(1), (3, 1), 1, (1, 0)),
functional.max_pool2d(reachable.float().unsqueeze(1), (1, 3), 1, (0, 1)),
)
.squeeze(1)
.bool()
@@ -129,10 +130,10 @@ def _hole_limit(max_hole_size: float, height: int, width: int) -> float | None:
def _torch_hole_areas(holes: torch.Tensor) -> torch.Tensor:
"""Each hole pixel's hole size in pixels, 0 elsewhere, for one HW frame.
Holes are 8-connected, the way _torch_fill_holes floods them. Every hole
Holes are 4-connected, the way _torch_fill_holes floods them. Every hole
pixel starts with its own index; each pass takes the largest index in
its 3x3 neighbourhood, then the index held by the pixel that index names
(both stay inside the same hole), until every hole holds one index.
its cross-shaped neighbourhood, then the index held by the pixel that
index names (both stay inside the same hole), until every hole holds one index.
"""
height, width = holes.shape
inside = holes.reshape(-1)
@@ -140,8 +141,9 @@ def _torch_hole_areas(holes: torch.Tensor) -> torch.Tensor:
index = torch.arange(1, height * width + 1, dtype=torch.float64, device=holes.device)
labels = torch.where(inside, index, zero)
while True:
spread = functional.max_pool2d(
labels.view(1, 1, height, width), kernel_size=3, stride=1, padding=1
spread = torch.maximum(
functional.max_pool2d(labels.view(1, 1, height, width), (3, 1), 1, (1, 0)),
functional.max_pool2d(labels.view(1, 1, height, width), (1, 3), 1, (0, 1)),
).reshape(-1)
spread = torch.where(inside, spread, zero)
spread = torch.where(inside, spread[(spread.long() - 1).clamp(min=0)], zero)
@@ -188,7 +190,7 @@ def fill_mask_holes(mask: torch.Tensor, max_hole_size: float = 0.0) -> torch.Ten
).to(mask.device)
else:
filled = _torch_fill_holes(solid)
return torch.maximum(mask, filled.to(mask.dtype))
return torch.where(filled & ~solid, 1.0, mask)
def blur_mask(mask: torch.Tensor, sigma: float) -> torch.Tensor:
+3 -1
View File
@@ -220,9 +220,11 @@ def _tell(node_id, note: dict, end: bool) -> bool:
if server is None or node_id is None:
return False
try:
read_context = getattr(sys.modules.get("comfy_execution.utils"), "get_executing_context", lambda: None)
prompt_id = getattr(read_context(), "prompt_id", None)
server.send_sync(
LOOK_EVENT,
{"node": str(node_id), "note": note, "end": bool(end)},
{"node": str(node_id), "note": note, "end": bool(end), "prompt_id": prompt_id},
getattr(server, "client_id", None),
)
except Exception: # noqa: BLE001
-54
View File
@@ -63,58 +63,6 @@ def named_copy_problems(node: dict) -> list[str]:
return [f"node {node['id']} has stale named widget values: {', '.join(stale)}"] if stale else []
def saved_widget(node: dict, name: str):
"""A widget's saved value by name, or KeyError when the node keeps no named copy of it."""
values = node.get("widgets_values")
if isinstance(values, dict) and name in values:
return values[name]
named = node.get("widgets_values_named")
if isinstance(named, dict) and name in named:
return named[name]
raise KeyError(name)
def switch_row_problems(card: dict, by_id: dict) -> list[str]:
"""A Workflow Switches row opens striped "changed", with neither off nor
on picked, when the values it sets are saved half one way and half the
other, or when its nodes run one way and its values say the other. Every
row of an example has to open plainly off or on."""
settings = (card.get("properties") or {}).get("ausboss_workflow_switches") or {}
problems = []
for row in settings.get("switches") or []:
title = row.get("title", row.get("id"))
sides = set()
on, off = row.get("on") or {}, row.get("off") or {}
for node_id, widgets in on.items():
node = by_id.get(int(node_id)) if str(node_id).isdigit() else None
if node is None or not isinstance(widgets, dict):
continue
for name, on_value in widgets.items():
off_value = (off.get(node_id) or {}).get(name)
lora_rows = isinstance(on_value, str) and on_value.startswith("[")
if isinstance(on_value, (dict, list)) or lora_rows or on_value == off_value:
continue # a LoRA stack is matched row by row, and a shared value says nothing
try:
saved = saved_widget(node, name)
except KeyError:
continue
if saved == on_value:
sides.add("on")
elif saved == off_value:
sides.add("off")
else:
sides.add(f"neither ({node_id} {name} is {saved!r}, not {on_value!r} or {off_value!r})")
members = [by_id[i] for i in row.get("nodes") or [] if i in by_id]
modes = {"on" if m.get("mode", 0) == 0 else "off" for m in members}
if len(modes) > 1:
problems.append(f"switch card {card['id']} row '{title}': some of its nodes are on and some off")
odd = sorted(side for side in sides if side not in ("on", "off"))
if odd or len(sides) > 1 or (sides and modes and sides != modes):
detail = "; ".join(odd) if odd else f"values say {'/'.join(sorted(sides))}, nodes say {'/'.join(sorted(modes)) or '-'}"
problems.append(f"switch card {card['id']} row '{title}' opens as changed: {detail}")
return problems
def workflow_problems(data: dict) -> list[str]:
problems = []
nodes = data.get("nodes", [])
@@ -164,8 +112,6 @@ def workflow_problems(data: dict) -> list[str]:
problems.append(f"saved zoom {scale} is below {MIN_ZOOM}; widget text would not draw")
for n in nodes:
problems.extend(named_copy_problems(n))
if n["type"] == "AUSBOSS_NODES_WorkflowSwitches":
problems.extend(switch_row_problems(n, by_id))
if n["type"] in CORE_LORA_LOADERS:
problems.append(f"node {n['id']} loads a LoRA with core {n['type']}; examples use LoRA Loader 🆎")
selected = selected_files(n)
+26
View File
@@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import test from "node:test";
import { SEED_LOOK_PROPERTY, controlChip, nextControl, seedLook, setSeedLook } from "../js/shared/seed_look.mjs";
test("the look lives on the node, and full leaves nothing behind", () => {
const node = { properties: {} };
assert.equal(seedLook(node), "full");
assert.equal(setSeedLook(node, "compact"), "compact");
assert.equal(node.properties[SEED_LOOK_PROPERTY], "compact");
assert.equal(setSeedLook(node, "full"), "full");
assert.deepEqual(node.properties, {});
assert.equal(seedLook(null), "full");
assert.equal(seedLook({ properties: { [SEED_LOOK_PROPERTY]: "huge" } }), "full");
});
test("the chip walks Random, Fixed, +1, -1 and back", () => {
assert.equal(nextControl("randomize"), "fixed");
assert.equal(nextControl("fixed"), "increment");
assert.equal(nextControl("increment"), "decrement");
assert.equal(nextControl("decrement"), "randomize");
assert.equal(nextControl("something else"), "randomize");
assert.equal(controlChip("randomize").text, "Random");
assert.equal(controlChip("fixed").text, "Fixed");
assert.equal(controlChip(undefined).text, "Fixed");
});
+1
View File
@@ -53,6 +53,7 @@ CORE_NODES = {
INTERNALS = [
("comfy.utils", "PROGRESS_BAR_HOOK", "Mask by Touch holds progress reports back while it looks at a picture"),
("comfy_execution.progress", "get_progress_state", "Mask by Touch holds progress reports back while it looks at a picture"),
("comfy_execution.utils", "get_executing_context", "Mask by Touch identifies the run sending an interim picture"),
("comfy.model_management", "load_model_gpu", "Mask by Touch asks SAM 3 the quick way"),
("comfy.model_management", "current_loaded_models", "Mask by Touch asks SAM 3 the quick way"),
("comfy.model_management", "intermediate_dtype", "the pack's tensors take ComfyUI's working type"),
+12
View File
@@ -191,6 +191,18 @@ class AskTests(ModelCase):
subject_matte(self.frame, OFF, key="two")
self.assertEqual(len(Loader.calls), 2)
def test_replacing_a_model_invalidates_the_same_pictures_subject(self):
Remover.answer = torch.full((1, 40, 60), 0.25)
first = subject_matte(self.frame, OFF, key="one")
self.file(PREFERRED, "replacement weights, longer")
os.utime(self.folder / PREFERRED, (1, 1))
Remover.answer = torch.full((1, 40, 60), 0.9)
second = subject_matte(self.frame, OFF, key="one")
self.assertEqual(len(Loader.calls), 2)
self.assertEqual(len(Remover.calls), 2)
self.assertLess(float(first.mean()), 0.3)
self.assertGreater(float(second.mean()), 0.8)
def test_an_older_comfyui_gets_a_plain_message(self):
self.nodes.NODE_CLASS_MAPPINGS = {"LoadBackgroundRemovalModel": Loader}
with self.assertRaises(RuntimeError) as raised:
-28
View File
@@ -652,12 +652,6 @@ class VideoJobTests(unittest.TestCase):
cast = Cast(number_pictures({1: "place"}, "copy its moves"), "copy its moves")
self.assertIn("To copy the moves", cast.problem("She dances."))
def test_a_hat_with_no_person_picture_says_how_to_put_it_on_the_video_person(self):
for job in ("outfit", "thing"):
for video in ("swap the person", "copy its moves"):
cast = Cast(number_pictures({1: job}, video), video)
self.assertIn("load a still of them from the video as Picture 1", cast.problem("He wears @1."))
def test_a_swap_with_nobody_to_put_in_says_so(self):
with self.assertRaisesRegex(ValueError, "new person"):
write_prompt("", jobs={}, video="swap the person")
@@ -682,28 +676,6 @@ class VideoJobTests(unittest.TestCase):
self.assertIn("Everyone else stays", parts["detailed_description"])
self.assertNotIn("Show one person", parts["detailed_description"])
def test_a_swap_with_two_people_puts_each_in_one_place(self):
parts = parts_of("", jobs={1: "person", 2: "person"}, video="swap the person")
self.assertIn("Replace <Subject 1> with <Subject 3> and <Subject 2> with <Subject 4>", parts["summary"])
self.assertIn("<Subject 1> is the person on the left in <Video 1>", parts["subject_definitions"])
self.assertIn("<Subject 2> is the person on the right in <Video 1>", parts["subject_definitions"])
self.assertIn("<Subject 4> is the person in <Picture 2>", parts["subject_definitions"])
self.assertIn("replace the person on the right with <Subject 4>", parts["retention_analysis"])
self.assertIn("change only the people", parts["retention_analysis"])
self.assertIn("<Subject 4> takes the place of the person on the right", parts["detailed_description"])
self.assertNotIn("Show one person", parts["detailed_description"])
def test_a_swap_with_two_people_reads_who_goes_from_the_script(self):
for script in ("@2 is the woman in red. @1 replaces the man in the hat", "The man in the hat.\nThe woman in red."):
parts = parts_of(script, jobs={1: "person", 2: "character sheet"}, video="swap the person")
self.assertIn("replace the man in the hat with <Subject 3>", parts["retention_analysis"])
self.assertIn("replace the woman in red with <Subject 4>", parts["retention_analysis"])
self.assertNotIn("@", join_sections(parts))
def test_one_swap_takes_the_at_out_of_who_goes(self):
parts = parts_of("@1 replaces the child in the seat", jobs={1: "person"}, video="swap the person")
self.assertIn("replace the child in the seat with <Subject 2>", parts["retention_analysis"])
def test_keeping_going_needs_no_words_and_no_picture(self):
parts = parts_of("", jobs={}, video="keep going")
self.assertTrue(parts["summary"].startswith("[video continuation]"))
+16 -2
View File
@@ -937,7 +937,7 @@ class RoundTests(TouchCase):
self.addCleanup(sys.modules.pop, "server", None)
sys.modules["server"] = server
self.assertIs(node_module._tell(5, {"key": "k"}, True), True)
self.assertEqual(sent, [("ausboss.mask_by_touch.look", {"node": "5", "note": {"key": "k"}, "end": True}, "a page")], "to the page that asked")
self.assertEqual(sent, [("ausboss.mask_by_touch.look", {"node": "5", "note": {"key": "k"}, "end": True, "prompt_id": None}, "a page")], "to the page that asked")
self.assertIs(node_module._tell(None, {}, True), False, "no id: nobody to tell")
server.PromptServer = types.SimpleNamespace(instance=None)
self.assertIs(node_module._tell(5, {}, True), False, "no page to tell")
@@ -945,6 +945,20 @@ class RoundTests(TouchCase):
self.assertIs(node_module._tell(5, {}, True), False, "outside ComfyUI")
self.assertEqual(len(sent), 1)
def test_interim_notes_carry_the_executing_prompt_id(self):
sent = []
context = types.ModuleType("comfy_execution.utils")
context.get_executing_context = lambda: types.SimpleNamespace(prompt_id="tab-a-prompt")
prior = sys.modules.get("comfy_execution.utils")
self.addCleanup(lambda: sys.modules.__setitem__("comfy_execution.utils", prior) if prior else sys.modules.pop("comfy_execution.utils", None))
sys.modules["comfy_execution.utils"] = context
server = types.ModuleType("server")
server.PromptServer = types.SimpleNamespace(instance=types.SimpleNamespace(send_sync=lambda *args: sent.append(args)))
self.addCleanup(sys.modules.pop, "server", None)
sys.modules["server"] = server
self.assertTrue(node_module._tell(5, {}, False))
self.assertEqual(sent[0][1]["prompt_id"], "tab-a-prompt")
def test_a_first_look_stopped_half_way_is_gone_on_with(self):
class Stopped(BaseException):
pass
@@ -1703,7 +1717,7 @@ class TellTests(TouchCase):
self.assertEqual(asked_at, list(range(4, total + 1, 3)) + [total + 1], "after every third half-second question, and at the end")
for event, data, sid in self.sent:
self.assertEqual((event, sid), ("ausboss.mask_by_touch.look", "the page that asked"))
self.assertEqual(sorted(data), ["end", "node", "note"])
self.assertEqual(sorted(data), ["end", "node", "note", "prompt_id"])
self.assertEqual(data["node"], "7", "the node's id, as text")
json.dumps(data) # it has to travel as JSON
self.assertEqual([data["end"] for _, data, _ in self.sent], [False] * (len(self.sent) - 1) + [True])
+27
View File
@@ -63,6 +63,33 @@ class GrowShrinkTests(unittest.TestCase):
class FillHolesTests(unittest.TestCase):
def test_fill_preserves_existing_soft_alpha(self):
from nodes._mask_by_touch_helpers import finish_mask
mask = torch.full((1, 20, 20), 0.75)
for limit in (0.0, 1.0):
self.assertTrue(torch.equal(fill_mask_holes(mask, limit), mask))
self.assertTrue(torch.equal(finish_mask(mask, fill_holes=True), mask))
mask[:, 9:11, 9:11] = 0.2
filled = fill_mask_holes(mask)
self.assertTrue(torch.all(filled[:, 9:11, 9:11] == 1.0))
self.assertEqual(float(filled[0, 0, 0]), 0.75)
def test_fallback_uses_the_same_diagonal_connectivity(self):
mask = torch.ones(1, 20, 20)
mask[0, 0, 0] = mask[0, 1, 1] = 0
# Two diagonally touching enclosed holes are separate components.
mask[0, 8, 8:11] = 0
mask[0, 9, 11:14] = 0
for limit in (0.0, 1.0):
expected = fill_mask_holes(mask, limit)
with mock.patch.object(mask_helpers, "_scipy_fill_holes", None):
actual = fill_mask_holes(mask, limit)
self.assertTrue(torch.equal(actual, expected))
self.assertEqual(float(actual[0, 1, 1]), 1.0)
self.assertEqual(float(actual[0, 8, 8]), 1.0)
self.assertEqual(float(actual[0, 0, 0]), 0.0)
def test_enclosed_hole_is_filled_but_outside_stays_empty(self):
filled = fill_mask_holes(donut_mask())
self.assertTrue(torch.all(filled[:, 6:10, 6:10] == 1.0))
-18
View File
@@ -38,24 +38,6 @@ class WorkflowContractTests(unittest.TestCase):
graph["nodes"][0]["widgets_values_named"] = {"video": "someone_else.mp4", "start_seconds": 0, "upload": ""}
self.assertIn("stale named widget values: video", " ".join(workflow_problems(graph)))
def test_a_switch_row_must_open_plainly_off_or_on(self):
graph = good_graph()
graph["nodes"][0]["widgets_values"] = {"megapixels": 0.41}
card = {"id": 4, "type": "AUSBOSS_NODES_WorkflowSwitches", "pos": [0, 300], "size": [100, 40], "properties": {
"ausboss_workflow_switches": {"switches": [{"id": 9, "title": "Fast test", "nodes": [],
"on": {"1": {"megapixels": 0.26}}, "off": {"1": {"megapixels": 1}}}]}}}
graph["nodes"].append(card)
graph["last_node_id"] = 4
graph["groups"][0]["bounding"] = [-20, 40, 320, 320]
self.assertIn("row 'Fast test' opens as changed", " ".join(workflow_problems(graph)))
card["properties"]["ausboss_workflow_switches"]["switches"][0]["off"] = {"1": {"megapixels": 0.41}}
self.assertEqual(workflow_problems(graph), [])
graph["nodes"][1]["widgets_values"] = {"steps": 8}
graph["nodes"][1]["mode"] = 4
card["properties"]["ausboss_workflow_switches"]["switches"].append(
{"id": 10, "title": "Turbo", "nodes": [2], "on": {"2": {"steps": 8}}, "off": {"2": {"steps": 20}}})
self.assertIn("row 'Turbo' opens as changed: values say on, nodes say off", " ".join(workflow_problems(graph)))
def test_workflow_id_and_zoom(self):
graph = good_graph()
graph["id"] = "3f2b8c1e-5d4a-4e6f-9a7b-1c2d3e4f5a6b"
+116
View File
@@ -0,0 +1,116 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import vm from "node:vm";
import { outputRecordQuery } from "../js/shared/input_preview.mjs";
const strip = (text) => text.replace(/^import\s+[\s\S]*?\sfrom\s+["'][^"']+["'];\s*/gm, "").replace(/^export /gm, "");
function entry() {
const events = new Map();
const timers = new Map();
let now = 1000, next = 0, queue = { queue_pending: [], queue_running: [] };
const checked = [];
const context = vm.createContext({
console, Map, Set, Promise, performance: { now: () => now },
setTimeout(fn, delay) {
const id = ++next;
timers.set(id, { fn: () => { timers.delete(id); return fn(); }, delay });
return id;
},
clearTimeout(id) { timers.delete(id); },
api: { addEventListener: (name, fn) => events.set(name, fn), fetchApi: async () => ({ json: async () => queue }) },
app: { graph: {}, registerExtension() {} },
executionIdOf: (_graph, node) => String(node.id),
isForeignRun(id) { checked.push(id); return id === "foreign"; },
});
vm.runInContext(strip(readFileSync(new URL("../js/mask_by_touch/index.js", import.meta.url), "utf8")), context);
vm.runInContext(`
globalThis.taken = [];
globalThis.face = {alive:true,inFlight:true,askedAt:0,node:{id:7},settle(){this.inFlight=false;},takeNote(note){taken.push(note.key);}};
faces.add(face);
`, context);
return { context, events, timers, checked, advance: (value) => { now = value; }, queue: (value) => { queue = value; } };
}
test("interim notes check their prompt's workflow before delivery", () => {
const { context, events, checked } = entry();
const look = events.get("ausboss.mask_by_touch.look");
look({ detail: { node: "7", prompt_id: "foreign", note: { key: "wrong tab" } } });
assert.equal(context.taken.length, 0);
look({ detail: { node: "7", prompt_id: "current", note: { key: "this tab" } } });
assert.deepEqual([...context.taken], ["this tab"]);
assert.deepEqual(checked, ["foreign", "current"]);
});
test("an early empty status rechecks a deleted request without another event", async () => {
const { context, events, timers, advance } = entry();
events.get("status")({ detail: { exec_info: { queue_remaining: 0 } } });
assert.equal(context.face.inFlight, true);
assert.equal(timers.size, 1);
const { fn, delay } = [...timers.values()][0];
assert.ok(delay > 0 && delay <= 501);
advance(1600);
await fn();
assert.equal(context.face.inFlight, false);
});
test("the delayed empty check leaves a running request waiting", async () => {
const { context, events, timers, advance, queue } = entry();
events.get("status")({ detail: { exec_info: { queue_remaining: 0 } } });
assert.equal(timers.size, 1);
queue({ queue_pending: [], queue_running: [[0, "current"]] });
advance(1600);
await [...timers.values()][0].fn();
assert.equal(context.face.inFlight, true);
});
test("a newer queue status wins over a delayed empty response", async () => {
const { context, events, timers, advance, queue } = entry();
let finish;
queue(new Promise((resolve) => { finish = resolve; }));
const status = events.get("status");
status({ detail: { exec_info: { queue_remaining: 0 } } });
advance(1600);
const checking = [...timers.values()][0].fn();
status({ detail: { exec_info: { queue_remaining: 1 } } });
finish({ queue_pending: [], queue_running: [] });
await checking;
assert.equal(context.face.inFlight, true);
assert.equal(timers.size, 0);
});
test("a failed queue response never cancels a waiting request", async () => {
const { context, events, timers, advance, queue } = entry();
events.get("status")({ detail: { exec_info: { queue_remaining: 0 } } });
queue({ error: "Unavailable" });
advance(1600);
await [...timers.values()][0].fn();
assert.equal(context.face.inFlight, true);
});
test("a count-only result cannot make an obsolete preview current", () => {
let extension;
const context = vm.createContext({
console, Map, Set, WeakMap, Promise, queueMicrotask() {}, setTimeout: () => 1, clearTimeout() {},
api: { addEventListener() {}, apiURL: (path) => path },
app: { graph: {}, nodeOutputs: {}, registerExtension(value) { extension = value; } },
chainCallback(target, name, fn) {
const prior = target[name];
target[name] = function (...args) { const result = prior?.apply(this, args); fn.apply(this, args); return result; };
},
isForeignRun: () => false, hideInputsInDef() {}, outputLocatorId: () => "7", outputRecordQuery,
});
vm.runInContext(strip(readFileSync(new URL("../js/input_preview/index.js", import.meta.url), "utf8")), context);
vm.runInContext("scheduleRefresh = () => {}; setNote = () => {}; setReadout = () => {};", context);
class PreviewNode {}
extension.beforeRegisterNodeDef(PreviewNode, { name: "AUSBOSS_NODES_MaskByName" });
const node = new PreviewNode();
const state = node.__ausbossInputPreview = { node, alive: true, stale: true, resultGone: true };
node.onExecuted({ ausboss_mask_by_name: [{ found: 1, name: "new search", sure: [0.9] }] });
assert.equal(state.stale, true);
assert.equal(state.resultGone, true);
node.onExecuted({ images: [{ filename: "new.png", type: "temp" }] });
assert.equal(state.stale, false);
assert.equal(state.resultGone, false);
});
+205
View File
@@ -0,0 +1,205 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import vm from "node:vm";
import * as math from "../js/shared/touch_math.mjs";
import * as brush from "../js/shared/brush.mjs";
const source = readFileSync(new URL("../js/shared/touch_stage.mjs", import.meta.url), "utf8");
const strip = (text) => text.replace(/^import\s+[\s\S]*?\sfrom\s+["'][^"']+["'];\s*/gm, "").replace(/^export /gm, "");
function stage() {
const timers = new Map();
let nextTimer = 0;
const context = vm.createContext({
...math, ...brush, console, URLSearchParams, Uint8Array, Uint32Array, Float32Array,
Map, Set, WeakMap, WeakSet, Promise,
performance: { now: () => 1000 },
setTimeout(fn, delay) { const id = ++nextTimer; timers.set(id, { fn, delay }); return id; },
clearTimeout(id) { timers.delete(id); },
requestAnimationFrame: () => 1, cancelAnimationFrame() {}, queueMicrotask,
api: { apiURL: (path) => path }, notifyAusbossChange() {},
chainCallback(target, name, fn) {
const prior = target[name];
target[name] = function (...args) { const result = prior?.apply(this, args); fn.apply(this, args); return result; };
},
});
vm.runInContext(strip(source), context);
const run = (code) => vm.runInContext(code, context);
return { context, timers, run };
}
test("late piece and picture downloads cannot replace a newer note", async () => {
const { context, run } = stage();
const downloads = new Map();
context.downloads = downloads;
run(`
loadImage = url => new Promise(resolve => downloads.set(new URLSearchParams(url.split('?')[1]).get('filename'), resolve));
readPixels = (_image, w, h) => new Uint8Array(w*h*4).fill(255);
syncState = () => {};
show = (_face, entry) => { globalThis.shown = entry.pieces.list[0].name; };
const face = {alive:true,flying:[],turn:0,sourceEpoch:0,node:{widgets:[]}};
const base = {key:'4x4-picture',width:4,height:4,scale:1,stamp:''};
globalThis.entry = pictureFor(base, face);
const note = (name, more) => ({...base,picture:{filename:name+'.jpg'},pieces:{list:[{x:1,y:1,kind:'name',name,sure:1}],planes:[{filename:name+'.png'}],more}});
globalThis.oldJob = takeNote(face, note('old', true));
globalThis.newJob = takeNote(face, note('new', false));
`);
downloads.get("new.jpg")({});
downloads.get("new.png")({});
await context.newJob;
downloads.get("old.jpg")({});
downloads.get("old.png")({});
await context.oldJob;
assert.equal(context.shown, "new");
assert.equal(context.entry.pieces.list[0].name, "new");
assert.equal(context.entry.file, "new.jpg");
assert.equal(context.entry.scoped, true);
});
test("selection, area and fine caches belong to each face", () => {
const { run } = stage();
run(`
const note = {key:'same-picture',width:2,height:2,scale:1};
const first = pictureFor(note, {});
const second = pictureFor(note, {});
const text = encodeTouches(first.key,[{t:'subject',op:1}]);
const state = {pic:first,items:decodeTouches(text).items,stamp:stampOf(text),stamps:prefixStamps(first.key,decodeTouches(text).items)};
remember(first,state.stamp,Uint8Array.from([1,0,0,0]));
keepArea(first,'subject',Uint8Array.from([1,0,0,0]));
first.fine = {made:'first model'};
remember(second,state.stamp,Uint8Array.from([0,0,0,1]));
keepArea(second,'subject',Uint8Array.from([0,0,0,1]));
second.fine = {made:'second model'};
resolve(state);
globalThis.result = {raw:Array.from(state.raw),area:Array.from(first.areas.get('subject')),fine:first.fine.made};
`);
const result = JSON.parse(run("JSON.stringify(result)"));
assert.deepEqual(result, { raw: [1, 0, 0, 0], area: [1, 0, 0, 0], fine: "first model" });
});
test("late selection and fine downloads cannot overwrite the current stamp", async () => {
const { context, run } = stage();
const downloads = context.downloads = new Map();
run(`
loadImage = url => new Promise(resolve => downloads.set(new URLSearchParams(url.split('?')[1]).get('filename'), resolve));
readPixels = (image, w, h) => new Uint8Array(w*h*4).fill(image.value);
syncState = () => {}; show = () => {};
const face = {alive:true,flying:[],turn:0,sourceEpoch:0,node:{widgets:[]}};
const base = {key:'2x2-picture',width:2,height:2,scale:1,stamp:'same',picture:{filename:'picture.jpg'}};
globalThis.entry = pictureFor(base,face);
entry.image = {}; entry.file = 'picture.jpg';
const note = name => ({...base,selection:{filename:name+'-selection.png'},fine:{file:{filename:name+'-fine.png'},made:name}});
globalThis.oldJob = takeNote(face,note('old'));
globalThis.newJob = takeNote(face,note('new'));
`);
downloads.get("new-selection.png")({ value: 0 });
downloads.get("new-fine.png")({ value: 0 });
await context.newJob;
downloads.get("old-selection.png")({ value: 255 });
downloads.get("old-fine.png")({ value: 255 });
await context.oldJob;
assert.deepEqual([...context.entry.states.get("same")], [0, 0, 0, 0]);
assert.equal(context.entry.fine.made, "new");
});
test("restored picks never schedule fine processing", () => {
const { context, timers, run } = stage();
run(`
adopt = () => false; readItems = () => {}; resolve = () => {};
syncState = () => {}; refreshHover = () => {}; draw = () => {}; drawInk = () => {};
settings = () => ({fine:'model.safetensors'}); fineOf = () => null;
globalThis.requested = [];
requestRun = (_face, ask) => requested.push(ask);
const pic = {key:'picture',piecesTag:''};
const face = {alive:true,pic,node:{},items:[{t:'paint',op:1,r:10,pts:[[1,1]]}],exact:true,stamp:'same',shownPieces:'',pending:[],inFlight:false};
show(face,pic,{stamp:'same'},null,{restored:true});
`);
for (const { fn } of [...timers.values()]) fn();
assert.deepEqual([...context.requested], []);
});
test("source subscriptions release removed faces while the loader survives", () => {
const { context, run } = stage();
run(`
globalThis.reads = 0;
globalThis.priorCalls = 0;
const loader = {widgets:[{name:'image',value:'picture.png',callback(){priorCalls++;}}]};
ownLoaderNode = () => loader;
for (let n=0;n<100;n++) {
const face = {node:{},watchedSources:new WeakSet(),forgetSources:[],host:{},get alive(){reads++;return false;}};
followOwnLoader(face);
for (const forget of face.forgetSources) forget();
}
loader.widgets[0].callback();
`);
assert.equal(context.reads, 0);
assert.equal(context.priorCalls, 1);
});
test("plain Load Image subscriptions are removable too", async () => {
const { context, run } = stage();
run(`
globalThis.reads = 0;
const widget = {};
loaderSource = () => null; loaderWidget = () => widget;
for (let n=0;n<100;n++) {
const face = {node:{},sourceUrl:'',watchedSources:new WeakSet(),forgetSources:[],get alive(){reads++;return false;}};
syncSource(face);
for (const forget of face.forgetSources) forget();
}
widget.callback();
`);
await Promise.resolve();
assert.equal(context.reads, 0);
});
test("destroy cancels the pending click and releases source subscriptions", () => {
const { context, timers, run } = stage();
run(`
globalThis.forgotten = 0; globalThis.aborted = false;
globalThis.face = {alive:true,pendingTimer:setTimeout(()=>{},500),forgetSources:[()=>forgotten++]};
globalThis.abort = {abort(){aborted=true;}};
`);
const start = source.indexOf(" face.destroy = () => {");
const end = source.indexOf("\n };", start) + "\n };".length;
run(source.slice(start, end));
context.face.destroy();
assert.equal(context.face.alive, false);
assert.equal(timers.size, 0);
assert.equal(context.forgotten, 1);
assert.equal(context.aborted, true);
});
test("Clear is enabled for malformed stored touches and erases the text", () => {
const { context, run } = stage();
run(`
const toggle = {toggle(){}};
globalThis.face = {node:{},pic:null,items:[],pending:[],past:[],touches:{value:'{'},host:{},
undo:{},clear:{},toolSwitch:{set(){},root:{classList:toggle}},softField:{classList:toggle},softBox:{set(){}},
subjectButton:{},backgroundButton:{},eye:{classList:toggle},size:{set(){}},view:'tint'};
syncTools(face);
`);
assert.equal(context.face.clear.disabled, false);
run(`
dropPending = () => {}; syncReadout = () => {}; syncState = () => {}; refreshHover = () => {};
button = (_class, _text, _title, click) => ({click});
globalThis.signal = null;
`);
const start = source.indexOf(" face.clear = button(");
const end = source.indexOf(" }, signal);", start) + " }, signal);".length;
run(source.slice(start, end));
context.face.clear.click();
assert.equal(context.face.touches.value, "");
});
test("named pieces of one through four pixels can be added and removed", () => {
for (let size = 1; size <= 4; size++) {
const planes = [new Uint32Array(size).fill(1)];
const sizes = math.pieceSizes(planes, 1);
assert.equal(math.pieceAt(planes, 0, { sizes, inside: [0] }), 0);
assert.equal(math.pieceAt(planes, 0, { sizes, inside: [size], adding: false }), 0);
assert.equal(math.pieceAt(planes, 0, { sizes, inside: [size] }), -1);
assert.equal(math.pieceAt(planes, 0, { sizes, inside: [0], adding: false }), -1);
}
});