@torrent-tv/proxy 2.98.0 → 2.98.2
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/package.json +1 -1
- package/server.js +77 -52
- package/services/encode/EncodePlan.js +55 -38
- package/services/encode/output/Timeline.js +6 -18
- package/services/viewer/DownloadMaps.js +61 -5
- package/services/viewer/NativeSourceMap.js +2 -2
- package/services/viewer/PriorityMap.js +319 -200
- package/services/viewer/playback-pass.js +20 -8
- package/services/viewer/segment-demands.js +37 -13
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -437,44 +437,55 @@ export async function startProxyServer({
|
|
|
437
437
|
/** Source byte ranges of one segment interval, by file and interval; forgotten with the source. */
|
|
438
438
|
const segmentSourceRanges = new Map();
|
|
439
439
|
/**
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
*
|
|
443
|
-
*
|
|
440
|
+
* WHAT ONE SEGMENT OF AN OUTPUT IS, kept from pass to pass: where it lies in
|
|
441
|
+
* the source, what it reads — its tracks and the key its bytes are kept under
|
|
442
|
+
* — and, once known, those bytes joined at the precision the torrent fetches,
|
|
443
|
+
* by output and segment. Worked out again only when the output's timeline,
|
|
444
|
+
* the tracks, the file's start time or the output's choices change; forgotten
|
|
445
|
+
* with the source. None of it depends on where a viewer stands, and a pass is
|
|
446
|
+
* made every time one moves: looked up, made and joined again for each of a
|
|
447
|
+
* film's 3500 segments on every pass, they were 6 s of the 40 s the passes
|
|
448
|
+
* cost the main thread in fifteen minutes on Home Assistant (r30,
|
|
449
|
+
* torrent-tv/meta#178).
|
|
444
450
|
*/
|
|
445
451
|
const segmentReadings = new Map();
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
const
|
|
462
|
-
const kept =
|
|
463
|
-
if (kept && kept.trackList === trackList && kept.shift === shift && kept.choicesKey === choicesKey
|
|
464
|
-
|
|
465
|
-
const selected =
|
|
452
|
+
/** One output's segments, by index. */
|
|
453
|
+
const segmentRecordsOf = (map, output) => {
|
|
454
|
+
const key = `${map.sourceKey}:${map.fileIndex}:${output.outputKey}`;
|
|
455
|
+
let records = segmentReadings.get(key);
|
|
456
|
+
if (!records) segmentReadings.set(key, records = new Map());
|
|
457
|
+
return records;
|
|
458
|
+
};
|
|
459
|
+
/** The choices of one output's tracks as a key, worked out once for the list of them a pass shares. */
|
|
460
|
+
const choicesKeys = new WeakMap();
|
|
461
|
+
const choicesKeyOf = (choices) => {
|
|
462
|
+
let key = choicesKeys.get(choices);
|
|
463
|
+
if (key === undefined) choicesKeys.set(choices, key = choices.map(choice => `${choice.type}/${choice.index}/${choice.mode}`).join(","));
|
|
464
|
+
return key;
|
|
465
|
+
};
|
|
466
|
+
const segmentRecordOf = (records, demand, { timeline, trackList, shift, map }) => {
|
|
467
|
+
const choicesKey = choicesKeyOf(demand.tracks);
|
|
468
|
+
const kept = records.get(demand.index);
|
|
469
|
+
if (kept && kept.timeline === timeline && kept.trackList === trackList && kept.shift === shift && kept.choicesKey === choicesKey) return kept;
|
|
470
|
+
const sourceInterval = timeline.sourceInterval(demand.index, demand.index, shift);
|
|
471
|
+
const selected = demand.tracks.map(choice => ({ choice, track: choice.type === "video"
|
|
466
472
|
? ContainerTrack.firstUsable(trackList, "video")
|
|
467
473
|
: trackList.filter(track => track.type === choice.type)[choice.index] }));
|
|
468
474
|
let reading = null;
|
|
469
475
|
if (!selected.some(({ track }) => !track)) {
|
|
470
476
|
const wanted = selected.map(({ track }) => track);
|
|
471
477
|
const modes = new Map(selected.map(({ choice, track }) => [track, choice.mode]));
|
|
472
|
-
const interval = { ...
|
|
478
|
+
const interval = { ...sourceInterval, trackIds: wanted.map(track => track.trackNumber),
|
|
473
479
|
modes: Object.fromEntries(wanted.map(track => [track.trackNumber, modes.get(track)])) };
|
|
474
480
|
reading = { wanted, modes, interval, rangesKey: `${map.sourceKey}:${map.fileIndex}:${JSON.stringify(interval)}` };
|
|
475
481
|
}
|
|
476
|
-
|
|
477
|
-
|
|
482
|
+
// `joined` is the segment's ranges as the torrent is told them, made from
|
|
483
|
+
// the list `ranges` at `pieceLength`; `registered` is the list the input
|
|
484
|
+
// table was last given.
|
|
485
|
+
const record = { timeline, trackList, shift, choicesKey, sourceInterval, reading,
|
|
486
|
+
ranges: null, pieceLength: Number.NaN, joined: null, registered: null };
|
|
487
|
+
records.set(demand.index, record);
|
|
488
|
+
return record;
|
|
478
489
|
};
|
|
479
490
|
/**
|
|
480
491
|
* The last complete pass over each file, by file: what it was worked out
|
|
@@ -508,15 +519,6 @@ export async function startProxyServer({
|
|
|
508
519
|
`${passCosts.built} zone list(s) built for the map in ${Math.round(passCosts.buildMs)}ms`);
|
|
509
520
|
Object.assign(passCosts, { since: now, reused: 0, worked: 0, incomplete: 0, segments: 0, ranges: 0, zones: 0, built: 0, buildMs: 0, ms: 0, maxMs: 0 });
|
|
510
521
|
};
|
|
511
|
-
/** A segment's ranges joined at the precision the torrent fetches, once per kept list and piece length. */
|
|
512
|
-
const joinedRanges = new WeakMap();
|
|
513
|
-
const joinedRangesOf = (ranges, pieceLength) => {
|
|
514
|
-
const kept = joinedRanges.get(ranges);
|
|
515
|
-
if (kept && kept.pieceLength === pieceLength) return kept.joined;
|
|
516
|
-
const joined = joinedWithinPieces(ranges, pieceLength);
|
|
517
|
-
if (ranges !== null && typeof ranges === "object") joinedRanges.set(ranges, { pieceLength, joined });
|
|
518
|
-
return joined;
|
|
519
|
-
};
|
|
520
522
|
const downloadMaps = new DownloadMaps({
|
|
521
523
|
// The ranges a picture reads at open are named again by every segment;
|
|
522
524
|
// only the zone the swarm would rank first is sent (torrent-tv/meta#166).
|
|
@@ -574,14 +576,22 @@ export async function startProxyServer({
|
|
|
574
576
|
const links = consumers.map(viewer => viewer.linkReading()?.linkMbps)
|
|
575
577
|
.filter(value => Number.isFinite(value) && value > 0);
|
|
576
578
|
const encodeSpeed = outputParts.encodeCost.latestSpeedReadingOf(output)?.speed;
|
|
579
|
+
const linkMbps = links.length ? Math.min(...links) : null;
|
|
580
|
+
const records = segmentRecordsOf(map, output);
|
|
581
|
+
const facts = { timeline: output.timeline, trackList: tracks.value, shift, map };
|
|
582
|
+
// Each demand is this pass's own object (`segmentDemands`), so it is
|
|
583
|
+
// completed in place rather than copied.
|
|
577
584
|
for (const demand of segmentDemands(output, map.fileIndex, map.zones)) {
|
|
578
585
|
if (inventory.has(demand.index)) continue;
|
|
579
586
|
const deadlineAt = sourceDeadline(demand, { encodeSpeed,
|
|
580
|
-
outputBytes: seconds > 0 ? bytes / seconds * (demand.to - demand.from) : null,
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
587
|
+
outputBytes: seconds > 0 ? bytes / seconds * (demand.to - demand.from) : null, linkMbps });
|
|
588
|
+
const record = segmentRecordOf(records, demand, facts);
|
|
589
|
+
demand.leadSeconds = Number.isFinite(demand.deadlineAt) ? Math.max(0, (demand.deadlineAt - deadlineAt) / 1000) : 0;
|
|
590
|
+
demand.deadlineAt = deadlineAt;
|
|
591
|
+
demand.outputKey = output.outputKey;
|
|
592
|
+
demand.sourceInterval = record.sourceInterval;
|
|
593
|
+
demand.record = record;
|
|
594
|
+
demands.push(demand);
|
|
585
595
|
}
|
|
586
596
|
}
|
|
587
597
|
demands.sort((left, right) => left.deadlineAt - right.deadlineAt || right.priority - left.priority || left.index - right.index);
|
|
@@ -618,24 +628,37 @@ export async function startProxyServer({
|
|
|
618
628
|
// two-hour AVI (torrent-tv/meta#178).
|
|
619
629
|
const waiting = new Set();
|
|
620
630
|
const turn = takeTurns();
|
|
631
|
+
/** One download interval per map zone, shared by the segments it owns: read, never changed. */
|
|
632
|
+
const downloadIntervals = new Map();
|
|
633
|
+
const downloadIntervalOf = (owner) => {
|
|
634
|
+
let interval = downloadIntervals.get(owner);
|
|
635
|
+
if (!interval) downloadIntervals.set(owner, interval = { from: owner.from, to: owner.to });
|
|
636
|
+
return interval;
|
|
637
|
+
};
|
|
621
638
|
for (const zone of demands) {
|
|
622
639
|
if (!map.isCurrent()) return [];
|
|
623
|
-
// WHAT A SEGMENT READS IS WORKED OUT ONCE, not on every publication
|
|
624
|
-
//
|
|
625
|
-
//
|
|
626
|
-
// new map changes. Rebuilt for every segment of the film on every map,
|
|
627
|
-
// this pass held the main thread up to 1.5 s at a time on Home
|
|
640
|
+
// WHAT A SEGMENT READS IS WORKED OUT ONCE, not on every publication
|
|
641
|
+
// (`segmentRecordOf`). Rebuilt for every segment of the film on every
|
|
642
|
+
// map, this pass held the main thread up to 1.5 s at a time on Home
|
|
628
643
|
// Assistant (torrent-tv/meta#178).
|
|
629
|
-
const
|
|
644
|
+
const { record } = zone;
|
|
645
|
+
const reading = record.reading;
|
|
630
646
|
if (!reading) continue;
|
|
631
647
|
const { wanted, modes, interval, rangesKey } = reading;
|
|
632
|
-
const { tracks: _choices, owner: _owner, sourceInterval: _sourceInterval, ...demand } = zone;
|
|
633
648
|
const convert = ranges => {
|
|
634
|
-
|
|
649
|
+
if (record.ranges !== ranges || record.pieceLength !== pieceLength) {
|
|
650
|
+
record.ranges = ranges;
|
|
651
|
+
record.pieceLength = pieceLength;
|
|
652
|
+
record.joined = joinedWithinPieces(ranges, pieceLength);
|
|
653
|
+
}
|
|
654
|
+
const joined = record.joined;
|
|
635
655
|
rangeCount += joined.length;
|
|
636
656
|
// What the segment reads is kept for the forecast in a table of its
|
|
637
657
|
// own, whatever form the torrent is told it in.
|
|
638
|
-
|
|
658
|
+
if (record.registered !== joined) {
|
|
659
|
+
segmentInputs.set(map.sourceKey, map.fileIndex, zone.outputKey, zone.index, joined);
|
|
660
|
+
record.registered = joined;
|
|
661
|
+
}
|
|
639
662
|
// ONE ZONE FOR THE SEGMENT, its demand stated once for all the
|
|
640
663
|
// ranges it reads. The demand — level, priority, deadline, the map
|
|
641
664
|
// zone it belongs to — is this pass's; the ranges are the list kept
|
|
@@ -643,14 +666,16 @@ export async function startProxyServer({
|
|
|
643
666
|
// range copied the demand into every one: ten thousand objects a
|
|
644
667
|
// pass over a two-hour AVI, made again 21-61 times a minute on Home
|
|
645
668
|
// Assistant (r26, torrent-tv/meta#178).
|
|
646
|
-
converted.push({
|
|
669
|
+
converted.push({ index: zone.index, from: zone.from, to: zone.to, priority: zone.priority, urgent: zone.urgent,
|
|
670
|
+
behind: zone.behind, deferred: zone.deferred, deadlineAt: zone.deadlineAt, leadSeconds: zone.leadSeconds,
|
|
671
|
+
outputKey: zone.outputKey, downloadInterval: downloadIntervalOf(zone.owner), ranges: joined });
|
|
647
672
|
};
|
|
648
673
|
// The bytes one segment needs are a fact of the file and the output, so
|
|
649
674
|
// they are worked out once. A whole film is thousands of segments and
|
|
650
675
|
// the map changes every few seconds; worked out afresh on every change,
|
|
651
676
|
// a pass over a two-hour AVI never finished and the next segment's bytes
|
|
652
677
|
// were never asked for (Home Assistant 2026-10-08, torrent-tv/meta#151).
|
|
653
|
-
const known = segmentSourceRanges.get(rangesKey);
|
|
678
|
+
const known = record.ranges ?? segmentSourceRanges.get(rangesKey);
|
|
654
679
|
if (known) { convert(known); continue; }
|
|
655
680
|
if (waiting.has(zone.outputKey)) { complete = false; continue; }
|
|
656
681
|
// What is resolved so far is stated before a lower zone is worked out
|
|
@@ -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
|
/**
|
|
@@ -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
|
}
|
|
@@ -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
|
/**
|
|
@@ -22,18 +22,30 @@
|
|
|
22
22
|
* behind?: boolean, deferred?: boolean, from: number, to: number,
|
|
23
23
|
* sourceInterval?: { from: number, to: number } }>} params.demands - In the
|
|
24
24
|
* order of their deadlines.
|
|
25
|
-
* @returns {{ outputs: string[], inputs:
|
|
25
|
+
* @returns {{ outputs: string[], inputs: Float64Array }}
|
|
26
26
|
*/
|
|
27
27
|
export function passInputs({ pieceLength, shift, targets, demands }) {
|
|
28
28
|
const outputs = [...targets.keys()];
|
|
29
|
-
const
|
|
30
|
-
|
|
29
|
+
const position = new Map(outputs.map((outputKey, at) => [outputKey, at]));
|
|
30
|
+
// Numbers in a list made to its length: grown a value at a time, eight for
|
|
31
|
+
// each of a film's 3500 segments, it was most of what comparing a pass cost
|
|
32
|
+
// (r30, torrent-tv/meta#178).
|
|
33
|
+
const inputs = new Float64Array(2 + targets.size + demands.length * 8);
|
|
34
|
+
let at = 0;
|
|
35
|
+
inputs[at++] = pieceLength;
|
|
36
|
+
inputs[at++] = shift;
|
|
37
|
+
for (const target of targets.values()) inputs[at++] = target ?? -1;
|
|
31
38
|
for (const demand of demands) {
|
|
32
39
|
// The segment's own interval too: a cut grid read again can give one
|
|
33
40
|
// number other bytes.
|
|
34
|
-
inputs.
|
|
35
|
-
|
|
36
|
-
|
|
41
|
+
inputs[at++] = position.get(demand.outputKey) ?? -1;
|
|
42
|
+
inputs[at++] = demand.index;
|
|
43
|
+
inputs[at++] = demand.priority;
|
|
44
|
+
inputs[at++] = (demand.urgent ? 1 : 0) + (demand.behind ? 2 : 0) + (demand.deferred ? 4 : 0);
|
|
45
|
+
inputs[at++] = demand.from;
|
|
46
|
+
inputs[at++] = demand.to;
|
|
47
|
+
inputs[at++] = demand.sourceInterval?.from ?? -1;
|
|
48
|
+
inputs[at++] = demand.sourceInterval?.to ?? -1;
|
|
37
49
|
}
|
|
38
50
|
return { outputs, inputs };
|
|
39
51
|
}
|
|
@@ -41,8 +53,8 @@ export function passInputs({ pieceLength, shift, targets, demands }) {
|
|
|
41
53
|
/**
|
|
42
54
|
* Whether a pass is worked out from what an earlier one was.
|
|
43
55
|
*
|
|
44
|
-
* @param {{ tracks: unknown, outputs: string[], inputs: number
|
|
45
|
-
* @param {{ tracks: unknown, outputs: string[], inputs: number
|
|
56
|
+
* @param {{ tracks: unknown, outputs: string[], inputs: ArrayLike<number> } | undefined} last
|
|
57
|
+
* @param {{ tracks: unknown, outputs: string[], inputs: ArrayLike<number> }} next
|
|
46
58
|
* @returns {boolean}
|
|
47
59
|
*/
|
|
48
60
|
export function samePass(last, next) {
|
|
@@ -11,26 +11,50 @@ 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
|
-
|
|
18
|
-
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
deadlineAt
|
|
25
|
+
if (ordered) while (first < zones.length && zones[first].to <= from) first += 1;
|
|
26
|
+
// The zones a segment overlaps are folded as they are met, into numbers:
|
|
27
|
+
// a list of them, and the lists made of it for each field, were five
|
|
28
|
+
// arrays for every segment of a film on every pass (r30,
|
|
29
|
+
// torrent-tv/meta#178).
|
|
30
|
+
let owner = null, ownerDeadline = Infinity, priority = 0, urgent = false, behind = true, deferred = true;
|
|
31
|
+
for (let at = ordered ? first : 0; at < zones.length; at += 1) {
|
|
32
|
+
const zone = zones[at];
|
|
33
|
+
if (ordered && !(zone.from < to)) break;
|
|
34
|
+
if (!(zone.priority > 0) || !(zone.from < to && zone.to > from)) continue;
|
|
35
|
+
// A zone states the deadline of its near edge. A later segment within it
|
|
36
|
+
// is reached later, at one second of film per second of playback.
|
|
37
|
+
const deadline = Number.isFinite(zone.deadlineAt) ? zone.deadlineAt + Math.max(0, from - zone.from) * 1000 : Infinity;
|
|
38
|
+
if (owner === null || deadline < ownerDeadline) { owner = zone; ownerDeadline = deadline; }
|
|
39
|
+
if (zone.priority > priority) priority = zone.priority;
|
|
40
|
+
if (zone.urgent) urgent = true;
|
|
41
|
+
if (!zone.behind) behind = false;
|
|
42
|
+
if (!zone.deferred) deferred = false;
|
|
43
|
+
}
|
|
44
|
+
if (owner === null) continue;
|
|
45
|
+
demands.push({ index, from, to, tracks, owner, priority, urgent, behind, deferred, deadlineAt: ownerDeadline });
|
|
30
46
|
}
|
|
31
47
|
return demands.sort((left, right) => left.deadlineAt - right.deadlineAt || right.priority - left.priority || left.index - right.index);
|
|
32
48
|
}
|
|
33
49
|
|
|
50
|
+
/** Whether zones come ascending and without overlaps, as a map's runs do. */
|
|
51
|
+
function ascendingAndApart(zones) {
|
|
52
|
+
for (let index = 1; index < zones.length; index += 1) {
|
|
53
|
+
if (!(zones[index].from >= zones[index - 1].to)) return false;
|
|
54
|
+
}
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
34
58
|
/** Source bytes must arrive before measured processing and delivery consume time. */
|
|
35
59
|
export function sourceDeadline(demand, { encodeSpeed, outputBytes, linkMbps }) {
|
|
36
60
|
const span = demand.to - demand.from;
|