Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c0e93a760 | ||
|
|
c37386c1d4 | ||
|
|
7cb0abaacf |
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 () {
|
||||
|
||||
@@ -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." };
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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
-67
@@ -88,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 = (
|
||||
@@ -121,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}>"
|
||||
|
||||
@@ -146,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))
|
||||
@@ -170,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}
|
||||
@@ -183,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."""
|
||||
@@ -275,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:
|
||||
@@ -330,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
|
||||
|
||||
@@ -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 ""))
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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"),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -676,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]"))
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user