/** * A duration for the WebM the browser records. * * MediaRecorder writes a live stream: the Segment is left with an unknown * size and the Info section carries no Duration, because neither is known * while the recording is still running. Browsers cope by scanning the file, * but many other players do not: ffmpeg reports "Duration: N/A", editors and * upload previews show 0:00, and seeking is unreliable. Since the lab knows * exactly how long it recorded, it can say so. * * `withWebmDuration` splices a Duration element into the Info section (or * overwrites one that is already there) and returns the patched bytes. Only * the Info section moves; everything after it is copied unchanged, and the * Segment's unknown size stays unknown, which is legal and is what every * player already handles. Anything that does not parse as WebM is returned * untouched, so a browser that writes something else loses nothing. * * The format: an EBML element is an id, then a size as a variable-length * integer whose leading zeros give its byte count, then the payload. Duration * (id 0x4489) is a big-endian float in units of the Segment's TimecodeScale * (id 0x2AD7B1, nanoseconds, one millisecond by default). */ const ID_EBML = 0x1a45dfa3; const ID_SEGMENT = 0x18538067; const ID_INFO = 0x1549a966; const ID_TIMECODE_SCALE = 0x2ad7b1; const ID_DURATION = 0x4489; interface Element { id: number; /** Offset of the id, the lengths of the id and size fields, then the payload's bounds */ start: number; idLen: number; sizeLen: number; body: number; end: number; /** The size field said "unknown", so the payload runs to the end of its parent */ unknownSize: boolean; } /** Length in bytes of the variable-length integer starting with this byte, or 0 if invalid. */ function vintLength(first: number): number { for (let i = 0; i < 8; i++) if (first & (0x80 >> i)) return i + 1; return 0; } /** Read one element header at `pos`, or null when the buffer runs out. */ function readElement(b: Uint8Array, pos: number, parentEnd: number): Element | null { if (pos >= parentEnd) return null; const idLen = vintLength(b[pos]); if (idLen === 0 || pos + idLen > parentEnd) return null; let id = 0; for (let i = 0; i < idLen; i++) id = id * 256 + b[pos + i]; const sizePos = pos + idLen; if (sizePos >= parentEnd) return null; const sizeLen = vintLength(b[sizePos]); if (sizeLen === 0 || sizePos + sizeLen > parentEnd) return null; let size = b[sizePos] & (0xff >> sizeLen); let allOnes = size === (0xff >> sizeLen); for (let i = 1; i < sizeLen; i++) { size = size * 256 + b[sizePos + i]; allOnes = allOnes && b[sizePos + i] === 0xff; } const body = sizePos + sizeLen; const end = allOnes ? parentEnd : Math.min(body + size, parentEnd); return { id, start: pos, idLen, sizeLen, body, end, unknownSize: allOnes }; } /** The first child with this id, searching the children of `parent` only. */ function findChild(b: Uint8Array, parent: Element, id: number): Element | null { let pos = parent.body; for (;;) { const el = readElement(b, pos, parent.end); if (!el) return null; if (el.id === id) return el; pos = el.end; if (pos <= el.start) return null; // a zero-length element would loop forever } } /** An unsigned integer payload, for TimecodeScale. */ function readUint(b: Uint8Array, el: Element): number { let v = 0; for (let i = el.body; i < el.end; i++) v = v * 256 + b[i]; return v; } /** The EBML size field for `value`, at least `minLength` bytes long. */ export function writeVintSize(value: number, minLength = 1): Uint8Array { let len = minLength; while (len < 8 && value >= 2 ** (7 * len) - 1) len++; const out = new Uint8Array(len); let v = value; for (let i = len - 1; i >= 0; i--) { out[i] = v % 256; v = Math.floor(v / 256); } out[0] |= 0x80 >> (len - 1); return out; } /** The Duration element: its id, its size, and the value as a big-endian double. */ function durationElement(ticks: number): Uint8Array { const out = new Uint8Array(11); out[0] = 0x44; out[1] = 0x89; out[2] = 0x88; // an 8-byte payload new DataView(out.buffer).setFloat64(3, ticks, false); return out; } /** * Return the recording with its length written into the Info section. Bytes * that are not WebM, or that carry no Info section, come back unchanged. */ export function withWebmDuration(bytes: Uint8Array, seconds: number): Uint8Array { if (!(seconds > 0) || bytes.length < 8) return bytes; const header = readElement(bytes, 0, bytes.length); if (!header || header.id !== ID_EBML) return bytes; let segment: Element | null = null; let pos = header.end; while (pos < bytes.length) { const el = readElement(bytes, pos, bytes.length); if (!el || el.end <= el.start) break; if (el.id === ID_SEGMENT) { segment = el; break; } pos = el.end; } if (!segment) return bytes; const info = findChild(bytes, segment, ID_INFO); if (!info || info.unknownSize) return bytes; const scaleEl = findChild(bytes, info, ID_TIMECODE_SCALE); const scale = scaleEl ? readUint(bytes, scaleEl) : 1e6; // nanoseconds per tick const ticks = (seconds * 1e9) / (scale > 0 ? scale : 1e6); const existing = findChild(bytes, info, ID_DURATION); if (existing && existing.end - existing.body === 8) { const out = bytes.slice(); new DataView(out.buffer, out.byteOffset, out.byteLength).setFloat64(existing.body, ticks, false); return out; } if (existing) return bytes; // a 4-byte float: leave a file we did not write alone // Splice the element in, and grow the Info size field to match const add = durationElement(ticks); const payload = info.end - info.body; const idBytes = bytes.subarray(info.start, info.start + info.idLen); const newSize = writeVintSize(payload + add.length, info.sizeLen); const out = new Uint8Array( info.start + idBytes.length + newSize.length + payload + add.length + (bytes.length - info.end), ); let o = 0; out.set(bytes.subarray(0, info.start), o); o += info.start; out.set(idBytes, o); o += idBytes.length; out.set(newSize, o); o += newSize.length; out.set(bytes.subarray(info.body, info.end), o); o += payload; out.set(add, o); o += add.length; out.set(bytes.subarray(info.end), o); return out; } /** The duration an EBML file declares [s], or null when it declares none. */ export function readWebmDuration(bytes: Uint8Array): number | null { const header = readElement(bytes, 0, bytes.length); if (!header || header.id !== ID_EBML) return null; let pos = header.end; let segment: Element | null = null; while (pos < bytes.length) { const el = readElement(bytes, pos, bytes.length); if (!el || el.end <= el.start) break; if (el.id === ID_SEGMENT) { segment = el; break; } pos = el.end; } if (!segment) return null; const info = findChild(bytes, segment, ID_INFO); if (!info) return null; const dur = findChild(bytes, info, ID_DURATION); if (!dur) return null; const scaleEl = findChild(bytes, info, ID_TIMECODE_SCALE); const scale = scaleEl ? readUint(bytes, scaleEl) : 1e6; const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const ticks = dur.end - dur.body === 4 ? view.getFloat32(dur.body, false) : view.getFloat64(dur.body, false); return (ticks * scale) / 1e9; }