feat(video): add load-friendly video outputs (#190)
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user