From ffd41e9d5de529510b87367d96966ddd373e1969 Mon Sep 17 00:00:00 2001 From: malkuthro Date: Fri, 29 May 2026 02:10:08 +0200 Subject: [PATCH] feat(video-combine): JSON sidecar is a drag-loadable workflow Write the litegraph workflow graph at the top level of the metadata JSON (CreationTime tucked into graph 'extra') so dropping the sidecar onto the ComfyUI canvas reloads the workflow, instead of a non-loadable wrapper. Falls back to {CreationTime, prompt} when no graph is available (headless). Co-Authored-By: Claude Opus 4.8 (1M context) --- k_video_combine.py | 40 ++++++++++++++++---------- tests/nodes/test_easy_video_combine.py | 39 +++++++++++++++---------- 2 files changed, 49 insertions(+), 30 deletions(-) diff --git a/k_video_combine.py b/k_video_combine.py index 073a2a1..38da6a2 100644 --- a/k_video_combine.py +++ b/k_video_combine.py @@ -346,23 +346,31 @@ def _remove_audio_suffix_from_result(result, keep_silent_intermediate: bool): _COUNTER_RE = re.compile(r"_\d{5}$") -def _build_metadata_payload(prompt, extra_pnginfo) -> dict: - """Assemble the workflow metadata written to the JSON sidecar. +def _build_sidecar_workflow(prompt, extra_pnginfo, creation_time): + """Build the JSON sidecar as a **drag-loadable ComfyUI workflow**. - Always records ``CreationTime``. Records the ``prompt`` graph when present. - Crucially, when ``extra_pnginfo`` (which carries the ``workflow`` graph in a - canvas run) is absent — the headless / API ``/prompt`` path, or any run - where the frontend doesn't forward it — fall back to recording the prompt - as ``workflow`` so the sidecar is never reduced to a bare timestamp (the - "empty JSON" the maintainer reported). + 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. """ - payload = {"CreationTime": datetime.datetime.now().isoformat(" ")[:19]} + 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 - if extra_pnginfo: - payload.update(extra_pnginfo) - elif prompt is not None: - payload["workflow"] = prompt return payload @@ -671,8 +679,10 @@ if _VHS_AVAILABLE: version_token = resolve_version_token( _coerce_version_input(kwargs.pop("version", "")) ) - metadata_payload = _build_metadata_payload( - kwargs.get("prompt"), kwargs.get("extra_pnginfo") + 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 diff --git a/tests/nodes/test_easy_video_combine.py b/tests/nodes/test_easy_video_combine.py index bbc1baf..20528e2 100644 --- a/tests/nodes/test_easy_video_combine.py +++ b/tests/nodes/test_easy_video_combine.py @@ -21,7 +21,7 @@ import pytest from k_video_combine import ( _add_metadata_json_sidecar, _append_version_to_prefix, - _build_metadata_payload, + _build_sidecar_workflow, _coerce_version_input, _compose_prefix, _display_format_name, @@ -401,24 +401,33 @@ def test_strict_version_replaces_on_collision_with_overwrite(tmp_path: Path) -> assert out["result"][0][1][-1] == str(clean) -def test_build_metadata_payload_canvas_records_prompt_and_workflow() -> None: - payload = _build_metadata_payload({"1": {"class_type": "X"}}, {"workflow": {"nodes": []}}) - assert payload["prompt"] == {"1": {"class_type": "X"}} - assert payload["workflow"] == {"nodes": []} - assert "CreationTime" in payload +def test_sidecar_is_drag_loadable_workflow_on_canvas() -> None: + """Canvas run: the sidecar IS the litegraph graph at top level, so dropping + it into ComfyUI reloads the workflow (not a metadata wrapper).""" + wf = {"last_node_id": 5, "nodes": [{"id": 1}], "links": []} + out = _build_sidecar_workflow({"1": {"class_type": "X"}}, {"workflow": wf}, "2026-05-29 01:00:00") + assert out["nodes"] == [{"id": 1}] + assert out["links"] == [] + assert out["last_node_id"] == 5 + assert out["extra"]["CreationTime"] == "2026-05-29 01:00:00" -def test_build_metadata_payload_headless_falls_back_to_prompt_as_workflow() -> None: - """The empty-JSON fix: no extra_pnginfo (headless / API) used to drop the - whole graph, leaving a bare timestamp. Fallback records the prompt.""" - payload = _build_metadata_payload({"1": {"class_type": "X"}}, None) - assert payload["prompt"] == {"1": {"class_type": "X"}} - assert payload["workflow"] == {"1": {"class_type": "X"}} +def test_sidecar_preserves_existing_workflow_extra() -> None: + wf = {"nodes": [], "extra": {"foo": "bar"}} + out = _build_sidecar_workflow(None, {"workflow": wf}, "t") + assert out["extra"] == {"foo": "bar", "CreationTime": "t"} -def test_build_metadata_payload_empty_inputs_still_timestamped() -> None: - payload = _build_metadata_payload(None, None) - assert set(payload) == {"CreationTime"} +def test_sidecar_falls_back_when_no_graph_available() -> None: + """Headless/API: no litegraph graph -> keep prompt + timestamp so the + sidecar is never empty (just not drag-loadable in that case).""" + out = _build_sidecar_workflow({"1": {"class_type": "X"}}, None, "2026-05-29 01:00:00") + assert out == {"CreationTime": "2026-05-29 01:00:00", "prompt": {"1": {"class_type": "X"}}} + + +def test_sidecar_empty_inputs_still_timestamped() -> None: + out = _build_sidecar_workflow(None, None, "2026-05-29 01:00:00") + assert out == {"CreationTime": "2026-05-29 01:00:00"} def test_coerce_version_input_drops_stale_boolean() -> None: