@torrent-tv/proxy 2.96.3 → 2.96.5
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/package.json +1 -1
- package/server.js +10 -1
- package/services/encode/EncodeInputs.js +61 -11
- package/services/encode/EncodePlan.js +1 -1
- package/services/encode/EncodeRuns.js +3 -3
- package/services/encode/OriginalInput.js +4 -4
- package/services/encode/OutputTimes.js +1 -1
- package/services/encode/args.js +2 -2
- package/services/encode/calibration.js +2 -15
- package/services/encode/encode-run-state.js +1 -1
- package/services/encode/hwaccel.js +5 -59
- package/services/encode/output/playlists.js +2 -3
- package/services/encode/piece-completeness.js +16 -3
- package/services/encode/quality/link-budget.js +1 -1
- package/services/encode/source-command.js +1 -1
- package/services/media/ContainerOrchestrator.js +6 -0
- package/services/media/container/avi-index.js +1 -1
- package/services/media/container/ebml-reader.js +0 -27
- package/services/media/container/ebml-stream.js +1 -1
- package/services/media/container/matroska-work-tags.js +1 -1
- package/services/media/container/work-tags.js +2 -2
- package/services/media/ffmpeg-banner.js +0 -90
- package/services/server/SegmentServing.js +1 -1
- package/services/storage/core-dumps.js +1 -1
- package/services/storage/memory-report.js +5 -5
- package/services/storage/segment-store/SegmentStore.js +1 -1
- package/services/torrent/demand/DemandRegister.js +1 -2
- package/services/torrent/demand/index.js +2 -2
- package/services/torrent/demand/pieces.js +0 -32
- package/services/torrent/files.js +2 -24
- package/services/torrent/torrent-pool.js +2 -2
- package/services/torrent/worker/channel.js +1 -1
- package/services/torrent/worker/fastest-wires.js +1 -1
- package/services/torrent/worker/piece-reader.js +1 -112
- package/services/torrent/worker/protocol.js +6 -10
- package/services/torrent/worker/worker.js +5 -5
- package/services/transport/delivery-probe.js +1 -1
- package/services/transport/packet-witness.js +5 -5
- package/services/transport/usrsctp-state.js +1 -1
- package/services/viewer/PriorityMap.js +2 -30
- package/services/viewer/choices.js +0 -14
- package/services/viewer/media-time.js +1 -1
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -630,7 +630,9 @@ export async function startProxyServer({
|
|
|
630
630
|
error.canRetry = true;
|
|
631
631
|
throw error;
|
|
632
632
|
},
|
|
633
|
-
|
|
633
|
+
// `timing`, when given, is told how long each read of a file waited behind
|
|
634
|
+
// the reads asked before it and how long it took (torrent-tv/meta#166).
|
|
635
|
+
resolveEncodeInput: async (output, fromIndex, toIndex, timing = null) => {
|
|
634
636
|
const grid = output.timeline?.published ?? output.timeline?.boundaries;
|
|
635
637
|
const from = grid?.[fromIndex], to = grid?.[toIndex + 1];
|
|
636
638
|
if (!Number.isFinite(from) || !(to > from)) return { kind: "terminal", reason: "output-interval-not-declared" };
|
|
@@ -650,6 +652,13 @@ export async function startProxyServer({
|
|
|
650
652
|
for (const [fileIndex, choices] of selected) {
|
|
651
653
|
const params = await containerOver({ sourceKey: output.file.sourceKey, fileIndex });
|
|
652
654
|
if (!params) return { kind: "needs-source" };
|
|
655
|
+
if (timing) {
|
|
656
|
+
params.onTimed = (_statement, waitedMs, readMs) => {
|
|
657
|
+
timing.reads += 1;
|
|
658
|
+
timing.queuedMs += waitedMs;
|
|
659
|
+
timing.readMs += readMs;
|
|
660
|
+
};
|
|
661
|
+
}
|
|
653
662
|
const tracks = await containerOrchestrator.inspect(params, "tracks");
|
|
654
663
|
if (tracks.kind !== "result") return tracks;
|
|
655
664
|
const container = containerOrchestrator.known(params.sourceKey, fileIndex);
|
|
@@ -59,12 +59,36 @@ export class EncodeInputs {
|
|
|
59
59
|
this.#now = now;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
/**
|
|
63
|
-
|
|
62
|
+
/**
|
|
63
|
+
* One line when a request's state changes, so a wait that never ends names
|
|
64
|
+
* what it waits for. `detail` is said with it and does not count as a change.
|
|
65
|
+
*/
|
|
66
|
+
#note(request, state, detail = "") {
|
|
64
67
|
if (request.noted === state) return;
|
|
65
68
|
request.noted = state;
|
|
66
69
|
this.#log(`encode input #${request.from}..#${request.to} of ${request.output.outputKey ?? request.output.id}: ${state}` +
|
|
67
|
-
` (held ${this.#held} of ${this.#allowed} allowed)`);
|
|
70
|
+
` (held ${this.#held} of ${this.#allowed} allowed)${detail}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The container's statement of an interval, its time added to the request's account. */
|
|
74
|
+
async #resolveTimed(timing, output, from, to) {
|
|
75
|
+
const startedAt = this.#now();
|
|
76
|
+
try {
|
|
77
|
+
return await this.#resolve(output, from, to, timing);
|
|
78
|
+
} finally {
|
|
79
|
+
timing.resolutions += 1;
|
|
80
|
+
timing.resolveMs += this.#now() - startedAt;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** `work`'s time added to one field of the request's account. */
|
|
85
|
+
async #spent(timing, field, work) {
|
|
86
|
+
const startedAt = this.#now();
|
|
87
|
+
try {
|
|
88
|
+
return await work();
|
|
89
|
+
} finally {
|
|
90
|
+
timing[field] += this.#now() - startedAt;
|
|
91
|
+
}
|
|
68
92
|
}
|
|
69
93
|
|
|
70
94
|
held() { return this.#held; }
|
|
@@ -216,8 +240,14 @@ export class EncodeInputs {
|
|
|
216
240
|
request.stale = false;
|
|
217
241
|
request.revision = this.#revision;
|
|
218
242
|
request.memoryRevision = this.#memoryRevision;
|
|
243
|
+
// Where the time from the request to its input goes, across every attempt:
|
|
244
|
+
// field 2026-10-10, a picture's request took 38 s before its copy began and
|
|
245
|
+
// nothing said on what (torrent-tv/meta#166).
|
|
246
|
+
request.timing ??= { askedAt: this.#now(), attempts: 0, resolutions: 0, resolveMs: 0,
|
|
247
|
+
reads: 0, queuedMs: 0, readMs: 0, heldMs: 0, budgetMs: 0 };
|
|
248
|
+
request.timing.attempts += 1;
|
|
219
249
|
request.promise = (async () => {
|
|
220
|
-
const resolved = await this.#
|
|
250
|
+
const resolved = await this.#resolveTimed(request.timing, request.output, request.from, request.from);
|
|
221
251
|
if (this.#requests.get(request.key) !== request) return;
|
|
222
252
|
let result;
|
|
223
253
|
if (resolved.kind !== "result") {
|
|
@@ -240,7 +270,8 @@ export class EncodeInputs {
|
|
|
240
270
|
request.result = result;
|
|
241
271
|
this.#note(request, result.kind === "result" ? `ready #${result.from}..#${result.to}, ${result.bytes ?? "?"} bytes` +
|
|
242
272
|
(Number.isFinite(result.copyMs) ? `, copied in ${result.copyMs}ms` : "")
|
|
243
|
-
: `${result.kind}${result.reason ? ` ${result.reason}` : ""}${Number.isFinite(result.bytes) ? ` ${result.bytes} bytes` : ""}
|
|
273
|
+
: `${result.kind}${result.reason ? ` ${result.reason}` : ""}${Number.isFinite(result.bytes) ? ` ${result.bytes} bytes` : ""}`,
|
|
274
|
+
describeTiming(request.timing, this.#now()));
|
|
244
275
|
if (result.kind === "result" || result.kind === "terminal") this.#changed(request.output, result);
|
|
245
276
|
})().catch(error => {
|
|
246
277
|
if (this.#requests.get(request.key) === request) {
|
|
@@ -261,7 +292,7 @@ export class EncodeInputs {
|
|
|
261
292
|
#reserveFor(request) {
|
|
262
293
|
return async bytes => {
|
|
263
294
|
this.#wanted.set(request.key, { wanted: bytes, required: bytes });
|
|
264
|
-
await this.#revise();
|
|
295
|
+
await this.#spent(request.timing, "budgetMs", () => this.#revise());
|
|
265
296
|
const capacity = this.#capacity();
|
|
266
297
|
if (capacity !== null && bytes > capacity && this.#urgent(request.output, request.from)) return {
|
|
267
298
|
kind: "terminal", reason: "source-input-exceeds-memory-capacity", bytes, capacity
|
|
@@ -303,7 +334,7 @@ export class EncodeInputs {
|
|
|
303
334
|
if (!stretches.has(end)) {
|
|
304
335
|
// A longer stretch that cannot be stated is not one to copy; the
|
|
305
336
|
// shorter one already stated still runs.
|
|
306
|
-
const resolved = await this.#
|
|
337
|
+
const resolved = await this.#resolveTimed(request.timing, output, from, end).catch(() => null);
|
|
307
338
|
stretches.set(end, resolved?.kind === "result" && resolved.sources?.every(source => source.input.original === true)
|
|
308
339
|
? { sources: resolved.sources, bytes: originalInputBytes(resolved.sources) } : null);
|
|
309
340
|
}
|
|
@@ -311,7 +342,7 @@ export class EncodeInputs {
|
|
|
311
342
|
};
|
|
312
343
|
const fits = async (end, maxBytes) => {
|
|
313
344
|
const stretch = await at(end);
|
|
314
|
-
return Boolean(stretch) && stretch.bytes <= maxBytes && await this.#present(stretch.sources, held);
|
|
345
|
+
return Boolean(stretch) && stretch.bytes <= maxBytes && await this.#present(stretch.sources, held, request.timing);
|
|
315
346
|
};
|
|
316
347
|
// Grows by doubling and then halves back: a handful of resolutions find
|
|
317
348
|
// the end whether it is a piece away or the rest of the film.
|
|
@@ -333,7 +364,7 @@ export class EncodeInputs {
|
|
|
333
364
|
let end = target === null || this.#heldRanges === null ? from : await longest(target);
|
|
334
365
|
if (this.#requests.get(request.key) !== request) return undefined;
|
|
335
366
|
this.#wanted.set(request.key, { wanted: stretches.get(end).bytes, required: minimum });
|
|
336
|
-
await this.#revise();
|
|
367
|
+
await this.#spent(request.timing, "budgetMs", () => this.#revise());
|
|
337
368
|
if (this.#requests.get(request.key) !== request) return undefined;
|
|
338
369
|
const capacity = this.#capacity();
|
|
339
370
|
if (capacity !== null && minimum > capacity && this.#urgent(output, from)) {
|
|
@@ -371,10 +402,10 @@ export class EncodeInputs {
|
|
|
371
402
|
}
|
|
372
403
|
|
|
373
404
|
/** Whether every range of these sources is downloaded whole now. */
|
|
374
|
-
async #present(sources, held) {
|
|
405
|
+
async #present(sources, held, timing) {
|
|
375
406
|
for (const source of sources) {
|
|
376
407
|
const name = `${source.sourceKey}:${source.fileIndex}`;
|
|
377
|
-
if (!held.has(name)) held.set(name, await Promise.resolve().then(() => this.#heldRanges(source)).catch(() => null) ?? []);
|
|
408
|
+
if (!held.has(name)) held.set(name, await this.#spent(timing, "heldMs", () => Promise.resolve().then(() => this.#heldRanges(source)).catch(() => null)) ?? []);
|
|
378
409
|
const ranges = held.get(name);
|
|
379
410
|
if (!source.input.ranges.every(([start, end]) => ranges.some(([first, last]) => first <= start && last >= end))) return false;
|
|
380
411
|
}
|
|
@@ -382,5 +413,24 @@ export class EncodeInputs {
|
|
|
382
413
|
}
|
|
383
414
|
}
|
|
384
415
|
|
|
416
|
+
/**
|
|
417
|
+
* Where a request has spent its time since it was asked, as one clause of its
|
|
418
|
+
* log line. The resolutions include the reads of the file they made, and those
|
|
419
|
+
* reads are split into the time spent behind earlier reads of the same file and
|
|
420
|
+
* the time of the read itself; what is left of the total is the copy and the
|
|
421
|
+
* waits between attempts for bytes or memory.
|
|
422
|
+
*
|
|
423
|
+
* @param {object | undefined} timing
|
|
424
|
+
* @param {number} now
|
|
425
|
+
* @returns {string}
|
|
426
|
+
*/
|
|
427
|
+
function describeTiming(timing, now) {
|
|
428
|
+
if (!timing) return "";
|
|
429
|
+
return `; asked ${Math.max(0, now - timing.askedAt)}ms ago over ${timing.attempts} attempt(s): ` +
|
|
430
|
+
`${timing.resolutions} resolution(s) ${timing.resolveMs}ms ` +
|
|
431
|
+
`(${timing.reads} file read(s): queued ${timing.queuedMs}ms, reading ${timing.readMs}ms), ` +
|
|
432
|
+
`held ranges ${timing.heldMs}ms, budget ${timing.budgetMs}ms`;
|
|
433
|
+
}
|
|
434
|
+
|
|
385
435
|
function addressOf(output) { return output.outputKey ?? output.id; }
|
|
386
436
|
function keyOf(output, from) { return `${addressOf(output)}:${from}`; }
|
|
@@ -1270,7 +1270,7 @@ export function firstUnmetWant(coverage, windows) {
|
|
|
1270
1270
|
* stated want is due now.
|
|
1271
1271
|
* @returns {number[]} Where to start each encoder, ascending.
|
|
1272
1272
|
*/
|
|
1273
|
-
|
|
1273
|
+
function placeEncoders({
|
|
1274
1274
|
coverage,
|
|
1275
1275
|
windows,
|
|
1276
1276
|
howMany,
|
|
@@ -60,7 +60,7 @@ export function formatSeconds(seconds) {
|
|
|
60
60
|
* @param {object[] | Set<object>} runs
|
|
61
61
|
* @returns {number | null}
|
|
62
62
|
*/
|
|
63
|
-
|
|
63
|
+
function earliestRunStart(runs) {
|
|
64
64
|
const live = liveRunsOf(runs);
|
|
65
65
|
return live.length > 0 ? live[0].from : null;
|
|
66
66
|
}
|
|
@@ -129,7 +129,7 @@ export function isInputUnavailable(message) {
|
|
|
129
129
|
* @param {string[]} args
|
|
130
130
|
* @returns {string}
|
|
131
131
|
*/
|
|
132
|
-
|
|
132
|
+
function describeFfmpegArgs(args) {
|
|
133
133
|
const parts = [];
|
|
134
134
|
for (let index = 0; index < args.length; index += 1) {
|
|
135
135
|
const value = args[index];
|
|
@@ -370,7 +370,7 @@ export class EncodeRuns {
|
|
|
370
370
|
const cut = cutOf(session.timeline, index);
|
|
371
371
|
const grid = session.timeline.published ?? session.timeline.boundaries;
|
|
372
372
|
const interval = admitted ? { from: grid?.[index], to: grid?.[index + 1],
|
|
373
|
-
...(index === session.timeline.segmentCount - 1 ? { sourceEnds: Object.fromEntries(
|
|
373
|
+
...(index === session.timeline.segmentCount - 1 ? { endsWithSource: true, sourceEnds: Object.fromEntries(
|
|
374
374
|
(admitted.tracks ?? []).filter(input => Number.isFinite(input.sourceEndSeconds))
|
|
375
375
|
.map(input => [input.track.type === "video" ? "vide" : "soun", input.sourceEndSeconds])) } : {}),
|
|
376
376
|
requiredKinds: [session.spec.carries !== "audio-only" ? "vide" : null,
|
|
@@ -37,10 +37,10 @@ export async function admitOriginalInput({ sources, reserve, readRanges, now = D
|
|
|
37
37
|
const reads = { count: 0, bytes: 0, firstAt: 0, lastAt: 0 };
|
|
38
38
|
return {
|
|
39
39
|
kind: "result", original: true, sources, bytes, copyMs, fingerprint: hash.digest("hex"),
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
// No track's own end: the container names the bytes an interval needs,
|
|
41
|
+
// not where each track stops. The final piece is judged by the input
|
|
42
|
+
// reaching the end of the file instead (`piece-completeness.js`).
|
|
43
|
+
tracks: sources.flatMap(source => (source.input.selections ?? []).map(selection => ({ track: selection.track }))),
|
|
44
44
|
read(fileIndex, start, end, partial = false) {
|
|
45
45
|
if (released) return null;
|
|
46
46
|
const entry = held.get(fileIndex);
|
|
@@ -31,7 +31,7 @@ const SEGMENT_START_DISAGREEMENT_SEC = 0.25;
|
|
|
31
31
|
* judged not to have loaded — so the player asks for it again, and again.
|
|
32
32
|
* Taken from the player's published default rather than chosen here.
|
|
33
33
|
*/
|
|
34
|
-
|
|
34
|
+
const PLAYER_BUFFER_HOLE_SEC = 0.5;
|
|
35
35
|
/**
|
|
36
36
|
* A live run of this session that begins exactly at this number, if there is
|
|
37
37
|
* one.
|
package/services/encode/args.js
CHANGED
|
@@ -31,7 +31,7 @@ export const TONEMAP_FILTER_CHAIN =
|
|
|
31
31
|
export const TRANSCODE_FPS = 24;
|
|
32
32
|
// Upper bound on the output frame rate: 50/60 fps sources are halved-in-effort
|
|
33
33
|
// by capping to 30, protecting the realtime encode budget on weak hosts.
|
|
34
|
-
|
|
34
|
+
const MAX_OUTPUT_FPS = 30;
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
37
|
* Choose an INTEGER output frame rate from the (possibly fractional) source
|
|
@@ -232,7 +232,7 @@ export function softwareRateControlFor({ width, height, fps, source = null, capK
|
|
|
232
232
|
* @param {number} nominalKbps
|
|
233
233
|
* @returns {number}
|
|
234
234
|
*/
|
|
235
|
-
|
|
235
|
+
function bufsizeKbpsFor(nominalKbps) {
|
|
236
236
|
return Math.round(nominalKbps * CAP_BUFSIZE_FACTOR);
|
|
237
237
|
}
|
|
238
238
|
|
|
@@ -53,7 +53,6 @@ import {
|
|
|
53
53
|
} from "./hwaccel.js";
|
|
54
54
|
import { interpolationErrorOf } from "./throughput.js";
|
|
55
55
|
|
|
56
|
-
export { throughputAt, interpolationErrorOf } from "./throughput.js";
|
|
57
56
|
|
|
58
57
|
/**
|
|
59
58
|
* The sizes a mode is read at, smallest first.
|
|
@@ -64,7 +63,7 @@ export { throughputAt, interpolationErrorOf } from "./throughput.js";
|
|
|
64
63
|
* rows, where most films are watched. 640x360 is the reference size every
|
|
65
64
|
* earlier reading was taken at, so the figures stay comparable with them.
|
|
66
65
|
*/
|
|
67
|
-
|
|
66
|
+
const CALIBRATION_FRAMES = Object.freeze([
|
|
68
67
|
Object.freeze({ width: 256, height: 144 }),
|
|
69
68
|
Object.freeze({ width: 640, height: 360 }),
|
|
70
69
|
Object.freeze({ width: 1280, height: 720 }),
|
|
@@ -95,18 +94,6 @@ const CALIBRATION_SOURCE = Object.freeze({ width: 1920, height: 1080, pictureKbp
|
|
|
95
94
|
* What the correctness check's segments carried.
|
|
96
95
|
*/
|
|
97
96
|
|
|
98
|
-
/**
|
|
99
|
-
* The settings a mode is known by, as text, for the log and for the key its
|
|
100
|
-
* later observations are filed under.
|
|
101
|
-
*
|
|
102
|
-
* @param {{ benchmarkArgs: (rung: string | null) => string[] }} encoder
|
|
103
|
-
* @param {string | null} rung
|
|
104
|
-
* @returns {string}
|
|
105
|
-
*/
|
|
106
|
-
export function settingsOf(encoder, rung) {
|
|
107
|
-
return encoder.benchmarkArgs(rung).join(" ");
|
|
108
|
-
}
|
|
109
|
-
|
|
110
97
|
/**
|
|
111
98
|
* Encode the calibration clip through the product's own arguments for this
|
|
112
99
|
* mode, and check every segment decodes on its own.
|
|
@@ -114,7 +101,7 @@ export function settingsOf(encoder, rung) {
|
|
|
114
101
|
* @param {{ ffmpegBin: string, encoder: object, rung: string | null, segmentDurationSec: number }} params
|
|
115
102
|
* @returns {Promise<{ ok: boolean, reason: string, segmentKbps: { averageKbps: number, peakKbps: number } | null }>}
|
|
116
103
|
*/
|
|
117
|
-
|
|
104
|
+
async function checkModeProducesCorrectSegments({ ffmpegBin, encoder, rung, segmentDurationSec }) {
|
|
118
105
|
let directory;
|
|
119
106
|
try {
|
|
120
107
|
directory = mkdtempSync(path.join(os.tmpdir(), "tt-modecheck-"));
|
|
@@ -493,7 +493,7 @@ export function declaredContainment() {
|
|
|
493
493
|
* @param {object[] | Set<object>} runs
|
|
494
494
|
* @returns {object[]}
|
|
495
495
|
*/
|
|
496
|
-
|
|
496
|
+
function runsOf(runs) {
|
|
497
497
|
const listed = Array.isArray(runs) ? runs : runs instanceof Set ? [...runs] : [];
|
|
498
498
|
return [...listed].sort((left, right) => left.from - right.from);
|
|
499
499
|
}
|
|
@@ -53,8 +53,7 @@ import {
|
|
|
53
53
|
export {
|
|
54
54
|
chooseOutputFps,
|
|
55
55
|
maxrateKbpsFor,
|
|
56
|
-
nominalKbpsFor
|
|
57
|
-
TRANSCODE_FPS
|
|
56
|
+
nominalKbpsFor
|
|
58
57
|
} from "./args.js";
|
|
59
58
|
|
|
60
59
|
const BENCHMARK_REF_W = 640;
|
|
@@ -72,7 +71,7 @@ const BENCHMARK_DURATION_SEC = 3;
|
|
|
72
71
|
* @param {{ width: number, height: number }} frame
|
|
73
72
|
* @returns {number}
|
|
74
73
|
*/
|
|
75
|
-
|
|
74
|
+
function rawFrameCountFor(frame) {
|
|
76
75
|
const referenceBytes = BENCHMARK_REF_W * BENCHMARK_REF_H * 1.5 * TRANSCODE_FPS * BENCHMARK_DURATION_SEC;
|
|
77
76
|
const frameBytes = frame.width * frame.height * 1.5;
|
|
78
77
|
return Math.max(TRANSCODE_FPS, Math.floor(referenceBytes / frameBytes));
|
|
@@ -614,10 +613,9 @@ export async function benchmarkContention({ ffmpegBin, logger, clipsDir = CALIBR
|
|
|
614
613
|
// company, not the clip's own cost, so the smallest one says it soonest.
|
|
615
614
|
const clip = path.join(clipsDir, "cal-h264-480-lo.mp4");
|
|
616
615
|
const startedAt = Date.now();
|
|
617
|
-
// Lifted once and decoded three times from the same bytes.
|
|
618
|
-
//
|
|
619
|
-
//
|
|
620
|
-
// remux whose result had not changed.
|
|
616
|
+
// Lifted once and decoded three times from the same bytes. Lifting it again
|
|
617
|
+
// for every reading cost three process starts on a path that is awaited
|
|
618
|
+
// before the proxy's tunnel opens, for a remux whose result had not changed.
|
|
621
619
|
const streams = await extractFamilyStreams(ffmpegBin, [clip], "h264");
|
|
622
620
|
const stream = streams?.[0];
|
|
623
621
|
if (!stream) {
|
|
@@ -995,58 +993,6 @@ function runCapturingStderr(ffmpegBin, args, timeoutMs) {
|
|
|
995
993
|
});
|
|
996
994
|
}
|
|
997
995
|
|
|
998
|
-
/**
|
|
999
|
-
* Measure how fast this host DECODES a clip, from ffmpeg's own report of how
|
|
1000
|
-
* much video it has processed.
|
|
1001
|
-
*
|
|
1002
|
-
* Two things are deliberately outside the measurement.
|
|
1003
|
-
*
|
|
1004
|
-
* **The process starting.** Wall-clock around the process cannot answer this:
|
|
1005
|
-
* starting ffmpeg costs about a second, and on a quick machine a five-second
|
|
1006
|
-
* clip decodes in a tenth of that, so the measurement would be of the program
|
|
1007
|
-
* starting. Progress lines arrive AFTER it has started, and the slope between
|
|
1008
|
-
* two of them — video processed against time taken — contains no part of the
|
|
1009
|
-
* startup by construction.
|
|
1010
|
-
*
|
|
1011
|
-
* **The clip restarting.** This used to loop the clip with `-stream_loop -1`,
|
|
1012
|
-
* and a loop is not free: measured 2026-08-22 on a desktop, a restart costs
|
|
1013
|
-
* 0.03 s on the 480p clip and 0.12 s on the 1080p one — the decoder tearing
|
|
1014
|
-
* down and re-allocating its frame buffers, which is why the price rises with
|
|
1015
|
-
* the picture. A five-second clip decoded at 55x restarts eleven times a
|
|
1016
|
-
* second, so that cost DOMINATED the reading: the same clips measured 53.7x
|
|
1017
|
-
* looped against 80.3x in one continuous pass, and 11.8x against 15.8x. Worse,
|
|
1018
|
-
* the bias is not shared — it depends on the clip's own resolution and on how
|
|
1019
|
-
* fast the host is — so it does not cancel out of the fit, it tilts it. That is
|
|
1020
|
-
* the fast-host failure recorded on 2026-08-20, where 1080p read cheaper than
|
|
1021
|
-
* 720p, which is not a thing a decoder does.
|
|
1022
|
-
*
|
|
1023
|
-
* So the clip is fed to the decoder as ONE stream instead. An Annex-B
|
|
1024
|
-
* elementary stream carries its parameter sets inline, so writing the same
|
|
1025
|
-
* bytes again is simply more stream — the decoder never re-initialises, and
|
|
1026
|
-
* there is no restart inside the window to measure. Verified against the
|
|
1027
|
-
* continuous-pass truth on the same host: -0.2 % and -5.5 %, against -25 % and
|
|
1028
|
-
* -33 % for the loop. Nothing is written to disk and the process is killed as
|
|
1029
|
-
* soon as the window is wide enough.
|
|
1030
|
-
*
|
|
1031
|
-
* Exported because the property that broke here is checkable and was not being
|
|
1032
|
-
* checked: a bigger picture must cost more than a smaller one of the same
|
|
1033
|
-
* bitrate, and under the loop it did not.
|
|
1034
|
-
*
|
|
1035
|
-
* @param {string} ffmpegBin
|
|
1036
|
-
* @param {string} clipPath
|
|
1037
|
-
* @param {string} [family="h264"]
|
|
1038
|
-
* @returns {Promise<{ speed: number, windowSec: number, megapixelsPerSecond: number, megabitsPerSecond: number } | null>}
|
|
1039
|
-
*/
|
|
1040
|
-
export async function measureDecodeSlope(ffmpegBin, clipPath, family = "h264") {
|
|
1041
|
-
const streams = await extractFamilyStreams(ffmpegBin, [clipPath], family);
|
|
1042
|
-
const stream = streams?.[0];
|
|
1043
|
-
if (!stream) {
|
|
1044
|
-
return null;
|
|
1045
|
-
}
|
|
1046
|
-
const measured = await decodePipedStream(ffmpegBin, stream);
|
|
1047
|
-
return measured?.speed ? measured : null;
|
|
1048
|
-
}
|
|
1049
|
-
|
|
1050
996
|
/**
|
|
1051
997
|
* Decode an elementary stream fed from memory, and report the slope.
|
|
1052
998
|
*
|
|
@@ -42,7 +42,7 @@ export const PLAYLIST_FILE_NAME = "index.m3u8";
|
|
|
42
42
|
* @param {string} language
|
|
43
43
|
* @returns {string}
|
|
44
44
|
*/
|
|
45
|
-
|
|
45
|
+
function languageTag(language) {
|
|
46
46
|
const code = String(language ?? "").toLowerCase();
|
|
47
47
|
return LANGUAGE_TAGS.get(code) ?? code;
|
|
48
48
|
}
|
|
@@ -57,7 +57,7 @@ export function languageTag(language) {
|
|
|
57
57
|
* @param {string} value
|
|
58
58
|
* @returns {string}
|
|
59
59
|
*/
|
|
60
|
-
|
|
60
|
+
function escapeAttribute(value) {
|
|
61
61
|
return String(value ?? "").replace(/"/g, "'").replace(/[\u0000-\u001f\u007f]/g, " ").trim();
|
|
62
62
|
}
|
|
63
63
|
|
|
@@ -281,4 +281,3 @@ export function segmentIndexForTime(boundaries, seconds, segmentDurationSec) {
|
|
|
281
281
|
return Math.min(result, boundaries.length - 2);
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
-
export { AUDIO_GROUP_ID, AUDIO_PATH_PREFIX, VARIANT_PATH_PREFIX };
|
|
@@ -43,7 +43,7 @@ export function cutOf(timeline, index) {
|
|
|
43
43
|
* @param {{ producedThroughSeconds?: (ranges: object) => number | null }} format
|
|
44
44
|
* @param {object} ranges - The piece's media intervals, as the format read them.
|
|
45
45
|
* @param {number | undefined} cutSeconds - From {@link cutOf}.
|
|
46
|
-
* @param {{ from: number, to: number, requiredKinds: string[], sourceEnds?: object }} [interval]
|
|
46
|
+
* @param {{ from: number, to: number, requiredKinds: string[], sourceEnds?: object, endsWithSource?: boolean }} [interval]
|
|
47
47
|
* @returns {{ whole: boolean, throughSeconds: number | null, reason?: string }}
|
|
48
48
|
*/
|
|
49
49
|
export function judgePiece(format, ranges, cutSeconds, interval) {
|
|
@@ -132,8 +132,18 @@ export function judgeNeighbors(left, right) {
|
|
|
132
132
|
* Every required track must cover the interval with at most one-frame error,
|
|
133
133
|
* on the clock the run's muxer cut by: the interval moved by `shift`
|
|
134
134
|
* ({@link cutShiftOf}). A track's own end in the file is not moved.
|
|
135
|
+
*
|
|
136
|
+
* `endsWithSource` marks the final piece of a film. Its input is the rest of
|
|
137
|
+
* the file, and it is published only by a run that reached the end of that
|
|
138
|
+
* input, so a track that stops before the interval's end stops there in the
|
|
139
|
+
* file too: the end the piece shows is the track's own, and only a track
|
|
140
|
+
* running past the interval is refused. Where the input states a track's end
|
|
141
|
+
* (`sourceEnds`), that end is held to as before. Field 2026-10-09, Drifters
|
|
142
|
+
* episode 1: the picture's last packet ends at 1419.92 s and the separate
|
|
143
|
+
* soundtrack's at 1419.904 s against a final cut of 1419.993 s, and both last
|
|
144
|
+
* pieces were refused, so no viewer could reach the end of the episode.
|
|
135
145
|
*/
|
|
136
|
-
function intervalFailure(coverage, { from, to, requiredKinds, sourceEnds = {}, shift = 0 }) {
|
|
146
|
+
function intervalFailure(coverage, { from, to, requiredKinds, sourceEnds = {}, shift = 0, endsWithSource = false }) {
|
|
137
147
|
if (!Number.isFinite(from) || !Number.isFinite(to) || !(to > from) || !requiredKinds?.length) return "segment-interval-is-not-declared";
|
|
138
148
|
for (const kind of requiredKinds) {
|
|
139
149
|
const track = coverage?.tracks?.find(track => track.kind === kind);
|
|
@@ -157,7 +167,10 @@ function intervalFailure(coverage, { from, to, requiredKinds, sourceEnds = {}, s
|
|
|
157
167
|
if (range.end > reached) reached = range.end;
|
|
158
168
|
}
|
|
159
169
|
const finalFrame = track.productionFrame ?? track.ranges.at(-1).frame;
|
|
160
|
-
|
|
170
|
+
const endsInFile = endsWithSource && !Number.isFinite(sourceEnds[kind]);
|
|
171
|
+
if ((reached < end - finalFrame - positionError && !endsInFile) || reached > end + finalFrame + positionError) {
|
|
172
|
+
return `segment-end-outside-interval-${kind}`;
|
|
173
|
+
}
|
|
161
174
|
}
|
|
162
175
|
return null;
|
|
163
176
|
|
|
@@ -179,7 +179,7 @@ export function soundtrackLoadOf(entry, transcode) {
|
|
|
179
179
|
* @param {import("../../media/audio-inventory.js").AudioInventoryEntry | null | undefined} entry
|
|
180
180
|
* @returns {boolean}
|
|
181
181
|
*/
|
|
182
|
-
|
|
182
|
+
function soundtrackHasFigure(entry) {
|
|
183
183
|
const peakKbps = Number(entry?.peakKbps);
|
|
184
184
|
const bitrateKbps = Number(entry?.bitrateKbps);
|
|
185
185
|
return (Number.isFinite(peakKbps) && peakKbps > 0) || (Number.isFinite(bitrateKbps) && bitrateKbps > 0);
|
|
@@ -137,10 +137,16 @@ export class ContainerOrchestrator {
|
|
|
137
137
|
if (!lifetime) this.#lifetimes.set(key, lifetime = {});
|
|
138
138
|
const isCurrent = () => this.#lifetimes.get(key) === lifetime && params.isCurrent?.() !== false;
|
|
139
139
|
const previous = this.#reads.get(key) ?? Promise.resolve();
|
|
140
|
+
// Reads of one file run one after another, so an answer can wait for every
|
|
141
|
+
// read asked before it. Told to whoever asked, apart from the read itself,
|
|
142
|
+
// because the two call for different remedies (torrent-tv/meta#166).
|
|
143
|
+
const askedAt = Date.now();
|
|
140
144
|
const reading = previous.catch(() => undefined).then(async () => {
|
|
141
145
|
if (!isCurrent()) return { result: { kind: "terminal", reason: "request-obsolete", requestId: params.requestId } };
|
|
146
|
+
const startedAt = Date.now();
|
|
142
147
|
const revision = params.onReadStart?.(statement);
|
|
143
148
|
const result = await this.#inspectRead({ ...params, isCurrent }, statement);
|
|
149
|
+
params.onTimed?.(statement, startedAt - askedAt, Date.now() - startedAt);
|
|
144
150
|
return { result, revision };
|
|
145
151
|
});
|
|
146
152
|
this.#reads.set(key, reading);
|
|
@@ -262,7 +262,7 @@ function streamOf(chunkId, streamCount) {
|
|
|
262
262
|
}
|
|
263
263
|
|
|
264
264
|
/** The packets an AVI index states, and the index bytes FFmpeg reads to learn them. */
|
|
265
|
-
|
|
265
|
+
class AviIndex {
|
|
266
266
|
#held;
|
|
267
267
|
#allocation;
|
|
268
268
|
/** @type {Map<number, Uint32Array> | undefined} */
|
|
@@ -130,30 +130,3 @@ export function readFloat(buffer, offset, size) {
|
|
|
130
130
|
}
|
|
131
131
|
return null;
|
|
132
132
|
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Depth-first search for the first element with `id`, descending only into the
|
|
136
|
-
* container ids listed in `descendInto`.
|
|
137
|
-
*
|
|
138
|
-
* @param {Buffer} buffer
|
|
139
|
-
* @param {number} id - Element id to find.
|
|
140
|
-
* @param {number[]} descendInto - Container ids worth entering.
|
|
141
|
-
* @param {number} [start=0]
|
|
142
|
-
* @param {number} [end=buffer.length]
|
|
143
|
-
* @returns {{ dataOffset: number, size: number } | null}
|
|
144
|
-
*/
|
|
145
|
-
export function findElement(buffer, id, descendInto, start = 0, end = buffer.length) {
|
|
146
|
-
for (const element of iterateElements(buffer, start, end)) {
|
|
147
|
-
if (element.id === id) {
|
|
148
|
-
return { dataOffset: element.dataOffset, size: element.size };
|
|
149
|
-
}
|
|
150
|
-
if (descendInto.includes(element.id)) {
|
|
151
|
-
const limit = Math.min(end, element.dataOffset + element.size);
|
|
152
|
-
const found = findElement(buffer, id, descendInto, element.dataOffset, limit);
|
|
153
|
-
if (found) {
|
|
154
|
-
return found;
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
return null;
|
|
159
|
-
}
|
|
@@ -79,7 +79,7 @@ function simpleTags(buffer, start, end, out) {
|
|
|
79
79
|
* @param {Buffer} data - The data of a `Tags` element.
|
|
80
80
|
* @returns {Map<number, Array<{ name: string, value: string, isDefault: boolean }>>}
|
|
81
81
|
*/
|
|
82
|
-
|
|
82
|
+
function tagsByLevel(data) {
|
|
83
83
|
/** @type {Map<number, Array<{ name: string, value: string, isDefault: boolean }>>} */
|
|
84
84
|
const levels = new Map();
|
|
85
85
|
for (const tag of iterateElements(data)) {
|
|
@@ -18,10 +18,10 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
/** Longest text taken from one field, in characters. A stated limit. */
|
|
21
|
-
|
|
21
|
+
const MAX_TEXT = 2000;
|
|
22
22
|
|
|
23
23
|
/** Most genres, track titles or chapter titles kept. A stated limit. */
|
|
24
|
-
|
|
24
|
+
const MAX_LIST = 32;
|
|
25
25
|
|
|
26
26
|
/** Largest cover image read, in bytes. A stated limit: covers measured in #136 are tens of kilobytes. */
|
|
27
27
|
export const MAX_COVER_BYTES = 4 * 1024 * 1024;
|
|
@@ -33,27 +33,6 @@ export function parseFfmpegDurationSeconds(stderrText) {
|
|
|
33
33
|
return hours * 3600 + minutes * 60 + seconds;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
/**
|
|
37
|
-
* Parse the container start time (seconds) from ffmpeg's "Duration: …, start:
|
|
38
|
-
* X, …" line. Many MKVs report a small non-zero start (e.g. 0.1 s); preserving
|
|
39
|
-
* it via `-copyts` would put a hole at the beginning, so we normalize it away.
|
|
40
|
-
* Returns 0 when absent.
|
|
41
|
-
*
|
|
42
|
-
* @param {string} stderrText
|
|
43
|
-
* @returns {number}
|
|
44
|
-
*/
|
|
45
|
-
export function parseFfmpegStartTimeSeconds(stderrText) {
|
|
46
|
-
if (typeof stderrText !== "string" || stderrText.length === 0) {
|
|
47
|
-
return 0;
|
|
48
|
-
}
|
|
49
|
-
const match = stderrText.match(/Duration:[^\n]*?start:\s*(-?\d+(?:\.\d+)?)/i);
|
|
50
|
-
if (!match) {
|
|
51
|
-
return 0;
|
|
52
|
-
}
|
|
53
|
-
const value = Number(match[1]);
|
|
54
|
-
return Number.isFinite(value) ? value : 0;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
36
|
/**
|
|
58
37
|
* Parse the bitrate (kbit/s) that the decode cost is priced from: the VIDEO
|
|
59
38
|
* stream's own, falling back to the container's when the stream does not state
|
|
@@ -195,72 +174,3 @@ export function parseFfmpegVideoFps(stderrText) {
|
|
|
195
174
|
const value = Number(match[1]);
|
|
196
175
|
return Number.isFinite(value) && value > 0 ? value : null;
|
|
197
176
|
}
|
|
198
|
-
|
|
199
|
-
/**
|
|
200
|
-
* Detect an HDR / wide-gamut source from the ffmpeg "Video:" line's colour
|
|
201
|
-
* metadata. HDR is identified by the transfer function — `smpte2084` (PQ /
|
|
202
|
-
* HDR10) or `arib-std-b67` (HLG). Re-encoding such a source to 8-bit SDR
|
|
203
|
-
* without tone mapping produces a washed-out, desaturated picture, so this
|
|
204
|
-
* gates the tonemap filter chain.
|
|
205
|
-
*
|
|
206
|
-
* @param {string} stderrText
|
|
207
|
-
* @returns {boolean}
|
|
208
|
-
*/
|
|
209
|
-
export function parseFfmpegHdr(stderrText) {
|
|
210
|
-
if (typeof stderrText !== "string" || stderrText.length === 0) {
|
|
211
|
-
return false;
|
|
212
|
-
}
|
|
213
|
-
const videoLine = stderrText.match(/Video:[^\n]*/i);
|
|
214
|
-
if (!videoLine) {
|
|
215
|
-
return false;
|
|
216
|
-
}
|
|
217
|
-
// ffmpeg prints the colour info in parentheses, e.g.
|
|
218
|
-
// "yuv420p10le(tv, bt2020nc/bt2020/smpte2084)". The transfer (last token) is
|
|
219
|
-
// the reliable HDR signal.
|
|
220
|
-
return /\b(smpte2084|arib-std-b67|arib_std_b67)\b/i.test(videoLine[0]);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* How many bits per sample the video carries, from the pixel format ffmpeg
|
|
225
|
-
* prints on its `Video:` line.
|
|
226
|
-
*
|
|
227
|
-
* This is a decode-cost input, not a colour one: a 10-bit stream holds wider
|
|
228
|
-
* samples and is decoded with wider arithmetic, and how much that costs is a
|
|
229
|
-
* property of the machine. It is therefore its own calibration family rather
|
|
230
|
-
* than a multiplier on the 8-bit one.
|
|
231
|
-
*
|
|
232
|
-
* ffmpeg names the depth in the format itself — `yuv420p10le`, `yuv420p12le`,
|
|
233
|
-
* `p010le` — and omits it at 8 bits (`yuv420p`, `nv12`). An unreadable line
|
|
234
|
-
* gives null, and the caller then prices the source as 8-bit, which is what
|
|
235
|
-
* every source was priced as before this existed.
|
|
236
|
-
*
|
|
237
|
-
* @param {string} stderrText
|
|
238
|
-
* @returns {number | null}
|
|
239
|
-
*/
|
|
240
|
-
export function parseFfmpegBitDepth(stderrText) {
|
|
241
|
-
if (typeof stderrText !== "string" || stderrText.length === 0) {
|
|
242
|
-
return null;
|
|
243
|
-
}
|
|
244
|
-
const videoLine = stderrText.match(/Video:[^\n]*/i);
|
|
245
|
-
if (!videoLine) {
|
|
246
|
-
return null;
|
|
247
|
-
}
|
|
248
|
-
// The pixel format is the token after the codec and its tag, and the depth
|
|
249
|
-
// rides on its name. `p010`/`p016` are the two that state it without a `p`
|
|
250
|
-
// separator, and they are 10 and 16 bits.
|
|
251
|
-
const named = videoLine[0].match(/\byuv[a-z0-9]*?p(\d{1,2})(?:le|be)\b/i);
|
|
252
|
-
if (named) {
|
|
253
|
-
const depth = Number(named[1]);
|
|
254
|
-
return Number.isFinite(depth) && depth >= 8 ? depth : null;
|
|
255
|
-
}
|
|
256
|
-
if (/\bp010(?:le|be)?\b/i.test(videoLine[0])) {
|
|
257
|
-
return 10;
|
|
258
|
-
}
|
|
259
|
-
if (/\bp016(?:le|be)?\b/i.test(videoLine[0])) {
|
|
260
|
-
return 16;
|
|
261
|
-
}
|
|
262
|
-
if (/\byuv[a-z0-9]*p\b|\bnv12\b|\bnv21\b|\bgbrp\b/i.test(videoLine[0])) {
|
|
263
|
-
return 8;
|
|
264
|
-
}
|
|
265
|
-
return null;
|
|
266
|
-
}
|
|
@@ -16,7 +16,7 @@ import { logger } from "../../utils/logger.js";
|
|
|
16
16
|
import { cutsAtGivenTimes, isOutputName, PLAYLIST_FILE_NAME } from "../encode/output/index.js";
|
|
17
17
|
// The index of variants. Served from the same route as the media playlist, so
|
|
18
18
|
// it needs no path of its own.
|
|
19
|
-
|
|
19
|
+
const MASTER_PLAYLIST_FILE_NAME = "master.m3u8";
|
|
20
20
|
// Read segment files in large blocks so the body is delivered to the data
|
|
21
21
|
// channel in few, big chunks. On a busy ARM host the in-process WebTorrent
|
|
22
22
|
// hashing starves the event loop in bursts, so fewer read iterations means
|
|
@@ -26,7 +26,7 @@ import v8 from "node:v8";
|
|
|
26
26
|
import path from "node:path";
|
|
27
27
|
|
|
28
28
|
/** How often the reading is taken and written. */
|
|
29
|
-
|
|
29
|
+
const MEMORY_REPORT_INTERVAL_MS = 60_000;
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
32
|
* How often the torrent worker takes its own reading.
|
|
@@ -57,7 +57,7 @@ export const WORKER_MEMORY_SAMPLE_MS = 1_000;
|
|
|
57
57
|
*
|
|
58
58
|
* @returns {{ rss: number, heapUsed: number, heapTotal: number, external: number, arrayBuffers: number, heapLimit: number }}
|
|
59
59
|
*/
|
|
60
|
-
|
|
60
|
+
function readProcessMemory() {
|
|
61
61
|
const usage = process.memoryUsage();
|
|
62
62
|
let heapLimit = 0;
|
|
63
63
|
try {
|
|
@@ -158,7 +158,7 @@ const LARGE_MAPPING_BYTES = 2 * 1024 * 1024;
|
|
|
158
158
|
*
|
|
159
159
|
* @returns {Promise<ReturnType<typeof summariseMappings> | null>}
|
|
160
160
|
*/
|
|
161
|
-
|
|
161
|
+
async function readMappingSummary() {
|
|
162
162
|
try {
|
|
163
163
|
return summariseMappings(await readFile("/proc/self/smaps", "utf8"));
|
|
164
164
|
} catch {
|
|
@@ -181,7 +181,7 @@ export async function readMappingSummary() {
|
|
|
181
181
|
*
|
|
182
182
|
* @returns {Promise<number | null>} Bytes, or null off Linux.
|
|
183
183
|
*/
|
|
184
|
-
|
|
184
|
+
async function readAnonymousMemory() {
|
|
185
185
|
try {
|
|
186
186
|
const text = await readFile("/proc/self/smaps_rollup", "utf8");
|
|
187
187
|
const match = /^Anonymous:\s+(\d+)\s+kB$/m.exec(text);
|
|
@@ -206,7 +206,7 @@ export async function readAnonymousMemory() {
|
|
|
206
206
|
* @param {string} directory
|
|
207
207
|
* @returns {Promise<number | null>} Bytes free, or null where it cannot be read.
|
|
208
208
|
*/
|
|
209
|
-
|
|
209
|
+
async function readDiskFree(directory) {
|
|
210
210
|
try {
|
|
211
211
|
const stats = await statfs(directory);
|
|
212
212
|
return Number(stats.bavail) * Number(stats.bsize);
|
|
@@ -46,7 +46,7 @@ import os from "node:os";
|
|
|
46
46
|
import path from "node:path";
|
|
47
47
|
|
|
48
48
|
/** Where every output's segments live. One root for the process. */
|
|
49
|
-
|
|
49
|
+
const DEFAULT_STORE_ROOT = path.join(os.tmpdir(), "torrent-tv-hls");
|
|
50
50
|
|
|
51
51
|
/** The file inside each directory that says which output it holds. */
|
|
52
52
|
const KEY_FILE = "key.txt";
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* about pieces — so it is testable with numbers alone.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { isConditional,
|
|
20
|
+
import { isConditional, URGENCY_ORDER } from "./Urgency.js";
|
|
21
21
|
import { unionOf, Window } from "./Window.js";
|
|
22
22
|
|
|
23
23
|
export class DemandRegister {
|
|
@@ -208,4 +208,3 @@ export class DemandRegister {
|
|
|
208
208
|
}
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
-
export { Urgency };
|
|
@@ -4,5 +4,5 @@
|
|
|
4
4
|
// still used there.
|
|
5
5
|
export { DemandRegister } from "./DemandRegister.js";
|
|
6
6
|
export { Window } from "./Window.js";
|
|
7
|
-
export { isConditional,
|
|
8
|
-
export {
|
|
7
|
+
export { isConditional, Urgency, urgencyName } from "./Urgency.js";
|
|
8
|
+
export { piecesOf } from "./pieces.js";
|
|
@@ -43,38 +43,6 @@ export function piecesOf({ fileOffset, byteStart, byteEnd, pieceLength }) {
|
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
/**
|
|
47
|
-
* The bytes of a file that a piece range covers, clamped to the file.
|
|
48
|
-
*
|
|
49
|
-
* The inverse of {@link piecesOf}, and here for the same reason: a caller that
|
|
50
|
-
* still thinks in pieces — the reader, which walks a file piece by piece
|
|
51
|
-
* because that is what arrives — states its need in bytes like everyone else,
|
|
52
|
-
* and the conversion stays in this one file rather than being written out
|
|
53
|
-
* again at the boundary.
|
|
54
|
-
*
|
|
55
|
-
* @param {object} params
|
|
56
|
-
* @param {number} params.fileOffset - Where the file starts within the torrent.
|
|
57
|
-
* @param {number} params.fileLength
|
|
58
|
-
* @param {number} params.from - First piece, inclusive.
|
|
59
|
-
* @param {number} params.to - Last piece, inclusive.
|
|
60
|
-
* @param {number} params.pieceLength
|
|
61
|
-
* @returns {{ byteStart: number, byteEnd: number } | null}
|
|
62
|
-
*/
|
|
63
|
-
export function bytesOf({ fileOffset, fileLength, from, to, pieceLength }) {
|
|
64
|
-
if (!Number.isFinite(pieceLength) || pieceLength <= 0) {
|
|
65
|
-
return null;
|
|
66
|
-
}
|
|
67
|
-
if (!Number.isFinite(fileOffset) || !Number.isFinite(fileLength) || fileLength <= 0) {
|
|
68
|
-
return null;
|
|
69
|
-
}
|
|
70
|
-
if (!Number.isInteger(from) || !Number.isInteger(to) || to < from) {
|
|
71
|
-
return null;
|
|
72
|
-
}
|
|
73
|
-
const byteStart = Math.max(0, from * pieceLength - fileOffset);
|
|
74
|
-
const byteEnd = Math.min(fileLength - 1, (to + 1) * pieceLength - 1 - fileOffset);
|
|
75
|
-
return byteEnd < byteStart ? null : { byteStart, byteEnd };
|
|
76
|
-
}
|
|
77
|
-
|
|
78
46
|
/**
|
|
79
47
|
* How many pieces a number of bytes needs, at worst.
|
|
80
48
|
*
|
|
@@ -164,28 +164,6 @@ export function countVideoFiles(files) {
|
|
|
164
164
|
return count;
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
-
/**
|
|
168
|
-
* Subtitle formats that are text, and therefore small by construction — a whole
|
|
169
|
-
* episode of dialogue is tens of kilobytes.
|
|
170
|
-
*
|
|
171
|
-
* The distinction is used to decide how much of such a file to fetch ahead of
|
|
172
|
-
* the viewer: a text file is fetched WHOLE, because it is smaller than the
|
|
173
|
-
* torrent's own piece and reading its edges would cost the same pieces as
|
|
174
|
-
* reading all of it. An image-based one (`.sup`, `.sub`+`.idx`) is a picture per
|
|
175
|
-
* cue and runs to tens of megabytes, so it gets the same edges treatment as
|
|
176
|
-
* anything else.
|
|
177
|
-
*/
|
|
178
|
-
export const TEXT_SUBTITLE_SIDECAR_EXTENSIONS = new Set([
|
|
179
|
-
".srt",
|
|
180
|
-
".ass",
|
|
181
|
-
".ssa",
|
|
182
|
-
".vtt",
|
|
183
|
-
".webvtt",
|
|
184
|
-
".ttml",
|
|
185
|
-
".smi",
|
|
186
|
-
".txt"
|
|
187
|
-
]);
|
|
188
|
-
|
|
189
167
|
/**
|
|
190
168
|
* Extensions whose track table a `Container` subclass can read. Everything else
|
|
191
169
|
* in {@link AUDIO_SIDECAR_EXTENSIONS} is a bare stream: one track, no metadata.
|
|
@@ -224,7 +202,7 @@ export function baseNameOf(name) {
|
|
|
224
202
|
* @param {string} extension - Lowercased, with the dot.
|
|
225
203
|
* @returns {boolean}
|
|
226
204
|
*/
|
|
227
|
-
|
|
205
|
+
function declaresItsOwnTracks(extension) {
|
|
228
206
|
return CONTAINER_BACKED_AUDIO.has(extension);
|
|
229
207
|
}
|
|
230
208
|
|
|
@@ -331,7 +309,7 @@ export function namesPair(sidecarName, videoName) {
|
|
|
331
309
|
* @param {string} videoName
|
|
332
310
|
* @returns {boolean}
|
|
333
311
|
*/
|
|
334
|
-
|
|
312
|
+
function imageNamesPair(imageName, videoName) {
|
|
335
313
|
if (sameRelease(imageName, videoName)) {
|
|
336
314
|
return true;
|
|
337
315
|
}
|
|
@@ -614,7 +614,7 @@ function pieceDownloadedBytes(torrent, index) {
|
|
|
614
614
|
* @param {import("webtorrent").Torrent} torrent
|
|
615
615
|
* @returns {number}
|
|
616
616
|
*/
|
|
617
|
-
|
|
617
|
+
function torrentDownloadedBytes(torrent) {
|
|
618
618
|
if (!torrent?.bitfield || !Array.isArray(torrent.pieces)) {
|
|
619
619
|
return 0;
|
|
620
620
|
}
|
|
@@ -634,7 +634,7 @@ export function torrentDownloadedBytes(torrent) {
|
|
|
634
634
|
* @param {import("webtorrent").TorrentFile & { _startPiece?: number, _endPiece?: number, offset?: number, length?: number }} file
|
|
635
635
|
* @returns {number}
|
|
636
636
|
*/
|
|
637
|
-
|
|
637
|
+
function fileDownloadedBytes(torrent, file) {
|
|
638
638
|
if (!torrent?.bitfield || !Array.isArray(torrent.pieces) || !file) {
|
|
639
639
|
return 0;
|
|
640
640
|
}
|
|
@@ -326,7 +326,7 @@ export function duplicateTailFor(torrent, pieceIndex) {
|
|
|
326
326
|
* @param {number} pieceIndex
|
|
327
327
|
* @returns {boolean}
|
|
328
328
|
*/
|
|
329
|
-
|
|
329
|
+
function pieceIsFullyReserved(torrent, pieceIndex) {
|
|
330
330
|
const piece = torrent?.pieces?.[pieceIndex];
|
|
331
331
|
if (!piece || typeof piece.reserve !== "function") {
|
|
332
332
|
return false;
|
|
@@ -30,41 +30,6 @@ import { minimumBufferFrom, requiredSpeedFrom } from "../supply-margin.js";
|
|
|
30
30
|
const PIECE_WAIT_LOG_MS = 1_000;
|
|
31
31
|
|
|
32
32
|
|
|
33
|
-
/**
|
|
34
|
-
* Who is working on the piece a reader is blocked on, right now.
|
|
35
|
-
*
|
|
36
|
-
* The open question about a seek: a single 8 MiB piece takes 3.0-4.6 s to
|
|
37
|
-
* arrive while the swarm as a whole is moving 4-6 MB/s, so roughly 2 MB/s is
|
|
38
|
-
* reaching the piece that is actually being waited for. Whether that is because
|
|
39
|
-
* few peers hold it, few are being asked, or each is slow cannot be told apart
|
|
40
|
-
* from the outside — these three counts tell them apart.
|
|
41
|
-
*
|
|
42
|
-
* `wire.requests` is what has been asked of that peer and not yet answered; a
|
|
43
|
-
* block is 16 KB, so `blocks x 16 KB` is the work in flight on this piece.
|
|
44
|
-
*
|
|
45
|
-
* @param {import("webtorrent").Torrent} torrent
|
|
46
|
-
* @param {number} pieceIndex
|
|
47
|
-
* @returns {{ peers: number, holders: number, askedOf: number, blocks: number }}
|
|
48
|
-
*/
|
|
49
|
-
export function pieceSupply(torrent, pieceIndex) {
|
|
50
|
-
const wires = Array.isArray(torrent?.wires) ? torrent.wires : [];
|
|
51
|
-
let holders = 0;
|
|
52
|
-
let askedOf = 0;
|
|
53
|
-
let blocks = 0;
|
|
54
|
-
for (const wire of wires) {
|
|
55
|
-
if (wire?.peerPieces?.get?.(pieceIndex)) {
|
|
56
|
-
holders += 1;
|
|
57
|
-
}
|
|
58
|
-
const requests = Array.isArray(wire?.requests) ? wire.requests : [];
|
|
59
|
-
const forThisPiece = requests.filter((request) => request?.piece === pieceIndex).length;
|
|
60
|
-
if (forThisPiece > 0) {
|
|
61
|
-
askedOf += 1;
|
|
62
|
-
blocks += forThisPiece;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
return { peers: wires.length, holders, askedOf, blocks };
|
|
66
|
-
}
|
|
67
|
-
|
|
68
33
|
/**
|
|
69
34
|
* Wait until a piece has been downloaded and verified.
|
|
70
35
|
*
|
|
@@ -286,80 +251,6 @@ function describeWaitLevels(key) {
|
|
|
286
251
|
*/
|
|
287
252
|
const waitsByLevel = new Map();
|
|
288
253
|
|
|
289
|
-
/**
|
|
290
|
-
* How many readers are blocked on a torrent AT THIS MOMENT, by infohash.
|
|
291
|
-
*
|
|
292
|
-
* Not a history and not an average: the question it answers is "is anything the
|
|
293
|
-
* viewer is watching waiting for the swarm right now", and the only honest
|
|
294
|
-
* answer is a count of readers currently inside a wait.
|
|
295
|
-
*
|
|
296
|
-
* It exists so that work which is NOT what the viewer is watching — fetching a
|
|
297
|
-
* soundtrack or a subtitle file they may switch to later — can proceed while the
|
|
298
|
-
* swarm has room and stand aside the instant it does not. That ordering is the
|
|
299
|
-
* whole of the requirement: the picture and the track being played come first,
|
|
300
|
-
* the other tracks next, and reading the film far ahead last.
|
|
301
|
-
*
|
|
302
|
-
* @type {Map<string, number>}
|
|
303
|
-
*/
|
|
304
|
-
const blockedReaders = new Map();
|
|
305
|
-
|
|
306
|
-
/**
|
|
307
|
-
* How many stalls a torrent's readers have had, ever. Only differences between
|
|
308
|
-
* two readings of it mean anything.
|
|
309
|
-
*
|
|
310
|
-
* @type {Map<string, number>}
|
|
311
|
-
*/
|
|
312
|
-
const stallsSeen = new Map();
|
|
313
|
-
|
|
314
|
-
/**
|
|
315
|
-
* Whether any reader on this torrent is waiting for a piece right now.
|
|
316
|
-
*
|
|
317
|
-
* @param {string} infoHash
|
|
318
|
-
* @returns {boolean}
|
|
319
|
-
*/
|
|
320
|
-
export function readersAreBlockedOn(infoHash) {
|
|
321
|
-
return (blockedReaders.get(infoHash) ?? 0) > 0;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
/**
|
|
325
|
-
* How many times a reader on this torrent has been blocked since the process
|
|
326
|
-
* started.
|
|
327
|
-
*
|
|
328
|
-
* Exists so that work of lower importance can ask "did the viewer stall while I
|
|
329
|
-
* was busy?" — which is a different and stricter question than "is the viewer
|
|
330
|
-
* stalled right now". On a swarm delivering exactly what the film needs, a
|
|
331
|
-
* background fetch that only pauses DURING a stall still takes bandwidth
|
|
332
|
-
* between them, and the stalls are the proof it had none to spare. Field
|
|
333
|
-
* 2026-08-31: the swarm delivered 200-600 KB/s against the 399 KB/s the film
|
|
334
|
-
* needs, and the picture stood still 145.6 s.
|
|
335
|
-
*
|
|
336
|
-
* @param {string} infoHash
|
|
337
|
-
* @returns {number}
|
|
338
|
-
*/
|
|
339
|
-
export function stallsSeenOn(infoHash) {
|
|
340
|
-
return stallsSeen.get(infoHash) ?? 0;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
/**
|
|
344
|
-
* @param {string} infoHash
|
|
345
|
-
* @param {number} delta
|
|
346
|
-
* @returns {void}
|
|
347
|
-
*/
|
|
348
|
-
function countBlockedReader(infoHash, delta) {
|
|
349
|
-
if (!infoHash) {
|
|
350
|
-
return;
|
|
351
|
-
}
|
|
352
|
-
if (delta > 0) {
|
|
353
|
-
stallsSeen.set(infoHash, (stallsSeen.get(infoHash) ?? 0) + 1);
|
|
354
|
-
}
|
|
355
|
-
const next = (blockedReaders.get(infoHash) ?? 0) + delta;
|
|
356
|
-
if (next > 0) {
|
|
357
|
-
blockedReaders.set(infoHash, next);
|
|
358
|
-
return;
|
|
359
|
-
}
|
|
360
|
-
blockedReaders.delete(infoHash);
|
|
361
|
-
}
|
|
362
|
-
|
|
363
254
|
function noteSupplyWait(key, label, waitedMs) {
|
|
364
255
|
const history = supplyWaits.get(key) ?? [];
|
|
365
256
|
history.push({ waitedMs, at: Date.now() });
|
|
@@ -427,9 +318,7 @@ export async function* readFragments({ torrent, fileIndex, start, end, cancellat
|
|
|
427
318
|
let retries = 0;
|
|
428
319
|
while (true) {
|
|
429
320
|
const waitAt = Date.now();
|
|
430
|
-
|
|
431
|
-
try { await whenPieceReady(torrent, index, cancellation, wanted); }
|
|
432
|
-
finally { countBlockedReader(torrent.infoHash, -1); }
|
|
321
|
+
await whenPieceReady(torrent, index, cancellation, wanted);
|
|
433
322
|
const waitedMs = Date.now() - waitAt;
|
|
434
323
|
if (index > first && waitedMs >= PIECE_WAIT_LOG_MS) {
|
|
435
324
|
const key = `${torrent.infoHash ?? "?"}/${file.name ?? "?"}`;
|
|
@@ -27,11 +27,12 @@
|
|
|
27
27
|
* standard interface outside, ownership transfer inside. Callers cannot tell
|
|
28
28
|
* the difference; the cost is a tenth of a percent of a segment's playing time.
|
|
29
29
|
*
|
|
30
|
-
*
|
|
31
|
-
* 64 KB chunks would spend 13 ms per segment on overhead
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* and
|
|
30
|
+
* The unit of one message follows from the same measurements: a round trip
|
|
31
|
+
* costs ~100 µs, so 64 KB chunks would spend 13 ms per segment on overhead
|
|
32
|
+
* against 0.5 ms sent whole. A read crosses as one message per PIECE fragment —
|
|
33
|
+
* its position in shared memory, not its bytes (`piece-reader.js`) — so a
|
|
34
|
+
* segment of a few pieces is a few messages, and a read is still cancelled at
|
|
35
|
+
* the next fragment.
|
|
35
36
|
*
|
|
36
37
|
* Torrent objects cannot cross a thread boundary, so the main thread names them
|
|
37
38
|
* by `sourceKey` (the identifier the registry already uses) and the worker owns
|
|
@@ -164,11 +165,6 @@ export const Event = {
|
|
|
164
165
|
HOLDINGS_CHANGED: "holdings-changed"
|
|
165
166
|
};
|
|
166
167
|
|
|
167
|
-
/**
|
|
168
|
-
* Bytes per CHUNK message. See the file header for why 1 MB.
|
|
169
|
-
*/
|
|
170
|
-
export const STREAM_CHUNK_BYTES = 1024 * 1024;
|
|
171
|
-
|
|
172
168
|
/**
|
|
173
169
|
* How many chunks may be in flight before the worker waits for an acknowledgement.
|
|
174
170
|
*
|
|
@@ -217,11 +217,11 @@ function sendFragment(id, fragment) {
|
|
|
217
217
|
/**
|
|
218
218
|
* Stream a byte range back as CHUNK messages.
|
|
219
219
|
*
|
|
220
|
-
* Reads
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
220
|
+
* Reads the range piece by piece (`readFragments`), which waits for what has
|
|
221
|
+
* not arrived, and forwards each fragment as its position in shared memory, so
|
|
222
|
+
* nothing is copied across the boundary. `createSendStream` applies the
|
|
223
|
+
* backpressure, so a fast disk cannot outrun the main thread and rebuild the
|
|
224
|
+
* queue in memory.
|
|
225
225
|
*
|
|
226
226
|
* @param {object} params
|
|
227
227
|
* @param {number} params.id - Request id; CHUNK/READ_END carry it.
|
|
@@ -133,7 +133,7 @@ export function allowedGap({
|
|
|
133
133
|
* echoIntervalMs?: number, peerLoopLagMs?: number, intervalMs?: number }} state
|
|
134
134
|
* @returns {number}
|
|
135
135
|
*/
|
|
136
|
-
|
|
136
|
+
function allowedWaitMs({
|
|
137
137
|
queuedBytes,
|
|
138
138
|
bytesPerSecond,
|
|
139
139
|
rttMs,
|
|
@@ -40,7 +40,7 @@ import os from "node:os";
|
|
|
40
40
|
import path from "node:path";
|
|
41
41
|
|
|
42
42
|
/** Per-packet capture length. Small: headers are what the signatures need. */
|
|
43
|
-
|
|
43
|
+
const WITNESS_SNAPLEN_BYTES = 128;
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
46
|
* usrsctp's maximum retransmission timeout, seconds (its `RTO.max` default).
|
|
@@ -66,19 +66,19 @@ export const WITNESS_RING_FILE_MB = 16;
|
|
|
66
66
|
export const WITNESS_RING_FILES = 4;
|
|
67
67
|
|
|
68
68
|
/** Megabytes per tail file. A wedged session emits a few packets a second. */
|
|
69
|
-
|
|
69
|
+
const WITNESS_TAIL_FILE_MB = 8;
|
|
70
70
|
|
|
71
71
|
/** How many tail files may be written before tcpdump stops on its own. */
|
|
72
|
-
|
|
72
|
+
const WITNESS_TAIL_FILES = 2;
|
|
73
73
|
|
|
74
74
|
/** Extra time before the SIGKILL fallback lands on a hanging tcpdump. */
|
|
75
|
-
|
|
75
|
+
const WITNESS_KILL_GRACE_MS = 5_000;
|
|
76
76
|
|
|
77
77
|
/** Minimum spacing between tail captures, whatever the reason for them. */
|
|
78
78
|
export const WITNESS_COOLDOWN_MS = 10 * 60_000;
|
|
79
79
|
|
|
80
80
|
/** How many old captures survive at startup, newest first. */
|
|
81
|
-
|
|
81
|
+
const WITNESS_CAPTURES_KEPT = 8;
|
|
82
82
|
|
|
83
83
|
/** Base name of the rolling ring, before tcpdump appends its file number. */
|
|
84
84
|
export const WITNESS_RING_BASENAME = "packet-witness-ring.pcap";
|
|
@@ -39,7 +39,7 @@ const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
|
39
39
|
export const SCTPSTATE_SCRIPT_PATH = path.join(HERE, "..", "..", "assets", "diagnostics", "sctpstate.gdb");
|
|
40
40
|
|
|
41
41
|
/** How long gdb may run before it is killed. Generous: this is a rare, one-shot read. */
|
|
42
|
-
|
|
42
|
+
const GDB_TIMEOUT_MS = 15_000;
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
45
|
* Create the reader.
|
|
@@ -95,10 +95,10 @@
|
|
|
95
95
|
* Direction, urgent preparation and attenuation are separate map fields;
|
|
96
96
|
* priority alone cannot distinguish an attenuated second from a passed one.
|
|
97
97
|
*/
|
|
98
|
-
|
|
98
|
+
const AT_A_WATCHING_VIEWER = 100;
|
|
99
99
|
|
|
100
100
|
/** Behind everybody. */
|
|
101
|
-
|
|
101
|
+
const NOBODY_IS_COMING = 1;
|
|
102
102
|
|
|
103
103
|
/** Nothing is wanted here at all. */
|
|
104
104
|
const NOTHING = 0;
|
|
@@ -401,31 +401,3 @@ export function inWorkingOrder(runs) {
|
|
|
401
401
|
(left, right) => right.priority - left.priority || left.from - right.from
|
|
402
402
|
);
|
|
403
403
|
}
|
|
404
|
-
|
|
405
|
-
/**
|
|
406
|
-
* The earliest second of film anybody is waiting on, or null when nobody is.
|
|
407
|
-
*
|
|
408
|
-
* WHY A SOUNDTRACK ASKS THE MAP AND NOT A VIEWER. Sound is produced for the
|
|
409
|
-
* same reason a picture is — somebody is about to reach it — and the map is
|
|
410
|
-
* where that is already stated, in seconds of film, merged over everybody
|
|
411
|
-
* watching. Reading it from a viewer instead means the encoding layer holds a
|
|
412
|
-
* person, and then every question about which person it is (the earliest of
|
|
413
|
-
* two, the one on this rung, the one who has not reported yet) has to be
|
|
414
|
-
* answered again in a second place. The map answers it once.
|
|
415
|
-
*
|
|
416
|
-
* The EARLIEST among the most urgent, because a track started at the leader's
|
|
417
|
-
* position has nothing to give the viewer behind them, and the map's own order
|
|
418
|
-
* — most urgent first, earliest film first within a priority — already says
|
|
419
|
-
* which that is.
|
|
420
|
-
*
|
|
421
|
-
* @param {DemandZone[]} runs - From {@link runsOf}.
|
|
422
|
-
* @returns {number | null} Seconds.
|
|
423
|
-
*/
|
|
424
|
-
export function earliestUrgentSecond(runs) {
|
|
425
|
-
const wanted = (runs ?? []).filter((zone) => zone && !zone.behind);
|
|
426
|
-
if (wanted.length === 0) {
|
|
427
|
-
return null;
|
|
428
|
-
}
|
|
429
|
-
const top = Math.max(...wanted.map((zone) => Number(zone.priority) || 0));
|
|
430
|
-
return Math.min(...wanted.filter((zone) => (Number(zone.priority) || 0) === top).map((zone) => zone.from));
|
|
431
|
-
}
|
|
@@ -319,20 +319,6 @@ export function visiblePictureOf(viewers, output, consumerId) {
|
|
|
319
319
|
return size ? { ...size } : null;
|
|
320
320
|
}
|
|
321
321
|
|
|
322
|
-
/**
|
|
323
|
-
* Take the picture as this viewer's page measured it, sent with the request
|
|
324
|
-
* that opened their output.
|
|
325
|
-
*
|
|
326
|
-
* @param {object} viewers
|
|
327
|
-
* @param {string} consumerId
|
|
328
|
-
* @param {unknown} size
|
|
329
|
-
* @returns {void}
|
|
330
|
-
*/
|
|
331
|
-
export function noteVisiblePicture(viewers, consumerId, size) {
|
|
332
|
-
const viewer = consumerId ? viewers.get(consumerId) : null;
|
|
333
|
-
viewer?.noteVisiblePicture(size);
|
|
334
|
-
}
|
|
335
|
-
|
|
336
322
|
/**
|
|
337
323
|
* Which way this viewer's buffer is going, and how much film it holds, as their
|
|
338
324
|
* page last said (`viewer/buffer-trend.js`).
|
|
@@ -162,7 +162,7 @@ export function subtract(left, right) {
|
|
|
162
162
|
* @param {bigint} factor
|
|
163
163
|
* @returns {MediaTime}
|
|
164
164
|
*/
|
|
165
|
-
|
|
165
|
+
function multiply(time, factor) {
|
|
166
166
|
return { ticks: time.ticks * factor, timescale: time.timescale };
|
|
167
167
|
}
|
|
168
168
|
|