Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a29c4a3c02 | ||
|
|
c24d9fa189 |
@@ -4,11 +4,6 @@ All notable changes to ComfyUI-AusBoss are documented here.
|
||||
|
||||
## Unreleased
|
||||
|
||||
- **New node: Load Audio.** Load a sound file and keep only the part you
|
||||
want. The node shows the sound as a wave: drag the IN and OUT handles to
|
||||
trim it, and press play to hear just that part. Set Max length to the
|
||||
longest sound the next step takes, and anything longer shows in red as
|
||||
cut off, so you see exactly what comes out before you run.
|
||||
- **Resizing one node of a selection now resizes them all.** With two or more
|
||||
nodes selected, pull the edge or corner of one: nodes stacked in a column
|
||||
get its width, nodes side by side in a row get its height, and a grid gets
|
||||
|
||||
@@ -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 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.
|
||||
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.
|
||||
|
||||

|
||||
|
||||
@@ -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 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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 enlarged 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 resized the plain way to the finished size), `width`, `height` and a `report` that says what was done.
|
||||
|
||||
### Tiled Upscale Stitch
|
||||
|
||||
@@ -162,25 +162,19 @@ Upload a video, or pick one from the Source list, which plays the clip under the
|
||||
|
||||
It returns the frames, audio, frame count, fps, width, height, duration and a core `VIDEO`. Only the part you kept is decoded, and it checks memory before a large load. When you save the frames, wire its fps into the save node.
|
||||
|
||||
### Load Audio
|
||||
|
||||
Upload a sound file, or pick one from the Source list, and the node shows it as a wave. Drag the IN and OUT handles to keep only the part you want, or type the times. Press play to hear just that part. Set **Max length** to the longest sound the next step can take, such as a video's length: anything past it turns red and is cut off, so you see what comes out before you run. Click the red length next to OUT to move OUT to the cut.
|
||||
|
||||
It returns the `audio` you kept and its length in `seconds`. Decoding reads from the beginning to the cut, so later selections take longer to load. [Help page](js/docs/AUSBOSS_NODES_LoadAudio.md).
|
||||
|
||||
### Video Crop + Rotate + Pad → Frame
|
||||
|
||||
Picks one frame from a video, then gives you the same rotate, crop and pad controls as the image node, plus an optional megapixel resize. Scrub to the frame with the playhead on the node; the editor adds frame-by-frame stepping and playback. It returns the transformed `image`, a `mask` of the added area, a `stitcher`, the chosen `original` frame, and the output `width` and `height`. For a whole clip, use the Clip version below.
|
||||
|
||||
### 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 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||

