diff --git a/comfy_execution/jobs.py b/comfy_execution/jobs.py index f0ad59f86..1ea5daebc 100644 --- a/comfy_execution/jobs.py +++ b/comfy_execution/jobs.py @@ -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') diff --git a/tests/execution/test_jobs.py b/tests/execution/test_jobs.py index f7cb612e4..907b9ff44 100644 --- a/tests/execution/test_jobs.py +++ b/tests/execution/test_jobs.py @@ -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()"""