From a40117c8c22fe481ac6058418b46a13fcdaa5fd8 Mon Sep 17 00:00:00 2001 From: malkuthro <166633396+malkuthro@users.noreply.github.com> Date: Sat, 16 May 2026 20:19:07 +0200 Subject: [PATCH] feat(video): add Easy_VideoCombine with absolute-path support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .github/copilot-instructions.md | 2 + CHANGELOG.md | 32 +++ __init__.py | 8 + .../nodes/koolook_video/easy_video_combine.md | 118 +++++++++++ forks/THIRD_PARTY.md | 47 +++++ forks/forks_manifest.yaml | 19 ++ k_video_combine.py | 198 ++++++++++++++++++ tests/nodes/__init__.py | 0 tests/nodes/test_easy_video_combine.py | 98 +++++++++ 9 files changed, 522 insertions(+) create mode 100644 docs/user_guide/nodes/koolook_video/easy_video_combine.md create mode 100644 k_video_combine.py create mode 100644 tests/nodes/__init__.py create mode 100644 tests/nodes/test_easy_video_combine.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index c415acf..7ffef6d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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`. diff --git a/CHANGELOG.md b/CHANGELOG.md index b5d2975..478139e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/__init__.py b/__init__.py index 34c54e4..e07bd78 100644 --- a/__init__.py +++ b/__init__.py @@ -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, } diff --git a/docs/user_guide/nodes/koolook_video/easy_video_combine.md b/docs/user_guide/nodes/koolook_video/easy_video_combine.md new file mode 100644 index 0000000..14866d2 --- /dev/null +++ b/docs/user_guide/nodes/koolook_video/easy_video_combine.md @@ -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` | `/output/_.` (upstream behavior, sandboxed) | +| `E:/renders/shot01/v003` (Windows) | `E:/renders/shot01/v003_.` | +| `/mnt/projects/shot01/v003` (Linux/Mac) | `/mnt/projects/shot01/v003_.` | +| `E:/renders/shot01/v003/` (trailing slash) | `E:/renders/shot01/v003_.` (`v003` becomes the file root) | +| `E:/renders/shot01/` (only a dir) | `E:/renders/shot01_.` — *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. diff --git a/forks/THIRD_PARTY.md b/forks/THIRD_PARTY.md index d02a4da..a26e391 100644 --- a/forks/THIRD_PARTY.md +++ b/forks/THIRD_PARTY.md @@ -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) diff --git a/forks/forks_manifest.yaml b/forks/forks_manifest.yaml index 3b0847f..cc104f1 100644 --- a/forks/forks_manifest.yaml +++ b/forks/forks_manifest.yaml @@ -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" diff --git a/k_video_combine.py b/k_video_combine.py new file mode 100644 index 0000000..c2d0b23 --- /dev/null +++ b/k_video_combine.py @@ -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 ``"_."`` 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"] diff --git a/tests/nodes/__init__.py b/tests/nodes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/nodes/test_easy_video_combine.py b/tests/nodes/test_easy_video_combine.py new file mode 100644 index 0000000..b751070 --- /dev/null +++ b/tests/nodes/test_easy_video_combine.py @@ -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)