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

@@ -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()"""