@torrent-tv/proxy 2.89.3 → 2.89.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/AGENTS.md +338 -336
  2. package/CHANGELOG.md +12 -0
  3. package/docs/encode-architecture.md +55 -19
  4. package/docs/playback-start-forecast.md +13 -5
  5. package/docs/storage-architecture.md +6 -0
  6. package/package.json +1 -1
  7. package/routes/stream/get.js +38 -2
  8. package/server.js +748 -745
  9. package/services/encode/EncodeOrchestrator.js +24 -49
  10. package/services/encode/EncodePlan.js +5 -3
  11. package/services/encode/EncodeRun.js +80 -17
  12. package/services/encode/EncodeRuns.js +1339 -1239
  13. package/services/encode/Renditions.js +2 -1
  14. package/services/encode/RunClock.js +87 -0
  15. package/services/encode/RunProgress.js +1 -6
  16. package/services/encode/encoder-readings.js +26 -28
  17. package/services/encode/piece-completeness.js +46 -0
  18. package/services/encode/quality/EncodeCost.js +1043 -1167
  19. package/services/encode/quality/OutputQualityState.js +1 -5
  20. package/services/encode/quality/QualityController.js +213 -390
  21. package/services/encode/quality/link-budget.js +5 -17
  22. package/services/encode/run-inputs.js +13 -2
  23. package/services/server/SegmentServing.js +3 -1
  24. package/services/server/ViewerRequests.js +2 -7
  25. package/services/server/wire-outputs.js +12 -7
  26. package/services/storage/segment-store/SegmentStore.js +46 -29
  27. package/services/torrent/supply-margin.js +7 -5
  28. package/test/auto-quality-step.test.js +75 -29
  29. package/test/decode-cost.test.js +1 -1
  30. package/test/encode-cost.test.js +31 -39
  31. package/test/encode-orchestrator.test.js +15 -8
  32. package/test/encode-plan-viewers.test.js +34 -27
  33. package/test/encode-run.test.js +34 -6
  34. package/test/encoder-readings.test.js +48 -26
  35. package/test/helpers/encode-run.js +113 -9
  36. package/test/link-budget.test.js +4 -5
  37. package/test/manager-surface.test.js +1 -2
  38. package/test/orchestrator-wired.test.js +5 -4
  39. package/test/output-speed.test.js +0 -1
  40. package/test/piece-completeness.test.js +42 -0
  41. package/test/quality-offer.test.js +1 -3
  42. package/test/quality-variants.test.js +1 -1
  43. package/test/run-progress.test.js +2 -3
  44. package/test/same-height-switch.test.js +1 -2
  45. package/test/segment-store.test.js +15 -15
  46. package/test/stream-route.test.js +28 -0
  47. package/test/supply-margin.test.js +13 -0
  48. package/test/two-viewers-one-picture.test.js +1 -1
  49. package/test/viewer-outputs.test.js +1 -1
@@ -1661,7 +1661,7 @@ export class Renditions {
1661
1661
  ) ?? null;
1662
1662
  }
1663
1663
 
