* fix(video): select one prompt carrier from per-key or comment tags Current ComfyUI savers write one container tag per key (prompt, workflow, parameters, comfyassets_prompt_pairs); only the old VideoHelperSuite comment envelope was read. select_video_carrier picks exactly one carrier in the order per-key tags, comment envelope, description: the first with a role key wins, else the first with a workflow, and carriers never merge. Matroska upper-cased tag names fold on our side; differing fold-duplicates fail the read like duplicate PNG keys, identical ones are one record. Per-key values stay original-text; envelope objects are reserialized. * fix(inspect): read per-key video prompt tags The inspector asked ffprobe only for comment and description, so videos written by core SaveVideo/SaveWEBM and current VideoHelperSuite (one tag per key) were inspected as having no metadata. It now asks for prompt,workflow,parameters,comfyassets_prompt_pairs,comment,description and hands the tags to the shared carrier selector. ffprobe stdout is bounded at the role and workflow budgets plus 64 KiB of envelope; past that it probes once more without the workflow tag within the role budget and marks the per-key workflow limited, while an over-budget envelope still fails the read. Differing fold-duplicate tags fail like duplicate PNG keys. * fix(video): scan videos written by current ComfyUI savers The output scan and live monitor read container tags through metadata_from_tags, which now delegates to the shared carrier selector, so per-key prompt, workflow, parameters and comfyassets_prompt_pairs tags (including Matroska upper-cased names) are ingested with the default resolver profile exactly like the same graph in a PNG. Fold-duplicate tags and null markers are skipped quietly. Pins the behavior with a mocked ffprobe scan test and real MP4/MKV/WebM round trips when ffmpeg is present. * docs(testing): document video carrier selection Record the shared video carrier order (per-key tags, comment envelope, description), the fold-duplicate rule, the 6 MiB + 64 KiB probe bound with the role-only re-probe and limited workflow, and that scans read the same carrier as the inspector. * fix(inspect): size video probe bounds for JSON escaping, exact-case envelopes ffprobe JSON-escapes tag text, so bounds measured on its stdout could mark a video workflow limited, or fail a role record, that the identical PNG reads within its decoded budget. The first probe now allows twice the role and workflow budgets plus 64 KiB (12 MiB + 64 KiB) and the role-only re-probe twice the role budget plus 64 KiB, leaving the decoded per-record budgets as the only gate; a reader-level test reads near-budget quote-dense records as PNG and as video and asserts identical metadata and formats. Envelope keys (comment, description) are matched exactly as the legacy readers did; only the four per-key carriers fold Matroska case. Fixtures can write the legacy ilst comment atom (metadata_tags=False), and the docs now say the admin scan reads the carrier while the live monitor still processes images only, with a follow-up recorded in TESTING.md.
223 lines
7.8 KiB
Python
223 lines
7.8 KiB
Python
"""Prompt metadata from video containers.
|
|
|
|
Current ComfyUI video savers (core SaveVideo/SaveWEBM, VideoHelperSuite since
|
|
``c67ff3e``) write one container tag per key, each a JSON string:
|
|
``prompt``, ``workflow``, ``parameters``, ``comfyassets_prompt_pairs``. Older
|
|
VideoHelperSuite wrote one ``comment`` tag holding a JSON envelope
|
|
``{"prompt": "<graph json>", "workflow": {...}}``; ``description`` is read as a
|
|
fallback envelope. ffprobe reads the tags on every platform; when ffprobe is
|
|
not installed, videos are skipped with a single notice instead of one error
|
|
per file.
|
|
|
|
``select_video_carrier`` picks exactly one carrier for both the inspector and
|
|
the scan, so Images and ingestion read the same prompts from the same file.
|
|
The result uses the same shape as PNG text chunks (string values keyed
|
|
``prompt`` / ``workflow`` / ...) 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
|
|
|
|
from .pair_execution import EXECUTION_KEY
|
|
|
|
VIDEO_EXTENSIONS = (".mp4", ".mov", ".m4v", ".webm", ".mkv", ".avi", ".wmv")
|
|
FFPROBE_TIMEOUT = 20 # seconds; a stuck probe must not hang a worker thread
|
|
# Carrier order: per-key tags, then the ``comment`` envelope, then ``description``.
|
|
ROLE_TAG_KEYS = ("prompt", "parameters", EXECUTION_KEY)
|
|
METADATA_TAG_KEYS = ("prompt", "workflow", "parameters", EXECUTION_KEY)
|
|
ENVELOPE_TAG_KEYS = ("comment", "description")
|
|
VIDEO_TAG_KEYS = METADATA_TAG_KEYS + ENVELOPE_TAG_KEYS
|
|
_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
|
|
|
|
|
|
class DuplicateVideoTagError(ValueError):
|
|
"""Two container tags fold to the same metadata key with different text."""
|
|
|
|
|
|
def _folded_tags(tags: dict) -> dict:
|
|
"""Known tags keyed by their carrier name.
|
|
|
|
Per-key prompt carriers fold ASCII case: Matroska muxers upper-case tag
|
|
names and ffprobe reports them as written. Two tags folding to one key are
|
|
a duplicate record: byte-identical duplicates are one record (as for PNG
|
|
text chunks), differing ones fail the read. Envelope keys (``comment``,
|
|
``description``) are read exactly as the legacy readers did, never folded.
|
|
"""
|
|
folded = {}
|
|
for key, value in tags.items():
|
|
if not isinstance(key, str):
|
|
continue
|
|
if key in ENVELOPE_TAG_KEYS:
|
|
folded[key] = value
|
|
continue
|
|
name = key.lower()
|
|
if name not in METADATA_TAG_KEYS:
|
|
continue
|
|
if name in folded and folded[name] != value:
|
|
raise DuplicateVideoTagError(f"Duplicate video metadata tag: {name}")
|
|
folded[name] = value
|
|
return folded
|
|
|
|
|
|
def _record(value, key):
|
|
"""One carrier field as (metadata text or None, record format or None).
|
|
|
|
A null marker (or a non-text role value) has no record format: it is kept
|
|
in the metadata as ``None`` so the inspector refuses it instead of letting
|
|
fallback evidence win, while scans drop it.
|
|
"""
|
|
if key == "workflow" and not isinstance(value, (str, dict)):
|
|
return None, "invalid"
|
|
if isinstance(value, str):
|
|
return value, "original-text"
|
|
if isinstance(value, dict):
|
|
return json.dumps(value), "reserialized-json"
|
|
return None, None
|
|
|
|
|
|
def _carriers(folded: dict):
|
|
"""Candidate carriers in order: (fields, raw envelope text or None)."""
|
|
per_key = {key: folded[key] for key in METADATA_TAG_KEYS if key in folded}
|
|
if per_key:
|
|
yield per_key, None
|
|
for tag in ENVELOPE_TAG_KEYS:
|
|
raw = folded.get(tag)
|
|
if not isinstance(raw, str):
|
|
continue
|
|
try:
|
|
envelope = json.loads(raw)
|
|
except ValueError:
|
|
continue
|
|
if isinstance(envelope, dict):
|
|
yield envelope, raw
|
|
|
|
|
|
def select_video_carrier(tags):
|
|
"""Choose the one container carrier of prompt metadata.
|
|
|
|
Returns ``(metadata, record_formats, carrier_text)`` or ``None`` when no
|
|
carrier holds a known key. ``metadata`` maps known keys to their text, or
|
|
to ``None`` for an explicit null marker that the inspector must refuse;
|
|
``record_formats`` is ``original-text``, ``reserialized-json`` (an envelope
|
|
object) or ``invalid`` (a workflow that is neither text nor an object), and
|
|
``carrier_text`` is the exact envelope for reserialized values (``None``
|
|
for per-key tags). The first carrier holding a role key wins; otherwise the
|
|
first holding ``workflow``. Carriers are never merged.
|
|
Raises :class:`DuplicateVideoTagError` for differing fold-duplicate tags.
|
|
"""
|
|
if not isinstance(tags, dict):
|
|
return None
|
|
candidates = list(_carriers(_folded_tags(tags)))
|
|
selected = next(
|
|
(item for item in candidates if any(key in item[0] for key in ROLE_TAG_KEYS)),
|
|
None,
|
|
)
|
|
if selected is None:
|
|
selected = next((item for item in candidates if "workflow" in item[0]), None)
|
|
if selected is None:
|
|
return None
|
|
fields, carrier_text = selected
|
|
metadata, formats = {}, {}
|
|
for key in METADATA_TAG_KEYS:
|
|
if key not in fields:
|
|
continue
|
|
text, record_format = _record(fields[key], key)
|
|
if record_format == "invalid":
|
|
formats[key] = record_format
|
|
continue
|
|
metadata[key] = text
|
|
if record_format is not None:
|
|
formats[key] = record_format
|
|
return metadata, formats, carrier_text
|
|
|
|
|
|
def metadata_from_tags(tags) -> dict:
|
|
"""Normalise container tags into PNG-text-chunk shaped metadata for scans.
|
|
|
|
Returns ``{}`` when no carrier holds a known key, and when fold-duplicate
|
|
tags make the file's evidence untrustworthy.
|
|
"""
|
|
try:
|
|
selected = select_video_carrier(tags)
|
|
except DuplicateVideoTagError as e:
|
|
logger.debug(f"Video tags skipped: {e}")
|
|
return {}
|
|
if selected is None:
|
|
return {}
|
|
metadata, _, _ = selected
|
|
return {key: value for key, value in metadata.items() if isinstance(value, str)}
|
|
|
|
|
|
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)
|