@torrent-tv/proxy 2.9.32 → 2.9.34

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,11 @@
1
+ ## 2.9.34
2
+
3
+ - **New**: Cold-start reduction (OpenSpec change `cold-start`). Creating a transcode session no longer runs a second full ffmpeg input scan: the playback planner caches the media info (duration/resolution/fps/start-time/HDR) parsed from the probe it already ran, and `createSession` reuses it (falling back to its own probe only when the cache cannot serve — e.g. after a restart, or a missing critical field). The banner parsers now live in a shared `ffmpeg-banner.js` so both sides parse identically. Once a plan probe succeeds the proxy also warms the START of the file body (~16 MB, fire-and-forget) so the first segment's encode reads downloaded data instead of waiting on pieces. Session startup is now measurable in the log: `cold-start <id>: media-info=<ms> (cached|probed) keyframes=<ms|skipped> create-total=<ms>` and, once per session, `cold-start <id>: first-segment ready +<ms>`.
4
+
5
+ ## 2.9.33
6
+
7
+ - **New**: HDR / 10-bit tone mapping (OpenSpec change `transcode-quality`, part 3). An HDR source (BT.2020 with a PQ `smpte2084` or HLG `arib-std-b67` transfer) re-encoded to 8-bit SDR without tone mapping looks washed-out and desaturated. The proxy now detects HDR from the probe and, when re-encoding video on the software path, inserts a `zscale`+`tonemap` (hable) chain to convert HDR→BT.709 SDR properly. It is **gated on filter availability**: at startup the proxy checks this ffmpeg build for the `zscale` (libzimg) and `tonemap` filters (`hwaccel: HDR tone mapping available/unavailable …`); when either is missing it falls back to the previous plain 8-bit convert (still plays, just washed-out). The tone map runs after the downscale (cheaper on ARM). Logged per session as `hdr=1 tonemap=on|off`. Hardware encoders keep their current path for now (tone mapping there is a follow-up). No client change — the browser plays the resulting SDR HLS.
8
+
1
9
  ## 2.9.32
2
10
 
3
11
  - **New**: Manual quality support (OpenSpec change `transcode-quality`, part 4). The playback plan now reports the source coded resolution (`videoWidth`/`videoHeight`, parsed from the ffprobe banner) so the browser can offer a quality menu. `POST /api/transcode-sessions` accepts `manualQuality: true`: the requested target box is then encoded exactly (capped to the source, never upscaled) with the realtime budget disabled for that session — no startup auto-downscale and no runtime downswitch — so a viewer-forced resolution stays constant for the whole session. `manualQuality` is part of the session key (a forced-quality session is distinct from Auto). Logged as `enc=WxH@fps quality=manual`. Auto (no flag) is unchanged: the realtime budget decides. Pairs with the server release that adds the player quality menu.
