@torrent-tv/proxy 2.87.14 → 2.87.16
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/CHANGELOG.md +13 -0
- package/docs/browser-proxy-contract.md +9 -0
- package/package.json +1 -1
- package/routes/api/transcode-sessions/net-report/post.js +1 -0
- package/services/encode/EncodeRun.js +43 -6
- package/services/encode/EncodeRuns.js +2 -1
- package/services/encode/segment-formats/fmp4.js +10 -2
- package/services/encode/segment-formats/index.js +1 -0
- package/services/encode/segment-formats/mp4-boxes.js +137 -1
- package/services/server/SegmentServing.js +5 -1
- package/services/server/ViewerRequests.js +26 -6
- package/services/server/playback-readiness.js +139 -143
- package/services/storage/piece-store/piece-disk-store.js +23 -0
- package/services/storage/piece-store/shared-piece-store.js +7 -2
- package/services/storage/segment-store/SegmentStore.js +36 -1
- package/services/viewer/Viewer.js +6 -0
- package/test/encode-orchestrator.test.js +3 -3
- package/test/encode-plan-viewers.test.js +1 -1
- package/test/encode-run.test.js +1 -0
- package/test/fmp4-presentation-clock.test.js +72 -0
- package/test/piece-disk-store.test.js +20 -0
- package/test/piece-store-reservations.test.js +26 -1
- package/test/playback-readiness.test.js +82 -6
- package/test/playback-start-regressions.test.js +99 -0
- package/test/segment-store.test.js +1 -1
|
@@ -6,14 +6,15 @@
|
|
|
6
6
|
* measurements and physical limits: source bytes and download rate, encoded
|
|
7
7
|
* media and encoder rate, segment sizes and client-link rate, the existing
|
|
8
8
|
* browser buffer, its capacity, the proxy look-ahead, and the measured reserve
|
|
9
|
-
* for interruptions.
|
|
10
|
-
*
|
|
9
|
+
* for interruptions. A measured rate relaxes toward the lower of its latest
|
|
10
|
+
* value and its observed mean over the measurement span. Its integral remains
|
|
11
|
+
* unbounded for a positive measured rate; an old slope cannot invent a permanent
|
|
12
|
+
* cessation of service. There are no fitted weights or rate multipliers.
|
|
11
13
|
*/
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
|
-
* The mean rate predicted over a horizon by the
|
|
15
|
-
*
|
|
16
|
-
* every measurement equally.
|
|
16
|
+
* The mean rate predicted over a horizon by the integral of the measured rate.
|
|
17
|
+
* Every supplied measurement contributes equally to the observed mean.
|
|
17
18
|
*
|
|
18
19
|
* @param {Array<{ at: number, value: number }>} readings
|
|
19
20
|
* @param {number} now
|
|
@@ -30,48 +31,36 @@ export function forecastRate(readings, now = Date.now(), horizonSeconds = 0) {
|
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
34
|
+
* Measurements over the output's existing look-ahead horizon. The oldest
|
|
35
|
+
* boundary sample is retained until another sample replaces it, so a repeated
|
|
36
|
+
* report never changes the history or invents a new measurement.
|
|
36
37
|
*/
|
|
37
38
|
export class RateTrend {
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
#sumTimeValue = 0;
|
|
39
|
+
#readings = [];
|
|
40
|
+
#horizonMs;
|
|
41
|
+
|
|
42
|
+
constructor(horizonSeconds = Number.POSITIVE_INFINITY) {
|
|
43
|
+
this.#horizonMs = Math.max(0, horizonSeconds) * 1000;
|
|
44
|
+
}
|
|
45
45
|
|
|
46
46
|
add(at, value) {
|
|
47
47
|
if (!Number.isFinite(at) || !Number.isFinite(value) || value < 0 ||
|
|
48
|
-
|
|
48
|
+
at <= (this.#readings.at(-1)?.at ?? Number.NEGATIVE_INFINITY)) {
|
|
49
49
|
return false;
|
|
50
50
|
}
|
|
51
|
-
this.#
|
|
52
|
-
const
|
|
53
|
-
this.#
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
this.#sumValue += value;
|
|
57
|
-
this.#sumTimeValue += elapsed * value;
|
|
58
|
-
this.#lastAt = at;
|
|
51
|
+
this.#readings.push({ at, value });
|
|
52
|
+
const cutoff = at - this.#horizonMs;
|
|
53
|
+
while (this.#readings.length > 1 && this.#readings[1].at <= cutoff) {
|
|
54
|
+
this.#readings.shift();
|
|
55
|
+
}
|
|
59
56
|
return true;
|
|
60
57
|
}
|
|
61
58
|
|
|
62
59
|
snapshot() {
|
|
63
|
-
if (this.#
|
|
60
|
+
if (this.#readings.length === 0) {
|
|
64
61
|
return null;
|
|
65
62
|
}
|
|
66
|
-
return
|
|
67
|
-
count: this.#count,
|
|
68
|
-
originAt: this.#originAt,
|
|
69
|
-
lastAt: this.#lastAt,
|
|
70
|
-
sumTime: this.#sumTime,
|
|
71
|
-
sumTimeSquared: this.#sumTimeSquared,
|
|
72
|
-
sumValue: this.#sumValue,
|
|
73
|
-
sumTimeValue: this.#sumTimeValue
|
|
74
|
-
};
|
|
63
|
+
return summarizeRate(this.#readings);
|
|
75
64
|
}
|
|
76
65
|
}
|
|
77
66
|
|
|
@@ -96,14 +85,24 @@ export class RateTrend {
|
|
|
96
85
|
*/
|
|
97
86
|
export function predictPlaybackReadiness(input = {}) {
|
|
98
87
|
const now = Number.isFinite(input.now) ? input.now : Date.now();
|
|
99
|
-
const
|
|
88
|
+
const tracks = Array.isArray(input.tracks) ? input.tracks : [];
|
|
89
|
+
const mediaOrigin = Math.max(0, ...tracks.map((track) =>
|
|
90
|
+
track.segments?.find(({ index }) => index === 0)?.mediaRanges?.[0]?.start ?? 0));
|
|
91
|
+
const position = Math.max(mediaOrigin, finiteNonNegative(input.positionSeconds));
|
|
100
92
|
const duration = finiteNonNegative(input.durationSeconds);
|
|
101
93
|
const remaining = Math.max(0, duration - position);
|
|
102
|
-
const
|
|
103
|
-
|
|
94
|
+
const measuredRanges = tracks.map((track) => Array.isArray(track.clientRanges) ?
|
|
95
|
+
normalizedRanges(track.clientRanges) : null);
|
|
96
|
+
const buffered = measuredRanges.length > 0 && measuredRanges.every(Boolean) ?
|
|
97
|
+
Math.max(0, Math.min(...measuredRanges.map((ranges) => contiguousEnd(ranges, position) - position))) :
|
|
98
|
+
finiteNonNegative(input.bufferedAheadSeconds);
|
|
99
|
+
// A loader target can be lowered after a fragment retry. Bytes already held
|
|
100
|
+
// prove a larger capacity, even when an older page reports only that target.
|
|
101
|
+
const capacity = Math.max(buffered, finiteNonNegative(input.bufferLimitSeconds),
|
|
102
|
+
...measuredRanges.map((ranges) => ranges ? ranges.reduce((total, range) =>
|
|
103
|
+
total + Math.max(0, range.end - Math.max(position, range.start)), 0) : 0));
|
|
104
104
|
const reserve = Math.min(remaining, finiteNonNegative(input.reserveSeconds));
|
|
105
105
|
const lookahead = finiteNonNegative(input.lookaheadSeconds);
|
|
106
|
-
const tracks = Array.isArray(input.tracks) ? input.tracks : [];
|
|
107
106
|
const sources = new Map((Array.isArray(input.sources) ? input.sources : [])
|
|
108
107
|
.filter((source) => typeof source?.id === "string")
|
|
109
108
|
.map((source) => [source.id, source]));
|
|
@@ -123,6 +122,8 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
123
122
|
|
|
124
123
|
const trackState = tracks.map((track) => ({
|
|
125
124
|
track,
|
|
125
|
+
clientRanges: Array.isArray(track.clientRanges) ? normalizedRanges(track.clientRanges) :
|
|
126
|
+
[{ start: position, end: position + buffered }],
|
|
126
127
|
ready: new Set(Array.isArray(track.readySegmentIndices) ? track.readySegmentIndices : []),
|
|
127
128
|
curve: rateCurve(track.readings, now),
|
|
128
129
|
bitsPerMediaSecond: averageBitsPerMediaSecond(track, track.segments,
|
|
@@ -166,11 +167,13 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
166
167
|
const { track, segments } = state;
|
|
167
168
|
const measuredBitsPerMediaSecond = state.bitsPerMediaSecond;
|
|
168
169
|
for (const segment of segments) {
|
|
169
|
-
if (
|
|
170
|
+
if (coveredSeconds(state.clientRanges, Math.max(position, segment.startSeconds), segment.endSeconds) >=
|
|
171
|
+
segment.endSeconds - Math.max(position, segment.startSeconds) - Number.EPSILON * duration) {
|
|
170
172
|
continue;
|
|
171
173
|
}
|
|
172
174
|
const exactBytes = sizeOf(track.segmentSizesBytes, segment.index);
|
|
173
175
|
const segmentSeconds = segment.endSeconds - segment.startSeconds;
|
|
176
|
+
// HLS fetches the complete file when any required part is absent.
|
|
174
177
|
const sizeBits = exactBytes > 0
|
|
175
178
|
? exactBytes * 8
|
|
176
179
|
: measuredBitsPerMediaSecond * segmentSeconds;
|
|
@@ -179,9 +182,9 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
179
182
|
}
|
|
180
183
|
|
|
181
184
|
const produced = state.ready.has(segment.index);
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
+
// Global encode progress may belong to another run beyond a missing
|
|
186
|
+
// segment. Only publication proves this segment's work is complete.
|
|
187
|
+
const workStart = Math.max(position, segment.startSeconds);
|
|
185
188
|
const encodeWork = produced ? 0 : Math.max(0, segment.endSeconds - workStart);
|
|
186
189
|
if (!produced && !(encodeWork > 0)) {
|
|
187
190
|
unknownReason ??= "segment-production-delay-unavailable";
|
|
@@ -237,32 +240,6 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
237
240
|
}
|
|
238
241
|
}
|
|
239
242
|
|
|
240
|
-
const rateRecoveryDelays = [];
|
|
241
|
-
const sourceDemandByService = new Map();
|
|
242
|
-
for (const sourceId of new Set(jobs.flatMap(({ sourceIntervals }) => [...sourceIntervals.keys()]))) {
|
|
243
|
-
const source = sourceState.get(sourceId);
|
|
244
|
-
const demand = sourceDemandByService.get(source.serviceId) ?? new Map();
|
|
245
|
-
demand.set(sourceId, Number(source.source.bytesPerMediaSecond));
|
|
246
|
-
sourceDemandByService.set(source.serviceId, demand);
|
|
247
|
-
}
|
|
248
|
-
for (const [serviceId, demandBySource] of sourceDemandByService) {
|
|
249
|
-
const demand = [...demandBySource.values()].reduce((sum, value) => sum + value, 0);
|
|
250
|
-
rateRecoveryDelays.push(downloadServices.get(serviceId).curve.timeAtLeast(demand));
|
|
251
|
-
}
|
|
252
|
-
for (const { track, curve } of trackState) {
|
|
253
|
-
if (jobs.some((job) => job.track === track && !job.produced)) {
|
|
254
|
-
rateRecoveryDelays.push(curve.timeAtLeast(1));
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
const linkDemand = trackState.reduce((sum, { track, bitsPerMediaSecond }) =>
|
|
258
|
-
sum + (jobs.some((job) => job.track === track) ? bitsPerMediaSecond : 0), 0);
|
|
259
|
-
if (linkDemand > 0) {
|
|
260
|
-
rateRecoveryDelays.push(link.timeAtLeast(linkDemand));
|
|
261
|
-
}
|
|
262
|
-
const rateRecoveryDelay = rateRecoveryDelays
|
|
263
|
-
.filter(Number.isFinite)
|
|
264
|
-
.reduce((latest, delay) => Math.max(latest, delay), 0);
|
|
265
|
-
|
|
266
243
|
jobs.sort((left, right) => left.segment.startSeconds - right.segment.startSeconds ||
|
|
267
244
|
left.segment.endSeconds - right.segment.endSeconds || left.trackIndex - right.trackIndex);
|
|
268
245
|
|
|
@@ -353,6 +330,7 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
353
330
|
return { safe: safety.safe,
|
|
354
331
|
bufferedAtStart: safety.bufferedAtStart,
|
|
355
332
|
neededSeconds: safety.neededSeconds,
|
|
333
|
+
completionTimes: usefulCompletions.map(({ at }) => at),
|
|
356
334
|
finishAt: usefulCompletions.reduce((latest, item) => Math.max(latest, item.at), 0) };
|
|
357
335
|
};
|
|
358
336
|
|
|
@@ -366,7 +344,7 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
366
344
|
if (immediate.safe) {
|
|
367
345
|
return result(true, 0, buffered, reserve, 0, "trajectory-safe-now", preparedSegments);
|
|
368
346
|
}
|
|
369
|
-
const searchLimit =
|
|
347
|
+
const searchLimit = maximumUsefulDelay;
|
|
370
348
|
if (!(searchLimit > 0)) {
|
|
371
349
|
return result(false, null, buffered, reserve, null, "no-safe-start-found", preparedSegments);
|
|
372
350
|
}
|
|
@@ -376,7 +354,7 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
376
354
|
const breakpoints = [...new Set([
|
|
377
355
|
maximumUsefulDelay,
|
|
378
356
|
searchLimit,
|
|
379
|
-
...
|
|
357
|
+
...prefillSchedule.completionTimes
|
|
380
358
|
])]
|
|
381
359
|
.filter((delay) => delay > 0 && delay <= searchLimit)
|
|
382
360
|
.sort((left, right) => left - right);
|
|
@@ -412,17 +390,9 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
412
390
|
|
|
413
391
|
function rateCurve(readings, now) {
|
|
414
392
|
if (readings && Number.isFinite(readings.count) && readings.count > 0 &&
|
|
415
|
-
Number.isFinite(readings.
|
|
416
|
-
Number.isFinite(readings.
|
|
417
|
-
|
|
418
|
-
const { count, originAt, sumTime, sumTimeSquared, sumValue, sumTimeValue } = readings;
|
|
419
|
-
const meanAt = sumTime / count;
|
|
420
|
-
const meanValue = sumValue / count;
|
|
421
|
-
const denominator = sumTimeSquared - count * meanAt ** 2;
|
|
422
|
-
const numerator = sumTimeValue - count * meanAt * meanValue;
|
|
423
|
-
const slope = denominator > 0 ? numerator / denominator : 0;
|
|
424
|
-
const current = Math.max(0, meanValue + slope * ((now - originAt) / 1000 - meanAt));
|
|
425
|
-
return rateCurveFrom(current, slope);
|
|
393
|
+
Number.isFinite(readings.lastAt) && Number.isFinite(readings.lastValue) &&
|
|
394
|
+
Number.isFinite(readings.meanValue) && Number.isFinite(readings.spanSeconds)) {
|
|
395
|
+
return rateCurveFrom(readings, now);
|
|
426
396
|
}
|
|
427
397
|
const ordered = (Array.isArray(readings) ? readings : [])
|
|
428
398
|
.filter((reading) => Number.isFinite(reading?.at) && Number.isFinite(reading?.value) && reading.value >= 0)
|
|
@@ -433,71 +403,70 @@ function rateCurve(readings, now) {
|
|
|
433
403
|
return null;
|
|
434
404
|
}
|
|
435
405
|
|
|
436
|
-
|
|
437
|
-
at: (reading.at - now) / 1000,
|
|
438
|
-
value: reading.value
|
|
439
|
-
}));
|
|
440
|
-
const meanAt = points.reduce((sum, point) => sum + point.at, 0) / points.length;
|
|
441
|
-
const meanValue = points.reduce((sum, point) => sum + point.value, 0) / points.length;
|
|
442
|
-
const numerator = points.reduce((sum, point) =>
|
|
443
|
-
sum + (point.at - meanAt) * (point.value - meanValue), 0);
|
|
444
|
-
const denominator = points.reduce((sum, point) =>
|
|
445
|
-
sum + (point.at - meanAt) ** 2, 0);
|
|
446
|
-
const slope = denominator > 0 ? numerator / denominator : 0;
|
|
447
|
-
const current = Math.max(0, meanValue - slope * meanAt);
|
|
448
|
-
return rateCurveFrom(current, slope);
|
|
406
|
+
return rateCurveFrom(summarizeRate(ordered), now);
|
|
449
407
|
}
|
|
450
408
|
|
|
451
|
-
function
|
|
452
|
-
const
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
if (!(work > 0)) {
|
|
460
|
-
return 0;
|
|
461
|
-
}
|
|
462
|
-
if (!(current > 0) && !(slope > 0)) {
|
|
463
|
-
return Number.POSITIVE_INFINITY;
|
|
464
|
-
}
|
|
465
|
-
const discriminant = current ** 2 + 2 * slope * work;
|
|
466
|
-
if (discriminant < 0) {
|
|
467
|
-
return Number.POSITIVE_INFINITY;
|
|
468
|
-
}
|
|
469
|
-
if (slope < 0 && work > workBy(zeroAt)) {
|
|
470
|
-
return Number.POSITIVE_INFINITY;
|
|
471
|
-
}
|
|
472
|
-
if (slope === 0) {
|
|
473
|
-
return current > 0 ? work / current : Number.POSITIVE_INFINITY;
|
|
474
|
-
}
|
|
475
|
-
const root = Math.sqrt(discriminant);
|
|
476
|
-
const denominator = current + root;
|
|
477
|
-
return denominator > 0 ? (2 * work) / denominator : Number.POSITIVE_INFINITY;
|
|
409
|
+
function summarizeRate(readings) {
|
|
410
|
+
const latest = readings.at(-1);
|
|
411
|
+
return {
|
|
412
|
+
count: readings.length,
|
|
413
|
+
lastAt: latest.at,
|
|
414
|
+
lastValue: latest.value,
|
|
415
|
+
meanValue: readings.reduce((sum, reading) => sum + reading.value, 0) / readings.length,
|
|
416
|
+
spanSeconds: (latest.at - readings[0].at) / 1000
|
|
478
417
|
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function rateCurveFrom({ lastAt, lastValue, meanValue, spanSeconds }, now) {
|
|
421
|
+
const baseline = Math.max(0, Math.min(lastValue, meanValue));
|
|
422
|
+
const span = Math.max(Number.EPSILON, spanSeconds);
|
|
423
|
+
const age = Math.max(0, (now - lastAt) / 1000);
|
|
424
|
+
const excess = Math.max(0, lastValue - baseline) * Math.exp(-age / span);
|
|
425
|
+
// r(t) = baseline + excess * exp(-t/span).
|
|
426
|
+
// W(t) = integral(0..t, r(u)du). Positive measured service has no finite
|
|
427
|
+
// total-work ceiling merely because an old sample was faster.
|
|
428
|
+
const workBy = (seconds) => rateIntegral(Math.max(0, seconds), baseline, excess, span);
|
|
479
429
|
return {
|
|
480
|
-
|
|
481
|
-
if (!Number.isFinite(target) || target < 0) {
|
|
482
|
-
return Number.POSITIVE_INFINITY;
|
|
483
|
-
}
|
|
484
|
-
if (current >= target) {
|
|
485
|
-
return 0;
|
|
486
|
-
}
|
|
487
|
-
return slope > 0 ? (target - current) / slope : Number.POSITIVE_INFINITY;
|
|
488
|
-
},
|
|
489
|
-
rateAt: (seconds) => Math.max(0, current + slope * Math.min(Math.max(0, seconds), zeroAt)),
|
|
430
|
+
rateAt: (seconds) => baseline + excess * Math.exp(-Math.max(0, seconds) / span),
|
|
490
431
|
workBy,
|
|
491
432
|
finish: (work, startAt) => {
|
|
492
|
-
if (!(work > 0)) {
|
|
493
|
-
return Math.max(0, startAt);
|
|
494
|
-
}
|
|
495
433
|
const start = Math.max(0, startAt);
|
|
496
|
-
return start + (
|
|
434
|
+
return start + invertRateIntegral(Math.max(0, work), baseline,
|
|
435
|
+
excess * Math.exp(-start / span), span);
|
|
497
436
|
}
|
|
498
437
|
};
|
|
499
438
|
}
|
|
500
439
|
|
|
440
|
+
function rateIntegral(seconds, baseline, excess, span) {
|
|
441
|
+
return baseline * seconds - excess * span * Math.expm1(-seconds / span);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Invert the strictly increasing service integral, with no playback rule. */
|
|
445
|
+
function invertRateIntegral(work, baseline, excess, span) {
|
|
446
|
+
if (work === 0) {
|
|
447
|
+
return 0;
|
|
448
|
+
}
|
|
449
|
+
if (!(baseline > 0) || !Number.isFinite(work)) {
|
|
450
|
+
return Number.POSITIVE_INFINITY;
|
|
451
|
+
}
|
|
452
|
+
let low = 0;
|
|
453
|
+
let high = work / baseline;
|
|
454
|
+
if (excess === 0) {
|
|
455
|
+
return high;
|
|
456
|
+
}
|
|
457
|
+
while (true) {
|
|
458
|
+
const middle = (low + high) / 2;
|
|
459
|
+
if (middle === low || middle === high) {
|
|
460
|
+
return high;
|
|
461
|
+
}
|
|
462
|
+
if (rateIntegral(middle, baseline, excess, span) >= work) {
|
|
463
|
+
high = middle;
|
|
464
|
+
} else {
|
|
465
|
+
low = middle;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
501
470
|
function averageBitsPerMediaSecond(track, segments, ready) {
|
|
502
471
|
let bits = 0;
|
|
503
472
|
let mediaSeconds = 0;
|
|
@@ -522,8 +491,8 @@ function isSafeSchedule(completions, productions, trackState, position, duration
|
|
|
522
491
|
startDelay) {
|
|
523
492
|
// Client continuity is constrained by its buffer limit; source-stall reserve
|
|
524
493
|
// is covered by the larger union of client data and output already prepared here.
|
|
525
|
-
const clientRanges = trackState.map(() =>
|
|
526
|
-
const preparedRanges = trackState.map(() =>
|
|
494
|
+
const clientRanges = trackState.map((state) => state.clientRanges.map((range) => ({ ...range })));
|
|
495
|
+
const preparedRanges = trackState.map((state) => state.clientRanges.map((range) => ({ ...range })));
|
|
527
496
|
const orderedCompletions = [...completions]
|
|
528
497
|
.filter(({ at }) => Number.isFinite(at))
|
|
529
498
|
.sort((left, right) => left.at - right.at || left.trackIndex - right.trackIndex);
|
|
@@ -545,13 +514,17 @@ function isSafeSchedule(completions, productions, trackState, position, duration
|
|
|
545
514
|
const addThrough = (time) => {
|
|
546
515
|
while (productionIndex < orderedProductions.length && orderedProductions[productionIndex].at <= time) {
|
|
547
516
|
const { trackIndex, segment } = orderedProductions[productionIndex];
|
|
548
|
-
|
|
517
|
+
for (const range of segment.mediaRanges ?? [{ start: segment.startSeconds, end: segment.endSeconds }]) {
|
|
518
|
+
addRange(preparedRanges[trackIndex], range.start, range.end);
|
|
519
|
+
}
|
|
549
520
|
productionIndex += 1;
|
|
550
521
|
}
|
|
551
522
|
while (completionIndex < orderedCompletions.length && orderedCompletions[completionIndex].at <= time) {
|
|
552
523
|
const { trackIndex, segment } = orderedCompletions[completionIndex];
|
|
553
|
-
|
|
554
|
-
|
|
524
|
+
for (const range of segment.mediaRanges ?? [{ start: segment.startSeconds, end: segment.endSeconds }]) {
|
|
525
|
+
addRange(clientRanges[trackIndex], range.start, range.end);
|
|
526
|
+
addRange(preparedRanges[trackIndex], range.start, range.end);
|
|
527
|
+
}
|
|
555
528
|
completionIndex += 1;
|
|
556
529
|
}
|
|
557
530
|
};
|
|
@@ -560,13 +533,18 @@ function isSafeSchedule(completions, productions, trackState, position, duration
|
|
|
560
533
|
if (playerPosition >= duration) {
|
|
561
534
|
return { safe: true, stockSafe: true, available: 0, neededSeconds: 0 };
|
|
562
535
|
}
|
|
563
|
-
const
|
|
564
|
-
|
|
536
|
+
const trackEnd = (index) => {
|
|
537
|
+
const last = trackState[index].segments.at(-1);
|
|
538
|
+
return last?.mediaRanges?.at(-1)?.end ?? duration;
|
|
539
|
+
};
|
|
540
|
+
const available = Math.min(capacity, ...clientRanges.map((trackRanges, index) =>
|
|
541
|
+
playerPosition >= trackEnd(index) ? Number.POSITIVE_INFINITY :
|
|
542
|
+
Math.max(0, contiguousEnd(trackRanges, playerPosition) - playerPosition)));
|
|
565
543
|
let neededSeconds = 0;
|
|
566
544
|
let stockSafe = true;
|
|
567
545
|
for (let trackIndex = 0; trackIndex < trackState.length; trackIndex += 1) {
|
|
568
546
|
const required = time <= reserveUntil[trackIndex]
|
|
569
|
-
? Math.min(reserve, Math.max(0,
|
|
547
|
+
? Math.min(reserve, Math.max(0, trackEnd(trackIndex) - playerPosition))
|
|
570
548
|
: 0;
|
|
571
549
|
const preparedAhead = Math.max(0,
|
|
572
550
|
contiguousEnd(preparedRanges[trackIndex], playerPosition) - playerPosition);
|
|
@@ -590,6 +568,9 @@ function isSafeSchedule(completions, productions, trackState, position, duration
|
|
|
590
568
|
|
|
591
569
|
const playbackEnd = startDelay + Math.max(0, duration - position);
|
|
592
570
|
const playbackBoundaries = [position + buffered,
|
|
571
|
+
...trackState.flatMap(({ clientRanges }) => clientRanges.flatMap(({ start, end }) => [start, end])),
|
|
572
|
+
...trackState.flatMap(({ segments }) => segments.flatMap((segment) =>
|
|
573
|
+
(segment.mediaRanges ?? []).flatMap(({ start, end }) => [start, end]))),
|
|
593
574
|
...trackState.flatMap(({ segments }) => segments.map(({ endSeconds }) => endSeconds))]
|
|
594
575
|
.filter((boundary) => boundary > position && boundary < duration)
|
|
595
576
|
.map((boundary) => startDelay + boundary - position);
|
|
@@ -641,6 +622,21 @@ function addRange(ranges, start, end) {
|
|
|
641
622
|
ranges.splice(0, ranges.length, ...merged);
|
|
642
623
|
}
|
|
643
624
|
|
|
625
|
+
function normalizedRanges(ranges) {
|
|
626
|
+
const result = [];
|
|
627
|
+
for (const range of ranges) {
|
|
628
|
+
if (Number.isFinite(range?.start) && Number.isFinite(range?.end) && range.end > range.start) {
|
|
629
|
+
addRange(result, range.start, range.end);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return result;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function coveredSeconds(ranges, start, end) {
|
|
636
|
+
return ranges.reduce((total, range) => total + Math.max(0,
|
|
637
|
+
Math.min(end, range.end) - Math.max(start, range.start)), 0);
|
|
638
|
+
}
|
|
639
|
+
|
|
644
640
|
function contiguousEnd(ranges, position) {
|
|
645
641
|
for (const range of ranges) {
|
|
646
642
|
if (range.start <= position && position <= range.end) {
|
|
@@ -300,6 +300,26 @@ export class PieceDiskStore {
|
|
|
300
300
|
* @param {number} [at] - Offset within the piece to start at.
|
|
301
301
|
* @returns {Promise<number>} Bytes read.
|
|
302
302
|
*/
|
|
303
|
+
hold(index) {
|
|
304
|
+
if (!this.#stored.has(index)) {
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
this.#reading.set(index, (this.#reading.get(index) ?? 0) + 1);
|
|
308
|
+
let held = true;
|
|
309
|
+
return () => {
|
|
310
|
+
if (!held) {
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
held = false;
|
|
314
|
+
const outstanding = (this.#reading.get(index) ?? 1) - 1;
|
|
315
|
+
if (outstanding > 0) {
|
|
316
|
+
this.#reading.set(index, outstanding);
|
|
317
|
+
} else {
|
|
318
|
+
this.#reading.delete(index);
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
303
323
|
async read(index, target, at = 0) {
|
|
304
324
|
if (!this.#stored.has(index)) {
|
|
305
325
|
throw new Error(`Piece ${index} is not on disk.`);
|
|
@@ -338,6 +358,9 @@ export class PieceDiskStore {
|
|
|
338
358
|
* @returns {void}
|
|
339
359
|
*/
|
|
340
360
|
forget(index) {
|
|
361
|
+
if (this.#reading.has(index)) {
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
341
364
|
const length = this.#stored.get(index);
|
|
342
365
|
if (length === undefined) {
|
|
343
366
|
return;
|
|
@@ -1407,8 +1407,12 @@ export class SharedPieceStore {
|
|
|
1407
1407
|
}
|
|
1408
1408
|
}
|
|
1409
1409
|
|
|
1410
|
-
|
|
1410
|
+
// Hold the disk bytes before waiting for memory: another reader may move
|
|
1411
|
+
// the eviction boundary while this read is waiting for a slot.
|
|
1412
|
+
const releaseDisk = this.#disk.hold?.(index) ?? (() => {});
|
|
1413
|
+
let release = null;
|
|
1411
1414
|
try {
|
|
1415
|
+
release = await this.#claimSlot();
|
|
1412
1416
|
// Another caller may have brought it back while this one waited for a
|
|
1413
1417
|
// slot. Registering a second buffer for the same piece would leave
|
|
1414
1418
|
// whoever holds the first reading memory nothing evicts.
|
|
@@ -1434,7 +1438,8 @@ export class SharedPieceStore {
|
|
|
1434
1438
|
this.#noteRevival(index);
|
|
1435
1439
|
return target;
|
|
1436
1440
|
} finally {
|
|
1437
|
-
release();
|
|
1441
|
+
release?.();
|
|
1442
|
+
releaseDisk();
|
|
1438
1443
|
}
|
|
1439
1444
|
}
|
|
1440
1445
|
|
|
@@ -83,6 +83,7 @@ export class SegmentStore {
|
|
|
83
83
|
|
|
84
84
|
/** Output key → what its directory holds. @type {Map<string, HeldContents>} */
|
|
85
85
|
#held = new Map();
|
|
86
|
+
#mediaRanges = new Map();
|
|
86
87
|
|
|
87
88
|
/** Output key → how to read its file names. @type {Map<string, object>} */
|
|
88
89
|
#formats = new Map();
|
|
@@ -431,6 +432,17 @@ export class SegmentStore {
|
|
|
431
432
|
return null;
|
|
432
433
|
}
|
|
433
434
|
const dir = path.join(this.#root, directoryNameFor(key));
|
|
435
|
+
const index = format?.segmentIndexFromName?.(served) ?? -1;
|
|
436
|
+
let mediaRanges = null;
|
|
437
|
+
if (format?.readMediaRanges && index >= 0) {
|
|
438
|
+
try {
|
|
439
|
+
mediaRanges = format.readMediaRanges(readFileSync(path.join(dir, makingName)));
|
|
440
|
+
if (mediaRanges.length === 0) throw new Error("The closed file contains no playable media intervals.");
|
|
441
|
+
} catch (error) {
|
|
442
|
+
this.#logger?.warn?.(`segment store: could not read media coverage of ${makingName}: ${error.message}`);
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
434
446
|
try {
|
|
435
447
|
renameSync(path.join(dir, makingName), path.join(dir, served));
|
|
436
448
|
} catch (error) {
|
|
@@ -445,8 +457,12 @@ export class SegmentStore {
|
|
|
445
457
|
}
|
|
446
458
|
// What the directory holds has changed, so the memory of it is stale.
|
|
447
459
|
this.#held.delete(key);
|
|
448
|
-
const index = format?.segmentIndexFromName?.(served) ?? -1;
|
|
449
460
|
if (index >= 0) {
|
|
461
|
+
if (mediaRanges) {
|
|
462
|
+
const byIndex = this.#mediaRanges.get(key) ?? new Map();
|
|
463
|
+
byIndex.set(index, mediaRanges);
|
|
464
|
+
this.#mediaRanges.set(key, byIndex);
|
|
465
|
+
}
|
|
450
466
|
this.announce(key, index);
|
|
451
467
|
for (const listener of this.#publishedListeners) {
|
|
452
468
|
listener(key, index);
|
|
@@ -455,6 +471,24 @@ export class SegmentStore {
|
|
|
455
471
|
return served;
|
|
456
472
|
}
|
|
457
473
|
|
|
474
|
+
mediaRangesOf(key, index) {
|
|
475
|
+
const known = this.#mediaRanges.get(key)?.get(index);
|
|
476
|
+
if (known) return known;
|
|
477
|
+
const format = this.#formats.get(key);
|
|
478
|
+
if (!format?.readMediaRanges) return undefined;
|
|
479
|
+
const filePath = this.pathOf(key, index);
|
|
480
|
+
if (!filePath) return undefined;
|
|
481
|
+
try {
|
|
482
|
+
const ranges = format.readMediaRanges(readFileSync(filePath));
|
|
483
|
+
const byIndex = this.#mediaRanges.get(key) ?? new Map();
|
|
484
|
+
byIndex.set(index, ranges);
|
|
485
|
+
this.#mediaRanges.set(key, byIndex);
|
|
486
|
+
return ranges;
|
|
487
|
+
} catch {
|
|
488
|
+
return [];
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
458
492
|
/**
|
|
459
493
|
* The init segment kept for this output, or null until one was read whole.
|
|
460
494
|
*
|
|
@@ -842,6 +876,7 @@ export class SegmentStore {
|
|
|
842
876
|
}
|
|
843
877
|
this.#held.delete(key);
|
|
844
878
|
this.#formats.delete(key);
|
|
879
|
+
this.#mediaRanges.delete(key);
|
|
845
880
|
this.#touched.delete(key);
|
|
846
881
|
this.#inits.delete(key);
|
|
847
882
|
this.#logger.info(`segment-store dropped ${directoryNameFor(key)} (${because})`);
|
|
@@ -391,6 +391,7 @@ export class Viewer {
|
|
|
391
391
|
linkSampleAt = null,
|
|
392
392
|
linkSampleAgeMs = null,
|
|
393
393
|
bufferedAheadSec,
|
|
394
|
+
bufferedRanges,
|
|
394
395
|
bufferLimitSeconds,
|
|
395
396
|
positionSeconds = null,
|
|
396
397
|
playing,
|
|
@@ -434,6 +435,11 @@ export class Viewer {
|
|
|
434
435
|
: now
|
|
435
436
|
: this.netReport?.linkSampleMeasuredAt ?? null,
|
|
436
437
|
bufferedAheadSec: held,
|
|
438
|
+
bufferedRanges: bufferedRanges && typeof bufferedRanges === "object" ?
|
|
439
|
+
Object.fromEntries(["video", "audio", "media"].filter((key) => Array.isArray(bufferedRanges[key]))
|
|
440
|
+
.map((key) => [key, bufferedRanges[key].slice(0, 256).filter((range) =>
|
|
441
|
+
Number.isFinite(range?.start) && Number.isFinite(range?.end) && range.start >= 0 && range.end > range.start)
|
|
442
|
+
.map(({ start, end }) => ({ start, end }))])) : null,
|
|
437
443
|
bufferLimitSeconds: Number.isFinite(bufferLimitSeconds) && bufferLimitSeconds > 0
|
|
438
444
|
? bufferLimitSeconds
|
|
439
445
|
: this.netReport?.bufferLimitSeconds ?? null,
|
|
@@ -44,7 +44,7 @@ class FakeProcess extends EventEmitter {
|
|
|
44
44
|
this.signals.push(signal);
|
|
45
45
|
// A real ffmpeg dies a moment later; the test wants the bookkeeping to
|
|
46
46
|
// happen where it can be seen, so the exit is immediate and synchronous.
|
|
47
|
-
this.emit("
|
|
47
|
+
this.emit("close", null, signal);
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
|
|
@@ -172,7 +172,7 @@ test("how many encoders are running is answered by the one thing that makes them
|
|
|
172
172
|
assert.equal(made.runningCount(), 2, "two processes on one output are two encoders, not one");
|
|
173
173
|
|
|
174
174
|
// And a run that has ended is forgotten as it ends, not at the next pass.
|
|
175
|
-
processes.get(first).emit("
|
|
175
|
+
processes.get(first).emit("close", 0, null);
|
|
176
176
|
assert.equal(made.runningCount(), 1);
|
|
177
177
|
|
|
178
178
|
// A SUSPENDED run is stopped where it stands and is using nothing, so a cost
|
|
@@ -372,7 +372,7 @@ test("every ending is counted, and our own kill is not counted as normal", () =>
|
|
|
372
372
|
wants(made, [{ from: 100, to: 130 }]);
|
|
373
373
|
made.reconcile();
|
|
374
374
|
const run = made.runsOn(PICTURE)[0];
|
|
375
|
-
processes.get(run).emit("
|
|
375
|
+
processes.get(run).emit("close", 255, null);
|
|
376
376
|
wants(made, []);
|
|
377
377
|
made.reconcile();
|
|
378
378
|
const tally = made.endings();
|