feat(nodes): Easy Image Batch keyframes_insert + insert-over-source +… (#246)
* feat(nodes): Easy Image Batch keyframes_insert + insert-over-source + clean batch - Rename optional input keyframe_batch -> keyframes_insert (node ID unchanged). - Insert-over-source mode: when keyframes_insert AND source_batch are both connected, composite the inserts over the source cut window (instead of the placeholder background). keyframes_insert alone keeps the offset/reconstruct behaviour over placeholder. - No image source connected -> clean placeholder batch sized by new width/height fallback widgets, instead of raising. width/height are appended LAST so older saved workflows keep their positional widgets_values alignment (fixes a mid-list insertion that shifted placeholder_color into source_frames). - alpha_batch marks only inserted/placed positions; source background is not counted as placed. - New pure planner plan_source_base_fill (torch-free) alongside plan_offset_placements; both unit-tested in CI. - Docs: behavior matrix spec, interactive HTML demo, and a generated "all versions" ComfyUI demo workflow under docs/designs/. Not yet implemented (specced as DRAFT in docs/designs, pending maintainer approval + tie-break decision): image1-4 "super-overwrite" layer across all modes, and removal of the Mode 2 unconnected-slot source_batch fallback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(deps): raise aiohttp [test] floor to >=3.14.1 The constraints lock already pins aiohttp 3.14.1 (the CVE-2026-54273..54280 fix line), but pyproject's [test] extra still declared aiohttp>=3.9. Dependabot keys off pyproject, not the lock, so the open declared range kept eight range-based alerts open even though the installed version is patched. Raising the floor to >=3.14.1 aligns the declared range with the lock and clears the alerts once this reaches the default branch. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(nodes): Easy Image Batch image1-4 super-overwrite + source passthrough Make the four manual slots the top compositing layer in every mode (select and insert): a connected imageN overwrites output frame imageN_frame on top of the sequence/inserts and the background. Higher slot wins a collision (image4 > image1); a slot beats a sequence pick / insert at the same position. Extracted as the torch-free plan_slot_overwrites planner + a shared _apply_slot_overwrites helper used by both code paths. Two model fixes ride along, per the finalised behaviour matrix: - An unconnected slot now contributes nothing -- imageN_frame is only a placement target for a wired image, never a source_batch pick. The old unwired-slot fallback (which surfaced seemingly-random frames) is removed. - source_batch + an empty frame list (no insert) now passes the source cut window straight through instead of a placeholder batch, matching the insert-mode empty-list passthrough. Tests: new plan_slot_overwrites planner cases + fake-tensor behaviour tests covering slot-beats-pick/insert, higher-slot-wins, passthrough-not-placed, and the removed phantom fallback. Docstring, tooltips, user guide, design spec (promoted DRAFT -> canonical), and CHANGELOG updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(nodes): assert selected_image_batch = picks/inserts + slots; drop matrix HTML demo selected_image_batch already packs every placed position -- both the source_frames picks / inserts and the manual-slot overwrites, with slots winning on overlap. Add explicit select- and insert-mode tests asserting the composition and ascending order, locking the behaviour in. Also delete docs/designs/easy-image-batch-matrix.html (stale interactive demo built against an earlier draft) and drop its companion reference from the design spec. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(nodes): Easy Image Batch passthrough inpaint mask + frame-range syntax Two refinements to the Easy Image Batch passthrough/list behaviour: - Short-source passthrough is now an extend/inpaint mask. When source_batch is connected and the frame list is empty, the covered source frames are kept content (alpha 0.0) and the uncovered tail becomes the selection (selected_image_batch / selected_frames) with alpha 1.0 -- "keep the first N, generate the rest". Full coverage -> no gap, empty selection. The selection is the gap here (inverse of select mode); insert mode is unchanged. - The source_frames field accepts inclusive ranges: "1-5, 7, 9, 14-17" expands to 1,2,3,4,5,7,9,14,15,16,17, in both select and insert, via a shared torch-free parse_frame_tokens (descending/non-integer tokens warn). Tests cover the gap selection (short/full/cut-start-offset/slot-in-gap), the parser (ranges, bad tokens), and range syntax end-to-end. Docstring, tooltip, user guide, design spec, and CHANGELOG updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(designs): remove draft-era Easy Image Batch demo workflow + builder Delete easy-image-batch-demo-setup.json and build_easy_image_batch_demo.py (the leftover draft-era demo, sibling to the already-removed matrix HTML) and drop the companion reference from the behavior spec. A purpose-built demo will replace them later; the spec matrix + the node remain the canonical reference. Also drop the now-stale "(proposed)" from the spec title (it is canonical). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(user-guide): remove stale v0.1.5 Easy Image Batch screenshot The screenshot predates the node's keyframes_insert / super-overwrite / passthrough rework and is unreferenced. Removing it to be re-created fresh. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs: point Easy Image Batch docs at the bundled Select-and-Rebuild demo Note the runnable demo workflow ("Easy Image Batch - Select and Rebuild", bundled into the Kforge Labs Workflows sidebar folder via #248) in both the user guide and the design spec, replacing the generated demo removed earlier. Referenced by path (not a clickable link) since the workflow lands via #248. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(nodes): address PR review — keyframe_batch alias + input guards - Restore keyframe_batch as a deprecated alias of keyframes_insert so pre-rename workflows (origin/main exposes the input) keep loading; an explicit keyframes_insert wins. Follows docs/maintainers/node-versioning.md (alias-then-deprecate); removal deferred to a release boundary. - Warn instead of silently returning a clean batch when source_frames is non-empty but no image source is connected. - Cap frame-list range expansion at 8192 frames/token to guard against typos like 1-99999999. - Docs: reframe the bundled demo-workflow note as forthcoming (the JSON lands via #248, not on this branch); document the alias, cap, and no-source guard in the user guide / design spec / CHANGELOG. Tests: alias routing + precedence, no-source warning, range cap + boundary (46 node tests). Fresh-eyes review: no correctness/compat issues (the IMAGE alias input adds no widget-position shift). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs: point Easy Image Batch docs at the now-merged Select-and-Rebuild demo #248 merged to main and is now in this branch (via the main merge), so the demo workflow exists at docs/workflows/kforge-labs-workflows/. Repoint the user guide and design spec at it directly (present tense, real folder link) instead of the earlier "coming soon / pending" framing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: AI Machine <ai.machine@x.baconcph.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
AI Machine
parent
4383c2fc0b
commit
88dbfedf86
+68
-16
@@ -7,27 +7,76 @@ The format is inspired by Keep a Changelog and SemVer.
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Easy Image Batch — review hardening.** `keyframe_batch` is kept as a
|
||||
**deprecated alias** of `keyframes_insert` (pre-rename workflows keep loading;
|
||||
an explicit `keyframes_insert` wins if both are wired; removal deferred to a
|
||||
release boundary). A non-empty `source_frames` with **no image source
|
||||
connected** now logs a warning instead of silently returning a clean batch.
|
||||
Frame-list **ranges are capped** — a single `N-M` token can't expand past
|
||||
8192 frames — guarding against typos like `1-99999999`.
|
||||
- **Easy Image Batch — passthrough inpaint mask + frame-range syntax.** Two
|
||||
refinements: (1) when `source_batch` is shorter than the output (empty list),
|
||||
the covered source frames are kept as real content (`alpha` 0.0) while the
|
||||
uncovered tail stays placeholder and becomes the `selected_image_batch` /
|
||||
`selected_frames` output — an extend-a-clip inpaint mask (keep the first N,
|
||||
generate the rest). A full-coverage passthrough has no gap, so `alpha` is
|
||||
all-kept and the selection is empty. This makes `selected_*` the *gap* in
|
||||
passthrough — the inverse of select mode, where it is the placed picks/slots.
|
||||
(2) The `source_frames` field now accepts inclusive ranges: `1-5, 7, 9,
|
||||
14-17` expands to `1,2,3,4,5,7,9,14,15,16,17`, in both select and insert
|
||||
modes (shared torch-free `parse_frame_tokens`, unit-tested in CI).
|
||||
- **Easy Image Batch — `image1-4` super-overwrite + source passthrough.** The
|
||||
four manual slots are now the top compositing layer in **every** mode: a
|
||||
connected `imageN` overwrites output frame `imageN_frame` on top of the
|
||||
sequence/inserts and the background, in select *and* insert modes (they were
|
||||
previously ignored whenever `keyframes_insert` was connected). On a
|
||||
collision the higher-numbered slot wins (`image4` > `image1`), and a slot
|
||||
beats a sequence pick / insert at the same position. Two model fixes ride
|
||||
along: an **unconnected** slot now contributes nothing — `imageN_frame` is
|
||||
only a placement target for a wired image, never a `source_batch` pick (the
|
||||
old "pull `source_batch[imageN_frame-1]`" fallback that surfaced
|
||||
seemingly-random frames is removed); and **`source_batch` + an empty frame
|
||||
list (no insert) now passes the source cut window straight through** instead
|
||||
of emitting a placeholder batch, matching the empty-list insert passthrough
|
||||
so "source in → source out" is consistent. Slot placement is a pure,
|
||||
torch-free planner (`plan_slot_overwrites`) unit-tested in CI alongside the
|
||||
existing insert planners.
|
||||
- **Easy Image Batch — insert modes (`keyframes_insert`).** A new optional
|
||||
`keyframes_insert` input (renamed from the unreleased `keyframe_batch`)
|
||||
adds the inverse of the node's select behaviour. When connected, the node
|
||||
switches from *selecting* sparse keyframes to *scattering* an
|
||||
already-packed short sequence onto the `source_frames` positions — placing
|
||||
the *i*-th incoming frame at the *i*-th listed number (ascending). The
|
||||
**background depends on `source_batch`**:
|
||||
- **not connected → offset / reconstruct mode** — gaps filled with the
|
||||
placeholder colour, at the original sequence length. 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.
|
||||
- **connected → insert-over-source mode** — the `source_batch` cut window
|
||||
is laid down as the background and the listed positions are overwritten
|
||||
with the insert frames (composite N>1 processed frames back into the
|
||||
source video at chosen indexes). Cut frames beyond the source fall back
|
||||
to the placeholder, reported in the summary.
|
||||
|
||||
Connected `image1…image4` still composite on top of the inserts in this mode
|
||||
(see the super-overwrite note above); `alpha_batch` marks the inserted
|
||||
positions and any slot overwrites. An empty
|
||||
`source_frames` list inserts nothing — a clean placeholder batch (no
|
||||
`source_batch`) or a clean `source_batch` passthrough. Placement and the
|
||||
source-base mapping are pure, torch-free planners (`plan_offset_placements`,
|
||||
`plan_source_base_fill`) so both are unit-tested in CI.
|
||||
- **Easy Image Batch — clean batch instead of erroring + `width`/`height`
|
||||
fallback.** With no image source connected at all, the node now emits a
|
||||
clean placeholder batch (sized by the new `width`/`height` widgets, default
|
||||
512×512) rather than raising. `width`/`height` are used **only** as the
|
||||
fallback when no connected image provides dimensions.
|
||||
- **Bundled Kforge Labs demo workflows.** Fresh sidebar installs now seed a
|
||||
**Kforge Labs Workflows** folder with `Easy Image Batch - Select and
|
||||
Rebuild` and `LOOP Demo`, and the source workflow JSONs are tracked under
|
||||
`docs/workflows/kforge-labs-workflows/` for release review and future demo
|
||||
additions. The loop demo ships with auto-queue disabled until the user sets a
|
||||
writable output folder.
|
||||
- **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
|
||||
@@ -170,7 +219,10 @@ The format is inspired by Keep a Changelog and SemVer.
|
||||
scripts upgrade `setuptools` alongside `pip` before audit. The test lock was
|
||||
regenerated to move `aiohttp` from 3.14.0 to the fixed 3.14.1 line, and the
|
||||
relock path now avoids editable Git metadata inspection so Windows
|
||||
cross-drive worktrees can regenerate the lock cleanly.
|
||||
cross-drive worktrees can regenerate the lock cleanly. The `[test]` extra's
|
||||
`aiohttp` floor was also raised from `>=3.9` to `>=3.14.1` so the declared
|
||||
dependency range can no longer resolve a vulnerable version, clearing the
|
||||
eight range-based Dependabot alerts the lock bump alone left open.
|
||||
- **`Koolook_LoopStatus` auto-queue failed on non-default ports.** The
|
||||
node's `server_url` defaulted to ComfyUI's standard port
|
||||
(`http://127.0.0.1:8188`), so installs launched with `--port` (or
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
|
||||
# Easy Image Batch — behavior matrix
|
||||
|
||||
Status: **Canonical** (approved 2026-06-18). Implemented in
|
||||
`k_easy_image_batch.py` and mirrored in
|
||||
[`docs/user_guide/nodes/koolook_image/easy_image_batch.md`](../user_guide/nodes/koolook_image/easy_image_batch.md).
|
||||
|
||||
Runnable demo: the **Easy Image Batch — Select and Rebuild** workflow bundled in
|
||||
the Kforge Labs Workflows sidebar folder
|
||||
([`docs/workflows/kforge-labs-workflows/`](../workflows/kforge-labs-workflows/),
|
||||
from [#248](https://github.com/malkuthro/ComfyUI-Koolook/pull/248)). It
|
||||
supersedes the generated demo removed here.
|
||||
|
||||
This captures the agreed changes:
|
||||
|
||||
1. `keyframe_batch` → **`keyframes_insert`** (rename; the old name is kept as a
|
||||
deprecated alias input so pre-rename workflows keep loading).
|
||||
2. **No image source connected → clean placeholder batch** sized by new
|
||||
`width`/`height` widgets (no error).
|
||||
3. **Insert-over-source mode** — `keyframes_insert` + `source_batch` composite
|
||||
the inserts over the source cut window.
|
||||
4. **`image1`–`image4` are "super-overwrites"** — when connected they replace
|
||||
whatever is at their index in **every** mode (top priority). When **not**
|
||||
connected they do **nothing** (the old "pull from `source_batch` via
|
||||
`imageN_frame`" fallback — Mode 2 — is removed).
|
||||
|
||||
---
|
||||
|
||||
## The layered model (single source of truth)
|
||||
|
||||
The output is built in three layers, lowest first. A later layer overwrites an
|
||||
earlier one at the same output index.
|
||||
|
||||
```
|
||||
output index i ↔ VFX frame v = cut_start_frame + i (window: i in [0, total_frames))
|
||||
|
||||
┌─ Layer 3 — SUPER-OVERWRITE (image1–image4) ────────────────┐ highest priority
|
||||
│ Each CONNECTED imageN → placed at index (imageN_frame │
|
||||
│ − cut_start_frame). Wins over everything. Unconnected │
|
||||
│ slots contribute NOTHING. (imageN_frame matters only │
|
||||
│ when imageN is connected.) │
|
||||
├─ Layer 2 — SEQUENCE ───────────────────────────────────────┤
|
||||
│ • keyframes_insert connected (INSERT): i-th incoming frame │
|
||||
│ → i-th source_frames position (ascending, deduped). │
|
||||
│ • else (SELECT): each source_frames number N picks │
|
||||
│ source_batch[N−1] → index (N − cut_start_frame). │
|
||||
├─ Layer 1 — BACKGROUND ─────────────────────────────────────┤ lowest priority
|
||||
│ • insert-over-source (keyframes_insert AND source_batch): │
|
||||
│ source_batch cut window (index i ← source_batch[v−1]; │
|
||||
│ frames beyond the source fall back to placeholder). │
|
||||
│ • otherwise: placeholder_color everywhere. │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Background rule.** Layer 1 is the source cut window whenever `source_batch`
|
||||
is connected **and** either an insert is active (`keyframes_insert`) **or** the
|
||||
`source_frames` list is empty; otherwise it is `placeholder_color`.
|
||||
|
||||
**What counts as "placed"** (drives `alpha_batch`, `selected_image_batch`,
|
||||
`selected_frames`): normally the Layer 2 placements **and** Layer 3 overwrites;
|
||||
the Layer 1 `source_batch` backdrop is *not* placed (just the background). So
|
||||
`alpha_batch` is `0.0` at sequence inserts/picks and connected-slot overwrites,
|
||||
`1.0` everywhere else (flipped by `invert_alpha`).
|
||||
|
||||
**Select passthrough is the exception (row 5).** When the source passes through
|
||||
with an empty list, the covered source frames *are* kept content (`alpha`
|
||||
`0.0`), and the **uncovered tail becomes the selection** (`alpha` `1.0`,
|
||||
emitted in `selected_*`) — an extend-a-clip inpaint mask. The selection here is
|
||||
the *gap*, the inverse of every other row. Insert-mode passthrough (row 9)
|
||||
keeps the plain backdrop semantics (nothing placed).
|
||||
|
||||
**Resolution / dtype** come from the first connected image (insert: from
|
||||
`keyframes_insert`; select: `image1` → `source_batch` → `image2…4`). With **no**
|
||||
image connected at all, the output is sized by the `width`/`height` widgets
|
||||
(channels = 3), on CPU/float — a clean placeholder batch.
|
||||
|
||||
**Tie-break:** if two connected slots target the same index, the
|
||||
higher-numbered slot wins (`image4` > … > `image1`). Sequence + slot at the
|
||||
same index → the slot wins (Layer 3 > Layer 2).
|
||||
|
||||
---
|
||||
|
||||
## Mode matrix
|
||||
|
||||
Legend — `keyframes_insert` = **KI**, `source_batch` = **SB**, `source_frames`
|
||||
list = **list**, connected `image1–4` = **slots**.
|
||||
|
||||
| # | KI | SB | list | slots | Background | Placed (alpha 0.0) | `selected_*` | Mode label |
|
||||
|---|----|----|------|-------|-----------|--------------------|--------------|-----------|
|
||||
| 1 | ✗ | ✗ | — | none | placeholder (`width`×`height`) | nothing | empty | **clean batch** |
|
||||
| 2 | ✗ | ✓ | has | none | placeholder | source picks at listed positions | the picks | select |
|
||||
| 3 | ✗ | ✗ | — | some | placeholder | connected slots | the slots | select (slots) |
|
||||
| 4 | ✗ | ✓ | has | some | placeholder | source picks **+** slots (slots win on overlap) | picks + slots | select |
|
||||
| 5 | ✗ | ✓ | empty | none | **source cut window** | covered source (kept) | the uncovered **gap** | source passthrough / extend |
|
||||
| 6 | ✓ | ✗ | has | none | placeholder | scattered inserts | the inserts | offset / reconstruct |
|
||||
| 7 | ✓ | ✗ | empty | none | placeholder | nothing | empty | **clean** (nothing inserted) |
|
||||
| 8 | ✓ | ✓ | has | none | **source cut window** | scattered inserts (overwrite source) | the inserts | insert-over-source |
|
||||
| 9 | ✓ | ✓ | empty | none | **source cut window** | nothing | empty | source passthrough |
|
||||
| 10 | ✓ | ✓ | has | some | **source cut window** | inserts **+** slots (slots win) | inserts + slots | insert-over-source |
|
||||
| 11 | any | any | any | **set but NOT connected** | (per above) | **slots contribute nothing** | (per above) | (per above) |
|
||||
|
||||
Row **11** was the bug fixed here: a slot with a non-default `imageN_frame`
|
||||
that is **not wired** has zero effect on the output. The old behaviour (an
|
||||
unconnected slot pulling from `source_batch`) is gone.
|
||||
|
||||
---
|
||||
|
||||
## Edge cases & logging
|
||||
|
||||
- **List ranges.** `N-M` (e.g. `14-17`) expands inclusively; a descending
|
||||
`M-N`, an over-wide range (more than 8192 frames), or any non-integer token
|
||||
warns and is skipped. Applies in both modes.
|
||||
- **List set but no image source** (select): the list is ignored and a clean
|
||||
placeholder batch is returned, with a console warning (not a silent drop).
|
||||
- **List token not in `source_batch`** (select): warn + skip that number.
|
||||
- **List/insert position outside the cut window**: dropped, summarized once.
|
||||
- **More inserts than list positions** / **more positions than inserts**:
|
||||
reported as "extra insert frames" / "positions with no insert frame".
|
||||
- **`source_batch` shorter than the cut window** (insert-over-source): the
|
||||
uncovered tail frames stay placeholder; count reported.
|
||||
- **Empty list** in an insert mode: clean placeholder (no SB) or clean source
|
||||
passthrough (with SB) — console note "frame list is empty".
|
||||
- **Mismatched H/W/C** among connected inputs: hard `ValueError` (unchanged).
|
||||
- Every run still prints one summary line: mode, cut window, placed count, and
|
||||
any of the notes above.
|
||||
|
||||
---
|
||||
|
||||
## Decisions (resolved)
|
||||
|
||||
1. **Tie-break direction** — higher-numbered slot wins (`image4` > `image1`);
|
||||
a slot beats a sequence pick / insert at the same index (Layer 3 > Layer 2).
|
||||
2. **Empty-list passthrough (select)** — `source_batch` + an empty list passes
|
||||
the source cut window through (row 5). Covered source frames are kept content
|
||||
(`alpha` 0.0); a source shorter than the output leaves a placeholder tail
|
||||
that becomes the selection (`selected_*`) — an extend/inpaint mask. The
|
||||
selection here is the *gap*, the inverse of other rows.
|
||||
3. **Mode-2 fallback removed** — an unconnected slot never pulls from
|
||||
`source_batch`; `imageN_frame` is only a placement target for a wired image.
|
||||
4. **Range syntax** — the frame list accepts inclusive ranges (`14-17` →
|
||||
14,15,16,17) in both select and insert; descending/non-integer tokens warn.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 174 KiB |
@@ -23,14 +23,31 @@ 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
|
||||
**Insert modes (the inverse).** Connect a packed sequence to
|
||||
`keyframes_insert` 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).
|
||||
sequence onto the keyframe positions from the frame list — placing frame
|
||||
*i* at the *i*-th listed number. The background depends on whether
|
||||
`source_batch` is connected:
|
||||
|
||||
- **`source_batch` not connected (offset / reconstruct mode):** gaps are
|
||||
filled with the placeholder. 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).
|
||||
- **`source_batch` connected (insert-over-source mode):** the source cut
|
||||
window is the background and the listed positions are overwritten with
|
||||
the insert frames — composite more-than-one processed frames back into
|
||||
the source video at chosen indexes.
|
||||
|
||||
**No inputs ⇒ clean batch.** With no image source connected at all, the
|
||||
node emits a clean placeholder batch sized by the `width`/`height` widgets
|
||||
instead of raising an error.
|
||||
|
||||
> **Demo workflow.** A runnable **Easy Image Batch — Select and Rebuild** demo
|
||||
> ships in the **Kforge Labs Workflows** sidebar folder (source:
|
||||
> [`docs/workflows/kforge-labs-workflows/`](../../../workflows/kforge-labs-workflows/)).
|
||||
> Load it to walk the select → process → rebuild loop and the layer /
|
||||
> passthrough behaviour end to end.
|
||||
|
||||
## Inputs
|
||||
|
||||
@@ -42,29 +59,42 @@ select → process → reconstruct workflow).
|
||||
| `cut_start_frame` | `INT` | `1` | First VFX frame in the output. The output represents `[cut_start_frame .. cut_start_frame + total_frames - 1]`. Frames placed outside this window are dropped from the cut and listed in a single end-of-run summary log. |
|
||||
| `placeholder_color` | enum | `Black` | Fill colour for unoccupied frames in `image_batch`. `Black = 0.0`, `Gray = 0.5`, `White = 1.0`. Independent of `alpha_batch`. |
|
||||
| `invert_alpha` | `BOOLEAN` | `false` (`inpaint`) | Off = inpaint convention (selected = `0.0` black, empty = `1.0` white). On = compositing alpha (selected = `1.0`, empty = `0.0`). |
|
||||
| `source_frames` | multiline `STRING` | `""` | Optional list of VFX frame numbers to pick from `source_batch`. Tokens may be separated by commas, newlines, spaces, tabs, or any mix (`"1, 27\n41 63"` → `[1, 27, 41, 63]`). Each token picks `source_batch[N - 1]` and places it at output index `N - cut_start_frame`. Bad tokens warn and skip. Frames not present in `source_batch` warn; frames outside the cut window are dropped silently and reported in the end-of-run summary. |
|
||||
| `image1_frame` … `image4_frame` | `INT` | `5`, `9`, `13`, `17` | VFX frame number (1-based) for the slot's keyframe. In Mode 2 also used as the `source_batch` pick index. |
|
||||
| `source_frames` | multiline `STRING` | `""` | Optional list of VFX frame numbers to pick from `source_batch`. Tokens may be separated by commas, newlines, spaces, tabs, or any mix (`"1, 27\n41 63"` → `[1, 27, 41, 63]`); inclusive ranges expand (`"14-17"` → `14, 15, 16, 17`). Each number picks `source_batch[N - 1]` and places it at output index `N - cut_start_frame`. Bad tokens warn and skip. Frames not present in `source_batch` warn; frames outside the cut window are dropped silently and reported in the end-of-run summary. |
|
||||
| `image1_frame` … `image4_frame` | `INT` | `5`, `9`, `13`, `17` | VFX timeline position (1-based) for the slot's keyframe. Used **only** when the matching `imageN` is connected — it's the output frame that image overwrites (the top layer). No longer a `source_batch` pick index. |
|
||||
|
||||
### Optional input slots
|
||||
|
||||
| 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`. |
|
||||
| `keyframes_insert` | `IMAGE` | Packed sequence for **insert modes**. When connected, the node switches from *select* to *insert*: frame *i* is placed at the *i*-th position in `source_frames` (ascending). Connected `image1…image4` still composite on top of the inserts. The background is placeholder (no `source_batch`) or the `source_batch` cut window (insert-over-source). |
|
||||
| `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`. In insert mode it becomes the composite background. |
|
||||
| `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`. (In **offset mode** — `keyframe_batch` connected —
|
||||
that input alone is sufficient and the others are ignored.)
|
||||
### Fallback widgets (last two)
|
||||
|
||||
| Input | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `width` | `INT` | `512` | Fallback output width. Used **only** when no image input provides dimensions (a fully empty node producing a clean placeholder batch). Ignored whenever an image source is connected. Range: `1..8192`. |
|
||||
| `height` | `INT` | `512` | Fallback output height. Same fallback-only semantics as `width`. Range: `1..8192`. |
|
||||
|
||||
These two are deliberately the **last** widgets on the node so they append
|
||||
to the end of the saved widget list — keeping older saved workflows (which
|
||||
predate them) correctly aligned.
|
||||
|
||||
With **no image source connected at all**, the node emits a clean
|
||||
placeholder batch sized by the `width`/`height` widgets (no error). When a
|
||||
source *is* connected, all connected inputs must share the same
|
||||
`H × W × C`. (In **insert modes** — `keyframes_insert` connected — that
|
||||
input drives the sequence placement; `source_batch`, if present, supplies
|
||||
the composite background; and connected `image1…image4` overwrite on top.)
|
||||
|
||||
## Outputs
|
||||
|
||||
| Output | Type | Description |
|
||||
|---|---|---|
|
||||
| `image_batch` | `IMAGE` | The cut window as a `total_frames`-long batch starting at VFX frame `cut_start_frame`. Selected positions carry the picked images; everything else is the chosen placeholder colour. |
|
||||
| `alpha_batch` | `MASK` | Same length as `image_batch`. Default convention: selected = `0.0`, empty = `1.0`. Toggle `invert_alpha` to flip. |
|
||||
| `selected_image_batch` | `IMAGE` | **Only** the keyframes that actually landed inside the cut window, packed back-to-back in ascending order. Length = number of placed keyframes; empty (length 0) if nothing was placed. |
|
||||
| `alpha_batch` | `MASK` | Same length as `image_batch`. Default (inpaint): content = `0.0`, empty/to-fill = `1.0`. Toggle `invert_alpha` to flip. In a short-source passthrough the covered frames are `0.0` (kept) and the uncovered tail `1.0` (to inpaint). |
|
||||
| `selected_image_batch` | `IMAGE` | **Only** the keyframes that landed inside the cut, packed back-to-back ascending; empty if nothing was placed. **In passthrough** this is instead the uncovered *gap* frames — the inverse: the frames to generate. |
|
||||
| `selected_frames` | `STRING` | Comma-separated VFX frame numbers of the placed-inside-cut keyframes, sorted ascending and deduped (e.g. `"41, 63, 78, 88, 98, 121"`). Same format as the `source_frames` input — paste it (or wire it) into another `easy_ImageBatch` to reuse the same selection. |
|
||||
|
||||
## Frame-numbering model
|
||||
@@ -85,68 +115,91 @@ There is no exposed knob to renumber `source_batch` (i.e. say "my video
|
||||
starts at VFX frame 100"). If your stack represents a different VFX
|
||||
range, renumber externally before connecting.
|
||||
|
||||
## Modes
|
||||
## Select mode — the three layers
|
||||
|
||||
The node supports three input modes that can be freely mixed.
|
||||
When `keyframes_insert` is **not** connected, the output is built in three
|
||||
layers, lowest first. A higher layer overwrites a lower one at the same
|
||||
output index.
|
||||
|
||||
### Mode 1 — Per-slot images (up to 4 keyframes)
|
||||
### Layer 1 — Background
|
||||
|
||||
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. No `source_batch` needed. Up to 4 keyframes
|
||||
total.
|
||||
- **`source_batch` connected and `source_frames` empty → passthrough.** The
|
||||
source cut window is laid down as the output (output index *i* ←
|
||||
`source_batch[cut_start_frame + i - 1]`). Those covered frames are **kept
|
||||
content** (`alpha` `0.0`). If the source is **shorter** than the output, the
|
||||
uncovered tail stays placeholder and becomes the **selection** — `alpha`
|
||||
`1.0`, plus the `selected_image_batch` / `selected_frames` outputs — i.e. an
|
||||
extend-a-clip inpaint mask: keep the first N real frames, generate the rest.
|
||||
(This is the inverse of select mode, where the selection is the picks/slots.)
|
||||
A full-coverage passthrough has no gap → all kept, empty selection.
|
||||
- **Otherwise → placeholder.** A non-empty `source_frames` list means *select*
|
||||
mode: the named frames are pulled onto a neutral `placeholder_color`
|
||||
background, and only those picks/slots are *placed*. With no `source_batch`
|
||||
at all the background is placeholder too.
|
||||
|
||||
### Mode 2 — `source_batch` + the 4 slots (up to 4 keyframes from a video)
|
||||
In select mode the placeholder background is never counted as *placed* —
|
||||
`alpha_batch` and the `selected_*` outputs describe only the picks and slots.
|
||||
|
||||
Connect a pre-batched stack to `source_batch` (e.g. from `Load Video`)
|
||||
and leave `imageN` unconnected. Each unconnected slot pulls
|
||||
`source_batch[imageN_frame - 1]` and places it at output index
|
||||
`imageN_frame - cut_start_frame` — collapsing the typical "Load Video →
|
||||
Get Images From Batch In Range × N → Easy Image Batch" helper chain
|
||||
into a single node.
|
||||
### Layer 2 — `source_frames` picks
|
||||
|
||||
This mode is active when `source_frames` is empty.
|
||||
Each VFX number in `source_frames` picks `source_batch[N - 1]` and places it
|
||||
at output index `N - cut_start_frame` (if inside the cut), on top of the
|
||||
background. Unlimited keyframes — removes the old 4-slot ceiling. Bad tokens
|
||||
warn and skip; frames absent from `source_batch` warn; outside-cut frames are
|
||||
dropped and summarised.
|
||||
|
||||
### Mode 3 — `source_batch` + `source_frames` list (unlimited keyframes)
|
||||
### Layer 3 — Manual slots (`image1`-`image4`), top priority
|
||||
|
||||
Connect `source_batch` and fill `source_frames` with as many VFX frame
|
||||
numbers as you need. Removes the 4-keyframe ceiling.
|
||||
Connect `imageN` and set `imageN_frame` to its VFX position; the image
|
||||
overwrites that output frame **on top of everything** — background and picks
|
||||
alike. On a collision the **higher-numbered slot wins** (`image4` > `image1`),
|
||||
and a slot beats a Layer-2 pick at the same position.
|
||||
|
||||
**Important interaction rule:** 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.
|
||||
An **unconnected** slot does nothing. `imageN_frame` is only a placement
|
||||
target for a *wired* `imageN` — it is no longer a `source_batch` pick index.
|
||||
(The old behaviour, where an unwired slot pulled `source_batch[imageN_frame -
|
||||
1]` whenever the list was empty, is removed: it surfaced seemingly-random
|
||||
source frames. To pick frames from a video, use the `source_frames` list.)
|
||||
|
||||
### Priority order
|
||||
The set of placed positions is deduped automatically (`selected_*` reflect the
|
||||
final, deduped state).
|
||||
|
||||
When more than one mode contributes:
|
||||
|
||||
1. `source_frames` list runs first.
|
||||
2. Manual slots (`image1` … `image4` with their `imageN_frame`) run
|
||||
after, so explicit `imageN` inputs always **override** list entries
|
||||
at the same cut-output position.
|
||||
3. The set of placed keyframes is deduped automatically (`selected_*`
|
||||
outputs reflect the final, deduped state).
|
||||
|
||||
### Mode 4 — Offset / reconstruct (`keyframe_batch`)
|
||||
### Mode 4 — Insert (`keyframes_insert`)
|
||||
|
||||
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
|
||||
re-loaded) to `keyframes_insert`. The node switches to **insert 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.
|
||||
`keyframes_insert` is placed at output index `position[i] - cut_start_frame`.
|
||||
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).
|
||||
The background depends on `source_batch`:
|
||||
|
||||
- **Offset / reconstruct (no `source_batch`).** Gaps are filled with
|
||||
`placeholder_color`, at the original sequence length.
|
||||
- **Insert-over-source (`source_batch` connected).** The `source_batch` cut
|
||||
window is laid down first (output index *i* ← `source_batch[cut_start_frame
|
||||
+ i - 1]`; cut frames beyond the source fall back to the placeholder and
|
||||
are reported), then the listed positions are overwritten with the insert
|
||||
frames. This composites several processed frames back into the source
|
||||
video at chosen indexes.
|
||||
|
||||
- **Wire-driven switch.** Insert mode is active whenever `keyframes_insert`
|
||||
is connected. Connected `image1…image4` still apply — they composite on top
|
||||
of the inserts (Layer 3): a slot beats an insert at the same position, and
|
||||
the higher-numbered slot wins a slot-vs-slot collision.
|
||||
- **Alpha marks the placed frames.** Regardless of background, `alpha_batch`
|
||||
is `0.0` at the inserted positions and any slot overwrites, and
|
||||
`selected_image_batch` / `selected_frames` describe just those.
|
||||
- **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.
|
||||
missing. Outside-cut positions (inserts or slots) are dropped, same as
|
||||
select mode.
|
||||
- **Empty list ⇒ nothing inserted** — a clean placeholder batch (no
|
||||
`source_batch`) or a clean `source_batch` cut-window passthrough — with a
|
||||
console note. (Connected slots still composite on top.)
|
||||
|
||||
## Conventions
|
||||
|
||||
@@ -195,7 +248,7 @@ VFX frame 41 for 81 frames.
|
||||
| `selected_image_batch` | 6 frames, packed back-to-back. |
|
||||
| `selected_frames` | `"41, 63, 78, 88, 98, 121"` |
|
||||
|
||||
Console summary: `cut window: frames 41..121 (81 frames). 6 placed; 2 outside cut: 1, 27.`
|
||||
Console summary: `cut window: frames 41..121 (81 frames). 6 placed. 2 outside cut: 1, 27.`
|
||||
|
||||
This replaces the workflow pattern of feeding the Easy Image Batch
|
||||
output through an external `ImageFromBatch (batch_index=40, length=81)`
|
||||
@@ -209,7 +262,7 @@ the original length:
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| `keyframe_batch` | the 5 processed frames (e.g. `Load EXR` / image batch) |
|
||||
| `keyframes_insert` | the 5 processed frames (e.g. `Load EXR` / image batch) |
|
||||
| `source_frames` | `"1, 10, 19, 30, 40"` |
|
||||
| `cut_start_frame` | `1` |
|
||||
| `total_frames` | `41` |
|
||||
@@ -224,18 +277,40 @@ the original length:
|
||||
|
||||
Console summary: `offset mode: cut window frames 1..41 (41 frames). 5 placed.`
|
||||
|
||||
## Example D — Insert-over-source (composite into a video)
|
||||
|
||||
Same 5 processed frames as Example C, but now you want them dropped back
|
||||
**into the original 41-frame video** at their positions rather than onto a
|
||||
black background — the untouched frames keep the source footage.
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| `keyframes_insert` | the 5 processed frames |
|
||||
| `source_batch` | the original 41-frame `Load Video` stack |
|
||||
| `source_frames` | `"1, 10, 19, 30, 40"` |
|
||||
| `cut_start_frame` | `1` |
|
||||
| `total_frames` | `41` |
|
||||
|
||||
| Output | What you get |
|
||||
|---|---|
|
||||
| `image_batch` | 41 frames; the 5 processed frames overwrite indices 0 / 9 / 18 / 29 / 39, every other frame is the original source frame. |
|
||||
| `alpha_batch` | 41-frame mask; only those 5 indices are `0.0` (the inserts), the rest `1.0`. |
|
||||
| `selected_image_batch` | the 5 inserted frames, packed back-to-back. |
|
||||
| `selected_frames` | `"1, 10, 19, 30, 40"` |
|
||||
|
||||
Console summary: `insert-over-source mode: cut window frames 1..41 (41 frames). 5 placed.`
|
||||
|
||||
## Tips
|
||||
|
||||
- **Round-tripping selections.** Wire `selected_frames` of one node
|
||||
into `source_frames` of another to chain or fan out the same picks
|
||||
across workflows.
|
||||
- **Picking from a video without writing the list manually.** If you
|
||||
already have helper nodes producing single frames, plug them into
|
||||
`image1`…`image4` (Mode 1) and leave `source_frames` empty. That's
|
||||
the legacy 4-keyframe pattern, kept fully working.
|
||||
- **Going past 4 keyframes.** Switch to Mode 3: connect `source_batch`,
|
||||
fill `source_frames`, leave `imageN` unconnected. Use `image1`…
|
||||
`image4` only for *overrides* (e.g. patching one frame manually).
|
||||
- **A few explicit keyframes.** Plug single-frame images into `image1`…
|
||||
`image4` and set each `imageN_frame`. They overwrite those positions on top
|
||||
of whatever else the node produces — no `source_batch` needed.
|
||||
- **Picking many frames from a video.** Connect `source_batch` and list the
|
||||
VFX numbers in `source_frames` (no 4-keyframe ceiling). Reach for `image1`…
|
||||
`image4` only to *override* individual positions on top of the picks.
|
||||
- **Cutting an output.** Set `cut_start_frame` to the first VFX frame
|
||||
you want and `total_frames` to the length of the cut. No external
|
||||
slicer node needed.
|
||||
|
||||
+479
-208
@@ -14,6 +14,11 @@ _PLACEHOLDER_FILL = {
|
||||
"White": 1.0,
|
||||
}
|
||||
|
||||
# Upper bound on how many frames a single ``N-M`` range token may expand to, so
|
||||
# a typo like ``1-99999999`` can't balloon the frame list into millions of
|
||||
# entries. Comfortably above the node's max cut window (total_frames <= 1024).
|
||||
_MAX_RANGE_SPAN = 8192
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OffsetPlan:
|
||||
@@ -21,7 +26,7 @@ class OffsetPlan:
|
||||
|
||||
- ``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``.
|
||||
position. ``source_index`` is the frame's ordinal in ``keyframes_insert``.
|
||||
- ``outside_cut`` — VFX frame numbers paired to a frame but whose
|
||||
output index fell outside ``[0, total_frames)`` (dropped, like select
|
||||
mode).
|
||||
@@ -71,6 +76,103 @@ def plan_offset_placements(
|
||||
return OffsetPlan(placements, outside_cut, unused_source, missing_positions)
|
||||
|
||||
|
||||
def plan_source_base_fill(
|
||||
total_frames: int, cut_start_frame: int, base_len: int
|
||||
) -> tuple[list[tuple[int, int]], int]:
|
||||
"""Map each output index in the cut window onto a ``source_batch`` index.
|
||||
|
||||
Used by insert-over-source mode to lay down the source video as the
|
||||
background before the inserts overwrite it. Output index ``i`` represents
|
||||
VFX frame ``cut_start_frame + i``, and ``source_batch[0]`` is VFX frame 1,
|
||||
so ``source_index = i + cut_start_frame - 1``.
|
||||
|
||||
Returns ``(placements, fallback_count)`` where ``placements`` is the list
|
||||
of ``(output_index, source_index)`` the source actually covers and
|
||||
``fallback_count`` is how many cut frames fell beyond the source (and stay
|
||||
placeholder). Pure Python — no torch — so it is unit-testable in CI.
|
||||
"""
|
||||
placements: list[tuple[int, int]] = []
|
||||
fallback = 0
|
||||
for output_index in range(total_frames):
|
||||
source_index = output_index + cut_start_frame - 1
|
||||
if 0 <= source_index < base_len:
|
||||
placements.append((output_index, source_index))
|
||||
else:
|
||||
fallback += 1
|
||||
return placements, fallback
|
||||
|
||||
|
||||
def plan_slot_overwrites(
|
||||
slot_frames: list[tuple[int, int]],
|
||||
total_frames: int,
|
||||
cut_start_frame: int,
|
||||
) -> tuple[list[tuple[int, int, int]], list[int]]:
|
||||
"""Map connected manual slots (image1-4) onto output indices — Layer 3.
|
||||
|
||||
``slot_frames`` is ``(slot_index, vfx_frame)`` for each CONNECTED slot, in
|
||||
ascending slot order (image1 = 0 … image4 = 3). Manual slots are the top
|
||||
compositing layer: they overwrite whatever the sequence or background put
|
||||
at the same output index. On a collision between two slots the
|
||||
higher-numbered slot wins (image4 > image1). A slot lands iff its output
|
||||
index ``vfx - cut_start_frame`` is inside ``[0, total_frames)``; otherwise
|
||||
the frame is reported in ``outside_cut``.
|
||||
|
||||
Returns ``(placements, outside_cut)`` where ``placements`` is
|
||||
``(slot_index, output_index, vfx)`` ascending by output index, one winner
|
||||
per index. Pure Python — no torch — so it is unit-testable in CI.
|
||||
"""
|
||||
winner: dict[int, tuple[int, int]] = {} # output_index -> (slot_index, vfx)
|
||||
outside_cut: list[int] = []
|
||||
for slot_index, vfx in slot_frames:
|
||||
output_index = vfx - cut_start_frame
|
||||
if 0 <= output_index < total_frames:
|
||||
# Higher slot wins on a collision (image4 > image1), independent of
|
||||
# the order slots are supplied in.
|
||||
prev = winner.get(output_index)
|
||||
if prev is None or slot_index > prev[0]:
|
||||
winner[output_index] = (slot_index, vfx)
|
||||
else:
|
||||
outside_cut.append(vfx)
|
||||
placements = [
|
||||
(slot_index, output_index, vfx)
|
||||
for output_index, (slot_index, vfx) in sorted(winner.items())
|
||||
]
|
||||
return placements, outside_cut
|
||||
|
||||
|
||||
def parse_frame_tokens(source_frames: str) -> tuple[list[int], list[str]]:
|
||||
"""Parse a frame-list string into 1-based VFX frame numbers.
|
||||
|
||||
Tokens are separated by commas, spaces, tabs, or newlines. A token is
|
||||
either a single integer (``7``) or an inclusive ascending range
|
||||
(``14-17`` → 14, 15, 16, 17). Returns ``(values, bad_tokens)`` where
|
||||
``bad_tokens`` are the tokens that could not be parsed (non-integer, a
|
||||
descending range like ``5-1``, or a range wider than ``_MAX_RANGE_SPAN``)
|
||||
so the caller can warn. Pure Python — no torch — so it is unit-testable in
|
||||
CI. Shared by select picks and insert positions.
|
||||
"""
|
||||
values: list[int] = []
|
||||
bad_tokens: list[str] = []
|
||||
for tok in re.split(r"[,\s]+", (source_frames or "").strip()):
|
||||
if not tok:
|
||||
continue
|
||||
span = re.fullmatch(r"(\d+)-(\d+)", tok)
|
||||
if span:
|
||||
lo, hi = int(span.group(1)), int(span.group(2))
|
||||
# Reject descending or absurdly large ranges (a typo like
|
||||
# "1-99999999" would otherwise expand into millions of entries).
|
||||
if lo <= hi <= lo + _MAX_RANGE_SPAN - 1:
|
||||
values.extend(range(lo, hi + 1))
|
||||
else:
|
||||
bad_tokens.append(tok)
|
||||
continue
|
||||
try:
|
||||
values.append(int(tok))
|
||||
except ValueError:
|
||||
bad_tokens.append(tok)
|
||||
return values, bad_tokens
|
||||
|
||||
|
||||
class easy_ImageBatch:
|
||||
"""
|
||||
Place up to N keyframes on a 1-based VFX timeline, then output a
|
||||
@@ -116,45 +218,54 @@ class easy_ImageBatch:
|
||||
`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.
|
||||
Select mode — the layers (lowest first; a later layer overwrites an
|
||||
earlier one at the same output index)
|
||||
-----------------------------------------------------------------------
|
||||
1. Background: with `source_batch` connected and an EMPTY `source_frames`
|
||||
list, the source cut window passes straight through (source in →
|
||||
source out) as kept content (`alpha` 0.0). Any tail beyond the source
|
||||
stays placeholder, and that uncovered gap becomes the `selected_*`
|
||||
output — the frames to inpaint/generate (the inverse of the usual
|
||||
"selected = picks/slots"). Otherwise the background is
|
||||
`placeholder_color` and is not counted as "placed".
|
||||
2. `source_frames` (optional comma/newline/whitespace string like
|
||||
`"1, 27, 41, 63"`, with inclusive ranges `"14-17"` → 14,15,16,17): each
|
||||
number picks `source_batch[N - 1]` and places it at output index
|
||||
`N - cut_start_frame` (if inside the cut), onto the placeholder. Bad
|
||||
tokens warn and are skipped; frames not present in `source_batch` warn;
|
||||
frames outside the cut window are dropped and summarised at the end.
|
||||
3. Manual slots (`image1`-`image4`): connect `imageN` and set
|
||||
`imageN_frame` to its VFX position; the image overwrites that output
|
||||
frame on TOP of everything. A higher-numbered slot wins a slot-vs-slot
|
||||
collision (`image4` > `image1`). An UNconnected slot does nothing —
|
||||
`imageN_frame` is only a placement target for a wired `imageN`, never a
|
||||
`source_batch` pick.
|
||||
|
||||
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.
|
||||
Output dedup is automatic (set-based). All inputs must share the same
|
||||
H/W/C. When no image source is connected at all, the node emits a clean
|
||||
placeholder batch sized by the `width`/`height` widgets instead of
|
||||
erroring.
|
||||
|
||||
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.
|
||||
Insert modes (the inverse) — connect a packed sequence to
|
||||
`keyframes_insert` and the node instead *scatters* those frames onto the
|
||||
`source_frames` positions (i-th frame → i-th position, ascending). The
|
||||
background depends on `source_batch`:
|
||||
|
||||
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`.
|
||||
- `source_batch` NOT connected → gaps filled with the placeholder
|
||||
(offset / reconstruct mode). Round-trips with `selected_image_batch` +
|
||||
`selected_frames`.
|
||||
- `source_batch` connected → the inserts are composited *over* the
|
||||
`source_batch` cut window (insert-over-source mode): the source video
|
||||
is the background and the listed positions are overwritten with the
|
||||
insert frames.
|
||||
|
||||
Connected manual slots (`image1`-`image4`) still apply here — they
|
||||
composite on TOP of the inserts (a slot beats an insert at the same
|
||||
position; the higher slot wins a slot collision). With an empty
|
||||
`source_frames` list and no slots nothing is placed: a clean placeholder
|
||||
batch (no `source_batch`) or a clean `source_batch` cut-window
|
||||
passthrough. `alpha_batch` marks the inserted positions and slot
|
||||
overwrites as placed. 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.
|
||||
@@ -223,8 +334,8 @@ class easy_ImageBatch:
|
||||
"source_frames": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"placeholder": "extra frames from source_batch, e.g. 1, 27, 41, 63, 85 (commas and/or newlines)",
|
||||
"tooltip": "Optional 1-based VFX frame list to pick from source_batch. Separators can be commas, spaces, tabs, or newlines.",
|
||||
"placeholder": "frames from source_batch, e.g. 1, 27, 41-50, 63 (commas/newlines; ranges with -)",
|
||||
"tooltip": "Optional 1-based VFX frame list to pick from source_batch (in insert mode, the destination positions). Separators: commas, spaces, tabs, or newlines. Inclusive ranges like 14-17 expand to 14,15,16,17.",
|
||||
}),
|
||||
"image1_frame": ("INT", {
|
||||
"default": 5,
|
||||
@@ -232,22 +343,32 @@ class easy_ImageBatch:
|
||||
"max": 999999,
|
||||
"step": 1,
|
||||
"display": "number",
|
||||
"tooltip": "1-based VFX frame number for image1, or source_batch pick when source_frames is empty.",
|
||||
"tooltip": "1-based VFX frame number for image1; the connected image is placed here on top (no effect when the slot is unwired).",
|
||||
}),
|
||||
},
|
||||
"optional": {
|
||||
"keyframes_insert": ("IMAGE", {
|
||||
"tooltip": (
|
||||
"Connect a packed sequence of frames to INSERT them onto the "
|
||||
"timeline at the positions from the frame list (source_frames): "
|
||||
"1st frame -> 1st number, 2nd -> 2nd, ... Connected image1-4 "
|
||||
"still composite on top of the inserts. The background depends on "
|
||||
"source_batch: if source_batch is NOT connected, the gaps are "
|
||||
"filled with placeholder_color (offset/reconstruct mode); if "
|
||||
"source_batch IS connected, the inserts are composited over the "
|
||||
"source frames in the cut window (insert-over-source mode). With "
|
||||
"an empty frame list and no slots nothing is placed: a clean "
|
||||
"placeholder batch (no source_batch) or a clean source-batch "
|
||||
"passthrough (with source_batch)."
|
||||
),
|
||||
}),
|
||||
"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."
|
||||
"Deprecated alias for keyframes_insert (the input was "
|
||||
"renamed before release). Use keyframes_insert; this alias "
|
||||
"keeps pre-rename workflows loading and will be removed in "
|
||||
"a future release. Ignored when keyframes_insert is also "
|
||||
"connected."
|
||||
),
|
||||
}),
|
||||
"source_batch": ("IMAGE", ),
|
||||
@@ -259,7 +380,7 @@ class easy_ImageBatch:
|
||||
"max": 999999,
|
||||
"step": 1,
|
||||
"display": "number",
|
||||
"tooltip": "1-based VFX frame number for image2, or source_batch pick when source_frames is empty.",
|
||||
"tooltip": "1-based VFX frame number for image2; the connected image is placed here on top (no effect when the slot is unwired).",
|
||||
}),
|
||||
"image3": ("IMAGE", ),
|
||||
"image3_frame": ("INT", {
|
||||
@@ -268,7 +389,7 @@ class easy_ImageBatch:
|
||||
"max": 999999,
|
||||
"step": 1,
|
||||
"display": "number",
|
||||
"tooltip": "1-based VFX frame number for image3, or source_batch pick when source_frames is empty.",
|
||||
"tooltip": "1-based VFX frame number for image3; the connected image is placed here on top (no effect when the slot is unwired).",
|
||||
}),
|
||||
"image4": ("IMAGE", ),
|
||||
"image4_frame": ("INT", {
|
||||
@@ -277,7 +398,26 @@ class easy_ImageBatch:
|
||||
"max": 999999,
|
||||
"step": 1,
|
||||
"display": "number",
|
||||
"tooltip": "1-based VFX frame number for image4, or source_batch pick when source_frames is empty.",
|
||||
"tooltip": "1-based VFX frame number for image4; the connected image is placed here on top (no effect when the slot is unwired).",
|
||||
}),
|
||||
# width/height are intentionally LAST so they append to the end
|
||||
# of the widget list — inserting them earlier would shift the
|
||||
# positional widgets_values of every previously-saved workflow.
|
||||
"width": ("INT", {
|
||||
"default": 512,
|
||||
"min": 1,
|
||||
"max": 8192,
|
||||
"step": 8,
|
||||
"display": "number",
|
||||
"tooltip": "Fallback output width. Used ONLY when no image input provides dimensions (e.g. a fully empty node producing a clean placeholder batch). Ignored whenever an image source is connected.",
|
||||
}),
|
||||
"height": ("INT", {
|
||||
"default": 512,
|
||||
"min": 1,
|
||||
"max": 8192,
|
||||
"step": 8,
|
||||
"display": "number",
|
||||
"tooltip": "Fallback output height. Used ONLY when no image input provides dimensions (e.g. a fully empty node producing a clean placeholder batch). Ignored whenever an image source is connected.",
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -304,27 +444,25 @@ class easy_ImageBatch:
|
||||
image3_frame=None,
|
||||
image4=None,
|
||||
image4_frame=None,
|
||||
keyframes_insert=None,
|
||||
keyframe_batch=None,
|
||||
width=512,
|
||||
height=512,
|
||||
):
|
||||
# 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,
|
||||
# Deprecated alias: `keyframe_batch` was renamed to `keyframes_insert`
|
||||
# before release. Honour a pre-rename workflow's connection so it keeps
|
||||
# loading; an explicit `keyframes_insert` wins if both are wired.
|
||||
if keyframes_insert is None and keyframe_batch is not None:
|
||||
keyframes_insert = keyframe_batch
|
||||
print(
|
||||
"[easy_ImageBatch] 'keyframe_batch' is deprecated; use "
|
||||
"'keyframes_insert'. Routing the connected batch to insert mode."
|
||||
)
|
||||
|
||||
# Validate per-slot image inputs (must be single-frame tensors,
|
||||
# not pre-batched). source_batch is the only accepted multi-frame input.
|
||||
# Validate per-slot image inputs (must be single-frame tensors, not
|
||||
# pre-batched) for BOTH modes — manual slots are now composited as the
|
||||
# top layer in insert mode too, not just select. source_batch is the
|
||||
# only accepted multi-frame input.
|
||||
for slot_name, slot_img in (
|
||||
("image1", image1),
|
||||
("image2", image2),
|
||||
@@ -337,16 +475,54 @@ class easy_ImageBatch:
|
||||
"tensor. Use the 'source_batch' input for pre-batched stacks."
|
||||
)
|
||||
|
||||
# Manual slots (image1-4) are the top compositing layer (Layer 3) in
|
||||
# every mode: a connected imageN overwrites output frame imageN_frame on
|
||||
# top of the sequence/background. Higher slot wins on a collision.
|
||||
slots = (
|
||||
(image1, image1_frame),
|
||||
(image2, image2_frame),
|
||||
(image3, image3_frame),
|
||||
(image4, image4_frame),
|
||||
)
|
||||
|
||||
# Insert modes: when keyframes_insert is connected, switch from "select
|
||||
# frames out of source_batch" to "scatter this packed sequence onto the
|
||||
# frame-list positions", then composite the slots over the result. The
|
||||
# background depends on source_batch:
|
||||
# - no source_batch -> placeholder gaps (offset / reconstruct mode)
|
||||
# - with source_batch -> the inserts sit over the source cut window
|
||||
# (insert-over-source mode)
|
||||
# See create_batch_from_frames.
|
||||
if keyframes_insert is not None:
|
||||
return self.create_batch_from_frames(
|
||||
keyframes_insert,
|
||||
source_frames,
|
||||
total_frames,
|
||||
cut_start_frame,
|
||||
placeholder_color,
|
||||
invert_alpha,
|
||||
base_batch=source_batch,
|
||||
slots=slots,
|
||||
)
|
||||
|
||||
# Determine reference H/W/C/device/dtype from the first available source.
|
||||
# Priority: image1 → source_batch → image2 → image3 → image4.
|
||||
# Priority: image1 → source_batch → image2 → image3 → image4. With no
|
||||
# image source connected at all, fall back to the width/height widgets
|
||||
# and emit a clean placeholder batch instead of erroring.
|
||||
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."
|
||||
if (source_frames or "").strip():
|
||||
print(
|
||||
"[easy_ImageBatch] source_frames is set but no image source "
|
||||
"(source_batch / image1-4) is connected; the list is ignored "
|
||||
"and a clean placeholder batch is returned."
|
||||
)
|
||||
return self._clean_placeholder_batch(
|
||||
total_frames, height, width, 3, placeholder_color, invert_alpha
|
||||
)
|
||||
|
||||
h, w, c = reference.shape[1:]
|
||||
@@ -367,31 +543,53 @@ class easy_ImageBatch:
|
||||
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] = []
|
||||
|
||||
# Compute the frame list early — it decides the background.
|
||||
source_frames_str = (source_frames or "").strip()
|
||||
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.
|
||||
# Background (Layer 1): with source_batch connected AND an empty frame
|
||||
# list, pass the source cut window straight through (the same backdrop
|
||||
# insert-over-source lays down) — "source in, source out" with nothing
|
||||
# else to do. Otherwise fill with the placeholder; a non-empty list
|
||||
# means select mode (pull the named frames onto a neutral background).
|
||||
# The background is NOT counted as "placed": only list picks and slots
|
||||
# are, so alpha / selected_* describe just those.
|
||||
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/to-fill; set to 0.0 on
|
||||
# frames that carry real content. `invert_alpha` flips at the end.
|
||||
alpha_batch = torch.ones((total_frames, h, w), device=device, dtype=torch.float32)
|
||||
|
||||
# Timeline indices with a keyframe placed (picks + slots), used to build
|
||||
# the selected_* outputs. `outside_cut` collects referenced VFX frames
|
||||
# that fell outside the window (summarised once at the end).
|
||||
placed_indices: set[int] = set()
|
||||
outside_cut: list[int] = []
|
||||
|
||||
# Passthrough (empty list + source): the source cut window is the kept
|
||||
# background — covered frames carry real content (alpha 0.0), the
|
||||
# uncovered tail stays placeholder. `covered_indices` lets the selection
|
||||
# below resolve to the GAP (the to-inpaint frames) rather than to the
|
||||
# picks/slots that drive select mode.
|
||||
passthrough = source_batch is not None and not source_frames_str
|
||||
covered_indices: set[int] = set()
|
||||
if passthrough:
|
||||
base_placements, _ = plan_source_base_fill(
|
||||
total_frames, cut_start_frame, source_len
|
||||
)
|
||||
for output_index, source_index in base_placements:
|
||||
image_batch[output_index] = source_batch[source_index]
|
||||
alpha_batch[output_index] = 0.0
|
||||
covered_indices.add(output_index)
|
||||
|
||||
# 1) source_frames list (optional). Each token is a VFX-numbered frame:
|
||||
# it picks source_batch[N - 1] (VFX 1-based) AND places it at output
|
||||
# index N - cut_start_frame, onto the placeholder. Runs only when the
|
||||
# list is non-empty (an empty list is the passthrough case above);
|
||||
# the manual slots run after and override per-position.
|
||||
#
|
||||
# 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(
|
||||
@@ -399,15 +597,12 @@ class easy_ImageBatch:
|
||||
"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
|
||||
values, bad_tokens = parse_frame_tokens(source_frames_str)
|
||||
for tok in bad_tokens:
|
||||
print(
|
||||
f"[easy_ImageBatch] source_frames: ignoring invalid token '{tok}'."
|
||||
)
|
||||
for f in values:
|
||||
source_index = f - 1
|
||||
output_index = f - cut_start_frame
|
||||
if not (0 <= source_index < source_len):
|
||||
@@ -424,57 +619,26 @@ class easy_ImageBatch:
|
||||
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"),
|
||||
# 2) Manual slots (Layer 3, top priority): a connected image1-4
|
||||
# overwrites its output frame on top of the list picks / passthrough
|
||||
# background. Higher slot wins on a collision (image4 > image1).
|
||||
# Unconnected slots contribute nothing — imageN_frame is only a
|
||||
# placement target for a wired imageN, never a source_batch pick.
|
||||
self._apply_slot_overwrites(
|
||||
image_batch, alpha_batch, placed_indices, slots,
|
||||
total_frames, cut_start_frame, outside_cut, (h, w, c),
|
||||
)
|
||||
|
||||
# When the user is driving the node from `source_frames`, the manual
|
||||
# `imageN_frame` defaults 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)
|
||||
# Passthrough selection: the "selected" frames are the GAP — placeholder
|
||||
# frames with no source coverage and no slot, i.e. the ones to inpaint /
|
||||
# generate. This is the inverse of select mode (where selected = the
|
||||
# placed picks/slots); covered source and slot overwrites are kept
|
||||
# content and excluded from the selection.
|
||||
if passthrough:
|
||||
content = covered_indices | placed_indices
|
||||
placed_indices = {
|
||||
i for i in range(total_frames) if i not in content
|
||||
}
|
||||
|
||||
if invert_alpha:
|
||||
alpha_batch = 1.0 - alpha_batch
|
||||
@@ -493,86 +657,183 @@ class easy_ImageBatch:
|
||||
)
|
||||
|
||||
# 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.
|
||||
# confirm what the node produced; surfaces outside-cut drops, the
|
||||
# passthrough background, and any source-shorter-than-cut fill.
|
||||
cut_end = cut_start_frame + total_frames - 1
|
||||
notes: list[str] = []
|
||||
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}."
|
||||
notes.append(
|
||||
f"{len(unique_outside)} outside cut: "
|
||||
+ ", ".join(str(f) for f in unique_outside)
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"[easy_ImageBatch] cut window: frames {cut_start_frame}..{cut_end} "
|
||||
f"({total_frames} frames). {len(sorted_indices)} placed."
|
||||
if passthrough:
|
||||
# kept = everything not in the gap (covered source + any slot fills).
|
||||
notes.append(
|
||||
f"source passthrough: {total_frames - len(sorted_indices)} kept, "
|
||||
f"{len(sorted_indices)} gap frame(s) selected to inpaint"
|
||||
)
|
||||
suffix = (" " + "; ".join(notes) + ".") if notes else ""
|
||||
print(
|
||||
f"[easy_ImageBatch] cut window: frames {cut_start_frame}..{cut_end} "
|
||||
f"({total_frames} frames). {len(sorted_indices)} placed.{suffix}"
|
||||
)
|
||||
|
||||
return (image_batch, alpha_batch, selected_image_batch, selected_frames_out, )
|
||||
|
||||
def _clean_placeholder_batch(
|
||||
self, total_frames, h, w, c, placeholder_color, invert_alpha, device=None, dtype=None
|
||||
):
|
||||
"""Build a fully-empty batch (no placed frames) of the placeholder color.
|
||||
|
||||
Used when nothing drives placement: a totally empty node (sized by the
|
||||
width/height widgets, so ``device``/``dtype`` are absent → CPU/float).
|
||||
``image_batch`` is uniform placeholder; ``alpha_batch`` is all-empty
|
||||
(1.0, flipped by ``invert_alpha``); ``selected_*`` outputs are
|
||||
zero-length. Returns the standard 4-tuple.
|
||||
"""
|
||||
fill_value = _PLACEHOLDER_FILL[placeholder_color]
|
||||
if device is None:
|
||||
image_batch = torch.full((total_frames, h, w, c), fill_value)
|
||||
alpha_batch = torch.ones((total_frames, h, w), dtype=torch.float32)
|
||||
else:
|
||||
image_batch = torch.full(
|
||||
(total_frames, h, w, c), fill_value, device=device, dtype=dtype
|
||||
)
|
||||
alpha_batch = torch.ones(
|
||||
(total_frames, h, w), device=device, dtype=torch.float32
|
||||
)
|
||||
if invert_alpha:
|
||||
alpha_batch = 1.0 - alpha_batch
|
||||
print(
|
||||
f"[easy_ImageBatch] clean batch: {total_frames} frames of "
|
||||
f"{placeholder_color} placeholder; nothing placed."
|
||||
)
|
||||
return (image_batch, alpha_batch, image_batch[:0], "")
|
||||
|
||||
def _apply_slot_overwrites(
|
||||
self,
|
||||
image_batch,
|
||||
alpha_batch,
|
||||
placed_indices,
|
||||
slots,
|
||||
total_frames,
|
||||
cut_start_frame,
|
||||
outside_cut,
|
||||
ref_hwc,
|
||||
):
|
||||
"""Composite connected manual slots on top of the batch (Layer 3).
|
||||
|
||||
``slots`` is ``((image, frame), …)`` for image1..image4 in ascending
|
||||
order. Only connected slots with a frame value participate; each must
|
||||
match ``ref_hwc`` (H/W/C). Higher slot wins on a collision (see
|
||||
``plan_slot_overwrites``). Mutates ``image_batch`` / ``alpha_batch`` /
|
||||
``placed_indices`` and extends ``outside_cut`` in place.
|
||||
"""
|
||||
h, w, c = ref_hwc
|
||||
connected: list[tuple[int, int]] = []
|
||||
for slot_index, (img, frame) in enumerate(slots):
|
||||
if img is None or frame is None:
|
||||
continue
|
||||
if tuple(img.shape[1:]) != (h, w, c):
|
||||
raise ValueError(
|
||||
f"image{slot_index + 1} shape {tuple(img.shape[1:])} does not match "
|
||||
f"reference ({h}, {w}, {c}). All inputs must share the same H/W/C."
|
||||
)
|
||||
connected.append((slot_index, frame))
|
||||
placements, slot_outside = plan_slot_overwrites(
|
||||
connected, total_frames, cut_start_frame
|
||||
)
|
||||
for slot_index, output_index, _vfx in placements:
|
||||
image_batch[output_index] = slots[slot_index][0][0]
|
||||
alpha_batch[output_index] = 0.0
|
||||
placed_indices.add(output_index)
|
||||
outside_cut.extend(slot_outside)
|
||||
|
||||
def create_batch_from_frames(
|
||||
self,
|
||||
keyframe_batch,
|
||||
keyframes_insert,
|
||||
source_frames,
|
||||
total_frames,
|
||||
cut_start_frame,
|
||||
placeholder_color,
|
||||
invert_alpha,
|
||||
ignored_inputs_connected=False,
|
||||
base_batch=None,
|
||||
slots=((None, None), (None, None), (None, None), (None, None)),
|
||||
):
|
||||
"""Offset mode — active when ``keyframe_batch`` is connected.
|
||||
"""Insert mode — active when ``keyframes_insert`` 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).
|
||||
Scatter the packed ``keyframes_insert`` sequence onto the timeline at
|
||||
the positions listed in ``source_frames`` (i-th frame → i-th position,
|
||||
ascending). The background depends on ``base_batch`` (the connected
|
||||
``source_batch``, or ``None``):
|
||||
|
||||
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).
|
||||
- **No ``base_batch`` (offset / reconstruct mode).** Gaps are filled
|
||||
with ``placeholder_color``. Feed back a processed copy of a previous
|
||||
``selected_image_batch`` plus the same ``selected_frames`` list to
|
||||
rebuild a full-length sequence in the original spacing.
|
||||
- **With ``base_batch`` (insert-over-source mode).** The cut window of
|
||||
``base_batch`` is laid down first (VFX frame ``cut_start_frame + i``
|
||||
→ ``base_batch[cut_start_frame + i - 1]``; frames beyond the source
|
||||
fall back to the placeholder), then the inserts overwrite at the
|
||||
listed positions. The inserts are composited *over* the source video.
|
||||
|
||||
Connected manual slots (``image1``-``image4``) are then composited on
|
||||
top as the highest layer — a slot overwrites an insert at the same
|
||||
position, and the higher-numbered slot wins a slot-vs-slot collision.
|
||||
|
||||
``alpha_batch`` marks the inserted positions and any slot overwrites as
|
||||
placed (0.0), regardless of the background — so ``selected_image_batch``
|
||||
/ ``selected_frames`` describe just those. With an empty frame list and
|
||||
no slots nothing is placed: a clean placeholder batch (no ``base_batch``)
|
||||
or a clean ``base_batch`` cut-window passthrough.
|
||||
"""
|
||||
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
|
||||
h, w, c = keyframes_insert.shape[1:]
|
||||
device = keyframes_insert.device
|
||||
dtype = keyframes_insert.dtype
|
||||
|
||||
# Background: source cut window (insert-over-source) or placeholder.
|
||||
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.
|
||||
base_short_fallback = 0
|
||||
if base_batch is not None:
|
||||
if tuple(base_batch.shape[1:]) != (h, w, c):
|
||||
raise ValueError(
|
||||
f"source_batch shape {tuple(base_batch.shape[1:])} does not match "
|
||||
f"keyframes_insert ({h}, {w}, {c}). Both must share the same H/W/C."
|
||||
)
|
||||
base_placements, base_short_fallback = plan_source_base_fill(
|
||||
total_frames, cut_start_frame, base_batch.shape[0]
|
||||
)
|
||||
for output_index, source_index in base_placements:
|
||||
image_batch[output_index] = base_batch[source_index]
|
||||
# Convention: 1.0 (white/empty), 0.0 on inserted positions only.
|
||||
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] = []
|
||||
# Parse the position list (commas, spaces, tabs, newlines, or any mix;
|
||||
# inclusive ranges like "5-7" expand via parse_frame_tokens).
|
||||
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}'.")
|
||||
positions, bad_tokens = parse_frame_tokens(source_frames_str)
|
||||
for tok in bad_tokens:
|
||||
print(f"[easy_ImageBatch] source_frames: ignoring invalid token '{tok}'.")
|
||||
|
||||
source_count = keyframe_batch.shape[0]
|
||||
source_count = keyframes_insert.shape[0]
|
||||
plan = plan_offset_placements(positions, source_count, total_frames, cut_start_frame)
|
||||
|
||||
placed_indices: list[int] = []
|
||||
placed_indices: set[int] = set()
|
||||
for source_index, output_index, _vfx in plan.placements:
|
||||
image_batch[output_index] = keyframe_batch[source_index]
|
||||
image_batch[output_index] = keyframes_insert[source_index]
|
||||
alpha_batch[output_index] = 0.0
|
||||
placed_indices.append(output_index)
|
||||
placed_indices.add(output_index)
|
||||
|
||||
# Manual slots (Layer 3): composite connected image1-4 on top of the
|
||||
# inserts and the source background. A slot beats an insert at the same
|
||||
# position; higher slot wins a slot-vs-slot collision.
|
||||
slot_outside: list[int] = []
|
||||
self._apply_slot_overwrites(
|
||||
image_batch, alpha_batch, placed_indices, slots,
|
||||
total_frames, cut_start_frame, slot_outside, (h, w, c),
|
||||
)
|
||||
|
||||
if invert_alpha:
|
||||
alpha_batch = 1.0 - alpha_batch
|
||||
@@ -586,9 +847,10 @@ class easy_ImageBatch:
|
||||
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.
|
||||
# One-line summary (mirrors select mode) with insert-specific notes so
|
||||
# silent surprises (extra frames, short lists, outside-cut, empty list,
|
||||
# source shorter than the cut) are visible without per-frame logs.
|
||||
mode_label = "insert-over-source mode" if base_batch is not None else "offset mode"
|
||||
cut_end = cut_start_frame + total_frames - 1
|
||||
notes: list[str] = []
|
||||
if plan.outside_cut:
|
||||
@@ -598,18 +860,27 @@ class easy_ImageBatch:
|
||||
)
|
||||
if plan.unused_source:
|
||||
notes.append(
|
||||
f"{plan.unused_source} extra source frame(s) had no listed position"
|
||||
f"{plan.unused_source} extra insert frame(s) had no listed position"
|
||||
)
|
||||
if plan.missing_positions:
|
||||
notes.append(
|
||||
f"{len(plan.missing_positions)} position(s) had no source frame: "
|
||||
f"{len(plan.missing_positions)} position(s) had no insert frame: "
|
||||
+ ", ".join(str(f) for f in plan.missing_positions)
|
||||
)
|
||||
if base_short_fallback:
|
||||
notes.append(
|
||||
f"{base_short_fallback} cut frame(s) beyond source_batch filled with placeholder"
|
||||
)
|
||||
if slot_outside:
|
||||
notes.append(
|
||||
f"{len(set(slot_outside))} slot frame(s) outside cut: "
|
||||
+ ", ".join(str(f) for f in sorted(set(slot_outside)))
|
||||
)
|
||||
if not source_frames_str:
|
||||
notes.append("frame list is empty — nothing placed")
|
||||
notes.append("frame list is empty — nothing inserted")
|
||||
suffix = (" " + "; ".join(notes) + ".") if notes else ""
|
||||
print(
|
||||
f"[easy_ImageBatch] offset mode: cut window frames {cut_start_frame}.."
|
||||
f"[easy_ImageBatch] {mode_label}: cut window frames {cut_start_frame}.."
|
||||
f"{cut_end} ({total_frames} frames). {len(sorted_indices)} placed.{suffix}"
|
||||
)
|
||||
|
||||
|
||||
+4
-2
@@ -71,5 +71,7 @@ addopts = ["--import-mode=importlib"]
|
||||
# ComfyUI custom node is not installed as a package in the workflow.
|
||||
# Locally use ``.venv/bin/pip install -e '.[test]'`` from the repo root.
|
||||
# ``aiohttp`` is a runtime dep of ``koolook_routes`` and must be importable
|
||||
# even when only a pure helper is under test.
|
||||
test = ["pytest>=8", "aiohttp>=3.9", "pip-audit>=2.7", "Pillow>=10"]
|
||||
# even when only a pure helper is under test. The lower bound is pinned to
|
||||
# the CVE-2026-54273..54280 fix line (>=3.14.1) so the declared range can't
|
||||
# resolve a vulnerable aiohttp; the lock (constraints-test.txt) matches.
|
||||
test = ["pytest>=8", "aiohttp>=3.14.1", "pip-audit>=2.7", "Pillow>=10"]
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
"""Tests for ``easy_ImageBatch`` offset mode.
|
||||
"""Tests for ``easy_ImageBatch`` insert modes and schema.
|
||||
|
||||
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
|
||||
The node imports ``torch`` at module load, but its *planners*
|
||||
(``plan_offset_placements`` for insert placement, ``plan_source_base_fill``
|
||||
for the insert-over-source background) are pure Python and never touch torch
|
||||
— they just compute which 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.
|
||||
"""
|
||||
@@ -12,13 +13,84 @@ from __future__ import annotations
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
# 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"))
|
||||
|
||||
# Enrich the stub with the handful of constructors ``create_batch`` uses so the
|
||||
# *behaviour* (layer compositing, tie-break, passthrough) can be exercised
|
||||
# torch-free — not just the pure planners. ``_FakeTensor`` wraps a flat list of
|
||||
# frame-identity tags, so a test can assert *which* frame landed at each output
|
||||
# index. Skipped automatically if a real torch is installed.
|
||||
_torch = sys.modules["torch"]
|
||||
_FAKE_TORCH = not hasattr(_torch, "__version__")
|
||||
if _FAKE_TORCH:
|
||||
class _FakeTensor:
|
||||
"""Minimal stand-in: a list of opaque per-frame tags + an H/W/C shape."""
|
||||
|
||||
def __init__(self, data, hwc=(2, 2, 3)):
|
||||
self._data = list(data)
|
||||
self._hwc = tuple(hwc)
|
||||
self.device = "cpu"
|
||||
self.dtype = "float32"
|
||||
|
||||
@property
|
||||
def shape(self):
|
||||
return (len(self._data),) + self._hwc
|
||||
|
||||
def __len__(self):
|
||||
return len(self._data)
|
||||
|
||||
def __getitem__(self, key):
|
||||
if isinstance(key, int):
|
||||
return self._data[key]
|
||||
if isinstance(key, slice):
|
||||
return _FakeTensor(self._data[key], self._hwc)
|
||||
if isinstance(key, (list, tuple)):
|
||||
return _FakeTensor([self._data[i] for i in key], self._hwc)
|
||||
raise TypeError(key)
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
if isinstance(key, int):
|
||||
self._data[key] = value
|
||||
else:
|
||||
raise TypeError(key)
|
||||
|
||||
def __rsub__(self, other): # 1.0 - alpha; identity is fine for these tests
|
||||
return self
|
||||
|
||||
def _fake_full(shape, fill_value, device=None, dtype=None):
|
||||
return _FakeTensor([("placeholder", fill_value)] * shape[0], shape[1:])
|
||||
|
||||
def _fake_ones(shape, device=None, dtype=None):
|
||||
return _FakeTensor([1.0] * shape[0], shape[1:])
|
||||
|
||||
_torch.float32 = "float32"
|
||||
_torch.full = _fake_full
|
||||
_torch.ones = _fake_ones
|
||||
else: # pragma: no cover - real torch present, skip the integration tests
|
||||
_FakeTensor = None
|
||||
|
||||
_GRAY = ("placeholder", 0.5) # what _fake_full tags an unfilled frame with
|
||||
|
||||
|
||||
def _source(n):
|
||||
"""A fake source_batch whose frame k (0-based) is the tag ``f"src{k+1}"``."""
|
||||
return _FakeTensor([f"src{k + 1}" for k in range(n)])
|
||||
|
||||
|
||||
requires_fake_torch = pytest.mark.skipif(
|
||||
not _FAKE_TORCH, reason="behaviour tests rely on the fake-tensor stub"
|
||||
)
|
||||
|
||||
from k_easy_image_batch import ( # noqa: E402 (import after stub)
|
||||
easy_ImageBatch,
|
||||
parse_frame_tokens,
|
||||
plan_offset_placements,
|
||||
plan_slot_overwrites,
|
||||
plan_source_base_fill,
|
||||
)
|
||||
|
||||
|
||||
@@ -89,14 +161,115 @@ def test_cut_start_offsets_output_indices():
|
||||
assert plan.placements == [(0, 0, 41), (1, 22, 63)]
|
||||
|
||||
|
||||
def test_input_types_exposes_keyframe_batch_with_tooltip():
|
||||
def test_source_base_fill_full_window_maps_one_to_one():
|
||||
# cut_start=1: output i -> source i (source_batch[0] is VFX frame 1).
|
||||
placements, fallback = plan_source_base_fill(total_frames=5, cut_start_frame=1, base_len=10)
|
||||
assert placements == [(0, 0), (1, 1), (2, 2), (3, 3), (4, 4)]
|
||||
assert fallback == 0
|
||||
|
||||
|
||||
def test_source_base_fill_offsets_with_cut_start():
|
||||
# cut_start=41: output 0 -> source 40 (VFX frame 41), etc.
|
||||
placements, fallback = plan_source_base_fill(total_frames=3, cut_start_frame=41, base_len=121)
|
||||
assert placements == [(0, 40), (1, 41), (2, 42)]
|
||||
assert fallback == 0
|
||||
|
||||
|
||||
def test_source_base_fill_beyond_source_falls_back_to_placeholder():
|
||||
# base only covers 2 frames; the remaining 3 cut frames stay placeholder.
|
||||
placements, fallback = plan_source_base_fill(total_frames=5, cut_start_frame=1, base_len=2)
|
||||
assert placements == [(0, 0), (1, 1)]
|
||||
assert fallback == 3
|
||||
|
||||
|
||||
def test_source_base_fill_cut_starts_past_source_end():
|
||||
# Cut window starts beyond the source entirely → all placeholder.
|
||||
placements, fallback = plan_source_base_fill(total_frames=4, cut_start_frame=50, base_len=10)
|
||||
assert placements == []
|
||||
assert fallback == 4
|
||||
|
||||
|
||||
def test_slot_overwrites_basic_placement():
|
||||
# image1 @ VFX frame 4, cut_start 1 -> output index 3.
|
||||
placements, outside = plan_slot_overwrites([(0, 4)], total_frames=24, cut_start_frame=1)
|
||||
assert placements == [(0, 3, 4)]
|
||||
assert outside == []
|
||||
|
||||
|
||||
def test_slot_overwrites_higher_slot_wins_collision():
|
||||
# image1 (idx 0) and image3 (idx 2) both target VFX frame 10 -> the
|
||||
# higher-numbered slot wins, one winner per output index.
|
||||
placements, outside = plan_slot_overwrites(
|
||||
[(0, 10), (2, 10)], total_frames=24, cut_start_frame=1
|
||||
)
|
||||
assert placements == [(2, 9, 10)]
|
||||
assert outside == []
|
||||
|
||||
|
||||
def test_slot_overwrites_higher_slot_wins_regardless_of_input_order():
|
||||
# Same collision, supplied highest-first: result must still pick image3.
|
||||
placements, _ = plan_slot_overwrites(
|
||||
[(2, 10), (0, 10)], total_frames=24, cut_start_frame=1
|
||||
)
|
||||
assert placements == [(2, 9, 10)]
|
||||
|
||||
|
||||
def test_slot_overwrites_outside_cut_reported():
|
||||
# cut_start 10: frame 2 -> output -8 (outside); frame 15 -> 5 (in).
|
||||
placements, outside = plan_slot_overwrites(
|
||||
[(0, 2), (1, 15)], total_frames=20, cut_start_frame=10
|
||||
)
|
||||
assert placements == [(1, 5, 15)]
|
||||
assert outside == [2]
|
||||
|
||||
|
||||
def test_slot_overwrites_cut_start_offsets_output_indices():
|
||||
placements, _ = plan_slot_overwrites(
|
||||
[(0, 41), (1, 63)], total_frames=81, cut_start_frame=41
|
||||
)
|
||||
assert placements == [(0, 0, 41), (1, 22, 63)]
|
||||
|
||||
|
||||
def test_slot_overwrites_sorted_by_output_index():
|
||||
# Distinct frames, ascending by output index regardless of slot order.
|
||||
placements, _ = plan_slot_overwrites(
|
||||
[(0, 20), (1, 5)], total_frames=24, cut_start_frame=1
|
||||
)
|
||||
assert [out for _s, out, _v in placements] == [4, 19]
|
||||
|
||||
|
||||
def test_slot_overwrites_empty_input_places_nothing():
|
||||
placements, outside = plan_slot_overwrites([], total_frames=10, cut_start_frame=1)
|
||||
assert placements == []
|
||||
assert outside == []
|
||||
|
||||
|
||||
def test_input_types_exposes_keyframes_insert_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 "keyframes_insert" in spec["optional"], "insert-mode input must be declared"
|
||||
# `keyframe_batch` is retained as a deprecated alias so pre-rename workflows
|
||||
# keep loading (unreleased, but origin/main exposed it).
|
||||
assert "keyframe_batch" in spec["optional"]
|
||||
assert "deprecated" in spec["optional"]["keyframe_batch"][1]["tooltip"].lower()
|
||||
entry = spec["optional"]["keyframes_insert"]
|
||||
assert entry[0] == "IMAGE"
|
||||
tooltip = entry[1]["tooltip"].lower()
|
||||
assert "offset" in tooltip
|
||||
assert "frame list" in tooltip
|
||||
assert "insert" in tooltip
|
||||
assert "source_frames" in tooltip
|
||||
|
||||
|
||||
def test_input_types_exposes_width_height_fallback_at_end():
|
||||
spec = easy_ImageBatch.INPUT_TYPES()
|
||||
# Fallback-only and declared OPTIONAL so they append to the END of the
|
||||
# widget list — keeping positional widgets_values of older saved workflows
|
||||
# aligned (inserting them earlier shifts placeholder_color/source_frames).
|
||||
for key in ("width", "height"):
|
||||
assert key in spec["optional"], f"{key} fallback widget must be optional"
|
||||
assert key not in spec["required"]
|
||||
assert spec["optional"][key][0] == "INT"
|
||||
assert spec["optional"][key][1]["default"] == 512
|
||||
# They must be the last two optional keys (= last two widgets).
|
||||
assert list(spec["optional"])[-2:] == ["width", "height"]
|
||||
|
||||
|
||||
def test_registration_exports_unchanged():
|
||||
@@ -104,3 +277,300 @@ def test_registration_exports_unchanged():
|
||||
|
||||
assert NODE_CLASS_MAPPINGS["easy_ImageBatch"] is easy_ImageBatch
|
||||
assert NODE_DISPLAY_NAME_MAPPINGS["easy_ImageBatch"] == "Easy Image Batch (Koolook)"
|
||||
|
||||
|
||||
# --- Behaviour (layer compositing) tests, run against the fake-tensor stub ---
|
||||
|
||||
|
||||
@requires_fake_torch
|
||||
def test_select_slot_overwrites_list_pick_at_same_frame():
|
||||
# source pick at frame 5 AND image1 wired at frame 5 -> the slot wins.
|
||||
node = easy_ImageBatch()
|
||||
image_batch, _alpha, selected, frames = node.create_batch(
|
||||
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
|
||||
invert_alpha=False, source_frames="5",
|
||||
image1_frame=5, source_batch=_source(24), image1=_FakeTensor(["slotA"]),
|
||||
)
|
||||
assert image_batch[4] == "slotA" # output index 4 == VFX frame 5
|
||||
assert frames == "5"
|
||||
assert len(selected) == 1
|
||||
|
||||
|
||||
@requires_fake_torch
|
||||
def test_select_higher_slot_wins_at_same_frame():
|
||||
# image1 and image2 both target frame 10 -> the higher-numbered slot wins.
|
||||
node = easy_ImageBatch()
|
||||
image_batch, _alpha, _sel, _frames = node.create_batch(
|
||||
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
|
||||
invert_alpha=False, source_frames="",
|
||||
image1_frame=10, image1=_FakeTensor(["slot1"]),
|
||||
image2=_FakeTensor(["slot2"]), image2_frame=10,
|
||||
)
|
||||
assert image_batch[9] == "slot2"
|
||||
|
||||
|
||||
@requires_fake_torch
|
||||
def test_insert_slot_composites_on_top_of_inserts():
|
||||
# Inserts ins1/ins2/ins3 land at frames 5/7/20; image1 @ frame 5 wins there.
|
||||
node = easy_ImageBatch()
|
||||
image_batch, _alpha, _sel, _frames = node.create_batch(
|
||||
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
|
||||
invert_alpha=False, source_frames="5 7 20",
|
||||
image1_frame=5, keyframes_insert=_FakeTensor(["ins1", "ins2", "ins3"]),
|
||||
image1=_FakeTensor(["slotA"]),
|
||||
)
|
||||
assert image_batch[4] == "slotA" # slot beats the insert at frame 5
|
||||
assert image_batch[6] == "ins2" # other inserts untouched
|
||||
assert image_batch[19] == "ins3"
|
||||
|
||||
|
||||
@requires_fake_torch
|
||||
def test_select_empty_list_passes_source_through_with_no_phantom_picks():
|
||||
# EXE 2: source connected, empty list, no slots wired (defaults 5/9/13/17).
|
||||
# Whole batch is the source passthrough; nothing is "placed".
|
||||
node = easy_ImageBatch()
|
||||
image_batch, _alpha, selected, frames = node.create_batch(
|
||||
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
|
||||
invert_alpha=False, source_frames="",
|
||||
image1_frame=5, source_batch=_source(24),
|
||||
image2_frame=9, image3_frame=13, image4_frame=17,
|
||||
)
|
||||
assert [image_batch[i] for i in range(24)] == [f"src{k + 1}" for k in range(24)]
|
||||
assert frames == "" # background is never counted as placed
|
||||
assert len(selected) == 0
|
||||
|
||||
|
||||
@requires_fake_torch
|
||||
def test_select_nonempty_list_ignores_unwired_slot_defaults():
|
||||
# EXE 3: only the listed picks are placed; the unwired slots' default
|
||||
# imageN_frame values (9/13/17) do NOT pull phantom frames from source.
|
||||
node = easy_ImageBatch()
|
||||
image_batch, _alpha, _sel, frames = node.create_batch(
|
||||
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
|
||||
invert_alpha=False, source_frames="5 8 20",
|
||||
image1_frame=5, source_batch=_source(24),
|
||||
image2_frame=9, image3_frame=13, image4_frame=17,
|
||||
)
|
||||
assert frames == "5, 8, 20"
|
||||
assert image_batch[4] == "src5"
|
||||
assert image_batch[7] == "src8"
|
||||
assert image_batch[19] == "src20"
|
||||
assert image_batch[8] == _GRAY # frame 9 stays placeholder, no phantom pick
|
||||
|
||||
|
||||
@requires_fake_torch
|
||||
def test_selected_image_batch_is_picks_plus_slots_slots_win_overlap():
|
||||
# selected_image_batch = union of multiline picks AND slot overwrites,
|
||||
# ascending by frame, with the slot winning where they overlap.
|
||||
node = easy_ImageBatch()
|
||||
_img, _alpha, selected, frames = node.create_batch(
|
||||
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
|
||||
invert_alpha=False, source_frames="5 8",
|
||||
image1_frame=5, source_batch=_source(24),
|
||||
image1=_FakeTensor(["slotA"]), # overlaps the pick at frame 5
|
||||
image2=_FakeTensor(["slotB"]), image2_frame=12, # slot-only position
|
||||
)
|
||||
assert frames == "5, 8, 12"
|
||||
assert [selected[i] for i in range(len(selected))] == ["slotA", "src8", "slotB"]
|
||||
|
||||
|
||||
@requires_fake_torch
|
||||
def test_selected_image_batch_includes_inserts_and_slots_in_insert_mode():
|
||||
# Same rule under insert mode: selected = inserts + slot overwrites.
|
||||
node = easy_ImageBatch()
|
||||
_img, _alpha, selected, frames = node.create_batch(
|
||||
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
|
||||
invert_alpha=False, source_frames="5 7 20",
|
||||
image1_frame=4, keyframes_insert=_FakeTensor(["ins1", "ins2", "ins3"]),
|
||||
image1=_FakeTensor(["slotA"]), # slot-only position at frame 4
|
||||
)
|
||||
assert frames == "4, 5, 7, 20"
|
||||
assert [selected[i] for i in range(len(selected))] == ["slotA", "ins1", "ins2", "ins3"]
|
||||
|
||||
|
||||
# --- Feature: range syntax in the frame list (parse_frame_tokens) ---
|
||||
|
||||
|
||||
def test_parse_frame_tokens_expands_inclusive_ranges():
|
||||
values, bad = parse_frame_tokens("1-5, 7, 9, 14-17")
|
||||
assert values == [1, 2, 3, 4, 5, 7, 9, 14, 15, 16, 17]
|
||||
assert bad == []
|
||||
|
||||
|
||||
def test_parse_frame_tokens_mixed_separators():
|
||||
values, bad = parse_frame_tokens("1-3 7\n10")
|
||||
assert values == [1, 2, 3, 7, 10]
|
||||
assert bad == []
|
||||
|
||||
|
||||
def test_parse_frame_tokens_single_value_range():
|
||||
values, bad = parse_frame_tokens("7-7")
|
||||
assert values == [7]
|
||||
assert bad == []
|
||||
|
||||
|
||||
def test_parse_frame_tokens_descending_range_is_bad():
|
||||
values, bad = parse_frame_tokens("5-1, 8")
|
||||
assert values == [8]
|
||||
assert bad == ["5-1"]
|
||||
|
||||
|
||||
def test_parse_frame_tokens_non_integer_tokens_are_bad():
|
||||
values, bad = parse_frame_tokens("3, foo, 5-x, 9")
|
||||
assert values == [3, 9]
|
||||
assert bad == ["foo", "5-x"]
|
||||
|
||||
|
||||
@requires_fake_torch
|
||||
def test_select_accepts_range_syntax_in_list():
|
||||
node = easy_ImageBatch()
|
||||
_img, _alpha, _sel, frames = node.create_batch(
|
||||
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
|
||||
invert_alpha=False, source_frames="1-3, 5",
|
||||
image1_frame=4, source_batch=_source(24),
|
||||
)
|
||||
assert frames == "1, 2, 3, 5"
|
||||
|
||||
|
||||
@requires_fake_torch
|
||||
def test_insert_accepts_range_syntax_in_positions():
|
||||
node = easy_ImageBatch()
|
||||
_img, _alpha, _sel, frames = node.create_batch(
|
||||
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
|
||||
invert_alpha=False, source_frames="5-7",
|
||||
image1_frame=4, keyframes_insert=_FakeTensor(["a", "b", "c"]),
|
||||
)
|
||||
assert frames == "5, 6, 7"
|
||||
|
||||
|
||||
# --- Feature: passthrough shorter than output -> keep + gap-as-selection ---
|
||||
|
||||
|
||||
@requires_fake_torch
|
||||
def test_passthrough_shorter_source_keeps_covered_and_selects_gap():
|
||||
# source = 17 frames, output = 24: frames 1-17 are kept (alpha 0/black),
|
||||
# frames 18-24 are the gap (alpha 1/white) and become the selection.
|
||||
node = easy_ImageBatch()
|
||||
image_batch, alpha, selected, frames = node.create_batch(
|
||||
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
|
||||
invert_alpha=False, source_frames="",
|
||||
image1_frame=4, source_batch=_source(17),
|
||||
)
|
||||
# image: 1-17 source, 18-24 placeholder
|
||||
assert image_batch[16] == "src17"
|
||||
assert image_batch[17] == _GRAY
|
||||
# alpha (inpaint): covered black (0.0), gap white (1.0)
|
||||
assert alpha[0] == 0.0 and alpha[16] == 0.0
|
||||
assert alpha[17] == 1.0 and alpha[23] == 1.0
|
||||
# selection = the gap frames 18..24 (to inpaint)
|
||||
assert frames == "18, 19, 20, 21, 22, 23, 24"
|
||||
assert len(selected) == 7
|
||||
assert selected[0] == _GRAY
|
||||
|
||||
|
||||
@requires_fake_torch
|
||||
def test_passthrough_full_coverage_selects_nothing():
|
||||
# source >= output: no gap -> alpha all black (kept), selection empty.
|
||||
node = easy_ImageBatch()
|
||||
image_batch, alpha, selected, frames = node.create_batch(
|
||||
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
|
||||
invert_alpha=False, source_frames="",
|
||||
image1_frame=4, source_batch=_source(24),
|
||||
)
|
||||
assert [image_batch[i] for i in range(24)] == [f"src{k + 1}" for k in range(24)]
|
||||
assert alpha[0] == 0.0 and alpha[23] == 0.0
|
||||
assert frames == ""
|
||||
assert len(selected) == 0
|
||||
|
||||
|
||||
@requires_fake_torch
|
||||
def test_passthrough_slot_in_gap_counts_as_kept_not_gap():
|
||||
# A slot filling a gap frame becomes kept content, excluded from the
|
||||
# inpaint selection.
|
||||
node = easy_ImageBatch()
|
||||
_img, alpha, _sel, frames = node.create_batch(
|
||||
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
|
||||
invert_alpha=False, source_frames="",
|
||||
image1_frame=20, source_batch=_source(17), image1=_FakeTensor(["slotA"]),
|
||||
)
|
||||
assert alpha[19] == 0.0 # frame 20 filled by slot -> kept
|
||||
assert frames == "18, 19, 21, 22, 23, 24" # gap excludes frame 20
|
||||
|
||||
|
||||
@requires_fake_torch
|
||||
def test_passthrough_gap_with_cut_start_offset():
|
||||
# cut_start=41, total=10 -> output is VFX 41..50. source has 45 frames, so
|
||||
# VFX 41..45 are covered (output 0..4) and 46..50 are the gap.
|
||||
node = easy_ImageBatch()
|
||||
image_batch, alpha, _sel, frames = node.create_batch(
|
||||
total_frames=10, cut_start_frame=41, placeholder_color="Gray",
|
||||
invert_alpha=False, source_frames="",
|
||||
image1_frame=4, source_batch=_source(45),
|
||||
)
|
||||
assert image_batch[0] == "src41" and image_batch[4] == "src45"
|
||||
assert image_batch[5] == _GRAY # VFX 46 = gap
|
||||
assert alpha[0] == 0.0 and alpha[4] == 0.0 # covered kept
|
||||
assert alpha[5] == 1.0 and alpha[9] == 1.0 # gap to inpaint
|
||||
assert frames == "46, 47, 48, 49, 50" # gap selection
|
||||
|
||||
|
||||
# --- Review-round fixes: deprecated alias + input guards ---
|
||||
|
||||
|
||||
def test_parse_frame_tokens_rejects_overlong_range():
|
||||
values, bad = parse_frame_tokens("1-99999999, 7")
|
||||
assert values == [7]
|
||||
assert bad == ["1-99999999"]
|
||||
|
||||
|
||||
def test_parse_frame_tokens_allows_large_bounded_range():
|
||||
values, bad = parse_frame_tokens("1-100")
|
||||
assert len(values) == 100 and bad == []
|
||||
|
||||
|
||||
def test_parse_frame_tokens_range_span_boundary():
|
||||
# exactly _MAX_RANGE_SPAN (8192) frames is allowed; one more is rejected.
|
||||
ok, bad = parse_frame_tokens("1-8192")
|
||||
assert len(ok) == 8192 and bad == []
|
||||
over, bad_over = parse_frame_tokens("1-8193")
|
||||
assert over == [] and bad_over == ["1-8193"]
|
||||
|
||||
|
||||
@requires_fake_torch
|
||||
def test_keyframe_batch_alias_routes_to_insert_mode():
|
||||
# A pre-rename workflow wires `keyframe_batch` (not `keyframes_insert`); it
|
||||
# must still behave like an insert.
|
||||
node = easy_ImageBatch()
|
||||
_img, _alpha, _sel, frames = node.create_batch(
|
||||
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
|
||||
invert_alpha=False, source_frames="5 7 20",
|
||||
image1_frame=4, keyframe_batch=_FakeTensor(["a", "b", "c"]),
|
||||
)
|
||||
assert frames == "5, 7, 20"
|
||||
|
||||
|
||||
@requires_fake_torch
|
||||
def test_keyframes_insert_wins_over_deprecated_alias():
|
||||
node = easy_ImageBatch()
|
||||
_img, _alpha, selected, _frames = node.create_batch(
|
||||
total_frames=24, cut_start_frame=1, placeholder_color="Gray",
|
||||
invert_alpha=False, source_frames="5",
|
||||
image1_frame=4,
|
||||
keyframes_insert=_FakeTensor(["new"]), keyframe_batch=_FakeTensor(["old"]),
|
||||
)
|
||||
assert selected[0] == "new"
|
||||
|
||||
|
||||
@requires_fake_torch
|
||||
def test_nonempty_list_without_image_source_warns_and_returns_clean(capsys):
|
||||
# source_frames set but no source_batch / imageN -> clean placeholder batch,
|
||||
# with a warning that the list was ignored (no silent drop).
|
||||
node = easy_ImageBatch()
|
||||
image_batch, _alpha, selected, frames = node.create_batch(
|
||||
total_frames=5, cut_start_frame=1, placeholder_color="Gray",
|
||||
invert_alpha=False, source_frames="2 3",
|
||||
image1_frame=4, width=8, height=8,
|
||||
)
|
||||
assert len(image_batch) == 5
|
||||
assert frames == "" and len(selected) == 0
|
||||
assert "source_frames is set but no image source" in capsys.readouterr().out
|
||||
|
||||
Reference in New Issue
Block a user