Files
ComfyAssets-ComfyUI_PromptM…/utils/video_metadata.py
T
Vito Sansevero b2ea2597e6 feat: scan reads prompts embedded in videos; unreadable files no longer log errors
The output scan handed every .mp4 to Pillow and logged one ERROR per file.
ComfyUI's video savers store the queued graph as JSON in the container's
comment tag, so those files carry prompts worth linking.

- utils/video_metadata.py reads the comment/description tag through
  ffprobe with a timeout, normalised to the PNG text-chunk shape so the
  existing parser handles both; without ffprobe videos are skipped and a
  single INFO notice is logged
- _extract_comfyui_metadata routes videos there and logs unreadable images
  as warnings, since the scan carries on past them

Test hygiene: every aiohttp test base class overrode tearDownAsync without
calling super(), so no test client was ever closed. That produced thousands
of ResourceWarnings, "Unclosed client session" errors from asyncio that
broke log assertions, and doubled the suite's run time.
2026-10-08 18:01:34 -07:00

124 lines
3.7 KiB
Python

"""Prompt metadata from video containers.
ComfyUI's video savers (core SaveVideo, VideoHelperSuite, ...) write the
queued graph into the container's ``comment`` tag as JSON:
``{"prompt": "<graph json>", "workflow": {...}}``. ffprobe reads it on every
platform; when ffprobe is not installed, videos are skipped with a single
notice instead of one error per file.
The result uses the same shape as PNG text chunks (string values keyed
``prompt`` / ``workflow`` / ``parameters``) so the existing prompt parser
handles both.
"""
import functools
import json
import logging
import os
import shutil
# Fixed argv, absolute binary, no shell.
import subprocess # nosec B404
VIDEO_EXTENSIONS = (".mp4", ".mov", ".m4v", ".webm", ".mkv", ".avi", ".wmv")
FFPROBE_TIMEOUT = 20 # seconds; a stuck probe must not hang a worker thread
_COMMENT_TAGS = ("comment", "description")
_METADATA_KEYS = ("prompt", "workflow", "parameters")
_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0) # no console flash on Windows
logger = logging.getLogger("prompt_manager.video")
_missing_notice_shown = False
def is_video_path(path) -> bool:
return os.path.splitext(str(path))[1].lower() in VIDEO_EXTENSIONS
@functools.lru_cache(maxsize=1)
def ffprobe_path():
"""Absolute path of ffprobe, or None. Cached for the life of the process."""
return shutil.which("ffprobe")
def reset_missing_notice():
"""Allow the 'ffprobe not found' notice to be logged again (tests)."""
global _missing_notice_shown
_missing_notice_shown = False
def metadata_from_tags(tags) -> dict:
"""Normalise container tags into PNG-text-chunk shaped metadata.
Returns ``{}`` when no tag carries a JSON object with a known key.
"""
if not isinstance(tags, dict):
return {}
for tag in _COMMENT_TAGS:
raw = tags.get(tag)
if not isinstance(raw, str):
continue
try:
data = json.loads(raw)
except ValueError:
continue
if not isinstance(data, dict):
continue
found = {}
for key in _METADATA_KEYS:
value = data.get(key)
if value is None:
continue
found[key] = value if isinstance(value, str) else json.dumps(value)
if found:
return found
return {}
def read_video_metadata(path, timeout=FFPROBE_TIMEOUT) -> dict:
"""Metadata embedded in the video at ``path``; ``{}`` when none or no ffprobe."""
global _missing_notice_shown
probe = ffprobe_path()
if not probe:
if not _missing_notice_shown:
_missing_notice_shown = True
logger.info(
"ffprobe not found; prompts embedded in videos are skipped "
"during scans. Install ffmpeg to include them."
)
return {}
cmd = [
probe,
"-v",
"quiet",
"-show_entries",
"format_tags",
"-of",
"json",
str(path),
]
try:
result = subprocess.run( # nosec B603
cmd,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
creationflags=_NO_WINDOW,
)
except (OSError, subprocess.TimeoutExpired, ValueError) as e:
logger.debug(f"ffprobe failed for {os.path.basename(str(path))}: {e}")
return {}
if result.returncode != 0:
return {}
try:
probed = json.loads(result.stdout or "{}")
except ValueError:
return {}
if not isinstance(probed, dict):
return {}
fmt = probed.get("format")
tags = fmt.get("tags") if isinstance(fmt, dict) else None
return metadata_from_tags(tags)