* 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>
414 lines
18 KiB
Python
414 lines
18 KiB
Python
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)"
|
||
} |