@torrent-tv/proxy 2.9.27 → 2.9.29

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/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## 2.9.29
2
+
3
+ - **New**: Global disk cap with LRU eviction (OpenSpec change `disk-cap`; Disk hygiene Level 1, final piece). Downloaded torrent data was already removed on a 5-min idle TTL and at shutdown, but under pressure it could still fill a small Home Assistant host's disk (which can take down HA itself). The pool now caps total downloaded data — default min(10 GB, half of free disk), overridable with `--max-disk-bytes` (0 disables) — and, when exceeded, evicts whole torrents with no active reader least-recently-used first (checked every 30 s). A torrent that is currently playing is never evicted. (LRU = least-recently-used.)
4
+ - **New**: Output frame rate follows the source instead of a fixed 24 fps (OpenSpec change `transcode-quality`, part 1). 25/30 fps content no longer plays resampled to 24 (which caused judder). Frame-count-GOP encoders (software libx264, v4l2m2m) use an integer rate — source rounded, capped at 30 as a speed guard — with the fps filter and the GOP length kept in lockstep so a keyframe still lands on every segment boundary; the time-based-keyframe encoders (nvenc, vaapi, qsv) inherit the exact source rate untouched (nvenc previously forced 24 — its fps filter is removed). Source rate is parsed from the existing startup probe. (GOP = group of pictures, the span between keyframes; the segment grid needs a keyframe at each boundary.)
5
+ - **Fix**: `GET /api/sources/:key/files` no longer blocks until metadata arrives (or fails prematurely on a cold magnet). It now waits only a short per-request budget (`maxWaitMs`, default 8 s, cap 20 s) and returns `{ pending: true }` while the swarm fetch continues in the background, so the browser can poll — mirroring the cold-torrent playback-plan poll. Field-found: a magnet whose metadata had not arrived yet failed with "no peers" on the first paste, then succeeded on a second paste because the fetch had kept running in the background. A real fetch error now returns 502 (distinct from pending). Pairs with server 0.8.39 (which references this as "proxy 2.9.28" — that release was folded into 2.9.29 before publishing).
6
+
1
7
  ## 2.9.27
2
8
 
3
9
  - **Fix**: A magnet whose infoHash matches a torrent already loaded in the pool no longer fails with 500 "Cannot add duplicate torrent" (scenario: one viewer opened the .torrent file, another pasted the magnet of the same content — different source keys, one swarm). The duplicate-add error now resolves to the already-loaded torrent (waiting for its metadata when it is itself still cold), so both source keys share the swarm. Found by a field test of the magnet flow.
package/bin/cli.js CHANGED
@@ -56,6 +56,7 @@ program
56
56
  .option("--name <name>", "Display name")
57
57
  .option("--no-transcode-audio", "Disable optional HLS AAC audio transcoding")
58
58
  .option("--no-port-mapping", "Disable automatic UPnP/NAT-PMP port mapping")
59
+ .option("--max-disk-bytes <bytes>", "Cap total downloaded torrent data (0 = disabled; default min(10GB, half free disk))")
59
60
  .option("--ffmpeg-bin <path>", "Path to ffmpeg binary")
60
61
  .option("--token <token>", "Registration token", "")
61
62
  .addHelpText("after", HELP_EXAMPLES);
