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 in943be67removed (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 since946e170). 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:
co-authored by
Claude Opus 4.7
parent
55dab39c9b
commit
ff79b0f418
@@ -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,5 +1,6 @@
|
||||
_safe*
|
||||
.venv/
|
||||
.tmp/
|
||||
Runpod_Comfy/
|
||||
PLAN.md
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"}]]
|
||||
}
|
||||
@@ -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
@@ -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": {}
|
||||
|
||||
Reference in New Issue
Block a user