Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eba880cdee | ||
|
|
b6a67a4c34 | ||
|
|
27525ffdd8 | ||
|
|
eb668c448c | ||
|
|
9b3cd7c769 | ||
|
|
13c303178b | ||
|
|
f945feabb6 | ||
|
|
24ce4ffb06 | ||
|
|
980ed5db8b | ||
|
|
bf59887643 | ||
|
|
60be0ee865 |
+37
-4
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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), "");
|
||||
});
|
||||
|
||||
@@ -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.");
|
||||
|
||||
Reference in New Issue
Block a user