Add previewable_outputs_count to the jobs API

outputs_count (get_outputs_summary) counts every output item across all
nodes for a job, including non-previewable files (e.g. SaveLatent's
.latent file). The Media Assets sidebar badge reads outputs_count, but
the expanded asset view only ever renders previewable outputs (image,
video, audio, 3D, text), so the badge can show a higher number than
what a user sees when they drill in.

Add count_previewable_outputs(), reusing the existing is_previewable()
filter, and expose it as previewable_outputs_count on /api/jobs job
entries (list, detail, and queue placeholders). get_outputs_summary()
and outputs_count are left untouched so the true total stays available.
This commit is contained in:
Claude
2026-07-30 04:42:05 +00:00
parent c65f9f169c
commit 27fedcd8ce
2 changed files with 152 additions and 0 deletions

View File

@@ -184,6 +184,7 @@ def normalize_queue_item(item: tuple, status: str) -> dict:
'priority': priority,
'create_time': create_time,
'outputs_count': 0,
'previewable_outputs_count': 0,
'workflow_id': workflow_id,
})
@@ -202,6 +203,7 @@ def normalize_history_item(prompt_id: str, history_item: dict, include_outputs:
outputs = history_item.get('outputs', {})
outputs_count, preview_output = get_outputs_summary(outputs)
previewable_outputs_count = count_previewable_outputs(outputs)
execution_error = None
execution_start_time = None
@@ -238,6 +240,7 @@ def normalize_history_item(prompt_id: str, history_item: dict, include_outputs:
'execution_end_time': execution_end_time,
'execution_error': execution_error,
'outputs_count': outputs_count,
'previewable_outputs_count': previewable_outputs_count,
'preview_output': preview_output,
'workflow_id': workflow_id,
})
@@ -322,6 +325,32 @@ def get_outputs_summary(outputs: dict) -> tuple[int, Optional[dict]]:
return count, preview_output or fallback_preview
def count_previewable_outputs(outputs: dict) -> int:
"""
Count only outputs that would actually render in the expanded asset view,
i.e. items is_previewable() accepts (image/video/audio/3D/text). Kept
separate from get_outputs_summary()'s outputs_count, which counts every
output item regardless of media type, so a job with non-previewable
outputs (e.g. a raw JSON blob from a custom node) alongside real media
doesn't inflate the Media Assets badge beyond what the expanded view shows.
"""
count = 0
for node_outputs in outputs.values():
if not isinstance(node_outputs, dict):
continue
for media_type, items in node_outputs.items():
if media_type == 'animated' or not isinstance(items, list):
continue
for item in items:
if not isinstance(item, dict):
item = normalize_output_item(item)
if item is None:
continue
if is_previewable(media_type, item):
count += 1
return count
def apply_sorting(jobs: list[dict], sort_by: str, sort_order: str) -> list[dict]:
"""Sort jobs list by specified field and order."""
reverse = (sort_order == 'desc')

View File

