@torrent-tv/proxy 2.35.0 → 2.36.1

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.
@@ -157,21 +157,18 @@ const ENCODE_BENCHMARK_MAX_PLAUSIBLE_SPEED = 1000;
157
157
  const ENCODE_BENCHMARK_TIMEOUT_MS = 10_000;
158
158
  /** Progress reports arrive line by line. */
159
159
  const NEWLINE = String.fromCharCode(10);
160
- // Require the predicted speed to clear realtime by this much. The benchmarks
161
- // run at startup with an idle CPU; during playback ffmpeg competes with
162
- // in-process WebTorrent (download + hashing) and delivery, so real throughput
163
- // is lower, and the margin covers that plus complex scenes.
164
- //
165
- // It was 1.8 while the prediction counted ENCODING only and was therefore
166
- // several times too optimistic on a re-encode; with the decode term the
167
- // prediction is within ~13 % of measured, so the margin no longer has to stand
168
- // in for a missing term as well as for load.
169
- const PRESET_SPEED_MARGIN = 1.5;
170
- // The bar for a prediction that has NO decode term — a host whose calibration
171
- // clips are missing, or whose fit was rejected. That figure is the one the
172
- // margin was 1.8 for, and lowering it there would make an uncalibrated host
173
- // more permissive than it was before any of this existed.
174
- const ENCODE_ONLY_SPEED_MARGIN = 1.8;
160
+ // Producing one second of video per second of clock. Not a margin and not a
161
+ // choice the definition of keeping up, and the bar when nothing better is
162
+ // known about the supply this step will meet.
163
+ const REALTIME = 1;
164
+ // The bar where decoding CANNOT be priced — no calibration fit, or a source the
165
+ // probe said too little about. This one is not measured and cannot be: the
166
+ // prediction it guards counts encoding only, which on the field host was
167
+ // several times optimistic, and there is no reading on such a host to correct
168
+ // it with. It is left at the figure it has had since before decoding was
169
+ // priced, because lowering it to realtime would make the least-measured hosts
170
+ // the most permissive. Where decoding IS priced, nothing chosen remains.
171
+ const UNPRICED_DECODE_BAR = 1.8;
175
172
 
176
173
  /**
177
174
  * @param {number} targetWidth
@@ -1064,7 +1061,7 @@ export function predictedRealtimeSpeed({
1064
1061
  * The encoder figure is the FASTEST benchmarked preset: it is the best this
1065
1062
  * host can do, so a rung it cannot hold cannot be held at any quality setting.
1066
1063
  *
1067
- * @param {{ benchmark: Array<{ preset: string, pixelsPerSec: number }>, decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null, outputPixelsPerSec: number }} params
1064
+ * @param {{ benchmark: Array<{ preset: string, pixelsPerSec: number }>, decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null, outputPixelsPerSec: number, requiredSpeed?: number | null }} params
1068
1065
  * @returns {{ speed: number | null, sustainable: boolean }}
1069
1066
  */
