Files
malkuthro-ComfyUI-Koolook/k_easy_image_batch.py
bf02db8ad8 feat(easy_image_batch): source_batch + source_frames list + selected_… (#134)
* feat(easy_image_batch): source_batch + source_frames list + selected_* outputs

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Updates the doc table and CHANGELOG bullet to match.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 19:37:49 +02:00

414 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import re
import torch
# Placeholder fill values (0..1) for unoccupied frames in the output IMAGE batch.
# Alpha output is independent of this choice; see `invert_alpha` for convention.
_PLACEHOLDER_FILL = {
"Black": 0.0,
"Gray": 0.5,
"White": 1.0,
}
class easy_ImageBatch:
"""
Place up to N keyframes on a 1-based VFX timeline, then output a
*cut window* of that timeline as a fixed-length IMAGE batch with
placeholder fill (Black / 50% Gray / White) for unoccupied frames.
Returns four outputs:
- `image_batch` — IMAGE batch of length `total_frames`,
representing the cut window starting at VFX frame
`cut_start_frame` and lasting `total_frames` frames. Selected
positions carry the picked images; everything else is the
placeholder.
- `alpha_batch` — MASK at the same length. Default
convention: selected = 0.0 (black), empty = 1.0 (white). Toggle
`invert_alpha` flips to compositing-style (selected = 1.0).
- `selected_image_batch` — IMAGE containing only the keyframes that
actually landed *inside the cut*, packed back-to-back in
ascending order (no placeholders). Empty if nothing was placed.
- `selected_frames` — STRING, comma-separated VFX frame
numbers of the placed-inside-cut keyframes (e.g.
`"41, 63, 78, 88, 98, 121"`). Same format as the `source_frames`
input → round-trippable into another `easy_ImageBatch`.
Frame-numbering conventions
---------------------------
All frame numbers (`source_frames`, `imageN_frame`, `cut_start_frame`)
are **VFX-numbered, 1-based**. Internally:
source pick index = vfx_frame - 1 # always
output (cut) index = vfx_frame - cut_start_frame # cut window
`source_batch[0]` is treated as VFX frame 1. There is no exposed
knob to renumber `source_batch` — if your stack represents a
different VFX range, renumber externally before connecting.
Cut window
----------
The output represents VFX frames `[cut_start_frame ..
cut_start_frame + total_frames - 1]`. Frames outside this window
are placed but silently *dropped from the cut* (a single summary
line is logged at the end listing them). Frames inside the window
are placed at output index `vfx - cut_start_frame`. The default
`cut_start_frame = 1` means the cut starts at VFX frame 1, so the
full timeline shows up — equivalent to "no cut".
Three input modes, freely mixable
---------------------------------
1. Per-slot images (up to 4): connect `imageN` and set `imageN_frame`
to its VFX timeline position. The image lands at output index
`imageN_frame - cut_start_frame` if that's inside the cut.
2. `source_batch` (optional pre-batched IMAGE, e.g. straight from
Load Video) + the 4 slots: when a slot's `imageN` is *not*
connected, the node pulls VFX frame `imageN_frame` from
`source_batch` (i.e. `source_batch[imageN_frame - 1]`) and
places it at output index `imageN_frame - cut_start_frame`.
Collapses the "Load Video → Get Images From Batch In Range × N"
helper chain into a single node.
3. `source_batch` + `source_frames` (optional comma/newline/whitespace
separated string like `"1, 27, 41, 63, 85, 120"`): for keyframe
counts beyond 4. Each number picks
`source_batch[N - 1]` and places it at output index
`N - cut_start_frame` (if inside the cut). Bad tokens warn and
are skipped; frames not present in `source_batch` warn; frames
outside the cut window are dropped silently and summarised at
the end.
Important: when `source_frames` is non-empty, the 4 manual slots
contribute ONLY where `imageN` is explicitly connected — the
`imageN_frame` defaults do NOT pick from `source_batch` in this
mode. The list fully controls the selection, with explicit
`imageN` connections used solely to override individual positions.
Priority order (later wins): `source_frames` list → 4 manual slots.
Explicit `imageN` inputs always override list entries at the same
cut-output position. Output dedup is automatic (set-based). At least
one of `image1` or `source_batch` must be provided. All keyframes
(and `source_batch` frames) must share the same H/W/C.
Useful for preparing sparse control sequences for video models like
Wan 2.2, where placeholder frames indicate no latent update.
Example A — cut_start_frame=1 (no cut, full timeline)
-----------------------------------------------------
Inputs:
source_batch = Load Video (121 frames)
source_frames = "1, 27, 41, 63, 78, 88, 98, 121"
cut_start_frame = 1
total_frames = 121
placeholder_color = "Gray"
Outputs:
image_batch — 121 frames; the 8 picked frames sit at
their natural positions (1→idx 0,
27→idx 26, …, 121→idx 120). Rest is gray.
selected_image_batch — 8 frames, packed back-to-back.
selected_frames — "1, 27, 41, 63, 78, 88, 98, 121"
Example B — cut_start_frame=41, total_frames=81 (cut)
-----------------------------------------------------
Same inputs as Example A, but cut_start_frame=41 / total_frames=81.
Outputs:
image_batch — 81 frames representing VFX 41..121.
Frames 41/63/78/88/98/121 land at output
indices 0/22/37/47/57/80. Frames 1, 27
are *outside the cut* and dropped.
selected_image_batch — 6 frames (the in-cut picks).
selected_frames — "41, 63, 78, 88, 98, 121"
Console summary: — "cut window: frames 41..121 (81 frames).
6 placed; 2 outside cut: 1, 27."
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"total_frames": ("INT", {
"default": 81,
"min": 1,
"max": 1024,
"step": 1,
"display": "number"
}),
"cut_start_frame": ("INT", {
"default": 1,
"min": 1,
"max": 999999,
"step": 1,
"display": "number"
}),
"placeholder_color": (list(_PLACEHOLDER_FILL.keys()), {
"default": "Black"
}),
"invert_alpha": ("BOOLEAN", {
"default": False,
"label_on": "compositing",
"label_off": "inpaint",
}),
"source_frames": ("STRING", {
"default": "",
"multiline": True,
"placeholder": "extra frames from source_batch, e.g. 1, 27, 41, 63, 85 (commas and/or newlines)",
}),
"image1_frame": ("INT", {
"default": 5,
"min": 1,
"max": 999999,
"step": 1,
"display": "number"
}),
},
"optional": {
"source_batch": ("IMAGE", ),
"image1": ("IMAGE", ),
"image2": ("IMAGE", ),
"image2_frame": ("INT", {
"default": 9,
"min": 1,
"max": 999999,
"step": 1,
"display": "number"
}),
"image3": ("IMAGE", ),
"image3_frame": ("INT", {
"default": 13,
"min": 1,
"max": 999999,
"step": 1,
"display": "number"
}),
"image4": ("IMAGE", ),
"image4_frame": ("INT", {
"default": 17,
"min": 1,
"max": 999999,
"step": 1,
"display": "number"
}),
}
}
RETURN_TYPES = ("IMAGE", "MASK", "IMAGE", "STRING", )
RETURN_NAMES = ("image_batch", "alpha_batch", "selected_image_batch", "selected_frames", )
FUNCTION = "create_batch"
CATEGORY = "Koolook/Image"
OUTPUT_NODE = False # Not necessarily an output node, but can be chained
def create_batch(
self,
total_frames,
cut_start_frame,
placeholder_color,
invert_alpha,
source_frames,
image1_frame,
source_batch=None,
image1=None,
image2=None,
image2_frame=None,
image3=None,
image3_frame=None,
image4=None,
image4_frame=None,
):
# Validate per-slot image inputs (must be single-frame tensors,
# not pre-batched). source_batch is the only accepted multi-frame input.
for slot_name, slot_img in (
("image1", image1),
("image2", image2),
("image3", image3),
("image4", image4),
):
if slot_img is not None and slot_img.shape[0] != 1:
raise ValueError(
f"{slot_name} should be a single IMAGE (batch size 1), not a pre-batched "
"tensor. Use the 'source_batch' input for pre-batched stacks."
)
# Determine reference H/W/C/device/dtype from the first available source.
# Priority: image1 → source_batch → image2 → image3 → image4.
reference = None
for candidate in (image1, source_batch, image2, image3, image4):
if candidate is not None:
reference = candidate
break
if reference is None:
raise ValueError(
"easy_ImageBatch needs at least one of: image1, source_batch, image2, image3, image4."
)
h, w, c = reference.shape[1:]
device = reference.device
dtype = reference.dtype
# Cross-check that all provided inputs share H/W/C.
for label, tensor in (
("image1", image1),
("source_batch", source_batch),
("image2", image2),
("image3", image3),
("image4", image4),
):
if tensor is not None and tensor.shape[1:] != (h, w, c):
raise ValueError(
f"{label} shape {tuple(tensor.shape[1:])} does not match reference "
f"({h}, {w}, {c}). All inputs must share the same H/W/C."
)
fill_value = _PLACEHOLDER_FILL[placeholder_color]
image_batch = torch.full((total_frames, h, w, c), fill_value, device=device, dtype=dtype)
# Alpha (default convention): 1.0 (white) = empty, set to 0.0 on occupied frames below.
# `invert_alpha` flips the result to compositing-style at the end.
alpha_batch = torch.ones((total_frames, h, w), device=device, dtype=torch.float32)
# 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] = []
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.
#
# The field is multiline: tokens may be separated by commas, newlines,
# spaces, tabs, or any mix of them ("1, 27\n41 63" → [1, 27, 41, 63]).
source_frames_str = (source_frames or "").strip()
if source_frames_str:
if source_batch is None:
print(
"[easy_ImageBatch] source_frames is set but source_batch is not connected; "
"ignoring the list (it only picks frames from source_batch)."
)
else:
tokens = [t for t in re.split(r"[,\s]+", source_frames_str) if t]
for tok in tokens:
try:
f = int(tok)
except ValueError:
print(
f"[easy_ImageBatch] source_frames: ignoring non-integer token '{tok}'."
)
continue
source_index = f - 1
output_index = f - cut_start_frame
if not (0 <= source_index < source_len):
print(
f"[easy_ImageBatch] source_frames: VFX frame {f} doesn't exist in "
f"source_batch (size {source_len}, covers frames 1..{source_len}); "
"skipping."
)
continue
if not (0 <= output_index < total_frames):
outside_cut.append(f)
continue
image_batch[output_index] = source_batch[source_index]
alpha_batch[output_index] = 0.0
placed_indices.add(output_index)
# 2) Resolve each manual slot to (frame_image, timeline_frame). A slot
# contributes iff (a) its imageN is connected, or (b) source_batch
# is connected (in which case imageN_frame doubles as the source
# pick index). Manual slots run AFTER the source_frames list so an
# explicit imageN always wins at its target position.
slots = (
(image1, image1_frame, "image1"),
(image2, image2_frame, "image2"),
(image3, image3_frame, "image3"),
(image4, image4_frame, "image4"),
)
# When the user is driving the node from `source_frames`, the 4 manual
# `imageN_frame` defaults (0, 4, 8, 12) shouldn't sneak in extra picks
# from source_batch — only the explicit list (and any explicitly
# connected imageN inputs) should contribute. So the source_batch
# fallback for an UN-connected slot is only active when the list is
# empty (the original 4-keyframe workflow).
slot_source_fallback = source_batch is not None and not source_frames_str
for slot_img, slot_frame, slot_label in slots:
if slot_frame is None:
continue # Optional slot with no frame value — skip.
output_index = slot_frame - cut_start_frame
if slot_img is not None:
frame_tensor = slot_img[0]
elif slot_source_fallback:
# Pull VFX frame `slot_frame` from source_batch (1-based).
source_index = slot_frame - 1
if not (0 <= source_index < source_len):
print(
f"[easy_ImageBatch] {slot_label}: VFX frame {slot_frame} doesn't "
f"exist in source_batch (size {source_len}, covers frames "
f"1..{source_len}); skipping."
)
continue
frame_tensor = source_batch[source_index]
else:
# Either source_batch isn't connected, or source_frames is
# being used and no explicit imageN was wired — leave this
# slot empty so it doesn't pollute the selection.
continue
if 0 <= output_index < total_frames:
image_batch[output_index] = frame_tensor
alpha_batch[output_index] = 0.0
placed_indices.add(output_index)
else:
outside_cut.append(slot_frame)
if invert_alpha:
alpha_batch = 1.0 - alpha_batch
# Build the "selected only" outputs: keyframes packed back-to-back in
# ascending timeline order, plus their VFX frame numbers as a string
# that round-trips cleanly into the `source_frames` input.
sorted_indices = sorted(placed_indices)
if sorted_indices:
selected_image_batch = image_batch[sorted_indices]
else:
# No keyframes placed — emit a 0-length batch with the same H/W/C.
selected_image_batch = image_batch[:0]
selected_frames_out = ", ".join(
str(idx + cut_start_frame) for idx in sorted_indices
)
# One-line summary of the cut window. Always logged so it's easy to
# confirm what the node produced; mentions outside-cut drops when any.
cut_end = cut_start_frame + total_frames - 1
if outside_cut:
unique_outside = sorted(set(outside_cut))
outside_str = ", ".join(str(f) for f in unique_outside)
print(
f"[easy_ImageBatch] cut window: frames {cut_start_frame}..{cut_end} "
f"({total_frames} frames). {len(sorted_indices)} placed; "
f"{len(unique_outside)} outside cut: {outside_str}."
)
else:
print(
f"[easy_ImageBatch] cut window: frames {cut_start_frame}..{cut_end} "
f"({total_frames} frames). {len(sorted_indices)} placed."
)
return (image_batch, alpha_batch, selected_image_batch, selected_frames_out, )
# Node mappings for ComfyUI registration
NODE_CLASS_MAPPINGS = {
"easy_ImageBatch": easy_ImageBatch
}
NODE_DISPLAY_NAME_MAPPINGS = {
"easy_ImageBatch": "Easy Image Batch (Koolook)"
}