Compare commits

..
Author SHA1 Message Date
ausboss b4657e2b58 Load Image: a loader that also sets the size
A new node, Load Image. It loads a picture and sets its size in the
same node, so a workflow needs no resize node and no size node after
its loader.

- Size: off, megapixels, longest side, scale, fit inside, or crop to
  fill. Each choice shows only the numbers it uses. Round to rounds
  the result to a multiple. Under More: the resize method, and Enlarge,
  which when off only ever makes a picture smaller.
- Arrows under the file picker step through the input folder, with
  the picture's place in the list beside them.
- The node shows the size going in and the size coming out before a
  run. The browser repeats the backend's arithmetic, and a table of
  252 cases holds the two to the same answers.
- Outputs: image, mask, width, height, filename, original_width,
  original_height. The mask is the picture's transparency, as the
  stock loader gives it, at the new size.
- The picture on the node is ComfyUI's own preview, so the mask editor
  and clipspace work as they do on the stock loader.
- With Size off the pixels are the file's own.

Mask by Touch and the run check know the new loader as a source.
2026-11-10 17:42:00 -06:00
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
33 changed files with 2424 additions and 227 deletions
+46 -4
View File
@@ -4,10 +4,52 @@ 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.
- **New node: Load Image.** Loads a picture and sets its size in the same
node, so a workflow needs no resize node after its loader. Size can be a
megapixel count, a longest side, a scale, a box the picture fits inside, or
a box it fills by cropping, rounded to a multiple you choose. Arrows step
through the pictures in the input folder, and the node shows the size
going in and coming out before you run. It also gives the file's name and
its original size. With Size off the picture comes out exactly as the file
has it.
- **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
+6
View File
@@ -79,6 +79,12 @@ It returns the `image`, a `mask` of everything the model paints (padding, see-th
Krea 2 Rotate + Outpaint turns a photo, pads it and paints the new area in one pass. [Try the Krea 2 Rotate + Outpaint example →](example_workflows/Krea%202%20Rotate%20%2B%20Outpaint%20%28AusBoss%29.json)
### Load Image
Loads a picture and sets its size in one node. Pick a file, upload one, or step through the input folder with the arrows. **Size** can stay off, or bring the picture to a megapixel count, a longest side, a scale, a box it must fit inside, or a box it fills by cropping. **Round to** keeps the result on a multiple of 8, 16 or whatever the model wants. The node shows the size going in and the size coming out before you run, such as `912 × 1632 → 1144 × 2048`.
It returns the `image`, a `mask` of its see-through parts, the new `width` and `height`, the `filename` for a save prefix, and the file's `original_width` and `original_height`.
### Load Image + Pad
Makes a canvas for outpainting: drag the edges out to add room around a picture. The Source list shows the picture under the pointer. The new area can be a solid color, the average color of the nearest edge, the edge pixels smeared outward, a blurred copy of the picture, or a mirror of it. The card also has a seam feather, a canvas multiple and a megapixel budget, and **Exact padding** opens one control per side. A budget of 0 keeps the picture's size, and anything higher resizes the picture before padding. A picture wired into `source_image` is padded instead of the chosen file.
+34 -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.
@@ -49,6 +35,7 @@ NODE_MODULES = [
"node_lama_inpaint",
"node_layout_boxes",
"node_literals",
"node_load_image",
"node_load_image_pad",
"node_load_audio",
"node_load_video",
@@ -77,8 +64,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 +89,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 +115,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 +125,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()
+83
View File
@@ -0,0 +1,83 @@
# Load Image
Loads a picture and sets its size in the same node, so a workflow does not
need a resize node and a size node after its loader.
Pick a file in **Source**, press **Load** to upload one, or use the arrows
under it to step through the pictures in ComfyUI's input folder. The line
beside the arrows shows the size of the file and, when the size changes, the
size that comes out:
`912 × 1632 → 1144 × 2048`
That is the real output size, worked out before you run.
## Size
**Size** (`size`) picks how the picture is resized. Each choice shows only
the numbers it uses.
| Choice | What it does | Its number |
|---|---|---|
| **off** | The picture comes out exactly as the file has it. | none |
| **megapixels** | Brings the whole picture to a total size and keeps its shape. | **Megapixels** (`megapixels`): 1.0 is about 1024 × 1024 |
| **longest side** | Sets the longer edge; the other edge follows. | **Longest side** (`longest_side`), in pixels |
| **scale** | Multiplies both edges. | **Scale** (`scale`): 0.5 is half, 2 is double |
| **fit inside** | Makes the picture as big as it can be inside a box, with nothing cut off. | **Width** and **Height** (`width`, `height`) |
| **crop to fill** | Covers the box completely and cuts off what sticks out, evenly from both sides. | **Width** and **Height** |
**Round to** (`multiple`) rounds the new width and height to a multiple of
that number. Most models want 8 or 16. Set it to 1 to keep the numbers as
they are. It does nothing while Size is off.
## More
- **Method** (`method`): how pixels are resampled. `lanczos` is the sharpest
for photos. `nearest` keeps pixel art crisp.
- **Enlarge** (`enlarge`): on, a small picture is made bigger to reach the
size you asked for. Off, a picture is only ever made smaller: one that is
already below the target comes out at its own size, and crop to fill cuts
the box's shape out of it without growing it.
## The picture on the node
The picture under the settings is ComfyUI's own preview, the same one the
stock Load Image shows. Drop a file on the node or paste one to upload it,
and right-click the node for the mask editor and for copy and paste.
## Outputs
- `image`: the picture at its new size.
- `mask`: white where the picture is see-through and black where it is
solid, as the stock loader gives it. A picture with no transparency gives
an all black mask. It is always the same size as `image`.
- `width`, `height`: the size that comes out.
- `filename`: the file's name without its folder or its ending
(`shots/hero.png` gives `hero`), ready for a save node's prefix.
- `original_width`, `original_height`: the size of the file before any
resize. Use them to bring a result back to the size you started from.
## Which loader to use
- **Load Image** (this node): load a picture and set its size.
- **Load Image + Pad**: add empty space around a picture for outpainting.
- **Image Crop + Rotate + Pad**: turn, crop and pad a picture by hand.
## Technical details
- Every frame of an animated file comes through as a batch, all at the same
new size. A phone photo is turned the way its EXIF data says.
- With Size off the pixels are the file's own, bit for bit, and Round to is
not applied.
- Rounding moves each edge to the nearest multiple, so the shape can change
by up to half a step. Fit inside rounds down instead, so the result never
leaves its box.
- Crop to fill resizes the picture until it covers the box, then cuts the
middle out. The mask goes through the same resize and the same cut.
- With Enlarge off, a rounded size is rounded down so it never passes the
file's own size.
- The size line on the node uses the same arithmetic as the run. The pack's
tests hold the two to the same answers over a table of cases.
- Files are read only from ComfyUI's input, output and temp folders. A name
such as `photo.png [output]` loads a picture ComfyUI made.
- The node runs again when the file changes on disk or a setting changes.
+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
+1
View File
@@ -17,6 +17,7 @@ help panel.
| Pick a picture size by shape and megapixels, in place of Empty Latent | Latent Size (`AUSBOSS_NODES_Resolution`) |
| Resize a picture, or snap its size to a multiple | Image Resize (`AUSBOSS_NODES_ImageResize`), Align Image (`AUSBOSS_NODES_AlignImage`) |
| Read a picture's width and height | Image Size (`AUSBOSS_NODES_ImageSize`) |
| Load a picture and set its size (megapixels, longest side, a box) in one node | Load Image (`AUSBOSS_NODES_LoadImage`) |
| Outpaint: load a picture and add empty space around it | Load Image + Pad (`AUSBOSS_NODES_LoadImagePad`) |
| Turn, crop and pad a picture in one node | Image Crop + Rotate + Pad (`AUSBOSS_NODES_ImageCropRotatePad`) |
| Inpaint only the masked part, then put it back | Crop For Inpaint (`AUSBOSS_NODES_CropForInpaint`) then Stitch Inpaint (`AUSBOSS_NODES_StitchInpaint`) |
+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();
},
});
+120
View File
@@ -0,0 +1,120 @@
// The row under the file picker on Load Image: arrows that step through the
// input folder, where the file is in the list ("14 / 212"), and the size the
// picture goes in at and comes out at ("912 × 1632 → 1144 × 2048"). The
// widgets underneath stay the source of truth: an arrow only changes the
// image widget's value, the way picking from the list does.
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
import { placeText, planLoadSize, sizeText, stepFile } from "./load_size.mjs";
import { mediaViewQuery } from "./media_list.mjs";
import { FONT_UI, RADIUS_CONTROL, THEME as T } from "./theme.mjs";
import { commitWidgetValue, comboValues } from "./widget_card_math.mjs";
const CSS_ID = "ausboss-load-strip-css";
// Sizes already read, by file: stepping back to a picture shows its size at once.
const SIZES_KEPT = 200;
const sizes = new Map();
function ensureCss() {
if (document.getElementById(CSS_ID)) return;
const style = document.createElement("style");
style.id = CSS_ID;
style.textContent = `
.ausboss-load-strip{display:flex;align-items:center;gap:6px;height:100%;min-width:0;font:500 11px/1 ${FONT_UI};color:${T.label}}
.ausboss-load-step{flex:none;width:26px;height:100%;padding:0;border:1px solid transparent;border-radius:${RADIUS_CONTROL}px;background:${T.fill};color:${T.inkSoft};font:600 11px/1 ${FONT_UI};cursor:pointer}
.ausboss-load-step:hover{background:${T.fillHover};color:${T.inkHover}}
.ausboss-load-step:focus-visible{outline:2px solid ${T.brand};outline-offset:1px}
.ausboss-load-step:disabled{opacity:.4;cursor:default}
.ausboss-load-place{flex:none;color:${T.faint};font-variant-numeric:tabular-nums;white-space:nowrap;user-select:none}
.ausboss-load-size{flex:1 1 0;min-width:0;overflow:hidden;text-align:right;white-space:nowrap;text-overflow:ellipsis;color:${T.inkSoft};font-weight:600;font-variant-numeric:tabular-nums}
.ausboss-load-size b{color:${T.brandInk};font-weight:700}
.ausboss-load-size i{color:${T.faint};font-style:normal;margin:0 5px}
`;
document.head.append(style);
}
const viewUrl = (value) => api.apiURL(`/view?${mediaViewQuery(value)}`);
// The picture's own size, read from the file the way the browser shows it.
function readSize(file, done) {
const known = sizes.get(file);
if (known) return known;
const picture = new Image();
picture.onload = () => {
if (sizes.size >= SIZES_KEPT) sizes.delete(sizes.keys().next().value);
sizes.set(file, { width: picture.naturalWidth, height: picture.naturalHeight });
done();
};
picture.src = viewUrl(file);
return null;
}
export function loadImageStrip(node) {
ensureCss();
const root = document.createElement("div");
root.className = "ausboss-load-strip";
const widget = () => node.widgets?.find((item) => item.name === "image") ?? null;
const linked = () => {
const slot = node.inputs?.find((input) => input?.widget?.name === "image" || input?.name === "image");
return slot?.link != null;
};
let last = {};
const step = (delta, label, title) => {
const button = document.createElement("button");
button.type = "button";
button.className = "ausboss-load-step";
button.textContent = label;
button.title = title;
button.addEventListener("click", () => {
const image = widget();
if (!image) return;
const next = stepFile(comboValues(image), image.value, delta);
if (next === null || next === image.value) return;
commitWidgetValue(node, image, next, app.canvas);
node.setDirtyCanvas?.(true, true);
});
root.append(button);
return button;
};
const back = step(-1, "◀", "The picture before this one in the input folder");
const on = step(1, "▶", "The next picture in the input folder");
const place = document.createElement("span");
place.className = "ausboss-load-place";
place.title = "Where this picture is in the input folder's list";
const size = document.createElement("span");
size.className = "ausboss-load-size";
root.append(place, size);
const sync = (values = last) => {
last = values;
const image = widget();
const files = comboValues(image);
const file = String(values.image ?? "");
place.textContent = placeText(files, file);
const stuck = linked() || stepFile(files, file, 1) === null;
back.disabled = stuck;
on.disabled = stuck;
const source = file ? readSize(file, () => { if (String(last.image ?? "") === file) sync(); }) : null;
size.replaceChildren();
if (!source) {
size.title = "";
return;
}
const plan = planLoadSize(source.width, source.height, values);
const text = sizeText(source.width, source.height, plan);
const [from, to] = text.split(" → ");
size.append(from);
if (to) {
const arrow = document.createElement("i");
arrow.textContent = "→";
const out = document.createElement("b");
out.textContent = to;
size.append(arrow, out);
}
size.title = to
? `The file is ${from}. The picture comes out at ${to}.`
: `The picture comes out at its own size, ${from}.`;
};
return { element: root, sync };
}
+116
View File
@@ -0,0 +1,116 @@
// Load Image: the size a picture comes out at, worked out in the browser so
// the node can show it before a run. This repeats plan_load_size in
// nodes/_load_image_helpers.py; tests/fixtures/load_size_cases.json holds
// both to the same answers. No DOM in here.
export const SIZE_MODES = Object.freeze(["off", "megapixels", "longest side", "scale", "fit inside", "crop to fill"]);
const roundHalfUp = (value) => Math.floor(value + 0.5);
const whole = (value, fallback) => (Number.isFinite(Number(value)) ? Math.trunc(Number(value)) : fallback);
// Nearest multiple, never below one step.
const nearest = (value, step) => Math.max(step, roundHalfUp(value / step) * step);
// The largest multiple that is not above the value, never below one step.
const below = (value, step) => Math.max(step, Math.floor(value / step) * step);
function target(sourceW, sourceH, size, { megapixels, longest_side: longestSide, scale, width, height }) {
const scaled = (factor) => [Math.max(1, roundHalfUp(sourceW * factor)), Math.max(1, roundHalfUp(sourceH * factor))];
if (size === "megapixels") {
const amount = Number(megapixels);
return amount > 0 ? scaled(Math.sqrt((amount * 1e6) / (sourceW * sourceH))) : [sourceW, sourceH];
}
if (size === "longest side") {
const edge = whole(longestSide, 0);
if (edge <= 0) return [sourceW, sourceH];
const [w, h] = scaled(edge / Math.max(sourceW, sourceH));
return sourceW >= sourceH ? [edge, h] : [w, edge];
}
if (size === "scale") {
const factor = Number(scale);
return factor > 0 ? scaled(factor) : [sourceW, sourceH];
}
const fit = Math.min(Math.max(1, whole(width, 1)) / sourceW, Math.max(1, whole(height, 1)) / sourceH);
return scaled(fit);
}
// { resizeWidth, resizeHeight, cropX, cropY, width, height } for a picture of
// sourceWidth x sourceHeight and the node's values, or null when the picture
// has no size yet or the choice is not one the node knows.
export function planLoadSize(sourceWidth, sourceHeight, values = {}) {
const sourceW = whole(sourceWidth, 0);
const sourceH = whole(sourceHeight, 0);
const size = values.size ?? "off";
if (sourceW <= 0 || sourceH <= 0 || !SIZE_MODES.includes(size)) return null;
const same = { resizeWidth: sourceW, resizeHeight: sourceH, cropX: 0, cropY: 0, width: sourceW, height: sourceH };
if (size === "off") return same;
const step = Math.max(1, whole(values.multiple, 1));
const enlarge = values.enlarge !== false;
const boxW = Math.max(1, whole(values.width, 1024));
const boxH = Math.max(1, whole(values.height, 1024));
if (size === "crop to fill") {
let width = boxW;
let height = boxH;
if (enlarge) {
width = nearest(width, step);
height = nearest(height, step);
} else {
const grow = Math.max(width / sourceW, height / sourceH);
if (grow > 1) {
width = Math.max(1, Math.floor(width / grow + 1e-6));
height = Math.max(1, Math.floor(height / grow + 1e-6));
}
width = Math.min(below(width, step), Math.max(step, sourceW));
height = Math.min(below(height, step), Math.max(step, sourceH));
}
const cover = Math.max(width / sourceW, height / sourceH);
const resizeWidth = Math.max(width, Math.ceil(sourceW * cover - 1e-6));
const resizeHeight = Math.max(height, Math.ceil(sourceH * cover - 1e-6));
return {
resizeWidth, resizeHeight,
cropX: Math.floor((resizeWidth - width) / 2), cropY: Math.floor((resizeHeight - height) / 2),
width, height,
};
}
let [targetW, targetH] = target(sourceW, sourceH, size, { ...values, width: boxW, height: boxH });
if (!enlarge && (targetW > sourceW || targetH > sourceH)) [targetW, targetH] = [sourceW, sourceH];
const rounded = (wanted, source, box) => {
let value = nearest(wanted, step);
if (size === "fit inside" && value > box) value = below(box, step);
if (!enlarge && value > source) value = below(source, step);
return value;
};
const width = rounded(targetW, sourceW, boxW);
const height = rounded(targetH, sourceH, boxH);
return { resizeWidth: width, resizeHeight: height, cropX: 0, cropY: 0, width, height };
}
// "912 × 1632" for a picture that comes out as it went in, "912 × 1632 →
// 1144 × 2048" when its size changes, "" while the picture's size is unknown.
export function sizeText(sourceWidth, sourceHeight, plan) {
if (!(sourceWidth > 0 && sourceHeight > 0)) return "";
const from = `${sourceWidth} × ${sourceHeight}`;
if (!plan || (plan.width === sourceWidth && plan.height === sourceHeight)) return from;
return `${from} → ${plan.width} × ${plan.height}`;
}
// The file one step before or after the current one in the list, going
// round at the ends. A file that is not in the list steps to the first or
// the last. null when the list has nothing to step to.
export function stepFile(files, current, delta) {
const list = (Array.isArray(files) ? files : []).filter((name) => name !== "" && name != null);
if (!list.length) return null;
const at = list.indexOf(current);
if (at < 0) return delta < 0 ? list[list.length - 1] : list[0];
if (list.length === 1) return null;
return list[(at + (delta < 0 ? -1 : 1) + list.length) % list.length];
}
// "14 / 212": where the current file is in the list. "" for an empty list,
// "– / 212" for a file that is not in it.
export function placeText(files, current) {
const list = (Array.isArray(files) ? files : []).filter((name) => name !== "" && name != null);
if (!list.length) return "";
const at = list.indexOf(current);
return `${at < 0 ? "–" : at + 1} / ${list.length}`;
}
+49
View File
@@ -37,6 +37,7 @@ export const SOURCE_LOADERS = Object.freeze({
LoadImageOutput: picture("image"),
LoadVideo: video("file"),
AUSBOSS_NODES_ImageCropRotatePad: picture("image"),
AUSBOSS_NODES_LoadImage: picture("image"),
// A wired source_image replaces the file, so the file may stay blank.
AUSBOSS_NODES_LoadImagePad: (inputs) => (linked(inputs?.source_image) ? null : picture("image")(inputs)),
AUSBOSS_NODES_LoadAudio: sound("audio"),
@@ -160,3 +161,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;
}
+50 -4
View File
@@ -57,9 +57,9 @@ export function goesBehind(flying, ask, epoch) {
// The pack's own picture loaders. One of them wired straight into Mask by
// Touch is run by the node itself, the way a press on Show picture runs it:
// it may crop, turn or pad its file, so the file alone is not the picture,
// it may crop, turn, pad or resize its file, so the file alone is not the picture,
// but running it costs next to nothing.
export const OWN_LOADERS = new Set(["AUSBOSS_NODES_ImageCropRotatePad", "AUSBOSS_NODES_LoadImagePad"]);
export const OWN_LOADERS = new Set(["AUSBOSS_NODES_ImageCropRotatePad", "AUSBOSS_NODES_LoadImage", "AUSBOSS_NODES_LoadImagePad"]);
// What a loader will hand on, as far as the page can tell: its file and its
// settings, as one text. Another text means another picture. Widgets that
@@ -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;
};
+23
View File
@@ -12,6 +12,7 @@ import { formatWidgetVisibility } from "../shared/save_video_formats.mjs";
import { hideInputsInDef } from "../shared/widget_visibility.mjs";
import { castChips } from "../shared/h3_cast_chips.mjs";
import { clipSetsDuration } from "../shared/h3_duration.mjs";
import { loadImageStrip } from "../shared/load_image_strip.mjs";
import { momentStrip } from "../shared/moment_strip.mjs";
import { mountWidgetCard } from "../shared/widget_card.mjs";
import { FONT_UI, THEME } from "../shared/theme.mjs";
@@ -400,6 +401,28 @@ const CARDS = {
title: "The longest the sound may be. Anything past it is cut off and shows in red. 0 means no limit." },
],
},
// Load Image: the file, then where it is in the folder and the size it
// goes in and comes out at, then the size settings. ComfyUI's own picture
// preview sits under the card, so its mask editor and copy and paste work
// as they do on the stock loader.
AUSBOSS_NODES_LoadImage: {
minWidth: 320, first: true, hide: ["upload"],
rows: [
{ widget: "image", label: "Source", kind: "select", preview: { kind: "image", url: viewUrl },
button: { text: PANEL_LABELS.load, title: "Upload a picture into ComfyUI's input folder", onClick: (node) => node.widgets?.find((w) => w.name === "upload")?.callback?.() } },
{ panel: loadImageStrip, height: 26 },
{ widget: "size", label: "Size", kind: "select" },
{ widget: "megapixels", label: "Megapixels", suffix: "MP", when: isMode("size", "megapixels") },
{ widget: "longest_side", label: "Longest side", suffix: "px", when: isMode("size", "longest side") },
{ widget: "scale", label: "Scale", suffix: "\u00d7", when: isMode("size", "scale") },
{ widget: "width", label: "Width", suffix: "px", when: isMode("size", "fit inside", "crop to fill") },
{ widget: "height", label: "Height", suffix: "px", when: isMode("size", "fit inside", "crop to fill") },
{ widget: "multiple", label: "Round to", snap: true, step: 8, when: (values) => values.size !== "off" },
{ group: "more", label: "More" },
{ widget: "method", label: "Method", kind: "select", group: "more" },
{ widget: "enlarge", label: "Enlarge", group: "more" },
],
},
AUSBOSS_NODES_LoadImagePad: {
minWidth: 340, first: true, hide: ["upload"],
rows: [
+59
View File
@@ -593,6 +593,65 @@
}
}
},
"AUSBOSS_NODES_LoadImage": {
"display_name": "加载图像 🆎",
"description": "在同一个节点里加载图片并设定尺寸。可以选择文件、上传,或用箭头逐张浏览输入文件夹。尺寸可以关闭,也可以按百万像素、最长边、缩放倍数、放入一个框内,或裁剪填满一个框来调整。运行之前,节点就会显示输入尺寸和输出尺寸。",
"inputs": {
"image": {
"name": "图像"
},
"size": {
"name": "尺寸"
},
"megapixels": {
"name": "百万像素"
},
"longest_side": {
"name": "最长边"
},
"scale": {
"name": "缩放倍数"
},
"width": {
"name": "宽度"
},
"height": {
"name": "高度"
},
"multiple": {
"name": "取整倍数"
},
"method": {
"name": "插值"
},
"enlarge": {
"name": "放大"
}
},
"outputs": {
"0": {
"name": "图像"
},
"1": {
"name": "遮罩"
},
"2": {
"name": "宽度"
},
"3": {
"name": "高度"
},
"4": {
"name": "文件名"
},
"5": {
"name": "原始宽度"
},
"6": {
"name": "原始高度"
}
}
},
"AUSBOSS_NODES_LoadImagePad": {
"display_name": "加载图像 + 扩边 🆎",
"description": "在一个节点里加载图像并扩边成扩图画布。拖动节点上画布的任意一边,直观地设置每边的扩边量;遮罩正好覆盖新扩出的部分,可选择向内羽化跨过接缝。画布会取整到整数倍,但不会给你没扩的边加条带;设置百万像素目标时会先缩放原图,让遮罩接缝保持清晰。",
+165
View File
@@ -0,0 +1,165 @@
"""Load Image: the size a picture comes out at, as plain numbers.
``plan_load_size`` turns the node's size choice into what the picture is
resized to and what is cut from it. It holds no tensors, so it is tested as
numbers, and ``js/shared/load_size.mjs`` repeats it for the size the node
shows before a run (``tests/fixtures/load_size_cases.json`` holds both to the
same answers). ``apply_load_size`` runs a plan on a picture and its mask.
"""
from __future__ import annotations
import math
import numpy as np
import torch
from ._resize_helpers import plan_resize, resample_batch, resolve_target, snap_to_multiple
SIZE_MODES = (
"off",
"megapixels",
"longest side",
"scale",
"fit inside",
"crop to fill",
)
def _round_half_up(value: float) -> int:
return int(math.floor(value + 0.5))
def _multiple_below(value: int, multiple: int) -> int:
"""The largest multiple that is not above ``value``, never below one step."""
step = max(1, int(multiple))
return max(step, (int(value) // step) * step)
def plan_load_size(
source_width: int,
source_height: int,
size: str = "off",
megapixels: float = 1.0,
longest_side: int = 1024,
scale: float = 1.0,
width: int = 1024,
height: int = 1024,
multiple: int = 1,
enlarge: bool = True,
) -> dict[str, int]:
"""What one picture is resized to (``resize_width``/``resize_height``),
where a crop starts in the resized picture (``crop_x``/``crop_y``) and
the size that comes out (``width``/``height``).
``off`` gives the picture back as it is; ``multiple`` rounds only a size
that is being changed. With ``enlarge`` off nothing comes out larger than
the picture: a target above it leaves the picture at its own size, and
``crop to fill`` cuts the box's shape out of the picture without growing
it. ``fit inside`` rounds down, so the result never leaves its box.
"""
source_w, source_h = int(source_width), int(source_height)
if source_w <= 0 or source_h <= 0:
raise ValueError("Load Image needs a picture larger than 0x0.")
if size not in SIZE_MODES:
raise ValueError(f"size must be one of {SIZE_MODES}, got '{size}'.")
if size == "off":
return {
"resize_width": source_w,
"resize_height": source_h,
"crop_x": 0,
"crop_y": 0,
"width": source_w,
"height": source_h,
}
step = max(1, int(multiple))
if size == "crop to fill":
box_w, box_h = max(1, int(width)), max(1, int(height))
if not enlarge:
grow = max(box_w / source_w, box_h / source_h)
if grow > 1.0:
# The box keeps its shape and shrinks to what the picture holds.
box_w = max(1, int(math.floor(box_w / grow + 1e-6)))
box_h = max(1, int(math.floor(box_h / grow + 1e-6)))
canvas_w = min(_multiple_below(box_w, step), max(step, source_w))
canvas_h = min(_multiple_below(box_h, step), max(step, source_h))
else:
canvas_w = snap_to_multiple(box_w, step)
canvas_h = snap_to_multiple(box_h, step)
plan = plan_resize(source_w, source_h, canvas_w, canvas_h, "cover_crop", 1)
return {
"resize_width": plan["resize_width"],
"resize_height": plan["resize_height"],
"crop_x": plan["crop_x"],
"crop_y": plan["crop_y"],
"width": canvas_w,
"height": canvas_h,
}
if size == "megapixels":
target_w, target_h = resolve_target(source_w, source_h, "megapixels", megapixels=megapixels)
elif size == "longest side":
target_w, target_h = resolve_target(source_w, source_h, "longest_edge", edge_length=longest_side)
elif size == "scale":
target_w, target_h = resolve_target(source_w, source_h, "scale_factor", scale_factor=scale)
else: # fit inside
fit = min(max(1, int(width)) / source_w, max(1, int(height)) / source_h)
target_w = max(1, _round_half_up(source_w * fit))
target_h = max(1, _round_half_up(source_h * fit))
if not enlarge and (target_w > source_w or target_h > source_h):
target_w, target_h = source_w, source_h
def rounded(target: int, source: int, box: int) -> int:
value = snap_to_multiple(target, step)
if size == "fit inside" and value > box:
value = _multiple_below(box, step)
if not enlarge and value > source:
value = _multiple_below(source, step)
return value
out_w = rounded(target_w, source_w, max(1, int(width)))
out_h = rounded(target_h, source_h, max(1, int(height)))
return {
"resize_width": out_w,
"resize_height": out_h,
"crop_x": 0,
"crop_y": 0,
"width": out_w,
"height": out_h,
}
def frames_to_image_and_mask(frames) -> tuple[torch.Tensor, torch.Tensor]:
"""RGBA PIL frames to a BHWC RGB batch and a BHW mask.
The mask is what ComfyUI's own Load Image gives: white where the picture
is see-through, black where it is solid, and all black for a picture
with no transparency (here at the picture's own size).
"""
images, masks = [], []
for frame in frames:
array = np.asarray(frame.convert("RGBA"), dtype=np.float32) / 255.0
images.append(torch.from_numpy(array[..., :3].copy()))
masks.append(torch.from_numpy(1.0 - array[..., 3]))
return torch.stack(images, dim=0), torch.stack(masks, dim=0)
def apply_load_size(
image: torch.Tensor, mask: torch.Tensor, plan: dict[str, int], method: str
) -> tuple[torch.Tensor, torch.Tensor]:
"""Run a plan on a BHWC picture and its BHW mask. A plan that changes
nothing hands both back untouched."""
resize_w, resize_h = int(plan["resize_width"]), int(plan["resize_height"])
out_w, out_h = int(plan["width"]), int(plan["height"])
if (int(image.shape[2]), int(image.shape[1])) != (resize_w, resize_h):
image = resample_batch(image, resize_w, resize_h, method)
mask = resample_batch(mask.unsqueeze(-1), resize_w, resize_h, method).squeeze(-1).clamp(0.0, 1.0)
if (resize_w, resize_h) != (out_w, out_h):
x0, y0 = int(plan["crop_x"]), int(plan["crop_y"])
image = image[:, y0 : y0 + out_h, x0 : x0 + out_w, :].contiguous()
mask = mask[:, y0 : y0 + out_h, x0 : x0 + out_w].contiguous()
return image, mask
__all__ = ["SIZE_MODES", "apply_load_size", "frames_to_image_and_mask", "plan_load_size"]
+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
+230
View File
@@ -0,0 +1,230 @@
"""Load Image 🆎."""
from __future__ import annotations
from ._execution_helpers import ignore_extra_inputs
from ._load_image_helpers import (
SIZE_MODES,
apply_load_size,
frames_to_image_and_mask,
plan_load_size,
)
from ._media_helpers import list_input_images, load_image_frames, resolve_input_path
from ._resize_helpers import INTERPOLATION_MODES
from ._transform_engine import stable_file_fingerprint
class AusBossLoadImage:
CATEGORY = "🆎 AusBoss/Image"
DESCRIPTION = (
"Loads a picture and sets its size in the same node. Pick a file, "
"upload one, or step through the input folder with the arrows. "
"Size can stay off, or bring the picture to a megapixel count, a "
"longest side, a scale, a box it must fit inside, or a box it fills "
"by cropping. The node shows the size going in and the size coming "
"out before you run."
)
SEARCH_ALIASES = [
"load image",
"load picture",
"image loader",
"resize on load",
"megapixels",
"longest side",
"ausboss",
]
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": (
list_input_images(),
{
"image_upload": True,
"tooltip": "The picture to load. Pick one from ComfyUI's input folder or upload a new one.",
},
),
"size": (
list(SIZE_MODES),
{
"default": "off",
"tooltip": (
"How the picture is resized. off keeps it as it "
"is. megapixels, longest side and scale keep its "
"shape. fit inside shrinks or grows it to sit in "
"a box. crop to fill covers the box and cuts off "
"what sticks out."
),
},
),
"megapixels": (
"FLOAT",
{
"default": 1.0,
"min": 0.01,
"max": 64.0,
"step": 0.01,
"tooltip": "For megapixels: the picture's total size. 1.0 is about 1024 x 1024.",
},
),
"longest_side": (
"INT",
{
"default": 1024,
"min": 16,
"max": 16384,
"step": 1,
"tooltip": "For longest side: the length of the longer edge, in pixels. The other edge follows.",
},
),
"scale": (
"FLOAT",
{
"default": 1.0,
"min": 0.05,
"max": 8.0,
"step": 0.01,
"tooltip": "For scale: 0.5 is half the size, 2 is double.",
},
),
"width": (
"INT",
{
"default": 1024,
"min": 16,
"max": 16384,
"step": 1,
"tooltip": "For fit inside and crop to fill: the box's width in pixels.",
},
),
"height": (
"INT",
{
"default": 1024,
"min": 16,
"max": 16384,
"step": 1,
"tooltip": "For fit inside and crop to fill: the box's height in pixels.",
},
),
"multiple": (
"INT",
{
"default": 8,
"min": 1,
"max": 256,
"step": 1,
"tooltip": (
"Rounds the new width and height to a multiple of "
"this. Most models want 8 or 16. 1 leaves the "
"numbers as they are. Not used while size is off."
),
},
),
"method": (
list(INTERPOLATION_MODES),
{
"default": "lanczos",
"tooltip": "How pixels are resampled. lanczos is the sharpest for photos; nearest keeps pixel art crisp.",
},
),
"enlarge": (
"BOOLEAN",
{
"default": True,
"tooltip": (
"On: a small picture is made bigger to reach the "
"size you asked for. Off: a picture is only ever "
"made smaller, and a small one comes out at its "
"own size."
),
},
),
},
}
RETURN_TYPES = ("IMAGE", "MASK", "INT", "INT", "STRING", "INT", "INT")
RETURN_NAMES = (
"image",
"mask",
"width",
"height",
"filename",
"original_width",
"original_height",
)
OUTPUT_TOOLTIPS = (
"The picture, at its new size.",
"White where the picture is see-through, black where it is solid. "
"All black for a picture with no transparency. Same size as the picture.",
"Width of the picture that comes out, in pixels.",
"Height of the picture that comes out, in pixels.",
"The file's name without its folder or its ending, ready for a save "
"node's prefix.",
"Width of the file before any resize.",
"Height of the file before any resize.",
)
FUNCTION = "load"
def load(
self,
image,
size="off",
megapixels=1.0,
longest_side=1024,
scale=1.0,
width=1024,
height=1024,
multiple=8,
method="lanczos",
enlarge=True,
):
path = resolve_input_path(image)
picture, mask = frames_to_image_and_mask(load_image_frames(path))
source_w, source_h = int(picture.shape[2]), int(picture.shape[1])
plan = plan_load_size(
source_w,
source_h,
str(size),
float(megapixels),
int(longest_side),
float(scale),
int(width),
int(height),
int(multiple),
bool(enlarge),
)
picture, mask = apply_load_size(picture, mask, plan, str(method))
return (
picture,
mask,
int(plan["width"]),
int(plan["height"]),
path.stem,
source_w,
source_h,
)
@classmethod
@ignore_extra_inputs
def VALIDATE_INPUTS(cls, image):
try:
resolve_input_path(image)
except Exception as exc:
return f"Load Image: {exc}"
return True
@classmethod
def IS_CHANGED(cls, image, **values):
try:
path = resolve_input_path(image)
except Exception:
path = image or ""
return stable_file_fingerprint(path, {"image": image, **values})
NODE_CLASS_MAPPINGS = {"AUSBOSS_NODES_LoadImage": AusBossLoadImage}
NODE_DISPLAY_NAME_MAPPINGS = {"AUSBOSS_NODES_LoadImage": "Load Image 🆎"}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+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
+1
View File
@@ -70,6 +70,7 @@ PUBLIC_NODE_IDS = {
"AUSBOSS_NODES_LaMaInpaint",
"AUSBOSS_NODES_LayoutBoxes",
"AUSBOSS_NODES_LoadAudio",
"AUSBOSS_NODES_LoadImage",
"AUSBOSS_NODES_LoadImagePad",
"AUSBOSS_NODES_LoadVideo",
"AUSBOSS_NODES_LoraLoader",
+256
View File
@@ -0,0 +1,256 @@
{"about":"Load Image size plans. nodes/_load_image_helpers.py made these; js/shared/load_size.mjs must give the same.",
"columns":["source_width", "source_height", "size", "megapixels", "longest_side", "scale", "width", "height", "multiple", "enlarge", "resize_width", "resize_height", "crop_x", "crop_y", "out_width", "out_height"],
"rows":[
[912,1632,"off",1.0,1024,1.0,1024,1024,1,true,912,1632,0,0,912,1632],
[912,1632,"off",1.0,1024,1.0,1024,1024,16,true,912,1632,0,0,912,1632],
[912,1632,"megapixels",0.25,1024,1.0,1024,1024,1,true,374,669,0,0,374,669],
[912,1632,"megapixels",2.0,1024,1.0,1024,1024,1,true,1057,1892,0,0,1057,1892],
[912,1632,"longest side",1.0,1000,1.0,1024,1024,1,true,559,1000,0,0,559,1000],
[912,1632,"longest side",1.0,2048,1.0,1024,1024,1,true,1144,2048,0,0,1144,2048],
[912,1632,"scale",1.0,1024,0.33,1024,1024,1,true,301,539,0,0,301,539],
[912,1632,"scale",1.0,1024,1.5,1024,1024,1,true,1368,2448,0,0,1368,2448],
[912,1632,"fit inside",1.0,1024,1.0,1024,1024,1,true,572,1024,0,0,572,1024],
[912,1632,"crop to fill",1.0,1024,1.0,1024,1024,1,true,1024,1833,0,404,1024,1024],
[912,1632,"fit inside",1.0,1024,1.0,1280,720,1,true,402,720,0,0,402,720],
[912,1632,"crop to fill",1.0,1024,1.0,1280,720,1,true,1280,2291,0,785,1280,720],
[912,1632,"fit inside",1.0,1024,1.0,1000,600,1,true,335,600,0,0,335,600],
[912,1632,"crop to fill",1.0,1024,1.0,1000,600,1,true,1000,1790,0,595,1000,600],
[912,1632,"megapixels",0.25,1024,1.0,1024,1024,1,false,374,669,0,0,374,669],
[912,1632,"megapixels",2.0,1024,1.0,1024,1024,1,false,912,1632,0,0,912,1632],
[912,1632,"longest side",1.0,1000,1.0,1024,1024,1,false,559,1000,0,0,559,1000],
[912,1632,"longest side",1.0,2048,1.0,1024,1024,1,false,912,1632,0,0,912,1632],
[912,1632,"scale",1.0,1024,0.33,1024,1024,1,false,301,539,0,0,301,539],
[912,1632,"scale",1.0,1024,1.5,1024,1024,1,false,912,1632,0,0,912,1632],
[912,1632,"fit inside",1.0,1024,1.0,1024,1024,1,false,572,1024,0,0,572,1024],
[912,1632,"crop to fill",1.0,1024,1.0,1024,1024,1,false,912,1632,0,360,912,912],
[912,1632,"fit inside",1.0,1024,1.0,1280,720,1,false,402,720,0,0,402,720],
[912,1632,"crop to fill",1.0,1024,1.0,1280,720,1,false,912,1632,0,559,912,513],
[912,1632,"fit inside",1.0,1024,1.0,1000,600,1,false,335,600,0,0,335,600],
[912,1632,"crop to fill",1.0,1024,1.0,1000,600,1,false,912,1632,0,542,912,547],
[912,1632,"megapixels",0.25,1024,1.0,1024,1024,16,true,368,672,0,0,368,672],
[912,1632,"megapixels",2.0,1024,1.0,1024,1024,16,true,1056,1888,0,0,1056,1888],
[912,1632,"longest side",1.0,1000,1.0,1024,1024,16,true,560,1008,0,0,560,1008],
[912,1632,"longest side",1.0,2048,1.0,1024,1024,16,true,1152,2048,0,0,1152,2048],
[912,1632,"scale",1.0,1024,0.33,1024,1024,16,true,304,544,0,0,304,544],
[912,1632,"scale",1.0,1024,1.5,1024,1024,16,true,1376,2448,0,0,1376,2448],
[912,1632,"fit inside",1.0,1024,1.0,1024,1024,16,true,576,1024,0,0,576,1024],
[912,1632,"crop to fill",1.0,1024,1.0,1024,1024,16,true,1024,1833,0,404,1024,1024],
[912,1632,"fit inside",1.0,1024,1.0,1280,720,16,true,400,720,0,0,400,720],
[912,1632,"crop to fill",1.0,1024,1.0,1280,720,16,true,1280,2291,0,785,1280,720],
[912,1632,"fit inside",1.0,1024,1.0,1000,600,16,true,336,592,0,0,336,592],
[912,1632,"crop to fill",1.0,1024,1.0,1000,600,16,true,1008,1804,0,598,1008,608],
[912,1632,"megapixels",0.25,1024,1.0,1024,1024,16,false,368,672,0,0,368,672],
[912,1632,"megapixels",2.0,1024,1.0,1024,1024,16,false,912,1632,0,0,912,1632],
[912,1632,"longest side",1.0,1000,1.0,1024,1024,16,false,560,1008,0,0,560,1008],
[912,1632,"longest side",1.0,2048,1.0,1024,1024,16,false,912,1632,0,0,912,1632],
[912,1632,"scale",1.0,1024,0.33,1024,1024,16,false,304,544,0,0,304,544],
[912,1632,"scale",1.0,1024,1.5,1024,1024,16,false,912,1632,0,0,912,1632],
[912,1632,"fit inside",1.0,1024,1.0,1024,1024,16,false,576,1024,0,0,576,1024],
[912,1632,"crop to fill",1.0,1024,1.0,1024,1024,16,false,912,1632,0,360,912,912],
[912,1632,"fit inside",1.0,1024,1.0,1280,720,16,false,400,720,0,0,400,720],
[912,1632,"crop to fill",1.0,1024,1.0,1280,720,16,false,912,1632,0,560,912,512],
[912,1632,"fit inside",1.0,1024,1.0,1000,600,16,false,336,592,0,0,336,592],
[912,1632,"crop to fill",1.0,1024,1.0,1000,600,16,false,912,1632,0,544,912,544],
[1920,1080,"off",1.0,1024,1.0,1024,1024,1,true,1920,1080,0,0,1920,1080],
[1920,1080,"off",1.0,1024,1.0,1024,1024,16,true,1920,1080,0,0,1920,1080],
[1920,1080,"megapixels",0.25,1024,1.0,1024,1024,1,true,667,375,0,0,667,375],
[1920,1080,"megapixels",2.0,1024,1.0,1024,1024,1,true,1886,1061,0,0,1886,1061],
[1920,1080,"longest side",1.0,1000,1.0,1024,1024,1,true,1000,563,0,0,1000,563],
[1920,1080,"longest side",1.0,2048,1.0,1024,1024,1,true,2048,1152,0,0,2048,1152],
[1920,1080,"scale",1.0,1024,0.33,1024,1024,1,true,634,356,0,0,634,356],
[1920,1080,"scale",1.0,1024,1.5,1024,1024,1,true,2880,1620,0,0,2880,1620],
[1920,1080,"fit inside",1.0,1024,1.0,1024,1024,1,true,1024,576,0,0,1024,576],
[1920,1080,"crop to fill",1.0,1024,1.0,1024,1024,1,true,1821,1024,398,0,1024,1024],
[1920,1080,"fit inside",1.0,1024,1.0,1280,720,1,true,1280,720,0,0,1280,720],
[1920,1080,"crop to fill",1.0,1024,1.0,1280,720,1,true,1280,720,0,0,1280,720],
[1920,1080,"fit inside",1.0,1024,1.0,1000,600,1,true,1000,563,0,0,1000,563],
[1920,1080,"crop to fill",1.0,1024,1.0,1000,600,1,true,1067,600,33,0,1000,600],
[1920,1080,"megapixels",0.25,1024,1.0,1024,1024,1,false,667,375,0,0,667,375],
[1920,1080,"megapixels",2.0,1024,1.0,1024,1024,1,false,1886,1061,0,0,1886,1061],
[1920,1080,"longest side",1.0,1000,1.0,1024,1024,1,false,1000,563,0,0,1000,563],
[1920,1080,"longest side",1.0,2048,1.0,1024,1024,1,false,1920,1080,0,0,1920,1080],
[1920,1080,"scale",1.0,1024,0.33,1024,1024,1,false,634,356,0,0,634,356],
[1920,1080,"scale",1.0,1024,1.5,1024,1024,1,false,1920,1080,0,0,1920,1080],
[1920,1080,"fit inside",1.0,1024,1.0,1024,1024,1,false,1024,576,0,0,1024,576],
[1920,1080,"crop to fill",1.0,1024,1.0,1024,1024,1,false,1821,1024,398,0,1024,1024],
[1920,1080,"fit inside",1.0,1024,1.0,1280,720,1,false,1280,720,0,0,1280,720],
[1920,1080,"crop to fill",1.0,1024,1.0,1280,720,1,false,1280,720,0,0,1280,720],
[1920,1080,"fit inside",1.0,1024,1.0,1000,600,1,false,1000,563,0,0,1000,563],
[1920,1080,"crop to fill",1.0,1024,1.0,1000,600,1,false,1067,600,33,0,1000,600],
[1920,1080,"megapixels",0.25,1024,1.0,1024,1024,16,true,672,368,0,0,672,368],
[1920,1080,"megapixels",2.0,1024,1.0,1024,1024,16,true,1888,1056,0,0,1888,1056],
[1920,1080,"longest side",1.0,1000,1.0,1024,1024,16,true,1008,560,0,0,1008,560],
[1920,1080,"longest side",1.0,2048,1.0,1024,1024,16,true,2048,1152,0,0,2048,1152],
[1920,1080,"scale",1.0,1024,0.33,1024,1024,16,true,640,352,0,0,640,352],
[1920,1080,"scale",1.0,1024,1.5,1024,1024,16,true,2880,1616,0,0,2880,1616],
[1920,1080,"fit inside",1.0,1024,1.0,1024,1024,16,true,1024,576,0,0,1024,576],
[1920,1080,"crop to fill",1.0,1024,1.0,1024,1024,16,true,1821,1024,398,0,1024,1024],
[1920,1080,"fit inside",1.0,1024,1.0,1280,720,16,true,1280,720,0,0,1280,720],
[1920,1080,"crop to fill",1.0,1024,1.0,1280,720,16,true,1280,720,0,0,1280,720],
[1920,1080,"fit inside",1.0,1024,1.0,1000,600,16,true,992,560,0,0,992,560],
[1920,1080,"crop to fill",1.0,1024,1.0,1000,600,16,true,1081,608,36,0,1008,608],
[1920,1080,"megapixels",0.25,1024,1.0,1024,1024,16,false,672,368,0,0,672,368],
[1920,1080,"megapixels",2.0,1024,1.0,1024,1024,16,false,1888,1056,0,0,1888,1056],
[1920,1080,"longest side",1.0,1000,1.0,1024,1024,16,false,1008,560,0,0,1008,560],
[1920,1080,"longest side",1.0,2048,1.0,1024,1024,16,false,1920,1072,0,0,1920,1072],
[1920,1080,"scale",1.0,1024,0.33,1024,1024,16,false,640,352,0,0,640,352],
[1920,1080,"scale",1.0,1024,1.5,1024,1024,16,false,1920,1072,0,0,1920,1072],
[1920,1080,"fit inside",1.0,1024,1.0,1024,1024,16,false,1024,576,0,0,1024,576],
[1920,1080,"crop to fill",1.0,1024,1.0,1024,1024,16,false,1821,1024,398,0,1024,1024],
[1920,1080,"fit inside",1.0,1024,1.0,1280,720,16,false,1280,720,0,0,1280,720],
[1920,1080,"crop to fill",1.0,1024,1.0,1280,720,16,false,1280,720,0,0,1280,720],
[1920,1080,"fit inside",1.0,1024,1.0,1000,600,16,false,992,560,0,0,992,560],
[1920,1080,"crop to fill",1.0,1024,1.0,1000,600,16,false,1053,592,30,0,992,592],
[640,480,"off",1.0,1024,1.0,1024,1024,1,true,640,480,0,0,640,480],
[640,480,"off",1.0,1024,1.0,1024,1024,16,true,640,480,0,0,640,480],
[640,480,"megapixels",0.25,1024,1.0,1024,1024,1,true,577,433,0,0,577,433],
[640,480,"megapixels",2.0,1024,1.0,1024,1024,1,true,1633,1225,0,0,1633,1225],
[640,480,"longest side",1.0,1000,1.0,1024,1024,1,true,1000,750,0,0,1000,750],
[640,480,"longest side",1.0,2048,1.0,1024,1024,1,true,2048,1536,0,0,2048,1536],
[640,480,"scale",1.0,1024,0.33,1024,1024,1,true,211,158,0,0,211,158],
[640,480,"scale",1.0,1024,1.5,1024,1024,1,true,960,720,0,0,960,720],
[640,480,"fit inside",1.0,1024,1.0,1024,1024,1,true,1024,768,0,0,1024,768],
[640,480,"crop to fill",1.0,1024,1.0,1024,1024,1,true,1366,1024,171,0,1024,1024],
[640,480,"fit inside",1.0,1024,1.0,1280,720,1,true,960,720,0,0,960,720],
[640,480,"crop to fill",1.0,1024,1.0,1280,720,1,true,1280,960,0,120,1280,720],
[640,480,"fit inside",1.0,1024,1.0,1000,600,1,true,800,600,0,0,800,600],
[640,480,"crop to fill",1.0,1024,1.0,1000,600,1,true,1000,750,0,75,1000,600],
[640,480,"megapixels",0.25,1024,1.0,1024,1024,1,false,577,433,0,0,577,433],
[640,480,"megapixels",2.0,1024,1.0,1024,1024,1,false,640,480,0,0,640,480],
[640,480,"longest side",1.0,1000,1.0,1024,1024,1,false,640,480,0,0,640,480],
[640,480,"longest side",1.0,2048,1.0,1024,1024,1,false,640,480,0,0,640,480],
[640,480,"scale",1.0,1024,0.33,1024,1024,1,false,211,158,0,0,211,158],
[640,480,"scale",1.0,1024,1.5,1024,1024,1,false,640,480,0,0,640,480],
[640,480,"fit inside",1.0,1024,1.0,1024,1024,1,false,640,480,0,0,640,480],
[640,480,"crop to fill",1.0,1024,1.0,1024,1024,1,false,640,480,80,0,480,480],
[640,480,"fit inside",1.0,1024,1.0,1280,720,1,false,640,480,0,0,640,480],
[640,480,"crop to fill",1.0,1024,1.0,1280,720,1,false,640,480,0,60,640,360],
[640,480,"fit inside",1.0,1024,1.0,1000,600,1,false,640,480,0,0,640,480],
[640,480,"crop to fill",1.0,1024,1.0,1000,600,1,false,640,480,0,48,640,384],
[640,480,"megapixels",0.25,1024,1.0,1024,1024,16,true,576,432,0,0,576,432],
[640,480,"megapixels",2.0,1024,1.0,1024,1024,16,true,1632,1232,0,0,1632,1232],
[640,480,"longest side",1.0,1000,1.0,1024,1024,16,true,1008,752,0,0,1008,752],
[640,480,"longest side",1.0,2048,1.0,1024,1024,16,true,2048,1536,0,0,2048,1536],
[640,480,"scale",1.0,1024,0.33,1024,1024,16,true,208,160,0,0,208,160],
[640,480,"scale",1.0,1024,1.5,1024,1024,16,true,960,720,0,0,960,720],
[640,480,"fit inside",1.0,1024,1.0,1024,1024,16,true,1024,768,0,0,1024,768],
[640,480,"crop to fill",1.0,1024,1.0,1024,1024,16,true,1366,1024,171,0,1024,1024],
[640,480,"fit inside",1.0,1024,1.0,1280,720,16,true,960,720,0,0,960,720],
[640,480,"crop to fill",1.0,1024,1.0,1280,720,16,true,1280,960,0,120,1280,720],
[640,480,"fit inside",1.0,1024,1.0,1000,600,16,true,800,592,0,0,800,592],
[640,480,"crop to fill",1.0,1024,1.0,1000,600,16,true,1008,756,0,74,1008,608],
[640,480,"megapixels",0.25,1024,1.0,1024,1024,16,false,576,432,0,0,576,432],
[640,480,"megapixels",2.0,1024,1.0,1024,1024,16,false,640,480,0,0,640,480],
[640,480,"longest side",1.0,1000,1.0,1024,1024,16,false,640,480,0,0,640,480],
[640,480,"longest side",1.0,2048,1.0,1024,1024,16,false,640,480,0,0,640,480],
[640,480,"scale",1.0,1024,0.33,1024,1024,16,false,208,160,0,0,208,160],
[640,480,"scale",1.0,1024,1.5,1024,1024,16,false,640,480,0,0,640,480],
[640,480,"fit inside",1.0,1024,1.0,1024,1024,16,false,640,480,0,0,640,480],
[640,480,"crop to fill",1.0,1024,1.0,1024,1024,16,false,640,480,80,0,480,480],
[640,480,"fit inside",1.0,1024,1.0,1280,720,16,false,640,480,0,0,640,480],
[640,480,"crop to fill",1.0,1024,1.0,1280,720,16,false,640,480,0,64,640,352],
[640,480,"fit inside",1.0,1024,1.0,1000,600,16,false,640,480,0,0,640,480],
[640,480,"crop to fill",1.0,1024,1.0,1000,600,16,false,640,480,0,48,640,384],
[333,777,"off",1.0,1024,1.0,1024,1024,1,true,333,777,0,0,333,777],
[333,777,"off",1.0,1024,1.0,1024,1024,16,true,333,777,0,0,333,777],
[333,777,"megapixels",0.25,1024,1.0,1024,1024,1,true,327,764,0,0,327,764],
[333,777,"megapixels",2.0,1024,1.0,1024,1024,1,true,926,2160,0,0,926,2160],
[333,777,"longest side",1.0,1000,1.0,1024,1024,1,true,429,1000,0,0,429,1000],
[333,777,"longest side",1.0,2048,1.0,1024,1024,1,true,878,2048,0,0,878,2048],
[333,777,"scale",1.0,1024,0.33,1024,1024,1,true,110,256,0,0,110,256],
[333,777,"scale",1.0,1024,1.5,1024,1024,1,true,500,1166,0,0,500,1166],
[333,777,"fit inside",1.0,1024,1.0,1024,1024,1,true,439,1024,0,0,439,1024],
[333,777,"crop to fill",1.0,1024,1.0,1024,1024,1,true,1024,2390,0,683,1024,1024],
[333,777,"fit inside",1.0,1024,1.0,1280,720,1,true,309,720,0,0,309,720],
[333,777,"crop to fill",1.0,1024,1.0,1280,720,1,true,1280,2987,0,1133,1280,720],
[333,777,"fit inside",1.0,1024,1.0,1000,600,1,true,257,600,0,0,257,600],
[333,777,"crop to fill",1.0,1024,1.0,1000,600,1,true,1000,2334,0,867,1000,600],
[333,777,"megapixels",0.25,1024,1.0,1024,1024,1,false,327,764,0,0,327,764],
[333,777,"megapixels",2.0,1024,1.0,1024,1024,1,false,333,777,0,0,333,777],
[333,777,"longest side",1.0,1000,1.0,1024,1024,1,false,333,777,0,0,333,777],
[333,777,"longest side",1.0,2048,1.0,1024,1024,1,false,333,777,0,0,333,777],
[333,777,"scale",1.0,1024,0.33,1024,1024,1,false,110,256,0,0,110,256],
[333,777,"scale",1.0,1024,1.5,1024,1024,1,false,333,777,0,0,333,777],
[333,777,"fit inside",1.0,1024,1.0,1024,1024,1,false,333,777,0,0,333,777],
[333,777,"crop to fill",1.0,1024,1.0,1024,1024,1,false,333,777,0,222,333,333],
[333,777,"fit inside",1.0,1024,1.0,1280,720,1,false,309,720,0,0,309,720],
[333,777,"crop to fill",1.0,1024,1.0,1280,720,1,false,333,777,0,295,333,187],
[333,777,"fit inside",1.0,1024,1.0,1000,600,1,false,257,600,0,0,257,600],
[333,777,"crop to fill",1.0,1024,1.0,1000,600,1,false,333,777,0,289,333,199],
[333,777,"megapixels",0.25,1024,1.0,1024,1024,16,true,320,768,0,0,320,768],
[333,777,"megapixels",2.0,1024,1.0,1024,1024,16,true,928,2160,0,0,928,2160],
[333,777,"longest side",1.0,1000,1.0,1024,1024,16,true,432,1008,0,0,432,1008],
[333,777,"longest side",1.0,2048,1.0,1024,1024,16,true,880,2048,0,0,880,2048],
[333,777,"scale",1.0,1024,0.33,1024,1024,16,true,112,256,0,0,112,256],
[333,777,"scale",1.0,1024,1.5,1024,1024,16,true,496,1168,0,0,496,1168],
[333,777,"fit inside",1.0,1024,1.0,1024,1024,16,true,432,1024,0,0,432,1024],
[333,777,"crop to fill",1.0,1024,1.0,1024,1024,16,true,1024,2390,0,683,1024,1024],
[333,777,"fit inside",1.0,1024,1.0,1280,720,16,true,304,720,0,0,304,720],
[333,777,"crop to fill",1.0,1024,1.0,1280,720,16,true,1280,2987,0,1133,1280,720],
[333,777,"fit inside",1.0,1024,1.0,1000,600,16,true,256,592,0,0,256,592],
[333,777,"crop to fill",1.0,1024,1.0,1000,600,16,true,1008,2352,0,872,1008,608],
[333,777,"megapixels",0.25,1024,1.0,1024,1024,16,false,320,768,0,0,320,768],
[333,777,"megapixels",2.0,1024,1.0,1024,1024,16,false,320,768,0,0,320,768],
[333,777,"longest side",1.0,1000,1.0,1024,1024,16,false,320,768,0,0,320,768],
[333,777,"longest side",1.0,2048,1.0,1024,1024,16,false,320,768,0,0,320,768],
[333,777,"scale",1.0,1024,0.33,1024,1024,16,false,112,256,0,0,112,256],
[333,777,"scale",1.0,1024,1.5,1024,1024,16,false,320,768,0,0,320,768],
[333,777,"fit inside",1.0,1024,1.0,1024,1024,16,false,320,768,0,0,320,768],
[333,777,"crop to fill",1.0,1024,1.0,1024,1024,16,false,320,747,0,213,320,320],
[333,777,"fit inside",1.0,1024,1.0,1280,720,16,false,304,720,0,0,304,720],
[333,777,"crop to fill",1.0,1024,1.0,1280,720,16,false,320,747,0,285,320,176],
[333,777,"fit inside",1.0,1024,1.0,1000,600,16,false,256,592,0,0,256,592],
[333,777,"crop to fill",1.0,1024,1.0,1000,600,16,false,320,747,0,277,320,192],
[8192,5464,"off",1.0,1024,1.0,1024,1024,1,true,8192,5464,0,0,8192,5464],
[8192,5464,"off",1.0,1024,1.0,1024,1024,16,true,8192,5464,0,0,8192,5464],
[8192,5464,"megapixels",0.25,1024,1.0,1024,1024,1,true,612,408,0,0,612,408],
[8192,5464,"megapixels",2.0,1024,1.0,1024,1024,1,true,1732,1155,0,0,1732,1155],
[8192,5464,"longest side",1.0,1000,1.0,1024,1024,1,true,1000,667,0,0,1000,667],
[8192,5464,"longest side",1.0,2048,1.0,1024,1024,1,true,2048,1366,0,0,2048,1366],
[8192,5464,"scale",1.0,1024,0.33,1024,1024,1,true,2703,1803,0,0,2703,1803],
[8192,5464,"scale",1.0,1024,1.5,1024,1024,1,true,12288,8196,0,0,12288,8196],
[8192,5464,"fit inside",1.0,1024,1.0,1024,1024,1,true,1024,683,0,0,1024,683],
[8192,5464,"crop to fill",1.0,1024,1.0,1024,1024,1,true,1536,1024,256,0,1024,1024],
[8192,5464,"fit inside",1.0,1024,1.0,1280,720,1,true,1079,720,0,0,1079,720],
[8192,5464,"crop to fill",1.0,1024,1.0,1280,720,1,true,1280,854,0,67,1280,720],
[8192,5464,"fit inside",1.0,1024,1.0,1000,600,1,true,900,600,0,0,900,600],
[8192,5464,"crop to fill",1.0,1024,1.0,1000,600,1,true,1000,667,0,33,1000,600],
[8192,5464,"megapixels",0.25,1024,1.0,1024,1024,1,false,612,408,0,0,612,408],
[8192,5464,"megapixels",2.0,1024,1.0,1024,1024,1,false,1732,1155,0,0,1732,1155],
[8192,5464,"longest side",1.0,1000,1.0,1024,1024,1,false,1000,667,0,0,1000,667],
[8192,5464,"longest side",1.0,2048,1.0,1024,1024,1,false,2048,1366,0,0,2048,1366],
[8192,5464,"scale",1.0,1024,0.33,1024,1024,1,false,2703,1803,0,0,2703,1803],
[8192,5464,"scale",1.0,1024,1.5,1024,1024,1,false,8192,5464,0,0,8192,5464],
[8192,5464,"fit inside",1.0,1024,1.0,1024,1024,1,false,1024,683,0,0,1024,683],
[8192,5464,"crop to fill",1.0,1024,1.0,1024,1024,1,false,1536,1024,256,0,1024,1024],
[8192,5464,"fit inside",1.0,1024,1.0,1280,720,1,false,1079,720,0,0,1079,720],
[8192,5464,"crop to fill",1.0,1024,1.0,1280,720,1,false,1280,854,0,67,1280,720],
[8192,5464,"fit inside",1.0,1024,1.0,1000,600,1,false,900,600,0,0,900,600],
[8192,5464,"crop to fill",1.0,1024,1.0,1000,600,1,false,1000,667,0,33,1000,600],
[8192,5464,"megapixels",0.25,1024,1.0,1024,1024,16,true,608,416,0,0,608,416],
[8192,5464,"megapixels",2.0,1024,1.0,1024,1024,16,true,1728,1152,0,0,1728,1152],
[8192,5464,"longest side",1.0,1000,1.0,1024,1024,16,true,1008,672,0,0,1008,672],
[8192,5464,"longest side",1.0,2048,1.0,1024,1024,16,true,2048,1360,0,0,2048,1360],
[8192,5464,"scale",1.0,1024,0.33,1024,1024,16,true,2704,1808,0,0,2704,1808],
[8192,5464,"scale",1.0,1024,1.5,1024,1024,16,true,12288,8192,0,0,12288,8192],
[8192,5464,"fit inside",1.0,1024,1.0,1024,1024,16,true,1024,688,0,0,1024,688],
[8192,5464,"crop to fill",1.0,1024,1.0,1024,1024,16,true,1536,1024,256,0,1024,1024],
[8192,5464,"fit inside",1.0,1024,1.0,1280,720,16,true,1072,720,0,0,1072,720],
[8192,5464,"crop to fill",1.0,1024,1.0,1280,720,16,true,1280,854,0,67,1280,720],
[8192,5464,"fit inside",1.0,1024,1.0,1000,600,16,true,896,592,0,0,896,592],
[8192,5464,"crop to fill",1.0,1024,1.0,1000,600,16,true,1008,673,0,32,1008,608],
[8192,5464,"megapixels",0.25,1024,1.0,1024,1024,16,false,608,416,0,0,608,416],
[8192,5464,"megapixels",2.0,1024,1.0,1024,1024,16,false,1728,1152,0,0,1728,1152],
[8192,5464,"longest side",1.0,1000,1.0,1024,1024,16,false,1008,672,0,0,1008,672],
[8192,5464,"longest side",1.0,2048,1.0,1024,1024,16,false,2048,1360,0,0,2048,1360],
[8192,5464,"scale",1.0,1024,0.33,1024,1024,16,false,2704,1808,0,0,2704,1808],
[8192,5464,"scale",1.0,1024,1.5,1024,1024,16,false,8192,5456,0,0,8192,5456],
[8192,5464,"fit inside",1.0,1024,1.0,1024,1024,16,false,1024,688,0,0,1024,688],
[8192,5464,"crop to fill",1.0,1024,1.0,1024,1024,16,false,1536,1024,256,0,1024,1024],
[8192,5464,"fit inside",1.0,1024,1.0,1280,720,16,false,1072,720,0,0,1072,720],
[8192,5464,"crop to fill",1.0,1024,1.0,1280,720,16,false,1280,854,0,67,1280,720],
[8192,5464,"fit inside",1.0,1024,1.0,1000,600,16,false,896,592,0,0,896,592],
[8192,5464,"crop to fill",1.0,1024,1.0,1000,600,16,false,992,662,0,35,992,592],
[1000,1000,"longest side",1.0,1000,1.0,1024,1024,64,true,1024,1024,0,0,1024,1024],
[30,20,"megapixels",1.0,1024,1.0,1024,1024,64,false,64,64,0,0,64,64]
]}
+45
View File
@@ -522,6 +522,51 @@
]
]
},
"AUSBOSS_NODES_LoadImage": {
"required": [
"image",
"size",
"megapixels",
"longest_side",
"scale",
"width",
"height",
"multiple",
"method",
"enlarge"
],
"optional": [],
"outputs": [
[
"IMAGE",
"image"
],
[
"MASK",
"mask"
],
[
"INT",
"width"
],
[
"INT",
"height"
],
[
"STRING",
"filename"
],
[
"INT",
"original_width"
],
[
"INT",
"original_height"
]
]
},
"AUSBOSS_NODES_LoadImagePad": {
"required": [
"image",
+90
View File
@@ -0,0 +1,90 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { SIZE_MODES, placeText, planLoadSize, sizeText, stepFile } from "../js/shared/load_size.mjs";
const table = JSON.parse(readFileSync(new URL("./fixtures/load_size_cases.json", import.meta.url), "utf8"));
test("the size the node shows is the size the run gives, case by case", () => {
assert.ok(table.rows.length > 200);
const seen = new Set();
for (const row of table.rows) {
const [sourceWidth, sourceHeight, size, megapixels, longest_side, scale, width, height, multiple, enlarge, ...wanted] = row;
seen.add(size);
const plan = planLoadSize(sourceWidth, sourceHeight, { size, megapixels, longest_side, scale, width, height, multiple, enlarge });
assert.deepEqual(
[plan.resizeWidth, plan.resizeHeight, plan.cropX, plan.cropY, plan.width, plan.height],
wanted,
JSON.stringify(row.slice(0, 10)),
);
}
assert.deepEqual([...seen].sort(), [...SIZE_MODES].sort(), "every size choice is in the table");
});
test("off gives the picture back whatever the other settings say", () => {
const plan = planLoadSize(913, 1631, { size: "off", multiple: 64, megapixels: 4, enlarge: false });
assert.deepEqual([plan.width, plan.height, plan.cropX, plan.cropY], [913, 1631, 0, 0]);
});
test("longest side lands on the number asked for, and round to moves it to a multiple", () => {
assert.deepEqual(planLoadSize(912, 1632, { size: "longest side", longest_side: 2048, multiple: 1 }).height, 2048);
const rounded = planLoadSize(912, 1632, { size: "longest side", longest_side: 2000, multiple: 16 });
assert.equal(rounded.height, 2000);
assert.equal(rounded.width % 16, 0);
});
test("with enlarge off a small picture keeps its size, and crop to fill only cuts", () => {
const kept = planLoadSize(640, 480, { size: "megapixels", megapixels: 4, multiple: 1, enlarge: false });
assert.deepEqual([kept.width, kept.height], [640, 480]);
const cut = planLoadSize(800, 600, { size: "crop to fill", width: 1024, height: 1024, multiple: 1, enlarge: false });
assert.deepEqual([cut.width, cut.height, cut.resizeWidth, cut.resizeHeight, cut.cropX, cut.cropY], [600, 600, 800, 600, 100, 0]);
});
test("fit inside never leaves its box, rounding included", () => {
const plan = planLoadSize(1920, 1080, { size: "fit inside", width: 1000, height: 1000, multiple: 64 });
assert.ok(plan.width <= 1000 && plan.height <= 1000);
assert.equal(plan.width % 64, 0);
});
test("a picture with no size yet, or a choice the node does not know, has no plan", () => {
assert.equal(planLoadSize(0, 0, { size: "scale" }), null);
assert.equal(planLoadSize(100, 100, { size: "" }), null);
assert.equal(planLoadSize(100, 100, { size: "stretch" }), null);
});
test("the size line shows one size when nothing changes and both when it does", () => {
assert.equal(sizeText(912, 1632, planLoadSize(912, 1632, { size: "off" })), "912 × 1632");
assert.equal(
sizeText(912, 1632, planLoadSize(912, 1632, { size: "longest side", longest_side: 2048, multiple: 8 })),
"912 × 1632 → 1144 × 2048",
);
assert.equal(sizeText(0, 0, null), "");
assert.equal(sizeText(912, 1632, null), "912 × 1632");
});
test("the arrows go round the list, and a file that is not listed steps onto it", () => {
const files = ["a.png", "b.png", "sub/c.png"];
assert.equal(stepFile(files, "a.png", 1), "b.png");
assert.equal(stepFile(files, "sub/c.png", 1), "a.png");
assert.equal(stepFile(files, "a.png", -1), "sub/c.png");
assert.equal(stepFile(files, "gone.png", 1), "a.png");
assert.equal(stepFile(files, "gone.png", -1), "sub/c.png");
assert.equal(stepFile(["only.png"], "only.png", 1), null);
assert.equal(stepFile([""], "", 1), null, "an empty input folder lists one blank");
assert.equal(stepFile(undefined, "a.png", 1), null);
});
test("the place in the list counts from one", () => {
const files = ["a.png", "b.png", "sub/c.png"];
assert.equal(placeText(files, "b.png"), "2 / 3");
assert.equal(placeText(files, "gone.png"), "– / 3");
assert.equal(placeText([""], ""), "");
});
test("Load Image is one of the loaders checked for a picked file before a run", async () => {
const { SOURCE_LOADERS } = await import("../js/shared/run_check.mjs");
assert.equal(typeof SOURCE_LOADERS.AUSBOSS_NODES_LoadImage, "function");
assert.ok(SOURCE_LOADERS.AUSBOSS_NODES_LoadImage({ image: "" }));
assert.equal(SOURCE_LOADERS.AUSBOSS_NODES_LoadImage({ image: "pic.png" }), null);
});
+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);
});
+228
View File
@@ -0,0 +1,228 @@
"""Load Image: the size plan as numbers, and the node on real files."""
from __future__ import annotations
import json
import os
from pathlib import Path
import shutil
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import patch
import numpy as np
import torch
from PIL import Image
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 _media_helpers # noqa: E402
from nodes._load_image_helpers import ( # noqa: E402
SIZE_MODES,
apply_load_size,
plan_load_size,
)
from nodes.node_load_image import ( # noqa: E402
NODE_CLASS_MAPPINGS,
NODE_DISPLAY_NAME_MAPPINGS,
AusBossLoadImage,
)
TABLE = ROOT / "tests" / "fixtures" / "load_size_cases.json"
class PlanTests(unittest.TestCase):
def test_the_table_the_browser_is_held_to_is_what_the_planner_gives(self):
table = json.loads(TABLE.read_text(encoding="utf-8"))
self.assertGreater(len(table["rows"]), 200)
for row in table["rows"]:
source_w, source_h, size, megapixels, longest_side, scale, width, height, multiple, enlarge = row[:10]
plan = plan_load_size(source_w, source_h, size, megapixels, longest_side, scale, width, height, multiple, enlarge)
got = [plan["resize_width"], plan["resize_height"], plan["crop_x"], plan["crop_y"], plan["width"], plan["height"]]
self.assertEqual(got, row[10:], row[:10])
def test_off_gives_the_picture_back_whatever_else_is_set(self):
plan = plan_load_size(913, 1631, "off", megapixels=4.0, multiple=64, enlarge=False)
self.assertEqual((plan["width"], plan["height"], plan["crop_x"], plan["crop_y"]), (913, 1631, 0, 0))
def test_each_choice_keeps_its_promise(self):
plan = plan_load_size(912, 1632, "megapixels", megapixels=2.0, multiple=1)
self.assertAlmostEqual(plan["width"] * plan["height"] / 1e6, 2.0, delta=0.01)
self.assertAlmostEqual(plan["width"] / plan["height"], 912 / 1632, delta=0.002)
plan = plan_load_size(912, 1632, "longest side", longest_side=2048, multiple=1)
self.assertEqual((plan["width"], plan["height"]), (1144, 2048))
plan = plan_load_size(1920, 1080, "longest side", longest_side=1280, multiple=1)
self.assertEqual((plan["width"], plan["height"]), (1280, 720))
plan = plan_load_size(1920, 1080, "scale", scale=0.5, multiple=1)
self.assertEqual((plan["width"], plan["height"]), (960, 540))
plan = plan_load_size(1920, 1080, "fit inside", width=1024, height=1024, multiple=1)
self.assertEqual((plan["width"], plan["height"]), (1024, 576))
plan = plan_load_size(1920, 1080, "crop to fill", width=1024, height=1024, multiple=1)
self.assertEqual((plan["width"], plan["height"]), (1024, 1024))
self.assertEqual((plan["resize_width"], plan["resize_height"]), (1821, 1024))
self.assertEqual((plan["crop_x"], plan["crop_y"]), (398, 0))
def test_round_to_gives_multiples_and_fit_inside_stays_in_its_box(self):
for size in SIZE_MODES[1:]:
plan = plan_load_size(1000, 777, size, megapixels=1.3, longest_side=1111, scale=1.37, width=900, height=700, multiple=64)
self.assertEqual((plan["width"] % 64, plan["height"] % 64), (0, 0), size)
plan = plan_load_size(1920, 1080, "fit inside", width=1000, height=1000, multiple=64)
self.assertLessEqual(plan["width"], 1000)
self.assertLessEqual(plan["height"], 1000)
def test_with_enlarge_off_nothing_comes_out_bigger_than_the_file(self):
for size in SIZE_MODES[1:]:
for multiple in (1, 8, 16):
plan = plan_load_size(640, 480, size, megapixels=4.0, longest_side=4096, scale=3.0, width=2048, height=2048, multiple=multiple, enlarge=False)
self.assertLessEqual(plan["width"], 640, (size, multiple))
self.assertLessEqual(plan["height"], 480, (size, multiple))
self.assertLessEqual(plan["resize_width"], 640, (size, multiple))
# Crop to fill still gives the box's shape, cut from the file as it is.
plan = plan_load_size(800, 600, "crop to fill", width=1024, height=1024, multiple=1, enlarge=False)
self.assertEqual((plan["width"], plan["height"], plan["resize_width"], plan["resize_height"], plan["crop_x"]), (600, 600, 800, 600, 100))
# A picture that is big enough is resized as usual.
plan = plan_load_size(4000, 3000, "longest side", longest_side=1024, multiple=1, enlarge=False)
self.assertEqual((plan["width"], plan["height"]), (1024, 768))
def test_bad_input_is_named(self):
with self.assertRaisesRegex(ValueError, "size must be one of"):
plan_load_size(100, 100, "stretch")
with self.assertRaisesRegex(ValueError, "larger than 0x0"):
plan_load_size(0, 100, "off")
def test_a_plan_that_changes_nothing_hands_the_same_tensors_back(self):
image, mask = torch.rand(1, 20, 30, 3), torch.zeros(1, 20, 30)
out_image, out_mask = apply_load_size(image, mask, plan_load_size(30, 20, "off"), "lanczos")
self.assertIs(out_image, image)
self.assertIs(out_mask, mask)
class NodeTests(unittest.TestCase):
def setUp(self):
# The temp folder stands in for ComfyUI's input folder.
self.folder = tempfile.mkdtemp(prefix="ausboss_load_image_")
self.addCleanup(shutil.rmtree, self.folder, True)
stand_in = SimpleNamespace(
get_input_directory=lambda: self.folder,
get_annotated_filepath=lambda name: os.path.join(self.folder, name),
)
patcher = patch.object(_media_helpers, "folder_paths", stand_in)
patcher.start()
self.addCleanup(patcher.stop)
def write(self, name="pic.png", width=96, height=64, mode="RGB"):
path = Path(self.folder) / name
path.parent.mkdir(parents=True, exist_ok=True)
values = np.zeros((height, width, 4), dtype=np.uint8)
values[..., 0] = np.linspace(0, 255, width, dtype=np.uint8)[None, :]
values[..., 1] = np.linspace(0, 255, height, dtype=np.uint8)[:, None]
values[..., 2] = 90
values[..., 3] = 255
values[: height // 2, : width // 2, 3] = 0 # the top left quarter is see-through
Image.fromarray(values).convert(mode).save(path)
return name
def run_node(self, name, **values):
return AusBossLoadImage().load(name, **values)
def test_the_node_is_registered_under_its_permanent_key(self):
self.assertIs(NODE_CLASS_MAPPINGS["AUSBOSS_NODES_LoadImage"], AusBossLoadImage)
self.assertEqual(NODE_DISPLAY_NAME_MAPPINGS["AUSBOSS_NODES_LoadImage"], "Load Image \U0001f18e")
spec = AusBossLoadImage.INPUT_TYPES()
self.assertEqual(
list(spec["required"]),
["image", "size", "megapixels", "longest_side", "scale", "width", "height", "multiple", "method", "enlarge"],
)
self.assertEqual(spec["required"]["size"][0], list(SIZE_MODES))
self.assertEqual(spec["required"]["size"][1]["default"], "off")
self.assertEqual(len(AusBossLoadImage.RETURN_TYPES), len(AusBossLoadImage.RETURN_NAMES))
self.assertEqual(len(AusBossLoadImage.RETURN_TYPES), len(AusBossLoadImage.OUTPUT_TOOLTIPS))
for name, (_, options) in spec["required"].items():
self.assertTrue(options.get("tooltip"), name)
def test_with_size_off_the_file_comes_out_as_it_is(self):
name = self.write()
image, mask, width, height, filename, original_w, original_h = self.run_node(name)
self.assertEqual(tuple(image.shape), (1, 64, 96, 3))
self.assertEqual(tuple(mask.shape), (1, 64, 96))
self.assertEqual((width, height, original_w, original_h), (96, 64, 96, 64))
self.assertEqual(filename, "pic")
wanted = np.asarray(Image.open(Path(self.folder) / name).convert("RGB"), dtype=np.float32) / 255.0
self.assertTrue(np.array_equal(image[0].numpy(), wanted), "every pixel as the file has it")
self.assertEqual(float(mask.max()), 0.0, "no transparency: an all black mask")
def test_a_see_through_picture_gives_its_mask_the_way_the_stock_loader_does(self):
name = self.write("cut.png", mode="RGBA")
_, mask, *_ = self.run_node(name)
self.assertEqual(float(mask[0, 5, 5]), 1.0, "see-through is white")
self.assertEqual(float(mask[0, 60, 90]), 0.0, "solid is black")
def test_every_size_choice_gives_the_planned_size_for_picture_and_mask(self):
name = self.write("cut.png", mode="RGBA")
for size in SIZE_MODES[1:]:
with self.subTest(size=size):
values = {"megapixels": 0.02, "longest_side": 160, "scale": 1.5, "width": 80, "height": 80, "multiple": 8}
image, mask, width, height, _, original_w, original_h = self.run_node(name, size=size, **values)
plan = plan_load_size(96, 64, size, **values)
self.assertEqual((width, height), (plan["width"], plan["height"]))
self.assertEqual(tuple(image.shape), (1, height, width, 3))
self.assertEqual(tuple(mask.shape), (1, height, width))
self.assertEqual((original_w, original_h), (96, 64))
self.assertGreaterEqual(float(image.min()), 0.0)
self.assertLessEqual(float(image.max()), 1.0)
self.assertGreaterEqual(float(mask.min()), 0.0)
self.assertLessEqual(float(mask.max()), 1.0)
def test_crop_to_fill_cuts_the_middle(self):
name = self.write("wide.png", width=200, height=100)
image, _, width, height, *_ = self.run_node(name, size="crop to fill", width=100, height=100, multiple=1, method="nearest")
self.assertEqual((width, height), (100, 100))
whole, *_ = self.run_node(name)
self.assertTrue(torch.equal(image, whole[:, :, 50:150, :]), "the middle of the file, untouched")
def test_enlarge_off_leaves_a_small_file_alone(self):
name = self.write()
image, _, width, height, *_ = self.run_node(name, size="longest side", longest_side=1024, multiple=1, enlarge=False)
self.assertEqual((width, height), (96, 64))
whole, *_ = self.run_node(name)
self.assertTrue(torch.equal(image, whole))
def test_a_file_in_a_subfolder_gives_its_bare_name(self):
name = self.write("shots/day one/hero.final.png")
*_, filename, _, _ = self.run_node(name)
self.assertEqual(filename, "hero.final")
def test_every_frame_of_an_animation_comes_through(self):
frames = [Image.new("RGB", (40, 30), (index * 60, 20, 20)) for index in range(3)]
path = Path(self.folder) / "turn.gif"
frames[0].save(path, save_all=True, append_images=frames[1:], duration=80, loop=0)
image, mask, width, height, *_ = self.run_node("turn.gif", size="scale", scale=2.0, multiple=1)
self.assertEqual(tuple(image.shape), (3, 60, 80, 3))
self.assertEqual(tuple(mask.shape), (3, 60, 80))
self.assertEqual((width, height), (80, 60))
def test_a_missing_or_outside_file_stops_before_the_run_with_the_nodes_name(self):
self.assertIs(AusBossLoadImage.VALIDATE_INPUTS(image=self.write()), True)
for name in ("", "gone.png", "../secret.png", "//fileserver/share/pic.png"):
verdict = AusBossLoadImage.VALIDATE_INPUTS(image=name)
self.assertIsInstance(verdict, str, name)
self.assertTrue(verdict.startswith("Load Image: "), verdict)
def test_a_changed_file_or_setting_runs_the_node_again(self):
name = self.write()
first = AusBossLoadImage.IS_CHANGED(name, size="off")
self.assertEqual(first, AusBossLoadImage.IS_CHANGED(name, size="off"))
self.assertNotEqual(first, AusBossLoadImage.IS_CHANGED(name, size="scale"))
path = Path(self.folder) / name
Image.new("RGB", (10, 10), (1, 2, 3)).save(path)
os.utime(path, ns=(1, 1))
self.assertNotEqual(first, AusBossLoadImage.IS_CHANGED(name, size="off"))
if __name__ == "__main__":
unittest.main()
+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()
+2 -1
View File
@@ -436,10 +436,11 @@ class LoaderValidationTests(TempFolders):
"""The loaders refuse the same paths before a run, with one message each."""
def test_image_loaders_take_what_core_load_image_takes_and_nothing_else(self):
from nodes import node_image_crop_rotate_pad, node_load_image_pad
from nodes import node_image_crop_rotate_pad, node_load_image, node_load_image_pad
for module, cls, label in (
(node_image_crop_rotate_pad, node_image_crop_rotate_pad.AusBossImageCropRotatePad, "Image Crop + Rotate + Pad"),
(node_load_image, node_load_image.AusBossLoadImage, "Load Image"),
(node_load_image_pad, node_load_image_pad.AusBossLoadImagePad, "Load Image + Pad"),
):
with self.subTest(node=label):
-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()
+1
View File
@@ -44,6 +44,7 @@ NAMED = {
"AusBossImageFolder": ["source", "folder"],
"AusBossLaMaInpaint": ["model"],
"AusBossLoadAudio": ["audio"],
"AusBossLoadImage": ["image"],
"AusBossLoadImagePad": ["image", "source_image"],
"AusBossLoadVideo": ["video"],
"AusBossLoraLoader": ["loras", "on_missing"],
+67 -1
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,
@@ -942,7 +943,7 @@ test("a name is shown only when SAM 3 is sure of it, and surer still for a big t
});
test("the pack's own picture loaders are the ones the node runs by itself", () => {
assert.deepEqual([...OWN_LOADERS].sort(), ["AUSBOSS_NODES_ImageCropRotatePad", "AUSBOSS_NODES_LoadImagePad"]);
assert.deepEqual([...OWN_LOADERS].sort(), ["AUSBOSS_NODES_ImageCropRotatePad", "AUSBOSS_NODES_LoadImage", "AUSBOSS_NODES_LoadImagePad"]);
// A core Load Image is not in the list: its file is the picture, and the node shows it without a run.
assert.equal(OWN_LOADERS.has("LoadImage"), false);
});
@@ -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.");