mirror of
https://github.com/Comfy-Org/ComfyUI.git
synced 2026-08-05 18:05:08 +08:00
feat(assets): extract video metadata into system_metadata on ingest and scan
This commit is contained in:
@@ -33,7 +33,7 @@ from app.assets.services.file_utils import (
|
||||
verify_file_unchanged,
|
||||
)
|
||||
from app.assets.services.hashing import HashCheckpoint, compute_blake3_hash
|
||||
from app.assets.services.image_dimensions import extract_image_dimensions
|
||||
from app.assets.services.media_metadata import extract_media_metadata
|
||||
from app.assets.services.metadata_extract import extract_file_metadata
|
||||
from app.assets.services.path_utils import (
|
||||
compute_loader_path,
|
||||
@@ -507,10 +507,9 @@ def enrich_asset(
|
||||
|
||||
if extract_metadata and metadata:
|
||||
system_metadata = metadata.to_user_metadata()
|
||||
if mime_type and mime_type.startswith("image/"):
|
||||
dims = extract_image_dimensions(file_path, mime_type=mime_type)
|
||||
if dims:
|
||||
system_metadata.update(dims)
|
||||
dims = extract_media_metadata(file_path, mime_type=mime_type)
|
||||
if dims:
|
||||
system_metadata.update(dims)
|
||||
set_reference_system_metadata(session, reference_id, system_metadata)
|
||||
|
||||
if full_hash:
|
||||
|
||||
@@ -31,7 +31,10 @@ from app.assets.database.queries import (
|
||||
from app.assets.helpers import get_utc_now, normalize_tags
|
||||
from app.assets.services.bulk_ingest import batch_insert_seed_assets
|
||||
from app.assets.services.file_utils import get_size_and_mtime_ns
|
||||
from app.assets.services.image_dimensions import extract_image_dimensions
|
||||
from app.assets.services.media_metadata import (
|
||||
MEDIA_METADATA_KEYS,
|
||||
extract_media_metadata,
|
||||
)
|
||||
from app.assets.services.path_utils import (
|
||||
compute_loader_path,
|
||||
get_name_and_tags_from_asset_path,
|
||||
@@ -138,7 +141,7 @@ def _ingest_file_from_path(
|
||||
user_metadata=user_metadata,
|
||||
)
|
||||
|
||||
_maybe_store_image_dimensions(
|
||||
_maybe_store_media_metadata(
|
||||
session,
|
||||
reference_id=reference_id,
|
||||
file_path=locator,
|
||||
@@ -316,7 +319,7 @@ def _register_existing_asset(
|
||||
user_metadata=new_meta,
|
||||
)
|
||||
|
||||
_backfill_image_dimensions_from_siblings(
|
||||
_backfill_media_metadata_from_siblings(
|
||||
session,
|
||||
asset_id=asset.id,
|
||||
new_reference_id=ref.id,
|
||||
@@ -369,25 +372,22 @@ def _update_metadata_with_filename(
|
||||
)
|
||||
|
||||
|
||||
_IMAGE_DIMENSION_KEYS = ("kind", "width", "height")
|
||||
_MEDIA_KINDS = ("image", "video")
|
||||
|
||||
|
||||
def _maybe_store_image_dimensions(
|
||||
def _maybe_store_media_metadata(
|
||||
session: Session,
|
||||
reference_id: str,
|
||||
file_path: str,
|
||||
mime_type: str | None,
|
||||
current_system_metadata: dict | None,
|
||||
) -> None:
|
||||
"""Populate ``kind``/``width``/``height`` on system_metadata for image refs.
|
||||
"""Populate media keys on system_metadata for image and video refs.
|
||||
|
||||
Non-image MIME types are a no-op. Pre-existing keys (e.g. enricher-written
|
||||
Non-media MIME types are a no-op. Pre-existing keys (e.g. enricher-written
|
||||
safetensors metadata, download provenance) are preserved by merge.
|
||||
"""
|
||||
if not mime_type or not mime_type.startswith("image/"):
|
||||
return
|
||||
|
||||
dims = extract_image_dimensions(file_path, mime_type=mime_type)
|
||||
dims = extract_media_metadata(file_path, mime_type=mime_type)
|
||||
if not dims:
|
||||
return
|
||||
|
||||
@@ -402,31 +402,35 @@ def _maybe_store_image_dimensions(
|
||||
)
|
||||
|
||||
|
||||
def _backfill_image_dimensions_from_siblings(
|
||||
def _backfill_media_metadata_from_siblings(
|
||||
session: Session,
|
||||
asset_id: str,
|
||||
new_reference_id: str,
|
||||
current_system_metadata: dict | None,
|
||||
) -> None:
|
||||
"""Copy image dimension keys from any sibling reference of the same asset.
|
||||
"""Copy media metadata keys from any sibling reference of the same asset.
|
||||
|
||||
The from-hash path doesn't read the file bytes, so dimensions can't be
|
||||
The from-hash path doesn't read the file bytes, so metadata can't be
|
||||
extracted there directly. When another reference of the same asset already
|
||||
carries image dimensions, copy them onto the new reference so consumers
|
||||
see consistent metadata regardless of how the asset was registered.
|
||||
carries media metadata, copy it onto the new reference so consumers see
|
||||
consistent metadata regardless of how the asset was registered.
|
||||
|
||||
Best-effort: missing siblings, non-image siblings, or absent dimension
|
||||
Best-effort: missing siblings, non-media siblings, or absent metadata
|
||||
keys leave the target reference unchanged.
|
||||
"""
|
||||
current = current_system_metadata or {}
|
||||
if current.get("kind") == "image" and "width" in current and "height" in current:
|
||||
if (
|
||||
current.get("kind") in _MEDIA_KINDS
|
||||
and "width" in current
|
||||
and "height" in current
|
||||
):
|
||||
return
|
||||
|
||||
for sibling in list_references_by_asset_id(session, asset_id):
|
||||
if sibling.id == new_reference_id:
|
||||
continue
|
||||
meta = sibling.system_metadata or {}
|
||||
if meta.get("kind") != "image":
|
||||
if meta.get("kind") not in _MEDIA_KINDS:
|
||||
continue
|
||||
width = meta.get("width")
|
||||
height = meta.get("height")
|
||||
@@ -438,9 +442,9 @@ def _backfill_image_dimensions_from_siblings(
|
||||
):
|
||||
continue
|
||||
merged = dict(current)
|
||||
merged["kind"] = "image"
|
||||
merged["width"] = width
|
||||
merged["height"] = height
|
||||
for key in MEDIA_METADATA_KEYS:
|
||||
if key in meta:
|
||||
merged[key] = meta[key]
|
||||
if merged != current:
|
||||
set_reference_system_metadata(
|
||||
session,
|
||||
|
||||
31
app/assets/services/media_metadata.py
Normal file
31
app/assets/services/media_metadata.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.assets.services.image_dimensions import extract_image_dimensions
|
||||
from app.assets.services.video_metadata import extract_video_metadata
|
||||
|
||||
MEDIA_METADATA_KEYS = (
|
||||
"kind",
|
||||
"width",
|
||||
"height",
|
||||
"duration",
|
||||
"fps",
|
||||
"frame_count",
|
||||
)
|
||||
|
||||
|
||||
def extract_media_metadata(
|
||||
file_path: str, mime_type: str | None = None
|
||||
) -> dict[str, Any] | None:
|
||||
"""Extract media metadata for the file, dispatched on the MIME prefix.
|
||||
|
||||
Returns ``None`` for non-media MIME types or unreadable files.
|
||||
"""
|
||||
if not mime_type:
|
||||
return None
|
||||
if mime_type.startswith("image/"):
|
||||
return extract_image_dimensions(file_path, mime_type=mime_type)
|
||||
if mime_type.startswith("video/"):
|
||||
return extract_video_metadata(file_path, mime_type=mime_type)
|
||||
return None
|
||||
81
app/assets/services/video_metadata.py
Normal file
81
app/assets/services/video_metadata.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Video metadata extraction for asset ingest.
|
||||
|
||||
Reads only the container/stream headers via PyAV to capture dimensions,
|
||||
duration, fps and frame count cheaply, without decoding frames. Returns a
|
||||
metadata dict suitable for merging into ``AssetReference.system_metadata``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def extract_video_metadata(
|
||||
file_path: str, mime_type: str | None = None
|
||||
) -> dict[str, Any] | None:
|
||||
"""Extract video stream metadata for the file at ``file_path``.
|
||||
|
||||
Args:
|
||||
file_path: Absolute path to a file on disk.
|
||||
mime_type: Optional MIME type hint. When provided and not prefixed
|
||||
with ``video/``, extraction is skipped without touching the file.
|
||||
|
||||
Returns:
|
||||
``{"kind": "video", "width": W, "height": H, ...}`` with optional
|
||||
``duration`` (seconds), ``fps`` and ``frame_count`` keys when the
|
||||
file has a recognizable video stream, otherwise ``None``.
|
||||
"""
|
||||
if mime_type is not None and not mime_type.startswith("video/"):
|
||||
return None
|
||||
|
||||
try:
|
||||
import av
|
||||
except ImportError:
|
||||
logger.debug(
|
||||
"PyAV not available; skipping video metadata extraction for %s",
|
||||
file_path,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
with av.open(file_path) as container:
|
||||
stream = next(
|
||||
(s for s in container.streams if s.type == "video"), None
|
||||
)
|
||||
if stream is None:
|
||||
return None
|
||||
|
||||
fps = float(stream.average_rate) if stream.average_rate else None
|
||||
duration = None
|
||||
if stream.duration is not None and stream.time_base is not None:
|
||||
duration = float(stream.duration * stream.time_base)
|
||||
elif container.duration is not None:
|
||||
duration = float(container.duration * av.time_base)
|
||||
frame_count = stream.frames or None
|
||||
if frame_count is None and duration is not None and fps is not None:
|
||||
frame_count = round(duration * fps)
|
||||
|
||||
width = stream.codec_context.width
|
||||
height = stream.codec_context.height
|
||||
except (OSError, ValueError, av.error.FFmpegError) as exc:
|
||||
logger.debug("Failed to read video metadata from %s: %s", file_path, exc)
|
||||
return None
|
||||
|
||||
if (
|
||||
not isinstance(width, int)
|
||||
or not isinstance(height, int)
|
||||
or width <= 0
|
||||
or height <= 0
|
||||
):
|
||||
return None
|
||||
|
||||
metadata: dict[str, Any] = {"kind": "video", "width": width, "height": height}
|
||||
if duration is not None:
|
||||
metadata["duration"] = duration
|
||||
if fps is not None:
|
||||
metadata["fps"] = fps
|
||||
if frame_count is not None:
|
||||
metadata["frame_count"] = frame_count
|
||||
return metadata
|
||||
Reference in New Issue
Block a user