Compare commits

...
1 Commits
Author SHA1 Message Date
ausboss 0c0e93a760 Mask by Touch: fixes from a full review
Twelve fixes with tests across the touch stage, preview, mask and
fine-edge helpers, including interim pictures sent for the right run.
2026-11-04 01:54:50 -06:00
13 changed files with 485 additions and 37 deletions
+5 -2
View File
@@ -862,8 +862,11 @@ app.registerExtension({
chainCallback(nodeType.prototype, "onExecuted", function (output) {
if (isForeignRun()) return;
const state = buildPanel(this, config);
// A result arrived (Save Image sends one even with its preview off).
if (state) { state.stale = false; state.resultGone = false; scheduleRefresh(state); }
// Count-only results carry no replacement for a stale picture.
if (state) {
if (outputRecordQuery(output)) { state.stale = false; state.resultGone = false; }
scheduleRefresh(state);
}
// Saved: the file's picture replaces the steps.
if (state?.stepOverlay) {
state.stepsSaved = stepRun().promptId;
+31 -3
View File
@@ -170,7 +170,7 @@ api.addEventListener("execution_interrupted", () => settleAll());
// second or two, so things light up while it is still looking.
api.addEventListener(LOOK_EVENT, ({ detail }) => {
try {
if (!faces.size || !detail?.note) return;
if (!faces.size || !detail?.note || isForeignRun(detail.prompt_id)) return;
const id = String(detail.node ?? "");
const root = app.rootGraph ?? app.graph;
for (const face of faces) {
@@ -183,11 +183,39 @@ api.addEventListener(LOOK_EVENT, ({ detail }) => {
// A run that left the queue without ever reaching the node (it was deleted
// there) would leave a face waiting for good. Once the queue is empty and a
// face has waited a while, it stops waiting; the next touch asks again.
let emptyQueueTimer = 0;
let emptyQueueVersion = 0;
function settleEmptyQueue() {
const now = performance.now();
let delay = Infinity;
for (const face of faces) {
if (!face.alive || !face.inFlight) continue;
const left = 1501 - (now - face.askedAt);
if (left <= 0) face.settle();
else delay = Math.min(delay, left);
}
if (Number.isFinite(delay)) {
const version = emptyQueueVersion;
emptyQueueTimer = setTimeout(async () => {
emptyQueueTimer = 0;
try {
const queue = await (await api.fetchApi("/queue")).json();
if (version !== emptyQueueVersion) return;
if (Array.isArray(queue?.queue_pending) && Array.isArray(queue?.queue_running)
&& !queue.queue_pending.length && !queue.queue_running.length) settleEmptyQueue();
} catch {
// A failed queue read never cancels a request that may still run.
}
}, delay);
}
}
api.addEventListener("status", ({ detail }) => {
try {
emptyQueueVersion += 1;
clearTimeout(emptyQueueTimer);
emptyQueueTimer = 0;
if (!faces.size || detail?.exec_info?.queue_remaining !== 0) return;
const now = performance.now();
for (const face of faces) if (face.inFlight && now - face.askedAt > 1500) face.settle();
settleEmptyQueue();
} catch {
// Never in the way of a run.
}
+1 -1
View File
@@ -1046,7 +1046,7 @@ export function pieceAt(planes, index, { sizes, inside, adding = true, ranks = n
const size = sizes?.[piece] ?? 0;
if (!size) continue;
const picked = inside?.[piece] ?? 0;
const slack = Math.max(4, size * NEARLY);
const slack = Math.min(size - 1, Math.max(4, size * NEARLY));
if (adding ? size - picked > slack : picked > slack) return piece;
}
return -1;
+48 -15
View File
@@ -141,11 +141,12 @@ const TOUCH_REACH = 2;
// The areas of picked things a picture keeps, as the node sent them.
const AREAS_KEPT = 96;
// Pictures the faces of a page have shown, newest last. A picture keeps its
// pieces and the picked areas it has shown, so a tab switch, an undo or a
// second node on the same picture costs nothing.
// pieces and the picked areas it has shown, so a tab switch, an undo or
// another visit to the same picture costs nothing. Results belong to one face.
const PICTURES_KEPT = 3;
const STATES_KEPT = 14;
const pictures = new Map();
let pictureSerial = 0;
// A face asks for a run again on its own at most this often in a row.
const RETRIES = 2;
// The long side of the small copy the masks are worked on, when the face
@@ -284,13 +285,14 @@ const widgetByName = (node, name) => node.widgets?.find((widget) => widget?.name
// ---- the pictures a page has shown ---------------------------------------
function pictureFor(note) {
function pictureFor(note, face) {
const width = Math.round(Number(note.width));
const height = Math.round(Number(note.height));
const scale = Number(note.scale) > 0 ? Number(note.scale) : 1;
let entry = pictures.get(note.key);
const cacheKey = face ? `${face.pictureId ??= ++pictureSerial}|${note.key}` : note.key;
let entry = pictures.get(cacheKey);
if (entry && (entry.width !== width || entry.height !== height)) entry = null;
pictures.delete(note.key);
pictures.delete(cacheKey);
entry ??= {
key: note.key,
width,
@@ -308,7 +310,7 @@ function pictureFor(note) {
nameSeeds: new Map(),
states: new Map(),
};
pictures.set(note.key, entry);
pictures.set(cacheKey, entry);
while (pictures.size > PICTURES_KEPT) pictures.delete(pictures.keys().next().value);
return entry;
}
@@ -386,7 +388,7 @@ function validNote(note) {
// whether the picture has points left to ask about.
const piecesTag = (pieces) => `${pieces?.list?.length ?? 0}:${(Array.isArray(pieces?.planes) ? pieces.planes : []).map((file) => file?.filename).join(",")}`;
async function loadPieces(entry, pieces) {
async function loadPieces(entry, pieces, generation) {
const tag = piecesTag(pieces);
const list = (Array.isArray(pieces.list) ? pieces.list : [])
.filter((piece) => piece && Number.isFinite(Number(piece.x)) && Number.isFinite(Number(piece.y)))
@@ -410,6 +412,7 @@ async function loadPieces(entry, pieces) {
});
const files = (Array.isArray(pieces.planes) ? pieces.planes : []).filter(isFile);
const images = await Promise.all(files.map((file) => loadImage(viewUrl(file, entry.key))));
if (entry.generation !== generation) return;
const planes = packPlanes(images.map((image) => readPixels(image, entry.pw, entry.ph)), entry.pw, entry.ph);
const sizes = pieceSizes(planes, list.length);
const overlaps = pieceOverlaps(planes, list.length);
@@ -1401,7 +1404,7 @@ function syncTools(face) {
// A click that waits for the look can be taken back like a pick.
const waiting = live && face.pending.length > 0;
face.undo.disabled = !live || (!some && !face.past.length && !waiting);
face.clear.disabled = !some && !waiting;
face.clear.disabled = !some && !waiting && !String(face.touches?.value ?? "").trim();
if (face.lookButton) {
const offer = live && face.snap && !face.held && !face.inFlight && !face.pic.scoped && Boolean(face.host.lookOnButton?.());
face.lookButton.style.display = offer ? "" : "none";
@@ -1480,6 +1483,28 @@ function ownLoader(node) {
// for: a crop that is being dragged changes them many times a second.
const OWN_SETTLE_MS = 700;
// One callback per source widget; removing a face removes its subscription.
const sourceWatchers = new WeakMap();
function sourceListeners(widget) {
let listeners = sourceWatchers.get(widget);
if (!listeners) {
listeners = new Set();
sourceWatchers.set(widget, listeners);
chainCallback(widget, "callback", () => {
for (const listener of listeners) listener();
});
}
return listeners;
}
function watchSource(face, widget, changed) {
if (face.watchedSources.has(widget)) return;
face.watchedSources.add(widget);
const listeners = sourceListeners(widget);
listeners.add(changed);
face.forgetSources.push(() => listeners.delete(changed));
}
// Show the loader's picture without a press on Show picture: the same run
// that button asks for. Only ever in answer to something the person did (a
// file chosen, a setting changed, a wire made), never while a workflow is
@@ -1520,9 +1545,8 @@ function followOwnLoader(face, now = false) {
};
for (const widget of upstream.widgets ?? []) {
if (!widget || face.watchedSources.has(widget)) continue;
face.watchedSources.add(widget);
// A workflow being opened sets its values too: that is not the person.
chainCallback(widget, "callback", () => { if (face.alive && !face.host.loading?.()) later(); });
watchSource(face, widget, () => { if (face.alive && !face.host.loading?.()) later(); });
}
if (now) later();
}
@@ -1552,8 +1576,7 @@ function syncSource(face) {
const url = source?.url ?? "";
const watched = loaderWidget(face.node);
if (watched && !face.watchedSources.has(watched)) {
face.watchedSources.add(watched);
chainCallback(watched, "callback", () => queueMicrotask(() => { if (face.alive) syncState(face); }));
watchSource(face, watched, () => queueMicrotask(() => { if (face.alive) syncState(face); }));
}
if (url === face.sourceUrl) return;
face.sourceUrl = url;
@@ -1791,8 +1814,8 @@ function show(face, entry, note, answered, { restored = false, own = false, inte
refreshHover(face);
draw(face);
drawInk(face);
wantFine(face);
if (restored) return;
wantFine(face);
const waited = settlePending(face);
// More is on its way (the look goes on, or a run sent behind it): what to
// ask next is decided when the last of it is in.
@@ -1842,7 +1865,8 @@ async function takeNote(face, note, { restored = false, interim = false } = {})
}
const turn = ++face.turn;
const sentStamp = sent?.stamp ?? "";
const entry = pictureFor(note);
const entry = pictureFor(note, face);
const generation = entry.generation = (entry.generation ?? 0) + 1;
// How many looks at this picture have come back, and how far through its
// list of words the node is: an early click waits for the words.
if (own && sent.ask === "scope") entry.looks = (entry.looks ?? 0) + 1;
@@ -1854,12 +1878,13 @@ async function takeNote(face, note, { restored = false, interim = false } = {})
const jobs = [];
if (!entry.image || entry.file !== note.picture.filename) {
jobs.push(loadImage(viewUrl(note.picture, note.key)).then((image) => {
if (entry.generation !== generation) return;
entry.image = image;
entry.file = note.picture.filename;
}));
}
if (note.pieces && typeof note.pieces === "object" && piecesTag(note.pieces) !== entry.piecesTag) {
jobs.push(loadPieces(entry, note.pieces));
jobs.push(loadPieces(entry, note.pieces, generation));
} else if (note.pieces && typeof note.pieces === "object") {
entry.scoped = note.pieces.more !== true;
}
@@ -1909,6 +1934,7 @@ async function takeNote(face, note, { restored = false, interim = false } = {})
}
return;
}
if (!face.alive || turn !== face.turn || entry.generation !== generation) return;
if (answered) remember(entry, stamp, answered);
if (fine) entry.fine = fine;
if (typeof note.fine?.trouble === "string" && note.fine.trouble) {
@@ -2870,6 +2896,7 @@ export function mountTouchStage(node, host) {
sourceEpoch: 0,
sentEpoch: 0,
watchedSources: new WeakSet(),
forgetSources: [],
// One of the pack's loaders wired straight in: what it was last asked for.
ownSeen: "",
ownTimer: 0,
@@ -2944,8 +2971,11 @@ export function mountTouchStage(node, host) {
face.active = 0;
face.fresh = 0;
if (!face.items.length) {
writeTouches(face, "");
readItems(face);
face.chipsShown = null;
syncReadout(face);
syncTools(face);
return;
}
change(face, []);
@@ -3110,6 +3140,9 @@ export function mountTouchStage(node, host) {
clearTimeout(face.ownTimer);
clearTimeout(face.dwell);
clearTimeout(face.fineTimer);
clearTimeout(face.pendingTimer);
for (const forget of face.forgetSources) forget();
face.forgetSources.length = 0;
if (face.frameRequest) cancelAnimationFrame(face.frameRequest);
abort.abort();
};
+10 -5
View File
@@ -170,16 +170,21 @@ def _core_node(key: str):
return node if hasattr(node, "execute") else None
def _model_stamp(name: str):
path = folder_paths.get_full_path(MODEL_FOLDER, name) if folder_paths is not None else None
if not path:
raise FileNotFoundError(NOT_FOUND.format(name=name) if list_subject_models() else NEEDS_MODEL)
info = os.stat(path)
return (path, info.st_mtime_ns, info.st_size)
def _load(name: str):
"""The model, through ComfyUI's own loader node, as a workflow would
load it. Kept while its file does not change."""
loader = _core_node("LoadBackgroundRemovalModel")
if loader is None or _core_node("RemoveBackground") is None:
raise RuntimeError(NEEDS_NEWER)
path = folder_paths.get_full_path(MODEL_FOLDER, name) if folder_paths is not None else None
if not path:
raise FileNotFoundError(NOT_FOUND.format(name=name) if list_subject_models() else NEEDS_MODEL)
stamp = (path, os.path.getmtime(path), os.path.getsize(path))
stamp = _model_stamp(name)
kept = _MODELS.get(name)
if kept is not None and kept[0] == stamp:
_MODELS.move_to_end(name)
@@ -206,7 +211,7 @@ def subject_matte(frame: torch.Tensor, choice, key: str | None = None) -> torch.
set to. With `key` (the picture's own key) the answer is remembered: a
picture is asked about once, however many picks follow."""
name = subject_model_name(choice)
slot = (name, key)
slot = (name, _model_stamp(name), key)
if key is not None and slot in _SUBJECTS:
_SUBJECTS.move_to_end(slot)
return _SUBJECTS[slot].float() / 255.0
+10 -8
View File
@@ -100,8 +100,9 @@ def _torch_fill_holes(solid: torch.Tensor) -> torch.Tensor:
reachable[:, :, -1] = background[:, :, -1]
while True:
grown = (
functional.max_pool2d(
reachable.float().unsqueeze(1), kernel_size=3, stride=1, padding=1
torch.maximum(
functional.max_pool2d(reachable.float().unsqueeze(1), (3, 1), 1, (1, 0)),
functional.max_pool2d(reachable.float().unsqueeze(1), (1, 3), 1, (0, 1)),
)
.squeeze(1)
.bool()
@@ -129,10 +130,10 @@ def _hole_limit(max_hole_size: float, height: int, width: int) -> float | None:
def _torch_hole_areas(holes: torch.Tensor) -> torch.Tensor:
"""Each hole pixel's hole size in pixels, 0 elsewhere, for one HW frame.
Holes are 8-connected, the way _torch_fill_holes floods them. Every hole
Holes are 4-connected, the way _torch_fill_holes floods them. Every hole
pixel starts with its own index; each pass takes the largest index in
its 3x3 neighbourhood, then the index held by the pixel that index names
(both stay inside the same hole), until every hole holds one index.
its cross-shaped neighbourhood, then the index held by the pixel that
index names (both stay inside the same hole), until every hole holds one index.
"""
height, width = holes.shape
inside = holes.reshape(-1)
@@ -140,8 +141,9 @@ def _torch_hole_areas(holes: torch.Tensor) -> torch.Tensor:
index = torch.arange(1, height * width + 1, dtype=torch.float64, device=holes.device)
labels = torch.where(inside, index, zero)
while True:
spread = functional.max_pool2d(
labels.view(1, 1, height, width), kernel_size=3, stride=1, padding=1
spread = torch.maximum(
functional.max_pool2d(labels.view(1, 1, height, width), (3, 1), 1, (1, 0)),
functional.max_pool2d(labels.view(1, 1, height, width), (1, 3), 1, (0, 1)),
).reshape(-1)
spread = torch.where(inside, spread, zero)
spread = torch.where(inside, spread[(spread.long() - 1).clamp(min=0)], zero)
@@ -188,7 +190,7 @@ def fill_mask_holes(mask: torch.Tensor, max_hole_size: float = 0.0) -> torch.Ten
).to(mask.device)
else:
filled = _torch_fill_holes(solid)
return torch.maximum(mask, filled.to(mask.dtype))
return torch.where(filled & ~solid, 1.0, mask)
def blur_mask(mask: torch.Tensor, sigma: float) -> torch.Tensor:
+3 -1
View File
@@ -220,9 +220,11 @@ def _tell(node_id, note: dict, end: bool) -> bool:
if server is None or node_id is None:
return False
try:
read_context = getattr(sys.modules.get("comfy_execution.utils"), "get_executing_context", lambda: None)
prompt_id = getattr(read_context(), "prompt_id", None)
server.send_sync(
LOOK_EVENT,
{"node": str(node_id), "note": note, "end": bool(end)},
{"node": str(node_id), "note": note, "end": bool(end), "prompt_id": prompt_id},
getattr(server, "client_id", None),
)
except Exception: # noqa: BLE001
+1
View File
@@ -53,6 +53,7 @@ CORE_NODES = {
INTERNALS = [
("comfy.utils", "PROGRESS_BAR_HOOK", "Mask by Touch holds progress reports back while it looks at a picture"),
("comfy_execution.progress", "get_progress_state", "Mask by Touch holds progress reports back while it looks at a picture"),
("comfy_execution.utils", "get_executing_context", "Mask by Touch identifies the run sending an interim picture"),
("comfy.model_management", "load_model_gpu", "Mask by Touch asks SAM 3 the quick way"),
("comfy.model_management", "current_loaded_models", "Mask by Touch asks SAM 3 the quick way"),
("comfy.model_management", "intermediate_dtype", "the pack's tensors take ComfyUI's working type"),
+12
View File
@@ -191,6 +191,18 @@ class AskTests(ModelCase):
subject_matte(self.frame, OFF, key="two")
self.assertEqual(len(Loader.calls), 2)
def test_replacing_a_model_invalidates_the_same_pictures_subject(self):
Remover.answer = torch.full((1, 40, 60), 0.25)
first = subject_matte(self.frame, OFF, key="one")
self.file(PREFERRED, "replacement weights, longer")
os.utime(self.folder / PREFERRED, (1, 1))
Remover.answer = torch.full((1, 40, 60), 0.9)
second = subject_matte(self.frame, OFF, key="one")
self.assertEqual(len(Loader.calls), 2)
self.assertEqual(len(Remover.calls), 2)
self.assertLess(float(first.mean()), 0.3)
self.assertGreater(float(second.mean()), 0.8)
def test_an_older_comfyui_gets_a_plain_message(self):
self.nodes.NODE_CLASS_MAPPINGS = {"LoadBackgroundRemovalModel": Loader}
with self.assertRaises(RuntimeError) as raised:
+16 -2
View File
@@ -937,7 +937,7 @@ class RoundTests(TouchCase):
self.addCleanup(sys.modules.pop, "server", None)
sys.modules["server"] = server
self.assertIs(node_module._tell(5, {"key": "k"}, True), True)
self.assertEqual(sent, [("ausboss.mask_by_touch.look", {"node": "5", "note": {"key": "k"}, "end": True}, "a page")], "to the page that asked")
self.assertEqual(sent, [("ausboss.mask_by_touch.look", {"node": "5", "note": {"key": "k"}, "end": True, "prompt_id": None}, "a page")], "to the page that asked")
self.assertIs(node_module._tell(None, {}, True), False, "no id: nobody to tell")
server.PromptServer = types.SimpleNamespace(instance=None)
self.assertIs(node_module._tell(5, {}, True), False, "no page to tell")
@@ -945,6 +945,20 @@ class RoundTests(TouchCase):
self.assertIs(node_module._tell(5, {}, True), False, "outside ComfyUI")
self.assertEqual(len(sent), 1)
def test_interim_notes_carry_the_executing_prompt_id(self):
sent = []
context = types.ModuleType("comfy_execution.utils")
context.get_executing_context = lambda: types.SimpleNamespace(prompt_id="tab-a-prompt")
prior = sys.modules.get("comfy_execution.utils")
self.addCleanup(lambda: sys.modules.__setitem__("comfy_execution.utils", prior) if prior else sys.modules.pop("comfy_execution.utils", None))
sys.modules["comfy_execution.utils"] = context
server = types.ModuleType("server")
server.PromptServer = types.SimpleNamespace(instance=types.SimpleNamespace(send_sync=lambda *args: sent.append(args)))
self.addCleanup(sys.modules.pop, "server", None)
sys.modules["server"] = server
self.assertTrue(node_module._tell(5, {}, False))
self.assertEqual(sent[0][1]["prompt_id"], "tab-a-prompt")
def test_a_first_look_stopped_half_way_is_gone_on_with(self):
class Stopped(BaseException):
pass
@@ -1703,7 +1717,7 @@ class TellTests(TouchCase):
self.assertEqual(asked_at, list(range(4, total + 1, 3)) + [total + 1], "after every third half-second question, and at the end")
for event, data, sid in self.sent:
self.assertEqual((event, sid), ("ausboss.mask_by_touch.look", "the page that asked"))
self.assertEqual(sorted(data), ["end", "node", "note"])
self.assertEqual(sorted(data), ["end", "node", "note", "prompt_id"])
self.assertEqual(data["node"], "7", "the node's id, as text")
json.dumps(data) # it has to travel as JSON
self.assertEqual([data["end"] for _, data, _ in self.sent], [False] * (len(self.sent) - 1) + [True])
+27
View File
@@ -63,6 +63,33 @@ class GrowShrinkTests(unittest.TestCase):
class FillHolesTests(unittest.TestCase):
def test_fill_preserves_existing_soft_alpha(self):
from nodes._mask_by_touch_helpers import finish_mask
mask = torch.full((1, 20, 20), 0.75)
for limit in (0.0, 1.0):
self.assertTrue(torch.equal(fill_mask_holes(mask, limit), mask))
self.assertTrue(torch.equal(finish_mask(mask, fill_holes=True), mask))
mask[:, 9:11, 9:11] = 0.2
filled = fill_mask_holes(mask)
self.assertTrue(torch.all(filled[:, 9:11, 9:11] == 1.0))
self.assertEqual(float(filled[0, 0, 0]), 0.75)
def test_fallback_uses_the_same_diagonal_connectivity(self):
mask = torch.ones(1, 20, 20)
mask[0, 0, 0] = mask[0, 1, 1] = 0
# Two diagonally touching enclosed holes are separate components.
mask[0, 8, 8:11] = 0
mask[0, 9, 11:14] = 0
for limit in (0.0, 1.0):
expected = fill_mask_holes(mask, limit)
with mock.patch.object(mask_helpers, "_scipy_fill_holes", None):
actual = fill_mask_holes(mask, limit)
self.assertTrue(torch.equal(actual, expected))
self.assertEqual(float(actual[0, 1, 1]), 1.0)
self.assertEqual(float(actual[0, 8, 8]), 1.0)
self.assertEqual(float(actual[0, 0, 0]), 0.0)
def test_enclosed_hole_is_filled_but_outside_stays_empty(self):
filled = fill_mask_holes(donut_mask())
self.assertTrue(torch.all(filled[:, 6:10, 6:10] == 1.0))
+116
View File
@@ -0,0 +1,116 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import vm from "node:vm";
import { outputRecordQuery } from "../js/shared/input_preview.mjs";
const strip = (text) => text.replace(/^import\s+[\s\S]*?\sfrom\s+["'][^"']+["'];\s*/gm, "").replace(/^export /gm, "");
function entry() {
const events = new Map();
const timers = new Map();
let now = 1000, next = 0, queue = { queue_pending: [], queue_running: [] };
const checked = [];
const context = vm.createContext({
console, Map, Set, Promise, performance: { now: () => now },
setTimeout(fn, delay) {
const id = ++next;
timers.set(id, { fn: () => { timers.delete(id); return fn(); }, delay });
return id;
},
clearTimeout(id) { timers.delete(id); },
api: { addEventListener: (name, fn) => events.set(name, fn), fetchApi: async () => ({ json: async () => queue }) },
app: { graph: {}, registerExtension() {} },
executionIdOf: (_graph, node) => String(node.id),
isForeignRun(id) { checked.push(id); return id === "foreign"; },
});
vm.runInContext(strip(readFileSync(new URL("../js/mask_by_touch/index.js", import.meta.url), "utf8")), context);
vm.runInContext(`
globalThis.taken = [];
globalThis.face = {alive:true,inFlight:true,askedAt:0,node:{id:7},settle(){this.inFlight=false;},takeNote(note){taken.push(note.key);}};
faces.add(face);
`, context);
return { context, events, timers, checked, advance: (value) => { now = value; }, queue: (value) => { queue = value; } };
}
test("interim notes check their prompt's workflow before delivery", () => {
const { context, events, checked } = entry();
const look = events.get("ausboss.mask_by_touch.look");
look({ detail: { node: "7", prompt_id: "foreign", note: { key: "wrong tab" } } });
assert.equal(context.taken.length, 0);
look({ detail: { node: "7", prompt_id: "current", note: { key: "this tab" } } });
assert.deepEqual([...context.taken], ["this tab"]);
assert.deepEqual(checked, ["foreign", "current"]);
});
test("an early empty status rechecks a deleted request without another event", async () => {
const { context, events, timers, advance } = entry();
events.get("status")({ detail: { exec_info: { queue_remaining: 0 } } });
assert.equal(context.face.inFlight, true);
assert.equal(timers.size, 1);
const { fn, delay } = [...timers.values()][0];
assert.ok(delay > 0 && delay <= 501);
advance(1600);
await fn();
assert.equal(context.face.inFlight, false);
});
test("the delayed empty check leaves a running request waiting", async () => {
const { context, events, timers, advance, queue } = entry();
events.get("status")({ detail: { exec_info: { queue_remaining: 0 } } });
assert.equal(timers.size, 1);
queue({ queue_pending: [], queue_running: [[0, "current"]] });
advance(1600);
await [...timers.values()][0].fn();
assert.equal(context.face.inFlight, true);
});
test("a newer queue status wins over a delayed empty response", async () => {
const { context, events, timers, advance, queue } = entry();
let finish;
queue(new Promise((resolve) => { finish = resolve; }));
const status = events.get("status");
status({ detail: { exec_info: { queue_remaining: 0 } } });
advance(1600);
const checking = [...timers.values()][0].fn();
status({ detail: { exec_info: { queue_remaining: 1 } } });
finish({ queue_pending: [], queue_running: [] });
await checking;
assert.equal(context.face.inFlight, true);
assert.equal(timers.size, 0);
});
test("a failed queue response never cancels a waiting request", async () => {
const { context, events, timers, advance, queue } = entry();
events.get("status")({ detail: { exec_info: { queue_remaining: 0 } } });
queue({ error: "Unavailable" });
advance(1600);
await [...timers.values()][0].fn();
assert.equal(context.face.inFlight, true);
});
test("a count-only result cannot make an obsolete preview current", () => {
let extension;
const context = vm.createContext({
console, Map, Set, WeakMap, Promise, queueMicrotask() {}, setTimeout: () => 1, clearTimeout() {},
api: { addEventListener() {}, apiURL: (path) => path },
app: { graph: {}, nodeOutputs: {}, registerExtension(value) { extension = value; } },
chainCallback(target, name, fn) {
const prior = target[name];
target[name] = function (...args) { const result = prior?.apply(this, args); fn.apply(this, args); return result; };
},
isForeignRun: () => false, hideInputsInDef() {}, outputLocatorId: () => "7", outputRecordQuery,
});
vm.runInContext(strip(readFileSync(new URL("../js/input_preview/index.js", import.meta.url), "utf8")), context);
vm.runInContext("scheduleRefresh = () => {}; setNote = () => {}; setReadout = () => {};", context);
class PreviewNode {}
extension.beforeRegisterNodeDef(PreviewNode, { name: "AUSBOSS_NODES_MaskByName" });
const node = new PreviewNode();
const state = node.__ausbossInputPreview = { node, alive: true, stale: true, resultGone: true };
node.onExecuted({ ausboss_mask_by_name: [{ found: 1, name: "new search", sure: [0.9] }] });
assert.equal(state.stale, true);
assert.equal(state.resultGone, true);
node.onExecuted({ images: [{ filename: "new.png", type: "temp" }] });
assert.equal(state.stale, false);
assert.equal(state.resultGone, false);
});
+205
View File
@@ -0,0 +1,205 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import vm from "node:vm";
import * as math from "../js/shared/touch_math.mjs";
import * as brush from "../js/shared/brush.mjs";
const source = readFileSync(new URL("../js/shared/touch_stage.mjs", import.meta.url), "utf8");
const strip = (text) => text.replace(/^import\s+[\s\S]*?\sfrom\s+["'][^"']+["'];\s*/gm, "").replace(/^export /gm, "");
function stage() {
const timers = new Map();
let nextTimer = 0;
const context = vm.createContext({
...math, ...brush, console, URLSearchParams, Uint8Array, Uint32Array, Float32Array,
Map, Set, WeakMap, WeakSet, Promise,
performance: { now: () => 1000 },
setTimeout(fn, delay) { const id = ++nextTimer; timers.set(id, { fn, delay }); return id; },
clearTimeout(id) { timers.delete(id); },
requestAnimationFrame: () => 1, cancelAnimationFrame() {}, queueMicrotask,
api: { apiURL: (path) => path }, notifyAusbossChange() {},
chainCallback(target, name, fn) {
const prior = target[name];
target[name] = function (...args) { const result = prior?.apply(this, args); fn.apply(this, args); return result; };
},
});
vm.runInContext(strip(source), context);
const run = (code) => vm.runInContext(code, context);
return { context, timers, run };
}
test("late piece and picture downloads cannot replace a newer note", async () => {
const { context, run } = stage();
const downloads = new Map();
context.downloads = downloads;
run(`
loadImage = url => new Promise(resolve => downloads.set(new URLSearchParams(url.split('?')[1]).get('filename'), resolve));
readPixels = (_image, w, h) => new Uint8Array(w*h*4).fill(255);
syncState = () => {};
show = (_face, entry) => { globalThis.shown = entry.pieces.list[0].name; };
const face = {alive:true,flying:[],turn:0,sourceEpoch:0,node:{widgets:[]}};
const base = {key:'4x4-picture',width:4,height:4,scale:1,stamp:''};
globalThis.entry = pictureFor(base, face);
const note = (name, more) => ({...base,picture:{filename:name+'.jpg'},pieces:{list:[{x:1,y:1,kind:'name',name,sure:1}],planes:[{filename:name+'.png'}],more}});
globalThis.oldJob = takeNote(face, note('old', true));
globalThis.newJob = takeNote(face, note('new', false));
`);
downloads.get("new.jpg")({});
downloads.get("new.png")({});
await context.newJob;
downloads.get("old.jpg")({});
downloads.get("old.png")({});
await context.oldJob;
assert.equal(context.shown, "new");
assert.equal(context.entry.pieces.list[0].name, "new");
assert.equal(context.entry.file, "new.jpg");
assert.equal(context.entry.scoped, true);
});
test("selection, area and fine caches belong to each face", () => {
const { run } = stage();
run(`
const note = {key:'same-picture',width:2,height:2,scale:1};
const first = pictureFor(note, {});
const second = pictureFor(note, {});
const text = encodeTouches(first.key,[{t:'subject',op:1}]);
const state = {pic:first,items:decodeTouches(text).items,stamp:stampOf(text),stamps:prefixStamps(first.key,decodeTouches(text).items)};
remember(first,state.stamp,Uint8Array.from([1,0,0,0]));
keepArea(first,'subject',Uint8Array.from([1,0,0,0]));
first.fine = {made:'first model'};
remember(second,state.stamp,Uint8Array.from([0,0,0,1]));
keepArea(second,'subject',Uint8Array.from([0,0,0,1]));
second.fine = {made:'second model'};
resolve(state);
globalThis.result = {raw:Array.from(state.raw),area:Array.from(first.areas.get('subject')),fine:first.fine.made};
`);
const result = JSON.parse(run("JSON.stringify(result)"));
assert.deepEqual(result, { raw: [1, 0, 0, 0], area: [1, 0, 0, 0], fine: "first model" });
});
test("late selection and fine downloads cannot overwrite the current stamp", async () => {
const { context, run } = stage();
const downloads = context.downloads = new Map();
run(`
loadImage = url => new Promise(resolve => downloads.set(new URLSearchParams(url.split('?')[1]).get('filename'), resolve));
readPixels = (image, w, h) => new Uint8Array(w*h*4).fill(image.value);
syncState = () => {}; show = () => {};
const face = {alive:true,flying:[],turn:0,sourceEpoch:0,node:{widgets:[]}};
const base = {key:'2x2-picture',width:2,height:2,scale:1,stamp:'same',picture:{filename:'picture.jpg'}};
globalThis.entry = pictureFor(base,face);
entry.image = {}; entry.file = 'picture.jpg';
const note = name => ({...base,selection:{filename:name+'-selection.png'},fine:{file:{filename:name+'-fine.png'},made:name}});
globalThis.oldJob = takeNote(face,note('old'));
globalThis.newJob = takeNote(face,note('new'));
`);
downloads.get("new-selection.png")({ value: 0 });
downloads.get("new-fine.png")({ value: 0 });
await context.newJob;
downloads.get("old-selection.png")({ value: 255 });
downloads.get("old-fine.png")({ value: 255 });
await context.oldJob;
assert.deepEqual([...context.entry.states.get("same")], [0, 0, 0, 0]);
assert.equal(context.entry.fine.made, "new");
});
test("restored picks never schedule fine processing", () => {
const { context, timers, run } = stage();
run(`
adopt = () => false; readItems = () => {}; resolve = () => {};
syncState = () => {}; refreshHover = () => {}; draw = () => {}; drawInk = () => {};
settings = () => ({fine:'model.safetensors'}); fineOf = () => null;
globalThis.requested = [];
requestRun = (_face, ask) => requested.push(ask);
const pic = {key:'picture',piecesTag:''};
const face = {alive:true,pic,node:{},items:[{t:'paint',op:1,r:10,pts:[[1,1]]}],exact:true,stamp:'same',shownPieces:'',pending:[],inFlight:false};
show(face,pic,{stamp:'same'},null,{restored:true});
`);
for (const { fn } of [...timers.values()]) fn();
assert.deepEqual([...context.requested], []);
});
test("source subscriptions release removed faces while the loader survives", () => {
const { context, run } = stage();
run(`
globalThis.reads = 0;
globalThis.priorCalls = 0;
const loader = {widgets:[{name:'image',value:'picture.png',callback(){priorCalls++;}}]};
ownLoaderNode = () => loader;
for (let n=0;n<100;n++) {
const face = {node:{},watchedSources:new WeakSet(),forgetSources:[],host:{},get alive(){reads++;return false;}};
followOwnLoader(face);
for (const forget of face.forgetSources) forget();
}
loader.widgets[0].callback();
`);
assert.equal(context.reads, 0);
assert.equal(context.priorCalls, 1);
});
test("plain Load Image subscriptions are removable too", async () => {
const { context, run } = stage();
run(`
globalThis.reads = 0;
const widget = {};
loaderSource = () => null; loaderWidget = () => widget;
for (let n=0;n<100;n++) {
const face = {node:{},sourceUrl:'',watchedSources:new WeakSet(),forgetSources:[],get alive(){reads++;return false;}};
syncSource(face);
for (const forget of face.forgetSources) forget();
}
widget.callback();
`);
await Promise.resolve();
assert.equal(context.reads, 0);
});
test("destroy cancels the pending click and releases source subscriptions", () => {
const { context, timers, run } = stage();
run(`
globalThis.forgotten = 0; globalThis.aborted = false;
globalThis.face = {alive:true,pendingTimer:setTimeout(()=>{},500),forgetSources:[()=>forgotten++]};
globalThis.abort = {abort(){aborted=true;}};
`);
const start = source.indexOf(" face.destroy = () => {");
const end = source.indexOf("\n };", start) + "\n };".length;
run(source.slice(start, end));
context.face.destroy();
assert.equal(context.face.alive, false);
assert.equal(timers.size, 0);
assert.equal(context.forgotten, 1);
assert.equal(context.aborted, true);
});
test("Clear is enabled for malformed stored touches and erases the text", () => {
const { context, run } = stage();
run(`
const toggle = {toggle(){}};
globalThis.face = {node:{},pic:null,items:[],pending:[],past:[],touches:{value:'{'},host:{},
undo:{},clear:{},toolSwitch:{set(){},root:{classList:toggle}},softField:{classList:toggle},softBox:{set(){}},
subjectButton:{},backgroundButton:{},eye:{classList:toggle},size:{set(){}},view:'tint'};
syncTools(face);
`);
assert.equal(context.face.clear.disabled, false);
run(`
dropPending = () => {}; syncReadout = () => {}; syncState = () => {}; refreshHover = () => {};
button = (_class, _text, _title, click) => ({click});
globalThis.signal = null;
`);
const start = source.indexOf(" face.clear = button(");
const end = source.indexOf(" }, signal);", start) + " }, signal);".length;
run(source.slice(start, end));
context.face.clear.click();
assert.equal(context.face.touches.value, "");
});
test("named pieces of one through four pixels can be added and removed", () => {
for (let size = 1; size <= 4; size++) {
const planes = [new Uint32Array(size).fill(1)];
const sizes = math.pieceSizes(planes, 1);
assert.equal(math.pieceAt(planes, 0, { sizes, inside: [0] }), 0);
assert.equal(math.pieceAt(planes, 0, { sizes, inside: [size], adding: false }), 0);
assert.equal(math.pieceAt(planes, 0, { sizes, inside: [size] }), -1);
assert.equal(math.pieceAt(planes, 0, { sizes, inside: [0], adding: false }), -1);
}
});