From c68d095a66a8ce8210fcb2712ccce806d8768ce6 Mon Sep 17 00:00:00 2001 From: / <6137228+NikolaiSachok@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:39:08 +0200 Subject: [PATCH 1/3] media-info-wdx: add Matroska/WebM (.mkv/.webm) support via native EBML parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS system frameworks can't open Matroska/WebM containers — AVURLAsset reports the file unreadable with zero tracks — so DC showed nothing for .mkv files. Add a self-contained EBML reader that pulls dimensions, duration, and frame rate straight from the file head (Segment > Info and Segment > Tracks > TrackEntry > Video), stopping at the first Cluster. Mirrors the existing AVI RIFF reader: no dependency, no network, bounded read, routed to the fast (non-deferred) parse path. .mkv/.webm added to the category table, the compiled DetectString, and register_plugin.py. Verified end-to-end against a real 6h54m 1080p30 .mkv — output matches ffprobe exactly (1920x1080, 30 fps, 24854.809 s). Headless harness grows a synthetic-Matroska case; 30/30 assertions pass. Bump VERSION 0.1.0 -> 0.2.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 10 ++ media-info-wdx/MediaInfo.m | 175 ++++++++++++++++++++++++++++-- media-info-wdx/README.md | 13 ++- media-info-wdx/register_plugin.py | 2 +- media-info-wdx/test/test_host.m | 64 +++++++++++ 5 files changed, 248 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a67deca..7acada4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ independently and tagged below. ## [Unreleased] +## media-info-wdx 0.2.0 — 2026-07-04 + +### Added +- **media-info-wdx:** **Matroska / WebM support** (`.mkv`, `.webm`). macOS system + frameworks can't open these EBML containers, so a self-contained EBML parser + reads dimensions, duration, and frame rate directly from the file head — + mirroring the existing AVI RIFF reader, with no dependency and no network. Both + extensions are added to the category table and the DetectString, and routed to + the fast (non-deferred) parse path. + ## media-info-wdx 0.1.0 — 2026-06-24 ### Added diff --git a/media-info-wdx/MediaInfo.m b/media-info-wdx/MediaInfo.m index d9aed12..f711531 100644 --- a/media-info-wdx/MediaInfo.m +++ b/media-info-wdx/MediaInfo.m @@ -27,7 +27,7 @@ #include "contplug.h" -#define MI_VERSION "0.1.0" +#define MI_VERSION "0.2.0" /* ---- Field table -------------------------------------------------------- */ @@ -94,8 +94,10 @@ static MICategory CategoryForPath(NSString *path) { @"dng",@"cr2",@"cr3",@"nef",@"arw",@"orf",@"rw2",@"raf",@"sr2",@"pef" ]]; aud = [NSSet setWithArray:@[ @"mp3",@"m4a",@"aac",@"wav",@"aiff",@"aif", @"aifc",@"caf" ]]; - /* avi is read by our own RIFF parser; the rest go through AVFoundation. */ - vid = [NSSet setWithArray:@[ @"mp4",@"mov",@"m4v",@"3gp",@"3g2",@"avi" ]]; + /* avi/mkv/webm are read by our own container parsers (macOS frameworks + can't open them); the rest go through AVFoundation. */ + vid = [NSSet setWithArray:@[ @"mp4",@"mov",@"m4v",@"3gp",@"3g2", + @"avi",@"mkv",@"webm" ]]; }); if ([img containsObject:ext]) return CAT_IMAGE; if ([aud containsObject:ext]) return CAT_AUDIO; @@ -114,7 +116,8 @@ static MICategory CategoryForPath(NSString *path) { "(EXT=\"ORF\")|(EXT=\"RW2\")|(EXT=\"RAF\")|(EXT=\"SR2\")|(EXT=\"PEF\")|" "(EXT=\"MP3\")|(EXT=\"M4A\")|(EXT=\"AAC\")|(EXT=\"WAV\")|(EXT=\"AIFF\")|" "(EXT=\"AIF\")|(EXT=\"AIFC\")|(EXT=\"CAF\")|(EXT=\"MP4\")|(EXT=\"MOV\")|" - "(EXT=\"M4V\")|(EXT=\"3GP\")|(EXT=\"3G2\")|(EXT=\"AVI\")|(EXT=\"PDF\")"; + "(EXT=\"M4V\")|(EXT=\"3GP\")|(EXT=\"3G2\")|(EXT=\"AVI\")|(EXT=\"MKV\")|" + "(EXT=\"WEBM\")|(EXT=\"PDF\")"; /* ---- Parsed-info value object + cache ----------------------------------- */ @@ -273,6 +276,148 @@ static uint32_t RdLE32(const uint8_t *p) { return info; } +/* Matroska / WebM is an EBML container that AVFoundation can't open on macOS. + Its Segment > Info (Duration, TimecodeScale) and Segment > Tracks > TrackEntry + > Video (PixelWidth/Height) elements carry everything we need, and they always + precede the media Clusters — so a bounded read of the file head is enough. + EBML basics: every element is an ID (variable 1-4 bytes, marker bits kept) then + a size VINT (1-8 bytes, marker stripped) then data. */ + +/* Read one element at *pp within [*pp, end). On success sets id/data ptr/data + len and advances *pp past the element; returns 1. Returns 0 to stop. */ +static int MKVNext(const uint8_t **pp, const uint8_t *end, + uint32_t *id, const uint8_t **dp, uint64_t *dlen) { + const uint8_t *p = *pp; + if (p >= end) return 0; + + uint8_t f = p[0]; + int idn = (f & 0x80) ? 1 : (f & 0x40) ? 2 : (f & 0x20) ? 3 : (f & 0x10) ? 4 : 0; + if (idn == 0 || p + idn > end) return 0; + uint32_t eid = 0; + for (int i = 0; i < idn; i++) eid = (eid << 8) | p[i]; + const uint8_t *q = p + idn; + if (q >= end) return 0; + + uint8_t s = q[0]; + int sn = 0; uint8_t smask = 0; + for (int b = 0; b < 8; b++) { + if (s & (0x80 >> b)) { sn = b + 1; smask = (uint8_t)(0xFF >> (b + 1)); break; } + } + if (sn == 0 || q + sn > end) return 0; + uint64_t size = (uint64_t)(s & smask); + int allOnes = ((s & smask) == smask); + for (int i = 1; i < sn; i++) { size = (size << 8) | q[i]; if (q[i] != 0xFF) allOnes = 0; } + + const uint8_t *d = q + sn; + uint64_t avail = (uint64_t)(end - d); + uint64_t dl = allOnes ? avail : (size > avail ? avail : size); /* unknown size -> to buffer end */ + *id = eid; *dp = d; *dlen = dl; + *pp = d + dl; /* truncated/unknown -> == end -> loop stops */ + return 1; +} + +static uint64_t MKVUInt(const uint8_t *p, uint64_t n) { + uint64_t v = 0; + for (uint64_t i = 0; i < n && i < 8; i++) v = (v << 8) | p[i]; + return v; +} + +static double MKVFloat(const uint8_t *p, uint64_t n) { + if (n == 4) { uint32_t u = (uint32_t)MKVUInt(p, 4); float f; memcpy(&f, &u, 4); return f; } + if (n == 8) { uint64_t u = MKVUInt(p, 8); double d; memcpy(&d, &u, 8); return d; } + return 0; +} + +typedef struct { + double timecodeScale; /* ns per Duration unit (Matroska default 1e6) */ + double duration; /* in timecodeScale units */ + uint64_t width, height; /* first video track's coded pixel size */ + uint64_t defDurNs; /* first video track's per-frame duration, ns */ +} MKVState; + +static void MKVWalk(const uint8_t *p, const uint8_t *end, int depth, MKVState *s) { + if (depth > 4) return; + uint32_t id; const uint8_t *dp; uint64_t dl; + while (MKVNext(&p, end, &id, &dp, &dl)) { + switch (id) { + case 0x18538067: /* Segment */ + case 0x1549A966: /* Info */ + case 0x1654AE6B: /* Tracks */ + MKVWalk(dp, dp + dl, depth + 1, s); + break; + case 0xAE: { /* TrackEntry: isolate per-track fields */ + const uint8_t *tp = dp, *te = dp + dl; + uint32_t tid; const uint8_t *tdp; uint64_t tdl; + uint64_t w = 0, h = 0, defDur = 0, type = 0; int hasVideo = 0; + while (MKVNext(&tp, te, &tid, &tdp, &tdl)) { + if (tid == 0x83) type = MKVUInt(tdp, tdl); /* TrackType (1=video) */ + else if (tid == 0x23E383) defDur = MKVUInt(tdp, tdl); /* DefaultDuration ns */ + else if (tid == 0xE0) { /* Video */ + hasVideo = 1; + const uint8_t *vp = tdp, *ve = tdp + tdl; + uint32_t vid; const uint8_t *vdp; uint64_t vdl; + while (MKVNext(&vp, ve, &vid, &vdp, &vdl)) { + if (vid == 0xB0) w = MKVUInt(vdp, vdl); /* PixelWidth */ + else if (vid == 0xBA) h = MKVUInt(vdp, vdl); /* PixelHeight */ + } + } + } + if ((hasVideo || type == 1) && s->width == 0 && w > 0 && h > 0) { + s->width = w; s->height = h; s->defDurNs = defDur; + } + break; + } + case 0x2AD7B1: s->timecodeScale = (double)MKVUInt(dp, dl); break; /* TimecodeScale */ + case 0x4489: s->duration = MKVFloat(dp, dl); break; /* Duration */ + case 0x1F43B675: return; /* Cluster: media data begins, nothing useful past here */ + default: break; + } + } +} + +static MIInfo *ParseMKV(NSURL *url) { + NSFileHandle *fh = [NSFileHandle fileHandleForReadingFromURL:url error:nil]; + if (!fh) return nil; + NSData *data = [fh readDataOfLength:2 * 1024 * 1024]; /* Info+Tracks precede Clusters */ + [fh closeFile]; + if (data.length < 4) return nil; + + const uint8_t *b = data.bytes; + if (!(b[0] == 0x1A && b[1] == 0x45 && b[2] == 0xDF && b[3] == 0xA3)) return nil; /* EBML */ + + MKVState s = { .timecodeScale = 1.0e6 }; + MKVWalk(b, b + data.length, 0, &s); + if (s.timecodeScale <= 0) s.timecodeScale = 1.0e6; + + NSMutableDictionary *v = [NSMutableDictionary dictionary]; + NSString *dims = nil; + if (s.width > 0 && s.height > 0 && s.width <= 100000 && s.height <= 100000) { + v[@(F_WIDTH)] = @((int)s.width); + v[@(F_HEIGHT)] = @((int)s.height); + dims = [NSString stringWithFormat:@"%llu × %llu", + (unsigned long long)s.width, (unsigned long long)s.height]; + v[@(F_DIMENSIONS)] = dims; + } + double secs = s.duration * s.timecodeScale / 1.0e9; + NSString *durStr = (secs > 0) ? FormatDuration(secs) : nil; + if (durStr) { + v[@(F_DURATION)] = durStr; + v[@(F_DURATIONSECS)] = @(round(secs * 10.0) / 10.0); + } + if (s.defDurNs > 0) + v[@(F_FRAMERATE)] = @(round(1.0e9 / (double)s.defDurNs * 100.0) / 100.0); + + if (dims && durStr) v[@(F_SUMMARY)] = [NSString stringWithFormat:@"%@ · %@", dims, durStr]; + else if (dims) v[@(F_SUMMARY)] = dims; + else if (durStr) v[@(F_SUMMARY)] = durStr; + + if (v.count == 0) return nil; + MIInfo *info = [MIInfo new]; + info.category = CAT_VIDEO; + info.values = v; + return info; +} + #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wdeprecated-declarations" static MIInfo *ParseAV(NSURL *url, MICategory hint) { @@ -355,6 +500,14 @@ static uint32_t RdLE32(const uint8_t *p) { } #pragma clang diagnostic pop +/* Video containers macOS frameworks can't open, read by our own parsers. + These are fast (a bounded header read), unlike the AVFoundation path. */ +static BOOL IsOwnParsedVideo(NSString *ext) { + return [ext isEqualToString:@"avi"] || + [ext isEqualToString:@"mkv"] || + [ext isEqualToString:@"webm"]; +} + /* Parse with caching keyed by path + mtime. Returns a (possibly empty) MIInfo so repeated probes of an unreadable file don't re-parse. */ static MIInfo *InfoForPath(NSString *path, MICategory cat) { @@ -383,8 +536,12 @@ static uint32_t RdLE32(const uint8_t *p) { switch (cat) { case CAT_IMAGE: info = ParseImage(url); break; case CAT_AUDIO: info = ParseAV(url, cat); break; - case CAT_VIDEO: info = [ext isEqualToString:@"avi"] - ? ParseAVI(url) : ParseAV(url, cat); break; + case CAT_VIDEO: + if ([ext isEqualToString:@"avi"]) info = ParseAVI(url); + else if ([ext isEqualToString:@"mkv"] || + [ext isEqualToString:@"webm"]) info = ParseMKV(url); + else info = ParseAV(url, cat); + break; case CAT_PDF: info = ParsePDF(url); break; default: break; } @@ -441,10 +598,10 @@ DLLEXPORT int __stdcall ContentGetValue(char *fileName, int field, int unit, if (cat == CAT_OTHER) return ft_fieldempty; /* AVFoundation parsing is the only slow path; defer it off the UI - thread when DC asks us to. (Our own AVI/RIFF reader is fast.) */ + thread when DC asks us to. (Our own AVI/MKV parsers are fast.) */ BOOL slow = (cat == CAT_AUDIO) || - (cat == CAT_VIDEO && ![path.pathExtension.lowercaseString - isEqualToString:@"avi"]); + (cat == CAT_VIDEO && + !IsOwnParsedVideo(path.pathExtension.lowercaseString)); if (slow && (flags & CONTENT_DELAYIFSLOW)) { struct stat st; long mtime = (stat(path.fileSystemRepresentation, &st) == 0) diff --git a/media-info-wdx/README.md b/media-info-wdx/README.md index e07c1da..e2e6bfb 100644 --- a/media-info-wdx/README.md +++ b/media-info-wdx/README.md @@ -17,6 +17,7 @@ network**: | **Audio** (mp3, m4a, aac, wav, aiff, caf) | Duration, Bitrate, Sample rate, Channels, Audio codec | **AVFoundation** | | **Video** (mp4, mov, m4v, 3gp) | Dimensions, Duration, Frame rate, Bitrate, Video/Audio codec | **AVFoundation** | | **Video** (avi) | Dimensions, Duration, Frame rate | self-contained **RIFF `avih`** reader (AVFoundation can't open AVI on macOS) | +| **Video** (mkv, webm) | Dimensions, Duration, Frame rate | self-contained **EBML** reader (AVFoundation can't open Matroska on macOS) | | **PDF** | Page count | **CoreGraphics (CGPDF)** | ## The `Summary` field @@ -107,20 +108,20 @@ which build is loaded (it's also embedded in the binary: ## Supported extensions -Only formats a system framework can actually read (so a column is never silently -blank for a "supported" type): +Only formats that either a system framework or a built-in parser can actually read +(so a column is never silently blank for a "supported" type): ``` images: jpg jpeg png gif tiff tif bmp webp heic heif avif ico icns psd jp2 dng cr2 cr3 nef arw orf rw2 raf sr2 pef audio: mp3 m4a aac wav aiff aif aifc caf -video: mp4 mov m4v 3gp 3g2 avi +video: mp4 mov m4v 3gp 3g2 avi mkv webm pdf: pdf ``` -`.avi` is read by a built-in RIFF parser. Other containers neither AVFoundation nor -that parser handle (e.g. `.mkv`, `.webm`, `.flv`) are intentionally left out rather -than shown as empty. +`.avi` is read by a built-in RIFF parser and `.mkv` / `.webm` by a built-in EBML +parser (AVFoundation opens neither on macOS). Other containers no framework or +parser handles (e.g. `.flv`) are intentionally left out rather than shown as empty. ## Uninstall diff --git a/media-info-wdx/register_plugin.py b/media-info-wdx/register_plugin.py index 51b25f5..ae706f1 100755 --- a/media-info-wdx/register_plugin.py +++ b/media-info-wdx/register_plugin.py @@ -18,7 +18,7 @@ "AVIF", "ICO", "ICNS", "PSD", "JP2", "DNG", "CR2", "CR3", "NEF", "ARW", "ORF", "RW2", "RAF", "SR2", "PEF", "MP3", "M4A", "AAC", "WAV", "AIFF", "AIF", "AIFC", "CAF", - "MP4", "MOV", "M4V", "3GP", "3G2", "AVI", + "MP4", "MOV", "M4V", "3GP", "3G2", "AVI", "MKV", "WEBM", "PDF", ]) diff --git a/media-info-wdx/test/test_host.m b/media-info-wdx/test/test_host.m index b73cbf4..14d28a2 100644 --- a/media-info-wdx/test/test_host.m +++ b/media-info-wdx/test/test_host.m @@ -133,6 +133,60 @@ static double getFloat(const char *path, NSString *field) { return path; } +/* EBML helpers: element = ID (bytes as-is) + size VINT + data. We keep sizes + small enough that a single-byte size VINT (0x80 | len) always suffices. */ +static void ebmlEl(NSMutableData *out, const uint8_t *id, int idn, NSData *body) { + [out appendBytes:id length:idn]; + uint8_t sz = (uint8_t)(0x80 | (uint8_t)body.length); /* len < 128 here */ + [out appendBytes:&sz length:1]; + [out appendData:body]; +} +static NSData *ebmlUInt(uint64_t v) { + uint8_t b[8]; int n = 0; uint64_t t = v; + do { b[n++] = 0; t >>= 8; } while (t); /* count bytes */ + NSMutableData *d = [NSMutableData dataWithLength:n]; + uint8_t *p = d.mutableBytes; + for (int i = n - 1; i >= 0; i--) { p[i] = (uint8_t)(v & 0xFF); v >>= 8; } + return d; +} +static NSData *ebmlF64(double v) { + uint64_t u; memcpy(&u, &v, 8); + uint8_t b[8]; for (int i = 0; i < 8; i++) b[i] = (uint8_t)(u >> (56 - 8 * i)); + return [NSData dataWithBytes:b length:8]; +} + +static NSString *makeMKV(NSString *dir) { + /* Minimal Matroska: 1920x1080 video track, 3.2s (TimecodeScale 1e6 ns, + Duration 3200 units), no clusters — exercises the EBML head parser. */ + NSMutableData *file = [NSMutableData data]; + const uint8_t EBMLHDR[4] = {0x1A,0x45,0xDF,0xA3}; + ebmlEl(file, EBMLHDR, 4, [NSData data]); /* empty EBML header */ + + /* Info */ + NSMutableData *info = [NSMutableData data]; + { const uint8_t id[3] = {0x2A,0xD7,0xB1}; ebmlEl(info, id, 3, ebmlUInt(1000000)); } /* TimecodeScale */ + { const uint8_t id[2] = {0x44,0x89}; ebmlEl(info, id, 2, ebmlF64(3200.0)); } /* Duration */ + + /* Tracks > TrackEntry > Video(PixelWidth/Height) + TrackType=1 */ + NSMutableData *video = [NSMutableData data]; + { const uint8_t id[1] = {0xB0}; ebmlEl(video, id, 1, ebmlUInt(1920)); } + { const uint8_t id[1] = {0xBA}; ebmlEl(video, id, 1, ebmlUInt(1080)); } + NSMutableData *track = [NSMutableData data]; + { const uint8_t id[1] = {0x83}; ebmlEl(track, id, 1, ebmlUInt(1)); } /* TrackType video */ + { const uint8_t id[1] = {0xE0}; ebmlEl(track, id, 1, video); } + NSMutableData *tracks = [NSMutableData data]; + { const uint8_t id[1] = {0xAE}; ebmlEl(tracks, id, 1, track); } + + NSMutableData *seg = [NSMutableData data]; + { const uint8_t id[4] = {0x15,0x49,0xA9,0x66}; ebmlEl(seg, id, 4, info); } + { const uint8_t id[4] = {0x16,0x54,0xAE,0x6B}; ebmlEl(seg, id, 4, tracks); } + { const uint8_t id[4] = {0x18,0x53,0x80,0x67}; ebmlEl(file, id, 4, seg); } + + NSString *path = [dir stringByAppendingPathComponent:@"sample.mkv"]; + [file writeToFile:path atomically:YES]; + return path; +} + int main(int argc, char **argv) { @autoreleasepool { if (argc < 2) { fprintf(stderr, "usage: test_host \n"); return 2; } @@ -202,6 +256,16 @@ int main(int argc, char **argv) { check([getStr(avi, @"Summary") isEqualToString:@"320 × 240 · 0:02"], ([NSString stringWithFormat:@"AVI Summary = '%@'", getStr(avi, @"Summary")])); + /* ---- video: MKV/WebM via our own EBML parser ---- */ + const char *mkv = makeMKV(dir).fileSystemRepresentation; + check(getInt(mkv, @"Width") == 1920, @"MKV Width = 1920"); + check(getInt(mkv, @"Height") == 1080, @"MKV Height = 1080"); + check(fabs(getFloat(mkv, @"Duration (s)") - 3.2) < 0.05, + ([NSString stringWithFormat:@"MKV Duration (s) = %.2f (want 3.2)", + getFloat(mkv, @"Duration (s)")])); + check([getStr(mkv, @"Summary") isEqualToString:@"1920 × 1080 · 0:03"], + ([NSString stringWithFormat:@"MKV Summary = '%@'", getStr(mkv, @"Summary")])); + /* ---- regression: survive DC's FP-exception traps (the RawCamera crash) ---- Double Commander (Lazarus/FPC) enables FP-exception traps; Apple media frameworks can raise them. The plugin must mask them during parsing and From aa2fc2e2fcad7b07de86374dbad582124c46e48e Mon Sep 17 00:00:00 2001 From: / <6137228+NikolaiSachok@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:57:07 +0200 Subject: [PATCH 2/3] media-info-wdx: address code-review findings on the MKV parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes from the fresh-agent /code-review of the EBML parser: - Bound FormatDuration (>~100y rejected) so a corrupt/hostile Matroska Duration float can't reach an out-of-range llround (undefined) and emit a garbage time string. Protects every parser path. - Rewrite ParseMKV as a *seeking* reader: it walks the Segment's children by element header, reading only the small Info/Tracks bodies and skipping past large SeekHead/Cues/Attachments. Removes the 2MB read cap that both (a) permanently cached empty metadata for spec-valid files whose Tracks fell past the window and (b) did a synchronous 2MB read on DC's calling thread. Now a few KB regardless of file size or layout. - Prefer DisplayWidth/Height (aspect-correct) over coded PixelWidth/Height, matching what a player and the AVFoundation path report for anamorphic video; parse the audio track and CodecID too, so mkv/webm now expose video/audio codec, sample rate, and channels (audio-only .webm included). - Extract FillVideoFields, shared by the AVI and MKV readers, removing the triplicated dimensions/duration/frame-rate/Summary assembly. Harness grows anamorphic (display != coded), audio-track/codec, and hostile-duration cases: 37/37 pass. Re-verified end-to-end against the real 6h54m .mkv — resolution, fps, duration, and now codec/48kHz/stereo all match ffprobe exactly. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 9 +- media-info-wdx/MediaInfo.m | 303 +++++++++++++++++++++++--------- media-info-wdx/README.md | 2 +- media-info-wdx/test/test_host.m | 83 +++++++-- 4 files changed, 304 insertions(+), 93 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7acada4..3bf3d5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,13 @@ independently and tagged below. ### Added - **media-info-wdx:** **Matroska / WebM support** (`.mkv`, `.webm`). macOS system frameworks can't open these EBML containers, so a self-contained EBML parser - reads dimensions, duration, and frame rate directly from the file head — - mirroring the existing AVI RIFF reader, with no dependency and no network. Both + reads dimensions (aspect-correct display size), duration, frame rate, video/audio + codecs, sample rate, and channels directly from the file — mirroring the existing + AVI RIFF reader, with no dependency and no network. The parser *seeks* over the + Segment's children (reading only element headers plus the small Info/Tracks + bodies, skipping past large SeekHead/Cues/Attachments), so it works in a few KB + regardless of file size and never misses a file whose `Tracks` sits past a fixed + byte window. Duration is bounds-checked against corrupt/hostile input. Both extensions are added to the category table and the DetectString, and routed to the fast (non-deferred) parse path. diff --git a/media-info-wdx/MediaInfo.m b/media-info-wdx/MediaInfo.m index f711531..5dd1dba 100644 --- a/media-info-wdx/MediaInfo.m +++ b/media-info-wdx/MediaInfo.m @@ -23,6 +23,8 @@ #import #import #import +#import +#import #import #include "contplug.h" @@ -133,7 +135,10 @@ @implementation MIInfo /* ---- Formatting helpers ------------------------------------------------- */ static NSString *FormatDuration(double secs) { - if (!isfinite(secs) || secs < 0) return nil; + /* Reject non-finite, negative, and absurd (>~100 years) values: the last + guards against a corrupt/hostile container feeding an out-of-range double + into llround, which is otherwise undefined. */ + if (!isfinite(secs) || secs < 0 || secs > 3.15e9) return nil; long t = (long)llround(secs); long h = t / 3600, m = (t % 3600) / 60, s = t % 60; if (h > 0) return [NSString stringWithFormat:@"%ld:%02ld:%02ld", h, m, s]; @@ -220,6 +225,32 @@ @implementation MIInfo return info; } +/* Populate the common video fields (dimensions, duration, frame rate) and the + adaptive Summary from raw numbers. Shared by every container that yields plain + width/height/seconds/fps (our AVI and Matroska readers) so the field assembly + and Summary format live in one place. Any of the inputs may be 0/absent. */ +static void FillVideoFields(NSMutableDictionary *v, long w, long h, + double secs, double fps) { + NSString *dims = nil; + if (w > 0 && h > 0 && w <= 100000 && h <= 100000) { + v[@(F_WIDTH)] = @((int)w); + v[@(F_HEIGHT)] = @((int)h); + dims = [NSString stringWithFormat:@"%ld × %ld", w, h]; + v[@(F_DIMENSIONS)] = dims; + } + NSString *durStr = (secs > 0) ? FormatDuration(secs) : nil; + if (durStr) { + v[@(F_DURATION)] = durStr; + v[@(F_DURATIONSECS)] = @(round(secs * 10.0) / 10.0); + } + if (fps > 0) + v[@(F_FRAMERATE)] = @(round(fps * 100.0) / 100.0); + + if (dims && durStr) v[@(F_SUMMARY)] = [NSString stringWithFormat:@"%@ · %@", dims, durStr]; + else if (dims) v[@(F_SUMMARY)] = dims; + else if (durStr) v[@(F_SUMMARY)] = durStr; +} + /* AVI is a RIFF format AVFoundation can't open on macOS, but its main header ('avih') carries dimensions and frame timing directly — read it ourselves. */ static uint32_t RdLE32(const uint8_t *p) { @@ -248,26 +279,10 @@ static uint32_t RdLE32(const uint8_t *p) { uint32_t hgt = RdLE32(h + 36); if (w > 100000 || hgt > 100000) return nil; /* sanity */ - NSMutableDictionary *v = [NSMutableDictionary dictionary]; - NSString *dims = nil; - if (w > 0 && hgt > 0) { - v[@(F_WIDTH)] = @(w); - v[@(F_HEIGHT)] = @(hgt); - dims = [NSString stringWithFormat:@"%u × %u", w, hgt]; - v[@(F_DIMENSIONS)] = dims; - } double secs = (double)usecPerFrame * (double)totalFrames / 1.0e6; - NSString *durStr = (secs > 0) ? FormatDuration(secs) : nil; - if (durStr) { - v[@(F_DURATION)] = durStr; - v[@(F_DURATIONSECS)] = @(round(secs * 10.0) / 10.0); - } - if (usecPerFrame > 0) - v[@(F_FRAMERATE)] = @(round(1.0e6 / (double)usecPerFrame * 100.0) / 100.0); - - if (dims && durStr) v[@(F_SUMMARY)] = [NSString stringWithFormat:@"%@ · %@", dims, durStr]; - else if (dims) v[@(F_SUMMARY)] = dims; - else if (durStr) v[@(F_SUMMARY)] = durStr; + double fps = (usecPerFrame > 0) ? 1.0e6 / (double)usecPerFrame : 0; + NSMutableDictionary *v = [NSMutableDictionary dictionary]; + FillVideoFields(v, w, hgt, secs, fps); if (v.count == 0) return nil; MIInfo *info = [MIInfo new]; @@ -278,8 +293,12 @@ static uint32_t RdLE32(const uint8_t *p) { /* Matroska / WebM is an EBML container that AVFoundation can't open on macOS. Its Segment > Info (Duration, TimecodeScale) and Segment > Tracks > TrackEntry - > Video (PixelWidth/Height) elements carry everything we need, and they always - precede the media Clusters — so a bounded read of the file head is enough. + (Video/Audio/CodecID) elements carry everything we need, and they precede the + media Clusters. We *seek* over the Segment's children — reading only each + element's short header, then the small Info/Tracks bodies in full, and skipping + past large siblings (SeekHead, Cues, Attachments) without reading them. So the + work is a few KB regardless of file size or where Tracks sits, and we never miss + a spec-valid file whose Tracks happens to fall past a fixed byte window. EBML basics: every element is an ID (variable 1-4 bytes, marker bits kept) then a size VINT (1-8 bytes, marker stripped) then data. */ @@ -328,92 +347,218 @@ static double MKVFloat(const uint8_t *p, uint64_t n) { return 0; } +/* Matroska CodecID (an ASCII string) -> a friendly name, matching the vocabulary + the AVFoundation path already uses. Unknown codecs return nil (blank) rather + than exposing a raw "V_MPEG4/ISO/AVC"-style token. */ +static NSString *MKVCodecName(const char *cid) { + if (!cid || !cid[0]) return nil; + if (!strncmp(cid, "V_MPEG4/ISO/AVC", 15)) return @"H.264"; + if (!strncmp(cid, "V_MPEGH/ISO/HEVC", 16)) return @"HEVC"; + if (!strncmp(cid, "V_MPEG4", 7)) return @"MPEG-4"; + if (!strncmp(cid, "V_MPEG2", 7)) return @"MPEG-2"; + if (!strncmp(cid, "V_MPEG1", 7)) return @"MPEG-1"; + if (!strcmp (cid, "V_VP8")) return @"VP8"; + if (!strcmp (cid, "V_VP9")) return @"VP9"; + if (!strcmp (cid, "V_AV1")) return @"AV1"; + if (!strncmp(cid, "V_THEORA", 8)) return @"Theora"; + if (!strncmp(cid, "A_OPUS", 6)) return @"Opus"; + if (!strncmp(cid, "A_VORBIS", 8)) return @"Vorbis"; + if (!strncmp(cid, "A_AAC", 5)) return @"AAC"; + if (!strncmp(cid, "A_FLAC", 6)) return @"FLAC"; + if (!strncmp(cid, "A_MPEG/L3", 9)) return @"MP3"; + if (!strncmp(cid, "A_MPEG/L2", 9)) return @"MP2"; + if (!strncmp(cid, "A_AC3", 5)) return @"AC-3"; + if (!strncmp(cid, "A_EAC3", 6)) return @"E-AC-3"; + if (!strncmp(cid, "A_DTS", 5)) return @"DTS"; + if (!strncmp(cid, "A_TRUEHD", 8)) return @"TrueHD"; + if (!strncmp(cid, "A_PCM", 5)) return @"PCM"; + return nil; +} + typedef struct { - double timecodeScale; /* ns per Duration unit (Matroska default 1e6) */ - double duration; /* in timecodeScale units */ - uint64_t width, height; /* first video track's coded pixel size */ - uint64_t defDurNs; /* first video track's per-frame duration, ns */ + double timecodeScale; /* ns per Duration unit (Matroska default 1e6) */ + double duration; /* in timecodeScale units */ + uint64_t width, height; /* first video track's display size (aspect-correct) */ + uint64_t defDurNs; /* first video track's per-frame duration, ns */ + char videoCodec[40]; /* first video track's CodecID */ + int hasAudio; + double audioRate; /* first audio track sampling frequency, Hz */ + uint64_t audioChannels; + char audioCodec[40]; /* first audio track's CodecID */ } MKVState; -static void MKVWalk(const uint8_t *p, const uint8_t *end, int depth, MKVState *s) { - if (depth > 4) return; +static void MKVCopyStr(char *dst, size_t cap, const uint8_t *src, uint64_t n) { + uint64_t m = (n < cap - 1) ? n : cap - 1; + memcpy(dst, src, (size_t)m); + dst[m] = 0; +} + +/* Walk the children of one TrackEntry (already isolated to [p, end)). */ +static void MKVParseTrackEntry(const uint8_t *p, const uint8_t *end, MKVState *s) { uint32_t id; const uint8_t *dp; uint64_t dl; + uint64_t px = 0, py = 0, dispx = 0, dispy = 0, defDur = 0, chans = 0, type = 0; + double rate = 0; int hasVideo = 0, hasAudio = 0; + char codec[40] = {0}; while (MKVNext(&p, end, &id, &dp, &dl)) { switch (id) { - case 0x18538067: /* Segment */ - case 0x1549A966: /* Info */ - case 0x1654AE6B: /* Tracks */ - MKVWalk(dp, dp + dl, depth + 1, s); - break; - case 0xAE: { /* TrackEntry: isolate per-track fields */ - const uint8_t *tp = dp, *te = dp + dl; - uint32_t tid; const uint8_t *tdp; uint64_t tdl; - uint64_t w = 0, h = 0, defDur = 0, type = 0; int hasVideo = 0; - while (MKVNext(&tp, te, &tid, &tdp, &tdl)) { - if (tid == 0x83) type = MKVUInt(tdp, tdl); /* TrackType (1=video) */ - else if (tid == 0x23E383) defDur = MKVUInt(tdp, tdl); /* DefaultDuration ns */ - else if (tid == 0xE0) { /* Video */ - hasVideo = 1; - const uint8_t *vp = tdp, *ve = tdp + tdl; - uint32_t vid; const uint8_t *vdp; uint64_t vdl; - while (MKVNext(&vp, ve, &vid, &vdp, &vdl)) { - if (vid == 0xB0) w = MKVUInt(vdp, vdl); /* PixelWidth */ - else if (vid == 0xBA) h = MKVUInt(vdp, vdl); /* PixelHeight */ - } - } + case 0x83: type = MKVUInt(dp, dl); break; /* TrackType 1=video 2=audio */ + case 0x23E383: defDur = MKVUInt(dp, dl); break; /* DefaultDuration ns */ + case 0x86: MKVCopyStr(codec, sizeof codec, dp, dl); break; /* CodecID */ + case 0xE0: { /* Video */ + hasVideo = 1; + const uint8_t *vp = dp, *ve = dp + dl; + uint32_t vid; const uint8_t *vdp; uint64_t vdl; + while (MKVNext(&vp, ve, &vid, &vdp, &vdl)) { + if (vid == 0xB0) px = MKVUInt(vdp, vdl); /* PixelWidth */ + else if (vid == 0xBA) py = MKVUInt(vdp, vdl); /* PixelHeight */ + else if (vid == 0x54B0) dispx = MKVUInt(vdp, vdl); /* DisplayWidth */ + else if (vid == 0x54BA) dispy = MKVUInt(vdp, vdl); /* DisplayHeight */ } - if ((hasVideo || type == 1) && s->width == 0 && w > 0 && h > 0) { - s->width = w; s->height = h; s->defDurNs = defDur; + break; + } + case 0xE1: { /* Audio */ + hasAudio = 1; + const uint8_t *ap = dp, *ae = dp + dl; + uint32_t aid; const uint8_t *adp; uint64_t adl; + while (MKVNext(&ap, ae, &aid, &adp, &adl)) { + if (aid == 0xB5) rate = MKVFloat(adp, adl); /* SamplingFrequency */ + else if (aid == 0x9F) chans = MKVUInt(adp, adl); /* Channels */ } break; } + default: break; + } + } + if ((hasVideo || type == 1) && s->width == 0) { + /* Prefer the aspect-correct display size (matches what a player shows and + what the AVFoundation path reports); fall back to the coded size. */ + uint64_t w = (dispx > 0) ? dispx : px; + uint64_t h = (dispy > 0) ? dispy : py; + if (w > 0 && h > 0) { + s->width = w; s->height = h; s->defDurNs = defDur; + MKVCopyStr(s->videoCodec, sizeof s->videoCodec, (const uint8_t *)codec, strlen(codec)); + } + } else if ((hasAudio || type == 2) && !s->hasAudio) { + s->hasAudio = 1; s->audioRate = rate; s->audioChannels = chans; + MKVCopyStr(s->audioCodec, sizeof s->audioCodec, (const uint8_t *)codec, strlen(codec)); + } +} + +/* Walk an in-memory Info or Tracks body, harvesting the fields we care about. */ +static void MKVParseBody(const uint8_t *p, const uint8_t *end, MKVState *s) { + uint32_t id; const uint8_t *dp; uint64_t dl; + while (MKVNext(&p, end, &id, &dp, &dl)) { + switch (id) { case 0x2AD7B1: s->timecodeScale = (double)MKVUInt(dp, dl); break; /* TimecodeScale */ case 0x4489: s->duration = MKVFloat(dp, dl); break; /* Duration */ - case 0x1F43B675: return; /* Cluster: media data begins, nothing useful past here */ + case 0xAE: MKVParseTrackEntry(dp, dp + dl, s); break; /* TrackEntry */ default: break; } } } +/* Read one element header at file offset `off`. On success sets id, the file + offset of its data, its declared data size, and whether the size is the EBML + "unknown" sentinel; returns 1. Reads at most a 12-byte header via pread. */ +static int MKVFileHeader(int fd, off_t off, uint32_t *id, off_t *dataOff, + uint64_t *size, int *unknown) { + uint8_t h[12]; + ssize_t got = pread(fd, h, sizeof h, off); + if (got < 2) return 0; + const uint8_t *end = h + got; + + uint8_t f = h[0]; + int idn = (f & 0x80) ? 1 : (f & 0x40) ? 2 : (f & 0x20) ? 3 : (f & 0x10) ? 4 : 0; + if (idn == 0 || h + idn > end) return 0; + uint32_t eid = 0; + for (int i = 0; i < idn; i++) eid = (eid << 8) | h[i]; + + const uint8_t *q = h + idn; + if (q >= end) return 0; + uint8_t sb = q[0]; + int sn = 0; uint8_t smask = 0; + for (int b = 0; b < 8; b++) { + if (sb & (0x80 >> b)) { sn = b + 1; smask = (uint8_t)(0xFF >> (b + 1)); break; } + } + if (sn == 0 || q + sn > end) return 0; + uint64_t sz = (uint64_t)(sb & smask); + int allOnes = ((sb & smask) == smask); + for (int i = 1; i < sn; i++) { sz = (sz << 8) | q[i]; if (q[i] != 0xFF) allOnes = 0; } + + *id = eid; *dataOff = off + idn + sn; *size = sz; if (unknown) *unknown = allOnes; + return 1; +} + static MIInfo *ParseMKV(NSURL *url) { - NSFileHandle *fh = [NSFileHandle fileHandleForReadingFromURL:url error:nil]; - if (!fh) return nil; - NSData *data = [fh readDataOfLength:2 * 1024 * 1024]; /* Info+Tracks precede Clusters */ - [fh closeFile]; - if (data.length < 4) return nil; + int fd = open(url.fileSystemRepresentation, O_RDONLY); + if (fd < 0) return nil; + struct stat st; + if (fstat(fd, &st) != 0 || st.st_size < 4) { close(fd); return nil; } + off_t fileSize = st.st_size; - const uint8_t *b = data.bytes; - if (!(b[0] == 0x1A && b[1] == 0x45 && b[2] == 0xDF && b[3] == 0xA3)) return nil; /* EBML */ + uint8_t magic[4]; + if (pread(fd, magic, 4, 0) != 4 || + !(magic[0] == 0x1A && magic[1] == 0x45 && magic[2] == 0xDF && magic[3] == 0xA3)) { + close(fd); return nil; /* not EBML */ + } + + /* Top level: skip the EBML header, find the Segment. */ + off_t off = 0, segData = -1, segEnd = 0; + for (int i = 0; i < 8 && off < fileSize; i++) { + uint32_t id; off_t dOff; uint64_t sz; int unk; + if (!MKVFileHeader(fd, off, &id, &dOff, &sz, &unk)) break; + if (id == 0x18538067) { /* Segment */ + segData = dOff; + segEnd = unk ? fileSize + : (off_t)MIN((uint64_t)fileSize, (uint64_t)dOff + sz); + break; + } + if (unk) break; /* unknown-size non-segment: can't skip */ + off = dOff + (off_t)sz; + } + if (segData < 0) { close(fd); return nil; } MKVState s = { .timecodeScale = 1.0e6 }; - MKVWalk(b, b + data.length, 0, &s); + int haveInfo = 0, haveTracks = 0; + off = segData; + for (int guard = 0; guard < 8192 && off < segEnd; guard++) { + uint32_t id; off_t dOff; uint64_t sz; int unk; + if (!MKVFileHeader(fd, off, &id, &dOff, &sz, &unk)) break; + if (id == 0x1F43B675) break; /* Cluster: media data begins */ + if (unk) break; /* unknown-size child: can't seek past it */ + if (id == 0x1549A966 || id == 0x1654AE6B) {/* Info / Tracks: read & parse the body */ + uint64_t rd = (sz > 8u * 1024 * 1024) ? 8u * 1024 * 1024 : sz; + NSMutableData *body = [NSMutableData dataWithLength:(NSUInteger)rd]; + ssize_t got = pread(fd, body.mutableBytes, (size_t)rd, dOff); + if (got > 0) { + const uint8_t *b = body.bytes; + MKVParseBody(b, b + got, &s); + if (id == 0x1549A966) haveInfo = 1; else haveTracks = 1; + } + } + if (haveInfo && haveTracks) break; + off = dOff + (off_t)sz; /* seek past this sibling */ + } + close(fd); if (s.timecodeScale <= 0) s.timecodeScale = 1.0e6; NSMutableDictionary *v = [NSMutableDictionary dictionary]; - NSString *dims = nil; - if (s.width > 0 && s.height > 0 && s.width <= 100000 && s.height <= 100000) { - v[@(F_WIDTH)] = @((int)s.width); - v[@(F_HEIGHT)] = @((int)s.height); - dims = [NSString stringWithFormat:@"%llu × %llu", - (unsigned long long)s.width, (unsigned long long)s.height]; - v[@(F_DIMENSIONS)] = dims; - } double secs = s.duration * s.timecodeScale / 1.0e9; - NSString *durStr = (secs > 0) ? FormatDuration(secs) : nil; - if (durStr) { - v[@(F_DURATION)] = durStr; - v[@(F_DURATIONSECS)] = @(round(secs * 10.0) / 10.0); + double fps = (s.defDurNs > 0) ? 1.0e9 / (double)s.defDurNs : 0; + FillVideoFields(v, (long)s.width, (long)s.height, secs, fps); + + NSString *vc = MKVCodecName(s.videoCodec); + if (vc) v[@(F_VIDEOCODEC)] = vc; + if (s.hasAudio) { + NSString *ac = MKVCodecName(s.audioCodec); + if (ac) v[@(F_AUDIOCODEC)] = ac; + if (s.audioRate > 0) v[@(F_SAMPLERATE)] = @((int)llround(s.audioRate)); + if (s.audioChannels > 0) v[@(F_CHANNELS)] = @((int)s.audioChannels); } - if (s.defDurNs > 0) - v[@(F_FRAMERATE)] = @(round(1.0e9 / (double)s.defDurNs * 100.0) / 100.0); - - if (dims && durStr) v[@(F_SUMMARY)] = [NSString stringWithFormat:@"%@ · %@", dims, durStr]; - else if (dims) v[@(F_SUMMARY)] = dims; - else if (durStr) v[@(F_SUMMARY)] = durStr; if (v.count == 0) return nil; MIInfo *info = [MIInfo new]; - info.category = CAT_VIDEO; + info.category = (s.width > 0) ? CAT_VIDEO : CAT_AUDIO; info.values = v; return info; } diff --git a/media-info-wdx/README.md b/media-info-wdx/README.md index e2e6bfb..68c18d8 100644 --- a/media-info-wdx/README.md +++ b/media-info-wdx/README.md @@ -17,7 +17,7 @@ network**: | **Audio** (mp3, m4a, aac, wav, aiff, caf) | Duration, Bitrate, Sample rate, Channels, Audio codec | **AVFoundation** | | **Video** (mp4, mov, m4v, 3gp) | Dimensions, Duration, Frame rate, Bitrate, Video/Audio codec | **AVFoundation** | | **Video** (avi) | Dimensions, Duration, Frame rate | self-contained **RIFF `avih`** reader (AVFoundation can't open AVI on macOS) | -| **Video** (mkv, webm) | Dimensions, Duration, Frame rate | self-contained **EBML** reader (AVFoundation can't open Matroska on macOS) | +| **Video** (mkv, webm) | Dimensions, Duration, Frame rate, Video/Audio codec, Sample rate, Channels | self-contained **EBML** reader (AVFoundation can't open Matroska on macOS) | | **PDF** | Page count | **CoreGraphics (CGPDF)** | ## The `Summary` field diff --git a/media-info-wdx/test/test_host.m b/media-info-wdx/test/test_host.m index 14d28a2..9dad080 100644 --- a/media-info-wdx/test/test_host.m +++ b/media-info-wdx/test/test_host.m @@ -155,9 +155,13 @@ static void ebmlEl(NSMutableData *out, const uint8_t *id, int idn, NSData *body) return [NSData dataWithBytes:b length:8]; } +static NSData *ebmlBytes(const char *s) { return [NSData dataWithBytes:s length:strlen(s)]; } + static NSString *makeMKV(NSString *dir) { - /* Minimal Matroska: 1920x1080 video track, 3.2s (TimecodeScale 1e6 ns, - Duration 3200 units), no clusters — exercises the EBML head parser. */ + /* Realistic Matroska head: 3.2s (TimecodeScale 1e6 ns, Duration 3200 units), + an anamorphic H.264 video track (coded 1440x1080, display 1920x1080), and a + stereo 48 kHz AAC audio track. No clusters. Exercises the seeking parser, + display-over-coded size preference, audio parsing, and CodecID mapping. */ NSMutableData *file = [NSMutableData data]; const uint8_t EBMLHDR[4] = {0x1A,0x45,0xDF,0xA3}; ebmlEl(file, EBMLHDR, 4, [NSData data]); /* empty EBML header */ @@ -167,15 +171,29 @@ static void ebmlEl(NSMutableData *out, const uint8_t *id, int idn, NSData *body) { const uint8_t id[3] = {0x2A,0xD7,0xB1}; ebmlEl(info, id, 3, ebmlUInt(1000000)); } /* TimecodeScale */ { const uint8_t id[2] = {0x44,0x89}; ebmlEl(info, id, 2, ebmlF64(3200.0)); } /* Duration */ - /* Tracks > TrackEntry > Video(PixelWidth/Height) + TrackType=1 */ + /* Video TrackEntry: coded 1440x1080 but display 1920x1080 (anamorphic). */ NSMutableData *video = [NSMutableData data]; - { const uint8_t id[1] = {0xB0}; ebmlEl(video, id, 1, ebmlUInt(1920)); } - { const uint8_t id[1] = {0xBA}; ebmlEl(video, id, 1, ebmlUInt(1080)); } - NSMutableData *track = [NSMutableData data]; - { const uint8_t id[1] = {0x83}; ebmlEl(track, id, 1, ebmlUInt(1)); } /* TrackType video */ - { const uint8_t id[1] = {0xE0}; ebmlEl(track, id, 1, video); } + { const uint8_t id[1] = {0xB0}; ebmlEl(video, id, 1, ebmlUInt(1440)); } /* PixelWidth */ + { const uint8_t id[1] = {0xBA}; ebmlEl(video, id, 1, ebmlUInt(1080)); } /* PixelHeight */ + { const uint8_t id[2] = {0x54,0xB0}; ebmlEl(video, id, 2, ebmlUInt(1920)); } /* DisplayWidth */ + { const uint8_t id[2] = {0x54,0xBA}; ebmlEl(video, id, 2, ebmlUInt(1080)); } /* DisplayHeight */ + NSMutableData *vtrack = [NSMutableData data]; + { const uint8_t id[1] = {0x83}; ebmlEl(vtrack, id, 1, ebmlUInt(1)); } /* TrackType video */ + { const uint8_t id[1] = {0x86}; ebmlEl(vtrack, id, 1, ebmlBytes("V_MPEG4/ISO/AVC")); } /* CodecID -> H.264 */ + { const uint8_t id[1] = {0xE0}; ebmlEl(vtrack, id, 1, video); } + + /* Audio TrackEntry: stereo 48 kHz AAC. */ + NSMutableData *audio = [NSMutableData data]; + { const uint8_t id[1] = {0xB5}; ebmlEl(audio, id, 1, ebmlF64(48000.0)); } /* SamplingFrequency */ + { const uint8_t id[1] = {0x9F}; ebmlEl(audio, id, 1, ebmlUInt(2)); } /* Channels */ + NSMutableData *atrack = [NSMutableData data]; + { const uint8_t id[1] = {0x83}; ebmlEl(atrack, id, 1, ebmlUInt(2)); } /* TrackType audio */ + { const uint8_t id[1] = {0x86}; ebmlEl(atrack, id, 1, ebmlBytes("A_AAC")); } /* CodecID -> AAC */ + { const uint8_t id[1] = {0xE1}; ebmlEl(atrack, id, 1, audio); } + NSMutableData *tracks = [NSMutableData data]; - { const uint8_t id[1] = {0xAE}; ebmlEl(tracks, id, 1, track); } + { const uint8_t id[1] = {0xAE}; ebmlEl(tracks, id, 1, vtrack); } + { const uint8_t id[1] = {0xAE}; ebmlEl(tracks, id, 1, atrack); } NSMutableData *seg = [NSMutableData data]; { const uint8_t id[4] = {0x15,0x49,0xA9,0x66}; ebmlEl(seg, id, 4, info); } @@ -187,6 +205,33 @@ static void ebmlEl(NSMutableData *out, const uint8_t *id, int idn, NSData *body) return path; } +static NSString *makeMKVBadDuration(NSString *dir) { + /* Hostile input: a finite-but-absurd Duration (1e300) that would overflow + llround if it reached FormatDuration. The parser must still yield the + video dimensions and simply omit Duration. */ + NSMutableData *file = [NSMutableData data]; + const uint8_t EBMLHDR[4] = {0x1A,0x45,0xDF,0xA3}; + ebmlEl(file, EBMLHDR, 4, [NSData data]); + NSMutableData *info = [NSMutableData data]; + { const uint8_t id[3] = {0x2A,0xD7,0xB1}; ebmlEl(info, id, 3, ebmlUInt(1000000)); } + { const uint8_t id[2] = {0x44,0x89}; ebmlEl(info, id, 2, ebmlF64(1e300)); } + NSMutableData *video = [NSMutableData data]; + { const uint8_t id[1] = {0xB0}; ebmlEl(video, id, 1, ebmlUInt(640)); } + { const uint8_t id[1] = {0xBA}; ebmlEl(video, id, 1, ebmlUInt(480)); } + NSMutableData *track = [NSMutableData data]; + { const uint8_t id[1] = {0x83}; ebmlEl(track, id, 1, ebmlUInt(1)); } + { const uint8_t id[1] = {0xE0}; ebmlEl(track, id, 1, video); } + NSMutableData *tracks = [NSMutableData data]; + { const uint8_t id[1] = {0xAE}; ebmlEl(tracks, id, 1, track); } + NSMutableData *seg = [NSMutableData data]; + { const uint8_t id[4] = {0x15,0x49,0xA9,0x66}; ebmlEl(seg, id, 4, info); } + { const uint8_t id[4] = {0x16,0x54,0xAE,0x6B}; ebmlEl(seg, id, 4, tracks); } + { const uint8_t id[4] = {0x18,0x53,0x80,0x67}; ebmlEl(file, id, 4, seg); } + NSString *path = [dir stringByAppendingPathComponent:@"bad-duration.mkv"]; + [file writeToFile:path atomically:YES]; + return path; +} + int main(int argc, char **argv) { @autoreleasepool { if (argc < 2) { fprintf(stderr, "usage: test_host \n"); return 2; } @@ -256,16 +301,32 @@ int main(int argc, char **argv) { check([getStr(avi, @"Summary") isEqualToString:@"320 × 240 · 0:02"], ([NSString stringWithFormat:@"AVI Summary = '%@'", getStr(avi, @"Summary")])); - /* ---- video: MKV/WebM via our own EBML parser ---- */ + /* ---- video: MKV/WebM via our own seeking EBML parser ---- */ const char *mkv = makeMKV(dir).fileSystemRepresentation; - check(getInt(mkv, @"Width") == 1920, @"MKV Width = 1920"); + check(getInt(mkv, @"Width") == 1920, @"MKV Width = 1920 (display size, not coded 1440)"); check(getInt(mkv, @"Height") == 1080, @"MKV Height = 1080"); check(fabs(getFloat(mkv, @"Duration (s)") - 3.2) < 0.05, ([NSString stringWithFormat:@"MKV Duration (s) = %.2f (want 3.2)", getFloat(mkv, @"Duration (s)")])); + check([getStr(mkv, @"Video codec") isEqualToString:@"H.264"], + ([NSString stringWithFormat:@"MKV Video codec = '%@'", getStr(mkv, @"Video codec")])); + check([getStr(mkv, @"Audio codec") isEqualToString:@"AAC"], + ([NSString stringWithFormat:@"MKV Audio codec = '%@'", getStr(mkv, @"Audio codec")])); + check(getInt(mkv, @"Sample rate") == 48000, @"MKV Sample rate = 48000"); + check(getInt(mkv, @"Channels") == 2, @"MKV Channels = 2"); check([getStr(mkv, @"Summary") isEqualToString:@"1920 × 1080 · 0:03"], ([NSString stringWithFormat:@"MKV Summary = '%@'", getStr(mkv, @"Summary")])); + /* ---- robustness: a hostile/absurd Duration must not become garbage ---- */ + const char *badmkv = makeMKVBadDuration(dir).fileSystemRepresentation; + check(getInt(badmkv, @"Width") == 640, @"hostile-duration MKV still yields Width = 640"); + check(getStr(badmkv, @"Duration") == nil, + ([NSString stringWithFormat:@"absurd MKV Duration rejected (got '%@')", + getStr(badmkv, @"Duration")])); + check([getStr(badmkv, @"Summary") isEqualToString:@"640 × 480"], + ([NSString stringWithFormat:@"hostile-duration MKV Summary = '%@'", + getStr(badmkv, @"Summary")])); + /* ---- regression: survive DC's FP-exception traps (the RawCamera crash) ---- Double Commander (Lazarus/FPC) enables FP-exception traps; Apple media frameworks can raise them. The plugin must mask them during parsing and From 4fcf4b3cb429254ec18e274a2bbce955dfe8d239 Mon Sep 17 00:00:00 2001 From: / <6137228+NikolaiSachok@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:15:50 +0200 Subject: [PATCH 3/3] media-info-wdx: address re-review findings on the seeking MKV parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second-pass /code-review of the seeking rewrite: - Fix a real regression: prefer DisplayWidth/Height only when DisplayUnit is pixels (0, the default). DisplayUnit 1/2/3 means cm/inches/aspect-ratio, where those fields aren't pixel counts — a 16:9 aspect-ratio track was reported as "16 × 9". Now falls back to the coded PixelWidth/Height. - Robustness: raise the top-level Segment search from 8 to 64 elements (a file may carry leading Void/padding before the Segment); handle an unknown-size Info/Tracks master by reading a bounded window and walking it recursively (stopping at the first Cluster) instead of bailing out. - Cleanup: remove the dead, write-only MIInfo.category property (never read; classification comes from CategoryForPath) and unify the duplicated EBML header decode into MKVDecodeHeader, shared by the in-memory walker and the file-seeking reader. Harness grows an aspect-ratio-display (DisplayUnit=3) case asserting the coded size wins: 40/40 pass. Real 6h54m .mkv output unchanged and still matches ffprobe (1080p30, 24854.8s, H.264/AAC/48kHz/stereo). Co-Authored-By: Claude Opus 4.8 (1M context) --- media-info-wdx/MediaInfo.m | 150 +++++++++++++++++--------------- media-info-wdx/test/test_host.m | 40 +++++++++ 2 files changed, 121 insertions(+), 69 deletions(-) diff --git a/media-info-wdx/MediaInfo.m b/media-info-wdx/MediaInfo.m index 5dd1dba..1e3f45f 100644 --- a/media-info-wdx/MediaInfo.m +++ b/media-info-wdx/MediaInfo.m @@ -124,7 +124,6 @@ static MICategory CategoryForPath(NSString *path) { /* ---- Parsed-info value object + cache ----------------------------------- */ @interface MIInfo : NSObject -@property (nonatomic) MICategory category; @property (nonatomic, strong) NSDictionary *values; /* field -> NSNumber|NSString */ @end @implementation MIInfo @@ -203,7 +202,6 @@ @implementation MIInfo if (depth && depth.intValue > 0) v[@(F_COLORDEPTH)] = @(depth.intValue); MIInfo *info = [MIInfo new]; - info.category = CAT_IMAGE; info.values = v; return info; } @@ -220,7 +218,6 @@ @implementation MIInfo v[@(F_SUMMARY)] = (n == 1) ? @"1 page" : [NSString stringWithFormat:@"%zu pages", n]; MIInfo *info = [MIInfo new]; - info.category = CAT_PDF; info.values = v; return info; } @@ -286,7 +283,6 @@ static uint32_t RdLE32(const uint8_t *p) { if (v.count == 0) return nil; MIInfo *info = [MIInfo new]; - info.category = CAT_VIDEO; info.values = v; return info; } @@ -302,35 +298,47 @@ past large siblings (SeekHead, Cues, Attachments) without reading them. So the EBML basics: every element is an ID (variable 1-4 bytes, marker bits kept) then a size VINT (1-8 bytes, marker stripped) then data. */ -/* Read one element at *pp within [*pp, end). On success sets id/data ptr/data - len and advances *pp past the element; returns 1. Returns 0 to stop. */ -static int MKVNext(const uint8_t **pp, const uint8_t *end, - uint32_t *id, const uint8_t **dp, uint64_t *dlen) { - const uint8_t *p = *pp; - if (p >= end) return 0; - - uint8_t f = p[0]; +/* Decode one EBML element header (ID + size VINT) from [buf, buf+len). On success + sets id, the header length in bytes, the declared data size, and whether the + size is the EBML "unknown" sentinel; returns 1. Returns 0 if truncated/invalid. + Shared by the in-memory walker (MKVNext) and the file-seeking reader. */ +static int MKVDecodeHeader(const uint8_t *buf, size_t len, uint32_t *id, + int *hdrLen, uint64_t *size, int *unknown) { + if (len < 2) return 0; + uint8_t f = buf[0]; int idn = (f & 0x80) ? 1 : (f & 0x40) ? 2 : (f & 0x20) ? 3 : (f & 0x10) ? 4 : 0; - if (idn == 0 || p + idn > end) return 0; + if (idn == 0 || (size_t)idn >= len) return 0; uint32_t eid = 0; - for (int i = 0; i < idn; i++) eid = (eid << 8) | p[i]; - const uint8_t *q = p + idn; - if (q >= end) return 0; + for (int i = 0; i < idn; i++) eid = (eid << 8) | buf[i]; - uint8_t s = q[0]; + const uint8_t *q = buf + idn; + uint8_t sb = q[0]; int sn = 0; uint8_t smask = 0; for (int b = 0; b < 8; b++) { - if (s & (0x80 >> b)) { sn = b + 1; smask = (uint8_t)(0xFF >> (b + 1)); break; } + if (sb & (0x80 >> b)) { sn = b + 1; smask = (uint8_t)(0xFF >> (b + 1)); break; } } - if (sn == 0 || q + sn > end) return 0; - uint64_t size = (uint64_t)(s & smask); - int allOnes = ((s & smask) == smask); - for (int i = 1; i < sn; i++) { size = (size << 8) | q[i]; if (q[i] != 0xFF) allOnes = 0; } + if (sn == 0 || (size_t)(idn + sn) > len) return 0; + uint64_t sz = (uint64_t)(sb & smask); + int allOnes = ((sb & smask) == smask); + for (int i = 1; i < sn; i++) { sz = (sz << 8) | q[i]; if (q[i] != 0xFF) allOnes = 0; } + + *id = eid; *hdrLen = idn + sn; *size = sz; if (unknown) *unknown = allOnes; + return 1; +} - const uint8_t *d = q + sn; +/* Read one element at *pp within [*pp, end). On success sets id/data ptr/data + len and advances *pp past the element; returns 1. Returns 0 to stop. */ +static int MKVNext(const uint8_t **pp, const uint8_t *end, + uint32_t *id, const uint8_t **dp, uint64_t *dlen) { + const uint8_t *p = *pp; + if (p >= end) return 0; + int hdr; uint64_t size; int unk; + if (!MKVDecodeHeader(p, (size_t)(end - p), id, &hdr, &size, &unk)) return 0; + + const uint8_t *d = p + hdr; uint64_t avail = (uint64_t)(end - d); - uint64_t dl = allOnes ? avail : (size > avail ? avail : size); /* unknown size -> to buffer end */ - *id = eid; *dp = d; *dlen = dl; + uint64_t dl = unk ? avail : (size > avail ? avail : size); /* unknown/truncated -> buffer end */ + *dp = d; *dlen = dl; *pp = d + dl; /* truncated/unknown -> == end -> loop stops */ return 1; } @@ -378,7 +386,7 @@ static double MKVFloat(const uint8_t *p, uint64_t n) { typedef struct { double timecodeScale; /* ns per Duration unit (Matroska default 1e6) */ double duration; /* in timecodeScale units */ - uint64_t width, height; /* first video track's display size (aspect-correct) */ + uint64_t width, height; /* first video track's pixel size (display if in pixels, else coded) */ uint64_t defDurNs; /* first video track's per-frame duration, ns */ char videoCodec[40]; /* first video track's CodecID */ int hasAudio; @@ -396,7 +404,8 @@ static void MKVCopyStr(char *dst, size_t cap, const uint8_t *src, uint64_t n) { /* Walk the children of one TrackEntry (already isolated to [p, end)). */ static void MKVParseTrackEntry(const uint8_t *p, const uint8_t *end, MKVState *s) { uint32_t id; const uint8_t *dp; uint64_t dl; - uint64_t px = 0, py = 0, dispx = 0, dispy = 0, defDur = 0, chans = 0, type = 0; + uint64_t px = 0, py = 0, dispx = 0, dispy = 0, dispUnit = 0; + uint64_t defDur = 0, chans = 0, type = 0; double rate = 0; int hasVideo = 0, hasAudio = 0; char codec[40] = {0}; while (MKVNext(&p, end, &id, &dp, &dl)) { @@ -409,10 +418,11 @@ static void MKVParseTrackEntry(const uint8_t *p, const uint8_t *end, MKVState *s const uint8_t *vp = dp, *ve = dp + dl; uint32_t vid; const uint8_t *vdp; uint64_t vdl; while (MKVNext(&vp, ve, &vid, &vdp, &vdl)) { - if (vid == 0xB0) px = MKVUInt(vdp, vdl); /* PixelWidth */ - else if (vid == 0xBA) py = MKVUInt(vdp, vdl); /* PixelHeight */ - else if (vid == 0x54B0) dispx = MKVUInt(vdp, vdl); /* DisplayWidth */ - else if (vid == 0x54BA) dispy = MKVUInt(vdp, vdl); /* DisplayHeight */ + if (vid == 0xB0) px = MKVUInt(vdp, vdl); /* PixelWidth */ + else if (vid == 0xBA) py = MKVUInt(vdp, vdl); /* PixelHeight */ + else if (vid == 0x54B0) dispx = MKVUInt(vdp, vdl); /* DisplayWidth */ + else if (vid == 0x54BA) dispy = MKVUInt(vdp, vdl); /* DisplayHeight */ + else if (vid == 0x54B2) dispUnit = MKVUInt(vdp, vdl); /* DisplayUnit */ } break; } @@ -430,10 +440,13 @@ static void MKVParseTrackEntry(const uint8_t *p, const uint8_t *end, MKVState *s } } if ((hasVideo || type == 1) && s->width == 0) { - /* Prefer the aspect-correct display size (matches what a player shows and - what the AVFoundation path reports); fall back to the coded size. */ - uint64_t w = (dispx > 0) ? dispx : px; - uint64_t h = (dispy > 0) ? dispy : py; + /* Prefer the aspect-correct display size, but ONLY when it is expressed in + pixels (DisplayUnit 0, the default). DisplayUnit 1/2/3 means cm / inches + / aspect-ratio, where DisplayWidth/Height are not pixel counts (e.g. 16×9) + — fall back to the coded size then. */ + int dispIsPixels = (dispUnit == 0); + uint64_t w = (dispIsPixels && dispx > 0) ? dispx : px; + uint64_t h = (dispIsPixels && dispy > 0) ? dispy : py; if (w > 0 && h > 0) { s->width = w; s->height = h; s->defDurNs = defDur; MKVCopyStr(s->videoCodec, sizeof s->videoCodec, (const uint8_t *)codec, strlen(codec)); @@ -444,14 +457,24 @@ static void MKVParseTrackEntry(const uint8_t *p, const uint8_t *end, MKVState *s } } -/* Walk an in-memory Info or Tracks body, harvesting the fields we care about. */ -static void MKVParseBody(const uint8_t *p, const uint8_t *end, MKVState *s) { +/* Walk an in-memory region, harvesting the fields we care about. Normally called + on a single Info or Tracks body (children at the top level), but it also recurses + into Segment/Info/Tracks masters and stops at the first Cluster, so it stays + correct when handed a larger window (e.g. an unknown-size master's contents). */ +static void MKVParseBody(const uint8_t *p, const uint8_t *end, int depth, MKVState *s) { + if (depth > 4) return; uint32_t id; const uint8_t *dp; uint64_t dl; while (MKVNext(&p, end, &id, &dp, &dl)) { switch (id) { + case 0x18538067: /* Segment */ + case 0x1549A966: /* Info */ + case 0x1654AE6B: /* Tracks */ + MKVParseBody(dp, dp + dl, depth + 1, s); + break; + case 0xAE: MKVParseTrackEntry(dp, dp + dl, s); break; /* TrackEntry */ case 0x2AD7B1: s->timecodeScale = (double)MKVUInt(dp, dl); break; /* TimecodeScale */ case 0x4489: s->duration = MKVFloat(dp, dl); break; /* Duration */ - case 0xAE: MKVParseTrackEntry(dp, dp + dl, s); break; /* TrackEntry */ + case 0x1F43B675: return; /* Cluster: media data begins, nothing useful past here */ default: break; } } @@ -465,27 +488,9 @@ static int MKVFileHeader(int fd, off_t off, uint32_t *id, off_t *dataOff, uint8_t h[12]; ssize_t got = pread(fd, h, sizeof h, off); if (got < 2) return 0; - const uint8_t *end = h + got; - - uint8_t f = h[0]; - int idn = (f & 0x80) ? 1 : (f & 0x40) ? 2 : (f & 0x20) ? 3 : (f & 0x10) ? 4 : 0; - if (idn == 0 || h + idn > end) return 0; - uint32_t eid = 0; - for (int i = 0; i < idn; i++) eid = (eid << 8) | h[i]; - - const uint8_t *q = h + idn; - if (q >= end) return 0; - uint8_t sb = q[0]; - int sn = 0; uint8_t smask = 0; - for (int b = 0; b < 8; b++) { - if (sb & (0x80 >> b)) { sn = b + 1; smask = (uint8_t)(0xFF >> (b + 1)); break; } - } - if (sn == 0 || q + sn > end) return 0; - uint64_t sz = (uint64_t)(sb & smask); - int allOnes = ((sb & smask) == smask); - for (int i = 1; i < sn; i++) { sz = (sz << 8) | q[i]; if (q[i] != 0xFF) allOnes = 0; } - - *id = eid; *dataOff = off + idn + sn; *size = sz; if (unknown) *unknown = allOnes; + int hdr; + if (!MKVDecodeHeader(h, (size_t)got, id, &hdr, size, unknown)) return 0; + *dataOff = off + hdr; return 1; } @@ -502,9 +507,9 @@ static int MKVFileHeader(int fd, off_t off, uint32_t *id, off_t *dataOff, close(fd); return nil; /* not EBML */ } - /* Top level: skip the EBML header, find the Segment. */ + /* Top level: skip the EBML header (and any leading padding), find the Segment. */ off_t off = 0, segData = -1, segEnd = 0; - for (int i = 0; i < 8 && off < fileSize; i++) { + for (int i = 0; i < 64 && off < fileSize; i++) { uint32_t id; off_t dOff; uint64_t sz; int unk; if (!MKVFileHeader(fd, off, &id, &dOff, &sz, &unk)) break; if (id == 0x18538067) { /* Segment */ @@ -518,6 +523,7 @@ static int MKVFileHeader(int fd, off_t off, uint32_t *id, off_t *dataOff, } if (segData < 0) { close(fd); return nil; } + const uint64_t kBodyCap = 8u * 1024 * 1024; MKVState s = { .timecodeScale = 1.0e6 }; int haveInfo = 0, haveTracks = 0; off = segData; @@ -525,16 +531,25 @@ static int MKVFileHeader(int fd, off_t off, uint32_t *id, off_t *dataOff, uint32_t id; off_t dOff; uint64_t sz; int unk; if (!MKVFileHeader(fd, off, &id, &dOff, &sz, &unk)) break; if (id == 0x1F43B675) break; /* Cluster: media data begins */ - if (unk) break; /* unknown-size child: can't seek past it */ - if (id == 0x1549A966 || id == 0x1654AE6B) {/* Info / Tracks: read & parse the body */ - uint64_t rd = (sz > 8u * 1024 * 1024) ? 8u * 1024 * 1024 : sz; + + int wanted = (id == 0x1549A966 || id == 0x1654AE6B); /* Info / Tracks */ + if (wanted || unk) { + /* Read this element's body and harvest it. For an unknown-size master + (rare — some streamed muxers) we can't know its extent, so read a + window to segEnd; the recursive walker harvests Info/Tracks children + and stops at the first Cluster. */ + uint64_t avail = (uint64_t)(segEnd - dOff); + uint64_t want = unk ? avail : (sz < avail ? sz : avail); + uint64_t rd = (want > kBodyCap) ? kBodyCap : want; NSMutableData *body = [NSMutableData dataWithLength:(NSUInteger)rd]; ssize_t got = pread(fd, body.mutableBytes, (size_t)rd, dOff); if (got > 0) { const uint8_t *b = body.bytes; - MKVParseBody(b, b + got, &s); - if (id == 0x1549A966) haveInfo = 1; else haveTracks = 1; + MKVParseBody(b, b + got, 0, &s); + if (id == 0x1549A966) haveInfo = 1; + else if (id == 0x1654AE6B) haveTracks = 1; } + if (unk) break; /* can't reliably resume past an unknown-size element */ } if (haveInfo && haveTracks) break; off = dOff + (off_t)sz; /* seek past this sibling */ @@ -558,7 +573,6 @@ static int MKVFileHeader(int fd, off_t off, uint32_t *id, off_t *dataOff, if (v.count == 0) return nil; MIInfo *info = [MIInfo new]; - info.category = (s.width > 0) ? CAT_VIDEO : CAT_AUDIO; info.values = v; return info; } @@ -639,7 +653,6 @@ static int MKVFileHeader(int fd, off_t off, uint32_t *id, off_t *dataOff, if (v.count == 0) return nil; MIInfo *info = [MIInfo new]; - info.category = isVideo ? CAT_VIDEO : CAT_AUDIO; info.values = v; return info; } @@ -695,7 +708,6 @@ static BOOL IsOwnParsedVideo(NSString *ext) { } if (!info) { /* sentinel: parsed, nothing usable */ info = [MIInfo new]; - info.category = cat; info.values = @{}; } [gCache setObject:info forKey:key]; diff --git a/media-info-wdx/test/test_host.m b/media-info-wdx/test/test_host.m index 9dad080..11ecccd 100644 --- a/media-info-wdx/test/test_host.m +++ b/media-info-wdx/test/test_host.m @@ -232,6 +232,36 @@ static void ebmlEl(NSMutableData *out, const uint8_t *id, int idn, NSData *body) return path; } +static NSString *makeMKVAspectDisplay(NSString *dir) { + /* Video where the display size is an ASPECT RATIO (DisplayUnit=3, 16:9), not + pixels. The parser must ignore DisplayWidth/Height here and report the coded + 1280x720 — not a nonsensical "16 × 9". */ + NSMutableData *file = [NSMutableData data]; + const uint8_t EBMLHDR[4] = {0x1A,0x45,0xDF,0xA3}; + ebmlEl(file, EBMLHDR, 4, [NSData data]); + NSMutableData *info = [NSMutableData data]; + { const uint8_t id[3] = {0x2A,0xD7,0xB1}; ebmlEl(info, id, 3, ebmlUInt(1000000)); } + { const uint8_t id[2] = {0x44,0x89}; ebmlEl(info, id, 2, ebmlF64(1000.0)); } + NSMutableData *video = [NSMutableData data]; + { const uint8_t id[1] = {0xB0}; ebmlEl(video, id, 1, ebmlUInt(1280)); } /* PixelWidth */ + { const uint8_t id[1] = {0xBA}; ebmlEl(video, id, 1, ebmlUInt(720)); } /* PixelHeight */ + { const uint8_t id[2] = {0x54,0xB0}; ebmlEl(video, id, 2, ebmlUInt(16)); } /* DisplayWidth */ + { const uint8_t id[2] = {0x54,0xBA}; ebmlEl(video, id, 2, ebmlUInt(9)); } /* DisplayHeight */ + { const uint8_t id[2] = {0x54,0xB2}; ebmlEl(video, id, 2, ebmlUInt(3)); } /* DisplayUnit=3 (AR) */ + NSMutableData *track = [NSMutableData data]; + { const uint8_t id[1] = {0x83}; ebmlEl(track, id, 1, ebmlUInt(1)); } + { const uint8_t id[1] = {0xE0}; ebmlEl(track, id, 1, video); } + NSMutableData *tracks = [NSMutableData data]; + { const uint8_t id[1] = {0xAE}; ebmlEl(tracks, id, 1, track); } + NSMutableData *seg = [NSMutableData data]; + { const uint8_t id[4] = {0x15,0x49,0xA9,0x66}; ebmlEl(seg, id, 4, info); } + { const uint8_t id[4] = {0x16,0x54,0xAE,0x6B}; ebmlEl(seg, id, 4, tracks); } + { const uint8_t id[4] = {0x18,0x53,0x80,0x67}; ebmlEl(file, id, 4, seg); } + NSString *path = [dir stringByAppendingPathComponent:@"aspect-display.mkv"]; + [file writeToFile:path atomically:YES]; + return path; +} + int main(int argc, char **argv) { @autoreleasepool { if (argc < 2) { fprintf(stderr, "usage: test_host \n"); return 2; } @@ -327,6 +357,16 @@ int main(int argc, char **argv) { ([NSString stringWithFormat:@"hostile-duration MKV Summary = '%@'", getStr(badmkv, @"Summary")])); + /* ---- robustness: DisplayUnit=aspect-ratio must not be read as pixels ---- */ + const char *armkv = makeMKVAspectDisplay(dir).fileSystemRepresentation; + check(getInt(armkv, @"Width") == 1280, + ([NSString stringWithFormat:@"aspect-ratio-display MKV Width = %d (want coded 1280, not 16)", + getInt(armkv, @"Width")])); + check(getInt(armkv, @"Height") == 720, @"aspect-ratio-display MKV Height = 720 (coded, not 9)"); + check([getStr(armkv, @"Summary") isEqualToString:@"1280 × 720 · 0:01"], + ([NSString stringWithFormat:@"aspect-ratio-display MKV Summary = '%@'", + getStr(armkv, @"Summary")])); + /* ---- regression: survive DC's FP-exception traps (the RawCamera crash) ---- Double Commander (Lazarus/FPC) enables FP-exception traps; Apple media frameworks can raise them. The plugin must mask them during parsing and