@torrent-tv/proxy 2.72.1 → 2.73.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1800,6 +1800,52 @@ export class TorrentPool {
1800
1800
  }
1801
1801
  }
1802
1802
 
1803
+ /**
1804
+ * Fetch a bounded region in the MIDDLE of a file.
1805
+ *
1806
+ * The warm-up fetches a file's two edges because the codec probe reads them.
1807
+ * A viewer resuming a film needs neither: they need the region under their own
1808
+ * position, and until now nothing asked for it before the encoder did. Field
1809
+ * 2026-09-03 — a retry after a crash reached the encoder 53 s after the button
1810
+ * was pressed, and only THEN was the piece under the viewer's position first
1811
+ * requested; it took another 46 s, and the browser gave up 0.4 s before it
1812
+ * landed.
1813
+ *
1814
+ * Read as an ordinary bounded read, never as a selection: claiming a whole
1815
+ * region alongside the readers' own windows is the mistake `#syncSelections`
1816
+ * was written against.
1817
+ *
1818
+ * @param {import("webtorrent").Torrent} torrent
1819
+ * @param {number} fileIndex
1820
+ * @param {number} startByte
1821
+ * @param {number} bytes
1822
+ * @param {{ timeoutMs?: number }} [options]
1823
+ * @returns {Promise<void>}
1824
+ */
1825
+ async prefetchFileRegion(torrent, fileIndex, startByte, bytes, { timeoutMs = 300_000 } = {}) {
1826
+ const file = torrent?.files?.[fileIndex];
1827
+ if (!file || typeof file.createReadStream !== "function") {
1828
+ return;
1829
+ }
1830
+ const fileSize = file.length;
1831
+ if (!Number.isFinite(fileSize) || fileSize <= 0 || !(bytes > 0)) {
1832
+ return;
1833
+ }
1834
+ const start = Math.max(0, Math.min(Math.floor(startByte), fileSize - 1));
1835
+ const end = Math.min(fileSize - 1, start + Math.floor(bytes) - 1);
1836
+ if (end <= start) {
1837
+ return;
1838
+ }
1839
+ const drained = new Promise((resolve) => {
1840
+ const stream = file.createReadStream({ start, end });
1841
+ stream.on("data", () => undefined);
1842
+ stream.once("end", resolve);
1843
+ stream.once("error", resolve);
1844
+ stream.once("close", resolve);
1845
+ });
1846
+ await Promise.race([drained, new Promise((resolve) => setTimeout(resolve, timeoutMs))]);
1847
+ }
1848
+
1803
1849
  /**
1804
1850
  * The body of {@link prefetchFileEdges}, without the de-duplication.
1805
1851
  *
@@ -349,6 +349,27 @@ export class TorrentWorkerClient {
349
349
  return this.#caller.call(Command.CONTAINER_TRACKS, { sourceKey, fileIndex });
350
350
  }
351
351
 
352
+ /**
353
+ * What one file declares about itself — format, duration, and where its own
354
+ * timeline begins.
355
+ *
356
+ * @param {{ sourceKey: string, fileIndex: number }} params
357
+ * @returns {Promise<{ info: import("../container/Container.js").ContainerMediaInfo | null }>}
358
+ */
359
+ async getContainerMediaInfo({ sourceKey, fileIndex }) {
360
+ return this.#caller.call(Command.CONTAINER_MEDIA_INFO, { sourceKey, fileIndex });
361
+ }
362
+
363
+ /**
364
+ * Start fetching the region a viewer is about to resume at.
365
+ *
366
+ * @param {{ sourceKey: string, fileIndex: number, positionSeconds: number }} params
367
+ * @returns {Promise<{ started: boolean }>}
368
+ */
369
+ async warmResumePosition({ sourceKey, fileIndex, positionSeconds }) {
370
+ return this.#caller.call(Command.WARM_POSITION, { sourceKey, fileIndex, positionSeconds });
371
+ }
372
+
352
373
  /**
353
374
  * The cues of one subtitle track that can be read from what is downloaded.
354
375
  *
@@ -224,6 +224,140 @@ export async function containerAudioTracksOf(torrent, fileIndex, sourceKey, opti
224
224
  /** Bytes of a file's head worth fetching before its track table is read. */
