diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f193f9..008e769 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/k_video_combine.py b/k_video_combine.py index 38da6a2..96f7f40 100644 --- a/k_video_combine.py +++ b/k_video_combine.py @@ -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 diff --git a/k_video_load.py b/k_video_load.py index 03e2920..6c5fb71 100644 --- a/k_video_load.py +++ b/k_video_load.py @@ -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 diff --git a/tests/nodes/test_easy_video_combine.py b/tests/nodes/test_easy_video_combine.py index 20528e2..1f38baa 100644 --- a/tests/nodes/test_easy_video_combine.py +++ b/tests/nodes/test_easy_video_combine.py @@ -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 diff --git a/tests/nodes/test_easy_video_load.py b/tests/nodes/test_easy_video_load.py index f1b5d52..9bb4082 100644 --- a/tests/nodes/test_easy_video_load.py +++ b/tests/nodes/test_easy_video_load.py @@ -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