@@ -0,0 +1,98 @@
1
+ # Design: Cold-start reduction (proxy side)
2
+
3
+ Written to be executed as specified. Read before coding:
4
+
5
+ - `services/playback-planner.js`: `probeStreamCodecs` (how the ffmpeg banner
6
+ is parsed today), `getPlan` (prefetch → probe → pending loop → plan cache
7
+ and its key/invalidation).
8
+ - `services/hls-session-manager.js`: `probeInputMediaInfo` (~line 383 — what
9
+ it parses: durationSeconds, width, height, fps, startTime, isHdr),
10
+ `createSession` (~line 806: the probe call at ~817, keyframe probe at
11
+ ~854), the segment-ready path (where segment 00000 first becomes
12
+ servable — grep the long-poll the `routes/transcode/session-file/get.js`
13
+ route uses).
14
+ - `services/torrent-pool.js`: `prefetchFileEdges` (~line 608) — signature
15
+ `(torrent, fileIndex, { headBytes, tailBytes, timeoutMs })`.
16
+ - `server.js`: how `playbackPlanner` and `hlsSessionManager` are constructed
17
+ and injected.
18
+
19
+ ## 1. Media-info reuse (kill ffmpeg scan #2)
20
+
21
+ Both probes run ffmpeg over the same input URL and parse the same stderr
22
+ banner; they just extract different fields. Unify:
23
+
24
+ 1. Extract the banner-parsing helpers that `probeInputMediaInfo` uses
25
+ (duration/width/height/fps/startTime/HDR detection) so the planner can
26
+ apply them to ITS probe's stderr. Where they live is the executor's
27
+ choice (export from hls-session-manager or move to a small shared
28
+ module) — do NOT duplicate the parsing logic.
29
+ 2. In the planner, on a probe whose codecs were detected (the same condition
30
+ that allows caching the plan), build a `mediaInfo` object
31
+ `{ durationSeconds, width, height, fps, startTime, isHdr }` and cache it
32
+ ALONGSIDE the plan — same key, same lifetime, same invalidation. A
33
+ pending (empty) probe caches nothing, exactly like the plan.
34
+ 3. Planner exposes `getCachedMediaInfo({ sourceKey, fileIndex })` →
35
+ `MediaInfo | null`.
36
+ 4. `server.js` passes it into HlsSessionManager (new constructor option
37
+ `getCachedMediaInfo`).
38
+ 5. `createSession`:
39
+
40
+ const cached = this.getCachedMediaInfo?.({ sourceKey, fileIndex }) ?? null;
41
+ const usable = cached
42
+ && Number.isFinite(cached.durationSeconds) && cached.durationSeconds > 0
43
+ && Number.isFinite(cached.width) && cached.width > 0
44
+ && Number.isFinite(cached.height) && cached.height > 0;
45
+ const mediaInfo = usable ? cached : await probeInputMediaInfo(this.ffmpegBin, inputUrl.toString());
46
+
47
+ Duration/width/height gate the cache because downstream logic depends on
48
+ them (synthetic VOD playlist, resolution ladder); fps/startTime/isHdr
49
+ have safe defaults and do not gate. The probe stays as the fallback —
50
+ behaviour is unchanged whenever the cache cannot serve.
51
+
52
+ Keyframe probe (`probeVideoKeyframeTimes`) is NOT touched: it runs only on
53
+ the video-copy path and probes different data (packet flags, not the
54
+ banner).
55
+
56
+ ## 2. Body-start prefetch
57
+
58
+ In `getPlan`, at the point where the probe succeeded and the plan is about
59
+ to be cached/returned, fire and FORGET:
60
+
61
+ void torrentPool.prefetchFileEdges(torrent, fileIndex, {
62
+ headBytes: BODY_PREFETCH_BYTES, // 16 MB
63
+ tailBytes: 0,
64
+ timeoutMs: 60_000
65
+ }).catch(() => {});
66
+
67
+ const BODY_PREFETCH_BYTES = 16 * 1024 * 1024;
68
+
69
+ No new torrent-pool method — `prefetchFileEdges` with `tailBytes: 0` is
70
+ exactly a head prefetch. Not awaited: the client's session-create follows
71
+ within a couple of seconds and ffmpeg reads sequentially behind the
72
+ prefetch. 16 MB ≈ 25 s of typical 5 Mbit media — covers the first segments
73
+ without denting the disk cap. Do NOT start it on pending polls (the header
74
+ prefetch must keep absolute priority while the probe is starving).
75
+
76
+ ## 3. Stage timings
77
+
78
+ - `createSession` measures and logs (one line, existing logger, prefix
79
+ matches the session log style):
80
+
81
+ cold-start <sessionId8>: media-info=<ms> (cached|probed) keyframes=<ms|skipped> create-total=<ms>
82
+
83
+ - When the FIRST segment file of a fresh session becomes servable (the
84
+ long-poll in the session-file path returns it), log once per session:
85
+
86
+ cold-start <sessionId8>: first-segment ready +<ms since createSession entry>
87
+
88
+ Store the create-entry timestamp on the session object; guard with a
89
+ boolean so the line logs once.
90
+
91
+ ## Rules — do NOT
92
+
93
+ - Do NOT change probe semantics, the pending/poll contract, or the plan
94
+ cache key.
95
+ - Do NOT prioritise body bytes before the probe has succeeded.
96
+ - Do NOT make session create fail on a cache problem — any doubt → fall
97
+ back to the probe.
98
+ - No behavioural change for the video-copy path beyond the timing lines.
@@ -0,0 +1,62 @@
1
+ # Proposal: Cold-start reduction (proxy side)
2
+
3
+ ## Why
4
+
5
+ Field recordings (mobile tester, 2026-07-08) show ~90 seconds from picking an
6
+ episode to a picture. The proxy owns two chunks of that:
7
+
8
+ 1. **A redundant ffmpeg input scan.** The playback planner probes the input
9
+ (`probeStreamCodecs`) and CACHES the plan — then `createSession` in
10
+ hls-session-manager immediately probes the SAME input again
11
+ (`probeInputMediaInfo`) for duration/resolution/fps/startTime/HDR. Each
12
+ probe is a full ffmpeg startup + banner scan over the torrent-backed HTTP
13
+ stream — seconds on the HA host, on the critical path, twice.
14
+ 2. **First-segment piece latency.** The planner prefetches only the file
15
+ EDGES (256 KB head + 2 MB tail — what the codec probe needs). The first
16
+ segment's encode then needs the first ~10–20 MB of the file BODY, whose
17
+ pieces start downloading only when ffmpeg asks for them — while the swarm
18
+ sat mostly idle during the plan poll.
19
+
20
+ There is also no per-stage timing in the logs, so field regressions in
21
+ session startup are invisible.
22
+
23
+ ## What Changes
24
+
25
+ - **Reuse the planner's probe in session create.** The planner parses and
26
+ caches the full media info (duration, width/height, fps, startTime, isHdr)
27
+ from the probe it already runs; hls-session-manager consults that cache
28
+ and skips its own `probeInputMediaInfo` on a hit (probe stays as the
29
+ fallback). One full ffmpeg scan disappears from the critical path.
30
+ - **Prefetch the file-body start once the plan is ready.** After a successful
31
+ probe, fire-and-forget prefetch of the first 16 MB of the file, so the
32
+ session's ffmpeg reads hit already-downloaded data instead of paying piece
33
+ latency at encode time.
34
+ - **Stage timings in the log.** Session create logs probe ms (and
35
+ cache-hit/probed), keyframe-probe ms, and the time from create to the
36
+ first servable segment — so cold-start is measurable per stage in the
37
+ field, next to the client-side summary line (server `cold-start` change).
38
+
39
+ Client-side counterparts (phase instrumentation, earlier prebuffer start)
40
+ live in the server repo's `cold-start` change. The two changes are
41
+ independent — no wire-format coupling, either releases alone.
42
+
43
+ NOT in this change (candidates for later, kept out deliberately):
44
+ next-episode speculative prefetch (bandwidth cost needs field data first);
45
+ encoder-side first-segment speed-ups (faster warm-up preset would need a
46
+ mid-stream switch — parked with transcode-quality 2.2b).
47
+
48
+ ## Capabilities
49
+
50
+ ### New Capabilities
51
+
52
+ - `cold-start`: proxy-side session startup latency behaviour.
53
+
54
+ ## Impact
55
+
56
+ - `services/playback-planner.js` — parse + cache full media info; expose
57
+ `getCachedMediaInfo`; body-start prefetch after a successful probe.
58
+ - `services/hls-session-manager.js` — consult the cache in `createSession`;
59
+ stage-timing log lines.
60
+ - `server.js` — wire `getCachedMediaInfo` into the HlsSessionManager deps.
61
+ - Release: proxy `npm run patch` + ha-addon version bump (standard order:
62
+ proxy first, then addon).
@@ -0,0 +1,47 @@
1
+ # cold-start — delta spec (proxy)
2
+
3
+ ## ADDED Requirements
4
+
5
+ ### Requirement: Session create reuses the planner's probe
6
+
7
+ Creating an HLS transcode session SHALL NOT re-probe an input whose media
8
+ info the playback planner already probed and cached for the same source and
9
+ file. The cached info is used only when its critical fields (duration,
10
+ width, height) are present and valid; otherwise the session SHALL probe as
11
+ before. Cache lifetime and invalidation follow the plan cache exactly.
12
+
13
+ #### Scenario: Warm plan, immediate session
14
+ - **WHEN** the browser requests a transcode session right after receiving a
15
+ playback plan for the same file
16
+ - **THEN** the session starts without a second ffmpeg input scan and its
17
+ playlist/ladder decisions are identical to what the probe would have
18
+ produced
19
+
20
+ #### Scenario: Cache cannot serve
21
+ - **WHEN** no cached media info exists (e.g. proxy restarted between plan
22
+ and session) or a critical field is missing
23
+ - **THEN** the session probes the input itself, exactly as before this
24
+ change
25
+
26
+ ### Requirement: The file-body start is warm before the encoder needs it
27
+
28
+ Once a playback plan probe succeeds, the proxy SHALL prefetch the beginning
29
+ of the file body (bounded, ~16 MB) in the background, without delaying the
30
+ plan response and without competing with a still-running header probe.
31
+
32
+ #### Scenario: First segment does not wait for pieces
33
+ - **WHEN** the viewer confirms playback within the normal flow (seconds
34
+ after the plan)
35
+ - **THEN** the encoder's initial reads are served from already-downloaded
36
+ data and the first segment's production is not blocked on piece download
37
+
38
+ ### Requirement: Session startup is measurable per stage
39
+
40
+ Session creation SHALL log the media-info acquisition time (and whether it
41
+ was cached or probed), the keyframe-probe time when it runs, and — once per
42
+ session — the time from session-create entry to the first servable segment.
43
+
44
+ #### Scenario: Field regression triage
45
+ - **WHEN** a tester reports a slow start
46
+ - **THEN** the proxy log shows, for that session, where the time went:
47
+ media info, keyframes, or first-segment production
@@ -0,0 +1,48 @@
1
+ # Tasks: Cold-start reduction (proxy side)
2
+
3
+ Execute in order; design.md is normative. Read the code regions listed at
4
+ the top of design.md first.
5
+
6
+ ## 1. Media-info reuse
7
+
8
+ - [ ] 1.1 Make the banner-parse helpers of `probeInputMediaInfo` reusable by
9
+ the planner (export or shared module — no duplication). `node --check`
10
+ both files.
11
+ - [ ] 1.2 playback-planner: build + cache `mediaInfo` alongside the plan on
12
+ a codecs-detected probe; expose `getCachedMediaInfo({ sourceKey,
13
+ fileIndex })`. Pending probes cache nothing.
14
+ - [ ] 1.3 server.js: pass `getCachedMediaInfo` into HlsSessionManager;
15
+ hls-session-manager: consult it in `createSession` with the
16
+ critical-fields gate (duration/width/height), probe as fallback.
17
+ - [ ] 1.4 Verify equivalence: for one file, run plan → session with the
18
+ cache and (by temporarily disabling the wiring) without it — the
19
+ session log line (duration, segments, ladder/rung) must be identical.
20
+
21
+ ## 2. Body-start prefetch
22
+
23
+ - [ ] 2.1 playback-planner: fire-and-forget 16 MB head prefetch
24
+ (`prefetchFileEdges` with `tailBytes: 0`) at the probe-success point;
25
+ never on pending polls; never awaited.
26
+ - [ ] 2.2 Verify: on a fresh well-seeded magnet, proxy log/order shows the
27
+ prefetch starting right after the plan while the session that follows
28
+ produces its first segment without a piece-wait stall (compare
29
+ first-segment ms with task 3 timings before/after).
30
+
31
+ ## 3. Stage timings
32
+
33
+ - [ ] 3.1 `createSession`: one `cold-start …: media-info=<ms> (cached|probed)
34
+ keyframes=<ms|skipped> create-total=<ms>` line.
35
+ - [ ] 3.2 First-segment-ready line (`+<ms>` since create entry), once per
36
+ session (timestamp on the session object + boolean guard).
37
+
38
+ ## 4. Verification and release
39
+
40
+ - [ ] 4.1 Regression: normal HLS transcode start, video-copy start (keyframe
41
+ probe still runs), seek-restart, quality switch — behaviour unchanged;
42
+ timings visible in the log.
43
+ - [ ] 4.2 Before/after numbers on the dev HA proxy for one cold magnet:
44
+ media-info ms (probed → cached) and first-segment ms. Record them in
45
+ this file.
46
+ - [ ] 4.3 CHANGELOG entry (current version + 1 patch); `npm run patch`; bump
47
+ ha-addon `config.yaml` + its CHANGELOG; push; update the addon in HA
48
+ (proxy FIRST, then addon — release order per root CLAUDE.md).
@@ -117,3 +117,23 @@ behaviour.
117
117
  - **WHEN** the viewer selects Auto