@@ -79,6 +80,12 @@ const clientName = options.name ? String(options.name) : `proxy-${clientId.slice
79
80
  const token = String(options.token ?? "");
80
81
  const transcodeAudio = options.transcodeAudio !== false;
81
82
  const portMappingEnabled = options.portMapping !== false;
83
+ // Optional disk cap. undefined → the pool computes its own default; a valid
84
+ // non-negative number (0 disables) → passed through.
85
+ const maxDiskBytes =
86
+ options.maxDiskBytes !== undefined && Number.isFinite(Number(options.maxDiskBytes)) && Number(options.maxDiskBytes) >= 0
87
+ ? Number(options.maxDiskBytes)
88
+ : undefined;
82
89
  const bundledFfmpegBin = typeof ffmpegStatic === "string" ? ffmpegStatic : "";
83
90
  const ffmpegBin = options.ffmpegBin ? String(options.ffmpegBin) : bundledFfmpegBin || "ffmpeg";
84
91
 
@@ -234,7 +241,8 @@ try {
234
241
  host: bindHost,
235
242
  port: localPort,
236
243
  transcodeAudio,
237
- ffmpegBin
244
+ ffmpegBin,
245
+ maxDiskBytes
238
246
  });
239
247
  app = started.app;
240
248
  actualPort = started.port;
@@ -0,0 +1,2 @@
1
+ schema: spec-driven
2
+ created: 2026-07-07
@@ -0,0 +1,37 @@
1
+ # Proposal: Global disk cap with LRU eviction (Disk hygiene Level 1, final)
2
+
3
+ ## Why
4
+
5
+ Downloaded torrent data is already removed on a 300 s idle TTL and at
6
+ shutdown, and orphans are swept at startup — but under pressure (several
7
+ large files opened within the TTL window, or a fast fill) the total can
8
+ still grow unbounded and fill a small Home Assistant host's disk
9
+ (SD/eMMC on a Yellow/Pi is often 16–32 GB). A full disk can take down Home
10
+ Assistant itself. This adds the last missing Level 1 piece: a global cap.
11
+
12
+ ## What Changes
13
+
14
+ - The pool tracks total downloaded bytes and, when it exceeds a cap, evicts
15
+ whole torrents with NO active reader, least-recently-used first, until
16
+ back under the cap (checked every 30 s and reused via the existing
17
+ remove-with-store path). A torrent that is currently playing is never
18
+ evicted — we cannot delete what is in use.
19
+ - The cap defaults to `min(10 GB, half of free disk)` (measured via
20
+ `statfs` on the store filesystem), and is overridable with
21
+ `--max-disk-bytes` (0 disables).
22
+
23
+ ## Capabilities
24
+
25
+ ### New Capabilities
26
+
27
+ - `disk-cap`: bounded total on-disk footprint via LRU eviction.
28
+
29
+ ### Modified Capabilities
30
+
31
+ <!-- none -->
32
+
33
+ ## Impact
34
+
35
+ - `services/torrent-pool.js` (access tracking, cap enforcement),
36
+ `bin/cli.js` (`--max-disk-bytes`), `server.js` (option pass-through);
37
+ ha-addon bump. Part of the proxy transcode/hygiene batch (2.9.29).
@@ -0,0 +1,25 @@
1
+ # disk-cap — delta spec
2
+
3
+ ## ADDED Requirements
4
+
5
+ ### Requirement: Total torrent data is bounded by a disk cap
6
+ The pool SHALL keep the total downloaded torrent footprint under a cap. When
7
+ the total exceeds the cap it SHALL evict whole torrents that have no active
8
+ file reader, least-recently-used first, removing each with its on-disk store,
9
+ until back under the cap or no evictable torrent remains. A torrent with an
10
+ active reader SHALL NEVER be evicted. The cap SHALL default to the smaller of
11
+ 10 GB and half the free disk, and be overridable (0 disables).
12
+
13
+ #### Scenario: Idle torrents evicted under pressure
14
+ - **WHEN** the total downloaded data exceeds the cap and some torrents have no
15
+ active reader
16
+ - **THEN** the least-recently-used idle torrents are removed with their stores
17
+ until the total is back under the cap
18
+
19
+ #### Scenario: Active torrent protected
20
+ - **WHEN** the cap is exceeded but the only large torrent is currently playing
21
+ - **THEN** it is not evicted (the cap cannot delete in-use data)
22
+
23
+ #### Scenario: Cap disabled
24
+ - **WHEN** the cap is set to 0
25
+ - **THEN** no eviction occurs
@@ -0,0 +1,19 @@
1
+ # Tasks: Global disk cap with LRU eviction
2
+
3
+ ## 1. Implementation
4
+
5
+ - [x] 1.1 Access tracking (`#lastAccess`) updated on getTorrent (incl. the
6
+ duplicate-infoHash path) and acquireFile
7
+ - [x] 1.2 Cap computed at construction (`min(10GB, half free)` via statfs) or
8
+ taken from the `maxDiskBytes` option; 0 disables
9
+ - [x] 1.3 Periodic (30 s) `#enforceDiskCap`: evict zero-reader torrents
10
+ LRU-first via the existing remove-with-store path; clear timer on
11
+ destroyAll; drop `#lastAccess` on removal
12
+ - [x] 1.4 `--max-disk-bytes` CLI flag → server.js → pool
13
+ - [x] 1.5 Syntax checks + unit-test the eviction ordering/active-skip
14
+
15
+ ## 2. Release
16
+
17
+ - [ ] 2.1 Ship in the proxy batch (2.9.29) + ha-addon bump
18
+ - [ ] 2.2 Field-check on the host: open several large files, confirm idle
19
+ ones are evicted and playback is never interrupted
@@ -0,0 +1,2 @@
1
+ schema: spec-driven
2
+ created: 2026-07-07
@@ -0,0 +1,51 @@
1
+ # Proposal: Transcode quality — source fps, realtime budget, HDR, manual quality
2
+
3
+ ## Why
4
+
5
+ The transcode pipeline was tuned for "make it play at all" and left several
6
+ quality/robustness gaps flagged in the project analysis: output was hard-
7
+ locked to 24 fps (25/30 fps content played with resampling judder); the
8
+ encoder profile could be slower than realtime on weak hosts (stalls instead
9
+ of graceful degradation); 10-bit/HDR sources transcoded to 8-bit H.264
10
+ without tone mapping (washed-out colours); and the viewer had no way to force
11
+ a resolution. This change is the transcode-stage batch (proxy side; the
12
+ manual-quality menu also needs the server UI).
13
+
14
+ ## What Changes
15
+
16
+ - **Source fps** (DONE): the output frame rate is inherited from the source
17
+ (rounded to an integer, capped at 30), replacing the fixed 24. The
18
+ fixed-GOP encoders keep the fps↔GOP relationship exact so keyframes stay on
19
+ the segment grid; time-based-keyframe encoders (nvenc) just use it as the
20
+ rate; VAAPI/QSV already inherited.
21
+ - **Realtime budget** (planned): the startup benchmark picks the
22
+ encoder/preset/resolution/fps combination whose predicted throughput stays
23
+ above realtime; a source that would not encode in time is downscaled
24
+ (720/540p) rather than refused, and a sustained runtime `speed<1` triggers
25
+ a restart with a lighter profile. `-maxrate`/`-bufsize` cap bitrate spikes.
26
+ - **HDR tone mapping** (planned): detect 10-bit/HDR (pix_fmt,
27
+ color_transfer smpte2084/HLG) and insert a tone-map chain when re-encoding
28
+ to 8-bit H.264, so colours are not washed out. Depends on the ffmpeg build
29
+ having the tonemap filters.
30
+ - **Manual quality** (planned): a player Quality menu — Auto (current
31
+ viewport/DPR behaviour) plus forced resolutions — the proxy already
32
+ honours a requested target height.
33
+
34
+ ## Capabilities
35
+
36
+ ### New Capabilities
37
+
38
+ - `transcode-quality`: output frame rate, realtime encode budget, HDR tone
39
+ mapping, and explicit quality selection.
40
+
41
+ ### Modified Capabilities
42
+
43
+ <!-- none -->
44
+
45
+ ## Impact
46
+
47
+ - `services/hwaccel.js` (fps, benchmark, tonemap args),
48
+ `services/hls-session-manager.js` (fps probe, runtime speed watch),
49
+ `services/playback-planner.js` (HDR/fps in the plan);
50
+ server player UI for the Quality menu; ha-addon bump.
51
+ - Released as a batch (proxy 2.9.29 + addon) after the pieces land.
@@ -0,0 +1,37 @@
1
+ # transcode-quality — delta spec
2
+
3
+ ## ADDED Requirements
4
+
5
+ ### Requirement: Output frame rate follows the source
6
+ When re-encoding video, the proxy SHALL NOT force a fixed 24 fps. The output
7
+ rate SHALL follow the source, and the fps handling SHALL depend on how the
8
+ chosen encoder places keyframes:
9
+
10
+ - Frame-count-GOP encoders (software libx264, v4l2m2m) SHALL use an INTEGER
11
+ output rate — source rate rounded and capped — with the `fps` filter and
12
+ the GOP length (`segmentDuration × fps`) using that same integer, so a
13
+ keyframe lands on every segment boundary and segments do not drift off the
14
+ synthetic playlist's uniform grid. When the source rate is unknown they
15
+ SHALL fall back to the default rate.
16
+ - Time-based-keyframe encoders (nvenc, vaapi, qsv) SHALL inherit the exact
17
+ source rate with no fps filter (their keyframes are forced by output time,
18
+ so any rate segments correctly); no rounding, no cap.
19
+
20
+ #### Scenario: 25 fps source on the software encoder
21
+ - **WHEN** a 25 fps video is re-encoded with libx264 and 4-second segments
22
+ - **THEN** the output is 25 fps and the GOP is 100 frames (keyframe every
23
+ segment)
24
+
25
+ #### Scenario: High-fps source on the software encoder
26
+ - **WHEN** a 60 fps video is re-encoded with libx264/v4l2m2m
27
+ - **THEN** the output rate is capped at 30 fps (speed guard)
28
+
29
+ #### Scenario: Fractional source on a hardware time-based encoder
30
+ - **WHEN** a 23.976 fps video is re-encoded with nvenc/vaapi/qsv
31
+ - **THEN** the exact source rate is kept (no fps filter) and segments are
32
+ still cut on time
33
+
34
+ #### Scenario: Unknown source rate
35
+ - **WHEN** the source frame rate cannot be probed on the software path
36
+ - **THEN** the output falls back to the default rate and playback still
37
+ segments correctly
@@ -0,0 +1,34 @@
1
+ # Tasks: Transcode quality
2
+
3
+ ## 1. Source fps
4
+
5
+ - [x] 1.1 hwaccel: `chooseOutputFps` (integer, capped); `buildVideoArgs`
6
+ takes `fps`, threaded into the filter + frame-count GOP (software,
7
+ v4l2m2m) and the filter only (nvenc); VAAPI/QSV unchanged (already
8
+ inherit); startup test-encode/benchmark keep the fixed rate
9
+ - [x] 1.2 hls-session-manager: parse source fps from the probe, compute
10
+ `session.outputFps`, pass it into `buildVideoArgs`
11
+ - [x] 1.3 Unit-verify fps choice and fps↔GOP consistency (25→100, 24→96,
12
+ default→96); syntax checks
13
+
14
+ ## 2. Realtime budget (planned)
15
+
16
+ - [ ] 2.1 Benchmark picks encoder/preset/resolution/fps within a realtime
17
+ margin; downscale instead of refuse
18
+ - [ ] 2.2 Runtime `speed<1` watch → restart with a lighter profile
19
+ - [ ] 2.3 `-maxrate`/`-bufsize`
20
+
21
+ ## 3. HDR tone mapping (planned)
22
+
23
+ - [ ] 3.1 Detect 10-bit/HDR; insert tonemap chain when re-encoding to 8-bit
24
+ - [ ] 3.2 Guard on tonemap-filter availability in the ffmpeg build
25
+
26
+ ## 4. Manual quality (planned)
27
+
28
+ - [ ] 4.1 Proxy honours requested target height (already partly there)
29
+ - [ ] 4.2 Server Quality menu (Auto + forced resolutions)
30
+
31
+ ## 5. Release
32
+
33
+ - [ ] 5.1 Batch release: proxy 2.9.29 + ha-addon bump; field-test on the
34
+ owner's hardware (25/30 fps content plays without judder; seek intact)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.27",
3
+ "version": "2.9.29",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -4,9 +4,14 @@
4
4
  * GET /api/sources/:sourceKey/files