225
225
  export const CONTAINER_HEAD_BYTES = HEAD_BYTES;
226
226
 
227
+ /**
228
+ * How much of the file to pull in under the viewer's resume position.
229
+ *
230
+ * One piece of a video torrent is 4-16 MB and a resume lands anywhere inside
231
+ * one, so anything smaller would still leave the encoder waiting for the piece
232
+ * it starts in. Eight megabytes covers that piece and usually the next.
233
+ */
234
+ const RESUME_REGION_BYTES = 8 * 1024 * 1024;
235
+
236
+ /**
237
+ * Where a position in seconds falls in a file, in bytes.
238
+ *
239
+ * Proportional, and therefore approximate on a variable bitrate — which is what
240
+ * it is for: a prefetch that puts the swarm to work on roughly the right place
241
+ * while the plan and the session are still being built. The encoder's own read
242
+ * asks for the exact bytes a moment later and corrects it.
243
+ *
244
+ * A position past the end is clamped to the end rather than refused: a resume
245
+ * position can outlive the file it was recorded against, and reading the last
246
+ * bytes is harmless where reading past them is an error.
247
+ *
248
+ * @param {number} fileLength
249
+ * @param {number} durationSeconds
250
+ * @param {number} positionSeconds
251
+ * @returns {number}
252
+ */
253
+ export function resumeByteOffset(fileLength, durationSeconds, positionSeconds) {
254
+ if (!(fileLength > 0) || !(durationSeconds > 0) || !(positionSeconds > 0)) {
255
+ return 0;
256
+ }
257
+ const within = Math.min(positionSeconds, durationSeconds);
258
+ return Math.min(fileLength - 1, Math.floor((fileLength * within) / durationSeconds));
259
+ }
260
+
261
+ /**
262
+ * Start fetching the region a viewer is about to resume at.
263
+ *
264
+ * Where that region IS can only be worked out from two numbers the file itself
265
+ * holds — its length and its duration — so this belongs beside the container
266
+ * read rather than in the route: the route knows a position in seconds and
267
+ * nothing else. The conversion is proportional and therefore approximate on a
268
+ * variable bitrate; it is a prefetch, and the encoder's own read corrects it.
269
+ *
270
+ * @param {object} torrent
271
+ * @param {number} fileIndex
272
+ * @param {string} sourceKey
273
+ * @param {number} positionSeconds
274
+ * @param {{ prefetchEdges?: () => Promise<unknown>, fetchRegion?: (start: number, bytes: number) => Promise<unknown> }} options
275
+ * @returns {Promise<boolean>} Whether a region was asked for.
276
+ */
277
+ export async function warmResumePosition(torrent, fileIndex, sourceKey, positionSeconds, options = {}) {
278
+ const file = torrent?.files?.[fileIndex];
279
+ if (!file || !(positionSeconds > 0) || typeof options.fetchRegion !== "function") {
280
+ return false;
281
+ }
282
+ const info = await containerMediaInfoOf(torrent, fileIndex, sourceKey, options);
283
+ const duration = info?.durationSeconds;
284
+ if (!Number.isFinite(duration) || duration <= 0) {
285
+ logger.info(
286
+ `warm ${sourceKey.slice(0, 8)}: "${String(file.name).slice(0, 40)}" does not declare its ` +
287
+ "duration, so where the viewer's position falls in it cannot be worked out — " +
288
+ "the region under it is left to the encoder's own read"
289
+ );
290
+ return false;
291
+ }
292
+ const at = resumeByteOffset(file.length, duration, positionSeconds);
293
+ logger.info(
294
+ `warm ${sourceKey.slice(0, 8)}: fetching ${(RESUME_REGION_BYTES / (1024 * 1024)).toFixed(0)}MB under the ` +
295
+ `viewer's position ${positionSeconds.toFixed(1)}s of ${duration.toFixed(1)}s, which is ` +
296
+ `${(at / (1024 * 1024)).toFixed(1)}MB into "${String(file.name).slice(0, 40)}"`
297
+ );
298
+ await options.fetchRegion(at, RESUME_REGION_BYTES);
299
+ return true;
300
+ }
301
+
302
+ /**
303
+ * What one file declares about itself: format, duration, and where its own
304
+ * timeline begins.
305
+ *
306
+ * The same header the track table is read from, and the container instance is
307
+ * cached per file, so asking for this after the tracks costs no read at all.
308
+ * It exists because the alternative was a second reader: the session manager
309
+ * used to spawn an ffmpeg over the proxy's own HTTP to learn where a sidecar
310
+ * soundtrack's timeline begins, and that read cost 8121 ms in the field on
311
+ * 2026-09-03 while this layer had read the same header in 8 ms in the same
312
+ * second.
313
+ *
314
+ * @param {object} torrent
315
+ * @param {number} fileIndex
316
+ * @param {string} sourceKey
317
+ * @param {{ prefetchEdges?: () => Promise<unknown> }} [options]
318
+ * @returns {Promise<import("../container/Container.js").ContainerMediaInfo | null>}
319
+ */
320
+ export async function containerMediaInfoOf(torrent, fileIndex, sourceKey, options = {}) {
321
+ const file = torrent?.files?.[fileIndex];
322
+ if (!file || !Number.isFinite(file.length) || file.length <= 0) {
323
+ return null;
324
+ }
325
+ if (typeof options.prefetchEdges === "function") {
326
+ try {
327
+ await options.prefetchEdges();
328
+ } catch {
329
+ // A prefetch that failed is not a reason to skip the read: the read
330
+ // fetches what it needs itself, only more slowly.
331
+ }
332
+ }
333
+ const readRange = async (start, end) =>
334
+ readFetching(file, start, Math.min(end, file.length - 1));
335
+ try {
336
+ const info = await containerOrchestrator.getMediaInfo({
337
+ sourceKey,
338
+ fileIndex,
339
+ readRange,
340
+ fileSize: file.length,
341
+ label: String(file.name ?? "")
342
+ });
343
+ if (info) {
344
+ logger.info(
345
+ `container-info: "${String(file.name).slice(0, 40)}" is ${info.format}, ` +
346
+ `${info.durationSeconds === null ? "duration not declared" : `${info.durationSeconds.toFixed(3)}s`}, ` +
347
+ `${info.startTimeSeconds === null
348
+ ? "start of its timeline not declared"
349
+ : `its timeline starts at ${info.startTimeSeconds.toFixed(6)}s`}`
350
+ );
351
+ }
352
+ return info;
353
+ } catch (error) {
354
+ logger.warn(
355
+ `container-info: "${String(file.name).slice(0, 40)}" could not be read: ${error?.message ?? error}`
356
+ );
357
+ return null;
358
+ }
359
+ }
360
+
227
361
  /**
228
362
  * Forget one file's tracks, or every file of a source.
229
363
  *
@@ -24,6 +24,19 @@
24
24
  * instead of staying with whoever got it first.
25
25
  */
