Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions comfy_cli/comfy_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,34 @@ def extract_output_entries(record: dict) -> list[dict]:
return results


def extract_text_outputs(record: dict) -> dict[str, list[str]]:
"""Group the text/STRING node outputs of a /history record by node id.

Text-emitting nodes (GeminiNode image descriptions, ShowText, anything
emitting ``ui.text``) surface their payload as a bare-string list under the
``text`` key of ``outputs[node_id]`` — a shape the media-key flatten in
:func:`extract_output_entries` drops entirely. This is the text counterpart:
for each node whose ``text`` value is a list, keep its ``str`` items and
return ``{node_id: [text, ...]}``. Nodes with no usable text are omitted.
Dict-shape tolerant like the flatten above (a non-dict ``outputs`` or a
non-dict node entry yields ``{}`` / is skipped rather than raising).
"""
results: dict[str, list[str]] = {}
outputs = record.get("outputs") or {}
if not isinstance(outputs, dict):
return results
for node_id, node_output in outputs.items():
if not isinstance(node_output, dict):
continue
text = node_output.get("text")
if not isinstance(text, list):
continue
strings = [item for item in text if isinstance(item, str)]
if strings:
results[str(node_id)] = strings
return results


def _group_outputs(outputs: list[dict], item_map: dict | None) -> tuple[dict[str, list[str]], dict[str, list[str]]]:
"""Group ``Client.extract_outputs`` entries by node and by foreach item.

Expand Down
20 changes: 19 additions & 1 deletion comfy_cli/command/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,7 @@ def _snapshot(host: str, port: int, prompt_id: str) -> dict | None:
"outputs": [],
"outputs_by_node": {},
"outputs_by_item": {},
"text_outputs": {},
"host": host,
"port": port,
}
Expand Down Expand Up @@ -663,7 +664,7 @@ def _snapshot(host: str, port: int, prompt_id: str) -> dict | None:
# their producing-node association — same flatten the cloud snapshot
# uses, so the grouped keys match the cloud envelope shape exactly.
from comfy_cli import jobs_state
from comfy_cli.comfy_client import _group_outputs, extract_output_entries
from comfy_cli.comfy_client import _group_outputs, extract_output_entries, extract_text_outputs

node_outputs: list[dict] = []
for entry in extract_output_entries(body):
Expand All @@ -686,6 +687,11 @@ def _snapshot(host: str, port: int, prompt_id: str) -> dict | None:
"outputs": output_urls,
"outputs_by_node": outputs_by_node,
"outputs_by_item": outputs_by_item,
# Text/STRING node outputs (image descriptions, ShowText, …) live under
# outputs[node]["text"] as bare strings, which the URL flatten drops.
# Additive key: full untruncated strings for `--json`; the pretty
# renderer previews them. Empty {} when the run emitted no text.
"text_outputs": extract_text_outputs(body),
"error": error_detail,
"host": host,
"port": port,
Expand Down Expand Up @@ -715,6 +721,18 @@ def _render_status_pretty(snap: dict, *, host: str, port: int) -> None:
tbl.add_row("status", badge)
if snap.get("outputs"):
tbl.add_row("outputs", "\n".join(snap["outputs"]))
if snap.get("text_outputs"):
# Bounded preview only — first line, ~120 chars per entry. The full
# untruncated text ships on the `--json` path (renderer.emit).
preview_lines = []
for node_id, texts in snap["text_outputs"].items():
for text in texts:
first = str(text).splitlines()[0] if str(text).strip() else ""
if len(first) > 120:
first = first[:117] + "…"
preview_lines.append(f"[{node_id}] {first}")
if preview_lines:
tbl.add_row("text", "\n".join(preview_lines))
if snap.get("error"):
tbl.add_row("error", str(snap["error"])[:600])

Expand Down
48 changes: 47 additions & 1 deletion tests/comfy_cli/command/test_transfer_download.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import pytest

from comfy_cli import comfy_client, jobs_state
from comfy_cli.comfy_client import extract_output_entries
from comfy_cli.comfy_client import extract_output_entries, extract_text_outputs
from comfy_cli.command import transfer
from comfy_cli.output import Renderer, set_renderer
from comfy_cli.output.renderer import OutputMode, reset_renderer_for_testing
Expand Down Expand Up @@ -232,6 +232,52 @@ def test_client_extract_outputs_delegates(self, fake_target):
assert outputs[0]["url"] == "https://cloud.example.com/api/view?filename=ComfyUI_a.png&subfolder=&type=output"


class TestExtractTextOutputs:
"""Text/STRING node outputs (GeminiNode descriptions, ShowText, …) live as
bare-string lists under ``outputs[node]["text"]`` — a shape the media-key
flatten drops. This pure helper groups them by node id."""

def test_groups_text_by_node(self):
record = {
"outputs": {
"7": {"text": ["a cat sitting on a mat", "second string"]},
"9": {"images": [{"filename": "x.png", "type": "output"}]},
"11": {"text": ["only line"]},
}
}
assert extract_text_outputs(record) == {
"7": ["a cat sitting on a mat", "second string"],
"11": ["only line"],
}

def test_no_text_yields_empty(self):
# A completed run with only media outputs -> {} (never raises).
record = {"outputs": {"9": {"images": [{"filename": "x.png", "type": "output"}]}}}
assert extract_text_outputs(record) == {}
assert extract_text_outputs({}) == {}
assert extract_text_outputs({"outputs": {}}) == {}

def test_non_list_text_is_skipped(self):
# Some nodes stash a bare string under "text"; only lists are kept.
assert extract_text_outputs({"outputs": {"7": {"text": "not a list"}}}) == {}
assert extract_text_outputs({"outputs": {"7": {"text": None}}}) == {}

def test_mixed_type_items_keep_only_strings(self):
record = {"outputs": {"7": {"text": ["keep", 42, None, {"nested": 1}, "also keep"]}}}
assert extract_text_outputs(record) == {"7": ["keep", "also keep"]}
# A list with no string items drops the node entirely.
assert extract_text_outputs({"outputs": {"7": {"text": [1, 2, 3]}}}) == {}

def test_non_dict_outputs_and_nodes_tolerated(self):
assert extract_text_outputs({"outputs": "garbage"}) == {}
assert extract_text_outputs({"outputs": None}) == {}
assert extract_text_outputs({"outputs": {"7": "garbage", "9": {"text": ["ok"]}}}) == {"9": ["ok"]}

def test_node_ids_coerced_to_str(self):
# /history keys can arrive as ints in some serializations.
assert extract_text_outputs({"outputs": {7: {"text": ["x"]}}}) == {"7": ["x"]}


class TestCollisionSafeNaming:
"""A retry fan-out reusing the same item ids re-downloads into the same
out-dir; attempt 1 must never be silently clobbered (fennec friction #3,
Expand Down
79 changes: 79 additions & 0 deletions tests/comfy_cli/jobs/test_jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,63 @@ def fake_get(url, timeout=10.0):
assert snap["outputs_by_item"] == {}


class TestLocalSnapshotTextOutputs:
"""`_snapshot` surfaces text/STRING node outputs under an always-present
additive `text_outputs` key: grouped-by-node full strings on the history
branch, `{}` when there's no text or the job is still queued/running."""

def _patch_history(self, monkeypatch, prompt_id, body):
def fake_get(url, timeout=10.0):
if url.endswith("/queue"):
return {"queue_running": [], "queue_pending": []}
if url.endswith(f"/history/{prompt_id}"):
return {prompt_id: body}
raise AssertionError(url)

monkeypatch.setattr(jobs_mod, "_http_get_json", fake_get)

def test_history_snapshot_groups_text_by_node(self, monkeypatch):
body = {
"status": {"completed": True, "messages": []},
"outputs": {
"7": {"text": ["a detailed image description that stays untruncated"]},
"9": {"images": [{"filename": "a.png", "subfolder": "", "type": "output"}]},
},
}
self._patch_history(monkeypatch, "txt-1", body)

snap = jobs_mod._snapshot("h", 8188, "txt-1")
assert snap is not None
assert snap["status"] == "completed"
# Full, untruncated strings grouped by producing node.
assert snap["text_outputs"] == {"7": ["a detailed image description that stays untruncated"]}
# Existing media keys are byte-identical to before — additive only.
assert snap["outputs"] == ["http://h:8188/view?filename=a.png&subfolder=&type=output"]

def test_history_snapshot_without_text_emits_empty(self, monkeypatch):
body = {
"status": {"completed": True, "messages": []},
"outputs": {"9": {"images": [{"filename": "a.png", "subfolder": "", "type": "output"}]}},
}
self._patch_history(monkeypatch, "txt-none", body)

snap = jobs_mod._snapshot("h", 8188, "txt-none")
assert snap is not None
assert snap["text_outputs"] == {}

def test_queue_snapshot_emits_empty_text_outputs(self, monkeypatch):
def fake_get(url, timeout=10.0):
if url.endswith("/queue"):
return {"queue_running": [[0, "txt-live", {"a": {}}, {}, {}]], "queue_pending": []}
raise AssertionError(url)

monkeypatch.setattr(jobs_mod, "_http_get_json", fake_get)
snap = jobs_mod._snapshot("h", 8188, "txt-live")
assert snap is not None
assert snap["status"] == "running"
assert snap["text_outputs"] == {}


def test_safe_queue_entry_handles_short_rows():
assert jobs_mod._safe_queue_entry([0, "id", {"node": {}}]) == ("id", {"node": {}})
assert jobs_mod._safe_queue_entry([])[0] == "?"
Expand Down Expand Up @@ -1009,6 +1066,28 @@ def fake_time():
assert state.error is None


def test_render_status_pretty_previews_text_truncated(monkeypatch, capsys):
"""Pretty render shows a bounded per-entry text preview (first line, ~120
chars); the full untruncated string is reserved for the `--json` path."""
from comfy_cli.output import Renderer, set_renderer
from comfy_cli.output.renderer import OutputMode

set_renderer(Renderer(mode=OutputMode.PRETTY))
tail = "TAILMARKER" + "X" * 400
snap = {
"prompt_id": "p",
"status": "completed",
"outputs": [],
# First line is long enough to truncate; a second line must be dropped.
"text_outputs": {"7": [f"HEADMARKER {'Y' * 130}\ndropped second line {tail}"]},
}
jobs_mod._render_status_pretty(snap, host="h", port=8188)
out = capsys.readouterr().out
assert "HEADMARKER" in out # first line previewed
assert "…" in out # truncated past ~120 chars
assert tail not in out # second line never rendered


def test_emit_terminal_verdicts():
import typer

Expand Down
Loading