118
118
  - **THEN** the proxy applies the realtime budget (startup selection + runtime
119
119
  downswitch) as before
120
+
121
+ ### Requirement: HDR sources are tone-mapped when re-encoded to SDR
122
+
123
+ The proxy SHALL apply an HDR→BT.709 SDR tone-map chain when re-encoding an HDR
124
+ source (a BT.2020 PQ `smpte2084` or HLG `arib-std-b67` transfer) to 8-bit SDR on
125
+ the software path, so the output is not washed-out — provided this ffmpeg build
126
+ has the required filters. The proxy SHALL detect filter availability (`zscale`
127
+ and `tonemap`) at startup and, when either is missing, SHALL fall back to a
128
+ plain 8-bit convert without failing playback.
129
+
130
+ #### Scenario: HDR source, filters available
131
+ - **WHEN** an HDR source is re-encoded on the software path and the build has
132
+ `zscale` + `tonemap`
133
+ - **THEN** the tone-map chain is inserted and the output is BT.709 SDR (not
134
+ washed-out)
135
+
136
+ #### Scenario: HDR source, filters missing
137
+ - **WHEN** an HDR source is re-encoded but the build lacks `zscale`/`tonemap`
138
+ - **THEN** playback still proceeds with a plain 8-bit convert (no tone map) and
139
+ the limitation is logged
@@ -34,10 +34,16 @@
34
34
  at the switch point.
35
35
  - [ ] 2.3 `-maxrate`/`-bufsize`
36
36
 
37
- ## 3. HDR tone mapping (planned)
38
-
39
- - [ ] 3.1 Detect 10-bit/HDR; insert tonemap chain when re-encoding to 8-bit
40
- - [ ] 3.2 Guard on tonemap-filter availability in the ffmpeg build
37
+ ## 3. HDR tone mapping
38
+
39
+ - [x] 3.1 Detect HDR from the probe (PQ `smpte2084` / HLG `arib-std-b67`
40
+ transfer); insert a `zscale`+`tonemap` (hable) BT.2020→BT.709 SDR chain
41
+ when re-encoding video on the software path (after the downscale).
42
+ - [x] 3.2 Guard on tonemap-filter availability: startup `detectTonemapSupport`
43
+ checks `ffmpeg -filters` for `zscale` + `tonemap`; when missing, HDR
44
+ falls back to the plain 8-bit convert (washed-out but plays). Logged.
45
+ NOTE (follow-up 3.3): hardware-encoder tone mapping (tonemap_vaapi / npp /
46
+ opencl) — software path only for now.
41
47
 
42
48
  ## 4. Manual quality
43
49
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.32",
3
+ "version": "2.9.34",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
package/server.js CHANGED
@@ -30,7 +30,7 @@ import { createSourceRegistry } from "./store/source-registry.js";
30
30
  import { TorrentPool } from "./services/torrent-pool.js";
31
31
  import { HlsSessionManager } from "./services/hls-session-manager.js";
32
32
  import { createPlaybackPlanner } from "./services/playback-planner.js";
33
- import { detectVideoEncoder, benchmarkSoftwarePresets } from "./services/hwaccel.js";
33
+ import { detectVideoEncoder, benchmarkSoftwarePresets, detectTonemapSupport } from "./services/hwaccel.js";
34
34
  import { logger } from "./utils/logger.js";
35
35
 
36
36
  const __filename = fileURLToPath(import.meta.url);
@@ -111,6 +111,12 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
111
111
  const softwarePresetBenchmark = videoEncoder?.kind === "software"
112
112
  ? await benchmarkSoftwarePresets({ ffmpegBin, logger })
113
113
  : null;
