Compare commits

..
11 Commits
Author SHA1 Message Date
ausboss eba880cdee Merge pull request #326 from ausboss/feat/touch-feedback 2026-11-10 14:50:45 -06:00
ausboss b6a67a4c34 Mask by Touch: take away by name, big pictures, a quiet queue
Four things people ran into while picking a mask:

- Ctrl-click on a part of a picked thing could take the whole thing
  away. With only a sweater picked, a Ctrl-click on the face offered
  the person, which holds the sweater, and the pick was gone. From a
  spot that is not picked, a piece that holds a whole pick is no longer
  offered; the picture says nothing is picked there.
- A name could only be taken away while Erase was chosen. The name
  field now has a + / - button, and a name typed with a minus in front
  ("-face") is taken away whichever tool is chosen.
- SAM 3 answers at the size of the picture it is given, so a look at a
  3840 px picture took 53 s and an 8192 px one ran a 32 GB card out of
  memory. A picture with a long side over 2048 px is now asked about on
  a smaller copy, and the answer comes back at the picture's own size:
  31 s and 33 s. Pictures up to 2048 px are asked about as before.
- Each change on the loader in front of the node sent one more look and
  left the earlier ones waiting. The node's own waiting looks are now
  taken out of the queue before a new one is sent.

The button that pauses the look now says Pause or Look on, and an out
of memory stop says so in plain words.
2026-11-10 14:49:34 -06:00
ausboss 27525ffdd8 Merge pull request #325 from ausboss/fix/explain-known-errors
Say what to do for two run errors that come from outside the workflow
2026-11-09 16:21:15 -06:00
ausboss eb668c448c Merge pull request #324 from ausboss/fix/two-copies
Start ComfyUI when the pack is installed twice, an older copy included
2026-11-09 16:21:05 -06:00
ausboss 9b3cd7c769 Merge the two-copies fix so both changes share one changelog section 2026-11-09 16:19:53 -06:00
ausboss 13c303178b Known run errors: shorter messages 2026-11-09 16:03:33 -06:00
ausboss f945feabb6 Say what to do for two run errors that come from outside the workflow
A MiniMax H3 run that keeps a clip's sound stops with KeyError: 'latent'
when the Muse MiniMax Director pack is installed, and an older ComfyUI
stops on a w6a8 model file. Neither message tells a person what to do,
so the run check now shows one toast for each with the cause and the fix.
2026-11-09 16:00:23 -06:00
ausboss 24ce4ffb06 README: install the pack one way, not both 2026-11-09 15:51:11 -06:00
ausboss 980ed5db8b One-copy check: only look in ComfyUI's custom_nodes folders 2026-11-09 15:27:28 -06:00
ausboss bf59887643 One-copy check: look beside the folder ComfyUI loaded, not its link target 2026-11-09 15:23:12 -06:00
ausboss 60be0ee865 Start ComfyUI when the pack is installed twice
A Manager install (custom_nodes/ausboss-nodes) beside a git clone
(custom_nodes/ComfyUI-AusBoss) added every route twice and ComfyUI
stopped while starting. Each copy now looks for the others, one loads
and the rest step aside and name the folder to delete.
2026-11-09 15:22:28 -06:00
17 changed files with 984 additions and 223 deletions
+37 -4
View File
@@ -4,10 +4,43 @@ All notable changes to ComfyUI-AusBoss are documented here.
## Unreleased
- **ComfyUI starts when the pack is installed twice.** Manager installs it in
`custom_nodes/ausboss-nodes` and a git clone in `custom_nodes/ComfyUI-AusBoss`.
With both there, ComfyUI used to stop at startup. Now the second copy loads
nothing, and the console names both folders so you can delete one.
- **Mask by Touch: type a name to take it out of the mask.** The name field
under the picture now has a sign in front. Click it to switch between **+**
(add by name) and **−** (take away by name), then type "face" or "hair" and
press Enter. Typing "-face" takes it away whatever the sign says. Before,
a typed name was only taken away while Erase was chosen, which nothing on
the node said.
- **Mask by Touch: Ctrl-click only takes away what you point at.** With a
sweater picked, a Ctrl-click on the face offered the whole person, and the
sweater went with it. Now a spot where nothing is picked says so and
changes nothing.
- **Mask by Touch works on big pictures.** A look at a 3840 pixel picture took
twice as long as at 2048, and at 8192 pixels it ran a 32 GB graphics card
out of memory. The node now asks SAM 3 about a copy of at most 2048 pixels
and brings the answer back to full size. Pictures up to 2048 pixels come
out exactly as before. On bigger ones a mask's edge can sit a pixel or two
differently.
- **Mask by Touch no longer fills the queue.** With Image Crop + Rotate + Pad
or Load Image + Pad wired in, every change to the loader (each step of its
megapixels, for one) queued one more look at a picture that was already
replaced. Now only the newest look waits. The button that pauses the
looking also says **Pause** and **Look on**.
- **Two errors from outside a workflow now say what to do.** When a MiniMax H3
run stops with `KeyError: 'latent'` because the Muse MiniMax Director pack is
installed, a message names that pack and says to turn it off. When an older
ComfyUI cannot read a `w6a8` model file, a message says to update ComfyUI.
ComfyUI's own error report stays as it is.
- **ComfyUI starts when the pack is installed twice.** A Manager install
(`custom_nodes/ausboss-nodes`) beside a git clone
(`custom_nodes/ComfyUI-AusBoss`) stopped ComfyUI while it started. Now one
copy loads and the other steps aside, and the console says which folder to
delete. A copy from 2.23.0 or older is the one that loads when it sits
beside a newer one, so delete the older folder.
## 2.23.0 - 2026-11-09
+33 -34
View File
@@ -7,26 +7,12 @@ name to NODE_MODULES below — nothing else to wire up.
A module that fails to import is skipped with a console warning instead
of taking the whole pack down, so one broken node never hides the rest.
Only one copy of the pack loads. People install it from ComfyUI-Manager
(custom_nodes/ausboss-nodes) and from GitHub (custom_nodes/ComfyUI-AusBoss),
and two copies used to register the same server routes, which stopped
ComfyUI at startup. A second copy now loads nothing and says which folder
to delete.
"""
import importlib
import os
import sys
import tomllib
import traceback
import types
_HERE = os.path.dirname(os.path.abspath(__file__))
_FIRST_COPY = sys.modules.setdefault(
"_ausboss_nodes_first_copy", types.SimpleNamespace(folder=_HERE)
)
_IS_SECOND_COPY = _FIRST_COPY.folder != _HERE
# One entry per file in nodes/, kept alphabetical. The order has no
# functional effect.
@@ -77,8 +63,18 @@ NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
_failed_modules = []
# Two copies of the pack (a Manager install beside a git clone) add every
# route twice and ComfyUI stops while starting. Only one copy loads.
_other_copy = None
try:
from .nodes._single_copy import other_copy_that_loads
_other_copy = other_copy_that_loads(os.path.dirname(__file__))
except Exception:
traceback.print_exc()
for _module_name in NODE_MODULES:
if _IS_SECOND_COPY:
if _other_copy:
break
try:
_module = importlib.import_module(f".nodes.{_module_name}", __name__)
@@ -92,15 +88,15 @@ for _module_name in NODE_MODULES:
# Folder ComfyUI serves to the browser; every .js file in it is loaded as a
# frontend extension. Shared modules use .mjs so they are import-only.
WEB_DIRECTORY = None if _IS_SECOND_COPY else "./js"
WEB_DIRECTORY = None if _other_copy else "./js"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
def _read_version(folder=None):
def _read_version():
"""Version straight from pyproject.toml so it is never duplicated."""
try:
pyproject = os.path.join(folder or os.path.dirname(__file__), "pyproject.toml")
pyproject = os.path.join(os.path.dirname(__file__), "pyproject.toml")
with open(pyproject, "rb") as f:
version = tomllib.load(f).get("project", {}).get("version")
return version if isinstance(version, str) and version.strip() else "unknown"
@@ -118,17 +114,6 @@ def _print_banner():
bar = f"{teal}{'-' * 70}{reset}"
print(bar)
if _IS_SECOND_COPY:
def where(folder):
text = f"{os.path.basename(folder)} (v{_read_version(folder)})"
return text.encode("ascii", "replace").decode("ascii")
print(f" {red}AusBoss is installed twice, so this copy is not loaded.{reset}")
print(f" In use: custom_nodes/{where(_FIRST_COPY.folder)}")
print(f" Skipped: custom_nodes/{where(_HERE)}")
print(" Delete one of the two folders and restart ComfyUI.")
print(bar)
return
print(
f" {teal}AusBoss{reset} v{_read_version()} | "
f"{len(NODE_CLASS_MAPPINGS)} nodes loaded"
@@ -139,12 +124,26 @@ def _print_banner():
print(bar)
try:
if not _IS_SECOND_COPY:
def _print_second_copy():
# ASCII only, as in the banner.
red = "\033[0;31m"
reset = "\033[0m"
own = os.path.basename(os.path.dirname(os.path.realpath(__file__)))
other = os.path.basename(_other_copy["folder"])
print(f"{red}AusBoss: this pack is installed twice.{reset}")
print(f" Using: custom_nodes/{other} (v{_other_copy['version']})")
print(f" Skipping: custom_nodes/{own} (v{_read_version()})")
print(" Delete one of the two folders, then restart ComfyUI.")
if _other_copy:
_print_second_copy()
else:
try:
from .nodes._frontend_cache import register_frontend_cache_guard
register_frontend_cache_guard()
except Exception:
traceback.print_exc()
except Exception:
traceback.print_exc()
_print_banner()
_print_banner()
+24 -5
View File
@@ -19,8 +19,8 @@ What is lit in teal is what the mask will be. When it is right, press Run
for the whole workflow.
The first time the pointer rests on a picture, the node looks at it. A
small button under the picture shows how far along it is, **❚❚ 40/104**,
filling up as it goes. The first things light up after a few seconds, and
button under the picture shows how far along it is, **❚❚ Pause · looking
40/104**, filling up as it goes. The first things light up after a few seconds, and
it has seen the whole picture after about 20.
You do not have to wait for that. Whatever is lit under the pointer can be
@@ -28,7 +28,7 @@ picked: a click takes it at once, also while the node is still looking.
Click that button to pause the looking when what you need is already lit:
what is lit stays, and you go on picking and painting. It turns into
**▶**; click it to look on from where it stopped. A click on a spot where
**▶ Look on**; click it to look on from where it stopped. A click on a spot where
nothing is lit yet looks on as well.
A click on a spot where nothing is lit yet is not lost: a dot marks it, the
@@ -50,6 +50,9 @@ at that spot.
A stroke across two things, like both shoes, takes both.
- Did the thing not light up at all? Type its name into the field under the
picture, like "screen" or "earring", and press Enter.
- Is something in the mask that should not be, like the face or the hair of
a person you picked? Click the **+** in front of that field so it shows
**−**, type "face" and press Enter. Or hold Ctrl and click the face.
- When nothing fits, switch to **Paint** and paint the area yourself.
## The whole subject, or the background
@@ -59,6 +62,10 @@ click: the person, the animal, the product. **Background** picks everything
else. They are picks like any other: click more things to add them, or
Ctrl-click a thing to take it away again.
Ctrl-click takes away the thing under the pointer, where it is in the mask.
On a spot where nothing is picked it changes nothing, and the picture says
so.
Both need ComfyUI's background-removal model, see "The model for Fine
edges" below.
@@ -115,8 +122,11 @@ or an area you painted.
it.
- **all 3** on a chip takes every thing of that name instead of the one you
clicked: all five birds, both shoes.
- The field at the end, **+ type a name**, picks every thing of the name
you type. With Erase chosen it takes them away.
- The field at the end picks every thing of the name you type. The sign in
front of it says what happens to the name: **+** adds it, **−** takes it
away. Click the sign to change it. A name typed with a minus in front,
like "-face", is taken away whatever the sign shows. With Erase chosen the
sign starts on **−**.
## The tools on the node
@@ -273,6 +283,15 @@ then.
### How SAM 3 is asked
- A picture whose long side is over 2048 pixels is asked about on a copy
with its long side at 2048. SAM 3 reads every picture at 1008 pixels
anyway, and returns each answer at the size it was given, on the graphics
card: at 8192 pixels one word with many matches needed over 27 GB. The
answer is brought back to the picture's own size, so the mask, the cut-out
and your touches stay in pixels of the picture. Paint is never scaled.
Measured on a 3840 pixel picture, five words: the new masks share 95 to
99.6 % of their area with the old ones, the edge sits a median 0 to 2
pixels differently, and a look takes 31 seconds in place of 53.
- The search is ComfyUI's own SAM 3: SAM3 Detect, asked about points, or
about a word that CLIP Text Encode has read. Both use ComfyUI's own
settings (threshold 0.5, two edge passes). The node needs ComfyUI 0.20 or
+19
View File
@@ -47,6 +47,24 @@ async function stopLook(node) {
return Boolean(running || waiting.length);
}
// Take this node's own runs that still wait out of the queue: the looks and
// touches its picture asked for (they carry an `ask`), never a run of the
// workflow. Says how many went. The one that is running is left alone.
async function dropWaiting(node) {
const id = executionIdOf(app.rootGraph ?? app.graph, node);
if (!id) return 0;
const queue = await (await api.fetchApi("/queue")).json();
const ours = (entry) => {
const step = entry?.[2]?.[id];
return step?.class_type === NODE_CLASS && String(step.inputs?.ask ?? "") !== "";
};
const waiting = (queue?.queue_pending ?? []).filter(ours).map((entry) => entry[1]);
if (waiting.length) {
await api.fetchApi("/queue", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ delete: waiting }) });
}
return waiting.length;
}
// The picture goes above the settings: the stage widget sits ahead of the
// node's card, whichever of the two was built first.
function stageFirst(node) {
@@ -92,6 +110,7 @@ function build(node) {
const face = mountTouchStage(node, {
lookOnButton: () => lookOnButton,
stopLook: () => stopLook(node).catch(() => false),
dropWaiting: () => dropWaiting(node).catch(() => 0),
// A workflow is being opened: nothing is run on the person's behalf.
loading: () => Boolean(app.configuringGraph),
// Bypassed or muted, or inside a box that is: nothing is run for it.
+11
View File
@@ -26,6 +26,7 @@ import { nodeByExecutionId } from "../shared/graph_ids.mjs";
import {
collapseRepeatedErrors,
emptySourceMessage,
explainRunError,
findEmptySources,
nodeLacksSource,
} from "../shared/run_check.mjs";
@@ -174,10 +175,20 @@ function collapseAnswers() {
api.queuePrompt = collapsing;
}
// A run that stops on an error the pack knows the cause of gets one toast
// that says what to do. ComfyUI's own error report is left as it is.
function explainKnownErrors() {
api.addEventListener("execution_error", ({ detail }) => {
const known = explainRunError(detail);
if (known) showToast({ severity: "warn", summary: known.summary, detail: known.detail, life: 30000 });
});
}
app.registerExtension({
name: "ausboss.run_check",
setup() {
guardRuns();
collapseAnswers();
explainKnownErrors();
},
});
+48
View File
@@ -160,3 +160,51 @@ export function collapseRepeatedErrors(nodeErrors) {
}
return nodeErrors;
}
// ------------------------------------------------------------ known run errors
//
// Some errors that stop a run come from outside the workflow and say nothing
// a person can act on ("KeyError: 'latent'"). Each entry here names one such
// error by what is in ComfyUI's own report, and says in plain words what to
// do. An entry is added only after the cause was found for real.
const KNOWN_RUN_ERRORS = Object.freeze([
{
// Muse MiniMax Director replaces part of H3 for every workflow, and that
// part reads a picture from every guide. ComfyUI's own H3 nodes also make
// guides that hold sound alone, which is how a clip keeps its sound.
matches: ({ type, message, traceback }) =>
type === "KeyError" && message.includes("'latent'") && traceback.includes("muse_minimax_hybrid_patch"),
summary: "Another node pack stopped this run",
detail:
"This error comes from the Muse MiniMax Director pack, not from this workflow. Turn that pack off in "
+ "Manager, restart ComfyUI and run again.",
},
{
// The w6a8 H3 files load from ComfyUI 0.38.0 on; older versions stop in
// the model loader on the name of the format.
matches: ({ message }) => message.includes("w6a8_int8"),
summary: "This model needs a newer ComfyUI",
detail:
"Your ComfyUI is too old to read this model file. Update ComfyUI to 0.38 or newer and run again.",
},
]);
/**
* Plain advice for a run error the pack knows the cause of, from the
* `execution_error` event's detail. `null` for every other error.
*/
export function explainRunError(detail) {
const report = {
type: String(detail?.exception_type ?? "").split(".").pop(),
message: String(detail?.exception_message ?? ""),
traceback: [].concat(detail?.traceback ?? []).join("\n"),
};
for (const known of KNOWN_RUN_ERRORS) {
try {
if (known.matches(report)) return { summary: known.summary, detail: known.detail };
} catch (_error) {
// A report in a shape this entry did not expect is simply not a match.
}
}
return null;
}
+48 -2
View File
@@ -74,6 +74,25 @@ export function loaderSignature(widgets) {
return JSON.stringify(settings);
}
// What the picture says when a run stopped at the node. ComfyUI's own text
// for a full graphics card is ten lines of numbers; here it is one plain
// line. Anything else is said as it came.
export const OUT_OF_MEMORY = "The graphics card ran out of memory, so the node could not look. Free some memory on it, then click again.";
export function stopMessage(message) {
const text = String(message ?? "");
return /out of memory|OutOfMemory/i.test(text) ? OUT_OF_MEMORY : text;
}
// A name as it is typed under the picture: "-face" (or the minus sign a
// phone types) takes the name away, "+face" adds it; without a sign it does
// what `takes` says. Returns { word, add }, the word cleaned.
export function typedName(text, takes = false) {
const typed = String(text ?? "").trim();
const sign = /^[-\u2212\u2013+]/.exec(typed)?.[0] ?? "";
const word = cleanWord(sign ? typed.slice(1) : typed);
return { word, add: sign ? sign === "+" : !takes };
}
// A word as the node sends it to SAM 3: commas, colons and brackets are the
// text reader's own syntax and go; spaces are collapsed.
export function cleanWord(word) {
@@ -1044,19 +1063,46 @@ export function strokePieces(planes, list, sizes, pixels, stroke, ranks, wholes
// picked area, and as untouched while almost none of it is.
const NEARLY = 0.03;
// Which pieces hold a whole pick: nearly all of one picked area (`areas`,
// on/off masks, one per chip) lies inside the piece. A person holds the
// sweater picked on them; hair that a picked jacket only laps over does not.
export const HOLDS = 0.9;
export function piecesHolding(planes, count, areas) {
const holds = new Uint8Array(count);
for (const area of areas ?? []) {
if (!area) continue;
let size = 0;
for (let index = 0; index < area.length; index += 1) if (area[index]) size += 1;
if (!size) continue;
const inside = piecesInside(planes, count, area);
for (let piece = 0; piece < count; piece += 1) if (inside[piece] >= HOLDS * size) holds[piece] = 1;
}
return holds;
}
// Which piece a pointer at pixel `index` lights up: the first piece there,
// in the order they are offered (`ranks`; without it, smallest first), that
// would still change something. Adding, that is a piece not yet (nearly)
// all picked, so clicking the same spot again goes on from the glasses to
// the face to the person. Taking away, it is a piece that has some of the
// picked area in it. -1 when there is none.
export function pieceAt(planes, index, { sizes, inside, adding = true, ranks = null } = {}) {
//
// Taking away from a spot that is not picked itself (`here` false) never
// means a thing that holds a whole pick somewhere else (`holds`, from
// piecesHolding): with a sweater picked, pointing at the face does not
// offer the person, which would take the sweater along.
export function pieceAt(planes, index, { sizes, inside, adding = true, ranks = null, here = true, holds = null } = {}) {
for (const piece of candidatesAt(planes, index, ranks)) {
const size = sizes?.[piece] ?? 0;
if (!size) continue;
const picked = inside?.[piece] ?? 0;
const slack = Math.max(4, size * NEARLY);
if (adding ? size - picked > slack : picked > slack) return piece;
if (adding) {
if (size - picked > slack) return piece;
continue;
}
if (picked <= slack || (!here && holds?.[piece])) continue;
return piece;
}
return -1;
}
+106 -19
View File
@@ -77,6 +77,7 @@ import {
pieceRanks,
pieceWholes,
pieceSizes,
piecesHolding,
piecesInside,
prefixStamps,
previewPixels,
@@ -85,9 +86,11 @@ import {
sharedPixels,
shrinkMask,
stampOf,
stopMessage,
strokePieces,
thinStroke,
touchFits,
typedName,
} from "./touch_math.mjs";
const CSS_ID = "ausboss-touch-css";
@@ -190,9 +193,17 @@ function ensureCss() {
.ausboss-touch-chip>button:hover{opacity:1;background:rgba(255,255,255,.14);}
.ausboss-touch-chip>button.more{padding:0 6px;background:rgba(0,0,0,.35);opacity:.95;}
.ausboss-touch-chip.open>button.more{background:rgba(0,0,0,.2);}
.ausboss-touch-name{box-sizing:border-box;flex:none;width:104px;height:20px;padding:0 9px;border:1px dashed rgba(0,180,170,.5);border-radius:10px;background:transparent;color:#dffcf9;font:600 10px/18px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;outline:none;}
.ausboss-touch-namebox{box-sizing:border-box;flex:none;display:inline-flex;align-items:stretch;height:20px;border:1px dashed rgba(0,180,170,.5);border-radius:10px;overflow:hidden;}
.ausboss-touch-namebox:focus-within{border-style:solid;border-color:${BRAND};background:rgba(0,0,0,.4);}
.ausboss-touch-namebox.away{border-color:rgba(${LIT_AWAY.join(",")},.6);}
.ausboss-touch-namebox.away:focus-within{border-color:rgb(${LIT_AWAY.join(",")});}
.ausboss-touch-sign{box-sizing:border-box;flex:none;width:22px;padding:0;border:0;border-right:1px solid rgba(0,180,170,.35);background:rgba(0,180,170,.16);color:#dffcf9;font:700 13px/18px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;cursor:pointer;}
.ausboss-touch-sign:hover{background:rgba(0,180,170,.34);color:#fff;}
.ausboss-touch-sign:disabled{opacity:.4;cursor:default;}
.ausboss-touch-namebox.away .ausboss-touch-sign{border-right-color:rgba(${LIT_AWAY.join(",")},.4);background:rgba(${LIT_AWAY.join(",")},.24);color:#ffe9e2;}
.ausboss-touch-namebox.away .ausboss-touch-sign:hover{background:rgba(${LIT_AWAY.join(",")},.42);}
.ausboss-touch-name{box-sizing:border-box;flex:none;width:118px;height:18px;padding:0 8px 0 6px;border:0;background:transparent;color:#dffcf9;font:600 10px/18px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;outline:none;}
.ausboss-touch-name::placeholder{color:#78908e;font-weight:500;}
.ausboss-touch-name:focus{border-style:solid;border-color:${BRAND};background:rgba(0,0,0,.4);}
.ausboss-touch-gap{flex:1 1 auto;min-width:0;}
.ausboss-touch-stage{position:relative;flex:1 1 auto;min-height:0;width:100%;overflow:hidden;border:1px solid rgba(0,180,170,.27);border-radius:6px;background:rgba(0,0,0,.28);}
[data-node-id] .ausboss-touch-stage{box-sizing:border-box;min-height:${STAGE_MIN}px;}
@@ -224,9 +235,12 @@ function ensureCss() {
.ausboss-touch.fining .ausboss-touch-fining{display:flex;}
.ausboss-touch-fining::before{content:"";flex:none;width:7px;height:7px;border-radius:50%;background:${BRAND};animation:ausboss-touch-pulse 1s ease-in-out infinite;}
@keyframes ausboss-touch-pulse{0%,100%{opacity:.25;}50%{opacity:1;}}
.ausboss-touch-btn.lookpill{display:inline-flex;align-items:center;gap:5px;height:${BAR_HEIGHT}px;padding:0 9px 0 8px;border:1px solid rgba(0,180,170,.6);border-radius:10px;letter-spacing:0;text-transform:none;font:600 10px/18px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#dffcf9;background:linear-gradient(90deg,rgba(0,180,170,.38) var(--done,0%),rgba(0,180,170,.08) var(--done,0%));}
.ausboss-touch-btn.lookpill{display:inline-flex;align-items:center;gap:5px;height:${BAR_HEIGHT}px;padding:0 9px 0 0;overflow:hidden;border:1px solid rgba(0,180,170,.8);border-radius:10px;letter-spacing:0;text-transform:none;font:600 10px/18px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#dffcf9;background:linear-gradient(90deg,rgba(0,180,170,.38) var(--done,0%),rgba(0,180,170,.08) var(--done,0%));}
.ausboss-touch-btn.lookpill:hover{border-color:${BRAND};color:#fff;background:linear-gradient(90deg,rgba(0,180,170,.5) var(--done,0%),rgba(0,180,170,.16) var(--done,0%));}
.ausboss-touch-btn.lookpill .icon{flex:none;font-size:8px;letter-spacing:-1px;opacity:.9;}
.ausboss-touch-btn.lookpill .key{flex:none;display:inline-flex;align-items:center;gap:4px;align-self:stretch;padding:0 7px 0 8px;background:${BRAND};color:#04201e;font-weight:700;}
.ausboss-touch-btn.lookpill:hover .key{background:#35d6cb;}
.ausboss-touch-btn.lookpill.paused .key{background:#9fb4b2;}
.ausboss-touch-btn.lookpill .icon{flex:none;font-size:8px;letter-spacing:-1px;}
.ausboss-touch-btn.lookpill.paused{border-color:rgba(120,144,142,.6);color:#c8dddd;background:linear-gradient(90deg,rgba(120,144,142,.3) var(--done,0%),rgba(120,144,142,.06) var(--done,0%));}
.ausboss-touch-btn.lookpill.paused .icon{letter-spacing:0;}
.ausboss-touch-btn.lookpill .count{opacity:.8;}
@@ -527,6 +541,7 @@ function resolve(face) {
face.raw = raw;
face.exact = exact;
face.chipAreas = null;
face.holds = null;
face.inside = pic.pieces ? piecesInside(pic.pieces.planes, pic.pieces.list.length, raw) : null;
face.hoverMask = null;
}
@@ -697,6 +712,11 @@ function requestRun(face, ask) {
return false;
}
const run = { ask, stamp: face.stamp, epoch: face.sourceEpoch, queued: false };
// Runs of this node that are at a picture it no longer has. The one that
// is running ends by itself once the next job waits; the ones that still
// wait in the queue would each run for nothing, one after the other, so
// they are taken out before this one is sent.
const stale = face.flying.filter((other) => other.queued && other.epoch !== run.epoch);
face.flying.push(run);
face.inFlight = true;
face.askedAt = performance.now();
@@ -707,7 +727,15 @@ function requestRun(face, ask) {
face.flying = face.flying.filter((other) => other !== run);
if (!face.flying.length) settle(face);
};
Promise.resolve(face.host.run(ask === "scope" ? scopeAsk(face) : ask)).then((queued) => {
const cleared = stale.length && face.host.dropWaiting
? Promise.resolve(face.host.dropWaiting()).then((gone) => {
// What was taken out never answers: the newest of the stale ones,
// because a run of this node goes to the front of the queue.
const out = new Set(stale.slice(Math.max(0, stale.length - (Number(gone) || 0))));
if (out.size) face.flying = face.flying.filter((other) => !out.has(other));
}, () => {})
: Promise.resolve();
cleared.then(() => face.host.run(ask === "scope" ? scopeAsk(face) : ask)).then((queued) => {
if (queued === false) {
failed();
return;
@@ -1258,11 +1286,23 @@ function takeAll(face, index) {
refreshHover(face);
}
// Whether a typed name is taken away: what the sign in front of the field
// was set to, or else what the tool does right now (Erase, or Ctrl held).
function nameTakes(face) {
return face.nameAway ?? face.away;
}
// A typed name, as every thing of that name. "-face" takes it away and
// "+face" adds it, whatever the sign says.
function addName(face, text) {
const word = cleanWord(text);
if (!word || !face.pic || face.held) return false;
const typed = typedName(text, nameTakes(face));
if (!typed.word || !face.pic || face.held) return false;
flushPending(face);
addItem(face, { t: "all", op: face.away ? 0 : 1, w: word });
if (!typed.add && !face.items.some((item) => item.op === 1)) {
setNote(face, "Nothing is picked yet. Pick something first, then take a name away from it.", { life: 3200 });
return false;
}
addItem(face, { t: "all", op: typed.add ? 1 : 0, w: typed.word });
return true;
}
@@ -1413,9 +1453,19 @@ function syncTools(face) {
face.lookButton.style.display = offer ? "" : "none";
}
if (face.nameField) {
face.nameField.disabled = !live || face.held > 0;
// A typed name is added, or taken away while the tool takes away.
face.nameField.placeholder = face.away ? "\u2212 type a name" : "+ type a name";
const off = !live || face.held > 0;
const takes = nameTakes(face);
face.nameField.disabled = off;
face.nameSign.disabled = off;
face.nameBox.classList.toggle("away", takes);
face.nameSign.textContent = takes ? "\u2212" : "+";
face.nameSign.title = takes
? "A name you type is taken away from the mask. Click to add by name instead."
: "A name you type is added to the mask. Click to take away by name instead.";
face.nameField.placeholder = takes ? "take away by name" : "add by name";
face.nameField.title = takes
? "Type what to take out of the mask, like \"face\" or \"hair\", and press Enter. Every thing of that name is taken away."
: "Type what to pick, like \"screen\" or \"earring\", and press Enter. Every thing of that name is taken. Start with a minus, like \"-face\", to take it away instead.";
}
if (face.newButton) face.newButton.disabled = !live || face.held > 0 || Boolean(face.fresh);
// The switch shows what a press does right now: with Ctrl held, Paint
@@ -1949,12 +1999,31 @@ function pointOf(face, event) {
return [clamp(x, 0, face.pic.width - 1), clamp(y, 0, face.pic.height - 1)];
}
// The pieces that hold a whole pick, worked out once per change to the
// picks and only when something is being taken away.
function holdsOf(face) {
const pic = face.pic;
if (!pic?.pieces || !face.raw) return null;
if (!face.holds || face.holds.length !== pic.pieces.list.length) {
const areas = chipGroups(face.items).filter((chip) => !chip.away).map((chip) => chipArea(face, chip));
face.holds = piecesHolding(pic.pieces.planes, pic.pieces.list.length, areas);
}
return face.holds;
}
function pieceUnder(face, point, adding) {
const pic = face.pic;
if (!pic?.pieces || !point) return -1;
const x = clamp(Math.floor(point[0] * pic.scale), 0, pic.pw - 1);
const y = clamp(Math.floor(point[1] * pic.scale), 0, pic.ph - 1);
return pieceAt(pic.pieces.planes, y * pic.pw + x, { sizes: pic.pieces.sizes, inside: face.inside, adding, ranks: pic.pieces.ranks });
const at = y * pic.pw + x;
if (adding) return pieceAt(pic.pieces.planes, at, { sizes: pic.pieces.sizes, inside: face.inside, ranks: pic.pieces.ranks });
// Taking away from a spot that is not picked: never a thing that holds a
// whole pick somewhere else (the person, for the sweater picked on them).
return pieceAt(pic.pieces.planes, at, {
sizes: pic.pieces.sizes, inside: face.inside, adding, ranks: pic.pieces.ranks,
here: face.raw?.[at] >= 0.5, holds: holdsOf(face),
});
}
// The pixel of the small copy under a point of the picture.
@@ -2324,7 +2393,8 @@ function syncLooking(face) {
pill.classList.toggle("paused", paused);
pill.style.setProperty("--done", `${total ? Math.round((done / total) * 100) : 0}%`);
face.lookIcon.textContent = paused ? "\u25b6" : "\u275a\u275a";
face.lookWord.textContent = paused ? "Paused" : "Looking";
face.lookVerb.textContent = paused ? "Look on" : "Pause";
face.lookWord.textContent = paused ? "paused" : "looking";
face.lookCount.textContent = total ? `${done}/${total}` : "\u2026";
const far = total ? ` ${done} of ${total}` : "";
pill.title = paused
@@ -2717,6 +2787,8 @@ function setSoft(face, soft) {
// Pick takes whole things; Paint and Erase are the brush.
function setTool(face, tool) {
// The sign in front of the name field follows the tool again.
face.nameAway = null;
if (!TOOLS.includes(tool)) return;
face.tool = tool;
face.snap = tool === "pick";
@@ -2773,16 +2845,21 @@ export function mountTouchStage(node, host) {
const bar = el("div", "ausboss-touch-bar");
const chips = el("div", "ausboss-touch-chips");
const chipList = el("span", "ausboss-touch-chiplist");
// A name typed here picks every thing of that name, or takes it away:
// the sign in front says which, and a press on it changes it.
const nameBox = el("span", "ausboss-touch-namebox");
const nameSign = el("button", "ausboss-touch-sign", "+");
nameSign.type = "button";
const nameField = el("input", "ausboss-touch-name");
nameField.type = "text";
nameField.placeholder = "+ type a name";
nameField.placeholder = "add by name";
nameField.maxLength = 60;
nameField.spellcheck = false;
nameField.title = "Type what to pick, like \"screen\" or \"earring\", and press Enter. Every thing of that name is taken.";
nameBox.append(nameSign, nameField);
const newButton = el("button", "ausboss-touch-new", "+ new");
newButton.type = "button";
newButton.title = "Start a new area: what you paint next gets a chip of its own, also where it touches another area.";
chips.append(chipList, newButton, nameField);
chips.append(chipList, newButton, nameBox);
root.append(tools, stage, bar, chips);
const frame = el("div", "ausboss-touch-frame");
@@ -2827,7 +2904,7 @@ export function mountTouchStage(node, host) {
const face = node.__ausbossTouch = {
node, host, root, stage, frame, img, tint, lit, ink, path, dots, ringBack, ring, coreRing, dab, brushLabel, tag, hint, noteEl, abort,
chips, chipList, newButton, nameField, chipsShown: null,
chips, chipList, newButton, nameField, nameBox, nameSign, nameAway: null, chipsShown: null,
flying: [],
pendingTimer: 0,
alive: true,
@@ -3031,10 +3108,15 @@ export function mountTouchStage(node, host) {
if (face.pic?.paused && !face.inFlight) resumeLook(face);
else pauseLook(face);
}, signal);
// The press it takes comes first, as a button of its own look: "Pause"
// while the node looks, "Look on" once it is paused.
face.lookIcon = el("span", "icon");
face.lookVerb = el("span", "verb");
face.lookWord = el("span", "word");
face.lookCount = el("span", "count");
face.lookPill.append(face.lookIcon, face.lookWord, face.lookCount);
const lookKey = el("span", "key");
lookKey.append(face.lookIcon, face.lookVerb);
face.lookPill.append(lookKey, face.lookWord, face.lookCount);
face.lookPill.style.display = "none";
// First in the line under the picture: what the node is doing with the
// picture, then what is picked; the buttons that pick come after.
@@ -3044,6 +3126,11 @@ export function mountTouchStage(node, host) {
for (const type of ["keydown", "keyup", "keypress", "pointerdown", "mousedown", "dblclick"]) {
chips.addEventListener(type, (event) => event.stopPropagation(), { signal });
}
nameSign.addEventListener("click", () => {
face.nameAway = !nameTakes(face);
syncTools(face);
nameField.focus();
}, { signal });
nameField.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
@@ -3096,7 +3183,7 @@ export function mountTouchStage(node, host) {
// The run stopped at this node with a message for the person.
face.stopped = (message) => {
settle(face);
setNote(face, String(message ?? ""));
setNote(face, stopMessage(message));
// It stays up until something else is said or the next result comes in.
face.stopNote = true;
};
+77
View File
@@ -41,6 +41,16 @@ RADIUS_MAX = 4096.0
# The face's own picture: the long side is at most this.
SMALL_SIDE = 1024
# SAM 3 is asked about a copy of a big picture: its long side is at most
# this. ComfyUI's SAM 3 reads every picture at 1008 pixels and returns each
# answer at the size of the picture it was given, on the graphics card. At
# 8192 pixels one word with sixteen matches needed over 27 GB there, and a
# look at 3840 took twice as long as at 2048, all for outlines the model
# never saw that finely. Twice what the model reads keeps its second pass
# (a close-up of each thing it found) as sharp as before on pictures up to
# this size, which come out exactly as they did.
WORK_SIDE = 2048
# How a find stroke is asked (find_points, choose_found). Measured on 27
# strokes over three pictures, each drawn 13 slightly different ways: four
# points alone returned the stroked thing in 87 % of 351 tries, two points
@@ -386,6 +396,73 @@ def read_ask(ask, height: int, width: int) -> tuple:
return "look", None
# ---------------------------------------------------------------------------
# The copy of a big picture that SAM 3 is asked about
# ---------------------------------------------------------------------------
class WorkView:
"""One picture as SAM 3 is asked about it.
A picture whose long side is over WORK_SIDE is asked about on a smaller
copy (`frame`, averaged down, so nothing shimmers), and what SAM 3
answers there is brought back to the picture's own size with `full`.
A smaller picture is its own copy: `same` is true and nothing changes.
Touches and pieces keep their places in pixels of the picture. `point`
gives a place on the copy, `full_point` a place on the picture that
`point` leads back to the same pixel of the copy.
"""
def __init__(self, frame: torch.Tensor):
self.height, self.width = int(frame.shape[1]), int(frame.shape[2])
long_side = max(self.height, self.width)
self.same = long_side <= WORK_SIDE
if self.same:
self.frame, self.work_height, self.work_width = frame, self.height, self.width
return
scale = WORK_SIDE / long_side
self.work_height = max(1, int(math.floor(self.height * scale + 0.5)))
self.work_width = max(1, int(math.floor(self.width * scale + 0.5)))
small = F.interpolate(
frame.detach().float().cpu().movedim(-1, 1), size=(self.work_height, self.work_width), mode="area",
)
self.frame = small.movedim(1, -1).clamp(0.0, 1.0).contiguous()
def point(self, point) -> tuple:
x = min(self.work_width - 1, max(0, int(point[0]) * self.work_width // self.width))
y = min(self.work_height - 1, max(0, int(point[1]) * self.work_height // self.height))
return (x, y)
def full_point(self, point) -> tuple:
x = min(self.width - 1, -(-int(point[0]) * self.width // self.work_width))
y = min(self.height - 1, -(-int(point[1]) * self.height // self.work_height))
return (x, y)
def points(self, points) -> tuple:
return tuple(self.point(point) for point in points)
def length(self, value: float) -> float:
"""A length of the picture (a brush's radius) on the copy."""
return float(value) * self.work_width / self.width
def full_box(self, box) -> list:
if self.same:
return list(box)
x0, y0, x1, y1 = (int(value) for value in box)
return [
x0 * self.width // self.work_width, y0 * self.height // self.work_height,
min(self.width, -(-x1 * self.width // self.work_width)), min(self.height, -(-y1 * self.height // self.work_height)),
]
def full(self, mask: torch.Tensor) -> torch.Tensor:
"""An answer from the copy at the picture's own size (bool)."""
if self.same or tuple(mask.shape) == (self.height, self.width):
return mask
soft = F.interpolate(mask[None, None].float(), size=(self.height, self.width), mode="bilinear", align_corners=False)
return soft[0, 0] >= 0.5
# ---------------------------------------------------------------------------
# Strokes
# ---------------------------------------------------------------------------
+97
View File
@@ -0,0 +1,97 @@
"""Only one copy of the pack loads.
A Manager install lands in ``custom_nodes/ausboss-nodes`` and a git clone in
``custom_nodes/ComfyUI-AusBoss``. With both in place every route is added
twice and ComfyUI stops while starting. So each copy looks for the others
and all of them agree on the one that loads; the rest step aside and say
which folder to delete.
A copy from before this file existed cannot step aside, so it always loads
and a newer copy yields to it. Among copies that can, the newest version
loads.
"""
from __future__ import annotations
import os
import tomllib
PACK_NAME = "ausboss-nodes"
# A copy that has this file knows how to step aside.
_GUARD_FILE = os.path.join("nodes", "_single_copy.py")
def _read_pack(folder: str) -> dict | None:
"""Name the pack copy in ``folder``, or None when it is another pack."""
try:
with open(os.path.join(folder, "pyproject.toml"), "rb") as f:
project = tomllib.load(f).get("project", {})
except Exception:
return None
if project.get("name") != PACK_NAME:
return None
if not os.path.isfile(os.path.join(folder, "__init__.py")):
return None
version = project.get("version")
return {
"folder": folder,
"version": version if isinstance(version, str) else "unknown",
"guarded": os.path.isfile(os.path.join(folder, _GUARD_FILE)),
}
def _version_key(version: str) -> tuple:
parts = []
for part in version.split("."):
digits = "".join(ch for ch in part if ch.isdigit())
parts.append(int(digits) if digits else -1)
return tuple(parts)
def find_copies(own_folder: str, roots: list[str]) -> list[dict]:
"""Every copy of the pack below ``roots``, this one included."""
own = os.path.realpath(own_folder)
seen = {own}
copies = [_read_pack(own) or {"folder": own, "version": "unknown", "guarded": True}]
for root in roots:
try:
names = sorted(os.listdir(root))
except OSError:
continue
for name in names:
# ComfyUI skips these too.
if name.endswith(".disabled") or name.startswith("."):
continue
folder = os.path.realpath(os.path.join(root, name))
if folder in seen or not os.path.isdir(folder):
continue
seen.add(folder)
pack = _read_pack(folder)
if pack:
copies.append(pack)
return copies
def pick_copy(copies: list[dict]) -> dict:
"""The one copy that loads. Every copy reaches the same answer."""
unguarded = [c for c in copies if not c["guarded"]]
pool = unguarded or copies
return max(pool, key=lambda c: (_version_key(c["version"]), c["folder"]))
def other_copy_that_loads(own_folder: str, roots: list[str] | None = None) -> dict | None:
"""The copy this one steps aside for, or None when this one loads."""
if roots is None:
# Outside ComfyUI nothing is served, so there is nothing to clash.
try:
import folder_paths
roots = list(folder_paths.get_folder_paths("custom_nodes"))
except Exception:
return None
roots = list(dict.fromkeys(os.path.realpath(r) for r in roots))
copies = find_copies(own_folder, roots)
if len(copies) < 2:
return None
chosen = pick_copy(copies)
return None if chosen["folder"] == os.path.realpath(own_folder) else chosen
+65 -30
View File
@@ -42,6 +42,7 @@ from ._mask_by_touch_helpers import (
WORDS_MORE,
Memory,
PieceSieve,
WorkView,
apply_item,
box_of,
choose_found,
@@ -126,6 +127,8 @@ QUICK = True
# Shared by every Mask by Touch node: what SAM 3 answered, per picture.
_MEMORY = Memory()
# How many of those smaller copies a node keeps: this picture's and the last one's.
VIEWS_KEPT = 2
# What the text reader made of each word, per text reader. A word reads the
# same for every picture, so it is read once.
_WORDS = OrderedDict()
@@ -836,25 +839,44 @@ class AusBossMaskByTouch:
# What Fine edges is set to in the run at hand: the subject is asked
# of that model.
self._edge_choice = FINE_OFF
# The smaller copies of big pictures SAM 3 is asked about, newest last.
self._views = OrderedDict()
# -- what SAM 3 finds ---------------------------------------------------
def _tap(self, session: _Session, frame, frame_key: str, point):
"""The thing at one point, without its stray specks. None for nothing.
def _view(self, frame, frame_key: str) -> WorkView:
"""The picture as SAM 3 is asked about it: itself, or a smaller copy
of a big one, kept for the next run on the same picture."""
held = self._views.get(frame_key)
if held is not None and (held.height, held.width) == (int(frame.shape[1]), int(frame.shape[2])):
self._views.move_to_end(frame_key)
return held
view = WorkView(frame)
if not view.same:
self._views[frame_key] = view
while len(self._views) > VIEWS_KEPT:
self._views.popitem(last=False)
return view
def _tap(self, session: _Session, view: WorkView, frame_key: str, point):
"""The thing at one point, without its stray specks, at the size SAM 3
was asked at (`view`). None for nothing. `point` is a pixel of the
picture.
Hovering's pieces and taps share this, and what it returned is
remembered, so a tap on a piece that was shown gives that piece."""
key = (session.token, frame_key, ("tap", int(point[0]), int(point[1])))
found, answer = _MEMORY.get(key)
if not found:
answer = drop_specks(session.ask(frame, [point]), point)
spot = view.point(point)
answer = drop_specks(session.ask(view.frame, [spot]), spot)
answer = answer if bool(answer.any()) else None
_MEMORY.put(key, answer)
return answer
def _named(self, session: _Session, frame, frame_key: str, word: str) -> list:
def _named(self, session: _Session, view: WorkView, frame_key: str, word: str) -> list:
"""Everything in one picture that SAM 3 calls by a word, tidied:
[(H x W bool, how sure), ...].
[(bool at the size SAM 3 was asked at, how sure), ...].
Hovering's named pieces and `name` touches share this, and what it
returned is remembered, so a touch on a named piece that was shown
@@ -862,7 +884,7 @@ class AusBossMaskByTouch:
key = (session.token, session.reader, frame_key, ("word", word))
found, matches = _MEMORY.get_matches(key)
if not found:
matches = tidy_matches(*session.ask_word(frame, word))
matches = tidy_matches(*session.ask_word(view.frame, word))
_MEMORY.put_matches(key, matches)
return matches
@@ -873,6 +895,9 @@ class AusBossMaskByTouch:
The area of a `paint` touch made with a soft brush is float, 0..1.
"""
height, width = int(frame.shape[1]), int(frame.shape[2])
# What SAM 3 is asked comes back at the size it was asked at, and is
# brought to the picture's own size here.
view = self._view(frame, frame_key)
if item.kind in ("subject", "background"):
# The picture's main subject, as ComfyUI's background-removal
# model sees it, or everything but it.
@@ -881,25 +906,25 @@ class AusBossMaskByTouch:
return (area, "thing", ()) if bool(area.any()) else (None, "none", ())
if item.kind == "all":
# Every thing called by that word, joined.
matches = self._named(session, frame, frame_key, item.word) if item.word else []
matches = self._named(session, view, frame_key, item.word) if item.word else []
if not matches:
return None, "none", ()
area = torch.zeros(height, width, dtype=torch.bool)
area = torch.zeros(view.work_height, view.work_width, dtype=torch.bool)
for mask, _ in matches:
area |= mask
return area, "thing", tuple(range(len(matches)))
return view.full(area), "thing", tuple(range(len(matches)))
if item.kind in ("tap", "name"):
if not (0 <= item.x < width and 0 <= item.y < height):
return None, "none", ()
if item.kind == "tap":
answer = self._tap(session, frame, frame_key, (item.x, item.y))
return (answer, "thing", ()) if answer is not None else (None, "none", ())
answer = self._tap(session, view, frame_key, (item.x, item.y))
return (view.full(answer), "thing", ()) if answer is not None else (None, "none", ())
if not item.word:
return None, "none", ()
# Of the things called by that word, the smallest at the point.
matches = self._named(session, frame, frame_key, item.word)
index = match_under(matches, (item.x, item.y))
return (None, "none", ()) if index is None else (matches[index][0], "thing", (index,))
matches = self._named(session, view, frame_key, item.word)
index = match_under(matches, view.point((item.x, item.y)))
return (None, "none", ()) if index is None else (view.full(matches[index][0]), "thing", (index,))
if item.kind not in ("find", "paint"):
return None, "none", ()
if item.kind == "paint" and item.soft > 0:
@@ -918,14 +943,18 @@ class AusBossMaskByTouch:
found, answer = _MEMORY.get(key)
if not found:
many, pair = find_points(item.pts, height, width)
# The stroke as it lies on the copy SAM 3 is asked about.
under = stroke if view.same else stroke_area(
view.points(item.pts), max(1.0, view.length(item.r)), view.work_height, view.work_width,
)
answer = choose_found(
session.ask(frame, many) if many else None,
session.ask(frame, pair) if pair else None,
stroke,
session.ask(view.frame, view.points(many)) if many else None,
session.ask(view.frame, view.points(pair)) if pair else None,
under,
)
_MEMORY.put(key, answer)
# Over empty space the paint is kept as it is.
return (answer, "thing", ()) if answer is not None else (stroke, "paint", ())
return (view.full(answer), "thing", ()) if answer is not None else (stroke, "paint", ())
def _look(self, session: _Session, frame, key: str, pieces: dict, pointer=None, tell=None) -> dict:
"""Find a picture's pieces for hovering.
@@ -943,6 +972,7 @@ class AusBossMaskByTouch:
piece.
"""
height, width = int(frame.shape[1]), int(frame.shape[2])
view = self._view(frame, key)
queue = _scope_queue(width, height)
sieve = pieces["sieve"]
fresh = {} # (name or None, point) -> (area, is it tidy and remembered)
@@ -967,7 +997,7 @@ class AusBossMaskByTouch:
if pointer is not None:
since = time.perf_counter()
known = _MEMORY.has((session.token, key, ("tap", pointer[0], pointer[1])))
answer = self._tap(session, frame, key, pointer)
answer = self._tap(session, view, key, pointer)
answered(known, since)
if answer is not None and sieve.offer(pointer, answer, pointed=True):
fresh[(None, pointer)] = (answer, True)
@@ -993,8 +1023,10 @@ class AusBossMaskByTouch:
break
ahead.append(later)
session.read_ahead(ahead, stop=_jobs_waiting)
matches = self._named(session, frame, key, what)
for (area, sure), point in zip(matches, match_points(matches)):
matches = self._named(session, view, key, what)
for (area, sure), spot in zip(matches, match_points(matches)):
# The match's own point, as a pixel of the picture.
point = None if spot is None else view.full_point(spot)
if point is not None and sieve.offer(point, area, name=what, sure=sure):
fresh[(what, point)] = (area, True)
pieces["words"] += 1
@@ -1002,7 +1034,7 @@ class AusBossMaskByTouch:
memory_key = (session.token, key, ("tap", what[0], what[1]))
known, answer = _MEMORY.get(memory_key)
if not known:
answer = session.ask(frame, [what])
answer = session.ask(view.frame, [view.point(what)])
if not bool(answer.any()):
_MEMORY.put(memory_key, None)
answer = None
@@ -1025,6 +1057,7 @@ class AusBossMaskByTouch:
first, and the small copies packed into planes. `fresh` holds the
areas found since the last time, and is emptied."""
height, width = int(frame.shape[1]), int(frame.shape[2])
view = self._view(frame, key)
small_width, small_height, _ = small_size(width, height)
sieve = pieces["sieve"]
before = {ident: slot for slot, ident in enumerate(pieces["ids"])}
@@ -1036,18 +1069,19 @@ class AusBossMaskByTouch:
else:
# A piece kept by a look that was stopped half way is asked
# for again: its answer was not held on to.
answer, tidy = fresh.get(ident) or (self._piece(session, frame, key, name, point), True)
answer, tidy = fresh.get(ident) or (self._piece(session, view, key, name, point), True)
if answer is None:
continue
if not tidy:
# What a tap at this point gives: the same answer, tidied
# the same way, remembered so the tap asks nothing.
answer = drop_specks(answer, point)
answer = drop_specks(answer, view.point(point))
_MEMORY.put((session.token, key, ("tap", point[0], point[1])), answer)
sure = sieve.sure_of(name, point)
entry = {
"x": point[0], "y": point[1],
"share": round(float(answer.sum()) / (height * width), 5), "box": box_of(answer),
"share": round(float(answer.sum()) / (view.work_height * view.work_width), 5),
"box": view.full_box(box_of(answer)),
"kind": "point" if name is None else "name", "name": name,
"sure": None if sure is None else round(sure, 3),
}
@@ -1064,13 +1098,13 @@ class AusBossMaskByTouch:
pieces["list"] = [row[4] for row in rows]
pieces["planes"] = pack_planes(masks)
def _piece(self, session: _Session, frame, key: str, name, point):
def _piece(self, session: _Session, view: WorkView, key: str, name, point):
"""The area of a piece from what is remembered: a point's answer, or
the match of a word that the piece's own point stands for."""
if name is None:
return self._tap(session, frame, key, point)
matches = self._named(session, frame, key, name)
index = match_under(matches, point)
return self._tap(session, view, key, point)
matches = self._named(session, view, key, name)
index = match_under(matches, view.point(point))
return None if index is None else matches[index][0]
# -- files for the node's face -------------------------------------------
@@ -1385,8 +1419,9 @@ class AusBossMaskByTouch:
if asked == "scope" and (pieces is None or pointer is not None or pieces["next"] < pieces["total"]):
if pieces is None:
words = len(WORDS_FIRST) + len(WORDS_MORE)
view = self._view(image[:1], key)
pieces = {
"sieve": PieceSieve(height, width), "next": 0, "total": len(_scope_queue(width, height)),
"sieve": PieceSieve(view.work_height, view.work_width), "next": 0, "total": len(_scope_queue(width, height)),
"words": 0, "words_total": words, "number": 0, "ids": [], "list": [], "planes": [],
}
# Remembered from the start: a look that is stopped half way
+38
View File
@@ -4,6 +4,7 @@ import test from "node:test";
import {
collapseRepeatedErrors,
emptySourceMessage,
explainRunError,
findEmptySources,
nodeLacksSource,
nodesThatRun,
@@ -199,3 +200,40 @@ test("an empty Load Audio asks for a sound file", () => {
prompt[1].inputs.audio = "song.mp3";
assert.deepEqual(findEmptySources(prompt, outputs), []);
});
// ------------------------------------------------------------ known run errors
// The report a user posted: ComfyUI 0.44.0, H3 keeping the clip's sound, with
// Muse MiniMax Director installed.
const MUSE_REPORT = {
node_type: "SamplerCustomAdvanced",
exception_type: "KeyError",
exception_message: "'latent'",
traceback: [
' File "G:\\ComfyUI\\ComfyUI\\comfy\\samplers.py", line 953, in encode_model_conds\n out = model_function(**params)\n',
' File "G:\\ComfyUI\\ComfyUI\\custom_nodes\\muse-minimax-director-v1-2\\muse_minimax_hybrid_patch.py", line 30, in extra_conds_with_hybrid\n visual_latents.extend(item["latent"] for item in keyframes)\n',
],
};
test("the Muse MiniMax Director error is explained and names the pack", () => {
const known = explainRunError(MUSE_REPORT);
assert.match(known.detail, /Muse MiniMax Director/);
assert.match(known.detail, /Turn that pack off/);
});
test("the same KeyError from anywhere else is left alone", () => {
assert.equal(explainRunError({ ...MUSE_REPORT, traceback: [' File "comfy/samplers.py", line 953\n'] }), null);
assert.equal(explainRunError({ ...MUSE_REPORT, exception_message: "'samples'" }), null);
});
test("an old ComfyUI loading a w6a8 model is told to update", () => {
const known = explainRunError({ exception_type: "KeyError", exception_message: "'w6a8_int8'", traceback: [] });
assert.match(known.detail, /0\.38/);
});
test("an unknown error, or no report at all, gives nothing", () => {
assert.equal(explainRunError({ exception_type: "RuntimeError", exception_message: "CUDA out of memory", traceback: [] }), null);
assert.equal(explainRunError(null), null);
assert.equal(explainRunError({}), null);
assert.equal(explainRunError({ exception_type: "KeyError", exception_message: "'latent'", traceback: "muse_minimax_hybrid_patch" }) !== null, true);
});
+235
View File
@@ -0,0 +1,235 @@
"""Mask by Touch on a big picture: SAM 3 is asked about a smaller copy, and
what it answers comes back at the picture's own size. ComfyUI's detector and
text reader are stood in for by fakes, so this runs without ComfyUI and
without a model."""
from __future__ import annotations
import json
from pathlib import Path
import shutil
import sys
import tempfile
import types
import unittest
import torch
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
if "nodes" in sys.modules and not hasattr(sys.modules["nodes"], "__path__"):
del sys.modules["nodes"]
from nodes import _mask_by_touch_helpers as helpers # noqa: E402
from nodes import node_mask_by_touch as node_module # noqa: E402
from nodes._mask_by_name_helpers import IF_NOTHING # noqa: E402
from nodes._mask_by_touch_helpers import WORK_SIDE, WorkView # noqa: E402
UI = "ausboss_mask_by_touch"
# The big picture, and the long side of the copy SAM 3 is asked about here.
H, W = 300, 240
SIDE = 60
# Things as shares of the picture: (left, top, right, bottom).
PERSON = (0.25, 0.1, 0.75, 0.95)
FACE = (0.4, 0.15, 0.6, 0.3)
THINGS = {"person": [(PERSON, 0.98)], "face": [(FACE, 0.9)]}
def box(thing, height, width):
left, top, right, bottom = thing
mask = torch.zeros(height, width)
mask[round(top * height):round(bottom * height), round(left * width):round(right * width)] = 1.0
return mask
def same_area(one, other):
one, other = one.bool(), other.bool()
return float((one & other).sum()) / max(1.0, float((one | other).sum()))
class SizedSam:
"""Stands in for ComfyUI's SAM3_Detect. It answers at the size of the
picture it is given, as the real one does."""
calls: list = []
@classmethod
def execute(cls, model, image, conditioning=None, positive_coords=None, negative_coords=None, threshold=0.5, refine_iterations=2, individual_masks=False):
height, width = int(image.shape[1]), int(image.shape[2])
call = {"size": (height, width)}
cls.calls.append(call)
if conditioning is not None:
call["word"] = conditioning[0][2].rpartition(":")[0]
found = THINGS.get(call["word"], [])
masks = torch.stack([box(thing, height, width) for thing, _ in found]) if found else torch.zeros(0, height, width)
return types.SimpleNamespace(result=(masks, [[{"score": sure} for _, sure in found]]))
call["points"] = points = [(point["x"], point["y"]) for point in json.loads(positive_coords)]
answer = torch.zeros(height, width)
for thing in (FACE, PERSON): # the smallest thing that holds every point
area = box(thing, height, width)
if all(0 <= y < height and 0 <= x < width and float(area[y, x]) > 0 for x, y in points):
answer = area
break
return types.SimpleNamespace(result=(answer[None], [[]]))
class Encode:
def encode(self, clip, text):
return ([["read by", clip, text]],)
class Folders:
def __init__(self, folder):
self.folder = folder
def get_temp_directory(self):
return self.folder
def get_save_image_path(self, prefix, folder, _width, _height):
counter = 1 + sum(1 for name in Path(folder).iterdir() if name.name.startswith(prefix + "_"))
return folder, prefix, counter, "", prefix
class WorkViewTests(unittest.TestCase):
def test_a_picture_up_to_the_work_side_is_asked_about_as_it_is(self):
frame = torch.rand(1, 40, WORK_SIDE, 3)
view = WorkView(frame)
self.assertTrue(view.same)
self.assertIs(view.frame, frame)
self.assertEqual((view.work_height, view.work_width), (40, WORK_SIDE))
self.assertEqual(view.point((17, 5)), (17, 5))
self.assertEqual(view.full_point((17, 5)), (17, 5))
mask = torch.zeros(40, WORK_SIDE, dtype=torch.bool)
self.assertIs(view.full(mask), mask)
self.assertEqual(view.full_box([1, 2, 3, 4]), [1, 2, 3, 4])
def test_a_bigger_picture_gets_a_copy_with_the_long_side_at_the_work_side(self):
frame = torch.rand(1, WORK_SIDE * 2, WORK_SIDE + 500, 3)
view = WorkView(frame)
self.assertFalse(view.same)
self.assertEqual(view.work_height, WORK_SIDE)
self.assertEqual(tuple(view.frame.shape), (1, view.work_height, view.work_width, 3))
self.assertEqual(view.work_width, round((WORK_SIDE + 500) / 2))
# Averaged down: the copy's mean is the picture's.
self.assertAlmostEqual(float(view.frame.mean()), float(frame.mean()), places=3)
def test_a_place_on_the_copy_leads_back_to_itself(self):
for height, width in ((4096, 4096), (3840, 2160), (2049, 8192), (5000, 3333)):
view = WorkView(torch.zeros(1, height, width, 1))
for x in (0, 1, 7, view.work_width // 3, view.work_width - 2, view.work_width - 1):
for y in (0, 1, 5, view.work_height // 2, view.work_height - 1):
full = view.full_point((x, y))
self.assertTrue(0 <= full[0] < width and 0 <= full[1] < height)
self.assertEqual(view.point(full), (x, y), f"{width}x{height} at {x},{y}")
self.assertEqual(view.point((width - 1, height - 1)), (view.work_width - 1, view.work_height - 1))
def test_an_answer_comes_back_at_the_pictures_own_size(self):
view = WorkView(torch.zeros(1, 4096, 3000, 3))
small = box(FACE, view.work_height, view.work_width) > 0.5
full = view.full(small)
self.assertEqual(tuple(full.shape), (4096, 3000))
self.assertEqual(full.dtype, torch.bool)
self.assertGreater(same_area(full, box(FACE, 4096, 3000)), 0.98)
x0, y0, x1, y1 = view.full_box(helpers.box_of(small))
wanted = helpers.box_of(box(FACE, 4096, 3000) > 0.5)
for got, want in zip((x0, y0, x1, y1), wanted):
self.assertLessEqual(abs(got - want), 3)
class BigPictureTests(unittest.TestCase):
def setUp(self):
SizedSam.calls = []
node_module._MEMORY.clear()
node_module._WORDS.clear()
node_module._TRUST.clear()
node_module._UNSTEADY.clear()
node_module._NOTED.clear()
self.swap(node_module, "_core_nodes", lambda: {"SAM3_Detect": SizedSam, "CLIPTextEncode": Encode})
self.swap(node_module, "SCOPE_SPACING", 12)
self.swap(node_module, "SCOPE_SECONDS_BLIND", 1e9)
self.swap(helpers, "WORK_SIDE", SIDE)
folder = tempfile.mkdtemp(prefix="ausboss_touch_big_")
self.addCleanup(shutil.rmtree, folder, True)
self.swap(node_module, "folder_paths", Folders(folder))
self.picture = torch.rand(1, H, W, 3) * 0.9
self.node = node_module.AusBossMaskByTouch()
def swap(self, module, attribute, value):
self.addCleanup(setattr, module, attribute, getattr(module, attribute))
setattr(module, attribute, value)
def run_node(self, text="", image=None, **overrides):
values = {"grow": 0, "soften": 0.0, "smooth": 0, "fill_holes": False, "if_nothing": IF_NOTHING[1]}
values.update(overrides)
return self.node.pick(self.picture if image is None else image, "sam3 model", "sam3 clip", text, **values)
def touches(self, *items):
return json.dumps({"v": 1, "items": list(items)})
def test_sam3_is_never_given_the_big_picture(self):
self.run_node(self.touches({"t": "tap", "op": 1, "x": 120, "y": 66}, {"t": "all", "op": 1, "w": "person"}))
self.assertTrue(SizedSam.calls)
for call in SizedSam.calls:
self.assertEqual(call["size"], (SIDE, 48), "the copy: 300 x 240 with its long side at 60")
def test_a_tap_takes_the_thing_at_its_place_and_the_mask_is_full_size(self):
out = self.run_node(self.touches({"t": "tap", "op": 1, "x": 120, "y": 66})) # on the face
mask = out["result"][0]
self.assertEqual(tuple(mask.shape), (1, H, W))
self.assertGreater(same_area(mask[0] > 0.5, box(FACE, H, W)), 0.9)
# The point was asked where it lies on the copy.
self.assertEqual(SizedSam.calls[0]["points"], [(120 * 48 // W, 66 * SIDE // H)])
def test_a_word_takes_every_thing_of_that_name_at_full_size(self):
out = self.run_node(self.touches({"t": "all", "op": 1, "w": "person"}, {"t": "all", "op": 0, "w": "face"}))
mask = out["result"][0][0] > 0.5
wanted = (box(PERSON, H, W) > 0.5) & ~(box(FACE, H, W) > 0.5)
self.assertGreater(same_area(mask, wanted), 0.93)
self.assertEqual(out["result"][2], "person")
def test_a_stroke_is_asked_about_on_the_copy(self):
stroke = {"t": "find", "op": 1, "r": 8, "pts": [[110, 60], [130, 70]]} # across the face
out = self.run_node(self.touches(stroke))
self.assertGreater(same_area(out["result"][0][0] > 0.5, box(FACE, H, W)), 0.9)
for call in SizedSam.calls:
for x, y in call.get("points", []):
self.assertTrue(0 <= x < 48 and 0 <= y < SIDE)
def test_what_a_look_lights_up_is_what_a_click_on_it_takes(self):
note = self.run_node(ask="scope")["ui"][UI][0]
self.assertEqual((note["width"], note["height"]), (W, H))
pieces = {piece["name"]: piece for piece in note["pieces"]["list"] if piece["name"]}
self.assertEqual(set(pieces), {"person", "face"})
face = pieces["face"]
# Its own point is a pixel of the picture, inside the thing, and its box is the picture's.
self.assertGreater(float(box(FACE, H, W)[face["y"], face["x"]]), 0)
for got, want in zip(face["box"], helpers.box_of(box(FACE, H, W) > 0.5)):
self.assertLessEqual(abs(got - want), 6)
self.assertAlmostEqual(face["share"], float(box(FACE, H, W).mean()), delta=0.01)
# The face picks it as a named touch at that point: nothing is asked again.
before = len(SizedSam.calls)
out = self.run_node(self.touches({"t": "name", "op": 1, "w": "face", "x": face["x"], "y": face["y"]}), ask="look")
self.assertEqual(len(SizedSam.calls), before)
self.assertGreater(same_area(out["result"][0][0] > 0.5, box(FACE, H, W)), 0.9)
# A point piece is remembered under the picture's own pixel as well.
spot = next((piece for piece in note["pieces"]["list"] if not piece["name"]), None)
if spot is not None:
self.run_node(self.touches({"t": "tap", "op": 1, "x": spot["x"], "y": spot["y"]}), ask="look")
self.assertEqual(len(SizedSam.calls), before)
def test_a_small_picture_is_asked_about_as_it_is(self):
small = torch.rand(1, 50, 40, 3) * 0.9
out = self.run_node(self.touches({"t": "tap", "op": 1, "x": 20, "y": 11}), image=small)
self.assertEqual([call["size"] for call in SizedSam.calls], [(50, 40)])
self.assertTrue(torch.equal(out["result"][0][0] > 0.5, box(FACE, 50, 40) > 0.5), "exactly what SAM 3 answered")
def test_paint_stays_exactly_as_painted_on_the_big_picture(self):
stroke = [[20, 20], [200, 20]]
out = self.run_node(self.touches({"t": "paint", "op": 1, "r": 5, "pts": stroke}))
wanted = helpers.stroke_area(tuple(tuple(point) for point in stroke), 5.0, H, W)
self.assertTrue(torch.equal(out["result"][0][0] > 0.5, wanted))
self.assertEqual(SizedSam.calls, [], "paint asks SAM 3 nothing")
if __name__ == "__main__":
unittest.main()
-128
View File
@@ -1,128 +0,0 @@
"""A second install of the pack loads nothing, so ComfyUI still starts.
People install the pack from ComfyUI-Manager (custom_nodes/ausboss-nodes) and
from GitHub (custom_nodes/ComfyUI-AusBoss). With both folders present, the two
copies registered the same server routes, and ComfyUI stopped at startup with
"Added route will never be executed, method HEAD is already registered".
This loads the pack twice, from two folders, in one fresh standard-library
Python with the same stand-ins as tests/test_node_api.py. The second copy must
add no routes, no nodes and no browser files, and must name both folders.
"""
from __future__ import annotations
from pathlib import Path
import contextlib
import importlib.util
import io
import json
import subprocess
import sys
import tempfile
import types
import unittest
ROOT = Path(__file__).resolve().parent.parent
ROUTES: list[tuple[str, str]] = []
def stub_server():
"""A PromptServer whose route table records every route, the way aiohttp will add them."""
class Routes:
def __getattr__(self, method):
def register(path, *_args, **_kwargs):
ROUTES.append((method.upper(), path))
return lambda handler: handler
return register
class Instance:
routes = Routes()
def __getattr__(self, _name):
return lambda *_args, **_kwargs: None
module = types.ModuleType("server")
module.PromptServer = type("PromptServer", (), {"instance": Instance()})
sys.modules["server"] = module
def load_copy(name: str, folder: Path):
spec = importlib.util.spec_from_file_location(
name, folder / "__init__.py", submodule_search_locations=[str(folder)]
)
pack = importlib.util.module_from_spec(spec)
sys.modules[name] = pack
out = io.StringIO()
with contextlib.redirect_stdout(out):
spec.loader.exec_module(pack)
return pack, out.getvalue()
def child(tmp: Path, report: Path):
sys.path.insert(0, str(ROOT / "tests"))
from test_node_api import StandInFinder, StandInModule
sys.meta_path.insert(0, StandInFinder())
sys.modules["aiohttp"] = StandInModule("aiohttp") # route helpers import aiohttp.web first
stub_server()
# The two folder names people end up with: a git clone and Manager's install.
first, second = tmp / "ComfyUI-AusBoss", tmp / "ausboss-nodes"
first.symlink_to(ROOT, target_is_directory=True)
second.symlink_to(ROOT, target_is_directory=True)
first_pack, _ = load_copy("ComfyUI-AusBoss", first)
first_routes = list(ROUTES)
second_pack, second_out = load_copy("ausboss-nodes", second)
report.write_text(json.dumps({
"first_nodes": len(first_pack.NODE_CLASS_MAPPINGS),
"first_web": first_pack.WEB_DIRECTORY,
"first_routes": len(first_routes),
"second_nodes": len(second_pack.NODE_CLASS_MAPPINGS),
"second_web": second_pack.WEB_DIRECTORY,
"second_routes": len(ROUTES) - len(first_routes),
"duplicates": sorted({f"{m} {p}" for m, p in ROUTES if ROUTES.count((m, p)) > 1}),
"second_out": second_out,
}), encoding="utf-8")
class SecondCopyTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
with tempfile.TemporaryDirectory() as tmp:
report = Path(tmp) / "report.json"
result = subprocess.run(
[sys.executable, "-S", str(Path(__file__).resolve()), "--child", tmp, str(report)],
cwd=ROOT, capture_output=True, text=True, timeout=120,
)
if result.returncode or not report.exists():
raise AssertionError(f"Loading two copies failed:\n{result.stdout}\n{result.stderr}")
cls.found = json.loads(report.read_text(encoding="utf-8"))
def test_first_copy_loads_as_usual(self):
self.assertGreater(self.found["first_nodes"], 0)
self.assertGreater(self.found["first_routes"], 0)
self.assertEqual(self.found["first_web"], "./js")
def test_second_copy_adds_no_routes(self):
self.assertEqual(self.found["duplicates"], [])
self.assertEqual(self.found["second_routes"], 0)
def test_second_copy_adds_no_nodes_or_browser_files(self):
self.assertEqual(self.found["second_nodes"], 0)
self.assertIsNone(self.found["second_web"])
def test_second_copy_names_both_folders(self):
out = self.found["second_out"]
self.assertIn("ComfyUI-AusBoss", out)
self.assertIn("ausboss-nodes", out)
out.encode("ascii") # cp1252 consoles: the message must be plain ASCII
if __name__ == "__main__":
if len(sys.argv) == 4 and sys.argv[1] == "--child":
child(Path(sys.argv[2]), Path(sys.argv[3]))
else:
unittest.main()
+79
View File
@@ -0,0 +1,79 @@
from __future__ import annotations
from pathlib import Path
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
if "nodes" in sys.modules and not hasattr(sys.modules["nodes"], "__path__"):
del sys.modules["nodes"]
from nodes._single_copy import other_copy_that_loads
def make_copy(root: Path, name: str, version: str, guarded: bool = True, pack: str = "ausboss-nodes") -> str:
folder = root / name
(folder / "nodes").mkdir(parents=True)
(folder / "__init__.py").write_text("")
(folder / "pyproject.toml").write_text(f'[project]\nname = "{pack}"\nversion = "{version}"\n')
if guarded:
(folder / "nodes" / "_single_copy.py").write_text("")
return str(folder)
def loads(folder: str, root: Path) -> bool:
return other_copy_that_loads(folder, [str(root)]) is None
class SingleCopyTests(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.root = Path(self._tmp.name).resolve()
def tearDown(self):
self._tmp.cleanup()
def test_a_lone_copy_loads(self):
a = make_copy(self.root, "ComfyUI-AusBoss", "2.24.0")
make_copy(self.root, "some-other-pack", "9.0.0", pack="other")
self.assertTrue(loads(a, self.root))
def test_exactly_one_of_two_copies_loads_and_it_is_the_newer(self):
a = make_copy(self.root, "ComfyUI-AusBoss", "2.24.0")
b = make_copy(self.root, "ausboss-nodes", "2.25.0")
self.assertFalse(loads(a, self.root))
self.assertTrue(loads(b, self.root))
self.assertEqual(other_copy_that_loads(a, [str(self.root)])["version"], "2.25.0")
def test_same_version_still_picks_one(self):
a = make_copy(self.root, "ComfyUI-AusBoss", "2.24.0")
b = make_copy(self.root, "ausboss-nodes", "2.24.0")
self.assertEqual([loads(a, self.root), loads(b, self.root)].count(True), 1)
def test_yields_to_an_older_copy_that_cannot_step_aside(self):
new = make_copy(self.root, "ComfyUI-AusBoss", "2.24.0")
make_copy(self.root, "ausboss-nodes", "2.22.0", guarded=False)
self.assertFalse(loads(new, self.root))
def test_a_disabled_copy_does_not_count(self):
a = make_copy(self.root, "ComfyUI-AusBoss", "2.24.0")
make_copy(self.root, "ausboss-nodes.disabled", "2.25.0")
self.assertTrue(loads(a, self.root))
def test_a_link_to_the_same_folder_is_one_copy(self):
a = make_copy(self.root, "ComfyUI-AusBoss", "2.24.0")
(self.root / "link").symlink_to(a)
self.assertTrue(loads(a, self.root))
self.assertTrue(loads(str(self.root / "link"), self.root))
def test_ten_is_newer_than_nine(self):
a = make_copy(self.root, "ComfyUI-AusBoss", "2.9.0")
b = make_copy(self.root, "ausboss-nodes", "2.10.0")
self.assertFalse(loads(a, self.root))
self.assertTrue(loads(b, self.root))
if __name__ == "__main__":
unittest.main()
+66
View File
@@ -60,6 +60,7 @@ import {
thinStroke,
touchFits,
} from "../js/shared/touch_math.mjs";
import { OUT_OF_MEMORY, goesBehind as goesBehindAgain, piecesHolding, stopMessage, typedName } from "../js/shared/touch_math.mjs";
import {
GROUP_MAX,
areaKey,
@@ -1062,3 +1063,68 @@ test("picks the node moved onto a new picture are taken only while nothing was p
assert.equal(movedTouches({ moved: { from: 7, touches: after } }, before), null, "a note that is not this shape moves nothing");
assert.equal(movedTouches(null, before), null);
});
test("taking away from a spot that is not picked never offers a thing that holds a whole pick", () => {
const { width, masks, planes, sizes, ranks } = scene();
// The jacket is picked. The pointer is on the person's legs, which are not.
const inside = piecesInside(planes, 5, masks.jacket);
const holds = piecesHolding(planes, 5, [masks.jacket]);
assert.deepEqual([...holds], [0, 1, 1, 1, 1], "the jacket, the dress over it, the person and the wall hold all of it");
const legs = 32 * width + 14;
assert.equal(pieceAt(planes, legs, { sizes, inside, ranks, adding: false }), 3, "as before, when nothing is said about the spot");
assert.equal(pieceAt(planes, legs, { sizes, inside, ranks, adding: false, here: false, holds }), -1, "not the person: the jacket would go with it");
// On the jacket itself the spot is picked: the jacket is offered.
const onJacket = 14 * width + 14;
assert.equal(pieceAt(planes, onJacket, { sizes, inside, ranks, adding: false, here: true, holds }), 1);
// The person is picked, and the pointer is on the jacket: the jacket is a part of the pick, and is offered.
const person = piecesInside(planes, 5, masks.person);
const personHolds = piecesHolding(planes, 5, [masks.person]);
assert.equal(pieceAt(planes, onJacket, { sizes, inside: person, ranks, adding: false, here: true, holds: personHolds }), 1);
// A thing a pick only laps over is still offered from a spot that is not picked.
const lap = Uint8Array.from(masks.person, (value, index) => (value && Math.floor(index / width) >= 30 ? 1 : 0));
const lapInside = piecesInside(planes, 5, lap);
const lapHolds = piecesHolding(planes, 5, [masks.wall]);
assert.equal(lapHolds[3], 0, "the person does not hold a pick of the wall");
assert.equal(pieceAt(planes, onJacket + 0, { sizes, inside: lapInside, ranks, adding: false, here: false, holds: lapHolds }), 3, "the person has picked area in it and holds no whole pick");
// Adding is not changed by any of it.
assert.equal(pieceAt(planes, legs, { sizes, inside, ranks, here: false, holds }), 3);
assert.deepEqual([...piecesHolding(planes, 5, [null, new Uint8Array(masks.jacket.length)])], [0, 0, 0, 0, 0], "no area, nothing held");
});
test("a typed name is added or taken away by the sign, and a sign in the text wins", () => {
assert.deepEqual(typedName("face"), { word: "face", add: true });
assert.deepEqual(typedName("face", true), { word: "face", add: false });
assert.deepEqual(typedName("-face"), { word: "face", add: false });
assert.deepEqual(typedName(" - fur collar "), { word: "fur collar", add: false });
assert.deepEqual(typedName("\u2212hair"), { word: "hair", add: false }, "the minus a phone types");
assert.deepEqual(typedName("+hat", true), { word: "hat", add: true });
assert.deepEqual(typedName("t-shirt", false), { word: "t-shirt", add: true }, "a dash inside a word is the word's");
assert.deepEqual(typedName("-"), { word: "", add: false });
assert.deepEqual(typedName(null), { word: "", add: true });
});
test("looks at a picture the node no longer has are taken out of the queue before the next one is sent", () => {
const here = dirname(fileURLToPath(import.meta.url));
const stage = readFileSync(join(here, "..", "js", "shared", "touch_stage.mjs"), "utf8");
const entry = readFileSync(join(here, "..", "js", "mask_by_touch", "index.js"), "utf8");
const run = stage.slice(stage.indexOf("function requestRun"), stage.indexOf("function settle(face)"));
// the stale ones are counted before this run joins them, and dropped before it is sent
assert.ok(run.indexOf("const stale = face.flying.filter(") < run.indexOf("face.flying.push(run)"));
assert.match(run, /other\.queued && other\.epoch !== run\.epoch/);
assert.ok(run.indexOf("face.host.dropWaiting()") < run.indexOf("face.host.run("));
// only this node's own runs (they carry an `ask`) that still wait: never the one that runs, never a workflow run
const drop = entry.slice(entry.indexOf("async function dropWaiting"), entry.indexOf("// The picture goes above the settings"));
assert.match(drop, /queue_pending/);
assert.doesNotMatch(drop, /queue_running|interrupt/);
assert.match(drop, /class_type === NODE_CLASS && String\(step\.inputs\?\.ask \?\? ""\) !== ""/);
// a look still goes behind looks at an older picture, as before
assert.equal(goesBehindAgain([{ ask: "scope", queued: true, epoch: 1 }], "scope", 2), true);
});
test("a full graphics card is said in one plain line, anything else as it came", () => {
const comfy = "Allocation on device 0 would exceed allowed memory. (out of memory)\nCurrently allocated : 2.07 GiB\nRequested : 40.50 MiB";
assert.equal(stopMessage(comfy), OUT_OF_MEMORY);
assert.equal(stopMessage("torch.OutOfMemoryError: CUDA"), OUT_OF_MEMORY);
assert.equal(stopMessage("Nothing is picked on this picture."), "Nothing is picked on this picture.");
assert.equal(stopMessage(null), "");
});
+1 -1
View File
@@ -39,7 +39,7 @@ test("a stopped run's message stays up until the next result, then goes", () =>
const { face, setNote, shown } = noteFace();
const stopped = SOURCE.match(/face\.stopped = \(message\) => \{([\s\S]*?)\n {2}\};/)?.[1];
assert.ok(stopped, "touch_stage.mjs has no face.stopped");
assert.match(stopped, /setNote\(face, String\(message \?\? ""\)\);\s*(?:\/\/[^\n]*\n\s*)*face\.stopNote = true;/,
assert.match(stopped, /setNote\(face, stopMessage\(message\)\);\s*(?:\/\/[^\n]*\n\s*)*face\.stopNote = true;/,
"a stop marks its message as a stop message, after it is set");
// What face.stopped does to the note.
setNote(face, "Mask by Touch: nothing is picked.");