26
26
 
27
+ /**
28
+ * How short a piece's remaining tail must be for a second copy of it to be
29
+ * worth asking for.
30
+ *
31
+ * Sixteen blocks is 256 KB against the 4-16 MB piece they hold up. The figure
32
+ * comes from what was measured rather than chosen for roundness: the tails a
33
+ * blocked reader waits on were 2 to 14 blocks of 512 on 2026-08-19, and 3 of
34
+ * 512 in the field failure of 2026-09-03 that took 46.3 s. Above this the piece
35
+ * is still arriving normally and duplicating would spend the shared link on
36
+ * bytes already on their way.
37
+ */
38
+ const SHORT_TAIL_BLOCKS = 16;
39
+
27
40
  /**
28
41
  * The library's own request entry. Internal, so its absence must be noticed
29
42
  * rather than swallowed: without it this lever silently does nothing.
@@ -269,6 +282,15 @@ export function duplicateTailFor(torrent, pieceIndex) {
269
282
  return { duplicated: 0, missing: missing.length, wires: candidates.length };
270
283
  }
271
284
 
285
+ // A tail this short is what a blocked read is actually waiting on, and a
286
+ // second copy of it costs a few dozen kilobytes. A longer one is a piece
287
+ // still arriving normally, where duplicating would spend the shared link on
288
+ // bytes that are already coming — measured 2026-08-19, the tails a reader
289
+ // waits on are 2 to 14 blocks of 512.
290
+ if (missing.length > SHORT_TAIL_BLOCKS) {
291
+ return { duplicated: 0, missing: missing.length, wires: candidates.length };
292
+ }
293
+
272
294
  let duplicated = 0;
273
295
  // One block per wire: that is what the pipelines can usefully take at once,
274
296
  // and it needs no number of its own.
@@ -281,13 +303,14 @@ export function duplicateTailFor(torrent, pieceIndex) {
281
303
  // or it would free a THIRD wire's block as well.
282
304
  if (torrent._request(candidates[index], pieceIndex, false) === true) {
283
305
  duplicated += 1;
284
- continue;
285
306
  }
286
- // The wire's pipeline is full. The block stays in the piece's cancellation
287
- // stack and will be handed to whoever asks next, which is harmless it is
288
- // already in flight elsewhere but there is no point asking the remaining
289
- // wires, whose pipelines are no emptier.
290
- break;
307
+ // A refusal means THAT wire's pipeline is full, and says nothing about the
308
+ // next one's pipelines are per wire. This used to stop the whole pass on
309
+ // the first refusal, on the stated reasoning that the remaining wires were
310
+ // "no emptier", which is an assumption about other peers' queues that
311
+ // nothing here measures. The block whose reservation was freed stays in the
312
+ // piece's cancellation stack and is handed to whoever asks next, which is
313
+ // harmless: it is already in flight elsewhere.
291
314
  }
292
315
  return { duplicated, missing: missing.length, wires: candidates.length };
293
316
  }
@@ -950,11 +950,18 @@ export async function* readFragments({
950
950
  const result = askFastestWiresFor(torrent, pieceIndex);
951
951
  if (result.asked === 0) {
952
952
  tailWhenNothingPlaced = describePieceTail(torrent, pieceIndex);
953
- // Nothing could be placed the ordinary way, which means every block
954
- // is spoken for. That is exactly when a second copy of the last
955
- // blocks is worth asking for.
956
- duplicated += duplicateTailFor(torrent, pieceIndex).duplicated;
957
953
  }
954
+ // Every attempt, not only the ones where nothing else could be placed.
955
+ // The ordinary steering asks for whatever blocks are still free; the
956
+ // read, meanwhile, ends when the LAST block arrives, and that block is
957
+ // reserved to one wire whether or not other blocks could be asked for.
958
+ // Field 2026-09-03: 46.3 s on one piece, ordinary requests placed on
959
+ // 54 of 87 attempts throughout, and a tail of 3 blocks of 512 held by
960
+ // wires at 51-99 KB/s to the end — while duplication, which ran only
961
+ // on the 33 attempts that placed nothing, managed 5 blocks in the
962
+ // whole wait. `duplicateTailFor` bounds itself by the tail's length,
963
+ // so a piece that is merely still arriving is left alone.
964
+ duplicated += duplicateTailFor(torrent, pieceIndex).duplicated;
958
965
  pushed = {
959
966
  asked: pushed.asked + result.asked,
960
967
  refusedWhileReserved:
@@ -204,6 +204,41 @@ export class WorkerTorrentPool {
204
204
  return Array.isArray(answer?.tracks) ? answer.tracks : [];
205
205
  }
206
206
 
207
+ /**
208
+ * Start fetching the region a viewer is about to resume at. Named in seconds
209
+ * here; the worker turns it into bytes, where the file's duration is readable.
210
+ *
211
+ * @param {object} torrent
212
+ * @param {number} fileIndex
213
+ * @param {number} positionSeconds
214
+ * @returns {Promise<boolean>}
215
+ */
216
+ async warmResumePosition(torrent, fileIndex, positionSeconds) {
217
+ const sourceKey = torrent?.sourceKey;
218
+ if (!sourceKey) {
219
+ return false;
220
+ }
221
+ const answer = await this.#client.warmResumePosition({ sourceKey, fileIndex, positionSeconds });
222
+ return answer?.started === true;
223
+ }
224
+
225
+ /**
226
+ * What one file declares about itself: format, duration, and where its own
227
+ * timeline begins.
228
+ *
229
+ * @param {object} torrent
230
+ * @param {number} fileIndex
231
+ * @returns {Promise<import("../container/Container.js").ContainerMediaInfo | null>}
232
+ */
233
+ async getContainerMediaInfo(torrent, fileIndex) {
234
+ const sourceKey = torrent?.sourceKey;
235
+ if (!sourceKey) {
236
+ return null;
237
+ }
238
+ const answer = await this.#client.getContainerMediaInfo({ sourceKey, fileIndex });
239
+ return answer?.info ?? null;
240
+ }
241
+
207
242
  /**
208
243
  * The audio tracks one file declares, in the order ffmpeg numbers them
209
244
  * `0:a:N`.
@@ -81,6 +81,18 @@ export const Command = {
81
81
  * file beside it, which is the same question about a different file.
82
82
  */
