Compare commits

..
Author SHA1 Message Date
ausboss d5e60ce7a6 Pika H3 Flex example: note steps that work, blank audio loaders
- The Settings note now includes setting the video job to swap the
  person, so its steps work as written.
- Voice and Song loaders are saved blank, like the other media loaders.
- The version sentence with unfilled placeholders is removed.
2026-11-04 00:37:41 -06:00
ausboss 0ffb6a576b H3 Prompt Writer: a swap puts each person picture in one person's place 2026-11-03 20:57:51 -06:00
ausboss 5656144a9f Add the Pika H3 Flex example workflow 2026-11-03 20:29:04 -06:00
ausboss 3b16d1e254 Load Audio: shorter wave, length moves off the wave next to OUT 2026-11-03 23:09:43 +00:00
ausboss dff58e6b88 Load Audio: open smaller and go down to 280 wide 2026-11-03 22:57:38 +00:00
ausboss ef06093d56 Merge remote-tracking branch 'origin/main' into feat/load-audio-trim
# Conflicts:
#	CHANGELOG.md
2026-11-03 22:40:25 +00:00
ausboss 8582a0219d Add Load Audio node: wave, trim, preview and max length
Load a sound file and keep only the part you want. The node shows the
whole file as a wave. Drag the IN and OUT handles (or type the times),
press play to hear just that part, and set Max length to the longest
sound the next step takes: anything past it turns red and is cut off,
so you see what comes out before you run. Max length can be wired.

