mirror of
https://github.com/Hugoved/pymp4dump.git
synced 2026-07-15 18:10:06 +02:00
1092 lines
48 KiB
Python
1092 lines
48 KiB
Python
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 <input>.<track_id>; 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()) |