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/.gitignore b/.gitignore index 182a8c7..e60f1ed 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ _safe* .venv/ +.tmp/ Runpod_Comfy/ PLAN.md diff --git a/CHANGELOG.md b/CHANGELOG.md index e2aa68d..33ed7b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.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 diff --git a/CLAUDE.md b/CLAUDE.md index 3d1b1dd..80b2cb6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/` diff --git a/README.md b/README.md index bc0565f..b42d580 100644 --- a/README.md +++ b/README.md @@ -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. 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..2ff2a94 --- /dev/null +++ b/docs/user_guide/nodes/koolook_video/easy_video_combine.md @@ -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_.` | +| `/mnt/projects/shot01` | `clip_v003` | `/mnt/projects/shot01/clip_v003_.` | +| `shots/v003` | `clip` | `/output/shots/v003/clip_.` (relative dir = sandboxed) | +| `E:/renders/shot01/` (trailing slash) | `clip` | `E:/renders/shot01/clip_.` (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` | `/output/_.` (upstream behavior, sandboxed) | +| `E:/renders/shot01/v003` (Windows, `v003` does not already exist as a folder) | `E:/renders/shot01/v003_.` | +| `/mnt/projects/shot01/v003` (Linux/Mac, `v003` does not already exist as a folder) | `/mnt/projects/shot01/v003_.` | +| `E:/renders/shot01/v003/` or an existing `E:/renders/shot01/v003` folder | `E:/renders/shot01/v003/v003_.` (`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. 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..81fe44c --- /dev/null +++ b/k_video_combine.py @@ -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 ``"_."`` 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"] diff --git a/pyproject.toml b/pyproject.toml index 8fdb50b..99da4b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/scripts/sync_to_dev.py b/scripts/sync_to_dev.py index 32db531..9d7493d 100644 --- a/scripts/sync_to_dev.py +++ b/scripts/sync_to_dev.py @@ -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", ) 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..ae473b0 --- /dev/null +++ b/tests/nodes/test_easy_video_combine.py @@ -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" diff --git a/video_formats/koolook-ASTRA-h264.json b/video_formats/koolook-ASTRA-h264.json new file mode 100644 index 0000000..e4ac029 --- /dev/null +++ b/video_formats/koolook-ASTRA-h264.json @@ -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" +} diff --git a/video_formats/koolook-ProRes.json b/video_formats/koolook-ProRes.json new file mode 100644 index 0000000..aced163 --- /dev/null +++ b/video_formats/koolook-ProRes.json @@ -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"}]] +} diff --git a/web/easy_video_combine.js b/web/easy_video_combine.js new file mode 100644 index 0000000..074877d --- /dev/null +++ b/web/easy_video_combine.js @@ -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 has been set to " 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); + } + }, +}); diff --git a/web/starter_preset.json b/web/starter_preset.json index 55e6477..3674a16 100644 --- a/web/starter_preset.json +++ b/web/starter_preset.json @@ -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": {}