* docs(setups): add setup runner quickstart runbook Standalone reference for opening the published-setup mockup frontend (setup runner simulator): the `<comfy-address>/koolook/setup_runner_simulator.html` URL pattern, the live-registry vs setup-file (inspect-only) load paths, and demo mode. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(sidebar): close throwaway workflow tabs left by publish capture Publishing a setup captures ComfyUI's API-format prompt by loading the graph onto the canvas (graphToPrompt) and reloading the prior canvas. In current Comfy each loadGraphData spawns an "Unsaved Workflow" tab, so every publish left two stray tabs behind. captureWorkflowApiPrompt now snapshots the open workflows before capture and, afterward, re-activates the user's original tab and closes whatever the capture opened, via app.extensionManager.workflow. All of it is guarded and best-effort: the workflow-store API is Comfy-version specific, so if it is absent capture still succeeds and behavior falls back to today (no crash, no broken publish). Covered by a source-pattern test. Needs live verification against the 1.45 frontend (dev-sync) to confirm the closeWorkflow API shape. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(nodes): Easy_LoadVideo degrades to empty on an unselected folder branch When an upstream mode switch routes a path into Easy_LoadVideo, both the EXR sequence loader and the video loader execute eagerly. On the EXR branch the video loader was handed a folder (no filename) and raised ValueError, crashing the whole prompt -- even though that branch is discarded downstream. Lenient sequence loaders just ignore a wrong-type path; the video loader was the only one that hard-failed. _compose_input_video_path now returns EMPTY_BRANCH_SENTINEL instead of raising when input_path resolves to an existing directory. load_video turns that into a correctly-shaped empty result (IMAGE zeros, frame_count 0, empty AUDIO, empty video_info) matching VHS_LoadVideoPath returns, so the dead branch no longer crashes the prompt. VALIDATE_INPUTS and IS_CHANGED handle the sentinel too. A path that does not exist is still a genuine mistake and keeps raising. torch is imported lazily in the empty-result builder so the path logic stays importable/testable without torch. Covered by unit tests for the sentinel (absolute + relative dirs), the still-raises case, and a load_video integration test that asserts the empty result and that the loader never runs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(setups): expose prompt as an always-on field in the publish frontend The publish surface modelled `prompt` as a 4th source mode (mutually exclusive with EXR/QT/Img) and hid it (visible:false), so a published setup couldn't offer "pick a source AND describe the shot". This makes prompt a standalone, always-on input. - published_surface.js: prompt becomes visible + standalone + multiline, rendered independently of the source switch; the author's prompt-widget text is surfaced as a `placeholder` hint (submitted default is empty so an untouched hint is never sent), with a `help` line. The switch excludes standalone fields from its source options. - setup_runner_simulator.js: renders always-on standalone inputs alongside the selected source (textarea + placeholder + help), includes them in the run payload, and updates the demo setup. - setup_runner_simulator.html: .field-help styling. - contract doc: documents the always-on prompt behaviour. - launch.json: portability fix (hardcoded C:\Python313 -> python). Contract test updated + a new test asserting prompt is always-on, carries the placeholder, and is excluded from the switch source options (runs on CI where Node is available). Behaviour verified in the runner simulator (demo mode) in a browser: textarea + hint, EXR/QT/Img-only switch, prompt stays visible across source changes, payload includes the typed prompt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(setups): mirror always-on prompt field in the Python publish inference The publish surface is inferred server-side in Python (koolook_setups.py _infer_app_surface, called by publishSetup), which overrides the client. The earlier change only updated the JS (published_surface.js), so re-publishing still produced the old surface with a hidden prompt. Mirror the JS here: prompt is a standalone, always-on input (visible + multiline) with the author's prompt-widget text as a placeholder hint and an empty submitted default; it is excluded from the source switch options. Keeps koolook_setups.py and published_surface.js in sync. Registry test updated to the new shape. Server + publish-contract suites green (115); full non-sidebar suite green (530). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(setups): keep the runner simulator polling through long renders The poller used a fixed 30s total budget, so a multi-minute render (e.g. 428s) timed out mid-run with "Timed out waiting for run" even though ComfyUI was still rendering and the run later landed as succeeded. timeoutMs is now a "no-progress" window: while ComfyUI keeps reporting the run as queued/running, the deadline is pushed forward, so a long-but-active render never spuriously times out; only a stalled/vanished run gives up. Default raised 30s -> 120s. The run status line shows elapsed seconds while active so a long wait visibly ticks instead of looking frozen. Happy path (queued -> running -> succeeded) unchanged; verified in the runner simulator (demo mode) in a browser. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(setups): serve simulator assets with no-store so dev-syncs take effect The runner-simulator HTML/JS route returned no cache headers, so browsers cached the assets and kept running stale JS after a dev-sync -- e.g. the old 30s poller persisted client-side even though the server was serving the long-render fix, which looked like "the fix didn't land". Serve these dev-synced assets with Cache-Control: no-store so a normal reload always picks up the current code. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(setups): add live run/poll smoke check for the published-setup API tools/check_setup_run.py queues a published setup through the same routes the runner simulator uses and polls the run endpoint with the same deadline-extension logic, printing the status timeline. Lets us self-verify end-to-end run tracking against a live ComfyUI (e.g. "does a long render reach succeeded?") without driving the browser UI. Used it to confirm the run/poll server flow is correct (terminal status detected in ~2s; run-000001 reported succeeded), isolating the simulator's earlier "Timed out" to stale browser cache rather than a server bug. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(nodes): Koolook_NextVersion auto-detects the next output version Adds filesystem-aware version auto-detection so a re-run lands on a fresh version instead of failing with "Output file already exists and overwrite is disabled" (the real EasyAIPipeline failure seen on the RMGB setup). - koolook_versioning.next_version_token(directory, name, ...): scans for existing <name>_vNNN files AND sequence-folders (handles name_v002.png, name_v002.0001.exr, name_v003/), returns highest+1 zero-padded; missing/ empty dir -> start (v001); exact base-name match so other shots never bump this one; empty name matches bare vNNN; configurable prefix/padding. - Koolook_NextVersion node (Koolook/Publish): folder + name -> next version token. Wire once into both the EXR and video version inputs so branches stay in sync. IS_CHANGED returns NaN to re-scan every run. Tested: helper edge cases + node registration/run. Preflight clean (20 node IDs). Full non-sidebar suite green (539). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(nodes): 'auto' version keyword for EasyAIPipeline + Easy_VideoCombine No new node or wiring needed: type 'auto' (or 'next') in the version field and the node scans its own output folder for existing <name>_vNNN entries and writes the next free version -- fixing "Output file already exists and overwrite is disabled" without manual version bumps. - koolook_versioning.is_auto_version(): detects the auto/next keyword. - EasyAIPipeline (build_pipeline_outputs): on auto, scans the version-folder parent (base, or base/shot/method when subfolders are on) for vNNN dirs and picks the next; version computed after path segments so the scan dir is known. - Easy_VideoCombine: on auto, resolves the token after the output dir + name root are composed, scanning that dir for <name>_vNNN outputs. - Non-auto/typed/wired tokens and disable_versioning are unchanged. Tests: is_auto_version keywords + EasyAIPipeline auto (v003 after v001/v002, v001 first run). Full non-sidebar suite green (542), preflight clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(nodes): drop redundant Koolook_NextVersion node The 'auto' version keyword on EasyAIPipeline + Easy_VideoCombine covers auto-detection without any new node or wiring, so the standalone Koolook_NextVersion node is redundant. Remove it (and its node test) to keep the node list clean; the shared next_version_token helper stays (the 'auto' keyword uses it). Node-ID count back to 19; full non-sidebar suite green (541), preflight clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tools): satisfy security gate for setup smoke check * fix(pr244): address verified review findings * fix(video): fail if relative auto-version scan root is unavailable --------- Co-authored-by: Cristian Predut <cristian.predut@x.baconcph.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: AI Machine <ai.machine@x.baconcph.com>
908 lines
37 KiB
Python
908 lines
37 KiB
Python
# 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
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Optional, Tuple
|
|
|
|
try:
|
|
from .koolook_versioning import (
|
|
is_auto_version,
|
|
next_version_token,
|
|
resolve_version_token,
|
|
)
|
|
except ImportError: # pragma: no cover - standalone (pytest / tooling)
|
|
from koolook_versioning import (
|
|
is_auto_version,
|
|
next_version_token,
|
|
resolve_version_token,
|
|
)
|
|
|
|
try:
|
|
import folder_paths # ComfyUI core; always present at runtime.
|
|
_KOLOOK_VIDEO_FORMATS_DIR = Path(__file__).resolve().parent / "video_formats"
|
|
if _KOLOOK_VIDEO_FORMATS_DIR.exists():
|
|
_paths, _exts = folder_paths.folder_names_and_paths.get(
|
|
"VHS_video_formats",
|
|
((), {".json"}),
|
|
)
|
|
_format_path = str(_KOLOOK_VIDEO_FORMATS_DIR)
|
|
if _format_path not in _paths:
|
|
folder_paths.folder_names_and_paths["VHS_video_formats"] = (
|
|
tuple(_paths) + (_format_path,),
|
|
set(_exts) | {".json"},
|
|
)
|
|
|
|
# Reach the VHS VideoCombine class through ComfyUI's global node
|
|
# registry rather than `from videohelpersuite.nodes import ...`.
|
|
# VHS uses relative imports internally (`from .logger import ...`,
|
|
# `from .utils import ...`) which only resolve correctly when VHS
|
|
# is loaded through ComfyUI's custom-node loader, not through a
|
|
# plain absolute import. The global registry is the documented
|
|
# cross-pack interface — ComfyUI populates it as each pack loads,
|
|
# and Koolook loads after VHS in every install we've seen because
|
|
# `comfyui-videohelpersuite` sorts before `koolook` (and even an
|
|
# uppercase `ComfyUI-VideoHelperSuite` sorts before
|
|
# `ComfyUI-Koolook` if both are present). If for some reason VHS
|
|
# isn't loaded yet, the lookup returns None and we self-skip with
|
|
# a clear message.
|
|
import nodes as _comfy_nodes_module
|
|
_VHS_VideoCombine = _comfy_nodes_module.NODE_CLASS_MAPPINGS.get("VHS_VideoCombine")
|
|
if _VHS_VideoCombine is None:
|
|
raise RuntimeError(
|
|
"VHS_VideoCombine not present in ComfyUI's NODE_CLASS_MAPPINGS "
|
|
"at Koolook load time. Either ComfyUI-VideoHelperSuite isn't "
|
|
"installed, or it loaded after Koolook (unexpected — check "
|
|
"custom_nodes folder names)."
|
|
)
|
|
_VHS_AVAILABLE = True
|
|
_VHS_IMPORT_ERROR: Optional[BaseException] = None
|
|
except (ImportError, RuntimeError) as _exc:
|
|
_VHS_AVAILABLE = False
|
|
_VHS_IMPORT_ERROR = _exc
|
|
_VHS_VideoCombine = None # type: ignore[assignment]
|
|
|
|
|
|
def _resolve_abs_target(
|
|
filename_prefix: str,
|
|
create_path_if_missing: bool,
|
|
) -> Optional[Tuple[str, str]]:
|
|
"""Discriminate relative vs absolute ``filename_prefix``.
|
|
|
|
Returns ``None`` when the prefix is relative — caller should let
|
|
upstream's sandboxed flow handle it unchanged. Returns
|
|
``(abs_dir, abs_basename)`` when the prefix is absolute; upstream's
|
|
counter pattern will append ``"_<counter:05>.<ext>"`` to
|
|
``abs_basename`` inside ``abs_dir``.
|
|
|
|
Raises ``FileNotFoundError`` if the parent directory does not exist
|
|
and ``create_path_if_missing`` is ``False`` — a typo in a render
|
|
path should surface as a clear error, not silently spawn a stray
|
|
directory tree. Raises ``ValueError`` if the absolute prefix has no
|
|
filename component (e.g. a bare drive root like ``"C:\\"``).
|
|
"""
|
|
if not os.path.isabs(filename_prefix):
|
|
return None
|
|
|
|
normed = os.path.normpath(filename_prefix)
|
|
if os.path.isdir(normed):
|
|
abs_dir = normed
|
|
abs_base = os.path.basename(normed.rstrip("/\\"))
|
|
if abs_base == "":
|
|
raise ValueError(
|
|
f"filename_prefix '{filename_prefix}' is an absolute directory "
|
|
f"but has no usable folder name for the filename root."
|
|
)
|
|
return abs_dir, abs_base
|
|
|
|
abs_dir = os.path.dirname(normed)
|
|
abs_base = os.path.basename(normed)
|
|
|
|
if abs_base == "":
|
|
raise ValueError(
|
|
f"filename_prefix '{filename_prefix}' is an absolute path but "
|
|
f"has no filename component. Append a filename root, e.g. "
|
|
f"'{normed.rstrip(os.sep)}{os.sep}clipname'."
|
|
)
|
|
|
|
if not os.path.exists(abs_dir):
|
|
if create_path_if_missing:
|
|
os.makedirs(abs_dir, exist_ok=True)
|
|
else:
|
|
raise FileNotFoundError(
|
|
f"Directory does not exist: {abs_dir}. Enable "
|
|
f"'create_path_if_missing' on the Easy Video Combine node "
|
|
f"to auto-create it."
|
|
)
|
|
|
|
return abs_dir, abs_base
|
|
|
|
|
|
_SENTINEL_STRINGS = ("undefined", "null", "none")
|
|
|
|
|
|
def _normalize_text_input(value) -> str:
|
|
"""Coerce a ComfyUI STRING widget value to a clean Python string.
|
|
|
|
ComfyUI's frontend has a long-running quirk: when a STRING input
|
|
with an empty default sits untouched, the value sometimes arrives
|
|
at the backend as the literal string ``"undefined"`` (or ``"null"``
|
|
/ ``"None"``) instead of ``""``. Treating those as valid input
|
|
causes nonsensical ``undefined/`` subdirectories on disk.
|
|
|
|
Also defends against:
|
|
- Surrounding quotes (users paste paths from Explorer with quotes).
|
|
- Trailing whitespace including newlines (WAS Text Multiline can
|
|
leave a trailing newline when the user pressed Enter at the end).
|
|
- Embedded sentinel components separated by a newline (a multi-line
|
|
Text Multiline node with a second sentinel-looking line).
|
|
|
|
Any sentinel string (case-insensitive, post-strip) becomes ``""``;
|
|
everything else is the cleaned value.
|
|
"""
|
|
if value is None:
|
|
return ""
|
|
s = str(value).strip().strip('"').strip("'").strip()
|
|
# If a newline split the value across lines, take the first
|
|
# non-empty non-sentinel line.
|
|
if "\n" in s or "\r" in s:
|
|
for line in s.replace("\r", "\n").split("\n"):
|
|
line = line.strip()
|
|
if line and line.lower() not in _SENTINEL_STRINGS:
|
|
s = line
|
|
break
|
|
else:
|
|
s = ""
|
|
if s.lower() in _SENTINEL_STRINGS:
|
|
return ""
|
|
return s
|
|
|
|
|
|
def _normalize_bool_input(value, default: bool = False) -> bool:
|
|
"""Coerce ComfyUI widget values to a real bool.
|
|
|
|
This is intentionally stricter than Python truthiness. A corrupted
|
|
saved workflow can shift a format-widget value such as ``"hq"`` into
|
|
the ``pingpong`` slot; ``bool("hq")`` would enable ping-pong and
|
|
double the video. Unknown strings fall back to ``default``.
|
|
"""
|
|
if isinstance(value, bool):
|
|
return value
|
|
if value is None:
|
|
return default
|
|
if isinstance(value, (int, float)):
|
|
return bool(value)
|
|
s = str(value).strip().lower()
|
|
if s in ("true", "1", "yes", "on"):
|
|
return True
|
|
if s in ("false", "0", "no", "off", ""):
|
|
return False
|
|
return default
|
|
|
|
|
|
def _strip_sentinel_components(path: str) -> str:
|
|
"""Remove any path component that's exactly a sentinel string.
|
|
|
|
Belt-and-suspenders defense: if anything upstream of us managed
|
|
to inject ``undefined`` / ``null`` / ``None`` as a path component
|
|
(despite the per-field normalization above), drop it before the
|
|
path reaches the filesystem. Preserves the rest of the path,
|
|
including the drive root on Windows.
|
|
|
|
Returns the path unchanged if no sentinels found, so this is a
|
|
cheap pass for the common case.
|
|
"""
|
|
if not path:
|
|
return path
|
|
# Split on whichever separator is present, defaulting to os.sep.
|
|
sep = "\\" if "\\" in path else "/"
|
|
parts = path.split(sep)
|
|
cleaned = [
|
|
p for p in parts
|
|
if p == "" or p.lower() not in _SENTINEL_STRINGS
|
|
]
|
|
if len(cleaned) == len(parts):
|
|
return path
|
|
return sep.join(cleaned)
|
|
|
|
|
|
def _compose_prefix(filename_prefix: str, output_directory: str) -> str:
|
|
"""Combine ``output_directory`` + ``filename_prefix`` into one path-prefix.
|
|
|
|
When ``output_directory`` is empty, ``filename_prefix`` is returned
|
|
unchanged — it retains the overloaded "relative-or-absolute prefix"
|
|
semantics for back-compat with users who like the one-field flow.
|
|
|
|
When ``output_directory`` is set, only the basename of
|
|
``filename_prefix`` becomes the filename root (any path components
|
|
the user accidentally typed there are stripped, so they can change
|
|
the name without re-typing the directory). The directory keeps its
|
|
shape (absolute or relative), so the downstream
|
|
:func:`_resolve_abs_target` discrimination still works on the
|
|
composed prefix.
|
|
"""
|
|
if not output_directory:
|
|
return filename_prefix
|
|
name_root = os.path.basename(filename_prefix.rstrip("/\\")) or "AnimateDiff"
|
|
clean_dir = output_directory.rstrip("/\\")
|
|
return clean_dir + os.sep + name_root
|
|
|
|
|
|
def _display_format_name(format_name: str) -> str:
|
|
"""Hide the JSON suffix VHS exposes for external Koolook presets."""
|
|
if format_name.startswith("video/koolook-") and format_name.endswith(".json"):
|
|
return format_name[:-5]
|
|
return format_name
|
|
|
|
|
|
def _runtime_format_name(format_name: str) -> str:
|
|
"""Map cleaned Koolook display names back to VHS's external file names."""
|
|
if format_name.startswith("video/koolook-") and not format_name.endswith(".json"):
|
|
return format_name + ".json"
|
|
return format_name
|
|
|
|
|
|
def _metadata_sidecar_path(output_files: list[str]) -> Optional[str]:
|
|
"""Pick the JSON sidecar path from VHS's output file list."""
|
|
if not output_files:
|
|
return None
|
|
return os.path.splitext(output_files[0])[0] + ".json"
|
|
|
|
|
|
def _add_metadata_json_sidecar(
|
|
result,
|
|
metadata_payload: dict,
|
|
save_metadata_json: bool,
|
|
save_metadata_png: bool,
|
|
):
|
|
"""Write a workflow metadata JSON next to VHS's output and return result."""
|
|
if not save_metadata_json or not isinstance(result, dict):
|
|
return result
|
|
try:
|
|
output_files = result.get("result", ((None, []),))[0][1]
|
|
if not isinstance(output_files, list):
|
|
return result
|
|
json_path = _metadata_sidecar_path(output_files)
|
|
if not json_path:
|
|
return result
|
|
Path(json_path).write_text(
|
|
json.dumps(metadata_payload, indent=2, sort_keys=True) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
if save_metadata_png:
|
|
output_files.append(json_path)
|
|
elif output_files and output_files[0].lower().endswith(".png"):
|
|
output_files[0] = json_path
|
|
else:
|
|
output_files.insert(0, json_path)
|
|
ui = result.get("ui", {})
|
|
gifs = ui.get("gifs", [])
|
|
if gifs and isinstance(gifs[0], dict) and gifs[0].get("workflow", "").endswith(".png"):
|
|
gifs[0]["workflow"] = os.path.basename(json_path)
|
|
except Exception as exc:
|
|
print(f"[Easy_VideoCombine] metadata JSON sidecar skipped: {exc!r}")
|
|
return result
|
|
|
|
|
|
def _remove_audio_suffix_from_result(result, keep_silent_intermediate: bool):
|
|
"""Rename VHS's final ``-audio`` mux output to the clean base name."""
|
|
if keep_silent_intermediate or not isinstance(result, dict):
|
|
return result
|
|
try:
|
|
output_files = result.get("result", ((None, []),))[0][1]
|
|
if not isinstance(output_files, list) or not output_files:
|
|
return result
|
|
final_path = Path(output_files[-1])
|
|
if final_path.stem.endswith("-audio"):
|
|
clean_path = final_path.with_name(final_path.stem[:-6] + final_path.suffix)
|
|
if clean_path.exists() and clean_path != final_path:
|
|
clean_path.unlink()
|
|
final_path.replace(clean_path)
|
|
output_files[-1] = str(clean_path)
|
|
ui = result.get("ui", {})
|
|
gifs = ui.get("gifs", [])
|
|
if gifs and isinstance(gifs[0], dict):
|
|
gifs[0]["filename"] = clean_path.name
|
|
gifs[0]["fullpath"] = str(clean_path)
|
|
output_files[:] = [
|
|
path for path in output_files
|
|
if not isinstance(path, str) or os.path.exists(path)
|
|
]
|
|
deduped = []
|
|
for path in output_files:
|
|
if path not in deduped:
|
|
deduped.append(path)
|
|
output_files[:] = deduped
|
|
except Exception as exc:
|
|
print(f"[Easy_VideoCombine] audio suffix cleanup skipped: {exc!r}")
|
|
return result
|
|
|
|
|
|
# VHS appends a zero-padded counter to every output (e.g. ``clip_00001.mp4``).
|
|
# Strict-version mode strips it so the filename is exactly ``<root>_<token>``.
|
|
_COUNTER_RE = re.compile(r"_\d{5}$")
|
|
|
|
|
|
def _build_sidecar_workflow(prompt, extra_pnginfo, creation_time):
|
|
"""Build the JSON sidecar as a **drag-loadable ComfyUI workflow**.
|
|
|
|
ComfyUI restores a workflow from the litegraph graph at the TOP LEVEL of
|
|
the JSON (``nodes`` / ``links`` / ``last_node_id`` ...). That graph is
|
|
carried in ``extra_pnginfo['workflow']``, so we write it directly —
|
|
dropping the sidecar onto the canvas reloads the graph, exactly like
|
|
ComfyUI's own "Save". ``CreationTime`` is tucked into the graph's
|
|
``extra`` so it is preserved without breaking loadability.
|
|
|
|
Falls back to ``{CreationTime, prompt}`` when no litegraph graph is
|
|
available (e.g. the headless / API ``/prompt`` path, which carries only
|
|
the API-format prompt, not a positioned graph) so the sidecar is never
|
|
empty — just not drag-loadable in that case.
|
|
"""
|
|
workflow = extra_pnginfo.get("workflow") if isinstance(extra_pnginfo, dict) else None
|
|
if isinstance(workflow, dict) and "nodes" in workflow:
|
|
graph = dict(workflow)
|
|
extra = dict(graph.get("extra") or {})
|
|
extra.setdefault("CreationTime", creation_time)
|
|
graph["extra"] = extra
|
|
return graph
|
|
payload = {"CreationTime": creation_time}
|
|
if prompt is not None:
|
|
payload["prompt"] = prompt
|
|
return payload
|
|
|
|
|
|
def _append_version_to_prefix(prefix: str, token: str) -> str:
|
|
"""Suffix the path-prefix's filename root with ``_<token>``.
|
|
|
|
``effective_prefix`` always ends in a filename component at the call site
|
|
(``_compose_prefix`` guarantees a name root, never a bare directory), so the
|
|
token attaches directly to the end — e.g. ``E:/renders/clip`` ->
|
|
``E:/renders/clip_v001``.
|
|
"""
|
|
if not token:
|
|
return prefix
|
|
return f"{prefix}_{token}"
|
|
|
|
|
|
def _auto_version_scan_target(effective_prefix: str) -> tuple[str, str]:
|
|
"""Return ``(directory, name)`` matching where VHS will write the prefix."""
|
|
name = os.path.basename(effective_prefix.rstrip("/\\"))
|
|
directory = os.path.dirname(effective_prefix)
|
|
if os.path.isabs(effective_prefix):
|
|
return directory, name
|
|
output_root = folder_paths.get_output_directory()
|
|
return os.path.normpath(os.path.join(output_root, directory)), name
|
|
|
|
|
|
def _coerce_version_input(raw):
|
|
"""Defend the ``version`` field against a stale boolean.
|
|
|
|
Removing the old ``keep_silent_intermediate`` BOOLEAN widget shifts a saved
|
|
workflow's stored value into the new same-position ``version`` STRING slot
|
|
(widgets serialize by index — see docs/maintainers/node-versioning.md). A
|
|
leftover bool would otherwise become the literal token ``"False"`` / ``"True"``
|
|
and silently force strict mode (``clip_False.mp4``). Treat any boolean — or
|
|
its string form — as "no version" so old workflows fall back to the legacy
|
|
counter, exactly as before.
|
|
"""
|
|
if isinstance(raw, bool):
|
|
return ""
|
|
if str(raw).strip().lower() in ("true", "false"):
|
|
return ""
|
|
return raw
|
|
|
|
|
|
def _write_sidecar(json_path: str, metadata_payload: dict) -> bool:
|
|
"""Write the metadata JSON sidecar to ``json_path``. True on success."""
|
|
try:
|
|
Path(json_path).write_text(
|
|
json.dumps(metadata_payload, indent=2, sort_keys=True) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
return True
|
|
except Exception as exc:
|
|
print(f"[Easy_VideoCombine] metadata JSON sidecar skipped: {exc!r}")
|
|
return False
|
|
|
|
|
|
def _strip_counter_path(path_str: str, enable_overwrite: bool) -> str:
|
|
"""Rename a rendered video to drop VHS's trailing ``_NNNNN`` counter.
|
|
|
|
Collision: if the counter-free name already exists and ``enable_overwrite``
|
|
is False, keep the counter (return the original path) so nothing is
|
|
clobbered; if True, replace it. Returns the resulting path string.
|
|
"""
|
|
p = Path(path_str)
|
|
stripped = _COUNTER_RE.sub("", p.stem)
|
|
if stripped == p.stem:
|
|
return path_str
|
|
candidate = p.with_name(stripped + p.suffix)
|
|
if candidate == p:
|
|
return path_str
|
|
if candidate.exists():
|
|
if not enable_overwrite:
|
|
print(
|
|
f"[Easy_VideoCombine] {candidate.name} exists; kept counter "
|
|
f"suffix to avoid overwrite (enable_overwrite to replace)."
|
|
)
|
|
return path_str
|
|
candidate.unlink()
|
|
print(f"[Easy_VideoCombine] replaced existing {candidate.name}.")
|
|
p.replace(candidate)
|
|
return str(candidate)
|
|
|
|
|
|
def _finalize_strict_version_output(
|
|
result,
|
|
enable_overwrite: bool,
|
|
metadata_payload: dict,
|
|
save_metadata_json: bool,
|
|
save_metadata_png: bool = False,
|
|
):
|
|
"""Strict-version finalize: strip VHS's ``_NNNNN`` counter from the final
|
|
video so the name is exactly ``<root>_<token>.<ext>``, align a kept
|
|
metadata PNG to the same stem, and write the JSON sidecar beside them.
|
|
|
|
Collision is handled in :func:`_strip_counter_path` (lossless unless
|
|
``enable_overwrite``); the sidecar always matches the video's final name.
|
|
"""
|
|
if 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
|
|
|
|
new_video = _strip_counter_path(output_files[-1], enable_overwrite)
|
|
if new_video != output_files[-1]:
|
|
output_files[-1] = new_video
|
|
ui = result.get("ui", {})
|
|
gifs = ui.get("gifs", [])
|
|
if gifs and isinstance(gifs[0], dict):
|
|
gifs[0]["filename"] = os.path.basename(new_video)
|
|
gifs[0]["fullpath"] = new_video
|
|
video_stem_path = os.path.splitext(new_video)[0]
|
|
|
|
# Keep the metadata PNG (when requested) under the video's stem so the
|
|
# PNG/JSON/video trio share one name; otherwise it's dropped below.
|
|
if (
|
|
save_metadata_png
|
|
and isinstance(output_files[0], str)
|
|
and output_files[0].lower().endswith(".png")
|
|
and output_files[0] != new_video
|
|
):
|
|
png = Path(output_files[0])
|
|
aligned = png.with_name(os.path.basename(video_stem_path) + png.suffix)
|
|
if aligned != png:
|
|
try:
|
|
if aligned.exists():
|
|
aligned.unlink()
|
|
png.replace(aligned)
|
|
output_files[0] = str(aligned)
|
|
except OSError as exc:
|
|
print(f"[Easy_VideoCombine] metadata PNG rename skipped: {exc!r}")
|
|
|
|
if save_metadata_json:
|
|
json_path = video_stem_path + ".json"
|
|
if _write_sidecar(json_path, metadata_payload):
|
|
if save_metadata_png:
|
|
if json_path not in output_files:
|
|
output_files.append(json_path)
|
|
elif (
|
|
isinstance(output_files[0], str)
|
|
and output_files[0].lower().endswith(".png")
|
|
):
|
|
output_files[0] = json_path
|
|
elif json_path not in output_files:
|
|
output_files.insert(0, json_path)
|
|
ui = result.get("ui", {})
|
|
gifs = ui.get("gifs", [])
|
|
if (
|
|
gifs
|
|
and isinstance(gifs[0], dict)
|
|
and str(gifs[0].get("workflow", "")).endswith(".png")
|
|
):
|
|
gifs[0]["workflow"] = os.path.basename(json_path)
|
|
except Exception as exc:
|
|
print(f"[Easy_VideoCombine] strict-version finalize skipped: {exc!r}")
|
|
return result
|
|
|
|
|
|
def _finalize_output(
|
|
result,
|
|
version_token: str,
|
|
enable_overwrite: bool,
|
|
metadata_payload: dict,
|
|
save_metadata_json: bool,
|
|
save_metadata_png: bool,
|
|
):
|
|
"""Route to strict-version finalize (token set -> counter stripped) or the
|
|
legacy sidecar attach (token empty -> VHS counter kept)."""
|
|
if version_token:
|
|
return _finalize_strict_version_output(
|
|
result, enable_overwrite, metadata_payload, save_metadata_json, save_metadata_png
|
|
)
|
|
return _add_metadata_json_sidecar(
|
|
result, metadata_payload, save_metadata_json, save_metadata_png
|
|
)
|
|
|
|
|
|
_VIDEO_OUTPUT_EXTENSIONS = {
|
|
".avi",
|
|
".gif",
|
|
".m4v",
|
|
".mkv",
|
|
".mov",
|
|
".mp4",
|
|
".webm",
|
|
}
|
|
|
|
|
|
def _final_video_path_from_result(result) -> str:
|
|
"""Return the final video path from VHS's mixed output-file list.
|
|
|
|
VHS/Koolook sidecars can sit before or after the rendered clip. If more
|
|
than one video-like path appears, the later one is treated as final because
|
|
VHS appends muxed/renamed outputs after earlier intermediates.
|
|
"""
|
|
if not isinstance(result, dict):
|
|
return ""
|
|
try:
|
|
output_files = result.get("result", ((None, []),))[0][1]
|
|
except (IndexError, TypeError):
|
|
return ""
|
|
if not isinstance(output_files, list):
|
|
return ""
|
|
string_paths = [path for path in output_files if isinstance(path, str)]
|
|
for path in reversed(string_paths):
|
|
if Path(path).suffix.lower() in _VIDEO_OUTPUT_EXTENSIONS:
|
|
return path
|
|
return string_paths[-1] if string_paths else ""
|
|
|
|
|
|
def _final_json_path_from_result(result, video_path: str = "") -> str:
|
|
"""Return the JSON sidecar path from VHS/Koolook's output-file list.
|
|
|
|
Normal combine runs include the JSON in ``result``. The video-stem fallback
|
|
matches Koolook's current sidecar convention and is only for defensive
|
|
best-effort output when upstream omits the JSON path from the list.
|
|
"""
|
|
if isinstance(result, dict):
|
|
try:
|
|
output_files = result.get("result", ((None, []),))[0][1]
|
|
except (IndexError, TypeError):
|
|
output_files = []
|
|
if isinstance(output_files, list):
|
|
for path in output_files:
|
|
if isinstance(path, str) and Path(path).suffix.lower() == ".json":
|
|
return os.path.normpath(path)
|
|
if video_path:
|
|
return os.path.splitext(os.path.normpath(video_path))[0] + ".json"
|
|
return ""
|
|
|
|
|
|
def _video_path_parts(path: str) -> Tuple[str, str, str]:
|
|
"""Return ``(full_path, directory, filename)`` for loader wiring."""
|
|
if not path:
|
|
return ("", "", "")
|
|
normed = os.path.normpath(path)
|
|
return (normed, os.path.dirname(normed), os.path.basename(normed))
|
|
|
|
|
|
def _append_video_path_outputs(result):
|
|
"""Append clean string outputs without disturbing VHS's original output."""
|
|
video_path, video_directory, video_name = _video_path_parts(_final_video_path_from_result(result))
|
|
json_path = _final_json_path_from_result(result, video_path)
|
|
if not isinstance(result, dict):
|
|
return {
|
|
"result": (result, video_path, video_directory, video_name, json_path),
|
|
}
|
|
existing = result.get("result", tuple())
|
|
if not isinstance(existing, tuple):
|
|
existing = (existing,)
|
|
result["result"] = existing + (video_path, video_directory, video_name, json_path)
|
|
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."
|
|
RETURN_TYPES = tuple(getattr(_VHS_VideoCombine, "RETURN_TYPES", ("VHS_FILENAMES",))) + (
|
|
"STRING",
|
|
"STRING",
|
|
"STRING",
|
|
"STRING",
|
|
)
|
|
RETURN_NAMES = tuple(getattr(_VHS_VideoCombine, "RETURN_NAMES", ("Filenames",))) + (
|
|
"video_path",
|
|
"video_directory",
|
|
"video_name",
|
|
"json_path",
|
|
)
|
|
|
|
@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"]["version"] = (
|
|
"STRING",
|
|
{
|
|
"default": "",
|
|
"tooltip": (
|
|
"Strict version token (e.g. 'v001'), typically wired "
|
|
"from a global version node. When set, the output is "
|
|
"named exactly <prefix>_<version>.<ext> with NO automatic "
|
|
"_NNNNN counter. Leave empty to keep VHS's automatic "
|
|
"counter (the casual default)."
|
|
),
|
|
},
|
|
)
|
|
types["optional"]["enable_overwrite"] = (
|
|
"BOOLEAN",
|
|
{
|
|
"default": False,
|
|
"tooltip": (
|
|
"Strict-version mode only. If the versioned filename "
|
|
"already exists: on = replace it; off = keep VHS's "
|
|
"counter for this render so nothing is overwritten."
|
|
),
|
|
},
|
|
)
|
|
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,
|
|
)
|
|
enable_overwrite = _normalize_bool_input(
|
|
kwargs.pop("enable_overwrite", False),
|
|
default=False,
|
|
)
|
|
# Strict versioning: a wired/typed token replaces VHS's automatic
|
|
# _NNNNN counter with a deterministic <prefix>_<token>.<ext> name.
|
|
# Empty -> the counter stays (the casual, non-professional default).
|
|
# The token is resolved below, once the output dir + name root are
|
|
# known (so `auto`/`next` can scan that folder).
|
|
raw_version = _coerce_version_input(kwargs.pop("version", ""))
|
|
metadata_payload = _build_sidecar_workflow(
|
|
kwargs.get("prompt"),
|
|
kwargs.get("extra_pnginfo"),
|
|
datetime.datetime.now().isoformat(" ")[:19],
|
|
)
|
|
|
|
# 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)
|
|
# Resolve the version token now that the output dir + name root are
|
|
# known. `auto`/`next` scans that directory for existing
|
|
# <name>_vNNN outputs and picks the next free one; otherwise the
|
|
# typed/wired token is used verbatim.
|
|
if is_auto_version(raw_version):
|
|
scan_dir, scan_name = _auto_version_scan_target(effective_prefix)
|
|
version_token = next_version_token(
|
|
scan_dir,
|
|
scan_name,
|
|
)
|
|
else:
|
|
version_token = resolve_version_token(raw_version)
|
|
# Strict versioning bakes the token into the filename root; VHS
|
|
# then appends its counter, which _finalize_output strips back off.
|
|
if version_token:
|
|
effective_prefix = _append_version_to_prefix(effective_prefix, version_token)
|
|
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,
|
|
)
|
|
result = _finalize_output(
|
|
result,
|
|
version_token,
|
|
enable_overwrite,
|
|
metadata_payload,
|
|
save_metadata_json,
|
|
save_metadata_png,
|
|
)
|
|
return _append_video_path_outputs(result)
|
|
|
|
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,
|
|
)
|
|
result = _finalize_output(
|
|
result,
|
|
version_token,
|
|
enable_overwrite,
|
|
metadata_payload,
|
|
save_metadata_json,
|
|
save_metadata_png,
|
|
)
|
|
return _append_video_path_outputs(result)
|
|
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"]
|