feat(nodes): Easy Image Batch offset mode via keyframe_batch input (#242)

Add the inverse of the node's select behaviour. When the new optional
keyframe_batch IMAGE input is connected, the node scatters a packed
sequence back onto the source_frames positions (i-th frame -> i-th listed
number, ascending), placeholder fill in between, at total_frames length.
This closes the select -> process -> reconstruct loop: feed a processed
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-4 are ignored while
keyframe_batch is connected, with a console note); count 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, where torch is unavailable. Fully backward-compatible:
unconnected keyframe_batch preserves the existing select path exactly.

Tests: 10 new torch-free tests; full suite 630 passed, 1 skipped; preflight_release green.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
malkuthro
2026-06-16 10:45:10 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent ae2480eee9
commit df34ddcf1a
4 changed files with 377 additions and 1 deletions
+15
View File
@@ -7,6 +7,21 @@ The format is inspired by Keep a Changelog and SemVer.
## [Unreleased] ## [Unreleased]
### Added ### Added
- **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 - **Publish setup success feedback (#227).** Publishing a saved sidebar
workflow no longer closes the dialog silently — a confirmation card now workflow no longer closes the dialog silently — a confirmation card now
shows where the setup was saved (the registry `setups.json` path) and shows where the setup was saved (the registry `setups.json` path) and
@@ -23,6 +23,15 @@ 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 here". The cut window lets you grab any sub-range of a longer sequence
without needing an external `ImageFromBatch` slicer. 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
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).
## Inputs ## Inputs
### Required widgets ### Required widgets
@@ -40,12 +49,14 @@ without needing an external `ImageFromBatch` slicer.
| Input | Type | Description | | 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`. | | `source_batch` | `IMAGE` | Pre-batched image stack, e.g. straight from `Load Video`. `source_batch[0]` is treated as VFX frame 1; `source_batch[k]` is VFX frame `k + 1`. |
| `image1` … `image4` | `IMAGE` | Per-slot keyframes. Each must be a single-frame `IMAGE` (batch size 1). Pre-batched stacks must come through `source_batch` instead. | | `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 At least one of `image1` or `source_batch` must be connected; otherwise
the node raises a clear `ValueError`. All connected inputs must share the node raises a clear `ValueError`. All connected inputs must share
the same `H × W × C`. the same `H × W × C`. (In **offset mode** — `keyframe_batch` connected —
that input alone is sufficient and the others are ignored.)
## Outputs ## Outputs
@@ -118,6 +129,25 @@ When more than one mode contributes:
3. The set of placed keyframes is deduped automatically (`selected_*` 3. The set of placed keyframes is deduped automatically (`selected_*`
outputs reflect the final, deduped state). outputs reflect the final, deduped state).
### Mode 4 — Offset / reconstruct (`keyframe_batch`)
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
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.
- **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).
- **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.
## Conventions ## Conventions
- **1-based VFX numbering.** `imageN_frame`, `source_frames`, - **1-based VFX numbering.** `imageN_frame`, `source_frames`,
@@ -171,6 +201,29 @@ This replaces the workflow pattern of feeding the Easy Image Batch
output through an external `ImageFromBatch (batch_index=40, length=81)` output through an external `ImageFromBatch (batch_index=40, length=81)`
node — the cut is built in. node — the cut is built in.
## Example C — Offset mode (reconstruct a selection)
Step 1 produced 5 enhanced frames (a 5-frame sequence) that originally
came from VFX frames 1, 10, 19, 30, 40 of a 41-frame shot. Step 2 rebuilds
the original length:
| Field | Value |
|---|---|
| `keyframe_batch` | the 5 processed frames (e.g. `Load EXR` / image batch) |
| `source_frames` | `"1, 10, 19, 30, 40"` |
| `cut_start_frame` | `1` |
| `total_frames` | `41` |
| `placeholder_color` | `Black` |
| Output | What you get |
|---|---|
| `image_batch` | 41 frames; the 5 processed frames land at indices 0 / 9 / 18 / 29 / 39. Rest is black. |
| `alpha_batch` | 41-frame mask; those 5 indices are `0.0`, the other 36 are `1.0` — ready as an inpaint mask for the gaps. |
| `selected_image_batch` | the 5 frames, packed back-to-back. |
| `selected_frames` | `"1, 10, 19, 30, 40"` |
Console summary: `offset mode: cut window frames 1..41 (41 frames). 5 placed.`
## Tips ## Tips
- **Round-tripping selections.** Wire `selected_frames` of one node - **Round-tripping selections.** Wire `selected_frames` of one node
+202
View File
@@ -1,3 +1,6 @@
from __future__ import annotations
from dataclasses import dataclass
import re import re
import torch import torch
@@ -12,6 +15,62 @@ _PLACEHOLDER_FILL = {
} }
@dataclass(frozen=True)
class OffsetPlan:
"""Result of planning offset-mode placements (pure; no torch).
- ``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``.
- ``outside_cut`` — VFX frame numbers paired to a frame but whose
output index fell outside ``[0, total_frames)`` (dropped, like select
mode).
- ``unused_source`` — count of incoming frames with no position to fill
(more frames than listed positions).
- ``missing_positions`` — listed positions with no incoming frame left
(more positions than frames).
"""
placements: list[tuple[int, int, int]]
outside_cut: list[int]
unused_source: int
missing_positions: list[int]
def plan_offset_placements(
positions: list[int],
source_count: int,
total_frames: int,
cut_start_frame: int,
) -> OffsetPlan:
"""Map a packed sequence of ``source_count`` frames onto keyframe positions.
Offset mode (the inverse of selecting): the i-th incoming frame is assigned
to the i-th position, with positions taken ascending and de-duplicated so
the mapping round-trips cleanly with the ascending ``selected_frames``
output. A position is placed iff its output index ``vfx - cut_start_frame``
falls inside ``[0, total_frames)``; otherwise it is reported in
``outside_cut`` and dropped (matching select-mode cut behaviour).
Pure Python — no torch — so the placement logic is unit-testable in CI
without the heavyweight tensor dependency.
"""
ordered = sorted(dict.fromkeys(int(p) for p in positions))
paired = min(source_count, len(ordered))
placements: list[tuple[int, int, int]] = []
outside_cut: list[int] = []
for source_index in range(paired):
vfx = ordered[source_index]
output_index = vfx - cut_start_frame
if 0 <= output_index < total_frames:
placements.append((source_index, output_index, vfx))
else:
outside_cut.append(vfx)
unused_source = max(0, source_count - len(ordered))
missing_positions = ordered[source_count:] if source_count < len(ordered) else []
return OffsetPlan(placements, outside_cut, unused_source, missing_positions)
class easy_ImageBatch: class easy_ImageBatch:
""" """
Place up to N keyframes on a 1-based VFX timeline, then output a Place up to N keyframes on a 1-based VFX timeline, then output a
@@ -90,6 +149,13 @@ class easy_ImageBatch:
one of `image1` or `source_batch` must be provided. All keyframes one of `image1` or `source_batch` must be provided. All keyframes
(and `source_batch` frames) must share the same H/W/C. (and `source_batch` frames) must share the same H/W/C.
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`.
Useful for preparing sparse control sequences for video models like Useful for preparing sparse control sequences for video models like
Wan 2.2, where placeholder frames indicate no latent update. Wan 2.2, where placeholder frames indicate no latent update.
@@ -170,6 +236,20 @@ class easy_ImageBatch:
}), }),
}, },
"optional": { "optional": {
"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."
),
}),
"source_batch": ("IMAGE", ), "source_batch": ("IMAGE", ),
"image1": ("IMAGE", ), "image1": ("IMAGE", ),
"image2": ("IMAGE", ), "image2": ("IMAGE", ),
@@ -224,7 +304,25 @@ class easy_ImageBatch:
image3_frame=None, image3_frame=None,
image4=None, image4=None,
image4_frame=None, image4_frame=None,
keyframe_batch=None,
): ):
# 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,
)
# Validate per-slot image inputs (must be single-frame tensors, # Validate per-slot image inputs (must be single-frame tensors,
# not pre-batched). source_batch is the only accepted multi-frame input. # not pre-batched). source_batch is the only accepted multi-frame input.
for slot_name, slot_img in ( for slot_name, slot_img in (
@@ -413,6 +511,110 @@ class easy_ImageBatch:
return (image_batch, alpha_batch, selected_image_batch, selected_frames_out, ) return (image_batch, alpha_batch, selected_image_batch, selected_frames_out, )
def create_batch_from_frames(
self,
keyframe_batch,
source_frames,
total_frames,
cut_start_frame,
placeholder_color,
invert_alpha,
ignored_inputs_connected=False,
):
"""Offset mode — active when ``keyframe_batch`` 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).
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).
"""
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
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.
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] = []
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}'.")
source_count = keyframe_batch.shape[0]
plan = plan_offset_placements(positions, source_count, total_frames, cut_start_frame)
placed_indices: list[int] = []
for source_index, output_index, _vfx in plan.placements:
image_batch[output_index] = keyframe_batch[source_index]
alpha_batch[output_index] = 0.0
placed_indices.append(output_index)
if invert_alpha:
alpha_batch = 1.0 - alpha_batch
sorted_indices = sorted(placed_indices)
if sorted_indices:
selected_image_batch = image_batch[sorted_indices]
else:
selected_image_batch = image_batch[:0]
selected_frames_out = ", ".join(
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.
cut_end = cut_start_frame + total_frames - 1
notes: list[str] = []
if plan.outside_cut:
notes.append(
f"{len(plan.outside_cut)} outside cut: "
+ ", ".join(str(f) for f in plan.outside_cut)
)
if plan.unused_source:
notes.append(
f"{plan.unused_source} extra source frame(s) had no listed position"
)
if plan.missing_positions:
notes.append(
f"{len(plan.missing_positions)} position(s) had no source frame: "
+ ", ".join(str(f) for f in plan.missing_positions)
)
if not source_frames_str:
notes.append("frame list is empty — nothing placed")
suffix = (" " + "; ".join(notes) + ".") if notes else ""
print(
f"[easy_ImageBatch] offset mode: cut window frames {cut_start_frame}.."
f"{cut_end} ({total_frames} frames). {len(sorted_indices)} placed.{suffix}"
)
return (image_batch, alpha_batch, selected_image_batch, selected_frames_out, )
# Node mappings for ComfyUI registration # Node mappings for ComfyUI registration
NODE_CLASS_MAPPINGS = { NODE_CLASS_MAPPINGS = {
"easy_ImageBatch": easy_ImageBatch "easy_ImageBatch": easy_ImageBatch
+106
View File
@@ -0,0 +1,106 @@
# SPDX-License-Identifier: GPL-3.0-or-later
"""Tests for ``easy_ImageBatch`` offset mode.
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
only ``pytest`` / ``aiohttp`` / ``Pillow`` (no torch), so we stub the module
to import the node torch-free and test the placement logic directly.
"""
from __future__ import annotations
import sys
import types
# 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"))
from k_easy_image_batch import ( # noqa: E402 (import after stub)
easy_ImageBatch,
plan_offset_placements,
)
def _frames(plan):
"""VFX frame numbers placed, in order."""
return [vfx for _src, _out, vfx in plan.placements]
def test_basic_offset_maps_packed_frames_to_listed_positions():
# 5 processed frames, list 1,10,19,30,40, full 41-frame window (cut=1).
plan = plan_offset_placements([1, 10, 19, 30, 40], source_count=5, total_frames=41, cut_start_frame=1)
assert plan.placements == [
(0, 0, 1),
(1, 9, 10),
(2, 18, 19),
(3, 29, 30),
(4, 39, 40),
]
assert plan.outside_cut == []
assert plan.unused_source == 0
assert plan.missing_positions == []
def test_pairing_is_by_ascending_position_not_list_order():
# Out-of-order list still pairs source[0] -> lowest position, etc., so it
# round-trips with selected_frames (which is emitted ascending).
plan = plan_offset_placements([40, 1, 19], source_count=3, total_frames=41, cut_start_frame=1)
assert _frames(plan) == [1, 19, 40]
assert [src for src, _out, _vfx in plan.placements] == [0, 1, 2]
def test_more_source_frames_than_positions_reports_unused():
plan = plan_offset_placements([1, 10, 19], source_count=5, total_frames=41, cut_start_frame=1)
assert _frames(plan) == [1, 10, 19]
assert plan.unused_source == 2
assert plan.missing_positions == []
def test_fewer_source_frames_than_positions_reports_missing():
plan = plan_offset_placements([1, 10, 19, 30], source_count=2, total_frames=41, cut_start_frame=1)
assert _frames(plan) == [1, 10]
assert plan.unused_source == 0
assert plan.missing_positions == [19, 30]
def test_positions_outside_cut_window_are_dropped():
# output index = vfx - cut_start_frame; window [0, 20).
# frame 1 -> -9 (out), 15 -> 5 (in), 40 -> 30 (out).
plan = plan_offset_placements([1, 15, 40], source_count=3, total_frames=20, cut_start_frame=10)
assert _frames(plan) == [15]
assert plan.outside_cut == [1, 40]
def test_duplicate_positions_are_deduped():
plan = plan_offset_placements([10, 10, 19], source_count=3, total_frames=41, cut_start_frame=1)
assert _frames(plan) == [10, 19]
def test_empty_position_list_places_nothing():
plan = plan_offset_placements([], source_count=5, total_frames=41, cut_start_frame=1)
assert plan.placements == []
assert plan.unused_source == 5
def test_cut_start_offsets_output_indices():
# cut_start_frame=41: frame 41 -> idx 0, 63 -> idx 22.
plan = plan_offset_placements([41, 63], source_count=2, total_frames=81, cut_start_frame=41)
assert plan.placements == [(0, 0, 41), (1, 22, 63)]
def test_input_types_exposes_keyframe_batch_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 entry[0] == "IMAGE"
tooltip = entry[1]["tooltip"].lower()
assert "offset" in tooltip
assert "frame list" in tooltip
def test_registration_exports_unchanged():
from k_easy_image_batch import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
assert NODE_CLASS_MAPPINGS["easy_ImageBatch"] is easy_ImageBatch
assert NODE_DISPLAY_NAME_MAPPINGS["easy_ImageBatch"] == "Easy Image Batch (Koolook)"