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: