@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 +2 -0
- package/package.json +1 -1
- package/server.js +4 -0
- package/services/encode/EncodePlan.js +55 -38
- package/services/encode/Renditions.js +17 -2
- package/services/encode/hwaccel.js +3 -2
- package/services/encode/output/Timeline.js +6 -18
- package/services/encode/quality/EncodeCost.js +27 -8
- package/services/encode/quality/output-format.js +2 -1
- package/services/server/wire-outputs.js +22 -1
- package/services/viewer/DownloadMaps.js +61 -5
- package/services/viewer/NativeSourceMap.js +2 -2
- package/services/viewer/PriorityMap.js +319 -200
- package/services/viewer/segment-demands.js +26 -1
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
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
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
//
|
|
1007
|
-
|
|
1008
|
-
|
|
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 <
|
|
1030
|
-
|
|
1011
|
+
if (here < table[index]) {
|
|
1012
|
+
table[index] = here;
|
|
1031
1013
|
}
|
|
1032
1014
|
}
|
|
1033
|
-
|
|
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
|
-
|
|
1362
|
-
|
|
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 -
|
|
160
|
-
*
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
53
|
+
const current = sourceZones.exactly(interval);
|
|
53
54
|
if (!current) {
|
|
54
|
-
const stillWanted =
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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].
|
|
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].
|
|
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:
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* a list of stretches
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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
|
-
* -
|
|
59
|
-
* computable at all: whoever fills the map compares "when is this
|
|
60
|
-
* against "when would it arrive", and only the second half is theirs
|
|
61
|
-
* out. `Infinity` where nobody is on their way.
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
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
|
-
*
|
|
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}
|
|
174
|
-
* @
|
|
190
|
+
* @param {number} step
|
|
191
|
+
* @param {number} allowanceSeconds
|
|
192
|
+
* @returns {number}
|
|
175
193
|
*/
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
if (
|
|
282
|
-
|
|
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
|
-
*
|
|
434
|
+
* A map with nothing in it, which says the same as nobody watching.
|
|
290
435
|
*
|
|
291
|
-
*
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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[]}
|
|
342
|
-
* wants are left out.
|
|
494
|
+
* @param {PriorityMap | null | undefined} map
|
|
495
|
+
* @returns {DemandZone[]}
|
|
343
496
|
*/
|
|
344
497
|
export function runsOf(map) {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
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;
|