mirror of
https://github.com/zoriya/v10.git
synced 2026-08-15 10:23:32 +00:00
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
51caeb7d95
commit
cf8aaca45b
@@ -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<Box> {
|
||||
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<Box> {
|
||||
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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user