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>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
f74b30603e
commit
a40117c8c2
@@ -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`.
|
||||
|
||||
@@ -4,6 +4,38 @@ All notable changes to this project should be documented in this file.
|
||||
|
||||
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.
|
||||
|
||||
## [0.3.2] - 2026-05-16
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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,118 @@
|
||||
# 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
|
||||
|
||||
The decision is driven entirely by **the shape of the value** you type
|
||||
into `filename_prefix` — no toggle, no extra pin:
|
||||
|
||||
| `filename_prefix` value | Where the file lands |
|
||||
|---|---|
|
||||
| `AnimateDiff`, `clip01`, `shots/v003` | `<ComfyUI>/output/<prefix>_<counter>.<ext>` (upstream behavior, sandboxed) |
|
||||
| `E:/renders/shot01/v003` (Windows) | `E:/renders/shot01/v003_<counter>.<ext>` |
|
||||
| `/mnt/projects/shot01/v003` (Linux/Mac) | `/mnt/projects/shot01/v003_<counter>.<ext>` |
|
||||
| `E:/renders/shot01/v003/` (trailing slash) | `E:/renders/shot01/v003_<counter>.<ext>` (`v003` becomes the file root) |
|
||||
| `E:/renders/shot01/` (only a dir) | `E:/renders/shot01_<counter>.<ext>` — *probably not what you want; append a file root* |
|
||||
|
||||
The basename of the absolute path is treated as the **filename root**
|
||||
(not as a subdirectory). 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`. |
|
||||
| `create_path_if_missing` | `BOOLEAN` (default `false`) | **Koolook-only.** When `filename_prefix` is absolute and the parent 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,198 @@
|
||||
# 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 os
|
||||
from typing import Optional, Tuple
|
||||
|
||||
try:
|
||||
import folder_paths # ComfyUI core; always present at runtime.
|
||||
from videohelpersuite.nodes import VideoCombine as _VHS_VideoCombine
|
||||
_VHS_AVAILABLE = True
|
||||
_VHS_IMPORT_ERROR: Optional[BaseException] = None
|
||||
except ImportError as _exc:
|
||||
_VHS_AVAILABLE = False
|
||||
_VHS_IMPORT_ERROR = _exc
|
||||
|
||||
|
||||
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)
|
||||
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
|
||||
|
||||
|
||||
if _VHS_AVAILABLE:
|
||||
class Easy_VideoCombine(_VHS_VideoCombine):
|
||||
"""Path-aware variant of VHS's Video Combine.
|
||||
|
||||
Behavior matrix:
|
||||
|
||||
- ``filename_prefix`` is relative (e.g. ``"AnimateDiff"``,
|
||||
``"shots/clip01"``) — pure passthrough to
|
||||
:meth:`videohelpersuite.nodes.VideoCombine.combine_video`. The
|
||||
ComfyUI output-directory sandbox stays in force.
|
||||
- ``filename_prefix`` is absolute (e.g.
|
||||
``"E:/renders/shot01/v003"``) — the parent directory is
|
||||
resolved (and optionally created), then upstream's combine
|
||||
runs against that location. Counter (``_00001``), extension
|
||||
(``.mp4`` / ``.gif`` / …), metadata PNG, and audio mux all
|
||||
land alongside the video in ``abs_dir``.
|
||||
|
||||
The UI preview tile in the workflow shows the standard "video
|
||||
written" pill, but Comfy's ``/view`` endpoint can only serve
|
||||
files under its own output directory — so absolute-path renders
|
||||
don't surface a clickable thumbnail. The file is still written,
|
||||
and the absolute path appears in the saved-files list.
|
||||
"""
|
||||
|
||||
CATEGORY = "Koolook/Video"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
types = _VHS_VideoCombine.INPUT_TYPES()
|
||||
# Override upstream's bare filename_prefix entry with a
|
||||
# tooltip that documents the new dual-mode semantics.
|
||||
types["required"]["filename_prefix"] = (
|
||||
"STRING",
|
||||
{
|
||||
"default": "AnimateDiff",
|
||||
"tooltip": (
|
||||
"Relative path -> ComfyUI's output directory "
|
||||
"(sandboxed, identical to upstream). Absolute "
|
||||
"path (e.g. 'E:/renders/shot01/v003') -> writes "
|
||||
"directly there. Counter (_00001) and extension "
|
||||
"are appended; treat this as the filename root, "
|
||||
"not a directory."
|
||||
),
|
||||
},
|
||||
)
|
||||
types["optional"]["create_path_if_missing"] = (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": False,
|
||||
"tooltip": (
|
||||
"When filename_prefix is absolute and the parent "
|
||||
"directory does not exist, auto-create it. Off "
|
||||
"by default so typos surface as errors instead "
|
||||
"of creating stray directories."
|
||||
),
|
||||
},
|
||||
)
|
||||
return types
|
||||
|
||||
def combine_video(self, *args, **kwargs):
|
||||
filename_prefix = kwargs.get("filename_prefix", "AnimateDiff")
|
||||
create_path_if_missing = kwargs.pop("create_path_if_missing", False)
|
||||
|
||||
target = _resolve_abs_target(filename_prefix, create_path_if_missing)
|
||||
if target is None:
|
||||
return super().combine_video(*args, **kwargs)
|
||||
|
||||
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
|
||||
return super().combine_video(*args, **kwargs)
|
||||
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"]
|
||||
@@ -0,0 +1,98 @@
|
||||
"""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 _resolve_abs_target
|
||||
|
||||
|
||||
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:
|
||||
"""Trailing separator is normalized; dirname becomes the basename.
|
||||
|
||||
Matches spacepxl's SaveEXR convention — typing
|
||||
``"E:/renders/shot01/"`` is interpreted as "use 'shot01' as the
|
||||
file root inside 'E:/renders/'."
|
||||
"""
|
||||
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) == tmp_path
|
||||
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 filename component"):
|
||||
_resolve_abs_target(root, create_path_if_missing=False)
|
||||
Reference in New Issue
Block a user