diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ee2381..2eda064 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/user_guide/nodes/koolook_image/easy_image_batch.md b/docs/user_guide/nodes/koolook_image/easy_image_batch.md index c8765d9..6040f4e 100644 --- a/docs/user_guide/nodes/koolook_image/easy_image_batch.md +++ b/docs/user_guide/nodes/koolook_image/easy_image_batch.md @@ -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 diff --git a/k_easy_image_batch.py b/k_easy_image_batch.py index ed6ec8a..5898980 100644 --- a/k_easy_image_batch.py +++ b/k_easy_image_batch.py @@ -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 diff --git a/tests/nodes/test_easy_image_batch.py b/tests/nodes/test_easy_image_batch.py new file mode 100644 index 0000000..3858c06 --- /dev/null +++ b/tests/nodes/test_easy_image_batch.py @@ -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)"