Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4657e2b58 | ||
|
|
eba880cdee | ||
|
|
b6a67a4c34 | ||
|
|
27525ffdd8 | ||
|
|
eb668c448c | ||
|
|
9b3cd7c769 | ||
|
|
13c303178b | ||
|
|
f945feabb6 | ||
|
|
24ce4ffb06 | ||
|
|
980ed5db8b | ||
|
|
bf59887643 | ||
|
|
60be0ee865 |
+46
-4
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
@@ -47,6 +47,24 @@ async function stopLook(node) {
|
||||
return Boolean(running || waiting.length);
|
||||
}
|
||||
|
||||
// Take this node's own runs that still wait out of the queue: the looks and
|
||||
// touches its picture asked for (they carry an `ask`), never a run of the
|
||||
// workflow. Says how many went. The one that is running is left alone.
|
||||
async function dropWaiting(node) {
|
||||
const id = executionIdOf(app.rootGraph ?? app.graph, node);
|
||||
if (!id) return 0;
|
||||
const queue = await (await api.fetchApi("/queue")).json();
|
||||
const ours = (entry) => {
|
||||
const step = entry?.[2]?.[id];
|
||||
return step?.class_type === NODE_CLASS && String(step.inputs?.ask ?? "") !== "";
|
||||
};
|
||||
const waiting = (queue?.queue_pending ?? []).filter(ours).map((entry) => entry[1]);
|
||||
if (waiting.length) {
|
||||
await api.fetchApi("/queue", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ delete: waiting }) });
|
||||
}
|
||||
return waiting.length;
|
||||
}
|
||||
|
||||
// The picture goes above the settings: the stage widget sits ahead of the
|
||||
// node's card, whichever of the two was built first.
|
||||
function stageFirst(node) {
|
||||
@@ -92,6 +110,7 @@ function build(node) {
|
||||
const face = mountTouchStage(node, {
|
||||
lookOnButton: () => lookOnButton,
|
||||
stopLook: () => stopLook(node).catch(() => false),
|
||||
dropWaiting: () => dropWaiting(node).catch(() => 0),
|
||||
// A workflow is being opened: nothing is run on the person's behalf.
|
||||
loading: () => Boolean(app.configuringGraph),
|
||||
// Bypassed or muted, or inside a box that is: nothing is run for it.
|
||||
|
||||
@@ -26,6 +26,7 @@ import { nodeByExecutionId } from "../shared/graph_ids.mjs";
|
||||
import {
|
||||
collapseRepeatedErrors,
|
||||
emptySourceMessage,
|
||||
explainRunError,
|
||||
findEmptySources,
|
||||
nodeLacksSource,
|
||||
} from "../shared/run_check.mjs";
|
||||
@@ -174,10 +175,20 @@ function collapseAnswers() {
|
||||
api.queuePrompt = collapsing;
|
||||
}
|
||||
|
||||
// A run that stops on an error the pack knows the cause of gets one toast
|
||||
// that says what to do. ComfyUI's own error report is left as it is.
|
||||
function explainKnownErrors() {
|
||||
api.addEventListener("execution_error", ({ detail }) => {
|
||||
const known = explainRunError(detail);
|
||||
if (known) showToast({ severity: "warn", summary: known.summary, detail: known.detail, life: 30000 });
|
||||
});
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "ausboss.run_check",
|
||||
setup() {
|
||||
guardRuns();
|
||||
collapseAnswers();
|
||||
explainKnownErrors();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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": "在一个节点里加载图像并扩边成扩图画布。拖动节点上画布的任意一边,直观地设置每边的扩边量;遮罩正好覆盖新扩出的部分,可选择向内羽化跨过接缝。画布会取整到整数倍,但不会给你没扩的边加条带;设置百万像素目标时会先缩放原图,让遮罩接缝保持清晰。",
|
||||
|
||||
@@ -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"]
|
||||
@@ -41,6 +41,16 @@ RADIUS_MAX = 4096.0
|
||||
# The face's own picture: the long side is at most this.
|
||||
SMALL_SIDE = 1024
|
||||
|
||||
# SAM 3 is asked about a copy of a big picture: its long side is at most
|
||||
# this. ComfyUI's SAM 3 reads every picture at 1008 pixels and returns each
|
||||
# answer at the size of the picture it was given, on the graphics card. At
|
||||
# 8192 pixels one word with sixteen matches needed over 27 GB there, and a
|
||||
# look at 3840 took twice as long as at 2048, all for outlines the model
|
||||
# never saw that finely. Twice what the model reads keeps its second pass
|
||||
# (a close-up of each thing it found) as sharp as before on pictures up to
|
||||
# this size, which come out exactly as they did.
|
||||
WORK_SIDE = 2048
|
||||
|
||||
# How a find stroke is asked (find_points, choose_found). Measured on 27
|
||||
# strokes over three pictures, each drawn 13 slightly different ways: four
|
||||
# points alone returned the stroked thing in 87 % of 351 tries, two points
|
||||
@@ -386,6 +396,73 @@ def read_ask(ask, height: int, width: int) -> tuple:
|
||||
return "look", None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The copy of a big picture that SAM 3 is asked about
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class WorkView:
|
||||
"""One picture as SAM 3 is asked about it.
|
||||
|
||||
A picture whose long side is over WORK_SIDE is asked about on a smaller
|
||||
copy (`frame`, averaged down, so nothing shimmers), and what SAM 3
|
||||
answers there is brought back to the picture's own size with `full`.
|
||||
A smaller picture is its own copy: `same` is true and nothing changes.
|
||||
|
||||
Touches and pieces keep their places in pixels of the picture. `point`
|
||||
gives a place on the copy, `full_point` a place on the picture that
|
||||
`point` leads back to the same pixel of the copy.
|
||||
"""
|
||||
|
||||
def __init__(self, frame: torch.Tensor):
|
||||
self.height, self.width = int(frame.shape[1]), int(frame.shape[2])
|
||||
long_side = max(self.height, self.width)
|
||||
self.same = long_side <= WORK_SIDE
|
||||
if self.same:
|
||||
self.frame, self.work_height, self.work_width = frame, self.height, self.width
|
||||
return
|
||||
scale = WORK_SIDE / long_side
|
||||
self.work_height = max(1, int(math.floor(self.height * scale + 0.5)))
|
||||
self.work_width = max(1, int(math.floor(self.width * scale + 0.5)))
|
||||
small = F.interpolate(
|
||||
frame.detach().float().cpu().movedim(-1, 1), size=(self.work_height, self.work_width), mode="area",
|
||||
)
|
||||
self.frame = small.movedim(1, -1).clamp(0.0, 1.0).contiguous()
|
||||
|
||||
def point(self, point) -> tuple:
|
||||
x = min(self.work_width - 1, max(0, int(point[0]) * self.work_width // self.width))
|
||||
y = min(self.work_height - 1, max(0, int(point[1]) * self.work_height // self.height))
|
||||
return (x, y)
|
||||
|
||||
def full_point(self, point) -> tuple:
|
||||
x = min(self.width - 1, -(-int(point[0]) * self.width // self.work_width))
|
||||
y = min(self.height - 1, -(-int(point[1]) * self.height // self.work_height))
|
||||
return (x, y)
|
||||
|
||||
def points(self, points) -> tuple:
|
||||
return tuple(self.point(point) for point in points)
|
||||
|
||||
def length(self, value: float) -> float:
|
||||
"""A length of the picture (a brush's radius) on the copy."""
|
||||
return float(value) * self.work_width / self.width
|
||||
|
||||
def full_box(self, box) -> list:
|
||||
if self.same:
|
||||
return list(box)
|
||||
x0, y0, x1, y1 = (int(value) for value in box)
|
||||
return [
|
||||
x0 * self.width // self.work_width, y0 * self.height // self.work_height,
|
||||
min(self.width, -(-x1 * self.width // self.work_width)), min(self.height, -(-y1 * self.height // self.work_height)),
|
||||
]
|
||||
|
||||
def full(self, mask: torch.Tensor) -> torch.Tensor:
|
||||
"""An answer from the copy at the picture's own size (bool)."""
|
||||
if self.same or tuple(mask.shape) == (self.height, self.width):
|
||||
return mask
|
||||
soft = F.interpolate(mask[None, None].float(), size=(self.height, self.width), mode="bilinear", align_corners=False)
|
||||
return soft[0, 0] >= 0.5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Strokes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Only one copy of the pack loads.
|
||||
|
||||
A Manager install lands in ``custom_nodes/ausboss-nodes`` and a git clone in
|
||||
``custom_nodes/ComfyUI-AusBoss``. With both in place every route is added
|
||||
twice and ComfyUI stops while starting. So each copy looks for the others
|
||||
and all of them agree on the one that loads; the rest step aside and say
|
||||
which folder to delete.
|
||||
|
||||
A copy from before this file existed cannot step aside, so it always loads
|
||||
and a newer copy yields to it. Among copies that can, the newest version
|
||||
loads.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tomllib
|
||||
|
||||
PACK_NAME = "ausboss-nodes"
|
||||
# A copy that has this file knows how to step aside.
|
||||
_GUARD_FILE = os.path.join("nodes", "_single_copy.py")
|
||||
|
||||
|
||||
def _read_pack(folder: str) -> dict | None:
|
||||
"""Name the pack copy in ``folder``, or None when it is another pack."""
|
||||
try:
|
||||
with open(os.path.join(folder, "pyproject.toml"), "rb") as f:
|
||||
project = tomllib.load(f).get("project", {})
|
||||
except Exception:
|
||||
return None
|
||||
if project.get("name") != PACK_NAME:
|
||||
return None
|
||||
if not os.path.isfile(os.path.join(folder, "__init__.py")):
|
||||
return None
|
||||
version = project.get("version")
|
||||
return {
|
||||
"folder": folder,
|
||||
"version": version if isinstance(version, str) else "unknown",
|
||||
"guarded": os.path.isfile(os.path.join(folder, _GUARD_FILE)),
|
||||
}
|
||||
|
||||
|
||||
def _version_key(version: str) -> tuple:
|
||||
parts = []
|
||||
for part in version.split("."):
|
||||
digits = "".join(ch for ch in part if ch.isdigit())
|
||||
parts.append(int(digits) if digits else -1)
|
||||
return tuple(parts)
|
||||
|
||||
|
||||
def find_copies(own_folder: str, roots: list[str]) -> list[dict]:
|
||||
"""Every copy of the pack below ``roots``, this one included."""
|
||||
own = os.path.realpath(own_folder)
|
||||
seen = {own}
|
||||
copies = [_read_pack(own) or {"folder": own, "version": "unknown", "guarded": True}]
|
||||
for root in roots:
|
||||
try:
|
||||
names = sorted(os.listdir(root))
|
||||
except OSError:
|
||||
continue
|
||||
for name in names:
|
||||
# ComfyUI skips these too.
|
||||
if name.endswith(".disabled") or name.startswith("."):
|
||||
continue
|
||||
folder = os.path.realpath(os.path.join(root, name))
|
||||
if folder in seen or not os.path.isdir(folder):
|
||||
continue
|
||||
seen.add(folder)
|
||||
pack = _read_pack(folder)
|
||||
if pack:
|
||||
copies.append(pack)
|
||||
return copies
|
||||
|
||||
|
||||
def pick_copy(copies: list[dict]) -> dict:
|
||||
"""The one copy that loads. Every copy reaches the same answer."""
|
||||
unguarded = [c for c in copies if not c["guarded"]]
|
||||
pool = unguarded or copies
|
||||
return max(pool, key=lambda c: (_version_key(c["version"]), c["folder"]))
|
||||
|
||||
|
||||
def other_copy_that_loads(own_folder: str, roots: list[str] | None = None) -> dict | None:
|
||||
"""The copy this one steps aside for, or None when this one loads."""
|
||||
if roots is None:
|
||||
# Outside ComfyUI nothing is served, so there is nothing to clash.
|
||||
try:
|
||||
import folder_paths
|
||||
|
||||
roots = list(folder_paths.get_folder_paths("custom_nodes"))
|
||||
except Exception:
|
||||
return None
|
||||
roots = list(dict.fromkeys(os.path.realpath(r) for r in roots))
|
||||
copies = find_copies(own_folder, roots)
|
||||
if len(copies) < 2:
|
||||
return None
|
||||
chosen = pick_copy(copies)
|
||||
return None if chosen["folder"] == os.path.realpath(own_folder) else chosen
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Vendored
+256
@@ -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]
|
||||
]}
|
||||
Vendored
+45
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import test from "node:test";
|
||||
import {
|
||||
collapseRepeatedErrors,
|
||||
emptySourceMessage,
|
||||
explainRunError,
|
||||
findEmptySources,
|
||||
nodeLacksSource,
|
||||
nodesThatRun,
|
||||
@@ -199,3 +200,40 @@ test("an empty Load Audio asks for a sound file", () => {
|
||||
prompt[1].inputs.audio = "song.mp3";
|
||||
assert.deepEqual(findEmptySources(prompt, outputs), []);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ known run errors
|
||||
|
||||
// The report a user posted: ComfyUI 0.44.0, H3 keeping the clip's sound, with
|
||||
// Muse MiniMax Director installed.
|
||||
const MUSE_REPORT = {
|
||||
node_type: "SamplerCustomAdvanced",
|
||||
exception_type: "KeyError",
|
||||
exception_message: "'latent'",
|
||||
traceback: [
|
||||
' File "G:\\ComfyUI\\ComfyUI\\comfy\\samplers.py", line 953, in encode_model_conds\n out = model_function(**params)\n',
|
||||
' File "G:\\ComfyUI\\ComfyUI\\custom_nodes\\muse-minimax-director-v1-2\\muse_minimax_hybrid_patch.py", line 30, in extra_conds_with_hybrid\n visual_latents.extend(item["latent"] for item in keyframes)\n',
|
||||
],
|
||||
};
|
||||
|
||||
test("the Muse MiniMax Director error is explained and names the pack", () => {
|
||||
const known = explainRunError(MUSE_REPORT);
|
||||
assert.match(known.detail, /Muse MiniMax Director/);
|
||||
assert.match(known.detail, /Turn that pack off/);
|
||||
});
|
||||
|
||||
test("the same KeyError from anywhere else is left alone", () => {
|
||||
assert.equal(explainRunError({ ...MUSE_REPORT, traceback: [' File "comfy/samplers.py", line 953\n'] }), null);
|
||||
assert.equal(explainRunError({ ...MUSE_REPORT, exception_message: "'samples'" }), null);
|
||||
});
|
||||
|
||||
test("an old ComfyUI loading a w6a8 model is told to update", () => {
|
||||
const known = explainRunError({ exception_type: "KeyError", exception_message: "'w6a8_int8'", traceback: [] });
|
||||
assert.match(known.detail, /0\.38/);
|
||||
});
|
||||
|
||||
test("an unknown error, or no report at all, gives nothing", () => {
|
||||
assert.equal(explainRunError({ exception_type: "RuntimeError", exception_message: "CUDA out of memory", traceback: [] }), null);
|
||||
assert.equal(explainRunError(null), null);
|
||||
assert.equal(explainRunError({}), null);
|
||||
assert.equal(explainRunError({ exception_type: "KeyError", exception_message: "'latent'", traceback: "muse_minimax_hybrid_patch" }) !== null, true);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,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()
|
||||
@@ -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()
|
||||
@@ -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):
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
"""A second install of the pack loads nothing, so ComfyUI still starts.
|
||||
|
||||
People install the pack from ComfyUI-Manager (custom_nodes/ausboss-nodes) and
|
||||
from GitHub (custom_nodes/ComfyUI-AusBoss). With both folders present, the two
|
||||
copies registered the same server routes, and ComfyUI stopped at startup with
|
||||
"Added route will never be executed, method HEAD is already registered".
|
||||
|
||||
This loads the pack twice, from two folders, in one fresh standard-library
|
||||
Python with the same stand-ins as tests/test_node_api.py. The second copy must
|
||||
add no routes, no nodes and no browser files, and must name both folders.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
ROUTES: list[tuple[str, str]] = []
|
||||
|
||||
|
||||
def stub_server():
|
||||
"""A PromptServer whose route table records every route, the way aiohttp will add them."""
|
||||
|
||||
class Routes:
|
||||
def __getattr__(self, method):
|
||||
def register(path, *_args, **_kwargs):
|
||||
ROUTES.append((method.upper(), path))
|
||||
return lambda handler: handler
|
||||
|
||||
return register
|
||||
|
||||
class Instance:
|
||||
routes = Routes()
|
||||
|
||||
def __getattr__(self, _name):
|
||||
return lambda *_args, **_kwargs: None
|
||||
|
||||
module = types.ModuleType("server")
|
||||
module.PromptServer = type("PromptServer", (), {"instance": Instance()})
|
||||
sys.modules["server"] = module
|
||||
|
||||
|
||||
def load_copy(name: str, folder: Path):
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
name, folder / "__init__.py", submodule_search_locations=[str(folder)]
|
||||
)
|
||||
pack = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = pack
|
||||
out = io.StringIO()
|
||||
with contextlib.redirect_stdout(out):
|
||||
spec.loader.exec_module(pack)
|
||||
return pack, out.getvalue()
|
||||
|
||||
|
||||
def child(tmp: Path, report: Path):
|
||||
sys.path.insert(0, str(ROOT / "tests"))
|
||||
from test_node_api import StandInFinder, StandInModule
|
||||
|
||||
sys.meta_path.insert(0, StandInFinder())
|
||||
sys.modules["aiohttp"] = StandInModule("aiohttp") # route helpers import aiohttp.web first
|
||||
stub_server()
|
||||
# The two folder names people end up with: a git clone and Manager's install.
|
||||
first, second = tmp / "ComfyUI-AusBoss", tmp / "ausboss-nodes"
|
||||
first.symlink_to(ROOT, target_is_directory=True)
|
||||
second.symlink_to(ROOT, target_is_directory=True)
|
||||
|
||||
first_pack, _ = load_copy("ComfyUI-AusBoss", first)
|
||||
first_routes = list(ROUTES)
|
||||
second_pack, second_out = load_copy("ausboss-nodes", second)
|
||||
report.write_text(json.dumps({
|
||||
"first_nodes": len(first_pack.NODE_CLASS_MAPPINGS),
|
||||
"first_web": first_pack.WEB_DIRECTORY,
|
||||
"first_routes": len(first_routes),
|
||||
"second_nodes": len(second_pack.NODE_CLASS_MAPPINGS),
|
||||
"second_web": second_pack.WEB_DIRECTORY,
|
||||
"second_routes": len(ROUTES) - len(first_routes),
|
||||
"duplicates": sorted({f"{m} {p}" for m, p in ROUTES if ROUTES.count((m, p)) > 1}),
|
||||
"second_out": second_out,
|
||||
}), encoding="utf-8")
|
||||
|
||||
|
||||
class SecondCopyTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
report = Path(tmp) / "report.json"
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-S", str(Path(__file__).resolve()), "--child", tmp, str(report)],
|
||||
cwd=ROOT, capture_output=True, text=True, timeout=120,
|
||||
)
|
||||
if result.returncode or not report.exists():
|
||||
raise AssertionError(f"Loading two copies failed:\n{result.stdout}\n{result.stderr}")
|
||||
cls.found = json.loads(report.read_text(encoding="utf-8"))
|
||||
|
||||
def test_first_copy_loads_as_usual(self):
|
||||
self.assertGreater(self.found["first_nodes"], 0)
|
||||
self.assertGreater(self.found["first_routes"], 0)
|
||||
self.assertEqual(self.found["first_web"], "./js")
|
||||
|
||||
def test_second_copy_adds_no_routes(self):
|
||||
self.assertEqual(self.found["duplicates"], [])
|
||||
self.assertEqual(self.found["second_routes"], 0)
|
||||
|
||||
def test_second_copy_adds_no_nodes_or_browser_files(self):
|
||||
self.assertEqual(self.found["second_nodes"], 0)
|
||||
self.assertIsNone(self.found["second_web"])
|
||||
|
||||
def test_second_copy_names_both_folders(self):
|
||||
out = self.found["second_out"]
|
||||
self.assertIn("ComfyUI-AusBoss", out)
|
||||
self.assertIn("ausboss-nodes", out)
|
||||
out.encode("ascii") # cp1252 consoles: the message must be plain ASCII
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) == 4 and sys.argv[1] == "--child":
|
||||
child(Path(sys.argv[2]), Path(sys.argv[3]))
|
||||
else:
|
||||
unittest.main()
|
||||
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
if "nodes" in sys.modules and not hasattr(sys.modules["nodes"], "__path__"):
|
||||
del sys.modules["nodes"]
|
||||
|
||||
from nodes._single_copy import other_copy_that_loads
|
||||
|
||||
|
||||
def make_copy(root: Path, name: str, version: str, guarded: bool = True, pack: str = "ausboss-nodes") -> str:
|
||||
folder = root / name
|
||||
(folder / "nodes").mkdir(parents=True)
|
||||
(folder / "__init__.py").write_text("")
|
||||
(folder / "pyproject.toml").write_text(f'[project]\nname = "{pack}"\nversion = "{version}"\n')
|
||||
if guarded:
|
||||
(folder / "nodes" / "_single_copy.py").write_text("")
|
||||
return str(folder)
|
||||
|
||||
|
||||
def loads(folder: str, root: Path) -> bool:
|
||||
return other_copy_that_loads(folder, [str(root)]) is None
|
||||
|
||||
|
||||
class SingleCopyTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self._tmp.name).resolve()
|
||||
|
||||
def tearDown(self):
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_a_lone_copy_loads(self):
|
||||
a = make_copy(self.root, "ComfyUI-AusBoss", "2.24.0")
|
||||
make_copy(self.root, "some-other-pack", "9.0.0", pack="other")
|
||||
self.assertTrue(loads(a, self.root))
|
||||
|
||||
def test_exactly_one_of_two_copies_loads_and_it_is_the_newer(self):
|
||||
a = make_copy(self.root, "ComfyUI-AusBoss", "2.24.0")
|
||||
b = make_copy(self.root, "ausboss-nodes", "2.25.0")
|
||||
self.assertFalse(loads(a, self.root))
|
||||
self.assertTrue(loads(b, self.root))
|
||||
self.assertEqual(other_copy_that_loads(a, [str(self.root)])["version"], "2.25.0")
|
||||
|
||||
def test_same_version_still_picks_one(self):
|
||||
a = make_copy(self.root, "ComfyUI-AusBoss", "2.24.0")
|
||||
b = make_copy(self.root, "ausboss-nodes", "2.24.0")
|
||||
self.assertEqual([loads(a, self.root), loads(b, self.root)].count(True), 1)
|
||||
|
||||
def test_yields_to_an_older_copy_that_cannot_step_aside(self):
|
||||
new = make_copy(self.root, "ComfyUI-AusBoss", "2.24.0")
|
||||
make_copy(self.root, "ausboss-nodes", "2.22.0", guarded=False)
|
||||
self.assertFalse(loads(new, self.root))
|
||||
|
||||
def test_a_disabled_copy_does_not_count(self):
|
||||
a = make_copy(self.root, "ComfyUI-AusBoss", "2.24.0")
|
||||
make_copy(self.root, "ausboss-nodes.disabled", "2.25.0")
|
||||
self.assertTrue(loads(a, self.root))
|
||||
|
||||
def test_a_link_to_the_same_folder_is_one_copy(self):
|
||||
a = make_copy(self.root, "ComfyUI-AusBoss", "2.24.0")
|
||||
(self.root / "link").symlink_to(a)
|
||||
self.assertTrue(loads(a, self.root))
|
||||
self.assertTrue(loads(str(self.root / "link"), self.root))
|
||||
|
||||
def test_ten_is_newer_than_nine(self):
|
||||
a = make_copy(self.root, "ComfyUI-AusBoss", "2.9.0")
|
||||
b = make_copy(self.root, "ausboss-nodes", "2.10.0")
|
||||
self.assertFalse(loads(a, self.root))
|
||||
self.assertTrue(loads(b, self.root))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -44,6 +44,7 @@ NAMED = {
|
||||
"AusBossImageFolder": ["source", "folder"],
|
||||
"AusBossLaMaInpaint": ["model"],
|
||||
"AusBossLoadAudio": ["audio"],
|
||||
"AusBossLoadImage": ["image"],
|
||||
"AusBossLoadImagePad": ["image", "source_image"],
|
||||
"AusBossLoadVideo": ["video"],
|
||||
"AusBossLoraLoader": ["loras", "on_missing"],
|
||||
|
||||
@@ -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), "");
|
||||
});
|
||||
|
||||
@@ -39,7 +39,7 @@ test("a stopped run's message stays up until the next result, then goes", () =>
|
||||
const { face, setNote, shown } = noteFace();
|
||||
const stopped = SOURCE.match(/face\.stopped = \(message\) => \{([\s\S]*?)\n {2}\};/)?.[1];
|
||||
assert.ok(stopped, "touch_stage.mjs has no face.stopped");
|
||||
assert.match(stopped, /setNote\(face, String\(message \?\? ""\)\);\s*(?:\/\/[^\n]*\n\s*)*face\.stopNote = true;/,
|
||||
assert.match(stopped, /setNote\(face, stopMessage\(message\)\);\s*(?:\/\/[^\n]*\n\s*)*face\.stopNote = true;/,
|
||||
"a stop marks its message as a stop message, after it is set");
|
||||
// What face.stopped does to the note.
|
||||
setNote(face, "Mask by Touch: nothing is picked.");
|
||||
|
||||
Reference in New Issue
Block a user