From bf02db8ad8847fa80ad67e6ef15b8a65018830e9 Mon Sep 17 00:00:00 2001 From: malkuthro <166633396+malkuthro@users.noreply.github.com> Date: Wed, 13 May 2026 19:37:49 +0200 Subject: [PATCH] =?UTF-8?q?feat(easy=5Fimage=5Fbatch):=20source=5Fbatch=20?= =?UTF-8?q?+=20source=5Fframes=20list=20+=20selected=5F=E2=80=A6=20(#134)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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) --------- Co-authored-by: Cursor Co-authored-by: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 87 ++++ docs/user_guide/README.md | 2 +- .../nodes/koolook_image/easy_image_batch.md | 191 +++++++++ k_easy_image_batch.py | 397 +++++++++++++++--- 4 files changed, 622 insertions(+), 55 deletions(-) create mode 100644 docs/user_guide/nodes/koolook_image/easy_image_batch.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 84b7247..7eb100f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/user_guide/README.md b/docs/user_guide/README.md index bd08fb7..d39e5b8 100644 --- a/docs/user_guide/README.md +++ b/docs/user_guide/README.md @@ -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` | diff --git a/docs/user_guide/nodes/koolook_image/easy_image_batch.md b/docs/user_guide/nodes/koolook_image/easy_image_batch.md new file mode 100644 index 0000000..c8765d9 --- /dev/null +++ b/docs/user_guide/nodes/koolook_image/easy_image_batch.md @@ -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). diff --git a/k_easy_image_batch.py b/k_easy_image_batch.py index 6e06d9c..0bcf57d 100644 --- a/k_easy_image_batch.py +++ b/k_easy_image_batch.py @@ -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 = {