1/**
2 * A duration for the WebM the browser records.
3 *
4 * MediaRecorder writes a live stream: the Segment is left with an unknown
5 * size and the Info section carries no Duration, because neither is known
6 * while the recording is still running. Browsers cope by scanning the file,
7 * but many other players do not: ffmpeg reports "Duration: N/A", editors and
8 * upload previews show 0:00, and seeking is unreliable. Since the lab knows
9 * exactly how long it recorded, it can say so.
10 *
11 * `withWebmDuration` splices a Duration element into the Info section (or
12 * overwrites one that is already there) and returns the patched bytes. Only
13 * the Info section moves; everything after it is copied unchanged, and the
14 * Segment's unknown size stays unknown, which is legal and is what every
15 * player already handles. Anything that does not parse as WebM is returned
16 * untouched, so a browser that writes something else loses nothing.
17 *
18 * The format: an EBML element is an id, then a size as a variable-length
19 * integer whose leading zeros give its byte count, then the payload. Duration
20 * (id 0x4489) is a big-endian float in units of the Segment's TimecodeScale
21 * (id 0x2AD7B1, nanoseconds, one millisecond by default).
22 */
24const ID_EBML = 0x1a45dfa3;
25const ID_SEGMENT = 0x18538067;
26const ID_INFO = 0x1549a966;
27const ID_TIMECODE_SCALE = 0x2ad7b1;
28const ID_DURATION = 0x4489;
30interface Element {
31 id: number;
32 /** Offset of the id, the lengths of the id and size fields, then the payload's bounds */
33 start: number;
34 idLen: number;
35 sizeLen: number;
36 body: number;
37 end: number;
38 /** The size field said "unknown", so the payload runs to the end of its parent */
39 unknownSize: boolean;
40}
42/** Length in bytes of the variable-length integer starting with this byte, or 0 if invalid. */
43function vintLength(first: number): number {
44 for (let i = 0; i < 8; i++) if (first & (0x80 >> i)) return i + 1;
45 return 0;
46}
48/** Read one element header at `pos`, or null when the buffer runs out. */
49function readElement(b: Uint8Array, pos: number, parentEnd: number): Element | null {
50 if (pos >= parentEnd) return null;
51 const idLen = vintLength(b[pos]);
52 if (idLen === 0 || pos + idLen > parentEnd) return null;
53 let id = 0;
54 for (let i = 0; i < idLen; i++) id = id * 256 + b[pos + i];
55 const sizePos = pos + idLen;
56 if (sizePos >= parentEnd) return null;
57 const sizeLen = vintLength(b[sizePos]);
58 if (sizeLen === 0 || sizePos + sizeLen > parentEnd) return null;
59 let size = b[sizePos] & (0xff >> sizeLen);
60 let allOnes = size === (0xff >> sizeLen);
61 for (let i = 1; i < sizeLen; i++) {
62 size = size * 256 + b[sizePos + i];
63 allOnes = allOnes && b[sizePos + i] === 0xff;
64 }
65 const body = sizePos + sizeLen;
66 const end = allOnes ? parentEnd : Math.min(body + size, parentEnd);
67 return { id, start: pos, idLen, sizeLen, body, end, unknownSize: allOnes };
68}
70/** The first child with this id, searching the children of `parent` only. */
71function findChild(b: Uint8Array, parent: Element, id: number): Element | null {
72 let pos = parent.body;
73 for (;;) {
74 const el = readElement(b, pos, parent.end);
75 if (!el) return null;
76 if (el.id === id) return el;
77 pos = el.end;
78 if (pos <= el.start) return null; // a zero-length element would loop forever
79 }
80}
82/** An unsigned integer payload, for TimecodeScale. */
83function readUint(b: Uint8Array, el: Element): number {
84 let v = 0;
85 for (let i = el.body; i < el.end; i++) v = v * 256 + b[i];
86 return v;
87}
89/** The EBML size field for `value`, at least `minLength` bytes long. */
90export function writeVintSize(value: number, minLength = 1): Uint8Array {
91 let len = minLength;
92 while (len < 8 && value >= 2 ** (7 * len) - 1) len++;
93 const out = new Uint8Array(len);
94 let v = value;
95 for (let i = len - 1; i >= 0; i--) {
96 out[i] = v % 256;
97 v = Math.floor(v / 256);
98 }
99 out[0] |= 0x80 >> (len - 1);
100 return out;
101}
103/** The Duration element: its id, its size, and the value as a big-endian double. */
104function durationElement(ticks: number): Uint8Array {
105 const out = new Uint8Array(11);
106 out[0] = 0x44;
107 out[1] = 0x89;
108 out[2] = 0x88; // an 8-byte payload
109 new DataView(out.buffer).setFloat64(3, ticks, false);
110 return out;
111}
113/**
114 * Return the recording with its length written into the Info section. Bytes
115 * that are not WebM, or that carry no Info section, come back unchanged.
116 */
117export function withWebmDuration(bytes: Uint8Array, seconds: number): Uint8Array {
118 if (!(seconds > 0) || bytes.length < 8) return bytes;
119 const header = readElement(bytes, 0, bytes.length);
120 if (!header || header.id !== ID_EBML) return bytes;
121 let segment: Element | null = null;
122 let pos = header.end;
123 while (pos < bytes.length) {
124 const el = readElement(bytes, pos, bytes.length);
125 if (!el || el.end <= el.start) break;
126 if (el.id === ID_SEGMENT) {
127 segment = el;
128 break;
129 }
130 pos = el.end;
131 }
132 if (!segment) return bytes;
133 const info = findChild(bytes, segment, ID_INFO);
134 if (!info || info.unknownSize) return bytes;
136 const scaleEl = findChild(bytes, info, ID_TIMECODE_SCALE);
137 const scale = scaleEl ? readUint(bytes, scaleEl) : 1e6; // nanoseconds per tick
138 const ticks = (seconds * 1e9) / (scale > 0 ? scale : 1e6);
140 const existing = findChild(bytes, info, ID_DURATION);
141 if (existing && existing.end - existing.body === 8) {
142 const out = bytes.slice();
143 new DataView(out.buffer, out.byteOffset, out.byteLength).setFloat64(existing.body, ticks, false);
144 return out;
145 }
146 if (existing) return bytes; // a 4-byte float: leave a file we did not write alone
148 // Splice the element in, and grow the Info size field to match
149 const add = durationElement(ticks);
150 const payload = info.end - info.body;
151 const idBytes = bytes.subarray(info.start, info.start + info.idLen);
152 const newSize = writeVintSize(payload + add.length, info.sizeLen);
153 const out = new Uint8Array(
154 info.start + idBytes.length + newSize.length + payload + add.length + (bytes.length - info.end),
155 );
156 let o = 0;
157 out.set(bytes.subarray(0, info.start), o);
158 o += info.start;
159 out.set(idBytes, o);
160 o += idBytes.length;
161 out.set(newSize, o);
162 o += newSize.length;
163 out.set(bytes.subarray(info.body, info.end), o);
164 o += payload;
165 out.set(add, o);
166 o += add.length;
167 out.set(bytes.subarray(info.end), o);
168 return out;
169}
171/** The duration an EBML file declares [s], or null when it declares none. */
172export function readWebmDuration(bytes: Uint8Array): number | null {
173 const header = readElement(bytes, 0, bytes.length);
174 if (!header || header.id !== ID_EBML) return null;
175 let pos = header.end;
176 let segment: Element | null = null;
177 while (pos < bytes.length) {
178 const el = readElement(bytes, pos, bytes.length);
179 if (!el || el.end <= el.start) break;
180 if (el.id === ID_SEGMENT) {
181 segment = el;
182 break;
183 }
184 pos = el.end;
185 }
186 if (!segment) return null;
187 const info = findChild(bytes, segment, ID_INFO);
188 if (!info) return null;
189 const dur = findChild(bytes, info, ID_DURATION);
190 if (!dur) return null;
191 const scaleEl = findChild(bytes, info, ID_TIMECODE_SCALE);
192 const scale = scaleEl ? readUint(bytes, scaleEl) : 1e6;
193 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
194 const ticks = dur.end - dur.body === 4 ? view.getFloat32(dur.body, false) : view.getFloat64(dur.body, false);
195 return (ticks * scale) / 1e9;
196}