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:
malkuthro
2026-05-16 20:19:07 +02:00
co-authored by Claude Opus 4.7
parent f74b30603e
commit a40117c8c2
9 changed files with 522 additions and 0 deletions
+2
View File
@@ -33,6 +33,8 @@ ComfyUI-Koolook is a custom-node pack for ComfyUI. Two shipped surfaces:
with front-end widgets in [`web/ai_pipeline.js`](../web/ai_pipeline.js)).
- [`k_easy_image_batch.py`](../k_easy_image_batch.py) → `easy_ImageBatch`.
- [`k_easy_track.py`](../k_easy_track.py) → `KoolookLoadCameraPosesAbsolute`.
- [`k_video_combine.py`](../k_video_combine.py) → `Easy_VideoCombine`
(runtime subclass of VHS `VideoCombine`; self-skips if VHS not installed).
- Radiance VAE wrappers under
[`forks/radiance_koolook/versions/v2_3_3/`](../forks/radiance_koolook/versions/v2_3_3/)
→ `Easy_hdr_VAE_encode`, `Easy_hdr_VAE_decode`.
+32
View File
@@ -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
+8
View File
@@ -17,6 +17,12 @@ try:
from .k_easy_pattern import NODE_CLASS_MAPPINGS as pattern_mappings
from .k_easy_pattern import NODE_DISPLAY_NAME_MAPPINGS as pattern_display
# Easy_VideoCombine self-skips registration when VHS isn't installed
# (its inner try/import returns empty mappings), so importing here is
# always safe even on workstations without ComfyUI-VideoHelperSuite.
from .k_video_combine import NODE_CLASS_MAPPINGS as video_combine_mappings
from .k_video_combine import NODE_DISPLAY_NAME_MAPPINGS as video_combine_display
from .forks.radiance_koolook import NODE_CLASS_MAPPINGS as radiance_koolook_mappings
from .forks.radiance_koolook import NODE_DISPLAY_NAME_MAPPINGS as radiance_koolook_display
except ImportError as _node_import_exc:
@@ -60,6 +66,7 @@ else:
**batch_mappings,
**cam_loader_mappings,
**pattern_mappings,
**video_combine_mappings,
**radiance_koolook_mappings,
}
@@ -70,6 +77,7 @@ else:
**batch_display,
**cam_loader_display,
**pattern_display,
**video_combine_display,
**radiance_koolook_display,
}
@@ -0,0 +1,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.
+47
View File
@@ -65,6 +65,53 @@ below — GPL-3.0 §5(c) requires the whole work to be GPL-3.0.
still load. The KJ Nodes attribution stays in the file header and here.
- **Last reviewed:** 2026-05-03
### Kosinkadink/ComfyUI-VideoHelperSuite — VideoCombine subclass (runtime composition)
- **Name:** ComfyUI-VideoHelperSuite (Kosinkadink)
- **Upstream repo URL:** https://github.com/Kosinkadink/ComfyUI-VideoHelperSuite
- **License:** GPL-3.0 (verified 2026-05-16 via raw `LICENSE` fetch)
- **Local path(s):** [`k_video_combine.py`](../k_video_combine.py)
— exposed as the `Easy_VideoCombine` ComfyUI node ID (display name
*"Easy Video Combine (Koolook)"*, category `Koolook/Video`).
- **What was incorporated (not copied):** A ~60-line subclass of
`videohelpersuite.nodes.VideoCombine`. No VHS source code is copied
into MAIN — the relationship is purely a Python runtime subclass that
imports VHS at module load and delegates almost the entire body of
`combine_video()` back to the parent class via `super()`. The only
Koolook-side logic is:
- An additional `create_path_if_missing` BOOLEAN input (optional,
default `False`).
- An `os.path.isabs(filename_prefix)` discriminator that bypasses
ComfyUI's output-directory sandbox when the user types an absolute
path.
- A scoped monkey-patch of `folder_paths.get_save_image_path` for the
duration of one `combine_video()` call so upstream's encoder /
audio mux / batch manager run unchanged but write into the
absolute target directory.
- **Pattern inspiration (separate from the runtime dependency on VHS):**
The `os.path.isabs(filename_prefix)` overload-the-existing-field
pattern is borrowed from
[`spacepxl/ComfyUI-HQ-Image-Save`](https://github.com/spacepxl/ComfyUI-HQ-Image-Save)'s
`SaveEXR` node (MIT, copyright 2023 spacepxl). MIT is GPL-3.0
compatible. No spacepxl source code is copied — only the pattern.
License verified 2026-05-16 via raw `LICENSE` fetch.
- **Why a subclass rather than a fork:** Patching the upstream VHS
install on every workstation is fragile (ComfyUI-Manager auto-updates
blow patches away); a global monkey-patch at Koolook import time
changes behavior for every VHS node whether the user asked for it or
not. A separate node opted into by placing it on the canvas is the
additive, neighborly approach.
- **Runtime dependency:** Requires
`Kosinkadink/ComfyUI-VideoHelperSuite` to be installed alongside
Koolook. If VHS isn't importable at module load, `k_video_combine.py`
prints a one-line `[Koolook] Easy_VideoCombine skipped: …` notice
and returns empty NODE_CLASS_MAPPINGS — the rest of Koolook is
unaffected.
- **Upstream PR plan:** the same `isabs` discrimination is a candidate
to upstream into VHS proper. If/when accepted, `Easy_VideoCombine`
can be deprecated in favour of `VHS_VideoCombine` directly.
- **Last reviewed:** 2026-05-16
### fxtdstudios/radiance — v2.3.3 VAE subset (Koolook fork)
- **Name:** Radiance (FXTD Studios)
+19
View File
@@ -54,6 +54,25 @@ entries:
- "EasyResize" # deprecated alias for workflow back-compat
notes: "Idea-level inspiration from KJ Nodes' Resize Image V2 (GPL-3.0 — same as our pack, so no relicense pressure). Implementation is a fresh write that substantially extends the surface (aspect-ratio parser, keep_proportion modes, mask + composed outputs, device selection, etc). Renamed in v0.1.6 from bare 'EasyResize' to 'EasyResize_Koolook' to resolve a node-ID collision with ComfyUI-EasyFilePaths."
- id: "video_combine_path_aware_subclass"
name: "Easy Video Combine (Koolook subclass of VHS VideoCombine)"
source_repo: "https://github.com/Kosinkadink/ComfyUI-VideoHelperSuite"
source_ref: "main (runtime composition; no upstream code copied)"
local_paths:
- "k_video_combine.py"
status: "subclass_runtime_dep"
sync_state: "no-upstream-tracking-needed"
license: "GPL-3.0"
license_verified_at: "2026-05-16"
exposed_node_ids:
- "Easy_VideoCombine"
pattern_inspiration:
source_repo: "https://github.com/spacepxl/ComfyUI-HQ-Image-Save"
license: "MIT"
license_verified_at: "2026-05-16"
notes: "os.path.isabs(filename_prefix) discrimination pattern from SaveEXR — overload the existing field for relative vs absolute paths rather than adding a new input pin. No source code copied; pattern only."
notes: "Thin subclass of videohelpersuite.nodes.VideoCombine (~60 LoC). Scoped-patches folder_paths.get_save_image_path for the duration of one combine_video() call so upstream's encoder/audio/batch-manager pipeline runs unchanged but writes to an absolute path when filename_prefix is absolute. Relative prefixes pass through to upstream verbatim. VHS is a runtime dependency only — if it isn't installed, k_video_combine.py logs a one-line skip and the node is omitted from NODE_CLASS_MAPPINGS."
- id: "promptjson_integration_reference"
name: "PromptJSON interoperability reference"
source_repo: "https://github.com/NeuralSamurAI/ComfyUI-PromptJSON"
+198
View File
@@ -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"]
View File
+98
View File
@@ -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)