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) <noreply@anthropic.com>
This commit is contained in:
malkuthro
2026-05-29 08:36:28 +02:00
co-authored by Claude Opus 4.8
parent 6cad285f80
commit ffd41e9d5d
2 changed files with 49 additions and 30 deletions
+25 -15
View File
@@ -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
+24 -15
View File
@@ -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: