@torrent-tv/proxy 2.88.5 → 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.
Files changed (50) hide show
  1. package/AGENTS.md +21 -12
  2. package/CHANGELOG.md +18 -0
  3. package/assets/calibration/audio/NOTICE.md +19 -19
  4. package/assets/calibration/audio/manifest.json +98 -98
  5. package/docs/container-architecture.md +77 -22
  6. package/docs/playback-start-forecast.md +56 -56
  7. package/main2.cpuprofile +1 -0
  8. package/package.json +1 -1
  9. package/scripts/build-audio-samples.mjs +31 -31
  10. package/server.js +42 -27
  11. package/services/encode/EncodeRuns.js +3 -1
  12. package/services/encode/OutputOpening.js +5 -4
  13. package/services/encode/audio-calibration.js +67 -67
  14. package/services/encode/audio-work-rate.js +15 -15
  15. package/services/encode/output/index.js +4 -0
  16. package/services/encode/run-command.js +2 -1
  17. package/services/media/ContainerOrchestrator.js +99 -46
  18. package/services/media/KeyframeTables.js +61 -21
  19. package/services/media/SubtitleCues.js +255 -183
  20. package/services/media/container/AviContainer.js +5 -3
  21. package/services/media/container/Container.js +66 -43
  22. package/services/media/container/ContainerFactory.js +8 -26
  23. package/services/media/container/MatroskaContainer.js +719 -1182
  24. package/services/media/container/Mp4Container.js +83 -73
  25. package/services/media/container/ebml-stream.js +174 -0
  26. package/services/media/container/matroska-clusters.js +1165 -0
  27. package/services/media/container/unavailable.js +77 -0
  28. package/services/media/tracks/ContainerTrack.js +9 -0
  29. package/services/media/tracks/TextSubtitleTrack.js +9 -1
  30. package/services/server/ViewerRequests.js +3 -2
  31. package/services/server/playback-readiness.js +516 -516
  32. package/services/torrent/worker/client.js +6 -2
  33. package/services/torrent/worker/held-bytes.js +84 -8
  34. package/services/torrent/worker/worker.js +10 -2
  35. package/services/transport/data-channel-handler.js +7 -3
  36. package/services/viewer/PriorityMap.js +31 -0
  37. package/test/audio-calibration.test.js +23 -23
  38. package/test/coalesce.test.js +39 -0
  39. package/test/held-read-ownership.test.js +77 -0
  40. package/test/helpers/held-file.js +50 -2
  41. package/test/helpers/matroska-file.js +296 -0
  42. package/test/keyframe-table-budget.test.js +21 -0
  43. package/test/matroska-cluster-discovery.test.js +323 -0
  44. package/test/reads-not-yet-arrived.test.js +218 -0
  45. package/test/segment-store.test.js +14 -0
  46. package/test/subtitle-cue-walk.test.js +16 -1
  47. package/test/subtitle-plan-waits.test.js +125 -0
  48. package/test/subtitle-track-numbering.test.js +8 -3
  49. package/test/viewer-starts-on-map.test.js +27 -0
  50. 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, and how to read one of
267
- * those without asking the swarm. Piece length, file offsets and the bitfield
268
- * stay in the torrent's thread.
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
- * @param {string} sourceKey
303
- * @param {number} fileIndex
304
- * @returns {Promise<void>}
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
- `clusters walked ${entry.walkedClusters}/${entry.indexedClusters}, cursor ${entry.cursor}`
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({
@@ -15,6 +15,7 @@ import { ENCODE_EXIT } from "./encode-exit.js";
15
15
  import { EncodeRun } from "./EncodeRun.js";
16
16
  import { computeOutputDimensions } from "./args.js";
17
17
  import { buildRunCommand, trueStartOf } from "./run-command.js";
18
+ import { SEGMENT_CUT_TIME_DELTA_SECONDS } from "./output/index.js";
18
19
 
19
20
  /**
20
21
  * @typedef {Object} SegmentFiles
@@ -837,7 +838,8 @@ export class EncodeRuns {
837
838
  session.segmentFormat.servedNameOf?.(name) ?? name);
838
839
  if (Number.isInteger(index) && index < safeIndex) return null;
839
840
  return this.#host.segmentFiles.publish(session.outputKey ?? "", name, session.segmentFormat, {
840
- endSeconds: index < session.timeline.segmentCount - 1 ? session.timeline.publishedStartOf(index + 1) : undefined
841
+ endSeconds: index < session.timeline.segmentCount - 1 ?
842
+ session.timeline.publishedStartOf(index + 1) - SEGMENT_CUT_TIME_DELTA_SECONDS : undefined
841
843
  });
842
844
  },
843
845
  onEnded: (ended) => this.noteRunEnded(session, run, ended)
@@ -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 the table is
453
- // still unanswered — which is not the same as a file with no keyframes,
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 in ` +
460
- `${Math.round(this.#host.keyframeTables.budgetMs / 1000)}s, so this session re-encodes the picture ` +
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,6 +1,10 @@
1
1
  export { OutputSpec, VideoOutput, AudioOutput, CutGrid, isOutputName } from "./OutputSpec.js";
2
2
  export { PLAYLIST_FILE_NAME } from "./playlists.js";
3
3
 
4
+ // FFmpeg may close a segment this far before its requested cut. Both the
5
+ // command and coverage validation use this existing muxer configuration.
6
+ export const SEGMENT_CUT_TIME_DELTA_SECONDS = 0.05;
7
+
4
8
  /**
5
9
  * The code an error carries when no output suits the viewer: nothing this proxy
6
10
  * could produce or already holds is admitted by their link. Its `details` say
@@ -17,6 +17,7 @@
17
17
 
18
18
  import { PLAYLIST_FILE_NAME } from "./output/playlists.js";
19
19
  import { AUDIO_TRANSCODE_KBPS } from "./args.js";
20
+ import { SEGMENT_CUT_TIME_DELTA_SECONDS } from "./output/index.js";
20
21
 
21
22
  /**
22
23
  * What ffmpeg's own CLI subtracts from an input seek, and therefore what has to
@@ -694,7 +695,7 @@ export function buildRunCommand({
694
695
  // recorded a hair late would skip to the next one and double the
695
696
  // segment. The tolerance absorbs that rounding.
696
697
  "-segment_time_delta",
697
- "0.05",
698
+ String(SEGMENT_CUT_TIME_DELTA_SECONDS),
698
699
  "-segment_start_number",
699
700
  String(inputIndex),
700
701
  // THE ENCODER SAYS WHEN A PIECE IS FINISHED, on a channel of its own.