@@ -10,6 +10,7 @@ from comfy_execution.jobs import (
normalize_output_item,
normalize_outputs,
get_outputs_summary,
count_previewable_outputs,
apply_sorting,
has_3d_extension,
validate_job_id,
@@ -281,6 +282,79 @@ class TestGetOutputsSummary:
assert preview['mediaType'] == '3d'
class TestCountPreviewableOutputs:
"""Unit tests for count_previewable_outputs()
Kept separate from get_outputs_summary()'s outputs_count: the Media Assets
badge should reflect only what the expanded asset view actually renders
(previewable outputs), while outputs_count keeps counting every output
item for other consumers.
"""
def test_empty_outputs(self):
assert count_previewable_outputs({}) == 0
def test_previewable_outputs_all_counted(self):
"""When every output is previewable, the two counts should match."""
outputs = {
'node1': {'images': [{'filename': 'a.png', 'type': 'output'}]},
'node2': {'images': [{'filename': 'b.png', 'type': 'output'}]},
}
outputs_count, _ = get_outputs_summary(outputs)
assert count_previewable_outputs(outputs) == outputs_count == 2
def test_save_latent_counted_but_not_previewable(self):
"""SaveLatent (nodes.py) emits a real saved file under the 'latents'
media type: {'latents': [{'filename': '..._00001_.latent',
'subfolder': '', 'type': 'output'}]}. It has no previewable media
type, format, or extension, so it inflates outputs_count without
ever rendering in the expanded asset view."""
outputs = {
'node1': {
'images': [{'filename': 'ComfyUI_00001_.png', 'subfolder': '', 'type': 'output'}]
},
'node2': {
'latents': [{'filename': 'ComfyUI_00001_.latent', 'subfolder': '', 'type': 'output'}]
},
}
outputs_count, _ = get_outputs_summary(outputs)
assert outputs_count == 2
assert count_previewable_outputs(outputs) == 1
def test_save_text_file_output_is_previewable_by_extension(self):
"""SaveText (comfy_extras/nodes_text.py) emits its saved file under a
'files' media type via ui.SavedResult: {'files': [{'filename':
'..._00001.txt', 'subfolder': ..., 'type': 'output'}]}. The .txt
extension makes it previewable even though 'files' itself isn't a
previewable media type."""
outputs = {
'node1': {
'files': [{'filename': 'ComfyUI_00001.txt', 'subfolder': '', 'type': 'output'}]
}
}
assert count_previewable_outputs(outputs) == 1
def test_preview_any_text_tuple_not_counted(self):
"""PreviewAny (comfy_extras/nodes_preview_any.py) emits only
{'text': (value,)} with no saved file. Since the value is a tuple,
not a list, it is excluded from both outputs_count and
previewable_outputs_count — matching get_outputs_summary()."""
outputs = {
'node1': {'text': ('some previewed value',)}
}
outputs_count, _ = get_outputs_summary(outputs)
assert outputs_count == 0
assert count_previewable_outputs(outputs) == 0
def test_string_3d_filename_previewable(self):
"""String 3D filenames (e.g. Preview3D) normalize into a previewable
item just like they do for outputs_count."""
outputs = {
'node1': {'result': ['preview3d_abc123.glb', None]}
}
assert count_previewable_outputs(outputs) == 1
class TestHas3DExtension:
"""Unit tests for has_3d_extension()"""
@@ -367,6 +441,7 @@ class TestNormalizeQueueItem:
assert 'execution_error' not in job
assert 'preview_output' not in job
assert job['outputs_count'] == 0
assert job['previewable_outputs_count'] == 0
assert job['workflow_id'] == 'workflow-abc'
@@ -555,6 +630,54 @@ class TestNormalizeHistoryItem:
{'filename': 'photo.png', 'type': 'output', 'subfolder': ''},
]
def test_previewable_outputs_count_excludes_non_previewable_outputs(self):
"""Regression test for the Media Assets badge overcount: a job with an
image (SaveImage) and a SaveLatent output should report previewable_
outputs_count == 1 while outputs_count == 2, so the frontend badge
(once switched to previewable_outputs_count) matches what the
expanded asset view actually renders."""
history_item = {
'prompt': (
5,
'prompt-mixed',
{'nodes': {}},
{'create_time': 1234567890},
['node1', 'node2'],
),
'status': {'status_str': 'success', 'completed': True, 'messages': []},
'outputs': {
'node1': {
'images': [{'filename': 'ComfyUI_00001_.png', 'subfolder': '', 'type': 'output'}]
},
'node2': {
'latents': [{'filename': 'ComfyUI_00001_.latent', 'subfolder': '', 'type': 'output'}]
},
},
}
job = normalize_history_item('prompt-mixed', history_item)
assert job['outputs_count'] == 2
assert job['previewable_outputs_count'] == 1
def test_previewable_outputs_count_zero_pruned_by_prune_dict(self):
"""A job with no outputs at all should still report both counts as 0,
not omit the field (prune_dict only strips None, not 0)."""
history_item = {
'prompt': (
5,
'prompt-empty',
{'nodes': {}},
{'create_time': 1234567890},
['node1'],
),
'status': {'status_str': 'success', 'completed': True, 'messages': []},
'outputs': {},
}
job = normalize_history_item('prompt-empty', history_item)
assert job['outputs_count'] == 0
assert job['previewable_outputs_count'] == 0
class TestNormalizeOutputItem:
"""Unit tests for normalize_output_item()"""