Only the picked part is decoded. Outputs the audio and its seconds.
Help page, README, changelog, Chinese names, tests and the API snapshot
are included.
2026-11-03 20:51:32 +00:00
23 changed files with 8722 additions and 725 deletions
+10
View File
@@ -4,6 +4,16 @@ 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
File diff suppressed because it is too large Load Diff
-9
View File
@@ -27,15 +27,6 @@ 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.
+2 -5
View File
@@ -862,11 +862,8 @@ app.registerExtension({
chainCallback(nodeType.prototype, "onExecuted", function (output) {
if (isForeignRun()) return;
const state = buildPanel(this, config);
// Count-only results carry no replacement for a stale picture.
if (state) {
if (outputRecordQuery(output)) { state.stale = false; state.resultGone = false; }
scheduleRefresh(state);
}
// A result arrived (Save Image sends one even with its preview off).
if (state) { state.stale = false; state.resultGone = false; scheduleRefresh(state); }
// Saved: the file's picture replaces the steps.
if (state?.stepOverlay) {
state.stepsSaved = stepRun().promptId;
+3 -31
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 || isForeignRun(detail.prompt_id)) return;
if (!faces.size || !detail?.note) return;
const id = String(detail.node ?? "");
const root = app.rootGraph ?? app.graph;
for (const face of faces) {
@@ -183,39 +183,11 @@ 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;
settleEmptyQueue();
const now = performance.now();
for (const face of faces) if (face.inFlight && now - face.askedAt > 1500) face.settle();
} catch {
// Never in the way of a run.
}
+12 -123
View File
@@ -19,8 +19,6 @@ 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";
@@ -30,19 +28,8 @@ 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;
// 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 PANEL_DECLARED_HEIGHT = CARD_HEIGHT + 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;
@@ -72,21 +59,6 @@ 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;}
@@ -152,7 +124,6 @@ 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";
@@ -165,11 +136,6 @@ 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 = "";
@@ -179,8 +145,6 @@ 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"
@@ -188,16 +152,6 @@ 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);
@@ -287,9 +241,7 @@ function buildPanel(node) {
input.inputMode = "numeric";
input.spellcheck = false;
const copy = button("ausboss-seed-icon", "⧉", "Copy the seed");
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.append(tag, input, copy);
field.addEventListener("pointerdown", (event) => event.stopPropagation());
const seg = el("div", "ausboss-seed-seg");
@@ -305,16 +257,11 @@ function buildPanel(node) {
row.append(newSeed, useLast, more);
const hint = el("div", "ausboss-seed-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);
card.append(field, seg, row, hint);
root.append(card);
const state = (node.__ausbossSeed = {
node, root, card, field, tag, input, copy, gear, chip, dice, segRandom, segFixed, segStep, newSeed, useLast, more, hint,
node, root, field, tag, input, copy, segRandom, segFixed, segStep, newSeed, useLast, more, hint,
});
const commitTyped = () => {
@@ -357,31 +304,6 @@ 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); }
@@ -396,7 +318,7 @@ function buildPanel(node) {
const widget = node.addDOMWidget(WIDGET_NAME, "ausboss_seed", root, {
serialize: false,
hideOnZoom: false,
getMinHeight: () => panelHeight(node),
getMinHeight: () => PANEL_DECLARED_HEIGHT,
});
keepDomWidgetWidthAuto(widget);
// Not saved with the workflow either: options.serialize only keeps it out
@@ -405,20 +327,18 @@ function buildPanel(node) {
// A constant-height card: pinned on purpose (see
// tests/panel_guards.test.mjs, fixedByDesign).
widget.computeSize = (width) => [
Math.max(nodeWidthFloor(node), Number(width || node.size?.[0] || nodeWidthFloor(node))),
panelHeight(node),
Math.max(NODE_WIDTH, Number(width || node.size?.[0] || NODE_WIDTH)),
PANEL_DECLARED_HEIGHT,
];
// Pinned in Nodes 2.0 as well, where a node can be made taller than its card.
pinVuePanelHeight(widget);
widget.options.minNodeSize = [nodeWidthFloor(node), 60];
widget.options.minNodeSize = [NODE_WIDTH, 120];
// The frontend skips a pinned card's width, so its floor is held here.
holdNodeMinWidth(widget, nodeWidthFloor(node));
state.widget = widget;
holdNodeMinWidth(widget, NODE_WIDTH);
new ResizeObserver(() => fitCompactNumber(state)).observe(input);
render(state);
node.setSize?.([
Math.max(node.size?.[0] ?? 0, nodeWidthFloor(node)),
Math.max(node.size?.[0] ?? 0, NODE_WIDTH),
node.computeSize?.()[1] ?? node.size?.[1],
]);
// The card is a fixed-height row: width may grow, but a corner drag
@@ -436,49 +356,18 @@ 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 () {
// 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]]);
}
buildPanel(this);
});
chainCallback(nodeType.prototype, "onConfigure", function () {
queueMicrotask(() => {
const state = buildPanel(this);
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));
}
if (state) render(state);
});
});
chainCallback(nodeType.prototype, "onConnectionsChange", function () {
-61
View File
@@ -1,61 +0,0 @@
// 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.min(size - 1, Math.max(4, size * NEARLY));
const slack = Math.max(4, size * NEARLY);
if (adding ? size - picked > slack : picked > slack) return piece;
}
return -1;
+15 -48
View File
@@ -141,12 +141,11 @@ 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
// another visit to the same picture costs nothing. Results belong to one face.
// pieces and the picked areas it has shown, so a tab switch, an undo or a
// second node on the same picture costs nothing.
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
@@ -285,14 +284,13 @@ const widgetByName = (node, name) => node.widgets?.find((widget) => widget?.name
// ---- the pictures a page has shown ---------------------------------------
function pictureFor(note, face) {
function pictureFor(note) {
const width = Math.round(Number(note.width));
const height = Math.round(Number(note.height));
const scale = Number(note.scale) > 0 ? Number(note.scale) : 1;
const cacheKey = face ? `${face.pictureId ??= ++pictureSerial}|${note.key}` : note.key;
let entry = pictures.get(cacheKey);
let entry = pictures.get(note.key);
if (entry && (entry.width !== width || entry.height !== height)) entry = null;
pictures.delete(cacheKey);
pictures.delete(note.key);
entry ??= {
key: note.key,
width,
@@ -310,7 +308,7 @@ function pictureFor(note, face) {
nameSeeds: new Map(),
states: new Map(),
};
pictures.set(cacheKey, entry);
pictures.set(note.key, entry);
while (pictures.size > PICTURES_KEPT) pictures.delete(pictures.keys().next().value);
return entry;
}
@@ -388,7 +386,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, generation) {
async function loadPieces(entry, pieces) {
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)))
@@ -412,7 +410,6 @@ async function loadPieces(entry, pieces, generation) {
});
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);
@@ -1404,7 +1401,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 && !String(face.touches?.value ?? "").trim();
face.clear.disabled = !some && !waiting;
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";
@@ -1483,28 +1480,6 @@ 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
@@ -1545,8 +1520,9 @@ 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.
watchSource(face, widget, () => { if (face.alive && !face.host.loading?.()) later(); });
chainCallback(widget, "callback", () => { if (face.alive && !face.host.loading?.()) later(); });
}
if (now) later();
}
@@ -1576,7 +1552,8 @@ function syncSource(face) {
const url = source?.url ?? "";
const watched = loaderWidget(face.node);
if (watched && !face.watchedSources.has(watched)) {
watchSource(face, watched, () => queueMicrotask(() => { if (face.alive) syncState(face); }));
face.watchedSources.add(watched);
chainCallback(watched, "callback", () => queueMicrotask(() => { if (face.alive) syncState(face); }));
}
if (url === face.sourceUrl) return;
face.sourceUrl = url;
@@ -1814,8 +1791,8 @@ function show(face, entry, note, answered, { restored = false, own = false, inte
refreshHover(face);
draw(face);
drawInk(face);
if (restored) return;
wantFine(face);
if (restored) return;
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.
@@ -1865,8 +1842,7 @@ async function takeNote(face, note, { restored = false, interim = false } = {})
}
const turn = ++face.turn;
const sentStamp = sent?.stamp ?? "";
const entry = pictureFor(note, face);
const generation = entry.generation = (entry.generation ?? 0) + 1;
const entry = pictureFor(note);
// 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;
@@ -1878,13 +1854,12 @@ 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, generation));
jobs.push(loadPieces(entry, note.pieces));
} else if (note.pieces && typeof note.pieces === "object") {
entry.scoped = note.pieces.more !== true;
}
@@ -1934,7 +1909,6 @@ 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) {
@@ -2896,7 +2870,6 @@ 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,
@@ -2971,11 +2944,8 @@ 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, []);
@@ -3140,9 +3110,6 @@ 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();
};
+5 -10
View File
@@ -170,21 +170,16 @@ 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)
stamp = _model_stamp(name)
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))
kept = _MODELS.get(name)
if kept is not None and kept[0] == stamp:
_MODELS.move_to_end(name)
@@ -211,7 +206,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, _model_stamp(name), key)
slot = (name, key)
if key is not None and slot in _SUBJECTS:
_SUBJECTS.move_to_end(slot)
return _SUBJECTS[slot].float() / 255.0
+67 -12
View File
@@ -88,14 +88,20 @@ 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_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 = (
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 person.\n"
"{replaced}: removed - replace {who} with {main} throughout."
"change only the {people}."
)
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,
)
SWAPPED = "attribute_transfer - apply the defined appearance to the original performance; the background of {picture} is not transferred."
KEEP_IS = (
@@ -115,6 +121,11 @@ 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}>"
@@ -135,11 +146,16 @@ 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 person being replaced comes first, as <Subject 1>."""
the people being replaced come first, as <Subject 1>, <Subject 2> and on."""
pictures = []
subjects = 1 if video == SWAP else 0
subjects = swapped_in(jobs) 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))
@@ -154,8 +170,12 @@ 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}
@@ -163,6 +183,39 @@ 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."""
@@ -222,7 +275,8 @@ class Cast:
ends = self.ends
lines = []
if self.video == SWAP:
lines.append(SWAP_IS.format(replaced=self.replaced, who=self.who))
lines.append(SWAP_VIDEO)
lines += [SWAP_IS.format(replaced=gone, who=who) for gone, who, _ in self.swaps]
worn = set(wearers.values())
for picture in self.pictures:
if picture.job == END:
@@ -276,7 +330,8 @@ class Cast:
lines = []
if self.video == SWAP:
lines.append(SWAP_KEPT.format(replaced=self.replaced, who=self.who, main=self.main))
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]
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, lower_first, read_script, script_parts, speakers
from ._h3_script_helpers import ACTION_GOES_ON, Moment, add_moments, add_sound, 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.who = lower_first(" ".join(moment.text for moment in scene.moments).rstrip(". "))
cast.name_who_goes([moment.text for moment in scene.moments])
scene.moments = []
if not scene.moments:
scene.moments.append(Moment(ACTION_GOES_ON if video == KEEP else ""))
+8 -7
View File
@@ -25,7 +25,10 @@ from ._h3_cast_helpers import (
SOUND_GOES_ON_KEPT,
THING,
TOKEN,
ONE_GOES,
SPOTS,
Cast,
lower_first,
number_pictures,
picture_label,
subject_label,
@@ -124,7 +127,6 @@ 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")
@@ -539,9 +541,6 @@ 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):
@@ -669,7 +668,8 @@ 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=cast.main, who=cast.who), ONE_PERSON if cast.who == "the original person" else OTHERS_STAY]
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)
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):
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.
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,7 +786,8 @@ def task_of(cast):
def summary_of(script, cast, new_places, soundtrack):
task = task_of(cast)
if cast.video == SWAP:
return f"[{task}] Replace {cast.replaced} with {cast.main} while preserving the original action, place, camera and look of <Video 1>."
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>."
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 "
+8 -10
View File
@@ -100,9 +100,8 @@ def _torch_fill_holes(solid: torch.Tensor) -> torch.Tensor:
reachable[:, :, -1] = background[:, :, -1]
while True:
grown = (
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)),
functional.max_pool2d(
reachable.float().unsqueeze(1), kernel_size=3, stride=1, padding=1
)
.squeeze(1)
.bool()
@@ -130,10 +129,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 4-connected, the way _torch_fill_holes floods them. Every hole
Holes are 8-connected, the way _torch_fill_holes floods them. Every hole
pixel starts with its own index; each pass takes the largest index in
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.
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.
"""
height, width = holes.shape
inside = holes.reshape(-1)
@@ -141,9 +140,8 @@ 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 = 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)),
spread = functional.max_pool2d(
labels.view(1, 1, height, width), kernel_size=3, stride=1, padding=1
).reshape(-1)
spread = torch.where(inside, spread, zero)
spread = torch.where(inside, spread[(spread.long() - 1).clamp(min=0)], zero)
@@ -190,7 +188,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.where(filled & ~solid, 1.0, mask)
return torch.maximum(mask, filled.to(mask.dtype))
def blur_mask(mask: torch.Tensor, sigma: float) -> torch.Tensor:
+1 -3
View File
@@ -220,11 +220,9 @@ 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), "prompt_id": prompt_id},
{"node": str(node_id), "note": note, "end": bool(end)},
getattr(server, "client_id", None),
)
except Exception: # noqa: BLE001
-26
View File
@@ -1,26 +0,0 @@
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,7 +53,6 @@ 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,18 +191,6 @@ 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:
+22
View File
@@ -676,6 +676,28 @@ 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]"))
+2 -16
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, "prompt_id": None}, "a page")], "to the page that asked")
self.assertEqual(sent, [("ausboss.mask_by_touch.look", {"node": "5", "note": {"key": "k"}, "end": True}, "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,20 +945,6 @@ 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
@@ -1717,7 +1703,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", "prompt_id"])
self.assertEqual(sorted(data), ["end", "node", "note"])
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,33 +63,6 @@ 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))
-116
View File
@@ -1,116 +0,0 @@
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
@@ -1,205 +0,0 @@
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);
}
});