@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 +8 -0
- package/openspec/changes/cold-start/design.md +98 -0
- package/openspec/changes/cold-start/proposal.md +62 -0
- package/openspec/changes/cold-start/specs/cold-start/spec.md +47 -0
- package/openspec/changes/cold-start/tasks.md +48 -0
- package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +20 -0
- package/openspec/changes/transcode-quality/tasks.md +10 -4
- package/package.json +1 -1
- package/server.js +13 -2
- package/services/ffmpeg-banner.js +125 -0
- package/services/hls-session-manager.js +78 -100
- package/services/hwaccel.js +41 -2
- package/services/playback-planner.js +57 -4
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
|
|
38
|
-
|
|
39
|
-
- [
|
|
40
|
-
|
|
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
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
|
-
//
|
|
785
|
-
// playlist (
|
|
786
|
-
//
|
|
787
|
-
//
|
|
788
|
-
|
|
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
|
package/services/hwaccel.js
CHANGED
|
@@ -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 };
|