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:
co-authored by
Claude Opus 4.8
parent
ae2480eee9
commit
df34ddcf1a
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)"
|
||||||
Reference in New Issue
Block a user