114
+ // Whether this ffmpeg build can tone-map HDR→SDR (zscale + tonemap filters).
115
+ // Detected once; the session manager applies the tonemap chain only for HDR
116
+ // sources on the software path when available.
117
+ const tonemapSupported = transcodeAudio
118
+ ? await detectTonemapSupport({ ffmpegBin, logger })
119
+ : false;
114
120
  const hlsSessionManager = new HlsSessionManager({
115
121
  enabled: transcodeAudio,
116
122
  ffmpegBin,
@@ -118,6 +124,7 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
118
124
  localPort: selectedPort,
119
125
  videoEncoder,
120
126
  softwarePresetBenchmark,
127
+ tonemapSupported,
121
128
  // Live download stats accessor for the realtime budget: lets it tell a
122
129
  // CPU-bound transcode from a download-starved input before downscaling.
123
130
  getSourceStats: async (sourceKey, fileIndex) => {
@@ -131,7 +138,11 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
131
138
  } catch {
132
139
  return null;
133
140
  }
134
- }
141
+ },
142
+ // Reuse the media info the planner already probed for this file (same
143
+ // ffmpeg scan) so createSession skips its own probe. Late-bound: invoked
144
+ // only at session-create time, after playbackPlanner is initialised.
145
+ getCachedMediaInfo: (params) => playbackPlanner.getCachedMediaInfo(params)
135
146
  });
