feat(video): add load-friendly video outputs (#190)

This commit is contained in:
malkuthro
2026-05-30 22:52:04 +02:00
committed by GitHub
parent 1d9d40ab70
commit ac01c4e573
5 changed files with 208 additions and 3 deletions
+12
View File
@@ -6,7 +6,19 @@ The format is inspired by Keep a Changelog and SemVer.
## [Unreleased]
### Added
- **`Easy_VideoCombine`: loader-friendly video path outputs.** The node
now keeps its original `Filenames` output and appends clean string
outputs for the final video's full path, directory, filename, and JSON
sidecar path, so a rendered clip can wire directly into `Easy Load Video
(Koolook)` for a second workflow stage without parsing VHS's mixed
JSON/video list.
### Fixed
- **`Easy_LoadVideo`: two-line path/name input.** When `input_path` is
empty, the loader now accepts a multiline `video` string whose first
line is the directory and second line is the filename, matching the
path/name text-block pattern used in automation workflows.
- **`dev-sync-audio`: stale Director web extension cleanup.** The scoped
audio sync now removes the old `web/whatdreamscost_koolook_v1_3_2/`
folder after the stable v1.3.9 web-extension rename, preventing legacy
+93 -2
View File
@@ -540,12 +540,101 @@ def _finalize_output(
)
_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):
@@ -721,7 +810,7 @@ if _VHS_AVAILABLE:
result,
keep_silent_intermediate,
)
return _finalize_output(
result = _finalize_output(
result,
version_token,
enable_overwrite,
@@ -729,6 +818,7 @@ if _VHS_AVAILABLE:
save_metadata_json,
save_metadata_png,
)
return _append_video_path_outputs(result)
abs_dir, abs_base = target
@@ -760,7 +850,7 @@ if _VHS_AVAILABLE:
result,
keep_silent_intermediate,
)
return _finalize_output(
result = _finalize_output(
result,
version_token,
enable_overwrite,
@@ -768,6 +858,7 @@ if _VHS_AVAILABLE:
save_metadata_json,
save_metadata_png,
)
return _append_video_path_outputs(result)
finally:
folder_paths.get_save_image_path = original_get_save_path
+24 -1
View File
@@ -53,6 +53,19 @@ def _normalize_text_input(value) -> str:
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()
@@ -78,9 +91,19 @@ def _compose_input_video_path(
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.
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.
"""
video = _normalize_text_input(video)
raw_video_lines = _clean_text_lines(video)
input_path = _normalize_text_input(input_path)
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
+69
View File
@@ -20,11 +20,14 @@ import pytest
from k_video_combine import (
_add_metadata_json_sidecar,
_append_video_path_outputs,
_append_version_to_prefix,
_build_sidecar_workflow,
_coerce_version_input,
_compose_prefix,
_display_format_name,
_final_json_path_from_result,
_final_video_path_from_result,
_finalize_strict_version_output,
_metadata_sidecar_path,
_normalize_bool_input,
@@ -32,7 +35,9 @@ from k_video_combine import (
_remove_audio_suffix_from_result,
_resolve_abs_target,
_runtime_format_name,
_video_path_parts,
)
from k_video_load import _compose_input_video_path
def test_relative_prefix_returns_none() -> None:
@@ -468,3 +473,67 @@ def test_strict_version_with_metadata_png_aligns_and_keeps_png(tmp_path: Path) -
assert str(tmp_path / "clip_v001.mp4") in files
assert str(tmp_path / "clip_v001.png") in files
assert str(tmp_path / "clip_v001.json") in files
def test_final_video_path_prefers_video_over_sidecars(tmp_path: Path) -> None:
png = tmp_path / "clip_v001.png"
video = tmp_path / "clip_v001.mp4"
json_path = tmp_path / "clip_v001.json"
result = {"result": ((True, [str(png), str(video), str(json_path)]),)}
assert _final_video_path_from_result(result) == str(video)
def test_final_json_path_reads_sidecar_from_output_list(tmp_path: Path) -> None:
video = tmp_path / "clip_v001.mp4"
json_path = tmp_path / "clip_v001.json"
result = {"result": ((True, [str(json_path), str(video)]),)}
assert _final_json_path_from_result(result, str(video)) == os.path.normpath(str(json_path))
def test_final_json_path_falls_back_to_video_stem(tmp_path: Path) -> None:
video = tmp_path / "clip_v001.mp4"
assert _final_json_path_from_result({"result": ((True, [str(video)]),)}, str(video)) == str(
tmp_path / "clip_v001.json"
)
def test_video_path_parts_returns_loader_friendly_strings(tmp_path: Path) -> None:
video = tmp_path / "clip_v001.mp4"
full_path, directory, name = _video_path_parts(str(video))
assert full_path == os.path.normpath(str(video))
assert directory == os.path.normpath(str(tmp_path))
assert name == "clip_v001.mp4"
def test_append_video_path_outputs_preserves_original_filenames_output(tmp_path: Path) -> None:
video = tmp_path / "clip_v001.mp4"
json_path = tmp_path / "clip_v001.json"
result = {"ui": {"gifs": []}, "result": ((True, [str(json_path), str(video)]),)}
out = _append_video_path_outputs(result)
assert out["result"][0] == (True, [str(json_path), str(video)])
assert out["result"][1:] == (
os.path.normpath(str(video)),
os.path.normpath(str(tmp_path)),
"clip_v001.mp4",
os.path.normpath(str(json_path)),
)
def test_combine_clean_outputs_round_trip_into_easy_load_video(tmp_path: Path) -> None:
video = tmp_path / "clip_v001.mp4"
json_path = tmp_path / "clip_v001.json"
result = {"result": ((True, [str(json_path), str(video)]),)}
out = _append_video_path_outputs(result)
video_path, video_directory, video_name, sidecar_path = out["result"][1:]
assert sidecar_path == os.path.normpath(str(json_path))
assert _compose_input_video_path(video_name, video_directory) == video_path
assert _compose_input_video_path(video_path, "") == video_path
+10
View File
@@ -13,6 +13,16 @@ def test_empty_input_path_passes_video_through() -> None:
assert _compose_input_video_path("https://example.test/clip.mp4", "") == "https://example.test/clip.mp4"
def test_empty_input_path_accepts_directory_and_filename_lines() -> None:
root = Path.cwd().anchor
absolute_dir = str(Path(root) / "projects" / "shot01")
composed = _compose_input_video_path(
f"{absolute_dir}\nplate_main.mp4",
"",
)
assert Path(composed) == Path(absolute_dir) / "plate_main.mp4"
def test_absolute_input_path_joins_filename() -> None:
# Use a real tmp_path so the absolute-path branch is platform-neutral.
root = Path.cwd().anchor