@torrent-tv/proxy 2.87.13 → 2.87.15
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
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
## 2.87.15
|
|
2
|
+
|
|
3
|
+
- **Fix**: Integrate a bounded, nonnegative forecast of recent measured service rates, so an old faster link sample cannot predict permanent delivery failure and prevent playback of fully prepared media.
|
|
4
|
+
- **Fix**: Treat the already-held browser buffer as a measured lower bound on capacity when its loader target has been reduced by fragment retries.
|
|
5
|
+
- **Chore**: Record forecast measurements when the playback-readiness reason changes.
|
|
6
|
+
|
|
7
|
+
## 2.87.14
|
|
8
|
+
|
|
9
|
+
- **Fix**: Evaluate source-stall coverage against the continuous timeline already prepared on the proxy, while independently checking client-buffer continuity and delivery capacity.
|
|
10
|
+
|
|
1
11
|
## 2.87.13
|
|
2
12
|
|
|
3
13
|
- **Fix**: Include distinct active piece reads in memory demand and preserve their slots immediately, so reads without priority-map ranges cannot deadlock the torrent piece store.
|
|
@@ -101,6 +101,15 @@ remaining-work estimates and share the measured download rate of their torrent.
|
|
|
101
101
|
The browser reports buffer state and delivery measurements, but does not
|
|
102
102
|
calculate or override readiness.
|
|
103
103
|
|
|
104
|
+
Service work is the integral of a nonnegative measured-rate forecast. The latest
|
|
105
|
+
rate relaxes over the observed measurement span toward the lower of its latest
|
|
106
|
+
value and the recent mean; the measurement history covers the output look-ahead.
|
|
107
|
+
A positive measured rate cannot become permanently zero from an old declining
|
|
108
|
+
linear fit. A measured zero remains zero until a new measurement arrives.
|
|
109
|
+
The reported loader limit is bounded below by the browser buffer already held.
|
|
110
|
+
During startup waiting the browser refreshes stale delivery measurements through
|
|
111
|
+
the existing link probe and reports their age to avoid clock-offset errors.
|
|
112
|
+
|
|
104
113
|
## Subtitles
|
|
105
114
|
|
|
106
115
|
`GET /api/subtitles` requires `sourceKey` and `fileIndex`. Without `trackIndex`
|
package/package.json
CHANGED
|
@@ -663,7 +663,7 @@ export class ViewerRequests {
|
|
|
663
663
|
return Number.isFinite(segmentSeconds) && segmentSeconds > 0 ? segmentSeconds : 0;
|
|
664
664
|
}));
|
|
665
665
|
|
|
666
|
-
|
|
666
|
+
const input = {
|
|
667
667
|
now,
|
|
668
668
|
positionSeconds,
|
|
669
669
|
durationSeconds: session.file.durationSeconds,
|
|
@@ -675,7 +675,24 @@ export class ViewerRequests {
|
|
|
675
675
|
sources: [...sourceMeasurements.values()],
|
|
676
676
|
tracks,
|
|
677
677
|
linkReadings: measurement.link.snapshot()
|
|
678
|
-
}
|
|
678
|
+
};
|
|
679
|
+
const forecast = predictPlaybackReadiness(input);
|
|
680
|
+
if (measurement.forecastReason !== forecast.reason) {
|
|
681
|
+
measurement.forecastReason = forecast.reason;
|
|
682
|
+
logger.info(`playback readiness ${session.id} consumer=${consumerId} ${JSON.stringify({
|
|
683
|
+
...forecast,
|
|
684
|
+
positionSeconds,
|
|
685
|
+
durationSeconds: input.durationSeconds,
|
|
686
|
+
bufferLimitSeconds: input.bufferLimitSeconds,
|
|
687
|
+
lookaheadSeconds: input.lookaheadSeconds,
|
|
688
|
+
link: input.linkReadings,
|
|
689
|
+
sources: input.sources,
|
|
690
|
+
tracks: tracks.map(({ id, processedSeconds, readings, segments, readySegmentIndices }) => ({
|
|
691
|
+
id, processedSeconds, readings, segments: segments.length, prepared: readySegmentIndices.length
|
|
692
|
+
}))
|
|
693
|
+
})}`);
|
|
694
|
+
}
|
|
695
|
+
return forecast;
|
|
679
696
|
}
|
|
680
697
|
|
|
681
698
|
#rateStateFor(session, consumerId) {
|
|
@@ -687,7 +704,7 @@ export class ViewerRequests {
|
|
|
687
704
|
const id = typeof consumerId === "string" ? consumerId : "";
|
|
688
705
|
let state = byViewer.get(id);
|
|
689
706
|
if (!state) {
|
|
690
|
-
state = { link: new RateTrend(), downloads: new Map(), tracks: new Map() };
|
|
707
|
+
state = { link: new RateTrend(this.#host.lookaheadSeconds), downloads: new Map(), tracks: new Map() };
|
|
691
708
|
byViewer.set(id, state);
|
|
692
709
|
}
|
|
693
710
|
return state;
|
|
@@ -696,7 +713,7 @@ export class ViewerRequests {
|
|
|
696
713
|
#downloadRateReadings(state, serviceId, downloadSpeed, now) {
|
|
697
714
|
let trend = state.downloads.get(serviceId);
|
|
698
715
|
if (!trend) {
|
|
699
|
-
trend = new RateTrend();
|
|
716
|
+
trend = new RateTrend(this.#host.lookaheadSeconds);
|
|
700
717
|
state.downloads.set(serviceId, trend);
|
|
701
718
|
}
|
|
702
719
|
const speed = Number(downloadSpeed);
|
|
@@ -710,7 +727,7 @@ export class ViewerRequests {
|
|
|
710
727
|
const id = output.outputKey;
|
|
711
728
|
let trend = state.tracks.get(id);
|
|
712
729
|
if (!trend) {
|
|
713
|
-
trend = new RateTrend();
|
|
730
|
+
trend = new RateTrend(this.#host.lookaheadSeconds);
|
|
714
731
|
state.tracks.set(id, trend);
|
|
715
732
|
}
|
|
716
733
|
const reading = this.#host.encodeSpeedReadingOf?.(output, now) ?? null;
|
|
@@ -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
|
|
|
@@ -81,7 +70,7 @@ export class RateTrend {
|
|
|
81
70
|
* @param {number} input.durationSeconds
|
|
82
71
|
* @param {number} input.bufferedAheadSeconds
|
|
83
72
|
* @param {number} input.bufferLimitSeconds
|
|
84
|
-
* @param {number} input.reserveSeconds
|
|
73
|
+
* @param {number} input.reserveSeconds - Measured source-stall coverage, in media seconds.
|
|
85
74
|
* @param {number} input.lookaheadSeconds
|
|
86
75
|
* @param {number} input.now
|
|
87
76
|
* @param {boolean} [input.requiredAudio]
|
|
@@ -100,7 +89,9 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
100
89
|
const duration = finiteNonNegative(input.durationSeconds);
|
|
101
90
|
const remaining = Math.max(0, duration - position);
|
|
102
91
|
const buffered = finiteNonNegative(input.bufferedAheadSeconds);
|
|
103
|
-
|
|
92
|
+
// A loader target can be lowered after a fragment retry. Bytes already held
|
|
93
|
+
// prove a larger capacity, even when an older page reports only that target.
|
|
94
|
+
const capacity = Math.max(buffered, finiteNonNegative(input.bufferLimitSeconds));
|
|
104
95
|
const reserve = Math.min(remaining, finiteNonNegative(input.reserveSeconds));
|
|
105
96
|
const lookahead = finiteNonNegative(input.lookaheadSeconds);
|
|
106
97
|
const tracks = Array.isArray(input.tracks) ? input.tracks : [];
|
|
@@ -110,12 +101,9 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
110
101
|
if (remaining <= buffered) {
|
|
111
102
|
return result(true, 0, buffered, reserve, 0, "client-buffer-covers-end", 0);
|
|
112
103
|
}
|
|
113
|
-
if (!(duration > 0) || !(capacity > 0) ||
|
|
104
|
+
if (!(duration > 0) || !(capacity > 0) || tracks.length === 0) {
|
|
114
105
|
return result(false, null, buffered, reserve, null, "incomplete-state", 0);
|
|
115
106
|
}
|
|
116
|
-
if (reserve > capacity) {
|
|
117
|
-
return result(false, null, buffered, reserve, null, "reserve-exceeds-client-capacity", 0);
|
|
118
|
-
}
|
|
119
107
|
const requiredTracks = input.requiredAudio === true ? 2 : 1;
|
|
120
108
|
if (tracks.length < requiredTracks) {
|
|
121
109
|
return result(false, null, buffered, reserve, null, "separate-audio-not-observed", 0);
|
|
@@ -166,7 +154,7 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
166
154
|
|
|
167
155
|
for (let trackIndex = 0; trackIndex < trackState.length; trackIndex += 1) {
|
|
168
156
|
const state = trackState[trackIndex];
|
|
169
|
-
const { track,
|
|
157
|
+
const { track, segments } = state;
|
|
170
158
|
const measuredBitsPerMediaSecond = state.bitsPerMediaSecond;
|
|
171
159
|
for (const segment of segments) {
|
|
172
160
|
if (segment.endSeconds <= position + buffered) {
|
|
@@ -240,32 +228,6 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
240
228
|
}
|
|
241
229
|
}
|
|
242
230
|
|
|
243
|
-
const rateRecoveryDelays = [];
|
|
244
|
-
const sourceDemandByService = new Map();
|
|
245
|
-
for (const sourceId of new Set(jobs.flatMap(({ sourceIntervals }) => [...sourceIntervals.keys()]))) {
|
|
246
|
-
const source = sourceState.get(sourceId);
|
|
247
|
-
const demand = sourceDemandByService.get(source.serviceId) ?? new Map();
|
|
248
|
-
demand.set(sourceId, Number(source.source.bytesPerMediaSecond));
|
|
249
|
-
sourceDemandByService.set(source.serviceId, demand);
|
|
250
|
-
}
|
|
251
|
-
for (const [serviceId, demandBySource] of sourceDemandByService) {
|
|
252
|
-
const demand = [...demandBySource.values()].reduce((sum, value) => sum + value, 0);
|
|
253
|
-
rateRecoveryDelays.push(downloadServices.get(serviceId).curve.timeAtLeast(demand));
|
|
254
|
-
}
|
|
255
|
-
for (const { track, curve } of trackState) {
|
|
256
|
-
if (jobs.some((job) => job.track === track && !job.produced)) {
|
|
257
|
-
rateRecoveryDelays.push(curve.timeAtLeast(1));
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
const linkDemand = trackState.reduce((sum, { track, bitsPerMediaSecond }) =>
|
|
261
|
-
sum + (jobs.some((job) => job.track === track) ? bitsPerMediaSecond : 0), 0);
|
|
262
|
-
if (linkDemand > 0) {
|
|
263
|
-
rateRecoveryDelays.push(link.timeAtLeast(linkDemand));
|
|
264
|
-
}
|
|
265
|
-
const rateRecoveryDelay = rateRecoveryDelays
|
|
266
|
-
.filter(Number.isFinite)
|
|
267
|
-
.reduce((latest, delay) => Math.max(latest, delay), 0);
|
|
268
|
-
|
|
269
231
|
jobs.sort((left, right) => left.segment.startSeconds - right.segment.startSeconds ||
|
|
270
232
|
left.segment.endSeconds - right.segment.endSeconds || left.trackIndex - right.trackIndex);
|
|
271
233
|
|
|
@@ -277,6 +239,7 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
277
239
|
const trackFinish = trackState.map(() => 0);
|
|
278
240
|
let transferFinish = 0;
|
|
279
241
|
const completions = [];
|
|
242
|
+
const productions = [];
|
|
280
243
|
for (const job of jobs) {
|
|
281
244
|
const { track, trackIndex, segment, ahead, sizeBits, encodeWork, sourceIntervals } = job;
|
|
282
245
|
if (prefillOnly && (ahead > prefillReach || segment.endSeconds - position > capacity)) {
|
|
@@ -322,7 +285,14 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
322
285
|
if (!job.produced) {
|
|
323
286
|
trackFinish[trackIndex] = encodedAt;
|
|
324
287
|
}
|
|
325
|
-
const producedAt = Math.max(sourceReadyAtLatest, encodedAt);
|
|
288
|
+
const producedAt = job.produced ? 0 : Math.max(sourceReadyAtLatest, encodedAt);
|
|
289
|
+
productions.push({
|
|
290
|
+
at: producedAt,
|
|
291
|
+
sourceReadyAt: sourceReadyAtLatest,
|
|
292
|
+
trackIndex,
|
|
293
|
+
segment,
|
|
294
|
+
requiresSource: sourceIntervals.size > 0
|
|
295
|
+
});
|
|
326
296
|
const roomAt = segment.endSeconds - position <= capacity
|
|
327
297
|
? 0
|
|
328
298
|
: startDelaySeconds + segment.endSeconds - position - capacity;
|
|
@@ -339,12 +309,17 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
339
309
|
? completions.filter(({ segment }) => segment.startSeconds - position <= prefillReach &&
|
|
340
310
|
segment.endSeconds - position <= capacity)
|
|
341
311
|
: completions;
|
|
342
|
-
const
|
|
312
|
+
const usefulProductions = prefillOnly
|
|
313
|
+
? productions.filter(({ segment }) => segment.startSeconds - position <= prefillReach &&
|
|
314
|
+
segment.endSeconds - position <= capacity)
|
|
315
|
+
: productions;
|
|
316
|
+
const safety = isSafeSchedule(usefulCompletions, usefulProductions, trackState, position, duration,
|
|
343
317
|
buffered, capacity, reserve, startDelaySeconds);
|
|
344
318
|
return { safe: safety.safe,
|
|
345
319
|
bufferedAtStart: safety.bufferedAtStart,
|
|
346
|
-
|
|
347
|
-
|
|
320
|
+
neededSeconds: safety.neededSeconds,
|
|
321
|
+
completionTimes: usefulCompletions.map(({ at }) => at),
|
|
322
|
+
finishAt: usefulCompletions.reduce((latest, item) => Math.max(latest, item.at), 0) };
|
|
348
323
|
};
|
|
349
324
|
|
|
350
325
|
const prefillReach = Math.min(capacity, lookahead, remaining);
|
|
@@ -353,10 +328,11 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
353
328
|
if (!Number.isFinite(maximumUsefulDelay)) {
|
|
354
329
|
return result(false, null, buffered, reserve, null, "prefill-cannot-complete", preparedSegments);
|
|
355
330
|
}
|
|
356
|
-
|
|
331
|
+
const immediate = schedule(0);
|
|
332
|
+
if (immediate.safe) {
|
|
357
333
|
return result(true, 0, buffered, reserve, 0, "trajectory-safe-now", preparedSegments);
|
|
358
334
|
}
|
|
359
|
-
const searchLimit =
|
|
335
|
+
const searchLimit = maximumUsefulDelay;
|
|
360
336
|
if (!(searchLimit > 0)) {
|
|
361
337
|
return result(false, null, buffered, reserve, null, "no-safe-start-found", preparedSegments);
|
|
362
338
|
}
|
|
@@ -366,7 +342,7 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
366
342
|
const breakpoints = [...new Set([
|
|
367
343
|
maximumUsefulDelay,
|
|
368
344
|
searchLimit,
|
|
369
|
-
...
|
|
345
|
+
...prefillSchedule.completionTimes
|
|
370
346
|
])]
|
|
371
347
|
.filter((delay) => delay > 0 && delay <= searchLimit)
|
|
372
348
|
.sort((left, right) => left - right);
|
|
@@ -396,23 +372,15 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
396
372
|
}
|
|
397
373
|
}
|
|
398
374
|
const readiness = schedule(high);
|
|
399
|
-
return result(false, high, buffered, reserve,
|
|
375
|
+
return result(false, high, buffered, reserve, immediate.neededSeconds,
|
|
400
376
|
"minimum-safe-delay", preparedSegments, readiness.bufferedAtStart);
|
|
401
377
|
}
|
|
402
378
|
|
|
403
379
|
function rateCurve(readings, now) {
|
|
404
380
|
if (readings && Number.isFinite(readings.count) && readings.count > 0 &&
|
|
405
|
-
Number.isFinite(readings.
|
|
406
|
-
Number.isFinite(readings.
|
|
407
|
-
|
|
408
|
-
const { count, originAt, sumTime, sumTimeSquared, sumValue, sumTimeValue } = readings;
|
|
409
|
-
const meanAt = sumTime / count;
|
|
410
|
-
const meanValue = sumValue / count;
|
|
411
|
-
const denominator = sumTimeSquared - count * meanAt ** 2;
|
|
412
|
-
const numerator = sumTimeValue - count * meanAt * meanValue;
|
|
413
|
-
const slope = denominator > 0 ? numerator / denominator : 0;
|
|
414
|
-
const current = Math.max(0, meanValue + slope * ((now - originAt) / 1000 - meanAt));
|
|
415
|
-
return rateCurveFrom(current, slope);
|
|
381
|
+
Number.isFinite(readings.lastAt) && Number.isFinite(readings.lastValue) &&
|
|
382
|
+
Number.isFinite(readings.meanValue) && Number.isFinite(readings.spanSeconds)) {
|
|
383
|
+
return rateCurveFrom(readings, now);
|
|
416
384
|
}
|
|
417
385
|
const ordered = (Array.isArray(readings) ? readings : [])
|
|
418
386
|
.filter((reading) => Number.isFinite(reading?.at) && Number.isFinite(reading?.value) && reading.value >= 0)
|
|
@@ -423,71 +391,70 @@ function rateCurve(readings, now) {
|
|
|
423
391
|
return null;
|
|
424
392
|
}
|
|
425
393
|
|
|
426
|
-
|
|
427
|
-
at: (reading.at - now) / 1000,
|
|
428
|
-
value: reading.value
|
|
429
|
-
}));
|
|
430
|
-
const meanAt = points.reduce((sum, point) => sum + point.at, 0) / points.length;
|
|
431
|
-
const meanValue = points.reduce((sum, point) => sum + point.value, 0) / points.length;
|
|
432
|
-
const numerator = points.reduce((sum, point) =>
|
|
433
|
-
sum + (point.at - meanAt) * (point.value - meanValue), 0);
|
|
434
|
-
const denominator = points.reduce((sum, point) =>
|
|
435
|
-
sum + (point.at - meanAt) ** 2, 0);
|
|
436
|
-
const slope = denominator > 0 ? numerator / denominator : 0;
|
|
437
|
-
const current = Math.max(0, meanValue - slope * meanAt);
|
|
438
|
-
return rateCurveFrom(current, slope);
|
|
394
|
+
return rateCurveFrom(summarizeRate(ordered), now);
|
|
439
395
|
}
|
|
440
396
|
|
|
441
|
-
function
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
if (!(work > 0)) {
|
|
450
|
-
return 0;
|
|
451
|
-
}
|
|
452
|
-
if (!(current > 0) && !(slope > 0)) {
|
|
453
|
-
return Number.POSITIVE_INFINITY;
|
|
454
|
-
}
|
|
455
|
-
const discriminant = current ** 2 + 2 * slope * work;
|
|
456
|
-
if (discriminant < 0) {
|
|
457
|
-
return Number.POSITIVE_INFINITY;
|
|
458
|
-
}
|
|
459
|
-
if (slope < 0 && work > workBy(zeroAt)) {
|
|
460
|
-
return Number.POSITIVE_INFINITY;
|
|
461
|
-
}
|
|
462
|
-
if (slope === 0) {
|
|
463
|
-
return current > 0 ? work / current : Number.POSITIVE_INFINITY;
|
|
464
|
-
}
|
|
465
|
-
const root = Math.sqrt(discriminant);
|
|
466
|
-
const denominator = current + root;
|
|
467
|
-
return denominator > 0 ? (2 * work) / denominator : Number.POSITIVE_INFINITY;
|
|
397
|
+
function summarizeRate(readings) {
|
|
398
|
+
const latest = readings.at(-1);
|
|
399
|
+
return {
|
|
400
|
+
count: readings.length,
|
|
401
|
+
lastAt: latest.at,
|
|
402
|
+
lastValue: latest.value,
|
|
403
|
+
meanValue: readings.reduce((sum, reading) => sum + reading.value, 0) / readings.length,
|
|
404
|
+
spanSeconds: (latest.at - readings[0].at) / 1000
|
|
468
405
|
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function rateCurveFrom({ lastAt, lastValue, meanValue, spanSeconds }, now) {
|
|
409
|
+
const baseline = Math.max(0, Math.min(lastValue, meanValue));
|
|
410
|
+
const span = Math.max(Number.EPSILON, spanSeconds);
|
|
411
|
+
const age = Math.max(0, (now - lastAt) / 1000);
|
|
412
|
+
const excess = Math.max(0, lastValue - baseline) * Math.exp(-age / span);
|
|
413
|
+
// r(t) = baseline + excess * exp(-t/span).
|
|
414
|
+
// W(t) = integral(0..t, r(u)du). Positive measured service has no finite
|
|
415
|
+
// total-work ceiling merely because an old sample was faster.
|
|
416
|
+
const workBy = (seconds) => rateIntegral(Math.max(0, seconds), baseline, excess, span);
|
|
469
417
|
return {
|
|
470
|
-
|
|
471
|
-
if (!Number.isFinite(target) || target < 0) {
|
|
472
|
-
return Number.POSITIVE_INFINITY;
|
|
473
|
-
}
|
|
474
|
-
if (current >= target) {
|
|
475
|
-
return 0;
|
|
476
|
-
}
|
|
477
|
-
return slope > 0 ? (target - current) / slope : Number.POSITIVE_INFINITY;
|
|
478
|
-
},
|
|
479
|
-
rateAt: (seconds) => Math.max(0, current + slope * Math.min(Math.max(0, seconds), zeroAt)),
|
|
418
|
+
rateAt: (seconds) => baseline + excess * Math.exp(-Math.max(0, seconds) / span),
|
|
480
419
|
workBy,
|
|
481
420
|
finish: (work, startAt) => {
|
|
482
|
-
if (!(work > 0)) {
|
|
483
|
-
return Math.max(0, startAt);
|
|
484
|
-
}
|
|
485
421
|
const start = Math.max(0, startAt);
|
|
486
|
-
return start + (
|
|
422
|
+
return start + invertRateIntegral(Math.max(0, work), baseline,
|
|
423
|
+
excess * Math.exp(-start / span), span);
|
|
487
424
|
}
|
|
488
425
|
};
|
|
489
426
|
}
|
|
490
427
|
|
|
428
|
+
function rateIntegral(seconds, baseline, excess, span) {
|
|
429
|
+
return baseline * seconds - excess * span * Math.expm1(-seconds / span);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Invert the strictly increasing service integral, with no playback rule. */
|
|
433
|
+
function invertRateIntegral(work, baseline, excess, span) {
|
|
434
|
+
if (work === 0) {
|
|
435
|
+
return 0;
|
|
436
|
+
}
|
|
437
|
+
if (!(baseline > 0) || !Number.isFinite(work)) {
|
|
438
|
+
return Number.POSITIVE_INFINITY;
|
|
439
|
+
}
|
|
440
|
+
let low = 0;
|
|
441
|
+
let high = work / baseline;
|
|
442
|
+
if (excess === 0) {
|
|
443
|
+
return high;
|
|
444
|
+
}
|
|
445
|
+
while (true) {
|
|
446
|
+
const middle = (low + high) / 2;
|
|
447
|
+
if (middle === low || middle === high) {
|
|
448
|
+
return high;
|
|
449
|
+
}
|
|
450
|
+
if (rateIntegral(middle, baseline, excess, span) >= work) {
|
|
451
|
+
high = middle;
|
|
452
|
+
} else {
|
|
453
|
+
low = middle;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
491
458
|
function averageBitsPerMediaSecond(track, segments, ready) {
|
|
492
459
|
let bits = 0;
|
|
493
460
|
let mediaSeconds = 0;
|
|
@@ -508,53 +475,104 @@ function averageBitsPerMediaSecond(track, segments, ready) {
|
|
|
508
475
|
return finiteNonNegative(track.bitsPerMediaSecond);
|
|
509
476
|
}
|
|
510
477
|
|
|
511
|
-
function isSafeSchedule(completions, trackState, position, duration, buffered, capacity, reserve,
|
|
512
|
-
|
|
513
|
-
|
|
478
|
+
function isSafeSchedule(completions, productions, trackState, position, duration, buffered, capacity, reserve,
|
|
479
|
+
startDelay) {
|
|
480
|
+
// Client continuity is constrained by its buffer limit; source-stall reserve
|
|
481
|
+
// is covered by the larger union of client data and output already prepared here.
|
|
482
|
+
const clientRanges = trackState.map(() => [{ start: position, end: position + buffered }]);
|
|
483
|
+
const preparedRanges = trackState.map(() => [{ start: position, end: position + buffered }]);
|
|
484
|
+
const orderedCompletions = [...completions]
|
|
485
|
+
.filter(({ at }) => Number.isFinite(at))
|
|
486
|
+
.sort((left, right) => left.at - right.at || left.trackIndex - right.trackIndex);
|
|
487
|
+
const orderedProductions = [...productions]
|
|
514
488
|
.filter(({ at }) => Number.isFinite(at))
|
|
515
489
|
.sort((left, right) => left.at - right.at || left.trackIndex - right.trackIndex);
|
|
490
|
+
const reserveUntil = trackState.map(() => Number.NEGATIVE_INFINITY);
|
|
491
|
+
// The measured reserve applies only until this track's last required source
|
|
492
|
+
// interval has arrived; encoding and client delivery stay in the schedule.
|
|
493
|
+
for (const production of productions) {
|
|
494
|
+
if (production.requiresSource) {
|
|
495
|
+
reserveUntil[production.trackIndex] = Math.max(reserveUntil[production.trackIndex],
|
|
496
|
+
production.sourceReadyAt);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
516
499
|
let completionIndex = 0;
|
|
500
|
+
let productionIndex = 0;
|
|
517
501
|
|
|
518
502
|
const addThrough = (time) => {
|
|
519
|
-
while (
|
|
520
|
-
const { trackIndex, segment } =
|
|
521
|
-
addRange(
|
|
503
|
+
while (productionIndex < orderedProductions.length && orderedProductions[productionIndex].at <= time) {
|
|
504
|
+
const { trackIndex, segment } = orderedProductions[productionIndex];
|
|
505
|
+
addRange(preparedRanges[trackIndex], segment.startSeconds, segment.endSeconds);
|
|
506
|
+
productionIndex += 1;
|
|
507
|
+
}
|
|
508
|
+
while (completionIndex < orderedCompletions.length && orderedCompletions[completionIndex].at <= time) {
|
|
509
|
+
const { trackIndex, segment } = orderedCompletions[completionIndex];
|
|
510
|
+
addRange(clientRanges[trackIndex], segment.startSeconds, segment.endSeconds);
|
|
511
|
+
addRange(preparedRanges[trackIndex], segment.startSeconds, segment.endSeconds);
|
|
522
512
|
completionIndex += 1;
|
|
523
513
|
}
|
|
524
514
|
};
|
|
525
|
-
const safeAt = (time) => {
|
|
515
|
+
const safeAt = (time, checkClient = true) => {
|
|
526
516
|
const playerPosition = position + Math.max(0, time - startDelay);
|
|
527
|
-
|
|
517
|
+
if (playerPosition >= duration) {
|
|
518
|
+
return { safe: true, stockSafe: true, available: 0, neededSeconds: 0 };
|
|
519
|
+
}
|
|
520
|
+
const available = Math.min(capacity, ...clientRanges.map((trackRanges) =>
|
|
528
521
|
Math.max(0, contiguousEnd(trackRanges, playerPosition) - playerPosition)));
|
|
529
|
-
|
|
530
|
-
|
|
522
|
+
let neededSeconds = 0;
|
|
523
|
+
let stockSafe = true;
|
|
524
|
+
for (let trackIndex = 0; trackIndex < trackState.length; trackIndex += 1) {
|
|
525
|
+
const required = time <= reserveUntil[trackIndex]
|
|
526
|
+
? Math.min(reserve, Math.max(0, duration - playerPosition))
|
|
527
|
+
: 0;
|
|
528
|
+
const preparedAhead = Math.max(0,
|
|
529
|
+
contiguousEnd(preparedRanges[trackIndex], playerPosition) - playerPosition);
|
|
530
|
+
const deficit = Math.max(0, required - preparedAhead);
|
|
531
|
+
neededSeconds = Math.max(neededSeconds, deficit);
|
|
532
|
+
stockSafe &&= deficit === 0;
|
|
533
|
+
}
|
|
534
|
+
return {
|
|
535
|
+
safe: (!checkClient || available > 0) && stockSafe,
|
|
536
|
+
stockSafe,
|
|
537
|
+
available,
|
|
538
|
+
neededSeconds
|
|
539
|
+
};
|
|
531
540
|
};
|
|
532
541
|
|
|
533
542
|
addThrough(startDelay);
|
|
534
543
|
const atStart = safeAt(startDelay);
|
|
535
544
|
if (!atStart.safe) {
|
|
536
|
-
return { safe: false, bufferedAtStart: atStart.available };
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
545
|
+
return { safe: false, bufferedAtStart: atStart.available, neededSeconds: atStart.neededSeconds };
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const playbackEnd = startDelay + Math.max(0, duration - position);
|
|
549
|
+
const playbackBoundaries = [position + buffered,
|
|
550
|
+
...trackState.flatMap(({ segments }) => segments.map(({ endSeconds }) => endSeconds))]
|
|
551
|
+
.filter((boundary) => boundary > position && boundary < duration)
|
|
552
|
+
.map((boundary) => startDelay + boundary - position);
|
|
553
|
+
const sourceReadyTimes = productions
|
|
554
|
+
.filter(({ requiresSource }) => requiresSource)
|
|
555
|
+
.map(({ sourceReadyAt }) => sourceReadyAt);
|
|
556
|
+
const eventTimes = [...orderedCompletions.map(({ at }) => at),
|
|
557
|
+
...orderedProductions.map(({ at }) => at), ...playbackBoundaries, ...sourceReadyTimes]
|
|
558
|
+
.filter((time) => Number.isFinite(time) && time > startDelay && time < playbackEnd)
|
|
559
|
+
.sort((left, right) => left - right)
|
|
560
|
+
.filter((time, index, all) => index === 0 || time !== all[index - 1]);
|
|
561
|
+
|
|
562
|
+
for (const time of eventTimes) {
|
|
563
|
+
const beforeArrival = safeAt(time, false);
|
|
564
|
+
if (!beforeArrival.stockSafe) {
|
|
565
|
+
return { safe: false, bufferedAtStart: atStart.available, neededSeconds: beforeArrival.neededSeconds };
|
|
546
566
|
}
|
|
547
567
|
addThrough(time);
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
return { safe: false, bufferedAtStart: atStart.available };
|
|
552
|
-
}
|
|
568
|
+
const afterArrival = safeAt(time);
|
|
569
|
+
if (!afterArrival.safe) {
|
|
570
|
+
return { safe: false, bufferedAtStart: atStart.available, neededSeconds: afterArrival.neededSeconds };
|
|
553
571
|
}
|
|
554
572
|
}
|
|
555
573
|
|
|
556
|
-
const atEnd = safeAt(
|
|
557
|
-
return { safe: atEnd.safe, bufferedAtStart: atStart.available };
|
|
574
|
+
const atEnd = safeAt(playbackEnd);
|
|
575
|
+
return { safe: atEnd.safe, bufferedAtStart: atStart.available, neededSeconds: atStart.neededSeconds };
|
|
558
576
|
}
|
|
559
577
|
|
|
560
578
|
function addRange(ranges, start, end) {
|
|
@@ -36,7 +36,50 @@ test("finds the first safe start from segments delivered before playback", () =>
|
|
|
36
36
|
|
|
37
37
|
assert.equal(forecast.ready, false);
|
|
38
38
|
assert.equal(forecast.reason, "minimum-safe-delay");
|
|
39
|
-
assert.ok(Math.abs(forecast.delaySeconds -
|
|
39
|
+
assert.ok(Math.abs(forecast.delaySeconds - 1) < 1e-8);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("keeps source-stall reserve in the proxy's prepared timeline, not the capped browser buffer", () => {
|
|
43
|
+
const now = 10_000;
|
|
44
|
+
const forecast = predictPlaybackReadiness({
|
|
45
|
+
now,
|
|
46
|
+
positionSeconds: 0,
|
|
47
|
+
durationSeconds: 12,
|
|
48
|
+
bufferedAheadSeconds: 2,
|
|
49
|
+
bufferLimitSeconds: 4,
|
|
50
|
+
reserveSeconds: 6,
|
|
51
|
+
lookaheadSeconds: 12,
|
|
52
|
+
sources: [{
|
|
53
|
+
id: "source",
|
|
54
|
+
complete: false,
|
|
55
|
+
bytesPerMediaSecond: 1,
|
|
56
|
+
readings: [{ at: now - 1_000, value: 2 }, { at: now, value: 2 }]
|
|
57
|
+
}],
|
|
58
|
+
tracks: [{
|
|
59
|
+
id: "video",
|
|
60
|
+
sourceIds: ["source"],
|
|
61
|
+
processedSeconds: 8,
|
|
62
|
+
bitsPerMediaSecond: 80,
|
|
63
|
+
readings: [{ at: now - 1_000, value: 2 }, { at: now, value: 2 }],
|
|
64
|
+
segments: [
|
|
65
|
+
{ index: 0, startSeconds: 0, endSeconds: 2 },
|
|
66
|
+
{ index: 1, startSeconds: 2, endSeconds: 4 },
|
|
67
|
+
{ index: 2, startSeconds: 4, endSeconds: 6 },
|
|
68
|
+
{ index: 3, startSeconds: 6, endSeconds: 8 },
|
|
69
|
+
{ index: 4, startSeconds: 8, endSeconds: 10 },
|
|
70
|
+
{ index: 5, startSeconds: 10, endSeconds: 12 }
|
|
71
|
+
],
|
|
72
|
+
readySegmentIndices: [0, 1, 2, 3],
|
|
73
|
+
segmentSizesBytes: new Map([[0, 20], [1, 20], [2, 20], [3, 20], [4, 20], [5, 20]])
|
|
74
|
+
}],
|
|
75
|
+
linkReadings: [{ at: now - 1_000, value: 80_000 }, { at: now, value: 80_000 }]
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
assert.equal(forecast.ready, true);
|
|
79
|
+
assert.equal(forecast.reason, "trajectory-safe-now");
|
|
80
|
+
assert.equal(forecast.reserveSeconds, 6);
|
|
81
|
+
assert.equal(forecast.bufferedSeconds, 2);
|
|
82
|
+
assert.equal(forecast.preparedSegments, 4);
|
|
40
83
|
});
|
|
41
84
|
|
|
42
85
|
test("overlaps source reads and encoding, then waits for the slower stage and delivery", () => {
|
|
@@ -53,7 +96,7 @@ test("overlaps source reads and encoding, then waits for the slower stage and de
|
|
|
53
96
|
const forecast = predictPlaybackReadiness(input({ sources: [source], tracks: [track] }));
|
|
54
97
|
|
|
55
98
|
assert.equal(forecast.ready, false);
|
|
56
|
-
assert.ok(Math.abs(forecast.delaySeconds -
|
|
99
|
+
assert.ok(Math.abs(forecast.delaySeconds - 2) < 1e-8);
|
|
57
100
|
});
|
|
58
101
|
|
|
59
102
|
test("does not produce an ETA without a client-link measurement", () => {
|
|
@@ -64,12 +107,14 @@ test("does not produce an ETA without a client-link measurement", () => {
|
|
|
64
107
|
assert.equal(forecast.reason, "link-rate-unavailable");
|
|
65
108
|
});
|
|
66
109
|
|
|
67
|
-
test("rate
|
|
110
|
+
test("rate forecast does not extrapolate acceleration beyond observed service", () => {
|
|
68
111
|
const trend = new RateTrend();
|
|
69
112
|
trend.add(9_000, 2);
|
|
70
113
|
trend.add(10_000, 4);
|
|
71
114
|
|
|
72
|
-
|
|
115
|
+
const predicted = forecastRate(trend.snapshot(), 10_000, 2);
|
|
116
|
+
assert.ok(predicted > 3 && predicted < 4);
|
|
117
|
+
assert.ok(forecastRate(trend.snapshot(), 100_000, 2) >= 3);
|
|
73
118
|
});
|
|
74
119
|
|
|
75
120
|
test("uses each track's own segment boundaries and requires continuous coverage on both tracks", () => {
|
|
@@ -177,7 +222,7 @@ test("downloads an overlapping source interval once when two tracks use the same
|
|
|
177
222
|
|
|
178
223
|
assert.equal(forecast.ready, false);
|
|
179
224
|
assert.equal(forecast.reason, "minimum-safe-delay");
|
|
180
|
-
assert.ok(Math.abs(forecast.delaySeconds - 2.
|
|
225
|
+
assert.ok(Math.abs(forecast.delaySeconds - 2.032) < 1e-8);
|
|
181
226
|
});
|
|
182
227
|
|
|
183
228
|
test("downloads sources on separate torrents concurrently", () => {
|
|
@@ -288,7 +333,7 @@ test("shares one torrent download rate across distinct video and audio files", (
|
|
|
288
333
|
assert.ok(Math.abs(forecast.delaySeconds - 4.032) < 1e-8);
|
|
289
334
|
});
|
|
290
335
|
|
|
291
|
-
test("
|
|
336
|
+
test("does not invent future encode acceleration to start an unsustainable output", () => {
|
|
292
337
|
const now = 1_000;
|
|
293
338
|
const forecast = predictPlaybackReadiness({
|
|
294
339
|
now,
|
|
@@ -317,7 +362,81 @@ test("finds a safe interval when rising encode speed and falling link speed make
|
|
|
317
362
|
});
|
|
318
363
|
|
|
319
364
|
assert.equal(forecast.ready, false);
|
|
320
|
-
assert.equal(forecast.reason, "
|
|
321
|
-
assert.
|
|
322
|
-
|
|
365
|
+
assert.equal(forecast.reason, "no-safe-start-found");
|
|
366
|
+
assert.equal(forecast.delaySeconds, null);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("a faster old probe cannot turn positive delivery into a permanent zero rate", () => {
|
|
370
|
+
const trend = new RateTrend(120);
|
|
371
|
+
trend.add(0, 112_400_000);
|
|
372
|
+
trend.add(66_757, 37_640_000);
|
|
373
|
+
assert.equal(forecastRate(trend.snapshot(), 20 * 60_000, 1443.97), 37_640_000);
|
|
374
|
+
assert.equal(trend.add(66_757, 37_640_000), false);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("the measurement window retains one boundary sample and discards older history", () => {
|
|
378
|
+
const trend = new RateTrend(2);
|
|
379
|
+
trend.add(0, 100);
|
|
380
|
+
trend.add(1_000, 40);
|
|
381
|
+
trend.add(4_000, 20);
|
|
382
|
+
assert.equal(trend.snapshot().count, 2);
|
|
383
|
+
assert.equal(trend.snapshot().meanValue, 30);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
function preparedEpisode(bufferLimitSeconds, linkReadings) {
|
|
387
|
+
const durationSeconds = 1443.97;
|
|
388
|
+
const boundaries = [0, 10.01, 18.977, 29.488];
|
|
389
|
+
for (let index = 4; index <= 197; index += 1) {
|
|
390
|
+
boundaries.push(29.488 + (durationSeconds - 29.488) * (index - 3) / 194);
|
|
391
|
+
}
|
|
392
|
+
const segments = boundaries.slice(0, -1).map((startSeconds, index) => ({
|
|
393
|
+
index, startSeconds, endSeconds: boundaries[index + 1]
|
|
394
|
+
}));
|
|
395
|
+
const tracks = [9_257_176.8, 189_000].map((bitsPerMediaSecond, index) => ({
|
|
396
|
+
id: String(index),
|
|
397
|
+
sourceIds: ["source"],
|
|
398
|
+
processedSeconds: 1437.269,
|
|
399
|
+
bitsPerMediaSecond,
|
|
400
|
+
readings: [],
|
|
401
|
+
segments,
|
|
402
|
+
readySegmentIndices: segments.map(({ index }) => index),
|
|
403
|
+
segmentSizesBytes: new Map(segments.map((segment) => [segment.index,
|
|
404
|
+
(segment.endSeconds - segment.startSeconds) * bitsPerMediaSecond / 8]))
|
|
405
|
+
}));
|
|
406
|
+
return {
|
|
407
|
+
now: 20 * 60_000,
|
|
408
|
+
positionSeconds: 0,
|
|
409
|
+
durationSeconds,
|
|
410
|
+
bufferedAheadSeconds: 19.187,
|
|
411
|
+
bufferLimitSeconds,
|
|
412
|
+
reserveSeconds: 5.674,
|
|
413
|
+
lookaheadSeconds: 120,
|
|
414
|
+
requiredAudio: true,
|
|
415
|
+
sources: [{ id: "source", complete: false, bytesPerMediaSecond: 1, readings: [{ at: 0, value: 0 }] }],
|
|
416
|
+
tracks,
|
|
417
|
+
linkReadings
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
test("starts a prepared episode despite an old declining link measurement", () => {
|
|
422
|
+
const forecast = predictPlaybackReadiness(preparedEpisode(120, [
|
|
423
|
+
{ at: 0, value: 112_400_000 }, { at: 66_757, value: 37_640_000 }
|
|
424
|
+
]));
|
|
425
|
+
assert.equal(forecast.preparedSegments, 394);
|
|
426
|
+
assert.equal(forecast.ready, true);
|
|
427
|
+
assert.equal(forecast.reason, "trajectory-safe-now");
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("the measured buffer proves capacity even when the loader reports a smaller target", () => {
|
|
431
|
+
const forecast = predictPlaybackReadiness(preparedEpisode(9.092, [{ at: 0, value: 37_640_000 }]));
|
|
432
|
+
assert.equal(forecast.ready, true);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test("a measured source stop is not replaced by hypothetical future recovery", () => {
|
|
436
|
+
const forecast = predictPlaybackReadiness(input({ sources: [{
|
|
437
|
+
id: "source", complete: false, bytesPerMediaSecond: 1,
|
|
438
|
+
readings: [{ at: 9_000, value: 10 }, { at: 10_000, value: 0 }]
|
|
439
|
+
}] }));
|
|
440
|
+
assert.equal(forecast.ready, false);
|
|
441
|
+
assert.equal(forecast.delaySeconds, null);
|
|
323
442
|
});
|