1664
- inputOf(session) {
1664
+ inputOf(session, runToken) {
1665
1665
  return encoderInputs({
1666
1666
  picture: session.file,
1667
1667
  soundtrack: session.spec.audio
@@ -1670,6 +1670,7 @@ export class Renditions {
1670
1670
  carries: session.spec.carries,
1671
1671
  audioSeparate: this.servesAudioSeparately(session),
1672
1672
  sessionId: session.id,
1673
+ runToken,
1673
1674
  readWindowBytes: this.#host.encodeRuns.readWindowFor(session),
1674
1675
  baseUrl: this.#host.localBaseUrl
1675
1676
  });
@@ -0,0 +1,87 @@
1
+ /**
2
+ * @file Where one encoder run's time went: working, waiting for its input, or
3
+ * stopped.
4
+ *
5
+ * A speed read as "film made per second of clock" measures the swarm as well
6
+ * as the machine whenever the input runs dry: field 2026-10-01, a copy waited
7
+ * 32.93 s and 43.91 s for two pieces and its speed read 0.21x, which the
8
+ * forecast then carried over the whole remaining film while it charged the
9
+ * same download separately. So the clock is split, from two facts that are
10
+ * observed and not inferred:
11
+ *
12
+ * 1. the input read waited — the stream route says when it starts and stops
13
+ * waiting for bytes of this run's input;
14
+ * 2. the process was stopped — the run says when it sends `SIGSTOP` and
15
+ * `SIGCONT`.
16
+ *
17
+ * Time during which either holds is idle; the rest is the run's own work. Two
18
+ * inputs that wait at once, or a wait while the process is stopped, are idle
19
+ * once. What is not separated is what the encoder does with bytes it already
20
+ * holds while its read waits; that is bounded by its input buffer, which is
21
+ * the one error this split has.
22
+ */
23
+ export class RunClock {
24
+ /** @type {() => number} */
25
+ #now;
26
+ #startedAt;
27
+ /** Idle causes in force now: input reads waiting, plus one while stopped. */
28
+ #idleCauses = 0;
29
+ #idleSince = 0;
30
+ #idleMs = 0;
31
+
32
+ /**
33
+ * @param {{ now?: () => number }} [options]
34
+ */
35
+ constructor({ now = Date.now } = {}) {
36
+ this.#now = now;
37
+ this.#startedAt = now();
38
+ }
39
+
40
+ #idleBegins() {
41
+ if (this.#idleCauses === 0) {
42
+ this.#idleSince = this.#now();
43
+ }
44
+ this.#idleCauses += 1;
45
+ }
46
+
47
+ #idleEnds() {
48
+ if (this.#idleCauses === 0) {
49
+ return;
50
+ }
51
+ this.#idleCauses -= 1;
52
+ if (this.#idleCauses === 0) {
53
+ this.#idleMs += this.#now() - this.#idleSince;
54
+ }
55
+ }
56
+
57
+ /** An input read of this run starts waiting for bytes. */
58
+ inputWaitBegins() {
59
+ this.#idleBegins();
60
+ }
61
+
62
+ /** An input read of this run has its bytes. */
63
+ inputWaitEnds() {
64
+ this.#idleEnds();
65
+ }
66
+
67
+ /** The process is stopped. */
68
+ stopped() {
69
+ this.#idleBegins();
70
+ }
71
+
72
+ /** The process runs again. */
73
+ continued() {
74
+ this.#idleEnds();
75
+ }
76
+
77
+ /**
78
+ * Milliseconds of the run's own work so far.
79
+ *
80
+ * @param {number} [at]
81
+ * @returns {number}
82
+ */
83
+ workingMs(at = this.#now()) {
84
+ const idleNow = this.#idleCauses > 0 ? at - this.#idleSince : 0;
85
+ return Math.max(0, at - this.#startedAt - this.#idleMs - idleNow);
86
+ }
87
+ }
@@ -28,23 +28,19 @@ export class RunProgress {
28
28
  this.totalSeconds = Number.isFinite(totalSeconds) && totalSeconds > 0 ? totalSeconds : null;
29
29
  this.percent = null;
30
30
  this.remainingSeconds = this.totalSeconds;
31
- this.speed = "";
32
31
  this.updatedAt = now();
33
32
  this.lastLoggedAt = 0;
34
33
  this.now = now;
35
34
  this.#derive();
36
35
  }
37
36
 
38
- note({ processedSeconds = null, outTime, speed = null }) {
37
+ note({ processedSeconds = null, outTime }) {
39
38
  const relative = Number.isFinite(processedSeconds)
40
39
  ? processedSeconds
41
40
  : parseTimestamp(outTime);
42
41
  if (relative !== null) {
43
42
  this.processedSeconds = this.startPositionSeconds + Math.max(0, relative);
44
43
  }
45
- if (typeof speed === "string") {
46
- this.speed = speed;
47
- }
48
44
  this.updatedAt = this.now();
49
45
  this.#derive();
50
46
  return this.snapshot();
@@ -65,7 +61,6 @@ export class RunProgress {
65
61
  totalSeconds: this.totalSeconds,
66
62
  percent: this.percent,
67
63
  remainingSeconds: this.remainingSeconds,
68
- speed: this.speed,
69
64
  updatedAt: this.updatedAt
70
65
  };
71
66
  }
@@ -1,45 +1,43 @@
1
1
  /**
2
- * @file Turning two readings of a running encoder into a speed.
2
+ * @file Turning two work samples of a running encoder into a speed.
3
3
  *
4
- * ffmpeg reports `speed=` cumulatively, over the whole run. That figure counts
5
- * every second the encoder spent SIGSTOPped by the look-ahead cap, and a COPY
6
- * spends most of its life there — it reaches the cap in about fifteen seconds
7
- * and then waits a minute. Read that way, a copy running at eight times
8
- * realtime reports 1.6x and falling; filed as the price of copying, it would
9
- * refuse quality rungs on arithmetic that had measured a pause.
4
+ * ffmpeg reports `speed=` cumulatively, over the whole run: every second the
5
+ * encoder was stopped and every second its input waited for the swarm is in
6
+ * the denominator. A copy at eight times realtime that waits a minute reads
7
+ * 1.6x; a copy that waited 32.93 s and 43.91 s for two pieces read 0.21x in the
8
+ * field on 2026-10-01, and the forecast carried that over the whole film while
9
+ * charging the same download separately.
10
10
  *
11
- * The difference between two readings of an uninterrupted stretch does not have
12
- * that fault, and it is the same technique the startup benchmarks use.
11
+ * A run's work sample states the film made and the milliseconds of the run's
12
+ * OWN work, with input waits and stops taken out (`RunClock`). Two samples of
13
+ * one run give its processing speed over the stretch between them; the run
14
+ * takes them when it closes pieces (`EncodeRun.speedReading`), so there is no
15
+ * window to choose.
13
16
  */
14
17
 
15
18
  /**
16
- * @typedef {object} EncoderReading
17
- * @property {number} takenAt - Wall clock, in milliseconds.
18
- * @property {number} processedSeconds - Output position ffmpeg has reached.
19
+ * @typedef {object} WorkSample
20
+ * @property {number} at - Wall clock, in milliseconds.
21
+ * @property {number} producedSeconds - Film this run has made.
22
+ * @property {number} workingMs - The run's own working time so far.
19
23
  */
20
24
 
21
25
  /**
22
- * Speed between two readings, or null when the pair cannot answer.
26
+ * Processing speed between two samples of one run, or null when the pair
27
+ * cannot answer: nothing made, or no working time between them.
23
28
  *
24
- * @param {EncoderReading | null} previous
25
- * @param {EncoderReading | null} current
26
- * @param {number} minimumWindowSec - The narrowest stretch worth dividing by.
27
- * @returns {number | null} Video seconds produced per second of clock.
29
+ * @param {WorkSample | null} previous
30
+ * @param {WorkSample | null} current
31
+ * @returns {number | null} Film seconds made per second of the run's own work.
28
32
  */
29
- export function speedFromReadings(previous, current, minimumWindowSec) {
33
+ export function speedFromWork(previous, current) {
30
34
  if (!previous || !current) {
31
35
  return null;
32
36
  }
33
- const wallSeconds = (current.takenAt - previous.takenAt) / 1000;
34
- const producedSeconds = current.processedSeconds - previous.processedSeconds;
35
- if (!Number.isFinite(wallSeconds) || !Number.isFinite(producedSeconds)) {
37
+ const producedSeconds = current.producedSeconds - previous.producedSeconds;
38
+ const workingSeconds = (current.workingMs - previous.workingMs) / 1000;
39
+ if (!(producedSeconds > 0) || !(workingSeconds > 0)) {
36
40
  return null;
37
41
  }
38
- // A window too narrow to divide by, or one in which nothing was produced —
39
- // the second happens when a run has just been repositioned and has not yet
40
- // reached the position it is restarting from.
41
- if (!(wallSeconds >= minimumWindowSec) || !(producedSeconds > 0)) {
42
- return null;
43
- }
44
- return producedSeconds / wallSeconds;
42
+ return producedSeconds / workingSeconds;
45
43
  }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * @file Whether a closed piece holds the whole of its stretch of film.
3
+ *
4
+ * A fact of production, decided where production is known: the cut a piece
5
+ * must reach comes from the output's cut table, and what it holds from the
6
+ * piece's own media intervals as its format reads them. The segment store keeps
7
+ * the bytes and whatever is derived from them; it does not judge them.
8
+ *
9
+ * A non-final piece is whole when the end of its last frame, on every track,
10
+ * reaches its next cut less `SEGMENT_CUT_TIME_DELTA_SECONDS`, the delta the
11
+ * muxer is configured with. The final piece has no next cut and is whole when
12
+ * it holds playable media at all. A piece that holds no playable media is never
13
+ * whole: an interrupted run can close a file with nothing in it.
14
+ */
15
+
16
+ import { SEGMENT_CUT_TIME_DELTA_SECONDS } from "./output/index.js";
17
+
18
+ /**
19
+ * The time a piece must reach, or undefined for the final piece.
20
+ *
21
+ * @param {{ segmentCount: number, publishedStartOf: (index: number) => number }} timeline
22
+ * @param {number} index
23
+ * @returns {number | undefined}
24
+ */
25
+ export function cutOf(timeline, index) {
26
+ return index < timeline.segmentCount - 1
27
+ ? timeline.publishedStartOf(index + 1) - SEGMENT_CUT_TIME_DELTA_SECONDS
28
+ : undefined;
29
+ }
30
+
31
+ /**
32
+ * @param {{ producedThroughSeconds?: (ranges: object) => number | null }} format
33
+ * @param {object} ranges - The piece's media intervals, as the format read them.
34
+ * @param {number | undefined} cutSeconds - From {@link cutOf}.
35
+ * @returns {{ whole: boolean, throughSeconds: number | null }}
36
+ */
37
+ export function judgePiece(format, ranges, cutSeconds) {
38
+ const throughSeconds = format.producedThroughSeconds?.(ranges) ?? null;
39
+ if (throughSeconds === null) {
40
+ return { whole: false, throughSeconds };
41
+ }
42
+ if (Number.isFinite(cutSeconds) && throughSeconds < cutSeconds) {
43
+ return { whole: false, throughSeconds };
44
+ }
45
+ return { whole: true, throughSeconds };
46
+ }