@torrent-tv/proxy 2.88.6 → 2.89.0
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 +21 -12
- package/CHANGELOG.md +17 -3
- package/assets/calibration/audio/NOTICE.md +19 -19
- package/assets/calibration/audio/manifest.json +98 -98
- package/docs/container-architecture.md +77 -22
- package/docs/playback-start-forecast.md +56 -56
- package/main2.cpuprofile +1 -0
- package/package.json +1 -1
- package/scripts/build-audio-samples.mjs +31 -31
- package/server.js +42 -27
- package/services/encode/OutputOpening.js +5 -4
- package/services/encode/audio-calibration.js +67 -67
- package/services/encode/audio-work-rate.js +15 -15
- package/services/media/ContainerOrchestrator.js +99 -46
- package/services/media/KeyframeTables.js +61 -21
- package/services/media/SubtitleCues.js +255 -183
- package/services/media/container/AviContainer.js +5 -3
- package/services/media/container/Container.js +66 -43
- package/services/media/container/ContainerFactory.js +8 -26
- package/services/media/container/MatroskaContainer.js +719 -1182
- package/services/media/container/Mp4Container.js +83 -73
- package/services/media/container/ebml-stream.js +174 -0
- package/services/media/container/matroska-clusters.js +1165 -0
- package/services/media/container/unavailable.js +77 -0
- package/services/media/tracks/ContainerTrack.js +9 -0
- package/services/media/tracks/TextSubtitleTrack.js +9 -1
- package/services/server/playback-readiness.js +516 -516
- package/services/torrent/worker/client.js +6 -2
- package/services/torrent/worker/held-bytes.js +84 -8
- package/services/torrent/worker/worker.js +10 -2
- package/services/transport/data-channel-handler.js +7 -3
- package/services/viewer/PriorityMap.js +31 -0
- package/test/audio-calibration.test.js +23 -23
- package/test/coalesce.test.js +39 -0
- package/test/held-read-ownership.test.js +77 -0
- package/test/helpers/held-file.js +50 -2
- package/test/helpers/matroska-file.js +296 -0
- package/test/keyframe-table-budget.test.js +21 -0
- package/test/matroska-cluster-discovery.test.js +323 -0
- package/test/reads-not-yet-arrived.test.js +218 -0
- package/test/subtitle-cue-walk.test.js +16 -1
- package/test/subtitle-plan-waits.test.js +125 -0
- package/test/subtitle-track-numbering.test.js +8 -3
- package/test/viewer-starts-on-map.test.js +27 -0
- package/utils/coalesce.js +41 -0
|
@@ -1,31 +1,31 @@
|
|
|
1
|
-
/** Build-time only. No sample generation runs when the proxy starts. */
|
|
2
|
-
import { spawnSync } from "node:child_process";
|
|
3
|
-
import { mkdirSync, writeFileSync } from "node:fs";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
6
|
-
|
|
7
|
-
const directory = fileURLToPath(new URL("../assets/calibration/audio/", import.meta.url));
|
|
8
|
-
const formats = [
|
|
9
|
-
["aac", "aac", "m4a"], ["ac3", "ac3", "ac3"], ["eac3", "eac3", "eac3"],
|
|
10
|
-
["dts", "dca", "dts"], ["flac", "flac", "flac"], ["opus", "libopus", "opus"],
|
|
11
|
-
["vorbis", "libvorbis", "ogg"], ["truehd", "truehd", "thd"], ["mp3", "libmp3lame", "mp3"],
|
|
12
|
-
["mp2", "mp2", "mp2"], ["alac", "alac", "m4a"], ["pcm", "pcm_s16le", "wav"]
|
|
13
|
-
];
|
|
14
|
-
mkdirSync(directory, { recursive: true });
|
|
15
|
-
const manifest = [];
|
|
16
|
-
for (const [codec, encoder, extension] of formats) {
|
|
17
|
-
const file = `${codec}.${extension}`;
|
|
18
|
-
const result = spawnSync("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y",
|
|
19
|
-
"-f", "lavfi", "-i", "anoisesrc=color=pink:seed=1:sample_rate=48000",
|
|
20
|
-
"-t", "4", "-ac", "2", "-strict", "-2", "-c:a", encoder, path.join(directory, file)],
|
|
21
|
-
{ encoding: "utf8", windowsHide: true });
|
|
22
|
-
if (result.status !== 0) throw new Error(`${codec}: ${result.stderr}`);
|
|
23
|
-
const probe = spawnSync("ffprobe", ["-v", "error", "-show_streams", "-show_format", "-of", "json",
|
|
24
|
-
path.join(directory, file)], { encoding: "utf8", windowsHide: true });
|
|
25
|
-
if (probe.status !== 0) throw new Error(`${codec}: ${probe.stderr}`);
|
|
26
|
-
const { streams, format } = JSON.parse(probe.stdout);
|
|
27
|
-
const stream = streams.find(({ codec_type }) => codec_type === "audio");
|
|
28
|
-
manifest.push({ codec, file, durationSeconds: Number(format.duration), channels: stream.channels,
|
|
29
|
-
samplingFrequency: Number(stream.sample_rate), bytes: Number(format.size) });
|
|
30
|
-
}
|
|
31
|
-
writeFileSync(path.join(directory, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
1
|
+
/** Build-time only. No sample generation runs when the proxy starts. */
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
const directory = fileURLToPath(new URL("../assets/calibration/audio/", import.meta.url));
|
|
8
|
+
const formats = [
|
|
9
|
+
["aac", "aac", "m4a"], ["ac3", "ac3", "ac3"], ["eac3", "eac3", "eac3"],
|
|
10
|
+
["dts", "dca", "dts"], ["flac", "flac", "flac"], ["opus", "libopus", "opus"],
|
|
11
|
+
["vorbis", "libvorbis", "ogg"], ["truehd", "truehd", "thd"], ["mp3", "libmp3lame", "mp3"],
|
|
12
|
+
["mp2", "mp2", "mp2"], ["alac", "alac", "m4a"], ["pcm", "pcm_s16le", "wav"]
|
|
13
|
+
];
|
|
14
|
+
mkdirSync(directory, { recursive: true });
|
|
15
|
+
const manifest = [];
|
|
16
|
+
for (const [codec, encoder, extension] of formats) {
|
|
17
|
+
const file = `${codec}.${extension}`;
|
|
18
|
+
const result = spawnSync("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y",
|
|
19
|
+
"-f", "lavfi", "-i", "anoisesrc=color=pink:seed=1:sample_rate=48000",
|
|
20
|
+
"-t", "4", "-ac", "2", "-strict", "-2", "-c:a", encoder, path.join(directory, file)],
|
|
21
|
+
{ encoding: "utf8", windowsHide: true });
|
|
22
|
+
if (result.status !== 0) throw new Error(`${codec}: ${result.stderr}`);
|
|
23
|
+
const probe = spawnSync("ffprobe", ["-v", "error", "-show_streams", "-show_format", "-of", "json",
|
|
24
|
+
path.join(directory, file)], { encoding: "utf8", windowsHide: true });
|
|
25
|
+
if (probe.status !== 0) throw new Error(`${codec}: ${probe.stderr}`);
|
|
26
|
+
const { streams, format } = JSON.parse(probe.stdout);
|
|
27
|
+
const stream = streams.find(({ codec_type }) => codec_type === "audio");
|
|
28
|
+
manifest.push({ codec, file, durationSeconds: Number(format.duration), channels: stream.channels,
|
|
29
|
+
samplingFrequency: Number(stream.sample_rate), bytes: Number(format.size) });
|
|
30
|
+
}
|
|
31
|
+
writeFileSync(path.join(directory, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
package/server.js
CHANGED
|
@@ -47,6 +47,8 @@ import { KeyframeTables } from "./services/media/KeyframeTables.js";
|
|
|
47
47
|
import { contentsOf } from "./services/torrent/Contents.js";
|
|
48
48
|
import { SubtitleOrchestrator } from "./services/media/SubtitleOrchestrator.js";
|
|
49
49
|
import { containerOrchestrator, CONTAINER_HEAD_BYTES } from "./services/media/ContainerOrchestrator.js";
|
|
50
|
+
import { viewerStartsOn } from "./services/viewer/PriorityMap.js";
|
|
51
|
+
import { coalescing } from "./utils/coalesce.js";
|
|
50
52
|
import {
|
|
51
53
|
warmSubtitleCues,
|
|
52
54
|
cuesHeldFor,
|
|
@@ -152,7 +154,10 @@ export async function startProxyServer({
|
|
|
152
154
|
// after the declarations below have run.
|
|
153
155
|
onPiecesArrived: ({ sourceKey, fileIndexes }) => {
|
|
154
156
|
for (const fileIndex of fileIndexes) {
|
|
155
|
-
void pushFreshCues(sourceKey, fileIndex);
|
|
157
|
+
void pushFreshCues(`${sourceKey}:${fileIndex}`, sourceKey, fileIndex);
|
|
158
|
+
// A keyframe table somebody asked for while its bytes had not arrived
|
|
159
|
+
// is read again now; nothing was recorded for that wait.
|
|
160
|
+
keyframeTables.readAgainIfUnanswered({ sourceKey, fileIndex, logName: String(fileIndex) });
|
|
156
161
|
}
|
|
157
162
|
}
|
|
158
163
|
});
|
|
@@ -263,9 +268,10 @@ export async function startProxyServer({
|
|
|
263
268
|
// rules. The media layer used to import the second directly.
|
|
264
269
|
/**
|
|
265
270
|
* One file of one torrent, reduced to what the subtitle walk may know: a
|
|
266
|
-
* name, a length, which ranges are downloaded whole,
|
|
267
|
-
* those without asking the swarm
|
|
268
|
-
*
|
|
271
|
+
* name, a length, its one container, which ranges are downloaded whole, how
|
|
272
|
+
* to read one of those without asking the swarm, the portion one read may
|
|
273
|
+
* span, and where the viewers stand. File offsets and the bitfield stay in
|
|
274
|
+
* the torrent's thread; the piece length crosses as a plain number.
|
|
269
275
|
*
|
|
270
276
|
* @param {string} sourceKey
|
|
271
277
|
* @param {number} fileIndex
|
|
@@ -290,30 +296,39 @@ export async function startProxyServer({
|
|
|
290
296
|
fileIndex,
|
|
291
297
|
name: String(file.name ?? ""),
|
|
292
298
|
length: file.length,
|
|
299
|
+
portionBytes: Number(torrent.pieceLength) > 0 ? Number(torrent.pieceLength) : undefined,
|
|
300
|
+
// The file's ONE container — the one the track table, the media info and
|
|
301
|
+
// the keyframe table are read from. Built here only when nobody has built
|
|
302
|
+
// it yet, so a pass of the walk does not fetch the file's edges again.
|
|
303
|
+
container: async () => {
|
|
304
|
+
const known = containerOrchestrator.known(sourceKey, fileIndex);
|
|
305
|
+
if (known !== undefined) {
|
|
306
|
+
return known;
|
|
307
|
+
}
|
|
308
|
+
const params = await containerOver({ sourceKey, fileIndex, tailBytes: CONTAINER_HEAD_BYTES });
|
|
309
|
+
return params ? containerOrchestrator.containerFor(params) : null;
|
|
310
|
+
},
|
|
293
311
|
heldRanges: () => torrentPool.heldRangesOf(torrent, fileIndex),
|
|
294
|
-
readHeld: (start, end) => torrentPool.readHeldOf(torrent, fileIndex, start, end)
|
|
312
|
+
readHeld: (start, end) => torrentPool.readHeldOf(torrent, fileIndex, start, end),
|
|
313
|
+
// Where the viewers of this file stand: the start of every urgent stretch
|
|
314
|
+
// of its priority map, which is where each viewer's own need begins.
|
|
315
|
+
wantedSeconds: () => viewerStartsOn(outputParts.priority?.mapFor(sourceKey, fileIndex)),
|
|
316
|
+
// A pass left readable clusters for the next one: run it.
|
|
317
|
+
askAgain: () => {
|
|
318
|
+
void pushFreshCues(`${sourceKey}:${fileIndex}`, sourceKey, fileIndex);
|
|
319
|
+
}
|
|
295
320
|
};
|
|
296
321
|
};
|
|
297
|
-
/** @type {Set<string>} */
|
|
298
|
-
const walksInFlight = new Set();
|
|
299
322
|
/**
|
|
300
323
|
* Walk whatever new cues a file now holds and push them to the viewers.
|
|
301
324
|
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
325
|
+
* One walk of a file at a time, and one more after it whenever pieces
|
|
326
|
+
* arrived meanwhile (`coalescing`): `verified` fires per piece, so a queue of
|
|
327
|
+
* identical passes would only postpone the one with something new to find,
|
|
328
|
+
* and dropping them — as this used to — lost the pass that would have found
|
|
329
|
+
* the last pieces of a file.
|
|
305
330
|
*/
|
|
306
|
-
const pushFreshCues = async (sourceKey, fileIndex) => {
|
|
307
|
-
// A pass that arrives while the previous one is still walking is dropped
|
|
308
|
-
// rather than queued: `verified` fires per piece, so on a fast download
|
|
309
|
-
// these arrive many times a second, and a queue of identical passes would
|
|
310
|
-
// only postpone the one that has something new to find. The walk is
|
|
311
|
-
// serialized per file inside `SubtitleCues` anyway.
|
|
312
|
-
const key = `${sourceKey}:${fileIndex}`;
|
|
313
|
-
if (walksInFlight.has(key)) {
|
|
314
|
-
return;
|
|
315
|
-
}
|
|
316
|
-
walksInFlight.add(key);
|
|
331
|
+
const pushFreshCues = coalescing(async (sourceKey, fileIndex) => {
|
|
317
332
|
try {
|
|
318
333
|
const file = await heldFileFor(sourceKey, fileIndex);
|
|
319
334
|
if (!file) {
|
|
@@ -325,17 +340,16 @@ export async function startProxyServer({
|
|
|
325
340
|
: `${entry.spanStartSeconds.toFixed(1)}-${entry.spanEndSeconds.toFixed(1)}s`;
|
|
326
341
|
logger.info(
|
|
327
342
|
`subtitle push ${sourceKey.slice(0, 8)}:${fileIndex} track ${entry.trackIndex}: ` +
|
|
328
|
-
`${entry.cues.length} new cue(s) covering ${span}
|
|
329
|
-
|
|
343
|
+
`${entry.cues.length} new cue(s) covering ${span}` +
|
|
344
|
+
(entry.withdrawn.length > 0 ? `, ${entry.withdrawn.length} taken back` : "") +
|
|
345
|
+
`, clusters walked ${entry.walkedClusters}/${entry.indexedClusters}, cursor ${entry.cursor}`
|
|
330
346
|
);
|
|
331
347
|
onSubtitleCues?.({ sourceKey, fileIndex, ...entry });
|
|
332
348
|
}
|
|
333
349
|
} catch (error) {
|
|
334
350
|
logger.warn(`subtitle push ${sourceKey.slice(0, 8)}:${fileIndex} failed: ${error?.message ?? error}`);
|
|
335
|
-
} finally {
|
|
336
|
-
walksInFlight.delete(key);
|
|
337
351
|
}
|
|
338
|
-
};
|
|
352
|
+
});
|
|
339
353
|
const subtitles = new SubtitleOrchestrator(containerOrchestrator, {
|
|
340
354
|
warm: async (torrent, fileIndex, sourceKey) => {
|
|
341
355
|
const file = await heldFileFor(sourceKey, fileIndex, torrent);
|
|
@@ -396,7 +410,8 @@ export async function startProxyServer({
|
|
|
396
410
|
readRange: (start, end) =>
|
|
397
411
|
torrentPool.readRangeOf(torrent, fileIndex, start, Math.min(end, file.length - 1)),
|
|
398
412
|
fileSize: file.length,
|
|
399
|
-
label: String(file.name ?? "")
|
|
413
|
+
label: String(file.name ?? ""),
|
|
414
|
+
portionBytes: Number(torrent.pieceLength) > 0 ? Number(torrent.pieceLength) : undefined
|
|
400
415
|
};
|
|
401
416
|
};
|
|
402
417
|
const keyframeTables = new KeyframeTables({
|
|
@@ -449,15 +449,16 @@ export class OutputOpening {
|
|
|
449
449
|
const { arrived } = await this.#host.keyframeTables.within({ sourceKey, fileIndex, logName });
|
|
450
450
|
keyframeMs = Date.now() - keyframeStartMs;
|
|
451
451
|
if (!arrived) {
|
|
452
|
-
// A read that ran out of its budget is still running, and
|
|
453
|
-
//
|
|
452
|
+
// A read that ran out of its budget is still running, and one that
|
|
453
|
+
// found its bytes not downloaded yet is made again when they arrive;
|
|
454
|
+
// either way the table is still unanswered — which is not the same as a file with no keyframes,
|
|
454
455
|
// and the distinction is the table's own (`answered` against
|
|
455
456
|
// `readable`). Recorded as an absence it would make a passing shortage
|
|
456
457
|
// of bytes look like a property of the bytes, and every later session
|
|
457
458
|
// of the file would re-encode a picture that can be copied.
|
|
458
459
|
this.#host.logger.warn(
|
|
459
|
-
`transcode: the keyframe table for "${logName}" has not arrived
|
|
460
|
-
|
|
460
|
+
`transcode: the keyframe table for "${logName}" has not arrived after ${keyframeMs}ms ` +
|
|
461
|
+
`(budget ${Math.round(this.#host.keyframeTables.budgetMs / 1000)}s), so this session re-encodes the picture ` +
|
|
461
462
|
"instead of copying it; the read goes on and the next session of this file gets the copy"
|
|
462
463
|
);
|
|
463
464
|
}
|
|
@@ -1,67 +1,67 @@
|
|
|
1
|
-
/** Short startup measurements of packaged audio samples. */
|
|
2
|
-
import { spawn } from "node:child_process";
|
|
3
|
-
import { readFile } from "node:fs/promises";
|
|
4
|
-
import { fileURLToPath } from "node:url";
|
|
5
|
-
import path from "node:path";
|
|
6
|
-
import { AUDIO_TRANSCODE_KBPS } from "./args.js";
|
|
7
|
-
import { ENCODE_BENCHMARK_TIMEOUT_MS } from "./hwaccel.js";
|
|
8
|
-
|
|
9
|
-
const SAMPLE_DIRECTORY = fileURLToPath(new URL("../../assets/calibration/audio/", import.meta.url));
|
|
10
|
-
|
|
11
|
-
function measure(ffmpegBin, file, operation, signal) {
|
|
12
|
-
return new Promise((resolve) => {
|
|
13
|
-
const startedAt = performance.now();
|
|
14
|
-
let mediaSeconds = 0;
|
|
15
|
-
let text = "";
|
|
16
|
-
let diagnostics = "";
|
|
17
|
-
const child = spawn(ffmpegBin, ["-hide_banner", "-loglevel", "info", "-nostats", "-benchmark",
|
|
18
|
-
"-i", file, "-map", "0:a:0", "-vn", "-c:a", operation,
|
|
19
|
-
...(operation === "aac" ? ["-b:a", `${AUDIO_TRANSCODE_KBPS}k`, "-ac", "2"] : []),
|
|
20
|
-
"-f", "null", "-", "-progress", "pipe:1"],
|
|
21
|
-
{ stdio: ["ignore", "pipe", "pipe"], windowsHide: true,
|
|
22
|
-
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(ENCODE_BENCHMARK_TIMEOUT_MS)]) :
|
|
23
|
-
AbortSignal.timeout(ENCODE_BENCHMARK_TIMEOUT_MS) });
|
|
24
|
-
child.stdout.on("data", (data) => {
|
|
25
|
-
text += String(data);
|
|
26
|
-
const rows = text.split("\n");
|
|
27
|
-
text = rows.pop();
|
|
28
|
-
for (const row of rows) {
|
|
29
|
-
if (row.startsWith("out_time_us=")) {
|
|
30
|
-
const value = Number(row.slice("out_time_us=".length)) / 1e6;
|
|
31
|
-
if (Number.isFinite(value) && value > 0) mediaSeconds = value;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
});
|
|
35
|
-
child.stderr.on("data", (data) => { diagnostics += String(data); });
|
|
36
|
-
child.on("error", () => resolve(null));
|
|
37
|
-
child.on("close", (code) => {
|
|
38
|
-
const elapsed = (performance.now() - startedAt) / 1000;
|
|
39
|
-
const runtime = diagnostics.match(/bench:.*rtime=(\d+\.(\d+))s/);
|
|
40
|
-
// The reported decimal precision bounds timing uncertainty, including a
|
|
41
|
-
// runtime printed as zero. This is a clock-resolution bound, not a weight.
|
|
42
|
-
const processingSeconds = runtime ? Number(runtime[1]) + 10 ** -runtime[2].length : null;
|
|
43
|
-
resolve(code === 0 && mediaSeconds > 0 && processingSeconds > 0 ?
|
|
44
|
-
{ speed: mediaSeconds / processingSeconds, work: mediaSeconds, elapsed, processingSeconds,
|
|
45
|
-
startupSeconds: Math.max(0, elapsed - processingSeconds) } : null);
|
|
46
|
-
});
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export async function benchmarkAudio({ ffmpegBin, logger, signal, sampleDirectory = SAMPLE_DIRECTORY }) {
|
|
51
|
-
const samples = JSON.parse(await readFile(path.join(sampleDirectory, "manifest.json"), "utf8"));
|
|
52
|
-
const readings = [];
|
|
53
|
-
const startedAt = performance.now();
|
|
54
|
-
for (const sample of samples) {
|
|
55
|
-
for (const operation of ["copy", "aac"]) {
|
|
56
|
-
if (signal?.aborted) return readings;
|
|
57
|
-
const reading = await measure(ffmpegBin, path.join(sampleDirectory, sample.file), operation, signal);
|
|
58
|
-
if (reading) readings.push({ ...sample, operation, ...reading });
|
|
59
|
-
else logger?.warn(`audio calibration ${sample.codec}->${operation} could not be measured`);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
logger?.info(`audio calibration: ${readings.length} short packaged-sample measurements in ` +
|
|
63
|
-
`${((performance.now() - startedAt) / 1000).toFixed(2)}s`);
|
|
64
|
-
return readings;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export { audioReadingFor } from "./audio-work-rate.js";
|
|
1
|
+
/** Short startup measurements of packaged audio samples. */
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { AUDIO_TRANSCODE_KBPS } from "./args.js";
|
|
7
|
+
import { ENCODE_BENCHMARK_TIMEOUT_MS } from "./hwaccel.js";
|
|
8
|
+
|
|
9
|
+
const SAMPLE_DIRECTORY = fileURLToPath(new URL("../../assets/calibration/audio/", import.meta.url));
|
|
10
|
+
|
|
11
|
+
function measure(ffmpegBin, file, operation, signal) {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
const startedAt = performance.now();
|
|
14
|
+
let mediaSeconds = 0;
|
|
15
|
+
let text = "";
|
|
16
|
+
let diagnostics = "";
|
|
17
|
+
const child = spawn(ffmpegBin, ["-hide_banner", "-loglevel", "info", "-nostats", "-benchmark",
|
|
18
|
+
"-i", file, "-map", "0:a:0", "-vn", "-c:a", operation,
|
|
19
|
+
...(operation === "aac" ? ["-b:a", `${AUDIO_TRANSCODE_KBPS}k`, "-ac", "2"] : []),
|
|
20
|
+
"-f", "null", "-", "-progress", "pipe:1"],
|
|
21
|
+
{ stdio: ["ignore", "pipe", "pipe"], windowsHide: true,
|
|
22
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(ENCODE_BENCHMARK_TIMEOUT_MS)]) :
|
|
23
|
+
AbortSignal.timeout(ENCODE_BENCHMARK_TIMEOUT_MS) });
|
|
24
|
+
child.stdout.on("data", (data) => {
|
|
25
|
+
text += String(data);
|
|
26
|
+
const rows = text.split("\n");
|
|
27
|
+
text = rows.pop();
|
|
28
|
+
for (const row of rows) {
|
|
29
|
+
if (row.startsWith("out_time_us=")) {
|
|
30
|
+
const value = Number(row.slice("out_time_us=".length)) / 1e6;
|
|
31
|
+
if (Number.isFinite(value) && value > 0) mediaSeconds = value;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
child.stderr.on("data", (data) => { diagnostics += String(data); });
|
|
36
|
+
child.on("error", () => resolve(null));
|
|
37
|
+
child.on("close", (code) => {
|
|
38
|
+
const elapsed = (performance.now() - startedAt) / 1000;
|
|
39
|
+
const runtime = diagnostics.match(/bench:.*rtime=(\d+\.(\d+))s/);
|
|
40
|
+
// The reported decimal precision bounds timing uncertainty, including a
|
|
41
|
+
// runtime printed as zero. This is a clock-resolution bound, not a weight.
|
|
42
|
+
const processingSeconds = runtime ? Number(runtime[1]) + 10 ** -runtime[2].length : null;
|
|
43
|
+
resolve(code === 0 && mediaSeconds > 0 && processingSeconds > 0 ?
|
|
44
|
+
{ speed: mediaSeconds / processingSeconds, work: mediaSeconds, elapsed, processingSeconds,
|
|
45
|
+
startupSeconds: Math.max(0, elapsed - processingSeconds) } : null);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function benchmarkAudio({ ffmpegBin, logger, signal, sampleDirectory = SAMPLE_DIRECTORY }) {
|
|
51
|
+
const samples = JSON.parse(await readFile(path.join(sampleDirectory, "manifest.json"), "utf8"));
|
|
52
|
+
const readings = [];
|
|
53
|
+
const startedAt = performance.now();
|
|
54
|
+
for (const sample of samples) {
|
|
55
|
+
for (const operation of ["copy", "aac"]) {
|
|
56
|
+
if (signal?.aborted) return readings;
|
|
57
|
+
const reading = await measure(ffmpegBin, path.join(sampleDirectory, sample.file), operation, signal);
|
|
58
|
+
if (reading) readings.push({ ...sample, operation, ...reading });
|
|
59
|
+
else logger?.warn(`audio calibration ${sample.codec}->${operation} could not be measured`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
logger?.info(`audio calibration: ${readings.length} short packaged-sample measurements in ` +
|
|
63
|
+
`${((performance.now() - startedAt) / 1000).toFixed(2)}s`);
|
|
64
|
+
return readings;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export { audioReadingFor } from "./audio-work-rate.js";
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
/** Initial per-codec work model, replaced by the track's actual speed. */
|
|
2
|
-
export function audioReadingFor(readings, { codec, transcode, channels, samplingFrequency, bitrateKbps }) {
|
|
3
|
-
const operation = transcode ? "aac" : "copy";
|
|
4
|
-
const normalized = String(codec ?? "").replace(/^pcm_.*/, "pcm");
|
|
5
|
-
const reference = readings?.find((reading) => reading.codec === normalized && reading.operation === operation);
|
|
6
|
-
if (!reference) return null;
|
|
7
|
-
const referenceBytesPerSecond = reference.bytes / reference.durationSeconds;
|
|
8
|
-
// Copy work is encoded bytes; processing work is audio samples. Unstated
|
|
9
|
-
// dimensions retain the reference measurement, rather than invented values.
|
|
10
|
-
const workRatio = !transcode && bitrateKbps > 0 ? (bitrateKbps * 1000 / 8) / referenceBytesPerSecond :
|
|
11
|
-
transcode && channels > 0 && samplingFrequency > 0 ?
|
|
12
|
-
(channels * samplingFrequency) / (reference.channels * reference.samplingFrequency) : 1;
|
|
13
|
-
return { ...reference, speed: reference.speed / workRatio,
|
|
14
|
-
basis: workRatio === 1 ? "measured-reference" : transcode ? "audio-samples" : "encoded-bytes" };
|
|
15
|
-
}
|
|
1
|
+
/** Initial per-codec work model, replaced by the track's actual speed. */
|
|
2
|
+
export function audioReadingFor(readings, { codec, transcode, channels, samplingFrequency, bitrateKbps }) {
|
|
3
|
+
const operation = transcode ? "aac" : "copy";
|
|
4
|
+
const normalized = String(codec ?? "").replace(/^pcm_.*/, "pcm");
|
|
5
|
+
const reference = readings?.find((reading) => reading.codec === normalized && reading.operation === operation);
|
|
6
|
+
if (!reference) return null;
|
|
7
|
+
const referenceBytesPerSecond = reference.bytes / reference.durationSeconds;
|
|
8
|
+
// Copy work is encoded bytes; processing work is audio samples. Unstated
|
|
9
|
+
// dimensions retain the reference measurement, rather than invented values.
|
|
10
|
+
const workRatio = !transcode && bitrateKbps > 0 ? (bitrateKbps * 1000 / 8) / referenceBytesPerSecond :
|
|
11
|
+
transcode && channels > 0 && samplingFrequency > 0 ?
|
|
12
|
+
(channels * samplingFrequency) / (reference.channels * reference.samplingFrequency) : 1;
|
|
13
|
+
return { ...reference, speed: reference.speed / workRatio,
|
|
14
|
+
basis: workRatio === 1 ? "measured-reference" : transcode ? "audio-samples" : "encoded-bytes" };
|
|
15
|
+
}
|
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file Container orchestrator — application layer over Container domain.
|
|
3
3
|
*
|
|
4
|
-
* Holds
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Holds ONE Container per file (key sourceKey:fileIndex), and every reader of a
|
|
5
|
+
* file's statements — the track table, the media info, the keyframe table, the
|
|
6
|
+
* subtitle plan and the cue walk — goes through that one. The subtitle walk used
|
|
7
|
+
* to build a second container over the same file with readers of its own, which
|
|
8
|
+
* read the head and the Cues table a second time under a different rule for a
|
|
9
|
+
* read that had not arrived (`research/subtitles-never-appear-2026-10-01.md`).
|
|
10
|
+
*
|
|
11
|
+
* What is kept, and what is not: a container is kept once the bytes have said
|
|
12
|
+
* what format they are (including "none this proxy knows"); a head that has not
|
|
13
|
+
* arrived throws `BytesUnavailable` and leaves nothing behind, so the next ask
|
|
14
|
+
* reads again. Delegates format detection to ContainerFactory. Transport-
|
|
15
|
+
* agnostic — takes readRange, knows nothing about torrents or HTTP.
|
|
8
16
|
*/
|
|
9
17
|
|
|
10
18
|
import { ContainerFactory } from "./container/ContainerFactory.js";
|
|
19
|
+
import { isUnavailable } from "./container/unavailable.js";
|
|
20
|
+
import { logger } from "../../utils/logger.js";
|
|
11
21
|
|
|
12
22
|
/**
|
|
13
23
|
* How much of a file's head its track table lives in.
|
|
@@ -17,7 +27,6 @@ import { ContainerFactory } from "./container/ContainerFactory.js";
|
|
|
17
27
|
* fetches bytes before asking is told this figure rather than choosing one.
|
|
18
28
|
*/
|
|
19
29
|
export const CONTAINER_HEAD_BYTES = 256 * 1024;
|
|
20
|
-
import { logger } from "../../utils/logger.js";
|
|
21
30
|
|
|
22
31
|
export class ContainerOrchestrator {
|
|
23
32
|
constructor() {
|
|
@@ -26,14 +35,11 @@ export class ContainerOrchestrator {
|
|
|
26
35
|
/** @type {Map<string, Promise<import("./container/Container.js").Container|null>>} */
|
|
27
36
|
this.pending = new Map();
|
|
28
37
|
/**
|
|
29
|
-
* What each file's container declares, once it has said
|
|
38
|
+
* What each file's container declares, once it has said it.
|
|
30
39
|
*
|
|
31
|
-
* The
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* be kept in the torrent thread (`torrent-worker/container-tracks.js`), which is where the
|
|
35
|
-
* parse used to happen; it is the same one cache in its new place, not a
|
|
36
|
-
* second one.
|
|
40
|
+
* The track table is asked for the audio menu, the subtitle defaults and
|
|
41
|
+
* the video facts of one file; the container keeps its own reading, and
|
|
42
|
+
* this keeps the answer so the question costs nothing the second time.
|
|
37
43
|
*
|
|
38
44
|
* @type {Map<string, import("./tracks/index.js").ContainerTrack[]>}
|
|
39
45
|
*/
|
|
@@ -41,36 +47,78 @@ export class ContainerOrchestrator {
|
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
/**
|
|
50
|
+
* The container already built for a file, if any — without reading anything.
|
|
51
|
+
*
|
|
52
|
+
* `undefined` means none has been built yet; `null` means the bytes were read
|
|
53
|
+
* and are no format this proxy knows.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} sourceKey
|
|
56
|
+
* @param {number} fileIndex
|
|
57
|
+
* @returns {import("./container/Container.js").Container | null | undefined}
|
|
58
|
+
*/
|
|
59
|
+
known(sourceKey, fileIndex) {
|
|
60
|
+
return this.cache.get(`${sourceKey}:${fileIndex}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The file's one container, built on first ask.
|
|
65
|
+
*
|
|
44
66
|
* @param {object} params
|
|
45
67
|
* @param {string} params.sourceKey
|
|
46
68
|
* @param {number} params.fileIndex
|
|
47
69
|
* @param {(start:number,end:number)=>Promise<Buffer|null>} params.readRange
|
|
48
70
|
* @param {number} params.fileSize
|
|
49
71
|
* @param {string} [params.label]
|
|
72
|
+
* @param {number} [params.portionBytes]
|
|
50
73
|
* @returns {Promise<import("./container/Container.js").Container|null>}
|
|
74
|
+
* @throws {import("./container/unavailable.js").BytesUnavailable} While the
|
|
75
|
+
* head has not arrived. Nothing is kept for it.
|
|
51
76
|
*/
|
|
52
|
-
async
|
|
77
|
+
async containerFor({ sourceKey, fileIndex, readRange, fileSize, label = "", portionBytes }) {
|
|
53
78
|
const key = `${sourceKey}:${fileIndex}`;
|
|
54
79
|
if (this.cache.has(key)) return this.cache.get(key);
|
|
55
80
|
if (this.pending.has(key)) return this.pending.get(key);
|
|
56
|
-
const p = ContainerFactory.create({ readRange, fileSize, label })
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
81
|
+
const p = ContainerFactory.create({ readRange, fileSize, label, portionBytes })
|
|
82
|
+
.then((c) => {
|
|
83
|
+
this.cache.set(key, c);
|
|
84
|
+
if (c) logger.info(`container: ${c.formatName} for "${label}"`);
|
|
85
|
+
else logger.info(`container: unknown for "${label}"`);
|
|
86
|
+
return c;
|
|
87
|
+
})
|
|
88
|
+
.catch((e) => {
|
|
89
|
+
if (!isUnavailable(e)) {
|
|
90
|
+
logger.warn(`container: failed for "${label}": ${e?.message ?? e}`);
|
|
91
|
+
}
|
|
92
|
+
throw e;
|
|
93
|
+
})
|
|
94
|
+
.finally(() => {
|
|
95
|
+
this.pending.delete(key);
|
|
96
|
+
});
|
|
67
97
|
this.pending.set(key, p);
|
|
68
98
|
return p;
|
|
69
99
|
}
|
|
70
100
|
|
|
71
101
|
/**
|
|
72
|
-
*
|
|
73
|
-
*
|
|
102
|
+
* The same, answering null rather than throwing while the head is not here —
|
|
103
|
+
* for the callers whose own answer to "not yet" is "nothing yet".
|
|
104
|
+
*
|
|
105
|
+
* @param {object} params - same as containerFor
|
|
106
|
+
* @returns {Promise<import("./container/Container.js").Container|null>}
|
|
107
|
+
*/
|
|
108
|
+
async getContainer(params) {
|
|
109
|
+
try {
|
|
110
|
+
return await this.containerFor(params);
|
|
111
|
+
} catch {
|
|
112
|
+
// `containerFor` has already said what failed, where it was not a
|
|
113
|
+
// shortage of bytes; either way nothing was kept.
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {object} params - same as containerFor
|
|
120
|
+
* @returns {Promise<import("./tracks/index.js").ContainerTrack[]>} Empty while
|
|
121
|
+
* the bytes have not arrived; that empty answer is not kept.
|
|
74
122
|
*/
|
|
75
123
|
async getTracks(params) {
|
|
76
124
|
const key = `${params.sourceKey}:${params.fileIndex}`;
|
|
@@ -78,21 +126,19 @@ export class ContainerOrchestrator {
|
|
|
78
126
|
if (known) {
|
|
79
127
|
return known;
|
|
80
128
|
}
|
|
81
|
-
const container = await this.getContainer(params);
|
|
82
|
-
if (!container) return [];
|
|
83
129
|
try {
|
|
130
|
+
const container = await this.containerFor(params);
|
|
131
|
+
if (!container) return [];
|
|
84
132
|
const tracks = await container.readTracks();
|
|
85
|
-
|
|
86
|
-
// has not arrived off the swarm yet, and caching that would answer "this
|
|
87
|
-
// file declares nothing" for the life of the process — which is what
|
|
88
|
-
// decides whether the viewer is offered a soundtrack at all.
|
|
89
|
-
if (Array.isArray(tracks) && tracks.length > 0) {
|
|
133
|
+
if (Array.isArray(tracks)) {
|
|
90
134
|
this.tracks.set(key, tracks);
|
|
91
135
|
return tracks;
|
|
92
136
|
}
|
|
93
|
-
return
|
|
137
|
+
return [];
|
|
94
138
|
} catch (e) {
|
|
95
|
-
|
|
139
|
+
if (!isUnavailable(e)) {
|
|
140
|
+
logger.warn(`container: readTracks failed for "${params.label}": ${e?.message ?? e}`);
|
|
141
|
+
}
|
|
96
142
|
return [];
|
|
97
143
|
}
|
|
98
144
|
}
|
|
@@ -101,34 +147,41 @@ export class ContainerOrchestrator {
|
|
|
101
147
|
* What the file declares about itself as a whole — format, duration, and
|
|
102
148
|
* where its own timeline begins.
|
|
103
149
|
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
150
|
+
* A `null` field means the container does not declare it, which is a final
|
|
151
|
+
* answer about the container. A null RESULT means the bytes have not arrived,
|
|
152
|
+
* or the format is unknown; neither is kept here.
|
|
107
153
|
*
|
|
108
|
-
* @param {object} params - same as
|
|
154
|
+
* @param {object} params - same as containerFor
|
|
109
155
|
* @returns {Promise<import("./container/Container.js").ContainerMediaInfo|null>}
|
|
110
156
|
*/
|
|
111
157
|
async getMediaInfo(params) {
|
|
112
|
-
const container = await this.getContainer(params);
|
|
113
|
-
if (!container) return null;
|
|
114
158
|
try {
|
|
159
|
+
const container = await this.containerFor(params);
|
|
160
|
+
if (!container) return null;
|
|
115
161
|
return await container.readMediaInfo();
|
|
116
162
|
} catch (e) {
|
|
117
|
-
|
|
163
|
+
if (!isUnavailable(e)) {
|
|
164
|
+
logger.warn(`container: readMediaInfo failed for "${params.label}": ${e?.message ?? e}`);
|
|
165
|
+
}
|
|
118
166
|
return null;
|
|
119
167
|
}
|
|
120
168
|
}
|
|
121
169
|
|
|
122
170
|
/**
|
|
123
|
-
* @param {object} params - same as
|
|
124
|
-
* @returns {Promise<{times:number[],tolerance:number}|null>}
|
|
171
|
+
* @param {object} params - same as containerFor
|
|
172
|
+
* @returns {Promise<{times:number[],tolerance:number}|null>} Null where the
|
|
173
|
+
* file has no usable index, or is of no known format.
|
|
174
|
+
* @throws {import("./container/unavailable.js").BytesUnavailable} While the
|
|
175
|
+
* bytes the index needs have not arrived — so that the keyframe table does
|
|
176
|
+
* not record "no keyframes" for a file whose index is still downloading.
|
|
125
177
|
*/
|
|
126
178
|
async getKeyframeIndex(params) {
|
|
127
|
-
const container = await this.
|
|
179
|
+
const container = await this.containerFor(params);
|
|
128
180
|
if (!container) return null;
|
|
129
181
|
try {
|
|
130
182
|
return await container.readKeyframeIndex();
|
|
131
|
-
} catch {
|
|
183
|
+
} catch (e) {
|
|
184
|
+
if (isUnavailable(e)) throw e;
|
|
132
185
|
return null;
|
|
133
186
|
}
|
|
134
187
|
}
|