Source code for reprostim.bids.media

# SPDX-FileCopyrightText: 2020-2026 ReproNim Team <info@repronim.org>
#
# SPDX-License-Identifier: MIT

"""
BIDS media-file metadata API helpers.

Intended to provide the shared BIDS media-file field table and
AudioInfo/VideoInfo -> BIDS-dict mapping helpers, per the BEP044/media-files
proposal (bids-standard/bids-specification PR #2367):
https://bids-specification--2367.org.readthedocs.build/en/2367/appendices/media-files.html

See .ai/bids/media-spec.md for the full specification.
"""

from enum import Enum
from pathlib import PurePath
from typing import FrozenSet, List, Optional, Union

from pydantic import BaseModel, Field

#######################################################
# Enums


[docs] class BidsMediaType(str, Enum): """BIDS media-file suffix, per the BEP044/media-files proposal (bids-standard/bids-specification PR #2367).""" AUDIO = "audio" """An audio data file containing one or more audio streams. Common formats include WAV (uncompressed), MP3, AAC, and Ogg Vorbis.""" AUDIOVIDEO = "audiovideo" """A media file containing both audio and video streams. Common containers include MP4, MKV, AVI, and WebM.""" IMAGE = "image" """A still image data file. Common formats include JPEG, PNG, SVG, WebP, and TIFF.""" VIDEO = "video" """A video data file containing one or more video streams but no audio. Common containers include MP4, MKV, AVI, and WebM."""
[docs] class AudioFormat(str, Enum): """Audio file format, identified by its file extension (without the leading dot), per the BEP044/media-files proposal (bids-standard/bids-specification PR #2367).""" WAV = "wav" """A Waveform Audio File Format audio file, typically containing uncompressed PCM audio.""" FLAC = "flac" """A FLAC lossless audio file.""" MP3 = "mp3" """An MP3 audio file.""" AAC = "aac" """An Advanced Audio Coding audio file.""" OGG = "ogg" """An Ogg audio file, typically containing Vorbis-encoded audio."""
[docs] class ImageFormat(str, Enum): """Image file format, identified by its file extension (without the leading dot), per the BEP044/media-files proposal (bids-standard/bids-specification PR #2367).""" JPG = "jpg" """A JPEG image file.""" PNG = "png" """A Portable Network Graphics file.""" SVG = "svg" """A Scalable Vector Graphics image file.""" WEBP = "webp" """A WebP image file.""" TIF = "tif" """A Tag Image File Format file.""" TIFF = "tiff" """A Tag Image File Format image file. The .tiff extension is the long form of .tif."""
[docs] class VideoFormat(str, Enum): """Video container format, identified by its file extension (without the leading dot), per the BEP044/media-files proposal (bids-standard/bids-specification PR #2367).""" MP4 = "mp4" """An MPEG-4 Part 14 media container file.""" AVI = "avi" """An Audio Video Interleave media container file.""" MKV = "mkv" """A Matroska media container file.""" WEBM = "webm" """A WebM media container file, typically containing VP8/VP9 video and Vorbis/Opus audio."""
[docs] class BidsMediaCodec(str, Enum): """Common codec reference, per the BEP044/media-files proposal's Codec Identification section (bids-standard/bids-specification PR #2367). Each member's value is the FFmpeg codec name — the RECOMMENDED value for the ``AudioCodec``/``VideoCodec`` fields, auto-extractable via ``ffprobe -v quiet -print_format json -show_streams <file>``. ``rfc6381`` is a representative RFC 6381 codec string (OPTIONAL; used for ``AudioCodecRFC6381``/ ``VideoCodecRFC6381`` — actual strings vary by profile/level, so this is only an example value), and ``category`` is the :class:`BidsMediaType` stream kind (``AUDIO`` or ``VIDEO``) the codec applies to. Not exhaustive: ``AudioCodec``/``VideoCodec`` accept any FFmpeg codec name as a free string; this enum only covers the common codecs listed in the BEP044 reference table.""" def __new__(cls, value: str, rfc6381: Optional[str], category: BidsMediaType): obj = str.__new__(cls, value) obj._value_ = value obj.rfc6381 = rfc6381 obj.category = category return obj H264 = ("h264", "avc1.640028", BidsMediaType.VIDEO) """H.264 / AVC. Most widely supported.""" HEVC = ("hevc", "hev1.1.6.L93.B0", BidsMediaType.VIDEO) """H.265 / HEVC. High efficiency.""" VP9 = ("vp9", "vp09.00.10.08", BidsMediaType.VIDEO) """VP9. Open, royalty-free.""" AV1 = ("av1", "av01.0.01M.08", BidsMediaType.VIDEO) """AV1. Next-gen open codec.""" AAC = ("aac", "mp4a.40.2", BidsMediaType.AUDIO) """AAC-LC. Default audio for MP4.""" MP3 = ("mp3", "mp4a.6B", BidsMediaType.AUDIO) """MP3. Legacy lossy audio.""" OPUS = ("opus", "Opus", BidsMediaType.AUDIO) """Opus. Open, low-latency audio.""" FLAC = ("flac", "fLaC", BidsMediaType.AUDIO) """FLAC. Open lossless audio.""" PCM_S16LE = ("pcm_s16le", None, BidsMediaType.AUDIO) """PCM 16-bit LE. Uncompressed (WAV). No RFC 6381 string."""
[docs] @classmethod def for_category(cls, category: BidsMediaType) -> List["BidsMediaCodec"]: """Return all codecs applicable to the given stream category (AUDIO or VIDEO).""" return [member for member in cls if member.category == category]
[docs] class BidsMediaErrorCode(str, Enum): """Category of problem that can be detected while determining a :class:`BidsMediaInfo` from a file path. Populated by a separate path-parsing module function (not part of this data class).""" INVALID_PATH = "invalid_path" """The given path is malformed or has no filename/extension.""" UNKNOWN_EXTENSION = "unknown_extension" """The file extension is not a recognized Audio/Video/Image format.""" UNKNOWN_MEDIA_TYPE = "unknown_media_type" """The BIDS media type could not be determined from the path.""" MEDIA_TYPE_MISMATCH = "media_type_mismatch" """The file extension is inconsistent with the detected/declared media type (e.g. an audio-only extension on a ``_video`` file)."""
[docs] class BidsMediaProperty(str, Enum): """BIDS media-file sidecar metadata property, per the BEP044/media-files proposal (bids-standard/bids-specification PR #2367). Each member's value is the exact sidecar JSON key; ``categories`` lists the :class:`BidsMediaType` suffix(es) the property applies to, so properties can be filtered by category via :meth:`for_category`.""" def __new__(cls, value: str, categories: FrozenSet[BidsMediaType]): obj = str.__new__(cls, value) obj._value_ = value obj.categories = categories return obj RECORDING_DURATION = ( "RecordingDuration", frozenset({BidsMediaType.AUDIO, BidsMediaType.VIDEO, BidsMediaType.AUDIOVIDEO}), ) """Length of the recording in seconds.""" AUDIO_CODEC = ( "AudioCodec", frozenset({BidsMediaType.AUDIO, BidsMediaType.AUDIOVIDEO}), ) """Audio codec as FFmpeg name (e.g. "aac", "mp3", "opus").""" AUDIO_SAMPLE_RATE = ( "AudioSampleRate", frozenset({BidsMediaType.AUDIO, BidsMediaType.AUDIOVIDEO}), ) """Sampling frequency of the audio stream, in Hz.""" AUDIO_CHANNEL_COUNT = ( "AudioChannelCount", frozenset({BidsMediaType.AUDIO, BidsMediaType.AUDIOVIDEO}), ) """Number of audio channels.""" AUDIO_BIT_DEPTH = ( "AudioBitDepth", frozenset({BidsMediaType.AUDIO, BidsMediaType.AUDIOVIDEO}), ) """Number of bits per sample in the audio stream.""" AUDIO_CODEC_RFC6381 = ( "AudioCodecRFC6381", frozenset({BidsMediaType.AUDIO, BidsMediaType.AUDIOVIDEO}), ) """Audio codec as an RFC 6381 string (e.g. "mp4a.40.2" for AAC-LC).""" IMAGE_WIDTH = ( "ImageWidth", frozenset({BidsMediaType.VIDEO, BidsMediaType.AUDIOVIDEO, BidsMediaType.IMAGE}), ) """Width of the video frame or image, in pixels.""" IMAGE_HEIGHT = ( "ImageHeight", frozenset({BidsMediaType.VIDEO, BidsMediaType.AUDIOVIDEO, BidsMediaType.IMAGE}), ) """Height of the video frame or image, in pixels.""" IMAGE_PIXEL_FORMAT = ( "ImagePixelFormat", frozenset({BidsMediaType.VIDEO, BidsMediaType.AUDIOVIDEO, BidsMediaType.IMAGE}), ) """Pixel format per FFmpeg pix_fmt (e.g. "yuv420p", "rgb24").""" IMAGE_BIT_DEPTH = ( "ImageBitDepth", frozenset({BidsMediaType.VIDEO, BidsMediaType.AUDIOVIDEO, BidsMediaType.IMAGE}), ) """Bit depth per channel of the stored pixel data.""" VIDEO_CODEC = ( "VideoCodec", frozenset({BidsMediaType.VIDEO, BidsMediaType.AUDIOVIDEO}), ) """Video codec as FFmpeg name (e.g. "h264", "hevc", "vp9").""" VIDEO_FRAME_RATE = ( "VideoFrameRate", frozenset({BidsMediaType.VIDEO, BidsMediaType.AUDIOVIDEO}), ) """Video frame rate of the video stream, in Hz.""" VIDEO_FRAME_COUNT = ( "VideoFrameCount", frozenset({BidsMediaType.VIDEO, BidsMediaType.AUDIOVIDEO}), ) """Total number of frames in the video stream.""" VIDEO_CODEC_RFC6381 = ( "VideoCodecRFC6381", frozenset({BidsMediaType.VIDEO, BidsMediaType.AUDIOVIDEO}), ) """Video codec as an RFC 6381 string (e.g. "avc1.640028" for H.264)."""
[docs] @classmethod def for_category(cls, category: BidsMediaType) -> List["BidsMediaProperty"]: """Return all properties that apply to the given media category.""" return [member for member in cls if category in member.categories]
####################################################### # Classes
[docs] class BidsMediaInfoError(BaseModel): """A single problem detected while determining a :class:`BidsMediaInfo` from a file path.""" code: BidsMediaErrorCode = Field(description="Category of the problem.") message: str = Field(description="Human-readable detail about the problem.")
[docs] class BidsMediaInfo(BaseModel): """BIDS media type/format info derived from a file path, per the BEP044/media-files proposal. Pure data holder — populated by a separate path-parsing module function, not a method on this class.""" path: str = Field(description="Input file path this info was derived from.") media_type: Optional[BidsMediaType] = Field( default=None, description="Detected BIDS media type (audio/video/image/audiovideo), " "if determined.", ) format: Optional[Union[AudioFormat, VideoFormat, ImageFormat]] = Field( default=None, description="Detected concrete file format, if recognized.", ) errors: List[BidsMediaInfoError] = Field( default_factory=list, description="All problems detected while parsing the path, if any.", ) @property def valid(self) -> bool: """True when no errors were detected.""" return not self.errors
####################################################### # Functions def _format_from_extension( ext: str, ) -> Optional[Union[AudioFormat, VideoFormat, ImageFormat]]: """Look up a (lowercase, dot-less) file extension in AudioFormat/VideoFormat/ImageFormat.""" for format_cls in (AudioFormat, VideoFormat, ImageFormat): try: return format_cls(ext) except ValueError: continue return None def _media_type_from_format( media_format: Optional[Union[AudioFormat, VideoFormat, ImageFormat]] ) -> Optional[BidsMediaType]: """Best-effort BidsMediaType for a format determined by extension alone. VideoFormat is inherently ambiguous between VIDEO and AUDIOVIDEO from the extension alone (a .mp4/.mkv/.avi/.webm container may or may not carry an audio stream); VIDEO is used as the conservative default rather than asserting an audio stream that hasn't been confirmed. """ if isinstance(media_format, AudioFormat): return BidsMediaType.AUDIO if isinstance(media_format, ImageFormat): return BidsMediaType.IMAGE if isinstance(media_format, VideoFormat): return BidsMediaType.VIDEO return None
[docs] def parse_bids_media_info(path: str) -> BidsMediaInfo: """Determine a :class:`BidsMediaInfo` from a file path, by name only (no filesystem access). The BIDS media type is primarily read from the filename's trailing ``_<suffix>`` token (e.g. ``..._video.mkv`` -> ``BidsMediaType.VIDEO``). If that token is missing or not a valid :class:`BidsMediaType` value, a ``UNKNOWN_MEDIA_TYPE`` error is recorded and the media type is instead guessed from the file extension (see :func:`_media_type_from_format`). The file extension is always independently resolved to a format (``AudioFormat``/``VideoFormat``/``ImageFormat``); an unrecognized extension is recorded as an ``UNKNOWN_EXTENSION`` error regardless of whether the media type was resolved from the filename suffix. """ name = PurePath(path).name if not name or "." not in name: return BidsMediaInfo( path=path, errors=[ BidsMediaInfoError( code=BidsMediaErrorCode.INVALID_PATH, message=f"path {path!r} has no filename/extension to parse", ) ], ) stem, _, ext_raw = name.rpartition(".") ext = ext_raw.lower() errors: List[BidsMediaInfoError] = [] media_format = _format_from_extension(ext) if media_format is None: errors.append( BidsMediaInfoError( code=BidsMediaErrorCode.UNKNOWN_EXTENSION, message=f"'.{ext}' is not a recognized audio/video/image extension", ) ) suffix_token = stem.rsplit("_", 1)[-1] if stem else "" try: media_type: Optional[BidsMediaType] = BidsMediaType(suffix_token.lower()) except ValueError: known = "/".join(t.value for t in BidsMediaType) errors.append( BidsMediaInfoError( code=BidsMediaErrorCode.UNKNOWN_MEDIA_TYPE, message=f"filename has no valid BIDS media type suffix ({known}); " f"found {suffix_token!r} instead, falling back to extension", ) ) media_type = _media_type_from_format(media_format) return BidsMediaInfo( path=path, media_type=media_type, format=media_format, errors=errors )