|
||||
|
||||
@@ -313,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 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -327,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 still lasts as long.
|
||||
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.
|
||||
|
||||
### Split Batch
|
||||
|
||||
@@ -347,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. Like every number box in the pack, 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. It takes a typed sum: `1024*1.5`.
|
||||
|
||||
### Float
|
||||
|
||||
@@ -443,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, so the frame shown for IN is the first one decoded and the frame shown for OUT is the last.
|
||||
- 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.
|
||||
- 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.
|
||||
@@ -452,7 +446,7 @@ The video transform nodes read a video you pick from a list of ComfyUI's input f
|
||||
|
||||
## Optional extras
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
```bash
|
||||
python -m pip install opencv-contrib-python # Mask Refine: guided filter
|
||||
|
||||
@@ -36,7 +36,6 @@ NODE_MODULES = [
|
||||
"node_layout_boxes",
|
||||
"node_literals",
|
||||
"node_load_image_pad",
|
||||
"node_load_audio",
|
||||
"node_load_video",
|
||||
"node_lora_loader",
|
||||
"node_mask_by_name",
|
||||
|
||||
@@ -17,8 +17,7 @@ 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: corrects overall casts and contrast without shifting
|
||||
individual hues around.
|
||||
default: adjusts overall casts and contrast. It can also change hues.
|
||||
- **rgb** — the same mean/std shift in raw channels.
|
||||
- **mkl** — maps the full color covariance; best when hues are rotated,
|
||||
not just shifted.
|
||||
|
||||
@@ -110,7 +110,7 @@ parts back.
|
||||
|
||||
## Technical details
|
||||
|
||||
- Starting values: Context `1.2`, Blend `16` px, Multiple `8`; Grow, Blur,
|
||||
- Starting values: Context `1.2`, Blend `16` px, Multiple `32`; 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
|
||||
|
||||
@@ -22,7 +22,8 @@ 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 takes any number again.
|
||||
- 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`.
|
||||
- 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.
|
||||
|
||||
@@ -77,8 +77,9 @@ 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. It needs at least one picture or a video to start
|
||||
from.
|
||||
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.
|
||||
|
||||
## Use a video
|
||||
|
||||
@@ -96,7 +97,9 @@ 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** and keep the video's own length.
|
||||
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.
|
||||
|
||||
## What you can type
|
||||
|
||||
@@ -119,12 +122,13 @@ Swap and new voice ignore **Length** and keep the video's own length.
|
||||
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 they go through as
|
||||
they are.
|
||||
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.
|
||||
|
||||
## What you can plug in
|
||||
|
||||
- **picture_1** to **picture_4**: your pictures.
|
||||
- **picture_1**, **picture_2**, **picture_3**, **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
|
||||
@@ -159,13 +163,16 @@ 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`): how long the clip is, 5 to 15.
|
||||
- **Length** (`seconds`): requested seconds, `5` to `15`, starting at `8`.
|
||||
The `frames` output rounds this to a length H3 takes at 24 fps.
|
||||
- **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` to `job_4`): what each picture is
|
||||
for, on the picture chips under the bar. An empty slot shows as a faint `+ @3`.
|
||||
- **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`.
|
||||
- **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.
|
||||
@@ -211,6 +218,16 @@ 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,6 +266,30 @@ 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
|
||||
|
||||
@@ -114,8 +114,9 @@ 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`), so two files with one name
|
||||
never save over each other.
|
||||
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`.
|
||||
- **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,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. It holds numbers as
|
||||
big as a seed.
|
||||
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.
|
||||
- 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 takes any whole number again.
|
||||
- Unwire it and it uses its own whole-number range 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 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.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
@@ -152,8 +152,9 @@ removes it. The first color also colors the box on the canvas.
|
||||
|
||||
## In place of Create Bounding Boxes
|
||||
|
||||
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
|
||||
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
|
||||
gives the same `bboxes` and `elements`, and MOSS-Image Layout Prompt makes the
|
||||
same prompt from them.
|
||||
|
||||
@@ -161,6 +162,9 @@ 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
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
# Load Audio
|
||||
|
||||
Loads a sound file and hands on only the part you pick. Use it for a voice
|
||||
clip, a song or any sound a video model or a save node takes, when you want
|
||||
one part of the file and not the whole thing.
|
||||
|
||||
The node shows the whole file as a wave, so you can see where the loud parts
|
||||
are. The part that comes out is bright. Drag the white **IN** and **OUT**
|
||||
handles to set where it starts and stops, drag the bright part to slide it,
|
||||
or click somewhere else on the wave to move it there. You can also type a time
|
||||
under the wave, in seconds (`12.5`) or minutes and seconds (`1:02.5`).
|
||||
|
||||
Press **▶** to hear only the part that comes out. **LOOP** plays it again and
|
||||
again.
|
||||
|
||||
## Max length
|
||||
|
||||
Set **Max length** to the longest sound the next step can take, such as the
|
||||
length of the video you are making. The start stays where it is, and anything
|
||||
past the limit turns red with a dashed line where it is cut. Next to OUT,
|
||||
the node shows how long the sound that comes out is, such as `15.0 s`. It
|
||||
turns red when part of your pick is cut; hold the mouse over it to see how
|
||||
much, and click it to move OUT to the cut. Play stops at the cut, so what you
|
||||
hear is what comes out. `0` means no limit.
|
||||
|
||||
Max length can be wired. When the wire comes from a number node such as
|
||||
Float 🆎, the wave shows the cut for that number; otherwise the length's tooltip says
|
||||
`max wired` and the run still cuts the sound to the wired length.
|
||||
|
||||
## Controls
|
||||
|
||||
- **Source** (`audio`): a sound file in ComfyUI's input folder. **Upload**
|
||||
adds one. Videos are listed as well, for their sound.
|
||||
- **Max length** (`max_seconds`): the longest the sound may be, in seconds.
|
||||
`0` means no limit.
|
||||
- **IN** (`start_seconds`): where the sound starts.
|
||||
- **OUT** (`end_seconds`): where the sound stops. `0` means the end of the
|
||||
file.
|
||||
|
||||
## Outputs
|
||||
|
||||
- `audio`: the part you picked, for any node that takes `AUDIO`.
|
||||
- `seconds`: how long that sound is.
|
||||
|
||||
## Technical details
|
||||
|
||||
- Only the picked part is returned. Decoding starts at the beginning,
|
||||
discards sound before IN and stops at OUT or the Max length cut. A late
|
||||
selection in a long recording takes longer to load.
|
||||
- Preview playback pauses when you switch tabs or hide the page.
|
||||
- The sound keeps its own sample rate and channels.
|
||||
- A start past the end of the file stops the run with a message that says so.
|
||||
- The wave is drawn in the browser from the file itself; nothing extra is
|
||||
stored with the workflow besides the four values above.
|
||||
@@ -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/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.
|
||||
- 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.
|
||||
|
||||
## Guarantees
|
||||
|
||||
@@ -41,6 +41,8 @@ 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
|
||||
@@ -50,7 +52,8 @@ 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`. **Color** shows for `color`, **Backdrop** for
|
||||
`pillarbox blur`, `mirror`. The defaults are `color`, `#808080` and
|
||||
blur `0.5`. **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
|
||||
|
||||
@@ -272,10 +272,8 @@ 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 — 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.
|
||||
run before anything is sampled**, and the error names the file. 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
|
||||
@@ -345,8 +343,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** (a comma and a space to start): what joins the
|
||||
`triggers` output.
|
||||
- **Trigger word separator** (`trigger_separator`): what joins the
|
||||
`triggers` output. It starts as a comma and a space.
|
||||
- **Hide file extension** and **Hide folder names** (both on): how names
|
||||
show on rows. The full path stays in the tooltip.
|
||||
|
||||
@@ -356,9 +354,18 @@ 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 by default).
|
||||
joined with the gear menu's separator (a comma and a space 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.
|
||||
|
||||
@@ -39,23 +39,27 @@ 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. For a batch or a video, give
|
||||
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
|
||||
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**: `zoom + shift` (the default) fixes the usual Qwen drift.
|
||||
- **Fit** (`fit`): `zoom + shift` (the default) fixes the usual Qwen drift.
|
||||
`affine` also fixes a slight tilt.
|
||||
- **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** (`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**: the biggest zoom it will undo, in percent (20 by default).
|
||||
- **Max zoom** (`max_zoom`): the biggest zoom it will undo, in percent
|
||||
(`1` to `50`, `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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
@@ -65,21 +65,23 @@ 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`). Empty writes
|
||||
no sidecar. The old `caption` box still works underneath; a linked caption
|
||||
wins.
|
||||
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.
|
||||
|
||||
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).
|
||||
existing file (counter off, exact or linked names). Its choices are
|
||||
`overwrite` (the default), `skip` and `error`.
|
||||
|
||||
## Name tokens
|
||||
|
||||
|
||||
@@ -18,8 +18,10 @@ 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`, so the kept frames still span
|
||||
the same time; `0` when no fps is wired.
|
||||
- **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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -8,11 +8,17 @@ 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 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.
|
||||
**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.
|
||||
|
||||
- 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,6 +28,11 @@ 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,6 +31,30 @@ 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,6 +114,30 @@ 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
|
||||
@@ -153,16 +177,25 @@ The node performs no remote network requests and does not rewrite the source vid
|
||||
|
||||
## Connected timing inputs
|
||||
|
||||
Five optional sockets sit on the left, above the editor:
|
||||
Six optional timing inputs can take links above the editor:
|
||||
|
||||
| Input | Meaning |
|
||||
| --- | --- |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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 from IN; the source must be long enough. Connected, it sets the length: OUT, Length and Frames for step aside. |
|
||||
| `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. |
|
||||
| `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. 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. |
|
||||
| `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.
|
||||
|
||||
Frame bounds use the source's frame-rate grid, independent of `force_rate`.
|
||||
The footer labels **Source fps** separately from **Output fps**. A direct
|
||||
|
||||
@@ -11,4 +11,8 @@ 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.
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
# AusBoss nodes: a guide for AI agents
|
||||
# AusBoss nodes: a workflow guide
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Which node for which job
|
||||
|
||||
@@ -33,16 +27,16 @@ help panel.
|
||||
| Apply a stack of LoRAs | LoRA Loader (`AUSBOSS_NODES_LoraLoader`) |
|
||||
| Krea 2 prompt, and Krea 2 outpainting | Krea 2 Encode (`AUSBOSS_NODES_Krea2Encode`), Krea 2 Outpaint Model Patch (`AUSBOSS_NODES_Krea2OutpaintModelPatch`) |
|
||||
| Load or save a video | Load Video (`AUSBOSS_NODES_LoadVideo`), Save Video (`AUSBOSS_NODES_SaveVideo`) |
|
||||
| Load a sound file and keep only part of it, up to a set length | Load Audio (`AUSBOSS_NODES_LoadAudio`) |
|
||||
| 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, 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`) |
|
||||
| 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`) |
|
||||
| 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`): [its own agent guide](workflow_switches.md) |
|
||||
| Turn parts of a workflow off and on | Workflow Switches (`AUSBOSS_NODES_WorkflowSwitches`): [workflow file 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`) |
|
||||
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
# Workflow Switches: a guide for AI agents
|
||||
# Workflow Switches: workflow file guide
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Before you start
|
||||
|
||||
@@ -94,8 +91,9 @@ 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`. Up to 4 names sit in a
|
||||
pill, more become a list.
|
||||
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.
|
||||
|
||||
```json
|
||||
"exclusive": true,
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
// Load Audio's window rules for the face, mirroring audio_window in
|
||||
// nodes/_audio_trim_helpers.py so the wave shows exactly what one Run hands
|
||||
// on. Pure: tests/load_audio_window.test.mjs runs it without a browser.
|
||||
import { inputNumber } from "../shared/clip_rate.mjs";
|
||||
import { dragTrimHandle } from "../load_video/trim_preview.mjs";
|
||||
|
||||
// Calculated outputs stay unknown until execution, even if the source has numbers.
|
||||
export function wiredMax(node) {
|
||||
const input = node.inputs?.find((slot) => slot.name === "max_seconds" || slot.widget?.name === "max_seconds");
|
||||
if (input?.link == null) return { wired: false, value: null };
|
||||
return { wired: true, value: inputNumber(node, "max_seconds", null) };
|
||||
}
|
||||
|
||||
export function typedTrimBounds(duration, bounds, edge, seconds) {
|
||||
// OUT 0 is the saved widget's "to the end", before handle clamping.
|
||||
if (edge === "end" && seconds === 0) return { start: bounds.start, end: duration };
|
||||
return dragTrimHandle(duration, bounds, edge, seconds / duration);
|
||||
}
|
||||
|
||||
function finite(value, fallback = 0) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : fallback;
|
||||
}
|
||||
|
||||
// The pick, and what comes out of it. `end` 0 means "to the end of the
|
||||
// file". A max above 0 keeps the start and cuts off anything past
|
||||
// start + max. All times in seconds; duration is the file's length.
|
||||
export function outputWindow(durationValue, startValue, endValue, maxValue = 0) {
|
||||
const duration = Math.max(0, finite(durationValue));
|
||||
const start = Math.min(Math.max(0, finite(startValue)), duration);
|
||||
const pickedEnd = finite(endValue) > 0 ? Math.min(finite(endValue), duration) : duration;
|
||||
const end = Math.max(start, pickedEnd);
|
||||
const max = Math.max(0, finite(maxValue));
|
||||
const keepEnd = max > 0 && end - start > max ? Math.min(duration, start + max) : end;
|
||||
return { start, end, keepEnd, length: keepEnd - start, cut: Math.max(0, end - keepEnd) };
|
||||
}
|
||||
|
||||
// "0:12.0" style, the way the IN and OUT fields write a time.
|
||||
export function secondsLabel(value) {
|
||||
const seconds = Math.max(0, finite(value));
|
||||
if (seconds < 60) return `${seconds.toFixed(1)} s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
return `${minutes}:${(seconds - minutes * 60).toFixed(1).padStart(4, "0")}`;
|
||||
}
|
||||
|
||||
// The line between IN and OUT: what comes out, and what the max cuts off.
|
||||
export function outputSummary(window, { maxFromLink = false } = {}) {
|
||||
if (!window || window.length <= 0) return "";
|
||||
const parts = [secondsLabel(window.length)];
|
||||
if (window.cut > 0.005) parts.push(`cut ${secondsLabel(window.cut)}`);
|
||||
if (maxFromLink) parts.push("max wired");
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
// Loud and quiet across the file: for each of `buckets` slices, the lowest
|
||||
// and highest sample of the channels mixed down. Channel data is a list of
|
||||
// Float32Arrays (AudioBuffer.getChannelData). Returns a Float32Array of
|
||||
// [min0, max0, min1, max1, ...].
|
||||
export function wavePeaks(channels, bucketsValue = 1200) {
|
||||
const list = Array.isArray(channels) ? channels.filter(Boolean) : [];
|
||||
const buckets = Math.max(1, Math.floor(finite(bucketsValue, 1200)));
|
||||
const peaks = new Float32Array(buckets * 2);
|
||||
const length = list.length ? list[0].length : 0;
|
||||
if (!length) return peaks;
|
||||
const count = list.length;
|
||||
for (let bucket = 0; bucket < buckets; bucket += 1) {
|
||||
const from = Math.floor((bucket * length) / buckets);
|
||||
const to = Math.max(from + 1, Math.floor(((bucket + 1) * length) / buckets));
|
||||
let low = 0;
|
||||
let high = 0;
|
||||
for (let index = from; index < to && index < length; index += 1) {
|
||||
let sample = 0;
|
||||
for (let channel = 0; channel < count; channel += 1) sample += list[channel][index] || 0;
|
||||
sample /= count;
|
||||
if (sample < low) low = sample;
|
||||
if (sample > high) high = sample;
|
||||
}
|
||||
peaks[bucket * 2] = low;
|
||||
peaks[bucket * 2 + 1] = high;
|
||||
}
|
||||
return peaks;
|
||||
}
|
||||
|
||||
// Which part of the wave a press at `fraction` (0..1 across it) grabs: a
|
||||
// handle within `hitPixels` of it, the inside of the pick to slide it, or
|
||||
// "jump" to move the pick there.
|
||||
export function grabZone(window, durationValue, fraction, widthValue, hitPixels = 10) {
|
||||
const duration = Math.max(0, finite(durationValue));
|
||||
const width = Math.max(0, finite(widthValue));
|
||||
if (duration <= 0 || width <= 0) return "none";
|
||||
const x = Math.min(1, Math.max(0, finite(fraction))) * width;
|
||||
const startX = (window.start / duration) * width;
|
||||
const endX = (window.end / duration) * width;
|
||||
const toStart = Math.abs(x - startX);
|
||||
const toEnd = Math.abs(x - endX);
|
||||
if (Math.min(toStart, toEnd) <= hitPixels) return toStart <= toEnd ? "start" : "end";
|
||||
if (x > startX && x < endX) return "move";
|
||||
return "jump";
|
||||
}
|
||||
@@ -1,563 +0,0 @@
|
||||
// Load Audio 🆎 - the wave, the trim and the player.
|
||||
//
|
||||
// The card above (js/widget_cards) holds Source and Max length. This panel
|
||||
// draws the whole file as a wave: the part that comes out is bright, the
|
||||
// part a Max length cuts off is red, and the rest is dim. Drag a handle to
|
||||
// move IN or OUT, drag the middle to slide the pick, or click elsewhere to
|
||||
// move it there. Play plays only what comes out. The widgets underneath
|
||||
// (start_seconds, end_seconds, max_seconds) stay the source of truth.
|
||||
import { api } from "/scripts/api.js";
|
||||
import { app } from "/scripts/app.js";
|
||||
import { BRAND, chainCallback, keepDomWidgetWidthAuto, notifyAusbossChange } from "../shared/index.mjs";
|
||||
import { keepKeyInField } from "../shared/canvas_passthrough.mjs";
|
||||
import { WIDGET_FRAME, fillNodeHeight } from "../shared/panel_layout.mjs";
|
||||
import { formatTimecode, parseTimecode } from "../shared/timecode.mjs";
|
||||
import { parseImageReference } from "../shared/pad_canvas.mjs";
|
||||
import { mediaViewQuery, splitMediaName } from "../shared/video_preview.mjs";
|
||||
import { ensureVideoCss, hideCanvasWidget, makeToolButton } from "../shared/video_ui.mjs";
|
||||
import { MIN_TRIM_SECONDS, dragTrimHandle, slideTrimWindow } from "../load_video/trim_preview.mjs";
|
||||
import { grabZone, outputSummary, outputWindow, secondsLabel, typedTrimBounds, wavePeaks, wiredMax } from "./audio_window.mjs";
|
||||
|
||||
const NODE_NAME = "AUSBOSS_NODES_LoadAudio";
|
||||
const PANEL_WIDGET = "ausboss_load_audio_wave";
|
||||
// The frontend's own player for an audio_upload input. The wave replaces it.
|
||||
const CORE_AUDIO_WIDGET = "audioUI";
|
||||
const MIN_WIDTH = 280;
|
||||
// 8px root padding + the wave's 28px floor + 6px gap + the 22px play, IN/OUT and loop row.
|
||||
const PANEL_MIN_HEIGHT = 64;
|
||||
// Opens small and neat: 300 px wide, with a wave about as tall as a field
|
||||
// (32 px). Drag the corner down for a taller wave.
|
||||
const DEFAULT_WIDTH = 300;
|
||||
const DEFAULT_WAVE_EXTRA = 4;
|
||||
const CUT_RED = "#e0525a";
|
||||
const PEAK_BUCKETS = 1600;
|
||||
const END_EPSILON = 0.03;
|
||||
|
||||
// Decoded once per file and kept small: only the peaks and the length.
|
||||
const peakCache = new Map();
|
||||
|
||||
function findWidget(node, name) {
|
||||
return node.widgets?.find((widget) => widget.name === name);
|
||||
}
|
||||
|
||||
function roundHundredth(value) {
|
||||
return Math.round(Number(value || 0) * 100) / 100;
|
||||
}
|
||||
|
||||
function hideCoreAudio(node) {
|
||||
for (const widget of node.widgets ?? []) {
|
||||
if (widget?.name === CORE_AUDIO_WIDGET) hideCanvasWidget(widget, widget.element);
|
||||
}
|
||||
}
|
||||
|
||||
function maxSeconds(state) {
|
||||
const wired = wiredMax(state.node);
|
||||
if (wired.wired) return { value: wired.value ?? 0, fromLink: true, known: wired.value != null };
|
||||
return { value: Number(state.maxWidget?.value) || 0, fromLink: false, known: true };
|
||||
}
|
||||
|
||||
function currentWindow(state) {
|
||||
return outputWindow(state.duration, state.startWidget.value, state.endWidget.value, maxSeconds(state).value);
|
||||
}
|
||||
|
||||
async function loadPeaks(name, route) {
|
||||
const key = name;
|
||||
if (peakCache.has(key)) return peakCache.get(key);
|
||||
const job = (async () => {
|
||||
const response = await api.fetchApi(route, { cache: "no-store" });
|
||||
if (!response.ok) throw new Error(`the file could not be read (${response.status})`);
|
||||
const bytes = await response.arrayBuffer();
|
||||
const Context = window.AudioContext || window.webkitAudioContext;
|
||||
const context = new Context();
|
||||
try {
|
||||
const buffer = await context.decodeAudioData(bytes);
|
||||
const channels = [];
|
||||
for (let index = 0; index < buffer.numberOfChannels; index += 1) channels.push(buffer.getChannelData(index));
|
||||
return { duration: buffer.duration, peaks: wavePeaks(channels, PEAK_BUCKETS) };
|
||||
} finally {
|
||||
context.close?.();
|
||||
}
|
||||
})();
|
||||
peakCache.set(key, job);
|
||||
job.catch(() => peakCache.delete(key));
|
||||
return job;
|
||||
}
|
||||
|
||||
function ensureAudioCss() {
|
||||
ensureVideoCss();
|
||||
if (document.getElementById("ausboss-load-audio-css")) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = "ausboss-load-audio-css";
|
||||
style.textContent = `
|
||||
.ausboss-audio-root .ausboss-video-stage{min-height:28px;background:#050b0b;cursor:pointer;touch-action:none;}
|
||||
.ausboss-audio-root canvas.ausboss-audio-wave{position:absolute;inset:0;width:100%;height:100%;display:block;}
|
||||
.ausboss-audio-root .ausboss-video-stage:not(.is-empty) .ausboss-video-status{display:none;}
|
||||
.ausboss-audio-root .ausboss-audio-row{display:flex;align-items:center;gap:6px;flex:none;padding:0 2px;}
|
||||
.ausboss-audio-root .ausboss-audio-row .ausboss-video-values{flex:1 1 auto;min-width:0;}
|
||||
.ausboss-audio-root .ausboss-audio-play{min-width:30px;font-size:10px;}
|
||||
.ausboss-audio-root .ausboss-audio-length{flex:none;padding:0 2px;color:#7fd9d2;font-size:10px;font-variant-numeric:tabular-nums;white-space:nowrap;cursor:default;}
|
||||
.ausboss-audio-root .ausboss-audio-length:empty{display:none;}
|
||||
.ausboss-audio-root .ausboss-audio-length.has-cut{color:#ff9aa0;cursor:pointer;text-decoration:underline dotted rgba(224,82,90,.8);text-underline-offset:2px;}
|
||||
.ausboss-audio-root .ausboss-audio-row .ausboss-video-tool{flex:none;}
|
||||
.ausboss-audio-root .ausboss-video-values{display:flex;gap:8px;}
|
||||
.ausboss-audio-root .ausboss-video-value input{width:48px;}
|
||||
.ausboss-audio-root .ausboss-audio-length{margin-left:auto;}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function draw(state) {
|
||||
const { canvas } = state;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scale = window.devicePixelRatio || 1;
|
||||
const width = Math.max(1, Math.round(rect.width * scale));
|
||||
const height = Math.max(1, Math.round(rect.height * scale));
|
||||
if (canvas.width !== width || canvas.height !== height) {
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
}
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
if (!state.peaks || state.duration <= 0) return;
|
||||
const win = currentWindow(state);
|
||||
const xOf = (seconds) => (seconds / state.duration) * width;
|
||||
const startX = xOf(win.start);
|
||||
const keepX = xOf(win.keepEnd);
|
||||
const endX = xOf(win.end);
|
||||
// Backdrops: the part that comes out, then the part cut off.
|
||||
ctx.fillStyle = "rgba(0,180,170,0.13)";
|
||||
ctx.fillRect(startX, 0, keepX - startX, height);
|
||||
if (endX > keepX) {
|
||||
ctx.fillStyle = "rgba(224,82,90,0.16)";
|
||||
ctx.fillRect(keepX, 0, endX - keepX, height);
|
||||
}
|
||||
// The wave, one bar per pixel column.
|
||||
const mid = height / 2;
|
||||
const reach = mid * 0.92;
|
||||
const buckets = state.peaks.length / 2;
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const from = Math.floor((x / width) * buckets);
|
||||
const to = Math.max(from + 1, Math.floor(((x + 1) / width) * buckets));
|
||||
let low = 0;
|
||||
let high = 0;
|
||||
for (let bucket = from; bucket < to && bucket < buckets; bucket += 1) {
|
||||
low = Math.min(low, state.peaks[bucket * 2]);
|
||||
high = Math.max(high, state.peaks[bucket * 2 + 1]);
|
||||
}
|
||||
if (x >= startX && x < keepX) ctx.fillStyle = BRAND;
|
||||
else if (x >= keepX && x < endX) ctx.fillStyle = CUT_RED;
|
||||
else ctx.fillStyle = "#3a4948";
|
||||
const top = mid - high * reach;
|
||||
ctx.fillRect(x, top, 1, Math.max(1, (high - low) * reach));
|
||||
}
|
||||
// The handles: IN and OUT, and a dashed line where the max cuts off.
|
||||
// The grips stay small on a short wave so they don't hide it.
|
||||
const grip = Math.round(Math.max(3, Math.min(7, rect.height * 0.12)) * scale);
|
||||
const handle = (x, color) => {
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(Math.round(x) - scale, 0, 2 * scale, height);
|
||||
ctx.fillRect(Math.round(x) - 4 * scale, 0, 8 * scale, grip);
|
||||
ctx.fillRect(Math.round(x) - 4 * scale, height - grip, 8 * scale, grip);
|
||||
};
|
||||
handle(startX, "#f4ffff");
|
||||
handle(endX, "#f4ffff");
|
||||
if (endX > keepX + 1) {
|
||||
ctx.strokeStyle = CUT_RED;
|
||||
ctx.lineWidth = 2 * scale;
|
||||
ctx.setLineDash([4 * scale, 3 * scale]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(keepX, 0);
|
||||
ctx.lineTo(keepX, height);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
// The playhead while it plays, or where it paused inside the pick.
|
||||
if (state.audio && !state.audio.paused) {
|
||||
const x = xOf(state.audio.currentTime);
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(x - scale / 2, 0, scale, height);
|
||||
}
|
||||
}
|
||||
|
||||
function updateFace(state) {
|
||||
const win = currentWindow(state);
|
||||
const max = maxSeconds(state);
|
||||
if (document.activeElement !== state.startInput) state.startInput.value = formatTimecode(win.start);
|
||||
if (document.activeElement !== state.endInput) state.endInput.value = formatTimecode(win.end);
|
||||
if (state.duration > 0) {
|
||||
const summary = outputSummary(win, { maxFromLink: max.fromLink && !max.known });
|
||||
state.summary.textContent = win.length > 0 ? secondsLabel(win.length) : "";
|
||||
state.summary.classList.toggle("has-cut", win.cut > 0.005);
|
||||
state.summary.title = win.cut > 0.005
|
||||
? `${summary}. Max length is ${max.value} s, so ${formatTimecode(win.keepEnd)} to ${formatTimecode(win.end)} is cut off (red). Click to move OUT to ${formatTimecode(win.keepEnd)}.`
|
||||
: `${summary}: ${formatTimecode(win.start)} to ${formatTimecode(win.keepEnd)} comes out.`;
|
||||
} else {
|
||||
state.summary.textContent = "";
|
||||
}
|
||||
draw(state);
|
||||
}
|
||||
|
||||
function writeWidget(widget, value) {
|
||||
const next = roundHundredth(value);
|
||||
if (Math.abs(Number(widget.value || 0) - next) < 0.0001) return;
|
||||
widget.value = next;
|
||||
widget.callback?.(next);
|
||||
}
|
||||
|
||||
function writeTrim(state, bounds) {
|
||||
state.writing = true;
|
||||
try {
|
||||
writeWidget(state.startWidget, bounds.start);
|
||||
// OUT at the very end stays 0, the widget's own "to the end".
|
||||
writeWidget(state.endWidget, bounds.end >= state.duration - 0.005 ? 0 : bounds.end);
|
||||
} finally {
|
||||
state.writing = false;
|
||||
}
|
||||
updateFace(state);
|
||||
state.node.setDirtyCanvas?.(true, true);
|
||||
}
|
||||
|
||||
function installDrag(state) {
|
||||
const surface = state.stage;
|
||||
const infoAt = (clientX) => {
|
||||
const rect = state.canvas.getBoundingClientRect();
|
||||
const fraction = rect.width ? Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)) : 0;
|
||||
const win = currentWindow(state);
|
||||
return { fraction, win, zone: grabZone(win, state.duration, fraction, rect.width) };
|
||||
};
|
||||
surface.addEventListener("pointermove", (event) => {
|
||||
if (state.dragging || state.duration <= 0) return;
|
||||
const { zone } = infoAt(event.clientX);
|
||||
surface.style.cursor = zone === "start" || zone === "end" ? "ew-resize" : zone === "move" ? "grab" : "pointer";
|
||||
});
|
||||
surface.addEventListener("pointerdown", (event) => {
|
||||
if (event.button !== 0 || state.dragging || state.duration <= 0) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const initial = infoAt(event.clientX);
|
||||
const bounds = { start: initial.win.start, end: initial.win.end };
|
||||
const anchor = initial.fraction * state.duration;
|
||||
const downX = event.clientX;
|
||||
let moved = false;
|
||||
state.dragging = true;
|
||||
try { surface.setPointerCapture(event.pointerId); } catch (_error) { /* mouse fallback */ }
|
||||
const centred = (fraction) => {
|
||||
const span = bounds.end - bounds.start;
|
||||
return slideTrimWindow(state.duration, bounds, fraction * state.duration - span / 2 - bounds.start);
|
||||
};
|
||||
const apply = (clientX) => {
|
||||
const next = infoAt(clientX);
|
||||
if (initial.zone === "start" || initial.zone === "end") {
|
||||
const now = currentWindow(state);
|
||||
writeTrim(state, dragTrimHandle(state.duration, { start: now.start, end: now.end }, initial.zone, next.fraction, MIN_TRIM_SECONDS));
|
||||
} else if (initial.zone === "move") {
|
||||
writeTrim(state, slideTrimWindow(state.duration, bounds, next.fraction * state.duration - anchor));
|
||||
} else {
|
||||
writeTrim(state, centred(next.fraction));
|
||||
}
|
||||
};
|
||||
const move = (moveEvent) => {
|
||||
if (!(moveEvent.buttons & 1)) { finish(moveEvent); return; }
|
||||
if (!moved && Math.abs(moveEvent.clientX - downX) < 3) return;
|
||||
moved = true;
|
||||
apply(moveEvent.clientX);
|
||||
};
|
||||
const finish = (finishEvent) => {
|
||||
if (!state.dragging) return;
|
||||
state.dragging = false;
|
||||
surface.removeEventListener("pointermove", move);
|
||||
try { surface.releasePointerCapture(event.pointerId); } catch (_error) { /* already released */ }
|
||||
if (!moved && initial.zone === "jump") writeTrim(state, centred(infoAt(finishEvent?.clientX ?? downX).fraction));
|
||||
if (moved || initial.zone === "jump") {
|
||||
if (state.audio && !state.audio.paused) state.audio.currentTime = currentWindow(state).start;
|
||||
notifyAusbossChange();
|
||||
}
|
||||
};
|
||||
surface.addEventListener("pointermove", move);
|
||||
surface.addEventListener("pointerup", finish, { once: true });
|
||||
surface.addEventListener("pointercancel", finish, { once: true });
|
||||
surface.addEventListener("lostpointercapture", finish, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function loopEnabled(node) {
|
||||
node.properties ??= {};
|
||||
return !!node.properties.ausboss_load_audio_loop;
|
||||
}
|
||||
|
||||
function installPlayer(state) {
|
||||
const audio = state.audio;
|
||||
const stop = () => {
|
||||
if (state.raf) cancelAnimationFrame(state.raf);
|
||||
state.raf = 0;
|
||||
state.playButton.textContent = "▶";
|
||||
state.playButton.classList.remove("active");
|
||||
draw(state);
|
||||
};
|
||||
const tick = () => {
|
||||
if (audio.paused) { stop(); return; }
|
||||
const win = currentWindow(state);
|
||||
if (audio.currentTime >= win.keepEnd - END_EPSILON || audio.currentTime < win.start - 0.25) {
|
||||
if (loopEnabled(state.node)) audio.currentTime = win.start;
|
||||
else { audio.pause(); audio.currentTime = win.start; stop(); return; }
|
||||
}
|
||||
draw(state);
|
||||
state.raf = requestAnimationFrame(tick);
|
||||
};
|
||||
audio.addEventListener("play", () => {
|
||||
// Hidden tabs suspend animation frames, so never keep playing there.
|
||||
if (document.hidden) { audio.pause(); return; }
|
||||
state.playButton.textContent = "❚❚";
|
||||
state.playButton.classList.add("active");
|
||||
if (state.raf) cancelAnimationFrame(state.raf);
|
||||
state.raf = requestAnimationFrame(tick);
|
||||
}, { signal: state.abort.signal });
|
||||
audio.addEventListener("pause", stop, { signal: state.abort.signal });
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.hidden) audio.pause();
|
||||
}, { signal: state.abort.signal });
|
||||
audio.addEventListener("ended", () => {
|
||||
const win = currentWindow(state);
|
||||
if (!document.hidden && loopEnabled(state.node) && win.length > 0) {
|
||||
audio.currentTime = win.start;
|
||||
audio.play().catch(() => stop());
|
||||
}
|
||||
}, { signal: state.abort.signal });
|
||||
state.playButton.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (state.duration <= 0) return;
|
||||
if (!audio.paused) { audio.pause(); return; }
|
||||
const win = currentWindow(state);
|
||||
if (audio.currentTime < win.start || audio.currentTime >= win.keepEnd - END_EPSILON) audio.currentTime = win.start;
|
||||
audio.play().catch(() => stop());
|
||||
});
|
||||
state.stopPlayer = () => { audio.pause(); stop(); };
|
||||
}
|
||||
|
||||
function commitTimeInput(state, edge, input) {
|
||||
if (state.duration <= 0) return;
|
||||
const seconds = parseTimecode(input.value);
|
||||
const win = currentWindow(state);
|
||||
if (seconds === null) {
|
||||
input.value = formatTimecode(edge === "start" ? win.start : win.end);
|
||||
return;
|
||||
}
|
||||
writeTrim(state, typedTrimBounds(state.duration, { start: win.start, end: win.end }, edge, seconds));
|
||||
notifyAusbossChange();
|
||||
input.blur();
|
||||
}
|
||||
|
||||
function watchWidget(state, widget) {
|
||||
if (!widget || widget.__ausbossAudioWatch) return;
|
||||
widget.__ausbossAudioWatch = true;
|
||||
const prior = widget.callback;
|
||||
widget.callback = function (...args) {
|
||||
const result = prior?.apply(this, args);
|
||||
if (!state.writing) updateFace(state);
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
function buildPanel(node) {
|
||||
if (node.__ausbossLoadAudio) return node.__ausbossLoadAudio;
|
||||
const audioWidget = findWidget(node, "audio");
|
||||
const startWidget = findWidget(node, "start_seconds");
|
||||
const endWidget = findWidget(node, "end_seconds");
|
||||
if (!audioWidget || !startWidget || !endWidget) return null;
|
||||
ensureAudioCss();
|
||||
hideCoreAudio(node);
|
||||
hideCanvasWidget(startWidget);
|
||||
hideCanvasWidget(endWidget);
|
||||
|
||||
const root = document.createElement("div");
|
||||
root.className = "ausboss-video-root ausboss-audio-root";
|
||||
const stage = document.createElement("div");
|
||||
stage.className = "ausboss-video-stage is-empty";
|
||||
stage.title = "Drag a handle to set where the sound starts or stops. Drag the middle to slide it.";
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.className = "ausboss-audio-wave";
|
||||
const status = document.createElement("div");
|
||||
status.className = "ausboss-video-status";
|
||||
status.textContent = "Choose or upload a sound file";
|
||||
const playButton = makeToolButton("▶", "Play the part that comes out");
|
||||
playButton.classList.add("ausboss-audio-play");
|
||||
const loopButton = makeToolButton("LOOP", "Play it again and again");
|
||||
stage.append(canvas, status);
|
||||
|
||||
const values = document.createElement("div");
|
||||
values.className = "ausboss-video-values";
|
||||
const makeValue = (labelText, title) => {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "ausboss-video-value";
|
||||
const label = document.createElement("label");
|
||||
label.textContent = labelText;
|
||||
const input = document.createElement("input");
|
||||
input.type = "text";
|
||||
input.inputMode = "decimal";
|
||||
input.placeholder = "m:ss.s";
|
||||
input.title = title;
|
||||
input.addEventListener("keydown", (event) => {
|
||||
keepKeyInField(event);
|
||||
if (event.key === "Enter") input.dispatchEvent(new Event("change"));
|
||||
});
|
||||
wrap.append(label, input);
|
||||
return { wrap, input };
|
||||
};
|
||||
const startValue = makeValue("IN", "Where the sound starts. Type seconds (12.5) or a time (0:12.5).");
|
||||
const endValue = makeValue("OUT", "Where the sound stops. Type seconds (24) or a time (0:24.0).");
|
||||
// How long the output is, beside OUT and off the wave. Red when Max
|
||||
// length cuts the pick short; the tooltip says how much.
|
||||
const summary = document.createElement("div");
|
||||
summary.className = "ausboss-audio-length";
|
||||
values.append(startValue.wrap, endValue.wrap);
|
||||
const row = document.createElement("div");
|
||||
row.className = "ausboss-audio-row";
|
||||
row.append(playButton, values, summary, loopButton);
|
||||
root.append(stage, row);
|
||||
|
||||
const widget = node.addDOMWidget(PANEL_WIDGET, "ausboss_audio", root, {
|
||||
serialize: false,
|
||||
hideOnZoom: false,
|
||||
getMinHeight: () => PANEL_MIN_HEIGHT + WIDGET_FRAME,
|
||||
});
|
||||
keepDomWidgetWidthAuto(widget);
|
||||
widget.serialize = false;
|
||||
fillNodeHeight(widget, {
|
||||
minWidth: MIN_WIDTH,
|
||||
minHeight: PANEL_MIN_HEIGHT + WIDGET_FRAME,
|
||||
minNodeSize: [MIN_WIDTH, 200],
|
||||
exactMinWidth: true,
|
||||
});
|
||||
|
||||
const audio = new Audio();
|
||||
audio.preload = "auto";
|
||||
const abort = new AbortController();
|
||||
const state = node.__ausbossLoadAudio = {
|
||||
node, root, stage, canvas, status, playButton, loopButton, summary, audio,
|
||||
startInput: startValue.input, endInput: endValue.input,
|
||||
audioWidget, startWidget, endWidget, maxWidget: findWidget(node, "max_seconds"),
|
||||
duration: 0, peaks: null, abort, writing: false, dragging: false, raf: 0, loading: 0,
|
||||
};
|
||||
|
||||
// Clicking the red length moves OUT to where the sound really ends.
|
||||
summary.addEventListener("click", (event) => {
|
||||
const win = currentWindow(state);
|
||||
if (win.cut <= 0.005) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
writeTrim(state, { start: win.start, end: win.keepEnd });
|
||||
notifyAusbossChange();
|
||||
});
|
||||
|
||||
const updateLoop = () => loopButton.classList.toggle("active", loopEnabled(node));
|
||||
updateLoop();
|
||||
loopButton.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
node.properties.ausboss_load_audio_loop = !loopEnabled(node);
|
||||
updateLoop();
|
||||
notifyAusbossChange();
|
||||
});
|
||||
|
||||
const refresh = async () => {
|
||||
state.stopPlayer?.();
|
||||
const name = audioWidget.value;
|
||||
const ticket = ++state.loading;
|
||||
state.duration = 0;
|
||||
state.peaks = null;
|
||||
if (!name || typeof name !== "string") {
|
||||
audio.removeAttribute("src");
|
||||
stage.classList.add("is-empty");
|
||||
status.textContent = "Choose or upload a sound file";
|
||||
updateFace(state);
|
||||
return;
|
||||
}
|
||||
const parts = splitMediaName(name);
|
||||
stage.classList.add("is-empty");
|
||||
status.textContent = `Loading ${parts.filename}…`;
|
||||
const type = parseImageReference(name)?.type ?? "input";
|
||||
const route = `/view?${mediaViewQuery({ ...parts, type }, "input")}`;
|
||||
audio.src = api.apiURL(route);
|
||||
try {
|
||||
const result = await loadPeaks(name, route);
|
||||
if (ticket !== state.loading) return;
|
||||
state.duration = result.duration;
|
||||
state.peaks = result.peaks;
|
||||
stage.classList.remove("is-empty");
|
||||
updateFace(state);
|
||||
} catch (error) {
|
||||
if (ticket !== state.loading) return;
|
||||
status.textContent = `This sound could not be shown: ${error?.message || error}`;
|
||||
}
|
||||
};
|
||||
state.refresh = refresh;
|
||||
|
||||
installDrag(state);
|
||||
installPlayer(state);
|
||||
watchWidget(state, startWidget);
|
||||
watchWidget(state, endWidget);
|
||||
watchWidget(state, state.maxWidget);
|
||||
startValue.input.addEventListener("change", () => commitTimeInput(state, "start", startValue.input));
|
||||
endValue.input.addEventListener("change", () => commitTimeInput(state, "end", endValue.input));
|
||||
const observer = new ResizeObserver(() => draw(state));
|
||||
observer.observe(stage);
|
||||
state.observer = observer;
|
||||
|
||||
const priorAudioCallback = audioWidget.callback;
|
||||
audioWidget.callback = function (...args) {
|
||||
const result = priorAudioCallback?.apply(this, args);
|
||||
peakCache.delete(audioWidget.value);
|
||||
refresh();
|
||||
return result;
|
||||
};
|
||||
setTimeout(refresh, 0);
|
||||
return state;
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "ausboss.load_audio",
|
||||
beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData?.name !== NODE_NAME) return;
|
||||
// The frontend's Upload button for an audio_upload input looks for its
|
||||
// player widget and fails without one, and it adds that player only to
|
||||
// its own audio nodes. Ask for it here, ahead of the button; the panel
|
||||
// hides it, since the wave plays the sound instead.
|
||||
const required = nodeData.input?.required;
|
||||
if (required && !required.audioUI) {
|
||||
const entries = Object.entries(required);
|
||||
const at = entries.findIndex(([name]) => name === "audio") + 1;
|
||||
entries.splice(at, 0, ["audioUI", ["AUDIO_UI", {}]]);
|
||||
nodeData.input.required = Object.fromEntries(entries);
|
||||
}
|
||||
chainCallback(nodeType.prototype, "onNodeCreated", function () {
|
||||
buildPanel(this);
|
||||
// Only for a genuinely new node: onConfigure restores a saved size
|
||||
// after this runs, so a workflow's own size still wins.
|
||||
const floor = Number(this.computeSize?.()?.[1]) || 0;
|
||||
if (floor > 0) this.setSize?.([DEFAULT_WIDTH, floor + DEFAULT_WAVE_EXTRA]);
|
||||
});
|
||||
chainCallback(nodeType.prototype, "onConfigure", function () {
|
||||
queueMicrotask(() => {
|
||||
const state = buildPanel(this);
|
||||
if (state) {
|
||||
hideCoreAudio(this);
|
||||
state.refresh?.();
|
||||
}
|
||||
});
|
||||
});
|
||||
// A wire into Max length changes what comes out; redraw when one comes or goes.
|
||||
chainCallback(nodeType.prototype, "onConnectionsChange", function () {
|
||||
const state = this.__ausbossLoadAudio;
|
||||
if (state) queueMicrotask(() => updateFace(state));
|
||||
});
|
||||
chainCallback(nodeType.prototype, "onRemoved", function () {
|
||||
const state = this.__ausbossLoadAudio;
|
||||
state?.stopPlayer?.();
|
||||
state?.abort?.abort();
|
||||
state?.observer?.disconnect();
|
||||
if (state) state.audio.removeAttribute("src");
|
||||
this.__ausbossLoadAudio = null;
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -9,7 +9,7 @@ export function inputNumber(node, name, fallback) {
|
||||
for (let depth = 0; depth < 16; depth++) {
|
||||
if (seen.has(linkId)) return null;
|
||||
seen.add(linkId);
|
||||
const link = graph?.getLink?.(linkId) ?? graph?.links?.get?.(linkId) ?? graph?.links?.[linkId] ?? graph?._links?.get?.(linkId);
|
||||
const link = graph?.links?.[linkId] ?? graph?._links?.get?.(linkId);
|
||||
const source = graph?.getNodeById?.(link?.origin_id);
|
||||
if (!source || (source.mode ?? 0) !== 0) return null;
|
||||
if (source.type === "Reroute") {
|
||||
@@ -18,7 +18,7 @@ export function inputNumber(node, name, fallback) {
|
||||
continue;
|
||||
}
|
||||
if (!["PrimitiveFloat", "PrimitiveInt", "AUSBOSS_NODES_Integer", "AUSBOSS_NODES_Float"].includes(source.type) || link.origin_slot !== 0) return null;
|
||||
if (source.inputs?.some((slot) => (slot.name === "value" || slot.widget?.name === "value") && slot.link != null)) return null;
|
||||
if (source.inputs?.some((slot) => slot.name === "value" && slot.link != null)) return null;
|
||||
const value = source.widgets?.find((widget) => widget.name === "value")?.value;
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ const emptyWidget = (inputs, name) => !linked(inputs?.[name]) && blank(inputs?.[
|
||||
|
||||
const picture = (name) => (inputs) => (emptyWidget(inputs, name) ? { input: name, kind: "picture" } : null);
|
||||
const video = (name) => (inputs) => (emptyWidget(inputs, name) ? { input: name, kind: "video" } : null);
|
||||
const sound = (name) => (inputs) => (emptyWidget(inputs, name) ? { input: name, kind: "sound file" } : null);
|
||||
|
||||
function videoSource(inputs) {
|
||||
const mode = inputs?.source_mode;
|
||||
@@ -39,7 +38,6 @@ export const SOURCE_LOADERS = Object.freeze({
|
||||
AUSBOSS_NODES_ImageCropRotatePad: 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"),
|
||||
AUSBOSS_NODES_LoadVideo: video("video"),
|
||||
AUSBOSS_NODES_VideoCropRotatePad: videoSource,
|
||||
AUSBOSS_NODES_VideoCropRotatePadClip: videoSource,
|
||||
@@ -112,8 +110,7 @@ export function emptySourceMessage(found) {
|
||||
if (!first) return "";
|
||||
const more = found.length - 1;
|
||||
const tail = more > 0 ? ` (and ${more} more node${more === 1 ? "" : "s"})` : "";
|
||||
const noun = first.kind === "video" || first.kind === "sound file" ? first.kind : "picture";
|
||||
return `Load a ${noun} first: ${first.title}${tail}`;
|
||||
return `Load a ${first.kind === "video" ? "video" : "picture"} first: ${first.title}${tail}`;
|
||||
}
|
||||
|
||||
// "Save Image: exact_name may not ..." names the input it is about, after
|
||||
|
||||
@@ -384,16 +384,6 @@ const CARDS = {
|
||||
{ widget: "max_frames", label: "Limit" },
|
||||
],
|
||||
},
|
||||
AUSBOSS_NODES_LoadAudio: {
|
||||
// The wave, IN and OUT live in the panel under the card (js/load_audio).
|
||||
minWidth: 280, first: true, hide: ["upload"],
|
||||
rows: [
|
||||
{ widget: "audio", label: "Source", kind: "select",
|
||||
button: { text: "Upload", title: "Upload a sound file into ComfyUI's input folder", onClick: (node) => node.widgets?.find((w) => w.name === "upload")?.callback?.() } },
|
||||
{ widget: "max_seconds", label: "Max length", suffix: "s", decimals: 1, trimZeros: true,
|
||||
title: "The longest the sound may be. Anything past it is cut off and shows in red. 0 means no limit." },
|
||||
],
|
||||
},
|
||||
AUSBOSS_NODES_LoadImagePad: {
|
||||
minWidth: 340, first: true, hide: ["upload"],
|
||||
rows: [
|
||||
|
||||
@@ -1557,31 +1557,5 @@
|
||||
"AUSBOSS_NODES_WorkflowSwitches": {
|
||||
"display_name": "工作流开关 🆎",
|
||||
"description": "用一张小卡片开关工作流的各个部分。选中组成一个部分的节点,例如一个框和它的保存节点,按 + Switch:它们会得到一个 off | on 开关,名字由你定。每个组也可以有自己的开关。off 会旁路这些节点,图片仍然传到下一步(也可以设置为静音)。on 让它们重新运行。开关也可以改设置,而不是关闭节点:分别保存节点在 on 和 off 时的值,点一下就能把 Steps 设为 8 并打开一行 LoRA。按 Done,节点会缩成只有标签和开关。它从不运行,也没有连线。"
|
||||
},
|
||||
"AUSBOSS_NODES_LoadAudio": {
|
||||
"display_name": "加载音频 🆎",
|
||||
"description": "加载声音文件,只输出你选中的部分。节点把声音画成波形:拖动两个手柄设置起点和终点,按播放只听这一段。把最大长度设为下一步能接收的最长声音,超出的部分会显示为红色并被剪掉,运行前就能看到输出的内容。",
|
||||
"inputs": {
|
||||
"audio": {
|
||||
"name": "音频"
|
||||
},
|
||||
"start_seconds": {
|
||||
"name": "起始秒数"
|
||||
},
|
||||
"end_seconds": {
|
||||
"name": "结束秒数"
|
||||
},
|
||||
"max_seconds": {
|
||||
"name": "最大秒数"
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "音频"
|
||||
},
|
||||
"1": {
|
||||
"name": "秒数"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
"""Trimmed audio decoding for Load Audio.
|
||||
|
||||
The window rules live in ``audio_window`` so the node and its tests agree on
|
||||
what one Run hands on; ``js/load_audio/audio_window.mjs`` mirrors them for the
|
||||
face. Decoding counts samples from the start of the sound track and stops as
|
||||
soon as the window is full. Samples before the window are decoded and discarded.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import av
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from ._execution_helpers import raise_if_interrupted
|
||||
|
||||
try:
|
||||
import folder_paths
|
||||
except ImportError: # offline tests
|
||||
folder_paths = None
|
||||
|
||||
AUDIO_EXTENSIONS = {
|
||||
".aac",
|
||||
".aif",
|
||||
".aiff",
|
||||
".flac",
|
||||
".m4a",
|
||||
".mp3",
|
||||
".oga",
|
||||
".ogg",
|
||||
".opus",
|
||||
".wav",
|
||||
".webm",
|
||||
".wma",
|
||||
}
|
||||
# Video files carry sound too: a song is often only on hand inside a clip.
|
||||
SOUND_IN_VIDEO_EXTENSIONS = {".mkv", ".mov", ".mp4", ".m4v"}
|
||||
|
||||
FALLBACK_SAMPLE_RATE = 44100
|
||||
# Shorter than this is no window at all; the face keeps its handles this far apart.
|
||||
MIN_WINDOW_SECONDS = 0.1
|
||||
|
||||
|
||||
def _number(value, default: float = 0.0) -> float:
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return number if math.isfinite(number) else default
|
||||
|
||||
|
||||
def list_input_audio() -> list[str]:
|
||||
"""Sound files (and videos, for their sound) in ComfyUI's input folder."""
|
||||
if folder_paths is None:
|
||||
return [""]
|
||||
root = Path(folder_paths.get_input_directory())
|
||||
kinds = AUDIO_EXTENSIONS | SOUND_IN_VIDEO_EXTENSIONS
|
||||
results = [
|
||||
path.relative_to(root).as_posix()
|
||||
for path in root.rglob("*")
|
||||
if path.is_file() and not path.name.startswith(".") and path.suffix.lower() in kinds
|
||||
]
|
||||
return sorted(results, key=str.casefold) or [""]
|
||||
|
||||
|
||||
def audio_window(start_seconds, end_seconds, max_seconds=0.0, duration=None) -> dict:
|
||||
"""The part of a sound file one Run hands on.
|
||||
|
||||
end 0 means "to the end of the file". max_seconds above 0 caps the
|
||||
window's length: the pick keeps its start and anything past
|
||||
start + max_seconds is cut off. ``duration`` is the file's length when it
|
||||
is known; None leaves an open end open.
|
||||
|
||||
Returns start, end (None when open), the end the person picked
|
||||
(``picked_end``) and how many seconds the cap cut off (``cut``).
|
||||
"""
|
||||
start = max(0.0, _number(start_seconds))
|
||||
picked_end = _number(end_seconds)
|
||||
end = picked_end if picked_end > 0 else None
|
||||
if duration is not None:
|
||||
length = max(0.0, _number(duration))
|
||||
start = min(start, length)
|
||||
end = length if end is None else min(end, length)
|
||||
if end is not None:
|
||||
end = max(start, end)
|
||||
picked = end
|
||||
limit = max(0.0, _number(max_seconds))
|
||||
if limit > 0 and (end is None or end - start > limit):
|
||||
end = start + limit
|
||||
if duration is not None:
|
||||
end = min(end, max(0.0, _number(duration)))
|
||||
cut = 0.0
|
||||
if picked is not None and end is not None:
|
||||
cut = max(0.0, picked - end)
|
||||
return {"start": start, "end": end, "picked_end": picked, "cut": cut}
|
||||
|
||||
|
||||
def decode_audio_window(path: Path, start_seconds, end_seconds, max_seconds=0.0) -> dict:
|
||||
"""ComfyUI AUDIO for the window ``audio_window`` describes.
|
||||
|
||||
Raises a plain ValueError for a file with no sound, or a start at or past
|
||||
the end of the sound.
|
||||
"""
|
||||
window = audio_window(start_seconds, end_seconds, max_seconds)
|
||||
start = window["start"]
|
||||
# Read only this already-validated file. An empty protocol whitelist
|
||||
# prevents demuxers from opening any referenced files or network URLs,
|
||||
# including playlists disguised with an ordinary audio extension.
|
||||
with path.open("rb") as source, av.open(source, options={"protocol_whitelist": ""}) as container:
|
||||
stream = next((candidate for candidate in container.streams if candidate.type == "audio"), None)
|
||||
if stream is None:
|
||||
raise ValueError(f"{path.name} has no sound in it.")
|
||||
rate = int(stream.rate or FALLBACK_SAMPLE_RATE)
|
||||
first = round(start * rate)
|
||||
last = None if window["end"] is None else round(window["end"] * rate)
|
||||
resampler = av.AudioResampler(format="fltp", layout=stream.layout, rate=rate)
|
||||
chunks: list[np.ndarray] = []
|
||||
position = 0
|
||||
|
||||
def take(frame) -> None:
|
||||
nonlocal position
|
||||
for resampled in resampler.resample(frame):
|
||||
data = resampled.to_ndarray()
|
||||
begin = position
|
||||
position += data.shape[1]
|
||||
if position <= first:
|
||||
continue
|
||||
low = max(0, first - begin)
|
||||
high = data.shape[1] if last is None else min(data.shape[1], last - begin)
|
||||
if high > low:
|
||||
chunks.append(data[:, low:high])
|
||||
|
||||
for frame in container.decode(stream):
|
||||
raise_if_interrupted()
|
||||
take(frame)
|
||||
if last is not None and position >= last:
|
||||
break
|
||||
else:
|
||||
take(None) # the resampler hands back what it still holds
|
||||
if not chunks:
|
||||
if position == 0:
|
||||
raise ValueError(f"{path.name} has no sound in it.")
|
||||
raise ValueError(
|
||||
f"The start ({start:.2f} s) is past the end of {path.name}, "
|
||||
f"which is {position / rate:.2f} s long. Drag the start back into the sound."
|
||||
)
|
||||
data = np.concatenate(chunks, axis=1).astype(np.float32, copy=False)
|
||||
return {
|
||||
"waveform": torch.from_numpy(np.ascontiguousarray(data)).unsqueeze(0),
|
||||
"sample_rate": rate,
|
||||
}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AUDIO_EXTENSIONS",
|
||||
"MIN_WINDOW_SECONDS",
|
||||
"SOUND_IN_VIDEO_EXTENSIONS",
|
||||
"audio_window",
|
||||
"decode_audio_window",
|
||||
"list_input_audio",
|
||||
]
|
||||
@@ -23,7 +23,8 @@ 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 duration."
|
||||
"the thinned clip keeps its playback speed. Offset and whole-frame "
|
||||
"rounding can change its duration."
|
||||
)
|
||||
SEARCH_ALIASES = ["every nth", "skip frames", "thin batch", "reduce frames", "ausboss"]
|
||||
|
||||
@@ -74,8 +75,8 @@ class AusBossSelectEveryNth:
|
||||
RETURN_NAMES = ("images", "fps")
|
||||
OUTPUT_TOOLTIPS = (
|
||||
"The kept frames, in their original order.",
|
||||
"The wired fps divided by nth, so the kept frames still span the same "
|
||||
"time; 0 when no fps is wired.",
|
||||
"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.",
|
||||
)
|
||||
FUNCTION = "select"
|
||||
|
||||
|
||||
@@ -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 as they are. Connect a small text model and it fills in a "
|
||||
"one-line idea."
|
||||
"through with spacing cleaned up and connected audio added. Connect "
|
||||
"a small text model and it fills in a one-line idea."
|
||||
)
|
||||
SEARCH_ALIASES = [
|
||||
"h3 prompt",
|
||||
@@ -253,7 +253,8 @@ 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 and keep the video's length."
|
||||
"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."
|
||||
),
|
||||
},
|
||||
),
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
"""Load Audio 🆎."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from ._audio_trim_helpers import decode_audio_window, list_input_audio
|
||||
from ._execution_helpers import ignore_extra_inputs
|
||||
from ._media_helpers import resolve_input_path
|
||||
|
||||
|
||||
class AusBossLoadAudio:
|
||||
CATEGORY = "🆎 AusBoss/Video"
|
||||
DESCRIPTION = (
|
||||
"Loads a sound file and hands on only the part you pick. The node shows the "
|
||||
"sound as a wave, so you can see where the loud parts are. Drag the two "
|
||||
"handles to set where it starts and stops, and press play to hear just that "
|
||||
"part. Set Max length to the longest sound the next step takes: anything "
|
||||
"longer shows in red as cut off, so you see exactly what comes out before "
|
||||
"you run anything."
|
||||
)
|
||||
SEARCH_ALIASES = [
|
||||
"load audio",
|
||||
"audio loader",
|
||||
"trim audio",
|
||||
"cut audio",
|
||||
"song",
|
||||
"voice",
|
||||
"music",
|
||||
"waveform",
|
||||
"ausboss",
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"audio": (
|
||||
list_input_audio(),
|
||||
{
|
||||
"audio_upload": True,
|
||||
"tooltip": "A sound file in ComfyUI's input folder. Use Upload to add one.",
|
||||
},
|
||||
),
|
||||
"start_seconds": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 0.0,
|
||||
"min": 0.0,
|
||||
"max": 86400.0,
|
||||
"step": 0.01,
|
||||
"tooltip": "Where the sound starts. Drag the wave's IN handle or type a time.",
|
||||
},
|
||||
),
|
||||
"end_seconds": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 0.0,
|
||||
"min": 0.0,
|
||||
"max": 86400.0,
|
||||
"step": 0.01,
|
||||
"tooltip": "Where the sound stops. 0 plays to the end. Drag the OUT handle or type a time.",
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"max_seconds": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 0.0,
|
||||
"min": 0.0,
|
||||
"max": 86400.0,
|
||||
"step": 0.1,
|
||||
"tooltip": (
|
||||
"The longest the sound may be. Anything past it is cut off and shows "
|
||||
"in red. 0 means no limit."
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("AUDIO", "FLOAT")
|
||||
RETURN_NAMES = ("audio", "seconds")
|
||||
OUTPUT_TOOLTIPS = (
|
||||
"The part of the sound you picked, ready for any node that takes audio.",
|
||||
"How long that sound is, in seconds.",
|
||||
)
|
||||
FUNCTION = "load_audio"
|
||||
|
||||
async def load_audio(self, audio, start_seconds, end_seconds, max_seconds=0.0):
|
||||
path = resolve_input_path(audio)
|
||||
# Decoding reads from the beginning through OUT; a worker thread keeps
|
||||
# ComfyUI's event loop answering, and carries the context ComfyUI
|
||||
# reads to attribute an interrupt to this node.
|
||||
sound = await asyncio.to_thread(
|
||||
decode_audio_window, path, start_seconds, end_seconds, max_seconds
|
||||
)
|
||||
seconds = sound["waveform"].shape[-1] / float(sound["sample_rate"])
|
||||
return (sound, float(seconds))
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, audio, **_values):
|
||||
try:
|
||||
stat = resolve_input_path(audio).stat()
|
||||
except Exception:
|
||||
return float("nan")
|
||||
return f"{audio}:{stat.st_mtime_ns}:{stat.st_size}"
|
||||
|
||||
@classmethod
|
||||
@ignore_extra_inputs
|
||||
def VALIDATE_INPUTS(cls, audio):
|
||||
# Only the source is named: ComfyUI skips its own range checks for
|
||||
# every input named here. resolve_input_path takes a file inside the
|
||||
# input folder, or one named "[output]" or "[temp]", and nothing else.
|
||||
try:
|
||||
resolve_input_path(audio)
|
||||
except Exception as exc:
|
||||
return f"Load Audio: {exc}"
|
||||
return True
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"AUSBOSS_NODES_LoadAudio": AusBossLoadAudio}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"AUSBOSS_NODES_LoadAudio": "Load Audio 🆎"}
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
@@ -210,7 +210,8 @@ 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). Empty writes no sidecar."
|
||||
"-> portrait.txt). Blank falls back to caption; "
|
||||
"both blank means no sidecar."
|
||||
),
|
||||
},
|
||||
),
|
||||
|
||||
@@ -67,7 +67,6 @@ PUBLIC_NODE_IDS = {
|
||||
"AUSBOSS_NODES_Krea2OutpaintModelPatch",
|
||||
"AUSBOSS_NODES_LaMaInpaint",
|
||||
"AUSBOSS_NODES_LayoutBoxes",
|
||||
"AUSBOSS_NODES_LoadAudio",
|
||||
"AUSBOSS_NODES_LoadImagePad",
|
||||
"AUSBOSS_NODES_LoadVideo",
|
||||
"AUSBOSS_NODES_LoraLoader",
|
||||
|
||||
Vendored
-20
@@ -490,26 +490,6 @@
|
||||
]
|
||||
]
|
||||
},
|
||||
"AUSBOSS_NODES_LoadAudio": {
|
||||
"required": [
|
||||
"audio",
|
||||
"start_seconds",
|
||||
"end_seconds"
|
||||
],
|
||||
"optional": [
|
||||
"max_seconds"
|
||||
],
|
||||
"outputs": [
|
||||
[
|
||||
"AUDIO",
|
||||
"audio"
|
||||
],
|
||||
[
|
||||
"FLOAT",
|
||||
"seconds"
|
||||
]
|
||||
]
|
||||
},
|
||||
"AUSBOSS_NODES_LoadImagePad": {
|
||||
"required": [
|
||||
"image",
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { grabZone, outputSummary, outputWindow, typedTrimBounds, wavePeaks, wiredMax } from "../js/load_audio/audio_window.mjs";
|
||||
|
||||
function maxLink(source, originSlot = 0, linkStore = "object") {
|
||||
const link = { origin_id: 1, origin_slot: originSlot };
|
||||
const graph = { getNodeById: () => source };
|
||||
if (linkStore === "map") graph.links = new Map([[2, link]]);
|
||||
else if (linkStore === "getter") graph.getLink = () => link;
|
||||
else graph.links = { 2: link };
|
||||
return { inputs: [{ name: "max_seconds", widget: { name: "max_seconds" }, link: 2 }], graph };
|
||||
}
|
||||
|
||||
test("wired Max length only reads the connected output of an unlinked literal", () => {
|
||||
assert.deepEqual(wiredMax({}), { wired: false, value: null });
|
||||
const video = { type: "AUSBOSS_NODES_LoadVideo", widgets: [{ name: "start_seconds", value: 4 }, { name: "end_seconds", value: 14 }] };
|
||||
assert.deepEqual(wiredMax(maxLink(video, 7)), { wired: true, value: null });
|
||||
for (const type of ["AUSBOSS_NODES_Float", "AUSBOSS_NODES_Integer", "PrimitiveFloat", "PrimitiveInt"]) {
|
||||
const source = { type, widgets: [{ name: "unrelated", value: 4 }, { name: "value", value: 10 }] };
|
||||
for (const store of ["object", "map", "getter"]) {
|
||||
assert.deepEqual(wiredMax(maxLink(source, 0, store)), { wired: true, value: 10 });
|
||||
}
|
||||
assert.equal(wiredMax(maxLink(source, 1)).value, null);
|
||||
source.inputs = [{ name: "value", widget: { name: "value" }, link: 9 }];
|
||||
assert.equal(wiredMax(maxLink(source)).value, null);
|
||||
source.inputs = [];
|
||||
source.mode = 4;
|
||||
assert.equal(wiredMax(maxLink(source)).value, null);
|
||||
source.mode = 0;
|
||||
source.widgets[1].value = NaN;
|
||||
assert.equal(wiredMax(maxLink(source)).value, null);
|
||||
}
|
||||
});
|
||||
|
||||
test("typing OUT zero restores the file end while other typed times keep handle limits", () => {
|
||||
assert.deepEqual(typedTrimBounds(10, { start: 2, end: 5 }, "end", 0), { start: 2, end: 10 });
|
||||
assert.deepEqual(typedTrimBounds(10, { start: 2, end: 5 }, "end", 1), { start: 2, end: 2.1 });
|
||||
assert.deepEqual(typedTrimBounds(10, { start: 2, end: 5 }, "end", 7), { start: 2, end: 7 });
|
||||
assert.deepEqual(typedTrimBounds(10, { start: 2, end: 5 }, "start", 0), { start: 0, end: 5 });
|
||||
});
|
||||
|
||||
test("the face shows what the node hands on", () => {
|
||||
// Mirrors audio_window in nodes/_audio_trim_helpers.py.
|
||||
assert.deepEqual(outputWindow(40, 8, 30, 15), { start: 8, end: 30, keepEnd: 23, length: 15, cut: 7 });
|
||||
assert.deepEqual(outputWindow(40, 8, 12, 15), { start: 8, end: 12, keepEnd: 12, length: 4, cut: 0 });
|
||||
assert.equal(outputWindow(40, 5, 0, 0).end, 40, "OUT 0 means the end of the file");
|
||||
assert.equal(outputWindow(40, 30, 0, 20).keepEnd, 40, "a max past the end stops at the end");
|
||||
assert.equal(outputWindow(10, 50, 0).start, 10, "a start past the end is held at the end");
|
||||
assert.equal(outputWindow(10, "x", null, NaN).length, 10);
|
||||
});
|
||||
|
||||
test("the line between IN and OUT says how long and what is cut", () => {
|
||||
assert.equal(outputSummary(outputWindow(40, 8, 30, 15)), "15.0 s · cut 7.0 s");
|
||||
assert.equal(outputSummary(outputWindow(200, 0, 0, 0)), "3:20.0");
|
||||
assert.equal(outputSummary(outputWindow(40, 8, 30, 0), { maxFromLink: true }), "22.0 s · max wired");
|
||||
assert.equal(outputSummary(outputWindow(0, 0, 0)), "");
|
||||
});
|
||||
|
||||
test("a press grabs the nearest handle, the middle, or jumps", () => {
|
||||
const win = outputWindow(40, 10, 30);
|
||||
assert.equal(grabZone(win, 40, 0.25, 400), "start");
|
||||
assert.equal(grabZone(win, 40, 0.75, 400), "end");
|
||||
assert.equal(grabZone(win, 40, 0.5, 400), "move");
|
||||
assert.equal(grabZone(win, 40, 0.05, 400), "jump");
|
||||
assert.equal(grabZone(win, 0, 0.5, 400), "none");
|
||||
});
|
||||
|
||||
test("peaks keep the loudest sample of each slice, channels mixed", () => {
|
||||
const left = Float32Array.from([0, 0.5, -0.5, 0, 1, -1]);
|
||||
const right = Float32Array.from([0, 0.5, -0.5, 0, 1, -1]);
|
||||
const peaks = wavePeaks([left, right], 2);
|
||||
assert.deepEqual(Array.from(peaks), [-0.5, 0.5, -1, 1]);
|
||||
assert.equal(wavePeaks([], 3).length, 6);
|
||||
});
|
||||
@@ -116,7 +116,6 @@ test("a panel that should follow the node's height never declares computeSize",
|
||||
"image_folder",
|
||||
"input_preview",
|
||||
"layout_boxes",
|
||||
"load_audio",
|
||||
"load_image_pad",
|
||||
"load_video",
|
||||
"lora_loader",
|
||||
|
||||
@@ -184,18 +184,3 @@ test("different problems, other kinds of error and other packs' nodes are left a
|
||||
assert.equal(errors[2].errors.length, 2);
|
||||
assert.equal(collapseRepeatedErrors(null), null);
|
||||
});
|
||||
|
||||
test("an empty Load Audio asks for a sound file", () => {
|
||||
const outputs = (classType) => (classType === "SaveAudio"
|
||||
? { output_node: true, input: { required: { audio: ["AUDIO"] } } }
|
||||
: definitionOf(classType));
|
||||
const prompt = {
|
||||
1: node("AUSBOSS_NODES_LoadAudio", { audio: "" }, "Song"),
|
||||
2: node("SaveAudio", { audio: ["1", 0] }),
|
||||
};
|
||||
const found = findEmptySources(prompt, outputs);
|
||||
assert.deepEqual(found.map((entry) => entry.input), ["audio"]);
|
||||
assert.equal(emptySourceMessage(found), "Load a sound file first: Song");
|
||||
prompt[1].inputs.audio = "song.mp3";
|
||||
assert.deepEqual(findEmptySources(prompt, outputs), []);
|
||||
});
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import av
|
||||
import numpy as np
|
||||
|
||||
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._audio_trim_helpers import audio_window, decode_audio_window
|
||||
|
||||
RATE = 8000
|
||||
|
||||
|
||||
def write_ramp(path: Path, seconds: float) -> None:
|
||||
"""A mono wav whose sample value tells the second it was written at."""
|
||||
samples = int(seconds * RATE)
|
||||
data = (np.arange(samples, dtype=np.float32) / RATE / 100.0).reshape(1, -1)
|
||||
with av.open(str(path), "w") as container:
|
||||
stream = container.add_stream("pcm_f32le", rate=RATE, layout="mono")
|
||||
frame = av.AudioFrame.from_ndarray(data, format="flt", layout="mono")
|
||||
frame.sample_rate = RATE
|
||||
for packet in stream.encode(frame):
|
||||
container.mux(packet)
|
||||
for packet in stream.encode(None):
|
||||
container.mux(packet)
|
||||
|
||||
|
||||
class AudioWindowTests(unittest.TestCase):
|
||||
def test_open_end_stays_open_until_the_length_is_known(self):
|
||||
self.assertEqual(audio_window(2, 0)["end"], None)
|
||||
self.assertEqual(audio_window(2, 0, duration=40)["end"], 40)
|
||||
|
||||
def test_max_keeps_the_start_and_cuts_the_rest(self):
|
||||
window = audio_window(8, 30, 15, duration=40)
|
||||
self.assertEqual((window["start"], window["end"], window["cut"]), (8, 23, 7))
|
||||
|
||||
def test_max_longer_than_the_pick_changes_nothing(self):
|
||||
window = audio_window(8, 12, 15, duration=40)
|
||||
self.assertEqual((window["end"], window["cut"]), (12, 0))
|
||||
|
||||
def test_max_with_an_open_end_caps_it(self):
|
||||
self.assertEqual(audio_window(5, 0, 10)["end"], 15)
|
||||
|
||||
def test_bad_values_fall_back(self):
|
||||
window = audio_window("x", None, float("nan"), duration=10)
|
||||
self.assertEqual((window["start"], window["end"], window["cut"]), (0, 10, 0))
|
||||
|
||||
def test_end_before_start_is_an_empty_pick(self):
|
||||
window = audio_window(6, 4, duration=10)
|
||||
self.assertEqual(window["end"], 6)
|
||||
|
||||
|
||||
class DecodeTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.folder = tempfile.TemporaryDirectory()
|
||||
self.path = Path(self.folder.name, "ramp.wav")
|
||||
write_ramp(self.path, 10.0)
|
||||
|
||||
def tearDown(self):
|
||||
self.folder.cleanup()
|
||||
|
||||
def test_takes_exactly_the_pick(self):
|
||||
audio = decode_audio_window(self.path, 2.0, 5.0)
|
||||
waveform = audio["waveform"]
|
||||
self.assertEqual(audio["sample_rate"], RATE)
|
||||
self.assertEqual(tuple(waveform.shape), (1, 1, 3 * RATE))
|
||||
self.assertAlmostEqual(float(waveform[0, 0, 0]) * 100, 2.0, places=3)
|
||||
|
||||
def test_max_cuts_the_end(self):
|
||||
audio = decode_audio_window(self.path, 1.0, 9.0, 2.5)
|
||||
self.assertEqual(audio["waveform"].shape[-1], int(2.5 * RATE))
|
||||
|
||||
def test_open_end_runs_to_the_end_of_the_file(self):
|
||||
audio = decode_audio_window(self.path, 7.5, 0.0)
|
||||
self.assertEqual(audio["waveform"].shape[-1], int(2.5 * RATE))
|
||||
|
||||
def test_start_past_the_end_says_so(self):
|
||||
with self.assertRaisesRegex(ValueError, "past the end"):
|
||||
decode_audio_window(self.path, 12.0, 0.0)
|
||||
|
||||
|
||||
class ReferenceFileTests(unittest.TestCase):
|
||||
def test_hls_cannot_read_a_segment_outside_the_source_folder(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
root = Path(folder)
|
||||
source_folder = root / "input"
|
||||
source_folder.mkdir()
|
||||
outside = root / "outside.aac"
|
||||
with av.open(str(outside), "w") as container:
|
||||
stream = container.add_stream("aac", rate=48000, layout="mono")
|
||||
frame = av.AudioFrame.from_ndarray(
|
||||
np.zeros((1, 48000), dtype=np.float32), format="fltp", layout="mono"
|
||||
)
|
||||
frame.sample_rate = 48000
|
||||
for packet in stream.encode(frame):
|
||||
container.mux(packet)
|
||||
for packet in stream.encode(None):
|
||||
container.mux(packet)
|
||||
for reference in ("../outside.aac", outside.as_posix(), outside.as_uri()):
|
||||
for suffix in (".m3u8", ".wav"):
|
||||
with self.subTest(reference=reference, suffix=suffix):
|
||||
playlist = source_folder / f"song{suffix}"
|
||||
playlist.write_text(
|
||||
"#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:2\n"
|
||||
f"#EXTINF:1.0,\n{reference}\n#EXT-X-ENDLIST\n"
|
||||
)
|
||||
with self.assertRaises(av.error.FFmpegError):
|
||||
decode_audio_window(playlist, 0, 0)
|
||||
|
||||
def test_concat_cannot_follow_references_even_with_an_audio_extension(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
root = Path(folder)
|
||||
source_folder = root / "input"
|
||||
source_folder.mkdir()
|
||||
outside = root / "outside.wav"
|
||||
write_ramp(outside, 1)
|
||||
# A safe-looking relative name can still lead outside the allowed roots.
|
||||
(source_folder / "segment.wav").symlink_to(outside)
|
||||
for suffix in (".ffconcat", ".wav", ".mp3"):
|
||||
with self.subTest(suffix=suffix):
|
||||
playlist = source_folder / f"song{suffix}"
|
||||
playlist.write_text("ffconcat version 1.0\nfile 'segment.wav'\n")
|
||||
with self.assertRaises(av.error.FFmpegError):
|
||||
decode_audio_window(playlist, 0, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -43,7 +43,6 @@ NAMED = {
|
||||
"AusBossImageCropRotatePad": ["image"],
|
||||
"AusBossImageFolder": ["source", "folder"],
|
||||
"AusBossLaMaInpaint": ["model"],
|
||||
"AusBossLoadAudio": ["audio"],
|
||||
"AusBossLoadImagePad": ["image", "source_image"],
|
||||
"AusBossLoadVideo": ["video"],
|
||||
"AusBossLoraLoader": ["loras", "on_missing"],
|
||||
|
||||
Reference in New Issue
Block a user