@torrent-tv/proxy 2.91.0 → 2.92.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +3 -2
- package/package.json +18 -2
- package/routes/api/playback-plan/post.js +21 -4
- package/routes/api/sources/files/get.js +20 -28
- package/routes/api/sources/post.js +4 -2
- package/routes/api/sources/stats/get.js +4 -11
- package/routes/api/sources/viewer/post.js +16 -0
- package/routes/api/sources/warm/post.js +6 -215
- package/routes/api/subtitles/get.js +22 -125
- package/routes/api/subtitles/post.js +26 -0
- package/routes/api/transcode-sessions/post.js +24 -2
- package/routes/media/get.js +80 -0
- package/routes/stream/get.js +26 -94
- package/routes/transcode/audio-warm/get.js +9 -21
- package/routes/transcode/variant-warm/get.js +8 -14
- package/server.js +410 -46
- package/services/encode/AdmittedInput.js +136 -0
- package/services/encode/CushionReport.js +1 -76
- package/services/encode/EncodeInputs.js +142 -0
- package/services/encode/EncodeOrchestrator.js +4 -1
- package/services/encode/EncodeRun.js +13 -4
- package/services/encode/EncodeRuns.js +132 -52
- package/services/encode/InputFailures.js +37 -0
- package/services/encode/MatroskaInput.js +158 -0
- package/services/encode/OutputOpening.js +16 -64
- package/services/encode/Renditions.js +1 -17
- package/services/encode/admitted-command.js +60 -0
- package/services/encode/annex-b-input.js +37 -0
- package/services/encode/bit-payload.js +29 -0
- package/services/encode/output/OutputCatalog.js +1 -1
- package/services/encode/output/Timeline.js +11 -0
- package/services/encode/pcm-normalization.js +30 -0
- package/services/encode/piece-completeness.js +54 -1
- package/services/encode/quality/HostLoad.js +19 -73
- package/services/encode/quality/QualityOffer.js +1 -1
- package/services/encode/run-command.js +1 -589
- package/services/encode/segment-formats/mp4-boxes.js +3 -1
- package/services/encode/segment-formats/presentation-segment.js +114 -0
- package/services/media/ContainerOrchestrator.js +118 -48
- package/services/media/KeyframeTables.js +16 -115
- package/services/media/MediaReadRequests.js +120 -0
- package/services/media/ProbeRequests.js +135 -0
- package/services/media/SegmentInputs.js +48 -0
- package/services/media/SourceFile.js +6 -0
- package/services/media/SubtitleOrchestrator.js +190 -4
- package/services/media/await-plan.js +47 -0
- package/services/media/container/AsfContainer.js +217 -0
- package/services/media/container/AviContainer.js +343 -34
- package/services/media/container/CadencedPictureOrder.js +45 -0
- package/services/media/container/Container.js +7 -3
- package/services/media/container/ContainerFactory.js +10 -3
- package/services/media/container/FfprobeContainer.js +222 -0
- package/services/media/container/MatroskaContainer.js +72 -46
- package/services/media/container/Mp4Container.js +472 -139
- package/services/media/container/MpegPsContainer.js +281 -0
- package/services/media/container/MpegTsContainer.js +277 -0
- package/services/media/container/PacketIndex.js +270 -0
- package/services/media/container/PacketRecords.js +237 -0
- package/services/media/container/RetainedBytes.js +39 -0
- package/services/media/container/RetainedReads.js +51 -0
- package/services/media/container/ac3-frame.js +46 -0
- package/services/media/container/asf-packets.js +153 -0
- package/services/media/container/avc-picture-order.js +50 -0
- package/services/media/container/avi-open-dml.js +47 -0
- package/services/media/container/dts-frame.js +51 -0
- package/services/media/container/ebml-stream.js +12 -1
- package/services/media/container/ffprobe-record.js +45 -0
- package/services/media/container/flv-packet-ranges.js +27 -0
- package/services/media/container/following-packet-time.js +37 -0
- package/services/media/container/h264-configuration.js +183 -0
- package/services/media/container/h264-elementary-index.js +245 -0
- package/services/media/container/hevc-configuration.js +165 -0
- package/services/media/container/hevc-elementary-index.js +132 -0
- package/services/media/container/hevc-picture-order.js +66 -0
- package/services/media/container/latm-frame.js +99 -0
- package/services/media/container/matroska-packets.js +207 -0
- package/services/media/container/memory-unavailable.js +8 -0
- package/services/media/container/mp3-packet-dependencies.js +41 -0
- package/services/media/container/mp4-fragments.js +148 -0
- package/services/media/container/mpeg-audio-frame.js +19 -0
- package/services/media/container/mpeg-audio-reservoir.js +27 -0
- package/services/media/container/mpeg-elementary-index.js +196 -0
- package/services/media/container/mpeg-sequence-reader.js +69 -0
- package/services/media/container/mpeg-ts-packets.js +93 -0
- package/services/media/container/mpeg-video-configuration.js +50 -0
- package/services/media/container/mpeg4-picture-timing.js +108 -0
- package/services/media/container/pes-header.js +34 -0
- package/services/media/playback-declarations.js +57 -0
- package/services/media/playback-planner.js +72 -230
- package/services/media/probe-packets.js +61 -0
- package/services/media/read-playback-declarations.js +18 -0
- package/services/media/tracks/AudioTrack.js +9 -0
- package/services/media/tracks/TextSubtitleTrack.js +4 -1
- package/services/server/OutputLifecycle.js +2 -0
- package/services/server/SegmentServing.js +40 -33
- package/services/server/ViewerRequests.js +22 -41
- package/services/server/controllers/PlaybackController.js +4 -0
- package/services/server/controllers/SubtitleController.js +8 -29
- package/services/server/transcode-session-files.js +58 -99
- package/services/server/wire-outputs.js +58 -15
- package/services/storage/IndexMemory.js +85 -0
- package/services/storage/MachineBudget.js +27 -3
- package/services/storage/allowance.js +8 -1
- package/services/storage/piece-store/piece-disk-store.js +18 -3
- package/services/storage/piece-store/piece-lru.js +8 -4
- package/services/storage/piece-store/shared-piece-store.js +26 -11
- package/services/storage/segment-store/SegmentStore.js +49 -18
- package/services/storage/wire.js +7 -0
- package/services/torrent/Contents.js +52 -20
- package/services/torrent/demand/DemandRegister.js +13 -4
- package/services/torrent/demand/Window.js +13 -2
- package/services/torrent/demand-progress.js +25 -0
- package/services/torrent/download/DeadlineScheduler.js +167 -0
- package/services/torrent/download/FutureDownload.js +166 -0
- package/services/torrent/download/SwarmSelection.js +30 -59
- package/services/torrent/download/registry.js +45 -5
- package/services/torrent/download/withdraw-claim.js +2 -0
- package/services/torrent/torrent-pool.js +39 -600
- package/services/torrent/worker/channel.js +8 -1
- package/services/torrent/worker/client.js +74 -72
- package/services/torrent/worker/files-in-use.js +9 -0
- package/services/torrent/worker/handle-state.js +12 -0
- package/services/torrent/worker/held-bytes.js +82 -99
- package/services/torrent/worker/held-file-bytes.js +17 -0
- package/services/torrent/worker/piece-reader.js +67 -683
- package/services/torrent/worker/pool-adapter.js +37 -123
- package/services/torrent/worker/protocol.js +6 -17
- package/services/torrent/worker/whole-file-stats.js +2 -0
- package/services/torrent/worker/worker.js +86 -156
- package/services/transport/data-channel-handler.js +56 -13
- package/services/viewer/DownloadMaps.js +168 -0
- package/services/viewer/NativeSourceMap.js +33 -0
- package/services/viewer/PriorityMap.js +49 -40
- package/services/viewer/PriorityOrchestrator.js +42 -13
- package/services/viewer/SourcePreparation.js +278 -0
- package/services/viewer/Viewer.js +11 -0
- package/services/viewer/Viewers.js +109 -2
- package/services/viewer/playback-readiness.js +50 -63
- package/services/viewer/segment-demands.js +37 -0
- package/services/viewer/urgent-output-ready.js +26 -0
- package/services/encode/run-inputs.js +0 -53
- package/services/media/keyframe-probe.js +0 -157
- package/services/media/media-info-probe.js +0 -93
- package/services/torrent/worker/background-fill.js +0 -213
- package/services/torrent/worker/resume-warm.js +0 -107
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/** One published download map per file, including download-only metadata reads. */
|
|
2
|
+
export class DownloadMaps {
|
|
3
|
+
#files = new Map();
|
|
4
|
+
#publish;
|
|
5
|
+
#resolvePlayback;
|
|
6
|
+
#nextEpoch = 0;
|
|
7
|
+
|
|
8
|
+
constructor({ publish, resolvePlayback = async map => map.zones }) {
|
|
9
|
+
if (typeof publish !== "function") throw new TypeError("Download map publication is required.");
|
|
10
|
+
this.#publish = publish;
|
|
11
|
+
this.#resolvePlayback = resolvePlayback;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async playback({ sourceKey, fileIndex, durationSeconds, zones }) {
|
|
15
|
+
const file = this.#file(sourceKey, fileIndex);
|
|
16
|
+
file.durationSeconds = durationSeconds;
|
|
17
|
+
const revision = ++file.revision;
|
|
18
|
+
const now = Date.now();
|
|
19
|
+
file.sourceZones = zones.map(zone => Number.isFinite(zone.deadlineAt) || !Number.isFinite(zone.withinSeconds)
|
|
20
|
+
? zone : { ...zone, deadlineAt: now + Math.max(0, zone.withinSeconds) * 1000 });
|
|
21
|
+
let changed = false;
|
|
22
|
+
const retained = file.zones.flatMap(zone => {
|
|
23
|
+
const interval = zone.downloadInterval ?? zone;
|
|
24
|
+
const current = file.sourceZones.find(candidate => candidate.from === interval.from && candidate.to === interval.to);
|
|
25
|
+
const deadlineAt = current && Number.isFinite(current.deadlineAt)
|
|
26
|
+
? current.deadlineAt - (zone.leadSeconds ?? 0) * 1000 : current?.deadlineAt;
|
|
27
|
+
if (!current || Object.keys(current).some(key => key !== "from" && key !== "to" && !Object.is(zone[key], key === "deadlineAt" ? deadlineAt : current[key]))) changed = true;
|
|
28
|
+
return current ? [{ ...zone, ...current, from: zone.from, to: zone.to, deadlineAt }] : [];
|
|
29
|
+
});
|
|
30
|
+
file.zones = retained;
|
|
31
|
+
for (const [statement, metadata] of file.metadata) {
|
|
32
|
+
if (metadata.interval && !this.wantsInterval(sourceKey, fileIndex, metadata.interval)) {
|
|
33
|
+
file.metadata.delete(statement);
|
|
34
|
+
changed = true;
|
|
35
|
+
}
|
|
36
|
+
else if (metadata.interval) {
|
|
37
|
+
const current = file.sourceZones.find(zone => zone.from === metadata.interval.from && zone.to === metadata.interval.to);
|
|
38
|
+
const deadlineAt = Number.isFinite(current.deadlineAt) ? current.deadlineAt - metadata.leadSeconds * 1000 : current.deadlineAt;
|
|
39
|
+
if (metadata.priority !== current.priority || metadata.urgent !== current.urgent || metadata.deadlineAt !== deadlineAt) changed = true;
|
|
40
|
+
metadata.priority = current.priority;
|
|
41
|
+
metadata.urgent = current.urgent;
|
|
42
|
+
metadata.deadlineAt = deadlineAt;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (changed) await this.#emit(file);
|
|
46
|
+
if (revision !== file.revision) return;
|
|
47
|
+
const resolved = await this.#resolvePlayback({ sourceKey, fileIndex, durationSeconds, zones: file.sourceZones });
|
|
48
|
+
if (revision !== file.revision) return;
|
|
49
|
+
file.zones = resolved;
|
|
50
|
+
return this.#emit(file);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
refresh(sourceKey, fileIndex) {
|
|
54
|
+
const file = this.#files.get(`${sourceKey}:${fileIndex}`);
|
|
55
|
+
if (!file) return Promise.resolve();
|
|
56
|
+
return this.playback({ sourceKey, fileIndex, durationSeconds: file.durationSeconds, zones: file.sourceZones });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
epoch(sourceKey, fileIndex) {
|
|
60
|
+
return this.#files.get(`${sourceKey}:${fileIndex}`)?.epoch ?? 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
native({ sourceKey, fileIndex, zones }) {
|
|
64
|
+
const file = this.#file(sourceKey, fileIndex);
|
|
65
|
+
file.nativeZones = zones;
|
|
66
|
+
return this.#emit(file);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
wantsInterval(sourceKey, fileIndex, { from, to }) {
|
|
70
|
+
return this.#files.get(`${sourceKey}:${fileIndex}`)?.sourceZones.some(zone => zone.from === from && zone.to === to) ?? false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Exact source ranges already resolved by the download map, without new reads. */
|
|
74
|
+
inputsForOutput(sourceKey, outputKey, index) {
|
|
75
|
+
const sources = [];
|
|
76
|
+
for (const file of this.#files.values()) {
|
|
77
|
+
if (file.sourceKey !== sourceKey) continue;
|
|
78
|
+
const zones = file.zones.filter(zone => zone.outputKey === outputKey && zone.index === index);
|
|
79
|
+
if (!zones.length) continue;
|
|
80
|
+
sources.push({ sourceId: `${sourceKey}:${file.fileIndex}`,
|
|
81
|
+
ranges: zones.map(zone => ({ start: zone.byteStart, end: zone.byteEnd + 1 })) });
|
|
82
|
+
}
|
|
83
|
+
return sources.length ? sources : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
metadata({ sourceKey, fileIndex, statement, result, priority = 100, urgent = true, deadlineAt = 0, interval, leadSeconds = 0, scope = "playback", order = 0 }) {
|
|
87
|
+
if (!Number.isFinite(leadSeconds) || leadSeconds < 0) throw new TypeError("Metadata preparation time must be finite and nonnegative.");
|
|
88
|
+
const file = this.#file(sourceKey, fileIndex);
|
|
89
|
+
if (result.kind === "needs-ranges") {
|
|
90
|
+
if (interval && !this.wantsInterval(sourceKey, fileIndex, interval)) return Promise.resolve();
|
|
91
|
+
const current = interval && file.sourceZones.find(zone => zone.from === interval.from && zone.to === interval.to);
|
|
92
|
+
if (current) {
|
|
93
|
+
priority = current.priority;
|
|
94
|
+
urgent = current.urgent;
|
|
95
|
+
deadlineAt = Number.isFinite(current.deadlineAt) ? current.deadlineAt - leadSeconds * 1000 : current.deadlineAt;
|
|
96
|
+
}
|
|
97
|
+
file.metadata.set(statement, { ranges: result.ranges, requestId: result.requestId, priority, urgent, deadlineAt, interval, leadSeconds, scope, order });
|
|
98
|
+
} else {
|
|
99
|
+
file.metadata.delete(statement);
|
|
100
|
+
}
|
|
101
|
+
return this.#emit(file);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
withdrawMetadata(sourceKey, fileIndex, statement) {
|
|
105
|
+
const file = this.#files.get(`${sourceKey}:${fileIndex}`);
|
|
106
|
+
return file?.metadata.delete(statement) ? this.#emit(file) : Promise.resolve();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
repriceMetadata(sourceKey, fileIndex, statement, demand) {
|
|
110
|
+
const file = this.#files.get(`${sourceKey}:${fileIndex}`);
|
|
111
|
+
const metadata = file?.metadata.get(statement);
|
|
112
|
+
if (!metadata || Object.keys(demand).every(key => Object.is(metadata[key], demand[key]))) return Promise.resolve();
|
|
113
|
+
Object.assign(metadata, demand);
|
|
114
|
+
return this.#emit(file);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
forget(sourceKey, fileIndex, { keepPreparation = false } = {}) {
|
|
118
|
+
const file = this.#file(sourceKey, fileIndex);
|
|
119
|
+
file.zones = [];
|
|
120
|
+
file.sourceZones = [];
|
|
121
|
+
if (!keepPreparation) file.nativeZones = [];
|
|
122
|
+
file.revision++;
|
|
123
|
+
file.epoch = ++this.#nextEpoch;
|
|
124
|
+
for (const [statement, metadata] of file.metadata) {
|
|
125
|
+
if (!keepPreparation || metadata.scope !== "preparation") file.metadata.delete(statement);
|
|
126
|
+
}
|
|
127
|
+
// Keep the publication sequence when the same file is requested again.
|
|
128
|
+
return this.#emit(file);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Closing a source discards its maps and cancels queued publications. */
|
|
132
|
+
retire(sourceKey) {
|
|
133
|
+
for (const [key, file] of this.#files) {
|
|
134
|
+
if (file.sourceKey !== sourceKey) continue;
|
|
135
|
+
file.revision++;
|
|
136
|
+
this.#files.delete(key);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
#file(sourceKey, fileIndex) {
|
|
141
|
+
if (typeof sourceKey !== "string" || !sourceKey || !Number.isSafeInteger(fileIndex) || fileIndex < 0) {
|
|
142
|
+
throw new TypeError("A download map requires a source file address.");
|
|
143
|
+
}
|
|
144
|
+
const key = `${sourceKey}:${fileIndex}`;
|
|
145
|
+
let file = this.#files.get(key);
|
|
146
|
+
if (!file) {
|
|
147
|
+
file = { sourceKey, fileIndex, durationSeconds: 0, zones: [], nativeZones: [], sourceZones: [], revision: 0, epoch: ++this.#nextEpoch, metadata: new Map(), pending: Promise.resolve() };
|
|
148
|
+
this.#files.set(key, file);
|
|
149
|
+
}
|
|
150
|
+
return file;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
#emit(file) {
|
|
154
|
+
const zones = [...file.zones, ...file.nativeZones];
|
|
155
|
+
for (const { ranges, requestId, priority, urgent, deadlineAt, order } of file.metadata.values()) {
|
|
156
|
+
for (const [byteStart, byteEnd] of ranges) {
|
|
157
|
+
zones.push({ byteStart, byteEnd, priority, urgent, downloadOnly: true, requestId, deadlineAt, ...(order ? { order } : {}) });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const publication = { sourceKey: file.sourceKey, fileIndex: file.fileIndex, durationSeconds: file.durationSeconds, zones };
|
|
161
|
+
const pending = file.pending.then(() => {
|
|
162
|
+
if (this.#files.get(`${file.sourceKey}:${file.fileIndex}`) === file) return this.#publish(publication);
|
|
163
|
+
});
|
|
164
|
+
// A failed publication must not prevent the next state change from being sent.
|
|
165
|
+
file.pending = pending.catch(() => undefined);
|
|
166
|
+
return pending;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { mapForViewer, mergeMaps, runsOf } from "./PriorityMap.js";
|
|
2
|
+
|
|
3
|
+
/** Price native source packets with the same viewer map as encoded outputs. */
|
|
4
|
+
export function nativeSourceMap({ index, tracks, viewers, durationSeconds, startTimeSeconds = 0,
|
|
5
|
+
allowanceSeconds, urgentReadyFor, now = Date.now() }) {
|
|
6
|
+
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) return [];
|
|
7
|
+
const maps = viewers.map(viewer => mapForViewer({
|
|
8
|
+
atSeconds: viewer.positionSeconds(now) ?? 0, durationSeconds, allowanceSeconds,
|
|
9
|
+
playing: viewer.playing || viewer.waiting, viewerCount: viewers.length,
|
|
10
|
+
pauseSeconds: viewer.pausedAt === null ? 0 : (now - viewer.pausedAt) / 1000,
|
|
11
|
+
urgentReady: urgentReadyFor(viewer)
|
|
12
|
+
}));
|
|
13
|
+
const zones = [];
|
|
14
|
+
for (const zone of runsOf(mergeMaps(maps))) {
|
|
15
|
+
for (const track of tracks.filter(track => ["video", "audio"].includes(track.type))) {
|
|
16
|
+
const bounds = index.boundsOf(track.trackNumber);
|
|
17
|
+
if (!bounds) continue;
|
|
18
|
+
const from = Math.max(bounds.start, zone.from + startTimeSeconds);
|
|
19
|
+
const to = Math.min(bounds.end, durationSeconds + startTimeSeconds, zone.to + startTimeSeconds);
|
|
20
|
+
if (!(to > from)) continue;
|
|
21
|
+
const input = index.inputFor({ trackId: track.trackNumber, from, to });
|
|
22
|
+
if (input.kind === "needs-index") continue;
|
|
23
|
+
if (input.kind !== "result") throw new Error(`Native source input refused: ${input.reason}`);
|
|
24
|
+
const links = viewers.filter((_viewer, viewerIndex) => maps[viewerIndex].priority[zone.from] === zone.priority)
|
|
25
|
+
.map(viewer => viewer.linkReading()?.linkMbps).filter(rate => Number.isFinite(rate) && rate > 0);
|
|
26
|
+
const bytes = input.ranges.reduce((sum, [start, end]) => sum + end - start + 1, 0);
|
|
27
|
+
const deliverySeconds = links.length ? bytes * 8 / (Math.min(...links) * 1_000_000) : 0;
|
|
28
|
+
for (const [byteStart, byteEnd] of input.ranges) zones.push({ ...zone, byteStart, byteEnd,
|
|
29
|
+
deadlineAt: Number.isFinite(zone.withinSeconds) ? now + (zone.withinSeconds - deliverySeconds) * 1000 : Infinity });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return zones;
|
|
33
|
+
}
|
|
@@ -87,31 +87,14 @@
|
|
|
87
87
|
*/
|
|
88
88
|
|
|
89
89
|
/**
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
* it below every value of band 1. A stopped viewer will watch this film when
|
|
97
|
-
* they press play, so it is still in front of them; nobody is waiting for it
|
|
98
|
-
* now, so it yields to anybody who is;
|
|
99
|
-
* 3. BEHIND EVERYBODY — reachable only by a seek back, and nothing measures how
|
|
100
|
-
* likely that is, so it is one value rather than a shape.
|
|
101
|
-
*
|
|
102
|
-
* IN FRONT ALWAYS OUTRANKS BEHIND, at any distance and whoever is stopped. The
|
|
103
|
-
* bands make that true by construction rather than by a comparison somebody has
|
|
104
|
-
* to remember to write.
|
|
105
|
-
*
|
|
106
|
-
* The values inside a band are AN ENCODING OF AN ORDER, not a measurement:
|
|
107
|
-
* every consumer asks only which of two seconds comes first, so any strictly
|
|
108
|
-
* decreasing shape does the same work, and none of these numbers claims
|
|
109
|
-
* anything about the world.
|
|
90
|
+
* Wanted seconds use priorities 1..100. Distance lowers priority relative to
|
|
91
|
+
* the measured urgent allowance. Pause preserves the same priorities until
|
|
92
|
+
* urgent outputs are ready. With competing viewers, elapsed pause time then
|
|
93
|
+
* attenuates the excess above 1 before maps merge. Zero means no demand.
|
|
94
|
+
* Direction, urgent preparation and attenuation are separate map fields;
|
|
95
|
+
* priority alone cannot distinguish an attenuated second from a passed one.
|
|
110
96
|
*/
|
|
111
97
|
export const AT_A_WATCHING_VIEWER = 100;
|
|
112
|
-
const WATCHING_FLOOR = 70;
|
|
113
|
-
const AT_A_STOPPED_VIEWER = 69;
|
|
114
|
-
const STOPPED_FLOOR = 39;
|
|
115
98
|
|
|
116
99
|
/** Behind everybody. */
|
|
117
100
|
export const NOBODY_IS_COMING = 1;
|
|
@@ -159,7 +142,7 @@ export function isAtAWatchingViewer(priority) {
|
|
|
159
142
|
* @returns {boolean}
|
|
160
143
|
*/
|
|
161
144
|
export function isNobodyComingNow(priority) {
|
|
162
|
-
return priority
|
|
145
|
+
return priority === NOBODY_IS_COMING;
|
|
163
146
|
}
|
|
164
147
|
|
|
165
148
|
/**
|
|
@@ -197,7 +180,9 @@ export function emptyMap(durationSeconds) {
|
|
|
197
180
|
durationSeconds: seconds,
|
|
198
181
|
priority: new Uint8Array(seconds),
|
|
199
182
|
secondsUntilPlayed: new Float64Array(seconds).fill(Number.POSITIVE_INFINITY),
|
|
200
|
-
behind: new Uint8Array(seconds).fill(1)
|
|
183
|
+
behind: new Uint8Array(seconds).fill(1),
|
|
184
|
+
urgent: new Uint8Array(seconds),
|
|
185
|
+
deferred: new Uint8Array(seconds)
|
|
201
186
|
};
|
|
202
187
|
}
|
|
203
188
|
|
|
@@ -209,11 +194,9 @@ export function emptyMap(durationSeconds) {
|
|
|
209
194
|
* a clock time — is what the value is derived from, and it is why two viewers
|
|
210
195
|
* can be compared at all: the nearer one wins the second they both want.
|
|
211
196
|
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
* to one flat value over the whole film, as it was, their position disappeared
|
|
216
|
-
* entirely — and with it the rule that what is in front is made first.
|
|
197
|
+
* Pause retains urgent preparation first. Once that work is ready, the
|
|
198
|
+
* allowance / (allowance + pauseSeconds) coefficient gradually reduces this
|
|
199
|
+
* viewer's priorities if others consume the file. Resume restores them.
|
|
217
200
|
*
|
|
218
201
|
* **What must NOT be here**, and the boundary is the point: how fast this
|
|
219
202
|
* machine encodes, how many encoders that takes, what a second weighs in bytes,
|
|
@@ -229,7 +212,15 @@ export function emptyMap(durationSeconds) {
|
|
|
229
212
|
* @param {boolean} [params.playing] - Whether the picture is moving.
|
|
230
213
|
* @returns {PriorityMap}
|
|
231
214
|
*/
|
|
232
|
-
export function
|
|
215
|
+
export function pauseCoefficient({ playing, urgentReady, viewerCount, pauseSeconds, allowanceSeconds }) {
|
|
216
|
+
if (playing !== false || !urgentReady || viewerCount <= 1) return 1;
|
|
217
|
+
const allowance = Number.isFinite(allowanceSeconds) && allowanceSeconds > 0 ? allowanceSeconds : 1;
|
|
218
|
+
const elapsed = Number.isFinite(pauseSeconds) ? Math.max(0, pauseSeconds) : 0;
|
|
219
|
+
return allowance / (allowance + elapsed);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function mapForViewer({ atSeconds, durationSeconds, allowanceSeconds, playing = true,
|
|
223
|
+
pauseSeconds = 0, urgentReady = false, viewerCount = 1 }) {
|
|
233
224
|
const map = emptyMap(durationSeconds);
|
|
234
225
|
if (map.durationSeconds === 0) {
|
|
235
226
|
return map;
|
|
@@ -237,10 +228,13 @@ export function mapForViewer({ atSeconds, durationSeconds, allowanceSeconds, pla
|
|
|
237
228
|
const at = Number.isFinite(atSeconds) && atSeconds > 0 ? Math.floor(atSeconds) : 0;
|
|
238
229
|
const allowance = Number.isFinite(allowanceSeconds) && allowanceSeconds > 0
|
|
239
230
|
? allowanceSeconds
|
|
240
|
-
:
|
|
231
|
+
: map.durationSeconds;
|
|
241
232
|
const watching = playing !== false;
|
|
242
|
-
|
|
243
|
-
|
|
233
|
+
// The measured urgent span is the time scale. There is no chosen decay
|
|
234
|
+
// period: one span on pause halves the excess above minimum priority.
|
|
235
|
+
const attenuate = !watching && urgentReady && viewerCount > 1;
|
|
236
|
+
const elapsed = Number.isFinite(pauseSeconds) ? Math.max(0, pauseSeconds) : 0;
|
|
237
|
+
const coefficient = pauseCoefficient({ playing, urgentReady, viewerCount, pauseSeconds: elapsed, allowanceSeconds: allowance });
|
|
244
238
|
for (let second = 0; second < map.durationSeconds; second += 1) {
|
|
245
239
|
if (second < at) {
|
|
246
240
|
map.priority[second] = NOBODY_IS_COMING;
|
|
@@ -248,11 +242,13 @@ export function mapForViewer({ atSeconds, durationSeconds, allowanceSeconds, pla
|
|
|
248
242
|
}
|
|
249
243
|
map.behind[second] = 0;
|
|
250
244
|
const distance = second - at;
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
245
|
+
const priority = Math.max(NOBODY_IS_COMING, AT_A_WATCHING_VIEWER - stepsAway(distance, allowance));
|
|
246
|
+
map.priority[second] = Math.max(NOBODY_IS_COMING, 1 + Math.floor((priority - 1) * coefficient));
|
|
247
|
+
map.urgent[second] = distance < allowance && !attenuate ? 1 : 0;
|
|
248
|
+
map.deferred[second] = attenuate ? 1 : 0;
|
|
249
|
+
// Urgent preparation keeps its deadline during pause. After completion,
|
|
250
|
+
// pause duration defers this viewer's work without altering other viewers.
|
|
251
|
+
map.secondsUntilPlayed[second] = distance + (attenuate ? elapsed : 0);
|
|
256
252
|
}
|
|
257
253
|
return map;
|
|
258
254
|
}
|
|
@@ -308,6 +304,7 @@ export function mergeMaps(maps) {
|
|
|
308
304
|
return emptyMap(0);
|
|
309
305
|
}
|
|
310
306
|
const merged = emptyMap(Math.max(...all.map((map) => map.durationSeconds)));
|
|
307
|
+
merged.deferred.fill(1);
|
|
311
308
|
for (const map of all) {
|
|
312
309
|
for (let second = 0; second < map.durationSeconds; second += 1) {
|
|
313
310
|
if (map.priority[second] > merged.priority[second]) {
|
|
@@ -321,8 +318,14 @@ export function mergeMaps(maps) {
|
|
|
321
318
|
if (map.behind[second] === 0) {
|
|
322
319
|
merged.behind[second] = 0;
|
|
323
320
|
}
|
|
321
|
+
if (map.urgent?.[second]) merged.urgent[second] = 1;
|
|
322
|
+
// Deferred only if every viewer needing this second has yielded it.
|
|
323
|
+
if (map.behind[second] === 0 && !map.deferred?.[second]) merged.deferred[second] = 0;
|
|
324
324
|
}
|
|
325
325
|
}
|
|
326
|
+
for (let second = 0; second < merged.durationSeconds; second += 1) {
|
|
327
|
+
if (merged.behind[second]) merged.deferred[second] = 0;
|
|
328
|
+
}
|
|
326
329
|
return merged;
|
|
327
330
|
}
|
|
328
331
|
|
|
@@ -349,12 +352,16 @@ export function runsOf(map) {
|
|
|
349
352
|
continue;
|
|
350
353
|
}
|
|
351
354
|
const behind = map.behind[second] === 1;
|
|
355
|
+
const urgent = map.urgent?.[second] === 1;
|
|
356
|
+
const deferred = map.deferred?.[second] === 1;
|
|
352
357
|
const previous = runs[runs.length - 1];
|
|
353
358
|
if (
|
|
354
359
|
previous
|
|
355
360
|
&& previous.to === second
|
|
356
361
|
&& previous.priority === priority
|
|
357
362
|
&& previous.behind === behind
|
|
363
|
+
&& previous.urgent === urgent
|
|
364
|
+
&& previous.deferred === deferred
|
|
358
365
|
) {
|
|
359
366
|
previous.to = second + 1;
|
|
360
367
|
continue;
|
|
@@ -373,7 +380,9 @@ export function runsOf(map) {
|
|
|
373
380
|
to: second + 1,
|
|
374
381
|
priority,
|
|
375
382
|
withinSeconds,
|
|
376
|
-
behind
|
|
383
|
+
behind,
|
|
384
|
+
urgent,
|
|
385
|
+
deferred
|
|
377
386
|
});
|
|
378
387
|
}
|
|
379
388
|
return runs;
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* append, and an empty picture for six minutes (field 2026-09-05).
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { emptyMap, mapForViewer, mergeMaps, runsOf } from "./PriorityMap.js";
|
|
19
|
+
import { emptyMap, mapForViewer, mergeMaps, runsOf, pauseCoefficient } from "./PriorityMap.js";
|
|
20
20
|
|
|
21
21
|
export class PriorityOrchestrator {
|
|
22
22
|
/** Where the map goes once it is built. @type {(published: object) => void} */
|
|
@@ -66,6 +66,7 @@ export class PriorityOrchestrator {
|
|
|
66
66
|
/** Whether this output is what that person is consuming, as opposed to one
|
|
67
67
|
* they merely hold a record on. @type {(session: object, viewer: object) => boolean} */
|
|
68
68
|
#watchedBy;
|
|
69
|
+
#urgentReadyFor;
|
|
69
70
|
|
|
70
71
|
/**
|
|
71
72
|
* Who watches an output is this component's own registry, handed in as the
|
|
@@ -85,7 +86,7 @@ export class PriorityOrchestrator {
|
|
|
85
86
|
* this layer does not know; absent, every registered viewer counts, and then
|
|
86
87
|
* the per-output map says the same as the per-file one.
|
|
87
88
|
*/
|
|
88
|
-
constructor({ publish, viewers, allowanceFor, watchedBy }) {
|
|
89
|
+
constructor({ publish, viewers, allowanceFor, watchedBy, urgentReadyFor }) {
|
|
89
90
|
if (typeof viewers?.forOutput !== "function") {
|
|
90
91
|
throw new TypeError("PriorityOrchestrator needs the viewer registry");
|
|
91
92
|
}
|
|
@@ -93,6 +94,16 @@ export class PriorityOrchestrator {
|
|
|
93
94
|
this.#viewers = viewers;
|
|
94
95
|
this.#allowanceFor = typeof allowanceFor === "function" ? allowanceFor : () => 0;
|
|
95
96
|
this.#watchedBy = typeof watchedBy === "function" ? watchedBy : () => true;
|
|
97
|
+
this.#urgentReadyFor = typeof urgentReadyFor === "function" ? urgentReadyFor : () => false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Apply the viewer's same pause attenuation to related source preparation. */
|
|
101
|
+
priorityFor(session, viewer, priority, viewerCount, now = Date.now()) {
|
|
102
|
+
const allowanceSeconds = this.#allowanceFor(session);
|
|
103
|
+
const coefficient = pauseCoefficient({ playing: viewer.playing || viewer.waiting, viewerCount,
|
|
104
|
+
pauseSeconds: viewer.pausedAt === null ? 0 : (now - viewer.pausedAt) / 1000, allowanceSeconds,
|
|
105
|
+
urgentReady: viewer.pausedAt !== null && this.#urgentReadyFor(session, viewer, allowanceSeconds, now) });
|
|
106
|
+
return Math.max(1, 1 + Math.floor((priority - 1) * coefficient));
|
|
96
107
|
}
|
|
97
108
|
|
|
98
109
|
/**
|
|
@@ -110,17 +121,21 @@ export class PriorityOrchestrator {
|
|
|
110
121
|
* nobody is watching or the film's length is unknown, which says the same as
|
|
111
122
|
* a map with nothing in it.
|
|
112
123
|
*/
|
|
113
|
-
#mapFrom({ durationSeconds, allowanceSeconds, viewers }) {
|
|
124
|
+
#mapFrom({ durationSeconds, allowanceSeconds, viewers, viewerCount }) {
|
|
114
125
|
if (!(durationSeconds > 0) || !(viewers?.length > 0)) {
|
|
115
126
|
return emptyMap(0);
|
|
116
127
|
}
|
|
128
|
+
const count = viewerCount ?? new Set(viewers.map((one, index) => one.id ?? index)).size;
|
|
117
129
|
return mergeMaps(
|
|
118
130
|
viewers.map((viewer) =>
|
|
119
131
|
mapForViewer({
|
|
120
132
|
atSeconds: viewer.atSeconds,
|
|
121
133
|
durationSeconds,
|
|
122
134
|
allowanceSeconds,
|
|
123
|
-
playing: viewer.playing !== false
|
|
135
|
+
playing: viewer.playing !== false,
|
|
136
|
+
pauseSeconds: viewer.pauseSeconds ?? 0,
|
|
137
|
+
urgentReady: viewer.urgentReady === true,
|
|
138
|
+
viewerCount: count
|
|
124
139
|
})
|
|
125
140
|
)
|
|
126
141
|
);
|
|
@@ -139,7 +154,7 @@ export class PriorityOrchestrator {
|
|
|
139
154
|
* @returns {import("./PriorityMap.js").PriorityMap} One number per second of
|
|
140
155
|
* film, merged over everyone watching it.
|
|
141
156
|
*/
|
|
142
|
-
build({ sourceKey, fileIndex, durationSeconds, allowanceSeconds, viewers }) {
|
|
157
|
+
build({ sourceKey, fileIndex, durationSeconds, allowanceSeconds, viewers, demandKey = null }) {
|
|
143
158
|
const map = this.#mapFrom({ durationSeconds, allowanceSeconds, viewers });
|
|
144
159
|
const key = `${sourceKey}:${fileIndex}`;
|
|
145
160
|
this.#maps.set(key, map);
|
|
@@ -148,7 +163,7 @@ export class PriorityOrchestrator {
|
|
|
148
163
|
// make it do that several times a second. Compared as stretches rather than
|
|
149
164
|
// second by second, which is the same comparison over far fewer values.
|
|
150
165
|
const zones = runsOf(map);
|
|
151
|
-
const shape = JSON.stringify(zones);
|
|
166
|
+
const shape = JSON.stringify({ zones, demandKey });
|
|
152
167
|
if (this.#last.get(key)?.shape !== shape) {
|
|
153
168
|
// The length is remembered with the shape, so that a file whose viewers
|
|
154
169
|
// have all gone can still be spoken for: what is published then is the
|
|
@@ -196,7 +211,12 @@ export class PriorityOrchestrator {
|
|
|
196
211
|
const byOutput = new Map();
|
|
197
212
|
for (const sessions of sessionGroups) {
|
|
198
213
|
for (const session of sessions) {
|
|
199
|
-
const
|
|
214
|
+
const sourceKey = session.file?.sourceKey;
|
|
215
|
+
const fileIndex = session.file?.fileIndex;
|
|
216
|
+
if (typeof sourceKey !== "string" || !sourceKey || !Number.isSafeInteger(fileIndex) || fileIndex < 0) {
|
|
217
|
+
throw new TypeError("A priority map requires its output file's source and file index.");
|
|
218
|
+
}
|
|
219
|
+
const key = `${sourceKey}:${fileIndex}`;
|
|
200
220
|
const durationSeconds = Number(session.file?.durationSeconds) || 0;
|
|
201
221
|
// The first band is as wide as an interruption this file has actually
|
|
202
222
|
// shown on this swarm, never a chosen number.
|
|
@@ -204,11 +224,12 @@ export class PriorityOrchestrator {
|
|
|
204
224
|
let held = byFile.get(key);
|
|
205
225
|
if (!held) {
|
|
206
226
|
held = {
|
|
207
|
-
sourceKey
|
|
208
|
-
fileIndex
|
|
227
|
+
sourceKey,
|
|
228
|
+
fileIndex,
|
|
209
229
|
durationSeconds,
|
|
210
230
|
allowanceSeconds,
|
|
211
|
-
viewers: []
|
|
231
|
+
viewers: [],
|
|
232
|
+
demandKey: []
|
|
212
233
|
};
|
|
213
234
|
byFile.set(key, held);
|
|
214
235
|
}
|
|
@@ -219,15 +240,21 @@ export class PriorityOrchestrator {
|
|
|
219
240
|
const address = session.outputKey ?? "";
|
|
220
241
|
let mine = byOutput.get(address);
|
|
221
242
|
if (!mine) {
|
|
222
|
-
mine = { durationSeconds, allowanceSeconds, viewers: [] };
|
|
243
|
+
mine = { durationSeconds, allowanceSeconds, viewers: [], fileKey: key };
|
|
223
244
|
byOutput.set(address, mine);
|
|
224
245
|
}
|
|
225
246
|
for (const viewer of this.#viewers.forOutput(session).values()) {
|
|
226
247
|
if (!viewer.isPresent()) {
|
|
227
248
|
continue;
|
|
228
249
|
}
|
|
250
|
+
held.demandKey.push([session.outputKey, viewer.id, viewer.audio?.trackIndex,
|
|
251
|
+
viewer.audio?.transcode, viewer.activeVariantId, viewer.warmingVariantId,
|
|
252
|
+
viewer.warmingAudioId, this.#watchedBy(session, viewer)]);
|
|
229
253
|
const stated = {
|
|
254
|
+
id: viewer.id,
|
|
230
255
|
atSeconds: viewer.positionSeconds(now) ?? 0,
|
|
256
|
+
pauseSeconds: viewer.pausedAt == null ? 0 : Math.max(0, (now - viewer.pausedAt) / 1000),
|
|
257
|
+
urgentReady: viewer.pausedAt != null && this.#urgentReadyFor(session, viewer, allowanceSeconds, now),
|
|
231
258
|
// WATCHING OR WAITING, not merely playing. Three states reach this
|
|
232
259
|
// one question: a viewer whose picture is advancing, a viewer
|
|
233
260
|
// blocked on material we owe them, and a viewer who stopped it
|
|
@@ -236,9 +263,9 @@ export class PriorityOrchestrator {
|
|
|
236
263
|
// on screen has its timers throttled and asks for nothing, which is
|
|
237
264
|
// indistinguishable from a full cushion, so it is not consuming
|
|
238
265
|
// either — told apart on the page, folded into one question here.
|
|
239
|
-
playing: typeof viewer.wantsFilmNow === "function"
|
|
266
|
+
playing: viewer.pausedAt == null && (typeof viewer.wantsFilmNow === "function"
|
|
240
267
|
? viewer.wantsFilmNow()
|
|
241
|
-
: viewer.playing === true || viewer.waiting === true
|
|
268
|
+
: viewer.playing === true || viewer.waiting === true)
|
|
242
269
|
};
|
|
243
270
|
held.viewers.push(stated);
|
|
244
271
|
if (this.#watchedBy(session, viewer)) {
|
|
@@ -280,6 +307,8 @@ export class PriorityOrchestrator {
|
|
|
280
307
|
}
|
|
281
308
|
}
|
|
282
309
|
for (const [address, one] of byOutput) {
|
|
310
|
+
// Competition is per file, even when two viewers use different outputs.
|
|
311
|
+
one.viewerCount = new Set((byFile.get(one.fileKey)?.viewers ?? []).map((viewer, index) => viewer.id ?? index)).size;
|
|
283
312
|
this.#byOutput.set(address, this.#mapFrom(one));
|
|
284
313
|
}
|
|
285
314
|
}
|