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]
### 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
workflow no longer closes the dialog silently — a confirmation card now
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
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
### Required widgets
@@ -40,12 +49,14 @@ without needing an external `ImageFromBatch` slicer.
| 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`. |
| `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`.
the same `H × W × C`. (In **offset mode** — `keyframe_batch` connected —
that input alone is sufficient and the others are ignored.)
## Outputs
@@ -118,6 +129,25 @@ When more than one mode contributes:
3. The set of placed keyframes is deduped automatically (`selected_*`
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
- **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)`
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
- **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 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:
"""
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
(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
Wan 2.2, where placeholder frames indicate no latent update.
@@ -170,6 +236,20 @@ class easy_ImageBatch:
}),
},
"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", ),
"image1": ("IMAGE", ),
"image2": ("IMAGE", ),
@@ -224,7 +304,25 @@ class easy_ImageBatch:
image3_frame=None,
image4=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,
# not pre-batched). source_batch is the only accepted multi-frame input.
for slot_name, slot_img in (
@@ -413,6 +511,110 @@ class easy_ImageBatch:
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_CLASS_MAPPINGS = {
"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)"