diff --git a/CHANGELOG.md b/CHANGELOG.md index 86aacde..f974be8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/designs/easy-image-batch-behavior.md b/docs/designs/easy-image-batch-behavior.md new file mode 100644 index 0000000..56700b9 --- /dev/null +++ b/docs/designs/easy-image-batch-behavior.md @@ -0,0 +1,141 @@ + +# 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. diff --git a/docs/user_guide/img/Easy_Image_Batch_v0.1.5.png b/docs/user_guide/img/Easy_Image_Batch_v0.1.5.png deleted file mode 100644 index 873ec80..0000000 Binary files a/docs/user_guide/img/Easy_Image_Batch_v0.1.5.png and /dev/null differ diff --git a/docs/user_guide/nodes/koolook_image/easy_image_batch.md b/docs/user_guide/nodes/koolook_image/easy_image_batch.md index 6040f4e..c7288b3 100644 --- a/docs/user_guide/nodes/koolook_image/easy_image_batch.md +++ b/docs/user_guide/nodes/koolook_image/easy_image_batch.md @@ -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. diff --git a/k_easy_image_batch.py b/k_easy_image_batch.py index 5898980..cc705b8 100644 --- a/k_easy_image_batch.py +++ b/k_easy_image_batch.py @@ -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}" ) diff --git a/pyproject.toml b/pyproject.toml index fbce1a8..94787cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/tests/nodes/test_easy_image_batch.py b/tests/nodes/test_easy_image_batch.py index 3858c06..d3fd233 100644 --- a/tests/nodes/test_easy_image_batch.py +++ b/tests/nodes/test_easy_image_batch.py @@ -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