feat(video): add Easy_VideoCombine (#154)

* feat(video): add Easy_VideoCombine with absolute-path support

Adds a Koolook subclass of VHS VideoCombine that bypasses ComfyUI's
output-directory sandbox when filename_prefix is an absolute path —
the VFX-pipeline missing piece for writing renders into a project
tree (e.g. E:/projects/shot01/v003) instead of ComfyUI/output/.

Implementation:
- k_video_combine.py: ~60 LoC subclass + a pure _resolve_abs_target
  helper. Scoped-patches folder_paths.get_save_image_path for the
  duration of one combine_video() call so upstream's encoder, audio
  mux, batch manager, and progress bar run unchanged. No VHS code
  copied. Self-skips registration when VHS isn't importable (clean
  one-line stderr notice).
- Discriminator: os.path.isabs(filename_prefix). No new input pin;
  pattern borrowed from spacepxl/ComfyUI-HQ-Image-Save's SaveEXR.
- Optional create_path_if_missing BOOLEAN (default False) so typos
  surface as FileNotFoundError instead of spawning stray dirs.

License audit (2026-05-16):
- VHS upstream: GPL-3.0 (verified via raw LICENSE fetch).
- HQ-Image-Save pattern source: MIT, copyright 2023 spacepxl.
- Koolook: already GPL-3.0; both upstreams are compatible.
- No source code from either upstream is incorporated.

Verification: 18 pytest cases green (12 existing + 6 new covering
relative passthrough, absolute resolution, create-on/off branches,
trailing-separator normalization, and empty-basename rejection).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(starter-preset): pre-favourite Easy_VideoCombine

Adds the new node to the shipped starter_preset.json picks so fresh
installs surface it in the Kforge Labs sidebar's Nodes section without
the user having to discover it via Comfy's stock search first. Pairs
naturally with the existing LoadEXR / SaveEXRFrames picks from
HQ-Image-Save.

Existing installs with non-empty picks are unaffected (the seeder
skips them by design); they can either Load -> "starter" in the
Snapshot dialog or hit "+" once in the Nodes section to pull it in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(dev-sync): include k_video_combine.py in RUNTIME_PATHS

Without this entry the new Easy_VideoCombine module is never copied
into the dev ComfyUI install, so the node fails to register on
restart with a silent ImportError. Caught when the maintainer
restarted ComfyUI and couldn't find the node in the search palette.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(starter-preset): include all Koolook nodes in default picks

Fresh installs should land on a populated Nodes (favorites) panel
that immediately surfaces what Koolook provides — otherwise new
users see only third-party picks and have no entry point into the
Koolook-native workflow. Adds the 8 currently-registered Koolook
node IDs to the shipped starter preset alongside the existing
third-party essentials:

  EasyAIPipeline, EasyResize_Koolook, EasyWan22Prompt,
  Easy_Pattern, Easy_VideoCombine, Easy_hdr_VAE_decode,
  Easy_hdr_VAE_encode, KoolookLoadCameraPosesAbsolute,
  easy_ImageBatch

Also adds Easy_VideoCombine to the README's Included Nodes table.

Existing installs with non-empty picks are unaffected by the seeder
(by design). To pull the Koolook defaults into an existing install,
Quick Save first, then Snapshot -> Load -> "starter".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(video): reach VHS_VideoCombine via nodes.NODE_CLASS_MAPPINGS

The previous `from videohelpersuite.nodes import VideoCombine` fails
inside ComfyUI's loader context because VHS uses relative imports
throughout (`from .logger ...`, `from .utils ...`) that only resolve
when VHS is loaded through ComfyUI's importlib spec — not through a
plain absolute import path. The error surfaced as

    No module named 'utils.install_util'; 'utils' is not a package

at Koolook load time, which caused the node to self-skip with an
empty NODE_CLASS_MAPPINGS — invisible in ComfyUI's search palette.

Fix: look up VHS_VideoCombine through `nodes.NODE_CLASS_MAPPINGS`,
which is the documented cross-pack interface. ComfyUI's loader
(custom_nodes/.../__init__.py exec_module → merge into global) runs
to completion for each pack before moving to the next, and VHS sorts
before Koolook alphabetically in every layout we've seen, so by the
time Koolook's __init__.py runs the global registry already has
VHS_VideoCombine in it.

Verified against ComfyUI's nodes.py load_custom_node() at
load_custom_node() lines 2196 (exec_module) and 2225-2228 (merge),
which guarantee this ordering.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(video): add output_directory input for split path/name editing

Overloading filename_prefix for absolute paths means losing the
"just type a name" affordance — once you put the path there, there's
nowhere to specify just the file root. Across many renders into the
same project tree this becomes tedious: every iteration retypes the
whole path.

Adds an optional `output_directory` STRING input. When set:
  - filename_prefix is treated as just the filename root; any path
    components are stripped (so accidental typos like "sub/clip"
    don't double-up under the directory).
  - Absolute output_directory writes directly to that location.
  - Relative output_directory joins under ComfyUI's output/.

When output_directory is empty, the original overloaded
filename_prefix behavior is preserved unchanged (back-compat for
the one-field flow).

Implementation:
- New pure helper `_compose_prefix(filename_prefix, output_directory)`
  combines the two fields into one effective prefix. The existing
  isabs discrimination then handles sandboxed vs absolute writes
  uniformly downstream.
- 6 new tests cover all combinations: empty/abs/relative directory,
  path stripping from filename_prefix, trailing-separator trim,
  empty-name fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(video): coerce frontend 'undefined' STRING values to empty

The ComfyUI frontend serializes a STRING widget with empty default
as the literal string "undefined" when left untouched (a known
long-running quirk). Without defensive normalization, an untouched
output_directory was treated as a real directory name and prepended
to the user's path:

  filename_prefix = "E:/renders/clip_v003" (typed by user)
  output_directory = "undefined" (frontend default, not typed)

  _compose_prefix(...) -> "undefined/clip_v003"      (relative)
  _resolve_abs_target -> None (not absolute)
  super().combine_video -> writes to <output>/undefined/clip_v003_*

The QuickTime ended up at <ComfyUI>/output/undefined/<name>.mov
with a spurious `undefined/` subfolder, instead of at the absolute
path the user actually specified.

Fix: a `_normalize_text_input()` helper coerces the literal strings
"undefined" / "null" / "None" (case-insensitive, post-strip) to "".
Applied to both filename_prefix and output_directory at the top of
combine_video() before any path composition. 4 new tests cover the
sentinels and a regression test for the exact path-composition
behavior that produced the bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(video-fe): wire format-specific widgets for Easy_VideoCombine

VHS's frontend extension (web/js/VHS.core.js:2030) gates its
format-widget machinery on `nodeData?.name == "VHS_VideoCombine"` —
strict-equality on the registered node ID. Our subclass uses a
different ID (`Easy_VideoCombine`), so none of VHS's per-format
widget rendering ran. The user saw:

    [VideoHelperSuite] - WARNING - Missing input for p has been set to hq

on every execution (profile defaulting to "hq" for ProRes), and
had no way to pick anything other than the default — because the
profile dropdown was never rendered.

Fix: a minimal frontend extension at web/easy_video_combine.js
(~80 LoC) that replicates `addFormatWidgets` for our node name.
Hooks the format dropdown's callback to add/remove per-format
widgets the same way upstream does. Self-contained — does not
import from VHS's module (their helpers aren't exported as ES
imports).

Verifies via the actual ComfyUI install after dev-sync; static
preview servers can't exercise it (depends on LiteGraph's
registered_node_types at runtime).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(video): strip 'undefined' from anywhere + one-shot debug log

The previous normalization only caught the EXACT literal "undefined"
/ "null" / "None" at field-level. Adds two stronger defenses for the
case where a sentinel still slips through:

1. _normalize_text_input now also strips surrounding quotes (paste-
   from-Explorer artifact) and handles embedded newlines by taking
   the first non-empty non-sentinel line (defends against multi-line
   Text Multiline inputs with trailing blank or junk lines).