83
83
  CONTAINER_TRACKS: "container-tracks",
84
+ /**
85
+ * What a file declares about itself as a whole — format, duration, and where
86
+ * its own timeline begins. Read from the same header, by the same reader, as
87
+ * the track table above; the alternative was a second ffmpeg over the proxy's
88
+ * own HTTP reading the same bytes again.
89
+ */
90
+ CONTAINER_MEDIA_INFO: "container-media-info",
91
+ /**
92
+ * Start fetching the region a viewer is about to resume at, named in seconds
93
+ * and turned into bytes here, where the file's own duration can be read.
94
+ */
95
+ WARM_POSITION: "warm-position",
84
96
  /** Cues of one subtitle track, from the clusters already downloaded. */
85
97
  SUBTITLE_CUES: "subtitle-cues",
86
98
  /** Shut the client down, optionally deleting downloaded data. */
@@ -28,7 +28,12 @@ import { createSendStream } from "./channel.js";
28
28
  import { createFileClaims } from "./file-claims.js";
29
29
  import { readFragments, supplyFiguresFor } from "./piece-reader.js";
30
30
  import { cuesHeldFor, declaredSubtitleTracksOf, subtitleTracksOf, warmSubtitleCues } from "./subtitle-cues.js";
31
- import { CONTAINER_HEAD_BYTES, containerTracksOf } from "./container-tracks.js";
31
+ import {
32
+ CONTAINER_HEAD_BYTES,
33
+ containerMediaInfoOf,
34
+ containerTracksOf,
35
+ warmResumePosition
36
+ } from "./container-tracks.js";
32
37
  import { fillFileInBackground } from "./background-fill.js";
