from __future__ import annotations import argparse import base64 import binascii import json import os import struct import sys from dataclasses import dataclass, field from typing import Any, Dict, Iterable, List, Optional, Tuple VERSION = "1.0" BANNER = "MP4 File Dumper - Python Version %s" % VERSION MAX_SCAN_BOXES = 1_000_000 MAX_PREVIEW_BYTES = 64 CONTAINER_BOXES = { "moov", "trak", "mdia", "minf", "dinf", "stbl", "edts", "udta", "meta", "ilst", "moof", "traf", "mfra", "skip", "wide", "ipro", "sinf", "schi", "tref", "iref", "meco", "mere", "strk", "stri", "strd", "sbgp", "sgpd", "hnti", "hinf", "avcn", "avss", "wave", "gmhd", "clip", "matt", "rmra", "rmda", "rmdb", "cmov", "cmvd", "dref", "paen", "font", "vttc", "vtte", "vtta", "vttC", "stpp", "uuid", } FULL_BOX_TYPES = { "mvhd", "tkhd", "mdhd", "hdlr", "vmhd", "smhd", "hmhd", "nmhd", "dref", "stsd", "stts", "ctts", "cslg", "stsc", "stsz", "stz2", "stco", "co64", "stss", "stsh", "stdp", "sdtp", "stdp", "padb", "elst", "mehd", "trex", "mfhd", "tfhd", "trun", "tfdt", "mfra", "tfra", "mfro", "pdin", "ftyp", "mvex", "url ", "urn ", "meta", "pitm", "iloc", "iinf", "infe", "iprp", "ipco", "ipma", "ipro", "sidx", "ssix", "pssh", "tenc", "schm", "frma", "saiz", "saio", "senc", "sgpd", "sbgp", "prft", } HANDLER_NAMES = { "vide": "Video Track", "soun": "Audio Track", "hint": "Hint Track", "meta": "Metadata Track", "text": "Text Track", "subt": "Subtitle Track", "sbtl": "Subtitle Track", "clcp": "Closed Caption", "mdir": "Metadata", "tmcd": "Timecode Track", "auxv": "Auxiliary Video", "pict": "Picture Track", } PROTECTION_SYSTEM_IDS = { "edef8ba979d64acea3c827dcd51d21ed": "Widevine", "9a04f07998404286ab92e65be0885f95": "PlayReady", "94ce86fb07ff4f43adb893d2fa968ca2": "FairPlay", "1077efecc0b24d02ace33c1e52e2fb4b": "Common PSSH", } class Mp4DumpError(Exception): pass class Reader: def __init__(self, data: bytes, base_offset: int = 0) -> None: self.data = data self.pos = 0 self.base_offset = base_offset def remaining(self) -> int: return len(self.data) - self.pos def tell(self) -> int: return self.pos def seek(self, pos: int) -> None: if pos < 0 or pos > len(self.data): raise Mp4DumpError("seek outside buffer") self.pos = pos def skip(self, size: int) -> None: self.seek(self.pos + size) def read(self, size: int) -> bytes: if size < 0 or self.pos + size > len(self.data): raise Mp4DumpError("unexpected end of data") out = self.data[self.pos:self.pos + size] self.pos += size return out def u8(self) -> int: return self.read(1)[0] def i8(self) -> int: return struct.unpack(">b", self.read(1))[0] def u16(self) -> int: return struct.unpack(">H", self.read(2))[0] def i16(self) -> int: return struct.unpack(">h", self.read(2))[0] def u24(self) -> int: b = self.read(3) return (b[0] << 16) | (b[1] << 8) | b[2] def u32(self) -> int: return struct.unpack(">I", self.read(4))[0] def i32(self) -> int: return struct.unpack(">i", self.read(4))[0] def u64(self) -> int: return struct.unpack(">Q", self.read(8))[0] def i64(self) -> int: return struct.unpack(">q", self.read(8))[0] def fixed16_16(self) -> float: return self.i32() / 65536.0 def fixed8_8(self) -> float: return self.i16() / 256.0 def cstring(self) -> str: start = self.pos while self.pos < len(self.data) and self.data[self.pos] != 0: self.pos += 1 raw = self.data[start:self.pos] if self.pos < len(self.data): self.pos += 1 return decode_text(raw) def fourcc(raw: bytes) -> str: return "".join(chr(b) if 32 <= b <= 126 else "." for b in raw) def decode_text(raw: bytes) -> str: for enc in ("utf-8", "utf-16-be", "latin-1"): try: return raw.decode(enc).rstrip("\x00") except UnicodeDecodeError: pass return raw.hex() def hex_preview(raw: bytes, limit: int = MAX_PREVIEW_BYTES) -> str: if len(raw) <= limit: return raw.hex() return raw[:limit].hex() + "..." def language_from_code(code: int) -> str: chars = [] for shift in (10, 5, 0): chars.append(chr(((code >> shift) & 0x1F) + 0x60)) return "".join(chars) def qt_time(seconds_since_1904: int) -> str: # Keep numeric output deterministic; avoid timezone/date dependencies. return str(seconds_since_1904) @dataclass class Box: type: str size: int start: int header_size: int payload_start: int payload_size: int uuid: Optional[str] = None version: Optional[int] = None flags: Optional[int] = None fields: Dict[str, Any] = field(default_factory=dict) children: List["Box"] = field(default_factory=list) error: Optional[str] = None @property def end(self) -> int: return self.start + self.size def to_json(self) -> Dict[str, Any]: obj: Dict[str, Any] = { "name": self.type, "type": self.type, "offset": self.start, "size": self.size, "header_size": self.header_size, } if self.uuid: obj["uuid"] = self.uuid if self.version is not None: obj["version"] = self.version if self.flags is not None: obj["flags"] = self.flags if self.fields: obj.update(self.fields) if self.error: obj["error"] = self.error if self.children: obj["children"] = [child.to_json() for child in self.children] return obj class Mp4Parser: def __init__(self, data: bytes, verbosity: int = 0) -> None: self.data = data self.verbosity = max(0, min(3, verbosity)) self.track_context: Dict[int, Dict[str, Any]] = {} self.current_track_id: Optional[int] = None self.current_handler: Optional[str] = None self.current_timescale: Optional[int] = None def parse(self) -> List[Box]: return self._parse_children(0, len(self.data), None) def _parse_children(self, start: int, end: int, parent_type: Optional[str]) -> List[Box]: boxes: List[Box] = [] pos = start count = 0 while pos + 8 <= end and count < MAX_SCAN_BOXES: count += 1 box = self._parse_box(pos, end, parent_type) boxes.append(box) if box.size <= 0: break next_pos = box.end if next_pos <= pos: break pos = next_pos return boxes def _parse_box(self, start: int, parent_end: int, parent_type: Optional[str]) -> Box: r = Reader(self.data[start:parent_end], base_offset=start) try: small_size = r.u32() typ = fourcc(r.read(4)) header_size = 8 size = small_size if small_size == 1: size = r.u64() header_size = 16 elif small_size == 0: size = parent_end - start uuid_value = None if typ == "uuid": uuid_value = r.read(16).hex() header_size += 16 if size < header_size: size = header_size if start + size > parent_end: size = parent_end - start payload_start = start + header_size payload_size = max(0, size - header_size) box = Box(typ, size, start, header_size, payload_start, payload_size, uuid_value) self._decode_box(box, parent_type) return box except Exception as exc: return Box("????", max(8, parent_end - start), start, 8, start + 8, max(0, parent_end - start - 8), error=str(exc)) def _decode_box(self, box: Box, parent_type: Optional[str]) -> None: payload = self.data[box.payload_start:box.payload_start + box.payload_size] r = Reader(payload, base_offset=box.payload_start) saved_track = self.current_track_id saved_handler = self.current_handler saved_timescale = self.current_timescale try: decoder = getattr(self, "decode_" + box.type.strip().replace(" ", "_"), None) if box.type == "url ": decoder = self.decode_url elif box.type == "urn ": decoder = self.decode_urn elif box.type == "©nam": decoder = self.decode_data_container if decoder: decoder(box, r) elif self._looks_like_full_box(box.type, payload): self._read_full_box(box, r) if self._is_container(box, parent_type): child_start = box.payload_start + self._container_payload_skip(box) if child_start < box.end: box.children = self._parse_children(child_start, box.end, box.type) except Exception as exc: box.error = str(exc) finally: if box.type == "trak": self.current_track_id = saved_track self.current_handler = saved_handler self.current_timescale = saved_timescale def _looks_like_full_box(self, typ: str, payload: bytes) -> bool: return typ in FULL_BOX_TYPES and len(payload) >= 4 def _read_full_box(self, box: Box, r: Reader) -> None: box.version = r.u8() box.flags = r.u24() def _is_container(self, box: Box, parent_type: Optional[str]) -> bool: if box.type in {"mdat", "free", "ftyp", "styp", "sidx", "ssix", "pssh", "emsg"}: return False if box.type in CONTAINER_BOXES: return True if parent_type in {"stsd", "ipco", "ilst", "wave", "sinf", "schi", "dref"}: return box.payload_size >= 8 return False def _container_payload_skip(self, box: Box) -> int: if box.type == "meta": return 4 if box.version is not None else 0 if box.type in {"stsd", "dref", "ipro"}: return 8 return 0 def _register(self, key: str, value: Any) -> None: if self.current_track_id is None: return ctx = self.track_context.setdefault(self.current_track_id, {}) ctx[key] = value def decode_ftyp(self, box: Box, r: Reader) -> None: box.fields["major_brand"] = fourcc(r.read(4)) box.fields["minor_version"] = r.u32() brands = [] while r.remaining() >= 4: brands.append(fourcc(r.read(4))) box.fields["compatible_brands"] = brands decode_styp = decode_ftyp def decode_mvhd(self, box: Box, r: Reader) -> None: self._read_full_box(box, r) if box.version == 1: creation, modification, timescale, duration = r.u64(), r.u64(), r.u32(), r.u64() else: creation, modification, timescale, duration = r.u32(), r.u32(), r.u32(), r.u32() box.fields.update({"creation_time": qt_time(creation), "modification_time": qt_time(modification), "timescale": timescale, "duration": duration}) if r.remaining() >= 8: box.fields["duration_seconds"] = duration / timescale if timescale else None box.fields["rate"] = r.fixed16_16() box.fields["volume"] = r.fixed8_8() if r.remaining() >= 10: r.skip(10) if r.remaining() >= 36: box.fields["matrix"] = [r.i32() for _ in range(9)] if r.remaining() >= 24: r.skip(24) if r.remaining() >= 4: box.fields["next_track_id"] = r.u32() def decode_tkhd(self, box: Box, r: Reader) -> None: self._read_full_box(box, r) if box.version == 1: creation, modification, track_id, reserved, duration = r.u64(), r.u64(), r.u32(), r.u32(), r.u64() else: creation, modification, track_id, reserved, duration = r.u32(), r.u32(), r.u32(), r.u32(), r.u32() self.current_track_id = track_id self.track_context.setdefault(track_id, {"id": track_id}) box.fields.update({"creation_time": qt_time(creation), "modification_time": qt_time(modification), "track_id": track_id, "duration": duration}) if r.remaining() >= 8: r.skip(8) if r.remaining() >= 8: box.fields["layer"] = r.i16(); box.fields["alternate_group"] = r.i16(); box.fields["volume"] = r.fixed8_8(); r.skip(2) if r.remaining() >= 36: box.fields["matrix"] = [r.i32() for _ in range(9)] if r.remaining() >= 8: box.fields["width"] = r.fixed16_16(); box.fields["height"] = r.fixed16_16() def decode_mdhd(self, box: Box, r: Reader) -> None: self._read_full_box(box, r) if box.version == 1: creation, modification, timescale, duration = r.u64(), r.u64(), r.u32(), r.u64() else: creation, modification, timescale, duration = r.u32(), r.u32(), r.u32(), r.u32() lang = language_from_code(r.u16()) if r.remaining() >= 2 else "und" if r.remaining() >= 2: r.u16() self.current_timescale = timescale self._register("timescale", timescale) box.fields.update({"creation_time": qt_time(creation), "modification_time": qt_time(modification), "timescale": timescale, "duration": duration, "language": lang}) box.fields["duration_seconds"] = duration / timescale if timescale else None def decode_hdlr(self, box: Box, r: Reader) -> None: self._read_full_box(box, r) if r.remaining() >= 4: box.fields["pre_defined"] = r.u32() handler = fourcc(r.read(4)) if r.remaining() >= 4 else "" self.current_handler = handler self._register("handler", handler) box.fields["handler_type"] = handler box.fields["handler_name"] = HANDLER_NAMES.get(handler, "") if r.remaining() >= 12: r.skip(12) if r.remaining() > 0: box.fields["name"] = r.cstring() def decode_vmhd(self, box: Box, r: Reader) -> None: self._read_full_box(box, r) if r.remaining() >= 8: box.fields["graphics_mode"] = r.u16(); box.fields["op_color"] = [r.u16(), r.u16(), r.u16()] def decode_smhd(self, box: Box, r: Reader) -> None: self._read_full_box(box, r) if r.remaining() >= 4: box.fields["balance"] = r.fixed8_8(); r.skip(2) def decode_hmhd(self, box: Box, r: Reader) -> None: self._read_full_box(box, r) if r.remaining() >= 8: box.fields.update({"max_pdu_size": r.u16(), "avg_pdu_size": r.u16(), "max_bitrate": r.u32(), "avg_bitrate": r.u32()}) def decode_nmhd(self, box: Box, r: Reader) -> None: self._read_full_box(box, r) def decode_stsd(self, box: Box, r: Reader) -> None: self._read_full_box(box, r) if r.remaining() >= 4: box.fields["entry_count"] = r.u32() def _decode_sample_entry_common(self, box: Box, r: Reader) -> None: if r.remaining() >= 6: r.skip(6) if r.remaining() >= 2: box.fields["data_reference_index"] = r.u16() def decode_avc1(self, box: Box, r: Reader) -> None: self._decode_visual_sample_entry(box, r) def decode_avc3(self, box: Box, r: Reader) -> None: self._decode_visual_sample_entry(box, r) def decode_hev1(self, box: Box, r: Reader) -> None: self._decode_visual_sample_entry(box, r) def decode_hvc1(self, box: Box, r: Reader) -> None: self._decode_visual_sample_entry(box, r) def decode_dvhe(self, box: Box, r: Reader) -> None: self._decode_visual_sample_entry(box, r) def decode_dvh1(self, box: Box, r: Reader) -> None: self._decode_visual_sample_entry(box, r) def decode_mp4v(self, box: Box, r: Reader) -> None: self._decode_visual_sample_entry(box, r) def decode_encv(self, box: Box, r: Reader) -> None: self._decode_visual_sample_entry(box, r) def _decode_visual_sample_entry(self, box: Box, r: Reader) -> None: self._decode_sample_entry_common(box, r) if r.remaining() >= 16: r.skip(16) if r.remaining() >= 4: box.fields["width"] = r.u16(); box.fields["height"] = r.u16() if r.remaining() >= 14: box.fields["horiz_resolution"] = r.fixed16_16(); box.fields["vert_resolution"] = r.fixed16_16(); r.skip(4); box.fields["frame_count"] = r.u16() if r.remaining() >= 32: compressor = r.read(32) box.fields["compressor_name"] = decode_text(compressor[1:1 + min(compressor[0], 31)]) if compressor else "" if r.remaining() >= 4: box.fields["depth"] = r.u16(); box.fields["pre_defined_3"] = r.i16() consumed = r.tell() child_start = box.payload_start + consumed if child_start < box.end: box.children = self._parse_children(child_start, box.end, box.type) def decode_mp4a(self, box: Box, r: Reader) -> None: self._decode_audio_sample_entry(box, r) def decode_enca(self, box: Box, r: Reader) -> None: self._decode_audio_sample_entry(box, r) def decode_alac(self, box: Box, r: Reader) -> None: self._decode_audio_sample_entry(box, r) def decode_ac_3(self, box: Box, r: Reader) -> None: self._decode_audio_sample_entry(box, r) def decode_ec_3(self, box: Box, r: Reader) -> None: self._decode_audio_sample_entry(box, r) def _decode_audio_sample_entry(self, box: Box, r: Reader) -> None: self._decode_sample_entry_common(box, r) if r.remaining() >= 8: r.skip(8) if r.remaining() >= 8: box.fields["channel_count"] = r.u16(); box.fields["sample_size"] = r.u16(); r.skip(4) if r.remaining() >= 4: rate = r.u32() box.fields["sample_rate"] = rate >> 16 consumed = r.tell() child_start = box.payload_start + consumed if child_start < box.end: box.children = self._parse_children(child_start, box.end, box.type) def decode_esds(self, box: Box, r: Reader) -> None: self._read_full_box(box, r) if r.remaining() > 0: box.fields["descriptor_data"] = hex_preview(r.read(r.remaining())) def decode_avcC(self, box: Box, r: Reader) -> None: if r.remaining() >= 7: box.fields["configuration_version"] = r.u8(); box.fields["avc_profile_indication"] = r.u8(); box.fields["profile_compatibility"] = r.u8(); box.fields["avc_level_indication"] = r.u8() length_size_minus_one = r.u8() & 3 box.fields["nal_unit_length_size"] = length_size_minus_one + 1 sps_count = r.u8() & 31 sps = [] for _ in range(sps_count): if r.remaining() < 2: break n = r.u16(); sps.append(r.read(min(n, r.remaining())).hex()) pps_count = r.u8() if r.remaining() >= 1 else 0 pps = [] for _ in range(pps_count): if r.remaining() < 2: break n = r.u16(); pps.append(r.read(min(n, r.remaining())).hex()) box.fields["sequence_parameter_sets"] = sps box.fields["picture_parameter_sets"] = pps def decode_hvcC(self, box: Box, r: Reader) -> None: if r.remaining() >= 23: box.fields["configuration_version"] = r.u8() byte = r.u8(); box.fields["general_profile_space"] = (byte >> 6) & 3; box.fields["general_tier_flag"] = (byte >> 5) & 1; box.fields["general_profile_idc"] = byte & 31 box.fields["general_profile_compatibility_flags"] = r.u32() box.fields["general_constraint_indicator_flags"] = r.read(6).hex() box.fields["general_level_idc"] = r.u8() r.skip(12) arrays = r.u8() if r.remaining() else 0 box.fields["num_arrays"] = arrays if self.verbosity >= 2: parsed = [] for _ in range(arrays): if r.remaining() < 3: break hdr = r.u8(); count = r.u16(); units = [] for _ in range(count): if r.remaining() < 2: break n = r.u16(); units.append(r.read(min(n, r.remaining())).hex()) parsed.append({"nal_unit_type": hdr & 0x3F, "nal_units": units}) box.fields["arrays"] = parsed def decode_dvcC(self, box: Box, r: Reader) -> None: self._decode_dovi(box, r) def decode_dvvC(self, box: Box, r: Reader) -> None: self._decode_dovi(box, r) def _decode_dovi(self, box: Box, r: Reader) -> None: if r.remaining() >= 4: box.fields["dv_version_major"] = r.u8(); box.fields["dv_version_minor"] = r.u8() value = r.u16() box.fields["dv_profile"] = (value >> 9) & 0x7F box.fields["dv_level"] = (value >> 3) & 0x3F box.fields["rpu_present_flag"] = (value >> 2) & 1 box.fields["el_present_flag"] = (value >> 1) & 1 box.fields["bl_present_flag"] = value & 1 def decode_pasp(self, box: Box, r: Reader) -> None: if r.remaining() >= 8: box.fields["h_spacing"] = r.u32(); box.fields["v_spacing"] = r.u32() def decode_clli(self, box: Box, r: Reader) -> None: if r.remaining() >= 4: box.fields["max_content_light_level"] = r.u16(); box.fields["max_pic_average_light_level"] = r.u16() def decode_mdcv(self, box: Box, r: Reader) -> None: if r.remaining() >= 24: box.fields["display_primaries"] = [[r.u16(), r.u16()] for _ in range(3)] box.fields["white_point"] = [r.u16(), r.u16()] box.fields["max_display_mastering_luminance"] = r.u32() box.fields["min_display_mastering_luminance"] = r.u32() def decode_btrt(self, box: Box, r: Reader) -> None: if r.remaining() >= 12: box.fields["buffer_size_db"] = r.u32(); box.fields["max_bitrate"] = r.u32(); box.fields["avg_bitrate"] = r.u32() def decode_colr(self, box: Box, r: Reader) -> None: if r.remaining() >= 4: colour_type = fourcc(r.read(4)); box.fields["colour_type"] = colour_type if colour_type in {"nclx", "nclc"} and r.remaining() >= 6: box.fields["colour_primaries"] = r.u16(); box.fields["transfer_characteristics"] = r.u16(); box.fields["matrix_coefficients"] = r.u16() if colour_type == "nclx" and r.remaining() >= 1: box.fields["full_range_flag"] = (r.u8() >> 7) & 1 elif r.remaining() > 0: box.fields["payload"] = hex_preview(r.read(r.remaining())) def decode_stts(self, box: Box, r: Reader) -> None: self._read_full_box(box, r); count = r.u32() if r.remaining() >= 4 else 0; entries = [] for _ in range(count): if r.remaining() < 8: break entries.append({"sample_count": r.u32(), "sample_delta": r.u32()}) box.fields["entry_count"] = count if self.verbosity >= 1: box.fields["entries"] = entries self._register("stts", entries) def decode_ctts(self, box: Box, r: Reader) -> None: self._read_full_box(box, r); count = r.u32() if r.remaining() >= 4 else 0; entries = [] for _ in range(count): if r.remaining() < 8: break offset = r.i32() if box.version == 1 else r.u32() entries.append({"sample_count": r.u32(), "sample_offset": offset}) box.fields["entry_count"] = count if self.verbosity >= 1: box.fields["entries"] = entries def decode_stsc(self, box: Box, r: Reader) -> None: self._read_full_box(box, r); count = r.u32() if r.remaining() >= 4 else 0; entries = [] for _ in range(count): if r.remaining() < 12: break entries.append({"first_chunk": r.u32(), "samples_per_chunk": r.u32(), "sample_description_index": r.u32()}) box.fields["entry_count"] = count if self.verbosity >= 1: box.fields["entries"] = entries self._register("stsc", entries) def decode_stsz(self, box: Box, r: Reader) -> None: self._read_full_box(box, r) sample_size = r.u32() if r.remaining() >= 4 else 0 sample_count = r.u32() if r.remaining() >= 4 else 0 sizes = [] if sample_size == 0: for _ in range(sample_count): if r.remaining() < 4: break sizes.append(r.u32()) else: sizes = [sample_size] * sample_count box.fields["sample_size"] = sample_size; box.fields["sample_count"] = sample_count if self.verbosity >= 1: box.fields["entries"] = sizes self._register("stsz", sizes) def decode_stz2(self, box: Box, r: Reader) -> None: self._read_full_box(box, r) if r.remaining() >= 4: r.skip(3); field_size = r.u8() else: field_size = 0 sample_count = r.u32() if r.remaining() >= 4 else 0 sizes = [] if field_size == 4: for _ in range((sample_count + 1) // 2): if r.remaining() < 1: break b = r.u8(); sizes.extend([(b >> 4) & 15, b & 15]) sizes = sizes[:sample_count] elif field_size == 8: sizes = [r.u8() for _ in range(min(sample_count, r.remaining()))] elif field_size == 16: for _ in range(sample_count): if r.remaining() < 2: break sizes.append(r.u16()) box.fields["field_size"] = field_size; box.fields["sample_count"] = sample_count if self.verbosity >= 1: box.fields["entries"] = sizes self._register("stsz", sizes) def decode_stco(self, box: Box, r: Reader) -> None: self._read_full_box(box, r); count = r.u32() if r.remaining() >= 4 else 0; entries = [] for _ in range(count): if r.remaining() < 4: break entries.append(r.u32()) box.fields["entry_count"] = count if self.verbosity >= 1: box.fields["entries"] = entries self._register("chunk_offsets", entries) def decode_co64(self, box: Box, r: Reader) -> None: self._read_full_box(box, r); count = r.u32() if r.remaining() >= 4 else 0; entries = [] for _ in range(count): if r.remaining() < 8: break entries.append(r.u64()) box.fields["entry_count"] = count if self.verbosity >= 1: box.fields["entries"] = entries self._register("chunk_offsets", entries) def decode_stss(self, box: Box, r: Reader) -> None: self._read_full_box(box, r); count = r.u32() if r.remaining() >= 4 else 0; entries = [] for _ in range(count): if r.remaining() < 4: break entries.append(r.u32()) box.fields["entry_count"] = count if self.verbosity >= 1: box.fields["entries"] = entries def decode_sdtp(self, box: Box, r: Reader) -> None: self._read_full_box(box, r) if self.verbosity >= 2: box.fields["sample_dependency_table"] = list(r.read(r.remaining())) else: box.fields["entry_count"] = r.remaining() def decode_elst(self, box: Box, r: Reader) -> None: self._read_full_box(box, r); count = r.u32() if r.remaining() >= 4 else 0; entries = [] for _ in range(count): if box.version == 1: if r.remaining() < 20: break duration, media_time = r.u64(), r.i64() else: if r.remaining() < 12: break duration, media_time = r.u32(), r.i32() entries.append({"segment_duration": duration, "media_time": media_time, "media_rate": r.fixed16_16()}) box.fields["entry_count"] = count if self.verbosity >= 1: box.fields["entries"] = entries def decode_url(self, box: Box, r: Reader) -> None: self._read_full_box(box, r) if r.remaining() > 0: box.fields["location"] = r.cstring() def decode_urn(self, box: Box, r: Reader) -> None: self._read_full_box(box, r) box.fields["name"] = r.cstring() if r.remaining() else "" box.fields["location"] = r.cstring() if r.remaining() else "" def decode_dref(self, box: Box, r: Reader) -> None: self._read_full_box(box, r) box.fields["entry_count"] = r.u32() if r.remaining() >= 4 else 0 def decode_mfhd(self, box: Box, r: Reader) -> None: self._read_full_box(box, r) if r.remaining() >= 4: box.fields["sequence_number"] = r.u32() def decode_tfhd(self, box: Box, r: Reader) -> None: self._read_full_box(box, r) track_id = r.u32() if r.remaining() >= 4 else 0 self.current_track_id = track_id flags = box.flags or 0 box.fields["track_id"] = track_id if flags & 0x000001 and r.remaining() >= 8: box.fields["base_data_offset"] = r.u64() if flags & 0x000002 and r.remaining() >= 4: box.fields["sample_description_index"] = r.u32() if flags & 0x000008 and r.remaining() >= 4: box.fields["default_sample_duration"] = r.u32() if flags & 0x000010 and r.remaining() >= 4: box.fields["default_sample_size"] = r.u32() if flags & 0x000020 and r.remaining() >= 4: box.fields["default_sample_flags"] = r.u32() def decode_tfdt(self, box: Box, r: Reader) -> None: self._read_full_box(box, r) if box.version == 1 and r.remaining() >= 8: box.fields["base_media_decode_time"] = r.u64() elif r.remaining() >= 4: box.fields["base_media_decode_time"] = r.u32() def decode_trun(self, box: Box, r: Reader) -> None: self._read_full_box(box, r) flags = box.flags or 0 sample_count = r.u32() if r.remaining() >= 4 else 0 box.fields["sample_count"] = sample_count if flags & 0x000001 and r.remaining() >= 4: box.fields["data_offset"] = r.i32() if flags & 0x000004 and r.remaining() >= 4: box.fields["first_sample_flags"] = r.u32() entries = [] for _ in range(sample_count): entry: Dict[str, Any] = {} if flags & 0x000100 and r.remaining() >= 4: entry["sample_duration"] = r.u32() if flags & 0x000200 and r.remaining() >= 4: entry["sample_size"] = r.u32() if flags & 0x000400 and r.remaining() >= 4: entry["sample_flags"] = r.u32() if flags & 0x000800 and r.remaining() >= 4: entry["sample_composition_time_offset"] = r.i32() if box.version == 1 else r.u32() entries.append(entry) if self.verbosity >= 1: box.fields["entries"] = entries def decode_trex(self, box: Box, r: Reader) -> None: self._read_full_box(box, r) if r.remaining() >= 20: box.fields.update({"track_id": r.u32(), "default_sample_description_index": r.u32(), "default_sample_duration": r.u32(), "default_sample_size": r.u32(), "default_sample_flags": r.u32()}) def decode_mehd(self, box: Box, r: Reader) -> None: self._read_full_box(box, r) if box.version == 1 and r.remaining() >= 8: box.fields["fragment_duration"] = r.u64() elif r.remaining() >= 4: box.fields["fragment_duration"] = r.u32() def decode_sidx(self, box: Box, r: Reader) -> None: self._read_full_box(box, r) if r.remaining() < 8: return box.fields["reference_id"] = r.u32(); box.fields["timescale"] = r.u32() if box.version == 1: if r.remaining() < 16: return box.fields["earliest_presentation_time"] = r.u64(); box.fields["first_offset"] = r.u64() else: if r.remaining() < 8: return box.fields["earliest_presentation_time"] = r.u32(); box.fields["first_offset"] = r.u32() if r.remaining() >= 2: r.u16() count = r.u16() if r.remaining() >= 2 else 0 refs = [] for _ in range(count): if r.remaining() < 12: break a = r.u32(); b = r.u32(); c = r.u32() refs.append({"reference_type": (a >> 31) & 1, "referenced_size": a & 0x7fffffff, "subsegment_duration": b, "starts_with_sap": (c >> 31) & 1, "sap_type": (c >> 28) & 7, "sap_delta_time": c & 0x0fffffff}) box.fields["reference_count"] = count if self.verbosity >= 1: box.fields["references"] = refs def decode_pssh(self, box: Box, r: Reader) -> None: self._read_full_box(box, r) if r.remaining() < 20: return system_id = r.read(16).hex() box.fields["system_id"] = system_id box.fields["system_name"] = PROTECTION_SYSTEM_IDS.get(system_id, "") if box.version and box.version > 0 and r.remaining() >= 4: kid_count = r.u32(); kids = [] for _ in range(kid_count): if r.remaining() < 16: break kids.append(r.read(16).hex()) box.fields["kid_count"] = kid_count; box.fields["kids"] = kids if r.remaining() >= 4: data_size = r.u32(); data = r.read(min(data_size, r.remaining())) box.fields["data_size"] = data_size if self.verbosity >= 1: box.fields["data"] = data.hex() box.fields["data_base64"] = base64.b64encode(data).decode("ascii") def decode_tenc(self, box: Box, r: Reader) -> None: self._read_full_box(box, r) if r.remaining() >= 2: box.fields["reserved"] = r.u8() second = r.u8() if box.version and box.version > 0: box.fields["default_crypt_byte_block"] = (second >> 4) & 0x0F box.fields["default_skip_byte_block"] = second & 0x0F else: box.fields["reserved_2"] = second if r.remaining() >= 1: box.fields["default_is_protected"] = r.u8() if r.remaining() >= 1: box.fields["default_per_sample_iv_size"] = r.u8() if r.remaining() >= 16: box.fields["default_kid"] = r.read(16).hex() if box.fields.get("default_is_protected") == 1 and box.fields.get("default_per_sample_iv_size") == 0 and r.remaining() >= 1: const_iv_size = r.u8(); box.fields["default_constant_iv_size"] = const_iv_size if r.remaining() >= const_iv_size: box.fields["default_constant_iv"] = r.read(const_iv_size).hex() def decode_frma(self, box: Box, r: Reader) -> None: if r.remaining() >= 4: box.fields["data_format"] = fourcc(r.read(4)) def decode_schm(self, box: Box, r: Reader) -> None: self._read_full_box(box, r) if r.remaining() >= 8: box.fields["scheme_type"] = fourcc(r.read(4)); box.fields["scheme_version"] = r.u32() if (box.flags or 0) & 1 and r.remaining() > 0: box.fields["scheme_uri"] = r.cstring() def decode_saiz(self, box: Box, r: Reader) -> None: self._read_full_box(box, r) if (box.flags or 0) & 1 and r.remaining() >= 8: box.fields["aux_info_type"] = fourcc(r.read(4)); box.fields["aux_info_type_parameter"] = r.u32() if r.remaining() >= 5: default_size = r.u8(); sample_count = r.u32() box.fields["default_sample_info_size"] = default_size; box.fields["sample_count"] = sample_count if default_size == 0 and self.verbosity >= 1: box.fields["sample_info_sizes"] = list(r.read(min(sample_count, r.remaining()))) def decode_saio(self, box: Box, r: Reader) -> None: self._read_full_box(box, r) if (box.flags or 0) & 1 and r.remaining() >= 8: box.fields["aux_info_type"] = fourcc(r.read(4)); box.fields["aux_info_type_parameter"] = r.u32() count = r.u32() if r.remaining() >= 4 else 0; offsets = [] for _ in range(count): need = 8 if box.version == 1 else 4 if r.remaining() < need: break offsets.append(r.u64() if box.version == 1 else r.u32()) box.fields["entry_count"] = count if self.verbosity >= 1: box.fields["offsets"] = offsets def decode_senc(self, box: Box, r: Reader) -> None: self._read_full_box(box, r) count = r.u32() if r.remaining() >= 4 else 0 box.fields["sample_count"] = count if self.verbosity >= 2: box.fields["raw"] = hex_preview(r.read(r.remaining()), 256) def decode_prft(self, box: Box, r: Reader) -> None: self._read_full_box(box, r) if r.remaining() >= 12: box.fields["reference_track_id"] = r.u32(); box.fields["ntp_timestamp"] = r.u64() if box.version == 1 and r.remaining() >= 8: box.fields["media_time"] = r.i64() elif r.remaining() >= 4: box.fields["media_time"] = r.i32() def decode_uuid(self, box: Box, r: Reader) -> None: if self.verbosity >= 2 and r.remaining() > 0: box.fields["payload"] = hex_preview(r.read(r.remaining()), 256) def decode_data(self, box: Box, r: Reader) -> None: if r.remaining() >= 8: data_type = r.u32(); locale = r.u32(); payload = r.read(r.remaining()) box.fields["data_type"] = data_type; box.fields["locale"] = locale if data_type in {1, 2}: box.fields["value"] = decode_text(payload) elif data_type in {13, 14, 27}: box.fields["value"] = payload.hex() else: box.fields["value"] = hex_preview(payload) def decode_data_container(self, box: Box, r: Reader) -> None: if box.payload_size >= 8: box.children = self._parse_children(box.payload_start, box.end, box.type) def decode_mdat(self, box: Box, r: Reader) -> None: box.fields["data_size"] = box.payload_size if self.verbosity >= 3 and box.payload_size: box.fields["data_preview"] = hex_preview(r.read(min(box.payload_size, MAX_PREVIEW_BYTES))) def decode_free(self, box: Box, r: Reader) -> None: box.fields["data_size"] = box.payload_size decode_skip = decode_free class TextPrinter: def __init__(self, verbosity: int = 0) -> None: self.verbosity = verbosity def print(self, boxes: List[Box]) -> None: for box in boxes: self._print_box(box, 0) def _print_box(self, box: Box, depth: int) -> None: indent = " " * depth extras = [] if box.uuid: extras.append("uuid=%s" % box.uuid) if box.version is not None: extras.append("version=%d" % box.version) if box.flags is not None: extras.append("flags=0x%06x" % box.flags) detail = " " + " ".join(extras) if extras else "" print(f"{indent}[{box.type}] size={box.size} offset={box.start}{detail}") if box.error: print(f"{indent} error = {box.error}") for key, value in box.fields.items(): if self.verbosity == 0 and key in {"entries", "data", "data_base64", "payload", "sample_info_sizes", "offsets", "raw"}: continue self._print_field(key, value, depth + 1) for child in box.children: self._print_box(child, depth + 1) def _print_field(self, key: str, value: Any, depth: int) -> None: indent = " " * depth if isinstance(value, list) and len(value) > 16 and self.verbosity < 2: print(f"{indent}{key} = [{len(value)} entries]") return text = json.dumps(value, ensure_ascii=False) if isinstance(value, (dict, list)) else str(value) if len(text) > 240 and self.verbosity < 3: text = text[:240] + "..." print(f"{indent}{key} = {text}") class TrackDumper: def __init__(self, data: bytes, parser: Mp4Parser) -> None: self.data = data self.parser = parser def dump_track(self, input_filename: str, track_id: int) -> str: ctx = self.parser.track_context.get(track_id) if not ctx: raise Mp4DumpError("track not found: %d" % track_id) sizes = ctx.get("stsz") or [] offsets = ctx.get("chunk_offsets") or [] stsc = ctx.get("stsc") or [] if not sizes or not offsets or not stsc: raise Mp4DumpError("track %d cannot be dumped because sample tables are incomplete or fragmented-only" % track_id) sample_offsets = self._build_sample_offsets(sizes, offsets, stsc) if len(sample_offsets) != len(sizes): raise Mp4DumpError("track %d sample table mismatch" % track_id) output_name = f"{input_filename}.{track_id}" with open(output_name, "wb") as out: for off, size in zip(sample_offsets, sizes): if off < 0 or off + size > len(self.data): raise Mp4DumpError("track %d sample offset outside file" % track_id) out.write(struct.pack(">I", size)) out.write(self.data[off:off + size]) return output_name def _build_sample_offsets(self, sizes: List[int], chunk_offsets: List[int], stsc: List[Dict[str, int]]) -> List[int]: sample_offsets: List[int] = [] sample_index = 0 entries = sorted(stsc, key=lambda e: e.get("first_chunk", 0)) for entry_index, entry in enumerate(entries): first_chunk = entry.get("first_chunk", 1) next_first = entries[entry_index + 1].get("first_chunk", len(chunk_offsets) + 1) if entry_index + 1 < len(entries) else len(chunk_offsets) + 1 samples_per_chunk = entry.get("samples_per_chunk", 0) for chunk_number in range(first_chunk, next_first): if chunk_number < 1 or chunk_number > len(chunk_offsets): continue offset = chunk_offsets[chunk_number - 1] for _ in range(samples_per_chunk): if sample_index >= len(sizes): break sample_offsets.append(offset) offset += sizes[sample_index] sample_index += 1 return sample_offsets def parse_track_option(value: str) -> Tuple[int, Optional[str]]: if ":" in value: tid, key = value.split(":", 1) else: tid, key = value, None try: track_id = int(tid, 10) except ValueError as exc: raise argparse.ArgumentTypeError("invalid track id") from exc if key: clean = key.replace("-", "").replace(" ", "") if len(clean) != 32: raise argparse.ArgumentTypeError("track keys must be 128-bit hex values") try: binascii.unhexlify(clean) except binascii.Error as exc: raise argparse.ArgumentTypeError("invalid track key hex") from exc return track_id, key def load_file(path: str) -> bytes: with open(path, "rb") as f: return f.read() PLAYREADY_SYSTEM_ID = "9a04f07998404286ab92e65be0885f95" WIDEVINE_SYSTEM_ID = "edef8ba979d64acea3c827dcd51d21ed" def normalize_system_id(system_id: str) -> str: return system_id.lower().replace("-", "").strip() def walk_boxes(boxes: List[Box]) -> Iterable[Box]: for box in boxes: yield box if box.children: yield from walk_boxes(box.children) def parse_mp4_boxes(input_file: str, verbosity: int = 1) -> List[Box]: if not os.path.isfile(input_file): raise Mp4DumpError("input file does not exist: %s" % input_file) data = load_file(input_file) parser = Mp4Parser(data, verbosity=verbosity) return parser.parse() def extract_pssh_boxes_from_mp4(input_file: str, system_id: Optional[str] = None) -> List[Dict[str, Any]]: wanted = normalize_system_id(system_id) if system_id else None if not os.path.isfile(input_file): raise Mp4DumpError("input file does not exist: %s" % input_file) data = load_file(input_file) parser = Mp4Parser(data, verbosity=1) boxes = parser.parse() results: List[Dict[str, Any]] = [] for box in walk_boxes(boxes): if box.type != "pssh": continue found_system_id = str(box.fields.get("system_id", "")).lower() if wanted and found_system_id != wanted: continue full_box = data[box.start:box.end] item: Dict[str, Any] = { "system_id": found_system_id, "system_name": box.fields.get("system_name", ""), "offset": box.start, "size": box.size, "pssh_base64": base64.b64encode(full_box).decode("ascii"), "pssh_hex": full_box.hex(), "data_size": box.fields.get("data_size"), "data_base64": box.fields.get("data_base64"), "data_hex": box.fields.get("data"), } if "kids" in box.fields: item["kids"] = box.fields.get("kids") if "kid_count" in box.fields: item["kid_count"] = box.fields.get("kid_count") results.append(item) return results def extract_playready_pssh_from_mp4(input_file: str) -> Optional[str]: matches = extract_pssh_boxes_from_mp4(input_file, PLAYREADY_SYSTEM_ID) if not matches: return None value = matches[0].get("pssh_base64") return str(value) if value is not None else None def extract_playready_pssh_payload_from_mp4(input_file: str) -> Optional[str]: matches = extract_pssh_boxes_from_mp4(input_file, PLAYREADY_SYSTEM_ID) if not matches: return None value = matches[0].get("data_base64") return str(value) if value is not None else None def extract_playready_pssh_from_init(input_file: str) -> Optional[str]: return extract_playready_pssh_from_mp4(input_file) def extract_playready_pssh_payload_from_init(input_file: str) -> Optional[str]: return extract_playready_pssh_payload_from_mp4(input_file) def build_arg_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser( prog="pymp4dump.py", description="Python MP4/QuickTime atom dumper inspired by Bento4 mp4dump.", formatter_class=argparse.RawTextHelpFormatter, ) p.add_argument("input", help="input MP4/QuickTime/ISO BMFF file") p.add_argument("--verbosity", type=int, default=0, choices=[0, 1, 2, 3], help="sets the detail level between 0 and 3") p.add_argument("--format", choices=["text", "json"], default="text", help="output format: text or json") p.add_argument("--track", action="append", default=[], metavar="TRACK_ID[:KEY]", help="dump a track to .; KEY is accepted for Bento4 CLI compatibility but decryption is not performed") p.add_argument("--no-banner", action="store_true", help="do not print the banner in text mode") p.add_argument("--extract-playready-pssh", action="store_true", help="print the first complete PlayReady PSSH box as base64, matching pypr") p.add_argument("--extract-playready-payload", action="store_true", help="print only the first inner PlayReady PSSH data payload as base64") return p def main(argv: Optional[List[str]] = None) -> int: args = build_arg_parser().parse_args(argv) if not os.path.isfile(args.input): print("ERROR: input file does not exist: %s" % args.input, file=sys.stderr) return 1 try: track_specs = [parse_track_option(x) for x in args.track] if args.extract_playready_pssh: pssh = extract_playready_pssh_from_mp4(args.input) if pssh is None: print("ERROR: no PlayReady PSSH found", file=sys.stderr) return 1 print(pssh) return 0 if args.extract_playready_payload: payload = extract_playready_pssh_payload_from_mp4(args.input) if payload is None: print("ERROR: no PlayReady PSSH payload found", file=sys.stderr) return 1 print(payload) return 0 data = load_file(args.input) parser = Mp4Parser(data, verbosity=args.verbosity) boxes = parser.parse() if args.format == "json": print(json.dumps([box.to_json() for box in boxes], indent=2, ensure_ascii=False)) else: if not args.no_banner: print(BANNER) print() TextPrinter(args.verbosity).print(boxes) if track_specs: dumper = TrackDumper(data, parser) for track_id, key in track_specs: if key: print("WARNING: decryption keys are accepted for CLI compatibility, but this Python implementation writes stored sample bytes only.", file=sys.stderr) output = dumper.dump_track(args.input, track_id) print("Dumped track %d to %s" % (track_id, output), file=sys.stderr) return 0 except (OSError, Mp4DumpError, argparse.ArgumentTypeError) as exc: print("ERROR: %s" % exc, file=sys.stderr) return 1 except KeyboardInterrupt: print("Interrupted", file=sys.stderr) return 130 if __name__ == "__main__": raise SystemExit(main())