feat(easy_image_batch): source_batch + source_frames list + selected_… (#134)

* feat(easy_image_batch): source_batch + source_frames list + selected_* outputs

Three-mode input system, two new "selected only" outputs, and an alpha
output convention that's explicit about its role.

Inputs
- source_batch (IMAGE, optional): pre-batched stack, e.g. straight from
  Load Video.
- source_frames (multiline STRING): VFX frame numbers separated by
  commas, newlines, spaces or any mix. Picks from source_batch and
  places at the same timeline position. Removes the 4-keyframe ceiling.
- placeholder_color: now offers Black / Gray / White (was Black / Gray).
- invert_alpha (BOOLEAN, default off = "inpaint"): flips alpha to
  compositing convention (selected=white) when on.
- image1 is now optional (you can drive the node entirely from
  source_batch + source_frames). At least one of image1 or source_batch
  must be connected.
- start_frame default changed from 0 -> 1 to match VFX 1-based numbering.

Mode rules
- Mode 1 -- per-slot images: connect imageN, set imageN_frame.
- Mode 2 -- source_batch + 4 slots: when imageN is unconnected,
  imageN_frame doubles as the source pick index AND timeline target.
- Mode 3 -- source_batch + source_frames list: when the list is
  non-empty, the 4 slots contribute ONLY where imageN is explicitly
  connected -- the imageN_frame defaults (0, 4, 8, 12) do NOT pick from
  source_batch in this mode, so the list fully controls the selection
  unless an imageN override is wired.
- Priority: source_frames list runs first; manual slots run after and
  override at the same target index. Output dedup is automatic
  (set-based).

Outputs
- image_batch (IMAGE): full timeline with placeholders. Unchanged.
- alpha_batch (MASK): renamed from mask_batch; same default convention
  (selected=0.0/black, empty=1.0/white) so existing wires work
  unchanged. Toggle invert_alpha for compositing-style.
- selected_image_batch (IMAGE, NEW): only the keyframes that landed,
  packed back-to-back in timeline order. Empty (length 0) if none
  placed.
- selected_frames (STRING, NEW): comma-separated VFX frame numbers,
  sorted ascending and deduped. Same format as the source_frames input
  -- round-trips into another easy_ImageBatch instance.

Docs
- Updated class docstring with input modes + worked example (surfaces
  in ComfyUI's node tooltip).
- New per-node user guide:
  docs/user_guide/nodes/koolook_image/easy_image_batch.md.
- docs/user_guide/README.md graduates easy_ImageBatch from "TBD" to a
  real link.
- CHANGELOG: Unreleased Added/Changed entries cover the full change set.

Backward compatibility
- Existing workflows wired to image_batch + mask_batch keep working;
  the second output slot position is unchanged.
- The 4-keyframe screenshot pattern (source_batch + 4 slots, no list)
  is unchanged in behaviour.
- start_frame default change affects only newly added nodes; saved
  workflows keep the value they were saved with.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(easy_image_batch): cut window — rename start_frame -> cut_start_frame, fix source-pick math

The 2nd output of Easy Image Batch (Koolook) gets a built-in cut
window, replacing the workflow pattern of feeding the output through
an external ImageFromBatch (batch_index, length) slicer.

Conceptual rename
- `start_frame` -> `cut_start_frame` everywhere.
- The output now represents VFX frames
  [cut_start_frame .. cut_start_frame + total_frames - 1].
- Frames placed outside the cut window are dropped from the cut
  silently and listed in a single end-of-run summary log (replaces
  the previous per-frame "out of range" warnings).

Math fix (real bug)
The previous code conflated source pick index and output index under a
single `target_index = vfx - start_frame`. That happened to work when
start_frame=1 (source pick index = vfx - 1, by coincidence), but
produced a wrong source pick whenever start_frame != 1. Now:

    source pick index   = vfx_frame - 1                  # hardcoded VFX 1-based
    output (cut) index  = vfx_frame - cut_start_frame    # cut window

The two are validated independently:
- source-side range error -> per-frame warning ("VFX frame N doesn't
  exist in source_batch")
- output-side range -> silent drop, collected into the end-of-run
  summary

Defaults
- cut_start_frame=1 (was start_frame=1, so default behaviour for
  source_batch starting at VFX frame 1 is identical).
- total_frames default 16 -> 81 (matches typical VFX shot length).
- imageN_frame defaults 0/4/8/12 -> 1/5/9/13 (valid 1-based).
- imageN_frame min 0 -> 1 (frame 0 is invalid under VFX 1-based).

Console summary
Always logged so it's easy to confirm what the node produced. Shape:

    [easy_ImageBatch] cut window: frames 41..121 (81 frames).
    6 placed; 2 outside cut: 1, 27.

Docs
- Class docstring rewritten with the cut-window model + Examples A
  (cut_start_frame=1, no cut) and B (cut_start_frame=41, cut).
- docs/user_guide/nodes/koolook_image/easy_image_batch.md fully
  updated to match.
- CHANGELOG: new "Changed" entry under [Unreleased] explaining the
  rename, math fix, defaults bump, and migration notes.

Migration notes for users of the previous PR commit
- Saved workflows that had start_frame=1 (the default) keep producing
  identical output -- the math is unchanged in that case.
- Saved workflows that set start_frame to a non-1 value will see
  different source picks (the old code was actually picking the wrong
  source frame, the new code picks correctly). Re-check those.

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(easy_image_batch): shift imageN_frame defaults to 5/9/13/17

The previous 1/5/9/13 cluster put all four default keyframes inside the
first 13 frames of an 81-frame timeline. Shifting to 5/9/13/17 keeps the
same stride-of-4 but leaves a small gap at the start, which reads
cleaner in the node UI and gives a more representative spread of where
keyframes commonly land.

Updates the doc table and CHANGELOG bullet to match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
malkuthro
2026-05-13 19:37:49 +02:00
committed by GitHub
co-authored by Claude Opus 4.7 Cursor
parent dc58e7b224
commit bf02db8ad8
4 changed files with 622 additions and 55 deletions
+87
View File
@@ -6,6 +6,93 @@ The format is inspired by Keep a Changelog and SemVer.
## [Unreleased]
### Added
- **Easy Image Batch (`easy_ImageBatch`): "White" placeholder + `source_batch`
input.** The `placeholder_color` dropdown now offers `Black / Gray / White`
(fill values `0.0 / 0.5 / 1.0`); previously only Black and Gray were
exposed. A new optional `source_batch` (IMAGE) input accepts a pre-batched
image stack (e.g. straight from Load Video). When `source_batch` is
connected and a slot's individual `imageN` is *not* connected, the node
pulls frame `imageN_frame - start_frame` from `source_batch` — so
`imageN_frame` controls both *which source frame to pick* and *where to
place it on the output timeline*. This collapses the typical
"Load Video → Get Images From Batch In Range × N → Easy Image Batch"
helper chain into the node itself. Explicit `imageN` inputs still
override the source-batch pick for that slot, so existing workflows keep
working unchanged. `image1` is now optional too (you can drive the node
entirely from `source_batch`); a clear error is raised if neither
`image1` nor `source_batch` is connected.
- **Easy Image Batch unlimited keyframes via `source_frames` field.** A new
optional multiline STRING input (positioned right above `image1_frame`)
accepts a list of VFX-numbered frames separated by commas, newlines,
spaces, or any mix (e.g. `"1, 27, 41, 63, 85, 120"` or one number per
line) — for sequences needing more than 4 keyframes from a
`source_batch`. Each number both picks `source_batch[N - start_frame]`
and places it at the same timeline position (same convention as the
per-slot `imageN_frame` fields). Bad tokens warn and are skipped;
out-of-range values warn and are skipped.
**List-vs-slot interaction rule:** when `source_frames` is non-empty,
the 4 manual slots contribute ONLY where `imageN` is explicitly
connected — the `imageN_frame` defaults (0, 4, 8, 12) do NOT pick from
`source_batch` in this mode. So the list fully controls the selection,
with explicit `imageN` connections used solely to override individual
positions. When `source_frames` is empty, the original 4-keyframe
behaviour is preserved (unconnected slots fall back to
`source_batch[imageN_frame]`).
Priority order (later wins): `source_frames` list → 4 manual slots.
Output dedup is automatic (set-based tracking).
- **Easy Image Batch: two new outputs — `selected_image_batch` (IMAGE)
and `selected_frames` (STRING).** `selected_image_batch` contains
only the keyframes that actually landed, packed back-to-back in
ascending timeline order with no placeholders (length 0 if nothing
was placed). `selected_frames` is a comma-separated VFX-frame-number
string of the placed keyframes (e.g. `"1, 27, 41, 63"`) — same
format as the `source_frames` input, so the output round-trips
cleanly into another `easy_ImageBatch` instance for chaining or
re-using selections. Outputs are appended at slot positions 3 and 4,
so existing wires on `image_batch` and `alpha_batch` are unaffected.
### Changed
- **Easy Image Batch second output renamed `mask_batch` → `alpha_batch`,
with the same value convention as before.** The default behaviour is
unchanged — selected (occupied) frames = `0.0` (black), missing
frames = `1.0` (white) — so existing workflows wire up identically.
A new `invert_alpha` boolean toggle (default `false`, labels *"inpaint"*
/ *"compositing"*) flips the alpha to compositing-style
(`selected = 1.0, empty = 0.0`) when needed. The output slot keeps its
position (second output) so wires re-attach automatically when the
node reloads.
- **Easy Image Batch: `start_frame` renamed to `cut_start_frame` and
reinterpreted as a built-in cut window.** The output now represents
VFX frames `[cut_start_frame .. cut_start_frame + total_frames - 1]`.
Frames placed outside this window are silently dropped from the output
(a single end-of-run summary lists them, replacing the previous
per-frame "out of range" warnings). Math:
source pick index = vfx_frame - 1 (hardcoded VFX 1-based)
output (cut) index = vfx_frame - cut_start_frame (cut window)
The two were previously conflated under `start_frame`, which produced
the wrong source pick whenever `start_frame` differed from `1`. With
the new model each slot picks `source_batch[vfx-1]` and places it at
output index `vfx - cut_start_frame` independently.
Builds in the workflow that previously used an external
`ImageFromBatch (batch_index, length)` to slice the Easy Image Batch
output. Defaults: `cut_start_frame=1`, `total_frames=81` (was `16`),
`imageN_frame` defaults bumped to `5, 9, 13, 17` (was `0, 4, 8, 12`)
so they're valid VFX-1-based frame numbers.
**Migration note:** saved workflows that had `start_frame` set will
load with the field's stored value into `cut_start_frame` (same field
position, same units). Behaviour is preserved when `cut_start_frame=1`
is the default and the source_batch starts at VFX frame 1; if you had
set `start_frame` to a non-1 value, re-check that your source pick
result is still what you want — under the old code, `start_frame=N`
also offset the source-batch index, which the new model intentionally
does not.
### Fixed
- **Snapshot Settings folder save no longer writes a snapshot.** Saving the
library-path field in the Settings dialog no longer writes the in-memory
+1 -1
View File
@@ -13,7 +13,7 @@ attribution is visible without needing a fork-shaped directory tree.
| ComfyUI menu | Status | Nodes |
|---|---|---|
| `Koolook/Pipeline` | 🚧 page TBD | `EasyAIPipeline` |
| `Koolook/Image` | 🚧 page TBD | `EasyResize_Koolook`, `easy_ImageBatch` |
| `Koolook/Image` | 🚧 `EasyResize_Koolook` page TBD · ✅ [`easy_ImageBatch`](nodes/koolook_image/easy_image_batch.md) | `EasyResize_Koolook`, `easy_ImageBatch` |
| `Koolook/Camera` | 🚧 page TBD | `KoolookLoadCameraPosesAbsolute` |
| `Koolook/Wan_Video` | 🚧 page TBD | `EasyWan22Prompt` |
| `Koolook/VAE` | ✅ [`nodes/radiance_koolook_v2_3_3/`](nodes/radiance_koolook_v2_3_3/) | `Easy_hdr_VAE_encode`, `Easy_hdr_VAE_decode` |
@@ -0,0 +1,191 @@
# Easy Image Batch (Koolook)
| Aspect | Value |
|---|---|
| Display name | `Easy Image Batch (Koolook)` |
| Node ID | `easy_ImageBatch` |
| ComfyUI category | `Koolook/Image` |
| Source | Koolook-native |
| Source file | [`k_easy_image_batch.py`](../../../../k_easy_image_batch.py) |
## What it does
Places up to *N* keyframes on a 1-based VFX timeline, then outputs a
**cut window** of that timeline as a fixed-length `IMAGE` batch. Empty
positions are filled with a chosen placeholder colour (Black / 50 % Gray
/ White). Pairs the image batch with an `alpha_batch` mask marking
selected vs empty frames, and also returns just the in-cut keyframes
packed back-to-back as a separate short-batch output.
Designed for sparse control sequences — e.g. feeding a video model like
Wan 2.2 a 81-frame guide where only frames 41, 63, 78, 88, 98 and 121
carry real image data and the rest tell the model "no latent update
here". The cut window lets you grab any sub-range of a longer sequence
without needing an external `ImageFromBatch` slicer.
## Inputs
### Required widgets
| Input | Type | Default | Description |
|---|---|---|---|
| `total_frames` | `INT` | `81` | Length of the cut window (= length of the output). Range: `1..1024`. |
| `cut_start_frame` | `INT` | `1` | First VFX frame in the output. The output represents `[cut_start_frame .. cut_start_frame + total_frames - 1]`. Frames placed outside this window are dropped from the cut and listed in a single end-of-run summary log. |
| `placeholder_color` | enum | `Black` | Fill colour for unoccupied frames in `image_batch`. `Black = 0.0`, `Gray = 0.5`, `White = 1.0`. Independent of `alpha_batch`. |
| `invert_alpha` | `BOOLEAN` | `false` (`inpaint`) | Off = inpaint convention (selected = `0.0` black, empty = `1.0` white). On = compositing alpha (selected = `1.0`, empty = `0.0`). |
| `source_frames` | multiline `STRING` | `""` | Optional list of VFX frame numbers to pick from `source_batch`. Tokens may be separated by commas, newlines, spaces, tabs, or any mix (`"1, 27\n41 63"` → `[1, 27, 41, 63]`). Each token picks `source_batch[N - 1]` and places it at output index `N - cut_start_frame`. Bad tokens warn and skip. Frames not present in `source_batch` warn; frames outside the cut window are dropped silently and reported in the end-of-run summary. |
| `image1_frame` … `image4_frame` | `INT` | `5`, `9`, `13`, `17` | VFX frame number (1-based) for the slot's keyframe. In Mode 2 also used as the `source_batch` pick index. |
### Optional input slots
| Input | Type | Description |
|---|---|---|
| `source_batch` | `IMAGE` | Pre-batched image stack, e.g. straight from `Load Video`. `source_batch[0]` is treated as VFX frame 1; `source_batch[k]` is VFX frame `k + 1`. |
| `image1` … `image4` | `IMAGE` | Per-slot keyframes. Each must be a single-frame `IMAGE` (batch size 1). Pre-batched stacks must come through `source_batch` instead. |
At least one of `image1` or `source_batch` must be connected; otherwise
the node raises a clear `ValueError`. All connected inputs must share
the same `H × W × C`.
## Outputs
| Output | Type | Description |
|---|---|---|
| `image_batch` | `IMAGE` | The cut window as a `total_frames`-long batch starting at VFX frame `cut_start_frame`. Selected positions carry the picked images; everything else is the chosen placeholder colour. |
| `alpha_batch` | `MASK` | Same length as `image_batch`. Default convention: selected = `0.0`, empty = `1.0`. Toggle `invert_alpha` to flip. |
| `selected_image_batch` | `IMAGE` | **Only** the keyframes that actually landed inside the cut window, packed back-to-back in ascending order. Length = number of placed keyframes; empty (length 0) if nothing was placed. |
| `selected_frames` | `STRING` | Comma-separated VFX frame numbers of the placed-inside-cut keyframes, sorted ascending and deduped (e.g. `"41, 63, 78, 88, 98, 121"`). Same format as the `source_frames` input — paste it (or wire it) into another `easy_ImageBatch` to reuse the same selection. |
## Frame-numbering model
Two indices are computed per VFX frame number `N`:
```text
source pick index = N - 1 # source_batch[0] is VFX frame 1
output (cut) index = N - cut_start_frame # cut window
```
These are independent. A frame is *in the cut* iff its output index is
in `[0, total_frames)`. The two are equal only when `cut_start_frame =
1` (the default), so the previously-shipped behaviour is preserved by
default.
There is no exposed knob to renumber `source_batch` (i.e. say "my video
starts at VFX frame 100"). If your stack represents a different VFX
range, renumber externally before connecting.
## Modes
The node supports three input modes that can be freely mixed.
### Mode 1 — Per-slot images (up to 4 keyframes)
Connect `imageN` and set `imageN_frame` to its VFX timeline position.
The image lands at output index `imageN_frame - cut_start_frame` if
that's inside the cut. No `source_batch` needed. Up to 4 keyframes
total.
### Mode 2 — `source_batch` + the 4 slots (up to 4 keyframes from a video)
Connect a pre-batched stack to `source_batch` (e.g. from `Load Video`)
and leave `imageN` unconnected. Each unconnected slot pulls
`source_batch[imageN_frame - 1]` and places it at output index
`imageN_frame - cut_start_frame` — collapsing the typical "Load Video →
Get Images From Batch In Range × N → Easy Image Batch" helper chain
into a single node.
This mode is active when `source_frames` is empty.
### Mode 3 — `source_batch` + `source_frames` list (unlimited keyframes)
Connect `source_batch` and fill `source_frames` with as many VFX frame
numbers as you need. Removes the 4-keyframe ceiling.
**Important interaction rule:** when `source_frames` is non-empty, the
4 manual slots contribute ONLY where `imageN` is explicitly connected —
the `imageN_frame` defaults do **not** pick from `source_batch` in this
mode. The list fully controls the selection, with explicit `imageN`
connections used solely to override individual positions.
### Priority order
When more than one mode contributes:
1. `source_frames` list runs first.
2. Manual slots (`image1` … `image4` with their `imageN_frame`) run
after, so explicit `imageN` inputs always **override** list entries
at the same cut-output position.
3. The set of placed keyframes is deduped automatically (`selected_*`
outputs reflect the final, deduped state).
## Conventions
- **1-based VFX numbering.** `imageN_frame`, `source_frames`,
`cut_start_frame` are all 1-based. `source_batch[0]` is VFX frame 1.
Frame `0` is invalid and out-of-source-range; frame `1` is the first
real frame.
- **Cut-window drops.** Frames placed outside the cut window aren't
errors — they're intentionally absent. The console prints one summary
line per run noting the cut range, the placed count, and any
outside-cut drops.
- **Alpha vs mask vocabulary.** `alpha_batch` is a `MASK`-typed output
but follows whichever convention `invert_alpha` selects. The default
(`inpaint`) matches the previous `mask_batch` semantics for backward
compatibility (selected = black, empty = white).
## Example A — `cut_start_frame = 1` (no cut, full timeline)
| Field | Value |
|---|---|
| `source_batch` | `Load Video` (121 frames) |
| `source_frames` | `"1, 27, 41, 63, 78, 88, 98, 121"` |
| `cut_start_frame` | `1` |
| `total_frames` | `121` |
| `placeholder_color` | `Gray` |
| `invert_alpha` | `false` (inpaint) |
| Output | What you get |
|---|---|
| `image_batch` | 121 frames; the 8 picks land at indices 0 / 26 / 40 / 62 / 77 / 87 / 97 / 120. Rest is gray. |
| `alpha_batch` | 121-frame mask; those 8 indices are `0.0`, the other 113 are `1.0`. |
| `selected_image_batch` | 8 frames, packed back-to-back in timeline order. |
| `selected_frames` | `"1, 27, 41, 63, 78, 88, 98, 121"` |
Console summary: `cut window: frames 1..121 (121 frames). 8 placed.`
## Example B — `cut_start_frame = 41`, `total_frames = 81` (cut)
Same inputs as Example A, but with the cut window changed to start at
VFX frame 41 for 81 frames.
| Output | What you get |
|---|---|
| `image_batch` | 81 frames representing VFX frames 41..121. Frames 41 / 63 / 78 / 88 / 98 / 121 land at output indices 0 / 22 / 37 / 47 / 57 / 80. Rest is gray. |
| `alpha_batch` | 81-frame mask, 6 indices selected. |
| `selected_image_batch` | 6 frames, packed back-to-back. |
| `selected_frames` | `"41, 63, 78, 88, 98, 121"` |
Console summary: `cut window: frames 41..121 (81 frames). 6 placed; 2 outside cut: 1, 27.`
This replaces the workflow pattern of feeding the Easy Image Batch
output through an external `ImageFromBatch (batch_index=40, length=81)`
node — the cut is built in.
## Tips
- **Round-tripping selections.** Wire `selected_frames` of one node
into `source_frames` of another to chain or fan out the same picks
across workflows.
- **Picking from a video without writing the list manually.** If you
already have helper nodes producing single frames, plug them into
`image1`…`image4` (Mode 1) and leave `source_frames` empty. That's
the legacy 4-keyframe pattern, kept fully working.
- **Going past 4 keyframes.** Switch to Mode 3: connect `source_batch`,
fill `source_frames`, leave `imageN` unconnected. Use `image1`…
`image4` only for *overrides* (e.g. patching one frame manually).
- **Cutting an output.** Set `cut_start_frame` to the first VFX frame
you want and `total_frames` to the length of the cut. No external
slicer node needed.
- **Inverted alpha for compositing.** Toggle `invert_alpha` on when you
want to use the alpha output as a true compositing alpha (selected =
white = visible).
+343 -54
View File
@@ -1,13 +1,127 @@
import re
import torch
# Placeholder fill values (0..1) for unoccupied frames in the output IMAGE batch.
# Alpha output is independent of this choice; see `invert_alpha` for convention.
_PLACEHOLDER_FILL = {
"Black": 0.0,
"Gray": 0.5,
"White": 1.0,
}
class easy_ImageBatch:
"""
A custom ComfyUI node for VFX workflows that takes 1-4 keyframe images with specified frame positions
and creates an IMAGE batch sequence of a given total length, filling undefined frames with selected placeholder color (black or 50% gray).
Also outputs a MASK batch where occupied frames are black (0.0) and empty are white (1.0), directly compatible with standard mask nodes.
Supports VFX-style frame numbering with a start_frame offset (internal indices are 0-based).
Useful for preparing sparse control sequences for video models like Wan 2.2, where placeholder frames indicate no latent update.
Assumes all input images have identical dimensions.
Place up to N keyframes on a 1-based VFX timeline, then output a
*cut window* of that timeline as a fixed-length IMAGE batch with
placeholder fill (Black / 50% Gray / White) for unoccupied frames.
Returns four outputs:
- `image_batch` — IMAGE batch of length `total_frames`,
representing the cut window starting at VFX frame
`cut_start_frame` and lasting `total_frames` frames. Selected
positions carry the picked images; everything else is the
placeholder.
- `alpha_batch` — MASK at the same length. Default
convention: selected = 0.0 (black), empty = 1.0 (white). Toggle
`invert_alpha` flips to compositing-style (selected = 1.0).
- `selected_image_batch` — IMAGE containing only the keyframes that
actually landed *inside the cut*, packed back-to-back in
ascending order (no placeholders). Empty if nothing was placed.
- `selected_frames` — STRING, comma-separated VFX frame
numbers of the placed-inside-cut keyframes (e.g.
`"41, 63, 78, 88, 98, 121"`). Same format as the `source_frames`
input → round-trippable into another `easy_ImageBatch`.
Frame-numbering conventions
---------------------------
All frame numbers (`source_frames`, `imageN_frame`, `cut_start_frame`)
are **VFX-numbered, 1-based**. Internally:
source pick index = vfx_frame - 1 # always
output (cut) index = vfx_frame - cut_start_frame # cut window
`source_batch[0]` is treated as VFX frame 1. There is no exposed
knob to renumber `source_batch` — if your stack represents a
different VFX range, renumber externally before connecting.
Cut window
----------
The output represents VFX frames `[cut_start_frame ..
cut_start_frame + total_frames - 1]`. Frames outside this window
are placed but silently *dropped from the cut* (a single summary
line is logged at the end listing them). Frames inside the window
are placed at output index `vfx - cut_start_frame`. The default
`cut_start_frame = 1` means the cut starts at VFX frame 1, so the
full timeline shows up — equivalent to "no cut".
Three input modes, freely mixable
---------------------------------
1. Per-slot images (up to 4): connect `imageN` and set `imageN_frame`
to its VFX timeline position. The image lands at output index
`imageN_frame - cut_start_frame` if that's inside the cut.
2. `source_batch` (optional pre-batched IMAGE, e.g. straight from
Load Video) + the 4 slots: when a slot's `imageN` is *not*
connected, the node pulls VFX frame `imageN_frame` from
`source_batch` (i.e. `source_batch[imageN_frame - 1]`) and
places it at output index `imageN_frame - cut_start_frame`.
Collapses the "Load Video → Get Images From Batch In Range × N"
helper chain into a single node.
3. `source_batch` + `source_frames` (optional comma/newline/whitespace
separated string like `"1, 27, 41, 63, 85, 120"`): for keyframe
counts beyond 4. Each number picks
`source_batch[N - 1]` and places it at output index
`N - cut_start_frame` (if inside the cut). Bad tokens warn and
are skipped; frames not present in `source_batch` warn; frames
outside the cut window are dropped silently and summarised at
the end.
Important: when `source_frames` is non-empty, the 4 manual slots
contribute ONLY where `imageN` is explicitly connected — the
`imageN_frame` defaults do NOT pick from `source_batch` in this
mode. The list fully controls the selection, with explicit
`imageN` connections used solely to override individual positions.
Priority order (later wins): `source_frames` list → 4 manual slots.
Explicit `imageN` inputs always override list entries at the same
cut-output position. Output dedup is automatic (set-based). At least
one of `image1` or `source_batch` must be provided. All keyframes
(and `source_batch` frames) must share the same H/W/C.
Useful for preparing sparse control sequences for video models like
Wan 2.2, where placeholder frames indicate no latent update.
Example A — cut_start_frame=1 (no cut, full timeline)
-----------------------------------------------------
Inputs:
source_batch = Load Video (121 frames)
source_frames = "1, 27, 41, 63, 78, 88, 98, 121"
cut_start_frame = 1
total_frames = 121
placeholder_color = "Gray"
Outputs:
image_batch — 121 frames; the 8 picked frames sit at
their natural positions (1→idx 0,
27→idx 26, …, 121→idx 120). Rest is gray.
selected_image_batch — 8 frames, packed back-to-back.
selected_frames — "1, 27, 41, 63, 78, 88, 98, 121"
Example B — cut_start_frame=41, total_frames=81 (cut)
-----------------------------------------------------
Same inputs as Example A, but cut_start_frame=41 / total_frames=81.
Outputs:
image_batch — 81 frames representing VFX 41..121.
Frames 41/63/78/88/98/121 land at output
indices 0/22/37/47/57/80. Frames 1, 27
are *outside the cut* and dropped.
selected_image_batch — 6 frames (the in-cut picks).
selected_frames — "41, 63, 78, 88, 98, 121"
Console summary: — "cut window: frames 41..121 (81 frames).
6 placed; 2 outside cut: 1, 27."
"""
@classmethod
@@ -15,52 +129,63 @@ class easy_ImageBatch:
return {
"required": {
"total_frames": ("INT", {
"default": 16,
"default": 81,
"min": 1,
"max": 1024,
"step": 1,
"display": "number"
}),
"start_frame": ("INT", {
"default": 0,
"min": 0,
"cut_start_frame": ("INT", {
"default": 1,
"min": 1,
"max": 999999,
"step": 1,
"display": "number"
}),
"placeholder_color": (["Black", "Gray"], {
"placeholder_color": (list(_PLACEHOLDER_FILL.keys()), {
"default": "Black"
}),
"image1": ("IMAGE", ),
"invert_alpha": ("BOOLEAN", {
"default": False,
"label_on": "compositing",
"label_off": "inpaint",
}),
"source_frames": ("STRING", {
"default": "",
"multiline": True,
"placeholder": "extra frames from source_batch, e.g. 1, 27, 41, 63, 85 (commas and/or newlines)",
}),
"image1_frame": ("INT", {
"default": 0,
"min": 0,
"default": 5,
"min": 1,
"max": 999999,
"step": 1,
"display": "number"
}),
},
"optional": {
"source_batch": ("IMAGE", ),
"image1": ("IMAGE", ),
"image2": ("IMAGE", ),
"image2_frame": ("INT", {
"default": 4,
"min": 0,
"default": 9,
"min": 1,
"max": 999999,
"step": 1,
"display": "number"
}),
"image3": ("IMAGE", ),
"image3_frame": ("INT", {
"default": 8,
"min": 0,
"default": 13,
"min": 1,
"max": 999999,
"step": 1,
"display": "number"
}),
"image4": ("IMAGE", ),
"image4_frame": ("INT", {
"default": 12,
"min": 0,
"default": 17,
"min": 1,
"max": 999999,
"step": 1,
"display": "number"
@@ -68,52 +193,216 @@ class easy_ImageBatch:
}
}
RETURN_TYPES = ("IMAGE", "MASK", )
RETURN_NAMES = ("image_batch", "mask_batch", )
RETURN_TYPES = ("IMAGE", "MASK", "IMAGE", "STRING", )
RETURN_NAMES = ("image_batch", "alpha_batch", "selected_image_batch", "selected_frames", )
FUNCTION = "create_batch"
CATEGORY = "Koolook/Image"
OUTPUT_NODE = False # Not necessarily an output node, but can be chained
def create_batch(self, total_frames, start_frame, placeholder_color, image1, image1_frame, image2=None, image2_frame=None, image3=None, image3_frame=None, image4=None, image4_frame=None):
if image1.shape[0] != 1:
raise ValueError("Each input should be a single IMAGE (batch size 1), not a pre-batched tensor.")
def create_batch(
self,
total_frames,
cut_start_frame,
placeholder_color,
invert_alpha,
source_frames,
image1_frame,
source_batch=None,
image1=None,
image2=None,
image2_frame=None,
image3=None,
image3_frame=None,
image4=None,
image4_frame=None,
):
# Validate per-slot image inputs (must be single-frame tensors,
# not pre-batched). source_batch is the only accepted multi-frame input.
for slot_name, slot_img in (
("image1", image1),
("image2", image2),
("image3", image3),
("image4", image4),
):
if slot_img is not None and slot_img.shape[0] != 1:
raise ValueError(
f"{slot_name} should be a single IMAGE (batch size 1), not a pre-batched "
"tensor. Use the 'source_batch' input for pre-batched stacks."
)
h, w, c = image1.shape[1:]
device = image1.device
dtype = image1.dtype
# Determine reference H/W/C/device/dtype from the first available source.
# Priority: image1 → source_batch → image2 → image3 → image4.
reference = None
for candidate in (image1, source_batch, image2, image3, image4):
if candidate is not None:
reference = candidate
break
if reference is None:
raise ValueError(
"easy_ImageBatch needs at least one of: image1, source_batch, image2, image3, image4."
)
# Vectorized creation of image_batch
fill_value = 0.0 if placeholder_color == "Black" else 0.5
h, w, c = reference.shape[1:]
device = reference.device
dtype = reference.dtype
# Cross-check that all provided inputs share H/W/C.
for label, tensor in (
("image1", image1),
("source_batch", source_batch),
("image2", image2),
("image3", image3),
("image4", image4),
):
if tensor is not None and tensor.shape[1:] != (h, w, c):
raise ValueError(
f"{label} shape {tuple(tensor.shape[1:])} does not match reference "
f"({h}, {w}, {c}). All inputs must share the same H/W/C."
)
fill_value = _PLACEHOLDER_FILL[placeholder_color]
image_batch = torch.full((total_frames, h, w, c), fill_value, device=device, dtype=dtype)
# Alpha (default convention): 1.0 (white) = empty, set to 0.0 on occupied frames below.
# `invert_alpha` flips the result to compositing-style at the end.
alpha_batch = torch.ones((total_frames, h, w), device=device, dtype=torch.float32)
# Vectorized creation of mask_batch (single channel), initialized to 1.0 (white for empty)
mask_batch = torch.ones((total_frames, h, w), device=device, dtype=torch.float32)
# Track timeline indices that actually got a keyframe placed. Used at the
# end to build `selected_image_batch` (just the placed frames, packed)
# and `selected_frames` (their VFX numbers as a comma-separated string).
placed_indices: set[int] = set()
# VFX frame numbers that were referenced but landed outside the cut
# window. Collected once and printed as a single summary line at the
# end so each frame doesn't generate its own console warning.
outside_cut: list[int] = []
# Collect keyframes and compute indices
keyframes = [(image1[0], image1_frame)]
if image2 is not None and image2_frame is not None:
if image2.shape[1:] != (h, w, c):
raise ValueError("All images must have the same height, width, and channels.")
keyframes.append((image2[0], image2_frame))
if image3 is not None and image3_frame is not None:
if image3.shape[1:] != (h, w, c):
raise ValueError("All images must have the same height, width, and channels.")
keyframes.append((image3[0], image3_frame))
if image4 is not None and image4_frame is not None:
if image4.shape[1:] != (h, w, c):
raise ValueError("All images must have the same height, width, and channels.")
keyframes.append((image4[0], image4_frame))
source_len = source_batch.shape[0] if source_batch is not None else 0
# Place keyframes
for img, f in keyframes:
index = f - start_frame
if 0 <= index < total_frames:
image_batch[index] = img
mask_batch[index] = 0.0 # Black (0.0) for occupied
# 1) source_frames list (optional, additive). Each token is a VFX-numbered
# frame: it picks source_batch[N - 1] (VFX 1-based, hardcoded) AND
# places it at output index N - cut_start_frame. The 4 manual slots
# run after this and override per-position when needed.
#
# The field is multiline: tokens may be separated by commas, newlines,
# spaces, tabs, or any mix of them ("1, 27\n41 63" → [1, 27, 41, 63]).
source_frames_str = (source_frames or "").strip()
if source_frames_str:
if source_batch is None:
print(
"[easy_ImageBatch] source_frames is set but source_batch is not connected; "
"ignoring the list (it only picks frames from source_batch)."
)
else:
print(f"Warning: Frame {f} maps to index {index} which is out of range (0 to {total_frames-1}); ignoring this keyframe.")
tokens = [t for t in re.split(r"[,\s]+", source_frames_str) if t]
for tok in tokens:
try:
f = int(tok)
except ValueError:
print(
f"[easy_ImageBatch] source_frames: ignoring non-integer token '{tok}'."
)
continue
source_index = f - 1
output_index = f - cut_start_frame
if not (0 <= source_index < source_len):
print(
f"[easy_ImageBatch] source_frames: VFX frame {f} doesn't exist in "
f"source_batch (size {source_len}, covers frames 1..{source_len}); "
"skipping."
)
continue
if not (0 <= output_index < total_frames):
outside_cut.append(f)
continue
image_batch[output_index] = source_batch[source_index]
alpha_batch[output_index] = 0.0
placed_indices.add(output_index)
return (image_batch, mask_batch, )
# 2) Resolve each manual slot to (frame_image, timeline_frame). A slot
# contributes iff (a) its imageN is connected, or (b) source_batch
# is connected (in which case imageN_frame doubles as the source
# pick index). Manual slots run AFTER the source_frames list so an
# explicit imageN always wins at its target position.
slots = (
(image1, image1_frame, "image1"),
(image2, image2_frame, "image2"),
(image3, image3_frame, "image3"),
(image4, image4_frame, "image4"),
)
# When the user is driving the node from `source_frames`, the 4 manual
# `imageN_frame` defaults (0, 4, 8, 12) shouldn't sneak in extra picks
# from source_batch — only the explicit list (and any explicitly
# connected imageN inputs) should contribute. So the source_batch
# fallback for an UN-connected slot is only active when the list is
# empty (the original 4-keyframe workflow).
slot_source_fallback = source_batch is not None and not source_frames_str
for slot_img, slot_frame, slot_label in slots:
if slot_frame is None:
continue # Optional slot with no frame value — skip.
output_index = slot_frame - cut_start_frame
if slot_img is not None:
frame_tensor = slot_img[0]
elif slot_source_fallback:
# Pull VFX frame `slot_frame` from source_batch (1-based).
source_index = slot_frame - 1
if not (0 <= source_index < source_len):
print(
f"[easy_ImageBatch] {slot_label}: VFX frame {slot_frame} doesn't "
f"exist in source_batch (size {source_len}, covers frames "
f"1..{source_len}); skipping."
)
continue
frame_tensor = source_batch[source_index]
else:
# Either source_batch isn't connected, or source_frames is
# being used and no explicit imageN was wired — leave this
# slot empty so it doesn't pollute the selection.
continue
if 0 <= output_index < total_frames:
image_batch[output_index] = frame_tensor
alpha_batch[output_index] = 0.0
placed_indices.add(output_index)
else:
outside_cut.append(slot_frame)
if invert_alpha:
alpha_batch = 1.0 - alpha_batch
# Build the "selected only" outputs: keyframes packed back-to-back in
# ascending timeline order, plus their VFX frame numbers as a string
# that round-trips cleanly into the `source_frames` input.
sorted_indices = sorted(placed_indices)
if sorted_indices:
selected_image_batch = image_batch[sorted_indices]
else:
# No keyframes placed — emit a 0-length batch with the same H/W/C.
selected_image_batch = image_batch[:0]
selected_frames_out = ", ".join(
str(idx + cut_start_frame) for idx in sorted_indices
)
# One-line summary of the cut window. Always logged so it's easy to
# confirm what the node produced; mentions outside-cut drops when any.
cut_end = cut_start_frame + total_frames - 1
if outside_cut:
unique_outside = sorted(set(outside_cut))
outside_str = ", ".join(str(f) for f in unique_outside)
print(
f"[easy_ImageBatch] cut window: frames {cut_start_frame}..{cut_end} "
f"({total_frames} frames). {len(sorted_indices)} placed; "
f"{len(unique_outside)} outside cut: {outside_str}."
)
else:
print(
f"[easy_ImageBatch] cut window: frames {cut_start_frame}..{cut_end} "
f"({total_frames} frames). {len(sorted_indices)} placed."
)
return (image_batch, alpha_batch, selected_image_batch, selected_frames_out, )
# Node mappings for ComfyUI registration
NODE_CLASS_MAPPINGS = {