1070
1067
  export function canSustainOutput({
@@ -1073,7 +1070,8 @@ export function canSustainOutput({
1073
1070
  source = null,
1074
1071
  outputPixelsPerSec,
1075
1072
  observedDecodeCostSec = null,
1076
- concurrentCostSec = 0
1073
+ concurrentCostSec = 0,
1074
+ requiredSpeed = null
1077
1075
  }) {
1078
1076
  if (!Array.isArray(benchmark) || benchmark.length === 0) {
1079
1077
  // Nothing measured on this host: the budget cannot refuse what it cannot
@@ -1114,11 +1112,45 @@ export function canSustainOutput({
1114
1112
  if (speed === null) {
1115
1113
  return { speed: null, sustainable: true };
1116
1114
  }
1117
- return { speed, sustainable: speed >= PRESET_SPEED_MARGIN };
1115
+ return { speed, sustainable: speed >= speedBar(requiredSpeed) };
1116
+ }
1117
+
1118
+ /**
1119
+ * The speed a step has to reach to be worth offering.
1120
+ *
1121
+ * Realtime is not enough on its own: a step that produces exactly one second
1122
+ * per second never recovers the seconds lost while its reader waits for the
1123
+ * swarm, so it survives its own supply only if what it gains between
1124
+ * interruptions covers what one interruption costs. That is measured per file
1125
+ * and per swarm by the reader — `1 + worst wait / median interval`, in
1126
+ * `supply-margin.js` — and on the field torrent of 2026-08-17 it came to 1.67
1127
+ * against the 1.5 that used to stand here, and to 4.04-8.14 on a torrent whose
1128
+ * swarm no encoder could have kept up with.
1129
+ *
1130
+ * Where that figure does not exist yet — fewer than two interruptions measured
1131
+ * — the bar is realtime. It is the one thing that can be said without
1132
+ * measuring the swarm, and the offer is restated as soon as the reader has
1133
+ * something to say.
1134
+ *
1135
+ * @param {number | null | undefined} requiredSpeed - What this file's own
1136
+ * interruptions demand, when they have been measured.
1137
+ * @returns {number}
1138
+ */
1139
+ export function speedBar(requiredSpeed) {
1140
+ return Number.isFinite(requiredSpeed) && requiredSpeed > REALTIME ? requiredSpeed : REALTIME;
1118
1141
  }
1119
1142
 
1120
- /** The margin a predicted speed must clear to be offered. */
1121
- export const REALTIME_SPEED_MARGIN = PRESET_SPEED_MARGIN;
1143
+ /**
1144
+ * The bar for a cost description — the supply's demand where decoding is
1145
+ * priced, and never below the unpriced-decode bar where it is not.
1146
+ *
1147
+ * @param {{ decodeModel?: object | null, source?: object | null, observedDecodeCostSec?: number | null, requiredSpeed?: number | null }} cost
1148
+ * @returns {number}
1149
+ */
1150
+ function barFor(cost) {
1151
+ const measured = speedBar(cost?.requiredSpeed);
1152
+ return isDecodePriced(cost) ? measured : Math.max(UNPRICED_DECODE_BAR, measured);
1153
+ }
1122
1154
 
1123
1155
  /**
1124
1156
  * Benchmark software libx264 presets on this host. Encodes a short synthetic
@@ -1377,7 +1409,7 @@ async function decodeToRawFrames(ffmpegBin, log) {
1377
1409
  *
1378
1410
  * @param {Array<{ preset: string, pixelsPerSec: number }>} benchmark - slowest→fastest
1379
1411
  * @param {number} pixelsPerSecNeeded
1380
- * @param {{ decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null }} [cost]
1412
+ * @param {{ decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null, requiredSpeed?: number | null }} [cost]
1381
1413
  * @returns {string}
1382
1414
  */
1383
1415
  /**
@@ -1405,8 +1437,7 @@ export function pickSoftwarePreset(benchmark, pixelsPerSecNeeded, cost = {}) {
1405
1437
  const observed = Number.isFinite(cost?.observedDecodeCostSec) && cost.observedDecodeCostSec > 0
1406
1438
  ? cost.observedDecodeCostSec
1407
1439
  : null;
1408
- const priced = isDecodePriced(cost);
1409
- const bar = priced ? PRESET_SPEED_MARGIN : ENCODE_ONLY_SPEED_MARGIN;
1440
+ const bar = barFor(cost);
1410
1441
  // The FIRST entry that clears the bar wins — the list is in quality order, so
1411
1442
  // that is the best picture this host can hold. Every entry is examined rather
1412
1443
  // than the walk stopping at the first miss, because the measurements do not
@@ -1493,9 +1524,9 @@ export function buildResolutionLadder(ceilingWidth, ceilingHeight) {
1493
1524
  * Choose the software encode settings (resolution + preset) that fit the
1494
1525
  * realtime budget on this host. From the resolution ladder (ceiling downward),
1495
1526
  * pick the HIGHEST rung whose encode throughput — predicted from the startup
1496
- * benchmark's fastest preset — clears realtime × PRESET_SPEED_MARGIN. Then, at
1497
- * that resolution, pick the highest-quality preset that still clears the
1498
- * margin. When even the lowest rung cannot clear it, use the lowest rung with
1527
+ * benchmark's fastest preset — clears the speed this file's own supply
1528
+ * demands (`speedBar`). Then, at that resolution, pick the highest-quality
1529
+ * preset that still clears it. When even the lowest rung cannot clear it, use the lowest rung with
1499
1530
  * the fastest preset (best effort — a smaller picture beats sub-realtime
1500
1531
  * playback at full size). Returns null when no benchmark or ceiling is
1501
1532
  * available (the caller keeps the ceiling resolution and the default preset).
@@ -1503,7 +1534,7 @@ export function buildResolutionLadder(ceilingWidth, ceilingHeight) {
1503
1534
  * @param {Array<{ preset: string, pixelsPerSec: number }>} benchmark - slowest→fastest
1504
1535
  * @param {{ width: number, height: number }} ceiling
1505
1536
  * @param {number} outputFps
1506
- * @param {{ decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null }} [cost]
1537
+ * @param {{ decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null, requiredSpeed?: number | null }} [cost]
1507
1538
  * @returns {{ width: number, height: number, preset: string, ladder: Array<{ width: number, height: number }>, rungIndex: number } | null}
1508
1539
  */
1509
1540
  export function chooseSoftwareEncodeSettings(benchmark, ceiling, outputFps, cost = {}) {
@@ -1516,7 +1547,7 @@ export function chooseSoftwareEncodeSettings(benchmark, ceiling, outputFps, cost
1516
1547
  return null;
1517
1548
  }
1518
1549
  const fastest = cheapestPresetPixelsPerSec(benchmark); // the cheapest preset's throughput
1519
- const bar = isDecodePriced(cost) ? PRESET_SPEED_MARGIN : ENCODE_ONLY_SPEED_MARGIN;
1550
+ const bar = barFor(cost);
1520
1551
  let chosenIndex = ladder.length - 1; // default: lowest rung (best effort)
1521
1552
  for (let i = 0; i < ladder.length; i += 1) {
1522
1553
  const speed = predictedRealtimeSpeed({
@@ -0,0 +1,79 @@
1
+ /**
2
+ * @file Publishing a value that is learned from repeated readings.
3
+ *
4
+ * Four costs are learned this way — what a file costs to decode, what copying
5
+ * it costs, what a soundtrack costs, what the torrent costs per megabyte — and
6
+ * each has the same two decisions to make: which figure the readings amount to,
7
+ * and when a new figure replaces the published one.
8
+ *
9
+ * The first is the median, because one starved moment must not drag the answer.
10
+ *
11
+ * The second used to be "more than five per cent", a figure nobody measured.
12
+ * It is now the scatter of the readings themselves: a median that has moved
13
+ * further than its own readings disagree with each other has moved for a
14
+ * reason, and one that has not is the same answer read again. Republishing
15
+ * costs something real — every session recomputes its offer, on the path that
16
+ * serves every playlist, init and segment — so the question is worth asking,
17
+ * but it is answered from the measurements rather than from a constant.
18
+ */
19
+
20
+ /**
21
+ * The middle of a set of readings, or null when there are none.
22
+ *
23
+ * @param {number[]} values
24
+ * @returns {number | null}
25
+ */
26
+ export function medianOf(values) {
27
+ const usable = (Array.isArray(values) ? values : []).filter((value) => Number.isFinite(value));
28
+ if (usable.length === 0) {
29
+ return null;
30
+ }
31
+ const sorted = [...usable].sort((left, right) => left - right);
32
+ const middle = Math.floor(sorted.length / 2);
33
+ return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle];
34
+ }
35
+
36
+ /**
37
+ * How far the readings sit from their own middle — the median of their
38
+ * distances to it.
39
+ *
40
+ * The median distance rather than the mean one, for the same reason the value
41
+ * itself is a median: a single disturbed reading describes the disturbance, not
42
+ * the host.
43
+ *
44
+ * @param {number[]} values
45
+ * @returns {number | null} Null when there is nothing to measure. Zero is a
46
+ * real answer: readings that all agree disagree by nothing.
47
+ */
48
+ export function scatterOf(values) {
49
+ const middle = medianOf(values);
50
+ if (middle === null) {
51
+ return null;
52
+ }
53
+ const distances = (Array.isArray(values) ? values : [])
54
+ .filter((value) => Number.isFinite(value))
55
+ .map((value) => Math.abs(value - middle));
56
+ return medianOf(distances);
57
+ }
58
+
59
+ /**
60
+ * Whether a newly computed median should replace the published one.
61
+ *
62
+ * @param {number | null} published - What every session is answering with now.
63
+ * @param {number} median - What the readings say today.
64
+ * @param {number[]} readings - The readings that median came from.
65
+ * @returns {boolean}
66
+ */
67
+ export function movedBeyondScatter(published, median, readings) {
68
+ if (!Number.isFinite(median)) {
69
+ return false;
70
+ }
71
+ if (!Number.isFinite(published)) {
72
+ return true; // nothing published yet; the first answer is always news
73
+ }
74
+ const scatter = scatterOf(readings);
75
+ if (scatter === null) {
76
+ return true;
77
+ }
78
+ return Math.abs(median - published) > scatter;
79
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * @file What the torrent itself costs this machine, per megabyte it moves.
3
+ *
4
+ * Downloading, verifying every piece and pushing segments down a data channel
5
+ * are work on the same box as the encoder, and they scale with what is moved.
6
+ * The reading is taken only while nothing is encoding, because that is the one
7
+ * moment the spending needs no arithmetic to attribute: what the process uses
8
+ * then is the torrent's.
9
+ *
10
+ * Except that it is not all the torrent's, and that is what this file exists
11
+ * for. Measured on the addon host 2026-08-18, one session reported **145.4 ms
12
+ * per megabyte over 8.7 MB** and **23.1 ms per megabyte over 54 MB** — the same
13
+ * host, the same minute, a sixfold disagreement that follows the size of the
14
+ * interval rather than anything about the torrent. A process with nothing to do
15
+ * still runs its timers, its tunnel and its session sweeps, and that draw does
16
+ * not shrink when fewer megabytes move: divided by a small number it swamps the
17
+ * answer. A minimum-megabytes threshold stood against exactly this and did not
18
+ * hold, because a chosen number was standing in for a measured one.
19
+ *
20
+ * The draw is measurable. An interval in which nothing encodes and the torrents
21
+ * move no bytes at all costs whatever this process spends anyway; subtract that
22
+ * from an interval which did move bytes, and what remains is the torrent's.
23
+ */
24
+
25
+ /**
26
+ * The share of one core this process draws while it has nothing to do.
27
+ *
28
+ * @param {{ cpuSeconds: number, elapsedSeconds: number }} interval - CPU seconds
29
+ * already divided by the number of cores, so the figure is comparable with
30
+ * the wall seconds every other cost in the budget is stated in.
31
+ * @returns {number | null} Null when the interval cannot be divided.
32
+ */
33
+ export function baseDrawFrom({ cpuSeconds, elapsedSeconds }) {
34
+ if (!Number.isFinite(cpuSeconds) || !Number.isFinite(elapsedSeconds) || !(elapsedSeconds > 0)) {
35
+ return null;
36
+ }
37
+ if (!(cpuSeconds >= 0)) {
38
+ return null;
39
+ }
40
+ return cpuSeconds / elapsedSeconds;
41
+ }
42
+
43
+ /**
44
+ * What one megabyte cost, once the draw that would have been spent anyway is
45
+ * taken off.
46
+ *
47
+ * The draw is subtracted, so what is left is only as certain as the draw is.
48
+ * The draw's own readings disagree with each other by a measured amount, and
49
+ * over an interval that disagreement is worth `scatter × elapsed` seconds of
50
+ * CPU: a reading whose remainder is smaller than that measures the wobble in
51
+ * the subtraction rather than the torrent. This is what the removed
52
+ * minimum-megabytes threshold was reaching for, arrived at from the readings
53
+ * instead of from a chosen size — a small interval fails it because little was
54
+ * moved in it, and a large one passes on the same arithmetic.
55
+ *
56
+ * @param {{ cpuSeconds: number, elapsedSeconds: number, megabytes: number, baseDraw: number | null, drawScatter?: number | null }} interval
57
+ * @returns {number | null} Seconds of work per megabyte, or null when this
58
+ * interval cannot say — no base draw measured yet, nothing moved, or the
59
+ * interval spent no more than the draw and its own uncertainty account for.
60
+ */
61
+ export function costPerMegabyteFrom({ cpuSeconds, elapsedSeconds, megabytes, baseDraw, drawScatter = null }) {
62
+ if (!Number.isFinite(cpuSeconds) || !Number.isFinite(elapsedSeconds) || !(elapsedSeconds > 0)) {
63
+ return null;
64
+ }
65
+ if (!Number.isFinite(megabytes) || !(megabytes > 0)) {
66
+ return null;
67
+ }
68
+ if (!Number.isFinite(baseDraw) || !(baseDraw >= 0)) {
69
+ // Nothing measured to subtract. Publishing the whole spending as the
70
+ // torrent's is what produced the 145 ms/MB reading, so this interval says
71
+ // nothing instead.
72
+ return null;
73
+ }
74
+ const attributable = cpuSeconds - baseDraw * elapsedSeconds;
75
+ if (!(attributable > 0)) {
76
+ // The draw accounts for everything this interval spent. Not a discovery
77
+ // that the torrent is free — a reading with nothing left in it.
78
+ return null;
79
+ }
80
+ const uncertainty = Number.isFinite(drawScatter) && drawScatter > 0 ? drawScatter * elapsedSeconds : 0;
81
+ if (!(attributable > uncertainty)) {
82
+ // What is left is inside the disagreement between the draw's own readings.
83
+ // Dividing it by a small number of megabytes is how 145 ms/MB was reported
84
+ // on a host that costs 23.
85
+ return null;
86
+ }
87
+ return attributable / megabytes;
88
+ }
@@ -24,7 +24,7 @@ import {
24
24
  canSustainOutput,
25
25
  decodeSpeedFor,
26
26
  predictedRealtimeSpeed,
27
- REALTIME_SPEED_MARGIN
27
+ speedBar
28
28
  } from "../services/hwaccel.js";
29
29
  import { HlsSessionManager, sourceDecodeCharacteristics } from "../services/hls-session-manager.js";
30
30
  import { parseFfmpegBitrateKbps, parseFfmpegVideoDimensions, parseFfmpegVideoFps } from "../services/ffmpeg-banner.js";
@@ -111,7 +111,7 @@ test("with no decode fit the prediction is the encoder alone — what it was bef
111
111
  assert.equal(speed, 5.99, "the old figure, four times the truth on that rung");
112
112
  });
113
113
 
114
- test("a rung is refused when the combined speed is under the margin", () => {
114
+ test("a rung is refused when the combined speed is under the bar", () => {
115
115
  // The addon host's fastest preset, read from its own log: 11.2 Mpx/s.
116
116
  const benchmark = [{ preset: "ultrafast", pixelsPerSec: 11.2e6 }];
117
117
  const source = MEASURED_FILM;
@@ -123,14 +123,10 @@ test("a rung is refused when the combined speed is under the margin", () => {
123
123
  outputPixelsPerSec: 1280 * 720 * 24
124
124
  });
125
125
  assert.equal(heavy.sustainable, false, "720p needs 22 Mpx/s from an 11.2 Mpx/s host");
126
- assert.ok(heavy.speed < REALTIME_SPEED_MARGIN);
127
-
128
- // And this is the gap the model does NOT close: the 240p rung predicts 1.58x
129
- // and clears a margin of 1.5, while the field measured that same rung at
130
- // 0.388-0.947x under real load — a host simultaneously copying 1080p,
131
- // downloading the torrent and pushing segments. The prediction is honest for
132
- // an idle machine; the margin is what has to carry the load, and 1.5 does not
133
- // carry it. Pinned here so the arithmetic is not rediscovered from a log.
126
+ assert.ok(heavy.speed < 1, `predicted ${heavy.speed.toFixed(2)}x`);
127
+
128
+ // The 240p rung of that film predicts 1.58x on an idle machine, and with
129
+ // nothing known about the swarm the bar is realtime, so it is offered.
134
130
  const light = canSustainOutput({
135
131
  benchmark,
136
132
  decodeModel: ADDON_HOST_MODEL,
@@ -141,6 +137,26 @@ test("a rung is refused when the combined speed is under the margin", () => {
141
137
  assert.equal(light.sustainable, true);
142
138
  });
143
139
 
140
+ test("the bar is what this file's own supply demands, when it has been measured", () => {
141
+ // The field torrent of 2026-08-17: waits of 1.49 s median arriving every
142
+ // 2.22 s demand 1.67x of any step that is to survive them. The same 240p rung
143
+ // predicted at 1.58x clears realtime and does not clear that.
144
+ const benchmark = [{ preset: "ultrafast", pixelsPerSec: 11.2e6 }];
145
+ const rung = {
146
+ benchmark,
147
+ decodeModel: ADDON_HOST_MODEL,
148
+ source: MEASURED_FILM,
149
+ outputPixelsPerSec: 426 * 240 * 24
150
+ };
151
+ assert.equal(canSustainOutput({ ...rung, requiredSpeed: 1.67 }).sustainable, false);
152
+ assert.equal(canSustainOutput({ ...rung, requiredSpeed: 1.2 }).sustainable, true);
153
+ // A swarm that has not been measured cannot raise the bar, and cannot lower
154
+ // it below realtime either.
155
+ assert.equal(speedBar(null), 1);
156
+ assert.equal(speedBar(0.4), 1);
157
+ assert.equal(speedBar(1.67), 1.67);
158
+ });
159
+
144
160
  test("a reading from the running encoder outranks the model of the clips", () => {
145
161
  // The field case, 2026-08-14: the 240p rung of that film ran at 0.95x at its
146
162
  // best on a host whose fastest preset benchmarked at 11.2 Mpx/s. Subtracting
@@ -340,8 +356,24 @@ test("the master playlist drops the rungs the host cannot hold", async (t) => {
340
356
  const stronger = { ...session, id: "dddddddd-eeee-ffff-0000-111111111111", offeredHeightsCache: undefined };
341
357
  manager.sessionsById.set(stronger.id, stronger);
342
358
  const master = manager.buildMasterPlaylist(stronger.id);
343
- assert.ok(master, "1080p copied plus the one rung this host can produce");
359
+ assert.ok(master, "1080p copied plus the rungs this host can produce");
344
360
  const heights = [...master.matchAll(/^v\/(\d+)\/index\.m3u8$/gm)].map((match) => Number(match[1]));
345
- assert.deepEqual(heights, [1080, 240]);
361
+ assert.deepEqual(heights, [1080, 360, 240], "nothing is known about this swarm, so the bar is realtime");
346
362
  assert.deepEqual(manager.offeredHeights(stronger), heights);
363
+
364
+ // The same host, once the reader has measured what this file's supply
365
+ // demands: waits arriving as they did on the field torrent of 2026-08-17 ask
366
+ // 1.67x of any step, and the rungs that only just cleared realtime go.
367
+ const onAThinSwarm = {
368
+ ...stronger,
369
+ id: "eeeeeeee-ffff-0000-1111-222222222222",
370
+ offeredHeightsCache: undefined,
371
+ supplyFigures: { requiredSpeed: 1.67, worstWaitSec: 1.49, medianIntervalSec: 2.22, samples: 12 }
372
+ };
373
+ manager.sessionsById.set(onAThinSwarm.id, onAThinSwarm);
374
+ assert.deepEqual(
375
+ manager.offeredHeights(onAThinSwarm),
376
+ [1080],
377
+ "the copied height costs no encoder and stays; nothing re-encoded survives that supply"
378
+ );
347
379
  });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * @file Adopting a new median only when it has moved further than the readings
3
+ * behind it disagree with each other.
4
+ */
5
+ import assert from "node:assert/strict";
6
+ import { test } from "node:test";
7
+ import { medianOf, movedBeyondScatter, scatterOf } from "../services/learned-median.js";
8
+
9
+ test("the median is the middle reading, and the mean of the two middle ones", () => {
10
+ assert.equal(medianOf([3, 1, 2]), 2);
11
+ assert.equal(medianOf([1, 2, 3, 4]), 2.5);
12
+ assert.equal(medianOf([]), null);
13
+ assert.equal(medianOf([Number.NaN]), null);
14
+ });
15
+
16
+ test("the scatter is the median distance from the middle", () => {
17
+ assert.equal(scatterOf([10, 10, 10]), 0);
18
+ assert.equal(scatterOf([8, 10, 12]), 2);
19
+ assert.equal(scatterOf([]), null);
20
+ });
21
+
22
+ test("the first answer is always adopted", () => {
23
+ assert.equal(movedBeyondScatter(null, 5, [5]), true);
24
+ });
25
+
26
+ test("a move smaller than the readings' own disagreement is the same answer", () => {
27
+ assert.equal(movedBeyondScatter(10, 11, [8, 10, 12]), false);
28
+ });
29
+
30
+ test("a move larger than that disagreement is adopted", () => {
31
+ assert.equal(movedBeyondScatter(10, 13, [8, 10, 12]), true);
32
+ });
33
+
34
+ test("readings that all agree let any change through", () => {
35
+ assert.equal(movedBeyondScatter(10, 10.4, [10, 10, 10]), true);
36
+ assert.equal(movedBeyondScatter(10, 10, [10, 10, 10]), false);
37
+ });
@@ -0,0 +1,192 @@
1
+ /**
2
+ * @file The container's table names a track, and only the picture's entries are
3
+ * cut points.
4
+ *
5
+ * Measured 2026-08-18 on the two files the field sessions were recorded from:
6
+ * `Minions.and.Monsters.1080p.mkv` carries 2778 cue entries for its video track
7
+ * — one every 2.002 s — and 4669 more across four subtitle tracks;
8
+ * `Moana.2 … MegaPeer.mkv` carries 1055 for video and 5007 across five subtitle
9
+ * tracks. Read without the track, both sets went into the cut list together,
10
+ * and ffmpeg — which can only cut a copied picture at a real keyframe at or
11
+ * after the time it is asked for — moved every such cut forward to the next
12
+ * keyframe. That is the whole of the disagreement the field reported: 2.002 s
13
+ * on the first file, a median of 6.3 s and a worst case of 21 s on the second,
14
+ * and never once negative.
15
+ */
16
+
17
+ import test from "node:test";
18
+ import assert from "node:assert/strict";
19
+ import { readMatroskaKeyframeTimes } from "../services/container-index/matroska.js";
20
+
21
+ const ID_EBML = 0x1a45dfa3;
22
+ const ID_SEGMENT = 0x18538067;
23
+ const ID_SEEK_HEAD = 0x114d9b74;
24
+ const ID_SEEK = 0x4dbb;
25
+ const ID_SEEK_ID = 0x53ab;
26
+ const ID_SEEK_POSITION = 0x53ac;
27
+ const ID_INFO = 0x1549a966;
28
+ const ID_TIMESTAMP_SCALE = 0x2ad7b1;
29
+ const ID_TRACKS = 0x1654ae6b;
30
+ const ID_TRACK_ENTRY = 0xae;
31
+ const ID_TRACK_NUMBER = 0xd7;
32
+ const ID_TRACK_TYPE = 0x83;
33
+ const ID_CUES = 0x1c53bb6b;
34
+ const ID_CUE_POINT = 0xbb;
35
+ const ID_CUE_TIME = 0xb3;
36
+ const ID_CUE_TRACK_POSITIONS = 0xb7;
37
+ const ID_CUE_TRACK = 0xf7;
38
+ const ID_CUE_CLUSTER_POSITION = 0xf1;
39
+
40
+ /** An element id, as the bytes the specification gives it. */
41
+ function idBytes(id) {
42
+ const bytes = [];
43
+ let rest = id;
44
+ while (rest > 0) {
45
+ bytes.unshift(rest & 0xff);
46
+ rest = Math.floor(rest / 256);
47
+ }
48
+ return Buffer.from(bytes);
49
+ }
50
+
51
+ /** A size, as a four-byte EBML variable-length integer. */
52
+ function sizeBytes(size) {
53
+ const buffer = Buffer.alloc(4);
54
+ buffer.writeUInt32BE(size, 0);
55
+ buffer[0] |= 0x10;
56
+ return buffer;
57
+ }
58
+
59
+ function element(id, payload) {
60
+ return Buffer.concat([idBytes(id), sizeBytes(payload.length), payload]);
61
+ }
62
+
63
+ /** An unsigned value, in as few bytes as it needs. */
64
+ function uintElement(id, value) {
65
+ const bytes = [];
66
+ let rest = value;
67
+ do {
68
+ bytes.unshift(rest & 0xff);
69
+ rest = Math.floor(rest / 256);
70
+ } while (rest > 0);
71
+ return element(id, Buffer.from(bytes));
72
+ }
73
+
74
+ function cuePoint(timeMs, tracks) {
75
+ return element(ID_CUE_POINT, Buffer.concat([
76
+ uintElement(ID_CUE_TIME, timeMs),
77
+ ...tracks.map((track) => element(ID_CUE_TRACK_POSITIONS, Buffer.concat([
78
+ uintElement(ID_CUE_TRACK, track),
79
+ uintElement(ID_CUE_CLUSTER_POSITION, 4096)
80
+ ])))
81
+ ]));
82
+ }
83
+
84
+ /**
85
+ * A file with the shape the field files have: one picture, one set of subtitles,
86
+ * and a table that indexes both.
87
+ *
88
+ * @param {{ withTracks?: boolean }} [options]
89
+ * @returns {Buffer}
90
+ */
91
+ function buildFile({ withTracks = true, cueTrack = null } = {}) {
92
+ const info = element(ID_INFO, uintElement(ID_TIMESTAMP_SCALE, 1_000_000));
93
+ const tracks = element(ID_TRACKS, Buffer.concat([
94
+ element(ID_TRACK_ENTRY, Buffer.concat([
95
+ uintElement(ID_TRACK_NUMBER, 1),
96
+ uintElement(ID_TRACK_TYPE, 1) // video
97
+ ])),
98
+ element(ID_TRACK_ENTRY, Buffer.concat([
99
+ uintElement(ID_TRACK_NUMBER, 2),
100
+ uintElement(ID_TRACK_TYPE, 17) // subtitles
101
+ ]))
102
+ ]));
103
+ const forPicture = cueTrack ?? 1;
104
+ const forSubtitles = cueTrack ?? 2;
105
+ const cues = element(ID_CUES, Buffer.concat([
106
+ cuePoint(0, [forPicture]),
107
+ cuePoint(1070, [forSubtitles]),
108
+ cuePoint(2002, [forPicture]),
109
+ cuePoint(3141, [forSubtitles]),
110
+ cuePoint(4004, [forPicture])
111
+ ]));
112
+
113
+ const seekEntry = (targetId, position) => element(ID_SEEK, Buffer.concat([
114
+ element(ID_SEEK_ID, idBytes(targetId)),
115
+ element(ID_SEEK_POSITION, (() => {
116
+ const buffer = Buffer.alloc(4);
117
+ buffer.writeUInt32BE(position, 0);
118
+ return buffer;
119
+ })())
120
+ ]));
121
+ // Positions are relative to the Segment's payload, so the SeekHead has to be
122
+ // measured before they can be stated. Its own length does not change when the
123
+ // placeholders become real values: every size and position here is written at
124
+ // a fixed width.
125
+ const draft = element(ID_SEEK_HEAD, Buffer.concat([
126
+ seekEntry(ID_INFO, 0),
127
+ ...(withTracks ? [seekEntry(ID_TRACKS, 0)] : []),
128
+ seekEntry(ID_CUES, 0)
129
+ ]));
130
+ const infoAt = draft.length;
131
+ const tracksAt = infoAt + info.length;
132
+ const cuesAt = withTracks ? tracksAt + tracks.length : infoAt + info.length;
133
+ const seekHead = element(ID_SEEK_HEAD, Buffer.concat([
134
+ seekEntry(ID_INFO, infoAt),
135
+ ...(withTracks ? [seekEntry(ID_TRACKS, tracksAt)] : []),
136
+ seekEntry(ID_CUES, cuesAt)
137
+ ]));
138
+
139
+ const segmentPayload = Buffer.concat(
140
+ withTracks ? [seekHead, info, tracks, cues] : [seekHead, info, cues]
141
+ );
142
+ return Buffer.concat([
143
+ element(ID_EBML, Buffer.from([0x42, 0x86, 0x81, 0x01])),
144
+ element(ID_SEGMENT, segmentPayload)
145
+ ]);
146
+ }
147
+
148
+ function readerOver(file) {
149
+ return async (start, end) => {
150
+ const last = Math.min(end, file.length - 1);
151
+ return start > last ? null : file.subarray(start, last + 1);
152
+ };
153
+ }
154
+
155
+ test("only the picture's entries become cut times", async () => {
156
+ const file = buildFile();
157
+
158
+ const times = await readMatroskaKeyframeTimes(readerOver(file), file.length);
159
+
160
+ assert.deepEqual(
161
+ times.map((time) => Number(time.toFixed(3))),
162
+ [0, 2.002, 4.004],
163
+ "the subtitle entries at 1.070 and 3.141 are not keyframes, and a cut asked for there lands late"
164
+ );
165
+ });
166
+
167
+ test("a table whose entries name no known track is still used", async () => {
168
+ // The cue points reference track 9, which Tracks never declares. Filtering
169
+ // leaves nothing — and returning nothing would put an even grid on a copied
170
+ // picture, the failure this reader exists to prevent.
171
+ const file = buildFile({ cueTrack: 9 });
172
+
173
+ const times = await readMatroskaKeyframeTimes(readerOver(file), file.length);
174
+
175
+ assert.deepEqual(
176
+ times.map((time) => Number(time.toFixed(3))),
177
+ [0, 1.07, 2.002, 3.141, 4.004],
178
+ "an unrecognised table beats no table at all"
179
+ );
180
+ });
181
+
182
+ test("a file whose tracks cannot be read keeps every entry", async () => {
183
+ const file = buildFile({ withTracks: false });
184
+
185
+ const times = await readMatroskaKeyframeTimes(readerOver(file), file.length);
186
+
187
+ assert.deepEqual(
188
+ times.map((time) => Number(time.toFixed(3))),
189
+ [0, 1.07, 2.002, 3.141, 4.004],
190
+ "with nothing to tell the tracks apart, the old behaviour is the only one available"
191
+ );
192
+ });