Files
malkuthro-ComfyUI-Koolook/k_video_load.py
T
6f158c9020 fix(nodes): Easy_LoadVideo empty-branch degrade (bundles #243 publish-tab cleanup) (#244)
* 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>
2026-06-19 00:10:59 +02:00

350 lines
13 KiB
Python

# SPDX-License-Identifier: GPL-3.0-or-later
#
# ComfyUI-Koolook - Easy Load Video
# Copyright (C) 2026 ComfyUI-Koolook contributors (kforgelabs).
#
# Runtime composition over Kosinkadink/ComfyUI-VideoHelperSuite (GPL-3.0).
# No VHS source is copied here. Easy_LoadVideo subclasses VHS_LoadVideoPath
# and composes a split input_path + video filename into the path string that
# upstream already knows how to load.
"""Path-aware Video Helper Suite loader for ComfyUI-Koolook."""
from __future__ import annotations
import os
from typing import Optional
try:
import folder_paths # ComfyUI core; present at runtime.
import nodes as _comfy_nodes_module
_VHS_LoadVideoPath = _comfy_nodes_module.NODE_CLASS_MAPPINGS.get("VHS_LoadVideoPath")
if _VHS_LoadVideoPath is None:
raise RuntimeError(
"VHS_LoadVideoPath not present in ComfyUI's NODE_CLASS_MAPPINGS "
"at Koolook load time. Either ComfyUI-VideoHelperSuite isn't "
"installed, or it loaded after Koolook."
)
# VHS LoadVideoPath wraps the shared decoder with path validation that
# rejects absolute/local handoff paths. Existing local videos can use the
# same shared decoder directly; fail loudly if a future VHS refactor moves it.
_VHS_LOAD_VIDEO_FN = _VHS_LoadVideoPath.load_video.__globals__.get("load_video")
if _VHS_LOAD_VIDEO_FN is None:
raise RuntimeError("VHS shared load_video function not found.")
_VHS_AVAILABLE = True
_VHS_IMPORT_ERROR: Optional[BaseException] = None
except (ImportError, RuntimeError) as _exc:
_VHS_AVAILABLE = False
_VHS_IMPORT_ERROR = _exc
_VHS_LoadVideoPath = None # type: ignore[assignment]
_VHS_LOAD_VIDEO_FN = None
# Returned by _compose_input_video_path when input_path is an existing folder
# with no filename -- typically the unselected branch of an upstream mode switch
# (e.g. an EXR sequence dir handed to the video loader). load_video turns this
# into an empty, correctly-shaped result instead of crashing the whole prompt.
EMPTY_BRANCH_SENTINEL = "\x00__koolook_empty_video_branch__"
_SENTINEL_STRINGS = ("undefined", "null", "none")
_VIDEO_EXTENSIONS = {
".avi",
".gif",
".m4v",
".mkv",
".mov",
".mp4",
".webm",
}
def _normalize_text_input(value) -> str:
"""Coerce a ComfyUI STRING widget value to a clean Python string."""
if value is None:
return ""
s = str(value).strip().strip('"').strip("'").strip()
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 _clean_text_lines(value) -> list[str]:
"""Return non-empty, non-sentinel lines from a connected text widget."""
if value is None:
return []
s = str(value).strip().strip('"').strip("'").strip()
lines = []
for line in s.replace("\r", "\n").split("\n"):
line = line.strip().strip('"').strip("'").strip()
if line and line.lower() not in _SENTINEL_STRINGS:
lines.append(line)
return lines
def _input_root() -> str:
try:
return folder_paths.get_input_directory()
except Exception:
return os.getcwd()
def _basename_any_sep(path: str) -> str:
return path.rstrip("/\\").replace("\\", "/").rsplit("/", 1)[-1]
def _looks_like_video_file(path: str) -> bool:
return os.path.splitext(_basename_any_sep(path))[1].lower() in _VIDEO_EXTENSIONS
def _strip_outer_quotes(path: str) -> str:
return path.strip().strip('"').strip("'").strip()
def _is_existing_local_video_path(path: str) -> bool:
clean = _strip_outer_quotes(path)
return _looks_like_video_file(clean) and os.path.isfile(clean)
def _input_path_is_existing_dir(path: str, input_root: str | None = None) -> bool:
"""True when ``path`` resolves to a real directory on disk.
Absolute dirs are checked directly; relative dirs are rooted under ComfyUI's
input directory, matching how the loader resolves relative paths elsewhere.
"""
candidate = _existing_local_path_candidate(path, input_root)
return bool(candidate) and os.path.isdir(candidate)
def _existing_local_path_candidate(path: str, input_root: str | None = None) -> str:
clean = _strip_outer_quotes(path)
if not clean:
return ""
if os.path.isabs(clean):
return os.path.normpath(clean)
root = input_root if input_root is not None else _input_root()
return os.path.normpath(os.path.join(root, clean))
def _normalize_path_input(value, input_root: str | None = None) -> str:
"""Normalize a path input while preserving wrapped full paths.
Multiline text can mean either "directory + filename" or a single path
wrapped by a text node. Prefer candidates that exist on disk, then candidates
that at least look like video files, and otherwise keep the first line.
"""
lines = _clean_text_lines(value)
if not lines:
return ""
if len(lines) == 1:
return lines[0]
joined_text = "".join(lines)
joined_path = os.path.join(lines[0].rstrip("/\\"), *lines[1:])
candidates = [joined_text, joined_path]
for candidate in candidates:
resolved = _existing_local_path_candidate(candidate, input_root)
if os.path.isfile(resolved):
return candidate
for candidate in candidates:
if _looks_like_video_file(candidate):
return candidate
return lines[0]
def _compose_input_video_path(
video: str,
input_path: str,
input_root: str | None = None,
) -> str:
"""Resolve split ``input_path`` + ``video`` into one loader path.
Empty ``input_path`` is pass-through, so users can still paste a full path
or URL into ``video`` exactly like upstream VHS_LoadVideoPath.
When ``input_path`` is set, ``video`` is treated as a filename field:
path components are stripped, then the name is joined under the directory.
Absolute directories are used directly; relative directories are rooted in
ComfyUI's input directory.
If ``input_path`` itself points at a video file and ``video`` is empty,
that full file path is passed through. This lets
``Easy_VideoCombine.video_path`` wire directly into ``input_path``. If a
multiline text node wraps that path, the lines are joined back together
before validation/loading.
When ``input_path`` is empty and ``video`` contains multiple non-empty
lines, the first line is treated as the directory and the second as the
filename; later lines are ignored. This matches connected text blocks that
package a path/name pair for loader handoff.
"""
raw_video_lines = _clean_text_lines(video)
input_path = _normalize_path_input(input_path, input_root)
if not input_path and len(raw_video_lines) >= 2:
input_path = raw_video_lines[0]
video = raw_video_lines[1]
else:
video = _normalize_text_input(video)
if not input_path:
return video
clean_dir = input_path.rstrip("/\\")
if not video and _looks_like_video_file(clean_dir):
if os.path.isabs(clean_dir):
return os.path.normpath(clean_dir)
root = input_root if input_root is not None else _input_root()
return os.path.normpath(os.path.join(root, clean_dir))
name = _basename_any_sep(video) or video
if not name:
# input_path is a directory with no filename. If that folder actually
# exists it is almost always the unselected branch of an upstream mode
# switch -- e.g. an EXR sequence dir routed into the video loader. Signal
# a graceful empty so the loader returns nothing instead of crashing the
# whole prompt (a lenient sequence loader would just ignore it). A path
# that does not exist is a genuine mistake and still raises.
if _input_path_is_existing_dir(clean_dir, input_root):
return EMPTY_BRANCH_SENTINEL
raise ValueError("video must include a filename when input_path is set.")
if os.path.isabs(clean_dir):
return os.path.normpath(os.path.join(clean_dir, name))
root = input_root if input_root is not None else _input_root()
return os.path.normpath(os.path.join(root, clean_dir, name))
def _empty_value_for_type(type_name: str):
"""Build an empty, correctly-typed output value for a VHS return slot.
``torch`` is imported lazily so the path-composition logic stays importable
(and unit-testable) without torch installed; it is always present at runtime
inside ComfyUI.
"""
name = str(type_name)
if name in ("IMAGE", "MASK", "LATENT"):
import torch
if name == "IMAGE":
return torch.zeros((1, 64, 64, 3), dtype=torch.float32)
if name == "MASK":
return torch.zeros((1, 64, 64), dtype=torch.float32)
return {"samples": torch.zeros((1, 4, 8, 8), dtype=torch.float32)}
if name == "AUDIO":
import torch
return {"waveform": torch.zeros((1, 2, 1), dtype=torch.float32), "sample_rate": 44100}
if name == "INT":
return 0
if name == "FLOAT":
return 0.0
if name == "STRING":
return ""
if name == "VHS_VIDEOINFO":
return {}
return None
def _empty_load_video_result(return_types) -> tuple:
return tuple(_empty_value_for_type(t) for t in return_types)
if _VHS_AVAILABLE:
class Easy_LoadVideo(_VHS_LoadVideoPath):
"""VHS Load Video Path variant with split directory + filename fields."""
CATEGORY = "Koolook/Video"
DESCRIPTION = "Load Video variant with split input path and filename fields."
@classmethod
def INPUT_TYPES(cls):
types = _VHS_LoadVideoPath.INPUT_TYPES()
required = dict(types.get("required", {}))
required.pop("video", None)
types["required"] = {
"input_path": (
"STRING",
{
"default": "",
"tooltip": (
"Folder containing the video. Absolute paths load "
"directly; relative paths start at ComfyUI's input folder."
),
},
),
"video": (
"STRING",
{
"default": "",
"tooltip": "Video filename, or a full path/URL when input_path is empty.",
},
),
**required,
}
return types
def load_video(self, *args, **kwargs):
composed = _compose_input_video_path(
kwargs.get("video", ""),
kwargs.pop("input_path", ""),
)
if composed == EMPTY_BRANCH_SENTINEL:
# Unselected branch (existing folder, no filename): return an
# empty, correctly-shaped result so the prompt does not crash.
print(
"[Easy_LoadVideo] input_path resolved to an existing folder "
"with no video filename; returning an empty branch result. "
"If this is the selected video branch, set the video filename."
)
return _empty_load_video_result(self.RETURN_TYPES)
kwargs["video"] = composed
if _is_existing_local_video_path(kwargs["video"]):
kwargs["video"] = _strip_outer_quotes(kwargs["video"])
return _VHS_LOAD_VIDEO_FN(*args, **kwargs)
return super().load_video(*args, **kwargs)
@classmethod
def IS_CHANGED(cls, video, input_path="", **kwargs):
resolved = _compose_input_video_path(video, input_path)
if resolved == EMPTY_BRANCH_SENTINEL:
return EMPTY_BRANCH_SENTINEL
return _VHS_LoadVideoPath.IS_CHANGED(resolved, **kwargs)
@classmethod
def VALIDATE_INPUTS(cls, video, input_path="", **kwargs):
# Linked inputs are unresolved during ComfyUI validation; execution
# receives the real value after the upstream text node runs.
if video is None or input_path is None:
return True
resolved = _compose_input_video_path(video, input_path)
# An existing-folder branch validates fine; it degrades to empty at
# run time rather than blocking the whole prompt.
if resolved == EMPTY_BRANCH_SENTINEL:
return True
if _is_existing_local_video_path(resolved):
return True
return _VHS_LoadVideoPath.VALIDATE_INPUTS(resolved)
NODE_CLASS_MAPPINGS = {"Easy_LoadVideo": Easy_LoadVideo}
NODE_DISPLAY_NAME_MAPPINGS = {"Easy_LoadVideo": "Easy Load Video (Koolook)"}
else:
print(
f"[Koolook] Easy_LoadVideo 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"]