feat(nodes): Easy Image Batch keyframes_insert + insert-over-source +… (#246)

* feat(nodes): Easy Image Batch keyframes_insert + insert-over-source + clean batch

- Rename optional input keyframe_batch -> keyframes_insert (node ID unchanged).
- Insert-over-source mode: when keyframes_insert AND source_batch are both
  connected, composite the inserts over the source cut window (instead of the
  placeholder background). keyframes_insert alone keeps the offset/reconstruct
  behaviour over placeholder.
- No image source connected -> clean placeholder batch sized by new
  width/height fallback widgets, instead of raising. width/height are appended
  LAST so older saved workflows keep their positional widgets_values alignment
  (fixes a mid-list insertion that shifted placeholder_color into source_frames).
- alpha_batch marks only inserted/placed positions; source background is not
  counted as placed.
- New pure planner plan_source_base_fill (torch-free) alongside
  plan_offset_placements; both unit-tested in CI.
- Docs: behavior matrix spec, interactive HTML demo, and a generated
  "all versions" ComfyUI demo workflow under docs/designs/.

Not yet implemented (specced as DRAFT in docs/designs, pending maintainer
approval + tie-break decision): image1-4 "super-overwrite" layer across all
modes, and removal of the Mode 2 unconnected-slot source_batch fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(deps): raise aiohttp [test] floor to >=3.14.1

The constraints lock already pins aiohttp 3.14.1 (the CVE-2026-54273..54280
fix line), but pyproject's [test] extra still declared aiohttp>=3.9.
Dependabot keys off pyproject, not the lock, so the open declared range kept
eight range-based alerts open even though the installed version is patched.
Raising the floor to >=3.14.1 aligns the declared range with the lock and
clears the alerts once this reaches the default branch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(nodes): Easy Image Batch image1-4 super-overwrite + source passthrough

Make the four manual slots the top compositing layer in every mode (select
and insert): a connected imageN overwrites output frame imageN_frame on top
of the sequence/inserts and the background. Higher slot wins a collision
(image4 > image1); a slot beats a sequence pick / insert at the same position.
Extracted as the torch-free plan_slot_overwrites planner + a shared
_apply_slot_overwrites helper used by both code paths.

Two model fixes ride along, per the finalised behaviour matrix:
- An unconnected slot now contributes nothing -- imageN_frame is only a
  placement target for a wired image, never a source_batch pick. The old
  unwired-slot fallback (which surfaced seemingly-random frames) is removed.
- source_batch + an empty frame list (no insert) now passes the source cut
  window straight through instead of a placeholder batch, matching the
  insert-mode empty-list passthrough.

Tests: new plan_slot_overwrites planner cases + fake-tensor behaviour tests
covering slot-beats-pick/insert, higher-slot-wins, passthrough-not-placed, and
the removed phantom fallback. Docstring, tooltips, user guide, design spec
(promoted DRAFT -> canonical), and CHANGELOG updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(nodes): assert selected_image_batch = picks/inserts + slots; drop matrix HTML demo

selected_image_batch already packs every placed position -- both the
source_frames picks / inserts and the manual-slot overwrites, with slots
winning on overlap. Add explicit select- and insert-mode tests asserting the
composition and ascending order, locking the behaviour in.

Also delete docs/designs/easy-image-batch-matrix.html (stale interactive demo
built against an earlier draft) and drop its companion reference from the
design spec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(nodes): Easy Image Batch passthrough inpaint mask + frame-range syntax

Two refinements to the Easy Image Batch passthrough/list behaviour:

- Short-source passthrough is now an extend/inpaint mask. When source_batch is
  connected and the frame list is empty, the covered source frames are kept
  content (alpha 0.0) and the uncovered tail becomes the selection
  (selected_image_batch / selected_frames) with alpha 1.0 -- "keep the first N,
  generate the rest". Full coverage -> no gap, empty selection. The selection
  is the gap here (inverse of select mode); insert mode is unchanged.
- The source_frames field accepts inclusive ranges: "1-5, 7, 9, 14-17" expands
  to 1,2,3,4,5,7,9,14,15,16,17, in both select and insert, via a shared
  torch-free parse_frame_tokens (descending/non-integer tokens warn).

Tests cover the gap selection (short/full/cut-start-offset/slot-in-gap), the
parser (ranges, bad tokens), and range syntax end-to-end. Docstring, tooltip,
user guide, design spec, and CHANGELOG updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(designs): remove draft-era Easy Image Batch demo workflow + builder

Delete easy-image-batch-demo-setup.json and build_easy_image_batch_demo.py
(the leftover draft-era demo, sibling to the already-removed matrix HTML) and
drop the companion reference from the behavior spec. A purpose-built demo will
replace them later; the spec matrix + the node remain the canonical reference.
Also drop the now-stale "(proposed)" from the spec title (it is canonical).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(user-guide): remove stale v0.1.5 Easy Image Batch screenshot

The screenshot predates the node's keyframes_insert / super-overwrite /
passthrough rework and is unreferenced. Removing it to be re-created fresh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs: point Easy Image Batch docs at the bundled Select-and-Rebuild demo

Note the runnable demo workflow ("Easy Image Batch - Select and Rebuild",
bundled into the Kforge Labs Workflows sidebar folder via #248) in both the
user guide and the design spec, replacing the generated demo removed earlier.
Referenced by path (not a clickable link) since the workflow lands via #248.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(nodes): address PR review — keyframe_batch alias + input guards

- Restore keyframe_batch as a deprecated alias of keyframes_insert so
  pre-rename workflows (origin/main exposes the input) keep loading; an explicit
  keyframes_insert wins. Follows docs/maintainers/node-versioning.md
  (alias-then-deprecate); removal deferred to a release boundary.
- Warn instead of silently returning a clean batch when source_frames is
  non-empty but no image source is connected.
- Cap frame-list range expansion at 8192 frames/token to guard against typos
  like 1-99999999.
- Docs: reframe the bundled demo-workflow note as forthcoming (the JSON lands
  via #248, not on this branch); document the alias, cap, and no-source guard in
  the user guide / design spec / CHANGELOG.

Tests: alias routing + precedence, no-source warning, range cap + boundary
(46 node tests). Fresh-eyes review: no correctness/compat issues (the IMAGE
alias input adds no widget-position shift).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs: point Easy Image Batch docs at the now-merged Select-and-Rebuild demo

#248 merged to main and is now in this branch (via the main merge), so the
demo workflow exists at docs/workflows/kforge-labs-workflows/. Repoint the user
guide and design spec at it directly (present tense, real folder link) instead
of the earlier "coming soon / pending" framing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: AI Machine <ai.machine@x.baconcph.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
malkuthro
2026-06-19 00:51:16 +02:00
committed by GitHub
co-authored by Claude Opus 4.8 AI Machine
parent 4383c2fc0b
commit 88dbfedf86
7 changed files with 1316 additions and 305 deletions
+68 -16
View File
@@ -7,27 +7,76 @@ The format is inspired by Keep a Changelog and SemVer.
## [Unreleased]
### Added
- **Easy Image Batch — review hardening.** `keyframe_batch` is kept as a
**deprecated alias** of `keyframes_insert` (pre-rename workflows keep loading;
an explicit `keyframes_insert` wins if both are wired; removal deferred to a
release boundary). A non-empty `source_frames` with **no image source
connected** now logs a warning instead of silently returning a clean batch.
Frame-list **ranges are capped** — a single `N-M` token can't expand past
8192 frames — guarding against typos like `1-99999999`.
- **Easy Image Batch — passthrough inpaint mask + frame-range syntax.** Two
refinements: (1) when `source_batch` is shorter than the output (empty list),
the covered source frames are kept as real content (`alpha` 0.0) while the
uncovered tail stays placeholder and becomes the `selected_image_batch` /
`selected_frames` output — an extend-a-clip inpaint mask (keep the first N,
generate the rest). A full-coverage passthrough has no gap, so `alpha` is
all-kept and the selection is empty. This makes `selected_*` the *gap* in
passthrough — the inverse of select mode, where it is the placed picks/slots.
(2) The `source_frames` field now accepts inclusive ranges: `1-5, 7, 9,
14-17` expands to `1,2,3,4,5,7,9,14,15,16,17`, in both select and insert
modes (shared torch-free `parse_frame_tokens`, unit-tested in CI).
- **Easy Image Batch — `image1-4` super-overwrite + source passthrough.** The
four manual slots are now the top compositing layer in **every** mode: a
connected `imageN` overwrites output frame `imageN_frame` on top of the
sequence/inserts and the background, in select *and* insert modes (they were
previously ignored whenever `keyframes_insert` was connected). On a
collision the higher-numbered slot wins (`image4` > `image1`), and a slot
beats a sequence pick / insert at the same position. Two model fixes ride
along: an **unconnected** slot now contributes nothing — `imageN_frame` is
only a placement target for a wired image, never a `source_batch` pick (the
old "pull `source_batch[imageN_frame-1]`" fallback that surfaced
seemingly-random frames is removed); and **`source_batch` + an empty frame
list (no insert) now passes the source cut window straight through** instead
of emitting a placeholder batch, matching the empty-list insert passthrough
so "source in → source out" is consistent. Slot placement is a pure,
torch-free planner (`plan_slot_overwrites`) unit-tested in CI alongside the
existing insert planners.
- **Easy Image Batch — insert modes (`keyframes_insert`).** A new optional
`keyframes_insert` input (renamed from the unreleased `keyframe_batch`)
adds the inverse of the node's select behaviour. When connected, the node
switches from *selecting* sparse keyframes to *scattering* an
already-packed short sequence onto the `source_frames` positions — placing
the *i*-th incoming frame at the *i*-th listed number (ascending). The
**background depends on `source_batch`**:
- **not connected → offset / reconstruct mode** — gaps filled with the
placeholder colour, at the original sequence length. Closes the
select → process → reconstruct loop: feed a processed copy of a previous
`selected_image_batch` plus the same `selected_frames` list back in to
rebuild the original spacing.
- **connected → insert-over-source mode** — the `source_batch` cut window
is laid down as the background and the listed positions are overwritten
with the insert frames (composite N>1 processed frames back into the
source video at chosen indexes). Cut frames beyond the source fall back
to the placeholder, reported in the summary.
Connected `image1…image4` still composite on top of the inserts in this mode
(see the super-overwrite note above); `alpha_batch` marks the inserted
positions and any slot overwrites. An empty
`source_frames` list inserts nothing — a clean placeholder batch (no
`source_batch`) or a clean `source_batch` passthrough. Placement and the
source-base mapping are pure, torch-free planners (`plan_offset_placements`,
`plan_source_base_fill`) so both are unit-tested in CI.
- **Easy Image Batch — clean batch instead of erroring + `width`/`height`
fallback.** With no image source connected at all, the node now emits a
clean placeholder batch (sized by the new `width`/`height` widgets, default
512×512) rather than raising. `width`/`height` are used **only** as the
fallback when no connected image provides dimensions.
- **Bundled Kforge Labs demo workflows.** Fresh sidebar installs now seed a
**Kforge Labs Workflows** folder with `Easy Image Batch - Select and
Rebuild` and `LOOP Demo`, and the source workflow JSONs are tracked under
`docs/workflows/kforge-labs-workflows/` for release review and future demo
additions. The loop demo ships with auto-queue disabled until the user sets a
writable output folder.
- **Easy Image Batch — offset mode (`keyframe_batch`).** A new optional
`keyframe_batch` input adds the inverse of the node's select behaviour.
When connected, the node switches from *selecting* sparse keyframes out of
a dense `source_batch` to *scattering* an already-packed short sequence
back onto the `source_frames` positions — placing the *i*-th incoming
frame at the *i*-th listed number (ascending), placeholder colour in
between, at the original sequence length. This closes the
select → process → reconstruct loop: feed a processed copy of a previous
`selected_image_batch` plus the same `selected_frames` list back in to
rebuild the original spacing. The switch is wire-driven (`source_batch`
and `image1…image4` are ignored while `keyframe_batch` is connected, with
a console note); frame-count vs list-length mismatches and outside-cut
positions are reported in the end-of-run summary. Placement logic is a
pure, torch-free planner (`plan_offset_placements`) so it is unit-tested
in CI.
- **Publish setup success feedback (#227).** Publishing a saved sidebar
workflow no longer closes the dialog silently — a confirmation card now
shows where the setup was saved (the registry `setups.json` path) and
@@ -170,7 +219,10 @@ The format is inspired by Keep a Changelog and SemVer.
scripts upgrade `setuptools` alongside `pip` before audit. The test lock was
regenerated to move `aiohttp` from 3.14.0 to the fixed 3.14.1 line, and the
relock path now avoids editable Git metadata inspection so Windows
cross-drive worktrees can regenerate the lock cleanly.
cross-drive worktrees can regenerate the lock cleanly. The `[test]` extra's
`aiohttp` floor was also raised from `>=3.9` to `>=3.14.1` so the declared
dependency range can no longer resolve a vulnerable version, clearing the
eight range-based Dependabot alerts the lock bump alone left open.
- **`Koolook_LoopStatus` auto-queue failed on non-default ports.** The
node's `server_url` defaulted to ComfyUI's standard port
(`http://127.0.0.1:8188`), so installs launched with `--port` (or
+141
View File
@@ -0,0 +1,141 @@
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
# Easy Image Batch — behavior matrix
Status: **Canonical** (approved 2026-06-18). Implemented in
`k_easy_image_batch.py` and mirrored in
[`docs/user_guide/nodes/koolook_image/easy_image_batch.md`](../user_guide/nodes/koolook_image/easy_image_batch.md).
Runnable demo: the **Easy Image Batch — Select and Rebuild** workflow bundled in
the Kforge Labs Workflows sidebar folder
([`docs/workflows/kforge-labs-workflows/`](../workflows/kforge-labs-workflows/),
from [#248](https://github.com/malkuthro/ComfyUI-Koolook/pull/248)). It
supersedes the generated demo removed here.
This captures the agreed changes:
1. `keyframe_batch` → **`keyframes_insert`** (rename; the old name is kept as a
deprecated alias input so pre-rename workflows keep loading).
2. **No image source connected → clean placeholder batch** sized by new
`width`/`height` widgets (no error).
3. **Insert-over-source mode** — `keyframes_insert` + `source_batch` composite
the inserts over the source cut window.
4. **`image1`–`image4` are "super-overwrites"** — when connected they replace
whatever is at their index in **every** mode (top priority). When **not**
connected they do **nothing** (the old "pull from `source_batch` via
`imageN_frame`" fallback — Mode 2 — is removed).
---
## The layered model (single source of truth)
The output is built in three layers, lowest first. A later layer overwrites an
earlier one at the same output index.
```
output index i ↔ VFX frame v = cut_start_frame + i (window: i in [0, total_frames))
┌─ Layer 3 — SUPER-OVERWRITE (image1–image4) ────────────────┐ highest priority
│ Each CONNECTED imageN → placed at index (imageN_frame │
│ − cut_start_frame). Wins over everything. Unconnected │
│ slots contribute NOTHING. (imageN_frame matters only │
│ when imageN is connected.) │
├─ Layer 2 — SEQUENCE ───────────────────────────────────────┤
│ • keyframes_insert connected (INSERT): i-th incoming frame │
│ → i-th source_frames position (ascending, deduped). │
│ • else (SELECT): each source_frames number N picks │
│ source_batch[N−1] → index (N − cut_start_frame). │
├─ Layer 1 — BACKGROUND ─────────────────────────────────────┤ lowest priority
│ • insert-over-source (keyframes_insert AND source_batch): │
│ source_batch cut window (index i ← source_batch[v−1]; │
│ frames beyond the source fall back to placeholder). │
│ • otherwise: placeholder_color everywhere. │
└─────────────────────────────────────────────────────────────┘
```
**Background rule.** Layer 1 is the source cut window whenever `source_batch`
is connected **and** either an insert is active (`keyframes_insert`) **or** the
`source_frames` list is empty; otherwise it is `placeholder_color`.
**What counts as "placed"** (drives `alpha_batch`, `selected_image_batch`,
`selected_frames`): normally the Layer 2 placements **and** Layer 3 overwrites;
the Layer 1 `source_batch` backdrop is *not* placed (just the background). So
`alpha_batch` is `0.0` at sequence inserts/picks and connected-slot overwrites,
`1.0` everywhere else (flipped by `invert_alpha`).
**Select passthrough is the exception (row 5).** When the source passes through
with an empty list, the covered source frames *are* kept content (`alpha`
`0.0`), and the **uncovered tail becomes the selection** (`alpha` `1.0`,
emitted in `selected_*`) — an extend-a-clip inpaint mask. The selection here is
the *gap*, the inverse of every other row. Insert-mode passthrough (row 9)
keeps the plain backdrop semantics (nothing placed).
**Resolution / dtype** come from the first connected image (insert: from
`keyframes_insert`; select: `image1` → `source_batch` → `image2…4`). With **no**
image connected at all, the output is sized by the `width`/`height` widgets
(channels = 3), on CPU/float — a clean placeholder batch.
**Tie-break:** if two connected slots target the same index, the
higher-numbered slot wins (`image4` > … > `image1`). Sequence + slot at the
same index → the slot wins (Layer 3 > Layer 2).
---
## Mode matrix
Legend — `keyframes_insert` = **KI**, `source_batch` = **SB**, `source_frames`
list = **list**, connected `image1–4` = **slots**.
| # | KI | SB | list | slots | Background | Placed (alpha 0.0) | `selected_*` | Mode label |
|---|----|----|------|-------|-----------|--------------------|--------------|-----------|
| 1 | ✗ | ✗ | — | none | placeholder (`width`×`height`) | nothing | empty | **clean batch** |
| 2 | ✗ | ✓ | has | none | placeholder | source picks at listed positions | the picks | select |
| 3 | ✗ | ✗ | — | some | placeholder | connected slots | the slots | select (slots) |
| 4 | ✗ | ✓ | has | some | placeholder | source picks **+** slots (slots win on overlap) | picks + slots | select |
| 5 | ✗ | ✓ | empty | none | **source cut window** | covered source (kept) | the uncovered **gap** | source passthrough / extend |
| 6 | ✓ | ✗ | has | none | placeholder | scattered inserts | the inserts | offset / reconstruct |
| 7 | ✓ | ✗ | empty | none | placeholder | nothing | empty | **clean** (nothing inserted) |
| 8 | ✓ | ✓ | has | none | **source cut window** | scattered inserts (overwrite source) | the inserts | insert-over-source |
| 9 | ✓ | ✓ | empty | none | **source cut window** | nothing | empty | source passthrough |
| 10 | ✓ | ✓ | has | some | **source cut window** | inserts **+** slots (slots win) | inserts + slots | insert-over-source |
| 11 | any | any | any | **set but NOT connected** | (per above) | **slots contribute nothing** | (per above) | (per above) |
Row **11** was the bug fixed here: a slot with a non-default `imageN_frame`
that is **not wired** has zero effect on the output. The old behaviour (an
unconnected slot pulling from `source_batch`) is gone.
---
## Edge cases & logging
- **List ranges.** `N-M` (e.g. `14-17`) expands inclusively; a descending
`M-N`, an over-wide range (more than 8192 frames), or any non-integer token
warns and is skipped. Applies in both modes.
- **List set but no image source** (select): the list is ignored and a clean
placeholder batch is returned, with a console warning (not a silent drop).
- **List token not in `source_batch`** (select): warn + skip that number.
- **List/insert position outside the cut window**: dropped, summarized once.
- **More inserts than list positions** / **more positions than inserts**:
reported as "extra insert frames" / "positions with no insert frame".
- **`source_batch` shorter than the cut window** (insert-over-source): the
uncovered tail frames stay placeholder; count reported.
- **Empty list** in an insert mode: clean placeholder (no SB) or clean source
passthrough (with SB) — console note "frame list is empty".
- **Mismatched H/W/C** among connected inputs: hard `ValueError` (unchanged).
- Every run still prints one summary line: mode, cut window, placed count, and
any of the notes above.
---
## Decisions (resolved)
1. **Tie-break direction** — higher-numbered slot wins (`image4` > `image1`);
a slot beats a sequence pick / insert at the same index (Layer 3 > Layer 2).
2. **Empty-list passthrough (select)** — `source_batch` + an empty list passes
the source cut window through (row 5). Covered source frames are kept content
(`alpha` 0.0); a source shorter than the output leaves a placeholder tail
that becomes the selection (`selected_*`) — an extend/inpaint mask. The
selection here is the *gap*, the inverse of other rows.
3. **Mode-2 fallback removed** — an unconnected slot never pulls from
`source_batch`; `imageN_frame` is only a placement target for a wired image.
4. **Range syntax** — the frame list accepts inclusive ranges (`14-17` →
14,15,16,17) in both select and insert; descending/non-integer tokens warn.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

@@ -23,14 +23,31 @@ 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.
**Offset mode (the inverse).** Connect a packed sequence to
`keyframe_batch` and the node flips around: instead of *selecting* sparse
**Insert modes (the inverse).** Connect a packed sequence to
`keyframes_insert` and the node flips around: instead of *selecting* sparse
keyframes out of a dense source, it *scatters* an already-dense short
sequence back onto the keyframe positions from the frame list — placing
frame *i* at the *i*-th listed number, placeholder in between. Use it to
rebuild a full-length sequence from frames you selected, exported,
processed, and want back in their original spacing (step 2 of a
select → process → reconstruct workflow).
sequence onto the keyframe positions from the frame list — placing frame
*i* at the *i*-th listed number. The background depends on whether
`source_batch` is connected:
- **`source_batch` not connected (offset / reconstruct mode):** gaps are
filled with the placeholder. Use it to rebuild a full-length sequence
from frames you selected, exported, processed, and want back in their
original spacing (step 2 of a select → process → reconstruct workflow).
- **`source_batch` connected (insert-over-source mode):** the source cut
window is the background and the listed positions are overwritten with
the insert frames — composite more-than-one processed frames back into
the source video at chosen indexes.
**No inputs ⇒ clean batch.** With no image source connected at all, the
node emits a clean placeholder batch sized by the `width`/`height` widgets
instead of raising an error.
> **Demo workflow.** A runnable **Easy Image Batch — Select and Rebuild** demo
> ships in the **Kforge Labs Workflows** sidebar folder (source:
> [`docs/workflows/kforge-labs-workflows/`](../../../workflows/kforge-labs-workflows/)).
> Load it to walk the select → process → rebuild loop and the layer /
> passthrough behaviour end to end.
## Inputs
@@ -42,29 +59,42 @@ select → process → reconstruct workflow).
| `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. |
| `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]`); inclusive ranges expand (`"14-17"` → `14, 15, 16, 17`). Each number 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 timeline position (1-based) for the slot's keyframe. Used **only** when the matching `imageN` is connected — it's the output frame that image overwrites (the top layer). No longer a `source_batch` pick index. |
### Optional input slots
| Input | Type | Description |
|---|---|---|
| `keyframe_batch` | `IMAGE` | Packed sequence for **offset mode**. When connected, the node switches from *select* to *offset*: frame *i* is placed at the *i*-th position in `source_frames` (ascending), placeholder elsewhere. `source_batch` and `image1…image4` are ignored while this is connected. |
| `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`. |
| `keyframes_insert` | `IMAGE` | Packed sequence for **insert modes**. When connected, the node switches from *select* to *insert*: frame *i* is placed at the *i*-th position in `source_frames` (ascending). Connected `image1…image4` still composite on top of the inserts. The background is placeholder (no `source_batch`) or the `source_batch` cut window (insert-over-source). |
| `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`. In insert mode it becomes the composite background. |
| `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`. (In **offset mode** — `keyframe_batch` connected —
that input alone is sufficient and the others are ignored.)
### Fallback widgets (last two)
| Input | Type | Default | Description |
|---|---|---|---|
| `width` | `INT` | `512` | Fallback output width. Used **only** when no image input provides dimensions (a fully empty node producing a clean placeholder batch). Ignored whenever an image source is connected. Range: `1..8192`. |
| `height` | `INT` | `512` | Fallback output height. Same fallback-only semantics as `width`. Range: `1..8192`. |
These two are deliberately the **last** widgets on the node so they append
to the end of the saved widget list — keeping older saved workflows (which
predate them) correctly aligned.
With **no image source connected at all**, the node emits a clean
placeholder batch sized by the `width`/`height` widgets (no error). When a
source *is* connected, all connected inputs must share the same
`H × W × C`. (In **insert modes** — `keyframes_insert` connected — that
input drives the sequence placement; `source_batch`, if present, supplies
the composite background; and connected `image1…image4` overwrite on top.)
## 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. |
| `alpha_batch` | `MASK` | Same length as `image_batch`. Default (inpaint): content = `0.0`, empty/to-fill = `1.0`. Toggle `invert_alpha` to flip. In a short-source passthrough the covered frames are `0.0` (kept) and the uncovered tail `1.0` (to inpaint). |
| `selected_image_batch` | `IMAGE` | **Only** the keyframes that landed inside the cut, packed back-to-back ascending; empty if nothing was placed. **In passthrough** this is instead the uncovered *gap* frames — the inverse: the frames to generate. |
| `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
@@ -85,68 +115,91 @@ 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
## Select mode — the three layers
The node supports three input modes that can be freely mixed.
When `keyframes_insert` is **not** connected, the output is built in three
layers, lowest first. A higher layer overwrites a lower one at the same
output index.
### Mode 1 — Per-slot images (up to 4 keyframes)
### Layer 1 — Background
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.
- **`source_batch` connected and `source_frames` empty → passthrough.** The
source cut window is laid down as the output (output index *i* ←
`source_batch[cut_start_frame + i - 1]`). Those covered frames are **kept
content** (`alpha` `0.0`). If the source is **shorter** than the output, the
uncovered tail stays placeholder and becomes the **selection** — `alpha`
`1.0`, plus the `selected_image_batch` / `selected_frames` outputs — i.e. an
extend-a-clip inpaint mask: keep the first N real frames, generate the rest.
(This is the inverse of select mode, where the selection is the picks/slots.)
A full-coverage passthrough has no gap → all kept, empty selection.
- **Otherwise → placeholder.** A non-empty `source_frames` list means *select*
mode: the named frames are pulled onto a neutral `placeholder_color`
background, and only those picks/slots are *placed*. With no `source_batch`
at all the background is placeholder too.
### Mode 2 — `source_batch` + the 4 slots (up to 4 keyframes from a video)
In select mode the placeholder background is never counted as *placed* —
`alpha_batch` and the `selected_*` outputs describe only the picks and slots.
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.
### Layer 2 — `source_frames` picks
This mode is active when `source_frames` is empty.
Each VFX number in `source_frames` picks `source_batch[N - 1]` and places it
at output index `N - cut_start_frame` (if inside the cut), on top of the
background. Unlimited keyframes — removes the old 4-slot ceiling. Bad tokens
warn and skip; frames absent from `source_batch` warn; outside-cut frames are
dropped and summarised.
### Mode 3 — `source_batch` + `source_frames` list (unlimited keyframes)
### Layer 3 — Manual slots (`image1`-`image4`), top priority
Connect `source_batch` and fill `source_frames` with as many VFX frame
numbers as you need. Removes the 4-keyframe ceiling.
Connect `imageN` and set `imageN_frame` to its VFX position; the image
overwrites that output frame **on top of everything** — background and picks
alike. On a collision the **higher-numbered slot wins** (`image4` > `image1`),
and a slot beats a Layer-2 pick at the same position.
**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.
An **unconnected** slot does nothing. `imageN_frame` is only a placement
target for a *wired* `imageN` — it is no longer a `source_batch` pick index.
(The old behaviour, where an unwired slot pulled `source_batch[imageN_frame -
1]` whenever the list was empty, is removed: it surfaced seemingly-random
source frames. To pick frames from a video, use the `source_frames` list.)
### Priority order
The set of placed positions is deduped automatically (`selected_*` reflect the
final, deduped state).
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).
### Mode 4 — Offset / reconstruct (`keyframe_batch`)
### Mode 4 — Insert (`keyframes_insert`)
The inverse of Modes 1–3. Connect a packed `IMAGE` sequence (e.g. frames
you previously pulled via `selected_image_batch`, processed, and
re-loaded) to `keyframe_batch`. The node switches to **offset mode** and
re-loaded) to `keyframes_insert`. The node switches to **insert mode** and
reads `source_frames` as *destination positions*: the *i*-th frame of
`keyframe_batch` is placed at output index `position[i] - cut_start_frame`,
everything else filled by `placeholder_color`. Positions are taken
ascending and de-duplicated, so it round-trips cleanly with the ascending
`selected_image_batch` + `selected_frames` a select-mode node produces.
`keyframes_insert` is placed at output index `position[i] - cut_start_frame`.
Positions are taken ascending and de-duplicated, so it round-trips cleanly
with the ascending `selected_image_batch` + `selected_frames` a select-mode
node produces.
- **Wire-driven switch.** Offset mode is active whenever `keyframe_batch`
is connected; `source_batch` and `image1…image4` are ignored (a one-line
console note fires if they're connected, so nothing is silently dropped).
The background depends on `source_batch`:
- **Offset / reconstruct (no `source_batch`).** Gaps are filled with
`placeholder_color`, at the original sequence length.
- **Insert-over-source (`source_batch` connected).** The `source_batch` cut
window is laid down first (output index *i* ← `source_batch[cut_start_frame
+ i - 1]`; cut frames beyond the source fall back to the placeholder and
are reported), then the listed positions are overwritten with the insert
frames. This composites several processed frames back into the source
video at chosen indexes.
- **Wire-driven switch.** Insert mode is active whenever `keyframes_insert`
is connected. Connected `image1…image4` still apply — they composite on top
of the inserts (Layer 3): a slot beats an insert at the same position, and
the higher-numbered slot wins a slot-vs-slot collision.
- **Alpha marks the placed frames.** Regardless of background, `alpha_batch`
is `0.0` at the inserted positions and any slot overwrites, and
`selected_image_batch` / `selected_frames` describe just those.
- **Counts needn't match.** Extra incoming frames beyond the list are
reported as unused; positions beyond the available frames are reported as
missing. Outside-cut positions are dropped, same as select mode.
- **Empty list ⇒ nothing placed** (all placeholder), with a console note.
missing. Outside-cut positions (inserts or slots) are dropped, same as
select mode.
- **Empty list ⇒ nothing inserted** — a clean placeholder batch (no
`source_batch`) or a clean `source_batch` cut-window passthrough — with a
console note. (Connected slots still composite on top.)
## Conventions
@@ -195,7 +248,7 @@ VFX frame 41 for 81 frames.
| `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.`
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)`
@@ -209,7 +262,7 @@ the original length:
| Field | Value |
|---|---|
| `keyframe_batch` | the 5 processed frames (e.g. `Load EXR` / image batch) |
| `keyframes_insert` | the 5 processed frames (e.g. `Load EXR` / image batch) |
| `source_frames` | `"1, 10, 19, 30, 40"` |
| `cut_start_frame` | `1` |
| `total_frames` | `41` |
@@ -224,18 +277,40 @@ the original length:
Console summary: `offset mode: cut window frames 1..41 (41 frames). 5 placed.`
## Example D — Insert-over-source (composite into a video)
Same 5 processed frames as Example C, but now you want them dropped back
**into the original 41-frame video** at their positions rather than onto a
black background — the untouched frames keep the source footage.
| Field | Value |
|---|---|
| `keyframes_insert` | the 5 processed frames |
| `source_batch` | the original 41-frame `Load Video` stack |
| `source_frames` | `"1, 10, 19, 30, 40"` |
| `cut_start_frame` | `1` |
| `total_frames` | `41` |
| Output | What you get |
|---|---|
| `image_batch` | 41 frames; the 5 processed frames overwrite indices 0 / 9 / 18 / 29 / 39, every other frame is the original source frame. |
| `alpha_batch` | 41-frame mask; only those 5 indices are `0.0` (the inserts), the rest `1.0`. |
| `selected_image_batch` | the 5 inserted frames, packed back-to-back. |
| `selected_frames` | `"1, 10, 19, 30, 40"` |
Console summary: `insert-over-source mode: cut window frames 1..41 (41 frames). 5 placed.`
## 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).
- **A few explicit keyframes.** Plug single-frame images into `image1`…
`image4` and set each `imageN_frame`. They overwrite those positions on top
of whatever else the node produces — no `source_batch` needed.
- **Picking many frames from a video.** Connect `source_batch` and list the
VFX numbers in `source_frames` (no 4-keyframe ceiling). Reach for `image1`…
`image4` only to *override* individual positions on top of the picks.
- **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.
+479 -208
View File
@@ -14,6 +14,11 @@ _PLACEHOLDER_FILL = {
"White": 1.0,
}
# Upper bound on how many frames a single ``N-M`` range token may expand to, so
# a typo like ``1-99999999`` can't balloon the frame list into millions of
# entries. Comfortably above the node's max cut window (total_frames <= 1024).
_MAX_RANGE_SPAN = 8192
@dataclass(frozen=True)
class OffsetPlan:
@@ -21,7 +26,7 @@ class OffsetPlan:
- ``placements`` — ``(source_index, output_index, vfx_frame)`` for
each incoming frame that lands inside the cut window, ascending by
position. ``source_index`` is the frame's ordinal in ``keyframe_batch``.
position. ``source_index`` is the frame's ordinal in ``keyframes_insert``.
- ``outside_cut`` — VFX frame numbers paired to a frame but whose
output index fell outside ``[0, total_frames)`` (dropped, like select
mode).
@@ -71,6 +76,103 @@ def plan_offset_placements(
return OffsetPlan(placements, outside_cut, unused_source, missing_positions)
def plan_source_base_fill(
total_frames: int, cut_start_frame: int, base_len: int
) -> tuple[list[tuple[int, int]], int]:
"""Map each output index in the cut window onto a ``source_batch`` index.
Used by insert-over-source mode to lay down the source video as the
background before the inserts overwrite it. Output index ``i`` represents
VFX frame ``cut_start_frame + i``, and ``source_batch[0]`` is VFX frame 1,
so ``source_index = i + cut_start_frame - 1``.
Returns ``(placements, fallback_count)`` where ``placements`` is the list
of ``(output_index, source_index)`` the source actually covers and
``fallback_count`` is how many cut frames fell beyond the source (and stay
placeholder). Pure Python — no torch — so it is unit-testable in CI.
"""
placements: list[tuple[int, int]] = []
fallback = 0
for output_index in range(total_frames):
source_index = output_index + cut_start_frame - 1
if 0 <= source_index < base_len:
placements.append((output_index, source_index))
else:
fallback += 1
return placements, fallback
def plan_slot_overwrites(
slot_frames: list[tuple[int, int]],
total_frames: int,
cut_start_frame: int,
) -> tuple[list[tuple[int, int, int]], list[int]]:
"""Map connected manual slots (image1-4) onto output indices — Layer 3.
``slot_frames`` is ``(slot_index, vfx_frame)`` for each CONNECTED slot, in
ascending slot order (image1 = 0 … image4 = 3). Manual slots are the top
compositing layer: they overwrite whatever the sequence or background put
at the same output index. On a collision between two slots the
higher-numbered slot wins (image4 > image1). A slot lands iff its output
index ``vfx - cut_start_frame`` is inside ``[0, total_frames)``; otherwise
the frame is reported in ``outside_cut``.
Returns ``(placements, outside_cut)`` where ``placements`` is
``(slot_index, output_index, vfx)`` ascending by output index, one winner
per index. Pure Python — no torch — so it is unit-testable in CI.
"""
winner: dict[int, tuple[int, int]] = {} # output_index -> (slot_index, vfx)
outside_cut: list[int] = []
for slot_index, vfx in slot_frames:
output_index = vfx - cut_start_frame
if 0 <= output_index < total_frames:
# Higher slot wins on a collision (image4 > image1), independent of
# the order slots are supplied in.
prev = winner.get(output_index)
if prev is None or slot_index > prev[0]:
winner[output_index] = (slot_index, vfx)
else:
outside_cut.append(vfx)
placements = [
(slot_index, output_index, vfx)
for output_index, (slot_index, vfx) in sorted(winner.items())
]
return placements, outside_cut
def parse_frame_tokens(source_frames: str) -> tuple[list[int], list[str]]:
"""Parse a frame-list string into 1-based VFX frame numbers.
Tokens are separated by commas, spaces, tabs, or newlines. A token is
either a single integer (``7``) or an inclusive ascending range
(``14-17`` → 14, 15, 16, 17). Returns ``(values, bad_tokens)`` where
``bad_tokens`` are the tokens that could not be parsed (non-integer, a
descending range like ``5-1``, or a range wider than ``_MAX_RANGE_SPAN``)
so the caller can warn. Pure Python — no torch — so it is unit-testable in
CI. Shared by select picks and insert positions.
"""
values: list[int] = []
bad_tokens: list[str] = []
for tok in re.split(r"[,\s]+", (source_frames or "").strip()):
if not tok:
continue
span = re.fullmatch(r"(\d+)-(\d+)", tok)
if span:
lo, hi = int(span.group(1)), int(span.group(2))
# Reject descending or absurdly large ranges (a typo like
# "1-99999999" would otherwise expand into millions of entries).
if lo <= hi <= lo + _MAX_RANGE_SPAN - 1:
values.extend(range(lo, hi + 1))
else:
bad_tokens.append(tok)
continue
try:
values.append(int(tok))
except ValueError:
bad_tokens.append(tok)
return values, bad_tokens
class easy_ImageBatch:
"""
Place up to N keyframes on a 1-based VFX timeline, then output a
@@ -116,45 +218,54 @@ class easy_ImageBatch:
`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.
Select mode — the layers (lowest first; a later layer overwrites an
earlier one at the same output index)
-----------------------------------------------------------------------
1. Background: with `source_batch` connected and an EMPTY `source_frames`
list, the source cut window passes straight through (source in →
source out) as kept content (`alpha` 0.0). Any tail beyond the source
stays placeholder, and that uncovered gap becomes the `selected_*`
output — the frames to inpaint/generate (the inverse of the usual
"selected = picks/slots"). Otherwise the background is
`placeholder_color` and is not counted as "placed".
2. `source_frames` (optional comma/newline/whitespace string like
`"1, 27, 41, 63"`, with inclusive ranges `"14-17"` → 14,15,16,17): each
number picks `source_batch[N - 1]` and places it at output index
`N - cut_start_frame` (if inside the cut), onto the placeholder. Bad
tokens warn and are skipped; frames not present in `source_batch` warn;
frames outside the cut window are dropped and summarised at the end.
3. Manual slots (`image1`-`image4`): connect `imageN` and set
`imageN_frame` to its VFX position; the image overwrites that output
frame on TOP of everything. A higher-numbered slot wins a slot-vs-slot
collision (`image4` > `image1`). An UNconnected slot does nothing —
`imageN_frame` is only a placement target for a wired `imageN`, never a
`source_batch` pick.
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.
Output dedup is automatic (set-based). All inputs must share the same
H/W/C. When no image source is connected at all, the node emits a clean
placeholder batch sized by the `width`/`height` widgets instead of
erroring.
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.
Insert modes (the inverse) — connect a packed sequence to
`keyframes_insert` and the node instead *scatters* those frames onto the
`source_frames` positions (i-th frame → i-th position, ascending). The
background depends on `source_batch`:
Offset mode (the inverse) — connect a packed sequence to
`keyframe_batch` and the node instead *scatters* those frames back onto
the `source_frames` positions (i-th frame → i-th position, ascending),
filling the rest with the placeholder. `source_batch` and the manual
slots are ignored while `keyframe_batch` is connected. Round-trips with
`selected_image_batch` + `selected_frames`. See `create_batch_from_frames`.
- `source_batch` NOT connected → gaps filled with the placeholder
(offset / reconstruct mode). Round-trips with `selected_image_batch` +
`selected_frames`.
- `source_batch` connected → the inserts are composited *over* the
`source_batch` cut window (insert-over-source mode): the source video
is the background and the listed positions are overwritten with the
insert frames.
Connected manual slots (`image1`-`image4`) still apply here — they
composite on TOP of the inserts (a slot beats an insert at the same
position; the higher slot wins a slot collision). With an empty
`source_frames` list and no slots nothing is placed: a clean placeholder
batch (no `source_batch`) or a clean `source_batch` cut-window
passthrough. `alpha_batch` marks the inserted positions and slot
overwrites as placed. See `create_batch_from_frames`.
Useful for preparing sparse control sequences for video models like
Wan 2.2, where placeholder frames indicate no latent update.
@@ -223,8 +334,8 @@ class easy_ImageBatch:
"source_frames": ("STRING", {
"default": "",
"multiline": True,
"placeholder": "extra frames from source_batch, e.g. 1, 27, 41, 63, 85 (commas and/or newlines)",
"tooltip": "Optional 1-based VFX frame list to pick from source_batch. Separators can be commas, spaces, tabs, or newlines.",
"placeholder": "frames from source_batch, e.g. 1, 27, 41-50, 63 (commas/newlines; ranges with -)",
"tooltip": "Optional 1-based VFX frame list to pick from source_batch (in insert mode, the destination positions). Separators: commas, spaces, tabs, or newlines. Inclusive ranges like 14-17 expand to 14,15,16,17.",
}),
"image1_frame": ("INT", {
"default": 5,
@@ -232,22 +343,32 @@ class easy_ImageBatch:
"max": 999999,
"step": 1,
"display": "number",
"tooltip": "1-based VFX frame number for image1, or source_batch pick when source_frames is empty.",
"tooltip": "1-based VFX frame number for image1; the connected image is placed here on top (no effect when the slot is unwired).",
}),
},
"optional": {
"keyframes_insert": ("IMAGE", {
"tooltip": (
"Connect a packed sequence of frames to INSERT them onto the "
"timeline at the positions from the frame list (source_frames): "
"1st frame -> 1st number, 2nd -> 2nd, ... Connected image1-4 "
"still composite on top of the inserts. The background depends on "
"source_batch: if source_batch is NOT connected, the gaps are "
"filled with placeholder_color (offset/reconstruct mode); if "
"source_batch IS connected, the inserts are composited over the "
"source frames in the cut window (insert-over-source mode). With "
"an empty frame list and no slots nothing is placed: a clean "
"placeholder batch (no source_batch) or a clean source-batch "
"passthrough (with source_batch)."
),
}),
"keyframe_batch": ("IMAGE", {
"tooltip": (
"Connect a packed sequence of already-selected/processed "
"frames to switch the node into OFFSET mode: each incoming "
"frame is placed onto the timeline at the matching number "
"from the frame list (1st frame -> 1st number, 2nd -> 2nd, "
"...), with every other position filled by "
"placeholder_color. The frame list (source_frames) supplies "
"the positions; source_batch and image1-4 are ignored while "
"this is connected. Use it to rebuild a full-length sequence "
"from frames you exported, processed, and want back in their "
"original positions."
"Deprecated alias for keyframes_insert (the input was "
"renamed before release). Use keyframes_insert; this alias "
"keeps pre-rename workflows loading and will be removed in "
"a future release. Ignored when keyframes_insert is also "
"connected."
),
}),
"source_batch": ("IMAGE", ),
@@ -259,7 +380,7 @@ class easy_ImageBatch:
"max": 999999,
"step": 1,
"display": "number",
"tooltip": "1-based VFX frame number for image2, or source_batch pick when source_frames is empty.",
"tooltip": "1-based VFX frame number for image2; the connected image is placed here on top (no effect when the slot is unwired).",
}),
"image3": ("IMAGE", ),
"image3_frame": ("INT", {
@@ -268,7 +389,7 @@ class easy_ImageBatch:
"max": 999999,
"step": 1,
"display": "number",
"tooltip": "1-based VFX frame number for image3, or source_batch pick when source_frames is empty.",
"tooltip": "1-based VFX frame number for image3; the connected image is placed here on top (no effect when the slot is unwired).",
}),
"image4": ("IMAGE", ),
"image4_frame": ("INT", {
@@ -277,7 +398,26 @@ class easy_ImageBatch:
"max": 999999,
"step": 1,
"display": "number",
"tooltip": "1-based VFX frame number for image4, or source_batch pick when source_frames is empty.",
"tooltip": "1-based VFX frame number for image4; the connected image is placed here on top (no effect when the slot is unwired).",
}),
# width/height are intentionally LAST so they append to the end
# of the widget list — inserting them earlier would shift the
# positional widgets_values of every previously-saved workflow.
"width": ("INT", {
"default": 512,
"min": 1,
"max": 8192,
"step": 8,
"display": "number",
"tooltip": "Fallback output width. Used ONLY when no image input provides dimensions (e.g. a fully empty node producing a clean placeholder batch). Ignored whenever an image source is connected.",
}),
"height": ("INT", {
"default": 512,
"min": 1,
"max": 8192,
"step": 8,
"display": "number",
"tooltip": "Fallback output height. Used ONLY when no image input provides dimensions (e.g. a fully empty node producing a clean placeholder batch). Ignored whenever an image source is connected.",
}),
}
}
@@ -304,27 +444,25 @@ class easy_ImageBatch:
image3_frame=None,
image4=None,
image4_frame=None,
keyframes_insert=None,
keyframe_batch=None,
width=512,
height=512,
):
# Offset mode: when keyframe_batch is connected, switch behaviour from
# "select frames out of source_batch" to "scatter this packed sequence
# back onto the frame-list positions". See create_batch_from_frames.
if keyframe_batch is not None:
ignored = source_batch is not None or any(
img is not None for img in (image1, image2, image3, image4)
)
return self.create_batch_from_frames(
keyframe_batch,
source_frames,
total_frames,
cut_start_frame,
placeholder_color,
invert_alpha,
ignored_inputs_connected=ignored,
# Deprecated alias: `keyframe_batch` was renamed to `keyframes_insert`
# before release. Honour a pre-rename workflow's connection so it keeps
# loading; an explicit `keyframes_insert` wins if both are wired.
if keyframes_insert is None and keyframe_batch is not None:
keyframes_insert = keyframe_batch
print(
"[easy_ImageBatch] 'keyframe_batch' is deprecated; use "
"'keyframes_insert'. Routing the connected batch to insert mode."
)
# Validate per-slot image inputs (must be single-frame tensors,
# not pre-batched). source_batch is the only accepted multi-frame input.
# Validate per-slot image inputs (must be single-frame tensors, not
# pre-batched) for BOTH modes — manual slots are now composited as the
# top layer in insert mode too, not just select. source_batch is the
# only accepted multi-frame input.
for slot_name, slot_img in (
("image1", image1),
("image2", image2),
@@ -337,16 +475,54 @@ class easy_ImageBatch:
"tensor. Use the 'source_batch' input for pre-batched stacks."
)
# Manual slots (image1-4) are the top compositing layer (Layer 3) in
# every mode: a connected imageN overwrites output frame imageN_frame on
# top of the sequence/background. Higher slot wins on a collision.
slots = (
(image1, image1_frame),
(image2, image2_frame),
(image3, image3_frame),
(image4, image4_frame),
)
# Insert modes: when keyframes_insert is connected, switch from "select
# frames out of source_batch" to "scatter this packed sequence onto the
# frame-list positions", then composite the slots over the result. The
# background depends on source_batch:
# - no source_batch -> placeholder gaps (offset / reconstruct mode)
# - with source_batch -> the inserts sit over the source cut window
# (insert-over-source mode)
# See create_batch_from_frames.
if keyframes_insert is not None:
return self.create_batch_from_frames(
keyframes_insert,
source_frames,
total_frames,
cut_start_frame,
placeholder_color,
invert_alpha,
base_batch=source_batch,
slots=slots,
)
# Determine reference H/W/C/device/dtype from the first available source.
# Priority: image1 → source_batch → image2 → image3 → image4.
# Priority: image1 → source_batch → image2 → image3 → image4. With no
# image source connected at all, fall back to the width/height widgets
# and emit a clean placeholder batch instead of erroring.
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."
if (source_frames or "").strip():
print(
"[easy_ImageBatch] source_frames is set but no image source "
"(source_batch / image1-4) is connected; the list is ignored "
"and a clean placeholder batch is returned."
)
return self._clean_placeholder_batch(
total_frames, height, width, 3, placeholder_color, invert_alpha
)
h, w, c = reference.shape[1:]
@@ -367,31 +543,53 @@ class easy_ImageBatch:
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)
# 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] = []
# Compute the frame list early — it decides the background.
source_frames_str = (source_frames or "").strip()
source_len = source_batch.shape[0] if source_batch is not None else 0
# 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.
# Background (Layer 1): with source_batch connected AND an empty frame
# list, pass the source cut window straight through (the same backdrop
# insert-over-source lays down) — "source in, source out" with nothing
# else to do. Otherwise fill with the placeholder; a non-empty list
# means select mode (pull the named frames onto a neutral background).
# The background is NOT counted as "placed": only list picks and slots
# are, so alpha / selected_* describe just those.
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/to-fill; set to 0.0 on
# frames that carry real content. `invert_alpha` flips at the end.
alpha_batch = torch.ones((total_frames, h, w), device=device, dtype=torch.float32)
# Timeline indices with a keyframe placed (picks + slots), used to build
# the selected_* outputs. `outside_cut` collects referenced VFX frames
# that fell outside the window (summarised once at the end).
placed_indices: set[int] = set()
outside_cut: list[int] = []
# Passthrough (empty list + source): the source cut window is the kept
# background — covered frames carry real content (alpha 0.0), the
# uncovered tail stays placeholder. `covered_indices` lets the selection
# below resolve to the GAP (the to-inpaint frames) rather than to the
# picks/slots that drive select mode.
passthrough = source_batch is not None and not source_frames_str
covered_indices: set[int] = set()
if passthrough:
base_placements, _ = plan_source_base_fill(
total_frames, cut_start_frame, source_len
)
for output_index, source_index in base_placements:
image_batch[output_index] = source_batch[source_index]
alpha_batch[output_index] = 0.0
covered_indices.add(output_index)
# 1) source_frames list (optional). Each token is a VFX-numbered frame:
# it picks source_batch[N - 1] (VFX 1-based) AND places it at output
# index N - cut_start_frame, onto the placeholder. Runs only when the
# list is non-empty (an empty list is the passthrough case above);
# the manual slots run after and override per-position.
#
# 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(
@@ -399,15 +597,12 @@ class easy_ImageBatch:
"ignoring the list (it only picks frames from source_batch)."
)
else:
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
values, bad_tokens = parse_frame_tokens(source_frames_str)
for tok in bad_tokens:
print(
f"[easy_ImageBatch] source_frames: ignoring invalid token '{tok}'."
)
for f in values:
source_index = f - 1
output_index = f - cut_start_frame
if not (0 <= source_index < source_len):
@@ -424,57 +619,26 @@ class easy_ImageBatch:
alpha_batch[output_index] = 0.0
placed_indices.add(output_index)
# 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"),
# 2) Manual slots (Layer 3, top priority): a connected image1-4
# overwrites its output frame on top of the list picks / passthrough
# background. Higher slot wins on a collision (image4 > image1).
# Unconnected slots contribute nothing — imageN_frame is only a
# placement target for a wired imageN, never a source_batch pick.
self._apply_slot_overwrites(
image_batch, alpha_batch, placed_indices, slots,
total_frames, cut_start_frame, outside_cut, (h, w, c),
)
# When the user is driving the node from `source_frames`, the manual
# `imageN_frame` defaults 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)
# Passthrough selection: the "selected" frames are the GAP — placeholder
# frames with no source coverage and no slot, i.e. the ones to inpaint /
# generate. This is the inverse of select mode (where selected = the
# placed picks/slots); covered source and slot overwrites are kept
# content and excluded from the selection.
if passthrough:
content = covered_indices | placed_indices
placed_indices = {
i for i in range(total_frames) if i not in content
}
if invert_alpha:
alpha_batch = 1.0 - alpha_batch
@@ -493,86 +657,183 @@ class easy_ImageBatch:
)
# 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.
# confirm what the node produced; surfaces outside-cut drops, the
# passthrough background, and any source-shorter-than-cut fill.
cut_end = cut_start_frame + total_frames - 1
notes: list[str] = []
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}."
notes.append(
f"{len(unique_outside)} outside cut: "
+ ", ".join(str(f) for f in unique_outside)
)
else:
print(
f"[easy_ImageBatch] cut window: frames {cut_start_frame}..{cut_end} "
f"({total_frames} frames). {len(sorted_indices)} placed."
if passthrough:
# kept = everything not in the gap (covered source + any slot fills).
notes.append(
f"source passthrough: {total_frames - len(sorted_indices)} kept, "
f"{len(sorted_indices)} gap frame(s) selected to inpaint"
)
suffix = (" " + "; ".join(notes) + ".") if notes else ""
print(
f"[easy_ImageBatch] cut window: frames {cut_start_frame}..{cut_end} "
f"({total_frames} frames). {len(sorted_indices)} placed.{suffix}"
)
return (image_batch, alpha_batch, selected_image_batch, selected_frames_out, )
def _clean_placeholder_batch(
self, total_frames, h, w, c, placeholder_color, invert_alpha, device=None, dtype=None
):
"""Build a fully-empty batch (no placed frames) of the placeholder color.
Used when nothing drives placement: a totally empty node (sized by the
width/height widgets, so ``device``/``dtype`` are absent → CPU/float).
``image_batch`` is uniform placeholder; ``alpha_batch`` is all-empty
(1.0, flipped by ``invert_alpha``); ``selected_*`` outputs are
zero-length. Returns the standard 4-tuple.
"""
fill_value = _PLACEHOLDER_FILL[placeholder_color]
if device is None:
image_batch = torch.full((total_frames, h, w, c), fill_value)
alpha_batch = torch.ones((total_frames, h, w), dtype=torch.float32)
else:
image_batch = torch.full(
(total_frames, h, w, c), fill_value, device=device, dtype=dtype
)
alpha_batch = torch.ones(
(total_frames, h, w), device=device, dtype=torch.float32
)
if invert_alpha:
alpha_batch = 1.0 - alpha_batch
print(
f"[easy_ImageBatch] clean batch: {total_frames} frames of "
f"{placeholder_color} placeholder; nothing placed."
)
return (image_batch, alpha_batch, image_batch[:0], "")
def _apply_slot_overwrites(
self,
image_batch,
alpha_batch,
placed_indices,
slots,
total_frames,
cut_start_frame,
outside_cut,
ref_hwc,
):
"""Composite connected manual slots on top of the batch (Layer 3).
``slots`` is ``((image, frame), …)`` for image1..image4 in ascending
order. Only connected slots with a frame value participate; each must
match ``ref_hwc`` (H/W/C). Higher slot wins on a collision (see
``plan_slot_overwrites``). Mutates ``image_batch`` / ``alpha_batch`` /
``placed_indices`` and extends ``outside_cut`` in place.
"""
h, w, c = ref_hwc
connected: list[tuple[int, int]] = []
for slot_index, (img, frame) in enumerate(slots):
if img is None or frame is None:
continue
if tuple(img.shape[1:]) != (h, w, c):
raise ValueError(
f"image{slot_index + 1} shape {tuple(img.shape[1:])} does not match "
f"reference ({h}, {w}, {c}). All inputs must share the same H/W/C."
)
connected.append((slot_index, frame))
placements, slot_outside = plan_slot_overwrites(
connected, total_frames, cut_start_frame
)
for slot_index, output_index, _vfx in placements:
image_batch[output_index] = slots[slot_index][0][0]
alpha_batch[output_index] = 0.0
placed_indices.add(output_index)
outside_cut.extend(slot_outside)
def create_batch_from_frames(
self,
keyframe_batch,
keyframes_insert,
source_frames,
total_frames,
cut_start_frame,
placeholder_color,
invert_alpha,
ignored_inputs_connected=False,
base_batch=None,
slots=((None, None), (None, None), (None, None), (None, None)),
):
"""Offset mode — active when ``keyframe_batch`` is connected.
"""Insert mode — active when ``keyframes_insert`` is connected.
Scatter the packed ``keyframe_batch`` sequence back onto the timeline
at the positions listed in ``source_frames`` (i-th frame → i-th
position, ascending), filling the rest with ``placeholder_color``. This
is the inverse of the node's select behaviour: feed back a processed
copy of a previous ``selected_image_batch`` together with the same
``selected_frames`` list to rebuild a full-length sequence in the
original spacing. ``source_batch`` and the 4 manual slots are ignored
in this mode (the wire-driven switch keeps the behaviour unambiguous).
Scatter the packed ``keyframes_insert`` sequence onto the timeline at
the positions listed in ``source_frames`` (i-th frame → i-th position,
ascending). The background depends on ``base_batch`` (the connected
``source_batch``, or ``None``):
Outputs match the select path: ``image_batch`` (cut window),
``alpha_batch`` (placed = 0.0 / empty = 1.0, flipped by
``invert_alpha``), ``selected_image_batch`` (the placed frames packed
ascending), and ``selected_frames`` (their VFX numbers, round-trippable).
- **No ``base_batch`` (offset / reconstruct mode).** Gaps are filled
with ``placeholder_color``. Feed back a processed copy of a previous
``selected_image_batch`` plus the same ``selected_frames`` list to
rebuild a full-length sequence in the original spacing.
- **With ``base_batch`` (insert-over-source mode).** The cut window of
``base_batch`` is laid down first (VFX frame ``cut_start_frame + i``
→ ``base_batch[cut_start_frame + i - 1]``; frames beyond the source
fall back to the placeholder), then the inserts overwrite at the
listed positions. The inserts are composited *over* the source video.
Connected manual slots (``image1``-``image4``) are then composited on
top as the highest layer — a slot overwrites an insert at the same
position, and the higher-numbered slot wins a slot-vs-slot collision.
``alpha_batch`` marks the inserted positions and any slot overwrites as
placed (0.0), regardless of the background — so ``selected_image_batch``
/ ``selected_frames`` describe just those. With an empty frame list and
no slots nothing is placed: a clean placeholder batch (no ``base_batch``)
or a clean ``base_batch`` cut-window passthrough.
"""
if ignored_inputs_connected:
print(
"[easy_ImageBatch] offset mode (keyframe_batch connected): "
"source_batch and image1-4 are ignored; the frame list controls "
"placement."
)
h, w, c = keyframe_batch.shape[1:]
device = keyframe_batch.device
dtype = keyframe_batch.dtype
h, w, c = keyframes_insert.shape[1:]
device = keyframes_insert.device
dtype = keyframes_insert.dtype
# Background: source cut window (insert-over-source) or placeholder.
fill_value = _PLACEHOLDER_FILL[placeholder_color]
image_batch = torch.full((total_frames, h, w, c), fill_value, device=device, dtype=dtype)
# Same convention as the select path: 1.0 (white/empty), 0.0 on placed.
base_short_fallback = 0
if base_batch is not None:
if tuple(base_batch.shape[1:]) != (h, w, c):
raise ValueError(
f"source_batch shape {tuple(base_batch.shape[1:])} does not match "
f"keyframes_insert ({h}, {w}, {c}). Both must share the same H/W/C."
)
base_placements, base_short_fallback = plan_source_base_fill(
total_frames, cut_start_frame, base_batch.shape[0]
)
for output_index, source_index in base_placements:
image_batch[output_index] = base_batch[source_index]
# Convention: 1.0 (white/empty), 0.0 on inserted positions only.
alpha_batch = torch.ones((total_frames, h, w), device=device, dtype=torch.float32)
# Parse the position list (mirrors the select-mode tokenizer: commas,
# spaces, tabs, newlines, or any mix).
positions: list[int] = []
# Parse the position list (commas, spaces, tabs, newlines, or any mix;
# inclusive ranges like "5-7" expand via parse_frame_tokens).
source_frames_str = (source_frames or "").strip()
for tok in re.split(r"[,\s]+", source_frames_str):
if not tok:
continue
try:
positions.append(int(tok))
except ValueError:
print(f"[easy_ImageBatch] source_frames: ignoring non-integer token '{tok}'.")
positions, bad_tokens = parse_frame_tokens(source_frames_str)
for tok in bad_tokens:
print(f"[easy_ImageBatch] source_frames: ignoring invalid token '{tok}'.")
source_count = keyframe_batch.shape[0]
source_count = keyframes_insert.shape[0]
plan = plan_offset_placements(positions, source_count, total_frames, cut_start_frame)
placed_indices: list[int] = []
placed_indices: set[int] = set()
for source_index, output_index, _vfx in plan.placements:
image_batch[output_index] = keyframe_batch[source_index]
image_batch[output_index] = keyframes_insert[source_index]
alpha_batch[output_index] = 0.0
placed_indices.append(output_index)
placed_indices.add(output_index)
# Manual slots (Layer 3): composite connected image1-4 on top of the
# inserts and the source background. A slot beats an insert at the same
# position; higher slot wins a slot-vs-slot collision.
slot_outside: list[int] = []
self._apply_slot_overwrites(
image_batch, alpha_batch, placed_indices, slots,
total_frames, cut_start_frame, slot_outside, (h, w, c),
)
if invert_alpha:
alpha_batch = 1.0 - alpha_batch
@@ -586,9 +847,10 @@ class easy_ImageBatch:
str(idx + cut_start_frame) for idx in sorted_indices
)
# One-line summary (mirrors select mode) with offset-specific notes so
# silent surprises (extra frames, short lists, outside-cut, empty list)
# are visible without scrolling per-frame logs.
# One-line summary (mirrors select mode) with insert-specific notes so
# silent surprises (extra frames, short lists, outside-cut, empty list,
# source shorter than the cut) are visible without per-frame logs.
mode_label = "insert-over-source mode" if base_batch is not None else "offset mode"
cut_end = cut_start_frame + total_frames - 1
notes: list[str] = []
if plan.outside_cut:
@@ -598,18 +860,27 @@ class easy_ImageBatch:
)
if plan.unused_source:
notes.append(
f"{plan.unused_source} extra source frame(s) had no listed position"
f"{plan.unused_source} extra insert frame(s) had no listed position"
)
if plan.missing_positions:
notes.append(
f"{len(plan.missing_positions)} position(s) had no source frame: "
f"{len(plan.missing_positions)} position(s) had no insert frame: "
+ ", ".join(str(f) for f in plan.missing_positions)
)
if base_short_fallback:
notes.append(
f"{base_short_fallback} cut frame(s) beyond source_batch filled with placeholder"
)
if slot_outside:
notes.append(
f"{len(set(slot_outside))} slot frame(s) outside cut: "
+ ", ".join(str(f) for f in sorted(set(slot_outside)))
)
if not source_frames_str:
notes.append("frame list is empty — nothing placed")
notes.append("frame list is empty — nothing inserted")
suffix = (" " + "; ".join(notes) + ".") if notes else ""
print(
f"[easy_ImageBatch] offset mode: cut window frames {cut_start_frame}.."
f"[easy_ImageBatch] {mode_label}: cut window frames {cut_start_frame}.."
f"{cut_end} ({total_frames} frames). {len(sorted_indices)} placed.{suffix}"
)
+4 -2
View File
@@ -71,5 +71,7 @@ addopts = ["--import-mode=importlib"]
# ComfyUI custom node is not installed as a package in the workflow.
# Locally use ``.venv/bin/pip install -e '.[test]'`` from the repo root.
# ``aiohttp`` is a runtime dep of ``koolook_routes`` and must be importable
# even when only a pure helper is under test.
test = ["pytest>=8", "aiohttp>=3.9", "pip-audit>=2.7", "Pillow>=10"]
# even when only a pure helper is under test. The lower bound is pinned to
# the CVE-2026-54273..54280 fix line (>=3.14.1) so the declared range can't
# resolve a vulnerable aiohttp; the lock (constraints-test.txt) matches.
test = ["pytest>=8", "aiohttp>=3.14.1", "pip-audit>=2.7", "Pillow>=10"]
+479 -9
View File
@@ -1,9 +1,10 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Tests for ``easy_ImageBatch`` offset mode.
"""Tests for ``easy_ImageBatch`` insert modes and schema.
The node imports ``torch`` at module load, but the offset *planner*
(``plan_offset_placements``) is pure Python and never touches torch — it
just computes which incoming frame lands at which output index. CI installs
The node imports ``torch`` at module load, but its *planners*
(``plan_offset_placements`` for insert placement, ``plan_source_base_fill``
for the insert-over-source background) are pure Python and never touch torch
— they just compute which frame lands at which output index. CI installs
only ``pytest`` / ``aiohttp`` / ``Pillow`` (no torch), so we stub the module
to import the node torch-free and test the placement logic directly.
"""
@@ -12,13 +13,84 @@ from __future__ import annotations
import sys
import types
import pytest
# Must run before importing the node module (its top-level ``import torch``).
# ``setdefault`` keeps a real torch if one happens to be installed.
sys.modules.setdefault("torch", types.ModuleType("torch"))
# Enrich the stub with the handful of constructors ``create_batch`` uses so the
# *behaviour* (layer compositing, tie-break, passthrough) can be exercised
# torch-free — not just the pure planners. ``_FakeTensor`` wraps a flat list of
# frame-identity tags, so a test can assert *which* frame landed at each output
# index. Skipped automatically if a real torch is installed.
_torch = sys.modules["torch"]
_FAKE_TORCH = not hasattr(_torch, "__version__")
if _FAKE_TORCH:
class _FakeTensor:
"""Minimal stand-in: a list of opaque per-frame tags + an H/W/C shape."""
def __init__(self, data, hwc=(2, 2, 3)):
self._data = list(data)
self._hwc = tuple(hwc)
self.device = "cpu"
self.dtype = "float32"
@property
def shape(self):
return (len(self._data),) + self._hwc
def __len__(self):
return len(self._data)
def __getitem__(self, key):
if isinstance(key, int):
return self._data[key]
if isinstance(key, slice):
return _FakeTensor(self._data[key], self._hwc)
if isinstance(key, (list, tuple)):
return _FakeTensor([self._data[i] for i in key], self._hwc)
raise TypeError(key)
def __setitem__(self, key, value):
if isinstance(key, int):
self._data[key] = value
else:
raise TypeError(key)
def __rsub__(self, other): # 1.0 - alpha; identity is fine for these tests
return self
def _fake_full(shape, fill_value, device=None, dtype=None):
return _FakeTensor([("placeholder", fill_value)] * shape[0], shape[1:])
def _fake_ones(shape, device=None, dtype=None):
return _FakeTensor([1.0] * shape[0], shape[1:])
_torch.float32 = "float32"
_torch.full = _fake_full
_torch.ones = _fake_ones
else: # pragma: no cover - real torch present, skip the integration tests
_FakeTensor = None
_GRAY = ("placeholder", 0.5) # what _fake_full tags an unfilled frame with
def _source(n):
"""A fake source_batch whose frame k (0-based) is the tag ``f"src{k+1}"``."""
return _FakeTensor([f"src{k + 1}" for k in range(n)])
requires_fake_torch = pytest.mark.skipif(
not _FAKE_TORCH, reason="behaviour tests rely on the fake-tensor stub"
)
from k_easy_image_batch import ( # noqa: E402 (import after stub)
easy_ImageBatch,
parse_frame_tokens,
plan_offset_placements,
plan_slot_overwrites,
plan_source_base_fill,
)
@@ -89,14 +161,115 @@ def test_cut_start_offsets_output_indices():
assert plan.placements == [(0, 0, 41), (1, 22, 63)]
def test_input_types_exposes_keyframe_batch_with_tooltip():
def test_source_base_fill_full_window_maps_one_to_one():
# cut_start=1: output i -> source i (source_batch[0] is VFX frame 1).
placements, fallback = plan_source_base_fill(total_frames=5, cut_start_frame=1, base_len=10)
assert placements == [(0, 0), (1, 1), (2, 2), (3, 3), (4, 4)]
assert fallback == 0
def test_source_base_fill_offsets_with_cut_start():
# cut_start=41: output 0 -> source 40 (VFX frame 41), etc.
placements, fallback = plan_source_base_fill(total_frames=3, cut_start_frame=41, base_len=121)
assert placements == [(0, 40), (1, 41), (2, 42)]
assert fallback == 0
def test_source_base_fill_beyond_source_falls_back_to_placeholder():
# base only covers 2 frames; the remaining 3 cut frames stay placeholder.
placements, fallback = plan_source_base_fill(total_frames=5, cut_start_frame=1, base_len=2)
assert placements == [(0, 0), (1, 1)]
assert fallback == 3
def test_source_base_fill_cut_starts_past_source_end():
# Cut window starts beyond the source entirely → all placeholder.
placements, fallback = plan_source_base_fill(total_frames=4, cut_start_frame=50, base_len=10)
assert placements == []
assert fallback == 4
def test_slot_overwrites_basic_placement():
# image1 @ VFX frame 4, cut_start 1 -> output index 3.
placements, outside = plan_slot_overwrites([(0, 4)], total_frames=24, cut_start_frame=1)
assert placements == [(0, 3, 4)]
assert outside == []
def test_slot_overwrites_higher_slot_wins_collision():
# image1 (idx 0) and image3 (idx 2) both target VFX frame 10 -> the
# higher-numbered slot wins, one winner per output index.
placements, outside = plan_slot_overwrites(
[(0, 10), (2, 10)], total_frames=24, cut_start_frame=1
)
assert placements == [(2, 9, 10)]
assert outside == []
def test_slot_overwrites_higher_slot_wins_regardless_of_input_order():
# Same collision, supplied highest-first: result must still pick image3.
placements, _ = plan_slot_overwrites(
[(2, 10), (0, 10)], total_frames=24, cut_start_frame=1
)
assert placements == [(2, 9, 10)]
def test_slot_overwrites_outside_cut_reported():
# cut_start 10: frame 2 -> output -8 (outside); frame 15 -> 5 (in).
placements, outside = plan_slot_overwrites(
[(0, 2), (1, 15)], total_frames=20, cut_start_frame=10
)
assert placements == [(1, 5, 15)]
assert outside == [2]
def test_slot_overwrites_cut_start_offsets_output_indices():
placements, _ = plan_slot_overwrites(
[(0, 41), (1, 63)], total_frames=81, cut_start_frame=41
)
assert placements == [(0, 0, 41), (1, 22, 63)]
def test_slot_overwrites_sorted_by_output_index():
# Distinct frames, ascending by output index regardless of slot order.
placements, _ = plan_slot_overwrites(
[(0, 20), (1, 5)], total_frames=24, cut_start_frame=1
)
assert [out for _s, out, _v in placements] == [4, 19]
def test_slot_overwrites_empty_input_places_nothing():
placements, outside = plan_slot_overwrites([], total_frames=10, cut_start_frame=1)
assert placements == []
assert outside == []
def test_input_types_exposes_keyframes_insert_with_tooltip():
spec = easy_ImageBatch.INPUT_TYPES()
assert "keyframe_batch" in spec["optional"], "new offset-mode input must be declared"
entry = spec["optional"]["keyframe_batch"]
assert "keyframes_insert" in spec["optional"], "insert-mode input must be declared"
# `keyframe_batch` is retained as a deprecated alias so pre-rename workflows
# keep loading (unreleased, but origin/main exposed it).
assert "keyframe_batch" in spec["optional"]
assert "deprecated" in spec["optional"]["keyframe_batch"][1]["tooltip"].lower()
entry = spec["optional"]["keyframes_insert"]
assert entry[0] == "IMAGE"
tooltip = entry[1]["tooltip"].lower()
assert "offset" in tooltip
assert "frame list" in tooltip
assert "insert" in tooltip
assert "source_frames" in tooltip
def test_input_types_exposes_width_height_fallback_at_end():
spec = easy_ImageBatch.INPUT_TYPES()
# Fallback-only and declared OPTIONAL so they append to the END of the
# widget list — keeping positional widgets_values of older saved workflows
# aligned (inserting them earlier shifts placeholder_color/source_frames).
for key in ("width", "height"):
assert key in spec["optional"], f"{key} fallback widget must be optional"
assert key not in spec["required"]
assert spec["optional"][key][0] == "INT"
assert spec["optional"][key][1]["default"] == 512
# They must be the last two optional keys (= last two widgets).
assert list(spec["optional"])[-2:] == ["width", "height"]
def test_registration_exports_unchanged():
@@ -104,3 +277,300 @@ def test_registration_exports_unchanged():
assert NODE_CLASS_MAPPINGS["easy_ImageBatch"] is easy_ImageBatch
assert NODE_DISPLAY_NAME_MAPPINGS["easy_ImageBatch"] == "Easy Image Batch (Koolook)"
# --- Behaviour (layer compositing) tests, run against the fake-tensor stub ---
@requires_fake_torch
def test_select_slot_overwrites_list_pick_at_same_frame():
# source pick at frame 5 AND image1 wired at frame 5 -> the slot wins.
node = easy_ImageBatch()
image_batch, _alpha, selected, frames = node.create_batch(
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
invert_alpha=False, source_frames="5",
image1_frame=5, source_batch=_source(24), image1=_FakeTensor(["slotA"]),
)
assert image_batch[4] == "slotA" # output index 4 == VFX frame 5
assert frames == "5"
assert len(selected) == 1
@requires_fake_torch
def test_select_higher_slot_wins_at_same_frame():
# image1 and image2 both target frame 10 -> the higher-numbered slot wins.
node = easy_ImageBatch()
image_batch, _alpha, _sel, _frames = node.create_batch(
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
invert_alpha=False, source_frames="",
image1_frame=10, image1=_FakeTensor(["slot1"]),
image2=_FakeTensor(["slot2"]), image2_frame=10,
)
assert image_batch[9] == "slot2"
@requires_fake_torch
def test_insert_slot_composites_on_top_of_inserts():
# Inserts ins1/ins2/ins3 land at frames 5/7/20; image1 @ frame 5 wins there.
node = easy_ImageBatch()
image_batch, _alpha, _sel, _frames = node.create_batch(
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
invert_alpha=False, source_frames="5 7 20",
image1_frame=5, keyframes_insert=_FakeTensor(["ins1", "ins2", "ins3"]),
image1=_FakeTensor(["slotA"]),
)
assert image_batch[4] == "slotA" # slot beats the insert at frame 5
assert image_batch[6] == "ins2" # other inserts untouched
assert image_batch[19] == "ins3"
@requires_fake_torch
def test_select_empty_list_passes_source_through_with_no_phantom_picks():
# EXE 2: source connected, empty list, no slots wired (defaults 5/9/13/17).
# Whole batch is the source passthrough; nothing is "placed".
node = easy_ImageBatch()
image_batch, _alpha, selected, frames = node.create_batch(
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
invert_alpha=False, source_frames="",
image1_frame=5, source_batch=_source(24),
image2_frame=9, image3_frame=13, image4_frame=17,
)
assert [image_batch[i] for i in range(24)] == [f"src{k + 1}" for k in range(24)]
assert frames == "" # background is never counted as placed
assert len(selected) == 0
@requires_fake_torch
def test_select_nonempty_list_ignores_unwired_slot_defaults():
# EXE 3: only the listed picks are placed; the unwired slots' default
# imageN_frame values (9/13/17) do NOT pull phantom frames from source.
node = easy_ImageBatch()
image_batch, _alpha, _sel, frames = node.create_batch(
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
invert_alpha=False, source_frames="5 8 20",
image1_frame=5, source_batch=_source(24),
image2_frame=9, image3_frame=13, image4_frame=17,
)
assert frames == "5, 8, 20"
assert image_batch[4] == "src5"
assert image_batch[7] == "src8"
assert image_batch[19] == "src20"
assert image_batch[8] == _GRAY # frame 9 stays placeholder, no phantom pick
@requires_fake_torch
def test_selected_image_batch_is_picks_plus_slots_slots_win_overlap():
# selected_image_batch = union of multiline picks AND slot overwrites,
# ascending by frame, with the slot winning where they overlap.
node = easy_ImageBatch()
_img, _alpha, selected, frames = node.create_batch(
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
invert_alpha=False, source_frames="5 8",
image1_frame=5, source_batch=_source(24),
image1=_FakeTensor(["slotA"]), # overlaps the pick at frame 5
image2=_FakeTensor(["slotB"]), image2_frame=12, # slot-only position
)
assert frames == "5, 8, 12"
assert [selected[i] for i in range(len(selected))] == ["slotA", "src8", "slotB"]
@requires_fake_torch
def test_selected_image_batch_includes_inserts_and_slots_in_insert_mode():
# Same rule under insert mode: selected = inserts + slot overwrites.
node = easy_ImageBatch()
_img, _alpha, selected, frames = node.create_batch(
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
invert_alpha=False, source_frames="5 7 20",
image1_frame=4, keyframes_insert=_FakeTensor(["ins1", "ins2", "ins3"]),
image1=_FakeTensor(["slotA"]), # slot-only position at frame 4
)
assert frames == "4, 5, 7, 20"
assert [selected[i] for i in range(len(selected))] == ["slotA", "ins1", "ins2", "ins3"]
# --- Feature: range syntax in the frame list (parse_frame_tokens) ---
def test_parse_frame_tokens_expands_inclusive_ranges():
values, bad = parse_frame_tokens("1-5, 7, 9, 14-17")
assert values == [1, 2, 3, 4, 5, 7, 9, 14, 15, 16, 17]
assert bad == []
def test_parse_frame_tokens_mixed_separators():
values, bad = parse_frame_tokens("1-3 7\n10")
assert values == [1, 2, 3, 7, 10]
assert bad == []
def test_parse_frame_tokens_single_value_range():
values, bad = parse_frame_tokens("7-7")
assert values == [7]
assert bad == []
def test_parse_frame_tokens_descending_range_is_bad():
values, bad = parse_frame_tokens("5-1, 8")
assert values == [8]
assert bad == ["5-1"]
def test_parse_frame_tokens_non_integer_tokens_are_bad():
values, bad = parse_frame_tokens("3, foo, 5-x, 9")
assert values == [3, 9]
assert bad == ["foo", "5-x"]
@requires_fake_torch
def test_select_accepts_range_syntax_in_list():
node = easy_ImageBatch()
_img, _alpha, _sel, frames = node.create_batch(
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
invert_alpha=False, source_frames="1-3, 5",
image1_frame=4, source_batch=_source(24),
)
assert frames == "1, 2, 3, 5"
@requires_fake_torch
def test_insert_accepts_range_syntax_in_positions():
node = easy_ImageBatch()
_img, _alpha, _sel, frames = node.create_batch(
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
invert_alpha=False, source_frames="5-7",
image1_frame=4, keyframes_insert=_FakeTensor(["a", "b", "c"]),
)
assert frames == "5, 6, 7"
# --- Feature: passthrough shorter than output -> keep + gap-as-selection ---
@requires_fake_torch
def test_passthrough_shorter_source_keeps_covered_and_selects_gap():
# source = 17 frames, output = 24: frames 1-17 are kept (alpha 0/black),
# frames 18-24 are the gap (alpha 1/white) and become the selection.
node = easy_ImageBatch()
image_batch, alpha, selected, frames = node.create_batch(
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
invert_alpha=False, source_frames="",
image1_frame=4, source_batch=_source(17),
)
# image: 1-17 source, 18-24 placeholder
assert image_batch[16] == "src17"
assert image_batch[17] == _GRAY
# alpha (inpaint): covered black (0.0), gap white (1.0)
assert alpha[0] == 0.0 and alpha[16] == 0.0
assert alpha[17] == 1.0 and alpha[23] == 1.0
# selection = the gap frames 18..24 (to inpaint)
assert frames == "18, 19, 20, 21, 22, 23, 24"
assert len(selected) == 7
assert selected[0] == _GRAY
@requires_fake_torch
def test_passthrough_full_coverage_selects_nothing():
# source >= output: no gap -> alpha all black (kept), selection empty.
node = easy_ImageBatch()
image_batch, alpha, selected, frames = node.create_batch(
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
invert_alpha=False, source_frames="",
image1_frame=4, source_batch=_source(24),
)
assert [image_batch[i] for i in range(24)] == [f"src{k + 1}" for k in range(24)]
assert alpha[0] == 0.0 and alpha[23] == 0.0
assert frames == ""
assert len(selected) == 0
@requires_fake_torch
def test_passthrough_slot_in_gap_counts_as_kept_not_gap():
# A slot filling a gap frame becomes kept content, excluded from the
# inpaint selection.
node = easy_ImageBatch()
_img, alpha, _sel, frames = node.create_batch(
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
invert_alpha=False, source_frames="",
image1_frame=20, source_batch=_source(17), image1=_FakeTensor(["slotA"]),
)
assert alpha[19] == 0.0 # frame 20 filled by slot -> kept
assert frames == "18, 19, 21, 22, 23, 24" # gap excludes frame 20
@requires_fake_torch
def test_passthrough_gap_with_cut_start_offset():
# cut_start=41, total=10 -> output is VFX 41..50. source has 45 frames, so
# VFX 41..45 are covered (output 0..4) and 46..50 are the gap.
node = easy_ImageBatch()
image_batch, alpha, _sel, frames = node.create_batch(
total_frames=10, cut_start_frame=41, placeholder_color="Gray",
invert_alpha=False, source_frames="",
image1_frame=4, source_batch=_source(45),
)
assert image_batch[0] == "src41" and image_batch[4] == "src45"
assert image_batch[5] == _GRAY # VFX 46 = gap
assert alpha[0] == 0.0 and alpha[4] == 0.0 # covered kept
assert alpha[5] == 1.0 and alpha[9] == 1.0 # gap to inpaint
assert frames == "46, 47, 48, 49, 50" # gap selection
# --- Review-round fixes: deprecated alias + input guards ---
def test_parse_frame_tokens_rejects_overlong_range():
values, bad = parse_frame_tokens("1-99999999, 7")
assert values == [7]
assert bad == ["1-99999999"]
def test_parse_frame_tokens_allows_large_bounded_range():
values, bad = parse_frame_tokens("1-100")
assert len(values) == 100 and bad == []
def test_parse_frame_tokens_range_span_boundary():
# exactly _MAX_RANGE_SPAN (8192) frames is allowed; one more is rejected.
ok, bad = parse_frame_tokens("1-8192")
assert len(ok) == 8192 and bad == []
over, bad_over = parse_frame_tokens("1-8193")
assert over == [] and bad_over == ["1-8193"]
@requires_fake_torch
def test_keyframe_batch_alias_routes_to_insert_mode():
# A pre-rename workflow wires `keyframe_batch` (not `keyframes_insert`); it
# must still behave like an insert.
node = easy_ImageBatch()
_img, _alpha, _sel, frames = node.create_batch(
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
invert_alpha=False, source_frames="5 7 20",
image1_frame=4, keyframe_batch=_FakeTensor(["a", "b", "c"]),
)
assert frames == "5, 7, 20"
@requires_fake_torch
def test_keyframes_insert_wins_over_deprecated_alias():
node = easy_ImageBatch()
_img, _alpha, selected, _frames = node.create_batch(
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
invert_alpha=False, source_frames="5",
image1_frame=4,
keyframes_insert=_FakeTensor(["new"]), keyframe_batch=_FakeTensor(["old"]),
)
assert selected[0] == "new"
@requires_fake_torch
def test_nonempty_list_without_image_source_warns_and_returns_clean(capsys):
# source_frames set but no source_batch / imageN -> clean placeholder batch,
# with a warning that the list was ignored (no silent drop).
node = easy_ImageBatch()
image_batch, _alpha, selected, frames = node.create_batch(
total_frames=5, cut_start_frame=1, placeholder_color="Gray",
invert_alpha=False, source_frames="2 3",
image1_frame=4, width=8, height=8,
)
assert len(image_batch) == 5
assert frames == "" and len(selected) == 0
assert "source_frames is set but no image source" in capsys.readouterr().out