From cf8aaca45b6896dc401cdbad9b141444eec0e7d3 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Thu, 2 Jul 2026 13:50:12 -0700 Subject: [PATCH] feat(spf): add mp4 box parser and decode-time origin extraction Minimal ISO-BMFF box walker (media/mp4/box.ts) plus decode-time origin extraction (timestamp-origin.ts) for the non-zero-PTS timestampOffset relocation spike. Relocating a source by tfdt.baseMediaDecodeTime (a DTS) over mdhd.timescale keeps the earliest DTS >= 0, so a Chromium negative-DTS append failure is impossible by construction. Two variants share the box walker and leaf field-readers: - Presumptive (readFirstMediaTimescale / readFirstBaseMediaDecodeTime): the first mdhd timescale + first tfdt baseMediaDecodeTime, no track_id, no iteration. For single-media-track sources. - Track-selected (findMediaTrack by hdlr handler / readBaseMediaDecodeTime by tfhd track_id): the track_id joins one track's timescale to the same track's baseMediaDecodeTime -- required when a source muxes CEA-608/708 captions into the same moov/moof (each track carries its own timescale and baseMediaDecodeTime). Split into separate exports so caption-free platforms tree-shake the matching machinery away (~37% smaller minified than the track-selected pair). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/spf/src/media/mp4/box.ts | 88 ++++++++++ packages/spf/src/media/mp4/tests/box.test.ts | 66 ++++++++ .../src/media/mp4/tests/synthetic-boxes.ts | 124 ++++++++++++++ .../media/mp4/tests/timestamp-origin.test.ts | 103 ++++++++++++ .../spf/src/media/mp4/timestamp-origin.ts | 155 ++++++++++++++++++ 5 files changed, 536 insertions(+) create mode 100644 packages/spf/src/media/mp4/box.ts create mode 100644 packages/spf/src/media/mp4/tests/box.test.ts create mode 100644 packages/spf/src/media/mp4/tests/synthetic-boxes.ts create mode 100644 packages/spf/src/media/mp4/tests/timestamp-origin.test.ts create mode 100644 packages/spf/src/media/mp4/timestamp-origin.ts diff --git a/packages/spf/src/media/mp4/box.ts b/packages/spf/src/media/mp4/box.ts new file mode 100644 index 00000000..4c2c7508 --- /dev/null +++ b/packages/spf/src/media/mp4/box.ts @@ -0,0 +1,88 @@ +/** + * Minimal ISO-BMFF (MP4/CMAF) box walker. + * + * Just enough to locate boxes by nested path — the framework needs a couple of + * leaf fields (`mdhd` timescale, `tfdt` baseMediaDecodeTime) to derive a + * segment's decode-time origin, not a full demuxer. DOM-free: operates on an + * `ArrayBuffer` / `Uint8Array` via `DataView`. + * + * Box layout: `[u32 size][u32 type][payload]`. `size === 1` means a `u64 + * largesize` follows the type (payload after it); `size === 0` means the box + * runs to the end of its container. + */ + +/** A located box: its 4-char type and byte offsets within the buffer. */ +export interface Box { + type: string; + /** Offset of the box's first byte (its size field). */ + start: number; + /** Offset of the box's payload — after `size` + `type` (+ `largesize`). */ + dataStart: number; + /** Offset one past the box's last byte. */ + end: number; +} + +export function toDataView(data: ArrayBuffer | Uint8Array): DataView { + return data instanceof Uint8Array ? new DataView(data.buffer, data.byteOffset, data.byteLength) : new DataView(data); +} + +/** Read a 4-character box type / FourCC at `offset`. */ +export function readFourCC(view: DataView, offset: number): string { + return String.fromCharCode( + view.getUint8(offset), + view.getUint8(offset + 1), + view.getUint8(offset + 2), + view.getUint8(offset + 3) + ); +} + +/** Iterate the boxes directly contained in `[start, end)`. */ +export function* iterateBoxes(view: DataView, start = 0, end = view.byteLength): Generator { + let offset = start; + while (offset + 8 <= end) { + let size = view.getUint32(offset); + const type = readFourCC(view, offset + 4); + let dataStart = offset + 8; + if (size === 1) { + size = Number(view.getBigUint64(offset + 8)); + dataStart = offset + 16; + } else if (size === 0) { + size = end - offset; + } + // A size smaller than its own header is malformed — stop rather than loop. + if (size < dataStart - offset) return; + yield { type, start: offset, dataStart, end: offset + size }; + offset += size; + } +} + +/** Iterate the direct child boxes of a given `type` within `[start, end)`. */ +export function* iterateBoxesOfType(view: DataView, type: string, start = 0, end = view.byteLength): Generator { + for (const box of iterateBoxes(view, start, end)) { + if (box.type === type) yield box; + } +} + +/** + * Depth-first descent to the first box matching a nested path, e.g. + * `['moov', 'trak', 'mdia', 'mdhd']`. Returns `undefined` if any level is + * absent. + */ +export function findBox(view: DataView, path: readonly string[], start = 0, end = view.byteLength): Box | undefined { + const [head, ...rest] = path; + for (const box of iterateBoxes(view, start, end)) { + if (box.type !== head) continue; + if (rest.length === 0) return box; + const found = findBox(view, rest, box.dataStart, box.end); + if (found) return found; + } + return undefined; +} + +/** + * Read the version byte of a FullBox — the `version(1) + flags(3)` header at the + * start of the payload of `mdhd` / `tkhd` / `tfdt` / `hdlr` / `elst` / etc. + */ +export function readFullBoxVersion(view: DataView, dataStart: number): number { + return view.getUint8(dataStart); +} diff --git a/packages/spf/src/media/mp4/tests/box.test.ts b/packages/spf/src/media/mp4/tests/box.test.ts new file mode 100644 index 00000000..bed4cc78 --- /dev/null +++ b/packages/spf/src/media/mp4/tests/box.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { findBox, iterateBoxes, iterateBoxesOfType, readFullBoxVersion, toDataView } from '../box'; +import { box, concat, largeBox, u32 } from './synthetic-boxes'; + +const view = (data: Uint8Array) => toDataView(data); + +describe('iterateBoxes', () => { + it('yields top-level boxes with correct type and offsets', () => { + const ftyp = box('ftyp', u32(0)); + const moov = box('moov', u32(0)); + const boxes = [...iterateBoxes(view(concat(ftyp, moov)))]; + expect(boxes.map((b) => b.type)).toEqual(['ftyp', 'moov']); + expect(boxes[0]).toMatchObject({ start: 0, dataStart: 8, end: ftyp.length }); + expect(boxes[1]).toMatchObject({ start: ftyp.length, dataStart: ftyp.length + 8 }); + }); + + it('reads a 64-bit largesize box (size field === 1)', () => { + const big = largeBox('free', u32(0)); + const [b] = [...iterateBoxes(view(big))]; + expect(b).toMatchObject({ type: 'free', start: 0, dataStart: 16, end: big.length }); + }); + + it('stops on a malformed size smaller than the header', () => { + const bad = new Uint8Array(8); + new DataView(bad.buffer).setUint32(0, 2); // size 2 < 8-byte header + expect([...iterateBoxes(view(bad))]).toEqual([]); + }); +}); + +describe('iterateBoxesOfType', () => { + it('yields only direct children of the given type', () => { + const data = concat(box('trak', u32(1)), box('free', u32(0)), box('trak', u32(2))); + const traks = [...iterateBoxesOfType(view(data), 'trak')]; + expect(traks).toHaveLength(2); + expect(traks.every((b) => b.type === 'trak')).toBe(true); + }); +}); + +describe('readFullBoxVersion', () => { + it('reads the version byte at the payload start', () => { + for (const version of [0, 1] as const) { + const dv = view(box('mdhd', new Uint8Array([version, 0, 0, 0]))); + const [b] = [...iterateBoxes(dv)]; + expect(readFullBoxVersion(dv, b!.dataStart)).toBe(version); + } + }); +}); + +describe('findBox', () => { + it('descends a nested path', () => { + const data = box('moov', box('trak', box('mdia', box('mdhd', u32(90000))))); + const found = findBox(view(data), ['moov', 'trak', 'mdia', 'mdhd']); + expect(found?.type).toBe('mdhd'); + }); + + it('skips non-matching siblings at each level', () => { + const data = concat(box('free', u32(0)), box('moov', box('mvhd', u32(0)), box('trak', box('tkhd', u32(0))))); + expect(findBox(view(data), ['moov', 'trak'])?.type).toBe('trak'); + }); + + it('returns undefined when any path level is absent', () => { + const data = box('moov', box('trak')); + expect(findBox(view(data), ['moov', 'trak', 'mdia'])).toBeUndefined(); + expect(findBox(view(data), ['moof', 'traf'])).toBeUndefined(); + }); +}); diff --git a/packages/spf/src/media/mp4/tests/synthetic-boxes.ts b/packages/spf/src/media/mp4/tests/synthetic-boxes.ts new file mode 100644 index 00000000..5247a59d --- /dev/null +++ b/packages/spf/src/media/mp4/tests/synthetic-boxes.ts @@ -0,0 +1,124 @@ +/** + * Hand-built ISO-BMFF boxes for unit tests — precise control over + * version/field layout and multi-track muxing without committing binary + * segment fixtures. Real-stream validation of the parsers lives in the + * non-zero-PTS spike probes, not here. + */ + +export function concat(...parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((n, p) => n + p.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const part of parts) { + out.set(part, offset); + offset += part.length; + } + return out; +} + +export function u32(value: number): Uint8Array { + const out = new Uint8Array(4); + new DataView(out.buffer).setUint32(0, value); + return out; +} + +export function u64(value: number | bigint): Uint8Array { + const out = new Uint8Array(8); + new DataView(out.buffer).setBigUint64(0, BigInt(value)); + return out; +} + +function fourcc(type: string): Uint8Array { + return new Uint8Array([type.charCodeAt(0), type.charCodeAt(1), type.charCodeAt(2), type.charCodeAt(3)]); +} + +/** A standard box: `[u32 size][4-char type][payload]`. */ +export function box(type: string, ...children: Uint8Array[]): Uint8Array { + const payload = concat(...children); + const out = new Uint8Array(8 + payload.length); + new DataView(out.buffer).setUint32(0, out.length); + out.set(fourcc(type), 4); + out.set(payload, 8); + return out; +} + +/** A 64-bit-`largesize` box (`size` field === 1). */ +export function largeBox(type: string, ...children: Uint8Array[]): Uint8Array { + const payload = concat(...children); + const out = new Uint8Array(16 + payload.length); + const view = new DataView(out.buffer); + view.setUint32(0, 1); + out.set(fourcc(type), 4); + view.setBigUint64(8, BigInt(out.length)); + out.set(payload, 16); + return out; +} + +const flags = new Uint8Array(3); +const versionFlags = (version: 0 | 1) => new Uint8Array([version, 0, 0, 0]); + +/** `mdhd` FullBox carrying `timescale`. v0 = 32-bit dates, v1 = 64-bit dates. */ +export function mdhd(timescale: number, version: 0 | 1 = 0): Uint8Array { + const dates = version === 1 ? concat(u64(0), u64(0)) : concat(u32(0), u32(0)); + const duration = version === 1 ? u64(0) : u32(0); + return box('mdhd', new Uint8Array([version]), flags, dates, u32(timescale), duration); +} + +/** `tkhd` FullBox carrying `track_id`. */ +export function tkhd(trackId: number, version: 0 | 1 = 0): Uint8Array { + const dates = version === 1 ? concat(u64(0), u64(0)) : concat(u32(0), u32(0)); + return box('tkhd', versionFlags(version), dates, u32(trackId), u32(0) /* reserved */); +} + +/** `hdlr` FullBox: version+flags, pre_defined, `handler_type`, reserved, name. */ +export function hdlr(handlerType: string): Uint8Array { + return box( + 'hdlr', + versionFlags(0), + u32(0) /* pre_defined */, + fourcc(handlerType), + new Uint8Array(12), + new Uint8Array([0]) + ); +} + +/** `tfdt` FullBox carrying `baseMediaDecodeTime`. v0 = 32-bit, v1 = 64-bit. */ +export function tfdt(baseMediaDecodeTime: number | bigint, version: 0 | 1 = 0): Uint8Array { + const value = version === 1 ? u64(baseMediaDecodeTime) : u32(Number(baseMediaDecodeTime)); + return box('tfdt', versionFlags(version), value); +} + +/** `tfhd` FullBox carrying `track_id` (right after version+flags). */ +export function tfhd(trackId: number): Uint8Array { + return box('tfhd', versionFlags(0), u32(trackId)); +} + +export interface TrakSpec { + handler: string; + trackId: number; + timescale: number; + mdhdVersion?: 0 | 1; + tkhdVersion?: 0 | 1; +} + +/** A `trak`: `tkhd` + `mdia > (hdlr, mdhd)`. */ +export function trak({ handler, trackId, timescale, mdhdVersion = 0, tkhdVersion = 0 }: TrakSpec): Uint8Array { + return box('trak', tkhd(trackId, tkhdVersion), box('mdia', hdlr(handler), mdhd(timescale, mdhdVersion))); +} + +/** An init segment: `ftyp` + `moov > (mvhd, ...traks)`. */ +export function initSegment(...traks: Uint8Array[]): Uint8Array { + return concat(box('ftyp', u32(0)), box('moov', box('mvhd', u32(0)), ...traks)); +} + +export interface TrafSpec { + trackId: number; + baseMediaDecodeTime: number | bigint; + version?: 0 | 1; +} + +/** A media segment: `styp` + `moof > (mfhd, ...traf)`, each `traf` = `tfhd` + `tfdt`. */ +export function mediaSegment(...trafs: TrafSpec[]): Uint8Array { + const trafBoxes = trafs.map((t) => box('traf', tfhd(t.trackId), tfdt(t.baseMediaDecodeTime, t.version ?? 0))); + return concat(box('styp', u32(0)), box('moof', box('mfhd', u32(0)), ...trafBoxes)); +} diff --git a/packages/spf/src/media/mp4/tests/timestamp-origin.test.ts b/packages/spf/src/media/mp4/tests/timestamp-origin.test.ts new file mode 100644 index 00000000..de1db35c --- /dev/null +++ b/packages/spf/src/media/mp4/tests/timestamp-origin.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; +import { + findMediaTrack, + readBaseMediaDecodeTime, + readFirstBaseMediaDecodeTime, + readFirstMediaTimescale, +} from '../timestamp-origin'; +import { box, initSegment, mediaSegment, trak } from './synthetic-boxes'; + +// Mirrors the Apple bipbop advanced example: a video init/segment that muxes a +// closed-caption (`clcp`) track alongside the `vide` track, each with its own +// timescale and baseMediaDecodeTime. +const muxedVideoInit = initSegment( + trak({ handler: 'vide', trackId: 1, timescale: 6000 }), + trak({ handler: 'clcp', trackId: 2, timescale: 30000 }) +); +const muxedVideoSegment = mediaSegment( + { trackId: 1, baseMediaDecodeTime: 60000 }, // video → 60000/6000 = 10.0s + { trackId: 2, baseMediaDecodeTime: 300000 } // captions → 300000/30000 = 10.0s +); + +describe('readFirstMediaTimescale', () => { + it('reads the first mdhd timescale (single-track init)', () => { + const audioInit = initSegment(trak({ handler: 'soun', trackId: 1, timescale: 48000 })); + expect(readFirstMediaTimescale(audioInit)).toBe(48000); + }); + + it('reads the first trak on a muxed init — correct only by ordering', () => { + // Video happens to be first here; a clcp-first muxing would mis-read. That + // ordering fragility is exactly why the muxed case needs findMediaTrack. + expect(readFirstMediaTimescale(muxedVideoInit)).toBe(6000); + }); + + it('reads a v1 mdhd timescale (wider date fields)', () => { + const init = initSegment(trak({ handler: 'vide', trackId: 1, timescale: 90000, mdhdVersion: 1 })); + expect(readFirstMediaTimescale(init)).toBe(90000); + }); + + it('returns undefined when no mdhd exists', () => { + expect(readFirstMediaTimescale(box('moov', box('mvhd')))).toBeUndefined(); + }); +}); + +describe('findMediaTrack', () => { + it('selects the media track by handler, ignoring a muxed caption track', () => { + expect(findMediaTrack(muxedVideoInit, 'vide')).toEqual({ trackId: 1, timescale: 6000 }); + }); + + it('selects an audio track by handler', () => { + const audioInit = initSegment(trak({ handler: 'soun', trackId: 1, timescale: 48000 })); + expect(findMediaTrack(audioInit, 'soun')).toEqual({ trackId: 1, timescale: 48000 }); + }); + + it('handles v1 tkhd/mdhd (wider date fields)', () => { + const init = initSegment(trak({ handler: 'vide', trackId: 7, timescale: 90000, mdhdVersion: 1, tkhdVersion: 1 })); + expect(findMediaTrack(init, 'vide')).toEqual({ trackId: 7, timescale: 90000 }); + }); + + it('returns undefined when no track matches the handler', () => { + expect(findMediaTrack(muxedVideoInit, 'soun')).toBeUndefined(); + }); +}); + +describe('readFirstBaseMediaDecodeTime', () => { + it('reads the first traf', () => { + expect(readFirstBaseMediaDecodeTime(muxedVideoSegment)).toBe(60000); + }); + + it('reads a 64-bit v1 baseMediaDecodeTime beyond the 32-bit range', () => { + const large = 2 ** 33 + 12345; + expect(readFirstBaseMediaDecodeTime(mediaSegment({ trackId: 1, baseMediaDecodeTime: large, version: 1 }))).toBe( + large + ); + }); + + it('returns undefined when the traf or tfdt is absent', () => { + expect(readFirstBaseMediaDecodeTime(box('moof', box('traf')))).toBeUndefined(); + }); +}); + +describe('readBaseMediaDecodeTime', () => { + it('selects the traf matching track_id in a muxed segment', () => { + expect(readBaseMediaDecodeTime(muxedVideoSegment, 1)).toBe(60000); + expect(readBaseMediaDecodeTime(muxedVideoSegment, 2)).toBe(300000); + }); + + it('returns undefined when no traf matches track_id', () => { + expect(readBaseMediaDecodeTime(muxedVideoSegment, 99)).toBeUndefined(); + }); +}); + +describe('track-tied origin (cross-track mismatch guard)', () => { + it('pairs timescale and baseMediaDecodeTime from the same track', () => { + const track = findMediaTrack(muxedVideoInit, 'vide')!; + const bmdt = readBaseMediaDecodeTime(muxedVideoSegment, track.trackId)!; + // Correct, track-tied origin. + expect(bmdt / track.timescale).toBe(10); + // The bug track_id matching prevents: video timescale paired with the + // caption track's baseMediaDecodeTime would read 50s, not 10s. + const captionBmdt = readBaseMediaDecodeTime(muxedVideoSegment, 2)!; + expect(captionBmdt / track.timescale).toBe(50); + }); +}); diff --git a/packages/spf/src/media/mp4/timestamp-origin.ts b/packages/spf/src/media/mp4/timestamp-origin.ts new file mode 100644 index 00000000..d39d0950 --- /dev/null +++ b/packages/spf/src/media/mp4/timestamp-origin.ts @@ -0,0 +1,155 @@ +/** + * Decode-time origin extraction from fMP4/CMAF segments. + * + * Non-zero-PTS sources encode media at a non-zero start time (an instant clip + * starting at asset second 60, an Apple bipbop asset starting at 10s, …). To + * relocate such a source onto a 0-based presentation timeline via + * `SourceBuffer.timestampOffset`, the engine needs that start time — the + * **decode-time origin** — read straight from the container: + * + * - `tfdt.baseMediaDecodeTime` (from a media segment's `moof`) — the decode + * time of the segment's first sample, in the track's media timescale. This is + * a DTS: relocating by `−(baseMediaDecodeTime / timescale)` lands the earliest + * DTS at exactly 0, so a negative-DTS append failure on Chromium is impossible + * by construction. + * - `mdhd.timescale` (from the init segment's `moov`) — ticks per second for + * that track, needed to convert the raw tick count to seconds. + * + * ## Presumptive vs. track-selected reads + * + * The two variants share only the leaf field-readers (`mdhd` timescale, `tfdt` + * baseMediaDecodeTime) and the box walker. Everything else differs, and the + * split is deliberate so the presumptive pair is *proportionally* smaller under + * tree-shaking: + * + * - **Presumptive** — {@link readFirstMediaTimescale} / {@link readFirstBaseMediaDecodeTime} + * read the first `mdhd` timescale and first `tfdt` baseMediaDecodeTime. There's + * no `track_id` (nothing to match against) and no `trak`/`traf` iteration — a + * direct `findBox` to the first leaf. Correct when the init/segment holds a + * single media track (the common CMAF case). A caption-free platform imports + * only this pair and tree-shakes away all the matching machinery below. + * - **Track-selected** — {@link findMediaTrack} / {@link readBaseMediaDecodeTime}. + * `findMediaTrack` returns `{ trackId, timescale }` for the `trak` whose `hdlr` + * handler matches; `readBaseMediaDecodeTime` takes that `trackId` and reads the + * `traf` whose `tfhd.track_id` matches. The `track_id` is the join that ties one + * track's timescale to the *same* track's baseMediaDecodeTime — required when a + * source muxes CEA-608/708 captions (a `clcp` track shares the same `moov` and + * `moof`, each track with its own timescale + baseMediaDecodeTime, so a + * presumptive read there risks `300000 / 6000 = 50s` instead of + * `60000 / 6000 = 10s`). This pair adds `trak`/`traf` iteration plus the handler + * and `track_id` reads. We only ever ask for buffered media handlers + * (`vide` / `soun`); the caption track is never selected — we read the origin, + * not the captions (caption *rendering* is out of scope). + * + * Both raw values are returned un-divided to leave room for an edit-list (`elst`) + * presentation-time correction term if a source ever carries one — the validated + * streams do not. + */ +import { type Box, findBox, iterateBoxesOfType, readFourCC, readFullBoxVersion, toDataView } from './box'; + +/** MSE-buffered media handler types (`mdhd`/`hdlr`). Captions/subtitles excluded. */ +export type MediaHandlerType = 'vide' | 'soun'; + +export interface MediaTrackInfo { + /** `tkhd.track_id` — used to match the corresponding `traf` in media segments. */ + trackId: number; + /** `mdhd.timescale` — ticks per second for this track. */ + timescale: number; +} + +// --- presumptive: first leaf, no track_id, no iteration ----------------------- + +/** + * Presumptive: the `timescale` of the **first** `mdhd` in an init segment. + * Correct only for single-media-track inits — for muxed captions use + * {@link findMediaTrack}. `undefined` if no `mdhd` exists. + */ +export function readFirstMediaTimescale(initSegment: ArrayBuffer | Uint8Array): number | undefined { + const view = toDataView(initSegment); + const mdhd = findBox(view, ['moov', 'trak', 'mdia', 'mdhd']); + return mdhd ? readMdhdTimescale(view, mdhd) : undefined; +} + +/** + * Presumptive: `baseMediaDecodeTime` from the **first** `tfdt` of a media + * segment. Correct only for single-`traf` segments — for muxed captions use + * {@link readBaseMediaDecodeTime}. `undefined` if no `tfdt` exists. + */ +export function readFirstBaseMediaDecodeTime(mediaSegment: ArrayBuffer | Uint8Array): number | undefined { + const view = toDataView(mediaSegment); + const tfdt = findBox(view, ['moof', 'traf', 'tfdt']); + return tfdt ? readTfdtBaseMediaDecodeTime(view, tfdt) : undefined; +} + +// --- track-selected: iterate + match by handler / track_id -------------------- + +/** + * The `track_ID` + timescale of the `trak` whose `mdhd` handler matches + * `handlerType`, skipping a muxed `clcp` caption track. `undefined` if no + * matching track exists. + */ +export function findMediaTrack( + initSegment: ArrayBuffer | Uint8Array, + handlerType: MediaHandlerType +): MediaTrackInfo | undefined { + const view = toDataView(initSegment); + const moov = findBox(view, ['moov']); + if (!moov) return undefined; + + for (const trak of iterateBoxesOfType(view, 'trak', moov.dataStart, moov.end)) { + if (readTrakHandler(view, trak) !== handlerType) continue; + const tkhd = findBox(view, ['tkhd'], trak.dataStart, trak.end); + const mdhd = findBox(view, ['mdia', 'mdhd'], trak.dataStart, trak.end); + if (!tkhd || !mdhd) return undefined; + // tkhd FullBox: version(1)+flags(3), creation/modification dates (v0: 4+4, v1: + // 8+8), then track_id. + const trackId = view.getUint32(tkhd.dataStart + 4 + (readFullBoxVersion(view, tkhd.dataStart) === 1 ? 16 : 8)); + return { trackId, timescale: readMdhdTimescale(view, mdhd) }; + } + return undefined; +} + +/** + * `baseMediaDecodeTime` (in the track's media timescale) from the `traf` matching + * `trackId` (via `tfhd.track_id`) — required for muxed multi-`traf` segments. + * `undefined` if no matching `tfdt` exists. + */ +export function readBaseMediaDecodeTime(mediaSegment: ArrayBuffer | Uint8Array, trackId: number): number | undefined { + const view = toDataView(mediaSegment); + const moof = findBox(view, ['moof']); + if (!moof) return undefined; + + for (const traf of iterateBoxesOfType(view, 'traf', moof.dataStart, moof.end)) { + if (readTrafTrackId(view, traf) !== trackId) continue; + const tfdt = findBox(view, ['tfdt'], traf.dataStart, traf.end); + return tfdt ? readTfdtBaseMediaDecodeTime(view, tfdt) : undefined; + } + return undefined; +} + +// --- shared leaf field-readers ------------------------------------------------ + +/** `mdhd.timescale`: FullBox version(1)+flags(3) + dates (v0: 4+4, v1: 8+8) + timescale. */ +function readMdhdTimescale(view: DataView, mdhd: Box): number { + return view.getUint32(mdhd.dataStart + 4 + (readFullBoxVersion(view, mdhd.dataStart) === 1 ? 16 : 8)); +} + +/** `tfdt.baseMediaDecodeTime`: FullBox, then the value — v0: (4), v1: (8). */ +function readTfdtBaseMediaDecodeTime(view: DataView, tfdt: Box): number { + const at = tfdt.dataStart + 4; + return readFullBoxVersion(view, tfdt.dataStart) === 1 ? Number(view.getBigUint64(at)) : view.getUint32(at); +} + +// --- track-selection readers (referenced only by the track-selected variants) - + +/** `hdlr.handler_type` for a `trak`: FullBox version(1)+flags(3) + pre_defined(4) + handler_type(4). */ +function readTrakHandler(view: DataView, trak: Box): string | undefined { + const hdlr = findBox(view, ['mdia', 'hdlr'], trak.dataStart, trak.end); + return hdlr ? readFourCC(view, hdlr.dataStart + 8) : undefined; +} + +/** `tfhd.track_id` for a `traf`: FullBox version(1)+flags(3) + track_id(4). */ +function readTrafTrackId(view: DataView, traf: Box): number | undefined { + const tfhd = findBox(view, ['tfhd'], traf.dataStart, traf.end); + return tfhd ? view.getUint32(tfhd.dataStart + 4) : undefined; +}