2. New _strip_sentinel_components helper drops any path component
   that is exactly a sentinel string from the composed effective_
   prefix — belt-and-suspenders for any path that somehow carries
   `\undefined\` as a literal segment by the time it reaches us.

Also adds a one-shot print() of fp_raw, od_raw, normalized values,
and effective_prefix at the top of combine_video. The user is
seeing an undefined/ folder under their custom path despite the
field-level normalization, and pure code tracing doesn't reproduce
it — the log will surface what kwargs actually contains at run
time so we can fix the real cause if defensive stripping doesn't
already handle it. To be removed in a follow-up once root cause
is known.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(video): trim node docstring + tooltips, remove debug print

- Class docstring collapsed from ~20 lines to a single sentence (the
  frontend was showing it as the node description on right-click,
  cluttering the UI).
- Widget tooltips trimmed to one short sentence each — same intent,
  no walls of text.
- Debug print() added in 943be67 removed (defensive strip handled
  the undefined/ bug; no further diagnosis needed).
- Local fp_raw / od_raw variables (only used by the debug print)
  collapsed back into direct call sites.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(video): suppress metadata PNG + silent intermediate by default

Upstream VHS writes 3 files per render when audio is connected:
  - <name>_NNNNN.png      (first-frame sidecar with workflow metadata)
  - <name>_NNNNN.mov      (silent intermediate)
  - <name>_NNNNN-audio.mov (final audio-muxed video)

Both extras are controlled by hidden VHS_MetadataImage and
VHS_KeepIntermediate flags in extra_pnginfo['workflow']['extra'],
both default-on upstream. Result: a single 500MB render lands as
three files (one needed, two clutter), nearly 2x the disk footprint
and a confusing folder.

Adds two optional BOOLEAN inputs (both default False on Koolook):
  - save_metadata_png
  - keep_silent_intermediate

combine_video injects them into extra_pnginfo before delegating to
super(), so the existing upstream flags do the actual work — no
post-hoc file deletion, no fork divergence from upstream's encoder
path. The extra_pnginfo dict is copied (not mutated in place) so
other nodes in the workflow that reference the same dict are
unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* diag(video): log frame-count + audio length, restore PNG default-on

Two changes to investigate a doubled-length output report:

1. Default save_metadata_png back to True (was False since 946e170).
   The PNG sidecar carries the embedded workflow JSON — it's the
   only audit trail when a render comes out wrong. Removing it by
   default left no way to diagnose retroactively. Users who don't
   want the sidecar can still toggle it off explicitly; ones who do
   want auditability (most VFX pipelines) get it automatically.

2. One-shot diagnostic print() at the top of combine_video logging:
   - input image batch size + shape
   - audio sample count + sample rate -> seconds
   - frame_rate -> expected video seconds
   - pingpong + loop_count

This pins down whether a doubled-length output is the image stream
arriving already-doubled (workflow issue: ImageConcatMulti in batch
mode, an upstream pingpong, etc.) vs combine_video itself doing
something with the frames. To be removed in a follow-up once the
current report is diagnosed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(video): restore Easy_VideoCombine widgets by name

* feat(video): polish Easy_VideoCombine review workflow

* test(video): fix Easy_VideoCombine pytest coverage

* docs: require repo-local Python venv

* fix(video): handle existing silent mux intermediates

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
malkuthro
2026-05-17 14:53:18 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 55dab39c9b
commit ff79b0f418
18 changed files with 1356 additions and 2 deletions
+2
View File
@@ -33,6 +33,8 @@ ComfyUI-Koolook is a custom-node pack for ComfyUI. Two shipped surfaces:
with front-end widgets in [`web/ai_pipeline.js`](../web/ai_pipeline.js)).
- [`k_easy_image_batch.py`](../k_easy_image_batch.py) → `easy_ImageBatch`.
- [`k_easy_track.py`](../k_easy_track.py) → `KoolookLoadCameraPosesAbsolute`.
- [`k_video_combine.py`](../k_video_combine.py) → `Easy_VideoCombine`
(runtime subclass of VHS `VideoCombine`; self-skips if VHS not installed).
- Radiance VAE wrappers under
[`forks/radiance_koolook/versions/v2_3_3/`](../forks/radiance_koolook/versions/v2_3_3/)
→ `Easy_hdr_VAE_encode`, `Easy_hdr_VAE_decode`.
+1
View File
@@ -1,5 +1,6 @@
_safe*
.venv/
.tmp/
Runpod_Comfy/
PLAN.md
+39
View File
@@ -6,6 +6,45 @@ The format is inspired by Keep a Changelog and SemVer.
## [Unreleased]
### Added
- **Easy Video Combine (`Easy_VideoCombine`).** A path-aware sibling
of Kosinkadink/ComfyUI-VideoHelperSuite's `Video Combine` node.
When the `filename_prefix` field carries an absolute path
(e.g. `E:/renders/shot01/v003`), the node bypasses ComfyUI's
output-directory sandbox and writes the video, metadata PNG, and
any audio mux directly to that location. Relative prefixes behave
identically to upstream (sandboxed under ComfyUI's `output/`).
Adds an optional `create_path_if_missing` BOOLEAN (default
`False`) — when on, the parent directory is auto-created; when
off, a missing parent surfaces as a clear error so typos don't
silently spawn directories.
Implemented as a thin subclass of VHS's `VideoCombine` (~60 lines
of Python, no upstream code copied — see
[`k_video_combine.py`](k_video_combine.py)) that scoped-patches
`folder_paths.get_save_image_path` for the duration of one
`combine_video()` call. All encoding, format widgets, audio mux,
batch-manager, and progress-bar behavior comes from VHS unchanged.
If VHS isn't installed the node simply doesn't register, with a
one-line stderr notice at module load.
The `os.path.isabs(filename_prefix)` discrimination pattern is
borrowed from spacepxl/ComfyUI-HQ-Image-Save's `SaveEXR` (MIT) —
overload the existing field for both modes rather than adding a
new input pin. See
[`forks/THIRD_PARTY.md`](forks/THIRD_PARTY.md) for attribution
and [`docs/user_guide/nodes/koolook_video/easy_video_combine.md`](docs/user_guide/nodes/koolook_video/easy_video_combine.md)
for the user-facing guide.
Also adds an optional **`output_directory`** STRING input (default
empty). When set, `filename_prefix` is treated as just the
filename root and `output_directory` carries the path — so you
can change the name without retyping the directory across many
renders. Absolute `output_directory` writes there directly;
relative `output_directory` joins under ComfyUI's `output/`. The
overloaded-absolute-`filename_prefix` mode is preserved
unchanged for users who prefer a single field.
### Changed
- **Workflow right-click menu shortened for large libraries.** The menu no
longer lists every existing folder as a move destination, which could push
+8
View File
@@ -10,6 +10,14 @@
## Hard rules
- Do not vendor/copy full third-party repositories into MAIN.
- Install Python dev/test dependencies into a repo-local `.venv` whenever
possible. Do not install into system Python or user-site packages as a
shortcut. From a fresh checkout/worktree use:
`python -m venv .venv`, then
`.venv\Scripts\python -m pip install -e ".[test]"` on Windows (or
`.venv/bin/python -m pip install -e '.[test]'` on POSIX), and run tests
through that interpreter. If `.venv` cannot be used, stop and explain the
blocker before installing anywhere broader.
- Keep MAIN limited to:
- custom Koolook nodes
- wrapper loaders under `forks/`
+1
View File
@@ -44,6 +44,7 @@ Using it inside ComfyUI? Open the operational guide:
| `Easy_hdr_VAE_encode` | Easy HDR VAE Encode (Koolook) | `Koolook/VAE` |
| `Easy_hdr_VAE_decode` | Easy HDR VAE Decode (Koolook) | `Koolook/VAE` |
| `Easy_Pattern` | Easy Pattern (Koolook) | `Koolook/Testing` |
| `Easy_VideoCombine` | Easy Video Combine (Koolook) | `Koolook/Video` |
The root Koolook nodes and the slim Radiance Koolook VAE wrappers have no
extra Python dependencies beyond `torch`, which ComfyUI already requires.
+8
View File
@@ -17,6 +17,12 @@ try:
from .k_easy_pattern import NODE_CLASS_MAPPINGS as pattern_mappings
from .k_easy_pattern import NODE_DISPLAY_NAME_MAPPINGS as pattern_display
# Easy_VideoCombine self-skips registration when VHS isn't installed
# (its inner try/import returns empty mappings), so importing here is
# always safe even on workstations without ComfyUI-VideoHelperSuite.
from .k_video_combine import NODE_CLASS_MAPPINGS as video_combine_mappings
from .k_video_combine import NODE_DISPLAY_NAME_MAPPINGS as video_combine_display
from .forks.radiance_koolook import NODE_CLASS_MAPPINGS as radiance_koolook_mappings
from .forks.radiance_koolook import NODE_DISPLAY_NAME_MAPPINGS as radiance_koolook_display
except ImportError as _node_import_exc:
@@ -60,6 +66,7 @@ else:
**batch_mappings,
**cam_loader_mappings,
**pattern_mappings,
**video_combine_mappings,
**radiance_koolook_mappings,
}
@@ -70,6 +77,7 @@ else:
**batch_display,
**cam_loader_display,
**pattern_display,
**video_combine_display,
**radiance_koolook_display,
}
@@ -0,0 +1,138 @@
# Easy Video Combine (Koolook)
| Aspect | Value |
|---|---|
| Display name | `Easy Video Combine (Koolook)` |
| Node ID | `Easy_VideoCombine` |
| ComfyUI category | `Koolook/Video` |
| Source | Koolook subclass of VHS `VideoCombine` (runtime composition) |
| Source file | [`k_video_combine.py`](../../../../k_video_combine.py) |
| Requires | [Kosinkadink/ComfyUI-VideoHelperSuite](https://github.com/Kosinkadink/ComfyUI-VideoHelperSuite) installed alongside Koolook |
## What it does
Same as VHS's standard `Video Combine` — gif / webp / mp4 / ProRes / any
configured format from a stack of frames, optional audio mux, optional
batch-manager streaming for long sequences — **except** it can write the
output directly to an absolute path on disk instead of being confined to
ComfyUI's `output/` directory.
Designed for VFX / facility pipelines where renders need to land under a
project tree (`E:/projects/shot01/comp/v003/`) rather than in ComfyUI's
local sandbox.
## How it picks where to save
Two ergonomic modes — pick the one you prefer:
### Mode A — Split (recommended): `output_directory` + `filename_prefix`
| `output_directory` | `filename_prefix` | Where the file lands |
|---|---|---|
| `E:/renders/shot01` | `clip_v003` | `E:/renders/shot01/clip_v003_<counter>.<ext>` |
| `/mnt/projects/shot01` | `clip_v003` | `/mnt/projects/shot01/clip_v003_<counter>.<ext>` |
| `shots/v003` | `clip` | `<ComfyUI>/output/shots/v003/clip_<counter>.<ext>` (relative dir = sandboxed) |
| `E:/renders/shot01/` (trailing slash) | `clip` | `E:/renders/shot01/clip_<counter>.<ext>` (trailing slash trimmed) |
When `output_directory` is set, only the basename of `filename_prefix`
is used as the file root — any path components you accidentally typed
there are stripped. This is the field layout to use when you do many
renders into the same directory with different names: type the
directory once, then just change the name per render.
### Mode B — Combined (overload): `filename_prefix` only
Leave `output_directory` empty and put everything in `filename_prefix`:
| `filename_prefix` | Where the file lands |
|---|---|
| `AnimateDiff`, `clip01`, `shots/v003` | `<ComfyUI>/output/<prefix>_<counter>.<ext>` (upstream behavior, sandboxed) |
| `E:/renders/shot01/v003` (Windows, `v003` does not already exist as a folder) | `E:/renders/shot01/v003_<counter>.<ext>` |
| `/mnt/projects/shot01/v003` (Linux/Mac, `v003` does not already exist as a folder) | `/mnt/projects/shot01/v003_<counter>.<ext>` |
| `E:/renders/shot01/v003/` or an existing `E:/renders/shot01/v003` folder | `E:/renders/shot01/v003/v003_<counter>.<ext>` (`v003` becomes the folder and file root) |
Both modes use the same `create_path_if_missing` toggle. Use Mode A
when you want an unambiguous fixed directory and changing filename;
Mode B keeps the old single-field workflow for quick pasted paths.
VHS's existing counter (`_00001`, `_00002`, …) and extension are
appended exactly as in upstream.
## Inputs
Same as VHS `Video Combine` plus one Koolook-specific toggle.
### Required widgets
| Input | Type | Default | Description |
|---|---|---|---|
| `images` | `IMAGE` or `LATENT` | — | Same as upstream. Latents need a connected `vae`. |
| `frame_rate` | `FLOAT` / `INT` | `8` | Same as upstream. |
| `loop_count` | `INT` | `0` | Same as upstream (gif/webp loop count). |
| `filename_prefix` | `STRING` | `AnimateDiff` | **Dual-mode.** Relative → ComfyUI's `output/`. Absolute → writes there directly. See the table above. |
| `format` | enum | `image/gif` | Same as upstream — gif, webp, or any ffmpeg-registered video format. |
| `pingpong` | `BOOLEAN` | `false` | Same as upstream. |
| `save_output` | `BOOLEAN` | `true` | Same as upstream when `filename_prefix` is relative. When `filename_prefix` is absolute, this toggle is ignored — the absolute path wins. |
### Optional inputs
| Input | Type | Description |
|---|---|---|
| `audio` | `AUDIO` | Same as upstream. |
| `meta_batch` | `VHS_BatchManager` | Same as upstream. |
| `vae` | `VAE` | Same as upstream — required when `images` is a `LATENT`. |
| `output_directory` | `STRING` (default empty) | **Koolook-only.** Optional output directory. Absolute (e.g. `E:/renders/shot01`) writes there directly; relative (e.g. `shots/v003`) joins under ComfyUI's `output/`. When set, `filename_prefix` is treated as just the filename root (path components stripped) — use this when you want to keep the directory fixed across many renders and only vary the name. Leave empty to let `filename_prefix` carry the whole path. |
| `create_path_if_missing` | `BOOLEAN` (default `false`) | **Koolook-only.** When the resolved output directory does not exist, auto-create it (recursively). Off by default so typos surface as errors instead of silently spawning directory trees. |
## Outputs
Identical to upstream — `Filenames` (`VHS_FILENAMES`) carrying the list
of files actually written. For absolute-path renders the entries are
fully-qualified paths.
## UI preview caveat
ComfyUI's frontend serves preview thumbnails through its `/view` HTTP
endpoint, which is locked to files under the local output directory. So:
- **Relative `filename_prefix`** — the workflow tile shows the standard
inline video preview, same as upstream.
- **Absolute `filename_prefix`** — the file *is written* (you'll see it
on disk and in the `Filenames` output), but the inline preview tile
stays empty because Comfy can't fetch files outside its sandbox.
This is a Comfy frontend limitation, not a Koolook one. The render is
fine; just open the file with your favourite player.
## How it works under the hood
`Easy_VideoCombine` is a Python subclass of VHS's
`videohelpersuite.nodes.VideoCombine`. Its `combine_video()` does
exactly two things before delegating back to upstream:
1. Looks at `filename_prefix`. If it's relative, calls
`super().combine_video(...)` unchanged — pure passthrough.
2. If it's absolute, scoped-patches `folder_paths.get_save_image_path`
to return the absolute parent directory and basename, then calls
`super().combine_video(...)`. A `try/finally` restores the original
function whether the call succeeds or raises.
No VHS source is copied. Every encoder bug fix, new format, audio
improvement, or batch-manager enhancement that lands upstream flows
through automatically the next time you update VHS.
If VHS isn't installed at all, `Easy_VideoCombine` is simply not
registered in `NODE_CLASS_MAPPINGS` and a one-line message appears in
the ComfyUI console at startup explaining why.
## Why this isn't an upstream PR (yet)
It can be, eventually — and probably should be. The path-discrimination
pattern is borrowed from spacepxl's `ComfyUI-HQ-Image-Save` (`SaveEXR`)
which has carried it since 2023, so there's precedent for landing it
upstream. In the meantime, packaging it as a Koolook node means every
workstation that installs Koolook gets it immediately, without waiting
on upstream review and without per-workstation patches that
ComfyUI-Manager's auto-update would clobber.
See [`forks/THIRD_PARTY.md`](../../../../forks/THIRD_PARTY.md) for the
full attribution + license audit.
+47
View File
@@ -65,6 +65,53 @@ below — GPL-3.0 §5(c) requires the whole work to be GPL-3.0.
still load. The KJ Nodes attribution stays in the file header and here.
- **Last reviewed:** 2026-05-03
### Kosinkadink/ComfyUI-VideoHelperSuite — VideoCombine subclass (runtime composition)
- **Name:** ComfyUI-VideoHelperSuite (Kosinkadink)
- **Upstream repo URL:** https://github.com/Kosinkadink/ComfyUI-VideoHelperSuite
- **License:** GPL-3.0 (verified 2026-05-16 via raw `LICENSE` fetch)
- **Local path(s):** [`k_video_combine.py`](../k_video_combine.py)
— exposed as the `Easy_VideoCombine` ComfyUI node ID (display name
*"Easy Video Combine (Koolook)"*, category `Koolook/Video`).
- **What was incorporated (not copied):** A ~60-line subclass of
`videohelpersuite.nodes.VideoCombine`. No VHS source code is copied
into MAIN — the relationship is purely a Python runtime subclass that
imports VHS at module load and delegates almost the entire body of
`combine_video()` back to the parent class via `super()`. The only
Koolook-side logic is:
- An additional `create_path_if_missing` BOOLEAN input (optional,
default `False`).
- An `os.path.isabs(filename_prefix)` discriminator that bypasses
ComfyUI's output-directory sandbox when the user types an absolute
path.
- A scoped monkey-patch of `folder_paths.get_save_image_path` for the
duration of one `combine_video()` call so upstream's encoder /
audio mux / batch manager run unchanged but write into the
absolute target directory.
- **Pattern inspiration (separate from the runtime dependency on VHS):**
The `os.path.isabs(filename_prefix)` overload-the-existing-field
pattern is borrowed from
[`spacepxl/ComfyUI-HQ-Image-Save`](https://github.com/spacepxl/ComfyUI-HQ-Image-Save)'s
`SaveEXR` node (MIT, copyright 2023 spacepxl). MIT is GPL-3.0
compatible. No spacepxl source code is copied — only the pattern.
License verified 2026-05-16 via raw `LICENSE` fetch.
- **Why a subclass rather than a fork:** Patching the upstream VHS
install on every workstation is fragile (ComfyUI-Manager auto-updates
blow patches away); a global monkey-patch at Koolook import time
changes behavior for every VHS node whether the user asked for it or
not. A separate node opted into by placing it on the canvas is the
additive, neighborly approach.
- **Runtime dependency:** Requires
`Kosinkadink/ComfyUI-VideoHelperSuite` to be installed alongside
Koolook. If VHS isn't importable at module load, `k_video_combine.py`
prints a one-line `[Koolook] Easy_VideoCombine skipped: …` notice
and returns empty NODE_CLASS_MAPPINGS — the rest of Koolook is
unaffected.
- **Upstream PR plan:** the same `isabs` discrimination is a candidate
to upstream into VHS proper. If/when accepted, `Easy_VideoCombine`
can be deprecated in favour of `VHS_VideoCombine` directly.
- **Last reviewed:** 2026-05-16
### fxtdstudios/radiance — v2.3.3 VAE subset (Koolook fork)
- **Name:** Radiance (FXTD Studios)
+19
View File
@@ -54,6 +54,25 @@ entries:
- "EasyResize" # deprecated alias for workflow back-compat
notes: "Idea-level inspiration from KJ Nodes' Resize Image V2 (GPL-3.0 — same as our pack, so no relicense pressure). Implementation is a fresh write that substantially extends the surface (aspect-ratio parser, keep_proportion modes, mask + composed outputs, device selection, etc). Renamed in v0.1.6 from bare 'EasyResize' to 'EasyResize_Koolook' to resolve a node-ID collision with ComfyUI-EasyFilePaths."
- id: "video_combine_path_aware_subclass"
name: "Easy Video Combine (Koolook subclass of VHS VideoCombine)"
source_repo: "https://github.com/Kosinkadink/ComfyUI-VideoHelperSuite"
source_ref: "main (runtime composition; no upstream code copied)"
local_paths:
- "k_video_combine.py"
status: "subclass_runtime_dep"
sync_state: "no-upstream-tracking-needed"
license: "GPL-3.0"
license_verified_at: "2026-05-16"
exposed_node_ids:
- "Easy_VideoCombine"
pattern_inspiration:
source_repo: "https://github.com/spacepxl/ComfyUI-HQ-Image-Save"
license: "MIT"
license_verified_at: "2026-05-16"
notes: "os.path.isabs(filename_prefix) discrimination pattern from SaveEXR — overload the existing field for relative vs absolute paths rather than adding a new input pin. No source code copied; pattern only."
notes: "Thin subclass of videohelpersuite.nodes.VideoCombine (~60 LoC). Scoped-patches folder_paths.get_save_image_path for the duration of one combine_video() call so upstream's encoder/audio/batch-manager pipeline runs unchanged but writes to an absolute path when filename_prefix is absolute. Relative prefixes pass through to upstream verbatim. VHS is a runtime dependency only — if it isn't installed, k_video_combine.py logs a one-line skip and the node is omitted from NODE_CLASS_MAPPINGS."
- id: "promptjson_integration_reference"
name: "PromptJSON interoperability reference"
source_repo: "https://github.com/NeuralSamurAI/ComfyUI-PromptJSON"
+548
View File
@@ -0,0 +1,548 @@
# SPDX-License-Identifier: GPL-3.0-or-later
#
# ComfyUI-Koolook — Easy Video Combine
# Copyright (C) 2026 ComfyUI-Koolook contributors (kforgelabs).
#
# This file is part of ComfyUI-Koolook, licensed under GPL-3.0-or-later.
# See the LICENSE file at the repo root for the full text.
#
# Runtime composition over Kosinkadink/ComfyUI-VideoHelperSuite (GPL-3.0).
# No VHS source is copied here — Easy_VideoCombine subclasses
# VHS_VideoCombine and scoped-patches folder_paths.get_save_image_path
# for the duration of one combine_video() call, letting upstream's
# encoder run untouched while the output is redirected to an absolute
# path on disk. If VHS isn't installed alongside Koolook, the node is
# simply not registered.
#
# The os.path.isabs(filename_prefix) discrimination pattern is borrowed
# from spacepxl/ComfyUI-HQ-Image-Save's SaveEXR (MIT) — same idea: one
# field carries both relative (sandboxed) and absolute (direct) paths
# so no new pin is needed on the canvas. See forks/THIRD_PARTY.md for
# the full attribution + change log.
"""Path-aware Video Combine for ComfyUI-Koolook.
Exposes ``Easy_VideoCombine`` (display name *Easy Video Combine
(Koolook)*, category ``Koolook/Video``). Identical to VHS's
``Video Combine`` except an absolute ``filename_prefix`` writes
directly to that location, and an optional ``create_path_if_missing``
toggle controls whether the parent directory is auto-created.
"""
from __future__ import annotations
import datetime
import json
import os
from pathlib import Path
from typing import Optional, Tuple
try:
import folder_paths # ComfyUI core; always present at runtime.
_KOLOOK_VIDEO_FORMATS_DIR = Path(__file__).resolve().parent / "video_formats"
if _KOLOOK_VIDEO_FORMATS_DIR.exists():
_paths, _exts = folder_paths.folder_names_and_paths.get(
"VHS_video_formats",
((), {".json"}),
)
_format_path = str(_KOLOOK_VIDEO_FORMATS_DIR)
if _format_path not in _paths:
folder_paths.folder_names_and_paths["VHS_video_formats"] = (
tuple(_paths) + (_format_path,),
set(_exts) | {".json"},
)
# Reach the VHS VideoCombine class through ComfyUI's global node
# registry rather than `from videohelpersuite.nodes import ...`.
# VHS uses relative imports internally (`from .logger import ...`,
# `from .utils import ...`) which only resolve correctly when VHS
# is loaded through ComfyUI's custom-node loader, not through a
# plain absolute import. The global registry is the documented
# cross-pack interface — ComfyUI populates it as each pack loads,
# and Koolook loads after VHS in every install we've seen because
# `comfyui-videohelpersuite` sorts before `koolook` (and even an
# uppercase `ComfyUI-VideoHelperSuite` sorts before
# `ComfyUI-Koolook` if both are present). If for some reason VHS
# isn't loaded yet, the lookup returns None and we self-skip with
# a clear message.
import nodes as _comfy_nodes_module
_VHS_VideoCombine = _comfy_nodes_module.NODE_CLASS_MAPPINGS.get("VHS_VideoCombine")
if _VHS_VideoCombine is None:
raise RuntimeError(
"VHS_VideoCombine not present in ComfyUI's NODE_CLASS_MAPPINGS "
"at Koolook load time. Either ComfyUI-VideoHelperSuite isn't "
"installed, or it loaded after Koolook (unexpected — check "
"custom_nodes folder names)."
)
_VHS_AVAILABLE = True
_VHS_IMPORT_ERROR: Optional[BaseException] = None
except (ImportError, RuntimeError) as _exc:
_VHS_AVAILABLE = False
_VHS_IMPORT_ERROR = _exc
_VHS_VideoCombine = None # type: ignore[assignment]
def _resolve_abs_target(
filename_prefix: str,
create_path_if_missing: bool,
) -> Optional[Tuple[str, str]]:
"""Discriminate relative vs absolute ``filename_prefix``.
Returns ``None`` when the prefix is relative — caller should let
upstream's sandboxed flow handle it unchanged. Returns
``(abs_dir, abs_basename)`` when the prefix is absolute; upstream's
counter pattern will append ``"_<counter:05>.<ext>"`` to
``abs_basename`` inside ``abs_dir``.
Raises ``FileNotFoundError`` if the parent directory does not exist
and ``create_path_if_missing`` is ``False`` — a typo in a render
path should surface as a clear error, not silently spawn a stray
directory tree. Raises ``ValueError`` if the absolute prefix has no
filename component (e.g. a bare drive root like ``"C:\\"``).
"""
if not os.path.isabs(filename_prefix):
return None
normed = os.path.normpath(filename_prefix)
if os.path.isdir(normed):
abs_dir = normed
abs_base = os.path.basename(normed.rstrip("/\\"))
if abs_base == "":
raise ValueError(
f"filename_prefix '{filename_prefix}' is an absolute directory "
f"but has no usable folder name for the filename root."
)
return abs_dir, abs_base
abs_dir = os.path.dirname(normed)
abs_base = os.path.basename(normed)
if abs_base == "":
raise ValueError(
f"filename_prefix '{filename_prefix}' is an absolute path but "
f"has no filename component. Append a filename root, e.g. "
f"'{normed.rstrip(os.sep)}{os.sep}clipname'."
)
if not os.path.exists(abs_dir):
if create_path_if_missing:
os.makedirs(abs_dir, exist_ok=True)
else:
raise FileNotFoundError(
f"Directory does not exist: {abs_dir}. Enable "
f"'create_path_if_missing' on the Easy Video Combine node "
f"to auto-create it."
)
return abs_dir, abs_base
_SENTINEL_STRINGS = ("undefined", "null", "none")
def _normalize_text_input(value) -> str:
"""Coerce a ComfyUI STRING widget value to a clean Python string.
ComfyUI's frontend has a long-running quirk: when a STRING input
with an empty default sits untouched, the value sometimes arrives
at the backend as the literal string ``"undefined"`` (or ``"null"``
/ ``"None"``) instead of ``""``. Treating those as valid input
causes nonsensical ``undefined/`` subdirectories on disk.
Also defends against:
- Surrounding quotes (users paste paths from Explorer with quotes).
- Trailing whitespace including newlines (WAS Text Multiline can
leave a trailing newline when the user pressed Enter at the end).
- Embedded sentinel components separated by a newline (a multi-line
Text Multiline node with a second sentinel-looking line).
Any sentinel string (case-insensitive, post-strip) becomes ``""``;
everything else is the cleaned value.
"""
if value is None:
return ""
s = str(value).strip().strip('"').strip("'").strip()
# If a newline split the value across lines, take the first
# non-empty non-sentinel line.
if "\n" in s or "\r" in s:
for line in s.replace("\r", "\n").split("\n"):
line = line.strip()
if line and line.lower() not in _SENTINEL_STRINGS:
s = line
break
else:
s = ""
if s.lower() in _SENTINEL_STRINGS:
return ""
return s
def _normalize_bool_input(value, default: bool = False) -> bool:
"""Coerce ComfyUI widget values to a real bool.
This is intentionally stricter than Python truthiness. A corrupted
saved workflow can shift a format-widget value such as ``"hq"`` into
the ``pingpong`` slot; ``bool("hq")`` would enable ping-pong and
double the video. Unknown strings fall back to ``default``.
"""
if isinstance(value, bool):
return value
if value is None:
return default
if isinstance(value, (int, float)):
return bool(value)
s = str(value).strip().lower()
if s in ("true", "1", "yes", "on"):
return True
if s in ("false", "0", "no", "off", ""):
return False
return default
def _strip_sentinel_components(path: str) -> str:
"""Remove any path component that's exactly a sentinel string.
Belt-and-suspenders defense: if anything upstream of us managed
to inject ``undefined`` / ``null`` / ``None`` as a path component
(despite the per-field normalization above), drop it before the
path reaches the filesystem. Preserves the rest of the path,
including the drive root on Windows.
Returns the path unchanged if no sentinels found, so this is a
cheap pass for the common case.
"""
if not path:
return path
# Split on whichever separator is present, defaulting to os.sep.
sep = "\\" if "\\" in path else "/"
parts = path.split(sep)
cleaned = [
p for p in parts
if p == "" or p.lower() not in _SENTINEL_STRINGS
]
if len(cleaned) == len(parts):
return path
return sep.join(cleaned)
def _compose_prefix(filename_prefix: str, output_directory: str) -> str:
"""Combine ``output_directory`` + ``filename_prefix`` into one path-prefix.
When ``output_directory`` is empty, ``filename_prefix`` is returned
unchanged — it retains the overloaded "relative-or-absolute prefix"
semantics for back-compat with users who like the one-field flow.
When ``output_directory`` is set, only the basename of
``filename_prefix`` becomes the filename root (any path components
the user accidentally typed there are stripped, so they can change
the name without re-typing the directory). The directory keeps its
shape (absolute or relative), so the downstream
:func:`_resolve_abs_target` discrimination still works on the
composed prefix.
"""
if not output_directory:
return filename_prefix
name_root = os.path.basename(filename_prefix.rstrip("/\\")) or "AnimateDiff"
clean_dir = output_directory.rstrip("/\\")
return clean_dir + os.sep + name_root
def _display_format_name(format_name: str) -> str:
"""Hide the JSON suffix VHS exposes for external Koolook presets."""
if format_name.startswith("video/koolook-") and format_name.endswith(".json"):
return format_name[:-5]
return format_name
def _runtime_format_name(format_name: str) -> str:
"""Map cleaned Koolook display names back to VHS's external file names."""
if format_name.startswith("video/koolook-") and not format_name.endswith(".json"):
return format_name + ".json"
return format_name
def _metadata_sidecar_path(output_files: list[str]) -> Optional[str]:
"""Pick the JSON sidecar path from VHS's output file list."""
if not output_files:
return None
return os.path.splitext(output_files[0])[0] + ".json"
def _add_metadata_json_sidecar(
result,
metadata_payload: dict,
save_metadata_json: bool,
save_metadata_png: bool,
):
"""Write a workflow metadata JSON next to VHS's output and return result."""
if not save_metadata_json or not isinstance(result, dict):
return result
try:
output_files = result.get("result", ((None, []),))[0][1]
if not isinstance(output_files, list):
return result
json_path = _metadata_sidecar_path(output_files)
if not json_path:
return result
Path(json_path).write_text(
json.dumps(metadata_payload, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
if save_metadata_png:
output_files.append(json_path)
elif output_files and output_files[0].lower().endswith(".png"):
output_files[0] = json_path
else:
output_files.insert(0, json_path)
ui = result.get("ui", {})
gifs = ui.get("gifs", [])
if gifs and isinstance(gifs[0], dict) and gifs[0].get("workflow", "").endswith(".png"):
gifs[0]["workflow"] = os.path.basename(json_path)
except Exception as exc:
print(f"[Easy_VideoCombine] metadata JSON sidecar skipped: {exc!r}")
return result
def _remove_audio_suffix_from_result(result, keep_silent_intermediate: bool):
"""Rename VHS's final ``-audio`` mux output to the clean base name."""
if keep_silent_intermediate or not isinstance(result, dict):
return result
try:
output_files = result.get("result", ((None, []),))[0][1]
if not isinstance(output_files, list) or not output_files:
return result
final_path = Path(output_files[-1])
if final_path.stem.endswith("-audio"):
clean_path = final_path.with_name(final_path.stem[:-6] + final_path.suffix)
if clean_path.exists() and clean_path != final_path:
clean_path.unlink()
final_path.replace(clean_path)
output_files[-1] = str(clean_path)
ui = result.get("ui", {})
gifs = ui.get("gifs", [])
if gifs and isinstance(gifs[0], dict):
gifs[0]["filename"] = clean_path.name
gifs[0]["fullpath"] = str(clean_path)
output_files[:] = [
path for path in output_files
if not isinstance(path, str) or os.path.exists(path)
]
deduped = []
for path in output_files:
if path not in deduped:
deduped.append(path)
output_files[:] = deduped
except Exception as exc:
print(f"[Easy_VideoCombine] audio suffix cleanup skipped: {exc!r}")
return result
if _VHS_AVAILABLE:
class Easy_VideoCombine(_VHS_VideoCombine):
"""Video Combine variant with absolute-path output."""
CATEGORY = "Koolook/Video"
DESCRIPTION = "Video Combine variant with absolute-path output."
@classmethod
def INPUT_TYPES(cls):
types = _VHS_VideoCombine.INPUT_TYPES()
format_options, format_config = types["required"]["format"]
cleaned_format_options = []
cleaned_format_widgets = {}
format_widgets = dict(format_config.get("formats", {}))
for option in format_options:
cleaned = _display_format_name(option)
cleaned_format_options.append(cleaned)
if option in format_widgets:
cleaned_format_widgets[cleaned] = format_widgets[option]
for option, widgets in format_widgets.items():
cleaned_format_widgets.setdefault(_display_format_name(option), widgets)
format_options = cleaned_format_options
format_config = {**format_config, "formats": cleaned_format_widgets}
if "video/ProRes" in format_options:
types["required"]["format"] = (
format_options,
{**format_config, "default": "video/ProRes"},
)
types["required"]["filename_prefix"] = (
"STRING",
{
"default": "AnimateDiff",
"tooltip": "Filename root. Counter and extension are appended.",
},
)
# Upstream VHS uses save_output to choose ComfyUI output/ vs temp/.
# Easy_VideoCombine is an explicit file-writer, and absolute-path
# mode ignores that distinction, so hiding it avoids a misleading
# toggle while preserving the persistent-output behavior.
types["required"].pop("save_output", None)
types["optional"]["output_directory"] = (
"STRING",
{
"default": "",
"tooltip": "Absolute or relative output directory.",
},
)
types["optional"]["create_path_if_missing"] = (
"BOOLEAN",
{
"default": False,
"tooltip": "Auto-create the output directory if missing.",
},
)
types["optional"]["save_metadata_png"] = (
"BOOLEAN",
{
"default": False,
"tooltip": "Save the first-frame PNG with embedded workflow metadata.",
},
)
types["optional"]["save_metadata_json"] = (
"BOOLEAN",
{
"default": True,
"tooltip": "Save workflow metadata as a JSON sidecar.",
},
)
types["optional"]["keep_silent_intermediate"] = (
"BOOLEAN",
{
"default": False,
"tooltip": "Keep the audio-less intermediate video when audio is muxed in.",
},
)
return types
def combine_video(self, *args, **kwargs):
# Defensive normalization — the frontend occasionally sends
# the literal string "undefined" for STRING widgets with
# empty defaults. Coerce those to "" before any path
# composition so we never create an `undefined/` subdir.
filename_prefix = _normalize_text_input(
kwargs.get("filename_prefix", "AnimateDiff")
) or "AnimateDiff"
kwargs["filename_prefix"] = filename_prefix
output_directory = _normalize_text_input(
kwargs.pop("output_directory", "")
)
kwargs["pingpong"] = _normalize_bool_input(
kwargs.get("pingpong", False),
default=False,
)
if "format" in kwargs:
kwargs["format"] = _runtime_format_name(kwargs["format"])
kwargs["save_output"] = True
create_path_if_missing = _normalize_bool_input(
kwargs.pop("create_path_if_missing", False),
default=False,
)
save_metadata_png = _normalize_bool_input(
kwargs.pop("save_metadata_png", False),
default=False,
)
save_metadata_json = _normalize_bool_input(
kwargs.pop("save_metadata_json", True),
default=True,
)
keep_silent_intermediate = _normalize_bool_input(
kwargs.pop("keep_silent_intermediate", False),
default=False,
)
metadata_payload = {
"CreationTime": datetime.datetime.now().isoformat(" ")[:19],
}
if kwargs.get("prompt") is not None:
metadata_payload["prompt"] = kwargs.get("prompt")
if kwargs.get("extra_pnginfo") is not None:
metadata_payload.update(kwargs.get("extra_pnginfo") or {})
# Inject VHS's hidden extra_options flags so upstream handles
# metadata PNG and silent-intermediate cleanup according to
# Koolook's explicit toggles. Copy extra_pnginfo before mutating
# so other nodes referencing the same dict aren't affected.
extra_pnginfo = dict(kwargs.get("extra_pnginfo") or {})
workflow = dict(extra_pnginfo.get("workflow") or {})
extra = dict(workflow.get("extra") or {})
extra["VHS_MetadataImage"] = bool(save_metadata_png)
extra["VHS_KeepIntermediate"] = bool(keep_silent_intermediate)
workflow["extra"] = extra
extra_pnginfo["workflow"] = workflow
kwargs["extra_pnginfo"] = extra_pnginfo
# Split-mode: directory + name combined into one effective
# prefix that the existing isabs discrimination handles
# uniformly. When output_directory is empty this is a no-op.
effective_prefix = _compose_prefix(filename_prefix, output_directory)
# Belt-and-suspenders: drop any sentinel components that
# slipped past per-field normalization (e.g. a path that
# already carried `\undefined\` as a literal segment).
effective_prefix = _strip_sentinel_components(effective_prefix)
if effective_prefix != filename_prefix:
kwargs["filename_prefix"] = effective_prefix
filename_prefix = effective_prefix
target = _resolve_abs_target(filename_prefix, create_path_if_missing)
if target is None:
result = super().combine_video(*args, **kwargs)
result = _remove_audio_suffix_from_result(
result,
keep_silent_intermediate,
)
return _add_metadata_json_sidecar(
result,
metadata_payload,
save_metadata_json,
save_metadata_png,
)
abs_dir, abs_base = target
# VHS calls folder_paths.get_save_image_path() exactly once
# in combine_video(). Patch it to return our absolute target
# so the rest of upstream's pipeline (counter scan, metadata
# PNG, ffmpeg encode, audio mux, batch manager) writes into
# abs_dir/abs_base_*. The patch is scoped to one super() call
# and restored in finally to guarantee no leak on exception.
#
# Concurrency note: ComfyUI executes nodes sequentially within
# a workflow and serializes workflows at the prompt level, so
# no other node observes the patched function during the call.
original_get_save_path = folder_paths.get_save_image_path
def patched_get_save_path(prefix, output_dir, *_a, **_kw):
# VHS expects:
# (full_output_folder, filename, counter_seed, subfolder, prefix)
# Upstream re-scans full_output_folder for the real
# counter, so counter_seed (0) is ignored. subfolder=""
# makes the UI preview tile bypass the relative-to-
# output-dir link Comfy builds for the /view endpoint.
return (abs_dir, abs_base, 0, "", abs_base)
try:
folder_paths.get_save_image_path = patched_get_save_path
result = super().combine_video(*args, **kwargs)
result = _remove_audio_suffix_from_result(
result,
keep_silent_intermediate,
)
return _add_metadata_json_sidecar(
result,
metadata_payload,
save_metadata_json,
save_metadata_png,
)
finally:
folder_paths.get_save_image_path = original_get_save_path
NODE_CLASS_MAPPINGS = {"Easy_VideoCombine": Easy_VideoCombine}
NODE_DISPLAY_NAME_MAPPINGS = {"Easy_VideoCombine": "Easy Video Combine (Koolook)"}
else:
print(
f"[Koolook] Easy_VideoCombine skipped: "
f"ComfyUI-VideoHelperSuite not importable ({_VHS_IMPORT_ERROR}). "
f"Install VHS from ComfyUI-Manager to enable this node."
)
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+12
View File
@@ -37,6 +37,18 @@ includes = []
[tool.ruff]
extend-exclude = [".venv", "forks", "upscaler_FIX", "nuke_CAM_exporter"]
[tool.setuptools]
py-modules = [
"k_ai_pipeline",
"k_easy_image_batch",
"k_easy_pattern",
"k_easy_resize",
"k_easy_track",
"k_easy_wan22_prompt",
"k_video_combine",
"koolook_routes",
]
[tool.ruff.lint.per-file-ignores]
"k_easy_resize.py" = ["F841"]
+2
View File
@@ -216,8 +216,10 @@ RUNTIME_PATHS: tuple[str, ...] = (
"k_easy_resize.py",
"k_easy_track.py",
"k_easy_wan22_prompt.py",
"k_video_combine.py",
"koolook_routes.py",
"forks",
"video_formats",
"web",
)
View File
+311
View File
@@ -0,0 +1,311 @@
"""Tests for Easy_VideoCombine's absolute-path discrimination.
The full VHS encode pipeline needs ffmpeg + torch + a live ComfyUI
process to exercise meaningfully, so we don't drive ``combine_video()``
end-to-end here. The behavior worth pinning at unit-test scope is the
relative-vs-absolute branch and the ``create_path_if_missing`` toggle —
both live in the pure helper :func:`_resolve_abs_target`, which doesn't
need VHS to be installed.
Importing ``k_video_combine`` works regardless of VHS availability: the
module's top-level try/except shape leaves ``_resolve_abs_target``
defined either way (only the class registration is gated).
"""
from __future__ import annotations
import os
from pathlib import Path
import pytest
from k_video_combine import (
_add_metadata_json_sidecar,
_compose_prefix,
_display_format_name,
_metadata_sidecar_path,
_normalize_bool_input,
_normalize_text_input,
_remove_audio_suffix_from_result,
_resolve_abs_target,
_runtime_format_name,
)
def test_relative_prefix_returns_none() -> None:
"""Relative prefixes are upstream's domain — helper signals passthrough."""
assert _resolve_abs_target("AnimateDiff", create_path_if_missing=False) is None
assert _resolve_abs_target("renders/clip01", create_path_if_missing=True) is None
# A path with `..` is still relative — sandbox stays in force.
assert _resolve_abs_target("../escape", create_path_if_missing=False) is None
def test_absolute_prefix_existing_dir(tmp_path: Path) -> None:
"""Absolute prefix with existing parent returns (dir, basename)."""
prefix = str(tmp_path / "clipname")
result = _resolve_abs_target(prefix, create_path_if_missing=False)
assert result is not None
abs_dir, abs_base = result
assert Path(abs_dir) == tmp_path
assert abs_base == "clipname"
def test_absolute_prefix_missing_dir_raises_when_create_off(tmp_path: Path) -> None:
"""Missing parent + create_path_if_missing=False -> FileNotFoundError.
A typo in a render path should fail loudly, not silently spawn
directories the user didn't intend.
"""
prefix = str(tmp_path / "nested" / "deeper" / "clip")
with pytest.raises(FileNotFoundError, match="Directory does not exist"):
_resolve_abs_target(prefix, create_path_if_missing=False)
assert not (tmp_path / "nested").exists() # No silent mkdir.
def test_absolute_prefix_missing_dir_created_when_toggled(tmp_path: Path) -> None:
"""Missing parent + create_path_if_missing=True -> mkdir + resolve."""
parent = tmp_path / "nested" / "deeper"
prefix = str(parent / "clip")
result = _resolve_abs_target(prefix, create_path_if_missing=True)
assert result is not None
abs_dir, abs_base = result
assert parent.exists()
assert Path(abs_dir) == parent
assert abs_base == "clip"
def test_absolute_prefix_with_trailing_separator(tmp_path: Path) -> None:
"""Existing directory paths work with a final slash."""
renders_dir = tmp_path / "renders"
renders_dir.mkdir()
prefix = str(renders_dir) + os.sep
result = _resolve_abs_target(prefix, create_path_if_missing=False)
assert result is not None
abs_dir, abs_base = result
assert Path(abs_dir) == renders_dir
assert abs_base == "renders"
def test_absolute_existing_directory_without_trailing_separator(tmp_path: Path) -> None:
"""Pasted directory paths work with or without a final slash."""
renders_dir = tmp_path / "renders"
renders_dir.mkdir()
result = _resolve_abs_target(str(renders_dir), create_path_if_missing=False)
assert result is not None
abs_dir, abs_base = result
assert Path(abs_dir) == renders_dir
assert abs_base == "renders"
def test_absolute_prefix_with_no_filename_component_raises(tmp_path: Path) -> None:
"""An absolute path with empty basename has no filename root to
append the counter to — surface as a clear ValueError instead of
silently writing files named ``_00001.mp4``."""
root = tmp_path.anchor # "/" on POSIX, "C:\\" on Windows.
assert os.path.isabs(root)
with pytest.raises(ValueError, match="no usable folder name"):
_resolve_abs_target(root, create_path_if_missing=False)
# =============================================================================
# _compose_prefix: split-mode output_directory + filename_prefix.
# =============================================================================
def test_compose_empty_output_directory_passes_through() -> None:
"""No output_directory -> filename_prefix returned unchanged."""
assert _compose_prefix("AnimateDiff", "") == "AnimateDiff"
assert _compose_prefix("E:/renders/clip", "") == "E:/renders/clip"
def test_compose_absolute_directory_with_name() -> None:
"""Absolute output_directory + name -> joined absolute prefix."""
composed = _compose_prefix("clip_v003", "E:/renders/shot01")
assert composed == "E:/renders/shot01" + os.sep + "clip_v003"
def test_compose_relative_directory_with_name() -> None:
"""Relative output_directory + name -> joined relative prefix.
Stays relative so the downstream isabs discrimination passes it
through to upstream's sandbox.
"""
composed = _compose_prefix("clip", "shots/v003")
assert composed == "shots/v003" + os.sep + "clip"
assert not os.path.isabs(composed)
def test_compose_strips_path_components_from_filename_prefix() -> None:
"""When output_directory is set, only the basename of filename_prefix
becomes the file root — users can change the name without retyping
the directory."""
composed = _compose_prefix("subdir/clip_v003", "E:/renders/shot01")
assert composed == "E:/renders/shot01" + os.sep + "clip_v003"
def test_compose_trims_trailing_separator_from_directory() -> None:
"""Trailing slashes on output_directory don't double-up in the join."""
composed_unix = _compose_prefix("clip", "E:/renders/shot01/")
composed_win = _compose_prefix("clip", "E:\\renders\\shot01\\")
assert composed_unix == "E:/renders/shot01" + os.sep + "clip"
assert composed_win == "E:\\renders\\shot01" + os.sep + "clip"
def test_compose_empty_name_falls_back_to_default() -> None:
"""If filename_prefix's basename is empty (e.g. user typed just '/'),
fall back to AnimateDiff rather than producing a path ending in
just a separator."""
composed = _compose_prefix("/", "E:/renders/shot01")
assert composed == "E:/renders/shot01" + os.sep + "AnimateDiff"
# =============================================================================
# _normalize_text_input: guard against the ComfyUI frontend "undefined" quirk.
# A STRING widget left untouched can arrive at the backend as the literal
# string "undefined" (or "null" / "None") instead of an empty value.
# Without normalization that would slip into _compose_prefix and produce a
# nonsensical `undefined/` subdirectory on disk.
# =============================================================================
def test_normalize_empty_inputs() -> None:
assert _normalize_text_input("") == ""
assert _normalize_text_input(" ") == ""
assert _normalize_text_input(None) == ""
def test_normalize_undefined_sentinel_strings() -> None:
"""All case variants of the frontend-quirk strings become empty."""
assert _normalize_text_input("undefined") == ""
assert _normalize_text_input("Undefined") == ""
assert _normalize_text_input("UNDEFINED") == ""
assert _normalize_text_input("null") == ""
assert _normalize_text_input("None") == ""
assert _normalize_text_input(" undefined ") == ""
def test_normalize_passes_through_real_values() -> None:
assert _normalize_text_input("E:/renders/shot01") == "E:/renders/shot01"
assert _normalize_text_input("clip_v003") == "clip_v003"
# Substring matches don't trigger — only exact match (post-strip).
assert _normalize_text_input("undefined_path") == "undefined_path"
assert _normalize_text_input("my_null_clip") == "my_null_clip"
def test_normalize_prevents_undefined_subdir_in_compose() -> None:
"""Regression test for the bug: output_directory='undefined' was
being treated as a real directory, producing an `undefined/`
subfolder when the user typed an absolute path in filename_prefix."""
normalized_dir = _normalize_text_input("undefined")
composed = _compose_prefix("E:/renders/clip_v003", normalized_dir)
# With normalization, no output_directory -> filename_prefix passes through.
assert composed == "E:/renders/clip_v003"
assert "undefined" not in composed
# =============================================================================
# _normalize_bool_input: guard against dynamic widget restore drift.
# If format-specific widgets are restored by position instead of by name, a
# value like "hq" can land in pingpong. Python truthiness would treat that as
# True and VHS would double the video by ping-ponging the frame sequence.
# =============================================================================
def test_normalize_bool_input_known_values() -> None:
assert _normalize_bool_input(True) is True
assert _normalize_bool_input(False) is False
assert _normalize_bool_input("true") is True
assert _normalize_bool_input("1") is True
assert _normalize_bool_input("on") is True
assert _normalize_bool_input("false") is False
assert _normalize_bool_input("0") is False
assert _normalize_bool_input("off") is False
assert _normalize_bool_input("") is False
def test_normalize_bool_input_unknown_string_uses_default() -> None:
assert _normalize_bool_input("hq", default=False) is False
assert _normalize_bool_input("hq", default=True) is True
def test_metadata_sidecar_path_replaces_vhs_png_sidecar(tmp_path: Path) -> None:
png = tmp_path / "clip_00001.png"
assert _metadata_sidecar_path([str(png)]) == str(tmp_path / "clip_00001.json")
def test_add_metadata_json_sidecar_replaces_disabled_png_entry(tmp_path: Path) -> None:
png = tmp_path / "clip_00001.png"
mp4 = tmp_path / "clip_00001.mp4"
result = {
"ui": {"gifs": [{"workflow": png.name}]},
"result": ((True, [str(png), str(mp4)]),),
}
out = _add_metadata_json_sidecar(
result,
{"prompt": {"1": {"class_type": "Example"}}},
save_metadata_json=True,
save_metadata_png=False,
)
json_path = tmp_path / "clip_00001.json"
assert json_path.exists()
assert out["result"][0][1] == [str(json_path), str(mp4)]
assert out["ui"]["gifs"][0]["workflow"] == json_path.name
def test_remove_audio_suffix_renames_final_and_drops_missing_intermediate(tmp_path: Path) -> None:
png = tmp_path / "clip_00001.png"
silent = tmp_path / "clip_00001.mp4"
audio = tmp_path / "clip_00001-audio.mp4"
png.write_text("png")
audio.write_text("video")
result = {
"ui": {"gifs": [{"filename": audio.name, "fullpath": str(audio)}]},
"result": ((True, [str(png), str(silent), str(audio)]),),
}
out = _remove_audio_suffix_from_result(result, keep_silent_intermediate=False)
clean = tmp_path / "clip_00001.mp4"
assert clean.exists()
assert not audio.exists()
assert out["result"][0][1] == [str(png), str(clean)]
assert out["ui"]["gifs"][0]["filename"] == clean.name
assert out["ui"]["gifs"][0]["fullpath"] == str(clean)
def test_remove_audio_suffix_replaces_silent_intermediate(tmp_path: Path) -> None:
silent = tmp_path / "clip_00001.mp4"
audio = tmp_path / "clip_00001-audio.mp4"
silent.write_bytes(b"silent")
audio.write_bytes(b"with_audio")
result = {
"ui": {"gifs": [{"filename": audio.name, "fullpath": str(audio)}]},
"result": ((True, [str(silent), str(audio)]),),
}
out = _remove_audio_suffix_from_result(result, keep_silent_intermediate=False)
assert silent.exists()
assert silent.read_bytes() == b"with_audio"
assert not audio.exists()
assert out["result"][0][1] == [str(silent)]
assert out["ui"]["gifs"][0]["filename"] == silent.name
assert out["ui"]["gifs"][0]["fullpath"] == str(silent)
def test_koolook_format_display_hides_json_suffix() -> None:
assert _display_format_name("video/koolook-ASTRA-h264.json") == "video/koolook-ASTRA-h264"
assert _display_format_name("video/ProRes") == "video/ProRes"
def test_koolook_format_runtime_restores_json_suffix() -> None:
assert _runtime_format_name("video/koolook-ASTRA-h264") == "video/koolook-ASTRA-h264.json"
assert _runtime_format_name("video/ProRes") == "video/ProRes"
+15
View File
@@ -0,0 +1,15 @@
{
"main_pass":
[
"-n", "-c:v", "libx264",
"-pix_fmt", ["pix_fmt", ["yuv420p10le", "yuv420p"], {"default": "yuv420p10le"}],
"-crf", ["crf", "INT", {"default": 5, "min": 0, "max": 100, "step": 1}],
"-vf", "scale=out_color_matrix=bt709",
"-color_range", "tv", "-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"
],
"fake_trc": "bt709",
"audio_pass": ["-c:a", "aac"],
"save_metadata": ["save_metadata", "BOOLEAN", {"default": true}],
"trim_to_audio": ["trim_to_audio", "BOOLEAN", {"default": false}],
"extension": "mp4"
}
+23
View File
@@ -0,0 +1,23 @@
{
"main_pass":
[
"-n", "-c:v", "prores_ks",
"-profile:v", [["$profile"]],
["profile", {
"lt": [[]], "1": [[]], "standard": [[]], "2": [[]], "hq": [[]], "3": [[]],
"4": ["has_alpha", {"True": [["-pix_fmt", "yuva444p10le"]],
"False": [["-pix_fmt", "yuv444p10le"]]}],
"4444": ["has_alpha", {"True": [["-pix_fmt", "yuva444p10le"]],
"False": [["-pix_fmt", "yuv444p10le"]]}],
"4444xq": ["has_alpha", {"True": [["-pix_fmt", "yuva444p10le"]],
"False": [["-pix_fmt", "yuv444p10le"]]}]
}],
"-vf", "scale=in_range=pc:out_range=tv:out_color_matrix=bt709",
"-color_range", "tv",
"-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"
],
"fake_trc": "bt709",
"audio_pass": ["-c:a", "pcm_s16le"],
"extension": "mov",
"extra_widgets": [["profile", ["lt", "standard", "hq", "4444", "4444xq"], {"default": "hq"}]]
}
+171
View File
@@ -0,0 +1,171 @@
// SPDX-License-Identifier: GPL-3.0-or-later
//
// ComfyUI-Koolook — Easy Video Combine frontend extension
// Copyright (C) 2026 ComfyUI-Koolook contributors (kforgelabs).
//
// VHS's frontend extension (web/js/VHS.core.js:2030) hard-codes
// `nodeData?.name == "VHS_VideoCombine"` to gate its format-widget
// machinery — picking a format like ProRes or h264 dynamically adds
// format-specific widgets (`profile`, `crf`, `pix_fmt`, …). Because
// our subclass is registered under a different node ID
// (`Easy_VideoCombine`), none of that code runs for our node, and
// the format-specific widgets never render. The backend then warns
// "Missing input for <widget> has been set to <default>" on every
// execution and the user has no way to choose anything other than
// the default.
//
// This extension replicates the relevant bit of VHS's logic
// (`addFormatWidgets`) for our node, so the format dropdown's
// callback adds/removes per-format widgets the same way as upstream.
// Self-contained — does NOT import from VHS's module (their helpers
// aren't exported). Only ~80 lines including comments.
import { app } from "../../scripts/app.js";
import { setWidgetConfig } from "../../extensions/core/widgetInputs.js";
function chainCallback(object, property, callback) {
if (object == undefined) {
console.error("[Koolook EasyVideoCombine] chainCallback on undefined object");
return;
}
if (property in object && object[property]) {
const original = object[property];
object[property] = function () {
const r = original.apply(this, arguments);
return callback.apply(this, arguments) ?? r;
};
} else {
object[property] = callback;
}
}
function fitHeight(node) {
node.setSize([node.size[0], node.computeSize([node.size[0], node.size[1]])[1]]);
node?.graph?.setDirtyCanvas(true);
}
function setWidgetValue(widget, value) {
widget.value = value;
widget.callback?.(widget.value);
}
function coerceBoolValue(value, fallback) {
if (typeof value === "boolean") return value;
if (typeof value === "number") return value !== 0;
if (value == null) return fallback;
const s = String(value).trim().toLowerCase();
if (["true", "1", "yes", "on"].includes(s)) return true;
if (["false", "0", "no", "off", ""].includes(s)) return false;
return fallback;
}
const BOOLEAN_WIDGETS = new Set([
"pingpong",
"create_path_if_missing",
"save_metadata_png",
"save_metadata_json",
"keep_silent_intermediate",
]);
function setSavedWidgetValue(node, name, value) {
const widget = node.widgets?.find((w) => w.name === name);
if (!widget || widget.type === "button") return;
if (BOOLEAN_WIDGETS.has(name)) {
setWidgetValue(widget, coerceBoolValue(value, widget.value));
} else {
setWidgetValue(widget, value);
}
const input = node.inputs?.find((i) => i.name === name);
if (input && widget.config) setWidgetConfig(input, widget.config);
}
function useNamedWidgetState(nodeType) {
chainCallback(nodeType.prototype, "onNodeCreated", function () {
chainCallback(this, "onConfigure", function (info) {
if (!this.widgets || info?.widgets_values == null) return;
const saved = info.widgets_values;
if (!Array.isArray(saved) && typeof saved === "object") {
for (const name of ["frame_rate", "loop_count", "filename_prefix"]) {
if (Object.hasOwn(saved, name)) setSavedWidgetValue(this, name, saved[name]);
}
// Restore format before the rest; its callback creates the
// format-specific widgets that also need named restoration.
if (Object.hasOwn(saved, "format")) {
setSavedWidgetValue(this, "format", saved.format);
}
for (const [name, value] of Object.entries(saved)) {
setSavedWidgetValue(this, name, value);
}
}
});
chainCallback(this, "onSerialize", function (info) {
if (!this.widgets) return;
info.widgets_values = {};
for (const widget of this.widgets) {
if (widget.type === "button") continue;
info.widgets_values[widget.name] = widget.value;
}
});
});
}
function addFormatWidgets(nodeType) {
chainCallback(nodeType.prototype, "onNodeCreated", function () {
let formatWidget = null;
let formatWidgetIndex = -1;
for (let i = 0; i < this.widgets.length; i++) {
if (this.widgets[i].name === "format") {
formatWidget = this.widgets[i];
formatWidgetIndex = i + 1;
break;
}
}
if (!formatWidget) return;
let formatWidgetsCount = 0;
chainCallback(formatWidget, "callback", (value) => {
const formats = LiteGraph.registered_node_types[this.type]
?.nodeData?.input?.required?.format?.[1]?.formats;
const newWidgets = [];
if (formats?.[value]) {
for (const wDef of formats[value]) {
let type = wDef[2]?.widgetType ?? wDef[1];
if (Array.isArray(type)) type = "COMBO";
app.widgets[type](this, wDef[0], wDef.slice(1), app);
const w = this.widgets.pop();
w.config = wDef.slice(1);
newWidgets.push(w);
}
}
const removed = this.widgets.splice(formatWidgetIndex, formatWidgetsCount, ...newWidgets);
const newNames = new Set(newWidgets.map((w) => w.name));
for (const w of removed) {
w?.onRemove?.();
if (newNames.has(w.name)) continue;
const slot = this.inputs.findIndex((i) => i.name == w.name);
if (slot >= 0) this.removeInput(slot);
}
for (const w of newWidgets) {
const existing = this.inputs.find((i) => i.name == w.name);
if (existing) {
setWidgetConfig(existing, w.config);
} else {
this.addInput(w.name, w.config[0], { widget: { name: w.name } });
}
}
fitHeight(this);
formatWidgetsCount = newWidgets.length;
});
});
}
app.registerExtension({
name: "Koolook.EasyVideoCombine",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData?.name === "Easy_VideoCombine") {
useNamedWidgetState(nodeType);
addFormatWidgets(nodeType);
}
},
});
+11 -2
View File
@@ -2,12 +2,20 @@
"kind": "koolook-snapshot",
"version": 1,
"name": "starter",
"exportedAt": "2026-05-05T14:30:00.000Z",
"exportedAt": "2026-05-16T19:00:00.000Z",
"picks": [
"EasyAIPipeline",
"EasyResize_Koolook",
"EasyWan22Prompt",
"Easy_Pattern",
"Easy_VideoCombine",
"Easy_hdr_VAE_decode",
"Easy_hdr_VAE_encode",
"Frames Slice",
"GetImageSizeAndCount",
"GetNode",
"ImageScaleBy",
"KoolookLoadCameraPosesAbsolute",
"LoadEXR",
"NukeConstant",
"NukeOCIOColorSpace",
@@ -15,7 +23,8 @@
"ReplaceImagesInBatch",
"SaveEXRFrames",
"Text Multiline",
"easy showAnything"
"easy showAnything",
"easy_ImageBatch"
],
"workflows": {
"directories": {}