Compare commits

..
Author SHA1 Message Date
ausboss 8911fd9eb3 Add edge-case tests and fix three small bugs they found
- Integer rounding: adding 0.5 before flooring could round large
  floats the wrong way; compare the fraction instead.
- H3 prompt sections: Windows line endings (CRLF) kept the six part
  names from being found; split with splitlines().
- Media routes: NaN, infinity or a huge number in a query parameter
  raised or passed through; fall back to the default.

26 new CPU-only tests cover resize and pad math, media paths, route
parameters, rounding and prompt parsing.
2026-11-04 00:07:46 -06:00
33 changed files with 423 additions and 232 deletions
+11 -11
View File
@@ -68,7 +68,7 @@ Rotate, crop and pad a picture by dragging handles on the node. Cyan squares cro
Tap a ratio under the preview to pad the picture to it, or to crop it with **Fit** set to crop. A lit ratio is the shape the canvas has right now: drag a handle to another shape and it goes dark, and the row says Custom. The padlock at the end of the row keeps the shape while you drag, and the button at the start turns the shape between portrait and landscape. **Reset crop** brings back the whole picture, and **Divisible by** makes the width and height divide evenly by a number such as 8, 16 or 32, for models that need it. Feather and output resizing are on the node and in the editor.
It returns the `image`, a `mask` of everything the model paints (padding, see-through parts of the source and the corners a rotation opens up), a `stitcher`, the untransformed `original`, the output `width` and `height`, and a `prompt_image`. Wire the stitcher into **Stitch Inpaint** after the sampler to put your original back. By default, see-through parts of a PNG, such as a cutout's missing background, are filled like the padding and painted; `prompt_image` and `original` show them as white. Give `prompt_image` to whatever writes your prompt, so it describes a real backdrop instead of the gray fill. **Load Image + Pad** has more fill choices and a separate reference output.
It returns the `image`, a `mask` of everything the model paints (padding, see-through parts of the source and the corners a rotation opens up), a `stitcher`, the untouched `original`, the output `width` and `height`, and a `prompt_image`. Wire the stitcher into **Stitch Inpaint** after the sampler to put your original back. See-through parts of a PNG, such as a cutout's missing background, are filled like the padding and painted; `prompt_image` and `original` show them as white. Give `prompt_image` to whatever writes your prompt, so it describes a real backdrop instead of the gray fill. **Load Image + Pad** has more fill choices and a separate reference output.
![The full-screen image editor with a rotated lake photo, cyan crop handles, orange padding handles, aspect ratios, and dimension controls.](assets/readme/image-editor.webp)
@@ -78,9 +78,9 @@ Krea 2 Rotate + Outpaint turns a photo, pads it and paints the new area in one p
### 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 skips resizing, though the canvas multiple can still trim a few pixels; anything higher resizes the picture before padding. A picture wired into `source_image` is padded instead of the chosen file.
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.
It returns the padded `image`, a `mask` of the padding, `width`, `height`, a `stitcher`, and an unpadded `reference` for the model, fitted to a multiple of 16 and shrunk if needed. Wire the stitcher into **Stitch Inpaint** after the sampler, and everything outside the seam stays your original picture.
It returns the padded `image`, a `mask` of the padding, `width`, `height`, a `stitcher`, and a smaller unpadded `reference` for the model. Wire the stitcher into **Stitch Inpaint** after the sampler, and everything outside the seam stays your original picture.
The Klein 9B Outpaint example pads its picture with Load Image + Pad. [Open the Klein 9B Outpaint workflow →](example_workflows/Klein%209B%20Outpaint%20%28AusBoss%29.json)
@@ -100,7 +100,7 @@ An optional mask is resized the same way. It returns `image`, `mask`, `width` an
Sets the size of the finished picture in megapixels and cuts your picture into tiles a model can redraw one at a time. A picture that fits in one tile stays whole, and a small picture is not redrawn far past the detail it has. Send the tiles through your sampler, then into Tiled Upscale Stitch with the stitcher.
Megapixels is the one control to set. Under More are the tile size, the overlap, the grid multiple and the redraw limit, and an upscale model can be plugged in. It returns `tiles`, `stitcher`, `before` (your picture resized the plain way to the finished size), `width`, `height` and a `report` that says what was done.
Megapixels is the one control to set. Under More are the tile size, the overlap, the grid multiple and the redraw limit, and an upscale model can be plugged in. It returns `tiles`, `stitcher`, `before` (your picture enlarged the plain way to the finished size), `width`, `height` and a `report` that says what was done.
### Tiled Upscale Stitch
@@ -168,13 +168,13 @@ Picks one frame from a video, then gives you the same rotate, crop and pad contr
### Video Crop + Rotate + Pad → Clip
Applies one rotate, crop and pad to every frame of a trimmed clip. It has the source picker, a timeline with a playhead and frame-accurate IN and OUT handles, frame skipping, a Length control, the transform handles and output resizing. While you drag a trim handle, the node shows the exact first or last frame the run keeps. The bright part of the selection is what gets output.
Applies one rotate, crop and pad to every frame of a trimmed clip. It has the source picker, a timeline with a playhead and frame-accurate IN and OUT handles, frame skipping, a frame limit, the transform handles and output resizing. While you drag a trim handle, the node shows the exact first or last frame the run keeps. The bright part of the selection is what gets output.
Fill, feather and the resize budget sit on the node face, and a new clip keeps them. That matters for video outpainting, because the black, hard-edged canvas the model needs survives a source swap. A fresh Clip node starts with that canvas, and the editor's **Reset transform** goes back to it. **Frames for** trims the end to the frame count the next video model takes: LTX (8n+1) or Wan (4n+1). Tap a ratio to pad the clip to it; a new clip is padded to the lit ratio too. The padlock keeps the shape while you drag a handle.
It returns the frames, mask, audio, frame count, fps, size, duration, a `stitcher`, and the chosen `original` frames before the transform. **Inpaint & Stitch** in the editor sets the protected source area and previews the blend. After a video outpaint, wire the stitcher straight into **Stitch Inpaint**, with no Crop For Inpaint in between. It works in chunks to keep memory down.
**Length** sets a number of output frames from IN. Drag IN or the small middle grip to move the window; drag OUT or type the count to change its length. The count stays when you choose a new clip, but the source can end before it is reached. For an exact count, link `fixed_frames`: 120 frames at 24 fps is five seconds. A positive value overrides OUT/`end_frame`, Length, `frame_load_cap` and Frames for; 0 turns it off. The source must be long enough, and IN can move earlier near the end so the window fits.
**Fixed frames** sets an exact output length: 120 frames at 24 fps is five seconds. Drag either handle, or drag the highlighted band to move the whole window. Type the count, or link a frame count into `fixed_frames`. 0 turns the fixed length off. The frame limit still caps the length.
![The full-screen video editor with a portrait clip padded to landscape, an Inpaint and Stitch panel, and a timeline with IN and OUT handles.](assets/readme/video-editor.webp)
@@ -307,7 +307,7 @@ It returns `width`, `height` and an empty image latent. The gear picks the laten
### Layout Boxes
Draw boxes on a canvas and say what goes in each one: a thing, or words to write in the picture. Wire `elements` to MOSS-Image Layout Prompt and the picture puts each thing where its box is. It shares the `background`, `bboxes`, `width` and `height` inputs with ComfyUI's own Create Bounding Boxes. For the same boxes, its first three outputs match, with `mask` added after them. It saves its boxes in its own `layout` text, and its canvas also works in the classic node look.
Draw boxes on a canvas and say what goes in each one: a thing, or words to write in the picture. Wire `elements` to MOSS-Image Layout Prompt and the picture puts each thing where its box is. It does what ComfyUI's own Create Bounding Boxes does, with the same inputs and the same outputs from the same boxes, and its canvas also works in the classic node look.
Drag to draw a box, drag it to move it and its handles to size it; under the canvas each box gets **object** or **text**, its words and up to five colors. A picture on `background` shows under the boxes and sets the canvas size, `width` and `height` can come from Latent Size, and boxes on the `bboxes` wire fill the canvas when the workflow runs. **Grid** snaps the boxes as you drag them, starts off over a picture and takes its size from the gear, **Layouts** starts from a ready arrangement, the arrow keys move the chosen box one pixel at a time (ten with Shift), and Delete and Ctrl + D remove and copy it.
@@ -321,7 +321,7 @@ One seed for several samplers. **Random**, **Fixed** and **Step** set what happe
### Select Every Nth
Keeps every nth picture of a batch, after an offset that counts from 0. With nth 2, offset 0 keeps frames 1, 3 and 5, and offset 1 keeps 2, 4 and 6. The order stays the same. Wire the batch's fps in, and the `fps` output is divided by nth, so the thinned clip keeps its playback speed. Offset removes the start, and rounding to whole frames can change the duration.
Keeps every nth picture of a batch, after an offset that counts from 0. With nth 2, offset 0 keeps frames 1, 3 and 5, and offset 1 keeps 2, 4 and 6. The order stays the same. Wire the batch's fps in, and the `fps` output is divided by nth, so the thinned clip still lasts as long.
### Split Batch
@@ -341,7 +341,7 @@ Type what happens, one line for each moment, and leave a blank line to cut to a
### Integer
One whole number (`INT`) on a scrub control, for sharing a size, count or other whole number across nodes. It takes a typed sum: `1024*1.5`.
One whole number (`INT`) on a scrub control, for sharing a size, count or other whole number across nodes. Like every number box in the pack, it takes a typed sum: `1024*1.5`.
### Float
@@ -437,7 +437,7 @@ People who download your workflow get the nodes through ComfyUI-Manager's missin
- Hold Shift while you turn the green rotation handle to snap to 15° steps.
- In the full-screen editor, the mouse wheel zooms, and the middle mouse button or Alt-drag pans. On the node itself, the wheel and middle mouse button still move the graph.
- **Alt+E** opens the selected node's transform editor. Change the key in Settings → Keybindings.
- The video editors have frame stepping, playback and exact timeline seeking, on the node face as well as in the editor. The Clip node's IN and OUT handles sit on the source's own frames and show the first and last frames the run keeps.
- The video editors have frame stepping, playback and exact timeline seeking, on the node face as well as in the editor. The Clip node's IN and OUT handles sit on the source's own frames, so the frame shown for IN is the first one decoded and the frame shown for OUT is the last.
- Your own aspect presets go in `ausboss_presets.json` beside the pack. Copy [`ausboss_presets_example.json`](ausboss_presets_example.json) to start. Your file survives updates.
LoRA Loader and Latent Size have their own gear menus for per-node preferences.
@@ -446,7 +446,7 @@ The video transform nodes read a video you pick from a list of ComfyUI's input f
## Optional extras
These optional features need an extra Python package. Install it with the Python that runs ComfyUI. For the Windows portable build, that is `python_embeded\python.exe -m pip install ...` from the portable folder.
Three features need an extra Python package. Install it with the Python that runs ComfyUI. For the Windows portable build, that is `python_embeded\python.exe -m pip install ...` from the portable folder.
```bash
python -m pip install opencv-contrib-python # Mask Refine: guided filter
+2 -1
View File
@@ -17,7 +17,8 @@ reduce color drift between frames.
**Reference** (`reference_mode`) is **first frame**.
- **Method** (`method`): How the colors move.
- **lab** — per-channel mean/std shift in LAB. Perceptual and the safe
default: adjusts overall casts and contrast. It can also change hues.
default: corrects overall casts and contrast without shifting
individual hues around.
- **rgb** — the same mean/std shift in raw channels.
- **mkl** — maps the full color covariance; best when hues are rotated,
not just shifted.
+1 -1
View File
@@ -110,7 +110,7 @@ parts back.
## Technical details
- Starting values: Context `1.2`, Blend `16` px, Multiple `32`; Grow, Blur,
- Starting values: Context `1.2`, Blend `16` px, Multiple `8`; Grow, Blur,
Extra context, the Target rows and the Extend rows start at `0`.
- With no Target size set, the crop is still rounded up to **Multiple**, so
with **Stay in picture** on it can still reach a few pixels (at most
+1 -2
View File
@@ -22,8 +22,7 @@ Wired to a sampler's **denoise**, it goes from 0 to 1 and no further: type
- It works through a Reroute and into a box (subgraph) too.
- Wired to two settings, it keeps to what both allow.
- Unwire it and it uses its own range again: -1,000,000,000,000,000 to
1,000,000,000,000,000, with a stored step of `0.001`.
- Unwire it and it takes any number again.
- A new box takes the value of the first setting you wire it to, so wiring
it up changes nothing until you move it. A box you have already set, or
one that came with a workflow, keeps its own value.
+9 -26
View File
@@ -77,9 +77,8 @@ Some scripts, with the jobs they need:
```
A picture that is off simply isn't counted. If a line still names it, the node
stops and tells you which one. A plain script needs at least one picture or
a video to start from. A prompt already written in H3's six parts can run
without either.
stops and tells you which one. It needs at least one picture or a video to start
from.
## Use a video
@@ -97,9 +96,7 @@ Connect a video, then pick what it is for:
- **new voice**: the same clip with a new voice. Connect the voice, and type
what they say in quotes.
Swap and new voice ignore **Length**. They use the video's frame count,
treated as 24 fps, capped at 15 seconds and rounded down to a length H3 takes.
This can shorten the requested result; the video output itself stays whole.
Swap and new voice ignore **Length** and keep the video's own length.
## What you can type
@@ -122,13 +119,12 @@ This can shorten the requested result; the video output itself stays whole.
A `camera:` or `sound:` line inside a group of lines belongs to that shot. On its
own, with blank lines around it, it is for the whole clip.
Already wrote H3's six parts yourself? Paste them in and the story is kept.
Spacing is cleaned up. A connected voice or song still updates the audio
parts. The Sound and Music boxes do not add to this form of prompt.
Already wrote H3's six parts yourself? Paste them in and they go through as
they are.
## What you can plug in
- **picture_1**, **picture_2**, **picture_3**, **picture_4**: your pictures.
- **picture_1** to **picture_4**: your pictures.
- **video** and **video_sound**: the frames and the sound of a video. Only keep
going uses the sound.
- **voice**: a clip of someone talking, about 10 seconds, plus words in quotes
@@ -163,16 +159,13 @@ as not plugged in.
- **Sound** (`sound`): what the clip sounds like, for the whole clip. Same as a
`sound:` line.
- **Music** (`music`): the music for the whole clip. Same as a `music:` line.
- **Length** (`seconds`): requested seconds, `5` to `15`, starting at `8`.
The `frames` output rounds this to a length H3 takes at 24 fps.
- **Length** (`seconds`): how long the clip is, 5 to 15.
- **Camera** (`camera`): how the camera moves the whole way through.
- **Look** (`look`): from the picture keeps your photo's look. The others redraw
the clip. Clay and 3D don't work from words, so change your picture first with
an edit model.
- **Picture 1** to **Picture 4** (`job_1`, `job_2`, `job_3`, `job_4`): what
each picture is for, on the picture chips under the bar. An empty slot
shows as a faint `+ @3`.
The first three start on `person`; the fourth starts on `thing`.
- **Picture 1** to **Picture 4** (`job_1` to `job_4`): what each picture is
for, on the picture chips under the bar. An empty slot shows as a faint `+ @3`.
- **Video** (`video_job`): what the video is for, on the video chip.
- **Song starts at** (`song_start`, under **More**): the second of the song to
start from, so the clip gets the good part.
@@ -218,16 +211,6 @@ as not plugged in.
## Technical details
H3 lengths use `17n + 5` frames, with `n` at least 1. A requested length
rounds to the nearest such count at 24 fps: `5` seconds gives `124` frames,
and `15` gives `362`. Swap and new voice round down instead, after capping
the source count at `360`, so their largest output length is `345` frames.
This node has no input for the source video's fps; change its rate to
24 fps first when its timing must match.
With a six-part prompt and no pictures or video, `width` and `height` are
`1344` and `756`. Unused picture, video and audio outputs are empty.
Code does the reading, so nothing typed can be lost or moved. It follows Pika's
and MiniMax's published prompt guides: every picture is defined once, each one
states what is kept and how, and a person who changes clothes or moves to a new
@@ -266,30 +266,6 @@ The editor's right side holds the stitcher's settings, the same as on the clip n
marks it. See-through parts of a PNG follow the same switch; a cutout usually stores
black or its old background there, so leave a cutout on fill.
## Transform values
The handles and editor write these inputs. An API prompt can set them too.
| Input | Default | What it sets |
| --- | --- | --- |
| `rotation_degrees` | `0` | Clockwise turn, from `-180` to `180` degrees. |
| `crop_aspect_ratio` | `free` | Crop shape: `free`, `source`, or a width:height ratio. |
| `crop_x`, `crop_y` | `0` | Crop's left and top, in pixels of the turned picture; `0` to `65536`. |
| `crop_width`, `crop_height` | `0` | Crop size, `0` to `65536`; `0` keeps the available size from that position. |
| `pad_left`, `pad_top`, `pad_right`, `pad_bottom` | `0` | Added pixels on each side, `0` to `32768`. |
| `feather` | `24` | Mask feather in pixels, `0` to `4096`. The picture meets the fill at a hard edge. |
| `canvas_multiple` | `1` | Size multiple, `1` to `4096`; `1` turns rounding off. |
| `fill_color` | `#808080` | Fill for padding and empty corners. |
| `resize_to_megapixels` | `false` | Turn the output resize on. |
| `megapixels` | `1` | Resize budget, `0.01` to `16`; one megapixel here is 1024 × 1024 pixels. |
| `resize_method` | `lanczos` | Resize filter: `lanczos`, `area`, `bicubic`, `bilinear`, `nearest-exact`. |
| `resolution_steps` | `1` | Round resized sides to this multiple, `1` to `256`. |
The built-in crop ratios are `1:1`, `9:16`, `16:9`, `2:3`, `3:2`,
`3:4`, `4:3`, `9:21` and `21:9`. Other positive whole-number ratios
are accepted too. The resize runs after the transform; its step can change
a size that already met `canvas_multiple`.
## Technical details
- **Saved values.** The handles write plain number values that save with the
+2 -3
View File
@@ -114,9 +114,8 @@ the pictures repeat in order for as many runs as you queue.
skipped.
- By name means the way a person sorts: `photo_2` before `photo_10`.
- With **Subfolders** on, a picture's `filename` carries its folder in
front (`trips/beach.png` gives `trips_beach`). Folder separators become
underscores, so names can still collide: `trips/beach.png` and
`trips_beach.jpg` both give `trips_beach`.
front (`trips/beach.png` gives `trips_beach`), so two files with one name
never save over each other.
- **image**, **mask**, **filename** and **index** are lists. Pictures keep
their own sizes. They are not resized or stacked into one batch.
- Moving Picture on is done by the editor when a run is queued. A workflow
+6 -6
View File
@@ -6,8 +6,8 @@ wire it to every node that needs it.
## Controls
- **Value** (`value`): The whole number to send out. It starts at `0`. The
arrows and a drag move it by 1, or click and type. Its range is listed
below; use Seed for the full sampler seed range.
arrows and a drag move it by 1, or click and type. It holds numbers as
big as a seed.
- The gear, right: **Look**. **normal** is the usual box with its label.
**big** shows the number large in the middle of the node, with an arrow
on each side, for a setting people change on every run, such as the
@@ -22,7 +22,7 @@ Wired to a sampler's **steps**, it goes from 1 to 10000: type 0 and you get
- It works through a Reroute and into a box (subgraph) too.
- Wired to two settings, it keeps to what both allow.
- Unwire it and it uses its own whole-number range again.
- Unwire it and it takes any whole number again.
- A new box takes the value of the first setting you wire it to, so wiring
it up changes nothing until you move it. A box you have already set, or
one that came with a workflow, keeps its own value.
@@ -42,9 +42,9 @@ For arithmetic on the way — halving a size, adding a margin — feed this into
nearest one.
- The limits are kept by the node's face. A value sent straight through the
API is passed on as it is.
- On its own the box goes from -9,007,199,254,740,991 to
9,007,199,254,740,991, the largest whole number a browser holds exactly.
It stopped at 2,147,483,647 before, which most seeds are above.
- On its own the box goes up to 9,007,199,254,740,991, the largest whole
number a browser holds exactly. It stopped at 2,147,483,647 before, which
most seeds are above.
- The look is stored in the node's properties as `ausboss_text_look: "big"`, the
same as Text 🆎. It is not an input, so the saved value and what the node
sends are the same in both looks, and an older version of the pack shows
+2 -6
View File
@@ -152,9 +152,8 @@ removes it. The first color also colors the box on the canvas.
## In place of Create Bounding Boxes
Layout Boxes shares the `background`, `bboxes`, `width` and `height` inputs.
Its first three outputs match Create Bounding Boxes, with `mask` after them,
so those wires move straight over. For the same boxes it
Layout Boxes has the same inputs and the same outputs in the same order (and
`mask` after them), so the wires move straight over. For the same boxes it
gives the same `bboxes` and `elements`, and MOSS-Image Layout Prompt makes the
same prompt from them.
@@ -162,9 +161,6 @@ The boxes drawn on a Create Bounding Boxes do not move over by themselves. To
take them along, wire that node's `elements` into `bboxes` of Layout Boxes and
run once: they fill the canvas. Then remove the old node.
The saved editor values differ. Layout Boxes uses its own `layout` text;
it does not take Create Bounding Boxes' `editor_state` or `last_incoming`.
## Technical details
- The boxes are kept on the node as text, in the hidden `layout` input. Each
+4 -7
View File
@@ -26,9 +26,9 @@ padding value unchanged.
- **Reset padding** appears in the top-right corner while there is padding
to clear; it sets every side a link does not drive back to 0.
- Clicks on empty canvas space fall through, so the node still drags.
- The hidden `pad_left`, `pad_top`, `pad_right` and `pad_bottom` widgets
hold the real values. The canvas sets them, so undo, save/load and the API
format all see plain INT widgets.
- The hidden `pad_left/top/right/bottom` widgets hold the real values — the
canvas is their remote control, so undo, save/load, and the API format all
see plain INT widgets.
## Guarantees
@@ -41,8 +41,6 @@ padding value unchanged.
## Controls
- **Exact padding** (`pad_left`, `pad_top`, `pad_right`, `pad_bottom`):
Pixels to add on each side. Each starts at `0` and accepts `0` to `16384`.
- **Source** (`image`): Choose or upload from ComfyUI's input folder; the list previews the image under the pointer and filters as you type. A picture ComfyUI made, named like `photo.png [output]`, loads too, as it does in core Load Image.
- **source_image** (optional socket): Wire an image from another node here
and it is padded instead of the file; the Source row dims while the wire
@@ -52,8 +50,7 @@ padding value unchanged.
loader that feeds one image per run pads a whole folder the same way.
- **Fill** / **Color** / **Backdrop** (`mode` / `fill_color` /
`backdrop_blur`): Five fills — `color`, `edge`, `edge pixel`,
`pillarbox blur`, `mirror`. The defaults are `color`, `#808080` and
blur `0.5`. **Color** shows for `color`, **Backdrop** for
`pillarbox blur`, `mirror`. **Color** shows for `color`, **Backdrop** for
`pillarbox blur`. `mirror` reflects the picture outward at every edge (the
edge pixel included, and on past the picture's own size), so the padding
reads as more of the scene: the margin to give an edit model room to
+7 -14
View File
@@ -272,8 +272,10 @@ data.
The run side matches: a stale saved path resolves by name at load time
(exact → unique case-insensitive path → unique basename, with a one-line
console note when it remaps). A LoRA that is genuinely missing **stops the
run before anything is sampled**, and the error names the file. The error
also says where the switch is.
run before anything is sampled**, and the error names the file — a picture
that rendered without its LoRA is worse than no picture, above all for a
script or an agent that would happily judge it. The error also says where
the switch is.
Gear menu → **Stop on missing LoRA** (on by default) turns that off for this
node, and for LoRA Loaders you add later in this browser; nodes already saved
@@ -343,8 +345,8 @@ switched on for the old one.
- **Default strength** (starts at 1): the strength a newly added LoRA gets.
- **Strength step** (starts at 0.05): how far one drag step or arrow click
moves a strength. Shift always steps by 0.01.
- **Trigger word separator** (`trigger_separator`): what joins the
`triggers` output. It starts as a comma and a space.
- **Trigger word separator** (a comma and a space to start): what joins the
`triggers` output.
- **Hide file extension** and **Hide folder names** (both on): how names
show on rows. The full path stays in the tooltip.
@@ -354,18 +356,9 @@ switched on for the old one.
- **clip**: The CLIP with every enabled LoRA applied (empty when no CLIP
is connected).
- **triggers**: The selected trigger words from enabled rows, deduplicated,
joined with the gear menu's separator (a comma and a space by default).
joined with the gear menu's separator (a comma by default).
Rows with a strength of `0` (both model and CLIP) are skipped at load time
but still contribute their trigger words, so you can park a LoRA at zero
while comparing. A row skipped because its file is missing contributes none:
that LoRA is not in the picture.
## API inputs
- **model**: the model to patch.
- **loras**: the stack as JSON text, empty (`[]`) by default. Each row can
hold `name`, `enabled`, `strength`, `strength_clip` and `triggers`.
- **clip** (optional): the text encoder to patch. Without it, CLIP strengths
are ignored and the `clip` output is empty.
- **trigger_separator** and **on_missing**: the settings described above.
+6 -10
View File
@@ -39,27 +39,23 @@ names from here on; how it measures may still improve.
- **edited**: the edited picture.
- **source**: the original picture you edited, at the size the edit model
saw it (in the Qwen Image 2.1 Edit + Realign example, the Image Resize
output). The result comes out at this size. If you wire the padded canvas
itself here with its stitcher, the result is just the source rectangle,
without the padding. For a batch or a video, give
output). The result comes out at this size. For a batch or a video, give
one source picture, or one per edited frame.
- **stitcher** (optional): from Load Image + Pad 🆎 when you padded the
picture before editing. Image Crop + Rotate + Pad 🆎 works too. A Crop
For Inpaint 🆎 stitcher does not work here.
- **Fit** (`fit`): `zoom + shift` (the default) fixes the usual Qwen drift.
- **Fit**: `zoom + shift` (the default) fixes the usual Qwen drift.
`affine` also fixes a slight tilt.
- **Empty fill** (`empty_fill`): when the edit slid past the edge, a thin
strip ends up with nothing in it. `edge` (the default) stretches the nearest pixels
- **Empty fill**: when the edit slid past the edge, a thin strip ends up
with nothing in it. `edge` (the default) stretches the nearest pixels
into it, `source` fills it from the original, `gray` fills it flat gray
for inpainting. With a big enough margin there is no strip.
- **Max zoom** (`max_zoom`): the biggest zoom it will undo, in percent
(`1` to `50`, `20` by default).
- **Max zoom**: the biggest zoom it will undo, in percent (20 by default).
Anything bigger is treated as a change you meant and left alone.
## Outputs
- **image**: the edit, lined up with the original and at its size, or just
the source rectangle when a padded canvas and its stitcher are wired in.
- **image**: the edit, lined up with the original and at its size.
- **empty_mask**: white where the lined-up edit has nothing (a strip that
slid off the edge), black everywhere else. Crop the strip off or inpaint
it.
+4 -6
View File
@@ -65,23 +65,21 @@ file's name. A caption on the `caption_text` input writes the paired `.txt`.
## Inputs
- **images** (IMAGE): the batch to save. Each image becomes a separate file.
- **filename** (STRING, link only): the exact name from upstream - a Load
Image name, a caption tool's id. Its image extension is swapped for the
chosen format's (`photo123.jpg` -> `photo123.png`); a batch appends
`_001`, `_002`... A linked but empty value stops the run instead of saving
under a made-up name.
- **caption_text** (STRING, link only): saved as a UTF-8 `.txt` beside every
image with the same name (`portrait.png` -> `portrait.txt`). A non-blank
linked caption wins. If it is blank or unwired, the old `caption` box is
used instead. No sidecar is written when both are blank.
image with the same name (`portrait.png` -> `portrait.txt`). Empty writes
no sidecar. The old `caption` box still works underneath; a linked caption
wins.
Rooted paths and `..` in names typed on the node are rejected before the
run. A bad name arriving through the `filename` link stops the run at this
node, before any file is written. `on_existing`
from older workflows is still honored wherever a save can land on an
existing file (counter off, exact or linked names). Its choices are
`overwrite` (the default), `skip` and `error`.
existing file (counter off, exact or linked names).
## Name tokens
+2 -4
View File
@@ -18,10 +18,8 @@ before an expensive stage, or thin a long sweep down to samples.
## Outputs
- **images**: The kept frames, in their original order.
- **fps**: The wired rate divided by `nth`, to keep playback speed;
`0` when no fps is wired. Offset removes the start, and rounding to whole
frames can change the duration. Five frames thinned by `2` give three
frames at half the rate, which lasts slightly longer.
- **fps**: The wired rate divided by `nth`, so the kept frames still span
the same time; `0` when no fps is wired.
An offset at or past the end of the batch stops with the batch's size
instead of returning an empty batch, which no downstream node could use.
+3 -9
View File
@@ -8,17 +8,11 @@ Wire it like this: **Tiled Upscale 🆎** → your encode, sampler and decode
→ **Tiled Upscale Stitch 🆎**. Plug `stitcher` straight across. The nodes
in between run once for each tile by themselves.
## Input
- **image**: The picture or batch to cut into tiles. Every picture in a batch
uses the same tile positions.
## Megapixels
**Megapixels** (`megapixels`) is the size of the finished picture. It starts
at `4` and accepts `0` to `64`. It moves in half steps, and you can type a
value between those limits. `0` keeps the picture's own size, for a light
pass over a picture that is already big.
**Megapixels** (`megapixels`) is the size of the finished picture. It moves
in half steps, and you can type any number. `0` keeps the picture's own
size, for a light pass over a picture that is already big.
- A picture that fits in one tile stays whole: one pass, no joins.
- A bigger one is cut into the fewest tiles that fit, as square as they
@@ -28,11 +28,6 @@ picture before the tiles are joined.
Grain and color noise in an old photo do not throw it off, and a faded
photo comes back faded.
## Output
- **image**: The finished RGB picture at the requested size. The output is
a list, with one batch for each stitcher supplied.
## Technical details
- Neighbouring tiles are blended across the strip they share, fading from
@@ -31,30 +31,6 @@ generated-area `mask`, a `stitcher` for Stitch Inpaint, the selected
The editor has the same rail full width, plus first/last, ±1, ±25, ±50, ±100, play/pause, keyboard arrows (one frame, 10 with Shift), Home/End and Space to play or pause. **Save & close** keeps your edits; **Cancel** or Escape puts everything back, and asks first if anything changed. Only one frame preview request remains active; stale requests are cancelled, and frames already shown are served from a small cache.
## Transform values
The handles and editor write these inputs. An API prompt can set them too.
| Input | Default | What it sets |
| --- | --- | --- |
| `rotation_degrees` | `0` | Clockwise turn, from `-180` to `180` degrees. |
| `crop_aspect_ratio` | `free` | Crop shape: `free`, `source`, or a width:height ratio. |
| `crop_x`, `crop_y` | `0` | Crop's left and top, in pixels of the turned picture; `0` to `65536`. |
| `crop_width`, `crop_height` | `0` | Crop size, `0` to `65536`; `0` keeps the available size from that position. |
| `pad_left`, `pad_top`, `pad_right`, `pad_bottom` | `0` | Added pixels on each side, `0` to `32768`. |
| `feather` | `24` | Mask feather in pixels, `0` to `4096`. The picture meets the fill at a hard edge. |
| `canvas_multiple` | `1` | Size multiple, `1` to `4096`; `1` turns rounding off. |
| `fill_color` | `#808080` | Fill for padding and empty corners. |
| `resize_to_megapixels` | `false` | Turn the output resize on. |
| `megapixels` | `1` | Resize budget, `0.01` to `16`; one megapixel here is 1024 × 1024 pixels. |
| `resize_method` | `lanczos` | Resize filter: `lanczos`, `area`, `bicubic`, `bilinear`, `nearest-exact`. |
| `resolution_steps` | `1` | Round resized sides to this multiple, `1` to `256`. |
The built-in crop ratios are `1:1`, `9:16`, `16:9`, `2:3`, `3:2`,
`3:4`, `4:3`, `9:21` and `21:9`. Other positive whole-number ratios
are accepted too. The resize runs after the transform; its step can change
a size that already met `canvas_multiple`.
## Transform and outputs
Rotation, crop, padding, feathering, fill, **Divisible by**, the megapixel resize, gestures, `image`, and `mask` match the image node. The output batch contains one frame.
@@ -114,30 +114,6 @@ padding, keeping rotation and resize settings.
Pixel rounding can differ by one pixel between opposite bands. **Divisible by** and
the resize **Step** can slightly change the final aspect ratio.
## Transform values
The handles and editor write these inputs. An API prompt can set them too.
| Input | Default | What it sets |
| --- | --- | --- |
| `rotation_degrees` | `0` | Clockwise turn, from `-180` to `180` degrees. |
| `crop_aspect_ratio` | `free` | Crop shape: `free`, `source`, or a width:height ratio. |
| `crop_x`, `crop_y` | `0` | Crop's left and top, in pixels of the turned picture; `0` to `65536`. |
| `crop_width`, `crop_height` | `0` | Crop size, `0` to `65536`; `0` keeps the available size from that position. |
| `pad_left`, `pad_top`, `pad_right`, `pad_bottom` | `0` | Added pixels on each side, `0` to `32768`. |
| `feather` | `0` | Mask feather in pixels, `0` to `4096`. The picture meets the fill at a hard edge. |
| `canvas_multiple` | `1` | Size multiple, `1` to `4096`; `1` turns rounding off. |
| `fill_color` | `#000000` | Fill for padding and empty corners. |
| `resize_to_megapixels` | `false` | Turn the output resize on. |
| `megapixels` | `1` | Resize budget, `0.01` to `16`; one megapixel here is 1024 × 1024 pixels. |
| `resize_method` | `lanczos` | Resize filter: `lanczos`, `area`, `bicubic`, `bilinear`, `nearest-exact`. |
| `resolution_steps` | `1` | Round resized sides to this multiple, `1` to `256`. |
The built-in crop ratios are `1:1`, `9:16`, `16:9`, `2:3`, `3:2`,
`3:4`, `4:3`, `9:21` and `21:9`. Other positive whole-number ratios
are accepted too. The resize runs after the transform; its step can change
a size that already met `canvas_multiple`.
## Transform and outputs
Rotation, crop, padding, feathering, fill, **Divisible by**, and the editor's handles
@@ -177,25 +153,16 @@ The node performs no remote network requests and does not rewrite the source vid
## Connected timing inputs
Six optional timing inputs can take links above the editor:
Five optional sockets sit on the left, above the editor:
| Input | Meaning |
| --- | --- |
| `force_rate` | Sample at this fps before Every nth. 0 keeps the source rate; the allowed range is 0 to 1000 fps. Drops or repeats frames to keep playback speed. Accepts FLOAT or INT. |
| `force_rate` | Sample at this fps before Every nth. 0 keeps the source rate. Drops or repeats frames to keep playback speed. Accepts FLOAT or INT. |
| `start_frame` | Zero-based source start frame. Overrides timeline IN. |
| `end_frame` | Exclusive source end frame. 0 means the end of the source. Overrides timeline OUT. |
| `fixed_frames` | Exact output frames, 0 to 100000; 0 turns it off. A positive value overrides OUT/end_frame, Length, frame_load_cap and Frames for. The source must be long enough. Near the end, IN can move earlier so the window fits. |
| `fixed_frames` | Exact output frames from IN; the source must be long enough. Connected, it sets the length: OUT, Length and Frames for step aside. |
| `frame_load_cap` | Maximum frames after rate conversion and Every nth, before Frames for. 0 means unlimited. Connected, it sets the length in place of Length. |
| `last_seconds` | Load only this many seconds from the end of the clip, or of the part before OUT, without moving before IN. 0 keeps the current window. Use it to carry on from a clip: a long clip then costs no more memory than a short one. |
`seek_mode` (`frame index` by default, or `time seconds`), `frame_index`
and `frame_time` set only the preview position. Both position values start
at `0`; neither trims the output.
Fresh timing settings are IN/OUT `0`, Every nth `1`, Length (`max_frames`)
`0` and Frames for (`frame_snap`) `free`. Every nth accepts `1` to `512`;
Length accepts `0` to `100000`. The API choices for Frames for are `free`,
`8n+1` and `4n+1`; they round the kept count down.
| `last_seconds` | Load only this many seconds from the end of the clip, or of the part before OUT. 0 loads all of it. Use it to carry on from a clip: a long clip then costs no more memory than a short one. |
Frame bounds use the source's frame-rate grid, independent of `force_rate`.
The footer labels **Source fps** separately from **Output fps**. A direct
-4
View File
@@ -11,8 +11,4 @@ be there. For anything new, use
[LaMa Inpaint 🆎](AUSBOSS_NODES_LaMaInpaint.md) instead: the same engine
with the full set of controls and live previews.
## Output
- **image**: The inpainted image batch. Its size and unmasked pixels are kept.
This id will keep working; it just will not grow new features.
+13 -8
View File
@@ -1,8 +1,14 @@
# AusBoss nodes: a workflow guide
# AusBoss nodes: a guide for AI agents
Find the job in the table below, then read that node's help page before
wiring it. Each help page is `js/docs/<node id>.md` in this pack. ComfyUI
shows the same page in its node help panel. This guide ships with the pack.
Give this page to an AI agent and it can pick the right AusBoss node for a job,
wire it into a workflow, and find the help for every input. It ships with the
pack, so it is also in your ComfyUI install, under
`custom_nodes/<this pack>/js/docs/agents/`.
**If you are the agent:** find the job in the table below, then read that
node's help page before you wire it. Each help page is
`js/docs/<node id>.md` in this pack. ComfyUI shows the same page in its node
help panel.
## Which node for which job
@@ -29,14 +35,13 @@ shows the same page in its node help panel. This guide ships with the pack.
| Load or save a video | Load Video (`AUSBOSS_NODES_LoadVideo`), Save Video (`AUSBOSS_NODES_SaveVideo`) |
| Turn, crop and pad a video: one frame, or the whole clip | Video Crop + Rotate + Pad → Frame (`AUSBOSS_NODES_VideoCropRotatePad`), → Clip (`AUSBOSS_NODES_VideoCropRotatePadClip`) |
| Change a clip's frame rate | Frame Interpolate (`AUSBOSS_NODES_FrameInterpolate`) |
| Work with frame batches | Select Frame (`AUSBOSS_NODES_SelectFrame`), Select Every Nth (`AUSBOSS_NODES_SelectEveryNth`), Split Batch (`AUSBOSS_NODES_SplitBatch`), Merge Batches (`AUSBOSS_NODES_MergeBatches`) |
| One shared seed, number or text | Seed (`AUSBOSS_NODES_Seed`), Integer (`AUSBOSS_NODES_Integer`), Float (`AUSBOSS_NODES_Float`), Text (`AUSBOSS_NODES_Text`) |
| Write a MiniMax H3 or Pika H3 Flex prompt from a script and references | H3 Prompt Writer (`AUSBOSS_NODES_H3PromptWriter`) |
| Work with frame batches | Select Frame, Select Every Nth, Split Batch, Merge Batches (`AUSBOSS_NODES_SelectFrame`, `_SelectEveryNth`, `_SplitBatch`, `_MergeBatches`) |
| One shared seed, number or text | Seed, Integer, Float, Text (`AUSBOSS_NODES_Seed`, `_Integer`, `_Float`, `_Text`) |
| Do maths on numbers | Math Expression (`AUSBOSS_NODES_MathExpression`) |
| Show text on the node, or the picture while it is made | Show Text (`AUSBOSS_NODES_ShowText`), Step Preview (`AUSBOSS_NODES_StepPreview`) |
| Free graphics memory between steps | Free Memory (`AUSBOSS_NODES_FreeMemory`) |
| Time the whole run | Run Timer (`AUSBOSS_NODES_RunTimer`) |
| Turn parts of a workflow off and on | Workflow Switches (`AUSBOSS_NODES_WorkflowSwitches`): [workflow file guide](workflow_switches.md) |
| Turn parts of a workflow off and on | Workflow Switches (`AUSBOSS_NODES_WorkflowSwitches`): [its own agent guide](workflow_switches.md) |
| Explain a shared workflow on the canvas | Workflow Note (`AUSBOSS_NODES_WorkflowNote`), Callout (`AUSBOSS_NODES_Callout`) |
| Lay out boxes of things and words on a canvas | Layout Boxes (`AUSBOSS_NODES_LayoutBoxes`) |
+10 -8
View File
@@ -1,9 +1,12 @@
# Workflow Switches: workflow file guide
# Workflow Switches: a guide for AI agents
Add switches by writing the card into a saved workflow file. The card is
one node with no wires. It lists which nodes each switch controls in one
property. Pick the recipe below that fits, add the card, and set the nodes'
starting state.
Give this page to an AI agent and it can add switches to a ComfyUI workflow
for you. Nobody has to click through menus: the agent writes the card straight
into the workflow file.
**If you are the agent:** the card is one node in the workflow file. It has no
wires. It lists which nodes each switch controls, in one property. Pick the
recipe below that fits, add the card, and set the nodes' starting state.
## Before you start
@@ -91,9 +94,8 @@ To switch a whole group of nodes, put the group's `id` in `groups`.
### Pick one part
One pill: picking a name turns its nodes on and the other rows' nodes off. Open
it with one row's nodes at `0` and the rest at `4`. Two to four short names
sit in a pill when they fit within 400 px. More rows or longer names become
a list. A single row keeps the row view.
it with one row's nodes at `0` and the rest at `4`. Up to 4 names sit in a
pill, more become a list.
```json
"exclusive": true,
+1 -1
View File
@@ -37,7 +37,7 @@ def split_sections(text):
"""The six parts of a prompt by name, or None when one is missing."""
found = {}
current = None
for line in text.split("\n"):
for line in text.splitlines():
name = line.strip().rstrip(":")
if line.strip().endswith(":") and name in SECTIONS:
current = found.setdefault(name, [])
+4 -3
View File
@@ -177,9 +177,10 @@ def round_half_away_from_zero(value: float) -> int:
Python's round() rounds halves to even (2.5 -> 2), which reads as a bug
when the number is on screen next to the float it came from.
"""
if value >= 0:
return int(math.floor(value + 0.5))
return int(math.ceil(value - 0.5))
fraction, whole = math.modf(value)
# Compare the fraction directly: adding 0.5 can round the float before
# we round it, including changing an already whole large number.
return int(whole) + (1 if fraction >= 0.5 else -1 if fraction <= -0.5 else 0)
__all__ = [
+3 -2
View File
@@ -847,12 +847,13 @@ def _safe_route_error(exc: Exception) -> str:
def _query_int(request, name: str, default: int) -> int:
try:
return int(float(request.query.get(name, default)))
except (TypeError, ValueError):
except (TypeError, ValueError, OverflowError):
return default
def _query_float(request, name: str, default: float) -> float:
try:
return float(request.query.get(name, default))
value = float(request.query.get(name, default))
return value if math.isfinite(value) else default
except (TypeError, ValueError):
return default
+3 -4
View File
@@ -23,8 +23,7 @@ class AusBossSelectEveryNth:
"offset skips that many frames before the first kept one, so "
"nth 2 / offset 0 keeps frames 1, 3, 5 and offset 1 keeps 2, 4, 6. "
"Wire the batch's fps in and the fps output divides it by nth, so "
"the thinned clip keeps its playback speed. Offset and whole-frame "
"rounding can change its duration."
"the thinned clip keeps its duration."
)
SEARCH_ALIASES = ["every nth", "skip frames", "thin batch", "reduce frames", "ausboss"]
@@ -75,8 +74,8 @@ class AusBossSelectEveryNth:
RETURN_NAMES = ("images", "fps")
OUTPUT_TOOLTIPS = (
"The kept frames, in their original order.",
"The wired fps divided by nth, to keep playback speed; 0 when no fps "
"is wired. Offset and whole-frame rounding can change the duration.",
"The wired fps divided by nth, so the kept frames still span the same "
"time; 0 when no fps is wired.",
)
FUNCTION = "select"
+3 -4
View File
@@ -130,8 +130,8 @@ class AusBossH3PromptWriter:
"such as person, place or outfit, and name them @1 and @2 in your "
"lines. Connect a video to copy its moves or carry it on. Add a voice "
"or a song and it writes the lines H3 needs. H3's own six parts go "
"through with spacing cleaned up and connected audio added. Connect "
"a small text model and it fills in a one-line idea."
"through as they are. Connect a small text model and it fills in a "
"one-line idea."
)
SEARCH_ALIASES = [
"h3 prompt",
@@ -253,8 +253,7 @@ class AusBossH3PromptWriter:
"default": VIDEO_JOBS[0],
"tooltip": (
"What the video is for: copy its moves, swap the person in it, keep it going, or "
"give it a new voice. Swap and new voice ignore Seconds: the frame count is "
"treated as 24 fps, capped at 15 seconds and rounded down to an H3 length."
"give it a new voice. Swap and new voice ignore Seconds and keep the video's length."
),
},
),
+1 -2
View File
@@ -210,8 +210,7 @@ class AusBossSaveImage:
"tooltip": (
"Caption from upstream: saved as a .txt in the same "
"folder with the same name as the image (portrait.png "
"-> portrait.txt). Blank falls back to caption; "
"both blank means no sidecar."
"-> portrait.txt). Empty writes no sidecar."
),
},
),
+26
View File
@@ -20,6 +20,7 @@ from nodes._h3_prompt_helpers import (
clean_words,
find_beats,
join_sections,
next_label,
snap_cuts,
split_sections,
write_prompt,
@@ -87,6 +88,31 @@ def count_moments(line):
return 1 + len(re.findall(r"\bAt \d\d:", line))
class PromptTextHelperTests(unittest.TestCase):
def test_sections_round_trip_unicode_and_windows_newlines(self):
parts = split_sections(SIX)
parts["summary"] = '[reference generation] Café 雨 — "stay."'
parts["detailed_description"] += "\n\nA sign reads: café."
text = join_sections(parts)
self.assertEqual(split_sections(text), parts)
self.assertEqual(split_sections(text.replace("\n", "\r\n")), parts)
def test_incomplete_sections_are_not_mistaken_for_a_complete_prompt(self):
text = SIX.replace("non_diegetic_music:", "music:")
self.assertIsNone(split_sections(text))
def test_next_label_uses_the_highest_number_of_the_requested_kind(self):
text = "<Audio 2> <Audio 9> <Audio 2> <Picture 30> <Audio 4>"
self.assertEqual(next_label(text, "Audio"), 10)
self.assertEqual(next_label(text, "Video"), 1)
def test_blank_words_and_punctuation_stay_well_formed(self):
for text, expected in ((None, ""), ("\t\n ", ""), ("<>", ""),
(" café\n 雨! ", "café 雨!"), ("go.", "go.")):
with self.subTest(text=text):
self.assertEqual(clean_words(text), expected)
class ReadingTests(unittest.TestCase):
def test_every_line_is_a_moment_in_the_order_typed(self):
self.assertEqual(moments(LINES), ["She lowers the camera", "She looks at us and smiles", "She raises the camera again and a bright flash fires"])
+40
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from pathlib import Path
import math
import sys
import unittest
@@ -79,10 +80,37 @@ class EvaluateExpressionTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "parsed"):
evaluate_expression("1 +")
def test_non_finite_results_are_refused(self):
for expression, variables in (
("1e309", None),
("1e308 * 2", None),
("a + 1", {"a": float("inf")}),
("a", {"a": float("nan")}),
):
with self.subTest(expression=expression, variables=variables):
with self.assertRaisesRegex(ValueError, "non-finite"):
evaluate_expression(expression, variables)
def test_negative_division_and_power_precedence(self):
for expression, expected in (("-7 // 2", -4), ("-7 % 2", 1),
("-2 ** 2", -4), ("2 ** 3 ** 2", 512)):
with self.subTest(expression=expression):
self.assertEqual(evaluate_expression(expression), expected)
class ExpressionSafetyTests(unittest.TestCase):
"""The expression is attacker-controlled; everything here must be refused."""
def test_non_text_input_is_refused(self):
for value in (None, 12, True, ["1 + 2"]):
with self.subTest(value=value), self.assertRaisesRegex(ValueError, "as text"):
evaluate_expression(value)
def test_bitwise_operators_and_assignment_are_refused(self):
for expression in ("1 << 2", "4 >> 1", "1 | 2", "1 & 2", "1 ^ 2", "~1", "(a := 1)"):
with self.subTest(expression=expression), self.assertRaises(ValueError):
evaluate_expression(expression)
def test_rejects_import(self):
with self.assertRaisesRegex(ValueError, "__import__"):
evaluate_expression("__import__('os')")
@@ -150,6 +178,18 @@ class ExpressionSafetyTests(unittest.TestCase):
class RoundHalfAwayTests(unittest.TestCase):
def test_values_immediately_below_a_half_do_not_round_up(self):
below = math.nextafter(0.5, 0.0)
for value in (below, -below):
with self.subTest(value=value):
self.assertEqual(round_half_away_from_zero(value), 0)
def test_large_exact_integers_are_unchanged(self):
# Adding 0.5 to an odd integer here rounds it to the next float.
for value in (2**52 + 1, -(2**52 + 1), 2**53, -(2**53)):
with self.subTest(value=value):
self.assertEqual(round_half_away_from_zero(float(value)), value)
def test_halves_round_away_from_zero(self):
self.assertEqual(round_half_away_from_zero(2.5), 3)
self.assertEqual(round_half_away_from_zero(-2.5), -3)
+39
View File
@@ -124,6 +124,45 @@ class TempFolders(unittest.TestCase):
class InputFolderTests(TempFolders):
def test_symlink_chains_stay_inside_the_selected_folder(self):
for kind in ("input", "output", "temp"):
root = getattr(self, kind)
target = self.touch(root / "target.png")
(root / "first.png").symlink_to(target)
(root / "second.png").symlink_to(root / "first.png")
for resolve in (resolve_input_path, resolve_server_file):
with self.subTest(folder=kind, resolver=resolve.__name__):
self.assertEqual(resolve(f"second.png [{kind}]"), target)
def test_similarly_named_sibling_and_cross_folder_links_are_refused(self):
sibling = self.root / "input-backup"
sibling.mkdir()
target = self.touch(sibling / "pic.png")
(self.input / "sibling.png").symlink_to(target)
(self.input / "output.png").symlink_to(self.made)
for resolve in (resolve_input_path, resolve_server_file):
for name in ("sibling.png", "output.png"):
with self.subTest(name=name, resolver=resolve.__name__):
with self.assertRaisesRegex(ValueError, WHOLE_PATH_REFUSAL):
resolve(name)
def test_annotated_backslash_traversal_is_refused_before_disk_access(self):
for resolve in (resolve_input_path, resolve_server_file):
for kind in ("input", "output", "temp"):
for name in (f"sub\\..\\pic.png [{kind}]", f"..\\secret.png [{kind}]",
f"sub/../pic.png [{kind}]"):
with self.subTest(name=name, resolver=resolve.__name__):
with patch.object(Path, "resolve") as disk:
with self.assertRaisesRegex(ValueError, WHOLE_PATH_REFUSAL):
resolve(name)
disk.assert_not_called()
def test_directories_cannot_be_selected_as_files(self):
for resolve in (resolve_input_path, resolve_server_file):
with self.subTest(resolver=resolve.__name__):
with self.assertRaisesRegex(ValueError, "no longer"):
resolve("sub [input]")
def test_files_inside_the_input_folder_resolve(self):
self.assertEqual(resolve_input_path("pic.png"), self.inside)
self.assertEqual(resolve_input_path("sub/pic.png"), self.nested)
+137
View File
@@ -0,0 +1,137 @@
"""Exercise preview handlers offline, with decoding replaced by small fakes."""
from __future__ import annotations
import asyncio
import json
from pathlib import Path
import sys
import tempfile
import types
import unittest
from unittest.mock import patch
from aiohttp import web
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 as media
class QueryNumberTests(unittest.TestCase):
def test_missing_and_malformed_values_use_the_default(self):
for parser, default in ((media._query_int, 17), (media._query_float, 2.5)):
for query in ({}, {"value": ""}, {"value": "words"}, {"value": None}):
with self.subTest(parser=parser.__name__, query=query):
self.assertEqual(parser(types.SimpleNamespace(query=query), "value", default), default)
def test_non_finite_numbers_use_the_default(self):
for parser, default in ((media._query_int, 17), (media._query_float, 2.5)):
for value in ("nan", "NaN", "inf", "-inf", "1e999"):
with self.subTest(parser=parser.__name__, value=value):
request = types.SimpleNamespace(query={"value": value})
self.assertEqual(parser(request, "value", default), default)
def test_finite_decimal_and_exponent_values_keep_existing_conversion(self):
request = types.SimpleNamespace(query={"value": " -1.25e1 "})
self.assertEqual(media._query_int(request, "value", 0), -12)
self.assertEqual(media._query_float(request, "value", 0.0), -12.5)
class VideoRouteTests(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
# Keep the worker boundary awaitable without starting decoder threads.
async def run_worker(executor, function, *args):
return function(*args)
patcher = patch.object(asyncio.get_running_loop(), "run_in_executor", run_worker)
patcher.start()
self.addCleanup(patcher.stop)
def setUp(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.root = Path(tmp.name)
self.dirs = {kind: self.root / kind for kind in ("input", "output", "temp")}
for root in self.dirs.values():
root.mkdir()
(root / "clip.mp4").write_bytes(b"placeholder")
folders = types.SimpleNamespace(**{
f"get_{kind}_directory": lambda root=root: str(root)
for kind, root in self.dirs.items()
})
patcher = patch.object(media, "folder_paths", folders)
patcher.start()
self.addCleanup(patcher.stop)
# Only collect route decorators; no server or listener is started.
instance = types.SimpleNamespace(routes=web.RouteTableDef())
server = types.ModuleType("server")
server.PromptServer = types.SimpleNamespace(instance=instance)
with patch.dict(sys.modules, {"server": server}):
media.register_video_routes()
self.handlers = {route.path: route.handler for route in instance.routes}
async def call(self, endpoint, **query):
request = types.SimpleNamespace(query=query)
return await self.handlers[f"/ausboss/transform/video/{endpoint}"](request)
async def test_annotated_names_select_each_allowed_root(self):
for kind, root in self.dirs.items():
with self.subTest(folder=kind):
with patch.object(media, "cached_video_metadata", return_value={"fps": 24}) as worker:
response = await self.call("metadata", video=f"clip.mp4 [{kind}]")
self.assertEqual(response.status, 200)
self.assertEqual(json.loads(response.text), {"fps": 24})
worker.assert_called_once_with(root / "clip.mp4")
async def test_invalid_sources_never_reach_preview_workers(self):
outside = self.root / "outside.mp4"
outside.write_bytes(b"keep")
(self.dirs["input"] / "escape.mp4").symlink_to(outside)
sources = (
{"video": "../outside.mp4"},
{"video": str(outside)},
{"video": "escape.mp4"},
{"source_mode": "local path", "local_path": "../outside.mp4 [output]"},
{"source_mode": "unexpected", "video": "clip.mp4"},
)
for endpoint, name in (("metadata", "cached_video_metadata"),
("frame", "cached_preview"), ("storyboard", "storyboard_payload")):
for query in sources:
with self.subTest(endpoint=endpoint, query=query):
with patch.object(media, name) as worker:
response = await self.call(endpoint, **query)
self.assertEqual(response.status, 400)
self.assertIn("error", json.loads(response.text))
self.assertNotIn(str(self.root), response.text)
worker.assert_not_called()
async def test_non_finite_frame_parameters_are_normalized_before_decoding(self):
with patch.object(media, "cached_preview", return_value=(b"jpeg", 0, 0.0)) as worker:
response = await self.call(
"frame", video="clip.mp4", frame_index="inf", frame_time="nan",
max_width="1e999", max_height="-inf",
)
self.assertEqual(response.status, 200, response.text)
worker.assert_called_once_with(
self.dirs["input"] / "clip.mp4", "frame index", 0, 0.0, 1600, 1600
)
self.assertEqual(response.body, b"jpeg")
async def test_decoder_errors_do_not_reveal_server_paths(self):
for endpoint, name in (("metadata", "cached_video_metadata"),
("frame", "cached_preview"), ("storyboard", "storyboard_payload")):
with self.subTest(endpoint=endpoint):
error = ValueError(f"Cannot decode '{self.dirs['input'] / 'clip.mp4'}'")
with patch.object(media, name, side_effect=error):
response = await self.call(endpoint, video="clip.mp4")
self.assertEqual(response.status, 400)
self.assertEqual(json.loads(response.text), {"error": "The media request failed."})
if __name__ == "__main__":
unittest.main()
+19
View File
@@ -36,6 +36,25 @@ PADS = (3, 5, 2, 4) # left, top, right, bottom
class MaskContractTests(unittest.TestCase):
def test_rgba_single_pixel_axes_allow_padding_larger_than_the_source(self):
for height, width in ((1, 1), (1, 3), (3, 1)):
colors = torch.tensor([[0.2, 0.4, 0.6, 0.25], [0.8, 0.6, 0.4, 0.75]])
image = colors[:, None, None, :].expand(2, height, width, 4).clone()
original = image.clone()
for mode in PAD_MODES:
with self.subTest(size=(height, width), mode=mode):
out, mask = pad_image(image, 4, 3, 5, 6, mode, backdrop_blur=0.0)
self.assertEqual(tuple(out.shape), (2, height + 9, width + 9, 4))
self.assertEqual(tuple(mask.shape), (2, height + 9, width + 9))
self.assertTrue(torch.equal(out[:, 3:3 + height, 4:4 + width], image))
expected_mask = torch.ones_like(mask)
expected_mask[:, 3:3 + height, 4:4 + width] = 0
self.assertTrue(torch.equal(mask, expected_mask))
self.assertTrue(torch.isfinite(out).all())
if mode != "color":
self.assertTrue(torch.allclose(out, colors[:, None, None, :].expand_as(out)))
self.assertTrue(torch.equal(image, original))
def test_every_mode_shares_the_geometry_and_mask_contract(self):
image = rand_image(2, 16, 20, seed=1)
left, top, right, bottom = PADS
+60
View File
@@ -184,6 +184,18 @@ class PlanResizeTests(unittest.TestCase):
class ResampleBatchTests(unittest.TestCase):
def test_rgba_and_single_pixel_axes_keep_frames_and_channels_separate(self):
for batch_size in (1, 3):
colors = torch.arange(batch_size * 4, dtype=torch.float32).reshape(batch_size, 1, 1, 4) / 12
for source_h, source_w in ((1, 7), (7, 1), (3, 5)):
# A strided view exercises both the PIL and torch paths.
image = colors.expand(batch_size, source_h, source_w * 2, 4)[:, :, ::2]
for mode in INTERPOLATION_MODES:
with self.subTest(batch=batch_size, source=(source_h, source_w), mode=mode):
out = resample_batch(image, 9, 5, mode)
self.assertEqual(tuple(out.shape), (batch_size, 5, 9, 4))
self.assertTrue(torch.allclose(out, colors.expand_as(out), atol=1e-6))
def test_every_mode_returns_the_requested_shape(self):
image = rand_image(2, 16, 16, seed=1)
for mode in INTERPOLATION_MODES:
@@ -257,6 +269,54 @@ def run_resize(image, mask=None, **overrides):
class ApplyResizeTests(unittest.TestCase):
def test_nearest_scaling_keeps_each_image_and_mask_pixel_together(self):
mask = torch.arange(30, dtype=torch.float32).reshape(2, 3, 5) / 30
image = torch.stack((mask, 1 - mask, mask / 2, mask), dim=-1)
out, out_mask, width, height = run_resize(
image, mask, width=9, height=5, keep_proportion="stretch"
)
# Nearest pixel centres on this 5x3 -> 9x5 resize, written out so
# the check does not reuse the resampling helper's geometry.
rows = [0, 0, 1, 2, 2]
cols = [0, 0, 1, 1, 2, 3, 3, 4, 4]
self.assertEqual((width, height), (9, 5))
self.assertTrue(torch.equal(out, image[:, rows][:, :, cols]))
self.assertTrue(torch.equal(out_mask, mask[:, rows][:, :, cols]))
def test_odd_center_crop_keeps_rgba_and_each_mask_aligned(self):
for batch_size in (1, 3):
values = torch.arange(batch_size * 5 * 8, dtype=torch.float32).reshape(batch_size, 5, 8)
mask = values / values.numel()
image = mask.unsqueeze(-1).expand(-1, -1, -1, 4).clone()
original = image.clone()
out, out_mask, width, height = run_resize(
image, mask, width=5, height=5, keep_proportion="cover_crop"
)
self.assertEqual((width, height), (5, 5))
# Three excess columns: one comes off the left, two off the right.
self.assertTrue(torch.equal(out, image[:, :, 1:6]))
self.assertTrue(torch.equal(out_mask, mask[:, :, 1:6]))
self.assertTrue(torch.equal(image, original))
def test_odd_padding_keeps_rgba_and_single_or_per_frame_masks(self):
image = torch.zeros((3, 3, 5, 4))
image[..., 3] = 0.25
for mask_batch in (1, 3):
mask = torch.arange(mask_batch * 15, dtype=torch.float32).reshape(mask_batch, 3, 5) / 45
original = mask.clone()
out, out_mask, width, height = run_resize(
image, mask, width=5, height=6, keep_proportion="pad", fill_color="#ff0000"
)
self.assertEqual((width, height), (5, 6))
self.assertEqual(tuple(out.shape), (3, 6, 5, 4))
self.assertEqual(tuple(out_mask.shape), (mask_batch, 6, 5))
self.assertTrue(torch.equal(out[:, 1:4], image))
self.assertTrue(torch.equal(out_mask[:, 1:4], mask))
for rows in (slice(0, 1), slice(4, 6)):
self.assertTrue(torch.equal(out[:, rows], torch.tensor([1., 0., 0., 1.]).expand_as(out[:, rows])))
self.assertTrue(torch.all(out_mask[:, rows] == 1))
self.assertTrue(torch.equal(mask, original))
def test_stretch_hits_the_exact_target_and_reports_it(self):
image = rand_image(2, 24, 32, seed=3)
out, mask, width, height = run_resize(