5
5
  *
6
6
  * The browser parses `.torrent` files locally, but a magnet URI carries no
7
- * file list — the metadata comes from the swarm. This route resolves the
8
- * torrent (waiting for metadata on a cold magnet; callers should use a
9
- * generous timeout) and returns the file inventory.
7
+ * file list — the metadata comes from the swarm and can take a while to
8
+ * arrive on a cold magnet. Rather than block the request until it does (a
9
+ * single long request racing the transport timeout, which surfaced as a
10
+ * premature "no peers" error while the metadata was in fact still arriving),
11
+ * this waits only a short per-request budget: if the metadata is not ready it
12
+ * returns `{ pending: true }` while the fetch continues in the background. The
13
+ * caller polls again until the file list comes back — mirroring the cold-
14
+ * torrent playback-plan poll.
10
15
  *
11
16
  * @param {import("fastify").FastifyRequest} req
12
17
  * @param {import("fastify").FastifyReply} reply
@@ -16,6 +21,10 @@
16
21
  * }} deps
17
22
  * @returns {Promise<void>}
18
23
  */
24
+
25
+ /** Sentinel resolved when the per-request wait elapses before metadata. */
26
+ const PENDING = Symbol("pending");
27
+
19
28
  export async function handleApiSourceFilesGet(req, reply, { sourceRegistry, torrentPool }) {
20
29
  const sourceKey = typeof req.params?.sourceKey === "string" ? req.params.sourceKey.trim() : "";
21
30
  if (!sourceKey) {
@@ -26,7 +35,41 @@ export async function handleApiSourceFilesGet(req, reply, { sourceRegistry, torr
26
35
  return reply.code(404).send({ error: "Source key was not found." });
27
36
  }
28
37
 
29
- const torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
38
+ // How long to wait within THIS request before returning `pending`. Well
39
+ // under the transport's request timeout so a single poll never races it.
40
+ const rawWait = Number(req.query?.maxWaitMs);
41
+ const maxWaitMs = Number.isFinite(rawWait) && rawWait > 0 ? Math.min(rawWait, 20_000) : 8_000;
42
+
43
+ // getTorrent dedupes concurrent/repeated calls via the pool's in-flight map,
44
+ // so polling keeps joining the same background fetch (metadata keeps
45
+ // downloading between polls). Race it against the wait budget; if the wait
46
+ // wins, the fetch is left running for the next poll to observe.
47
+ let timer;
48
+ const waitPromise = new Promise((resolve) => {
49
+ timer = setTimeout(() => resolve(PENDING), maxWaitMs);
50
+ timer.unref?.();
51
+ });
52
+ const torrentPromise = torrentPool
53
+ .getTorrent(sourceRecord.sourceType, sourceRecord.source)
54
+ // Swallow so a rejection that loses the race is not an unhandled rejection;
55
+ // the next poll re-issues getTorrent and re-observes any real error.
56
+ .catch((error) => (error instanceof Error ? error : new Error(String(error))));
57
+
58
+ let result;
59
+ try {
60
+ result = await Promise.race([torrentPromise, waitPromise]);
61
+ } finally {
62
+ clearTimeout(timer);
63
+ }
64
+
65
+ if (result === PENDING) {
66
+ return reply.send({ pending: true });
67
+ }
68
+ if (result instanceof Error) {
69
+ return reply.code(502).send({ error: `Could not load torrent metadata: ${result.message}` });
70
+ }
71
+
72
+ const torrent = result;
30
73
  const files = (torrent.files ?? []).map((file, index) => ({
31
74
  index,
32
75
  name: file?.name ?? "",
package/server.js CHANGED
@@ -60,6 +60,7 @@ function buildPortCandidates(startPort, maxAttempts = 51) {
60
60
  * @property {number} port - Preferred listen port.
61
61
  * @property {boolean} transcodeAudio - Whether HLS audio transcoding is enabled.
62
62
  * @property {string} ffmpegBin - Path to the ffmpeg executable.
63
+ * @property {number} [maxDiskBytes] - Global disk cap for torrent data (undefined = pool default).
63
64
  */
64
65
 
65
66
  /**
@@ -68,7 +69,7 @@ function buildPortCandidates(startPort, maxAttempts = 51) {
68
69
  * @param {ProxyServerOptions} options
69
70
  * @returns {Promise<{ app: import("fastify").FastifyInstance, port: number }>}
70
71
  */
71
- export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin }) {
72
+ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin, maxDiskBytes }) {
72
73
  const app = Fastify({
73
74
  // No practical body-size limit — the proxy server is localhost-only and
74
75
  // receives torrent source payloads that may be arbitrarily large.
@@ -94,7 +95,7 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin }
94
95
  });
95
96
 
96
97
  const sourceRegistry = createSourceRegistry(200);
97
- const torrentPool = new TorrentPool();
98
+ const torrentPool = new TorrentPool({ maxDiskBytes });
98
99
  const selectedPort = await getPort({
99
100
  port: buildPortCandidates(port)
100
101
  });
@@ -15,7 +15,7 @@ import path from "node:path";
15
15
  import { randomUUID } from "node:crypto";
16
16
  import { spawn } from "node:child_process";
17
17
  import { logger } from "../utils/logger.js";
18
- import { softwareDescriptor, pickSoftwarePreset, TRANSCODE_FPS } from "./hwaccel.js";
18
+ import { softwareDescriptor, pickSoftwarePreset, TRANSCODE_FPS, chooseOutputFps } from "./hwaccel.js";
19
19
 
20
20
  const PLAYLIST_FILE_NAME = "index.m3u8";
21
21
  const SEGMENT_FILE_NAME_PATTERN = /^segment-\d{5}\.ts$/;
@@ -295,6 +295,29 @@ function parseFfmpegVideoDimensions(stderrText) {
295
295
  };
296
296
  }
297
297
 
298
+ /**
299
+ * Parse the source frame rate from the ffmpeg "Video:" line
300
+ * (e.g. "… 23.98 fps," / "… 25 fps,"). Returns null when absent.
301
+ *
302
+ * @param {string} stderrText
303
+ * @returns {number | null}
304
+ */
305
+ function parseFfmpegVideoFps(stderrText) {
306
+ if (typeof stderrText !== "string" || stderrText.length === 0) {
307
+ return null;
308
+ }
309
+ const videoLine = stderrText.match(/Video:[^\n]*/i);
310
+ if (!videoLine) {
311
+ return null;
312
+ }
313
+ const match = videoLine[0].match(/([\d.]+)\s*fps/i);
314
+ if (!match) {
315
+ return null;
316
+ }
317
+ const value = Number(match[1]);
318
+ return Number.isFinite(value) && value > 0 ? value : null;
319
+ }
320
+
298
321
  /**
299
322
  * Run a short ffmpeg probe to extract the total duration AND video resolution
300
323
  * of a stream from the container header. Both are printed almost immediately
@@ -323,6 +346,7 @@ async function probeInputMediaInfo(ffmpegBin, inputUrl) {
323
346
  durationSeconds: parseFfmpegDurationSeconds(stderr),
324
347
  width: dims.width,
325
348
  height: dims.height,
349
+ fps: parseFfmpegVideoFps(stderr),
326
350
  startTime: parseFfmpegStartTimeSeconds(stderr)
327
351
  });
328
352
  };
@@ -720,6 +744,10 @@ export class HlsSessionManager {
720
744
  const sourceWidth = mediaInfo.width;
721
745
  const sourceHeight = mediaInfo.height;
722
746
  const sourceStartTime = Number.isFinite(mediaInfo.startTime) ? mediaInfo.startTime : 0;
747
+ // Output frame rate inherited from the source (integer, capped) so 25/30
748
+ // fps content is not resampled to 24. Fixed-GOP encoders keep the fps↔GOP
749
+ // relationship exact; time-based-keyframe encoders just use it as the rate.
750
+ const outputFps = chooseOutputFps(mediaInfo.fps);
723
751
  const hasDuration = Number.isFinite(durationSeconds) && durationSeconds > 0;
724
752
  const logName = normalizeLogFileName(fileName, fileIndex);
725
753
  if (!hasDuration) {
@@ -789,6 +817,7 @@ export class HlsSessionManager {
789
817
  transcodeVideo,
790
818
  transcodeAudio,
791
819
  audioTrackIndex: normalizedAudioTrack,
820
+ outputFps,
792
821
  targetWidth: normalizedTargetWidth,
793
822
  targetHeight: normalizedTargetHeight,
794
823
  sourceWidth,
@@ -1001,6 +1030,9 @@ export class HlsSessionManager {
1001
1030
  targetWidth: session.targetWidth,
1002
1031
  targetHeight: session.targetHeight,
1003
1032
  segmentDurationSec: this.segmentDurationSec,
1033
+ // Source-inherited output rate (integer, capped); descriptors that
1034
+ // use time-based keyframes just apply it as the frame rate.
1035
+ fps: session.outputFps,
1004
1036
  // Software-only; hardware descriptors ignore it.
1005
1037
  preset: session.softwarePreset ?? undefined
1006
1038
  })