136
147
  const playbackPlanner = createPlaybackPlanner({
137
148
  ffmpegBin,
@@ -0,0 +1,125 @@
1
+ /**
2
+ * @file ffmpeg banner parsers.
3
+ *
4
+ * Pure helpers that extract media info from the ffmpeg `-i` stderr banner
5
+ * (printed before any decoding): duration, start time, video resolution,
6
+ * frame rate and HDR transfer. Shared by the playback planner (which runs the
7
+ * codec probe) and the HLS session manager (which needs the same fields when
8
+ * building a session), so a session can reuse the planner's probe instead of
9
+ * running a second ffmpeg scan of the same input.
10
+ */
11
+
12
+ /**
13
+ * Extract the total duration in seconds from ffmpeg stderr output.
14
+ * Returns `null` if the duration line is absent or unparseable.
15
+ *
16
+ * @param {string} stderrText
17
+ * @returns {number | null}
18
+ */
19
+ export function parseFfmpegDurationSeconds(stderrText) {
20
+ if (typeof stderrText !== "string" || stderrText.length === 0) {
21
+ return null;
22
+ }
23
+ const match = stderrText.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/i);
24
+ if (!match) {
25
+ return null;
26
+ }
27
+ const hours = Number(match[1]);
28
+ const minutes = Number(match[2]);
29
+ const seconds = Number(match[3]);
30
+ if (![hours, minutes, seconds].every((item) => Number.isFinite(item))) {
31
+ return null;
32
+ }
33
+ return hours * 3600 + minutes * 60 + seconds;
34
+ }
35
+
36
+ /**
37
+ * Parse the container start time (seconds) from ffmpeg's "Duration: …, start:
38
+ * X, …" line. Many MKVs report a small non-zero start (e.g. 0.1 s); preserving
39
+ * it via `-copyts` would put a hole at the beginning, so we normalize it away.
40
+ * Returns 0 when absent.
41
+ *
42
+ * @param {string} stderrText
43
+ * @returns {number}
44
+ */
45
+ export function parseFfmpegStartTimeSeconds(stderrText) {
46
+ if (typeof stderrText !== "string" || stderrText.length === 0) {
47
+ return 0;
48
+ }
49
+ const match = stderrText.match(/Duration:[^\n]*?start:\s*(-?\d+(?:\.\d+)?)/i);
50
+ if (!match) {
51
+ return 0;
52
+ }
53
+ const value = Number(match[1]);
54
+ return Number.isFinite(value) ? value : 0;
55
+ }
56
+
57
+ /**
58
+ * Parse the source video resolution from ffmpeg's stderr (the "Stream … Video:
59
+ * … WxH" line). Returns `{ width: null, height: null }` when absent.
60
+ *
61
+ * @param {string} stderrText
62
+ * @returns {{ width: number | null, height: number | null }}
63
+ */
64
+ export function parseFfmpegVideoDimensions(stderrText) {
65
+ if (typeof stderrText !== "string" || stderrText.length === 0) {
66
+ return { width: null, height: null };
67
+ }
68
+ const match = stderrText.match(/Video:[^\n]*?\b(\d{2,5})x(\d{2,5})\b/i);
69
+ if (!match) {
70
+ return { width: null, height: null };
71
+ }
72
+ const width = Number(match[1]);
73
+ const height = Number(match[2]);
74
+ return {
75
+ width: Number.isFinite(width) && width > 0 ? width : null,
76
+ height: Number.isFinite(height) && height > 0 ? height : null
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Parse the source frame rate from the ffmpeg "Video:" line
82
+ * (e.g. "… 23.98 fps," / "… 25 fps,"). Returns null when absent.
83
+ *
84
+ * @param {string} stderrText
85
+ * @returns {number | null}
86
+ */
87
+ export function parseFfmpegVideoFps(stderrText) {
88
+ if (typeof stderrText !== "string" || stderrText.length === 0) {
89
+ return null;
90
+ }
91
+ const videoLine = stderrText.match(/Video:[^\n]*/i);
92
+ if (!videoLine) {
93
+ return null;
94
+ }
95
+ const match = videoLine[0].match(/([\d.]+)\s*fps/i);
96
+ if (!match) {
97
+ return null;
98
+ }
99
+ const value = Number(match[1]);
100
+ return Number.isFinite(value) && value > 0 ? value : null;
101
+ }
102
+
103
+ /**
104
+ * Detect an HDR / wide-gamut source from the ffmpeg "Video:" line's colour
105
+ * metadata. HDR is identified by the transfer function — `smpte2084` (PQ /
106
+ * HDR10) or `arib-std-b67` (HLG). Re-encoding such a source to 8-bit SDR
107
+ * without tone mapping produces a washed-out, desaturated picture, so this
108
+ * gates the tonemap filter chain.
109
+ *
110
+ * @param {string} stderrText
111
+ * @returns {boolean}
112
+ */
113
+ export function parseFfmpegHdr(stderrText) {
114
+ if (typeof stderrText !== "string" || stderrText.length === 0) {
115
+ return false;
116
+ }
117
+ const videoLine = stderrText.match(/Video:[^\n]*/i);
118
+ if (!videoLine) {
119
+ return false;
120
+ }
121
+ // ffmpeg prints the colour info in parentheses, e.g.
122
+ // "yuv420p10le(tv, bt2020nc/bt2020/smpte2084)". The transfer (last token) is
123
+ // the reliable HDR signal.
124
+ return /\b(smpte2084|arib-std-b67|arib_std_b67)\b/i.test(videoLine[0]);
125
+ }
@@ -22,6 +22,13 @@ import {
22
22
  TRANSCODE_FPS,
23
23
  chooseOutputFps
24
24
  } from "./hwaccel.js";
25
+ import {
26
+ parseFfmpegDurationSeconds,
27
+ parseFfmpegStartTimeSeconds,
28
+ parseFfmpegVideoDimensions,
29
+ parseFfmpegVideoFps,
30
+ parseFfmpegHdr
31
+ } from "./ffmpeg-banner.js";
25
32
 
26
33
  const PLAYLIST_FILE_NAME = "index.m3u8";
27
34
  const SEGMENT_FILE_NAME_PATTERN = /^segment-\d{5}\.ts$/;
@@ -206,51 +213,6 @@ function parseFfmpegTimestamp(value) {
206
213
  return hours * 3600 + minutes * 60 + seconds;
207
214
  }
208
215
 
209
- /**
210
- * Extract the total duration in seconds from ffmpeg stderr output.
211
- * Returns `null` if the duration line is absent or unparseable.
212
- *
213
- * @param {string} stderrText
214
- * @returns {number | null}
215
- */
216
- function parseFfmpegDurationSeconds(stderrText) {
217
- if (typeof stderrText !== "string" || stderrText.length === 0) {
218
- return null;
219
- }
220
- const match = stderrText.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/i);
221
- if (!match) {
222
- return null;
223
- }
224
- const hours = Number(match[1]);
225
- const minutes = Number(match[2]);
226
- const seconds = Number(match[3]);
227
- if (![hours, minutes, seconds].every((item) => Number.isFinite(item))) {
228
- return null;
229
- }
230
- return hours * 3600 + minutes * 60 + seconds;
231
- }
232
-
233
- /**
234
- * Parse the container start time (seconds) from ffmpeg's "Duration: …, start:
235
- * X, …" line. Many MKVs report a small non-zero start (e.g. 0.1 s); preserving
236
- * it via `-copyts` would put a hole at the beginning, so we normalize it away.
237
- * Returns 0 when absent.
238
- *
239
- * @param {string} stderrText
240
- * @returns {number}
241
- */
242
- function parseFfmpegStartTimeSeconds(stderrText) {
243
- if (typeof stderrText !== "string" || stderrText.length === 0) {
244
- return 0;
245
- }
246
- const match = stderrText.match(/Duration:[^\n]*?start:\s*(-?\d+(?:\.\d+)?)/i);
247
- if (!match) {
248
- return 0;
249
- }
250
- const value = Number(match[1]);
251
- return Number.isFinite(value) ? value : 0;
252
- }
253
-
254
216
  /**
255
217
  * Format a seconds value as `HH:MM:SS`, or `"n/a"` if not finite.
256
218
  *
@@ -302,52 +264,6 @@ function computeProgressMetrics(processedSeconds, totalSeconds, startPositionSec
302
264
  };
303
265
  }
304
266
 
305
- /**
306
- * Parse the source video resolution from ffmpeg's stderr (the "Stream … Video:
307
- * … WxH" line). Returns `{ width: null, height: null }` when absent.
308
- *
309
- * @param {string} stderrText
310
- * @returns {{ width: number | null, height: number | null }}
311
- */
312
- function parseFfmpegVideoDimensions(stderrText) {
313
- if (typeof stderrText !== "string" || stderrText.length === 0) {
314
- return { width: null, height: null };
315
- }
316
- const match = stderrText.match(/Video:[^\n]*?\b(\d{2,5})x(\d{2,5})\b/i);
317
- if (!match) {
318
- return { width: null, height: null };
319
- }
320
- const width = Number(match[1]);
321
- const height = Number(match[2]);
322
- return {
323
- width: Number.isFinite(width) && width > 0 ? width : null,
324
- height: Number.isFinite(height) && height > 0 ? height : null
325
- };
326
- }
327
-
328
- /**
329
- * Parse the source frame rate from the ffmpeg "Video:" line
330
- * (e.g. "… 23.98 fps," / "… 25 fps,"). Returns null when absent.
331
- *
332
- * @param {string} stderrText
333
- * @returns {number | null}
334
- */
335
- function parseFfmpegVideoFps(stderrText) {
336
- if (typeof stderrText !== "string" || stderrText.length === 0) {
337
- return null;
338
- }
339
- const videoLine = stderrText.match(/Video:[^\n]*/i);
340
- if (!videoLine) {
341
- return null;
342
- }
343
- const match = videoLine[0].match(/([\d.]+)\s*fps/i);
344
- if (!match) {
345
- return null;
346
- }
347
- const value = Number(match[1]);
348
- return Number.isFinite(value) && value > 0 ? value : null;
349
- }
350
-
351
267
  /**
352
268
  * Run a short ffmpeg probe to extract the total duration AND video resolution
353
269
  * of a stream from the container header. Both are printed almost immediately
@@ -356,7 +272,7 @@ function parseFfmpegVideoFps(stderrText) {
356
272
  *
357
273
  * @param {string} ffmpegBin - Path to the ffmpeg executable.
358
274
  * @param {string | URL} inputUrl - URL of the stream to probe.
359
- * @returns {Promise<{ durationSeconds: number | null, width: number | null, height: number | null }>}
275
+ * @returns {Promise<{ durationSeconds: number | null, width: number | null, height: number | null, fps: number | null, startTime: number, isHdr: boolean }>}
360
276
  */
361
277
  async function probeInputMediaInfo(ffmpegBin, inputUrl) {
362
278
  return new Promise((resolve) => {
@@ -377,7 +293,8 @@ async function probeInputMediaInfo(ffmpegBin, inputUrl) {
377
293
  width: dims.width,
378
294
  height: dims.height,
379
295
  fps: parseFfmpegVideoFps(stderr),
380
- startTime: parseFfmpegStartTimeSeconds(stderr)
296
+ startTime: parseFfmpegStartTimeSeconds(stderr),
297
+ isHdr: parseFfmpegHdr(stderr)
381
298
  });
382
299
  };
383
300
  const timeoutId = setTimeout(() => {
@@ -656,10 +573,15 @@ export class HlsSessionManager {
656
573
  startupWaitMs = DEFAULT_STARTUP_WAIT_MS,
657
574
  videoEncoder = null,
658
575
  softwarePresetBenchmark = null,
659
- getSourceStats = null
576
+ getSourceStats = null,
577
+ tonemapSupported = false,
578
+ getCachedMediaInfo = null
660
579
  }) {
661
580
  this.enabled = Boolean(enabled);
662
581
  this.ffmpegBin = ffmpegBin;
582
+ // Optional accessor for media info the playback planner already probed for
583
+ // (sourceKey, fileIndex), so session create can skip its own ffmpeg scan.
584
+ this.getCachedMediaInfo = typeof getCachedMediaInfo === "function" ? getCachedMediaInfo : null;
663
585
  // Optional async accessor for a source's live download stats, used by the
664
586
  // realtime budget to tell a CPU limit from a download-starved input:
665
587
  // (sourceKey, fileIndex) => Promise<{ downloadSpeed, fileLength, fileProgress } | null>.
@@ -672,6 +594,9 @@ export class HlsSessionManager {
672
594
  // used to pick the best preset per stream. Null when unavailable (hardware
673
595
  // encoder, or benchmark skipped/failed).
674
596
  this.softwarePresetBenchmark = Array.isArray(softwarePresetBenchmark) ? softwarePresetBenchmark : null;
597
+ // Whether this ffmpeg build can tone-map HDR→SDR (zscale + tonemap filters).
598
+ // Gates the tonemap chain for HDR sources on the software path.
599
+ this.tonemapSupported = Boolean(tonemapSupported);
675
600
  this.segmentDurationSec = segmentDurationSec;
676
601
  this.sessionTtlMs = sessionTtlMs;
677
602
  this.startupWaitMs = startupWaitMs;
@@ -775,21 +700,48 @@ export class HlsSessionManager {
775
700
  }
776
701
 
777
702
  const sessionId = randomUUID();
703
+ const createEntryMs = Date.now();
778
704
  const sessionDir = createSessionDirPath(sessionId);
779
705
  await mkdir(sessionDir, { recursive: true });
780
706
  const inputUrl = new URL("/stream", `${this.localBaseUrl}/`);
781
707
  inputUrl.searchParams.set("sourceKey", sourceKey);
782
708
  inputUrl.searchParams.set("fileIndex", String(fileIndex));
783
709
 
784
- // Probe the full media duration up-front so we can serve a complete VOD
785
- // playlist (terminated with #EXT-X-ENDLIST) immediately. This gives the
786
- // player the correct total duration and a fully seekable timeline before a
787
- // single segment has been transcoded.
788
- const mediaInfo = await probeInputMediaInfo(this.ffmpegBin, inputUrl.toString());
710
+ // Media info (duration/resolution/fps/startTime/HDR) up front, so we can
711
+ // serve a complete VOD playlist (#EXT-X-ENDLIST) with the correct total
712
+ // duration and a fully seekable timeline before a single segment exists.
713
+ // Reuse the planner's probe when it is available and complete — the plan
714
+ // request just ran the same ffmpeg scan over the same input. Fall back to
715
+ // a fresh probe otherwise (proxy restarted between plan and session, or a
716
+ // critical field is missing).
717
+ const mediaInfoStartMs = Date.now();
718
+ const cachedMediaInfo = this.getCachedMediaInfo?.({ sourceKey, fileIndex }) ?? null;
719
+ const cachedUsable =
720
+ cachedMediaInfo &&
721
+ Number.isFinite(cachedMediaInfo.durationSeconds) &&
722
+ cachedMediaInfo.durationSeconds > 0 &&
723
+ Number.isFinite(cachedMediaInfo.width) &&
724
+ cachedMediaInfo.width > 0 &&
725
+ Number.isFinite(cachedMediaInfo.height) &&
726
+ cachedMediaInfo.height > 0;
727
+ const mediaInfo = cachedUsable
728
+ ? cachedMediaInfo
729
+ : await probeInputMediaInfo(this.ffmpegBin, inputUrl.toString());
730
+ const mediaInfoMs = Date.now() - mediaInfoStartMs;
731
+ const mediaInfoSource = cachedUsable ? "cached" : "probed";
789
732
  const durationSeconds = mediaInfo.durationSeconds;
790
733
  const sourceWidth = mediaInfo.width;
791
734
  const sourceHeight = mediaInfo.height;
792
735
  const sourceStartTime = Number.isFinite(mediaInfo.startTime) ? mediaInfo.startTime : 0;
736
+ // Tone-map an HDR source to SDR only when re-encoding video on the software
737
+ // path and this ffmpeg has the filters. Hardware encoders keep their own
738
+ // (untone-mapped) path for now; when unavailable, HDR falls back to a plain
739
+ // 8-bit convert (washed-out but playable).
740
+ const applyTonemap =
741
+ transcodeVideo === true &&
742
+ mediaInfo.isHdr === true &&
743
+ this.tonemapSupported &&
744
+ this.videoEncoder?.kind === "software";
793
745
  // Output frame rate inherited from the source (integer, capped) so 25/30
794
746
  // fps content is not resampled to 24. Fixed-GOP encoders keep the fps↔GOP
795
747
  // relationship exact; time-based-keyframe encoders just use it as the rate.
@@ -809,11 +761,14 @@ export class HlsSessionManager {
809
761
  // uniform grid (current behaviour). Re-encoded video uses a uniform grid
810
762
  // (its fixed GOP makes the cuts land there).
811
763
  let keyframeTimes = null;
764
+ let keyframeMs = -1; // -1 = not run (skipped)
812
765
  if (hasDuration && !transcodeVideo) {
813
766
  // Short timeout: mp4 keyframes come from the moov index (fast); containers
814
767
  // that force a full packet scan time out and fall back to a uniform grid,
815
768
  // so this never adds more than ~6 s to session start.
769
+ const keyframeStartMs = Date.now();
816
770
  keyframeTimes = await probeVideoKeyframeTimes(this.ffmpegBin, inputUrl.toString(), 6_000);
771
+ keyframeMs = Date.now() - keyframeStartMs;
817
772
  if (!keyframeTimes) {
818
773
  logger.warn(
819
774
  `transcode ${sessionId}: keyframe probe unavailable; using uniform grid ` +
@@ -821,6 +776,10 @@ export class HlsSessionManager {
821
776
  );
822
777
  }
823
778
  }
779
+ logger.info(
780
+ `cold-start ${sessionId.slice(0, 8)}: media-info=${mediaInfoMs}ms (${mediaInfoSource}) ` +
781
+ `keyframes=${keyframeMs < 0 ? "skipped" : `${keyframeMs}ms`} create-total=${Date.now() - createEntryMs}ms`
782
+ );
824
783
  const segmentBoundaries = hasDuration
825
784
  ? computeSegmentBoundaries({
826
785
  transcodeVideo,
@@ -870,6 +829,10 @@ export class HlsSessionManager {
870
829
  lastAccessedAt: Date.now(),
871
830
  ffmpeg: null,
872
831
  lastError: "",
832
+ // Cold-start timing: entry timestamp + a once-guard so the first servable
833
+ // segment logs its latency exactly once.
834
+ createEntryMs,
835
+ firstSegmentLogged: false,
873
836
  consumers: new Set(consumerId ? [consumerId] : []),
874
837
  // Transcode parameters retained so the encode run can be restarted at an
875
838
  // arbitrary segment when the player seeks (server-side seeking).
@@ -888,6 +851,8 @@ export class HlsSessionManager {
888
851
  // software hosts, else the client target). 0 = keep source.
889
852
  encodeWidth,
890
853
  encodeHeight,
854
+ // Whether to insert the HDR→SDR tone-map chain (software path only).
855
+ applyTonemap,
891
856
  // Realtime-budget runtime state (software encoder only). The ladder is the
892
857
  // resolution rungs from the ceiling down; rungIndex is the current rung.
893
858
  // The monitor steps rungIndex down when the encoder is sustainedly
@@ -947,6 +912,9 @@ export class HlsSessionManager {
947
912
  // ceiling), manual (user-forced, budget off), or unset (keep source).
948
913
  `${transcodeVideo && encodeBudget ? `enc=${encodeWidth}x${encodeHeight}@${outputFps} budget=on ` : ""}` +
949
914
  `${transcodeVideo && forceManualQuality ? `enc=${encodeWidth || "src"}x${encodeHeight || "src"}@${outputFps} quality=manual ` : ""}` +
915
+ // HDR source and whether the tone-map chain was applied (vs washed-out
916
+ // fallback when the filters are missing or on a hardware encoder).
917
+ `${transcodeVideo && mediaInfo.isHdr ? `hdr=1 tonemap=${applyTonemap ? "on" : "off"} ` : ""}` +
950
918
  `${sourceStartTime ? `start=${sourceStartTime.toFixed(3)} ` : ""}` +
951
919
  `duration=${hasDuration ? formatSeconds(durationSeconds) : "unknown"} segments=${segmentCount}`
952
920
  );
@@ -1282,7 +1250,9 @@ export class HlsSessionManager {
1282
1250
  // use time-based keyframes just apply it as the frame rate.
1283
1251
  fps: session.outputFps,
1284
1252
  // Software-only; hardware descriptors ignore it.
1285
- preset: session.softwarePreset ?? undefined
1253
+ preset: session.softwarePreset ?? undefined,
1254
+ // HDR→SDR tone map (software path only; gated on filter availability).
1255
+ tonemap: session.applyTonemap === true
1286
1256
  })
1287
1257
  : ["-c:v", "copy"];
1288
1258
  const audioCodecArgs = session.transcodeAudio
@@ -1628,6 +1598,14 @@ export class HlsSessionManager {
1628
1598
  try {
1629
1599
  await access(filePath);
1630
1600
  const isPlaylist = fileName === PLAYLIST_FILE_NAME;
1601
+ // Cold-start: log the first servable SEGMENT of this session exactly once
1602
+ // — the time from session-create entry to a playable first segment.
1603
+ if (!isPlaylist && !session.firstSegmentLogged) {
1604
+ session.firstSegmentLogged = true;
1605
+ logger.info(
1606
+ `cold-start ${sessionId.slice(0, 8)}: first-segment ready +${Date.now() - session.createEntryMs}ms`
1607
+ );
1608
+ }
1631
1609
  return {
1632
1610
  kind: "file",
1633
1611
  stream: isPlaylist
@@ -27,6 +27,14 @@ import path from "node:path";
27
27
 
28
28
  const SOFTWARE_PRESET = "ultrafast";
29
29
  const SOFTWARE_CRF = "24";
30
+ // HDR→SDR tone-map chain (software). Converts a BT.2020 PQ/HLG source to BT.709
31
+ // 8-bit SDR so the re-encode is not washed-out/desaturated. Requires the
32
+ // `zscale` (libzimg) and `tonemap` filters — gated by detectTonemapSupport;
33
+ // when unavailable the encode falls back to a plain 8-bit convert (no tonemap).
34
+ // npl=100 targets ~100-nit SDR; hable is a well-behaved tone-mapping operator.
35
+ const TONEMAP_FILTER_CHAIN =
36
+ "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709," +
37
+ "tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p";
30
38
  // Default output frame rate when the source rate is unknown, and the rate used
31
39
  // by the synthetic startup test-encode / preset benchmark. The real encode
32
40
  // inherits the source rate (rounded to an integer, capped) — see
@@ -108,20 +116,24 @@ export function softwareDescriptor() {
108
116
  kind: "software",
109
117
  device: null,
110
118
  inputArgs: [],
111
- buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, preset, fps }) {
119
+ buildVideoArgs({ targetWidth, targetHeight, segmentDurationSec, preset, fps, tonemap }) {
112
120
  const { w, h } = safeDimensions(targetWidth, targetHeight);
113
121
  const chosenPreset = typeof preset === "string" && preset.length > 0 ? preset : SOFTWARE_PRESET;
114
122
  // Output frame rate: inherited from the source (rounded/capped) by the
115
123
  // session manager, TRANSCODE_FPS by default. MUST be an integer and MUST
116
124
  // equal the value used in the GOP below, or keyframes drift off the grid.
117
125
  const outFps = Number.isInteger(fps) && fps > 0 ? fps : TRANSCODE_FPS;
126
+ // HDR→SDR tone-map, inserted AFTER the downscale so it runs on the smaller
127
+ // frame (cheaper on ARM); only when the source is HDR and the filters are
128
+ // present (session manager gates on both).
129
+ const tonemapPart = tonemap === true ? `,${TONEMAP_FILTER_CHAIN}` : "";
118
130
  return [
119
131
  // Never upscale: cap the target box to the source size (min with
120
132
  // iw/ih), so a small source (e.g. 720x400) is encoded at its own
121
133
  // resolution instead of being scaled up to the viewport — far fewer
122
134
  // pixels, much faster on ARM. force_original_aspect_ratio keeps aspect.
123
135
  "-vf",
124
- `scale='min(${w},iw)':'min(${h},ih)':force_original_aspect_ratio=decrease:force_divisible_by=2,fps=${outFps}`,
136
+ `scale='min(${w},iw)':'min(${h},ih)':force_original_aspect_ratio=decrease:force_divisible_by=2${tonemapPart},fps=${outFps}`,
125
137
  "-c:v", "libx264",
126
138
  // Preset is chosen per stream by the session manager from the startup
127
139
  // benchmark (highest quality that still encodes the source resolution
@@ -496,6 +508,33 @@ export async function detectVideoEncoder({ ffmpegBin, logger, segmentDurationSec
496
508
  return software;
497
509
  }
498
510
 
511
+ /**
512
+ * Detect whether this ffmpeg build has the filters needed for the HDR→SDR
513
+ * tone-map chain (`zscale`, from libzimg, and `tonemap`). Both are required;
514
+ * when either is missing, HDR sources are re-encoded without tone mapping
515
+ * (washed-out but playable). Always resolves.
516
+ *
517
+ * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void } }} options
518
+ * @returns {Promise<boolean>}
519
+ */
520
+ export async function detectTonemapSupport({ ffmpegBin, logger }) {
521
+ const log = logger ?? { info: () => {}, warn: () => {} };
522
+ const { code, stdout } = await runFfmpeg(ffmpegBin, ["-hide_banner", "-filters"], 10000);
523
+ if (code !== 0) {
524
+ log.warn("hwaccel: could not list ffmpeg filters; HDR tone mapping disabled");
525
+ return false;
526
+ }
527
+ // `-filters` prints one filter per line: "... zscale ...", "... tonemap ...".
528
+ const hasZscale = /\bzscale\b/.test(stdout);
529
+ const hasTonemap = /\btonemap\b/.test(stdout);
530
+ const supported = hasZscale && hasTonemap;
531
+ log.info(
532
+ `hwaccel: HDR tone mapping ${supported ? "available" : "unavailable"} ` +
533
+ `(zscale=${hasZscale} tonemap=${hasTonemap})`
534
+ );
535
+ return supported;
536
+ }
537
+
499
538
  /**
500
539
  * Benchmark software libx264 presets on this host. Encodes a short synthetic
501
540
  * clip at a fixed reference resolution with each preset and measures encoder
@@ -7,10 +7,23 @@
7
7
  */
8
8
 
9
9
  import { spawn } from "node:child_process";
10
+ import {
11
+ parseFfmpegDurationSeconds,
12
+ parseFfmpegStartTimeSeconds,
13
+ parseFfmpegVideoDimensions,
14
+ parseFfmpegVideoFps,
15
+ parseFfmpegHdr
16
+ } from "./ffmpeg-banner.js";
10
17
 
11
18
  /** Audio codecs that browsers can decode natively without transcoding. */
12
19
  const DIRECT_AUDIO_CODECS = new Set(["aac", "mp3", "opus", "vorbis", "flac"]);
13
20
 
21
+ // Once the plan probe succeeds, warm the START of the file body so the
22
+ // transcode session's ffmpeg reads hit downloaded data instead of paying
23
+ // piece latency at encode time (the edge prefetch only covers head+tail for
24
+ // the codec probe). ~16 MB ≈ the first segments of typical media.
25
+ const BODY_PREFETCH_BYTES = 16 * 1024 * 1024;
26
+
14
27
  /** Subtitle codecs that can be converted to WebVTT (text-based). */
15
28
  const TEXT_SUBTITLE_CODECS = new Set(["subrip", "srt", "ass", "ssa", "webvtt", "vtt", "mov_text", "text"]);
16
29
 
@@ -134,7 +147,9 @@ function parseStreamCodecs(ffmpegOutput) {
134
147
  * @param {string} options.inputUrl
135
148
  * @param {string} [options.userAgent=""]
136
149
  * @param {number} [options.timeoutMs=8000]
137
- * @returns {Promise<{ audioCodec: string, videoCodec: string }>}
150
+ * @returns {Promise<{ audioCodec: string, videoCodec: string, container: string, durationSeconds: number, videoWidth: number, videoHeight: number, audioTracks: object[], subtitleTracks: object[], stderr: string }>}
151
+ * Parsed banner fields plus the raw `stderr`, so the caller can derive the
152
+ * full media info (fps/startTime/HDR) without a second ffmpeg scan.
138
153
  */
139
154
  function probeStreamCodecs({ ffmpegBin, inputUrl, userAgent = "", timeoutMs = 8_000 }) {
140
155
  return new Promise((resolve) => {
@@ -167,7 +182,7 @@ function probeStreamCodecs({ ffmpegBin, inputUrl, userAgent = "", timeoutMs = 8_
167
182
  if (!ffmpeg.killed) {
168
183
  ffmpeg.kill("SIGTERM");
169
184
  }
170
- finish(parseStreamCodecs(stderr));
185
+ finish({ ...parseStreamCodecs(stderr), stderr });
171
186
  }, timeoutMs);
172
187
 
173
188
  ffmpeg.stderr.on("data", (chunk) => {
@@ -176,12 +191,12 @@ function probeStreamCodecs({ ffmpegBin, inputUrl, userAgent = "", timeoutMs = 8_
176
191
 
177
192
  ffmpeg.on("error", () => {
178
193
  clearTimeout(timeoutId);
179
- finish({ audioCodec: "", videoCodec: "" });
194
+ finish({ audioCodec: "", videoCodec: "", stderr: "" });
180
195
  });
181
196
 
182
197
  ffmpeg.on("exit", () => {
183
198
  clearTimeout(timeoutId);
184
- finish(parseStreamCodecs(stderr));
199
+ finish({ ...parseStreamCodecs(stderr), stderr });
185
200
  });
186
201
  });
187
202
  }
@@ -251,8 +266,26 @@ export function createPlaybackPlanner({
251
266
  }) {
252
267
  /** @type {Map<string, PlaybackPlan>} */
253
268
  const cache = new Map();
269
+ /**
270
+ * Full media info parsed from the SAME probe that produced the plan, cached
271
+ * under the same key so a transcode session can reuse it instead of running
272
+ * a second ffmpeg scan. Only set when the plan is cached (codecs detected).
273
+ * @type {Map<string, { durationSeconds: number | null, width: number | null, height: number | null, fps: number | null, startTime: number, isHdr: boolean }>}
274
+ */
275
+ const mediaInfoCache = new Map();
254
276
 
255
277
  return {
278
+ /**
279
+ * Media info the planner already probed for this file, or `null`. Lets the
280
+ * HLS session manager skip its own duplicate `probeInputMediaInfo` scan.
281
+ *
282
+ * @param {{ sourceKey: string, fileIndex: number }} params
283
+ * @returns {{ durationSeconds: number | null, width: number | null, height: number | null, fps: number | null, startTime: number, isHdr: boolean } | null}
284
+ */
285
+ getCachedMediaInfo({ sourceKey, fileIndex }) {
286
+ return mediaInfoCache.get(`${sourceKey}:${fileIndex}`) ?? null;
287
+ },
288
+
256
289
  /**
257
290
  * Return the playback plan for the given source file.
258
291
  * Throws with `error.code === "SOURCE_NOT_FOUND"` or `"FILE_NOT_FOUND"`
@@ -364,6 +397,26 @@ export function createPlaybackPlanner({
364
397
  // prioritised by the prefetch above).
365
398
  if (codecsDetected) {
366
399
  cache.set(cacheKey, plan);
400
+ // Cache the full media info from THIS probe's banner (same helpers the
401
+ // session manager uses) so createSession can skip its own probe.
402
+ const dims = parseFfmpegVideoDimensions(probe.stderr);
403
+ mediaInfoCache.set(cacheKey, {
404
+ durationSeconds: parseFfmpegDurationSeconds(probe.stderr),
405
+ width: dims.width,
406
+ height: dims.height,
407
+ fps: parseFfmpegVideoFps(probe.stderr),
408
+ startTime: parseFfmpegStartTimeSeconds(probe.stderr),
409
+ isHdr: parseFfmpegHdr(probe.stderr)
410
+ });
411
+ // Warm the file-body start for the transcode session that follows.
412
+ // Fire-and-forget: never delays the plan response.
413
+ void torrentPool
414
+ .prefetchFileEdges(torrent, fileIndex, {
415
+ headBytes: BODY_PREFETCH_BYTES,
416
+ tailBytes: 0,
417
+ timeoutMs: 60_000
418
+ })
419
+ .catch(() => {});
367
420
  return plan;
368
421
  }
369
422
  return { ...plan, pending: true };