@torrent-tv/proxy 2.89.2 → 2.89.4
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/AGENTS.md +338 -336
- package/CHANGELOG.md +2315 -2299
- package/docs/encode-architecture.md +55 -19
- package/docs/playback-start-forecast.md +13 -5
- package/docs/storage-architecture.md +6 -0
- package/package.json +1 -1
- package/routes/stream/get.js +38 -2
- package/server.js +748 -745
- package/services/encode/EncodeOrchestrator.js +24 -49
- package/services/encode/EncodePlan.js +5 -3
- package/services/encode/EncodeRun.js +80 -17
- package/services/encode/EncodeRuns.js +1339 -1239
- package/services/encode/Renditions.js +2 -1
- package/services/encode/RunClock.js +87 -0
- package/services/encode/RunProgress.js +1 -6
- package/services/encode/encoder-readings.js +26 -28
- package/services/encode/piece-completeness.js +46 -0
- package/services/encode/quality/EncodeCost.js +1043 -1167
- package/services/encode/quality/OutputQualityState.js +1 -5
- package/services/encode/quality/QualityController.js +213 -390
- package/services/encode/quality/link-budget.js +5 -17
- package/services/encode/run-inputs.js +13 -2
- package/services/server/SegmentServing.js +3 -1
- package/services/server/ViewerRequests.js +2 -7
- package/services/server/wire-outputs.js +12 -7
- package/services/storage/segment-store/SegmentStore.js +46 -29
- package/services/torrent/supply-margin.js +7 -5
- package/test/auto-quality-step.test.js +75 -29
- package/test/decode-cost.test.js +1 -1
- package/test/encode-cost.test.js +31 -39
- package/test/encode-orchestrator.test.js +15 -8
- package/test/encode-plan-viewers.test.js +34 -27
- package/test/encode-run.test.js +34 -6
- package/test/encoder-readings.test.js +48 -26
- package/test/helpers/encode-run.js +113 -9
- package/test/link-budget.test.js +4 -5
- package/test/manager-surface.test.js +1 -2
- package/test/orchestrator-wired.test.js +5 -4
- package/test/output-speed.test.js +0 -1
- package/test/piece-completeness.test.js +42 -0
- package/test/quality-offer.test.js +1 -3
- package/test/quality-variants.test.js +1 -1
- package/test/run-progress.test.js +2 -3
- package/test/same-height-switch.test.js +1 -2
- package/test/segment-store.test.js +15 -15
- package/test/stream-route.test.js +28 -0
- package/test/supply-margin.test.js +13 -0
- package/test/two-viewers-one-picture.test.js +1 -1
- package/test/viewer-outputs.test.js +1 -1
|
@@ -24,21 +24,6 @@
|
|
|
24
24
|
|
|
25
25
|
import { AUDIO_TRANSCODE_KBPS, maxrateKbpsFor, nominalKbpsFor } from "../args.js";
|
|
26
26
|
|
|
27
|
-
/**
|
|
28
|
-
* How much of a measured link may be spent on the stream.
|
|
29
|
-
*
|
|
30
|
-
* The rest is what the link does when it is not being perfect: transport
|
|
31
|
-
* framing, retransmission, the other tabs, the moment somebody else in the
|
|
32
|
-
* house starts something. A load sized to the whole reading stalls on the
|
|
33
|
-
* first of those.
|
|
34
|
-
*
|
|
35
|
-
* Chosen, not measured, and written as a constant rather than dressed up as a
|
|
36
|
-
* measurement. What would replace it is a reading of the transport's own
|
|
37
|
-
* overhead and of how far a link's throughput varies over a session, which
|
|
38
|
-
* nothing takes.
|
|
39
|
-
*/
|
|
40
|
-
export const LINK_SAFETY = 0.8;
|
|
41
|
-
|
|
42
27
|
/** How far a figure in a load can be trusted. */
|
|
43
28
|
export const PEAK_CLASS = Object.freeze({
|
|
44
29
|
KNOWN: "known",
|
|
@@ -279,7 +264,11 @@ export function linkCouldCarry(linkMbps, load) {
|
|
|
279
264
|
if (load.peakClass === PEAK_CLASS.UNKNOWN) {
|
|
280
265
|
return answer(LINK_VERDICT.NO_SAFE_BOUND, false, false);
|
|
281
266
|
}
|
|
282
|
-
|
|
267
|
+
// The measured link against the whole load, with no share held back. The
|
|
268
|
+
// reading is what the link carried while it carried a piece, and the stream
|
|
269
|
+
// needs that on average; what a link does when it is not perfect — a pause,
|
|
270
|
+
// another tab — is what the viewer's buffer and `drain-threat.js` measure.
|
|
271
|
+
if (linkMbps < load.totalMbps) {
|
|
283
272
|
return answer(LINK_VERDICT.DOES_NOT_FIT, false, false);
|
|
284
273
|
}
|
|
285
274
|
return load.peakClass === PEAK_CLASS.KNOWN
|
|
@@ -338,7 +327,6 @@ export function linkAnswerFigures(answer) {
|
|
|
338
327
|
return {
|
|
339
328
|
verdict: answer.verdict,
|
|
340
329
|
linkMbps: answer.linkMbps,
|
|
341
|
-
linkSafety: LINK_SAFETY,
|
|
342
330
|
videoMbps: answer.load.video?.mbps ?? null,
|
|
343
331
|
videoClass: answer.load.video?.peakClass ?? null,
|
|
344
332
|
audioMbps: answer.load.audio?.mbps ?? null,
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
* sized in seconds of playback by whoever knows the file's byte rate.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
/** The query parameter naming which run of an output a read feeds. */
|
|
15
|
+
const RUN_PARAMETER = "run";
|
|
16
|
+
|
|
14
17
|
/**
|
|
15
18
|
* @param {object} params
|
|
16
19
|
* @param {{ fileIndex: number, streamUrl: (baseUrl: string, options?: { sessionId?: string }) => URL }} params.picture
|
|
@@ -20,15 +23,23 @@
|
|
|
20
23
|
* @param {"video-only" | "audio-only" | "muxed" | "empty"} params.carries
|
|
21
24
|
* @param {boolean} params.audioSeparate - The picture's sound travels as its own output.
|
|
22
25
|
* @param {string} params.sessionId - Which output this read feeds.
|
|
26
|
+
* @param {number} [params.runToken] - Which run of that output the read feeds,
|
|
27
|
+
* so the time its input waits is charged to that run and not to another one
|
|
28
|
+
* working on the same output.
|
|
23
29
|
* @param {number} params.readWindowBytes - Zero when no window was measured.
|
|
24
30
|
* @param {string} params.baseUrl
|
|
25
31
|
* @returns {{ inputFile: object, audioFile: object, inputUrl: string, audioInputUrl: string }}
|
|
26
32
|
*/
|
|
27
|
-
export function encoderInputs({ picture, soundtrack, carries, audioSeparate, sessionId, readWindowBytes, baseUrl }) {
|
|
33
|
+
export function encoderInputs({ picture, soundtrack, carries, audioSeparate, sessionId, runToken, readWindowBytes, baseUrl }) {
|
|
28
34
|
// A soundtrack shipped as its own file is encoded FROM that file, so a
|
|
29
35
|
// rendition of it reads nothing else.
|
|
30
36
|
const inputFile = carries === "audio-only" && soundtrack !== picture ? soundtrack : picture;
|
|
31
37
|
const input = inputFile.streamUrl(baseUrl, { sessionId });
|
|
38
|
+
const forRun = (url) => {
|
|
39
|
+
if (Number.isInteger(runToken)) url.searchParams.set(RUN_PARAMETER, String(runToken));
|
|
40
|
+
return url;
|
|
41
|
+
};
|
|
42
|
+
forRun(input);
|
|
32
43
|
if (Number.isFinite(readWindowBytes) && readWindowBytes > 0) {
|
|
33
44
|
input.searchParams.set("windowBytes", String(readWindowBytes));
|
|
34
45
|
input.searchParams.set("reader", "playback");
|
|
@@ -36,7 +47,7 @@ export function encoderInputs({ picture, soundtrack, carries, audioSeparate, ses
|
|
|
36
47
|
// The second input exists only for a picture that carries its sound inside
|
|
37
48
|
// itself when that sound is in another file.
|
|
38
49
|
const audioInputUrl = carries !== "audio-only" && !audioSeparate && soundtrack !== inputFile
|
|
39
|
-
? soundtrack.streamUrl(baseUrl, { sessionId }).toString()
|
|
50
|
+
? forRun(soundtrack.streamUrl(baseUrl, { sessionId })).toString()
|
|
40
51
|
: "";
|
|
41
52
|
return { inputFile, audioFile: soundtrack, inputUrl: input.toString(), audioInputUrl };
|
|
42
53
|
}
|
|
@@ -607,7 +607,9 @@ export class SegmentServing {
|
|
|
607
607
|
: this.#host.runStartTimeFor(session, this.#host.encodeRuns.earliestStartOf(session) ?? 0);
|
|
608
608
|
const position = Number(progress?.processedSeconds);
|
|
609
609
|
const produced = Number.isFinite(position) ? position - runStartSeconds : null;
|
|
610
|
-
const
|
|
610
|
+
const measured = this.#host.encodeRuns.liveRunsOf(session)
|
|
611
|
+
.reduce((best, run) => Math.max(best, run.speedX || 0), 0);
|
|
612
|
+
const speed = measured > 0 ? `${measured.toFixed(2)}x` : "n/a";
|
|
611
613
|
logger.warn(
|
|
612
614
|
`transcode ${session.id} holding ${fileName}: ${reason} ` +
|
|
613
615
|
`(runs from #${this.#host.encodeRuns.earliestStartOf(session) ?? "?"}, viewer at #${this.#host.outputTimes.segmentIndexForTime(session, this.#host.viewerSecondsOn(session))}, ` +
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { logger } from "../../utils/logger.js";
|
|
11
|
-
import { isOutputName
|
|
11
|
+
import { isOutputName } from "../encode/output/index.js";
|
|
12
12
|
|
|
13
13
|
function isWarmupTimeoutError(error) {
|
|
14
14
|
if (!(error instanceof Error)) {
|
|
@@ -484,7 +484,6 @@ export class ViewerRequests {
|
|
|
484
484
|
// segment (the only thing it waits for before playback starts) instead
|
|
485
485
|
// of a percentage of the whole-file transcode.
|
|
486
486
|
segmentDurationSec: this.#host.segmentDurationSec,
|
|
487
|
-
speed: progress.speed,
|
|
488
487
|
outputMbps,
|
|
489
488
|
playbackReadiness,
|
|
490
489
|
// The height the viewer is WATCHING right now, which is what the menu
|
|
@@ -566,11 +565,7 @@ export class ViewerRequests {
|
|
|
566
565
|
const timeline = output.timeline;
|
|
567
566
|
const trackName = output === session ? "video" : "audio";
|
|
568
567
|
const nativeRanges = Array.from({ length: timeline?.segmentCount ?? 0 }, (_, index) =>
|
|
569
|
-
this.#host.
|
|
570
|
-
startSeconds: timeline.publishedStartOf(index),
|
|
571
|
-
endSeconds: index < timeline.segmentCount - 1 ?
|
|
572
|
-
timeline.publishedStartOf(index + 1) - SEGMENT_CUT_TIME_DELTA_SECONDS : undefined
|
|
573
|
-
}));
|
|
568
|
+
this.#host.encodeRuns.finishedMediaRangesOf(output, index));
|
|
574
569
|
// The page states the offset its player applied to this track, and the
|
|
575
570
|
// init it applied it to is the session's. Until both are known, the
|
|
576
571
|
// pieces stay on their own presentation timeline: there is nothing yet
|
|
@@ -247,7 +247,10 @@ export function wireOutputs({
|
|
|
247
247
|
pathFor: (address) => parts.segmentStore.pathFor(address),
|
|
248
248
|
initOf: (address) => parts.segmentStore.initOf(address),
|
|
249
249
|
directoryFor: (address) => parts.segmentStore.directoryFor(address),
|
|
250
|
-
publish: (address, makingName, format,
|
|
250
|
+
publish: (address, makingName, format, read) => parts.segmentStore.publish(address, makingName, format, read),
|
|
251
|
+
closedBytesOf: (address, makingName) => parts.segmentStore.closedBytesOf(address, makingName),
|
|
252
|
+
mediaRangesOf: (address, index, where) => parts.segmentStore.mediaRangesOf(address, index, where),
|
|
253
|
+
remove: (address, index, because) => parts.segmentStore.remove(address, index, because)
|
|
251
254
|
};
|
|
252
255
|
const segmentOutputFiles = {
|
|
253
256
|
addresses: () => parts.segmentStore.addresses(),
|
|
@@ -257,7 +260,8 @@ export function wireOutputs({
|
|
|
257
260
|
useFormat: (address, format) => parts.segmentStore.useFormat(address, format)
|
|
258
261
|
};
|
|
259
262
|
const segmentPaths = {
|
|
260
|
-
pathOf: (address, index) => parts.segmentStore.pathOf(address, index)
|
|
263
|
+
pathOf: (address, index) => parts.segmentStore.pathOf(address, index),
|
|
264
|
+
sizesOf: (address) => parts.segmentStore.sizesOf(address)
|
|
261
265
|
};
|
|
262
266
|
/**
|
|
263
267
|
* Whether everything this output will ever serve is made: every segment of
|
|
@@ -303,6 +307,11 @@ export function wireOutputs({
|
|
|
303
307
|
// The encoders of this proxy: built where the plan places them, followed while they run, accounted when they end.
|
|
304
308
|
parts.encodeRuns = new EncodeRuns({
|
|
305
309
|
logger,
|
|
310
|
+
// What this host is doing with an encode is learned each time a run has
|
|
311
|
+
// measured its processing speed, on every host.
|
|
312
|
+
noteRunSpeedMeasured: (session, run) => {
|
|
313
|
+
void parts.encodeCost.learnFrom(session, run);
|
|
314
|
+
},
|
|
306
315
|
// What an admitted encode was seen to do, filed under its configuration.
|
|
307
316
|
observeEncodeEnded: (session) => {
|
|
308
317
|
if (!parts.localObservations || !session?.spec?.video?.encode) {
|
|
@@ -317,7 +326,6 @@ export function wireOutputs({
|
|
|
317
326
|
});
|
|
318
327
|
},
|
|
319
328
|
viewerSecondsOn: (output, consumerId, now) => viewerSecondsOn(parts.viewers, output, consumerId, now),
|
|
320
|
-
noteRunStarting: (output) => parts.quality.noteRunStarting(output),
|
|
321
329
|
inputOf: (...args) => parts.renditions.inputOf(...args),
|
|
322
330
|
producedNumbers: (...args) => parts.serving.producedNumbers(...args),
|
|
323
331
|
servesAudioSeparately: (...args) => parts.renditions.servesAudioSeparately(...args),
|
|
@@ -809,11 +817,8 @@ export function wireOutputs({
|
|
|
809
817
|
runningEncoders: () => parts.encodeOrchestrator.runningCount(),
|
|
810
818
|
encodersRunningNow: () => parts.encodeOrchestrator.runningCount(),
|
|
811
819
|
torrentCostSecFor: (session) => parts.hostLoad.torrentCostSecFor(session),
|
|
812
|
-
boundBy: (session) => parts.quality.classifyTranscodeBound(session),
|
|
813
820
|
runsFor: (session) => parts.encodeRuns.runsOf(session),
|
|
814
|
-
stateFor: (session) => parts.encodeRuns.runStateOf(session)
|
|
815
|
-
// The run's OWN progress: a speed is a pair of readings of one process.
|
|
816
|
-
progressFor: (_session, run) => run?.progress?.snapshot?.() ?? null
|
|
821
|
+
stateFor: (session) => parts.encodeRuns.runStateOf(session)
|
|
817
822
|
});
|
|
818
823
|
// WHICH HEIGHTS ARE ON THE MENU, which is the arithmetic above plus three
|
|
819
824
|
// things that are nothing to do with it: whose answer it is, what may never
|
|
@@ -421,33 +421,24 @@ export class SegmentStore {
|
|
|
421
421
|
* over with the picture frozen at 319.66 s. `segment-00055.mp4` went the same
|
|
422
422
|
* way, 211 957 against 2 620 617.
|
|
423
423
|
*
|
|
424
|
+
* Whether the piece is whole is decided before this, by the encoding
|
|
425
|
+
* (`encode/piece-completeness.js`): the store keeps bytes and what is read
|
|
426
|
+
* from them, and does not judge them.
|
|
427
|
+
*
|
|
424
428
|
* @param {string} key
|
|
425
429
|
* @param {string} makingName - What the encoder called it while writing.
|
|
426
|
-
* @param {{ servedNameOf?: (name: string) => string | null }} format
|
|
430
|
+
* @param {{ servedNameOf?: (name: string) => string | null, segmentIndexFromName?: (name: string) => number }} format
|
|
431
|
+
* @param {{ mediaRanges?: object | null }} [read] - The piece's media
|
|
432
|
+
* intervals as its format read them from the closed file, kept with it.
|
|
427
433
|
* @returns {string | null} The served name, or null where nothing was renamed.
|
|
428
434
|
*/
|
|
429
|
-
publish(key, makingName, format, {
|
|
435
|
+
publish(key, makingName, format, { mediaRanges = null } = {}) {
|
|
430
436
|
const served = format?.servedNameOf?.(makingName) ?? null;
|
|
431
437
|
if (!served) {
|
|
432
438
|
return null;
|
|
433
439
|
}
|
|
434
440
|
const dir = path.join(this.#root, directoryNameFor(key));
|
|
435
441
|
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
|
-
const end = format.producedThroughSeconds?.(mediaRanges);
|
|
441
|
-
if (end === null) throw new Error("The closed file contains no playable media intervals.");
|
|
442
|
-
if (Number.isFinite(endSeconds) && Number.isFinite(end) && end < endSeconds) {
|
|
443
|
-
this.#logger?.warn?.(`segment store: refusing short segment ${index}: produced through ${end}s, cut ${endSeconds}s`);
|
|
444
|
-
return null;
|
|
445
|
-
}
|
|
446
|
-
} catch (error) {
|
|
447
|
-
this.#logger?.warn?.(`segment store: could not read media coverage of ${makingName}: ${error.message}`);
|
|
448
|
-
return null;
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
442
|
try {
|
|
452
443
|
renameSync(path.join(dir, makingName), path.join(dir, served));
|
|
453
444
|
} catch (error) {
|
|
@@ -476,20 +467,32 @@ export class SegmentStore {
|
|
|
476
467
|
return served;
|
|
477
468
|
}
|
|
478
469
|
|
|
479
|
-
|
|
470
|
+
/**
|
|
471
|
+
* The bytes of a file the encoder has closed under its working name, for the
|
|
472
|
+
* encoding to judge before it is published.
|
|
473
|
+
*
|
|
474
|
+
* @param {string} key
|
|
475
|
+
* @param {string} makingName
|
|
476
|
+
* @returns {Buffer}
|
|
477
|
+
*/
|
|
478
|
+
closedBytesOf(key, makingName) {
|
|
479
|
+
return readFileSync(path.join(this.#root, directoryNameFor(key), makingName));
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* The media intervals a stored piece holds, read once from its bytes and kept
|
|
484
|
+
* with them, or undefined where its format reads none or it is not here.
|
|
485
|
+
*
|
|
486
|
+
* @param {string} key
|
|
487
|
+
* @param {number} index
|
|
488
|
+
* @param {{ startSeconds?: number }} [where] - Where its cut puts it.
|
|
489
|
+
* @returns {object | undefined}
|
|
490
|
+
*/
|
|
491
|
+
mediaRangesOf(key, index, { startSeconds = 0 } = {}) {
|
|
480
492
|
const known = this.#mediaRanges.get(key)?.get(index);
|
|
481
493
|
const format = this.#formats.get(key);
|
|
482
494
|
if (!format?.readMediaRanges) return undefined;
|
|
483
|
-
|
|
484
|
-
const end = format.producedThroughSeconds?.(ranges);
|
|
485
|
-
if (Number.isFinite(endSeconds) && Number.isFinite(end) && end < endSeconds) {
|
|
486
|
-
this.#logger?.warn?.(`segment store: removing short cached segment ${index}: produced through ${end}s, cut ${endSeconds}s`);
|
|
487
|
-
this.#removeSegment(key, index);
|
|
488
|
-
return undefined;
|
|
489
|
-
}
|
|
490
|
-
return ranges;
|
|
491
|
-
};
|
|
492
|
-
if (known) return checked(known);
|
|
495
|
+
if (known) return known;
|
|
493
496
|
const filePath = this.pathOf(key, index);
|
|
494
497
|
if (!filePath) return undefined;
|
|
495
498
|
try {
|
|
@@ -506,7 +509,7 @@ export class SegmentStore {
|
|
|
506
509
|
const byIndex = this.#mediaRanges.get(key) ?? new Map();
|
|
507
510
|
byIndex.set(index, ranges);
|
|
508
511
|
this.#mediaRanges.set(key, byIndex);
|
|
509
|
-
return
|
|
512
|
+
return ranges;
|
|
510
513
|
} catch {
|
|
511
514
|
return [];
|
|
512
515
|
}
|
|
@@ -1086,6 +1089,20 @@ export class SegmentStore {
|
|
|
1086
1089
|
.map(({ key, index }) => ({ key, index }));
|
|
1087
1090
|
}
|
|
1088
1091
|
|
|
1092
|
+
/**
|
|
1093
|
+
* Take one segment off the disk because its owner says it is not usable.
|
|
1094
|
+
*
|
|
1095
|
+
* @param {string} key
|
|
1096
|
+
* @param {number} index
|
|
1097
|
+
* @param {string} because
|
|
1098
|
+
* @returns {void}
|
|
1099
|
+
*/
|
|
1100
|
+
remove(key, index, because) {
|
|
1101
|
+
if (this.#removeSegment(key, index) > 0) {
|
|
1102
|
+
this.#logger?.warn?.(`segment store: removed segment ${index} of ${key.slice(0, 60)}: ${because}`);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1089
1106
|
/**
|
|
1090
1107
|
* Take one segment off the disk.
|
|
1091
1108
|
*
|
|
@@ -141,11 +141,13 @@ export function requiredSpeedFrom(waits) {
|
|
|
141
141
|
const lostSec = interruptions
|
|
142
142
|
.slice(0, last)
|
|
143
143
|
.reduce((total, one) => total + (one.end - one.start), 0) / 1000;
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
|
|
144
|
+
// Strictly below one: the span holds at least one gap of positive length
|
|
145
|
+
// (`intervals` above is not empty), and the gaps are the time the supply
|
|
146
|
+
// delivered. So the speed this asks for is finite whatever it measured, and
|
|
147
|
+
// it is not cut down to a figure chosen here: a supply that delivered for a
|
|
148
|
+
// thousandth of the time asks a thousand times realtime, which no re-encode
|
|
149
|
+
// meets and which leaves the copy, as it should.
|
|
150
|
+
const lostShare = spanSec > 0 ? lostSec / spanSec : 0;
|
|
149
151
|
return {
|
|
150
152
|
requiredSpeed: 1 / (1 - lostShare),
|
|
151
153
|
lostShare,
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import test from "node:test";
|
|
20
20
|
import { recordViewerReport } from "../services/viewer/report-intake.js";
|
|
21
|
-
import { fakeProcess as fakeEncoder, startRunOn } from "./helpers/encode-run.js";
|
|
21
|
+
import { fakeProcess as fakeEncoder, measureSpeed, startRunOn } from "./helpers/encode-run.js";
|
|
22
22
|
import assert from "node:assert/strict";
|
|
23
23
|
import { SourceFile } from "../services/media/SourceFile.js";
|
|
24
24
|
import { Timeline } from "../services/encode/output/Timeline.js";
|
|
@@ -92,7 +92,7 @@ function fakeSession({ dirPath, transcodeVideo = true, cutGrid = transcodeVideo
|
|
|
92
92
|
usesExplicitCuts: false,
|
|
93
93
|
useSyntheticPlaylist: true,
|
|
94
94
|
playlistText: "#EXTM3U\n",
|
|
95
|
-
progress: { state: "running", processedSeconds: 40, startPositionSeconds: 0
|
|
95
|
+
progress: { state: "running", processedSeconds: 40, startPositionSeconds: 0 }
|
|
96
96
|
};
|
|
97
97
|
}
|
|
98
98
|
|
|
@@ -162,7 +162,7 @@ function drainingReports(viewer, linkMbps) {
|
|
|
162
162
|
* This viewer's page reports a buffer that grows, on the link named.
|
|
163
163
|
*
|
|
164
164
|
* @param {object} viewer
|
|
165
|
-
* @param {number} linkMbps
|
|
165
|
+
* @param {number | null} linkMbps - Null when the page has not measured it.
|
|
166
166
|
* @returns {void}
|
|
167
167
|
*/
|
|
168
168
|
function fillingReports(viewer, linkMbps) {
|
|
@@ -195,12 +195,12 @@ test("a picture that cannot be kept up with is asked for as another VARIANT, and
|
|
|
195
195
|
});
|
|
196
196
|
|
|
197
197
|
const sizeBefore = `${session.encodeWidth}x${session.encodeHeight}`;
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
|
|
198
|
+
// Below realtime over its own working time, and the viewer's buffer runs
|
|
199
|
+
// dry on its trend although their link carries the stream.
|
|
200
|
+
measureSpeed([...session.runs][0], 0.7);
|
|
201
|
+
drainingReports(manager.viewers.get("viewer"), 80);
|
|
202
202
|
|
|
203
|
-
await manager.quality.
|
|
203
|
+
await manager.quality.noteViewerReported(session.id, "viewer");
|
|
204
204
|
|
|
205
205
|
assert.equal(
|
|
206
206
|
`${session.encodeWidth}x${session.encodeHeight}`,
|
|
@@ -224,10 +224,10 @@ test("a manual viewer is never sent an automatic quality request", async (t) =>
|
|
|
224
224
|
await rm(dirPath, { recursive: true, force: true });
|
|
225
225
|
});
|
|
226
226
|
manager.viewers.get("viewer").qualityMode = "manual";
|
|
227
|
-
|
|
228
|
-
|
|
227
|
+
measureSpeed([...session.runs][0], 0.7);
|
|
228
|
+
drainingReports(manager.viewers.get("viewer"), 80);
|
|
229
229
|
|
|
230
|
-
await manager.quality.
|
|
230
|
+
await manager.quality.noteViewerReported(session.id, "viewer");
|
|
231
231
|
|
|
232
232
|
assert.equal(manager.viewers.get("viewer").qualityAsk, null);
|
|
233
233
|
const progress = await manager.viewerRequests.getSessionProgress(BASE_ID, "viewer");
|
|
@@ -255,8 +255,8 @@ test("the request reaches the browser in the progress report, and stops once the
|
|
|
255
255
|
assert.equal(manager.viewers.get("viewer").qualityAsk, null, "and it is let go of, not merely hidden");
|
|
256
256
|
});
|
|
257
257
|
|
|
258
|
-
test("a request
|
|
259
|
-
const { manager, dirPath } = await managerWithSession();
|
|
258
|
+
test("a request stands while its conditions hold, and the next report that does not ask for it lets it go", async (t) => {
|
|
259
|
+
const { manager, session, dirPath } = await managerWithSession();
|
|
260
260
|
t.after(async () => {
|
|
261
261
|
await manager.lifecycle.disposeAll();
|
|
262
262
|
// The store closes its watch on the directory as it drops the output, and
|
|
@@ -265,14 +265,39 @@ test("a request the player never follows runs out instead of being repeated for
|
|
|
265
265
|
await rm(dirPath, { recursive: true, force: true });
|
|
266
266
|
});
|
|
267
267
|
|
|
268
|
-
//
|
|
269
|
-
// stream with no variants. Neither is an error; both look the same from here.
|
|
268
|
+
// Asked when their buffer was running dry; no chosen time ends it.
|
|
270
269
|
manager.viewers.get("viewer").qualityAsk = { height: 480, at: Date.now() - 120_000, reason: "measured" };
|
|
270
|
+
const standing = await manager.viewerRequests.getSessionProgress(BASE_ID, "viewer");
|
|
271
|
+
assert.equal(standing.requestedHeight, 480, "two minutes on, it still stands: nothing has said otherwise");
|
|
271
272
|
|
|
272
|
-
|
|
273
|
+
// Their next report shows a buffer that grows: what it was asked for is gone.
|
|
274
|
+
// The machine has no room to spare, so nothing else is asked in its place.
|
|
275
|
+
measureSpeed([...session.runs][0], 0.9);
|
|
276
|
+
fillingReports(manager.viewers.get("viewer"), null);
|
|
277
|
+
await manager.quality.noteViewerReported(session.id, "viewer");
|
|
273
278
|
|
|
279
|
+
const progress = await manager.viewerRequests.getSessionProgress(BASE_ID, "viewer");
|
|
274
280
|
assert.equal(progress.requestedHeight, 0);
|
|
275
|
-
assert.equal(manager.viewers.get("viewer").qualityAsk, null, "
|
|
281
|
+
assert.equal(manager.viewers.get("viewer").qualityAsk, null, "let go by the judgement that did not repeat it");
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("a request the next report asks for again stands", async (t) => {
|
|
285
|
+
const { manager, session, dirPath } = await managerWithSession();
|
|
286
|
+
t.after(async () => {
|
|
287
|
+
await manager.lifecycle.disposeAll();
|
|
288
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
289
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
290
|
+
});
|
|
291
|
+
measureSpeed([...session.runs][0], 0.7);
|
|
292
|
+
drainingReports(manager.viewers.get("viewer"), 80);
|
|
293
|
+
await manager.quality.noteViewerReported(session.id, "viewer");
|
|
294
|
+
const asked = manager.viewers.get("viewer").qualityAsk;
|
|
295
|
+
assert.ok(asked, "the machine cannot keep up and the buffer runs dry");
|
|
296
|
+
|
|
297
|
+
drainingReports(manager.viewers.get("viewer"), 80);
|
|
298
|
+
await manager.quality.noteViewerReported(session.id, "viewer");
|
|
299
|
+
|
|
300
|
+
assert.equal(manager.viewers.get("viewer").qualityAsk, asked, "the same request, not let go and not made again");
|
|
276
301
|
});
|
|
277
302
|
|
|
278
303
|
test("a COPIED picture is never asked to slow its encoder, because it has none", async (t) => {
|
|
@@ -287,10 +312,10 @@ test("a COPIED picture is never asked to slow its encoder, because it has none",
|
|
|
287
312
|
|
|
288
313
|
// Whatever this reading says, a copy has no encoder to make cheaper: moving
|
|
289
314
|
// the viewer to a RE-ENCODED rung costs the machine more, not less.
|
|
290
|
-
|
|
291
|
-
|
|
315
|
+
measureSpeed([...session.runs][0], 0.4);
|
|
316
|
+
drainingReports(manager.viewers.get("viewer"), 80);
|
|
292
317
|
|
|
293
|
-
await manager.quality.
|
|
318
|
+
await manager.quality.noteViewerReported(session.id, "viewer");
|
|
294
319
|
|
|
295
320
|
assert.equal(manager.viewers.get("viewer").qualityAsk, null, "the copy path's lever is the viewer's link, not the CPU");
|
|
296
321
|
});
|
|
@@ -467,15 +492,18 @@ test("the way BACK UP exists, one rung at a time", async (t) => {
|
|
|
467
492
|
await rm(dirPath, { recursive: true, force: true });
|
|
468
493
|
});
|
|
469
494
|
|
|
470
|
-
// The viewer is on 480p
|
|
471
|
-
// than
|
|
495
|
+
// The viewer is on 480p, the machine makes it ahead of realtime over its own
|
|
496
|
+
// working time, and their buffer grows and holds more than another output
|
|
497
|
+
// takes to be ready. No window has to pass: every term is measured.
|
|
472
498
|
session.variantHeight = 480;
|
|
473
499
|
session.encodeWidth = 854;
|
|
474
500
|
session.encodeHeight = 480;
|
|
475
|
-
|
|
476
|
-
|
|
501
|
+
measureSpeed([...session.runs][0], 2.4);
|
|
502
|
+
// Their link is not measured here: what is under test is the room, and a
|
|
503
|
+
// measured link would also weigh a soundtrack this fixture states no rate for.
|
|
504
|
+
fillingReports(manager.viewers.get("viewer"), null);
|
|
477
505
|
|
|
478
|
-
await manager.quality.
|
|
506
|
+
await manager.quality.noteViewerReported(session.id, "viewer");
|
|
479
507
|
|
|
480
508
|
assert.ok(manager.viewers.get("viewer").qualityAsk, "for most of this project's life there was no step up at all");
|
|
481
509
|
assert.equal(
|
|
@@ -642,8 +670,9 @@ test("a picture seen larger than the rung on screen is asked one rung up, when t
|
|
|
642
670
|
session.variantHeight = 480;
|
|
643
671
|
session.encodeWidth = 854;
|
|
644
672
|
session.encodeHeight = 480;
|
|
645
|
-
|
|
673
|
+
measureSpeed([...session.runs][0], 2.4);
|
|
646
674
|
manager.viewers.get("viewer").noteVisiblePicture({ width: 1920, height: 1080 });
|
|
675
|
+
fillingReports(manager.viewers.get("viewer"), null);
|
|
647
676
|
|
|
648
677
|
await manager.quality.noteViewerReported(session.id, "viewer");
|
|
649
678
|
|
|
@@ -696,12 +725,29 @@ test("a step down for the machine goes to the rung the picture seen bounds, wher
|
|
|
696
725
|
await rm(dirPath, { recursive: true, force: true });
|
|
697
726
|
});
|
|
698
727
|
manager.viewers.get("viewer").noteVisiblePicture({ width: 640, height: 360 });
|
|
699
|
-
|
|
700
|
-
|
|
728
|
+
measureSpeed([...session.runs][0], 0.7);
|
|
729
|
+
drainingReports(manager.viewers.get("viewer"), 80);
|
|
701
730
|
|
|
702
|
-
await manager.quality.
|
|
731
|
+
await manager.quality.noteViewerReported(session.id, "viewer");
|
|
703
732
|
|
|
704
733
|
const asked = manager.viewers.get("viewer").qualityAsk?.height;
|
|
705
734
|
assert.ok(asked, "the machine cannot keep up: a step down is asked");
|
|
706
735
|
assert.ok(asked <= 360, `not a rung between that the screen cannot show (asked for ${asked}p)`);
|
|
707
736
|
});
|
|
737
|
+
|
|
738
|
+
test("a machine below realtime whose viewer's buffer is filling moves nothing", async (t) => {
|
|
739
|
+
const { manager, session, dirPath } = await managerWithSession();
|
|
740
|
+
t.after(async () => {
|
|
741
|
+
await manager.lifecycle.disposeAll();
|
|
742
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
743
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
744
|
+
});
|
|
745
|
+
// What the cushion already holds covers the shortfall: no chosen window of
|
|
746
|
+
// slowness decides, the viewer's own buffer trend does.
|
|
747
|
+
measureSpeed([...session.runs][0], 0.7);
|
|
748
|
+
fillingReports(manager.viewers.get("viewer"), 80);
|
|
749
|
+
|
|
750
|
+
await manager.quality.noteViewerReported(session.id, "viewer");
|
|
751
|
+
|
|
752
|
+
assert.ok(!(manager.viewers.get("viewer").qualityAsk?.height < 720), "no step down while the buffer grows");
|
|
753
|
+
});
|
package/test/decode-cost.test.js
CHANGED
|
@@ -355,7 +355,7 @@ test("the OFFER drops the rungs the host cannot hold, and the master keeps addre
|
|
|
355
355
|
useSyntheticPlaylist: true,
|
|
356
356
|
playlistText: "#EXTM3U\n",
|
|
357
357
|
segmentCount: 100,
|
|
358
|
-
progress: { state: "running", processedSeconds: 0, startPositionSeconds: 0
|
|
358
|
+
progress: { state: "running", processedSeconds: 0, startPositionSeconds: 0 }
|
|
359
359
|
};
|
|
360
360
|
manager.outputs.set(session.id, session);
|
|
361
361
|
|