33
38
  import { Command, Event } from "./protocol.js";
34
39
  import { startMemoryReport, WORKER_MEMORY_SAMPLE_MS } from "../memory-report.js";
@@ -418,6 +423,45 @@ async function runCommand(command, params, id) {
418
423
  };
419
424
  }
420
425
 
426
+ case Command.CONTAINER_MEDIA_INFO: {
427
+ const torrent = await requireTorrent(params.sourceKey);
428
+ return {
429
+ info: await containerMediaInfoOf(torrent, params.fileIndex, params.sourceKey, {
430
+ // Same reason as the track table above: the file this is asked about
431
+ // is often one nobody has played yet, so its head has to be fetched
432
+ // before there is anything to read.
433
+ prefetchEdges: () =>
434
+ pool.prefetchFileEdges(torrent, params.fileIndex, {
435
+ headBytes: CONTAINER_HEAD_BYTES,
436
+ tailBytes: 0,
437
+ timeoutMs: 60_000
438
+ })
439
+ })
440
+ };
441
+ }
442
+
443
+ case Command.WARM_POSITION: {
444
+ const torrent = await requireTorrent(params.sourceKey);
445
+ return {
446
+ started: await warmResumePosition(
447
+ torrent,
448
+ params.fileIndex,
449
+ params.sourceKey,
450
+ params.positionSeconds,
451
+ {
452
+ prefetchEdges: () =>
453
+ pool.prefetchFileEdges(torrent, params.fileIndex, {
454
+ headBytes: CONTAINER_HEAD_BYTES,
455
+ tailBytes: 0,
456
+ timeoutMs: 60_000
457
+ }),
458
+ fetchRegion: (start, bytes) =>
459
+ pool.prefetchFileRegion(torrent, params.fileIndex, start, bytes)
460
+ }
461
+ )
462
+ };
463
+ }
464
+
421
465
  case Command.SUBTITLE_CUES: {
422
466
  const torrent = await requireTorrent(params.sourceKey);
423
467
  const held = await cuesHeldFor(torrent, params.fileIndex, params.sourceKey, params.trackNumber);
@@ -0,0 +1,228 @@
1
+ /**
2
+ * @file What a file declares about ITSELF — format, duration, and where its own
3
+ * timeline begins — read from its header by the container layer.
4
+ *
5
+ * These fixtures are built byte by byte rather than produced by ffmpeg, on
6
+ * purpose: a test that runs a real encoder measures the machine it runs on, and
7
+ * two such tests in this suite have failed four times in one day for exactly
8
+ * that reason (roadmap item 54). The numbers here are checked against ffmpeg
9
+ * ONCE, by hand, and the result is recorded rather than re-measured on every
10
+ * run — 2026-09-03, a Matroska file offset by 0.130435 s: ffmpeg reported
11
+ * `Duration: 00:00:02.13, start: 0.130000` and this reader answered
12
+ * `durationSeconds 2.131, startTimeSeconds 0.13`, which is the same number at
13
+ * the precision each prints. The same file as MP4: `start: 0.000000` from
14
+ * ffmpeg, 0 from this reader.
15
+ */
16
+
17
+ import assert from "node:assert/strict";
18
+ import test from "node:test";
19
+
20
+ import { MatroskaContainer } from "../services/container/MatroskaContainer.js";
21
+ import { Mp4Container } from "../services/container/Mp4Container.js";
22
+ import { AviContainer } from "../services/container/AviContainer.js";
23
+
24
+ /**
25
+ * An EBML element: its id bytes, a four-byte size, then the payload.
26
+ *
27
+ * @param {number[]} idBytes
28
+ * @param {Buffer} payload
29
+ * @returns {Buffer}
30
+ */
31
+ function ebml(idBytes, payload) {
32
+ const size = Buffer.alloc(4);
33
+ // Four-byte size form: `0001xxxx` in the leading byte marks the width.
34
+ size.writeUInt32BE(payload.length);
35
+ size[0] |= 0x10;
36
+ return Buffer.concat([Buffer.from(idBytes), size, payload]);
37
+ }
38
+
39
+ /** @param {number} value @param {number} bytes @returns {Buffer} */
40
+ function uint(value, bytes) {
41
+ const out = Buffer.alloc(bytes);
42
+ out.writeUIntBE(value, 0, bytes);
43
+ return out;
44
+ }
45
+
46
+ /** @param {number} value @returns {Buffer} */
47
+ function float64(value) {
48
+ const out = Buffer.alloc(8);
49
+ out.writeDoubleBE(value);
50
+ return out;
51
+ }
52
+
53
+ const ID_EBML_HEADER = [0x1a, 0x45, 0xdf, 0xa3];
54
+ const ID_SEGMENT = [0x18, 0x53, 0x80, 0x67];
55
+ const ID_SEEK_HEAD = [0x11, 0x4d, 0x9b, 0x74];
56
+ const ID_SEEK = [0x4d, 0xbb];
57
+ const ID_SEEK_ID = [0x53, 0xab];
58
+ const ID_SEEK_POSITION = [0x53, 0xac];
59
+ const ID_INFO = [0x15, 0x49, 0xa9, 0x66];
60
+ const ID_TIMESTAMP_SCALE = [0x2a, 0xd7, 0xb1];
61
+ const ID_DURATION = [0x44, 0x89];
62
+ const ID_CLUSTER = [0x1f, 0x43, 0xb6, 0x75];
63
+ const ID_TIMESTAMP = [0xe7];
64
+ const ID_VOID = [0xec];
65
+
66
+ /**
67
+ * A reader over a buffer, in the shape the container layer takes.
68
+ *
69
+ * @param {Buffer} bytes
70
+ * @returns {(start: number, end: number) => Promise<Buffer>}
71
+ */
72
+ function readerOver(bytes) {
73
+ return async (start, end) => bytes.subarray(start, Math.min(end + 1, bytes.length));
74
+ }
75
+
76
+ test("a Matroska file states its duration in ticks and its start in the first cluster", async () => {
77
+ const info = ebml(ID_INFO, Buffer.concat([
78
+ ebml(ID_TIMESTAMP_SCALE, uint(1_000_000, 3)),
79
+ // 2131 ticks of a millisecond each.
80
+ ebml(ID_DURATION, float64(2131))
81
+ ]));
82
+ const cluster = ebml(ID_CLUSTER, ebml(ID_TIMESTAMP, uint(130, 1)));
83
+ const file = Buffer.concat([
84
+ ebml(ID_EBML_HEADER, Buffer.alloc(4)),
85
+ ebml(ID_SEGMENT, Buffer.concat([info, cluster]))
86
+ ]);
87
+
88
+ const container = new MatroskaContainer({ readRange: readerOver(file), fileSize: file.length });
89
+ const read = await container.readMediaInfo();
90
+
91
+ assert.equal(read.format, "matroska");
92
+ assert.ok(Math.abs(read.durationSeconds - 2.131) < 1e-9, `duration was ${read.durationSeconds}`);
93
+ assert.ok(Math.abs(read.startTimeSeconds - 0.13) < 1e-9, `start was ${read.startTimeSeconds}`);
94
+ });
95
+
96
+ test("a cluster past the head window is found through the SeekHead", async () => {
97
+ // Everything before the cluster is padded past the 64 KB the head read covers,
98
+ // which is the case this second path exists for: a file whose Tracks element
99
+ // is large enough to push the first cluster out of reach.
100
+ const info = ebml(ID_INFO, ebml(ID_TIMESTAMP_SCALE, uint(1_000_000, 3)));
101
+ const padding = ebml(ID_VOID, Buffer.alloc(70 * 1024));
102
+ const cluster = ebml(ID_CLUSTER, ebml(ID_TIMESTAMP, uint(2500, 2)));
103
+ // The SeekHead is written first, so its own length is known before the
104
+ // position it names can be computed — build it with a placeholder, measure,
105
+ // then write the real position.
106
+ const seekHeadFor = (position) => ebml(ID_SEEK_HEAD, ebml(ID_SEEK, Buffer.concat([
107
+ ebml(ID_SEEK_ID, Buffer.from(ID_CLUSTER)),
108
+ ebml(ID_SEEK_POSITION, uint(position, 4))
109
+ ])));
110
+ const seekHeadLength = seekHeadFor(0).length;
111
+ const clusterPosition = seekHeadLength + info.length + padding.length;
112
+ const segmentPayload = Buffer.concat([seekHeadFor(clusterPosition), info, padding, cluster]);
113
+ const file = Buffer.concat([
114
+ ebml(ID_EBML_HEADER, Buffer.alloc(4)),
115
+ ebml(ID_SEGMENT, segmentPayload)
116
+ ]);
117
+
118
+ const container = new MatroskaContainer({ readRange: readerOver(file), fileSize: file.length });
119
+ const read = await container.readMediaInfo();
120
+
121
+ assert.ok(Math.abs(read.startTimeSeconds - 2.5) < 1e-9, `start was ${read.startTimeSeconds}`);
122
+ });
123
+
124
+ test("a Matroska file that declares no duration says so, rather than saying zero", async () => {
125
+ const cluster = ebml(ID_CLUSTER, ebml(ID_TIMESTAMP, uint(0, 1)));
126
+ const file = Buffer.concat([
127
+ ebml(ID_EBML_HEADER, Buffer.alloc(4)),
128
+ ebml(ID_SEGMENT, cluster)
129
+ ]);
130
+
131
+ const container = new MatroskaContainer({ readRange: readerOver(file), fileSize: file.length });
132
+ const read = await container.readMediaInfo();
133
+
134
+ assert.equal(read.durationSeconds, null);
135
+ assert.equal(read.startTimeSeconds, 0);
136
+ });
137
+
138
+ /**
139
+ * An ISO/IEC 14496-12 box.
140
+ *
141
+ * @param {string} type
142
+ * @param {Buffer} payload
143
+ * @returns {Buffer}
144
+ */
145
+ function box(type, payload) {
146
+ const header = Buffer.alloc(8);
147
+ header.writeUInt32BE(payload.length + 8);
148
+ header.write(type, 4, "latin1");
149
+ return Buffer.concat([header, payload]);
150
+ }
151
+
152
+ test("an MP4 states its duration in mvhd and its start in an empty edit", async () => {
153
+ const mvhd = box("mvhd", Buffer.concat([
154
+ Buffer.alloc(4), // version 0 + flags
155
+ Buffer.alloc(8), // creation, modification
156
+ uint(1000, 4), // timescale: ticks per second
157
+ uint(2000, 4), // duration: two seconds
158
+ Buffer.alloc(80)
159
+ ]));
160
+ const elst = box("elst", Buffer.concat([
161
+ Buffer.alloc(4), // version 0 + flags
162
+ uint(1, 4), // one entry
163
+ uint(130, 4), // segment_duration: 0.130 s at the movie timescale
164
+ Buffer.from([0xff, 0xff, 0xff, 0xff]), // media_time -1: an EMPTY edit
165
+ uint(0x00010000, 4) // media_rate 1.0
166
+ ]));
167
+ const trak = box("trak", box("edts", elst));
168
+ const file = Buffer.concat([
169
+ box("ftyp", Buffer.from("isom", "latin1")),
170
+ box("moov", Buffer.concat([mvhd, trak]))
171
+ ]);
172
+
173
+ const container = new Mp4Container({ readRange: readerOver(file), fileSize: file.length });
174
+ const read = await container.readMediaInfo();
175
+
176
+ assert.equal(read.format, "mp4");
177
+ assert.ok(Math.abs(read.durationSeconds - 2) < 1e-9, `duration was ${read.durationSeconds}`);
178
+ assert.ok(Math.abs(read.startTimeSeconds - 0.13) < 1e-9, `start was ${read.startTimeSeconds}`);
179
+ });
180
+
181
+ test("an MP4 with no edit list begins at zero, and that is an answer", async () => {
182
+ const mvhd = box("mvhd", Buffer.concat([
183
+ Buffer.alloc(4),
184
+ Buffer.alloc(8),
185
+ uint(600, 4),
186
+ uint(1200, 4),
187
+ Buffer.alloc(80)
188
+ ]));
189
+ const file = Buffer.concat([
190
+ box("ftyp", Buffer.from("isom", "latin1")),
191
+ box("moov", mvhd)
192
+ ]);
193
+
194
+ const container = new Mp4Container({ readRange: readerOver(file), fileSize: file.length });
195
+ const read = await container.readMediaInfo();
196
+
197
+ assert.ok(Math.abs(read.durationSeconds - 2) < 1e-9, `duration was ${read.durationSeconds}`);
198
+ assert.equal(read.startTimeSeconds, 0);
199
+ });
200
+
201
+ test("an AVI states its length as microseconds per frame times the frame count", async () => {
202
+ const avih = Buffer.concat([
203
+ Buffer.from("avih", "latin1"),
204
+ uint(56, 4),
205
+ Buffer.from(new Uint8Array(new Uint32Array([
206
+ 40_000, // dwMicroSecPerFrame: 25 fps
207
+ 0, 0, 0,
208
+ 50 // dwTotalFrames: two seconds of them
209
+ ]).buffer)),
210
+ Buffer.alloc(36)
211
+ ]);
212
+ const file = Buffer.concat([
213
+ Buffer.from("RIFF", "latin1"),
214
+ uint(0, 4),
215
+ Buffer.from("AVI ", "latin1"),
216
+ Buffer.from("LIST", "latin1"),
217
+ uint(avih.length + 4, 4),
218
+ Buffer.from("hdrl", "latin1"),
219
+ avih
220
+ ]);
221
+
222
+ const container = new AviContainer({ readRange: readerOver(file), fileSize: file.length });
223
+ const read = await container.readMediaInfo();
224
+
225
+ assert.equal(read.format, "avi");
226
+ assert.ok(Math.abs(read.durationSeconds - 2) < 1e-9, `duration was ${read.durationSeconds}`);
227
+ assert.equal(read.startTimeSeconds, 0);
228
+ });