@torrent-tv/proxy 2.97.31 → 2.98.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.
package/bin/cli.js CHANGED
@@ -88,6 +88,7 @@ program
88
88
  .option("--no-transcode-audio", "Disable optional HLS AAC audio transcoding")
89
89
  .option("--no-port-mapping", "Disable automatic UPnP/NAT-PMP port mapping")
90
90
  .option("--delivery-sink", "Serve /api/delivery-sink, a torrent-free byte stream for delivery testing")
91
+ .option("--pin-preset <name>", "Encode every software picture at this libx264 preset only, for runs that must be compared at one quality (off by default)")
91
92
  .option("--profile-start <seconds>", "Profile the main thread's CPU for this many seconds from the first source opened, written beside the state (off by default)")
92
93
  .option(
93
94
  "--send-chunk-bytes <bytes>",
@@ -394,6 +395,7 @@ try {
394
395
  logFile: options.logFile ?? "",
395
396
  deliverySink: options.deliverySink === true,
396
397
  profileStartSeconds: Number(options.profileStart) || 0,
398
+ pinnedPreset: typeof options.pinPreset === "string" && options.pinPreset.trim() ? options.pinPreset.trim() : null,
397
399
  // Late-bound the same way `webRtcManager` is below: the torrent pool is
398
400
  // built inside `startProxyServer`, before `dataChannelHandler` — the
399
401
  // thing that actually owns a channel to push down — exists.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.97.31",
3
+ "version": "2.98.1",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
package/server.js CHANGED
@@ -127,6 +127,9 @@ export async function startProxyServer({
127
127
  deliverySink = false,
128
128
  // Seconds of main-thread CPU profile from the first source opened; 0 is off.
129
129
  profileStartSeconds = 0,
130
+ // The one software preset every picture is encoded at, for runs compared at
131
+ // one quality; null leaves the choice to the machine.
132
+ pinnedPreset = null,
130
133
  // What viewers' connections carry beyond the film, per byte of film; the
131
134
  // transport counts it and is built after this server, so it is late-bound.
132
135
  serviceShare = () => null,
@@ -798,6 +801,7 @@ export async function startProxyServer({
798
801
  });
799
802
  let sourcePreparation;
800
803
  const outputParts = wireOutputs({
804
+ pinnedPreset,
801
805
  indexMemory,
802
806
  serviceShare,
803
807
  readMetadataActivity: () => containerOrchestrator.activity(),
@@ -991,47 +991,64 @@ function lastOf(windows) {
991
991
 
992
992
  function deadlineReaderFor(windows, segmentSeconds) {
993
993
  const perSegment = segmentSeconds > 0 ? segmentSeconds : 0;
994
- return (index) => {
995
- let soonest = Number.POSITIVE_INFINITY;
996
- for (const span of windows) {
997
- if (index < span.from || index > span.to) {
998
- continue;
999
- }
1000
- const stated = /** @type {{ withinSeconds?: number }} */ (span).withinSeconds;
1001
- // A stretch stated with no time is somebody waiting at its near edge: that
1002
- // is what stating one means. The rest of it grows with the distance, the
1003
- // same as a stated one — read as due all at once instead, a window as wide
1004
- // as a viewer's cushion demanded its far end instantly and bought an
1005
- // encoder to stand beside one already working.
1006
- // `null` IS A STATEMENT AND IT SAYS NOBODY IS COMING. `undefined` is the
1007
- // absence of one, and a caller that knows only a position is somebody
1008
- // waiting at it.
1009
- //
1010
- // Read through `Number()`, `null` becomes 0 — due NOW — so the film BEHIND
1011
- // the viewers, which the map marks with exactly that, was the most urgent
1012
- // material in the file. Everything followed from it: it bought encoders,
1013
- // it took the run standing in front of the viewer because that run was the
1014
- // nearest encoder to it, and it did so again on every pass. Field 2026-09-08:
1015
- // 39 moves in one session, 24 between three adjacent numbers, one viewer
1016
- // on three encoders, and the picture stood still for 116.7 s in three
1017
- // interruptions, the worst of them 91.8 s.
1018
- //
1019
- // The map has always said it plainly — `{"from":0,"to":57,"priority":1,
1020
- // "withinSeconds":null,"behind":true}` is in the log of every session — and
1021
- // this line turned it into its opposite. Fourth time in this repository
1022
- // that the input to a calculation was not what the calculation assumed.
1023
- const within = stated === undefined ? 0 : (stated === null ? Number.NaN : Number(stated));
1024
- if (!Number.isFinite(within)) {
1025
- // Stated as no time at all: nobody is coming here.
1026
- continue;
1027
- }
994
+ // A TABLE BY SEGMENT, filled once per plan. Read for every segment of every
995
+ // arrangement the plan weighs, a search of every window for each index was
996
+ // the plan's own walk times the windows (r26, torrent-tv/meta#178). Filled
997
+ // span by span with the same rule, the soonest of overlapping spans kept.
998
+ let last = -1;
999
+ for (const span of windows) {
1000
+ if (Number.isFinite(span.to) && span.to > last) last = Math.floor(span.to);
1001
+ }
1002
+ const table = new Float64Array(last + 1).fill(Number.POSITIVE_INFINITY);
1003
+ for (const span of windows) {
1004
+ const within = withinOf(span);
1005
+ if (!Number.isFinite(within)) {
1006
+ // Stated as no time at all: nobody is coming here.
1007
+ continue;
1008
+ }
1009
+ for (let index = Math.max(0, Math.ceil(span.from)); index <= span.to; index += 1) {
1028
1010
  const here = within + (index - span.from) * perSegment;
1029
- if (here < soonest) {
1030
- soonest = here;
1011
+ if (here < table[index]) {
1012
+ table[index] = here;
1031
1013
  }
1032
1014
  }
1033
- return soonest;
1034
- };
1015
+ }
1016
+ return (index) => (index >= 0 && index < table.length ? table[index] : Number.POSITIVE_INFINITY);
1017
+ }
1018
+
1019
+ /**
1020
+ * When a window is first needed, in seconds from now: NaN where it says nobody
1021
+ * is coming.
1022
+ *
1023
+ * A stretch stated with no time is somebody waiting at its near edge: that
1024
+ * is what stating one means. The rest of it grows with the distance, the
1025
+ * same as a stated one — read as due all at once instead, a window as wide
1026
+ * as a viewer's cushion demanded its far end instantly and bought an
1027
+ * encoder to stand beside one already working.
1028
+ * `null` IS A STATEMENT AND IT SAYS NOBODY IS COMING. `undefined` is the
1029
+ * absence of one, and a caller that knows only a position is somebody
1030
+ * waiting at it.
1031
+ *
1032
+ * Read through `Number()`, `null` becomes 0 — due NOW — so the film BEHIND
1033
+ * the viewers, which the map marks with exactly that, was the most urgent
1034
+ * material in the file. Everything followed from it: it bought encoders,
1035
+ * it took the run standing in front of the viewer because that run was the
1036
+ * nearest encoder to it, and it did so again on every pass. Field 2026-09-08:
1037
+ * 39 moves in one session, 24 between three adjacent numbers, one viewer
1038
+ * on three encoders, and the picture stood still for 116.7 s in three
1039
+ * interruptions, the worst of them 91.8 s.
1040
+ *
1041
+ * The map has always said it plainly — `{"from":0,"to":57,"priority":1,
1042
+ * "withinSeconds":null,"behind":true}` is in the log of every session — and
1043
+ * that reading turned it into its opposite. Fourth time in this repository
1044
+ * that the input to a calculation was not what the calculation assumed.
1045
+ *
1046
+ * @param {WantedSpan} span
1047
+ * @returns {number}
1048
+ */
1049
+ function withinOf(span) {
1050
+ const stated = /** @type {{ withinSeconds?: number }} */ (span).withinSeconds;
1051
+ return stated === undefined ? 0 : (stated === null ? Number.NaN : Number(stated));
1035
1052
  }
1036
1053
 
1037
1054
  /**
@@ -1358,8 +1358,23 @@ export class Renditions {
1358
1358
  if (!base) {
1359
1359
  return null;
1360
1360
  }
1361
- if (!this.#host.qualityOffer.offeredHeightsFor(base).includes(height)) {
1362
- return null;
1361
+ const offered = this.#host.qualityOffer.offeredHeightsFor(base);
1362
+ if (!offered.includes(height)) {
1363
+ if (!this.#host.outputs.splicableHeights(base).includes(height)) {
1364
+ return null;
1365
+ }
1366
+ // A STEP THE MASTER LISTS AND THE OFFER NO LONGER HOLDS. The offer moves
1367
+ // with what the machine measures, and a page preparing a step it was
1368
+ // asked for can arrive after the ask was withdrawn: field r28, 360p
1369
+ // asked at 21:40:44.308, the offer down to 400p at 21:40:46.143, the
1370
+ // warm-up at 46.170 answered "no such variant" (torrent-tv/meta#178).
1371
+ // The step exists; it is not available now, and the page keeps what it
1372
+ // plays and follows the next ask.
1373
+ this.#host.logger.info(`transcode ${base.id} warming ${height}p for ${consumerId}: not offered now (offering ${offered.map(one => `${one}p`).join(" ") || "nothing"})`);
1374
+ const error = new Error(`${height}p is not offered now.`);
1375
+ error.code = OUTPUT_UNAVAILABLE;
1376
+ error.details = { reason: `${height}p is not offered on this machine now`, figures: { height, offeredHeights: offered } };
1377
+ throw error;
1363
1378
  }
1364
1379
  const index = this.#host.outputTimes.segmentIndexForTime(base, positionSeconds);
1365
1380
  const variant = await this.resolveVariantSession(baseSessionId, height, index, consumerId);
@@ -1715,7 +1715,8 @@ export function buildResolutionLadder(ceilingWidth, ceilingHeight) {
1715
1715
  * @param {{ width: number, height: number }} ceiling
1716
1716
  * @param {number} outputFps
1717
1717
  * @param {{ decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null, requiredSpeed?: number | null }} [cost]
1718
- * @returns {{ width: number, height: number, preset: string, ladder: Array<{ width: number, height: number }>, rungIndex: number } | null}
1718
+ * @returns {{ width: number, height: number, preset: string, ladder: Array<{ width: number, height: number }>, rungIndex: number, bar: number } | null}
1719
+ * `bar` is the speed the choice had to clear.
1719
1720
  */
1720
1721
  export function chooseSoftwareEncodeSettings(benchmark, ceiling, outputFps, cost = {}) {
1721
1722
  if (!Array.isArray(benchmark) || benchmark.length === 0) {
@@ -1750,7 +1751,7 @@ export function chooseSoftwareEncodeSettings(benchmark, ceiling, outputFps, cost
1750
1751
  }
1751
1752
  const chosen = ladder[chosenIndex];
1752
1753
  const preset = pickSoftwarePreset(benchmark, chosen.width * chosen.height * fps, cost, chosen);
1753
- return { width: chosen.width, height: chosen.height, preset, ladder, rungIndex: chosenIndex };
1754
+ return { width: chosen.width, height: chosen.height, preset, ladder, rungIndex: chosenIndex, bar };
1754
1755
  }
1755
1756
 
1756
1757
  /**
@@ -156,8 +156,8 @@ export class Timeline {
156
156
  * It lived in the session manager, which is a place, not a layer. Here it is
157
157
  * beside the table it converts against.
158
158
  *
159
- * @param {import("../../viewer/PriorityMap.js").PriorityMap} map - One number
160
- * per second of film.
159
+ * @param {import("../../viewer/PriorityMap.js").PriorityMap} map - What every
160
+ * viewer of this output wants, in seconds of film.
161
161
  * @param {number} segmentCount - How many pieces this output has.
162
162
  * @param {{ runsPastEnd?: boolean }} [shape] - `runsPastEnd`: the output's
163
163
  * pieces copy a stream, which is cut where its packets end, so a piece
@@ -188,22 +188,10 @@ export class Timeline {
188
188
  const until = index + 1 < segmentCount
189
189
  ? Math.max(from + 1, runsPastEnd ? Math.floor(end) + 1 : Math.ceil(end))
190
190
  : map.durationSeconds;
191
- let priority = 0;
192
- let withinSeconds = Number.POSITIVE_INFINITY;
193
- let behind = true;
194
- let urgent = false;
195
- for (let second = from; second < until && second < map.durationSeconds; second += 1) {
196
- if (map.priority[second] > priority) {
197
- priority = map.priority[second];
198
- }
199
- if (map.secondsUntilPlayed[second] < withinSeconds) {
200
- withinSeconds = map.secondsUntilPlayed[second];
201
- }
202
- if (map.behind[second] === 0) {
203
- behind = false;
204
- }
205
- if (map.urgent?.[second]) urgent = true;
206
- }
191
+ // Asked of the map for the piece's seconds as a whole, not second by
192
+ // second: a walk of every second of the film for every output on every
193
+ // plan (torrent-tv/meta#178).
194
+ const { priority, withinSeconds, behind, urgent } = map.on(from, until);
207
195
  if (priority === 0) {
208
196
  continue;
209
197
  }
@@ -1103,7 +1103,8 @@ export class EncodeCost {
1103
1103
  sourceHeight,
1104
1104
  outputFps,
1105
1105
  source = null,
1106
- requiredSpeed = null
1106
+ requiredSpeed = null,
1107
+ observedDecodeCostSec = null
1107
1108
  }) {
1108
1109
  if (!transcodeVideo || this.#host().encoderKind !== "software" || !this.#host().benchmark) {
1109
1110
  return null;
@@ -1113,26 +1114,44 @@ export class EncodeCost {
1113
1114
  return null;
1114
1115
  }
1115
1116
  const concurrentCostSec = this.#committedMachineCostSec();
1117
+ // THE FILE'S OWN DECODE COST, where its encoder has already been read: the
1118
+ // opening had it and the choice priced decoding from the startup model
1119
+ // instead (review of r28, torrent-tv/meta#178).
1120
+ const decodeCostSec = Number.isFinite(observedDecodeCostSec) && observedDecodeCostSec > 0 ? observedDecodeCostSec : null;
1121
+ /** Every size and preset the choice priced, with the speed it was priced at. */
1122
+ const considered = [];
1116
1123
  const speedForPreset = (benchmark, frame, outputPixelsPerSec) => {
1117
- if (concurrentCostSec === null) {
1118
- return null;
1119
- }
1120
- return this.#speedOnThisMachine({
1124
+ const speed = concurrentCostSec === null ? null : this.#speedOnThisMachine({
1121
1125
  benchmark,
1122
1126
  source,
1123
1127
  width: frame.width,
1124
1128
  height: frame.height,
1125
1129
  fps: outputPixelsPerSec / (frame.width * frame.height),
1126
- observedDecodeCostSec: null,
1130
+ observedDecodeCostSec: decodeCostSec,
1127
1131
  concurrentCostSec
1128
1132
  });
1133
+ considered.push({ preset: benchmark[0]?.preset, width: frame.width, height: frame.height, speed });
1134
+ return speed;
1129
1135
  };
1130
- return chooseSoftwareEncodeSettings(
1136
+ const choice = chooseSoftwareEncodeSettings(
1131
1137
  this.#host().benchmark,
1132
1138
  { width: ceiling.w, height: ceiling.h },
1133
1139
  outputFps,
1134
- { decodeModel: this.#host().decodeModel, source, requiredSpeed, speedForPreset }
1140
+ { decodeModel: this.#host().decodeModel, source, requiredSpeed, speedForPreset, observedDecodeCostSec: decodeCostSec }
1135
1141
  );
1142
+ // WHAT THE CHOICE WAS MADE FROM, said once per choice: three runs of one
1143
+ // file on one host opened at three presets and nothing said why (r25, r26,
1144
+ // r28, torrent-tv/meta#178).
1145
+ if (choice) {
1146
+ const availability = this.#host().availability;
1147
+ const priced = considered.map(({ preset, width, height, speed }) =>
1148
+ `${width}x${height} ${preset}=${Number.isFinite(speed) ? `${speed.toFixed(2)}x` : "unpriced"}`).join(", ");
1149
+ logger.info(`encode choice: ${choice.width}x${choice.height}@${Number(outputFps).toFixed(3)} ${choice.preset} against ${choice.bar.toFixed(2)}x; ` +
1150
+ `priced ${priced}; decoding ${decodeCostSec === null ? "from the startup model" : `${decodeCostSec.toFixed(4)} s per second, measured on this file`}; ` +
1151
+ `machine ${availability?.known ? `${Math.round(availability.share * 100)}% to spare` : "share unknown"}; ` +
1152
+ `committed work ${concurrentCostSec === null ? "unmeasured" : `${concurrentCostSec.toFixed(3)} s per second`}`);
1153
+ }
1154
+ return choice;
1136
1155
  }
1137
1156
  }
1138
1157
 
@@ -144,7 +144,8 @@ export function decideOutputFormat({
144
144
  sourceHeight: source.height,
145
145
  outputFps: fps,
146
146
  source: source.decode,
147
- requiredSpeed: cost.requiredSpeed
147
+ requiredSpeed: cost.requiredSpeed,
148
+ observedDecodeCostSec: cost.observedDecodeCostSec ?? null
148
149
  });
149
150
  const width = budget?.width ?? ceiling?.w ?? 0;
150
151
  const height = budget?.height ?? ceiling?.h ?? 0;
@@ -189,6 +189,9 @@ export function wireOutputs({
189
189
  keyframeTables = new KeyframeTables(),
190
190
  videoEncoder = null,
191
191
  softwarePresetBenchmark = null,
192
+ // A software preset every picture is encoded at, for runs compared at one
193
+ // quality (`--pin-preset`); null leaves the choice to the machine.
194
+ pinnedPreset = null,
192
195
  // What every encoder this host may use was qualified and measured to do at
193
196
  // startup, by kind (`encode/calibration.js`). Takes the place of the one
194
197
  // array above, which a test may still hand in on its own.
@@ -771,8 +774,26 @@ export function wireOutputs({
771
774
  // Null where nothing was measured, and then nothing is re-encoded here.
772
775
  parts.calibration = calibration ?? null;
773
776
  const givenModes = Array.isArray(softwarePresetBenchmark) ? softwarePresetBenchmark : null;
777
+ // ONE PRESET FOR RUNS COMPARED AT ONE QUALITY. Three runs of one film on one
778
+ // host opened at `faster`, `veryfast` and `fast`, so no change could be
779
+ // compared at the same picture (r25, r26, r28, torrent-tv/meta#178). Pinned
780
+ // here, every decision that prices or chooses a software mode — the choice,
781
+ // the offer, the machine's admission and the command — sees the one mode,
782
+ // measured as calibrated. A preset calibration did not qualify is not
783
+ // pinned: the machine's own list stands, and the log says so.
784
+ let pinWarned = false;
785
+ const pinned = (modes) => {
786
+ if (!pinnedPreset || !Array.isArray(modes) || parts.encoders.current?.kind !== "software") return modes;
787
+ const kept = modes.filter(mode => mode.preset === pinnedPreset);
788
+ if (kept.length > 0) return kept;
789
+ if (!pinWarned) {
790
+ pinWarned = true;
791
+ logger.warn(`encode: --pin-preset ${pinnedPreset} names no calibrated mode (${modes.map(mode => mode.preset).join(", ")}); not pinned`);
792
+ }
793
+ return modes;
794
+ };
774
795
  Object.defineProperty(parts, "softwarePresetBenchmark", {
775
- get: () => (parts.calibration
796
+ get: () => pinned(parts.calibration
776
797
  ? parts.calibration.modesFor(parts.encoders.current?.kind)
777
798
  : givenModes),
778
799
  enumerable: true
@@ -39,6 +39,7 @@ export class DownloadMaps {
39
39
  const now = Date.now();
40
40
  file.sourceZones = zones.map(zone => Number.isFinite(zone.deadlineAt) || !Number.isFinite(zone.withinSeconds)
41
41
  ? zone : { ...zone, deadlineAt: now + Math.max(0, zone.withinSeconds) * 1000 });
42
+ const sourceZones = zoneIndexOf(file);
42
43
  let changed = false;
43
44
  // Until the new zones are resolved to bytes, a resolved zone stays while
44
45
  // the film it covers is still wanted. A viewer moving on re-cuts every zone
@@ -49,9 +50,9 @@ export class DownloadMaps {
49
50
  // a seek away — is withdrawn at once.
50
51
  const retained = file.zones.flatMap(zone => {
51
52
  const interval = zone.downloadInterval ?? zone;
52
- const current = file.sourceZones.find(candidate => candidate.from === interval.from && candidate.to === interval.to);
53
+ const current = sourceZones.exactly(interval);
53
54
  if (!current) {
54
- const stillWanted = file.sourceZones.some(candidate => candidate.from < interval.to && interval.from < candidate.to);
55
+ const stillWanted = sourceZones.overlaps(interval);
55
56
  if (!stillWanted) changed = true;
56
57
  return stillWanted ? [zone] : [];
57
58
  }
@@ -76,7 +77,7 @@ export class DownloadMaps {
76
77
  changed = true;
77
78
  }
78
79
  else if (metadata.interval) {
79
- const current = file.sourceZones.find(zone => zone.from === metadata.interval.from && zone.to === metadata.interval.to);
80
+ const current = sourceZones.exactly(metadata.interval);
80
81
  const deadlineAt = Number.isFinite(current.deadlineAt) ? current.deadlineAt - metadata.leadSeconds * 1000 : current.deadlineAt;
81
82
  if (metadata.priority !== current.priority || metadata.urgent !== current.urgent || metadata.deadlineAt !== deadlineAt) changed = true;
82
83
  metadata.priority = current.priority;
@@ -147,7 +148,8 @@ export class DownloadMaps {
147
148
  }
148
149
 
149
150
  wantsInterval(sourceKey, fileIndex, { from, to }) {
150
- return this.#files.get(`${sourceKey}:${fileIndex}`)?.sourceZones.some(zone => zone.from === from && zone.to === to) ?? false;
151
+ const file = this.#files.get(`${sourceKey}:${fileIndex}`);
152
+ return Boolean(file && zoneIndexOf(file).exactly({ from, to }));
151
153
  }
152
154
 
153
155
  metadata({ sourceKey, fileIndex, statement, result, priority = 100, urgent = true, deadlineAt = 0, interval, leadSeconds = 0, scope = "playback", order = 0 }) {
@@ -155,7 +157,7 @@ export class DownloadMaps {
155
157
  const file = this.#file(sourceKey, fileIndex);
156
158
  if (result.kind === "needs-ranges") {
157
159
  if (interval && !this.wantsInterval(sourceKey, fileIndex, interval)) return Promise.resolve();
158
- const current = interval && file.sourceZones.find(zone => zone.from === interval.from && zone.to === interval.to);
160
+ const current = interval && zoneIndexOf(file).exactly(interval);
159
161
  if (current) {
160
162
  priority = current.priority;
161
163
  urgent = current.urgent;
@@ -372,6 +374,60 @@ export class DownloadMaps {
372
374
  }
373
375
  }
374
376
 
377
+ /**
378
+ * The map zones of one statement, looked up by interval.
379
+ *
380
+ * Every zone kept from the last pass asks which zone of the new map it belongs
381
+ * to, by its exact interval, and if none, whether any new zone still overlaps
382
+ * it — a search of every new zone for every kept one, thousands by tens on
383
+ * every statement over a film (r26, torrent-tv/meta#178). The exact match is
384
+ * a table, and the overlap a binary search over the zones, which a map states
385
+ * ascending and apart; zones in any other order are searched as before.
386
+ */
387
+ class ZoneIndex {
388
+ /** The list indexed: replaced whole, never edited in place, so it says whether the index is current. */
389
+ zones;
390
+ #byInterval = new Map();
391
+ #ordered = true;
392
+
393
+ /** @param {Array<{ from: number, to: number }>} zones */
394
+ constructor(zones) {
395
+ this.zones = zones;
396
+ zones.forEach((zone, index) => {
397
+ const key = `${zone.from}:${zone.to}`;
398
+ // The first of equals, as a search from the start would find.
399
+ if (!this.#byInterval.has(key)) this.#byInterval.set(key, zone);
400
+ if (index > 0 && !(zone.from >= zones[index - 1].to)) this.#ordered = false;
401
+ });
402
+ }
403
+
404
+ /** The zone with exactly this interval. @param {{ from: number, to: number }} interval */
405
+ exactly({ from, to }) {
406
+ return this.#byInterval.get(`${from}:${to}`);
407
+ }
408
+
409
+ /** Whether any zone overlaps this interval. @param {{ from: number, to: number }} interval */
410
+ overlaps({ from, to }) {
411
+ const zones = this.zones;
412
+ if (!this.#ordered) return zones.some(zone => zone.from < to && from < zone.to);
413
+ // The first zone that ends after `from`; it overlaps if it begins before `to`.
414
+ let low = 0;
415
+ let high = zones.length;
416
+ while (low < high) {
417
+ const middle = (low + high) >> 1;
418
+ if (zones[middle].to <= from) low = middle + 1;
419
+ else high = middle;
420
+ }
421
+ return low < zones.length && zones[low].from < to;
422
+ }
423
+ }
424
+
425
+ /** The index of a file's map zones, made again when they have been replaced. */
426
+ function zoneIndexOf(file) {
427
+ if (file.sourceIndex?.zones !== file.sourceZones) file.sourceIndex = new ZoneIndex(file.sourceZones);
428
+ return file.sourceIndex;
429
+ }
430
+
375
431
  /**
376
432
  * Zones resolved so far, with the retained zones of every segment they do not
377
433
  * yet resolve again: a segment's bytes are stated once, by its newest reading.
@@ -15,7 +15,7 @@ export async function nativeOriginalSourceMap({ container, viewers, durationSeco
15
15
  const input = await container.readMappedSourceRanges({ from: zone.from + startTimeSeconds,
16
16
  to: Math.min(durationSeconds, zone.to) + startTimeSeconds });
17
17
  if (input.kind !== "result") continue;
18
- const links = viewers.filter((_viewer, index) => maps[index].priority[zone.from] === zone.priority)
18
+ const links = viewers.filter((_viewer, index) => maps[index].at(zone.from).priority === zone.priority)
19
19
  .map(viewer => viewer.linkReading()?.linkMbps).filter(rate => Number.isFinite(rate) && rate > 0);
20
20
  const bytes = input.ranges.reduce((sum, [start, end]) => sum + end - start + 1, 0);
21
21
  const deliverySeconds = links.length ? bytes * 8 / (Math.min(...links) * 1_000_000) : 0;
@@ -46,7 +46,7 @@ export function nativeSourceMap({ index, tracks, viewers, durationSeconds, start
46
46
  const input = index.inputFor({ trackId: track.trackNumber, from, to });
47
47
  if (input.kind === "needs-index") continue;
48
48
  if (input.kind !== "result") throw new Error(`Native source input refused: ${input.reason}`);
49
- const links = viewers.filter((_viewer, viewerIndex) => maps[viewerIndex].priority[zone.from] === zone.priority)
49
+ const links = viewers.filter((_viewer, viewerIndex) => maps[viewerIndex].at(zone.from).priority === zone.priority)
50
50
  .map(viewer => viewer.linkReading()?.linkMbps).filter(rate => Number.isFinite(rate) && rate > 0);
51
51
  const bytes = input.ranges.reduce((sum, [start, end]) => sum + end - start + 1, 0);
52
52
  const deliverySeconds = links.length ? bytes * 8 / (Math.min(...links) * 1_000_000) : 0;
@@ -44,34 +44,45 @@
44
44
  */
45
45
 
46
46
  /**
47
- * THE MAP ITSELF: one number per second of film.
48
- *
49
- * Literally an array as long as the film, where the value at second `x` says how
50
- * urgently that second is wanted. Stated by the user 2026-09-06, and it replaced
51
- * a list of stretches each carrying two numbers.
52
- *
53
- * Two arrays rather than one, because two different questions are asked of the
54
- * map and only one of them can be answered by a rank:
47
+ * THE MAP ITSELF: what each viewer wants, kept as the rules that say it, and
48
+ * answered for a second, for a stretch, or as the stretches of one value.
49
+ *
50
+ * The rules are the ones the map has had since 2026-09-06, when an array of one
51
+ * number per second of film replaced a list of stretches: a priority for every
52
+ * second, the time until a viewer plays it, and whether it lies behind every
53
+ * viewer, is wanted before playback can continue, or has been yielded by a
54
+ * viewer who stopped. What changed (torrent-tv/meta#178) is only how they are
55
+ * held. Every one of those values is a step function of the distance from a
56
+ * viewer, with steps the rules themselves place — where the viewer stands, the
57
+ * end of the urgent allowance, the doubling bands of priority — and the time is
58
+ * that distance. Built as arrays the length of the film, a map cost a pass over
59
+ * every second for every viewer and again for the merge, on every move of a
60
+ * viewer: on Home Assistant the main thread built them 21-61 times a minute
61
+ * over a two-hour film (r26). Held as rules, a question costs what the viewers
62
+ * and their steps cost, and nothing per second of film.
63
+ *
64
+ * Two different questions are asked of the map and only one of them can be
65
+ * answered by a rank:
55
66
  *
56
67
  * - `priority` orders the work. Only the ORDER between values is meaningful;
57
68
  * the scale below says what the numbers separate;
58
- * - `secondsUntilPlayed` is a quantity, and it is what makes lateness
59
- * computable at all: whoever fills the map compares "when is this needed"
60
- * against "when would it arrive", and only the second half is theirs to work
61
- * out. `Infinity` where nobody is on their way.
62
- *
63
- * @typedef {object} PriorityMap
64
- * @property {number} durationSeconds - The film's length, in whole seconds.
65
- * @property {Uint8Array} priority - One entry per second. Zero means nothing is
66
- * wanted there, which happens only where nobody is watching at all.
67
- * @property {Float64Array} secondsUntilPlayed - One entry per second.
68
- * @property {Uint8Array} behind - One entry per second, 1 where the second lies
69
- * behind EVERY viewer. Stated rather than worked out from the other two,
70
- * because working it out means knowing this file's scale, and the consumers
71
- * are in other layers. Inferred from the absence of a time, as it was, it was
72
- * wrong for a stopped viewer: their whole film carries no time, so the film in
73
- * front of them counted as behind them and the encoders wandered to the start
74
- * of the file.
69
+ * - the time until a second is played is a quantity, and it is what makes
70
+ * lateness computable at all: whoever fills the map compares "when is this
71
+ * needed" against "when would it arrive", and only the second half is theirs
72
+ * to work out. `Infinity` where nobody is on their way.
73
+ *
74
+ * The merge keeps what it always did: the highest priority and the soonest
75
+ * time, taken separately — two viewers can tie on the coarse priority while one
76
+ * is genuinely nearer; in front of ANYBODY is in front; a second is urgent if
77
+ * any viewer needs it before playback can continue; and it is deferred only if
78
+ * every viewer in front of it has yielded it.
79
+ *
80
+ * @typedef {object} MapPoint
81
+ * @property {number} priority - Zero where nobody wants it.
82
+ * @property {number} untilSeconds - Seconds until a viewer plays it.
83
+ * @property {boolean} behind - Behind every viewer.
84
+ * @property {boolean} urgent
85
+ * @property {boolean} deferred
75
86
  */
76
87
 
77
88
  /**
@@ -87,6 +98,7 @@
87
98
  * @property {boolean} behind - Whether this lies behind every viewer.
88
99
  */
89
100
 
101
+
90
102
  /**
91
103
  * Wanted seconds use priorities 1..100. Distance lowers priority relative to
92
104
  * the measured urgent allowance. Pause preserves the same priorities until
@@ -167,28 +179,27 @@ function stepsAway(distanceSeconds, allowanceSeconds) {
167
179
  return Math.floor(Math.log2(1 + distanceSeconds / allowanceSeconds));
168
180
  }
169
181
 
182
+ /** Where nothing is wanted. */
183
+ const NO_DEMAND = Object.freeze({ priority: NOTHING, untilSeconds: Number.POSITIVE_INFINITY, behind: true, urgent: false, deferred: false });
184
+
170
185
  /**
171
- * An empty map of the right length: nothing wanted anywhere.
186
+ * The first whole distance whose step is at least `step`, worked out by the
187
+ * same arithmetic the step is read with, so a boundary is exactly where the
188
+ * value changes.
172
189
  *
173
- * @param {number} durationSeconds
174
- * @returns {PriorityMap}
190
+ * @param {number} step
191
+ * @param {number} allowanceSeconds
192
+ * @returns {number}
175
193
  */
176
- export function emptyMap(durationSeconds) {
177
- const seconds = Number.isFinite(durationSeconds) && durationSeconds > 0
178
- ? Math.ceil(durationSeconds)
179
- : 0;
180
- return {
181
- durationSeconds: seconds,
182
- priority: new Uint8Array(seconds),
183
- secondsUntilPlayed: new Float64Array(seconds).fill(Number.POSITIVE_INFINITY),
184
- behind: new Uint8Array(seconds).fill(1),
185
- urgent: new Uint8Array(seconds),
186
- deferred: new Uint8Array(seconds)
187
- };
194
+ function firstDistanceOfStep(step, allowanceSeconds) {
195
+ let distance = Math.ceil(allowanceSeconds * (2 ** step - 1));
196
+ while (distance > 0 && stepsAway(distance - 1, allowanceSeconds) >= step) distance -= 1;
197
+ while (stepsAway(distance, allowanceSeconds) < step) distance += 1;
198
+ return distance;
188
199
  }
189
200
 
190
201
  /**
191
- * One viewer's map.
202
+ * One viewer's demand: the rules of their map, with where they stand.
192
203
  *
193
204
  * **The number is a reading of how soon they will be there.** A viewer watching
194
205
  * forward reaches second `x` after `x - p` seconds of film. That distance — not
@@ -198,195 +209,303 @@ export function emptyMap(durationSeconds) {
198
209
  * Pause retains urgent preparation first. Once that work is ready, the
199
210
  * allowance / (allowance + pauseSeconds) coefficient gradually reduces this
200
211
  * viewer's priorities if others consume the file. Resume restores them.
201
- *
202
- * **What must NOT be here**, and the boundary is the point: how fast this
203
- * machine encodes, how many encoders that takes, what a second weighs in bytes,
204
- * where a piece boundary falls, and what has already been made. Those are
205
- * answers the encoding and the downloading work out for themselves, from this
206
- * map and from what each knows about itself.
207
- *
208
- * @param {object} params
209
- * @param {number} params.atSeconds - Where they are watching from.
210
- * @param {number} params.durationSeconds - How long the film is.
211
- * @param {number} params.allowanceSeconds - The measured depth below which an
212
- * interruption reaches this viewer. The width of the first step.
213
- * @param {boolean} [params.playing] - Whether the picture is moving.
214
- * @returns {PriorityMap}
215
212
  */
216
- export function pauseCoefficient({ playing, urgentReady, viewerCount, pauseSeconds, allowanceSeconds }) {
217
- if (playing !== false || !urgentReady || viewerCount <= 1) return 1;
218
- const allowance = Number.isFinite(allowanceSeconds) && allowanceSeconds > 0 ? allowanceSeconds : 1;
219
- const elapsed = Number.isFinite(pauseSeconds) ? Math.max(0, pauseSeconds) : 0;
220
- return allowance / (allowance + elapsed);
221
- }
213
+ class ViewerDemand {
214
+ /**
215
+ * @param {{ atSeconds: number, durationSeconds: number, allowanceSeconds: number, playing?: boolean,
216
+ * pauseSeconds?: number, urgentReady?: boolean, viewerCount?: number }} params
217
+ */
218
+ constructor({ atSeconds, durationSeconds, allowanceSeconds, playing = true, pauseSeconds = 0, urgentReady = false, viewerCount = 1 }) {
219
+ this.durationSeconds = Number.isFinite(durationSeconds) && durationSeconds > 0 ? Math.ceil(durationSeconds) : 0;
220
+ this.at = Number.isFinite(atSeconds) && atSeconds > 0 ? Math.floor(atSeconds) : 0;
221
+ this.allowance = Number.isFinite(allowanceSeconds) && allowanceSeconds > 0 ? allowanceSeconds : this.durationSeconds;
222
+ // The measured urgent span is the time scale. There is no chosen decay
223
+ // period: one span on pause halves the excess above minimum priority.
224
+ this.attenuate = playing === false && urgentReady && viewerCount > 1;
225
+ this.elapsed = Number.isFinite(pauseSeconds) ? Math.max(0, pauseSeconds) : 0;
226
+ this.coefficient = pauseCoefficient({ playing, urgentReady, viewerCount, pauseSeconds: this.elapsed, allowanceSeconds: this.allowance });
227
+ }
222
228
 
223
- export function mapForViewer({ atSeconds, durationSeconds, allowanceSeconds, playing = true,
224
- pauseSeconds = 0, urgentReady = false, viewerCount = 1 }) {
225
- const map = emptyMap(durationSeconds);
226
- if (map.durationSeconds === 0) {
227
- return map;
229
+ /** The priority at a whole distance in front of the viewer. @param {number} distance */
230
+ priorityAt(distance) {
231
+ const priority = Math.max(NOBODY_IS_COMING, AT_A_WATCHING_VIEWER - stepsAway(distance, this.allowance));
232
+ return Math.max(NOBODY_IS_COMING, 1 + Math.floor((priority - 1) * this.coefficient));
228
233
  }
229
- const at = Number.isFinite(atSeconds) && atSeconds > 0 ? Math.floor(atSeconds) : 0;
230
- const allowance = Number.isFinite(allowanceSeconds) && allowanceSeconds > 0
231
- ? allowanceSeconds
232
- : map.durationSeconds;
233
- const watching = playing !== false;
234
- // The measured urgent span is the time scale. There is no chosen decay
235
- // period: one span on pause halves the excess above minimum priority.
236
- const attenuate = !watching && urgentReady && viewerCount > 1;
237
- const elapsed = Number.isFinite(pauseSeconds) ? Math.max(0, pauseSeconds) : 0;
238
- const coefficient = pauseCoefficient({ playing, urgentReady, viewerCount, pauseSeconds: elapsed, allowanceSeconds: allowance });
239
- for (let second = 0; second < map.durationSeconds; second += 1) {
240
- if (second < at) {
241
- map.priority[second] = NOBODY_IS_COMING;
242
- continue;
234
+
235
+ /**
236
+ * @param {number} second
237
+ * @returns {MapPoint | null} Null where the film does not reach.
238
+ */
239
+ pointAt(second) {
240
+ if (!(second >= 0 && second < this.durationSeconds)) return null;
241
+ if (second < this.at) {
242
+ return { priority: NOBODY_IS_COMING, untilSeconds: Number.POSITIVE_INFINITY, behind: true, urgent: false, deferred: false };
243
243
  }
244
- map.behind[second] = 0;
245
- const distance = second - at;
246
- const priority = Math.max(NOBODY_IS_COMING, AT_A_WATCHING_VIEWER - stepsAway(distance, allowance));
247
- map.priority[second] = Math.max(NOBODY_IS_COMING, 1 + Math.floor((priority - 1) * coefficient));
248
- map.urgent[second] = distance < allowance && !attenuate ? 1 : 0;
249
- map.deferred[second] = attenuate ? 1 : 0;
244
+ const distance = second - this.at;
250
245
  // Urgent preparation keeps its deadline during pause. After completion,
251
246
  // pause duration defers this viewer's work without altering other viewers.
252
- map.secondsUntilPlayed[second] = distance + (attenuate ? elapsed : 0);
247
+ return { priority: this.priorityAt(distance), untilSeconds: distance + (this.attenuate ? this.elapsed : 0), behind: false,
248
+ urgent: distance < this.allowance && !this.attenuate, deferred: this.attenuate };
249
+ }
250
+
251
+ /** Every second where one of this viewer's values may change, inside the film. @returns {number[]} */
252
+ breaks() {
253
+ const breaks = [this.at, this.durationSeconds, this.at + Math.ceil(this.allowance)];
254
+ for (let step = 1; step < AT_A_WATCHING_VIEWER - NOBODY_IS_COMING; step += 1) {
255
+ const second = this.at + firstDistanceOfStep(step, this.allowance);
256
+ if (second >= this.durationSeconds) break;
257
+ breaks.push(second);
258
+ }
259
+ return breaks.filter(second => second > 0 && second <= this.durationSeconds);
253
260
  }
254
- return map;
255
261
  }
256
262
 
257
263
  /**
258
- * Where viewers stand, read off a map: every second whose priority rises above
259
- * the second before it.
260
- *
261
- * A viewer's own map is highest where they stand and falls away in front of
262
- * them, so in a merged map each viewer's position is a step UP — the one place
263
- * a value is higher than the value just behind it. A viewer standing inside the
264
- * stretch somebody else is walking towards adds nothing that map does not
265
- * already ask for, and is not a step; neither is somebody behind everybody.
266
- *
267
- * Read by whoever orders work by where people are and must not know what a
268
- * viewer is — the subtitle walk reads the clusters at these times first.
269
- *
270
- * @param {PriorityMap | null | undefined} map
271
- * @returns {number[]} Seconds, ascending.
264
+ * Every viewer's demand on one film, merged: the map.
272
265
  */
273
- export function viewerStartsOn(map) {
274
- const starts = [];
275
- if (!map || !(map.durationSeconds > 0)) {
276
- return starts;
266
+ export class PriorityMap {
267
+ /** @type {ViewerDemand[]} */
268
+ #viewers;
269
+ /** The film between the breaks, each stretch with its value at its start; worked out once. */
270
+ #pieces = null;
271
+
272
+ /** @param {ViewerDemand[]} [viewers] */
273
+ constructor(viewers = []) {
274
+ this.#viewers = viewers.filter(viewer => viewer.durationSeconds > 0);
275
+ /** The film's length, in whole seconds. */
276
+ this.durationSeconds = this.#viewers.reduce((longest, viewer) => Math.max(longest, viewer.durationSeconds), 0);
277
+ }
278
+
279
+ /** The viewers' demands, for a merge. */
280
+ get viewers() {
281
+ return this.#viewers;
282
+ }
283
+
284
+ /**
285
+ * What is wanted at one second.
286
+ *
287
+ * @param {number} second
288
+ * @returns {MapPoint}
289
+ */
290
+ at(second) {
291
+ let priority = NOTHING;
292
+ let untilSeconds = Number.POSITIVE_INFINITY;
293
+ let behind = true;
294
+ let urgent = false;
295
+ let yieldedByAll = true;
296
+ for (const viewer of this.#viewers) {
297
+ const point = viewer.pointAt(second);
298
+ if (!point) continue;
299
+ if (point.priority > priority) priority = point.priority;
300
+ if (point.untilSeconds < untilSeconds) untilSeconds = point.untilSeconds;
301
+ if (!point.behind) {
302
+ // In front of ANYBODY is in front: a stretch one viewer has passed is
303
+ // still the film another is walking towards.
304
+ behind = false;
305
+ // Deferred only if every viewer needing this second has yielded it.
306
+ if (!point.deferred) yieldedByAll = false;
307
+ }
308
+ if (point.urgent) urgent = true;
309
+ }
310
+ if (priority === NOTHING) return NO_DEMAND;
311
+ return { priority, untilSeconds, behind, urgent, deferred: !behind && yieldedByAll };
312
+ }
313
+
314
+ /**
315
+ * What a stretch of seconds wants as a whole, taken as a piece made or not
316
+ * made whole is wanted: as much as its strongest second, as soon as its
317
+ * soonest one, in front of somebody if any second of it is, urgent if any
318
+ * second of it is.
319
+ *
320
+ * A viewer's priority does not rise with distance and their time rises a
321
+ * second a second, so the second of the stretch nearest to them answers both.
322
+ *
323
+ * @param {number} from - First second, inclusive.
324
+ * @param {number} to - Last second, exclusive.
325
+ * @returns {{ priority: number, withinSeconds: number, behind: boolean, urgent: boolean }}
326
+ * Priority zero where nobody wants any of it.
327
+ */
328
+ on(from, to) {
329
+ let priority = NOTHING;
330
+ let withinSeconds = Number.POSITIVE_INFINITY;
331
+ let behind = true;
332
+ let urgent = false;
333
+ const pieces = this.#piecesOfFilm();
334
+ // The first stretch that ends after `from`.
335
+ let low = 0;
336
+ let high = pieces.length;
337
+ while (low < high) {
338
+ const middle = (low + high) >> 1;
339
+ if (pieces[middle].to <= from) low = middle + 1;
340
+ else high = middle;
341
+ }
342
+ for (let index = low; index < pieces.length && pieces[index].from < to; index += 1) {
343
+ const { from: start, point } = pieces[index];
344
+ if (point.priority === NOTHING) continue;
345
+ if (point.priority > priority) priority = point.priority;
346
+ // Inside a stretch every viewer in front stays in front and their time
347
+ // rises a second a second, so the soonest time rises with it.
348
+ const until = point.untilSeconds + (Math.max(from, start) - start);
349
+ if (until < withinSeconds) withinSeconds = until;
350
+ if (!point.behind) behind = false;
351
+ if (point.urgent) urgent = true;
352
+ }
353
+ return { priority, withinSeconds, behind, urgent };
277
354
  }
278
- for (let second = 0; second < map.durationSeconds; second += 1) {
279
- const value = map.priority[second];
280
- const before = second === 0 ? NOBODY_IS_COMING : map.priority[second - 1];
281
- if (value > before && value > NOBODY_IS_COMING) {
282
- starts.push(second);
355
+
356
+ /** @returns {Array<{ from: number, to: number, point: MapPoint }>} */
357
+ #piecesOfFilm() {
358
+ if (this.#pieces) return this.#pieces;
359
+ const breaks = this.#breaks();
360
+ const pieces = [];
361
+ for (let index = 0; index + 1 < breaks.length; index += 1) {
362
+ pieces.push({ from: breaks[index], to: breaks[index + 1], point: this.at(breaks[index]) });
283
363
  }
364
+ this.#pieces = pieces;
365
+ return pieces;
366
+ }
367
+
368
+ /** Every second where a merged value may change, with 0 and the end, ascending. */
369
+ #breaks() {
370
+ const breaks = new Set([0, this.durationSeconds]);
371
+ for (const viewer of this.#viewers) for (const second of viewer.breaks()) breaks.add(second);
372
+ return [...breaks].filter(second => second >= 0 && second <= this.durationSeconds).sort((left, right) => left - right);
373
+ }
374
+
375
+ /**
376
+ * The map as stretches: runs of seconds that agree on everything but the
377
+ * time, ascending, without gaps or overlaps. Seconds nobody wants are left
378
+ * out.
379
+ *
380
+ * THE TIME OF A STRETCH IS THE TIME OF ITS NEAR EDGE. A stretch is met at its
381
+ * beginning, so its beginning is when it must exist; whoever needs the time
382
+ * of a second inside it walks forward from there, at a second of film per
383
+ * second, which is the same arithmetic that put the number here. Merging on
384
+ * the exact time as well would merge nothing at all: the time is the
385
+ * distance, so it differs at every second.
386
+ *
387
+ * @returns {DemandZone[]}
388
+ */
389
+ runs() {
390
+ /** @type {DemandZone[]} */
391
+ const runs = [];
392
+ for (const { from, to, point } of this.#piecesOfFilm()) {
393
+ if (point.priority === NOTHING) continue;
394
+ const previous = runs[runs.length - 1];
395
+ if (previous && previous.to === from && previous.priority === point.priority && previous.behind === point.behind &&
396
+ previous.urgent === point.urgent && previous.deferred === point.deferred) {
397
+ previous.to = to;
398
+ continue;
399
+ }
400
+ runs.push({ from, to, priority: point.priority, withinSeconds: point.untilSeconds, behind: point.behind,
401
+ urgent: point.urgent, deferred: point.deferred });
402
+ }
403
+ return runs;
404
+ }
405
+
406
+ /**
407
+ * Where viewers stand, read off the map: every second whose priority rises
408
+ * above the second before it.
409
+ *
410
+ * A viewer's own map is highest where they stand and falls away in front of
411
+ * them, so in a merged map each viewer's position is a step UP — the one place
412
+ * a value is higher than the value just behind it. A viewer standing inside the
413
+ * stretch somebody else is walking towards adds nothing that map does not
414
+ * already ask for, and is not a step; neither is somebody behind everybody.
415
+ *
416
+ * Read by whoever orders work by where people are and must not know what a
417
+ * viewer is — the subtitle walk reads the clusters at these times first.
418
+ *
419
+ * @returns {number[]} Seconds, ascending.
420
+ */
421
+ viewerStarts() {
422
+ const starts = [];
423
+ for (const second of this.#breaks()) {
424
+ if (second >= this.durationSeconds) continue;
425
+ const value = this.at(second).priority;
426
+ const before = second === 0 ? NOBODY_IS_COMING : this.at(second - 1).priority;
427
+ if (value > before && value > NOBODY_IS_COMING) starts.push(second);
428
+ }
429
+ return starts;
284
430
  }
285
- return starts;
286
431
  }
287
432
 
288
433
  /**
289
- * Every viewer's map as one.
434
+ * A map with nothing in it, which says the same as nobody watching.
290
435
  *
291
- * The highest priority per second wins, and the soonest time: film two people
292
- * want is as urgent as the more urgent of them, and making it once serves both.
293
- * The two are taken separately on purpose — the priority is coarse, so two
294
- * viewers can tie on it while one is genuinely nearer, and whoever schedules
295
- * against the time must be given the nearer one.
436
+ * @returns {PriorityMap}
437
+ */
438
+ export function emptyMap() {
439
+ return new PriorityMap([]);
440
+ }
441
+
442
+ /**
443
+ * How far a viewer's priorities are lowered while they pause beside other
444
+ * viewers: allowance / (allowance + pauseSeconds), once their urgent work is
445
+ * ready; 1 otherwise.
446
+ *
447
+ * @param {{ playing?: boolean, urgentReady?: boolean, viewerCount: number, pauseSeconds?: number, allowanceSeconds?: number }} params
448
+ * @returns {number}
449
+ */
450
+ export function pauseCoefficient({ playing, urgentReady, viewerCount, pauseSeconds, allowanceSeconds }) {
451
+ if (playing !== false || !urgentReady || viewerCount <= 1) return 1;
452
+ const allowance = Number.isFinite(allowanceSeconds) && allowanceSeconds > 0 ? allowanceSeconds : 1;
453
+ const elapsed = Number.isFinite(pauseSeconds) ? Math.max(0, pauseSeconds) : 0;
454
+ return allowance / (allowance + elapsed);
455
+ }
456
+
457
+ /**
458
+ * One viewer's map.
296
459
  *
297
- * In front of ANYBODY is in front, and the third array says so directly.
460
+ * **What must NOT be here**, and the boundary is the point: how fast this
461
+ * machine encodes, how many encoders that takes, what a second weighs in bytes,
462
+ * where a piece boundary falls, and what has already been made. Those are
463
+ * answers the encoding and the downloading work out for themselves, from this
464
+ * map and from what each knows about itself.
465
+ *
466
+ * @param {object} params
467
+ * @param {number} params.atSeconds - Where they are watching from.
468
+ * @param {number} params.durationSeconds - How long the film is.
469
+ * @param {number} params.allowanceSeconds - The measured depth below which an
470
+ * interruption reaches this viewer. The width of the first step.
471
+ * @param {boolean} [params.playing] - Whether the picture is moving.
472
+ * @param {number} [params.pauseSeconds]
473
+ * @param {boolean} [params.urgentReady]
474
+ * @param {number} [params.viewerCount]
475
+ * @returns {PriorityMap}
476
+ */
477
+ export function mapForViewer(params) {
478
+ return new PriorityMap([new ViewerDemand(params)]);
479
+ }
480
+
481
+ /**
482
+ * Every viewer's map as one.
298
483
  *
299
484
  * @param {PriorityMap[]} maps
300
485
  * @returns {PriorityMap}
301
486
  */
302
487
  export function mergeMaps(maps) {
303
- const all = (maps ?? []).filter((map) => map && map.durationSeconds > 0);
304
- if (all.length === 0) {
305
- return emptyMap(0);
306
- }
307
- const merged = emptyMap(Math.max(...all.map((map) => map.durationSeconds)));
308
- merged.deferred.fill(1);
309
- for (const map of all) {
310
- for (let second = 0; second < map.durationSeconds; second += 1) {
311
- if (map.priority[second] > merged.priority[second]) {
312
- merged.priority[second] = map.priority[second];
313
- }
314
- if (map.secondsUntilPlayed[second] < merged.secondsUntilPlayed[second]) {
315
- merged.secondsUntilPlayed[second] = map.secondsUntilPlayed[second];
316
- }
317
- // In front of ANYBODY is in front: a stretch one viewer has passed is
318
- // still the film another is walking towards.
319
- if (map.behind[second] === 0) {
320
- merged.behind[second] = 0;
321
- }
322
- if (map.urgent?.[second]) merged.urgent[second] = 1;
323
- // Deferred only if every viewer needing this second has yielded it.
324
- if (map.behind[second] === 0 && !map.deferred?.[second]) merged.deferred[second] = 0;
325
- }
326
- }
327
- for (let second = 0; second < merged.durationSeconds; second += 1) {
328
- if (merged.behind[second]) merged.deferred[second] = 0;
329
- }
330
- return merged;
488
+ return new PriorityMap((maps ?? []).filter(Boolean).flatMap(map => map.viewers));
331
489
  }
332
490
 
333
491
  /**
334
- * The map as stretches: runs of seconds that agree on both numbers.
335
- *
336
- * A VIEW of the map, never a second copy of it. Consumers that work in ranges —
337
- * the swarm is asked for byte ranges, an encoder is given a stretch of pieces —
338
- * would otherwise each write this walk for themselves.
492
+ * The map as stretches (`PriorityMap.runs`).
339
493
  *
340
- * @param {PriorityMap} map
341
- * @returns {DemandZone[]} Ascending, without gaps or overlaps. Seconds nobody
342
- * wants are left out.
494
+ * @param {PriorityMap | null | undefined} map
495
+ * @returns {DemandZone[]}
343
496
  */
344
497
  export function runsOf(map) {
345
- /** @type {DemandZone[]} */
346
- const runs = [];
347
- if (!map || !(map.durationSeconds > 0)) {
348
- return runs;
349
- }
350
- for (let second = 0; second < map.durationSeconds; second += 1) {
351
- const priority = map.priority[second];
352
- if (priority === NOTHING) {
353
- continue;
354
- }
355
- const behind = map.behind[second] === 1;
356
- const urgent = map.urgent?.[second] === 1;
357
- const deferred = map.deferred?.[second] === 1;
358
- const previous = runs[runs.length - 1];
359
- if (
360
- previous
361
- && previous.to === second
362
- && previous.priority === priority
363
- && previous.behind === behind
364
- && previous.urgent === urgent
365
- && previous.deferred === deferred
366
- ) {
367
- previous.to = second + 1;
368
- continue;
369
- }
370
- // THE TIME OF A STRETCH IS THE TIME OF ITS NEAR EDGE. A stretch is met at
371
- // its beginning, so its beginning is when it must exist; whoever needs the
372
- // time of a second inside it walks forward from there, at a second of film
373
- // per second, which is the same arithmetic that put the number here.
374
- //
375
- // Merging on the exact time as well would merge nothing at all: the time is
376
- // the distance, so it differs at every second, and the map came back as one
377
- // stretch per second of film.
378
- const withinSeconds = map.secondsUntilPlayed[second];
379
- runs.push({
380
- from: second,
381
- to: second + 1,
382
- priority,
383
- withinSeconds,
384
- behind,
385
- urgent,
386
- deferred
387
- });
388
- }
389
- return runs;
498
+ return map ? map.runs() : [];
499
+ }
500
+
501
+ /**
502
+ * Where viewers stand on a map (`PriorityMap.viewerStarts`).
503
+ *
504
+ * @param {PriorityMap | null | undefined} map
505
+ * @returns {number[]}
506
+ */
507
+ export function viewerStartsOn(map) {
508
+ return map ? map.viewerStarts() : [];
390
509
  }
391
510
 
392
511
  /**
@@ -11,10 +11,27 @@ export function segmentDemands(output, fileIndex, zones) {
11
11
  const grid = output.timeline?.published ?? output.timeline?.boundaries;
12
12
  if (!Array.isArray(grid)) return [];
13
13
  const demands = [];
14
+ // THE ZONES ARE WALKED BESIDE THE SEGMENTS, not searched for each of them.
15
+ // A map's zones come ascending and apart (`PriorityMap.runs`) and the grid is
16
+ // ascending, so the zones a segment overlaps begin at or after those of the
17
+ // segment before it. Every zone filtered for every segment was thousands of
18
+ // segments by tens of zones on every pass over a film (r26,
19
+ // torrent-tv/meta#178). Zones in any other order are filtered as before.
20
+ const ordered = ascendingAndApart(zones);
21
+ let first = 0;
14
22
  for (let index = 0; index + 1 < grid.length; index++) {
15
23
  const from = grid[index], to = grid[index + 1];
16
24
  if (!Number.isFinite(from) || !Number.isFinite(to) || !(to > from)) throw new Error("An output segment needs a valid interval.");
17
- const overlapping = zones.filter(zone => zone.priority > 0 && zone.from < to && zone.to > from);
25
+ let overlapping;
26
+ if (ordered) {
27
+ while (first < zones.length && zones[first].to <= from) first += 1;
28
+ overlapping = [];
29
+ for (let at = first; at < zones.length && zones[at].from < to; at += 1) {
30
+ if (zones[at].priority > 0) overlapping.push(zones[at]);
31
+ }
32
+ } else {
33
+ overlapping = zones.filter(zone => zone.priority > 0 && zone.from < to && zone.to > from);
34
+ }
18
35
  if (!overlapping.length) continue;
19
36
  // A zone states the deadline of its near edge. A later segment within it
20
37
  // is reached later, at one second of film per second of playback.
@@ -31,6 +48,14 @@ export function segmentDemands(output, fileIndex, zones) {
31
48
  return demands.sort((left, right) => left.deadlineAt - right.deadlineAt || right.priority - left.priority || left.index - right.index);
32
49
  }
33
50
 
51
+ /** Whether zones come ascending and without overlaps, as a map's runs do. */
52
+ function ascendingAndApart(zones) {
53
+ for (let index = 1; index < zones.length; index += 1) {
54
+ if (!(zones[index].from >= zones[index - 1].to)) return false;
55
+ }
56
+ return true;
57
+ }
58
+
34
59
  /** Source bytes must arrive before measured processing and delivery consume time. */
35
60
  export function sourceDeadline(demand, { encodeSpeed, outputBytes, linkMbps }) {
36
61
  const span = demand.to - demand.from;