diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 8343118..966f12e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -13,7 +13,7 @@ body: attributes: label: mp4forge Version description: Which version are you using? - placeholder: "0.3.0" + placeholder: "0.4.0" validations: required: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a10c19..226cc4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +# 0.4.0 (April 22, 2026) + +- Added richer additive probe surfaces for broader codec families, codec-specific details, media-characteristics reporting, and lighter-weight probe controls for large-file inspection +- Added deterministic structured dump and `psshdump` JSON/YAML export, field-level dump payload reporting, and repeatable path or protection filters shared across text and structured output +- Expanded CLI path ergonomics with parsed-path extraction, subtree-scoped dump selection, path-scoped typed edit flows, and richer `psshdump` filtering by box path, system ID, and KID +- Improved `divide` by deriving playlist signaling from probed metadata and adding a first-class validation mode for unsupported fragmented layouts before any output is written +- Added optional `serde` support for reusable report types, including nested probe and dump companion data intended for library-side embedding +- Expanded checked-in fixture coverage for AV1, VP9, AAC, Opus, and PCM, and added dedicated high-level fuzz targets for probe, structured dump, and typed rewrite surfaces +- Refined README guidance, examples, tests, and goldens across the newer higher-level library and CLI workflows while preserving the existing low-level usage paths + # 0.3.0 (April 22, 2026) - Added byte-slice convenience helpers for typed extract, rewrite, and probe workflows so higher-level integrations can stay in-memory without dropping to the lower-level APIs diff --git a/Cargo.toml b/Cargo.toml index 5652f91..7f816c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mp4forge" -version = "0.3.0" +version = "0.4.0" edition = "2024" rust-version = "1.88" authors = ["bakgio"] @@ -16,5 +16,13 @@ exclude = [".github/**", "fuzz/**", "tests/**"] all-features = true rustdoc-args = ["--cfg", "docsrs"] +[features] +default = [] +serde = ["dep:serde"] + [dependencies] +serde = { version = "1", features = ["derive"], optional = true } terminal_size = "0.4" + +[dev-dependencies] +serde_json = "1" diff --git a/README.md b/README.md index 37c793b..2f0725d 100644 --- a/README.md +++ b/README.md @@ -20,13 +20,16 @@ - Low-level traversal, extraction, stringify, probe, and writer APIs - Thin typed path-based helpers and byte-slice convenience wrappers for common extraction, rewrite, and probe flows - Built-in CLI for `dump`, `extract`, `probe`, `psshdump`, `edit`, and `divide` -- Shared-fixture coverage for regular MP4, fragmented MP4, encrypted init segments, and QuickTime-style metadata cases +- Shared-fixture coverage for regular MP4, fragmented MP4, encrypted init segments, QuickTime-style metadata cases, and derived real codec fixtures for additional codec-family coverage ## Installation ```toml [dependencies] -mp4forge = "0.3.0" +mp4forge = "0.4.0" + +# With optional features: +# mp4forge = { version = "0.4.0", features = ["serde"] } ``` Install the CLI from crates.io: @@ -43,6 +46,17 @@ cargo install --path . --locked The published crate includes both the library and the `mp4forge` binary from `src/bin/mp4forge.rs`. +## Feature Flags + +`mp4forge` keeps the default dependency surface minimal and currently exposes one optional public +feature flag: + +- `serde`: derives `Serialize` and `Deserialize` for the reusable public report structs under + `mp4forge::cli::probe` and `mp4forge::cli::dump`, along with their nested public codec-detail, + media-characteristics, `FieldValue`, and `FourCc` data. This is intended for library-side report + embedding and uses the Rust field names of those public structs; the CLI `-format` outputs keep + their existing hand-authored JSON and YAML schemas. + ## CLI ```text @@ -52,22 +66,39 @@ COMMAND: divide split a fragmented MP4 into track playlists dump display the MP4 box tree edit rewrite selected boxes - extract extract raw boxes by type + extract extract raw boxes by type or path psshdump summarize pssh boxes probe summarize an MP4 file ``` -For example: - -```sh -mp4forge dump input.mp4 -mp4forge probe input.mp4 -mp4forge psshdump encrypted_init.mp4 -``` - -## Feature Flags - -`mp4forge` currently ships without public Cargo feature flags. +`divide` currently targets fragmented inputs with up to one AVC video track and one MP4A audio +track, including encrypted wrappers that preserve those original sample-entry formats. Pass +`-validate` when you want the same probe-driven layout checks without creating any output files. + +`dump` defaults to the existing human-readable tree view. Pass `-format json` or `-format yaml` for +deterministic structured tree export with stable `payload_fields` for supported boxes; `-full` and +`-a` still control when large raw or unsupported payloads expand beyond the default summary-oriented +view. Add repeatable `-path ` filters when you want text or structured output rooted at +only the matched parsed subtrees instead of the whole file. + +`edit` keeps the existing global `tfdt` replacement and `-drop` behavior, and now also accepts +repeatable `-path` filters when you want `-base_media_decode_time` to target only matching parsed +box paths. + +`psshdump` defaults to the existing human-readable protection summary. Pass `-format json` or +`-format yaml` for deterministic structured reports with box offsets, system IDs, KIDs, `Data` +bytes, and the legacy raw-box base64 payload. Add repeatable `-path `, `-system-id +`, or `-kid ` filters when you want text and structured reports to return only the +matching protection boxes. + +`probe` defaults to structured JSON output. When the input carries parsed codec-configuration +boxes, the report now includes a nested `codec_details` object per track for families such as AVC, +HEVC, AV1, VP8/VP9, MP4A, Opus, AC-3, PCM, XML subtitles, text subtitles, and WebVTT. When sample +entries carry `btrt`, `colr`, `pasp`, or `fiel`, the richer CLI path also emits nested +`media_characteristics` data such as declared bitrate, colorimetry, pixel aspect ratio, and +field-order hints. Pass `-detail light` for a lighter-weight probe that skips per-sample, +per-chunk, bitrate, and IDR aggregation, or use `mp4forge::probe::ProbeOptions` from the library +when you need the same control programmatically. > See the [`examples/`](./examples) directory for the crate's low-level and high-level API usage patterns. diff --git a/examples/dump_selected_paths.rs b/examples/dump_selected_paths.rs new file mode 100644 index 0000000..d609bab --- /dev/null +++ b/examples/dump_selected_paths.rs @@ -0,0 +1,21 @@ +use std::env; +use std::fs::File; + +use mp4forge::cli::dump::{DumpOptions, build_field_structured_report_paths}; +use mp4forge::walk::BoxPath; + +fn main() -> Result<(), Box> { + let input_path = env::args() + .nth(1) + .expect("usage: cargo run --example dump_selected_paths -- "); + + let mut file = File::open(input_path)?; + let paths = [BoxPath::parse("moov/trak")?]; + let report = build_field_structured_report_paths(&mut file, &DumpOptions::default(), &paths)?; + + for entry in report.boxes { + println!("{} children={}", entry.path, entry.children.len()); + } + + Ok(()) +} diff --git a/examples/dump_structured_fields.rs b/examples/dump_structured_fields.rs new file mode 100644 index 0000000..2f50c94 --- /dev/null +++ b/examples/dump_structured_fields.rs @@ -0,0 +1,23 @@ +use std::env; +use std::fs::File; +use std::io; + +use mp4forge::cli::dump::{DumpOptions, build_field_structured_report}; + +fn main() -> Result<(), Box> { + let input_path = env::args().nth(1).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "usage: dump_structured_fields INPUT.mp4", + ) + })?; + + let mut file = File::open(&input_path)?; + let report = build_field_structured_report(&mut file, &DumpOptions::default())?; + + for root in &report.boxes { + println!("{} {}", root.path, root.payload_fields.len()); + } + + Ok(()) +} diff --git a/examples/probe_codec_details.rs b/examples/probe_codec_details.rs new file mode 100644 index 0000000..2171344 --- /dev/null +++ b/examples/probe_codec_details.rs @@ -0,0 +1,27 @@ +use std::env; +use std::fs::File; +use std::io; + +use mp4forge::probe::probe_codec_detailed; + +fn main() -> Result<(), Box> { + let input_path = env::args().nth(1).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "usage: probe_codec_details INPUT.mp4", + ) + })?; + + let mut file = File::open(&input_path)?; + let summary = probe_codec_detailed(&mut file)?; + + for track in &summary.tracks { + println!( + "track {} family {:?}", + track.summary.summary.track_id, track.summary.codec_family + ); + println!(" details: {:?}", track.codec_details); + } + + Ok(()) +} diff --git a/examples/probe_lightweight.rs b/examples/probe_lightweight.rs new file mode 100644 index 0000000..eefd587 --- /dev/null +++ b/examples/probe_lightweight.rs @@ -0,0 +1,30 @@ +use std::env; +use std::fs::File; +use std::io; + +use mp4forge::probe::{ProbeOptions, probe_codec_detailed_with_options}; + +fn main() -> Result<(), Box> { + let input_path = env::args().nth(1).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "usage: probe_lightweight INPUT.mp4", + ) + })?; + + let mut file = File::open(&input_path)?; + let summary = probe_codec_detailed_with_options(&mut file, ProbeOptions::lightweight())?; + + println!("fast start: {}", summary.fast_start); + println!("track num: {}", summary.tracks.len()); + for track in &summary.tracks { + println!( + "track {} family {:?} expanded samples {}", + track.summary.summary.track_id, + track.summary.codec_family, + track.summary.summary.samples.len() + ); + } + + Ok(()) +} diff --git a/examples/probe_media_characteristics.rs b/examples/probe_media_characteristics.rs new file mode 100644 index 0000000..63aa14b --- /dev/null +++ b/examples/probe_media_characteristics.rs @@ -0,0 +1,41 @@ +use std::env; +use std::fs::File; + +use mp4forge::probe::probe_media_characteristics; + +fn main() -> Result<(), Box> { + let Some(input_path) = env::args().nth(1) else { + eprintln!("usage: cargo run --example probe_media_characteristics -- "); + std::process::exit(1); + }; + + let mut file = File::open(input_path)?; + let summary = probe_media_characteristics(&mut file)?; + + for track in &summary.tracks { + println!( + "track {} codec_family={:?}", + track.summary.summary.track_id, track.summary.codec_family + ); + if let Some(declared) = track.media_characteristics.declared_bitrate.as_ref() { + println!( + " declared bitrate: avg={} max={} buffer={}", + declared.avg_bitrate, declared.max_bitrate, declared.buffer_size_db + ); + } + if let Some(color) = track.media_characteristics.color.as_ref() { + println!(" color type: {}", color.colour_type); + } + if let Some(par) = track.media_characteristics.pixel_aspect_ratio.as_ref() { + println!(" pixel aspect ratio: {}/{}", par.h_spacing, par.v_spacing); + } + if let Some(field_order) = track.media_characteristics.field_order.as_ref() { + println!( + " field order: count={} ordering={} interlaced={}", + field_order.field_count, field_order.field_ordering, field_order.interlaced + ); + } + } + + Ok(()) +} diff --git a/examples/pssh_report.rs b/examples/pssh_report.rs new file mode 100644 index 0000000..89505d7 --- /dev/null +++ b/examples/pssh_report.rs @@ -0,0 +1,22 @@ +use std::env; +use std::fs::File; + +use mp4forge::cli::pssh::build_pssh_report; + +fn main() -> Result<(), Box> { + let input_path = env::args() + .nth(1) + .expect("usage: cargo run --example pssh_report -- "); + + let mut file = File::open(input_path)?; + let report = build_pssh_report(&mut file)?; + + for entry in report.entries { + println!( + "{} offset={} system_id={} kid_count={} data_size={}", + entry.path, entry.offset, entry.system_id, entry.kid_count, entry.data_size + ); + } + + Ok(()) +} diff --git a/examples/pssh_report_filtered.rs b/examples/pssh_report_filtered.rs new file mode 100644 index 0000000..7392b8e --- /dev/null +++ b/examples/pssh_report_filtered.rs @@ -0,0 +1,33 @@ +use std::env; +use std::fs::File; + +use mp4forge::cli::pssh::{PsshReportFilter, build_pssh_report_with_filters}; +use mp4forge::walk::BoxPath; + +fn main() -> Result<(), Box> { + let input_path = env::args() + .nth(1) + .expect("usage: cargo run --example pssh_report_filtered -- "); + + let mut file = File::open(input_path)?; + let report = build_pssh_report_with_filters( + &mut file, + &PsshReportFilter { + paths: vec![BoxPath::parse("moov")?], + system_ids: vec![[ + 0x10, 0x77, 0xef, 0xec, 0xc0, 0xb2, 0x4d, 0x02, 0xac, 0xe3, 0x3c, 0x1e, 0x52, 0xe2, + 0xfb, 0x4b, + ]], + kids: Vec::new(), + }, + )?; + + for entry in report.entries { + println!( + "{} system_id={} kid_count={} data_size={}", + entry.path, entry.system_id, entry.kid_count, entry.data_size + ); + } + + Ok(()) +} diff --git a/examples/validate_divide_layout.rs b/examples/validate_divide_layout.rs new file mode 100644 index 0000000..a8761d6 --- /dev/null +++ b/examples/validate_divide_layout.rs @@ -0,0 +1,35 @@ +use std::env; +use std::fs::File; +use std::io; + +use mp4forge::cli::divide::{DivideTrackRole, validate_divide_reader}; + +fn main() -> Result<(), Box> { + let input_path = env::args().nth(1).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "usage: validate_divide_layout INPUT.mp4", + ) + })?; + + let mut file = File::open(&input_path)?; + let report = validate_divide_reader(&mut file)?; + + for track in &report.tracks { + let role = match track.role { + DivideTrackRole::Video => "video", + DivideTrackRole::Audio => "audio", + }; + let codec = track + .original_format + .or(track.sample_entry_type) + .map(|value| value.to_string()) + .unwrap_or_else(|| format!("{:?}", track.codec_family)); + println!( + "track {} role {} codec {} segments {}", + track.track_id, role, codec, track.segment_count + ); + } + + Ok(()) +} diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index e51b614..aba7301 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -46,4 +46,25 @@ test = false doc = false bench = false +[[bin]] +name = "probe_reports" +path = "fuzz_targets/probe_reports.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "dump_reports" +path = "fuzz_targets/dump_reports.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "typed_rewrites" +path = "fuzz_targets/typed_rewrites.rs" +test = false +doc = false +bench = false + [workspace] diff --git a/fuzz/fuzz_targets/dump_reports.rs b/fuzz/fuzz_targets/dump_reports.rs new file mode 100644 index 0000000..3bb5485 --- /dev/null +++ b/fuzz/fuzz_targets/dump_reports.rs @@ -0,0 +1,119 @@ +#![no_main] + +mod support; + +use std::collections::BTreeSet; +use std::io::Cursor; + +use libfuzzer_sys::fuzz_target; +use mp4forge::FourCc; +use mp4forge::cli::dump::{ + DumpOptions, StructuredDumpFormat, build_field_structured_report_paths, + build_structured_report_paths, dump_reader_field_structured_paths, + dump_reader_structured_paths, write_field_structured_report, write_structured_report, +}; +use mp4forge::walk::BoxPath; + +use support::{FuzzInput, seeded_small_mp4_bytes}; + +const FTYP: FourCc = FourCc::from_bytes(*b"ftyp"); +const FREE: FourCc = FourCc::from_bytes(*b"free"); +const MDAT: FourCc = FourCc::from_bytes(*b"mdat"); +const MDHD: FourCc = FourCc::from_bytes(*b"mdhd"); +const MDIA: FourCc = FourCc::from_bytes(*b"mdia"); +const MINF: FourCc = FourCc::from_bytes(*b"minf"); +const MOOF: FourCc = FourCc::from_bytes(*b"moof"); +const MOOV: FourCc = FourCc::from_bytes(*b"moov"); +const MVHD: FourCc = FourCc::from_bytes(*b"mvhd"); +const PSSH: FourCc = FourCc::from_bytes(*b"pssh"); +const SKIP: FourCc = FourCc::from_bytes(*b"skip"); +const STBL: FourCc = FourCc::from_bytes(*b"stbl"); +const STSD: FourCc = FourCc::from_bytes(*b"stsd"); +const TFDT: FourCc = FourCc::from_bytes(*b"tfdt"); +const TRAF: FourCc = FourCc::from_bytes(*b"traf"); +const TRAK: FourCc = FourCc::from_bytes(*b"trak"); +const TRUN: FourCc = FourCc::from_bytes(*b"trun"); + +const FULL_BOX_TYPES: [FourCc; 10] = [FTYP, FREE, MDAT, MDHD, MVHD, PSSH, SKIP, STSD, TFDT, TRUN]; + +fuzz_target!(|data: &[u8]| { + let mut input = FuzzInput::new(data); + let bytes = seeded_small_mp4_bytes(&mut input); + let options = take_dump_options(&mut input); + let paths = take_dump_paths(&mut input); + let format = take_dump_format(&mut input); + + if let Ok(report) = + build_structured_report_paths(&mut Cursor::new(bytes.as_slice()), &options, &paths) + { + let mut rendered = Vec::new(); + let _ = write_structured_report(&mut rendered, &report, format); + } + + if let Ok(report) = + build_field_structured_report_paths(&mut Cursor::new(bytes.as_slice()), &options, &paths) + { + let mut rendered = Vec::new(); + let _ = write_field_structured_report(&mut rendered, &report, format); + } + + let mut structured_output = Vec::new(); + let _ = dump_reader_structured_paths( + &mut Cursor::new(bytes.as_slice()), + &options, + &paths, + format, + &mut structured_output, + ); + + let mut field_output = Vec::new(); + let _ = dump_reader_field_structured_paths( + &mut Cursor::new(bytes.as_slice()), + &options, + &paths, + format, + &mut field_output, + ); +}); + +fn take_dump_options(input: &mut FuzzInput<'_>) -> DumpOptions { + let mut full_box_types = BTreeSet::new(); + for _ in 0..input.take_usize(4) { + full_box_types.insert(input.choose_fourcc(&FULL_BOX_TYPES)); + } + + DumpOptions { + full_box_types, + show_all: input.take_bool(), + show_offset: input.take_bool(), + hex: input.take_bool(), + terminal_width: input.take_usize(240).max(16), + } +} + +fn take_dump_paths(input: &mut FuzzInput<'_>) -> Vec { + let known_paths = vec![ + BoxPath::empty(), + BoxPath::from([FTYP]), + BoxPath::from([MOOV]), + BoxPath::from([MOOV, MVHD]), + BoxPath::from([MOOV, TRAK]), + BoxPath::from([MOOV, TRAK, MDIA]), + BoxPath::from([MOOV, FourCc::ANY, MDIA]), + BoxPath::from([MOOV, TRAK, MDIA, MINF, STBL, STSD]), + BoxPath::from([MOOV, TRAK, MDIA, MINF, STBL, FourCc::ANY]), + BoxPath::from([MOOF]), + BoxPath::from([MOOF, TRAF]), + BoxPath::from([MOOF, TRAF, TFDT]), + BoxPath::from([MOOF, TRAF, TRUN]), + ]; + input.take_paths_from_table(&known_paths, 4) +} + +fn take_dump_format(input: &mut FuzzInput<'_>) -> StructuredDumpFormat { + if input.take_bool() { + StructuredDumpFormat::Json + } else { + StructuredDumpFormat::Yaml + } +} diff --git a/fuzz/fuzz_targets/probe_reports.rs b/fuzz/fuzz_targets/probe_reports.rs new file mode 100644 index 0000000..58055d3 --- /dev/null +++ b/fuzz/fuzz_targets/probe_reports.rs @@ -0,0 +1,132 @@ +#![no_main] + +mod support; + +use std::io::Cursor; + +use libfuzzer_sys::fuzz_target; +use mp4forge::cli::probe::{ + ProbeFormat, ProbeReportOptions, build_codec_detailed_report_with_options, + build_detailed_report_with_options, build_media_characteristics_report_with_options, + build_report_with_options, write_codec_detailed_report, write_detailed_report, + write_media_characteristics_report, write_report, +}; +use mp4forge::probe::{ + ProbeOptions, SegmentInfo, TrackInfo, average_sample_bitrate, average_segment_bitrate, + find_idr_frames, max_sample_bitrate, max_segment_bitrate, probe_bytes_with_options, + probe_codec_detailed_bytes_with_options, probe_codec_detailed_with_options, + probe_detailed_bytes_with_options, probe_detailed_with_options, probe_fra, + probe_fra_codec_detailed, probe_fra_detailed, probe_fra_media_characteristics, + probe_media_characteristics_bytes_with_options, probe_media_characteristics_with_options, + probe_with_options, +}; + +use support::{FuzzInput, seeded_any_mp4_bytes}; + +fuzz_target!(|data: &[u8]| { + let mut input = FuzzInput::new(data); + let bytes = seeded_any_mp4_bytes(&mut input); + let options = take_report_options(&mut input); + let format = take_probe_format(&mut input); + + match input.take_u8() % 4 { + 0 => exercise_coarse_probe_surface(&bytes, options, format), + 1 => exercise_detailed_probe_surface(&bytes, options, format), + 2 => exercise_codec_detailed_probe_surface(&bytes, options, format), + _ => exercise_media_characteristics_probe_surface(&bytes, options, format), + } +}); + +fn exercise_coarse_probe_surface(bytes: &[u8], options: ProbeReportOptions, format: ProbeFormat) { + if let Ok(summary) = probe_with_options(&mut Cursor::new(bytes), options.probe) { + exercise_track_metrics(bytes, &summary.tracks, &summary.segments); + } + + let _ = probe_bytes_with_options(bytes, options.probe); + let _ = probe_fra(&mut Cursor::new(bytes)); + + if let Ok(report) = build_report_with_options(&mut Cursor::new(bytes), options) { + let mut rendered = Vec::new(); + let _ = write_report(&mut rendered, &report, format); + } +} + +fn exercise_detailed_probe_surface(bytes: &[u8], options: ProbeReportOptions, format: ProbeFormat) { + let _ = probe_detailed_with_options(&mut Cursor::new(bytes), options.probe); + let _ = probe_detailed_bytes_with_options(bytes, options.probe); + let _ = probe_fra_detailed(&mut Cursor::new(bytes)); + + if let Ok(report) = build_detailed_report_with_options(&mut Cursor::new(bytes), options) { + let mut rendered = Vec::new(); + let _ = write_detailed_report(&mut rendered, &report, format); + } +} + +fn exercise_codec_detailed_probe_surface( + bytes: &[u8], + options: ProbeReportOptions, + format: ProbeFormat, +) { + let _ = probe_codec_detailed_with_options(&mut Cursor::new(bytes), options.probe); + let _ = probe_codec_detailed_bytes_with_options(bytes, options.probe); + let _ = probe_fra_codec_detailed(&mut Cursor::new(bytes)); + + if let Ok(report) = build_codec_detailed_report_with_options(&mut Cursor::new(bytes), options) { + let mut rendered = Vec::new(); + let _ = write_codec_detailed_report(&mut rendered, &report, format); + } +} + +fn exercise_media_characteristics_probe_surface( + bytes: &[u8], + options: ProbeReportOptions, + format: ProbeFormat, +) { + let _ = probe_media_characteristics_with_options(&mut Cursor::new(bytes), options.probe); + let _ = probe_media_characteristics_bytes_with_options(bytes, options.probe); + let _ = probe_fra_media_characteristics(&mut Cursor::new(bytes)); + + if let Ok(report) = + build_media_characteristics_report_with_options(&mut Cursor::new(bytes), options) + { + let mut rendered = Vec::new(); + let _ = write_media_characteristics_report(&mut rendered, &report, format); + } +} + +fn exercise_track_metrics(bytes: &[u8], tracks: &[TrackInfo], segments: &[SegmentInfo]) { + for track in tracks { + let _ = average_sample_bitrate(&track.samples, track.timescale); + let _ = max_sample_bitrate(&track.samples, track.timescale, 1); + let _ = average_segment_bitrate(segments, track.track_id, track.timescale); + let _ = max_segment_bitrate(segments, track.track_id, track.timescale); + + if track.avc.is_some() && !track.samples.is_empty() && !track.chunks.is_empty() { + let _ = find_idr_frames(&mut Cursor::new(bytes), track); + } + } +} + +fn take_probe_format(input: &mut FuzzInput<'_>) -> ProbeFormat { + if input.take_bool() { + ProbeFormat::Json + } else { + ProbeFormat::Yaml + } +} + +fn take_probe_options(input: &mut FuzzInput<'_>) -> ProbeOptions { + ProbeOptions { + expand_samples: input.take_bool(), + expand_chunks: input.take_bool(), + include_segments: input.take_bool(), + } +} + +fn take_report_options(input: &mut FuzzInput<'_>) -> ProbeReportOptions { + ProbeReportOptions { + probe: take_probe_options(input), + include_bitrate: input.take_bool(), + include_idr_frame_count: input.take_bool(), + } +} diff --git a/fuzz/fuzz_targets/support.rs b/fuzz/fuzz_targets/support.rs index a65d72d..812d77a 100644 --- a/fuzz/fuzz_targets/support.rs +++ b/fuzz/fuzz_targets/support.rs @@ -1,8 +1,49 @@ #![allow(dead_code)] use mp4forge::FourCc; +use mp4forge::header::BoxInfo; use mp4forge::walk::BoxPath; +const SAMPLE_MP4: &[u8] = include_bytes!("../../tests/fixtures/sample.mp4"); +const SAMPLE_FRAGMENTED_MP4: &[u8] = include_bytes!("../../tests/fixtures/sample_fragmented.mp4"); +const SAMPLE_INIT_ENCA_MP4: &[u8] = include_bytes!("../../tests/fixtures/sample_init.enca.mp4"); +const SAMPLE_INIT_ENCV_MP4: &[u8] = include_bytes!("../../tests/fixtures/sample_init.encv.mp4"); +const SAMPLE_QT_MP4: &[u8] = include_bytes!("../../tests/fixtures/sample_qt.mp4"); +const AAC_AUDIO_MP4: &[u8] = include_bytes!("../../tests/fixtures/aac_audio.mp4"); +const OPUS_AUDIO_MP4: &[u8] = include_bytes!("../../tests/fixtures/opus_audio.mp4"); +const PCM_AUDIO_MP4: &[u8] = include_bytes!("../../tests/fixtures/pcm_audio.mp4"); +const VP9_OPUS_MP4: &[u8] = include_bytes!("../../tests/fixtures/vp9_opus.mp4"); +const AV1_OPUS_MP4: &[u8] = include_bytes!("../../tests/fixtures/av1_opus.mp4"); + +const ANY_FIXTURES: [&[u8]; 10] = [ + SAMPLE_MP4, + SAMPLE_FRAGMENTED_MP4, + SAMPLE_INIT_ENCA_MP4, + SAMPLE_INIT_ENCV_MP4, + SAMPLE_QT_MP4, + AAC_AUDIO_MP4, + OPUS_AUDIO_MP4, + PCM_AUDIO_MP4, + VP9_OPUS_MP4, + AV1_OPUS_MP4, +]; + +const SMALL_FIXTURES: [&[u8]; 6] = [ + SAMPLE_MP4, + SAMPLE_FRAGMENTED_MP4, + SAMPLE_INIT_ENCA_MP4, + SAMPLE_INIT_ENCV_MP4, + AAC_AUDIO_MP4, + OPUS_AUDIO_MP4, +]; + +const REWRITE_FIXTURES: [&[u8]; 4] = [ + SAMPLE_MP4, + SAMPLE_FRAGMENTED_MP4, + SAMPLE_INIT_ENCA_MP4, + SAMPLE_INIT_ENCV_MP4, +]; + pub struct FuzzInput<'a> { data: &'a [u8], offset: usize, @@ -29,6 +70,10 @@ impl<'a> FuzzInput<'a> { u32::from_be_bytes(self.take_exact()) } + pub fn take_u64(&mut self) -> u64 { + u64::from_be_bytes(self.take_exact()) + } + pub fn take_exact(&mut self) -> [u8; N] { let mut bytes = [0_u8; N]; for byte in &mut bytes { @@ -77,4 +122,124 @@ impl<'a> FuzzInput<'a> { pub fn choose_fourcc(&mut self, table: &[FourCc]) -> FourCc { table[self.take_usize(table.len() - 1)] } + + pub fn take_path_from_table(&mut self, table: &[BoxPath]) -> BoxPath { + table[self.take_usize(table.len() - 1)].clone() + } + + pub fn take_paths_from_table(&mut self, table: &[BoxPath], max_len: usize) -> Vec { + let len = self.take_usize(max_len); + let mut paths = Vec::with_capacity(len); + for _ in 0..len { + paths.push(self.take_path_from_table(table)); + } + paths + } +} + +pub fn seeded_any_mp4_bytes(input: &mut FuzzInput<'_>) -> Vec { + seeded_mp4_bytes_from(input, &ANY_FIXTURES, 384 * 1024) +} + +pub fn seeded_small_mp4_bytes(input: &mut FuzzInput<'_>) -> Vec { + seeded_mp4_bytes_from(input, &SMALL_FIXTURES, 64 * 1024) +} + +pub fn seeded_rewrite_mp4_bytes(input: &mut FuzzInput<'_>) -> Vec { + seeded_mp4_bytes_from(input, &REWRITE_FIXTURES, 96 * 1024) +} + +fn seeded_mp4_bytes_from(input: &mut FuzzInput<'_>, fixtures: &[&[u8]], max_len: usize) -> Vec { + let mut bytes = select_seed_bytes(input, fixtures, max_len); + mutate_seed_bytes(input, &mut bytes, max_len); + if bytes.is_empty() { + bytes = malformed_truncated_mvhd_payload(); + } + bytes +} + +fn select_seed_bytes(input: &mut FuzzInput<'_>, fixtures: &[&[u8]], max_len: usize) -> Vec { + let malformed_seed_count = 4; + match input.take_usize(fixtures.len() + malformed_seed_count) { + index if index < fixtures.len() => fixtures[index].to_vec(), + index if index == fixtures.len() => malformed_truncated_child_header(), + index if index == fixtures.len() + 1 => malformed_huge_supported_payload(), + index if index == fixtures.len() + 2 => malformed_oversized_child_box(), + index if index == fixtures.len() + 3 => malformed_truncated_mvhd_payload(), + _ => input.take_bytes(max_len.min(4096)), + } +} + +fn mutate_seed_bytes(input: &mut FuzzInput<'_>, bytes: &mut Vec, max_len: usize) { + let steps = input.take_usize(8); + for _ in 0..steps { + match input.take_u8() % 6 { + 0 => { + if !bytes.is_empty() { + let index = input.take_usize(bytes.len() - 1); + bytes[index] ^= input.take_u8(); + } + } + 1 => { + if bytes.len() < max_len { + let index = input.take_usize(bytes.len()); + bytes.insert(index, input.take_u8()); + } + } + 2 => { + if !bytes.is_empty() { + let index = input.take_usize(bytes.len() - 1); + bytes.remove(index); + } + } + 3 => { + if !bytes.is_empty() { + let truncate_to = input.take_usize(bytes.len() - 1); + bytes.truncate(truncate_to); + } + } + 4 => { + let available = max_len.saturating_sub(bytes.len()).min(32); + if available != 0 { + bytes.extend(input.take_bytes(available)); + } + } + _ => { + if bytes.len() >= 2 { + let lhs = input.take_usize(bytes.len() - 1); + let rhs = input.take_usize(bytes.len() - 1); + bytes.swap(lhs, rhs); + } + } + } + } + + if bytes.len() > max_len { + bytes.truncate(max_len); + } +} + +fn malformed_truncated_child_header() -> Vec { + let mut bytes = BoxInfo::new(FourCc::from_bytes(*b"moov"), 16).encode(); + bytes.extend_from_slice(&[0x00, 0x00, 0x00, 0x0c]); + bytes +} + +fn malformed_huge_supported_payload() -> Vec { + let mut bytes = BoxInfo::new(FourCc::from_bytes(*b"styp"), u64::from(u32::MAX)).encode(); + bytes.extend_from_slice(b"isom"); + bytes.extend_from_slice(&0_u32.to_be_bytes()); + bytes +} + +fn malformed_oversized_child_box() -> Vec { + let mut bytes = BoxInfo::new(FourCc::from_bytes(*b"moov"), 16).encode(); + bytes.extend_from_slice(&BoxInfo::new(FourCc::from_bytes(*b"free"), 12).encode()); + bytes +} + +fn malformed_truncated_mvhd_payload() -> Vec { + let mut bytes = BoxInfo::new(FourCc::from_bytes(*b"mvhd"), 12).encode(); + bytes.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); + bytes } diff --git a/fuzz/fuzz_targets/typed_rewrites.rs b/fuzz/fuzz_targets/typed_rewrites.rs new file mode 100644 index 0000000..1ebd07a --- /dev/null +++ b/fuzz/fuzz_targets/typed_rewrites.rs @@ -0,0 +1,146 @@ +#![no_main] + +mod support; + +use std::collections::BTreeSet; +use std::io::Cursor; + +use libfuzzer_sys::fuzz_target; +use mp4forge::FourCc; +use mp4forge::boxes::iso14496_12::{Ftyp, Mvhd, Tfdt}; +use mp4forge::cli::dump::{DumpOptions, build_field_structured_report}; +use mp4forge::cli::edit::{EditOptions, edit_reader}; +use mp4forge::codec::ImmutableBox; +use mp4forge::probe::{ProbeOptions, probe_with_options}; +use mp4forge::rewrite::{rewrite_box_as_bytes, rewrite_boxes_as_bytes}; +use mp4forge::walk::{BoxPath, WalkControl, walk_structure}; + +use support::{FuzzInput, seeded_rewrite_mp4_bytes}; + +const FREE: FourCc = FourCc::from_bytes(*b"free"); +const FTYP: FourCc = FourCc::from_bytes(*b"ftyp"); +const MDAT: FourCc = FourCc::from_bytes(*b"mdat"); +const MDHD: FourCc = FourCc::from_bytes(*b"mdhd"); +const MOOF: FourCc = FourCc::from_bytes(*b"moof"); +const MOOV: FourCc = FourCc::from_bytes(*b"moov"); +const MVHD: FourCc = FourCc::from_bytes(*b"mvhd"); +const PSSH: FourCc = FourCc::from_bytes(*b"pssh"); +const SKIP: FourCc = FourCc::from_bytes(*b"skip"); +const TFDT: FourCc = FourCc::from_bytes(*b"tfdt"); +const TRAF: FourCc = FourCc::from_bytes(*b"traf"); +const TRAK: FourCc = FourCc::from_bytes(*b"trak"); +const TRUN: FourCc = FourCc::from_bytes(*b"trun"); + +const DROP_BOX_TYPES: [FourCc; 6] = [FREE, MDAT, MDHD, PSSH, SKIP, TRUN]; + +fuzz_target!(|data: &[u8]| { + let mut input = FuzzInput::new(data); + let bytes = seeded_rewrite_mp4_bytes(&mut input); + + match input.take_u8() % 3 { + 0 => exercise_ftyp_rewrite(&mut input, &bytes), + 1 => exercise_mvhd_rewrite(&mut input, &bytes), + _ => exercise_tfdt_rewrite(&mut input, &bytes), + } + + exercise_edit_flow(&mut input, &bytes); +}); + +fn exercise_ftyp_rewrite(input: &mut FuzzInput<'_>, bytes: &[u8]) { + let known_paths = vec![ + BoxPath::empty(), + BoxPath::from([FTYP]), + BoxPath::from([MOOV]), + BoxPath::from([MOOV, MVHD]), + BoxPath::from([MOOF, TRAF, TFDT]), + ]; + let path = input.take_path_from_table(&known_paths); + if let Ok(rewritten) = rewrite_box_as_bytes::(bytes, path, |ftyp| { + ftyp.major_brand = input.take_fourcc(); + ftyp.minor_version ^= input.take_u32(); + ftyp.add_compatible_brand(input.take_fourcc()); + if input.take_bool() && !ftyp.compatible_brands.is_empty() { + let brand = ftyp.compatible_brands[input.take_usize(ftyp.compatible_brands.len() - 1)]; + ftyp.remove_compatible_brand(brand); + } + }) { + exercise_rewritten_bytes(&rewritten); + } +} + +fn exercise_mvhd_rewrite(input: &mut FuzzInput<'_>, bytes: &[u8]) { + let known_paths = vec![ + BoxPath::empty(), + BoxPath::from([FTYP]), + BoxPath::from([MOOV]), + BoxPath::from([MOOV, MVHD]), + BoxPath::from([MOOV, TRAK]), + ]; + let paths = input.take_paths_from_table(&known_paths, 3); + if let Ok(rewritten) = rewrite_boxes_as_bytes::(bytes, &paths, |mvhd| { + mvhd.timescale = input.take_u32().max(1); + if mvhd.version() == 0 { + mvhd.duration_v0 = input.take_u32(); + } else { + mvhd.duration_v1 = input.take_u64(); + } + mvhd.next_track_id = input.take_u32(); + }) { + exercise_rewritten_bytes(&rewritten); + } +} + +fn exercise_tfdt_rewrite(input: &mut FuzzInput<'_>, bytes: &[u8]) { + let known_paths = vec![ + BoxPath::empty(), + BoxPath::from([MOOV, MVHD]), + BoxPath::from([MOOF]), + BoxPath::from([MOOF, TRAF]), + BoxPath::from([MOOF, TRAF, TFDT]), + BoxPath::from([MOOF, FourCc::ANY, TFDT]), + ]; + let paths = input.take_paths_from_table(&known_paths, 4); + let decode_time = input.take_u64(); + if let Ok(rewritten) = rewrite_boxes_as_bytes::(bytes, &paths, |tfdt| { + if tfdt.version() == 0 { + tfdt.base_media_decode_time_v0 = decode_time as u32; + } else { + tfdt.base_media_decode_time_v1 = decode_time; + } + }) { + exercise_rewritten_bytes(&rewritten); + } +} + +fn exercise_edit_flow(input: &mut FuzzInput<'_>, bytes: &[u8]) { + let mut drop_boxes = BTreeSet::new(); + for _ in 0..input.take_usize(4) { + drop_boxes.insert(input.choose_fourcc(&DROP_BOX_TYPES)); + } + + let options = EditOptions { + base_media_decode_time: if input.take_bool() { + Some(input.take_u64()) + } else { + None + }, + drop_boxes, + }; + + let mut rewritten = Cursor::new(Vec::new()); + if edit_reader(&mut Cursor::new(bytes), &mut rewritten, &options).is_ok() { + exercise_rewritten_bytes(rewritten.get_ref().as_slice()); + } +} + +fn exercise_rewritten_bytes(bytes: &[u8]) { + let _ = probe_with_options(&mut Cursor::new(bytes), ProbeOptions::lightweight()); + let _ = build_field_structured_report(&mut Cursor::new(bytes), &DumpOptions::default()); + let _ = walk_structure(&mut Cursor::new(bytes), |handle| { + Ok(if handle.is_supported_type() { + WalkControl::Descend + } else { + WalkControl::Continue + }) + }); +} diff --git a/src/cli/divide.rs b/src/cli/divide.rs index 451edfd..49e0c3b 100644 --- a/src/cli/divide.rs +++ b/src/cli/divide.rs @@ -1,6 +1,6 @@ //! Fragmented-file split command support. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::error::Error; use std::fmt; use std::fs::{self, File}; @@ -11,7 +11,9 @@ use crate::FourCc; use crate::boxes::iso14496_12::{Tfhd, Tkhd}; use crate::extract::{ExtractError, extract_boxes_with_payload}; use crate::header::{BoxInfo, HeaderError}; -use crate::probe::{ProbeError, ProbeInfo, TrackCodec, probe}; +use crate::probe::{ + DetailedProbeInfo, DetailedTrackInfo, ProbeError, TrackCodecFamily, probe_detailed, +}; use crate::walk::BoxPath; use crate::writer::{Writer, WriterError}; @@ -32,14 +34,24 @@ const VIDEO_ENC_DIR: &str = "video_enc"; const AUDIO_ENC_DIR: &str = "audio_enc"; const INIT_FILE_NAME: &str = "init.mp4"; const PLAYLIST_FILE_NAME: &str = "playlist.m3u8"; -const MASTER_PLAYLIST_CODECS: &str = "avc1.64001f,mp4a.40.2"; /// Runs the divide subcommand with `args`, writing files under `OUTPUT_DIR`. pub fn run(args: &[String], stderr: &mut E) -> i32 where E: Write, { - match run_inner(args) { + let mut stdout = io::sink(); + run_with_output(args, &mut stdout, stderr) +} + +/// Runs the divide subcommand with `args`, writing validation output to `stdout` when requested +/// and errors to `stderr`. +pub fn run_with_output(args: &[String], stdout: &mut W, stderr: &mut E) -> i32 +where + W: Write, + E: Write, +{ + match run_inner(args, stdout) { Ok(()) => 0, Err(DivideError::UsageRequested) => { let _ = write_usage(stderr); @@ -57,30 +69,97 @@ pub fn write_usage(writer: &mut W) -> io::Result<()> where W: Write, { - writeln!(writer, "USAGE: mp4forge divide INPUT.mp4 OUTPUT_DIR") + writeln!(writer, "USAGE: mp4forge divide INPUT.mp4 OUTPUT_DIR")?; + writeln!(writer, " mp4forge divide -validate INPUT.mp4")?; + writeln!(writer)?; + writeln!(writer, "OPTIONS:")?; + writeln!( + writer, + " -validate Validate the fragmented divide layout without writing output files" + )?; + writeln!(writer)?; + writeln!( + writer, + "Currently supports fragmented inputs with up to one AVC video track and one MP4A audio track," + )?; + writeln!( + writer, + "including encrypted wrappers that preserve those original sample-entry formats." + ) } -fn run_inner(args: &[String]) -> Result<(), DivideError> { - if args.len() != 2 { - return Err(DivideError::UsageRequested); +#[derive(Debug)] +struct ParsedDivideArgs<'a> { + validate_only: bool, + input_path: &'a Path, + output_dir: Option<&'a Path>, +} + +fn run_inner(args: &[String], stdout: &mut W) -> Result<(), DivideError> +where + W: Write, +{ + let parsed = parse_args(args)?; + let mut input = File::open(parsed.input_path)?; + if parsed.validate_only { + let report = validate_divide_reader(&mut input)?; + write_validation_report(stdout, &report)?; + return Ok(()); } - let input_path = Path::new(&args[0]); - let output_dir = Path::new(&args[1]); - let mut input = File::open(input_path)?; - divide_reader(&mut input, output_dir) + divide_reader( + &mut input, + parsed.output_dir.ok_or(DivideError::UsageRequested)?, + ) +} + +fn parse_args(args: &[String]) -> Result, DivideError> { + let mut validate_only = false; + let mut positional = Vec::new(); + let mut index = 0usize; + while index < args.len() { + match args[index].as_str() { + "-validate" | "--validate" => { + validate_only = true; + index += 1; + } + "-h" | "--help" => return Err(DivideError::UsageRequested), + value if value.starts_with('-') => { + return Err(invalid_input(format!("unknown divide option: {value}"))); + } + value => { + positional.push(Path::new(value)); + index += 1; + } + } + } + + match (validate_only, positional.as_slice()) { + (true, [input_path]) => Ok(ParsedDivideArgs { + validate_only, + input_path, + output_dir: None, + }), + (false, [input_path, output_dir]) => Ok(ParsedDivideArgs { + validate_only, + input_path, + output_dir: Some(output_dir), + }), + _ => Err(DivideError::UsageRequested), + } } /// Splits a fragmented MP4 reader into per-track outputs under `output_dir`. +/// +/// The current `divide` surface supports fragmented inputs with at most one AVC video track and +/// one MP4A audio track, including encrypted `encv` and `enca` wrappers when the original format +/// is still `avc1` or `mp4a`. pub fn divide_reader(reader: &mut R, output_dir: &Path) -> Result<(), DivideError> where R: Read + Seek, { - let summary = probe(reader)?; - let mut tracks = build_track_outputs(&summary, output_dir)?; - if tracks.is_empty() { - return Err(DivideError::NoSupportedTracks); - } + let plans = validate_divide_track_plans(reader)?; + let mut tracks = build_track_outputs(&plans, output_dir)?; reader.seek(SeekFrom::Start(0))?; write_init_segments(reader, &mut tracks)?; @@ -98,8 +177,52 @@ enum TrackKind { EncryptedAudio, } +/// High-level role assigned to one active track in the currently supported divide layout. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DivideTrackRole { + Video, + Audio, +} + +/// Validation summary for one active fragmented track accepted by `divide`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DivideValidationTrack { + /// Track identifier selected from `tkhd` and referenced by fragmented runs. + pub track_id: u32, + /// Role assigned by the current divide layout rules. + pub role: DivideTrackRole, + /// Whether the selected track uses an encrypted sample-entry wrapper. + pub encrypted: bool, + /// Normalized codec family derived from the sample entry or protected original format. + pub codec_family: TrackCodecFamily, + /// Sample-entry box type selected from `stsd`, including encrypted wrappers such as `encv`. + pub sample_entry_type: Option, + /// Original-format sample-entry type from `frma` when the track is protected. + pub original_format: Option, + /// Number of fragmented media segments currently associated with the track. + pub segment_count: usize, +} + +/// Additive divide preflight report returned when the fragmented layout is currently supported. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct DivideValidationReport { + /// Active fragmented tracks accepted by the current divide layout rules. + pub tracks: Vec, +} + +struct TrackLayout { + role: DivideTrackRole, + kind: TrackKind, + codecs: String, + audio_channels: Option, + width: Option, + height: Option, +} + struct TrackOutput { kind: TrackKind, + codecs: String, + audio_channels: Option, width: Option, height: Option, segment_durations: Vec, @@ -109,70 +232,213 @@ struct TrackOutput { next_segment_index: usize, } +struct ValidatedTrackPlan { + validation: DivideValidationTrack, + layout: TrackLayout, + segment_durations: Vec, +} + +/// Validates whether `reader` matches the fragmented layout currently supported by +/// [`divide_reader`] without creating any output files. +/// +/// On success, the returned report lists the active fragmented tracks that would participate in +/// the divide output. On failure, the returned [`DivideError`] explains why the current layout is +/// unsupported. +pub fn validate_divide_reader(reader: &mut R) -> Result +where + R: Read + Seek, +{ + let plans = validate_divide_track_plans(reader)?; + Ok(DivideValidationReport { + tracks: plans.into_iter().map(|plan| plan.validation).collect(), + }) +} + +fn validate_divide_track_plans(reader: &mut R) -> Result, DivideError> +where + R: Read + Seek, +{ + reader.seek(SeekFrom::Start(0))?; + let summary = probe_detailed(reader)?; + collect_track_plans(&summary) +} + fn build_track_outputs( - summary: &ProbeInfo, + plans: &[ValidatedTrackPlan], output_dir: &Path, ) -> Result, DivideError> { let mut tracks = BTreeMap::new(); - for track in &summary.tracks { - let Some((kind, dir_name, width, height)) = track_layout(track) else { - continue; - }; - let track_dir = output_dir.join(dir_name); + for plan in plans { + let track_dir = output_dir.join(relative_dir(plan.layout.kind)); fs::create_dir_all(&track_dir)?; let init_writer = Writer::new(File::create(track_dir.join(INIT_FILE_NAME))?); + tracks.insert( + plan.validation.track_id, + TrackOutput { + kind: plan.layout.kind, + codecs: plan.layout.codecs.clone(), + audio_channels: plan.layout.audio_channels, + width: plan.layout.width, + height: plan.layout.height, + segment_durations: plan.segment_durations.clone(), + bandwidth: 0, + output_dir: track_dir, + init_writer, + next_segment_index: 0, + }, + ); + } + + Ok(tracks) +} + +fn collect_track_plans( + summary: &DetailedProbeInfo, +) -> Result, DivideError> { + let active_track_ids = summary + .segments + .iter() + .map(|segment| segment.track_id) + .collect::>(); + let known_track_ids = summary + .tracks + .iter() + .map(|track| track.summary.track_id) + .collect::>(); + + if let Some(track_id) = active_track_ids.difference(&known_track_ids).next() { + return Err(DivideError::UnknownTrack(*track_id)); + } + + let mut tracks = BTreeMap::new(); + let mut selected_video_track_id = None; + let mut selected_audio_track_id = None; + + for track in &summary.tracks { + if !active_track_ids.contains(&track.summary.track_id) { + continue; + } + let layout = track_layout(track)?; + match layout.role { + DivideTrackRole::Video => { + if let Some(existing_track_id) = + selected_video_track_id.replace(track.summary.track_id) + { + return Err(invalid_input(format!( + "{}; found multiple fragmented video tracks ({existing_track_id} and {}).", + supported_scope_message(), + track.summary.track_id + ))); + } + } + DivideTrackRole::Audio => { + if let Some(existing_track_id) = + selected_audio_track_id.replace(track.summary.track_id) + { + return Err(invalid_input(format!( + "{}; found multiple fragmented audio tracks ({existing_track_id} and {}).", + supported_scope_message(), + track.summary.track_id + ))); + } + } + } + let segment_durations = summary .segments .iter() - .filter(|segment| segment.track_id == track.track_id) + .filter(|segment| segment.track_id == track.summary.track_id) .map(|segment| { - if track.timescale == 0 { + if track.summary.timescale == 0 { 0.0 } else { - segment.duration as f64 / f64::from(track.timescale) + segment.duration as f64 / f64::from(track.summary.timescale) } }) .collect::>(); tracks.insert( - track.track_id, - TrackOutput { - kind, - width, - height, + track.summary.track_id, + ValidatedTrackPlan { + validation: DivideValidationTrack { + track_id: track.summary.track_id, + role: layout.role, + encrypted: track.summary.encrypted, + codec_family: track.codec_family, + sample_entry_type: track.sample_entry_type, + original_format: track.original_format, + segment_count: segment_durations.len(), + }, + layout, segment_durations, - bandwidth: 0, - output_dir: track_dir, - init_writer, - next_segment_index: 0, }, ); } - Ok(tracks) + let plans = tracks.into_values().collect::>(); + if plans.is_empty() { + return Err(DivideError::NoSupportedTracks); + } + + Ok(plans) } -fn track_layout( - track: &crate::probe::TrackInfo, -) -> Option<(TrackKind, &'static str, Option, Option)> { - match (track.codec, track.encrypted) { - (TrackCodec::Avc1, false) => Some(( - TrackKind::Video, - VIDEO_DIR, - track.avc.as_ref().map(|avc| avc.width), - track.avc.as_ref().map(|avc| avc.height), - )), - (TrackCodec::Avc1, true) => Some(( - TrackKind::EncryptedVideo, - VIDEO_ENC_DIR, - track.avc.as_ref().map(|avc| avc.width), - track.avc.as_ref().map(|avc| avc.height), - )), - (TrackCodec::Mp4a, false) => Some((TrackKind::Audio, AUDIO_DIR, None, None)), - (TrackCodec::Mp4a, true) => Some((TrackKind::EncryptedAudio, AUDIO_ENC_DIR, None, None)), - (TrackCodec::Unknown, _) => None, +fn track_layout(track: &DetailedTrackInfo) -> Result { + match track.codec_family { + TrackCodecFamily::Avc => { + let avc = track.summary.avc.as_ref().ok_or_else(|| { + invalid_input(format!( + "track {} is missing the AVC decoder configuration needed for divide playlist signaling.", + track.summary.track_id + )) + })?; + Ok(TrackLayout { + role: DivideTrackRole::Video, + kind: if track.summary.encrypted { + TrackKind::EncryptedVideo + } else { + TrackKind::Video + }, + codecs: format!( + "avc1.{:02x}{:02x}{:02x}", + avc.profile, avc.profile_compatibility, avc.level + ), + audio_channels: None, + width: track.display_width.or(Some(avc.width)), + height: track.display_height.or(Some(avc.height)), + }) + } + TrackCodecFamily::Mp4Audio => { + let mp4a = track.summary.mp4a.as_ref().ok_or_else(|| { + invalid_input(format!( + "track {} is missing the MP4A decoder configuration needed for divide playlist signaling.", + track.summary.track_id + )) + })?; + Ok(TrackLayout { + role: DivideTrackRole::Audio, + kind: if track.summary.encrypted { + TrackKind::EncryptedAudio + } else { + TrackKind::Audio + }, + codecs: mp4a_codec_string(mp4a.object_type_indication, mp4a.audio_object_type), + audio_channels: track + .channel_count + .or(Some(mp4a.channel_count)) + .filter(|value| *value != 0), + width: None, + height: None, + }) + } + _ => Err(invalid_input(format!( + "track {} uses unsupported codec `{}`; {}", + track.summary.track_id, + track_codec_label(track), + supported_scope_message() + ))), } } @@ -332,18 +598,23 @@ fn write_playlists( let mut master = File::create(output_dir.join(PLAYLIST_FILE_NAME))?; writeln!(master, "#EXTM3U")?; if let Some(audio) = audio { - writeln!( + write!( master, - "#EXT-X-MEDIA:TYPE=AUDIO,URI=\"{}/{}\",GROUP-ID=\"audio\",NAME=\"audio\",AUTOSELECT=YES,CHANNELS=\"2\"", + "#EXT-X-MEDIA:TYPE=AUDIO,URI=\"{}/{}\",GROUP-ID=\"audio\",NAME=\"audio\",AUTOSELECT=YES", relative_dir(audio.kind), PLAYLIST_FILE_NAME )?; + if let Some(channels) = audio.audio_channels { + write!(master, ",CHANNELS=\"{channels}\"")?; + } + writeln!(master)?; } write!( master, "#EXT-X-STREAM-INF:BANDWIDTH={},CODECS=\"{}\"", - video.bandwidth, MASTER_PLAYLIST_CODECS + video.bandwidth, + master_playlist_codecs(video, audio) )?; if let (Some(width), Some(height)) = (video.width, video.height) { write!(master, ",RESOLUTION={}x{}", width, height)?; @@ -398,6 +669,103 @@ fn segment_file_name(index: usize) -> String { format!("{index}.mp4") } +fn master_playlist_codecs(video: &TrackOutput, audio: Option<&TrackOutput>) -> String { + match audio { + Some(audio) => format!("{},{}", video.codecs, audio.codecs), + None => video.codecs.clone(), + } +} + +fn mp4a_codec_string(object_type_indication: u8, audio_object_type: u8) -> String { + if object_type_indication == 0 { + "mp4a".to_string() + } else if audio_object_type == 0 { + format!("mp4a.{object_type_indication:x}") + } else { + format!("mp4a.{object_type_indication:x}.{audio_object_type}") + } +} + +fn write_validation_report( + writer: &mut W, + report: &DivideValidationReport, +) -> Result<(), DivideError> +where + W: Write, +{ + writeln!(writer, "supported fragmented divide layout")?; + for track in &report.tracks { + writeln!( + writer, + "track {}: role={} codec={} segments={}", + track.track_id, + validation_role_label(track.role), + validation_codec_label(track), + track.segment_count + )?; + } + Ok(()) +} + +fn validation_role_label(role: DivideTrackRole) -> &'static str { + match role { + DivideTrackRole::Video => "video", + DivideTrackRole::Audio => "audio", + } +} + +fn validation_codec_label(track: &DivideValidationTrack) -> String { + track + .original_format + .or(track.sample_entry_type) + .map(|value| value.to_string()) + .unwrap_or_else(|| match track.codec_family { + TrackCodecFamily::Unknown => "unknown".to_string(), + TrackCodecFamily::Avc => "avc".to_string(), + TrackCodecFamily::Hevc => "hevc".to_string(), + TrackCodecFamily::Av1 => "av1".to_string(), + TrackCodecFamily::Vp8 => "vp8".to_string(), + TrackCodecFamily::Vp9 => "vp9".to_string(), + TrackCodecFamily::Mp4Audio => "mp4a".to_string(), + TrackCodecFamily::Opus => "opus".to_string(), + TrackCodecFamily::Ac3 => "ac-3".to_string(), + TrackCodecFamily::Pcm => "pcm".to_string(), + TrackCodecFamily::XmlSubtitle => "stpp".to_string(), + TrackCodecFamily::TextSubtitle => "sbtt".to_string(), + TrackCodecFamily::WebVtt => "wvtt".to_string(), + }) +} + +fn track_codec_label(track: &DetailedTrackInfo) -> String { + track + .original_format + .or(track.sample_entry_type) + .map(|value| value.to_string()) + .unwrap_or_else(|| match track.codec_family { + TrackCodecFamily::Unknown => "unknown".to_string(), + TrackCodecFamily::Avc => "avc".to_string(), + TrackCodecFamily::Hevc => "hevc".to_string(), + TrackCodecFamily::Av1 => "av1".to_string(), + TrackCodecFamily::Vp8 => "vp8".to_string(), + TrackCodecFamily::Vp9 => "vp9".to_string(), + TrackCodecFamily::Mp4Audio => "mp4a".to_string(), + TrackCodecFamily::Opus => "opus".to_string(), + TrackCodecFamily::Ac3 => "ac-3".to_string(), + TrackCodecFamily::Pcm => "pcm".to_string(), + TrackCodecFamily::XmlSubtitle => "stpp".to_string(), + TrackCodecFamily::TextSubtitle => "sbtt".to_string(), + TrackCodecFamily::WebVtt => "wvtt".to_string(), + }) +} + +fn supported_scope_message() -> &'static str { + "divide currently supports fragmented inputs with at most one AVC video track and one MP4A audio track" +} + +fn invalid_input(message: String) -> DivideError { + DivideError::Io(io::Error::new(io::ErrorKind::InvalidInput, message)) +} + fn trak_track_id(reader: &mut R, trak: &BoxInfo) -> Result where R: Read + Seek, @@ -490,7 +858,11 @@ impl fmt::Display for DivideError { Self::MissingTrackId => f.write_str("track id not found"), Self::UnknownTrack(track_id) => write!(f, "unknown track id: {track_id}"), Self::UnexpectedMdat => f.write_str("mdat appeared without a preceding moof"), - Self::NoSupportedTracks => f.write_str("no supported tracks found"), + Self::NoSupportedTracks => write!( + f, + "no supported fragmented tracks found; {}", + supported_scope_message() + ), Self::NumericOverflow => f.write_str("numeric value does not fit in memory"), Self::UsageRequested => f.write_str("usage requested"), } diff --git a/src/cli/dump.rs b/src/cli/dump.rs index b4689c0..250bb3a 100644 --- a/src/cli/dump.rs +++ b/src/cli/dump.rs @@ -9,10 +9,10 @@ use std::io::{self, Read, Seek, Write}; use terminal_size::{Width, terminal_size}; use crate::FourCc; -use crate::codec::CodecError; +use crate::codec::{CodecError, FieldValue}; use crate::header::HeaderError; -use crate::stringify::{StringifyError, stringify}; -use crate::walk::{WalkControl, WalkError, walk_structure}; +use crate::stringify::{StringifyError, collect_structured_fields, stringify}; +use crate::walk::{BoxPath, WalkControl, WalkError, WalkHandle, walk_structure}; use super::util::should_have_no_children; @@ -21,6 +21,128 @@ const FREE: FourCc = FourCc::from_bytes(*b"free"); const MDAT: FourCc = FourCc::from_bytes(*b"mdat"); const SKIP: FourCc = FourCc::from_bytes(*b"skip"); +/// Structured output format supported by the dump command. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StructuredDumpFormat { + /// Pretty-printed JSON output. + Json, + /// Simple YAML output with stable field order. + Yaml, +} + +impl StructuredDumpFormat { + fn parse(value: &str) -> Result, DumpError> { + match value { + "text" => Ok(None), + "json" => Ok(Some(Self::Json)), + "yaml" => Ok(Some(Self::Yaml)), + other => Err(DumpError::InvalidArgument(format!( + "unsupported dump format: {other}" + ))), + } + } +} + +/// Structured payload state recorded for one dumped box. +#[cfg_attr( + feature = "serde", + derive(serde::Serialize, serde::Deserialize), + serde(rename_all = "snake_case") +)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum DumpPayloadStatus { + /// The box payload rendered as a descriptor-backed summary string. + Summary, + /// The box payload was empty or has no visible summary fields. + #[default] + Empty, + /// Raw payload bytes were included in the structured report. + Bytes, + /// Payload bytes were intentionally omitted until `-full` or `-a` is requested. + Omitted, + /// The box type is known, but the encoded version is not currently supported. + UnsupportedVersion, +} + +/// Top-level structured dump report used by JSON and YAML tree export. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct StructuredDumpReport { + /// Top-level boxes in file order. + pub boxes: Vec, +} + +/// One node in the structured dump tree. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct StructuredDumpBoxReport { + /// Four-character box identifier. + pub box_type: String, + /// Slash-delimited path from the file root to this box. + pub path: String, + /// Absolute file offset of the box header. + pub offset: u64, + /// Total box size including the header. + pub size: u64, + /// Whether the current box type is registered in the active lookup context. + pub supported: bool, + /// Summary of how payload detail is represented in this report node. + pub payload_status: DumpPayloadStatus, + /// Descriptor-backed payload summary when one was rendered. + pub payload_summary: Option, + /// Raw payload bytes when full raw expansion was requested. + pub payload_bytes: Option>, + /// Direct child boxes in file order. + pub children: Vec, +} + +/// Additive structured dump report that includes field-level payload data for supported boxes. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct FieldStructuredDumpReport { + /// Top-level boxes in file order. + pub boxes: Vec, +} + +/// One node in the field-level structured dump tree. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct FieldStructuredDumpBoxReport { + /// Four-character box identifier. + pub box_type: String, + /// Slash-delimited path from the file root to this box. + pub path: String, + /// Absolute file offset of the box header. + pub offset: u64, + /// Total box size including the header. + pub size: u64, + /// Whether the current box type is registered in the active lookup context. + pub supported: bool, + /// Summary of how payload detail is represented in this report node. + pub payload_status: DumpPayloadStatus, + /// Deterministic field-level payload data for supported boxes when it is available. + pub payload_fields: Vec, + /// Descriptor-backed payload summary when one was rendered. + pub payload_summary: Option, + /// Raw payload bytes when full raw expansion was requested. + pub payload_bytes: Option>, + /// Direct child boxes in file order. + pub children: Vec, +} + +/// One field entry in the field-level structured dump payload report. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StructuredDumpFieldReport { + /// Stable field name from the active descriptor table. + pub name: String, + /// Machine-readable field value. + pub value: FieldValue, + /// Optional human-oriented display projection when the field uses a display override or + /// non-default formatting. + pub display_value: Option, +} + /// Formatting controls for the dump command. #[derive(Clone, Debug, PartialEq, Eq)] pub struct DumpOptions { @@ -96,6 +218,14 @@ where writer, " -a Show full content for supported boxes" )?; + writeln!( + writer, + " -format Output format (default: text)" + )?; + writeln!( + writer, + " -path Dump only matched parsed subtrees (repeatable)" + )?; writeln!( writer, " -mdat Deprecated shorthand for -full mdat" @@ -118,14 +248,39 @@ pub fn dump_reader( options: &DumpOptions, writer: &mut W, ) -> Result<(), DumpError> +where + R: Read + Seek, + W: Write, +{ + dump_reader_paths(reader, options, &[], writer) +} + +/// Dumps only the subtrees that match any parsed `paths` using the provided formatting +/// `options`. +/// +/// Paths use the existing [`BoxPath`] parser, including slash-delimited segments, `*` wildcards, +/// and the `` marker. Matching roots become the top-level boxes in the rendered text view, +/// so descendants are indented relative to the selected subtree instead of the original file root. +/// When `paths` is empty, this behaves the same as [`dump_reader`]. +pub fn dump_reader_paths( + reader: &mut R, + options: &DumpOptions, + paths: &[BoxPath], + writer: &mut W, +) -> Result<(), DumpError> where R: Read + Seek, W: Write, { let mut dump_error = None; let result = walk_structure(reader, |handle| { + let selection = match_dump_paths(paths, handle.path()); + if !selection.include { + return continue_dump_search(handle, selection.descend); + } + let info = *handle.info(); - let mut line = " ".repeat(handle.path().len().saturating_sub(1) * 2); + let mut line = " ".repeat(selection.relative_depth(handle.path()).unwrap_or(0) * 2); line.push('['); line.push_str(&info.box_type().to_string()); line.push(']'); @@ -225,15 +380,238 @@ where Ok(()) } +/// Builds a structured dump tree from one MP4 reader using the provided formatting `options`. +pub fn build_structured_report( + reader: &mut R, + options: &DumpOptions, +) -> Result +where + R: Read + Seek, +{ + build_structured_report_paths(reader, options, &[]) +} + +/// Builds a structured dump tree from only the subtrees that match any parsed `paths`. +/// +/// Matching roots become the top-level boxes in the returned report while each node keeps its +/// original full file path. When `paths` is empty, this behaves the same as +/// [`build_structured_report`]. +pub fn build_structured_report_paths( + reader: &mut R, + options: &DumpOptions, + paths: &[BoxPath], +) -> Result +where + R: Read + Seek, +{ + let mut roots = Vec::new(); + let mut stack = Vec::new(); + let mut dump_error = None; + + let result = walk_structure(reader, |handle| { + let selection = match_dump_paths(paths, handle.path()); + if !selection.include { + return continue_dump_search(handle, selection.descend); + } + + finalize_completed_boxes( + selection.relative_depth(handle.path()).unwrap_or(0), + &mut stack, + &mut roots, + ); + let (node, control) = build_structured_box_report(handle, options, &mut dump_error)?; + stack.push(node); + Ok(control) + }); + + if let Some(error) = dump_error { + return Err(error); + } + result?; + finalize_completed_boxes(0, &mut stack, &mut roots); + + Ok(StructuredDumpReport { boxes: roots }) +} + +/// Builds an additive field-level structured dump tree from one MP4 reader using the provided +/// formatting `options`. +pub fn build_field_structured_report( + reader: &mut R, + options: &DumpOptions, +) -> Result +where + R: Read + Seek, +{ + build_field_structured_report_paths(reader, options, &[]) +} + +/// Builds an additive field-level structured dump tree from only the subtrees that match any +/// parsed `paths`. +/// +/// Matching roots become the top-level boxes in the returned report while each node keeps its +/// original full file path. When `paths` is empty, this behaves the same as +/// [`build_field_structured_report`]. +pub fn build_field_structured_report_paths( + reader: &mut R, + options: &DumpOptions, + paths: &[BoxPath], +) -> Result +where + R: Read + Seek, +{ + let mut roots = Vec::new(); + let mut stack = Vec::new(); + let mut dump_error = None; + + let result = walk_structure(reader, |handle| { + let selection = match_dump_paths(paths, handle.path()); + if !selection.include { + return continue_dump_search(handle, selection.descend); + } + + finalize_completed_field_boxes( + selection.relative_depth(handle.path()).unwrap_or(0), + &mut stack, + &mut roots, + ); + let (node, control) = build_field_structured_box_report(handle, options, &mut dump_error)?; + stack.push(node); + Ok(control) + }); + + if let Some(error) = dump_error { + return Err(error); + } + result?; + finalize_completed_field_boxes(0, &mut stack, &mut roots); + + Ok(FieldStructuredDumpReport { boxes: roots }) +} + +/// Writes a structured dump `report` in the selected `format`. +pub fn write_structured_report( + writer: &mut W, + report: &StructuredDumpReport, + format: StructuredDumpFormat, +) -> Result<(), DumpError> +where + W: Write, +{ + match format { + StructuredDumpFormat::Json => { + write_json_structured_report(writer, report).map_err(DumpError::Io) + } + StructuredDumpFormat::Yaml => { + write_yaml_structured_report(writer, report).map_err(DumpError::Io) + } + } +} + +/// Writes a field-level structured dump `report` in the selected `format`. +pub fn write_field_structured_report( + writer: &mut W, + report: &FieldStructuredDumpReport, + format: StructuredDumpFormat, +) -> Result<(), DumpError> +where + W: Write, +{ + match format { + StructuredDumpFormat::Json => { + write_json_field_structured_report(writer, report).map_err(DumpError::Io) + } + StructuredDumpFormat::Yaml => { + write_yaml_field_structured_report(writer, report).map_err(DumpError::Io) + } + } +} + +/// Dumps one MP4 reader as a structured JSON or YAML tree using the provided `options`. +pub fn dump_reader_structured( + reader: &mut R, + options: &DumpOptions, + format: StructuredDumpFormat, + writer: &mut W, +) -> Result<(), DumpError> +where + R: Read + Seek, + W: Write, +{ + dump_reader_structured_paths(reader, options, &[], format, writer) +} + +/// Dumps only the subtrees that match any parsed `paths` as a structured JSON or YAML tree. +/// +/// When `paths` is empty, this behaves the same as [`dump_reader_structured`]. +pub fn dump_reader_structured_paths( + reader: &mut R, + options: &DumpOptions, + paths: &[BoxPath], + format: StructuredDumpFormat, + writer: &mut W, +) -> Result<(), DumpError> +where + R: Read + Seek, + W: Write, +{ + let report = build_structured_report_paths(reader, options, paths)?; + write_structured_report(writer, &report, format) +} + +/// Dumps one MP4 reader as an additive field-level structured JSON or YAML tree using the +/// provided `options`. +pub fn dump_reader_field_structured( + reader: &mut R, + options: &DumpOptions, + format: StructuredDumpFormat, + writer: &mut W, +) -> Result<(), DumpError> +where + R: Read + Seek, + W: Write, +{ + dump_reader_field_structured_paths(reader, options, &[], format, writer) +} + +/// Dumps only the subtrees that match any parsed `paths` as an additive field-level structured +/// JSON or YAML tree. +/// +/// When `paths` is empty, this behaves the same as [`dump_reader_field_structured`]. +pub fn dump_reader_field_structured_paths( + reader: &mut R, + options: &DumpOptions, + paths: &[BoxPath], + format: StructuredDumpFormat, + writer: &mut W, +) -> Result<(), DumpError> +where + R: Read + Seek, + W: Write, +{ + let report = build_field_structured_report_paths(reader, options, paths)?; + write_field_structured_report(writer, &report, format) +} + fn run_inner(args: &[String], stdout: &mut W) -> Result<(), DumpError> where W: Write, { let mut options = DumpOptions::default(); + let mut format = None; + let mut paths = Vec::new(); let mut input_path = None; let mut index = 0usize; while index < args.len() { match args[index].as_str() { + "-format" | "--format" => { + let Some(value) = args.get(index + 1) else { + return Err(DumpError::InvalidArgument( + "missing value for -format".to_string(), + )); + }; + format = StructuredDumpFormat::parse(value)?; + index += 2; + } "-full" | "--full" => { let Some(value) = args.get(index + 1) else { return Err(DumpError::InvalidArgument( @@ -243,6 +621,18 @@ where parse_full_box_types(value, &mut options.full_box_types)?; index += 2; } + "-path" | "--path" => { + let Some(value) = args.get(index + 1) else { + return Err(DumpError::InvalidArgument( + "missing value for -path".to_string(), + )); + }; + let path = BoxPath::parse(value).map_err(|error| { + DumpError::InvalidArgument(format!("invalid box path: {error}")) + })?; + paths.push(path); + index += 2; + } "-a" | "--a" => { options.show_all = true; index += 1; @@ -287,7 +677,291 @@ where }; let mut file = File::open(input_path)?; - dump_reader(&mut file, &options, stdout) + match format { + Some(format) => { + dump_reader_field_structured_paths(&mut file, &options, &paths, format, stdout) + } + None => dump_reader_paths(&mut file, &options, &paths, stdout), + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct DumpPathMatch { + include: bool, + descend: bool, + display_depth_base: Option, +} + +impl DumpPathMatch { + fn relative_depth(self, path: &BoxPath) -> Option { + self.display_depth_base + .map(|base| path.len().saturating_sub(base)) + } +} + +fn match_dump_paths(paths: &[BoxPath], current: &BoxPath) -> DumpPathMatch { + if paths.is_empty() { + return DumpPathMatch { + include: true, + descend: true, + display_depth_base: Some(1), + }; + } + + let mut matched = DumpPathMatch::default(); + for path in paths { + let current_vs_selected = current.compare_with(path); + if current_vs_selected.forward_match { + matched.descend = true; + } + + let selected_vs_current = path.compare_with(current); + if selected_vs_current.exact_match || selected_vs_current.forward_match { + matched.include = true; + matched.descend = true; + let display_depth_base = dump_display_depth_base(path); + matched.display_depth_base = Some( + matched + .display_depth_base + .map_or(display_depth_base, |base| base.min(display_depth_base)), + ); + } + } + + matched +} + +fn dump_display_depth_base(path: &BoxPath) -> usize { + if path.is_empty() { 1 } else { path.len() } +} + +fn continue_dump_search( + handle: &mut WalkHandle<'_, R>, + should_descend: bool, +) -> Result +where + R: Read + Seek, +{ + if !should_descend { + return Ok(WalkControl::Continue); + } + + if !handle.is_supported_type() { + return Ok(WalkControl::Continue); + } + + if handle.info().payload_size()? >= 256 && should_have_no_children(handle.info().box_type()) { + return Ok(WalkControl::Continue); + } + + match handle.read_payload() { + Ok(_) => Ok(WalkControl::Descend), + Err(WalkError::Codec(CodecError::UnsupportedVersion { .. })) => Ok(WalkControl::Continue), + Err(error) => Err(error), + } +} + +fn build_structured_box_report( + handle: &mut WalkHandle<'_, R>, + options: &DumpOptions, + dump_error: &mut Option, +) -> Result<(StructuredDumpBoxReport, WalkControl), WalkError> +where + R: Read + Seek, +{ + let info = *handle.info(); + let box_type = info.box_type(); + let is_full = options.show_all || options.is_full(box_type); + let mut node = StructuredDumpBoxReport { + box_type: box_type.to_string(), + path: handle.path().to_string(), + offset: info.offset(), + size: info.size(), + supported: handle.is_supported_type(), + payload_status: DumpPayloadStatus::Empty, + payload_summary: None, + payload_bytes: None, + children: Vec::new(), + }; + + if !is_full && matches!(box_type, MDAT | FREE | SKIP) { + node.payload_status = DumpPayloadStatus::Omitted; + return Ok((node, WalkControl::Continue)); + } + + if handle.is_supported_type() { + if !is_full && info.payload_size()? >= 64 && should_have_no_children(box_type) { + node.payload_status = DumpPayloadStatus::Omitted; + return Ok((node, WalkControl::Continue)); + } + + match handle.read_payload() { + Ok((payload, _)) => { + let rendered = match stringify(payload.as_ref(), None) { + Ok(rendered) => rendered, + Err(error) => { + *dump_error = Some(error.into()); + return Err(io::Error::other("dump stringify failed").into()); + } + }; + if rendered.is_empty() { + node.payload_status = DumpPayloadStatus::Empty; + } else { + node.payload_status = DumpPayloadStatus::Summary; + node.payload_summary = Some(rendered); + } + return Ok((node, WalkControl::Descend)); + } + Err(WalkError::Codec(CodecError::UnsupportedVersion { .. })) => { + node.payload_status = DumpPayloadStatus::UnsupportedVersion; + } + Err(error) => return Err(error), + } + } + + if is_full { + let capacity = match usize::try_from(info.payload_size()?) { + Ok(capacity) => capacity, + Err(_) => { + *dump_error = Some(DumpError::NumericOverflow); + return Err(io::Error::other("dump payload too large").into()); + } + }; + let mut bytes = Vec::with_capacity(capacity); + handle.read_data(&mut bytes)?; + if !matches!(node.payload_status, DumpPayloadStatus::UnsupportedVersion) { + node.payload_status = DumpPayloadStatus::Bytes; + } + node.payload_bytes = Some(bytes); + } else if !matches!(node.payload_status, DumpPayloadStatus::UnsupportedVersion) { + node.payload_status = DumpPayloadStatus::Omitted; + } + + Ok((node, WalkControl::Continue)) +} + +fn build_field_structured_box_report( + handle: &mut WalkHandle<'_, R>, + options: &DumpOptions, + dump_error: &mut Option, +) -> Result<(FieldStructuredDumpBoxReport, WalkControl), WalkError> +where + R: Read + Seek, +{ + let info = *handle.info(); + let box_type = info.box_type(); + let is_full = options.show_all || options.is_full(box_type); + let mut node = FieldStructuredDumpBoxReport { + box_type: box_type.to_string(), + path: handle.path().to_string(), + offset: info.offset(), + size: info.size(), + supported: handle.is_supported_type(), + payload_status: DumpPayloadStatus::Empty, + payload_fields: Vec::new(), + payload_summary: None, + payload_bytes: None, + children: Vec::new(), + }; + + if !is_full && matches!(box_type, MDAT | FREE | SKIP) { + node.payload_status = DumpPayloadStatus::Omitted; + return Ok((node, WalkControl::Continue)); + } + + if handle.is_supported_type() { + match handle.read_payload() { + Ok((payload, _)) => { + let rendered = match stringify(payload.as_ref(), None) { + Ok(rendered) => rendered, + Err(error) => { + *dump_error = Some(error.into()); + return Err(io::Error::other("dump stringify failed").into()); + } + }; + let fields = match collect_structured_fields(payload.as_ref(), None) { + Ok(fields) => fields, + Err(error) => { + *dump_error = Some(error.into()); + return Err(io::Error::other("dump field collection failed").into()); + } + }; + node.payload_fields = fields + .into_iter() + .map(|field| StructuredDumpFieldReport { + name: field.name.to_string(), + value: field.value, + display_value: field.include_display_value.then_some(field.rendered_value), + }) + .collect(); + if rendered.is_empty() { + node.payload_status = if node.payload_fields.is_empty() { + DumpPayloadStatus::Empty + } else { + DumpPayloadStatus::Summary + }; + } else { + node.payload_status = DumpPayloadStatus::Summary; + node.payload_summary = Some(rendered); + } + return Ok((node, WalkControl::Descend)); + } + Err(WalkError::Codec(CodecError::UnsupportedVersion { .. })) => { + node.payload_status = DumpPayloadStatus::UnsupportedVersion; + } + Err(error) => return Err(error), + } + } + + if is_full { + let capacity = match usize::try_from(info.payload_size()?) { + Ok(capacity) => capacity, + Err(_) => { + *dump_error = Some(DumpError::NumericOverflow); + return Err(io::Error::other("dump payload too large").into()); + } + }; + let mut bytes = Vec::with_capacity(capacity); + handle.read_data(&mut bytes)?; + if !matches!(node.payload_status, DumpPayloadStatus::UnsupportedVersion) { + node.payload_status = DumpPayloadStatus::Bytes; + } + node.payload_bytes = Some(bytes); + } else if !matches!(node.payload_status, DumpPayloadStatus::UnsupportedVersion) { + node.payload_status = DumpPayloadStatus::Omitted; + } + + Ok((node, WalkControl::Continue)) +} + +fn finalize_completed_boxes( + depth: usize, + stack: &mut Vec, + roots: &mut Vec, +) { + while stack.len() > depth { + let node = stack.pop().expect("stack length checked before pop"); + if let Some(parent) = stack.last_mut() { + parent.children.push(node); + } else { + roots.push(node); + } + } +} + +fn finalize_completed_field_boxes( + depth: usize, + stack: &mut Vec, + roots: &mut Vec, +) { + while stack.len() > depth { + let node = stack.pop().expect("stack length checked before pop"); + if let Some(parent) = stack.last_mut() { + parent.children.push(node); + } else { + roots.push(node); + } + } } fn parse_full_box_types(value: &str, dst: &mut BTreeSet) -> Result<(), DumpError> { @@ -316,6 +990,668 @@ fn render_hex_bytes(bytes: &[u8]) -> String { .join(" ") } +fn write_json_structured_report(writer: &mut W, report: &StructuredDumpReport) -> io::Result<()> +where + W: Write, +{ + writeln!(writer, "{{")?; + writeln!(writer, " \"Boxes\": [")?; + for (index, entry) in report.boxes.iter().enumerate() { + write_json_structured_box(writer, entry, 2)?; + let trailing = if index + 1 == report.boxes.len() { + "" + } else { + "," + }; + writeln!(writer, "{trailing}")?; + } + writeln!(writer, " ]")?; + writeln!(writer, "}}") +} + +fn write_json_structured_box( + writer: &mut W, + entry: &StructuredDumpBoxReport, + indent_level: usize, +) -> io::Result<()> +where + W: Write, +{ + let indent = " ".repeat(indent_level); + writeln!(writer, "{indent}{{")?; + write_json_field( + writer, + indent_level + 1, + "BoxType", + &json_string(&entry.box_type), + true, + )?; + write_json_field( + writer, + indent_level + 1, + "Path", + &json_string(&entry.path), + true, + )?; + write_json_field( + writer, + indent_level + 1, + "Offset", + &entry.offset.to_string(), + true, + )?; + write_json_field( + writer, + indent_level + 1, + "Size", + &entry.size.to_string(), + true, + )?; + write_json_field( + writer, + indent_level + 1, + "Supported", + if entry.supported { "true" } else { "false" }, + true, + )?; + write_json_field( + writer, + indent_level + 1, + "PayloadStatus", + &json_string(payload_status_name(entry.payload_status)), + true, + )?; + if let Some(summary) = entry.payload_summary.as_ref() { + write_json_field( + writer, + indent_level + 1, + "PayloadSummary", + &json_string(summary), + true, + )?; + } + if let Some(bytes) = entry.payload_bytes.as_ref() { + write_json_u8_array_field(writer, indent_level + 1, "PayloadBytes", bytes, true)?; + } + + writeln!(writer, "{}\"Children\": [", " ".repeat(indent_level + 1))?; + for (index, child) in entry.children.iter().enumerate() { + write_json_structured_box(writer, child, indent_level + 2)?; + let trailing = if index + 1 == entry.children.len() { + "" + } else { + "," + }; + writeln!(writer, "{trailing}")?; + } + writeln!(writer, "{}]", " ".repeat(indent_level + 1))?; + write!(writer, "{indent}}}") +} + +fn write_json_u8_array_field( + writer: &mut W, + indent_level: usize, + name: &str, + values: &[u8], + trailing_comma: bool, +) -> io::Result<()> +where + W: Write, +{ + write_json_array_field( + writer, + indent_level, + name, + &values.iter().map(u8::to_string).collect::>(), + trailing_comma, + ) +} + +fn write_json_array_field( + writer: &mut W, + indent_level: usize, + name: &str, + values: &[String], + trailing_comma: bool, +) -> io::Result<()> +where + W: Write, +{ + let trailing = if trailing_comma { "," } else { "" }; + writeln!(writer, "{}\"{name}\": [", " ".repeat(indent_level))?; + for (index, value) in values.iter().enumerate() { + let trailing_value = if index + 1 == values.len() { "" } else { "," }; + writeln!( + writer, + "{}{value}{trailing_value}", + " ".repeat(indent_level + 1) + )?; + } + writeln!(writer, "{}]{trailing}", " ".repeat(indent_level)) +} + +fn write_json_field( + writer: &mut W, + indent_level: usize, + name: &str, + value: &str, + trailing_comma: bool, +) -> io::Result<()> +where + W: Write, +{ + let trailing = if trailing_comma { "," } else { "" }; + writeln!( + writer, + "{}\"{name}\": {value}{trailing}", + " ".repeat(indent_level) + ) +} + +fn write_yaml_structured_report(writer: &mut W, report: &StructuredDumpReport) -> io::Result<()> +where + W: Write, +{ + writeln!(writer, "boxes:")?; + for entry in &report.boxes { + write_yaml_structured_box(writer, entry, 0)?; + } + Ok(()) +} + +fn write_yaml_structured_box( + writer: &mut W, + entry: &StructuredDumpBoxReport, + indent_level: usize, +) -> io::Result<()> +where + W: Write, +{ + let indent = " ".repeat(indent_level); + let child_indent = " ".repeat(indent_level + 1); + + writeln!( + writer, + "{indent}- box_type: {}", + yaml_string(&entry.box_type) + )?; + writeln!(writer, "{child_indent}path: {}", yaml_string(&entry.path))?; + writeln!(writer, "{child_indent}offset: {}", entry.offset)?; + writeln!(writer, "{child_indent}size: {}", entry.size)?; + writeln!(writer, "{child_indent}supported: {}", entry.supported)?; + writeln!( + writer, + "{child_indent}payload_status: {}", + yaml_string(payload_status_name(entry.payload_status)) + )?; + if let Some(summary) = entry.payload_summary.as_ref() { + writeln!( + writer, + "{child_indent}payload_summary: {}", + yaml_string(summary) + )?; + } + if let Some(bytes) = entry.payload_bytes.as_ref() { + writeln!(writer, "{child_indent}payload_bytes:")?; + for value in bytes { + writeln!(writer, "{}- {value}", " ".repeat(indent_level + 2))?; + } + } + if entry.children.is_empty() { + writeln!(writer, "{child_indent}children: []")?; + } else { + writeln!(writer, "{child_indent}children:")?; + for child in &entry.children { + write_yaml_structured_box(writer, child, indent_level + 1)?; + } + } + Ok(()) +} + +fn write_json_field_structured_report( + writer: &mut W, + report: &FieldStructuredDumpReport, +) -> io::Result<()> +where + W: Write, +{ + writeln!(writer, "{{")?; + writeln!(writer, " \"Boxes\": [")?; + for (index, entry) in report.boxes.iter().enumerate() { + write_json_field_structured_box(writer, entry, 2)?; + let trailing = if index + 1 == report.boxes.len() { + "" + } else { + "," + }; + writeln!(writer, "{trailing}")?; + } + writeln!(writer, " ]")?; + writeln!(writer, "}}") +} + +fn write_json_field_structured_box( + writer: &mut W, + entry: &FieldStructuredDumpBoxReport, + indent_level: usize, +) -> io::Result<()> +where + W: Write, +{ + let indent = " ".repeat(indent_level); + writeln!(writer, "{indent}{{")?; + write_json_field( + writer, + indent_level + 1, + "BoxType", + &json_string(&entry.box_type), + true, + )?; + write_json_field( + writer, + indent_level + 1, + "Path", + &json_string(&entry.path), + true, + )?; + write_json_field( + writer, + indent_level + 1, + "Offset", + &entry.offset.to_string(), + true, + )?; + write_json_field( + writer, + indent_level + 1, + "Size", + &entry.size.to_string(), + true, + )?; + write_json_field( + writer, + indent_level + 1, + "Supported", + if entry.supported { "true" } else { "false" }, + true, + )?; + write_json_field( + writer, + indent_level + 1, + "PayloadStatus", + &json_string(payload_status_name(entry.payload_status)), + true, + )?; + write_json_payload_fields(writer, indent_level + 1, &entry.payload_fields, true)?; + if let Some(summary) = entry.payload_summary.as_ref() { + write_json_field( + writer, + indent_level + 1, + "PayloadSummary", + &json_string(summary), + true, + )?; + } + if let Some(bytes) = entry.payload_bytes.as_ref() { + write_json_u8_array_field(writer, indent_level + 1, "PayloadBytes", bytes, true)?; + } + + writeln!(writer, "{}\"Children\": [", " ".repeat(indent_level + 1))?; + for (index, child) in entry.children.iter().enumerate() { + write_json_field_structured_box(writer, child, indent_level + 2)?; + let trailing = if index + 1 == entry.children.len() { + "" + } else { + "," + }; + writeln!(writer, "{trailing}")?; + } + writeln!(writer, "{}]", " ".repeat(indent_level + 1))?; + write!(writer, "{indent}}}") +} + +fn write_json_payload_fields( + writer: &mut W, + indent_level: usize, + fields: &[StructuredDumpFieldReport], + trailing_comma: bool, +) -> io::Result<()> +where + W: Write, +{ + let trailing = if trailing_comma { "," } else { "" }; + writeln!(writer, "{}\"PayloadFields\": [", " ".repeat(indent_level))?; + for (index, field) in fields.iter().enumerate() { + write_json_payload_field(writer, field, indent_level + 1)?; + let trailing_field = if index + 1 == fields.len() { "" } else { "," }; + writeln!(writer, "{trailing_field}")?; + } + writeln!(writer, "{}]{trailing}", " ".repeat(indent_level)) +} + +fn write_json_payload_field( + writer: &mut W, + field: &StructuredDumpFieldReport, + indent_level: usize, +) -> io::Result<()> +where + W: Write, +{ + let indent = " ".repeat(indent_level); + writeln!(writer, "{indent}{{")?; + write_json_field( + writer, + indent_level + 1, + "Name", + &json_string(&field.name), + true, + )?; + write_json_field( + writer, + indent_level + 1, + "ValueKind", + &json_string(structured_field_value_kind_name(&field.value)), + true, + )?; + write_json_dump_field_value( + writer, + indent_level + 1, + "Value", + &field.value, + field.display_value.is_some(), + )?; + if let Some(display_value) = field.display_value.as_ref() { + write_json_field( + writer, + indent_level + 1, + "DisplayValue", + &json_string(display_value), + false, + )?; + } + write!(writer, "{indent}}}") +} + +fn write_json_dump_field_value( + writer: &mut W, + indent_level: usize, + name: &str, + value: &FieldValue, + trailing_comma: bool, +) -> io::Result<()> +where + W: Write, +{ + match value { + FieldValue::Unsigned(value) => write_json_field( + writer, + indent_level, + name, + &value.to_string(), + trailing_comma, + ), + FieldValue::Signed(value) => write_json_field( + writer, + indent_level, + name, + &value.to_string(), + trailing_comma, + ), + FieldValue::Boolean(value) => write_json_field( + writer, + indent_level, + name, + if *value { "true" } else { "false" }, + trailing_comma, + ), + FieldValue::String(value) => write_json_field( + writer, + indent_level, + name, + &json_string(value), + trailing_comma, + ), + FieldValue::Bytes(values) => { + write_json_u8_array_field(writer, indent_level, name, values, trailing_comma) + } + FieldValue::UnsignedArray(values) => write_json_array_field( + writer, + indent_level, + name, + &values.iter().map(u64::to_string).collect::>(), + trailing_comma, + ), + FieldValue::SignedArray(values) => write_json_array_field( + writer, + indent_level, + name, + &values.iter().map(i64::to_string).collect::>(), + trailing_comma, + ), + FieldValue::BooleanArray(values) => write_json_array_field( + writer, + indent_level, + name, + &values.iter().map(bool::to_string).collect::>(), + trailing_comma, + ), + } +} + +fn write_yaml_field_structured_report( + writer: &mut W, + report: &FieldStructuredDumpReport, +) -> io::Result<()> +where + W: Write, +{ + writeln!(writer, "boxes:")?; + for entry in &report.boxes { + write_yaml_field_structured_box(writer, entry, 0)?; + } + Ok(()) +} + +fn write_yaml_field_structured_box( + writer: &mut W, + entry: &FieldStructuredDumpBoxReport, + indent_level: usize, +) -> io::Result<()> +where + W: Write, +{ + let indent = " ".repeat(indent_level); + let child_indent = " ".repeat(indent_level + 1); + + writeln!( + writer, + "{indent}- box_type: {}", + yaml_string(&entry.box_type) + )?; + writeln!(writer, "{child_indent}path: {}", yaml_string(&entry.path))?; + writeln!(writer, "{child_indent}offset: {}", entry.offset)?; + writeln!(writer, "{child_indent}size: {}", entry.size)?; + writeln!(writer, "{child_indent}supported: {}", entry.supported)?; + writeln!( + writer, + "{child_indent}payload_status: {}", + yaml_string(payload_status_name(entry.payload_status)) + )?; + if entry.payload_fields.is_empty() { + writeln!(writer, "{child_indent}payload_fields: []")?; + } else { + writeln!(writer, "{child_indent}payload_fields:")?; + for field in &entry.payload_fields { + write_yaml_payload_field(writer, field, indent_level + 1)?; + } + } + if let Some(summary) = entry.payload_summary.as_ref() { + writeln!( + writer, + "{child_indent}payload_summary: {}", + yaml_string(summary) + )?; + } + if let Some(bytes) = entry.payload_bytes.as_ref() { + writeln!(writer, "{child_indent}payload_bytes:")?; + for value in bytes { + writeln!(writer, "{}- {value}", " ".repeat(indent_level + 2))?; + } + } + if entry.children.is_empty() { + writeln!(writer, "{child_indent}children: []")?; + } else { + writeln!(writer, "{child_indent}children:")?; + for child in &entry.children { + write_yaml_field_structured_box(writer, child, indent_level + 1)?; + } + } + Ok(()) +} + +fn write_yaml_payload_field( + writer: &mut W, + field: &StructuredDumpFieldReport, + indent_level: usize, +) -> io::Result<()> +where + W: Write, +{ + let indent = " ".repeat(indent_level + 1); + let child_indent = " ".repeat(indent_level + 2); + writeln!(writer, "{indent}- name: {}", yaml_string(&field.name))?; + writeln!( + writer, + "{child_indent}value_kind: {}", + yaml_string(structured_field_value_kind_name(&field.value)) + )?; + write_yaml_dump_field_value(writer, indent_level + 2, "value", &field.value)?; + if let Some(display_value) = field.display_value.as_ref() { + writeln!( + writer, + "{child_indent}display_value: {}", + yaml_string(display_value) + )?; + } + Ok(()) +} + +fn write_yaml_dump_field_value( + writer: &mut W, + indent_level: usize, + name: &str, + value: &FieldValue, +) -> io::Result<()> +where + W: Write, +{ + let indent = " ".repeat(indent_level); + let child_indent = " ".repeat(indent_level + 1); + match value { + FieldValue::Unsigned(value) => writeln!(writer, "{indent}{name}: {value}"), + FieldValue::Signed(value) => writeln!(writer, "{indent}{name}: {value}"), + FieldValue::Boolean(value) => writeln!(writer, "{indent}{name}: {value}"), + FieldValue::String(value) => writeln!(writer, "{indent}{name}: {}", yaml_string(value)), + FieldValue::Bytes(values) => { + if values.is_empty() { + writeln!(writer, "{indent}{name}: []") + } else { + writeln!(writer, "{indent}{name}:")?; + for value in values { + writeln!(writer, "{child_indent}- {value}")?; + } + Ok(()) + } + } + FieldValue::UnsignedArray(values) => { + if values.is_empty() { + writeln!(writer, "{indent}{name}: []") + } else { + writeln!(writer, "{indent}{name}:")?; + for value in values { + writeln!(writer, "{child_indent}- {value}")?; + } + Ok(()) + } + } + FieldValue::SignedArray(values) => { + if values.is_empty() { + writeln!(writer, "{indent}{name}: []") + } else { + writeln!(writer, "{indent}{name}:")?; + for value in values { + writeln!(writer, "{child_indent}- {value}")?; + } + Ok(()) + } + } + FieldValue::BooleanArray(values) => { + if values.is_empty() { + writeln!(writer, "{indent}{name}: []") + } else { + writeln!(writer, "{indent}{name}:")?; + for value in values { + writeln!(writer, "{child_indent}- {value}")?; + } + Ok(()) + } + } + } +} + +fn payload_status_name(status: DumpPayloadStatus) -> &'static str { + match status { + DumpPayloadStatus::Summary => "summary", + DumpPayloadStatus::Empty => "empty", + DumpPayloadStatus::Bytes => "bytes", + DumpPayloadStatus::Omitted => "omitted", + DumpPayloadStatus::UnsupportedVersion => "unsupported_version", + } +} + +fn structured_field_value_kind_name(value: &FieldValue) -> &'static str { + match value { + FieldValue::Unsigned(_) => "unsigned", + FieldValue::Signed(_) => "signed", + FieldValue::Boolean(_) => "boolean", + FieldValue::Bytes(_) => "bytes", + FieldValue::String(_) => "string", + FieldValue::UnsignedArray(_) => "unsigned_array", + FieldValue::SignedArray(_) => "signed_array", + FieldValue::BooleanArray(_) => "boolean_array", + } +} + +fn json_string(value: &str) -> String { + let mut escaped = String::from("\""); + for ch in value.chars() { + match ch { + '"' => escaped.push_str("\\\""), + '\\' => escaped.push_str("\\\\"), + '\n' => escaped.push_str("\\n"), + '\r' => escaped.push_str("\\r"), + '\t' => escaped.push_str("\\t"), + ch if ch.is_control() => escaped.push_str(&format!("\\u{:04x}", ch as u32)), + ch => escaped.push(ch), + } + } + escaped.push('"'); + escaped +} + +fn yaml_string(value: &str) -> String { + if !value.is_empty() + && value.trim() == value + && value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_' | '/' | ' ')) + { + value.to_string() + } else { + format!("'{}'", value.replace('\'', "''")) + } +} + /// Errors raised while parsing dump arguments or rendering dump output. #[derive(Debug)] pub enum DumpError { diff --git a/src/cli/edit.rs b/src/cli/edit.rs index d1d3cbe..a522fa0 100644 --- a/src/cli/edit.rs +++ b/src/cli/edit.rs @@ -4,7 +4,7 @@ use std::collections::BTreeSet; use std::error::Error; use std::fmt; use std::fs::File; -use std::io::{self, Read, Seek, SeekFrom, Write}; +use std::io::{self, Cursor, Read, Seek, SeekFrom, Write}; use crate::FourCc; use crate::boxes::iso14496_12::{Ftyp, Tfdt}; @@ -13,7 +13,10 @@ use crate::boxes::{BoxLookupContext, BoxRegistry, default_registry}; use crate::codec::{ CodecError, DynCodecBox, ImmutableBox, marshal_dyn, unmarshal, unmarshal_any_with_context, }; +use crate::extract::{ExtractError, extract_boxes_as}; use crate::header::{BoxInfo, HeaderError, SMALL_HEADER_SIZE}; +use crate::rewrite::{RewriteError, rewrite_boxes_as}; +use crate::walk::{BoxPath, WalkError}; use crate::writer::{Writer, WriterError}; const FTYP: FourCc = FourCc::from_bytes(*b"ftyp"); @@ -37,7 +40,7 @@ where { match run_inner(args) { Ok(()) => 0, - Err(EditError::UsageRequested) => { + Err(EditCliError::UsageRequested) => { let _ = write_usage(stderr); 1 } @@ -63,6 +66,10 @@ where writer, " -base_media_decode_time Replace tfdt base media decode times" )?; + writeln!( + writer, + " -path Limit supported typed rewrites to parsed slash-delimited box paths" + )?; writeln!( writer, " -drop Drop boxes by fourcc" @@ -95,34 +102,61 @@ where Ok(()) } -fn run_inner(args: &[String]) -> Result<(), EditError> { - let (options, input_path, output_path) = parse_args(args)?; - let mut input = File::open(input_path)?; - let output = File::create(output_path)?; - edit_reader(&mut input, output, &options) +fn run_inner(args: &[String]) -> Result<(), EditCliError> { + let parsed = parse_args(args)?; + let mut input = File::open(parsed.input_path)?; + let output = File::create(parsed.output_path)?; + if parsed.paths.is_empty() { + return edit_reader(&mut input, output, &parsed.options).map_err(EditCliError::Edit); + } + + edit_reader_scoped_paths(&mut input, output, &parsed.options, &parsed.paths) +} + +#[derive(Debug)] +struct ParsedEditArgs<'a> { + options: EditOptions, + paths: Vec, + input_path: &'a str, + output_path: &'a str, } -fn parse_args(args: &[String]) -> Result<(EditOptions, &str, &str), EditError> { +fn parse_args(args: &[String]) -> Result, EditCliError> { let mut options = EditOptions::default(); + let mut paths = Vec::new(); let mut positional = Vec::new(); let mut index = 0usize; while index < args.len() { match args[index].as_str() { "-base_media_decode_time" | "--base_media_decode_time" => { let Some(value) = args.get(index + 1) else { - return Err(EditError::InvalidArgument( + return Err(EditCliError::InvalidArgument( "missing value for -base_media_decode_time".to_string(), )); }; let value = value.parse::().map_err(|_| { - EditError::InvalidArgument(format!("invalid base media decode time: {value}")) + EditCliError::InvalidArgument(format!( + "invalid base media decode time: {value}" + )) })?; options.base_media_decode_time = Some(value); index += 2; } + "-path" | "--path" => { + let Some(value) = args.get(index + 1) else { + return Err(EditCliError::InvalidArgument( + "missing value for -path".to_string(), + )); + }; + let path = BoxPath::parse(value).map_err(|error| { + EditCliError::InvalidArgument(format!("invalid box path: {error}")) + })?; + paths.push(path); + index += 2; + } "-drop" | "--drop" => { let Some(value) = args.get(index + 1) else { - return Err(EditError::InvalidArgument( + return Err(EditCliError::InvalidArgument( "missing value for -drop".to_string(), )); }; @@ -130,16 +164,16 @@ fn parse_args(args: &[String]) -> Result<(EditOptions, &str, &str), EditError> { options .drop_boxes .insert(FourCc::try_from(name).map_err(|_| { - EditError::InvalidArgument(format!( + EditCliError::InvalidArgument(format!( "box types passed to -drop must be 4 bytes: {name}" )) })?); } index += 2; } - "-h" | "--help" => return Err(EditError::UsageRequested), + "-h" | "--help" => return Err(EditCliError::UsageRequested), value if value.starts_with('-') => { - return Err(EditError::InvalidArgument(format!( + return Err(EditCliError::InvalidArgument(format!( "unknown edit option: {value}" ))); } @@ -151,10 +185,177 @@ fn parse_args(args: &[String]) -> Result<(EditOptions, &str, &str), EditError> { } if positional.len() != 2 { - return Err(EditError::UsageRequested); + return Err(EditCliError::UsageRequested); + } + + if !paths.is_empty() && options.base_media_decode_time.is_none() { + return Err(EditCliError::InvalidArgument( + "edit -path currently supports only -base_media_decode_time rewrites".to_string(), + )); + } + + Ok(ParsedEditArgs { + options, + paths, + input_path: positional[0], + output_path: positional[1], + }) +} + +fn edit_reader_scoped_paths( + reader: &mut R, + writer: W, + options: &EditOptions, + paths: &[BoxPath], +) -> Result<(), EditCliError> +where + R: Read + Seek, + W: Write + Seek, +{ + let Some(base_media_decode_time) = options.base_media_decode_time else { + return Err(EditCliError::InvalidArgument( + "edit -path currently supports only -base_media_decode_time rewrites".to_string(), + )); + }; + + let matched_tfdt = + extract_boxes_as::<_, Tfdt>(reader, None, paths).map_err(map_scoped_extract_error)?; + if base_media_decode_time > u64::from(u32::MAX) + && matched_tfdt.iter().any(|tfdt| tfdt.version() == 0) + { + return Err(EditCliError::Edit(EditError::NumericOverflow { + field_name: "base media decode time", + })); + } + + reader.seek(SeekFrom::Start(0))?; + let mut scoped_output = Cursor::new(Vec::new()); + rewrite_boxes_as::<_, _, Tfdt, _>(reader, &mut scoped_output, paths, |tfdt| { + if tfdt.version() == 0 { + tfdt.base_media_decode_time_v0 = base_media_decode_time as u32; + } else { + tfdt.base_media_decode_time_v1 = base_media_decode_time; + } + }) + .map_err(map_scoped_rewrite_error)?; + + let scoped_bytes = scoped_output.into_inner(); + let follow_up_options = EditOptions { + base_media_decode_time: None, + drop_boxes: options.drop_boxes.clone(), + }; + if follow_up_options.is_noop() { + let mut writer = writer; + writer.write_all(&scoped_bytes)?; + return Ok(()); + } + + let mut scoped_reader = Cursor::new(scoped_bytes); + edit_reader(&mut scoped_reader, writer, &follow_up_options).map_err(EditCliError::Edit) +} + +fn map_scoped_extract_error(error: ExtractError) -> EditCliError { + match error { + ExtractError::Io(error) => EditCliError::Edit(EditError::Io(error)), + ExtractError::Header(error) => EditCliError::Edit(EditError::Header(error)), + ExtractError::Codec(error) => EditCliError::Edit(EditError::Codec(error)), + ExtractError::Walk(error) => EditCliError::Edit(map_walk_error(error)), + ExtractError::EmptyPath => { + EditCliError::InvalidArgument("box path must not be empty".to_string()) + } + ExtractError::PayloadDecode { source, .. } => EditCliError::Edit(EditError::Codec(source)), + ExtractError::UnexpectedPayloadType { + path, + box_type, + offset, + .. + } => EditCliError::InvalidArgument(format!( + "path-based -base_media_decode_time rewrites require tfdt boxes: matched {path} (type={box_type}, offset={offset})" + )), + } +} + +fn map_scoped_rewrite_error(error: RewriteError) -> EditCliError { + match error { + RewriteError::Io(error) => EditCliError::Edit(EditError::Io(error)), + RewriteError::Header(error) => EditCliError::Edit(EditError::Header(error)), + RewriteError::Codec(error) => EditCliError::Edit(EditError::Codec(error)), + RewriteError::Writer(error) => EditCliError::Edit(EditError::Writer(error)), + RewriteError::EmptyPath => { + EditCliError::InvalidArgument("box path must not be empty".to_string()) + } + RewriteError::PayloadDecode { source, .. } | RewriteError::PayloadEncode { source, .. } => { + EditCliError::Edit(EditError::Codec(source)) + } + RewriteError::UnexpectedPayloadType { + path, + box_type, + offset, + .. + } => EditCliError::InvalidArgument(format!( + "path-based -base_media_decode_time rewrites require tfdt boxes: matched {path} (type={box_type}, offset={offset})" + )), + RewriteError::TooLargeBoxSize { + box_type, + size, + available_size, + } => EditCliError::Edit(EditError::TooLargeBoxSize { + box_type, + size, + available_size, + }), + RewriteError::UnexpectedEof => EditCliError::Edit(EditError::UnexpectedEof), + } +} + +fn map_walk_error(error: WalkError) -> EditError { + match error { + WalkError::Io(error) => EditError::Io(error), + WalkError::Header(error) => EditError::Header(error), + WalkError::Codec(error) => EditError::Codec(error), + WalkError::TooLargeBoxSize { + box_type, + size, + available_size, + } => EditError::TooLargeBoxSize { + box_type, + size, + available_size, + }, + WalkError::UnexpectedEof => EditError::UnexpectedEof, + } +} + +#[derive(Debug)] +enum EditCliError { + Edit(EditError), + InvalidArgument(String), + UsageRequested, +} + +impl fmt::Display for EditCliError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Edit(error) => error.fmt(f), + Self::InvalidArgument(message) => f.write_str(message), + Self::UsageRequested => f.write_str("usage requested"), + } } +} - Ok((options, positional[0], positional[1])) +impl Error for EditCliError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Edit(error) => Some(error), + Self::InvalidArgument(..) | Self::UsageRequested => None, + } + } +} + +impl From for EditCliError { + fn from(value: io::Error) -> Self { + Self::Edit(EditError::Io(value)) + } } #[derive(Clone, Copy)] diff --git a/src/cli/extract.rs b/src/cli/extract.rs index 9880cd1..2dc6d70 100644 --- a/src/cli/extract.rs +++ b/src/cli/extract.rs @@ -7,8 +7,9 @@ use std::io::{self, Read, Seek, Write}; use crate::FourCc; use crate::codec::CodecError; +use crate::extract::{ExtractError, extract_boxes_bytes}; use crate::header::HeaderError; -use crate::walk::{WalkControl, WalkError, walk_structure}; +use crate::walk::{BoxPath, WalkControl, WalkError, walk_structure}; use super::util::should_have_no_children; @@ -37,7 +38,16 @@ where W: Write, { writeln!(writer, "USAGE: mp4forge extract BOX_TYPE INPUT.mp4")?; - Ok(()) + writeln!( + writer, + " mp4forge extract -path [-path ...] INPUT.mp4" + )?; + writeln!(writer)?; + writeln!(writer, "OPTIONS:")?; + writeln!( + writer, + " -path Extract raw boxes that match the parsed slash-delimited box path" + ) } /// Extracts every box of type `box_type` from `reader`, preserving raw bytes. @@ -77,18 +87,97 @@ where Ok(()) } +/// Extracts every box that matches any path in `paths` from `reader`, preserving raw bytes. +/// +/// Paths use the existing [`BoxPath`] parser, including slash-delimited segments and `*` +/// wildcards. Each match is copied with its original box header and payload bytes intact. +pub fn extract_reader_paths( + reader: &mut R, + paths: &[BoxPath], + writer: &mut W, +) -> Result<(), ExtractCliError> +where + R: Read + Seek, + W: Write, +{ + for bytes in extract_boxes_bytes(reader, None, paths).map_err(map_extract_error)? { + writer.write_all(&bytes)?; + } + Ok(()) +} + fn run_inner(args: &[String], stdout: &mut W) -> Result<(), ExtractCliError> where W: Write, { - if args.len() != 2 { - return Err(ExtractCliError::UsageRequested); + let mut paths = Vec::new(); + let mut positional = Vec::new(); + let mut index = 0usize; + while index < args.len() { + match args[index].as_str() { + "-path" | "--path" => { + let Some(value) = args.get(index + 1) else { + return Err(ExtractCliError::InvalidArgument( + "missing value for -path".to_string(), + )); + }; + let path = BoxPath::parse(value).map_err(|error| { + ExtractCliError::InvalidArgument(format!("invalid box path: {error}")) + })?; + paths.push(path); + index += 2; + } + "-h" | "--help" => return Err(ExtractCliError::UsageRequested), + value if value.starts_with('-') => { + return Err(ExtractCliError::InvalidArgument(format!( + "unknown extract option: {value}" + ))); + } + value => { + positional.push(value); + index += 1; + } + } + } + + if paths.is_empty() { + if positional.len() != 2 { + return Err(ExtractCliError::UsageRequested); + } + + let box_type = FourCc::try_from(positional[0]).map_err(|_| { + ExtractCliError::InvalidArgument(format!("invalid box type: {}", positional[0])) + })?; + let mut file = File::open(positional[1])?; + return extract_reader(&mut file, box_type, stdout); + } + + if positional.len() != 1 { + return Err(ExtractCliError::InvalidArgument( + "extract with -path accepts exactly one input path".to_string(), + )); } - let box_type = FourCc::try_from(args[0].as_str()) - .map_err(|_| ExtractCliError::InvalidArgument(format!("invalid box type: {}", args[0])))?; - let mut file = File::open(&args[1])?; - extract_reader(&mut file, box_type, stdout) + let mut file = File::open(positional[0])?; + extract_reader_paths(&mut file, &paths, stdout) +} + +fn map_extract_error(error: ExtractError) -> ExtractCliError { + match error { + ExtractError::Io(error) => ExtractCliError::Io(error), + ExtractError::Header(error) => ExtractCliError::Header(error), + ExtractError::Codec(error) => ExtractCliError::Walk(WalkError::Codec(error)), + ExtractError::Walk(error) => ExtractCliError::Walk(error), + ExtractError::EmptyPath => { + ExtractCliError::InvalidArgument("box path must not be empty".to_string()) + } + ExtractError::PayloadDecode { source, .. } => { + ExtractCliError::Walk(WalkError::Codec(source)) + } + unexpected @ ExtractError::UnexpectedPayloadType { .. } => { + ExtractCliError::InvalidArgument(unexpected.to_string()) + } + } } /// Errors raised while parsing extract arguments or copying raw boxes. diff --git a/src/cli/mod.rs b/src/cli/mod.rs index c996db4..85781f1 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -26,7 +26,7 @@ where let _ = write_usage(stderr); 0 } - "divide" => divide::run(&args[1..], stderr), + "divide" => divide::run_with_output(&args[1..], stdout, stderr), "dump" => dump::run(&args[1..], stdout, stderr), "edit" => edit::run(&args[1..], stderr), "extract" => extract::run(&args[1..], stdout, stderr), @@ -53,7 +53,7 @@ where )?; writeln!(writer, " dump display the MP4 box tree")?; writeln!(writer, " edit rewrite selected boxes")?; - writeln!(writer, " extract extract raw boxes by type")?; + writeln!(writer, " extract extract raw boxes by type or path")?; writeln!(writer, " psshdump summarize pssh boxes")?; writeln!(writer, " probe summarize an MP4 file")?; Ok(()) diff --git a/src/cli/probe.rs b/src/cli/probe.rs index 5103a05..98467a5 100644 --- a/src/cli/probe.rs +++ b/src/cli/probe.rs @@ -6,8 +6,10 @@ use std::fs::File; use std::io::{self, Read, Seek, Write}; use crate::probe::{ - ProbeError, TrackCodec, average_sample_bitrate, average_segment_bitrate, find_idr_frames, - max_sample_bitrate, max_segment_bitrate, probe, + DetailedTrackInfo, ProbeError, ProbeOptions, TrackCodec, TrackCodecDetails, TrackCodecFamily, + TrackMediaCharacteristics, average_sample_bitrate, average_segment_bitrate, find_idr_frames, + max_sample_bitrate, max_segment_bitrate, probe_codec_detailed_with_options, + probe_detailed_with_options, probe_media_characteristics_with_options, probe_with_options, }; /// Structured output format supported by the probe command. @@ -31,7 +33,70 @@ impl ProbeFormat { } } +/// Additive controls for expensive probe-report rendering work. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ProbeReportOptions { + /// Library-side probe expansion controls. + pub probe: ProbeOptions, + /// Whether to aggregate bitrate summaries for each track. + pub include_bitrate: bool, + /// Whether to scan AVC samples for IDR frame counts. + pub include_idr_frame_count: bool, +} + +impl ProbeReportOptions { + /// Returns the existing eager probe-report behavior. + pub const fn full() -> Self { + Self { + probe: ProbeOptions::full(), + include_bitrate: true, + include_idr_frame_count: true, + } + } + + /// Returns a lighter-weight probe-report behavior for large-file inspection. + pub const fn lightweight() -> Self { + Self { + probe: ProbeOptions::lightweight(), + include_bitrate: false, + include_idr_frame_count: false, + } + } +} + +impl Default for ProbeReportOptions { + fn default() -> Self { + Self::full() + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ProbeDetailLevel { + Full, + Light, +} + +impl ProbeDetailLevel { + fn parse(value: &str) -> Result { + match value { + "full" => Ok(Self::Full), + "light" => Ok(Self::Light), + other => Err(ProbeCliError::InvalidArgument(format!( + "unsupported probe detail level: {other}" + ))), + } + } + + const fn report_options(self) -> ProbeReportOptions { + match self { + Self::Full => ProbeReportOptions::full(), + Self::Light => ProbeReportOptions::lightweight(), + } + } +} + /// Top-level probe report used by the CLI layer. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug, Default, PartialEq)] pub struct ProbeReport { /// Root `ftyp` major brand. @@ -53,6 +118,7 @@ pub struct ProbeReport { } /// One track entry in the CLI probe report. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug, Default, PartialEq)] pub struct ProbeTrackReport { /// Track identifier from `tkhd`. @@ -83,6 +149,228 @@ pub struct ProbeTrackReport { pub max_bitrate: Option, } +/// Top-level detailed probe report used by the CLI command surface. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, Default, PartialEq)] +pub struct DetailedProbeReport { + /// Root `ftyp` major brand. + pub major_brand: String, + /// Root `ftyp` minor version. + pub minor_version: u32, + /// Root `ftyp` compatible brands. + pub compatible_brands: Vec, + /// Whether the file places `moov` before the first `mdat`. + pub fast_start: bool, + /// Movie timescale from `mvhd`. + pub timescale: u32, + /// Movie duration from `mvhd`. + pub duration: u64, + /// Movie duration expressed in seconds. + pub duration_seconds: f32, + /// Per-track detailed probe summaries. + pub tracks: Vec, +} + +/// One track entry in the detailed CLI probe report. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, Default, PartialEq)] +pub struct DetailedProbeTrackReport { + /// Track identifier from `tkhd`. + pub track_id: u32, + /// Track timescale from `mdhd`. + pub timescale: u32, + /// Track duration from `mdhd`. + pub duration: u64, + /// Track duration expressed in seconds. + pub duration_seconds: f32, + /// Human-readable codec identifier. + pub codec: String, + /// Normalized codec-family label. + pub codec_family: String, + /// Whether the track uses an encrypted sample entry. + pub encrypted: bool, + /// Handler type when present. + pub handler_type: Option, + /// ISO-639-2 language code when present. + pub language: Option, + /// Sample-entry box type when present. + pub sample_entry_type: Option, + /// Protected original-format sample-entry type when present. + pub original_format: Option, + /// Protection-scheme type when present. + pub protection_scheme_type: Option, + /// Protection-scheme version when present. + pub protection_scheme_version: Option, + /// Display width for visual tracks. + pub width: Option, + /// Display height for visual tracks. + pub height: Option, + /// Channel count for audio tracks. + pub channel_count: Option, + /// Integer sample rate for audio tracks. + pub sample_rate: Option, + /// Expanded sample count when present. + pub sample_num: Option, + /// Expanded chunk count when present. + pub chunk_num: Option, + /// Count of samples carrying IDR NAL units. + pub idr_frame_num: Option, + /// Average bitrate in bits per second. + pub bitrate: Option, + /// Maximum bitrate in bits per second. + pub max_bitrate: Option, +} + +/// Top-level codec-detailed probe report used by the CLI command surface. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, Default, PartialEq)] +pub struct CodecDetailedProbeReport { + /// Root `ftyp` major brand. + pub major_brand: String, + /// Root `ftyp` minor version. + pub minor_version: u32, + /// Root `ftyp` compatible brands. + pub compatible_brands: Vec, + /// Whether the file places `moov` before the first `mdat`. + pub fast_start: bool, + /// Movie timescale from `mvhd`. + pub timescale: u32, + /// Movie duration from `mvhd`. + pub duration: u64, + /// Movie duration expressed in seconds. + pub duration_seconds: f32, + /// Per-track codec-detailed probe summaries. + pub tracks: Vec, +} + +/// One track entry in the codec-detailed CLI probe report. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, Default, PartialEq)] +pub struct CodecDetailedProbeTrackReport { + /// Track identifier from `tkhd`. + pub track_id: u32, + /// Track timescale from `mdhd`. + pub timescale: u32, + /// Track duration from `mdhd`. + pub duration: u64, + /// Track duration expressed in seconds. + pub duration_seconds: f32, + /// Human-readable codec identifier. + pub codec: String, + /// Normalized codec-family label. + pub codec_family: String, + /// Parsed codec-specific configuration details. + pub codec_details: TrackCodecDetails, + /// Whether the track uses an encrypted sample entry. + pub encrypted: bool, + /// Handler type when present. + pub handler_type: Option, + /// ISO-639-2 language code when present. + pub language: Option, + /// Sample-entry box type when present. + pub sample_entry_type: Option, + /// Protected original-format sample-entry type when present. + pub original_format: Option, + /// Protection-scheme type when present. + pub protection_scheme_type: Option, + /// Protection-scheme version when present. + pub protection_scheme_version: Option, + /// Display width for visual tracks. + pub width: Option, + /// Display height for visual tracks. + pub height: Option, + /// Channel count for audio tracks. + pub channel_count: Option, + /// Integer sample rate for audio tracks. + pub sample_rate: Option, + /// Expanded sample count when present. + pub sample_num: Option, + /// Expanded chunk count when present. + pub chunk_num: Option, + /// Count of samples carrying IDR NAL units. + pub idr_frame_num: Option, + /// Average bitrate in bits per second. + pub bitrate: Option, + /// Maximum bitrate in bits per second. + pub max_bitrate: Option, +} + +/// Top-level media-characteristics probe report used by the CLI command surface. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, Default, PartialEq)] +pub struct MediaCharacteristicsProbeReport { + /// Root `ftyp` major brand. + pub major_brand: String, + /// Root `ftyp` minor version. + pub minor_version: u32, + /// Root `ftyp` compatible brands. + pub compatible_brands: Vec, + /// Whether the file places `moov` before the first `mdat`. + pub fast_start: bool, + /// Movie timescale from `mvhd`. + pub timescale: u32, + /// Movie duration from `mvhd`. + pub duration: u64, + /// Movie duration expressed in seconds. + pub duration_seconds: f32, + /// Per-track media-characteristics probe summaries. + pub tracks: Vec, +} + +/// One track entry in the media-characteristics CLI probe report. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, Default, PartialEq)] +pub struct MediaCharacteristicsProbeTrackReport { + /// Track identifier from `tkhd`. + pub track_id: u32, + /// Track timescale from `mdhd`. + pub timescale: u32, + /// Track duration from `mdhd`. + pub duration: u64, + /// Track duration expressed in seconds. + pub duration_seconds: f32, + /// Human-readable codec identifier. + pub codec: String, + /// Normalized codec-family label. + pub codec_family: String, + /// Parsed codec-specific configuration details. + pub codec_details: TrackCodecDetails, + /// Sample-entry media characteristics already parsed by the crate. + pub media_characteristics: TrackMediaCharacteristics, + /// Whether the track uses an encrypted sample entry. + pub encrypted: bool, + /// Handler type when present. + pub handler_type: Option, + /// ISO-639-2 language code when present. + pub language: Option, + /// Sample-entry box type when present. + pub sample_entry_type: Option, + /// Protected original-format sample-entry type when present. + pub original_format: Option, + /// Protection-scheme type when present. + pub protection_scheme_type: Option, + /// Protection-scheme version when present. + pub protection_scheme_version: Option, + /// Display width for visual tracks. + pub width: Option, + /// Display height for visual tracks. + pub height: Option, + /// Channel count for audio tracks. + pub channel_count: Option, + /// Integer sample rate for audio tracks. + pub sample_rate: Option, + /// Expanded sample count when present. + pub sample_num: Option, + /// Expanded chunk count when present. + pub chunk_num: Option, + /// Count of samples carrying IDR NAL units. + pub idr_frame_num: Option, + /// Average bitrate in bits per second. + pub bitrate: Option, + /// Maximum bitrate in bits per second. + pub max_bitrate: Option, +} + /// Runs the probe subcommand with `args`, writing output to `stdout`. pub fn run(args: &[String], stdout: &mut W, stderr: &mut E) -> i32 where @@ -114,6 +402,10 @@ where writer, " -format Output format (default: json)" )?; + writeln!( + writer, + " -detail Probe detail level (default: full)" + )?; Ok(()) } @@ -122,7 +414,18 @@ pub fn build_report(reader: &mut R) -> Result where R: Read + Seek, { - let summary = probe(reader)?; + build_report_with_options(reader, ProbeReportOptions::default()) +} + +/// Builds a CLI probe report from an MP4 reader with additive report controls. +pub fn build_report_with_options( + reader: &mut R, + options: ProbeReportOptions, +) -> Result +where + R: Read + Seek, +{ + let summary = probe_with_options(reader, options.probe)?; let mut report = ProbeReport { major_brand: summary.major_brand.to_string(), @@ -140,13 +443,13 @@ where }; for track in &summary.tracks { - let mut bitrate = average_sample_bitrate(&track.samples, track.timescale); - let mut max_bitrate = - max_sample_bitrate(&track.samples, track.timescale, track.timescale.into()); - if bitrate == 0 || max_bitrate == 0 { - bitrate = average_segment_bitrate(&summary.segments, track.track_id, track.timescale); - max_bitrate = max_segment_bitrate(&summary.segments, track.track_id, track.timescale); - } + let (bitrate, max_bitrate) = summarize_bitrate( + &track.samples, + track.timescale, + track.track_id, + &summary.segments, + options.include_bitrate, + ); let mut row = ProbeTrackReport { track_id: track.track_id, @@ -167,7 +470,9 @@ where if let Some(avc) = track.avc.as_ref() { row.width = Some(avc.width); row.height = Some(avc.height); - row.idr_frame_num = idr_frame_count(reader, track)?; + if options.include_idr_frame_count && !track.samples.is_empty() { + row.idr_frame_num = idr_frame_count(reader, track)?; + } } report.tracks.push(row); @@ -176,72 +481,406 @@ where Ok(report) } -/// Writes `report` in the selected `format`. -pub fn write_report( - writer: &mut W, - report: &ProbeReport, - format: ProbeFormat, -) -> Result<(), ProbeCliError> +/// Builds a detailed CLI probe report from an MP4 reader. +pub fn build_detailed_report(reader: &mut R) -> Result where - W: Write, + R: Read + Seek, { - match format { - ProbeFormat::Json => write_json_report(writer, report).map_err(ProbeCliError::Io), - ProbeFormat::Yaml => write_yaml_report(writer, report).map_err(ProbeCliError::Io), - } + build_detailed_report_with_options(reader, ProbeReportOptions::default()) } -fn run_inner(args: &[String], stdout: &mut W) -> Result<(), ProbeCliError> +/// Builds a detailed CLI probe report from an MP4 reader with additive report controls. +pub fn build_detailed_report_with_options( + reader: &mut R, + options: ProbeReportOptions, +) -> Result where - W: Write, + R: Read + Seek, { - let mut format = ProbeFormat::Json; - let mut input_path = None; - let mut index = 0usize; - while index < args.len() { - match args[index].as_str() { - "-format" | "--format" => { - let Some(value) = args.get(index + 1) else { - return Err(ProbeCliError::InvalidArgument( - "missing value for -format".to_string(), - )); - }; - format = ProbeFormat::parse(value)?; - index += 2; - } - "-h" | "--help" => return Err(ProbeCliError::UsageRequested), - value if value.starts_with('-') => { - return Err(ProbeCliError::InvalidArgument(format!( - "unknown probe option: {value}" - ))); - } - value => { - if input_path.is_some() { - return Err(ProbeCliError::InvalidArgument( - "probe accepts exactly one input path".to_string(), - )); - } - input_path = Some(value); - index += 1; - } + let summary = probe_detailed_with_options(reader, options.probe)?; + + let mut report = DetailedProbeReport { + major_brand: summary.major_brand.to_string(), + minor_version: summary.minor_version, + compatible_brands: summary + .compatible_brands + .iter() + .map(ToString::to_string) + .collect(), + fast_start: summary.fast_start, + timescale: summary.timescale, + duration: summary.duration, + duration_seconds: seconds(summary.duration, summary.timescale), + tracks: Vec::with_capacity(summary.tracks.len()), + }; + + for track in &summary.tracks { + let basic = &track.summary; + let (bitrate, max_bitrate) = summarize_bitrate( + &basic.samples, + basic.timescale, + basic.track_id, + &summary.segments, + options.include_bitrate, + ); + + let mut row = DetailedProbeTrackReport { + track_id: basic.track_id, + timescale: basic.timescale, + duration: basic.duration, + duration_seconds: seconds(basic.duration, basic.timescale), + codec: detailed_track_codec_string(track), + codec_family: track_codec_family_string(track.codec_family).to_string(), + encrypted: basic.encrypted, + handler_type: track.handler_type.map(|value| value.to_string()), + language: track.language.clone(), + sample_entry_type: track.sample_entry_type.map(|value| value.to_string()), + original_format: track.original_format.map(|value| value.to_string()), + protection_scheme_type: track + .protection_scheme + .as_ref() + .map(|value| value.scheme_type.to_string()), + protection_scheme_version: track + .protection_scheme + .as_ref() + .map(|value| value.scheme_version), + width: track.display_width, + height: track.display_height, + channel_count: track.channel_count, + sample_rate: track.sample_rate, + sample_num: some_if_nonzero(basic.samples.len()), + chunk_num: some_if_nonzero(basic.chunks.len()), + idr_frame_num: None, + bitrate: some_if_nonzero(bitrate as usize).map(|_| bitrate), + max_bitrate: some_if_nonzero(max_bitrate as usize).map(|_| max_bitrate), + }; + + if options.include_idr_frame_count && basic.avc.is_some() && !basic.samples.is_empty() { + row.idr_frame_num = idr_frame_count(reader, basic)?; } + + report.tracks.push(row); } - let Some(input_path) = input_path else { - return Err(ProbeCliError::UsageRequested); - }; + Ok(report) +} - let mut file = File::open(input_path)?; - let report = build_report(&mut file)?; - write_report(stdout, &report, format) +/// Builds a codec-detailed CLI probe report from an MP4 reader. +pub fn build_codec_detailed_report( + reader: &mut R, +) -> Result +where + R: Read + Seek, +{ + build_codec_detailed_report_with_options(reader, ProbeReportOptions::default()) } -fn track_codec_string(track: &crate::probe::TrackInfo) -> String { - match track.codec { - TrackCodec::Avc1 => track - .avc - .as_ref() - .map(|avc| { +/// Builds a codec-detailed CLI probe report from an MP4 reader with additive report controls. +pub fn build_codec_detailed_report_with_options( + reader: &mut R, + options: ProbeReportOptions, +) -> Result +where + R: Read + Seek, +{ + let summary = probe_codec_detailed_with_options(reader, options.probe)?; + + let mut report = CodecDetailedProbeReport { + major_brand: summary.major_brand.to_string(), + minor_version: summary.minor_version, + compatible_brands: summary + .compatible_brands + .iter() + .map(ToString::to_string) + .collect(), + fast_start: summary.fast_start, + timescale: summary.timescale, + duration: summary.duration, + duration_seconds: seconds(summary.duration, summary.timescale), + tracks: Vec::with_capacity(summary.tracks.len()), + }; + + for track in &summary.tracks { + let basic = &track.summary.summary; + let (bitrate, max_bitrate) = summarize_bitrate( + &basic.samples, + basic.timescale, + basic.track_id, + &summary.segments, + options.include_bitrate, + ); + + let mut row = CodecDetailedProbeTrackReport { + track_id: basic.track_id, + timescale: basic.timescale, + duration: basic.duration, + duration_seconds: seconds(basic.duration, basic.timescale), + codec: detailed_track_codec_string(&track.summary), + codec_family: track_codec_family_string(track.summary.codec_family).to_string(), + codec_details: track.codec_details.clone(), + encrypted: basic.encrypted, + handler_type: track.summary.handler_type.map(|value| value.to_string()), + language: track.summary.language.clone(), + sample_entry_type: track + .summary + .sample_entry_type + .map(|value| value.to_string()), + original_format: track.summary.original_format.map(|value| value.to_string()), + protection_scheme_type: track + .summary + .protection_scheme + .as_ref() + .map(|value| value.scheme_type.to_string()), + protection_scheme_version: track + .summary + .protection_scheme + .as_ref() + .map(|value| value.scheme_version), + width: track.summary.display_width, + height: track.summary.display_height, + channel_count: track.summary.channel_count, + sample_rate: track.summary.sample_rate, + sample_num: some_if_nonzero(basic.samples.len()), + chunk_num: some_if_nonzero(basic.chunks.len()), + idr_frame_num: None, + bitrate: some_if_nonzero(bitrate as usize).map(|_| bitrate), + max_bitrate: some_if_nonzero(max_bitrate as usize).map(|_| max_bitrate), + }; + + if options.include_idr_frame_count && basic.avc.is_some() && !basic.samples.is_empty() { + row.idr_frame_num = idr_frame_count(reader, basic)?; + } + + report.tracks.push(row); + } + + Ok(report) +} + +/// Builds a media-characteristics CLI probe report from an MP4 reader. +pub fn build_media_characteristics_report( + reader: &mut R, +) -> Result +where + R: Read + Seek, +{ + build_media_characteristics_report_with_options(reader, ProbeReportOptions::default()) +} + +/// Builds a media-characteristics CLI probe report from an MP4 reader with additive report +/// controls. +pub fn build_media_characteristics_report_with_options( + reader: &mut R, + options: ProbeReportOptions, +) -> Result +where + R: Read + Seek, +{ + let summary = probe_media_characteristics_with_options(reader, options.probe)?; + + let mut report = MediaCharacteristicsProbeReport { + major_brand: summary.major_brand.to_string(), + minor_version: summary.minor_version, + compatible_brands: summary + .compatible_brands + .iter() + .map(ToString::to_string) + .collect(), + fast_start: summary.fast_start, + timescale: summary.timescale, + duration: summary.duration, + duration_seconds: seconds(summary.duration, summary.timescale), + tracks: Vec::with_capacity(summary.tracks.len()), + }; + + for track in &summary.tracks { + let basic = &track.summary.summary; + let (bitrate, max_bitrate) = summarize_bitrate( + &basic.samples, + basic.timescale, + basic.track_id, + &summary.segments, + options.include_bitrate, + ); + + let mut row = MediaCharacteristicsProbeTrackReport { + track_id: basic.track_id, + timescale: basic.timescale, + duration: basic.duration, + duration_seconds: seconds(basic.duration, basic.timescale), + codec: detailed_track_codec_string(&track.summary), + codec_family: track_codec_family_string(track.summary.codec_family).to_string(), + codec_details: track.codec_details.clone(), + media_characteristics: track.media_characteristics.clone(), + encrypted: basic.encrypted, + handler_type: track.summary.handler_type.map(|value| value.to_string()), + language: track.summary.language.clone(), + sample_entry_type: track + .summary + .sample_entry_type + .map(|value| value.to_string()), + original_format: track.summary.original_format.map(|value| value.to_string()), + protection_scheme_type: track + .summary + .protection_scheme + .as_ref() + .map(|value| value.scheme_type.to_string()), + protection_scheme_version: track + .summary + .protection_scheme + .as_ref() + .map(|value| value.scheme_version), + width: track.summary.display_width, + height: track.summary.display_height, + channel_count: track.summary.channel_count, + sample_rate: track.summary.sample_rate, + sample_num: some_if_nonzero(basic.samples.len()), + chunk_num: some_if_nonzero(basic.chunks.len()), + idr_frame_num: None, + bitrate: some_if_nonzero(bitrate as usize).map(|_| bitrate), + max_bitrate: some_if_nonzero(max_bitrate as usize).map(|_| max_bitrate), + }; + + if options.include_idr_frame_count && basic.avc.is_some() && !basic.samples.is_empty() { + row.idr_frame_num = idr_frame_count(reader, basic)?; + } + + report.tracks.push(row); + } + + Ok(report) +} + +/// Writes `report` in the selected `format`. +pub fn write_report( + writer: &mut W, + report: &ProbeReport, + format: ProbeFormat, +) -> Result<(), ProbeCliError> +where + W: Write, +{ + match format { + ProbeFormat::Json => write_json_report(writer, report).map_err(ProbeCliError::Io), + ProbeFormat::Yaml => write_yaml_report(writer, report).map_err(ProbeCliError::Io), + } +} + +/// Writes `report` in the selected `format`. +pub fn write_detailed_report( + writer: &mut W, + report: &DetailedProbeReport, + format: ProbeFormat, +) -> Result<(), ProbeCliError> +where + W: Write, +{ + match format { + ProbeFormat::Json => write_json_detailed_report(writer, report).map_err(ProbeCliError::Io), + ProbeFormat::Yaml => write_yaml_detailed_report(writer, report).map_err(ProbeCliError::Io), + } +} + +/// Writes `report` in the selected `format`. +pub fn write_codec_detailed_report( + writer: &mut W, + report: &CodecDetailedProbeReport, + format: ProbeFormat, +) -> Result<(), ProbeCliError> +where + W: Write, +{ + match format { + ProbeFormat::Json => { + write_json_codec_detailed_report(writer, report).map_err(ProbeCliError::Io) + } + ProbeFormat::Yaml => { + write_yaml_codec_detailed_report(writer, report).map_err(ProbeCliError::Io) + } + } +} + +/// Writes `report` in the selected `format`. +pub fn write_media_characteristics_report( + writer: &mut W, + report: &MediaCharacteristicsProbeReport, + format: ProbeFormat, +) -> Result<(), ProbeCliError> +where + W: Write, +{ + match format { + ProbeFormat::Json => { + write_json_media_characteristics_report(writer, report).map_err(ProbeCliError::Io) + } + ProbeFormat::Yaml => { + write_yaml_media_characteristics_report(writer, report).map_err(ProbeCliError::Io) + } + } +} + +fn run_inner(args: &[String], stdout: &mut W) -> Result<(), ProbeCliError> +where + W: Write, +{ + let mut format = ProbeFormat::Json; + let mut detail = ProbeDetailLevel::Full; + let mut input_path = None; + let mut index = 0usize; + while index < args.len() { + match args[index].as_str() { + "-format" | "--format" => { + let Some(value) = args.get(index + 1) else { + return Err(ProbeCliError::InvalidArgument( + "missing value for -format".to_string(), + )); + }; + format = ProbeFormat::parse(value)?; + index += 2; + } + "-detail" | "--detail" => { + let Some(value) = args.get(index + 1) else { + return Err(ProbeCliError::InvalidArgument( + "missing value for -detail".to_string(), + )); + }; + detail = ProbeDetailLevel::parse(value)?; + index += 2; + } + "-h" | "--help" => return Err(ProbeCliError::UsageRequested), + value if value.starts_with('-') => { + return Err(ProbeCliError::InvalidArgument(format!( + "unknown probe option: {value}" + ))); + } + value => { + if input_path.is_some() { + return Err(ProbeCliError::InvalidArgument( + "probe accepts exactly one input path".to_string(), + )); + } + input_path = Some(value); + index += 1; + } + } + } + + let Some(input_path) = input_path else { + return Err(ProbeCliError::UsageRequested); + }; + + let mut file = File::open(input_path)?; + let report = + build_media_characteristics_report_with_options(&mut file, detail.report_options())?; + write_media_characteristics_report(stdout, &report, format) +} + +fn track_codec_string(track: &crate::probe::TrackInfo) -> String { + match track.codec { + TrackCodec::Avc1 => track + .avc + .as_ref() + .map(|avc| { format!( "avc1.{:02X}{:02X}{:02X}", avc.profile, avc.profile_compatibility, avc.level @@ -268,6 +907,56 @@ fn track_codec_string(track: &crate::probe::TrackInfo) -> String { } } +fn detailed_track_codec_string(track: &DetailedTrackInfo) -> String { + let codec_box_type = track.original_format.or(track.sample_entry_type); + match track.codec_family { + TrackCodecFamily::Avc | TrackCodecFamily::Mp4Audio => track_codec_string(&track.summary), + TrackCodecFamily::Unknown => codec_box_type + .map(|value| value.to_string()) + .unwrap_or_else(|| track_codec_string(&track.summary)), + _ => codec_box_type + .map(|value| value.to_string()) + .unwrap_or_else(|| "unknown".to_string()), + } +} + +fn track_codec_family_string(family: TrackCodecFamily) -> &'static str { + match family { + TrackCodecFamily::Unknown => "unknown", + TrackCodecFamily::Avc => "avc", + TrackCodecFamily::Hevc => "hevc", + TrackCodecFamily::Av1 => "av1", + TrackCodecFamily::Vp8 => "vp8", + TrackCodecFamily::Vp9 => "vp9", + TrackCodecFamily::Mp4Audio => "mp4_audio", + TrackCodecFamily::Opus => "opus", + TrackCodecFamily::Ac3 => "ac3", + TrackCodecFamily::Pcm => "pcm", + TrackCodecFamily::XmlSubtitle => "xml_subtitle", + TrackCodecFamily::TextSubtitle => "text_subtitle", + TrackCodecFamily::WebVtt => "webvtt", + } +} + +fn summarize_bitrate( + samples: &[crate::probe::SampleInfo], + timescale: u32, + track_id: u32, + segments: &[crate::probe::SegmentInfo], + include_bitrate: bool, +) -> (u64, u64) { + if !include_bitrate { + return (0, 0); + } + let mut bitrate = average_sample_bitrate(samples, timescale); + let mut max_bitrate = max_sample_bitrate(samples, timescale, timescale.into()); + if bitrate == 0 || max_bitrate == 0 { + bitrate = average_segment_bitrate(segments, track_id, timescale); + max_bitrate = max_segment_bitrate(segments, track_id, timescale); + } + (bitrate, max_bitrate) +} + fn idr_frame_count( reader: &mut R, track: &crate::probe::TrackInfo, @@ -405,29 +1094,877 @@ where Ok(()) } -fn write_json_field( - writer: &mut W, - indent_level: usize, - name: &str, - value: &str, - trailing_comma: bool, -) -> io::Result<()> +fn write_json_detailed_report(writer: &mut W, report: &DetailedProbeReport) -> io::Result<()> where W: Write, { - let trailing = if trailing_comma { "," } else { "" }; - writeln!( + writeln!(writer, "{{")?; + write_json_field( writer, - "{}\"{name}\": {value}{trailing}", - " ".repeat(indent_level) - ) + 1, + "MajorBrand", + &json_string(&report.major_brand), + true, + )?; + write_json_field( + writer, + 1, + "MinorVersion", + &report.minor_version.to_string(), + true, + )?; + writeln!(writer, " \"CompatibleBrands\": [")?; + for (index, brand) in report.compatible_brands.iter().enumerate() { + let trailing = if index + 1 == report.compatible_brands.len() { + "" + } else { + "," + }; + writeln!(writer, " {}{trailing}", json_string(brand))?; + } + writeln!(writer, " ],")?; + write_json_field( + writer, + 1, + "FastStart", + if report.fast_start { "true" } else { "false" }, + true, + )?; + write_json_field(writer, 1, "Timescale", &report.timescale.to_string(), true)?; + write_json_field(writer, 1, "Duration", &report.duration.to_string(), true)?; + write_json_field( + writer, + 1, + "DurationSeconds", + &format_seconds(report.duration_seconds), + true, + )?; + writeln!(writer, " \"Tracks\": [")?; + for (index, track) in report.tracks.iter().enumerate() { + let trailing = if index + 1 == report.tracks.len() { + "" + } else { + "," + }; + write_json_detailed_track(writer, track)?; + writeln!(writer, " }}{trailing}")?; + } + writeln!(writer, " ]")?; + writeln!(writer, "}}") } -fn write_yaml_report(writer: &mut W, report: &ProbeReport) -> io::Result<()> +fn write_json_detailed_track(writer: &mut W, track: &DetailedProbeTrackReport) -> io::Result<()> where W: Write, { - writeln!(writer, "major_brand: {}", yaml_string(&report.major_brand))?; + let mut fields = vec![ + ("TrackID", track.track_id.to_string()), + ("Timescale", track.timescale.to_string()), + ("Duration", track.duration.to_string()), + ("DurationSeconds", format_seconds(track.duration_seconds)), + ("Codec", json_string(&track.codec)), + ("CodecFamily", json_string(&track.codec_family)), + ( + "Encrypted", + if track.encrypted { "true" } else { "false" }.to_string(), + ), + ]; + + if let Some(handler_type) = track.handler_type.as_ref() { + fields.push(("HandlerType", json_string(handler_type))); + } + if let Some(language) = track.language.as_ref() { + fields.push(("Language", json_string(language))); + } + if let Some(sample_entry_type) = track.sample_entry_type.as_ref() { + fields.push(("SampleEntryType", json_string(sample_entry_type))); + } + if let Some(original_format) = track.original_format.as_ref() { + fields.push(("OriginalFormat", json_string(original_format))); + } + if let Some(protection_scheme_type) = track.protection_scheme_type.as_ref() { + fields.push(("ProtectionSchemeType", json_string(protection_scheme_type))); + } + if let Some(protection_scheme_version) = track.protection_scheme_version { + fields.push(( + "ProtectionSchemeVersion", + protection_scheme_version.to_string(), + )); + } + if let Some(width) = track.width { + fields.push(("Width", width.to_string())); + } + if let Some(height) = track.height { + fields.push(("Height", height.to_string())); + } + if let Some(channel_count) = track.channel_count { + fields.push(("ChannelCount", channel_count.to_string())); + } + if let Some(sample_rate) = track.sample_rate { + fields.push(("SampleRate", sample_rate.to_string())); + } + if let Some(sample_num) = track.sample_num { + fields.push(("SampleNum", sample_num.to_string())); + } + if let Some(chunk_num) = track.chunk_num { + fields.push(("ChunkNum", chunk_num.to_string())); + } + if let Some(idr_frame_num) = track.idr_frame_num { + fields.push(("IDRFrameNum", idr_frame_num.to_string())); + } + if let Some(bitrate) = track.bitrate { + fields.push(("Bitrate", bitrate.to_string())); + } + if let Some(max_bitrate) = track.max_bitrate { + fields.push(("MaxBitrate", max_bitrate.to_string())); + } + + writeln!(writer, " {{")?; + for (index, (name, value)) in fields.iter().enumerate() { + write_json_field(writer, 3, name, value, index + 1 != fields.len())?; + } + Ok(()) +} + +fn write_json_codec_detailed_report( + writer: &mut W, + report: &CodecDetailedProbeReport, +) -> io::Result<()> +where + W: Write, +{ + writeln!(writer, "{{")?; + write_json_field( + writer, + 1, + "MajorBrand", + &json_string(&report.major_brand), + true, + )?; + write_json_field( + writer, + 1, + "MinorVersion", + &report.minor_version.to_string(), + true, + )?; + writeln!(writer, " \"CompatibleBrands\": [")?; + for (index, brand) in report.compatible_brands.iter().enumerate() { + let trailing = if index + 1 == report.compatible_brands.len() { + "" + } else { + "," + }; + writeln!(writer, " {}{trailing}", json_string(brand))?; + } + writeln!(writer, " ],")?; + write_json_field( + writer, + 1, + "FastStart", + if report.fast_start { "true" } else { "false" }, + true, + )?; + write_json_field(writer, 1, "Timescale", &report.timescale.to_string(), true)?; + write_json_field(writer, 1, "Duration", &report.duration.to_string(), true)?; + write_json_field( + writer, + 1, + "DurationSeconds", + &format_seconds(report.duration_seconds), + true, + )?; + writeln!(writer, " \"Tracks\": [")?; + for (index, track) in report.tracks.iter().enumerate() { + let trailing = if index + 1 == report.tracks.len() { + "" + } else { + "," + }; + write_json_codec_detailed_track(writer, track)?; + writeln!(writer, " }}{trailing}")?; + } + writeln!(writer, " ]")?; + writeln!(writer, "}}") +} + +fn write_json_codec_detailed_track( + writer: &mut W, + track: &CodecDetailedProbeTrackReport, +) -> io::Result<()> +where + W: Write, +{ + let mut fields = vec![ + ("TrackID", track.track_id.to_string()), + ("Timescale", track.timescale.to_string()), + ("Duration", track.duration.to_string()), + ("DurationSeconds", format_seconds(track.duration_seconds)), + ("Codec", json_string(&track.codec)), + ("CodecFamily", json_string(&track.codec_family)), + ( + "Encrypted", + if track.encrypted { "true" } else { "false" }.to_string(), + ), + ]; + + if let Some(handler_type) = track.handler_type.as_ref() { + fields.push(("HandlerType", json_string(handler_type))); + } + if let Some(language) = track.language.as_ref() { + fields.push(("Language", json_string(language))); + } + if let Some(sample_entry_type) = track.sample_entry_type.as_ref() { + fields.push(("SampleEntryType", json_string(sample_entry_type))); + } + if let Some(original_format) = track.original_format.as_ref() { + fields.push(("OriginalFormat", json_string(original_format))); + } + if let Some(protection_scheme_type) = track.protection_scheme_type.as_ref() { + fields.push(("ProtectionSchemeType", json_string(protection_scheme_type))); + } + if let Some(protection_scheme_version) = track.protection_scheme_version { + fields.push(( + "ProtectionSchemeVersion", + protection_scheme_version.to_string(), + )); + } + if let Some(width) = track.width { + fields.push(("Width", width.to_string())); + } + if let Some(height) = track.height { + fields.push(("Height", height.to_string())); + } + if let Some(channel_count) = track.channel_count { + fields.push(("ChannelCount", channel_count.to_string())); + } + if let Some(sample_rate) = track.sample_rate { + fields.push(("SampleRate", sample_rate.to_string())); + } + if let Some(sample_num) = track.sample_num { + fields.push(("SampleNum", sample_num.to_string())); + } + if let Some(chunk_num) = track.chunk_num { + fields.push(("ChunkNum", chunk_num.to_string())); + } + if let Some(idr_frame_num) = track.idr_frame_num { + fields.push(("IDRFrameNum", idr_frame_num.to_string())); + } + if let Some(bitrate) = track.bitrate { + fields.push(("Bitrate", bitrate.to_string())); + } + if let Some(max_bitrate) = track.max_bitrate { + fields.push(("MaxBitrate", max_bitrate.to_string())); + } + + writeln!(writer, " {{")?; + for (name, value) in &fields { + write_json_field(writer, 3, name, value, true)?; + } + write_json_codec_details(writer, 3, &track.codec_family, &track.codec_details, false)?; + Ok(()) +} + +fn write_json_media_characteristics_report( + writer: &mut W, + report: &MediaCharacteristicsProbeReport, +) -> io::Result<()> +where + W: Write, +{ + writeln!(writer, "{{")?; + write_json_field( + writer, + 1, + "MajorBrand", + &json_string(&report.major_brand), + true, + )?; + write_json_field( + writer, + 1, + "MinorVersion", + &report.minor_version.to_string(), + true, + )?; + writeln!(writer, " \"CompatibleBrands\": [")?; + for (index, brand) in report.compatible_brands.iter().enumerate() { + let trailing = if index + 1 == report.compatible_brands.len() { + "" + } else { + "," + }; + writeln!(writer, " {}{trailing}", json_string(brand))?; + } + writeln!(writer, " ],")?; + write_json_field( + writer, + 1, + "FastStart", + if report.fast_start { "true" } else { "false" }, + true, + )?; + write_json_field(writer, 1, "Timescale", &report.timescale.to_string(), true)?; + write_json_field(writer, 1, "Duration", &report.duration.to_string(), true)?; + write_json_field( + writer, + 1, + "DurationSeconds", + &format_seconds(report.duration_seconds), + true, + )?; + writeln!(writer, " \"Tracks\": [")?; + for (index, track) in report.tracks.iter().enumerate() { + let trailing = if index + 1 == report.tracks.len() { + "" + } else { + "," + }; + write_json_media_characteristics_track(writer, track)?; + writeln!(writer, " }}{trailing}")?; + } + writeln!(writer, " ]")?; + writeln!(writer, "}}") +} + +fn write_json_media_characteristics_track( + writer: &mut W, + track: &MediaCharacteristicsProbeTrackReport, +) -> io::Result<()> +where + W: Write, +{ + let mut fields = vec![ + ("TrackID", track.track_id.to_string()), + ("Timescale", track.timescale.to_string()), + ("Duration", track.duration.to_string()), + ("DurationSeconds", format_seconds(track.duration_seconds)), + ("Codec", json_string(&track.codec)), + ("CodecFamily", json_string(&track.codec_family)), + ( + "Encrypted", + if track.encrypted { "true" } else { "false" }.to_string(), + ), + ]; + + if let Some(handler_type) = track.handler_type.as_ref() { + fields.push(("HandlerType", json_string(handler_type))); + } + if let Some(language) = track.language.as_ref() { + fields.push(("Language", json_string(language))); + } + if let Some(sample_entry_type) = track.sample_entry_type.as_ref() { + fields.push(("SampleEntryType", json_string(sample_entry_type))); + } + if let Some(original_format) = track.original_format.as_ref() { + fields.push(("OriginalFormat", json_string(original_format))); + } + if let Some(protection_scheme_type) = track.protection_scheme_type.as_ref() { + fields.push(("ProtectionSchemeType", json_string(protection_scheme_type))); + } + if let Some(protection_scheme_version) = track.protection_scheme_version { + fields.push(( + "ProtectionSchemeVersion", + protection_scheme_version.to_string(), + )); + } + if let Some(width) = track.width { + fields.push(("Width", width.to_string())); + } + if let Some(height) = track.height { + fields.push(("Height", height.to_string())); + } + if let Some(channel_count) = track.channel_count { + fields.push(("ChannelCount", channel_count.to_string())); + } + if let Some(sample_rate) = track.sample_rate { + fields.push(("SampleRate", sample_rate.to_string())); + } + if let Some(sample_num) = track.sample_num { + fields.push(("SampleNum", sample_num.to_string())); + } + if let Some(chunk_num) = track.chunk_num { + fields.push(("ChunkNum", chunk_num.to_string())); + } + if let Some(idr_frame_num) = track.idr_frame_num { + fields.push(("IDRFrameNum", idr_frame_num.to_string())); + } + if let Some(bitrate) = track.bitrate { + fields.push(("Bitrate", bitrate.to_string())); + } + if let Some(max_bitrate) = track.max_bitrate { + fields.push(("MaxBitrate", max_bitrate.to_string())); + } + + writeln!(writer, " {{")?; + for (name, value) in &fields { + write_json_field(writer, 3, name, value, true)?; + } + let include_media = has_media_characteristics(&track.media_characteristics); + write_json_codec_details( + writer, + 3, + &track.codec_family, + &track.codec_details, + include_media, + )?; + if include_media { + write_json_media_characteristics(writer, 3, &track.media_characteristics)?; + } + Ok(()) +} + +fn write_json_media_characteristics( + writer: &mut W, + indent_level: usize, + characteristics: &TrackMediaCharacteristics, +) -> io::Result<()> +where + W: Write, +{ + let section_count = usize::from(characteristics.declared_bitrate.is_some()) + + usize::from(characteristics.color.is_some()) + + usize::from(characteristics.pixel_aspect_ratio.is_some()) + + usize::from(characteristics.field_order.is_some()); + if section_count == 0 { + return Ok(()); + } + + let indent = " ".repeat(indent_level); + writeln!(writer, "{indent}\"MediaCharacteristics\": {{")?; + let mut written = 0usize; + + if let Some(value) = characteristics.declared_bitrate.as_ref() { + written += 1; + writeln!( + writer, + "{}\"DeclaredBitrate\": {{", + " ".repeat(indent_level + 1) + )?; + write_json_field( + writer, + indent_level + 2, + "BufferSizeDB", + &value.buffer_size_db.to_string(), + true, + )?; + write_json_field( + writer, + indent_level + 2, + "MaxBitrate", + &value.max_bitrate.to_string(), + true, + )?; + write_json_field( + writer, + indent_level + 2, + "AvgBitrate", + &value.avg_bitrate.to_string(), + false, + )?; + let trailing = if written == section_count { "" } else { "," }; + writeln!(writer, "{}}}{trailing}", " ".repeat(indent_level + 1))?; + } + + if let Some(value) = characteristics.color.as_ref() { + written += 1; + writeln!(writer, "{}\"Color\": {{", " ".repeat(indent_level + 1))?; + let mut fields = vec![("ColourType", json_string(&value.colour_type.to_string()))]; + if let Some(colour_primaries) = value.colour_primaries { + fields.push(("ColourPrimaries", colour_primaries.to_string())); + } + if let Some(transfer_characteristics) = value.transfer_characteristics { + fields.push(( + "TransferCharacteristics", + transfer_characteristics.to_string(), + )); + } + if let Some(matrix_coefficients) = value.matrix_coefficients { + fields.push(("MatrixCoefficients", matrix_coefficients.to_string())); + } + if let Some(full_range) = value.full_range { + fields.push(( + "FullRange", + if full_range { "true" } else { "false" }.to_string(), + )); + } + if let Some(profile_size) = value.profile_size { + fields.push(("ProfileSize", profile_size.to_string())); + } + if let Some(unknown_size) = value.unknown_size { + fields.push(("UnknownSize", unknown_size.to_string())); + } + for (index, (name, field_value)) in fields.iter().enumerate() { + write_json_field( + writer, + indent_level + 2, + name, + field_value, + index + 1 != fields.len(), + )?; + } + let trailing = if written == section_count { "" } else { "," }; + writeln!(writer, "{}}}{trailing}", " ".repeat(indent_level + 1))?; + } + + if let Some(value) = characteristics.pixel_aspect_ratio.as_ref() { + written += 1; + writeln!( + writer, + "{}\"PixelAspectRatio\": {{", + " ".repeat(indent_level + 1) + )?; + write_json_field( + writer, + indent_level + 2, + "HSpacing", + &value.h_spacing.to_string(), + true, + )?; + write_json_field( + writer, + indent_level + 2, + "VSpacing", + &value.v_spacing.to_string(), + false, + )?; + let trailing = if written == section_count { "" } else { "," }; + writeln!(writer, "{}}}{trailing}", " ".repeat(indent_level + 1))?; + } + + if let Some(value) = characteristics.field_order.as_ref() { + written += 1; + writeln!( + writer, + "{}\"FieldOrder\": {{", + " ".repeat(indent_level + 1) + )?; + write_json_field( + writer, + indent_level + 2, + "FieldCount", + &value.field_count.to_string(), + true, + )?; + write_json_field( + writer, + indent_level + 2, + "FieldOrdering", + &value.field_ordering.to_string(), + true, + )?; + write_json_field( + writer, + indent_level + 2, + "Interlaced", + if value.interlaced { "true" } else { "false" }, + false, + )?; + let trailing = if written == section_count { "" } else { "," }; + writeln!(writer, "{}}}{trailing}", " ".repeat(indent_level + 1))?; + } + + writeln!(writer, "{}}}", indent) +} + +fn write_json_codec_details( + writer: &mut W, + indent_level: usize, + codec_family: &str, + details: &TrackCodecDetails, + trailing_comma: bool, +) -> io::Result<()> +where + W: Write, +{ + writeln!(writer, "{}\"CodecDetails\": {{", " ".repeat(indent_level))?; + let fields = codec_detail_json_fields(codec_family, details); + for (index, (name, value)) in fields.iter().enumerate() { + write_json_field( + writer, + indent_level + 1, + name, + value, + index + 1 != fields.len(), + )?; + } + let trailing = if trailing_comma { "," } else { "" }; + writeln!(writer, "{}}}{trailing}", " ".repeat(indent_level)) +} + +fn codec_detail_json_fields( + codec_family: &str, + details: &TrackCodecDetails, +) -> Vec<(&'static str, String)> { + let mut fields = vec![("Kind", json_string(codec_family))]; + match details { + TrackCodecDetails::Unknown => {} + TrackCodecDetails::Avc(details) => { + fields.push(( + "ConfigurationVersion", + details.configuration_version.to_string(), + )); + fields.push(("Profile", details.profile.to_string())); + fields.push(( + "ProfileCompatibility", + details.profile_compatibility.to_string(), + )); + fields.push(("Level", details.level.to_string())); + fields.push(("LengthSize", details.length_size.to_string())); + if let Some(chroma_format) = details.chroma_format { + fields.push(("ChromaFormat", chroma_format.to_string())); + } + if let Some(bit_depth_luma) = details.bit_depth_luma { + fields.push(("BitDepthLuma", bit_depth_luma.to_string())); + } + if let Some(bit_depth_chroma) = details.bit_depth_chroma { + fields.push(("BitDepthChroma", bit_depth_chroma.to_string())); + } + } + TrackCodecDetails::Hevc(details) => { + fields.push(( + "ConfigurationVersion", + details.configuration_version.to_string(), + )); + fields.push(("ProfileSpace", details.profile_space.to_string())); + fields.push(( + "TierFlag", + if details.tier_flag { "true" } else { "false" }.to_string(), + )); + fields.push(("ProfileIDC", details.profile_idc.to_string())); + fields.push(( + "ProfileCompatibilityMask", + details.profile_compatibility_mask.to_string(), + )); + fields.push(( + "ConstraintIndicator", + json_u8_array(&details.constraint_indicator), + )); + fields.push(("LevelIDC", details.level_idc.to_string())); + fields.push(( + "MinSpatialSegmentationIDC", + details.min_spatial_segmentation_idc.to_string(), + )); + fields.push(("ParallelismType", details.parallelism_type.to_string())); + fields.push(("ChromaFormatIDC", details.chroma_format_idc.to_string())); + fields.push(("BitDepthLuma", details.bit_depth_luma.to_string())); + fields.push(("BitDepthChroma", details.bit_depth_chroma.to_string())); + fields.push(("AvgFrameRate", details.avg_frame_rate.to_string())); + fields.push(("ConstantFrameRate", details.constant_frame_rate.to_string())); + fields.push(("NumTemporalLayers", details.num_temporal_layers.to_string())); + fields.push(("TemporalIDNested", details.temporal_id_nested.to_string())); + fields.push(("LengthSize", details.length_size.to_string())); + } + TrackCodecDetails::Av1(details) => { + fields.push(("SeqProfile", details.seq_profile.to_string())); + fields.push(("SeqLevelIdx0", details.seq_level_idx_0.to_string())); + fields.push(("SeqTier0", details.seq_tier_0.to_string())); + fields.push(("BitDepth", details.bit_depth.to_string())); + fields.push(( + "Monochrome", + if details.monochrome { "true" } else { "false" }.to_string(), + )); + fields.push(( + "ChromaSubsamplingX", + details.chroma_subsampling_x.to_string(), + )); + fields.push(( + "ChromaSubsamplingY", + details.chroma_subsampling_y.to_string(), + )); + fields.push(( + "ChromaSamplePosition", + details.chroma_sample_position.to_string(), + )); + if let Some(delay) = details.initial_presentation_delay_minus_one { + fields.push(("InitialPresentationDelayMinusOne", delay.to_string())); + } + } + TrackCodecDetails::Vp8(details) | TrackCodecDetails::Vp9(details) => { + fields.push(("Profile", details.profile.to_string())); + fields.push(("Level", details.level.to_string())); + fields.push(("BitDepth", details.bit_depth.to_string())); + fields.push(("ChromaSubsampling", details.chroma_subsampling.to_string())); + fields.push(( + "FullRange", + if details.full_range { "true" } else { "false" }.to_string(), + )); + fields.push(("ColourPrimaries", details.colour_primaries.to_string())); + fields.push(( + "TransferCharacteristics", + details.transfer_characteristics.to_string(), + )); + fields.push(( + "MatrixCoefficients", + details.matrix_coefficients.to_string(), + )); + fields.push(( + "CodecInitializationDataSize", + details.codec_initialization_data_size.to_string(), + )); + } + TrackCodecDetails::Mp4Audio(details) => { + fields.push(( + "ObjectTypeIndication", + details.object_type_indication.to_string(), + )); + fields.push(("AudioObjectType", details.audio_object_type.to_string())); + fields.push(("ChannelCount", details.channel_count.to_string())); + if let Some(sample_rate) = details.sample_rate { + fields.push(("SampleRate", sample_rate.to_string())); + } + } + TrackCodecDetails::Opus(details) => { + fields.push(( + "OutputChannelCount", + details.output_channel_count.to_string(), + )); + fields.push(("PreSkip", details.pre_skip.to_string())); + fields.push(("InputSampleRate", details.input_sample_rate.to_string())); + fields.push(("OutputGain", details.output_gain.to_string())); + fields.push(( + "ChannelMappingFamily", + details.channel_mapping_family.to_string(), + )); + if let Some(stream_count) = details.stream_count { + fields.push(("StreamCount", stream_count.to_string())); + } + if let Some(coupled_count) = details.coupled_count { + fields.push(("CoupledCount", coupled_count.to_string())); + } + if !details.channel_mapping.is_empty() { + fields.push(("ChannelMapping", json_u8_array(&details.channel_mapping))); + } + } + TrackCodecDetails::Ac3(details) => { + fields.push(("SampleRateCode", details.sample_rate_code.to_string())); + fields.push(( + "BitStreamIdentification", + details.bit_stream_identification.to_string(), + )); + fields.push(("BitStreamMode", details.bit_stream_mode.to_string())); + fields.push(("AudioCodingMode", details.audio_coding_mode.to_string())); + fields.push(( + "LfeOn", + if details.lfe_on { "true" } else { "false" }.to_string(), + )); + fields.push(("BitRateCode", details.bit_rate_code.to_string())); + } + TrackCodecDetails::Pcm(details) => { + fields.push(("FormatFlags", details.format_flags.to_string())); + fields.push(("SampleSize", details.sample_size.to_string())); + } + TrackCodecDetails::XmlSubtitle(details) => { + fields.push(("Namespace", json_string(&details.namespace))); + fields.push(("SchemaLocation", json_string(&details.schema_location))); + fields.push(( + "AuxiliaryMimeTypes", + json_string(&details.auxiliary_mime_types), + )); + } + TrackCodecDetails::TextSubtitle(details) => { + fields.push(("ContentEncoding", json_string(&details.content_encoding))); + fields.push(("MimeFormat", json_string(&details.mime_format))); + } + TrackCodecDetails::WebVtt(details) => { + if let Some(config) = details.config.as_ref() { + fields.push(("Config", json_string(config))); + } + if let Some(source_label) = details.source_label.as_ref() { + fields.push(("SourceLabel", json_string(source_label))); + } + } + } + + fields +} + +fn write_json_field( + writer: &mut W, + indent_level: usize, + name: &str, + value: &str, + trailing_comma: bool, +) -> io::Result<()> +where + W: Write, +{ + let trailing = if trailing_comma { "," } else { "" }; + writeln!( + writer, + "{}\"{name}\": {value}{trailing}", + " ".repeat(indent_level) + ) +} + +fn write_yaml_report(writer: &mut W, report: &ProbeReport) -> io::Result<()> +where + W: Write, +{ + writeln!(writer, "major_brand: {}", yaml_string(&report.major_brand))?; + writeln!(writer, "minor_version: {}", report.minor_version)?; + writeln!(writer, "compatible_brands:")?; + for brand in &report.compatible_brands { + writeln!(writer, "- {}", yaml_string(brand))?; + } + writeln!(writer, "fast_start: {}", report.fast_start)?; + writeln!(writer, "timescale: {}", report.timescale)?; + writeln!(writer, "duration: {}", report.duration)?; + writeln!( + writer, + "duration_seconds: {}", + format_seconds(report.duration_seconds) + )?; + writeln!(writer, "tracks:")?; + for track in &report.tracks { + write_yaml_track(writer, track)?; + } + Ok(()) +} + +fn write_yaml_track(writer: &mut W, track: &ProbeTrackReport) -> io::Result<()> +where + W: Write, +{ + writeln!(writer, "- track_id: {}", track.track_id)?; + writeln!(writer, " timescale: {}", track.timescale)?; + writeln!(writer, " duration: {}", track.duration)?; + writeln!( + writer, + " duration_seconds: {}", + format_seconds(track.duration_seconds) + )?; + writeln!(writer, " codec: {}", yaml_string(&track.codec))?; + writeln!(writer, " encrypted: {}", track.encrypted)?; + if let Some(width) = track.width { + writeln!(writer, " width: {width}")?; + } + if let Some(height) = track.height { + writeln!(writer, " height: {height}")?; + } + if let Some(sample_num) = track.sample_num { + writeln!(writer, " sample_num: {sample_num}")?; + } + if let Some(chunk_num) = track.chunk_num { + writeln!(writer, " chunk_num: {chunk_num}")?; + } + if let Some(idr_frame_num) = track.idr_frame_num { + writeln!(writer, " idr_frame_num: {idr_frame_num}")?; + } + if let Some(bitrate) = track.bitrate { + writeln!(writer, " bitrate: {bitrate}")?; + } + if let Some(max_bitrate) = track.max_bitrate { + writeln!(writer, " max_bitrate: {max_bitrate}")?; + } + Ok(()) +} + +fn write_yaml_detailed_report(writer: &mut W, report: &DetailedProbeReport) -> io::Result<()> +where + W: Write, +{ + writeln!(writer, "major_brand: {}", yaml_string(&report.major_brand))?; writeln!(writer, "minor_version: {}", report.minor_version)?; writeln!(writer, "compatible_brands:")?; for brand in &report.compatible_brands { @@ -443,12 +1980,242 @@ where )?; writeln!(writer, "tracks:")?; for track in &report.tracks { - write_yaml_track(writer, track)?; + write_yaml_detailed_track(writer, track)?; } Ok(()) } -fn write_yaml_track(writer: &mut W, track: &ProbeTrackReport) -> io::Result<()> +fn write_yaml_detailed_track(writer: &mut W, track: &DetailedProbeTrackReport) -> io::Result<()> +where + W: Write, +{ + writeln!(writer, "- track_id: {}", track.track_id)?; + writeln!(writer, " timescale: {}", track.timescale)?; + writeln!(writer, " duration: {}", track.duration)?; + writeln!( + writer, + " duration_seconds: {}", + format_seconds(track.duration_seconds) + )?; + writeln!(writer, " codec: {}", yaml_string(&track.codec))?; + writeln!( + writer, + " codec_family: {}", + yaml_string(&track.codec_family) + )?; + writeln!(writer, " encrypted: {}", track.encrypted)?; + if let Some(handler_type) = track.handler_type.as_ref() { + writeln!(writer, " handler_type: {}", yaml_string(handler_type))?; + } + if let Some(language) = track.language.as_ref() { + writeln!(writer, " language: {}", yaml_string(language))?; + } + if let Some(sample_entry_type) = track.sample_entry_type.as_ref() { + writeln!( + writer, + " sample_entry_type: {}", + yaml_string(sample_entry_type) + )?; + } + if let Some(original_format) = track.original_format.as_ref() { + writeln!( + writer, + " original_format: {}", + yaml_string(original_format) + )?; + } + if let Some(protection_scheme_type) = track.protection_scheme_type.as_ref() { + writeln!( + writer, + " protection_scheme_type: {}", + yaml_string(protection_scheme_type) + )?; + } + if let Some(protection_scheme_version) = track.protection_scheme_version { + writeln!( + writer, + " protection_scheme_version: {protection_scheme_version}" + )?; + } + if let Some(width) = track.width { + writeln!(writer, " width: {width}")?; + } + if let Some(height) = track.height { + writeln!(writer, " height: {height}")?; + } + if let Some(channel_count) = track.channel_count { + writeln!(writer, " channel_count: {channel_count}")?; + } + if let Some(sample_rate) = track.sample_rate { + writeln!(writer, " sample_rate: {sample_rate}")?; + } + if let Some(sample_num) = track.sample_num { + writeln!(writer, " sample_num: {sample_num}")?; + } + if let Some(chunk_num) = track.chunk_num { + writeln!(writer, " chunk_num: {chunk_num}")?; + } + if let Some(idr_frame_num) = track.idr_frame_num { + writeln!(writer, " idr_frame_num: {idr_frame_num}")?; + } + if let Some(bitrate) = track.bitrate { + writeln!(writer, " bitrate: {bitrate}")?; + } + if let Some(max_bitrate) = track.max_bitrate { + writeln!(writer, " max_bitrate: {max_bitrate}")?; + } + Ok(()) +} + +fn write_yaml_codec_detailed_report( + writer: &mut W, + report: &CodecDetailedProbeReport, +) -> io::Result<()> +where + W: Write, +{ + writeln!(writer, "major_brand: {}", yaml_string(&report.major_brand))?; + writeln!(writer, "minor_version: {}", report.minor_version)?; + writeln!(writer, "compatible_brands:")?; + for brand in &report.compatible_brands { + writeln!(writer, "- {}", yaml_string(brand))?; + } + writeln!(writer, "fast_start: {}", report.fast_start)?; + writeln!(writer, "timescale: {}", report.timescale)?; + writeln!(writer, "duration: {}", report.duration)?; + writeln!( + writer, + "duration_seconds: {}", + format_seconds(report.duration_seconds) + )?; + writeln!(writer, "tracks:")?; + for track in &report.tracks { + write_yaml_codec_detailed_track(writer, track)?; + } + Ok(()) +} + +fn write_yaml_codec_detailed_track( + writer: &mut W, + track: &CodecDetailedProbeTrackReport, +) -> io::Result<()> +where + W: Write, +{ + writeln!(writer, "- track_id: {}", track.track_id)?; + writeln!(writer, " timescale: {}", track.timescale)?; + writeln!(writer, " duration: {}", track.duration)?; + writeln!( + writer, + " duration_seconds: {}", + format_seconds(track.duration_seconds) + )?; + writeln!(writer, " codec: {}", yaml_string(&track.codec))?; + writeln!( + writer, + " codec_family: {}", + yaml_string(&track.codec_family) + )?; + writeln!(writer, " encrypted: {}", track.encrypted)?; + if let Some(handler_type) = track.handler_type.as_ref() { + writeln!(writer, " handler_type: {}", yaml_string(handler_type))?; + } + if let Some(language) = track.language.as_ref() { + writeln!(writer, " language: {}", yaml_string(language))?; + } + if let Some(sample_entry_type) = track.sample_entry_type.as_ref() { + writeln!( + writer, + " sample_entry_type: {}", + yaml_string(sample_entry_type) + )?; + } + if let Some(original_format) = track.original_format.as_ref() { + writeln!( + writer, + " original_format: {}", + yaml_string(original_format) + )?; + } + if let Some(protection_scheme_type) = track.protection_scheme_type.as_ref() { + writeln!( + writer, + " protection_scheme_type: {}", + yaml_string(protection_scheme_type) + )?; + } + if let Some(protection_scheme_version) = track.protection_scheme_version { + writeln!( + writer, + " protection_scheme_version: {protection_scheme_version}" + )?; + } + if let Some(width) = track.width { + writeln!(writer, " width: {width}")?; + } + if let Some(height) = track.height { + writeln!(writer, " height: {height}")?; + } + if let Some(channel_count) = track.channel_count { + writeln!(writer, " channel_count: {channel_count}")?; + } + if let Some(sample_rate) = track.sample_rate { + writeln!(writer, " sample_rate: {sample_rate}")?; + } + if let Some(sample_num) = track.sample_num { + writeln!(writer, " sample_num: {sample_num}")?; + } + if let Some(chunk_num) = track.chunk_num { + writeln!(writer, " chunk_num: {chunk_num}")?; + } + if let Some(idr_frame_num) = track.idr_frame_num { + writeln!(writer, " idr_frame_num: {idr_frame_num}")?; + } + if let Some(bitrate) = track.bitrate { + writeln!(writer, " bitrate: {bitrate}")?; + } + if let Some(max_bitrate) = track.max_bitrate { + writeln!(writer, " max_bitrate: {max_bitrate}")?; + } + writeln!(writer, " codec_details:")?; + for (name, value) in codec_detail_yaml_fields(&track.codec_family, &track.codec_details) { + writeln!(writer, " {name}: {value}")?; + } + Ok(()) +} + +fn write_yaml_media_characteristics_report( + writer: &mut W, + report: &MediaCharacteristicsProbeReport, +) -> io::Result<()> +where + W: Write, +{ + writeln!(writer, "major_brand: {}", yaml_string(&report.major_brand))?; + writeln!(writer, "minor_version: {}", report.minor_version)?; + writeln!(writer, "compatible_brands:")?; + for brand in &report.compatible_brands { + writeln!(writer, "- {}", yaml_string(brand))?; + } + writeln!(writer, "fast_start: {}", report.fast_start)?; + writeln!(writer, "timescale: {}", report.timescale)?; + writeln!(writer, "duration: {}", report.duration)?; + writeln!( + writer, + "duration_seconds: {}", + format_seconds(report.duration_seconds) + )?; + writeln!(writer, "tracks:")?; + for track in &report.tracks { + write_yaml_media_characteristics_track(writer, track)?; + } + Ok(()) +} + +fn write_yaml_media_characteristics_track( + writer: &mut W, + track: &MediaCharacteristicsProbeTrackReport, +) -> io::Result<()> where W: Write, { @@ -461,13 +2228,57 @@ where format_seconds(track.duration_seconds) )?; writeln!(writer, " codec: {}", yaml_string(&track.codec))?; + writeln!( + writer, + " codec_family: {}", + yaml_string(&track.codec_family) + )?; writeln!(writer, " encrypted: {}", track.encrypted)?; + if let Some(handler_type) = track.handler_type.as_ref() { + writeln!(writer, " handler_type: {}", yaml_string(handler_type))?; + } + if let Some(language) = track.language.as_ref() { + writeln!(writer, " language: {}", yaml_string(language))?; + } + if let Some(sample_entry_type) = track.sample_entry_type.as_ref() { + writeln!( + writer, + " sample_entry_type: {}", + yaml_string(sample_entry_type) + )?; + } + if let Some(original_format) = track.original_format.as_ref() { + writeln!( + writer, + " original_format: {}", + yaml_string(original_format) + )?; + } + if let Some(protection_scheme_type) = track.protection_scheme_type.as_ref() { + writeln!( + writer, + " protection_scheme_type: {}", + yaml_string(protection_scheme_type) + )?; + } + if let Some(protection_scheme_version) = track.protection_scheme_version { + writeln!( + writer, + " protection_scheme_version: {protection_scheme_version}" + )?; + } if let Some(width) = track.width { writeln!(writer, " width: {width}")?; } if let Some(height) = track.height { writeln!(writer, " height: {height}")?; } + if let Some(channel_count) = track.channel_count { + writeln!(writer, " channel_count: {channel_count}")?; + } + if let Some(sample_rate) = track.sample_rate { + writeln!(writer, " sample_rate: {sample_rate}")?; + } if let Some(sample_num) = track.sample_num { writeln!(writer, " sample_num: {sample_num}")?; } @@ -483,9 +2294,266 @@ where if let Some(max_bitrate) = track.max_bitrate { writeln!(writer, " max_bitrate: {max_bitrate}")?; } + writeln!(writer, " codec_details:")?; + for (name, value) in codec_detail_yaml_fields(&track.codec_family, &track.codec_details) { + writeln!(writer, " {name}: {value}")?; + } + if has_media_characteristics(&track.media_characteristics) { + writeln!(writer, " media_characteristics:")?; + if let Some(value) = track.media_characteristics.declared_bitrate.as_ref() { + writeln!(writer, " declared_bitrate:")?; + writeln!(writer, " buffer_size_db: {}", value.buffer_size_db)?; + writeln!(writer, " max_bitrate: {}", value.max_bitrate)?; + writeln!(writer, " avg_bitrate: {}", value.avg_bitrate)?; + } + if let Some(value) = track.media_characteristics.color.as_ref() { + writeln!(writer, " color:")?; + writeln!( + writer, + " colour_type: {}", + yaml_string(&value.colour_type.to_string()) + )?; + if let Some(colour_primaries) = value.colour_primaries { + writeln!(writer, " colour_primaries: {colour_primaries}")?; + } + if let Some(transfer_characteristics) = value.transfer_characteristics { + writeln!( + writer, + " transfer_characteristics: {transfer_characteristics}" + )?; + } + if let Some(matrix_coefficients) = value.matrix_coefficients { + writeln!(writer, " matrix_coefficients: {matrix_coefficients}")?; + } + if let Some(full_range) = value.full_range { + writeln!(writer, " full_range: {full_range}")?; + } + if let Some(profile_size) = value.profile_size { + writeln!(writer, " profile_size: {profile_size}")?; + } + if let Some(unknown_size) = value.unknown_size { + writeln!(writer, " unknown_size: {unknown_size}")?; + } + } + if let Some(value) = track.media_characteristics.pixel_aspect_ratio.as_ref() { + writeln!(writer, " pixel_aspect_ratio:")?; + writeln!(writer, " h_spacing: {}", value.h_spacing)?; + writeln!(writer, " v_spacing: {}", value.v_spacing)?; + } + if let Some(value) = track.media_characteristics.field_order.as_ref() { + writeln!(writer, " field_order:")?; + writeln!(writer, " field_count: {}", value.field_count)?; + writeln!(writer, " field_ordering: {}", value.field_ordering)?; + writeln!(writer, " interlaced: {}", value.interlaced)?; + } + } Ok(()) } +fn codec_detail_yaml_fields( + codec_family: &str, + details: &TrackCodecDetails, +) -> Vec<(&'static str, String)> { + let mut fields = vec![("kind", yaml_string(codec_family))]; + match details { + TrackCodecDetails::Unknown => {} + TrackCodecDetails::Avc(details) => { + fields.push(( + "configuration_version", + details.configuration_version.to_string(), + )); + fields.push(("profile", details.profile.to_string())); + fields.push(( + "profile_compatibility", + details.profile_compatibility.to_string(), + )); + fields.push(("level", details.level.to_string())); + fields.push(("length_size", details.length_size.to_string())); + if let Some(chroma_format) = details.chroma_format { + fields.push(("chroma_format", chroma_format.to_string())); + } + if let Some(bit_depth_luma) = details.bit_depth_luma { + fields.push(("bit_depth_luma", bit_depth_luma.to_string())); + } + if let Some(bit_depth_chroma) = details.bit_depth_chroma { + fields.push(("bit_depth_chroma", bit_depth_chroma.to_string())); + } + } + TrackCodecDetails::Hevc(details) => { + fields.push(( + "configuration_version", + details.configuration_version.to_string(), + )); + fields.push(("profile_space", details.profile_space.to_string())); + fields.push(("tier_flag", details.tier_flag.to_string())); + fields.push(("profile_idc", details.profile_idc.to_string())); + fields.push(( + "profile_compatibility_mask", + details.profile_compatibility_mask.to_string(), + )); + fields.push(( + "constraint_indicator", + yaml_u8_array(&details.constraint_indicator), + )); + fields.push(("level_idc", details.level_idc.to_string())); + fields.push(( + "min_spatial_segmentation_idc", + details.min_spatial_segmentation_idc.to_string(), + )); + fields.push(("parallelism_type", details.parallelism_type.to_string())); + fields.push(("chroma_format_idc", details.chroma_format_idc.to_string())); + fields.push(("bit_depth_luma", details.bit_depth_luma.to_string())); + fields.push(("bit_depth_chroma", details.bit_depth_chroma.to_string())); + fields.push(("avg_frame_rate", details.avg_frame_rate.to_string())); + fields.push(( + "constant_frame_rate", + details.constant_frame_rate.to_string(), + )); + fields.push(( + "num_temporal_layers", + details.num_temporal_layers.to_string(), + )); + fields.push(("temporal_id_nested", details.temporal_id_nested.to_string())); + fields.push(("length_size", details.length_size.to_string())); + } + TrackCodecDetails::Av1(details) => { + fields.push(("seq_profile", details.seq_profile.to_string())); + fields.push(("seq_level_idx_0", details.seq_level_idx_0.to_string())); + fields.push(("seq_tier_0", details.seq_tier_0.to_string())); + fields.push(("bit_depth", details.bit_depth.to_string())); + fields.push(("monochrome", details.monochrome.to_string())); + fields.push(( + "chroma_subsampling_x", + details.chroma_subsampling_x.to_string(), + )); + fields.push(( + "chroma_subsampling_y", + details.chroma_subsampling_y.to_string(), + )); + fields.push(( + "chroma_sample_position", + details.chroma_sample_position.to_string(), + )); + if let Some(delay) = details.initial_presentation_delay_minus_one { + fields.push(("initial_presentation_delay_minus_one", delay.to_string())); + } + } + TrackCodecDetails::Vp8(details) | TrackCodecDetails::Vp9(details) => { + fields.push(("profile", details.profile.to_string())); + fields.push(("level", details.level.to_string())); + fields.push(("bit_depth", details.bit_depth.to_string())); + fields.push(("chroma_subsampling", details.chroma_subsampling.to_string())); + fields.push(("full_range", details.full_range.to_string())); + fields.push(("colour_primaries", details.colour_primaries.to_string())); + fields.push(( + "transfer_characteristics", + details.transfer_characteristics.to_string(), + )); + fields.push(( + "matrix_coefficients", + details.matrix_coefficients.to_string(), + )); + fields.push(( + "codec_initialization_data_size", + details.codec_initialization_data_size.to_string(), + )); + } + TrackCodecDetails::Mp4Audio(details) => { + fields.push(( + "object_type_indication", + details.object_type_indication.to_string(), + )); + fields.push(("audio_object_type", details.audio_object_type.to_string())); + fields.push(("channel_count", details.channel_count.to_string())); + if let Some(sample_rate) = details.sample_rate { + fields.push(("sample_rate", sample_rate.to_string())); + } + } + TrackCodecDetails::Opus(details) => { + fields.push(( + "output_channel_count", + details.output_channel_count.to_string(), + )); + fields.push(("pre_skip", details.pre_skip.to_string())); + fields.push(("input_sample_rate", details.input_sample_rate.to_string())); + fields.push(("output_gain", details.output_gain.to_string())); + fields.push(( + "channel_mapping_family", + details.channel_mapping_family.to_string(), + )); + if let Some(stream_count) = details.stream_count { + fields.push(("stream_count", stream_count.to_string())); + } + if let Some(coupled_count) = details.coupled_count { + fields.push(("coupled_count", coupled_count.to_string())); + } + if !details.channel_mapping.is_empty() { + fields.push(("channel_mapping", yaml_u8_array(&details.channel_mapping))); + } + } + TrackCodecDetails::Ac3(details) => { + fields.push(("sample_rate_code", details.sample_rate_code.to_string())); + fields.push(( + "bit_stream_identification", + details.bit_stream_identification.to_string(), + )); + fields.push(("bit_stream_mode", details.bit_stream_mode.to_string())); + fields.push(("audio_coding_mode", details.audio_coding_mode.to_string())); + fields.push(("lfe_on", details.lfe_on.to_string())); + fields.push(("bit_rate_code", details.bit_rate_code.to_string())); + } + TrackCodecDetails::Pcm(details) => { + fields.push(("format_flags", details.format_flags.to_string())); + fields.push(("sample_size", details.sample_size.to_string())); + } + TrackCodecDetails::XmlSubtitle(details) => { + fields.push(("namespace", yaml_string(&details.namespace))); + fields.push(("schema_location", yaml_string(&details.schema_location))); + fields.push(( + "auxiliary_mime_types", + yaml_string(&details.auxiliary_mime_types), + )); + } + TrackCodecDetails::TextSubtitle(details) => { + fields.push(("content_encoding", yaml_string(&details.content_encoding))); + fields.push(("mime_format", yaml_string(&details.mime_format))); + } + TrackCodecDetails::WebVtt(details) => { + if let Some(config) = details.config.as_ref() { + fields.push(("config", yaml_string(config))); + } + if let Some(source_label) = details.source_label.as_ref() { + fields.push(("source_label", yaml_string(source_label))); + } + } + } + + fields +} + +fn has_media_characteristics(characteristics: &TrackMediaCharacteristics) -> bool { + characteristics.declared_bitrate.is_some() + || characteristics.color.is_some() + || characteristics.pixel_aspect_ratio.is_some() + || characteristics.field_order.is_some() +} + +fn json_u8_array(values: &[u8]) -> String { + let mut rendered = String::from("["); + for (index, value) in values.iter().enumerate() { + if index != 0 { + rendered.push_str(", "); + } + rendered.push_str(&value.to_string()); + } + rendered.push(']'); + rendered +} + +fn yaml_u8_array(values: &[u8]) -> String { + json_u8_array(values) +} + fn json_string(value: &str) -> String { let mut escaped = String::from("\""); for ch in value.chars() { diff --git a/src/cli/pssh.rs b/src/cli/pssh.rs index 4b45b1f..4b0880a 100644 --- a/src/cli/pssh.rs +++ b/src/cli/pssh.rs @@ -8,13 +8,96 @@ use std::io::{self, Read, Seek, Write}; use crate::FourCc; use crate::boxes::iso23001_7::Pssh; use crate::codec::ImmutableBox; -use crate::extract::{ExtractError, extract_boxes_with_payload}; -use crate::walk::BoxPath; +use crate::extract::ExtractError; +use crate::walk::{BoxPath, WalkControl, WalkError, WalkHandle, walk_structure}; const MOOV: FourCc = FourCc::from_bytes(*b"moov"); const MOOF: FourCc = FourCc::from_bytes(*b"moof"); const PSSH: FourCc = FourCc::from_bytes(*b"pssh"); +/// Structured output format supported by the pssh-dump command. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PsshDumpFormat { + /// Pretty-printed JSON output. + Json, + /// Simple YAML output with stable field order. + Yaml, +} + +impl PsshDumpFormat { + fn parse(value: &str) -> Result, PsshDumpError> { + match value { + "text" => Ok(None), + "json" => Ok(Some(Self::Json)), + "yaml" => Ok(Some(Self::Yaml)), + other => Err(invalid_argument(format!( + "unsupported psshdump format: {other}" + ))), + } + } +} + +/// Additive selection controls for reusable `pssh` reports. +/// +/// Filters inside one category are combined with OR semantics, while different categories are +/// combined with AND semantics. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct PsshReportFilter { + /// Parsed subtree selectors that scope which `pssh` paths are eligible for inclusion. + /// + /// These reuse the existing [`BoxPath`] parser, including `*` wildcard segments and the + /// special `` marker. The filter behaves like subtree selection, so `moov` matches + /// `moov/pssh` and `` matches every discovered `pssh` box. + pub paths: Vec, + /// Protection-system UUIDs that are allowed to match. + pub system_ids: Vec<[u8; 16]>, + /// Key IDs that are allowed to match. + pub kids: Vec<[u8; 16]>, +} + +impl PsshReportFilter { + /// Returns `true` when the filter leaves the report unscoped. + pub fn is_unfiltered(&self) -> bool { + self.paths.is_empty() && self.system_ids.is_empty() && self.kids.is_empty() + } +} + +/// Top-level structured `pssh` summary report used by JSON and YAML output. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct PsshReport { + /// Parsed `pssh` entries in file order. + pub entries: Vec, +} + +/// One parsed `pssh` summary entry. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct PsshEntryReport { + /// Zero-based file-order index used by the text formatter. + pub index: usize, + /// Slash-delimited path from the file root to the matched `pssh` box. + pub path: String, + /// Absolute file offset of the `pssh` header. + pub offset: u64, + /// Total box size including the header. + pub size: u64, + /// Parsed full-box version. + pub version: u8, + /// Parsed full-box flags. + pub flags: u32, + /// Formatted protection-system UUID. + pub system_id: String, + /// Parsed KID count field. + pub kid_count: u32, + /// Formatted key IDs carried by version `1` entries. + pub kids: Vec, + /// Parsed data-size field. + pub data_size: u32, + /// Raw `Data` field bytes. + pub data_bytes: Vec, + /// Base64 encoding of the exact serialized box bytes, including the header. + pub raw_box_base64: String, +} + /// Runs the pssh-dump subcommand with `args`, writing output to `stdout`. pub fn run(args: &[String], stdout: &mut W, stderr: &mut E) -> i32 where @@ -39,59 +122,637 @@ pub fn write_usage(writer: &mut W) -> io::Result<()> where W: Write, { - writeln!(writer, "USAGE: mp4forge psshdump INPUT.mp4") + writeln!(writer, "USAGE: mp4forge psshdump [OPTIONS] INPUT.mp4")?; + writeln!(writer)?; + writeln!(writer, "OPTIONS:")?; + writeln!( + writer, + " -format Output format (default: text)" + )?; + writeln!( + writer, + " -path Limit results to matching parsed subtrees (repeatable)" + )?; + writeln!( + writer, + " -system-id Limit results to matching protection-system IDs (repeatable)" + )?; + writeln!( + writer, + " -kid Limit results to matching key IDs (repeatable)" + )?; + Ok(()) } -/// Writes formatted `pssh` summaries discovered in `reader`. +/// Writes the existing human-readable `pssh` summaries discovered in `reader`. pub fn dump_pssh(reader: &mut R, writer: &mut W) -> Result<(), PsshDumpError> where R: Read + Seek, W: Write, { - let extracted = extract_boxes_with_payload( - reader, - None, - &[BoxPath::from([MOOV, PSSH]), BoxPath::from([MOOF, PSSH])], - )?; - - for (index, entry) in extracted.iter().enumerate() { - let pssh = entry - .payload - .as_ref() - .as_any() - .downcast_ref::() - .ok_or(PsshDumpError::UnexpectedPayloadType)?; - - entry.info.seek_to_start(reader)?; - let raw_len = - usize::try_from(entry.info.size()).map_err(|_| PsshDumpError::NumericOverflow)?; - let mut raw = vec![0_u8; raw_len]; - reader.read_exact(&mut raw)?; - - writeln!(writer, "{index}:")?; - writeln!(writer, " offset: {}", entry.info.offset())?; - writeln!(writer, " size: {}", entry.info.size())?; - writeln!(writer, " version: {}", pssh.version())?; - writeln!(writer, " flags: 0x{:06x}", pssh.flags())?; - writeln!(writer, " systemId: {}", format_uuid(&pssh.system_id))?; - writeln!(writer, " dataSize: {}", pssh.data_size)?; - writeln!(writer, " base64: \"{}\"", encode_base64(&raw))?; - writeln!(writer)?; + let report = build_pssh_report(reader)?; + write_text_report(writer, &report) +} + +/// Builds a reusable structured `pssh` summary report from one MP4 reader. +pub fn build_pssh_report(reader: &mut R) -> Result +where + R: Read + Seek, +{ + build_pssh_report_with_filters(reader, &PsshReportFilter::default()) +} + +/// Writes the existing human-readable `pssh` summaries discovered in `reader`, limited by +/// `filters`. +pub fn dump_pssh_with_filters( + reader: &mut R, + filters: &PsshReportFilter, + writer: &mut W, +) -> Result<(), PsshDumpError> +where + R: Read + Seek, + W: Write, +{ + let report = build_pssh_report_with_filters(reader, filters)?; + write_text_report(writer, &report) +} + +/// Builds a reusable structured `pssh` summary report from one MP4 reader, limited by `filters`. +pub fn build_pssh_report_with_filters( + reader: &mut R, + filters: &PsshReportFilter, +) -> Result +where + R: Read + Seek, +{ + let mut collector = PsshReportCollector { + filters, + next_index: 0, + entries: Vec::new(), + build_error: None, + }; + let result = walk_structure(reader, |handle| { + collect_pssh_report_entry(handle, &mut collector) + }); + if let Some(error) = collector.build_error { + return Err(error); } + result.map_err(walk_error_as_extract)?; + Ok(PsshReport { + entries: collector.entries, + }) +} - Ok(()) +/// Writes a structured `pssh` `report` in the selected `format`. +pub fn write_pssh_report( + writer: &mut W, + report: &PsshReport, + format: PsshDumpFormat, +) -> Result<(), PsshDumpError> +where + W: Write, +{ + match format { + PsshDumpFormat::Json => write_json_pssh_report(writer, report).map_err(PsshDumpError::Io), + PsshDumpFormat::Yaml => write_yaml_pssh_report(writer, report).map_err(PsshDumpError::Io), + } +} + +/// Writes one MP4 reader as a structured `pssh` JSON or YAML report. +pub fn dump_pssh_structured( + reader: &mut R, + format: PsshDumpFormat, + writer: &mut W, +) -> Result<(), PsshDumpError> +where + R: Read + Seek, + W: Write, +{ + dump_pssh_structured_with_filters(reader, &PsshReportFilter::default(), format, writer) +} + +/// Writes one MP4 reader as a structured `pssh` JSON or YAML report, limited by `filters`. +pub fn dump_pssh_structured_with_filters( + reader: &mut R, + filters: &PsshReportFilter, + format: PsshDumpFormat, + writer: &mut W, +) -> Result<(), PsshDumpError> +where + R: Read + Seek, + W: Write, +{ + let report = build_pssh_report_with_filters(reader, filters)?; + write_pssh_report(writer, &report, format) } fn run_inner(args: &[String], stdout: &mut W) -> Result<(), PsshDumpError> where W: Write, { - if args.len() != 1 { + let mut format = None; + let mut filters = PsshReportFilter::default(); + let mut input_path = None; + let mut index = 0usize; + while index < args.len() { + match args[index].as_str() { + "-format" | "--format" => { + let Some(value) = args.get(index + 1) else { + return Err(invalid_argument("missing value for -format")); + }; + format = PsshDumpFormat::parse(value)?; + index += 2; + } + "-path" | "--path" => { + let Some(value) = args.get(index + 1) else { + return Err(invalid_argument("missing value for -path")); + }; + let path = + BoxPath::parse(value).map_err(|error| invalid_argument(error.to_string()))?; + filters.paths.push(path); + index += 2; + } + "-system-id" | "--system-id" => { + let Some(value) = args.get(index + 1) else { + return Err(invalid_argument("missing value for -system-id")); + }; + let system_id = parse_uuid_filter(value, "system ID")?; + filters.system_ids.push(system_id); + index += 2; + } + "-kid" | "--kid" => { + let Some(value) = args.get(index + 1) else { + return Err(invalid_argument("missing value for -kid")); + }; + let kid = parse_uuid_filter(value, "KID")?; + filters.kids.push(kid); + index += 2; + } + "-h" | "--help" => return Err(PsshDumpError::UsageRequested), + value if value.starts_with('-') => { + return Err(invalid_argument(format!( + "unknown psshdump option: {value}" + ))); + } + value => { + if input_path.is_some() { + return Err(invalid_argument("psshdump accepts exactly one input path")); + } + input_path = Some(value); + index += 1; + } + } + } + + let Some(input_path) = input_path else { return Err(PsshDumpError::UsageRequested); + }; + + let mut file = File::open(input_path)?; + match format { + Some(format) => dump_pssh_structured_with_filters(&mut file, &filters, format, stdout), + None => dump_pssh_with_filters(&mut file, &filters, stdout), } +} - let mut file = File::open(&args[0])?; - dump_pssh(&mut file, stdout) +struct PsshReportCollector<'a> { + filters: &'a PsshReportFilter, + next_index: usize, + entries: Vec, + build_error: Option, +} + +fn collect_pssh_report_entry( + handle: &mut WalkHandle<'_, R>, + collector: &mut PsshReportCollector<'_>, +) -> Result +where + R: Read + Seek, +{ + if should_descend_pssh_path(handle.path().as_slice()) { + return Ok(WalkControl::Descend); + } + + if !is_pssh_path(handle.path().as_slice()) { + return Ok(WalkControl::Continue); + } + + let entry_index = collector.next_index; + collector.next_index += 1; + if !matches_path_filters(collector.filters, handle.path()) { + return Ok(WalkControl::Continue); + } + + let (payload, _) = handle.read_payload()?; + let Some(pssh) = payload.as_ref().as_any().downcast_ref::() else { + collector.build_error = Some(PsshDumpError::UnexpectedPayloadType); + return Err(io::Error::other("unexpected pssh payload type").into()); + }; + if !matches_system_id_filters(collector.filters, &pssh.system_id) + || !matches_kid_filters(collector.filters, &pssh.kids) + { + return Ok(WalkControl::Continue); + } + + let payload_bytes = read_payload_bytes(handle, &mut collector.build_error)?; + let mut raw_box = handle.info().encode(); + raw_box.extend_from_slice(&payload_bytes); + + collector.entries.push(PsshEntryReport { + index: entry_index, + path: handle.path().to_string(), + offset: handle.info().offset(), + size: handle.info().size(), + version: pssh.version(), + flags: pssh.flags(), + system_id: format_uuid(&pssh.system_id), + kid_count: pssh.kid_count, + kids: pssh.kids.iter().map(|kid| format_uuid(&kid.kid)).collect(), + data_size: pssh.data_size, + data_bytes: pssh.data.clone(), + raw_box_base64: encode_base64(&raw_box), + }); + + Ok(WalkControl::Continue) +} + +fn should_descend_pssh_path(path: &[FourCc]) -> bool { + matches!(path, [MOOV] | [MOOF]) +} + +fn is_pssh_path(path: &[FourCc]) -> bool { + matches!(path, [MOOV, PSSH] | [MOOF, PSSH]) +} + +fn matches_path_filters(filters: &PsshReportFilter, entry_path: &BoxPath) -> bool { + filters.paths.is_empty() + || filters.paths.iter().any(|path| { + let selected_vs_entry = path.compare_with(entry_path); + selected_vs_entry.exact_match || selected_vs_entry.forward_match + }) +} + +fn matches_system_id_filters(filters: &PsshReportFilter, system_id: &[u8; 16]) -> bool { + filters.system_ids.is_empty() + || filters + .system_ids + .iter() + .any(|candidate| candidate == system_id) +} + +fn matches_kid_filters( + filters: &PsshReportFilter, + kids: &[crate::boxes::iso23001_7::PsshKid], +) -> bool { + filters.kids.is_empty() + || kids + .iter() + .any(|kid| filters.kids.iter().any(|candidate| candidate == &kid.kid)) +} + +fn parse_uuid_filter(value: &str, label: &str) -> Result<[u8; 16], PsshDumpError> { + let mut digits = String::with_capacity(32); + for ch in value.chars() { + if ch == '-' { + continue; + } + digits.push(ch); + } + + if digits.len() != 32 { + return Err(invalid_argument(format!( + "invalid {label}: expected 32 hexadecimal digits with optional hyphens" + ))); + } + + let mut parsed = [0u8; 16]; + let bytes = digits.as_bytes(); + for (index, slot) in parsed.iter_mut().enumerate() { + let high = decode_hex_nibble(bytes[index * 2]).ok_or_else(|| { + invalid_argument(format!( + "invalid {label}: expected 32 hexadecimal digits with optional hyphens" + )) + })?; + let low = decode_hex_nibble(bytes[index * 2 + 1]).ok_or_else(|| { + invalid_argument(format!( + "invalid {label}: expected 32 hexadecimal digits with optional hyphens" + )) + })?; + *slot = (high << 4) | low; + } + + Ok(parsed) +} + +fn decode_hex_nibble(value: u8) -> Option { + match value { + b'0'..=b'9' => Some(value - b'0'), + b'a'..=b'f' => Some(value - b'a' + 10), + b'A'..=b'F' => Some(value - b'A' + 10), + _ => None, + } +} + +fn read_payload_bytes( + handle: &mut WalkHandle<'_, R>, + build_error: &mut Option, +) -> Result, WalkError> +where + R: Read + Seek, +{ + let payload_size = handle.info().payload_size().map_err(WalkError::Header)?; + let capacity = match usize::try_from(payload_size) { + Ok(capacity) => capacity, + Err(_) => { + *build_error = Some(PsshDumpError::NumericOverflow); + return Err(io::Error::other("payload too large").into()); + } + }; + let mut payload = Vec::with_capacity(capacity); + handle.read_data(&mut payload)?; + Ok(payload) +} + +fn write_text_report(writer: &mut W, report: &PsshReport) -> Result<(), PsshDumpError> +where + W: Write, +{ + for entry in &report.entries { + writeln!(writer, "{}:", entry.index)?; + writeln!(writer, " offset: {}", entry.offset)?; + writeln!(writer, " size: {}", entry.size)?; + writeln!(writer, " version: {}", entry.version)?; + writeln!(writer, " flags: 0x{:06x}", entry.flags)?; + writeln!(writer, " systemId: {}", entry.system_id)?; + writeln!(writer, " dataSize: {}", entry.data_size)?; + writeln!(writer, " base64: \"{}\"", entry.raw_box_base64)?; + writeln!(writer)?; + } + + Ok(()) +} + +fn write_json_pssh_report(writer: &mut W, report: &PsshReport) -> io::Result<()> +where + W: Write, +{ + writeln!(writer, "{{")?; + writeln!(writer, " \"Entries\": [")?; + for (index, entry) in report.entries.iter().enumerate() { + write_json_pssh_entry(writer, entry, 2)?; + let trailing = if index + 1 == report.entries.len() { + "" + } else { + "," + }; + writeln!(writer, "{trailing}")?; + } + writeln!(writer, " ]")?; + writeln!(writer, "}}") +} + +fn write_json_pssh_entry( + writer: &mut W, + entry: &PsshEntryReport, + indent_level: usize, +) -> io::Result<()> +where + W: Write, +{ + let indent = " ".repeat(indent_level); + writeln!(writer, "{indent}{{")?; + write_json_field( + writer, + indent_level + 1, + "Index", + &entry.index.to_string(), + true, + )?; + write_json_field( + writer, + indent_level + 1, + "Path", + &json_string(&entry.path), + true, + )?; + write_json_field( + writer, + indent_level + 1, + "Offset", + &entry.offset.to_string(), + true, + )?; + write_json_field( + writer, + indent_level + 1, + "Size", + &entry.size.to_string(), + true, + )?; + write_json_field( + writer, + indent_level + 1, + "Version", + &entry.version.to_string(), + true, + )?; + write_json_field( + writer, + indent_level + 1, + "Flags", + &entry.flags.to_string(), + true, + )?; + write_json_field( + writer, + indent_level + 1, + "SystemId", + &json_string(&entry.system_id), + true, + )?; + write_json_field( + writer, + indent_level + 1, + "KidCount", + &entry.kid_count.to_string(), + true, + )?; + write_json_string_array_field(writer, indent_level + 1, "Kids", &entry.kids, true)?; + write_json_field( + writer, + indent_level + 1, + "DataSize", + &entry.data_size.to_string(), + true, + )?; + write_json_u8_array_field( + writer, + indent_level + 1, + "DataBytes", + &entry.data_bytes, + true, + )?; + write_json_field( + writer, + indent_level + 1, + "RawBoxBase64", + &json_string(&entry.raw_box_base64), + false, + )?; + write!(writer, "{indent}}}") +} + +fn write_json_u8_array_field( + writer: &mut W, + indent_level: usize, + name: &str, + values: &[u8], + trailing_comma: bool, +) -> io::Result<()> +where + W: Write, +{ + write_json_array_field( + writer, + indent_level, + name, + &values.iter().map(u8::to_string).collect::>(), + trailing_comma, + ) +} + +fn write_json_string_array_field( + writer: &mut W, + indent_level: usize, + name: &str, + values: &[String], + trailing_comma: bool, +) -> io::Result<()> +where + W: Write, +{ + write_json_array_field( + writer, + indent_level, + name, + &values + .iter() + .map(|value| json_string(value)) + .collect::>(), + trailing_comma, + ) +} + +fn write_json_array_field( + writer: &mut W, + indent_level: usize, + name: &str, + values: &[String], + trailing_comma: bool, +) -> io::Result<()> +where + W: Write, +{ + let trailing = if trailing_comma { "," } else { "" }; + writeln!(writer, "{}\"{name}\": [", " ".repeat(indent_level))?; + for (index, value) in values.iter().enumerate() { + let trailing_value = if index + 1 == values.len() { "" } else { "," }; + writeln!( + writer, + "{}{value}{trailing_value}", + " ".repeat(indent_level + 1) + )?; + } + writeln!(writer, "{}]{trailing}", " ".repeat(indent_level)) +} + +fn write_json_field( + writer: &mut W, + indent_level: usize, + name: &str, + value: &str, + trailing_comma: bool, +) -> io::Result<()> +where + W: Write, +{ + let trailing = if trailing_comma { "," } else { "" }; + writeln!( + writer, + "{}\"{name}\": {value}{trailing}", + " ".repeat(indent_level) + ) +} + +fn write_yaml_pssh_report(writer: &mut W, report: &PsshReport) -> io::Result<()> +where + W: Write, +{ + writeln!(writer, "entries:")?; + for entry in &report.entries { + write_yaml_pssh_entry(writer, entry, 0)?; + } + Ok(()) +} + +fn write_yaml_pssh_entry( + writer: &mut W, + entry: &PsshEntryReport, + indent_level: usize, +) -> io::Result<()> +where + W: Write, +{ + let indent = " ".repeat(indent_level); + let child_indent = " ".repeat(indent_level + 1); + writeln!(writer, "{indent}- index: {}", entry.index)?; + writeln!(writer, "{child_indent}path: {}", yaml_string(&entry.path))?; + writeln!(writer, "{child_indent}offset: {}", entry.offset)?; + writeln!(writer, "{child_indent}size: {}", entry.size)?; + writeln!(writer, "{child_indent}version: {}", entry.version)?; + writeln!(writer, "{child_indent}flags: {}", entry.flags)?; + writeln!( + writer, + "{child_indent}system_id: {}", + yaml_string(&entry.system_id) + )?; + writeln!(writer, "{child_indent}kid_count: {}", entry.kid_count)?; + if entry.kids.is_empty() { + writeln!(writer, "{child_indent}kids: []")?; + } else { + writeln!(writer, "{child_indent}kids:")?; + for kid in &entry.kids { + writeln!( + writer, + "{}- {}", + " ".repeat(indent_level + 2), + yaml_string(kid) + )?; + } + } + writeln!(writer, "{child_indent}data_size: {}", entry.data_size)?; + if entry.data_bytes.is_empty() { + writeln!(writer, "{child_indent}data_bytes: []")?; + } else { + writeln!(writer, "{child_indent}data_bytes:")?; + for value in &entry.data_bytes { + writeln!(writer, "{}- {value}", " ".repeat(indent_level + 2))?; + } + } + writeln!( + writer, + "{child_indent}raw_box_base64: {}", + yaml_string(&entry.raw_box_base64) + )?; + Ok(()) +} + +fn walk_error_as_extract(error: WalkError) -> PsshDumpError { + PsshDumpError::Extract(ExtractError::from(error)) +} + +fn invalid_argument(message: impl Into) -> PsshDumpError { + PsshDumpError::Io(io::Error::new(io::ErrorKind::InvalidInput, message.into())) } fn format_uuid(value: &[u8; 16]) -> String { @@ -143,6 +804,36 @@ fn encode_base64(data: &[u8]) -> String { encoded } +fn json_string(value: &str) -> String { + let mut escaped = String::from("\""); + for ch in value.chars() { + match ch { + '"' => escaped.push_str("\\\""), + '\\' => escaped.push_str("\\\\"), + '\n' => escaped.push_str("\\n"), + '\r' => escaped.push_str("\\r"), + '\t' => escaped.push_str("\\t"), + ch if ch.is_control() => escaped.push_str(&format!("\\u{:04x}", ch as u32)), + ch => escaped.push(ch), + } + } + escaped.push('"'); + escaped +} + +fn yaml_string(value: &str) -> String { + if !value.is_empty() + && value.trim() == value + && value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_' | '/' | ' ')) + { + value.to_string() + } else { + format!("'{}'", value.replace('\'', "''")) + } +} + /// Errors raised while parsing `psshdump` arguments or formatting summaries. #[derive(Debug)] pub enum PsshDumpError { diff --git a/src/codec.rs b/src/codec.rs index 41a3dc8..96e81e4 100644 --- a/src/codec.rs +++ b/src/codec.rs @@ -626,6 +626,11 @@ fn select_hooks<'a>( } /// Owned field value transferred between descriptor code and concrete boxes. +#[cfg_attr( + feature = "serde", + derive(serde::Serialize, serde::Deserialize), + serde(tag = "kind", content = "value", rename_all = "snake_case") +)] #[derive(Clone, Debug, PartialEq, Eq)] pub enum FieldValue { Unsigned(u64), diff --git a/src/fourcc.rs b/src/fourcc.rs index 019fe17..30f2a60 100644 --- a/src/fourcc.rs +++ b/src/fourcc.rs @@ -103,6 +103,61 @@ impl fmt::Debug for FourCc { } } +#[cfg(feature = "serde")] +impl serde::Serialize for FourCc { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.serde_string()) + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for FourCc { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = ::deserialize(deserializer)?; + Self::from_serde_str(&value).map_err(serde::de::Error::custom) + } +} + +#[cfg(feature = "serde")] +impl FourCc { + // Keep the serde form lossless instead of reusing the display formatter's `(c)` expansion. + fn serde_string(self) -> String { + if self.0.iter().all(|byte| matches!(byte, 0x20..=0x7e)) { + self.0.iter().map(|byte| char::from(*byte)).collect() + } else { + format!( + "0x{:02x}{:02x}{:02x}{:02x}", + self.0[0], self.0[1], self.0[2], self.0[3] + ) + } + } + + fn from_serde_str(value: &str) -> Result { + if let Some(hex) = value + .strip_prefix("0x") + .or_else(|| value.strip_prefix("0X")) + { + if hex.len() != 8 { + return Err(format!( + "hex fourcc values must contain exactly 8 digits, got {}", + hex.len() + )); + } + let parsed = u32::from_str_radix(hex, 16) + .map_err(|error| format!("invalid hex fourcc value: {error}"))?; + return Ok(Self::from_u32(parsed)); + } + + Self::try_from(value).map_err(|error| error.to_string()) + } +} + /// Error returned when a string does not contain exactly four bytes. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct ParseFourCcError { diff --git a/src/probe.rs b/src/probe.rs index 01cdcef..69edaae 100644 --- a/src/probe.rs +++ b/src/probe.rs @@ -8,11 +8,19 @@ use std::io::{self, Cursor, Read, Seek, SeekFrom}; use crate::BoxInfo; use crate::FourCc; use crate::bitio::BitReader; +use crate::boxes::av1::AV1CodecConfiguration; +use crate::boxes::etsi_ts_102_366::Dac3; use crate::boxes::iso14496_12::{ - AVCDecoderConfiguration, AudioSampleEntry, Co64, Ctts, Mvhd, Stco, Stsc, Stsz, Stts, Tfdt, - Tfhd, Tkhd, Trun, VisualSampleEntry, + AVCDecoderConfiguration, AudioSampleEntry, Btrt, Co64, Colr, Ctts, Fiel, + HEVCDecoderConfiguration, Mvhd, Pasp, Stco, Stsc, Stsz, Stts, TextSubtitleSampleEntry, Tfdt, + Tfhd, Tkhd, Trun, VisualSampleEntry, XMLSubtitleSampleEntry, }; +use crate::boxes::iso14496_12::{Frma, Hdlr, Schm}; use crate::boxes::iso14496_14::Esds; +use crate::boxes::iso14496_30::{WebVTTConfigurationBox, WebVTTSourceLabelBox}; +use crate::boxes::iso23001_5::PcmC; +use crate::boxes::opus::DOps; +use crate::boxes::vp::VpCodecConfiguration; use crate::codec::{CodecBox, CodecError, ImmutableBox, unmarshal}; use crate::extract::{ExtractError, ExtractedBox, extract_boxes, extract_boxes_with_payload}; use crate::header::HeaderError; @@ -28,17 +36,48 @@ const TKHD: FourCc = FourCc::from_bytes(*b"tkhd"); const EDTS: FourCc = FourCc::from_bytes(*b"edts"); const ELST: FourCc = FourCc::from_bytes(*b"elst"); const MDIA: FourCc = FourCc::from_bytes(*b"mdia"); +const HDLR: FourCc = FourCc::from_bytes(*b"hdlr"); const MDHD: FourCc = FourCc::from_bytes(*b"mdhd"); const MINF: FourCc = FourCc::from_bytes(*b"minf"); const STBL: FourCc = FourCc::from_bytes(*b"stbl"); const STSD: FourCc = FourCc::from_bytes(*b"stsd"); const AVC1: FourCc = FourCc::from_bytes(*b"avc1"); const AVCC: FourCc = FourCc::from_bytes(*b"avcC"); +const HEV1: FourCc = FourCc::from_bytes(*b"hev1"); +const HVC1: FourCc = FourCc::from_bytes(*b"hvc1"); +const HVCC: FourCc = FourCc::from_bytes(*b"hvcC"); +const AV01: FourCc = FourCc::from_bytes(*b"av01"); +const AV1C: FourCc = FourCc::from_bytes(*b"av1C"); +const VP08: FourCc = FourCc::from_bytes(*b"vp08"); +const VP09: FourCc = FourCc::from_bytes(*b"vp09"); +const VPCC: FourCc = FourCc::from_bytes(*b"vpcC"); const ENCV: FourCc = FourCc::from_bytes(*b"encv"); +const BTRT: FourCc = FourCc::from_bytes(*b"btrt"); +const COLR: FourCc = FourCc::from_bytes(*b"colr"); +const FIEL: FourCc = FourCc::from_bytes(*b"fiel"); +const PASP: FourCc = FourCc::from_bytes(*b"pasp"); const MP4A: FourCc = FourCc::from_bytes(*b"mp4a"); +const OPUS: FourCc = FourCc::from_bytes(*b"Opus"); +const DOPS: FourCc = FourCc::from_bytes(*b"dOps"); +const AC_3: FourCc = FourCc::from_bytes(*b"ac-3"); +const DAC3: FourCc = FourCc::from_bytes(*b"dac3"); +const IPCM: FourCc = FourCc::from_bytes(*b"ipcm"); +const FPCM: FourCc = FourCc::from_bytes(*b"fpcm"); +const PCMC: FourCc = FourCc::from_bytes(*b"pcmC"); const WAVE: FourCc = FourCc::from_bytes(*b"wave"); const ESDS: FourCc = FourCc::from_bytes(*b"esds"); const ENCA: FourCc = FourCc::from_bytes(*b"enca"); +const STPP: FourCc = FourCc::from_bytes(*b"stpp"); +const SBTT: FourCc = FourCc::from_bytes(*b"sbtt"); +const WVTT: FourCc = FourCc::from_bytes(*b"wvtt"); +const VTTC_CONFIG: FourCc = FourCc::from_bytes(*b"vttC"); +const VLAB: FourCc = FourCc::from_bytes(*b"vlab"); +const COLR_NCLX: FourCc = FourCc::from_bytes(*b"nclx"); +const COLR_RICC: FourCc = FourCc::from_bytes(*b"rICC"); +const COLR_PROF: FourCc = FourCc::from_bytes(*b"prof"); +const SINF: FourCc = FourCc::from_bytes(*b"sinf"); +const FRMA: FourCc = FourCc::from_bytes(*b"frma"); +const SCHM: FourCc = FourCc::from_bytes(*b"schm"); const STCO: FourCc = FourCc::from_bytes(*b"stco"); const CO64: FourCc = FourCc::from_bytes(*b"co64"); const STTS: FourCc = FourCc::from_bytes(*b"stts"); @@ -86,6 +125,48 @@ impl Default for ProbeInfo { } } +/// Additive controls for eager probe expansion. +/// +/// The existing [`probe`], [`probe_detailed`], and [`probe_codec_detailed`] entry points continue +/// to use the full eager behavior. Callers that need a lighter-weight summary can opt into the +/// companion `*_with_options` entry points and disable the expensive expansions they do not need. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ProbeOptions { + /// Whether to expand per-sample timing, composition-offset, and size data from `stts`, `ctts`, + /// and `stsz`. + pub expand_samples: bool, + /// Whether to expand per-chunk offsets and sample counts from `stco`/`co64` and `stsc`. + pub expand_chunks: bool, + /// Whether to aggregate fragmented segment summaries from `moof` boxes. + pub include_segments: bool, +} + +impl ProbeOptions { + /// Returns the existing eager probe behavior. + pub const fn full() -> Self { + Self { + expand_samples: true, + expand_chunks: true, + include_segments: true, + } + } + + /// Returns a lighter-weight probe behavior for large-file inspection. + pub const fn lightweight() -> Self { + Self { + expand_samples: false, + expand_chunks: false, + include_segments: false, + } + } +} + +impl Default for ProbeOptions { + fn default() -> Self { + Self::full() + } +} + /// Summary of one logical media track. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct TrackInfo { @@ -111,6 +192,520 @@ pub struct TrackInfo { pub mp4a: Option, } +/// Additive detailed probe summary that extends [`ProbeInfo`] without changing its public shape. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DetailedProbeInfo { + /// Major brand from the root `ftyp` box. + pub major_brand: FourCc, + /// Minor version from the root `ftyp` box. + pub minor_version: u32, + /// Compatible brands listed by the root `ftyp` box. + pub compatible_brands: Vec, + /// Whether the `moov` box appears before the first `mdat`. + pub fast_start: bool, + /// Movie timescale from `mvhd`. + pub timescale: u32, + /// Movie duration from `mvhd`. + pub duration: u64, + /// Per-track detailed summaries extracted from `trak` boxes. + pub tracks: Vec, + /// Fragment summaries extracted from `moof` boxes. + pub segments: Vec, +} + +impl Default for DetailedProbeInfo { + fn default() -> Self { + Self { + major_brand: FourCc::ANY, + minor_version: 0, + compatible_brands: Vec::new(), + fast_start: false, + timescale: 0, + duration: 0, + tracks: Vec::new(), + segments: Vec::new(), + } + } +} + +/// Additive per-track summary that extends [`TrackInfo`] with richer sample-entry details. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct DetailedTrackInfo { + /// Backwards-compatible coarse summary preserved from [`TrackInfo`]. + pub summary: TrackInfo, + /// Normalized codec family derived from the sample entry or protected original format. + pub codec_family: TrackCodecFamily, + /// Handler type from `hdlr` when present. + pub handler_type: Option, + /// ISO-639-2 language code derived from `mdhd` when present. + pub language: Option, + /// Sample-entry box type found under `stsd`, including encrypted wrappers such as `encv`. + pub sample_entry_type: Option, + /// Original-format sample-entry type from `frma` when the track is protected. + pub original_format: Option, + /// Protection-scheme summary from `schm` when the track is protected. + pub protection_scheme: Option, + /// Display width from the visual sample entry when present. + pub display_width: Option, + /// Display height from the visual sample entry when present. + pub display_height: Option, + /// Channel count from the audio sample entry when present. + pub channel_count: Option, + /// Integer sample rate from the audio sample entry when present. + pub sample_rate: Option, +} + +/// Additive detailed probe summary that extends [`DetailedProbeInfo`] with codec-specific +/// configuration details. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CodecDetailedProbeInfo { + /// Major brand from the root `ftyp` box. + pub major_brand: FourCc, + /// Minor version from the root `ftyp` box. + pub minor_version: u32, + /// Compatible brands listed by the root `ftyp` box. + pub compatible_brands: Vec, + /// Whether the `moov` box appears before the first `mdat`. + pub fast_start: bool, + /// Movie timescale from `mvhd`. + pub timescale: u32, + /// Movie duration from `mvhd`. + pub duration: u64, + /// Per-track detailed summaries extracted from `trak` boxes. + pub tracks: Vec, + /// Fragment summaries extracted from `moof` boxes. + pub segments: Vec, +} + +impl Default for CodecDetailedProbeInfo { + fn default() -> Self { + Self { + major_brand: FourCc::ANY, + minor_version: 0, + compatible_brands: Vec::new(), + fast_start: false, + timescale: 0, + duration: 0, + tracks: Vec::new(), + segments: Vec::new(), + } + } +} + +/// Additive per-track summary that extends [`DetailedTrackInfo`] with parsed codec-specific +/// configuration. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct CodecDetailedTrackInfo { + /// Backwards-compatible detailed track summary preserved from [`DetailedTrackInfo`]. + pub summary: DetailedTrackInfo, + /// Parsed codec-specific configuration when it is available for the track family. + pub codec_details: TrackCodecDetails, +} + +/// Additive detailed probe summary that extends [`CodecDetailedProbeInfo`] with media +/// characteristics already parsed by the crate. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MediaCharacteristicsProbeInfo { + /// Major brand from the root `ftyp` box. + pub major_brand: FourCc, + /// Minor version from the root `ftyp` box. + pub minor_version: u32, + /// Compatible brands listed by the root `ftyp` box. + pub compatible_brands: Vec, + /// Whether the `moov` box appears before the first `mdat`. + pub fast_start: bool, + /// Movie timescale from `mvhd`. + pub timescale: u32, + /// Movie duration from `mvhd`. + pub duration: u64, + /// Per-track detailed summaries extracted from `trak` boxes. + pub tracks: Vec, + /// Fragment summaries extracted from `moof` boxes. + pub segments: Vec, +} + +impl Default for MediaCharacteristicsProbeInfo { + fn default() -> Self { + Self { + major_brand: FourCc::ANY, + minor_version: 0, + compatible_brands: Vec::new(), + fast_start: false, + timescale: 0, + duration: 0, + tracks: Vec::new(), + segments: Vec::new(), + } + } +} + +/// Additive per-track summary that extends [`DetailedTrackInfo`] with parsed codec and media +/// characteristics. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct MediaCharacteristicsTrackInfo { + /// Backwards-compatible detailed track summary preserved from [`DetailedTrackInfo`]. + pub summary: DetailedTrackInfo, + /// Parsed codec-specific configuration when it is available for the track family. + pub codec_details: TrackCodecDetails, + /// Sample-entry media characteristics already parsed by the crate. + pub media_characteristics: TrackMediaCharacteristics, +} + +/// Media characteristics derived from sample-entry side boxes such as `btrt`, `colr`, `pasp`, +/// and `fiel`. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct TrackMediaCharacteristics { + /// Declared buffering and bitrate data from `btrt` when present. + pub declared_bitrate: Option, + /// Declared colorimetry data from `colr` when present. + pub color: Option, + /// Declared pixel aspect ratio from `pasp` when present. + pub pixel_aspect_ratio: Option, + /// Declared field-order hint from `fiel` when present. + pub field_order: Option, +} + +/// Declared buffering and bitrate values parsed from `btrt`. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct DeclaredBitrateInfo { + /// Decoder buffer size from `BufferSizeDB`. + pub buffer_size_db: u32, + /// Peak bitrate from `MaxBitrate`. + pub max_bitrate: u32, + /// Average bitrate from `AvgBitrate`. + pub avg_bitrate: u32, +} + +/// Declared color information parsed from `colr`. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ColorInfo { + /// Active colour-type discriminator such as `nclx`, `rICC`, or `prof`. + pub colour_type: FourCc, + /// Colour-primaries code when `ColourType` is `nclx`. + pub colour_primaries: Option, + /// Transfer-characteristics code when `ColourType` is `nclx`. + pub transfer_characteristics: Option, + /// Matrix-coefficients code when `ColourType` is `nclx`. + pub matrix_coefficients: Option, + /// Full-range flag when `ColourType` is `nclx`. + pub full_range: Option, + /// Embedded ICC profile size when `ColourType` stores profile bytes. + pub profile_size: Option, + /// Opaque payload size for unrecognized colour types. + pub unknown_size: Option, +} + +impl Default for ColorInfo { + fn default() -> Self { + Self { + colour_type: FourCc::ANY, + colour_primaries: None, + transfer_characteristics: None, + matrix_coefficients: None, + full_range: None, + profile_size: None, + unknown_size: None, + } + } +} + +/// Declared pixel aspect ratio parsed from `pasp`. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct PixelAspectRatioInfo { + /// Horizontal spacing numerator from `HSpacing`. + pub h_spacing: u32, + /// Vertical spacing denominator from `VSpacing`. + pub v_spacing: u32, +} + +/// Declared field-order hint parsed from `fiel`. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct FieldOrderInfo { + /// Stored field count from `FieldCount`. + pub field_count: u8, + /// Stored field-ordering code from `FieldOrdering`. + pub field_ordering: u8, + /// Whether the hint indicates multiple interlaced fields. + pub interlaced: bool, +} + +/// Parsed codec-specific configuration for one recognized track family. +#[cfg_attr( + feature = "serde", + derive(serde::Serialize, serde::Deserialize), + serde(tag = "kind", content = "value", rename_all = "snake_case") +)] +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub enum TrackCodecDetails { + /// No codec-specific configuration was parsed for the track. + #[default] + Unknown, + /// AVC decoder configuration parsed from `avcC`. + Avc(AvcCodecDetails), + /// HEVC decoder configuration parsed from `hvcC`. + Hevc(HevcCodecDetails), + /// AV1 decoder configuration parsed from `av1C`. + Av1(Av1CodecDetails), + /// VP8 decoder configuration parsed from `vpcC`. + Vp8(VpCodecDetails), + /// VP9 decoder configuration parsed from `vpcC`. + Vp9(VpCodecDetails), + /// MPEG-4 audio configuration parsed from `esds`. + Mp4Audio(Mp4AudioCodecDetails), + /// Opus decoder configuration parsed from `dOps`. + Opus(OpusCodecDetails), + /// AC-3 decoder configuration parsed from `dac3`. + Ac3(Ac3CodecDetails), + /// PCM configuration parsed from `pcmC`. + Pcm(PcmCodecDetails), + /// XML subtitle metadata parsed from `stpp`. + XmlSubtitle(XmlSubtitleCodecDetails), + /// Text subtitle metadata parsed from `sbtt`. + TextSubtitle(TextSubtitleCodecDetails), + /// WebVTT metadata parsed from `vttC` and `vlab`. + WebVtt(WebVttCodecDetails), +} + +/// Parsed AVC decoder configuration details. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct AvcCodecDetails { + /// AVC decoder configuration version. + pub configuration_version: u8, + /// AVC profile indication. + pub profile: u8, + /// AVC profile-compatibility byte. + pub profile_compatibility: u8, + /// AVC level indication. + pub level: u8, + /// Length-prefix width used for NAL units. + pub length_size: u16, + /// Chroma-format identifier when the high-profile extension fields are present. + pub chroma_format: Option, + /// Bit depth for luma samples when the high-profile extension fields are present. + pub bit_depth_luma: Option, + /// Bit depth for chroma samples when the high-profile extension fields are present. + pub bit_depth_chroma: Option, +} + +/// Parsed HEVC decoder configuration details. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct HevcCodecDetails { + /// HEVC decoder configuration version. + pub configuration_version: u8, + /// General profile space value. + pub profile_space: u8, + /// General tier flag. + pub tier_flag: bool, + /// General profile identifier. + pub profile_idc: u8, + /// Packed 32-bit compatibility mask derived from `general_profile_compatibility`. + pub profile_compatibility_mask: u32, + /// General constraint-indicator bytes. + pub constraint_indicator: [u8; 6], + /// General level identifier. + pub level_idc: u8, + /// Minimum spatial segmentation identifier. + pub min_spatial_segmentation_idc: u16, + /// Parallelism type. + pub parallelism_type: u8, + /// Chroma format identifier. + pub chroma_format_idc: u8, + /// Luma bit depth in bits. + pub bit_depth_luma: u8, + /// Chroma bit depth in bits. + pub bit_depth_chroma: u8, + /// Average frame rate from `hvcC`. + pub avg_frame_rate: u16, + /// Constant-frame-rate indicator. + pub constant_frame_rate: u8, + /// Number of temporal layers. + pub num_temporal_layers: u8, + /// Temporal-ID-nested indicator. + pub temporal_id_nested: u8, + /// Length-prefix width used for NAL units. + pub length_size: u16, +} + +/// Parsed AV1 decoder configuration details. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Av1CodecDetails { + /// Sequence profile identifier. + pub seq_profile: u8, + /// Sequence level identifier. + pub seq_level_idx_0: u8, + /// Sequence tier identifier. + pub seq_tier_0: u8, + /// Decoded bit depth in bits. + pub bit_depth: u8, + /// Whether the sequence is monochrome. + pub monochrome: bool, + /// Horizontal chroma-subsampling flag. + pub chroma_subsampling_x: u8, + /// Vertical chroma-subsampling flag. + pub chroma_subsampling_y: u8, + /// Chroma sample-position code. + pub chroma_sample_position: u8, + /// Initial presentation-delay offset when the field is present. + pub initial_presentation_delay_minus_one: Option, +} + +/// Parsed VP8 or VP9 decoder configuration details. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct VpCodecDetails { + /// VP profile identifier. + pub profile: u8, + /// VP level identifier. + pub level: u8, + /// Decoded bit depth in bits. + pub bit_depth: u8, + /// Chroma-subsampling code. + pub chroma_subsampling: u8, + /// Whether the stream uses full-range luma values. + pub full_range: bool, + /// Color-primaries code. + pub colour_primaries: u8, + /// Transfer-characteristics code. + pub transfer_characteristics: u8, + /// Matrix-coefficients code. + pub matrix_coefficients: u8, + /// Codec-initialization-data size from `vpcC`. + pub codec_initialization_data_size: u16, +} + +/// Parsed MPEG-4 audio decoder configuration details. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Mp4AudioCodecDetails { + /// MPEG object-type indication from the decoder-config descriptor. + pub object_type_indication: u8, + /// AAC audio object type derived from the decoder-specific info payload. + pub audio_object_type: u8, + /// Channel count from the audio sample entry. + pub channel_count: u16, + /// Integer sample rate from the audio sample entry when present. + pub sample_rate: Option, +} + +/// Parsed Opus decoder configuration details. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct OpusCodecDetails { + /// Output channel count from `dOps`. + pub output_channel_count: u8, + /// Decoder pre-skip from `dOps`. + pub pre_skip: u16, + /// Input sample rate from `dOps`. + pub input_sample_rate: u32, + /// Output gain from `dOps`. + pub output_gain: i16, + /// Channel-mapping-family identifier from `dOps`. + pub channel_mapping_family: u8, + /// Stream count when explicit channel mapping is present. + pub stream_count: Option, + /// Coupled-stream count when explicit channel mapping is present. + pub coupled_count: Option, + /// Channel-mapping table when explicit channel mapping is present. + pub channel_mapping: Vec, +} + +/// Parsed AC-3 decoder configuration details. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Ac3CodecDetails { + /// Sample-rate code from `dac3`. + pub sample_rate_code: u8, + /// Bit-stream identification from `dac3`. + pub bit_stream_identification: u8, + /// Bit-stream mode from `dac3`. + pub bit_stream_mode: u8, + /// Audio-coding-mode from `dac3`. + pub audio_coding_mode: u8, + /// Whether the bitstream carries an LFE channel. + pub lfe_on: bool, + /// Bit-rate code from `dac3`. + pub bit_rate_code: u8, +} + +/// Parsed PCM configuration details. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct PcmCodecDetails { + /// PCM format flags from `pcmC`. + pub format_flags: u8, + /// PCM sample size from `pcmC`. + pub sample_size: u8, +} + +/// Parsed XML subtitle sample-entry details. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct XmlSubtitleCodecDetails { + /// XML namespace string from `stpp`. + pub namespace: String, + /// XML schema-location string from `stpp`. + pub schema_location: String, + /// Auxiliary MIME types from `stpp`. + pub auxiliary_mime_types: String, +} + +/// Parsed text subtitle sample-entry details. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct TextSubtitleCodecDetails { + /// Content-encoding label from `sbtt`. + pub content_encoding: String, + /// MIME format label from `sbtt`. + pub mime_format: String, +} + +/// Parsed WebVTT sample-entry details. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct WebVttCodecDetails { + /// Configuration payload from `vttC` when present. + pub config: Option, + /// Source-label payload from `vlab` when present. + pub source_label: Option, +} + +#[derive(Default)] +struct TrackCodecConfigRefs<'a> { + avcc: Option<&'a AVCDecoderConfiguration>, + hvcc: Option<&'a HEVCDecoderConfiguration>, + av1c: Option<&'a AV1CodecConfiguration>, + vpcc: Option<&'a VpCodecConfiguration>, + dops: Option<&'a DOps>, + dac3: Option<&'a Dac3>, + pcmc: Option<&'a PcmC>, + xml_subtitle_sample_entry: Option<&'a XMLSubtitleSampleEntry>, + text_subtitle_sample_entry: Option<&'a TextSubtitleSampleEntry>, + webvtt_configuration: Option<&'a WebVTTConfigurationBox>, + webvtt_source_label: Option<&'a WebVTTSourceLabelBox>, +} + +#[derive(Default)] +struct TrackMediaCharacteristicRefs<'a> { + btrt: Option<&'a Btrt>, + colr: Option<&'a Colr>, + pasp: Option<&'a Pasp>, + fiel: Option<&'a Fiel>, +} + +struct ParsedRichTrackInfo { + summary: DetailedTrackInfo, + codec_details: TrackCodecDetails, + media_characteristics: TrackMediaCharacteristics, +} + /// Coarse codec classification used by the probe surface. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum TrackCodec { @@ -123,6 +718,56 @@ pub enum TrackCodec { Mp4a, } +/// Normalized codec family derived from the sample entry or protected original format. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum TrackCodecFamily { + /// No recognized codec family was derived. + #[default] + Unknown, + /// AVC/H.264 video. + Avc, + /// HEVC/H.265 video. + Hevc, + /// AV1 video. + Av1, + /// VP8 video. + Vp8, + /// VP9 video. + Vp9, + /// MPEG-4 audio carried by `mp4a`. + Mp4Audio, + /// Opus audio. + Opus, + /// AC-3 audio. + Ac3, + /// PCM audio carried by `ipcm` or `fpcm`. + Pcm, + /// XML subtitle text carried by `stpp`. + XmlSubtitle, + /// Plain-text subtitle data carried by `sbtt`. + TextSubtitle, + /// WebVTT text carried by `wvtt`. + WebVtt, +} + +/// Protection-scheme summary derived from `schm`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProtectionSchemeInfo { + /// Protection scheme type from `schm`. + pub scheme_type: FourCc, + /// Protection scheme version from `schm`. + pub scheme_version: u32, +} + +impl Default for ProtectionSchemeInfo { + fn default() -> Self { + Self { + scheme_type: FourCc::ANY, + scheme_version: 0, + } + } +} + /// One edit-list entry from `elst`. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct EditListEntry { @@ -212,25 +857,70 @@ pub struct SegmentInfo { pub size: u32, } -/// Probes a file and returns high-level movie, track, and fragment summaries. +/// Probes a file and returns the backwards-compatible coarse movie, track, and fragment summary. +/// +/// For richer sample-entry, handler, language, and protection metadata, use [`probe_detailed`]. pub fn probe(reader: &mut R) -> Result where R: Read + Seek, { - let infos = extract_boxes( - reader, - None, - &[ - BoxPath::from([FTYP]), - BoxPath::from([MOOV]), - BoxPath::from([MOOV, MVHD]), - BoxPath::from([MOOV, TRAK]), - BoxPath::from([MOOF]), - BoxPath::from([MDAT]), - ], - )?; + probe_with_options(reader, ProbeOptions::default()) +} + +/// Probes a file with additive expansion controls and returns the backwards-compatible coarse +/// movie, track, and fragment summary. +pub fn probe_with_options(reader: &mut R, options: ProbeOptions) -> Result +where + R: Read + Seek, +{ + Ok(strip_probe_details(probe_detailed_with_options( + reader, options, + )?)) +} + +/// Probes a file and returns an additive detailed movie, track, and fragment summary. +pub fn probe_detailed(reader: &mut R) -> Result +where + R: Read + Seek, +{ + probe_detailed_with_options(reader, ProbeOptions::default()) +} + +/// Probes a file with additive expansion controls and returns the detailed movie, track, and +/// fragment summary. +pub fn probe_detailed_with_options( + reader: &mut R, + options: ProbeOptions, +) -> Result +where + R: Read + Seek, +{ + Ok(strip_codec_details(probe_codec_detailed_with_options( + reader, options, + )?)) +} + +/// Probes a file and returns an additive detailed summary with parsed codec-specific +/// configuration when it is available. +pub fn probe_codec_detailed(reader: &mut R) -> Result +where + R: Read + Seek, +{ + probe_codec_detailed_with_options(reader, ProbeOptions::default()) +} + +/// Probes a file with additive expansion controls and returns the codec-detailed summary. +pub fn probe_codec_detailed_with_options( + reader: &mut R, + options: ProbeOptions, +) -> Result +where + R: Read + Seek, +{ + let paths = root_probe_box_paths(options); + let infos = extract_boxes(reader, None, &paths)?; - let mut summary = ProbeInfo::default(); + let mut summary = CodecDetailedProbeInfo::default(); let mut mdat_appeared = false; for info in infos { @@ -250,9 +940,11 @@ where summary.duration = mvhd.duration(); } TRAK => { - summary.tracks.push(probe_trak(reader, &info)?); + summary + .tracks + .push(probe_trak_codec_detailed(reader, &info, options)?); } - MOOF => { + MOOF if options.include_segments => { summary.segments.push(probe_moof(reader, &info)?); } MDAT => { @@ -265,8 +957,66 @@ where Ok(summary) } -/// Probes an in-memory MP4 byte slice and returns high-level movie, track, and fragment -/// summaries. +/// Probes a file and returns an additive summary with parsed codec and media characteristics. +pub fn probe_media_characteristics( + reader: &mut R, +) -> Result +where + R: Read + Seek, +{ + probe_media_characteristics_with_options(reader, ProbeOptions::default()) +} + +/// Probes a file with additive expansion controls and returns the media-characteristics summary. +pub fn probe_media_characteristics_with_options( + reader: &mut R, + options: ProbeOptions, +) -> Result +where + R: Read + Seek, +{ + let paths = root_probe_box_paths(options); + let infos = extract_boxes(reader, None, &paths)?; + + let mut summary = MediaCharacteristicsProbeInfo::default(); + let mut mdat_appeared = false; + + for info in infos { + match info.box_type() { + FTYP => { + let ftyp = read_payload_as::<_, crate::boxes::iso14496_12::Ftyp>(reader, &info)?; + summary.major_brand = ftyp.major_brand; + summary.minor_version = ftyp.minor_version; + summary.compatible_brands = ftyp.compatible_brands; + } + MOOV => { + summary.fast_start = !mdat_appeared; + } + MVHD => { + let mvhd = read_payload_as::<_, Mvhd>(reader, &info)?; + summary.timescale = mvhd.timescale; + summary.duration = mvhd.duration(); + } + TRAK => { + summary + .tracks + .push(probe_trak_media_characteristics(reader, &info, options)?); + } + MOOF if options.include_segments => { + summary.segments.push(probe_moof(reader, &info)?); + } + MDAT => { + mdat_appeared = true; + } + _ => {} + } + } + + Ok(summary) +} + +/// Probes an in-memory MP4 byte slice and returns the coarse movie, track, and fragment +/// summary. /// /// This is equivalent to calling [`probe`] with `Cursor<&[u8]>`. pub fn probe_bytes(input: &[u8]) -> Result { @@ -274,6 +1024,72 @@ pub fn probe_bytes(input: &[u8]) -> Result { probe(&mut reader) } +/// Probes an in-memory MP4 byte slice with additive expansion controls and returns the coarse +/// movie, track, and fragment summary. +pub fn probe_bytes_with_options( + input: &[u8], + options: ProbeOptions, +) -> Result { + let mut reader = Cursor::new(input); + probe_with_options(&mut reader, options) +} + +/// Probes an in-memory MP4 byte slice and returns the additive detailed summary. +/// +/// This is equivalent to calling [`probe_detailed`] with `Cursor<&[u8]>`. +pub fn probe_detailed_bytes(input: &[u8]) -> Result { + let mut reader = Cursor::new(input); + probe_detailed(&mut reader) +} + +/// Probes an in-memory MP4 byte slice with additive expansion controls and returns the detailed +/// summary. +pub fn probe_detailed_bytes_with_options( + input: &[u8], + options: ProbeOptions, +) -> Result { + let mut reader = Cursor::new(input); + probe_detailed_with_options(&mut reader, options) +} + +/// Probes an in-memory MP4 byte slice and returns the additive codec-detailed summary. +/// +/// This is equivalent to calling [`probe_codec_detailed`] with `Cursor<&[u8]>`. +pub fn probe_codec_detailed_bytes(input: &[u8]) -> Result { + let mut reader = Cursor::new(input); + probe_codec_detailed(&mut reader) +} + +/// Probes an in-memory MP4 byte slice with additive expansion controls and returns the +/// codec-detailed summary. +pub fn probe_codec_detailed_bytes_with_options( + input: &[u8], + options: ProbeOptions, +) -> Result { + let mut reader = Cursor::new(input); + probe_codec_detailed_with_options(&mut reader, options) +} + +/// Probes an in-memory MP4 byte slice and returns the additive media-characteristics summary. +/// +/// This is equivalent to calling [`probe_media_characteristics`] with `Cursor<&[u8]>`. +pub fn probe_media_characteristics_bytes( + input: &[u8], +) -> Result { + let mut reader = Cursor::new(input); + probe_media_characteristics(&mut reader) +} + +/// Probes an in-memory MP4 byte slice with additive expansion controls and returns the +/// media-characteristics summary. +pub fn probe_media_characteristics_bytes_with_options( + input: &[u8], + options: ProbeOptions, +) -> Result { + let mut reader = Cursor::new(input); + probe_media_characteristics_with_options(&mut reader, options) +} + /// Legacy fragmented-file probe entry point that currently aliases [`probe`]. pub fn probe_fra(reader: &mut R) -> Result where @@ -282,6 +1098,34 @@ where probe(reader) } +/// Legacy fragmented-file detailed probe entry point that currently aliases [`probe_detailed`]. +pub fn probe_fra_detailed(reader: &mut R) -> Result +where + R: Read + Seek, +{ + probe_detailed(reader) +} + +/// Legacy fragmented-file codec-detailed probe entry point that currently aliases +/// [`probe_codec_detailed`]. +pub fn probe_fra_codec_detailed(reader: &mut R) -> Result +where + R: Read + Seek, +{ + probe_codec_detailed(reader) +} + +/// Legacy fragmented-file media-characteristics probe entry point that currently aliases +/// [`probe_media_characteristics`]. +pub fn probe_fra_media_characteristics( + reader: &mut R, +) -> Result +where + R: Read + Seek, +{ + probe_media_characteristics(reader) +} + /// Legacy fragmented-file probe entry point for in-memory MP4 bytes. /// /// This currently aliases [`probe_bytes`] for callers that already use the `probe_fra` naming. @@ -290,6 +1134,33 @@ pub fn probe_fra_bytes(input: &[u8]) -> Result { probe_fra(&mut reader) } +/// Legacy fragmented-file detailed probe entry point for in-memory MP4 bytes. +/// +/// This currently aliases [`probe_detailed_bytes`] for callers that already use the +/// `probe_fra` naming. +pub fn probe_fra_detailed_bytes(input: &[u8]) -> Result { + let mut reader = Cursor::new(input); + probe_fra_detailed(&mut reader) +} + +/// Legacy fragmented-file codec-detailed probe entry point for in-memory MP4 bytes. +/// +/// This currently aliases [`probe_codec_detailed_bytes`] for callers that already use the +/// `probe_fra` naming. +pub fn probe_fra_codec_detailed_bytes(input: &[u8]) -> Result { + let mut reader = Cursor::new(input); + probe_fra_codec_detailed(&mut reader) +} + +/// This currently aliases [`probe_media_characteristics_bytes`] for callers that already use the +/// fragmented-file helper naming. +pub fn probe_fra_media_characteristics_bytes( + input: &[u8], +) -> Result { + let mut reader = Cursor::new(input); + probe_fra_media_characteristics(&mut reader) +} + /// Detects the AAC object profile exposed by an `esds` descriptor stream. pub fn detect_aac_profile(esds: &Esds) -> Result, ProbeError> { let Some(decoder_config) = esds.decoder_config_descriptor() else { @@ -502,42 +1373,203 @@ pub fn max_segment_bitrate(segments: &[SegmentInfo], track_id: u32, timescale: u max_bitrate } -fn probe_trak(reader: &mut R, parent: &BoxInfo) -> Result -where - R: Read + Seek, -{ - let boxes = extract_boxes_with_payload( - reader, - Some(parent), - &[ - BoxPath::from([TKHD]), - BoxPath::from([EDTS, ELST]), - BoxPath::from([MDIA, MDHD]), - BoxPath::from([MDIA, MINF, STBL, STSD, AVC1]), - BoxPath::from([MDIA, MINF, STBL, STSD, AVC1, AVCC]), - BoxPath::from([MDIA, MINF, STBL, STSD, ENCV]), - BoxPath::from([MDIA, MINF, STBL, STSD, ENCV, AVCC]), - BoxPath::from([MDIA, MINF, STBL, STSD, MP4A]), - BoxPath::from([MDIA, MINF, STBL, STSD, MP4A, ESDS]), - BoxPath::from([MDIA, MINF, STBL, STSD, MP4A, WAVE, ESDS]), - BoxPath::from([MDIA, MINF, STBL, STSD, ENCA]), - BoxPath::from([MDIA, MINF, STBL, STSD, ENCA, ESDS]), +fn strip_probe_details(details: DetailedProbeInfo) -> ProbeInfo { + ProbeInfo { + major_brand: details.major_brand, + minor_version: details.minor_version, + compatible_brands: details.compatible_brands, + fast_start: details.fast_start, + timescale: details.timescale, + duration: details.duration, + tracks: details + .tracks + .into_iter() + .map(|track| track.summary) + .collect(), + segments: details.segments, + } +} + +fn strip_codec_details(details: CodecDetailedProbeInfo) -> DetailedProbeInfo { + DetailedProbeInfo { + major_brand: details.major_brand, + minor_version: details.minor_version, + compatible_brands: details.compatible_brands, + fast_start: details.fast_start, + timescale: details.timescale, + duration: details.duration, + tracks: details + .tracks + .into_iter() + .map(|track| track.summary) + .collect(), + segments: details.segments, + } +} + +fn root_probe_box_paths(options: ProbeOptions) -> Vec { + let mut paths = vec![ + BoxPath::from([FTYP]), + BoxPath::from([MOOV]), + BoxPath::from([MOOV, MVHD]), + BoxPath::from([MOOV, TRAK]), + BoxPath::from([MDAT]), + ]; + if options.include_segments { + paths.push(BoxPath::from([MOOF])); + } + paths +} + +fn track_probe_box_paths(options: ProbeOptions) -> Vec { + let visual_sample_entries = [AVC1, HEV1, HVC1, AV01, VP08, VP09, ENCV]; + let audio_sample_entries = [MP4A, OPUS, AC_3, IPCM, FPCM, ENCA]; + let mut paths = vec![ + BoxPath::from([TKHD]), + BoxPath::from([EDTS, ELST]), + BoxPath::from([MDIA, MDHD]), + BoxPath::from([MDIA, HDLR]), + BoxPath::from([MDIA, MINF, STBL, STSD, AVC1]), + BoxPath::from([MDIA, MINF, STBL, STSD, AVC1, AVCC]), + BoxPath::from([MDIA, MINF, STBL, STSD, HEV1]), + BoxPath::from([MDIA, MINF, STBL, STSD, HEV1, HVCC]), + BoxPath::from([MDIA, MINF, STBL, STSD, HVC1]), + BoxPath::from([MDIA, MINF, STBL, STSD, HVC1, HVCC]), + BoxPath::from([MDIA, MINF, STBL, STSD, AV01]), + BoxPath::from([MDIA, MINF, STBL, STSD, AV01, AV1C]), + BoxPath::from([MDIA, MINF, STBL, STSD, VP08]), + BoxPath::from([MDIA, MINF, STBL, STSD, VP08, VPCC]), + BoxPath::from([MDIA, MINF, STBL, STSD, VP09]), + BoxPath::from([MDIA, MINF, STBL, STSD, VP09, VPCC]), + BoxPath::from([MDIA, MINF, STBL, STSD, ENCV]), + BoxPath::from([MDIA, MINF, STBL, STSD, ENCV, AVCC]), + BoxPath::from([MDIA, MINF, STBL, STSD, ENCV, HVCC]), + BoxPath::from([MDIA, MINF, STBL, STSD, ENCV, AV1C]), + BoxPath::from([MDIA, MINF, STBL, STSD, ENCV, VPCC]), + BoxPath::from([MDIA, MINF, STBL, STSD, ENCV, SINF, FRMA]), + BoxPath::from([MDIA, MINF, STBL, STSD, ENCV, SINF, SCHM]), + BoxPath::from([MDIA, MINF, STBL, STSD, MP4A]), + BoxPath::from([MDIA, MINF, STBL, STSD, MP4A, ESDS]), + BoxPath::from([MDIA, MINF, STBL, STSD, MP4A, WAVE, ESDS]), + BoxPath::from([MDIA, MINF, STBL, STSD, OPUS]), + BoxPath::from([MDIA, MINF, STBL, STSD, OPUS, DOPS]), + BoxPath::from([MDIA, MINF, STBL, STSD, AC_3]), + BoxPath::from([MDIA, MINF, STBL, STSD, AC_3, DAC3]), + BoxPath::from([MDIA, MINF, STBL, STSD, IPCM]), + BoxPath::from([MDIA, MINF, STBL, STSD, IPCM, PCMC]), + BoxPath::from([MDIA, MINF, STBL, STSD, FPCM]), + BoxPath::from([MDIA, MINF, STBL, STSD, FPCM, PCMC]), + BoxPath::from([MDIA, MINF, STBL, STSD, ENCA]), + BoxPath::from([MDIA, MINF, STBL, STSD, ENCA, ESDS]), + BoxPath::from([MDIA, MINF, STBL, STSD, ENCA, WAVE, ESDS]), + BoxPath::from([MDIA, MINF, STBL, STSD, ENCA, DOPS]), + BoxPath::from([MDIA, MINF, STBL, STSD, ENCA, DAC3]), + BoxPath::from([MDIA, MINF, STBL, STSD, ENCA, PCMC]), + BoxPath::from([MDIA, MINF, STBL, STSD, ENCA, SINF, FRMA]), + BoxPath::from([MDIA, MINF, STBL, STSD, ENCA, SINF, SCHM]), + BoxPath::from([MDIA, MINF, STBL, STSD, STPP]), + BoxPath::from([MDIA, MINF, STBL, STSD, SBTT]), + BoxPath::from([MDIA, MINF, STBL, STSD, WVTT]), + BoxPath::from([MDIA, MINF, STBL, STSD, WVTT, VTTC_CONFIG]), + BoxPath::from([MDIA, MINF, STBL, STSD, WVTT, VLAB]), + ]; + + for sample_entry in visual_sample_entries { + paths.extend([ + BoxPath::from([MDIA, MINF, STBL, STSD, sample_entry, BTRT]), + BoxPath::from([MDIA, MINF, STBL, STSD, sample_entry, COLR]), + BoxPath::from([MDIA, MINF, STBL, STSD, sample_entry, PASP]), + BoxPath::from([MDIA, MINF, STBL, STSD, sample_entry, FIEL]), + ]); + } + + for sample_entry in audio_sample_entries { + paths.push(BoxPath::from([MDIA, MINF, STBL, STSD, sample_entry, BTRT])); + } + + if options.expand_chunks { + paths.extend([ BoxPath::from([MDIA, MINF, STBL, STCO]), BoxPath::from([MDIA, MINF, STBL, CO64]), + BoxPath::from([MDIA, MINF, STBL, STSC]), + ]); + } + + if options.expand_samples { + paths.extend([ BoxPath::from([MDIA, MINF, STBL, STTS]), BoxPath::from([MDIA, MINF, STBL, CTTS]), - BoxPath::from([MDIA, MINF, STBL, STSC]), BoxPath::from([MDIA, MINF, STBL, STSZ]), - ], - )?; + ]); + } + + paths +} - let mut track = TrackInfo::default(); +fn probe_trak_codec_detailed( + reader: &mut R, + parent: &BoxInfo, + options: ProbeOptions, +) -> Result +where + R: Read + Seek, +{ + let track = probe_trak_rich_details(reader, parent, options)?; + Ok(CodecDetailedTrackInfo { + summary: track.summary, + codec_details: track.codec_details, + }) +} + +fn probe_trak_media_characteristics( + reader: &mut R, + parent: &BoxInfo, + options: ProbeOptions, +) -> Result +where + R: Read + Seek, +{ + let track = probe_trak_rich_details(reader, parent, options)?; + Ok(MediaCharacteristicsTrackInfo { + summary: track.summary, + codec_details: track.codec_details, + media_characteristics: track.media_characteristics, + }) +} + +fn probe_trak_rich_details( + reader: &mut R, + parent: &BoxInfo, + options: ProbeOptions, +) -> Result +where + R: Read + Seek, +{ + let paths = track_probe_box_paths(options); + let boxes = extract_boxes_with_payload(reader, Some(parent), &paths)?; + + let mut track = DetailedTrackInfo::default(); let mut tkhd = None; let mut mdhd = None; let mut visual_sample_entry = None; let mut avcc = None; + let mut hvcc = None; + let mut av1c = None; + let mut vpcc = None; let mut audio_sample_entry = None; let mut esds = None; + let mut dops = None; + let mut dac3 = None; + let mut pcmc = None; + let mut xml_subtitle_sample_entry = None; + let mut text_subtitle_sample_entry = None; + let mut webvtt_configuration = None; + let mut webvtt_source_label = None; + let mut btrt = None; + let mut colr = None; + let mut pasp = None; + let mut fiel = None; + let mut original_format = None; let mut stco = None; let mut co64 = None; let mut stts = None; @@ -549,12 +1581,12 @@ where match extracted.info.box_type() { TKHD => { let payload = downcast_clone::(&extracted)?; - track.track_id = payload.track_id; + track.summary.track_id = payload.track_id; tkhd = Some(payload); } ELST => { let elst = downcast_clone::(&extracted)?; - track.edit_list = elst + track.summary.edit_list = elst .entries .iter() .enumerate() @@ -566,34 +1598,154 @@ where } MDHD => { let payload = downcast_clone::(&extracted)?; - track.timescale = payload.timescale; - track.duration = payload.duration(); + track.summary.timescale = payload.timescale; + track.summary.duration = payload.duration(); + track.language = Some(decode_language(payload.language)); mdhd = Some(payload); } + HDLR => { + let payload = downcast_clone::(&extracted)?; + track.handler_type = Some(payload.handler_type); + } AVC1 => { - track.codec = TrackCodec::Avc1; + track.summary.codec = TrackCodec::Avc1; + track.codec_family = TrackCodecFamily::Avc; + track.sample_entry_type = Some(AVC1); visual_sample_entry = Some(downcast_clone::(&extracted)?); } AVCC => { avcc = Some(downcast_clone::(&extracted)?); } + HVCC => { + hvcc = Some(downcast_clone::(&extracted)?); + } + HEV1 => { + track.codec_family = TrackCodecFamily::Hevc; + track.sample_entry_type = Some(HEV1); + visual_sample_entry = Some(downcast_clone::(&extracted)?); + } + HVC1 => { + track.codec_family = TrackCodecFamily::Hevc; + track.sample_entry_type = Some(HVC1); + visual_sample_entry = Some(downcast_clone::(&extracted)?); + } + AV01 => { + track.codec_family = TrackCodecFamily::Av1; + track.sample_entry_type = Some(AV01); + visual_sample_entry = Some(downcast_clone::(&extracted)?); + } + AV1C => { + av1c = Some(downcast_clone::(&extracted)?); + } + VP08 => { + track.codec_family = TrackCodecFamily::Vp8; + track.sample_entry_type = Some(VP08); + visual_sample_entry = Some(downcast_clone::(&extracted)?); + } + VP09 => { + track.codec_family = TrackCodecFamily::Vp9; + track.sample_entry_type = Some(VP09); + visual_sample_entry = Some(downcast_clone::(&extracted)?); + } + VPCC => { + vpcc = Some(downcast_clone::(&extracted)?); + } ENCV => { - track.codec = TrackCodec::Avc1; - track.encrypted = true; + track.summary.codec = TrackCodec::Avc1; + track.summary.encrypted = true; + track.sample_entry_type = Some(ENCV); visual_sample_entry = Some(downcast_clone::(&extracted)?); } MP4A => { - track.codec = TrackCodec::Mp4a; + track.summary.codec = TrackCodec::Mp4a; + track.codec_family = TrackCodecFamily::Mp4Audio; + track.sample_entry_type = Some(MP4A); audio_sample_entry = Some(downcast_clone::(&extracted)?); } ENCA => { - track.codec = TrackCodec::Mp4a; - track.encrypted = true; + track.summary.codec = TrackCodec::Mp4a; + track.summary.encrypted = true; + track.sample_entry_type = Some(ENCA); + audio_sample_entry = Some(downcast_clone::(&extracted)?); + } + OPUS => { + track.codec_family = TrackCodecFamily::Opus; + track.sample_entry_type = Some(OPUS); + audio_sample_entry = Some(downcast_clone::(&extracted)?); + } + DOPS => { + dops = Some(downcast_clone::(&extracted)?); + } + AC_3 => { + track.codec_family = TrackCodecFamily::Ac3; + track.sample_entry_type = Some(AC_3); audio_sample_entry = Some(downcast_clone::(&extracted)?); } + DAC3 => { + dac3 = Some(downcast_clone::(&extracted)?); + } + IPCM => { + track.codec_family = TrackCodecFamily::Pcm; + track.sample_entry_type = Some(IPCM); + audio_sample_entry = Some(downcast_clone::(&extracted)?); + } + FPCM => { + track.codec_family = TrackCodecFamily::Pcm; + track.sample_entry_type = Some(FPCM); + audio_sample_entry = Some(downcast_clone::(&extracted)?); + } + PCMC => { + pcmc = Some(downcast_clone::(&extracted)?); + } + STPP => { + track.codec_family = TrackCodecFamily::XmlSubtitle; + track.sample_entry_type = Some(STPP); + xml_subtitle_sample_entry = + Some(downcast_clone::(&extracted)?); + } + SBTT => { + track.codec_family = TrackCodecFamily::TextSubtitle; + track.sample_entry_type = Some(SBTT); + text_subtitle_sample_entry = + Some(downcast_clone::(&extracted)?); + } + WVTT => { + track.codec_family = TrackCodecFamily::WebVtt; + track.sample_entry_type = Some(WVTT); + } + VTTC_CONFIG => { + webvtt_configuration = Some(downcast_clone::(&extracted)?); + } + VLAB => { + webvtt_source_label = Some(downcast_clone::(&extracted)?); + } + BTRT => { + btrt = Some(downcast_clone::(&extracted)?); + } + COLR => { + colr = Some(downcast_clone::(&extracted)?); + } + PASP => { + pasp = Some(downcast_clone::(&extracted)?); + } + FIEL => { + fiel = Some(downcast_clone::(&extracted)?); + } ESDS => { esds = Some(downcast_clone::(&extracted)?); } + FRMA => { + let payload = downcast_clone::(&extracted)?; + original_format = Some(payload.data_format); + track.original_format = Some(payload.data_format); + } + SCHM => { + let payload = downcast_clone::(&extracted)?; + track.protection_scheme = Some(ProtectionSchemeInfo { + scheme_type: payload.scheme_type, + scheme_version: payload.scheme_version, + }); + } STCO => { stco = Some(downcast_clone::(&extracted)?); } @@ -623,8 +1775,24 @@ where return Err(ProbeError::MissingRequiredBox("mdhd")); } + if let Some(entry) = visual_sample_entry.as_ref() { + track.display_width = Some(entry.width); + track.display_height = Some(entry.height); + } + + if let Some(entry) = audio_sample_entry.as_ref() { + track.channel_count = Some(entry.channel_count); + track.sample_rate = Some(entry.sample_rate_int()); + } + + if let Some(original_format) = original_format { + track.codec_family = codec_family_from_sample_entry(original_format); + } else if let Some(sample_entry_type) = track.sample_entry_type { + track.codec_family = codec_family_from_sample_entry(sample_entry_type); + } + if let (Some(entry), Some(avcc)) = (visual_sample_entry.as_ref(), avcc.as_ref()) { - track.avc = Some(AvcDecoderConfigInfo { + track.summary.avc = Some(AvcDecoderConfigInfo { configuration_version: avcc.configuration_version, profile: avcc.profile, profile_compatibility: avcc.profile_compatibility, @@ -638,85 +1806,395 @@ where if let (Some(entry), Some(esds)) = (audio_sample_entry.as_ref(), esds.as_ref()) && let Some(profile) = detect_aac_profile(esds)? { - track.mp4a = Some(Mp4aInfo { + track.summary.mp4a = Some(Mp4aInfo { object_type_indication: profile.object_type_indication, audio_object_type: profile.audio_object_type, channel_count: entry.channel_count, }); } - let mut chunks = Vec::new(); - if let Some(stco) = stco.as_ref() { - chunks.extend(stco.chunk_offset.iter().map(|offset| ChunkInfo { - data_offset: *offset, - samples_per_chunk: 0, - })); - } else if let Some(co64) = co64.as_ref() { - chunks.extend(co64.chunk_offset.iter().map(|offset| ChunkInfo { - data_offset: *offset, - samples_per_chunk: 0, - })); - } else { - return Err(ProbeError::MissingRequiredBox("stco/co64")); - } + if options.expand_chunks { + if let Some(stco) = stco.as_ref() { + track + .summary + .chunks + .extend(stco.chunk_offset.iter().map(|offset| ChunkInfo { + data_offset: *offset, + samples_per_chunk: 0, + })); + } else if let Some(co64) = co64.as_ref() { + track + .summary + .chunks + .extend(co64.chunk_offset.iter().map(|offset| ChunkInfo { + data_offset: *offset, + samples_per_chunk: 0, + })); + } else { + return Err(ProbeError::MissingRequiredBox("stco/co64")); + } - let stts = stts.ok_or(ProbeError::MissingRequiredBox("stts"))?; - let mut samples = Vec::new(); - for entry in &stts.entries { - for _ in 0..entry.sample_count { - samples.push(SampleInfo { - time_delta: entry.sample_delta, - ..SampleInfo::default() - }); + let stsc = stsc.ok_or(ProbeError::MissingRequiredBox("stsc"))?; + for (index, entry) in stsc.entries.iter().enumerate() { + let mut end = track.summary.chunks.len() as u32; + if index + 1 != stsc.entries.len() { + end = end.min(stsc.entries[index + 1].first_chunk.saturating_sub(1)); + } + for chunk_index in entry.first_chunk.saturating_sub(1)..end { + if let Some(chunk) = track.summary.chunks.get_mut(chunk_index as usize) { + chunk.samples_per_chunk = entry.samples_per_chunk; + } + } } } - let stsc = stsc.ok_or(ProbeError::MissingRequiredBox("stsc"))?; - for (index, entry) in stsc.entries.iter().enumerate() { - let mut end = chunks.len() as u32; - if index + 1 != stsc.entries.len() { - end = end.min(stsc.entries[index + 1].first_chunk.saturating_sub(1)); + if options.expand_samples { + let stts = stts.ok_or(ProbeError::MissingRequiredBox("stts"))?; + for entry in &stts.entries { + for _ in 0..entry.sample_count { + track.summary.samples.push(SampleInfo { + time_delta: entry.sample_delta, + ..SampleInfo::default() + }); + } } - for chunk_index in entry.first_chunk.saturating_sub(1)..end { - if let Some(chunk) = chunks.get_mut(chunk_index as usize) { - chunk.samples_per_chunk = entry.samples_per_chunk; + + if let Some(ctts) = ctts.as_ref() { + let mut sample_index = 0usize; + for (entry_index, entry) in ctts.entries.iter().enumerate() { + for _ in 0..entry.sample_count { + if sample_index >= track.summary.samples.len() { + break; + } + track.summary.samples[sample_index].composition_time_offset = + ctts.sample_offset(entry_index); + sample_index += 1; + } } } - } - if let Some(ctts) = ctts.as_ref() { - let mut sample_index = 0usize; - for (entry_index, entry) in ctts.entries.iter().enumerate() { - for _ in 0..entry.sample_count { - if sample_index >= samples.len() { - break; + if let Some(stsz) = stsz.as_ref() { + if stsz.sample_size != 0 { + for sample in &mut track.summary.samples { + sample.size = stsz.sample_size; + } + } else { + for (sample, entry_size) in + track.summary.samples.iter_mut().zip(stsz.entry_size.iter()) + { + sample.size = + (*entry_size) + .try_into() + .map_err(|_| ProbeError::NumericOverflow { + field_name: "stsz entry size", + })?; } - samples[sample_index].composition_time_offset = ctts.sample_offset(entry_index); - sample_index += 1; } } } + let codec_details = build_track_codec_details( + &track, + &TrackCodecConfigRefs { + avcc: avcc.as_ref(), + hvcc: hvcc.as_ref(), + av1c: av1c.as_ref(), + vpcc: vpcc.as_ref(), + dops: dops.as_ref(), + dac3: dac3.as_ref(), + pcmc: pcmc.as_ref(), + xml_subtitle_sample_entry: xml_subtitle_sample_entry.as_ref(), + text_subtitle_sample_entry: text_subtitle_sample_entry.as_ref(), + webvtt_configuration: webvtt_configuration.as_ref(), + webvtt_source_label: webvtt_source_label.as_ref(), + }, + ); + let media_characteristics = build_track_media_characteristics(&TrackMediaCharacteristicRefs { + btrt: btrt.as_ref(), + colr: colr.as_ref(), + pasp: pasp.as_ref(), + fiel: fiel.as_ref(), + }); + + Ok(ParsedRichTrackInfo { + summary: track, + codec_details, + media_characteristics, + }) +} + +fn codec_family_from_sample_entry(sample_entry_type: FourCc) -> TrackCodecFamily { + match sample_entry_type { + AVC1 => TrackCodecFamily::Avc, + HEV1 | HVC1 => TrackCodecFamily::Hevc, + AV01 => TrackCodecFamily::Av1, + VP08 => TrackCodecFamily::Vp8, + VP09 => TrackCodecFamily::Vp9, + MP4A => TrackCodecFamily::Mp4Audio, + OPUS => TrackCodecFamily::Opus, + AC_3 => TrackCodecFamily::Ac3, + IPCM | FPCM => TrackCodecFamily::Pcm, + STPP => TrackCodecFamily::XmlSubtitle, + SBTT => TrackCodecFamily::TextSubtitle, + WVTT => TrackCodecFamily::WebVtt, + _ => TrackCodecFamily::Unknown, + } +} - if let Some(stsz) = stsz.as_ref() { - if stsz.sample_size != 0 { - for sample in &mut samples { - sample.size = stsz.sample_size; +fn build_track_codec_details( + track: &DetailedTrackInfo, + config_refs: &TrackCodecConfigRefs<'_>, +) -> TrackCodecDetails { + if let Some(avc) = track.summary.avc.as_ref() { + return TrackCodecDetails::Avc(AvcCodecDetails { + configuration_version: avc.configuration_version, + profile: avc.profile, + profile_compatibility: avc.profile_compatibility, + level: avc.level, + length_size: avc.length_size, + chroma_format: config_refs + .avcc + .filter(|config| config.high_profile_fields_enabled) + .map(|config| config.chroma_format), + bit_depth_luma: config_refs + .avcc + .filter(|config| config.high_profile_fields_enabled) + .map(|config| config.bit_depth_luma_minus8.saturating_add(8)), + bit_depth_chroma: config_refs + .avcc + .filter(|config| config.high_profile_fields_enabled) + .map(|config| config.bit_depth_chroma_minus8.saturating_add(8)), + }); + } + + if let Some(mp4a) = track.summary.mp4a.as_ref() { + return TrackCodecDetails::Mp4Audio(Mp4AudioCodecDetails { + object_type_indication: mp4a.object_type_indication, + audio_object_type: mp4a.audio_object_type, + channel_count: mp4a.channel_count, + sample_rate: track.sample_rate, + }); + } + + match track.codec_family { + TrackCodecFamily::Hevc => { + if let Some(hvcc) = config_refs.hvcc { + return TrackCodecDetails::Hevc(HevcCodecDetails { + configuration_version: hvcc.configuration_version, + profile_space: hvcc.general_profile_space, + tier_flag: hvcc.general_tier_flag, + profile_idc: hvcc.general_profile_idc, + profile_compatibility_mask: hevc_profile_compatibility_mask( + &hvcc.general_profile_compatibility, + ), + constraint_indicator: hvcc.general_constraint_indicator, + level_idc: hvcc.general_level_idc, + min_spatial_segmentation_idc: hvcc.min_spatial_segmentation_idc, + parallelism_type: hvcc.parallelism_type, + chroma_format_idc: hvcc.chroma_format_idc, + bit_depth_luma: hvcc.bit_depth_luma_minus8.saturating_add(8), + bit_depth_chroma: hvcc.bit_depth_chroma_minus8.saturating_add(8), + avg_frame_rate: hvcc.avg_frame_rate, + constant_frame_rate: hvcc.constant_frame_rate, + num_temporal_layers: hvcc.num_temporal_layers, + temporal_id_nested: hvcc.temporal_id_nested, + length_size: u16::from(hvcc.length_size_minus_one) + 1, + }); } - } else { - for (sample, entry_size) in samples.iter_mut().zip(stsz.entry_size.iter()) { - sample.size = - (*entry_size) - .try_into() - .map_err(|_| ProbeError::NumericOverflow { - field_name: "stsz entry size", - })?; + } + TrackCodecFamily::Av1 => { + if let Some(av1c) = config_refs.av1c { + return TrackCodecDetails::Av1(Av1CodecDetails { + seq_profile: av1c.seq_profile, + seq_level_idx_0: av1c.seq_level_idx_0, + seq_tier_0: av1c.seq_tier_0, + bit_depth: av1_bit_depth(av1c), + monochrome: av1c.monochrome != 0, + chroma_subsampling_x: av1c.chroma_subsampling_x, + chroma_subsampling_y: av1c.chroma_subsampling_y, + chroma_sample_position: av1c.chroma_sample_position, + initial_presentation_delay_minus_one: if av1c.initial_presentation_delay_present + != 0 + { + Some(av1c.initial_presentation_delay_minus_one) + } else { + None + }, + }); + } + } + TrackCodecFamily::Vp8 => { + if let Some(vpcc) = config_refs.vpcc { + return TrackCodecDetails::Vp8(vp_codec_details(vpcc)); + } + } + TrackCodecFamily::Vp9 => { + if let Some(vpcc) = config_refs.vpcc { + return TrackCodecDetails::Vp9(vp_codec_details(vpcc)); + } + } + TrackCodecFamily::Opus => { + if let Some(dops) = config_refs.dops { + return TrackCodecDetails::Opus(OpusCodecDetails { + output_channel_count: dops.output_channel_count, + pre_skip: dops.pre_skip, + input_sample_rate: dops.input_sample_rate, + output_gain: dops.output_gain, + channel_mapping_family: dops.channel_mapping_family, + stream_count: if dops.channel_mapping_family != 0 { + Some(dops.stream_count) + } else { + None + }, + coupled_count: if dops.channel_mapping_family != 0 { + Some(dops.coupled_count) + } else { + None + }, + channel_mapping: if dops.channel_mapping_family != 0 { + dops.channel_mapping.clone() + } else { + Vec::new() + }, + }); + } + } + TrackCodecFamily::Ac3 => { + if let Some(dac3) = config_refs.dac3 { + return TrackCodecDetails::Ac3(Ac3CodecDetails { + sample_rate_code: dac3.fscod, + bit_stream_identification: dac3.bsid, + bit_stream_mode: dac3.bsmod, + audio_coding_mode: dac3.acmod, + lfe_on: dac3.lfe_on != 0, + bit_rate_code: dac3.bit_rate_code, + }); } } + TrackCodecFamily::Pcm => { + if let Some(pcmc) = config_refs.pcmc { + return TrackCodecDetails::Pcm(PcmCodecDetails { + format_flags: pcmc.format_flags, + sample_size: pcmc.pcm_sample_size, + }); + } + } + TrackCodecFamily::XmlSubtitle => { + if let Some(entry) = config_refs.xml_subtitle_sample_entry { + return TrackCodecDetails::XmlSubtitle(XmlSubtitleCodecDetails { + namespace: entry.namespace.clone(), + schema_location: entry.schema_location.clone(), + auxiliary_mime_types: entry.auxiliary_mime_types.clone(), + }); + } + } + TrackCodecFamily::TextSubtitle => { + if let Some(entry) = config_refs.text_subtitle_sample_entry { + return TrackCodecDetails::TextSubtitle(TextSubtitleCodecDetails { + content_encoding: entry.content_encoding.clone(), + mime_format: entry.mime_format.clone(), + }); + } + } + TrackCodecFamily::WebVtt => { + if config_refs.webvtt_configuration.is_some() + || config_refs.webvtt_source_label.is_some() + { + return TrackCodecDetails::WebVtt(WebVttCodecDetails { + config: config_refs + .webvtt_configuration + .map(|value| value.config.clone()), + source_label: config_refs + .webvtt_source_label + .map(|value| value.source_label.clone()), + }); + } + } + TrackCodecFamily::Unknown | TrackCodecFamily::Avc | TrackCodecFamily::Mp4Audio => {} + } + + TrackCodecDetails::Unknown +} + +fn build_track_media_characteristics( + refs: &TrackMediaCharacteristicRefs<'_>, +) -> TrackMediaCharacteristics { + TrackMediaCharacteristics { + declared_bitrate: refs.btrt.map(|value| DeclaredBitrateInfo { + buffer_size_db: value.buffer_size_db, + max_bitrate: value.max_bitrate, + avg_bitrate: value.avg_bitrate, + }), + color: refs.colr.map(track_color_info), + pixel_aspect_ratio: refs.pasp.map(|value| PixelAspectRatioInfo { + h_spacing: value.h_spacing, + v_spacing: value.v_spacing, + }), + field_order: refs.fiel.map(track_field_order_info), + } +} + +fn track_color_info(value: &Colr) -> ColorInfo { + let is_nclx = value.colour_type == COLR_NCLX; + let stores_profile = matches!(value.colour_type, COLR_RICC | COLR_PROF); + ColorInfo { + colour_type: value.colour_type, + colour_primaries: is_nclx.then_some(value.colour_primaries), + transfer_characteristics: is_nclx.then_some(value.transfer_characteristics), + matrix_coefficients: is_nclx.then_some(value.matrix_coefficients), + full_range: is_nclx.then_some(value.full_range_flag), + profile_size: stores_profile.then_some(value.profile.len()), + unknown_size: (!is_nclx && !stores_profile).then_some(value.unknown.len()), } +} + +fn track_field_order_info(value: &Fiel) -> FieldOrderInfo { + FieldOrderInfo { + field_count: value.field_count, + field_ordering: value.field_ordering, + // `fiel` uses `1` for progressive content and multiple fields for interlaced layouts. + interlaced: value.field_count > 1, + } +} + +fn hevc_profile_compatibility_mask(flags: &[bool; 32]) -> u32 { + let mut mask = 0_u32; + for (index, value) in flags.iter().copied().enumerate() { + if value { + mask |= 1_u32 << (31 - index); + } + } + mask +} + +fn av1_bit_depth(config: &AV1CodecConfiguration) -> u8 { + if config.high_bitdepth == 0 { + 8 + } else if config.twelve_bit != 0 { + 12 + } else { + 10 + } +} + +fn vp_codec_details(config: &VpCodecConfiguration) -> VpCodecDetails { + VpCodecDetails { + profile: config.profile, + level: config.level, + bit_depth: config.bit_depth, + chroma_subsampling: config.chroma_subsampling, + full_range: config.video_full_range_flag != 0, + colour_primaries: config.colour_primaries, + transfer_characteristics: config.transfer_characteristics, + matrix_coefficients: config.matrix_coefficients, + codec_initialization_data_size: config.codec_initialization_data_size, + } +} - track.chunks = chunks; - track.samples = samples; - Ok(track) +fn decode_language(language: [u8; 3]) -> String { + language + .into_iter() + .map(|value| char::from(value.saturating_add(0x60))) + .collect() } fn probe_moof(reader: &mut R, parent: &BoxInfo) -> Result diff --git a/src/stringify.rs b/src/stringify.rs index 1f13c94..248ce69 100644 --- a/src/stringify.rs +++ b/src/stringify.rs @@ -8,20 +8,18 @@ use crate::codec::{ ResolvedField, }; -/// Renders a descriptor-backed box into the compact single-line form used by tests and CLI output. -pub fn stringify( - src: &dyn CodecDescription, - hooks: Option<&dyn FieldHooks>, -) -> Result { - stringify_with_indent(src, "", hooks) +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct StructuredStringifyField { + pub name: &'static str, + pub value: FieldValue, + pub rendered_value: String, + pub include_display_value: bool, } -/// Renders a descriptor-backed box with one field per line using the supplied indentation prefix. -pub fn stringify_with_indent( +pub(crate) fn collect_structured_fields( src: &dyn CodecDescription, - indent: &str, hooks: Option<&dyn FieldHooks>, -) -> Result { +) -> Result, StringifyError> { let mut resolved = src.field_table().resolve_active(src, hooks)?; resolved.sort_by_key(ResolvedField::display_order); let mut rendered_fields = Vec::new(); @@ -31,9 +29,37 @@ pub fn stringify_with_indent( continue; } - rendered_fields.push(render_field(src, field)?); + let (value, rendered_value, include_display_value) = collect_field(src, field)?; + rendered_fields.push(StructuredStringifyField { + name: field.name(), + value, + rendered_value, + include_display_value, + }); } + Ok(rendered_fields) +} + +/// Renders a descriptor-backed box into the compact single-line form used by tests and CLI output. +pub fn stringify( + src: &dyn CodecDescription, + hooks: Option<&dyn FieldHooks>, +) -> Result { + stringify_with_indent(src, "", hooks) +} + +/// Renders a descriptor-backed box with one field per line using the supplied indentation prefix. +pub fn stringify_with_indent( + src: &dyn CodecDescription, + indent: &str, + hooks: Option<&dyn FieldHooks>, +) -> Result { + let rendered_fields = collect_structured_fields(src, hooks)? + .into_iter() + .map(|field| format!("{}={}", field.name, field.rendered_value)) + .collect::>(); + if indent.is_empty() { return Ok(rendered_fields.join(" ")); } @@ -47,24 +73,50 @@ pub fn stringify_with_indent( Ok(rendered) } -fn render_field( +fn collect_field( src: &dyn CodecDescription, field: ResolvedField<'_>, +) -> Result<(FieldValue, String, bool), StringifyError> { + match field.descriptor.role { + crate::codec::FieldRole::Version => { + let value = FieldValue::Unsigned(u64::from(src.version())); + let rendered = value_string(field, src, &value)?; + Ok((value, rendered, false)) + } + crate::codec::FieldRole::Flags => { + let value = FieldValue::Unsigned(u64::from(src.flags())); + let rendered = render_flags(src.flags(), field); + Ok((value, rendered, true)) + } + crate::codec::FieldRole::Data => { + let value = src.field_value(field.name())?; + let rendered = value_string(field, src, &value)?; + let include_display_value = src.display_field(field.name()).is_some() + || !matches!( + field.descriptor.display.format, + FieldFormat::Default | FieldFormat::Decimal + ); + Ok((value, rendered, include_display_value)) + } + } +} + +fn value_string( + field: ResolvedField<'_>, + src: &dyn CodecDescription, + value: &FieldValue, ) -> Result { - let value = match field.descriptor.role { - crate::codec::FieldRole::Version => src.version().to_string(), - crate::codec::FieldRole::Flags => render_flags(src.flags(), field), + match field.descriptor.role { + crate::codec::FieldRole::Version => render_default_value(value), + crate::codec::FieldRole::Flags => Ok(render_flags(src.flags(), field)), crate::codec::FieldRole::Data => { if let Some(rendered) = src.display_field(field.name()) { - rendered + Ok(rendered) } else { - let value = src.field_value(field.name())?; - render_value(field, &value)? + render_value(field, value) } } - }; - - Ok(format!("{}={value}", field.name())) + } } fn render_flags(value: u32, field: ResolvedField<'_>) -> String { diff --git a/tests/cli_dispatch.rs b/tests/cli_dispatch.rs index b0c4eef..ecc1caf 100644 --- a/tests/cli_dispatch.rs +++ b/tests/cli_dispatch.rs @@ -15,7 +15,7 @@ fn dispatch_prints_usage_for_empty_or_unknown_commands() { " divide split a fragmented MP4 into track playlists\n", " dump display the MP4 box tree\n", " edit rewrite selected boxes\n", - " extract extract raw boxes by type\n", + " extract extract raw boxes by type or path\n", " psshdump summarize pssh boxes\n", " probe summarize an MP4 file\n" ) @@ -37,7 +37,7 @@ fn dispatch_prints_usage_for_empty_or_unknown_commands() { " divide split a fragmented MP4 into track playlists\n", " dump display the MP4 box tree\n", " edit rewrite selected boxes\n", - " extract extract raw boxes by type\n", + " extract extract raw boxes by type or path\n", " psshdump summarize pssh boxes\n", " probe summarize an MP4 file\n" ) @@ -62,7 +62,7 @@ fn dispatch_handles_help() { " divide split a fragmented MP4 into track playlists\n", " dump display the MP4 box tree\n", " edit rewrite selected boxes\n", - " extract extract raw boxes by type\n", + " extract extract raw boxes by type or path\n", " psshdump summarize pssh boxes\n", " probe summarize an MP4 file\n" ) diff --git a/tests/cli_divide.rs b/tests/cli_divide.rs index 038d357..5a425c7 100644 --- a/tests/cli_divide.rs +++ b/tests/cli_divide.rs @@ -7,9 +7,13 @@ use std::path::Path; use mp4forge::boxes::AnyTypeBox; use mp4forge::boxes::iso14496_12::{ - AVCDecoderConfiguration, Mdhd, Stco, Stsc, StscEntry, Stsd, Stsz, Stts, SttsEntry, - TFHD_DEFAULT_SAMPLE_DURATION_PRESENT, TFHD_DEFAULT_SAMPLE_SIZE_PRESENT, Tfdt, Tfhd, Tkhd, Trun, - VisualSampleEntry, + AVCDecoderConfiguration, AudioSampleEntry, Ftyp, HEVCDecoderConfiguration, Mdhd, SampleEntry, + Stco, Stsc, StscEntry, Stsd, Stsz, Stts, SttsEntry, TFHD_DEFAULT_SAMPLE_DURATION_PRESENT, + TFHD_DEFAULT_SAMPLE_SIZE_PRESENT, Tfdt, Tfhd, Tkhd, Trun, VisualSampleEntry, +}; +use mp4forge::boxes::iso14496_14::{ + DECODER_CONFIG_DESCRIPTOR_TAG, DECODER_SPECIFIC_INFO_TAG, DecoderConfigDescriptor, Descriptor, + Esds, }; use mp4forge::cli::divide; use mp4forge::codec::MutableBox; @@ -49,7 +53,7 @@ fn divide_command_writes_playlists_and_segments() { master_playlist, concat!( "#EXTM3U\n", - "#EXT-X-STREAM-INF:BANDWIDTH=128,CODECS=\"avc1.64001f,mp4a.40.2\",RESOLUTION=1920x1080\n", + "#EXT-X-STREAM-INF:BANDWIDTH=128,CODECS=\"avc1.64001f\",RESOLUTION=1920x1080\n", "video/playlist.m3u8\n" ) ); @@ -82,8 +86,72 @@ fn divide_command_validates_argument_shape() { assert_eq!(divide::run(&[], &mut stderr), 1); assert_eq!( String::from_utf8(stderr).unwrap(), - "USAGE: mp4forge divide INPUT.mp4 OUTPUT_DIR\n" + concat!( + "USAGE: mp4forge divide INPUT.mp4 OUTPUT_DIR\n", + " mp4forge divide -validate INPUT.mp4\n", + "\n", + "OPTIONS:\n", + " -validate Validate the fragmented divide layout without writing output files\n", + "\n", + "Currently supports fragmented inputs with up to one AVC video track and one MP4A audio track,\n", + "including encrypted wrappers that preserve those original sample-entry formats.\n", + ) + ); +} + +#[test] +fn divide_command_derives_master_playlist_signaling_from_probe_metadata() { + let input = build_video_and_audio_divide_input_file(); + let input_path = write_temp_file("divide-signaling-input", &input); + let output_dir = temp_output_dir("divide-signaling-output"); + let args = vec![ + input_path.to_string_lossy().into_owned(), + output_dir.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = divide::run(&args, &mut stderr); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + assert_eq!( + read_text(&output_dir.join("playlist.m3u8")), + concat!( + "#EXTM3U\n", + "#EXT-X-MEDIA:TYPE=AUDIO,URI=\"audio/playlist.m3u8\",GROUP-ID=\"audio\",NAME=\"audio\",AUTOSELECT=YES,CHANNELS=\"6\"\n", + "#EXT-X-STREAM-INF:BANDWIDTH=128,CODECS=\"avc1.4d401f,mp4a.40.5\",RESOLUTION=640x360,AUDIO=\"audio\"\n", + "video/playlist.m3u8\n" + ) + ); + + let _ = fs::remove_file(&input_path); + let _ = fs::remove_dir_all(&output_dir); +} + +#[test] +fn divide_command_rejects_multiple_video_tracks_with_clear_message() { + let input = build_two_video_track_divide_input_file(); + let input_path = write_temp_file("divide-multi-video-input", &input); + let output_dir = temp_output_dir("divide-multi-video-output"); + let args = vec![ + input_path.to_string_lossy().into_owned(), + output_dir.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = divide::run(&args, &mut stderr); + + assert_eq!(exit_code, 1); + assert_eq!( + String::from_utf8(stderr).unwrap(), + concat!( + "Error: divide currently supports fragmented inputs with at most one AVC video track and one MP4A audio track; ", + "found multiple fragmented video tracks (1 and 2).\n" + ) ); + + let _ = fs::remove_file(&input_path); + let _ = fs::remove_dir_all(&output_dir); } #[test] @@ -180,37 +248,187 @@ fn divide_command_matches_shared_fragmented_fixture_outputs() { let _ = fs::remove_dir_all(&output_dir); } +#[test] +fn divide_validate_reports_supported_layout_without_writing_files() { + let input = build_video_and_audio_divide_input_file(); + let input_path = write_temp_file("divide-validate-supported-input", &input); + let args = vec![ + "-validate".to_string(), + input_path.to_string_lossy().into_owned(), + ]; + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let exit_code = divide::run_with_output(&args, &mut stdout, &mut stderr); + + let _ = fs::remove_file(&input_path); + + assert_eq!(exit_code, 0, "{}", String::from_utf8_lossy(&stderr)); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + assert_eq!( + String::from_utf8(stdout).unwrap(), + concat!( + "supported fragmented divide layout\n", + "track 1: role=video codec=avc1 segments=1\n", + "track 2: role=audio codec=mp4a segments=1\n", + ) + ); +} + +#[test] +fn divide_validate_rejects_duplicate_video_layouts_before_writing_output() { + let input = build_two_video_track_divide_input_file(); + let input_path = write_temp_file("divide-validate-duplicate-video-input", &input); + let args = vec![ + "--validate".to_string(), + input_path.to_string_lossy().into_owned(), + ]; + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let exit_code = divide::run_with_output(&args, &mut stdout, &mut stderr); + + let _ = fs::remove_file(&input_path); + + assert_eq!(exit_code, 1); + assert_eq!(String::from_utf8(stdout).unwrap(), ""); + assert_eq!( + String::from_utf8(stderr).unwrap(), + concat!( + "Error: divide currently supports fragmented inputs with at most one AVC video track and one MP4A audio track; ", + "found multiple fragmented video tracks (1 and 2).\n" + ) + ); +} + +#[test] +fn divide_validate_rejects_unsupported_hevc_layout_with_clear_message() { + let input = build_hevc_divide_input_file(); + let input_path = write_temp_file("divide-validate-hevc-input", &input); + let args = vec![ + "-validate".to_string(), + input_path.to_string_lossy().into_owned(), + ]; + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let exit_code = divide::run_with_output(&args, &mut stdout, &mut stderr); + + let _ = fs::remove_file(&input_path); + + assert_eq!(exit_code, 1); + assert_eq!(String::from_utf8(stdout).unwrap(), ""); + assert_eq!( + String::from_utf8(stderr).unwrap(), + concat!( + "Error: track 1 uses unsupported codec `hvc1`; ", + "divide currently supports fragmented inputs with at most one AVC video track and one MP4A audio track\n" + ) + ); +} + +#[test] +fn validate_divide_reader_reports_supported_tracks() { + let input = build_video_and_audio_divide_input_file(); + let report = divide::validate_divide_reader(&mut std::io::Cursor::new(input)).unwrap(); + + assert_eq!(report.tracks.len(), 2); + assert_eq!(report.tracks[0].track_id, 1); + assert_eq!(report.tracks[0].role, divide::DivideTrackRole::Video); + assert_eq!(report.tracks[0].sample_entry_type, Some(fourcc("avc1"))); + assert_eq!(report.tracks[0].segment_count, 1); + assert_eq!(report.tracks[1].track_id, 2); + assert_eq!(report.tracks[1].role, divide::DivideTrackRole::Audio); + assert_eq!(report.tracks[1].sample_entry_type, Some(fourcc("mp4a"))); + assert_eq!(report.tracks[1].segment_count, 1); +} + fn build_divide_input_file() -> Vec { + build_fragmented_input_file( + vec![build_video_trak_with_profile( + 1, 1_920, 1_080, 0x64, 0x00, 0x1f, + )], + vec![ + build_track_segment(1, 0, 1_000, 8), + build_track_segment(1, 1_000, 1_000, 8), + ], + ) +} + +fn build_video_and_audio_divide_input_file() -> Vec { + build_fragmented_input_file( + vec![ + build_video_trak_with_profile(1, 640, 360, 0x4d, 0x40, 0x1f), + build_audio_trak(2, 6, 0x40, &[0x10, 0x02, 0xb7, 0x2c, 0x00]), + ], + vec![ + build_track_segment(1, 0, 1_000, 8), + build_track_segment(2, 0, 1_000, 6), + ], + ) +} + +fn build_two_video_track_divide_input_file() -> Vec { + build_fragmented_input_file( + vec![ + build_video_trak_with_profile(1, 640, 360, 0x64, 0x00, 0x1f), + build_video_trak_with_profile(2, 320, 180, 0x42, 0x00, 0x1e), + ], + vec![ + build_track_segment(1, 0, 1_000, 8), + build_track_segment(2, 0, 1_000, 8), + ], + ) +} + +fn build_hevc_divide_input_file() -> Vec { + build_fragmented_input_file( + vec![build_hevc_trak(1, 640, 360)], + vec![build_track_segment(1, 0, 1_000, 8)], + ) +} + +fn build_fragmented_input_file(traks: Vec>, segments: Vec>) -> Vec { let ftyp = encode_supported_box( - &mp4forge::boxes::iso14496_12::Ftyp { + &Ftyp { major_brand: fourcc("iso6"), minor_version: 1, compatible_brands: vec![fourcc("iso6"), fourcc("dash")], }, &[], ); - let moov = encode_raw_box(fourcc("moov"), &build_video_trak()); - let segment0 = build_video_segment(0); - let segment1 = build_video_segment(1_000); - [ftyp, moov, segment0, segment1].concat() + let moov = encode_raw_box(fourcc("moov"), &traks.concat()); + + let mut file = [ftyp, moov].concat(); + for segment in segments { + file.extend_from_slice(&segment); + } + file } -fn build_video_trak() -> Vec { +fn build_video_trak_with_profile( + track_id: u32, + width: u16, + height: u16, + profile: u8, + profile_compatibility: u8, + level: u8, +) -> Vec { let mut tkhd = Tkhd::default(); - tkhd.track_id = 1; - tkhd.width = 1_920 << 16; - tkhd.height = 1_080 << 16; + tkhd.track_id = track_id; + tkhd.width = u32::from(width) << 16; + tkhd.height = u32::from(height) << 16; let mut mdhd = Mdhd::default(); mdhd.timescale = 1_000; - mdhd.duration_v0 = 2_000; + mdhd.duration_v0 = 1_000; let avcc = encode_supported_box( &AVCDecoderConfiguration { configuration_version: 1, - profile: 0x64, - profile_compatibility: 0, - level: 0x1f, + profile, + profile_compatibility, + level, length_size_minus_one: 3, ..AVCDecoderConfiguration::default() }, @@ -220,8 +438,8 @@ fn build_video_trak() -> Vec { let mut avc1 = VisualSampleEntry::default(); avc1.set_box_type(fourcc("avc1")); avc1.sample_entry.data_reference_index = 1; - avc1.width = 1_920; - avc1.height = 1_080; + avc1.width = width; + avc1.height = height; avc1.horizresolution = 0x0048_0000; avc1.vertresolution = 0x0048_0000; avc1.frame_count = 1; @@ -270,11 +488,161 @@ fn build_video_trak() -> Vec { ) } -fn build_video_segment(base_media_decode_time: u32) -> Vec { +fn build_audio_trak( + track_id: u32, + channel_count: u16, + object_type_indication: u8, + decoder_specific_info: &[u8], +) -> Vec { + let mut tkhd = Tkhd::default(); + tkhd.track_id = track_id; + + let mut mdhd = Mdhd::default(); + mdhd.timescale = 1_000; + mdhd.duration_v0 = 1_000; + + let mut mp4a = AudioSampleEntry::default(); + mp4a.set_box_type(fourcc("mp4a")); + mp4a.sample_entry = SampleEntry { + box_type: fourcc("mp4a"), + data_reference_index: 1, + }; + mp4a.channel_count = channel_count; + mp4a.sample_size = 16; + mp4a.sample_rate = 48_000_u32 << 16; + + let mut stsd = Stsd::default(); + stsd.entry_count = 1; + let mp4a = encode_supported_box( + &mp4a, + &encode_supported_box( + &aac_profile_esds(object_type_indication, decoder_specific_info), + &[], + ), + ); + let stsd = encode_supported_box(&stsd, &mp4a); + + let mut stts = Stts::default(); + stts.entry_count = 1; + stts.entries = vec![SttsEntry { + sample_count: 1, + sample_delta: 1_000, + }]; + let stts = encode_supported_box(&stts, &[]); + + let mut stsc = Stsc::default(); + stsc.entry_count = 1; + stsc.entries = vec![StscEntry { + first_chunk: 1, + samples_per_chunk: 1, + sample_description_index: 1, + }]; + let stsc = encode_supported_box(&stsc, &[]); + + let mut stsz = Stsz::default(); + stsz.sample_size = 6; + stsz.sample_count = 1; + let stsz = encode_supported_box(&stsz, &[]); + + let mut stco = Stco::default(); + stco.entry_count = 0; + let stco = encode_supported_box(&stco, &[]); + + let stbl = encode_raw_box(fourcc("stbl"), &[stsd, stts, stsc, stsz, stco].concat()); + let minf = encode_raw_box(fourcc("minf"), &stbl); + let mdia = encode_raw_box( + fourcc("mdia"), + &[encode_supported_box(&mdhd, &[]), minf].concat(), + ); + encode_raw_box( + fourcc("trak"), + &[encode_supported_box(&tkhd, &[]), mdia].concat(), + ) +} + +fn build_hevc_trak(track_id: u32, width: u16, height: u16) -> Vec { + let mut tkhd = Tkhd::default(); + tkhd.track_id = track_id; + tkhd.width = u32::from(width) << 16; + tkhd.height = u32::from(height) << 16; + + let mut mdhd = Mdhd::default(); + mdhd.timescale = 1_000; + mdhd.duration_v0 = 1_000; + + let hvcc = encode_supported_box( + &HEVCDecoderConfiguration { + configuration_version: 1, + general_profile_idc: 1, + length_size_minus_one: 3, + ..HEVCDecoderConfiguration::default() + }, + &[], + ); + + let mut hvc1 = VisualSampleEntry::default(); + hvc1.set_box_type(fourcc("hvc1")); + hvc1.sample_entry.data_reference_index = 1; + hvc1.width = width; + hvc1.height = height; + hvc1.horizresolution = 0x0048_0000; + hvc1.vertresolution = 0x0048_0000; + hvc1.frame_count = 1; + hvc1.depth = 0x0018; + hvc1.pre_defined3 = -1; + + let mut stsd = Stsd::default(); + stsd.entry_count = 1; + let stsd = encode_supported_box(&stsd, &encode_supported_box(&hvc1, &hvcc)); + + let mut stts = Stts::default(); + stts.entry_count = 1; + stts.entries = vec![SttsEntry { + sample_count: 1, + sample_delta: 1_000, + }]; + let stts = encode_supported_box(&stts, &[]); + + let mut stsc = Stsc::default(); + stsc.entry_count = 1; + stsc.entries = vec![StscEntry { + first_chunk: 1, + samples_per_chunk: 1, + sample_description_index: 1, + }]; + let stsc = encode_supported_box(&stsc, &[]); + + let mut stsz = Stsz::default(); + stsz.sample_size = 8; + stsz.sample_count = 1; + let stsz = encode_supported_box(&stsz, &[]); + + let mut stco = Stco::default(); + stco.entry_count = 0; + let stco = encode_supported_box(&stco, &[]); + + let stbl = encode_raw_box(fourcc("stbl"), &[stsd, stts, stsc, stsz, stco].concat()); + let minf = encode_raw_box(fourcc("minf"), &stbl); + let mdia = encode_raw_box( + fourcc("mdia"), + &[encode_supported_box(&mdhd, &[]), minf].concat(), + ); + encode_raw_box( + fourcc("trak"), + &[encode_supported_box(&tkhd, &[]), mdia].concat(), + ) +} + +fn build_track_segment( + track_id: u32, + base_media_decode_time: u32, + sample_duration: u32, + sample_size: u32, +) -> Vec { let mut tfhd = Tfhd::default(); - tfhd.track_id = 1; - tfhd.default_sample_duration = 1_000; - tfhd.default_sample_size = 8; + tfhd.track_id = track_id; + tfhd.default_sample_duration = sample_duration; + tfhd.default_sample_size = sample_size; tfhd.set_flags(TFHD_DEFAULT_SAMPLE_DURATION_PRESENT | TFHD_DEFAULT_SAMPLE_SIZE_PRESENT); let mut tfdt = Tfdt::default(); @@ -293,10 +661,34 @@ fn build_video_segment(base_media_decode_time: u32) -> Vec { .concat(), ); let moof = encode_raw_box(fourcc("moof"), &traf); - let mdat = encode_raw_box(fourcc("mdat"), &[0, 1, 2, 3, 4, 5, 6, 7]); + let mdat = encode_raw_box(fourcc("mdat"), &vec![0_u8; sample_size as usize]); [moof, mdat].concat() } +fn aac_profile_esds(object_type_indication: u8, decoder_specific_info: &[u8]) -> Esds { + let mut esds = Esds::default(); + esds.descriptors = vec![ + Descriptor { + tag: DECODER_CONFIG_DESCRIPTOR_TAG, + size: 13, + decoder_config_descriptor: Some(DecoderConfigDescriptor { + object_type_indication, + stream_type: 5, + reserved: true, + ..DecoderConfigDescriptor::default() + }), + ..Descriptor::default() + }, + Descriptor { + tag: DECODER_SPECIFIC_INFO_TAG, + size: decoder_specific_info.len() as u32, + data: decoder_specific_info.to_vec(), + ..Descriptor::default() + }, + ]; + esds +} + fn sorted_file_names(path: &Path) -> Vec { let mut names = fs::read_dir(path) .unwrap() diff --git a/tests/cli_dump.rs b/tests/cli_dump.rs index c89f12b..172c7aa 100644 --- a/tests/cli_dump.rs +++ b/tests/cli_dump.rs @@ -3,15 +3,276 @@ mod support; use std::fs; +use std::io::Cursor; use mp4forge::boxes::iso14496_12::{Ftyp, Moov, Mvhd}; -use mp4forge::cli::dump; +use mp4forge::cli::dump::{ + self, DumpPayloadStatus, FieldStructuredDumpBoxReport, FieldStructuredDumpReport, + StructuredDumpBoxReport, StructuredDumpFieldReport, StructuredDumpFormat, StructuredDumpReport, +}; +use mp4forge::codec::FieldValue; +use mp4forge::walk::BoxPath; use support::{ encode_raw_box, encode_supported_box, fixture_path, fourcc, normalize_text, read_golden, write_temp_file, }; +#[test] +fn structured_dump_report_renders_json_and_yaml_with_stable_field_order() { + let report = StructuredDumpReport { + boxes: vec![ + StructuredDumpBoxReport { + box_type: "ftyp".to_string(), + path: "ftyp".to_string(), + offset: 0, + size: 20, + supported: true, + payload_status: DumpPayloadStatus::Summary, + payload_summary: Some( + "MajorBrand=\"isom\" MinorVersion=512 CompatibleBrands=[{CompatibleBrand=\"isom\"}]" + .to_string(), + ), + payload_bytes: None, + children: Vec::new(), + }, + StructuredDumpBoxReport { + box_type: "moov".to_string(), + path: "moov".to_string(), + offset: 20, + size: 116, + supported: true, + payload_status: DumpPayloadStatus::Empty, + payload_summary: None, + payload_bytes: None, + children: vec![StructuredDumpBoxReport { + box_type: "mvhd".to_string(), + path: "moov/mvhd".to_string(), + offset: 28, + size: 108, + supported: true, + payload_status: DumpPayloadStatus::Omitted, + payload_summary: None, + payload_bytes: None, + children: Vec::new(), + }], + }, + StructuredDumpBoxReport { + box_type: "zzzz".to_string(), + path: "zzzz".to_string(), + offset: 136, + size: 11, + supported: false, + payload_status: DumpPayloadStatus::Bytes, + payload_summary: None, + payload_bytes: Some(vec![1, 2, 3]), + children: Vec::new(), + }, + ], + }; + + let mut json = Vec::new(); + dump::write_structured_report(&mut json, &report, StructuredDumpFormat::Json).unwrap(); + assert_eq!( + String::from_utf8(json).unwrap(), + concat!( + "{\n", + " \"Boxes\": [\n", + " {\n", + " \"BoxType\": \"ftyp\",\n", + " \"Path\": \"ftyp\",\n", + " \"Offset\": 0,\n", + " \"Size\": 20,\n", + " \"Supported\": true,\n", + " \"PayloadStatus\": \"summary\",\n", + " \"PayloadSummary\": \"MajorBrand=\\\"isom\\\" MinorVersion=512 CompatibleBrands=[{CompatibleBrand=\\\"isom\\\"}]\",\n", + " \"Children\": [\n", + " ]\n", + " },\n", + " {\n", + " \"BoxType\": \"moov\",\n", + " \"Path\": \"moov\",\n", + " \"Offset\": 20,\n", + " \"Size\": 116,\n", + " \"Supported\": true,\n", + " \"PayloadStatus\": \"empty\",\n", + " \"Children\": [\n", + " {\n", + " \"BoxType\": \"mvhd\",\n", + " \"Path\": \"moov/mvhd\",\n", + " \"Offset\": 28,\n", + " \"Size\": 108,\n", + " \"Supported\": true,\n", + " \"PayloadStatus\": \"omitted\",\n", + " \"Children\": [\n", + " ]\n", + " }\n", + " ]\n", + " },\n", + " {\n", + " \"BoxType\": \"zzzz\",\n", + " \"Path\": \"zzzz\",\n", + " \"Offset\": 136,\n", + " \"Size\": 11,\n", + " \"Supported\": false,\n", + " \"PayloadStatus\": \"bytes\",\n", + " \"PayloadBytes\": [\n", + " 1,\n", + " 2,\n", + " 3\n", + " ],\n", + " \"Children\": [\n", + " ]\n", + " }\n", + " ]\n", + "}\n" + ) + ); + + let mut yaml = Vec::new(); + dump::write_structured_report(&mut yaml, &report, StructuredDumpFormat::Yaml).unwrap(); + assert_eq!( + String::from_utf8(yaml).unwrap(), + concat!( + "boxes:\n", + "- box_type: ftyp\n", + " path: ftyp\n", + " offset: 0\n", + " size: 20\n", + " supported: true\n", + " payload_status: summary\n", + " payload_summary: 'MajorBrand=\"isom\" MinorVersion=512 CompatibleBrands=[{CompatibleBrand=\"isom\"}]'\n", + " children: []\n", + "- box_type: moov\n", + " path: moov\n", + " offset: 20\n", + " size: 116\n", + " supported: true\n", + " payload_status: empty\n", + " children:\n", + " - box_type: mvhd\n", + " path: moov/mvhd\n", + " offset: 28\n", + " size: 108\n", + " supported: true\n", + " payload_status: omitted\n", + " children: []\n", + "- box_type: zzzz\n", + " path: zzzz\n", + " offset: 136\n", + " size: 11\n", + " supported: false\n", + " payload_status: bytes\n", + " payload_bytes:\n", + " - 1\n", + " - 2\n", + " - 3\n", + " children: []\n" + ) + ); +} + +#[test] +fn field_structured_dump_report_renders_json_and_yaml_with_stable_field_order() { + let report = FieldStructuredDumpReport { + boxes: vec![FieldStructuredDumpBoxReport { + box_type: "ftyp".to_string(), + path: "ftyp".to_string(), + offset: 0, + size: 20, + supported: true, + payload_status: DumpPayloadStatus::Summary, + payload_fields: vec![ + StructuredDumpFieldReport { + name: "MajorBrand".to_string(), + value: FieldValue::String("isom".to_string()), + display_value: None, + }, + StructuredDumpFieldReport { + name: "CompatibleBrands".to_string(), + value: FieldValue::Bytes(vec![105, 115, 111, 109]), + display_value: Some("[{CompatibleBrand=\"isom\"}]".to_string()), + }, + ], + payload_summary: Some( + "MajorBrand=\"isom\" CompatibleBrands=[{CompatibleBrand=\"isom\"}]".to_string(), + ), + payload_bytes: None, + children: Vec::new(), + }], + }; + + let mut json = Vec::new(); + dump::write_field_structured_report(&mut json, &report, StructuredDumpFormat::Json).unwrap(); + assert_eq!( + String::from_utf8(json).unwrap(), + concat!( + "{\n", + " \"Boxes\": [\n", + " {\n", + " \"BoxType\": \"ftyp\",\n", + " \"Path\": \"ftyp\",\n", + " \"Offset\": 0,\n", + " \"Size\": 20,\n", + " \"Supported\": true,\n", + " \"PayloadStatus\": \"summary\",\n", + " \"PayloadFields\": [\n", + " {\n", + " \"Name\": \"MajorBrand\",\n", + " \"ValueKind\": \"string\",\n", + " \"Value\": \"isom\"\n", + " },\n", + " {\n", + " \"Name\": \"CompatibleBrands\",\n", + " \"ValueKind\": \"bytes\",\n", + " \"Value\": [\n", + " 105,\n", + " 115,\n", + " 111,\n", + " 109\n", + " ],\n", + " \"DisplayValue\": \"[{CompatibleBrand=\\\"isom\\\"}]\"\n", + " }\n", + " ],\n", + " \"PayloadSummary\": \"MajorBrand=\\\"isom\\\" CompatibleBrands=[{CompatibleBrand=\\\"isom\\\"}]\",\n", + " \"Children\": [\n", + " ]\n", + " }\n", + " ]\n", + "}\n" + ) + ); + + let mut yaml = Vec::new(); + dump::write_field_structured_report(&mut yaml, &report, StructuredDumpFormat::Yaml).unwrap(); + assert_eq!( + String::from_utf8(yaml).unwrap(), + concat!( + "boxes:\n", + "- box_type: ftyp\n", + " path: ftyp\n", + " offset: 0\n", + " size: 20\n", + " supported: true\n", + " payload_status: summary\n", + " payload_fields:\n", + " - name: MajorBrand\n", + " value_kind: string\n", + " value: isom\n", + " - name: CompatibleBrands\n", + " value_kind: bytes\n", + " value:\n", + " - 105\n", + " - 115\n", + " - 111\n", + " - 109\n", + " display_value: '[{CompatibleBrand=\"isom\"}]'\n", + " payload_summary: 'MajorBrand=\"isom\" CompatibleBrands=[{CompatibleBrand=\"isom\"}]'\n", + " children: []\n" + ) + ); +} + #[test] fn dump_command_renders_supported_and_unsupported_boxes() { let path = write_temp_file("dump-cli", &build_dump_input_file()); @@ -42,6 +303,8 @@ fn dump_command_matches_shared_fixture_goldens() { let fixture = fixture_path("sample.mp4"); let cases: &[(&[&str], &str)] = &[ (&[], "cli_dump/sample.txt"), + (&["-format", "json"], "cli_dump/sample.json"), + (&["-format", "yaml"], "cli_dump/sample.yaml"), ( &["-full", "mvhd,loci"], "cli_dump/sample-full-mvhd-loci.txt", @@ -75,6 +338,106 @@ fn dump_command_matches_shared_fixture_goldens() { } } +#[test] +fn structured_dump_report_respects_full_payload_controls() { + let mut default_reader = Cursor::new(build_dump_input_file()); + let default_report = + dump::build_structured_report(&mut default_reader, &dump::DumpOptions::default()).unwrap(); + + assert_eq!(default_report.boxes.len(), 4); + assert_eq!( + default_report.boxes[0].payload_status, + DumpPayloadStatus::Summary + ); + assert_eq!( + default_report.boxes[1].payload_status, + DumpPayloadStatus::Omitted + ); + assert_eq!( + default_report.boxes[2].payload_status, + DumpPayloadStatus::Omitted + ); + assert_eq!( + default_report.boxes[3].payload_status, + DumpPayloadStatus::Empty + ); + assert_eq!( + default_report.boxes[3].children[0].payload_status, + DumpPayloadStatus::Summary + ); + + let mut full_options = dump::DumpOptions::default(); + full_options.full_box_types.insert(fourcc("free")); + full_options.full_box_types.insert(fourcc("zzzz")); + full_options.full_box_types.insert(fourcc("mvhd")); + + let mut full_reader = Cursor::new(build_dump_input_file()); + let full_report = dump::build_structured_report(&mut full_reader, &full_options).unwrap(); + + assert_eq!( + full_report.boxes[1].payload_status, + DumpPayloadStatus::Summary + ); + assert!( + full_report.boxes[1] + .payload_summary + .as_ref() + .unwrap() + .contains("Data=[0xaa, 0xbb, 0xcc, 0xdd]") + ); + assert_eq!( + full_report.boxes[2].payload_status, + DumpPayloadStatus::Bytes + ); + assert_eq!( + full_report.boxes[2].payload_bytes.as_deref(), + Some(&[1, 2, 3][..]) + ); + assert_eq!( + full_report.boxes[3].children[0].payload_status, + DumpPayloadStatus::Summary + ); + assert!( + full_report.boxes[3].children[0] + .payload_summary + .as_ref() + .unwrap() + .contains("Version=0") + ); +} + +#[test] +fn field_structured_dump_report_prefers_supported_fields_over_legacy_leaf_omission() { + let fixture = fixture_path("sample.mp4"); + let mut reader = fs::File::open(&fixture).unwrap(); + let report = + dump::build_field_structured_report(&mut reader, &dump::DumpOptions::default()).unwrap(); + + let mdat = find_field_box(&report.boxes, "mdat").unwrap(); + assert_eq!(mdat.payload_status, DumpPayloadStatus::Omitted); + assert!(mdat.payload_fields.is_empty()); + + let ctts = find_field_box(&report.boxes, "moov/trak/mdia/minf/stbl/ctts").unwrap(); + assert_eq!(ctts.payload_status, DumpPayloadStatus::Summary); + assert!(ctts.payload_summary.is_some()); + assert_eq!( + ctts.payload_fields + .iter() + .map(|field| field.name.as_str()) + .collect::>(), + vec!["Version", "Flags", "EntryCount", "Entries"] + ); + let entries = &ctts.payload_fields[3]; + assert!(matches!(entries.value, FieldValue::Bytes(_))); + assert!( + entries + .display_value + .as_ref() + .unwrap() + .contains("SampleCount=1") + ); +} + #[test] fn dump_command_accepts_go_style_long_options() { let fixture = fixture_path("sample.mp4"); @@ -119,6 +482,165 @@ fn dump_command_reads_quicktime_wave_audio_children() { )); } +#[test] +fn dump_command_scopes_text_output_to_selected_subtrees() { + let path = write_temp_file("dump-cli-path", &build_dump_input_file()); + let args = vec![ + "--path".to_string(), + "moov".to_string(), + path.to_string_lossy().into_owned(), + ]; + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let exit_code = dump::run(&args, &mut stdout, &mut stderr); + + let _ = fs::remove_file(&path); + + assert_eq!(exit_code, 0); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + assert_eq!( + String::from_utf8(stdout).unwrap(), + concat!( + "[moov] Size=116\n", + " [mvhd] Size=108 ... (use \"-full mvhd\" to show all)\n" + ) + ); +} + +#[test] +fn dump_command_scopes_structured_output_to_selected_subtrees() { + let path = write_temp_file("dump-cli-path-json", &build_dump_input_file()); + let args = vec![ + "--format".to_string(), + "json".to_string(), + "--path".to_string(), + "moov".to_string(), + path.to_string_lossy().into_owned(), + ]; + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let exit_code = dump::run(&args, &mut stdout, &mut stderr); + + let _ = fs::remove_file(&path); + + assert_eq!(exit_code, 0); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + + let output = String::from_utf8(stdout).unwrap(); + assert!(output.contains("\"Path\": \"moov\"")); + assert!(output.contains("\"Path\": \"moov/mvhd\"")); + assert!(!output.contains("\"Path\": \"ftyp\"")); + assert!(!output.contains("\"Path\": \"zzzz\"")); +} + +#[test] +fn structured_dump_report_paths_support_wildcards_and_exact_roots() { + let fixture = fixture_path("sample.mp4"); + let mut reader = fs::File::open(&fixture).unwrap(); + let paths = vec![BoxPath::parse("moov/*/mdia/mdhd").unwrap()]; + let report = + dump::build_structured_report_paths(&mut reader, &dump::DumpOptions::default(), &paths) + .unwrap(); + + assert_eq!(report.boxes.len(), 2); + assert!(report.boxes.iter().all(|entry| entry.box_type == "mdhd")); + assert!( + report + .boxes + .iter() + .all(|entry| entry.path == "moov/trak/mdia/mdhd") + ); + assert!(report.boxes.iter().all(|entry| entry.children.is_empty())); +} + +#[test] +fn dump_command_treats_root_path_like_full_dump() { + let fixture = fixture_path("sample.mp4"); + let args = vec![ + "--path".to_string(), + "".to_string(), + fixture.to_string_lossy().into_owned(), + ]; + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let exit_code = dump::run(&args, &mut stdout, &mut stderr); + + assert_eq!(exit_code, 0); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + assert_eq!( + normalize_text(&String::from_utf8(stdout).unwrap()), + read_golden("cli_dump/sample.txt") + ); +} + +#[test] +fn dump_command_returns_empty_output_for_unmatched_paths() { + let fixture = fixture_path("sample.mp4"); + + let mut text_stdout = Vec::new(); + let mut text_stderr = Vec::new(); + let text_exit_code = dump::run( + &[ + "--path".to_string(), + "moov/zzzz".to_string(), + fixture.to_string_lossy().into_owned(), + ], + &mut text_stdout, + &mut text_stderr, + ); + + assert_eq!(text_exit_code, 0); + assert_eq!(String::from_utf8(text_stderr).unwrap(), ""); + assert_eq!(String::from_utf8(text_stdout).unwrap(), ""); + + let mut json_stdout = Vec::new(); + let mut json_stderr = Vec::new(); + let json_exit_code = dump::run( + &[ + "--format".to_string(), + "json".to_string(), + "--path".to_string(), + "moov/zzzz".to_string(), + fixture.to_string_lossy().into_owned(), + ], + &mut json_stdout, + &mut json_stderr, + ); + + assert_eq!(json_exit_code, 0); + assert_eq!(String::from_utf8(json_stderr).unwrap(), ""); + assert_eq!( + String::from_utf8(json_stdout).unwrap(), + concat!("{\n", " \"Boxes\": [\n", " ]\n", "}\n") + ); +} + +#[test] +fn dump_command_rejects_invalid_path_arguments() { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + assert_eq!( + dump::run( + &[ + "--path".to_string(), + "moov/trakk".to_string(), + fixture_path("sample.mp4").to_string_lossy().into_owned(), + ], + &mut stdout, + &mut stderr, + ), + 1 + ); + assert_eq!(String::from_utf8(stdout).unwrap(), ""); + assert_eq!( + String::from_utf8(stderr).unwrap(), + "Error: invalid box path: invalid box path segment 2 (\"trakk\"): fourcc values must be exactly 4 bytes, got 5\n" + ); +} + fn build_dump_input_file() -> Vec { let ftyp = encode_supported_box( &Ftyp { @@ -140,3 +662,18 @@ fn build_dump_input_file() -> Vec { [ftyp, free, unknown, moov].concat() } + +fn find_field_box<'a>( + boxes: &'a [FieldStructuredDumpBoxReport], + path: &str, +) -> Option<&'a FieldStructuredDumpBoxReport> { + for entry in boxes { + if entry.path == path { + return Some(entry); + } + if let Some(found) = find_field_box(&entry.children, path) { + return Some(found); + } + } + None +} diff --git a/tests/cli_edit.rs b/tests/cli_edit.rs index 8b9867c..83405fd 100644 --- a/tests/cli_edit.rs +++ b/tests/cli_edit.rs @@ -6,12 +6,12 @@ use std::fs; use std::io::Cursor; use mp4forge::boxes::iso14496_12::{ - Ftyp, Moof, TFHD_DEFAULT_SAMPLE_DURATION_PRESENT, TFHD_DEFAULT_SAMPLE_SIZE_PRESENT, Tfdt, Tfhd, - Traf, + Ftyp, Meta, Moof, TFHD_DEFAULT_SAMPLE_DURATION_PRESENT, TFHD_DEFAULT_SAMPLE_SIZE_PRESENT, Tfdt, + Tfhd, Traf, }; use mp4forge::cli::edit; use mp4forge::codec::MutableBox; -use mp4forge::extract::extract_box; +use mp4forge::extract::{extract_box, extract_box_as}; use mp4forge::probe::probe; use mp4forge::walk::BoxPath; @@ -59,6 +59,7 @@ fn edit_command_validates_argument_shape() { "\n", "OPTIONS:\n", " -base_media_decode_time Replace tfdt base media decode times\n", + " -path Limit supported typed rewrites to parsed slash-delimited box paths\n", " -drop Drop boxes by fourcc\n" ) ); @@ -165,6 +166,153 @@ fn edit_command_matches_shared_fragmented_fixture_behavior() { assert!(mfra.is_empty()); } +#[test] +fn edit_command_scopes_tfdt_rewrites_to_matching_paths() { + let input = build_edit_scoped_input_file(); + let input_path = write_temp_file("edit-scoped-input", &input); + let output_path = write_temp_file("edit-scoped-output", &[]); + let args = vec![ + "-path".to_string(), + "moof/traf/tfdt".to_string(), + "-base_media_decode_time".to_string(), + "12345".to_string(), + input_path.to_string_lossy().into_owned(), + output_path.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = edit::run(&args, &mut stderr); + + let output = fs::read(&output_path).unwrap(); + let scoped_tfdt = extract_box_as::<_, Tfdt>( + &mut Cursor::new(output.clone()), + None, + BoxPath::from([fourcc("moof"), fourcc("traf"), fourcc("tfdt")]), + ) + .unwrap(); + let untouched_tfdt = extract_box_as::<_, Tfdt>( + &mut Cursor::new(output), + None, + BoxPath::from([fourcc("meta"), fourcc("tfdt")]), + ) + .unwrap(); + + let _ = fs::remove_file(&input_path); + let _ = fs::remove_file(&output_path); + + assert_eq!(exit_code, 0); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + assert_eq!(scoped_tfdt.len(), 1); + assert_eq!(untouched_tfdt.len(), 1); + assert_eq!(scoped_tfdt[0].base_media_decode_time_v0, 12_345); + assert_eq!(untouched_tfdt[0].base_media_decode_time_v0, 54_321); +} + +#[test] +fn edit_command_rejects_path_scoped_type_mismatches() { + let input = build_edit_input_file(); + let input_path = write_temp_file("edit-path-type-mismatch-input", &input); + let output_path = write_temp_file("edit-path-type-mismatch-output", &[]); + let args = vec![ + "-path".to_string(), + "moof/traf/tfhd".to_string(), + "-base_media_decode_time".to_string(), + "12345".to_string(), + input_path.to_string_lossy().into_owned(), + output_path.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = edit::run(&args, &mut stderr); + let stderr = String::from_utf8(stderr).unwrap(); + + let _ = fs::remove_file(&input_path); + let _ = fs::remove_file(&output_path); + + assert_eq!(exit_code, 1); + assert!( + stderr.contains( + "Error: path-based -base_media_decode_time rewrites require tfdt boxes: matched moof/traf/tfhd (type=tfhd" + ), + "{stderr}" + ); +} + +#[test] +fn edit_command_reports_invalid_path_arguments() { + let mut stderr = Vec::new(); + let exit_code = edit::run( + &[ + "-path".to_string(), + "moof//tfdt".to_string(), + "-base_media_decode_time".to_string(), + "12345".to_string(), + "input.mp4".to_string(), + "output.mp4".to_string(), + ], + &mut stderr, + ); + + assert_eq!(exit_code, 1); + assert_eq!( + String::from_utf8(stderr).unwrap(), + "Error: invalid box path: box path segment 2 must not be empty\n" + ); +} + +#[test] +fn edit_command_rejects_unsupported_path_only_rewrites() { + let input = build_edit_input_file(); + let input_path = write_temp_file("edit-path-unsupported-input", &input); + let output_path = write_temp_file("edit-path-unsupported-output", &[]); + let args = vec![ + "-path".to_string(), + "moof/traf/tfdt".to_string(), + "-drop".to_string(), + "free".to_string(), + input_path.to_string_lossy().into_owned(), + output_path.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = edit::run(&args, &mut stderr); + + let _ = fs::remove_file(&input_path); + let _ = fs::remove_file(&output_path); + + assert_eq!(exit_code, 1); + assert_eq!( + String::from_utf8(stderr).unwrap(), + "Error: edit -path currently supports only -base_media_decode_time rewrites\n" + ); +} + +#[test] +fn edit_command_preserves_bytes_when_scoped_path_matches_nothing() { + let input = build_edit_input_file(); + let input_path = write_temp_file("edit-path-noop-input", &input); + let output_path = write_temp_file("edit-path-noop-output", &[]); + let args = vec![ + "-path".to_string(), + "moov/trak/tfdt".to_string(), + "-base_media_decode_time".to_string(), + "12345".to_string(), + input_path.to_string_lossy().into_owned(), + output_path.to_string_lossy().into_owned(), + ]; + + let mut stderr = Vec::new(); + let exit_code = edit::run(&args, &mut stderr); + let output = fs::read(&output_path).unwrap(); + + let _ = fs::remove_file(&input_path); + let _ = fs::remove_file(&output_path); + + assert_eq!(exit_code, 0); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + assert_eq!(output, input); +} + fn build_edit_input_file() -> Vec { let ftyp = encode_supported_box( &Ftyp { @@ -197,3 +345,32 @@ fn build_edit_input_file() -> Vec { [ftyp, free, moof, mdat].concat() } + +fn build_edit_scoped_input_file() -> Vec { + let ftyp = encode_supported_box( + &Ftyp { + major_brand: fourcc("iso6"), + minor_version: 1, + compatible_brands: vec![fourcc("iso6"), fourcc("dash")], + }, + &[], + ); + + let tfdt_in_fragment = { + let mut tfdt = Tfdt::default(); + tfdt.base_media_decode_time_v0 = 9_000; + encode_supported_box(&tfdt, &[]) + }; + let tfdt_in_meta = { + let mut tfdt = Tfdt::default(); + tfdt.base_media_decode_time_v0 = 54_321; + encode_supported_box(&tfdt, &[]) + }; + + let traf = encode_supported_box(&Traf, &tfdt_in_fragment); + let moof = encode_supported_box(&Moof, &traf); + let meta = encode_supported_box(&Meta::default(), &tfdt_in_meta); + let mdat = encode_raw_box(fourcc("mdat"), &[0, 1, 2, 3]); + + [ftyp, moof, meta, mdat].concat() +} diff --git a/tests/cli_extract.rs b/tests/cli_extract.rs index b3c6793..5b3bc23 100644 --- a/tests/cli_extract.rs +++ b/tests/cli_extract.rs @@ -29,6 +29,28 @@ fn extract_command_writes_matching_raw_boxes() { assert_eq!(&stdout[4..8], b"mvhd"); } +#[test] +fn extract_command_writes_matching_raw_boxes_by_path() { + let file = build_extract_input_file(); + let path = write_temp_file("extract-cli-path", &file); + let args = vec![ + "-path".to_string(), + "moov/mvhd".to_string(), + path.to_string_lossy().into_owned(), + ]; + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let exit_code = extract::run(&args, &mut stdout, &mut stderr); + + let _ = fs::remove_file(&path); + + assert_eq!(exit_code, 0); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + assert_eq!(stdout.len(), 108); + assert_eq!(&stdout[4..8], b"mvhd"); +} + #[test] fn extract_command_rejects_invalid_arguments() { let mut stdout = Vec::new(); @@ -36,7 +58,13 @@ fn extract_command_rejects_invalid_arguments() { assert_eq!(extract::run(&[], &mut stdout, &mut stderr), 1); assert_eq!( String::from_utf8(stderr).unwrap(), - "USAGE: mp4forge extract BOX_TYPE INPUT.mp4\n" + concat!( + "USAGE: mp4forge extract BOX_TYPE INPUT.mp4\n", + " mp4forge extract -path [-path ...] INPUT.mp4\n", + "\n", + "OPTIONS:\n", + " -path Extract raw boxes that match the parsed slash-delimited box path\n" + ) ); let mut stdout = Vec::new(); @@ -56,6 +84,29 @@ fn extract_command_rejects_invalid_arguments() { ); } +#[test] +fn extract_command_rejects_invalid_path_arguments() { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + assert_eq!( + extract::run( + &[ + "-path".to_string(), + "moov/trakk".to_string(), + fixture_path("sample.mp4").to_string_lossy().into_owned(), + ], + &mut stdout, + &mut stderr, + ), + 1 + ); + assert_eq!(String::from_utf8(stdout).unwrap(), ""); + assert_eq!( + String::from_utf8(stderr).unwrap(), + "Error: invalid box path: invalid box path segment 2 (\"trakk\"): fourcc values must be exactly 4 bytes, got 5\n" + ); +} + #[test] fn extract_command_matches_shared_fixture_reference_sizes() { let cases = [ @@ -96,6 +147,28 @@ fn extract_command_matches_shared_fixture_reference_sizes() { } } +#[test] +fn extract_command_matches_shared_fixture_reference_paths() { + let args = vec![ + "--path".to_string(), + "moov/*/mdia/mdhd".to_string(), + fixture_path("sample.mp4").to_string_lossy().into_owned(), + ]; + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let exit_code = extract::run(&args, &mut stdout, &mut stderr); + + assert_eq!(exit_code, 0); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + assert_eq!(stdout.len(), 64); + + let infos = parse_box_stream(&stdout); + assert_eq!(infos.len(), 2); + assert!(infos.iter().all(|info| info.box_type() == fourcc("mdhd"))); + assert_eq!(infos.iter().map(BoxInfo::size).sum::(), 64); +} + fn build_extract_input_file() -> Vec { let ftyp = encode_supported_box( &Ftyp { diff --git a/tests/cli_probe.rs b/tests/cli_probe.rs index f406b46..8e2b0fe 100644 --- a/tests/cli_probe.rs +++ b/tests/cli_probe.rs @@ -5,7 +5,16 @@ mod support; use std::fs; use mp4forge::boxes::iso14496_12::{Ftyp, Moov, Mvhd}; -use mp4forge::cli::probe::{self, ProbeFormat, ProbeReport, ProbeTrackReport}; +use mp4forge::cli::probe::{ + self, CodecDetailedProbeReport, CodecDetailedProbeTrackReport, DetailedProbeReport, + DetailedProbeTrackReport, MediaCharacteristicsProbeReport, + MediaCharacteristicsProbeTrackReport, ProbeFormat, ProbeReport, ProbeReportOptions, + ProbeTrackReport, +}; +use mp4forge::probe::{ + Av1CodecDetails, ColorInfo, DeclaredBitrateInfo, FieldOrderInfo, PixelAspectRatioInfo, + TrackCodecDetails, TrackMediaCharacteristics, +}; use support::{ encode_supported_box, fixture_path, fourcc, normalize_text, read_golden, write_temp_file, @@ -148,6 +157,451 @@ fn probe_report_renders_json_and_yaml_with_stable_field_order() { ); } +#[test] +fn detailed_probe_report_renders_json_and_yaml_with_stable_field_order() { + let report = DetailedProbeReport { + major_brand: "isom".to_string(), + minor_version: 512, + compatible_brands: vec!["isom".to_string(), "iso8".to_string(), "av01".to_string()], + fast_start: true, + timescale: 1_000, + duration: 2_000, + duration_seconds: 2.0, + tracks: vec![DetailedProbeTrackReport { + track_id: 1, + timescale: 1_000, + duration: 1_000, + duration_seconds: 1.0, + codec: "av01".to_string(), + codec_family: "av1".to_string(), + encrypted: false, + handler_type: Some("vide".to_string()), + language: Some("eng".to_string()), + sample_entry_type: Some("av01".to_string()), + original_format: None, + protection_scheme_type: None, + protection_scheme_version: None, + width: Some(640), + height: Some(360), + channel_count: None, + sample_rate: None, + sample_num: Some(1), + chunk_num: Some(1), + idr_frame_num: None, + bitrate: Some(32_000), + max_bitrate: Some(32_000), + }], + }; + + let mut json = Vec::new(); + probe::write_detailed_report(&mut json, &report, ProbeFormat::Json).unwrap(); + assert_eq!( + String::from_utf8(json).unwrap(), + concat!( + "{\n", + " \"MajorBrand\": \"isom\",\n", + " \"MinorVersion\": 512,\n", + " \"CompatibleBrands\": [\n", + " \"isom\",\n", + " \"iso8\",\n", + " \"av01\"\n", + " ],\n", + " \"FastStart\": true,\n", + " \"Timescale\": 1000,\n", + " \"Duration\": 2000,\n", + " \"DurationSeconds\": 2,\n", + " \"Tracks\": [\n", + " {\n", + " \"TrackID\": 1,\n", + " \"Timescale\": 1000,\n", + " \"Duration\": 1000,\n", + " \"DurationSeconds\": 1,\n", + " \"Codec\": \"av01\",\n", + " \"CodecFamily\": \"av1\",\n", + " \"Encrypted\": false,\n", + " \"HandlerType\": \"vide\",\n", + " \"Language\": \"eng\",\n", + " \"SampleEntryType\": \"av01\",\n", + " \"Width\": 640,\n", + " \"Height\": 360,\n", + " \"SampleNum\": 1,\n", + " \"ChunkNum\": 1,\n", + " \"Bitrate\": 32000,\n", + " \"MaxBitrate\": 32000\n", + " }\n", + " ]\n", + "}\n" + ) + ); + + let mut yaml = Vec::new(); + probe::write_detailed_report(&mut yaml, &report, ProbeFormat::Yaml).unwrap(); + assert_eq!( + String::from_utf8(yaml).unwrap(), + concat!( + "major_brand: isom\n", + "minor_version: 512\n", + "compatible_brands:\n", + "- isom\n", + "- iso8\n", + "- av01\n", + "fast_start: true\n", + "timescale: 1000\n", + "duration: 2000\n", + "duration_seconds: 2\n", + "tracks:\n", + "- track_id: 1\n", + " timescale: 1000\n", + " duration: 1000\n", + " duration_seconds: 1\n", + " codec: av01\n", + " codec_family: av1\n", + " encrypted: false\n", + " handler_type: vide\n", + " language: eng\n", + " sample_entry_type: av01\n", + " width: 640\n", + " height: 360\n", + " sample_num: 1\n", + " chunk_num: 1\n", + " bitrate: 32000\n", + " max_bitrate: 32000\n" + ) + ); +} + +#[test] +fn codec_detailed_probe_report_renders_json_and_yaml_with_stable_field_order() { + let report = CodecDetailedProbeReport { + major_brand: "isom".to_string(), + minor_version: 512, + compatible_brands: vec!["isom".to_string(), "iso8".to_string(), "av01".to_string()], + fast_start: true, + timescale: 1_000, + duration: 2_000, + duration_seconds: 2.0, + tracks: vec![CodecDetailedProbeTrackReport { + track_id: 1, + timescale: 1_000, + duration: 1_000, + duration_seconds: 1.0, + codec: "av01".to_string(), + codec_family: "av1".to_string(), + codec_details: TrackCodecDetails::Av1(Av1CodecDetails { + seq_profile: 0, + seq_level_idx_0: 13, + seq_tier_0: 1, + bit_depth: 10, + monochrome: false, + chroma_subsampling_x: 1, + chroma_subsampling_y: 0, + chroma_sample_position: 2, + initial_presentation_delay_minus_one: Some(3), + }), + encrypted: false, + handler_type: Some("vide".to_string()), + language: Some("eng".to_string()), + sample_entry_type: Some("av01".to_string()), + original_format: None, + protection_scheme_type: None, + protection_scheme_version: None, + width: Some(640), + height: Some(360), + channel_count: None, + sample_rate: None, + sample_num: Some(1), + chunk_num: Some(1), + idr_frame_num: None, + bitrate: Some(32_000), + max_bitrate: Some(32_000), + }], + }; + + let mut json = Vec::new(); + probe::write_codec_detailed_report(&mut json, &report, ProbeFormat::Json).unwrap(); + assert_eq!( + String::from_utf8(json).unwrap(), + concat!( + "{\n", + " \"MajorBrand\": \"isom\",\n", + " \"MinorVersion\": 512,\n", + " \"CompatibleBrands\": [\n", + " \"isom\",\n", + " \"iso8\",\n", + " \"av01\"\n", + " ],\n", + " \"FastStart\": true,\n", + " \"Timescale\": 1000,\n", + " \"Duration\": 2000,\n", + " \"DurationSeconds\": 2,\n", + " \"Tracks\": [\n", + " {\n", + " \"TrackID\": 1,\n", + " \"Timescale\": 1000,\n", + " \"Duration\": 1000,\n", + " \"DurationSeconds\": 1,\n", + " \"Codec\": \"av01\",\n", + " \"CodecFamily\": \"av1\",\n", + " \"Encrypted\": false,\n", + " \"HandlerType\": \"vide\",\n", + " \"Language\": \"eng\",\n", + " \"SampleEntryType\": \"av01\",\n", + " \"Width\": 640,\n", + " \"Height\": 360,\n", + " \"SampleNum\": 1,\n", + " \"ChunkNum\": 1,\n", + " \"Bitrate\": 32000,\n", + " \"MaxBitrate\": 32000,\n", + " \"CodecDetails\": {\n", + " \"Kind\": \"av1\",\n", + " \"SeqProfile\": 0,\n", + " \"SeqLevelIdx0\": 13,\n", + " \"SeqTier0\": 1,\n", + " \"BitDepth\": 10,\n", + " \"Monochrome\": false,\n", + " \"ChromaSubsamplingX\": 1,\n", + " \"ChromaSubsamplingY\": 0,\n", + " \"ChromaSamplePosition\": 2,\n", + " \"InitialPresentationDelayMinusOne\": 3\n", + " }\n", + " }\n", + " ]\n", + "}\n" + ) + ); + + let mut yaml = Vec::new(); + probe::write_codec_detailed_report(&mut yaml, &report, ProbeFormat::Yaml).unwrap(); + assert_eq!( + String::from_utf8(yaml).unwrap(), + concat!( + "major_brand: isom\n", + "minor_version: 512\n", + "compatible_brands:\n", + "- isom\n", + "- iso8\n", + "- av01\n", + "fast_start: true\n", + "timescale: 1000\n", + "duration: 2000\n", + "duration_seconds: 2\n", + "tracks:\n", + "- track_id: 1\n", + " timescale: 1000\n", + " duration: 1000\n", + " duration_seconds: 1\n", + " codec: av01\n", + " codec_family: av1\n", + " encrypted: false\n", + " handler_type: vide\n", + " language: eng\n", + " sample_entry_type: av01\n", + " width: 640\n", + " height: 360\n", + " sample_num: 1\n", + " chunk_num: 1\n", + " bitrate: 32000\n", + " max_bitrate: 32000\n", + " codec_details:\n", + " kind: av1\n", + " seq_profile: 0\n", + " seq_level_idx_0: 13\n", + " seq_tier_0: 1\n", + " bit_depth: 10\n", + " monochrome: false\n", + " chroma_subsampling_x: 1\n", + " chroma_subsampling_y: 0\n", + " chroma_sample_position: 2\n", + " initial_presentation_delay_minus_one: 3\n" + ) + ); +} + +#[test] +fn media_characteristics_probe_report_renders_json_and_yaml_with_stable_field_order() { + let report = MediaCharacteristicsProbeReport { + major_brand: "isom".to_string(), + minor_version: 512, + compatible_brands: vec!["isom".to_string(), "iso8".to_string(), "avc1".to_string()], + fast_start: true, + timescale: 1_000, + duration: 2_000, + duration_seconds: 2.0, + tracks: vec![MediaCharacteristicsProbeTrackReport { + track_id: 1, + timescale: 1_000, + duration: 1_000, + duration_seconds: 1.0, + codec: "avc1.64001F".to_string(), + codec_family: "avc".to_string(), + codec_details: TrackCodecDetails::Unknown, + media_characteristics: TrackMediaCharacteristics { + declared_bitrate: Some(DeclaredBitrateInfo { + buffer_size_db: 32_768, + max_bitrate: 4_000_000, + avg_bitrate: 2_500_000, + }), + color: Some(ColorInfo { + colour_type: fourcc("nclx"), + colour_primaries: Some(9), + transfer_characteristics: Some(16), + matrix_coefficients: Some(9), + full_range: Some(true), + profile_size: None, + unknown_size: None, + }), + pixel_aspect_ratio: Some(PixelAspectRatioInfo { + h_spacing: 4, + v_spacing: 3, + }), + field_order: Some(FieldOrderInfo { + field_count: 2, + field_ordering: 6, + interlaced: true, + }), + }, + encrypted: false, + handler_type: Some("vide".to_string()), + language: Some("eng".to_string()), + sample_entry_type: Some("avc1".to_string()), + original_format: None, + protection_scheme_type: None, + protection_scheme_version: None, + width: Some(640), + height: Some(360), + channel_count: None, + sample_rate: None, + sample_num: Some(1), + chunk_num: Some(1), + idr_frame_num: None, + bitrate: Some(32_000), + max_bitrate: Some(32_000), + }], + }; + + let mut json = Vec::new(); + probe::write_media_characteristics_report(&mut json, &report, ProbeFormat::Json).unwrap(); + assert_eq!( + String::from_utf8(json).unwrap(), + concat!( + "{\n", + " \"MajorBrand\": \"isom\",\n", + " \"MinorVersion\": 512,\n", + " \"CompatibleBrands\": [\n", + " \"isom\",\n", + " \"iso8\",\n", + " \"avc1\"\n", + " ],\n", + " \"FastStart\": true,\n", + " \"Timescale\": 1000,\n", + " \"Duration\": 2000,\n", + " \"DurationSeconds\": 2,\n", + " \"Tracks\": [\n", + " {\n", + " \"TrackID\": 1,\n", + " \"Timescale\": 1000,\n", + " \"Duration\": 1000,\n", + " \"DurationSeconds\": 1,\n", + " \"Codec\": \"avc1.64001F\",\n", + " \"CodecFamily\": \"avc\",\n", + " \"Encrypted\": false,\n", + " \"HandlerType\": \"vide\",\n", + " \"Language\": \"eng\",\n", + " \"SampleEntryType\": \"avc1\",\n", + " \"Width\": 640,\n", + " \"Height\": 360,\n", + " \"SampleNum\": 1,\n", + " \"ChunkNum\": 1,\n", + " \"Bitrate\": 32000,\n", + " \"MaxBitrate\": 32000,\n", + " \"CodecDetails\": {\n", + " \"Kind\": \"avc\"\n", + " },\n", + " \"MediaCharacteristics\": {\n", + " \"DeclaredBitrate\": {\n", + " \"BufferSizeDB\": 32768,\n", + " \"MaxBitrate\": 4000000,\n", + " \"AvgBitrate\": 2500000\n", + " },\n", + " \"Color\": {\n", + " \"ColourType\": \"nclx\",\n", + " \"ColourPrimaries\": 9,\n", + " \"TransferCharacteristics\": 16,\n", + " \"MatrixCoefficients\": 9,\n", + " \"FullRange\": true\n", + " },\n", + " \"PixelAspectRatio\": {\n", + " \"HSpacing\": 4,\n", + " \"VSpacing\": 3\n", + " },\n", + " \"FieldOrder\": {\n", + " \"FieldCount\": 2,\n", + " \"FieldOrdering\": 6,\n", + " \"Interlaced\": true\n", + " }\n", + " }\n", + " }\n", + " ]\n", + "}\n" + ) + ); + + let mut yaml = Vec::new(); + probe::write_media_characteristics_report(&mut yaml, &report, ProbeFormat::Yaml).unwrap(); + assert_eq!( + String::from_utf8(yaml).unwrap(), + concat!( + "major_brand: isom\n", + "minor_version: 512\n", + "compatible_brands:\n", + "- isom\n", + "- iso8\n", + "- avc1\n", + "fast_start: true\n", + "timescale: 1000\n", + "duration: 2000\n", + "duration_seconds: 2\n", + "tracks:\n", + "- track_id: 1\n", + " timescale: 1000\n", + " duration: 1000\n", + " duration_seconds: 1\n", + " codec: avc1.64001F\n", + " codec_family: avc\n", + " encrypted: false\n", + " handler_type: vide\n", + " language: eng\n", + " sample_entry_type: avc1\n", + " width: 640\n", + " height: 360\n", + " sample_num: 1\n", + " chunk_num: 1\n", + " bitrate: 32000\n", + " max_bitrate: 32000\n", + " codec_details:\n", + " kind: avc\n", + " media_characteristics:\n", + " declared_bitrate:\n", + " buffer_size_db: 32768\n", + " max_bitrate: 4000000\n", + " avg_bitrate: 2500000\n", + " color:\n", + " colour_type: nclx\n", + " colour_primaries: 9\n", + " transfer_characteristics: 16\n", + " matrix_coefficients: 9\n", + " full_range: true\n", + " pixel_aspect_ratio:\n", + " h_spacing: 4\n", + " v_spacing: 3\n", + " field_order:\n", + " field_count: 2\n", + " field_ordering: 6\n", + " interlaced: true\n" + ) + ); +} + #[test] fn probe_command_reads_a_file_and_honors_the_yaml_flag() { let path = write_temp_file("probe-cli", &build_probe_input_file()); @@ -189,6 +643,11 @@ fn probe_command_matches_shared_fixture_goldens() { (&[], "cli_probe/sample.json"), (&["-format", "json"], "cli_probe/sample.json"), (&["-format", "yaml"], "cli_probe/sample.yaml"), + (&["-detail", "light"], "cli_probe/sample_light.json"), + ( + &["-detail", "light", "-format", "yaml"], + "cli_probe/sample_light.yaml", + ), ]; for (options, golden) in cases { @@ -216,6 +675,44 @@ fn probe_command_matches_shared_fixture_goldens() { } } +#[test] +fn probe_report_with_lightweight_options_omits_expensive_fields() { + let mut file = fs::File::open(fixture_path("sample.mp4")).unwrap(); + let report = probe::build_codec_detailed_report_with_options( + &mut file, + ProbeReportOptions::lightweight(), + ) + .unwrap(); + + assert_eq!(report.major_brand, "isom"); + assert!(!report.fast_start); + assert_eq!(report.tracks.len(), 2); + + let video = &report.tracks[0]; + assert_eq!(video.track_id, 1); + assert_eq!(video.codec, "avc1.64000C"); + assert_eq!(video.codec_family, "avc"); + assert_eq!(video.width, Some(320)); + assert_eq!(video.height, Some(180)); + assert_eq!(video.sample_num, None); + assert_eq!(video.chunk_num, None); + assert_eq!(video.idr_frame_num, None); + assert_eq!(video.bitrate, None); + assert_eq!(video.max_bitrate, None); + + let audio = &report.tracks[1]; + assert_eq!(audio.track_id, 2); + assert_eq!(audio.codec, "mp4a.40.2"); + assert_eq!(audio.codec_family, "mp4_audio"); + assert_eq!(audio.channel_count, Some(2)); + assert_eq!(audio.sample_rate, Some(44100)); + assert_eq!(audio.sample_num, None); + assert_eq!(audio.chunk_num, None); + assert_eq!(audio.idr_frame_num, None); + assert_eq!(audio.bitrate, None); + assert_eq!(audio.max_bitrate, None); +} + #[test] fn probe_library_handles_quicktime_fixture() { let mut file = fs::File::open(fixture_path("sample_qt.mp4")).unwrap(); diff --git a/tests/cli_psshdump.rs b/tests/cli_psshdump.rs index 17fa688..9c87a67 100644 --- a/tests/cli_psshdump.rs +++ b/tests/cli_psshdump.rs @@ -3,19 +3,186 @@ mod support; use std::fs; +use std::io::Cursor; -use mp4forge::boxes::iso14496_12::{Ftyp, Moov}; +use mp4forge::boxes::iso14496_12::{Ftyp, Moof, Moov}; use mp4forge::boxes::iso23001_7::{Pssh, PsshKid}; -use mp4forge::cli::pssh; +use mp4forge::cli::pssh::{self, PsshDumpFormat, PsshEntryReport, PsshReport, PsshReportFilter}; use mp4forge::codec::MutableBox; +use mp4forge::walk::BoxPath; use support::{ encode_supported_box, fixture_path, fourcc, normalize_text, read_golden, write_temp_file, }; +const PRIMARY_SYSTEM_ID: [u8; 16] = [ + 0x10, 0x77, 0xef, 0xec, 0xc0, 0xb2, 0x4d, 0x02, 0xac, 0xe3, 0x3c, 0x1e, 0x52, 0xe2, 0xfb, 0x4b, +]; +const PRIMARY_KID: [u8; 16] = [ + 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, +]; +const SECONDARY_SYSTEM_ID: [u8; 16] = [ + 0xed, 0xef, 0x8b, 0xa9, 0x79, 0xd6, 0x4a, 0xce, 0xa3, 0xc8, 0x27, 0xdc, 0xd5, 0x1d, 0x21, 0xed, +]; +const SECONDARY_KID: [u8; 16] = [ + 0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54, 0x32, 0x10, 0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54, 0x32, 0x10, +]; + +#[test] +fn pssh_report_renders_json_and_yaml_with_stable_field_order() { + let report = PsshReport { + entries: vec![PsshEntryReport { + index: 0, + path: "moov/pssh".to_string(), + offset: 28, + size: 54, + version: 1, + flags: 0, + system_id: "1077efec-c0b2-4d02-ace3-3c1e52e2fb4b".to_string(), + kid_count: 1, + kids: vec!["01234567-89ab-cdef-0123-456789abcdef".to_string()], + data_size: 2, + data_bytes: vec![170, 187], + raw_box_base64: "AAA=".to_string(), + }], + }; + + let mut json = Vec::new(); + pssh::write_pssh_report(&mut json, &report, PsshDumpFormat::Json).unwrap(); + assert_eq!( + String::from_utf8(json).unwrap(), + concat!( + "{\n", + " \"Entries\": [\n", + " {\n", + " \"Index\": 0,\n", + " \"Path\": \"moov/pssh\",\n", + " \"Offset\": 28,\n", + " \"Size\": 54,\n", + " \"Version\": 1,\n", + " \"Flags\": 0,\n", + " \"SystemId\": \"1077efec-c0b2-4d02-ace3-3c1e52e2fb4b\",\n", + " \"KidCount\": 1,\n", + " \"Kids\": [\n", + " \"01234567-89ab-cdef-0123-456789abcdef\"\n", + " ],\n", + " \"DataSize\": 2,\n", + " \"DataBytes\": [\n", + " 170,\n", + " 187\n", + " ],\n", + " \"RawBoxBase64\": \"AAA=\"\n", + " }\n", + " ]\n", + "}\n" + ) + ); + + let mut yaml = Vec::new(); + pssh::write_pssh_report(&mut yaml, &report, PsshDumpFormat::Yaml).unwrap(); + assert_eq!( + String::from_utf8(yaml).unwrap(), + concat!( + "entries:\n", + "- index: 0\n", + " path: moov/pssh\n", + " offset: 28\n", + " size: 54\n", + " version: 1\n", + " flags: 0\n", + " system_id: 1077efec-c0b2-4d02-ace3-3c1e52e2fb4b\n", + " kid_count: 1\n", + " kids:\n", + " - 01234567-89ab-cdef-0123-456789abcdef\n", + " data_size: 2\n", + " data_bytes:\n", + " - 170\n", + " - 187\n", + " raw_box_base64: 'AAA='\n" + ) + ); +} + +#[test] +fn build_pssh_report_extracts_kids_data_and_raw_box_base64() { + let file = build_pssh_input_file(&[0xaa, 0xbb]); + let report = pssh::build_pssh_report(&mut Cursor::new(file)).unwrap(); + + assert_eq!( + report, + PsshReport { + entries: vec![PsshEntryReport { + index: 0, + path: "moov/pssh".to_string(), + offset: 28, + size: 54, + version: 1, + flags: 0, + system_id: "1077efec-c0b2-4d02-ace3-3c1e52e2fb4b".to_string(), + kid_count: 1, + kids: vec!["01234567-89ab-cdef-0123-456789abcdef".to_string()], + data_size: 2, + data_bytes: vec![0xaa, 0xbb], + raw_box_base64: + "AAAANnBzc2gBAAAAEHfv7MCyTQKs4zweUuL7SwAAAAEBI0VniavN7wEjRWeJq83vAAAAAqq7" + .to_string(), + }], + } + ); +} + +#[test] +fn build_pssh_report_with_filters_scopes_by_path_system_id_and_kid() { + let file = build_filtered_pssh_input_file(); + + let moof_only = pssh::build_pssh_report_with_filters( + &mut Cursor::new(&file), + &PsshReportFilter { + paths: vec![BoxPath::parse("moof").unwrap()], + ..PsshReportFilter::default() + }, + ) + .unwrap(); + assert_eq!(moof_only.entries.len(), 1); + assert_eq!(moof_only.entries[0].index, 1); + assert_eq!(moof_only.entries[0].path, "moof/pssh"); + assert_eq!( + moof_only.entries[0].system_id, + "edef8ba9-79d6-4ace-a3c8-27dcd51d21ed" + ); + + let system_filtered = pssh::build_pssh_report_with_filters( + &mut Cursor::new(&file), + &PsshReportFilter { + system_ids: vec![SECONDARY_SYSTEM_ID], + ..PsshReportFilter::default() + }, + ) + .unwrap(); + assert_eq!(system_filtered.entries.len(), 1); + assert_eq!(system_filtered.entries[0].index, 1); + assert_eq!(system_filtered.entries[0].path, "moof/pssh"); + + let kid_filtered = pssh::build_pssh_report_with_filters( + &mut Cursor::new(&file), + &PsshReportFilter { + kids: vec![PRIMARY_KID], + ..PsshReportFilter::default() + }, + ) + .unwrap(); + assert_eq!(kid_filtered.entries.len(), 1); + assert_eq!(kid_filtered.entries[0].index, 0); + assert_eq!(kid_filtered.entries[0].path, "moov/pssh"); + assert_eq!( + kid_filtered.entries[0].kids, + vec!["01234567-89ab-cdef-0123-456789abcdef".to_string()] + ); +} + #[test] fn psshdump_command_renders_offsets_flags_and_base64() { - let file = build_pssh_input_file(); + let file = build_pssh_input_file(&[]); let path = write_temp_file("psshdump-cli", &file); let args = vec![path.to_string_lossy().into_owned()]; @@ -44,54 +211,248 @@ fn psshdump_command_renders_offsets_flags_and_base64() { } #[test] -fn psshdump_command_matches_shared_encrypted_init_fixtures() { - for fixture_name in ["sample_init.encv.mp4", "sample_init.enca.mp4"] { - let args = vec![fixture_path(fixture_name).to_string_lossy().into_owned()]; +fn psshdump_command_filters_text_output_by_path() { + let file = build_filtered_pssh_input_file(); + let path = write_temp_file("psshdump-cli-filter-path", &file); + let args = vec![ + "-path".to_string(), + "moof".to_string(), + path.to_string_lossy().into_owned(), + ]; + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let exit_code = pssh::run(&args, &mut stdout, &mut stderr); + + let _ = fs::remove_file(&path); + + let stdout = String::from_utf8(stdout).unwrap(); + assert_eq!(exit_code, 0); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + assert!(stdout.contains("1:\n")); + assert!(stdout.contains("systemId: edef8ba9-79d6-4ace-a3c8-27dcd51d21ed")); + assert!(!stdout.contains("1077efec-c0b2-4d02-ace3-3c1e52e2fb4b")); +} + +#[test] +fn psshdump_command_filters_structured_output_with_stable_goldens() { + let file = build_filtered_pssh_input_file(); + let path = write_temp_file("psshdump-cli-filtered", &file); + let cases: &[(&[&str], &str)] = &[ + ( + &["-path", "moof", "-format", "json"], + "cli_psshdump/filtered_path.json", + ), + ( + &[ + "-system-id", + "edef8ba9-79d6-4ace-a3c8-27dcd51d21ed", + "-format", + "json", + ], + "cli_psshdump/filtered_system_id.json", + ), + ( + &[ + "-kid", + "fedcba98-7654-3210-fedc-ba9876543210", + "-format", + "yaml", + ], + "cli_psshdump/filtered_kid.yaml", + ), + ]; + + for (options, golden) in cases { + let mut args = options + .iter() + .map(|value| value.to_string()) + .collect::>(); + args.push(path.to_string_lossy().into_owned()); let mut stdout = Vec::new(); let mut stderr = Vec::new(); let exit_code = pssh::run(&args, &mut stdout, &mut stderr); - assert_eq!(exit_code, 0, "fixture psshdump failed for {fixture_name}"); + assert_eq!(exit_code, 0, "filtered psshdump failed for {golden}"); assert_eq!( String::from_utf8(stderr).unwrap(), "", - "stderr for {fixture_name}" + "stderr for {golden}" ); assert_eq!( normalize_text(&String::from_utf8(stdout).unwrap()), - read_golden("cli_psshdump/sample_init.txt"), - "golden mismatch for {fixture_name}" + read_golden(golden), + "golden mismatch for {golden}" ); } + + let _ = fs::remove_file(&path); +} + +#[test] +fn psshdump_command_rejects_invalid_filter_values() { + let file = build_filtered_pssh_input_file(); + let path = write_temp_file("psshdump-cli-invalid-filter", &file); + let cases: &[(&[&str], &str)] = &[ + ( + &["-path", "moov//pssh"], + "Error: box path segment 2 must not be empty\n", + ), + ( + &["-system-id", "not-a-uuid"], + "Error: invalid system ID: expected 32 hexadecimal digits with optional hyphens\n", + ), + ( + &["-kid", "xyz"], + "Error: invalid KID: expected 32 hexadecimal digits with optional hyphens\n", + ), + ]; + + for (options, expected_stderr) in cases { + let mut args = options + .iter() + .map(|value| value.to_string()) + .collect::>(); + args.push(path.to_string_lossy().into_owned()); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let exit_code = pssh::run(&args, &mut stdout, &mut stderr); + + assert_eq!(exit_code, 1, "invalid filter unexpectedly succeeded"); + assert_eq!(String::from_utf8(stdout).unwrap(), ""); + assert_eq!(String::from_utf8(stderr).unwrap(), *expected_stderr); + } + + let _ = fs::remove_file(&path); +} + +#[test] +fn psshdump_command_returns_empty_reports_for_empty_matches() { + let file = build_filtered_pssh_input_file(); + let path = write_temp_file("psshdump-cli-empty-filter", &file); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let text_exit = pssh::run( + &[ + "-system-id".to_string(), + "ffffffff-ffff-ffff-ffff-ffffffffffff".to_string(), + path.to_string_lossy().into_owned(), + ], + &mut stdout, + &mut stderr, + ); + assert_eq!(text_exit, 0); + assert_eq!(String::from_utf8(stderr).unwrap(), ""); + assert_eq!(String::from_utf8(stdout).unwrap(), ""); + + let mut json_stdout = Vec::new(); + let mut json_stderr = Vec::new(); + let json_exit = pssh::run( + &[ + "-system-id".to_string(), + "ffffffff-ffff-ffff-ffff-ffffffffffff".to_string(), + "-format".to_string(), + "json".to_string(), + path.to_string_lossy().into_owned(), + ], + &mut json_stdout, + &mut json_stderr, + ); + assert_eq!(json_exit, 0); + assert_eq!(String::from_utf8(json_stderr).unwrap(), ""); + assert_eq!( + String::from_utf8(json_stdout).unwrap(), + "{\n \"Entries\": [\n ]\n}\n" + ); + + let _ = fs::remove_file(&path); +} + +#[test] +fn psshdump_command_matches_shared_encrypted_init_fixtures() { + let cases: &[(&[&str], &str)] = &[ + (&[], "cli_psshdump/sample_init.txt"), + (&["-format", "json"], "cli_psshdump/sample_init.json"), + (&["-format", "yaml"], "cli_psshdump/sample_init.yaml"), + ]; + + for fixture_name in ["sample_init.encv.mp4", "sample_init.enca.mp4"] { + for (options, golden) in cases { + let mut args = options + .iter() + .map(|value| value.to_string()) + .collect::>(); + args.push(fixture_path(fixture_name).to_string_lossy().into_owned()); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let exit_code = pssh::run(&args, &mut stdout, &mut stderr); + + assert_eq!( + exit_code, 0, + "fixture psshdump failed for {fixture_name} {golden}" + ); + assert_eq!( + String::from_utf8(stderr).unwrap(), + "", + "stderr for {fixture_name} {golden}" + ); + assert_eq!( + normalize_text(&String::from_utf8(stdout).unwrap()), + read_golden(golden), + "golden mismatch for {fixture_name} {golden}" + ); + } + } +} + +fn build_pssh_input_file(data: &[u8]) -> Vec { + let moov = encode_supported_box( + &Moov, + &encode_supported_box(&build_pssh_box(PRIMARY_SYSTEM_ID, PRIMARY_KID, data), &[]), + ); + [build_ftyp_box(), moov].concat() } -fn build_pssh_input_file() -> Vec { - let ftyp = encode_supported_box( +fn build_filtered_pssh_input_file() -> Vec { + let moov = encode_supported_box( + &Moov, + &encode_supported_box( + &build_pssh_box(PRIMARY_SYSTEM_ID, PRIMARY_KID, &[0xaa]), + &[], + ), + ); + let moof = encode_supported_box( + &Moof, + &encode_supported_box( + &build_pssh_box(SECONDARY_SYSTEM_ID, SECONDARY_KID, &[0xbb, 0xcc]), + &[], + ), + ); + [build_ftyp_box(), moov, moof].concat() +} + +fn build_ftyp_box() -> Vec { + encode_supported_box( &Ftyp { major_brand: fourcc("isom"), minor_version: 1, compatible_brands: vec![fourcc("isom")], }, &[], - ); + ) +} +fn build_pssh_box(system_id: [u8; 16], kid: [u8; 16], data: &[u8]) -> Pssh { let mut pssh = Pssh::default(); - pssh.system_id = [ - 0x10, 0x77, 0xef, 0xec, 0xc0, 0xb2, 0x4d, 0x02, 0xac, 0xe3, 0x3c, 0x1e, 0x52, 0xe2, 0xfb, - 0x4b, - ]; + pssh.system_id = system_id; pssh.kid_count = 1; - pssh.kids = vec![PsshKid { - kid: [ - 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, - 0xcd, 0xef, - ], - }]; - pssh.data_size = 0; - pssh.data = Vec::new(); + pssh.kids = vec![PsshKid { kid }]; + pssh.data_size = u32::try_from(data.len()).unwrap(); + pssh.data = data.to_vec(); pssh.set_version(1); - - let moov = encode_supported_box(&Moov, &encode_supported_box(&pssh, &[])); - [ftyp, moov].concat() + pssh } diff --git a/tests/fixture_probe_coverage.rs b/tests/fixture_probe_coverage.rs new file mode 100644 index 0000000..b4cabd1 --- /dev/null +++ b/tests/fixture_probe_coverage.rs @@ -0,0 +1,251 @@ +mod support; + +use std::fs; + +use mp4forge::cli::probe as cli_probe; +use mp4forge::probe::{TrackCodec, TrackCodecFamily, probe, probe_media_characteristics}; + +use support::fixture_path; + +struct FixtureExpectation { + file_name: &'static str, + major_brand: &'static str, + tracks: &'static [TrackExpectation], +} + +struct TrackExpectation { + coarse_codec: TrackCodec, + report_codec: &'static str, + codec_family: TrackCodecFamily, + sample_entry_type: &'static str, + width: Option, + height: Option, + channel_count: Option, + sample_rate: Option, +} + +#[test] +fn fixture_probe_surfaces_cover_added_codec_families() { + let cases = [ + FixtureExpectation { + file_name: "vp9_opus.mp4", + major_brand: "isom", + tracks: &[ + TrackExpectation { + coarse_codec: TrackCodec::Unknown, + report_codec: "vp09", + codec_family: TrackCodecFamily::Vp9, + sample_entry_type: "vp09", + width: Some(1920), + height: Some(1080), + channel_count: None, + sample_rate: None, + }, + TrackExpectation { + coarse_codec: TrackCodec::Unknown, + report_codec: "Opus", + codec_family: TrackCodecFamily::Opus, + sample_entry_type: "Opus", + width: None, + height: None, + channel_count: Some(2), + sample_rate: Some(48_000), + }, + ], + }, + FixtureExpectation { + file_name: "av1_opus.mp4", + major_brand: "isom", + tracks: &[ + TrackExpectation { + coarse_codec: TrackCodec::Unknown, + report_codec: "av01", + codec_family: TrackCodecFamily::Av1, + sample_entry_type: "av01", + width: Some(1280), + height: Some(720), + channel_count: None, + sample_rate: None, + }, + TrackExpectation { + coarse_codec: TrackCodec::Unknown, + report_codec: "Opus", + codec_family: TrackCodecFamily::Opus, + sample_entry_type: "Opus", + width: None, + height: None, + channel_count: Some(2), + sample_rate: Some(48_000), + }, + ], + }, + FixtureExpectation { + file_name: "aac_audio.mp4", + major_brand: "isom", + tracks: &[TrackExpectation { + coarse_codec: TrackCodec::Mp4a, + report_codec: "mp4a.40.2", + codec_family: TrackCodecFamily::Mp4Audio, + sample_entry_type: "mp4a", + width: None, + height: None, + channel_count: Some(2), + sample_rate: Some(48_000), + }], + }, + FixtureExpectation { + file_name: "opus_audio.mp4", + major_brand: "isom", + tracks: &[TrackExpectation { + coarse_codec: TrackCodec::Unknown, + report_codec: "Opus", + codec_family: TrackCodecFamily::Opus, + sample_entry_type: "Opus", + width: None, + height: None, + channel_count: Some(2), + sample_rate: Some(48_000), + }], + }, + FixtureExpectation { + file_name: "pcm_audio.mp4", + major_brand: "isom", + tracks: &[TrackExpectation { + coarse_codec: TrackCodec::Unknown, + report_codec: "ipcm", + codec_family: TrackCodecFamily::Pcm, + sample_entry_type: "ipcm", + width: None, + height: None, + channel_count: Some(2), + sample_rate: Some(48_000), + }], + }, + ]; + + for case in cases { + let path = fixture_path(case.file_name); + + let mut summary_file = fs::File::open(&path).unwrap(); + let summary = probe(&mut summary_file).unwrap(); + assert_eq!(summary.major_brand.to_string(), case.major_brand); + assert_eq!( + summary.tracks.len(), + case.tracks.len(), + "fixture={}", + case.file_name + ); + + let mut rich_file = fs::File::open(&path).unwrap(); + let rich = probe_media_characteristics(&mut rich_file).unwrap(); + assert_eq!( + rich.major_brand.to_string(), + case.major_brand, + "fixture={}", + case.file_name + ); + assert_eq!( + rich.tracks.len(), + case.tracks.len(), + "fixture={}", + case.file_name + ); + + let mut report_file = fs::File::open(&path).unwrap(); + let report = cli_probe::build_media_characteristics_report(&mut report_file).unwrap(); + assert_eq!( + report.major_brand, case.major_brand, + "fixture={}", + case.file_name + ); + assert_eq!( + report.tracks.len(), + case.tracks.len(), + "fixture={}", + case.file_name + ); + + for (((summary_track, rich_track), report_track), expected_track) in summary + .tracks + .iter() + .zip(rich.tracks.iter()) + .zip(report.tracks.iter()) + .zip(case.tracks.iter()) + { + assert_eq!( + summary_track.codec, expected_track.coarse_codec, + "fixture={} track={}", + case.file_name, summary_track.track_id + ); + assert_eq!( + rich_track.summary.codec_family, expected_track.codec_family, + "fixture={} track={}", + case.file_name, summary_track.track_id + ); + assert_eq!( + rich_track.summary.sample_entry_type, + Some(mp4forge::FourCc::try_from(expected_track.sample_entry_type).unwrap()), + "fixture={} track={}", + case.file_name, + summary_track.track_id + ); + assert_eq!( + report_track.codec, expected_track.report_codec, + "fixture={} track={}", + case.file_name, summary_track.track_id + ); + assert_eq!( + report_track.codec_family, + codec_family_name(expected_track.codec_family), + "fixture={} track={}", + case.file_name, + summary_track.track_id + ); + assert_eq!( + report_track.sample_entry_type.as_deref(), + Some(expected_track.sample_entry_type), + "fixture={} track={}", + case.file_name, + summary_track.track_id + ); + assert_eq!( + report_track.width, expected_track.width, + "fixture={} track={}", + case.file_name, summary_track.track_id + ); + assert_eq!( + report_track.height, expected_track.height, + "fixture={} track={}", + case.file_name, summary_track.track_id + ); + assert_eq!( + report_track.channel_count, expected_track.channel_count, + "fixture={} track={}", + case.file_name, summary_track.track_id + ); + assert_eq!( + report_track.sample_rate, expected_track.sample_rate, + "fixture={} track={}", + case.file_name, summary_track.track_id + ); + } + } +} + +fn codec_family_name(value: TrackCodecFamily) -> &'static str { + match value { + TrackCodecFamily::Unknown => "unknown", + TrackCodecFamily::Avc => "avc", + TrackCodecFamily::Hevc => "hevc", + TrackCodecFamily::Av1 => "av1", + TrackCodecFamily::Vp8 => "vp8", + TrackCodecFamily::Vp9 => "vp9", + TrackCodecFamily::Mp4Audio => "mp4_audio", + TrackCodecFamily::Opus => "opus", + TrackCodecFamily::Ac3 => "ac3", + TrackCodecFamily::Pcm => "pcm", + TrackCodecFamily::XmlSubtitle => "xml_subtitle", + TrackCodecFamily::TextSubtitle => "text_subtitle", + TrackCodecFamily::WebVtt => "webvtt", + } +} diff --git a/tests/fixtures/aac_audio.mp4 b/tests/fixtures/aac_audio.mp4 new file mode 100644 index 0000000..5f49cf0 Binary files /dev/null and b/tests/fixtures/aac_audio.mp4 differ diff --git a/tests/fixtures/av1_opus.mp4 b/tests/fixtures/av1_opus.mp4 new file mode 100644 index 0000000..d89eec0 Binary files /dev/null and b/tests/fixtures/av1_opus.mp4 differ diff --git a/tests/fixtures/opus_audio.mp4 b/tests/fixtures/opus_audio.mp4 new file mode 100644 index 0000000..06c080b Binary files /dev/null and b/tests/fixtures/opus_audio.mp4 differ diff --git a/tests/fixtures/pcm_audio.mp4 b/tests/fixtures/pcm_audio.mp4 new file mode 100644 index 0000000..e1c2361 Binary files /dev/null and b/tests/fixtures/pcm_audio.mp4 differ diff --git a/tests/fixtures/vp9_opus.mp4 b/tests/fixtures/vp9_opus.mp4 new file mode 100644 index 0000000..9bf2911 Binary files /dev/null and b/tests/fixtures/vp9_opus.mp4 differ diff --git a/tests/golden/cli_divide/sample_fragmented/master.m3u8 b/tests/golden/cli_divide/sample_fragmented/master.m3u8 index f411998..e03e818 100644 --- a/tests/golden/cli_divide/sample_fragmented/master.m3u8 +++ b/tests/golden/cli_divide/sample_fragmented/master.m3u8 @@ -1,4 +1,4 @@ #EXTM3U #EXT-X-MEDIA:TYPE=AUDIO,URI="audio/playlist.m3u8",GROUP-ID="audio",NAME="audio",AUTOSELECT=YES,CHANNELS="2" -#EXT-X-STREAM-INF:BANDWIDTH=28320,CODECS="avc1.64001f,mp4a.40.2",RESOLUTION=1280x720,AUDIO="audio" +#EXT-X-STREAM-INF:BANDWIDTH=28320,CODECS="avc1.4d401f,mp4a.40.2",RESOLUTION=1280x720,AUDIO="audio" video/playlist.m3u8 diff --git a/tests/golden/cli_dump/sample.json b/tests/golden/cli_dump/sample.json new file mode 100644 index 0000000..55700d6 --- /dev/null +++ b/tests/golden/cli_dump/sample.json @@ -0,0 +1,2308 @@ +{ + "Boxes": [ + { + "BoxType": "ftyp", + "Path": "ftyp", + "Offset": 0, + "Size": 32, + "Supported": true, + "PayloadStatus": "summary", + "PayloadFields": [ + { + "Name": "MajorBrand", + "ValueKind": "bytes", + "Value": [ + 105, + 115, + 111, + 109 + ], + "DisplayValue": "\"isom\"" + }, + { + "Name": "MinorVersion", + "ValueKind": "unsigned", + "Value": 512 + }, + { + "Name": "CompatibleBrands", + "ValueKind": "bytes", + "Value": [ + 105, + 115, + 111, + 109, + 105, + 115, + 111, + 50, + 97, + 118, + 99, + 49, + 109, + 112, + 52, + 49 + ], + "DisplayValue": "[{CompatibleBrand=\"isom\"}, {CompatibleBrand=\"iso2\"}, {CompatibleBrand=\"avc1\"}, {CompatibleBrand=\"mp41\"}]" + } + ], + "PayloadSummary": "MajorBrand=\"isom\" MinorVersion=512 CompatibleBrands=[{CompatibleBrand=\"isom\"}, {CompatibleBrand=\"iso2\"}, {CompatibleBrand=\"avc1\"}, {CompatibleBrand=\"mp41\"}]", + "Children": [ + ] + }, + { + "BoxType": "free", + "Path": "free", + "Offset": 32, + "Size": 8, + "Supported": true, + "PayloadStatus": "omitted", + "PayloadFields": [ + ], + "Children": [ + ] + }, + { + "BoxType": "mdat", + "Path": "mdat", + "Offset": 40, + "Size": 6402, + "Supported": true, + "PayloadStatus": "omitted", + "PayloadFields": [ + ], + "Children": [ + ] + }, + { + "BoxType": "moov", + "Path": "moov", + "Offset": 6442, + "Size": 1836, + "Supported": true, + "PayloadStatus": "empty", + "PayloadFields": [ + ], + "Children": [ + { + "BoxType": "mvhd", + "Path": "moov/mvhd", + "Offset": 6450, + "Size": 108, + "Supported": true, + "PayloadStatus": "summary", + "PayloadFields": [ + { + "Name": "Version", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "Flags", + "ValueKind": "unsigned", + "Value": 0, + "DisplayValue": "0x000000" + }, + { + "Name": "CreationTimeV0", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "ModificationTimeV0", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "Timescale", + "ValueKind": "unsigned", + "Value": 1000 + }, + { + "Name": "DurationV0", + "ValueKind": "unsigned", + "Value": 1024 + }, + { + "Name": "Rate", + "ValueKind": "signed", + "Value": 65536, + "DisplayValue": "1" + }, + { + "Name": "Volume", + "ValueKind": "signed", + "Value": 256 + }, + { + "Name": "Matrix", + "ValueKind": "signed_array", + "Value": [ + 65536, + 0, + 0, + 0, + 65536, + 0, + 0, + 0, + 1073741824 + ], + "DisplayValue": "[0x10000, 0x0, 0x0, 0x0, 0x10000, 0x0, 0x0, 0x0, 0x40000000]" + }, + { + "Name": "PreDefined", + "ValueKind": "signed_array", + "Value": [ + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "Name": "NextTrackID", + "ValueKind": "unsigned", + "Value": 3 + } + ], + "PayloadSummary": "Version=0 Flags=0x000000 CreationTimeV0=0 ModificationTimeV0=0 Timescale=1000 DurationV0=1024 Rate=1 Volume=256 Matrix=[0x10000, 0x0, 0x0, 0x0, 0x10000, 0x0, 0x0, 0x0, 0x40000000] PreDefined=[0, 0, 0, 0, 0, 0] NextTrackID=3", + "Children": [ + ] + }, + { + "BoxType": "trak", + "Path": "moov/trak", + "Offset": 6558, + "Size": 743, + "Supported": true, + "PayloadStatus": "empty", + "PayloadFields": [ + ], + "Children": [ + { + "BoxType": "tkhd", + "Path": "moov/trak/tkhd", + "Offset": 6566, + "Size": 92, + "Supported": true, + "PayloadStatus": "summary", + "PayloadFields": [ + { + "Name": "Version", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "Flags", + "ValueKind": "unsigned", + "Value": 3, + "DisplayValue": "0x000003" + }, + { + "Name": "CreationTimeV0", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "ModificationTimeV0", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "TrackID", + "ValueKind": "unsigned", + "Value": 1 + }, + { + "Name": "DurationV0", + "ValueKind": "unsigned", + "Value": 1000 + }, + { + "Name": "Layer", + "ValueKind": "signed", + "Value": 0 + }, + { + "Name": "AlternateGroup", + "ValueKind": "signed", + "Value": 0 + }, + { + "Name": "Volume", + "ValueKind": "signed", + "Value": 0 + }, + { + "Name": "Matrix", + "ValueKind": "signed_array", + "Value": [ + 65536, + 0, + 0, + 0, + 65536, + 0, + 0, + 0, + 1073741824 + ], + "DisplayValue": "[0x10000, 0x0, 0x0, 0x0, 0x10000, 0x0, 0x0, 0x0, 0x40000000]" + }, + { + "Name": "Width", + "ValueKind": "unsigned", + "Value": 20971520, + "DisplayValue": "320" + }, + { + "Name": "Height", + "ValueKind": "unsigned", + "Value": 11796480, + "DisplayValue": "180" + } + ], + "PayloadSummary": "Version=0 Flags=0x000003 CreationTimeV0=0 ModificationTimeV0=0 TrackID=1 DurationV0=1000 Layer=0 AlternateGroup=0 Volume=0 Matrix=[0x10000, 0x0, 0x0, 0x0, 0x10000, 0x0, 0x0, 0x0, 0x40000000] Width=320 Height=180", + "Children": [ + ] + }, + { + "BoxType": "edts", + "Path": "moov/trak/edts", + "Offset": 6658, + "Size": 36, + "Supported": true, + "PayloadStatus": "empty", + "PayloadFields": [ + ], + "Children": [ + { + "BoxType": "elst", + "Path": "moov/trak/edts/elst", + "Offset": 6666, + "Size": 28, + "Supported": true, + "PayloadStatus": "summary", + "PayloadFields": [ + { + "Name": "Version", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "Flags", + "ValueKind": "unsigned", + "Value": 0, + "DisplayValue": "0x000000" + }, + { + "Name": "EntryCount", + "ValueKind": "unsigned", + "Value": 1 + }, + { + "Name": "Entries", + "ValueKind": "bytes", + "Value": [ + 0, + 0, + 3, + 232, + 0, + 0, + 8, + 0, + 0, + 1, + 0, + 0 + ], + "DisplayValue": "[{SegmentDurationV0=1000 MediaTimeV0=2048 MediaRateInteger=1}]" + } + ], + "PayloadSummary": "Version=0 Flags=0x000000 EntryCount=1 Entries=[{SegmentDurationV0=1000 MediaTimeV0=2048 MediaRateInteger=1}]", + "Children": [ + ] + } + ] + }, + { + "BoxType": "mdia", + "Path": "moov/trak/mdia", + "Offset": 6694, + "Size": 607, + "Supported": true, + "PayloadStatus": "empty", + "PayloadFields": [ + ], + "Children": [ + { + "BoxType": "mdhd", + "Path": "moov/trak/mdia/mdhd", + "Offset": 6702, + "Size": 32, + "Supported": true, + "PayloadStatus": "summary", + "PayloadFields": [ + { + "Name": "Version", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "Flags", + "ValueKind": "unsigned", + "Value": 0, + "DisplayValue": "0x000000" + }, + { + "Name": "CreationTimeV0", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "ModificationTimeV0", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "Timescale", + "ValueKind": "unsigned", + "Value": 10240 + }, + { + "Name": "DurationV0", + "ValueKind": "unsigned", + "Value": 10240 + }, + { + "Name": "Language", + "ValueKind": "unsigned_array", + "Value": [ + 5, + 14, + 7 + ], + "DisplayValue": "\"eng\"" + }, + { + "Name": "PreDefined", + "ValueKind": "unsigned", + "Value": 0 + } + ], + "PayloadSummary": "Version=0 Flags=0x000000 CreationTimeV0=0 ModificationTimeV0=0 Timescale=10240 DurationV0=10240 Language=\"eng\" PreDefined=0", + "Children": [ + ] + }, + { + "BoxType": "hdlr", + "Path": "moov/trak/mdia/hdlr", + "Offset": 6734, + "Size": 44, + "Supported": true, + "PayloadStatus": "summary", + "PayloadFields": [ + { + "Name": "Version", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "Flags", + "ValueKind": "unsigned", + "Value": 0, + "DisplayValue": "0x000000" + }, + { + "Name": "PreDefined", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "HandlerType", + "ValueKind": "bytes", + "Value": [ + 118, + 105, + 100, + 101 + ], + "DisplayValue": "\"vide\"" + }, + { + "Name": "Name", + "ValueKind": "string", + "Value": "VideoHandle", + "DisplayValue": "\"VideoHandle\"" + } + ], + "PayloadSummary": "Version=0 Flags=0x000000 PreDefined=0 HandlerType=\"vide\" Name=\"VideoHandle\"", + "Children": [ + ] + }, + { + "BoxType": "minf", + "Path": "moov/trak/mdia/minf", + "Offset": 6778, + "Size": 523, + "Supported": true, + "PayloadStatus": "empty", + "PayloadFields": [ + ], + "Children": [ + { + "BoxType": "vmhd", + "Path": "moov/trak/mdia/minf/vmhd", + "Offset": 6786, + "Size": 20, + "Supported": true, + "PayloadStatus": "summary", + "PayloadFields": [ + { + "Name": "Version", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "Flags", + "ValueKind": "unsigned", + "Value": 1, + "DisplayValue": "0x000001" + }, + { + "Name": "Graphicsmode", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "Opcolor", + "ValueKind": "unsigned_array", + "Value": [ + 0, + 0, + 0 + ] + } + ], + "PayloadSummary": "Version=0 Flags=0x000001 Graphicsmode=0 Opcolor=[0, 0, 0]", + "Children": [ + ] + }, + { + "BoxType": "dinf", + "Path": "moov/trak/mdia/minf/dinf", + "Offset": 6806, + "Size": 36, + "Supported": true, + "PayloadStatus": "empty", + "PayloadFields": [ + ], + "Children": [ + { + "BoxType": "dref", + "Path": "moov/trak/mdia/minf/dinf/dref", + "Offset": 6814, + "Size": 28, + "Supported": true, + "PayloadStatus": "summary", + "PayloadFields": [ + { + "Name": "Version", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "Flags", + "ValueKind": "unsigned", + "Value": 0, + "DisplayValue": "0x000000" + }, + { + "Name": "EntryCount", + "ValueKind": "unsigned", + "Value": 1 + } + ], + "PayloadSummary": "Version=0 Flags=0x000000 EntryCount=1", + "Children": [ + { + "BoxType": "url ", + "Path": "moov/trak/mdia/minf/dinf/dref/url ", + "Offset": 6830, + "Size": 12, + "Supported": true, + "PayloadStatus": "summary", + "PayloadFields": [ + { + "Name": "Version", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "Flags", + "ValueKind": "unsigned", + "Value": 1, + "DisplayValue": "0x000001" + } + ], + "PayloadSummary": "Version=0 Flags=0x000001", + "Children": [ + ] + } + ] + } + ] + }, + { + "BoxType": "stbl", + "Path": "moov/trak/mdia/minf/stbl", + "Offset": 6842, + "Size": 459, + "Supported": true, + "PayloadStatus": "empty", + "PayloadFields": [ + ], + "Children": [ + { + "BoxType": "stsd", + "Path": "moov/trak/mdia/minf/stbl/stsd", + "Offset": 6850, + "Size": 167, + "Supported": true, + "PayloadStatus": "summary", + "PayloadFields": [ + { + "Name": "Version", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "Flags", + "ValueKind": "unsigned", + "Value": 0, + "DisplayValue": "0x000000" + }, + { + "Name": "EntryCount", + "ValueKind": "unsigned", + "Value": 1 + } + ], + "PayloadSummary": "Version=0 Flags=0x000000 EntryCount=1", + "Children": [ + { + "BoxType": "avc1", + "Path": "moov/trak/mdia/minf/stbl/stsd/avc1", + "Offset": 6866, + "Size": 151, + "Supported": true, + "PayloadStatus": "summary", + "PayloadFields": [ + { + "Name": "DataReferenceIndex", + "ValueKind": "unsigned", + "Value": 1 + }, + { + "Name": "PreDefined", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "PreDefined2", + "ValueKind": "unsigned_array", + "Value": [ + 0, + 0, + 0 + ] + }, + { + "Name": "Width", + "ValueKind": "unsigned", + "Value": 320 + }, + { + "Name": "Height", + "ValueKind": "unsigned", + "Value": 180 + }, + { + "Name": "Horizresolution", + "ValueKind": "unsigned", + "Value": 4718592 + }, + { + "Name": "Vertresolution", + "ValueKind": "unsigned", + "Value": 4718592 + }, + { + "Name": "FrameCount", + "ValueKind": "unsigned", + "Value": 1 + }, + { + "Name": "Compressorname", + "ValueKind": "bytes", + "Value": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "DisplayValue": "\"\"" + }, + { + "Name": "Depth", + "ValueKind": "unsigned", + "Value": 24 + }, + { + "Name": "PreDefined3", + "ValueKind": "signed", + "Value": -1 + } + ], + "PayloadSummary": "DataReferenceIndex=1 PreDefined=0 PreDefined2=[0, 0, 0] Width=320 Height=180 Horizresolution=4718592 Vertresolution=4718592 FrameCount=1 Compressorname=\"\" Depth=24 PreDefined3=-1", + "Children": [ + { + "BoxType": "avcC", + "Path": "moov/trak/mdia/minf/stbl/stsd/avc1/avcC", + "Offset": 6952, + "Size": 49, + "Supported": true, + "PayloadStatus": "summary", + "PayloadFields": [ + { + "Name": "ConfigurationVersion", + "ValueKind": "unsigned", + "Value": 1, + "DisplayValue": "0x1" + }, + { + "Name": "Profile", + "ValueKind": "unsigned", + "Value": 100, + "DisplayValue": "0x64" + }, + { + "Name": "ProfileCompatibility", + "ValueKind": "unsigned", + "Value": 0, + "DisplayValue": "0x0" + }, + { + "Name": "Level", + "ValueKind": "unsigned", + "Value": 12, + "DisplayValue": "0xc" + }, + { + "Name": "LengthSizeMinusOne", + "ValueKind": "unsigned", + "Value": 3, + "DisplayValue": "0x3" + }, + { + "Name": "NumOfSequenceParameterSets", + "ValueKind": "unsigned", + "Value": 1, + "DisplayValue": "0x1" + }, + { + "Name": "SequenceParameterSets", + "ValueKind": "bytes", + "Value": [ + 0, + 25, + 103, + 100, + 0, + 12, + 172, + 217, + 65, + 65, + 159, + 159, + 1, + 108, + 128, + 0, + 0, + 3, + 0, + 128, + 0, + 0, + 10, + 7, + 138, + 20, + 203 + ], + "DisplayValue": "[{Length=25 NALUnit=[0x67, 0x64, 0x0, 0xc, 0xac, 0xd9, 0x41, 0x41, 0x9f, 0x9f, 0x1, 0x6c, 0x80, 0x0, 0x0, 0x3, 0x0, 0x80, 0x0, 0x0, 0xa, 0x7, 0x8a, 0x14, 0xcb]}]" + }, + { + "Name": "NumOfPictureParameterSets", + "ValueKind": "unsigned", + "Value": 1, + "DisplayValue": "0x1" + }, + { + "Name": "PictureParameterSets", + "ValueKind": "bytes", + "Value": [ + 0, + 5, + 104, + 235, + 236, + 178, + 44 + ], + "DisplayValue": "[{Length=5 NALUnit=[0x68, 0xeb, 0xec, 0xb2, 0x2c]}]" + } + ], + "PayloadSummary": "ConfigurationVersion=0x1 Profile=0x64 ProfileCompatibility=0x0 Level=0xc LengthSizeMinusOne=0x3 NumOfSequenceParameterSets=0x1 SequenceParameterSets=[{Length=25 NALUnit=[0x67, 0x64, 0x0, 0xc, 0xac, 0xd9, 0x41, 0x41, 0x9f, 0x9f, 0x1, 0x6c, 0x80, 0x0, 0x0, 0x3, 0x0, 0x80, 0x0, 0x0, 0xa, 0x7, 0x8a, 0x14, 0xcb]}] NumOfPictureParameterSets=0x1 PictureParameterSets=[{Length=5 NALUnit=[0x68, 0xeb, 0xec, 0xb2, 0x2c]}]", + "Children": [ + ] + }, + { + "BoxType": "pasp", + "Path": "moov/trak/mdia/minf/stbl/stsd/avc1/pasp", + "Offset": 7001, + "Size": 16, + "Supported": true, + "PayloadStatus": "summary", + "PayloadFields": [ + { + "Name": "HSpacing", + "ValueKind": "unsigned", + "Value": 1 + }, + { + "Name": "VSpacing", + "ValueKind": "unsigned", + "Value": 1 + } + ], + "PayloadSummary": "HSpacing=1 VSpacing=1", + "Children": [ + ] + } + ] + } + ] + }, + { + "BoxType": "stts", + "Path": "moov/trak/mdia/minf/stbl/stts", + "Offset": 7017, + "Size": 24, + "Supported": true, + "PayloadStatus": "summary", + "PayloadFields": [ + { + "Name": "Version", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "Flags", + "ValueKind": "unsigned", + "Value": 0, + "DisplayValue": "0x000000" + }, + { + "Name": "EntryCount", + "ValueKind": "unsigned", + "Value": 1 + }, + { + "Name": "Entries", + "ValueKind": "bytes", + "Value": [ + 0, + 0, + 0, + 10, + 0, + 0, + 4, + 0 + ], + "DisplayValue": "[{SampleCount=10 SampleDelta=1024}]" + } + ], + "PayloadSummary": "Version=0 Flags=0x000000 EntryCount=1 Entries=[{SampleCount=10 SampleDelta=1024}]", + "Children": [ + ] + }, + { + "BoxType": "stss", + "Path": "moov/trak/mdia/minf/stbl/stss", + "Offset": 7041, + "Size": 20, + "Supported": true, + "PayloadStatus": "summary", + "PayloadFields": [ + { + "Name": "Version", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "Flags", + "ValueKind": "unsigned", + "Value": 0, + "DisplayValue": "0x000000" + }, + { + "Name": "EntryCount", + "ValueKind": "unsigned", + "Value": 1 + }, + { + "Name": "SampleNumber", + "ValueKind": "unsigned_array", + "Value": [ + 1 + ] + } + ], + "PayloadSummary": "Version=0 Flags=0x000000 EntryCount=1 SampleNumber=[1]", + "Children": [ + ] + }, + { + "BoxType": "ctts", + "Path": "moov/trak/mdia/minf/stbl/ctts", + "Offset": 7061, + "Size": 88, + "Supported": true, + "PayloadStatus": "summary", + "PayloadFields": [ + { + "Name": "Version", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "Flags", + "ValueKind": "unsigned", + "Value": 0, + "DisplayValue": "0x000000" + }, + { + "Name": "EntryCount", + "ValueKind": "unsigned", + "Value": 9 + }, + { + "Name": "Entries", + "ValueKind": "bytes", + "Value": [ + 0, + 0, + 0, + 2, + 0, + 0, + 8, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 20, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 8, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 4, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 12, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 4, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 12, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 4, + 0 + ], + "DisplayValue": "[{SampleCount=2 SampleOffsetV0=2048}, {SampleCount=1 SampleOffsetV0=5120}, {SampleCount=1 SampleOffsetV0=2048}, {SampleCount=1 SampleOffsetV0=0}, {SampleCount=1 SampleOffsetV0=1024}, {SampleCount=1 SampleOffsetV0=3072}, {SampleCount=1 SampleOffsetV0=1024}, {SampleCount=1 SampleOffsetV0=3072}, {SampleCount=1 SampleOffsetV0=1024}]" + } + ], + "PayloadSummary": "Version=0 Flags=0x000000 EntryCount=9 Entries=[{SampleCount=2 SampleOffsetV0=2048}, {SampleCount=1 SampleOffsetV0=5120}, {SampleCount=1 SampleOffsetV0=2048}, {SampleCount=1 SampleOffsetV0=0}, {SampleCount=1 SampleOffsetV0=1024}, {SampleCount=1 SampleOffsetV0=3072}, {SampleCount=1 SampleOffsetV0=1024}, {SampleCount=1 SampleOffsetV0=3072}, {SampleCount=1 SampleOffsetV0=1024}]", + "Children": [ + ] + }, + { + "BoxType": "stsc", + "Path": "moov/trak/mdia/minf/stbl/stsc", + "Offset": 7149, + "Size": 40, + "Supported": true, + "PayloadStatus": "summary", + "PayloadFields": [ + { + "Name": "Version", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "Flags", + "ValueKind": "unsigned", + "Value": 0, + "DisplayValue": "0x000000" + }, + { + "Name": "EntryCount", + "ValueKind": "unsigned", + "Value": 2 + }, + { + "Name": "Entries", + "ValueKind": "bytes", + "Value": [ + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 2, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 2, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 1 + ], + "DisplayValue": "[{FirstChunk=1 SamplesPerChunk=2 SampleDescriptionIndex=1}, {FirstChunk=2 SamplesPerChunk=1 SampleDescriptionIndex=1}]" + } + ], + "PayloadSummary": "Version=0 Flags=0x000000 EntryCount=2 Entries=[{FirstChunk=1 SamplesPerChunk=2 SampleDescriptionIndex=1}, {FirstChunk=2 SamplesPerChunk=1 SampleDescriptionIndex=1}]", + "Children": [ + ] + }, + { + "BoxType": "stsz", + "Path": "moov/trak/mdia/minf/stbl/stsz", + "Offset": 7189, + "Size": 60, + "Supported": true, + "PayloadStatus": "summary", + "PayloadFields": [ + { + "Name": "Version", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "Flags", + "ValueKind": "unsigned", + "Value": 0, + "DisplayValue": "0x000000" + }, + { + "Name": "SampleSize", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "SampleCount", + "ValueKind": "unsigned", + "Value": 10 + }, + { + "Name": "EntrySize", + "ValueKind": "unsigned_array", + "Value": [ + 3679, + 86, + 545, + 180, + 69, + 60, + 182, + 22, + 204, + 15 + ] + } + ], + "PayloadSummary": "Version=0 Flags=0x000000 SampleSize=0 SampleCount=10 EntrySize=[3679, 86, 545, 180, 69, 60, 182, 22, 204, 15]", + "Children": [ + ] + }, + { + "BoxType": "stco", + "Path": "moov/trak/mdia/minf/stbl/stco", + "Offset": 7249, + "Size": 52, + "Supported": true, + "PayloadStatus": "summary", + "PayloadFields": [ + { + "Name": "Version", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "Flags", + "ValueKind": "unsigned", + "Value": 0, + "DisplayValue": "0x000000" + }, + { + "Name": "EntryCount", + "ValueKind": "unsigned", + "Value": 9 + }, + { + "Name": "ChunkOffset", + "ValueKind": "unsigned_array", + "Value": [ + 48, + 3836, + 4527, + 4864, + 5043, + 5227, + 5560, + 5702, + 6038 + ] + } + ], + "PayloadSummary": "Version=0 Flags=0x000000 EntryCount=9 ChunkOffset=[48, 3836, 4527, 4864, 5043, 5227, 5560, 5702, 6038]", + "Children": [ + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "BoxType": "trak", + "Path": "moov/trak", + "Offset": 7301, + "Size": 844, + "Supported": true, + "PayloadStatus": "empty", + "PayloadFields": [ + ], + "Children": [ + { + "BoxType": "tkhd", + "Path": "moov/trak/tkhd", + "Offset": 7309, + "Size": 92, + "Supported": true, + "PayloadStatus": "summary", + "PayloadFields": [ + { + "Name": "Version", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "Flags", + "ValueKind": "unsigned", + "Value": 3, + "DisplayValue": "0x000003" + }, + { + "Name": "CreationTimeV0", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "ModificationTimeV0", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "TrackID", + "ValueKind": "unsigned", + "Value": 2 + }, + { + "Name": "DurationV0", + "ValueKind": "unsigned", + "Value": 1024 + }, + { + "Name": "Layer", + "ValueKind": "signed", + "Value": 0 + }, + { + "Name": "AlternateGroup", + "ValueKind": "signed", + "Value": 1 + }, + { + "Name": "Volume", + "ValueKind": "signed", + "Value": 256 + }, + { + "Name": "Matrix", + "ValueKind": "signed_array", + "Value": [ + 65536, + 0, + 0, + 0, + 65536, + 0, + 0, + 0, + 1073741824 + ], + "DisplayValue": "[0x10000, 0x0, 0x0, 0x0, 0x10000, 0x0, 0x0, 0x0, 0x40000000]" + }, + { + "Name": "Width", + "ValueKind": "unsigned", + "Value": 0, + "DisplayValue": "0" + }, + { + "Name": "Height", + "ValueKind": "unsigned", + "Value": 0, + "DisplayValue": "0" + } + ], + "PayloadSummary": "Version=0 Flags=0x000003 CreationTimeV0=0 ModificationTimeV0=0 TrackID=2 DurationV0=1024 Layer=0 AlternateGroup=1 Volume=256 Matrix=[0x10000, 0x0, 0x0, 0x0, 0x10000, 0x0, 0x0, 0x0, 0x40000000] Width=0 Height=0", + "Children": [ + ] + }, + { + "BoxType": "edts", + "Path": "moov/trak/edts", + "Offset": 7401, + "Size": 36, + "Supported": true, + "PayloadStatus": "empty", + "PayloadFields": [ + ], + "Children": [ + { + "BoxType": "elst", + "Path": "moov/trak/edts/elst", + "Offset": 7409, + "Size": 28, + "Supported": true, + "PayloadStatus": "summary", + "PayloadFields": [ + { + "Name": "Version", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "Flags", + "ValueKind": "unsigned", + "Value": 0, + "DisplayValue": "0x000000" + }, + { + "Name": "EntryCount", + "ValueKind": "unsigned", + "Value": 1 + }, + { + "Name": "Entries", + "ValueKind": "bytes", + "Value": [ + 0, + 0, + 3, + 232, + 0, + 0, + 4, + 0, + 0, + 1, + 0, + 0 + ], + "DisplayValue": "[{SegmentDurationV0=1000 MediaTimeV0=1024 MediaRateInteger=1}]" + } + ], + "PayloadSummary": "Version=0 Flags=0x000000 EntryCount=1 Entries=[{SegmentDurationV0=1000 MediaTimeV0=1024 MediaRateInteger=1}]", + "Children": [ + ] + } + ] + }, + { + "BoxType": "mdia", + "Path": "moov/trak/mdia", + "Offset": 7437, + "Size": 708, + "Supported": true, + "PayloadStatus": "empty", + "PayloadFields": [ + ], + "Children": [ + { + "BoxType": "mdhd", + "Path": "moov/trak/mdia/mdhd", + "Offset": 7445, + "Size": 32, + "Supported": true, + "PayloadStatus": "summary", + "PayloadFields": [ + { + "Name": "Version", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "Flags", + "ValueKind": "unsigned", + "Value": 0, + "DisplayValue": "0x000000" + }, + { + "Name": "CreationTimeV0", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "ModificationTimeV0", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "Timescale", + "ValueKind": "unsigned", + "Value": 44100 + }, + { + "Name": "DurationV0", + "ValueKind": "unsigned", + "Value": 45124 + }, + { + "Name": "Language", + "ValueKind": "unsigned_array", + "Value": [ + 5, + 14, + 7 + ], + "DisplayValue": "\"eng\"" + }, + { + "Name": "PreDefined", + "ValueKind": "unsigned", + "Value": 0 + } + ], + "PayloadSummary": "Version=0 Flags=0x000000 CreationTimeV0=0 ModificationTimeV0=0 Timescale=44100 DurationV0=45124 Language=\"eng\" PreDefined=0", + "Children": [ + ] + }, + { + "BoxType": "hdlr", + "Path": "moov/trak/mdia/hdlr", + "Offset": 7477, + "Size": 44, + "Supported": true, + "PayloadStatus": "summary", + "PayloadFields": [ + { + "Name": "Version", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "Flags", + "ValueKind": "unsigned", + "Value": 0, + "DisplayValue": "0x000000" + }, + { + "Name": "PreDefined", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "HandlerType", + "ValueKind": "bytes", + "Value": [ + 115, + 111, + 117, + 110 + ], + "DisplayValue": "\"soun\"" + }, + { + "Name": "Name", + "ValueKind": "string", + "Value": "SoundHandle", + "DisplayValue": "\"SoundHandle\"" + } + ], + "PayloadSummary": "Version=0 Flags=0x000000 PreDefined=0 HandlerType=\"soun\" Name=\"SoundHandle\"", + "Children": [ + ] + }, + { + "BoxType": "minf", + "Path": "moov/trak/mdia/minf", + "Offset": 7521, + "Size": 624, + "Supported": true, + "PayloadStatus": "empty", + "PayloadFields": [ + ], + "Children": [ + { + "BoxType": "smhd", + "Path": "moov/trak/mdia/minf/smhd", + "Offset": 7529, + "Size": 16, + "Supported": true, + "PayloadStatus": "summary", + "PayloadFields": [ + { + "Name": "Version", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "Flags", + "ValueKind": "unsigned", + "Value": 0, + "DisplayValue": "0x000000" + }, + { + "Name": "Balance", + "ValueKind": "signed", + "Value": 0, + "DisplayValue": "0" + } + ], + "PayloadSummary": "Version=0 Flags=0x000000 Balance=0", + "Children": [ + ] + }, + { + "BoxType": "dinf", + "Path": "moov/trak/mdia/minf/dinf", + "Offset": 7545, + "Size": 36, + "Supported": true, + "PayloadStatus": "empty", + "PayloadFields": [ + ], + "Children": [ + { + "BoxType": "dref", + "Path": "moov/trak/mdia/minf/dinf/dref", + "Offset": 7553, + "Size": 28, + "Supported": true, + "PayloadStatus": "summary", + "PayloadFields": [ + { + "Name": "Version", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "Flags", + "ValueKind": "unsigned", + "Value": 0, + "DisplayValue": "0x000000" + }, + { + "Name": "EntryCount", + "ValueKind": "unsigned", + "Value": 1 + } + ], + "PayloadSummary": "Version=0 Flags=0x000000 EntryCount=1", + "Children": [ + { + "BoxType": "url ", + "Path": "moov/trak/mdia/minf/dinf/dref/url ", + "Offset": 7569, + "Size": 12, + "Supported": true, + "PayloadStatus": "summary", + "PayloadFields": [ + { + "Name": "Version", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "Flags", + "ValueKind": "unsigned", + "Value": 1, + "DisplayValue": "0x000001" + } + ], + "PayloadSummary": "Version=0 Flags=0x000001", + "Children": [ + ] + } + ] + } + ] + }, + { + "BoxType": "stbl", + "Path": "moov/trak/mdia/minf/stbl", + "Offset": 7581, + "Size": 564, + "Supported": true, + "PayloadStatus": "empty", + "PayloadFields": [ + ], + "Children": [ + { + "BoxType": "stsd", + "Path": "moov/trak/mdia/minf/stbl/stsd", + "Offset": 7589, + "Size": 106, + "Supported": true, + "PayloadStatus": "summary", + "PayloadFields": [ + { + "Name": "Version", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "Flags", + "ValueKind": "unsigned", + "Value": 0, + "DisplayValue": "0x000000" + }, + { + "Name": "EntryCount", + "ValueKind": "unsigned", + "Value": 1 + } + ], + "PayloadSummary": "Version=0 Flags=0x000000 EntryCount=1", + "Children": [ + { + "BoxType": "mp4a", + "Path": "moov/trak/mdia/minf/stbl/stsd/mp4a", + "Offset": 7605, + "Size": 90, + "Supported": true, + "PayloadStatus": "summary", + "PayloadFields": [ + { + "Name": "DataReferenceIndex", + "ValueKind": "unsigned", + "Value": 1 + }, + { + "Name": "EntryVersion", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "ChannelCount", + "ValueKind": "unsigned", + "Value": 2 + }, + { + "Name": "SampleSize", + "ValueKind": "unsigned", + "Value": 16 + }, + { + "Name": "PreDefined", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "SampleRate", + "ValueKind": "unsigned", + "Value": 2890137600, + "DisplayValue": "44100" + } + ], + "PayloadSummary": "DataReferenceIndex=1 EntryVersion=0 ChannelCount=2 SampleSize=16 PreDefined=0 SampleRate=44100", + "Children": [ + { + "BoxType": "esds", + "Path": "moov/trak/mdia/minf/stbl/stsd/mp4a/esds", + "Offset": 7641, + "Size": 54, + "Supported": true, + "PayloadStatus": "summary", + "PayloadFields": [ + { + "Name": "Version", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "Flags", + "ValueKind": "unsigned", + "Value": 0, + "DisplayValue": "0x000000" + }, + { + "Name": "Descriptors", + "ValueKind": "bytes", + "Value": [ + 3, + 128, + 128, + 128, + 37, + 0, + 2, + 0, + 4, + 128, + 128, + 128, + 23, + 64, + 21, + 0, + 0, + 0, + 0, + 0, + 41, + 74, + 0, + 0, + 41, + 74, + 5, + 128, + 128, + 128, + 5, + 18, + 16, + 86, + 229, + 0, + 6, + 128, + 128, + 128, + 1, + 2 + ], + "DisplayValue": "[{Tag=ESDescr Size=37 ESID=2 StreamDependenceFlag=false UrlFlag=false OcrStreamFlag=false StreamPriority=0}, {Tag=DecoderConfigDescr Size=23 ObjectTypeIndication=0x40 StreamType=5 UpStream=false Reserved=true BufferSizeDB=0 MaxBitrate=10570 AvgBitrate=10570}, {Tag=DecSpecificInfo Size=5 Data=[0x12, 0x10, 0x56, 0xe5, 0x0]}, {Tag=SLConfigDescr Size=1 Data=[0x2]}]" + } + ], + "PayloadSummary": "Version=0 Flags=0x000000 Descriptors=[{Tag=ESDescr Size=37 ESID=2 StreamDependenceFlag=false UrlFlag=false OcrStreamFlag=false StreamPriority=0}, {Tag=DecoderConfigDescr Size=23 ObjectTypeIndication=0x40 StreamType=5 UpStream=false Reserved=true BufferSizeDB=0 MaxBitrate=10570 AvgBitrate=10570}, {Tag=DecSpecificInfo Size=5 Data=[0x12, 0x10, 0x56, 0xe5, 0x0]}, {Tag=SLConfigDescr Size=1 Data=[0x2]}]", + "Children": [ + ] + } + ] + } + ] + }, + { + "BoxType": "stts", + "Path": "moov/trak/mdia/minf/stbl/stts", + "Offset": 7695, + "Size": 48, + "Supported": true, + "PayloadStatus": "summary", + "PayloadFields": [ + { + "Name": "Version", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "Flags", + "ValueKind": "unsigned", + "Value": 0, + "DisplayValue": "0x000000" + }, + { + "Name": "EntryCount", + "ValueKind": "unsigned", + "Value": 4 + }, + { + "Name": "Entries", + "ValueKind": "bytes", + "Value": [ + 0, + 0, + 0, + 1, + 0, + 0, + 4, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 5, + 225, + 0, + 0, + 0, + 41, + 0, + 0, + 4, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 2, + 99 + ], + "DisplayValue": "[{SampleCount=1 SampleDelta=1024}, {SampleCount=1 SampleDelta=1505}, {SampleCount=41 SampleDelta=1024}, {SampleCount=1 SampleDelta=611}]" + } + ], + "PayloadSummary": "Version=0 Flags=0x000000 EntryCount=4 Entries=[{SampleCount=1 SampleDelta=1024}, {SampleCount=1 SampleDelta=1505}, {SampleCount=41 SampleDelta=1024}, {SampleCount=1 SampleDelta=611}]", + "Children": [ + ] + }, + { + "BoxType": "stsc", + "Path": "moov/trak/mdia/minf/stbl/stsc", + "Offset": 7743, + "Size": 100, + "Supported": true, + "PayloadStatus": "summary", + "PayloadFields": [ + { + "Name": "Version", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "Flags", + "ValueKind": "unsigned", + "Value": 0, + "DisplayValue": "0x000000" + }, + { + "Name": "EntryCount", + "ValueKind": "unsigned", + "Value": 7 + }, + { + "Name": "Entries", + "ValueKind": "bytes", + "Value": [ + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 2, + 0, + 0, + 0, + 4, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 3, + 0, + 0, + 0, + 5, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 4, + 0, + 0, + 0, + 4, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 6, + 0, + 0, + 0, + 5, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 7, + 0, + 0, + 0, + 4, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 9, + 0, + 0, + 0, + 13, + 0, + 0, + 0, + 1 + ], + "DisplayValue": "[{FirstChunk=1 SamplesPerChunk=1 SampleDescriptionIndex=1}, {FirstChunk=2 SamplesPerChunk=4 SampleDescriptionIndex=1}, {FirstChunk=3 SamplesPerChunk=5 SampleDescriptionIndex=1}, {FirstChunk=4 SamplesPerChunk=4 SampleDescriptionIndex=1}, {FirstChunk=6 SamplesPerChunk=5 SampleDescriptionIndex=1}, {FirstChunk=7 SamplesPerChunk=4 SampleDescriptionIndex=1}, {FirstChunk=9 SamplesPerChunk=13 SampleDescriptionIndex=1}]" + } + ], + "PayloadSummary": "Version=0 Flags=0x000000 EntryCount=7 Entries=[{FirstChunk=1 SamplesPerChunk=1 SampleDescriptionIndex=1}, {FirstChunk=2 SamplesPerChunk=4 SampleDescriptionIndex=1}, {FirstChunk=3 SamplesPerChunk=5 SampleDescriptionIndex=1}, {FirstChunk=4 SamplesPerChunk=4 SampleDescriptionIndex=1}, {FirstChunk=6 SamplesPerChunk=5 SampleDescriptionIndex=1}, {FirstChunk=7 SamplesPerChunk=4 SampleDescriptionIndex=1}, {FirstChunk=9 SamplesPerChunk=13 SampleDescriptionIndex=1}]", + "Children": [ + ] + }, + { + "BoxType": "stsz", + "Path": "moov/trak/mdia/minf/stbl/stsz", + "Offset": 7843, + "Size": 196, + "Supported": true, + "PayloadStatus": "summary", + "PayloadFields": [ + { + "Name": "Version", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "Flags", + "ValueKind": "unsigned", + "Value": 0, + "DisplayValue": "0x000000" + }, + { + "Name": "SampleSize", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "SampleCount", + "ValueKind": "unsigned", + "Value": 44 + }, + { + "Name": "EntrySize", + "ValueKind": "unsigned_array", + "Value": [ + 23, + 39, + 35, + 36, + 36, + 35, + 31, + 33, + 28, + 30, + 33, + 29, + 23, + 25, + 25, + 36, + 27, + 36, + 35, + 25, + 28, + 36, + 27, + 29, + 28, + 29, + 34, + 36, + 30, + 32, + 34, + 29, + 24, + 34, + 35, + 28, + 24, + 30, + 29, + 29, + 27, + 31, + 34, + 35 + ] + } + ], + "PayloadSummary": "Version=0 Flags=0x000000 SampleSize=0 SampleCount=44 EntrySize=[23, 39, 35, 36, 36, 35, 31, 33, 28, 30, 33, 29, 23, 25, 25, 36, 27, 36, 35, 25, 28, 36, 27, 29, 28, 29, 34, 36, 30, 32, 34, 29, 24, 34, 35, 28, 24, 30, 29, 29, 27, 31, 34, 35]", + "Children": [ + ] + }, + { + "BoxType": "stco", + "Path": "moov/trak/mdia/minf/stbl/stco", + "Offset": 8039, + "Size": 52, + "Supported": true, + "PayloadStatus": "summary", + "PayloadFields": [ + { + "Name": "Version", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "Flags", + "ValueKind": "unsigned", + "Value": 0, + "DisplayValue": "0x000000" + }, + { + "Name": "EntryCount", + "ValueKind": "unsigned", + "Value": 9 + }, + { + "Name": "ChunkOffset", + "ValueKind": "unsigned_array", + "Value": [ + 3813, + 4381, + 4707, + 4933, + 5103, + 5409, + 5582, + 5906, + 6053 + ] + } + ], + "PayloadSummary": "Version=0 Flags=0x000000 EntryCount=9 ChunkOffset=[3813, 4381, 4707, 4933, 5103, 5409, 5582, 5906, 6053]", + "Children": [ + ] + }, + { + "BoxType": "sgpd", + "Path": "moov/trak/mdia/minf/stbl/sgpd", + "Offset": 8091, + "Size": 26, + "Supported": true, + "PayloadStatus": "summary", + "PayloadFields": [ + { + "Name": "Version", + "ValueKind": "unsigned", + "Value": 1 + }, + { + "Name": "Flags", + "ValueKind": "unsigned", + "Value": 0, + "DisplayValue": "0x000000" + }, + { + "Name": "GroupingType", + "ValueKind": "bytes", + "Value": [ + 114, + 111, + 108, + 108 + ], + "DisplayValue": "\"roll\"" + }, + { + "Name": "DefaultLength", + "ValueKind": "unsigned", + "Value": 2 + }, + { + "Name": "EntryCount", + "ValueKind": "unsigned", + "Value": 1 + }, + { + "Name": "RollDistances", + "ValueKind": "bytes", + "Value": [ + 255, + 255 + ], + "DisplayValue": "[-1]" + } + ], + "PayloadSummary": "Version=1 Flags=0x000000 GroupingType=\"roll\" DefaultLength=2 EntryCount=1 RollDistances=[-1]", + "Children": [ + ] + }, + { + "BoxType": "sbgp", + "Path": "moov/trak/mdia/minf/stbl/sbgp", + "Offset": 8117, + "Size": 28, + "Supported": true, + "PayloadStatus": "summary", + "PayloadFields": [ + { + "Name": "Version", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "Flags", + "ValueKind": "unsigned", + "Value": 0, + "DisplayValue": "0x000000" + }, + { + "Name": "GroupingType", + "ValueKind": "unsigned", + "Value": 1919904876 + }, + { + "Name": "EntryCount", + "ValueKind": "unsigned", + "Value": 1 + }, + { + "Name": "Entries", + "ValueKind": "bytes", + "Value": [ + 0, + 0, + 0, + 44, + 0, + 0, + 0, + 1 + ], + "DisplayValue": "[{SampleCount=44 GroupDescriptionIndex=1}]" + } + ], + "PayloadSummary": "Version=0 Flags=0x000000 GroupingType=1919904876 EntryCount=1 Entries=[{SampleCount=44 GroupDescriptionIndex=1}]", + "Children": [ + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "BoxType": "udta", + "Path": "moov/udta", + "Offset": 8145, + "Size": 133, + "Supported": true, + "PayloadStatus": "empty", + "PayloadFields": [ + ], + "Children": [ + { + "BoxType": "meta", + "Path": "moov/udta/meta", + "Offset": 8153, + "Size": 90, + "Supported": true, + "PayloadStatus": "summary", + "PayloadFields": [ + { + "Name": "Version", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "Flags", + "ValueKind": "unsigned", + "Value": 0, + "DisplayValue": "0x000000" + } + ], + "PayloadSummary": "Version=0 Flags=0x000000", + "Children": [ + { + "BoxType": "hdlr", + "Path": "moov/udta/meta/hdlr", + "Offset": 8165, + "Size": 33, + "Supported": true, + "PayloadStatus": "summary", + "PayloadFields": [ + { + "Name": "Version", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "Flags", + "ValueKind": "unsigned", + "Value": 0, + "DisplayValue": "0x000000" + }, + { + "Name": "PreDefined", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "HandlerType", + "ValueKind": "bytes", + "Value": [ + 109, + 100, + 105, + 114 + ], + "DisplayValue": "\"mdir\"" + }, + { + "Name": "Name", + "ValueKind": "string", + "Value": "", + "DisplayValue": "\"\"" + } + ], + "PayloadSummary": "Version=0 Flags=0x000000 PreDefined=0 HandlerType=\"mdir\" Name=\"\"", + "Children": [ + ] + }, + { + "BoxType": "ilst", + "Path": "moov/udta/meta/ilst", + "Offset": 8198, + "Size": 45, + "Supported": true, + "PayloadStatus": "empty", + "PayloadFields": [ + ], + "Children": [ + { + "BoxType": "(c)too", + "Path": "moov/udta/meta/ilst/(c)too", + "Offset": 8206, + "Size": 37, + "Supported": true, + "PayloadStatus": "empty", + "PayloadFields": [ + ], + "Children": [ + { + "BoxType": "data", + "Path": "moov/udta/meta/ilst/(c)too/data", + "Offset": 8214, + "Size": 29, + "Supported": true, + "PayloadStatus": "summary", + "PayloadFields": [ + { + "Name": "DataType", + "ValueKind": "unsigned", + "Value": 1, + "DisplayValue": "UTF8" + }, + { + "Name": "DataLang", + "ValueKind": "unsigned", + "Value": 0 + }, + { + "Name": "EncodingTool", + "ValueKind": "bytes", + "Value": [ + 76, + 97, + 118, + 102, + 53, + 56, + 46, + 50, + 57, + 46, + 49, + 48, + 48 + ], + "DisplayValue": "\"Lavf58.29.100\"" + } + ], + "PayloadSummary": "DataType=UTF8 DataLang=0 EncodingTool=\"Lavf58.29.100\"", + "Children": [ + ] + } + ] + } + ] + } + ] + }, + { + "BoxType": "loci", + "Path": "moov/udta/loci", + "Offset": 8243, + "Size": 35, + "Supported": false, + "PayloadStatus": "omitted", + "PayloadFields": [ + ], + "Children": [ + ] + } + ] + } + ] + } + ] +} diff --git a/tests/golden/cli_dump/sample.yaml b/tests/golden/cli_dump/sample.yaml new file mode 100644 index 0000000..17f0cab --- /dev/null +++ b/tests/golden/cli_dump/sample.yaml @@ -0,0 +1,1666 @@ +boxes: +- box_type: ftyp + path: ftyp + offset: 0 + size: 32 + supported: true + payload_status: summary + payload_fields: + - name: MajorBrand + value_kind: bytes + value: + - 105 + - 115 + - 111 + - 109 + display_value: '"isom"' + - name: MinorVersion + value_kind: unsigned + value: 512 + - name: CompatibleBrands + value_kind: bytes + value: + - 105 + - 115 + - 111 + - 109 + - 105 + - 115 + - 111 + - 50 + - 97 + - 118 + - 99 + - 49 + - 109 + - 112 + - 52 + - 49 + display_value: '[{CompatibleBrand="isom"}, {CompatibleBrand="iso2"}, {CompatibleBrand="avc1"}, {CompatibleBrand="mp41"}]' + payload_summary: 'MajorBrand="isom" MinorVersion=512 CompatibleBrands=[{CompatibleBrand="isom"}, {CompatibleBrand="iso2"}, {CompatibleBrand="avc1"}, {CompatibleBrand="mp41"}]' + children: [] +- box_type: free + path: free + offset: 32 + size: 8 + supported: true + payload_status: omitted + payload_fields: [] + children: [] +- box_type: mdat + path: mdat + offset: 40 + size: 6402 + supported: true + payload_status: omitted + payload_fields: [] + children: [] +- box_type: moov + path: moov + offset: 6442 + size: 1836 + supported: true + payload_status: empty + payload_fields: [] + children: + - box_type: mvhd + path: moov/mvhd + offset: 6450 + size: 108 + supported: true + payload_status: summary + payload_fields: + - name: Version + value_kind: unsigned + value: 0 + - name: Flags + value_kind: unsigned + value: 0 + display_value: 0x000000 + - name: CreationTimeV0 + value_kind: unsigned + value: 0 + - name: ModificationTimeV0 + value_kind: unsigned + value: 0 + - name: Timescale + value_kind: unsigned + value: 1000 + - name: DurationV0 + value_kind: unsigned + value: 1024 + - name: Rate + value_kind: signed + value: 65536 + display_value: 1 + - name: Volume + value_kind: signed + value: 256 + - name: Matrix + value_kind: signed_array + value: + - 65536 + - 0 + - 0 + - 0 + - 65536 + - 0 + - 0 + - 0 + - 1073741824 + display_value: '[0x10000, 0x0, 0x0, 0x0, 0x10000, 0x0, 0x0, 0x0, 0x40000000]' + - name: PreDefined + value_kind: signed_array + value: + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 + - name: NextTrackID + value_kind: unsigned + value: 3 + payload_summary: 'Version=0 Flags=0x000000 CreationTimeV0=0 ModificationTimeV0=0 Timescale=1000 DurationV0=1024 Rate=1 Volume=256 Matrix=[0x10000, 0x0, 0x0, 0x0, 0x10000, 0x0, 0x0, 0x0, 0x40000000] PreDefined=[0, 0, 0, 0, 0, 0] NextTrackID=3' + children: [] + - box_type: trak + path: moov/trak + offset: 6558 + size: 743 + supported: true + payload_status: empty + payload_fields: [] + children: + - box_type: tkhd + path: moov/trak/tkhd + offset: 6566 + size: 92 + supported: true + payload_status: summary + payload_fields: + - name: Version + value_kind: unsigned + value: 0 + - name: Flags + value_kind: unsigned + value: 3 + display_value: 0x000003 + - name: CreationTimeV0 + value_kind: unsigned + value: 0 + - name: ModificationTimeV0 + value_kind: unsigned + value: 0 + - name: TrackID + value_kind: unsigned + value: 1 + - name: DurationV0 + value_kind: unsigned + value: 1000 + - name: Layer + value_kind: signed + value: 0 + - name: AlternateGroup + value_kind: signed + value: 0 + - name: Volume + value_kind: signed + value: 0 + - name: Matrix + value_kind: signed_array + value: + - 65536 + - 0 + - 0 + - 0 + - 65536 + - 0 + - 0 + - 0 + - 1073741824 + display_value: '[0x10000, 0x0, 0x0, 0x0, 0x10000, 0x0, 0x0, 0x0, 0x40000000]' + - name: Width + value_kind: unsigned + value: 20971520 + display_value: 320 + - name: Height + value_kind: unsigned + value: 11796480 + display_value: 180 + payload_summary: 'Version=0 Flags=0x000003 CreationTimeV0=0 ModificationTimeV0=0 TrackID=1 DurationV0=1000 Layer=0 AlternateGroup=0 Volume=0 Matrix=[0x10000, 0x0, 0x0, 0x0, 0x10000, 0x0, 0x0, 0x0, 0x40000000] Width=320 Height=180' + children: [] + - box_type: edts + path: moov/trak/edts + offset: 6658 + size: 36 + supported: true + payload_status: empty + payload_fields: [] + children: + - box_type: elst + path: moov/trak/edts/elst + offset: 6666 + size: 28 + supported: true + payload_status: summary + payload_fields: + - name: Version + value_kind: unsigned + value: 0 + - name: Flags + value_kind: unsigned + value: 0 + display_value: 0x000000 + - name: EntryCount + value_kind: unsigned + value: 1 + - name: Entries + value_kind: bytes + value: + - 0 + - 0 + - 3 + - 232 + - 0 + - 0 + - 8 + - 0 + - 0 + - 1 + - 0 + - 0 + display_value: '[{SegmentDurationV0=1000 MediaTimeV0=2048 MediaRateInteger=1}]' + payload_summary: 'Version=0 Flags=0x000000 EntryCount=1 Entries=[{SegmentDurationV0=1000 MediaTimeV0=2048 MediaRateInteger=1}]' + children: [] + - box_type: mdia + path: moov/trak/mdia + offset: 6694 + size: 607 + supported: true + payload_status: empty + payload_fields: [] + children: + - box_type: mdhd + path: moov/trak/mdia/mdhd + offset: 6702 + size: 32 + supported: true + payload_status: summary + payload_fields: + - name: Version + value_kind: unsigned + value: 0 + - name: Flags + value_kind: unsigned + value: 0 + display_value: 0x000000 + - name: CreationTimeV0 + value_kind: unsigned + value: 0 + - name: ModificationTimeV0 + value_kind: unsigned + value: 0 + - name: Timescale + value_kind: unsigned + value: 10240 + - name: DurationV0 + value_kind: unsigned + value: 10240 + - name: Language + value_kind: unsigned_array + value: + - 5 + - 14 + - 7 + display_value: '"eng"' + - name: PreDefined + value_kind: unsigned + value: 0 + payload_summary: 'Version=0 Flags=0x000000 CreationTimeV0=0 ModificationTimeV0=0 Timescale=10240 DurationV0=10240 Language="eng" PreDefined=0' + children: [] + - box_type: hdlr + path: moov/trak/mdia/hdlr + offset: 6734 + size: 44 + supported: true + payload_status: summary + payload_fields: + - name: Version + value_kind: unsigned + value: 0 + - name: Flags + value_kind: unsigned + value: 0 + display_value: 0x000000 + - name: PreDefined + value_kind: unsigned + value: 0 + - name: HandlerType + value_kind: bytes + value: + - 118 + - 105 + - 100 + - 101 + display_value: '"vide"' + - name: Name + value_kind: string + value: VideoHandle + display_value: '"VideoHandle"' + payload_summary: 'Version=0 Flags=0x000000 PreDefined=0 HandlerType="vide" Name="VideoHandle"' + children: [] + - box_type: minf + path: moov/trak/mdia/minf + offset: 6778 + size: 523 + supported: true + payload_status: empty + payload_fields: [] + children: + - box_type: vmhd + path: moov/trak/mdia/minf/vmhd + offset: 6786 + size: 20 + supported: true + payload_status: summary + payload_fields: + - name: Version + value_kind: unsigned + value: 0 + - name: Flags + value_kind: unsigned + value: 1 + display_value: 0x000001 + - name: Graphicsmode + value_kind: unsigned + value: 0 + - name: Opcolor + value_kind: unsigned_array + value: + - 0 + - 0 + - 0 + payload_summary: 'Version=0 Flags=0x000001 Graphicsmode=0 Opcolor=[0, 0, 0]' + children: [] + - box_type: dinf + path: moov/trak/mdia/minf/dinf + offset: 6806 + size: 36 + supported: true + payload_status: empty + payload_fields: [] + children: + - box_type: dref + path: moov/trak/mdia/minf/dinf/dref + offset: 6814 + size: 28 + supported: true + payload_status: summary + payload_fields: + - name: Version + value_kind: unsigned + value: 0 + - name: Flags + value_kind: unsigned + value: 0 + display_value: 0x000000 + - name: EntryCount + value_kind: unsigned + value: 1 + payload_summary: 'Version=0 Flags=0x000000 EntryCount=1' + children: + - box_type: 'url ' + path: 'moov/trak/mdia/minf/dinf/dref/url ' + offset: 6830 + size: 12 + supported: true + payload_status: summary + payload_fields: + - name: Version + value_kind: unsigned + value: 0 + - name: Flags + value_kind: unsigned + value: 1 + display_value: 0x000001 + payload_summary: 'Version=0 Flags=0x000001' + children: [] + - box_type: stbl + path: moov/trak/mdia/minf/stbl + offset: 6842 + size: 459 + supported: true + payload_status: empty + payload_fields: [] + children: + - box_type: stsd + path: moov/trak/mdia/minf/stbl/stsd + offset: 6850 + size: 167 + supported: true + payload_status: summary + payload_fields: + - name: Version + value_kind: unsigned + value: 0 + - name: Flags + value_kind: unsigned + value: 0 + display_value: 0x000000 + - name: EntryCount + value_kind: unsigned + value: 1 + payload_summary: 'Version=0 Flags=0x000000 EntryCount=1' + children: + - box_type: avc1 + path: moov/trak/mdia/minf/stbl/stsd/avc1 + offset: 6866 + size: 151 + supported: true + payload_status: summary + payload_fields: + - name: DataReferenceIndex + value_kind: unsigned + value: 1 + - name: PreDefined + value_kind: unsigned + value: 0 + - name: PreDefined2 + value_kind: unsigned_array + value: + - 0 + - 0 + - 0 + - name: Width + value_kind: unsigned + value: 320 + - name: Height + value_kind: unsigned + value: 180 + - name: Horizresolution + value_kind: unsigned + value: 4718592 + - name: Vertresolution + value_kind: unsigned + value: 4718592 + - name: FrameCount + value_kind: unsigned + value: 1 + - name: Compressorname + value_kind: bytes + value: + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 + display_value: '""' + - name: Depth + value_kind: unsigned + value: 24 + - name: PreDefined3 + value_kind: signed + value: -1 + payload_summary: 'DataReferenceIndex=1 PreDefined=0 PreDefined2=[0, 0, 0] Width=320 Height=180 Horizresolution=4718592 Vertresolution=4718592 FrameCount=1 Compressorname="" Depth=24 PreDefined3=-1' + children: + - box_type: avcC + path: moov/trak/mdia/minf/stbl/stsd/avc1/avcC + offset: 6952 + size: 49 + supported: true + payload_status: summary + payload_fields: + - name: ConfigurationVersion + value_kind: unsigned + value: 1 + display_value: 0x1 + - name: Profile + value_kind: unsigned + value: 100 + display_value: 0x64 + - name: ProfileCompatibility + value_kind: unsigned + value: 0 + display_value: 0x0 + - name: Level + value_kind: unsigned + value: 12 + display_value: 0xc + - name: LengthSizeMinusOne + value_kind: unsigned + value: 3 + display_value: 0x3 + - name: NumOfSequenceParameterSets + value_kind: unsigned + value: 1 + display_value: 0x1 + - name: SequenceParameterSets + value_kind: bytes + value: + - 0 + - 25 + - 103 + - 100 + - 0 + - 12 + - 172 + - 217 + - 65 + - 65 + - 159 + - 159 + - 1 + - 108 + - 128 + - 0 + - 0 + - 3 + - 0 + - 128 + - 0 + - 0 + - 10 + - 7 + - 138 + - 20 + - 203 + display_value: '[{Length=25 NALUnit=[0x67, 0x64, 0x0, 0xc, 0xac, 0xd9, 0x41, 0x41, 0x9f, 0x9f, 0x1, 0x6c, 0x80, 0x0, 0x0, 0x3, 0x0, 0x80, 0x0, 0x0, 0xa, 0x7, 0x8a, 0x14, 0xcb]}]' + - name: NumOfPictureParameterSets + value_kind: unsigned + value: 1 + display_value: 0x1 + - name: PictureParameterSets + value_kind: bytes + value: + - 0 + - 5 + - 104 + - 235 + - 236 + - 178 + - 44 + display_value: '[{Length=5 NALUnit=[0x68, 0xeb, 0xec, 0xb2, 0x2c]}]' + payload_summary: 'ConfigurationVersion=0x1 Profile=0x64 ProfileCompatibility=0x0 Level=0xc LengthSizeMinusOne=0x3 NumOfSequenceParameterSets=0x1 SequenceParameterSets=[{Length=25 NALUnit=[0x67, 0x64, 0x0, 0xc, 0xac, 0xd9, 0x41, 0x41, 0x9f, 0x9f, 0x1, 0x6c, 0x80, 0x0, 0x0, 0x3, 0x0, 0x80, 0x0, 0x0, 0xa, 0x7, 0x8a, 0x14, 0xcb]}] NumOfPictureParameterSets=0x1 PictureParameterSets=[{Length=5 NALUnit=[0x68, 0xeb, 0xec, 0xb2, 0x2c]}]' + children: [] + - box_type: pasp + path: moov/trak/mdia/minf/stbl/stsd/avc1/pasp + offset: 7001 + size: 16 + supported: true + payload_status: summary + payload_fields: + - name: HSpacing + value_kind: unsigned + value: 1 + - name: VSpacing + value_kind: unsigned + value: 1 + payload_summary: 'HSpacing=1 VSpacing=1' + children: [] + - box_type: stts + path: moov/trak/mdia/minf/stbl/stts + offset: 7017 + size: 24 + supported: true + payload_status: summary + payload_fields: + - name: Version + value_kind: unsigned + value: 0 + - name: Flags + value_kind: unsigned + value: 0 + display_value: 0x000000 + - name: EntryCount + value_kind: unsigned + value: 1 + - name: Entries + value_kind: bytes + value: + - 0 + - 0 + - 0 + - 10 + - 0 + - 0 + - 4 + - 0 + display_value: '[{SampleCount=10 SampleDelta=1024}]' + payload_summary: 'Version=0 Flags=0x000000 EntryCount=1 Entries=[{SampleCount=10 SampleDelta=1024}]' + children: [] + - box_type: stss + path: moov/trak/mdia/minf/stbl/stss + offset: 7041 + size: 20 + supported: true + payload_status: summary + payload_fields: + - name: Version + value_kind: unsigned + value: 0 + - name: Flags + value_kind: unsigned + value: 0 + display_value: 0x000000 + - name: EntryCount + value_kind: unsigned + value: 1 + - name: SampleNumber + value_kind: unsigned_array + value: + - 1 + payload_summary: 'Version=0 Flags=0x000000 EntryCount=1 SampleNumber=[1]' + children: [] + - box_type: ctts + path: moov/trak/mdia/minf/stbl/ctts + offset: 7061 + size: 88 + supported: true + payload_status: summary + payload_fields: + - name: Version + value_kind: unsigned + value: 0 + - name: Flags + value_kind: unsigned + value: 0 + display_value: 0x000000 + - name: EntryCount + value_kind: unsigned + value: 9 + - name: Entries + value_kind: bytes + value: + - 0 + - 0 + - 0 + - 2 + - 0 + - 0 + - 8 + - 0 + - 0 + - 0 + - 0 + - 1 + - 0 + - 0 + - 20 + - 0 + - 0 + - 0 + - 0 + - 1 + - 0 + - 0 + - 8 + - 0 + - 0 + - 0 + - 0 + - 1 + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 + - 1 + - 0 + - 0 + - 4 + - 0 + - 0 + - 0 + - 0 + - 1 + - 0 + - 0 + - 12 + - 0 + - 0 + - 0 + - 0 + - 1 + - 0 + - 0 + - 4 + - 0 + - 0 + - 0 + - 0 + - 1 + - 0 + - 0 + - 12 + - 0 + - 0 + - 0 + - 0 + - 1 + - 0 + - 0 + - 4 + - 0 + display_value: '[{SampleCount=2 SampleOffsetV0=2048}, {SampleCount=1 SampleOffsetV0=5120}, {SampleCount=1 SampleOffsetV0=2048}, {SampleCount=1 SampleOffsetV0=0}, {SampleCount=1 SampleOffsetV0=1024}, {SampleCount=1 SampleOffsetV0=3072}, {SampleCount=1 SampleOffsetV0=1024}, {SampleCount=1 SampleOffsetV0=3072}, {SampleCount=1 SampleOffsetV0=1024}]' + payload_summary: 'Version=0 Flags=0x000000 EntryCount=9 Entries=[{SampleCount=2 SampleOffsetV0=2048}, {SampleCount=1 SampleOffsetV0=5120}, {SampleCount=1 SampleOffsetV0=2048}, {SampleCount=1 SampleOffsetV0=0}, {SampleCount=1 SampleOffsetV0=1024}, {SampleCount=1 SampleOffsetV0=3072}, {SampleCount=1 SampleOffsetV0=1024}, {SampleCount=1 SampleOffsetV0=3072}, {SampleCount=1 SampleOffsetV0=1024}]' + children: [] + - box_type: stsc + path: moov/trak/mdia/minf/stbl/stsc + offset: 7149 + size: 40 + supported: true + payload_status: summary + payload_fields: + - name: Version + value_kind: unsigned + value: 0 + - name: Flags + value_kind: unsigned + value: 0 + display_value: 0x000000 + - name: EntryCount + value_kind: unsigned + value: 2 + - name: Entries + value_kind: bytes + value: + - 0 + - 0 + - 0 + - 1 + - 0 + - 0 + - 0 + - 2 + - 0 + - 0 + - 0 + - 1 + - 0 + - 0 + - 0 + - 2 + - 0 + - 0 + - 0 + - 1 + - 0 + - 0 + - 0 + - 1 + display_value: '[{FirstChunk=1 SamplesPerChunk=2 SampleDescriptionIndex=1}, {FirstChunk=2 SamplesPerChunk=1 SampleDescriptionIndex=1}]' + payload_summary: 'Version=0 Flags=0x000000 EntryCount=2 Entries=[{FirstChunk=1 SamplesPerChunk=2 SampleDescriptionIndex=1}, {FirstChunk=2 SamplesPerChunk=1 SampleDescriptionIndex=1}]' + children: [] + - box_type: stsz + path: moov/trak/mdia/minf/stbl/stsz + offset: 7189 + size: 60 + supported: true + payload_status: summary + payload_fields: + - name: Version + value_kind: unsigned + value: 0 + - name: Flags + value_kind: unsigned + value: 0 + display_value: 0x000000 + - name: SampleSize + value_kind: unsigned + value: 0 + - name: SampleCount + value_kind: unsigned + value: 10 + - name: EntrySize + value_kind: unsigned_array + value: + - 3679 + - 86 + - 545 + - 180 + - 69 + - 60 + - 182 + - 22 + - 204 + - 15 + payload_summary: 'Version=0 Flags=0x000000 SampleSize=0 SampleCount=10 EntrySize=[3679, 86, 545, 180, 69, 60, 182, 22, 204, 15]' + children: [] + - box_type: stco + path: moov/trak/mdia/minf/stbl/stco + offset: 7249 + size: 52 + supported: true + payload_status: summary + payload_fields: + - name: Version + value_kind: unsigned + value: 0 + - name: Flags + value_kind: unsigned + value: 0 + display_value: 0x000000 + - name: EntryCount + value_kind: unsigned + value: 9 + - name: ChunkOffset + value_kind: unsigned_array + value: + - 48 + - 3836 + - 4527 + - 4864 + - 5043 + - 5227 + - 5560 + - 5702 + - 6038 + payload_summary: 'Version=0 Flags=0x000000 EntryCount=9 ChunkOffset=[48, 3836, 4527, 4864, 5043, 5227, 5560, 5702, 6038]' + children: [] + - box_type: trak + path: moov/trak + offset: 7301 + size: 844 + supported: true + payload_status: empty + payload_fields: [] + children: + - box_type: tkhd + path: moov/trak/tkhd + offset: 7309 + size: 92 + supported: true + payload_status: summary + payload_fields: + - name: Version + value_kind: unsigned + value: 0 + - name: Flags + value_kind: unsigned + value: 3 + display_value: 0x000003 + - name: CreationTimeV0 + value_kind: unsigned + value: 0 + - name: ModificationTimeV0 + value_kind: unsigned + value: 0 + - name: TrackID + value_kind: unsigned + value: 2 + - name: DurationV0 + value_kind: unsigned + value: 1024 + - name: Layer + value_kind: signed + value: 0 + - name: AlternateGroup + value_kind: signed + value: 1 + - name: Volume + value_kind: signed + value: 256 + - name: Matrix + value_kind: signed_array + value: + - 65536 + - 0 + - 0 + - 0 + - 65536 + - 0 + - 0 + - 0 + - 1073741824 + display_value: '[0x10000, 0x0, 0x0, 0x0, 0x10000, 0x0, 0x0, 0x0, 0x40000000]' + - name: Width + value_kind: unsigned + value: 0 + display_value: 0 + - name: Height + value_kind: unsigned + value: 0 + display_value: 0 + payload_summary: 'Version=0 Flags=0x000003 CreationTimeV0=0 ModificationTimeV0=0 TrackID=2 DurationV0=1024 Layer=0 AlternateGroup=1 Volume=256 Matrix=[0x10000, 0x0, 0x0, 0x0, 0x10000, 0x0, 0x0, 0x0, 0x40000000] Width=0 Height=0' + children: [] + - box_type: edts + path: moov/trak/edts + offset: 7401 + size: 36 + supported: true + payload_status: empty + payload_fields: [] + children: + - box_type: elst + path: moov/trak/edts/elst + offset: 7409 + size: 28 + supported: true + payload_status: summary + payload_fields: + - name: Version + value_kind: unsigned + value: 0 + - name: Flags + value_kind: unsigned + value: 0 + display_value: 0x000000 + - name: EntryCount + value_kind: unsigned + value: 1 + - name: Entries + value_kind: bytes + value: + - 0 + - 0 + - 3 + - 232 + - 0 + - 0 + - 4 + - 0 + - 0 + - 1 + - 0 + - 0 + display_value: '[{SegmentDurationV0=1000 MediaTimeV0=1024 MediaRateInteger=1}]' + payload_summary: 'Version=0 Flags=0x000000 EntryCount=1 Entries=[{SegmentDurationV0=1000 MediaTimeV0=1024 MediaRateInteger=1}]' + children: [] + - box_type: mdia + path: moov/trak/mdia + offset: 7437 + size: 708 + supported: true + payload_status: empty + payload_fields: [] + children: + - box_type: mdhd + path: moov/trak/mdia/mdhd + offset: 7445 + size: 32 + supported: true + payload_status: summary + payload_fields: + - name: Version + value_kind: unsigned + value: 0 + - name: Flags + value_kind: unsigned + value: 0 + display_value: 0x000000 + - name: CreationTimeV0 + value_kind: unsigned + value: 0 + - name: ModificationTimeV0 + value_kind: unsigned + value: 0 + - name: Timescale + value_kind: unsigned + value: 44100 + - name: DurationV0 + value_kind: unsigned + value: 45124 + - name: Language + value_kind: unsigned_array + value: + - 5 + - 14 + - 7 + display_value: '"eng"' + - name: PreDefined + value_kind: unsigned + value: 0 + payload_summary: 'Version=0 Flags=0x000000 CreationTimeV0=0 ModificationTimeV0=0 Timescale=44100 DurationV0=45124 Language="eng" PreDefined=0' + children: [] + - box_type: hdlr + path: moov/trak/mdia/hdlr + offset: 7477 + size: 44 + supported: true + payload_status: summary + payload_fields: + - name: Version + value_kind: unsigned + value: 0 + - name: Flags + value_kind: unsigned + value: 0 + display_value: 0x000000 + - name: PreDefined + value_kind: unsigned + value: 0 + - name: HandlerType + value_kind: bytes + value: + - 115 + - 111 + - 117 + - 110 + display_value: '"soun"' + - name: Name + value_kind: string + value: SoundHandle + display_value: '"SoundHandle"' + payload_summary: 'Version=0 Flags=0x000000 PreDefined=0 HandlerType="soun" Name="SoundHandle"' + children: [] + - box_type: minf + path: moov/trak/mdia/minf + offset: 7521 + size: 624 + supported: true + payload_status: empty + payload_fields: [] + children: + - box_type: smhd + path: moov/trak/mdia/minf/smhd + offset: 7529 + size: 16 + supported: true + payload_status: summary + payload_fields: + - name: Version + value_kind: unsigned + value: 0 + - name: Flags + value_kind: unsigned + value: 0 + display_value: 0x000000 + - name: Balance + value_kind: signed + value: 0 + display_value: 0 + payload_summary: 'Version=0 Flags=0x000000 Balance=0' + children: [] + - box_type: dinf + path: moov/trak/mdia/minf/dinf + offset: 7545 + size: 36 + supported: true + payload_status: empty + payload_fields: [] + children: + - box_type: dref + path: moov/trak/mdia/minf/dinf/dref + offset: 7553 + size: 28 + supported: true + payload_status: summary + payload_fields: + - name: Version + value_kind: unsigned + value: 0 + - name: Flags + value_kind: unsigned + value: 0 + display_value: 0x000000 + - name: EntryCount + value_kind: unsigned + value: 1 + payload_summary: 'Version=0 Flags=0x000000 EntryCount=1' + children: + - box_type: 'url ' + path: 'moov/trak/mdia/minf/dinf/dref/url ' + offset: 7569 + size: 12 + supported: true + payload_status: summary + payload_fields: + - name: Version + value_kind: unsigned + value: 0 + - name: Flags + value_kind: unsigned + value: 1 + display_value: 0x000001 + payload_summary: 'Version=0 Flags=0x000001' + children: [] + - box_type: stbl + path: moov/trak/mdia/minf/stbl + offset: 7581 + size: 564 + supported: true + payload_status: empty + payload_fields: [] + children: + - box_type: stsd + path: moov/trak/mdia/minf/stbl/stsd + offset: 7589 + size: 106 + supported: true + payload_status: summary + payload_fields: + - name: Version + value_kind: unsigned + value: 0 + - name: Flags + value_kind: unsigned + value: 0 + display_value: 0x000000 + - name: EntryCount + value_kind: unsigned + value: 1 + payload_summary: 'Version=0 Flags=0x000000 EntryCount=1' + children: + - box_type: mp4a + path: moov/trak/mdia/minf/stbl/stsd/mp4a + offset: 7605 + size: 90 + supported: true + payload_status: summary + payload_fields: + - name: DataReferenceIndex + value_kind: unsigned + value: 1 + - name: EntryVersion + value_kind: unsigned + value: 0 + - name: ChannelCount + value_kind: unsigned + value: 2 + - name: SampleSize + value_kind: unsigned + value: 16 + - name: PreDefined + value_kind: unsigned + value: 0 + - name: SampleRate + value_kind: unsigned + value: 2890137600 + display_value: 44100 + payload_summary: 'DataReferenceIndex=1 EntryVersion=0 ChannelCount=2 SampleSize=16 PreDefined=0 SampleRate=44100' + children: + - box_type: esds + path: moov/trak/mdia/minf/stbl/stsd/mp4a/esds + offset: 7641 + size: 54 + supported: true + payload_status: summary + payload_fields: + - name: Version + value_kind: unsigned + value: 0 + - name: Flags + value_kind: unsigned + value: 0 + display_value: 0x000000 + - name: Descriptors + value_kind: bytes + value: + - 3 + - 128 + - 128 + - 128 + - 37 + - 0 + - 2 + - 0 + - 4 + - 128 + - 128 + - 128 + - 23 + - 64 + - 21 + - 0 + - 0 + - 0 + - 0 + - 0 + - 41 + - 74 + - 0 + - 0 + - 41 + - 74 + - 5 + - 128 + - 128 + - 128 + - 5 + - 18 + - 16 + - 86 + - 229 + - 0 + - 6 + - 128 + - 128 + - 128 + - 1 + - 2 + display_value: '[{Tag=ESDescr Size=37 ESID=2 StreamDependenceFlag=false UrlFlag=false OcrStreamFlag=false StreamPriority=0}, {Tag=DecoderConfigDescr Size=23 ObjectTypeIndication=0x40 StreamType=5 UpStream=false Reserved=true BufferSizeDB=0 MaxBitrate=10570 AvgBitrate=10570}, {Tag=DecSpecificInfo Size=5 Data=[0x12, 0x10, 0x56, 0xe5, 0x0]}, {Tag=SLConfigDescr Size=1 Data=[0x2]}]' + payload_summary: 'Version=0 Flags=0x000000 Descriptors=[{Tag=ESDescr Size=37 ESID=2 StreamDependenceFlag=false UrlFlag=false OcrStreamFlag=false StreamPriority=0}, {Tag=DecoderConfigDescr Size=23 ObjectTypeIndication=0x40 StreamType=5 UpStream=false Reserved=true BufferSizeDB=0 MaxBitrate=10570 AvgBitrate=10570}, {Tag=DecSpecificInfo Size=5 Data=[0x12, 0x10, 0x56, 0xe5, 0x0]}, {Tag=SLConfigDescr Size=1 Data=[0x2]}]' + children: [] + - box_type: stts + path: moov/trak/mdia/minf/stbl/stts + offset: 7695 + size: 48 + supported: true + payload_status: summary + payload_fields: + - name: Version + value_kind: unsigned + value: 0 + - name: Flags + value_kind: unsigned + value: 0 + display_value: 0x000000 + - name: EntryCount + value_kind: unsigned + value: 4 + - name: Entries + value_kind: bytes + value: + - 0 + - 0 + - 0 + - 1 + - 0 + - 0 + - 4 + - 0 + - 0 + - 0 + - 0 + - 1 + - 0 + - 0 + - 5 + - 225 + - 0 + - 0 + - 0 + - 41 + - 0 + - 0 + - 4 + - 0 + - 0 + - 0 + - 0 + - 1 + - 0 + - 0 + - 2 + - 99 + display_value: '[{SampleCount=1 SampleDelta=1024}, {SampleCount=1 SampleDelta=1505}, {SampleCount=41 SampleDelta=1024}, {SampleCount=1 SampleDelta=611}]' + payload_summary: 'Version=0 Flags=0x000000 EntryCount=4 Entries=[{SampleCount=1 SampleDelta=1024}, {SampleCount=1 SampleDelta=1505}, {SampleCount=41 SampleDelta=1024}, {SampleCount=1 SampleDelta=611}]' + children: [] + - box_type: stsc + path: moov/trak/mdia/minf/stbl/stsc + offset: 7743 + size: 100 + supported: true + payload_status: summary + payload_fields: + - name: Version + value_kind: unsigned + value: 0 + - name: Flags + value_kind: unsigned + value: 0 + display_value: 0x000000 + - name: EntryCount + value_kind: unsigned + value: 7 + - name: Entries + value_kind: bytes + value: + - 0 + - 0 + - 0 + - 1 + - 0 + - 0 + - 0 + - 1 + - 0 + - 0 + - 0 + - 1 + - 0 + - 0 + - 0 + - 2 + - 0 + - 0 + - 0 + - 4 + - 0 + - 0 + - 0 + - 1 + - 0 + - 0 + - 0 + - 3 + - 0 + - 0 + - 0 + - 5 + - 0 + - 0 + - 0 + - 1 + - 0 + - 0 + - 0 + - 4 + - 0 + - 0 + - 0 + - 4 + - 0 + - 0 + - 0 + - 1 + - 0 + - 0 + - 0 + - 6 + - 0 + - 0 + - 0 + - 5 + - 0 + - 0 + - 0 + - 1 + - 0 + - 0 + - 0 + - 7 + - 0 + - 0 + - 0 + - 4 + - 0 + - 0 + - 0 + - 1 + - 0 + - 0 + - 0 + - 9 + - 0 + - 0 + - 0 + - 13 + - 0 + - 0 + - 0 + - 1 + display_value: '[{FirstChunk=1 SamplesPerChunk=1 SampleDescriptionIndex=1}, {FirstChunk=2 SamplesPerChunk=4 SampleDescriptionIndex=1}, {FirstChunk=3 SamplesPerChunk=5 SampleDescriptionIndex=1}, {FirstChunk=4 SamplesPerChunk=4 SampleDescriptionIndex=1}, {FirstChunk=6 SamplesPerChunk=5 SampleDescriptionIndex=1}, {FirstChunk=7 SamplesPerChunk=4 SampleDescriptionIndex=1}, {FirstChunk=9 SamplesPerChunk=13 SampleDescriptionIndex=1}]' + payload_summary: 'Version=0 Flags=0x000000 EntryCount=7 Entries=[{FirstChunk=1 SamplesPerChunk=1 SampleDescriptionIndex=1}, {FirstChunk=2 SamplesPerChunk=4 SampleDescriptionIndex=1}, {FirstChunk=3 SamplesPerChunk=5 SampleDescriptionIndex=1}, {FirstChunk=4 SamplesPerChunk=4 SampleDescriptionIndex=1}, {FirstChunk=6 SamplesPerChunk=5 SampleDescriptionIndex=1}, {FirstChunk=7 SamplesPerChunk=4 SampleDescriptionIndex=1}, {FirstChunk=9 SamplesPerChunk=13 SampleDescriptionIndex=1}]' + children: [] + - box_type: stsz + path: moov/trak/mdia/minf/stbl/stsz + offset: 7843 + size: 196 + supported: true + payload_status: summary + payload_fields: + - name: Version + value_kind: unsigned + value: 0 + - name: Flags + value_kind: unsigned + value: 0 + display_value: 0x000000 + - name: SampleSize + value_kind: unsigned + value: 0 + - name: SampleCount + value_kind: unsigned + value: 44 + - name: EntrySize + value_kind: unsigned_array + value: + - 23 + - 39 + - 35 + - 36 + - 36 + - 35 + - 31 + - 33 + - 28 + - 30 + - 33 + - 29 + - 23 + - 25 + - 25 + - 36 + - 27 + - 36 + - 35 + - 25 + - 28 + - 36 + - 27 + - 29 + - 28 + - 29 + - 34 + - 36 + - 30 + - 32 + - 34 + - 29 + - 24 + - 34 + - 35 + - 28 + - 24 + - 30 + - 29 + - 29 + - 27 + - 31 + - 34 + - 35 + payload_summary: 'Version=0 Flags=0x000000 SampleSize=0 SampleCount=44 EntrySize=[23, 39, 35, 36, 36, 35, 31, 33, 28, 30, 33, 29, 23, 25, 25, 36, 27, 36, 35, 25, 28, 36, 27, 29, 28, 29, 34, 36, 30, 32, 34, 29, 24, 34, 35, 28, 24, 30, 29, 29, 27, 31, 34, 35]' + children: [] + - box_type: stco + path: moov/trak/mdia/minf/stbl/stco + offset: 8039 + size: 52 + supported: true + payload_status: summary + payload_fields: + - name: Version + value_kind: unsigned + value: 0 + - name: Flags + value_kind: unsigned + value: 0 + display_value: 0x000000 + - name: EntryCount + value_kind: unsigned + value: 9 + - name: ChunkOffset + value_kind: unsigned_array + value: + - 3813 + - 4381 + - 4707 + - 4933 + - 5103 + - 5409 + - 5582 + - 5906 + - 6053 + payload_summary: 'Version=0 Flags=0x000000 EntryCount=9 ChunkOffset=[3813, 4381, 4707, 4933, 5103, 5409, 5582, 5906, 6053]' + children: [] + - box_type: sgpd + path: moov/trak/mdia/minf/stbl/sgpd + offset: 8091 + size: 26 + supported: true + payload_status: summary + payload_fields: + - name: Version + value_kind: unsigned + value: 1 + - name: Flags + value_kind: unsigned + value: 0 + display_value: 0x000000 + - name: GroupingType + value_kind: bytes + value: + - 114 + - 111 + - 108 + - 108 + display_value: '"roll"' + - name: DefaultLength + value_kind: unsigned + value: 2 + - name: EntryCount + value_kind: unsigned + value: 1 + - name: RollDistances + value_kind: bytes + value: + - 255 + - 255 + display_value: '[-1]' + payload_summary: 'Version=1 Flags=0x000000 GroupingType="roll" DefaultLength=2 EntryCount=1 RollDistances=[-1]' + children: [] + - box_type: sbgp + path: moov/trak/mdia/minf/stbl/sbgp + offset: 8117 + size: 28 + supported: true + payload_status: summary + payload_fields: + - name: Version + value_kind: unsigned + value: 0 + - name: Flags + value_kind: unsigned + value: 0 + display_value: 0x000000 + - name: GroupingType + value_kind: unsigned + value: 1919904876 + - name: EntryCount + value_kind: unsigned + value: 1 + - name: Entries + value_kind: bytes + value: + - 0 + - 0 + - 0 + - 44 + - 0 + - 0 + - 0 + - 1 + display_value: '[{SampleCount=44 GroupDescriptionIndex=1}]' + payload_summary: 'Version=0 Flags=0x000000 GroupingType=1919904876 EntryCount=1 Entries=[{SampleCount=44 GroupDescriptionIndex=1}]' + children: [] + - box_type: udta + path: moov/udta + offset: 8145 + size: 133 + supported: true + payload_status: empty + payload_fields: [] + children: + - box_type: meta + path: moov/udta/meta + offset: 8153 + size: 90 + supported: true + payload_status: summary + payload_fields: + - name: Version + value_kind: unsigned + value: 0 + - name: Flags + value_kind: unsigned + value: 0 + display_value: 0x000000 + payload_summary: 'Version=0 Flags=0x000000' + children: + - box_type: hdlr + path: moov/udta/meta/hdlr + offset: 8165 + size: 33 + supported: true + payload_status: summary + payload_fields: + - name: Version + value_kind: unsigned + value: 0 + - name: Flags + value_kind: unsigned + value: 0 + display_value: 0x000000 + - name: PreDefined + value_kind: unsigned + value: 0 + - name: HandlerType + value_kind: bytes + value: + - 109 + - 100 + - 105 + - 114 + display_value: '"mdir"' + - name: Name + value_kind: string + value: '' + display_value: '""' + payload_summary: 'Version=0 Flags=0x000000 PreDefined=0 HandlerType="mdir" Name=""' + children: [] + - box_type: ilst + path: moov/udta/meta/ilst + offset: 8198 + size: 45 + supported: true + payload_status: empty + payload_fields: [] + children: + - box_type: '(c)too' + path: 'moov/udta/meta/ilst/(c)too' + offset: 8206 + size: 37 + supported: true + payload_status: empty + payload_fields: [] + children: + - box_type: data + path: 'moov/udta/meta/ilst/(c)too/data' + offset: 8214 + size: 29 + supported: true + payload_status: summary + payload_fields: + - name: DataType + value_kind: unsigned + value: 1 + display_value: UTF8 + - name: DataLang + value_kind: unsigned + value: 0 + - name: EncodingTool + value_kind: bytes + value: + - 76 + - 97 + - 118 + - 102 + - 53 + - 56 + - 46 + - 50 + - 57 + - 46 + - 49 + - 48 + - 48 + display_value: '"Lavf58.29.100"' + payload_summary: 'DataType=UTF8 DataLang=0 EncodingTool="Lavf58.29.100"' + children: [] + - box_type: loci + path: moov/udta/loci + offset: 8243 + size: 35 + supported: false + payload_status: omitted + payload_fields: [] + children: [] diff --git a/tests/golden/cli_probe/sample.json b/tests/golden/cli_probe/sample.json index e237386..637a1e0 100644 --- a/tests/golden/cli_probe/sample.json +++ b/tests/golden/cli_probe/sample.json @@ -18,14 +18,32 @@ "Duration": 10240, "DurationSeconds": 1, "Codec": "avc1.64000C", + "CodecFamily": "avc", "Encrypted": false, + "HandlerType": "vide", + "Language": "eng", + "SampleEntryType": "avc1", "Width": 320, "Height": 180, "SampleNum": 10, "ChunkNum": 9, "IDRFrameNum": 1, "Bitrate": 40336, - "MaxBitrate": 40336 + "MaxBitrate": 40336, + "CodecDetails": { + "Kind": "avc", + "ConfigurationVersion": 1, + "Profile": 100, + "ProfileCompatibility": 0, + "Level": 12, + "LengthSize": 4 + }, + "MediaCharacteristics": { + "PixelAspectRatio": { + "HSpacing": 1, + "VSpacing": 1 + } + } }, { "TrackID": 2, @@ -33,11 +51,24 @@ "Duration": 45124, "DurationSeconds": 1.02322, "Codec": "mp4a.40.2", + "CodecFamily": "mp4_audio", "Encrypted": false, + "HandlerType": "soun", + "Language": "eng", + "SampleEntryType": "mp4a", + "ChannelCount": 2, + "SampleRate": 44100, "SampleNum": 44, "ChunkNum": 9, "Bitrate": 10570, - "MaxBitrate": 10632 + "MaxBitrate": 10632, + "CodecDetails": { + "Kind": "mp4_audio", + "ObjectTypeIndication": 64, + "AudioObjectType": 2, + "ChannelCount": 2, + "SampleRate": 44100 + } } ] } diff --git a/tests/golden/cli_probe/sample.yaml b/tests/golden/cli_probe/sample.yaml index 4ab5581..7050bb8 100644 --- a/tests/golden/cli_probe/sample.yaml +++ b/tests/golden/cli_probe/sample.yaml @@ -15,7 +15,11 @@ tracks: duration: 10240 duration_seconds: 1 codec: avc1.64000C + codec_family: avc encrypted: false + handler_type: vide + language: eng + sample_entry_type: avc1 width: 320 height: 180 sample_num: 10 @@ -23,13 +27,36 @@ tracks: idr_frame_num: 1 bitrate: 40336 max_bitrate: 40336 + codec_details: + kind: avc + configuration_version: 1 + profile: 100 + profile_compatibility: 0 + level: 12 + length_size: 4 + media_characteristics: + pixel_aspect_ratio: + h_spacing: 1 + v_spacing: 1 - track_id: 2 timescale: 44100 duration: 45124 duration_seconds: 1.02322 codec: mp4a.40.2 + codec_family: mp4_audio encrypted: false + handler_type: soun + language: eng + sample_entry_type: mp4a + channel_count: 2 + sample_rate: 44100 sample_num: 44 chunk_num: 9 bitrate: 10570 max_bitrate: 10632 + codec_details: + kind: mp4_audio + object_type_indication: 64 + audio_object_type: 2 + channel_count: 2 + sample_rate: 44100 diff --git a/tests/golden/cli_probe/sample_light.json b/tests/golden/cli_probe/sample_light.json new file mode 100644 index 0000000..71eb789 --- /dev/null +++ b/tests/golden/cli_probe/sample_light.json @@ -0,0 +1,65 @@ +{ + "MajorBrand": "isom", + "MinorVersion": 512, + "CompatibleBrands": [ + "isom", + "iso2", + "avc1", + "mp41" + ], + "FastStart": false, + "Timescale": 1000, + "Duration": 1024, + "DurationSeconds": 1.024, + "Tracks": [ + { + "TrackID": 1, + "Timescale": 10240, + "Duration": 10240, + "DurationSeconds": 1, + "Codec": "avc1.64000C", + "CodecFamily": "avc", + "Encrypted": false, + "HandlerType": "vide", + "Language": "eng", + "SampleEntryType": "avc1", + "Width": 320, + "Height": 180, + "CodecDetails": { + "Kind": "avc", + "ConfigurationVersion": 1, + "Profile": 100, + "ProfileCompatibility": 0, + "Level": 12, + "LengthSize": 4 + }, + "MediaCharacteristics": { + "PixelAspectRatio": { + "HSpacing": 1, + "VSpacing": 1 + } + } + }, + { + "TrackID": 2, + "Timescale": 44100, + "Duration": 45124, + "DurationSeconds": 1.02322, + "Codec": "mp4a.40.2", + "CodecFamily": "mp4_audio", + "Encrypted": false, + "HandlerType": "soun", + "Language": "eng", + "SampleEntryType": "mp4a", + "ChannelCount": 2, + "SampleRate": 44100, + "CodecDetails": { + "Kind": "mp4_audio", + "ObjectTypeIndication": 64, + "AudioObjectType": 2, + "ChannelCount": 2, + "SampleRate": 44100 + } + } + ] +} diff --git a/tests/golden/cli_probe/sample_light.yaml b/tests/golden/cli_probe/sample_light.yaml new file mode 100644 index 0000000..096dda3 --- /dev/null +++ b/tests/golden/cli_probe/sample_light.yaml @@ -0,0 +1,53 @@ +major_brand: isom +minor_version: 512 +compatible_brands: +- isom +- iso2 +- avc1 +- mp41 +fast_start: false +timescale: 1000 +duration: 1024 +duration_seconds: 1.024 +tracks: +- track_id: 1 + timescale: 10240 + duration: 10240 + duration_seconds: 1 + codec: avc1.64000C + codec_family: avc + encrypted: false + handler_type: vide + language: eng + sample_entry_type: avc1 + width: 320 + height: 180 + codec_details: + kind: avc + configuration_version: 1 + profile: 100 + profile_compatibility: 0 + level: 12 + length_size: 4 + media_characteristics: + pixel_aspect_ratio: + h_spacing: 1 + v_spacing: 1 +- track_id: 2 + timescale: 44100 + duration: 45124 + duration_seconds: 1.02322 + codec: mp4a.40.2 + codec_family: mp4_audio + encrypted: false + handler_type: soun + language: eng + sample_entry_type: mp4a + channel_count: 2 + sample_rate: 44100 + codec_details: + kind: mp4_audio + object_type_indication: 64 + audio_object_type: 2 + channel_count: 2 + sample_rate: 44100 diff --git a/tests/golden/cli_psshdump/filtered_kid.yaml b/tests/golden/cli_psshdump/filtered_kid.yaml new file mode 100644 index 0000000..34f8245 --- /dev/null +++ b/tests/golden/cli_psshdump/filtered_kid.yaml @@ -0,0 +1,16 @@ +entries: +- index: 1 + path: moof/pssh + offset: 89 + size: 54 + version: 1 + flags: 0 + system_id: edef8ba9-79d6-4ace-a3c8-27dcd51d21ed + kid_count: 1 + kids: + - fedcba98-7654-3210-fedc-ba9876543210 + data_size: 2 + data_bytes: + - 187 + - 204 + raw_box_base64: 'AAAANnBzc2gBAAAA7e+LqXnWSs6jyCfc1R0h7QAAAAH+3LqYdlQyEP7cuph2VDIQAAAAArvM' diff --git a/tests/golden/cli_psshdump/filtered_path.json b/tests/golden/cli_psshdump/filtered_path.json new file mode 100644 index 0000000..8cab3b7 --- /dev/null +++ b/tests/golden/cli_psshdump/filtered_path.json @@ -0,0 +1,23 @@ +{ + "Entries": [ + { + "Index": 1, + "Path": "moof/pssh", + "Offset": 89, + "Size": 54, + "Version": 1, + "Flags": 0, + "SystemId": "edef8ba9-79d6-4ace-a3c8-27dcd51d21ed", + "KidCount": 1, + "Kids": [ + "fedcba98-7654-3210-fedc-ba9876543210" + ], + "DataSize": 2, + "DataBytes": [ + 187, + 204 + ], + "RawBoxBase64": "AAAANnBzc2gBAAAA7e+LqXnWSs6jyCfc1R0h7QAAAAH+3LqYdlQyEP7cuph2VDIQAAAAArvM" + } + ] +} diff --git a/tests/golden/cli_psshdump/filtered_system_id.json b/tests/golden/cli_psshdump/filtered_system_id.json new file mode 100644 index 0000000..8cab3b7 --- /dev/null +++ b/tests/golden/cli_psshdump/filtered_system_id.json @@ -0,0 +1,23 @@ +{ + "Entries": [ + { + "Index": 1, + "Path": "moof/pssh", + "Offset": 89, + "Size": 54, + "Version": 1, + "Flags": 0, + "SystemId": "edef8ba9-79d6-4ace-a3c8-27dcd51d21ed", + "KidCount": 1, + "Kids": [ + "fedcba98-7654-3210-fedc-ba9876543210" + ], + "DataSize": 2, + "DataBytes": [ + 187, + 204 + ], + "RawBoxBase64": "AAAANnBzc2gBAAAA7e+LqXnWSs6jyCfc1R0h7QAAAAH+3LqYdlQyEP7cuph2VDIQAAAAArvM" + } + ] +} diff --git a/tests/golden/cli_psshdump/sample_init.json b/tests/golden/cli_psshdump/sample_init.json new file mode 100644 index 0000000..84b1c53 --- /dev/null +++ b/tests/golden/cli_psshdump/sample_init.json @@ -0,0 +1,21 @@ +{ + "Entries": [ + { + "Index": 0, + "Path": "moov/pssh", + "Offset": 1307, + "Size": 52, + "Version": 1, + "Flags": 0, + "SystemId": "1077efec-c0b2-4d02-ace3-3c1e52e2fb4b", + "KidCount": 1, + "Kids": [ + "01234567-89ab-cdef-0123-456789abcdef" + ], + "DataSize": 0, + "DataBytes": [ + ], + "RawBoxBase64": "AAAANHBzc2gBAAAAEHfv7MCyTQKs4zweUuL7SwAAAAEBI0VniavN7wEjRWeJq83vAAAAAA==" + } + ] +} diff --git a/tests/golden/cli_psshdump/sample_init.yaml b/tests/golden/cli_psshdump/sample_init.yaml new file mode 100644 index 0000000..8163668 --- /dev/null +++ b/tests/golden/cli_psshdump/sample_init.yaml @@ -0,0 +1,14 @@ +entries: +- index: 0 + path: moov/pssh + offset: 1307 + size: 52 + version: 1 + flags: 0 + system_id: 1077efec-c0b2-4d02-ace3-3c1e52e2fb4b + kid_count: 1 + kids: + - 01234567-89ab-cdef-0123-456789abcdef + data_size: 0 + data_bytes: [] + raw_box_base64: 'AAAANHBzc2gBAAAAEHfv7MCyTQKs4zweUuL7SwAAAAEBI0VniavN7wEjRWeJq83vAAAAAA==' diff --git a/tests/parity_harness.rs b/tests/parity_harness.rs index fa3b6ec..fcd91b6 100644 --- a/tests/parity_harness.rs +++ b/tests/parity_harness.rs @@ -9,8 +9,8 @@ use mp4forge::FourCc; use mp4forge::cli::{divide, edit, extract, probe as cli_probe, pssh}; use mp4forge::extract::extract_box; use mp4forge::probe::{ - ProbeError, TrackCodec, average_sample_bitrate, average_segment_bitrate, find_idr_frames, - max_sample_bitrate, max_segment_bitrate, probe, + ProbeError, ProbeOptions, TrackCodec, average_sample_bitrate, average_segment_bitrate, + find_idr_frames, max_sample_bitrate, max_segment_bitrate, probe, probe_with_options, }; use mp4forge::walk::BoxPath; @@ -301,6 +301,61 @@ fn probe_report_matches_library_summary_across_shared_fixtures() { } } +#[test] +fn lightweight_probe_report_matches_library_summary_across_representative_fixtures() { + for file_name in ["sample.mp4", "sample_fragmented.mp4", "sample_qt.mp4"] { + let path = fixture_path(file_name); + + let mut summary_file = fs::File::open(&path).unwrap(); + let summary = probe_with_options(&mut summary_file, ProbeOptions::lightweight()).unwrap(); + + let mut report_file = fs::File::open(&path).unwrap(); + let report = cli_probe::build_report_with_options( + &mut report_file, + cli_probe::ProbeReportOptions::lightweight(), + ) + .unwrap(); + + assert_eq!(report.major_brand, summary.major_brand.to_string()); + assert_eq!( + report.compatible_brands, + summary + .compatible_brands + .iter() + .map(ToString::to_string) + .collect::>() + ); + assert_eq!(report.fast_start, summary.fast_start); + assert_eq!(report.timescale, summary.timescale); + assert_eq!(report.duration, summary.duration); + assert!(summary.segments.is_empty()); + assert_eq!(report.tracks.len(), summary.tracks.len()); + + for (summary_track, report_track) in summary.tracks.iter().zip(report.tracks.iter()) { + assert!(summary_track.samples.is_empty()); + assert!(summary_track.chunks.is_empty()); + + assert_eq!(report_track.track_id, summary_track.track_id); + assert_eq!(report_track.timescale, summary_track.timescale); + assert_eq!(report_track.duration, summary_track.duration); + assert_eq!(report_track.encrypted, summary_track.encrypted); + assert_eq!( + report_track.width, + summary_track.avc.as_ref().map(|avc| avc.width) + ); + assert_eq!( + report_track.height, + summary_track.avc.as_ref().map(|avc| avc.height) + ); + assert_eq!(report_track.sample_num, None); + assert_eq!(report_track.chunk_num, None); + assert_eq!(report_track.idr_frame_num, None); + assert_eq!(report_track.bitrate, None); + assert_eq!(report_track.max_bitrate, None); + } + } +} + #[test] fn extract_command_matches_library_box_boundaries_on_shared_fixtures() { let cases = [ diff --git a/tests/probe.rs b/tests/probe.rs index 2843ea1..8f96fda 100644 --- a/tests/probe.rs +++ b/tests/probe.rs @@ -3,22 +3,37 @@ use std::io::Cursor; use mp4forge::boxes::AnyTypeBox; +use mp4forge::boxes::av1::AV1CodecConfiguration; +use mp4forge::boxes::etsi_ts_102_366::Dac3; use mp4forge::boxes::iso14496_12::{ - AVCDecoderConfiguration, AudioSampleEntry, Ctts, CttsEntry, Edts, Elst, ElstEntry, Ftyp, Mdhd, - Mdia, Minf, Moof, Moov, Mvhd, SampleEntry, Stbl, Stco, Stsc, StscEntry, Stsd, Stsz, Stts, - SttsEntry, TFHD_DEFAULT_SAMPLE_DURATION_PRESENT, TFHD_DEFAULT_SAMPLE_SIZE_PRESENT, + AVCDecoderConfiguration, AudioSampleEntry, Btrt, Colr, Ctts, CttsEntry, Edts, Elst, ElstEntry, + Fiel, Frma, Ftyp, HEVCDecoderConfiguration, Hdlr, Mdhd, Mdia, Minf, Moof, Moov, Mvhd, Pasp, + SampleEntry, Schm, Sinf, Stbl, Stco, Stsc, StscEntry, Stsd, Stsz, Stts, SttsEntry, + TFHD_DEFAULT_SAMPLE_DURATION_PRESENT, TFHD_DEFAULT_SAMPLE_SIZE_PRESENT, TRUN_SAMPLE_COMPOSITION_TIME_OFFSET_PRESENT, TRUN_SAMPLE_DURATION_PRESENT, - TRUN_SAMPLE_SIZE_PRESENT, Tfdt, Tfhd, Tkhd, Traf, Trak, Trun, TrunEntry, VisualSampleEntry, + TRUN_SAMPLE_SIZE_PRESENT, TextSubtitleSampleEntry, Tfdt, Tfhd, Tkhd, Traf, Trak, Trun, + TrunEntry, VisualSampleEntry, XMLSubtitleSampleEntry, }; use mp4forge::boxes::iso14496_14::{ DECODER_CONFIG_DESCRIPTOR_TAG, DECODER_SPECIFIC_INFO_TAG, DecoderConfigDescriptor, Descriptor, Esds, }; +use mp4forge::boxes::iso14496_30::{WVTTSampleEntry, WebVTTConfigurationBox, WebVTTSourceLabelBox}; +use mp4forge::boxes::iso23001_5::PcmC; +use mp4forge::boxes::opus::DOps; +use mp4forge::boxes::vp::VpCodecConfiguration; use mp4forge::codec::{CodecBox, MutableBox, marshal}; use mp4forge::probe::{ - AacProfileInfo, EditListEntry, TrackCodec, average_sample_bitrate, average_segment_bitrate, - detect_aac_profile, find_idr_frames, max_sample_bitrate, max_segment_bitrate, probe, - probe_bytes, probe_fra, probe_fra_bytes, + AacProfileInfo, EditListEntry, ProbeOptions, TrackCodec, TrackCodecDetails, TrackCodecFamily, + average_sample_bitrate, average_segment_bitrate, detect_aac_profile, find_idr_frames, + max_sample_bitrate, max_segment_bitrate, probe, probe_bytes, probe_bytes_with_options, + probe_codec_detailed, probe_codec_detailed_bytes, probe_codec_detailed_bytes_with_options, + probe_codec_detailed_with_options, probe_detailed, probe_detailed_bytes, + probe_detailed_bytes_with_options, probe_detailed_with_options, probe_fra, probe_fra_bytes, + probe_fra_codec_detailed, probe_fra_codec_detailed_bytes, probe_fra_detailed, + probe_fra_detailed_bytes, probe_media_characteristics, probe_media_characteristics_bytes, + probe_media_characteristics_bytes_with_options, probe_media_characteristics_with_options, + probe_with_options, }; use mp4forge::{BoxInfo, FourCc}; @@ -128,6 +143,156 @@ fn probe_bytes_matches_cursor_based_probe() { assert_eq!(actual, expected); } +#[test] +fn probe_with_options_skips_expensive_expansions_but_preserves_core_summary() { + let file = build_movie_file(); + let mut reader = Cursor::new(file.clone()); + + let full = probe(&mut Cursor::new(file)).unwrap(); + let info = probe_with_options(&mut reader, ProbeOptions::lightweight()).unwrap(); + + assert_eq!(info.major_brand, full.major_brand); + assert_eq!(info.minor_version, full.minor_version); + assert_eq!(info.compatible_brands, full.compatible_brands); + assert_eq!(info.fast_start, full.fast_start); + assert_eq!(info.timescale, full.timescale); + assert_eq!(info.duration, full.duration); + assert_eq!(info.segments, Vec::new()); + assert_eq!(info.tracks.len(), full.tracks.len()); + + for (light_track, full_track) in info.tracks.iter().zip(full.tracks.iter()) { + assert_eq!(light_track.track_id, full_track.track_id); + assert_eq!(light_track.timescale, full_track.timescale); + assert_eq!(light_track.duration, full_track.duration); + assert_eq!(light_track.codec, full_track.codec); + assert_eq!(light_track.encrypted, full_track.encrypted); + assert_eq!(light_track.edit_list, full_track.edit_list); + assert_eq!(light_track.avc, full_track.avc); + assert_eq!(light_track.mp4a, full_track.mp4a); + assert!(light_track.samples.is_empty()); + assert!(light_track.chunks.is_empty()); + } +} + +#[test] +fn probe_with_options_bytes_matches_cursor_based_probe() { + let file = build_movie_file(); + let options = ProbeOptions::lightweight(); + let expected = probe_with_options(&mut Cursor::new(file.clone()), options).unwrap(); + let actual = probe_bytes_with_options(&file, options).unwrap(); + assert_eq!(actual, expected); +} + +#[test] +fn probe_detailed_exposes_handler_language_sample_entry_and_codec_family() { + let file = build_movie_file(); + let mut reader = Cursor::new(file); + + let info = probe_detailed(&mut reader).unwrap(); + + assert_eq!(info.tracks.len(), 2); + + let video = &info.tracks[0]; + assert_eq!(video.summary.track_id, 1); + assert_eq!(video.codec_family, TrackCodecFamily::Avc); + assert_eq!(video.handler_type, Some(fourcc("vide"))); + assert_eq!(video.language.as_deref(), Some("eng")); + assert_eq!(video.sample_entry_type, Some(fourcc("avc1"))); + assert_eq!(video.original_format, None); + assert_eq!(video.display_width, Some(320)); + assert_eq!(video.display_height, Some(180)); + assert_eq!(video.channel_count, None); + assert_eq!(video.sample_rate, None); + + let audio = &info.tracks[1]; + assert_eq!(audio.summary.track_id, 2); + assert_eq!(audio.codec_family, TrackCodecFamily::Mp4Audio); + assert_eq!(audio.handler_type, Some(fourcc("soun"))); + assert_eq!(audio.language.as_deref(), Some("eng")); + assert_eq!(audio.sample_entry_type, Some(fourcc("mp4a"))); + assert_eq!(audio.original_format, None); + assert_eq!(audio.display_width, None); + assert_eq!(audio.display_height, None); + assert_eq!(audio.channel_count, Some(2)); + assert_eq!(audio.sample_rate, Some(48_000)); +} + +#[test] +fn probe_detailed_bytes_matches_cursor_based_probe_detailed() { + let file = build_movie_file(); + let expected = probe_detailed(&mut Cursor::new(file.clone())).unwrap(); + let actual = probe_detailed_bytes(&file).unwrap(); + assert_eq!(actual, expected); +} + +#[test] +fn probe_detailed_with_options_preserves_metadata_without_sample_tables() { + let file = build_av01_movie_file(); + let mut reader = Cursor::new(file.clone()); + + let full = probe_detailed(&mut Cursor::new(file)).unwrap(); + let info = probe_detailed_with_options(&mut reader, ProbeOptions::lightweight()).unwrap(); + + assert_eq!(info.major_brand, full.major_brand); + assert_eq!(info.timescale, full.timescale); + assert_eq!(info.duration, full.duration); + assert_eq!(info.segments, Vec::new()); + assert_eq!(info.tracks.len(), 1); + assert_eq!(info.tracks[0].codec_family, full.tracks[0].codec_family); + assert_eq!(info.tracks[0].handler_type, full.tracks[0].handler_type); + assert_eq!(info.tracks[0].language, full.tracks[0].language); + assert_eq!( + info.tracks[0].sample_entry_type, + full.tracks[0].sample_entry_type + ); + assert_eq!(info.tracks[0].display_width, full.tracks[0].display_width); + assert_eq!(info.tracks[0].display_height, full.tracks[0].display_height); + assert!(info.tracks[0].summary.samples.is_empty()); + assert!(info.tracks[0].summary.chunks.is_empty()); +} + +#[test] +fn probe_detailed_bytes_with_options_matches_cursor_based_probe_detailed() { + let file = build_movie_file(); + let options = ProbeOptions::lightweight(); + let expected = probe_detailed_with_options(&mut Cursor::new(file.clone()), options).unwrap(); + let actual = probe_detailed_bytes_with_options(&file, options).unwrap(); + assert_eq!(actual, expected); +} + +#[test] +fn probe_codec_detailed_bytes_matches_cursor_based_probe_codec_detailed() { + let file = build_hevc_movie_file(); + let expected = probe_codec_detailed(&mut Cursor::new(file.clone())).unwrap(); + let actual = probe_codec_detailed_bytes(&file).unwrap(); + assert_eq!(actual, expected); +} + +#[test] +fn probe_codec_detailed_with_options_skips_fragment_segments() { + let file = build_fragment_file(); + let mut reader = Cursor::new(file.clone()); + + let full = probe_codec_detailed(&mut Cursor::new(file)).unwrap(); + let info = probe_codec_detailed_with_options(&mut reader, ProbeOptions::lightweight()).unwrap(); + + assert_eq!(info.major_brand, full.major_brand); + assert_eq!(info.timescale, full.timescale); + assert_eq!(info.duration, full.duration); + assert!(!full.segments.is_empty()); + assert!(info.segments.is_empty()); +} + +#[test] +fn probe_codec_detailed_bytes_with_options_matches_cursor_based_probe_codec_detailed() { + let file = build_hevc_movie_file(); + let options = ProbeOptions::lightweight(); + let expected = + probe_codec_detailed_with_options(&mut Cursor::new(file.clone()), options).unwrap(); + let actual = probe_codec_detailed_bytes_with_options(&file, options).unwrap(); + assert_eq!(actual, expected); +} + #[test] fn probe_and_probe_fra_summarize_fragment_runs() { let file = build_fragment_file(); @@ -166,6 +331,22 @@ fn probe_and_probe_fra_summarize_fragment_runs() { assert_eq!(second.size, 36); } +#[test] +fn probe_fra_detailed_bytes_matches_cursor_based_probe_fra_detailed() { + let file = build_fragment_file(); + let expected = probe_fra_detailed(&mut Cursor::new(file.clone())).unwrap(); + let actual = probe_fra_detailed_bytes(&file).unwrap(); + assert_eq!(actual, expected); +} + +#[test] +fn probe_fra_codec_detailed_bytes_matches_cursor_based_probe_fra_codec_detailed() { + let file = build_fragment_file(); + let expected = probe_fra_codec_detailed(&mut Cursor::new(file.clone())).unwrap(); + let actual = probe_fra_codec_detailed_bytes(&file).unwrap(); + assert_eq!(actual, expected); +} + #[test] fn probe_fra_bytes_matches_cursor_based_probe_fra() { let file = build_fragment_file(); @@ -174,6 +355,317 @@ fn probe_fra_bytes_matches_cursor_based_probe_fra() { assert_eq!(actual, expected); } +#[test] +fn probe_detailed_recognizes_av01_track_family() { + let file = build_av01_movie_file(); + let mut reader = Cursor::new(file); + + let info = probe_detailed(&mut reader).unwrap(); + + assert_eq!(info.tracks.len(), 1); + let track = &info.tracks[0]; + assert_eq!(track.summary.codec, TrackCodec::Unknown); + assert_eq!(track.codec_family, TrackCodecFamily::Av1); + assert_eq!(track.handler_type, Some(fourcc("vide"))); + assert_eq!(track.language.as_deref(), Some("eng")); + assert_eq!(track.sample_entry_type, Some(fourcc("av01"))); + assert_eq!(track.original_format, None); + assert_eq!(track.display_width, Some(640)); + assert_eq!(track.display_height, Some(360)); + assert_eq!(track.summary.samples.len(), 1); +} + +#[test] +fn probe_codec_detailed_exposes_richer_landed_codec_details() { + { + let mut reader = Cursor::new(build_hevc_movie_file()); + let info = probe_codec_detailed(&mut reader).unwrap(); + let track = &info.tracks[0]; + assert_eq!(track.summary.codec_family, TrackCodecFamily::Hevc); + match &track.codec_details { + TrackCodecDetails::Hevc(details) => { + assert_eq!(details.configuration_version, 1); + assert_eq!(details.profile_space, 1); + assert!(details.tier_flag); + assert_eq!(details.profile_idc, 2); + assert_eq!(details.profile_compatibility_mask, 0x4000_0000); + assert_eq!(details.constraint_indicator, [1, 2, 3, 4, 5, 6]); + assert_eq!(details.level_idc, 120); + assert_eq!(details.chroma_format_idc, 1); + assert_eq!(details.bit_depth_luma, 10); + assert_eq!(details.bit_depth_chroma, 10); + assert_eq!(details.avg_frame_rate, 30_000); + assert_eq!(details.length_size, 4); + } + other => panic!("expected HEVC details, got {other:?}"), + } + } + + { + let mut reader = Cursor::new(build_av01_movie_file()); + let info = probe_codec_detailed(&mut reader).unwrap(); + let track = &info.tracks[0]; + assert_eq!(track.summary.codec_family, TrackCodecFamily::Av1); + match &track.codec_details { + TrackCodecDetails::Av1(details) => { + assert_eq!(details.seq_profile, 0); + assert_eq!(details.seq_level_idx_0, 13); + assert_eq!(details.seq_tier_0, 1); + assert_eq!(details.bit_depth, 10); + assert!(!details.monochrome); + assert_eq!(details.chroma_subsampling_x, 1); + assert_eq!(details.chroma_subsampling_y, 0); + assert_eq!(details.chroma_sample_position, 2); + assert_eq!(details.initial_presentation_delay_minus_one, Some(3)); + } + other => panic!("expected AV1 details, got {other:?}"), + } + } + + { + let mut reader = Cursor::new(build_vp09_movie_file()); + let info = probe_codec_detailed(&mut reader).unwrap(); + let track = &info.tracks[0]; + assert_eq!(track.summary.codec_family, TrackCodecFamily::Vp9); + match &track.codec_details { + TrackCodecDetails::Vp9(details) => { + assert_eq!(details.profile, 2); + assert_eq!(details.level, 31); + assert_eq!(details.bit_depth, 10); + assert_eq!(details.chroma_subsampling, 1); + assert!(details.full_range); + assert_eq!(details.colour_primaries, 9); + assert_eq!(details.transfer_characteristics, 16); + assert_eq!(details.matrix_coefficients, 9); + assert_eq!(details.codec_initialization_data_size, 3); + } + other => panic!("expected VP9 details, got {other:?}"), + } + } + + { + let mut reader = Cursor::new(build_opus_movie_file()); + let info = probe_codec_detailed(&mut reader).unwrap(); + let track = &info.tracks[0]; + assert_eq!(track.summary.codec_family, TrackCodecFamily::Opus); + match &track.codec_details { + TrackCodecDetails::Opus(details) => { + assert_eq!(details.output_channel_count, 2); + assert_eq!(details.pre_skip, 312); + assert_eq!(details.input_sample_rate, 48_000); + assert_eq!(details.output_gain, 0); + assert_eq!(details.channel_mapping_family, 1); + assert_eq!(details.stream_count, Some(2)); + assert_eq!(details.coupled_count, Some(1)); + assert_eq!(details.channel_mapping, vec![0, 1]); + } + other => panic!("expected Opus details, got {other:?}"), + } + } + + { + let mut reader = Cursor::new(build_ac3_movie_file()); + let info = probe_codec_detailed(&mut reader).unwrap(); + let track = &info.tracks[0]; + assert_eq!(track.summary.codec_family, TrackCodecFamily::Ac3); + match &track.codec_details { + TrackCodecDetails::Ac3(details) => { + assert_eq!(details.sample_rate_code, 1); + assert_eq!(details.bit_stream_identification, 8); + assert_eq!(details.bit_stream_mode, 3); + assert_eq!(details.audio_coding_mode, 7); + assert!(details.lfe_on); + assert_eq!(details.bit_rate_code, 10); + } + other => panic!("expected AC-3 details, got {other:?}"), + } + } + + { + let mut reader = Cursor::new(build_pcm_movie_file()); + let info = probe_codec_detailed(&mut reader).unwrap(); + let track = &info.tracks[0]; + assert_eq!(track.summary.codec_family, TrackCodecFamily::Pcm); + match &track.codec_details { + TrackCodecDetails::Pcm(details) => { + assert_eq!(details.format_flags, 1); + assert_eq!(details.sample_size, 24); + } + other => panic!("expected PCM details, got {other:?}"), + } + } + + { + let mut reader = Cursor::new(build_stpp_movie_file()); + let info = probe_codec_detailed(&mut reader).unwrap(); + let track = &info.tracks[0]; + assert_eq!(track.summary.codec_family, TrackCodecFamily::XmlSubtitle); + match &track.codec_details { + TrackCodecDetails::XmlSubtitle(details) => { + assert_eq!(details.namespace, "urn:ebu:tt:metadata"); + assert_eq!(details.schema_location, "urn:ebu:tt:schema"); + assert_eq!(details.auxiliary_mime_types, "application/ttml+xml"); + } + other => panic!("expected XML subtitle details, got {other:?}"), + } + } + + { + let mut reader = Cursor::new(build_sbtt_movie_file()); + let info = probe_codec_detailed(&mut reader).unwrap(); + let track = &info.tracks[0]; + assert_eq!(track.summary.codec_family, TrackCodecFamily::TextSubtitle); + match &track.codec_details { + TrackCodecDetails::TextSubtitle(details) => { + assert_eq!(details.content_encoding, "utf-8"); + assert_eq!(details.mime_format, "text/plain"); + } + other => panic!("expected text subtitle details, got {other:?}"), + } + } + + { + let mut reader = Cursor::new(build_wvtt_movie_file()); + let info = probe_codec_detailed(&mut reader).unwrap(); + let track = &info.tracks[0]; + assert_eq!(track.summary.codec_family, TrackCodecFamily::WebVtt); + match &track.codec_details { + TrackCodecDetails::WebVtt(details) => { + assert_eq!(details.config.as_deref(), Some("WEBVTT")); + assert_eq!(details.source_label.as_deref(), Some("eng")); + } + other => panic!("expected WebVTT details, got {other:?}"), + } + } +} + +#[test] +fn probe_detailed_reports_protected_sample_entry_metadata() { + let file = build_encrypted_video_movie_file(); + let mut reader = Cursor::new(file); + + let info = probe_detailed(&mut reader).unwrap(); + + assert_eq!(info.tracks.len(), 1); + let track = &info.tracks[0]; + assert_eq!(track.summary.codec, TrackCodec::Avc1); + assert!(track.summary.encrypted); + assert_eq!(track.codec_family, TrackCodecFamily::Avc); + assert_eq!(track.handler_type, Some(fourcc("vide"))); + assert_eq!(track.language.as_deref(), Some("eng")); + assert_eq!(track.sample_entry_type, Some(fourcc("encv"))); + assert_eq!(track.original_format, Some(fourcc("avc1"))); + assert_eq!( + track + .protection_scheme + .as_ref() + .map(|value| (value.scheme_type, value.scheme_version)), + Some((fourcc("cenc"), 0x0001_0000)) + ); + assert_eq!(track.display_width, Some(320)); + assert_eq!(track.display_height, Some(180)); +} + +#[test] +fn probe_codec_detailed_reports_protected_hevc_codec_details() { + let file = build_encrypted_hevc_movie_file(); + let mut reader = Cursor::new(file); + + let info = probe_codec_detailed(&mut reader).unwrap(); + + assert_eq!(info.tracks.len(), 1); + let track = &info.tracks[0]; + assert_eq!(track.summary.summary.codec, TrackCodec::Avc1); + assert!(track.summary.summary.encrypted); + assert_eq!(track.summary.codec_family, TrackCodecFamily::Hevc); + assert_eq!(track.summary.sample_entry_type, Some(fourcc("encv"))); + assert_eq!(track.summary.original_format, Some(fourcc("hvc1"))); + match &track.codec_details { + TrackCodecDetails::Hevc(details) => { + assert_eq!(details.profile_idc, 2); + assert_eq!(details.length_size, 4); + } + other => panic!("expected HEVC details, got {other:?}"), + } +} + +#[test] +fn probe_media_characteristics_exposes_sample_entry_side_metadata() { + let file = build_media_characteristics_movie_file(); + let mut reader = Cursor::new(file); + + let info = probe_media_characteristics(&mut reader).unwrap(); + + assert_eq!(info.tracks.len(), 1); + let track = &info.tracks[0]; + assert_eq!(track.summary.summary.track_id, 1); + assert_eq!(track.summary.codec_family, TrackCodecFamily::Avc); + assert_eq!(track.summary.sample_entry_type, Some(fourcc("avc1"))); + assert_eq!( + track.media_characteristics.declared_bitrate, + Some(mp4forge::probe::DeclaredBitrateInfo { + buffer_size_db: 32_768, + max_bitrate: 4_000_000, + avg_bitrate: 2_500_000, + }) + ); + assert_eq!( + track.media_characteristics.color, + Some(mp4forge::probe::ColorInfo { + colour_type: fourcc("nclx"), + colour_primaries: Some(9), + transfer_characteristics: Some(16), + matrix_coefficients: Some(9), + full_range: Some(true), + profile_size: None, + unknown_size: None, + }) + ); + assert_eq!( + track.media_characteristics.pixel_aspect_ratio, + Some(mp4forge::probe::PixelAspectRatioInfo { + h_spacing: 4, + v_spacing: 3, + }) + ); + assert_eq!( + track.media_characteristics.field_order, + Some(mp4forge::probe::FieldOrderInfo { + field_count: 2, + field_ordering: 6, + interlaced: true, + }) + ); +} + +#[test] +fn probe_media_characteristics_bytes_matches_cursor_based_probe() { + let file = build_media_characteristics_movie_file(); + let expected = probe_media_characteristics(&mut Cursor::new(file.clone())).unwrap(); + let actual = probe_media_characteristics_bytes(&file).unwrap(); + assert_eq!(actual, expected); +} + +#[test] +fn probe_media_characteristics_with_options_preserves_media_fields_without_sample_tables() { + let file = build_media_characteristics_movie_file(); + let options = ProbeOptions::lightweight(); + let expected = + probe_media_characteristics_with_options(&mut Cursor::new(file.clone()), options).unwrap(); + let actual = probe_media_characteristics_bytes_with_options(&file, options).unwrap(); + + assert_eq!(actual, expected); + assert_eq!(actual.tracks.len(), 1); + assert!(actual.tracks[0].summary.summary.samples.is_empty()); + assert!(actual.tracks[0].summary.summary.chunks.is_empty()); + assert!( + actual.tracks[0] + .media_characteristics + .declared_bitrate + .is_some() + ); +} + #[test] fn probe_bytes_propagates_decode_errors() { let file = encode_raw_box(fourcc("ftyp"), &[0x69, 0x73]); @@ -316,6 +808,7 @@ fn build_video_trak(chunk_offsets: &[u64; 2]) -> Vec { mdhd.duration_v0 = 3_072; mdhd.language = [5, 14, 7]; let mdhd = encode_supported_box(&mdhd, &[]); + let hdlr = handler_box("vide", "VideoHandler"); let mut stsd = Stsd::default(); stsd.entry_count = 1; @@ -377,7 +870,7 @@ fn build_video_trak(chunk_offsets: &[u64; 2]) -> Vec { let stbl = encode_supported_box(&Stbl, &[stsd, stco, stts, ctts, stsc, stsz].concat()); let minf = encode_supported_box(&Minf, &stbl); - let mdia = encode_supported_box(&Mdia, &[mdhd, minf].concat()); + let mdia = encode_supported_box(&Mdia, &[mdhd, hdlr, minf].concat()); encode_supported_box(&Trak, &[tkhd, edts, mdia].concat()) } @@ -392,6 +885,7 @@ fn build_audio_trak(chunk_offsets: &[u64; 2]) -> Vec { mdhd.duration_v0 = 2_048; mdhd.language = [5, 14, 7]; let mdhd = encode_supported_box(&mdhd, &[]); + let hdlr = handler_box("soun", "SoundHandler"); let mut stsd = Stsd::default(); stsd.entry_count = 1; @@ -430,7 +924,7 @@ fn build_audio_trak(chunk_offsets: &[u64; 2]) -> Vec { let stbl = encode_supported_box(&Stbl, &[stsd, stco, stts, stsc, stsz].concat()); let minf = encode_supported_box(&Minf, &stbl); - let mdia = encode_supported_box(&Mdia, &[mdhd, minf].concat()); + let mdia = encode_supported_box(&Mdia, &[mdhd, hdlr, minf].concat()); encode_supported_box(&Trak, &[tkhd, mdia].concat()) } @@ -513,6 +1007,626 @@ fn build_fragment_moof_two() -> Vec { encode_supported_box(&Moof, &traf) } +fn build_av01_movie_file() -> Vec { + let ftyp = encode_supported_box( + &Ftyp { + major_brand: fourcc("isom"), + minor_version: 0x0200, + compatible_brands: vec![fourcc("isom"), fourcc("iso8"), fourcc("av01")], + }, + &[], + ); + + let placeholder_moov = build_av01_moov(&[0]); + let mdat_payload = vec![0x12, 0x34, 0x56, 0x78]; + let mdat_data_offset = ftyp.len() as u64 + placeholder_moov.len() as u64 + 8; + let moov = build_av01_moov(&[mdat_data_offset]); + let mdat = encode_raw_box(fourcc("mdat"), &mdat_payload); + [ftyp, moov, mdat].concat() +} + +fn build_av01_moov(chunk_offsets: &[u64; 1]) -> Vec { + let mut mvhd = Mvhd::default(); + mvhd.timescale = 1_000; + mvhd.duration_v0 = 1_000; + mvhd.rate = 1 << 16; + mvhd.volume = 1 << 8; + mvhd.next_track_id = 2; + let mvhd = encode_supported_box(&mvhd, &[]); + let video = build_av01_trak(chunk_offsets); + encode_supported_box(&Moov, &[mvhd, video].concat()) +} + +fn build_av01_trak(chunk_offsets: &[u64; 1]) -> Vec { + let mut tkhd = Tkhd::default(); + tkhd.track_id = 1; + tkhd.duration_v0 = 1_000; + tkhd.width = u32::from(640_u16) << 16; + tkhd.height = u32::from(360_u16) << 16; + let tkhd = encode_supported_box(&tkhd, &[]); + + let mut mdhd = Mdhd::default(); + mdhd.timescale = 1_000; + mdhd.duration_v0 = 1_000; + mdhd.language = [5, 14, 7]; + let mdhd = encode_supported_box(&mdhd, &[]); + let hdlr = handler_box("vide", "VideoHandler"); + + let mut stsd = Stsd::default(); + stsd.entry_count = 1; + let av01 = encode_supported_box( + &video_sample_entry_with_type("av01", 640, 360), + &encode_supported_box(&av1_config(), &[]), + ); + let stsd = encode_supported_box(&stsd, &av01); + + let mut stco = Stco::default(); + stco.entry_count = 1; + stco.chunk_offset = chunk_offsets.to_vec(); + let stco = encode_supported_box(&stco, &[]); + + let mut stts = Stts::default(); + stts.entry_count = 1; + stts.entries = vec![SttsEntry { + sample_count: 1, + sample_delta: 1_000, + }]; + let stts = encode_supported_box(&stts, &[]); + + let mut stsc = Stsc::default(); + stsc.entry_count = 1; + stsc.entries = vec![StscEntry { + first_chunk: 1, + samples_per_chunk: 1, + sample_description_index: 1, + }]; + let stsc = encode_supported_box(&stsc, &[]); + + let mut stsz = Stsz::default(); + stsz.sample_count = 1; + stsz.entry_size = vec![4]; + let stsz = encode_supported_box(&stsz, &[]); + + let stbl = encode_supported_box(&Stbl, &[stsd, stco, stts, stsc, stsz].concat()); + let minf = encode_supported_box(&Minf, &stbl); + let mdia = encode_supported_box(&Mdia, &[mdhd, hdlr, minf].concat()); + encode_supported_box(&Trak, &[tkhd, mdia].concat()) +} + +fn build_encrypted_video_movie_file() -> Vec { + let ftyp = encode_supported_box( + &Ftyp { + major_brand: fourcc("iso6"), + minor_version: 1, + compatible_brands: vec![fourcc("iso6"), fourcc("dash"), fourcc("cenc")], + }, + &[], + ); + + let placeholder_moov = build_encrypted_video_moov(&[0]); + let mdat_payload = [avc_sample(5)].concat(); + let mdat_data_offset = ftyp.len() as u64 + placeholder_moov.len() as u64 + 8; + let moov = build_encrypted_video_moov(&[mdat_data_offset]); + let mdat = encode_raw_box(fourcc("mdat"), &mdat_payload); + [ftyp, moov, mdat].concat() +} + +fn build_encrypted_video_moov(chunk_offsets: &[u64; 1]) -> Vec { + let mut mvhd = Mvhd::default(); + mvhd.timescale = 1_000; + mvhd.duration_v0 = 1_000; + mvhd.rate = 1 << 16; + mvhd.volume = 1 << 8; + mvhd.next_track_id = 2; + let mvhd = encode_supported_box(&mvhd, &[]); + let video = build_encrypted_video_trak(chunk_offsets); + encode_supported_box(&Moov, &[mvhd, video].concat()) +} + +fn build_encrypted_video_trak(chunk_offsets: &[u64; 1]) -> Vec { + let mut tkhd = Tkhd::default(); + tkhd.track_id = 1; + tkhd.duration_v0 = 1_000; + tkhd.width = u32::from(320_u16) << 16; + tkhd.height = u32::from(180_u16) << 16; + let tkhd = encode_supported_box(&tkhd, &[]); + + let mut mdhd = Mdhd::default(); + mdhd.timescale = 1_000; + mdhd.duration_v0 = 1_000; + mdhd.language = [5, 14, 7]; + let mdhd = encode_supported_box(&mdhd, &[]); + let hdlr = handler_box("vide", "VideoHandler"); + + let mut schm = Schm::default(); + schm.set_version(0); + schm.scheme_type = fourcc("cenc"); + schm.scheme_version = 0x0001_0000; + let sinf = encode_supported_box( + &Sinf, + &[ + encode_supported_box( + &Frma { + data_format: fourcc("avc1"), + }, + &[], + ), + encode_supported_box(&schm, &[]), + ] + .concat(), + ); + + let mut stsd = Stsd::default(); + stsd.entry_count = 1; + let encv = encode_supported_box( + &video_sample_entry_with_type("encv", 320, 180), + &[encode_supported_box(&avc_config(), &[]), sinf].concat(), + ); + let stsd = encode_supported_box(&stsd, &encv); + + let mut stco = Stco::default(); + stco.entry_count = 1; + stco.chunk_offset = chunk_offsets.to_vec(); + let stco = encode_supported_box(&stco, &[]); + + let mut stts = Stts::default(); + stts.entry_count = 1; + stts.entries = vec![SttsEntry { + sample_count: 1, + sample_delta: 1_000, + }]; + let stts = encode_supported_box(&stts, &[]); + + let mut stsc = Stsc::default(); + stsc.entry_count = 1; + stsc.entries = vec![StscEntry { + first_chunk: 1, + samples_per_chunk: 1, + sample_description_index: 1, + }]; + let stsc = encode_supported_box(&stsc, &[]); + + let mut stsz = Stsz::default(); + stsz.sample_count = 1; + stsz.entry_size = vec![5]; + let stsz = encode_supported_box(&stsz, &[]); + + let stbl = encode_supported_box(&Stbl, &[stsd, stco, stts, stsc, stsz].concat()); + let minf = encode_supported_box(&Minf, &stbl); + let mdia = encode_supported_box(&Mdia, &[mdhd, hdlr, minf].concat()); + encode_supported_box(&Trak, &[tkhd, mdia].concat()) +} + +fn build_single_track_movie_file( + compatible_brands: Vec, + track_builder: fn(&[u64; 1]) -> Vec, + mdat_payload: Vec, +) -> Vec { + let ftyp = encode_supported_box( + &Ftyp { + major_brand: fourcc("isom"), + minor_version: 0x0200, + compatible_brands, + }, + &[], + ); + + let placeholder_moov = build_single_track_moov(track_builder(&[0])); + let mdat_data_offset = ftyp.len() as u64 + placeholder_moov.len() as u64 + 8; + let moov = build_single_track_moov(track_builder(&[mdat_data_offset])); + let mdat = encode_raw_box(fourcc("mdat"), &mdat_payload); + [ftyp, moov, mdat].concat() +} + +fn build_single_track_moov(track: Vec) -> Vec { + let mut mvhd = Mvhd::default(); + mvhd.timescale = 1_000; + mvhd.duration_v0 = 1_000; + mvhd.rate = 1 << 16; + mvhd.volume = 1 << 8; + mvhd.next_track_id = 2; + let mvhd = encode_supported_box(&mvhd, &[]); + encode_supported_box(&Moov, &[mvhd, track].concat()) +} + +fn build_hevc_movie_file() -> Vec { + build_single_track_movie_file( + vec![fourcc("isom"), fourcc("iso8"), fourcc("hvc1")], + build_hevc_trak, + vec![0x12, 0x34, 0x56, 0x78], + ) +} + +fn build_hevc_trak(chunk_offsets: &[u64; 1]) -> Vec { + let sample_entry = encode_supported_box( + &video_sample_entry_with_type("hvc1", 640, 360), + &encode_supported_box(&hevc_config(), &[]), + ); + build_single_sample_video_trak(1, 1_000, 1_000, (640, 360), sample_entry, chunk_offsets, 4) +} + +fn build_vp09_movie_file() -> Vec { + build_single_track_movie_file( + vec![fourcc("isom"), fourcc("iso8"), fourcc("vp09")], + build_vp09_trak, + vec![0xaa, 0xbb, 0xcc, 0xdd], + ) +} + +fn build_vp09_trak(chunk_offsets: &[u64; 1]) -> Vec { + let sample_entry = encode_supported_box( + &video_sample_entry_with_type("vp09", 640, 360), + &encode_supported_box(&vp9_config(), &[]), + ); + build_single_sample_video_trak(1, 1_000, 1_000, (640, 360), sample_entry, chunk_offsets, 4) +} + +fn build_opus_movie_file() -> Vec { + build_single_track_movie_file( + vec![fourcc("isom"), fourcc("iso8"), fourcc("Opus")], + build_opus_trak, + vec![0x11, 0x22, 0x33, 0x44], + ) +} + +fn build_opus_trak(chunk_offsets: &[u64; 1]) -> Vec { + let sample_entry = encode_supported_box( + &audio_sample_entry_with_type("Opus", 2, 48_000), + &encode_supported_box(&opus_config(), &[]), + ); + build_single_sample_audio_trak(1, 48_000, 1_024, sample_entry, chunk_offsets, 4) +} + +fn build_ac3_movie_file() -> Vec { + build_single_track_movie_file( + vec![fourcc("isom"), fourcc("iso8"), fourcc("ac-3")], + build_ac3_trak, + vec![0x21, 0x22, 0x23, 0x24], + ) +} + +fn build_ac3_trak(chunk_offsets: &[u64; 1]) -> Vec { + let sample_entry = encode_supported_box( + &audio_sample_entry_with_type("ac-3", 6, 48_000), + &encode_supported_box(&ac3_config(), &[]), + ); + build_single_sample_audio_trak(1, 48_000, 1_536, sample_entry, chunk_offsets, 4) +} + +fn build_pcm_movie_file() -> Vec { + build_single_track_movie_file( + vec![fourcc("isom"), fourcc("iso8"), fourcc("ipcm")], + build_pcm_trak, + vec![0x31, 0x32, 0x33, 0x34], + ) +} + +fn build_pcm_trak(chunk_offsets: &[u64; 1]) -> Vec { + let sample_entry = encode_supported_box( + &audio_sample_entry_with_type("ipcm", 2, 48_000), + &encode_supported_box(&pcm_config(), &[]), + ); + build_single_sample_audio_trak(1, 48_000, 1_024, sample_entry, chunk_offsets, 4) +} + +fn build_stpp_movie_file() -> Vec { + build_single_track_movie_file( + vec![fourcc("isom"), fourcc("iso8"), fourcc("stpp")], + build_stpp_trak, + vec![0x41, 0x42, 0x43, 0x44], + ) +} + +fn build_stpp_trak(chunk_offsets: &[u64; 1]) -> Vec { + let sample_entry = encode_supported_box(&xml_subtitle_sample_entry(), &[]); + build_single_sample_subtitle_trak(1, 1_000, 1_000, sample_entry, chunk_offsets, 4) +} + +fn build_sbtt_movie_file() -> Vec { + build_single_track_movie_file( + vec![fourcc("isom"), fourcc("iso8"), fourcc("sbtt")], + build_sbtt_trak, + vec![0x51, 0x52, 0x53, 0x54], + ) +} + +fn build_sbtt_trak(chunk_offsets: &[u64; 1]) -> Vec { + let sample_entry = encode_supported_box(&text_subtitle_sample_entry(), &[]); + build_single_sample_subtitle_trak(1, 1_000, 1_000, sample_entry, chunk_offsets, 4) +} + +fn build_wvtt_movie_file() -> Vec { + build_single_track_movie_file( + vec![fourcc("isom"), fourcc("iso8"), fourcc("wvtt")], + build_wvtt_trak, + vec![0x61, 0x62, 0x63, 0x64], + ) +} + +fn build_wvtt_trak(chunk_offsets: &[u64; 1]) -> Vec { + let sample_entry = encode_supported_box( + &wvtt_sample_entry(), + &[ + encode_supported_box( + &WebVTTConfigurationBox { + config: "WEBVTT".to_string(), + }, + &[], + ), + encode_supported_box( + &WebVTTSourceLabelBox { + source_label: "eng".to_string(), + }, + &[], + ), + ] + .concat(), + ); + build_single_sample_subtitle_trak(1, 1_000, 1_000, sample_entry, chunk_offsets, 4) +} + +fn build_encrypted_hevc_movie_file() -> Vec { + build_single_track_movie_file( + vec![fourcc("iso6"), fourcc("dash"), fourcc("cenc")], + build_encrypted_hevc_trak, + vec![0x71, 0x72, 0x73, 0x74], + ) +} + +fn build_encrypted_hevc_trak(chunk_offsets: &[u64; 1]) -> Vec { + let mut schm = Schm::default(); + schm.set_version(0); + schm.scheme_type = fourcc("cenc"); + schm.scheme_version = 0x0001_0000; + let sinf = encode_supported_box( + &Sinf, + &[ + encode_supported_box( + &Frma { + data_format: fourcc("hvc1"), + }, + &[], + ), + encode_supported_box(&schm, &[]), + ] + .concat(), + ); + + let sample_entry = encode_supported_box( + &video_sample_entry_with_type("encv", 640, 360), + &[encode_supported_box(&hevc_config(), &[]), sinf].concat(), + ); + build_single_sample_video_trak(1, 1_000, 1_000, (640, 360), sample_entry, chunk_offsets, 4) +} + +fn build_media_characteristics_movie_file() -> Vec { + build_single_track_movie_file( + vec![fourcc("isom"), fourcc("iso8"), fourcc("avc1")], + build_media_characteristics_trak, + vec![0x81, 0x82, 0x83, 0x84], + ) +} + +fn build_media_characteristics_trak(chunk_offsets: &[u64; 1]) -> Vec { + let sample_entry = encode_supported_box( + &video_sample_entry_with_type("avc1", 640, 360), + &[ + encode_supported_box(&avc_config(), &[]), + encode_supported_box( + &Btrt { + buffer_size_db: 32_768, + max_bitrate: 4_000_000, + avg_bitrate: 2_500_000, + }, + &[], + ), + encode_supported_box( + &Colr { + colour_type: fourcc("nclx"), + colour_primaries: 9, + transfer_characteristics: 16, + matrix_coefficients: 9, + full_range_flag: true, + reserved: 0, + profile: Vec::new(), + unknown: Vec::new(), + }, + &[], + ), + encode_supported_box( + &Pasp { + h_spacing: 4, + v_spacing: 3, + }, + &[], + ), + encode_supported_box( + &Fiel { + field_count: 2, + field_ordering: 6, + }, + &[], + ), + ] + .concat(), + ); + build_single_sample_video_trak(1, 1_000, 1_000, (640, 360), sample_entry, chunk_offsets, 4) +} + +fn build_single_sample_video_trak( + track_id: u32, + timescale: u32, + duration: u32, + dimensions: (u16, u16), + sample_entry: Vec, + chunk_offsets: &[u64; 1], + sample_size: u32, +) -> Vec { + let mut tkhd = Tkhd::default(); + tkhd.track_id = track_id; + tkhd.duration_v0 = duration; + tkhd.width = u32::from(dimensions.0) << 16; + tkhd.height = u32::from(dimensions.1) << 16; + let tkhd = encode_supported_box(&tkhd, &[]); + + let mut mdhd = Mdhd::default(); + mdhd.timescale = timescale; + mdhd.duration_v0 = duration; + mdhd.language = [5, 14, 7]; + let mdhd = encode_supported_box(&mdhd, &[]); + let hdlr = handler_box("vide", "VideoHandler"); + + let mut stsd = Stsd::default(); + stsd.entry_count = 1; + let stsd = encode_supported_box(&stsd, &sample_entry); + + let mut stco = Stco::default(); + stco.entry_count = 1; + stco.chunk_offset = chunk_offsets.to_vec(); + let stco = encode_supported_box(&stco, &[]); + + let mut stts = Stts::default(); + stts.entry_count = 1; + stts.entries = vec![SttsEntry { + sample_count: 1, + sample_delta: duration, + }]; + let stts = encode_supported_box(&stts, &[]); + + let mut stsc = Stsc::default(); + stsc.entry_count = 1; + stsc.entries = vec![StscEntry { + first_chunk: 1, + samples_per_chunk: 1, + sample_description_index: 1, + }]; + let stsc = encode_supported_box(&stsc, &[]); + + let mut stsz = Stsz::default(); + stsz.sample_count = 1; + stsz.entry_size = vec![u64::from(sample_size)]; + let stsz = encode_supported_box(&stsz, &[]); + + let stbl = encode_supported_box(&Stbl, &[stsd, stco, stts, stsc, stsz].concat()); + let minf = encode_supported_box(&Minf, &stbl); + let mdia = encode_supported_box(&Mdia, &[mdhd, hdlr, minf].concat()); + encode_supported_box(&Trak, &[tkhd, mdia].concat()) +} + +fn build_single_sample_audio_trak( + track_id: u32, + timescale: u32, + duration: u32, + sample_entry: Vec, + chunk_offsets: &[u64; 1], + sample_size: u32, +) -> Vec { + let mut tkhd = Tkhd::default(); + tkhd.track_id = track_id; + tkhd.duration_v0 = duration; + let tkhd = encode_supported_box(&tkhd, &[]); + + let mut mdhd = Mdhd::default(); + mdhd.timescale = timescale; + mdhd.duration_v0 = duration; + mdhd.language = [5, 14, 7]; + let mdhd = encode_supported_box(&mdhd, &[]); + let hdlr = handler_box("soun", "SoundHandler"); + + let mut stsd = Stsd::default(); + stsd.entry_count = 1; + let stsd = encode_supported_box(&stsd, &sample_entry); + + let mut stco = Stco::default(); + stco.entry_count = 1; + stco.chunk_offset = chunk_offsets.to_vec(); + let stco = encode_supported_box(&stco, &[]); + + let mut stts = Stts::default(); + stts.entry_count = 1; + stts.entries = vec![SttsEntry { + sample_count: 1, + sample_delta: duration, + }]; + let stts = encode_supported_box(&stts, &[]); + + let mut stsc = Stsc::default(); + stsc.entry_count = 1; + stsc.entries = vec![StscEntry { + first_chunk: 1, + samples_per_chunk: 1, + sample_description_index: 1, + }]; + let stsc = encode_supported_box(&stsc, &[]); + + let mut stsz = Stsz::default(); + stsz.sample_count = 1; + stsz.entry_size = vec![u64::from(sample_size)]; + let stsz = encode_supported_box(&stsz, &[]); + + let stbl = encode_supported_box(&Stbl, &[stsd, stco, stts, stsc, stsz].concat()); + let minf = encode_supported_box(&Minf, &stbl); + let mdia = encode_supported_box(&Mdia, &[mdhd, hdlr, minf].concat()); + encode_supported_box(&Trak, &[tkhd, mdia].concat()) +} + +fn build_single_sample_subtitle_trak( + track_id: u32, + timescale: u32, + duration: u32, + sample_entry: Vec, + chunk_offsets: &[u64; 1], + sample_size: u32, +) -> Vec { + let mut tkhd = Tkhd::default(); + tkhd.track_id = track_id; + tkhd.duration_v0 = duration; + let tkhd = encode_supported_box(&tkhd, &[]); + + let mut mdhd = Mdhd::default(); + mdhd.timescale = timescale; + mdhd.duration_v0 = duration; + mdhd.language = [5, 14, 7]; + let mdhd = encode_supported_box(&mdhd, &[]); + let hdlr = handler_box("subt", "SubtitleHandler"); + + let mut stsd = Stsd::default(); + stsd.entry_count = 1; + let stsd = encode_supported_box(&stsd, &sample_entry); + + let mut stco = Stco::default(); + stco.entry_count = 1; + stco.chunk_offset = chunk_offsets.to_vec(); + let stco = encode_supported_box(&stco, &[]); + + let mut stts = Stts::default(); + stts.entry_count = 1; + stts.entries = vec![SttsEntry { + sample_count: 1, + sample_delta: duration, + }]; + let stts = encode_supported_box(&stts, &[]); + + let mut stsc = Stsc::default(); + stsc.entry_count = 1; + stsc.entries = vec![StscEntry { + first_chunk: 1, + samples_per_chunk: 1, + sample_description_index: 1, + }]; + let stsc = encode_supported_box(&stsc, &[]); + + let mut stsz = Stsz::default(); + stsz.sample_count = 1; + stsz.entry_size = vec![u64::from(sample_size)]; + let stsz = encode_supported_box(&stsz, &[]); + + let stbl = encode_supported_box(&Stbl, &[stsd, stco, stts, stsc, stsz].concat()); + let minf = encode_supported_box(&Minf, &stbl); + let mdia = encode_supported_box(&Mdia, &[mdhd, hdlr, minf].concat()); + encode_supported_box(&Trak, &[tkhd, mdia].concat()) +} + fn avc_config() -> AVCDecoderConfiguration { AVCDecoderConfiguration { configuration_version: 1, @@ -524,36 +1638,178 @@ fn avc_config() -> AVCDecoderConfiguration { } } -fn video_sample_entry() -> VisualSampleEntry { +fn hevc_config() -> HEVCDecoderConfiguration { + let mut general_profile_compatibility = [false; 32]; + general_profile_compatibility[1] = true; + + HEVCDecoderConfiguration { + configuration_version: 1, + general_profile_space: 1, + general_tier_flag: true, + general_profile_idc: 2, + general_profile_compatibility, + general_constraint_indicator: [1, 2, 3, 4, 5, 6], + general_level_idc: 120, + min_spatial_segmentation_idc: 0, + parallelism_type: 0, + chroma_format_idc: 1, + bit_depth_luma_minus8: 2, + bit_depth_chroma_minus8: 2, + avg_frame_rate: 30_000, + constant_frame_rate: 0, + num_temporal_layers: 1, + temporal_id_nested: 1, + length_size_minus_one: 3, + num_of_nalu_arrays: 0, + nalu_arrays: Vec::new(), + } +} + +fn av1_config() -> AV1CodecConfiguration { + AV1CodecConfiguration { + seq_profile: 0, + seq_level_idx_0: 13, + seq_tier_0: 1, + high_bitdepth: 1, + twelve_bit: 0, + monochrome: 0, + chroma_subsampling_x: 1, + chroma_subsampling_y: 0, + chroma_sample_position: 2, + initial_presentation_delay_present: 1, + initial_presentation_delay_minus_one: 3, + config_obus: vec![0x12, 0x34, 0x56], + } +} + +fn vp9_config() -> VpCodecConfiguration { + let mut config = VpCodecConfiguration::default(); + config.profile = 2; + config.level = 31; + config.bit_depth = 10; + config.chroma_subsampling = 1; + config.video_full_range_flag = 1; + config.colour_primaries = 9; + config.transfer_characteristics = 16; + config.matrix_coefficients = 9; + config.codec_initialization_data_size = 3; + config.codec_initialization_data = vec![0x01, 0x02, 0x03]; + config +} + +fn opus_config() -> DOps { + DOps { + version: 0, + output_channel_count: 2, + pre_skip: 312, + input_sample_rate: 48_000, + output_gain: 0, + channel_mapping_family: 1, + stream_count: 2, + coupled_count: 1, + channel_mapping: vec![0, 1], + } +} + +fn ac3_config() -> Dac3 { + Dac3 { + fscod: 1, + bsid: 8, + bsmod: 3, + acmod: 7, + lfe_on: 1, + bit_rate_code: 10, + } +} + +fn pcm_config() -> PcmC { + let mut config = PcmC::default(); + config.format_flags = 1; + config.pcm_sample_size = 24; + config +} + +fn handler_box(handler_type: &str, name: &str) -> Vec { + let mut hdlr = Hdlr::default(); + hdlr.handler_type = fourcc(handler_type); + hdlr.name = name.to_string(); + encode_supported_box(&hdlr, &[]) +} + +fn video_sample_entry_with_type(box_type: &str, width: u16, height: u16) -> VisualSampleEntry { let mut entry = VisualSampleEntry { sample_entry: SampleEntry { - box_type: fourcc("avc1"), + box_type: fourcc(box_type), data_reference_index: 1, }, - width: 320, - height: 180, + width, + height, frame_count: 1, ..VisualSampleEntry::default() }; - entry.set_box_type(fourcc("avc1")); + entry.set_box_type(fourcc(box_type)); entry } +fn video_sample_entry() -> VisualSampleEntry { + video_sample_entry_with_type("avc1", 320, 180) +} + fn audio_sample_entry() -> AudioSampleEntry { + audio_sample_entry_with_type("mp4a", 2, 48_000) +} + +fn audio_sample_entry_with_type( + box_type: &str, + channel_count: u16, + sample_rate: u32, +) -> AudioSampleEntry { let mut entry = AudioSampleEntry { sample_entry: SampleEntry { - box_type: fourcc("mp4a"), + box_type: fourcc(box_type), data_reference_index: 1, }, - channel_count: 2, + channel_count, sample_size: 16, - sample_rate: 48_000_u32 << 16, + sample_rate: sample_rate << 16, ..AudioSampleEntry::default() }; - entry.set_box_type(fourcc("mp4a")); + entry.set_box_type(fourcc(box_type)); entry } +fn xml_subtitle_sample_entry() -> XMLSubtitleSampleEntry { + XMLSubtitleSampleEntry { + sample_entry: SampleEntry { + box_type: fourcc("stpp"), + data_reference_index: 1, + }, + namespace: "urn:ebu:tt:metadata".to_string(), + schema_location: "urn:ebu:tt:schema".to_string(), + auxiliary_mime_types: "application/ttml+xml".to_string(), + } +} + +fn text_subtitle_sample_entry() -> TextSubtitleSampleEntry { + TextSubtitleSampleEntry { + sample_entry: SampleEntry { + box_type: fourcc("sbtt"), + data_reference_index: 1, + }, + content_encoding: "utf-8".to_string(), + mime_format: "text/plain".to_string(), + } +} + +fn wvtt_sample_entry() -> WVTTSampleEntry { + WVTTSampleEntry { + sample_entry: SampleEntry { + box_type: fourcc("wvtt"), + data_reference_index: 1, + }, + } +} + fn aac_profile_esds(object_type_indication: u8, decoder_specific_info: &[u8]) -> Esds { let mut esds = Esds::default(); esds.descriptors = vec![ diff --git a/tests/serde_reports.rs b/tests/serde_reports.rs new file mode 100644 index 0000000..0a18fe9 --- /dev/null +++ b/tests/serde_reports.rs @@ -0,0 +1,273 @@ +#![cfg(feature = "serde")] + +mod support; + +use std::fmt::Debug; + +use mp4forge::cli::dump::{ + DumpPayloadStatus, FieldStructuredDumpBoxReport, FieldStructuredDumpReport, + StructuredDumpBoxReport, StructuredDumpFieldReport, StructuredDumpReport, +}; +use mp4forge::cli::probe::{ + CodecDetailedProbeReport, CodecDetailedProbeTrackReport, DetailedProbeReport, + DetailedProbeTrackReport, MediaCharacteristicsProbeReport, + MediaCharacteristicsProbeTrackReport, ProbeReport, ProbeTrackReport, +}; +use mp4forge::codec::FieldValue; +use mp4forge::probe::{ + Av1CodecDetails, ColorInfo, DeclaredBitrateInfo, FieldOrderInfo, PixelAspectRatioInfo, + TrackCodecDetails, TrackMediaCharacteristics, +}; + +use support::fourcc; + +#[test] +fn probe_report_types_roundtrip_with_serde_json() { + assert_json_roundtrip(ProbeReport { + major_brand: "isom".to_string(), + minor_version: 512, + compatible_brands: vec!["isom".to_string(), "iso2".to_string()], + fast_start: true, + timescale: 1_000, + duration: 2_000, + duration_seconds: 2.0, + tracks: vec![ProbeTrackReport { + track_id: 1, + timescale: 90_000, + duration: 3_072, + duration_seconds: 0.034133334, + codec: "avc1.64001F".to_string(), + encrypted: false, + width: Some(320), + height: Some(180), + sample_num: Some(3), + chunk_num: Some(2), + idr_frame_num: Some(1), + bitrate: Some(20_000), + max_bitrate: Some(32_000), + }], + }); + + assert_json_roundtrip(DetailedProbeReport { + major_brand: "isom".to_string(), + minor_version: 512, + compatible_brands: vec!["isom".to_string(), "iso8".to_string()], + fast_start: true, + timescale: 1_000, + duration: 2_000, + duration_seconds: 2.0, + tracks: vec![DetailedProbeTrackReport { + track_id: 1, + timescale: 1_000, + duration: 1_000, + duration_seconds: 1.0, + codec: "av01".to_string(), + codec_family: "av1".to_string(), + encrypted: false, + handler_type: Some("vide".to_string()), + language: Some("eng".to_string()), + sample_entry_type: Some("av01".to_string()), + original_format: None, + protection_scheme_type: None, + protection_scheme_version: None, + width: Some(640), + height: Some(360), + channel_count: None, + sample_rate: None, + sample_num: Some(1), + chunk_num: Some(1), + idr_frame_num: None, + bitrate: Some(32_000), + max_bitrate: Some(32_000), + }], + }); + + let codec_report = CodecDetailedProbeReport { + major_brand: "isom".to_string(), + minor_version: 512, + compatible_brands: vec!["isom".to_string(), "iso8".to_string()], + fast_start: true, + timescale: 1_000, + duration: 2_000, + duration_seconds: 2.0, + tracks: vec![CodecDetailedProbeTrackReport { + track_id: 1, + timescale: 1_000, + duration: 1_000, + duration_seconds: 1.0, + codec: "av01".to_string(), + codec_family: "av1".to_string(), + codec_details: TrackCodecDetails::Av1(Av1CodecDetails { + seq_profile: 0, + seq_level_idx_0: 13, + seq_tier_0: 1, + bit_depth: 10, + monochrome: false, + chroma_subsampling_x: 1, + chroma_subsampling_y: 0, + chroma_sample_position: 2, + initial_presentation_delay_minus_one: Some(3), + }), + encrypted: false, + handler_type: Some("vide".to_string()), + language: Some("eng".to_string()), + sample_entry_type: Some("av01".to_string()), + original_format: None, + protection_scheme_type: None, + protection_scheme_version: None, + width: Some(640), + height: Some(360), + channel_count: None, + sample_rate: None, + sample_num: Some(1), + chunk_num: Some(1), + idr_frame_num: None, + bitrate: Some(32_000), + max_bitrate: Some(32_000), + }], + }; + let codec_json = serde_json::to_value(&codec_report).unwrap(); + assert_eq!(codec_json["tracks"][0]["codec_details"]["kind"], "av1"); + assert_eq!( + codec_json["tracks"][0]["codec_details"]["value"]["bit_depth"], + 10 + ); + assert_eq!( + serde_json::from_value::(codec_json).unwrap(), + codec_report + ); + + let media_report = MediaCharacteristicsProbeReport { + major_brand: "isom".to_string(), + minor_version: 512, + compatible_brands: vec!["isom".to_string(), "iso8".to_string()], + fast_start: true, + timescale: 1_000, + duration: 2_000, + duration_seconds: 2.0, + tracks: vec![MediaCharacteristicsProbeTrackReport { + track_id: 1, + timescale: 1_000, + duration: 1_000, + duration_seconds: 1.0, + codec: "avc1.64001F".to_string(), + codec_family: "avc".to_string(), + codec_details: TrackCodecDetails::Unknown, + media_characteristics: TrackMediaCharacteristics { + declared_bitrate: Some(DeclaredBitrateInfo { + buffer_size_db: 32_768, + max_bitrate: 4_000_000, + avg_bitrate: 2_500_000, + }), + color: Some(ColorInfo { + colour_type: fourcc("nclx"), + colour_primaries: Some(9), + transfer_characteristics: Some(16), + matrix_coefficients: Some(9), + full_range: Some(true), + profile_size: None, + unknown_size: None, + }), + pixel_aspect_ratio: Some(PixelAspectRatioInfo { + h_spacing: 4, + v_spacing: 3, + }), + field_order: Some(FieldOrderInfo { + field_count: 2, + field_ordering: 6, + interlaced: true, + }), + }, + encrypted: false, + handler_type: Some("vide".to_string()), + language: Some("eng".to_string()), + sample_entry_type: Some("avc1".to_string()), + original_format: None, + protection_scheme_type: None, + protection_scheme_version: None, + width: Some(640), + height: Some(360), + channel_count: None, + sample_rate: None, + sample_num: Some(1), + chunk_num: Some(1), + idr_frame_num: None, + bitrate: Some(32_000), + max_bitrate: Some(32_000), + }], + }; + let media_json = serde_json::to_value(&media_report).unwrap(); + assert_eq!( + media_json["tracks"][0]["media_characteristics"]["color"]["colour_type"], + "nclx" + ); + assert_eq!( + serde_json::from_value::(media_json).unwrap(), + media_report + ); +} + +#[test] +fn dump_report_types_roundtrip_with_serde_json() { + assert_json_roundtrip(StructuredDumpReport { + boxes: vec![StructuredDumpBoxReport { + box_type: "ftyp".to_string(), + path: "ftyp".to_string(), + offset: 0, + size: 20, + supported: true, + payload_status: DumpPayloadStatus::Summary, + payload_summary: Some("MajorBrand=\"isom\"".to_string()), + payload_bytes: None, + children: Vec::new(), + }], + }); + + let report = FieldStructuredDumpReport { + boxes: vec![FieldStructuredDumpBoxReport { + box_type: "ftyp".to_string(), + path: "ftyp".to_string(), + offset: 0, + size: 20, + supported: true, + payload_status: DumpPayloadStatus::Summary, + payload_fields: vec![ + StructuredDumpFieldReport { + name: "MajorBrand".to_string(), + value: FieldValue::String("isom".to_string()), + display_value: None, + }, + StructuredDumpFieldReport { + name: "CompatibleBrands".to_string(), + value: FieldValue::Bytes(vec![105, 115, 111, 109]), + display_value: Some("[{CompatibleBrand=\"isom\"}]".to_string()), + }, + ], + payload_summary: Some("MajorBrand=\"isom\"".to_string()), + payload_bytes: None, + children: Vec::new(), + }], + }; + let json = serde_json::to_value(&report).unwrap(); + assert_eq!(json["boxes"][0]["payload_status"], "summary"); + assert_eq!( + json["boxes"][0]["payload_fields"][1]["value"]["kind"], + "bytes" + ); + assert_eq!( + json["boxes"][0]["payload_fields"][1]["value"]["value"][0], + 105 + ); + assert_eq!( + serde_json::from_value::(json).unwrap(), + report + ); +} + +fn assert_json_roundtrip(value: T) +where + T: serde::Serialize + serde::de::DeserializeOwned + PartialEq + Debug, +{ + let json = serde_json::to_value(&value).unwrap(); + assert_eq!(serde_json::from_value::(json).unwrap(), value); +}