@torrent-tv/proxy 2.95.38 → 2.95.40
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 +4 -1
- package/package.json +1 -1
- package/server.js +4 -0
- package/services/encode/OutputOpening.js +11 -3
- package/services/encode/Renditions.js +4 -2
- package/services/encode/hwaccel.js +18 -13
- package/services/encode/quality/EncodeCost.js +31 -1
- package/services/encode/quality/QualityController.js +2 -1
- package/services/encode/quality/link-budget.js +22 -5
- package/services/encode/quality/output-format.js +3 -2
- package/services/media/SourceFile.js +54 -17
- package/services/media/container/MatroskaContainer.js +42 -1
- package/services/media/container/Mp4Container.js +40 -1
- package/services/media/container/matroska-work-tags.js +48 -1
- package/services/media/playback-declarations.js +2 -0
- package/services/media/playback-planner.js +2 -0
- package/services/media/tracks/VideoTrack.js +3 -0
- package/services/server/wire-outputs.js +7 -0
- package/services/transport/data-channel-handler.js +20 -4
- package/services/transport/delivery-probe.js +5 -1
- package/services/transport/delivery-shares.js +108 -0
package/bin/cli.js
CHANGED
|
@@ -376,7 +376,10 @@ try {
|
|
|
376
376
|
// Late-bound the same way `webRtcManager` is below: the torrent pool is
|
|
377
377
|
// built inside `startProxyServer`, before `dataChannelHandler` — the
|
|
378
378
|
// thing that actually owns a channel to push down — exists.
|
|
379
|
-
onSubtitleCues: (event) => dataChannelHandler?.publishSubtitleCues(event)
|
|
379
|
+
onSubtitleCues: (event) => dataChannelHandler?.publishSubtitleCues(event),
|
|
380
|
+
// Late-bound for the same reason: what the connections carry beyond the
|
|
381
|
+
// film is counted by the data-channel handler, built further down.
|
|
382
|
+
serviceShare: () => dataChannelHandler?.serviceShare?.() ?? null
|
|
380
383
|
});
|
|
381
384
|
app = started.app;
|
|
382
385
|
actualPort = started.port;
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -117,6 +117,9 @@ function buildPortCandidates(startPort, maxAttempts = 51) {
|
|
|
117
117
|
export async function startProxyServer({
|
|
118
118
|
host, port, transcodeAudio, ffmpegBin, memoryBytes, segmentFormat, stateDir, onSubtitleCues, diagnostics, budgetPolicy,
|
|
119
119
|
deliverySink = false,
|
|
120
|
+
// What viewers' connections carry beyond the film, per byte of film; the
|
|
121
|
+
// transport counts it and is built after this server, so it is late-bound.
|
|
122
|
+
serviceShare = () => null,
|
|
120
123
|
// Where the proxy writes its own log. Its DIRECTORY is what matters here:
|
|
121
124
|
// the browser's half of every session is written beside it, so the two are
|
|
122
125
|
// on one durable disk and join by name.
|
|
@@ -608,6 +611,7 @@ export async function startProxyServer({
|
|
|
608
611
|
let sourcePreparation;
|
|
609
612
|
const outputParts = wireOutputs({
|
|
610
613
|
indexMemory,
|
|
614
|
+
serviceShare,
|
|
611
615
|
readMetadataActivity: () => containerOrchestrator.activity(),
|
|
612
616
|
onViewerChanged: () => { void sourcePreparation?.refresh(); },
|
|
613
617
|
sourceInputsFor: (output, index) => downloadMaps.inputsForOutput(output.file.sourceKey, output.outputKey, index),
|
|
@@ -587,13 +587,21 @@ export class OutputOpening {
|
|
|
587
587
|
linkMbps: viewerLinkMbps,
|
|
588
588
|
keys: [...this.#host.outputs.values()].map((other) => other.outputKey).concat(this.#host.segmentOutputFiles.addresses()),
|
|
589
589
|
readyAt: (key) => this.#host.segmentOutputFiles.isClosed(key, timeline.indexForTime(Math.max(0, startPositionSeconds))),
|
|
590
|
-
observedPeakMbps: (candidate) => this.#host.observedPeakMbps?.(candidate) ?? null
|
|
590
|
+
observedPeakMbps: (candidate) => this.#host.observedPeakMbps?.(candidate) ?? null,
|
|
591
|
+
serviceShare: () => this.#host.serviceShare?.() ?? null
|
|
591
592
|
}
|
|
592
593
|
});
|
|
594
|
+
// Which picture rate the link was asked about, and where it came from
|
|
595
|
+
// (`SourceFile.pictureRate`): a track's own figure, the file minus its
|
|
596
|
+
// soundtracks, or the whole file as a bound.
|
|
597
|
+
const pictureRate = file.pictureRate;
|
|
598
|
+
const pictureRateNote = pictureRate
|
|
599
|
+
? ` picture=${pictureRate.kbps}kbps (${pictureRate.source})`
|
|
600
|
+
: " picture rate unknown: the file's own rate is not known yet";
|
|
593
601
|
if (decided.unavailable) {
|
|
594
602
|
this.#host.logger.info(
|
|
595
603
|
`transcode "${logName}": no output suits this viewer — ${decided.unavailable.reason} ` +
|
|
596
|
-
`${JSON.stringify(decided.unavailable.figures)} (wanted ${decided.wantedKey})`
|
|
604
|
+
`${JSON.stringify(decided.unavailable.figures)} (wanted ${decided.wantedKey})${pictureRateNote}`
|
|
597
605
|
);
|
|
598
606
|
const error = new Error(`No output suits this viewer: ${decided.unavailable.reason}.`);
|
|
599
607
|
// Bound by the MACHINE rather than by the viewer's link, for the output a
|
|
@@ -616,7 +624,7 @@ export class OutputOpening {
|
|
|
616
624
|
}
|
|
617
625
|
if (decided.answer) {
|
|
618
626
|
this.#host.logger.info(
|
|
619
|
-
`transcode "${logName}": ${decided.answer.verdict} for this viewer ${JSON.stringify(linkFiguresOf(decided.answer))}`
|
|
627
|
+
`transcode "${logName}": ${decided.answer.verdict} for this viewer ${JSON.stringify(linkFiguresOf(decided.answer))}${pictureRateNote}`
|
|
620
628
|
);
|
|
621
629
|
}
|
|
622
630
|
const spec = decided.spec;
|
|
@@ -137,7 +137,8 @@ export class Renditions {
|
|
|
137
137
|
Number.isFinite(base.file.pictureKbps) ? base.file.pictureKbps / 1000 : null,
|
|
138
138
|
this.#host.observedPeakMbps?.(spec) ?? null
|
|
139
139
|
),
|
|
140
|
-
audioLoad
|
|
140
|
+
audioLoad,
|
|
141
|
+
this.#host.serviceShare?.() ?? null
|
|
141
142
|
)
|
|
142
143
|
)
|
|
143
144
|
});
|
|
@@ -535,7 +536,8 @@ export class Renditions {
|
|
|
535
536
|
}
|
|
536
537
|
const linkMbps = this.#host.linkMbpsOf(base, consumerId);
|
|
537
538
|
const audioLoad = this.viewerAudioLoadOf(base, consumerId);
|
|
538
|
-
const
|
|
539
|
+
const serviceShare = this.#host.serviceShare?.() ?? null;
|
|
540
|
+
const admits = (limit) => linkCouldCarry(linkMbps, loadOf(videoLoadOfLimit(limit), audioLoad, serviceShare)).admitted;
|
|
539
541
|
const target = direction === "down"
|
|
540
542
|
? limits.slice(at + 1).find(admits)
|
|
541
543
|
: (at > 0 && admits(limits[at - 1]) ? limits[at - 1] : undefined);
|
|
@@ -1682,13 +1682,15 @@ export function pickSoftwarePreset(benchmark, pixelsPerSecNeeded, cost = {}, fra
|
|
|
1682
1682
|
if (encodePixelsPerSec === null) {
|
|
1683
1683
|
continue;
|
|
1684
1684
|
}
|
|
1685
|
-
const speed =
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1685
|
+
const speed = typeof cost.speedForPreset === "function"
|
|
1686
|
+
? cost.speedForPreset([entry], frame, pixelsPerSecNeeded)
|
|
1687
|
+
: predictedRealtimeSpeed({
|
|
1688
|
+
decodeModel: cost.decodeModel ?? null,
|
|
1689
|
+
encodePixelsPerSec,
|
|
1690
|
+
outputPixelsPerSec: pixelsPerSecNeeded,
|
|
1691
|
+
source: cost.source ?? null,
|
|
1692
|
+
observedDecodeCostSec: observed
|
|
1693
|
+
});
|
|
1692
1694
|
if (speed !== null && speed >= bar) {
|
|
1693
1695
|
return entry.preset;
|
|
1694
1696
|
}
|
|
@@ -1786,12 +1788,15 @@ export function chooseSoftwareEncodeSettings(benchmark, ceiling, outputFps, cost
|
|
|
1786
1788
|
if (fastest === null) {
|
|
1787
1789
|
continue;
|
|
1788
1790
|
}
|
|
1789
|
-
const
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1791
|
+
const outputPixelsPerSec = ladder[i].width * ladder[i].height * fps;
|
|
1792
|
+
const speed = typeof cost.speedForPreset === "function"
|
|
1793
|
+
? cost.speedForPreset([benchmark.at(-1)], ladder[i], outputPixelsPerSec)
|
|
1794
|
+
: predictedRealtimeSpeed({
|
|
1795
|
+
decodeModel: cost.decodeModel ?? null,
|
|
1796
|
+
encodePixelsPerSec: fastest,
|
|
1797
|
+
outputPixelsPerSec,
|
|
1798
|
+
source: cost.source ?? null
|
|
1799
|
+
});
|
|
1795
1800
|
if (speed !== null && speed >= bar) {
|
|
1796
1801
|
chosenIndex = i;
|
|
1797
1802
|
break;
|
|
@@ -172,6 +172,21 @@ export class EncodeCost {
|
|
|
172
172
|
);
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
+
#committedMachineCostSec() {
|
|
176
|
+
let cost = 0;
|
|
177
|
+
for (const session of this.#outputs.values()) {
|
|
178
|
+
if (!processCanBeSignalled(this.#stateFor(session))) {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const measured = this.#loadOfSession(session)?.costSec;
|
|
182
|
+
if (!Number.isFinite(measured) || measured <= 0) {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
cost += measured;
|
|
186
|
+
}
|
|
187
|
+
return cost;
|
|
188
|
+
}
|
|
189
|
+
|
|
175
190
|
// The last refusal printed. The offer is recomputed on the path that serves
|
|
176
191
|
// every playlist, init and segment, and the figures behind it move every few
|
|
177
192
|
// seconds — so the line is written when the ANSWER changes, not when it is
|
|
@@ -1085,11 +1100,26 @@ export class EncodeCost {
|
|
|
1085
1100
|
if (!ceiling) {
|
|
1086
1101
|
return null;
|
|
1087
1102
|
}
|
|
1103
|
+
const concurrentCostSec = this.#committedMachineCostSec();
|
|
1104
|
+
const speedForPreset = (benchmark, frame, outputPixelsPerSec) => {
|
|
1105
|
+
if (concurrentCostSec === null) {
|
|
1106
|
+
return null;
|
|
1107
|
+
}
|
|
1108
|
+
return this.#speedOnThisMachine({
|
|
1109
|
+
benchmark,
|
|
1110
|
+
source,
|
|
1111
|
+
width: frame.width,
|
|
1112
|
+
height: frame.height,
|
|
1113
|
+
fps: outputPixelsPerSec / (frame.width * frame.height),
|
|
1114
|
+
observedDecodeCostSec: null,
|
|
1115
|
+
concurrentCostSec
|
|
1116
|
+
});
|
|
1117
|
+
};
|
|
1088
1118
|
return chooseSoftwareEncodeSettings(
|
|
1089
1119
|
this.#host().benchmark,
|
|
1090
1120
|
{ width: ceiling.w, height: ceiling.h },
|
|
1091
1121
|
outputFps,
|
|
1092
|
-
{ decodeModel: this.#host().decodeModel, source, requiredSpeed }
|
|
1122
|
+
{ decodeModel: this.#host().decodeModel, source, requiredSpeed, speedForPreset }
|
|
1093
1123
|
);
|
|
1094
1124
|
}
|
|
1095
1125
|
}
|
|
@@ -373,7 +373,8 @@ export class QualityController {
|
|
|
373
373
|
const report = this.#host.linkReportOf(session, consumerId);
|
|
374
374
|
const load = loadOf(
|
|
375
375
|
this.#host.qualityOffer.videoLoadFor(base, height, this.#host.videoEncoder?.kind ?? ""),
|
|
376
|
-
this.#host.viewerAudioLoadOf(base, consumerId)
|
|
376
|
+
this.#host.viewerAudioLoadOf(base, consumerId),
|
|
377
|
+
this.#host.serviceShare?.() ?? null
|
|
377
378
|
);
|
|
378
379
|
return linkCouldCarry(report?.linkMbps ?? null, load).admitted;
|
|
379
380
|
}
|
|
@@ -20,6 +20,14 @@
|
|
|
20
20
|
* soundtrack whose rate nothing states.
|
|
21
21
|
*
|
|
22
22
|
* A load is as trustworthy as its least trustworthy part.
|
|
23
|
+
*
|
|
24
|
+
* AND WHAT THE CONNECTION CARRIES BESIDES THE FILM (torrent-tv/meta#169). The
|
|
25
|
+
* same connection carries message framing, playlists, poll answers, probes and
|
|
26
|
+
* pushed cues. Their share is measured by the transport over everything this
|
|
27
|
+
* proxy has delivered (`transport/delivery-shares.js`) and added on top of the
|
|
28
|
+
* picture and the sound. It is an average proportion and not a bound, so it
|
|
29
|
+
* moves the figure without deciding the load's class; unmeasured, nothing is
|
|
30
|
+
* added and the figures say so (`serviceShare: null`).
|
|
23
31
|
*/
|
|
24
32
|
|
|
25
33
|
import { AUDIO_TRANSCODE_KBPS, maxrateKbpsFor, nominalKbpsFor } from "../args.js";
|
|
@@ -44,7 +52,7 @@ const CLASS_ORDER = [PEAK_CLASS.KNOWN, PEAK_CLASS.ESTIMATED, PEAK_CLASS.UNKNOWN]
|
|
|
44
52
|
|
|
45
53
|
/**
|
|
46
54
|
* @typedef {{ mbps: number | null, peakClass: string }} LoadPart
|
|
47
|
-
* @typedef {{ video: LoadPart | null, audio: LoadPart | null, totalMbps: number | null, peakClass: string }} Load
|
|
55
|
+
* @typedef {{ video: LoadPart | null, audio: LoadPart | null, serviceMbps: number | null, serviceShare: number | null, totalMbps: number | null, peakClass: string }} Load
|
|
48
56
|
*/
|
|
49
57
|
|
|
50
58
|
/**
|
|
@@ -220,22 +228,29 @@ export const SOUNDTRACK_MODE_CAUSE = Object.freeze({
|
|
|
220
228
|
});
|
|
221
229
|
|
|
222
230
|
/**
|
|
223
|
-
* The whole load: the parts added,
|
|
231
|
+
* The whole load: the parts added, what the connection carries besides them,
|
|
232
|
+
* and the class of the least trustworthy part.
|
|
224
233
|
*
|
|
225
234
|
* @param {LoadPart | null} video
|
|
226
235
|
* @param {LoadPart | null} audio
|
|
236
|
+
* @param {number | null} [serviceShare] - What the connection carries beyond
|
|
237
|
+
* the film, per byte of film, as the transport measured it; null while
|
|
238
|
+
* nothing has been measured.
|
|
227
239
|
* @returns {Load}
|
|
228
240
|
*/
|
|
229
|
-
export function loadOf(video, audio) {
|
|
241
|
+
export function loadOf(video, audio, serviceShare = null) {
|
|
230
242
|
const parts = [video, audio].filter(Boolean);
|
|
231
243
|
const peakClass = parts.reduce(
|
|
232
244
|
(worst, one) => (CLASS_ORDER.indexOf(one.peakClass) > CLASS_ORDER.indexOf(worst) ? one.peakClass : worst),
|
|
233
245
|
PEAK_CLASS.KNOWN
|
|
234
246
|
);
|
|
235
|
-
const
|
|
247
|
+
const share = Number.isFinite(serviceShare) && serviceShare >= 0 ? serviceShare : null;
|
|
248
|
+
const filmMbps = peakClass === PEAK_CLASS.UNKNOWN
|
|
236
249
|
? null
|
|
237
250
|
: parts.reduce((sum, one) => sum + one.mbps, 0);
|
|
238
|
-
|
|
251
|
+
const serviceMbps = filmMbps === null || share === null ? null : filmMbps * share;
|
|
252
|
+
const totalMbps = filmMbps === null ? null : filmMbps + (serviceMbps ?? 0);
|
|
253
|
+
return { video, audio, serviceMbps, serviceShare: share, totalMbps, peakClass };
|
|
239
254
|
}
|
|
240
255
|
|
|
241
256
|
/**
|
|
@@ -335,6 +350,8 @@ export function linkAnswerFigures(answer) {
|
|
|
335
350
|
videoClass: answer.load.video?.peakClass ?? null,
|
|
336
351
|
audioMbps: answer.load.audio?.mbps ?? null,
|
|
337
352
|
audioClass: answer.load.audio?.peakClass ?? null,
|
|
353
|
+
serviceMbps: answer.load.serviceMbps ?? null,
|
|
354
|
+
serviceShare: answer.load.serviceShare ?? null,
|
|
338
355
|
totalMbps: answer.load.totalMbps,
|
|
339
356
|
peakClass: answer.load.peakClass
|
|
340
357
|
};
|
|
@@ -62,7 +62,7 @@ import { linkAnswerFigures, linkCouldCarry, linkRefusalReason, loadOf, videoLoad
|
|
|
62
62
|
* soundtrack this viewer receives with the picture, from it or beside it.
|
|
63
63
|
* @param {(encode: object | null) => OutputSpec} params.specWith - The output
|
|
64
64
|
* with this picture format and every other part as the request made it.
|
|
65
|
-
* @param {{ mode: "auto" | "manual", linkMbps: number | null, keys: Iterable<string>, readyAt: (key: string) => boolean, observedPeakMbps?: (spec: OutputSpec) => number | null }} params.serving
|
|
65
|
+
* @param {{ mode: "auto" | "manual", linkMbps: number | null, keys: Iterable<string>, readyAt: (key: string) => boolean, observedPeakMbps?: (spec: OutputSpec) => number | null, serviceShare?: () => number | null }} params.serving
|
|
66
66
|
* @returns {{
|
|
67
67
|
* spec: OutputSpec | null,
|
|
68
68
|
* budget: object | null,
|
|
@@ -97,7 +97,8 @@ export function decideOutputFormat({
|
|
|
97
97
|
loadOf(
|
|
98
98
|
videoLoadOfSpec(spec, Number.isFinite(source.pictureKbps) ? source.pictureKbps / 1000 : null,
|
|
99
99
|
serving.observedPeakMbps?.(spec) ?? null),
|
|
100
|
-
audioLoad
|
|
100
|
+
audioLoad,
|
|
101
|
+
serving.serviceShare?.() ?? null
|
|
101
102
|
)
|
|
102
103
|
);
|
|
103
104
|
const given = (spec, answer, extra = {}) => ({
|
|
@@ -60,30 +60,58 @@ export function sourceDecodeCharacteristics(mediaInfo) {
|
|
|
60
60
|
};
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/** Where a picture rate was taken from, most exact first. */
|
|
64
|
+
export const PICTURE_RATE_SOURCE = Object.freeze({
|
|
65
|
+
TRACK: "the picture track's own rate",
|
|
66
|
+
FILE_MINUS_AUDIO: "the file's rate minus every soundtrack's",
|
|
67
|
+
FILE_BOUND: "bounded by the whole file's rate"
|
|
68
|
+
});
|
|
69
|
+
|
|
63
70
|
/**
|
|
64
|
-
* The source picture's average rate,
|
|
65
|
-
* the rates stated for every embedded audio track. A missing track rate makes
|
|
66
|
-
* the picture rate unknown rather than treating the whole file rate as video.
|
|
71
|
+
* The source picture's average rate, and where it came from.
|
|
67
72
|
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
73
|
+
* 1. the picture track's own rate, where the container states it per track —
|
|
74
|
+
* MP4 counts it from the sizes of its samples, Matroska carries it in the
|
|
75
|
+
* track's statistics tags;
|
|
76
|
+
* 2. otherwise the whole file's rate minus every soundtrack's stated rate;
|
|
77
|
+
* 3. otherwise the whole file's rate. The picture is part of the file, so its
|
|
78
|
+
* average cannot be larger: this is an upper bound, not a guess, and it
|
|
79
|
+
* errs towards a heavier load. It is used where a soundtrack states no
|
|
80
|
+
* average — before torrent-tv/meta#169 that made the picture unknown, and
|
|
81
|
+
* every MP4 and Matroska file with sound was refused on a measured link.
|
|
82
|
+
*
|
|
83
|
+
* @param {{ bitrateKbps?: number | null, videoBitrateKbps?: number | null, audioTracks?: Array<{ bitrateKbps?: number | null }> } | null} mediaInfo
|
|
84
|
+
* @returns {{ kbps: number, source: string } | null} Null when not even the
|
|
85
|
+
* file's rate is known.
|
|
70
86
|
*/
|
|
71
|
-
export function
|
|
87
|
+
export function sourcePictureRate(mediaInfo) {
|
|
88
|
+
const positive = (value) => Number.isFinite(Number(value)) && Number(value) > 0;
|
|
89
|
+
if (positive(mediaInfo?.videoBitrateKbps)) {
|
|
90
|
+
return { kbps: Math.round(Number(mediaInfo.videoBitrateKbps)), source: PICTURE_RATE_SOURCE.TRACK };
|
|
91
|
+
}
|
|
72
92
|
const totalKbps = Number(mediaInfo?.bitrateKbps);
|
|
73
|
-
|
|
74
|
-
if (!(Number.isFinite(totalKbps) && totalKbps > 0) || !Array.isArray(audioTracks)) {
|
|
93
|
+
if (!positive(totalKbps)) {
|
|
75
94
|
return null;
|
|
76
95
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
if (
|
|
81
|
-
return
|
|
96
|
+
const audioTracks = Array.isArray(mediaInfo?.audioTracks) ? mediaInfo.audioTracks : null;
|
|
97
|
+
if (audioTracks && audioTracks.every((track) => positive(track?.bitrateKbps))) {
|
|
98
|
+
const pictureKbps = totalKbps - audioTracks.reduce((sum, track) => sum + Number(track.bitrateKbps), 0);
|
|
99
|
+
if (pictureKbps > 0) {
|
|
100
|
+
return { kbps: Math.round(pictureKbps), source: PICTURE_RATE_SOURCE.FILE_MINUS_AUDIO };
|
|
82
101
|
}
|
|
83
|
-
audioKbps += rate;
|
|
84
102
|
}
|
|
85
|
-
|
|
86
|
-
|
|
103
|
+
return { kbps: Math.round(totalKbps), source: PICTURE_RATE_SOURCE.FILE_BOUND };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The source picture's average rate in kbit/s, or null while not even the
|
|
108
|
+
* file's rate is known ({@link sourcePictureRate}).
|
|
109
|
+
*
|
|
110
|
+
* @param {object | null} mediaInfo
|
|
111
|
+
* @returns {number | null}
|
|
112
|
+
*/
|
|
113
|
+
export function sourcePictureBitrateKbps(mediaInfo) {
|
|
114
|
+
return sourcePictureRate(mediaInfo)?.kbps ?? null;
|
|
87
115
|
}
|
|
88
116
|
|
|
89
117
|
export class SourceFile {
|
|
@@ -221,11 +249,20 @@ export class SourceFile {
|
|
|
221
249
|
return Number.isFinite(kbps) && kbps > 0 ? kbps / 1000 : null;
|
|
222
250
|
}
|
|
223
251
|
|
|
224
|
-
/** The source picture's average rate, or null until
|
|
252
|
+
/** The source picture's average rate, or null until the file's rate is known. */
|
|
225
253
|
get pictureKbps() {
|
|
226
254
|
return sourcePictureBitrateKbps(this.media);
|
|
227
255
|
}
|
|
228
256
|
|
|
257
|
+
/**
|
|
258
|
+
* The source picture's average rate with where it came from, for log lines.
|
|
259
|
+
*
|
|
260
|
+
* @returns {{ kbps: number, source: string } | null}
|
|
261
|
+
*/
|
|
262
|
+
get pictureRate() {
|
|
263
|
+
return sourcePictureRate(this.media);
|
|
264
|
+
}
|
|
265
|
+
|
|
229
266
|
/**
|
|
230
267
|
* The address this proxy's own HTTP serves this file at.
|
|
231
268
|
*
|
|
@@ -49,7 +49,7 @@ import { RetainedReads } from "./RetainedReads.js";
|
|
|
49
49
|
import { isUnavailable } from "./unavailable.js";
|
|
50
50
|
import {
|
|
51
51
|
ID_ATTACHED_FILE, ID_ATTACHMENTS, ID_CHAPTERS, ID_FILE_DATA, ID_FILE_MEDIA_TYPE, ID_FILE_NAME, ID_INFO_TITLE, ID_TAGS,
|
|
52
|
-
chapterTitlesOf, coverIndexOf, workFromTags
|
|
52
|
+
chapterTitlesOf, coverIndexOf, trackRatesFromTags, uidKeyOf, workFromTags
|
|
53
53
|
} from "./matroska-work-tags.js";
|
|
54
54
|
import {
|
|
55
55
|
COVER_TYPES, MAX_COVER_BYTES, OutsideReadableEdges, edgeReader, emptyWorkTags, imageTypeOf, isNumberingOnly, text, textList
|
|
@@ -80,6 +80,7 @@ const ID_TRACKS = 0x1654ae6b;
|
|
|
80
80
|
const ID_TRACK_ENTRY = 0xae;
|
|
81
81
|
const ID_TRACK_NUMBER = 0xd7;
|
|
82
82
|
const ID_TRACK_TYPE = 0x83;
|
|
83
|
+
const ID_TRACK_UID = 0x73c5;
|
|
83
84
|
const ID_FLAG_ENABLED = 0xb9;
|
|
84
85
|
const ID_FLAG_DEFAULT = 0x88;
|
|
85
86
|
const ID_FLAG_FORCED = 0x55aa;
|
|
@@ -531,6 +532,8 @@ export class MatroskaContainer extends Container {
|
|
|
531
532
|
/** @type {import("../tracks/index.js").ContainerTrack[]} */
|
|
532
533
|
const result = [];
|
|
533
534
|
const counters = { video: -1, audio: -1, subtitle: -1 };
|
|
535
|
+
/** @type {Map<string, import("../tracks/index.js").ContainerTrack>} */
|
|
536
|
+
const byUid = new Map();
|
|
534
537
|
const within = this.#readerWithin(tracksHeader.end);
|
|
535
538
|
let at = tracksHeader.dataOffset;
|
|
536
539
|
while (at < tracksHeader.end) {
|
|
@@ -563,15 +566,51 @@ export class MatroskaContainer extends Container {
|
|
|
563
566
|
track.codecDelaySeconds = fields.codecDelaySeconds ?? 0;
|
|
564
567
|
track.timestampScale = fields.timestampScale ?? 1;
|
|
565
568
|
track.codecRanges = [[entry.dataOffset, entry.end - 1]];
|
|
569
|
+
if (fields.trackUid && (track.type === "video" || track.type === "audio")) byUid.set(fields.trackUid, track);
|
|
566
570
|
result.push(track);
|
|
567
571
|
}
|
|
568
572
|
}
|
|
569
573
|
at = entry.end;
|
|
570
574
|
}
|
|
575
|
+
await this.#readTrackRates(reader, layout, byUid);
|
|
571
576
|
this.#tracks = result;
|
|
572
577
|
return result;
|
|
573
578
|
}
|
|
574
579
|
|
|
580
|
+
/**
|
|
581
|
+
* The average rate each picture and soundtrack's statistics state, written
|
|
582
|
+
* onto the track as `bitrateKbps` (`trackRatesFromTags`).
|
|
583
|
+
*
|
|
584
|
+
* Read strictly with the track table, like the table itself: a `Tags`
|
|
585
|
+
* element whose bytes have not arrived is not an answer that the file states
|
|
586
|
+
* none, so the whole reading is asked again when they have. A `Tags`
|
|
587
|
+
* element larger than one portion is not read, and one that cannot be parsed
|
|
588
|
+
* states no rates: a damaged tag costs the rate, never the track table.
|
|
589
|
+
*
|
|
590
|
+
* @param {ElementReader} reader
|
|
591
|
+
* @param {SegmentLayout} layout
|
|
592
|
+
* @param {Map<string, import("../tracks/index.js").ContainerTrack>} byUid
|
|
593
|
+
* @returns {Promise<void>}
|
|
594
|
+
*/
|
|
595
|
+
async #readTrackRates(reader, layout, byUid) {
|
|
596
|
+
if (byUid.size === 0 || layout.tagsAt === null) return;
|
|
597
|
+
const header = await reader.header(layout.tagsAt, layout.segmentEnd);
|
|
598
|
+
if (!header || header.id !== ID_TAGS || header.size === null || header.size > this.portionBytes) return;
|
|
599
|
+
const data = await reader.data(header);
|
|
600
|
+
if (!data) return;
|
|
601
|
+
let rates;
|
|
602
|
+
try {
|
|
603
|
+
rates = trackRatesFromTags(data);
|
|
604
|
+
} catch (error) {
|
|
605
|
+
if (isUnavailable(error)) throw error;
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
for (const [uid, kbps] of rates) {
|
|
609
|
+
const track = byUid.get(uid);
|
|
610
|
+
if (track) track.bitrateKbps = kbps;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
575
614
|
/** Original-file ranges from Cues alone, without reading media block headers.
|
|
576
615
|
* FFmpeg needs the declarations, the Clusters its stream search reads from the
|
|
577
616
|
* first one, and the seek table as well as complete clusters.
|
|
@@ -1071,6 +1110,7 @@ function parseTrackEntry(data) {
|
|
|
1071
1110
|
const fields = {
|
|
1072
1111
|
refused: false,
|
|
1073
1112
|
trackNumber: null,
|
|
1113
|
+
trackUid: "",
|
|
1074
1114
|
type: null,
|
|
1075
1115
|
codecId: "",
|
|
1076
1116
|
language: null,
|
|
@@ -1102,6 +1142,7 @@ function parseTrackEntry(data) {
|
|
|
1102
1142
|
case 0x56aa: fields.codecDelaySeconds = readUint(data, f.dataOffset, f.size) / 1e9; break;
|
|
1103
1143
|
case 0x23314f: fields.timestampScale = readFloat(data, f.dataOffset, f.size); break;
|
|
1104
1144
|
case ID_TRACK_NUMBER: fields.trackNumber = readUint(data, f.dataOffset, f.size); break;
|
|
1145
|
+
case ID_TRACK_UID: fields.trackUid = uidKeyOf(data, f); break;
|
|
1105
1146
|
case ID_TRACK_TYPE: fields.type = readUint(data, f.dataOffset, f.size); break;
|
|
1106
1147
|
case ID_CODEC_ID: fields.codecId = readString(data, f); break;
|
|
1107
1148
|
case ID_CODEC_PRIVATE: fields.codecPrivateB64 = data.toString("base64", f.dataOffset, f.dataOffset + f.size); break;
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* - elng: extendedLanguage BCP47 (when present, replaces mdhd language per spec)
|
|
9
9
|
* - stsd: sample entry format (avc1/hev1/mp4a/tx3g/wvtt/stpp)
|
|
10
10
|
* - stbl tables for subtitle cue ranges (stts/stsz/stsc/stco/co64) and video keyframes (stss/stts/ctts/elst)
|
|
11
|
+
* - stsz + mdhd: each track's average rate, from the sizes of its own samples
|
|
11
12
|
*
|
|
12
13
|
* Keyframe reading is delegated to mp4.js. The subtitle sample table is read in
|
|
13
14
|
* this module, because every rule in it is a statement of ISO/IEC 14496-12 about
|
|
@@ -757,7 +758,8 @@ export class Mp4Container extends Container {
|
|
|
757
758
|
displayWidth,
|
|
758
759
|
displayHeight,
|
|
759
760
|
fps: configuration?.fps ?? videoSampleRate(moov, mdhd, stbl),
|
|
760
|
-
bitDepth: configuration?.bitDepth ?? null
|
|
761
|
+
bitDepth: configuration?.bitDepth ?? null,
|
|
762
|
+
bitrateKbps: trackBitrateKbps(moov, mdhd, stbl)
|
|
761
763
|
});
|
|
762
764
|
if (configuration) {
|
|
763
765
|
track.codecConfiguration = configuration;
|
|
@@ -811,6 +813,7 @@ export class Mp4Container extends Container {
|
|
|
811
813
|
channels: sampleEntry?.channels ?? null,
|
|
812
814
|
samplingFrequency: sampleEntry?.sampleRate ?? null,
|
|
813
815
|
bitDepth: sampleEntry?.bitDepth ?? null,
|
|
816
|
+
bitrateKbps: trackBitrateKbps(moov, mdhd, stbl),
|
|
814
817
|
codecDelaySeconds: sampleEntry?.codecDelaySeconds ?? 0,
|
|
815
818
|
seekPrerollSeconds: sampleEntry?.seekPrerollSeconds ?? 0
|
|
816
819
|
}));
|
|
@@ -854,6 +857,42 @@ export class Mp4Container extends Container {
|
|
|
854
857
|
}
|
|
855
858
|
|
|
856
859
|
/** Average decoded-frame cadence from the declared sample timing table. */
|
|
860
|
+
/**
|
|
861
|
+
* The average rate of one track, from its own sample table: every sample's
|
|
862
|
+
* size (`stsz`, ISO/IEC 14496-12 §8.7.3) added up, over the track's duration
|
|
863
|
+
* in its media timescale (`mdhd`, §8.4.2). These are the bytes the track
|
|
864
|
+
* carries, counted, not a figure a muxer wrote down.
|
|
865
|
+
*
|
|
866
|
+
* Null where the table lists no samples — a fragmented file keeps them in its
|
|
867
|
+
* fragments — the duration is zero, or either box is too short for what it
|
|
868
|
+
* declares. A rate is one figure among the track's facts; a damaged table
|
|
869
|
+
* costs that figure and not the track.
|
|
870
|
+
*
|
|
871
|
+
* @param {Buffer} bytes
|
|
872
|
+
* @param {{ dataOffset: number, end: number } | null} mdhd
|
|
873
|
+
* @param {{ dataOffset: number, end: number } | null} stbl
|
|
874
|
+
* @returns {number | null} kbit/s
|
|
875
|
+
*/
|
|
876
|
+
function trackBitrateKbps(bytes, mdhd, stbl) {
|
|
877
|
+
const stsz = mdhd && stbl && childOf(bytes, stbl.dataOffset, stbl.end, "stsz");
|
|
878
|
+
if (!stsz) return null;
|
|
879
|
+
const version = bytes[mdhd.dataOffset];
|
|
880
|
+
const scaleAt = mdhd.dataOffset + (version === 1 ? 20 : 12);
|
|
881
|
+
if (scaleAt + (version === 1 ? 12 : 8) > mdhd.end) return null;
|
|
882
|
+
const scale = bytes.readUInt32BE(scaleAt);
|
|
883
|
+
const duration = version === 1 ? Number(bytes.readBigUInt64BE(scaleAt + 4)) : bytes.readUInt32BE(scaleAt + 4);
|
|
884
|
+
if (stsz.dataOffset + 12 > stsz.end) return null;
|
|
885
|
+
const uniform = bytes.readUInt32BE(stsz.dataOffset + 4);
|
|
886
|
+
const count = bytes.readUInt32BE(stsz.dataOffset + 8);
|
|
887
|
+
if (!uniform && stsz.dataOffset + 12 + count * 4 > stsz.end) return null;
|
|
888
|
+
let total = uniform * count;
|
|
889
|
+
if (!uniform) {
|
|
890
|
+
for (let at = stsz.dataOffset + 12, end = at + count * 4; at < end; at += 4) total += bytes.readUInt32BE(at);
|
|
891
|
+
}
|
|
892
|
+
const seconds = scale > 0 && Number.isSafeInteger(duration) ? duration / scale : 0;
|
|
893
|
+
return total > 0 && seconds > 0 ? total * 8 / seconds / 1000 : null;
|
|
894
|
+
}
|
|
895
|
+
|
|
857
896
|
function videoSampleRate(bytes, mdhd, stbl) {
|
|
858
897
|
if (!mdhd || !stbl) return null;
|
|
859
898
|
const scaleAt = mdhd.dataOffset + (bytes[mdhd.dataOffset] === 1 ? 20 : 12);
|
|
@@ -25,7 +25,8 @@ export const ID_INFO_TITLE = 0x7ba9;
|
|
|
25
25
|
const ID_TAG = 0x7373;
|
|
26
26
|
const ID_TARGETS = 0x63c0;
|
|
27
27
|
const ID_TARGET_TYPE_VALUE = 0x68ca;
|
|
28
|
-
const
|
|
28
|
+
const ID_TAG_TRACK_UID = 0x63c5;
|
|
29
|
+
const TARGET_UIDS = new Set([ID_TAG_TRACK_UID, 0x63c9, 0x63c4, 0x63c6]);
|
|
29
30
|
const ID_SIMPLE_TAG = 0x67c8;
|
|
30
31
|
const ID_TAG_NAME = 0x45a3;
|
|
31
32
|
const ID_TAG_STRING = 0x4487;
|
|
@@ -102,6 +103,52 @@ export function tagsByLevel(data) {
|
|
|
102
103
|
return levels;
|
|
103
104
|
}
|
|
104
105
|
|
|
106
|
+
/**
|
|
107
|
+
* A Matroska UID as a key: its bytes in hexadecimal, leading zeros dropped.
|
|
108
|
+
* A UID is any 64-bit value, which a JavaScript number cannot hold exactly.
|
|
109
|
+
*
|
|
110
|
+
* @param {Buffer} buffer
|
|
111
|
+
* @param {{ dataOffset: number, size: number }} element
|
|
112
|
+
* @returns {string}
|
|
113
|
+
*/
|
|
114
|
+
export function uidKeyOf(buffer, element) {
|
|
115
|
+
return buffer.toString("hex", element.dataOffset, element.dataOffset + element.size).replace(/^0+/u, "");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The average rate each track's statistics state, keyed by its `TrackUID`.
|
|
120
|
+
*
|
|
121
|
+
* mkvmerge writes, per track, a `Tag` whose `Targets` name that track by
|
|
122
|
+
* `TagTrackUID` and whose `SimpleTag`s carry `BPS` — the bits the track holds
|
|
123
|
+
* over its duration — beside `NUMBER_OF_BYTES` and `DURATION`. Those are the
|
|
124
|
+
* tags read here; a track the file gives no `BPS` is left out.
|
|
125
|
+
*
|
|
126
|
+
* @param {Buffer} data - The data of a `Tags` element.
|
|
127
|
+
* @returns {Map<string, number>} kbit/s by {@link uidKeyOf} of the track.
|
|
128
|
+
*/
|
|
129
|
+
export function trackRatesFromTags(data) {
|
|
130
|
+
/** @type {Map<string, number>} */
|
|
131
|
+
const rates = new Map();
|
|
132
|
+
for (const tag of iterateElements(data)) {
|
|
133
|
+
if (tag.id !== ID_TAG) continue;
|
|
134
|
+
const end = Math.min(data.length, tag.dataOffset + tag.size);
|
|
135
|
+
const tracks = [];
|
|
136
|
+
for (const element of iterateElements(data, tag.dataOffset, end)) {
|
|
137
|
+
if (element.id !== ID_TARGETS) continue;
|
|
138
|
+
for (const field of iterateElements(data, element.dataOffset, Math.min(end, element.dataOffset + element.size))) {
|
|
139
|
+
if (field.id === ID_TAG_TRACK_UID) tracks.push(uidKeyOf(data, field));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (tracks.length === 0) continue;
|
|
143
|
+
const found = [];
|
|
144
|
+
simpleTags(data, tag.dataOffset, end, found);
|
|
145
|
+
const bps = Number(found.find((simple) => simple.name === "BPS")?.value);
|
|
146
|
+
if (!(Number.isFinite(bps) && bps > 0)) continue;
|
|
147
|
+
for (const uid of tracks) if (uid.length > 0) rates.set(uid, bps / 1000);
|
|
148
|
+
}
|
|
149
|
+
return rates;
|
|
150
|
+
}
|
|
151
|
+
|
|
105
152
|
/**
|
|
106
153
|
* The values one level states for one name, the default one first.
|
|
107
154
|
*
|
|
@@ -57,6 +57,8 @@ export function playbackDeclarations({ tracks, media, fileBytes = null }) {
|
|
|
57
57
|
startTimeSeconds: media.startTimeSeconds,
|
|
58
58
|
videoWidth: video?.width ?? 0, videoHeight: video?.height ?? 0,
|
|
59
59
|
fps: video?.fps ?? null, isHdr: video?.isHdr ?? false, bitDepth: video?.bitDepth ?? null,
|
|
60
|
+
// The picture's own average, where the container states it per track.
|
|
61
|
+
videoBitrateKbps: video?.bitrateKbps ?? null,
|
|
60
62
|
// The measured whole-file average prices decoding; it never addresses packets.
|
|
61
63
|
bitrateKbps: Number.isFinite(media.bitrateKbps) ? media.bitrateKbps
|
|
62
64
|
: Number.isSafeInteger(fileBytes) && fileBytes > 0 && media.durationSeconds > 0
|
|
@@ -716,6 +716,7 @@ export function createPlaybackPlanner({
|
|
|
716
716
|
height: videoFacts.height,
|
|
717
717
|
fps: videoFacts.fps,
|
|
718
718
|
bitrateKbps: probe.bitrateKbps,
|
|
719
|
+
videoBitrateKbps: probe.videoBitrateKbps,
|
|
719
720
|
audioTracks: probe.audioTracks,
|
|
720
721
|
// Which family of the decode measurement prices this source. A video
|
|
721
722
|
// that has to be re-encoded is one the browser could not play, so it
|
|
@@ -755,6 +756,7 @@ export function createPlaybackPlanner({
|
|
|
755
756
|
width: videoFacts.width,
|
|
756
757
|
height: videoFacts.height,
|
|
757
758
|
bitrateKbps: probe.bitrateKbps,
|
|
759
|
+
videoBitrateKbps: probe.videoBitrateKbps,
|
|
758
760
|
audioTracks: probe.audioTracks,
|
|
759
761
|
fps: videoFacts.fps,
|
|
760
762
|
startTime: probe.startTimeSeconds,
|
|
@@ -19,6 +19,8 @@ export class VideoTrack extends ContainerTrack {
|
|
|
19
19
|
* @param {number | null} params.fps - From container when available (otherwise from ffmpeg banner elsewhere).
|
|
20
20
|
* @param {boolean} params.isHdr - From container colr / ffmpeg detection later.
|
|
21
21
|
* @param {number | null} params.bitDepth
|
|
22
|
+
* @param {number | null} [params.bitrateKbps] - The rate the file states for
|
|
23
|
+
* this track, in kbit/s: an average, not a bound.
|
|
22
24
|
*/
|
|
23
25
|
constructor(params) {
|
|
24
26
|
super({ ...params, type: "video" });
|
|
@@ -29,5 +31,6 @@ export class VideoTrack extends ContainerTrack {
|
|
|
29
31
|
this.fps = Number.isFinite(params.fps) ? params.fps : null;
|
|
30
32
|
this.isHdr = params.isHdr === true;
|
|
31
33
|
this.bitDepth = Number.isFinite(params.bitDepth) ? params.bitDepth : null;
|
|
34
|
+
this.bitrateKbps = Number.isFinite(params.bitrateKbps) && params.bitrateKbps > 0 ? params.bitrateKbps : null;
|
|
32
35
|
}
|
|
33
36
|
}
|
|
@@ -222,6 +222,10 @@ export function wireOutputs({
|
|
|
222
222
|
encodeInputs = null,
|
|
223
223
|
indexMemory = null,
|
|
224
224
|
budgetPolicy = null,
|
|
225
|
+
// What viewers' connections carry beyond the film, per byte of film, as
|
|
226
|
+
// the transport measured it (`transport/delivery-shares.js`); null while
|
|
227
|
+
// nothing has been delivered.
|
|
228
|
+
serviceShare = () => null,
|
|
225
229
|
onViewerChanged = null}) {
|
|
226
230
|
const parts = {};
|
|
227
231
|
const audioDescriptionOf = (output) => (getCachedAudioTracks?.({
|
|
@@ -363,6 +367,7 @@ export function wireOutputs({
|
|
|
363
367
|
// re-encoded where it may be, and copied where it may not.
|
|
364
368
|
get transcodeEnabled() { return parts.enabled; },
|
|
365
369
|
observedPeakMbps: (spec) => parts.localObservations?.peakMbps(spec) ?? null,
|
|
370
|
+
serviceShare,
|
|
366
371
|
// A viewer has been moved onto an output prepared for them: how long it
|
|
367
372
|
// took, and what they held.
|
|
368
373
|
notePreparation: (output, seconds, bufferedSec) =>
|
|
@@ -519,6 +524,7 @@ export function wireOutputs({
|
|
|
519
524
|
// The largest peak an output of this mode and rate control has been seen
|
|
520
525
|
// carrying here: the only figure an encoder with no bound of its own has.
|
|
521
526
|
observedPeakMbps: (spec) => parts.localObservations?.peakMbps(spec) ?? null,
|
|
527
|
+
serviceShare,
|
|
522
528
|
limitsFor: (frame, file) => parts.limitsFor(frame, file),
|
|
523
529
|
readSourceMedia,
|
|
524
530
|
get encodeInputs() { return parts.encodeInputs; },
|
|
@@ -1000,6 +1006,7 @@ export function wireOutputs({
|
|
|
1000
1006
|
// otherwise.
|
|
1001
1007
|
// The quality budget: what each output is asked to step to, and the bitrate ceiling a viewer's link sets. Everything it reads of the rest of the proxy is listed here.
|
|
1002
1008
|
parts.quality = new QualityController({
|
|
1009
|
+
serviceShare,
|
|
1003
1010
|
// The longest an output of this mode has been seen taking to be ready for
|
|
1004
1011
|
// a viewer moving onto it, in milliseconds, or null.
|
|
1005
1012
|
observedPreparationMs: (output) => {
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
/** @import { DataChannel } from 'node-datachannel' */
|
|
48
48
|
|
|
49
49
|
import { createDeliveryProbe, PROBE_INTERVAL_MS } from "./delivery-probe.js";
|
|
50
|
+
import { DeliveryShares } from "./delivery-shares.js";
|
|
50
51
|
import { Agent } from "undici";
|
|
51
52
|
|
|
52
53
|
// Route availability and caller cancellation determine media request lifetime.
|
|
@@ -845,6 +846,7 @@ export function createDataChannelHandler({
|
|
|
845
846
|
}
|
|
846
847
|
try {
|
|
847
848
|
channel.sendMessage(message);
|
|
849
|
+
deliveryShares.recordSent(Buffer.byteLength(message));
|
|
848
850
|
sent += 1;
|
|
849
851
|
} catch {
|
|
850
852
|
// Closed between the lookup and the send. The subscription belongs to
|
|
@@ -862,6 +864,9 @@ export function createDataChannelHandler({
|
|
|
862
864
|
/** Channel-scoped requests cannot be cancelled by a different connection. */
|
|
863
865
|
const activeRequests = new WeakMap();
|
|
864
866
|
|
|
867
|
+
// How much of what the connections carry is not the film
|
|
868
|
+
// (`delivery-shares.js`), counted where both halves are seen.
|
|
869
|
+
const deliveryShares = new DeliveryShares();
|
|
865
870
|
const { watchSendQueue, readDelivery, noteBrowserReport } = makeSendQueueWatcher({
|
|
866
871
|
log: (message) => log(message),
|
|
867
872
|
getTransportSnapshot,
|
|
@@ -887,7 +892,8 @@ export function createDataChannelHandler({
|
|
|
887
892
|
readDelivery,
|
|
888
893
|
getTransportSnapshot,
|
|
889
894
|
witness,
|
|
890
|
-
usrsctpState
|
|
895
|
+
usrsctpState,
|
|
896
|
+
onSent: (bytes) => deliveryShares.recordSent(bytes)
|
|
891
897
|
});
|
|
892
898
|
|
|
893
899
|
/**
|
|
@@ -1235,6 +1241,7 @@ export function createDataChannelHandler({
|
|
|
1235
1241
|
if (done) {
|
|
1236
1242
|
chunks += body.flush();
|
|
1237
1243
|
sendChunk(channel, requestId, null, true);
|
|
1244
|
+
deliveryShares.recordBody(path, totalBytes);
|
|
1238
1245
|
const elapsedMs = Date.now() - sendStartedAt;
|
|
1239
1246
|
let bufferedNow = 0;
|
|
1240
1247
|
try { bufferedNow = typeof channel.bufferedAmount === "function" ? channel.bufferedAmount() : 0; } catch { /* ignore */ }
|
|
@@ -1319,7 +1326,9 @@ export function createDataChannelHandler({
|
|
|
1319
1326
|
|
|
1320
1327
|
function sendChunk(channel, requestId, bytes, done) {
|
|
1321
1328
|
try {
|
|
1322
|
-
|
|
1329
|
+
const frame = encodeFrame(requestIdBytes(requestId), bytes, done);
|
|
1330
|
+
channel.sendMessageBinary(frame);
|
|
1331
|
+
deliveryShares.recordSent(frame.length);
|
|
1323
1332
|
} catch {
|
|
1324
1333
|
// Channel closed between check and send — safe to ignore.
|
|
1325
1334
|
}
|
|
@@ -1392,13 +1401,20 @@ export function createDataChannelHandler({
|
|
|
1392
1401
|
*/
|
|
1393
1402
|
function send(channel, message) {
|
|
1394
1403
|
try {
|
|
1395
|
-
|
|
1404
|
+
const text = JSON.stringify(message);
|
|
1405
|
+
channel.sendMessage(text);
|
|
1406
|
+
deliveryShares.recordSent(Buffer.byteLength(text));
|
|
1396
1407
|
} catch {
|
|
1397
1408
|
// Channel closed between check and send — safe to ignore.
|
|
1398
1409
|
}
|
|
1399
1410
|
}
|
|
1400
1411
|
|
|
1401
|
-
return {
|
|
1412
|
+
return {
|
|
1413
|
+
handleChannel,
|
|
1414
|
+
publishSubtitleCues,
|
|
1415
|
+
/** What the film costs viewers' links beyond itself, per byte of film, or null. */
|
|
1416
|
+
serviceShare: () => deliveryShares.serviceShare()
|
|
1417
|
+
};
|
|
1402
1418
|
}
|
|
1403
1419
|
|
|
1404
1420
|
/**
|
|
@@ -425,7 +425,10 @@ export function createDeliveryProbe({
|
|
|
425
425
|
readDelivery,
|
|
426
426
|
getTransportSnapshot,
|
|
427
427
|
witness,
|
|
428
|
-
usrsctpState
|
|
428
|
+
usrsctpState,
|
|
429
|
+
// Every probe handed to a channel, in bytes: what it costs the viewer's link
|
|
430
|
+
// is counted with everything else the connection carries.
|
|
431
|
+
onSent = () => {}
|
|
429
432
|
}) {
|
|
430
433
|
/** @type {Map<string, ProbeConnection>} */
|
|
431
434
|
const connections = new Map();
|
|
@@ -456,6 +459,7 @@ export function createDeliveryProbe({
|
|
|
456
459
|
for (const channel of connection.channels.keys()) {
|
|
457
460
|
try {
|
|
458
461
|
channel.sendMessage(message);
|
|
462
|
+
onSent(Buffer.byteLength(message));
|
|
459
463
|
} catch {
|
|
460
464
|
// A channel closing between the check and the send is ordinary.
|
|
461
465
|
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file How much of what this proxy hands to its viewers' connections is not
|
|
3
|
+
* the film.
|
|
4
|
+
*
|
|
5
|
+
* A viewer's link carries the picture and the soundtrack AND everything else
|
|
6
|
+
* this proxy sends down the same connection: the framing of every data-channel
|
|
7
|
+
* message, playlists, answers to progress polls, delivery probes, pushed
|
|
8
|
+
* subtitle cues, segments sent and then abandoned. A load that counts only the
|
|
9
|
+
* picture and the sound asks the link for less than it will be given
|
|
10
|
+
* (torrent-tv/meta#169).
|
|
11
|
+
*
|
|
12
|
+
* What is counted, all of it in bytes handed to a data channel, which is the
|
|
13
|
+
* level the browser's own link measurement is taken at — application bytes,
|
|
14
|
+
* without SCTP, DTLS, UDP or IP framing — and all of it at the moment it is
|
|
15
|
+
* sent, so the three counts always describe the same messages:
|
|
16
|
+
*
|
|
17
|
+
* 1. `sent` — every message handed to a channel, frame headers included;
|
|
18
|
+
* 2. `media` — the bodies of segment and init responses delivered whole;
|
|
19
|
+
* 3. `measurement` — the bodies of the link measurement and the delivery sink,
|
|
20
|
+
* which are sent when a connection is made or on request and are not part
|
|
21
|
+
* of watching a film.
|
|
22
|
+
*
|
|
23
|
+
* The share is `(sent - media - measurement) / media`: what the film costs
|
|
24
|
+
* the link beyond itself, per byte of film. Counted over the life of the
|
|
25
|
+
* process and across every connection, because a viewer about to open a film
|
|
26
|
+
* has no history of their own and the traffic around a film is the proxy's,
|
|
27
|
+
* not the person's.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** Which kind of response a request path asks for. */
|
|
31
|
+
export const DELIVERY_KIND = Object.freeze({
|
|
32
|
+
MEDIA: "media",
|
|
33
|
+
MEASUREMENT: "measurement",
|
|
34
|
+
SERVICE: "service"
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const MEDIA_FILE = /\/(?:segment-\d+\.[a-z0-9]+|init\.mp4)$/u;
|
|
38
|
+
const MEASUREMENT_ROUTE = /^\/api\/(?:link-probe|delivery-sink)$/u;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* What a data-channel request path asks for.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} path - The request path, query included or not.
|
|
44
|
+
* @returns {string} One of {@link DELIVERY_KIND}.
|
|
45
|
+
*/
|
|
46
|
+
export function deliveryKindOf(path) {
|
|
47
|
+
const bare = String(path ?? "").split("?")[0];
|
|
48
|
+
if (MEDIA_FILE.test(bare)) return DELIVERY_KIND.MEDIA;
|
|
49
|
+
if (MEASUREMENT_ROUTE.test(bare)) return DELIVERY_KIND.MEASUREMENT;
|
|
50
|
+
return DELIVERY_KIND.SERVICE;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class DeliveryShares {
|
|
54
|
+
#sent = 0;
|
|
55
|
+
#media = 0;
|
|
56
|
+
#measurement = 0;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A response body handed over whole.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} path
|
|
62
|
+
* @param {number} bytes
|
|
63
|
+
*/
|
|
64
|
+
recordBody(path, bytes) {
|
|
65
|
+
if (!(Number.isFinite(bytes) && bytes > 0)) return;
|
|
66
|
+
const kind = deliveryKindOf(path);
|
|
67
|
+
if (kind === DELIVERY_KIND.MEDIA) this.#media += bytes;
|
|
68
|
+
else if (kind === DELIVERY_KIND.MEASUREMENT) this.#measurement += bytes;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* One message handed to a channel, whatever it carries.
|
|
73
|
+
*
|
|
74
|
+
* @param {number} bytes
|
|
75
|
+
*/
|
|
76
|
+
recordSent(bytes) {
|
|
77
|
+
if (Number.isFinite(bytes) && bytes > 0) this.#sent += bytes;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* What the film costs the link beyond itself, per byte of film, or null
|
|
82
|
+
* while no film has been delivered.
|
|
83
|
+
*
|
|
84
|
+
* Never below zero: a body is counted once its last message has gone, so
|
|
85
|
+
* the two counts agree exactly between responses and the body's own
|
|
86
|
+
* messages are already in `sent` when it is counted.
|
|
87
|
+
*
|
|
88
|
+
* @returns {number | null}
|
|
89
|
+
*/
|
|
90
|
+
serviceShare() {
|
|
91
|
+
if (this.#media <= 0) return null;
|
|
92
|
+
return Math.max(0, (this.#sent - this.#media - this.#measurement) / this.#media);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The counts behind the share, for a log line.
|
|
97
|
+
*
|
|
98
|
+
* @returns {{ sentBytes: number, mediaBytes: number, measurementBytes: number, serviceShare: number | null }}
|
|
99
|
+
*/
|
|
100
|
+
figures() {
|
|
101
|
+
return {
|
|
102
|
+
sentBytes: this.#sent,
|
|
103
|
+
mediaBytes: this.#media,
|
|
104
|
+
measurementBytes: this.#measurement,
|
|
105
|
+
serviceShare: this.serviceShare()
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
}
|